作者:HW-HM
上一篇文章【码手防御-7】程序界面,我们讨论了程序界面(接口)的设计,这一篇我们将讨论如何主动发现程序错误,并提供一个C语言版的内存Debug工具,方便大家在开发阶段发现内存相关的错误。
在设计一个子系统时,问问自己:程序员什么情况下会错误地使用这个子系统,在这个子系统中怎样才能自动地检查出这些问题?
程序往往会存在错误,有的错误是必现的,很容易发现;而有的错误的出现则具有随机性,运气好才能发现;更有的错误出现的几率非常小,以致于程序(实际上是程序员)几乎总能蒙对,但这样的错误也更难发现。我们所能做的,就是消除这样随机性,让错误可重现、易出现,让程序很难蒙对,这就是进攻式编程。进攻式编程是主动暴露可能出现错误的态度,在开发阶段让它显现出来,而在产品代码运行时让它能够自我恢复。编程中这种要靠运气才能发现的错误往往与内存管理有关,在这方面程序员可能犯的错误是:
l 分配一个内存块并使用其中未经初始化的内容;
l 释放一个内存块但继续引用其中的内容;
l 调用realloc对一个内存块进行扩展,因此原来的内容发生了存储位置的变化,但程序引用的仍是原来存储位置的内容;
l 分配一个内存块后即“失去”了它,因为没有保存指向所分配内存块的指针;
l 读写操作越过了所分配内存块的边界;
l 没有对错误情况进行检查。
接下来介绍一些进攻式编程的方法,并以内存管理的例子说明。
1、 确保断言语句使程序终止运行。这使得错误不能忽视,避免程序员养成一碰到问题就回车跳过的坏习惯。如下是内存Debug工具中内存释放、重分配时检查指针是否合法的断言宏,在指针非法情况下,打印出对应的文件名、行号、指针,然后终止程序。
#define ASSERT_POINTER(pv, file, line)
{
if (!IsPointerValid(pv))
{
printf('Invalid pointer: %p at [%s:%u]', pv, file, line);
abort();
}
}
2、 完全填充分配到的所有内存。内存分配后,填充上废料,这可以帮助发现一些未初始化内存引起的错误。
在通过malloc分配了一块内存后,这块内存往往会残留着上次使用留下的数据,可能会隐藏一些错误,因此有必要将这块内存使用废料进行填充。如下的函数将内存的每个字节填充为0xA3。
#define GARBAGE 0xA3
void* MemAlloc(size_t size, const char* file, unsigned int line)
{
ASSERT_SIZE(size, file, line);
void* pv = malloc(size);
if (pv)
{
memset(pv, GARBAGE, size);
if (!CreateMemBlockInfo(pv, size, file, line))
{
free(pv);
pv = NULL;
}
}
return pv;
}
3、 在删除一个对象之前把它填满垃圾数据。内存释放后,将其填上废料,可以帮助我们发现悬空指针引起的错误。
内存释放后,程序可能还维护着指向这块内存的指针,并继续引用这块内存,如果碰巧这块内存还没分配出去,程序运行可能并不会出错。如果释放后,就将其填充为废料,这种情况下出错的可能性就大大增加了。
void MemFree(void* pv, const char* file, unsigned int line)
{
ASSERT_POINTER(pv, file, line);
memset(pv, GARBAGE, SizeOfMemBlock(pv));
FreeMemBlockInfo(pv);
free(pv);
}
4、 如果某件事甚少发生的话,设法使其经常发生。就是消除随即性,让可能出现错误的情况必现。
调用realloc对一个内存块进行扩展,因此原来的内容发生了存储位置的变化,但程序仍引用原来的指针,如果内存的位置未发生变化,错误可能就掩盖起来了。如果扩种内存时,内存地址必然发生变化,这样的错误就很难隐藏。如下的函数当内存收缩时,将尾部填充废料;扩张时,则重新分配,并释放掉原来的内存。
void* MemRealloc(void* pOld, size_t newSize, const char* file, unsigned int line)
{
ASSERT_POINTER(pOld, file, line);
ASSERT_SIZE(newSize, file, line);
size_t oldSize = SizeOfMemBlock(pOld);
void* pDst = pOld;
if (newSize < oldSize)
{
memset((char*)pDst + newSize, GARBAGE, oldSize -newSize);
UpdateMemBlockInfo(pOld, newSize, file, line);
}
else if (newSize > oldSize)
{
pDst = MemAlloc(newSize, file, line);
if (pDst)
{
memcpy(pDst, pOld, oldSize);
MemFree(pOld, file, line);
}
}
return pDst;
}
5、 完全填充己分配到的所有文件或流。这有助于排查文件格式错误。
6、 确保每一个case 语句中的default分支或else 分支都能产生严重错误(如终止程序),或者至少让这些错误不会被忽视。这句话是说,如果default和else分支是非法的,一定要使这种非法不被忽视。
7、 对新代码进行逐条跟踪,并密切观察数据流,以发现错误;利用调试工具测试极少出现的情况,例如内存分配失败。
8、 让程序把它的错误日志文件用电子邮件发给你。这一点微软做得不错。
这上面的前6条,通常只用于调测版本,在发布版本是没有的,因为做这些事情会带来很大的性能消耗,而在调测版本中,是绝对有必要的。
不要把对交付版本的约束应用到相应的调试版本上要用大小和速度来换取错误检查能力:调试版本事用来发现错误的,而交付版本则是用来取悦顾客的。
另外,如果要使用这些方法,程序员需要做很多额外的工作,那么就很难落实下去,因此透明性很重要。
努力做到透明的一致性检查:最好的调测代码应该是透明的代码,不管程序员能否感觉到它们的存在,它们都会起作用。
接下来简单介绍下附件中的内存Debug工具。
#include <stdlib.h>
#ifdef _DEBUG
//将pv指向内存块标记为使用中
extern void MarkMemUsed(void* pv);
//将所有已经分配的内存块标记为使用中
extern void MarkAllMemUsed();
//检查未标记为使用中的内存,如果存在,则认为出现了内存泄漏
extern void CheckMemLeak();
#define malloc(size) MemAlloc(size, __FILE__, __LINE__)
#define realloc(pv, sizeNew) MemRealloc(pv, sizeNew, __FILE__, __LINE__)
#define free(pv) MemFree(pv, __FILE__, __LINE__)
#else
#define MarkMemUsed(pv) NULL
#define MarkAllMemUsed() NULL
#define CheckMemLeak() NULL
#endif
这个工具通过_DEBUG宏开关进行控制,开关打开,则使用工具封装的内存管理函数,会执行如上2、3、4介绍的功能;否则使用标准C语言库的标准库,并将用于内存泄漏检查的调用定义为空操作,这样对发布版本不会有任何影响。这样就达到了透明检查的效果,只需通过一个编译选项就可以搞定,调用者的代码包含如上的头文件即可。工具内部会维护内存分配的信息,包括开始地址、长度、分配的文件名和行号等信息。
malloc在分配大小为0的内存、realloc和free在传入指针非法时,会打印出调用代码的文件名、行号并终止程序。MarkMemUsed用于将一块堆中的内存标记为使用中,MarkAllMemUsed用于将当前所有堆中的内存标记为使用中,CheckMemLeak检查内存分配的记录信息,如果有未被标记为使用中的内存,则认为出现了内存泄漏,打印出内存分配的信息,然后终止程序。
强烈建议大家在单元测试中使用这个工具。可以参考如下结构进行内存泄漏检查:
TestXX()
{
//初始化
MarkAllMemUsed();
//调用业务逻辑
CheckMemLeak();
//执行结果检查
}
最后给出一个使用代码样例
#include 'stdafx.h'
#include 'MemDebug.h'
int main()
{
void* p[10];
for (int i = 0; i < 10; i++)
{
p[i] = malloc(i + 10);
}
MarkMemUsed(p[0]);
MarkMemUsed(p[9]);
CheckMemLeak();
return 0;
}
运行结果如下:





































苏公安网备 32011402010933号