自我維護
用编译器来自动抓虫是很棒的事,但是我打赌,如果你检查过程序项目中抓到的那些臭虫,你会发现编译器只抓到了其中一小部份。我打赌,如果你将臭虫隔离开来,你会发现程序现在大概会执行得很正常。
还记得下面这个第一章中的程序片段吗?
strCopy = memcpy(malloc(length), str, length);
这程序在任何状态下都会正常运作,除非 malloc 配置内存失败。当内存配置失败时, malloc 会传给 memcpy 一个 NULL 指针,而 memcpy 没办法处理这种状况。如果你够幸运,在你推出这个产品之前,你就会看到这个系统当掉;否则,你的顾客也会碰到程序当掉的灾难。
编译器抓不到像这样的错误,也没有编译器能够帮你抓到算法中的错误,检验你的假设,或在资料传递时进行一般性的查核工作。
找寻这种错误是困难的事情,需要一名有技巧的程序员或测试人员整个把它们挖出来。不过要自动找到这类错误是容易的事情,如果你知道怎么做的话。
两个版本的故事
让我们更进一步,看看你该怎么抓到像上头那个 memcpy 叙述中的错误。最简单的解决办法就是让 memcpy 检查 NULL 指针是不是被当成了参数使用,如果是,就丢个错误讯息出来,并中止程序执行。底下就是我们自己改良过的新版 memcpy :
/* 复制一段非重叠的内存。 */
void *memcpy(void *pvTo, void *pvFrom, size_t size)
{
byte *pbTo = (byte *)pvTo;
byte *pbFrom = (byte *)pvFrom;
if (pvTo == NULL || pvFrom == NULL)
{
fprintf(stderr, "Bad args in memcpy\n");
abort();
}
while (size- > 0)
*pbTo++ = *pbFrom++;
return (pvTo);
}
使用这个函式,没人会漏掉将 NULL 指针传给 memcpy 函式的错误。剩下来的问题只是这种测试方式把程序变大而且变慢了些。如果你觉得这是另一个有医比没医更糟糕的状况,我想你是对的;这种测试方式并不实用。这时 C 语言前置处理器就派上用场了。
如果你有两个版本的程序,结果会怎样?一个发行版本又快又好,另一个包含额外检查码的版本则又胖又慢。你可以在同一份原始码中维护两个版本的程序,只要使用 C 语言的前置处理器来条件性的加入或移除检查程序代码就好了。
举例来说,你可以让 NULL 指针的测试只有在 DEBUG 符号被定义时才会被编译到:
void *memcpy(void *pvTo, void *pvFrom, size_t size)
{
byte *pbTo = (byte a)pvTo;
byte *pbFrom = (byte a)pvFrom;
#ifdef DEBUG
if (pvTo == NULL || pvFrom == NULL)
{
fprintf(stderr, "Bad args in memcpy\n");
abort();
}
#endif
while (size- > 0)
*pbTo++ = *pbFrom++;
return (pvTo);
}
这里的构想是同时维护你程序的除错跟非除错版(就是用来公开发行的版本)。在写程序时,你编译出除错版的程序,在加入新功能时用它来自动除错。之后,当你准备推出产品时,重新编译一个公开发行版,把它打包好,就可以送去给经销商了。
当然,你不会真的想等到推出产品的最后一刻才执行你的程序-那太糟糕了。在开发过程中,你就应该让程序的除错版本能跑得动了,主要的理由在于,如我们将在本章跟下一章中看到的,执行除错版本的程序能大幅降低发展程序所需要的时间。如果每个函式都有着最低限度的错误检查跟测试不应该发生的状况,想象一下你的程序将会多么稳固啊。
技巧,当然就是确保除错码是最后产品中完全不必要的额外程序代码。你也许明白了,不过稍后我还会在提一下。
同时维护你程序的发行跟除错版本。
除错检查宏 ASSERT 的说明
坦白说 , ,我在 memcpy 里头放的除错码看来很差而且占据了整个函式的空间。我知道不多程序员忍受得了这种东西,即使这么做的理由很好。所以有些精明的程序员就把这些除错码全用个宏隐藏起来,把它称作 assert 除错检查宏,全定义在 ANSI 的 assert.h 表头档里头。
assert 只是我们之前看到那些 #ifdef 程序代码的重新包装版而已,不过当你使用宏时,它只需要用到一行原始码的空间而已:
assert 只是个除错专用的宏,会在参数为 false 时中止程序的执行。在上头的程序中,如果两个指针之中有一个是 NULL , assert 就会发生效用。
assert 并不是一个匆匆拼凑而成的宏;你必须小心定义它,避免在除错版跟发行版的程序中出现重大差异。 assert 不应该干扰到内存内容,不应该初始化原先未初始化的内存,也不应该产生任何副作用,程序在除错时产生的结果得跟发行版的一模一样。所以 assert 才被写成宏而不是一个函式;如果它是个函式,呼叫它将会造成非预期的内存或程序变化。记住,程序员是把 assert 当成非破坏性的测试工具使用,因为他们能够安全的使用它而不会改变系统状态。
你也应该留意到,一旦程序员学会用除错检查宏,他们常会重新定义 assert 宏的内容。举例来说,与其让 assert 在错误发生时中止程序执行,程序员有时会把 assert 重新定义,让它在错误发生时把控制权转移给除错器去处理。有些版本的 assert 甚至给你选择是否要当成错误没发生过,让程序继续执行下去。
如果你决定定义自己的除错维护宏,想个 assert 以外的名称,让那些使用标准除错检查宏的程序不会受到影响。在本书中,我会使用非标准的除错维护宏,我给了它一个叫做 ASSERT 的宏名称,好在程序中辨识出它来。 assert 跟 ASSERT 的主要差别在于 assert 是个你能自由使用在程序中的表达式,而 ASSERT 是个叙述,限制了能使用的地方。使用 assert ,你可以写这样子:
if (assert(p != NULL), p->foo != bar)
.
.
.
改用 ASSERT 的话,你会碰到语法错误的警告。我是故意这样安排的,除非你想在表达式里头使用除错检查宏,不然你应该将 ASSERT 定义成一个叙述,这样编译器才能在你把它误用在表达式中时发出错误。记住,这里的每一点都对除错有所帮助。为何要让你根本用不着的弹性存在?
你可以如下定义 ASSERT 宏:
#ifdef DEBUG
void _Assert(char *, unsigned); /* prototype */
#define ASSERT(f) \
if (f) \
{} \
else \
_Assert(__FILE__, __LINE__)
#else
#define ASSERT(f)
#endif
你看到如果 DEBUG 定义了, ASSERT 将会扩展成一个 if 叙述。 if 叙述中的空区块可能有点奇怪,不过你需要前头的 if 跟后头的 else ,以免出现非预期的 if 叙述被孤立的警告。你也许会觉得你需要在呼叫 _Assert 之后的 ) 后头加个分号,不过你并不并需要这么作,因为你在使用 ASSERT 时就会自己加上那个分号了:
ASSERT(pvTo != NULL && pvFrom != NULL);
当 ASSERT 叙述不成立时,它会以前置处理器透过 __FILE__ 跟 __LINE__ 宏提供的文件名称跟行号数当成参数来呼叫 _Assert. _Assert 会把错误讯息印到 stderr ,然后中止程序执行:
void _Assert(char *strFile, unsigned uLine)
{
fflush(NULL);
fprintf(stderr, "\nAssertion failed: %s, line %u\n",
strFile, uLine);
fflush(stderr);
abort();
}
程序结束前,你得呼叫 fflush 来完全写出缓冲区中等待输出的东西。呼叫 fflush(NULL) 是非常重要的,因为这样可以确保错误讯息在其它缓冲区的东西都被送去写入后才出现。
现在,如果你使用 NULL 指针呼叫 memcpy , ASSERT 将抓到这个错误,并印出如下的讯息
Assertion failed: string.c, line 153
这显示出了 assert 跟 ASSERT 的差别。标准宏会显示一个类似上头的讯息,可是它也会显示不成立的那个测试表达式。底下就是我常用的一个编译器产生的 assert 不成立时的讯息:
Assertion failed: pvTo != NULL && pvFrom != NULL
File string.c, line 153
将条件运算视野一起印出来的唯一问题是当你使用 assert 时,程序中也会包含一份这个条件式的文字给 _Assert 打印。那编译器怎么存放这字符串?麦金塔、 MS-DOS 跟 Windows 的编译器一般都会将字符串放在整体资料区域内,在麦金塔上的整体资料区域大小限制一般是 32K ,在 MS-DOS 跟 16-bit Windows 上,这个大小限制是 64K. 对于如 Microsoft Word 跟 Microsoft Excel 这类的大程序来说,这些除错维护字符串会迅速吃光整体资料区域的空间。
译注:
在 Mac OS 7.0 的 32-bit addressing 跟 Win 95/98/NT 下,类似的限制几乎是不存在了。不过不要忘了,即使没有限制了,这些除错讯息所用到的字符串还是要吃内存的。
当然有解决方法,不过最简单的就是从错误讯息中省掉那个条件表达式的字符串。毕竟,当你看过 string.c 的第 153 航后,你就知道问题在哪里,你可以在那里头找到原因。
如果你想看看怎样标准的 assert 是怎么定义的,你可以看到系统上提供的 assert.h 档案。 ANSI 标准的 Rationale 段落也谈到 assert ,并提供一个可能实作。 P. J. Plauger 也在他的书 The Standard C Library (Prentice Hall , 1992) 中提到实作标准 assert 的巧妙之处。
不管你最后怎么定义自己的除错维护宏叙述,用它来核对传给函式的参数正确性。如果你在每个函式的进入点都检查资料的正确性,程序的错误不用多久就会被找到。最棒的,是这些错误都是在发生时就被自动抓到的。
用除错检查宏来核对函式参数。
" 未定义 " 就要 " 厘清 "
如果你要停下来看 ANSI C 怎么定义 memcpy 子程序的,你会看到最后一行写着, " 在互相覆盖的内存区块间进行资料搬移动作时的行为是未定义的。 " 其它书籍将这种未定义说得不太一样,像 Standard C (Microsoft Press , 1989) , P. J. Plauger 跟 Jim Brodie 说, " 数组的元素可以任意顺序存取跟存放。 "
简单说来,这些书在说的就是,如果你依赖 memcpy 在处理重叠的内存块时的某种特定行为,你假定这种在不同计算机系统间或甚至同个编译器的不同版本间可能有所变化的行为是不变的。
我确定有程序员谨慎的使用着这种未定义的行为,不过我想大部分的程序员聪明的避免这么作。那些不避开这种未定义行为的人最好学着避开它。大部分程序员将未定义行为视作非法行为,这时除错检查宏就派上用场了。如果你在想呼叫 memmove 时使用 memcpy ,你不想多了解一点这样子有什么不同吗?
你可以加强 memcpy 的功能,加上一个除错检查宏来检查两个内存区间是不是完全没重叠到:
/* memcpy -- 复制一块非重叠的内存区域。 */
void *memcpy(void *pvTo, void *pvFrom, size_t size)
{
byte *pbTo = (byte a)pvTo;
byte *pbFrom = (byte a)pvFrom;
ASSERT(pvTo != NULL && pvFrom != NULL);
ASSERT(pbTo >= pbFrom+size || pbFrom >= pbTo+size);
while (size- > 0)
*pbTo++ = *pbFrom++;
return (pvTo);
}
那个只有一行的重叠检查也许运作得不太明显,不过你可以简单的把两块内存想成两台停在交通号志前面的车子。当一台车子的后安全杆在另一台车的前安全杆之前时,你知道车子不能重叠。这里的检查就是实作这样的想法: pbTo 跟 pbFrom 就是两块内存的后端,而 pbTo+Size 跟 pbFrom+size 就是两块内存的前端,这就是整个实作所需的东西。
不要让这种事情发生
在 1988 年尾,微软的一只大金牛- MS-DOS 版 Word 的推出日期已经顺延了三个月,而且严重影响到公司的底线。(这些喜怒无常的大金牛们常见这种问题。)那次顺延令人挫折的一面是 Word 的开发团队在那三个月中认为这个东西 " 随时都可以推出了 " 。
Word 小组依赖一个应用工具小组开发的一个关键组件,而这个工具小组一直告诉 Word 小组说那个程序快写好了,而且工具小组内的人也真的相信自己所说的。他们不明白自己的程序代码中充满了问题。
Word 程序代码跟那个工具小组的程序代码中一个显著的不同点是, Word 的程序代码过去充满了(现在也还是如此)除错检查宏跟除错码,而那个工具小组则完全不使用除错检查宏,所以这些工具程序员没办法判定他们的程序到底有多少问题。臭虫一直跑出来,那种如果用了除错检查宏的话,早在好几个月前就被抓光了的臭虫。
顺带一提,如果你不了解为何重叠内存间搬移的问题是如何重要,想想当 pbTo 等于 pbFrom+1 而你至少搬移两个字节时的情况- memcpy 不会正确动作。
所以在将来,请停下来检视你的程序中未定义行为的运用。如果发现有哪里用到了未定义行为,请将它从设计中移除,或加上一段除错检查宏来警惕程序员们他们正在使用的是未定义的行为。
如果你提供链接库(或操作系统)给其它程序员,处理未定义行为将是非常重要的。如果你曾经发展过这样供人使用的链接库,你就会了解其它程序员可能会使用任何他们找得到的未定义行为来产生他们要的结果出来。当你推出改进并推出新版链接库时,这些未定义行为的使用结果就会真正浮现台面。你会发现当你的链接库百分之百兼容于过去版本时,总是有一半使用它的程序当掉了。原因很简单:新的链接库不完全兼容于旧链接库的 " 未定义行为 " 。
拿掉程序中用到的未定义行为,不然就用除错检查宏把未定义行为的错误使用抓出来。
哀嚎着 " 危险 " 的程序代码
当我们谈到这里,我想再提处理 memcpy 重叠状况的除错检查宏一下。再看一下底下这东西:
ASSERT(pbTo >= pbFrom+size || pbFrom >= pbTo+size);
假设你呼叫了 memcpy 而上头的除错检查宏发生了不成立的状况。当你检查时,如果你从来没看过如何检查内存区域的重叠,你会晓得哪里出问题了吗?我想我大概不晓得。这种写法的诡异或不清楚是不用提了-毕竟,它只是个简单的内存区间重叠检查。不过简单跟明白是两回事。
记住我的话,没有多少事情比底下这件更让人有挫折感的-追踪程序执行到别人写的除错检查宏里头,却摸不清楚为何放个那样的检查在那边。你不但没修好问题,反而浪费了许多时间来想问题究竟在哪里。这样还没结束哩,程序员们有时会写出有问题的除错检查宏,可是如果当你除错时,如果你根本不知道他们的除错检查宏在检查什么,那你根本就很难弄清楚该修理这程序还是那个除错检查宏。
幸运的,这问题很好收拾-只要对用意不明的除错检查宏加上注释就好了。这听来简单,可是很少程序员作到这点。他们制造了一堆麻烦让你不会碰到危险的东西,可是他们不会告诉你危险的究竟是什么。就好象走在森林里,你看到一个牌子钉在树上,牌子上写着大大的红色危险字样,那到底哪个东西才是危险的?是树会倒下来吗?还是附近有废弃的矿坑?或是有沼泽怪兽大脚?除非你告诉人们危险的是什么(或至少那东西很明显),不然你留下的警告标志一点也没帮到他们,树林中的人们将会忽略那个警告标志。相似的,程序员们会忽略任何他们看不懂得除错检查宏-他们假设那个检查是错的而把它拿掉,所以才需要加上注释来加以详细说明。
这不是用来抓错误的
当程序员们开始使用除错检查宏时,他们有时会用它来捕捉真正的错误,而不是非法状况。例如下面这个 strdup 函式中的除错检查宏:
/* strdup - 配置一块内存来复制一个字符串。 */
char *strdup(char *str)
{
char *strNew;
ASSERT(str != NULL);
strNew = (char a)malloc(strlen(str)+1);
ASSERT(strNew != NULL);
strcpy(strNew, str);
return (strNew);
}
在这程序中,第一个除错检查宏用得对,因为它检查只要程序正确执行就永远不会发生的错误状况。第二个就不一样了-它检查一个会出现在最后产品中而必须要处理掉的错误。这样的用法是不对的,应该补上一段错误状况处理程序才是。
如果一种错误有个大概解决方案,最好把它纪录下来。当一名程序员呼叫 memcpy 来搬动重叠的内存区域时,那很可能就是他或她想作的事情,只是没注意到内存区域有重叠到而已。你可以加个注释来说明,如果要搬动重叠的内存区域,应该用 memmove :
/* 内存区块重叠时,请改用memmove. */
ASSERT(pbTo >= pbFrom+size || pbFrom >= pbTo+size);
你不需要写得很长,一种做法是使用简短的问题来诱使程序员自己想办法,这样比起长篇大论的解释每个解决的细节要能让看的人得到更多信息。不过要小心的-除非你确定有用,不然不要随便建议一个做法给别的程序员当作问题的解决方案,你总不想让你的注释误导别人吧?
不要浪费别人的时间,把你自己的除错检查宏注释说明清楚。
你又在假设东西如你想的那样了吗?
有时在你写程序时,你需要作些目的环境的假设,虽然不总是如此。举例来说,底下的 memset 子程序就不用任何关于使用环境的假设而能在任何一种 ANSI C 编译器上用得很好:
/* memset - 将内存填满那个字节型态参数的值。 */
void *memset(void *pv, byte b, size_t size)
{
byte *pb = (byte *)pv;
while (size- > 0)
*pb++ = b;
return (pv);
}
不过对许多环境来说,你能写个更快速的 memset 子程序,将一个较大的资料型态填满那个字节型态参数的值,再拿这个较大的资料型态用较少的循环次数填满几乎同样多的内存。底下的例子,在 68000 微处理器上,就能以前一页本来那个可移植版的 memset 跑得快四倍:
/*
* longfill - 将内存填满long参数的值,并传回一个指针指向
* 最后一个填入的长整数之后的一个下长整数的地址。
*/
long *longfill(long *pl, long l, size_t size);
/a prototype */
void *memset(void *pv, byte b, size_t size)
{
byte *pb = (byte a)pv;
if (size >= sizeThreshold)
{
unsigned long l;
l = (b << 8) ? b;/* 将一个长整数的四个字节都填成b的值。 */
l = (l << 16) ? l;
pb = (byte a)longfill((long a)pb, l, size / 4);
size = size % 4;
}
while (size- > 0)
*pb++ = b;
return (pv);
}
上面这个子程序相当简单,除了那个对 sizeThreshold 的检查可能有点不清楚。不清楚的东西为什么要留着呢?想想,将一个长整数的四个字节填成我们要的值也是需要时间的,再加上呼叫 longfill 函式的损耗时间,对 sizeThreshold 的检查确保 memset 只有在能比原先的做法跑得更快时,才会使用新的做法。
新版本 memset 唯一的问题是,它使用了一堆对编译器与操作系统的假设。这程序假设长整数一定是四个字节长的,而一个字节一定是八个位宽的。这样的假设在许多计算机上都成立,而且现在在微电脑上也近乎每一部上头都成立。可是,那不代表你应该让程序依赖于这样的假设之上,因为唯一靠得住的假设,就是你的假设现在可能成立,可是几年后却可能不成立。
译注:
从十六位的 MS-DOS , Windows 3.1 到三十二位的 Mac OS 与 Win32 环境跟目前的大部分 Unix 版本下,大部分的编译器都将长整数当成 32 位长的。但在微软公司 1998 年五月订定的 Win64 环境的资料型态标准中,长整数的长度延伸成了 64 位长。在一些 64 位微处理机的系统上,如 DEC Alpha ,也有编译器将长整数的长度订为 64 位长的。所以依赖这种位长度的假设可以说是相当危险的一件事情。
有些程序员会将这子程序改写成底下这样,来改善可移植性:
void *memset(void *pv, byte b, size_t size)
{
byte *pb = (byte a)pv;
if (size >= sizeThreshold)
{
unsigned long l;
size_t sizeLong;
l = 0;
for (sizeLong = sizeof(long); sizeLong- > 0; )
l = (l << CHAR_BIT) ? b;
pb = (byte a)longfill((long a)pb, l, size /
sizeof(long));
size = size % sizeof(long);
}
while (size- > 0)
*pb++ = b;
return (pv);
}
这程序看来比较具有可移植性,因为它使用了大量的 sizeof 运算子,可是用看的一点意义也没有;你还是得在它移植到另一个环境后,重新检查一遍。如果你将这程序在 Macintosh Plus (译按:好几年以前的一种标准型麦金塔计算机)或任何其它 68000 计算机上执行,这程序在 pv 一开始就指向奇数地址时就当掉。因为 byte * 跟 long * 两种资料型态在 68000 上是不能完全互通的-你不能把一个长整数存放在奇数地址上,不然就会引发微处理器的总线地址失误。
译注:
32 位微处理器如 Motorola 68000 跟 Sun Sparc 要求长整数一定要存放在偶数地址上,不然就会引发 Bus fault ,总线地址失误。好一点的操作系统会处理这个失误,把程序关闭掉,差一点的操作系统干脆直接当机了事。从 Motorola 68020 允许长整数存放在奇数地址以后,这种地址存取失误就变成可以选择性关闭触发了;其实在 Intel 80386 以后的微处理器也都支持开启这样的地址存取失误处理,不过在 Windows 底下是绝对不会开启这旗标的。
所以你该怎么作才好呢?
在这里,你不想把 memset 写成本来那个没有可移植性问题的版本,而想接受那些不可移植到其它环境的版本,抗拒以后可能有的变化。对 68000 来说,你可以先只填入字节,直到对齐了偶数地址,才开始填入长整数。虽然对齐偶数地址就算足够了,在使用更新的 68020 , 68030 跟 68040 微处理器的麦金塔计算机上,对齐地址为四的倍数可以让长整数的内存填写达到更好的执行效率。如同其它假设情形,你可以用除错检查宏跟条件式编译叙述来核对这样的做法有没有用:
/* 将一个长整数的四个字节都填成填写内存用的字节值,再呼
叫longfill.
*/
void *memset(void *pv, byte b, size_t size)
{
byte *pb = (byte a)pv;
#ifdef MC680x0
if (size >= sizeThreshold)
{
unsigned long l;
ASSERT(sizeof(long) == 4 && CHAR_BIT == 8);
ASSERT(sizeThreshold >= 3);
/* 填入字节,直到对齐地址为四的倍数。 */
while (((unsigned long)pb & 3) != 0)
{
*pb++ = b;
size-;
}
/* 将一个长整数的四个字节都填成填写内存用的字节值,
再呼叫longfill. */
l = (b << 8) | b;
l = (l << 16) | l;
pb = (byte *)longfill((long *)pb, l, size / sizeof(long));
size = size % sizeof(long);
}
#endif /* MC680x0 */
while (size- > 0)
*pb++ = b;
return (pv);
}
如你所见,我把机器平台相关的程序代码放在检查 MC680x0 的前置处理器定义的条件式编译叙述里头。使用这样的前置处理器定义不只让这些不可移植到其它环境的程序在其它环境下不会被意外用到,当你在程序中搜寻 MC680x0 时,你就能找到各个依赖于执行环境的特定条件的程序代码。
我也加了个简单的除错检查宏来检查长整数是否占用四个字节,一个字节是否占用八个位。这些假设所依赖的条件也许不会改变,但是你怎么知道这些永远不会改变呢?
最后,我加了个循环在呼叫 longfill 之前让 pb 对齐内存地址,由于不管 size 多大,这个循环最多会执行三次,我还加上了一个除错检查宏来检查 sizeThreshold 最少为 3. ( sizeThreshold 应该更大一点,不过最少一定要是 3 ,不然程序就跑不动了。)
在这些改变之下,这个子程序明显不具可移植性,而所有的假设都用除错检查宏消除或检查过了。这样的设计让这个函式不太可能会被误用啰。
拿掉含糊的假设,不然就用除错检查宏来检查这些假设成不成立。
编译器是自家人写的又怎样?
微软公司内有的应用程序开发小组发现它们得检阅并清理他们的程序代码,因为程序里头到处都把 +sizeof(int) 写成 +2 ,把无号数跟 0xFFFF 比较而不是跟 UINT_MAX 比较,把数据结构中 16 位宽的字段写成 int 。
对你来说,本来的那些程序员们似乎很懒散,不过他们过去认为拿 +2 来取代 +sizeof(int) 确实有很好的理由。微软公司开发自己的编译器,这让程序员们有着安全的错觉。一名程序员好几年前说过一句话, " 编译器小组永远也不会改变编译器的某些东西来让我们大修我们的程序。 "
编译器小组改变了整数 int (还有其它一些资料型态)的长度,以便产生在 Intel 的 80386 跟更新的微处理器上跑得更快而又更小的程序执行码。编译器小组不想让其它小组的人伤脑筋,可是对他们来说,在市场上维持竞争力是一件更重要的事情。毕竟,一些微软的程序员自己作出错误的假设并不是他们的错。
不可能的事情会发生吗?
一个函式的输入资料不总是来自函式参数,有时你从输入端只得到一个指针。看一下这个反压缩子程序:
byte *pbExpand(byte *pbFrom, byte *pbTo, size_t sizeFrom)
{
byte b, *pbEnd;
size_t size;
pbEnd = pbFrom+sizeFrom; /* 指针指的地方刚好在缓冲区尾端。 */
while (pbFrom < pbEnd)
{
b = *pbFrom++;
if (b == bRepeatCode)
{
/* Store "size" copies of "b" at pbTo. */
b = *pbFrom++;
size = (size_t)*pbFrom++;
while (size- > 0)
*pbTo++ = b;
}
else
*pbTo++ = b;
}
return (pbTo);
}
这程序将资料从一个缓冲区复制到另一个缓冲区,但在复制过程中寻找压缩字符的封包。如果他发现数据中有特殊字节 bRepeatCode ,它就知道接下来两个字节是要重复的字符跟该重复的次数。虽然这做法很简单,你可以在程序员使用文字编辑器中使用这样的子程序。在那样的程序中,通常每一行前面都有一堆缩排用的移位字符或空格符。
要让 pbExpand 更稳固,你可以在开头检查 pbFrom , pbTo 跟 SizeFrom 参数是否合格,不过你还可以做到更多检查:你可以核对缓冲区中的资料。
要编码一串文字要用三个字节,所以压缩子程序从来不会在相同字符只有两个时进行编码;把三个同样的字符编码起来也得不到好处。它只在同样的字符连续出现四次时,才会进行编码。
有个例外。如果本来的资料包含 bRepeatCode ,它得特别处理这个情形,以便在 bRepeatCode 单独出现时, pbExpand 不会以为自己拿到了一个压缩过的封包。当压缩子程序发现 bRepeatCode 时,它把这东西当成被编码一次的 bRepeatCode 处理。
简单说来,对每个封包,长度最少是四,不然被编码的字节一定是 bRepeatCode ,而长度一定是一。你可以用个除错检查宏来查对这一点:
.
.
.
{
/* 将size个b存在pbTo的位置开始的地方。 */
b = *pbFrom++;
size = (size_t)*bFrom++;
ASSERT(size >= 4 ?? (size == 1 && b == bRepeatCode));
.
.
.
如果这个检查叙述不成立,一定是 pbFrom 指向了垃圾,不然就是压缩子程序出错了。两种情形都算是其它做法不好捉到的错误。
用除错检查宏来找出不可能发生的状况。
沉默的羔羊
假设有人聘你去写核子反应炉的软件,你得要处理炉心过热的情形。
一些程序员会以自动加水到炉心,插入控制棒,或任何他们认为要冷却炉心时所该作的事情来解决这个问题。只要这个程控全局,就不会触发警报。
别的程序员也许会选择只要炉心过热就触发警报。计算机还是可以自动处理状况,不过管理员总是可以知道有什么事情发生了。
你会怎么写这个程序?
我想不会有太多人不同意这个做法;你会警告管理员。让计算机把炉心温度冷却回正常状态是不对的,炉心不会自动增温-一定有些东西出错了,最好有人赶快找出哪里出了问题,才不会让同样的事情再度发生。
令人惊讶的,程序员们,尤其是经验老到的程序员们,每天写着修正一些非预期发生的问题的程序。他们甚至是故意这样写的,也许你也是这样子作。
当然,我所要针对的不是这样的程序员们,而是这样子防御性的程序设计方式。
在上一节里,我给你看过 pbExpand 是怎么写的了。那个函是使用了防御性的程序设计方式。这里有个改过的版本-看看它的循环条件吧:
byte *pbExpand(byte *pbFrom, byte *pbTo, size_t sizeFrom)
{
byte b, *pbEnd;
size_t size;
pbEnd = pbFrom+sizeFrom;/* 指针指的地方刚好在缓冲区尾端。*/
while (pbFrom != pbEnd)
{
b = *pbFrom++;
if (b == bRepeatCode)
{
/* 将size个b存在pbTo的位置开始的地方。 */
b = *pbFrom++;
size = (size_t)*pbFrom++;
do
*pbTo++ = b;
while (-size != 0);
}
else
*pbTo++ = b;
}
return (pbTo);
}
即使程序本身更精确的反映了算法,只有少数经验老到的程序员们会这样子实作这个算法。这样子写太危险了。
他们会认为, " 我晓得 pbFrom 在外层循环永远不应该大于 pbEnd ,可是如果这种情形发生了呢?嗯,我最好在这种情形发生时,让这循环跳出来。 "
他们会对内层循环也采用同样的逻辑 , 即使 size 应该总是大于或等于 1 ,用个 while 循环代替 do 循环可以在 size 为 0 时让这程序不会当掉。
这样子似乎合理,甚至精明,从不可能发生的情形中保护自己。不过如果 pbFrom 不知怎的超出了 pbEnd 呢?你比较想在前一页那个比较危险的版本中找出为何如此呢,还是在我们稍早看到的那个防御性的版本里头,当作什么事情都没发生?
比较危险的那个版本大概会当掉,因为 pbExpand 会拿它所碰得到的内存地址中的所有东西来解压缩。你当然会注意到这个。那个防御性的版本,另一方面,则在 pbExpand 可以破坏任何东西以前就跳离开了。你可能还是找得到造成这种现象的问题,不过你不会跟我赌你找得到这个问题在哪里。
防御性程序设计方式看来似乎是一种比较好的写作方式,但它会把错误隐藏起来。记住,错误根本不应该发生,如果你把它们藏在安全处,那你就更难写出零错误程序了。特别是当你有个如 pbFrom 般到处乱指,每次都处理不同资料量的指针时,上面的话更是真实的。
这意味着你应该停止使用防御性的程序写作方式吗?
当然不是。防御性的写作程序会把错误隐藏起来,也同时有可贵的用途。一个程序最糟糕就是当掉,遗失使用者可能花了好几个小时建立的资料。在一个程序会当掉的非乌扥邦里,任何能避免使用者遗失资料的手段都是值得的。防御性程序设计方式正式朝着这样的目标前进,如果没有它,任何一点硬件或操作系统上的改变都可能让你的程序如一叠骨牌般完全倒掉。不过同时,你也不想用防御性的程序写作方式把错误隐藏起来。
假设 pbExpand 被呼叫时的参数不合格,特别是在 sizeFrom 有点小而数据缓冲区的最后一个字节刚好是 bRepeatCode 时。由于这最后一个字节看来像是个压缩封包的开头, pbExpand 会一次读取超出缓冲区边界的两个字节,而让 pbFrom 超出 pbEnd. 结果?危险版的 pbExpand 大概会当掉,而防御版的 pbExpand 会保护使用者免于遗失资料,虽然它还是清掉了 255 个字节的未知数据。两种行为都是你要的,不过是在不同版本的程序中。你要除错版的程序能在出错时警告你,也要发行版的程序能安全渡过错误状况而不会遗失资料。解决方式就是用你平常的那种防御性写作方式,加上一个除错检查宏来警告你东西出错了:
byte *pbExpand(byte *pbFrom, byte *pbTo, size_t sizeFrom)
{
byte b, *pbEnd;
size_t size;
pbEnd = pbFrom+sizeFrom;/* 指针指的地方刚好在缓冲区尾端。 */
while (pbFrom < pbEnd)
{
b = *pbFrom++;
.
.
.
}
ASSERT(pbFrom == pbEnd);
return (pbTo);
}
这个除错检查宏只是简单的查对程序有没正常结束。在发行版的程序中,这样的防御性程序保护使用者免于碰到错误。但在除错版的程序里,错误发生时还是会被报告出来。如果这样子还不够好,我不知道怎样子才算好了。
你还不用太关心这个。如果 pbFrom 在循环中一次总是加一,得要有个迷失的宇宙射线刚好打中它,把它踢到 pbEnd 后头去,这才会发生问题。在这种情形下,除错检查宏帮不了你任何忙。好好检查你的程序,善用你的常识吧。
最后一点,循环只是程序员们惯常防御性写作程序的地方之一。无论你在哪边用防御性的方式来写程序,问问你自己, " 我正在用防御性程序写作的方式隐藏错误吗? " 如果是,加上除错检查宏来帮你抓臭虫吧。
防御性的写程序时,不要把臭虫藏起来。
有两种算法好过只有一种算法
检查错误输入跟不正确的假设只是你在程序中抓虫所该作的事情中的一部份。如同其它函式可能丢垃圾给你的函式,你的函式也可能传回垃圾给它的呼叫者-你当然不想让这种事情发生。
memcpy 跟 memset 都只传回一个参数,它们不太可能会意外传回垃圾给呼叫它们的程序。不过对于更复杂的子程序来说,你可能就没办法如此肯定了。
举例来说,我最近写了个 68000 反组译器,作为一个麦金塔程序开发工具的一部份。对反组译器来说,速度不是最重要的,重要的是它正确运作,所以我决定用一种简单的查表算法来写这个程序,好让我能简单的测试它,我也用了除错检查宏来自动捕捉任何我在测试程序时漏掉的错误。
如果你看过汇编语言的参考手册,运气好的话,上头会有每个指令的仔细说明。而且为了表示这本手册很完整,它还会附一张每个指令的位编码表。举例来说,如果你在一本 68000 参考说明书上头找到 ADD 这指令,你会看到它的位编码格式如下:
你可以把这指令的缓存器代码跟模式代码那两栏忽略不看-我们在乎的只有那些明白为 0 或 1 的位-在这例子中,就是这指令的前四个位。你可以判断一个指令是不是个 ADD 指令,只要将它不明确的位滤掉,再比较剩下的前四个位是不是 1101 ,或十六进制的 0xD 就好了:
if ((inst & 0xF000) == 0xD000)
这是个ADD指令...
DIVS 指令(用来作有号数除法)的编码格式中有七个明确的位:
如果你将不明确的位滤掉,你就可以用下头的叙述分辨一个指令是不是 DIVS :
if ((inst & 0xF1C0) == 0x81C0)
这是个DIVS指令...
你可以用这种屏蔽测试的技巧来分出每个汇编语言指令,一旦你分出了 ADD 或 DIVS ,你可以呼叫一个译码函式来判读刚刚我们忽略掉的位里头的缓存器跟指令模式讯息。
这就是我在工具中发展的反组译器的运作方式。
当然,我没写 142 个不同的 if 叙述来检查每个可能的指令,我作了张有屏蔽,位格式跟指令译码函式地址的指令表。检查算法会查遍整张表,如果有符合的指令,就呼叫对应的子程序来解读指令的缓存器跟模式字段。
底下是这张表的一部份,还有使用它的程序:
/* idInst是张识别各指令的位屏蔽与位格式的指令表 */
static identity idInst[] =
{
{ 0xFF00, 0x0600, pcDecodeADDI }, /* mask, pat, function */
{ 0xF130, 0xD100, pcDecodeADDX },
{ 0xF000, 0xD000, pcDecodeADD },
{ 0xF000, 0x6000, pcDecodeBcc }, /* short branches */
{ 0xF1C0, 0x4180, pcDecodeCHK },
{ 0xF138, 0xB108, pcDecodeCMPM },
{ 0xFF00, 0x0C00, pcDecodeCMPI },
{ 0xF1C0, 0x81C0, pcDecodeDIVS },
{ 0xF100, 0xB100, pcDecodeEOR },
.
.
.
{ 0xFF00, 0x4A00, pcDecodeTST },
{ 0xFFF8, 0x4E58, pcDecodeUNLK },
{ 0x0000, 0x0000, pcDecodeError }
};
/* pcDisasm
* 反组译指令,并填入opc运算码结构中,
* 然后传回更新过的程序计数器值。
* 一般用法:pcNext = pcDisasm(pc,&opc);
*/
instruction *pcDisasm(instruction *pc, opcode *popcRet)
{
identity *pid;
instruction inst = *pc;
for (pid = &idInst[0]; pid->mask != 0; pid++)
{
if ((inst & pid->mask) == pid->pat)
break;
}
return (pid->pcDecode(inst, pc+1, popcRet));
}
如你所看到的, pcDisasm 不是个大函式。它用了个读取目前指令,查表,然后呼叫译码子程序填好 popcRet 指向的 opcode 结构的简单算法。 pcDisasm 的最后工作就是把更新过的程序计数器值传回,这是必要的,因为不是每个 68000 的指令长度都是一样的。这个译码子程序会在需要时读取指令的额外部分,并传回新的程序计数器值给 pcDisasm ,再传给呼叫 pcDisasm 的程序。
现在回到原来的话题,我们正在讨论你没办法确定子程序永远不会传回垃圾的问题。
一个像 pcDisasm 般的函式,很难说它传回来的资料是不是正确的。即使 pcDisasm 本身会适当的辨识指令,译码子程序也可能会吐出垃圾,让你找臭虫找个老半天。一个抓住这样错误的办法是将除错检查宏加到每个译码子程序中。我没说你应该这么作,不过有个更为有用的方法,就是将除错检查宏加在 pcDisasm 中,因为这里是所有译码函式的瓶颈所在。
问题是,怎么作?你如何自动检查每个译码子程序是不是都正确填好了 opcode 结构?你得写程序来检验这个结构的正确性,而你又会怎么写这东西?基本上,你得写个子程序比较一个 68000 指令跟 opcode 结构内容。换句话说,你得写第二个反组译器。
我知道这听起来很疯狂,不过这真的会很疯吗?
看看 Microsoft Excel 在它的重新计算引擎里的设计吧。速度对于一个电子表格程序的成功是很关键的要求, Excel 使用了一种复杂的算法来确定它不会重复计算一个不需要重新计算的储存格。唯一的问题是这算法太复杂了,改写它很难不产生新错误。 Excel 的程序员们不喜欢这件难事,所以它们在除错版的程序中写了另一个重新计算引擎。当灵巧的那一个引擎停止计算后,除错版的引擎就开始计算,缓慢但完整的重新算过每个有表达式的储存格。当两者的结果有所差异时,就会让除错检查宏出现不成立的状况。
Microsoft Word 也有类似的问题,因为速度对于文书处理器的排版程序也是重要的东西, Word 的程序员们把排版程序代码用手工最佳化过的汇编语言来写。这东西当然跑得很快,可是除错上很伤脑筋。不像不常更动的 Excel 重新计算引擎, Word 的排版程序代码经常在新功能加入 Word 时被动到。为了自动捕捉排版错误, Word 的程序员们用 C 重写了每个手工最佳化过的汇编语言子程序。如果两个版本的结果不一样,除错检查宏的条件式就不成立。
同样的,用个除错版的反组译器来核对我在程序发展工具中写的主反组译器也是有意义的。
我不会拿我怎么写第二个反组译器 pcDisasmAlt 的细节来烦你,不过它的逻辑并不是查表法。我用巢状的 switch 叙述来去掉有效位,直到分离出正确指令为止。后面就是我怎么用 pcDisasmAlt 来核对主反组译器结果的程序代码。
instruction *pcDisasm(instruction *pc, opcode *popcRet)
{
identity *pid;
instruction inst = *pc;
instruction *pcRet;
for (pid = &idInst[0]; pid->mask != 0; pid++)
{
if ((inst & pid->mask) == pid->pat)
break;
}
pcRet = pid->pcDecode(inst, pc+1, popcRet);
#ifdef DEBUG
{
opcode opc;
/* 检查两个结果是不是吻合。 */
ASSERT(pcRet == pcDisasmAlt(pc, &opc));
ASSERT(compare_opc(popcRet, &opc) == SAME);
}
#endif
return (pcRet);
}
正常说来,除错检查不应该影响到现有程序结果,不过在这里我没办法完全作到这点-我得宣告一个存放 pid->pcDecode 传回来结果的区域变量 pcRet ,才能在接下来作核对。这没什么问题,这并没有违背 " 不应该拿除错版本程序代码产生的结果来取代发行版本程序的结果 " 的基本原则。这原则当然很对,不过当你开始用除错检查宏跟除错程序代码时,你会发现有时候你真的得执行除错版本的程序代码来替代发行版本程序,我们在第三章就会碰到一个例子。不过在那之前,让我重申一遍:不要那样作。我虽然更动了 pcDisasm 来加上除错检查,不过发行版本的程序代码还是都跑到了。
我不会要求你一定要替程序的每个函式都写两个版本出来,那样子作就跟要求每个函式都跑得很有效率一样可笑而且浪费时间。我相信大部分的程序都有正常运作必须的关键功能,这关键的部分最不可以出错。在文书处理程序中,排版引擎就是个关键。在项目管理程序中,工作时程表就是关键。数据库里头,关键在于资料搜寻与取出引擎。对每个程序,保障使用者永远不会遗失资料的程序代码都是关键所在。
当你写程序时,留意每个核对结果的机会,瓶颈所在的子程序就是仔细检查的好地方。如果可能,用另一套算法而不只是同样算法的另一个实作版本,来检查输出的结果是不是正确的。藉由不同算法的使用,你不只找得出实作的错误,也能增加找出算法本身错误的机会。
用第二种算法来核对结果。
嘿,这里怎啦?
本章稍早,我说过你必须小心定义 ASSERT 宏。我特地提到,这宏不能搬动内存,不能呼叫别的函式或是造成其它非预期的副作用。如果这些都是对的,为什么我在 pcDisasm 中用了底下这样的除错检查宏?
/a Check both outputs for validity. a/
ASSERT(pcRet == pcDisasmAlt(pc, &opc));
ASSERT(compare_opc(popcRet, &opc) == SAME)
ASSERT 不可以呼叫函式的理由是这个宏可能会非预期的干扰到周围的程序代码。不过在上面的程序片段里, ASSERT 并不是在呼叫函式;我是写 ASSERT 的人,我知道这样子在 pcDisasm 中呼叫函式是安全的而不会产生任何问题,所以我并不担心在除错检查宏中呼叫函式。
在一开始就把错误找出来
到现在为止,我都忽略了指令的缓存器跟模式字段,不过如果这些字段有个特别的编码方式改变了指令本身的意义呢?举例来说, EOR 指令长得像这样:
而 CMPM 指令长得出奇的像 EOR :
注意到,如果 EOR 的有效地址模式是 001 ,这样的 EOR 指令就长得像是个 CMPM 指令了。当然问题就是,如果把 EOR 指令放在 IdInst 指令表的前面,它会挡到 CMPM 指令的判断。
还好, pcDisasm 跟 pcDisasmAlt 做法不同,你会在第一次要反组译 CMPM 指令时碰到除错检查宏发出来的警报。这是因为 pcDisasm 会把 opcode 结构填成 EOR 指令,可是 pcDisasmAlt 会正确(至少我们希望如此)填入 CMPM 指令的东西。当两个结构在除错码中比较时,你就会得到检查失败的结果。这是个在除错时使用不同算法对结果后找到错误的正面例子。。
坏事是,只有在你试着反组译 CMPM 指令时,你才会抓到问题。我希望你的外部测试套件够完整得能抓到这个问题,不过记住我在第一章中说过的:你得尽可能早的自动抓到臭虫,而不要依赖别人找寻错误的技巧。
所以当你希望把这东西丢给测试小组去解决时,先不要那样作。尽管许多程序员相信测试人员是帮他们找寻问题,实际上测试人员存在的目的是找寻程序员没找到的错误,写程序的人应该自己找寻自己制造的臭虫。如果你不同意,不如不要把自己叫做程序员了,叫个别的名称都好,反正你可以毫不在意的乱写东西,一定会有人帮你检查哪边犯了错,不是吗?写程序没有例外的,如果你要写出零错误的程序,你就必须抓紧机会,把握每个能找出错误的机会。如果你没有这样的观念,从现在起照着这观念作吧。
当你注意到程序中某个危险的东西跑了过去,问问你自己, " 我该怎样尽可能早的自动抓到那个错误? "
习于如此自我省问,你将发现各种让你的程序更稳固的方法。
在 main 主函式初始化过程序后,你可以检查一下指令表中有没有错误。只要看看指令表中的各个指令有没有跟前面的判断互相冲突的,就能找到有没有错误了。底下的程序代码就是检查指令表中有没有这样的错误的,虽然短了点,也不用写得太清楚了:
void CheckIdInst(void)
{
identity *pid, *pidEarlier;
instruction inst;
/* 检查指令表中的每个指令... */
for (pid = &idInst[0]; pid->mask != 0; pid++)
{
/* ...verify that no earlier entries collide with
it. */
for (pidEarlier = &idInst[0]; pidEarlier < pid;
pidEarlier++)
{
inst = pid->pat ? (pidEarlier->pat & ~pid-
>mask);
if ((inst & pidEarlier->mask) == pidEarlier-
>pat)
ASSERT(bitcount(pid->mask) <
bitcount(pidEarlier->mask));
}
}
}
这个检查会逐一比对稍早出现过的指令跟现在的指令。每个指令都有个忽略屏蔽-通常是缓存器与模式位被屏蔽掉。不过如果这些被屏蔽掉的位里有组成在指令表中前面的指令所需的位呢?这时你就找到两个冲突的指令了。哪个指令放到前面去才好?
这答案很简单,如果有一串位吻合指令表中的两个指令的格式,明确位愈多的指令应该在指令表中排得愈前面。如果你觉得这样不够直觉,再看一下 EOR 跟 CMPM 的指令格式吧。如果有一串位吻合这两个指令的格式,你会怎么判定哪一个才是这一串位所表示的指令?为何如此?由于屏蔽位对每个明确位的对应位都是 1 ,你可以找出哪个指令有更多明确位,只要计算一下屏蔽中被设立的位数就好了。
更困难的是如何分辨哪两个指令冲突了。上头程序中的构想是将一个指令格式中的忽略屏蔽填成前面每一个命令对应的位,如果产生的结果能满足前面某一个指令的格式要求,就找出冲突的两个命令在指令表中的位置了。
警告
一旦你开始用除错检查宏,你大概会发现你抓到的臭虫数急速增加。这会吓到那些没准备好的人们。
我有次重写了一个错误百出的链接库,微软公司内好几个小组共享这链接库。原来的版本没有任何除错检查宏,我写的新版本里头则有用到。我得到预料之外的惊讶反应,当我把新链接库丢出来时,一名程序员愤怒的要我把原来的版本弄回来。我问他,为什么。
" 我们安装了你写的新版本,结果得到一大堆错误 " ,他说。
" 你认为是这链接库造成的问题? " 我吓到了。
" 看来是这样。我们碰到一大堆我们没碰过的除错检查宏发出的警告讯息。 "
" 你有看过这些警告讯息中的任何一个吗? "
" 我们看过了,那些都说是我们程序中的错误,可是那么多错误不可能都是真的错误。我们没有时间可以浪费在找这些鬼错误上,把我们的老链接库弄回来! "
嗯,我不认为他看到的是鬼错误,所以我要他继续用我的链接库版本,直到他真的碰到了鬼错误为止。他很生气,可是他承认,到目前为止,他所找到的错误都是在他的项目程序里的,而不是链接库中的。
那名程序员吓慌了,因为我没告诉任何人我在链接库中加上了除错检查宏,没人预期会碰到一堆错误讯息。如果我告诉人们注意看他们碰到的错误讯息,我就不会让程序员们抓狂了。不过程序员们并不是唯一抓狂的人,因为公司以未完成的功能数和抓到的臭虫数来评估项目进度,只要这两个数字一大幅攀升,参予项目的每个人就会开始紧张。如果你可以,好好预警你底下的人,告诉他们不要太紧张。
在程序开头呼叫 CheckIdInst ,你就可以在程序一执行时找到冲突的指令了-你还不必反组译任何一个命令哩。你应该在自己程序的开头设计类似的激活检查,因为他们能快速预警错误的存在,而不会让错误不小心躲过你的视线。
不要等到错误发生;使用激活检查吧。
除错检查宏永远有效
在本章中,你已经看到如何用除错检查宏来自动捕捉程序中的错。当这种做法成为帮你迅速找到 " 最后 " 一只臭虫的宝贵工具时,你可以如其它工具一般的滥用它,怎么用全看你自己。对某些程序员来说,检查一个除法的分母不为零也许很重要;对别人来说,这则是很好笑的事情。所以唯有经过你自己的判断,这个工具才派得上用场。
另一件事:在项目的生命周期里,把除错检查宏放进程序中-除非程序已经发行了,不然不要拿掉除错检查宏。这些检查在你开始为下一版本的发行作准备时会再度变成很宝贵的基础。
快速回顾
维护程序的发行版跟除错版。发行版用来打包发行,除错版用来尽可能快速抓虫。 ?
除错检查宏是写出除错检查的快速方式。用它们来捕捉不应该发生的非法状况。不要把这些状况跟错误状况混在一起;错误状况在最后的产品中一定得被处理掉。
用除错检查宏来核对函式参数,并警告程序原有东西是未定义的。你愈严格定义你的函式,核对它的参数就愈简单。
一旦你写好了一个函式,把它重新检查一遍,并问问你自己, " 我假设了什么条件一定成立的吗? " 如果你找到一个假设条件,用除错检查宏检查这条件是否永远成立,或者重写程序以去除这样的假设。也问问自己, " 程序中哪里最可能出错,我怎样才能自动找到错误? " 尽可能将捕捉错误的测试放在愈早执行到的地方愈好。
教科书鼓励程序员防御性的写程序,可是记住这种写作方式会隐藏错误。当你写出防御性的程序时,用除错检查宏来提醒你自己是否有 " 不可能 " 发生的状况发生了。
该想想的事
1. 假设你维护一套共享链接库,你要把除错检查宏加进去,可是你不想把链接库原始码公开出来。你该如何定义 ASSERTMSG 这个用来显示一段有意义讯息的宏,取代本来显示的档名跟行号数呢?例如 memcpy 子程序也许会显示下面除错维护讯息:
Assertion failure in memcpy: The blocks overlap
2. 每当你使用 ASSERT 时, __FILE__ 宏产生一个唯一的文件名字符串。这表示说,如果你在同个档案中使用了 73 次除错检查宏,编译器就会产生同个文件名字符串的 73 个复制体。你该怎样实作 ASSERT 宏,让文件名在每个档案中只会被定义一次?
3. 底下函式中的除错检查宏哪里错了?
4. /* getline - 读取以\n中止的一行文字到缓冲区中。 */
5. void getline(char *pch)
6. {
7. int ch; /* ch must be an int. */
8.
9. do
10.
11. ASSERT((ch = getchar()) != EOF);
12. while ((*pch++ = ch) != n');
}
13. 当程序员们在一个资料列举型态中加入了新元素,它们有时会忘了在适当的 switch 叙述中处理新的元素条件。怎样使用除错检查宏才能找到这种错误?
14. CheckIdInst 检查 IdInst 指令表中的项目是否排列正确,可是指令表中会出现的问题并不只有这一种。有那么多个指令,打错屏蔽或指令格式的数值是很容易发生的事情。你该如何加强 CheckIdInst 来检查这样子打错的东西?
15. 稍早我们看到 EOR 指令的有效地址模式字段的值是 001 时,真正表示的是个 CMPM 指令。还有其它指令也包含在我们使用的 EOR 指令格式中,像 EOR 的那个两位的模式字段的值就不能是 11 (不然就变成 CMPA.L 指令了),而有效地址模式字段如果是 111 ,有效地址缓存器字段就必须是 000 或 001. 由于 pcDecodeEor 永远不会碰到这些不是 EOR 的组合,你该怎样加上除错检查宏来捕捉表格中的这些错误?
16. 怎样用第二种算法来查对 qsort 函式的结果?怎样查对一个二元搜寻子程序的结果?怎样查对 itoa 函式的结果?
学习计划:
联络你的操作系统厂商,鼓励它们提供一套除错版本的操作系统给程序员们。这样子一来对于双方都有好处,因为操作系统厂商希望人们替他们的操作系统写应用程序,这样子可以让这个操作系统上执行的产品更容易出现于市面。