当子系统编写完成后,要问自己:“程序员什么情况下会错误地使用这个子系统,在这个子系统中怎样才能自动检查出这些问题?”在这篇文章中,将讲述一些用来肃清子系统中错误的技术。使用这些技术,可以免除许多麻烦。本章将以C的内存管理程序为例,但所得到的结论同样适用于其它子系统。
通常,我们可以直接在子系统中加入相应的测试代码,但是有时我们无法得到子系统的源代码。所以这里我们将利用所谓的“外壳”函数把内存管理程序包装起来,并在这层包装的内部加上相应的测试代码。
首先以malloc的外壳函数fNewMomory为例:
flag fNewMemory(void** ppv,size_t size) { byte** ppb=(byte**)ppv; *ppb=(byte*)malloc(size); return (*ppb!=NULL); }
从fNewMemory的定义我们可以看出,以前我们需要这样调用malloc: pbBlock=(byte*)malloc(32);而现在如果使用fNewMemory,就需要这样调用,fNewMemory(&pbBlock,32)。同时,malloc通过判断pbBlock是否为NULL指针来判断分配内存是否成功,而fNewMemory直接通过函数的返回值来进行判断。这样设计是有原因的,笔者将会在后面的文章详细说明。
<<编程精粹--编写高质量的C语言代码(2):自己设计并使用断言(一)>>中讲过,对于未定义的特性,要么将其从程序设计里去掉,要么利用断言来验证其不会被用到。ANSI C中的malloc的未定义特性有两点:1,当分配内存块的大小为0时,其结果未定义;2,当内存块分配成功后,内存块的初始内容未定义。对于第一点,我们可以使用断言来进行检查,但是对于第二点,我们无法用断言来进行验证。那如果我们人为地利用一个常规值(例如0)来填充这个内存块,这样就可以消去这个未定义的特性。但是这样至少带来两点影响:1,对内存块填充一个常规值有可能会影响程序的结果。2,有可能会隐瞒错误(例如程序员在分配内存后未初始化,但是由于事先对内存块填充了一个值,所以程序可能正常运行,从而隐瞒错误)。
但是,无论如何我们还是不希望内存块的初始内容未定义,因为这样意味着错误难以再现。因为有可能程序只有在某个特定的初始值时才出错。这样程序大部分时间都发现不了错误,但总是不明原因地失败。暴露错误的关键就是消除错误发生的随机性。所以对于malloc来说,只有对其分配的内存块进行填充,才能消除其随机性。但是又要避免填充值对程序造成影响或者隐瞒程序中的错误,所以填充值应该离奇地看起来像无用信息。而且这种填充应该在程序的调试版本中,这样既可以解决问题,又不影响程序的发行版。在基于Intel 80x86的机器上,作者推荐这个值为OxCC。
所以新版本的fNewMemory的代码如下:
#define bGarbage 0xCC 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不仅可以有助于错误的再现,而且常常使错误被很容易的发现出来。例如当你调试跟踪时,发现某个值是0xCC,是不是让你瞬间想到这是个未初始化的数据。因此要查看子系统,确定子系统中引起随机错误的设计之处。一旦发现了这些地方,就可以通过改变相应的设计方法来把它们排除,或者在他们周围加上调试代码,最大限度地减少错误行为的随机性。
要消除错误的随机性--使错误可再现
接下来是内存释放函数free的外壳函数FreeMemory,在ANSI C中,如果传递给free函数的指针是个无效指针,那么free函数的结果是未定义的。所以对于未定义的特性,我们要么改变设计以消除未定义的特性,要么使用断言检查未定义的特性不会被使用。同时,还有一点需要注意:即使我们把内存释放了,但是如果还有其他指针指向这块内存,而且继续对这块内存进行访问,得到的似乎还是有效数据。所以已经释放了的无用内存仍然包含着好像有效的数据,这将让我们程序错误,并且难以发现。
void FreeMemory(void* pv) { ASSERT(pv!=NULL); #ifdef DEBUG { memset(pv,bGarbage,sizeofBlock(pv)); } #endif free(pv); }
FreeMemory 中首先检查pv是否为空指针,作者不赞成为了实现方便,就把无意义的空指针传给FreeMemory函数,所以用断言检查pv不能为空指针,接着加入调试代码,把即将被释放的内存用垃圾填充。这样当我们对已经被释放的内存块进行访问时,得到的就是垃圾信息。这样有助于我们发现错误。这里用到的sizeofBlock函数是需要我们自己编写的调试函数,用来获取指针所指向内存块的大小。
再来看realloc的外壳函数fResizeMemory,fResizeMemory函数用来改变内存块的大小。fResizeMemory可以是缩小内存,也可以是扩大内存。基于上面的分析,我们可以写出这样的代码:
flag fResizeMemory(void** ppv,size_t sizeNew) { byte** ppb=(byte**) ppv; byte* pbResize; #ifdef DEBUG size_t sizeOld; #endif ASSERT(ppb!=NULL&&sizeNew!=0); #ifdef DEBUG { sizeOld=sizeof(*ppb); /** 如果缩小,冲掉尾部无用的内存 */ if(sizeNew<sizeOld) { memset((*ppb)+sizeNew,bGarbage,sizeOld-sizeNew); } } #endif pbResize=(byte*)realloc(*ppb,sizeNew); if(pbResize!=NULL) { #ifdef DEBUG { /** 如果扩大,对尾部增加的内容用无用信息填充 */ if(sizeNew>sizeOld) { memset((*ppb)+sizeOld,bGarbage,sizeNew-sizeOld); } } #endif *ppb=pbResize; } return (pbResize!=NULL); }
代码中有一点需要说明,就是sizeOld这个用于调试的局部变量。用#ifdef来保证sizeOld只有在程序调试时才可以使用,当程序交付版本中不小心使用了这个变量,就会获得一个编译错误。上面的程序代码尽管看上去有些复杂,但是调试版本本来就不必短小精悍。一般可以在程序中加上你认为有必要的任何调试代码,以增强程序的查错能力。
冲掉无用信息,以免被错误地使用。
但是上述程序还有一个隐藏的非常深的错误。ANSI C中说明了realloc扩大内存时有可能会让原有的内存块进行移动,也就是说扩大后的内存块有可能被分到新的地址处,该块原有的内容被拷贝到新的位置。这会导致什么后果呢?想象一下,如果有两个指针p,q,它们都指向同一块内存,然后realloc把指针p作为参数,对这块内存进行扩大,而此时内存块发生了移动,p指向了新的内存块位置,而q仍然指向的是原来的内存块位置,而原来的内存块位置其实已经被释放了,但是数据可能看起来仍然有效。更要命的是,realloc的这个特性可能很少发生,所以你的程序是震荡的,时而正确,时而出错。
你可能给出一种解决方案:在fResizeMemory中加入调试代码,如果内存块发生移动时,就把原来的内存块用无用信息填充,当我们对原来的内存块进行访问时,得到无用信息,就会发现这个错误。很遗憾,这种方案是不行的,因为原来的内存块是内存管理程序自己释放的,我们不知道内存管理程序会对其释放了的内存空间如何处理。一旦我们动了这部分内存空间,就会有破坏整个系统的危险。
尽管上面描述的realloc的这个特性可能很少发生,但是我们编写无错代码的一个准则就是:“不要让事情很少发生”。因此我们需要确定子系统可能发生哪些事情,并且使他们经常发生和一定发生。如果确实发现子系统中极罕见的行为,要千方百计地使其重现。
对于realloc的这个特性,我们无法控制让realloc经常移动内存块,但是我们可以在调试代码中模仿realloc的这个特性,我们在realloc扩大内存块时,通过先新建一个新的内存块,然后把原来内存块的内容拷贝到这个新的内存块,最后释放掉原有的内存块,就可以准确的模仿出realloc的全部动作。
flag fResizeMemory(void** ppv,size_t sizeNew) { byte** ppb=(byte**) ppv; byte* pbResize; #ifdef DEBUG size_t sizeOld; #endif; ASSERT(ppv!=NULL&&sizeNew!=0); #ifdef DEBUG { sizeOld=sizeofBlock(*ppb); if(sizeOld>sizeNew) { memset(ppb+sizeNew,bGarbage,sizeOld-sizeNew); } else if(sizeOld<sizeNew) { byte* pbNew; /** 模拟realloc的内存块移动 */ if(fMemoryNew(&pbNew,sizeNew)) { memcpy(pbNew,*ppb,sizeOld); FreeMemory(*ppb); *ppb=pbNew; } } } #endif pbResize=(byte*)realloc(*ppb,sizeNew); /** 后面代码省略 */ }
上面的程序代码不仅使相应的内存发生了移动,而且还充掉了原有内存块的内容,因为它调用了FreeMemory释放原有内存块的同时,该内存块的内容也会被垃圾信息填充。还有一点需要说明,即使我们通过移动内存块的位置模仿了realloc的行为,但是我们还是调用了realloc函数,因为调试代码只是多余的代码,而不是不同的代码,除非有非常值得考虑的理由,否则永远执行原有的非调试代码。毕竟查出代码错误的最好方法是执行代码,所以我们尽可能执行原有的非调试代码。
可能你还是对上述做法的原因不是很清楚,笔者的理解是:realloc扩大内存块可能让内存块的位置发生移动,但是realloc的这个特性很少发生,所以你的程序有可能长时间都是正确的,但是一旦realloc的这个特性发生了,有可能你的程序就会发生错误。那为了我们的程序能够在这种情况下仍然成功,那我们在程序的调试版本中,通过模拟realloc这个特性,检查我们程序中是否存在错误。如果程序能够正常运行,那我们就不用担心程序的交付版本中realloc的这个特性了,因为我们已经在调试版本中考虑过了。所以如果某件事情很少发生,这并没有什么问题,只要在程序的调试版本中不少发生就行了。
如果某件事甚少发生的话,设法使其经常发生。
总结:
1,考察所编写的子系统,问自己:“在什么样的情况下,程序员在使用这些子系统时会犯错误。”在系统中加上相应的断言和确认检查代码,以捕捉难以发现的错误和常见的错误”。
2,找出程序中可能引起随机行为的因素,将它们从程序的调试版本中清除。这样至少每次程序出错时,都会得到同样的错误结果。
3,如果编写的子系统释放了内存(或其他资源),并因此产生了“无用信息”,那么要把它搅乱,使它真的像无用信息。否则,这些被释放了的数据就有可能仍被引用,而又不会引起注意。
4,如果编写的子系统中某些事情可能发生,那么要为子系统加上相应的调试代码,使这些事情一定发生。这样对于那些通常得不到执行的代码,可以提供检查出错误的可能性。
最后依旧以一句话结束这篇文章:
错误处理程序之所以往往容易出错,正是因为它们很少被执行到。