函数调用流程
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今能见到的所有计算机的语言。在解释为什么栈如此重要之前,我们先了解一下传统的栈的定义:
在经典的计算机科学中,栈被定义为一个特殊的容器,用户可以将数据压入栈中(入栈,push
),也可以将压入栈中的数据弹出(出栈,pop
),但是栈容器必须遵循一条规则:先入栈的数据最后出栈(First In Last Out,FILO
)。
在经典的操作系统中,栈总是向下增长的。压栈的操作使得栈顶的地址减小,弹出操作使得栈顶地址增大。
栈在程序运行中具有极其重要的地位。最重要的,栈保存一个函数调用所需要维护的信息,这通常被称为堆栈帧(Stack Frame
)或者活动记录(Activate Record
).一个函数调用过程所需要的信息一般包括以下几个方面:
- 函数的返回地址;
- 函数的参数;
- 临时变量;
- 保存的上下文:包括在函数调用前后需要保持不变的寄存器。
我们从下面的代码,分析以下函数的调用过程:
int func(int a,int b){
int t_a = a;
int t_b = b;
return t_a + t_b;
}
int main(){
int ret = 0;
ret = func(10, 20);
return EXIT_SUCCESS;
}
调用惯例
现在,我们大致了解了函数调用的过程,这期间有一个现象,那就是函数的调用者和被调用者对函数调用有着一致的理解,例如,它们双方都一致的认为函数的参数是按照某个固定的方式压入栈中。如果不这样的话,函数将无法正确运行。
如果函数调用方在传递参数的时候先压入a参数,再压入b参数,而被调用函数则认为先压入的是b,后压入的是a,那么被调用函数在使用a,b值时候,就会颠倒。
因此,函数的调用方和被调用方对于函数是如何调用的必须有一个明确的约定,只有双方都遵循同样的约定,函数才能够被正确的调用,这样的约定被称为”调用惯例(Calling Convention)”.一个调用惯例一般包含以下几个方面:
函数参数的传递顺序和方式
函数的传递有很多种方式,最常见的是通过栈传递。函数的调用方将参数压入栈中,函数自己再从栈中将参数取出。对于有多个参数的函数,调用惯例要规定函数调用方将参数压栈的顺序:从左向右,还是从右向左。有些调用惯例还允许使用寄存器传递参数,以提高性能。
栈的维护方式
在函数将参数压入栈中之后,函数体会被调用,此后需要将被压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由函数的调用方来完成,也可以由函数本身来完成。
为了在链接的时候对调用惯例进行区分,调用惯例要对函数本身的名字进行修饰。不同的调用惯例有不同的名字修饰策略。
事实上,在c语言里,存在着多个调用惯例,而默认的是cdecl
.任何一个没有显示指定调用惯例的函数都是默认是cdecl
惯例。比如我们上面对于func
函数的声明,它的完整写法应该是:int _cdecl func(int a,int b);
注意:
_cdecl
不是标准的关键字,在不同的编译器里可能有不同的写法,例如gcc
里就不存在_cdecl
这样的关键字,而是使用__attribute__((cdecl))
调用惯例 | 出栈方 | 参数传递 | 名字修饰 |
---|---|---|---|
cdecl | 函数调用方 | 从右至左参数入栈 | 下划线+函数名 |
stdcall | 函数本身 | 从右至左参数入栈 | 下划线+函数名+@+参数字节数 |
fastcall | 函数本身 | 前两个参数由寄存器传递,其余参数通过堆栈传递。 | @+函数名+@+参数的字节数 |
pascal | 函数本身 | 从左至右参数入栈 | 较为复杂参见相关文档 |
函数变量传递分析