编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程地址空间布局
      • 对象内存布局原理
      • 引用与指针本质
      • this指针与成员函数
      • 虚函数表深度剖析
      • 多重继承内存模型
      • 内存对齐与缓存行
      • 内存分配器演进史
      • 五大值类别详解
      • 右值引用与移动语义
      • 完美转发与引用折叠
      • 类型推导三大规则
      • 类型转换与隐式构造
      • const与volatile真相
      • RTTI与dynamic_cast
      • 类型擦除技术原理
      • 模板实例化机制
      • 模板特化与偏特化
      • SFINAE与enable_if
      • 可变参数模板原理
      • constexpr编译期计算
      • Concepts深度剖析
      • 元编程模板技巧
      • Modules模块化设计
      • RAII的设计哲学
      • 对象构造与析构
      • 拷贝与移动控制
      • unique_ptr原理剖析
      • shared_ptr底层剖析
      • weak_ptr与this增强
      • 五种存储期管理
      • vector扩容真相
      • deque分段连续设计
      • list与forward_list
      • 关联容器红黑树
      • 哈希容器深度剖析
      • 迭代器五大类别
      • STL算法设计哲学
      • Allocator分配器机制
      • C++内存模型基石
      • 六大内存序详解
      • atomic原子操作原理
      • mutex与条件变量
      • thread与jthread机制
      • 异步编程future家族
      • 无锁数据结构设计
      • 协程coroutine原理
      • 翻译单元与预处理
        • 1. 案例引入
          • 1.1 宏的嵌套展开炸弹——42 行变成 17000 行
          • 1.2 include 顺序依赖——换个顺序就编译失败
          • 1.3 七个待解疑问
        • 2. 架构概览
          • 2.1 翻译单元的四个阶段——从字符到 AST 前的全部处理
          • 2.2 为何这么切
        • 3. 翻译单元的定义与边界
          • 3.1 一个 .cpp + 所有展开后的 .h = 一个 TU
          • 3.2 预处理后的 TU 长什么样——用 -E 看真相
          • 3.3 TU 之间的隔离——static / 匿名命名空间 / ODR 的根源
        • 4. #include 的完整机制
          • 4.1 尖括号 vs 双引号——搜索路径的完整列表
          • 4.2 #pragma once vs 头文件守卫——为什么前者不能替代后者
          • 4.3 头文件的自给性——每个 .h 应该独立可编译
          • 4.4 include 图与编译时间的指数关系
        • 5. 宏的展开规则——预处理器的完整语义
          • 5.1 对象宏与函数宏——#define 的两种形态
          • 5.2 宏参数的 prescan——展开前的参数替换与蓝漆规则
          • 5.3 # 与 ## 运算符——字符串化与粘贴的利器与陷阱
          • 5.4 宏的嵌套展开与自引用终止
          • 5.5 可变参数宏与 _VAARGS__——C++11/C++20 的三代演进
        • 6. 条件编译——#if / #ifdef / #elif 的完整体系
          • 6.1 #if defined vs #ifdef 的细微差异
          • 6.2 #if 中的预处理器算术——表达式的类型与限制
          • 6.3 常见误区——#if 不能比较字符串、不识别 sizeof、不识别 C++ 常量
        • 7. 预编译头(PCH)——以空间换编译时间
          • 7.1 PCH 的工作原理——预处理结果的 dump 与快照恢复
          • 7.2 GCC 的 .gch 与 MSVC 的 .pch——生成与使用
          • 7.3 PCH 的反噬——PCH 失效的三种原因与隐性全量重编
        • 8. Unity Build——把多个 .cpp 拼成一个 TU
          • 8.1 原理——减少重复预处理 + 给链接器减负
          • 8.2 静态重名陷阱——匿名命名空间中的符号冲突
          • 8.3 适用场景——什么时候 Unity Build 有正向收益
        • 9. include-what-you-use(IWYU)——精准包含的工程实践
          • 9.1 原则:用到的符号必须有对应的 #include
          • 9.2 常见反模式:隐式依赖——被别的头文件代理包含
          • 9.3 IWYU 工具的工作流程与限制
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一个 .cpp 从磁盘到 AST 的完整旅程
          • 10.3 设计哲学回扣
          • 10.4 速查表合集
      • 编译期符号生成
      • 链接器工作原理
      • ODR规则与陷阱
      • 动态库与符号可见性
      • C++ ABI兼容性
      • LTO与PGO优化
      • 异常机制底层原理
      • Ranges革命与管道
      • format与print体系
      • UB未定义行为图鉴
      • C++设计哲学回望
      • 写作模板
    • 开发技巧

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 专栏博客
杨充
2026-06-06
目录

翻译单元与预处理

# 48.翻译单元与预处理

