天天看點

[Win32]一個調試器的實作(十一)顯示函數調用棧

本文講解如何在調試器中顯示函數調用棧,如下圖所示:

[Win32]一個調試器的實作(十一)顯示函數調用棧

原理

首先我們來看一下顯示調用棧所依據的原理。每個線程都有一個棧結構,用來記錄函數的調用過程,這個棧是由高位址向低位址增長的,即棧底的位址比棧頂的位址大。ESP寄存器的值是棧頂的位址,通過增加或減小ESP的值可以縮減或擴大棧的大小。上一篇文章已經簡略地介紹過在調用函數時線程棧上會發生什麼事情,現在我們再來詳細地看看這個過程:

①在棧上壓入參數。

②執行CALL指令,在棧上壓入函數的傳回位址。

③壓入EBP寄存器的值。

④将ESP寄存器的值賦給EBP寄存器。

⑤減小ESP寄存器的值,為局部變量配置設定空間。

⑥執行函數代碼。

⑦将EBP寄存器的值賦給ESP寄存器,等于回收了局部變量的空間。

⑧彈出棧頂的值,賦給EBP,即将第③步中壓入的值重新賦給EBP。

⑨執行RET指令,彈出棧頂的傳回位址。如果被調用函數負責回收參數的空間,則需要增加ESP的值。

完成第③步的指令是push ebp,它是所有函數的第一條指令,是以每個函數在棧上都會儲存有一個EBP值,标志了一個函數調用的開始,這就像分界線一樣,将每個函數調用區分開來。從一個分界線開始,到下一個分界線之間的部分稱作“棧幀”,一個棧幀代表一個函數調用。

在壓入了EBP的值之後,第④步立即将ESP的值賦給了EBP,此時ESP和EBP的值都是剛剛壓入的值的位址。從此之後,ESP的值随着指令的執行不斷變化,而EBP的值在目前棧幀中永遠不會改變,一直指向目前棧幀的起始位址,是以EBP也被稱為“棧幀指針”。

函數傳回的時候,第⑦步将EBP的值賦給ESP,此時ESP指向第③步壓入的值,然後第⑧步彈出這個值,賦給EBP,恢複EBP在上一個函數調用中的值。

函數調用過程的第③步和第④步使得各個壓入的EBP值形成了一個連結清單的結構,而EBP寄存器是連結清單的表頭,如下圖所示:

[Win32]一個調試器的實作(十一)顯示函數調用棧

正是這種連結清單結構的存在,使得擷取函數調用棧成為可能。隻要從EBP寄存器開始,沿着連結清單層層往上,就可以得到函數調用的軌迹。

由于EBP在目前函數調用中的不變性,調試版的程式都使用EBP作為變量和參數的基址,将EBP的值與一個偏移值相加就可以得到變量或參數的位址。有些發行版的程式會對函數的調用過程進行優化,省略了壓入EBP的步驟,是以不能再使用EBP作為變量和參數的基址,也不能使用EBP連結清單來擷取函數調用棧。

StackWalk64

在DbgHelp中主要使用StackWalk64函數來擷取函數調用棧,該函數的聲明如下:

BOOL WINAPI StackWalk64(
    DWORD MachineType,
    HANDLE hProcess,
    HANDLE hThread,
    LPSTACKFRAME64 StackFrame,
    PVOID ContextRecord,
    PREAD_PROCESS_MEMORY_ROUTINE64 ReadMemoryRoutine,
    PFUNCTION_TABLE_ACCESS_ROUTINE64 FunctionTableAccessRoutine,
    PGET_MODULE_BASE_ROUTINE64 GetModuleBaseRoutine,
    PTRANSLATE_ADDRESS_ROUTINE64 TranslateAddress
);      

該函數的參數比較多,這意味着靈活性,同時也意味着複雜性。事實上StackWalk64有多種不同的使用方式,使用何種方式由傳入的參數決定。在這裡我隻介紹一種最簡單的方式,這種方式已經足夠了。如果想了解更多有關StackWalk64的資訊,請參考MSDN。

MachineType參數指定CPU的類型,它的取值範圍以及意義如下表所示(摘自MSDN):

Value Meaning
IMAGE_FILE_MACHINE_I386 Intel x86
IMAGE_FILE_MACHINE_IA64 Intel Itanium Processor Family (IPF)
IMAGE_FILE_MACHINE_AMD64 x64 (AMD64 or EM64T)

調試器需要根據CPU的類型來設定該參數的值。在目前,大部分情況下都是設定為IMAGE_FILE_MACHINE_I386。

hProcess和hThread分别指定被調試程序的程序句柄以及線程句柄。而且在目前所使用的方式下,hProcess必須是符号處理器的辨別符。如果在調用SymInitialize建立符号處理器時使用的就是程序的句柄,那麼在這裡不會有任何問題;如果不是使用程序句柄,那麼就必須用另一種方式調用StackWalk64了。

StackFrame參數是一個

STACKFRAME64結構體的指針,在調用

StackWalk64之前需要初始化這個結構體,函數調用成功後,前一個棧幀的資訊會儲存到該結構體中;然後用這些資訊再次調用

StackWalk64,以擷取再前一個棧幀的資訊……由此看出,

StackWalk64的工作就是擷取指定棧幀的前一個棧幀,是以,必須要在循環中擷取所有棧幀。

STACKFRAME64結構體中需要初始化的字段有三個:

AddrPC,

AddrStack和

AddrFrame,它們分别表示程式計數器,線程棧頂以及棧幀指針,也是

EIP,

ESP和

EBP的用途。這三個字段又分别是一個

ADDRESS64結構體,這個結構體可以表示多種不同類型的位址,但

Windows應用程式隻會使用虛拟位址,是以

