你编译过的每个 C 文件,开头几乎都有一行 #include <stdio.h>,而这一行并不是 C 代码。它是给另一个程序,也就是预处理器的指令。预处理器先运行,改写你的源代码文本,再把结果交给编译器。
理解这种两阶段的分工,就能解释 C 的很多行为:为什么头文件是被粘贴进来而不是被导入,为什么一个写错的宏会在一行看起来完全正常的代码上报错,以及为什么同一个源文件在不同系统上会编译出不同的程序。
编译之前到底发生了什么
编译一个 .c 文件并不是一步完成的。大致分为四步:
- 预处理:执行每一行以
#开头的指令,生成一大段展开后的源代码文本(称为翻译单元)。 - 编译:把这段文本转换成汇编,再转换成目标文件。
- 汇编:生成机器码。
- 链接:把目标文件和库连接成可执行文件。
预处理器只处理文本。它不知道什么是变量、什么是类型,也不管你的花括号是否配对。它看到的是字符和记号,把其中一些替换成另一些,然后继续往下。宏的各种奇怪之处都源于这一个事实。
等编译器读到这个程序时,已经找不到任何 GREETING 了。预处理器早已把它替换成字面量 "Hello from a macro",就像你亲手输入的一样。
预处理指令一览
每条预处理指令都以 # 开头,# 必须是该行第一个非空白字符。指令不加分号,到行尾就结束,除非你用反斜杠续行。
| 指令 | 作用 |
|---|---|
#include | 粘贴另一个文件的内容 |
#define | 定义宏(一种文本替换) |
#undef | 删除宏定义 |
#ifdef, #ifndef | 只有在宏已定义(或未定义)时才保留后面的代码 |
#if, #elif, #else, #endif | 根据常量表达式决定是否保留代码 |
#error | 带着一条消息终止编译 |
#pragma | 编译器特有的指令,例如 #pragma once |
#line | 修改报告的行号(很少用) |
其中三个有各自的专题页面:宏会深入讲解 #define,条件编译讲解 #if 系列,头文件讲解如何用 #include 组织多文件程序。
#include:尖括号与双引号
#include 只做一件事:用指定文件的全部内容替换它自己所在的这一行。被包含的文件本身也会经过预处理,所以其中的 #include 行也会展开。
#include <stdio.h> /* search the system include directories */
#include "config.h" /* search this file's directory first */
区别在于查找顺序:
<尖括号>在编译器的标准 include 目录中查找,包括/usr/include、工具链自己的文件夹,以及你用-I添加的目录。用于库的头文件。"双引号"先在执行包含操作的文件所在目录中查找,找不到再回到和尖括号相同的路径列表。用于你自己写的头文件。
在大多数编译器上,两种写法对两类头文件都能用,但这个约定是有含义的:尖括号表示“这是别人的头文件”,双引号表示“这是我自己的”。把两者混用,项目就可能包含了一个过时的系统头文件,而不是自己的那一个。
因为包含就是文本粘贴,同一个头文件包含两次就会被粘贴两次,所以头文件需要包含保护(include guard)。这是头文件页面要解决的第一个问题。
#define:只是文本替换
#define NAME replacement 告诉预处理器:从这里开始,凡是出现记号 NAME 的地方,都换成 replacement。
注意这里没有发生的事情。MAX_USERS 没有类型,也不存放在任何地方,在调试器里也看不到它。它只是一条查找替换规则,预处理之后,程序里实实在在写着 printf("%s allows %d users\n", "Coddy", 100);。
这也意味着替换是盲目的。下面的代码能编译,结果却出人意料:
#define SIZE 5 + 1
int arr[SIZE]; /* fine: int arr[5 + 1]; */
int total = SIZE * 2; /* 5 + 1 * 2 == 7, not 12 */
解决办法是给所有内容都加上括号,这是宏页面的核心规则,那里还会讲带参数的宏。
没有替换文本的 #define 会把这个名字定义为“存在但为空”。作为替换它毫无用处,作为标志却必不可少:
#define DEBUG /* defined, expands to nothing */
之后代码就可以用 #ifdef 判断 DEBUG 是否存在。
查看预处理器的输出
建立直觉的最好办法,就是看看预处理器实际生成了什么。gcc -E 会在预处理之后停下并打印结果:
gcc -E hello.c
对于包含了 <stdio.h> 的文件,输出有 700 到 30,000 行,具体取决于你的系统,几乎全部是头文件自身的内容。只想看自己的那部分,就取末尾几行:
gcc -E hello.c | tail -20
拿下面这样的文件试试:
#define SQUARE(x) ((x) * (x))
#define LIMIT 10
int main(void) {
int n = SQUARE(LIMIT);
return n;
}
输出的末尾显示:
int main(void) {
int n = ((10) * (10));
return n;
}
每个宏都不见了,只剩下替换后的文本。宏出问题时,这条命令几秒钟就能告诉你原因,比瞎猜强得多。还有两个好用的搭档:gcc -dM -E - < /dev/null 列出编译器预定义的所有宏,gcc -E -P file.c 则去掉行标记带来的干扰。
为什么错误会指向错误的行
因为编译器看到的是展开后的文本,宏内部的错误会在宏被使用的地方报告,而不是在定义它的地方:
#define HALF(x) (x / 2
int main(void) {
int y = HALF(8); /* error reported here */
return 0;
}
缺少的括号在 #define 那一行,但编译器报错的是包含 HALF(8) 的那一行,提示信息往往是一个意外的记号,放在上下文里完全说不通。当一个错误看起来不可能出现时,用 gcc -E 展开文件,读一读真正的那一行。
现代编译器会帮忙:GCC 和 clang 会打印一条“in expansion of macro”提示,指回宏的定义。编译时加上 -Wall -Wextra,才能真正看到这些提示。
预定义宏
预处理器自己也提供一些宏。它们在诊断问题时非常有用:
__FILE__ 和 __LINE__ 会展开为当前文件名和行号,断言宏和日志宏就是靠它们报告出错位置的。(__func__ 略有不同:它是编译器提供的真正的标识符,而不是预处理器宏,但用法相同。)
编译器还会预定义平台相关的宏,例如 __linux__、_WIN32 和 __APPLE__。需要针对不同系统写不同代码时,就用 #ifdef 测试这些宏,这正是条件编译的主题。
要点总结
预处理器又小又笨,而这两点都是有意为之。它给了你三种能力:引入文件、替换文本、打开或关闭代码,却完全不做类型检查。
这种取舍就是为什么标准建议是优先使用语言本身的特性:能用 const int 或 enum 定义常量,就不要用 #define;能用真正的函数,就不要用函数式宏。而在预处理器确实是合适工具的地方,比如头文件、可移植性开关、编译期配置,它是无可替代的。
接下来,宏这一篇会认真讲解 #define:参数、括号规则,以及把文本替换进不是你写的代码时会遇到的陷阱。
常见问题
C 语言中的预处理器是什么?
在编译之前运行的一个文本处理步骤。它执行以 # 开头的行:用头文件的内容替换 #include,替换用 #define 定义的宏,并根据 #if/#ifdef 删除或保留代码。编译器看到的永远只是处理后的结果,而不是你的原始文件。
#include <stdio.h> 和 #include "myfile.h" 有什么区别?
尖括号在编译器的系统 include 目录中查找,标准库头文件就放在那里。双引号先在当前文件所在的目录中查找,找不到再回到系统路径。库的头文件用尖括号,自己写的头文件用双引号。
怎样查看预处理器生成的结果?
运行 gcc -E file.c,它会在预处理之后停下并打印展开后的源代码。对于包含了 <stdio.h> 的文件,输出有几千行,所以用管道处理一下:gcc -E file.c | tail -30 只显示你自己的代码,其中每个宏都已被替换。
#include 是 C 语句吗?
不是。预处理指令不属于 C 语言本身:它们有自己按行划分的语法,不加分号,而且在编译器解析程序之前就已经消失了。这就是为什么宏里的错误会在一行看起来没问题的代码上报出令人费解的错误。