跳轉到內容

x86 反彙編/呼叫約定示例

來自華夏公益教科書,開放的書籍,開放的世界


Microsoft C 編譯器

[編輯 | 編輯原始碼]

這裡是一個簡單的 C 函式

 int MyFunction(int x, int y)
 {
 	return (x * 2) + (y * 3);
 }

使用 cl.exe,我們將為 MyFunction 生成 3 個單獨的清單,分別使用 CDECL、FASTCALL 和 STDCALL 呼叫約定。在命令列中,您可以使用幾個開關來強制編譯器更改預設值

  • /Gd : 預設呼叫約定為 CDECL
  • /Gr : 預設呼叫約定為 FASTCALL
  • /Gz : 預設呼叫約定為 STDCALL

使用這些命令列選項,以下是清單

 int MyFunction(int x, int y)
 {
 	return (x * 2) + (y * 3);
 }

變為

 PUBLIC	_MyFunction
 _TEXT	SEGMENT
 _x$ = 8						; size = 4
 _y$ = 12						; size = 4
 _MyFunction	PROC NEAR
 ; Line 4
 	push  ebp
 	mov   ebp, esp
 ; Line 5
 	mov   eax, _y$[ebp]
 	imul  eax, 3
 	mov   ecx, _x$[ebp]
 	lea	  eax, [eax+ecx*2]
 ; Line 6 
 	pop	  ebp
 	ret	  0
 _MyFunction	ENDP
 _TEXT	ENDS
 END

在函式進入時,ESP 指向由呼叫者在堆疊上推送的返回地址call指令(即 EIP 的先前內容)。堆疊中任何高於入口 ESP 地址的引數都在執行 call 之前由呼叫者推送;在本例中,第一個引數位於 ESP+4(EIP 為 4 位元組寬)處的偏移量,以及在堆疊上推送 EBP 後再加 4 個位元組。因此,在第 5 行,ESP 指向儲存的幀指標 EBP,並且引數位於 ESP+8(x)和 ESP+12(y)的地址。

對於 CDECL,呼叫者以從右到左的順序將引數推入堆疊。由於使用了 ret 0,因此必須由呼叫者清理堆疊。

有趣的是,請注意在本函式中如何使用 lea 來同時執行乘法(ecx * 2)和將該數量加到 eax。類似於此的直觀性不強的指令將在關於 直觀性不強的指令 的章節中進一步探討。

 int MyFunction(int x, int y)
 {
 	return (x * 2) + (y * 3);
 }

變為

 PUBLIC	@MyFunction@8
 _TEXT	SEGMENT
 _y$ = -8						; size = 4
 _x$ = -4						; size = 4
 @MyFunction@8 PROC NEAR
 ; _x$ = ecx
 ; _y$ = edx
 ; Line 4
 	push   ebp
 	mov	   ebp, esp
 	sub	   esp, 8
 	mov	   _y$[ebp], edx
 	mov	   _x$[ebp], ecx
 ; Line 5
 	mov	   eax, _y$[ebp]
 	imul   eax, 3
 	mov	   ecx, _x$[ebp]
 	lea	   eax, [eax+ecx*2]
 ; Line 6
 	mov	   esp, ebp
 	pop	   ebp
 	ret	   0
 @MyFunction@8 ENDP
 _TEXT	ENDS
 END

該函式是在關閉最佳化的情況下編譯的。在這裡,我們可以看到引數首先儲存在堆疊中,然後從堆疊中獲取,而不是直接使用。這是因為編譯器希望透過堆疊訪問以一致的方式使用所有引數,而不僅僅是一個編譯器這樣操作。

沒有使用正偏移量訪問入口 SP 的引數,似乎呼叫者沒有將它們推入,因此它可以使用 ret 0。讓我們進一步調查

 int FastTest(int x, int y, int z, int a, int b, int c)
 {
     return x * y * z * a * b * c;
 }

以及相應的清單

 PUBLIC	@FastTest@24
 _TEXT	SEGMENT
 _y$ = -8						; size = 4
 _x$ = -4						; size = 4
 _z$ = 8						; size = 4
 _a$ = 12						; size = 4
 _b$ = 16						; size = 4
 _c$ = 20						; size = 4
 @FastTest@24 PROC NEAR
 ; _x$ = ecx
 ; _y$ = edx
 ; Line 2
 	push    ebp
 	mov	    ebp, esp
 	sub	    esp, 8
 	mov	    _y$[ebp], edx
 	mov	    _x$[ebp], ecx
 ; Line 3
 	mov	    eax, _x$[ebp]
 	imul	eax, DWORD PTR _y$[ebp]
 	imul	eax, DWORD PTR _z$[ebp]
 	imul	eax, DWORD PTR _a$[ebp]
 	imul	eax, DWORD PTR _b$[ebp]
 	imul	eax, DWORD PTR _c$[ebp]
 ; Line 4
 	mov	    esp, ebp
 	pop	    ebp
 	ret	    16					; 00000010H

