相信許多讀者對福爾摩斯的探案過程很感興趣,《血字的研究》和《四簽名》都提到了演繹法,福爾摩斯認為應從事實的結果找出原因,經過精密的分析和推斷來破案;
程序員的許多工作也類似於一個偵探,尤其當我們調試程序時,如何在數目眾多的代碼中找到邏輯錯誤,是一門必修的功課,程序中有許多我們已經驗證為正確的事實,也有很多假定為正確的前提,如果程序有Bug,那麼無論這些假定正確的東西看起來是多麼可靠,都值得我們懷疑,然後通過驗證這些判斷逐步縮小范圍,剿滅Bugs。
本文通過一個對串口接收處理子程序的調試過程,向讀者展示調試思路,同時對在下位機協議定義存在嚴重缺陷時如何使自己的程序具有良好的防錯能力給出了解決方法。本例雖短小,但較典型,發生的錯誤讓人意想不到。
在本人寫的一個小的工控系統中,通過串口從下位機讀入許多點的GPS定位信息,下位機軟件設計者定義了以下的回應格式(上位機命令略):
1、 讀數據應答信令:
表示在上位機開始讀時,下位機先給出的一個應答(包含點數,時間范圍等)
格式:20H,1DH,...CHK1,CHK2,0FH (25bytes)
2、清除數據應答信令:
表示下位機在接收到上位機發出的清除數據命令後的應答;
格式:同讀數據應答信令
3、數據上傳信令:
上位機要求下位機發送所有采集點後,下位機發送的GPS數據(時間、位置等);
格式:20H,1EH,...CHK1[1],CHK2[1],0FH (25bytes)
4、數據上傳結束信令:
當3完成時報告上位機。
格式:同讀數據應答信令
這裡我想指出協議存在的缺陷,以使讀者明白它即將對我所論述的處理程序產生的不良影響,這裡說明:以上1、2、4條所說的回應信令其實是沒有的,其實質是:當下位機空閒時,就以一秒為間隔不斷發送這樣的數據,包含記錄儀中的點數、采樣點所在的起始時間與結束時間、巡查員內碼等信息。
const
LeadLength=2; // 命令頭的長度
我定義了以下的結構來保存串口的狀態,其中Data為串口接收到的一行數據(已經過初步處理<處理過程略>,形式如1、或3、的格式):
TCommData=record
CommOpened :Boolean;
DataFileOpened :Boolean;
GotCommHeader :Boolean;
HaveData:Boolean;
LeadData:array[1..LeadLength] of Byte;
LeadTurn:integer; // 讀頭字節存在LeadData中的位置,輪流讀入一個字節,
DataBytes:integer; // 已經讀入到Data中的字節數。
Data :array[1..25] of Byte;
UserID: integer; // 用戶內碼 誰在巡查
RoadID: integer; // 在哪條路巡查
end;
上位機當前所執行的功能的集合:
TCommFuncNo=(funcWait, // 上位機的命令。
funcReadRoad,
funcReadData,
funcClear,
funcFinish);
var
currFunc : TCommFuncNo;
現在,在串口接收子程序中有如下判斷:
{解算一行數據的代碼,略;}
case currFunc of
FuncWait:
begin
{略}
end;
FuncReadRoad: // 讀道路數據, 這兩個功能是在上位機中加以區分的;
FuncReadData: // 讀巡查數據
begin
SaveDataToFile(...); // 保存已經采得的數據到文件。
if (CommData.Data[2]=$1E) // 一條GPS數據,見協議3
then begin
{略}
end
else if (CommData.Data[2]=$1D)
then begin
if (CurrFunc=FuncReadRoad)
then ProcessA // 寫入道路數據庫
else ProcessB; // 寫入巡查數據庫
CurrFunc:=FuncWait; // 數據發送完畢,進入等待狀態。
end;
end;
end;
以上進入讀數據功能後,當(CommData.Data[2]=$1D)成立時,按照協議中“4、數據上傳結束信令”的規定,應當是讀數據結束應答信號,則存盤,然後處理並寫入數據庫。
調試時發現:
問題A.從未看到存盤文件有改變,數據庫表也沒有新記錄
a. 跟蹤發現,數據是采集到了的,這是不可否認的事實;這就表明:采集到數據後上位機的當前功能不為FuncReadRoad 或 FuncReadData;而另一個事實是上位機發送讀數命令時,已經把當前功能設為了這兩個值之一。那麼可推斷出:當前功能又被設置成為其它值;
b. 跟蹤還發現,SaveDataToFile(...);確實被調用過。
綜合以上兩點,作出判斷:采集到數據之前,已經存過盤,但由於當時沒數據,因此數據文件沒變化;采集到數據後,當前功能已經改變。
問題是程序為什麼“急於”存盤?查找所有改變當前功能的代碼,極可能是采集到數據之前已經執行過CurrFunc:=FuncWait;也就是在采集到數據之前,if (CommData.Data[2]=$1D) 就曾經成立。因此可給出解釋:上位發送傳數命令後並設CurrFunc為FuncReadRoad 或 FuncReadData,下位機並未反應過來,並沒如我們所希望的立即發送數據,而是如常一樣在空閒時發送1、中命令,但下位機此時把它判斷成4、數據上傳結束信令,其後采集到數據時因不處在讀數據狀態,故對此不予理睬,從而永遠看不到存盤操作;這裡就暴露出了協議的問題。
問題的症結在於協議沒有區分不同的狀態,把不同的狀態混為一談,下位機程序倒是簡單了,雖然可行,但在描述上不應如文初所示。
解決辦法:給CommData加上HaveData成員,然後在判斷中以此為條件,
調整語句CurrFunc:=FuncWait;的位置,即當下位機尚未轉換功能時,沒有數據,則上位機不設置當前功能為等待。
else if (CommData.Data[2]=$1D)
then begin
if CommData.HaveData // 新增條件,在解算數據中設置
then begin
if (CurrFunc=FuncReadRoad)
then ProcessA // 寫入道路數據庫
else ProcessB; // 寫入巡查數據庫
CurrFunc:=FuncWait; // 調整這行的位置
end;
end;
修改後,發現新的問題
問題B.程序不響應用戶
發現程序不響應,強行終止,發現數據庫中寫入很多的記錄。
分析:程序在執行某個循環不能跳出,但查看程序並無循環。
跟蹤,發現程序不斷進重復ProcessA或ProcessB的操作。很令人驚奇。思索良久,程序的前提是(CommData.Data[2]=$1D),而數據發送結束的空閒狀態就是不斷地發送此語句;但查看源代碼,在ProcessA及ProcessB之後把功能設為了等待狀態的,照理不會進入數據處理過程,但此數據處理過程確實不斷地出現,因而程序一定未進入等待狀態,程序一定是在設置CurrFunc:=FuncWait之前又進入了數據處理過程,那麼事情就豁然開朗了:在數據發送結束後,收到第一個結束信號,便進入數據處理過程,並准備設置狀態為等待,在數據處理尚未完成時,又收到一條結束命令(空閒時每秒一次,不得不再次詛咒糟糕的協議!),程序繼續進入數據處理過程,由此,總在未處理完畢都再次進入處理過程。串口事件導致不斷地重復處理(空閒狀態下位機每隔一秒發送一次,我測試我的處理過程需要三四秒才能完成)。
解決辦法:明白怎麼回事,解決就簡單了,先設置當前狀態,再處理數據,相應程序改為:
else if (CommData.Data[2]=$1D)
then begin
if CommData.HaveData
then begin
if (CurrFunc=FuncReadRoad)
then begin
CurrFunc:=FuncWait; // 先設置等待狀態。
ProcessA // 寫入道路數據庫
end
else begin
CurrFunc:=FuncWait; // 先設置等待狀態。
ProcessB; // 寫入巡查數據庫
end;
end;
end;
經驗:事件的不斷發生使程序進入了循環!
如果只是界面的顯示,也可以用防止重入的方法解決:
if InMyProcess then Exit;
InMyProcess:=True;
MyProcess; // 處理過程。
InMyProcess:=False; // 最後設置為假,可以再次進入。
這與臨界值的使用原理應當是一樣的,請參考 TCriticalSection,記得使用SyncObJS 單元。
後記--感悟:
1、程序有問題時,首先去檢查你自己的代碼,而不要懷疑操作系統或其它,只有在較充分地排除自己程序中的錯誤後才去懷疑其它,因為操作系統的問題一般來說遠少於程序的錯誤;
2、在設計程序時,要有清晰的思路,不相干的代碼要分離,這樣有利於逐步求精;
3、認真的態度,不要想當然。當養成了這種習慣後,你不但能很快找出他人程序的毛病,也同樣能快速找到自己程序中的Bug根源;
4、懷疑一切,你才能獲得真知。但要注意不要“打倒一切”,一定要相信已經存在的事實;
5、最好能在事先能預想到可能出錯的地方,要做容錯處理;對於不能用代碼防止的用戶操作(比如刪除文件)心裡應有底,這樣當用戶的一些操作引起系統不正常時,即使不看源程序也能給予有效的技術支持。