程序猿
通过调用这些关键的入口点来实现与子系统的通信。因此假设在程序中使用这种子系统而且在其调用点加上了调试检查,那么不须要花费多少力气就能够进行很多错误检查。当子系统编写完毕后,要问自己:“程序猿什么情况下会错误地使用这个子系统。在这个子系统中如何才干自己主动检查出这些问题?”在这篇文章中。将讲述一些用来肃清子系统中错误的技术。使用这些技术。能够免除很多麻烦。本章将以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不仅能够有助于错误的再现,并且经常使错误被非常easy的发现出来。比如当你调试跟踪时。发现某个值是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。假设编写的子系统中某些事情可能发生,那么要为子系统加上对应的调试代码,使这些事情一定发生。这样对于那些通常得不到运行的代码。能够提供检查出错误的可能性。
最后依然以一句话结束这篇文章:
错误处理程序之所以往往easy出错,正是由于它们非常少被运行到。