函数调用栈
我们在编程中写的函数,会被编译器编译为机器指令,写入可执行文件,程序执行的时候,会把这个可执行文件加载到内存,在虚拟地址空间中的代码段 存放。
如果在一个函数中调用另一个函数,编译器就会对应生成一条call 指令,当程序执行到这条call指令时,就会跳到对应的函数入口处开始执行,而每一个函数的最后,都有一条ret 指令,负责在函数结束后跳回到调用处继续执行。
栈区
函数执行的时候需要有足够的内存空间来存放局部变量,参数,返回值等数据,这些数据存在上图中的栈中。
栈就是先入后出,先入栈的在底部。
在虚拟地址空间的栈区,上面的是高地址,下面是低地址,放了一些数据,栈底通常称为栈基 ,栈顶又叫栈指针 。
具体的栈帧布局是:
调用者栈基地址 (也就是谁调用了这个函数)局部变量 调用函数的返回值 参数
通过栈指针加上偏移来定位到每个参数和返回值。
比如栈指针+8字节处,就是栈指针的上一格,通过这种方式来进行偏移。
还记得我们之前说当在A函数中调用B函数时,会在A函数中插入一条call 指令,当执行到call 指令的时候,会去B函数开始处运行。
那么call 指令做的事情就是:
- 首先把A函数中下一条指令的地址入栈(栈基地址,当B函数执行完之后,可以再通过这个地址回到A函数的调用处继续执行A函数。)
- 跳转到被调用函数的入口处执行(也就是被调用函数的栈帧,而所有的函数栈帧布局都遵循统一的结构约定。)
栈具体的入栈策略
程序执行时,CPU通过特定的寄存器来存运行时的栈基和栈指针,也有指令指针寄存器用来存储下一条要执行的指令地址。
执行指令的过程有两种,第一种是逐步扩张:
- 如果要执行
入栈3 这条指令,CPU读取之后,会先把指令指针移向下一条指令,然后栈指针向下移动,入栈数字3。 - 然后再执行
入栈4 这条指令,CPU读取之后,再把指令指针移向下一条指令,然后栈指针向下移动,入栈数字4。 - 一直往复。
Go语言中的是第二种——一次性分配 ,它会直接将栈指针移动到所需最大栈空间的位置,然后通过右边这种相对寻址的方式,来把对应的值入栈。
Go语言选择使用一次性分配 的策略是有原因的,拿下图来讲,下面三个goroutine,初始分配的栈空间只有那么大,如果要逐步扩张的话,如果g2执行到最后了,但是接下来要执行的函数又要用掉很多的空间,如果函数栈是逐步扩张的,执行时就可能会发生栈访问越界。
函数栈帧的大小可以在编译时期确定, 对于栈消耗大的函数,Go编译器会在函数头部插入检测代码,如果发现需要进行栈增长 ,则会另外分配一段足够大的空间,然后把原来的内容移过来,并释放原来的空间。
call和ret的细节
首先我们可以看到,下面是栈区 和代码段 。
当代码段执行到对应的指令时,就会给栈中添加对应的元素,最终再把栈全部出栈。
假如说,我们是在函数A中的a1处调用函数B(函数B开始位置为b1)。
首先,在最开始的时候,寄存器在栈中的情况是这样的:
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之前的样子。通过这些指令的配合,就能实现函数的层层嵌套了。
函数传参和返回值
首先看一个例子,下面这个例子是交换两个局部变量的值,可以看到,结果并没有改变:
上面那个函数在栈中的分配如下:
- 首先分配局部变量的空间,然后把局部变量存进去。
- 然后分配被调用函数的参数,从右至左分配。先入栈第二个参数,再入栈第一个参数。传参是值拷贝,所以把两个参数的值压入栈。
- 接下来是call指令存入的返回地址。也就是fmt.Println这一行代码所对应的指令
- 再接下来就是swap函数栈帧了
当swap函数执行到a,b=b,a 时,就会修改参数对应的值,但是调用者的局部变量a和b在上面,交换的并不是它们,所以最终结果显示没有交换成功。
我们再修改一下:
还是和上一次的一样,只是我们把指针作为参数,函数参数还是值类型,所以会拷贝两个地址的值。
再swap函数中,会将对应地址的值进行交换,修改的是调用者的局部变量a 和b ,所以最终修改成功。
通常,返回值是通过寄存器传递的,但是Go语言支持多返回值,所以在栈上分配返回值更合适。
接下来我们看一个有返回值的例子:
-
一次性分配main函数栈帧,sp直接到达对应的位置。 -
把局部变量压入栈 -
压入栈函数返回值(默认为0)——因为栈是先入后出的缘故,所以一个函数的执行步骤要从后往前的压入栈。 -
把函数参数压入栈 -
保存调用者函数main的栈基地址以方便最后回到main函数。 -
接下来进入函数incr 的函数栈帧。 首先初始化局部变量b,默认为0, -
然后执行a++ 指令,把局部变量a的值加1. -
运行到b=a 的指令,把参数a赋给局部变量b。 -
接下来就是返回值和defer函数的问题。 在Go语言中,是先给返回值赋值,然后再执行defer函数。
- 把局部变量b的值,拷贝到返回值空间。
- 执行注册的defer函数,在defer函数中,参数a再次自增1,局部变量b也加1。但是需要注意的是它让局部变量b自增1,不代表就把返回值自增1,因为在defer之前,已经给返回值赋值过了,可以看下图,b的值是2,但是返回值还是1.
- 把返回值给局部变量b
- 输出a,b——0,1
接下来我们看这个例子,用的是命名返回值:
- main函数的栈情况还是如下图右边所示。
- 接下来会运动到
incr 函数的a++ 指令,然后把参数a的值加1. return a 指令赋值a变量的值给返回值局部变量b,此时b=a=1.- 运行defer函数,a++,b++,a和b都是2.
- 此时返回值的位置为2,所以会把main函数中的局部变量b赋值为2.
- 打印0,2
当函数A中调用函数B和函数C时,栈的寻址策略
-
首先分配A函数的局部变量空间。 -
因为后面有两个函数要执行,又因为Go是一次性分配空间的,所以会分配最大的参数和返回值空间,函数B比函数C的空间要大,就以函数B所需要的空间标准来分配,如下图r2~p1 这么大的空间。 -
接下来把函数B的参数和返回值压入栈,进入函数B的栈帧。
- 当函数B执行完毕之后,会释放这两片空间。把函数C的参数和返回值压入栈,但是此时空间还是那么大,
r1 和p1 是存在这片空间的上面,还是下面,还是中间?
最终的答案就是,会把r1 和p1 分配到最下面,和函数C的栈指针挨着,这样虽然上面会空出来一块,但是被调用者通过栈指针相对寻址自己的参数和返回值时会比较方便。
|