You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

15 KiB

07 | 函数调用为什么会发生stack overflow

在开发软件的过程中我们经常会遇到错误如果你用Google搜过出错信息那你多少应该都访问过[Stack Overflow](https://stackoverflow.com/)这个网站。作为全球最大的程序员问答网站Stack Overflow的名字来自于一个常见的报错就是栈溢出stack overflow

今天,我们就从程序的函数调用开始,讲讲函数间的相互调用,在计算机指令层面是怎么实现的,以及什么情况下会发生栈溢出这个错误。

为什么我们需要程序栈?

和前面几讲一样我们还是从一个非常简单的C程序function_example.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然后通过调用这个add函数来计算u=x+y最后把u的数值打印出来。

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

我们把这个程序编译之后objdump出来。我们来看一看对应的汇编代码。

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    

可以看出来在这段代码里main函数和上一节我们讲的的程序执行区别并不大它主要是把jump指令换成了函数调用的call指令。call指令后面跟着的仍然是跳转后的程序地址。

这些你理解起来应该不成问题。我们下面来看一个有意思的部分。

我们来看add函数。可以看到add函数编译之后代码先执行了一条push指令和一条mov指令在函数执行结束的时候又执行了一条pop和一条ret指令。这四条指令的执行其实就是在进行我们接下来要讲压栈Push出栈Pop操作。

你有没有发现函数调用和上一节我们讲的if…else和for/while循环有点像。它们两个都是在原来顺序执行的指令过程里执行了一个内存地址的跳转指令让指令从原来顺序执行的过程里跳开从新的跳转后的位置开始执行。

但是这两个跳转有个区别if…else和for/while的跳转是跳转走了就不再回来了就在跳转后的新地址开始顺序地执行指令就好像徐志摩在《再别康桥》里面写的“我挥一挥衣袖不带走一片云彩”继续进行新的生活了。而函数调用的跳转在对应函数的指令执行完了之后还要再回到函数调用的地方继续执行call之后的指令就好像贺知章在《回乡偶书》里面写的那样“少小离家老大回乡音未改鬓毛衰”不管走多远最终还是要回来。

那我们有没有一个可以不跳转回到原来开始的地方来实现函数的调用呢直觉上似乎有这么一个解决办法。你可以把调用的函数指令直接插入在调用函数的地方替换掉对应的call指令然后在编译器编译代码的时候直接就把函数调用变成对应的指令替换掉。

不过仔细琢磨一下你会发现这个方法有些问题。如果函数A调用了函数B然后函数B再调用函数A我们就得面临在A里面插入B的指令然后在B里面插入A的指令这样就会产生无穷无尽地替换。就好像两面镜子面对面放在一块儿任何一面镜子里面都会看到无穷多面镜子。

Infinite Mirror Effect如果函数A调用BB再调用A那么代码会无限展开图片来源

看来把被调用函数的指令直接插入在调用处的方法行不通。那我们就换一个思路能不能把后面要跳回来执行的指令地址给记录下来呢就像前面讲PC寄存器一样我们可以专门设立一个“程序调用寄存器”来存储接下来要跳转回来执行的指令地址。等到函数调用结束从这个寄存器里取出地址再跳转到这个记录的地址继续执行就好了。

但是在多层函数调用里简单只记录一个地址也是不够的。我们在调用函数A之后A还可以调用函数BB还能调用函数C。这一层又一层的调用并没有数量上的限制。在所有函数调用返回之前每一次调用的返回地址都要记录下来但是我们CPU里的寄存器数量并不多。像我们一般使用的Intel i7 CPU只有16个64位寄存器调用的层数一多就存不下了。

最终,计算机科学家们想到了一个比单独记录跳转回来的地址更完善的办法。我们在内存里面开辟一段空间,用栈这个后进先出LIFOLast In First Out的数据结构。栈就像一个乒乓球桶每次程序调用函数之前我们都把调用返回后的地址写在一个乒乓球上然后塞进这个球桶。这个操作其实就是我们常说的压栈。如果函数执行完了,我们就从球桶里取出最上面的那个乒乓球,很显然,这就是出栈

拿到出栈的乒乓球找到上面的地址把程序跳转过去就返回到了函数调用后的下一条指令了。如果函数A在执行完成之前又调用了函数B那么在取出乒乓球之前我们需要往球桶里塞一个乒乓球。而我们从球桶最上面拿乒乓球的时候拿的也一定是最近一次的也就是最下面一层的函数调用完成后的地址。乒乓球桶的底部就是栈底,最上面的乒乓球所在的位置,就是栈顶

在真实的程序里压栈的不只有函数调用完成后的返回地址。比如函数A在调用B的时候需要传输一些参数数据这些参数数据在寄存器不够用的时候也会被压入栈中。整个函数A所占用的所有内存空间就是函数A的栈帧Stack Frame。Frame在中文里也有“相框”的意思所以每次到这里我都有种感觉整个函数A所需要的内存空间就像是被这么一个“相框”给框了起来放在了栈里面。

而实际的程序栈布局,顶和底与我们的乒乓球桶相比是倒过来的。底在最上面,顶在最下面,这样的布局是因为栈底的内存地址是在一开始就固定的。而一层层压栈之后,栈顶的内存地址是在逐渐变小而不是变大。

图中rbp是register base pointer 栈基址寄存器栈帧指针指向当前栈帧的栈底地址。rsp是register stack pointer 栈顶寄存器(栈指针),指向栈顶元素。

对应上面函数add的汇编代码我们来仔细看看main函数调用add函数时add函数入口在01行add函数结束之后在1213行。

我们在调用第34行的call指令时会把当前的PC寄存器里的下一条指令的地址压栈保留函数调用结束后要执行的指令地址。而add函数的第0行push rbp这个指令就是在进行压栈。这里的rbp又叫栈帧指针Frame Pointer是一个存放了当前栈帧位置的寄存器。push rbp就把之前调用函数也就是main函数的栈帧的栈底地址压到栈顶。

接着第1行的一条命令mov rbp, rsp里则是把rsp这个栈指针Stack Pointer的值复制到rbp里而rsp始终会指向栈顶。这个命令意味着rbp这个栈帧指针指向的地址变成当前最新的栈顶也就是add函数的栈帧的栈底地址了。

而在函数add执行完成之后又会分别调用第12行的pop rbp来将当前的栈顶出栈这部分操作维护好了我们整个栈帧。然后我们可以调用第13行的ret指令这时候同时要把call调用的时候压入的PC寄存器里的下一条指令出栈更新到PC寄存器中将程序的控制权返回到出栈后的栈顶。

如何构造一个stack overflow

通过引入栈我们可以看到无论有多少层的函数调用或者在函数A里调用函数B再在函数B里调用A这样的递归调用我们都只需要通过维持rbp和rsp这两个维护栈顶所在地址的寄存器就能管理好不同函数之间的跳转。不过栈的大小也是有限的。如果函数调用层数太多我们往栈里压入它存不下的内容程序在执行的过程中就会遇到栈溢出的错误这就是大名鼎鼎的“stack overflow”。

要构造一个栈溢出的错误并不困难最简单的办法就是我们上面说的Infiinite Mirror Effect的方式让函数A调用自己并且不设任何终止条件。这样一个无限递归的程序在不断地压栈过程中将整个栈空间填满并最终遇上stack overflow。

int a()
{
  return a();
}


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

除了无限递归递归层数过深在栈空间里面创建非常占内存的变量比如一个巨大的数组这些情况都很可能给你带来stack overflow。相信你理解了栈在程序运行的过程里面是怎么回事未来在遇到stackoverflow这个错误的时候不会完全没有方向了。

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

上面我们提到一个方法,把一个实际调用的函数产生的指令,直接插入到的位置,来替换对应的函数调用指令。尽管这个通用的函数调用方案,被我们否决了,但是如果被调用的函数里,没有调用其他函数,这个方法还是可以行得通的。

事实上,这就是一个常见的编译器进行自动优化的场景,我们通常叫函数内联Inline。我们只要在GCC编译的时候加上对应的一个让编译器自动优化的参数-O编译器就会在可行的情况下进行这样的指令替换。

我们来看一段代码。

#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)
}

