为什么写这个系列
这个系列的文章是希望能帮助读者从Go汇编的层面更好的理解一个Golang程序。笔者刚工作的时候用的是C语言,深刻的感到理解一个程序的汇编代码对于编程的是非常有帮助的(这个也是读了深入理解计算机系统之后才意识到这个问题)。所以,笔者觉得,要想更好的使用Golang,也应该理解其汇编层面的东西(这里说的汇编是指Golang工具链使用的汇编格式)。
环境
Golang自带的工具就能够帮助我们理解其汇编代码了,我们只需要建立一个简单的编程环境即可。 Golang上个月刚刚发布了1.7版本,所以本系列也会使用go1.7。主要的环境设置如下:
然后,我们建议一个本地的代码目录用来存放实验代码:
后面的实验代码都会存放在 ${GOPATH}/goexamples/src这个目录下。
编译和反汇编
编译Golang程序,一般是用 go build工具,不过为了查看汇编代码,我们需要使用 go tool compile工具。其实 go build也是使用了 go tool compile和 go tool link工具,一个负责编译,一个负责链接。
先写个 hello, world 程序 goexamples/hello/hello.go ,在程序里定义一个看起来没用的 foo()
函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| package main
import (
"fmt"
)
func foo() int {
var a int
return a
}
func main() {
fmt.Printf("hello, world\n")
}
|
接下来,我们可以执行编译该文件来得到该文件的汇编代码,执行命令go tool compile -S hello.go
:
现在我们得到了这个文件的汇编代码,可以看到这个代码里不仅有汇编指令,还有很多Golang实现相关的东西。现在,作为起步,我们先来看一些基本的内容:
上面两行就是我们定义的两个函数:main()
和foo()
,跟在这两行后面的是两个函数的内容。为了方便阐述,这里会把要研究的代码都写在foo()
函数内。
接下来,我们需要 关系编译优化。Golang的编译器在编译的时候会执行一些默认的优化,这会提升性能和节约空间,但是会使得汇编代码和Golang代码对应不起来,所以我们要先关闭这个优化,然后才能更好的研究汇编代码。关闭优化需要传递 -N选项给编译器:
这样得到的汇编输出会和前面略有不同,可以通过对比foo()
函数的汇编代码来看到这些不同点:
有优化
无优化
可以看出,无优化的代码更长,包含了函数完整的栈帧操作。后续我们会一步一步的研究Golang的代码和无优化的汇编代码之间的关系,以帮助读者更好的理解Golang。
Golang汇编基础
Golang的汇编器采用的是Plan 9的汇编语法,官方有一篇关于Golang汇编器的快速入门:A Quick Guide to Go’s Assembler,其中介绍了一些简单的语法。在开始研究Golang的汇编代码之前,我们也需要先了解一下基本的语法。
伪寄存器
Golang汇编中有4个伪寄存器,这些伪寄存器是由Golang工具链维护的。这4个伪寄存器是:
- FP: Frame pointer:栈帧寄存器,指向参数和本地变量。
- PC:Program counter:程序计数器,用来进行跳转处理。
- SB:Static base pointer:全局符号表。
- SP:Stack pointer:栈顶指针。
SB寄存器的常用语法有以下几种:
- foo(SB)是全局变量foo的地址
- foo<>(SB)是只在当前源文件可见的变量foo的地址
- foo+4(SB)是全局变量foo的地址偏移4字节的地址
FP寄存器是用来访问函数参数的,理论上来说,0(FP)是第一个参数,8(FP)是第二个参数(64位系统)。但是,实际使用时,需要把参数名加进去:
- first_arg+0(FP)
- second_arg+8(FP)
SP寄存器是一个虚拟的栈指针,用于指向当前栈帧的栈顶,用于访问局部变量和函数参数(除了 FP,这个也可以访问到函数参数)。SP是指向栈顶的,所以访问数据通常是做减法:
注意,这里的两个不同语法。在有 SP寄存器的机器上,带有一个名称前缀的语法表示访问Golang虚拟的SP寄存器,而不带名称前缀的则是访问硬件的SP寄存器。
寻址模式
说到汇编,就不得不提到寻址模式。Plan 9汇编的寻址模式挺多的,具体的可以看A Manual for Plan 9 assembler。这里我们列举一些常用的:
- $con 常量
- $fcon 浮点数常量
- name+o(SB) 外部符号
- name<>+o(SB) 本地符号
- name+o(FP) 函数参数
- $name+o(SB) 外部符号地址
- $name<>+o(SB) 本地符号地址
o表示整数偏移量。
指令
下面来介绍一下上文代码中的foo()
函数的汇编代码中用到的汇编指令:
TEXT是一个伪指令,表示定义一个入口点,也就是定义一个函数。它可以带三个参数:
- 第一个参数表示函数名,就是这里的
"".foo(SB)
。
- 第二个参数是flag,这里没有体现出来,以后的文章再说。
- 第三个参数是frame size,表示当前函数的栈帧大小以及参数大小,形式如同
$framesize-argsize
,减号前面是栈帧大小,减号后面是参数大小(包括返回值)。比如$16-8
表示该函数栈帧大小为16,参数大小为8。
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
这是一段入栈的代码。在这段代码执行前,SP指针指向调用函数,这里分配栈帧,并且保存函数返回地址,也就是 BP寄存器的内容到栈的开始部分,然后设置新的 BP寄存器的值。随后就可以开始执行这个函数了。
BP寄存器是 base pointer ,一般用于指向当前栈帧的某个位置,和 SP 寄存器配合完成函数调用的操作。
这里的三个指令都是常用指令,格式都是源操作数在前,目标操作数在后:
- SUBQ 减
- MOVQ 拷贝
- LEAQ 载入有效地址。LEA的全称是 Load Effective Address,LEA指令计算源操作数所表示的地址的值,然后保存在目标操作数中。有时候LEA指令也会被用来进行简单的无符号计算(支持加、减、乘)。关于LEA指令的更多资料,可以看这里X86 Assembly/Data Transfer。
FUNCDATA $0, gclocals·5184031d3a32a42d85027f073f873668(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
这两行是用于保存GC所需要的数据。相关指定以后再介绍。
MOVQ $0, "".~r0+24(FP)
MOVQ $0, "".a(SP)
MOVQ $0, "".~r0+24(FP)
这里没有新的指令,赋值操作实现了返回a
变量。
MOVQ 8(SP), BP
ADDQ $16, SP
这些是出栈的代码,通过把 SP 寄存器和 BP 寄存器设置为调用函数的值来完成返回。