第3章 为子系统设防
在上一章中我们说过瓶颈子程序是加入断言的绝佳之处,因为它可以使我们用很少的代码就能够进行很彻底的错误检查。这就好象一个足球场,虽然可以有50000个球迷来看球,但如果检票人员站在球场的入口,那么只需要几个检票人员就够了。程序中也有这样的入口,这就是子系统的调用点。
例如,对于文件系统,用户可以打开文件、关闭文件、读写文件和创建文件。这是五个基本的文件操作,这些操作通常需要大量复杂代码的支持。有了这些基本的操作,用户就可以通过对它们的调用来完成相应的文件操作,而不必操心文件目录、自由存储空间映射或者特定硬件设备(如磁盘驱动器、磁带驱动器或联网设备)的读写等实现细节。资料个人收集整理,勿做商业用途 又如,对于内存管理程序,用户可以分配内存、释放内存,有时还可以改变分配了的内存的大小。这些操作同样需要许多代码的支持。资料个人收集整理,勿做商业用途 通常,子系统都要对其实现细节进行隐藏,所隐藏的实现细节可能相当复杂。在进行实现细节隐藏的同时,子系统为用户提供了一些关键的入口点。程序员通过调用这些关键的入口点来实现同子系统的通讯。因此如果在程序中使用这样的子系统并且在其调用点加上了调试检查,那么不用花很大力气就可以进行许多的错误检查。资料个人收集整理,勿做商业用途 例如,假如要求你为标准的C运行时间库编写malloc、free和realloc子程序(有时必须做这件事情),那么你可能会在代码中加上断言。你可能进行了彻底的测试,并已编写了极好的程序员指南。尽管如此,我们知道在使用这些程序时,用户还是会遇到问题。那么为了对用户有所帮助,我们可以作些什么呢?资料个人收集整理,勿做商业用途 这里给出的建议是:当子系统编写完成之后,要问自己:“程序员什么情况下会错误地使用这个子系统,在这个子系统中怎样才能自动地检查出这些问题?”在正常情况下,当开始编码排除设计中的危险因素时就应该问过了这个问题。但不管怎样,还应该再问一次。资料个人收集整理,勿做商业用途 对于内存管理程序。程序员可能犯的错误是: ? 分配一个内存块并使用其中未经初始化的内容; ? 释放一个内存块但继续引用其中的内容;
? 调用realloc对一个内存块进行扩展,因此原来的内容发生了存储位置的变化,但程序引用的仍是原来存储位置的内容;资料个人收集整理,勿做商业用途 ? 分配一个内存块后即“失去”了它,因为没有保存指向所分配内存块的指针; ? 读写操作越过了所分配内存块的边界; ? 没有对错误情况进行检查。
这些问题并不是臆想出来的,它们每时每刻都存在。更糟的是,这些问题都具有不可再现的特点,所以很难发现。出现一次,就再也看不到了。直到某一天,用户因为被上面某个常见问题搞得一筹莫展而怒气冲冲地打电话来“请”你排除相应的错误时,才会被再次发现。
1 / 24
资料个人收集整理,勿做商业用途 确实,这些错误都很难发现。但是,这并不是说我们没有什么可以改进的事情了。断言确实很有用,但要使断言发挥作用就必须使其能够被执行到。对于我们上面列出的问题,内存管理程序中的断言能够查出它们吗?显然不能。资料个人收集整理,勿做商业用途 在这一章中,将介绍一些用来肃清子系统中错误的其它技术。使用这些技术,可以免除许多麻烦。本章虽然以C的内存管理程序为例进行阐述,但所得到的结论同样适用于其它的子系统,无论是简单的链表管理程序,还是个多用户共享的正文检查工具都适用。资料个人收集整理,勿做商业用途
若隐若现,时有时无
通常,解决上述问题的方法是直接在子系统中加上相应的测试代码。但是出于两个理由,本书并没有这么做。第一个理由是我不想让例子中到处都是malloc、free和realloc的实现代码。第二个理由是用户有时得不到所用子系统的源代码。我之所以会这么说,是因为在用来测试本书例子的六个编译程序中,有两个提供了标准的源代码。资料个人收集整理,勿做商业用途 由于用户可能得不到子系统的源代码,或者即使能够得到,这些源代码的实现也未必都相同,所以本书不是直接在子程序的源代码中加上相应的测试代码,而是利用所谓的“外壳”函数把内存管理程序包装起来,并在这层包装的内部加上相应的测试代码。这就是在得不到子系统源代码的情况下所能采用的方法。在编写外壳函数时,将采用本书前面介绍过的命名约定。资料个人收集整理,勿做商业用途 下面我们先讨论malloc的外壳函数。它的形式如下: /* fNewMemory ─── 分配一个内存块 */ flag fNewMemory(void** pv, size_t size) { }
该函数看起来比malloc要复杂,这主要是其指针参数void**带来的麻烦。但如果你看到程序员调用这一函数的方法,就会发现它比malloc的调用形式更清晰。有了fNewMemory,下面的调用形式:资料个人收集整理,勿做商业用途 if( (pbBlock) = (byte*)malloc(32) != NULL )
成功 ─── pbBlock指向所分配的内存块 else
不成功 ─── pbBlock等于NULL 就可以被代替为:
byte** ppb = (byte**)ppv; *ppb = (byte*)malloc(size); return(*ppb != NULL);
/* 成功 */
2 / 24
if( fNewMemory(&pbBlock, 32) )
成功 ─── pbBlock指向所分配的内存块 else
不成功 ─── pbBlock等于NULL
后一种调用形式与前一种功能相同。FNewMemory和malloc之间的唯一不同,是前者把调用“成功”标志与内存块分开返回,而后者则把这两个不同的输出结果合在一个参数中返回。无论上面哪种调用形式,如果分配成功,pbBlock都指向所分配的内存块;如果分配失败,则pbBlock为NULL。资料个人收集整理,勿做商业用途 在上一章中我们讲过,对于无定义的特性,要么应该将其从程序中消去,要么应该利用断言验证其不会被用到。如果把这一准则应用于malloc,就会发现这个函数的行为在两种情况下无定义,必须进行相应的处理。第一种情况,根据ANSI标准,请求malloc分配长度为零的内存块时,其结果无定义。第二种情况,如果malloc分配成功,那么它返回的内存块的内容无定义,它们可以是零,还可以是内容随机的无用信息,不得而知。资料个人收集整理,勿做商业用途 对于长度为零的内存块,处理方法非常简单,可以使用断言对这种情况进行检查。但是对于另一种情况,使用断言能够检查出所分配内存块的内容是否有效吗?不能,这样做毫无意义。因此,我们别无选择,只能将其消去。消去这个无定义行为的明显方法,是使fNewMemory在分配成功时返回一个内容全部为零的内存块。这样虽然可以解决问题,但对于一个正确的程序来说,所分配内存块的初始内容并不应该影响程序的执行结果,所以这种不必要的填零增加了交付程序的负担,因此应该避免。资料个人收集整理,勿做商业用途 不必要的填充还可能隐瞒错误。
假如在为某一数据结构分配内存时,忘了对其某个域进行初始化(或者当维护程序扩展该数据结构时,忘了为新增加的域编写相应的初始化代码)就会出现错误。但是如果fNewMemory把这些域填充为零或者其它可能有用的值,就可能隐瞒了这一错误。资料个人收集整理,勿做商业用途 不管怎样,我们还是不希望所分配内存块的内容无定义,因为这样会使错误难以再现。那么如果只有当所分配内存块中的无用信息碰巧是某个特定值时才出错,会产生什么样的结果呢?这就会在大部分的时间内发现不了错误,而程序却会由于不明显的原因不断地失败、我们可以想象一下,如果每个错误都是在某个特定的时刻才发生,要排除程序中的所有错误会多难。要是这样,程序(和测试人员)非发疯不可。暴露错误的关键是消除错误发生的随机性。资料个人收集整理,勿做商业用途 确实,如何做到这一点要取决于具体的子系统及其所涉及到的随机特性。但对于malloc来说,通过对其所分配的内存块进行填充,就可以消除其随机性。当然,这种填充只应该用在程序的调试版本中。这样既可以解决问题,又不影响程序的发行代码。然而必须记住,我们不希望隐瞒错误,所以用来填充内存块的值应该离奇得看起来象是无用的信息,但又应该能够使错误暴露。资料个人收集整理,勿做商业用途 例如对于Macintosh程序,可以使用值0xA3。选定这个值是向自己发问以下问题的结
3 / 24
果:什么样的值可以使非法的指针暴露出来?什么样的值可以使非法的计数器或非法的索引值暴露出来?如果新分配的内存块被当做指令执行会怎样?资料个人收集整理,勿做商业用途 在一些Macintosh机上,用户使用奇数的指针不能引用16或32位的值。由此可知,新选择的填充值应该是奇数。另外,如果非法的计数器或索引值较大。就会引起明显的延迟,或者会使系统的行为显得不正常,从而增大发现这类错误的可能性。因此,所选择的填充值应该是用一个字节能够表示的、看起来很奇怪的较大奇数。我选择0xA3不仅因为它能够满足上述的要求,而且因为它还是一条非法的机器语言指令。因此如果该内存块被莫名其妙地执行到,程序会立即瘫痪。此时如果是在系统调试程序的控制下,就会产生“undefined A-Line trap”错误。最后一点似乎有点象大海捞针,发现错误的可能性极小。但我们为什么不应该利每个机会,不管它奏效的可能性有多么小,去自动地进行查错呢?资料个人收集整理,勿做商业用途 机器不同,所选定和填充值也可能不同。例如在基于Intel 80x86的机器上,指针可以是奇数,所以填充值是否奇数并不重要。但填充值的选择过程是类似的,即先来确定在什么样的情况下未经初始化的数据才会被用到,然后再千方百计使相应的情况出现。对于Microsoft应用,填充值可以选为0xCC。因为它不仅较大,容易发现,而且如果被执行,能使程序安全地进入调试程序。资料个人收集整理,勿做商业用途 在fNewMemory中加上内存块大小的检查和内存块的填充代码之后,其形式如下:
#define bGarbage 0xA3
flag fNewMemory(void** ppv, size_t size) { }
fNewMemory的这个版本不仅有助于错误的再现,而且常常可以使错误被很容易地发现。如果在调试时发现循环的索引值是0xA3A3,或者某个指针的值是0xA3A3A3A3,那么显然它们都是未经初始化的数据。不止一次,我在跟踪一个错误时,由于偶然遇到了0xA3某种不期望的组合,结果又发现了另一个错误。资料个人收集整理,勿做商业用途 因此要查看应用中的子系统,以确定其引起随机错误的设计之处。一旦发现了这些地方,就要通过改变设计的方法把它们排除。或行在它们的周围加上相应的调试代码,最大限度地
4 / 24
byte** ppb = (byte**)ppv; ASSERT(ppv!=NULL && size!=0); *ppb = (byte*)malloc(size); #ifdef DEBUG { } #endif
return(*ppb != NULL);
if( *ppb != NULL )
memset(*ppb, bGarbage, size);
减少错误行为的随机性。资料个人收集整理,勿做商业用途
要消除随机特性 ─── 使错误可再现 冲掉无用的信息
free的外壳函数形式如下: void FreeMemory(void* pv) { }
根据ANSI标准,如果给free传递了无效的指针,其结果无定义。这似乎很合理,可是怎样才能知道pv是否有效呢?又怎样才能得出pv指向的是一个已分配内存块的开始地址呢?结论是没法做到,至少在得不到更多信息的情况下做不到。资料个人收集整理,勿做商业用途 事情还可能变得更糟。
假定程序维护一颗某种类型的树,其deletenode程序调用FreeMemory进行结点的释放。那么如果deletenode中有错,使其释放相应结点时没有对邻接分配结点中的链指针进行相应的修改,会产生什么样的结果?很明显,这会使树结构中含有一个已被释放了的自由结点。但这又怎么样呢?在大多数的系统中,这一自由结点仍将被看作有效的树结点。资料个人收集整理,勿做商业用途 free(pv);
这一结果应该不会使人感到特别地惊讶。因为当调用Free时,就是要通知内存管理程序该块内存空间已经不再需要,所以为什么还要浪费时间搞乱它的内容呢?资料个人收集整理,勿做商业用途 从优化的角度看,这样做很合理。可是它却产生了一个不好的副作用,它使已经被释放了的无用内存信息仍然包含着好象有效的数据。树中有了这种结点,并不会使树的遍历产生错误,而导致相应系统的失败。相反,在程序看来,这颗树似乎没什么问题,是颗有效的树。怎样才能够发现这种问题?除非你的运气同lotto数卡牌戏的获胜者一样好,否则很可能就发现不了。资料个人收集整理,勿做商业用途 “没问题”,你可能会说,“只要在freememory中加上一些调试代码,使其在调用Free之前把相应内存块都填上bGarbage就行了。那样的话,相应内存块的内容看起来就象无用信息一样,所以树处理程序遇到自由结点时就会跳出来”。这倒是个好主意,但你知道要释放的内存块的大小吗?唬,不知道。资料个人收集整理,勿做商业用途 你可能要举手投降了,承认完全被FreeMemory击败了。不是吗?既没办法利用断言检查pv的有效性,又没办法破坏被释放内存块的内容,因为根本就不知这个内存块究竟有多大。资料个人收集整理,勿做商业用途 但是不要放弃努力,让我们暂时假定有一个调试函数sizeofBlock,它可以给出任何内存分配块和大小。如果有内存管理程序的源代码,编写一个这样的函数可能并不费事。即使
5 / 24