Articles

浅谈MFC内存泄露检测与内存访问越界检测机制

在使用MFC的过程中,我们经常会在Visual C++的输出窗口看到如下所示的内存泄露:

Detected memory leaks!
Dumping objects ->
memleak.cpp(34) : {126} normal block at 0x00A321A0, 4 bytes long.
Data: <    > 01 00 00 00
Object dump complete.

而有的内存泄露还伴随着异常:

Detected memory leaks!
Dumping objects ->
First-chance exception at 0x75c739e5 (kernel32.dll) in MemLeak.exe:
  0xC0000005: Access violation reading location 0x711af9f4.
#File Error#(62) : {137} normal block at 0x00A721A0, 4 bytes long.
Data: <    > CD CD CD CD
Object dump complete.

Visual C++是怎么知道我们写的代码有内存泄露并能精确到文件、行号的呢? 但为什么有的时候又无法正确显示出导致内存泄露的文件名称呢? 上述异常又是怎么产生的呢?

下面我们从C++内存分配与回收的两个操作符newdelete一步步地分析C++内存管 理以及MFC内存泄露检测机制。这里所说的MFC内存泄露检测机制底层也是依赖于C/C++的, 只不过我们新建一个MFC工程,编译器会自动应用这一机制。 本文前面的分析都是基于Debug版本的,最后我们再看看Release版本的情况。

本文所有代码均在VC6和VC2008下编译、调试。如果您使用的编译器不同,结果可能会有差 别,但本文讲述的原理对于大部分编译器应该是相似的。要分析这个问题最好的办法就是查 看VC提供的MFC/C++/C源代码。

内存分配操作符new

新建一个MFC应用程序,无论是Win32 Console Application + MFC Support, 还是MFC Application或者是MFC DLL。编译器为我们生成的代码最前面,在#include 下面都会有下面这三行代码:

#ifdef _DEBUG
#define new DEBUG_NEW
#endif

这三句话的意思是,如果是Debug版本,那么将new操作符定义为DEBUG_NEW。 在afx.h中有对DEBUG_NEW的定义:

// Memory tracking allocation
void* AFX_CDECL operator new(size_t nSize, LPCSTR lpszFileName, int nLine);
#define DEBUG_NEW new(THIS_FILE, __LINE__)

看来MFC是重新定义了一个new操作符,并把文件名、行号调试信息传给了new。 下面是这个new操作符调用的其它函数。可见是按照MFC -> C++ -> C -> Win32 API的 流程分配的内存:

// DEBUG_NEW
// afxmem.cpp
void * operator new(size_t nSize, LPCSTR lpszFileName, int nLine)

// afxmem.cpp
void * operator new()

// dbgheap.c
void * _malloc_dbg()
void * _nh_malloc_dbg()
void * _nh_malloc_dbg_impl()                   
void * _heap_alloc_dbg_impl()

// malloc.c
void * _heap_alloc (size_t size)

// winbase.h
LPVOID HeapAlloc();

内存回收操作符delete

MFC并没有重新定义delete操作符,因为所有调试信息已经传给了new操作符。 delete操作符只要依然按照MFC -> C++ -> C -> Win32 API的流程将之前分配的内存 释放掉就可以了:

// operator delete
// atlalloc.h
void Free(void* p) throw()

// dbgheap.c
void _free_dbg(void * pUserData, int nBlockUse)
void _free_dbg_nolock(void * pUserData, int nBlockUse)

// free.c
void _free_base (void * pBlock)

// winbase.h
BOOL HeapFree();

C++内存链

内存链是MFC检测内存泄露的基础,当我们每new一块内存,_heap_alloc_dbg_impl 就会把这块内存加入内存链,当我们delete一块内存,_free_dbg_nolock就会把 这块内存从内存链中删除。VC的实现是使用了一个双向链表。 每一个节点的结构定义如下:

typedef struct _CrtMemBlockHeader
{
  // 下一个节点指针
  struct _CrtMemBlockHeader * pBlockHeaderNext;

  // 前一个节点指针
  struct _CrtMemBlockHeader * pBlockHeaderPrev;
  char *                      szFileName;                 // 调用new的文件名
  int                         nLine;                      // 调用new的行号
  size_t                      nDataSize;                  // 调用new的内存大小
  int                         nBlockUse;                  // 本块内存使用目的
  long                        lRequest;                   // 请求编号
  unsigned char               gap[nNoMansLandSize];       // 内存前面的空白
  /* followed by:
   *  unsigned char           data[nDataSize];            // 真正的内存
   *  unsigned char           anotherGap[nNoMansLandSize];// 内存后面的空白
   */
} _CrtMemBlockHeader;

