本文已参与「新人创作礼」活动,一起开启掘金创作之路。
golang函数调用栈如果在一个函数中调用另一个函数,编译器就会对应生成一条call指令,程序执行到这条指令时,就会跳转到被调用函数入口处开始执行,而每个函数的最后都有一条ret指令,负责在函数结束后跳回到调用处,继续执行。
函数栈帧
函数执行时需要有足够的内存空间,供它存放局部变量、参数等数据,这段空间对应到虚拟地址空间的栈。 运行时栈,上面是高地址,向下增长,栈底通常被称为栈,栈顶被称为栈指针。
分配给函数的栈空间被称为函数栈帧。 Go语言中函数栈帧布局是这样的,先是调用者栈基地址,然后是函数的局部变量,最后是被调用函数的返回值和参数。
BP of callee和SP of callee标识被调用函数执行时,栈基寄存器和栈指针寄存器指向的位置,但是注意“BP of caller”不一定会存在,有些情况下可能会被优化掉,也有可能是平台不支持。我们只关注局部变量和参数、返回值的相对位置就好。
下面举一个例子:
func A() {
var a1, a2, r1, r2 int64
a1, a2 = 1, 2
r1, r2 = B(a1, a2)
r1 = C(a1)
println(r1, r2)
}
func B(p1, p2 int64) (int64, int64) {
return p2, p1
}
func C(p1 int64) int64 {
return p1
}
复制代码
函数A的栈帧布局如下图所示:
注意观察参数的顺序,先入栈第二个参数,再入栈第一个参数,返回值也是一样的,上面是第二个返回值的空间,然后才是第一个返回值的空间。 因为这些是被调用函数的返回值和参数,被调用函数是通过栈指针加上偏移值这样相对寻址的方式来定位到自己的参数和返回值的,这样由下至上正好先找到第一个参数,再找到第二个参数。所以参数和返回值采用由右至左的入栈顺序比较合适。 通常,我们认为返回值是通过寄存器传递的,但是Go语言支持多返回值,所以在栈上分配返回值空间更合适。
对函数B的调用会被编译器编译为call指令。实际上call指令只做两件事情:
- 将下一条指令的地址入栈,被调用函数执行结束后会跳回到这个地址继续执行,这就是函数调用的“返回地址”。
- 跳转到被调用的函数B指令入口处执行,所以在“返回地址”下面就是函数B的栈帧了。
所有函数的栈帧布局都遵循统一的约定,函数B结束后它的栈帧被释放,回到函数A中继续执行。
到了调用函数C的时候,它只有一个参数和一个返回值,它们会占用函数A栈帧中最下面的一部分空间,所以上面会空出来一块,这是为了在被调用函数中可以用标准的相对地址定位到自己的参数和返回值,而无需顾虑其它。
同样的,call指令会压入返回地址,并跳转到函数C的指令入口处,所以下面就是函数C的栈帧了。
Go语言中,函数栈帧是一次性分配的,也就是在函数开始执行的时候分配足够大的栈帧空间。就像上例中函数A一样,它要调用两个函数,除了调用者栈基地址、局部变量以外,再有四个int64的空间用作被调用函数的参数与返回值就足够了。 一次性分配函数栈帧的主要原因是避免栈访问越界,如下图所示,三个goroutine初始分配的栈空间是一样的,如果g2剩余的栈空间不够执行接下来的函数,若函数栈帧是逐步扩张的,那么执行期间就可能发生栈访问越界。
其实,对于栈消耗较大的函数,go语言的编译器还会在函数头部插入检测代码,如果发现需要进行“栈增长”,就会另外分配一段足够大的栈空间,并把原来栈上的数据拷过来,原来的这段栈空间就被释放了。
函数跳转与返回
程序执行时 CPU用特定寄存器来存储运行时栈基与栈指针,同时也有指令指针寄存器用于存储下一条要执行的指令地址。
如果接下来要执行"push 3"这条指令,CPU读取后,会将指令指针移向下一条指令,然后栈指针向下移动,数字3入栈。
继续执行下一条指令,再次移动栈指针入栈数字4。
前面我们提过Go语言中函数栈帧不是这样逐步扩张的,而是一次性分配,也就是在分配栈帧时,直接将栈指针移动到所需最大栈空间的位置。
然后通过栈指针加上偏移值这种相对寻址方式使用函数栈帧。例如sp加16字节处存储3,加8字节处存储4,诸如此类。
接下来我们来看看call指令和ret指令,是怎样实现函数跳转与返回的。
func A(){
a,b := 1,2
B(a,b)
return
}
func B(c,d int){
println(c,d)
return
}
复制代码
调用函数B之前函数A栈帧如下图所示,注意函数A和函数B的指令分布在代码段,而且函数A调用函数B的call指令在地址a1处,函数B入口地址在b1处。
然后到call指令这里,它的作用有两点:
- 第一,把返回地址a2入栈保存起来;
- 第二,跳转到指令地址b1处。
call指令结束。函数B开始执行,我们先看它最开始的三条指令:
- 第一条指令,把SP向下移动24字节(从s6挪到s9),为自己分配足够大的栈帧;
- 第二条指令,要把调用者栈基s1存到SP+16字节的地方(s7那里);
- 第三条指令,把s7(SP+16)存入BP寄存器。
接下来就是执行函数B剩下的指令了,没有局部变量,只有被调用者的参数空间。在最后的ret指令之前,编译器还会插入两条指令:
- 第1条指令:恢复调用者A的栈基地址,它之前被存储在SP+16字节(s7)这里,所以BP恢复到s1;
- 第2条指令:释放自己的栈帧空间,分配时向下移动多少(从s6到s9)释放时就向上移动多少(从s9到s6)。
现在可以从a2这里继续执行了。简单来说,函数通过call指令实现跳转,而每个函数开始时会分配栈帧,结束前又释放自己的栈帧,ret指令又会把栈恢复到call之前的样子,通过这些指令的配合最终实现了函数跳转与返回。