编译器自举实战:从种子到字节级对拍的完整记录
2026/9/19 16:20:54 网站建设 项目流程

如果你自己写过一个编译器,大概率对这个场景不陌生:编译器源码里到处都是“我希望这里能有个很高级的语法糖”,但你又很清楚,一旦你用了它,就永远无法自举。编译器自举,说的就是让一个编译器去编译自己的源码;那个最初用别的语言写出来、用来启动第一轮编译的编译器,叫种子编译器;等编译器能编译自己之后,再用新编译器把源码重新编一遍,叫二次编译;把两轮产物逐字节对比,叫字节级对拍。我最近用一个小型 C 子集编译器 sbcc 完整走了一遍这条链,中间炸了无数次,也留下不少可以直接抄作业的套路。今天就把这份实战记录整理出来。

这套东西适合在写编译器、解释器、语言工具链的人参考,尤其是想验证“我的编译器是不是真的够自洽”的人。可能你不做编译器,但如果你对软件构建的可复现性、可信工具链感兴趣,这篇也能帮你理解为什么有些项目宁可让构建过程慢一倍,也要保证输出字节可复现。

1. 自举这事到底在解决什么

1.1 鸡生蛋的问题,和种子的意义

编译器不是凭空长出来的。要编译 C 代码,你得先有一个能编译 C 代码的程序。那第一个 C 编译器怎么来的?早期做法是先用别的语言写一个低级编译器,再用它去编译真正的编译器。这个“先凑合能用”的版本,就是种子编译器。

放到我的项目里,sbcc 是这样一个链条:

  1. 我用宿主机的 gcc 写了一个非常小的 C 编译器,这就是 stage0(种子)。
  2. stage0 去编译 sbcc 的完整源码,得到 stage1。
  3. stage1 再去编译同一份源码,得到 stage2。
  4. 比较 stage1 和 stage2。

这里面的关键是“同一份源码”。如果编译器真的只依赖源码里定义的那些行为,那么 stage1 和 stage2 在字节级别应该几乎一致。如果不一致,说明编译器里面有隐藏的不确定性,或者某些功能只有在第一次编译时碰巧正常,后面就会被自己坑到。

编译器的自举,本质上是在说一件事:编译器不只是能编译别的程序,它有资格成为自己依赖链的一部分。一个不能自举的编译器,总归是“寄宿”在别的工具链上的。能自举之后,它才算真正独立。

1.2 为什么不能编译一次就完事

有人会问,既然种子编译器已经能把源码编译成可执行文件了,那直接用这个可执行文件不就行了,为什么还要二次编译、三次编译?

我第一次做的时候也是这种想法。直到我发现,stage0 和 stage1 的构建方式不一样:stage0 是 gcc 编出来的,它会带上 gcc 的某些头文件展开方式、默认宏、栈对齐策略;stage1 是 sbcc 自己编出来的,处理同一段源码的时候,解析器对某些语法的接受程度可能和 gcc 不同。

举一个最简单的例子。假如 sbcc 源码里写了int *p;,而 sbcc 自己的解析器对*前后的空白处理有 bug,那么 stage1 在编译这段代码时可能把它解析成乘法,直接拒绝掉。这种问题在“编译别的程序”时可能永远测不出来,但在“编译自己”时一定会暴露。因为你拿到的源码,恰恰是用宿主编译器验证过的语法,而你自己的编译器要去逐字解析同一份文件,任何一个宽严不一致都会导致二次编译失败。

所以二次编译不是仪式感,它是对编译器正确性最残酷的回归测试。

1.3 历史视角:TCC、chibicc 和可复现构建

玩过 C 编译器的人多少都听说过 TCC,TinyCC,它整个编译器加链接器大概十万行,而且能用非常快的速度自举。chibicc 是另一个知名教学项目,用大约几千行 C 实现了能编译自身的编译器,作者在 README 里直接把自举作为一个测试项。这些项目传递出来的经验是:

  • 自举需要刻意设计语言子集,而不是事后再补。
  • 编译器一旦能自举,后续对语法、类型系统、ABI 的改动都有了一个“安全网”。
  • 字节级可复现构建,是自举之上更进一步的目标。

我这次选择的是“尽可能小”的路线:源码大概 3000 行 C,目标平台 x86-64 Linux,不支持结构体嵌套、不支持 switch、不支持float,但支持指针、数组、全局变量、函数调用和基本的控制流。为什么砍掉这些?因为自举编译器有个铁律:你只能在源码里使用你能编译的语言功能。你要是想用 switch,那你的编译器必须支持 switch;你要是想用 float,那你的解析器、代码生成、调用约定都得处理浮点寄存器。功能越多,自举越难。

