Go: Under the Hood Under the Hood
中文

3.3 Bootstrapping the Language

The Go compiler is written in Go. To compile it, one first needs a Go compiler, and then the question is who compiled that one.

Start with an experiment. Take the source of Go 1.26.10 (with the precompiled programs in the release package removed) and copy it twice. Build one copy with an installed Go 1.26.0 and the other with Go 1.27.3. make.bash is the script that builds Go from source, and the environment variable GOROOT_BOOTSTRAP names the existing Go to use as the starting point (darwin/arm64, paths abbreviated, about 40 seconds per build):

1
2
3
4
5
6
7
8
9
$ GOROOT_BOOTSTRAP=.../go1.27.3 bash make.bash
Building Go cmd/dist using .../go1.27.3. (go1.27.3 darwin/arm64)
Building Go toolchain1 using .../go1.27.3.
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.
---
Installed Go for darwin/arm64 in .../goroot-b

The two builds produce 10 programs in total under bin and pkg/tool, and a byte-by-byte comparison shows that all are identical:

1
2
3
4
5
6
7
$ shasum -a 256 goroot-{a,b}/bin/go goroot-{a,b}/pkg/tool/darwin_arm64/{compile,link} | cut -c1-16,65-
2ff6483c8cefb8fd  goroot-a/bin/go
2ff6483c8cefb8fd  goroot-b/bin/go
115ccaac31d7e4d4  goroot-a/pkg/tool/darwin_arm64/compile
c2deb9df764bc6dc  goroot-a/pkg/tool/darwin_arm64/link
115ccaac31d7e4d4  goroot-b/pkg/tool/darwin_arm64/compile
c2deb9df764bc6dc  goroot-b/pkg/tool/darwin_arm64/link

The starting compilers differ, yet the products are exactly the same. This section answers three questions: where the first Go compiler came from; what toolchain1, 2 and 3 in the output above are and why the result does not depend on the starting point; and why this property matters.

3.3.1 The First Compiler

A compiler involves three languages: the source language it processes (Go), the target language it produces (for example arm64 machine code), and the language in which it is implemented. The first two determine what the compiler does; the third determines how one gets the compiler in the first place.

Up to Go 1.4 (December 2014), the compiler, assembler and linker were all written in C, derived from the Plan 9 C compiler (2.1). Building Go required only a C compiler on the system, and either gcc or clang would do.

Go 1.5 (August 2015) reimplemented all of them in Go [Go Authors, 2015]. The conversion was not a rewrite: Russ Cox wrote a program that translated the C code into Go automatically. The translated code at first kept the style of C and was then gradually rewritten into idiomatic Go. In a 2013 design document, Cox gave the reasons: correct programs are easier to write in Go than in C, and Go comes with testing and profiling tools; the runtime was being migrated from C to Go, and a compiler written in Go would cooperate with it more easily; and contributors would need to know only one language [Cox, 2013].

The cost is that building Go now requires a working Go first [Cox, 2015]. Compiling a compiler with the compiler of the same language is called bootstrapping, a word that comes from the English idiom “to pull oneself up by one’s bootstraps”.

3.3.2 Building the New Version from an Old One

The output of make.bash shows the whole bootstrapping process. Below, the existing Go used as the starting point is called the old Go:

  1. The old Go first compiles cmd/dist, a small program that orchestrates the remaining steps and uses only language features the old Go also supports.
  2. toolchain1: the old Go compiles the source of the new version’s compiler, assembler and linker. The behavior of the resulting compiler is determined by the new source, but its own machine code was generated by the old compiler.
  3. go_bootstrap: toolchain1 compiles the new go command, which takes over the builds from here on.
  4. toolchain2: toolchain1 compiles the same source again.
  5. toolchain3: toolchain2 compiles it once more.
  6. toolchain3 compiles the standard library and the remaining commands.

Step 4 is the key. The code a compiler generates depends only on its source and not on which compiler compiled it. The behavior of toolchain1 is determined by the new source, so toolchain2 is the result of “the new compiler compiling the new source”, and the influence of the old Go ends there.

make.bash -d saves the compiler of each round as compile1, compile2 and compile3. The results of the two builds are:

Starting from Go 1.26.0Starting from Go 1.27.3
compile1 (compiled by the old Go)f04179ffde503a8f21b1c7c8410a0c79
compile2 (compiled by toolchain1)f91490ecbac04f2cf91490ecbac04f2c
compile3 (compiled by toolchain2)115ccaac31d7e4d4115ccaac31d7e4d4

compile1 differs with the starting point, and from compile2 on the results are identical.

compile2 and compile3 differ because of a compile option. The compiler’s source directory holds a profile of the compiler itself, default.pgo, which guides optimization (3.2). When cmd/dist builds toolchain2, it deliberately turns this option off; a comment in the source gives the reason that toolchain2 does not need PGO [Go Authors, 2026b]. It is turned on when toolchain3 is built. Compile the compiler once more with the final compiler and the same options (here go is the Go 1.26.10 just built):

1
2
3
4
$ CGO_ENABLED=0 GOFLAGS="-trimpath -ldflags=-w -gcflags=cmd/...=-dwarf=false" \
    go build -a -o compile4 cmd/compile
$ shasum -a 256 compile4 | cut -c1-16
115ccaac31d7e4d4

