什么是Go语言汇编
是伪汇编
传统的汇编语言是和硬件架构是一一对应的,一种硬件架构对应一种汇编语言。而对Go 语言汇编而言,其输出的结果是一种抽象可移植的汇编代码,这种汇编并不对应某种真实的硬件架构。Go 的汇编器会使用这种伪汇编,再为目标硬件生成具体的机器指令。
语法不同
Go 语言汇编使用的是 GAS 汇编语法(Gnu ASsembler),与传统的汇编语言存在一些差异。
存在伪寄存器
Go 语言汇编提供了SB、FP、 PC、SP 4 个伪寄存器,其他汇编语言没有伪寄存器的概念, 详细介绍:会在寄存器章节会展开。
寄存器 | 官方说明 |
---|---|
SB(Static base pointer) | global symbols |
FP(Frame pointer) | arguments and locals |
PC(Program counter) | jumps and branches |
SP(Stack pointer) | top of stack |
为什么学习Go语言汇编
读懂Go汇编源码
Go的源码中有很多.s为后缀的文件,这就是GO的汇编源码,如果不了解Go语言汇编很难读懂这些汇编源码,下面是atomic(原子操作)的目录截图:
实现高级编程功能
为了系统安全一般操作系统会限制高级语言的权限,比如说禁止直接操作内存(堆&栈)、操作寄存器、执行CPU指令等,而go支持的runtime调度,调度过程中涉及了栈和寄存器等操作,这里就必须借助汇编来实现。
Go语言的runtime机制里有很多使用了汇编,比如:GMP、defer、原子操作等等。
代码性能优化
大家都知道汇编语言是低级语言更靠近硬件,可以直接操作寄存器和内存,性能更高。以[]byte转string举例:
正常写法:
Go
func bytesToString(bs []byte) string {
return string(bs)
}
unsafe写法:
Go
func bytesToString1(bs []byte) string {
return *(*string)(unsafe.Pointer(&bs))
}
汇编写法:
Go
// main.go
func bytesToString2(bs []byte) string
// main.s 汇编文件末尾必须有个空行
#include "textflag.h"
TEXT ·bytesToString2(SB), NOSPLIT, $0-40
MOVQ bs+0(FP), AX
MOVQ bs+8(FP), BX
MOVQ AX, r0+24(FP)
MOVQ BX, r0+32(FP)
RET
通过基准测试可以看出,使用汇编写法的性能是最高的:
理解底层实现机制
同样以[]byte to string 为例:
Go
func bytesToString(bs []byte) string {
return string(bs)
}
其中string(bs) 不是函数调用,在go源码里也没有相关的声明,单纯看代码很难弄明白其中原理,如果我们把上面代码编译成汇编之后我们就可以发现其实底层是调用了runtime.slicebytetostring()函数。
Go
0x0000 00000 (main.go:17) TEXT main.bytesToString(SB), ABIInternal, $48-24
0x0006 00006 (main.go:17) SUBQ $48, SP
0x000a 00010 (main.go:17) MOVQ BP, 40(SP)
0x000f 00015 (main.go:17) LEAQ 40(SP), BP
0x0014 00020 (main.go:17) MOVQ AX, main.bs+56(SP)
0x0019 00025 (main.go:17) MOVQ BX, main.bs+64(SP)
0x001e 00030 (main.go:17) MOVQ CX, main.bs+72(SP)
0x0023 00035 (main.go:17) MOVUPS X15, main.~r0+24(SP)
0x0029 00041 (main.go:18) MOVQ main.bs+56(SP), BX
0x002e 00046 (main.go:18) MOVQ main.bs+64(SP), CX
0x0033 00051 (main.go:18) XORL AX, AX
0x0035 00053 (main.go:18) CALL runtime.slicebytetostring(SB)
0x003a 00058 (main.go:18) MOVQ AX, main.~r0+24(SP)
0x003f 00063 (main.go:18) MOVQ BX, main.~r0+32(SP)
0x0044 00068 (main.go:18) MOVQ 40(SP), BP
0x0049 00073 (main.go:18) ADDQ $48, SP
0x004d 00077 (main.go:18) RET
Go汇编的基础知识(P0)
指令格式
Go汇编指令格式是:操作码 + 源操作数 + 目标操作数的形式。
Go
操作码 源操作数 目标操作数
MOVQ $10, AX // 含义是:将 10 赋值给 AX 寄存器
Go 汇编会在指令后加上 B , W , L 或 Q , 分别表示操作数的大小为1个,2个,4个或8个字节。
Go
MOVB $1, DI // 1 byte
MOVW $0x10, BX // 2 bytes
MOVL $1, DX // 4 bytes
MOVQ $-10, AX // 8 bytes
常数在 Go汇编中用 <math xmlns="http://www.w3.org/1998/Math/MathML"> n u m 表示,可以为负数,默认情况下为十进制。用 num 表示,可以为负数,默认情况下为十进制。用 </math>num表示,可以为负数,默认情况下为十进制。用0x123 的形式来表示十六进制数。
常用操作指令
Go语言汇编支持几千个指令,作为入门掌握下面常见的指令就可以。
指令 | 指令种类 | 用途 | 示例 |
---|---|---|---|
MOVQ | 传送 | 数据传送 | MOVQ $48, AX // 把 48 传送到 AX |
LEAQ | 传送 | 地址传送 | LEAQ AX, BX // 把 AX 有效地址传送到 BX |
PUSHQ | 传送 | 栈压入 | PUSHQ AX // 将 AX 内容送入栈顶位置 |
POPQ | 传送 | 栈弹出 | POPQ AX // 弹出栈顶数据后修改栈顶指针 |
ADDQ | 运算 | 相加并赋值 | ADDQ BX, AX // 等价于 AX+=BX |
SUBQ | 运算 | 相减并赋值 | SUBQ BX, AX // 等价于 AX-=BX |
CMPQ | 运算 | 比较大小 | CMPQ SI CX // 比较 SI 和 CX 的大小 |
CALL | 转移 | 调用函数 | CALL runtime.printnl(SB) // 发起调用 |
JMP | 转移 | 无条件转移指令 | JMP 0x0185 //无条件转至 0x0185 地址处 |
JLS | 转移 | 条件转移指令 | JLS 0x0185 //左边小于右边,则跳到 0x0185 |
JZ | 转移 | 条件跳转指令 |
寄存器
伪寄存器
go 汇编中有SB、FP、 PC、SP 4 个伪寄存器:
寄存器 | 说明 |
---|---|
SB(Static base pointer) | global symbols |
FP(Frame pointer) | arguments and locals |
PC(Program counter) | jumps and branches |
SP(Stack pointer) | top of stack |
-
SB寄存器: 全局静态基指针,一般用在声明函数、全局变量中。
-
FP寄存器: 在手写汇编代码时通常用来操作参数和返回值 , (FP)指向的是 caller 调用 callee 时传递的第一个参数的位置因此可以使用如 symbol+offset(FP)的方式来访问 callee 函数的参数与返回值。
Go
// func add(a, b int) int
TEXT ·add(SB), NOSPLIT, $0-24
MOVQ a+0(FP), AX // 参数 a
MOVQ b+8(FP), BX // 参数 b
ADDQ BX, AX // AX += BX
MOVQ AX, ret+16(FP) // 返回值赋值
RET
-
PC寄存器: 指令寄存器,和 x86 平台的 ip 寄存器相对应,保存CPU即将运行的下一条指令。
-
SP****寄存器: 该寄存器也是最具有迷惑性的寄存器,因为会有伪 SP 寄存器和硬件 SP 寄存器之分。
通用寄存器
在 Go 汇编里还可以直接使用的X86_64架构的通用寄存器,常用的主要有: rax, rbx, rcx, rdx, rdi, rsi, rbp,rsp, r8~r15 这些寄存器,
下表是通用寄存器的名字在 X86_64 和 Go汇编(plan9)中的对应关系:
变量定义与声明
Go汇编中使用 DATA 和 GLOBL 来定义一个变量(常量)。
-
DATA 用来指定对应内存中的值;
-
GLOBL 用来声明一个变量对应的符号,以及变量对应的内存大小。
Go
// DATA 汇编指令指定对应内存中的值; width 必须是 1、2、4、8 几个宽度之一
DATA symbol+offset(SB)/width, value // symbol+offset 偏移量,width 宽度, value 初始值
// GLOBL 指令声明一个变量对应的符号,以及变量对应的内存大小
GLOBL symbol(SB), flag, width // 名为 symbol, 内存宽度为 width, flag可省略
下面是声明变量的例子:
函数声明
函数声明的公式如下:
Go
告诉汇编器该数据放到TEXT区
^ 静态基地址指针(告诉汇编器这是基于静态地址的数据)
| ^
| | 标签 函数入参+返回值占用空间大小
| | ^ ^
| | | |
TEXT pkgname·funcname(SB),flag,$16-24
^ ^ ^
| | |
函数所属包名 函数名 函数栈帧大小(本地变量占用空间大小)
格式 | 描述 |
---|---|
TEXT | 定义函数标识 |
pkgname | 函数包名,可以不写,也可以""替代 |
· | 在程序链接后会转换为 "." ,mac 电脑按 option + shift + 9 能打出这个点。 |
(SB) | 让 SB 认识这个函数,即生成全局符号。 |
flag | 表示函数某些特殊功能,多个标签可以通过|连接。定义和全局变量的 tag 一样 |
$16-24 | 16表示函数栈帧大小,24表示入参和返回大小 |
Go语言调用规约
所谓的调用规约就是约定了如何调用函数,包括函数参数的传递、返回值的处理以及调用栈的管理等。
基本概念
-
栈:进程、线程、goroutine 都有自己的调用栈。
-
栈帧:可以理解是函数调用时,在栈上为函数所分配的内存区域
-
调用者:caller,比如:A 函数调用了 B 函数,那么 A 就是调用者
-
被调者:callee,比如:A 函数调用了 B 函数,那么 B 就是被调者
caller-save模式
Go语言的函数调用使用的是caller-save模式:在caller调用callee的时候,caller需要将callee的参数、返回值在栈上准备好,然后才能执行CALL callee 指令,参数和返回值具体在栈上的具体分布请参考Go内存模型章节。
CALL 与 RET指令
CALL指令是用来调用函数的,** RET指令**是用来返回一个函数的相当 return 语句。在调用函数时除了要准备参数和返回值还需要保存CALL指令的下一条指令的地址(return addr),以便函数返回时能继续运行caller里的代码,这个保存动作就是CALL指令隐式去做的,它会把 PC 寄存器(储存的 CPU 下一条要运行的指令)的值(return addr)保存到栈里,RET指令正好是个相反的操作,它会把 return addr 从栈里取出来赋值 PC 寄存器。
下面以 main() -> add() 举个例子:
Go
// main 函数片段
0x0000 00000 TEXT "".main(SB), ABIInternal, $32-0
......
0x002e 00046 CALL "".add(SB)
0x0033 00051 MOVQ 24(SP), BP
......
Go
// add 函数片段
0x0000 00000 TEXT "".add(SB), NOSPLIT, $16-24
0x0000 00001 SUBQ $16, SP ;;生成add栈空间
...
0x003b 00059 RET
CALL相当于有三步操作:
- SUBQ $8, SP
- MOVQ PC, (SP)
- MOVQ 函数的第一条指令, PC
RET相当于两步操作:
-
MOVQ (SP), PC
-
ADDQ $8, SP
Go内存模型
通过内存模型能更好的理解caller-save模式,以及栈桢与各个寄存器的关系,在学习内存模型前我们先看一些问题。
带着问题学习:
-
栈的增长方向?
-
BP寄存器、硬件SP寄存器、伪SP寄存器、伪FP寄存器的指向?
-
callee参数和返回值的内存布局?
-
callee会如何访问参数和返回值?
-
go 支持多返回值的原理?
-
栈帧都包含什么?
有一点需要注意的是,return addr 也是在 caller 的栈上的,不过往栈上插入 return addr 的过程是由 CALL 指令完成的(在分析汇编时,是看不到关于 addr 相关空间信息的。在分配栈空间时,addr 所占用空间大小不包含在栈帧大小内)。
Go语言汇编分析
如何生成Go语言汇编
方式一(推荐):
Go
go build -gcflags "-N -l -S" main.go 2 > main.s
方式二(推荐):
Go
go tool compile -N -l -S main.go > main.s
方式三:
Go
go tool compile -N -l main.go
go tool objdump simpleMap > simpleMap.s
生成示例代码
Go
package main
func add(a, b int) int {
sum := 0 // 不设置该局部变量sum,add栈空间大小会是0
sum = a+b
return sum
}
func main(){
add(1, 2)
}
编译 go 源代码,输出汇编,下面的代码是使用go1.14生成的,如果go是1.17以后版本,生成的汇编代码会有些不同。
Go
go tool compile -N -l -S main.go
截取主要汇编如下:
Go
// add 函数
0x0000 00000 (main.go:3) TEXT "".add(SB), NOSPLIT, $16-24
0x0000 00000 (main.go:3) SUBQ $16, SP ;;生成add栈空间
0x0004 00004 (main.go:3) MOVQ BP, 8(SP)
0x0009 00009 (main.go:3) LEAQ 8(SP), BP
......
0x000e 00014 (main.go:3) MOVQ $0, "".~r2+40(SP) ;;初始化返回值
0x0017 00023 (main.go:4) MOVQ $0, "".sum(SP) ;;局部变量sum赋为0
0x001f 00031 (main.go:5) MOVQ "".a+24(SP), AX ;;取参数a
0x0024 00036 (main.go:5) ADDQ "".b+32(SP), AX ;;等价于AX=a+b
0x0029 00041 (main.go:5) MOVQ AX, "".sum(SP) ;;赋值局部变量sum
0x002d 00045 (main.go:6) MOVQ AX, "".~r2+40(SP) ;;设置返回值
0x0032 00050 (main.go:6) MOVQ 8(SP), BP
0x0037 00055 (main.go:6) ADDQ $16, SP ;;清除add栈空间
0x003b 00059 (main.go:6) RET
......
// main 函数
0x0000 00000 (main.go:7) TEXT "".main(SB), ABIInternal, $32-0
......
0x000f 00015 (main.go:7) SUBQ $32, SP
0x0013 00019 (main.go:7) MOVQ BP, 24(SP)
0x0018 00024 (main.go:7) LEAQ 24(SP), BP
......
0x001d 00029 (main.go:8) MOVQ $1, (SP)
0x0025 00037 (main.go:8) MOVQ $2, 8(SP)
0x002e 00046 (main.go:8) CALL "".add(SB)
0x0033 00051 (main.go:9) MOVQ 24(SP), BP
0x0038 00056 (main.go:9) ADDQ $32, SP
0x003c 00060 (main.go:9) RET
......
Go 汇编代码解析
下面针对上一章节输出汇编代码进行分析。
add 函数汇编解析
指令 | 说明 |
---|---|
TEXT "".add | TEXT 指令声明了 "".add 是一个函数 |
NOSPLIT | 向编译器表明不需要栈扩容检查。 |
$16-24 | 16: 说明add函数需要16字节的栈空间(caller BP 8字节 + 局部变量8字节) 24: 说明add函数的参数+返回值总大小为24字节(24 字节=入参 a、b 大小8字节*2+返回值8字节) |
SUBQ $16, SP | SP 为栈顶指针,该语句等价于 SP-=16(由于栈空间是向下增长的,所以开辟栈空间时为减操作),表示生成 16 字节大小的栈空间。 |
MOVQ BP, 8(SP) | BP是栈帧的栈底指针,当进行函数调用时需要把caller的BP中的值保存在callee的栈帧中。 |
LEAQ 8(SP), BP | 把当前(callee)栈帧的栈底地址赋值给BP寄存器。 |
MOVQ $0, "".~r2+40(SP) | 此时的 SP 为 add 函数栈的栈顶指针,40(SP)的位置则是 add 返回值的位置,该位置位于 main 函数栈空间内。该语句设置返回值类型的 0 值,即初始化返回值,防止得到脏数据(返回值类型为 int,int 的 0 值为 0) |
MOVQ "".a+24(SP), AX | 从 main 函数栈空间获取入参 a 的值,存到寄存器 AX |
ADDQ "".b+32(SP), AX | 从 main 函数栈空间获取入参 b 的值,与寄存器 AX 中存储的 a 值相加,结果存到 AX。相当于 AX=a+b |
MOVQ AX, "".~r2+40(SP) | 把 a+b 的结果放到 main 函数栈中, add(a+b)返回值所在的位置 |
MOVQ 8(SP), BP | 恢复BP指向caller的栈底 |
ADDQ $16, SP | 归还 add 函数占用的栈空间 |
RET | 返回add函数,并把return addr出栈并赋值给 PC 寄存器。 |
main 函数汇编解析
指令 | 说明 |
---|---|
MOVQ $1, (SP) | 参数1赋值 |
MOVQ $2, 8(SP) | 参数2赋值 |
CALL "".add(SB) | 调用add函数,将PC寄存器的值压入到栈中,并将函数的第一条指令赋值给PC寄存器。 |
函数栈桢结构模型
手写Go语言汇编
前置知识
-
汇编文件是以 .s后缀结尾,汇编文件末尾需要有个空行
-
将汇编文件和go文件放到一个目录下就能一起编译
-
手写汇编代码时注意点:
两数求和
Go
package main
import "fmt"
func add(a, b int) int // 汇编函数声明
func main() {
fmt.Println(add(10, 11))
}
Go
#include "textflag.h"
// func add(a, b int) int
TEXT ·add(SB), NOSPLIT, $0-24
MOVQ a+0(FP), AX // 参数 a
MOVQ b+8(FP), BX // 参数 b
ADDQ BX, AX // AX += BX
MOVQ AX, ret+16(FP) // 返回
RET
你能解释这里为什么是 $0-24 吗?
slice求和
Go
package main
func sum([]int64) int64
func main() {
println(sum([]int64{1, 2, 3, 4, 5}))
}
Go
#include "textflag.h"
// func sum(sl []int64) int64
TEXT ·sum(SB), NOSPLIT, $0-32
MOVQ $0, SI
MOVQ sl+0(FP), BX // &sl[0], addr of the first elem
MOVQ sl+8(FP), CX // len(sl)
INCQ CX // CX++, 因为要循环 len 次
start:
DECQ CX // CX--
JZ done // 判断为零则跳转
ADDQ (BX), SI // SI += *BX
ADDQ $8, BX // 指针移动
JMP start
done:
MOVQ SI, ret+24(FP)
RET
问题,返回值的 ret+24(FP),这里的 24 是怎么算出来的呢?
bytesToString
Go
// main.go
package main
import (
"fmt"
)
func main() {
bs := []byte{'a', 'b', 'c'}
fmt.Println(bytesToString(bs))
}
func bytesToString(bs []byte) string
Go
#include "textflag.h"
TEXT ·bytesToString(SB), NOSPLIT, $0-40
MOVQ bs+0(FP), AX
MOVQ bs+8(FP), BX
MOVQ AX, r0+24(FP)
MOVQ BX, r0+32(FP)
RET
Go语言汇编的应用
定位runtime源码
以make函数为例,初始化不同类型的数据会调用不同的runtime函数
Go
func makeRuntimeCode() {
s := make([]int, 0, 3)
m := make(map[string]string, 3)
c := make(chan int, 1)
fmt.Println(s, m, c)
}
编译成汇编:
Go
TEXT "".makeRuntimeCode(SB), ABIInternal, $200-0
// ...
CALL runtime.makeslice(SB)
// ...
CALL runtime.makemap_small(SB)
// ...
CALL runtime.makechan(SB)
// ...
RET
通过查看汇编代码,可以确定初始化切片时的make对应的runtime.makeslice,初始化map时的对应runtime.makemap_small,初始化chan时的对应runtime.makechan。
调用硬件指令
下面是Cas的源码,阅读源码可知原子操作是通过调用硬件LOCK指令实现的:
Go
// func Cas(ptr *int32, old, new int32) bool
// runtime/internal/atomic/asm_amd64.s
TEXT runtime∕internal∕atomic·Cas(SB),NOSPLIT,$0-17
MOVQ ptr+0(FP), BX
MOVL old+8(FP), AX
MOVL new+12(FP), CX
LOCK
CMPXCHGL CX, 0(BX)
SETEQ ret+16(FP)
RET
LOCK:是一个指令前缀,其后必须跟一条"读-改-写"性质的指令,它们可以是ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, CMPXCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, XCHG。该指令是一种锁定协议,用于封锁总线,禁止其他 CPU 对内存的操作来保证原子性。
其他
-
调用其他package的私有函数:可以参考:sitano.github.io/2016/04/28/...
-
优化获取行号性能,参考:golang文件行号探索
-
等等