关于 Go bootstrapping 的学习记录

Go bootstrap

问题:Go 是怎么来的

在 Go 1.5 版本发布的时候提到:Go 语言实现了 bootstrapping (中文叫自举,或者自展,总之都很拗口)。
那么,这个概念(或者说这项技术),究竟意味着什么呢?
在我曾经看到过的各种资料文章中,bootstrapping 它总是是不是出现一下,而且好像大部分人都对这个重要概念非常熟悉。

对我个人而言,长期以来 bootstrapping 只是「用 Go 语言实现自己」这么一个模糊的概念。
因此,清楚地弄明白这个概念,补足自己的知识盲点,是这篇日志的目的。

和 C 的关系

刚刚开始使用 Go 的时候(Go 1.3),一些相关的工具,无论是 gccgo,还是 6a/6c/6l,或者是 ldd,nm,objdump 这些熟悉的面孔,总是让人容易让人联想到 C 语言。

结合 Go 源码里面大量的 C 代码,我们可以说:

最早的 Go 版本是使用 C 和 plan9 工具链实现的。

先确定一下目标

由于 Go 语言的 runtime (如内存管理和 GC,goroutine 以及调度等),大部分在很早的时候就已经是 Go 语言实现的了。因此,我们说的「Go 怎么来」或者「Go 是用什么写的」其实(应该)指的是:「Go 编译器是怎么实现的」。

我们已经知道的信息:

  1. 最早 Go 编译器是用 C 写的。
  2. 现在的 Go 编译器(及相关工具)是使用 Go 实现。
  3. 1.2. 的过程,被称为 bootstrapping。

另外,为什么要实现 bootstrapping 也是有充足的理由和原因的:

  • Go 比 C 好(更清晰,测试更方便,更容易做 profile,更好写)。
  • 优化工具链的实现,更少代码,更容易维护。
  • 提高可移植性。
  • 未来的后端优化(使用 SSA)要比用 C 写轻松。

关于 bootstrapping 的定义

比较严谨的定义如下:

In computer science, bootstrapping is the technique for producing a self-compiling compiler – that is, a compiler (or assembler) written in the source programming language that it intends to compile.

即:

bootstrapping: 用要编译的目标编程语言编写其编译器。

同时,wiki 上面列举了能够 bootstrapping 的语言(基本上囊括了所有的「正经」程序语言): C/Go/Java/Python/Rust...

回到过去,我们先关注一下 C 语言

在我刚开始学习 C 语言的时候,就想过一个问题:

  • C 编译器是怎么实现的呢?
  • 如果 C编译器 是用 C 语言实现的,那么编译它的编译器又是怎么来的呢?
  • 如果一个平台上没有最初的 C 编译器,那么这个平台的 C 编译器又如何生成呢?

最早的 C 编译器是怎么来的

请参考 The Development of the C Language,这个文档中 Dennis Ritchie 给大家说明了:

  • C 是从 B 语言演化而来,B 是从 BCPL 而来。
  • C 真正实现了代码到机器指令(instructions)的转变,有了自己的编译器(B 则是一种 thread code 的方式实现)
  • 最初的编译器是使用 Thompson 的汇编器,在 PDP-7 上实现的。

所以最早的 C 是不能够 bootstrapping 的。

接下来我们关注一下 GCC 的情况。

GCC bootstrapping

根据文档描述,GCC 的 bootstrapping 流程是这样的:

首先我们需要有一个旧版本的 C 语言编译器,假设是 1.0 版本。现在开发了一个更快的 1.1 版本。当然,这个编译器是使用 C 语言(或者有大量 c++特性的 C 文件)实现的。

  • 使用 1.0的 编译器A 编译 1.1 版本的编译器B
  • 编译器B 本身很慢(编译其他程序时),但是它生成的目标程序跑的很快。
  • 编译器B 如果有 bug,那么有两种可能:

    • 编译器A 的 bug
    • 1.1 版本引入的新 bug。

然后使用 编译器B 编译出一个新的1.1版本的编译器C 出来。编译器C 不仅执行快,生成的目标程序也快。 现在的问题是:编译器C 也可能有 bug,它生成的目标程序可能是有缺陷的,我们要验证它的正确性。

处理方式:

  • 编译器C 再编译(编译器代码)一次,生成编译器D
  • 对比 编译器C编译器D,看看是否是一样的。(实际上是检查 B 和 C 是否等效)

注意这不是执行 test suite,这个是用编译器程序的==特性==来检查自己

疑惑:为什么不比较 B 和 C?
答: B 是1.0编译器生成的,本来就和 C 不一样。要检查的是 B 和 C 生成的目标程序。

疑惑:为什么不去怀疑 B 有问题而是去怀疑 C 有问题呢?

Bootstrapping 需要编译三次,旧编译器 A 生成 B,B生成 C,C 生成 D。

