相信许多读者对福尔摩斯的探案过程很感兴趣,《血字的研究》和《四签名》都提到了演绎法,福尔摩斯认为应从事实的结果找出原因,经过精密的分析和推断来破案;
程序员的许多工作也类似于一个侦探,尤其当我们调试程序时,如何在数目众多的代码中找到逻辑错误,是一门必修的功课,程序中有许多我们已经验证为正确的事实,也有很多假定为正确的前提,如果程序有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、最好能在事先能预想到可能出错的地方,要做容错处理;对于不能用代码防止的用户操作(比如删除文件)心里应有底,这样当用户的一些操作引起系统不正常时,即使不看源程序也能给予有效的技术支持。