乐趣区

关于go:关于-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 语言本来

退出移动版