x86 反汇编/调用约定
调用约定是函数在机器上实现和调用的标准化方法。调用约定指定编译器设置访问子程序的方法。理论上,只要函数具有相同的调用约定,来自任何编译器的代码都可以相互连接。然而,在实践中,情况并非总是如此。
调用约定指定如何将参数传递给函数、如何将返回值传递回函数、如何调用函数以及函数如何管理堆栈及其堆栈帧。简而言之,调用约定指定 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 语言默认使用 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
这里有一些重要的要点需要注意。
- 在函数体中,ret 指令有一个(可选)参数,用于指示函数返回时从堆栈中弹出多少字节。
- 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++ 要求类的非静态方法由类的实例调用。因此,它使用自己的标准调用约定来确保将指向对象的指针传递给函数: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
在 C++ 源文件,放置在extern "C"
块中的函数保证不会被修饰。当在 C++ 中编写库并且需要导出函数而不进行修饰时,通常这样做。即使程序是用 C++ 编写并用 C++ 编译器编译的,一些函数可能不会被修饰,并将使用其中一个普通的 C 调用约定(通常是 CDECL)。
我们一直在本章讨论名称修饰,但事实是,在纯反汇编代码中通常没有名称,尤其是没有带有花哨修饰的名称。汇编阶段会删除所有这些可读标识符,并用二进制位置代替它们。函数名实际上只出现在两个地方。
- 编译期间产生的列表文件。
- 在导出表中,如果函数被导出。
在反汇编原始机器码时,将没有函数名和名称修饰可供检查。因此,您需要更多地注意参数传递方式、堆栈清理方式以及其他类似细节。
虽然我们还没有介绍优化,但足以说明优化编译器甚至可以将这些细节弄得一团糟。未导出的函数不一定需要维护标准接口,如果确定特定函数不需要遵循标准约定,则会优化掉一些细节。在这些情况下,很难确定使用了哪些调用约定(如果有的话),也很难确定函数的开始和结束位置。本书无法考虑所有可能性,因此我们尽量显示尽可能多的信息,同时了解这里提供的许多信息在真正的反汇编情况下将不可用。
- x86 反汇编/调用约定示例
- 嵌入式系统/混合 C 和汇编编程 描述了其他 CPU 上的调用约定。
- ↑ Josh Lospinoso。 "常见的 x86 调用约定".
- ↑ "C 到汇编调用约定 32 位与 64 位".
- ↑ "ASM 调用约定".