ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

编译器自举浅析:从“自己编译自己”到工具链的自我信任与演进

2026/9/8 11:05:19 拓冰建站 浏览量
编译器自举浅析:从“自己编译自己”到工具链的自我信任与演进 写编译器的人大概率会在某个深夜产生一个奇怪的想法我现在写的这个编译器能不能编译它自己这个问题在编译领域有一个专门的名字叫自举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从丛林中长出来的“自产自销”工具链GCCGNU 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 环境变量、依赖库的版本、甚至操作系统的动态链接器都可能影响结果。换一个环境后先跑一遍最小示例再跑自举流程能减少大量干扰因素。第五步检查生成代码的宿主机依赖。自举编译器的源码可能依赖某个动态库或绝对路径而新编译器在另一个环境下运行时找不到这些依赖导致自举产物无法复现。不要从第一步就追求两个阶段产物的二进制完全一致。先把三阶段流程跑通再用行为比对的方式逐步核查。如果行为一致二进制不一致可能只是构建时间戳等非语义差异如果行为不一致才需要深入追踪代码生成逻辑。最后如果有人问我编译器自举到底哪里迷人我的回答是它不是一段可以拿来炫耀的技术业绩而是一场对信任链的建设。当你亲手把编译器源码交给自己的编译器看着它最终还原出一个能够独立工作的工具链你会重新理解什么叫“闭环”。哪怕你以后不写编译器这套“让一个工具去处理自己”的思路也值得用在脚手架、脚本、代码生成器和所有会被长期流传的构建流程上。真正的成熟不是功能多、界面炫而是它经得起自己这一关。下一次你写完一个工具不妨问一句它能处理自己吗如果暂时不能那缺的往往不是功能而是对工具链完整性的理解。