ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

编译器自举深度解析:从种子编译器到GCC三阶段构建

编译器自举深度解析:从种子编译器到GCC三阶段构建 编译器的自举简单说就是一个编译器能够编译自己的源码。听上去像一句绕口令但它背后是一个真实的工程难题——第一台编译器出现之前世界上还没有编译器第一台编译器出现之后才算有了“用编译器编译编译器”的可能。本文会按“是什么、为什么、怎么做到、怎么验证、踩过哪些坑”的顺序把自举拆成一个不再神秘的技术过程。即使你之前只写过简单的 C 或 Python 程序也能在读完本文后用 GCC 的构建过程亲手观察一次自举。1. 先理解编译器自举到底在解决什么问题1.1 一句通俗解释编译器“自己编译自己”意味着什么编译器compiler是一个程序它的输入是源码文本输出是目标机器代码或中间表示。如果这个编译器本身是用它自己支持的语言编写的那么它就具备了“自举”self-hosting能力你可以用这个编译器的可执行文件去编译这个编译器的源码最终生成一个新的可执行文件。举一个常见例子GCC 的主体是用 C 和 C 写的。一台机器上已经安装了 GCC 的可执行程序时你可以用这个可执行程序去编译 GCC 的完整源码得到一个新的 GCC 可执行程序。新生成的 GCC 在功能上与原 GCC 等价但它确实是从源码重新构建出来的而不是从安装包拷贝来的。这就是自举在工程层面的含义。它并不是“程序把自己的源码打印出来”而是“一个翻译工具能够翻译它的翻译逻辑本身”。1.2 自举不是什么三个容易混淆的概念搜索“自举”时你会看到很多内容大致可以分为三类概念所属领域核心行为与编译器自举的关系编译器自举软件工程编译器编译自身源码并生成可执行文件本文主题Quine自产生程序程序设计程序把自己的源码文本输出到 stdout同样涉及自我引用但不是编译过程自举电容、自举电路电子工程利用电容储能抬升驱动电路电压中文同名含义完全不同另一个要分清的是“编译器”和“编辑器”。编辑器editor负责录入和修改文本编译器负责把文本翻译成目标代码。编译器自举和编辑器没有直接关系真正参与自举的是“翻译逻辑”不是“文本编辑逻辑”。1.3 自举为什么值得学可移植性与工具链可信度自举的价值体现在两个地方。第一是可移植性。当你要把一套开发环境迁移到一个全新的 CPU 架构上时只要手里有一个极小的种子编译器再加上完整源码就能逐步重建整个工具链。这个过程不是魔法而是分阶段推进的工程迭代。第二是可信度。一个分布式编译器如果声称“二进制来自某一份源码”别人可以重新构建一遍比较两轮构建的产物是否一致。自举验证就是这个流程的核心操作。对这个机制有清晰理解后再看“构建服务器上的二进制到底是谁生成的”这类问题会更有判断力。2. 把自举拆开T 型图、种子编译器与阶段推进2.1 用 T 型图描述一次编译过程理解自举之前先理解编译器的三类属性源语言、目标语言、实现语言。编译器用实现语言 I 编写把源语言 S 翻译成目标语言 T。记作S → T由 I 实现。画成 T 型图是下面这样------------------ | S | T | ------------------ | I | ------------------这个图的核心价值是组合。假设你有一个编译器 AS → L用 I 编写还有一个编译器 BI → M用 J 编写。那么可以通过“先编译再编译”的组合得到一个 S → M 的编译器。自举的所有阶段推进本质都是这种组合关系的不断展开。很多初学者第一次看自举时会觉得这是个循环论证“如果用 C 写编译器那编译器去哪里找”答案在于第一代编译器不需要用 C 写它可以用汇编、机器码甚至手写的加载器来充当种子。种子能编译的语言很小但足够启动整个迭代链条。2.2 起点是一个极小的种子编译器一个完整的“从零自举”过程通常从种子编译器开始。种子编译器的功能往往弱到不像编译器它可能只认识return 数字;这一种语句只负责输出一条汇编指令。但它的存在解决了鸡生蛋蛋生鸡的问题——它不需要用目标语言来编译自己它是用汇编或机器码手工写成的。接下来按阶段推进阶段 1用汇编写种子编译器 seed只能编译语言子集 S0。 阶段 2用 S0 写编译器 c0 的源码c0 能编译语言子集 S1S0 的超集。 阶段 3用 seed 编译 c0 源码得到可执行文件 c0。 阶段 4用 c0 编译 c1 源码c1 能编译语言子集 S2。 阶段 5继续扩大语言子集直到写出完整语言 L 的编译器。 阶段 6用完整编译器编译它自己的源码验证产物与上一轮基本一致。阶段 6 完成时自举就实现了。整个过程真正解决循环依赖的关键是每一轮使用的编译器和它要编译的源码都不是同一个对象。这就是为什么自举看起来像循环实际上是一条螺旋上升的推进链。2.3 自举四个阶段速查表阶段输入的源码使用哪个编译器产生的可执行文件说明种子无源码直接手写指令汇编器或手工汇编seed功能极弱只解析 S0第一轮用 S0 写的编译器源码seedc0c0 能处理 S1第二轮用 S1 写的编译器源码c0c1c1 能处理 S2自举验证用完整 L 写的编译器源码c_fullc_full2对比 c_full 与 c_full2需要说明的是实际语言项目的子集划分远没有这么干净中间会穿插语法扩展、类型系统修复和代码生成优化但整体推进逻辑是一样的。3. 亲手观察一次自举以 GCC 的 3 阶段构建为例3.1 为什么拿 GCC 当例子GCC 是开源编译器里最便于观察自举过程的一个。它的构建系统内置了bootstrap目标会自动执行多轮构建并在最后比较前后两轮生成的编译器是否一致。你不用发明一个玩具语言只要在自己的 Linux 机器上用现成工具就能看到“编译器编译自己”的真实过程。3.2 构建之前要准备什么学习环境里建议准备一台至少有 2 核 CPU、4 GB 内存、20 GB 空闲磁盘的 Linux 机器。不同发行版的依赖包名会有差异这里以 Ubuntu/Debian 为例sudo apt-get update sudo apt-get install build-essential sudo apt-get install libgmp-dev libmpfr-dev libmpc-dev sudo apt-get install flex bison texinfo这几步分别安装了基础编译工具、数学库开发头和辅助生成工具。GCC 构建时依赖 GMP、MPFR、MPC 三个库缺少任意一个configure 阶段就会直接报错。下载 GCC 源码时建议只用官方镜像站或发行版自带源码包。版本可以按当时官网最新版本为准下面以gcc-13.2.0为例说明命令格式wget https://ftp.gnu.org/gnu/gcc/gcc-13.2.0/gcc-13.2.0.tar.xz tar -xf gcc-13.2.0.tar.xz3.3 配置、构建和自举验证GCC 不推荐在源码目录内直接构建而是建议建一个独立构建目录把源码目录和构建产物分开。这样方便清理、隔离环境也方便保存构建日志。mkdir -p /opt/gcc-bootstrap/src mv gcc-13.2.0 /opt/gcc-bootstrap/src/gcc-13.2.0 mkdir -p /opt/gcc-bootstrap/build mkdir -p /opt/gcc-bootstrap/install cd /opt/gcc-bootstrap/build ../src/gcc-13.2.0/configure \ --prefix/opt/gcc-bootstrap/install \ --enable-languagesc,c \ --disable-multilib make -j$(nproc) bootstrap这里的关键参数是make bootstrap。它告诉构建系统先用当前机器上的主机编译器生成第一轮 GCC再用第一轮 GCC 生成第二轮 GCC然后用第二轮 GCC 生成第三轮 GCC最后对比第二轮和第三轮的产物。构建时间通常在十分钟到几个小时之间取决于机器性能。构建过程中会看到大量stage1-gcc、stage2-gcc、stage3-gcc之类的目录名称。看到这些目录出现就说明自举过程确实分阶段发生了。3.4 验证自举结果比较第二轮和第三轮产物构建完成后验证自举是否成功最直接的方法是比较两轮编译器可执行文件的哈希值。不同 GCC 版本的目录结构会有差异可以用 find 定位cd /opt/gcc-bootstrap/build find . -type f \( -name xgcc -o -name cc1 \) | sort然后对找到的对应文件计算哈希md5sum ./stage2-gcc/xgcc ./stage3-gcc/xgcc以下命令只是示意实际目录名要以你构建出的目录结构为准md5sum ./stage2-gcc/xgcc ./stage3-gcc/xgcc如果两个哈希完全一致说明第二轮和第三轮构建生成了相同的编译器。这基本可以证明这个编译器能够用自己的可执行文件重新编译自身源码并且结果稳定。如果哈希不一致则需要进入排查流程。安装可执行文件并查看版本make install /opt/gcc-bootstrap/install/bin/gcc -vgcc -v中会显示配置参数和版本信息。这一步能确认最终产物就是你自己构建出来的那个编译器。4. GCC 构建参数速查改什么会影响自举结果4.1 configure 参数速查表参数含义常见取值配置错误时的表现推荐场景--prefix安装路径/usr/local或自定义目录目录无写权限时报错版本升级覆盖旧文件生产环境装到独立目录便于回滚--enable-languages要编译的语言前端c、c,c等前端越多编译越慢、内存越高只需要 C 就写c别贪多--disable-multilib是否构建多架构库默认由平台决定x86_64 机器上开启 multilib 但缺少 32 位库时链接失败通用服务器建议关闭--with-system-zlib是否使用系统 zlib默认视版本而定系统 zlib 过旧可能失败系统库可维护性高时使用参数本身不是越多越好。每一次configure参数的差异都会体现在后续编译器生成的代码里。同一个源码目录用不同参数构建两遍得到的可执行文件大概率不会完全一致。这也是为什么生产环境里要固定一份经过验证的 configure 参数集。4.2 参数配置不当的错误现象与定位先看一个典型组合--enable-languagesc但主机上并没有安装 g。configure 阶段会提示找不到 C 编译器甚至不会走到真正编译那一步。解决办法是先安装 g或者把参数改成--enable-languagesc。再看--prefix配置到系统目录但当前用户不是 root 的情况。构建可能很顺利但make install时失败日志最后几行通常是Permission denied。定位方式很简单ls -ld查看目标目录权限。生产环境推荐把安装目录设置成独立路径例如/opt/toolchain/gcc-13.2.0升级时切换软链接即可回滚。第三类错误是缺少 GMP、MPFR、MPC。configure 会直接提示类似 “Building GCC requires GMP 4.2, MPFR 2.4.0, MPC 0.8.0” 的信息。处理方式不是去网上下载三个库的源码手动拼装而是先检查系统包管理器里是否有对应 dev 包dpkg -l | grep libgmp-dev dpkg -l | grep libmpc-dev dpkg -l | grep libmpfr-dev装完依赖后安全起见建议重建构建目录因为 configure 探测结果不会因为make clean完全重置。5. 自举中的常见坑与排查链路5.1 常见坑一误以为自举 程序输出自己的源码很多资料把 Quine 和自举放在一起讲容易让人混淆。Quine 是“程序运行后打印出自己的源码文本”自举是“编译器编译自己的源码并生成可执行文件”。两者都涉及自我引用但目标完全不同。验证方式很简单如果一个程序只是把源码原样打印出来它不能称为自举的编译器必须有一个可执行的翻译流程参与并且能够重复迭代才算自举。5.2 常见坑二忘记构建环境会影响自举结果自举验证出现哈希不一致最常见的反向推手不是源码问题而是环境问题。C 和 C 编译器预置了__DATE__、__TIME__、__FILE__这类宏。源码里如果用到__TIME__每次构建都会把当前的编译时刻写进二进制。两个阶段构建时间不同产物自然不同。这类差异不能说明编译器不自举只能说明构建不可复现。处理办法是检查两轮构建是否在同一台机器、同一时刻附近完成并提前统一环境。很多构建系统会读取SOURCE_DATE_EPOCH环境变量来固定时间戳GCC 生态里也建议在评审阶段排除这种确定性差异之后再比较哈希。表格式排查链路如下问题现象常见原因检查方式处理建议stage2 与 stage3 哈希不一致源码使用了__TIME__等宏strings xgccgrep 20xx 查看嵌入时间stage2 与 stage3 哈希不一致两轮构建参数不同对比每个阶段的 configure 命令日志统一 configure 参数后重建stage2 与 stage3 哈希不一致构建目录污染ls -la查看是否有残留文件使用全新构建目录编译器功能不同主机编译器版本过旧gcc --version升级主机编译器或换稳定版本5.3 常见坑三把“对比哈希”当成自举的唯一标准自举的严格定义是“编译器能编译自身源码且生成的编译器可用”。哈希一致是一种强验证手段但不是唯一手段。真正的工程验证还要看测试集。GCC 构建完成后可以运行测试套件make check这一条命令会执行大量测试用例覆盖语法、代码生成、优化和运行时行为。测试集通过比单一哈希对比更有说服力。实际项目里建议把哈希对比和测试集结果一起记录到构建日志里而不是只保存“构建成功”四个字。5.4 常见坑四一上来就追求“完整自举”新手容易把自举理解成“一夜之间写一个完整编译器并让它编译自己”。正确的推进方式是先定义一个极小的语言子集例如只支持整数、加法、赋值和函数调用然后让这个子集足够表达“下一个更大子集的编译器”。这个过程有一些反直觉写一个能编译完整语言的编译器之前你往往要先写一个能编译编译器源码的编译器。它要求你把语言特性逐步扩展而不是一次性设计到位。对个人学习来说从解析return 数字;开始是最稳的路径。6. 动手练习从小种子到完整自举6.1 最小种子编译器能编译return 数字;就够下面是一个用于教学的种子编译器草图功能是读取一个极简源码文件输出一条汇编指令。它不处理表达式、变量、语法树只完成“输入一行return n;输出对应 mov 指令”这一件事。#include stdio.h #include string.h int main(int argc, char **argv) { if (argc 3) { fprintf(stderr, usage: %s input output\n, argv[0]); return 1; } FILE *in fopen(argv[1], r); if (!in) { fprintf(stderr, cannot open %s\n, argv[1]); return 1; } int value 0; char line[256]; while (fgets(line, sizeof(line), in)) { if (sscanf(line, return %d;, value) 1) { break; } } fclose(in); FILE *out fopen(argv[2], w); if (!out) { fprintf(stderr, cannot open %s\n, argv[2]); return 1; } fprintf(out, # generated by seed compiler\n); fprintf(out, movl $%d, %%eax\n, value); fprintf(out, ret\n); fclose(out); return 0; }编译并运行gcc -o seed_compiler seed_compiler.c printf return 42;\n demo.m1 ./seed_compiler demo.m1 demo.s cat demo.s预期输出类似# generated by seed compiler movl $42, %eax ret这个种子编译器能做的事情极少但它已经建立了“源码文件到汇编文件”的翻译通道。自举实验可以从这个通道出发继续写一个能解析表达式的编译器的源码然后让 seed 编译它。每一步只扩展一点点直到最终编译器能够编译自身的源码。6.2 进阶练习给 GCC 源码加上你的标记再做自举如果种子编译器练习不过瘾可以进入真实工具链练习。思路是修改 GCC 源码里的一个字符串常量例如版本字符串在后面添加你自己的标记然后完整执行make bootstrap。构建完成后运行/opt/gcc-bootstrap/install/bin/gcc -v如果输出里能看到你的标记说明最终产物确实来自于你修改过的源码而不是主机上旧编译器的复制品。这个实验能让“自举”从抽象概念变成可观察、可验证的过程。6.3 自举项目开始前的检查清单下表可以直接复制到项目文档里作为自举或工具链构建前的核对依据检查项说明是否完成明确种子编译器用哪个语言/工具生成第一代编译器定义语言子集顺序S0、S1、S2 分别支持什么语法准备主机编译器第一轮构建使用的 GCC/Clang 版本记录构建环境OS、CPU、位数、依赖库版本分离目录源码目录、构建目录、安装目录互相独立固定构建参数保存 configure 参数到版本库或文档统一时间戳设置SOURCE_DATE_EPOCH或忽略时间宏差异设计验证方式哈希对比、测试集、版本号标记中的至少一种准备回滚方案保留旧版本可执行文件和构建日志这套清单对所有编译器自举项目都适用不局限于 GCC。7. 看完自举之后下一步值得做的三件事第一件事是理解“交叉编译”和自举的关系。自举关注的是编译器能否编译自己交叉编译关注的是在一台机器上生成另一台机器的代码。两者组合起来就是工具链移植的完整路线先用旧编译器生成一个交叉编译器再用交叉编译器在目标平台上重建自举工具链。第二件事是建立“可信构建”的意识。现代软件供应链里“二进制对应哪份源码”是一个需要证据的问题。自举验证提供的就是这种证据。生产服务器上构建工具链时应该把构建机器、构建参数、依赖版本和验证结果一并存档而不只是保存安装包。第三件事是回到理论学习。自举不是孤立技巧它与语法分析、代码生成、运行时支持都有关联。理解自举之后去读龙书或者动手写一个简单解释器会比一开始就埋头啃编译器理论更有方向感。最值得做的第一步不是背定义而是亲手跑一次 GCC 的三阶段构建。当构建日志里出现两份哈希完全一致的编译器时自举这个概念就不再是一个术语而是一条你自己走过的工程链路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进