文档库 最新最全的文档下载
当前位置:文档库 › VC编译运行后内存泄漏分析及解决方法分享

VC编译运行后内存泄漏分析及解决方法分享

?----------------------- Page 1-----------------------

VC 下内存泄漏的检测方法

用 MFC 开发的应用程序,在 DEBUG 版模式下编译后,都会自动加入内存泄漏的检测代码。

在程序结束后,如果发生了内存泄漏,在 Debug 窗口中会显示出所有发生泄漏的内存块的信息,

以下两行显示了一块被泄漏的内存块的信息:

E:"TestMemLeak"TestDlg.cpp(70) : {59} normal block at 0x00881710, 200 bytes

long.

Data: 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F 70

第一行显示该内存块由 TestDlg.cpp 文件,第 70 行代码分配,地址在 0x00881710,大

小为 200 字节,{59}是指调用内存分配函数的 Request Order,关于它的详细信息可以参见

MSDN 中_CrtSetBreakAlloc() 的帮助。第二行显示该内存块前 16 个字节的内容,尖括号内

是以 ASCII 方式显示,接着的是以 16 进制方式显示。

一般大家都误以为这些内存泄漏的检测功能是由 MFC 提供的,其实不然。MFC 只是封装和

利用了 MS C-Runtime Library 的 Debug Function。非MFC 程序也可以利用 MS C-Runtime

Library 的 Debug Function 加入内存泄漏的检测功能。MS C-Runtime Library 在实现

malloc/free,strdup 等函数时已经内建了内存泄漏的检测功能。

注意观察一下由 MFC Application Wizard 生成的项目,在每一个 cpp 文件的头部都有这样一

段宏定义:

#ifdef _DEBUG

#define new DEBUG_NEW

#undef THIS_FILE

static char THIS_FILE[] = __FILE__;

#endif



有了这样的定义,在编译 DEBUG 版时,出现在这个 cpp 文件中的所有 new 都被替换成

DEBUG_NEW 了。那么DEBUG_NEW是什么呢?DEBUG_NEW也是一个宏,以下摘自afx.h,

1632 行

#define DEBUG_NEW new(THIS_FILE, __LINE__)



所以如果有这样一行代码:

----------------------- Page 2-----------------------

char* p = new char[200];



经过宏替换就变成了:

char* p = new( THIS_FILE, __LINE__)char[200];



根据 C++的标准,对于以上的 new 的使用方法,编译器会去找这样定义的 operator new:

void* operator new(size_t, LPCSTR, int)



我们在 afxmem.cpp 63 行找到了一个这样的 operator new 的实现

void* AFX_CDECL operator new(size_t nSize, LPCSTR lpszFileName, int nLine)

{

return ::operator new(nSize, _NORMAL_BLOCK, lpszFileName, nLine);

}

void* __cdecl operator new(size_t nSize, int nType, LPCSTR lpszFileName, int

nLine)

{



pResult = _malloc_dbg(nSize, nType, lpszFileName, nLine);

if (pResult != NULL)

return pResult;



}



第二个 operator new 函数比较长,为

了简单期间,我只摘录了部分。很显然最后的内存

分配还是通过_malloc_dbg 函数实现的,这个函数属于 MS C-Runtime Library 的 Debug

Function。这个函数不但要求传入内存的大小,另外还有文件名和行号两个参数。文件名和行

号就是用来记录此次分配是由哪一段代码造成的。如果这块内存在程序结束之前没有被释放,那

么这些信息就会输出到 Debug 窗口里。

----------------------- Page 3-----------------------

这里顺便提一下 THIS_FILE,__FILE 和__LINE__ 。__FILE__和__LINE__都是编译器

定义的宏。当碰到__FILE__时,编译器会把__FILE__替换成一个字符串,这个字符串就是当

前在编译的文件的路径名。当碰到__LINE__时,编译器会把__LINE__替换成一个数字,这个

数字就是当前这行代码的行号。在 DEBUG_NEW 的定义中没有直接使用__FILE__,而是用了

THIS_FILE,其目的是为了减小目标文件的大小。假设在某个cpp 文件中有 100 处使用了 new,

