大型软件的编译过程
大型软件的编译远不只是「把代码翻译成机器码」这么简单。把它拆成两层来理解会清晰很多:底层是编译器链(预处理→编译→汇编→链接)对单个文件做的事;上层是构建系统如何把这套流程在数万甚至数十万个文件上高效地调度起来。
一、底层:单个源文件经历了什么
以 C/C++ 为例(大多数大型系统软件的主力语言),一个 .c / .cpp 文件到可执行文件要经过四个阶段:
1. 预处理(Preprocessing)
处理 #include(头文件展开)、#define(宏替换)、#ifdef(条件编译)等指令。这一步的输出是「纯粹的 C/C++ 源码」——没有预处理指令,但体积会膨胀很多,因为一个 #include <iostream> 可能展开出数万行。
2. 编译(Compilation)
编译器(GCC/Clang/MSVC)将预处理后的源码翻译成汇编代码。这是最耗 CPU 的阶段,涉及词法分析、语法分析、语义分析、中间表示(IR)生成、优化等子步骤。编译器在这里做大量优化:内联、循环展开、死代码消除、常量折叠等。
3. 汇编(Assembly)
汇编器将汇编代码转换成机器码,输出目标文件(.o / .obj)。目标文件里是二进制机器指令、数据段、符号表(记录了这个文件提供了哪些函数/变量,又引用了哪些外部符号)。
4. 链接(Linking)
链接器把成百上千个目标文件和库拼成一个可执行文件(ELF、PE、Mach-O)。核心工作:符号解析(把各 .o 文件里「我调用了 printf,但它在哪」的问号填上地址)和重定位(修正地址引用)。
二、大型软件的特殊挑战:为什么不是简单放大
单文件编译四阶段看起来很简单。但当项目有几万个源文件时,新的问题出现了:
- 依赖图复杂度:
A.cpp改了,哪些.o需要重新编译?哪些可执行文件需要重新链接? - 编译时间:Chromium 全量编译在普通 4 核机器上约需 13 小时 Big Project Build Times – Chromium,即使用 M2 MacBook Pro 也要近 2 小时。
- 链接时间:当链接器面对几千个
.o和上百个库时,符号解析本身就变得极慢——甚至可能超过编译时间。 - 内存压力:LTO(链接时优化)需要将整个程序的 IR 同时加载到内存,对大型项目可能耗上百 GB 内存。
- 头文件地狱:C++ 的头文件展开机制导致每个翻译单元(translation unit)都要重新解析大量重复代码。
三、构建系统:编译的「大脑」
直接把命令行里的 gcc 一个一个调用来编译是不现实的。构建系统的作用是把整个编译过程建模成一棵依赖 DAG(有向无环图),然后自动决定执行什么、以什么顺序执行、什么可以并行执行。
构建系统的演进
| 代际 | 代表 | 特点 |
|---|---|---|
| 第一代 | GNU Make | 基于时间戳的增量判断;手写依赖规则容易出错;递归 Make 产生大量串行瓶颈 |
| 第二代 | CMake + Ninja | CMake 生成 Ninja 文件,Ninja 极速执行;依赖追踪更精确 |
| 第三代 | Bazel(Google)、Buck2(Meta)、Pants | 基于内容哈希的缓存;声明式规则;原生支持分布式编译和远程缓存 |
以 Bazel 为例,它在一次构建中分为三个核心阶段 Bazel: Action Graph:
- Loading 阶段:解析
BUILD文件,构建目标依赖图(target graph)。 - Analysis 阶段:生成 action graph——每个节点是一个具体动作(编译
foo.cpp、链接bar.so),附带完整命令行和输入/输出文件。 - Execution 阶段:实际执行这些动作。Bazel 可以在这里将 action 分发到远程机器上执行。
Bazel 最关键的特性是基于内容哈希的缓存:如果 foo.cpp 的内容、编译器版本、编译选项、所有依赖头的哈希都跟上次一样,就直接从缓存拿结果,跳过编译。这跟 Make 的「看时间戳」有本质区别——更精确、更适合大规模分布式场景 Bazel: Distributed Builds。
四、关键加速技术
大型项目的编译速度是核心痛点,工业界发展出了一整套技术栈:
4.1 并行编译
最基础的手段。make -jN 或 Ninja 会将无依赖关系的编译任务同时跑在多个核上。但并行度受限于依赖链的宽度:如果所有文件最终都依赖一个大头文件,那改了这个头文件后,并行编译前必须等它先处理完。
4.2 增量编译(Incremental Build)
只重编译「真正受影响」的文件。传统 Make 基于时间戳判断——但时间戳可能不可靠(切个分支、换个编译器就全乱了)。Bazel 这类系统用内容哈希做增量判断,可靠性大幅提升。一项对 383 个 Bazel 项目的研究显示,增量编译相比全量编译的中位加速达到 4.22x(通用 CI 缓存)到 4.71x(Bazel 专用缓存) Does Using Bazel Help Speed Up Continuous Integration Builds?。
4.3 分布式编译与远程缓存
这是 Google/Meta 级别的终极方案:
- 远程缓存:编译结果(
.o文件)存在共享服务器上。团队成员和 CI 之间可以共享——你编译了一个别人今天上午已经编译过的文件,直接下载.o。 - 远程执行:编译任务本身分发到远程机器集群上执行。Google 内部用的 Blaze(Bazel 的前身)配合 Goma 远程编译服务,将 Chromium 的全量编译从 13 小时压到分钟级。
4.4 Unity Build / Jumbo Build
把多个 .cpp 文件 #include 进一个「超级翻译单元」一次性编译。好处是:减少重复的头文件解析、减少重复模板实例化、减少 .o 文件数量(从而减轻链接负担)。有报告称可以将编译速度提升 2 倍以上 C++ Weekly: Unity Builds。代价是:改一个文件就要重编整个合并单元;全局命名冲突(两个 .cpp 里定义了同名的 static 函数);不适合持续集成场景。
4.5 预编译头(PCH)
把不常变动的头文件(如标准库、大型第三方库)预先编译成二进制中间格式,后续编译时直接加载,跳过重复的解析开销。CMake 原生支持 PCH,通常能带来 20-40% 的编译加速 Qt Blog: Precompiled Headers and Unity Builds in CMake。
五、链接——真正的瓶颈所在
在大型 C++ 项目里,链接经常比编译更让人头疼。一个 reddit 用户描述 Chromium 的增量构建:「80-90% 的时间都花在了链接巨大的 browser.dll 上」 r/cpp: How long are your compilation & linking times?。
静态链接 vs 动态链接
| 静态链接 | 动态链接(.so/.dll) |
|
|---|---|---|
| 链接时间 | 长(全量处理) | 短(符号只是占位引用) |
| 运行时性能 | 略好(无间接跳转) | 极轻微开销(PLT/GOT) |
| 增量构建 | 改一个小文件 → 重新完整链接 | Component build:只重链受影响的小 .so |
| 分发便利性 | 单文件自包含 | 需附带一堆 .so |
Chromium 开发时默认使用 component build 模式——将浏览器拆成几十个 .so/.dll,改了某个模块只重链对应的那个动态库。链接时间从「等十几分钟」变成「等几十秒」,极大提升了开发迭代速度。发布时则切回静态 + LTO 以获得最优性能。
LTO 与 ThinLTO
Link-Time Optimization(链接时优化) 允许编译器在链接阶段跨翻译单元做优化——比如跨文件内联、跨文件死代码消除。但全量 LTO 需要将整个程序的 IR 加载到内存中,对 Chromium 级别的项目内存消耗极恐怖。
ThinLTO 是 LLVM 给出的解决方案:编译阶段为每个 .o 附加一个紧凑的模块摘要,链接时先用摘要做全局分析,然后并行地对各个模块做后端优化 Clang: ThinLTO。ThinLTO 在保持接近全量 LTO 优化效果的同时,大幅降低了内存和时间开销,已被 Android 等大型项目采用。
六、三个真实案例
Chromium
- 源码规模:约 25,000+ 个 C/C++ 源文件,超过 1,800 万行代码
- 构建工具:GN(生成 Ninja 文件)+ Ninja;Google 内部用 Blaze
- 全量编译:约 50,000+ 个编译任务,M4 MacBook Pro 约 6 分钟(M2 约 1 小时 49 分钟),普通 4 核机器约 13 小时 Chromium Build Times - Solomon Kinard
- 增量编译:改一个
.cpp通常重编 + 重链在几分钟以内(component build 下几十秒) - 关键策略:GN + Ninja 并行、component build(开发时)、ThinLTO(发布时)、Google 内部使用 Goma 远程编译
LLVM / Clang
- 全量构建 LLVM 本身就是一个大型编译任务:优化构建
llc约 48 秒(用户态时间约 13 分钟分布式),Debug 构建约 9 分钟 LLVM Discourse: If LLVM is so slow - 用 CMake + Ninja,也支持 Bazel 构建
- LLVM 自身的编译速度也是 LLVM 开发者持续优化的目标
Linux Kernel
- 构建系统:Kbuild(基于 Make 的递归构建系统)+ Kconfig 配置系统 Exploring the Linux kernel: Kconfig/kbuild
- 每个子目录有自己的
Makefile和Kconfig,顶层make menuconfig生成.config,然后递归进入各子目录编译 - 使用
make -j$(nproc)做并行编译,全量编译在现代机器上约 10-30 分钟 - 内核模块(
.ko)的编译与主内核镜像(vmlinux→bzImage)是分开的流程
七、总结:一个全景视图
把整个流程串起来,一次大型软件的编译大致是这样的:
配置阶段
│ CMake/GN/Kconfig 读取项目描述 → 生成构建文件
▼
构建系统启动(Bazel / Ninja / Make)
│ 解析依赖图 → 判断哪些需要重新编译 → 决定并行调度
▼
对每个需重编译的源文件:
│ 预处理器 → 编译器 → 汇编器 → .o 文件
│ (展开头文件) (源码→IR→汇编) (汇编→机器码)
▼
链接阶段
│ 链接器收集所有 .o + 库 → 符号解析 + 重定位
│ (可选:LTO/ThinLTO 在此阶段做跨模块优化)
▼
可执行文件 / 动态库 / 内核镜像
整个过程的核心矛盾是:依赖图的复杂性与编译任务的计算密集性。解决思路则是:精确的依赖追踪(知道什么需要重编)+ 最大化并行度(多核/多机)+ 缓存复用(不重复劳动)+ 减少冗余解析(PCH、Unity Build)。
尚需核实的点:本文中 Chromium、LLVM 的具体编译时间数据来自不同硬件平台和时间点,仅供量级参考,不是精确基准。实际时间高度依赖于 CPU 核心数、内存、磁盘 I/O 和具体编译选项。如果你的场景有特定的项目或编译器,可以进一步深入。