上次做了一個記憶體洩露檢測的工具,可以在系統退出的時候檢測是否發生記憶體洩露,並列印出洩露記憶體處的函式呼叫堆疊,該工具對於發現的洩露的程式確實能夠快速的定位到洩露發生的函式呼叫位置,但是人總是懶惰的動物,使用了幾次後發現用起來實在是有點不爽,不爽點主要有:
1、  每次申請記憶體的時候都記錄了呼叫堆疊,這個時候有多次分配記憶體操作用來儲存檔名、函式名、行號等資訊,但是這些資訊用起來卻非常的少,因為發生記憶體洩露的可能性還是比較低的,這樣使用該工具後程式的效能大打折扣,對於資料庫這樣的軟體來說,非常影響測試的效率。
2、  原始碼的控制並不在測試部門,因此如果要進行記憶體洩露的測試,則每次拿到原始碼之後,都需要修改程式碼,而且修改起來比較繁瑣,比如要初始化,記錄記憶體指標和大小,釋放的時候要從連結串列中將其刪除,選擇合適的位置將洩露的記憶體列印出來。第一次還很新鮮,第二次開始嫌麻煩了,還出了點錯,第三次則決定將這個方法放棄了,這實在太麻煩了,而且程式裡面還不一定會有問題。
上述理由應該足夠支援我對記憶體洩露檢測工具進行優化了,期望達到的效果就是在對程式改動儘可能小的情況下,可以配置的對程式記憶體洩露問題進行檢測。
第一想法肯定就是用鉤子截獲記憶體分配和釋放函式,這樣在記憶體分配完後將指標和大小記錄下來,釋放的時候則將其刪除掉,最後剩下的就是有記憶體洩露的地方了。接下來就是研究如何截獲程式內的函式了,Windows下面提供了專門的hook函式,但是隻能截獲訊息,程式內的函式呼叫並沒有訊息的通訊,這個方法基本上被否定了,在《程式設計高手箴言》中,有講述WindowsC的掛鉤,這裡擷取其中部分文字說明下。

Windows中,所有編譯出來的程式都有一個ImportImport中有一個JMP表,所有的函式呼叫時,先會跳到Import表中,再通過JMP跳到對應的執行函式。所以如果要掛鉤的話,只需要把JMP的地址換成要掛鉤的函式的地址即可。別人呼叫函式時就會JMP到掛鉤的函式處。

OK,實踐一下看看Windows下面函式呼叫到底是怎麼回事吧,寫個簡單的程式。
#include <stdlib.h>
#include <stdio.h> 
void test1()
{
       printf(“call test1
“);
 
      return;
} 
void test2()
{
       printf(“call test2
“);
       return;
}
void
main()
{ 
      test1();
}
test1的呼叫處設定一個斷點,除錯到此處,檢視一下彙編程式碼:
call        @ILT+10(test) (0040100f)
test1的呼叫是call了一個@ILT+10ILT的意思就是Incremental Link Table(只在Debug版本下才有),即test1函式對應的是ILT偏移10處的JMP指令,即call跳轉到地址0040100f 處,OK,按F11跟進去看看是什麼情況吧。
0040100F   jmp         test1 (00401020)
00401014   jmp         test2 (00401070)
在地址0040100f 處果然有一個JMP指令,它是跳轉到00401020處,另外test2函式的JMP指令也在這裡哦。看看00401020記憶體是什麼吧,按F11繼續跟進,發現我們終於來到了test1函式定義的地方了,首先肯定還是一些函式呼叫最基本的壓棧操作什麼的,這裡就不細說了,對我們hook並沒有什麼用處。
現在知道函式呼叫的基本方式了:call 然後JMP,要掛鉤的話,我們只需要修改ILTjmp的地址即可,例如上面,如果要hook函式test1使得其跳轉到test2中去,只需要將jmp後面的地址修改為test2的地址即可。但是要注意的是,這塊記憶體可不是隨隨便便就可以讓你改的,不然不小心寫錯記憶體地址,Windows就慘了,但是Windows也不是那麼絕情啊,提供了函式來讓一個WriteProcessMemory的函式來讓你寫EXE的記憶體,也就是說你很清楚自己在做什麼了。另外有點要說明一下,在debug下,當把斷點設定到test1()呼叫處的時候,watch一下test1,發現其值是00401020,也就是函式定義的位置,但是如果我們將test1列印出來的話,卻發現是0040100F,也就是test1ILT中的位置,這個剛開始走了不少彎路才發現。
寫個簡單的程式試下我們的想法是否能夠行得通吧。
void hookfunc()
{
       LPBYTE lpByte1;
       LPBYTE lpByte2;
       DWORD dwAddr1;
              lpByte1 = (LPBYTE)test1;       //Get old function JMP Addr
       lpByte1 = (LPBYTE)&lpByte1[1];
       lpByte2 = (LPBYTE)test2;       //Get new function JMP Addr
       lpByte2 = (LPBYTE)&lpByte2[1];       //get new and old function`s addr 
      memcpy(&dwAddr1, lpByte2, sizeof(DWORD));
       WriteProcessMemory(GetCurrentProcess(), lpByte1, &dwAddr1,sizeof(DWORD), NULL);
}
這個程式首先獲得test1test2函式的地址,也就是ILT中的記憶體地址,JMP指令佔用了一個位元組,後面JMP的地址是一個DWORD,所以先跳過JMP的一個位元組,將指標指向JMP後面的地址,將test2函式的地址拷貝出來,用WriteProcessMemory函式將test2JMP地址寫入到test1JMP地址中,除錯執行下吧,很不幸,程式掛了。上面的想法似乎哪裡出問題了?還是繼續跟蹤一下吧,在test1的呼叫處設定斷點,跟蹤到ILT中,發現:

