x86 反彙編/呼叫約定示例
這裡是一個簡單的 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 中節省了一些操作。
我們將使用兩個示例 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 函式的呼叫約定
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);
}