函數是任何一門高級語言中必須要存在的,使用函數式編程可以讓程式可讀性更高,充分發揮了模塊化設計思想的精髓,今天我將帶大家一起來探索函數的實現機理,探索編譯器到底是如何對函數這個關鍵字進行實現的,並使用彙編語言模擬實現函數編程中的參數傳遞調用規範等。說到函數我們必須要提起調用約定這個名詞,而調用約定離... ...
函數是任何一門高級語言中必須要存在的,使用函數式編程可以讓程式可讀性更高,充分發揮了模塊化設計思想的精髓,今天我將帶大家一起來探索函數的實現機理,探索編譯器到底是如何對函數這個關鍵字進行實現的,並使用彙編語言模擬實現函數編程中的參數傳遞調用規範等。
說到函數我們必須要提起調用約定這個名詞,而調用約定離不開棧的支持,棧在記憶體中是一塊特殊的存儲空間,遵循先進後出原則,使用push與pop指令對棧空間執行數據壓入和彈出操作。棧結構在記憶體中占用一段連續存儲空間,通過esp與ebp這兩個棧指針寄存器來保存當前棧起始地址與結束地址,每4個位元組保存一個數據。
當棧頂指針esp小於棧底指針ebp時,就形成了棧幀,棧幀中可以定址的數據有局部變數,函數返回地址,函數參數等。不同的兩次函數調用,所形成的棧幀也不相同,當由一個函數進入另一個函數時,就會針對調用的函數開闢出其所需的棧空間,形成此函數的獨有棧幀,而當調用結束時,則清除掉它所使用的棧空間,關閉棧幀,該過程通俗的講叫做棧平衡。而如果棧在使用結束後沒有恢復或過度恢復,則會造成棧的上溢或下溢,給程式帶來致命錯誤。
一般情況下在Win32環境預設遵循的就是STDCALL,而在Win64環境下使用的則是FastCALL,在Linux系統上則遵循SystemV的約定,這裡我整理了他們之間的異同點.
- CDECL:C/C++預設的調用約定,調用方平棧,不定參數的函數可以使用,參數通過堆棧傳遞.
- STDCALL:被調方平棧,不定參數的函數無法使用,參數預設全部通過堆棧傳遞.
- FASTCALL32:被調方平棧,不定參數的函數無法使用,前兩個參數放入(ECX, EDX),剩下的參數壓棧保存.
- FASTCALL64:被調方平棧,不定參數的函數無法使用,前四個參數放入(RCX, RDX, R8, R9),剩下的參數壓棧保存.
- System V:類Linux系統預設約定,前八個參數放入(RDI,RSI, RDX, RCX, R8, R9),剩下的參數壓棧保存.
首先先來寫一段非函數版的堆棧使用案例,案例中模擬了編譯器如何生成Main函數棧幀以及如何對棧幀初始化和使用的流程,筆者通過自己的理解寫出了Debug版本的一段仿寫代碼。
.386p
.model flat,stdcall
option casemap:none
include windows.inc
include kernel32.inc
includelib kernel32.lib
.code
main PROC
push ebp ; 保存棧底指針ebp
mov ebp,esp ; 調整當前棧底指針到棧頂
sub esp,0e4h ; 抬高棧頂esp開闢局部空間
push ebx ; 保存寄存器
push esi
push edi
lea edi,dword ptr [ ebp - 0e4h ] ; 取出當前函數可用棧空間首地址
mov ecx,39h ; 填充長度
mov eax,0CCCCCCCCh ; 填充四位元組數據
rep stosd ; 將當前函數局部空間填充初始值
; 使用當前函數可用局部空間
xor eax,eax
mov dword ptr [ ebp - 08h ],1
mov dword ptr [ ebp - 014h ],2 ; 使用局部變數
mov dword ptr [ ebp - 020h ],3
mov eax,dword ptr [ ebp - 014h ]
add eax,dword ptr [ ebp - 020h ]
; 如果指令影響了堆棧平衡,則需要平棧
push 4 ; 此情況,由於入棧時沒有修改過,平棧只需add esp,12
push 5
push 6 ; 如果代碼沒有自動平棧,則需要手動平
add esp,12 ; 每個指令4位元組 * 多少條影響
push 10
push 20
push 30 ; 使用3條指令影響堆棧
pop eax
pop ebx ; 彈出兩條
add esp,4 ; 修複堆棧時只需要平一個變數
pop edi ; 恢復寄存器
pop esi
pop ebx
add esp,0e4h ; 降低棧頂esp開闢的局部空間,局部空間被釋放
cmp ebp,esp ; 檢測堆棧是否平衡,不平衡則直接停機
jne error
pop ebp
mov esp,ebp ; 恢復基址指針
int 3
error:
int 3
main ENDP
END main
5.1 CDECL
CDECL是C/C++中的一種預設調用約定(調用者平棧)。這種調用方式規定函數調用者在將參數壓入棧中後,再將控制權轉移到被調用函數,被調用函數通過棧頂指針ESP來訪問這些參數。函數返回時,由調用者程式負責將堆棧平衡清除。CDECL調用約定的特點是簡單易用,但相比於其他調用約定,由於棧平衡的操作需要在函數返回後再進行,因此在一些情況下可能會帶來一些性能上的開銷。
該調用方式在函數內不進行任何平衡參數操作,而是在退出函數後對esp執行加4操作,從而實現棧平衡。該約定會採用覆寫傳播
優化,將每次參數平衡的操作進行歸併,在函數結束後一次性平衡棧頂指針esp,且不定參數函數也可使用此約定。
.386p
.model flat,stdcall
option casemap:none
include windows.inc
include kernel32.inc
includelib kernel32.lib
.code
function PROC
push ebp
mov ebp,esp
sub esp,0cch
push ebx
push esi
push edi
lea edi,dword ptr [ ebp - 0cch ] ; 初始化局部變數
mov ecx,33h
mov eax,0CCCCCCCCh
rep stosd
mov eax,dword ptr [ ebp + 08h ] ; 第一個變數(傳入參數1)
add eax,dword ptr [ ebp + 0Ch ] ; 第二個變數(傳入參數2)
add eax,dword ptr [ ebp + 10h ] ; 第三個變數(傳入參數3)
mov dword ptr [ ebp - 08h ],eax ; 將結果放入到局部變數
mov eax,dword ptr [ ebp - 08h ] ; 給eax寄存器返回
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret
function endp
main PROC
; 單獨調用並無優勢
push 3
push 2
push 1
call function ; __cdecl functin(1,2,3)
add esp,12
; 連續調用則可體現出優勢
push 5
push 4
push 3
call function ; __cdecl function(3,4,5)
mov ebx,eax
push 6
push 7
push 8
call function ; __cdecl function(8,7,6)
mov ecx,eax
add esp,24 ; 一次性平兩次棧
int 3
main ENDP
END main
5.2 STDCALL
STDCALL 調用約定規定由被調用者負責將堆棧平衡清除。STDCALL是一種被調用者平棧的約定,這意味著,在函數調用過程中,被調用函數使用棧來存儲傳遞的參數,併在函數返回之前移除這些參數,這種方式可以使調用代碼更短小簡潔。STDCALL與CDECL只在參數平衡上有所不同,其餘部分都一樣,但該約定不定參數函數無法使用。
通過以上分析發現_cdecl
與_stdcall
兩者只在參數平衡上有所不同,其餘部分都一樣,但經過優化後_cdecl
調用方式的函數在同一作用域內多次使用,會在效率上比_stdcall
髙,這是因為_cdecl
可以使用覆寫傳播優化,而_stdcall
的平棧都是在函數內部完成的,無法使用覆寫傳播這種優化方式。
.386p
.model flat,stdcall
option casemap:none
include windows.inc
include kernel32.inc
includelib kernel32.lib
.code
function PROC
push ebp
mov ebp,esp
sub esp,0cch
push ebx
push esi
push edi
lea edi,dword ptr [ ebp - 0cch ] ; 初始化局部變數
mov ecx,33h
mov eax,0CCCCCCCCh
rep stosd
mov eax,dword ptr [ ebp + 08h ] ; 第一個變數(傳入參數1)
add eax,dword ptr [ ebp + 0Ch ] ; 第二個變數(傳入參數2)
add eax,dword ptr [ ebp + 10h ] ; 第三個變數(傳入參數3)
mov dword ptr [ ebp - 08h ],eax ; 將結果放入到局部變數
mov eax,dword ptr [ ebp - 08h ] ; 給eax寄存器返回
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret 12 ; 應用stdcall時,通過ret對目標平棧
function endp
main PROC
push 3
push 2
push 1
call function ; __stdcall functin(1,2,3)
mov ebx,eax ; 獲取返回值
push 4
push 5
push 6
call function ; __stdcall function(6,5,4)
mov ecx,eax ; 獲取返回值
add ebx,ecx ; 結果相加
int 3
main ENDP
END main
5.3 FASTCALL
FASTCALL是一種針對寄存器的調用約定。它通常採用被調用者平衡堆棧的方式,類似於STDCALL調用約定。但是,FASTCALL約定規定函數的前兩個參數在ECX和EDX寄存器中傳遞,節省了壓入堆棧所需的指令。此外,函數使用堆棧來傳遞其他參數,併在返回之前使用類似於STDCALL約定的方式來平衡堆棧。
FASTCALL的優點是可以在發生大量參數傳遞時加快函數的處理速度,因為使用寄存器傳遞參數比使用堆棧傳遞參數更快。但是,由於FASTCALL約定使用的寄存器數量比CDECL和STDCALL約定多,因此它也有一些限制,例如不支持使用浮點數等實現中需要使用多個寄存器的數據類型。
FASTCALL效率最高,其他兩種調用方式都是通過棧傳遞參數,唯獨_fastcall
可以利用寄存器傳遞參數,一般前兩個或前四個參數用寄存器傳遞,其餘參數傳遞則轉換為棧傳遞,此約定不定參數函數無法使用。
- 對於32位來說使用ecx,edx傳遞前兩個參數,後面的用堆棧傳遞。
- 對於64位則會使用RCX,RDX,R8,R9傳遞前四個參數,後面的用堆棧傳遞。
.386p
.model flat,stdcall
option casemap:none
include windows.inc
include kernel32.inc
includelib kernel32.lib
.code
function PROC
push ebp
mov ebp,esp
sub esp,0e4h
push ebx
push esi
push edi
push ecx
lea edi,dword ptr [ ebp - 0e4h ] ; 初始化局部變數
mov ecx,39h
mov eax,0CCCCCCCCh
rep stosd
pop ecx
mov dword ptr [ ebp - 14h ],edx ; 讀入第二個參數放入局部變數
mov dword ptr [ ebp - 8h ],ecx ; 讀入第一個參數放入局部變數
mov eax,dword ptr [ ebp - 8h ] ; 從局部變數內讀入第一個參數
add eax,dword ptr [ ebp - 14h ] ; 從局部變數內讀入第二個參數
add eax,dword ptr [ ebp + 8h ] ; 從堆棧中讀入第三個參數
add eax,dword ptr [ ebp + 0ch ] ; 從堆棧中讀入第四個參數
add eax,dword ptr [ ebp + 10h ] ; 從堆棧中讀入第五個參數
add eax,dword ptr [ ebp + 14h ] ; 從堆棧中讀入第六個參數
mov dword ptr [ ebp - 20h ],eax ; 將結果給第三個局部變數
mov eax,dword ptr [ ebp - 20h ] ; 返回數據
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret 16 ; 平棧
function endp
main PROC
push 6
push 5
push 4
push 3
mov edx,2
mov ecx,1 ; __fastcall function(1,2,3,4,5,6)
call function ; 調用函數
int 3
main ENDP
END main
5.4 使用ESP寄存器定址
編譯器開啟了O2優化模式選項,則為了提高程式執行效率,只要棧頂是穩定的,編譯器編譯時就不再使用ebp指針了,而是利用esp指針直接訪問局部變數,這樣可節省一個寄存器資源。
在程式編譯時編譯器會自動為我們計算ESP基地址與傳入變數的參數偏移,使用esp定址後,不必每次進入函數後都調整棧底ebp,從而減少了ebp的使用,因此可以有效提升程式執行效率。但如果在函數執行過程中esp發生了變化,再次訪問變數就需要重新計算偏移了。
.386p
.model flat,stdcall
option casemap:none
include windows.inc
include kernel32.inc
includelib kernel32.lib
.code
function PROC
push ebp
mov ebp,esp
sub esp,0ch
push esi
; 動態計算出四個參數
lea eax,dword ptr [ esp - 4h + 01ch ] ; 計算參數1 [esp+18]
lea ebx,dword ptr [ esp - 0h + 01ch ] ; 計算參數2 [esp+1c]
lea ecx,dword ptr [ esp + 4h + 01ch ] ; 計算參數3 [esp+20]
lea edx,dword ptr [ esp + 8h + 01ch ] ; 計算參數4 [esp+24]
; 如果ESP被干擾則需要動態調整
lea eax,dword ptr [ esp - 4h + 01ch ] ; 當前參數1的地址
push ebx
push ecx ; 指令讓ESP被減去8
lea eax,dword ptr [ esp - 4h + 01ch + 8h ] ; 此處需要+8h修正堆棧
add esp,0ch
pop esi
mov esp,ebp
pop ebp
ret
function endp
main PROC
push 5
push 3
push 4
push 1
call function
int 3
main ENDP
END main
5.5 使用數組指針傳值
這裡我們以一維數組為例,二維數組的傳遞其實和一維數組是相通的,只不過在定址方式上要使用二維數組的定址公式,此外傳遞數組其實本質上就是傳遞指針,所以數組與指針的傳遞方式也是相通的。
使用彙編仿寫數組傳遞方式,在main
函數內我們動態開闢一塊棧空間,並將數組元素依次排列在棧內,參數傳遞時通過lea eax,dword ptr [ ebp - 18h ]
獲取到數組棧地址空間,由於main函數並不會被釋放所以它的棧也是穩定的,調用function
函數時只需要將棧首地址通過push eax
的方式傳遞給function
函數內,併在函數內通過mov ecx,dword ptr [ ebp + 8h ]
獲取到函數基地址,通過比例因數定位棧空間。
.386p
.model flat,stdcall
option casemap:none
include windows.inc
include kernel32.inc
includelib kernel32.lib
.code
function PROC
push ebp
mov ebp,esp
sub esp,0cch
push ebx
push esi
push edi
lea edi,dword ptr [ ebp - 0cch ]
mov ecx,33h
mov eax,0CCCCCCCCh
rep stosd
; 檢索數組第一個元素
mov eax,1
mov ecx,dword ptr [ ebp + 8h ] ; 定位數組基地址
mov edx,dword ptr [ ecx + eax * 4 ] ; 定位元素
; 檢索數組第二個元素
mov eax,2
mov ecx,dword ptr [ ebp + 8h ]
mov edx,dword ptr [ ecx + eax * 4 ]
pop edi
pop esi
pop ebx
add esp,0cch
mov esp,ebp
pop ebp
ret
function ENDP
main PROC
push ebp
mov ebp,esp
sub esp,0dch
push ebx
push esi
push edi
lea edi,dword ptr [ ebp - 0dch ]
mov ecx,37h
mov eax,0CCCCCCCCh
rep stosd
mov dword ptr [ ebp - 18h ],1 ; 局部空間存儲數組元素
mov dword ptr [ ebp - 14h ],2
mov dword ptr [ ebp - 10h ],3
mov dword ptr [ ebp - 0ch ],4
mov dword ptr [ ebp - 8h ],5
push 5
lea eax,dword ptr [ ebp - 18h ] ; 取數組首地址併入棧
push eax
call function ; 調用函數 function(5,eax)
add esp,8 ; 平棧
pop edi
pop esi
pop ebx
add esp,0dch
mov esp,ebp
pop ebp
ret
main ENDP
END main
5.6 指向函數的指針
程式通過CALL指令跳轉到函數首地址執行代碼,既然是地址那就可以使用指針變數來存儲函數的首地址,該指針變數被稱作函數指針。
在編譯時編譯器為函數代碼分配一段存儲空間,這段存儲空間的起始地址就是這個函數的指針,我們可以調用這個指針實現間接調用指針所指向的函數。
#include <iostream>
void __stdcall Show(int x, int y)
{
printf("%d --> %d \n",x,y);
}
int __stdcall ShowPrint(int nShow, int nCount)
{
int ref = nShow + nCount;
return ref;
}
int main(int argc, char* argv[])
{
// 空返回值調用
void(__stdcall *pShow)(int,int) = Show;
pShow(1,2);
// 帶參數調用返回
int(__stdcall *pShowPrint)(int, int) = ShowPrint;
int Ret = pShowPrint(2, 4);
printf("返回值 = %d \n", Ret);
return 0;
}
首先我們使用彙編仿寫ShowPrint
函數以及該函數所對應的int(__stdcall *pShowPrint)(int, int)
函數指針,看一下在彙編層面該如何實現這個功能。
.386p
.model flat,stdcall
option casemap:none
include windows.inc
include kernel32.inc
includelib kernel32.lib
.code
function PROC
push ebp
mov ebp,esp
sub esp,0cch
push ebx
push esi
push edi
lea edi,dword ptr [ ebp - 0cch ]
mov ecx,33h
mov eax,0CCCCCCCCh
rep stosd
mov eax,dword ptr [ ebp + 4h ] ; 此處+4得到的是返回後上一條指令地址
mov eax,dword ptr [ ebp + 8h ] ; 得到第一個堆棧傳入參數地址
mov ebx,dword ptr [ ebp + 0ch ] ; 得到第二個堆棧傳入參數地址
add eax,ebx ; 遞增並返回到EAX
pop edi
pop esi
pop ebx
add esp,0cch
mov esp,ebp
pop ebp
ret
function ENDP
main PROC
push ebp
mov ebp,esp
sub esp,0d8h
push ebx
push esi
push edi
lea edi,dword ptr [ ebp - 0d8h ]
mov ecx,36h
mov eax,0CCCCCCCCh
rep stosd
lea eax,function ; 獲取函數指針
mov dword ptr [ ebp - 8h ],eax ; 將指針放入局部空間
push 4
push 2 ; 傳入參數
call dword ptr [ ebp - 8h ] ; 調用函數
add esp,8 ; 平棧
pop edi
pop esi
pop ebx
add esp,0d8h
mov esp,ebp
pop ebp
ret
main ENDP
END main
本文作者: 王瑞
本文鏈接: https://www.lyshark.com/post/17fb1a42.html
版權聲明: 本博客所有文章除特別聲明外,均採用 BY-NC-SA 許可協議。轉載請註明出處!