实现逆向工程——代码调用约定的类型

71 阅读9分钟

在第 2 章《理解 x86 机器架构》中,我们了解了栈的概念。当我们调用一个函数时,后台会发生一系列操作:控制流被转移到被调用函数,为其局部变量分配栈帧,并将参数传递给被调用者;函数返回时,返回地址会被压入栈中,以便调用者能够取回,并对栈进行清理。想象一下,如果被调用者和调用者都去清理同一份栈区域,就会产生混乱。因此,理解不同的代码调用约定(calling convention)就显得尤为重要。由于不同 CPU 可能对调用过程有严格要求,而在 x86 架构上则相对灵活,程序员可以自行决定调用方式,这也就衍生出多种调用约定。

当你用 C/C++ 编写并调用共享库时,调用约定尤其重要,因为你所调用的代码并不在你的控制之内。对于普通程序员而言,通常不必手动处理 —— 编译器会根据语言自动选择默认的调用约定。本章中,我们将深入理解各种调用约定之间的差异。

章节结构

本章将涵盖以下内容:

  • 理解调用约定的类型
  • 不同调用约定背后的原理

学习目标

学习本章后,你将能够区分不同的汇编代码所采用的调用约定。如果遇到一段汇编代码,通过阅读就能判断其使用了哪种调用约定。为此,我们将通过一个简单伪代码,详细演示各调用约定的工作机制。

调用约定类型

考虑下面的伪代码,其中 funcA 调用 funcB

funcA()
{
    Arg1;
    Arg2;
    funcB(Arg1, Arg2);
}

funcA 是调用者(caller),funcB 是被调用者(callee)。不同编译器在编译同样的 C/C++ 代码时会生成不同的汇编,调用约定便是一套规则,定义了:

  1. 函数参数如何传递
  2. 函数返回值如何传递
  3. 调用者如何调用被调用者
  4. 一个函数调用另一个函数时栈如何管理
  5. 调用结束后栈如何清理

在 C/C++ 中,主要有三种调用约定:CDECLSTDCALLFASTCALL

CDECL

(“C Declaration” 的缩写)

  • 参数按“右到左”顺序压栈:funcB(Arg1, Arg2) 时先压 Arg2,再压 Arg1
  • 返回值通过 EAX 寄存器返回。
  • 调用者负责清理栈。

STDCALL

(“Standard Call”,微软为 Win32 API 定义的标准约定)

  • 参数按“右到左”顺序压栈,与 CDECL 相同。
  • 返回值通过 EAX 返回,与 CDECL 相同。
  • 与 CDECL 不同的是:由被调用者(callee)负责清理栈。

FASTCALL

(“Fast Call”,优先将参数放入寄存器,以加快速度)

  • 前两到三个参数通过寄存器传递(通常是 EDX、ECX,然后 EAX),其余参数按“右到左”顺序压栈。
  • 返回值通过 EAX 返回。
  • 若在栈上有额外参数,由调用者在调用结束后清理栈。

不同调用约定背后的原理

为深入理解这些调用约定,我们将编写一段简单的 C/C++ 代码,并使用 Visual Studio 的 cl.exe 编译器,分别指定不同的调用约定,比较生成的汇编差异。

image.png

AddNumber.cpp 代码是一个用于将两个数字相加的简单示例程序。前述程序中需要注意的几点如下:

  • main 函数是程序的入口点。
  • main 函数调用了一个加法函数,因此 main 是调用者(caller),加法函数是被调用者(callee)。
  • main 函数和加法函数中定义的局部变量类型均为 int
  • 向被调用函数传递了两个参数,分别是 45

编写完代码后,我们将使用 cl.exe 编译程序,并通过不同的编译选项强制编译器采用不同的调用约定:

  • /Gd:使用 CDECL 调用约定
  • /Gz:使用 STDCALL 调用约定
  • /Gr:使用 FASTCALL 调用约定

下面我们将逐个演示每种调用约定及其区别。

CDECL

我们先在不做优化的情况下,使用 CDECL 调用约定编译 AddNumber.cpp。使用 /Gd 选项强制采用 CDECL。请在 Windows 命令提示符中执行以下命令来配置 cl.exe(VS 编译器)环境,并使用相应开关进行编译:

cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat

cd "C:\JitenderN\REBook\AddNumber\AddNumber"
cl AddNumber.cpp /FaAddNumber-CDECL.asm /Gd /FeAddNumber-CDECL.exe

