1. 程式人生 > >一個小專案 --- C++實現記憶體洩漏檢查器

一個小專案 --- C++實現記憶體洩漏檢查器


先貼出程式碼:

.h:

// 注意, 我們的標頭檔案是要被包含進被測試的.cpp 的, 所以標頭檔案中不要出現"多餘的"程式碼及庫檔案, 以免影響被測檔案
#ifndef LEAK_DETECTOR_H_
#define LEAK_DETECTOR_H_
// 有個小技巧: C/C++庫中標準的標頭檔案巨集定義是這種形式: _STDIO_H( 標準規定保留下劃線作字首 )
// 所以平時我們為了避免自己定義的巨集意外地與標準標頭檔案定義的巨集發生衝突, 我們使用下劃線作字尾, 並且不用下劃線作字首



// 過載版本: operator new/new[]( ), operator delete/delete[]( ) 的宣告
void* operator new( size_t size, char* file, size_t line );
void* operator new[]( size_t size, char* file, size_t line );
// 注意到, 上面我們過載的函式中, 第一個引數和第三個引數的型別是size_t
// 其中第一個引數size為 sizeof的返回值, 所以為size_t型別
// 第三個引數的含義為 行號, 是我們過載 operator new/new[]( )後自己加的引數, 此處也可以用unsigned int. 但最好用 size_t. 原因是size_t的可移植性好. 理由見上面連結
void operator delete( void* ptr );
void operator delete[]( void* ptr );


// 這個巨集在LeakDetector.cpp中定義, 以防止LeakDetector.cpp中, 我們自己過載的 operator new/new[]( ) 被巨集替換. 而這個巨集在被測試檔案中未定義(我們除過在被測試檔案包含LeakDetector.h標頭檔案外, 不改變被測試檔案的程式碼), 所以 替換被測試檔案new運算子, 傳進兩個引數 檔名 和 行號 使用我們自己的過載版本operator new/new[]( size_t size, char* file, size_t line )
#ifndef NEW_OVERLOAD_IMPLEMENTATION_
#define new new( __FILE__, __LINE__ )
// 預定義巨集: 
// __FILE__(兩個下劃線): 代表當前原始碼檔名的字串文字(我們用這個巨集獲得存在記憶體洩漏檔案的檔名)
// __LINE__(兩個下劃線): 代表當前原始碼檔案中的行號的整數常量(我們用這個巨集獲得存在記憶體洩漏檔案記憶體洩漏的行號)
#endif


class LeakDetector{
public:
	// LeakDetector.cpp和被測試的.cpp都會包 LeakDetector.h標頭檔案
	// 因此兩個原始檔中會建立兩個靜態LeakDetector類物件 exitCounter (兩個靜態類物件同名, 但是它們的連結屬性均為內連結(只在當前原始檔有效), 因此不會重定義), 如果此時兩個物件析構, 會呼叫兩次解構函式, 呼叫兩次記憶體洩漏檢測函式. 而我們的預期是隻呼叫一次記憶體洩漏檢測函式. 所以我們宣告一個所有類物件共享的靜態變數來實現我們的目的
	static size_t _callCount;

	LeakDetector( ){ ++_callCount; }
	~LeakDetector( ){ if(0 == --_callCount) _LeakDetector( ); }

private:
	void _LeakDetector( );
};

// 靜態物件
static LeakDetector exitCounter;



#endif


.cpp:

// 這個巨集保證 LeakDetector.cpp 中的new 不會被LeakDetector.h中的 巨集替換 替換掉
#define NEW_OVERLOAD_IMPLEMENTATION_


#include <iostream>								//cout 
#include <cstring>								//strlen 和 strcpy
#include "LeakDetector.h"


// 初始化 LeakDetector類中定義的靜態變數
size_t LeakDetector::_callCount = 0;


