本文快速阅读需要一定的汇编、Go、编译原理基础
因水平极其有限,错误难以避免,欢迎批评指正
# 1. Go与Plan 9
- 一图胜千言:
- 网传,开发Go的一些重要人物也是Plan 9项目的重要人物,所以Go汇编和一些工具链是Plan 9项目搬过来的。因为这个汇编独立与所有的CPU架构和操作系统(独立于操作系统,其实生成的汇编已经要使用寄存器了,每个架构寄存器情况不同)。所以Go项目需要为具体架构和操作系统生成目标机器代码。所以我们甚至可以把Go汇编理解成Go的一种IR。
- Go汇编学习资料:
- 网上大部分书籍和资料的汇编停留在1.17以前的版本,但是1.17开始(最新的1.18支持更多架构)函数调用有了新ABI规范。所以如果我们的Go版本比较新,那么可能生成的汇编和网上各种教程里的不太一样。其实也没有关系,没有太大区别。本文的汇编是基于Go1.17生成的。
# 2. 一段相对简单的Go代码学习Go汇编
- 前置知识:简单强调一下本文阅读预备知识中的一些知识点
- 编译原理:一个程序编译的过程为词法分析,语法分析,语义分析,中间代码生成,代码分析和优化,目标代码生成。对于其它语言的编译器后端,生成的目标代码一般就是对应平台的汇编代码。再由对应汇编器处理。而对于Go,可以认为生成的目标代码在任何时候都是Plan 9汇编(屏蔽了操作系统带来的差异,如系统调用规范,而CPU带给Go汇编的主要差异就是寄存器数量和名字)。之后会再根据架构和操作系统翻译成对应的机器代码,所以也有人称Go在这个层面是平台无关性的。
- 汇编基础:这里说一下调用约定,我们程序员一般研究的对象是Linux/x86-64,其调用约定为函数参数只有6个能放在寄存器中,多于6个需要放入栈中。返回地址也在寄存器中。而Go1.17之前,Go调用约定是返回值和调用参数都存放在栈中。现在最新版本的函数调用参数是使用寄存器的,带来了性能的提升。 再说一下程序运行时候的内存布局,栈内存在内存中是由高地址向低地址延伸的,所以每个栈帧的栈低地址大于栈顶。
- Go汇编与主流汇编较大区别介绍:
- 4个伪寄存器:PC、FP、SP、SB。我们需要重点关注的是FP与SP。特别是SP也是部分架构中的实寄存器。以下内容如无特别表述,SP即表示伪SP。
- FP:可以认为是当前栈帧的栈底(不包括参数返回值),当有寄存器放不下的调用参数或者有返回值时。这些对象的寻址会用到FP,且为正偏移(参数在FP高地址方向存储)。
- SP:一定要注意区分真伪SP寄存器。伪SP也可以认为是栈底(不包括参数返回值),而真SP认为是栈顶。一般局部变量的寻址会使用伪SP。且为负偏移。伪寄存器一般需要一个标识符和偏移量为前缀,如果没有标识符前缀则是真寄存器。比如(SP)、+8(SP)没有标识符前缀为真SP寄存器,而a(SP)、b+8(SP)有标识符为前缀表示伪寄存器。
- 一般一个函数的栈帧可以认为是真伪SP所指地址中间部分。上面的表述中,可能有人认为FP和SP一定是在一起的,但是由于返回地址等内存需求和内存对齐等原因,不是一起的。
- Go汇编的调用约定中,所有信息都是由调用者保护的,所以可以看出,每个函数栈帧中包含了调用别的函数的参数和返回值空间。
- 4个伪寄存器:PC、FP、SP、SB。我们需要重点关注的是FP与SP。特别是SP也是部分架构中的实寄存器。以下内容如无特别表述,SP即表示伪SP。
# 3. Go汇编阅读
- 阅读Go汇编常用的命令为go tool compile -N -l -S 。-N代表不优化,不然Go汇编和我们想象的可能大不一样,-l为不内联,-S为打印汇编信息。还有其它命令也可以使用。在线网站gossa (opens new window)可以实时查看某个函数的汇编代码
- 源代码:
package main
func main() {
var a int64 = 10
var b int64 = 20
a += sum(a, b)
}
func sum(a int64, b int64) int64 {
return a + b
}
- Go汇编及解读:每行#开头的代码解释下一行汇编含义
函数定义:TEXT 函数名(SB), [flags,] $栈大小[-参数及返回值大小]。再次注意,函数自己的参数及返回值不在自己的栈帧中。而自己栈帧大小包括调用别的函数的返回值及参数。flags一般很多,遇到时搜索一下啥意思
FUNCDATA和PCDATA:记录了函数中指针信息和调用信息等,panic时的调用情况及垃圾回收时的根对象都分别依赖它们。它们是编译器自行插入的,阅读时可以跳过
使用go tool compile -S / go tool objdump命令输出的汇编来说,所有的 SP 都是真SP即SP寄存器中的地址。所以从下面汇编(使用go tool compile -S -N -l)可以看出没有负索引取值
a+24(SP)和40(SP):前者代表a的起始地址在SP上方24字节位置。后者代表的地址为SP上方40字节处。
"".main STEXT size=88 args=0x0 locals=0x30 funcid=0x0
# main函数,ABIInternal代表使用了新的ABI,即不是所有参数都在栈中了,main函数栈帧占48字节
0x0000 00000 (main.go:3) TEXT "".main(SB), ABIInternal, $48-0
# 48可以计算出来,看完后再来理解一下:48 = 局部变量a,b sum参数及返回地址 上一个栈帧BP 一共6个8B即48
# 下面这几行是判断栈空间是否足够。不够进行栈扩容。同样的,GC时可以进行栈缩减
0x0000 00000 (main.go:3) CMPQ SP, 16(R14)
0x0004 00004 (main.go:3) PCDATA $0, $-2
0x0004 00004 (main.go:3) JLS 81
0x0006 00006 (main.go:3) PCDATA $0, $-1
# SP(栈顶)减少48,即为当前栈帧分配48字节。我们读代码时可以对称读,下面必定有个命令是加48
0x0006 00006 (main.go:3) SUBQ $48, SP
# 先保存上一个栈帧的栈底(上一栈帧的起始)
0x000a 00010 (main.go:3) MOVQ BP, 40(SP)
# BP移动到新的栈帧栈底。我们可以发现,其实没有使用FP,如果有FP的话FP的值会为48(SP)。没有FP原因上面也说了。我们需要注意的是不是任何时候FP和伪SP/BP的位置间隔都是一样的。
0x000f 00015 (main.go:3) LEAQ 40(SP), BP
0x0014 00020 (main.go:3) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0014 00020 (main.go:3) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
# 可以看出就算没有优化,也是没有定义再赋值,而是直接给a赋值10
0x0014 00020 (main.go:4) MOVQ $10, "".a+24(SP)
# 给b赋值20
0x001d 00029 (main.go:5) MOVQ $20, "".b+16(SP)
# sum参数之一放到AX寄存器中
0x0026 00038 (main.go:6) MOVQ "".a+24(SP), AX
# 第二个参数放到BX寄存器中
0x002b 00043 (main.go:6) MOVL $20, BX
0x0030 00048 (main.go:6) PCDATA $1, $0
# 调用sum函数。此时我们发现b下面还有16字节,其实是sum的调用参数
0x0030 00048 (main.go:6) CALL "".sum(SB)
# 返回结果存在寄存器AX中,这里存到栈中,可见在局部变量a上面
0x0035 00053 (main.go:6) MOVQ AX, ""..autotmp_2+32(SP)
# a值存在CX
0x003a 00058 (main.go:6) MOVQ "".a+24(SP), CX
# a与结果相加
0x003f 00063 (main.go:6) ADDQ AX, CX
# 相加结果赋值给a
0x0042 00066 (main.go:6) MOVQ CX, "".a+24(SP)
# BP变成上一个栈帧的栈底
0x0047 00071 (main.go:7) MOVQ 40(SP), BP
# 函数调用完成之前,SP回归上一栈帧栈顶
0x004c 00076 (main.go:7) ADDQ $48, SP
# 返回,
0x0050 00080 (main.go:7) RET
# 下面这几行对应上面栈扩容的跳转行。可以看见,栈扩容后又跳转回去重新判断栈是否有爆栈可能性
0x0051 00081 (main.go:7) NOP
0x0051 00081 (main.go:3) PCDATA $1, $-1
0x0051 00081 (main.go:3) PCDATA $0, $-2
0x0051 00081 (main.go:3) CALL runtime.morestack_noctxt(SB)
0x0056 00086 (main.go:3) PCDATA $0, $-1
0x0056 00086 (main.go:3) JMP 0
0x0000 49 3b 66 10 76 4b 48 83 ec 30 48 89 6c 24 28 48 I;f.vKH..0H.l$(H
0x0010 8d 6c 24 28 48 c7 44 24 18 0a 00 00 00 48 c7 44 .l$(H.D$.....H.D
0x0020 24 10 14 00 00 00 48 8b 44 24 18 bb 14 00 00 00 $.....H.D$......
0x0030 e8 00 00 00 00 48 89 44 24 20 48 8b 4c 24 18 48 .....H.D$ H.L$.H
0x0040 01 c1 48 89 4c 24 18 48 8b 6c 24 28 48 83 c4 30 ..H.L$.H.l$(H..0
0x0050 c3 e8 00 00 00 00 eb a8 ........
rel 49+4 t=7 "".sum+0
rel 82+4 t=7 runtime.morestack_noctxt+0
"".sum STEXT nosplit size=56 args=0x10 locals=0x10 funcid=0x0
# 可见sum的栈帧大小为16B,参数大小为16B,存在上一个栈帧
0x0000 00000 (main.go:9) TEXT "".sum(SB), NOSPLIT|ABIInternal, $16-16
# sum函数有NOSPLIT修饰,所以没有栈扩容阶段
0x0000 00000 (main.go:9) SUBQ $16, SP
0x0004 00004 (main.go:9) MOVQ BP, 8(SP)
0x0009 00009 (main.go:9) LEAQ 8(SP), BP
0x000e 00014 (main.go:9) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x000e 00014 (main.go:9) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x000e 00014 (main.go:9) FUNCDATA $5, "".sum.arginfo1(SB)
# 这里注意一下,这里是把main的局部变量a存在AX寄存器中的值移动到了sum的参数a中。
# 而sum的参数a存在main栈帧中,所以可以看出加24。
# 一个偏移24一个偏移32。不是16和24的原因是,CALL和RET会进行隐式的PC/IP寄存器的值存储
0x000e 00014 (main.go:9) MOVQ AX, "".a+24(SP)
0x0013 00019 (main.go:9) MOVQ BX, "".b+32(SP)
# 这个应该是return a + b变成了 r2 = a + b; return r2。先把r2区域置0
0x0018 00024 (main.go:9) MOVQ $0, "".~r2(SP)
# 加法
0x0020 00032 (main.go:10) MOVQ "".a+24(SP), AX
0x0025 00037 (main.go:10) ADDQ "".b+32(SP), AX
0x002a 00042 (main.go:10) MOVQ AX, "".~r2(SP)
0x002e 00046 (main.go:10) MOVQ 8(SP), BP
0x0033 00051 (main.go:10) ADDQ $16, SP
0x0037 00055 (main.go:10) RET
0x0000 48 83 ec 10 48 89 6c 24 08 48 8d 6c 24 08 48 89 H...H.l$.H.l$.H.
0x0010 44 24 18 48 89 5c 24 20 48 c7 04 24 00 00 00 00 D$.H.\$ H..$....
0x0020 48 8b 44 24 18 48 03 44 24 20 48 89 04 24 48 8b H.D$.H.D$ H..$H.
0x0030 6c 24 08 48 83 c4 10 c3 l$.H....
go.cuinfo.packagename. SDWARFCUINFO dupok size=0
0x0000 6d 61 69 6e main
""..inittask SNOPTRDATA size=24
0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0x0010 00 00 00 00 00 00 00 00 ........
gclocals·33cdeccccebe80329f1fdbee7f5874cb SRODATA dupok size=8
0x0000 01 00 00 00 00 00 00 00 ........
"".sum.arginfo1 SRODATA static dupok size=5
0x0000 00 08 08 08 ff .....
- 可能你看了上面的汇编有疑问,不是说1.17开始一些架构ABI改变了吗。为什么还是有寄存器和栈空间中的来回复制。因为上面是加了不优化参数的汇编。当我们去掉-N。就可以看到。sum的栈帧占用内存为0。main栈帧空间也大大缩小(连局部变量a , b都不占用空间了)
- 个人觉得如果看上面的Go汇编没什么阻碍,Go汇编就可以先学到这了,当我们真要到汇编层面找Bug或提升性能时。看不懂再边学边做就行。上来就学习完Go汇编所有细节,这个付出回报比相对于一般人来说是有点低的
# 4. 最后我来绘制一下上面汇编代码中栈内存的情况
------
celler BP (8 bytes)
------ main函数栈帧 BP
sum.ret (8 bytes)
------
main.a (8 bytes)
------
main.b (8 bytes)
------
sum.b (8 bytes)
------
sum.a (8 bytes)
------ main函数栈帧 SP
ret addr (8 bytes)
------
caller(main) BP (8 bytes)
------ sum函数栈帧 BP
临时变量 (8 bytes)
------ sum函数栈帧 SP