其中:

  • /Gd:指定 CDECL 调用约定
  • /Fa:指定生成的汇编列表文件名
  • /Fe:指定生成的可执行文件名

执行上述命令后,编译器将输出相应的汇编和可执行文件。

image.png

这会生成 AddNumber-CDECL.asm。现在我们将继续分析 AddNumber-CDECL.asm 中生成的汇编代码:我们的 C++ 代码分为两个函数,一个是主函数(调用者),另一个是加法函数(被调用者)。为了理解 CDECL 调用约定,我们将从 C++ 代码到汇编,具体分析主函数的代码转换。

image.png

主函数的汇编代码如下:

image.png

让我们来分析 AddNumber-CDECL.asm 中主函数的汇编代码:

  • 第 20–21 行是函数序言(prologue),一系列指令用于开始一个函数。

  • 第 22 行的 PUSH ECX 并不是要保存 ECX 到栈上,而是为了在栈上分配 4 字节空间,用于存放局部变量 add

  • 变量 add 可以通过宏 _add$ 访问,它的值为 -4,因此 add 存放在 [EBP-0x4]

  • 从第 24 行开始,我们来对应 CDECL 调用约定的要点进行分析:

    1. 参数按 “右到左” 顺序压栈。
      AddNumber.cpp 的第 12 行,int add = addition(4,5); 中向 addition 函数传入参数 4, 5。按右到左顺序,先压入 5,再压入 4——这正对应汇编第 24–25 行。

    2. 调用后由调用者清理栈。
      addition 函数返回后,第 27 行的 add esp, 8 将栈指针 ESP 增加 8,清理掉刚才压入的两个参数(各 4 字节)。这是 CDECL 约定下由调用者清理栈的体现。

    3. 返回值通过 EAX 寄存器传递。
      第 26 行 CALL addition 调用结束后,addition 的返回值已经保存在 EAX。第 28 行的

      mov DWORD PTR _add$[ebp], eax
      

      EAX 中的结果复制到局部变量 add 的栈空间([EBP-0x4])。

    4. 最后,main 函数通过 xor eax, eaxEAX 清零,实现 return 0;

如果对某条指令不够熟悉,请参见第 4 章“汇编指令演练”。

STDCALL

接下来,我们在不做优化的情况下,使用 STDCALL 调用约定重新编译代码。使用 /Gz 选项强制采用 STDCALL。请在命令提示符中依次执行:

cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat

cd "C:\JitenderN\REBook\AddNumber\AddNumber"
cl AddNumber.cpp /FaAddNumber-STDCALL.asm /Gz /FeAddNumber-STDCALL.exe

其中:

  • /Gz:指定 STDCALL 调用约定
  • /Fa:生成的汇编列表文件名
  • /Fe:生成的可执行文件名

执行上述命令后,会在当前目录下生成 AddNumber-STDCALL.asmAddNumber-STDCALL.exe

image.png

这将生成 AddNumber-STDCALL.asm。接下来我们将分析其中的汇编代码。为了理解 STDCALL 调用约定,我们仍然从 C++ 的 main 函数及 addition 函数,对照它们在汇编中的实现,来观察二者之间的转换。

image.png

在 STDCALL 调用约定下,main 函数和 addition 函数的汇编代码如下:

image.png

让我们基于 STDCALL 来分析汇编代码。大多数要点与 CDECL 调用约定相同,这里只讨论几处差异:

在汇编代码第 20–21 行是函数的序言(prologue),是一系列用于函数开始的指令。
在第 22 行,紧跟在函数序言之后的 PUSH ECX 指令,其目的并不是保存 ECX 寄存器的值到栈上,而是为存放局部变量 add 在栈上分配 4 个字节空间。
局部变量 add 可以通过宏 _add$ 来访问,_add$ 的值等于 -4,因此可以通过 [EBP-0x4] 来访问该变量。

