2.4 参数传递与栈帧布局
2.3 给出了两套 ABI 的分工与参数分配的递归算法,那是「规则」。本节把规则落到 一个具体例子上:一个同时含标量与数组的签名,参数究竟在栈帧里怎样摆放,寄存器参数为何还要在 栈上留一格溢出槽,序言里那条几乎人人都付的栈增长检查又是怎么搭上抢占的便车。最后我们回到 「为何自定义 ABI」这个总账,把这两节合成一个完整的回答。
2.4.1 一个掺杂栈传与寄存器传的例子
抽象规则不如一个例子来得清楚。取 ABI 规范里那个精心设计的签名,它故意同时含有能进寄存器的 标量与必须走栈的数组:
| |
按 2.3.1 的递归分配,在 amd64(整数寄存器 RAX, RBX, ...)上结果是:
a1、a3是能进寄存器的标量,分到RAX、RBX;a2是非平凡数组,整个退回栈上;- 返回值
r1含数组,也走栈;r2是string,拆成 base 与 len 两部分,回填RAX、RBX(返回时寄存器重新从头计数,与入参不冲突)。
于是入口时只有 a2 在栈上有初值,栈帧布局如下,其余区域调用时一律不初始化:
+------------------------------+
| a3Spill uint8 | 寄存器参数的溢出槽
| a1Spill uint8 | (调用方预留,调用时空着)
+------------------------------+
| r1.y [2]uintptr | 栈传返回值
| r1.x uintptr |
+------------------------------+
| a2 [2]uintptr | 栈传参数(唯一带初值者)
+------------------------------+ ↓ 低地址
这个例子把三件事一次说清:标量优先进寄存器,含数组的值整体走栈,而每个寄存器参数仍在栈上
留有一格溢出槽待命。把它和 2.3.1 那条「全栈传」的 ABI0 对照,差别一目了然:ABI0
下 a1、a3 连同 a2 全在栈上按序排开,没有寄存器、也无需溢出槽;ABIInternal 把能搬的都搬进了
寄存器,省下的正是那几次进出栈内存的读写。
2.4.2 栈帧、溢出槽与寄存器约定
一次调用压入一个栈帧,自高地址向低地址,依次容纳:调用方为被调用方预留的栈传参数区与栈传 返回值区、被调用方的局部变量、需要保存的寄存器、以及返回地址。amd64 上以低地址在下的惯例画出, 一个含寄存器传参的调用,其栈帧大致是这样:
+------------------------------+
| 寄存器参数的溢出槽(spill) | ← 调用方预留,调用时不填
+------------------------------+
| 栈传返回值 |
+------------------------------+
| 栈传参数 |
+------------------------------+ ↓ 低地址
这里的溢出槽(spill space)是寄存器 ABI 特有的设计,也是一处精巧的取舍。寄存器传参省下了
栈读写,但寄存器会被后续指令覆写,一旦函数中途需要扩栈(见下一节),就得有地方把这些寄存器
实参先「溢」出来暂存。关键在于:这块暂存空间由调用方在自己的栈帧里预留,而非被调用方。原因
是被调用方扩栈时,它自己的栈帧可能根本腾不出空间,让调用方提前备好,扩栈路径才有落脚点。这块
槽位同时充当实参的「家」,traceback 打印参数、reflect 调用归约参数都借它,是一处一举多用的安排。
寄存器还分两类约定。amd64 上 R14 恒定指向当前 goroutine(即 g),RSP/RBP 是栈指针与
帧指针,RDX 在调用闭包时传递闭包上下文。值得点出的是:Go 的 ABIInternal 没有调用方保存/
被调用方保存(callee-save)的寄存器之分,一次调用可以覆写任何没有固定含义的寄存器,包括参数
寄存器。这简化了实现,代价是调用前后调用方若还要用某个寄存器里的值,得自己负责保存。
2.4.3 序言里的栈增长检查,与抢占搭的便车
Go 的调用规范还嵌进了一件别家 ABI 少有的事:栈增长检查。Go 的 goroutine 栈很小(初始 2KB),
按需增长(14 执行栈),这要求几乎每个函数在干正事之前,先确认当前栈
空间还够用。于是编译器在几乎每个函数的序言(prologue)里都插入一小段检查:把栈指针 SP 与
g.stackguard0 比一比,不够就跳去 morestack 扩栈,扩完再从头执行本函数。下面是 go tool objdump
对一个普通函数序言的实拍(arm64 上 R28 即 g,16(R28) 是 g.stackguard0,与 amd64 同构):
| |
注意尾巴里那个 .abi0 后缀,morestack 是用手写汇编实现的,走 ABI0,正是 2.3.1
所说的边界。
序言代码并非千篇一律,编译器按栈帧大小分三档优化(StackSmall = 128、StackBig = 4096 字节):
帧 $\le$ StackSmall 的函数,栈底预留的余量足以容纳它,于是只需一条 CMP guard, SP 加一次跳转,
省掉一条减法指令;帧介于二者之间的,要先算出 SP - framesize 再比;帧 $\ge$ StackBig 的索性
不比,无条件调 morestack。这种「为小帧函数省一条指令」的斤斤计较,正因为这段检查会出现在
几乎每一个函数里,省下的常数乘以调用频次便相当可观。
真正巧妙的是,这段为栈增长而生的检查,被 Go 的协作式抢占搭了便车(9.7)。
运行时想让某个 goroutine 让出 CPU 时,并不需要另设一套打断机制,只消把它的 stackguard0 改写成
一个永远比任何合法 SP 都大的哨兵值 stackPreempt:
| |
这样一来,该 goroutine 下一次进入任意函数序言、做那条 CMP guard, SP 时,检查必然「失败」,
跳进 morestack。morestack 发现 stackguard0 是 stackPreempt 而非真的栈不足,便顺势把控制权
交还调度器,完成一次让出,让出后再把 stackguard0 复位为 stack.lo + stackGuard。一个看似纯粹的
ABI 细节(序言里那条栈检查),同时服务了栈增长与协作式抢占两件事。这是 Go 把多种机制叠在
同一个低成本检查点上的典型手法:检查点你反正每次调用都要付,那就让它一票多用。当然,这条路
只在函数调用处生效,对不含调用的紧凑循环无能为力,那是异步抢占要补的另一半,9.7
专门展开。
2.4.4 为何自定义 ABI
Go 没有沿用所在平台的标准 ABI(如 amd64 上的 System V AMD64 ABI),而是从头定义了自己的两套 ABI。这是一个有明确代价、也有明确收益的选择。
放进谱系里看会更清楚。多数原生编译型语言(C、C++、Rust)直接复用平台 ABI,于是它们之间、以及
与操作系统接口之间能零成本互调,代价是被平台约定锁死,难以为自己的运行时定制。带托管运行时的
语言则普遍另起炉灶:JVM、CLR 都有自己的内部调用约定,与平台 ABI 只在 JNI、P/Invoke 这类受控
边界处对接。Go 选的是后一条路,且走得更彻底,它连汇编器(2.1)都是自有的,整条
工具链对调用规范握有完全的话语权。System V ABI 用 rdi, rsi, rdx, rcx, r8, r9 传整型参数,Go 却
另选了 RAX, RBX, RCX, ... 这一串,并把 R14 钉死为当前 goroutine,这些都是平台 ABI 给不了的
自由。
代价是与外部目标文件不能直接互通。调用一段 C 代码(cgo),就要跨越 ABI 边界:Go 的寄存器约定、
栈布局、g 指针约定与 C 世界全不一样,每次过界都要切换栈、保存/恢复一批寄存器、调整调用约定,
这是实打实的开销,也是 cgo「调用一次不便宜」的根源之一(15 编译器)。
收益是对调用规范的完全掌控。最有说服力的证据,正是 2.3.1 那次从栈到寄存器的 切换:ABI0 演进到 ABIInternal,给整个生态带来约 5% 的提速,而用户代码一行未改。这件事之所以能 透明地发生,恰恰因为这套 ABI 是 Go 自己的,不必兼容任何外部约定,运行时与编译器同属一个 项目、同步演进,改 ABI 不会惊动谁。若 Go 当初绑定了平台 System V ABI,这类优化要么做不成, 要么会破坏与外部世界的二进制兼容。
这与 2.1 维护一套自有的 Plan 9 汇编器、6.1 在调用约定上 反复打磨,是同一种哲学的不同侧面:Go 宁可牺牲与外部世界的无缝互通,也要保住对自身实现的主权。 正是这份主权,让它能在不惊动用户代码的前提下,一次又一次地优化运行时的底层。性能的提升从不 白来,这一次,它换走的是与 C 世界免费互通的便利。
延伸阅读的文献
- The Go Authors. Go internal ABI specification (ABIInternal). https://github.com/golang/go/blob/master/src/cmd/compile/abi-internal (溢出槽、栈帧布局、各体系结构寄存器映射)
- The Go Authors. runtime/stack.go、preempt.go、internal/abi/stack.go.
https://github.com/golang/go/tree/master/src/runtime
(序言栈检查、
stackPreempt哨兵、StackSmall/StackBig帧分档) - 本书 2.1 Plan 9 汇编语言、2.3 调用约定与寄存器 ABI、 6.1 函数调用、 14 执行栈管理、 9.7 协作与抢占、 15 编译器。