重学计算机组成原理(六)- 函数调用怎么突然Stack Overflow了!(上)

重学计算机组成原理(六)- 函数调用怎么突然Stack Overflow了!(上)

用Google搜异常信息,肯定都访问过Stack Overflow网站

全球最大的程序员问答网站,名字来自于一个常见的报错,就是栈溢出(stack overflow)


从函数调用开始,在计算机指令层面函数间的相互调用是怎么实现的,以及什么情况下会发生栈溢出

1 栈的意义

先看一个简单的C程序

  • function.c

重学计算机组成原理(六)- 函数调用怎么突然Stack Overflow了!(上)

直接在Linux中使用GCC编译运行

[hadoop@JavaEdge Documents]$ vim function.c
[hadoop@JavaEdge Documents]$ gcc -g -c function.c 
[hadoop@JavaEdge Documents]$ objdump -d -M intel -S function.o

function.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <add>:
#include <stdio.h>
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
   a:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
   d:   8b 55 fc                mov    edx,DWORD PTR [rbp-0x4]
  10:   01 d0                   add    eax,edx
  12:   5d                      pop    rbp
  13:   c3                      ret    

0000000000000014 <main>:
    return a+b;
}


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
    return 0;
  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的指令,这样就会产生无穷无尽地替换。

就好像两面镜子面对面放在一块儿,任何一面镜子里面都会看到无穷多面镜子

重学计算机组成原理(六)- 函数调用怎么突然Stack Overflow了!(上)

Infinite Mirror Effect

如果函数A调用B,B再调用A,那么代码会无限展开

那就换一个思路,能不能把后面要跳回来执行的指令地址给记录下来呢?

就像PC寄存器一样,可以专门设立一个“程序调用寄存器”,存储接下来要跳转回来执行的指令地址

等到函数调用结束,从这个寄存器里取出地址,再跳转到这个记录的地址,继续执行就好了。


但在多层函数调用里,只记录一个地址是不够的

在调用函数A之后,A还可以调用函数B,B还能调用函数C

这一层又一层的调用并没有数量上的限制

在所有函数调用返回之前,每一次调用的返回地址都要记录下来,但是我们CPU里的寄存器数量并不多

像我们一般使用的Intel i7 CPU只有16个64位寄存器,调用的层数一多就存不下了。


最终,CSer们想到了一个比单独记录跳转回来的地址更完善的办法

在内存里面开辟一段空间,用栈这个后进先出(LIFO,Last In First Out)的数据结构


栈就像一个乒乓球桶,每次程序调用函数之前,我们都把调用返回后的地址写在一个乒乓球上,然后塞进这个球桶

这个操作其实就是我们常说的压栈。如果函数执行完了,我们就从球桶里取出最上面的那个乒乓球,很显然,这就是出栈。


拿到出栈的乒乓球,找到上面的地址,把程序跳转过去,就返回到了函数调用后的下一条指令了

如果函数A在执行完成之前又调用了函数B,那么在取出乒乓球之前,我们需要往球桶里塞一个乒乓球。而我们从球桶最上面拿乒乓球的时候,拿的也一定是最近一次的,也就是最下面一层的函数调用完成后的地址

乒乓球桶的底部,就是栈底,最上面的乒乓球所在的位置,就是栈顶


重学计算机组成原理(六)- 函数调用怎么突然Stack Overflow了!(上)

压栈的不只有函数调用完成后的返回地址

比如函数A在调用B的时候,需要传输一些参数数据,这些参数数据在寄存器不够用的时候也会被压入栈中

整个函数A所占用的所有内存空间,就是函数A的栈帧(Stack Frame)

Frame在中文里也有“相框”的意思,所以,每次到这里,都有种感觉,整个函数A所需要的内存空间就像是被这么一个“相框”给框了起来,放在了栈里面。


而实际的程序栈布局,顶和底与我们的乒乓球桶相比是倒过来的

底在最上面,顶在最下面,这样的布局是因为栈底的内存地址是在一开始就固定的。而一层层压栈之后,栈顶的内存地址是在逐渐变小而不是变大

重学计算机组成原理(六)- 函数调用怎么突然Stack Overflow了!(上)

对应上面函数add的汇编代码,我们来仔细看看,main函数调用add函数时

  • add函数入口在0~1行
  • add函数结束之后在12~13行


在调用第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寄存器中,将程序的控制权返回到出栈后的栈顶。


上一篇:重学计算机组成原理(四)- 进击,更强的性能!(下)


下一篇:C实现简单的本地数据存储