在深入学习 Golang 的 runtime 和标准库实现的时候发现,如果对 Golang 汇编没有一定了解的话,很难深入了解其底层实现机制。在这里整理总结了一份基础的 Golang 汇编入门知识,通过学习之后能够对其底层实现有一定的认识。
0. 为什么写本文
平时业务中一直使用 PHP 编写代码,但是一直对 Golang 比较感兴趣,闲暇、周末之余会看一些 Go 底层源码。
近日在分析 go 的某些特性底层功能实现时发现:有些又跟 runtime 运行时有关,而要掌握这一部分的话,有一道坎是绕不过去的,那就是 Go 汇编。索性就查阅了很多大佬们写的资料,在阅读之余整理总结了一下,并在这里分享给大家。
本文使用 Go 版本为 go1.14.1
1. 为什么需要汇编
众所周知,在计算机的世界里,只有 2 种类型。那就是:0 和 1。
计算机工作是由一系列的机器指令进行驱动的,这些指令又是一组二进制数字,其对应计算机的高低电平。而这些机器指令的集合就是机器语言,这些机器语言在最底层是与硬件一一对应的。
可阅读性太差为了解决可读性的问题以及代码编辑的需求,于是就诞生了最接近机器的语言:汇编语言(在我看来,汇编语言更像一种助记符,这些人们容易记住的每一条助记符都映射着一条不容易记住的由 0、1 组成的机器指令。你觉得像不像域名与 IP 地址的关系呢?)。
1.1 程序的编译过程
以 C 语言为例来说,从 hello.c 的源码文件到 hello 可执行文件,经过编译器处理,大致分为几个阶段:
编译器在不同的阶段会做不同的事情,但是有一步是可以确定的,那就是:源码会被编译成汇编,最后才是二进制。
2. 程序与进程
文件文件在 Linux 中文件类型大致分为 7 种:
一切皆文件- 什么是程序?
- 什么是进程?
2.1 程序
程序2.2 进程
进程线程创建进程一般使用 fork 方法(通常会有个拉起程序,先 fork 自身生成一个子进程。然后,在该子进程中通过 exec 函数把对应程序加载进来,进而启动目标进程。当然,实际上要复杂得多),而创建线程则是使用 pthread 线程库。
运行时堆(heap)运行时栈(stack)运行时堆运行时栈3. Go 汇编
对于 Go 编译器而言,其输出的结果是一种抽象可移植的汇编代码,这种汇编(Go 的汇编是基于 Plan9 的汇编)并不对应某种真实的硬件架构。Go 的汇编器会使用这种伪汇编,再为目标硬件生成具体的机器指令。
伪汇编Rob PikeThe Design of the Go Assemblercaller-save3.1 几个概念
在深入了解 Go 汇编之前,需要知道的几个概念:
- 栈:进程、线程、goroutine 都有自己的调用栈,先进后出(FILO)
- 栈帧:可以理解是函数调用时,在栈上为函数所分配的内存区域
- 调用者:caller,比如:A 函数调用了 B 函数,那么 A 就是调用者
- 被调者:callee,比如:A 函数调用了 B 函数,那么 B 就是被调者
3.2 Go 的核心寄存器
go 汇编中有 4 个核心的伪寄存器,这 4 个寄存器是编译器用来维护上下文、特殊标识等作用的:
| 寄存器 | 说明 |
|---|
symbol+offset(FP)arg0+0(FP),arg1+8(FP)symbol+offset(FP)务必注意3.2.1 伪寄存器的内存模型
下图描述了栈桢与各个寄存器的内存关系模型,值得注意的是要站在 callee 的角度来看
有一点需要注意的是,return addr 也是在 caller 的栈上的,不过往栈上插 return addr 的过程是由 CALL 指令完成的(在分析汇编时,是看不到关于 addr 相关空间信息的。在分配栈空间时,addr 所占用空间大小不包含在栈帧大小内)。
在 AMD64 环境,伪 PC 寄存器其实是 IP 指令计数器寄存器的别名。伪 FP 寄存器对应的是 caller 函数的帧指针,一般用来访问 callee 函数的入参参数和返回值。伪 SP 栈指针对应的是当前 callee 函数栈帧的底部(不包括参数和返回值部分),一般用于定位局部变量。伪 SP 是一个比较特殊的寄存器,因为还存在一个同名的 SP 真寄存器,真 SP 寄存器对应的是栈的顶部。
在编写 Go 汇编时,当需要区分伪寄存器和真寄存器的时候只需要记住一点:伪寄存器一般需要一个标识符和偏移量为前缀,如果没有标识符前缀则是真寄存器。比如(SP)、+8(SP)没有标识符前缀为真 SP 寄存器,而 a(SP)、b+8(SP)有标识符为前缀表示伪寄存器。
3.2.2 几点说明
我们这里对容易混淆的几点简单进行说明:
- 伪 SP 和硬件 SP 不是一回事,在手写汇编代码时,伪 SP 和硬件 SP 的区分方法是看该 SP 前是否有 symbol。如果有 symbol,那么即为伪寄存器,如果没有,那么说明是硬件 SP 寄存器。
- 伪 SP 和 FP 的相对位置是会变的,所以不应该尝试用伪 SP 寄存器去找那些用 FP+offset 来引用的值,例如函数的入参和返回值。
- 官方文档中说的伪 SP 指向 stack 的 top,可能是有问题的。其指向的局部变量位置实际上是整个栈的栈底(除 caller BP 之外),所以说 bottom 更合适一些。
- 在 go tool objdump/go tool compile -S 输出的代码中,是没有伪 SP 和 FP 寄存器的,我们上面说的区分伪 SP 和硬件 SP 寄存器的方法,对于上述两个命令的输出结果是没法使用的。在编译和反汇编的结果中,只有真实的 SP 寄存器。
3.2.3 IA64 和 plan9 的对应关系
在 plan9 汇编里还可以直接使用的 amd64 的通用寄存器,应用代码层面会用到的通用寄存器主要是: rax, rbx, rcx, rdx, rdi, rsi, r8~r15 这些寄存器,虽然 rbp 和 rsp 也可以用,不过 bp 和 sp 会被用来管理栈顶和栈底,最好不要拿来进行运算。
plan9 中使用寄存器不需要带 r 或 e 的前缀,例如 rax,只要写 AX 即可: MOVQ $101, AX = mov rax, 101
下面是通用通用寄存器的名字在 IA64 和 plan9 中的对应关系:
3.3 常用操作指令
Q| 助记符 | 指令种类 | 用途 | 示例 |
|---|
4. 汇编分析
说了那么多,it is code show time。
4.1 如何输出 Go 汇编
对于写好的 go 源码,生成对应的 Go 汇编,大概有下面几种
go build -gcflags "-N -l" main.gogo tool objdump -s "main." main"main.""main.main"go tool compile -S -N -l main.gogo build -gcflags="-N -l -S" main.go4.2 Go 汇编示例
go 示例代码
编译 go 源代码,输出汇编
截取主要汇编如下:
加法4.3 Go 汇编解析
针对 4.2 输出汇编,对重要核心代码进行分析。
4.3.1 add 函数汇编解析
TEXT "".add(SB), NOSPLIT|ABIInternal, $16-24TEXT "".add"".add"""".add(SB)"".add(SB)NOSPLIT:$0-168字节*2返回值8字节SUBQ $16, SPMOVQ $0, "".~r2+40(SP)MOVQ "".a+24(SP), AXADDQ "".b+32(SP), AXMOVQ AX, "".~r2+40(SP)ADDQ $16, SPcaller-save在函数栈桢结构中可以看到,add()函数的入参以及返回值都由调用者 main()函数维护。也正是因为如此,GO 有了其他语言不具有的,支持多个返回值的特性。
4.4 Go 汇编语法
这里重点讲一下函数声明、变量声明。
4.4.1 函数声明
来看一个典型的 Go 汇编函数定义
TEXT""··.runtime·mainruntime.main简单总结一下, Go 汇编实现函数声明,格式为:
"".add(SB)4.4.2 变量声明
.rodata.data大多数参数都是字面意思,不过这个 offset 需要注意:其含义是该值相对于符号 symbol 的偏移,而不是相对于全局某个地址的偏移。
GLOBL 汇编指令用于定义名为 symbol 的全局变量,变量对应的内存宽度为 width,内存宽度部分必须用常量初始化。
下面是定义了多个变量的例子:
<>5. 手写汇编实现功能
在 Go 源码中会看到一些汇编写的代码,这些代码跟其他 go 代码一起组成了整个 go 的底层功能实现。下面,我们通过一个简单的 Go 汇编代码示例来实现两数相加功能。
5.1 使用 Go 汇编实现 add 函数
Go 代码
Go 源码中 add()函数只有函数签名,没有具体的实现(使用 GO 汇编实现)
使用 Go 汇编实现的 add()函数
把 Go 源码与 Go 汇编编译到一起(我这里,这两个文件在同一个目录)
我这里目录为 demo1,所以得到可执行程序 demo1,运行得到结果:5
5.2 反编译可执行程序
对 5.1 中得到的可执行程序 demo1 使用 objdump 进行反编译,获取汇编代码
得到汇编
通过上面操作,可知:
- (FP)伪寄存器,只有在编写 Go 汇编代码时使用。FP 伪寄存器指向 caller 传递给 callee 的第一个参数
- 使用 go tool compile / go tool objdump 得到的汇编中看不到(FP)寄存器的踪影
6. Go 调试工具
这里推荐 2 个 Go 代码调试工具。
6.1 gdb 调试 Go 代码
测试代码
go build -gcflags "-N -l" -o main常用的 gdb 调试命令
- run
- continue
- break
- backtrace 与 frame
- info break、locals
- list 命令
- print 和 ptype 命令
- disass
除了 gdb,另外推荐一款 gdb 的增强版调试工具 cgdb
https://cgdb.github.io/
效果如下图所示,分两个窗口:上面显示源代码,下面是具体的命令行调试界面(跟 gdb 一样):
6.2 delve 调试代码
delve 项目地址
带图形化界面的 dlv 项目地址
dlv 的安装使用,这里不再做过多讲解,感兴趣的可以尝试一下。
- gdb 作为调试工具自是不用多说,比较老牌、强大,可以支持多种语言。
- delve 则是使用 go 语言开发的,用来调试 go 的工具,功能也是十分强大,打印结果可以显示 gdb 支持不了的东西,这里不再做过多讲解,有兴趣的可以查阅相关资料。
7. 总结
对于 Go 汇编基础大致需要熟悉下面几个方面:
通过上面的例子相信已经让你对 Go 的汇编有了一定的理解。当然,对于大部分业务开发人员来说,只要看的懂即可。如果想进一步的了解,可以阅读相关的资料或者书籍。
最后想说的是:鉴于个人能力有限,在阅读过程中你可能会发现存在的一些问题或者缺陷,欢迎各位大佬指正。如果感兴趣的话,也可以一起私下交流。