结构体中有几个成员可能需要解释一下。nBlockUse表示本块内存的用途,一般取值为 _NORMAL_BLOCKlRequest表示请求内存的编号,初始值为1,每请求一次,该值 加1。我们在输出窗口看到的normal block就表示nBlockUse=_NORMAL_BLOCK, {137} 就是lRequest的值。data是真正返回给我们的指针,编译器在data前后 用gap, anotherGap将数据保护起来并赋予特殊的值,以检测我们对指针操作是否 越界。这些空白区域内存大小为#define nNoMansLandSize 4data同样被赋予特 殊的值,特殊值总共有四种:

// fill no-man's land with this
static unsigned char _bNoMansLandFill = 0xFD;

// fill no-man's land for aligned routines
static unsigned char _bAlignLandFill = 0xED;

// fill free objects with this
static unsigned char _bDeadLandFill = 0xDD;

// fill new objects with this
static unsigned char _bCleanLandFill = 0xCD;

比如说我们new了一个int对象,int* p = new int那么上面这个结构体内容 如下:

int_p

如果我们内存访问越界了,例如:*(p+1) = 0,那么在delete这个指针的时候, _free_dbg_nolock会对gap, anotherGap的值进行检查,发现不等于 _bNoMansLandFill,就报错。如果我们写*(p+1) = 0xFDFDFDFD,那么就把编译器 骗了,编译器认为内存访问并没有越界。当我们delete一块内存的时候,这块内存会 被用_bDeadLandFill填充。如果我们new了多个对象,那么这些对象就链接再了一 起,例如:

int* pB = new int;
int* pA = new int;

int_a_b

内存泄露检测机制

MFC正是因为有了内存链,才可以检测出哪些内存还没有被释放。在程序退出的时候, dbgheap.c中的extern “C” _CRTIMP int __cdecl _CrtDumpMemoryLeaks(void)函 数会被调用,然后遍历当前的内存链,看看还有哪些内存没有被释放,然后打印出内存泄 露的信息。原理很简单,这里不再赘述。那么为什么有的情况下我们无法通过输出的信息 定位到具体泄露的文件呢?为什么有的时候会显示#File Error#

看看上面提到的结构体中文件名的保存char * szFileName,仅仅保存了一个指向文件 名的指针而已。这个文件名是作为一个字符串,保存在.exe或.dll的.rdata中的。如果在 .exe文件退出的时候,我们显式加载的.dll文件已经被我们卸载了,并且在该.dll文件内 存在内存泄露的话,虽然_CrtDumpMemoryLeaks会尝试读取并显示文件名,但 szFileName指针指向的内存空间已经是无效的了。_CrtDumpMemoryLeaks在读取文 件名之前会先调用API函数IsBadReadPtr判断该指针是否有效。如果已经无效则显示 #File Error#。本文最开始所提到的异常,正是由IsBadReadPtr导致的。

Release版本

对于Release版本,就没有上面提到的内存链了。对于newdelete的调用将会被 直接转到malloc.cfree.c。因为没有内存链,没有多余的保护数据填充,没有 内存越界检测机制,所以有些时候Debug版本会崩溃,但是Release版本却没有。这并不代 表代码没有问题,而是内存非法访问更难发现了,当Release版本崩溃的时候,问题也更难 定位了。

为了验证上面所说的内存链的正确性,我们可以尝试用一个指针去访问双向链表中的下一 个节点。

int* pB = new int(2);
int* pA = new int(1);

// *pA = 1, *pB = 2
cout << "*pA = " << *pA << ", "
     << "*pB = " << *pB << endl;

*((int*)(*(pA - 8)) + 8) = 1;
*((int*)(*(pB - 7)) + 8) = 2;

// *pA = 2, *pB = 1
cout << "*pA = " << *pA << ", "
     << "*pB = " << *pB << endl;

delete pA;
delete pB;

理论上,我们可以在程序中任意地方new一个指针,然后通过该指针遍历所有当前已经 分配过的内存,并可以显示出分配内存的文件名、行号、内存大小等信息。