Go语言内幕(3):链接器、链接器、重定位

伯乐在线 · · 4840 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

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


有疑问加站长微信联系(非本文作者)

本文来自:伯乐在线

感谢作者:伯乐在线

查看原文:Go语言内幕(3):链接器、链接器、重定位

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

4840 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传