C预处理器和C标准库
掌握宏展开、包含与条件编译、字符串化与粘贴、可变参数宏、_Generic、inline、assert和标准库契约
学习目标
- 能追踪函数式宏的记号替换与重扫,判断字符串化、粘贴和可变参数宏中的参数展开次数
- 能用条件编译、包含守卫和 _Generic 分别完成配置选择、单翻译单元防重与类型分派
- 能比较宏与 static inline,并正确使用 assert、math.h 与标准库函数的域和错误协议
机制总览
C预处理器和C标准库:机制路径
- 1
编译前还有一道「改写」工序
你写的 .c 文件进入语法与类型分析前,会先经过 预处理器(preprocessor) 。它不是任意字符级“搜索替换”:源文件先经历字符映射、行拼接、注释替换和预处理记号划分,宏参数替换后还会按规则重扫。
- 2
预处理器在流水线中的位置
完整路径仍是:源码 → 预处理 → 编译 → 汇编 → 链接。本章聚焦第一道工位。
- 3
define:常量与宏
本节把「define:常量与宏」放回《C预处理器和C标准库》的输入、状态变化与输出路径中理解。
章级决策实验
C预处理器和C标准库:机制与证据
切换《C预处理器和C标准库》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。
选择推理阶段
当前阶段 · 编译前还有一道「改写」工序
你写的 .c 文件进入语法与类型分析前,会先经过 预处理器(preprocessor) 。它不是任意字符级“搜索替换”:源文件先经历字符映射、行拼接、注释替换和预处理记号划分,宏参数替换后还会按规则重扫。
可核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「编译前还有一道「改写」工序」的实际行为。
学完《C预处理器和C标准库》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。
失效—证据矩阵
C预处理器和C标准库:失效与核验
编译前还有一道「改写」工序
典型失效
若只记语法而忽略「编译前还有一道「改写」工序」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。
核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「编译前还有一道「改写」工序」的实际行为。
预处理器在流水线中的位置
典型失效
若只记语法而忽略「预处理器在流水线中的位置」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。
核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「预处理器在流水线中的位置」的实际行为。
define:常量与宏
典型失效
若只记语法而忽略「define:常量与宏」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。
核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「define:常量与宏」的实际行为。
编译前还有一道「改写」工序
你写的 .c 文件进入语法与类型分析前,会先经过 ↡执行预处理指令、宏替换和条件包含的翻译阶段;主要处理预处理记号,不做C类型检查。。它不是任意字符级“搜索替换”:源文件先经历字符映射、行拼接、注释替换和预处理记号划分,宏参数替换后还会按规则重扫。
想象出版社排版:作者交稿(源码)后,编辑先统一替换术语、插入标准版权页(#include)、按「精装版/平装版」删掉不同章节(条件编译),排完才交给印刷机(编译器)。若没有预处理器,每个 .c 都要手写一遍 stdio.h 里的声明,常量 PI 也要到处改数字。
这一章掌握宏、头文件包含与条件编译——它们决定编译器最终看到什么文本。
预处理器在流水线中的位置
完整路径仍是:源码 → 预处理 → 编译 → 汇编 → 链接。本章聚焦第一道工位。
第一步:预处理
执行 include、条件包含和宏替换等翻译阶段。常见工具链可输出 .i 便于审查,但扩展名不是 C 标准接口。
可用 gcc -E main.c 只看预处理结果(常配合 -o main.i 保存)。
#define:常量与宏
↡由#define定义的对象式或函数式替换规则;操作单位是预处理记号,参数替换、字符串化、粘贴与重扫遵守各自规则。 分两类:
对象式宏(无参数)——常用来起名:
#define PI 3.14159265358979323846
#define LIMIT 100
#define NL '\n'函数式宏(带参数)——形似函数调用:
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))要点:
- 宏名习惯全大写(与变量区分),但语言不强制
#define行末尾通常不加分号;替换列表还会经历参数替换与重扫,并非一次原样粘贴- 在
#define SQUARE(x)定义中,宏名与左括号之间不能有空白,否则定义成对象式宏;在调用SQUARE (value)时,空白不妨碍函数式宏识别
取消定义用 #undef NAME。
#include:把头文件「粘贴」进来
↡#include 指令在预处理阶段把指定头文件的内容插入当前位置,相当于复制粘贴。 两种形式:
#include <stdio.h> /* 由实现定义的位置序列搜索 */
#include "myheader.h" /* 先按实现定义方式搜索,失败后按尖括号形式处理 */↡由include纳入翻译单元的源文件,通常发布函数声明、类型、宏和extern对象声明;搜索位置与文件命名由实现和构建系统约定。 里常见函数原型、extern 对象声明、宏与类型定义。可移植代码不要把“双引号必先当前目录”当成语言保证,应按编译器和构建系统配置 include 路径。
同一头文件被多个 .c include 时,声明会重复粘贴。用 ↡#ifndef GUARD / #define GUARD / ... / #endif 防止头文件被重复包含导致重复定义。:
#ifndef MYPROJECT_UTILS_H
#define MYPROJECT_UTILS_H
/* 声明与宏 ... */
#endifC 还可用 #pragma once(非标准但广泛支持)达到类似效果;可移植项目仍推荐 #ifndef 守卫。
条件编译:按「开关」裁剪代码
↡根据宏是否定义,在预处理阶段保留或丢弃 #if/#ifdef/#ifndef ... #endif 之间的代码块。 常见用途:
- 调试版 vs 发布版(
DEBUG宏) - 跨平台差异(使用构建系统或实现文档明确提供的宏)
- 功能开关
#define DEBUG 1
#ifdef DEBUG
printf("进入函数 foo, x=%d\n", x);
#else
/* 发布版:无输出 */
#endif相关指令:
| 指令 | 含义 |
|---|---|
#ifdef NAME | 若 NAME 已 #define 则保留 |
#ifndef NAME | 若 NAME 未定义则保留 |
#else | 与 #if/#ifdef 配对的分支 |
#elif | 否则若… |
#endif | 结束条件块 |
编译时加 -DDEBUG 等价于在文件开头写 #define DEBUG,-U DEBUG 取消定义。
带参宏的经典陷阱
宏是文本替换,不是函数:没有参数类型、实参可能被求值多次、运算符优先级由展开后的文本决定。
安全习惯:
- replacement 整体加括号:
((x) * (x)) - 每个形参加括号:
((a) > (b) ? (a) : (b)) - 避免
MAX(a++, b++)这类副作用实参 - 复杂逻辑用
static inline函数(C99)或普通函数,让编译器做类型检查
C 标准库头文件可以在声明真实函数的同时提供同名函数式宏。函数式宏只有在名字后跟左括号记号时才展开,因此 &getchar 仍可取得函数地址;也可写 (getchar)() 或先 #undef getchar 抑制宏。不要假定某个实现一定把 getchar 定义成宏。
预定义宏与实用技巧
编译器预定义若干宏,便于调试与可移植代码:
printf("文件 %s 行 %d\n", __FILE__, __LINE__);
#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 199901L
/* C99 及以上 */
#endif标准预定义宏还包括 __DATE__、__TIME__、__STDC__;__STDC_VERSION__ 在 C11 实现中为 201112L。__DATE__ 和 __TIME__ 会让构建结果随时间变化,要求可复现构建时不应把它们写进产物标识。
字符串化 # 与连接 ##(进阶):
#define STR_RAW(x) #x
#define STR(x) STR_RAW(x)
#define CAT_RAW(a, b) a##b
#define CAT(a, b) CAT_RAW(a, b)# 把参数原始预处理记号拼成字符串字面量,## 把两侧记号拼成一个新记号;直接邻接它们的参数不会先按普通参数规则展开。两层宏让外层先展开参数,再交给内层字符串化或粘贴。粘贴结果必须形成有效预处理记号,不能用 ## 随意拼任意字符。
可变参数宏
C99 的 ↡函数式宏形参列表末尾使用省略号,并在替换列表通过__VA_ARGS__访问其余实参记号;每次调用应满足宏定义所需的参数形状。 适合把格式串与参数转发给日志函数:
#define TRACE(...) do { \
fprintf(stderr, "[trace] "); \
fprintf(stderr, __VA_ARGS__); \
fputc('\n', stderr); \
} while (0)
TRACE("count=%d", count);这个 C11 宏要求至少传入一个可作为 fprintf 格式串的实参。__VA_ARGS__ 仍是记号替换,不会检查格式与参数类型;编译器是否给出格式诊断取决于它能否追踪到 fprintf。不要依赖后来的 __VA_OPT__ 或厂商“吞逗号”扩展来处理空可变参数。
多行宏用 \ 续行:
#define SWAP(type, a, b) do { \
type _tmp = (a); \
(a) = (b); \
(b) = _tmp; \
} while (0)do { ... } while (0) 保证在 if 后当单语句使用时不踩语法坑。
_Generic 与内联函数:把类型分派和求值分开
C11 的 ↡按控制表达式经过左值、数组和函数转换后的类型,在编译期选择一个关联表达式;控制表达式本身不求值。 可以让一个宏名选择不同类型的真实函数:
static inline int max_int(int left, int right) {
return left > right ? left : right;
}
static inline double max_double(double left, double right) {
return left > right ? left : right;
}
#define MAX_VALUE(a, b) _Generic(((a) + (b)), \
int: max_int, \
double: max_double \
)((a), (b))((a) + (b)) 只用于确定共同类型,在 _Generic 控制位置不会求值;选中的函数调用才各求值一次 a 与 b。这个接口只支持关联表列出的类型,其他类型会诊断。函数实参之间的求值顺序仍未指定,因此不要传入相互依赖的副作用表达式。
↡inline函数说明符允许实现用函数体替换调用,但不保证实际内联;static inline定义在头文件时,每个翻译单元拥有内部链接版本。 有参数和返回类型检查,也不会像 MAX 宏那样重复求值实参。static inline 适合头文件中的短辅助函数;仅写具有外部链接的 inline 涉及外部定义规则,公共接口应采用项目统一模式而不是凭感觉删掉某个 .c 定义。
C 标准库:头文件、断言与数学函数
↡由标准头文件发布的函数、宏和类型集合;每个接口都有参数域、返回值、错误指示和可能的宏实现规则。 不只是“链接进来的函数文件”。使用接口前包含指定头文件,按声明传递匹配类型,并阅读返回值和错误协议。
assert:程序员不变量,不是用户输入校验
#include <assert.h>
#include <stddef.h>
double mean(const double *values, size_t count) {
double sum = 0.0;
size_t i;
assert(values != NULL);
assert(count > 0);
for (i = 0; i < count; ++i)
sum += values[i];
return sum / count;
}↡assert.h提供的诊断宏;表达式为零时输出实现提供的位置信息并调用abort,定义NDEBUG后宏不求值表达式。 用于违反即表示程序有 bug 的内部不变量。若在包含 <assert.h> 前定义 NDEBUG,assert 表达式完全不求值,所以其中不能放赋值、函数调用等必要副作用。文件损坏、用户输入错误和内存不足都必须走正常错误处理,不能只靠可被关闭的 assert。
math.h:先看定义域,再看错误协议
#include <math.h>
#include <stddef.h>
int square_root(double input, double *output) {
if (output == NULL || input < 0.0)
return 0;
*output = sqrt(input);
return 1;
}<math.h> 提供 sqrt、pow、sin、isfinite 等数学接口。示例先执行产品定义的实数域检查;若要诊断库的域、极点或范围错误,还要依据 math_errhandling 检查 errno 和/或浮点异常。某些 Unix 工具链需要在链接命令末尾加 -lm,这是工具链约定,不是 C 源语言规则。
代码示例:调试宏与头文件守卫
config.h:
#ifndef CONFIG_H
#define CONFIG_H
#ifdef DEBUG
#define LOG(msg) fprintf(stderr, "[dbg] %s\n", msg)
#else
#define LOG(msg) ((void)0)
#endif
#endifmain.c:
#include <stdio.h>
#include "config.h"
#define TWICE(x) ((x) + (x))
int main(void) {
LOG("程序启动");
int v = TWICE(3 + 1); /* ((3 + 1) + (3 + 1)) → 8 */
printf("v = %d\n", v);
return 0;
}发布编译:gcc -O2 main.c -o app;调试:gcc -DDEBUG -g main.c -o app。
容易踩的坑
小结
- 预处理器在编译前运行:宏展开、
#include粘贴、条件编译裁剪、删注释 #define按预处理记号执行参数替换与重扫;带参宏注意括号、副作用与优先级#include <>与""搜索路径不同;头文件需要包含守卫#ifdef/#ifndef/#elif/#else按宏是否定义选代码;-D/-U在命令行控制- 字符串化和记号粘贴有特殊参数展开顺序;可变参数宏仍不提供类型或格式安全
- _Generic 只做编译期类型选择且控制表达式不求值;static inline 提供真实函数类型与单次实参求值
- assert 可被 NDEBUG 完全关闭,只检查程序员不变量;标准库函数还要遵守定义域、返回值和错误协议
练习
问题 1(问答型) 预处理器与编译器分工有何不同?#define PI 3.14 在哪一阶段生效?
问题 2(改代码型) 下面宏在除法表达式中为何得到 12 而不是 5?写出带括号版本,并说明仍剩什么风险。
#define DOUBLE(x) x + x
int r = 20 / DOUBLE(2);问题 3(综合实现型) 写头文件 guard.h:包含守卫 + MAX(a,b) 宏 + 仅在 DEBUG 定义时启用的可变参数 TRACE(...) 宏。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 预处理器(preprocessor)
执行 include、条件包含和宏替换等翻译阶段,处理预处理记号而不做 C 类型检查。见 PreprocessorPipelineDiagram step 1。
- 宏(macro)
由 #define 建立的对象式或函数式记号替换规则;参数替换后还会重扫,字符串化与粘贴有特殊规则。见 MacroExpansionDiagram。
- 宏展开(macro expansion)
预处理器按规则替换宏名和参数、处理 # 与 ##、再重扫结果的过程;没有 C 类型检查,参数可能在结果中出现多次。
- #include
预处理阶段纳入指定源文件;尖括号位置序列与双引号的初始搜索方式由实现定义,构建系统负责配置路径。
- 头文件(header)
由 include 纳入翻译单元的接口源文件,通常放函数声明、类型、宏和 extern 对象声明。文件后缀不是语言语义。
- 包含守卫(include guard)
#ifndef / #define / #endif 防止头文件重复包含。见 ConditionalCompileDiagram。
- 条件编译(conditional compilation)
按宏是否定义保留或丢弃代码块;用于 DEBUG、平台差异。见 ConditionalCompileDiagram。
- #ifdef
若宏已定义则编译其后代码直到 #else/#endif。
- #ifndef
若宏未定义则编译其后代码;常用于 include guard。
- 带参宏(function-like macro)
形如 #define SQUARE(x) ((x)*(x));实参以文本代入形参。陷阱见 MacroPitfallDiagram。
- #undef
取消先前 #define 的宏名,使之在后续源码中未定义。
- __FILE__
预定义宏,展开为当前源文件名字符串字面量。
- __LINE__
预定义宏,展开为当前行号的整数字面量。
- 可变参数宏
函数式宏以省略号接收其余实参记号,并通过 VA_ARGS 放入替换列表。C11 不具备用 VA_OPT 处理空参数的标准机制。
- 泛型选择(_Generic)
C11 按控制表达式的类型在编译期选择关联表达式;控制表达式不求值,未列出的类型若无 default 会诊断。
- 内联函数(inline function)
inline 允许但不强制实现用函数体替换调用。头文件常用 static inline 获得每翻译单元内部链接定义与真实类型检查。
- C 标准库
标准头文件发布的函数、宏与类型集合。每个接口都有参数域、返回值、错误指示和可能的函数式宏规则。
- assert
assert.h 的诊断宏,条件为零时报告并调用 abort;包含头文件前定义 NDEBUG 后表达式不求值,只适合检查程序员不变量。