Go 汇编
2023-04-17 Golang
为什么要学习汇编语言
- 更加接近硬件底层,性能极致优化
- 降维打击所有 “高级编程语言”
如果读者对汇编语言零基础,建议直接跳转到文末 Reference 列表,最后两个链接可以作为入门读物。
概述
Go 汇编语言
并不是一个独立的语言,因为其无法独立编译和使用。Go 汇编代码必须以 Go 包的方式组织,同时包中至少要有一个 Go 语言文件用于指明当前包名等基本包信息。
如果 Go 汇编代码中定义的变量和函数要被其它 Go 语言代码引用,还需要通过 Go 语言代码将汇编中定义的符号声明出来,用于变量的定义和函数的定义。
Go 汇编文件类似于 C 语言中的 .c 文件,而用于导出汇编中定义符号的 Go 源文件类似于 C 语言的.h 文件。
查看 Go 程序汇编代码
// main.go
package main
func main() {
println(3)
}
例如,我们想查看 Linux/amd64
架构体系下的汇编代码,可以使用下面的指令:
$ GOOS=linux GOARCH=amd64 go tool compile -S main.go
# 或者
$ GOOS=linux GOARCH=amd64 go build -gcflags -S main.go
# 输出结果如下
main.main STEXT size=66 args=0x0 locals=0x10 funcid=0x0 align=0x0
0x0000 00000 (main.go:50) TEXT main.main(SB), ABIInternal, $16-0
0x0000 00000 (main.go:50) CMPQ SP, 16(R14)
0x0004 00004 (main.go:50) PCDATA $0, $-2
0x0004 00004 (main.go:50) JLS 57
0x0006 00006 (main.go:50) PCDATA $0, $-1
0x0006 00006 (main.go:50) SUBQ $16, SP
0x000a 00010 (main.go:50) MOVQ BP, 8(SP)
0x000f 00015 (main.go:50) LEAQ 8(SP), BP
0x0014 00020 (main.go:50) FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x0014 00020 (main.go:50) FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x0014 00020 (main.go:51) PCDATA $1, $0
0x0014 00020 (main.go:51) CALL runtime.printlock(SB)
0x0019 00025 (main.go:51) MOVL $3, AX
0x001e 00030 (main.go:51) NOP
0x0020 00032 (main.go:51) CALL runtime.printint(SB)
0x0025 00037 (main.go:51) CALL runtime.printnl(SB)
0x002a 00042 (main.go:51) CALL runtime.printunlock(SB)
0x002f 00047 (main.go:52) MOVQ 8(SP), BP
0x0034 00052 (main.go:52) ADDQ $16, SP
0x0038 00056 (main.go:52) RET
0x0039 00057 (main.go:52) NOP
0x0039 00057 (main.go:50) PCDATA $1, $-1
0x0039 00057 (main.go:50) PCDATA $0, $-2
0x0039 00057 (main.go:50) CALL runtime.morestack_noctxt(SB)
0x003e 00062 (main.go:50) PCDATA $0, $-1
0x003e 00062 (main.go:50) NOP
0x0040 00064 (main.go:50) JMP 0
...
输出结果中的 FUNCDATA
和 PCDATA
指令由编译器引入,包含 GC 用到的信息。
查看链接后放入二进制文件的内容,使用 go tool objdump
# 编译 main.go 文件
$ go build -o main main.go
# 匹配编译后文件中的 "main.main" 符号
$ go tool objdump -s main.main main
# 输出如下
main.go:50 0x457c00 493b6610 CMPQ 0x10(R14), SP
main.go:50 0x457c04 7633 JBE 0x457c39
main.go:50 0x457c06 4883ec10 SUBQ $0x10, SP
main.go:50 0x457c0a 48896c2408 MOVQ BP, 0x8(SP)
main.go:50 0x457c0f 488d6c2408 LEAQ 0x8(SP), BP
main.go:51 0x457c14 e84776fdff CALL runtime.printlock(SB)
main.go:51 0x457c19 b803000000 MOVL $0x3, AX
main.go:51 0x457c1e 6690 NOPW
main.go:51 0x457c20 e83b7dfdff CALL runtime.printint(SB)
main.go:51 0x457c25 e89678fdff CALL runtime.printnl(SB)
main.go:51 0x457c2a e8b176fdff CALL runtime.printunlock(SB)
main.go:52 0x457c2f 488b6c2408 MOVQ 0x8(SP), BP
main.go:52 0x457c34 4883c410 ADDQ $0x10, SP
main.go:52 0x457c38 c3 RET
main.go:50 0x457c39 e802cdffff CALL runtime.morestack_noctxt.abi0(SB)
main.go:50 0x457c3e 6690 NOPW
main.go:50 0x457c40 ebbe JMP main.main(SB)
寄存器
通用寄存器
Go 汇编语言
中使用寄存器不需要带通用寄存器的前缀,例如 rax,只要写 AX 即可。
MOVQ $101, AX = mov rax, 101
伪寄存器
Go 汇编语言
有 4 个预声明的符号引用 伪寄存器
,是由工具链维护的 虚拟寄存器
(不是真正的寄存器),例如帧指针,伪寄存器在所有硬件体系结构下的语义和作用都是相同的。
- FP: 栈基,栈帧(函数的栈叫栈帧)的开始位置 (一般用来访问函数的参数和返回值)
- PC: 存放 CPU 下一条执行指令的位置地址 (amd64 环境中就是 IP 指令计数寄存器的别名)
- SB: 静态内存的开始地址 (内存是通过SB伪寄存器定位,所有的静态全局符号通常可以通过 SB 加一个偏移量获得)
- SP: 栈底,栈帧的结束位置 (不包括参数和返回值,一般用于定位局部变量)
SP
寄存器比较特殊,因为存在一个 真 SP
寄存器。真 SP
寄存器对应的是栈顶,一般用于定位调用其它函数的参数和返回值。
如何区分真 · 伪寄存器?
伪寄存器
需要一个标识符和偏移量为前缀,真寄存器
则不需要。比如 (SP)、+8(SP) 没有标识符前缀表示 真 SP
,而 a(SP)、b+8(SP) 有标识符前缀表示 伪 SP
。
基础指令
变量声明
使用 DATA + GLOBL
定义一个变量,GLOBL 必须在 DATA 指令之后,语法如下。
DATA symbol+offset(SB)/width, value
# GLOBL 指令将变量声明为 global
# RODATA 表示变量位于只读区
# $64 表示数据大小为 64 字节
GLOBL divtab(SB), RODATA, $64
# ·count 以 · 开头表示变量属于当前包
GLOBL ·count(SB),$4
示例
# 逐个字节初始化变量
DATA ·count+0(SB)/1,$1
DATA ·count+1(SB)/1,$2
DATA ·count+2(SB)/1,$3
DATA ·count+3(SB)/1,$4
# 一次性初始化变量
DATA ·count+0(SB)/4,$0x04030201
# 定义一个字符串
GLOBL ·helloworld(SB),$16
# 字符串内容为 Hello World
GLOBL text<>(SB),NOPTR,$16
DATA text<>+0(SB)/8,$"Hello Wo"
DATA text<>+8(SB)/8,$"rld!"
# 初始化 Go 中的字符串对应的 StringHeader 结构体
DATA ·helloworld+0(SB)/8,$text<>(SB) # StringHeader.Data
DATA ·helloworld+8(SB)/8,$12 # StringHeader.Len
# 声明数据不含指针
GLOBL ·NameData(SB),NOPTR,$8
DATA ·NameData+0(SB)/8,$"gopher"
赋值操作
字节数由 MOV
指令的后缀决定。
MOVB $1, DI # 1 byte B => Byte
MOVW $0x10, BX # 2 bytes W => Word
MOVD $1, DX # 4 bytes L => Long
MOVQ $-10, AX # 8 bytes Q => Quadword
数值计算
类似赋值操作指令,可以通过修改指令后缀来对应不同长度的操作数。例如 ADDQ/ADDW/ADDL/ADDB。
ADDQ AX, BX # BX += AX
SUBQ AX, BX # BX -= AX
IMULQ AX, BX # BX *= AX
MOVL g(CX), AX # 将 g 赋值到寄存器 AX
MOVL g_m(AX), BX # 将 g.m 赋值到寄存器 BX
条件跳转/无条件跳转
# 无条件跳转
JMP addr # 跳转到指定地址
JMP label # 跳转到指定标签
JMP 2(PC) # 以当前指令为基础,向前/后跳转指定行数
JMP -2(PC) # 同上
# 有条件跳转
CMPQ CX, $0 # 如果寄存器 CX 的值等于 0,跳转到 L 标签
JZ L
栈调整
SUBQ $0x18, SP # 对 SP 做减法,为函数分配函数栈帧
SUBQ $40, SP # 分配 40 字节栈空间
ADDQ $0x18, SP # 对 SP 做加法,清除函数栈帧
函数声明
示例
add 函数
func add(a, b int) int
对应的汇编代码如下:
# pkgname 名称直接省略即可
TEXT pkgname·add(SB), NOSPLIT, $0-8
MOVQ a+0(FP), AX
MOVQ a+8(FP), BX
ADDQ AX, BX
MOVQ BX, ret+16(FP)
RET
Say 函数
func Say()
对应的汇编代码如下:
TEXT ·Say(SB), NOSPLIT, $16-0
MOVQ ·helloWorld+0(SB), AX
MOVQ AX, 0(SP)
MOVQ ·helloWorld+8(SB), BX
MOVQ BX, 8(SP)
CALL ·output(SB) # 在调用 output 之前,已经把参数通过真寄存器 SP 传递到了函数栈顶
RET
函数调用
调用函数时,被调用函数的参数和返回值内存空间都必须由调用者提供。
.s 汇编文件和 .go 文件重定向
// Go 文件变量
var version float32 = 1.0
// Go 文件函数
func getVersion() float32
# 汇编文件函数
# ·version(SB),表示该符号需要链接器来帮我们进行重定向(relocation)
# 这里表示使用 Go 程序中的 version 变量
TEXT ·getVersion(SB),NOSPLIT,$0-4
MOVQ ·version(SB), AX
MOVQ AX, ret+0(FP)
RET
不支持的指令
如果要使用硬件体系结构缺失的汇编指令,有两种方法:
- 更新汇编程序以支持该指令
- 使用
BYTE
和WORD
指令将数据显式放到TEXT
中的指令流中
// 示例: 386 如何定义 atomic.LoadUint64 函数
TEXT runtime·atomicload64(SB), NOSPLIT, $0-12
MOVL ptr+0(FP), AX
TESTL $7, AX
JZ 2(PC)
MOVL 0, AX // crash with nil ptr deref
LEAL ret_lo+4(FP), BX
// MOVQ (%EAX), %MM0
BYTE $0x0f; BYTE $0x6f; BYTE $0x00
// MOVQ %MM0, 0(%EBX)
BYTE $0x0f; BYTE $0x7f; BYTE $0x03
// EMMS
BYTE $0x0F; BYTE $0x77
RET
综合示例
main.go
文件中的函数都是声明未实现,全部使用汇编语言实现。
package main
import (
"fmt"
"unsafe"
)
type Text struct {
Language string
_ uint8
Length int
}
var version float32 = 1.0
func add(a, b int) int
func addX(a, b int) int
func sub(a, b int) int
func mul(a, b int) int
// 获取 Text 的 Length 字段的值
func length(text *Text) int
// 获取 Text 结构体的大小
func sizeOfTextStruct() int
func getAge() int32
func getPI() float64
func getBirthYear() int32
func getVersion() float32
func main() {
println(add(1, 2))
println(addX(1, 2))
println(sub(10, 5))
println(mul(10, 5))
text := &Text{
Language: "Go",
Length: 1024,
}
fmt.Println(text)
println(length(text))
println(sizeOfTextStruct())
println(unsafe.Sizeof(*text))
println(getAge())
println(getPI())
println(getBirthYear())
println(getVersion())
}
main.s
汇编程序文件中使用了 Go 文件中声明的部分变量,并且实现了Go 文件中声明的所有函数。
#include "textflag.h"
#include "go_asm.h" // 该文件编译时自动生成
# 实现了 add 函数
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
# 实现了 addX 函数
TEXT ·addX(SB),NOSPLIT,$0-24
MOVQ a+0(FP), AX
MOVQ b+8(FP), BX
ADDQ BX, AX
MOVQ $x(SB), BX # 读取全局变量 x 的地址
MOVQ 0(BX), BX # 读取全局变量 x 的值
ADDQ BX, AX
MOVQ AX, ret+16(FP)
RET
# 实现了 sub 函数
TEXT ·sub(SB), NOSPLIT, $0-24
MOVQ a+0(FP), AX
MOVQ b+8(FP), BX
SUBQ BX, AX // AX -= BX
MOVQ AX, ret+16(FP)
RET
# 实现了 mul 函数
TEXT ·mul(SB), NOSPLIT, $0-24
MOVQ a+0(FP), AX
MOVQ b+8(FP), BX
IMULQ BX, AX # AX *= BX
MOVQ AX, ret+16(FP)
RET
# 实现了 length 函数
TEXT ·length(SB),NOSPLIT,$0-16
MOVQ text+0(FP), AX
MOVQ Text_Length(AX), AX # 通过字段在结构体中的偏移量读取字段值
MOVQ AX, ret+8(FP)
RET
# 实现了 sizeOfTextStruct 函数
TEXT ·sizeOfTextStruct(SB),NOSPLIT,$0-8
MOVQ $Text__size, AX // 保存结构体的大小到 AX 寄存器
MOVQ AX, ret+0(FP)
RET
# 实现了 getAge 函数
TEXT ·getAge(SB),NOSPLIT,$0-4
MOVQ age(SB), AX
MOVQ AX, ret+0(FP)
RET
# 实现了 getPI 函数
TEXT ·getPI(SB),NOSPLIT,$0-8
MOVQ pi(SB), AX
MOVQ AX, ret+0(FP)
RET
# 实现了 getBirthYear 函数
TEXT ·getBirthYear(SB),NOSPLIT,$0-4
MOVQ birthYear(SB), AX
MOVQ AX, ret+0(FP)
RET
# 实现了 getVersion 函数
TEXT ·getVersion(SB),NOSPLIT,$0-4
MOVQ ·version(SB), AX
MOVQ AX, ret+0(FP)
RET
DATA x+0(SB)/8, $10 # 初始化全局变量 x, 赋值为 10
GLOBL x(SB), RODATA, $8 # 声明全局变量 x, GLOBL 必须跟在 DATA 指令之后
DATA age+0x00(SB)/4, $18
GLOBL age(SB), RODATA, $4
DATA pi+0(SB)/8, $3.1415926
GLOBL pi(SB), RODATA, $8
DATA birthYear+0(SB)/4, $1992
GLOBL birthYear(SB), RODATA, $4
# 最后一行的空行是必须的,否则报错 unexpected EOF; 或者在最后一行代码末尾加分号 ;
运行输出
# 注意这里要以当前包为运行路径,因为要包含 main.s 汇编文件
$ go run ./
# 输出结果如下
3
13
5
50
&{Go 0 1024}
1024
32
32
18
+3.141593e+000
1992
+1.000000e+000
小结
本文介绍了 Go 汇编语言
的基础,包括变量/常量、分支/循环、函数,读者可以根据 综合示例 的代码来编写一些简单的小程序。
如果读者希望深入学习或者通过汇编来优化已有的 Go
语言程序,可以参考下面的链接列表。
Reference
- A Quick Guide to Go’s Assembler
- A Manual for the Plan 9 assembler
- Optimizing GoLang for High Performance with ARM64 Assembly
- Go Assembly by Example
- Chapter I: A Primer on Go Assembly
- Go 汇编
- Go语言高级编程
- Go汇编优化入门.pdf
- 函数调用
- Go Assembly 示例
- Linux 汇编语言开发指南
- Some Assembly Required
- 深入理解程序设计-Linux汇编语言
- NASM 程序设计
- 初探 Go 的编译命令执行过程
- Go语言链接器简介
- 用C重写Go中cpu密集型函数的一般方法