# 目录介绍

  • 1. 案例引入
    • 1.1 宏的嵌套展开炸弹——42 行变成 17000 行
    • 1.2 include 顺序依赖——换个顺序就编译失败
    • 1.3 七个待解疑问
  • 2. 架构概览
    • 2.1 翻译单元的四个阶段——从字符到 AST 前的全部处理
    • 2.2 为何这么切
  • 3. 翻译单元的定义与边界
    • 3.1 一个 .cpp + 所有展开后的 .h = 一个 TU
    • 3.2 预处理后的 TU 长什么样——用 -E 看真相
    • 3.3 TU 之间的隔离——static / 匿名命名空间 / ODR 的根源
  • 4. #include 的完整机制
    • 4.1 尖括号 vs 双引号——搜索路径的完整列表
    • 4.2 #pragma once vs 头文件守卫——为什么前者不能替代后者
    • 4.3 头文件的自给性——每个 .h 应该独立可编译
    • 4.4 include 图与编译时间的指数关系
  • 5. 宏的展开规则——预处理器的完整语义
    • 5.1 对象宏与函数宏——#define 的两种形态
    • 5.2 宏参数的 prescan——展开前的参数替换与蓝漆规则
    • 5.3 # 与 ## 运算符——字符串化与粘贴的利器与陷阱
    • 5.4 宏的嵌套展开与自引用终止——递归宏为什么不递归
    • 5.5 可变参数宏与 VA_ARGS——C++11/C++20 的三代演进
  • 6. 条件编译——#if / #ifdef / #elif 的完整体系
    • 6.1 #if defined vs #ifdef 的细微差异
    • 6.2 #if 中的预处理器算术——表达式的类型与限制
    • 6.3 常见误区——#if 不能比较字符串、不识别 sizeof、不识别 C++ 常量
  • 7. 预编译头(PCH)——以空间换编译时间
    • 7.1 PCH 的工作原理——预处理结果的 dump 与快照恢复
    • 7.2 GCC 的 .gch 与 MSVC 的 .pch——生成与使用
    • 7.3 PCH 的反噬——PCH 失效的三种原因与隐性全量重编
  • 8. Unity Build——把多个 .cpp 拼成一个 TU
    • 8.1 原理——减少重复预处理 + 给链接器减负
    • 8.2 静态重名陷阱——匿名命名空间中的符号冲突
    • 8.3 适用场景——什么时候 Unity Build 有正向收益
  • 9. include-what-you-use(IWYU)——精准包含的工程实践
    • 9.1 原则:用到的符号必须有对应的 #include
    • 9.2 常见反模式:隐式依赖——被别的头文件代理包含
    • 9.3 IWYU 工具的工作流程与限制
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一个 .cpp 从磁盘到 AST 的完整旅程
    • 10.3 设计哲学回扣
    • 10.4 速查表合集

# 1. 案例引入

# 1.1 宏的嵌套展开炸弹——42 行变成 17000 行

某嵌入式框架用宏做驱动注册。一个看似简单的 REGISTER_DRIVER(UART) 展开后让单个 TU 膨胀到 17000 行——编译时间从 0.2s 跳到 3.8s:

// ====== 事故代码 V1:宏嵌套爆炸 ======
#define CONCAT_IMPL(a, b) a ## b
#define CONCAT(a, b) CONCAT_IMPL(a, b)

#define REGISTER_DRIVER_IMPL(name, id)  \
    static Driver CONCAT(drv_, name) {  \
        #name, id,                      \
        &CONCAT(name, _init),           \
        &CONCAT(name, _read),           \
        &CONCAT(name, _write),          \
    }

#define REGISTER_DRIVER(name)           \
    REGISTER_DRIVER_IMPL(name, __COUNTER__)

// 42 个驱动注册:
REGISTER_DRIVER(UART)
REGISTER_DRIVER(SPI)
REGISTER_DRIVER(I2C)
// ... 39 more

// 每个 REGISTER_DRIVER → 展开后约 400 行(包含 Driver 构造函数的全部模板实例化)
// 42 个 × 400 = 16800 行 → 编译器看到的是 16800 行的「纯文本」
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

根因:宏在预处理阶段展开——编译器真正的输入不是 42 行,而是 16800 行。C++ 中的宏是纯文本替换——没有任何语义检查、没有任何类型收缩。展开后编译器需要逐字符处理这 16800 行——包括所有的模板实例化和函数签名。

真相还原——g++ -E 查看预处理输出:

$ g++ -E driver_registry.cpp | wc -l
16823
1
2

这 16823 行在编译器后续阶段(词法分析→语法分析→语义分析→代码生成)都要逐行走一遍。预处理器是第一个耗时的关卡。

# 1.2 include 顺序依赖——换个顺序就编译失败

同一框架的某个头文件在不同的 .cpp 中编译——有的通过、有的失败。排查了一天发现是 include 顺序不同:

