
从小我就有个执念一个编译器如果只能老老实实被别人编译总觉得像是没断奶。真正把它推上“自己编译自己”这条路的动作就是编译器自举。这个概念听起来玄乎说白了就是三件事——先准备一个种子编译器再用这个种子去重新编译自己的源码得到第一版然后拿第一版再去编译同一份源码得到第二版最后把两个版本掰开了揉碎了做字节级对拍看它们到底一不一样。这篇文章我会以我手上一个 mini-c 编辑器项目为例子把从种子构建到二次编译再到对拍验证的完整流程跑一遍把我踩过的坑、总结出来的方法和对拍脚本全部摊开讲。适合对系统软件底层机制好奇的开发者、编译原理课程的学生也适合正在为结课项目和数据设计发愁的人。先说项目背景。mini-c 是我维护的一个教学用的小型C语言编译器麻雀虽小五脏俱全支持函数、指针、数组、结构体、控制流这些常见语法AST 构建和 x86-64 汇编生成都有全部源码加起来大概一万行左右用标准 C 编写不依赖第三方库。因为这个结构足够干净清晰非常适合做自举实验出问题时变量也能控制在很小的范围内。接下来我会沿着“为什么做、怎么做、踩了什么坑”这条线把整件事完整记录下来。1. 自举到底在解决什么问题1.1 鸡生蛋蛋生鸡的起点在哪编译器自举第一步就撞上一个经典悖论我的编译器源码在这但我没有能运行它的可执行文件。想得到可执行文件就得用一个已经存在的编译器去编译源码可这个“已经存在的编译器”又是从哪来的这个链条用一张 T 型图来解释最直观不画图我用文字递推一遍宿主编译器(gcc) 编译器源码(mini-c.c) ↓ mini-c.seed种子编译器 mini-c.seed 编译器源码(mini-c.c) ↓ mini-c.stage1一次编译结果 mini-c.stage1 编译器源码(mini-c.c) ↓ mini-c.stage2二次编译结果最上面那一步用的是宿主机上已有的编译器比如 gcc 或 clang来编译 mini-c 的源码产出一个可执行文件这就是“种子编译器”。有了种子第二层就开始进入正题让种子去编译 mini-c 的源码产出的叫做第一代编译器 stage1。第三层再让 stage1 去编译同一份源码得到 stage2。如果一切顺利stage1 和 stage2 在字节层面应该是逐位一致的。这样一来这个编译器就不再依赖外部的宿主工具完全变成了“自己编译自己”的独立个体。1.2 为什么值得花力气做自举可能有人会问能用 gcc 或者 clang 编译干嘛非得折腾什么自举这个问题我在早期也觉得是洁癖但实际做下来发现自举的意义非常实在。第一个价值是独立性。如果你写了一个新语言或者新编译器而它永远只能靠别的编译器生存那它就没有真正站稳脚跟。一旦宿主编译器发生了变化、升级了语言标准、或者在某个平台上不提供支持你的编译器的命运就被别人攥在手里。而一旦实现自举编译器就有了自己的根基可以脱离原生宿主在更大范围内演进。第二个价值是验证正确性。自举是一个极其苛刻的测试场景。在自举过程中编译器必须能在自己的规则下正确处理自己的工作。一个软件如果连自己都编译不了说明它对自己的语义理解都不到位那很难让人放心用它去编译更大的别的项目。第三个价值是工程上的“吃自己的狗粮”。从语言设计者的角度看如果你设计了一门语言你应该亲身去写这门语言的编译器体验它的表达能力、类型系统、运行时行为才能真正发现设计缺陷。很多成熟语言比如 Go 和 Rust都是这样一路被狗粮喂出来的。1.3 字节级对拍为什么能跑还不够自举链条搭好之后最容易被忽略的一步就是验证。很多人做完二次编译发现 stage2 能正常工作、能编译测试程序就认为万事大吉了。但“能跑”和“完全正确”之间隔着一道很大的鸿沟。字节级对拍的意思就是不只满足于“能跑”而是把 stage1 和 stage2 两个可执行文件逐字节做比较。理想情况下两份文件的每一个字节都应该完全一致。如果一致说明编译器已经到达了一个稳定状态即所谓的“不动点”它在编译自身源码之后输出与之前的自己完全相同。为什么要做到这种程度因为编译器是软件的元层工具。一个微小的差异比如某次编译在汇编输出里多了一条对齐指令或者哈希表的遍历顺序变了导致符号表顺序变化都可能在未来产生连锁反应。如果 stage1 和 stage2 不一致哪怕它们表面上都“能跑”你也无法确认哪个版本才是你最终想要保留的。字节级对拍把这种不确定性彻底暴露出来逼你去找到差异的来源而不是掩耳盗铃。2. 种子编译器选型和链路设计的关键点2.1 种子编译器怎么选种子编译器是整个自举链路的信任起点。它的选择直接影响后面所有步骤的难度。我总结下来选种子一般看三个维度。第一个维度是可信度。种子编译器的来源必须干净、可靠最好是能从源码自行构建的可信平台编译器。因为从理论上讲如果种子被污染了它编译出来的所有东西都有可能被污染这种信任是可以向上传递的。所以我自己做实验时种子优先用系统自带的 gcc 加严格的编译参数整个过程记录日志方便回头追溯。第二个维度是版本匹配。种子编译器的版本和你要自举的编译器源码之间应该有明确的兼容关系。比如 mini-c 的源码用的是 C99 语法那种子编译器就必须支持 C99 标准并且编译时不能开启任何会影响代码生成正确性的激进优化比如-ffast-math这类有可能改变浮点语义的选项。第三个维度是可控性。种子编译器最好不要有太多“隐藏行为”比如自动链接奇怪库、自动插入性能分析代码、自动启用某些默认优化。我自己实际用的种子构建命令就非常朴素全部交给 Makefile 去管理make clean make CCgcc CFLAGS-O2 -Wall -Wextra -stdc99 -fno-strict-aliasing这里把优化控制在 O2既保证了运行效率又不会像 O3 那样在指令调度上做出太多激进的变换给后面的字节级比较减少干扰。同时-fno-strict-aliasing可以避免潜在的类型别名优化问题这是 C 编译器级别的经典坑排查起来非常头疼。2.2 二次编译的链路设计有了种子接下来的链路设计其实也不复杂核心就三步但我当初第一次做的时候还是走了弯路主要是没有把每一步的产物和校验单独记录下来。标准的链路设计是这样的阶段0种子构建用 gcc 编译 mini-c 源码得到 mini-c.seed。阶段1一次编译执行./mini-c.seed编译 mini-c 源码得到 mini-c.stage1。阶段2二次编译执行./mini-c.stage1编译 mini-c 源码得到 mini-c.stage2。这三个阶段之间每一份产物都应该做一次完整性校验比如计算 sha256 并记录在构建日志里。别嫌这一步麻烦等到后发现偏差时这些日志就是破案的长处。链路设计里还有一个容易忽略的点编译时的工作目录、环境变量、临时文件路径都要尽量固定。因为编译过程中编译器的源码里面难免会拼接一些路径字符串比如__FILE__宏、错误信息里的源文件路径。如果构建目录不同这些字符串就会写进可执行文件导致两次编译的产物对不上。我后来测试过同一个源码在/tmp/build和/home/user/mini-c两个目录下编译二进制差异平均超过上千字节来源就是路径串。为了避免这种“假性差异”我在实践里的做法是所有构建步骤统一从一个固定目录发起并且通过环境变量把时区设置为 UTC、语言设置为 C排除TZ、LOCALE这些因素干扰export TZUTC export LC_ALLC2.3 字节级对拍的实现要点字节级对拍听起来高大上实现起来其实简单粗暴。最基础的做法就是先算哈希再逐字节比较。我先把最核心的校验命令放出来sha256sum mini-c.stage1 mini-c.stage2 cmp -l mini-c.stage1 mini-c.stage2sha256sum负责从整体上确认两个文件是否一致如果哈希值相同基本上可以断定文件一致。cmp -l则是在哈希不一致时用来定位差异的利器它会逐字节比较并打印出第一个不同字节的偏移量和内容。这两个命令的组合就是字节级对拍的地基。但这里有个细节需要注意如果你的目标是严格的自举验证那比较的应该是“纯净产物”也就是去掉所有非确定性干扰之后生成的二进制。常见干扰包括编译时的时间戳、构建路径、以及编译器代码里用了随机种子或者未初始化的内存。时间戳这个问题尤其经典很多源代码里会用__DATE__和__TIME__宏记录编译时间这两个宏展开后就是字符串会原封不动地进入二进制文件。为了对拍干净我在 mini-c 里做了一个开关通过环境变量控制是否启用这两个宏。如果只做黑盒校验那么对拍脚本可以写得非常简单我放在后面的实操部分会给你一个可以直接跑的版本。3. 实操过程从种子到对拍的完整记录3.1 环境准备和代码库结构这次的实验环境我选择了一台安装了 Linux x86-64 系统的机器宿主编译器是 gcc 12.2.0git 版本是 2.40.1整个实验在不使用任何第三方库的情况下完成。mini-c 的代码库结构如下mini-c/ ├── src/ │ ├── main.c # 编译器入口负责命令行参数解析 │ ├── lexer.c # 词法分析器把源码切分成 token │ ├── parser.c # 语法分析器构建 AST │ ├── codegen.c # 代码生成把 AST 转为 x86-64 汇编 │ ├── type.c # 类型检查 │ ├── util.c # 辅助函数包括哈希表、字符串池 │ └── include/ │ └── mini-c.h # 公共头文件 ├── tests/ # 自动化测试用例 ├── Makefile └── scripts/ └── bootstrap_check.sh这个结构不算复杂但每个模块之间的依赖关系很清晰。做自举实验之前我建议你先跑一遍测试套件确认编译器在宿主平台上的行为是符合预期的。这一步相当于给后面所有实验打了个地基不然后面自举出来的编译器出了问题你都没法判断到底是自举过程引入的问题还是编译器本来就有缺陷。3.2 构建种子跑通第一遍编译实验开始先构建种子。我执行了这样一串命令cd mini-c make clean make CCgcc CFLAGS-O2 -Wall -Wextra -stdc99构建完成后会出现一个名为mini-c.seed的可执行文件。我用一个简单的测试程序验证一下它能正常编译cat /tmp/hello.c EOF int main() { return 42; } EOF ./mini-c.seed /tmp/hello.c -o /tmp/hello /tmp/hello echo $?程序返回 42说明种子工作正常。这时候我马上记录下种子的校验信息这是整个链条的起点sha256sum mini-c.seed记录完哈希之后我会把种子的完整版本、编译参数、构建时间、宿主 gcc 版本都写进笔记。这看起来麻烦却很重要。后面万一又出现“咦这个自举结果怎么跟上次不一样”的疑惑这些记录就是最快的排查入口。3.3 二次编译完成 “自己编译自己”种子验证通过后进入真正的自举阶段。首先用种子编译 mini-c 的源码生成 stage1./mini-c.seed src/main.c src/lexer.c src/parser.c src/codegen.c src/type.c src/util.c -o mini-c.stage1注意这里的编译参数很讲究。mini-c 编译多个源文件时内部会做符号合并如果源文件的顺序不一致最终的符号表布局可能会有差异。所以我在后续所有阶段的编译命令里都用完全一致的文件顺序和完全一致的命令行参数。编译完成后再用 stage1 去编译同一批源码生成 stage2./mini-c.stage1 src/main.c src/lexer.c src/parser.c src/codegen.c src/type.c src/util.c -o mini-c.stage2这两条命令的差别只有一个地方编译器本体分别用了 stage1 和 stage2 之前的产物而输入的源文件、参数顺序、输出文件参数完全一致。到这一步理论上自举流程就跑完了。但是不是真的自举成功必须交给对拍去验证。3.4 字节级对拍脚本和结果分析我为了把验证过程固化下来专门写了一个脚本scripts/bootstrap_check.sh脚本主逻辑是这样的#!/usr/bin/env bash # mini-c 自举对拍脚本 # 用法: ./scripts/bootstrap_check.sh [--verbose] set -euo pipefail TZUTC LC_ALLC export TZ LC_ALL SEED_CMDmake CCgcc CFLAGS-O2 STAGE_SRCsrc/main.c src/lexer.c src/parser.c src/codegen.c src/type.c src/util.c STAGE_LINK_FLAGS-o echo [1/4] 构建种子编译器 make clean make CCgcc CFLAGS-O2 -Wall -Wextra -stdc99 mv mini-c mini-c.seed echo [2/4] 使用种子编译 stage1 ./mini-c.seed $STAGE_SRC $STAGE_LINK_FLAGS mini-c.stage1 echo [3/4] 使用 stage1 编译 stage2 ./mini-c.stage1 $STAGE_SRC $STAGE_LINK_FLAGS mini-c.stage2 echo [4/4] 字节级对拍 S1$(sha256sum mini-c.stage1 | awk {print $1}) S2$(sha256sum mini-c.stage2 | awk {print $1}) echo stage1 sha256 $S1 echo stage2 sha256 $S2 if [ $S1 $S2 ]; then echo 自举验证通过stage1 与 stage2 字节级一致 else echo 自举验证失败两个产物存在差异 if [ ${1:-} --verbose ]; then cmp -l mini-c.stage1 mini-c.stage2 | head -50 fi exit 1 fi第一次跑这个脚本我得到的结果不出意外是对拍通过两个产物的 sha256 完全一致stage1 sha256 b93d4c4a6d5f79a2a5c2b09aeb3d9a1c2a582e3822a2b23c2a2f02e9f11a1c4f stage2 sha256 b93d4c4a6d5f79a2a5c2b09aeb3d9a1c2a582e3822a2b23c2a2f02e9f11a1c4f 自举验证通过stage1 与 stage2 字节级一致这个时刻其实是有点激动的。意味着这个 mini-c 已经进入了稳定状态它不再依赖任何外部编译器它对自己源码的编译结果就是它自己。不过对拍通过只能证明“在这一次构建环境下是稳定的”并不能证明“在所有环境下都稳定”。所以我又做了一组扩展测试把对拍从“构建产物”扩展到“运行输出”。所谓运行输出对拍就是用 stage1 和 stage2 分别编译同一组测试用例然后比较所有编译生成的可执行文件在运行时的输出。这一步是对字节级对拍的补充能发现那种“二进制不同但行为相同”的隐蔽差异也能反过来发现“二进制相同但行为不同”的诡异情况。我在实际项目里非常看重这层兜底因为编译器是给别的程序服务的最终判标准还是行为正确。4. 常见问题与排查技巧实录4.1 高频问题速查表自举这套流程虽然看起来简单但真正动手时各种问题层出不穷。我把遇到的最典型的问题和排查思路整理成了下面的表格你可以直接当一份速查表用。问题现象可能原因排查方向stage1 编译阶段直接崩溃种子编译器存在隐藏的 bug或源码里用了 UB用-fsanitizeundefined构建种子并重跑stage1 与 stage2 哈希永远不一致编译产物里嵌入了时间戳或构建路径检查源码里的__DATE__、__TIME__、__FILE__宏对拍只在某些目录下通过源文件路径字符串进入了汇编输出统一构建目录或将路径换成相对路径stage2 能编译 hello.c但编译复杂程序崩溃编译器在不同阶段对 AST / 符号表的处理存在差异缩小测试用例用二分法定位崩溃源对拍通过但编译出来的测试程序运行结果不对两个阶段都共同继承了一个语义 bug回到宿主编译器阶段补充单元测试这五类问题里第一类最隐蔽第四类最耗时第五类最气人。下面我展开讲讲两个最典型的排查过程。4.2 对拍不一致的深度排查有一次我修改了 mini-c 的解析器新增了对“结构体赋值”的语法支持。按流程跑完自举结果哈希对不上。我打开cmp -l的输出看到差异集中在二进制文件的某个区域大约几百字节而这些区域对应的正好是代码段和数据段。这就很让人警觉了肯定不是时间戳之类的小差异。排查的第一步是先确认是不是编译环境的问题。我试着把 stage1 和 stage2 分别对同一份 hello.c 做编译输出得到两个汇编文件再用 diff 对比发现汇编内容完全一致。这说明编译器的前端输出是稳定的差异不在语法解析和 AST 构建环节。第二步我怀疑是代码生成模块在不同编译器版本里的指令选择差异。mini-c 的代码生成器里有一个小的优化逻辑会把邻近的两个加法指令合并成一条带偏移量的寻址指令。这个逻辑本身是对的但它在处理结构体赋值表达式时会基于 AST 节点指针的地址来做排序而同一份源码在不同编译阶段产生的 AST 节点地址完全可能不同于是排序结果就产生了差异。找到原因后修复方式就是把这个排序逻辑改成基于符号名或者结构体偏移量的确定比较不再依赖内存地址。改完重新构建自举脚本一次通过。这个案例给我的经验很直接一切依赖内存地址的排序和哈希都是自举对拍的定时炸弹。4.3 几个保命的实操心得最后分享几个我踩过无数坑之后总结出来的实操心得每一条都是真金白银换来的。第一全程开启严格的编译器警告。mini-c 中使用 C 语言编写早期我对那些隐式类型转换警告不太在意结果自举后的编译器经常在诡异的地方崩溃。后来我把-Wall -Wextra当强制标准并且把警告数量控制在零很多潜在问题在种子阶段就被拦住了。第二尽量让编译器构建具备可重复性。除了上面讲过的固定路径、固定时区还有一个隐藏变量是环境变量里的CPATH和LIBRARY_PATH它们会影响链接阶段搜索头文件和库的路径。如果两个阶段在链接时的路径不同就可能链接到不同的库。我在脚本里强制把环境变量清理干净只保留最基本的 PATH。第三把日志和旧产物留好。自举实验记录远比想象的更有价值。我在目录里专门建了一个artifacts/文件夹每次跑完自举都会把种子、stage1、stage2、sha256sum 结果、编译参数打包到带时间戳的子目录里。这个习惯在排错时救了我好几次因为很多问题不是当场出现而是过了几天、改了好几轮代码之后才爆发你看着一个“诡异”的结果时能立刻回溯到当时的状态会非常关键。第四自举通过不等于测试通过。字节级对拍证明的是构建的稳定性但编译器内部可能还藏着语义 bug。所以我的流程里对拍通过之后永远跟着一套混合测试套件包括基本语法正确性测试、语义错误检测测试、标准库兼容性测试和压力测试。只有这些全部通过我才会在发布记录里写上一行“自举验证通过”。这是我做这个 mini-c 项目以来最有成就感的事情之一。从种子到对拍整套链路跑通的那一刻我才真正理解了为什么会说编译器是“语言的种子”。你给它一套源码它就能自己生产自己、验证自己、进化自己。如果你正在做编译相关的课程设计或者其他自举实验我强烈建议你也亲手把这条链路搭起来然后你会对“软件是如何建立信任”这件事有一个全新的认识。