現在我們有 6 個引數,四個引數由呼叫者從右到左推送,最後兩個引數再次傳遞到 cx/dx 中,並以與先前示例相同的方式處理。堆疊清理由 ret 16 完成,這對應於在執行 call 之前推送的 4 個引數。

對於 FASTCALL,編譯器將嘗試在暫存器中傳遞引數,如果暫存器不足,呼叫者仍然會以從右到左的順序將它們推入堆疊。堆疊清理由被呼叫者完成。它被稱為 FASTCALL,因為如果引數可以在暫存器中傳遞(對於 64 位 CPU,最大數量為 6),則不需要進行堆疊推送/清理。

函式的名稱修飾方案:@MyFunction@n,其中 n 是所有引數所需的堆疊大小。

 int MyFunction(int x, int y)
 {
 	return (x * 2) + (y * 3);
 }

變為

 PUBLIC	_MyFunction@8
 _TEXT	SEGMENT
 _x$ = 8						; size = 4
 _y$ = 12						; size = 4
 _MyFunction@8 PROC NEAR
 ; Line 4
 	push	ebp
 	mov	    ebp, esp
 ; Line 5
 	mov	    eax, _y$[ebp]
 	imul	eax, 3
 	mov	    ecx, _x$[ebp]
 	lea	    eax, [eax+ecx*2]
 ; Line 6
 	pop	    ebp
 	ret	    8
 _MyFunction@8 ENDP
 _TEXT	ENDS
 END

STDCALL 清單與 CDECL 清單隻有一個區別,即它使用“ret 8”進行堆疊的自我清理。讓我們舉一個引數更多的例子

 int STDCALLTest(int x, int y, int z, int a, int b, int c)
 {
 	return x * y * z * a * b * c;
 }

讓我們看看 cl.exe 如何將該函式轉換為彙編程式碼

 PUBLIC	_STDCALLTest@24
 _TEXT	SEGMENT
 _x$ = 8						; size = 4
 _y$ = 12						; size = 4
 _z$ = 16						; size = 4
 _a$ = 20						; size = 4
 _b$ = 24						; size = 4
 _c$ = 28						; size = 4
 _STDCALLTest@24 PROC NEAR
 ; Line 2
 	push	ebp
 	mov	    ebp, esp
 ; Line 3
 	mov	    eax, _x$[ebp]
 	imul	eax, DWORD PTR _y$[ebp]
 	imul	eax, DWORD PTR _z$[ebp]
 	imul	eax, DWORD PTR _a$[ebp]
 	imul	eax, DWORD PTR _b$[ebp]
 	imul	eax, DWORD PTR _c$[ebp]
 ; Line 4
 	pop	    ebp
 	ret	    24					; 00000018H
 _STDCALLTest@24 ENDP
 _TEXT	ENDS
 END

是的,STDCALL 和 CDECL 之間的唯一區別是前者在被呼叫者中進行堆疊清理,後者在呼叫者中進行。由於 X86 的“ret n”,這在 X86 中節省了一些操作。

GNU C 編譯器

[編輯 | 編輯原始碼]

我們將使用兩個示例 C 函式來演示 GCC 如何實現呼叫約定

 int MyFunction1(int x, int y)
 {
 	return (x * 2) + (y * 3);
 }

以及

 int MyFunction2(int x, int y, int z, int a, int b, int c)
 {
 	return x * y * (z + 1) * (a + 2) * (b + 3) * (c + 4);
 }

GCC 沒有命令列引數來強制預設呼叫約定從 CDECL(對於 C)更改,因此它們將在文字中使用指令手動定義:__cdecl、__fastcall 和 __stdcall。

第一個函式(MyFunction1)提供以下彙編清單

 _MyFunction1:
 	pushl	%ebp
 	movl	%esp, %ebp
 	movl	8(%ebp), %eax
 	leal	(%eax,%eax), %ecx
 	movl	12(%ebp), %edx
 	movl	%edx, %eax
 	addl	%eax, %eax
 	addl	%edx, %eax
 	leal	(%eax,%ecx), %eax
 	popl	%ebp
 	ret

首先,我們可以看到名稱修飾與 cl.exe 中的相同。我們還可以看到 ret 指令沒有引數,因此呼叫函式正在清理堆疊。但是,由於 GCC 在清單中沒有為我們提供變數名,因此我們必須推斷出哪些引數是哪些。在設定堆疊幀後,函式的第一條指令是“movl 8(%ebp), %eax”。一旦我們記住(或第一次學習)GAS 指令具有以下通用形式

