【文档翻译】__cdecl/__stdcall/__fastcall?解开神秘的调用约定!
本文档译自 www.codeproject.com 的文章 "Calling Conventions Demystified",作者 Nemanja Trifunovic,原文参见此处
引言 - Introduction
在学习 Windows 编程的漫长、艰难而美妙的旅途中,你可能会对函数声明前出现的奇怪说明符感到好奇,比如 __cdecl
、__stdcall
、__fastcall
、WINAPI
等等。在阅读过 MSDN 或其他参考资料之后,你可能知道了这些说明符是用来为函数指定一种叫“调用约定”的东西。在这篇文章中,我会使用 Visual C++ 来向你解释不同的调用约定。我要强调的是,上面提到的说明符是微软特有的,如果你想编写可移植代码,就不应该使用它们。
那么,调用约定究竟是什么呢?当我们调用函数时,通常会将参数传递给它,并获得返回值。而调用约定就描述了参数是如何传递、值是如何从函数返回的。它还指定了函数名称的修饰方式。不过,编写优秀的 C/C++ 程序真的一定要了解调用约定吗?并不是。但是,它可能有助于调试。此外,如果要把 C/C++ 与汇编代码链接,那么这也有帮助。
要理解本文,你需要具备汇编编程的一些非常基本的知识。
无论使用哪种调用约定,都会发生以下情况:
- 所有参数都被扩展到 4 字节(除非特别说明,默认在 Win32 上),并放入内存的适当位置,这些位置通常在栈上。不过它们也可能被放在寄存器中,这便是通过调用约定指定的。
- 程序执行流会跳转到被调用函数的地址。
- 在函数内部,寄存器 ESI、EDI、EBX 和 EBP 的值被保存在栈上。执行这些操作的代码部分称为 function prolog,通常由编译器生成。
- 执行函数代码,并将返回值放入 EAX 寄存器中。
- 寄存器 ESI、EDI、EBX 和 EBP 的值从栈中恢复。执行此操作的代码段称为 function epilog,与 function prolog 一样,在大多数情况下,它由编译器生成。
- 参数从栈中移除。此操作称为清栈(stack cleanup),可以在被调用函数的内部执行,也可以由调用方执行,具体取决于所使用的调用约定。
作为调用约定的例子(不考虑 this
),我们将使用一个简单的函数:
int sumExample (int a, int b)
{
return a + b;
}
对这个函数的调用看起来像这样:
int c = sum (2, 3);
对于使用 __cdecl
、__stdcall
、__fastcall
的例子,我会把示例代码编译成 C 代码。本文后面提到的函数名修饰用的是 C 的修饰方法。C++ 的名称修饰方法超出了本文的讨论范围。
C 调用约定 - C calling convention (__cdecl)
这个约定是 C/C++ 的默认调用约定。如果项目被设置成使用其他的调用约定,我们也可以通过显式声明 __cdecl
来为某个函数指定:
int __cdecl sumExample (int a, int b);
__cdecl
调用约定的主要特点是:
- 参数将从右到左依次压入栈中。
- 由调用者执行清栈。
- 函数名用下划线字符
_
作为前缀进行修饰。
现在,示例函数的调用看起来像这样:
; // 参数从右到左依次压入栈中
push 3
push 2
; // 调用函数
call _sumExample
; // 增加参数的总大小到 ESP 寄存器(向高位移动栈指针),以此来清理堆栈
add esp,8
; // 将 EAX 的返回值复制到局部变量 (int c)
mov dword ptr [c],eax
被调用函数 sumExample
的内部如下所示:
; // function prolog
push ebp
mov ebp,esp
sub esp,0C0h
push ebx
push esi
push edi
lea edi,[ebp-0C0h]
mov ecx,30h
mov eax,0CCCCCCCCh
rep stos dword ptr [edi]
; // return a + b;
mov eax,dword ptr [a]
add eax,dword ptr [b]
; // function epilog
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret
标准调用约定 - Standard calling convention (__stdcall)
这个调用约定常常用在 Win32 API 的函数上。事实上,WINAPI
只是 __stdcall
的另一个名称。
#define WINAPI __stdcall
同样,可以为一个函数显式指定标准调用约定:
int __stdcall sumExample (int a, int b);
我们也可以使用编译器选项 /Gz
来给所有未显式声明约定的函数指定 __stdcall
。
__stdcall
调用约定的主要特点是:
- 参数将从右到左依次压入栈中。
- 由被调用的函数执行清栈。
- 函数名通过添加下划线
_
和@
字符和所需的堆栈空间字节数来修饰。
调用示例如下:
; // 参数从右到左依次压入栈中
push 3
push 2
; // 调用函数
call _sumExample@8
; // 将 EAX 的返回值复制到局部变量 (int c)
mov dword ptr [c],eax
函数如下所示:
; // 此处是 function prolog (和 __cdecl 的例子一样,略过)
; // return a + b;
mov eax,dword ptr [a]
add eax,dword ptr [b]
; // 此处是 function epilog (和 __cdecl 的例子一样,略过)
; // 清栈并返回控制流
ret 8
因为栈由被调用的函数清理,所以通常 __stdcall
调用约定创建的可执行文件比 __cdecl
要小。因为在 __cdecl
中,必须为每个函数调用生成清栈的代码。另一方面,参数数量可变的函数(如 printf()
)必须使用 __cdecl
,因为只有调用者知道函数调用中的参数数量;所以,也只有调用方才能执行清栈。
Fast 调用约定 - Fast calling convention (__fastcall)
__fastcall
指出,只要有可能,参数就应该放在寄存器中,而不是栈中。这减少了函数调用的成本,因为使用寄存器的操作比使用堆栈的操作要快。
我们可以显式声明 __fastcall
来使用约定,如下所示:
int __fastcall sumExample (int a, int b);
我们也可以使用编译器选项 /Gr
来给所有未显式声明约定的函数指定 __fastcall
。
__fastcall
的主要特点是:
- 需要 32 位大小(及以下)的前两个函数参数被放入寄存器 ECX 和 EDX。其余的从右向左压入堆栈。
- 被调用的函数负责从堆栈中弹出参数。
- 函数名通过在开头添加
@
字符并附加@
和参数所需的字节数(十进制)来修饰。
注意:Microsoft 保留在未来的编译器版本中更改传递参数的寄存器的权利。
调用例子如下:
; // 将参数放入寄存器 EDX 和 ECX 中
mov edx,3
mov ecx,2
; // 调用函数
call @fastcallSum@8
; // 从寄存器 EAX 拷贝返回值到局部变量 (int c)
mov dword ptr [c],eax
函数内部:
; // function prolog
push ebp
mov ebp,esp
sub esp,0D8h
push ebx
push esi
push edi
push ecx
lea edi,[ebp-0D8h]
mov ecx,36h
mov eax,0CCCCCCCCh
rep stos dword ptr [edi]
pop ecx
mov dword ptr [ebp-14h],edx
mov dword ptr [ebp-8],ecx
; // return a + b;
mov eax,dword ptr [a]
add eax,dword ptr [b]
;// function epilog
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret
这个调用约定究竟和 __cdecl
、__stdcall
相比有多快呢?你可以自己寻找答案。通过声明不同的约定,再比较执行时间看看吧。我没有发现 __fastcall
比其他调用约定更快,不过你可能会得出不同的结论。
Thiscall - Thiscall
Thiscall
是调用 C++ 类成员函数的默认调用约定(参数数量可变的除外)。
这种约定的主要特点是:
- 参数将从右到左依次压入栈中。
this
被放在 ECX 寄存器中。 - 由被调用的函数执行清栈。
这个调用约定的例子有点不同。首先,代码被编译为 C++,而不是 C。其次,我们用一个带有成员函数的结构体,而不是用自由函数。
struct CSum
{
int sum ( int a, int b) {return a+b;}
};
函数调用的汇编代码如下所示:
push 3
push 2
lea ecx,[sumObj]
call ?sum@CSum@@QAEHHH@Z ; CSum::sum
mov dword ptr [s4],eax
函数内部如下所示:
push ebp
mov ebp,esp
sub esp,0CCh
push ebx
push esi
push edi
push ecx
lea edi,[ebp-0CCh]
mov ecx,33h
mov eax,0CCCCCCCCh
rep stos dword ptr [edi]
pop ecx
mov dword ptr [ebp-8],ecx
mov eax,dword ptr [a]
add eax,dword ptr [b]
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret 8
如果我们有一个成员函数使用可变数量参数会发生什么?在这种情况下,会使用 __cdecl
,this
最后被压入栈。
总结 - Conclusion
长话短说,我们总结调用约定之间的主要区别:
__cdecl
是 C 和 C++ 程序的默认调用约定。这种调用约定的优点是,它允许使用具有可变数量参数的函数。缺点是它会创建更大的可执行文件。__stdcall
多用于 Win32 API 函数。它不允许函数具有可变数量的参数。__fastcall
尝试将参数放在寄存器中,而不是堆栈中,从而使函数调用更快。Thscall
调用约定是不使用可变参数的 C++ 成员函数使用的默认调用约定。
在大多数情况下,这就是你需要了解的关于调用约定的全部内容。