跳轉到內容

x86 反彙編/呼叫約定

來自 Wikibooks,開放書籍,開放世界

呼叫約定

[編輯 | 編輯原始碼]

呼叫約定是函式在機器上實現和呼叫的標準化方法。呼叫約定指定編譯器設定訪問子程式的方法。理論上,只要函式具有相同的呼叫約定,來自任何編譯器的程式碼都可以相互連線。然而,在實踐中,情況並非總是如此。

呼叫約定指定如何將引數傳遞給函式、如何將返回值傳遞迴函式、如何呼叫函式以及函式如何管理堆疊及其堆疊幀。簡而言之,呼叫約定指定 C 或 C++ 中的函式呼叫如何轉換為組合語言。不言而喻,這種轉換有很多方法,這就是指定某些標準方法如此重要的原因。如果沒有這些標準約定,使用不同編譯器建立的程式幾乎不可能相互通訊和互動。

在 32 位 x86 處理器上使用 C 語言時,有三種主要的呼叫約定:STDCALL、CDECL 和 FASTCALL。此外,還有一種通常與 C++ 一起使用的呼叫約定:THISCALL。[1] 還有其他呼叫約定,包括 PASCAL 和 FORTRAN 約定,等等。

其他處理器,例如 AMD64 處理器(也稱為 x86-64 處理器),每個都有自己的呼叫約定。[2][3]

術語說明

[編輯 | 編輯原始碼]

我們將在下面使用一些術語,它們大多數都是常識,但值得直接說明

傳遞引數
"傳遞引數"是指呼叫函式將資料寫入被呼叫函式將查詢它們的位置。在執行call指令之前傳遞引數。
從右到左和從左到右
這些描述了引數傳遞給子程式的方式,指的是高階程式碼。例如,以下 C 函式呼叫
MyFunction1(a, b);

如果從左到右傳遞,將生成以下程式碼

push a
push b
call _MyFunction

如果從右到左傳遞,將生成以下程式碼

push b
push a
call _MyFunction
返回值
某些函式會返回值,並且該值必須由函式的呼叫者可靠地接收。被呼叫函式將返回值放在呼叫函式可以在執行返回時獲取它的位置。被呼叫函式在執行ret指令之前儲存返回值。
清理堆疊
當引數被壓入堆疊時,最終它們必須被彈出。無論是呼叫者還是被呼叫者,負責清理堆疊的函式必須重置堆疊指標以消除傳遞的引數。
呼叫函式(呼叫者)
呼叫子程式的 "父" 函式。除非程式在子程式內終止,否則執行將在子程式呼叫之後直接在呼叫函式中恢復。
被呼叫函式(被呼叫者)
被 "父" 函式呼叫的 "子" 函式。
名稱修飾
當 C 程式碼轉換為彙編程式碼時,編譯器通常會透過新增連結器將使用來查詢和連結到正確函式的額外資訊來 "修飾" 函式名。對於大多數呼叫約定,修飾非常簡單(通常只是額外的符號或兩個符號來表示呼叫約定),但在某些極端情況下(特別是 C++ "thiscall" 約定),名稱會被嚴重 "破壞"。
進入序列(函式序言)
函式開頭的一些指令,用於準備堆疊和暫存器以便在函式中使用。
退出序列(函式尾聲)
函式結束時的若干指令,將堆疊和暫存器恢復到呼叫者期望的狀態,並返回到呼叫者。某些呼叫約定在退出序列中清理堆疊。
呼叫序列
函式(呼叫者)中間的一些指令,用於傳遞引數並呼叫被呼叫函式。在被呼叫函式返回後,某些呼叫約定在呼叫序列中還有一條指令用於清理堆疊。

標準 C 呼叫約定

[編輯 | 編輯原始碼]

C 語言預設使用 CDECL 呼叫約定,但大多數編譯器允許程式設計師透過指定符關鍵字指定另一個約定。這些關鍵字不是 ISO-ANSI C 標準的一部分,因此您應始終檢視編譯器文件以瞭解實現細節。

