C 预处理器
预处理器(preprocessor)是 C 编译的第一阶段——在正式编译前,先把源码里的 # 开头的指令处理掉。它做的事情是纯文本替换:展开宏、包含文件、按条件删掉部分代码。理解预处理器,你才能看懂很多 C 代码里的"魔法",也能写出更灵活的程序。
一、预处理在编译流程中的位置
完整流程:预处理 → 编译 → 汇编 → 链接。预处理只做文本处理,不管语法对错。可以用 gcc -E hello.c 只跑预处理,看展开后的结果——你会看到 #include 把整个 stdio.h 几千行都贴了进来。
二、#include 文件包含
#include 把指定头文件的内容原封不动地粘贴到当前位置:
#include <stdio.h> // 系统头文件:用尖括号
#include <stdlib.h> // 在系统目录(如 /usr/include)查找
#include "myheader.h" // 用户头文件:用双引号
// 先在当前目录找,找不到再去系统目录
// #include 本质:把指定文件的内容"复制粘贴"到当前位置
// 头文件通常放:函数声明、struct/typedef 定义、宏定义、全局变量声明(extern)
// 防止重复包含(头文件保护):
#ifndef MY_HEADER_H // 如果没定义过这个宏
#define MY_HEADER_H // 就定义它
// ... 头文件内容 ...
#endif // 第二次 include 时,MY_HEADER_H 已定义,整个内容被跳过
// 更简洁的写法(主流编译器都支持):
#pragma once // 文件只被包含一次两个细节:
- 尖括号
<>:系统头文件(stdio.h、stdlib.h),在系统目录找。 - 双引号
"":用户头文件,先在当前目录找,找不到再去系统目录。 - 头文件保护(include guard):防止同一个头文件被重复包含(重复包含会导致重复定义错误)。用
#ifndef ... #define ... #endif或#pragma once。
三、#define 宏定义
#define 定义宏——给一段文本起个名字,编译时替换。分两种:
#include <stdio.h>
// 1. 对象式宏(常量):#define 名字 值
#define PI 3.14159
#define MAX_SIZE 100
#define VERSION "1.0.0"
#define AUTHOR "小明"
// 编译时所有 PI 都被替换成 3.14159
// 注意:没有类型检查,没有分号,纯文本替换!
// 2. 函数式宏(带参数)
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define ABS(x) ((x) < 0 ? -(x) : (x))
// 警告:宏的参数必须加括号!不然有坑
#define BAD_SQUARE(x) x * x
// BAD_SQUARE(3 + 1) 展开成 3 + 1 * 3 + 1 = 7,不是 16!
int main(void) {
printf("PI = %f\n", PI);
printf("SQUARE(5) = %d\n", SQUARE(5)); // 25
printf("SQUARE(3+1) = %d\n", SQUARE(3 + 1)); // 16(对了,因为加了括号)
printf("MAX(3, 7) = %d\n", MAX(3, 7)); // 7
printf("ABS(-5) = %d\n", ABS(-5)); // 5
printf("BAD_SQUARE(3+1) = %d\n", BAD_SQUARE(3 + 1)); // 7(错的!)
return 0;
}- 对象式宏(常量):
#define PI 3.14159。编译时所有 PI 替换成 3.14159。 - 函数式宏:
#define SQUARE(x) ((x)*(x))。带参数的文本替换。
宏最大的坑:参数必须加括号! 因为宏是纯文本替换,不带括号会出问题:BAD_SQUARE(3+1) 展开成 3+1*3+1 = 7(不是预期的 16),而 SQUARE(3+1) 展开成 ((3+1)*(3+1)) = 16。每个参数和整体都要加括号。
宏 vs const vs enum:
- 宏:无类型检查,纯替换,可能产生副作用(如
SQUARE(i++)让 i 加两次)。 - const 变量:有类型,有作用域,但 C 的 const 不是真正的编译期常量。
- enum:真正的编译期整数常量,可作数组长度,可被调试器识别。
四、条件编译
条件编译让你在同一段源码里编译出不同的版本——调试版/发布版、Windows/Linux、启用/禁用某功能:
#include <stdio.h>
#define DEBUG 1 // 0 关闭调试,1 开启
int main(void) {
int x = 42;
// 条件编译:根据宏的值决定编译哪段代码
#if DEBUG
printf("调试: x = %d\n", x); // DEBUG=1 时编译
#endif
// #ifdef:如果定义了某宏(不管值是什么)
#ifdef DEBUG
printf("DEBUG 已定义\n");
#endif
// #ifndef:如果没定义
#ifndef RELEASE
printf("不是 RELEASE 模式\n");
#endif
// #if-#elif-#else
#if DEBUG == 1
printf("详细日志\n");
#elif DEBUG == 2
printf("超详细日志\n");
#else
printf("无日志\n");
#endif
// 实际用途:
// 1. 跨平台代码:#ifdef _WIN32 / #elif __linux__
// 2. 调试开关:#if DEBUG printf(...)
// 3. 功能开关:#define ENABLE_FEATURE_X
return 0;
}主要指令:
#if 表达式/#elif/#else/#endif:根据常量表达式选择编译。#ifdef 名字:如果定义了某宏(不管值)。#ifndef 名字:如果没定义。#undef 名字:取消已定义的宏。
编译时也可以从命令行定义宏:gcc -DDEBUG hello.c 等于在文件开头写了 #define DEBUG 1。这是构建系统实现"Debug/Release 配置"的常用方式。
五、预定义宏
编译器内置了一些有用的宏,可以直接用:
#include <stdio.h>
int main(void) {
// 预定义宏(编译器内置,直接可用)
printf("文件: %s\n", __FILE__); // 源文件名
printf("行号: %d\n", __LINE__); // 当前行号
printf("日期: %s\n", __DATE__); // 编译日期
printf("时间: %s\n", __TIME__); // 编译时间
printf("标准: %ld\n", __STDC__); // 1 表示遵循 ANSI C
// C99 新增
printf("函数: %s\n", __func__); // 当前函数名
// 实际用途:
// 1. 日志宏:printf("[%s:%d] 错误", __FILE__, __LINE__);
// 2. 调试断言:assert 宏内部就是用 __FILE__/__LINE__
// 3. 编译时戳:版本信息
// 自定义日志宏(很常用)
// #define LOG(msg) printf("[%s:%d] %s\n", __FILE__, __LINE__, msg)
// LOG("程序启动");
return 0;
}最常用的是 __FILE__、__LINE__、__func__(C99),用来写日志和断言:
printf("[%s:%d] 错误", __FILE__, __LINE__);assert宏内部就用__FILE__/__LINE__报告位置。
六、高级宏技巧
宏能做的事比想象的多——多行宏、字符串化、拼接、可变参数:
#include <stdio.h>
// 多行宏用反斜杠续行
#define SWAP(type, a, b) do { \
type tmp = (a); \
(a) = (b); \
(b) = tmp; \
} while (0)
// 字符串化运算符 #
#define STR(x) #x
// STR(hello) 展开成 "hello"
// 拼接运算符 ##
#define CONCAT(a, b) a ## b
// CONCAT(foo, bar) 展开成 foobar
// 可变参数宏(C99)
#define LOG(fmt, ...) printf("[LOG] " fmt "\n", __VA_ARGS__)
// __VA_ARGS__ 代表 ... 的所有参数
// do { ... } while (0) 的作用:让宏像一条语句一样使用
// 能跟在 if 后面而不出错
int main(void) {
int x = 10, y = 20;
SWAP(int, x, y);
printf("x=%d, y=%d\n", x, y); // x=20, y=10
printf("%s\n", STR(hello world)); // "hello world"
printf("%s\n", STR(123)); // "123"
int foobar = 42;
printf("%d\n", CONCAT(foo, bar)); // 42
LOG("x = %d", x); // [LOG] x = 20
LOG("简单的消息"); // [LOG] 简单的消息
return 0;
}几个高级运算符:
#字符串化:#x把参数 x 变成字符串字面量。##拼接:a ## b把两个 token 拼成一个,如foo##bar→foobar。__VA_ARGS__:C99 可变参数宏,代表...的所有参数。do { ... } while (0):让多行宏能像单条语句一样用在 if 后,是 C 宏的标准写法。
七、#pragma 编译器指令
#pragma 是给编译器的特殊指令,高度依赖具体编译器(可移植性差):
#include <stdio.h>
// #pragma:编译器特定的指令(可移植性差)
#pragma once // 文件只包含一次(替代头文件保护)
// #pragma pack:控制结构体内存对齐
#pragma pack(push, 1) // 保存当前对齐,设为 1 字节对齐
struct Packed {
char c; // 1 字节
int i; // 紧跟在 c 后面,无填充
}; // sizeof = 5(不是 8)
#pragma pack(pop) // 恢复之前的对齐
// GCC/Clang 特有的 __attribute__
struct Packed2 {
char c;
int i;
} __attribute__((packed)); // 等价于 #pragma pack
// #pragma message:编译时打印消息
#pragma message("正在编译 " __FILE__)
// 常用 #pragma:
// #pragma once - 头文件只包含一次
// #pragma pack(n) - 设置对齐
// #pragma GCC diagnostic - 控制警告
// #pragma comment(lib, "x") - MSVC 链接库
int main(void) {
printf("Packed 大小: %zu\n", sizeof(struct Packed)); // 5
return 0;
}最常用的是 #pragma once(现代头文件保护)和 #pragma pack(控制内存对齐)。其他 pragma 用得少,需要时查编译器文档。
八、宏的副作用陷阱
函数式宏有一个经典陷阱——参数被多次求值:
#define MAX(a, b) ((a) > (b) ? (a) : (b))MAX(i++, j++)会让 i 或 j 其中一个自增两次(因为 a/b 被求值了多次)。
解决:要么用函数,要么写的时候确保参数没有副作用。这也是为什么 C++ 引入了 inline 函数和 constexpr——它们既有宏的性能,又有函数的类型安全。
九、宏 vs 函数:怎么选?
- 简单常量:用
#define或enum或const。 - 类型泛型(如 MAX 适用于 int/double/char):用宏(C99 起可用
_Generic)。 - 有副作用或需要类型安全:用函数(
static inline既有性能又安全)。 - 日志/调试:用宏(能用
__FILE__/__LINE__)。
小结
预处理器做纯文本替换。#include 粘贴文件,#define 定义宏(参数记得加括号),#ifdef 条件编译实现多版本。__FILE__/__LINE__ 用于日志。宏没有类型检查,小心副作用。下一篇看 C 的文件 I/O。
← 上一篇 C 动态内存
下一篇 C 文件 I/O →