增強子系統
一场橄榄球赛就算有五千名球迷到场观战,也只需要几个计票员-当然,前提是他们都站在球场门口前。你的程序就有这样的门口,用来进入各个程序中的子系统。
想想你用的档案系统,你开关档案,读写档案,建立档案中的资料。这其中有五个基本的档案存取动作,而支持这些动作的程序通常又大又复杂。但从这些使用档案系统功能的进入点,你不用担心档案目录怎么摆的,储存空间怎么分布的,也不用担心怎样读写档案,不管是从磁盘驱动器、磁带机还是网络上。
内存管理也是如此,你配置内存,释放内存,有时改变配置的内存块的大小,却不用管这些动作的背后是怎么处理的。
一般说来,子系统将其下可能相当复杂的实作细节隐藏,而其供给个关键进入点让程序员能跟子系统所提供的功能沟通。如果你是在这样的子系统进入点上加上一些除错检查,你就能得到实在的错误检查而不用大费周章的到处更动程序代码。
假设你得替一个标准 C 语言执行期链接库写出 malloc , free 跟 realloc 子程序。(总得有人来写这些东西的,不是吗?)你可以将这些程序加上除错检查宏,你可以彻底将它测试过,然后你可以写本一流的程序写作指导手册。不过你我都知道,即使如此,程序员们还是会在使用这些程序时碰到问题。你该怎么帮助他们?
这里有个建议:当你已经写好一个子系统时,问问你自己, " 程序员们会怎样误用这个子系统?我该怎么把这些问题自动抓出来? " 你也许在开始写程序以前就自问过这些问题,拿掉了危险的设计方式,但你得对同样的问题再自我省思一遍。对一个内存管理器而言,你可以想到程序员可能会作底下这些事:
配置一块内存,使用里头未初始化的内容。
释放一块内存后,继续使用它里头存放的东西。
呼叫 realloc 加大一块内存,却在内存块被搬移后继续使用内存块旧位置的内容。
配置内存后,没保留好内存地址的指针。
读写不在已配置内存块所属空间内的内容。
没注意到错误状况。
这些问题不是随便乱说出来的-这些问题总是层出不穷的出现着。更糟糕的,它们经常难以找出来,因为这些问题不会重复出现。
当一次机,下一次跑又不当了-至少在你程序的使用者生气要你修好这个经常当机的问题之前,你的程序没在你的机器上再当掉过。这些错误不好找,可是那不表示你改善不了这种情形。除错检查宏很好用,可是得要让这些检查跑得到才行。看看上面的情况,告诉我,在内存管理器中放入除错检查宏会有用吗?一点用也没有。
在本章中,我会谈到你能用来找寻别的方法很难找出来的子系统错误的其它技巧。我会用 C 语言的内存管理器作为例子,不过你还是可以把后头提到的技巧用到别的子系统上,无论那是个简单的串行管理器或共享的文字存取引擎。
时有时无的错误
一般说来,你会把测试检查直接写在子系统中,不过我不会这样作的理由有两点。首先,我不想用 malloc , free 跟 realloc 的实作程序代码填满这整本书。再者,你有时不可能拿得到你用的子系统的原始码。我用来测试本书中范例的半打编译器中,只有两个提供了标准链接库的原始码。
代替将测试检查写到你可能拿不到的原始码中或者写到跟我手上的原始码完全不一样的东西里头的做法,是在内存管理器提供的子程序呼叫之上加上一层包装。不管你有没有你使用的子系统的原始码,你都可以这样子作。我这样子作了以后,我在整本书中就沿用这些加盖上去的包装函式了。
这就开始提 malloc 的包装函式。这包装函式看起来就像这样:
/* fNewMemory - 配置一块内存。 */
flag fNewMemory(void **ppv, size_t size)
{
byte **ppb = (byte **)ppv;
*ppb = (byte *)malloc(size);
return (*ppb != NULL); /* 成功? */
}
这看来可能比 malloc 复杂,不过这大抵是由于 void ** 这个参数指针造成的视觉错乱引起的。如果你看过程序员怎么使用这个函式的,你就会发现这个函式并不会比呼叫 malloc 来得差。本来是写这样:
if ((pbBlock = (byte *)malloc(32)) != NULL)
成功 - pbBlock指向配置到的内存块
else
不成功 - pbBlock的值是NULL
你会写成:
if (fNewMemory(&pbBlock32))
成功 - pbBlock指向配置到的内存块
else
不成功 - pbBlock的值是NULL
这样也达到了同样效果。两个函式的唯一不同是, fNewMemory 分开了成功与否的判断跟指针结果的输出,而 malloc 把两个东西混在一起送出来。其实在两个写法里头,如果内存配置成功了, pbBlock 都会指向配置好的内存块,否则 pbBlock 就会是 NULL.
在上一章中,我说你应该消除未定义行为的存在,或者使用除错检查宏来检查未定义行为的运作。如果你将这些建议用在 malloc 上,你将看到两个必须处理的未定义状况。首先,要求 malloc 配置一块没有大小的内存(依据 ANSI 标准)是没有意义的。再者, malloc 如果传回一块内存,它不会初始化那块内存的内容-那块内存也许全部都为 0 ,或者保留着之前用过那个地方的程序留下的垃圾,反正你不知道会是哪种情形。
处理配置没大小的内存块的要求很简单。你用个除错检查宏检查就好了。不过另一个问题该怎么处理?你能检查一块内存的内容到底有没有用吗?一点意义也没有。所以你只有一个选择:消除未定义的行为。这样的做法就是让我们在 fNewMemory 中将配置好的内存全部填为 0. 这么作当然有用,可是在一个正确的程序中,配置出来的内存块一开始的内容不管是怎样子,应该都不打紧的。以不必要的填写动作加重程序的负担是应该避免的。
何况不必要的填写动作也会藏住错误。
假设你给一个数据结构配置一块内存,却为了初始化一个字段-或者一个维护这程序的人扩充了结构的字段,却忘了替新字段加上初始化的动作。这么作当然是错的,可是如果 fNewMemory 替你把这些字段都填成了 0 或别的可能有用的值,你就不会注意到这样错误的做法。
不过你还是不希望内存内容出现未定义的状态,因为那样子错误不好重现,你就不好抓出错误来。如果错误只发生在这些未定义内容的值是个特定值时,在大多数状态下你就会漏掉这只臭虫,而让程序在某个时候莫名其妙当掉。想象一下,如果每个错误都只在某些不特定时候出现,你要怎么让程序达到零错误?程序员们(跟测试员们)为了抓出这些臭虫,一定会抓狂。找出错误的关键,就是在你发现会产生随机结果的非预期行为时,消除这样的行为。
你该怎么作,决定于你所用的子系统是怎样子的,以及会产生随机结果的非预期行为是怎么来的。要消除 malloc 制造的随机行为,只要将配置出来的内存填入某个值就好了-不过你只能在除错版的程序中这么作。这样子解决了问题,而不用在你推出的程序中放上一副影响执行速度的铁链。不过必须记住,不要把错误隐藏起来。这里的构想是,内存还是照样填入一个固定值,不过这个值看起来就像垃圾,会让错误无所遁形的垃圾。
我在麦金塔的程序中使用 0xA3 的值。在我挑选这个值以前,我问过自己几个问题:怎样让一个坏指针自己暴露出来?怎样让不正确的计数器或数组索引现形?如果数组中的内容被当成程序执行,结果会怎样?
在一些麦金塔机器上,你不能在奇数地址上读写 16 位或 32 位的数值,所以我想我该挑着奇数值。我也知道,一个看起来大得足以造成系统变慢或错乱的错误计数器或数组索引会更好被找到。最后,我挑出了一个又怪异又大而又能用字节表示的奇数值, 0xA3 ,因为当内存块被当成程序执行时, 0xA3A3 是个未定义的机械语言指令,会立刻让程序当掉-你在系统除错器中会得到一个 " 未定义的 A 列陷落 " 。(译注:在 Motorola 680X0 微处理器系列上,所有 A 开头的机械码指令都会造成这个陷落例外,让系统能够处理某些东西。有些 680X0 的操作系统就靠这种特性来提供如 MS-DOS 上 INT 21h 等透过中断呼叫来提供的的系统功能。)最后一点看来有点吹毛求疵,不过有什么理由不把握任何一个能自动捉住错误的机会,不管这机会有多小呢?
在你的机器上,你挑的值可能不同。在以 Intel 80x86 为微处理器的系统上,指针可以是奇数值,用不用奇数值当作除错值就不重要了。不过挑选这个值的过程还是相似:你该省思一下未初始化的内存会怎么被用到,然后想办法让这种情形能被找出来。微软的应用程序用 0xCC 来填写刚配置出来的内存块,因为这数字够大,容易注意到,而且如果被执行到,会自动呼叫除错器的断点处理。(这个值本身就是 Intel 80x86 微处理器用来当作除错断点指令用的。)
如果你将检查大小的除错检查宏跟填写特定值到未初始化内存块的程序代码加上, fNewMemory 会变成
#define bGarbage 0xA3
flag fNewMemory(void **ppv, size_t size)
{
byte **ppb = (byte * )ppv;
ASSERT(ppv != NULL && size != 0);
*ppb = (byte *)malloc(size);
#ifdef DEBUG
{
if (*ppb != NULL)
memset(*ppb, bGarbage, size);
}
#endif
return (*ppb != NULL);
}
这一版的 fNewMemory 不只可以让错误重现,而且让这些错误更易于被找出来。如果你发现自己的循环索引的值是 0xA3A3 ,或者找到一个指针的值是 0xA3A3A3A3 ,那很清楚的就是你用到了未初始化的资料。不只一次,我找到了一个错误后,又发现另一个错误,只因为它们一连串的使用了未初始化的内存区块而读到一堆 0xA3.
所以,检查你程序中使用的子系统,找出设计上会产生随机错误的那些地方。一旦你找到了,变更你的设计来消除这些随机错误产生的原因,或者加入除错码来减低随机现象出现的机率。
消除随机行为。
让错误可重现。
辗碎垃圾
free 的包装函式长得像这样:
void FreeMemory(void *pv)
{
free(pv);
}
ANSI 标准说 free 在你传入一个非法指针时的行为是未定义的。这听来合理,可是你怎么判断 pv 是合法的?你怎么检查 pv 指向一块已配置内存的起头?答案是,你没办法,至少你得要有更多信息才作得到。
还有更糟糕的呢。
假设你的程序处理某种树状结构,而删除树状结构中某个节点的 deletenode 子程序呼叫 FreeMemory 来释放某个节点。如果 deletenode 中有个错误会让它释放了节点的内存,可是没同时更新周围那些已配置节点指向这个被释放节点的连结指针?很明显,你将有个包含已释放内存块的树状结构。还有你意想不到的,在大部分系统上,这个已释放节点会继续让你觉得它的内容是合法的。
这应该不会太惊人才对,当你呼叫 free 时,你只告诉内存管理器说你不需要那块内存了而已,它为什么要帮你把里头的垃圾处理掉?
那是个合理的最佳化做法,但对被释放的那块内存来说有个糟糕的副作用-里头都是别人丢掉的东西-让它看起来好象还是带着合法的资料。这样子并不会让你有一个进行处理时会让你的系统当掉的已释放节点的树状结构,这个树状结构的内容看起来完全正常。所以你怎么找出这种问题?除非你有买到中奖彩券的运气,不然实在不太可能找得到。
" 没问题 " ,你说, " 我在 FreeMemory 里头加上一些除错码,在内存块被释放前填上 0xA3 的值,这样子里头的内容看起来就保证像是垃圾了,而处理树状结构的子程序将会在碰到这个已释放节点时当掉。 " 好构想,不过这内存块有多大?糟糕,你不知道!
你可能会放下双手,宣布 FreeMemory 已经打败你了。毕竟你不能检查 pv 到底合不合法,因为你没办法检查。你也不能清掉已释放内存块的内容,因为你不知道它到底多大。
不要放弃得太快。假设一下,你有个除错函式 sizeofBlock 可以告诉你任何已配置内存有多大。如果你有内存管理器的原始码,你大概不用费多少力就可以写出这样的函式。不过即使你没有,也不用担心-我会提供一个本章稍后要用到的 sizeofBlock 实作。使用 sizeofBlock ,你可以在释放一块内存前先把它的内容清掉。
void FreeMemory(void *pv)
{
ASSERT(pv != NULL);
#ifdef DEBUG
{
memset(pv, bGarbage, sizeofBlock(pv));
}
#endif
free(pv);
}
这程序代码不只填写内存块的内容,还用呼叫 sizeofBlock 的副作用来核对 pv 合不合法。如果这指针是坏的, sizeofBlock 会发出警告-它能作到这点,因为它必须认得每一个已配置的内存块。
这可能很奇怪,我怎么用了个除错检查宏来检查 pv 是不是 NULL ,何况 NULL 对 free 来说是合法的参数值- ANSI 标准说这种情形下 free 什么事也不会作。理由很简单:我不相信除了方便以外,还有什么理由要将 NULL 传入函式中;这个除错检查宏只是简单的核对这里的指针内容而已。当然,你的想法可能不同,你也可以把这个检查拿掉。我所要指出的是,你不用盲目跟着 ANSI 标准所说的每一件事情。别人认为 free 应该能接受 NULL 指针,并不表示你应该强迫自己也接受这种观念。
realloc 函式是另一个会释放内存并制造垃圾的地方。底下是它的包装函式:
flag fResizeMemory(void **ppv, size_t sizeNew)
{
byte **ppb = (byte **)ppv;
byte *pbNew;
pbNew = (byte a)realloc(*ppb, sizeNew);
if (pbNew != NULL)
*ppb = pbNew;
return (pbNew != NULL);
}
类似 fNewMemory , fResizeMemory 也传回一个状态旗标,显示它是否已经成功改变了内存块的大小。假设 pbBlock 指向已配置内存,你就可以用底下的方法改变内存块的大小:
if (fResizeMemory(&pbBlock,sizeNew))
成功 - pbBlock指向新的内存块地址
else
不成功 - pbBlock指向旧地址
你应该注意到了不像 realloc , fResizeMemory 不会在操作失败时传回 NULL 指针;它把本来的指针传回来,仍然指向本来那个没改变大小的内存块。
realloc 函式(以及 fResizeMemory )令人感到有趣的地方在于它同时处理 free 跟 malloc 的功能,视你要的是扩增或缩减内存而定。在 FreeMemory 中,我在内存块释放以前把它的内容处理掉。在 fNewMemory 中,我把 malloc 配置出来的新内存块的内容填成难看的垃圾。这两件事你都得作到,好让 fResizeMemory 稳固得起来。这需要两段不同的除错码:
flag fResizeMemory(void **ppv, size_t sizeNew)
{
byte **ppb = (byte **)ppv;
byte *pbNew;
#ifdef DEBUG
size_t sizeOld;
#endif
ASSERT(ppb != NULL && sizeNew != 0);
#ifdef DEBUG
{
sizeOld = sizeofBlock(*ppb);
/* 缩减内存块大小时,把尾端的内容处理掉。 */
if (sizeNew < sizeOld)
memset((*ppb)+sizeNew, bGarbage, sizeOld-
sizeNew);
}
#endif
pbNew = (byte a)realloc(*ppb, sizeNew);
if (pbNew != NULL)
{
#ifdef DEBUG
{
/* 扩大内存块时,初始化尾端的新区域。 */
if (sizeNew > sizeOld)
memset(pbNew+sizeOld, bGarbage, sizeNew-
sizeOld);
}
#endif
*ppb = pbNew;
}
return (pbNew != NULL);
}
这看起来多了一堆程序代码,不过当你仔细观察时,你会发现到大部分都在 #idef 编译指示中。不过就算这些东西都是额外的程序代码好了,担心这样多出来的程序代码会造成什么问题都是浪费精力的事情。
除错版本不需要既小又快 ; 它们唯一的需要作到的就是稳定到让程序员跟测试者能够跑得动它们。除非程序代码变得太肥或是慢到让程序员与测试者拒绝用它,不然你可以加入任何你想要的除错码来加强程序的稳定度。如果程序代码太大了或太慢了,你可以用特定集合的除错码来产生混合版本的程序。
重要的是,检查你的子系统,找出配置或是放内存的地方,确定它处理完的内存中都是看来像垃圾的东西。
把丢掉的数据处理干净,才不会被误用。
把区域变量用 #ifdef 围起来真的很难看!
看一下 sizeOld 这个除错用的区域变量。把它用 #ifdef 括起来也许不好看,可是对于将所有除错码从发行版本的程序中移除有所帮助。当然,我晓得,如果你把 #ifdef 从那边拿掉,整个程序会更有可读性,函式当然也还是会在发行版根除错版的程序中正确执行。唯一的缺点是,在发行版的程序中,你宣告了 sizeOld 却从来没用到它。
这么作看来好好的,却有个大问题。如果维护程序的程序员没注意到 sizeOld 只是个除错用的变量而误用了它,这个没经过初始化的变量会在发行版的程序里头造成什么问题?把这样的变量用 #ifdef 围起来,你可以避免程序元误用这变量,而不会在发行版的程序要编译时让编译器发出错误讯息。
把除错用的变量用 #ifdef 围起来确实不好看,可是有助于去除一个潜在的错误。
搬家工人
假设你的程序呼叫 fResizeMemory 来扩大一个装着可变长度资料的树状结构节点,而不释放这个节点的内存。如果 fResizeMemory 将节点在扩充内存块大小时搬移了,你现在就有两个节点了:新位置上那个新的节点,跟旧位置上那个装着没处理掉的垃圾的节点。
如果程序员写的 expandnode 没注意到 fResizeMemory 可能会在扩大节点大小时搬移它的话,会发生什么事?程序员不会让整个树状结构的其它地方都维持在旧状态,周围的节点仍然指向原来那个看起来仍然有效的节点?新的节点会不会就这样被丢着,没有任何指向它的指针保留着?结果,你会有个看起来好好的,实际上却坏了的树状结构-跟一块遗失的内存。这样子不好吧?
现在你可能会想,让 fResizeMemory 在搬移内存块时毁掉旧内容不就好了?呼叫一下 memset 就能作到这件事了。
flag fResizeMemory(void **ppv, size_t sizeNew)
{
.
.
.
pbNew = (byte a)realloc(*ppb, sizeNew);
if (pbNew != NULL)
{
#ifdef DEBUG
{
/* 内存块搬移时,把旧的那一块的内容毁掉。 */
if (pbNew != *ppb)
memset(*ppb, bGarbage, sizeOld);
/* 扩大内存块时,初始化尾端的新区域。 */
if (sizeNew > sizeOld)
memset(pbNew+sizeOld, bGarbage, sizeNew-
sizeOld);
}
#endif
*ppb = pbNew;
}
return (pbNew != NULL);
}
不幸的,你不能这么作。即使你知道旧内存块的位置跟大小,你也不能把它的内容处理掉,因为你不知道内存管理器会对它掌握下的未使用内存作什么处置。有些内存管理器什么也不作,其它的则将它用来存放未使用内存块的串联资料或其它内部实作的资料。事实是,一旦你是放了内存,你就不再拥有它,也不能再去动它了。如果你去动了你释放的内存,你就得冒着系统当掉的风险。
" 我克服了其它问题,我还需要担心这个吗? " 我想式的,主要因为程序员们不知道,或经常忘记, realloc 可以搬移内存块的地址。找出这个问题是重要的。
在一个特例中,我在替微软内部的 68000 交叉汇编器加上新功能时, Word 跟 Excel 的程序员要我找出一个会随机当掉系统的老臭虫。唯一的困难在于这只臭虫很少出现,每个人都不太常碰到它,可是每个人都碰过,所以他们希望先把这个问题找出来。我不会告诉你整个细节经过,不过那花了好几个星期,到处找来找去,我才弄出一个可以重现那个错误的环境,然后我又花了三天才找出真正的出错的地方。
为了找到这样一个可以重现的错误,那真的花了很久的时间,不过我还是不知道为什么会出错,我每次追踪执行过那里,所有东西看起来都很漂亮。我不知道那些看起来很正常的资料原来是之前一个 realloc 留下来的垃圾。
不过真正的问题不在于我花了那么久的时间去找这样一个错误;而是花费了那么大的功夫才让这个错误能被准确重现。不只因为 realloc 在扩大内存块时把它搬家了,旧的内存还被重复使用,填上新的资料。在组译器中,这两件事同时发生的机率很小。
一条零错误程序写作准则这时浮现了。你不要任何机率很小的错误。你得找出子系统中可能发生的那些错误行为,确定它们真的会不会发生。常常,如果你发现子系统中有个少见的怪异行为,最好加些检查措施来盯住它。
如果 realloc 不是这么少将内存块搬来搬去的话,这个组译器的错误其实可以在几个钟头内就被发现,而不用等好几年。不过问题是,你该怎样强迫 realloc 把内存块更常搬来搬去?答案是你办不到,至少在你的操作系统允许你这么作以前,你是办不到。不过你可以仿真 realloc 的行为。如果有名程序员呼叫 fResizeMemory 来扩展一块内存的大小,你可以在 fResizeMemory 中藉由配置一块新的内存,复制旧内存块的内容到新的内存块中,然后释放本来的内存块,来达到搬移内存块地址的目标。把 fResizeMemory 改成如下的写法就能跟 realloc 的动作一模一样:
flag fResizeMemory(void **ppv, size_t sizeNew)
{
byte **ppb = (byte **)ppv;
byte *pbNew;
#ifdef DEBUG
size_t sizeOld;
#endif
ASSERT(ppb != NULL && sizeNew != 0);
#ifdef DEBUG
{
sizeOld = sizeofBlock(*ppb);
/* 如果要缩小一块内存,先将要释放掉的内存部分清理掉。
* 如果内存块要扩大,就强迫它搬到新的地址去(而不是原地
* 扩大)。
* 如果新旧大小一样,就什么都不作。
*/
if (sizeNew < sizeOld)
memset((*ppb)+sizeNew, bGarbage, sizeOld-
sizeNew);
else if (sizeNew > sizeOld)
{
byte *pbForceNew;
if (fNewMemory(&pbForceNew, sizeNew))
{
memcpy(pbForceNew, *ppb, sizeOld);
FreeMemory(*ppb);
*ppb = pbForceNew;
}
}
}
#endif
pbNew = (byte a)realloc(*ppb, sizeNew);
.
.
.
}
在上头,我已经加上了只有在扩大内存块时才会执行的程序代码。透过在释放旧内存块前配置新内存块的做法,可以确定内存块的地址一定会被搬动,除非内存配置失败。如果内存配置失败了,新的程序代码就像一大块什么都不作的程序了。
不过回想一下我在里头加了什么东西。这些新加上的程序不只强迫内存块的搬移,还-多了个副作用-会清理掉旧内存块的内容。这副作用出现在 FreeMemory 将内存块释放掉时。
现在你也许在想, " 既然这程序仿真了 realloc ,为什么它还要呼叫 realloc ? " 毕竟,你觉得应该可以在新程序代码里头加个 return 来让整个函式跑更快一些:
if (fNewMemory(&pbForceNew, sizeNew))
{
memcpy(pbForceNew, *ppb, sizeOld);
FreeMemory(*ppb);
*ppb = pbForceNew;
return (TRUE);
}
你可以那样作,没错,但是不要那样作-那是个坏习惯。记住,除错码对整个程序而言只是额外的东西,而不是让程序变得不一样的东西。除非你有令人信服的理由不执行发行版程序中的程序代码,不然你应该要让发行版程序的程序代码被执行到,即使这样作是多余的。毕竟,没有比执行一个程序更好的除错方法,你应该要尽可能让发行版程序的程序代码在除错时被执行到。
有时在我把这节的观念解释给程序员们听时,会有人说总是把内存搬来搬去跟没搬一样糟糕-他们说我只是把事情的做法写成了另一个特例而已。这样的看法值得多提一下。
如果在除错版跟发行版的程序中总是这样作或从来不这样作,当然是一样糟糕的事情。但在这例子中,发行版的 fResizeMemory 跟在除错版时的表现是不一样的。
很少出现的错误当然不构成问题,只要你能在除错版时逼它常常出现,而让它在发行版的程序中不会出现的话。
如果出现了不太常发生的错误,就制造一个环境让它经常发生吧。
保存内存配置的纪录
内存管理器的问题-从除错的观点看来-在于当你刚配置一块内存时,你虽然知道它的大小,但你会几乎立即失去这个信息,除非你在某个地方保留一份纪录。你已经晓得 sizeofBlock 这函式多么有用了,可是再想想,如果知道有多少块已配置的内存跟他们的位置,会多有用呢?如果你晓得这些信息的用处,我就可以随便丢给你一个指针,让你告诉我这指针有没有指向一个正确配置的内存块位置。想想这会多有用处啊,尤其在检查传递给函式的指针参数时。
假设你有个函式 fValidPointer ,取得一个指针跟大小参数后,它会在这个指针正确指向一块给定大小的内存时传回 TRUE 。那藉由这个函式,你就可以写出特定用途而参数检查更严格的常用函式了。举例来说,如果你发现自己经常把资料填入配置到的内存中,你就可以略过 memset 而使用你自己那个会检查指针参数正确性的 FillMemory 子程序了:
void FillMemory(void *pv, byte b, size_t size)
{
ASSERT(fValidPointer(pv, size));
memset(pv, b, size);
}
藉由呼叫 fValidPointer ,你可以确定 pv 指向一个合法的内存块,而且至少配置了 size 个字节给这内存块,这远比 memset 的 NULL 指针测试要更有力多了。这是个以速度跟程序大小交换安全性的例子。
或者,你也可以选择在除错版的程序中呼叫 FillMemory 而在发行版的程序里直接呼叫 memset. 你可以在发行版的程序中加上一行这样的宏来达到这个目的:
#define FillMemory(pb,b,size) memset((pb),(b),(size))
不过我不是要提这个。
我要说的是,如果你在除错版的程序中提供额外信息,你往往可以提供更强悍的错误检查功能。
到现在为止,你已经看过如何用 sizeofBlock 在 FreeMemory 跟 fResizeMemory 中清理内存块的内容,可是清理内存内容跟保留一份每个内存块配置的信息比较起来,前者的效力就弱多了。
再来假设一下最糟的状况:你没办法从子系统中取得内存配置的信息。对一个内存管理器而言,最糟的状况代表你不能取得一块内存的大小,你没办法分辨一个指针是否有效,你也没办法分辨有一块内存还是一堆内存块。如果你需要这些信息,你得自己提供这些东西,也就是说自己纪录配置了哪些内存。你怎么纪录不打紧,要紧的是你必须在需要的时候取得它。
这里有个维护这些纪录的可行办法:在使用 fNewMemory 配置一块内存时,多配置一块纪录项目;当你使用 FreeMemory 释放一块内存时,把纪录信息一起释放掉;当你使用 fResizeMemory 改变内存块的大小时,就更新纪录信息来反映新内存块的位置跟大小。不用惊讶,这三个动作可以被分离成三个除错接口:
/* 帮新内存块建立一笔记录。 */
flag fCreateBlockInfo(byte *pbNew, size_t sizeNew);
/* 释放一块内存的纪录信息。 */
void FreeBlockInfo(byte *pb);
/* 更新一块现成内存的信息。 */
void UpdateBlockInfo(byte *pbOld, byte *pbNew, size_t sizeNew);
当然,这些子程序怎么维护纪录信息是不太重要的事情,只要他们不会把系统拖慢到不能用的地步就好了。你可以在附录 B 中找到实作这些内存配置纪录功能的程序代码。
更动 FreeMemory 跟 fResizeMemory 来呼叫适当的内存配置纪录函式是挺简单的一件事。改过的 FreeMemory 变成
void FreeMemory(void *pv)
{
#ifdef DEBUG
{
memset(pv, bGarbage, sizeofBlock(pv));
FreeBlockInfo(pv);
}
#endif
free(pv);
}
在 fResizeMemory 中,如果 realloc 成功改变了内存块的大小,你可以呼叫 UpdateBlockInfo. 如果 realloc 失败了,就不用更新任何东西。 fResizeMemory 的尾端变成
flag fResizeMemory(void **ppv, size_t sizeNew)
{
.
.
.
pbNew = (byte a)realloc(*ppb, sizeNew);
if (pbNew != NULL)
{
#ifdef DEBUG
{
UpdateBlockInfo(*ppb, pbNew, sizeNew);
/* 扩大内存块时,初始化尾端的新区域。 */
if (sizeNew > sizeOld)
memset(pbNew+sizeOld, bGarbage, sizeNew-
sizeOld);
}
#endif
*ppb = pbNew;
}
return (pbNew != NULL);
}
修改 fNewMemory 有点复杂些,所以我留到最后才讲。当你呼叫 fNewMemory 配置内存块时,系统必须配置两块内存出来:一块给你,另一块给 fNewMemory 纪录配置给你的内存的信息。一个 fNewMemory 呼叫要成功,两个内存配置的呼叫都必须要成功才行;不然你就会有块没有纪录的内存了。这一点是重要的,因为没有纪录的内存会在任何一个有检查指针参数正确性的函式时中被除错检查宏的检查栏下来。
在接下来的程序代码中,你将看到 fNewMemory 成功配置一块内存后却没办法配置纪录用的内存块时 , 它会把第一块内存释放掉,然后伪装成内存配置失败的情形。这样让内存系统与纪录信息保持同步状态。
flag fNewMemory(void **ppv, size_t size)
{
byte **ppb = (byte **)ppv;
ASSERT(ppv != NULL && size != 0);
*ppb = (byte a)malloc(size);
#ifdef DEBUG
{
if (*ppb != NULL)
{
memset(*ppb, bGarbage, size);
/*
如果没办法配置纪录内存块信息所需的内存,
就伪装成内存配置失败的情形。
*/
if (!fCreateBlockInfo(*ppb, size))
{
free(*ppb);
*ppb = NULL;
}
}
}
#endif
return (*ppb != NULL);
}
现在你有了内存系统的完全掌控,你可以轻松写出 sizeofBlock 跟 fValidPointer 函式(你也可以参考 附录B )或任何有觉得有用的东西了。
留下除错纪录可以强化错误检查的功能。
不要等到错误被用到
到现在为止,我所建议的每个修改动作都帮你在错误出现时找出它们来。这很好,不过不是自动的。想想稍早提到的 deletenode 子程序的例子,如果这程序呼叫 FreeMemory 来释放一个节点,然后留下一个错误指针在树状结构中,如果这些指针从来没被用到,你会发现这样的错误吗?当然不会。那如果我在 fResizeMemory 中忘了呼叫 FreeMemory 呢?
if (fNewMemory(&pbForceNew,sizeNew))
{
memcpy(pbForceNew,*ppb,sizeOld);
/* FreeMemory(*ppb); */
*ppb = pbForceNew;
}
不确定原理与其它鬼怪
有时在我对程序员解释使用除错检查的观念时,有人会关切加上这样的测试码会不会有什么麻烦发生。海森堡的不确定原理这时就开始作怪了。
无疑的,除错码会在你程序的发行版跟除错版间产生差异,不过只要你小心不去动到程序的行为方式,这样的差异应该可以忽视不计。 fResizeMemory 的除错版会更常搬动内存块的位置,可是它不会改变程序的基本行为。相似的, fNewMemory 会在除错版中配置比你要的更多的内存(用来存放内存配置纪录的信息),可是它不会影响到你程序的行为。如果你希望 fNewMemory 或 malloc 确实在你要配置 21 个字节的内存时就只给你那么多内存,那不管你有没有加上除错码,都会碰到麻烦。为了遵守内存地址对齐的问题,内存管理器经常会配置比你要的更多的内存。
另一个论点是,除错码本身会让程序变胖,因而占用更多内存。不过你得记住一点,除错版本出现的用意在于找出错误来,而不是让内存发挥最大用处。也许你没办法加载你手上最大的电子表格资料或是编辑最大的文件,或者你的程序作不到任何要吃很多内存的动作,那都无妨。最糟糕的情形是,你会比正常更快耗光可用内存,比往常更常触动你的错误处理程序。最好的情形则是除错码不用太费事就快速抓出了错误的地方。
我制造了一只躲着的臭虫。它躲着,因为没有任何东西会因为这样子而出错。不过每次你执行程序时,你就会 " 失去 " 一块内存,因为唯一参考到它的指针已经在你把 pbForceNew 的值指派给 *ppb 时毁掉了。除错码能捉出这个错误吗?完全不行。
如这样的错误与其它稍早提到过的问题不同的地方在于,没有任何非法行为浮现台面。就好比坏人如果都不出城,那在出城的路上设下路障有什么用一样。到目前为止所提到过的除错码的写法,在捕捉让错误资料从来不会被用到的错误时完全没用。
要找出这些错误,你得在程序中到处查探。不是坐着等错误出现,而是自己去找。
在第一个例子中,你有一个指向已释放内存的指针。在第二个例子中,你有一块失去参考指针的已配置内存。这些错误通常不好找,不过你手上有除错信息,你可以用这些信息来找出它们来。
想想你在银行中怎么寻找错误:你有一串你认为你握在手上的资金纪录;银行有一串它认为你借了的资金纪录;只要比对两份纪录,就能找出错误来。这样子你会发现错误指针跟遗失内存块的问题其实没差别,你只要比较存放在你自己的数据结构中的已知指针的纪录跟存放在除错信息中那份已知配置内存块的纪录,你就能找出一个指针是否没指向已配置的内存,或者一块内存没有任何指针参考到它。
不过程序员们-特别是经验老到的程序员们-对这个检查每个数据结构中的指针的想法会犹豫不决,因为追踪这些东西似乎是困难的,即使可行的话。事实上,只有写得最差的程序才会把一堆指针分开来放。
举例来说,稍早提到的 68000 组译器会配置 753 个符号名称所需的内存,可是它不会追踪 753 个整体变量的值,当然不能用那么笨的方法。它用一个数组,一个杂取表,一个树状结构或一个简单的串行来作这件事。也许会有 753 个符号名称,可是要检查这些东西很简单,而且不用多大的程序代码就作得到。
要把一串存在数据结构中的指针跟除错信息中存放的内存配置串行进行比较,我定义了三个函式,与上一节提到的信息搜集子程序搭配在一起-你可以在附录 B 找到这些东西的实作:
/* 将所有内存块标示成为没被指针指到。 */
void ClearMemoryRefs(void);
/* 将一块内存标示成被pv指到。 */
void NoteMemoryRef(void *pv);
/* 检查参考旗标,找出遗失的内存块。 */
void CheckMemoryRefs(void);
这些子程序的用法很简单。首先,你呼叫 ClearMemoryRefs 将除错信息初始化。然后检查整体数据结构,并呼叫 NoteMemoryRef ,这么会作检查指针的正确性,同时标示有指针参考到的内存块。一旦完成了这个步骤,每个指针应该都被核对过了,而每一块内存也应该都有个被参考到的旗标。最后,呼叫 CheckMemoryRefs 来检查是否每个内存块都被标示成参考到了;如果 CheckMemoryRefs 发现一块未标示的内存,它就会发出警告,告诉你找到遗失的内存块了。
看看这些子程序怎么用来核对 68000 组译器中的指针吧。为了简单起见,假设组译器的符号表以二元树存放,每个节点长得像这样:
/*
* "symbol"是个符号名称的节点定义。
* 每个定义在使用者的汇编语言原始码中的符号都会配置一个节点。
*/
typedef struct SYMBOL
{
struct SYMBOL *psymRight;
struct SYMBOL *psymLeft;
char *strName; /* 文字表示式 */
.
.
.
} symbol; /* 命名方式: sym,*psym */
上头只列出三个指针字段。前两个字段式指向左子树跟右子树的指针;第三个则是个零字符结尾的符号字符串。一旦你呼叫过 ClearMemoryRefs ,你在处理这个树状结构时就得注记一下你用的每个指针。我把这些程序从一个除错专用的函式中分离出来:
void NoteSymbolRefs(symbol *psym)
{
if (psym != NULL)
{
/* 继续执行前,先检查目前节点的正确性。 */
NoteMemoryRef(psym);
NoteMemoryRef(psym->strName);
/* 检查两个子树的正确性。 */
NoteSymbolRefs(psym->psymRight);
NoteSymbolRefs(psym->psymLeft);
}
}
上头的程序代码对整个符号表以前序处理的方式进行指针的检查。正常说来,由于符号表是存成中序树状结构,我应该用中序处理的方式才对,可是我没有这么作,因为我想在跳过一个节点以前先检查它。这样作需要以前序搜寻的方式进行。如果你采用中序或后序进行的处理方式,你必须在检查 psym 以前先使用它提供给你的指针,那可能会让这函式在递归许多遍以后让系统当掉。是的,错误出现了,可是一个控制良好的除错检查宏发出的警告比一个随机性的当机更好找出原因来。
一旦你写好了其它数据结构用的 Note-Ref 子程序,将它们包装在单一个子程序中,这样你就可以从程序的任何地方呼叫它们了。对我的组译器来说,这个子程序会长得像这样:
void CheckMemoryIntegrity(void)
{
/* 将所有区块标示成未参考到。 */
ClearMemoryRefs();
/* 注记所有已知的整体内存配置。 */
NoteSymbolRefs(psymRoot);
NoteMacroRefs();
.
.
.
NoteCacheRefs();
NoteVariableRefs();
/* 确定所有东西都正常。 */
CheckMemoryRefs();
}
唯一剩下的问题是,你怎么呼叫这个子程序?显然,你得经常呼叫它,不过呼叫的时机取决于你的程序怎么写的。至少,你应该在你准备使用子系统时呼叫它。更好一点,你应该在你的程序等待使用者输入时呼叫它,不管是案件输入、鼠标移动或是拨动某个硬件开关。你也可以在同样的时机检查其它的问题有没有出现。
建立彻底的子系统检查,经常作检查。
知道了就很明显的事
Robert Cialdini 博士在他的 Influence: How and Why People Agree to Things (Morrow , 1984) 一书中指出,如果你是个销售员,有人走进你的男装店想买件毛衣跟套装,你应该先给那个人看看套装,然后再让他看毛衣。你的销售额就会更好,因为在你卖给一个人 500 元的套装后,一件 80 元的毛衣就显得很便宜了。不过如果你反过来作,你的顾客会觉得 80 元的毛衣太贵了,他要 35 元的就好了。很明显,任何人都可以在半分钟内想到为什么这么作,可是多少人会这么作呢?
同样的,有些程序员也许会觉得挑选 bGarbage 的数值是简单的事情-就挑任一个老数字就好了。其它程序员也许会觉得在处理符号表的树状结构时,不管怎么递归处理都不打紧,管他是前序、中序还是后序处理?不过,就如我前面指出的,有些选择就是比别的选择要来得好。
如果你发现自己在处理实作细节时作出了随便的选择,停下来思考半分钟,看看会发生什么事;对每件可能发生的事情,问问你自己, " 这会产生问题,还是会帮我找出问题? " 如果你对 bGarbage 值的问题作出同样的询问,你就会看到挑选 0 会制造问题,而挑选 0xA3 会帮你找出问题来。
小心设计测试检查。
没有东西是可以随便来的。
不用知道的事
你当然也会碰到过必须先知道一堆东西才会用的测试方式。 fValidPointer 就是个例子;如果你不知道有这东西,你当然就不会用它。不过最好的测试方式是透明的-无论程序员知不知道这些东西,它们都会运作着。
假设一名菜鸟程序员或不熟悉项目的人加入你的团队中。这个人难到不能在不清楚 fNewMemory , fResizeMemory 跟 FreeMemory 这些东西怎么运作的前提下使用这些测试函式吗?
如果一名新进程序员不晓得 fResizeMemory 可能会搬动内存块的位置而产生一个如我的组译器中出现的错误一样的问题呢?这个人得知道那些子系统整合度检查怎么作,才能让系统丢出一个非法指针的警告吗?
假设一名新程序员制造了一大堆遗失的内存块。再一次的,一堆检查动作了,并且告诉这个人程序中有遗失内存块的错误。这名新程序员甚至可能不晓得什么是遗失的内存块;他或她当然也不用晓得这些检查是怎么进行的。最好,经由找寻错误的根源,这名菜鸟会了解什么是遗失的内存块-而不用花一名老程序员的时间来教这名菜鸟。
这就是设计完善的子系统测试的厉害之处-当它们逮到一只臭虫时,它们会揪住这个错误,把问题报告出来,打断你正常的时间规划来处理这个问题。你去哪里找比它们回报状况报得更好的测试员呢?
积极实作透明的整合度检查。
不要把除错版的程序发行出去
我晓得在本章中对内存管理器已经加上了一大堆程序代码。有些程序写作者也许会认为, " 这些东西似乎值得,可是全都加上去的话,再加上那些纪录信息的程序代码就真的太多了。 " 我得承认,我也有过这种感觉。
我对在程序中加上这么多没效率的东西曾经有过直觉的反感,可是我很快就了解我错了。在一个公开发行版的程序中加上这些东西,当然会让它在市场上一败涂地,可是我们只在除错版的程序中加上这些检查。当然,除错码降低了执行效率,不过哪件事情比较糟糕?让你的零售产品当死在使用者面前,或者你的内部除错版在帮你找寻错误时会跑得比较慢?你应该不用太担心除错码的效率问题。毕竟,你的顾客不是拿那个版本来用的。
对使用的感觉上,区别除错版跟发行版程序是重要的。你拿除错版的程序来找寻错误,拿发行版的程序来讨好顾客。就因为这样,两个版本之间的程序代码效率跟代价也是截然不同的。
记住,当你的产品必须满足顾客对大小跟速度的需求时,你可以在自己的除错版程序中作任何你认为可以用来找出错误的事情。如果加上内存管理器对已配置内存的纪录信息可以帮你找出各种骯脏的臭虫,那一定会皆大欢喜的。你的使用者有个跑得快又好的程序,而你自己则不用花很多时间跟精力来找寻问题。
历史注记
微软惯于经常性的发出除错版的程序给公开测试人员,让它们帮忙找出更多错误。因为有本叫做 " 抢鲜版 " ( Prerelease )的杂志上的一篇报导,微软公司一度停止这么作-根据产品的除错测试版-这篇报导判定,微软的程序很好,可是跑得跟三指树獭一样慢。如果是你,当然也会觉得这是个警讯。不要把除错版丢给测试人员,不然就告诉他们这个除错版里头有些内部除错检查会拖慢执行速度。如果你的程序会显示开头讯息,最好在上头声明这一点。
微软的程序员经常在程序中加入除错码。 Microsoft Excel ,就是个例子,包含一个内存子系统测试(比这里提供的那些更完整),一个储存表整合度测试,跟人工假造内存配置的机制,让测试人员可以强迫程序执行内存用完了的错误处理程序,还有其它一大堆检查。不用说, Excel 没问题了才会推出-它有内存子系统检查-可是在给末端使用者的发行版本里头几乎不会有这些东西。
我知道我在本章中加上了一大堆内存管理器相关的程序代码,不过你可以想想:所有新的程序代码都写在包装函式 fNewMemory , FreeMemory 跟 fResizeMemory 中;没有半个是加在呼叫这些函式的程序里头,也没有半点需要加在 malloc , free 跟 realloc 的实作中。
而速度也没有你预期的下降那么多。如果微软的结果是典型的例子,那除错版的程序-加满了除错检查宏跟子系统检查的-大约有发行版本的程序执行速度的一半。
不要将发行版的限制加到除错版上。
用大小跟速度作为代价来找寻错误。
现在就把错误找出来,不要等以后再找
本章中,我提供了半打的方式来加强内存子系统的检查,不过这些做法也可以用到其它方面。想象一下怎么可能有错误跑得过彻底自我检查的程序眼线呢?同理可推,如果这些除错检查被用在我提到的那个 68000 组译器中,那个难以理解的 realloc 问题就不用花上好几年来找,而会自动在程序第一次写出来的几个小时或几天内被找到。我不在乎一名维护程序的程序员是否技术高超或是菜鸟一个;这些测试都能抓得住臭虫。
事实上,这些测试应该已经抓住所有类似的错误了。自动的,而不用任何运气或技巧。
这就是你该怎样写出零错误程序的方法。
快速回顾
检查你的子系统,问问你自己,程序员们可能会怎么误用它。加上除错维护叙述跟核对检查来捕捉不好找跟常见的错误。
如果你不重复找寻,就修不好错误。找寻任何可能造成随机行为的东西,把它们从除错版程序中拿掉。将未初始化的内存以某个垃圾值填满只是一种去除随机行为的办法,那样子如果有参考到未初始化内存的情形发生,你就可以在每次执行到出错的程序时都能重复同样的现象。
如果你的子系统释放内存(或其它资源)然后制造垃圾,请清理被释放的内存中的内容,让里头的东西看起来像垃圾;不然别处的程序可能会继续使用这些已释放内存中的东西而不被察觉。
类似的,如果你的子系统有些可能会发生却不一定发生的行为,加上除错码来确定这些行为一定会发生。让每件事都发生过可以增进你捕捉到较少执行到的程序中出现的怪异现象。
确定你的测试工作在程序员没注意到时都在进行着。最佳的测试方式就是那些完全不用在乎它们的存在的测试。
如果可能,将测试码建立在子系统里头,而不要写在它们上头。不要等到子系统已经写好了,才来想办法查对它们的动作正不正确。对每个你考虑的设计方式,问问你自己, " 我该怎样彻底查对这个实作? " 如果你发现几乎不可能或难于测试这个实作方式,好好考虑一下是不是该换个设计方式,即使那表示拿程序大小跟执行速度为代价来让系统可被测试。
在拿掉一个让程序跑得很慢或吃很多内存的查核测试前,多想一遍。记住,查核程序代码不会出现在发行版的程序里。如果你发现自己想着, " 这些测试太慢了(或太大了) " ,停下来,问问自己, " 我该怎样让这些测试继续留着,而让程序跑快一点(或变小一点)? "
该想想的事
1. 在测试程序时,你碰到了一大堆 0xA3 之中,你知道你大概用了未初始化的资料或是已经释放了的内存块。你该怎么改变除错码,让它更好决定你碰到了哪一种情形?
2. 程序员们偶尔写出会盖过已配置内存末端的程序。描述一下你该怎么增强内存子系统的检查来找出这类的问题。
3. 虽然 CheckMemoryIntegrity 子程序会抓出指向已释放内存的非法指针的问题,它也有找不到问题的时候。举例来说,假设你有个函式呼叫了 FreeMemory ,可是函式中的错误留下了一个指向已释放内存的指针。更进一步,假设在这指针被核对正确与否以前,有程序呼叫了 fNewMemory ,并重新配置了一块之前释放掉的内存。然后那个错误的指针指向了刚被配置的这块内存,虽然这个内存块已经不是这指针本来所指向的那一块了。这当然是个错误,可是 CheckMemoryIntegrity 也找不出这问题来,每件是看起来都好好的。如果这在你的程序中是个常见的问题,你该怎样增强系统来找出这些问题?
4. 借着 NoteMemoryRef 子程序,你可以查核每个程序中的指针,不过你要怎么检查内存块的大小?举例来说,假设你有个合法的指针指向一个 18 个字符长的字符串,可是内存块的大小小于那个字符串的话,你该怎么办?或者反过来,你的程序认为它配置的内存有 15 个字节的空间,可是内存置纪录信息显示你配置了 118 个字节?这通常是很不好的情形,你该怎样强化整合度检查来找出这些问题?
5. 附录 B 中的 NoteMemoryRef 子程序让你能将一块内存标示为已被参考状态,但是它不会在碰到一个内存块被参考五次而实际上只被参考一次时警告你。举例来说,一个双向连结串行应该对某个节点都有两次参考:一个是前向指针,一个是后向指针。不过在大多数情形下,你的内存块只会有一次参考,如果有超过的情形,某个地方一定出错了。你该怎样改善整合度检查来让多重参考情形能被除错检查宏判断,并找出不应该发生的情形?
6. 综观本章,我提到你可以在内存系统中加上帮程序员找出问题的除错码。可是该怎么加上程序代码来帮助测试员找出问题?测试员知道程序员们常常会搞错错误状态应该怎么处理,所以你该怎样让测试员能够伪造内存不足的情形?
学习计划:
检查你程序的主子系统。你能用怎样的除错码来捕捉跟这些子系统相关的常见错误?
学习计划:
如果你没有操作系统的除错版本,尽可能取得一份;不然运用包装功能的技巧,自己写一个。如果你觉得自己很好心,还可以把这份自己写的包装功能公开出来-以某种形式-给其它程序开发者。