8. 函数调用:为什么会发生stack overflow?

1. 什么叫stack overflow?

答:

  1. 栈溢出 Stack Overflow
  2. 全球最大的程序员问答网站
    Stack Overflow

2. 什么是程序栈?

从一个简单的C语言程序开始。

// function_example.c
#include <stdio.h>
int static add(int a, int b)
{
    return a+b;
}


int main()
{
    int x = 5;
    int y = 10;
    int u = add(x, y);
}

在名字叫add的子函数中,接受a 和 b两个参数 ,返回值就是 a+b。在main函数中定义了x、y和u三个变量。

上述这段程序编译后的汇编程序如下。

int static add(int a, int b)
{
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
   4:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi
   7:   89 75 f8                mov    DWORD PTR [rbp-0x8],esi
    return a+b;
   a:   8b 55 fc                mov    edx,DWORD PTR [rbp-0x4]
   d:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
  10:   01 d0                   add    eax,edx
}
  12:   5d                      pop    rbp
  13:   c3                      ret    
0000000000000014 <main>:
int main()
{
  14:   55                      push   rbp
  15:   48 89 e5                mov    rbp,rsp
  18:   48 83 ec 10             sub    rsp,0x10
    int x = 5;
  1c:   c7 45 fc 05 00 00 00    mov    DWORD PTR [rbp-0x4],0x5
    int y = 10;
  23:   c7 45 f8 0a 00 00 00    mov    DWORD PTR [rbp-0x8],0xa
    int u = add(x, y);
  2a:   8b 55 f8                mov    edx,DWORD PTR [rbp-0x8]
  2d:   8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  30:   89 d6                   mov    esi,edx
  32:   89 c7                   mov    edi,eax
  34:   e8 c7 ff ff ff          call   0 <add>
  39:   89 45 f4                mov    DWORD PTR [rbp-0xc],eax
  3c:   b8 00 00 00 00          mov    eax,0x0
}
  41:   c9                      leave  
  42:   c3                      ret    

发现:

  1. add函数中先执行了一条push指令和一条mov指令。
  2. add函数执行结束后,又执行了一条pop指令和一条ret指令。

分析:
在这里插入图片描述

  1. push就是压栈
  2. pop就是出栈
  3. 函数调用的跳转,在对应函数的指令执行完之后,还需要再回到函数调用的地方,继续执行call之后的指令。

问题来了: 这种情况下,有没有办法可以不跳转回原来的地方,实现函数的调用呢?(例如:Java的方法内联优化方法)
**答:**似乎可以。

  1. 就是把调用的函数指令,直接插入在调用函数的地方,替换对应的 call 指令。然后在编译器编译代码的时候,直接就把函数调用变成对应的指令替换掉。

但是,这会产生:如果函数A调用B,B再调用A,那么程序会导致无限镜面效应(Infinite Mirror Effect)。这种方法行不通(例如:内联优化方法中调用树不能递归)。over
在这里插入图片描述
2. 用一个类似PC寄存器那样的“程序调用寄存器”,来存储接下来要跳转回来执行的指令地址。等到函数调用结束,从这个寄存器里取出地址,再跳转到这个记录的地址,继续执行。

但是,随着多层函数调用的调用数量的增加,每一次调用的返回地址都需要记录下来,所需要的“程序调用寄存器”的数量也一定会增加。而CPU中的寄存器数量并不多,例如Intel i7 CPU中只有16个64-bit的寄存器。这种方法不现实。over

所以,在内存中开一个“后进先出(LIFO,Last In First Out)”数据结构的的存储空间,这就是“”。
栈图
不只有上述函数调用的情况会用堆栈,如下情况也都会会用到堆栈:

  1. a+b*c
    在这里插入图片描述
  2. 函数A在调用函数B时,在跳转之前需要把A的状态(包括函数A的参数、变量、返回地址等)保留下来。这里函数A的状态被称为栈帧(Stack Frame)

在实际内存中,栈底的内存地址一开始就是固定的,在最上面。栈顶的内存地址是逐渐变小,而不是变大,在最下面。这是因为栈底决定了这个栈的最大存储空间。栈底是最大值,然后随着数据不断压栈存储,栈顶不断靠近最上层即接近最小值。如果是逐渐变大的话会造成超出内存地址的最大限制。
在这里插入图片描述
其中,rbp代表register base pointer栈基址寄存器(栈帧指针Frame Pointer),指向栈底的地址(start of stack)。rsp代表register stack pointer栈顶寄存器(栈指针Stack Pointer),指向栈顶的地址(current location in stack)。

3. 如何构造一个stack overflow?

栈的大小是有限制的。如果函数调用层数太多,往堆栈中压入太多的内容,就会发生栈的溢出错误(Stack Overflow)。用上面的Infinite Mirror Effect,让函数A不限递归,就可以实现一个Stack Overflow。

int a()
{
  return a();
}


int main()
{
  a();
  return 0;
}

除了无限递归,如果在堆栈空间里创建非常占内存的变量(例如:巨大的数组),也可能会stack overflow。

4. 如何利用函数内联进行性能优化?

函数内联(Inline):如果被调用的函数里没有调用其他函数(这种被调用的函数称为叶子函数),则可以把调用函数中的指令替换对应的函数调用指令。优点是CPU需要执行的指令变少,不需要根据地址进行跳转,不需要压栈和出栈。例如下列程序:

#include <stdio.h>
#include <time.h>
#include <stdlib.h>

int static add(int a, int b)
{
    return a+b;
}

int main()
{
    srand(time(NULL));
    int x = rand() % 5;
    int y = rand() % 10;
    int u = add(x, y);
    printf("u = %d\n", u);
}

用下列命令执行:

$ gcc -g -c -O function_example_inline.c
$ objdump -d -M intel -S function_example_inline.o

编译出来的汇编程序,没有把add子函数单独编译成一段指令,而直接替换成了一个add命令。

    return a+b;
  4c:   01 de                   add    esi,ebx

除了像上面那样,在 GCC 编译的时候用参数 -O,进行函数内联外,还可以在定义函数时使用inline关键字,来进行函数内联。例如:

#include <stdio.h>
#include <time.h>
#include <stdlib.h>

inline int add(int a, int b)
{
    return a+b;
}

int main()
{
    srand(time(NULL));
    int x = rand() % 5;
    int y = rand() % 10;
    int u = add(x, y);
    printf("u = %d\n", u);
}

内联的代价:内联意味着把可以复用的程序指令在调用它的地方完全展开了。如果一个函数在很多地方都被调用了,那么就会展开很多次,整个程序占用的空间就会变大了。
在这里插入图片描述

5. 小结

通过程序栈,能在跳转去运行新的指令之后,再回到跳出去的位置,能够实现更加丰富和灵活的指令执行流程。程序栈也提供了“函数”这样一个抽象,使得在软件开发的过程中,可以复用代码和指令,而不是只能简单粗暴地复制、粘贴代码和指令。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

叶老师讲大数据

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

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

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

打赏作者

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

抵扣说明:

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

余额充值