一个函数在栈上的形式、函数的调用惯例

一个函数在栈上的形式、函数的调用惯例

函数的调用和栈是分不开的,没有栈就没有函数调用,我们来了解一下函数在栈上是如何被调用的。

栈帧/活动记录

当函数发生调用时,会将函数运行需要的信息全部压入栈中,这常常被称为栈帧(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)

一个调用惯例一般规定以下内容:

  1. 函数参数的传递方式,是通过栈传递还是通过寄存器传递(这里我们只讨论通过栈传递的情况)
  2. 函数参数的传递顺序,是从左到右入栈还是从右到左入栈
  3. 参数的弹出方式。函数调用结束后需要将压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由调用方来完成,也可以由被调用方来完成。
  4. 函数名修饰方式。函数名在编译时会被修改,调用惯例可以决定如何修改函数名。

在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,还有其他调用惯例,请看下表:

在这里插入图片描述

  • 3
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

JayerZhou

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值