Skip to content

预处理深入:宏展开、头文件膨胀与条件编译

引言:预处理是「不懂语法的文本替换机」

上一章我们已经见过预处理这一站:一个 8 行的 hello.c,预处理后 .i 膨胀到几百行(stdio.h 被整个塞了进来),宏 GREET 被替换成了 "hello from C"。这一章我们要一头扎进这一站,因为它看起来最简单、实际上埋的雷最多

为什么?因为预处理器根本不懂 C 语法。它是一台纯粹的「文本替换机」——你说把 GREET 换成 "hello from C",它就机械地换,完全不管你换出来的东西在 C 语法里成不成立。C 标准(§6.10.3)对宏展开的描述就是「参数替换后再重新扫描(rescan)」,全程是文本层面的,没有任何类型检查、没有任何语法判断。这种「无脑替换」正是预处理坑多的根源:运算符优先级会反咬你、条件编译会悄悄走错分支、宏里多一个分号会炸出莫名其妙的编译错误。

本章的姿态和前两章一样:每一条断言,我们都用 gcc -E 让它在预处理这一站就停下来、把替换后的文本摆出来看,不靠想象。

核心概念:预处理就干三件事

预处理本质上只做三件文本层面的活:

  1. 宏展开:把 #define 的宏,按名替换成它的定义体(§6.10.3)。
  2. 文件包含:把 #include 指向的头文件内容,原样插入到 #include 所在位置(§6.10.2)。
  3. 条件编译:根据 #ifdef/#if 的真伪,决定哪一段文本进入后续编译、哪一段被丢弃(§6.10.1)。

注意这三件事都没有任何 C 语义参与——宏替换不看类型、头文件插入不看作用域、条件编译不看运行时值(只看预处理期常量表达式)。记住这条,后面所有坑都是它的直接推论。

带参宏的参数只是文本:运算符优先级会反咬你

这是预处理最经典、也最坑新手的一类问题。我们定义一个「平方」宏,故意写得不严谨:

c
#include <stdio.h>
#define SQ_BAD(x) x* x
#define SQ_GOOD(x) ((x) * (x))
int main(void) {
    printf("SQ_BAD(2+3)  = %d\n", SQ_BAD(2 + 3));
    printf("SQ_GOOD(2+3) = %d\n", SQ_GOOD(2 + 3));
    return 0;
}

直觉上 SQ(2+3) 应该是 5*5=25,对吧?我们跑一下:

text
$ gcc -std=c11 macro.c -o macro && ./macro
SQ_BAD(2+3)  = 11
SQ_GOOD(2+3) = 25

SQ_BAD(2+3) 给出了 11,不是 25。为什么?因为预处理器把 x 原样替换成 2+3,它不会替你加括号。我们用 -E 把替换后的文本抓出来看:

text
$ gcc -std=c11 -E macro.c | grep -n 'printf' | tail -2
562:    printf("SQ_BAD(2+3)  = %d\n", 2+3 * 2+3);
563:    printf("SQ_GOOD(2+3) = %d\n", ((2+3) * (2+3)));

看第 562 行:SQ_BAD(2+3) 展开成了 2+3 * 2+3乘法 * 的优先级比加法 +,所以编译期实际算的是 2 + (3*2) + 3 = 2 + 6 + 3 = 11。而 SQ_GOOD 因为定义体里给参数和整体都加了括号,展开成 ((2+3) * (2+3)),结果才是 25。

所以写带参数的宏有一条几乎铁打的规矩:定义体里每个参数都要加括号,整体也要加括号。不然只要调用者传进来一个带运算符的表达式(a+1i++n<<2),优先级就会反咬你;而且这种 bug 在运行时只表现为「结果不对」,不崩、不报警告,极难发现。SQ_GOOD 的写法 ((x)*(x)) 才是对的。

条件编译悄悄走了 #else:看 .c 看不出来

条件编译让你能用 #ifdef 控制哪段代码进入编译。听起来很方便,但它有个隐蔽的坑:最终哪一支代码生效,取决于宏有没有被定义,而这个「有没有定义」往往不在 .c 文件里、而在编译命令行的 -D 开关里。你盯着 .c 看,根本看不出当前走的是哪一支。

c
#include <stdio.h>
int main(void) {
#ifdef FIRST_OPTION
    printf("FIRST is ON\n");
#else
    printf("FIRST is OFF (default)\n");
#endif
    return 0;
}

同一份 .c,编译命令不同,行为就不同:

text
$ gcc -std=c11 cond.c -o cond_off && ./cond_off
FIRST is OFF (default)
$ gcc -std=c11 -DFIRST_OPTION cond.c -o cond_on && ./cond_on
FIRST is ON

-DFIRST_OPTION 这个命令行开关在预处理期定义了宏 FIRST_OPTION,于是 #ifdef 为真、走上支。这个开关只存在于编译命令里,源码文件本身没有任何痕迹。 万一你 CI 里漏写了 -D、或者拼错了名字,程序就会静默走 #else 分支——能编、能跑、就是行为不对。排查这种问题的唯一办法就是 -E,看哪一支真的存活了:

text
$ gcc -std=c11 -DFIRST_OPTION -E cond.c | grep -n 'printf' | tail -1
561:    printf("FIRST is ON\n");

