Go 语言原本 Go 语言原本
English

2.4 参数传递与栈帧布局

2.3 中的参数改由寄存器传递,但寄存器中的值并不持久,下一次函数调用就可能将其覆盖;寄存器也没有内存地址,无法对其取地址。因此即使在 ABIInternal 下,每次调用仍须在栈上保留一块区域。本节先说明这块区域中存放的内容,再分析几乎每个函数开头都有的栈空间检查,以及调度器如何借助这项检查暂停一个 goroutine,最后讨论 Go 为何不采用 C 的调用约定。

2.4.1 栈上保留的区域

以 Go ABI 规范中的示例为例 [Go Authors, 2026a]:

1
func F(a1 uint8, a2 [2]uintptr, a3 uint8) (r1 struct{ x uintptr; y [2]uintptr }, r2 string)

按照 2.3 的规则,a1 与 a3 分别放入 AX 与 BX;a2 是数组,经由栈传递;返回值 r1 含有数组,同样位于栈上;r2 分解为指针和长度两个分量,返回时放在 AX 与 BX 中。调用方在栈上准备的区域自低地址向高地址依次为:

偏移内容调用时的状态
0a2(16 字节)已由调用方写入
16r1(24 字节)未初始化,由 F 在返回前写入
40为 a1 预留的 1 字节未初始化
41为 a3 预留的 1 字节未初始化
42对齐填充(6 字节)

共 48 字节。编译器的 -S 输出中,F 的第一行是 main.F STEXT nosplit size=53 args=0x30 ...,其中 0x30 即 48。

表中最后几行值得注意:参数虽然经由寄存器传递,调用方仍在栈上为每个寄存器参数预留了位置,但并不写入。这块区域称为参数的溢出区(spill space),用于以下几种情况:

  • F 需要扩栈时(2.4.2),必须先把寄存器中的参数保存到内存。此时 F 自身的栈已接近耗尽,未必能再为这些参数腾出空间,而调用方预留的区域早已就绪。
  • F 对参数取地址(如 &a1)时,参数需要一个固定的内存位置。
  • 参数因其他原因需要溢出到内存时,也存放在这里。运行时打印调用栈时从这里读取参数的值;若参数在此后未被写回,打印出的值可能已经过时,运行时会在其后标注 ?。

其代价是栈空间的占用有所增加。据 ABI 规范估计,这部分空间的浪费中位数为每次调用 16 字节;规范把完全去掉预留的溢出区列为长期目标 [Go Authors, 2026a]。

调用之后哪些寄存器的值保持不变,也是调用约定的一部分。C 的调用约定规定了一组被调方保存寄存器(callee-saved register),被调函数若使用它们,必须在返回前恢复原值。ABIInternal 没有这类寄存器:除下表所列具有固定含义的寄存器外,任何寄存器都可能在调用中被改写,调用方若在调用之后仍需要某个值,须自行将其保存到栈上。ABI 规范指出,这一设计显著简化了垃圾回收器与编译器的寄存器分配器,代价是损失一部分性能 [Go Authors, 2026a]。对垃圾回收器而言,扫描栈时无须追查某个指针被哪一层函数暂存在了哪个寄存器中。amd64 上具有固定含义的寄存器如下:

寄存器含义
SP栈指针
BP帧指针,调试器与性能剖析工具借助它回溯调用链
R14指向当前 goroutine
X15在调用与返回时恒为零

此外,调用闭包时 DX 传递闭包的上下文指针,但这一含义仅在调用发生的时刻成立。

2.4.2 函数开头的栈检查

goroutine 的栈起初很小,不足时再更换为更大的栈。新建 goroutine 的栈从 2 KiB 开始;自 Go 1.19 起,运行时还在每轮垃圾回收中统计各 goroutine 栈的平均使用量,据此调整从空闲链表复用的 goroutine 的栈大小 [Go Authors, 2022](14.2.5)。因此,几乎每个函数在开头都要先确认剩余的栈空间是否足够。下面的递归函数 Deep 有一个参数 n,局部变量中有一个 16 字节的数组:

1
2
3
4
5
6
7
8
9
//go:noinline
func Deep(n int) int {
	var buf [2]int
	buf[n&1] = n
	if n == 0 {
		return buf[0]
	}
	return Deep(n-1) + buf[1]
}