// 我們使用帶頭節點的雙向連結串列來手動管理記憶體申請與釋放, 頭節點的_prev指向最後一個結點, _next指向第一個結點
// 雙向連結串列結構
typedef struct MemoryList{
	struct MemoryList* _prev;
	struct MemoryList* _next;
	size_t _size;								// operator new( )申請的記憶體大小
	bool   _isArray;							// 是否為申請陣列(即使用operator new[]( ) 而不是 operator new( ))
	char*  _file;								// 如果有, 儲存存在記憶體洩漏檔案的檔案資訊
	size_t _line;								// 儲存存在記憶體洩漏位置的行號
} MemoryList;

// 建立一個頭結點, 它的前後指標均初始化為指向自己(插入、刪除雙向連結串列中結點 和 _LeakDetector( )函式中遍歷雙向連結串列時, 這樣初始化的作用就體現出來了)。使用靜態變數使其只在本檔案內有效
// 我們只使用這個頭節點的 _prev 和 _next 成員
static MemoryList memoryListHead = { &memoryListHead, &memoryListHead, 0, false, NULL, 0 }; 


// 儲存未釋放的記憶體大小
static size_t memoryAllocated = 0;


// 對雙向連結串列採用頭插法分配記憶體
void* AllocateMemory( size_t size, bool array, char* file, size_t line){
	// 我們需要為我們管理記憶體分配的 MemoryList結點 也申請記憶體
	// 計算新的大小
	size_t newSize = size + sizeof( MemoryList );

	// 把接收到的地址強轉為 MemoryList*, 以便我們後續操作
	// 由於過載了new, 所以我們使用 malloc 來申請記憶體
	MemoryList* newElem = (MemoryList*)malloc(newSize);

	// 更新MemoryList結構成員的值
	newElem->_prev = &memoryListHead;
	newElem->_next = memoryListHead._next;
	newElem->_size = size;						// 注意, 此處為size而不是newSize. 因為我們管理記錄的是 new申請的記憶體, 驗證它是否未釋放, 存在記憶體洩漏問題. 申請 newSize的記憶體(為 MemoryList結點多申請出的記憶體), 只是為了實現手動管理記憶體所必須, 這個記憶體我們一定會釋放, 不需關注. 所以儲存 時用size而不是newSize
	newElem->_isArray = array;

	// 如果有檔案資訊, 則儲存下來
	if ( NULL != file ){
		newElem->_file = (char*)malloc(strlen(file) + 1);
		strcpy( newElem->_file, file );
	}
	else
		newElem->_file = NULL;

	// 儲存行號
	newElem->_line = line;

	// 更新雙向連結串列結構
	memoryListHead._next->_prev = newElem;
	memoryListHead._next = newElem;

	// 更新未釋放的記憶體數
	// 我們管理的只是 new申請的記憶體. 為memoryListHead結點多申請的記憶體,和為儲存檔案資訊多申請記憶體無關, 這些記憶體我們一定會釋放, 所以這裡只記錄size
	memoryAllocated += size;

	// 返回new 申請的記憶體地址
	// 將newElem強轉為char* 型別(保證指標+1時每次加的位元組數為1) + memoryListHead所佔用位元組數( 總共申請的newSize位元組數 減去memoryListHead結點佔用的位元組數, 即為new申請的位元組數 )
	return (char*)newElem + sizeof(memoryListHead);
}

// 對雙向連結串列採用頭刪法手動管理釋放記憶體
// 注意: delete/delete[]時 我們並不知道它操作的是雙向連結串列中的哪一個結點
void  DeleteMemory( void* ptr, bool array ){
	// 注意, 堆的空間自底向上增長. 所以此處為減
	MemoryList* curElem = (MemoryList*)( (char*)ptr - sizeof(MemoryList) );

	// 如果 new/new[] 和 delete/delete[] 不匹配使用. 直接返回
	if ( curElem->_isArray != array )
		return;

	// 更新連結串列結構
	curElem->_next->_prev = curElem->_prev;
	curElem->_prev->_next = curElem->_next;

	// 更新memoryAllocated值
	memoryAllocated -= curElem->_size;

	// 如果curElem->_file不為NULL, 釋放儲存檔案資訊時申請的記憶體
	if ( NULL != curElem->_file )
		free( curElem->_file );

	// 釋放記憶體
	free( curElem );
}


