编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • 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语言入门精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程虚拟地址空间
      • 栈与堆底层对决
      • 指针本质与多级解引
      • 指针运算底层真相
      • 函数指针与回调机制
      • 限定符与指针语义
      • 补码与位运算原理
      • IEEE754浮点本质
      • 数组与指针的纠葛
      • 结构体对齐与优化
      • 字符串存储与安全
      • 预处理器宏与条件编译
        • 目录
        • 1. 案例引入
          • 1.1 一段"不可能"的崩溃
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 预处理器位置
          • 2.2 宏与函数的本质差异
          • 2.3 翻译阶段全景图
        • 3. 宏展开三步机制
          • 3.1 prescan参数预扫描
          • 3.2 替换体展开
          • 3.3 再扫描与蓝漆规则
          • 3.4 递归抑制
        • 4. # 和 ## 操作符
          • 4.1 # 字符串化的精确语义
          • 4.2 ## 令牌粘贴的魔法
          • 4.3 两级宏必要
          • 4.4 可变参数宏与 _VAARGS__
        • 5. do-while(0) 工程理由
          • 5.1 裸宏在if-else中的语义断裂
          • 5.2 花括号方案为何不够
          • 5.3 do-while(0) 是唯一正确解
          • 5.4 死循环证明
        • 6. X-macro 自动代码生成
          • 6.1 什么是X-macro
          • 6.2 枚举与字符串互转
          • 6.3 运行时反射
          • 6.4 多维度代码生成
        • 7. 条件编译守卫
          • 7.1 #if-#elif-#else-#endif 体系
          • 7.2 defined运算符与短路求值
          • 7.3 #ifdef vs #if defined 的暗坑
          • 7.4 #pragma once vs #ifndef 头文件守卫
          • 7.5 错误与警告指令
        • 8. 综合案例串讲
          • 8.1 案例真相揭晓
          • 8.2 一个宏的生命周期
          • 8.3 面试高频问题清单
          • 8.4 宏编程速查卡
      • 编译到汇编全流程
      • 链接器符号与重定位
      • 静态库与动态库对比
      • Make与CMake构建
      • 文件IO与系统调用
      • 动态内存管理揭秘
      • 未定义行为与防御
      • C工程化与设计哲学
    • 标准集库

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • C语言入门精通
  • 专栏博客
杨充
2026-06-10
目录

预处理器宏与条件编译

# 12.预处理器宏与条件编译

宏展开三步 prescan→替换→再扫描、"蓝漆规则"防止无限递归、# 字符串化与 ## 令牌粘贴、两级宏的间接展开必要性、do { } while(0) 包装宏的工程理由、X-macro 自动生成代码表驱动、#if/#ifdef/#if defined 条件编译、#pragma once vs #ifndef 头文件守卫

# 目录

  • 1. 案例引入
    • 1.1 一段"不可能"的崩溃
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 预处理器位置
    • 2.2 宏与函数的本质差异
    • 2.3 翻译阶段全景图
  • 3. 宏展开三步机制
    • 3.1 prescan参数预扫描
    • 3.2 替换体展开
    • 3.3 再扫描与蓝漆规则
    • 3.4 递归抑制
  • 4. # 和 ## 操作符
    • 4.1 # 字符串化的精确语义
    • 4.2 ## 令牌粘贴的魔法
    • 4.3 两级宏必要
    • 4.4 可变参数宏与 VA_ARGS
  • 5. do-while(0) 工程理由
    • 5.1 裸宏在if-else中的语义断裂
    • 5.2 花括号方案为何不够
    • 5.3 do-while(0) 是唯一正确解
    • 5.4 死循环证明
  • 6. X-macro 自动代码生成
    • 6.1 什么是X-macro
    • 6.2 枚举与字符串互转
    • 6.3 运行时反射
    • 6.4 多维度代码生成
  • 7. 条件编译守卫
    • 7.1 #if-#elif-#else-#endif 体系
    • 7.2 defined运算符与短路求值
    • 7.3 #ifdef vs #if defined 的暗坑
    • 7.4 #pragma once vs #ifndef 头文件守卫
    • 7.5 错误与警告指令
  • 8. 综合案例串讲
    • 8.1 案例真相揭晓
    • 8.2 一个宏的生命周期
    • 8.3 面试高频问题清单
    • 8.4 宏编程速查卡

# 1. 案例引入

# 1.1 一段"不可能"的崩溃

先看一段某银行核心交易系统里跑了三年的代码,换了一个编译器版本后,测试环境全绿,生产环境一旦开启 -O2 就随机输出错数据——没有崩溃,没有 segfault,只有"对的数字突然变成错的":

// trade_engine.c —— 交易撮合引擎的撮合函数
#include <stdio.h>

#define TRADE_PRICE(a, b)  ((a) + (b) * 0.5)

double get_mid_price(double bid, double ask, int is_cross) {
    if (is_cross)
        return TRADE_PRICE(bid, ask);
    else
        return (bid + ask) / 2.0;
}

