预处理、编译、汇编、链接:用同一个 C++ 程序看清四个阶段
LLVM 与异构编译器 · S1 · 第 03 期
适合能读基本 C++、会使用 Linux 终端的读者。视频制作中。
演示范围:Linux x86-64、Clang/LLVM 21.1.8;具体环境与验证边界见文末说明。
知识体系 / 编译器与 LLVM 全景
执行下面这条命令时:
clang++-21 hello.cpp -o hello终端只显示一条命令,driver 却组织了预处理、语言分析、代码生成、汇编和链接等工作。把它们全部叫作“编译”在日常交流中没有问题,但调试构建失败时,我们必须知道错误最早发生在哪个边界。
本文使用同一个 hello.cpp,分别停在预处理、编译、汇编和链接之后,并用四个最小失败例子验证边界。
一、先给出四阶段地图
| 阶段 | 主要输入 | 典型输出 | 主要职责 |
|---|---|---|---|
| 预处理 | .cpp、头文件、宏、条件编译环境 |
.ii |
展开包含与宏,处理预处理指令 |
| 编译 | 预处理后的翻译单元 | .s,也可观察 .ll/.bc |
语言分析、IR、优化与目标代码生成 |
| 汇编 | .s 或内部机器码表示 |
.o |
编码机器指令,生成节、符号和重定位 |
| 链接 | .o、库、启动文件、链接选项 |
主程序或库 | 解析符号、应用重定位、形成最终映像 |
Clang driver 的停止选项对应这张地图:
-E:预处理后停止;-S:生成汇编后停止;-c:生成目标文件,不链接;- 不指定停止选项:通常继续链接。
二、工具链四阶段不是 C++ 标准翻译阶段的同义词
C++ 标准也定义“翻译阶段”,涉及源字符、行拼接、token、预处理、字符串字面量拼接和语义分析等语言规则。编译器文档中的四阶段则是工程工具链模型。
两者相关,但不是一一对应。本文讨论四阶段,是为了观察命令、文件和错误边界,不是重新定义 C++ 标准中的翻译阶段。
三、实验程序与环境
#include <iostream>
#ifndef MESSAGE
#define MESSAGE "Hello"
#endif
int square(int x) { return x * x; }
int main() {
std::cout << MESSAGE << ", compiler! square(3)=" << square(3) << '\n';
return square(3) == 9 ? 0 : 1;
}本文证据包使用 Ubuntu 24.04.3、x86-64、Clang/LLVM 21.1.8 和 GNU binutils 2.42。命令显式指定 -std=c++17 -O0,不启用 LTO、Modules、PCH 或交叉编译。
先保存环境:
uname -a
clang++-21 --version
clang++-21 -dumpmachine
ld --version | head -n 1版本不是装饰。它决定哪些具体输出可以被称为本次实测。
四、预处理:从 .cpp 到 .ii
clang++-21 -std=c++17 -E hello.cpp -o hello.ii预处理器处理 #include、#define、#if 等指令。上例中的 MESSAGE 会被替换,<iostream> 相关内容也会进入预处理结果,所以 hello.ii 往往比原文件大得多。
可以检查:
wc -l hello.cpp hello.ii
rg -n "Hello|square" hello.ii | tail.ii 仍然是预处理后的 C++,不是 LLVM IR,更不是机器码。它适合排查宏展开、条件编译和包含路径问题。
典型失败:找不到头文件
#include "definitely_missing_header.hpp"
int main() { return 0; }没有可用头文件时,预处理无法形成完整翻译单元。后面的语义分析、代码生成和链接不会开始。
五、编译:语言分析与目标代码生成
clang++-21 -std=c++17 -O0 -S hello.cpp -o hello.s这条命令会经过预处理、词法与语法分析、语义检查、LLVM IR 生成、必要优化和目标相关代码生成,最终输出汇编文本。
汇编内容不是 C++ 语义的唯一写法。目标架构、ABI、优化级别、Clang 版本与 CPU 选项都会改变它。
LLVM IR 是额外观察点
clang++-21 -std=c++17 -O0 -S -emit-llvm hello.cpp -o hello.ll
clang++-21 -std=c++17 -O0 -c -emit-llvm hello.cpp -o hello.bc
llvm-as-21 hello.ll -o roundtrip.bc
llvm-dis-21 roundtrip.bc -o roundtrip.ll.ll 是文本形式,.bc 是 bitcode 形式。它们属于 LLVM 工具链中的中间表示,不应被硬塞成 C/C++ 四阶段之外的“第五个标准阶段”。
典型失败:语法或类型错误
一个缺右括号或类型不成立的程序可以完成宏展开,但前端无法建立合法 AST。诊断文字会随版本变化,错误所属边界更稳定。
六、汇编:从 .s 到 .o
为了明确隔离汇编阶段,直接使用刚才的 .s:
clang++-21 -c hello.s -o hello.o检查目标文件:
file hello.o
readelf -h hello.o
readelf -S hello.o
readelf -r hello.o
nm -C hello.o在本次 Linux x86-64 环境中,hello.o 是可重定位 ELF。它包含机器指令,也可能包含尚未确定的地址、未解析的外部符号和重定位记录。
正常执行 clang++-21 -c hello.cpp -o hello.o 时,Clang 可能使用集成汇编器,磁盘上不会先出现 hello.s。因此“汇编阶段存在”不等于“必须启动一个名为 as 的独立进程”。
典型失败:非法汇编
把明显无效的助记符交给 x86-64 汇编器,汇编阶段会直接失败。这个例子不经过 C++ 前端,可以清楚地隔离边界。
七、链接:从目标文件到主程序
clang++-21 hello.o -o hello链接器组合目标文件、启动文件和库,解析符号并应用重定位。使用 clang++ 而不是直接调用 ld 很重要,因为 C++ driver 知道怎样加入当前平台所需的标准库和运行时组件。
检查最终文件:
file hello
readelf -h hello
readelf -l hello
readelf -d hello
ldd hello
./hello
printf 'exit=%s\n' "$?"现代 Ubuntu 默认可能生成 PIE,所以 readelf -h 中的类型可能是 DYN。这并不意味着它只是普通共享库。要结合程序头、解释器、入口和系统语义判断。
典型失败:未定义符号
int square(int);
int main() { return square(3) == 9 ? 0 : 1; }这段代码中的声明足以让当前翻译单元通过前端检查并生成 main.o。如果链接命令没有提供 square 的定义,链接器才会报告未定义符号。
这就是“编译通过,链接失败”的最小原因:单个翻译单元成立,不代表整个程序的跨文件符号依赖已经满足。
补上定义后:
clang++-21 -c main_link.cpp -o main_link.o
clang++-21 -c square.cpp -o square.o
clang++-21 main_link.o square.o -o link_ok
./link_ok八、错误定位表
| 现象 | 最先检查的边界 | 进一步工具 |
|---|---|---|
| 头文件不存在、宏条件异常 | 预处理 | -E、-H、include 搜索路径 |
| 语法、类型、模板实例化错误 | 前端编译 | -fsyntax-only、最小化翻译单元 |
| 非法指令或汇编语法 | 汇编 | 单独汇编 .s,确认目标架构 |
| undefined reference、duplicate symbol | 链接 | nm -C、readelf -s/-r、链接顺序与库 |
| 程序启动后缺少共享库 | 装载/运行时 | readelf -d、ldd、动态装载器诊断 |
最后一行已经越过静态链接边界。把它与链接错误分开,排查会更快。
九、阶段不一定对应独立程序和磁盘文件
四阶段模型描述职责,不保证每一步都有独立进程:
- Clang 可能在一个
-cc1job 中完成预处理、前端和代码生成; - 集成汇编器可能直接生成
.o; - driver 根据平台选择系统链接器或 LLD;
- 中间文件只有在停止或保存临时文件时才落盘。
使用:
clang++-21 -### hello.cpp -o hello可以观察本次环境的 job 计划,但它不是实际执行日志。要证明命令成功,还需要真正运行。
十、四阶段模型何时需要扩展
以下场景会让线性模型变复杂:
- LTO 在链接时处理 IR,再次优化和生成目标代码;
- C++ Modules、PCH 和 header units 改变前端输入;
- 交叉编译引入 target、sysroot、目标头文件和目标库;
- CUDA/HIP 等 offloading 同时存在 host 与 device 管线;
- JIT 不以传统静态主程序为唯一终点。
四阶段仍然是可靠的起点。关键是知道它的适用范围,并在证据出现时扩展模型。
十一、结论
预处理解决宏与包含,编译负责语言分析和目标代码生成,汇编把目标相关代码编码进目标文件,链接组合全程序并解决跨文件依赖。
最实用的习惯是:保存版本,显式停在边界,检查真实文件,用最小失败例子确认错误属于哪里。这样学到的不只是四个名词,而是一套可以长期复用的构建问题定位方法。
未定义符号:从声明走到重定位的完整解释
声明int square(int)让前端知道调用表达式的参数与返回类型,却没有在当前翻译单元里提供函数体。编译器可生成调用,并在目标文件保留符号引用与需修补的位置;链接器随后寻找匹配定义。这解释了为什么前端通过与跨文件依赖未解决可以同时成立。
排查时先比较调用方未定义符号与提供方定义。C++名字修饰通常编码部分类型信息,square(int)和square(double)不是可随意互换的定义;readelf显示原始符号,nm -C有助于阅读,但解码后的显示不是链接器实际按自然语言匹配。
还要检查提供定义的目标文件是否真的进入链接,归档库成员是否被需要,以及可见性、架构与ABI是否一致。诊断阶段能缩小问题发生处,却不等于根因只在链接器本身。声明与定义签名写错,直到链接时才暴露,也仍是上游接口问题。
关于重定位所承载的符号、加数和位置关系,可参照ELF通用ABI的Relocation章节;具体计算式还要以目标架构ABI为准。
把本篇补定义步骤写完整
前文链接失败例子的main_link.cpp与补回的square.cpp应分别保存,不能把两个main放在同一链接任务里。下面是对应文件的完整最小内容;用于补足阅读与复现步骤,不作为本轮新增运行日志。
// main_link.cpp
int square(int);
int main() { return square(3) == 9 ? 0 : 1; }
// square.cpp
int square(int x) { return x * x; }调用只取3,乘法结果在int范围内;这不为任意输入的有符号平方承诺无溢出。若把测试扩展到大整数,必须先定义合法输入域或改用适当算术,不能让一次溢出把对工具链的比较变成对未定义行为的观察。
四阶段观察中,重新从hello.cpp执行每个停止选项与显式消费hello.ii、hello.s,是两种不同控制方式。前者展示同一驱动的停止位置;后者更清楚地隔离下一阶段输入。两者若使用不同宏、语言模式或目标参数,产物差异也就不应直接归给某个阶段。
2026-10-18内容修订:补缺失的square.cpp文件与符号依赖解释;不提升历史实测状态。本次为文献与语义审校,未新增实验;首次发布日期、原网址及历史运行版本保持。
来源与边界
核心事实来自 Clang、GCC、GNU assembler、GNU ld 和 ELF 规范文档。本文实验限定为 Linux x86-64、Clang/LLVM 21.1.8、本机构建与非 LTO 路径。完整命令、原始日志与哈希随证据包保存。
写下你的评论
要发表评论,您必须先登录。