// 過載new/new[]運算子
void* operator new( size_t size, char* file, size_t line ){
	return AllocateMemory( size, false, file, line );
}

void* operator new[]( size_t size, char* file, size_t line ){
	return AllocateMemory( size, true, file, line );
}

// 過載delete/delete[]運算子
void operator delete( void* ptr ){
	DeleteMemory( ptr, false );
}

void operator delete[]( void* ptr ){
	DeleteMemory( ptr, true );
}


// 我們定義的最後一個靜態物件析構時呼叫此函式, 判斷是否有記憶體洩漏, 若有, 則打印出記憶體洩漏資訊
void LeakDetector::_LeakDetector( ){
	if ( 0 == memoryAllocated ){
		std::cout << "恭喜, 您的程式碼不存在記憶體洩漏!" << std::endl;
		return;
	}
	
	// 存在記憶體洩漏
	// 記錄記憶體洩漏次數
	size_t count = 0;

	// 若不存在記憶體洩漏, 則雙向連結串列中應該只剩下一個頭節點
	// 若存在記憶體洩漏, 則雙向連結串列中除頭節點之外的結點都已洩露,個數即記憶體洩漏次數
	MemoryList* ptr = memoryListHead._next;
	while ( (NULL != ptr) && (&memoryListHead != ptr) ){
		if ( true == ptr->_isArray )
			std::cout << "new[] 空間未釋放, ";
		else
			std::cout << "new 空間未釋放, ";

		std::cout << "指標: " << ptr << " 大小: " << ptr->_size;

		if ( NULL != ptr->_file )
			std::cout << " 位於 " << ptr->_file << " 第 " << ptr->_line << " 行";
		else
			std::cout << " (無檔案資訊)";

		std::cout << std::endl;

		ptr = ptr->_next;
		++count;
	}

	std::cout << "存在" << count << "處記憶體洩露, 共包括 "<< memoryAllocated << " byte." << std::endl;
	return;
}


test.cpp:

#include "LeakDetector.h"


int main() {

    // 忘記釋放指標 b 申請的記憶體, 從而導致記憶體洩露
    int *a = new int;
	int *b = new int[12];

    delete a;

    return 0;

}


思路:

1.記憶體洩露產生於 new/new[] 操作進行後沒有執行 delete/delete[]

2.最先被建立的物件, 其解構函式是最後執行的

解決方法:

1.過載operator new/new[ ] 與 operator delete/delete[ ], 並藉助雙向連結串列結構(帶頭節點)我們自己手動管理記憶體

2.建立一個靜態物件, 在程式退出時才呼叫這個靜態物件的解構函式( 在解構函式中, 我們呼叫記憶體洩漏檢測函式 ), 這就得保證, 我們的靜態物件必須先於被測檔案的靜態物件建立(如果有), 這樣我們的靜態物件才會最後一個析構(必須保證最後一個析構, 以免發生問題( 如: 假設被測檔案也有一個靜態物件, 且靜態物件申請了空間,  被測檔案的靜態物件在我們的靜態物件析構後析構, 這樣記憶體洩漏檢測就會不準確 )), 所以被測檔案必須第一個包含我們的 LeakDetector.h 標頭檔案保證我們的靜態物件第一個建立.

這樣兩個步驟的好處在於: 無需修改原始程式碼的情況下, 就能進行記憶體檢查. 這同時也是我們希望看到的

既然我們已經過載了new/new[], delete/delete[]操作符, 那麼我們很自然就能想到通過手動管理記憶體申請和釋放, 如果我們delete/delete[]時沒有將申請的記憶體全部釋放完畢, 那麼一定發生了記憶體洩露. 接下來一個問題就是,使用什麼結構來實現手動管理記憶體:

