C预处理器和C标准库

掌握宏展开、包含与条件编译、字符串化与粘贴、可变参数宏、_Generic、inline、assert和标准库契约

学习目标

  • 能追踪函数式宏的记号替换与重扫,判断字符串化、粘贴和可变参数宏中的参数展开次数
  • 能用条件编译、包含守卫和 _Generic 分别完成配置选择、单翻译单元防重与类型分派
  • 能比较宏与 static inline,并正确使用 assert、math.h 与标准库函数的域和错误协议

机制总览

C预处理器和C标准库:机制路径

  1. 1

    编译前还有一道「改写」工序

    你写的 .c 文件进入语法与类型分析前,会先经过 预处理器(preprocessor) 。它不是任意字符级“搜索替换”:源文件先经历字符映射、行拼接、注释替换和预处理记号划分,宏参数替换后还会按规则重扫。

  2. 2

    预处理器在流水线中的位置

    完整路径仍是:源码 → 预处理 → 编译 → 汇编 → 链接。本章聚焦第一道工位。

  3. 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 文件进入语法与类型分析前,会先经过 。它不是任意字符级“搜索替换”:源文件先经历字符映射、行拼接、注释替换和预处理记号划分,宏参数替换后还会按规则重扫。

想象出版社排版:作者交稿(源码)后,编辑先统一替换术语、插入标准版权页(#include)、按「精装版/平装版」删掉不同章节(条件编译),排完才交给印刷机(编译器)。若没有预处理器,每个 .c 都要手写一遍 stdio.h 里的声明,常量 PI 也要到处改数字。

这一章掌握宏、头文件包含与条件编译——它们决定编译器最终看到什么文本

预处理器在流水线中的位置

完整路径仍是:源码 → 预处理 → 编译 → 汇编 → 链接。本章聚焦第一道工位。

.c源码1预处理宏·include·条件#define 展开#include 粘贴2编译C → 汇编语法检查语义分析3汇编汇编 → 机器码助记符→二进制生成 .o4链接合并·解析符号多 .o + 库符号解析按下方 Stepper 逐步看预处理器在编译流水线中的位置
预处理器是编译流水线的第一道工位:在真正编译 C 语法之前,先把源码「改写成」编译器能直接处理的形态。
分步1 / 4

第一步:预处理

.c源码1预处理宏·include·条件#define 展开#include 粘贴2编译C → 汇编语法检查语义分析3汇编汇编 → 机器码助记符→二进制生成 .o4链接合并·解析符号多 .o + 库符号解析预处理器在编译前运行:展开宏、粘贴头文件、按条件保留/丢弃代码块,产出 .i
预处理器是编译流水线的第一道工位:在真正编译 C 语法之前,先把源码「改写成」编译器能直接处理的形态。

执行 include、条件包含和宏替换等翻译阶段。常见工具链可输出 .i 便于审查,但扩展名不是 C 标准接口。

可用 gcc -E main.c 只看预处理结果(常配合 -o main.i 保存)。

#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 <stdio.h>      /* 由实现定义的位置序列搜索 */
#include "myheader.h"   /* 先按实现定义方式搜索,失败后按尖括号形式处理 */

里常见函数原型、extern 对象声明、宏与类型定义。可移植代码不要把“双引号必先当前目录”当成语言保证,应按编译器和构建系统配置 include 路径。

同一头文件被多个 .c include 时,声明会重复粘贴。用

#ifndef MYPROJECT_UTILS_H
#define MYPROJECT_UTILS_H
 
/* 声明与宏 ... */
 
#endif
#ifdef DEBUG ... #else ... #endifDEBUG已定义?保留 → 编译进程序#ifdef DEBUGprintf("dbg\n");丢弃 → 不进 .o#else/* 调试关闭 */#ifndef GUARD_H#define GUARD_H ... #endif — 首次 include 保留,重复 include 整段跳过防止头文件被多次粘贴导致重复定义。
预处理器根据宏是否定义决定保留哪条分支;#ifndef 常用于头文件包含守卫(include guard)。

C 还可用 #pragma once(非标准但广泛支持)达到类似效果;可移植项目仍推荐 #ifndef 守卫。

条件编译:按「开关」裁剪代码

常见用途:

  • 调试版 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 取消定义。

带参宏的经典陷阱

宏是文本替换,不是函数:没有参数类型、实参可能被求值多次、运算符优先级由展开后的文本决定。

安全习惯:

  1. replacement 整体加括号((x) * (x))
  2. 每个形参加括号((a) > (b) ? (a) : (b))
  3. 避免 MAX(a++, b++) 这类副作用实参
  4. 复杂逻辑用 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 的 适合把格式串与参数转发给日志函数:

#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。这个接口只支持关联表列出的类型,其他类型会诊断。函数实参之间的求值顺序仍未指定,因此不要传入相互依赖的副作用表达式。

有参数和返回类型检查,也不会像 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;
}

用于违反即表示程序有 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
 
#endif

main.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 后表达式不求值,只适合检查程序员不变量。

讨论

评论区加载中…