实验环境:

go version go1.9 darwin/amd64

为了弄清楚 Go 语言函数调用栈的关系,以下面这段代码为例,执行 go tool compile -S test.go

package main
//go:noinline
func add2(a int) int {
    var tmp int = 10
    b := a + tmp
    return b
}
//go:noinline
func add1(a int) int {
    var tmp int = 10
    b := a + tmp
    return add2(b)
}
func main() {
    a := 1
    add1(a)
}

下面展示的汇编代码中,省略了栈扩容判断,和 FUNCDATA PCDATA 相关的指令。

先看 main 函数的汇编代码:

"".main STEXT size=59 args=0x0 locals=0x18
	SUBQ	$24, SP
	MOVQ	BP, 16(SP)
	LEAQ	16(SP), BP
	MOVQ	$1, (SP)
	CALL	"".add1(SB)
SUBQ	$24, SP

给 main 函数分配栈桢,大小为 24 字节。

MOVQ	BP, 16(SP)
LEAQ	16(SP), BP

注意这里的 BP,是一个 architecture-specific register,在 AMD64 上是 %rbp 寄存器。这两句的作用是,记录调用链中上一个函数的栈底下 8 字节的位置。这样做主要是为了在程序 panic 后,能够 unwind 整个 goroutine 的调用链。

0x0021 00033 (test.go:20)	MOVQ	$1, (SP)
0x0029 00041 (test.go:20)	CALL	"".add1(SB)

为 add1 函数准备参数和进行函数调用。注意,与 C 汇编中的 call 指令一样,CALL 也会将当前函数中的下一条指令压栈。此时,当前 goroutine 的栈结构大致是下面这样:

     +-->  +-----------+  <--+ SP(old)
     |     |caller's BP|
     |     +-----------+  <--+ BP
     |     |           |
main |     +-----------+
     |     |    1      |
     +-->  +-----------+  <--+ SP(old)
           | ret addr  |
           +-----------+  <--+ SP(new)

下面看 add1 函数的汇编代码:

"".add1 STEXT size=74 args=0x10 locals=0x18
	SUBQ	$24, SP
	MOVQ	BP, 16(SP)
	LEAQ	16(SP), BP
	MOVQ	"".a+32(SP), AX
	ADDQ	$10, AX
	MOVQ	AX, (SP)
	CALL	"".add2(SB)

分析流程和前面类似,其中:

MOVQ	"".a+32(SP)

通过 SP 寄存器,取到了 main 函数里为 add1 提供的 argument。

下面是执行到 CALL 后的栈结构:

     +-->  +-----------+  <--+ SP(old)
     |     |caller's BP|
     |     +-----------+  <--+ BP(old)
     |     |           |
main |     +-----------+
     |     |    1      |
     +-->  +-----------+  <--+ SP(old)
           | ret addr  |
     +-->  +-----------+  <--+ SP(old)
     |     |caller's BP|
     |     +-----------+  <--+ BP(new)
add1 |     |           |
     |     +-----------+
     |     |    11     |
     +-->  +-----------+  <--+ SP(old)
           | ret addr  |
           +-----------+  <--+ SP(new)

最后是 add2 的汇编代码:

"".add2 STEXT nosplit size=15 args=0x10 locals=0x0
    TEXT	"".add2(SB), NOSPLIT, $0-16
    MOVQ	"".a+8(SP), AX
    ADDQ	$10, AX
    MOVQ	AX, "".~r1+16(SP)
    RET

与前面不同的是,这个函数没有栈桢。在执行 RET 前,栈结构是这样的:

     +-->  +-----------+  <--+ SP(old)
     |     |caller's BP|
     |     +-----------+  <--+ BP(old)
     |     |           |
main |     +-----------+
     |     |    1      |
     +-->  +-----------+  <--+ SP(old)
           | ret addr  |
     +-->  +-----------+  <--+ SP(old)
     |     |caller's BP|
     |     +-----------+  <--+ BP(new)
add1 |     |    21     |
     |     +-----------+
     |     |    11     |
     +-->  +-----------+  <--+ SP(old)
           | ret addr  |
           +-----------+  <--+ SP(new)

这里将 add2 的返回值存放在 add1 的栈桢中,位于 argument 之上。执行 RET 后,栈结构变化如下:

     +-->  +-----------+  <--+ SP(old)
     |     |caller's BP|
     |     +-----------+  <--+ BP(old)
     |     |           |
main |     +-----------+
     |     |    1      |
     +-->  +-----------+  <--+ SP(old)
           | ret addr  |
     +-->  +-----------+  <--+ SP(old)
     |     |caller's BP|
     |     +-----------+  <--+ BP(new)
add1 |     |    21     |
     |     +-----------+
     |     |    11     |
     +-->  +-----------+  <--+ SP(new)

回到 add1 函数,执行:

MOVQ	8(SP), AX
MOVQ	AX, "".~r1+40(SP)
MOVQ	16(SP), BP
ADDQ	$24, SP
RET

首先将返回值拷贝到 main 函数到栈桢中。然后执行 MOVQ 16(SP), BP 恢复 main 函数的 BP 寄存器,执行 ADDQ $24, SP 销毁 add1 函数的栈桢。最后执行 RET:

     +-->  +-----------+  <--+ SP(old)
     |     |caller's BP|
     |     +-----------+  <--+ BP(new)
main |     |    21     |
     |     +-----------+
     |     |    1      |
     +-->  +-----------+  <--+ SP(new)

回到 main 函数:

MOVQ	16(SP), BP
ADDQ	$24, SP
RET

销毁 main 函数的栈桢。回到 main 的 caller。

综上,整个过程和 C 的栈结构差不多。也有一些不同的地方:

需要注意的是,Go 提供了 SP 伪寄存器,官方解释如下:

The SP pseudo-register is a virtual stack pointer used to refer to frame-local variables and the arguments being prepared for function calls. It points to the top of the local stack frame, so references should use negative offsets in the range [−framesize, 0): x-8(SP), y-4(SP), and so on.

可是例子中出现的都是类似 xx+xx(SP) 的情况。通过不断的查阅资料,发现在 i386/AMD64 平台下,Go 的汇编器并没有使用 SP 这个伪寄存器,SP 指的就是物理寄存器 %esp,这个寄存器永远指向栈顶。

参考资料: