一文了解Golang的函数调用栈

327 阅读3分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

函数调用栈

  • 编写的函数代码会被编译器编译为机器指令写入可执行文件

  • 程序执行时,可执行文件被加载到内存,机器指令对应到虚拟地址空间中,位于代码段

  • 如果在函数中调用另一个函数,编译器会生成一个CALL指令,每一个函数调用结束后会有一个RET用于调回到调用处

  • 运行时用的内存栈上面的高地址,分配给函数的叫函数栈帧,栈底称为栈基BP,栈顶叫栈指针SP

内存布局

先是调用者的栈基地址,再到局部变量,再到调用函数的返回值,最后是参数,最后是返回地址,跳转到被调用函数的入口处执行。后面就是被调用的函数栈帧

go语言中函数栈帧是一次性分配的

在分配栈帧时直接将栈指针移动到所需最大栈空间的位置

然后根据栈指针sp+偏移offset的相对寻址方式使用函数栈帧

函数栈帧的大小在编译期间可以确定,但是如果栈空间是逐步扩张的,可能会发生栈访问越界

对于栈消耗较大的函数,go编译器会在函数头部插入一段检测代码,如果检测到越界,就会新申请另一端更大的栈空间,并将原来栈帧的数据copy到那上面,并free释放之前的栈空间的内存。

调用CALL指令实现跳转

  • 把入栈返回地址保存起来
  • 跳转到指令地址处(函数调用入口)

在执行完RET指令之前,编译器会插入两行代码

  • 恢复调用者栈基(bp)
  • 释放自己的栈帧空间,分配时向下移动多少,释放时就向上移动多少(sp)

在执行这两行代码时,会先执行给返回值赋值,然后执行defer调用函数

调用RET指令

  • 弹出返回地址
  • 跳转到返回地址

参数入栈顺序从右至左,先入栈第二个参数,再入栈第一个参数。返回值也一样,这样sp+偏移指示更方便

go支持多返回值,在栈上分配返回值空间

分析

func incr(a int) int {
    var b int
    
    defer func(){
        a++
        b++
    }()
    
    a++
    b = a
    return b
}
func main(){
    var a, b int
    b = incr(a)
    fmt.Println(a, b)	// 0, 1
}

调用栈帧分布如下,在执行defer之前会先返回返回值b,从b处拷贝到return value,再执行defer函数调用,a、b++。但是此时是局部变量自增,不影响到返回值。再执行RET跳转到返回地址。此时a=0,b=1

这种情况是基于返回值是匿名的情况

如果返回值不是匿名的,那么在执行defer函数的时候可能会修改return value

调用的不同函数,返回值和参数占用空间不同

func A(){
    r1, r2 := B(p1, p2, p3)
    r1 = C(p1)
}

A一次性分配

B占用的栈空间较多,B结束后会释放调锁占用的栈空间

这时轮到调用C了,参数和返回值只会占用下面的一部分空间,因为这样方便sp+offset寻址

参考

幼麟实验室

go语言笔试面试宝典