mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1264 字
4 分钟
Plan 9 汇编入门
2020-06-15

你在排查一个 Go 服务的性能热点,pprof 显示某个函数耗时异常。点开汇编视图,满屏的 MOVQLEAQSUBQ 看得一头雾水。这不是 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 9AT&TIntel
操作数顺序从左到右源 → 目的目的 ← 源
寄存器前缀%
立即数前缀$$
内存操作offset(reg)offset(%reg)[reg + offset]
指令后缀Q/L/W/Bq/l/w/b无(用 PTR
注释///* */#/* */;/* */

二、基础语法#

2.1 指令格式#

Plan 9 汇编指令的基本格式:

[标签:] 操作码 [操作数1, 操作数2, ...] // 注释

操作数从左到右:源操作数 → 目的操作数。这与 AT&T 一致,与 Intel 相反。

MOVQ $42, AX // 把立即数 42 存入 AXAX = 42
ADDQ AX, BX // BX = BX + AX

2.2 数据类型与后缀#

后缀大小C 类型Go 类型
B1 字节charbyte
W2 字节shortuint16
L4 字节intuint32
Q8 字节long longint64
D8 字节doublefloat64

2.3 立即数#

MOVQ $0, AX // 立即数 0
MOVQ $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) int64
TEXT ·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) // 第二个返回值
RET

3.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 += AX
SUBQ $1, CX // CX -= 1
IMULQ AX, BX // BX *= AX
INCQ CX // CX++
DECQ CX // CX--
NEGQ AX // AX = -AX

4.3 逻辑运算#

ANDQ $0xFF, AX // AX &= 0xFF
ORQ BX, AX // AX |= BX
XORQ AX, AX // AX = 0(常用清零技巧)
SHLQ $4, AX // AX <<= 4
SHRQ $8, BX // BX >>= 8(无符号)
SARQ $1, CX // CX >>= 1(有符号/算术右移)

4.4 比较与跳转#

CMPQ AX, BX // 比较 AXBX
JEQ label // 相等跳转
JNE label // 不等跳转
JLT label // AX < BX
JGT label // AX > BX
JLE label // AX <= BX
// 无条件跳转
JMP label
// 测试并跳转
TESTQ AX, AX // 测试 AX 是否为 0
JZ is_zero // 为 0 跳转
JNZ not_zero // 不为 0 跳转

4.5 栈操作#

PUSHQ AX // AX 压栈
POPQ AX // 栈顶弹出到 AX
// 等价于
SUBQ $8, SP
MOVQ AX, 0(SP)
// 和
MOVQ 0(SP), AX
ADDQ $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)
RET
a_greater:
MOVQ AX, ret+16(FP)
RET

5.2 循环#

// sum(1..n)
TEXT ·sum(SB), NOSPLIT, $0-16
MOVQ n+0(FP), CX // n
XORQ AX, AX // sum = 0
loop:
TESTQ CX, CX // CX == 0?
JZ done // 是,退出
ADDQ CX, AX // sum += CX
DECQ CX // CX--
JMP loop
done:
MOVQ AX, ret+8(FP)
RET

5.3 函数调用#

// 调用其他 Go 函数
CALL ·otherFunc(SB) // 调用包内函数
CALL runtime·morestack(SB) // 调用 runtime 函数
// 调用前需确保:
// 1. SP 对齐到 16 字节
// 2. 参数已放入约定位置
// 3. 返回地址已压栈

六、与 Go 互操作#

6.1 Go 调用汇编函数#

add.go
package mypkg
func add(a, b int64) int64 // 声明,无函数体
func main() {
result := add(10, 20) // 直接调用
println(result)
}
add_amd64.s
#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)
RET

6.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)
RET

6.3 访问 Go 全局变量#

// Go 侧声明
// var Count int64
// 汇编中访问
MOVQ ·Count(SB), AX // 读取
ADDQ $1, AX
MOVQ AX, ·Count(SB) // 写入

七、实战:常见模式#

7.1 原子操作#

// atomic add
TEXT ·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)
RET

7.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)
RET

7.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)
RET
out_of_bounds:
CALL runtime·panicIndex(SB)
RET

7.4 CAS 操作#

// Compare And Swap
TEXT ·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
RET
failed:
MOVB $0, ret+24(FB) // 返回 false
RET

八、调试技巧#

8.1 查看编译器输出的汇编#

# 查看函数的汇编输出
go build -gcflags="-S" ./...
# 查看特定函数
go tool compile -S -I xxx file.go
# 反编译二进制
go tool objdump binary
go tool objdump -s "main\.myFunc" binary
# 导出汇编到文件
go build -gcflags="-S" . 2> asm_output.txt

8.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 growable
ok:
// 函数体
RET

NOSPLIT 标志表示该函数不会增长栈,省略栈分裂检查。适用于:

  • 栈帧很小且不调用其他函数的叶函数
  • 运行时内部函数(避免递归调用 morestack)

9.2 g 结构体#

Go 的 goroutine(协程) 结构体 g 常用字段:

// 通过 TLS 或 FS/GS 段寄存器获取当前 g
get_tls(CX) // 将当前 g 的地址加载到 CX
MOVQ g(CX), DX // DX = *g
// 常用字段偏移
MOVQ g_stackguard(DX), AX // 栈保护
MOVQ g_p(DX), BX // 关联的 P

9.3 常用 runtime 函数#

函数用途
runtime·morestack栈增长
runtime·memmove内存拷贝
runtime·memequal内存比较
runtime·gcWriteBarrierGC 写屏障
runtime·panicIndex越界 panic
runtime·goexitgoroutine 退出

十、跨架构编写#

10.1 条件编译#

// 文件命名约定
add_amd64.s // 仅 amd64
add_arm64.s // 仅 arm64
add_riscv64.s // 仅 riscv64
// 代码中使用架构常量
#ifdef GOARCH_amd64
MOVQ $1, AX
#endif
#ifdef GOARCH_arm64
MOVD $1, R0
#endif

10.2 架构差异速查#

特性amd64arm64riscv64
通用寄存器AX-BX, R8-R15R0-R30X0-X31
栈指针SPRSPSP
指令后缀Q/L/W/BD/S/W/H/B
立即数$1$11
函数调用CALLBLCALL
函数返回RETRETRET

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Plan 9 汇编入门
https://blog.souloss.com/posts/language/language-plan9-asm/
作者
Souloss
发布于
2020-06-15
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时