如果要使用 CDECL 以外的呼叫約定,或者 CDECL 不是編譯器的預設值,並且您想要手動使用它,則必須在函式宣告本身以及函式的任何原型中指定呼叫約定關鍵字。這一點很重要,因為呼叫函式和被呼叫函式都需要知道呼叫約定。

在 CDECL 呼叫約定中,以下內容成立

  • 引數以從右到左的順序傳遞到堆疊上,返回值傳遞到 eax 中。
  • 呼叫函式清理堆疊。這允許 CDECL 函式具有可變長度引數列表(又稱可變引數函式)。因此,編譯器不會將引數數量追加到函式的名稱,因此彙編程式和連結器無法確定是否使用了錯誤數量的引數。

可變引數函式通常具有由 va_start()、va_arg() C 偽函式生成的特殊入口程式碼。

考慮以下 C 指令

_cdecl int MyFunction1(int a, int b)
{
  return a + b;
}

以及以下函式呼叫

 x = MyFunction1(2, 3);

它們將分別產生以下彙編列表

_MyFunction1:
push ebp
mov ebp, esp
mov eax, [ebp + 8]
mov edx, [ebp + 12]
add eax, edx
pop ebp
ret

以及

push 3
push 2
call _MyFunction1
add esp, 8

當轉換為彙編程式碼時,CDECL 函式幾乎總是以一個下劃線開頭(這就是為什麼所有先前的示例都在彙編程式碼中使用了 "_")。

STDCALL,也稱為 "WINAPI"(以及其他一些名稱,具體取決於您閱讀的位置),幾乎被微軟獨家用作 Win32 API 的標準呼叫約定。由於 STDCALL 是由微軟嚴格定義的,所以所有實現它的編譯器都以相同的方式實現它。

  • STDCALL 從右到左傳遞引數,並將返回值傳遞到 eax 中。(微軟文件錯誤地聲稱引數是從左到右傳遞的,但事實並非如此。)
  • 與 CDECL 不同,被呼叫函式清理堆疊。這意味著 STDCALL 不允許可變長度引數列表。

考慮以下 C 函式

_stdcall int MyFunction2(int a, int b)
{
   return a + b;
}

以及呼叫指令

 x = MyFunction2(2, 3);

它們將分別產生以下彙編程式碼片段

:_MyFunction2@8
push ebp
mov ebp, esp
mov eax, [ebp + 8]
mov edx, [ebp + 12]
add eax, edx
pop ebp
ret 8

以及

push 3
push 2
call _MyFunction2@8

這裡有一些重要的要點需要注意。

  1. 在函式體中,ret 指令有一個(可選)引數,用於指示函式返回時從堆疊中彈出多少位元組。
  2. STDCALL 函式以一個前導下劃線、一個 @ 符號,然後是傳遞到堆疊上的引數數量(以位元組為單位)進行修飾。在 32 位對齊的機器上,此數字始終是 4 的倍數。

FASTCALL 呼叫約定在所有編譯器中並不完全標準,因此應謹慎使用。在 FASTCALL 中,前 2 或 3 個 32 位(或更小)引數透過暫存器傳遞,最常用的暫存器是 edx、eax 和 ecx。其他引數,或大於 4 位元組的引數透過堆疊傳遞,通常以從右到左的順序(類似於 CDECL)。如果需要,呼叫函式通常負責清理堆疊。

由於存在歧義,建議僅在引數為 1、2 或 3 個 32 位且速度至關重要的情況下使用 FASTCALL。

以下 C 函式

_fastcall int MyFunction3(int a, int b)
{
   return a + b;
}

以及以下 C 函式呼叫

x = MyFunction3(2, 3);

將分別為被呼叫函式和呼叫函式生成以下彙編程式碼片段。

:@MyFunction3@8
push ebp
mov ebp, esp ;many compilers create a stack frame even if it isn't used
add eax, edx ;a is in eax, b is in edx
pop ebp
ret

以及

;the calling function
mov eax, 2
mov edx, 3
call @MyFunction3@8

FASTCALL 的名稱修飾在函式名前面新增一個 @ 符號,並在函式名後面新增 @x,其中 x 是傳遞給函式的引數數量(以位元組為單位)。