// a.cpp —— 编译通过
#include "config.h"      // config.h 里 #define ENABLE_FEATURE_X
#include "feature_x.h"   // feature_x.h 里 #ifdef ENABLE_FEATURE_X → 启用特性

// b.cpp —— 编译失败!
#include "feature_x.h"   // 先 include feature_x → ENABLE_FEATURE_X 未定义 → 特性被禁用
#include "config.h"      // 后 include config → 定义了 ENABLE_FEATURE_X,但已经晚了
#include <optional>      // feature_x.h 用了 std::optional——但 feature_x.h 没有 #include <optional>!
                         // a.cpp 不报错是因为 config.h 里代理包含了 <optional>
1
2
3
4
5
6
7
8
9

根因:

  1. 宏的依赖顺序:feature_x.h 的行为依赖于 config.h 中的宏定义——但没有通过 include 声明这个依赖
  2. 隐式头文件依赖:feature_x.h 用了 std::optional 但没有 #include <optional>——而是借了 config.h 的 include 链

# 1.3 七个待解疑问

① 翻译单元到底是什么?一个 .cpp 加上展开后的所有 .h 就是整个 TU?          → 第 3 章
② #include 的尖括号和双引号搜索路径有什么区别?系统路径和用户路径怎么定?    → 第 4 章
③ 宏的展开是递归的吗?为什么宏不能递归调用自己?# 和 ## 怎么用?            → 第 5 章
④ #if 和 #ifdef 的区别是什么?#if 能做哪些表达式求值、有哪些限制?           → 第 6 章
⑤ 预编译头(PCH)到底是什么?怎么加速编译?失效怎么排查?                    → 第 7 章
⑥ Unity Build 把多个 .cpp 拼成一个 TU——为什么能加速?有什么陷阱?            → 第 8 章
⑦ include-what-you-use 的原则是什么?为什么隐式依赖是定时炸弹?               → 第 9 章
1
2
3
4
5
6
7

# 2. 架构概览

# 2.1 翻译单元的四个阶段——从字符到 AST 前的全部处理

原始 .cpp 文件 (200 行)
         │
         ▼
┌──────────────────────────────────────────────┐
│  ① 字符映射 (Trigraph / Line Splicing)       │
│    - 三字符组 ???= → # (C++17 已移除)        │
│    - 行拼接: backslash-newline → 合并为一行   │
└──────────────────────┬───────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────┐
│  ② 预处理 (Preprocessing) —— 本文主体         │
│    - #include 展开 (递归包含头文件)           │
│    - #define 宏替换                           │
│    - #if / #ifdef 条件编译                    │
│    - #pragma 处理                             │
│    - 注释移除 (替换为空格)                     │
│                                               │
│    输出: 纯文本——所有的 # 指令被处理完        │
│    .cpp (200行) + 展开的 .h → TU (~20000行)   │
└──────────────────────┬───────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────┐
│  ③ 词法分析 (Lexing)                         │
│    - 将字符流切成 token (关键字/标识符/字面量)│
└──────────────────────┬───────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────┐
│  ④ 语法分析 (Parsing) → AST → 后续编译       │
│    - 将 token 流构建为抽象语法树               │
└──────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

关键:预处理是纯文本操作——不涉及类型、不涉及符号表。预处理器只看到 token 前的文本——这和编译器后端完全隔离。

# 2.2 为何这么切

疑惑:为什么 C++ 不直接做语义处理,非要先过一趟文本替换?

论证——C 遗产 + 物理必然:

原因 1:C 遗产——C++ 继承了 C 的预处理器
  #include / #define / #ifdef 是 C 时代的产物
  C++ 为了兼容 C——保留了完整的预处理器

原因 2:头文件的根本逻辑就是文本拼接
  #include 的本质:把另一个文件的内容复制到当前文件
  这是文本操作——不涉及任何语义

原因 3:条件编译必须先于语法分析
  #ifdef _WIN32 决定哪段代码被编译器看到
  语法分析器不能看到「两段互斥的代码」同时存在
1
2
3
4
5
6
7
8
9
10
11

# 3. 翻译单元的定义与边界

# 3.1 一个 .cpp + 所有展开后的 .h = 一个 TU

每个 .cpp 文件独立编译为一个翻译单元 (TU):

main.cpp:
  #include "a.h"
  #include "b.h"
  int main() { return a_func() + b_func(); }

预处理后(main.cpp 的 TU):
  // a.h 的全部内容展开...
  // b.h 的全部内容展开...
  int main() { return a_func() + b_func(); }

// a.cpp 是另一个独立的 TU:
  #include "a.h"
  int a_func() { return 42; }

// 两个 TU 互相不知道对方的存在——直到链接器把它们合在一起
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

TU 边界的关键性质:

  • 每个 TU 独立编译——a.cpp 的编译器不知道 b.cpp 的内容
  • 命名空间不跨 TU——static 和匿名命名空间只在当前 TU 内有效
  • 编译器在 TU 内做完整检查——TU 内所有使用的符号必须已声明

