在深入学习 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 种:

一切皆文件
  1. 什么是程序?
  2. 什么是进程?
    2.1 程序
程序

2.2 进程

进程线程

创建进程一般使用 fork 方法(通常会有个拉起程序,先 fork 自身生成一个子进程。然后,在该子进程中通过 exec 函数把对应程序加载进来,进而启动目标进程。当然,实际上要复杂得多),而创建线程则是使用 pthread 线程库。

运行时堆(heap)运行时栈(stack)
运行时堆运行时栈

3. Go 汇编

对于 Go 编译器而言,其输出的结果是一种抽象可移植的汇编代码,这种汇编(Go 的汇编是基于 Plan9 的汇编)并不对应某种真实的硬件架构。Go 的汇编器会使用这种伪汇编,再为目标硬件生成具体的机器指令。

伪汇编
Rob PikeThe Design of the Go Assembler
caller-save

3.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.go

4.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-16
8字节*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 进行反编译,获取汇编代码

得到汇编

通过上面操作,可知:

  1. (FP)伪寄存器,只有在编写 Go 汇编代码时使用。FP 伪寄存器指向 caller 传递给 callee 的第一个参数
  2. 使用 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 的汇编有了一定的理解。当然,对于大部分业务开发人员来说,只要看的懂即可。如果想进一步的了解,可以阅读相关的资料或者书籍。

最后想说的是:鉴于个人能力有限,在阅读过程中你可能会发现存在的一些问题或者缺陷,欢迎各位大佬指正。如果感兴趣的话,也可以一起私下交流。