作者:

在

预处理、编译、汇编、链接:用同一个 C++ 程序看清四个阶段

lihao · 发布: · 技术文章

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 可能在一个 -cc1 job 中完成预处理、前端和代码生成;
  • 集成汇编器可能直接生成 .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 路径。完整命令、原始日志与哈希随证据包保存。


阅读路线

系列导航:上一篇:GCC、Clang、LLVM 到底是什么关系

lihao
Author: lihao

喜欢这篇文章?登录后即可点赞,或先注册账号。

评论

0 条评论

欢迎交流与勘误。登录后可评论,提交后公开;请勿发布隐私、广告或无关内容。

写下你的评论