不妨使用雙向連結串列來實現記憶體洩露檢查. 原因在於, 對於記憶體檢查器來說, 並不知道實際程式碼在什麼時候會需要申請記憶體空間, 所以使用線性表並不夠合理, 一個動態的結構(連結串列)是非常便捷的. 而我們在刪除記憶體檢查器中的物件時, 需要更新整個結構, 對於單向連結串列來說, 也是不夠便利的

我們的記憶體洩漏檢測函式會在靜態物件析構時被呼叫, 這時候其他所有申請的物件都已經完成析構, 這時, 如果我們的雙向連結串列除頭節點外仍有結點, 那麼一定是洩露且尚未釋放的記憶體, 所以我們只需要遍歷雙向連結串列即可得到我們需要的結果

new操作符是由C++語言內建的, 就像sizeof那樣, 不能改變意義, 總是做相同的事情:

  1. 呼叫operator new (sizeof(A))
  2. 呼叫A:A()
  3. 返回指標

第一: 它分配足夠的記憶體, 用來放置某型別的物件.

第二: 它呼叫一個建構函式, 為剛才分配的記憶體中的那個物件設定初始值。

第三: 物件被分配了空間並構造完成, 返回一個指向該物件的指標

new operator(即 new 操作符)總是做這兩件事,無論如何你是不能改變其行為。

能夠改變的是用來容納物件的那塊記憶體的分配行為, new operator(new)呼叫某個函式, 執行必要的記憶體分配動作, 你可以重寫或者過載那個函式, 改變其行為. 這個函式名稱就叫operator new 。

函式 operator new 通常宣告如下:

void * operator new (size_t size);

其返回型別void*. 即返回一個指標, 指向一塊原始的, 未設定初始值的記憶體

函式中的size_t引數表示需要分配多少記憶體, 你可以將operator new 過載, 加上額外的引數, 但第一個引數型別必須總是size_t.


可以這樣理解: new int -> new(sizeof(int)) -> operator new(sizeof(int)/*即size_t size*/)->過載. 

不能改變關鍵字new的行為 但我們能過載operator new( size_t size )

注意: operator new( size_t size )中的引數size 是new 計算的. 不用我們自己計算. 我們過載時, 只需要開闢 size個位元組的記憶體大小即可

同樣, delete關鍵字做兩件事:

第一: 呼叫物件解構函式

第二: 呼叫operator delete( )釋放物件所佔用的記憶體

string* pte = new string("KobeBryant");

delete ptr -> ptr->~string -> operator delete( ptr )

void operator delete(void* memoryToBeDeallocated);

同樣, 我們能做的也只有過載operator delete( void* ) 來自己手動管理記憶體釋放.

接下來我們來說下new [] 和 delete []

:

AA為4位元組: new AA[10] -> 並不是只開40個位元組大小, 而要在物件陣列的大小上加上一個額外資料,用於編譯器區分物件陣列大小: 

sizeof(size_t) + 4 * sizeof(AA)

這個多出來的記憶體用來存申請數目, 由編譯器在operator new之後, new expression前設定, 你最終拿到的地址實際上和operator new分到的地址並不一樣

編譯器通過這個數目(物件陣列中元素個數), 來確定呼叫幾次物件的建構函式.

當然,這時, 呼叫幾次建構函式已經和operator new[]無關了. operator new[]根本無法判斷到底分配了幾個物件,它只知道一共有多少位元組

所以, 我們過載時不用關注這個步驟.

這是編譯器要做的.

在實現層,兩者就是一樣的,通常new[] 直接呼叫new實現

而operator new 和 operator new[] 的引數 size也是由編譯器傳給我們的, 我們不用自己去計算要多開的 sizeof(size_t)大小.

總結下: 我們要過載的 operator new 和·operator new[] 只需要做一件事: 呼叫malloc 開編譯器傳給我們的 size 個位元組即可.

同樣delete[ ]:

1. 因為之前儲存過物件個數, 它呼叫物件個數次解構函式

2. 呼叫 operator delete[ ] 對記憶體進行釋放(釋放物件佔用的記憶體和多開的儲存物件個數的記憶體)

operator delete[]( void* ptr ) 不管物件個數的事, 它也不知道這些. 它只需要做一件事: 釋放ptr指向記憶體即可. 通常呼叫 operator delete()實現