instruction src, dest

我們意識到,ebp(堆疊上推送的最後一個引數)偏移量 +8 處的值被移動到 eax。leal 指令更難解讀,尤其是在我們沒有任何 GAS 指令經驗的情況下。形式“leal(reg1,reg2), dest”將括號中的值加在一起,並將值儲存到 dest 中。轉換為 Intel 語法,我們得到指令

 lea ecx, [eax + eax]

這顯然與乘以 2 相同。然後,訪問的第一個值必須是傳遞的最後一個值,這似乎表明這裡的值是從右到左傳遞的。為了證明這一點,我們將檢視清單的下一部分

 movl	12(%ebp), %edx
 movl	%edx, %eax
 addl	%eax, %eax
 addl	%edx, %eax
 leal	(%eax,%ecx), %eax

ebp 偏移量 +12 處的值被移動到 edx。然後 edx 被移動到 eax。然後將 eax 加到它自身(eax * 2),然後加回到 edx(edx + eax)。請記住,eax = 2 * edx,所以結果是 edx * 3。這顯然是 y 引數,它在堆疊中距離最遠,因此是最先被推送的。因此,GCC 上的 CDECL 透過以從右到左的順序在堆疊上傳遞引數來實現,與 cl.exe 相同。

 .globl @MyFunction1@8
 	.def	@MyFunction1@8;	.scl	2;	.type	32;	.endef
 @MyFunction1@8:
 	pushl	%ebp
 	movl	%esp, %ebp
 	subl	$8, %esp
 	movl	%ecx, -4(%ebp)
 	movl	%edx, -8(%ebp)
 	movl	-4(%ebp), %eax
 	leal	(%eax,%eax), %ecx
 	movl	-8(%ebp), %edx
 	movl	%edx, %eax
 	addl	%eax, %eax
 	addl	%edx, %eax
 	leal	(%eax,%ecx), %eax
 	leave
 	ret

首先注意,使用的名稱修飾與 cl.exe 中的相同。敏銳的觀察者已經意識到 GCC 使用了與 cl.exe 相同的技巧,即將 fastcall 引數從它們的暫存器(再次是 ecx 和 edx)移動到堆疊上的負偏移量。同樣,最佳化被關閉。ecx 被移動到第一個位置(-4),edx 被移動到第二個位置(-8)。與上面的 CDECL 示例一樣,-4(ecx)加倍,-8(edx)加倍。因此,-4(ecx)是 x,-8(edx)是 y。從這個清單看來,似乎值是按從左到右的順序傳遞的,儘管我們需要看一下更大的 MyFunction2 示例

 .globl @MyFunction2@24
 	.def	@MyFunction2@24;	.scl	2;	.type	32;	.endef
 @MyFunction2@24:
 	pushl	%ebp
 	movl	%esp, %ebp
 	subl	$8, %esp
 	movl	%ecx, -4(%ebp)
 	movl	%edx, -8(%ebp)
 	movl	-4(%ebp), %eax
 	imull	-8(%ebp), %eax
 	movl	8(%ebp), %edx
 	incl	%edx
 	imull	%edx, %eax
 	movl	12(%ebp), %edx
 	addl	$2, %edx
 	imull	%edx, %eax
 	movl	16(%ebp), %edx
 	addl	$3, %edx
 	imull	%edx, %eax
 	movl	20(%ebp), %edx
 	addl	$4, %edx
 	imull	%edx, %eax
 	leave
 	ret	    $16

透過遵循 MyFunction2 中連續引數被新增到遞增常數這一事實,我們可以推斷出每個引數的位置。-4 仍然是 x,-8 仍然是 y。+8 按 1(z)遞增,+12 按 2(a)遞增。+16 按 3(b)遞增,+20 按 4(c)遞增。讓我們列出這些值

z = [ebp + 8]
a = [ebp + 12]
b = [ebp + 16]
c = [ebp + 20]

c 距離最遠,因此是最先被推送的。z 距離頂部最近,因此是最晚被推送的。因此,引數以從右到左的順序被推送,就像 cl.exe 一樣。

然後讓我們比較一下 GCC 中 MyFunction1 的實現

 .globl _MyFunction1@8
 	.def	_MyFunction1@8;	.scl	2;	.type	32;	.endef
 _MyFunction1@8:
 	pushl	%ebp
 	movl	%esp, %ebp
 	movl	8(%ebp), %eax
 	leal	(%eax,%eax), %ecx
 	movl	12(%ebp), %edx
 	movl	%edx, %eax
 	addl	%eax, %eax
 	addl	%edx, %eax
 	leal	(%eax,%ecx), %eax
 	popl	%ebp
 	ret	    $8