The result is still itself. Compiling again with the profile turned off (-pgo=off) gives exactly f91490ecbac04f2c, the compile2. A compiler that compiles its own source and gets itself back is said to have reached a fixed point: however many more times it is compiled, the result does not change. If a compiler cannot generate itself stably, its output depends on something it should not, such as the time or a memory address. GCC likewise uses a three-stage process when built from source and compares the products of the last two stages [GCC, n.d.].

Since toolchain2 is already independent of the old Go, why is toolchain3 needed? A comment in cmd/dist explains that the reason concerns the build ID introduced in 3.1 [Go Authors, 2026b]. toolchain1 is built by the go command of the old Go and contains no build ID, so a toolchain2 with a build ID has to be built. In a development version, however, the build ID of the toolchain enters the computation of the build IDs of its products, so once the compiler is replaced, the go command considers everything built before to be out of date. A release version instead substitutes the version number reported by the toolchain for the build ID, and the problem does not arise. To make the two cases behave alike, cmd/dist uses toolchain2 to force a rebuild of the whole set of tools, which yields toolchain3, whose compiler and linker carry accurate build IDs that the cache can rely on. At the end, make.bash also has the go command check, one by one, that from its point of view the toolchain and the standard library are all up to date and need no rebuild.

The comment at the top of buildid.go explains why the design in 3.1 is necessary here: the compiler is compiled by itself, so if the key of the next step recorded the action ID of the compiler step, the key would change every time the compiler compiled itself and never converge; if it records the digest of the compiler’s content, then once the content converges, the key converges with it.

3.3.3 The Minimum Version of the Starting Point

The Go used as a starting point has a minimum version, which rises over time with the language features and libraries the new source uses [Go Authors, 2026a]:

Version to buildMinimum requirement for the starting point
Go 1.4 and earliera C compiler
Go 1.5 to 1.19Go 1.4
Go 1.20, 1.21Go 1.17.13
Go 1.22, 1.23Go 1.20.6
Go 1.24, 1.25Go 1.22.6
Go 1.26, 1.27Go 1.24.6

Go 1.4 served as the starting point for nearly eight years. It is the last version written in C, and any machine with a C compiler can begin building from it. The cost is that the compiler itself could never use features introduced after Go 1.4, such as generics; Go 1.4 also does not support some platforms that appeared later, such as Macs with Apple silicon. go.dev/issue/44505 and 54265 record the related discussion [Cox, 2021] [Cox, 2022]. Go 1.20 raised the starting point to Go 1.17. From Go 1.22 on, it rises by a fixed rule: building Go 1.N requires Go 1.M, where M is N minus 2, rounded down to an even number. Go releases even-numbered versions in February and odd-numbered versions in August, so the starting point moves forward once a year and is always a version released at least a year earlier. The rule is implemented in requiredBootstrapVersion in cmd/dist [Go Authors, 2026b].

When the version is too old, cmd/dist reports an error as early as possible. Its source directory contains a file notgo124.go, which takes part in compilation only under versions before Go 1.24 and whose package name is building_Go_requires_Go_1_24_6_or_later. When cmd/dist is compiled with a Go that is too old, two different package names appear in the same directory, compilation fails, and the error message contains exactly this sentence. For versions between 1.24 and 1.24.6, cmd/dist checks the version number and reports the error itself. Any Go newer than required can be used; the Go 1.27.3 in the experiment above is one example.

3.3.4 Trusting the Compiler

In 1983 Ken Thompson described an attack in his Turing Award lecture “Reflections on Trusting Trust” [Thompson, 1984]. Modify the source of a C compiler so that when it compiles the login program, it quietly plants a backdoor; further make it so that when it compiles the compiler itself, it plants this same modification into the new compiler. Compile the modified source once to get a compiler with the backdoor, then delete the modification from the source. Nothing in the source looks wrong from then on, yet compiling the clean source with this compiler yields a compiler that still has the backdoor, and it passes from generation to generation. Thompson’s conclusion is that code one has not written entirely oneself cannot be fully trusted, and that inspecting the source is not enough.

No self-hosting language can avoid this problem: today’s Go compiler was compiled by the previous Go compiler, which can be traced all the way back to Go 1.4 written in C and to earlier C compilers. In 2005 David A. Wheeler proposed a way to check this, called diverse double-compiling (DDC) [Wheeler, 2005]: choose an independently sourced compiler, use it to compile the source of the compiler under test, use the resulting compiler to compile the same source once more, and compare the result byte by byte with the binary under test. If the two are identical, the binary was indeed produced from this source, unless both starting points were tampered with in exactly the same way.

The experiment at the beginning of this section has exactly this structure: two different starting points yield the same result. But Go 1.26.0 and Go 1.27.3 come from the same project, so the check is not independent. Go’s source can also be built starting from gccgo, an independent implementation in GCC, which is a starting point of truly different origin [Go Authors, 2026a].

Such a check is possible only because builds are reproducible (3.1): once time, paths or random numbers get mixed into compilation, a byte-by-byte comparison is out of the question. According to Russ Cox’s 2023 article, Go has had fully reproducible toolchains since Go 1.21: the toolchain for a given target platform is byte-for-byte identical no matter which operating system it is built on, and the Go team wrote a tool that rebuilds the official release toolchains from source and compares them with the published ones [Cox, 2023]. The two builds in the experiment above are identical to each other, but they cannot be compared byte by byte with the official downloaded binaries: the official macOS release carries Apple’s code signature (visible with codesign -dv), while a locally built binary has only the ad-hoc signature added by the linker.

Further Reading