# 3.2 预处理后的 TU 长什么样——用 -E 看真相

# GCC: 只预处理,不编译、不汇编、不链接
g++ -E source.cpp -o source.ii

# Clang: 同上
clang++ -E source.cpp -o source.ii

# MSVC: /E 或 /P
cl /E source.cpp > source.i
1
2
3
4
5
6
7
8

典型输出——一个简单的 #include <iostream> 后的 TU:

$ echo '#include &lt;iostream>' | g++ -E -xc++ - | wc -l
33198
1
2

3.3 万行的预处理输出——对于编译器后续的词法+语法+语义分析,这就是每个使用了 <iostream> 的 TU 的真实输入大小。

# 3.3 TU 之间的隔离——static / 匿名命名空间 / ODR 的根源

// a.cpp
static int counter = 0;  // 只在 a.cpp 的 TU 内可见
namespace { int secret = 42; }  // 同上

// b.cpp
static int counter = 0;  // 合法的——这是另一个 counter
namespace { int secret = 100; }  // 合法——不同的 TU 中的不同变量
1
2
3
4
5
6
7

TU 隔离是 ODR 规则的基础——ODR 只要求跨 TU 的符号唯一定义。TU 内的 static/匿名命名空间天然不受 ODR 约束。


# 4. #include 的完整机制

# 4.1 尖括号 vs 双引号——搜索路径的完整列表

#include <vector>          // 尖括号——搜索系统/编译器路径
#include "my_header.h"     // 双引号——先搜索当前目录,再搜索系统路径
1
2

完整搜索顺序(GCC/Clang):

#include "my_header.h":
  ① 当前源文件所在目录
  ② -I 指定的目录(按命令行顺序)
  ③ CPATH / C_INCLUDE_PATH / CPLUS_INCLUDE_PATH 环境变量
  ④ 系统标准路径:
     /usr/include/c++/13/
     /usr/include/x86_64-linux-gnu/c++/13/
     /usr/include/c++/13/backward/
     /usr/local/include/
     /usr/include/

#include &lt;vector>:
  跳过 ①——直接到 ②→④
1
2
3
4
5
6
7
8
9
10
11
12
13

-I vs -isystem 的关键差异:-I 路径中的头文件会产生警告,-isystem 路径中的头文件不产生警告(和系统头文件一样处理)。

# 4.2 #pragma once vs 头文件守卫——为什么前者不能替代后者

// 传统守卫——跨所有编译器
#ifndef MY_HEADER_H
#define MY_HEADER_H
// ... header content ...
#endif

// #pragma once —— 更简洁,但非标准
#pragma once
// ... header content ...
1
2
3
4
5
6
7
8
9

为什么 #pragma once 不能完全替代 #ifndef 守卫:

① 非标准——虽然所有主流编译器都支持,但 C++ 标准没有规定
② 符号链接/硬链接陷阱:
   /path/to/project/include/header.h
   /path/to/build/symlink/header.h → 同一个文件的不同路径
   → 编译器可能认为这是两个不同的文件 → #pragma once 失效
③ 分布式文件系统的 inode 判断可能出错

最佳实践:二者结合——#pragma once 在前 + #ifndef 守卫在后
  → pragma 提供快速路径(编译器识别 pragma 后不再打开文件)
  → #ifndef 提供安全网(pragma 失效时兜底)
1
2
3
4
5
6
7
8
9
10

# 4.3 头文件的自给性——每个 .h 应该独立可编译

// ❌ 不自给的 header——必须由其他 header 先 include
// feature.h
class Feature {
    std::string name_;  // 用了 std::string——但没有 #include <string>!
    // 依赖调用方先 include <string>
};

// ✅ 自给的 header
#include <string>
class Feature {
    std::string name_;
};
1
2
3
4
5
6
7
8
9
10
11
12

验证自给性——让每个 .h 文件作为第一个 include 编译:

// feature_test.cpp
#include "feature.h"  // 唯一 include——如果编译不过——feature.h 不自给
1
2

# 4.4 include 图与编译时间的指数关系

一个 .cpp 的编译时间 ∝ 展开后的 TU 行数

widget.cpp  include 了:
  widget.h (100行) → [include &lt;string> (20000行), &lt;vector> (18000行),
                       "config.h" (500行), "logger.h" (300行 → &lt;mutex>(15000行))]
  总展开:~54000 行

如果 200 个 .cpp 都 include 了 widget.h:
  每个 .cpp 的预处理输出都包含完整的 widget.h 展开 → 54000 行
  200 × 54000 = 1080 万行被编译器处理(大部分是重复的)
1
2
3
4
5
6
7
8
9
10