名稱修飾與 cl.exe 中的相同,因此 STDCALL 函式(以及 CDECL 和 FASTCALL)可以使用任一編譯器進行彙編,並可以使用任一連結器進行連結,似乎是。設定堆疊幀,然後 [ebp + 8] 處的值加倍。之後,[ebp + 12] 處的值加倍。因此,+8 是 x,+12 是 y。同樣,這些值按從右到左的順序被推送。此函式還使用“ret 8”指令清理自己的堆疊。

檢視一個更大的示例

 .globl _MyFunction2@24
 	.def	_MyFunction2@24;	.scl	2;	.type	32;	.endef
 _MyFunction2@24:
 	pushl	%ebp
 	movl	%esp, %ebp
 	movl	8(%ebp), %eax
 	imull	12(%ebp), %eax
 	movl	16(%ebp), %edx
 	incl	%edx
 	imull	%edx, %eax
 	movl	20(%ebp), %edx
 	addl	$2, %edx
 	imull	%edx, %eax
 	movl	24(%ebp), %edx
 	addl	$3, %edx
 	imull	%edx, %eax
 	movl	28(%ebp), %edx
 	addl	$4, %edx
 	imull	%edx, %eax
 	popl	%ebp
 	ret	    $24

我們在這裡可以看到,ebp 偏移量 +8 和 +12 處的值仍然分別是 x 和 y。+16 處的值按 1 遞增,+20 處的值按 2 遞增,依此類推,一直到 +28 處的值。因此,我們可以建立以下表格

x = [ebp + 8]
y = [ebp + 12]
z = [ebp + 16]
a = [ebp + 20]
b = [ebp + 24]
c = [ebp + 28]

其中 c 最先被推送,x 最晚被推送。因此,這些引數也按從右到左的順序被推送。然後,此函式還使用“ret 24”指令清理堆疊上的 24 個位元組。

示例:C 呼叫約定

[編輯 | 編輯原始碼]

識別以下 C 函式的呼叫約定

 int MyFunction(int a, int b)
 {
    return a + b;
 }

該函式是用 C 編寫的,沒有其他說明符,因此預設情況下為 CDECL。

示例:命名彙編函式

[編輯 | 編輯原始碼]

識別函式 MyFunction 的呼叫約定

 :_MyFunction@12
 push ebp
 mov  ebp, esp
 ...
 pop  ebp
 ret  12

該函式包含 STDCALL 函式的修飾名稱,並清理自己的堆疊。因此,它是一個 STDCALL 函式。

示例:未命名彙編函式

[編輯 | 編輯原始碼]

此程式碼片段是未命名彙編函式的整個函式體。識別此函式的呼叫約定。

 push ebp
 mov  ebp, esp
 add  eax, edx
 pop  ebp
 ret

該函式設定了一個堆疊幀,因此我們知道編譯器沒有對其進行任何“奇怪”的處理。它訪問尚未初始化的暫存器,即 edx 和 eax 暫存器。因此,它是一個 FASTCALL 函式。

示例:另一個未命名彙編函式

[編輯 | 編輯原始碼]
 push ebp 
 mov  ebp, esp
 mov  eax, [ebp + 8]
 pop  ebp
 ret  16

該函式有一個標準的堆疊幀,並且 ret 指令有一個引數來清理自己的堆疊。此外,它從堆疊中訪問引數。因此,它是一個 STDCALL 函式。

示例:名稱改編

[編輯 | 編輯原始碼]

關於以下函式呼叫,我們能得出什麼結論?

 mov    ecx, x
 push   eax
 mov    eax, ss:[ebp - 4]
 push   eax
 mov    al, ss:[ebp - 3]
 call   @__Load?$Container__XXXY_?Fcii

有兩件事應該立即引起我們的注意。首先是在函式呼叫之前,一個值被儲存到 ecx 中。此外,函式名本身也被嚴重改編。此示例必須使用 C++ THISCALL 約定。在函式的改編名稱中,我們可以選出兩個英文單詞,“Load”和“Container”。在不知道這種改編方案的具體情況的情況下,無法確定哪個詞是函式名,哪個詞是類名。

我們可以選出兩個被傳遞給函式的 32 位變數和一個 8 位變數。第一個位於 eax 中,第二個最初位於 ebp 偏移 -4 的堆疊上,第三個位於 ebp 偏移 -3 的位置。在 C++ 中,這些可能對應於兩個 int 變數和一個 char 變數。請注意,在改編函式名的末尾是三個小寫字母“cii”。我們不能確定,但似乎這三個字母對應於三個引數(char、int、int)。我們無法從這裡確定函式是否返回值,因此我們將假設函式返回 void

假設“Load”是函式名,“Container”是類名(也可能反過來),以下是我們的函式定義

class Container
{
  void Load(char, int, int);
}
華夏公益教科書