程式碼的追蹤除錯
我之前说过,找寻程序错误最好的方法就是执行程序,然后盯着它跑,无论是用眼睛或是用些自动化测试工具,如除错检查宏跟子系统整合度检查。不过除错检查宏与子系统检查虽然都有用,它们却不能帮你避开你没想到的错误;在这方面,它们跟家里的保全系统没两样。
如果你在家里的门窗都装上了铁条,而小偷还是能从楼顶或地下室进来,那保全系统的警报当然就不会响了。如果你在录像机、音响跟其它你认为小偷会偷的东西上装了警报器,而小偷却搬走了贵重珠宝组合,那当他偷你东西时,你根本不会察觉到。相似的,如果你对函式的参数使用除错检查宏进行核对,而臭虫是来自你的逻辑错误上的,那你设下的除错检查宏对这样的错误是根本不会有反应的。
理论上,你可以在程序中设下非常多除错检查宏跟除错程序代码,让臭虫很快就会被这些警报系统逮个正着。理论,只是理论。现实中,在大部分程序项目里加入很多除错程序代码可能只是浪费时间,而且你还是得预先判断好程序的哪边可能有错误。
与其加入一大堆除错检查宏跟除错检查,一个更好的办法是主动在错误可能发生时主动去找出错误来。不过,怎么做呢?你不是已经改好程序了吗?如果在你改过程序后,错误才出现,那你要明白,程序不会自己出错的-一定是你改了某些东西,才让它出现错误。
写出零错误程序代码的最好办法就是主动追踪所有新的或改过的程序代码,检查它们的执行状态,看看是不是每个动作都如你预期的般执行。
在本章中,我要谈的不只是为何追踪程序执行是件重要的事,还会谈到如何有效的做好这件工作。
增进对程序的自信
最近,我在替微软内部的麦金塔程序开发系统发展一个新功能。当我开始测试这个新功能时,我找到一只臭虫,追着它,跑到了另一名程序员写的新程序代码中。这只虫让我迷惑的地方是它跟其它程序员写的功能如此相关,我不知道别人写的功能大概怎样子才算是正常运作的状态。我去找那位先生谈了一下。
" 我想我在你刚完成的程序中找到一个错误 " ,我说。 " 你有时间简单看一下吗? "
他将程序加载编辑器中,然后我告诉他我认为错误在哪里。当他看了那边的程序,他吓了一跳。
" 你说对了;这程序是错的。我不知道为什么测试时没抓到这个问题。 "
我也想到同个问题。 " 你是怎样测试这段程序的? "
他解释了一下,那样作应该能抓到这只虫才对,这让我们都搞迷糊了。 " 在这函式中放个断点,追踪一下程序的运作,看看到底发生了什么事吧 " ,我建议。
我们试了一下,却发现当我们设好了断点并按下执行键,整个测试跑得好好的;完全没碰到我们设下的断点。这就是为何那位先生没找到错误的原因。没花多久,我们就找到为何测试动作没碰到那个断点-在整个呼叫链中,一个上头好几层处的函式里头被最佳化编译器跳掉了些东西,使一些非必要的呼叫完全不会被执行到,所以也跳过了后来加上的新程序代码。
你还记得我在第一章提到过黑箱测试的问题吗?我说测试人员对程序丢入输入资料,再从输出结果判断程序有没有正确运作-如果输出正确,就当作程序正确执行。这样做法带来的问题是,你没办法判断在输入与输出之间发生了什么事。上头那位程序员漏掉了那个错误,只因为他将程序当成黑箱子来测试;他放入一些输入资料,得到了正确结果,然后就判断程序是正确的。身为一名程序写作者,他没使用他能用到的额外工具来进行测试。
程序写作者们,不同于大部分测试者,有着在程序中设定断点的能力,他们可以追踪程序的执行,观看输入资料如何产生输出结果。奇怪的是,只有少部分程序员有测试程序时追踪程序执行过程的习惯;许多人根本不会在程序中设定断点来看看程序有没有正确执行。
回到我在本章开头所提到的:捕捉错误最好的办法就是在你写程序或改程序时就把它们找出来。而程序员测试程序最好的办法是什么?就是追踪程序执行,拿个工具来看看中间过程的结果。我不认识太多写出来的程序都没错误的程序员,但是我认识的那些人都有从头到尾追踪自己程序执行结果的习惯。
作为一个项目领导人,我要许多程序员在测试时追踪他们自己写的程序。几乎每个人都吓到了-不是因为他们不同意这个做法,而是因为这个做法听起来很耗时。他们已经很难跟得上程序发展的时间表了-他们要去哪里找时间来追踪自己的程序?
幸运的,他们的直觉反应是错的。是的,追踪程序需要花时间,可是只占写程序时间中的一小部份。想想看,当你写了个新功能,你必须设计这功能被使用的方式,想出其中的算法而且实际将它写成原始码。那在你第一次执行这程序时,设一下断点,在每一行原始码上按一下追踪执行键要花多少时间?不多,特别当你已经将它养成习惯时。就好象学开一部手排车-刚开始似乎不可能,但在几天的练习后,你甚至不会注意到排档的切换过程;你自然而然就会切换排档了。同样的,只要你养成了追踪程序执行的习惯,你自然而然就会设下断点并追踪程序执行过程。然后你就抓到以前抓不到的错误了。
不要等到有错误发生了才来追踪程序代码的执行。
程序中的分岔
当然有些技巧可以让你更有效的追踪程序的执行。毕竟虎头蛇尾的追踪程序没什么好处。例如,每个程序员都知道错误处理程序经常会出错,因为那部分很少被测试到,而错误就会留在那里,除非你努力测试那些程序。你可以制造一些产生错误状况的测试码,也可以在你追踪程序执行时仿真错误的发生。仿真错误发生通常需要花更少时间,看看下面这段程序段落的例子:
pbBlock = (byte *)malloc(32);
if (pbBlock == NULL)
{
处理错误状况;
}
一般说来,当你追踪执行这段程序时, malloc 会配置一块 32 字节大小的内存,并传回一个非 NULL 的指针,让这程序跳过错误处理码。所以我们可以在第二次追踪执行这段程序时,利用除错程序在执行过底下这行后,将 pbBlock 的值设成 NULL 指针:
pbBlock = (byte a)malloc(32);
malloc 会配置内存,但如果我们把 pbBlock 设成了 NULL 指针,对你的程序来说,就好象内存配置失败了一样,让你能够追踪错误处理程序的执行状态。(给追根究底的读者们:是的,你改变 pbBlock 的内容时会失去指向 malloc 配置的内存的指针,不过这里只是在进行一个测试而已。)
除了追踪错误状况的处理情形,你也应该看看程序中每一种状况的执行流程。会出现多个执行流程的常见例子就是 if 跟 switch 叙述,不过还有别种: && , || 跟 ?: 运算子也都有两条执行路线。
这么做的想法是追踪程序中每一条执行路线至少一次,以确保程序的正确无误。在你完成这项工作后,你可以更加确信自己的程序是没有错误的-至少你知道这个程序在某些输入状态下会正常运作。而如果你挑选的测试样本够好,追踪执行程序运作所能得到的经验将会让你难以忘怀。
追踪执行每一条执行路线
资料流-程序的命脉
在 第二章 中,我写了个快速的 memset 子程序,底下是那子程序的第一版(略掉了除错检查宏):
void *memset(void *pv, byte b, size_t size)
{
byte *pb = (byte a)pv;
if (size >= sizeThreshold)
{
unsigned long l;
/* 以四个字节拼组成一个常整数。*/
l = (b<<24) ? (b<<16) ? (b<<8) ? b;
pb = (byte a)longfill((long a)pb, l, size / 4);
size = size % 4;
}
while (size- > 0)
*pb++ = b;
return (pv);
}
这程序看起来正确,但是有个潜伏的问题。在我写好这程序后,我把它在一个现成的程序中使用。没碰到任何问题-这程序运作得好好的。但为了确定这子程序真的动作了,我在子程序中设了断点,然后重新执行那个程序。当除错程序把控制权交给我时,我看了一下函式参数: *pv 指针看起来是合法的, size 大小参数也对,而要填入的字节 b 是 0. 我讨厌用 0 测试程序代码,因为这样子不好找出不同类型的错误,所以我立刻将参数 b 改成了一个奇怪的值, 0x4E 。
我这次的追踪状态是 size 小于 sizeThreshold 的情形,这路线执行得好好的。接下来我让 size 大于或等于 sizeThreshold. 我不预期会碰到什么问题,可是当我追踪到这一行时,
l = (b<<24) | (b<<16) | (b<<8) | b;
我看到 | 被设成了 0x00004E4E ,而不是我要的 0x4E4E4E4E. 在查看这段程序的汇编语言列表后,我找到了问题所在-这解释了为何出现了这样的错误,程序还是动作得好好的。
如你所见,我用的编译器的整数宽度是 16 位的,如果你用 16 位宽度的整数进行如 b<<24 的运算,会得到什么结果?你会得到 0. 那 b<<16 呢?也是出现 0. 这子程序的逻辑没什么不对的,但是实作上有缺陷。这子程序在那个现成的程序中没出错,只是因为那程序使用 memset 将内存填成 0 ,而 0<<24 还是 0 ,所以得到的结果没出错-虽然过程错了。
我能立刻逮到这个错误,因为我花了额外的时间来追踪这个程序代码的执行,而不是写好它就丢着它不管了。是的,这问题足够严重到最后总有人会注意到他,不过记住我们的目标是尽可能愈早抓出错误来。追踪程序执行有助于达到这个目标。
追踪程序执行的真正威力在于你可以看到资料在你的函式中流通。只要你注意着资料流的去向,你想你能逮到底下多少种错误呢?
溢位跟借位错误
资料转换错误
错字
NULL 指针错误
使用不正确内存的错误
把 == 打成 = 的指派错误
运算子顺序错误
逻辑错误
你会抓不到这些错误吗?经由注意资料流的去向,你可以从另一个不一样角度来看待你的程序。你也许不会注意到程序中的这个指派错误:
if (ch = t')
ExpandTab();
可是当你循着资料流追踪到这里时,你可以轻易看到 ch 被乱改了。
追踪程序执行时,注意资料流的去向。
你错过了什么?
使用原始码除错器的一个问题是逐行追踪原始码可能会跳过很重要的东西。假设在底下的程序中,我们把 && 错打成了 & :
/* 如果符号存在,而且有个文字名称,
* 就释放那名称占用的内存。
*/
if (psym != NULL & psym->strName != NULL)
{
FreeMemory(psym->strName);
psym->strName = NULL;
}
程序代码合乎语法,可是写错了。那个 if 叙述的用途是防止 psym 的内容为 NULL 指针时被用来指向一个 symbol 结构的 strName 字段,但这程序完全没作到这点。它永远会拿 psym 指向一个 symbol 结构的 strName 字段,不管 psym 是不是 NULL.
如果你用原始码除错器来追踪程序,并在追踪到 if 叙述时按下逐行追踪键,除错器将会把整个 if 的条件测试当成一个完整的动作来看待。可是要找到问题核心的话,你得注意到条件式的右边即使左边结果是 FALSE 也会被执行到。(如果你够幸运,你的计算机会在你使用 NULL 指针时当掉,不过许多桌上型计算机都不会,至少现在还不会。)
译按:
现在的情况好多啰,不管是 Mac OS 或 Windows 3.1/95/98/NT ,使用 NULL 指针进行内存存取会立刻造成内存保护例外的触发的。注意到原作者写这本书时,大部分桌上型计算机的程序员都还在用不支持内存保护的操作系统跟十六位的程序编译器。事实上,今天的三十二位编译器已经不存在如前述的 b<<24 的溢位问题了,理由很简单,整数资料型态的预设宽度已经变成三十二位了。不过如果读者试着以短整数或字符进行类似的位位移运算,或者进行超出三十二位位移运算,结果还是会变成 00 的。
记住我稍早说过的: && , || 跟 ?: 运算子都有两条执行路线,要抓错误,你得两边都追踪一遍。使用原始码除错器的问题在于逐行追踪容易一次跳过这些运算子的两个执行路线。有两种办法可以克服这个问题。
首先,你要追踪有着 && 跟 || 运算子的复合叙述时,检查一下要被执行过去的那个叙述写得对不对。接下来,用除错器的计算评估功能检查一下表达式每一边的结果是不是你要的。这样可以帮你找到整个计算式评估结果正确而过程不对的情形。像是,如果你认为一个 || 表达式左边的结果是 TRUE 而右边是 FALSE ,却出现相反的结果,那这表达式一定算错了,虽然结果是对的。检查一下表达式的各个部分,将能让你警觉这类问题的存在。
另一个更彻底的做法是将复合叙述跟 ?: 运算子列成汇编语言来看。是的,这会花更多功夫,但是对于关键的程序代码来说,真正追踪过一遍来看看程序跑得对不对,是重要的。当你在 C 语言的程序开发环境中追踪程序时,只要你习惯了这么作,将程序摊开成汇编语言来追踪是很快的;多练习几次就会了。
原始码除错器可能隐藏执行细节。
将关键的程序代码摊开成汇编语言来追踪吧。
试试看-你会喜欢它
我希望我有办法说服程序员们去追踪自己的程序,或至少让他们试着追踪一个月看看。不过我发现程序员们通常都没办法丢弃追踪程序要花很多时间的观念。这时作为一名项目领导者的好处之一就派得上用处了;你可以专制一点,坚持参予项目的程序员们都要追踪他们写的程序,直到他们真的了解这么作不但不花时间,而且值得为止。
如果你还没追踪过自己的程序,你会开始这么作吗?只有你自己知道会不会。不过我想当你拿起这本书开始看是因为你对于减少程序中的错误非常在意,不管这些程序是你自己写的,或是你带的程序员们写的。最后你真的得面对这样的抉择:要花少部分时间追踪程序的执行来确定程序执行无误呢,还是你希望让臭虫跑进原始码正本中,再来冀望测试人员能抓到这些虫,然后你才来修正这些错误呢?如何选择完全都看你自己。
快速回顾
臭虫不会自己从程序中长出来;它们是从程序员新写的程序或改过的老程序中冒出来的。如果你要在程序中找寻错误,除了逐步追踪编译好了的程序代码的每一行,没有更好的方法。
虽然你的直觉反应会认为追踪程序代码很花时间,你的直觉可能是错的。是的,一开始会花更多时间-一旦你习惯了,就不会了。当你习惯之后,你追踪程序代码的速度可快了。 ?
小心追踪每一条程序执行路线-特别错误处理程序里头的东西-至少一次。不要忘了 && , || 跟 ?: 运算子都有两条执行路线要测试。 ?
在某些状况中,你得把程序摊开成汇编语言来追踪。虽然你往往不用这么作,但是该作的时候就要作。
学习计划:
如果你回顾一下 第一章 中的内容,那边谈的是编译器能帮你自动抓到的常见错误。复习一下那些内容,再回头问问你自己,追踪程序执行时会不会漏掉那些错误。
学习计划:
看一下过去六个月里,你程序中已知的那些臭虫。如果你当初写程序时就有追踪过你的程序代码,你可以抓到里头几只?