编译到 linux/amd64 后,函数的开头与结尾如下(省略了中间部分):

TEXT	main.Deep(SB), ABIInternal, $32-8
CMPQ	SP, 16(R14)                    // 与当前 goroutine 的警戒线比较
JLS	69                             // 不高于警戒线:跳转到末尾扩栈
PUSHQ	BP                             // 检查通过:保存 BP,为局部变量分配空间
MOVQ	SP, BP
SUBQ	$24, SP
……
MOVQ	AX, 8(SP)                      // 偏移 69:把 AX 中的参数 n 存入调用方预留的溢出区
CALL	runtime.morestack_noctxt(SB)   // 更换为更大的栈
MOVQ	8(SP), AX                      // 取回 n
JMP	0                              // 回到开头重新检查

32 字节的帧由三部分组成:保存 BP 的 8 字节、数组 buf 的 16 字节,以及调用 Deep(n-1) 时为其参数预留的 8 字节溢出区。

函数开头的这几条准备指令称为函数序言(prologue)。R14 指向当前 goroutine 的 g 结构体,其偏移 16 处存放该 goroutine 的警戒线(stack guard,即字段 stackguard0)。一旦 SP 不高于警戒线,函数便跳转到末尾:先把寄存器中的参数保存到 2.4.1 所述的溢出区,再调用 morestack(2.2)更换栈,随后取回参数,回到开头重新检查。

警戒线并不位于栈的最底端,而是位于其上方 928 字节处(linux/amd64)。其中 800 字节供不做检查的 NOSPLIT 函数链使用,链接器会核对这类调用链不超出此限;另外 128 字节使栈帧不超过 128 字节的函数可以直接用 SP 与警戒线比较,不必先减去自身栈帧的大小 [Go Authors, 2026b]。栈帧更大的函数需要多做一步计算;栈帧超过 4096 字节时,SP 减去帧大小还可能发生回绕,因此还要检查借位。下面两个函数的局部变量分别是 1 KiB 与 8 KiB 的数组:

TEXT	main.Medium(SB), ABIInternal, $1040-0
LEAQ	-912(SP), R12                  // 912 = 1040 - 128
CMPQ	R12, 16(R14)

TEXT	main.Huge(SB), ABIInternal, $8208-0
MOVQ	SP, R12
SUBQ	$8080, R12                     // 8080 = 8208 - 128
JCS	63                             // 发生借位(SP 小于 8080):直接转去扩栈
CMPQ	R12, 16(R14)

还有一类函数完全省去了检查。2.3 开头 Add 的输出中标有 NOSPLIT,而源码并未提出这一要求。在 amd64 上,若一个函数的栈帧小于 128 字节,且除少数不带参数的运行时函数(如报告下标越界的 runtime.panicBounds)外不调用任何函数,它使用的栈就不会超出警戒线以下预留的余量,cmd/internal/obj 便自动将其标记为 NOSPLIT,省去检查 [Go Authors, 2026b]。

扩栈时,运行时分配一块大小为原来两倍的新栈,把旧栈的内容整体复制过去,再调整栈上指向栈内的指针(14.3)。下面的程序在一个新 goroutine 中逐步加深递归,并观察一个局部变量的地址:栈被移动时,该地址随之改变。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
	"fmt"
	"unsafe"
)

//go:noinline
func down(n int) {
	if n > 0 {
		down(n - 1)
	}
}

func main() {
	done := make(chan bool)
	go func() { // 新 goroutine 的栈从 2 KiB 开始
		var x int // 栈被移动时,x 的地址随之改变
		old, last := uintptr(unsafe.Pointer(&x)), 0
		for depth := 1; depth <= 1000; depth++ {
			down(depth)
			if now := uintptr(unsafe.Pointer(&x)); now != old {
				fmt.Printf("递归 %3d 层时栈被移动,比上次多 %3d 层\n", depth, depth-last)
				old, last = now, depth
			}
		}
		done <- true
	}()
	<-done
}

在 darwin/arm64 上的输出为:

递归  29 层时栈被移动,比上次多  29 层
递归  93 层时栈被移动,比上次多  64 层
递归 221 层时栈被移动,比上次多 128 层
递归 477 层时栈被移动,比上次多 256 层
递归 989 层时栈被移动,比上次多 512 层

