之前有读者问过我一个问题:编译器是怎么编译自己的?这看起来像一句绕口令,其实是编译器领域最经典的“自举(bootstrap)”问题。本文围绕“编译器的自举”展开,梳理自举的含义、实现思路、业界经典案例,并且给出一个可以运行的最小自举演示。适合对编译原理感兴趣、准备入门编译器开发,或者在工作中遇到工具链引导问题的开发者阅读。读完你至少能回答三个问题:编译器为什么能编译自己、第一个编译器怎么来的、自举成功如何验证。
1. 什么是编译器自举
1.1 编译器在做什么
先明确一个基础概念:编译器是一个把“人类容易读的源代码”翻译成“机器或其他目标代码”的程序。最简单的流程是:
源代码 -> 编译器 -> 目标代码目标代码可能是汇编、字节码、另一种高级语言,甚至直接是机器码。Java 编译器把.java编译成.class字节码,GCC 把.c编译成可执行文件,这些都是编译器的典型工作。
编译器本身也是一个程序,所以它也需要被运行。而它运行之前,也必须有某种方式被“翻译”成可执行的形式。于是问题就来了:编译器是用什么语言写的?又是被谁编译的?
1.2 “自己编译自己”是什么意思
假设你写了一个新语言 X,并且用 X 语言本身写了一个 X 语言的编译器,那么这个编译器在编译别人写的 X 语言代码时,顺便也能编译自己的源码。此时,X 编译器就实现了“自己编译自己”,这就是编译器自举。
自举的状态可以简单表示为:
编译器 C 的源码 -> 编译器 C 的可执行程序 ^ | | (C 编译 C 的源码) +------------------+当你修改了 C 语言本身,并且用修改后的编译器源码重新编译了一遍自己,新得到的编译器就包含了最新特性。这个闭环一旦建立,语言和编译器就可以不依赖外部语言,持续迭代下去。
1.3 先避开两个误区
很多人第一次看到“自举”这个词,容易联想到两个完全无关的概念,这里单独说明一下,避免后续混淆。
第一个是“编辑器和编译器”的区别。编辑器是写代码的工具,比如 VS Code、Vim;编译器是把代码变成目标程序的工具。自举说的是编译器,不是编辑器。编辑器自己修改自己,那是另一个话题。
第二个是“自举电容”。自举电容是电子电路里的概念,常见于驱动电路设计,利用电容储能来抬升驱动电压。这和编译器自举除了名字相同,没有任何关系。在网络搜索“自举”时,会经常看到自举电容、自举电路这一堆结果,它们属于完全不同的技术方向。
2. 没有编译器时,第一个编译器从哪来
2.1 鸡生蛋问题
如果“编译器编译自己”成立,那么必然存在一个最初的编译器。最开始的编译器不可能是“自己编译自己”产生的,因为那时还没有它。所以整个自举过程必须从一个“外部引导编译器”开始。
最原始的机器语言程序是手工写二进制或助记符汇编语言。早期开发者先用汇编语言写了一个很简单的 C 编译器,这个编译器功能弱,但已经能编译一部分 C 代码。然后,开发者用这个弱编译器去编译一个更强的 C 编译器源码,得到更强的编译器。接下来,更强的编译器再去编译更完整版本的 C 编译器源码。如此循环,编译器能力越来越强,直到完全支持目标语言。
2.2 引导编译器与交叉编译
“引导编译器”就是指用于启动自举过程的那个初始编译器。它不一定是目标语言实现的,可以用汇编、C 或其他现有语言实现。引导编译器只要能编译目标语言的一个子集就够了,不需要一开始就支持全部语法。
这个过程和交叉编译也有关系。交叉编译是指在一种平台上编译另一种平台的可执行程序。比如在 x86 机器上编译 ARM 程序。在自举过程中,经常先用交叉编译器生成一个运行在目标平台上的引导编译器,再用这个引导编译器在目标平台上编译完整编译器,最终实现目标平台上的原生自举。
2.3 用 T 型图理解两层编译关系
学习编译器时经常会看到 T 型图。它用三列信息表示一次编译转换:
源语言 目标语言 \ / \ / 编译器实现语言例如,一个用 C 语言实现、能把 C 源码编译成 x86 汇编的编译器,可以画成:
C x86汇编 \ / \ / C自举的关键点是:编译器实现语言 = 编译器源语言。也就是 T 型图中间那一行和左边那一行相同。
当一个编译器满足“用 L 语言编写、能够编译 L 语言代码”时,它就具备了自举能力。此时,即使外部引导编译器消失,这个编译器仍然可以靠自身源码重新编译出新版本。
3. 为什么要做自举
3.1 验证编译器自身的正确性
自举是验证编译器正确性的一个强有力手段。如果编译器存在严重错误,它在编译自己的源码时很容易表现出来:语法分析错误、代码生成错误、运行崩溃、生成的可执行程序行为不一致等。
让编译器编译一份较复杂且逻辑严密的自举源码,相当于给它做了一次高强度压力测试。比起只编译教科书上的示例代码,自举测试覆盖了更多路径,因为编译器源码本身包含字符串处理、树形结构、递归、状态机等复杂逻辑。
3.2 摆脱外部工具链的束缚
一种新语言如果一直依赖 C 编译器或 OCaml 编译器来构建自己的编译器,那么它的生态始终受制于人。只有实现自举,才能让语言完全独立地发展。
例如,如果新语言 X 的编译器是用 C 写的,那么当你想给 X 添加一个需要修改编译器本身的语法特性时,就必须依赖 C 工具链完成一次“过渡编译”。但自举之后,X 语言可以只用 X 编译器去构建 X 编译器,整个工具链彻底闭环,不再受 C 语言版本和平台限制。
3.3 推动语言演进和新特性落地
自举之后,语言设计者可以直接用新语言本身来改进编译器。给语言添加新特性时,编译器的源码本身也需要使用新特性。自举让这种“自我驱动”的演进成为可能。
很多现代语言的编译器实现都遵循这个模式。语言先完成一个小核心,然后用这个小核心写出第一个编译器,再用这个编译器支持更复杂的语法,最终形成一个完整的、能够自我维护的编译系统。
3.4 建立可信的构建闭环
自举还有一个更深刻的意义:可信构建。当编译器能够用自己编译自己时,我们可以构建一条从源码到二进制都完全可追溯的链条。任何对编译器的恶意篡改,都可能在自举过程中暴露出来。
业界有一个著名的“信任链”问题:如果某个编译器在编译其他程序时植入后门,那么用这个编译器编译出来的所有程序都可能有问题。自举过程通过多阶段编译和产物比较,可以在一定程度上提高供应链的可信度。
4. 自举的实际流程
4.1 典型两步引导流程
用一个具体例子来说明。假设你要打造一门新语言 MyLang,并且最终希望 MyLang 编译器能用 MyLang 自身编写。
第一步,先用现有的 C 语言写一个“最小 MyLang 编译器”,只支持整型、变量、函数调用等最小子集。这个编译器称为 bootstrap compiler。
第二步,用这个最小 MyLang 编译器去编译一个用 MyLang 编写的完整编译器源码。如果完整编译器源码只使用了最小子集支持的语法,那么这一步就能成功。
第三步,完整编译器生成后,再让它编译自己的源码,此时就完成了第一次真正的自举。
流程表如下:
| 阶段 | 编译器来源 | 编译对象 | 产物 |
|---|---|---|---|
| 第 1 阶段 | C 语言编写的最小编译器 | MyLang 编写的完整编译器源码 | MyLang 完整编译器(stage1) |
| 第 2 阶段 | stage1 | MyLang 编写的完整编译器源码 | MyLang 完整编译器(stage2) |
| 第 3 阶段 | stage2 | MyLang 编写的完整编译器源码 | MyLang 完整编译器(stage3) |
如果 stage2 和 stage3 在行为上一致,说明自举闭环已经建立。
4.2 从简单编译器到完整编译器的升级路径
现实中的自举通常不是一步到位的,而是沿着“最小子集 -> 子集扩展 -> 完整语言”的路线前进。
一个常见做法是:
- 先定义语言的最小核心子集,例如只包含整数、变量、赋值、条件分支和函数调用。
- 用外部语言实现这个子集的编译器。
- 用子集编写一个更大的编译器,支持更多语法特性。
- 用子集编译器编译扩展编译器,然后用扩展编译器再次编译自己。
- 重复上述过程,直到语言功能完整。
这个方法的好处是每一步都容易调试。如果直接写一个支持全部语法的编译器源码,再用一个功能很弱的最小编译器去编译,大概率会因为语法不受支持而失败。
4.3 验证自举是否成功
自举不是“能跑起来”就算成功,还要验证新编译器和旧编译器的行为一致。常见验证方式有两种。
第一种是行为对拍。用两个编译器分别编译同一批测试程序,然后比较目标文件的运行结果。如果所有测试用例的输出一致,说明两个编译器在功能上等价。
第二种是二进制比较。在可控的构建环境中,连续多次用新编译、新生成的编译器重新编译自身源码,然后比较生成的编译器二进制文件是否一致。如果一致,说明构建过程是稳定、可重现的。
在 Linux 环境下,二进制比较通常用cmp命令:
cmp stage1_compiler stage2_compiler && echo "bootstrap success"5. 经典自举案例
5.1 GCC 的三阶段 bootstrap
GCC(GNU Compiler Collection)是自举的典型代表。在构建 GCC 时,通常使用系统现有的 C 编译器作为引导编译器,把 GCC 源码编译成 stage1 编译器。
然后用 stage1 编译器再次编译 GCC 源码,得到 stage2 编译器。接着用 stage2 编译器再次编译 GCC 源码,得到 stage3 编译器。最后比较 stage2 和 stage3 的构建产物是否一致,如果一致,说明 GCC 在完整工具链上具备了自举能力。
之所以要做三阶段,是因为 stage1 是外部编译器生成的,外部编译器可能与 GCC 的实现细节有差异。通过 stage1 生成 stage2、stage2 生成 stage3 的过程,能逐步消除外部编译器对构建结果的影响。
在实际构建 GCC 时,你只需要执行类似这样的命令:
../gcc-source/configure --prefix=/usr/local/gcc-bootstrap --enable-bootstrap make bootstrap具体的源码目录和版本需要根据你下载的 GCC 源码路径调整。重点是--enable-bootstrap选项,它告诉构建系统执行多阶段自举验证。
5.2 TCC:小而完整的自举编译器
TCC(Tiny C Compiler)是一个体积很小的 C 编译器,设计目标就是快速编译 C 代码。它的源码本身就是用 C 语言编写的,并且能够编译自己的源码。
TCC 的常见自举实验是:
# 先用系统 gcc 编译出第一个 tcc gcc -o tcc1 tcc.c # 再用 tcc1 编译 tcc.c 本身 ./tcc1 -o tcc2 tcc.c # 比较两个可执行文件是否一致 cmp tcc1 tcc2如果你下载了 TCC 源码,这个实验可以亲自跑一遍。注意这里的命令只是演示思路,实际项目中还要考虑配置、头文件路径和编译选项,需要按源码包里的说明操作。
5.3 Oberon 系统:从子集到全功能
Oberon 是 Niklaus Wirth 设计的操作系统和编程语言。Oberon 系统的编译器采用了典型的自举路线:先实现一个较小的 Oberon 子集编译器,然后用这个子集编译器去引导更完整的编译器,最终让 Oberon 编译器能够编译自己的全部源码。
这个案例很适合用来理解“小步快跑”的自举思想。Oberon 系统在极简设计理念下,仍然完成了从引导编译器到完整编译器的闭环,证明了子集引导方法的可行性。
5.4 Rust:借助外部语言完成首次自举
Rust 语言早期版本的编译器 rustc 是用 OCaml 语言实现的。后来随着 Rust 语言本身逐渐成熟,开发团队用 Rust 重新实现了 rustc。当 Rust 编译器能够编译用 Rust 编写的 rustc 源码时,Rust 就实现了自举。
这个案例说明,新语言在第一阶段通常可以借助其他成熟语言实现编译器,然后在语言稳定后过渡到自举。选择哪种外部语言并不重要,重要的是最终形成“编译器能编译自己”的闭环。
6. 一个可运行的最小自举演示
理论讲多了容易空洞。这里用一个可运行的 Python 实验,演示“程序处理自身的源码并生成下一代程序”的核心思想。需要提前说明:这不是一个真正的完整编译器,而是一个“自举闭环”的结构模拟器,用来帮助你理解自举验证的核心逻辑。
6.1 演示目标与边界
真实的自举要求编译器源码使用它自身支持的语言编写,并且编译器能够解析、分析、生成自己的源码。一个完整的可自举编译器需要成百上千行代码,不适合在一篇文章中展开。
下面这个实验只做一件事:用一个“编译器”去编译“编译器源码”,然后再用生成的编译器编译同一份源码,观察最终产物是否收敛。它帮你建立自举过程的直观画面。
6.2 第一步:编写一个极简的引导编译器
我们先写一个bootstrap_fakecc.py,它扮演“系统自带的外部编译器”。它的功能很简单:读取源码文件,在文件开头加一行标记后输出。
#!/usr/bin/env python3 # 文件:bootstrap_fakecc.py # 功能:模拟一个引导编译器,负责给源码添加版本标记。 import sys TAG = "# tag: compiler-1.0" def compile_one(source_path, output_path): with open(source_path, "r", encoding="utf-8") as f: source = f.read() # 如果源码已经带有标记,不再重复添加,保证生成的程序可以再次编译自身 if TAG not in source: output = TAG + "\n" + source else: output = source with open(output_path, "w", encoding="utf-8") as f: f.write(output) if __name__ == "__main__": if len(sys.argv) != 3: print("usage: python bootstrap_fakecc.py <source> <output>") sys.exit(1) compile_one(sys.argv[1], sys.argv[2])这个程序并不做真正的语法分析,但它具备“读取源码、生成新程序”的能力。在真实自举中,这一步由系统 gcc 或 clang 承担。
6.3 第二步:编写“编译器源码”
接下来写compiler_src.py,它代表“编译器自身的源码”。在真实世界里,这个文件应该用编译器支持的语言编写;在演示中,我们直接用 Python 语法来模拟。
#!/usr/bin/env python3 # 文件:compiler_src.py # 功能:模拟一个编译器的源码。 # 在真实自举中,这个文件应该使用目标语言 L 编写。 import sys TAG = "# tag: compiler-1.0" def compile_one(source_path, output_path): with open(source_path, "r", encoding="utf-8") as f: source = f.read() if TAG not in source: output = TAG + "\n" + source else: output = source with open(output_path, "w", encoding="utf-8") as f: f.write(output) if __name__ == "__main__": if len(sys.argv) != 3: print("usage: python compiler_src.py <source> <output>") sys.exit(1) compile_one(sys.argv[1], sys.argv[2])可以看到,它和引导编译器的逻辑几乎一样。这是有意设计的:自举的核心就是要保证“编译器源码”和“编译器行为”能够形成一个稳定闭环。
6.4 第三步:执行自举流程并验证
按顺序执行以下命令。
第一步,用外部引导编译器编译“编译器源码”,得到 stage1 编译器:
python bootstrap_fakecc.py compiler_src.py compiler_stage1.py第二步,用 stage1 编译器编译“编译器源码”,得到 stage2 编译器:
python compiler_stage1.py compiler_src.py compiler_stage2.py第三步,用 stage2 编译器再次编译“编译器源码”,得到 stage3 编译器:
python compiler_stage2.py compiler_src.py compiler_stage3.py第四步,比较 stage2 和 stage3 是否一致:
diff compiler_stage2.py compiler_stage3.py && echo "bootstrap success"如果看到终端输出bootstrap success,说明这个模拟闭环已经收敛。原因是 compiler_src.py 被编译时,第一行会被加上 TAG,而后面再次编译时,由于 TAG 已经存在,程序不会重复添加标记,所以 stage2 和 stage3 的内容完全一致。
这个实验虽然简单,但它清楚展示了自举验证最重要的思想:从“外部引导编译器”生成一个编译器,再用这个编译器去编译同一个编译器源码,最终判断产物是否收敛。
7. 常见问题与排查思路
7.1 自举过程高频问题表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 自举后生成的编译器一运行就崩溃 | 编译器源码存在未定义行为或内存错误 | 使用 sanitizer、加入回归测试、对拍标准编译器输出 |
| stage2 和 stage3 产物不一致 | 构建环境不固定、未定义行为、优化选项漂移 | 固定编译选项、构建可重现环境、检查源码中的未定义行为 |
| 编译器不支持自己的语法导致自举失败 | 编译器源码使用了目标语言尚未实现的新特性 | 先把源码限制在已支持子集,再逐步扩展语法 |
| 缺少初始编译器无法开始引导 | 系统没有可用的外部编译器 | 先安装系统 gcc/clang,或使用历史发布版编译器 |
| 修改语法后重新自举失败 | 新语法没有同步更新到编译器的前端解析器中 | 先完成词法和语法支持,再重新生成编译器并测试 |
| 小体积编译器编译大代码时内存不足 | 编译器资源消耗过大 | 降低优化等级、增加内存、分模块编译 |
最重要的排查思路是“最小化复现”。如果自举失败,先把编译器源码裁剪到最小可编译子集,确认子集能通过后,再逐步增加功能。
7.2 嵌入式工具链中的“编译器”话题
不少做嵌入式的同学会搜到“Keil MDK 没有 v5 编译器”“AC5 编译器下载”这类问题。这些属于嵌入式工具链配置问题,和“编译器自举”不完全是一回事。自举更多关注编译器如何构建自身,而嵌入式工具链的问题是“如何选择、安装、切换某个现成编译器”。
如果你在 Keil 中遇到找不到编译器的问题,通常先检查安装的 MDK 版本是否包含了对应编译器插件,再检查工程设置里的编译器版本选项。这属于工具链配置范畴,与编译器自举的原理可以分开看待。
8. 自举工程的最佳实践
8.1 从核心子集开始,避免一步到位
设计一个能够自举的语言时,不要试图在第一个版本就支持全部语法特性。先把范围缩小到“整数、变量、函数、条件分支、循环”等最小子集,用外部语言写一个能编译该子集的编译器,然后逐步扩大子集范围并重新自举。
每一步自举都要保证可运行、可验证。宁可多花几个阶段,也不要一次性引入大量新语法。
8.2 构建可重现的产物
可重现构建对自举非常重要。如果两次构建生成的编译器二进制不一致,就很难判断是自举失败还是构建环境不稳定。建议在构建文档中固定操作系统、编译器版本、优化参数、环境变量和依赖库版本。
可以在 CI 中加一个自举验证任务:
make bootstrap make compare-stage2-stage3如果比较失败,CI 直接报错,这样能第一时间发现自举链路被破坏。
8.3 重视引导链的可信性
编译器是软件供应链的最底层。如果引导编译器被篡改,它生成的编译器也可能被注入后门。运维和编译器开发团队应该对引导编译器产物做好记录和校验,必要时保存 stage 产物的哈希值。
当你发布一个新版本编译器时,官方通常也会附上源码包的哈希值,方便使用者校验下载文件没有被篡改。这是构建可信链的第一步。
8.4 持续集成自举验证
语言和编译器迭代过程中,每次提交都可能影响自举能力。建议把自举测试纳入持续集成,至少包含以下步骤:
- 使用当前主线编译器编译最新源码,得到新编译器。
- 使用新编译器再次编译同一份源码,得到第二个新编译器。
- 比较两个新编译器的行为是否一致。
- 运行回归测试套件,确认语言特性没有被破坏。
这样每次代码变更都会触发自举验证,避免等到发版时才发现自举链路已经断开。
9. 总结与下一步学习路线
编译器自举的核心并不是“玄学”,而是一个工程闭环:用外部语言写一个最小的引导编译器,然后用这个引导编译器编译一个更完整的编译器源码,最终让编译器能够编译自己。自举成功的标志是生成的编译器能够稳定地重建自身,并且多次构建结果保持一致。
如果你想进一步深入,可以从三个方向继续学习:
第一,阅读真实编译器的源码。TCC 是一个很好的入门选择,源码量相对小,功能完整,并且支持自举。你可以尝试修改它的一小部分功能,然后重新编译自己,实际体会一次自举流程。
第二,学习编译原理的基础知识。词法分析、语法分析、语义分析、中间代码生成、代码优化和目标代码生成,这些内容在《编译原理》这类经典教材里有系统讲解。掌握了这些,你才能真正看懂编译器源码中的每一部分。
第三,动手设计一门极小的语言。从只支持整数和四则运算开始,写一个解释器,再写一个编译器,最后尝试让这门语言写出的编译器编译自身。这个练习会帮助你理解:为什么自举要求语言具备足够的表达能力,为什么子集引导比一步到位更可行。
当你真正走通一次属于自己的编译器自举流程,你对“编译器”这三个字的理解,会和只使用现成工具链时有很大不同。源码在手,下一步就是打开编辑器,亲手试一试。