我們過載的operator delete[] 也實現同樣功能.

總體總結下:

new做了兩件事:
1. 呼叫operator new分配空間。
2. 呼叫建構函式初始化物件。
delete也做了兩件事:
1. 呼叫解構函式清理物件
2. 呼叫operator delete釋放空間
new[N]:
1. 呼叫operator new分配空間。
2. 呼叫N次建構函式分別初始化每個物件。
delete[]:
1. 呼叫N次解構函式清理物件。

2. 呼叫operator delete釋放空間。


1. operator new/operator delete operator new[]/operator delete[] 和 malloc/free用法一樣。
2. 他們只負責分配空間/釋放空間,不會呼叫物件建構函式/解構函式來初始化/清理物件。
3. 實際operator new和operator delete只是malloc和free的一層封裝。
 

對了, 並不是所有的型別new []都會多開sizeof(size_t)個位元組儲存物件個數: 為什麼要儲存物件個數呢?  因為編譯器得知道它要呼叫幾次建構函式和解構函式: 問題來了, 內建型別(int, char等)需要呼叫解構函式嗎: 答案是不一定, 參考這篇文章: http://blog.csdn.net/mind_v/article/details/70740354 所以, 自定義型別, 一定會多開. 內建型別: 不一定.

:http://www.cnblogs.com/fly1988happy/archive/2012/04/26/2471099.html

:http://blog.csdn.net/wudaijun/article/details/9273339#t7

:https://www.zhihu.com/question/25497587

通過這個專案我獲得的知識點(有些知識點是之前學過但遺忘了進行復習):

0.更深入瞭解了 C++ new/new[]和delete/delete[]背後所做的事

1.LeakDetector.cpp 和 被測試檔案.cpp 都包含了 LeakDetector.h. 會造成我們這個類重定義嗎? 

不會:  在一個給定的原始檔中,一個類只能被定義一次。如果在多個檔案中定義一個類,那麼每個檔案中的定義必須是完全相同的

因為這遵守“單一定義規則”(One-Definition Rule, ODR)。根據此規則, 如果對同一個類的兩個定義完全相同且出現在不同編譯單位,會被當作同一個定義。

這裡標頭檔案分別被兩個不同的編譯單位(LeakDetector.cpp, test.cpp)包含,滿足ODR規則,會被當作同一個定義。 所以不會有衝突。
此外,模板和inline函式也適用此規則。http://blog.csdn.net/baoxiaofeicsdn/article/details/48338515
2.__FILE__, __LINE__兩個預定義巨集
3.帶頭節點雙向連結串列結構(頭插, 和刪除任意節點)
4.變數儲存持續性,作用域, 連結性
5.堆疊生長規律
6.標頭檔案中通常包含的內容: 函式原型, 使用#define/const 定義的符號常量, 結構宣告, 類宣告, 模板宣告, 行內函數
7.複習了條件編譯
8.類中靜態資料成員與靜態類方法: 
靜態資料成員: 屬於所有物件而不是特定物件, 為了實現共享資料
靜態類方法: 通過類名::FunName( )呼叫, 不能通過物件呼叫.
它們不能通過物件呼叫是因為它們沒有隱含的this指標
http://www.cnblogs.com/ppgeneve/p/5091794.html
http://blog.csdn.net/kerry0071/article/details/25741425/
9.複習了多個.cpp原始檔 如何編譯連結成 一個可執行程式過程
10.typedef struct結構體時一些規則. 還有typedef 和 #define 區別
11.unsigned int / unsigned long 和 size_t 故事: 
size_t平臺移植性更好:
http://blog.csdn.net/lemoncyb/article/details/12012987
http://jeremybai.github.io/blog/2014/09/10/size-t
12.靜態物件:可以呼叫它的所有成員, 包括非靜態成員. 但靜態函式智慧呼叫靜態成員.
靜態物件何時建立, 何時銷燬:http://blog.csdn.net/shltsh/article/details/45959493