如果直接使用__FILE__,那编译器会产生 100 个常量字符串,这 100 个字符串都是

飧?/SPAN>cpp 文件的路径名,显然十分冗余。如果使用 THIS_FILE,编译器只会产生一个

常量字符串,那 100 处 new 的调用使用的都是指向常量字符串的指针。

再次观察一下由 MFC Application Wizard生成的项目,我们会发现在cpp 文件中只对 new

做了映射,如果你在程序中直接使用 malloc 函数分配内存,调用 malloc 的文件名和行号是不

会被记录下来的。如果这块内存发生了泄漏,MS C-Runtime Library 仍然能检测到,但是当

输出这块内存块的信息,不会包含分配它的的文件名和行号。

要在非 MFC 程序中打开内存泄漏的检测功能非常容易,你只要在程序的入口处加入以下几行代

码:

int tmpFlag = _CrtSetDbgFlag( _CRTDBG_REPORT_FLAG );

tmpFlag |= _CRTDBG_LEAK_CHECK_DF;

_CrtSetDbgFlag( tmpFlag );



这样,在程序结束的时候,也就是 winmain,main 或 dllmain 函数返回之后,如果还有

内存块没有释放,它们的信息会被打印到 Debug 窗口里。

如果你试着创建了一个非 MFC 应用程序,而且在程序的入口处加入了以上代码,并且故意在程

序中不释放某些内存块,你会在 Debug 窗口里看到以下的信息:

{47} normal block at 0x00C91C90, 200 bytes long.

Data: < > 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F



内存泄漏的确检测到了,但是和上面 MFC 程序的例子相比,缺少了文件名和行号。对于一

个比较大的程序,没有这些信息,解决问题将变得十分困难。

为了能够知道泄漏的内存块是在哪里分

配的,你需要实现类似 MFC 的映射功能,把 new,

maolloc 等函数映射到_malloc_dbg 函数上。这里我不再赘述,你可以参考 MFC 的源代码。

----------------------- Page 4-----------------------

由于 Debug Function 实现在 MS C-RuntimeLibrary 中,所以它只能检测到堆内存的泄

漏,而且只限于 malloc,realloc 或 strdup 等分配的内存,而那些系统资源,比如 HANDLE,

GDI Object,或是不通过 C-Runtime Library 分配的内存,比如 VARIANT,BSTR 的泄漏,

它是无法检测到的,这是这种检测法的一个重大的局限性。另外,为了能记录内存块是在哪里分

配的,源代码必须相应的配合,这在调试一些老的程序非常麻烦,毕竟修改源代码不是一件省心

的事,这是这种检测法的另一个局限性。

对于开发一个大型的程序,MS C-Runtime Library 提供的检测功能是远远不够的。接下来我

们就看看外挂式的检测工具。我用的比较多的是 BoundsChecker,一则因为它的功能比较全面,

更重要的是它的稳定性。这类工具如果不稳定,反而会忙里添乱。到底是出自鼎鼎大名的

NuMega,我用下来基本上没有什么大问题。

2.3.3.2 使用 BoundsChecker 检测内存泄漏

BoundsChecker 采用一种被称为 Code Injection 的技术,来截获对分配内存和释放内

存的函数的调用。简单地说,当你的程序开始运行时,BoundsChecker 的 DLL 被自动载入进

程的地址空间(这可以通过 system-level 的 Hook 实现),然后它会修改进程中对内存分配和

释放的函数调用,让这些调用首先转入它的代码,然后再执行原来的代码。BoundsChecker

在做这些动作的时,无须修改被调试程序的源代码或工程配置文件,这使得使用它非常的简便、

直接。

这里我们以 malloc 函数为例,截获其他的函数方法与此类似。

需要被截获的函数可能在 DLL 中,也可能在程序的代码里。比如,如果静态连结 C-Runtime

Library,那么 malloc 函数的代码会被连结到程序里。为了截获住对这类函数的调用,

BoundsChecker 会动态修改这些函数的指令。

以下两段汇编代码,一段没有 BoundsChecker 介入,另一段则有 BoundsChecker 的介入:

