当前位置:以往代写 > C/C++ 教程 >
2019-06-13

浅谈C/C++内存泄漏及检测东西

副标题#e#

对付一个c/c++措施员来说,内存泄漏是一个常见的也是令人头疼的问题。已经有很多技能被研究出来以应对这个问题,好比Smart Pointer,Garbage Collection等。Smart Pointer技能较量成熟,STL中已经包括支持Smart Pointer的class,可是它的利用好像并不遍及,并且它也不能办理所有的问题;Garbage Collection技能在Java中已经较量成熟,可是在c/c++规模的成长并不顺畅,固然很早就有人思考在C++中也插手GC的支持。现实世界就是这样的,作为一个c/c++措施员,内存泄漏是你心中永远的痛。不外亏得此刻有很多东西可以或许辅佐我们验证内存泄漏的存在,找出产生问题的代码。

内存泄漏的界说

一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指措施从堆中分派的,巨细任意的(内存块的巨细可以在措施运行期抉择),利用完后必需显示释放的内存。应用措施一般利用malloc,realloc,new等函数从堆中分派到一块内存,利用完后,措施必需认真相应的挪用free或delete释放该内存块,不然,这块内存就不能被再次利用,我们就说这块内存泄漏了。以下这段小措施演示了堆内存产生泄漏的景象:

void MyFunction(int nSize)
{
 char* p= new char[nSize];
 if( !GetStringFrom( p, nSize ) ){
  MessageBox(“Error”);
  return;
 }
 …//using the string pointed by p;
 delete p;
}

例一

当函数GetStringFrom()返回零的时候,指针p指向的内存就不会被释放。这是一种常见的产生内存泄漏的景象。措施在进口处分派内存,在出口处释放内存,可是c函数可以在任那里所退出,所以一旦有某个出口处没有释放应该释放的内存,就会产生内存泄漏。


#p#副标题#e#

广义的说,内存泄漏不只仅包括堆内存的泄漏,还包括系统资源的泄漏(resource leak),好比焦点态HANDLE,GDI Object,SOCKET, Interface等,从基础上说这些由操纵系统分派的工具也耗损内存,假如这些工具产生泄漏最终也会导致内存的泄漏。并且,某些工具耗损的是焦点态内存,这些工具严重泄漏时会导致整个操纵系统不不变。所以对比之下,系统资源的泄漏比堆内存的泄漏更为严重。

GDI Object的泄漏是一种常见的资源泄漏:

void CMyView::OnPaint( CDC* pDC )
{
 CBitmap bmp;
 CBitmap* pOldBmp;
 bmp.LoadBitmap(IDB_MYBMP);
 pOldBmp = pDC->SelectObject( &bmp );
 …
 if( Something() ){
  return;
 }
 pDC->SelectObject( pOldBmp );
 return;
}

例二

当函数Something()返回非零的时候,措施在退出前没有把pOldBmp选回pDC中,这会导致pOldBmp指向的HBITMAP工具产生泄漏。这个措施假如长时间的运行,大概会导致整个系统花屏。这种问题在Win9x下较量容易袒暴露来,因为Win9x的GDI堆比Win2k或NT的要小许多。

内存泄漏的产生方法:

以产生的方法来分类,内存泄漏可以分为4类:

1. 常发性内存泄漏。产生内存泄漏的代码会被多次执行到,每次被执行的时候城市导致一块内存泄漏。好比例二,假如Something()函数一直返回True,那么pOldBmp指向的HBITMAP工具老是产生泄漏。

2. 偶发性内存泄漏。产生内存泄漏的代码只有在某些特定情况或操纵进程下才会产生。好比例二,假如Something()函数只有在特定情况下才返回True,那么pOldBmp指向的HBITMAP工具并不老是产生泄漏。常发性和偶发性是相对的。对付特定的情况,偶发性的也许就酿成了常发性的。所以测试情况和测试要领对检测内存泄漏至关重要。

#p#副标题#e#

3. 一次性内存泄漏。产生内存泄漏的代码只会被执行一次,可能由于算法上的缺陷,导致总会有一块仅且一块内存产生泄漏。好比,在类的结构函数中分派内存,在析构函数中却没有释放该内存,可是因为这个类是一个Singleton,所以内存泄漏只会产生一次。另一个例子:

char* g_lpszFileName = NULL;
void SetFileName( const char* lpcszFileName )
{
 if( g_lpszFileName ){
  free( g_lpszFileName );
 }
 g_lpszFileName = strdup( lpcszFileName );
}

例三

假如措施在竣事的时候没有释放g_lpszFileName指向的字符串,那么,纵然多次挪用SetFileName(),总会有一块内存,并且仅有一块内存产生泄漏。

4. 隐式内存泄漏。措施在运行进程中不断的分派内存,可是直到竣事的时候才释放内存。严格的说这里并没有产生内存泄漏,因为最终措施释放了所有申请的内存。可是对付一个处事器措施,需要运行几天,几周甚至几个月,不实时释放内存也大概导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。举一个例子:

#p#分页标题#e#

