《Go 语言运行时原理》9.3 汇编 ABI 与运行时函数

用 objdump 看清 Go 的寄存器 ABI:参数怎么落进 R0 到 R15、超过十六个怎么溢出到栈、浮点参数为什么走 F 寄存器;再定位运行时关键函数的汇编入口,如 memmove、memclrNoHeapPointers、morestack 与 gcWriteBarrier,并给出 ABI 相关的决策清单。

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 B1.90–1.95~33 GB/s
copy 4 KiB56–78~50–73 GB/s
copy 1 MiB14.8–17.6 µs~60–71 GB/s
clear 4 KiB68–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 个参数是否溢出」的答案不同

三条检查项:

  1. 别把 ABI 当成性能旋钮。 参数进寄存器还是栈,是编译器决定的;代码层面能影响的是「别让值逃逸」(那归第 4 章),不是「让参数进寄存器」。
  2. 手写汇编只在该函数的调用约定明确时才安全。 声明成 ABIInternal 的函数用寄存器传参;声明成默认(ABI0)的用栈传参;混用会让参数错位。卷三 8.1/8.2 有完整写法,这里只强调「先确认约定再动手」。
  3. copy/clear 的批量语义要利用起来。 单字节循环比 copy 慢一个数量级,而 copy 会直接落到优化的 memmove;clear(s) 会落到 memclrNoHeapPointers,比逐元素清零快且能被优化掉。

阅读导航:上一节:9.2 反射与 unsafe 的运行时成本 · 下一节:10.1 一个服务的端到端调优 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练