126: _CRTIMP void * __cdecl malloc (

127: size_t nSize

128: )

129: {

00403C10 push ebp

00403C11 mov ebp,esp

130: return _nh_malloc_dbg(nSize, _newmode, _NORMAL_BLOCK, NULL, 0);

00403C13 push 0

----------------------- Page 5-----------------------

00403C15 push 0

00403C17 push 1

00403C19 mov eax,[__newmode (0042376c)]

00403C1E push eax

00403C1F mov ecx,dword ptr [nSize]

00403C22 push ecx



00403C23 call _nh_malloc_dbg (00403c80)

00403C28 add esp,14h

131: }



以下这一段代码有 BoundsChecker 介入:

126: _CRTIMP void * __cdecl malloc (

127: size_t nSize

128: )

129: {

00403C10 jmp 01F41EC8

00403C15 push 0

00403C17 push 1

00403C19 mov eax,[__newmode (0042376c)]

00403C1E push eax

00403C1F mov ecx,dword ptr [nSize]

00403C22 push ecx

00403C23 call _nh_malloc_dbg (00403c80)

00403C28 add esp,14h

131: }



当 BoundsChecker 介入后,函数 malloc 的前三条汇编指令被替换成一条jmp 指令,原

来的三条指令被搬到地址 01F41EC8 处了。当程序进入 malloc 后先 jmp 到 01F41EC8,执

----------------------- Page 6-----------------------

行原来的三条指令,然后就是 BoundsChecker 的天下了。大致上它会先记录函数的返回地址

(函数的返回地址在stack 上,所以很容易修改),然后把返回地址指向属于 BoundsChecker

的代码,接着跳到 malloc 函数原来的指令,也就是在 00403c15 的地方。当 malloc 函数结束

的时候,由于返回地址被修改,它会返回到 BoundsChecker 的代码中,此时 BoundsChecker

会记录由 malloc 分配的内存的指针,然后再跳转到到原来的返回地址去。

如果内存分配/释放函数在 DLL 中,BoundsChecker 则采用另一种方法来截获对这些函数

的调用。BoundsChecker 通过修改程序的 DLL Import Table 让 table 中的函数地址指向自

己的地址,以达到截获的目的。

截获住这些分配和释放函数,BoundsChecker 就能记录被分配的内存或资源的生命周期。接下

来的问题是如何与源代码相关,也就是说当 BoundsChecker 检测到内存泄漏,它如何报告这

块内存块是哪段代码分配的。答案是调试信息(Debug Information)。当我们编译一个 Debug

版的程序时,编译器会把源代码和二进制代码之间的对应关系记录下来,放到一个单独的文件里

(.pdb)或者直接连结进目标程序,通过直接读取调试信息就能得到分配某块内存的源代码在哪

个文件,哪一行上。使用 Code Injection 和 Debug Information,使 BoundsChecker 不但

能记录呼叫分配函数的源代码的位置,而且还能记录分配时的 Call Stack,以及 Call Stack 上

的函数的源代码位置。这在使用像 MFC 这样的类库时非常有用,以下我用一个例子来说明:

void ShowXItemMenu()

{



CMenu menu;

menu.CreatePopupMenu();

//add menu items.

menu.TrackPropupMenu();



}

void ShowYItemMenu( )

{



CMenu menu;

----------------------- Page 7-----------------------

m

enu.CreatePopupMenu();

//add menu items.

menu.TrackPropupMenu();

menu.Detach();//this will cause HMENU leak



}

BOOL CMenu::CreatePopupMenu()

{



hMenu = CreatePopupMenu();



}



当调用 ShowYItemMenu()时,我们故意造成 HMENU 的泄漏。但是,对于 BoundsChecker

来说被泄漏的 HMENU 是在 class CMenu::CreatePopupMenu()中分配的。假设的你的程序

有许多地方使用了 CMenu 的 CreatePopupMenu()函数,如 CMenu::CreatePopupMenu()

造成的,你依然无法确认问题的根结到底在哪里,在 ShowXItemMenu()中还是在