00401005   jmp         test1+4Bh (0040107b)0040100A   jmp         test2 (00401080)

JMP指令和test2處的還是不同,後面的地址並不是我們預想的test2的地址,理論上此處應該是00401070才符合我們的想法,看來上面的想法還是有問題,不如把JMP的值列印出來看看吧,分別列印test1test2後面JMP的地址發現,一個是0X26一個是0X71,這個肯定不會是函式的絕對地址了,看來跳轉的是一個相對地址,真是笨啊,居然把彙編的知識給忘了,點選右鍵開啟Code Bytes,再來看看吧:
00401005 E9 26 00 00 00       jmp         test1 (00401030)
這條JMP指令的16進位制形式為E9 26 00 00 00,而E9是遠距離跳轉,即此處的跳轉到的地址為:00401005 + 5(本條指令的長度)+ 26 = 00401030,而00401030test1函式的真實的地址。現在疑團都解開了,按照這個邏輯,我們上面修改過後,實際跳轉的位置就應該是00401005 + 5 + 71 = 0040107B 這個並不是test2函式的真實地址。所以上面的程式還要做下改動,即計算出JMP test1JMP test2兩條指令記憶體地址的偏移,調整之後應該是:
00401005 + 5 + 0040100A – 00401005+ 71 = 00401080
00401080就是test2函式的真實地址。因此上面dwAddr1的值應該按照上面的公式計算出一個偏移量,即71 + 0040100A – 00401005= 76
再試一下,是不是發現呼叫test1,列印出來的卻是call test2
Hook函式test1成功了!
但是現在卻出現了另外一個問題,如果要呼叫真實的test1函式怎麼辦呢?現在我們在ILT中已經沒有test1JMP指令了,一種辦法是再建立一個空的函式,把這個函式在ILT中的JMP指令修改為JMP test1去,呼叫那個空的函式就等於是呼叫了真實的test1了,另外一種辦法就是把JMP test2修改成JMP test1,即交換test1test2的呼叫。第二種辦法似乎更加合適點,因為我們有理由這麼假設,test2函式是一個鉤子函式,我們一般情況下肯定不會直接來呼叫test2,而是通過hook的方式來呼叫到test2的,所以,把test1test2ILT中的指令交換之後,顯式的呼叫test1的話就會跳轉到test2函式,顯式的呼叫test2函式的話則跳轉到test1函式。另外還有一種辦法就是記錄下test1的絕對地址,自己通過彙編程式碼來直接呼叫,這個相對來說就麻煩多了,沒有試驗。
另外有一點要說的就是,對於WindowsAPI函式,debug下也並不會產生類似的JMP指令,而是直接通過CALL指令跳轉到該API函式的地址,在《程式設計高手箴言》中對這種情況進行了處理,其方法是通過申請一個全域性變數,使得其仍然按照JMP的方式來呼叫,然後修改JMP的地址就可以了。
至此我們已經實現了掛接本地程式內部函式,從根本上解決了記憶體洩露程式優化的最大障礙。接下來考慮另外一個問題,我們是否直接提供原始碼,給一個初始化的函式,將所有這些程式碼加入到工程中去,然後呼叫初始化函式來hook?相比於最開始的版本需要對所有記憶體分配、釋放程式碼都做改動,這個已經進步了很多,但是似乎還不是那麼的透明,能否做得再方便一點呢?能不能做出動態連結庫的形式,然後隱式的呼叫初始化函式?這樣使用者只需要在程式編譯的時候載入.lib檔案即可。這裡需要提一下#pragma指令的一個用法:
#pragma comment(linker,”/include:…
pragma comment指令將一個註釋記錄放入一個物件檔案或可執行檔案中,最常用的就是#pragma comment(lib,”ws2_32.lib”)這個指令告訴編譯器將ws2_32.lib庫檔案連結到目標檔案中。而linker的作用則是將一個連結選項放入目標檔案中,/include則可以強制包含某個物件。因此我們可以在DLL中建立一個用來初始化的類,並宣告一個該類的全域性物件,例如__declspec(dllexport) ResourceLeakDetector rld; rld物件匯出,在rld.h的標頭檔案中,加上一條pragma指令:#pragma comment(linker, “/include:__imp_?rld@@3VResourceLeakDetector@@A”) 用來強制包含rld物件,後面的@符合是因為我們是用c++方式匯出的,而__imp_的意思則是使用匯入物件的一個字首。為了方便使用者使用,我們另外在rld.h標頭檔案中再加入一個:#pragma comment(lib, “rld.lib”)
至此我們要使用記憶體洩露檢測程式只需要在工程中加入rld.h,然後隨便在哪個檔案裡面將rld.h include儘量就可以了。
最後就是呼叫堆疊的效率問題,因為我們在記憶體洩露情況發生很少的前提下,每次函式呼叫的時候都獲取呼叫堆疊並將檔名、函式名解析出來儲存是很耗資源的事情,一個比較合理的方法就是隻獲取函式的偏移地址,而不去解析其檔名、函式名。在最後有洩露發生的地方再根據偏移地址來解析檔名、函式名。
最後出來的記憶體洩露檢測程式在執行效率上比原始的版本提升了很多,而且使用起來也不是那麼的複雜了:)。
另外,對於路徑中包含中文名的話,以前顯示會截斷字元,究其原因是很多機器上安裝的DbgHelp.dll版本太老,沒有提供解析路徑為寬字元的函式,從windows網站下載了最新的Debug工具庫之後,該問題也已經解決。關於Vista下無法獲得呼叫堆疊的問題也隨之解決了。