__cdecl、__stdcall、__fastcall 與 __pascal 淺析
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”,這是什麽意思呢?實際上呢,就相當於先執行“ADD ESP, 0x8”再執行“POP EIP” 。換言之,就是將 ESP 加 8,然後正常 RETN 返回到調用者。
我們不難發現,__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 | 從左到右 | 被調用者 |
__cdecl、__stdcall、__fastcall 與 __pascal 淺析