ShowYItemMenu()中,或者还有其它的地方也使用了 CreatePopupMenu()?有了Call

Stack 的信息,问题就容易了。BoundsChecker 会如下报告泄漏的 HMENU 的信息:

Function

File

Line

CMenu::CreatePopupMenu

E:"8168"vc98"mfc"mfc"include"afxwin1.inl

1009

ShowYItemMenu

E:"testmemleak"mytest.cpp

----------------------- Page 8-----------------------

100



这里省略了其他的函数调用

如此,我们很容易找到发生问题的函数是 ShowYItemMenu()。当使用 MFC 之类的类库

编程时,大部分的 API 调用都被封装在类库的 class 里,有了 Call Stack 信息,我们就可以非

常容易的追踪到真正发生泄漏的代码。

记录 Call Stack 信息会使程序的运行变得非常慢,因此默认情况下 BoundsChecker 不会

记录 Call Stack 信息。可以按照以下的步骤打开记录 Call Stack 信息的选项开关:

1. 打开菜单:BoundsChecker|Setting…

2. 在 Error Detection 页中,在 Error Detection Scheme 的 List 中选择 Custom

3. 在 Category 的 Combox 中选择 Pointer and leak error check

4. 钩上 Report Call Stack 复选框

5. 点击 Ok

基于 Code Injection,BoundsChecker 还提供了 API Parameter 的校验功能,memory

over run 等功能。这些功能对于程序的开发都非常有益。由于这些内容不属于本文的主题,所

以不在此详述了。

尽管 BoundsChecker 的功能如此强大,但是面对隐式内存泄漏仍然显得苍白无力。所以接下

来我们看看如何用 Performance Monitor 检测内存泄漏。

2.3.3.3 使用 Performance Monitor 检测内存泄漏

NT 的内核在设计过程中已经加入了系统监视功能,比如 CPU 的使用率,内存的使用情况,

I/O 操作的频繁度等都作为一个个 Counter,应用程序可以通过读取这些 Counter 了解整个系

统的或者某个进程的运行状况。Performance Monitor 就是这样一个应用程序。

为了检测内存泄漏,我们一般可以监视 Process 对象的 Handle Count,Virutal By

tes 和

Working Set 三个 Counter。Handle Count 记录了进程当前打开的 HANDLE 的个数,监视

这个 Counter 有助于我们发现程序是否有 Handle 泄漏;Virtual Bytes 记录了该进程当前在

虚地址空间上使用的虚拟内存的大小,NT 的内存分配采用了两步走的方法,首先,在虚地址空

间上保留一段空间,这时操作系统并没有分配物理内存,只是保留了一段地址。然后,再提交这

段空间,这时操作系统才会分配物理内存。所以,Virtual Bytes 一般总大于程序的 Working

Set。监视Virutal Bytes 可以帮助我们发现一些系统底层的问题; Working Set 记录了操作系

----------------------- Page 9-----------------------

统为进程已提交的内存的总量,这个值和程序申请的内存总量存在密切的关系,如果程序存在内

存的泄漏这个值会持续增加,但是 Virtual Bytes 却是跳跃式增加的。

监视这些 Counter 可以让我们了解进程使用内存的情况,如果发生了泄漏,即使是隐式内

存泄漏,这些 Counter 的值也会持续增加。但是,我们知道有问题却不知道哪里有问题,所以

一般使用 Performance Monitor 来验证是否有内存泄漏,而使用 BoundsChecker 来找到和

解决。

当 Performance Monitor 显示有内存泄漏,而 BoundsChecker 却无法检测到,这时有

两种可能:第一种,发生了偶发性内存泄漏。这时你要确保使用 Performance Monitor 和使用

BoundsChecker 时,程序的运行环境和操作方法是一致的。第二种,发生了隐式的内存泄漏。

这时你要重新审查程序的设计,然后仔细研究 Performance Monitor 记录的 Counter 的值的

变化图,分析其中的变化和程序运行逻辑的关系,找到一些可能的原因。这是一个痛苦的过程,

充满了假设、猜想、验证、失败,但这也是一个积累经验的绝好机会。



相关文档