class Connection
{
 public:
  Connection( SOCKET s);
  ~Connection();
  …
 private:
  SOCKET _socket;
  …
};
class ConnectionManager
{
 public:
  ConnectionManager(){}
  ~ConnectionManager(){
   list::iterator it;
   for( it = _connlist.begin(); it != _connlist.end(); ++it ){
    delete (*it);
   }
   _connlist.clear();
  }
  void OnClientConnected( SOCKET s ){
   Connection* p = new Connection(s);
   _connlist.push_back(p);
  }
  void OnClientDisconnected( Connection* pconn ){
   _connlist.remove( pconn );
   delete pconn;
  }
 private:
  list _connlist;
};

例四

#p#副标题#e#

假设在Client从Server端断开后,Server并没有呼唤OnClientDisconnected()函数,那么代表那次毗连的Connection工具就不会被实时的删除(在Server措施退出的时候,所有Connection工具会在ConnectionManager的析构函数里被删除)。当不绝的有毗连成立、断开时隐式内存泄漏就产生了。

从用户利用措施的角度来看,内存泄漏自己不会发生什么危害,作为一般的用户,基础感受不到内存泄漏的存在。真正有危害的是内存泄漏的会萃,这会最终耗损尽系统所有的内存。从这个角度来说,一次性内存泄漏并没有什么危害,因为它不会会萃,而隐式内存泄漏危害性则很是大,因为较之于常发性和偶发性内存泄漏它更难被检测到。

检测内存泄漏

检测内存泄漏的要害是要能截获住对分派内存和释放内存的函数的挪用。截获住这两个函数,我们就能跟踪每一块内存的生命周期,好比,每当乐成的分派一块内存后,就把它的指针插手一个全局的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,这时就需要截获多个分派函数)

#p#副标题#e#

在Windows平台下,检测内存泄漏的东西常用的一般有三种,MS C-Runtime Library内建的检测成果;外挂式的检测东西,诸如,Purify,BoundsChecker等;操作Windows NT自带的Performance Monitor。这三种东西各有优缺点,MS C-Runtime Library固然成果上较之外挂式的东西要弱,可是它是免费的;Performance Monitor固然无法标示出产生问题的代码,可是它能检测出隐式的内存泄漏的存在,这是其他两类东西无能为力的处所。

以下我们具体接头这三种检测东西:

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等函数时已经内建了内存泄漏的检测成果。

#p#副标题#e#

留意调查一下由MFC Application Wizard生成的项目,在每一个cpp文件的头部都有这样一段宏界说:

#ifdef _DEBUG
#define new DEBUG_NEW
#undef THIS_FILE
static char THIS_FILE[] = __FILE__;
#endif

#p#分页标题#e#

有了这样的界说,在编译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窗口里。

#p#副标题#e#

这里顺便提一下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窗口里看到以下的信息:

#p#副标题#e# {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的泄漏,它是无法检测到的,这是这种检测法的一个重大的范围性。别的,为了能记录内存块是在那边分派的,源代码必需相应的共同,这在调试一些老的措施很是贫苦,究竟修改源代码不是一件省心的事,这是这种检测法的另一个范围性。

#p#分页标题#e#

对付开拓一个大型的措施,MS C-Runtime Library提供的检测成果是远远不足的。接下来我们就看看外挂式的检测东西。我用的较量多的是BoundsChecker,一则因为它的成果较量全面,更重要的是它的不变性。这类东西假如不不变,反而会忙里添乱。到底是出自鼎鼎台甫的NuMega,我用下来根基上没有什么大问题。

利用BoundsChecker检测内存泄漏:

BoundsChecker回收一种被称为 Code Injection的技能,来截获对分派内存和释放内存的函数的挪用。简朴地说,当你的措施开始运行时,BoundsChecker的DLL被自动载入历程的地点空间(这可以通过system-level的Hook实现),然后它会修改造程中对内存分派和释放的函数挪用,让这些挪用首先转入它的代码,然后再执行本来的代码。BoundsChecker在做这些行动的时,无须修改被调试措施的源代码或工程设置文件,这使得利用它很是的轻便、直接。

#p#副标题#e#

这里我们以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
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,执行本来的三条指令,然后就是BoundsChecker的天下了。大抵上它会先记录函数的返回地点(函数的返回地点在stack上,所以很容易修改),然后把返回地点指向属于BoundsChecker的代码,接着跳到malloc函数本来的指令,也就是在00403c15的处所。当malloc函数竣事的时候,由于返回地点被修改,它会返回到BoundsChecker的代码中,此时BoundsChecker会记录由malloc分派的内存的指针,然后再跳转到到本来的返回地点去。

#p#副标题#e#

假如内存分派/释放函数在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;
 menu.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的信息:

#p#副标题#e#

#p#分页标题#e#

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检测内存泄漏。

利用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的值的变革图,阐明个中的变革和措施运行逻辑的干系,找到一些大概的原因。这是一个疾苦的进程,布满了假设、意料、验证、失败,但这也是一个积聚履历的绝好时机。

总结

内存泄漏是个大而巨大的问题,纵然是Java和.Net这样有Gabarge Collection机制的情况,也存在着泄漏的大概,好比隐式内存泄漏。由于篇幅和本领的限制,本文只能对这个主题做一个粗浅的研究。其他的问题,好比多模块下的泄漏检测,如安在措施运行时对内存利用环境举办阐明等等,都是可以深入研究的题目。假如您有什么想法,发起或发明白某些错误,接待和我交换。

    关键字:

在线提交作业