函数调用约定(Calling Convention),是一个重要的基础概念,它规定了程序执行过程中函数的调用者(caller)和被调用这(callee)之间如何传递参数以及如何恢复栈平衡之间的约定。在笔者面试微软ATC的过程中即被面官考查。当然笔者当时也很顺利的举出了这些调用规约,只是对C++中的一个调用规约记得不大清楚了。下面就来研究这些函数调用约定。
在参数传递中,有两个很重要的问题必须得到明确说明:
1.当参数个数多于一个时,按照什么顺序把参数压入栈;
2.函数调用后,由谁来把栈恢复原状。
假如在C语言中,定义下面这样一个函数:
int func(int x,int y, int z)
然后传递实参给函数func()就可以使用了。但是,在系统中,函数调用中参数的传递却是一门学问。因为在CPU中,计算机没有办法知道一个函数调用需要多少个、什么样的参数,也没有硬件可以保存这些参数。也就是说,计算机不知道怎么给这个函数传递参数,传递参数的工作必须由函数调用者和函数本身来协调。为此,计算机用栈来支持参数传递。
函数调用时,调用者依次把参数压栈,然后调用函数,函数被调用以后,在栈中取得数据,并进行计算。函数计算结束以后,或者调用者、或者函数本身修改栈,使栈恢复原状。
在高级语言中,通过函数调用约定来说明参数的入栈和栈的恢复问题。常见的调用约定有:
stdcallcdecl
fastcall
thiscall
naked call
不同的调用约定,在参数的入栈顺序,栈的恢复,函数名字的命名上就会不同。在编译后的代码量,程序执行效率上也会受到影响。
stdcall调用约定
stdcall调用约定声明的格式:
int __stdcall func(int x,int y)
stdcall的调用约定意味着:
参数入栈规则:参数从右向左压入栈
还原栈者:被调用函数自身修改栈
函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸。
在微软Windows的C/C++编译器中,常常用Pascal宏来声明这个调用约定,类似的宏还有WINAPI和CALLBACK。
cdecl调用约定
cdecl调用约定又称为C调用约定,是C语言缺省的调用约定,它的定义语法是:
int func (int x ,int y) //默认的C调用约定
int __cdecl func (int x,int y) //明确指出C调用约定
该调用约定遵循下面的规则:
参数入栈顺序:从右到左
还原栈者:调用者修改栈
函数名:前加下划线:_func
由于每次函数调用都要由编译器产生还原栈的代码,所以使用__cdecl方式编译的程序比使用__stdcall方式编译的程序要大很多,但是 __cdecl调用方式是由函数调用者负责清除栈中的函数参数,所以这种方式支持可变参数,比如printf()和Windows的API wsprintf()就是__cdecl调用方式。
由于参数按照从右向左顺序压栈,因此最开始的参数在最接近栈顶的位置,因此当采用不定个数参数时,第一个参数在栈中的位置肯定能知道,只要不定的参数个数能够根据第一个后者后续的明确的参数确定下来,就可以使用不定参数了。
fastcall调用约定
fastcall的声明语法为:
int fastcall func (int x,int y)
该调用约定遵循下面的规则:
参数入栈顺序:函数的第一个和第二个参数通过ecx和edx传递,剩余参数从右到左入栈
还原栈者:被调用者修改栈
函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸
以fastcall声明执行的函数,具有较快的执行速度,因为部分参数通过寄存器来进行传递的。
调用约定实际例子
int __stdcall func1(int x, int y){
return x+y;
}
int __cdecl func2(int x, int y)
{
return x+y;
}
int __fastcall func3(int x, int y, int z)
{
return x+y+z;
}
int main(int argc, char* argv[])
{
func1(1, 2);
func2(1, 2);
func3(1, 2, 3);
return 0;
}
对于上面3个函数,分别采取stdcall,cdecl,fastcall3种调用约定,从汇编层来分析参数入栈和栈平衡过程如下:
int __stdcall func1(int x, int y)//采用stdcall
{
42D640 push ebp
0042D641 mov ebp,esp
0042D643 sub esp,0C0h
0042D649 push ebx
0042D64A push esi
0042D64B push edi
0042D64C lea edi,[ebp-0C0h]
0042D652 mov ecx,30h
0042D657 mov eax,0CCCCCCCCh
0042D65C rep stos dword ptr es:[edi]
return x+y;
0042D65E mov eax,dword ptr [x]
0042D661 add eax,dword ptr [y]
<br ="" return="" x+y;
}
0042D664 pop edi
0042D665 pop esi
0042D666 pop ebx
0042D667 mov esp,ebp //ebp(调用前的栈顶)放入esp中,然后出栈,恢复老ebp
0042D669 pop ebp
0042D66A ret 8 //被调用者负责栈平衡,ret 8,esp += 8;
int __cdecl func2(int x, int y)//采用cdecl调用约定
{
0042D680 push ebp
0042D681 mov ebp,esp
0042D683 sub esp,0C0h
0042D689 push ebx
0042D68A push esi
0042D68B push edi
0042D68C lea edi,[ebp-0C0h]
0042D692 mov ecx,30h
0042D697 mov eax,0CCCCCCCCh
0040042D69C rep stos dword ptr es:[edi]
return x+y;
0042D69E mov eax,dword ptr [x]
0042D6A1 add eax,dword ptr [y]
}
0042D6A4 pop edi
0042D6A5 pop esi
0042D6A6 pop ebx
0042D6A7 mov esp,ebp
0042D6A9 pop ebp
00000042D6AA ret//被调用者直接返回,不用恢复栈平衡,由调用者负责
int __fastcall func3(int x, int y, int z)//采用fastcall调用约定
{
0042D6C0 push ebp
0042D6C1 mov ebp,esp
0042D6C3 sub esp,0D8h
0042D6C9 push ebx
0042D6CA push esi
0042D6CB push edi
0042D6CC push ecx
0042D6CD lea edi,[ebp-0D8h]
0042D6D3 mov ecx,36h
0042D6D8 mov eax,0CCCCCCCCh
0042D6DD rep stos dword ptr es:[edi]
0042D6DF pop ecx
0042D6E0 mov dword ptr [ebp-14h],edx //前2个参数放在了ecx和edx中
0040042D6E3 mov dword ptr [ebp-8],ecx//前2个参数放在了ecx和edx中
return x+y+z;
0042D6E6 mov eax,dword ptr [x]
0042D6E9 add eax,dword ptr [y]
0042D6EC add eax,dword ptr [z]
}
0042D6EF pop edi
0042D6F0 pop esi
0042D6F1 pop ebx
0042D6F2 mov esp,ebp
0042D6F4 pop ebp
0040042D6F5 ret 4 //第3个参数占4个字节,从栈上传递,所以栈平衡是弹出4个字节
int main(int argc, char* argv[])
{
func1(1, 2); //采用stdcall,参数从右往左依次入栈,被调用者负责栈平衡
//0042D72E push 2 //参数从右往左依次入栈,2入栈
//0042D730 push 1 //参数从右往左依次入栈,1入栈
//0042D732 call func1 (42B6F4h)
func2(1, 2);//采用cdecl调用约定,参数从右往左依次入栈,调用者负责栈平衡
//0042D737 push 2//参数从右往左依次入栈,2入栈
//0042D739 push 1//参数从右往左依次入栈,1入栈
//0042D73B call func2 (42B3FCh)
//0042D740 add esp,8 //调用者负责栈平衡,esp+8,等于2个入栈参数的长度
func3(1, 2, 3);//采用fastcall,前2个参数依次放入ecx和edx寄存器,剩余参数从右往左依次入栈,被调用者负责栈平衡
//0042D743 push 3 //剩余参数从右往左依次入栈,3入栈
//0042D745 mov edx,2 //前2个参数,分别送往ecx和edx寄存器,2入edx
//0042D74A mov ecx,1 //前2个参数,分别送往ecx和edx寄存器,1入ecx
//0042D74F call func3 (42B023h)23h)
return 0;
}
thiscall调用约定
thiscall是C++类成员函数缺省的调用约定,但它没有显示的声明形式。因为在C++类中,成员函数调用还有一个this指针参数,因此必须特殊处理,thiscall意味着:
参数入栈:参数从右向左入栈
this指针入栈:如果参数个数确定,this指针通过ecx传递给被调用者;如果参数个数不确定,this指针在所有参数压栈后被压入栈。
栈恢复:对参数个数不定的,调用者清理栈,否则函数自己清理栈。
naked call 调用约定
这是一个不常用的调用约定,编译器不会给这种函数增加初始化和清理代码,也不能用return语句返回值,只能用插入汇编返回结果。因此它一般用于实模式驱动程序设计,假设定义减法程序,可以定义为:
__declspec(naked) int sub(int a,int b)
{
__asm mov eax,a
__asm sub eax,b
__asm ret
}
上面讲解了函数的各种调用约定。那么如果定义的约定和使用的约定不一致,会出现什么样的问题呢?结果就是:则将导致栈被破坏。最常见的调用规约错误是:
1. 函数原型声明和函数体定义不一致
2. DLL导入函数时声明了不同的函数约定
栈帧(活动记录)
下面来研究C语言的活动记录,即它的栈帧。所谓的活动记录,就是在程序执行的过程中函数调用时栈上的内容变化。 一个函数被调用,反映在栈上的与之相关的内容被称为一个帧,其中包含了参数,返回地址,老ebp值,局部变量,以及esp,ebp。 下图就是程序执行时的一个活动记录。 C语言的默认调用约定为cdecl。因此C语言的活动记录中,参数是从右往左依次入栈。之后是函数的返回地址入栈,接着是ebp入栈。
例题:分析下面程序运行情况,有什么问题呢?
1 #include
2 void main(void)
3 {
4 char x,y,z;
5 int i;
6 int a[16];
7 for(i=0;i<=16;i++)
8 {
9 a[i]=0;
10 printf("\n");
11 }
12 return 0;
13 }
在分析程序执行时,一个重要的方法就是首先画出它的活动记录。根据它的活动记录,去分析它的执行。对于本题的问题,画出了下图的活动记录。

