從棧不平衡問題 理解 calling convention
最近在開發的過程中遇到了幾個很詭異的問題,造成了棧不平衡從而導致程序崩潰。
經過幾經排查發現是和調用規約(calling convention)相關的問題,特此分享出來。
首先,講一下什麽是調用規約。
函數調用規約,是指當一個函數被調用時,函數的參數會被傳遞給被調用的函數和返回值會被返回給調用函數。函數的調用規約就是描述參數是怎麽傳遞和由誰平衡堆棧的,當然還有返回值。
名稱 | 誰負責參數出棧 | 參數壓棧順序 |
Cdecl | Caller(調用者) | 從右往左 |
Pascal | Callee(被調用者) | 從左往右 |
Stdcall | Callee(被調用者) | 從右往左 |
Fastcall | Callee(被調用者) | 從右往左 |
Thiscall | Callee(被調用者) | 從右往左 |
下面,本文給出一個簡短的代碼示例:
1 #include<iostream> 2 3 typedef void(*funcPointer)(int); 4 5 void __stdcall testFunc(int i, int j) 6 { 7 std::cout << "i is:" << i << std::endl; 8 std::cout << "j is:" << j << std::endl;9 return ; 10 } 11 12 void callFunc(void(*func)(int)) 13 { 14 func(1); 15 } 16 17 void main() 18 { 19 callFunc((funcPointer)testFunc); 20 getchar(); 21 }
在第12行代碼定義的callFunc函數,它的參數是一個“返回值為void,參數為一個int型的函數指針”,並在內部調用這個函數指針傳實參為1。
在地5行代碼定義了函數testFunc,它的參數為兩個int,同時為它定義了__stdcall的調用約定。
在main函數的19行中進行調用的時候,對testFunc使用了(funcPointer)進行強行類型轉換,並將它傳入callFunc作為實參進行調用。
在x86平臺Debug版本運行這段程序的結果如下:
程序因為異常停在第19行了。
在X86平臺Release版本運行這段程序的結果如下:
程序雖然也執行到結尾了,但是由於傳參不正確,所以結果不對,而且也無法正常停機。
在X64平臺Debug版本和Release版本運行這段程序的結果類似如下:
程序沒有發生異常,順利執行完畢,只是運行結果不正確。
為什麽是這樣一個結果呢?下面,本文就來細細講解。
在計算機中有兩個寄存器稱為ebp和esp,它們分別稱為基址指針寄存器和堆棧指針寄存器。
esp和ebp分別指向當前運行函數的棧頂和棧底。
由於callFunc是以cdecl的方式進行聲明的,而testFunc是以stdcall的方式進行聲明的。因此在callFunc調用testFunc時,調用者(caller)負責將參數push進棧。因為此時testFunc已經進行了強行類型轉換,因此編譯器認為它的輸入參數即為1個int,所以在入棧時callFunc將1個int壓入堆棧中,接著調用testFunc。當testFunc執行完畢之後,由於它是stdcall所以由被調用者(callee)即testFunc自身負責參數的pop退棧。而此時,由於testFunc函數本身只有2個int型參數,所以在出棧時即pop兩個int,導致了棧不平衡問題的產生!(而且在執行完testFunc之後,由於callFunc是cdecl類型的所以它仍然會再進行退棧的操作)如下圖所示。
此處,截取了實際程序的反匯編代碼進行分析:
上圖是testFunc的反匯編,為了使反匯編看起來沒那麽冗長作者將其中一些代碼註釋掉了。可以看到,最後在結束時進行了ret 8的操作,即向上退8(兩個int的大小)。(此處可以看到stdcall聲明的函數進行自行參數退棧的實現)
上圖是callFunc的反匯編,可以看到在調用子函數結束之後它進行了esp+4的操作,即退棧1個int(因為棧的地址空間是從大向小增長的所以是加操作)。
而且最後在它ret時是沒有跟參數的,代表cdecl的函數不進行自我參數退棧操作。
關於Debug和Release,X86和X64結果不一樣的原因
①在Debug版本下,Visual Studio的編譯器會自動在編譯參數中加入/RTC,即Runtime Check。啟用運行時錯誤檢查。其中包括了:堆棧指針驗證,該操作檢測堆棧指針損壞。 調用約定不匹配可能導致堆棧指針損壞。 例如,使用函數指針調用 DLL 中作為 __stdcall 導出的函數,但將指向該函數的指針聲明為 __cdecl。此時編譯器會在每個函數的開始和結束處加入針對esp指針的檢查。詳見:MSDN_CL_編譯參數_RTC。
因此在Debug版本下會報出上文所提到的異常。而在Release版本下,因為默認不進行太多檢查即RTC被關閉,因此並不會出現彈出異常提示的情況。
Debug版反匯編代碼如下:
可以從反匯編的代碼中看到,在進入子函數之前先將esp的值保存在esi中,當執行完畢之後對比esi和現在的esp的值,即RTC。
②在X86版本下,在退棧時是以esp中的值為基址進行加減操作來進行的。而RTC又是對esp指針進行檢查,因此此時會報出異常。
而在X64版本下,在退棧時是以ebp中的值為基址進行加減操作來進行的,RTC檢查的是esp,毫不相關,所以不會抱任何異常。
誠然,這只是一個小“缺陷”,很多人認為不必在意。但是小小的問題也會在某一刻產生巨大的隱患,造成整個軟件的崩潰。
從棧不平衡問題 理解 calling convention