函数的栈帧

随着函数的层层调用与返回,运行时栈上的函数栈帧也随之分配和释放。实际管理栈帧的是函数自身的代码,就是编译阶段由编译器生成的指令,所以也可以说函数栈帧是由编译器管理的。

栈帧构成

参照下面的函数栈帧布局示意图,从空间分配的角度来看,函数的栈帧包含以下几个部分:

  1. return address:函数的返回地址,占用一个指针大小的空间。实际上是在函数被调用时由CALL指令自动压栈的,并非由被调用函数分配;
  2. caller’s BP:调用者的栈帧基址,占用一个指针大小的空间,有些情况下会被优化掉。用来将调用路径上所有的栈帧连成一个链表,方便栈回溯之类的操作。函数通过将栈指针SP直接向下移动指定大小,来一次性分配caller’s BP、locals和args to callee所占用的空间,在x86架构上就是使用SUB指令将SP减去指定大小;
  3. locals:局部变量区间,占用若干机器字。用来存放函数的局部变量,根据函数的局部变量占用空间大小来分配,没有局部变量的函数不分配;
  4. args to callee:调用传参区域,占用若干机器字。分配空间大小,根据当前函数发起的所有的函数调用中,返回值加上参数所占用的空间最大的,按此来分配。没有调用任何函数时,不需要分配该区间。在callee视角的args from caller区间,包含在caller视角的args to callee区间内,占用空间大小是小于等于的关系。

Stack Frame Layout

综上所述,只有return address是一定会存在的,其他3个区间都要根据实际情况进行分析。

代码示例

下面就结合实际代码,具体说明函数栈帧各区间的分配和使用情况。编译运行如下示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

func main() {
var v1, v2 int
v3, v4 := f1(v1, v2)
println(&v1, &v2, &v3, &v4)
f2(v3)
}

func f1(a1, a2 int) (r1, r2 int) {
var l1, l2, l3 int
println(&r2, &r1, &a2, &a1, &l1, &l2, &l3)
return
}

func f2(a1 int) {
println(&a1)
}

在笔者使用的amd64+linux环境,得到的输出如下所示:

1
2
3
4
5
$ go build -gcflags='-l'
$ ./stack_frame
0xc000038750 0xc000038748 0xc000038740 0xc000038738 0xc000038720 0xc000038718 0xc000038710
0xc000038770 0xc000038768 0xc000038760 0xc000038758
0xc000038738

编译时通过指定参数来防止编译器将小函数内联优化掉,那样就不存在真正的栈帧结构了。

3行输出依次是由f1、main、f2中的println打印的,所以可以以此为参照,画出栈帧布局图。下面先分别进行梳理:

println

代码里之所以使用println,而没有使用fmt.Printf之类的函数,是因为前者更底层更“简单”,不会造成变量逃逸等问题,所以不会带来不必要的干扰。

实际上,代码中的println会被编译器转换为多次调用runtime包中的printlock、printunlock、printpointer、printsp、printnl函数,前两个函数用来进行并发同步,后3个用来打印指针、空格和换行,这5个函数均无返回值,只有printpointer有一个参数。

例如:

1
2
var a, b int
println(&a, &b)

会被转换为:

1
2
3
4
5
6
runtime.printlock()      // 获得锁
runtime.printpointer(&a) // 打印指针
runtime.printsp() // 打印空格
runtime.printpointer(&b) // 打印指针
runtime.printnl() // 打印换行
runtime.printunlock() // 释放锁

所以这一组函数调用只需要一个机器字的空间,用来向printpointer传参。

栈帧布局

根据以上的示例代码,以及编译运行的输出,对3个函数的栈帧上各区间大小进行整理:

caller’s BP locals args to callee 分配大小
main 1个指针大小 4个int大小:v1、v2、v3、v4 4个int大小:调用f1 0x48
f1 1个指针大小 3个int大小:l1、l2、l3 1个int大小:println 0x28
f2 1个指针大小 1个int大小:println 0x10

对照以上表格,绘制栈帧布局图:

Real Stack Layout

左侧是调用f1时的运行时栈,右侧是调用f2时的运行时栈。

通过f1的调用栈,可以发现函数的返回值和参数是按照先返回值后参数,并且是从右至左的顺序在栈上分配的,与C语言时期的参数入栈顺序一致。这是因为f1的参数和返回值占满了整个args to callee区间。

值得注意的是f2的调用栈,在a1和v4之间是空了3个机器字的,因为Go语言的函数是固定栈帧大小,args to callee是按照所需的最大空间来分配。调用函数时,参数和返回值看起来更像是按照先参数后返回值,从左到右的顺序分配在args to callee区间里,并且是从低地址开始使用。这点与我们对传统的栈的理解有些不同,更符合传统的栈原理的如32位的VC++编译器,它使用PUSH指令动态入栈,args to callee区间的大小不是固定的。Go这种固定栈帧大小的分配方式,使得像调试、运行时栈扫描之类更易于实现,但是会造成更大的栈消耗。

函数的参数和返回值的传递,属于“调用约定”的范畴,是compiler和linker在进行构建时内部遵循的一致性规范。可以参考C语言的调用约定,来进行类比学习。

在函数内部访问运行时栈上的返回值、参数和局部变量时,通过栈指针SP加上相对偏移来寻址。函数栈帧的结构是编译器生成的,就隐含在函数的代码里面,有兴趣的同学可以自己看一下反编译后的汇编代码,自会一目了然。