这就是为什么 header-only 的库在编译时间上昂贵——每个使用它的 .cpp 都要把整个库展开一遍。


# 5. 宏的展开规则——预处理器的完整语义

# 5.1 对象宏与函数宏——#define 的两种形态

#define BUFFER_SIZE 1024       // 对象宏——简单替换
#define MAX(a, b) ((a)>(b)?(a):(b))  // 函数宏——参数替换
1
2

函数宏的潜在陷阱——运算符优先级和多重求值:

// ❌ 危险的 MAX
#define MAX_BAD(a, b) a > b ? a : b  // 没有括号
MAX_BAD(x & 0xFF, y)  →  x & 0xFF > y ? x & 0xFF : y
                       →  x & (0xFF > y ? x & 0xFF : y)   // 运算符优先级错了!

// ❌ 多重求值
MAX(++x, y)  →  ((++x)>(y)?(++x):(y))
// ++x 可能被求值两次——副作用翻倍

// ✅ 现代 C++ 替代——模板(无副作用、类型安全)
template <typename T>
constexpr const T& max(const T& a, const T& b) { return a > b ? a : b; }
1
2
3
4
5
6
7
8
9
10
11
12

# 5.2 宏参数的 prescan——展开前的参数替换与蓝漆规则

疑惑:宏的参数在替换前还是替换后展开?

论证——三步规则:

宏展开三步:
  ① 参数 prescan:如果参数本身是宏——先展开参数
  ② 参数替换:把展开后的参数替换到宏体中
  ③ 整体再扫描:替换后对结果再次扫描——检查是否有新宏需要展开

实例:
  #define FOO 42
  #define BAR(x) x + 1

  BAR(FOO) → ① FOO 展开为 42 → ② 替换:42 + 1 → ③ 扫描:无新宏 → 结果:42 + 1

蓝漆规则(blue paint):防止无限递归
  在展开一个宏时,被展开的宏被「涂蓝」(标记为「正在展开」)
  在同一个展开链中再次遇到这个宏——不再展开
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 5.3 # 与 ## 运算符——字符串化与粘贴的利器与陷阱

// # 运算符——字符串化
#define STRINGIFY(x) #x

STRINGIFY(hello)       → "hello"
STRINGIFY(hello world) → "hello world"
STRINGIFY(42 + 1)      → "42 + 1"    // 表达式变成字面字符串
STRINGIFY(\n)          → "\\n"       // 转义字符也字符串化

// ## 运算符——token 粘贴
#define CONCAT(a, b) a ## b

int CONCAT(my, _var) = 42;  → int my_var = 42;
1
2
3
4
5
6
7
8
9
10
11
12

## 的使用规则——两级宏:

// ❌ 直接 ## ——expanded 不被展开
#define BAD_CONCAT(a, b) a ## b
int BAD_CONCAT(FOO, BAR);  // → int FOOBAR; ——FOO 没有被展开

// ✅ 两级宏——先展开参数,再拼接
#define CONCAT_IMPL(a, b) a ## b
#define CONCAT(a, b) CONCAT_IMPL(a, b)
int CONCAT(FOO, BAR);  // → int 42BAR; ——FOO 先展开为 42
1
2
3
4
5
6
7
8

# 5.4 宏的嵌套展开与自引用终止

#define RECURSIVE RECURSIVE
RECURSIVE  // → RECURSIVE(展开一次——检测到自引用,停止)
// 蓝漆规则:在展开 RECURSIVE 时,「RECURSIVE」这个名字被涂蓝
// → 在替换结果中再次扫描到 RECURSIVE——跳过
1
2
3
4

间接自引用——同样被终止:

#define A B
#define B A

A  // → 展开 A→B → 展开 B→A(发现 A 已在展开中→停止)→ 结果:A
1
2
3
4

# 5.5 可变参数宏与 VA_ARGS——C++11/C++20 的三代演进

// C++11 基础——__VA_ARGS__ 为空时产生额外的逗号
#define LOG(fmt, ...) printf(fmt, __VA_ARGS__)
LOG("hello")  // → printf("hello", )  // ❌ 多余逗号——编译错误

// C++20 __VA_OPT__ ——优雅的空参数处理
#define LOG(fmt, ...) printf(fmt __VA_OPT__(,) __VA_ARGS__)
LOG("hello")       // → printf("hello")     ✅
LOG("x=%d", 42)    // → printf("x=%d", 42)  ✅

// GCC 扩展——##__VA_ARGS__ (在 C++20 之前的最常用写法)
#define LOG(fmt, ...) printf(fmt, ##__VA_ARGS__)
1
2
3
4
5
6
7
8
9
10
11

# 6. 条件编译——#if / #ifdef / #elif 的完整体系

# 6.1 #if defined vs #ifdef 的细微差异

// 这两行等价——检查宏是否定义(不管值是什么)
#ifdef FOO
#if defined(FOO)

