首页 > 代码库 > 编程精粹--编写高质量C语言代码(4):为子系统设防(一)

编程精粹--编写高质量C语言代码(4):为子系统设防(一)

通常,子系统都要对其实现细节进行隐藏,在进行细节隐藏的同时,子系统为用户提供了一些关键入口点。程序员通过调用这些关键的入口点来实现与子系统的通信。因此如果在程序中使用这样的子系统并且在其调用点加上了调试检查,那么不需要花费多少力气就可以进行许多错误检查。

当子系统编写完成后,要问自己:“程序员什么情况下会错误地使用这个子系统,在这个子系统中怎样才能自动检查出这些问题?”在这篇文章中,将讲述一些用来肃清子系统中错误的技术。使用这些技术,可以免除许多麻烦。本章将以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,如果编写的子系统中某些事情可能发生,那么要为子系统加上相应的调试代码,使这些事情一定发生。这样对于那些通常得不到执行的代码,可以提供检查出错误的可能性。


最后依旧以一句话结束这篇文章:


          错误处理程序之所以往往容易出错,正是因为它们很少被执行到。