交叉编译器无法 bootstrapping。(如果交叉编译器产生了本平台目标程序,那么他就不是一个交叉编译器了)。

关于上面 GCC 构建步骤的一些解释

有一个术语来描述:3-stage bootstrap

对于上面描述的 GCC bootstrapping 过程,就是一个标准的 3-stage bootstrap。

编译器 B (stage 1) - 编译器 C (stage 2) - 编译器 D(stage 3)

最初的 C 实现的 C 编译器

很多人可能对此比较感兴趣,这部分是我自己的理解:

  • 用汇编语言实现一个C 编译器,然后用这个编译器编译 C 实现的编译器,在用它编译自己,实现 bootstrapping。
  • 用汇编实现一个最小特性的 C 编译器,用这个功能有限的编译器编译编译 C 实现的编译器(该编译器仅仅使用了最小特性来实现),得到的这个编译器,他可以支持 C 的全部功能。然后在用这个编译器编译自己。

不论怎样,我们得到了一个支持全部语言的 C 实现的 C 编译器。

版本依赖问题:

当 C 实现了 bootstrapping 之后,可以通过最初版本的编译器,使用 C 不断开发新的编译器出来,实现编译器的迭代:
1.0 -> 1.1 -> 1.2 -> 1.3...

那么是不是每一个版本的编译器都紧密地依赖上一个版本的编译器,我们需要一点一点的从最初构建到最新的编译器呢?

GCC 的推荐流程是: bootstrapping 的时候不跨大版本,可以这么理解:

  • 1.x 版本的 GCC 可以使用 1.0 编译器构建。
  • 2.0 版本需要使用 1.x 中比较新的编译器构建。
  • 3.x 版本不推荐用 1.x 版本编译器构建。

总得来说,不用一个小版本一个小版本的构建,但也不支持一步到最新。如果我手头只有 6.x 的 GCC 编译器,要构建一个 9.x 版本的的新编译器,需要按照 7.x 8.x 9.x 的顺序迭代构建。

这个和 Go 语言的方式有所不同。为什么会这样呢?

我的理解是:GCC 并不是使用某一标准版本的 C 语言实现的,它自己的代码可能大量的依赖了之前比较临近版本的编译器提供的新特性。

关于 bootstrapping 含义的解释

Go 最早是使用 C,包括一些汇编代码来实现的。随后实现了自举(1.5版本)。
后续的 Go (1.18之前)都可以使用 Go 1.4 构建。

那么你可能会说:这个 Go 1.4 不也还是使用 C和汇编实现的么?
没错,这个 Go 1.4 是依赖 C 编译器的。而且,那怕这个 C 编译器是能够 bootstrapping 的,它必然也会追溯到一个祖宗级 C 编译器。这个祖宗 C 编译器多半是汇编实现的。而这个汇编器一定也依赖 machine instruction。
继续说下去,得有计算机,电子元器件,金属工艺和电……最终你可以得到生命(或者宇宙)的最初点。

所以,不是这么一回事。
只要编译器能够 build 自己,生成的新编译器也可以 build 自己,我们就能称其能 bootstrapping。

回到 Go

Go 的源码大概有这么几个部分:

  • dist: 用来帮助构建过程的工具。
  • 工具链: 编译器、汇编器、链接器。对应之前的 6c/6a/6l
  • runtime 运行时相关(goroutine/GC/内存管理)。
  • 标准库。
  • 一大堆工具。

Bootstrapping 流程:

  1. 准备一个旧版本的 Go (1.x & x >= 4)。
  2. Bootstrap 编译自己。
  3. 新的 bootstrap 编译新版本的 Go。
  4. 新版本的 Go 编译自己。

官方文档的 bootstrapping 描述:

The process to install Go 1.x, for x ≥ 5, is:
    1.  Build cmd/dist with Go 1.4.  -> 旧版本 Go 编译出的 bootstrapping 工具。
    2.  Using dist, build Go 1.x compiler toolchain with Go 1.4.  -> 用第一步的工具,使用 Go 1.4 编译出工具链。
    3.  Using dist, rebuild Go 1.x compiler toolchain with itself. -> 用第二步的工具链把自己再编译一遍(compiler/assembler/linker)。
    4.  Using dist, build Go 1.x cmd/go (as go\_bootstrap) with Go 1.x compiler toolchain. -> 用第三步的工具链编译出 bootstrap 编译器。
    5.  Using go\_bootstrap, build the remaining Go 1.x standard library and commands. -> 用 bootstrap 编译器编译出剩余的新版本 Go 库。

注意:第三步是比较重要的。

下面的日志是我在自己的机器上,用 Go 1.18 编译 Go 1.18 的输出日志:

