美文网首页
7. 函数调用:为什么会发生stack overflow?

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

作者: Jason_Shu | 来源:发表于2019-12-18 18:53 被阅读0次

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

    为什么我们需要栈?

    // 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 需要执行的指令数变少了,根据地址跳转的过程不需要了,压栈和出栈的过程也不用了。

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

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

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


    相关文章

      网友评论

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

          本文链接:https://www.haomeiwen.com/subject/odgsnctx.html