8.1 plan9 汇编读写与寄存器 ABI
7.3 的结论里,Go 汇编是「不引入 C 工具链、又能拿到 SIMD」的那条路。但它的门槛不在「写汇编」本身,而在Go 的汇编不是你以为的那个汇编。
Go 用的是一套基于 Plan 9 的汇编语法,和 AT&T、Intel 都不一样:操作数顺序是「源, 目标」(像 AT&T),但寄存器名、伪寄存器、寻址写法又是另一套。更关键的是,Go 在 1.17 之后引入了寄存器 ABI,但用户包的汇编与运行时自己的汇编在这一点上规则不同——这是最容易踩的坑。
本节要回答的问题是:Go 汇编长什么样、怎么读、寄存器 ABI 到底怎么工作。结论先行:Go 汇编用 Plan 9 语法,
FP/SB/SP是伪寄存器;用户包里的汇编函数默认是 ABI0(栈传参),而 ABIInternal(寄存器传参)的选择器<>只允许在 runtime 包内使用(实测报错 “ABI selector only permitted when compiling runtime”);当 ABI0 函数被当作值使用时,编译器会生成一个 ABI wrapper,实测能同时看到main.F与main.F.abi0两个符号。
8.1.1 先分清:这是谁的汇编
在写第一行之前,先把三个「不是」记住:
- 不是 AT&T 汇编。虽然操作数顺序类似,但
TEXT、FP、SB这些概念只属于 Go。 - 不是给汇编器直接执行的机器码。Go 的
.s文件先被go tool asm汇编成目标文件,再由链接器与编译器产物合并。 - 不是可移植的。一套
.s只对应一个 GOARCH,add_arm64.s里的指令在 amd64 上无法编译。
工具链自带三个必用的工具,先确认版本:
$ GOTOOLCHAIN=go1.27.0 go tool asm -V
asm version go1.27.0
go tool asm 是汇编器,go tool objdump 反汇编,go tool nm 看符号表。它们随工具链自带,不需要额外安装。
8.1.2 第一个函数:Add
目标:写一个 func Add(a, b int64) int64,用汇编实现。arm64 版本:
#include "textflag.h"
// func Add(a, b int64) int64
TEXT ·Add(SB), NOSPLIT, $0-24
MOVD a+0(FP), R0
MOVD b+8(FP), R1
ADD R0, R1, R0
MOVD R0, ret+16(FP)
RET
逐行拆解:
TEXT ·Add(SB), NOSPLIT, $0-24:定义一个函数符号。·Add里的·是「当前包」前缀,实际符号是main.Add。NOSPLIT表示不插入栈增长检查。$0-24是「帧大小-参数大小」:帧大小 0(不使用局部栈空间),参数+返回值共 24 字节(两个int64参数 16 字节 + 一个int64返回 8 字节)。a+0(FP)、b+8(FP):FP是伪寄存器,代表「当前函数的参数区基址」。a+0(FP)读第一个参数,b+8(FP)读第二个。ret+16(FP):返回值在参数区之后。RET:返回。
Go 侧的声明:
package main
import "fmt"
//go:noescape
func Add(a, b int64) int64
func main() {
fmt.Println("Add(20,22) =", Add(20, 22))
}
//go:noescape 告诉编译器「这个函数的参数不会逃逸到堆上」,是纯汇编函数的常规标注(编译器无法自己分析 .s 文件)。运行:
$ GOTOOLCHAIN=go1.27.0 go run .
Add(20,22) = 42
8.1.3 用 objdump 读懂它
源码写完了,真正要练的是反汇编——因为写汇编时你经常需要确认「我写的到底变成了什么」。用 go tool objdump:
$ GOTOOLCHAIN=go1.27.0 go build -o addbin .
$ GOTOOLCHAIN=go1.27.0 go tool objdump -s 'main\.Add' addbin
TEXT main.Add.abi0(SB) /tmp/asm/add_arm64.s
add_arm64.s:5 0x10009b5d0 f94007e0 MOVD 8(RSP), R0
add_arm64.s:6 0x10009b5d4 f9400be1 MOVD 16(RSP), R1
add_arm64.s:7 0x10009b5d8 8b000020 ADD R0, R1, R0
add_arm64.s:8 0x10009b5dc f9000fe0 MOVD R0, 24(RSP)
add_arm64.s:9 0x10009b5e0 d65f03c0 RET
注意两点:
- 符号名是
main.Add.abi0,不是main.Add。这是 ABI 归属的证据,见 8.1.5。 FP伪寄存器在反汇编里变成了RSP偏移。a+0(FP)对应MOVD 8(RSP), R0——伪寄存器在汇编阶段被翻译成了真实的栈寻址。
再用 go tool nm 看符号表:
$ GOTOOLCHAIN=go1.27.0 go tool nm addbin | grep -iE "main\.Add"
10009b5d0 T main.Add.abi0
只有 main.Add.abi0,没有 main.Add。调用点也直接指向它:
$ GOTOOLCHAIN=go1.27.0 go tool objdump -s 'main\.main' addbin | grep CALL
add.go:9 0x10009b554 9400001f CALL main.Add.abi0(SB)
8.1.4 寄存器 ABI:ABI0 与 ABIInternal
Go 1.17 引入了寄存器 ABI:函数参数和返回值优先走寄存器,而不是全部压栈,以提升调用性能。于是存在两套 ABI:
| ABI | 传参方式 | 谁在用 |
|---|---|---|
| ABI0 | 全部走栈(FP) | 用户手写汇编的默认 |
| ABIInternal | 优先走寄存器 | 编译器生成的代码 |
你手写的 TEXT ·Add(SB) 默认被汇编成 ABI0。而 Go 编译器生成的代码用的是 ABIInternal。两者需要桥接——这就是 ABI wrapper 的由来。
8.1.5 实测:ABIInternal 选择器是 runtime 专属
自然的问题是:能不能让手写汇编也用 ABIInternal?语法上有个 <> 选择器:
TEXT ·AddReg<ABIInternal>(SB), NOSPLIT, $0-0
ADD R0, R1, R0
RET
实测结果——被汇编器拒绝:
$ GOTOOLCHAIN=go1.27.0 go build .
./add_arm64.s:4: ABI selector only permitted when compiling runtime, reference was to "main.AddReg"
asm: assembly of ./add_arm64.s failed
报错很直白:「ABI selector only permitted when compiling runtime」。也就是说,用户包无法直接定义 ABIInternal 函数,只有 Go 运行时自己的汇编可以。这是一个反直觉但非常重要的边界——你在网上看到的很多「用寄存器 ABI 写汇编」的例子,其实是运行时源码里的写法,照搬到自己的包里会直接编译失败。
顺带说明://go:registerparams 这个指令也帮不上忙。实测在 Go 侧声明上加它,但汇编体仍按 ABI0 汇编,调用时参数不会进 R0/R1——实测返回 0(错误值),证明它没有把符号变成 ABIInternal。在用户包里,老老实实按 ABI0(栈传参)写。
8.1.6 实测:ABI wrapper 何时出现
既然用户汇编是 ABI0,而编译器用 ABIInternal,那桥接发生在哪?关键触发点是**「函数被当作值使用」**。把函数赋给一个变量:
//go:noescape
func AddReg(a, b int64) int64
var f = AddReg // 强制生成 ABI wrapper
func main() {
fmt.Println("AddReg(20,22) =", AddReg(20, 22), "via value =", f(1, 2))
}
实测符号表:
$ GOTOOLCHAIN=go1.27.0 go tool nm addreg2 | grep -iE "main\.AddReg"
10009b640 T main.AddReg
10009b620 T main.AddReg.abi0
现在两个符号都在:main.AddReg.abi0(你写的)和 main.AddReg(编译器自动生成的 wrapper)。反汇编 wrapper:
$ GOTOOLCHAIN=go1.27.0 go tool objdump -s '^main\.AddReg$' addreg2
TEXT main.AddReg(SB) <autogenerated>
<autogenerated>:1 0x10009b64c a90087e0 STP (R0, R1), 8(RSP)
<autogenerated>:1 0x10009b650 97fffff4 CALL main.AddReg.abi0(SB)
<autogenerated>:1 0x10009b654 f9400fe0 MOVD 24(RSP), R0
wrapper 做的事一目了然:STP (R0, R1), 8(RSP) 把寄存器里的参数存回栈,调用 ABI0 版本,再把结果 MOVD 24(RSP), R0 装回寄存器。这就是两套 ABI 之间的搬运成本——只在需要桥接时产生,静态直接调用时编译器可以直接走 ABI0,省掉这一层。
8.1.7 平台差异与构建标签
同一段逻辑,arm64 与 amd64 的汇编完全不同:
| 项目 | arm64 | amd64 |
|---|---|---|
| 指令后缀 | 无(MOVD/ADD) | 有宽度(MOVQ/ADDQ) |
| 参数读取 | MOVD a+0(FP), R0 | MOVQ a+0(FP), AX |
| 帧大小语法 | 相同 $0-24 | 相同 $0-24 |
所以需要为每个平台各写一个 .s,用文件后缀区分(add_arm64.s / add_amd64.s),Go 的构建系统会按 GOARCH 自动选择。本节同时准备了两个文件,在当前 arm64 机器上只编译 add_arm64.s。
8.1.8 速查表
| 伪寄存器 | 含义 |
|---|---|
SB | 静态基址(static base),用于引用全局符号 |
FP | 帧指针(frame pointer),指向参数区 |
SP | 栈指针;$0-24 里的 $0 是帧大小 |
·name | 当前包的符号 |
name+0(FP) | 参数区第 0 字节处的 name |
| 工具 | 用途 | 示例 |
|---|---|---|
go tool asm | 汇编 .s | go tool asm -V |
go tool objdump | 反汇编可执行文件 | go tool objdump -s 'main\.Add' |
go tool nm | 查看符号表 | go tool nm addbin |
plan9 汇编的语法与 ABI 规则是读懂 Go 性能底层的前提。下一节我们用它做一件实事:把一个真实的热点函数从 Go 改写成汇编,看能拿到多少加速比——以及一个「写了汇编反而更慢」的真实教训。
8.1.9 NOSPLIT 与栈增长
TEXT 行里的 NOSPLIT 值得单独说,因为它关系到安全。
Go 的 goroutine 栈是可增长的:函数入口会检查「剩余栈空间够不够」,不够就触发栈复制(morestack)。这个检查本身有成本。NOSPLIT 是告诉汇编器「这个函数不会增长栈,跳过检查」——省掉开销,但前提是函数确实不需要额外栈空间。
| 标记 | 含义 | 何时用 |
|---|---|---|
NOSPLIT | 跳过栈增长检查 | 叶子函数、只用 $0 帧、不调用其他函数 |
省略 NOSPLIT | 保留栈增长检查 | 有局部帧、或会调用其他 Go 函数 |
NOSPLIT 的函数不能做三件事:申请过大的局部帧、调用其他可能增长栈的函数、递归。违反了会导致栈溢出而无法扩容,程序直接崩。本节的 Add 是纯叶子、帧大小 $0,所以 NOSPLIT 是安全的。
8.1.10 常见错误速查
| 报错 / 现象 | 原因 | 修法 |
|---|---|---|
ABI selector only permitted when compiling runtime | 在用户包用了 <ABIInternal> | 用户包只能写 ABI0 |
missing function body | 汇编里没有对应符号,或文件名后缀不对 | 检查 _arm64.s 与 GOARCH |
| 返回值恒为 0 | 用了 //go:registerparams 但汇编仍按 ABI0 写 | 改成栈传参 name+0(FP) |
argument size mismatch | $0-24 里的 24 与实际参数不符 | 参数+返回值字节数要算对 |
| 栈溢出崩溃 | NOSPLIT 却申请了大帧 | 去掉 NOSPLIT 或缩小帧 |
//go:noescape 编译错误 | 标注在了非汇编函数上 | 只给汇编实现的声明加 |
8.1.11 一个小练习:读标准库的汇编
学习 plan9 汇编最快的方式,是读标准库自带的 .s 文件。它们就在 Go 的安装目录下:
$ ls /usr/local/go/src/runtime/*.s | head
$ ls /usr/local/go/src/internal/bytealg/*arm64.s
比如 internal/bytealg 里的 IndexByte、Equal 都是手写汇编——它们是 Go 标准库里最典型的热点汇编函数。用本节学到的 TEXT、FP、NOSPLIT 去读它们,再对照 8.2 的性能数据,你就能判断「标准库为什么在这里选择汇编」。
阅读导航:上一节:7.3 cgo 替代方案与性能权衡 · 下一节:8.2 手写热点函数实测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。