// #if defined 的优势——可以组合多个条件
#if defined(FOO) && defined(BAR)   // ✅ 两个都定义
#if defined(FOO) || defined(BAR)   // ✅ 至少一个定义
#if defined(FOO) && FOO > 10       // ✅ 定义且值大于 10

// #ifdef 做不到这些组合
1
2
3
4
5
6
7
8
9
10

# 6.2 #if 中的预处理器算术——表达式的类型与限制

#if 1 + 2 * 3 == 7       // ✅ 整数算术
#if 'A' == 65             // ✅ 字符字面量(转为整数值)
#if defined(FOO) && FOO   // ✅ 宏的展开值参与比较

// 未定义的宏在 #if 中等价为 0
#if UNDEFINED_MACRO       // → #if 0 → 跳过这段代码
1
2
3
4
5
6

预处理器算术的类型——intmax_t/uintmax_t(C++11 起),没有 short/long 区别。

# 6.3 常见误区——#if 不能比较字符串、不识别 sizeof、不识别 C++ 常量

// ❌ 不能比较字符串
#if NAME == "foo"          // 永远为 true——指针比较,不是字符串比较

// ❌ 不能做 sizeof
#if sizeof(int) == 4       // 编译错误——预处理器不认得 sizeof

// ❌ 不能访问 C++ 常量
constexpr int VERSION = 2;
#if VERSION > 1            // 编译错误——constexpr 在预处理阶段不可见
// 正确做法:
#define VERSION 2
#if VERSION > 1            // ✅ 宏在预处理阶段可见
1
2
3
4
5
6
7
8
9
10
11
12

# 7. 预编译头(PCH)——以空间换编译时间

# 7.1 PCH 的工作原理——预处理结果的 dump 与快照恢复

普通编译:
  .cpp → 预处理(500ms) → 编译(2000ms) → .o

PCH 编译:
  ① 首次编译 pch.h:
     pch.h → 预处理 → 编译器内部状态 dump → pch.h.gch (~100MB)
     这个 dump 包含:预处理后的 token 流 + 符号表 + 模板实例化缓存

  ② 后续编译依赖 pch 的 .cpp:
     #include "pch.h" → 编译器识别到 .gch 文件存在
     → 跳过文件读取 + 预处理 + 词法分析 + 语法分析 + 符号表构建
     → 直接从 .gch 快照恢复编译器状态
     → 然后编译 .cpp 自身部分
1
2
3
4
5
6
7
8
9
10
11
12
13

关键:PCH 不是缓存编译结果——是以编译器内部状态的快照。恢复快照的速度远快于重新做预处理+语法分析。

# 7.2 GCC 的 .gch 与 MSVC 的 .pch——生成与使用

# GCC: 编译头文件
g++ -o stdafx.h.gch stdafx.h

# GCC 会自动查找与 #include "stdafx.h" 同目录的 stdafx.h.gch
# 如果有多个 .gch(不同编译选项)→ 编译器按选项匹配选择

# MSVC: /Yc 创建 PCH, /Yu 使用 PCH
cl /Ycstdafx.h source.cpp    # 创建 PCH
cl /Yustdafx.h other.cpp     # 使用 PCH
1
2
3
4
5
6
7
8
9

# 7.3 PCH 的反噬——PCH 失效的三种原因与隐性全量重编

PCH 失效的三种常见原因:

① 编译选项不匹配——PCH 和当前 TU 用了不同的 -O2/-D/-I
   → 编译器静默忽略 PCH → 全量重编 → 没有报错、没有警告——就是慢了

② PCH 头文件本身被修改
   → PCH 需要重新生成 → 所有依赖它的 TU 全部重编

③ PCH 包含了「太常用的变头」
   → wrapper.h 每改一次 → 整个项目重编
   → PCH 应该只包含极少变化的稳定头文件(如标准库、第三方 API)
1
2
3
4
5
6
7
8
9
10
11

# 8. Unity Build——把多个 .cpp 拼成一个 TU

# 8.1 原理——减少重复预处理 + 给链接器减负

Unity Build = 创建一个「大总管」.cpp,里面只用 #include 把多个 .cpp 串起来:

// unity_batch1.cpp
#include "a.cpp"
#include "b.cpp"
#include "c.cpp"
// ... 20 个 .cpp 并成一个 TU

传统编译:
  a.cpp: 预处理&lt;共同的.h> + 编译 a.cpp → a.o
  b.cpp: 预处理&lt;同一批.h> + 编译 b.cpp → b.o
  c.cpp: 预处理&lt;同一批.h> + 编译 c.cpp → c.o
  开销: 3× 预处理 + 3× 编译

Unity Build:
  unity_batch1.cpp: 预处理&lt;共同的.h> + 编译 a+b+c → unity_batch1.o
  开销: 1× 预处理 + 1× 编译(虽然编译时间更长——但预处理只做了一次)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 8.2 静态重名陷阱——匿名命名空间中的符号冲突

