函数调用栈

我们在编程中写的函数,会被编译器编译为机器指令,写入可执行文件,程序执行的时候,会把这个可执行文件加载到内存,在虚拟地址空间中的代码段存放。

如果在一个函数中调用另一个函数,编译器就会对应生成一条call指令,当程序执行到这条call指令时,就会跳到对应的函数入口处开始执行,而每一个函数的最后,都有一条ret指令,负责在函数结束后跳回到调用处继续执行。

image-20221219212335264

栈区

函数执行的时候需要有足够的内存空间来存放局部变量,参数,返回值等数据,这些数据存在上图中的栈中。

栈先入后出,先入栈的在底部。

虚拟地址空间的栈区,上面是高地址,下面是低地址,栈底通常称为栈基,栈顶又叫栈指针。

具体的栈帧布局是:

  • 调用者栈基地址(也就是谁调用了这个函数)
  • 局部变量
  • 调用函数的返回值
  • 参数

通过栈指针加上偏移来定位到每个参数和返回值。

比如栈指针+8字节处,就是栈指针的上一格,通过这种方式来进行偏移。

image-20221219212525789

当在A函数中调用B函数时,会在A函数中插入一条call指令,当执行到call指令的时候,会去B函数开始处运行。那么call指令做的事情就是:

  1. 首先把A函数中下一条指令的地址入栈(栈基地址,当B函数执行完之后,可以再通过这个地址回到A函数的调用处继续执行A函数。)
  2. 跳转到被调用函数的入口处执行(也就是被调用函数的栈帧,而所有的函数栈帧布局都遵循统一的结构约定。)

image-20221219213123110

入栈策略

程序执行时,CPU通过特定的寄存器来存运行时的栈基和栈指针,也有指令指针寄存器用来存储下一条要执行的指令地址。

执行指令的过程有两种,第一种是逐步扩张:

逐步扩张

  • 如果要执行入栈3这条指令,CPU读取之后,会先把指令指针移向下一条指令,然后栈指针向下移动,入栈数字3。
  • 然后再执行入栈4这条指令,CPU读取之后,再把指令指针移向下一条指令,然后栈指针向下移动,入栈数字4。
  • 一直往复。

image-20221219213138921

一次性分配

Go语言中的是第二种——一次性分配,它会直接将栈指针移动到所需最大栈空间的位置,然后通过右边这种相对寻址的方式,来把对应的值入栈。

image-20221219213201227

Go语言选择使用一次性分配的策略是有原因的,拿下图来讲,下面三个goroutine,初始分配的栈空间只有那么大,如果要逐步扩张的话,如果g2执行到最后了,但是接下来要执行的函数又要用掉很多的空间,如果函数栈是逐步扩张的,执行时就可能会发生栈访问越界。

函数栈帧的大小可以在编译时期确定, 对于栈消耗大的函数,Go编译器会在函数头部插入检测代码,如果发现需要进行栈增长,则会另外分配一段足够大的空间,然后把原来的内容移过来,并释放原来的空间。

image-20221219213226288

call和ret

首先我们可以看到,下面是栈区代码段

当代码段执行到对应的指令时,就会给栈中添加对应的元素,最终再把栈全部出栈。

假如说,我们是在函数A中的a1处调用函数B(函数B开始位置为b1)。

首先,在最开始的时候,寄存器在栈中的情况是这样的:

image-20221219213244278

ip寄存器中存的是下一条要运行的指令,那么当我们的代码段运行到a1的call指令时,会做两件事:

首先会入栈返回地址a2,然后栈指针sp向下一格,然后给ip寄存器b1的指令地址,接下来要去B函数的开始处运行。call指令就结束了。

接下来就要运行四步函数都要做的事:

  • 第一步是先把栈指针sp移动到足够大的位置——s7上。
  • 第二步是存储一下之前栈基bp寄存器的值,这样可以在运行完之后,还能回到原来的栈基地址。
  • 第三步是把s5存入栈基地址。
  • 接下来就要做函数剩下的指令了——参数,代码等,并一一入栈。

在函数B运行到最后——ret指令之前,编译器还会插入两条指令:

  • 恢复调用者栈基。最开始我们分配了多少空间,此时就释放多少空间,修改bp寄存器为之前入栈的s1,bp继续指向s1处。
  • 然后就到ret指令了,它首先会弹出call指令压栈的返回地址a2,sp赋值为s3。然后跳转到这个返回地址a2,把ip寄存器赋值为a2。 接下来可以从a2这里继续执行了。

简单来说,call指令会分配栈帧,ret指令又会释放栈帧,恢复到call之前的样子。通过这些指令的配合,就能实现函数的层层嵌套了。

参考链接

幼麟实验室bilibili:https://space.bilibili.com/567195437/?spm_id_from=333.999.0.0