在第 2 章《理解 x86 机器架构》中,我们了解了栈的概念。当我们调用一个函数时,后台会发生一系列操作:控制流被转移到被调用函数,为其局部变量分配栈帧,并将参数传递给被调用者;函数返回时,返回地址会被压入栈中,以便调用者能够取回,并对栈进行清理。想象一下,如果被调用者和调用者都去清理同一份栈区域,就会产生混乱。因此,理解不同的代码调用约定(calling convention)就显得尤为重要。由于不同 CPU 可能对调用过程有严格要求,而在 x86 架构上则相对灵活,程序员可以自行决定调用方式,这也就衍生出多种调用约定。
当你用 C/C++ 编写并调用共享库时,调用约定尤其重要,因为你所调用的代码并不在你的控制之内。对于普通程序员而言,通常不必手动处理 —— 编译器会根据语言自动选择默认的调用约定。本章中,我们将深入理解各种调用约定之间的差异。
章节结构
本章将涵盖以下内容:
- 理解调用约定的类型
- 不同调用约定背后的原理
学习目标
学习本章后,你将能够区分不同的汇编代码所采用的调用约定。如果遇到一段汇编代码,通过阅读就能判断其使用了哪种调用约定。为此,我们将通过一个简单伪代码,详细演示各调用约定的工作机制。
调用约定类型
考虑下面的伪代码,其中 funcA 调用 funcB:
funcA()
{
Arg1;
Arg2;
funcB(Arg1, Arg2);
}
funcA 是调用者(caller),funcB 是被调用者(callee)。不同编译器在编译同样的 C/C++ 代码时会生成不同的汇编,调用约定便是一套规则,定义了:
- 函数参数如何传递
- 函数返回值如何传递
- 调用者如何调用被调用者
- 一个函数调用另一个函数时栈如何管理
- 调用结束后栈如何清理
在 C/C++ 中,主要有三种调用约定:CDECL、STDCALL、FASTCALL。
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 编译器,分别指定不同的调用约定,比较生成的汇编差异。
AddNumber.cpp 代码是一个用于将两个数字相加的简单示例程序。前述程序中需要注意的几点如下:
main函数是程序的入口点。main函数调用了一个加法函数,因此main是调用者(caller),加法函数是被调用者(callee)。- 在
main函数和加法函数中定义的局部变量类型均为int。 - 向被调用函数传递了两个参数,分别是
4和5。
编写完代码后,我们将使用 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:指定生成的可执行文件名
执行上述命令后,编译器将输出相应的汇编和可执行文件。
这会生成 AddNumber-CDECL.asm。现在我们将继续分析 AddNumber-CDECL.asm 中生成的汇编代码:我们的 C++ 代码分为两个函数,一个是主函数(调用者),另一个是加法函数(被调用者)。为了理解 CDECL 调用约定,我们将从 C++ 代码到汇编,具体分析主函数的代码转换。
主函数的汇编代码如下:
让我们来分析 AddNumber-CDECL.asm 中主函数的汇编代码:
-
第 20–21 行是函数序言(prologue),一系列指令用于开始一个函数。
-
第 22 行的
PUSH ECX并不是要保存ECX到栈上,而是为了在栈上分配 4 字节空间,用于存放局部变量add。 -
变量
add可以通过宏_add$访问,它的值为-4,因此add存放在[EBP-0x4]。 -
从第 24 行开始,我们来对应 CDECL 调用约定的要点进行分析:
-
参数按 “右到左” 顺序压栈。
在AddNumber.cpp的第 12 行,int add = addition(4,5);中向addition函数传入参数4, 5。按右到左顺序,先压入5,再压入4——这正对应汇编第 24–25 行。 -
调用后由调用者清理栈。
从addition函数返回后,第 27 行的add esp, 8将栈指针ESP增加 8,清理掉刚才压入的两个参数(各 4 字节)。这是 CDECL 约定下由调用者清理栈的体现。 -
返回值通过
EAX寄存器传递。
第 26 行CALL addition调用结束后,addition的返回值已经保存在EAX。第 28 行的mov DWORD PTR _add$[ebp], eax将
EAX中的结果复制到局部变量add的栈空间([EBP-0x4])。 -
最后,
main函数通过xor eax, eax将EAX清零,实现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.asm 和 AddNumber-STDCALL.exe。
这将生成 AddNumber-STDCALL.asm。接下来我们将分析其中的汇编代码。为了理解 STDCALL 调用约定,我们仍然从 C++ 的 main 函数及 addition 函数,对照它们在汇编中的实现,来观察二者之间的转换。
在 STDCALL 调用约定下,main 函数和 addition 函数的汇编代码如下:
让我们基于 STDCALL 来分析汇编代码。大多数要点与 CDECL 调用约定相同,这里只讨论几处差异:
在汇编代码第 20–21 行是函数的序言(prologue),是一系列用于函数开始的指令。
在第 22 行,紧跟在函数序言之后的 PUSH ECX 指令,其目的并不是保存 ECX 寄存器的值到栈上,而是为存放局部变量 add 在栈上分配 4 个字节空间。
局部变量 add 可以通过宏 _add$ 来访问,_add$ 的值等于 -4,因此可以通过 [EBP-0x4] 来访问该变量。
从第 24 行开始,我们将理解 STDCALL 调用约定。回顾 STDCALL 的要点:
-
参数从右到左压栈
与 CDECL 相同,实参按照从右到左的顺序被压入栈中。在AddNumber.cpp的第 12 行,即int add = addition(4,5);中,我们向addition函数传入了 4 和 5 两个参数;“从右到左”意味着先压入 5,再压入 4。这一点可以在AddNumber-STDCALL.asm的第 26 行看到:PUSH 5 PUSH 4 CALL addition -
被调用者(callee)负责清理栈空间
这与 CDECL 不同。在AddNumber-STDCALL.asm的第 47 行,可以看到RET 8指令。RET nBytes会将控制权从被调函数返回到调用者,并自动在返回地址之后释放 nBytes 个字节的栈空间;在此例中,nBytes 为 8(两个 4 字节的参数),完成栈的清理。 -
函数返回值通过 EAX 寄存器传递
在第 26 行调用addition函数后,其返回值会存放在 EAX 寄存器中。接着,在第 27 行,通过以下指令将 EAX 的值存回局部变量add的栈空间:mov DWORD PTR _add$[ebp], eax -
主函数的返回值
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:指定生成的可执行文件名
下面是执行上述命令后的汇编输出:
这将生成 AddNumber-FASTCALL.asm。要分析 AddNumber-FASTCALL.asm 中的汇编代码,我们将再次查看我们的 C/C++ 源代码。
下面我们来看看生成的 AddNumber-FASTCALL.asm,内容如下:
让我们按照与其他调用约定相同的顺序来分析汇编代码。
在第 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++ 源代码编译,进一步理解汇编输出。