很多 C 语言初学者把 #include 和 #define 当成"照抄就行的固定格式",直到有一天被宏的诡异行为坑到怀疑人生:明明写了个 SQUARE(x) 宏,算出来的结果却不对;加个调试输出,还得手动删代码。这篇文章带你走进编译流程的第一站——预处理,搞懂 # 开头的指令到底在做什么,常见陷阱是什么,以及怎么写出健壮、可调试的宏。

1. 编译的四个阶段:预处理是第一站

C 程序的编译不是一步到位的,大致分四步:预处理 → 编译 → 汇编 → 链接。预处理干的是纯粹的"文本手术":把 #include 的文件内容原样粘进来、把 #define 的宏展开成文本、处理条件编译、删除注释。注意,这一步不检查语法,它只是产生一个更大的、干净的 C 源文件。

用 gcc -E 可以只做预处理,把结果输出出来亲眼看看"魔法"的真面目:

gcc -E demo.c -o demo.i
wc -l demo.c demo.i
head -n 40 demo.i

你会看到 demo.i 比 demo.c 大得多——stdio.h 的内容被整个展开了。理解这一点,后面所有宏的坑就都好解释了:宏不是函数,它只是文本替换,替换发生在任何类型检查之前。

2. #include:把文件"粘"进来

#include 的语义就是"把这个文件的全部内容原封不动地插入到这里"。两种写法有区别:

包含路径可以用 -I 参数追加。有一个很常见的坑:头文件里定义了变量或函数实现,而它又被多个 .c 文件包含,链接时就会报"重复定义"错误——头文件里应该放声明,不要放定义,这个原则我们下一篇文章会详细展开。

3. #define:对象宏与函数宏

#define 有两种形态。对象宏就是一个常量替换,比如 #define PI 3.14159,用来给魔法数字起名字;函数宏带参数,比如 #define MAX(a, b) ((a) > (b) ? (a) : (b)),展开时参数会被文本替换进去。注意结尾不能有分号,分号要留给调用处。

#include <stdio.h>

#define PI 3.14159
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))

int main(void) {
    double r = 2.0;
    printf("面积 = %.2f\n", PI * r * r);

    int s = SQUARE(2 + 3);
    printf("SQUARE(2+3) = %d\n", s);   /* 25,不是 11 */

    int m = MAX(3 + 1, 5);
    printf("MAX = %d\n", m);           /* 5 */
    return 0;
}

注意 SQUARE 和 MAX 的写法:每个参数都加了括号、整体也加了括号,这是函数宏的保命法则。为什么?因为 SQUARE(2+3) 如果定义成 x * x,展开就是 2+3 * 2+3 = 11,和期望的 25 差了十万八千里——这就是文本替换不看"语义"的代价。

4. 条件编译:一份代码,多种形态

条件编译让同一份源码在编译时"长出"不同的样子,最常见的用途就是调试开关和跨平台适配。#ifdef 判断宏是否被定义,#if 判断宏的值,#ifndef 判断"未定义",配对的 #endif 收尾。

#include <stdio.h>

#ifndef DEBUG
#define DEBUG 0
#endif

int main(void) {
#ifdef DEBUG
    printf("[debug] 当前 DEBUG=%d\n", DEBUG);
#endif

#if DEBUG >= 2
    printf("[debug] 详细日志模式\n");
#endif

#if DEBUG >= 3
    printf("[debug] 更啰嗦的跟踪\n");
#endif

    printf("正常业务代码\n");
    return 0;
}

编译时用 -D 传值就能切换形态,完全不用改源码。用 gcc -DDEBUG=2 debug.c -o app && ./app 编译运行,会多出详细日志;不带 -D 时只有一行输出——同一份源码,两种形态。

条件编译最经典的工程应用是头文件卫士:

#ifndef MY_HEADER_H
#define MY_HEADER_H

/* 头文件内容只会在第一次被包含时真正展开 */

#endif /* MY_HEADER_H */

如果两个头文件互相包含,或者同一个头文件被重复 #include,没有卫士就会产生重复声明甚至死循环包含。头文件卫士是每个头文件必须有的第一行和最后一行,养成肌肉记忆。

5. 宏的陷阱:括号、副作用与分号

宏的坑主要集中在三个地方。第一个坑是缺括号,前面已经演示过;第二个坑是副作用被重复执行——函数宏的参数会"原样"出现在每个使用它的位置,如果传入的是 i++ 这种有副作用的表达式,它会被执行多次。把这两个坑放在一个程序里看:

#include <stdio.h>

/* 反面教材:没加括号 */
#define BAD_SQUARE(x) x * x
/* 正确写法:参数和整体都加括号 */
#define GOOD_SQUARE(x) ((x) * (x))
#define MAX2(a, b) ((a) > (b) ? (a) : (b))

int main(void) {
    printf("%d\n", BAD_SQUARE(2 + 3));   /* 2 + 3 * 2 + 3 = 11,错了 */
    printf("%d\n", GOOD_SQUARE(2 + 3));  /* 25,正确 */

    int i = 3, j = 5;
    int m = MAX2(i++, j++);
    /* 展开后:((i++) > (j++) ? (i++) : (j++))
       比较时 j++ 一次,分支里 j++ 又一次 */
    printf("m=%d i=%d j=%d\n", m, i, j);
    return 0;
}

第一行输出 11 而不是 25,因为展开后是 2 + 3 * 2 + 3,乘法优先级先执行;而 MAX2(i++, j++) 展开后比较和分支各用了一次 j++,所以 j 从 5 变成了 7——这就是"带副作用的参数绝不能传给函数宏"的原因。第三个坑是多语句宏的悬挂 else:

#include <stdio.h>

#define DO_IT(x) do { \
    printf("doing %d\n", (x)); \
    fflush(stdout); \
} while (0)

int main(void) {
    int k = 1;
    if (k > 0)
        DO_IT(k);
    else
        printf("不会执行\n");
    return 0;
}

多语句宏必须用 do { ... } while (0) 包起来,这样它整体是一个语句,放到 if 分支里不会和 else 打架;每行末尾用反斜杠 \ 续行,这是宏写多行的唯一方式。第三个陷阱是行尾分号:宏定义自己不带分号,调用处带,否则展开后会出现多余分号。

6. # 和 ##:字符串化与记号拼接

预处理还有两个高阶运算符。# 把参数变成字符串字面量,常用于生成日志或断言信息;## 把两个记号拼成一个新记号,常用于批量生成相似的名字。

#include <stdio.h>

#define STR(x) #x
#define CAT(a, b) a ## b

int main(void) {
    printf("%s\n", STR(hello world));   /* 打印:hello world */

    int xy = 100;
    printf("%d\n", CAT(x, y));          /* 展开成 xy,打印 100 */

    int version_major = 3;
    printf("%d\n", CAT(version_, major)); /* 展开成 version_major */
    return 0;
}

STR(hello world) 展开成字符串字面量 "hello world",CAT(x, y) 展开成标识符 xy——注意拼接后的东西必须是一个合法的记号(变量名、函数名),否则编译报错。这两个运算符是"用宏做代码生成"的基础,但可读性差、调试困难,工程上要克制使用。

7. 常用预定义宏

编译器自带一批预定义宏,不用声明直接用,对调试特别有用:

#include <stdio.h>

int main(void) {
    printf("所在文件: %s\n", __FILE__);
    printf("所在行号: %d\n", __LINE__);
    printf("编译日期: %s\n", __DATE__);
    printf("编译时间: %s\n", __TIME__);
#ifdef __STDC__
    printf("遵循标准 C\n");
#endif
    return 0;
}

__FILE__ 和 __LINE__ 在出错日志里极其常用——很多库的断言宏就是靠它们打印"错误发生在哪个文件的哪一行"。除此之外还有 __func__(当前函数名,C99 起)和 __cplusplus(C++ 编译器会定义它,常用它写"一份头文件同时兼容 C 和 C++"的 extern "C" 开关)。

8. 总结与练习

预处理本质是"编译前的文本替换",记住这个本质,所有陷阱都能推导出来:宏是文本不是函数,所以参数要加括号、调用不能传副作用表达式、多语句要包 do-while(0)、定义不带分号。需要类型检查、调试器单步、递归这些"函数特性"时,用 static inline 函数替代宏是现代 C 的推荐做法;宏则留给常量定义、条件编译、代码生成这些它真正擅长的场景。

练习建议:

💡 遇到诡异的宏 bug 时,第一反应应该是 gcc -E:把预处理结果打开,看看宏到底展开成了什么文本。九成以上的宏问题,一眼就能在展开结果里找到答案。