2. 实验设计:编译器到底怎么“长出”自己

2.1 语言子集选型,少即是多

我定义了一个叫 S 语言的小型子集,然后要求 sbcc 的源码只能用 S 语言写。这个约束一开始让我很难受,因为我在写编译器时,总是习惯性地想用switchforgoto。结果全都要改成if-elsewhile

S 语言支持这些:

特性说明
基础类型int、char、指针
运算符加减乘除、取模、位运算、逻辑运算、关系运算、赋值
控制流if-else、while、for、return
函数支持递归,最多 6 个参数,符合 x86-64 SysV ABI
数组一维数组,可以用来做字符串字面量池
指针指针运算、解引用、取地址
全局变量支持初始化器

不支持的东西就很明显了:没有struct,没有union,没有enum,没有switch,没有float,没有long long,没有const。这意味着写编译器的内部数据结构时,我得用“全局数组 + 偏移量”来模拟结构体,或者用并行数组:token_kind[token_count]token_value[token_count]

这种设计的好处是,解析器和代码生成器的实现会变得非常直白。没有复杂的 AST 节点类型,一个节点就是一个 int 标记,配上一个 int 数值。坏处是代码可读性下降,但为了自举成功,这是值得的。

2.2 目标机选择:x86-64 的最小子集

我的 sbcc 直接生成 x86-64 汇编,不经过中间表示,不做寄存器分配,所有局部变量都放栈上。寄存器只用于临时计算和函数调用传参。

这种策略很像 1980 年代早期编译器的做法:简单、粗暴、正确性优先。x86-64 的 SysV ABI 规定函数参数用rdi, rsi, rdx, rcx, r8, r9传递,更多的参数用栈传递,返回值放rax。我让 S 语言最多支持 6 个参数,就是为了避免处理栈上参数对齐的麻烦。

代码生成的一个关键点:表达式求值用“栈机”模式。比如a + b * c会生成:

mov rax, [rbp-8] ; a push rax mov rax, [rbp-16] ; b push rax mov rax, [rbp-24] ; c pop rbx ; b imul rax, rbx ; b * c pop rbx ; a add rax, rbx ; a + ...

性能非常差,但生成逻辑极其容易验证。对自举来说,性能不是问题,正确性才是。

2.3 编译器内部的三个模块

我的 sbcc 分成三段:

  1. 词法分析器:把源码切成 token,token 类型有类型、标识符、数字、符号、文件结束。
  2. 递归下降解析器:把 token 流变成表达式树和语句列表,边解析边生成代码。
  3. 代码生成器:直接输出汇编文本文件,再用系统 as 和 ld 汇编链接。

没有独立语法树的做法,省了很多内存管理,也让我在写的时候不用处理malloc的释放问题。所有动态内存都用静态数组,上限写死,比如 token 数组最多 100 万个,符号表最多 65536 个。超出就报错。

这也算自举编译器的一个实践技巧:能用静态数组,就别用malloc。不然你在 3000 行代码里处理内存泄漏,自举会变成噩梦。

3. 核心实现:哪些代码是自举的关键

3.1 词法分析器:空白、注释和 EOF 的坑

词法分析器最容易出错的地方是文件结束和换行处理。我用一个全局变量cur保存当前字符,用get_char()unget_char()维护一个单字符回溯。

int get_char(void) { if (src_pos >= src_len) return EOF; return src[src_pos++]; } void unget_char(void) { if (src_pos > 0) src_pos--; }

单字符回溯足够处理++--&&||这种双字符 token。反而是“注释最后字符后直接到 EOF”这个场景,我踩了坑:注释/* ... */结束后,如果紧跟文件结束,我的代码会把 EOF 误判为一个普通字符。修复方式是每次读 EOF 时停下来,而不是继续循环。

token 结构很简单,用并行数组:

int token_kind[MAX_TOKENS]; int token_val[MAX_TOKENS];

token_val 对于数字就是整数值,对于标识符就是符号表下标。这种设计在自举时不会遇到“结构体 self-reference”的问题,因为压根没有结构体。

3.2 递归下降解析器:运算符优先级

递归下降解析器是整套编译器的灵魂。我实现了 14 个优先级级别,从逗号、赋值、三元、逻辑或、逻辑与、按位或、异或、与、相等、关系、移位、加减、乘除、一元。

这里有一个常见误区:很多教学编译器只处理+ - * /就结束,但 S 语言必须支持位运算和移位,因为编译器内部经常用1 << 12这种表达式计算页对齐、用& 0xFF取字节值。如果你不支持这些,编译器源码就得用一堆除法乘法来模拟,代码量立刻膨胀。

优先级函数我统一写成parse_expr(int min_prec)的形式,遇到优先级比min_prec低的运算符就返回,否则继续解析右侧。这样可以避免一个优先级一个函数的重复代码。

int parse_expr(int min_prec) { int lhs = parse_unary(); while (1) { int bin_op = cur_token; int prec = bin_prec(bin_op); if (prec < min_prec) break; next_token(); int rhs = parse_expr(prec + 1); emit_binop(bin_op, lhs, rhs); lhs = new_temp(); } return lhs; }

3.3 代码生成:x86-64 的栈帧布局

每个函数入口,我生成:

push rbp mov rbp, rsp sub rsp, <frame_size>

帧大小由函数体里所有局部变量和临时变量的总和决定。我在解析阶段先统计变量数量,再生成序言。这意味着两遍处理:第一遍扫描参数列表,分配栈槽;第二遍解析函数体,生成指令。

参数寄存器怎么存到栈上?规则是前 6 个参数分别mov [rbp-offset], rdi等等。参数在符号表里记为“局部变量”,偏移量为负数。局部变量按声明顺序递减分配。

调用函数时,我生成:

mov rax, <arg1> push rax mov rax, <arg2> push rax call <func> add rsp, <arg_count * 8>

这里有个细节:传递给被调用函数的第 7 个及以上参数如果用栈,被调用函数并不知道栈上有几个参数是自己该用的,所以我干脆禁止超过 6 个参数。S 语言函数定义和函数调用的自举检查里,就有一条:参数个数大于 6,直接编译报错。

4. 自举流程:从种子到二次编译的完整记录

4.1 构建目录与 makefile 设计

项目目录结构很简单:

sbcc/ src/ sbcc.c # 完整源码,S 语言写死 stage0/ sbcc_stage0 # 用宿主 gcc 编译 sbcc.c 得到的种子编译器 stage1/ sbcc_stage1 # stage0 编译 sbcc.c 得到 stage2/ sbcc_stage2 # stage1 编译 sbcc.c 得到 test/ sample.c

makefile 里的核心目标是这样:

stage0/sbcc_stage0: src/sbcc.c gcc -std=c99 -O0 -o $@ $< stage1/sbcc_stage1: src/sbcc.c stage0/sbcc_stage0 ./stage0/sbcc_stage0 $< -o $@ stage2/sbcc_stage2: src/sbcc.c stage1/sbcc_stage1 ./stage1/sbcc_stage1 $< -o $@ bootstrap-check: stage1/sbcc_stage1 stage2/sbcc_stage2 cmp stage1/sbcc_stage1 stage2/sbcc_stage2

编译命令伪装成./sbcc input.c -o output,实际上内部会调用/usr/bin/as/usr/bin/ld完成汇编和链接。

4.2 第一次自举现场记录

我记得很清楚,第一次跑make bootstrap-check的时候,阶段输出是这样的:

$ make stage1/sbcc_stage1 gcc -std=c99 -O0 -o stage0/sbcc_stage0 src/sbcc.c ./stage0/sbcc_stage0 src/sbcc.c -o stage1/sbcc_stage1 Parsing OK, code generated. Assembling... Linking... OK $ make stage2/sbcc_stage2 ./stage1/sbcc_stage1 src/sbcc.c -o stage2/sbcc_stage2 Parsing OK, code generated. Assembling... Linking... OK $ cmp stage1/sbcc_stage1 stage2/sbcc_stage2 $ echo $? 0

cmp返回 0 意味着两个文件一模一样。当时我还不太信,又跑了一次sha1sum,看到两个哈希完全一致才算放心。那种感觉很难形容:一个由 gcc 生成的编译器,和由它自己生成的编译器,在编译同一份源码时,产物竟然逐字节一致。这说明这个编译器的行为已经完全由源码决定,宿主编译器 gcc 的某些“性格”没有泄漏进来。

4.3 为什么 stage0 和 stage1 不需要字节一致

注意,我只比较 stage1 和 stage2,不比较 stage0 和 stage1。因为 stage0 是 gcc 生成的,gcc 和 sbcc 的代码生成策略不一样,stage0 和 stage1 不可能一致。真正能证明“自举成功”的,是 stage1 和 stage2 一致。

