[原]C++跨平台内存泄露检测模块-笔录
内存泄露相信对C++程序员来说都不陌生。解决内存泄露的方案多种多样,大部分方案以追踪检测为主,这种方法实现起来容易,使用方便,也比较安全。
首先我们要确定这个模块的主要功能:
- 能追踪内存的分配和释放过程。
- 要能显示内存分配的相关信息,比如内存块大小,代码所在文件所在行等。
- 在发现内存泄露时及时给出相关信息。
- 能正确处理一些异常情况,比如内存不足,对象初始化失败等等。
- 是线程安全的。[*这个还没有实现]
有了一些基本功能需求,我们需要考虑每种功能怎么去实现。首先,我们可以通过重载的方式来追踪new,delete.malloc和free,C++给我提供了这样的特性。因为本文主要针对C++,所以主要讲重载new,delete的方法,malloc和free的重载实现于此类似,最终版本的程序中也实现了malloc和free的重载。
1.重载new和delete
首先我们要了解一下new和delete是怎么工作的。C++中的操作符最终都会被转换成函数形式,例如"new int"会变成"opetaor new(sizeof(int))",而"new double[10]"会变成"operator new(sizeof(double)*10)",同样“delete p”就变成了"operator delete(p)"。另外一个需要特别注意的地方是,new对于用户定义的数据类型(即你的自定义类)会自动调用该类型的构造函数,如果构造函数没有抛出异常,则正确分配,否则会中断分配操作,将异常传递给用户。默认情况下,new可以对象构造异常进行捕获。另外一个版本的new就是不带捕获异常功能的的了,所以C++系统提供的new和delete有:
void* operator new(size_t size)throw(std::bad_alloc); void* operator new[](size_t size) throw(std::bad_alloc); void* operator new(size_t,std::nothrow_t&)throw(); void* operator new[](size_t,std::nothrow_t&)throw(); void operator delete(void* pointer); void operator delete[](void* pointer); void operator delete(void* pointer,std::nothrow_t&); void operator delete[](void* pointer,std::nothrow_t&);
其中,nothrow_t是一个空结构体“struct nothrow_t{}",它的一个实例就是nothrow,C++用它来区分可以捕获异常的new和不可捕获异常的new。我们不能直接修改内部函数的行为,但是我们可以重载它们。为了实现提供内存分配信息的功能,我们给重载的函数加上几个参数。得到以下函数:
void* operator new(size_t size); void* operator new[](size_t size); void* operator new(size_t,std::nothrow_t&)throw(); void* operator new[](size_t,std::nothrow_t&)throw(); void* operator new(size_t size,const char* file,const char* func,const int line)throw(std::bad_alloc); void* operator new[](size_t size,const char* file,const char* func,const int line) throw(std::bad_alloc); void* operator delete(void* pointer); void* operator delete[](void* pointer); /*******Placement Delete********/ void operator delete(void* pointer,const char* file,const char* func,const int line); void operator delete[](void* pointer,const char* file,const char* func,const int line); void operator delete(void* pointer,std::nothrow_t&); void operator delete[](void* pointer,std::nothrow_t&); /*******************************/
中间的几个函数,就是我们主要需要重载的函数,模块的大部分工作也都由着几个函数完成。这些函数参数中,file表示分配代码所在的文件名,func表示代码所在的函数名,line表示代码行号。这几个参数信息我们可以通过编译器预定义好的几个宏来获得:__FILE__,__FUNCTION__,__LINE__。也就是说可以将"new ..."替换成"new(__FILE__,__FUNCTION__,__LINE__) ...",最终成为"operator new(sizeof(...),__FILE__,__FUNCTION__,__LINE__)"的形式,也就达到了我们的目的。关于placement delete将在下面详细解释。
2.空间分配
接下来我们要考虑内存分配信息的组织问题了。我们先来了解一下编译器是怎么组织的。在大部分编译器中,new所分配的空间都要大于实际申请的空间,大出来的部分就是编译器定义的内存块的信息,包括了内存块的大小还有一些其他信息。如下图所示:
我们把包含内存分配信息的部分叫做cookie数据。为了方便,我们把cookie数据放在分配的内存的起始位置,之后紧接有效数据区。我们还需要把返回给调用者的指针和new分配的数据区联系起来,原本想用性能比较好的STL的map数据结构来储存这些数据,但是map内部同样也使用new来分配内存,所以如果直接使用map来储存,就会陷入死循环中。所以这里我们必须自己现实一个数据结构。我们可以对返回给调用者的地址进行Hash,得到hash表中的地址,具有相同Hash值的数据我们用一个单向链表连接起来。最终的数据结构如下图所示:
2.1.构造函数中的异常
另外一个必须要注意的一点是,new操作符会先分配空间然后调用用户自定义类型的构造函数,如果构造函数抛出异常,需要用户手动释放已分配的内存。问题在于释放这样的内存不能用一般的delete操作符,可以用一个例子来说明:
#include <stdio.h> #include <stdexcept> void* operator new(size_t size, int line) { printf("Allocate %u bytes on line %d\\n", size, line); return operator new(size); } class UserClass { public: UserClass(int n)throw(){ if(n<=0){ throw std::runtime_error("n must be positive"); } } }; int main(){ try{ UserClass* myobj=new(__LINE__) UserClass(-10); delete myobj; //doesn't work if placement was not defined } catch(const std::runtime_error& e) { fprintf(stderr,"Exception: %s\n",e.what()); } return 0; }
这里,虽然在new过后试图使用delete释放已经分配的内存,但是实际上不会释放。也许你的编译器会给出这样一条消息:
“no matching operator delete found”
为了正确处理这种情况,并给用户提供相关的信息,我们需要定义placement delete操作符。placement delete是C++98标准中才有的一个特性,所以对于某些老的编译器(大致可以认为是指那些98年以前编写的编译器)不支持这个特性。这需要在模块中添加宏定义让用户可以关闭placement delete的定义,以便模块能在较老的编译器上编译。以下就是需要定义的placement delete操作符:
void operator delete(void* pointer,const char* file,const char* func,const int line); void operator delete[](void* pointer,const char* file,const char* func,const int line); void operator delete(void* pointer,std::nothrow_t&); void operator delete[](void* pointer,std::nothrow_t&);
3.检查内存泄露
有了上面的实现,我们可以方便的手动检测内存泄露。通过一个函数来实现,它会检索整个hash表,如果表不为空则存在内存泄露。
为了达到在最后程序退出时检查内存泄露的目的,我们需要在所有对象调用析构函数后进行内存泄露检测,这是因为某些用户类型在构造函数里调用new而在析构函数里调用delete。这样做能大大的减小误报的概率。而且因为对象的析构函数的调用往往在主函数main()执行结束之后进行,所以我们也不能直接在主函数里进行内存泄露检测。这里我们利用一个全局对象来实现这种检测。首先我们定义一个类:
class MemCheck{ public: MemCheck(){ memset(pTable,0,sizeof(mc_block_node_t*) * MC_HASHTABLESIZE); } ~MemCheck(){ if(mc_checkmem()){ abort(); } } };
这里的构造函数初始化Hash表。析构函数检测内存泄露。然后定义一个MemCheck的全局静态对象,这样当程序运行之前会初始化hash表,程序退出时检测内存泄露。可是问题又来了,如果一个程序中有多个全局静态对象会怎样?不幸的是,对于全局静态对象的构造顺序和析构顺序是C++标准中的一个未定义问题,也就是说,这个顺序取决于编译器的具体实现。考虑,绝大多数平台使用VC和GCC编译器,我们可以针对这两种编译器来控制全局对象的构造和解析顺序。
#ifdef _MSC_VER #pragma init_seg(lib) #endif static MemCheck mc_autocheck_object #ifdef __GNUC__ __attribute__((init_priority (101))) #endif ;
这里的宏定义部分都是编译器的选项。
至此,一个简易的跨平台内存泄露检测模块就完成了。源代码memcheck_v0.1.zip.
这个版本的一个缺点就是它不是线程安全的,所以在多线程应用程序里使用时可能会出问题。正在努力改进之中...
2018年1月23日 17:13
如果构造函数没有抛出异常,则正确分配,否则会中断分配操作,将异常传递给用户。默认情况下,new可以对象构造异常进行捕获。另外一个版本的new就是不带捕获异常功能的的了!!
2019年11月14日 15:21
I have read your article, it is very informative and helpful for me. I admire the valuable information you offer in your articles. Thanks for posting it.