从第 24 行开始,我们将理解 STDCALL 调用约定。回顾 STDCALL 的要点:

  1. 参数从右到左压栈
    与 CDECL 相同,实参按照从右到左的顺序被压入栈中。在 AddNumber.cpp 的第 12 行,即 int add = addition(4,5); 中,我们向 addition 函数传入了 4 和 5 两个参数;“从右到左”意味着先压入 5,再压入 4。这一点可以在 AddNumber-STDCALL.asm 的第 26 行看到:

    PUSH 5
    PUSH 4
    CALL addition
    
  2. 被调用者(callee)负责清理栈空间
    这与 CDECL 不同。在 AddNumber-STDCALL.asm 的第 47 行,可以看到 RET 8 指令。RET nBytes 会将控制权从被调函数返回到调用者,并自动在返回地址之后释放 nBytes 个字节的栈空间;在此例中,nBytes 为 8(两个 4 字节的参数),完成栈的清理。

  3. 函数返回值通过 EAX 寄存器传递
    在第 26 行调用 addition 函数后,其返回值会存放在 EAX 寄存器中。接着,在第 27 行,通过以下指令将 EAX 的值存回局部变量 add 的栈空间:

    mov DWORD PTR _add$[ebp], eax
    
  4. 主函数的返回值
    main 函数返回 0,是通过 xor eax, eax 指令实现的(将 EAX 寄存器清零即返回 0)。

FASTCALL

如前所述,FASTCALL 调用约定的主要区别在于参数的传递方式。下面我们在未优化的情况下,并启用 FASTCALL 开关(使用 /Gr)重新编译代码,从而观察差异。请在 Windows 命令提示符下执行以下命令,先设置 VS 编译环境变量,然后使用这些开关编译:

cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat

cd C:\JitenderN\REBook\AddNumber\AddNumber
cl AddNumber.cpp /FaAddNumber-FASTCALL.asm /Gr /FeAddNumber-FASTCALL.exe

以上命令说明:

  • /Gr:启用 FASTCALL 调用约定
  • /Fa:指定输出的汇编列表文件名
  • /Fe:指定生成的可执行文件名

下面是执行上述命令后的汇编输出:

image.png

这将生成 AddNumber-FASTCALL.asm。要分析 AddNumber-FASTCALL.asm 中的汇编代码,我们将再次查看我们的 C/C++ 源代码。

image.png

下面我们来看看生成的 AddNumber-FASTCALL.asm,内容如下:

image.png

让我们按照与其他调用约定相同的顺序来分析汇编代码。

在第 20–21 行的函数序言指令,是一系列用于函数启动的指令。
在第 22 行的 PUSH ECX 与之前相同,并不是为了保存 ECX 寄存器的值,而是在栈上为局部变量 add 分配 4 个字节的空间。
局部变量 add 可通过宏 _add$ 访问,其值等于 -4,因此可以通过 [EBP-0x4] 来访问该变量。

从第 24 行开始,我们将看到一种不同的参数传递方式。回顾 FASTCALL 的要点:

  • 最初的参数不再压入栈中,而是通过寄存器传递。前两个(或三个)参数分别传递到 EDX、ECX(或 EAX)寄存器中。
  • 超出部分的参数仍按从右到左的顺序压入栈中。

在这里需要注意的是,第 24–25 行中,实参 5 被移入 EDX,实参 4 被移入 ECX;随后在第 26 行调用 addition 函数。
addition 函数执行过程中,第 45–49 行可见,寄存在 EDX、ECX 中的参数被推送到栈上,以供后续处理。

在调用约定上,调用者负责清理栈空间。
由于实参已通过寄存器传递,因此调用时无需向栈中压入参数,也就无需清理栈。

函数返回值仍通过 EAX 寄存器传递,这与前面讨论的两种调用约定一致。
在第 26 行调用 addition 函数后,其返回值存入 EAX,然后在第 27 行通过以下指令将其存回局部变量 add 的栈空间:

mov DWORD PTR _add$[ebp], eax

同样,main 函数返回 0,是在第 29 行通过 xor eax, eax 指令实现的。

结论

本章我们介绍了三种主要的 C/C++ 调用约定:CDECL、STDCALL 和 FASTCALL。

  • 在 CDECL 和 STDCALL 中,参数均按从右到左顺序压入栈中,函数返回值通过 EAX 寄存器传递;区别在于 CDECL 由调用者清理栈,而 STDCALL 由被调用者清理栈。
  • 在 FASTCALL 中,前几个参数通过寄存器传递,剩余参数才压入栈;函数返回值仍通过 EAX 传递,调用者负责清理栈(如有需要)。

下一章,我们将结合 C/C++ 源代码编译,进一步理解汇编输出。