一個跨平臺的 C++ 內存泄漏檢測器

 基本使用

對於下面這樣的一個簡單程序test.cpp:

int main()
{
	int* p1 = new int;
	char* p2 = new char[10];
	return 0;
}

 

我們的基本需求當然是對於該程序報告存在兩處內存泄漏。要做到這點的話,非常簡單,只要把debug_new.cpp也編譯、鏈接進去就可以了。在Linux下,我們使用:

g++ test.cpp debug_new.cpp -o test

 

輸出結果如下所示:

Leaked object at 0x805e438 (size 10, <Unknown>:0)
Leaked object at 0x805e410 (size 4, <Unknown>:0)

 

如果我們需要更清晰的報告,也很簡單,在test.cpp開頭加一行

#include "debug_new.h"

 

即可。添加該行後的輸出如下:

Leaked object at 0x805e438 (size 10, test.cpp:5)
Leaked object at 0x805e410 (size 4, test.cpp:4)

 

非常簡單!

 

背景知識

在new/delete操作中,C++爲用戶產生了對operator new和operator delete的調用。這是用戶不能改變的。operator new和operator delete的原型如下所示:

void *operator new(size_t) throw(std::bad_alloc);
void *operator new[](size_t) throw(std::bad_alloc);
void operator delete(void*) throw();
void operator delete[](void*) throw();

 

對於"new int",編譯器會產生一個調用"operator new(sizeof(int))",而對於"new char[10]",編譯器會產生"operator new[](sizeof(char) * 10)"(如果new後面跟的是一個類名的話,當然還要調用該類的構造函數)。類似地,對於"delete ptr"和"delete[] ptr",編譯器會產生"operator delete(ptr)"調用和"operator delete[](ptr)"調用(如果ptr的類型是指向對象的指針的話,那在operator delete之前還要調用對象的析構函數)。當用戶沒有提供這些操作符時,編譯系統自動提供其定義;而當用戶自己提供了這些操作符時,就覆蓋了編譯系統提供的版本,從而可獲得對動態內存分配操作的精確跟蹤和控制。

同時,我們還可以使用placement new操作符來調整operator new的行爲。所謂placement new,是指帶有附加參數的new操作符,比如,當我們提供了一個原型爲

void* operator new(size_t size, const char* file, int line);

 

的操作符時,我們就可以使用"new("hello", 123) int"來產生一個調用"operator new(sizeof(int), "hello", 123)"。這可以是相當靈活的。又如,C++標準要求編譯器提供的一個placement new操作符是

void* operator new(size_t size, const std::nothrow_t&);

 

其中,nothrow_t通常是一個空結構(定義爲"struct nothrow_t {};"),其唯一目的是提供編譯器一個可根據重載規則識別具體調用的類型。用戶一般簡單地使用"new(std::nothrow) 類型"(nothrow是一個nothrow_t類型的常量)來調用這個placement new操作符。它與標準new的區別是,new在分配內存失敗時會拋出異常,而"new(std::nothrow)"在分配內存失敗時會返回一個空指針。

要注意的是,沒有對應的"delete(std::nothrow) ptr"的語法;不過後文會提到另一個相關問題。

要進一步瞭解以上關於C++語言特性的信息,請參閱[Stroustrup1997],特別是6.2.6、10.4.11、15.6、19.4.5和B.3.4節。這些C++語言特性是理解本實現的關鍵。

 

檢測原理

和其它一些內存泄漏檢測的方式類似,debug_new中提供了operator new重載,並使用了宏在用戶程序中進行替換。debug_new.h中的相關部分如下:

void* operator new(size_t size, const char* file, int line);
void* operator new[](size_t size, const char* file, int line);
#define new DEBUG_NEW
#define DEBUG_NEW new(__FILE__, __LINE__)

 

