跳转到内容

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);
}
华夏公益教科书