例题:一个C语言程序如下:
void func(void)
{
char s[4];
strcpy(s, "12345678");
printf("%s\n", s);
}
void main(void)
{
func();
printf("Return from func\n");
}
该程序在X86/Linux操作系统上运行的结果如下:
12345678
Return from func
Segmentation fault(core dumped)
试分析为什么会出现这样的运行错误。
答案:func()函数的活动记录如下图所示。在执行字符串拷贝函数之后,由于”12345678”长度大于4个字节,而strcpy()并不检查字符串拷贝是否溢出,因此造成s[4]数组溢出。s[4]数组的溢出正好覆盖了老ebp的内容,但是返回地址并没被覆盖。所以程序能够正常返回。但由于老ebp被覆盖了,因此从main()函数返回后,出现了段错误。因此,造成该错误结果的原因就是func()函数中串拷贝时出现数组越界。

void main(void)
{
addr();
loop();
}
long *p;
void loop(void)
{
long i, j;
j = 0;
for (i = 0; i < 10; i++)
{
(*p)--;
j++;
}
}
void addr(void)
{
long k;
k = 0;
p =& k;
}
分析:
首先变量p是一个全局变量,因此在程序执行期间都有效。然后画出了addr() 和loop()的活动记录如图4-9所示。由addr()和loop()的活动记录,可以看出,p在add()执行结束之后,在loop()执行之时指向了i。

addr()和loop()活动记录
而当p的类型为short*,k为short类型时,p指向了i的高2字节。(*p)--运算前后i的值的情况 如图1和2所示:

图1(*p)--之前i的值

图2(*p)--之后i的值
由于此时p 为short*类型,所以只有i的高2字节参加了运算,此时(*p)--后,i的高2字节为-1,即0xffff。所以对于long类型的i来说,由于系统是高位优先存储整数,那么它的值为:0x0000ffff,即4294901760,远远大于了10。因此循环执行了一次便停止了。
综上分析,程序运行时陷入死循环的原因是由于p指向分配给i的存储单元引起的。循环体执行一次便停止是由于p指向分配给i的高位引起的。可见,要解答好此题,必须要牢固掌握C语言的活动记录和整数的存储方式。