为了避免编译器优化掉太多代码我小小修改了一下function_example.c让参数x和y都变成了通过随机数生成并在代码的最后加上将u通过printf打印出来的语句。

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

上面的function_example_inline.c的编译出来的汇编代码没有把add函数单独编译成一段指令顺序而是在调用u = add(x, y)的时候直接替换成了一个add指令。

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

除了依靠编译器的自动优化你还可以在定义函数的地方加上inline的关键字来提示编译器对函数进行内联。

内联带来的优化是CPU需要执行的指令数变少了根据地址跳转的过程不需要了压栈和出栈的过程也不用了。

不过内联并不是没有代价,内联意味着,我们把可以复用的程序指令在调用它的地方完全展开了。如果一个函数在很多地方都被调用了,那么就会展开很多次,整个程序占用的空间就会变大了。

这样没有调用其他函数,只会被调用的函数,我们一般称之为叶子函数(或叶子过程)

总结延伸

这一节我们讲了一个程序的函数间调用在CPU指令层面是怎么执行的。其中一定需要你牢记的就是程序栈这个新概念。

我们可以方便地通过压栈和出栈操作使得程序在不同的函数调用过程中进行转移。而函数内联和栈溢出一个是我们常常可以选择的优化方案另一个则是我们会常遇到的程序Bug。

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

推荐阅读

如果你觉得还不过瘾可以仔细读一下《深入理解计算机系统第三版》的3.7小节《过程》,进一步了解函数调用是怎么回事。

另外我推荐你花一点时间通过搜索引擎搞清楚function_example.c每一行汇编代码的含义这个能够帮你进一步深入了解程序栈、栈帧、寄存器以及Intel CPU的指令集。

课后思考

在程序栈里面,除了我们跳转前的指令地址外,还需要保留哪些信息,才能在我们在函数调用完成之后,跳转回到指令地址的时候,继续执行完函数调用之后的指令呢?

你可以想一想,查一查,然后在留言区留下你的思考和答案,也欢迎你把今天的内容分享给你的朋友,和他一起思考和进步。