函式是任何一門高階語言中必須要存在的,使用函數語言程式設計可以讓程式可讀性更高,充分發揮了模組化設計思想的精髓,今天我將帶大家一起來探索函式的實現機理,探索編譯器到底是如何對函式這個關鍵字進行實現的,並使用匯編語言模擬實現函式程式設計中的引數傳遞呼叫規範等。
說到函式我們必須要提起呼叫約定這個名詞,而呼叫約定離不開棧的支援,棧在記憶體中是一塊特殊的儲存空間,遵循先進後出原則,使用push與pop指令對棧空間執行資料壓入和彈出操作。棧結構在記憶體中佔用一段連續儲存空間,通過esp與ebp這兩個棧指標暫存器來儲存當前棧起始地址與結束地址,每4個位元組儲存一個資料。
一般編譯器實現呼叫呼叫約定無外乎以下這幾種:
- CDECL:C/C++預設的呼叫約定,呼叫方平棧,不定引數的函式可以使用,引數通過堆疊傳遞.
- STDCALL:被調方平棧,不定引數的函式無法使用,引數預設全部通過堆疊傳遞.
- FASTCALL32:被調方平棧,不定引數的函式無法使用,前兩個引數放入(ECX, EDX),剩下的引數壓棧儲存.
- FASTCALL64:被調方平棧,不定引數的函式無法使用,前四個引數放入(RCX, RDX, R8, R9),剩下的引數壓棧儲存.
- System V:類Linux系統預設約定,前八個引數放入(RDI,RSI, RDX, RCX, R8, R9),剩下的引數壓棧儲存.
當棧頂指標esp小於棧底指標ebp時,就形成了棧幀,棧幀中可以定址的資料有區域性變數,函式返回地址,函式引數等。不同的兩次函式呼叫,所形成的棧幀也不相同,當由一個函式進入另一個函式時,就會針對呼叫的函式開闢出其所需的棧空間,形成此函式的獨有棧幀,而當呼叫結束時,則清除掉它所使用的棧空間,關閉棧幀,該過程通俗的講叫做棧平衡。而如果棧在使用結束後沒有恢復或過度恢復,則會造成棧的上溢或下溢,給程式帶來致命錯誤。
cdecl 呼叫者平棧: cdecl是C/C++預設呼叫約定,該呼叫方式在函式內不進行任何平衡引數操作,而是在退出函式後對esp執行加4操作,從而實現棧平衡。
該約定會採用複寫傳播優化,將每次引數平衡的操作進行歸併,在函式結束後一次性平衡棧頂指標esp,且不定引數函式可使用此約定。
stdcall 被呼叫者平棧: stdcall與cdecl只在引數平衡上有所不同,其餘部分都一樣,但該約定不定引數函式無法使用。
cdecl呼叫方式的函式在同一作用域內多次被呼叫,會在效率上比stdcall高一些,因為它可以使用複寫傳播優化,而stdcall在函式內平衡棧,無法使用複寫傳播優化。
fastcall 被呼叫者平棧: fastcall效率最高,它可利用暫存器傳遞引數,一般前兩個或前四個引數用暫存器傳遞,其餘引數傳遞則轉換為棧傳遞,此約定不定引數函式無法使用。
對於32位來說使用ecx,edx傳遞前兩個引數,後面的用堆疊傳遞。
對於64位則會使用RCX,RDX,R8,R9傳遞前四個引數,後面的用堆疊傳遞。
使用esp定址: 在O2編譯器選項中,為了提高程式執行效率,只要棧頂是穩定的,就可以不再使用ebp指標,而是利用esp指標直接訪問區域性變數,這樣可節省一個暫存器資源。
如下一段彙編程式碼,我們找到當前ESP基地址。
可以看到,esp+18就是第一個傳入引數,那麼程式在編譯時,其實已經算出來了。
使用esp定址後,不必每次進入函式後都調整棧底ebp,從而減少了ebp的使用,因此可以有效提升程式執行效率。
但每次訪問都需要計算,如果在函式執行過程中esp發生了變化,再次訪問變數就需要重新計算偏移了。
參考文獻:《C++反彙編與逆向分析技術揭祕》