相邻两次移动之间增加的递归层数依次翻倍:64、128、256、512。在这台机器上,down 每层占用 32 字节,64 层恰好是 2 KiB。栈共移动 5 次,依次增长为 4 KiB、8 KiB、16 KiB、32 KiB 与 64 KiB;每次新增的空间等于旧栈的大小,正好对应下一段多出的层数。在网页上运行时,程序被编译为 WebAssembly,每层只占 16 字节,输出变为 58、186、442、954 层,间隔同样是 128、256、512 层,逐次翻倍。由于栈的大小每次翻倍,历次复制的字节数之和不会超过最终栈的大小,扩栈的总开销与栈的最终大小成正比。

2.4.3 抢占借用同一项检查

一个 goroutine 连续运行超过 10 毫秒时,系统监控(9.8)会请求它让出处理器,使其他 goroutine 也有机会运行。最直接的做法是借用上述栈检查:把该 goroutine 的警戒线改为一个大于任何栈地址的值。它下一次调用函数时,检查必然失败,于是进入 morestack。运行时发现警戒线是这一特殊值,便知道并非栈空间不足,因此不扩栈,而是如同调用 runtime.Gosched 一样让出处理器;待它再次被调度执行时,警戒线恢复原值 [Go Authors, 2026c]。

1
2
3
4
5
// runtime/proc.go,preemptone(节选,注释为译文)
gp.preempt = true
// 每次函数调用都会比较当前的栈指针与 gp.stackguard0,以检查栈是否溢出。
// 将其设为 stackPreempt,便把抢占并入了常规的栈溢出检查。
gp.stackguard0 = stackPreempt // 在 64 位平台上为 0xfffffffffffffade

这种做法无须在代码中增加任何指令,局限也很明显:它只在函数调用时生效。不调用任何函数的循环永远不会执行这项检查,在 Go 1.14 之前,这样的循环可以一直占用处理器。Go 1.14 引入了基于操作系统信号的异步抢占 [Clements, 2019],preemptone 在设置警戒线之后紧接着发出这一请求,详见 9.7。

2.4.4 为何不采用 C 的调用约定

Linux 上的 C 与 C++ 程序遵循同一套平台调用约定,可以直接相互调用。Go 的寄存器传参提案(2.3)明确选择不采用平台约定 [Clements, 2020],理由有两方面。

其一,共用约定的收益有限。直观上看,与 C 共用约定可以降低调用 C 函数的开销;但 goroutine 的栈会动态伸缩,调度也由 Go 运行时而非操作系统完成,调用非 Go 代码时,无论约定是否相同,都必须切换到固定的栈并通知调度器。调试器与性能剖析工具普遍依赖 DWARF 调试信息或帧指针回溯,Go 不采用平台约定,也能与之配合。

其二,C 的约定并不适合 Go。x86-64、arm64 与 RISC-V 的 ELF ABI 都不用寄存器传递超过两个字的聚合类型,占三个字的切片因此只能经由内存传递;这些约定通常最多为返回值保留两个寄存器,而 Go 函数常常返回一个结果加一个占两个字的 error,共三个字。平台约定中的被调方保存寄存器会给垃圾回收器带来额外负担(2.4.1)。此外,各平台的约定在细节上各不相同,自行定义约定,就可以在所有体系结构上沿用同一种结构 [Clements, 2020]。

自行定义的约定还提供了平台约定所没有的便利:R14 始终指向当前 goroutine,函数序言用一条指令即可读到警戒线;X15 恒为零;多个返回值都可以放在寄存器中。更重要的是,ABIInternal 由编译器、运行时与汇编器在同一个代码库中共同维护,可以随版本演进:Go 1.17 把参数从栈移到寄存器,用户代码无须任何修改;将来去掉 2.4.1 中预留的溢出区,也是如此。

代价体现在与 C 交互时。经由 cgo 调用 C 函数,需要从 goroutine 的栈切换到线程的 g0 栈(C 代码不做栈检查,其栈上的指针也无法在栈移动时得到调整),按 C 的约定重新安排参数、保存和恢复寄存器,还要通知调度器该线程暂时脱离 Go 运行时的管理。因此,一次 cgo 调用的开销远大于一次普通的 Go 函数调用,详见 15.6。

延伸阅读