Go 与汇编

1. 为什么 Go 需要汇编

Go 在以下场景允许直接嵌入汇编:

  • 性能热点:手写 SIMD 指令、避免编译器优化不足
  • 系统调用:部分 runtime 代码(如 runtime·sys_linux_amd64.s)直接发起 syscall
  • 引导/启动rt0_linux_amd64.s 等启动代码必须在汇编层面完成栈和寄存器初始化
  • 密码学crypto/aes, crypto/sha256 等标准库大量使用汇编加速

Go 使用 Plan 9 汇编语法,与 AT&T / Intel 语法都不同。


2. Plan 9 汇编基础

2.1 操作数顺序

Plan 9 汇编是源操作数在前,目标操作数在后(类似 AT&T,但无 % / $ 前缀):

1
2
ADDQ BX, AX    // AX = AX + BX    (Intel: add rax, rbx)
MOVQ x+0(FP), AX  // AX = *(FP + x_offset)

2.2 寄存器命名

通用寄存器Plan 9 名Intel 名
AX / EAX / AX:BXAXRAX
BXBXRBX
CXCXRCX
DXDXRDX
Stack PointerSPRSP
Base PointerBPRBP
R8–R15R8–R15R8–R15
X0–X15 (SSE)X0–X15XMM0–XMM15

2.3 伪寄存器(核心概念)

Go 汇编定义了四个伪寄存器,没有物理硬件对应:

伪寄存器含义示例
SBStatic Base — 全局符号基址TEXT ·Sum(SB) 声明全局函数
FPFrame Pointer — 指向参数和返回值区域的起始x+0(FP) 访问第一个参数
SP栈指针 — 指向当前 goroutine 栈顶MOVQ AX, local+0(SP) 局部变量
PCProgram Counter — 指令地址用于跳转表和 CALL

重点FP 是函数参数/返回值的入口地址,硬件寄存器 SPRSP,而汇编中的 SP 指的是局部变量区的基址。两者是不同的。

2.4 指令后缀

后缀操作数大小对应 C 类型
B1 字节byte / uint8
W2 字节uint16
L4 字节uint32
Q8 字节uint64 / int (64-bit)

3. 函数声明与栈帧

3.1 TEXT 指令

1
TEXT ·函数名(SB), $帧大小-参数大小
  • ·函数名·(U+00B7)是包分隔符,等价于 Go 的 .
  • SB:使该符号全局可见,Go 链接器可找到它
  • $N-M
    • N:局部变量所需的栈空间(0 表示不需要)
    • M:参数 + 返回值占用的总字节数

3.2 栈布局(amd64)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
调用者视角:
+------------------+  高地址
|   caller frame   |
+------------------+
|   return addr    |  (8 bytes, 由 CALL 压栈)
+------------------+  ← 被调函数的硬件 SP (RSP)
|  参数1  x        |  x+0(FP)
|  参数2  y        |  y+8(FP)
|  返回值          |  ret+16(FP)
+------------------+  ← FP (伪寄存器)
|  局部变量区       |  local+0(SP) ... local+N(SP)
+------------------+  ← 硬件 SP 最终位置

关键FP 指向参数区的起始,SP(伪寄存器)指向局部变量区的起始。返回值存放在参数区的最末尾,紧接在参数之后。

3.3 参数与返回值的偏移计算

对于 func Sum(x, y int) int(amd64,int = 8 字节):

1
2
3
4
FP +  0:  x      (8 bytes)
FP +  8:  y      (8 bytes)  
FP + 16:  ret    (8 bytes)
总大小: 24 bytes

因此 TEXT 指令的帧大小应为 $0-24(0 字节局部变量 + 24 字节参数/返回值空间)。


4. 实例解析

4.1 Go 侧 (main.go)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package main

import "fmt"

func main() {
    x := 10
    y := 20
    sum := Sum(x, y)
    fmt.Println("Sum:", sum)
}

func Sum(x, y int) int  // 只声明,无函数体
  • Sum 只有签名没有实现体
  • Go 编译器看到这样的声明会自动查找同名汇编符号
  • 注意:汇编函数名大小写敏感,Sum 大写表示导出(exported)

4.2 汇编侧 (add.s)

1
2
3
4
5
6
TEXT ·Sum(SB), $0-24
    MOVQ x+0(FP), AX      // AX = x
    MOVQ y+8(FP), BX      // BX = y
    ADDQ BX, AX           // AX = AX + BX
    MOVQ AX, ret+16(FP)   // 将结果写入返回值位置
    RET

逐行解释:

  1. MOVQ x+0(FP), AX — 从栈上偏移 0 处取出参数 x,放入 AX
  2. MOVQ y+8(FP), BX — 从栈上偏移 8 处取出参数 y,放入 BX
  3. ADDQ BX, AXAX += BX
  4. MOVQ AX, ret+16(FP) — 将 AX 的值写入返回值槽位
  5. RET — 函数返回,调用者从 ret+16(FP) 处读取结果

4.3 编译与运行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 直接 go build,汇编文件 (.s) 会被自动识别
go build -o go-asm
./go-asm
# 输出: Sum: 30

# 查看 Go 生成的汇编(对比编译器生成版本)
go tool compile -S main.go

# 只编译汇编文件
go tool asm add.s

# 反汇编已编译的二进制
go tool objdump -s Sum go-asm

5. 调试与工具

5.1 验证栈帧大小

编译时设置 -asan 或者直接检查:

1
go build -gcflags="-live" 2>&1 | head

5.2 查看调用约定兼容性

1
2
# 确认寄存器传参是否被使用(Go 1.17+ ABI 内部变化)
go build -gcflags="-S" 2>&1 | grep Sum

Go 1.17 起引入了基于寄存器的调用约定(ABIInternal),参数和返回值优先通过寄存器传递而非栈。但手写的汇编 .s 文件默认仍使用栈传参(ABI0)。如果需要兼容 ABIInternal,需要在 TEXT 指令中指定。


6. 常见陷阱

6.1 帧大小标注不匹配

如果 TEXT 中声明的 $N-M 与实际使用的偏移量不一致,链接时可能不会报错但运行时会产生数据竞争或栈损坏。

当前仓库中 add.s$0-8 应修正为 $0-24(amd64 下 int 为 8 字节,参数 + 返回值共 24 字节)。

6.2 符号命名

  • Go 中使用 ·(middle dot)分隔包名和函数名,不可用普通点 .
  • 导出函数名(首字母大写)在汇编中同样需要大写
  • 使用 "".Sum·Sum 均可(后者是缩写形式)

6.3 栈对齐

x86-64 ABI 要求栈在 CALL 前 16 字节对齐。如果手动调整栈指针,需要确保对齐。

6.4 寄存器保存

Go 汇编中,调用者需要保存 callee-saved 寄存器(BPBXR12–R15)。AXCXDX 等是调用者保存的 scratch 寄存器,可以随意使用。

6.5 Go 版本差异

版本变化
Go 1.17引入 ABIInternal(寄存器传参),汇编默认仍使用 ABI0
Go 1.21+部分标准库汇编适配寄存器 ABI
Go 1.22+GOAMD64=v3 支持 AVX2 等新指令

7. 参考资源

  • A Quick Guide to Go’s Assembler — Go 官方汇编快速指南
  • go tool compile -S xxx.go — 查看编译器生成的汇编
  • go doc cmd/asm — 汇编器文档
  • 标准库 crypto/ 目录下大量 .s 文件可供学习
Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计