Go 1.17 起内部调用约定从「栈传递」换成「寄存器传递」,但大多数人对它的认识停留在「参数进寄存器」这句话。这句话在 arm64 上到底意味着哪几个寄存器、超过几个参数会溢出到栈、浮点参数走哪一路、运行时汇编函数怎么和 Go 代码交换数据——这些才是读 runtime/*.s 时会卡住的地方。本节用 objdump 把这几件事逐个看清楚。
本节要回答:Go 的寄存器 ABI 在 arm64 上的具体约定是什么,运行时汇编函数(
memmove、morestack、写屏障)以什么约定被调用?与卷三《Go 语言高级编程》第 8 章的分工是:卷三 8.1/8.2 讲「怎么写 Go 汇编」(语法、TEXT声明、参数命名),站内 Go 专题(/posts/golang/)也有汇编入门与手写实战文章;本节只写运行时函数的 ABI 约定——即已有汇编函数与 Go 侧如何握手,不重复汇编语法教程。结论先给:arm64 上有 16 个整数参数寄存器 R0–R15 和 16 个浮点参数寄存器 F0–F15,第 17 个整数参数开始溢出到栈;copy/clear分别落到runtime.memmove/runtime.memclrNoHeapPointers。
9.3.1 实验:寄存器 ABI 长什么样
复现基线
- Go 版本:
go1.27.0(GOTOOLCHAIN=go1.27.0) - 机器:Apple M1 Pro(arm64),
hw.ncpu=10,内存 32 GiB - 未开
-race;汇编用go tool objdump(随工具链自带) - 基准参数:
-benchtime=200000x,-count=5,取区间
被测的三个函数:sum8(8 个 int 参数)、sum20(20 个 int 参数)、mixIntFloat(int 与 float64 混排)。都标了 //go:noinline 保证有独立函数体:
//go:noinline
func sum8(a, b, c, d, e, f, g, h int) int {
return a + b + c + d + e + f + g + h
}
//go:noinline
func sum20(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t int) int {
return a + b + c + d + e + f + g + h + i + j + k + l + m + n + o + p + q + r + s + t
}
//go:noinline
func mixIntFloat(a int, x float64, b int, y float64) float64 {
return float64(a+b) + x + y
}
GOTOOLCHAIN=go1.27.0 go tool objdump -s "main.sum8" prog
GOTOOLCHAIN=go1.27.0 go tool objdump -s "main.sum20" prog
GOTOOLCHAIN=go1.27.0 go tool objdump -s "main.mixIntFloat" prog
sum8:8 个参数全在 R0–R7,结果写回 R0:
TEXT main.sum8(SB) /tmp/gbrt4/e93/main.go
main.go:7 0x10009b530 8b000021 ADD R0, R1, R1
main.go:7 0x10009b534 8b010041 ADD R1, R2, R1
main.go:7 0x10009b538 8b010061 ADD R1, R3, R1
main.go:7 0x10009b53c 8b010081 ADD R1, R4, R1
main.go:7 0x10009b540 8b0100a1 ADD R1, R5, R1
main.go:7 0x10009b544 8b0100c1 ADD R1, R6, R1
main.go:7 0x10009b548 8b0100e0 ADD R1, R7, R0
main.go:7 0x10009b54c d65f03c0 RET
sum20:前 16 个参数在 R0–R15,第 17 个开始从栈上取(MOVD 8(RSP) 起,每 8 字节一个):
TEXT main.sum20(SB) /tmp/gbrt4/e93/main.go
main.go:12 0x10009b550 8b000021 ADD R0, R1, R1
...
main.go:12 0x10009b588 8b0101e1 ADD R1, R15, R1
main.go:12 0x10009b58c f94007e2 MOVD 8(RSP), R2
main.go:12 0x10009b590 8b010041 ADD R1, R2, R1
main.go:12 0x10009b594 f9400be2 MOVD 16(RSP), R2
main.go:12 0x10009b598 8b010041 ADD R1, R2, R1
main.go:12 0x10009b59c f9400fe2 MOVD 24(RSP), R2
main.go:12 0x10009b5a0 8b010041 ADD R1, R2, R1
main.go:12 0x10009b5a4 f94013e2 MOVD 32(RSP), R2
main.go:12 0x10009b5a8 8b010040 ADD R1, R2, R0
main.go:12 0x10009b5ac d65f03c0 RET
mixIntFloat:int 参数走 R0/R1,float64 参数走 F0/F1,两条通道互不占用:
TEXT main.mixIntFloat(SB) /tmp/gbrt4/e93/main.go
main.go:17 0x10009b5b0 8b010000 ADD R1, R0, R0
main.go:17 0x10009b5b4 9e620002 SCVTFD R0, F2
main.go:17 0x10009b5b8 1e602842 FADDD F0, F2, F2
main.go:17 0x10009b5bc 1e622820 FADDD F2, F1, F0
main.go:17 0x10009b5c0 d65f03c0 RET
调用点同样能印证:main 里调用 mixIntFloat(1, 1.5, 2, 2.5) 时,常量先分别装进寄存器再调用:
main.go:23 0x10009b6d4 b24003e0 ORR $1, ZR, R0
main.go:23 0x10009b6d8 1e6f1000 FMOVD $1.5, F0
main.go:23 0x10009b6dc b27f03e1 ORR $2, ZR, R1
main.go:23 0x10009b6e0 1e609001 FMOVD $2.5, F1
main.go:23 0x10009b6e4 97ffffb3 CALL main.mixIntFloat(SB)
编译器会自动插哪些运行时调用
写一段 copy / clear / 越界访问,看编译器把它们编译成了哪个运行时函数:
//go:noinline
func copyBlock(n int) { copy(dst[:n], src[:n]) }
//go:noinline
func clearBlock(n int) { clear(dst[:n]) }
GOTOOLCHAIN=go1.27.0 go tool objdump -s "main.copyBlock" prog | grep CALL
GOTOOLCHAIN=go1.27.0 go tool objdump -s "main.clearBlock" prog | grep CALL
mem.go:9 0x10009b858 97ff6c1e CALL runtime.memmove(SB)
mem.go:9 0x10009b868 97ff6b96 CALL runtime.panicBounds(SB)
mem.go:9 0x10009b87c 97ff634d CALL runtime.morestack_noctxt.abi0(SB)
mem.go:12 0x10009b8cc 97ff6b95 CALL runtime.memclrNoHeapPointers(SB)
mem.go:12 0x10009b8dc 97ff6b79 CALL runtime.panicBounds(SB)
mem.go:12 0x10009b8ec 97ff6331 CALL runtime.morestack_noctxt.abi0(SB)
四类自动插入的调用:copy→runtime.memmove、clear→runtime.memclrNoHeapPointers、越界→runtime.panicBounds、栈增长检查→runtime.morestack_noctxt.abi0(注意 .abi0 后缀,后面解释)。
memmove 的吞吐与「arm64 没有 duffcopy」
runtime.memmove 是手写汇编,实测它的吞吐(dst、src 都是 1 MiB 的 []byte):
GOTOOLCHAIN=go1.27.0 go test -bench='Copy|Clear' -benchmem -count=5 -benchtime=200000x ./...
BenchmarkCopy64-10 200000 1.901 ns/op 0 B/op 0 allocs/op
BenchmarkCopy4K-10 200000 56.24 ns/op 0 B/op 0 allocs/op
BenchmarkCopy1M-10 200000 14834 ns/op 0 B/op 0 allocs/op
BenchmarkClear4K-10 200000 68.51 ns/op 0 B/op 0 allocs/op
| 操作 | ns/op 区间 | 折算带宽(近似) |
|---|---|---|
copy 64 B | 1.90–1.95 | ~33 GB/s |
copy 4 KiB | 56–78 | ~50–73 GB/s |
copy 1 MiB | 14.8–17.6 µs | ~60–71 GB/s |
clear 4 KiB | 68–88 | ~46–60 GB/s |
再看二进制里有哪些符号:
GOTOOLCHAIN=go1.27.0 go tool nm prog | grep -i duff
(无输出)
duffcopy/duffzero 在 arm64 二进制里不存在。这是有意的:src/runtime/ 下有 duff_386.s、duff_arm.s、duff_mips64x.s、duff_ppc64x.s、duff_s390x.s,没有 duff_arm64.s。Duff’s device 是为那些缺乏高效块复制指令的架构准备的;arm64 和 amd64 直接用 memmove 里的软件流水线循环(src/runtime/memmove_arm64.s 注释写着「Large copies use a software pipelined loop processing 64 bytes per iteration」)。
9.3.2 源码:ABI 约定在哪里定义
寄存器数量与 RegArgs
src/internal/abi/abi_arm64.go 定义 arm64 的参数寄存器数量:
const (
// R0 - R15.
IntArgRegs = 16
// F0 - F15.
FloatArgRegs = 16
EffectiveFloatRegSize = 8
)
对照 src/internal/abi/abi_amd64.go:IntArgRegs = 9(RAX、RBX、RCX、RDI、RSI、R8–R11)、FloatArgRegs = 15。同一条源码在不同架构上参数寄存器数量不同,所以讨论「第几个参数溢出到栈」时必须带架构。
Go 侧与汇编侧交换寄存器参数的结构体是 src/internal/abi/abi.go:RegArgs:
type RegArgs struct {
Ints [IntArgRegs]uintptr // untyped integer registers
Floats [FloatArgRegs]uint64 // untyped float registers
// Fields above this point are known to assembly.
Ptrs [IntArgRegs]unsafe.Pointer
ReturnIsPtr IntArgRegBitmap
}
注释「Fields above this point are known to assembly」是关键:汇编代码只依赖 Ints/Floats 的布局,Ptrs 是给 GC 看的(把寄存器里的指针也变成 GC 可见的 unsafe.Pointer)。9.2 里 reflect.makeFuncStub 用 spillArgs/unspillArgs 往返的就是这个结构体。
morestack:栈增长检查的汇编入口
每个函数的序言里都有一段栈检查,失败就跳到 src/runtime/asm_arm64.s:437 的 morestack:
TEXT runtime·morestack(SB),NOSPLIT|NOFRAME,$0-0
// Cannot grow scheduler stack (m->g0).
MOVD g_m(g), R8
MOVD m_g0(R8), R4
// Called from f.
// Set g->sched to context in f
MOVD RSP, R0
MOVD R0, (g_sched+gobuf_sp)(g)
MOVD R29, (g_sched+gobuf_bp)(g)
MOVD LR, (g_sched+gobuf_pc)(g)
MOVD R3, (g_sched+gobuf_lr)(g)
MOVD R26, (g_sched+gobuf_ctxt)(g)
...
// Call newstack on m->g0's stack.
MOVD m_g0(R8), g
...
BL runtime·newstack(SB)
它把当前 goroutine 的 sched(sp/bp/pc/lr/ctxt)存进 g,切到 g0 栈,再 BL runtime·newstack。gobuf_ctxt 存的是 R26——寄存器 ABI 下闭包上下文通过 R26 传递,这是「栈 ABI → 寄存器 ABI」迁移留下的固定寄存器约定。
写屏障:按指针数量展开的宏
src/runtime/asm_arm64.s:1120 起是一组按「一次写几个指针」展开的写屏障入口:
TEXT runtime·gcWriteBarrier1<ABIInternal>(SB),NOSPLIT,$0
MOVD $8, R25
JMP gcWriteBarrier<>(SB)
TEXT runtime·gcWriteBarrier2<ABIInternal>(SB),NOSPLIT,$0
MOVD $16, R25
JMP gcWriteBarrier<>(SB)
...
编译器在需要写屏障的赋值点插入 gcWriteBarrierN(N 为本次写入的指针数),由它跳到公共实现 gcWriteBarrier<>,后者把待写指针记入 wbBuf,缓冲区满时 CALL runtime·wbBufFlush(SB)。写屏障在汇编层是「批量展开」的——一次写多个指针只进一次屏障。
.abi0 后缀:栈 ABI 的桥
go tool nm 里有 176 个以 .abi0 结尾的符号(如 runtime.morestack_noctxt.abi0、reflect.callMethod.abi0)。这是「栈传递参数(ABI0)」版本的入口:Go 内部代码用寄存器 ABI(ABIInternal),但汇编和一些运行时边界需要栈 ABI,链接器会为同一个函数生成两个入口,.abi0 就是栈版本。所以看到序言里 CALL runtime.morestack_noctxt.abi0(SB) 是正常的——栈检查走的就是栈 ABI 入口。
9.3.3 决策:什么时候需要关心 ABI
ABI 对绝大多数 Go 代码是透明的,只在下面这些场景才需要主动关心:
| 场景 | 该知道什么 |
|---|---|
写 //go:noescape 或手写汇编 | 参数按 IntArgRegs 个数分寄存器/栈;arm64 是 16 个整数、16 个浮点 |
读 runtime/*.s 时看到 R26 | 那是闭包上下文(gobuf_ctxt),不是普通参数 |
看到 .abi0 符号 | 栈 ABI 版本入口,通常由链接器生成,别手动去改 |
追 copy/clear 的实现 | 分别是 runtime.memmove / runtime.memclrNoHeapPointers,arm64 上不用 duff |
| 关注大块内存拷贝的吞吐 | 4 KiB 约 50–73 GB/s、1 MiB 约 60–71 GB/s(本机),瓶颈在内存带宽而非指令 |
| 跨架构调优 | 参数寄存器数不同(amd64 9 个 vs arm64 16 个),「第 10 个参数是否溢出」的答案不同 |
三条检查项:
- 别把 ABI 当成性能旋钮。 参数进寄存器还是栈,是编译器决定的;代码层面能影响的是「别让值逃逸」(那归第 4 章),不是「让参数进寄存器」。
- 手写汇编只在该函数的调用约定明确时才安全。 声明成
ABIInternal的函数用寄存器传参;声明成默认(ABI0)的用栈传参;混用会让参数错位。卷三 8.1/8.2 有完整写法,这里只强调「先确认约定再动手」。 copy/clear的批量语义要利用起来。 单字节循环比copy慢一个数量级,而copy会直接落到优化的memmove;clear(s)会落到memclrNoHeapPointers,比逐元素清零快且能被优化掉。
阅读导航:上一节:9.2 反射与 unsafe 的运行时成本 · 下一节:10.1 一个服务的端到端调优 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。