1264 字
4 分钟
Plan 9 汇编入门
你在排查一个 Go 服务的性能热点,pprof 显示某个函数耗时异常。点开汇编视图,满屏的 MOVQ、LEAQ、SUBQ 看得一头雾水。这不是 x86 的 movq,也不是 ARM 的 mov——而是 Go 使用的 Plan 9 风格汇编。理解 Plan 9 汇编,是深入 Go 运行时、读懂编译器输出、定位底层性能问题的前提。本文从零开始,带你逐步掌握这套汇编的语法规则和实战技巧。
一、什么是 Plan 9 汇编
1.1 Plan 9 汇编的由来
Plan 9 汇编起源于 Bell Labs 的 Plan 9 操作系统,由 Ken Thompson 设计。Go 语言继承了这套汇编风格,原因有三:
- 跨平台统一:一套语法适配所有架构(amd64、arm64、riscv 等)
- 简化编译器:汇编器不需要理解每种 CPU 的细节差异
- 配合 Go 工具链:与 Go 的链接器、调试器无缝集成
1.2 与 AT&T / Intel 汇编的对比
| 特性 | Plan 9 | AT&T | Intel |
|---|---|---|---|
| 操作数顺序 | 从左到右 | 源 → 目的 | 目的 ← 源 |
| 寄存器前缀 | 无 | % | 无 |
| 立即数前缀 | $ | $ | 无 |
| 内存操作 | offset(reg) | offset(%reg) | [reg + offset] |
| 指令后缀 | Q/L/W/B | q/l/w/b | 无(用 PTR) |
| 注释 | // 或 /* */ | # 或 /* */ | ; 或 /* */ |
二、基础语法
2.1 指令格式
Plan 9 汇编指令的基本格式:
[标签:] 操作码 [操作数1, 操作数2, ...] // 注释操作数从左到右:源操作数 → 目的操作数。这与 AT&T 一致,与 Intel 相反。
MOVQ $42, AX // 把立即数 42 存入 AX(AX = 42)ADDQ AX, BX // BX = BX + AX2.2 数据类型与后缀
| 后缀 | 大小 | C 类型 | Go 类型 |
|---|---|---|---|
B | 1 字节 | char | byte |
W | 2 字节 | short | uint16 |
L | 4 字节 | int | uint32 |
Q | 8 字节 | long long | int64 |
D | 8 字节 | double | float64 |
2.3 立即数
MOVQ $0, AX // 立即数 0MOVQ $0x1F, BX // 十六进制立即数MOVQ $main·result, CX // 符号地址LEAQ ·name(SB), DX // 相对 SB 的偏移2.4 寄存器
amd64 架构下的常用寄存器:
// 通用寄存器AX, BX, CX, DX // 通用SI, DI // 源/目标索引BP // 基址指针(帧指针)SP // 栈指针R8-R15 // 扩展寄存器
// 伪寄存器(Plan 9 特有)FP // Frame Pointer:引用函数参数和局部变量SP // Stack Pointer:引用栈上的局部变量PC // Program Counter:跳转指令中使用SB // Static Base:全局符号的基准地址Info
注意:伪寄存器 SP 和硬件寄存器 SP 是不同的。引用参数时用伪寄存器 FP,引用局部变量用伪寄存器 SP。直接写 SP 时汇编器会根据上下文判断是哪个。
三、函数定义与调用约定
3.1 函数定义
// Go 函数签名// func add(a, b int64) int64TEXT ·add(SB), NOSPLIT, $0-24 MOVQ a+0(FP), AX // 第一个参数 a MOVQ b+8(FP), BX // 第二个参数 b ADDQ BX, AX // AX = a + b MOVQ AX, ret+16(FP) // 返回值 RET解析 TEXT ·add(SB), NOSPLIT, $0-24:
TEXT:声明函数·add:函数名(中点分隔包名和函数名)(SB):相对静态基址NOSPLIT:不需要栈分裂检查$0:局部变量占用 0 字节-24:参数和返回值共 24 字节(2 个 int64 参数 + 1 个 int64 返回值 = 24)
3.2 参数与返回值访问
// func example(a int, b int, c float64) (int, float64)// 参数布局:a(8) + b(8) + c(8) = 24 字节// 返回值布局:int(8) + float64(8) = 16 字节// 栈帧大小:$0-40
TEXT ·example(SB), NOSPLIT, $0-40 MOVQ a+0(FP), AX // a MOVQ b+8(FP), BX // b MOVSD c+16(FP), X0 // c(浮点用 X0 寄存器) // ... 处理 ... MOVQ AX, ret+24(FP) // 第一个返回值 MOVSD X0, ret2+32(FP) // 第二个返回值 RET3.3 Go 调用约定
Go 1.17+ 使用基于寄存器的调用约定(Register-based ABI),旧版使用栈传递参数。规则如下:
| 参数类型 | 传递方式 | 寄存器 |
|---|---|---|
| 整数/指针 | 寄存器 | AX, BX, CX, DI, SI, R8-R15 |
| 浮点数 | XMM 寄存器 | X0-X14 |
| 结构体/接口 | 栈(大于 4 字段) | 按大小决定 |
| 字符串 | 两个寄存器 | 指针+长度 |
| 切片 | 三个寄存器 | 指针+长度+容量 |
四、常用指令
4.1 数据传送
MOVQ $10, AX // 立即数 → 寄存器MOVQ AX, BX // 寄存器 → 寄存器MOVQ AX, 8(SP) // 寄存器 → 栈MOVQ 8(SP), AX // 栈 → 寄存器LEAQ ·data(SB), AX // 取地址(不加载内容)4.2 算术运算
ADDQ AX, BX // BX += AXSUBQ $1, CX // CX -= 1IMULQ AX, BX // BX *= AXINCQ CX // CX++DECQ CX // CX--NEGQ AX // AX = -AX4.3 逻辑运算
ANDQ $0xFF, AX // AX &= 0xFFORQ BX, AX // AX |= BXXORQ AX, AX // AX = 0(常用清零技巧)SHLQ $4, AX // AX <<= 4SHRQ $8, BX // BX >>= 8(无符号)SARQ $1, CX // CX >>= 1(有符号/算术右移)4.4 比较与跳转
CMPQ AX, BX // 比较 AX 和 BXJEQ label // 相等跳转JNE label // 不等跳转JLT label // AX < BXJGT label // AX > BXJLE label // AX <= BX
// 无条件跳转JMP label
// 测试并跳转TESTQ AX, AX // 测试 AX 是否为 0JZ is_zero // 为 0 跳转JNZ not_zero // 不为 0 跳转4.5 栈操作
PUSHQ AX // AX 压栈POPQ AX // 栈顶弹出到 AX
// 等价于SUBQ $8, SPMOVQ AX, 0(SP)// 和MOVQ 0(SP), AXADDQ $8, SP五、控制流
5.1 条件分支
// if a > b { return a } else { return b }TEXT ·max(SB), NOSPLIT, $0-24 MOVQ a+0(FP), AX MOVQ b+8(FP), BX CMPQ AX, BX JGT a_greater MOVQ BX, ret+16(FP) RETa_greater: MOVQ AX, ret+16(FP) RET5.2 循环
// sum(1..n)TEXT ·sum(SB), NOSPLIT, $0-16 MOVQ n+0(FP), CX // n XORQ AX, AX // sum = 0loop: TESTQ CX, CX // CX == 0? JZ done // 是,退出 ADDQ CX, AX // sum += CX DECQ CX // CX-- JMP loopdone: MOVQ AX, ret+8(FP) RET5.3 函数调用
// 调用其他 Go 函数CALL ·otherFunc(SB) // 调用包内函数CALL runtime·morestack(SB) // 调用 runtime 函数
// 调用前需确保:// 1. SP 对齐到 16 字节// 2. 参数已放入约定位置// 3. 返回地址已压栈六、与 Go 互操作
6.1 Go 调用汇编函数
package mypkg
func add(a, b int64) int64 // 声明,无函数体
func main() { result := add(10, 20) // 直接调用 println(result)}#include "textflag.h"
TEXT ·add(SB), NOSPLIT, $0-24 MOVQ a+0(FP), AX MOVQ b+8(FP), BX ADDQ BX, AX MOVQ AX, ret+16(FP) RET6.2 汇编中调用 Go 函数
// 调用 runtime 函数CALL runtime·memmove(SB)
// 调用自定义函数CALL ·helper(SB)
// 需要手动处理栈帧TEXT ·caller(SB), NOSPLIT, $32-8 // 分配 32 字节栈帧 MOVQ $42, 0(SP) // 第一个参数 CALL ·helper(SB) // 调用 MOVQ 8(SP), AX // 获取返回值 MOVQ AX, ret+0(FP) RET6.3 访问 Go 全局变量
// Go 侧声明// var Count int64
// 汇编中访问MOVQ ·Count(SB), AX // 读取ADDQ $1, AXMOVQ AX, ·Count(SB) // 写入七、实战:常见模式
7.1 原子操作
// atomic addTEXT ·atomicAdd(SB), NOSPLIT, $0-16 MOVQ addr+0(FP), BX MOVQ delta+8(FP), AX LOCK XADDQ AX, 0(BX) // LOCK 前缀保证原子性 MOVQ AX, ret+0(FP) RET7.2 字符串操作
Go 字符串在内存中是 {ptr, len} 结构:
// 获取字符串长度TEXT ·strLen(SB), NOSPLIT, $0-16 MOVQ str+0(FP), AX // 字符串指针 MOVQ str+8(FP), BX // 字符串长度 MOVQ BX, ret+16(FP) RET7.3 切片操作
Go 切片在内存中是 {ptr, len, cap} 结构:
// 获取切片的第三个元素TEXT ·third(SB), NOSPLIT, $0-32 MOVQ slice+0(FP), AX // 底层数组指针 MOVQ slice+8(FP), BX // 长度 CMPQ BX, $3 // len < 3? JLT out_of_bounds MOVQ 16(AX), AX // 第三个元素(偏移 16 = 2 * 8) MOVQ AX, ret+24(FP) RETout_of_bounds: CALL runtime·panicIndex(SB) RET7.4 CAS 操作
// Compare And SwapTEXT ·cas(SB), NOSPLIT, $0-24 MOVQ addr+0(FP), BX MOVQ old+8(FP), AX MOVQ new+16(FP), CX LOCK CMPXCHGQ CX, 0(BX) // if *addr == AX, *addr = CX JNE failed MOVB $1, ret+24(FB) // 返回 true RETfailed: MOVB $0, ret+24(FB) // 返回 false RET八、调试技巧
8.1 查看编译器输出的汇编
# 查看函数的汇编输出go build -gcflags="-S" ./...
# 查看特定函数go tool compile -S -I xxx file.go
# 反编译二进制go tool objdump binarygo tool objdump -s "main\.myFunc" binary
# 导出汇编到文件go build -gcflags="-S" . 2> asm_output.txt8.2 常用调试标志
| 标志 | 用途 |
|---|---|
-gcflags="-S" | 输出汇编 |
-gcflags="-N" | 禁止优化 |
-gcflags="-l" | 禁止内联 |
-gcflags="-N -l" | 禁止所有优化 |
GOSSAFUNC=func go build | 生成 SSA 中间代码 HTML |
8.3 常见问题排查
| 问题 | 原因 | 解决 |
|---|---|---|
| 栈帧大小不对 | 参数或局部变量计算错误 | 仔细计算 $framesize-argsize |
| 寄存器冲突 | 调用函数时未保存 caller-saved 寄存器 | 使用 PUSH/POP 保存 |
| 对齐问题 | SP 未对齐到 16 字节 | 调用前检查 SP 对齐 |
| 栈分裂失败 | 缺少 NOSPLIT 或帧过大 | 去掉 NOSPLIT 或手动处理栈分裂 |
| 符号找不到 | 中点 · 写成普通点 . | 确保使用 Unicode 中点 U+00B7 |
九、与 Go Runtime 交互
9.1 栈分裂
当函数可能增长栈时,必须在入口处检查栈空间:
TEXT ·growable(SB), $0-8 // 编译器会自动插入栈分裂检查 // 等价于: // CMPQ SP, 16(g) // JHI ok // CALL runtime·morestack(SB) // JMP growableok: // 函数体 RETNOSPLIT 标志表示该函数不会增长栈,省略栈分裂检查。适用于:
- 栈帧很小且不调用其他函数的叶函数
- 运行时内部函数(避免递归调用 morestack)
9.2 g 结构体
Go 的 goroutine(协程) 结构体 g 常用字段:
// 通过 TLS 或 FS/GS 段寄存器获取当前 gget_tls(CX) // 将当前 g 的地址加载到 CXMOVQ g(CX), DX // DX = *g
// 常用字段偏移MOVQ g_stackguard(DX), AX // 栈保护MOVQ g_p(DX), BX // 关联的 P9.3 常用 runtime 函数
| 函数 | 用途 |
|---|---|
runtime·morestack | 栈增长 |
runtime·memmove | 内存拷贝 |
runtime·memequal | 内存比较 |
runtime·gcWriteBarrier | GC 写屏障 |
runtime·panicIndex | 越界 panic |
runtime·goexit | goroutine 退出 |
十、跨架构编写
10.1 条件编译
// 文件命名约定add_amd64.s // 仅 amd64add_arm64.s // 仅 arm64add_riscv64.s // 仅 riscv64
// 代码中使用架构常量#ifdef GOARCH_amd64 MOVQ $1, AX#endif#ifdef GOARCH_arm64 MOVD $1, R0#endif10.2 架构差异速查
| 特性 | amd64 | arm64 | riscv64 |
|---|---|---|---|
| 通用寄存器 | AX-BX, R8-R15 | R0-R30 | X0-X31 |
| 栈指针 | SP | RSP | SP |
| 指令后缀 | Q/L/W/B | D/S/W/H/B | 无 |
| 立即数 | $1 | $1 | 1 |
| 函数调用 | CALL | BL | CALL |
| 函数返回 | RET | RET | RET |
参考资料
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时
相关文章 智能推荐
1
x86 汇编语言入门
语言 x86 汇编语言入门——寄存器、指令集、内存寻址与调用约定
2
PowerShell 实用指南
语言 PowerShell 实用指南——从管道对象到远程管理的自动化实践
3
Markdown 规范详解
语言 Markdown 规范详解——CommonMark 与 GFM 规范的差异与一致性
4
Python 进阶指南
语言 Python 进阶指南——装饰器、生成器、上下文管理器、类型注解与性能优化
5
综合实战:构建一个迷你容器运行时
容器运行时 综合实战——用 Go 从零构建一个迷你容器运行时——实现 Namespace 隔离(PID/Mount/UTS/IPC/Network)、Cgroup 资源限制(CPU/内存)、OverlayFS 分层文件系统、OCI Bundle 解析,最终实现一个能运行容器的 minirunc。将前 15 章的知识融会贯通,从「理解原理」到「动手实现」。






