call 指令與 retn 指令
首先我們得了解 CALL 和 RETN 指令的作用,才能更好地理解調用規則,這也是先決條件。
實際上,CALL 指令就是先將下一條指令的 EIP 壓棧,然后 JMP 跳轉到對應的函數的首地址,當執行完函數體后,通過 RETN 指令從堆棧中彈出 EIP,程序就可以繼續執行 CALL 的下一條指令。
__cdecl 與 __stdcall 調用規則
C/C++ 中不同的函數調用規則會生成不同的機器代碼,產生不同的微觀效果,接下來讓我們一起來淺析四種調用規則的原理和它們各自的異同。首先我們通過一段 C 語言代碼來引導我們的淺析過程。
這里我們編寫了三個函數,它們的功能都是返回兩個參數的相加結果,只是每個函數都有不一樣的調用規則。
我們使用 printf 函數主要是為了在 OllyDBG 中能夠快速下斷點,以確定后邊調用三個函數的位置,便於分析。在這里我給每個函數都用了內聯的 NOP 指令來分隔開,圖中也用紅框標明,這樣可以便於區分每個函數的調用過程。通過一些簡單的步驟,我們用 OllyDBG 查看了編譯后代碼的“真面目”。代碼中有 4 個 CALL,第一個是 printf,我們不關心這個。后面三個分別是具有 __cdecl,__stdcall,__fastcall 調用規則的函數 CALL(這里我已經做了注釋)。
在這里為了循序漸進,我們先介紹 __cdecl 與 __stdcall 調用規則,后面我們會接着淺析 __fastcall 調用規則。
首先,我們得明白一個教條(其實也是自己概括的),那就是 —— 調用規則的區別產生其實就是由於調用者與被調用者之間的“責任分配”問題。
代碼段中的第 2 個就是 __cdecl 調用規則的 CALL。__cdecl 是 C/C++、MFC 默認的調用規則。我們可以看到,在執行 CALL 之前,程序會將參數按照從右到左的方式壓棧,這里是兩個整型參數,每壓棧一個 ESP 都會減 4,這樣下來 ESP 會減少 8,然后 CALL 這個函數。常規地,我們可以看到,這個 CALL 里面參數的處理和通常情況下一致,先將 EBP 壓棧保存現場,然后使 EBP 重合於 ESP,再通過 EBP + 偏移地址來取得兩個參數值,賦值再累加到 EAX 中,EAX 將作為返回值給調用者使用,還原 EBP 現場,調用 RETN 返回到調用者。最后,使得 ESP 加 8。哎!這剛好和開頭對稱嘛!為了堆棧平衡,ESP 最終又被拉回到了 CALL 之前的位置。我們暫且可以小結一下,實際上在 __cdecl 調用規則中,需要調用者來負責清棧操作(由調用者將 ESP 拉高以維持堆棧平衡)。
代碼段中的第 3 個是 __stdcall 調用規則的 CALL。__stdcall 調用規則在 Win32 API 函數中用的比較多。跟 __cdecl 一樣,在執行 CALL 之前,程序會先將參數從右到左依次壓棧,我們跟進 CALL 里面,可以看到以下的反匯編代碼,我們很容易發現,除了最后一條指令,其他的指令與 __cdecl 調用規則是基本一樣的。最后一條指令是“RETN 0x8”,這是什么意思呢?實際上呢,就相當於先執行“POP EIP”再執行“ADD ESP, 8” (當然,EIP如果先改變了,就無法控制下一條指令的正常執行了,這僅僅是個流程,可以這么理解)。
我們不難發現,__stdcall 調用規則使得被調用者來執行清棧操作(由被調用者函數自身將 ESP 拉高以維持堆棧平衡),這也是 __stdcall 與 __cdecl 調用規則的最根本的區別。
__cdecl 偏向於把責任分配給調用者,動腦筋想想,我們的程序在 CALL __cdecl 調用規則的函數之前,把參數從右到左依次壓棧,CALL 返回后,剩下的清棧操作都交給調用者處理,調用者負責拉高 ESP。再回來想想 __stdcall,在 CALL 中將調用者的 EBP 壓棧以保存現場,然后使 EBP 對齊於 ESP,然后通過 EBP + 偏移地址取得參數,並且經過加法得到 EAX 返回值,從堆棧彈出 EBP 恢復現場,但是最后不一樣的地方,程序將執行 “RETN 0x8” 將 ESP 拉回之前的 ESP + 8 的位置,換言之,被調用者將負責清棧操作。這就是之前所謂的“責任分配”的區別。
__fastcall 調用規則
不難揣測 fastcall 的英文意思貌似是“快速調用”,這一點與它的調用規則息息相關,它的快速是有原因的,讓我們繼續來看看之前那張反匯編的截圖,代碼段中的第 4 個就是 __fastcall 調用規則的 CALL。進 CALL 前,出乎意料地,程序將兩個參數從右到左分別傳給了 EDX,ECX 寄存器,講到這里,學過計算機系統相關知識的人很容易理解為什么這叫“快速調用”了,寄存器比內存快很多很多倍,可以認為傳參給寄存器,要比在內存中更快得多,效率更高。
由於參數是直接傳遞給了寄存器,堆棧並未發生改變,在 CALL 中,EBP 壓棧,EBP 和 ESP 對齊之后,ESP 減 8,這個操作有點像對局部變量分配堆棧空間(這里有我之前一篇博客,對局部變量的存放規則做了淺析),然后程序將 EDX,ECX 分別賦值給 EBP – 8 與 EBP – 4 這兩個地址,這個過程相當於用寄存器給局部變量賦值,接下來運算結果將保存在 EAX 中,ESP 歸位,EBP 恢復現場,最后 RETN 返回調用者領空。
本例只傳送了兩個整數型參數。其實呢,對於 __fastcall 調用規則,左邊開始的兩個不大於4字節(int)的參數分別放在ECX和EDX寄存器,其余的參數仍舊自右向左壓棧傳送。並且,__fastcall 調用規則使得被調用者負責清理棧的操作(由被調用者函數自身將 ESP 拉高以維持堆棧平衡),這一點和 __stdcall 一樣。
__pascal 調用規則
__pascal 是用於 Pascal / Delphi 編程語言的調用規則,C/C++ 中也可以使用這種調用規則。簡單地說,__pascal 調用規則與 __stdcall 不同的地方就是壓棧順序恰恰相反,前面講到的三種調用規則的壓棧順序都是從右到左依次入棧,__pascal 則是從左到右依次入棧。並且,被調用者(函數自身)將自行完成清棧操作,這和 __stdcall,__fastcall 一樣。由於比較簡單,我就沒有做出示例。
小結
做個表格來小結一下,很直觀就能看出這四種調用規則的異同:
調用規則 |
入棧順序 |
清棧責任 |
__cdecl |
從右到左 |
調用者 |
__stdcall |
從右到左 |
被調用者 |
__fastcall |
從右到左(先 EDX、ECX,再到堆棧) |
被調用者 |
__pascal |
從左到右 |
被調用者 |