从第一个 hello.c 开始,我们几乎每个程序开头都有 #include <stdio.h>。你一直知道它是“引入头文件”,但你可能没深想过:那个 # 到底是什么?#include 和 #define 又是怎么工作的?
它们都归属于 C 语言的预处理器——在编译器真正开始编译之前,有一个独立的“预处理”阶段,对源码进行一系列的文本处理。可以把预处理器想象成一个文字编辑助手,它按照你的指令进行查找替换、条件保留、文件拼接,最后把一份“干净”的 .c 文件交给编译器。
预处理器是 C 语言极具特色的部分,用得好可以让代码更简洁、更灵活;用不好会引发各种诡异 bug。今天我们就来全面掌握它。
一、回顾编译四阶段中的预处理
第三篇我们简要介绍过编译的四个阶段。这里再复习一下预处理在整个流程中的位置:
源文件 (.c)
↓
[预处理] ← 我们在这里!处理 #include、#define、#ifdef 等
↓
翻译单元 (纯净的 .i 文件)
↓
[编译] → 汇编代码 (.s)
↓
[汇编] → 目标文件 (.o)
↓
[链接] → 可执行文件
预处理阶段做的工作包括:
- 展开
#include(把头文件内容插入) - 替换
#define宏 - 处理条件编译指令(
#if、#ifdef等) - 删除注释
- 处理行标识(
#line)、错误指令(#error)等
最终输出一个“翻译单元”,其中不包含任何预处理指令,全都是纯 C 代码。
你可以用 gcc -E 亲眼看看预处理结果:
gcc -E hello.c -o hello.i
打开 hello.i,你会看到原本的 #include <stdio.h> 被替换成了好几千行的内容——那就是 stdio.h 里嵌套包含的所有声明。
二、宏定义 #define:文本替换的利器
1. 简单宏(对象式宏)
#define PI 3.14159
#define MAX_STUDENTS 100
#define GREETING "Hello, World!"
本质就是文本替换。预处理阶段,代码中所有出现 PI 的地方(除了字符串字面量内部),都会被原样替换成 3.14159。这和我们第四篇讲的常量定义形成了对比:
#define PI 3.14159 | const double PI = 3.14159; | |
|---|---|---|
| 本质 | 文本替换 | 带类型的只读变量 |
| 内存 | 不占运行时内存 | 占内存 |
| 类型检查 | 无 | 有 |
| 可以取地址 | 否 | 是 |
| 作用域 | 从定义处到文件末尾或 #undef | 块作用域 |
定义宏的注意点:
- 宏名通常全大写(约定俗成,一眼就知道它是宏)。
- 不要在末尾加分号!
#define PI 3.14;会让所有PI被替换成3.14;,可能导致2 * PI变成2 * 3.14;这种非法语法。
2. 带参宏(函数式宏)
宏也可以有参数,像函数一样使用:
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
使用:
int y = SQUARE(5); // 展开为 ((5) * (5))
int m = MAX(10, 20); // 展开为 ((10) > (20) ? (10) : (20))
注意:这里有一个超级大坑——括号!
如果你写成:
#define SQUARE(x) x * x // 危险!没有括号
当你写 SQUARE(2 + 3) 时,它会展开成 2 + 3 * 2 + 3,由于乘法优先级高,实际计算的是 2 + 6 + 3 = 11,而不是预期的 25。
正确做法:给每个参数加括号,给整个表达式也加括号:#define SQUARE(x) ((x) * (x))。这是写宏的铁律。
3. 宏 vs 函数:什么时候用宏?
宏的优点:
- 没有函数调用的开销(不涉及栈帧、参数复制),执行快。
- 没有类型限制,同一个宏可以用于
int、double等。
宏的缺点:
- 没有类型检查。
- 多次使用会展开多次代码,导致可执行文件变大(代码膨胀)。
- 难以调试(调试器只能看到展开后的代码)。
- 参数有副作用时非常危险:
int x = 5;
int y = SQUARE(x++);
// 展开为 ((x++) * (x++)),未定义行为!x 被递增了两次
经验法则:简单的小型操作(如取最大最小值、简单的数学计算)可以考虑用宏;较复杂的逻辑优先用函数。如果函数式宏里参数会被多次使用,务必在文档中警告。
三、条件编译:让代码“随机应变”
条件编译让预处理器根据条件决定哪些代码被保留,哪些被丢弃。这是编写跨平台代码、调试开关、功能裁剪的利器。
1. #ifdef / #ifndef / #endif
#ifdef DEBUG
printf("调试信息:当前值 = %d\n", value);
#endif
如果 DEBUG 之前被 #define 过,这行 printf 会被保留;否则会在预处理阶段直接被删除,完全不存在于最终的可执行文件里。
#ifndef 是“如果未定义”。我们一直在头文件防护里用它:
#ifndef MY_HEADER_H
#define MY_HEADER_H
// 头文件内容
#endif
2. #if / #elif / #else
#if 可以判断常量表达式(只认整型常量):
#define VERSION 2
#if VERSION == 1
printf("版本 1 的功能\n");
#elif VERSION == 2
printf("版本 2 的新功能\n");
#else
printf("未知版本\n");
#endif
还可以配合 defined 操作符:
#if defined(DEBUG) && VERSION > 1
// ...
#endif
defined(DEBUG) 等价于 #ifdef DEBUG,但可以与其他条件组合。
3. 典型的应用场景
跨平台代码:
#ifdef _WIN32
#include <windows.h>
#define CLEAR_SCREEN "cls"
#elif defined(__linux__) || defined(__APPLE__)
#include <unistd.h>
#define CLEAR_SCREEN "clear"
#endif
int main(void) {
system(CLEAR_SCREEN);
// ...
}
调试开关:
#ifndef NDEBUG
#define LOG(msg) printf("[DEBUG] %s:%d: %s\n", __FILE__, __LINE__, msg)
#else
#define LOG(msg) ((void)0) // 发布版本中 LOG 变成空操作
#endif
((void)0) 是一个什么也不做的表达式,编译器会把它优化掉,完全零开销。这样就不需要在发布版本里删除调试语句。
四、# 和 ## 操作符:字符串化和拼接
这两个操作符是预处理阶段的功能,用于高级宏定义。
1. #:字符串化(Stringizing)
把宏参数转换成字符串字面量(在参数两边加双引号)。
#define TO_STRING(x) #x
printf("%s\n", TO_STRING(hello)); // 输出 "hello"
printf("%s\n", TO_STRING(3.14)); // 输出 "3.14"
printf("%s\n", TO_STRING(a + b)); // 输出 "a + b"
注意:如果参数里本身有空格,# 会保留。它会自动转义参数中的双引号和反斜杠。
2. ##:标记粘贴(Token Pasting)
把两个标记(token)粘成一个新的标记。
#define CONCAT(a, b) a ## b
int xy = 10;
printf("%d\n", CONCAT(x, y)); // 展开为 xy,输出 10
int value_1 = 100, value_2 = 200;
printf("%d\n", CONCAT(value_, 2)); // 展开为 value_2,输出 200
这常用于自动生成变量名或函数名。比如在状态机里:
#define STATE(name) state_##name
enum { STATE(start), STATE(running), STATE(stopped) };
// 展开为: enum { state_start, state_running, state_stopped };
# 和 ## 在复杂的宏定义中非常有用,但可读性会下降。只在确实能简化代码时使用,不要让宏变成“魔法咒语”。
五、预定义宏:编译器自带的“身份证”
C 标准规定了一些预定义宏,所有编译器的预处理阶段都自动定义(不需要你 #define),它们提供当前编译的源文件信息。常用的有:
| 宏 | 含义 | 示例值 |
|---|---|---|
__FILE__ | 当前源文件名(字符串) | "main.c" |
__LINE__ | 当前行号(整数) | 42 |
__DATE__ | 编译日期(“Mmm dd yyyy”) | "Jan 15 2025" |
__TIME__ | 编译时间(“hh:mm:ss”) | "14:30:00" |
__STDC__ | 如果编译器符合 ANSI C 标准,定义为 1 | 1 |
__STDC_VERSION__ | C 标准版本号 | 201112L(C11) |
这些宏在调试和日志中非常有用:
#include <stdio.h>
int main(void) {
printf("文件: %s\n", __FILE__);
printf("行号: %d\n", __LINE__);
printf("编译日期: %s\n", __DATE__);
printf("编译时间: %s\n", __TIME__);
if (__STDC_VERSION__ >= 201112L) {
printf("当前使用 C11 或更高版本\n");
}
return 0;
}
结合条件编译,你可以写出兼容多版本 C 标准的代码。
六、常见错误与陷阱
1. 宏定义末尾写分号
#define MAX 100;
// 在代码中: if (x > MAX) → if (x > 100;) 语法错误
2. 函数式宏不包裹括号(这个坑必须刻进脑子里)
#define MULTIPLY(a, b) a * b
// MULTIPLY(2+3, 4+5) → 2+3*4+5 = 19,不是 45
3. 宏参数有副作用
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int x = 5, y = 10;
int z = MAX(++x, y); // 展开: ((++x) > (y) ? (++x) : (y)),x 被递增两次
4. 宏定义中的类型不安全
#define SQUARE(x) ((x) * (x))
SQUARE(3.14); // 可以,double 没问题
SQUARE("hello"); // 编译错误,但错误信息指向展开后的代码,难以定位
5. 条件编译里用运行时变量
int version = 2;
#if version == 2 // 错误!#if 只能处理编译时常量
#if 发生在预处理阶段,此时没有任何变量存在。version 会被当成未定义的宏,替换为 0。
七、小结
预处理器是 C 语言给你的“编译前文本处理工具箱”:
#define:定义宏,做文本替换。函数式宏高效但需要加括号防副作用。- 条件编译:
#ifdef、#if、#else等让代码可以在不同条件下裁剪,是跨平台和调试开关的基础。 #和##:字符串化和标记粘贴,用于高级宏技巧。- 预定义宏:
__FILE__、__LINE__、__DATE__等提供编译期信息,是日志和断言的得力助手。
宏是“双刃剑”——它在极简的表象下藏着陷阱。写宏时心里默念:括号!括号!还是括号! 能用 const 或函数替代时,优先不用宏。
下一篇,我们将把这些知识融会贯通,学习如何编写可移植的头文件与模块——处理平台差异的条件编译、防止重复包含的最佳实践、以及把一个中等规模项目组织得井井有条的方法。
课后小练习
- 编写一个带参宏
CUBE(x),计算x的立方。测试CUBE(2+3)是否输出 125,如果不是,修正你的宏。 - 用条件编译实现一个程序:如果定义了
ENGLISH,输出"Hello";如果定义了FRENCH,输出"Bonjour";如果什么都没定义,输出"你好"。通过修改#define来切换语言。 - 用
__FILE__和__LINE__实现一个调试宏PRINT_HERE,调用它时打印当前文件名和行号。然后再写一个LOG(fmt, ...)宏(使用可变参数宏...和__VA_ARGS__),输出带文件名、行号的格式化日志。 - (小挑战)分析下面这段宏有什么问题,并给出正确的写法:
#define SWAP(a, b) { int temp = a; a = b; b = temp; } // 在 if-else 中使用: if (x > y) SWAP(x, y); else printf("ok\n");
我们下期见!
💡获取本系列示例代码请访问 GitCode 仓库。
1万+

被折叠的 条评论
为什么被折叠?



