资讯动态

编译器自举是什么?GCC与三阶段构建原理解析

发布时间:2026/9/8 11:04:37 来源:尧图企业网站定制
写过 C 语言的开发者可能都想过一个类似的问题GCC 是用 C 写的但 GCC 又负责编译 C 代码那么第一个 GCC 是谁编译出来的再往前推一步第一个 C 编译器又是哪来的这个问题听起来像“先有鸡还是先有蛋”但工程界从计算机诞生那天起就有答案它的名字叫编译器自举Bootstrap。自举这个说法第一次听的人容易觉得高深好像编译器做了什么很玄学的循环操作。实际拆开看逻辑非常朴素先用一个已有的工具编译出一个小型编译器再用这个小编译器去编译一个功能更完整的编译器最后让这个新编译器去编译它自己的源码。能通过自己这一关说明它已经具备独立构建自身的能力。这篇文章不打算只讲概念。我想把编译器自举从问题、历史、原理、实操到验证讲完整。读完你至少能回答三个问题第一个编译器是怎么被造出来的GCC 源码安装时为什么默认要做一次 bootstrap以及在实际构建中怎么判断一个编译器真的在“编译自己”。1. 这篇文章真正要解决的问题很多人看到“编译器自举”这个词第一个反应是“这事好像很高级但不关我的事”。实际上它和普通开发者的距离比你想象得要近得多。首先面试笔试里它是个高频考点。编译器相关的岗位、系统方向岗位经常会把自举原理作为考察点。哪怕只背答案理解了三阶段流程也比死记硬背强。其次在实际项目里只要你手动编译过 GCC、Clang、Go 这种大型基础软件就会遇到一个奇怪的现象源码包解压后第一遍编译很慢第二遍编译更慢中间还经常提示“重新构建”“比对结果”。这套流程背后的原因就是自举。再往深一层自举还是一种很特殊的工程验证手段。它能证明一件事这个编译器在真实世界中被运行起来之后能稳定地重新生成自身。如果连自己都编译不动说明工具链本身的一致性、可重复性是存疑的。这篇文章面向的读者是这么几类人后端或嵌入式开发者想彻底搞懂自己每天都在用的编译器工具链。准备面试的系统方向候选人需要体系化理解自举的完整链路。折腾过源码构建但被 configure、make bootstrap 搞懵了的 Linux 使用者。以及对“编译器”和“程序”之间关系感到好奇的技术学习者。读完本文你能获得一个清晰的判断自举不是魔法而是一套具有明确步骤的工程方法。2. 基础概念编译器、宿主机与自举动手之前先把概念理清。这个领域里有很多长相类似、含义完全不同的词新手很容易栽在概念混淆上。2.1 编译器和编辑器不要混为一谈有人会问“编译器”和“编辑器”的区别。这是两种完全不同的软件。软件作用例子编辑器让你书写和修改源代码文本VSCode、Vim、Notepad编译器把源代码翻译成目标机器可执行的形式GCC、Clang、MSVC编辑器只负责“写”它不关心代码能不能跑。编译器负责“翻译”它读取源代码经过词法分析、语法分析、语义分析、代码生成等步骤最终输出目标代码或可执行文件。自举说的是编译器不是编辑器。不等于是说“用 Vim 写出来的 Vim 能自己改自己”那是另一码事。2.2 编译器本身就是一个普通程序理解自举的第一步是接受一个事实编译器也是一个程序它也得被编译成可执行文件后放进内存里运行。一个完整的编译过程可以抽象成三样东西源程序Source Program人类写的代码。编译器Compiler一个可执行程序充当翻译器。目标程序Target Program编译器输出给机器执行的结果。编译器接收字符串经过内部复杂的处理输出另一段程序文本或者机器码。它不神秘本质上和其他“输入处理、输出结果”的程序没有区别。这个认知是理解自举的基石。正因为编译器是普通程序它才可以被其他编译器编译也正因为它是程序它才可以去处理自己的源码。2.3 宿主机、目标机和交叉编译在自举讨论里经常会看到“宿主”这个词。宿主系统Host运行编译器程序的系统和机器。目标系统Target编译产物要去运行的系统和机器。大多数时候二者相同这叫原生编译。如果宿主是 x86 Linux目标却是 ARM 嵌入式板子就叫交叉编译。交叉编译是现代嵌入式开发的常态但它和自举是两条技术线后者描述的是“谁编译了编译器”的关系不是“为谁编译”的关系。2.4 编译器领域的 Bootstrap 到底是什么Bootstrap 这个英文词在多个领域都有出现很多人检索“自举”时会搜到大量“自举电容”“自举电路”的内容。必须提醒一句硬件里的自举电容Bootstrap Capacitor解决的是功率驱动电路中高侧开关管的悬浮供电问题供电电压靠电容自举起来编译器领域的自举解决的是“谁来编译编译器”的问题。两者名字撞了核心原理没有任何关系。搜索时用“编译器 自举 bootstrap”才能过滤到正确内容。在编译器领域自举的含义是一个编译器能够编译自己源代码所产生的目标程序并且用这个新程序可以再次编译自己的源代码最终形成自我维持的构建过程。通俗地讲一个 C 编译器能够用它的源码重新生成一个可正常工作的自己就叫自举成功。2.5 自举不是说“源码瞬间变成自己”有人把自举想象成“编译器读自己的 C 源码然后哐当一声变成可执行文件”。这个理解方向是对的但缺少关键一环编译器不会凭空从源码里蹦出来它必须作为一个已有的可执行程序先运行起来然后读取源码、翻译、输出。换句话说在自举发生的那一刻一定有一个“已经在运行的编译器”充当起点。这个起点可以是手写的机器码、汇编器、另一个语言的编译器也可以是上一代自己。3. 编译器自举是怎么诞生的一段简短的历史了解了定义再来看历史。为什么人需要自举因为人类不可能一上来就写出一个巨大的现代编译器。3.1 第一代工具是手写机器码计算机诞生之初没有汇编语言更不可能有 C 语言。最早的“程序”就是机器码人直接给计算机输入二进制指令。这个阶段没有编译器也没有操作系统程序员的生存条件极其恶劣。很快程序员发现纯二进制太痛苦于是发明了助记符用 ADD、MOV 这类短单词代替二进制操作码。但计算机不认助记符它只认二进制。于是有人写了第一个汇编器把助记符翻译成机器码。第一个汇编器同样不能凭空出现。它的雏形是靠人手写出二进制格式然后在机器上硬跑起来的。一旦第一个汇编器能用后续的汇编器就可以用具有助记符的语法重写再由旧汇编器去编译它形成第一次自举。这正是“先从火星上带一点点土然后种出整个地球”的第一步。3.2 第一个高级语言编译器汇编器虽然比机器码好写但仍然是低级语言可移植性差。1950 年代Fortran 团队决定开发高级语言编译器。第一个 Fortran 编译器并不是用 Fortran 自己写的它最初靠汇编语言和机器码实现。等到 Fortran 语言逐渐成熟编译器代码才用 Fortran 重写再借助旧的 Fortran 编译器完成新编译器的构建。这个过程在今天叫“分阶段自举”先用低级工具实现一个能用的编译器再用它去编译用更高级语言写出的下一代编译器直到高级语言能完全“自我哺育”。3.3 C 语言与 Unix 的经典自举故事C 语言和 Unix 的发展是自举史上最出名的事件。1969 年前后Ken Thompson 在贝尔实验室用汇编语言写了第一个 Unix 版本。随后他设计出了 B 语言后来又改进成 C 语言。C 编译器最初是怎么来的当时的路径是先用已有的汇编器或系统工具把 C 编译器源码编译成可执行程序。这个过程可能非常简陋但只要能跑就能继续改进。随着 C 编译器功能变强Unix 核心代码开始用 C 重写。最终整个系统进入自举状态Unix 用 C 写C 编译器用 C 写C 编译器又能编译 C 编译器。这也是为什么后来 C 语言能成为系统编程的主流语言。一个语言一旦实现了自举它就不再依赖外部特定实现可以靠自身长期演进。3.4 现代开源编译器延续了同样的思想GCCGNU Compiler Collection从诞生起就以 C 语言实现它自己管理的构建流程中bootstrap 成为标准操作。GCC 在源码安装时默认构建方式就是 bootstrap这既是一种验证也是让 GCC 能在新环境中稳定落地的手段。4. 核心原理自举的三阶段模型自举的完整流程可以抽象成一个三阶段模型。理解这三步等于掀开了编译器自举的盖头。假设我们要构建一个功能完整的 C 语言编译器而当前机器上只有一个非常基础的编译器或者干脆只有一个汇编器。以下三步是标准做法。4.1 阶段一用已有编译器获取“种子编译器”我们把新编译器的源码称为 S。S 是我们用 C 语言写出来的、理想中功能完整的编译器源码。但是当前机器上没有能编译 C 的编译器。所以第一步先写一个“足够小”的编译器种子 G0。G0 可以是只支持最小 C 语法子集的编译器或者干脆是一个汇编器。用 G0 去编译 S会得到编译器 G1。注意G1 很可能跑起来很吃力因为 G0 支持的语言特性少翻译出来的 G1 可能性能差、功能受限但它确实是可执行程序。这是关键的“从无到有”环节。4.2 阶段二用种子编译器重新编译自己现在拿着 G1让它去读 S 这份源码再编译一次得到 G2。这时候 G2 是真正用“C 编译器”编译出来的 C 编译器。由于 G1 已经具备一定的编译能力G2 的翻译结果通常比 G1 更接近理想。这一步体现的是“初级编译器升级为高级编译器”。4.3 阶段三用新编译器再次编译源码并对比结果再用 G2 去编译 S得到 G3。然后对比 G2 和 G3 的产物。如果两次编译得到的可执行文件内容一致说明这个编译器已经可以对自身实现“稳定的、可复现的编译”。此时可以认为该编译器完成了自举。为什么需要三个阶段而不是两个阶段就够了表面上看G1 已经能编译 S 了但 G1 本身可能是靠 G0 硬翻译出来的它的代码质量、优化能力、对语言特性的支持都不够稳定。通过 G2 再编译一次得到的产物 G3 才是真正被“正常的自身编译链”所认可的二进制。若 G2 和 G3 结果一致则说明整条编译链已经不再依赖最初的 G0。用一个抽象的逻辑示意来表示这个思想# 简化逻辑示意理解三段式自举的核心关系 def bootstrap(compile_func, source_code): # 阶段一用已有的编译器去编译源码得到初始编译器 stage1_compiler compile_func(source_code) # 阶段二用刚得到的编译器再去编译同一份源码 stage2_compiler stage1_compiler(source_code) # 阶段三第二次生成的编译器再次编译源码并检查一致性 stage3_compiler stage2_compiler(source_code) if stage2_compiler stage3_compiler: print(自举成功编译器可以稳定生成自身) return stage3_compiler这段代码只是演示思想不是真实编译过程。真实环境里对比的不是 Python 对象而是生成的二进制文件和中间目标文件。5. 真实案例GCC 的 Bootstrap 构建流程理论讲完落到实操。GCC 是目前最容易观察到自举过程的开源项目。从源码编译 GCC 时默认的构建流程就是一段完整的 bootstrap这个过程由 Makefile 自动驱动。5.1 环境准备与源码获取操作系统建议是常见的 Linux 发行版比如 Ubuntu、Debian、CentOS或者 WSL 环境。编译 GCC 前需要确认系统已经安装了常用编译工具、make、bison、flex 等依赖。不同发行版安装方式不同这里只给出通用思路。从 GNU 官网或镜像站下载 GCC 源码包文件名一般类似gcc-13.2.0.tar.xz。具体版本请以实际下载为准文章只演示通用步骤。# 解压源码包会自动生成 gcc-版本号 目录 tar -xf gcc-13.2.0.tar.xz cd gcc-13.2.0 # 如果系统缺少 GMP、MPFR、MPC 等依赖可以使用源码树自带的脚本下载 # 该脚本会下载依赖源码到当前 gcc 源码目录下便于后续一并编译 ./contrib/download_prerequisites并不是所有环境都必须运行download_prerequisites。如果系统已经安装了足够新版本的 GMP、MPFR、MPC并且能被编译器找到可以跳过这步。但在无外网依赖环境的容器或离线机器上这个脚本很常用。5.2 配置与创建独立构建目录GCC 官方强烈建议在源码目录之外创建一个独立的构建目录避免把编译产生的中间文件混进源码目录里。mkdir build cd build # 以安装到 /usr/local/gcc-self 为例 ../configure \ --prefix/usr/local/gcc-self \ --enable-languagesc,c \ --disable-multilibconfigure 参数说明如下--prefix指定安装目录建议不要直接覆盖系统自带的 GCC。--enable-languages本次构建支持的编程语言这里只需要 C 和 C。--disable-multilib禁用多库减少构建量和依赖新手建议开启。配置完成后系统会生成对应的 Makefile。5.3 执行 bootstrap 构建这是自举流程最核心的命令make -j$(nproc) bootstrap其中nproc会返回当前机器的 CPU 核心数-j参数用于并行编译加快整个流程。这一步耗时很长GCC 在大型项目上跑一两个小时很正常。bootstrap目标背后做的事就是完整的三段式自举用当前系统已有的编译器把 GCC 源码编译成第一个阶段的编译器通常存放在stage1-xgcc之类的目录中。再用这个stage1编译器把同样的 GCC 源码重新编译一遍得到stage2编译器。继续用stage2编译器编译一次得到stage3编译器。Makefile 完成后会自动进入比较步骤对比stage2和stage3的编译产物。如果一致代表自举通过。如果出现差异构建会报错或给出警告。5.4 安装到目标目录构建通过后再执行安装make install安装过程中会把最终的可执行文件复制到/usr/local/gcc-self目录下。安装完成后需要手动把该目录下的 bin 目录加入 PATH或者直接用绝对路径调用/usr/local/gcc-self/bin/gcc --version到这里一个完整的“自举得到的 GCC”就装好了。6. 如何验证编译器真的在“编译自己”很多人在安装完 GCC 之后只记得gcc --version能打印版本号但这不足以证明自举成功。真正的验证思路有几种。6.1 对比两次构建的产物最直接的验证方式是在成功完成 bootstrap 的构建目录里查看阶段产物是否参与了对比并确认对比通过。GCC 构建结束时如果有类似 “The checks are good” 或 “Comparison of stage2 and stage3 succeeded” 的日志说明自举验证已经通过。如果想手动对比可以尝试在同一份源码上创建第二个独立构建目录重新配置、重新执行 bootstrap然后比较两个构建过程中生成的编译器二进制文件哈希值。例如# 假设第一次构建在 build1 目录第二次构建在 build2 目录 # 两份源码必须保持一致 sha256sum build1/stage3/xgcc build2/stage3/xgcc如果两次构建的二进制哈希完全一致说明编译器在重复构建中表现稳定。需要注意的是不同 GCC 版本的内部目录名可能不同具体以实际生成的构建目录结构为准。6.2 用新编译器重新编译自己源码另一种验证自举的方式是脱离构建脚本用新装好的编译器直接去编译 GCC 源码中用于生成编译器的核心源码部分。这会得到一个新的可执行文件。如果这个可执行文件能正常运行并再次完成工作说明它不是“一次性工具”而是具备独立编译能力的真正编译器。不过这个操作比较复杂日常实践更常用的判断依据还是构建日志和不中断的make bootstrap过程。6.3 时间戳与版本号验证安装完成后用--version查看新编译器输出可以确认安装路径对应的是新编译出的版本而不是系统旧版本/usr/local/gcc-self/bin/gcc --version再用which查看优先级which gcc hash -r type gcc如果which gcc仍然指向系统自带的旧 GCC说明 PATH 里/usr/local/gcc-self/bin的优先级还不够高需要调整 PATH 顺序。验证思路总结如下表验证手段目的注意点构建日志中含 stage2/stage3 对比确认自举流程执行了产物对比不同日志级别可能会隐藏该信息两次独立构建后比对哈希验证构建可重复性要求源码一致、依赖一致手动用新编译器编译源码验证编译器具备真实编译能力操作复杂适合进阶实践PATH 与版本检查确认当前使用的是新安装编译器避免误用系统旧编译器7. 常见误区与排查思路编译器自举这个概念嘴上说起来简单真正理解时新手经常栽在几个地方。这里列出几个真实高频误区。问题现象可能原因排查方式解决方案以为编译器是“源码直接变可执行文件”中间没有可执行程序参与忽略了编译器必须作为程序运行这一事实理解“源程序-编译器-目标程序”三要素模型多动手手动执行一次gcc hello.c观察中间产物把编译器自举和自举电容混为一谈检索词“自举”命中了硬件领域内容检查上下文是否涉及功率驱动、高侧开关管编译器自举搜索时加上“编译器 bootstrap”关键词分不清编译器和编辑器日常接触的 IDE 同时包含两种功能在命令行执行gcc和打开 VSCode 对比明确编辑器负责写代码编译器负责翻译代码认为自举必须“从头凭空开始”不理解种子编译器的作用回顾三阶段模型理解任何自举都存在初始工具链构建 GCC 时直接覆盖系统自带 gcc导致环境损坏把--prefix设成系统目录检查 PATH 与安装目录将新 GCC 安装到独立目录用 PATH 控制启用configure 时缺少依赖中途报错GMP、MPFR、MPC 等库缺失或版本过旧查看 configure 输出搜索报错信息执行./contrib/download_prerequisites或安装系统依赖build 目录混用源码目录出现文件污染在源码目录内直接 make观察目录中的build/configure文件布局严格按官方建议使用独立构建目录构建时内存不足导致 OOMGCC 编译过程非常消耗内存用free -h查看内存查看 dmesg 日志减少-j并行数或增加 swap 空间7.1 构建失败时从哪里看起手动编译 GCC 时如果失败不要急着重新跑按照这个顺序排查先看终端最后 50 行日志定位第一个error而不是最后一个。确认报错来自configure阶段还是make阶段。configure 阶段多数是依赖或配置参数问题。检查磁盘空间df -h。检查内存free -h。检查日志中的绝对路径前缀确认没有在构建中无意把源码目录当构建目录。8. 工程实践与最佳实践理解了自举除了增长见识还能在实际工程里用上。8.1 新语言设计时自举是成熟标志很多语言在早期版本里都依赖宿主语言实现编译器。随着语言不断完善最终会进入“编译器用自己写”的阶段。Go 就是一个典型例子。早期 Go 编译器是通过 C 语言实现的后来核心工具链重写为 Go 并用旧工具链完成引导。当一门语言编译器能用自身编译时说明语言已经足够复杂和稳定不再需要总是依赖另一种语言来维持生命。不过要注意不要把“自举”理解为“从零开始”。自举只是说“后续版本可以依赖前一个版本”种子工具链始终存在。工程上这叫引导链Bootstrap Chain每一版编译器都能编译下一版。8.2 大型工具链构建时标准流程就是 bootstrap你在生产服务器上从源码安装 GCC、Rust、Go官方构建命令经常就带着 bootstrap 语义。GCC 的make bootstrapGo 的make.bash启动早期工具链本质都是让编译器先把自己“喂大”再进行后续构建。理解这一点后遇到“第一次构建很慢”的现象就不会慌。慢的根本原因是构建过程做了好几次完整的编译周期而不是程序卡住了。8.3 自举思想也可以用在构建流水线里现代 CI/CD 系统里也有 bootstrap 的影子。发布新版本的编译器时先在干净容器里用旧版本构建新版本。然后立刻用新版本重新构建自己的源码对比产物。如果比对通过才把新版本产物上传到制品库。这套流程能有效防止“编译器在某个修改中引入了不确定性”或者“已经被污染的编译器悄悄把自己隐藏起来”这类问题。安全领域有个概念叫“可信引导”核心思想与之类似确保整个构建链条上的每个环节都可复现、可验证。8.4 什么时候不应该自举自举不是所有场合都该做。如果你只是想在自己的项目里使用某个新版本的 GCC没有特殊理由不要做完整 bootstrap直接用包管理器安装发行版预编译的版本更快。嵌入式开发目标机资源有限通常采用交叉编译而不是在目标机上自举。自举构建耗时巨大CI 环境下尽量缓存中间产物避免每次全量重建。工程判断很重要自举适合“工具链自己需要独立存活”的场景不适合“我只是个使用者”的场景。9. 总结与后续学习方向编译器自举解决的问题其实非常具体谁编译编译器答案是先有一个小编译器用编译器自己把自己滚大再用大编译器证明自己能生成自己。全文的几个关键判断我再说一遍编译器是普通程序不是魔法黑盒。自举采用三阶段模型种子编译器编译功能完整的编译器功能完整的编译器再编译自己的源码最后对比验证。GCC 的make bootstrap就是这个思想的工业化实践。自举不是从零创造而是从“足够小的种子”开始迭代。下一步如果你想让知识落地可以尝试这么几条路径。第一个在你的 Linux 环境里手动编译一次 GCC不跳过make bootstrap仔细观察构建日志中 stage1、stage2、stage3 的生成过程。这个过程耗时但对理解帮助很大。第二个研究一下 Go 的引导机制读一读src/make.bash的开头部分看看它如何找到上一代工具链。对比 GCC 的 Makefile你会发现不同语言工程对 bootstrap 的实现细节差别很大。第三个如果你在维护内部基础工具链建议建立产物哈希对比机制让每次工具链升级都经过“重新生成自身”的验证。这是一笔性价比很高的稳定性投资。最后提醒一句别为了“体验自举”去覆盖系统自带的编译工具链轻则装坏环境重则整个系统的包管理器都失灵。用独立前缀目录慢慢玩风险低很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价