如果把比较范围扩大,你会看到很多差异来自编译器内部环境变量、时间戳、文件路径、链接器版本。这些差异不是编译器的错,而是构建系统的信息泄漏。stage2 和 stage3 之间如果有差异,才需要警惕。

我做过一个实验:在 stage2 已有之后,再用 stage2 编译一次得到 stage3,然后比较 stage2 和 stage3。理想情况下它们也应该一致。如果 stage2 和 stage3 不一致,说明编译器在重复运行时不稳定,这种不稳定大多来自未初始化的变量、哈希表遍历顺序、或者依赖了环境变量。

5. 字节级对拍:验证自举的终极手段

5.1 对拍到底在比什么

字节级对拍,说的就是把两个编译产物视为纯字节流,逐个字节比较。它能发现的问题非常底层:

  • 指令顺序不同:比如两个阶段的编译器对同一个表达式生成了不同顺序的 push/pop。
  • 常量编码不同:比如1被编码成 4 字节还是 8 字节。
  • 符号表顺序不同:链接进可执行文件的符号名、节区排列顺序不一致。
  • 栈帧大小不同:说明变量分配逻辑有偏差,这是严重 bug。

最常见的三种原因是:符号表用qsort但没有稳定排序,哈希表遍历顺序取决于插入顺序,编译器运行时读取环境变量影响了输出。

5.2 对拍命令清单

我平时会准备几条命令,一步一步排查:

# 1. 先看整体是否一致 sha1sum stage1/sbcc_stage1 stage2/sbcc_stage2 # 2. 如果不一致,看第一个不同字节的偏移 cmp -l stage1/sbcc_stage1 stage2/sbcc_stage2 | head # 3. 看汇编输出差异(需要先让编译器保留 .s 文件) diff <(stage1/sbcc_stage1 src/sbcc.c -S) <(stage2/sbcc_stage2 src/sbcc.c -S) | head # 4. 反汇编对比,过滤掉地址后比较指令序列 objdump -d stage1/sbcc_stage1 | awk '{$1=""; print}' > stage1.dis objdump -d stage2/sbcc_stage2 | awk '{$1=""; print}' > stage2.dis diff stage1.dis stage2.dis | head

第 4 条命令很实用:ELF 文件里函数地址、全局变量地址可能不同,但指令助记符序列应该一样。去掉地址列之后 diff,就能过滤掉“合法但不影响行为”的地址差异。

5.3 字节一致不等于行为一致

这里要泼一盆冷水:字节级对拍通过,只能说明两个编译器在“给定输入”下产生了相同的输出。它不能保证编译器在所有输入下行为一致。我见过一个编译器,对0NULL的处理不同,但对拍用的源码里刚好没有触发那条路径,所以对拍通过,但实际编译其他程序时崩了。

所以对拍只是自举链里的一环,不是终点。更完整的测试应该是:

测试项目的
字节级对拍验证构建确定性
普通测试用例集验证功能正确性
编译自身后再编译自身验证编译器对自身的处理没有特殊化
用不同优化等级自举验证不依赖未定义行为

6. 实战中踩过的坑和排查思路

6.1 stage1 拒绝编译自身源码

最常遇到的坑就是:gcc 能编译 sbcc.c,但 stage0 生成的 sbcc_stage1 在编译 sbcc.c 时报错。

我遇到过一个经典问题:源码里写了for (;;),而我的解析器在处理空条件时,要求必须有;,但for (;;)的两个分号之间是空 token,解析器把第二个分号之前当成了条件,执行到;时发现 token 类型不对,直接报错。

修复方式很朴素:parse_for里判断;之后,如果下一个 token 还是;,就表示条件为空,直接把条件表达式设为常量 1。

从这里学到的经验是:写编译器的时候,要经常“用自己编自己”来测试。你觉得理所当然的语法,在实现里可能就是 bug。

6.2 指针运算的符号扩展问题

S 语言里,指针减法是一个大坑。比如p - q,在 C 语义里,结果除以元素大小。但 sbcc 的int*相减我一开始做成:

mov rax, [p] sub rax, [q] sar rax, 2

如果两个指针本身是负数(低地址),减法的结果可能被错误符号扩展。这个问题只有在我让编译器管理堆栈时才会出现:某些局部区域用高位地址,某些用低位地址,两个地址相减会出现正负混淆。

