Calling Convention

调用约定即在编程语言设计中为了实现函数调用而建立的一种协议。这种协议一般会规定处理以下问题:

  • 函数参数传送时入栈和出栈的顺序(由调用者还是被调用者把参数弹出栈)
  • 参数是否可变
  • 编译器用来识别函数名称的名称修饰约定

不同语言会规定不同的调用约定,例如在 c++ 当中,因为重载运算符和重载函数的需要,编译器往往按照某种规则改写每一个入口点的符号名,以便允许同一个名字(具有不同的参数类型或者是不同的作用域)有多个用法。这种技术通常被叫作命名修饰(name mangling)。例如我们有如下函数重载:

1
2
int add(int a, int b);
int add(int a, int b, int c);

在 MSVC 下可以就被编译为 _add_int_int_add_int_int_int

因此,如果我们想要其他语言的模块(例如 csharp)去调用 c/c++ 写的函数,必须使用正确的调用约定来导出函数,且不能使用任何命名修饰。

__cdecl

__cdecl 是 c/c++ 程序默认使用的调用约定(非成员函数和成员静态函数)。也可以加上 __cdecl 显示指明。其约定为:函数参数按照从右到左顺序依次入栈,并且由调用函数者把参数弹出栈以清理堆栈。因此可变参数的函数实现只能使用此约定。而且因为 __cdecl 需要包含清理堆栈的代码,所以编译产生的可执行文件会稍大一点。

1
2
3
4
5
6
7
8
9
int __cdecl add(int a, int b) {
return a + b;
}

int main() {
add(1, 2);

return 0;
}

使用以上代码进行 x64 反汇编

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
main    PROC
$LN3:
sub rsp, 40 ; 00000028H
mov edx, 2 ; 压入参数1
mov ecx, 1 ; 压入参数2
call int add(int,int) ; add
xor eax, eax
add rsp, 40 ; 00000028H 恢复堆栈
ret 0
main ENDP

int add(int,int) PROC ; add
mov DWORD PTR [rsp+16], edx
mov DWORD PTR [rsp+8], ecx
mov eax, DWORD PTR b$[rsp]
mov ecx, DWORD PTR a$[rsp]
add ecx, eax
mov eax, ecx
ret 0 ; 被调用者不清理堆栈
int add(int,int) ENDP ; add

__stdcall

__stdcall 调用约定用于调用 Win32 API 函数,由被调用方清理堆栈。其函数参数也是按照从右到左的顺序入栈。

由于函数体本身知道传进来的参数个数,因此被调用的函数可以在返回前用一条ret n指令直接清理传递参数的堆栈。

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>

int __stdcall add(int a, int b) {
return a + b;
}

int main() {
add(1, 2);

return 0;
}

反汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
_main   PROC
push ebp
mov ebp, esp
push 2 ; 参数从右向左传递
push 1
call int add(int,int) ; add
xor eax, eax ; 调用函数不会清理堆栈
pop ebp
ret 0
_main ENDP

int add(int,int) PROC ; add
push ebp
mov ebp, esp
mov eax, DWORD PTR _a$[ebp]
add eax, DWORD PTR _b$[ebp]
pop ebp
ret 8 ; 返回时清理堆栈
int add(int,int) ENDP ; add

注:此处以及上面的反汇编均来自 godbolt。但是 __cdecl 的是使用的是 msvc v19.38__stdcall 使用的是 msvc v19.14。使用不同版本是因为我发现,在目前较新的 msvc 编译器当中,__stdcall 也是由调用方清理堆栈。不过由于 __stdcall 的官方文档,因此此处使用老版本编译器作为验证。