拿上面加入debug_new.h包含後的test.cpp來說,"new char[10]"在預處理後會變成"new("test.cpp", 4) char[10]",編譯器會據此產生一個"operator new[](sizeof(char) * 10, "test.cpp", 4)"調用。通過在debug_new.cpp中自定義"operator new(size_t, const char*, int)"和"operator delete(void*)"(以及"operator new[]…"和"operator delete[]…";爲避免行文累贅,以下不特別指出,說到operator new和operator delete均同時包含數組版本),我可以跟蹤所有的內存分配調用,並在指定的檢查點上對不匹配的new和delete操作進行報警。實現可以相當簡單,用map記錄所有分配的內存指針就可以了:new時往map里加一個指針及其對應的信息,delete時刪除指針及對應的信息;delete時如果map裏不存在該指針爲錯誤刪除;程序退出時如果map裏還存在未刪除的指針則說明有內存泄漏。

不過,如果不包含debug_new.h,這種方法就起不了作用了。不僅如此,部分文件包含debug_new.h,部分不包含debug_new.h都是不可行的。因爲雖然我們使用了兩種不同的operator new --"operator new(size_t, const char*, int)"和"operator new(size_t)"-- 但可用的"operator delete"還是隻有一種!使用我們自定義的"operator delete",當我們刪除由"operator new(size_t)"分配的指針時,程序將認爲被刪除的是一個非法指針!我們處於一個兩難境地:要麼對這種情況產生誤報,要麼對重複刪除同一指針兩次不予報警:都不是可接受的良好行爲。

看來,自定義全局"operator new(size_t)"也是不可避免的了。在debug_new中,我是這樣做的:

void* operator new(size_t size)
{
	return operator new(size, "<Unknown>", 0);
}

 

但前面描述的方式去實現內存泄漏檢測器,在某些C++的實現中(如GCC 2.95.3中帶的SGI STL)工作正常,但在另外一些實現中會莫名其妙地崩潰。原因也不復雜,SGI STL使用了內存池,一次分配一大片內存,因而使利用map成爲可能;但在其他的實現可能沒這樣做,在map中添加數據會調用operator new,而operator new會在map中添加數據,從而構成一個死循環,導致內存溢出,應用程序立即崩潰。因此,我們不得不停止使用方便的STL模板,而使用手工構建的數據結構:

struct new_ptr_list_t
{
	new_ptr_list_t*		next;
	const char*			file;
	int					line;
	size_t				size;
};

 

我最初的實現方法就是每次在使用new分配內存時,調用malloc多分配 sizeof(new_ptr_list_t) 個字節,把分配的內存全部串成一個一個鏈表(利用next字段),把文件名、行號、對象大小信息分別存入file、line和size字段中,然後返回(malloc返回的指針 + sizeof(new_ptr_list_t))。在delete時,則在鏈表中搜索,如果找到的話((char*)鏈表指針 + sizeof(new_ptr_list_t) == 待釋放的指針),則調整鏈表、釋放內存,找不到的話報告刪除非法指針並abort。

至於自動檢測內存泄漏,我的做法是生成一個靜態全局對象(根據C++的對象生命期,在程序初始化時會調用該對象的構造函數,在其退出時會調用該對象的析構函數),在其析構函數中調用檢測內存泄漏的函數。用戶手工調用內存泄漏檢測函數當然也是可以的。

基本實現大體就是如此。

 

可用性改進

上述方案最初工作得相當好,直到我開始創建大量的對象爲止。由於每次delete時需要在鏈表中進行搜索,平均搜索次數爲(鏈表長度/2),程序很快就慢得像烏龜爬。雖說只是用於調試,速度太慢也是不能接受的。因此,我做了一個小更改,把指向鏈表頭部的new_ptr_list改成了一個數組,一個對象指針放在哪一個鏈表中則由它的哈希值決定。--用戶可以更改宏DEBUG_NEW_HASH和DEBUG_NEW_HASHTABLESIZE的定義來調整debug_new的行爲。他們的當前值是我測試下來比較滿意的定義。

使用中我們發現,在某些特殊情況下(請直接參看debug_new.cpp中關於DEBUG_NEW_FILENAME_LEN部分的註釋),文件名指針會失效。因此,目前的debug_new的缺省行爲會複製文件名的頭20個字符,而不只是存儲文件名的指針。另外,請注意原先new_ptr_list_t的長度爲16字節,現在是32字節,都能保證在通常情況下內存對齊。

