Go语言内幕(3):链接器、链接器、重定位 | 区块链大学-演道网

本文由 伯乐在线 – yhx 翻译,黄利民 校稿。
英文出处:Sergey Matyukevich。

本文将会讨论关于 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:  ...,
}
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 运行时是如何工作的。如果你有任何问题,欢迎你在评论中提出来。

转载自演道,想查看更及时的互联网产品技术热点文章请点击http://go2live.cn