本文将会讨论关于 Go 链接器、目标文件(object file)以及重定位(relocation)相关的内容。
为什么要关注这些东西呢?如果你想学习任何一个大项目的内部机制,那么你首先要做的一件事就是学会将其分割成不同的部件或者模块。接下来,你需要搞懂这些模块向外提供的接口。在 Go 中,编译器、链接器与运行时就是这样的高层次模块。编译器与链接器之间的接口就是目标文件,所以我们今天就从目标文件开始。
生成 Go 目标文件
让我们来做一个实验,写一个非常简单的程序并编译它,再看一下生成的目标文件是什么样的。在这个例子中,我写了这样一段程序:
package main func main() { print(1) }
非常简单明了,不是吗? 现在我们来编译:
go tool 6g test.go
这个命令会生成一个名为 test.6 的目标文件。为了搞清楚这个文件的内部结构,我们会用到 goobj 库。这个库在 Go 的源代码中有用到,它主要被用来实现一些单元测试,以确定是否在各种不同的情况下生成的目标文件都是正确的。为了这篇博客,我写了一个简单的程序将 goobj 库生成的内容输出到终端界面。你可以在这里找到程序的源代码。
首先,你需要下载并安装我的程序:
go get github.com/s-matyukevich/goobj_explorer
接下来执行如下命令:
goobj_explorer -o test.6
现在你就可以在你的终端看到输出的 goob.Package 的结构体了。
探索目标文件
目标文件中最有意思的一部分就是 Syms 数组了。实际上,这是一个符号表。你在程序中定义的所有东西,包括函数、全局变量、类型、常量等等,都写在这个表中。让我们来看一下这个表中存储 main 函数对应的项。(注意:我已经删掉了输出中 Reloc 与 Func 的内容。我们稍后会讨论这两个部分。)
&goobj.Sym{ SymID: goobj.SymID{Name:"main.main", Version:0}, Kind: 1, DupOK: false, Size: 48, Type: goobj.SymID{}, Data: goobj.Data{Offset:137, Size:44}, Reloc: ..., Func: ..., }
goobj.Sym 结构体中各域的命字就已经很好的解释了其本身的含义:
Field | Description/描述 |
---|---|
SymID | 唯一的符号 ID。这个 ID 值包含了符号的名称与版本号。版本信息可以帮助区分同名称的符号。 |
Kind | 标识符号的所属的类型(稍后会有更加详细的介绍) |
DupOK | 标识是否允许符号冗余(同名符号)。 |
Size | 符号数据的大小。 |
Type | 引用另外一个表示符号类型的符号(如果存在)。 |
Data | 包含二进制数据。不同类型的符号该域的含义不同。例如,对于函数该域表示汇编代码,对于字符串符号该域表示原始字符串,等等。 |
Reloc | 重定位列表(稍后会有详细介绍)。 |
Func | 包含函数符号的元数据(稍会有详细介绍)。 |
现在,让我们来看一下各种符号。所有的符号类型都是定义在 goobj 包中的常量(请参考这里)。下面,我列出了其中一部分的常量值:
const ( _ SymKind = iota // readonly, executable STEXT SELFRXSECT // readonly, non-executable STYPE SSTRING SGOSTRING SGOFUNC SRODATA SFUNCTAB STYPELINK SSYMTAB // TODO: move to unmapped section SPCLNTAB SELFROSECT ...
正如我们看到的那样,main.main 符号属于类型 1,对应于 STEXT 常量。STEXT 是一个包含可执行代码的符号。接下来,我们来看一下 Reloc 数组。这个数组包括如下的结构:
type Reloc struct { Offset int Size int Sym SymID Add int Type int }
每个可重定位项意味着 [offset, offset+size] 这个区间的字节需要被一个合适的地址所替代。这个合适的地址可以能过通过将 Sym 符号的地址加上 Add 个字节得到。
深入理解重定位
接下来让我们用一个例子来解释一下重定位是如何工作的。为了演示,我们需要使用 -S 参数编译我们的程序,这样编译器会输出生成的汇编代码:
go tool 6g -S test.go
让我们看一下汇编代码并找到 main 函数。
"".main t=1 size=48 value=0 args=0x0 locals=0x8 0x0000 00000 (test.go:3) TEXT "".main+0(SB),$8-0 0x0000 00000 (test.go:3) MOVQ (TLS),CX 0x0009 00009 (test.go:3) CMPQ SP,16(CX) 0x000d 00013 (test.go:3) JHI ,22 0x000f 00015 (test.go:3) CALL ,runtime.morestack_noctxt(SB) 0x0014 00020 (test.go:3) JMP ,0 0x0016 00022 (test.go:3) SUBQ $8,SP 0x001a 00026 (test.go:3) FUNCDATA $0,gclocals·3280bececceccd33cb74587feedb1f9f+0(SB) 0x001a 00026 (test.go:3) FUNCDATA $1,gclocals·3280bececceccd33cb74587feedb1f9f+0(SB) 0x001a 00026 (test.go:4) MOVQ $1,(SP) 0x0022 00034 (test.go:4) PCDATA $0,$0 0x0022 00034 (test.go:4) CALL ,runtime.printint(SB) 0x0027 00039 (test.go:5) ADDQ $8,SP 0x002b 00043 (test.go:5) RET ,
在后续文章中,我们会更仔细地分析这段代码以弄明白 Go 运行时的工作方式。在这儿,我们只对下面这一行感兴趣:
0x0022 00034 (test.go:4) CALL ,runtime.printint(SB)
这一行指令在函数数据中的 0x0022(十六进制)或者 00034(十进制) 偏移处。这一行实际表示调用 runtime.printint 函数。可是问题在于编译器在编译阶段并不知道 runtime.printint 函数在什么位置。这个符号在另外一个编译器完全不知道的目标文件中。因此,编译器就使用了重定位。下面是对应于这一个函数调用的重定位项(我从 goobj_explorer 工具的输出中拷贝过来的):
{ Offset: 35, Size: 4, Sym: goobj.SymID{Name:"runtime.printint", Version:0}, Add: 0, Type: 3, },
这个重定位项告诉链接器从偏移 35 字节开始的 4 个字节需要替换为 runtime.printint 符号的开始地址。不过,从 main 函数开始的 35 字节偏移的位置上实际上是我们之前看到的调用指令的参数(这个指定令从偏移量 34 字节处开始,其中第一个字节对应 call 指令,随后四个字节是这个指令所需的地址)。
链接器是如何工作的?
弄清楚了重定位是如何工作的之后,我们就能搞懂链接器的工作原理了。下面的概要非常的简单,但是却说明了链接器的工作原理:
- 链接器收集 main 包引用的所有其它包中的符号信息,并将它们装载到一个大的字节数组(或者二进制镜像)中。
- 对于每个符号,链接器计算它在镜像中的地址。
- 然后它为每一个符号应用重定位。这就非常简单了,因为链接器已经知道所有重定位项引用的符号的精确地址。
- 链接器准备所有 ELF 格式(Linux 系统中)文件或者 PE 格式文件(windows 系统中)所需的文件头。然后它再生成一个可执行的文件。
深入理解 TLS
细心的读者可能会从 goobj_explorer 的输出中注意到编译器为 main 方法生成了一个奇怪的重定位条目。它不能对应到任何方法调用甚至是指向了一个空符号:
{ Offset: 5, Size: 4, Sym: goobj.SymID{}, Add: 0, Type: 9, },
那么这个重定位条目是做什么的呢?我们可以看到这个条目的偏移量为 5 字节并且其大小为 4 字节。在这个偏移处,对应的汇编指令为:
0x0000 00000 (test.go:3) MOVQ (TLS),CX
这条指令从 0 偏移处开始并且占 9 字节的空间(因为下一条命令是从 9 字节偏移处开始的)。我们以猜测这个重定位条目会用某个地址替换掉这个奇怪的 TLS ,但是 TLS 到底是什么东西呢?它的地址又是什么呢?
TLS 其实是线程本地存储 (Thread Local Storage )的缩写。这个技术在很多编程语言中都有用到(请参考这里)。简单地说,它为每个线程提供了一个这样的变量,不同变量用于指向不同的内存区域。
在 Go 语言中,TLS 存储了一个 G 结构体的指针。这个指针所指向的结构体包括 Go 例程的内部细节(后面会详细谈到这些内容)。因此,当在不同的例程中访问该变量时,实际访问的是该例程相应的变量所指向的结构体。链接器知道这个变量所在的位置,前面的指令中移动到 CX 寄存器的就是这个变量。对于 AMD64,TLS 是用 FS 寄存器来实现的, 所在我们前面看到的命令实际上可以翻译为 MOVQ FS, CX。
在重定位的最后,我列出了包含所有重定位类型的枚举类型:
// Reloc.type enum { R_ADDR = 1, R_SIZE, R_CALL, // relocation for direct PC-relative call R_CALLARM, // relocation for ARM direct call R_CALLIND, // marker for indirect call (no actual relocating necessary) R_CONST, R_PCREL, R_TLS, R_TLS_LE, // TLS local exec offset from TLS segment register R_TLS_IE, // TLS initial exec offset from TLS base pointer R_GOTOFF, R_PLT0, R_PLT1, R_PLT2, R_USEFIELD, };
正如你从这个枚举类型中可以看到的那样, 重定位类型 3 是 R_CALL,重定位类型 9 是 R_TLS。这些枚举类型的名称很好地解释了我们前面讨论的它们的行为。
更多关于 Go 目标文件的内容
在后续文章中,我们会继续目标文件的讨论。我也会为你提供更多的信息以帮助你来理解 Go 运行时是如何工作的。如果你有任何问题,欢迎你在评论中提出来。
有疑问加站长微信联系(非本文作者)