此外,爲了允許程序能和 new(std::nothrow) 一起工作,我也重載了operator new(size_t, const std::nothrow_t&) throw();不然的話,debug_new會認爲對應於 new(nothrow) 的delete調用刪除的是一個非法指針。由於debug_new不拋出異常(內存不足時程序直接報警退出),所以這一重載的操作只不過是調用 operator new(size_t) 而已。這就不用多說了。

前面已經提到,要得到精確的內存泄漏檢測報告,可以在文件開頭包含"debug_new.h"。我的慣常做法可以用作參考:

#ifdef _DEBUG
#include "debug_new.h"
#endif

 

包含的位置應當儘可能早,除非跟系統的頭文件(典型情況是STL的頭文件)發生了衝突。在某些情況下,可能會不希望debug_new重定義new,這時可以在包含debug_new.h之前定義DEBUG_NEW_NO_NEW_REDEFINITION,這樣的話,在用戶應用程序中應使用debug_new來代替new(順便提一句,沒有定義DEBUG_NEW_NO_NEW_REDEFINITION時也可以使用debug_new代替new)。在源文件中也許就該這樣寫:

#ifdef _DEBUG
#define DEBUG_NEW_NO_NEW_REDEFINITION
#include "debug_new.h"
#else
#define debug_new new
#endif

 

並在需要追蹤內存分配的時候全部使用debug_new(考慮使用全局替換)。

用戶可以選擇定義DEBUG_NEW_EMULATE_MALLOC,這樣debug_new.h會使用debug_new和delete來模擬malloc和free操作,使得用戶程序中的malloc和free操作也可以被跟蹤。在使用某些編譯器的時候(如Digital Mars C++ Compiler 8.29和Borland C++ Compiler 5.5.1),用戶必須定義NO_PLACEMENT_DELETE,否則編譯無法通過。用戶還可以使用兩個全局布爾量來調整debug_new的行爲:new_verbose_flag,缺省爲false,定義爲true時能在每次new/delete時向標準錯誤輸出顯示跟蹤信息;new_autocheck_flag,缺省爲true,即在程序退出時自動調用check_leaks檢查內存泄漏,改爲false的話用戶必須手工調用check_leaks來檢查內存泄漏。

需要注意的一點是,由於自動調用check_leaks是在debug_new.cpp中的靜態對象析構時,因此不能保證用戶的全局對象的析構操作發生在check_leaks調用之前。對於Windows上的MSVC,我使用了"#pragma init_seg(lib)"來調整對象分配釋放的順序,但很遺憾,我不知道在其他的一些編譯器中(特別是,我沒能成功地在GCC中解決這一問題)怎麼做到這一點。爲了減少誤報警,我採取的方式是在自動調用了check_leaks之後設new_verbose_flag爲true;這樣,就算誤報告了內存泄漏,隨後的delete操作還是會被打印顯示出來。只要泄漏報告和delete報告的內容一致,我們仍可以判斷出沒有發生內存泄漏。

Debug_new也能檢測對同一指針重複調用delete(或delete無效指針)的錯誤。程序將顯示錯誤的指針值,並強制調用abort退出。

還有一個問題是異常處理。這值得用專門的一節來進行說明。

 

構造函數中的異常

我們看一下以下的簡單程序示例:

#include <stdexcept>
#include <stdio.h>
void* operator new(size_t size, int line)
{
	printf("Allocate %u bytes on line %d\\n", size, line);
	return operator new(size);
}
class Obj {
public:
	Obj(int n);
private:
	int _n;
};
Obj::Obj(int n) : _n(n)
{
	if (n == 0) {
		throw std::runtime_error("0 not allowed");
	}
}
int main()
{
	try {
		Obj* p = new(__LINE__) Obj(0);
		delete p;
	} catch (const std::runtime_error& e) {
		printf("Exception: %s\\n", e.what());
	}
}

 

看出代碼中有什麼問題了嗎?實際上,如果我們用MSVC編譯的話,編譯器的警告信息已經告訴我們發生了什麼:

test.cpp(27) : warning C4291: 'void *__cdecl operator new(unsigned int,int)' : 
no matching operator delete found; memory will not be freed if initialization throws
 an exception

 

好,把debug_new.cpp鏈接進去。運行結果如下:

