调用惯例是调用方
和被调用方
对于函数如何调用的一个明确的约定,只有双方都遵守同样的约定,函数才能被正确地调用。如果不这样的话,函数将无法正确运行。假设有一个foo
函数
int foo0(int n, float m)
{
int a = 0, b = 0;
...
}
如果函数的调用方
在传递参数时先压入参数n
,再压入参数m
,而foo
函数却认为其调用方应该先压入参数m
,后压入参数n
,那么不难想象foo
内部的m
和n
的值将会被交换。
再者如果函数的调用方
决定利用寄存器传递参数,而函数本身
却仍然认为参数通过栈传递,那么显然函数无法获取正确的参数。因此,毫无疑问函数的调用方
和被调用方
对于函数如何调用须要有一个明确的约定。
一个调用惯例一般会规定如下几个方面的内容:
-
函数参数的传递顺序和方式
函数参数的传递方式有很多种方式,
最常见
的一种是通过栈
传递。函数的调用方将参数压入栈中,函数自己再从栈中将参数取出。对于有多个参数的函数,调用惯例要规定函数调用方将参数压栈的顺序:是从左至右,还是从右至左。有些调用惯例还允许使用寄存器
传递参数,以提高性能
。 -
栈的维护方式
在函数将参数压栈之后,函数体会被调用,此后需要将被压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由函数的调用方来完成,也可以由函数体本身来完成。
-
名字修饰的策略
为了链接的时候对调用惯例进行区分,调用惯例要对函数本身的名字进行修饰。不同的调用惯例有不同的名字修饰策略。
下表介绍了几项主要的调用惯例的内容。
调用惯例 | 出栈方 | 参数传递 | 名字修饰 |
---|---|---|---|
cdecl | 函数调用方 | 从右至左的顺序压参数入栈 | 下划线+函数名 |
stdcall | 函数本身 | 从右至左的顺序压参数入栈 | 下划线+函数名+@+参数的字节数,如函数int func(int a,double b)的修饰名是_func@12 |
fastcall | 函数本身 | 头两个DWORD(4字节)类型或者占更少字节的参数被放入寄存器,其他剩下的参数按从右到左的顺序压入栈 | @+函数名+@+参数的字节数 |
pascal | 函数本身 | 从左至右的顺序压参数入栈 | 较为复杂,参加pascal文档 |
C++自己还有一种特殊的调用惯例,称为thiscall
,专用于类成员函数的调用。其特点随编译器不同而不同,在VC里是this指针
存放于ecx
寄存器,参数从右到左
压栈,而对于gcc、thiscall和cdecl完全一样,只是将this看作是函数的第一个参数
。
下面举个例子具体说明一下调用约定,在C语言里,存在着多个调用惯例,而默认的
调用惯例是cdecl
。任何一个没有显式指定调用惯例的函数都默认是cdecl
惯例。对于函数foo的声明,他的完整形式是:
int _cdecl foo(int n, float m)
因此foo被修饰之后就变成_foo
。在调用foo的时候,按照cdecl的参数传递方式,具体的堆栈操作如下。
- 将
m
压入栈。 - 将
n
压入栈。 - 调用
_foo
,此步又分为两个步骤:- 将
返回地址
(即调用_foo之后的下一条指令的地址)压入栈 - 跳转到
_foo
执行
- 将
当函数返回之后:sp = sp + 8
(参数出栈,由于不需要得到出栈的数据,所以直接调整栈顶位置就可以了)。因此进入foo函数之后,栈上大致是如下图所示。
然后在foo里面要保存一系列的寄存器,包括函数调用方的ebp
寄存器,以及要为a和b两个局部变量
分配空间(参见栈和栈帧的那一篇文章),最终的栈的构成会如下图所示。
在以上布局中,如果想访问变量n
,实际的地址是使用ebp+8
。当foo
返回的时候,程序首先会使用pop恢复保存在栈里的寄存器,然后从栈里取得返回地址,返回到调用方。调用方再调整ESP
将堆栈恢复。
参考书籍:程序员的自我修养