[翻译] Go 1.3 链接器大修

Go team 总是能带来一些惊喜的,关于 Go 的链接器,看来在 1.3 版本中要大修了
————翻译分隔线————

Go 1.3 链接器大修

Russ Cox

2013 年 11 月

摘要

在构建和运行一个标准的 Go 程序时,链接器是最慢的一部分。为了解决这个问题,我们计划将链接器拆分到两部分。其中的一部分可能会用 Go 来编写。

背景

链接器总是 Plan 9 工具链中最慢的部分之一,而现在它是 Go 工具链中最慢的部分了。Ken Thompson 在关于工具链的概述中进行了总结:

新的编译器编译迅速、加载缓慢,生成中等质量的目标代码。编译器与移植性相关,对于不同的计算机需要若干星期的工作来构建对应的编译器。对于 Plan 9 来说,需要若干有特定功能、且使用自己的目标格式的编译器,这一项目不可或缺。对于我们来说,编译器必须可以自由的伴随 Plan 9 发行。

在回顾中带来了两个问题。首先是必须对编译器和加载器进行简化。Plan 9 运行在多种处理器上,而这些编译通常会并行完成。不幸的是,在加载进行之前,所有的编译都必须完成。加载是单线程的。在这个模型下,任何用于加载的编译结果的变化都会显著的增加实际时间。着对于那些经常编译和加载的库来说同样如此。未来,我们可能尝试将一些加载工作放到编译器完成。

这篇文档编写于上世纪 90 年代初期。现在就是未来。

建议规划

当前的链接器执行两个独立的任务。首先,它伴随定位表,将伪指令输入流翻译为可执行代码和数据块。然后,它删除无用代码,合并其他到单一的镜像中、重新处理定位,并且生成一些例如运行时符号表这样的完整程序数据结构。

第一部分可以分解到一个库(liblink)中,这样就可以同汇编器与编译器联合。那些 6a、6c 或 6g 等等输出的目标文件可以由 liblink 输出,且包含可执行代码、数据块与定位表,即当前链接器的第一个中间产物。

第二部分可以由移除 liblink 之后的链接器处理。剩下的程序可以读取新的目标文件并完成链接。这个链接器只有很少的代码,大部分是与架构无关的。这使得将其合并为一个与平台无关的程序,然后像“go tool ld”这样调用成为可能。甚至使用 Go 来编写它,使其在大型链接的时候更加容易并行化。(参阅下面的章节了解如何开始。)

一开始,我们将集中精力使新剥离的部分能用于 C 代码。一旦所有修改完成,就开始 Go 的探索。

为了避免影响可用性,生成的目标文件将保持已有的扩展名 .5、.6、.8。可能在 Go 1.3 中,新的链接器可以包含叫做 5l、6l 和 8l 的过渡性程序。这些过渡程序将在 Go 1.4 中移除。

目标文件

新的拆分需要新的目标文件格式。当前的目标文件包含伪指令流,但是新的目标文件将包含可执行代码以及伴随定位表的数据块。

一个自然的问题是应当使用已有的目标文件格式吗,例如 ELF。首先,我们将使用定制的格式。为了构建像运行时符号表这样的运行时数据结构,需要定制一个 Go 的链接器,因此即使使用 ELF 目标文件,也无法重用标准的 ELF 链接器。ELF 文件也包含了大量超过 Go 定制的链接器所需要的通用语义和 ELF 语义。一个定制的,不那么通用的目标文件格式可以让生成与使用更加简单。另一方面,ELF 可以由如 readelf、objdump 等等的标准工具处理。不过,一旦尘埃落定,当我们知道到底这个格式需要什么的时候,还是值得确认一下使用 ELF 能否满足需求。

新的目标文件的细节还未完成。这个章节的剩余部分列出了一些设计的思路。

显然,这个文件越简单越好。除了一些例外,链接器的工作都应当在中间库中完成。可能会神奇地包含栈划分代码,这使得目标文件是操作系统依赖的,虽然它们在包中已经是基于依赖操作系统的 Go 代码了。同时软件的浮点工作也在中间库中完成,使得 ARM 目标文件是特定的 GOARM(当前在链接器运行之前,没有任何东西特定是 GOARM 的)。
我们需要确保目标文件是可以通过 mmap 使用的。这可以降低复制带来的 I/O。这需要修改 Go 的运行时对于非 nil 的地址产生 SIGSEGV 时,产生一个 panic 而不是崩溃掉。
纯 Go 包由 Go 编译器在一个 Go 代码文件完整的集合上一次产生一个目标文件。接下来这个目标文件被封装到一个打包文件中。我们可以整理这个单一的目标文件也是一个合法的打包文件,那么通常情况下就无需封装的步骤。

启动

如果新的 Go 链接器是用 Go 编写的,就有一个启动问题:如何链接这个链接器?这里有两个方案。

第一个方案是维护一个 CL 启动列表。队列中的第一个 CL 应当包含当前的链接器,由 C 编写。每个步骤都是一个 CL,包含了新的链接器用来链接下一个。队列的最后一个二进制结果就可以用于下载了。队列不能太长,并且能贴合里程碑。例如,可以让 Go 1.3 的链接器可以由 Go 1.2 的程序进行编译,Go 1.4 的链接器可以由 Go 1.3 的程序进行编译,等等。队列的记录确保了如果需要,可以重新启动,不过也得提供对付信任依赖问题的机制。另一个启动的方法是使用 gccgo,然后使用它来编译 Go 1.3 链接器。

第二个方案是在用 Go 写了一个更好的链接器之后,也仍然维护 C 的版本,并且保持大多数功能对等。用 C 编写的版本只需要保持足够的功能来链接 Go 编写的这个。它需要加载一些目标文件,合并它们,然后输出可执行文件。无需 cgo 支持、无需外部链接、无需共享库、无需高性能。只需要一些朴素的代码(可能只有几千行代码)并且不需要经常修改。C 版本会在 make.bash 的时候构建和使用,但不会被安装。这个方案对于其他从 Go 源码构建的开发者来说更加容易。

我们选择哪个方案并不重要,无论怎样都至少会有一个可用的方案。事情继续推进,我们就可以决定。

Leave a comment

Your email address will not be published. Required fields are marked *