Windows上目前最常见的调用约定应该有如下四种:__cdecl,__stdcall,__fastcall和__thiscall,另外有好多从中#define出来的macros。这四种每种都规定了函数在调用时和调用后的处理步骤。
在分析各种调用前,首先要了解下一些基本的函数调用常识:
- 函数调用前会将参数(如果有的话)存入指定的存储空间,一般来说是栈,但是也可以是寄存器。参数会被自动扩展为机器字大小。例如:32位的程序是32bits。这个和push指令有关。
- 函数调用指令call会自动将EIP压入栈中;相应的ret指令会自动将栈顶保存的地址还给EIP
- 大部分情况下,在刚进入函数后,编译器会将EBP设置成栈帧基址,并在函数的整个执行中以EBP为地址基准。接着编译器会把可能用到的寄存器压住栈中。通常情况下,编译器还会自动保留一段栈空间用以保存临时变量,而且这段地址常常会用0xCCCCCCCC填充
- 在函数的结尾部分,会执行寄存器和EBP的复原操作。并通常将返回值置入EAX。
- 上面的操作结束后就需要清理参数传递消耗的栈空间。这部分清理可以在函数内部和外部进行,依据调用约定
为了简化说明,使用两个数求和的DEMO是汇编中的传统。在这里,我们选择沿袭这个传统(说实在,我真不知道为什么传统是这个,望天)。我们的demo code如下:
1 | int sumexp( int a, int b) |
2 | { |
3 | return a + b; |
4 | } |
5 |
6 | int c = sumexp(5, 10); |
0.__cdecl
__cdecl是C/C++中普通函数默认的调用约定。其实我很怀疑“cdecl”是“c declaration”的缩写。
__cdecl的主要特点如下:
- 函数参数逆序压栈,即从右往左依次压入栈中
- 由调用者[caller]清栈
逆序压栈是因为需要更好的支持C中的format specification。而caller清栈指的自然是清理参数消耗的栈空间。
上面这段代码被编译后的汇编代码如下(代码来自OD):
01 | ; calling |
02 | push 0A |
03 | push 5 |
04 | call 00DD120D ; sumexp |
05 | add esp, 8 |
06 |
07 |
08 | ; in function sumexp |
09 | push ebp |
10 | mov ebp, esp |
11 | sub esp, 0C0 |
12 | push ebx |
13 | push esi |
14 | push edi |
15 | lea edi, [ebp-C0] |
16 | mov ecx, 30 |
17 | mov eax, CCCCCCCC |
18 | rep stos dword ptr es:[edi] |
19 |
20 | ; summing |
21 | ; return a + b |
22 | mov eax, [ebp+8] ; 1st param |
23 | add eax, [ebp+C] ; 2nd param |
24 |
25 | pop edi |
26 | pop esi |
27 | pop ebx |
28 | mov esp, ebp |
29 | pop ebp |
30 |
31 | ; return |
32 | retn |
上面反汇编代码中,在发生函数调用后,编译器手动增加了ESP的值以清理堆栈。
需要注意的是,C编译器在编译时会将采用__cdecl约定的函数名作如下修饰:
_functionname
在C++中由于要支持函数重载,所以会有额外的修饰操作。
1. __stdcall
__stdcall是Windows API使用的调用约定,故也经常被定义成WINAPI。
__stdcall有如下主要特点:
- 函数参数逆序压栈,即从右往左依次压入栈中
- 由被调用者[callee]清栈
函数的反汇编代码如下:
01 | ; calling |
02 | push 0A |
03 | push 5 |
04 | call 00CA1212 |
05 |
06 | push ebp |
07 | mov ebp, esp |
08 | sub esp, 0C0 |
09 | push ebx |
10 | push esi |
11 | push edi |
12 | lea edi, [ebp-C0] |
13 | mov ecx, 30 |
14 | mov eax, CCCCCCCC |
15 | rep stos dword ptr es:[edi] |
16 |
17 | ; summing |
18 | mov eax, [ebp+8] |
19 | add eax, [ebp+C] |
20 |
21 | pop edi |
22 | pop esi |
23 | pop ebx |
24 | mov esp, ebp |
25 | pop ebp |
26 |
27 | ; cleanup |
28 | retn 8 |
观察可以发现,清理参数消耗的栈空间直接在函数内部完成了。但是这里有个问题,编译器怎么知道参数用了多少栈空间?答案是不知道。
为了干掉这个不知道,编译器会将使用__stdcall的函数做如下修饰
__functionname@num
@后面num是参数消耗的栈空间。而且这个数在32bits下一定是4的倍数。
由于清理栈空间在函数内部完成,少了一条指令,所以采用__stdcall的函数用起来会比__cdecl更快更小。但是由于不定参数的存在,使得C和普通的C++函数无法使用__stdcall调用约定。而绝大多数的WinAPI由于在调用时就已经确定了参数个数,所以大量使用__stdcall调用约定。
2. __fastcall
使用__fastcall调用约定,可以将参数存入寄存器,从而减少使用栈的开销。但是考虑到寄存器的个数有限以及相当一部分寄存器都有特定的用途,所以__fastcall只使用了ECX和EDX两个寄存器。
__fastcall的主要特点如下:
- 某两个函数参数分别压入ECX和EDX(同样逆序)。其余的逆序压入栈
- 由被调用者[callee]清栈
相应的反汇编代码如下:
01 | mov edx, 0A |
02 | mov ecx, 5 |
03 | call 00FE1217 |
04 |
05 | push ebp |
06 | mov ebp, esp |
07 | sub esp, 0D8 |
08 | push ebx |
09 | push esi |
10 | push edi |
11 | push ecx |
12 | lea edi, [ebp-D8] |
13 | mov ecx, 36 |
14 | mov eax, CCCCCCCC |
15 | rep stos dword ptr es:[edi] |
16 | pop ecx |
17 |
18 | mov [ebp-14], edx |
19 | mov [ebp-8], ecx |
20 | mov eax, [ebp-8] |
21 | add eax, [ebp-14] |
22 |
23 | pop edi |
24 | pop esi |
25 | pop ebx |
26 | mov esp, ebp |
27 | pop ebp |
28 |
29 | retn |
编译器会在编译时将采用__fastcall的函数做如下修饰
@functionname@num
num是所有参数的大小
使用__fastcall需要非常的注意,因为这容易引起一些莫名其妙的问题,而且VC保留对这个调用约定的决定权。
3. __thiscall
__thiscall是C++中成员函数的默认调用约定,其主要特点如下:
- this指针置于ECX,其余参数逆序压栈
- 由被调用者[callee]清栈
为了使用成员函数,我们将函数放入struct中:
01 | struct SUM |
02 | { |
03 | int sumexp( int a, int b) |
04 | { |
05 | return a + b; |
06 | } |
07 | }; |
08 |
09 | SUM s; |
10 | int c = s.sumexp(5, 10); |
相应的反汇编代码如下:
01 | push 0A |
02 | push 5 |
03 | lea ecx, [ebp-5] ; this ptr |
04 | call 0002121C |
05 |
06 | push ebp |
07 | mov ebp, esp |
08 | sub esp, 0CC |
09 | push ebx |
10 | push esi |
11 | push edi |
12 | push ecx |
13 | lea edi, [ebp-CC] |
14 | mov ecx, 33 |
15 | mov eax, CCCCCCCC |
16 | rep stos dword ptr es:[edi] |
17 | pop ecx |
18 |
19 | mov [ebp-8], ecx |
20 | mov eax, [ebp+8] |
21 | add eax, [ebp+C] |
22 |
23 | pop edi |
24 | pop esi |
25 | pop ebx |
26 | mov esp, ebp |
27 | pop ebp |
28 |
29 | retn 8 |
另外,如果成员函数使用了不定参数,那么编译器则会转而使用__cdecl作为调用约定。