声明
本文为CodeProject网站 Detecting memory leaks: by using CRT diagnostic functions 的中文译稿;本文以“现状”提供,译者(eRay Jiang)将尽量保持、但不保证与CodeProject网站的同步更新。
注:转载此文,请遵守Codeproject相关约定。
引言
程序员的一大噩梦便是发现自己的程序存在内存泄露。不管程序规模是大是小,一个程序员犯点错误总是难免的。本文就是专门为那些想从内存泄露梦魇中解脱出来的程序员人员而作的。
为何而作
很多不错的开发工具中都带有不错的调试器,可以用来检测内存泄露,既然如此,本文还有什么好讲的呢?自己动手打造一个(完好的内存泄露检测)工具的理由之一便是:一般的调试器不能给出足够详细的输出。而我写作本文的另一重要动机是:我在MSDN论坛上看到许多帖子在询问检测内存泄露的有效方法。
内存堆简介
或许你已经意识到,所谓的“堆”(heap),只不过是你的应用程序动态申请/释放的一大块内存。Windows堆管理器负责处理应用程序的请求。Windows提供一系列函数处理堆操作,其中包括对压缩与内存重新分配的支持。这里不打算展开讨论内存堆管理,或许在我将来的文章里会细讲。本文主要讲堆内存分配、释放与重分配的HOOK操作。
Win32系统支持4GB的逻辑地址空间,其中2GB保留给OS自己使用,另外2GB才是留给应用程序的。如果应用程序希望使用多于2GB的地址空间,则可以请求OS留出更多的地址空间;即让OS只保留1GB的空间,而另外3GB空间则留给应用程序。应用程序最多可以使用3GB的逻辑地址空间,而实际需要的物理空间可以小于这个数。
默认情况下,应用程序保留留1M(256个内存分页,每页4KB)的堆空间,并提交一个内存分页。每当应用程序请求动态内存的时候,堆管理器尝试从已提交的4KB页中分配内存;如果请求超过了4KB的界限,那么堆管理器会提交更多的内存分页。如果应用程序请求超过1MB的动态内存,那么堆管理器将再提供更多的1MB。堆管理器以4KB为一小步,1MB为一大步地提供空间,直到满足应用程序的请求为止。
在Win16系统中,维护了两种内存堆:全局堆(Global Heap)和本地堆(Local Heap)。每一个Win16应用程序都拥有一个全局堆和一个本地堆;应用程序可以从这两个堆中自由请求内存。WINNT去除了全局堆和本地堆的概念之分;在WINNT中,应用程序有一个默认堆和多个动态堆(Dynamic Heap)。不过为了兼容Win16,WINNT中依然保留有GlobalAlloc和LocalAlloc之类的函数。如果应用程序使用了Win16的堆管理函数,WINNT会将这些请求映射到WINNT的默认堆上。
默认情况下,应用程序拥有一个默认堆,但可以创建许多的动态堆。(也许动态堆的个数会有限制,怕不会比HANDLE的数量多,如65535;不过我不确定。)默认堆是进程持有的;通常应用程序不会用到默认堆的HANDLE,实在要用的话也可以通过GetProcessHeap获取。通常malloc, new等调用都会映射到默认堆操作。动态堆则是应用程序可以在运行时创建或销毁的内存区域。有一组函数用于动态堆操作,如HeapCreate, AllocHeap等。我会在后续的文章中详细介绍内存堆管理的详情。
本文中提及了MS Visual Studio中(VC CRT)的诊断函数。为了避免混淆,当我提及“内存”的时候,我指的是”堆内存”,而不是通常所说的主内存、物理内存。
关于调试堆
调试堆是对基本内存堆操作的扩展。调试堆提供了非常强大的方法用于在DEBUG版的应用程序中管理内存分配的细节。通过使用调试堆,你可以跟踪多样的内存问题,比如检测内存泄露,又比如检查缓冲区访问越界等。在DEBUG版本中,当你调用malloc, new时,会被映射到相应的调试堆函数,如_malloc_dbg。这些调试堆函数(在作了一些标记工作之后)最终使用系统的堆管理函数处理请求。
调试堆维护了多样的信息以完成对内存分配操作的跟踪。假如你请求了20字节,调试版本实际上在调试堆中分配了更多的内存。其中20字节交给用户使用,而额外的字节则用于存放调试堆函数的校验信息和簿记信息。调试堆实际上为每一次分配操作使用了一个结构体CrtMemBlockHeader(定义于dbgint.h文件)。
_CrtMemBlockHeader结构体定义如下:
typedef struct _CrtMemBlockHeader { struct _CrtMemBlockHeader * pBlockHeaderNext; struct _CrtMemBlockHeader * pBlockHeaderPrev; char* szFileName; int nLine; size_t nDataSize; int nBlackUse ; long lRequest; unsigned char gap[nNoMansLandSize]; /* followed by * unsigned char data[nDataSize] ; * unsigned char anotherGap[nNoMansLandSize]; */ } _CrtMemBlockHeader;
如上所示,这些_CrtMemBlockHeader以一个双向链表的形式维护。_CrtMemBlockHeader的前两个参数分别指向下一个和上一个_CrtMemBlockHeader分配块,每个_CrtMemBlockHeader结构除了链表指针,还拥有如下成员:
- szFileName保存了分配内存代码所在的文件名(译注:对应__FILE__);
- nLine保存了分配内存代码所在的行数(译注:对应__LIEN__);
- nDataSize保存了用户请求的内存块的大小(译注:不包括因为分配头而带来的额外开销,即应用程序真正请求的内存大小);
- nBlockUse表示分配块的类型,这些类型可能为以下之一:
- _CRT_BLOCK: CRT块,CRT函数用这个类型标识CRT(C Runtime)内部的分配块(译注:后文会用到这个标识);
- _NORMAL_BLOCK: 普通块,当用malloc或者new之类分配内存块时,就是这种类型。
- _CLIENT_BLOGCK: 客户块,用于跟踪某一特定类型的内存块;这个类型还可以包括一些子类型。MFC就使用这种类型来标记所有从CObject继承的类的分配,详情请参考MSDN。
- _FREE_BLOCK: 释放块,即已经被释放的内存块。(使用_CRTDBG_DELAY_FREE_MEM_DF诊断标记可以让CRT在释放内存时并不是真正地进行释放操作,而只是将目标块填充上0xDD之类的标记,并将nBlockUse记为_FREE_BLOCK。该分配块还是在双链表中,这样可以检查内存的增长量。 )
- _IGNORE_BLOCK:在某一段时间内,你也许想关闭调试堆的跟踪;这段时间内分配的内存块就会被标记上_IGNORE_BLOCK标识。
- lRequest保存了内存块分配的序号(译注:递增的,可以用于内存泄漏检查)。
- 接下来是一个gap填充位,后跟data[nDataSize]数组,后边再接一个gap填充位。
- data[nDataSize]数组即是返回给应用程序使用的内存;
- gap和anotherGap分别位于data数组的前边和后边。这个两个填充位都是4个字节,MS将其称为NoMansland buffer。在运行的时候,这两个位都填充为0xFD,主要用来检查数组越界。
不难看出,在Debug版本上,无论何时请求一块内存,其实都分配了比你所请求内存更多的额外空间以记录一些簿记信息。
使用内存快照检测内存泄露
结构体_CrtMemState用来保存内存堆状态。通过调用_CrtMemCheckpoint函数,并传入一个_CrtMemState指针;CRT会将当前内存的状态填充到指针指向的_CrtMemState结构体中。以下代码片段用于记录下一个状态点:
_CrtMemState memstate1 ; _CrtMemCheckpoint(&memstate) ;
接下来,通过比较两个不同的状态点来探测内存泄露。通常,在程序开始的时候打下一个状态点,然后在程序结束前再打下一个状态点。通过比较这两个状态点即可以发现是否存在内存泄露。代码示例:
CrtMemState memstate1, memstate2, memstate3 ; _CrtMemCheckpoint(&memstate1) // call at the start of your program ............. ............ _CrtMemCheckpoint(&memstate2) // call at the end of the function
然后可以使用_CrtMemDiffernce()函数整理出内存泄露的信息。函数原型如下:
_CRTIMP int __cdecl _CrtMemDifference( _CrtMemState *diff, const _CrtMemState *oldstate, const _CrtMemState *newstate, );
_CrtMemDiffernce()函数的前两个参数分别指向要比较的两个状态点的_CrtMemState结构;而结果则保存在第三个参数中。代码示例:
_CrtMemeDifference(&memstate3, &memstate1, &memstate2) ;
如果检测到了内存泄露,则返回true,否则返回false。
最后,使用_CrtDumpMemoryLeaks()输出两个状态点之间的内存泄露信息;或者使用_CrtMemDumpAllObjectsSince()输出从某一个状态点之后的所有分配信息。
完整例子:
#include <stdio.h> #include <string.h> #include <crtdbg.h> #ifndef _CRTBLD #define _CRTBLD #include <dbgint.h> #endif int main(void) { _CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE); _CrtSetReportFile(_CRT_WARN, _CRTDBG_FILE_STDOUT ); _CrtMemState memstate1, memstate2, memstate3 ; // 内存状态点 _CrtMemCheckpoint(&memstate1) ; // 打下第一个状态点 int *x = new int(1177) ; // 分配内存 char *f = new char[50] ; // 分配内存 strcpy(f, "Hi Naren") ; // 使用内存 delete x ; // 释放X _CrtMemCheckpoint(&memstate2) ; // 打第二个点,注意这时f没有释放 // 比较两个快照:调试堆可以探测到char *f这一处泄露。 if(_CrtMemDifference(&memstate3, &memstate1, &memstate2)) { printf("/nOOps! Memory leak detected/n") ; _CrtDumpMemoryLeaks() ; // alternatively you can use _CrtMemDumpAllObjectsSince for //dumping from specific check point } else printf("/nNo memory leaks") ; return 0 ; }
输出:
OOps! Memory leak detected Detected memory leaks! Dumping objects -> {42} normal block at 0x002F07E0, 50 bytes long. Data: <Hi Naren > 48 69 20 4E 61 72 65 6E 00 CD CD CD CD CD CD CD Object dump complete.
使用钩子函数(Hooks)检测内存泄漏
我所使用的跟踪内存分配与释放操作的方法便是使用钩子函数。VC Debug CRT提供了Hook内存操作的方法。一旦通过调用Hook函数,设定了一个自定义的钩子方法,那么不管应用程序是分配或者释放内存,指定的钩子方法都会接到通知。
_CrtSetAllocHook
便是用来设定钩子方法的CRT函数。原型如下:
_CRTIMP _CRT_ALLOC_HOOK __cdecl _CrtSetAllocHook( _CRT_ALLOC_HOOK hookFunctionPtr );
hookFunctionPtr
是用于接收内存操作通知的函数指针。其原型如下:
int CustomAllocHook(int nAllocType, void *userData, size_t size, int nBlockType, long requestNumber, const unsigned char *filename, int lineNumber)
上述钩子函数的参数说明如下:
nAllocType
用于标明操作的类型,其取值范围如下:_HOOK_ALLOC
– 分配操作_HOOK_REALLOC
– 重分配操作_HOOK_FREE
– 释放操作
userData
是内存块的头指针,其实际类型为_CrtMemBlockHeader
。该能数只有释放操作才有效,其它操作的时候,该参数总是NULL
。size
是指整个内存块的大小(与userData
对应)。nBlockType
标明块类型(如_NORMAL_BLOCK
)。如果该参数为_CRT_BLOCK
,那么自定义钩子最好返回true
,否则很容易引起死循环。也就是说,你最好不要去管_CRT_BLOCK
操作。requestNumber
是指操作所请求的内存块大小(与应用程序获得的内存对应)。Filename
标明调用当前操作的源文件。lineNumber
标明调用当前操作的代码行数。
一个基本的钩子函数框架如下:
_CrtSetAllocHook(CustomAllocHook) int CustomAllocHook( int nAllocType, void *userData, size_t size, int nBlockType, long requestNumber, const unsigned char *filename, int lineNumber) { if( nBlockType == _CRT_BLOCK) return TRUE ; // 如上文所述,最好别管这种类型 switch(nAllocType) { case _HOOK_ALLOC : // 在这里添加分配操作的处理代码; break ; case _HOOK_REALLOC: // 在这里添加reallocation操作的处理代码; break ; case _HOOK_FREE : // 在这里添加释放操作的处理代码; break ; } return TRUE ; }
通过实现钩子函数,你可以添加自己的代码,以跟踪CRT的内存管理操作。然而,我仅仅在钩子函数打一些简单的Log。当遇到分配操作的时候,我把内存分配的相关信息存储在我自定义的有序链表中;当遇到相应的释放操作的时候,从自定义链表中移除相应节点。CrtMemBlockHeader中的lRequest字段,即block number,被用来当作释放与分配操作配对的key字段。
更多信息
原文提供的示列代码下载,以及作者narendra_ b的详细介绍,参见以下网址: