【Go基础】函数调用栈

2,334 阅读8分钟

函数调用栈

我们按照编程语言的语法定义的函数,会被编译器编译为一堆堆机器指令,写入可执行文件。程序执行时可执行文件被加载到内存,这些机器指令对应到虚拟地址空间中,位于代码段。

如果在一个函数中调用另一个函数,编译器就会对应生成一条call指令,程序执行到这条指令时,就会跳转到被调用函数入口处开始执行,而每个函数的最后都有一条ret指令,负责在函数结束后跳回到调用处,继续执行。(如果学过微机原理,你可以懂得指令的含义)

函数栈帧

函数执行时候,需要有足够的内存空间,供他存放局部变量,参数等数据,这段空间对应到虚拟地址空间的栈。

分配给函数的栈空间被称为函数栈帧,Go 语言中函数栈帧布局是如下的,先是调用者栈基地址,然后是函数的局部变量,最后是被调用函数的返回值和参数。

image-20211123203550591

注: bp 栈基不一定存在,在有些情况下会被优化掉,也有可能是平台不支持。我们只关注局部变量,返回值的相对位置就好了。

举个例子:

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 的栈帧分布就由上至下,分别是局部变量 a1, a2, r1, r2 ,被调函数 B 的返回值 r2,r1 ,被调用函数 B 的参数 a2,a1

注意观察参数的顺序,返回值和参数都是先入栈的第二个,然后再入栈的第一个,相当于是从右至左逐一入栈的。

被调用函数是通过栈指针加上偏移量这样相对寻址的方式来定位自己的参数和返回值,刚好由下至上先找到第一个参数在找到第二个参数(通过增加偏移量的方式)。所以说参数和返回值采用由右至左的入栈顺序比较合适。

通常,我们认为返回值是通过寄存器传递的,但是 Go 语言支持多返回值,所以在栈上分配返回值空间更合适

所有的函数的栈帧布局都会遵循统一的约定。

对于函数 B 的调用会被编译器编译成 Call 指令。Call 指令只做两件事情。

  1. 将下一条指令的地址入栈,被调用函数结束后,跳回到该地址继续执行,这就是返回地址
  2. 跳转到被调用函数的指令入口处执行,所以返回地址下面就是函数 B 的栈帧。

其余的部分会按照 A 函数布局一样布局。

当函数 B 执行结束后会释放栈帧,然后就到 Ret 指令了,Ret 指令也会干两件事。

  1. 弹出Call 指令压栈的返回地址
  2. 跳转到返回地址

函数通过 Call 指令实现跳转,每个函数会分配栈帧,结束前就会释放自己的栈帧,Ret 指令就会将栈恢复到 Call 之前的样子。

那么函数 C 就是重复函数 B 的行为。

image-20211123205624277

在 Go 语言中,函数栈帧是一次性分配的,也就是在函数开始执行时候分配足够大的栈帧空间。

一次性分配函数栈帧的主要原因是避免栈访问越界。如下图所示,三个 goroutine 初始化分配的栈空间是一样的。如果 g2 剩余的栈空间不够执行接下来的函数,如果选择逐步扩张,那么执行期间就会发生栈访问越界的情况。

image-20211123210314416

其实对于栈消耗较大的函数,Go 语言编译器会在函数头部插入检测代码,如果发现需要栈增长,就会另外分配一个足够大的栈空间,将原来栈上的数据都拷过来,原来的空间就会被释放。

传参

我们在学习 C 语言时候相信也会遇到一个问题 Swap 为什么传参就无法交换变量,而交换指针时候却可以了。我会有很多解决,例如:实参和形参不在同一个地址,改变形参不会影响到实参。现在我们通过函数调用栈,来看看为什么会失败?传指针为什么会成功?

//值传递
func swap(a,b int) {
	a,b = b,a
}

func main() {
	a,b := 1,2
	swap(a,b)
	println(a,b)  //1,2
}

首先分析 main 函数的函数调用栈。

main 函数栈帧中,先分配局部变量存储空间 a=1,b=2。接下来时被调用函数的返回值,但是 Swap 函数并没有返回值,所以这里就没有,接下来时被调用函数传入的参数 b,a 。在 Go 语言中传参都是值拷贝,拷贝整型变量值。注意:参数入栈顺序!如下图所示。

image-20211124160403622

调用者栈帧(sp of main)后面就是 Call 指令存入的返回地址,接下来就是 Swap 函数开始执行。再下面分配的就是 Swap 函数栈帧。接下来就是常规的函数栈帧的分配问题。我们聚焦于代码。

Swap 函数执行这一段交换代码时候a,b = b,a ,要交换两个参数的值,他的参数通过相对寻址找到了,但是交换的是 main 函数栈帧里面的参数空间的 ab ,现在我们想要交换的是局部变量里面的 ab ,而参数空间与局部变量空间并没有关联,这就是他失败的原因。

//指针传递
func swap(a,b *int) {
	*a,*b = *b,*a
}

func main() {
	a,b := 1,2
	swap(&a,&b)
	println(a,b)  //2,1
}

main 函数栈帧中,先分配局部变量,然后在分配参数空间,这里因为参数是指针类型,注意:参数是 int 的指针类型,而不是 int 类型了。传参是值拷贝,所以这里会拷贝 ab 的地址。从右至左入栈。接下来就是返回地址以及 Swap 函数栈帧了。

image-20211124163255158

Swap 函数中这一段 *a,*b = *b,*a,交换的是 a 的地址与 b 的地址对应的值。那么回到 main 函数局部变量空间的 a 的地址对应的值就变成了 2

在这里他依旧是值的交换,只不过他交换的是 ab 对应地址的值,地址没有发生改变。

image-20211124163347572

返回值

通常,我们认为返回值是通过寄存器传递的,但是 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)
    println(a,b) //0,1
}

我们先进行分析,main 函数栈帧,先是局部变量 a,b 接着是被调用函数 incr 的返回值,然后是被调用函数的参数 a,以及返回地址与 incr 函数栈帧。

image-20211124170446035

进入到 incr 函数里面,会执行参数 a 的自增加一,然后是局部变量 b 的赋值。现在有一个问题:到底是先返回值还是先执行 defer 函数?

答案:先返回值!

image-20211124170542833

先会将 incr 函数局部变量中间的 b 的值经过值拷贝到 main 函数的返回值空间,然后再执行 defer 函数。defer 函数将 incr 函数的参数 a 和局部变量 b 进行自增。之后就 incr 结束。

image-20211124171454627

main 函数返回值空间的值会通过值拷贝赋值给局部变量中的 b。所以输出分别为 0 和 1。

image-20211124171504432

再看下一个例子,这里的局部变量b改成命名返回值,看看有什么不同。

func incr(a int) (b int) {
    defer func(){
        a++
        b++
    }()
    
    a++
    return a
}
func main(){
    var a,b int
    b = incr(a)
    println(a,b) //0,2
}

我们将注意力聚焦到 incr 函数中,我们之前说过会先进行返回值赋值然后去执行 defer 函数,所以这里会先将 参数 a 的值赋值到返回值空间中的 b 上,现在 b=1,开始执行 defer 里面的函数,参数 a 自增,返回值 b 也会自增加一。现在 incr 的返回值 b=2,那么此时 main 函数的返回值空间 b 也会是 2

我们先来理一下思路:

第一种我们所讲的就是匿名返回值,我们只定义了返回值的类型,没有去给他命名。defer 函数的后续操作都是在 incr 函数里面的局部变量和 main 函数的参数空间进行改变。好像并没有改变 main 的返回值空间的变量。

第二种我们所讲的是命名返回值,在执行 return a 的时候,相当于此时进行一个 b=a 的一段代码。所以在没有执行 defer 函数之前 b=1,此时的 main 返回值空间 b=1,我们可以看到 defer 语句改变了我们返回值空间的变量。

此时我们可以猜测,如果我们给被调用函数早就命名好了返回值,相当于我们当前函数的返回值空间的已经多了一个命名好的变量,如果没有 defer 函数,此时情况与正常使用无异,但是如果有,此时 defer 就会去改变此时调用者函数返回值空间的变量。此时被调用函数的命名返回值的地址与调用者函数返回值地址一致。

参考文章

【Golang】图解函数调用栈