绑定完请刷新页面
取消
刷新

分享好友

×
取消 复制
Go 语言函数的栈帧
2021-07-29 15:25:48


【导读】什么是函数栈帧?这个特征是否是开发者在编程中需要关注的点?本文做了详细介绍。

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

栈帧构成

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

  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 个区间都要根据实际情况进行分析。

代码示例

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

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 环境,得到的输出如下所示:

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

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

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

println

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

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

例如:

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

会被转换为:

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 加上相对偏移来寻址。函数栈帧的结构是编译器生成的,就隐含在函数的代码里面,有兴趣的同学可以自己看一下反编译后的汇编代码,自会一目了然。


转自:fengyoulin

fengyoulin.com/func-stack-frame.html



分享好友

分享这个小栈给你的朋友们,一起进步吧。

Go语言技术精选专区
创建时间:2020-07-08 10:28:08
Go(又称 Golang)是 Google 的 Robert Griesemer,Rob Pike 及 Ken Thompson 开发的一种静态强类型、编译型语言。Go 语言语法与 C 相近,但功能上有:内存安全,GC(垃圾回收),结构形态及 CSP-style 并发计算。
展开
订阅须知

• 所有用户可根据关注领域订阅专区或所有专区

• 付费订阅:虚拟交易,一经交易不退款;若特殊情况,可3日内客服咨询

• 专区发布评论属默认订阅所评论专区(除付费小栈外)

技术专家

查看更多
  • dapan
    专家
戳我,来吐槽~