调用约定详解






4.89/5 (149投票s)
Visual C++ 调用约定解释
引言
在学习 Windows C++ 编程漫长、艰苦而又美好的过程中,您可能一直对函数声明前偶尔出现的奇怪修饰符感到好奇,例如 __cdecl
、__stdcall
、__fastcall
、WINAPI
等。查阅 MSDN 或其他参考资料后,您可能发现这些修饰符指定了函数的调用约定。在本文中,我将尝试解释 Visual C++(以及其他 Windows C/C++ 编译器)使用的不同调用约定。我强调,上述修饰符是 Microsoft 特定的,如果您想编写可移植代码,则不应使用它们。
那么,什么是调用约定?当一个函数被调用时,通常会将参数传递给它,并获取返回值。调用约定描述了如何传递参数以及函数如何返回值。它还指定了函数名称如何修饰。理解调用约定对于编写好的 C/C++ 程序真的有必要吗?完全没有。但是,它可能有助于调试。此外,对于将 C/C++ 与汇编代码链接也是必要的。
要理解本文,您需要具备一些非常基本的汇编编程知识。
无论使用何种调用约定,都会发生以下情况:
- 所有参数都被扩展为 4 字节(当然在 Win32 上),并放入适当的内存位置。这些位置通常在堆栈上,但也可能在寄存器中;这由调用约定指定。
- 程序执行跳转到被调用函数的地址。
- 在函数内部,寄存器 ESI、EDI、EBX 和 EBP 被保存到堆栈上。执行这些操作的代码部分称为函数序言,通常由编译器生成。
- 执行函数特定代码,并将返回值放入 EAX 寄存器。
- 寄存器 ESI、EDI、EBX 和 EBP 从堆栈中恢复。执行此操作的代码段称为函数尾声,与函数序言一样,在大多数情况下由编译器生成。
- 参数从堆栈中移除。此操作称为堆栈清理,可以由被调用函数内部执行,也可以由调用方执行,具体取决于所使用的调用约定。
作为调用约定(除了 this)的示例,我们将使用一个简单的函数
int sumExample (int a, int b) { return a + b; }
对此函数的调用将如下所示
int c = sum (2, 3);
对于 __cdecl、__stdcall 和 __fastcall 调用约定,我将示例代码编译为 C(而不是 C++)。文章后面提到的函数名称修饰适用于 C 的修饰方案。C++ 名称修饰超出了本文的范围。
C 调用约定 (__cdecl)
此约定是 C/C++ 程序的默认设置(编译器选项 /Gd)。如果项目设置为使用其他调用约定,我们仍然可以声明一个函数使用 __cdecl
int __cdecl sumExample (int a, int b);
__cdecl 调用约定的主要特点是:
- 参数从右到左传递,并放置在堆栈上。
- 堆栈清理由调用方执行。
- 函数名称通过前缀下划线字符 '_' 进行修饰。
现在,看一下 __cdecl 调用的示例
; // push arguments to the stack, from right to left push 3 push 2 ; // call the function call _sumExample ; // cleanup the stack by adding the size of the arguments to ESP register add esp,8 ; // copy the return value from EAX to a local variable (int c) mov dword ptr [c],eax
被调用函数如下所示
; // 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
标准调用约定 (__stdcall)
此约定通常用于调用 Win32 API 函数。实际上,WINAPI
只是 __stdcall
的另一个名称
#define WINAPI __stdcall
我们可以显式声明一个函数使用 __stdcall
约定
int __stdcall sumExample (int a, int b);
此外,我们可以使用编译器选项 /Gz 为所有未显式声明其他调用约定的函数指定 __stdcall
。
__stdcall
调用约定的主要特点是:
- 参数从右到左传递,并放置在堆栈上。
- 堆栈清理由被调用函数执行。
- 函数名称通过前缀下划线字符,并附加 '@' 字符和所需堆栈空间的字节数进行修饰。
示例如下:
; // push arguments to the stack, from right to left push 3 push 2 ; // call the function call _sumExample@8 ; // copy the return value from EAX to a local variable (int c) mov dword ptr [c],eax
函数代码如下所示:
; // function prolog goes here (the same code as in the __cdecl example) ; // return a + b; mov eax,dword ptr [a] add eax,dword ptr [b] ; // function epilog goes here (the same code as in the __cdecl example) ; // cleanup the stack and return ret 8
由于堆栈由被调用函数清理,__stdcall
调用约定创建的可执行文件比 __cdecl
更小,因为在 __cdecl
中,每次函数调用都必须生成用于堆栈清理的代码。另一方面,具有可变参数的函数(如 printf()
)必须使用 __cdecl
,因为只有调用方知道每次函数调用中的参数数量;因此只有调用方才能执行堆栈清理。
快速调用约定 (__fastcall)
快速调用约定表示只要可能,参数就应放置在寄存器中,而不是堆栈上。这减少了函数调用的开销,因为寄存器操作比堆栈操作更快。
我们可以显式声明一个函数使用 __fastcall
约定,如下所示
int __fastcall sumExample (int a, int b);
我们还可以使用编译器选项 /Gr 为所有未显式声明其他调用约定的函数指定 __fastcall
。
__fastcall
调用约定的主要特点是:
- 前两个需要 32 位或更少的函数参数放置在寄存器 ECX 和 EDX 中。其余参数从右到左推送到堆栈上。
- 参数由被调用函数从堆栈中弹出。
- 函数名称通过前缀 '@' 字符,并附加 '@' 和参数所需空间字节数(十进制)进行修饰。
注意:Microsoft 保留了在未来编译器版本中更改传递参数的寄存器的权利。
示例如下:
; // put the arguments in the registers EDX and ECX mov edx,3 mov ecx,2 ; // call the function call @fastcallSum@8 ; // copy the return value from EAX to a local variable (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
相比,这种调用约定有多快?自己去发现吧。设置编译器选项 /Gr,然后比较执行时间。我没有发现 __fastcall
比其他调用约定更快,但您可能会得出不同的结论。
Thiscall
Thiscall
是调用 C++ 类成员函数(除了那些具有可变参数的函数)的默认调用约定。
thiscall
调用约定的主要特点是:
- 参数从右到左传递,并放置在堆栈上。
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
最后被推入堆栈。
结论
长话短说,我们将概述调用约定之间的主要区别:
__cdecl
是 C 和 C++ 程序的默认调用约定。这种调用约定的优点是它允许使用可变参数的函数。缺点是它创建的可执行文件更大。__stdcall
用于调用 Win32 API 函数。它不允许函数具有可变参数。__fastcall
尝试将参数放入寄存器,而不是堆栈,从而使函数调用更快。Thiscall
调用约定是 C++ 成员函数(不使用可变参数)使用的默认调用约定。
在大多数情况下,这些就是您需要了解的关于调用约定的全部信息。