Building Go cmd/dist using /usr/local/go. (go1.1x darwin/arm64)
Building Go toolchain1 using /usr/local/go.
Building Go bootstrap cmd/go (go_bootstrap) using Go toolchain1.
Building Go toolchain2 using go_bootstrap and Go toolchain1.
Building Go toolchain3 using go_bootstrap and Go toolchain2.
Building packages and commands for darwin/arm64.

关于 Go bootstrapping 的一些有趣的事情

最早的 Go 编译器是用 C 写的,当语言的开发者们决定用 Go 替换掉 C 的时候,他们考虑过

  • A. 重写一遍。
  • B. C 代码变成 Go 代码。

Russ Cox 选择了B: Go from C to Go!,并且实现了一个翻译C代码到 Go 的工具。

他还专门解释了一通:

  • 为啥不完全重新用 Go 写编译器:舍不得。
  • 为啥不照着 C 用 Go 抄一版:

    • 类似的事情实际上干过,但是代码太多;
    • 写起来无聊;
    • 容易出察觉不到的 bug;
    • 还可以继续用 C 改代码(然后自动生成)。

注:我留意到 Go 1.5 release notes 里面有一句话提到自动生成的 Go 代码性能要比认真写的 Go 语言差。

The automatic translation of the compiler and linker from C to Go resulted in unidiomatic Go code that performs poorly compared to well-written Go.

然后,这些自动转化成 Go 代码的 C 代码,在 Go 1.8中就被干掉了 orz

如果你对此有兴趣,可以分别下载 1.3.3, 1.5 和 1.1x 的代码,会发现:

  • 1.3.3: src/cmd/gc 这里有原来的 C 代码。
  • 1.5.x: src/cmd/compile/internal/gc 将 C 代码自动翻译之后的 Go 代码。
  • 1.1x: ,这些翻译过来的 Go 代码已经消失(重新实现)了。

在古老的 Go 源码(<=1.3)里面,有一些继承自 plan9 的古董东西:

名为 Na/Ng/Nc/Nl(N 为数字)的 C 实现的工具:

    5: ARM, 6: AMD64, 8: Intel386
    a: plan9 asm 汇编器
    c: plan9 c 编译器
    g: go 编译器
    l: go 链接器

比如 6c,就是 x86-64 上面的 C 编译器。

使用旧版本 Go 编译出新版 Go 之后,还要再编译一次的原因。

这一步其实比较重要,它能够带来很多好处。

  • 首先,可以检查新版本编译器代码的准确性。
  • 其次,如果新版本的 Go 有优化的话,重新编译之后,编译器本身也可以获得速度提升。

关于编译器的「速度」

  1. 编译编译器的速度。
  2. 编译器执行「编译」操作的速度。
  3. 编译器生成的可执行文件的速度。

很明显,3是至关重要的,而对于开发人员来说,2的提升也是获益极大的,而1,只要你不是某个系统的包管理维护人员,应该是没有人在意的(bootstrapping 会导致这一步速度变慢)。

完整的编译流程:

preprocessor -> lexical analyzer -> parser -> code generator -> local optimizer -> assembler
前端:词法分析,语法分析,类型检查,中间代码生成。 code -> AST (Abstract syntax tree)
后端:代码优化,目标代码生成,目标代码优化。AST -> SSA -> machine code

最初的版本,是使用 bison/yacc 来做前端相关处理,源代码中的 go.y 就是相关的内容。
新版本(go1.8)里面也被替换了,现在是手写的。

关于 Go 的汇编器:

是一种半抽象指令集(semi-abstract instruction set),并不一定和机器指令对应(传统概念的汇编器是和机器指令有很强的对应关系的)。
Assembler 提供一种把 semi-abstract 的指令转成真正的指令并交给 linker 的方法。

关于 bootstrapping 的最小构建版本需求

最初的设计: 1.2 -> 1.3 -> 1.4 ...
然后,在 Go 1.5 版本的时候,确定下来使用 Go 1.4 作为基础版本,后续的所有 Go 代码都可以使用 Go 1.4 版本构建。这样从流程上可以简化 bootstrapping,同时对于打包者来说也更友好。

然后: rsc 大爷又变卦了 build: adopt Go 1.17 as bootstrap toolchain for Go 1.20
六年之后,在 Go 1.19 的时候,切换为 Go 1.17 作为构建 Go 的基础版本,Go 维护者给出升级的原因:

  • 新特性与 bug fix。
  • 有些平台已经不支持 Go 1.4 了。
  • 甩掉一大堆兼容补丁(提到现在的 C 编译器也不是用 ANSI C 写的)。

这不得不让我们想起了前文提到的 GCC 的 bootstrapping 过程。

参考资料:

Go 1.3+ Compiler Overhaul

Go 1.5 Bootstrap Plan

编译相关的知识介绍系列博客

Go in Go

A Quick Guide to Go's Assembler

A Manual for the Plan 9 assembler

Go: Overview of the Compiler

Go 语言原本

你可能感兴趣的:(关于 Go bootstrapping 的学习记录)