就是这样。
现在,我们有了相应内存系统的完整记录。利用这些信息,我们可以很容易地编写出象sizeofBlock和fValidPointer(见附录B)这样的函数,以及任何其它的有用函数。资料个人收集整理,勿做商业用途
保存调试信息,以便进行更强的错误检查 不要等待错误发生
直到目前为止,我们所做的一切努力只能帮助用户注意到错误的发生。这固然不错,但它还不能自动地发现错误。以前面讲过的deletenode函数为例,如果该函数调用函数FreeMemory释放某个结点时,在相应的树结构中留下了指向已释放内存空间的指针,那么在这些指针永远都不会被用到的情况下,我们能够发现这个问题吗?不,不能。又如,如果我们在函数fResizeMemory中忘了调用FreeMemory,又会怎样?资料个人收集整理,勿做商业用途 ……
if( fNewMemory(&pbNew, sizeNew) ) { }
结果会在该函数中产生一个难解的错误。说它难解,是因为表面看起来,什么问题都没有。但我们每次执行这段程序,就会“丢失”一块内存空间。因为在把pbNew赋给*ppb时,这个唯一指向该内存块的指针被冲掉了。那么该函数中的调试代码能够帮助我们查出这个错误吗?根本不能。资料个人收集整理,勿做商业用途 这些错误与前面讲的错误不同,因为它们不会引起任何不合法情况的发生。正如匪徒根本没打算出城,路障就没用了一样,在相应数据没被用到的情况下相应的调试代码也没用,因为它查不出这些错误。查不到错误并不意味这些错误不存在,它们确实存在只不过我们没有看到它们 ─── 它们“隐藏”得很深。资料个人收集整理,勿做商业用途 要找出这些错误,就得象程序员一样,对错误进行“挨门挨户”的搜查。不要等待错误自己暴露出来,要在程序中加上能够积极地寻找这种问题的调试代码。资料个人收集整理,勿做商业用途 memcpy(pbNew, *ppb, sizeOld) /* FreeMemory(*ppb); */ *ppb = pbNew;
对于上面的程序我们遇到两种情况。第一种情况,我们得到一个指向已被释放了的内存块的“悬挂指针”;第二种情况,我们分配了一个内存块,但却没有相应的指针指向它。这些错误通常都很难发现,但是如果我们在程序中一直保存有相应的调试信息,就可以比较容易地发现它们。资料个人收集整理,勿做商业用途 让我们来看看人们是怎样检查其银行财务报告书中的错误:我们自己有一个拨款清单,
16 / 24
银行有一个拨款清单。通过对这两个清单进行比较,我们就可以发现其中的错误、这种方法同样可以用来发现悬挂指针和内存块丢失的错误。我们可以对已知指计表(保存在程序的调试信息中)进行比较,如果发现指针所引用是尚未分配的内存块或者相应的内存块没有被任何指针所指向,就肯定出了问题。资料个人收集整理,勿做商业用途 但程序员,尤其是有经验的程序员总是避免直接对存储在每个数据结构中的每个指针进行检查。因为要对程序中的所有数据结构以及存储在其中的所有指针进行跟踪,如果不是不可能的话,似乎也非常困难。实际的情况是,即使某些编写得很差的程序,也是为指针再单独分配相应的内存空间,以便于对其进行检查。资料个人收集整理,勿做商业用途 例如,68000汇编程序可以为753个符号名分配内存空间,但它并没有使用753个全局变量对这些符号名进行跟踪,那样会显得相当的愚蠢。相反,它使用的是数组、散列表、树或者简单的链表。因此,尽管可能会有753个符号名,但利用循环可以非常简单地遍查这些数据结构,而且这也费不了多少代码。资料个人收集整理,勿做商业用途 为了对相应的指针表和对应的调试信息进行比较,我定义了三个函数。这三个函数可以同上节给出的信息收集子程序(读者在附录B中可以找到它们的实现代码)配合使用:资料个人收集整理,勿做商业用途 /* 将所有的内存块标记为“尚未引用” */ void ClearMemoryRefs(void);
/* 将pv所指向的内存块标记为“已被引用” */
void NoteMemoryRef(void* pv);
/* 扫描引用标志,寻找被丢失的内存块 */ void CheckMemoryRefs(void);
这三个子程序的使用方法非常简单。首先,调用ClearMemoryRefs把相应的调试信息设置成初始状态。其次,扫描程序中的全局数据结构,调用NoteMemoryRef对相应的指针进行确认并将其指向的内存块标记为“已被引用”。在对程序中所有的指针这样做了之后,每个指针都应该是有效的指针,所分配的每个内分块都应该标有引用标记。最后,调用CheckMemroyRefs验证某个内存块没有引用标记,它将引发相应的断言,警自用户相应的内存块是个被丢失了的内存块。资料个人收集整理,勿做商业用途 下面我们看看在本章前面介绍的汇编程序中,如何使用这些子程序对该汇编程序中使用的指针进行确认。为了简单起见,我们假定该汇编程序所使用的符号表是棵二叉树,其每个结点的形式如下:资料个人收集整理,勿做商业用途 /* “symbol”是一个符号名的结点定义。 * 对于用户汇编源程序中定义的每个符号, * 都分配一个这样的结点 typedef struct SYMBOL {
struct SYMBOL* psymRight; struct SYMBOL* psymLeft;
17 / 24
char* strName; ……
/* 结点的正文表示 */
}symbol; /* 命名方法:sym,*psym */
其中只给出了三个含有指针的域。头两个域是该结点的左子树指针和右子树指针,第三个域是以零字符结尾的字符串。在我们调用ClearMemoryRefs时,该函数完成对相应树的遍历,并将树中每个指针的有关信息记载下来。完成这些操作的代码破封装在一个调试专用的函数NoteSymbolRefs中,该函数的形式如下:资料个人收集整理,勿做商业用途 void NoteSymbolRefs(symbol* psym) { }
该函数对符号表进行先序遍历,记下树中每个指针的情况。通常,符号表都被存储为中序树,因此相应地应该对其进行中序遍历。但我这里使用的是先序遍历,其原因是我想在引用psym所指内容之前,对其有效性进行确认,这就要求进行先序遍历。如果进行中序遍历或者后序遍历,就会在企图对psym进行确认之前引用到其指向的内容,从而可能在进行了多次的递归之后,使程序失败。当然,这样也可以发现错误。但跟踪一个随机的错误和跟踪一个断言的失败,你宁愿选择哪一个呢?资料个人收集整理,勿做商业用途 在为其它的数据结构编写了“Note-Ref”这一类的例程之后,为了便于在程序的其它地方进行调用,应该把它们合并为一个单独的例程。对于这个汇编程序,相应的例程可以有如下的形式资料个人收集整理,勿做商业用途 #ifdef DEBUG
void CheckMemoryIntegrity(void) {
/* 将所有的内存块标记为“尚未引用” */ ClearMemoryRefs();
/* 记载所有的已知分配情况 */ NoteSymbolRefs(psymRoot); NoteMacroRefs(); ……
18 / 24
if(psym!=NULL) { }
/* 在进入到下层结点之前先确认当前的结点 */ NoteMemoryRef(psym);
NoteMemoryRef(psym->strName); /* 现在确认当前结点的子树 */ NoteSymbolRefs(psym->psymRight); NoteSymbolRefs(psym->psymLeft);
NoteCacheRefs(); NoteVariableRefs();
/* 保证每个指针都没有问题 */ CheckMemoryRefs(); } #endif
最后一个问题是:“应该在什么时候调用这个例程?”显然,我们应该尽可能多地调用这个例程,但其实这要取决于具体的需要。至少,在准备使用相应的子系统之前,应该调用这一例程对其进行一致性检查。如果能在程序等待用户按键、移动鼠标或者拨动硬件开关期间,对相应的子系统进行检查,效果会更好。总之,要利用一切机会去捕捉错误。资料个人收集整理,勿做商业用途
建立详尽的子系统检查并且经常地进行这些检查 非确定性原理
我经常向程序员解释使用调试检查是怎么回事。在我解释的过程中,有时他或她会因为所加入的调试代码会对原有的代码产生妨碍,而对增加这种代码可能带来的不良后果的严重程度表示担忧。这又是一个与Heisenberg提出的“非确定性原理”有关的问题。如果读者对这一问题感兴趣,请继续读下去。资料个人收集整理,勿做商业用途 毫无疑问,所加入的调试代码会引起程序交付版本和调试版本之间的区别。但只要在加入调试代码时十分谨慎,并没有改变原有程序的内部行为,那么这种区别就不应该有什么问题。例如虽然fResizeMemory可能会很频繁地移动内存块,但它并没有改变该函数的基本行为。同样,虽然fNewMemory所分配的内存空间会比用户所请求的多(用于存放相应的日志信息),但这对用户程序也不应该有什么影响。(如果你指望请求分配 21个字节,fNewMemory或者malloc就应该恰好为你分配21个字节,那么无论有没有调试代码你都会遇到麻烦。因为要满足对齐要求,内存管理程序分配的内存总是要比用户请求的量多)资料个人收集整理,勿做商业用途 另一个问题是调试代码会增加应用程序的大小,因此需要占用更多的RAM。但是读者应
19 / 24
该记得,建立调试版本的目的是捕捉错误,而不是最大限度地利用内存。对于调试版本来说,如果无法装人最大的电子表格,无法编辑最大可能的文档或者没法做需要大量内存的工作也没有什么关系,只要相应的交付版本能够做到这些就可以。使用调试版本会遇到的最坏情况,是相对交付版本而言,运行不久便耗尽了可用的内存空间,使程序异常频繁地执行相应的错误处理代码;最好的情况,是调试版本很快就捉住了错误,几乎没有或者花费很少的调试时间。这两种极端情况都有价值。资料个人收集整理,勿做商业用途
一点就透
Robert Cialdini博土在其“Influence:How and Why people Agree to Things”一书中指出:如果你是个售货员,那么当顾客来到你负责的男装部准备购买毛衣和套装时,你应该总是先给顾客看套装然后再给顾客看毛衣。这样做的理由是可以增加销售额,因为在顾客买了一件$500元的套装之后,相比之下,一件$80元的毛衣就显得不那么贵了。但是如果你先给顾客看毛衣,那么$80元一件的价格可能会使其无法接受,最后也许你只能卖出一件$30元的毛衣。任何人只要花30秒的时间想一想,就会明白这个道理。可是,又有多少人花时间想过这一问题呢?资料个人收集整理,勿做商业用途 同样,一些程序员可能会认为,bGarbage选为何值并不重要,只要从过去用过的数中随便挑一个就行了。另外一些程序员也可能会认为,究竟是按先序、中序还是后序对符号表进行递归遍历并不重要。但正如我们在前面指出的那样,有些选择确实比另外的一些选择要好。资料个人收集整理,勿做商业用途 如果可以随意地选择实现细节的话,那么在做出相应的选择之前,要先停下来花30秒钟考查一下所有的可能选择。对于每一种选择,我们都要问自己:“这种选择是会引起错误,还是会帮助发现错误?”如果对bGarbage的取值问过这一问题的话,你就会发现选择0会引起错误而选择OxA3之类的值则会帮助我们发现错误。资料个人收集整理,勿做商业用途
仔细设计程序的测试代码,任何选择都应该经过考虑 无需知道
在对子系统进行测试时,为了使用相应的测试程序,你可能遇到过需要了解这些测试程序各方面内容的情况。fValidPointer的使用就是这样一个例子。如果你不知道有这样一个函数,就根本不会去使用它。然而,最好的测试代码应该是透明的代码,不管程序员能否感觉到它们的存在,它们都会起作用。资料个人收集整理,勿做商业用途 假定一个没有经验的程序员或者某个对项目不熟悉的人加入了项目组。在根本不知道
20 / 24