在此博客文章中,我们将介绍Go链接器,Go对象文件和重定位。 我们为什么要关心这些事情? 好吧,如果您想学习任何大型项目的内部知识,则需要做的第一件事就是将其拆分为组件或模块。 其次,您需要了解这些模块相互提供的接口。 在Go中,这些高级模块是编译器,链接器和运行时。 编译器提供并链接器使用的接口是一个目标文件,今天我们将在这里进行研究。
生成Go对象文件
让我们做一个实际的实验-编写一个超级简单的程序,对其进行编译,然后查看将生成哪个目标文件。在我们的例子中,程序如下
package main
func main() {
print(1)
}
现在我们需要对其进行编译
go tool compile -N -l -S ./main.go > main.s
该命令产生test.6
目标文件。为了研究其内部结构,我们将使用goobj库。它在Go源代码内部使用,主要用于实现一组单元测试,以验证在不同情况下是否正确生成了目标文件。对于此博文,我们编写了一个非常简单的程序,该程序将从googj库生成的输出打印到控制台。您可以在此GitHub存储库中查看该程序的源代码。
首先,您需要下载并安装该程序。
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.Sum
结构中字段的名称非常不言自明。
The names of the fields in the goobj.Sum
structure are pretty self-explanatory.
Field | Description |
---|---|
SumID | 由符号名称和版本组成的唯一符号ID。版本有助于区分名称相同的符号。 |
Kind | 指示符号属于哪种类型(稍后会有更多详细信息)。 |
DupOK | DupOK |
Size | 符号数据的大小。 |
Type | 对表示一个符号类型(如果有)的另一个符号的引用。 |
Data | 包含二进制数据。对于不同种类的符号,此字段具有不同的含义,例如,功能的汇编代码,字符串符号的原始字符串内容等。 |
Reloc | 重定位列表(稍后将提供更多详细信息)。 |
Func | 包含功能符号的特殊功能元数据(请参见下面的更多详细信息)。 |
现在,让我们看一下不同种类的符号。所有可能的符号类型都在goobj
包中定义为常量(您可以在GitHub存储库中找到它们)。下面,我们复制了这些常量的第一部分。
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
符号属于与STEXT
常量相对应的种类1 。STEXT
是包含可执行代码的符号。现在,让我们看一下Reloc
数组。它由以下结构组成。
type Reloc struct {
Offset int
Size int
Sym SymID
Add int
Type int
}
每次重定位都意味着[Offset, Offset+Size]
应将位于该间隔的字节替换为指定的地址。该地址是通过将Sym
符号的位置与Add
字节数相加得出的。
了解relocations
go tool compile -N -l -S ./main.go > main.s
让我们浏览一下汇编器并尝试找到主要功能。
"".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个字节的偏移量开始,它需要用runtime.printint
符号起始点的地址替换4个字节的数据。但是,与主函数数据的偏移量为35个字节实际上是我们先前所见的调用指令的参数。(该指令从一个34字节的偏移量开始。一个字节对应于调用指令代码,而四个字节则指向该指令的地址。)
链接器如何操作
现在我们了解了这一点,我们可以弄清楚链接器是如何工作的。以下架构非常简化,但反映了主要思想。
- 链接器从主程序包引用的所有程序包中收集所有符号,并将它们加载到一个大字节数组(或二进制映像)中。
- 对于每个符号,链接器都会在此图像中计算一个地址。
- 然后,它应用为每个符号定义的重定位。现在很容易,因为链接器知道从那些重定位引用的所有其他符号的确切地址。
- 链接器为Linux上的可执行和可链接(ELF)格式或Windows上的可移植可执行(PE)格式准备所有必需的标头。然后,它生成一个带有结果的可执行文件。
了解TLS
细心的读者会注意到goobj_explorer utility
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是“线程本地存储”的缩写。这项技术已在许多编程语言中使用。简而言之,它使我们能够拥有一个变量,该变量在由不同线程使用时指向不同的内存位置。
在Go中,TLS用于存储指向G结构的指针,该G结构包含特定Go例程的内部详细信息(有关更多详细信息,请参见后面的博客文章)。因此,有一个变量(当从不同的Go例程访问时)始终指向具有此Go例程的内部详细信息的结构。链接器知道此变量的位置,而该变量正是上一条命令中移至CX寄存器的内容。TLS可以针对不同的体系结构以不同的方式实现。对于AMD64,TLS是通过FS
寄存器实现的,因此我们之前的命令被转换为MOVQ FS
和CX
。
为了结束对重定位的讨论,我们将向您展示包含所有不同类型的重定位的枚举类型(enum
)。
// 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
。这些enum
名称完美地解释了我们前面讨论的行为。
在下一篇文章中,我们将继续讨论目标文件。我们还将为您提供更多必要的信息,以使您继续前进并了解Go运行时的工作方式。如果您有任何疑问,请随时在评论中提问。