定位这个问题时,我把所有sub指令都打印出来,用一个小脚本统计哪些sub紧跟在mov之后,目标操作数恰好是[rbp-x][rbp-y]。然后人工检查是不是指针相减。

6.3 栈帧大小计算错误导致二次编译崩溃

还有一次,stage1 编出了 sbcc_stage2,但 sbcc_stage2 编译任何程序都段错误。这就比较邪门了,因为两个阶段的源码相同,stage2 应该是从 stage1 正常生成的。

排查过程让我记忆犹新。我打开反汇编,发现 sbcc_stage2 的main函数序言里sub rsp, 0x...的栈帧大小,比 stage1 少了一个字节对齐上来的 8 字节。原因是我在代码生成器里计算帧大小时,把“压栈保存参数”和“局部变量分配”分成两步,但局部变量分配时漏算了函数调用时的临时变量槽位。

这个 bug 在 gcc 编译 stage0 时没暴露,因为 gcc 生成的代码对栈帧大小没有那么敏感;但 stage0 编出的 sbcc_stage1,其运行时栈布局恰好踩到了边界,导致call指令返回地址被覆盖。

这次事故让我定下一条规则:每次调整代码生成器后,先跑一遍自举链,再跑测试用例集。自举链不是可选步骤,它是代码生成器质量的门禁。

6.4 对拍通过但功能仍不对的特例

有一种情况很坑:stage1 和 stage2 完全一致,但两者都是错的。比如我把加法生成了减法,对拍当然一致,因为两份可执行文件是一模一样的错法。这时候只能靠外部的测试用例,不能靠对拍。

所以我维护了一个tests/目录,大概 50 个测试用例,覆盖循环、递归、指针运算、数组下标、函数参数、全局变量初始化。每次自举成功之后,我会把 stage1 和 stage2 分别拿去跑这一套测试,对比两者的输出。如果 stage1 和 stage2 的输出一致,但和预期不同,那就是编译器本身有逻辑错误,需要去查代码生成,不能靠重复自举发现。

7. 做自举编译器的一点心得和建议

7.1 尽早把自举流程放进 Makefile

我最后悔的是没有从第一天就把自举流程写进 makefile。一开始只是拿一堆测试用例验证功能,直到某天我突然想“如果它能编自己会怎样”,才手动跑起来。结果一跑就崩,而且崩在完全没有想到的角落。

建议任何编译器的 maintainer,都从 v0.1 开始加入一个bootstrap目标。一旦你能自举,每次改动后跑一遍,能立刻发现问题。自举就变成了一个巨大的集成测试。

7.2 字节级对拍不是目的,确定性才是

我现在不会特别执着于 stage1 和 stage2 是否逐字节一致。我更关心“差异是怎么产生的”。如果两个二进制文件相差几十个字节,我会去看那些差异是不是来自构建 ID、文件路径、符号顺序。如果是,那就接受;如果不是,那就要排查。

其实很多时候,字节级差异来自链接器,而不是编译器。链接器可能把节区顺序排得不同,或者插入了.comment段里的编译时间。所以对拍前,我经常用stripobjcopy去掉元信息段再比,这样能更快聚焦到真正的代码差异。

strip stage1/sbcc_stage1 stage1_stripped strip stage2/sbcc_stage2 stage2_stripped cmp stage1_stripped stage2_stripped

7.3 不要贪多,功能子集越小越好

做自举编译器最大的诱惑是想要支持很多功能。我今天加一个float,明天加一个struct,后天加一个switch,每加一个功能,自举链的复杂度就上一个大台阶。因为编译器源码必须用这些新功能重新实现内部逻辑。

我的建议是:先做到能自举的最小核心,比如 int、指针、数组、函数、递归、控制流。自举稳定之后,再逐步扩展功能,每扩展完一个,立刻跑一遍自举和对拍。这样能维持一个随时可用的“安全基线”。

7.4 最后一个小技巧

在排查自举链问题时,我有一个保留节目:修改编译器源码里的一个常量,比如把某个默认阈值从 100 改成 200,然后重新自举。如果这个修改能正确传导到 stage1、stage2,说明编译器的常量传播没有坏;如果 stage2 还是 100,说明某个地方发生了“代码生成把立即数写死”的错误。这个技巧帮我抓到过至少两个深度 bug。

做编译器自举,本质上就是让编译器不断咬住自己尾巴转圈。这个过程既折磨人,又让人上瘾。每次看到cmp返回 0,我都会有一种真实的踏实感:这东西没骗我,它真的知道自己长什么样。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询