int main() {
    printf("mid = %.6f\n", get_mid_price(100.0, 102.0, 0));
    printf("mid = %.6f\n", get_mid_price(100.0, 102.0, 1));
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

现象(老编译器 GCC 7.5 -O0):

mid = 101.000000
mid = 151.000000    ← 100 + 102 × 0.5 = 151,正确
1
2

现象(新编译器 GCC 12.1 -O2):

mid = 101.000000
mid = 151.000000    ← 也是 151,没区别?
1
2

等等——这段代码本身没问题,问题出在生产环境里另一个宏的调用方没加括号。真实的生产代码是这样的:

// 生产环境真实代码(简化)
#define TAX_RATE 0.06
#define APPLY_TAX(amount) amount * TAX_RATE

double calc_profit(double trade_amount) {
    double commission = 5.0;
    return trade_amount
         + APPLY_TAX(trade_amount)     // ← 期望: trade_amount + trade_amount * 0.06
         - commission;                  // ← 实际: trade_amount + trade_amount * 0.06 - 5
    // 这里看上去没问题?看下一行……
}

double calc_total(double buy, double sell) {
    return APPLY_TAX(buy + sell);       // ← 期望: (buy + sell) * 0.06
                                        // ← 实际: buy + sell * 0.06
    // 💀 乘法的优先级高于加法!
    // buy=100, sell=200 → 期望 300*0.06=18
    //                    实际 100+200*0.06=100+12=112
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

更恐怖的场景——当宏和 ++ 组合时:

#define SQUARE(x)  ((x) * (x))

int a = 2;
int b = SQUARE(++a);    // 期望: ((3) * (3)) = 9
                        // 实际: ((++a) * (++a))
                        //       第一次 ++a → a=3,第二次 ++a → a=4
                        //       → 3 * 4 = 12 或 4 * 4 = 16(取决于求值顺序)
                        //       Undefined Behavior!
1
2
3
4
5
6
7
8

GCC 老版本在 -O0 下求值顺序恰好是"预期"的,-O2 打乱了——数据静默错误,不崩不报错,只输出差一块钱。金融系统里"差一块钱"比崩溃更可怕——后者至少有人修。

# 1.2 顺藤摸到根因

追查这个 bug 的过程:

  • 假设 1:是不是浮点精度?—— 差 94 块,不可能是精度。
  • 假设 2:是不是 GCC 12 的 bug?—— 查 Bugzilla,同一段代码在不同优化级别结果不同,GCC 回复 "== applied inside macros without parentheses → user error, not compiler bug"。
  • 假设 3:用 gcc -E 展开宏看看—— APPLY_TAX(buy + sell) 展开成 buy + sell * 0.06,而期望的是 (buy + sell) * 0.06。
  • 假设 4:这是"宏参数不加括号"的经典陷阱,但生产代码里这样的宏有 342 个,全部修完是不可能的。
  • 假设 5:为什么 GCC 7.5 -O0 结果是"对的"?—— 因为 -O0 下中间变量分配了额外的栈空间,巧合使得值的传播路径符合预期。-O2 做了更激进的寄存器分配和常量折叠,暴露了真实的语义(错误的语义)。

真正的凶手不是"优先级",而是"程序员把宏当函数用,却不知道宏是文本替换"。

这个事故里藏着至少 8 个原理点:

① 宏到底是怎么展开的?预处理器做了什么?                → 第 3 章
② 参数加括号就够了吗?什么时候加括号也不够?            → 第 3/5 章
③ # 和 ## 是什么?为什么需要两级宏配合它们?             → 第 4 章
④ 为什么函数用花括号、宏用 do-while(0)?               → 第 5 章
⑤ X-macro 是怎么做到自动生成代码的?                    → 第 6 章
⑥ #ifdef 和 #if defined 有区别吗?                    → 第 7 章
⑦ #pragma once 和 #ifndef 哪个好?                     → 第 7 章
⑧ 怎么用 gcc -E 一眼看出宏展开后的代码?                 → 第 8 章
1
2
3
4
5
6
7
8

# 1.3 我们要回答什么

这个事故就是本篇的主线案例。我们带着上面 8 个问号往下走,每讲完一段就解开一两个;最后在第 8 章把案例彻底剖开,并给出宏编程的铁律。

本篇路线:

架构总图 (第 2 章)
   ↓
宏展开三步 (第 3 章) ─→ 解开"预处理器到底怎么展开宏"
   ↓
# 和 ## 操作符 (第 4 章) ─→ 解开"字符串化和令牌粘贴"
   ↓
do-while(0) (第 5 章) ─→ 解开"多语句宏的正确包法"
   ↓
X-macro (第 6 章) ─→ 解开"用宏自动生成代码"
   ↓
条件编译 (第 7 章) ─→ 解开"#if/#ifdef/#pragma once"
   ↓
综合案例 (第 8 章) ─→ 彻底剖开 + 速查卡
1
2
3
4
5
6
7
8
9
10
11
12
13

📌 本篇定位:预处理器是 C 编译器流水线的第一步——在你写的代码到达"真正的编译器"(词法分析/语法分析)之前,预处理器已经改写了它。理解预处理器的行为,就是理解"编译器看到的代码长什么样"。第 03-10 篇讲的东西,都是"编译器看到预处理后的代码",而本篇告诉你"你写的代码变成了什么"。

# 2. 架构概览

# 2.1 预处理器位置

C 编译器处理一个 .c 文件的过程可以分为 8 个翻译阶段(translation phases)(C17 §5.1.1.2):

你的 .c 源代码
        │
        ├─ Phase 1: 字符映射(Trigraph 替换、行尾统一 \n)
        │
        ├─ Phase 2: 行拼接(反斜杠 + 换行 → 移除)    ← 宏定义可以跨多行!
        │
        ├─ Phase 3: 词法分割成"预处理令牌"              ← 注释被替换为空格
        │      │
        │   ┌─────────────────────────────────────────────────┐
        │   │            ★ 预处理器执行阶段(Phase 4)         │
        │   │                                                 │
        │   │  #include   → 递归展开头文件                    │
        │   │  #define    → 展开宏                            │
        │   │  #if/#ifdef → 条件编译(剪掉不满足的代码块)       │
        │   │  #error     → 触发编译错误                      │
        │   │  #pragma    → 传递给编译器                      │
        │   │  # 和 ##     → 字符串化 / 令牌粘贴              │
        │   │                                                 │
        │   └─────────────────────────────────────────────────┘
        │
        ├─ Phase 5: 字符常量/字符串字面量 → 转为执行字符集
        │
        ├─ Phase 6: 相邻字符串字面量拼接  ("a" "b" → "ab")
        │
        ├─ Phase 7: 词法分析 → 语法分析 → 语义分析 → 代码生成
        │
        └─ Phase 8: 链接(多个 .o 合并)
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

关键事实:预处理器(Phase 4)完全不知道 C 的语法——它不懂 if、for、类型、作用域。它只做文本层的宏替换和条件剪裁。

# 停下来看看预处理器输出的"纯文本"长什么样
$ gcc -E source.c -o source.i

# 对调试宏展开极其有用:
# - 保留注释以定位: gcc -E -C source.c
# - 不带行号标记:   gcc -E -P source.c
1
2
3
4
5
6

# 2.2 宏与函数的本质差异

很多人把宏当"没有类型检查的函数"用——这是错误认知的根源:

维度 宏 #define 函数
本质 文本替换 一段可执行代码
执行时间 编译前(预处理器) 运行时
参数求值 每次展开都"再求值"(文本复制) 调用前求值一次
类型检查 无 有
作用域 从定义处到文件末尾(或 #undef) C 作用域规则
副作用 参数有副作用时反复执行 参数只求值一次
递归 蓝漆规则禁止 允许
调试 看不到(展开后被编译器看到) 可以断点
性能 零调用开销(内联) 调用 / 返回开销
二进制体积 每次展开一份副本 代码只有一份

宏观选择建议:

  • 永远用函数,除非你能准确回答"为什么这里必须用宏"
  • 必须用宏的少数场景:sizeof 的替代(编译期常量)、类型无关的泛型操作、# / ## 操作符、X-macro 代码生成
  • 在 C++ 中,constexpr / template / inline 函数取代了 90% 的宏用途

# 2.3 翻译阶段全景图

理解一个 #define 从定义到展开的完整生命:

// 我们写的代码
#define MAX(a, b)  ((a) > (b) ? (a) : (b))
int x = MAX(1 + 2, 3 * 4);
1
2
3

预处理器视角的展开过程:

宏定义阶段:
  ┌─────────────────────────────────────────────────────┐
  │  预处理器读取 #define MAX(a, b)  ((a) > (b) ? (a) : (b))  │
  │  宏名: MAX                                            │
  │  参数列表: [a, b]                                      │
  │  替换体: ((a) > (b) ? (a) : (b))                       │
  │  → 存入宏符号表                                       │
  └─────────────────────────────────────────────────────┘

展开阶段(当遇到 MAX(1 + 2, 3 * 4)):
  ┌─────────────────────────────────────────────────────┐
  │ Step 1 - Prescan(预扫描):                           │
  │   实参 1+2 → 预扫描中识别为两个令牌: '1' '+' '2'        │
  │   实参 3*4 → 预扫描中识别为两个令牌: '3' '*' '4'        │
  │                                                     │
  │ Step 2 - 替换:                                        │
  │   把替换体中的 a → 1+2(纯文本替换)                     │
  │   把替换体中的 b → 3*4(纯文本替换)                     │
  │   结果: ((1+2) > (3*4) ? (1+2) : (3*4))             │
  │                                                     │
  │ Step 3 - Rescan(再扫描):                             │
  │   检查有没有新的宏需要展开(没有)                        │
  │   结果直接传递给编译器                                  │
  └─────────────────────────────────────────────────────┘

编译器看到的最终代码:
  int x = ((1+2) > (3*4) ? (1+2) : (3*4));
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

如果参数没加括号会怎样?

#define BAD_MAX(a, b)  a > b ? a : b
int x = BAD_MAX(1 + 2, 3 * 4);
// 展开后: int x = 1 + 2 > 3 * 4 ? 1 + 2 : 3 * 4;
// 优先级: 先算 3 * 4 = 12,然后 1 + 2 > 12 → false
//         → 结果取 3 * 4 = 12  ❌
// 期望:    (1+2) > (3*4) → 3 > 12 → false → 12
// 结果巧合一致,但语义已经错了——试试 BAD_MAX(1, 2+3)
// 展开: 1 > 2 + 3 ? 1 : 2 + 3 → 1 > 5 ? 1 : 5 → 5 ❌ 期望 5
// 这次又对了?不——试试 BAD_MAX(1+2, 3)
// 展开: 1 + 2 > 3 ? 1 + 2 : 3 → 3 > 3 ? 3 : 3 → 3 巧合一致
// 真正的坑: BAD_MAX(x = f(), g())
// 展开: x = f() > g() ? x = f() : g()  ← f() 被执行了两次!
1
2
3
4
5
6
7
8
9
10
11
12

# 3. 宏展开三步机制

# 3.1 prescan参数预扫描

当预处理器遇到一个函数式宏调用 MACRO(arg1, arg2) 时,第一步是对每个实参分别做 prescan(预扫描):

#define DOUBLE(x)  (x * 2)
#define VAL        10

int r = DOUBLE(VAL);     // 实参 VAL
1
2
3
4

Prescan 的过程:

DOUBLE(VAL) 被调用:
  实参 VAL → 预扫描 VAL → VAL 是一个宏!
            → 展开 VAL → 变成 10
            → 现在实参 = 10

  然后替换:
    DOUBLE 替换体中 x → 10
    结果: (10 * 2)
1
2
3
4
5
6
7
8

Prescan 不会展开的情况——被 # 或 ## 阻止:

#define STR(x)  #x           // # 阻止展开
#define VAL     hello

STR(VAL)     // → "VAL"(不是 "hello"!)
             // 因为 # 操作符阻止了实参的 prescan
1
2
3
4
5

这就是为什么需要两级宏来绕过这个阻止(第 4.3 节详述)。

实参里的逗号陷阱:

#define PAIR(a, b)  {a, b}

PAIR(1, 2)       // → {1, 2} ← 两个参数
PAIR((1, 2), 3)  // → {(1, 2), 3} ← 括号保护逗号不被当作分隔符
1
2
3
4

预处理器用括号嵌套层数来判断逗号是不是分隔符——只有最外层(括号深度为 0)的逗号才是参数分隔符。

# 3.2 替换体展开

Prescan 完成后,预处理器把 #define 的替换体拿过来,把形参全部替换成实参:

#define LOG(msg, level)  printf("[%d] %s\n", level, msg)

LOG("error", 3);
// 替换: printf("[%d] %s\n", level → 3, msg → "error")
// 结果: printf("[%d] %s\n", 3, "error")
1
2
3
4
5

字符串化 # 阻止展开:

#define STR(x)  #x
#define VAL     100

STR(VAL)       // 实参 VAL 不会被展开 → 直接字符串化 → "VAL"
1
2
3
4

令牌粘贴 ## 阻止展开:

#define CONCAT(a, b)  a##b
#define VAR           count

int CONCAT(VAR, _1) = 42;   // → int VAR ## _1 = 42 → int VAR_1 = 42
                             // VAR 和 _1 不会被展开!VAR_1 是一个新标识符
1
2
3
4
5

# 3.3 再扫描与蓝漆规则

替换完成后,预处理器会**再扫描(rescan)**整个展开结果,检查有没有新的宏需要展开:

#define QUADRULE(x)  DOUBLE(DOUBLE(x))
#define DOUBLE(x)    ((x) * 2)

int r = QUADRULE(5);
1
2
3
4

Rescan 过程:

QUADRULE(5):
  替换: x → 5,得到 DOUBLE(DOUBLE(5))
  Rescan: 发现了 DOUBLE(DOUBLE(5))
    → 先展开外层的 DOUBLE:
      实参 = DOUBLE(5) → prescan → 也是宏 → 先展开 → ((5) * 2)
      替换: ((DOUBLE(5)) * 2) → (((5) * 2) * 2)
    → 结果: (((5) * 2) * 2)
1
2
3
4
5
6
7

蓝漆规则(blue paint rule)——防止无限递归:

#define FOO BAR
#define BAR FOO

FOO   // → ??? 会不会无限展开?
1
2
3
4

预处理器如何防止:

FOO 被调用:
  FOO → 展开为 BAR
  在展开 BAR 时:
    预处理器标记"BAR 是 FOO 展开的"
    如果 BAR 的替换体又包含 FOO → 发现 
    → 不再展开(蓝漆规则)
  结果: BAR(FOO 被压住了)

实际上:
  FOO → BAR → FOO(被压住) → 字符串 "FOO"
  最终结果: FOO(取决于具体实现,标准说"行为未定义")
1
2
3
4
5
6
7
8
9
10
11

蓝漆规则:每个宏在历次展开中都会被标记——如果一次 rescan 又碰到了同一个宏,且这个宏在当前的展开链中正在展开,就跳过它。这就像刷蓝漆——"刷蓝了"的宏不能再被展开。

更直观的例子:

#define SELF(x)  SELF(x)

SELF(1)   // → SELF(1)(SELF 在自身展开中被标记为蓝色,不再展开)
1
2
3

# 3.4 递归抑制

用 gcc -E 验证蓝漆规则:

// test_blue.c
#define RECURSE(x)  (x > 0 ? RECURSE(x-1) : 0)
#define A B
#define B C
#define C A

int r1 = RECURSE(5);     // 期望: 递归?实际: ?
int r2 = A;              // 期望: 循环展开?实际: ?
1
2
3
4
5
6
7
8
$ gcc -E test_blue.c
int r1 = (5 > 0 ? RECURSE(5 -1) : 0);    # RECURSE 第二次出现被蓝漆压制
int r2 = A;                                # A→B→C→A(被压制)→A
1
2
3

结论:C 预处理器不支持递归宏。蓝漆规则保证每次展开终止。如果你需要"编译期递归展开",C 的方式是用 X-macro 或手动写出所有展开层级(如 __COUNTER__ + 宏模板展开)。

# 4. # 和 ## 操作符

# 4.1 # 字符串化的精确语义

# 操作符把宏实参转成字符串字面量:

#define STR(x)  #x

STR(hello)          // → "hello"
STR(42)             // → "42"
STR(hello world)    // ⚠️ 预处理器看到"两个实参"!逗号前、逗号后
STR("hello")        // → "\"hello\""  ← 内部的引号被转义
STR(a\nb)           // → "a\\nb"      ← 反斜线被转义(C 标准)
1
2
3
4
5
6
7

空格处理:

STR(  hello   world  )   // → "hello world"
                          // 前导和尾部空白被去掉
                          // 中间多个空白合并为一个
1
2
3

# 的暗坑——阻止 prescan:

#define STR(x)     #x
#define LOG_LEVEL  DEBUG

STR(LOG_LEVEL)    // → "LOG_LEVEL"(不是 "DEBUG"!)
                  // # 操作符阻止了 LOG_LEVEL 的预扫描展开
1
2
3
4
5

修复:用两级宏(第 4.3 节)。

# 4.2 ## 令牌粘贴的魔法

## 把两个预处理令牌粘成一个:

#define CONCAT(a, b)  a##b

int CONCAT(my_var_, 1) = 42;   // → int my_var_1 = 42;
int CONCAT(x, CONCAT(y, z));   // ⚠️ 先展开外层还是内层?
1
2
3
4

## 在参数替换之前的优先级:

#define PASTE(a, b)  a##b
int PASTE(1, PASTE(2, 3));   // → 1PASTE(2, 3) ← 不是 123!
// ## 阻止了内部 PASTE(2,3) 的 prescan 展开
1
2
3

经典应用——自动生成变量名:

#define UNIQUE_VAR(prefix)  CONCAT(prefix, __LINE__)

int UNIQUE_VAR(tmp) = 5;   // → int tmp_42 = 5;  // __LINE__ = 当前行号
1
2
3

经典应用——泛型宏(用 _Generic + ## 模拟类型重载):

#define PRINT(x)  _Generic((x), \
                    int:    print_int,   \
                    double: print_double, \
                    char*:  print_str    \
                  )(x)

PRINT(42);       // → print_int(42)
PRINT(3.14);     // → print_double(3.14)
PRINT("hello");  // → print_str("hello")
1
2
3
4
5
6
7
8
9

## 生成空令牌的合法技巧:

#define OPT_COMMA(x)  CONCAT(COMMA_, x)
#define COMMA_0
#define COMMA_1  ,

struct { int a OPT_COMMA(DEBUG) b; };
// DEBUG=0 → struct { int a b; };      ← 语法错误!缺少分号/逗号
// 正确用法:
int arr[] = { 1 OPT_COMMA(DEBUG) 2 };
// DEBUG=0 → int arr[] = { 1 2 };
1
2
3
4
5
6
7
8
9

# 4.3 两级宏必要

问题:# 和 ## 会阻止实参进一步展开,怎么突破?

#define STR(x)  #x
#define VAL     100

STR(VAL)       // → "VAL" ← 不是我们想要的 "100"
1
2
3
4

两级宏方案:

#define _STR(x)  #x
#define STR(x)   _STR(x)      // 第一级:展开 x,然后再传给 _STR
#define VAL      100

STR(VAL)       // → _STR(100) → "100" ✅
1
2
3
4
5

展开过程分析:

STR(VAL):
  Prescan: VAL → 展开为 100
  替换: _STR(100)
  Rescan: _STR(100):
    Prescan: 100 → 不是宏,直接
    替换: #100 → "100"
  结果: "100"
1
2
3
4
5
6
7

两级宏的根本原因:# 和 ## 阻止展开只作用于直接包含它们的宏。如果中间加一层"展开代理",代理宏不包含 #/##,就能正常展开实参。

实用模板——永远用两级宏包装 # 和 ##:

// 字符串化的标准写法
#define STRINGIFY_IMPL(x)  #x
#define STRINGIFY(x)       STRINGIFY_IMPL(x)

// 令牌粘贴的标准写法
#define CONCAT_IMPL(a, b)  a##b
#define CONCAT(a, b)       CONCAT_IMPL(a, b)

// 测试
#define DEBUG_LEVEL 3
STRINGIFY(DEBUG_LEVEL)   // → "3" ✅
1
2
3
4
5
6
7
8
9
10
11

# 4.4 可变参数宏与 VA_ARGS

C99 引入的可变参数宏:

#define LOG(fmt, ...)  printf(fmt, __VA_ARGS__)

LOG("error: %s", msg);      // → printf("error: %s", msg);
LOG("hello");               // ⚠️ 展开为 printf("hello", )
                            //    多了个逗号!编译警告
1
2
3
4
5

__VA_ARGS__ 为空时的多余逗号是可变参数宏最烦人的坑。

GNU 扩展 ##__VA_ARGS__(清除多余逗号):

#define LOG(fmt, ...)  printf(fmt, ##__VA_ARGS__)

LOG("hello");               // → printf("hello") ✅ 逗号被 ## 吞掉了
LOG("error: %s", msg);      // → printf("error: %s", msg) ✅
1
2
3
4

C++20/C23 的 __VA_OPT__(标准替代):

#if __STDC_VERSION__ >= 202311L
#define LOG(fmt, ...)  printf(fmt __VA_OPT__(,) __VA_ARGS__)
// __VA_OPT__(,) 表示: 如果 __VA_ARGS__ 非空,就插入一个逗号
#endif
1
2
3
4

可变参数宏与 snprintf 的安全封装:

#define LOG_SAFE(buf, sz, fmt, ...) \
    snprintf(buf, sz, "[%s:%d] " fmt, __FILE__, __LINE__, ##__VA_ARGS__)

char buf[256];
LOG_SAFE(buf, sizeof(buf), "trade_id=%d", trade_id);
// → snprintf(buf, 256, "[%s:%d] trade_id=%d", "trade.c", 42, trade_id);
1
2
3
4
5
6

# 5. do-while(0) 工程理由

# 5.1 裸宏在if-else中的语义断裂

多语句宏如果没有正确包裹,在 if-else 中使用时会语义断裂:

// ❌ 裸多语句宏 —— 灾难
#define SWAP(a, b)  \
    int tmp = a;    \
    a = b;          \
    b = tmp;

if (flag)
    SWAP(x, y);           // 展开后:
else                      // if (flag)
    do_something();       //     int tmp = x;
                          //     x = y;
                          //     b = tmp;  ← 不在 if 里面!
                          //     ;           ← 空语句,与 else 断开
                          // else           ← 语法错误!
                          //     do_something();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

展开后编译器看到的:

if (flag)
    int tmp = x;            // ← if 控制的只有这一句!
    x = y;                  // ← 这两句不在 if 里
    b = tmp;                // ←
    ;                       // ← 分号结束 if 语句
else                        // ← 💀 "else without a previous if"
    do_something();
1
2
3
4
5
6
7

# 5.2 花括号方案为何不够

"用花括号包住不就行了?" —— 不行:

// ❌ 花括号方案 —— 分号问题
#define SWAP(a, b)  {           \
    int tmp = a;                 \
    a = b;                       \
    b = tmp;                     \
}

if (flag)
    SWAP(x, y);                // 展开后: if (flag) { ... };
else                           //                              ↑ 多余的分号!
    do_something();            // 💀 语法错误:else 前面多了一个分号

// 不用分号?
if (flag)
    SWAP(x, y)                 // 没有分号,但看起来很奇怪
else
    do_something();            // ✅ 这样才对!但很容易忘掉不写分号
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

花括号方案的两个致命问题:

  1. 需要一个不该存在的分号——违反了"函数调用后加分号"的直觉
  2. 内部变量逃逸——tmp 可能会与外部作用域的同名变量产生阴影警告

# 5.3 do-while(0) 是唯一正确解

// ✅ do-while(0) —— 完美的多语句宏包装
#define SWAP(a, b)  do {        \
    int tmp_ = (a);              \
    (a) = (b);                   \
    (b) = tmp_;                  \
} while(0)

// 使用:
if (flag)
    SWAP(x, y);                 // 展开后: do { ... } while(0);
else                            //         ↑ 分号结束 do-while,合法!
    do_something();             // ✅ 完美,符合直觉
1
2
3
4
5
6
7
8
9
10
11
12

为什么 do-while(0) 是唯一正确解:

方案 if-else 分号直觉 变量作用域 break 兼容
裸语句 ❌ 断裂 ✅ ❌ 污染 N/A
{ } ❌ 分号问题 ❌ 需省略分号 ✅ ❌
do { } while(0) ✅ ✅ ✅ ✅
if(1) { } else ((void)0) ✅ ✅ ✅ ❌ 不优雅

break 兼容性的意义——宏内部可以用 break 提前退出:

#define SAFE_DIVIDE(result, a, b) do {  \
    if ((b) == 0) {                      \
        (result) = 0;                    \
        break;                           \
    }                                    \
    (result) = (a) / (b);                \
} while(0)

int result;
SAFE_DIVIDE(result, 10, 0);   // → result = 0(安全)
SAFE_DIVIDE(result, 10, 2);   // → result = 5
1
2
3
4
5
6
7
8
9
10
11

# 5.4 死循环证明

疑惑:do { ... } while(0) 会不会生成多余的循环指令?

论证:编译器看到 while(0) 这种常量 false 条件,会直接消除整个循环结构。

// test.c
#define SWAP(a, b) do { int t = a; a = b; b = t; } while(0)
void f(int* x, int* y) { SWAP(*x, *y); }
1
2
3
$ gcc -O2 -S test.c -o test.s
1

汇编(简化):

f:
    mov     eax, [rdi]       ; t = *x
    mov     ecx, [rsi]       ; ecx = *y
    mov     [rdi], ecx       ; *x = ecx
    mov     [rsi], eax       ; *y = t
    ret                      ; 没有循环!没有 while 的任何痕迹
1
2
3
4
5
6

结论:编译器完全消除 do-while(0) 的控制流开销。零成本抽象——在正确性上提供了块作用域和分号语义,在运行时代价上为零。

# 6. X-macro 自动代码生成

# 6.1 什么是X-macro

X-macro 是 C 语言中利用宏在编译期自动生成重复代码的技术——它的核心思想是:把数据定义和代码逻辑分离,用同一份数据定义生成多种代码。

// 数据定义文件: color_list.h
// 每一行是 X(name, value, description)
// 注意:不定义 X!让"使用方"来定义 X

X(COLOR_RED,   0xFF0000, "Red")
X(COLOR_GREEN, 0x00FF00, "Green")
X(COLOR_BLUE,  0x0000FF, "Blue")
X(COLOR_BLACK, 0x000000, "Black")
1
2
3
4
5
6
7
8
// 使用方——生成枚举
#define X(name, value, desc)  name = value,
typedef enum {
    #include "color_list.h"
    COLOR_COUNT
    #undef X
} Color;
// 展开后:
// typedef enum {
//     COLOR_RED = 0xFF0000,
//     COLOR_GREEN = 0x00FF00,
//     COLOR_BLUE = 0x0000FF,
//     COLOR_BLACK = 0x000000,
//     COLOR_COUNT
// } Color;

// 使用方——生成名字数组
#define X(name, value, desc)  #name,
const char* color_names[] = {
    #include "color_list.h"
};
#undef X
// 展开后: const char* color_names[] = { "COLOR_RED", "COLOR_GREEN", ... };

// 使用方——生成描述数组
#define X(name, value, desc)  desc,
const char* color_descs[] = {
    #include "color_list.h"
};
#undef X
// 展开后: const char* color_descs[] = { "Red", "Green", ... };
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

一次修改,三处同步——这就是 X-macro 的威力:你只需维护 color_list.h 一份数据,枚举、名字表、描述表自动同步。

# 6.2 枚举与字符串互转

这是 X-macro 最经典的场景:

// error_codes.def (.def 是 X-macro 数据文件的惯用后缀)
// 不写头文件保护!它就是"数据文件",要被多次 #include

X(ERR_OK,       0, "Success")
X(ERR_NOT_FOUND, 1, "Resource not found")
X(ERR_TIMEOUT,  2, "Operation timed out")
X(ERR_MEMORY,   3, "Out of memory")
X(ERR_PERM,     4, "Permission denied")
1
2
3
4
5
6
7
8
// error_codes.h —— 你 include 一次的"正常头文件"
#ifndef ERROR_CODES_H
#define ERROR_CODES_H

// 枚举
#define X(code, num, msg)  code = num,
typedef enum {
    #include "error_codes.def"
    ERR_MAX
} ErrorCode;
#undef X

// 字符串转换函数
const char* error_msg(ErrorCode code);

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// error_codes.c
#include "error_codes.h"

const char* error_msg(ErrorCode code) {
    static const char* msgs[] = {
        #define X(code, num, msg)  msg,
        #include "error_codes.def"
        #undef X
    };
    // 展开后: { "Success", "Resource not found", "Operation timed out", ... }

    if (code >= 0 && code < ERR_MAX)
        return msgs[code];
    return "Unknown error";
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

为什么 X-macro 比手动维护两张表强:

  • 添加一个错误码只需要在 error_codes.def 加一行——枚举和消息自动同步
  • 编译器报"枚举值重复"错误,但不会报"消息表缺了一项"——X-macro 从机制上消除遗漏的可能

# 6.3 运行时反射

X-macro 可以提供一种"穷人的反射(reflection)":

// fields.def —— 描述一个 struct 的所有字段
// X(type, name, default_value)

X(int,    id,       0)
X(char*,  name,     "unknown")
X(double, balance,  0.0)
X(int,    status,   1)
1
2
3
4
5
6
7
// 结构体定义
#define X(type, name, def)  type name;
typedef struct {
    #include "fields.def"
} Account;
#undef X
// 展开: typedef struct { int id; char* name; double balance; int status; } Account;

// 序列化为 JSON
void account_to_json(const Account* a, char* buf, size_t size) {
    char* p = buf;
    int n;

    n = snprintf(p, size, "{");
    p += n; size -= n;

    #define X(type, name, def) \
        _Generic(((type){0}),       \
            int:    _json_int,      \
            double: _json_double,   \
            char*:  _json_str       \
        )(a->name, &p, &size, #name);
    #include "fields.def"
    #undef X

    snprintf(p, size, "}");
}

// 清零
void account_init(Account* a) {
    #define X(type, name, def)  a->name = def;
    #include "fields.def"
    #undef X
}
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
34

X-macro 的限制:

  • 数据文件里的字段名必须是 C 标识符
  • 不能用 X-macro 生成"不同字段不同类型的不同处理"时,需要用 _Generic 配合(C11+)
  • X-macro 生成代码的可调试性差——错误信息指向展开后的位置而非 .def 文件

# 6.4 多维度代码生成

X-macro 不限于"一张表"。可以用多张表交叉生成:

// 协议命令表
#define PROTO_READ    0x01
#define PROTO_WRITE   0x02
#define PROTO_QUERY   0x03

// 数据类型表
#define DTYPE_INT     0x10
#define DTYPE_STR     0x20
#define DTYPE_FLOAT   0x30

// 组合生成处理函数(二维 X-macro)
#define COMMANDS \
    X(PROTO_READ,  DTYPE_INT,   read_int)   \
    X(PROTO_READ,  DTYPE_STR,   read_str)   \
    X(PROTO_WRITE, DTYPE_INT,   write_int)  \
    X(PROTO_WRITE, DTYPE_FLOAT, write_float)\
    X(PROTO_QUERY, DTYPE_STR,   query_str)

// 生成 switch-case 分发
void dispatch(uint8_t proto, uint8_t dtype, void* data) {
    switch (proto) {
    #define X(p, d, fn)  case p: /* 此处需要再嵌套一层 switch 或 if */
    #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

这种技巧在协议解析、固件状态机、嵌入式外设驱动等"数据表驱动"场景中非常常见。

# 7. 条件编译守卫

# 7.1 #if-#elif-#else-#endif 体系

条件编译让同一份源码在不同平台/配置下生成不同代码:

#if defined(__linux__)
    #include <sys/epoll.h>
    #define IO_BACKEND  "epoll"
#elif defined(__APPLE__) || defined(__FreeBSD__)
    #include <sys/event.h>
    #define IO_BACKEND  "kqueue"
#elif defined(_WIN32)
    #include <winsock2.h>
    #define IO_BACKEND  "IOCP"
#else
    #error "Unsupported platform: no async IO backend"
#endif
1
2
3
4
5
6
7
8
9
10
11
12

#if 的表达式限制:

  • 只能使用整数常量表达式
  • 可用运算符:+ - * / % < > <= >= == != && || ! & | ^ ~ << >>
  • 所有未定义的宏在 #if 中被当作 0
  • 可以使用 defined(MACRO) 运算符
  • 不能使用 sizeof、类型转换、变量
#if defined(DEBUG) && DEBUG_LEVEL > 2
    // 深层调试代码
#endif

#if UINT_MAX == 0xFFFFFFFF
    // 32位平台
#else
    // 64位平台
#endif
1
2
3
4
5
6
7
8
9

# 7.2 defined运算符与短路求值

defined 是预处理器的"关键词"——只存在于 #if/#elif 表达式中:

// 检查多个宏是否定义
#if defined(FEATURE_A) && defined(FEATURE_B)
    // 两者都有

// 等效写法
#ifdef FEATURE_A
    #ifdef FEATURE_B
        // ...
    #endif
#endif
1
2
3
4
5
6
7
8
9
10

短路求值——#if 中的 && 和 || 是短路的:

#if defined(ENCRYPTION) && ENCRYPTION_LEVEL >= 4
    // 如果 ENCRYPTION 未定义 → defined(ENCRYPTION)=0
    // → 短路,ENCRYPTION_LEVEL 不会被求值,不会报 unexpanded 警告
#endif
1
2
3
4

# 7.3 #ifdef vs #if defined 的暗坑

很多人认为 #ifdef MACRO 和 #if defined(MACRO) 等价——基本对,但有细微差异:

// ✅ 完全等价(单个条件)
#ifdef MACRO
#if defined(MACRO)

// ❌ 不等价(组合条件)
#ifdef MACRO_A && MACRO_B     // ← 编译器可能只检查"MACRO_A"是否定义!
                               //    因为预处理器把 && 当作"更多名字"来解析
                               //    标准规定 #ifdef 后只能跟一个标识符
                               //    行为未定义!

// ✅ 正确写法
#if defined(MACRO_A) && defined(MACRO_B)
1
2
3
4
5
6
7
8
9
10
11
12

#ifdef 的限制:

  • 只能跟一个标识符
  • 不能做复杂逻辑

实践建议:永远用 #if defined(...) 而不是 #ifdef——前者更通用、支持复杂逻辑、不易出错。

# 7.4 #pragma once vs #ifndef 头文件守卫

方案 A:#ifndef 传统守卫:

// my_header.h
#ifndef MY_HEADER_H
#define MY_HEADER_H

// ... 头文件内容 ...

#endif // MY_HEADER_H
1
2
3
4
5
6
7

方案 B:#pragma once:

// my_header.h
#pragma once

// ... 头文件内容 ...
1
2
3
4

对比:

维度 #ifndef #pragma once
标准化 ✅ C89 起标准 ⚠️ 非标准(但 GCC/Clang/MSVC 都支持)
可移植性 ✅ 100% ⚠️ 99.9%(几乎没例外)
重复包含 基于宏名检测 基于文件路径/文件系统
文件名冲突 ❌ 两文件用同一宏名会出问题 ✅ 不依赖宏名
符号链接 ✅ 宏名判断 ⚠️ 不同路径符号链接到同一文件 — 可能不检测
维护成本 需维护宏名 零维护

#ifndef 的经典 bug:

// a.h
#ifndef COMMON_H        // ← 手误!应该是 A_H
#define COMMON_H
// ...

// b.h
#ifndef COMMON_H        // ← 也用了 COMMON_H!
#define COMMON_H
// ...
// 💀 第二个 include 的 b.h 被"守卫"挡掉了!编译丢失整个 b.h 的内容
1
2
3
4
5
6
7
8
9
10

推荐策略:

// 双保险 —— 既防编译器不认识 #pragma once,又防宏名冲突
#pragma once
#ifndef MY_HEADER_H
#define MY_HEADER_H

// ... 内容 ...

#endif
1
2
3
4
5
6
7
8

大多数现代项目(Linux 内核除外——它不用 #pragma once)都在逐渐迁移到纯 #pragma once。

# 7.5 错误与警告指令

预处理器可以在编译期强制报错:

#if CHAR_BIT != 8
    #error "This code requires 8-bit chars"     // 编译在此停止
#endif

#if defined(USE_FLOAT) && defined(USE_DOUBLE)
    #error "USE_FLOAT and USE_DOUBLE are mutually exclusive"
#endif

#warning "This API is deprecated, use v2 instead"  // GCC/Clang 扩展
1
2
3
4
5
6
7
8
9

_Static_assert vs #error:

// #error:预处理器阶段报错,不能检查 sizeof / 类型
#if UINT_MAX < 0xFFFFFFFF
    #error "Requires at least 32-bit unsigned int"
#endif

// _Static_assert(C11):编译阶段报错,可以检查 sizeof / 类型
_Static_assert(sizeof(void*) == 8, "Requires 64-bit pointers");
1
2
3
4
5
6
7

# 8. 综合案例串讲

# 8.1 案例真相揭晓

回到第 1 章交易引擎的八个疑问,逐条作答:

疑问 答案
① 宏是怎么展开的?预处理器做了什么? 第 3 章:三步机制——prescan 实参、文本替换、rescan 再扫描
② 参数加括号就够了吗? 第 3/5 章:不够——括号防优先级但防不了副作用(++x 被执行多次)
③ # 和 ## 需要两级宏吗? 第 4.3:需要——#/## 阻止 prescan,中间加一层代理宏来绕过
④ 为什么用 do-while(0)? 第 5.3:唯一同时满足 if-else 语义、分号直觉、变量作用域、break 兼容的方案
⑤ X-macro 怎么自动生成代码? 第 6 章:数据文件被多次 #include,每次重新定义 X 宏生成不同代码
⑥ #ifdef 和 #if defined 有区别吗? 第 7.3:单条件等价,组合条件必须用 #if defined
⑦ #pragma once vs #ifndef 哪个好? 第 7.4:#pragma once 维护成本低,但非标准;#ifndef 标准但易手误
⑧ 怎么用 gcc -E 看展开? 第 2.1:gcc -E source.c 输出预处理后的纯文本

第 1 章案例的真正根因:

#define APPLY_TAX(amount) amount * TAX_RATE

APPLY_TAX(buy + sell)
  → buy + sell * 0.06
  → 不是 (buy + sell) * 0.06

根因: 宏参数未加括号,导致优先级错误
      GCC -O2 的寄存器分配暴露了真实语义
1
2
3
4
5
6
7
8

修复方案:

方案 A:宏参数全面加括号(治标)

#define APPLY_TAX(amount)  ((amount) * TAX_RATE)
1

代价:副作用仍然存在(APPLY_TAX(f()) 调 f() 两次)。

方案 B:改宏为静态内联函数(治本)

static inline double apply_tax(double amount) {
    return amount * TAX_RATE;
}
1
2
3

代价:失去类型泛化能力(但 _Generic 可补救)。收益:类型安全、参数只求值一次、可调试。

方案 C:C11 _Generic 泛型宏(兼顾安全与灵活)

#define APPLY_TAX(amount)                              \
    _Generic((amount),                                  \
        double: apply_tax_double,                       \
        float:  apply_tax_float,                        \
        default: apply_tax_default                      \
    )(amount)
1
2
3
4
5
6

# 8.2 一个宏的生命周期

把 #define MAX(a,b) ((a)>(b)?(a):(b)) 的全过程串起来:

定义期(Phase 4)
  ├─ 预处理器读到 #define MAX(a,b) ((a)>(b)?(a):(b))
  ├─ 词法分析:MAX=宏名, a,b=形参列表, ((a)>(b)?(a):(b))=替换体
  └─ 存入宏符号表,等待被调用

调用展开期(Phase 4,每次遇到 MAX(x,y))
  ├─ Prescan 实参: 扫描 x 和 y 中是否含有宏 → 有则展开
  ├─ 替换: 把替换体中的 a→x, b→y(纯文本复制)
  ├─ Rescan: 检查替换结果有无新宏 → 有则继续展开(蓝漆规则阻止递归)
  └─ 把最终结果传递给 Phase 5

编译期(Phase 5-7)
  ├─ 编译器收到的文本: ((x)>(y)?(x):(y))
  ├─ 词法分析 → 语法分析 → 语义分析
  └─ 代码生成:把三元运算符变成汇编指令

链接/运行期
  ├─ 宏已经不存在了——它是纯编译期概念
  └─ 性能: 零调用开销,但可能增大代码体积(每次调用一份副本)

清理期
  └─ #undef MAX 从符号表中移除
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 8.3 面试高频问题清单

1. 宏和函数的区别?什么时候用宏?

宏是编译前的文本替换,函数是运行时的代码调用。宏无类型检查、参数在替换中多次求值(副作用)、不能用调试器断点。用宏的少数场景:需要 #/## 操作符、编译期常量、X-macro 代码生成、类型无关的泛型(C 的 _Generic 之前)。在 C++ 中,constexpr/template/inline 取代了绝大多数宏。

2. do { ... } while(0) 的作用?

让多语句宏在 if-else 语句中表现为一个完整的语句,支持调用后面加分号、内部变量有块作用域、内部可用 break 提前退出。编译器优化后会完全消除 while(0) 循环。

3. # 和 ## 操作符各做什么?为什么需要两级宏?

# 把实参转成字符串字面量,## 把两个令牌粘成一个。它们都会阻止实参的 prescan 展开——两级宏(中间插入一个不含 #/## 的代理宏)让实参先展开,再传给含 #/## 的内部宏。

4. X-macro 是什么?用在什么场景?

X-macro 是一种编译期代码生成技术:定义一个数据文件(含 X 宏列表),用 #include 多次导入,每次重新定义 X 宏以生成不同代码(枚举、字符串表、序列化、初始化函数等)。经典场景:错误码定义与消息互转、状态机跳转表、协议命令分发。

5. #ifdef 和 #if defined 有区别吗?

单条件等价;组合条件必须用 #if defined(A) && defined(B),因为 #ifdef 只能跟一个标识符。生产代码推荐统一用 #if defined。

6. #pragma once vs #ifndef 头文件守卫,选哪个?

#pragma once 维护成本低、不依赖宏名唯一性;但非标准(实际上 GCC/Clang/MSVC 都支持)。#ifndef 是标准 C89,100% 可移植,但宏名可能手误冲突。推荐双保险:#pragma once + #ifndef 都写。

7.蓝漆规则是什么?为什么 C 预处理宏不支持递归?

蓝漆规则:一个宏在展开过程中如果再次遇到自己,就跳过不展开。这保证了宏展开一定终止,也意味着 C 预处理宏不支持真正的递归。

8. gcc -E 做什么?怎么用它调试宏?

gcc -E 让编译器停在预处理阶段,输出预处理后的纯文本。-C 保留注释,-P 去掉行号标记。调试宏时先跑 gcc -E,看展开结果是否符合预期。

9. 可变参数宏中 ##__VA_ARGS__ 的 ## 是什么?

GNU 扩展:当 __VA_ARGS__ 为空时,## 会吞掉前面的逗号,避免产生语法错误。C++20/C23 用 __VA_OPT__(,) 替代,更标准。

10. 宏参数用 () 包住就绝对安全了吗?

不——括号只解决运算符优先级问题,不解决副作用问题。MAX(++x, y) 中 ++x 在展开后的三元运算符中可能被执行两次(UB)。这就是为什么标准 C 库的 putc 等宏实现会先拷贝参数到临时变量。

# 8.4 宏编程速查卡

永远遵守的铁律:

// 铁律 1: 所有宏参数加括号(除非是 X-macro 的 # 使用场景)
#define SQUARE(x)  ((x) * (x))        // ✅
#define SQUARE(x)  x * x              // ❌

// 铁律 2: 整个替换体加括号(表达式宏)
#define THREE_FOURTHS(x)  ((x) * 3 / 4)   // ✅
#define THREE_FOURTHS(x)  (x) * 3 / 4     // ❌

// 铁律 3: 多语句宏用 do-while(0)
#define SWAP_GENERIC(a, b, type) do { \
    type tmp = (a); (a) = (b); (b) = tmp; \
} while(0)

// 铁律 4: # 和 ## 用两级宏
#define STR_IMPL(x) #x
#define STR(x) STR_IMPL(x)

// 铁律 5: 决不让宏参数有副作用
// ❌ SWAP(*p++, *q++)  → p 和 q 都自增多次,UB
// ✅ int tmp_p = *p++; SWAP(tmp_p, *q); ← 先求值再传

// 铁律 6: 能改成函数就不要用宏
static inline int max(int a, int b) { return a > b ? a : b; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

诊断调试命令:

# 查看预处理输出(关键!)
gcc -E source.c -o source.i            # 保留注释: gcc -E -C
gcc -E -dM source.c                     # 只输出所有宏定义(不带展开代码)
gcc -E -dD source.c                     # 输出所有宏定义 + 预处理结果
gcc -E -P source.c | grep -v '^$'       # 去掉行号+空行,干净查看

# 查看宏展开到哪一级终止了
gcc -E -dI source.c                     # 包含 #include 嵌套信息

# 单独运行预处理器
cpp source.c -o source.i                # cpp = C PreProcessor

# 看某个宏的定义
echo '#include "header.h"' | gcc -E -dM - | grep MACRO_NAME

# 验证 do-while(0) 被优化掉
gcc -O2 -S source.c -o source.s && grep -i 'while\|loop' source.s
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

宏安全等级分类:

风险等级 示例 替代方案
🔴 高风险 参数有副作用的宏 MAX(++x,y) 内联函数 / 先求值
🟡 中风险 无括号表达式宏 #define PI 3.14+eps 加括号 (3.14+eps)
🟢 低风险 纯常量宏 #define BUFSIZ 4096 可以,但 const 更好
🟢 低风险 X-macro 代码生成 无更优替代(C 语言的工程价值)

下一篇:13.文件IO缓冲与系统调用 —— 我们已经知道"程序长什么样(宏展开后)",下一步进入运行时:printf 到底经过了几层缓冲才到磁盘?write 和 fwrite 的效率差在哪?内核缓冲区和用户态缓冲区的秘密是什么?

上次更新: 2026/06/11, 09:01:44
字符串存储与安全
编译到汇编全流程

← 字符串存储与安全 编译到汇编全流程→

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