1. 程式人生 > >windows下記憶體洩露檢測

windows下記憶體洩露檢測

對於一個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函式可以在任何地方退出,所以一旦有某個出口處沒有釋放應該釋放的記憶體,就會發生記憶體洩漏。

   廣義的說,記憶體洩漏不僅僅包含堆記憶體的洩漏,還包含系統資源的洩漏(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物件並不總是發生洩漏。常發性和偶發性是相對的。對於特定的環境,偶發性的也許就變成了常發性的。所以 測試環境和測試方法對檢測記憶體洩漏至關重要。

  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. 隱式記憶體洩漏。程式在執行過程中不停的分配記憶體,但是直到結束的時候才釋放記憶體。嚴格的說這裡並沒有發生記憶體洩漏,因為最終程式釋放了所有申請的記憶體。但 是對於一個伺服器程式,需要執行幾天,幾周甚至幾個月,不及時釋放記憶體也可能導致最終耗盡系統的所有記憶體。所以,我們稱這類記憶體洩漏為隱式記憶體洩漏。舉一 個例子: 

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;
};

  例四

   假設在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,這時就需要 截獲多個分配函式)

  在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等函式時已經內建了記憶體洩漏的檢測功能。

  注意觀察一下由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個字串都是飧?/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的原始碼。

   由於Debug Function實現在MS C-RuntimeLibrary中,所以它只能檢測到堆記憶體的洩漏,而且只限於malloc,realloc或strdup等分配的記憶體,而那些系統資 源,比如HANDLE,GDI Object,或是不通過C-Runtime Library分配的記憶體,比如VARIANT,BSTR的洩漏,它是無法檢測到的,這是這種檢測法的一個重大的侷限性。另外,為了能記錄記憶體塊是在哪裡 分配的,原始碼必須相應的配合,這在除錯一些老的程式非常麻煩,畢竟修改原始碼不是一件省心的事,這是這種檢測法的另一個侷限性。

  對 於開發一個大型的程式,MS C-Runtime Library提供的檢測功能是遠遠不夠的。接下來我們就看看外掛式的檢測工具。我用的比較多的是BoundsChecker,一則因為它的功能比較全 面,更重要的是它的穩定性。這類工具如果不穩定,反而會忙裡添亂。到底是出自鼎鼎大名的NuMega,我用下來基本上沒有什麼大問題。
 使用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
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分配的記憶體的指標,然後再跳轉到到原來的返回地址去。

  如果記憶體分配/釋放函式在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的資訊:

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機制的環境,也存在著洩漏的可能,比如隱式記憶體洩漏。由於篇幅和能力的限制,本文只能對這個主題做一個粗淺的研究。其他的問題,比如 多模組下的洩漏檢測,如何在程式執行時對記憶體使用情況進行分析等等,都是可以深入研究的題目。如果您有什麼想法,建議或發現了某些錯誤,歡迎和我交流。