// a.cpp
namespace { struct Helper { int x; }; }   // TU 内私有——OK

// b.cpp
namespace { struct Helper { double y; }; } // 另一个 TU 内私有——OK

// 但合并为 Unity Build 后——同一个 TU 内有两个匿名的 Helper!
// → 编译器选择第一个定义(ODR 违规——UB)
1
2
3
4
5
6
7
8

规避规则:Unity Build 中的所有 .cpp 的匿名命名空间符号名不可冲突。

# 8.3 适用场景——什么时候 Unity Build 有正向收益

场景 收益 风险
CI 全量编译 -30~50% 编译时间 低(全量重编——PCH 也失效)
增量编译 ❌ 无效(只改一个 .cpp——整个 unity 重编) 高——比单独编译更慢
跨平台验证 ✅ 所有 .cpp 一起编译——ODR 检查 符号冲突可能暴露
Debug 构建 -20~30% 时间 难定位错误到具体 .cpp

# 9. include-what-you-use(IWYU)——精准包含的工程实践

# 9.1 原则:用到的符号必须有对应的 #include

IWYU 的核心规则:

① 使用任何函数/类型——必须直接 #include 声明它的头文件
② 不要依赖其他头文件代理包含——代理包含随时可能被重构移除
③ 不需要的不 include——减少 TU 膨胀

实例:
  // ❌ 隐式依赖
  #include "container.h"    // container.h 内部 include 了 &lt;string>
  std::string name_;        // 用了 string——但只 include 了 container.h

  // ✅ 显式依赖
  #include &lt;string>
  std::string name_;
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 9.2 常见反模式:隐式依赖——被别的头文件代理包含

重构隐形炸弹:
  ① module_a.h  include &lt;string>  include &lt;vector>
  ② user.cpp    include "module_a.h"
                 使用 std::string 和 std::vector——不直接 include

  ③ 重构 module_a.h——不再需要 vector——移除 #include &lt;vector>
  ④ user.cpp 编译失败——「std::vector not found」
     但 module_a.h 改了——user.cpp 不应该受影响!

链式影响:
  module_a.h → 20 个 .cpp 隐式依赖 std::vector(从 a.h 借)
  → 修改 a.h → 20 个 .cpp 编译失败——而它们和 a.h 没有逻辑关系
1
2
3
4
5
6
7
8
9
10
11
12

# 9.3 IWYU 工具的工作流程与限制

# 安装 (Clang 工具链)
apt install iwyu

# 分析一个文件
iwyu_tool.py -p build/ source.cpp

# 输出:
# source.cpp should add these lines:
# #include <memory>
#
# source.cpp should remove these lines:
# - #include "unused.h"  // lines 3-3
1
2
3
4
5
6
7
8
9
10
11
12

IWYU 的限制:

  • 基于 Clang 的 AST——必须有完整的编译数据库(compile_commands.json)
  • 智能——但不完美——对模板的符号归属判断有误判率
  • 需要人工审核——建议作为 CI 的警告而非硬错误

# 10. 综合案例串讲

# 10.1 案例真相揭晓

# 疑问 答案
① TU 的边界? 第 3 章:一个 .cpp = 一个 TU——预处理把所有 .h 展开为一个完整的文本文件
② #include 搜索路径? 第 4.1:引号=当前目录→-I →系统;尖括号=-I→系统
③ 宏展开规则? 第 5 章:prescan→替换→再扫描 + 蓝漆规则终止 + #/## 两级宏
④ #if vs #ifdef? 第 6.1:#if defined 支持组合条件、#ifdef 只检查单个定义
⑤ PCH 原理? 第 7 章:编译器内部状态快照——恢复快于重做预处理+语法分析
⑥ Unity Build? 第 8 章:多个 .cpp 拼成一个 TU——省预处理但增冲突风险
⑦ IWYU 原则? 第 9 章:显式 include 所有使用的符号——杜绝隐式依赖

案例①修复——宏爆炸:用 constexpr 替代宏、用 inline constexpr 变量替代表达式宏——在编译器层面收缩展开体积。

案例②修复——include 顺序依赖:

  1. 让 feature_x.h 自身 #include <optional>——实现头文件自给性
  2. 用 #ifndef 替代对 ENABLE_FEATURE_X 的无保护依赖——缺少配置时给清晰的 #error 消息
  3. 对所有 .cpp 运行 IWYU——补上缺失的直接 include

# 10.2 一个 .cpp 从磁盘到 AST 的完整旅程

source.cpp (300 行) → 原始文本

① 字符映射 (~10μs):
   - 三字符组检查 (C++17 已移除, 但编译器仍做兼容)
   - 行拼接 (backslash-newline → 合并)
   - 文件编码规范化 → 源文件字符集

