一个函数在栈上的形式、函数的调用惯例
函数的调用和栈是分不开的,没有栈就没有函数调用,我们来了解一下函数在栈上是如何被调用的。
栈帧/活动记录
当函数发生调用时,会将函数运行需要的信息全部压入栈中,这常常被称为栈帧(Stack Frame)或活动记录(Activate Record)。活动记录一般包括以下几个方面的内容:
1)函数的返回地址,也就是函数执行完成后从哪里开始继续执行后面的代码。例如:
int a, b, c;
func(1, 2);
c = a + b;
站在C语言的角度看,func()函数执行完成后,会继续执行c = a+b
语句,那么返回地址就是该语句在内存中的位置。
注意:C语言代码最终会被编译为机器指令,确切的说,返回地址应该是下一条指令的地址,这里之所以说是下一条C语言语句的地址,仅仅是为了更加直观的说明问题。
2)参数和局部变量。有些编译器,或者编译器在开启优化选项的情况下,会通过寄存器来传递参数,而不是将参数压入栈中,我们暂时不考虑这种情况。
3)编译器自动生成的临时数据。例如,当函数返回值长度较大(比如占用40个字节)时,会先将返回值压入栈中,然后再交给函数调用者。
当返回值的长度较小(char、int、long)时,不会被压入栈中,而是先将返回值放入寄存器,再传递给函数调用者。
4)一些需要保存的寄存器,例如ebp、ebx、esi、edi等。之所以要保存寄存器的值,是为了在函数退出时能够恢复到函数调用之前的场景,继续执行上层函数。
下图是一个函数调用的实例:
上图是在Windows下使用VS2010 Debug模式编译时一个函数所使用的栈内存,可以发现,理论上ebp寄存器应该指向栈底,但在实际应用中,他缺指向了old ebp。
在寄存器名字前面加“old”,表示函数调用之前该寄存器的值。
当发生函数调用时:
- 实参、返回地址、ebp寄存器首先入栈
- 然后再分配一块内存供局部变量、返回值等使用,这块内存一般比较大,足以容纳所有数据,并且会有冗余
- 最后将其他寄存器的值压入栈中
需要注意的是,不同编译器在不同编译模式下所产生的函数栈并不完全相同,例如在VS2010下选择Release模式,编译器会进行大量优化,函数栈的样貌荡然无存,不具有教学意义,所以我们以VS 2010 Debug模式为例进行分析。
关于数据的定位
由于esp的值会随着数据的入栈而不断变化,要想根据esp找到参数、局部变量等数据是比较困难的,所以在实现上是根据ebp来定位栈内数据的。ebp的值是固定的,数据相对ebp的偏移也是固定的,ebp的值加上偏移量就是数据的地址。
例如一个函数定义:
void func(int a, int b){
float f = 28.5;
int n = 100;
//TODO:
}
调用形式为:
func(15,92)
那么函数的活动记录如下图所示:
这里我们假设两个局部变量挨着,并且第一个变量和old ebp也挨着(实际上他们之间有4个字节的空白),如此,第一个参数的地址是ebp+12,第二个参数的地址是ebp+8,第一个局部变量的地址是ebp-4,第二个局部变量的地址是ebp-8。
后面我们会以一个具体的实例来深入剖析函数进栈出栈的过程。
函数调用惯例
我们知道,一个C程序由若干个函数组成,C程序的执行实际上就是函数之间的相互调用。请看下面代码:
#include <stdio.h>
void funcA(int m, int n){
printf("funcA被调用\n");
}
void funcB(float a, float b){
funcA(100, 200);
printf("funcB被调用\n");
}
int main(){
funcB(19.9, 28.5);
printf("main被调用\n");
return 0;
}
main()调用了funcB(),funB()又调用了funcA()。对于main()调用funcB(),我们称main()是调用方,funcB()是被调用方;同理,对于 funcB() 调用 funcA(),funcB() 是调用方,funcA() 是被调用方。
函数的参数(实参)由调用方压入栈中供被调用方使用,他们之间要有一致的约定。例如,参数是从左到右入栈还是从右到左入栈,如果双方理解不一致,被调用方使用参数时就会出错。
以 funcB() 为例,假设 main() 函数先将 19.9 入栈,后将 28.5 入栈,但是 funcB() 在使用这些实参时却认为 28.5 先入栈,19.9 后入栈,那么就一定会产生混乱,误以为19.9 是传递给 b、28.5 是传递给 a 的。
所以,函数调用方和被调用方必须遵守同样的约定,理解要一致,这称为调用惯例(Calling Convention)。
一个调用惯例一般规定以下内容:
- 函数参数的传递方式,是通过栈传递还是通过寄存器传递(这里我们只讨论通过栈传递的情况)
- 函数参数的传递顺序,是从左到右入栈还是从右到左入栈
- 参数的弹出方式。函数调用结束后需要将压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由调用方来完成,也可以由被调用方来完成。
- 函数名修饰方式。函数名在编译时会被修改,调用惯例可以决定如何修改函数名。
在C语言中,存在多种调用惯例,可以在函数声明或函数定义时指定,例如:
#include <stdio.h>
int __cdecl max(int m, int n);
int main(){
int a = max(10, 20);
printf("a = %d\n", a);
return 0;
}
int __cdecl max(int m, int n){
int max = m>n ? m : n;
return max;
}
函数调用惯例在函数声明和函数定义时都可以指定,语法格式为:
返回值类型 调用惯例 函数名(函数参数)
在函数声明处是为调用方指定调用惯例,而在函数定义处是为被调用方(也就是函数本身)指定调用惯例。
__cdecl
是C语言默认的调用惯例,在平时的编程中,我们其实很少去指定调用惯例,这个时候就需要使用默认的__cdecl
注意:__cdecl并不是标准关键字,上面的写法在VC/VS下有效,但是在GCC下,要使用__attribute__((cdcel))
除了cdecl,还有其他调用惯例,请看下表: