每当你写下 #include <stdio.h>,在 #define MAX 100 后面愉快地使用这个常量,或者用 #ifdef DEBUG 在调试和发布版本之间切换——你都在使用 C 语言的预处理器(preprocessor)。

预处理器是编译流水线中第一个登场的角色。在编译器看到你的源代码之前,预处理器已经默默做完了大量的文本替换工作。回忆一下第 24 讲的内容——.c 文件先经过预处理器变成 .i 文件,然后才交给编译器。如果你不确定宏展开的结果,随时可以用 gcc -E source.c -o source.i 停下来查看。

预处理器有一个核心特征,也是它所有陷阱的来源:它不懂 C 语法。它不懂类型系统,不知道什么是"声明"和"表达式",甚至不关心替换后的文本是否合法——它只做一件事:按照指令对源代码文本进行机械替换。正是这种"只认文本、不认语义"的特性,让预处理器既强大又危险。

这一讲我们会系统性地走完预处理的全部核心指令,并重点剖析那些"看起来对、跑起来错"的经典陷阱。学完之后你会得到一个非常重要的判断力:什么时候该用宏,什么时候不该用。

先看一眼这一讲的地图:

  1. 编译器免费送你的预定义符号;
  2. #define 定义常量(无参宏);
  3. #define 定义带参宏,以及括号陷阱;
  4. 宏参数的副作用——最隐蔽的坑;
  5. 宏替换的规则——预处理器到底怎么展开;
  6. 宏和函数的对比——什么时候用谁;
  7. # 字符串化 和 ## 记号粘合;
  8. #undef 与命令行定义;
  9. 条件编译:#if/#ifdef/#ifndef/#elif/#else/#endif;
  10. 头文件包含与重复包含防护;
  11. #error、#line、#pragma 及其他指令。

预定义符号

在开始学 #define 之前,先认识几个编译器免费送你的符号。C 语言标准规定了一组预定义符号,它们在任何源文件中都可以直接使用,不需要 #define 或 #include,在预处理阶段被自动替换为对应的值:

预定义符号含义示例值
__FILE__当前编译的源文件名(字符串)"main.c"
__LINE__当前行号(整数)42
__DATE__编译日期(字符串)"Aug 5 2026"
__TIME__编译时间(字符串)"14:30:00"
__STDC__编译器是否遵循 ANSI C(整数 1 或未定义)1

再补充几个实用的:

预定义符号含义说明
__STDC_VERSION__C 标准版本号(整数)C99 是 199901L,C11 是 201112L,C17 是 201710L
__cplusplus是否 C++ 编译C++ 编译时为版本号,纯 C 编译时未定义——常用于判断"这段代码是被 C 还是 C++ 编译的"
__func__当前函数名(字符串)注意它不是预处理符号,是 C99 引入的"预定义标识符",在函数内部可用

这些符号最常用的场景是日志和调试——在输出中带上文件名和行号,出问题时立刻知道该去哪里找:

#include <stdio.h>
 
// 定义一个带文件名和行号的日志宏
#define LOG(msg) printf("[%s:%d] %s\n", __FILE__, __LINE__, msg)
 
// 定义带时间戳的版本信息宏
#define SHOW_VERSION() \
    printf("Program built on %s at %s\n", __DATE__, __TIME__)
 
int main()
{
    // 使用预定义符号展示编译信息
    printf("Source file: %s\n", __FILE__);
    printf("Compiled on: %s at %s\n\n", __DATE__, __TIME__);
 
    // 使用日志宏——出问题时立刻定位
    LOG("程序开始运行");
    LOG("正在进行计算...");
    LOG("程序结束");
 
    return 0;
}

输出示例:

Source file: debug_demo.c
Compiled on: Aug  5 2026 at 14:30:00

[debug_demo.c:18] 程序开始运行
[debug_demo.c:19] 正在进行计算...
[debug_demo.c:20] 程序结束

注意 __LINE__ 是个整数(不是字符串),所以 printf 格式化用 %d。__FILE__、__DATE__、__TIME__ 都是字符串字面量,可以直接拼接。

__STDC_VERSION__ 的典型用途是"按标准版本写不同代码":

#include <stdio.h>
 
int main()
{
#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L
    printf("当前编译器支持 C11 或更高版本\n");
#else
    printf("当前编译器仅支持 C99 或更早版本\n");
#endif
    return 0;
}

注意这里用 #if 做整数比较——#if 只接受预处理期常量表达式,而 __STDC_VERSION__ 正是预处理期就被替换成整数的符号,所以可以比较。这是预定义符号参与条件编译的典型姿势。

#define 定义常量

#define 的基本语法非常简单:

#define 名称 替换文本

预处理器的行为就是:在后续的源代码中,每遇到"名称"这个地方,就用"替换文本"把它换掉。但 #define 能做的事远不止定义数字常量:

#define MAX 1000                    // 定义数值常量
#define REG register                // 为关键字起别名
#define DO_FOREVER for(;;)          // 用更形象的符号替换一种语法结构
#define CASE break;case             // 让 switch 语句自动带上 break

第三个 CASE break;case 是一个很有代表性的例子——预处理器不检查替换文本是否合法,哪怕替换后生成的是半句话法片段也没问题,只要最终文本合法就行。

如果替换文本太长,可以用反斜杠 \ 续行(最后一行不加):

#define DEBUG_PRINT printf("file:%s\tline:%d\t" \
                           "date:%s\ttime:%s\n", \
                           __FILE__, __LINE__,   \
                           __DATE__, __TIME__)

这里有个初学者必踩的坑:要不要在末尾加分号?

很多人在写 #define MAX 100; 时会在末尾顺手加一个分号——养成了写语句加 ; 的习惯。但这是个致命错误:

#define MAX 100;  // 错误!
 
if (condition)
    max = MAX;
else
    max = 0;

预处理器替换后变成:

if (condition)
    max = 100;;   // 两个分号——第二个是空语句
else             // 编译错误!因为 else 前面不是它期望的 if 块
    max = 0;

#define 是文本替换指令,不是 C 语句。分号在替换时会被原样插入,破坏上下文语法。结论:#define 定义常量时绝对不要在末尾加分号。

但这里有个微妙的点值得展开:为什么 #define MAX 100 后面跟着 int a = MAX; 就不会出问题?因为替换后是 int a = 100;,分号来自调用处的语句,而不是宏体。所以正确的心智模型是:宏体只包含"值"或"表达式",分号属于使用宏的那条语句。如果你在宏体里写分号,就等于在值后面多塞了一个分号。

补充一个反直觉的例外:如果宏替换文本以分号结尾是故意的(比如宏体本身就是多条语句),那又是另一回事了——那就是"语句型宏",后面讲 do { } while(0) 时会专门说到。

再对比一下 #define 和 const/enum 定义常量的选择。#define 定义的常量在预处理阶段就被替换成字面量,编译器看不到 MAX 这个名字——好处是没有存储空间、不占内存;坏处是调试器里看不到 MAX、没有类型检查、还可能污染命名空间。C 语言里更推荐:

const int max = 1000;        // const 常量:有类型、可调试
enum { MAX = 1000 };         // 枚举常量:编译期常量,适合数组大小、case 标签

什么时候仍要用 #define 定义常量? 当你需要"预处理期参与 #if 比较"的时候——const 变量在 #if 里不能用(#if 只能处理整数常量表达式,而 const int 对预处理器来说是未知符号)。所以开关类、版本号类的常量适合 #define,普通数值常量优先 const/enum。

#define 定义宏

宏不只是无参数替换。你可以定义带参数的宏:

#define 名称(参数列表) 替换文本

注意括号和名称之间不能有空格。如果写成 #define SQUARE (x) x*x,预处理器会把 (x) 当作替换文本的一部分,而不是参数列表。

来看一个简单的平方宏——从它开始,逐步揭示宏的所有陷阱:

#define SQUARE(x) x * x

你调用 SQUARE(5),预处理器用 5 替换 x,得到 5 * 5。一切正常——直到你调用 SQUARE(a + 1)。预处理器忠实地执行了"文本替换":用 a + 1 替换 x,得到 a + 1 * a + 1。由于乘法优先级高于加法,实际求值的是 a + (1 * a) + 1,当 a = 5 时结果是 11 而不是期望的 36。

解决方案:用括号把参数包起来。

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

现在 SQUARE(a + 1) 展开为 (a + 1) * (a + 1),结果正确。但故事还没完——如果把整个表达式放在一个更大的上下文中:

#define DOUBLE(x) (x) + (x)
int result = 10 * DOUBLE(5);  // 期望 100,实际 55

展开为 10 * (5) + (5),由于乘法优先于加法,结果是 55 而不是 100。要彻底安全,必须把整个表达式也包在括号里:

#define DOUBLE(x) ((x) + (x))  // 正确

用代码来验证三种写法的区别:

#include <stdio.h>
 
// 版本A:没有括号——最危险
#define SQUARE_A(x) x * x
 
// 版本B:只给参数加括号——还不够
#define SQUARE_B(x) (x) * (x)
 
// 版本C:参数和整体都加括号——安全
#define SQUARE_C(x) ((x) * (x))
 
int main()
{
    int a = 5;
 
    // 简单参数时三者都一样
    printf("SQUARE_A(5) = %d\n", SQUARE_A(5));   // 25 ✓
    printf("SQUARE_B(5) = %d\n", SQUARE_B(5));   // 25 ✓
    printf("SQUARE_C(5) = %d\n", SQUARE_C(5));   // 25 ✓
 
    printf("\n=== 参数为 a + 1 (a=5) ===\n");
    // 版本A:a + 1 * a + 1 = 5 + 1*5 + 1 = 11
    printf("SQUARE_A(a+1) = %d  (期望36)\n", SQUARE_A(a + 1));
    // 版本B:(a + 1) * (a + 1),括号对了,但如果用在更大表达式中仍有问题
    printf("SQUARE_B(a+1) = %d  (期望36)\n", SQUARE_B(a + 1));
    // 版本C:安全
    printf("SQUARE_C(a+1) = %d  (期望36)\n", SQUARE_C(a + 1));
 
    return 0;
}

输出:

SQUARE_A(5) = 25
SQUARE_B(5) = 25
SQUARE_C(5) = 25

=== 参数为 a + 1 (a=5) ===
SQUARE_A(a+1) = 11  (期望36)
SQUARE_B(a+1) = 36  (期望36)
SQUARE_C(a+1) = 36  (期望36)

宏安全三原则:(1)每个参数用括号包起来;(2)整个表达式用括号包起来;(3)如果宏包含多条语句,用 do { ... } while(0) 包装。

第三条的 do { ... } while(0) 是个重要的工程技巧,单独展开讲。假设你要写一个"交换两个变量"的宏:

// 危险写法
#define SWAP(a, b) { int tmp = (a); (a) = (b); (b) = tmp; }
 
// 用在 if 里就会出问题
if (x > y)
    SWAP(x, y);
else
    printf("x 不大于 y\n");

替换后:

if (x > y)
    { int tmp = (x); (x) = (y); (y) = tmp; };   // 注意这个分号!
else                                            // else 匹配不到 if 了!
    printf("x 不大于 y\n");

问题出在调用处那个分号:宏体是大括号块,调用处补的分号变成了空语句,else 就找不到配对的 if 了(else 前面的语句必须是 if 控制的语句,而这里是一个空语句)。经典的解决方案:

// 正确写法:do-while(0) 包装
#define SWAP(a, b) do { int tmp = (a); (a) = (b); (b) = tmp; } while (0)
 
// 使用:SWAP(x, y);  替换后:
// do { ... } while (0);

do { ... } while(0) 是一个"只执行一次的循环",它既能让宏体包含多条语句,又能像一个普通语句一样使用分号,还能和 if/else 正常配合——替换后是 do {...} while(0);,else 依然能正确匹配 if。这个技巧在真实代码库里几乎每条多语句宏都在用。

带有副作用的宏参数

括号加对了,你以为就安全了?还不够。这是宏参数最隐蔽的陷阱。看这个找最大值的宏:

#define MAX(a, b) ((a) > (b) ? (a) : (b))

括号正确,表达式也正确。但如果你这样用:

int x = 5, y = 8;
int z = MAX(x++, y++);
printf("x=%d y=%d z=%d\n", x, y, z);

预处理器展开后:

z = ((x++) > (y++) ? (x++) : (y++));

执行流程:

  • 先比较 x++(值为 5)和 y++(值为 8),比较后 x=6, y=9
  • 条件为假,执行 y++(值为 9),y 变成 10
  • z 赋值为 9

最终结果:x=6, y=10, z=9——y 被自增了两次。这就是"宏参数副作用"——因为宏参数在替换文本中出现多次,导致传入的带有副作用的表达式被多次求值。

对比一下函数版本:

#include <stdio.h>
 
#define MAX(a, b) ((a) > (b) ? (a) : (b))
 
int max_func(int a, int b)
{
    return a > b ? a : b;
}
 
int main()
{
    int x1 = 5, y1 = 8;
    int x2 = 5, y2 = 8;
 
    int z_macro = MAX(x1++, y1++);
    printf("宏版本:  x=%d y=%d z=%d\n", x1, y1, z_macro);
    // 输出:x=6 y=10 z=9 —— y 被自增了两次!
 
    int z_func = max_func(x2++, y2++);
    printf("函数版本:x=%d y=%d z=%d\n", x2, y2, z_func);
    // 输出:x=6 y=9 z=8 —— 每个参数只被求值一次(z 是 max(5, 8) = 8)
 
    printf("\n结论:向宏传递带副作用的参数是危险操作。\n");
    return 0;
}

函数版本的 max_func 在调用时,参数 x2++ 和 y2++ 各被求值一次后把结果传进去(分别是 5 和 8),函数内部使用 5 和 8,返回 8。而宏版本把 x1++ 和 y1++ 直接展开到比较表达式和返回值中,导致 y1 被自增两次。解决方案很明确:永远不要给宏传递带副作用的参数(如 x++、++x、函数调用等)。

什么时候会有副作用?一切"在求值时改变了程序状态"的表达式:自增自减(x++)、赋值(x = 5)、函数调用(f() 可能修改全局变量)、输入输出(scanf(...))。判断一个表达式有没有副作用的标准很简单:如果它被求值两次,程序行为会不会变?会变,就是有副作用。

宏替换的规则

预处理器展开宏时遵循一个系统性的流程:

  1. 在调用宏时,首先检查参数中是否包含由 #define 定义的符号——如果是,先替换它们。
  2. 替换文本被插入到程序中原来宏调用的位置。参数名被替换为实际参数值。
  3. 最后,再次扫描结果文本,看看还有没有被 #define 定义的符号需要替换——如果有,重复上述过程。

两个关键约束:

  • 宏不能递归。 如果替换文本中又出现了宏名自身,第二次扫描不会再替换它。
  • 字符串常量中的内容不会被扫描替换。 printf("MAX") 中的 MAX 始终是字符串的一部分,不会被当作宏展开。

把"宏不能递归"讲透:假设 #define A B 和 #define B A,那么使用 A 时会发生什么?展开 A → B,再展开 B → A,此时扫描到 A——但预处理器会标记"这个 A 是从 B 展开来的,而 B 又来自 A 本身",为防止无限循环,它不再展开这个 A,保留字面 A。最终得到 A。这种行为是 C 标准规定的:在展开过程中,一个记号若是在展开自身的过程中再次出现,就不再展开。

参数展开的规则其实还有一个容易忽略的细节:如果宏参数出现在替换文本中"普通位置",参数中的宏会先被完全展开,再代入;但如果参数前面有 # 或 ##,参数中的宏不会先展开。这是 C 标准里最容易考倒人的细节,举例说明:

#define STR(x) #x
#define X 100
 
STR(X)   // 参数 X 前有 #,不先展开 → 结果是 "X",不是 "100"

而:

#define F(x) x + 1
#define X 100
 
F(X)     // 参数 X 在普通位置 → 先展开 X 成 100 → 100 + 1

记住这个不对称规则,遇到 #/## 与嵌套宏组合的诡异输出时,就能解释清楚了。

宏和函数的对比

宏和函数都能封装可复用的逻辑,但各自的适用场景完全不同。直接看对比:

维度宏函数
执行开销无函数调用开销(代码直接展开)有调用/返回开销(压栈跳转)
代码体积每次使用都会展开一份副本,增加体积只存一份,体积小
类型检查无类型检查,参数类型无关编译器严格检查参数类型
调试不可调试(宏没有"入口"和"出口")可单步调试
运算符优先级容易出现括号问题无此问题
参数副作用多次求值仅求值一次
参数类型可以传类型名(比如 int)只能传值

什么时候用宏? 当操作极度简单(一行表达式)、性能关键、或者需要参数是类型名而不是值的时候。

宏可以传类型名,这是函数永远做不到的:

#define MALLOC(num, type) (type*)malloc((num) * sizeof(type))
 
// 使用
int *p = MALLOC(10, int);  // 预处理器替换后:
// int *p = (int*)malloc((10) * sizeof(int));

函数完全无法做到"把 int 这个类型名作为参数传递"——因为函数的参数必须是值,而类型名是编译期概念。

但请注意,现代 C 对"函数式宏"的替代方案越来越成熟:

  • C99 的 inline 函数:既保留"无调用开销"(内联展开由编译器决定),又有类型检查和可调试性——替代大部分简单函数式宏;
  • C11 的 _Generic:根据表达式类型选择不同代码分支——替代需要类型参数的宏;
  • const 和 enum:替代 #define 数值常量。

宏真正不可替代的场景越来越窄:条件编译、头文件保护、#/## 运算符、需要预处理期值(#if 里用)的常量。在这些场景之外,优先用语言特性。

# 和

# 和 ## 是宏独有的两个运算符,它们做的事情是函数完全无法替代的。

# 运算符:字符串化

# 将宏参数转换为字符串字面量。它仅允许出现在带参数的宏的替换列表中:

#include <stdio.h>
 
// # 运算符将宏参数转换为字符串字面量
#define PRINT_VAR(var) printf("变量 " #var " 的值是 %d\n", var)
 
// 更通用的打印宏——打印变量名和值
#define PRINT(val) printf(#val " = %d\n", val)
 
int main()
{
    int age = 25;
    int score = 95;
 
    // PRINT_VAR(age) 展开:
    // printf("变量 " "age" " 的值是 %d\n", age)
    // 相邻字符串字面量自动拼接,等价于:
    // printf("变量 age 的值是 %d\n", age)
    PRINT_VAR(age);
    PRINT_VAR(score);
 
    printf("\n使用 PRINT 宏:\n");
    PRINT(age);          // 输出: age = 25
    PRINT(score + 5);    // 输出: score + 5 = 100
    PRINT(age * 2 + 10); // 输出: age * 2 + 10 = 60
 
    return 0;
}

输出:

变量 age 的值是 25
变量 score 的值是 95

使用 PRINT 宏:
age = 25
score + 5 = 100
age * 2 + 10 = 60

#var 把参数名 var 转换成了字符串字面量 "var"。注意它转换的是传入的文本本身,而不是参数的值。后面 PRINT(score + 5) 输出的变量名是 "score + 5" 而不是 "100"——这正是 # 的行为特性。利用 C 语言中"相邻字符串字面量自动拼接"的特性("hello " "world" 等价于 "hello world"),我们可以很自然地生成带变量名的调试输出。

再补充 # 的一个细节:字符串化时,参数中的空白会被压缩成一个空格,且 \ 和 " 会被自动转义。比如 PRINT( a )(参数带多余空格)会得到 " a "——实际是 "a"(两侧空白被去掉了,中间空白压成一个)。这些细节平时用不到,但当你看到 # 输出和想象不一致时,知道是这个规则在起作用。

## 运算符:记号粘合

## 把位于它两边的符号合成一个新符号——它允许宏定义从分离的文本片段创建标识符。粘合的结果必须是一个合法的标识符,否则行为未定义。

这种技术在缺乏模板机制的 C 语言中非常实用,可以大量减少重复代码:

#include <stdio.h>
 
// ## 把左右两边的符号粘合成一个新符号
// 这个宏用于自动生成不同类型的"求最大值"函数
#define GENERIC_MAX(type)                    \
type type##_max(type x, type y)              \
{                                            \
    return (x > y ? x : y);                  \
}
 
// 使用宏生成 int_max 和 float_max 两个函数
// GENERIC_MAX(int) 展开后:
//   int int_max(int x, int y) { return (x > y ? x : y); }
GENERIC_MAX(int)
 
// GENERIC_MAX(float) 展开后:
//   float float_max(float x, float y) { return (x > y ? x : y); }
GENERIC_MAX(float)
 
int main()
{
    int a = 10, b = 20;
    printf("int_max(%d, %d) = %d\n", a, b, int_max(a, b));
 
    float f1 = 3.5f, f2 = 4.5f;
    printf("float_max(%.1f, %.1f) = %.1f\n", f1, f2, float_max(f1, f2));
 
    return 0;
}

输出:

int_max(10, 20) = 20
float_max(3.5, 4.5) = 4.5

## 的威力在于它可以根据类型名生成不同的函数名——type 是 int 时生成 int_max,是 float 时生成 float_max。这种技术在需要为多种类型实现相同逻辑时非常高效。

注意 ## 的两个使用要点:

  1. 粘合发生在所有替换之后——type##_max 先把 type 展开成实际类型名,再和 _max 拼起来。如果 ## 两侧参数需要先展开,要注意"参数前有 ## 时不先展开参数"的规则——这正是上面讲过的规则,所以在 ## 场景下通常需要双层宏来强制展开(进阶话题,初学者知道有这回事即可)。
  2. ## 的结果必须是一个合法记号——比如 3.14##e5 能粘成 3.14e5(合法浮点字面量),但 3##abc 会生成非法记号,行为未定义。编译器通常会直接报错提示。

命名约定

宏和函数的使用语法非常相似,语言本身没法帮你区分二者——所以行业里约定俗成一个规则:宏名全部大写,函数名不要全部大写。看到 MAX、SQUARE、DEBUG_PRINT 你就知道它们是宏;看到 max_value、print_log 你就知道它们是函数。这虽然只是约定而不是语法强制,但遵守它能让代码的可读性大幅提升,避免"这到底是宏还是函数"的困惑。唯一的例外是少数刻意模拟函数用法的宏会采用小写命名,但初学者请严格遵守"宏名全大写"的惯例。

另外一个相关的实践是命名空间隔离:宏没有作用域概念——一旦定义,就从这个位置往后全部生效,直到 #undef 或文件结束。它不认"块作用域",不认"函数作用域"。所以宏名容易和变量名、函数名冲突。给自己项目的宏统一加前缀(如 MYAPP_、PLAT_)是个好习惯,避免不小心覆盖标准库或其他模块的符号。

#undef 和命令行定义

#undef 用来移除之前定义的宏:

#define MAX 100
// ... 使用 MAX
 
#undef MAX
// 从这之后,MAX 不再是一个宏
#define MAX 200  // 可以重新定义,但必须先用 #undef 移除旧定义

如果不用 #undef 而直接重定义同一个宏,大多数编译器会给出警告(因为新定义和旧定义不同)。

#undef 的典型使用场景:

  1. 局部临时宏:在一个源文件中临时定义一个辅助宏,用完立刻 #undef,避免影响后面代码。这是大型项目的常用纪律。
  2. 控制头文件行为:很多头文件支持"在 include 之前定义某个宏来开启/关闭特性",#undef 可以在包含后重置这些开关。
  3. 重新定义:想改一个宏的值,先 #undef 再重新 #define。

另一个很有用的特性是命令行定义——在编译时通过 -D 选项从外部注入宏定义,不需要修改源代码:

// configurable.c —— 不定义 ARRAY_SIZE,等待编译时传入
#include <stdio.h>
 
int main()
{
    int array[ARRAY_SIZE];  // ARRAY_SIZE 必须在编译时通过 -D 选项指定
 
    for (int i = 0; i < ARRAY_SIZE; i++) {
        array[i] = i;
    }
 
    printf("数组大小: %d\n", ARRAY_SIZE);
    printf("数组内容: ");
    for (int i = 0; i < ARRAY_SIZE; i++) {
        printf("%d ", array[i]);
    }
    printf("\n");
 
    return 0;
}

编译运行:

# 编译时通过 -D 定义 ARRAY_SIZE=10
gcc -D ARRAY_SIZE=10 configurable.c -o demo10
./demo10
 
# 换一个大小重新编译
gcc -D ARRAY_SIZE=20 configurable.c -o demo20
./demo20

输出(demo10):数组大小 10,内容 0-9;输出(demo20):数组大小 20,内容 0-19。不需要修改一行源代码,通过编译选项就能生成不同配置的程序——这在需要为不同设备内存限制编译不同版本时非常实用。

命令行定义最常见的用途其实有两个:

  1. 开关:gcc -DDEBUG 等于在文件最前面写了 #define DEBUG——配合 #ifdef DEBUG 一键切换调试/发布版本(VS 的 Release 和 Debug 配置正是这么干的,只不过 VS 是在项目属性里设置,本质上就是 -D)。
  2. 配置参数:像上面的 ARRAY_SIZE 一样,编译期注入可变参数。

-D 定义的值默认是 1:-DDEBUG 等价于 #define DEBUG 1。#ifdef DEBUG 只检查"是否定义",所以 -DDEBUG 就能开启它;但如果你用的是 #if DEBUG 这种值判断,就要注意了——此时 -DDEBUG 给的是 1,可以;而如果你手写 #define DEBUG(不带值),DEBUG 会被替换成空,#if DEBUG 直接语法错误。用 #ifdef 配无值定义、用 #if 配有值定义,这是两种配套的惯用法。

条件编译

在编译一个程序的时候,如果能把一条语句(或一组语句)选择性地编译或丢弃,那就太方便了。比如:调试代码删掉可惜,留着又碍事——条件编译就是为这个场景设计的。

#include <stdio.h>
 
// 定义 DEBUG 宏来开启调试模式
// 注释掉下面这行就是发布版本
#define DEBUG
 
int main()
{
    int arr[5] = {10, 20, 30, 40, 50};
    int sum = 0;
 
    for (int i = 0; i < 5; i++) {
        arr[i] = arr[i] * 2;
        sum += arr[i];
 
        // 条件编译:只在 DEBUG 模式下输出数组赋值过程
        #ifdef DEBUG
        printf("[DEBUG] arr[%d] 被赋值为 %d, 当前累加和 = %d\n",
               i, arr[i], sum);
        #endif
    }
 
    printf("\n最终结果:sum = %d\n", sum);
 
    // #if 支持常量表达式
    #if defined(DEBUG) && defined(__STDC__)
    printf("调试模式,编译器遵循 ANSI C 标准\n");
    #endif
 
    return 0;
}

定义 DEBUG 时,输出包含详细的调试信息;注释掉 #define DEBUG 后,#ifdef DEBUG 和 #endif 之间的代码被完全剔除——不是运行时跳过,而是编译时就没了。这也意味着:调试代码不会影响最终程序的性能和体积。

常用的条件编译指令:

#if 常量表达式         // 如果表达式为非零值,编译后续代码
#ifdef 符号            // 如果符号已定义,编译后续代码
#ifndef 符号           // 如果符号未定义,编译后续代码
#elif 常量表达式       // 否则如果...
#else                 // 否则
#endif                // 条件编译块结束
 
// 等价形式
#if defined(符号)      // 等价于 #ifdef 符号
#if !defined(符号)     // 等价于 #ifndef 符号

几个使用要点:

  1. #if 后只能跟"预处理期常量表达式":可以包含整数常量、#define 定义的宏、defined 运算符;但不能使用 sizeof(它在编译期才求值)、const 变量、函数调用。#if sizeof(int) == 4 是错的(除非编译器扩展支持),要判断 int 大小应该用 #if INT_MAX > 32767 之类(<limits.h> 提供的宏,预处理器可计算)。
  2. #ifdef/#ifndef 只看"是否定义",不看值。#define DEBUG 0 后 #ifdef DEBUG 依然成立——DEBUG 被定义了,只是值是 0。如果想"定义且值为真",要写 #if DEBUG。
  3. defined 运算符:#if defined(A) && !defined(B) 这种组合只有 #if 写法支持(#ifdef 无法表达"并且"关系)。
  4. 条件编译必须配对:每个 #if/#ifdef/#ifndef 都要有对应的 #endif。漏写 #endif 是常见编译错误,报错信息往往指向文件末尾,不太好找。

条件编译可以嵌套,这在跨平台项目中非常常见:

#if defined(OS_UNIX)
    #ifdef OPTION1
        unix_version_option1();
    #endif
    #ifdef OPTION2
        unix_version_option2();
    #endif
#elif defined(OS_MSDOS)
    #ifdef OPTION2
        msdos_version_option2();
    #endif
#endif

这种嵌套结构让同一份源码可以针对不同的操作系统和编译选项进行精细的定制。

条件编译的三大典型应用场景:

场景一:调试开关。 像上面 DEBUG 的例子——开发时开,发布时关。注意这个"开/关"如果用手改源码很容易忘,工程上通常用 -D 命令行注入或 IDE 配置项控制。

场景二:跨平台适配。 同一份源码在不同平台编译时选择不同实现:

#ifdef _WIN32
    #include <windows.h>
    void delay_ms(int ms) { Sleep(ms); }
#else
    #include <unistd.h>
    void delay_ms(int ms) { usleep(ms * 1000); }
#endif

_WIN32、__linux__、__APPLE__ 这些是平台相关的预定义宏(编译器在对应平台上自动定义的),程序里靠它们区分平台。这也是 #ifdef 最常见的实际应用。

场景三:头文件保护符。 防止头文件被重复包含(见下一节)。

头文件的包含

#include 是预处理器中最常用的指令之一。它有两种写法:

#include "my_utils.h"   // 本地文件:先在源文件目录查找,再去标准路径
#include <stdio.h>      // 库文件:直接去标准路径查找

"" 的查找策略是"先本地后标准"——先在当前源文件所在的目录下查找,找不到再去编译器配置的标准头文件路径(Linux 下通常是 /usr/include,Windows 下 VS 安装目录的 VC/include)查找。<> 直接去标准路径查找。

虽然 "" 也可以用于库文件(因为本地找不到它也会去标准路径找),但不推荐这样做——(1)效率稍低;(2)无法从语法上区分本地文件和库文件,降低代码可读性。记住简单的规则:自己写的头文件用 "",系统的头文件用 <>。

#include 的行为本质是文本插入:把被包含文件的全部内容,原封不动地插入到 #include 这一行所在的位置。有几个由此推出的推论值得记住:

  1. 头文件里写 #include,同样会被递归展开——所以 A 头文件包含 B 头文件、B 又包含 C,最终全部内容都会合并进这个编译单元(即最终的 .i 文件)。
  2. #include 可以包含任何文件,不一定是 .h——只要内容是 C 代码就能插进来。有的项目用 .inc 后缀放数据,也有人用 #include "config.ini" 这种骚操作(不推荐,但能编译)。
  3. 因为包含就是复制粘贴,头文件里不要写定义(函数体、全局变量定义),否则被多个 .c 包含时就会产生重复定义链接错误——这和上一讲"声明 vs 定义"的教训是同一件事的两种说法。
  4. 头文件包含的顺序会影响编译结果吗?会。如果 A 头文件需要 size_t 类型,但没包含 <stddef.h>,那么只有"先包含 <stddef.h> 再包含 A"的文件才能编译通过。这就是"头文件应该自包含(self-contained)"原则的由来——每个头文件都应该包含它自己依赖的所有头文件,不依赖包含顺序。

头文件保护符

头文件被重复包含是一个常见的工程问题。如果 test.h 被多个 .c 文件包含,或者由于间接包含导致了重复包含,头文件的内容就会在一个编译单元中被处理多次——不仅增加编译时间,还可能导致重复定义错误。

解决方案是头文件保护符(include guard):

// my_utils.h —— 正确的头文件写法
#ifndef MY_UTILS_H         // 如果没有定义过 MY_UTILS_H
#define MY_UTILS_H         // 就定义它
 
// 头文件的实际内容
typedef struct {
    int id;
    char name[32];
} Record;
 
int init_record(Record *r, int id, const char *name);
void print_record(const Record *r);
 
#endif  // MY_UTILS_H —— 条件编译结束

第一次包含 my_utils.h 时,MY_UTILS_H 尚未定义,所以 #ifndef 的条件成立,头文件内容被处理,MY_UTILS_H 被定义。第二次及以后再次包含时,MY_UTILS_H 已经定义了,#ifndef 条件不成立,头文件内容被跳过。

另一种更简洁的写法:

#pragma once  // 效果等同于三行保护符,但不是 C 标准的一部分

两种方式的对比:

方式优点缺点
#ifndef/#define/#endifC 标准,所有编译器支持写三行,符号名要全局唯一
#pragma once简洁一行非标准(但几乎所有主流编译器都支持)

保护符的名字必须在整个项目中唯一。命名约定通常是用项目名_文件名_H的格式,比如 MYPROJ_MY_UTILS_H。或者直接使用 #pragma once 避免命名冲突问题。也可以两者都写,双保险。

一个真实工程里的细节:为什么 #pragma once 更高效?因为 #ifndef 方案下,预处理器虽然跳过了头文件内容,但仍要打开文件、读到 #ifndef 行才能判断;而 #pragma once 让编译器记住文件路径,第二次包含时根本不再打开文件。编译大项目时这个差异能省不少 I/O 时间。这也是很多现代工程(包括 VS 自动生成的代码)默认用 #pragma once 的原因。

#error、#line 和 #pragma

前面讲了预处理器最主要的指令,还有三个指令虽然不那么常用,但各有各的用途。

#error:编译期报错

#error 消息 让预处理器输出一条错误消息并停止编译。它的经典用法是在条件编译中做"兜底检查"——如果代码被编译进了一个不支持的配置,直接编译失败,而不是带着错误的配置继续编译、等到运行时才爆炸:

// platform_check.h
#if defined(_WIN32)
    #define PLATFORM "Windows"
#elif defined(__linux__)
    #define PLATFORM "Linux"
#elif defined(__APPLE__)
    #define PLATFORM "macOS"
#else
    #error "不支持此平台,请检查编译器定义"
#endif

如果这段代码被一个不认识的平台编译,编译会立刻停下,输出类似 error: #error "不支持此平台..."——把错误暴露在编译期,这是 #error 的价值。

另一个常见用途是版本检查:

#if !defined(__STDC_VERSION__) || __STDC_VERSION__ < 201112L
    #error "需要 C11 或更高版本的支持"
#endif

#line:篡改行号和文件名

#line 行号 或 #line 行号 "文件名" 让编译器认为后续代码来自指定的行号和文件。它主要用于代码生成工具——比如某个工具生成了 C 代码,#line 可以让编译器报错时指向原始源文件(而不是生成的文件),方便调试。手工写代码基本用不到,知道存在即可:

#line 100 "original.xyz"
int x = ;   // 编译错误会报 original.xyz 的第 100 行

#pragma:编译器特定指令

#pragma 是 C 标准留给编译器的"扩展接口"——标准规定"编译器可以对它不认识的其他指令忽略或警告,但 #pragma 本身是合法的"。不同编译器支持不同的 #pragma 指令,这就让 #pragma 成了厂家扩展的汇集地。最常用的是这几个:

(1)#pragma once:前面讲过,防重复包含,几乎所有主流编译器支持。

(2)#pragma pack(n):控制结构体内存对齐。 这是初学者最容易接触到的 #pragma。默认情况下,结构体成员会按"对齐要求"自动填充字节:

#include <stdio.h>
 
// 默认对齐:int 要求 4 字节对齐
typedef struct {
    char  c;    // 1 字节
    int   i;    // 由于对齐,会跳过 3 个填充字节,从偏移 4 开始
    char  d;    // 1 字节
} DefaultStruct;   // 实际大小 12 字节(含末尾填充)
 
// pack(1):禁止填充,成员紧挨着排
#pragma pack(1)
typedef struct {
    char  c;    // 偏移 0
    int   i;    // 偏移 1
    char  d;    // 偏移 5
} PackedStruct;    // 实际大小 6 字节
#pragma pack()   // 恢复默认对齐
 
int main()
{
    printf("DefaultStruct 大小: %zu 字节\n", sizeof(DefaultStruct));
    printf("PackedStruct  大小: %zu 字节\n", sizeof(PackedStruct));
    return 0;
}

输出(64 位编译器):

DefaultStruct 大小: 12 字节
PackedStruct  大小: 6 字节

为什么要关心这个?因为上一讲文件操作里提到:用 fwrite 写结构体到二进制文件时,填充字节也会被写进去。如果结构体按默认对齐,不同编译器、不同平台写出的文件字节数不同——用 #pragma pack(1) 可以消除填充,让结构体布局确定。代价是访问未对齐成员可能略慢(现代 x86 影响很小)。注意 #pragma pack() 的恢复写法:pack 影响的是从这行往后的结构体,用完一定要恢复默认,否则整个文件的结构体都被改了。

(3)#pragma message:输出编译提示信息。 类似 #error 但不停止编译,用于提示开发者:

#ifdef DEBUG
    #pragma message("注意:当前以 DEBUG 模式编译")
#endif

VS 里还可以用 #pragma message 配合字符串化宏打印宏值,用于排查宏定义问题。

(4)#pragma warning(disable: 4996)(VS 特有):关闭某个警告。比如 VS 对 fopen 报 C4996(建议用 fopen_s),可以用它禁用:

#pragma warning(disable: 4996)   // 关闭 fopen 等函数的安全警告

注意"关警告"要谨慎——警告是编译器在帮你找 bug,关掉之前要想清楚。常见的处理顺序是:先看警告内容,能改代码就改代码(比如用 fopen_s 或加 _CRT_SECURE_NO_WARNINGS 宏),只有确认无害才用 #pragma warning 关。

(5)#pragma comment(lib, "xxx.lib")(VS 特有):在源码里声明要链接哪个库,省去在工程配置里添加的步骤。

由于 #pragma 是厂家相关的,跨平台代码里经常用条件编译包裹它,保证不认识的编译器也能忽略:

#if defined(_MSC_VER)      // 只在 MSVC(VS 的编译器)下生效
    #pragma warning(disable: 4996)
#endif

预处理器指令全家桶

把这一讲所有指令汇总成一张表,方便你复习时对号入座:

指令作用例子
#define定义宏/常量#define MAX 100
#undef删除宏#undef MAX
#include插入文件内容#include <stdio.h>
#if条件编译(常量表达式)#if defined(A) && B > 2
#ifdef若已定义则编译#ifdef DEBUG
#ifndef若未定义则编译#ifndef MY_H
#elif否则如果#elif X == 2
#else否则#else
#endif结束条件编译#endif
#error编译期报错并停止#error "版本过低"
#line修改行号/文件名#line 100 "a.c"
#pragma编译器特定指令#pragma once / #pragma pack(1)
#(宏内)参数字符串化#define STR(x) #x
##(宏内)记号粘合#define CAT(a,b) a##b

总结与建议

最后把这一讲反复出现的"工程判断"集中收个尾。

什么时候用宏? 总结成四类不可替代的场景:

  1. 条件编译:#ifdef/#if 是运行时代码做不到的"编译期裁剪";
  2. 头文件保护:#ifndef 或 #pragma once;
  3. 需要类型名作参数:#define MALLOC(n, type) 这类;
  4. # / ## 运算符:字符串化和记号粘合是宏独有的。

什么时候别用宏? 能用语言特性就绝不碰宏:inline 函数替代简单函数式宏(有类型检查、可调试)、const/enum 替代数值常量、_Generic 替代类型分派。宏的每个陷阱(括号、副作用、重复求值、命名污染、不可调试)都在提醒你:让编译器而不是预处理器来处理你的代码,你会少掉很多头发。

最后说一句务实的建议:写宏之前先问自己三个问题——"这个宏会被传带副作用的参数吗?""宏体会被用在复杂表达式的上下文里吗?""我能在调试器里看到它吗?"只要有一个答案是"会/不能",优先考虑函数或 inline 函数。剩下的场景里,把宏写成"全大写 + 每个参数括起来 + 整体括起来 + 多语句用 do-while(0)",你就能避开 99% 的宏陷阱。

思考题

  1. 为什么 #define MAX 100;(带分号)是错的?#define 替换的本质是什么?
  2. #define SQUARE(x) ((x)*(x)) 是正确的写法,但为什么 SQUARE(a++) 仍然危险?求 SQUARE(a++) 时 a 会被自增几次?
  3. # 和 ## 的区别是什么?#define STR(x) #x 中 STR(3.14) 的结果是什么?
  4. #ifdef DEBUG 和 #if DEBUG 有什么区别?#define DEBUG 0 时,这两个条件分别是真是假?
  5. 头文件保护符 #ifndef X_H / #define X_H / #endif 的工作原理是什么?#pragma once 和它相比有什么优缺点?
  6. 多语句宏为什么推荐用 do { ... } while(0) 包裹?如果不包裹,在 if/else 下会出现什么编译错误?
  7. #error 在什么场景下使用?为什么说它"把错误暴露在编译期"很重要?
  8. #pragma pack(1) 有什么作用?为什么使用它读写结构体二进制文件更安全?为什么用完要 #pragma pack() 恢复?

参考答案与详解

1. 为什么 #define MAX 100;(带分号)是错的?#define 替换的本质是什么?

#define 是纯文本替换指令,不是 C 语句。它把 MAX 替换成 "100;"(分号是宏体的一部分,会被原样塞进去),而不是"值和分号分开"。

#define MAX 100;              // 宏体 = "100;"
int a = MAX;
// 替换后:int a = 100;;    ← 两个分号,第二个是空语句
 
// 更糟的是放进 if/else:
if (cond)
    x = MAX;                 // → x = 100;;
else
    x = 0;                   // else 前多了空语句 → 编译错误

"替换的本质":预处理器是文本替换——它不关心 C 语法,只是"把宏名换成宏体"。#define 定义里不含分号,分号应该由"使用宏的那条语句"负责。正确写法是 #define MAX 100(结尾无分号),用的时候 x = MAX; 分号自然落在语句末尾。

2. #define SQUARE(x) ((x)*(x)) 是正确的写法,但为什么 SQUARE(a++) 仍然危险?求 SQUARE(a++) 时 a 会被自增几次?

SQUARE(a++) 展开为 ((a++) * (a++))——参数出现了两次,就被展开了两次。这是一个未定义行为(UB):对同一个变量 a 在同一表达式中被两次自增,结果取决于编译器。

后果会有两种:要么 a 被自增(有的实现先把两个 a++ 的值(都是旧值)相乘,再把 a 增两次 → a 自增 2 次,结果 (a)*(a));要么更糟,第二次自增发生之前 a 已经被第一个副作用改过。总之:带副作用的参数(a++、++a、f()、scanf(...))绝对不能传给同一个宏参数出现多次的宏——因为宏是"文本替换 + 多次替换",会重复求值,这正是函数和宏的关键差异(函数参数只求值一次)。

int a = 5;
int r = SQUARE(a++);   // 展开为 ((a++)*(a++)) → 未定义行为,a 到底增几次无法确定
// 正确做法:先缓存,再调
int tmp = a++;
int r2 = SQUARE(tmp);  // (tmp)*(tmp),确定

3. # 和 ## 的区别是什么?#define STR(x) #x 中 STR(3.14) 的结果是什么?

  • #(字符串化):把宏参数转换成字符串字面量。STR(3.14) 预处理后变成 "3.14"。
  • ##(记号粘合):把 ## 左右两边的记号粘合成一个新记号(通常是标识符),结果必须是一个合法记号,否则是未定义行为。
#define STR(x) #x
STR(3.14)      // → "3.14"(字符串字面量 "3.14")
STR(hello)     // → "hello"

区别一句话:# 把东西"变成字符串",## 把两样东西"拼成一个名字"。另外注意:当参数前面有 # 或 ## 时,该参数本身的宏不会先展开(这是预处理最容易考倒人的点)。

4. #ifdef DEBUG 和 #if DEBUG 有什么区别?#define DEBUG 0 时,这两个条件分别是真是假?

  • #ifdef DEBUG:只问**"DEBUG 这个宏是否被定义过",不看它的值。哪怕 DEBUG 定义成 0,只要定义了,#ifdef DEBUG 就是真**(成立)。
  • #if DEBUG:问**"DEBUG 在预处理期的值是否为非零"**。#if 0 为假,#if 0 的代码块被丢弃。

所以 #define DEBUG 0 时:

  • #ifdef DEBUG → 真(因为 DEBUG 已定义);
  • #if DEBUG → 假(因为值是 0)。

这就是两种惯用法的差异:想"判断是否定义"用 #ifdef/#ifndef;想"判断定义的值"用 #if 值(如 #if __STDC_VERSION__ >= 201112L)。

5. 头文件保护符 #ifndef X_H / #define X_H / #endif 的工作原理是什么?#pragma once 和它相比有什么优缺点?

工作原理:预处理器是"文本插入"。第一次 #include 时 X_H 未定义 → #ifndef X_H 成立 → 处理头文件内容 → 顺手 #define X_H。第二次再包含时 X_H 已定义 → #ifndef 不成立 → 跳过整段内容。这样头文件内容在同一个编译单元里只被处理一次,避免重复定义。

// myutils.h
#ifndef MYUTILS_H      // 第一次包含时未定义 → 进入
#define MYUTILS_H      // 定义标记,防止再次进入
// ... 头文件内容(类型、函数声明、inline 等)
#endif                  // 结束
// 等价的简写:非标准但被所有主流编译器支持
#pragma once

对比:

方式优点缺点
#ifndef/#define/#endifC 标准,可移植性最强要写三行;宏名需全工程唯一,撞名会失效
#pragma once一行搞定;不依赖名字唯一性;编译器记住文件路径、"第二次包含都不再打开文件",更快非 C 标准(但 GCC/Clang/MSVC 都支持)

6. 多语句宏为什么推荐用 do { ... } while(0) 包裹?如果不包裹,在 if/else 下会出现什么编译错误?

多语句宏如果用花括号 { ... },调用处往往会补一个分号,替换后就带出多余的空语句;当它出现在 if 的分支里时,else 会匹配错,出现编译错误。

#define SWAP(a, b) { int t=(a); (a)=(b); (b)=t; }
if (x > y)
    SWAP(x, y);        // 替换成 { ... };   ← 多出的分号=空语句
else                   // 错误!这个 else 找不到配对的 if(前一条是空语句)
    ...

用 do { ... } while(0) 包裹后:替换结果是 do { ... } while(0);——以分号正确结尾,表现像一条普通语句,if/else 能正常配对,宏内也能安全用临时变量、break/return 等。因此"多语句宏一律包成 do { ... } while(0)"是工程铁律。

7. #error 在什么场景下使用?为什么说它"把错误暴露在编译期"很重要?

#error 消息 让预处理器输出一条错误消息并立即停止编译,常用于两部分:

  1. 平台/配置兜底:条件编译里最后加 #else #error,当代码被编译进一个不认识/不支持的平台或配置时,直接拒绝编译:
#if defined(_WIN32)
    ...
#elif defined(__linux__)
    ...
#else
    #error "不支持的平台"
#endif
  1. 版本/环境校验:比如 #if !defined(__STDC_VERSION__) || __STDC_VERSION__ < 201112L #error "需要 C11" #endif。

为什么"提前暴露"重要:如果你不检查而让错误配置继续编译过去,程序很可能带着错误假设进入运行期,问题到运行时才爆发,极难定位,还可能跨平台偷偷长错。#error 把错误卡在编译期,立即失败、报错信息明确(直接写着原因),开发者一编译就知道配置不对——把本要留给运行期的 bug 提前扼杀掉。

8. #pragma pack(1) 有什么作用?为什么使用它读写结构体二进制文件更安全?为什么用完要 #pragma pack() 恢复?

  • 作用:#pragma pack(1) 让其后定义的结构体按 1 字节对齐(取消成员间的填充字节)。默认结构体会按最大成员对齐插入 padding(比如 char c; int i; 默认 i 从偏移 4 开始,整个结构体带末尾填充),pack(1) 后成员紧挨着排。
  • 为什么读写二进制文件更安全:上一讲讲过 fwrite 会把结构体连同对齐填充字节一起原样写入磁盘。不同编译器、不同平台、不同对齐设置下,同一个结构体布局不同、字节数不同——写出的二进制文件就"对不上号"。用 #pragma pack(1) 迫使结构体布局确定、紧凑、无填充,配合 sizeof 就能可靠地做跨平台/跨编译器的二进制序列化(例如网络协议报文、存档格式)。
  • 为什么用完要恢复:#pragma pack(n) 影响的是"从这行往后定义的所有结构体"。如果不加 #pragma pack() 恢复默认,后面所有结构体都会被强制紧凑对齐,可能引发未对齐访问的性能惩罚(某些架构甚至崩溃)、以及意外的 sizeof 变化。所以标准写法是"用完立刻 #pragma pack() 恢复默认"。
#pragma pack(1)                     // 开启 1 字节对齐
typedef struct { char c; int i; } Packed;   // sizeof == 5(无填充)
#pragma pack()                      // 恢复默认对齐
 
// 默认对齐时 sizeof(Packed) 通常是 8(int 占 4 对齐,含填充)