编译器自举:从引导编译器到自我编译的完整闭环 之前有读者问过我一个问题编译器是怎么编译自己的这看起来像一句绕口令其实是编译器领域最经典的“自举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 阶段stage1MyLang 编写的完整编译器源码MyLang 完整编译器stage2第 3 阶段stage2MyLang 编写的完整编译器源码MyLang 完整编译器stage3如果 stage2 和 stage3 在行为上一致说明自举闭环已经建立。4.2 从简单编译器到完整编译器的升级路径现实中的自举通常不是一步到位的而是沿着“最小子集 - 子集扩展 - 完整语言”的路线前进。一个常见做法是先定义语言的最小核心子集例如只包含整数、变量、赋值、条件分支和函数调用。用外部语言实现这个子集的编译器。用子集编写一个更大的编译器支持更多语法特性。用子集编译器编译扩展编译器然后用扩展编译器再次编译自己。重复上述过程直到语言功能完整。这个方法的好处是每一步都容易调试。如果直接写一个支持全部语法的编译器源码再用一个功能很弱的最小编译器去编译大概率会因为语法不受支持而失败。4.3 验证自举是否成功自举不是“能跑起来”就算成功还要验证新编译器和旧编译器的行为一致。常见验证方式有两种。第一种是行为对拍。用两个编译器分别编译同一批测试程序然后比较目标文件的运行结果。如果所有测试用例的输出一致说明两个编译器在功能上等价。第二种是二进制比较。在可控的构建环境中连续多次用新编译、新生成的编译器重新编译自身源码然后比较生成的编译器二进制文件是否一致。如果一致说明构建过程是稳定、可重现的。在 Linux 环境下二进制比较通常用cmp命令cmp stage1_compiler stage2_compiler echo bootstrap success5. 经典自举案例5.1 GCC 的三阶段 bootstrapGCCGNU 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小而完整的自举编译器TCCTiny 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, encodingutf-8) as f: source f.read() # 如果源码已经带有标记不再重复添加保证生成的程序可以再次编译自身 if TAG not in source: output TAG \n source else: output source with open(output_path, w, encodingutf-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, encodingutf-8) as f: source f.read() if TAG not in source: output TAG \n source else: output source with open(output_path, w, encodingutf-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 是一个很好的入门选择源码量相对小功能完整并且支持自举。你可以尝试修改它的一小部分功能然后重新编译自己实际体会一次自举流程。第二学习编译原理的基础知识。词法分析、语法分析、语义分析、中间代码生成、代码优化和目标代码生成这些内容在《编译原理》这类经典教材里有系统讲解。掌握了这些你才能真正看懂编译器源码中的每一部分。第三动手设计一门极小的语言。从只支持整数和四则运算开始写一个解释器再写一个编译器最后尝试让这门语言写出的编译器编译自身。这个练习会帮助你理解为什么自举要求语言具备足够的表达能力为什么子集引导比一步到位更可行。当你真正走通一次属于自己的编译器自举流程你对“编译器”这三个字的理解会和只使用现成工具链时有很大不同。源码在手下一步就是打开编辑器亲手试一试。