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