5.5 組合語言:函數呼叫約定

2023-08-22 18:03:44

函數是任何一門高階語言中必須要存在的,使用函數語言程式設計可以讓程式可讀性更高,充分發揮了模組化設計思想的精髓,今天我將帶大家一起來探索函數的實現機理,探索編譯器到底是如何對函數這個關鍵字進行實現的,並使用組合語言模擬實現函數程式設計中的引數傳遞呼叫規範等。

說到函數我們必須要提起呼叫約定這個名詞,而呼叫約定離不開棧的支援,棧在記憶體中是一塊特殊的儲存空間,遵循先進後出原則,使用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 許可協定。轉載請註明出處!