深入了解调用约定






3.26/5 (17投票s)
不同调用约定之间的区别。
引言
大家好!在这篇文章中,我将尝试解释所有的调用约定,它们的优缺点。我还会解释为什么C语言可以传递可变数量的参数而C++不能,以及介绍快速调用(fast call)。
背景
了解汇编编程会有帮助,但我会尽量解释清楚,所以不用担心,放松就好。
使用代码
所以,相信你们在职业生涯中一定遇到过“调用约定”这个术语。如果没有,那么请准备好应对运行时错误,例如函数调用之间 ESP 值的保存不当。那么,这到底是怎么回事呢?别担心,你的所有疑问都会得到解答。为什么 C 语言可以向函数传递可变数量的参数,而 C++ 不能?什么是快速调用?准备好了吗?
我们将从 **Cdecl 调用约定** 或 C 声明风格开始。请看下面的简单代码。
#include "stdio.h"
int __cdecl Function(int a, int b, int c)
{
return a + b -c;
}
void main()
{
int r = Function(1, 2, 3);
}
只是一个简单的函数和一个 `main` 函数。现在,我们开始研究汇编。对于那些不懂汇编的人来说,别担心,托微软的福,这也不是什么难事。我们将使用 VC++ 自带的 Dumpbin 工具来反汇编代码并获取汇编。首先,使用命令行编译代码:**cl Cdecltest.c**,输出将是 *obj* 和 *exe* 文件。现在,我们将在命令行中使用 Dumpbin 工具。
dumpbin /disam Cdecltest.obj > Cdecltest.txt
并将输出重定向到一个文本文件。输出是:
Dump of file CdeclTest.obj
File Type: COFF OBJECT
_Function:
00000000: 55 push ebp
00000001: 8B EC mov ebp,esp
00000003: 8B 45 08 mov eax,dword ptr [ebp+8]
00000006: 03 45 0C add eax,dword ptr [ebp+0Ch]
00000009: 2B 45 10 sub eax,dword ptr [ebp+10h]
0000000C: 5D pop ebp
0000000D: C3 ret
_main:
0000000E: 55 push ebp
0000000F: 8B EC mov ebp,esp
00000011: 51 push ecx
00000012: 6A 03 push 3
00000014: 6A 02 push 2
00000016: 6A 01 push 1
00000018: E8 00 00 00 00 call 0000001D
0000001D: 83 C4 0C add esp,0Ch
00000020: 89 45 FC mov dword ptr [ebp-4],eax
00000023: 8B E5 mov esp,ebp
00000025: 5D pop ebp
00000026: C3 ret
其中 `main` 函数的前两行是序言(prolog),始终包含 Opcode 55 8B EC。在从 `main` 向函数传递参数时,我们是将它们压栈(push)的。参数从右到左压入,即 `push 3
`, `push 2
` 和 `push 1
`,最后是执行完函数后需要返回的地址。现在看一下堆栈图。
最初,堆栈指针指向 98H,即较高的内存地址。当我们不断地 `push
` 时,堆栈会变成上面所示的样子。现在,函数调用开始出现问题。如果我们假设压入了三个参数,然后 `pop
` 三个条目,那么它将 `pop
` 返回地址,然后它将不知道应该返回到哪个地址。所以,请看函数调用的汇编代码,并尝试理解它是如何 `pop
` 元素而不干扰返回地址的。可以看到,它首先在其 `ebp
`(基址指针)的地址 80H 处压栈,然后将堆栈指针移入 `ebp
`。
现在看指令 `mov eax,dword ptr [ebp+8]
`,也就是地址 88H,即压入的第一个元素 1。下一个指令是 `add eax,dword ptr [ebp+0Ch]
`,即 ebp+0Ch 给出第二个元素,即 2,然后将它们相加。类似地,它获取第三个元素。然后它 `pops Ebp
` 地址 80H,即堆栈变回图 1 中的样子,然后返回,这是正确的返回地址,即 84H。
这就是我们所需的全部汇编编程。所以,回到我们的主要讨论。抱歉让您偏离了主题。但现在有一个问题是堆栈没有清理,也就是说,我们还没有弹出压入堆栈的三个元素。所以需要有人来清理它,会是谁呢……???? 回到汇编... 在 `main` 函数中,调用之后,有一条指令 `add esp,0Ch
** **,它清理了堆栈,0CH 因为压入了三个元素。如果压入了两个元素,它将是……你猜对了…… `add esp,08h
`。
**就是这样。** 所以在 CDecl 调用约定中,调用函数的人主要负责清理堆栈。
StdCall (std 调用约定 __stdcall)
#include "stdio.h"
int __stdcall Function(int a, int b, int c)
{
return a + b -c;
}
void main()
{
int r = Function(1, 2, 3);
}
同样,让我们看一下汇编代码:**cl stdcalltest.c** 然后 **dumpbin /disasm stdcalltest.obj > stdcalltest.txt**。
Dump of file StdcallTest.obj
File Type: COFF OBJECT
_Function@12:
00000000: 55 push ebp
00000001: 8B EC mov ebp,esp
00000003: 8B 45 08 mov eax,dword ptr [ebp+8]
00000006: 03 45 0C add eax,dword ptr [ebp+0Ch]
00000009: 2B 45 10 sub eax,dword ptr [ebp+10h]
0000000C: 5D pop ebp
0000000D: C2 0C 00 ret 0Ch
_main:
00000010: 55 push ebp
00000011: 8B EC mov ebp,esp
00000013: 51 push ecx
00000014: 6A 03 push 3
00000016: 6A 02 push 2
00000018: 6A 01 push 1
0000001A: E8 00 00 00 00 call 0000001F
0000001F: 89 45 FC mov dword ptr [ebp-4],eax
00000022: 8B E5 mov esp,ebp
00000024: 5D pop ebp
00000025: C3
从汇编代码可以看出,**在 stdcall 中,由被调用的函数清理堆栈**。也就是说,函数最后一行的 `ret 0Ch
`。
所以,如果你使用一个函数,它被调用了二十次,那么清理代码将只在被调用的函数中出现一次,如果使用了 `__stdcall
`。但如果使用了 `__cdecl
`,那么在代码中就会出现二十次,也就是说,在 `main` 函数中每次函数调用之后都会出现。如果我们有一个文件包含五十个函数,每个函数被调用二十次,那么 CDecl 中 EXE 的大小将会很大。但是,`__cdecl
` 的优势是什么呢……这只有 C 语言有,而 C++ 甚至都没有。
**在 __cdecl 调用约定中,你可以传递可变数量的参数**。请记住省略号 (...)。但这在 C++ 使用的 `__stdcall
` 中是不可能的。我们来看看如何实现。正如在 `__stdcall
` 中看到的,函数清理代码只在函数中出现一次,并且值是 `ret
传入的参数数量。也就是说,如果传递了三个参数,那么就是 0Ch,这是固定的,所以我们不能向函数传递可变数量的参数。而在 `__cdecl
` 中,清理代码的次数与函数被调用的次数相同,所以可以进行清理,因为它知道每次传递的参数数量。明白了吗?如果不明白,请参考上面的解释和汇编代码。**同时观察两种情况下汇编代码中的函数名修饰(name mangling)**。函数名以 `_
` 作为前缀。但在 `__stdcall
` 的情况下包含 `@somevalue`,这个值就是传入堆栈以供清理的元素数量的大小。所以,在本例中,它是……是的,你猜对了。
对于 `__cdecl
`,调用 `_Function`;对于 `__stdcall
`,调用 `_Function@12`。
无论如何,再举一个例子来澄清。
下面是使用 __cdecl 调用约定并且可以编译的代码。
#include "stdio.h"
int __cdecl Function(int a,...)
{
//do some processing with all arguments
return 1;
}
void main()
{
int r = Function(1, 2, 3);
}
下面是使用 __stdcall 调用约定但无法编译的代码。
#include "stdio.h"
int __stdcall Function(int a, ...)
{
//do some processing with all arguments
return a;
}
void main()
{
int r = Function(1, 2, 3);
int x = Function(1, 2);
}
**最后但同样重要的是,快速调用(fast call)。** 让我们看一下汇编代码。
#include "stdio.h"
int __fastcall Function(int a, int b, int c)
{
return a + b -c;
}
void main()
{
int r = Function(1, 2, 3);
}
Microsoft (R) COFF Binary File Dumper Version 6.00.8168
Copyright (C) Microsoft Corp 1992-1998. All rights reserved.
Dump of file FastCallTest.obj
File Type: COFF OBJECT
@Function@12:
00000000: 55 push ebp
00000001: 8B EC mov ebp,esp
00000003: 83 EC 08 sub esp,8
00000006: 89 55 F8 mov dword ptr [ebp-8],edx
00000009: 89 4D FC mov dword ptr [ebp-4],ecx
0000000C: 8B 45 FC mov eax,dword ptr [ebp-4]
0000000F: 03 45 F8 add eax,dword ptr [ebp-8]
00000012: 2B 45 08 sub eax,dword ptr [ebp+8]
00000015: 8B E5 mov esp,ebp
00000017: 5D pop ebp
00000018: C2 04 00 ret 4
_main:
0000001B: 55 push ebp
0000001C: 8B EC mov ebp,esp
0000001E: 51 push ecx
0000001F: 6A 03 push 3
00000021: BA 02 00 00 00 mov edx,2
00000026: B9 01 00 00 00 mov ecx,1
0000002B: E8 00 00 00 00 call 00000030
00000030: 89 45 FC mov dword ptr [ebp-4],eax
00000033: 8B E5 mov esp,ebp
00000035: 5D pop ebp
00000036: C3 ret
在快速调用的情况下,从 `main` 的汇编代码可以看出,只压入了一个变量,而两个变量是通过寄存器传递的。也就是说,在快速调用的情况下,第一个和第二个参数通过寄存器传递,其余的按常规方式,即通过堆栈传递。由于使用了寄存器传递,速度更快,但最多只能通过寄存器传递两个参数。还要注意名称修饰,函数以 `@` 开头并包含传递的参数数量,也就是说,在本例中是 ` @Function@12`。
快速回顾
调用约定 | 堆栈清理责任 | 名称修饰 | 优点 |
---|---|---|---|
__stdcall |
被调用函数(即清理代码仅一次) | _FunctionName@4* 传递的参数 |
EXE 体积小 |
__cdecl |
调用函数(每次调用函数时) | _Function |
可以传递可变数量的参数 |
__fastcall |
被调用函数(即清理代码仅一次) | @FunctionName@4* 传递的参数 |
通过使用寄存器实现快速调用 |
注意
我想感谢 Sameer Vasani 先生,我的团队,以及我的朋友 Rahul Bhamre,我从他们身上学到了很多,并且仍在学习新东西。