yzhe819's Blog

用自己编译自己:编译器与那个古老的悖论

2026.06.15

先有鸡,还是先有蛋?先有编译器,还是先有源码?自举(Bootstrapping)藏着一个和生命起源一样古老的悖论——而工程师们,早已悄悄解开了它。

代码仓库

这是一个用 C 语言写成的 C 语言子集编译器,灵感来源于 c4。它足够小,可以编译自身;足够完整,足以让你理解一个编译器从字符到执行的全过程。


特性

  • 支持 intchar、指针类型
  • 支持函数定义与调用、递归
  • 支持 if / elsewhilereturnenum
  • 支持完整的一元与二元运算符(含位运算、逻辑运算、比较运算)
  • 支持数组访问与类型转换
  • 内置简易虚拟机,直接解释执行字节码
  • 可以编译自身(自举)

架构总览

源代码(.c 文件)
       
       
┌──────────────────┐
   词法分析器        next()
                    将字符流切割为 token 序列
  支持的 token   
  标识符 / 数字   
  字符串 / 关键字  
  运算符 / 标点    
└────────┬─────────┘
         
         
┌──────────────────────────────┐
        语法分析器               递归下降解析
                              
  program()                     入口,循环解析全局声明
  ├─ global_declaration()       全局变量 / 函数 / enum
     ├─ enum_declaration()     枚举常量
     └─ function_declaration()
         ├─ function_parameter()  形参列表
         └─ function_body()       函数体
             └─ statement()       语句
                 └─ expression()  表达式(按优先级递归)
  └─ ...
                              
  同步生成字节码,写入 text   
└────────┬─────────────────────┘
         
         
┌──────────────────┐
    虚拟机           eval()
                    解释执行 text 段中的字节码
  寄存器:         
  pc / bp / sp / ax
                  
  内存段:         
  text / data / stack
└──────────────────┘

虚拟机指令集

1. 存取指令

指令说明
IMM将立即数加载到 ax
LEA将局部变量地址加载到 ax
LC从地址(ax)读取 char
LI从地址(ax)读取 int
SC将 ax 写入栈顶地址(char)
SI将 ax 写入栈顶地址(int)
PUSH将 ax 压入栈

2. 运算指令

指令说明
ADD / SUB / MUL / DIV / MOD算术运算
OR / XOR / AND位运算
SHL / SHR移位运算
EQ / NE / LT / LE / GT / GE比较运算

3. 控制流指令

指令说明
JMP无条件跳转
JZax == 0 时跳转
JNZax != 0 时跳转
CALL调用函数(保存返回地址)
ENT创建栈帧(函数入口)
ADJ清理参数(调整 sp)
LEV恢复栈帧并返回

4. 系统调用

指令对应函数
OPENopen()
CLOSclose()
READread()
PRTFprintf()
MALCmalloc()
FREEfree()
MSETmemset()
MCMPmemcmp()
EXITexit()

符号表结构

词法分析阶段,所有标识符被统一存入符号表(一个平铺的整数数组):

每个标识符占 IdSize 个槽位:
[ Token | Hash | Name | Type | Class | Value | BType | BClass | BValue ]

其中 BType / BClass / BValue 用于在函数内部临时保存被遮蔽的全局变量信息,函数返回后恢复。


编译与运行

⚠️ 编译器运行在 32 位模式下,需要使用 -m32 标志。macOS 用户注意:Xcode 12 起已移除 32 位支持,建议在 Linux 或 Docker 环境下运行。

# 编译本编译器(注意 -m32)
gcc -m32 cc.c -o cc

# 或者直接用 Makefile
make compile

# 用它编译一个 C 源文件
./cc hello.c

# 开启 debug 模式(打印每条字节码指令)
./cc -d hello.c

运行测试

# 运行内置测试套件
make test
# 等价于:./cc test.c

自举:它如何编译自己

这个编译器最有趣的地方在于——它可以编译自身:

# 一步完成:编译自身并运行测试
make bootstrap
# 等价于:./cc cc.c test.c

# 或者手动分步:
# 第一步:用系统 gcc 编译得到第一个二进制
gcc -m32 cc.c -o cc

# 第二步:用 cc 编译自身源码
./cc cc.c

已知问题

test.c 中标注了三个已知失败的测试用例,均与逻辑运算符对负数的处理有关:

test(1, c || c);   // failed — c 为负数时 || 结果不正确
test(TRUE, b && c); // failed — && 对负数操作数处理有误
test(TRUE, c && c); // failed

根本原因:VM 的 JNZ(不等于零则跳转)指令在处理负数时行为不符合 C 标准——C 标准规定任何非零值均为真,但当前实现可能仅检查符号位。

这正是「先有鸡还是先有蛋」的工程答案:第一个编译器总是由别的工具引导而来,此后它便可以自我延续。


参考资料

项目

文章

书籍