② 预处理 ( ~200ms——主要是 #include 的文件 IO):
   - #include &lt;vector>: 打开文件→读 1000 行→插入当前位置→递归展开 vector 的 include
   - #include &lt;string>: 打开文件→读 800 行→插入
   - #define 宏展开
   - #ifdef 条件过滤
   → 输出: 约 50000 行的纯 C++ 文本(没有 # 指令、没有注释)

③ 词法分析 (~150ms):
   - 50000 行字符 → token 序列
   - 每个 token: 类型 (keyword/identifier/number/operator) + 位置 + 值

④ 语法分析 (~500ms——最耗时的阶段):
   - token 序列 → AST (抽象语法树)
   - 模板实例化 = 生成新的 AST 节点 → 大量时间消耗在此

⑤ 语义分析 (~300ms):
   - 名字查找、类型推导、重载决议
   - 常量表达式求值 (constexpr)
   - 诊断信息生成 (错误/警告)

⑥ 代码生成 → .o 文件

共: ~1-2s 对于一个中等复杂的 .cpp (取决于模板量和 include 深度)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

# 10.3 设计哲学回扣

哲学 1:预处理是 C 遗产——C++ 继承了它的能力也继承了它的祸根

#include 是优雅的文本拼接——也是编译时间的首要瓶颈。#define 是便捷的符号替换——也是错误信息的首要混淆源。C++ 在 C++11/14/17/20 中不断推出替代机制(constexpr、inline、templates、modules)来逐步削弱预处理器的必要性。 Modules (第 24 篇) 是这一演进路线的终点——用「模块」替代「头文件」、用「导出」替代「宏」。

哲学 2:隐式依赖是软件工程中的负债——重构时才会被追债

IWYU 的核心价值不是「让代码更干净」——是让重构不会引发连锁编译失败。隐式的 #include <string> 链让 module_a 的修改炸掉 20 个无关的 .cpp。依赖必须显式——这是软件工程的基本原则——在 C++ 的 include 体系中尤其重要。

哲学 3:预处理器不知道 C++——这种无知是故意的也是危险的

#if FOO > 10 在预处理阶段求值——此时 constexpr int FOO = 5 还不存在。#if sizeof(int) == 4 编译失败——因为 sizeof 是编译器概念。预处理器的「无知」意味着你需要牢记两套语言:预处理器语言 + C++ 语言——它们的边界就是编译的边界。

哲学 4:编译时间的优化不是「改编译器 flag」——是「减少输入量」

PCH 用快照省量、Unity Build 用合并省量、IWYU 用精确包含省量——这三种方案的核心策略都是减少编译器需要处理的文本总量。每个 #include <iostream> 在预处理后产生 33000 行输出——省掉一个不必要的 include 就是省 33000 行的编译器处理成本。

# 10.4 速查表合集

#include 搜索路径:

语法 优先搜索 备选搜索
"header.h" ① 当前目录 ② -I 路径 ③ 系统路径
<header> ① -I 路径 ② 系统路径

宏展开三步:

步 操作 关键规则
① 参数 prescan 对参数的宏先展开
② 参数替换 展开后的参数放进宏体
③ 整体再扫描 蓝漆规则——自引用终止

预处理器运算符:

运算符 作用 示例
# 字符串化 #x → "x"
## token 粘贴 a ## b → ab
defined 检查宏定义 defined(FOO)

条件编译速查:

指令 检查内容 组合条件
#ifdef X X 是否定义(不管值) ❌
#ifndef X X 是否未定义 ❌
#if defined(X) 同上 + 可组合 ✅ #if defined(X) && X > 1
#if X X 展开后的值(未定义 = 0) ✅
#elif / #else — —

编译加速三方案:

方案 原理 收益 适用
PCH 编译器状态快照 -30~50% 增量编译
Unity Build 合并多个 .cpp -30~50% CI 全量
IWYU 移除不必要 include -10~30% 持续优化

本篇小结:翻译单元是 C++ 编译的最小独立单位——一个 .cpp 加上所有展开后的头文件构成一个 TU。预处理是 C 的遗产——#include 展开头文件、#define 宏替换、#if 条件编译——所有这些在语义分析之前完成。PCH 用编译器状态快照来跳过重复预处理、Unity Build 用合并来减少预处理次数、IWYU 用精准包含来消灭隐式依赖。三者的共同目标:减少编译器需要处理的文本量——这是编译加速的最有效手段。

下一篇:预处理完成了——编译器拿到的是纯净的 C++ 文本。下一篇进入 49.编译期符号生成——name mangling 规则、Itanium ABI 命名、extern "C" 边界、重载在符号层的体现、demangle 工具——从文本到符号的下一步。

上次更新: 2026/06/10, 11:13:41
协程coroutine原理
编译期符号生成

← 协程coroutine原理 编译期符号生成→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式