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

2019-12-18  本文已影响0人  Jason_Shu

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

为什么我们需要栈?

// 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);
}
$ gcc -g -c function_example.c
$ objdump -d -M intel -S function_example.o
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」函数,被编译后,首先是一条「push」指令和「mov」指令,在函数结束的时候,有一条「pop」指令和「ret」指令。这四条指令的执行就是压栈出栈

「函数调用」和「if...else」有些像,都是从原来顺序执行的指令过程里,执行了一个内存地址的「跳转指令」,让指令从原来顺序执行的过程里跳开,从跳转后新的位置开始执行。

但是两者又有区别,「if...else」是跳转后就不回来了,就在跳转后的新地址顺序执行指令。而「函数调用」是在对应函数的指令执行后,还会回到函数调用的位置,继续执行「call」指令后的指令

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

不过,仔细琢磨一下,你会发现这个方法有些问题。如果函数 A 调用了函数 B,然后函数 B 再调用函数 A,我们就得面临在 A 里面插入 B 的指令,然后在 B 里面插入 A 的指令,这样就会产生无穷无尽地替换。

这个方法行不通,我们能不能把后面跳转回来执行的指令(call指令后的指令)地址记录下来?就像前面的「PC寄存器」一样,我们可以专门设立一个“程序调用寄存器”,来存储「要跳转回来执行的地址」。等函数调用结束后,从这个寄存器中取出地址,继续执行就好。

但是在多层函数中,简单的记录一个地址是不够的,比如「函数A」调用「函数B」,「函数B」调用「函数C」,这样一层层下去,在所有函数调用之前,每一次调用的「返回地址」都要记录下来,「寄存器」是不够用的。

最后想到的办法是,在内存中开辟一段空间,用这个先进后出的数据结构。

打个比方,「栈」相当于一个乒乓球桶,每次函数调用前,我们把「调用返回后的地址」写在一个乒乓球上,然后塞进这个乒乓球桶,这个操作就是「压栈」。如果函数执行完了,我们就从乒乓球桶中取出最上面的兵乓球,也就是「出栈」。

拿到出栈的兵乓球,上面写着地址,然后程序就跳转到该地址,就到了函数调用后下一条指令。如果函数A结束前,又调用了函数B,那么在取出乒乓球A前,又塞入了乒乓球B,而我们从乒乓球桶中取出的乒乓球,一定是最近的。乒乓球桶的底部,就是栈底,最上面的乒乓球所在的位置,就是栈顶

![](https://img.haomeiwen.com/i13186984/75ec30050a851ed4.png?
imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

在真实的程序中,压栈的不仅仅有「函数调用后返回的地址」。比如A调用B的时候,需要传入一些参数数据,在寄存器不够用的时候,这些参数也会被压入栈中。整个函数A所占的内存空间,就是A的栈帧

并且,实际的程序中,底和顶跟我们刚刚讲的乒乓球桶是颠倒的,底在最上面,顶在最下面,这样的布局是因为「栈底的内存地址是在一开始就固定的」。而一层层压栈之后,栈顶的内存地址是在逐渐变小而不是变大。

我们看下上述对应的汇编代码,main函数调用add函数的时候,add函数的入口在0-1行,add函数结束在12-13行。我们在调用34行的「call」指令时,会把当前「PC寄存器」里的下一条指令(也就是第39行指令)的地址「压栈」,保留函数调用结束后要执行的指令地址。而add函数的第0行,「push rbp」这个指令就是在进行「压栈」。这里的rbp又叫「栈帧指针」,是一个存放了当前栈帧位置的「寄存器」(rbp此时指向的是「main函数栈帧」的栈底地址)。「push rbp」就是把之前调用函数,也就是 main 函数的栈帧的栈底地址,压到栈顶。 接着第1行指令「mov rbp, rsp」,就是把「rsp」里面的地址复制到「rbp」里,「rsp」始终指向栈顶,这个命令意味着,rbp 这个栈帧指针指向的地址,变成当前最新的栈顶,也就是 add 函数的栈帧的栈底地址了。

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

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

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

事实上,这就是一个常见的编译器进行自动优化的场景,我们通常叫函数内联(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)
}
$ 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

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

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

上图中,没有调用其他函数,只是被调用的函数,也叫做叶子函数

如果你想一起学习这门课,可以扫下面的二维码购买:


上一篇下一篇

猜你喜欢

热点阅读