Allocate 4 bytes on line 27 Exception: 0 not allowed Leaked object at 
 00342BE8 (size 4, <Unknown>:0) 	

 

啊哦,內存泄漏了不是!

當然,這種情況並非很常見。可是,隨着對象越來越複雜,誰能夠保證一個對象的子對象的構造函數或者一個對象在構造函數中調用的所有函數都不會拋出異常?並且,解決該問題的方法並不複雜,只是需要編譯器對 C++ 標準有較好支持,允許用戶定義 placement delete 算符([C++1998],5.3.4節;網上可以找到1996年的標準草案,比如下面的網址 http://www.comnets.rwth-aachen.de/doc/c++std/expr.html#expr.new)。在我測試的編譯器中,GCC(2.95.3或更高版本,Linux/Windows)和MSVC(6.0或更高版本)沒有問題,而Borland C++ Compiler 5.5.1和Digital Mars C++ Compiler(到v8.38爲止的所有版本)則不支持該項特性。在上面的例子中,如果編譯器支持的話,我們就需要聲明並實現 operator delete(void*, int) 來回收new分配的內存。編譯器不支持的話,需要使用宏讓編譯器忽略相關的聲明和實現。如果要讓debug_new在Borland C++ Compiler 5.5.1或Digital Mars C++ Compiler下編譯的話,用戶必須定義宏NO_PLACEMENT_DELETE;當然,用戶得自己注意小心構造函數中拋出異常這個問題了。

 

方案比較

IBM developerWorks上刊載了洪琨先生設計實現的一個Linux上的內存泄漏檢測方法([洪琨2003])。我的方案與其相比,主要區別如下:

優點:

  • 跨平臺:只使用標準函數,並且在GCC 2.95.3/3.2(Linux/Windows)、MSVC 6、Digital Mars C++ 8.29、Borland C++ 5.5.1等多個編譯器下調試通過。(雖然Linux是我的主要開發平臺,但我發現,有時候能在Windows下編譯運行代碼還是非常方便的。)
  • 易用性:由於重載了operator new(size_t)--洪琨先生只重載了operator new(size_t, const char*, int)--即使不包含我的頭文件也能檢測內存泄漏;程序退出時能自動檢測內存泄漏;可以檢測用戶程序(不包括系統/庫文件)中malloc/free產生的內存泄漏。
  • 靈活性:有多個靈活的可配置項,可使用宏定義進行編譯時選擇。
  • 可重入性:不使用全局變量,沒有嵌套delete問題。
  • 異常安全性:在編譯器支持的情況下,能夠處理構造函數中拋出的異常而不發生內存泄漏。

缺點:

  • 單線程模型:跨平臺的多線程實現較爲麻煩,根據項目的實際需要,也爲了代碼清晰簡單起見,我的方案不是線程安全的;換句話說,如果多個線程中同時進行new或delete操作的話,後果未定義。
  • 未實現運行中內存泄漏檢測報告機制:沒有遇到這個需求J;不過,如果要手工調用check_leaks函數實現的話也不困難,只是跨平臺性就有點問題了。
  • 不能檢測帶 [] 算符和不帶 [] 算符混用的不匹配:主要也是需求問題(如果要修改實現的話並不困難)。
  • 不能在錯誤的delete調用時顯示文件名和行號:應該不是大問題;由於我重載了operator new(size_t),可以保證delete出錯時程序必然有問題,因而我不只是顯示警告信息,而且會強制程序abort,可以通過跟蹤程序、檢查abort時程序的調用棧知道問題出在哪兒。

另外,現在已存在不少商業和Open Source的內存泄漏檢測器,本文不打算一一再做比較。Debug_new與它們相比,功能上總的來說仍較弱,但是,其良好的易用性和跨平臺性、低廉的附加開銷還是具有很大優勢的。

 

總結和討論

以上段落基本上已經說明了debug_new的主要特點。下面做一個小小的總結。

重載的算符:

  • operator new(size_t, const char*, int)
  • operator new[](size_t, const char*, int)
  • operator new(size_t)
  • operator new[](size_t)
  • operator new(size_t, const std::nothrow_t&)
  • operator new[](size_t, const std::nothrow_t&)
  • operator delete(void*)
  • operator delete[](void*)
  • operator delete(void*, const char*, int)
  • operator delete[](void*, const char*, int)
  • operator delete(void*, const std::nothrow_t&)
  • operator delete[](void*, const std::nothrow_t&)

提供的函數:

  • check_leaks() 
    檢查是否發生內存泄漏

提供的全局變量

  • new_verbose_flag 
    是否在new和delete時"羅嗦"地顯示信息
  • new_autocheck_flag 
    是否在程序退出是自動檢測一次內存泄漏

可重定義的宏:

  • NO_PLACEMENT_DELETE 
    假設編譯器不支持placement delete(全局有效)
  • DEBUG_NEW_NO_NEW_REDEFINITION 
    不重定義new,假設用戶會自己使用debug_new(包含debug_new.h時有效)
  • DEBUG_NEW_EMULATE_MALLOC 
    重定義malloc/free,使用new/delete進行模擬(包含debug_new.h時有效)
  • DEBUG_NEW_HASH 
    改變內存塊鏈表哈希值的算法(編譯debug_new.cpp時有效)
  • DEBUG_NEW_HASHTABLE_SIZE 
    改變內存塊鏈表哈希桶的大小(編譯debug_new.cpp時有效)
  • DEBUG_NEW_FILENAME_LEN 
    如果在分配內存時複製文件名的話,保留的文件名長度;爲0時則自動定義DEBUG_NEW_NO_FILENAME_COPY(編譯debug_new.cpp時有效;參見文件中的註釋)
  • DEBUG_NEW_NO_FILENAME_COPY 
    分配內存時不進行文件名複製,而只是保存其指針;效率較高(編譯debug_new.cpp時有效;參見文件中的註釋)

我本人認爲,debug_new目前的一個主要缺陷是不支持多線程。對於某一特定平臺,要加入多線程支持並不困難,難就難在通用上(當然,條件編譯是一個辦法,雖然不夠優雅)。等到C++標準中包含線程模型時,這個問題也許能比較完美地解決吧。另一個辦法是使用像boost這樣的程序庫中的線程封裝類,不過,這又會增加對其它庫的依賴性--畢竟boost並不是C++標準的一部分。如果項目本身並不用boost,單爲了這一個目的使用另外一個程序庫似乎並不值得。因此,我自己暫時就不做這進一步的改進了。

另外一個可能的修改是保留標準operator new的異常行爲,使其在內存不足的情況下拋出異常(普通情況)或是返回NULL(nothrow情況),而不是像現在一樣終止程序運行(參見debug_new.cpp的源代碼)。這一做法的難度主要在於後者:我沒想出什麼方法,可以保留 new(nothrow) 的語法,同時能夠報告文件名和行號,並且還能夠使用普通的new。不過,如果不使用標準語法,一律使用debug_new和debug_new_nothrow的話,那還是非常容易實現的。

如果大家有改進意見或其它想法的話,歡迎來信討論。

debug_new 的源代碼目前可以在 dbg_new.zip處下載。

在這篇文章的寫完之後,我終於還是實現了一個線程安全的版本。該版本使用了一個輕量級的跨平臺互斥體類fast_mutex(目前支持Win32和POSIX線程,在使用GCC(Linux/MinGW)、MSVC時能通過命令行參數自動檢測線程類型)。有興趣的話可在http://mywebpage.netscape.com/yongweiwu/dbg_new.tgz下載。

 

參考資料

[C++1998] ISO/IEC 14882. Programming Languages-C++, 1st Edition. International Standardization Organization, International Electrotechnical Commission, American National Standards Institute, and Information Technology Industry Council, 1998

[Stroustrup1997] Bjarne Stroustrup. The C++ Programming Language, 3rd Edition. Addison-Wesley, 1997

[洪琨2003] 洪琨。 《如何在 linux 下檢測內存泄漏》,IBM developerWorks 中國網站。

關於作者

吳詠煒,目前在Linux上從 事高性能***檢測系統的研發。對於開發跨平臺、高性能、可重用的C++代碼有着 濃厚的興趣。[email protected]可以跟他聯繫。

 

原文地址:http://www.ibm.com/developerworks/cn/linux/l-mleak2/index.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章