1.1 内存泄露定义
一般常说的内存泄漏是指堆内存(heap memory)的泄漏(memory leak)。堆内存指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定),使用完后必须显式释放的内存。应用程序一般使用malloc,realloc,new等函数从堆中分配内存,使用完后,程序必须负责相应的调用free或delete释放该内存,否则,这块内存就不能被再次使用,即这块内存泄漏了。
广义的说,内存泄漏不仅仅包含堆内存的泄漏,还包含系统资源的泄漏(resource leak),比如核心态HANDLE,GDI Object,SOCKET连接,Interface等,从根本上说这些由操作系统分配的对象也消耗内存,如果这些对象发生泄漏最终也会导致内存的泄漏。
因此,把内存泄漏叫做系统可用资源的泄漏更为全面。
1.2 内存泄漏的发生方式
1.常发性内存泄漏。
发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄
2.偶发性内存泄漏。
发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。
3. 一次性内存泄漏。
发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,但是因为这个类是一个Singleton,所以内存泄漏只会发生一次。,总会有一块内存,而且仅有一块内存发生泄漏。
4.隐式内存泄漏。
程序在运行过程中不停的分配内存,是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。
从用户使用程序的角度来看,内存泄漏本身不会产生什么危害,作为一般的用户,根本感觉不到内存泄漏的存在。真正有危害的是内存泄漏的堆积,这会最终消耗尽系统所有的内存。从这个角度来说,一次性内存泄漏并没有什么危害,因为它不会堆积,而隐式内存泄漏危害性则非常大,因为较之于常发性和偶发性内存泄漏它更难被检测到。
1.3 内存泄露的种类和造成原因
内存泄漏的种类,归根结底只有一种,就是资源申请后没有释放,具体细分可大致分为如下几类:
1. 编码错误导致申请的内存未显式的释放
任何通过malloc,realloc,new在堆上分配的内存都必须显式的调用free,delete等释放,这两种操作必须成对使用,否则就会产生内存泄漏。这种情况还发生在类的构造函数和析构函数中,在构造函数中申请了成员指针的内存,但析构函数中没有对应的释放。
2. “无主”内存
内存分配后,若丢失指向其的指针,将无法找到该内存,导致无法释放该内存,内存泄漏。
3. API函数调用不当
一些系统和用户的API,使用的时候是传入一个指针,在API函数内部分配了一块内存空间,并将指针指向这块内存空间。调用程序在使用完这个指针后,必须释放该内存,如果没有释放,则会造成内存泄漏。
这种设计本身是存在问题的,内存的申请和释放应在程序的同一层上完成,内存的分配应该在调用API前由调用者完成,使用完后由调用者释放。
4. 异常分支导致资源未释放
程序的正常分支没问题,内存的申请释放都可以,但不幸得是一旦程序出现异常,正常的释放流程就得不到执行。如两次申请内存,第一次申请成功,第二次申请失败提前退出时没有释放第一次申请的内存。C++语言也存在一些与异常有关的内存泄漏的情形,如,构造函数中两次调用new分配内存,若第一次分配成功,而第二次发生异常,那么就会导致第一次分配的内存不被释放。
5. 隐式内存泄漏
程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。比如某些服务器会申请大块的内存作为缓存,或者申请大量的Socket资源作为连接POOL,这些资源一直占用直到程序退出,如果导致系统可用资源降到一个很低的水平,将会影响其他应用程序的正常运行。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。
6. C++ 中new/delete相关的内存泄漏
l malloc/free和new/delete混用产生的内存泄漏
内部数据类型(如,int、float等)的“对象”没有构造与析构的过程,对它们而言malloc/free和new/delete是等价的。
对于非内部数据类型的C++对象而言,对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。malloc/free不能够自动执行构造函数和析构函数,而new/delete会自动调用相应的构造函数和析构函数。
若混用malloc/free和new/delete分配释放C++对象,则有可能造成内存泄漏。例如:某个类的构造函数中申请了成员指针的内存,析构函数中也有对应的释放操作。但用new分配对象后,若采用free释放该对象,则会造成内存泄漏,因为采用free不能自动调用对象的析构函数,导致构造函数中申请的内存不能被释放。
l new/delete重载引发的内存泄漏
对C++程序,用户可能为特定的类提供自己的new/delete运算符(即运算符重载),而不采用C++缺省的new/delete运算符。若用户重载了new,就必须重载对应的delete,并且要保证重载的new/delete运算符匹配。否则,在某些情况下会造成内存泄漏。
7. C++ 基类析构函数非虚
C++基类析构函数一般应为virtual的;否则,在派生类对象消亡时,将只会调用派生类的析构函数,而基类的析构函数不被调用,若该基类使用new分配了内存,则分配的内存就不能被释放,内存泄漏。
1.4 内存泄露的危害
内存泄漏的危害显而易见。堆内存的泄漏会减少应用程序可用的堆内存,导致系统性能下降,服务器处理能力下降;久而久之就会造成程序申请内存失败,对于实时系统来会导致系统的崩溃。对于系统资源,某些资源对象消耗的是核心态内存,这些对象严重泄漏时会导致整个操作系统不稳定。相比之下,系统资源的泄漏比堆内存的泄漏更为严重。
2 检测内存泄漏:
检测内存泄漏的关键是要能截获住对分配内存和释放内存的函数的调用。截获住这两个函数,我们就能跟踪每一块内存的生命周期,比如,每当成功的分配一块内存后,就把它的指针加入一个全局的list中;每当释放一块内存,再把它的指针从list中删除。这样,当程序结束的时候,list中剩余的指针就是指向那些没有被释放的内存。这里只是简单的描述了检测内存泄漏的基本原理,详细的算法可以参见Steve Maguire的<<Writing Solid Code>>。
如果要检测堆内存的泄漏,那么需要截获住malloc/realloc/free和new/delete就可以了(其实new/delete最终也是用malloc/free的,所以只要截获前面一组即可)。对于其他的泄漏,可以采用类似的方法,截获住相应的分配和释放函数。比如,要检测BSTR的泄漏,就需要截获SysAllocString/SysFreeString;要检测HMENU的泄漏,就需要截获CreateMenu/ DestroyMenu。(有的资源的分配函数有多个,释放函数只有一个,比如,SysAllocStringLen也可以用来分配BSTR,这时就需要截获多个分配函数)
在Windows平台下,检测内存泄漏的工具常用的一般有三种,MS C-Runtime Library内建的检测功能;外挂式的检测工具,诸如,Purify,BoundsChecker等;利用Windows NT自带的Performance Monitor。这三种工具各有优缺点,MS C-Runtime Library虽然功能上较之外挂式的工具要弱,但是它是免费的;Performance Monitor虽然无法标示出发生问题的代码,但是它能检测出隐式的内存泄漏的存在,这是其他两类工具无能为力的地方。
以下我们详细讨论这三种检测工具:
2.1 VC下内存泄漏的检测方法
内存泄漏类型:
在VC上调试BREW应用时,DEBUG窗口提示很多如【1】和【2】信息:
*AEEHeap.c:1167 - 100 - dialereditapp #2042 e:\...\dialereditform.c:346 (L)【1】
*OEMOS.c:679 - BPOINT Type 1, Node 0x047856C8 dialereditapp【2】
*AEEHeap.c:1167——表示文件AEEHeap.c的第1167行输出的这个信息
100——表示内存泄漏的内存块的大小
dialereditapp——表示内存泄漏所在Applet
e:\...\dialereditform.c:346 (L)——表示内存泄漏所在文件和行号
*OEMOS.c:679——表示文件OEMOS.c的第679行输出的这个信息
BPOINT Type 1——表示内存泄漏
Node 0x047856C8 dialereditapp——节点,地址,应用
BPOINT 断点
BREW定义了四种错误类型:
TYPE 1: 内存泄露问题,就是用MALLOC分配的内存没有释放了。
TYPE 2: BREW接口内存泄露。这种错误在APP退出的时候会提示。
TYPE 3: 内存corruption,通常就意味着写了不该写的地方,或者释放了不该释放的地方。
TYPE 4: BREW异常。
对于1和2稍微容易调试一些。
TYPE1:可以加一些内存调试代码,记录所有分配和释放的地址,找出错误。很多软件在设计时,都预留了内存调试接口。如果没有,自己写呗。
TYPE2:应用中用的IF是很有限的吧,好好检查下,努力点,就没问题了
TYPE3:这类问题比较难调试,有时候可以通过设置内存断点来跟踪到错误的地方。在VC中设置内存断点,断点停时,查看操作该地址的地方是否有误,该地址是否为义分配内存。但大多时候,由于出错的地址(提示的地址)可能操作的很多,比如内存分配比较频繁,该内存区可能被重复分配释放,调试起来就比较困难了。这时候就需要有耐心了,多打调试信息,缩小出错的代码范围,然后仔细检查代码。
检测方法:
用MFC开发的应用程序,在DEBUG版模式下编译后,都会自动加入内存泄漏的检测代码。在程序结束后,如果发生了内存泄漏,在Debug窗口中会显示出所有发生泄漏的内存块的信息,以下两行显示了一块被泄漏的内存块的信息:
E:\TestMemLeak\TestDlg.cpp(70) : {59} normal block at 0x00881710, 200 bytes long.
Data: <abcdefghijklmnop> 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__)
所以如果有这样一行代码:
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窗口里。
这里顺便提一下THIS_FILE,__FILE和__LINE__。__FILE__和__LINE__都是编译器定义的宏。当碰到__FILE__时,编译器会把__FILE__替换成一个字符串,这个字符串就是当前在编译的文件的路径名。当碰到__LINE__时,编译器会把__LINE__替换成一个数字,这个数字就是当前这行代码的行号。在DEBUG_NEW的定义中没有直接使用__FILE__,而是用了THIS_FILE,其目的是为了减小目标文件的大小。假设在某个cpp文件中有100处使用了new,如果直接使用__FILE__,那编译器会产生100个常量字符串,这100个字符串都是这个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的源代码。
由于Debug Function实现在MS C-RuntimeLibrary中,所以它只能检测到堆内存的泄漏,而且只限于malloc,realloc或strdup等分配的内存,而那些系统资源,比如HANDLE,GDI Object,或是不通过C-Runtime Library分配的内存,比如VARIANT,BSTR的泄漏,它是无法检测到的,这是这种检测法的一个重大的局限性。另外,为了能记录内存块是在哪里分配的,源代码必须相应的配合,这在调试一些老的程序非常麻烦,毕竟修改源代码不是一件省心的事,这是这种检测法的另一个局限性。
对于开发一个大型的程序,MS C-Runtime Library提供的检测功能是远远不够的。接下来我们就看看外挂式的检测工具。我用的比较多的是BoundsChecker,一则因为它的功能比较全面,更重要的是它的稳定性。这类工具如果不稳定,反而会忙里添乱。到底是出自鼎鼎大名的NuMega,我用下来基本上没有什么大问题。
2.2 使用BoundsChecker检测内存泄漏:
BoundsChecker采用一种被称为 Code Injection的技术,来截获对分配内存和释放内存的函数的调用。简单地说,当你的程序开始运行时,BoundsChecker的DLL被自动载入进程的地址空间(这可以通过system-level的Hook实现),然后它会修改进程中对内存分配和释放的函数调用,让这些调用首先转入它的代码,然后再执行原来的代码。BoundsChecker在做这些动作的时,无须修改被调试程序的源代码或工程配置文件,这使得使用它非常的简便、直接。
这里我们以malloc函数为例,截获其他的函数方法与此类似。
需要被截获的函数可能在DLL中,也可能在程序的代码里。比如,如果静态连结C-Runtime Library,那么malloc函数的代码会被连结到程序里。为了截获住对这类函数的调用,BoundsChecker会动态修改这些函数的指令。
以下两段汇编代码,一段没有BoundsChecker介入,另一段则有BoundsChecker的介入:
当BoundsChecker介入后,函数malloc的前三条汇编指令被替换成一条jmp指令,原来的三条指令被搬到地址01F41EC8处了。当程序进入malloc后先jmp到01F41EC8,执行原来的三条指令,然后就是BoundsChecker的天下了。大致上它会先记录函数的返回地址(函数的返回地址在stack上,所以很容易修改),然后把返回地址指向属于BoundsChecker的代码,接着跳到malloc函数原来的指令,也就是在00403c15的地方。当malloc函数结束的时候,由于返回地址被修改,它会返回到BoundsChecker的代码中,此时BoundsChecker会记录由malloc分配的内存的指针,然后再跳转到到原来的返回地址去。
如果内存分配/释放函数在DLL中,BoundsChecker则采用另一种方法来截获对这些函数的调用。BoundsChecker通过修改程序的DLL Import Table让table中的函数地址指向自己的地址,以达到截获的目的。关于如何拦截Windows的系统函数,《程序员》杂志2002年8期,《API钩子揭密(下)》,对修改导入地址表做了概要的描述。我就不再赘述。
截获住这些分配和释放函数,BoundsChecker就能记录被分配的内存或资源的生命周期。接下来的问题是如何与源代码相关,也就是说当BoundsChecker检测到内存泄漏,它如何报告这块内存块是哪段代码分配的。答案是调试信息(Debug Information)。当我们编译一个Debug版的程序时,编译器会把源代码和二进制代码之间的对应关系记录下来,放到一个单独的文件里(.pdb)或者直接连结进目标程序中。有了这些信息,调试器才能完成断点设置,单步执行,查看变量等功能。BoundsChecker支持多种调试信息格式,它通过直接读取调试信息就能得到分配某块内存的源代码在哪个文件,哪一行上。使用Code Injection和Debug Information,使BoundsChecker不但能记录呼叫分配函数的源代码的位置,而且还能记录分配时的Call Stack,以及Call Stack上的函数的源代码位置。这在使用像MFC这样的类库时非常有用,以下我用一个例子来说明:
当调用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 | 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 使用Performance Monitor检测内存泄漏
NT的内核在设计过程中已经加入了系统监视功能,比如CPU的使用率,内存的使用情况,I/O操作的频繁度等都作为一个个Counter,应用程序可以通过读取这些Counter了解整个系统的或者某个进程的运行状况。Performance Monitor就是这样一个应用程序。
为了检测内存泄漏,我们一般可以监视Process对象的Handle Count,Virutal Bytes 和Working Set三个Counter。Handle Count记录了进程当前打开的HANDLE的个数,监视这个Counter有助于我们发现程序是否有Handle泄漏;Virtual Bytes记录了该进程当前在虚地址空间上使用的虚拟内存的大小,NT的内存分配采用了两步走的方法,首先,在虚地址空间上保留一段空间,这时操作系统并没有分配物理内存,只是保留了一段地址。然后,再提交这段空间,这时操作系统才会分配物理内存。所以,Virtual Bytes一般总大于程序的Working Set。监视Virutal Bytes可以帮助我们发现一些系统底层的问题; Working Set记录了操作系统为进程已提交的内存的总量,这个值和程序申请的内存总量存在密切的关系,如果程序存在内存的泄漏这个值会持续增加,但是Virtual Bytes却是跳跃式增加的。
监视这些Counter可以让我们了解进程使用内存的情况,如果发生了泄漏,即使是隐式内存泄漏,这些Counter的值也会持续增加。但是,我们知道有问题却不知道哪里有问题,所以一般使用Performance Monitor来验证是否有内存泄漏,而使用BoundsChecker来找到和解决问题。
当Performance Monitor显示有内存泄漏,而BoundsChecker却无法检测到,这时有两种可能:第一种,发生了偶发性内存泄漏。这时你要确保使用Performance Monitor和使用BoundsChecker时,程序的运行环境和操作方法是一致的。第二种,发生了隐式的内存泄漏。这时你要重新审查程序的设计,然后仔细研究Performance Monitor记录的Counter的值的变化图,分析其中的变化和程序运行逻辑的关系,找到一些可能的原因。这是一个痛苦的过程,充满了假设、猜想、验证、失败,但这也是一个积累经验的绝好机会。
3 研发各阶段如何预防内存泄漏
要预防和检察内存泄漏,重点就在于一个原则“申请的内存资源一定要在用完后释放”。下面本文就从研发的各个阶段阐述这个原则。
3.1 设计阶段
为了避免内存泄露,在设计阶段在接口定义方面必须遵从内容谁申请谁释放原则,就是说那个模块申请的内存必须是那个模块模块释放,那个函数申请的内存,那个函数释放,那个进程申请的那个进程释放。
3.2 编码阶段
在C语言的编码中,要防止内存泄漏原则只有一个,就是自己申请的内存一定要有明确的释放,即malloc/ free要成对使用。
在编码时经常有这种情况,在这个模块申请的内存需要在另一个模块才能释放,比如为消息发送申请的内存。在另一个模块释放前,中间可能又申请了内存,这样最后释放的时候就有可能发生内存释放不完全的情况,尤其是负责释放的模块发生了异常的时候更容易发生。要避免这种情况除了设计时加以避免,编码时应当一次性申请好所需内存,不要分步申请。
PC-Lint可以帮助我们自动查找代码中的可能存在的很多问题,包括变量值未初始化、数组访问越界、空指针访问、内存泄漏等问题。编码完成后进行PCLINT检查,不允许出现423和672错误,这两个error表明存在内存泄漏的可能。
3.3 代码走查
发现内存泄露的代码走查方式主要是专项走查。专项走查的重点应放在以下几个方面:
l 内存的申请和释放最好调用操作系统封装的接口,不要直接使用系统调用;
l 申请内存操作和释放内存操作是否成对出现;
l 追踪内存申请到释放的整个流程,确保在流程中的各个异常分支都能释放已申请的内存;
3.4 单元测试
CUnit可做到高覆盖率的可回归的自动化单元测试,可使用PURIFY工具来做进一步的模块测试工作。PURIFY又是一个实时的检测工具,单元测试是一个动态运行的阶段,因此以上的结合可取得较好效果。
Purify主要针对软件开发过程中难于发现的内存错误,运行时错误。在软件开发过程中:
a) 自动地发现错误;
b) 准确地定位错误;
c) 提供完备的错误信息。
Purify主要检测的错误包括:
a) 数组内存是否越界读/写;
b) 是否使用了未初始化的内存;
c) 是否对已释放的内存进行读/写;
d) 是否对空指针进行读/写;
e) 内存漏洞。
关于Purify如何使用请参考《单元测试指导书》。
3.5 集成测试
集成测试阶段,系统作为一个黑盒,无法使用单元测试中的代码级测试工具进行内存泄漏的定位。一般集成测试可采用两种方式进行:
1. 使用专业的测试工具软件,如IBM Rational Test Real-Time、IBM Rational PurifyPlus Real-Time、CodeTest以及ATE等常用测试工具,它们均支持集成测试,并具备运行时内存分析功能。
2. 通过系统长时间的运行,观察系统资源的使用情况,判断系统是否存在内存泄漏。
一般有以下几个步骤:
l 首先是观察。观察系统长时间大负荷运行,是否有性能下降、资源枯竭的现象,或者干脆有崩溃现象发生。按照经典的理念:一个程序在崩溃之前可运行的时间越长,则导致崩溃的原因与内存泄漏的关系越大。如果系统在长时间运行正常后突然崩溃,则可以比较重点的检查内存泄漏问题。
l 其次是监测。系统大负荷运行稳定后,取两个时间点,对比这两个时间点上系统资源的占用是否有持续增加的现象。如果是的话,则可以怀疑有内存泄漏。
l 各个操作系统都有各自的查看系统资源的方法,如Unix/Linux中可以用free命令显示系统内存使用情况,top命令查看系统进程资源占用情况。在Windows平台,则可以用资源管理器查看系统资源,而且有各种GUI的第三方工具可以做这个。时间点可以取足够的多,画出系统资源曲线图的话分析就更方便了。而对于我们目前使用的嵌入式操作系统来说,需要在程序中增加一些监控内存使用情况的手段,比如说增加对于内存UB数目的统计功能以及观察内存数目的人际命令等等。
l 然后就是进行跟踪定位。在集成测试阶段最常用的跟踪的方法对于内存的使用和释放情况进行记录,然后对于记录进行分析以定位引起内存泄露的进程、函数或者操作。或者直接退回到单元测试阶段或者代码走查阶段,使用那一阶段的工具进行定位。
4 附录一:常见内存泄漏分析及解决方法
4.1 编码错误导致申请的内存未显式的释放
条目 | 内容 | 备注 |
隐 蔽 性 | 中 | |
危害分析 | 任何通过malloc,realloc,new在堆上分配的内存都必须显式的调用free,delete等释放,这两种操作必须成对使用,否则就会产生内存泄漏。 类的构造函数和析构函数中,在构造函数中申请了成员指针的内存,但析构函数中没有对应的释放也会造成内存泄漏。 | |
典型案例 | 例1. void test() { BYTE * p; p = (BYTE *)malloc(100*sizeof(BYTE)); …… } 例2. const int N = 20; class Obj { private: int *p; public : Obj(void){ p=new int[N];} ~Obj(void){ } …… }; void test(void) { Obj *pObj=new Obj(); …… delete pObj; } | 程序退出后就有100个BYTE的内存泄漏了 |
案例分析 | 案例1,test函数使用malloc分配100字节大小的内存,使用完毕退出函数前未显式调用free进行内存释放,导致内存泄漏。 案例2,析构函数未释放内存,导致内存泄漏。 | |
检查建议 | 检查所有malloc/realloc操作是否有对应的free操作,所有new操作是否有对应的delete操作 | |
修改建议 | 使用malloc、realloc 、new等操作分配内存时,确保存在对应的free、delete操作释放内存 |
4.2 “无主”内存
条目 | 内容 | 备注 |
隐 蔽 性 | 高 | |
危害分析 | 内存分配后,若丢失指向其的指针,将无法找到该内存,导致无法释放该内存,内存泄漏。 | |
典型案例 | 例1. Temp1 = (BYTE *)malloc(100*sizeof(BYTE)); Temp2 = (BYTE *)malloc(100*sizeof(BYTE)); Temp2 = Temp1; 例2. for (i=0; i<10; i++) p = malloc(1024); | 代码段运行后,就会丢失指向其的指针,导致内存无法释放 |
案例分析 | 案例1. Temp1和Temp2分别指向分配的两段内存。程序运行后,Temp2指向的内存段丢失,因为Temp1和Temp2均指向了Temp1指向的内存。 案例2. 程序运行后,malloc操作前9次分配的1024大小的内存均丢失,导致内存泄漏,p指向最后一次malloc操作分配的内存。 | |
检查建议 | 检查分配的内存在生存期内是否一直存有指向它的指针 | |
修改建议 | 确保分配的内存在生存期内均有指针指向它。严禁编写类似案例2的循环内存分配代码。 |
4.3 异常分支导致资源未释放
条目 | 内容 | 备注 |
隐 蔽 性 | 高 | |
危害分析 | 程序的正常分支没问题,内存的申请释放都可以,但是程序出现异常时,正常的释放流程就得不到执行,此时内存有可能未被释放,内存泄漏。如两次申请内存,第一次申请成功,第二次申请失败提前退出时没有释放第一次申请的内存。 | |
典型案例 | 例1. void test() { BYTE * p; BOOL SUCCESS; p = (BYTE *)malloc(100*sizeof(BYTE)); …… if(SUCCESS==FALSE) returm; free(p); } 例2. void test() { BYTE *p; BYTE *q; p = (BYTE *)malloc(100*sizeof(BYTE)); If(p==NULL) return; q = (BYTE *)malloc(10*sizeof(BYTE)); If(q==NULL) /* 未释放p指向的内存 */ return; free(p); free(q); } | 在程序异常的情况下,蓝色部分就会直接返回,而导致分配内存未被释放 |
案例分析 | 在程序异常的情况下,内存能被正常释放;程序异常的情况下,蓝色部分就会直接返回,导致分配内存未被释放,内存泄漏。 | |
检查建议 | 检查程序异常路径上分配的内存是否被释放 | |
修改建议 | 程序存在异常路径的情况下,在异常处理分支确保分配的内存被释放。 |
4.4 释放已释放的内存(FFM)
条目 | 内容 | 备注 |
隐 蔽 性 | 高 | |
危害分析 | 同一内存被释放了两次。free(p)/delete p后,p并不为NULL,而是成为“野指针”,指向别的内存处,因此再次free/delete,会导致程序出现异常或别处内存被释放,导致程序崩溃或可用内存减少。 | |
典型案例 | void test() { BYTE * p; BOOL SUCCESS; p = (BYTE *)malloc(100*sizeof(BYTE)); …… free(p); …… free(p); } | 蓝色部分表示同一内存被释放了两次 |
案例分析 | p被释放的两次,会导致别处内存被释放或程序出现异常,导致可用内存减少或程序崩溃。 | |
检查建议 | 检查malloc/new和free/delete是否配对,以及检查某些分支是否会导致free(delete)与malloc(new)不匹配。 | |
修改建议 | 确保malloc/new和free/delete配对;free(p)/delete p后,立即将p置为NULL,杜绝“野指针”。 |
4.5 使用函数的指针型参数动态分配内存
条目 | 内容 | 备注 |
隐 蔽 性 | 高 | |
危害分析 | 如果函数的参数是一个指针,不要指望用该指针去申请动态内存,否则会导致内存泄漏。 | |
典型案例 | void GetMemory (char *p, int num) { p = (char *)malloc(sizeof(char) * num); } void Test(void) { char *str = NULL; GetMemory(str, 100); /* str 仍然为 NULL */ strcpy(str, "hello"); /* 运行错误 */ } | |
案例分析 | 本案例毛病出在函数GetMemory 中。编译器总是要为函数的每个参数制作临时副本,指针参数p 的副本是 _p,编译器使 _p = p。如果函数体内的程序修改了_p 的内容,就导致参数p 的内容作相应的修改。这就是指针可以用作输出参数的原因。本例中,_p 申请了新的内存,只是把_p 所指的内存地址改变了,但是p 丝毫未变。所以函数GetMemory并不能输出任何东西。每执行一次GetMemory 就会泄露一块内存,因为没有用free 释放内存。 | |
检查建议 | 检查是否存在使用函数指针型参数申请动态内存的情况 | |
修改建议 | 不推荐使用函数指针型参数申请动态内存。如果非得要用指针参数去申请内存,那么应该改用“指向指针的指针”: void GetMemory (char **p, int num) { *p = (char *)malloc(sizeof(char) * num); } void Test (void) { char *str = NULL; GetMemory (&str, 100); /* 注意参数是 &str,而不是str */ strcpy(str, "hello"); cout<< str << endl; free(str); /* 此处必须释放内存,否则内存泄漏 */ } 或者可以用函数返回值来传递动态内存: char *GetMemory (int num) { char *p = (char *)malloc(sizeof(char) * num); return p; } void Test (void) { char *str = NULL; str = GetMemory (100); strcpy(str, "hello"); cout<< str << endl; free(str); } |
4.6 API函数调用不当
条目 | 内容 | 备注 |
隐 蔽 性 | 高 | |
危害分析 | 一些系统API(也可能是用户自定义的API),使用的时候是传入一个指针,在API函数内部分配了一块内存空间,并将指针指向这块内存空间。调用程序在使用完这个指针后,必须释放该内存,如果没有释放,则会造成内存泄漏。 | |
典型案例 | 例1.“指向指针的指针”方式的指针参数申请内存 void GetMemory (char **p, int num) { *p = (char *)malloc(sizeof(char) * num); } void Test (void) { char *str = NULL; GetMemory (&str, 100); /* 注意参数是 &str,而不是str */ strcpy(str, "hello"); cout<< str << endl; /* free(str); */ } 例2.用函数返回值来传递动态内存 char *GetMemory (int num) { char *p = (char *)malloc(sizeof(char) * num); return p; } void Test (void) { char *str = NULL; str = GetMemory (100); strcpy(str, "hello"); cout<< str << endl; /* free(str); */ } | 此处的例子与5.5节的修改建议的例子是相同的。 |
案例分析 | 调用GetMemory后,必须显式地free在GetMemory中分配的内存,否则内存泄漏。 | |
检查建议 | 检查这两种情况下,调用函数是否显式的释放内存 | |
修改建议 | 由调用函数显式地释放内存,即去除例子中红色部分的注释即可由调用函数显式地释放内存。 注:用户API在设计时最好确保内存的申请和释放应在程序的同一层上完成。 |
4.7 C++ 混用malloc/free和new/delete分配释放C++对象
条目 | 内容 | 备注 |
隐 蔽 性 | 中 | |
危害分析 | 把new和delete与malloc和free混在一起用也是个坏想法。对一个用new获取来的指针调用free,或者对一个用malloc获取来的指针调用delete,其后果是不可预测的,并有可能造成内存泄漏。例如:某个类的构造函数中申请了成员指针的内存,析构函数中也有对应的释放操作。但用new分配对象后,若采用free释放该对象,则会造成内存泄漏,因为采用free不能自动调用对象的析构函数,导致构造函数中申请的内存不能被释放。 | |
典型案例 | const int N = 20; class Obj { private: int *p; public : Obj(void){ p=new int[N];} ~Obj(){ delete[] p;} …… }; void test(void) { Obj *pObj=new Obj(); …… free(pObj); } | |
案例分析 | 混用new和free,导致Obj中的析构函数未被调用,构造函数中分配的内存(即p)无法被释放,内存泄漏。 | |
检查建议 | 检查程序是否存在malloc/free和new/delete 混用的情况 | |
修改建议 | 确保malloc/free和new/delete配对使用。分配释放C++对象时,采用new/delete。 |
4.8 C++ delete数组
条目 | 内容 | 备注 |
隐 蔽 性 | 中 | |
危害分析 | 释放C++对象数组时,采用delete []而不要采用delete,否则仅第一个对象会被释放,漏掉了其它的对象。 | |
典型案例 | const int N = 20; const int M = 10; class Obj { private: int *p; public : Obj(void){ p=new int[N];} ~Obj(){ delete[] p;} …… }; void test(void) { Obj *pObj=new Obj[M]; …… delete pObj; } | |
案例分析 | 分配了M(=10)个Obj对象,但delete仅释放第一个对象pObj[0],漏掉了其它9个对象,造成内存泄漏。 | |
检查建议 | 检查程序是否存在使用delete释放C++对象的情况混用的情况 | |
修改建议 | 释放C++对象数组时采用delete []。 |
4.9 C++ new/delete运算符重载不匹配
条目 | 内容 | 备注 |
隐 蔽 性 | 高 | |
危害分析 | 用户可能为特定的类提供自己的new/delete运算符(即运算符重载),而不采用C++缺省的new/delete运算符(主要是为了效率。缺省的operator new和operator delete具有非常好的通用性,它的这种灵活性也使得在某些特定的场合下,可以进一步改善它的性能。尤其在那些需要动态分配大量的但很小的对象的应用程序里,情况更是如此)。若用户重载了new,就必须重载对应的delete,并且要保证重载的new/delete运算符匹配。否则,在某些情况下会造成内存泄漏。 | |
典型案例 | 例1.重载new,但未重载delete运算符 class airplane{ public: static void * operator new(size_t size); …… private: /* airplanenum > 3 */ static const int airplanenum; }; void * airplane::operator new(size_t size) { /* 把非airplane对象的new请求转给缺省的new运算符 */ if (size != sizeof(airplane)) return ::operator new(size); airplane *next=::operator new(airplanenum *size); /* 指向第3个airplane对象 */ return next+2*size; } void test(void) { const int airplane::airplanenum = 512; airplane *pa = new airplane; // 调airplane::operator new …… delete pa; // 调用 ::operator delete } 例2.重载的new/delete运算符不匹配 class airplane{ public: static void * operator new(size_t size); static void * operator delete(void *deadobj, size_t size); …… private: /* airplanenum > 3 */ static const int airplanenum; }; void * airplane::operator new(size_t size) { /* 把非airplane对象的new请求转给缺省的new运算符 */ if (size != sizeof(airplane)) return ::operator new(size); airplane *next=::operator new(airplanenum *size); /* 指向第3个airplane对象 */ return next+2*size; } void * airplane::operator delete(void * deadobj, size_t size) { if (deadobj == 0) return; /* 把非airplane对象的delete请求转给缺省的new delete运算符 */ if (size != sizeof(airplane)) { ::operator delete(deadobj); return; } ::operator delete(deadobj-size); } void test(void) { const int airplane::airplanenum = 512; airplane *pa = new airplane; // 调airplane::operator new …… delete pa; // 调用 airplane::operator delete } | |
案例分析 | l 例1未提供重载的delete运算符,因此test的蓝色行调用的是重载的new和缺省的delete。重载的new返回的是从第3个airplane对象开始的那部分,而缺省的delete只释放了从第3个airplane对象开始的那部分内存,这样第1、2个airplane对象的内存不能释放,内存泄漏。 l 例2提供重载的delete运算符,因此test的蓝色行调用的均是重载的new和delete。重载的new返回的是从第3个airplane对象开始的那部分内存,而重载的delete只释放了从第2个airplane对象开始的那部分内存,这样第1个airplane对象的内存不能释放,内存泄漏。 | |
检查建议 | 检查重载new和delete时,重载后的new/delete是否匹配。 | |
修改建议 | 修改建议:将例2 delete重载函数红色部分做修改,改为释放从next开始的内存。修改结果如下: void * airplane::operator delete(void * deadobj, size_t size) { if (deadobj == 0) return; /* 把非airplane对象的delete请求转给缺省的new delete运算符 */ if (size != sizeof(airplane)) { ::operator delete(deadobj); return; } ::operator delete(deadobj-2*size); } 注:若程序需要重载new/delete运算符,那么必须保证重载后new/delete运算符匹配,并且若重载了new,就必须重载delete。 |
4.10 C++ 基类析构函数非虚
条目 | 内容 | 备注 |
隐 蔽 性 | 高 | |
危害分析 | C++基类析构函数一般应为virtual的;否则,在派生类对象消亡时,将只会调用派生类的析构函数,而基类的析构函数不被调用,若该基类使用new分配了内存,则分配的内存就不能被释放,内存泄漏。 | |
典型案例 | const int N = 20; class Base { private: int *p; Int x; public : Base(int ix):x(ix){ p=new int[N];} ~Base(){ delete[] p;} …… }; class Derived:public Base { private: int y; public : Derived(int ix, int iy): Base(ix),y(iy){ } ~ Derived (){ } …… }; void test(void) { int x=0; Int y=0; Base *pDerived=new Derived(x, y); …… delete pDerived; } | |
案例分析 | Test中分配了Derived对象(基类指针指向它),并在使用完后释放,但Base类的析构函数非虚,delete只会调用Derived类的析构函数,而不会调用Base的析构函数,导致Base类分配的内存未被释放。 | |
检查建议 | 检查程序基类的析构函数是否是virtual的。 | |
修改建议 | 基类的析构函数设置为virtual,特别是在基类会分配内存的情况下。 |
4.11 消除mcmcpy中sizeof造成的内存越界隐患
主题 | 消除mcmcpy中sizeof造成的内存越界隐患 |
问题摘要 | mcmcpy函数中的拷贝长度经常用sizeof确定,因此也可能产生内存越界隐患。本文对如何消除这些内存越界隐患提出了一些建议。 |
特定约束 | 无 |
问题起因/背景 | |
走查告警管理模块的代码,发现了很多类似的语句: memcpy(ptRmsFmAlm->tAlarm.tFmBrsImaLinkAlarm.achReason, ptPlatAlarm->tAddInfo.tIMALink.aucReason, sizeof ptPlatAlarm->tAddInfo.tIMALink.aucReason); 其中ptRmsFmAlm->tAlarm.tFmBrsImaLinkAlarm.achReason是故障管理模块自己定义的结构,而ptPlatAlarm->tAddInfo.tIMALink.aucReason是另一个系统——统一平台定义的结构。很自然会考虑该语句有没有内存越界隐患:如果统一平台修改了ptPlatAlarm->tAddInfo.tIMALink.aucReason的定义,而故障管理模块没有及时修改ptRmsFmAlm->tAlarm.tFmBrsImaLinkAlarm.achReason定义的情况下(这是很有可能发生的),该memcpy有可能出现写越界。 | |
问题详述 | |
typedef ... SOURCE; /* 其他子系统定义的SOURCE类型 */ typedef ... DEST; /* 自定义DEST类型 */ SOURCE source; /* source变量 */ DEST dest; /* dest变量 */ memcpy(&dest, &source, sizeof source); 当dest和source的类型刚好相同,该语句是安全的。但是如果source和dest类型出现偏差,sizeof source容易出现写越界,sizeof dest最多出现读越界。写越界的后果比读越界严重,不可同日而语。 也有很多人自觉不自觉地使用sizeof(DEST),不如直接用sizeof dest安全。因为随着版本的不断演进,我们可能会修改dest的type,而如果没有及时修改memcpy中的sizeof(DEST)的话,DEST和dest会对不上,这样,写越界就出现了。 | |
对策/解决方案 | |
mcmcpy函数中size的确定,最好用sizeof dest,不提倡用sizeof(DEST),更不提倡用sizeof source。 这些点滴之处,需要我们在编程时多加思考以及平时注意积累。 | |
总结和建议 | |
细节决定成败,一点点好的编程习惯和编程风格积累起来,我们的产品命运随之改变。 |
4.12 网管积累的处理内存泄漏的经验集锦
条目 | 内容 | 备注 |
隐 蔽 性 | 高 | |
危害分析 | ||
典型案例 | a) 对于不管采用何种形式分配的内存,一定要做初始化,不然,可能出现意象不到的问题,一般包括如下情况: 1) 以数组的形式 2) 采用new的形式 3) 以结构和联合的形式 最简单的初始化方式是调用memset函数 b) 内存拷贝和赋值的时候,一定要做必要的长度检查,以避免出现不可读内存的错误,一般包括如下情况: 1)内存拷贝 BYTE pucTempSource[100]; BYTE pucTempCopy[200]; …. Memcpy(pucTempCopy, pucTempSource, 200 ); 2)字符串拷贝,主要是没有注意到字符串的0结束符的位置 char pchTempSource[]=“Hello Word!”; char pchTempCopy[10]; memcpy(pchTempCopy,pchTempSource,10); …. 在此代码以后对pchTempCopy按照字符串操作就有可能出现内存越界问题 c) 内存的释放,原则上是谁申请,谁维护。以避免出现内存泄漏的问题。 d) 指针的使用前必须进行初始化,以避免野指针的情况。 e) 尽量避免使用全局变量。 f) 使用MFC的函数时,请特别注意帮助中提到的特殊情况,在代码中最好加简单的说明。 | |
案例分析 | ||
检查建议 | ||
修改建议 |