許多編譯器仍然為 FASTCALL 函式生成堆疊幀,尤其是在 FASTCALL 函式本身呼叫另一個子程式的情況下。但是,如果 FASTCALL 函式不需要堆疊幀,最佳化編譯器可以自由地省略它。

通常,gcc 和 Windows FASTCALL 約定在將任何剩餘引數推送到堆疊之前,分別將引數一和二推送到 ecx 和 edx 中。使用此標準呼叫 MyFunction3 將如下所示:

;the calling function
mov ecx, 2
mov edx, 3
call @MyFunction3@8

C++ 呼叫約定

[編輯 | 編輯原始碼]

C++ 要求類的非靜態方法由類的例項呼叫。因此,它使用自己的標準呼叫約定來確保將指向物件的指標傳遞給函式:THISCALL

在 THISCALL 中,指向類物件的指標透過 ecx 傳遞,引數以從右到左的順序透過堆疊傳遞,返回值透過 eax 傳遞。

例如,以下 C++ 指令

 MyObj.MyMethod(a, b, c);

將形成以下彙編程式碼

mov ecx, MyObj
push c
push b
push a
call _MyMethod

至少,如果沒有名稱修飾,它看起來像上面的彙編程式碼。

名稱修飾

[編輯 | 編輯原始碼]

由於函式過載固有的複雜性,C++ 函式被大量修飾,以至於人們經常將此過程稱為“名稱修飾”。不幸的是,C++ 編譯器可以自由地以不同的方式進行名稱修飾,因為標準沒有強制執行約定。此外,異常處理等其他問題也未標準化。

由於每個編譯器都以不同的方式進行名稱修飾,因此本書不會花太多時間討論演算法的細節。請注意,在許多情況下,可以透過檢查名稱修飾格式的細節來確定建立可執行檔案的編譯器。但是,本書不會深入探討此主題。

以下是一些關於 THISCALL 修飾的函式的一般說明。

  • 它們一眼就能認出來,因為與 CDECL、FASTCALL 和 STDCALL 函式名稱修飾相比,它們很複雜。
  • 它們有時包含該函式的類的名稱。
  • 它們幾乎總是包含引數的數量和型別,以便可以透過傳遞給它的引數來區分過載函式。

以下是一個 C++ 類和函式宣告的示例。

 class MyClass {
  MyFunction(int a) { }
 };

這是生成的修飾後的名稱。

?MyFunction@MyClass@@QAEHH@Z

extern "C"

[編輯 | 編輯原始碼]

在 C++ 原始檔,放置在extern "C" 塊中的函式保證不會被修飾。當在 C++ 中編寫庫並且需要匯出函式而不進行修飾時,通常這樣做。即使程式是用 C++ 編寫並用 C++ 編譯器編譯的,一些函式可能不會被修飾,並將使用其中一個普通的 C 呼叫約定(通常是 CDECL)。

關於名稱修飾的說明

[編輯 | 編輯原始碼]

我們一直在本章討論名稱修飾,但事實是,在純反彙編程式碼中通常沒有名稱,尤其是沒有帶有花哨修飾的名稱。彙編階段會刪除所有這些可讀識別符號,並用二進位制位置代替它們。函式名實際上只出現在兩個地方。

  1. 編譯期間產生的列表檔案。
  2. 在匯出表中,如果函式被匯出。

在反彙編原始機器碼時,將沒有函式名和名稱修飾可供檢查。因此,您需要更多地注意引數傳遞方式、堆疊清理方式以及其他類似細節。

雖然我們還沒有介紹最佳化,但足以說明最佳化編譯器甚至可以將這些細節弄得一團糟。未匯出的函式不一定需要維護標準介面,如果確定特定函式不需要遵循標準約定,則會最佳化掉一些細節。在這些情況下,很難確定使用了哪些呼叫約定(如果有的話),也很難確定函式的開始和結束位置。本書無法考慮所有可能性,因此我們儘量顯示儘可能多的資訊,同時瞭解這裡提供的許多資訊在真正的反彙編情況下將不可用。

進一步閱讀

[編輯 | 編輯原始碼]
華夏公益教科書