加了 -DFIRST_OPTION 后,.i 里只剩 printf("FIRST is ON\n");——#else 那一支在预处理期就被物理删除了,根本不会进编译器。这就是条件编译的本质:它在预处理期就决定了代码的形状,之后再也看不到被删掉的那一支。

#include<>"":搜索路径不一样

#include 有两种写法:#include <stdio.h>(尖括号)和 #include "myheader.h"(双引号)。它们的区别在于预处理去哪里找这个头文件。C 标准(§6.10.2)规定:双引号形式按「实现定义的方式」查找(通常会先看当前目录),尖括号形式在「一系列实现定义的位置」里查找——说白了,具体的搜索路径是编译器/平台决定的,但「双引号先找当前目录、尖括号只找系统目录」是 gcc 遵循的惯例

我们让 gcc 把真实的搜索路径吐出来:

text
$ echo | gcc -std=c11 -E -Wp,-v -xc - 2>&1 | sed -n '/search starts here/,/End of search/p'
#include "..." search starts here:
#include <...> search starts here:
 /usr/lib/gcc/x86_64-pc-linux-gnu/16.1.1/include
 /usr/local/include
 /usr/include
End of search list.

读一下这份真实输出:#include <...> 会在 /usr/lib/gcc/.../include(编译器自带头,如 stdint.h)、/usr/local/include(手动装的库)、/usr/include(系统库,如 stdio.h)这三个地方找。而 #include "..."先在当前源文件所在目录找,找不到再退回走上面那三个系统路径(所以 "..." 的列表是「当前目录 + 上面三个」,gcc 这里只单独列了系统的部分)。

这里有个约定俗成的规矩:自己写的项目头文件用 ""(这样能从当前目录找到),系统/第三方库头文件用 <>。你要是把系统头用 "" 包含,多数情况也能找到(因为会退回系统路径),但会多一次当前目录的无谓查找;反过来,要是把自己写的头用 <>,当前目录不会被搜索,直接报 file not found。一句话:<> = 系统库,"" = 自己的头

宏末尾多一个分号:配 if-else 就编译报错

最后一个坑,专门坑那些「习惯每行结尾都加分号」的人。假设你想定义一个「初始化」宏,顺手在定义末尾加了分号:

c
#include <stdio.h>
#define INIT x = 0;
int main(void) {
    int x = 5;
    if (x > 0)
        INIT;
    else
        x = 1;
    return x;
}

注意 main 里我们习惯性地写了 INIT;(宏调用后面也加了分号)。编译一下:

text
$ gcc -std=c11 semi_bad.c -o semi_bad
semi_bad.c: In function 'main':
semi_bad.c:7:5: error: 'else' without a previous 'if'
    7 |     else
      |     ^~~~

'else' without a previous 'if'——else 找不到对应的 if。但我们的源码明明 ifelse 是配对的啊?问题就在宏展开。用 -E 看:

text
$ gcc -std=c11 -E semi_bad.c | grep -nA3 'if (x'
562:    if (x > 0)
563-        x = 0;;
564-    else
565-        x = 1;

看第 563 行:INIT; 展开成了 x = 0;;——两个分号。第一个 ; 来自宏定义体里的 x = 0;,第二个 ; 是我们调用时多写的那个。第一个分号结束了 if 的语句体,第二个分号变成了一个空语句,于是后面的 else 就找不到可以配对的 if 了——编译器报错。

解药有两个:要么宏定义体里不加分号(#define INIT x = 0,把分号留给调用处写),要么调用时不加分号(INIT 而非 INIT;)。两条规矩二选一,但绝不能宏里有、调用处也有。社区惯例是前者:宏定义体末尾不加分号,让它用起来像一个表达式而非一条语句。

小结

预处理这站走完,所有坑其实都源于同一条——预处理是纯文本替换、不懂 C 语法(§6.10.3 宏展开、§6.10.2 文件包含、§6.10.1 条件编译)。顺着这条推:带参宏的每个参数和整体都得加括号,否则 SQ_BAD(2+3) 就会展开成 2+3 * 2+3 = 11 而不是 25,正确写法是 ((x)*(x));条件编译哪一支生效是由命令行的 -D 开关决定的、不在 .c 里,排查得靠 -E 看哪一支存活;#include<> 只找系统路径、用 "" 会先找当前目录再退回系统路径(§6.10.2,具体路径实现定义),所以自己的头用 ""、系统的用 <>;还有宏定义体末尾千万别加 ;,加了配 if-else 会多出一个空语句,报 'else' without a previous 'if'

排预处理的坑,万能钥匙只有一把:gcc -E——让它停在预处理这一站,把替换后的真实文本摆出来,一切妖魔鬼怪都现形。下一章我们走向第二站,看 C 代码是怎么变成汇编的。

参考资源

  • ISO/IEC 9899 §6.10 预处理(§6.10.1 条件包含、§6.10.2 源文件包含、§6.10.3 宏展开与重扫描)
  • GCC 手册:-E-D-Wp,-v-H(打印包含树)
  • 第 3 章:编译四阶段全景(.i 是预处理产物的全景视角)
  • 第 9 章:警告旗标进阶(-Wpedantic 如何抓非标扩展)