Mode字段應設為

AddrModeFlat,

Offset字段設為上述寄存器的值。

STACKFRAME64結構體的初始化代碼如下所示(

context為

CONTEXT結構):

STACKFRAME64 stackFrame = { 0 };
stackFrame.AddrPC.Mode = AddrModeFlat;
stackFrame.AddrPC.Offset = context.Eip;
stackFrame.AddrStack.Mode = AddrModeFlat;
stackFrame.AddrStack.Offset = context.Esp;
stackFrame.AddrFrame.Mode = AddrModeFlat;
stackFrame.AddrFrame.Offset = context.Ebp;      

第一次成功調用StackWalk64後,STACKFRAME64結構體的其它字段會被設定為适當的值,而上述的三個字段不會改變。從第二次調用開始才會真正擷取前一個棧幀,這三個字段才會改變。

ContextRecord參數是指向CONTEXT結構體的指針,調用之前需要使用GetThreadContext初始化該結構體,StackWalk64函數會使用裡面的值,并有可能會修改它。

ReadMemoryRoutine是一個回調函數的指針,當StackWalk64函數需要讀取被調試程序的記憶體時會調用該函數。如果不想提供這樣的函數,最簡單的方法就是設定該參數為NULL,這樣StackWalk64就會使用預設的函數,此時hProcess必須是一個有效的程序句柄。

FunctionTableAccessRoutine也是一個回調函數的指針,當StackWalk64需要通路函數表時會調用該函數。簡單來說,函數表儲存了每一個函數的資訊,比如起始位址,長度等。這個參數不能為NULL,但是我們可以将它設定為一個已有的函數,這個函數就是SymFunctionTableAccess64。此時hProcess必須是符号處理器的辨別符。

GetModuleBaseRoutine又是一個回調函數的指針,當StackWalk64需要擷取子產品的基位址時會調用該函數。将該參數設定為GetModuleBase64函數即可,此時hProcess也必須是符号處理器的辨別符。

最後的參數TranslateAddress仍然是回調函數的指針,不過該參數隻用于16位位址的轉換,幾乎不會用到,設定為NULL即可。

可以看到,選擇StackWalk64的何種使用方式由後面的四個參數決定。最簡單的使用方式就是直接使用NULL或DbgHelp提供的函數作為這幾個參數的值,不過此時對hProcess和hThread的限制最大。我們也可以自己提供這幾個回調參數,此時hProcess和hThread幾乎沒有什麼限制,它們隻是作為唯一辨別符。

StackWalk64調用成功後,STACKFRAME64結構體被指派,在衆多的字段中,我們隻需要關心AddrPC,它表示棧幀的傳回位址(除了第一次調用StackWalk64之外),即CALL指令下一條指令的位址,本文開頭的圖檔中顯示的位址就是AddrPC.Offset的值。由于傳回位址是指向前一個棧幀的,是以每次調用StackWalk64都會使STACKFRAME64結構體填充前一個棧幀的資訊。StackWalk64隻能擷取使用者模式下的棧幀,如果棧幀周遊完畢,它會傳回FALSE。

擷取函數名稱

STACKFRAME64結構體的AddrPC字段的值肯定是某個函數内的位址,是以可以用這個字段的值來調用SymFromAddr擷取函數的資訊,包括函數名稱。關于SymFromAddr函數的用法,前面的文章已經介紹過了,這裡不再重複。

擷取子產品名稱

顯示函數調用棧時最好同時顯示函數所在的子產品,這樣可以友善知道每個函數位于哪個子產品。有一個

SymGetModuleInfo64函數可以擷取子產品的資訊,但是卻不可以擷取子產品的名稱,而另一個

SymEnumerateModules64函數可以做到這點,雖然它的使用方式比較麻煩。

SymEnumerateModules64用于枚舉所有已經加載到符号處理器中的子產品,它的聲明如下:

1 BOOL WINAPI SymEnumerateModules64(
2     HANDLE hProcess,
3     PSYM_ENUMMODULES_CALLBACK64 EnumModulesCallback,
4     PVOID UserContext
5 );      

第一個參數是符号處理器的辨別符。第二個參數是一個回調函數的指針,對于每個子產品都會調用這個函數。該回調函數的聲明如下:

1 BOOL CALLBACK SymEnumerateModulesProc64(
2     PCSTR ModuleName,
3     DWORD64 BaseOfDll,
4     PVOID UserContext
5 );      

ModuleName是子產品檔案的絕對路徑;BaseOfDll是子產品的基位址;UserContext就是SymEnumerateModules64的第三個參數,可以通過這個參數給回調函數傳遞更多資訊。

可以這樣使用SymEnumerateModules64函數:使用STL的map建立子產品的基址-名稱映射表,在回調函數中往這個表中添加記錄。然後使用SymGetModuleBase64函數擷取子產品的基位址,使用這個基位址從表中查找子產品的名稱。具體的做法請參考示例代碼。

示例代碼

MiniDebugger中新增了一個指令:

w

顯示函數調用棧。

----------

本文是《一個調試器的實作》系列文章的最後一篇。實作一個調試器不是簡單的事情,畢竟這不是主流的應用,相關的文檔非常匮乏,隻能靠自己不斷地摸索前進。我在實作MiniDebugger的過程中遇到了非常多的困難,在解決這些困難的過程中有很多心得體會,于是将它們寫成了這一系列文章,與大家分享我的經驗,希望能讓大家少走彎路,節省寶貴的時間。雖然最終實作的MiniDebugger非常醜陋,但是透過它所表達出來的技術原理,一定能幫助大家實作一個更優秀的調試器。對技術的追求,是我不斷前進的動力。

作者:

繼續閱讀