写编译器的人,大概率会在某个深夜产生一个奇怪的想法:我现在写的这个编译器,能不能编译它自己?这个问题在编译领域有一个专门的名字,叫自举(self-hosting)。第一次听到的人,通常会把它当成脑筋急转弯,或者“先有鸡还是先有蛋”的变体。但真正写过编译器的人会立刻意识到,这个问题一点都不闲。当编译器的源码已经能够描述词法分析、语法分析、类型检查和代码生成这些复杂逻辑时,让编译器去编译它自己的源码,就像让一个马拉松运动员评估自己能不能跑完全程——不是靠想象,而是真的跑一次。
顺带说一句,“自举”这两个字在不同技术领域含义完全不同。电子工程里的“自举电容”,是利用电容两端电压不能突变的特性把驱动电压抬高的电路技巧,和编译器自举只共享一个名字,没有亲属关系。这也是很多人检索“自举”时会搜出一堆电路原理图的原因。这篇文章要聊的,是编译器领域的自举,以及它真正值得被理解的工程意义。
1. 为什么“编译器编译自己”这件事值得认真想一想
1.1 先有鸡还是先有蛋:第一个编译器是怎么来的
回到计算机发展的早期。第一代程序员面对的机器只有机器码,编程就是把一条条二进制指令写进内存。后来有了汇编语言,用助记符代替二进制指令,但汇编器本身仍然是用机器码或更早的工具写出来的。再到高级语言出现,才谈得上“用高级语言写编译器”。
这个过程是渐进的,不是突变。第一代的编译器,通常是用更低级的工具手工搭出来的种子,再用这个种子编译更复杂的编译器。等现代开发者开始接触编译原理时,大多数人已经站在巨人的肩膀上:用 C、C++、Rust 甚至 OCaml 写一个新语言的前端和代码生成器,而不是从机器码开始。
但问题也随之而来:你写的编译器真的正确吗?你可以写一堆测试用例,跑一些小片段,验证输出结果。可总有一个角落让你不安——编译器本身也是一个程序,它处理过的源文件里,有没有哪个场景是测试用例没有覆盖的?于是你想到一个近乎残酷的测试:把这个编译器的源码喂给它自己。
这一瞬间,自举从一个哲学问题变成了工程测试。
1.2 自举不是文字游戏,而是一致性测试
我倾向于把自举理解成编译器对自身逻辑的一致性测试。它检验的不是某一段代码能不能跑,而是三层能力同时成立:
- 编译器所实现的语言,能够描述一个完整编译器的逻辑。
- 编译器的代码生成部分,能够把描述自身的源码翻译成可执行的机器码。
- 编译器生成的二进制,能够在真实运行时环境中重新完成一次编译。
如果这三层里任何一层有问题,自举就会失败。比如语言表达能力不够,你的编译器源码里用了某种高级特性,但编译器自己不支持,编译到一半就报错。比如代码生成有隐蔽 bug,虽然 demo 程序能跑,但编译编译器这种体量的代码时触发了错误路径。再比如运行时依赖某个外部库,但自举后换了环境就找不到符号。
所以我会说,自举是编译器写给自己的“难度最高的测试用例”。它不保证编译器没有 bug,但它能暴露出一大批用普通测试很难发现的问题。
1.3 先厘清几个容易混的概念
热身阶段有必要把几个相邻概念分清楚。热搜里经常出现“编译器和编辑器的区别”,这确实是个容易混淆的起点。编辑器(Editor)负责编辑文本,VSCode、Vim、记事本都属于这一类;编译器(Compiler)负责把高级语言翻译成目标代码,两者根本不在一层。自举也和“用 IDE 编写编译器”没有直接关系,IDE 只是外壳,真正的编译器还是那个被调用的命令行程序。
还有一个高频搜索词“自举电容”,它属于电子电路里的驱动电路概念。如果读者是从 Keil、STM32 这些嵌入式关键词跳转过来的,看到“自举”两个字先别急——驱动芯片内部的自举电容和编译器自举是两个完全独立的主题,下面也会专门聊到嵌入式工具链里的自举情况。
2. 绕开鸡生蛋问题的三条路线
2.1 路线一:先手写一个足够小的“种子编译器”
最原始的方案,是手工打造一个极小的编译器,业界通常称其为种子编译器。这个种子编译器不必覆盖全部语言语法,只要够用:它支持一个小型语言子集,能够把用这个子集编写的程序翻译成汇编或机器码。
关键在于,这个种子编译器要足够简单,简单到可以用汇编甚至机器码手工写出来。它不需要理解指针、泛型、异常处理这些复杂特性,只要拥有“加载文件、解析有限语法、生成低级代码”这样最基本的能力。
有了种子,就能开始“代际升级”。你用这个种子编译器去编译一个更完整的编译器源码,得到的新编译器覆盖更多语法;再用新编译器去编译更完整的版本,逐步逼近目标语言的全量功能。整个过程像滚雪球,种子很小,但链条一旦建立,后续迭代就不再需要回归到最原始的机器码。
2.2 路线二:借助别人的编译器完成“代际切换”
大多数现代编译器项目,其实不需要从零手写种子。更现实的做法,是借用现有的工具链完成第一次“代际切换”。
假设你正在设计一门新语言 L,最开始用 C 语言实现编译器的逻辑。你把 L 编译器源码写好之后,用现有的 C 编译器去编译它,得到第一版可执行的 L 编译器。这时候,你还没有完成真正的“用 L 编译 L”。
随后你开始用 L 语言本身改写编译器源码,把它从 C 移植到 L。改写到一定程度后,用第一版 L 编译器去编译这个“用 L 写的编译器源码”。如果编译成功,并且产物能正常工作,你就完成了自举。
这个流程是绝大多数语言的真实选择,因为它成本低、风险小。你不需要从机器码出发,只需借用现成的 C 编译器作为跳板。跳板没有消失,但不再承载整个项目未来所有的语法演进压力。
2.3 路线三:经典三阶段自举构建流程
自举在工程落地时,最常见的形态是三阶段构建。下面这段代码是通用示意,不指向某个具体项目,但结构是几乎所有自举构建流程的核心:
# 阶段0:使用某个已有的种子编译器或母语言编译器, # 编译当前用语言L写出的编译器源码,得到 stage1 可执行文件。 seed_compiler compiler_in_L.src -o compiler_stage1 # 阶段1:用刚得到的 stage1 编译器,编译同一份源码,得到 stage2。 compiler_stage1 compiler_in_L.src -o compiler_stage2 # 阶段2:对比 stage1 与 stage2 的输出行为,或直接比对二进制指纹。 ./compiler_stage1 test_program.lang -o output1 ./compiler_stage2 test_program.lang -o output2 diff output1 output2阶段2是整个流程里最关键的一步。如果 stage1 和 stage2 行为一致,说明“新编译器能够不依赖种子编译器而独立工作”。如果出现偏差,则说明编译器在自身编译过程中产生了语义漂移,必须回头排查。
这也是我为什么说自举不是一个一步到位的动作,而是一套包括验证、比对和回归的机制。很多初学者只记住了“用自己编译自己”,却忽略了后面那个“再编译一次并比对”的步骤。没有比对,就没有信任链。
3. 自举的价值不在“自己编译自己”这句话里
3.1 信任链:你能相信的,从机器码扩大到自家编译器
我们每天都在使用编译器,但很少有人真正“相信”编译器。编译器源码那么长,谁都没有逐行审阅过。更多时候,我们信任某个编译器是因为它被用了很多年、社区足够大、出现问题容易搜到解决方案。
自举提供了一种更本质的信任验证:让编译器用自己编译自己,然后比对二进制或者运行结果。如果一致,就说明编译器生成的代码能够正确重现它自身的逻辑。这样一来,信任链就建立起来了:从源码到二进制,从二进制到再次构建,环环相扣,至少“自指”这个方向上没有明显断裂。
这背后其实涉及一个经典的思想实验:如果编译器源码被人恶意修改,导致每次编译别的程序时都植入额外逻辑,那么仅仅靠测试目标程序很难发现异常。自举并不能彻底解决供应链安全问题,但它能让“编译器生成编译器”的过程变得可复现、可比对,一旦行为不一致就立刻暴露。这个特性在现代软件供应链审计里非常重要。
3.2 独立进化:摆脱母语言工具链的束缚
自举还有一个很容易被忽略的价值:独立进化。
当一个编译器还依赖 C 工具链时,它每次改动都受制于宿主语言和宿主编译器。你可能要在 C 编译器里查一个奇怪的宏展开问题,或者等待某个阶段的交叉编译工具更新。语言的新语法想加进去,必须先确认自己实现的编译器能不能处理;但更麻烦的是,改完语法后你还要回到 C 编译器那套构建体系里重新编译一次。
自举完成之后,这个循环就闭合了。编译器可以用自己的语言写源码,用自己的编译器编译自己。后续改语法、改优化、改运行时,只需要让“新编译器能编译自己”这一关过掉,就能自主迭代。Go 和 Rust 都走过这条路:早期编译器分别依赖 C 工具链和 OCaml,后来逐步用自己的语言重写自己的编译器,最终完成自举,从而获得不受制于外部母语言的演进能力。
3.3 对普通开发者的直接借鉴:工具链是否“长齐了”
从实用角度看,自举可以成为一个判断工具链是否成熟的参考信号。
当一个开源项目能够从源码开始自行构建,并且构建结果能反过来再构建一次,说明它的构建过程足够完整,依赖边界足够清晰。相反,如果一个工具链需要依赖一台已经“变异”的旧机器,或者依赖某个历史遗留的二进制才能构建,那它的可移植性和可再生性就值得担忧。
在团队选型基础设施时,这种“可再生”信号很有参考价值。一个能自举的工具,至少在构建链路上没有隐藏的死结。
4. 真实世界的自举案例:从大型编译器到嵌入式工具链
4.1 GCC:从丛林中长出来的“自产自销”工具链
GCC(GNU Compiler Collection)是自举式编译器最典型的代表之一。GCC 本身可以用 C 和 C++ 编写,现代版本的 GCC 能够直接编译自身源码。在常见的 Linux 发行版里,当我们从源码构建 GCC 时,通常会先用系统的旧版 GCC 作为种子编译器,编译新 GCC 源码,得到新的 GCC;再用新的 GCC 重新编译一次源码,验证结果一致。
这不是理论描述,而是很多开发者实际做过的操作。如果你曾在 x86 机器上交叉编译过 GCC 到其他架构,就会更深地体会到这个过程:先生成一个可以在宿主机上运行的交叉编译器,再用它编译目标平台的原生编译器。每一步都在为下一步提供“种子”。
4.2 Go 与 Rust:从母语言切换到自举的典型路径
Go 语言编译器在早期版本中,并没有完全用 Go 自己实现。公开资料显示,Go 编译器最初使用 Plan 9 工具链时代的 C 语言实现,后来项目团队逐步将编译器重写为 Go 语言。随着重写进度推进,自举能力最终建立,Go 工具链从此不再依赖原来的 C 实现来编译自身。
Rust 的路径也类似。rustc 早期的前端是用 OCaml 写的,后来 Rust 语言成熟后,编译器主体切换为 Rust 实现。今天我们在 macOS 或 Linux 上构建 Rust 工具链时,依赖的是一个已经自举的 rustc。这个过程非常能说明问题:自举不是某一时刻突然完成的魔法,而是长期重写、长期测试、最终闭环的工程结果。
这里有一个值得注意的细节:Go 和 Rust 都是在“语言已经具备足够表达能力”之后才完成自举的。如果你的语言还在频繁调整基础语法,过早追求自举只会让维护成本翻倍。
4.3 嵌入式场景里的“编译器版本”和自举为什么总被混在一起
热搜词里有大量 Keil、AC5、AC6、STM32、MDK 相关内容,很多人会问“为什么我的 Keil 没有 V5 编译器”“AC5 和 AC6 该怎么选”。这类问题其实和自举没有直接关系,但因为都涉及“编译器”这个词,总被放到一起讨论。
嵌入式开发中最常用的 Keil MDK、arm-none-eabi-gcc 这类工具链,本质上都是交叉编译器:编译器运行在 Windows 或 Linux 宿主机上,生成的目标代码运行在 STM32 等 MCU 上。编译器本身并不在 MCU 上运行,所以“让编译器在 MCU 上编译自己”这种场景非常少见。大家平常纠结的 AC5 与 AC6 的区别,更多是不同后端生成的代码大小、优化效果、启动文件兼容性差异,属于工具链行为差异,而不是编译器自举问题。
用一张表可以看得更清楚:
| 维度 | 本地自举编译器 | 嵌入式交叉编译器 |
|---|---|---|
| 编译器运行位置 | 与目标代码运行环境相同或同构 | 运行在宿主机上,目标代码运行在 MCU 上 |
| 是否强调自举 | 常用来验证工具链与语言一致性 | 通常不讨论自举,重点在目标平台适配 |
| 常见关注点 | 语言覆盖、代码生成正确性、二进制一致性 | 编译器版本、优化选项、启动文件、链接脚本 |
所以,如果你是在准备 STM32 开发环境,看到“自举”先别慌。你要解决的是“我的编译器版本对不对”“AC5 和 AC6 有没有行为差异”这类具体工程问题,而不是让编译器在单片机里编译自己。
5. 从自举思想引申出的“最小闭环”工作法
5.1 自举思维不只属于编译原理
编译器自举的思想其实可以迁移到很多开发场景。比如你写了一个代码生成器,用来生成接口定义、数据库模型或者前端模板。那你有没有问过它:你能不能生成“能描述你自己的模板结构”?如果能,说明你的模板抽象已经足够自洽,生成器覆盖了自己的复杂度。
再比如你维护一套构建脚本,这套脚本能够处理所有业务模块的发布流程。那你有没有试过用它来构建它自己的工具链?如果连自己都构建不出来,别人拿去也很难复现。
这类“自举式验证”的核心,不是强迫所有工具都去处理自己,而是提醒你:长期维护的工具,必须具备描述自身构建过程的能力。如果一个工具只能处理别人,不能处理自己,那它的可复制性和可维护性往往会出问题。
5.2 一个可复用的“自举式迭代”四步法
结合编译器自举的经验,我一般会推荐下面这套工作流,非常适合脚手架、代码生成器、CLI 工具这类偏“元编程”的项目。
第0步:定义最小闭环。想清楚这个工具的“最小可用形态”是什么。对编译器来说,是能编译一个小型程序;对代码生成器来说,是能从一个简单模板产出可用代码。不要一开始就把功能铺满。
第1步:用外部工具完成这个最小版本。不需要一开始就追求“自己生成自己”。先用熟悉的技术栈把最小版本跑通,得到第一个可用产品。
第2步:尝试让最小版本处理“描述自己的源文件”。把它自己的源码、模板或构建配置作为输入喂进去。如果失败,不要马上沮丧,而是要记录失败原因。
第3步:把“自举验证”当成回归测试。后续每次新增功能,都问一句:现在这个工具还能不能处理自己?如果不能,是功能覆盖不足,还是引入了循环依赖?把这套验证固化进 CI 或自动化流程里。
这套流程不是让每个工具都变成“自举编译器”,而是让工具链保持一种可自我验证的状态。它的价值不是性能提升,而是让复杂系统在持续演进时不至于悄悄腐烂。
一个实用提醒:不要站在第1步就去想“我要让它完全自己处理自己”,那会让你陷入过度的抽象设计。先用外部工具跑通最小版本,再慢慢推进自举闭环,风险会小很多。
5.3 适用边界:哪些项目适合“自举式验证”,哪些没必要
自举式验证不是万能方法论。判断标准很简单:你的工具是否具备“自描述”潜力?
适合做的项目,通常具有以下特征:
- 工具会接收某种结构化的输入,并转换或生成新的产物。
- 工具自身的输入形态,和它产出的产物形态存在某种同构性。
- 工具的用途是长期被团队或社区复用的。
典型例子包括:DSL 编译器、代码生成器、模板引擎、脚手架工具、静态站点生成器、自定义构建系统。
不适合做的项目也很明显:一次性脚本、纯数据处理任务、无状态 API 接口、简单 CRUD 页面。这类项目本身没有“处理自己”的意义,强行自举只会增加抽象成本,没有任何收益。
我见过不少团队为了让工具显得“高级”,硬是把简单的脚本改成“自己生成自己”的复杂架构,最后维护成本远超收益。记住,自举的目的是验证和进化,不是为了制造局部知识壁垒。
6. 让编译器回归“编译自己”时,最容易踩的坑
6.1 误区一:能自举等于编译器更快更好
这是最常见的误解。自举验证的是一致性,不是性能。一个编译器能不能编译自己,和它生成的代码是否高效、编译速度是否够快、内存占用是否合理,并没有直接联系。
你可以把自举想象成一场“体检”,它能查出很多问题,但查不出所有问题。体检结果正常,不代表这个人一定能跑马拉松。对一个编译器来说,真正的性能优化还是得靠算法优化、指令选择、寄存器分配、缓存友好性这些独立的工作。
6.2 误区二:能自举等于没有 bug
自举只能证明编译器能正确处理描述自身的源码。它不能证明编译器能正确处理世界上所有合法程序。
外部程序里可能出现编译器自身源码没有用到的语法组合、边界情况、平台相关行为。如果编译器在处理这些程序时出错,自举验证并不能发现。更复杂的是,如果编译器源码本身就承载了某种隐蔽的语义错误,那么自举反而会把这个错误一代一代传下去。
所以更准确的说法是:自举通过,证明这个编译器在“描述自己”这件事上没有明显问题;自举失败,则说明问题几乎必然存在。它是一个高难度的合格性测试,不是一个正确性证明。
6.3 误区三:自举只需证明一次
语言是持续演进的。今天你的编译器能编译自己,明天你给语言加了一个新语法,或者改变了一套类型推导规则,原来的编译器就未必能编译新的编译器源码了。
这时你需要再来一轮自举:先用旧编译器编译“已经支持新语法的编译器源码”的旧版本逻辑,再用得到的新编译器处理完整的新源码。这个过程本质上就是一个回归测试。语言设计得越激进,自举验证就要跑得越频繁。
因此,自举不是一个“完成时”,而是一种“持续状态”。优秀的编译器项目会把“编译器能否编译自己”作为 CI 流水线里最高优先级的检查。一旦失败,不解决就不合入主干。
6.4 实战排查链路:当你试图让编译器“编译自己”时
如果你真的在尝试让自己写的编译器自举,却一直失败,下面这条排查顺序能节省大量时间。
第一步,先看报错发生在哪一层。如果错误出现在词法、语法解析阶段,优先检查编译器源码里是否用了编译器自己还不支持的语法特性。如果错误出现在链接阶段,优先检查构建顺序和符号依赖,而不是一头扎进代码生成逻辑。
第二步,检查输入文件是不是“真的同一份”。自举场景里经常出现源码被不同的构建脚本预处理,导致阶段0和阶段1处理的已经不是同一份代码。用 git 切到固定版本,关掉所有预处理宏,再重试。
第三步,确认构建顺序。阶段0、阶段1、阶段2 不能跳步,尤其不能直接用种子编译器去编译最终源码。每一步的产物要保留下来,方便出问题时逐步对比。
第四步,锁定环境。编译器版本、PATH 环境变量、依赖库的版本、甚至操作系统的动态链接器都可能影响结果。换一个环境后先跑一遍最小示例,再跑自举流程,能减少大量干扰因素。
第五步,检查生成代码的宿主机依赖。自举编译器的源码可能依赖某个动态库或绝对路径,而新编译器在另一个环境下运行时找不到这些依赖,导致自举产物无法复现。
不要从第一步就追求两个阶段产物的二进制完全一致。先把三阶段流程跑通,再用行为比对的方式逐步核查。如果行为一致,二进制不一致可能只是构建时间戳等非语义差异;如果行为不一致,才需要深入追踪代码生成逻辑。
最后
如果有人问我,编译器自举到底哪里迷人,我的回答是:它不是一段可以拿来炫耀的技术业绩,而是一场对信任链的建设。当你亲手把编译器源码交给自己的编译器,看着它最终还原出一个能够独立工作的工具链,你会重新理解什么叫“闭环”。
哪怕你以后不写编译器,这套“让一个工具去处理自己”的思路,也值得用在脚手架、脚本、代码生成器和所有会被长期流传的构建流程上。真正的成熟不是功能多、界面炫,而是它经得起自己这一关。下一次你写完一个工具,不妨问一句:它能处理自己吗?如果暂时不能,那缺的往往不是功能,而是对工具链完整性的理解。