C 预处理器

预处理器(preprocessor)是 C 编译的第一阶段——在正式编译前,先把源码里的 # 开头的指令处理掉。它做的事情是纯文本替换:展开宏、包含文件、按条件删掉部分代码。理解预处理器,你才能看懂很多 C 代码里的"魔法",也能写出更灵活的程序。

一、预处理在编译流程中的位置

完整流程:预处理 → 编译 → 汇编 → 链接。预处理只做文本处理,不管语法对错。可以用 gcc -E hello.c 只跑预处理,看展开后的结果——你会看到 #include 把整个 stdio.h 几千行都贴了进来。

二、#include 文件包含

#include 把指定头文件的内容原封不动地粘贴到当前位置:

#include <stdio.h>      // 系统头文件:用尖括号
#include <stdlib.h>      // 在系统目录(如 /usr/include)查找

#include "myheader.h"    // 用户头文件:用双引号
                         // 先在当前目录找,找不到再去系统目录

// #include 本质:把指定文件的内容"复制粘贴"到当前位置
// 头文件通常放:函数声明、struct/typedef 定义、宏定义、全局变量声明(extern)

// 防止重复包含(头文件保护):
#ifndef MY_HEADER_H       // 如果没定义过这个宏
#define MY_HEADER_H       // 就定义它
// ... 头文件内容 ...
#endif                    // 第二次 include 时,MY_HEADER_H 已定义,整个内容被跳过

// 更简洁的写法(主流编译器都支持):
#pragma once              // 文件只被包含一次

两个细节:

三、#define 宏定义

#define 定义——给一段文本起个名字,编译时替换。分两种:

#include <stdio.h>

// 1. 对象式宏(常量):#define 名字 值
#define PI 3.14159
#define MAX_SIZE 100
#define VERSION "1.0.0"
#define AUTHOR "小明"

// 编译时所有 PI 都被替换成 3.14159
// 注意:没有类型检查,没有分号,纯文本替换!

// 2. 函数式宏(带参数)
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define ABS(x) ((x) < 0 ? -(x) : (x))

// 警告:宏的参数必须加括号!不然有坑
#define BAD_SQUARE(x) x * x
// BAD_SQUARE(3 + 1) 展开成 3 + 1 * 3 + 1 = 7,不是 16!

int main(void) {
    printf("PI = %f\n", PI);
    printf("SQUARE(5) = %d\n", SQUARE(5));        // 25
    printf("SQUARE(3+1) = %d\n", SQUARE(3 + 1));  // 16(对了,因为加了括号)
    printf("MAX(3, 7) = %d\n", MAX(3, 7));        // 7
    printf("ABS(-5) = %d\n", ABS(-5));            // 5

    printf("BAD_SQUARE(3+1) = %d\n", BAD_SQUARE(3 + 1));  // 7(错的!)
    return 0;
}

宏最大的坑:参数必须加括号! 因为宏是纯文本替换,不带括号会出问题:BAD_SQUARE(3+1) 展开成 3+1*3+1 = 7(不是预期的 16),而 SQUARE(3+1) 展开成 ((3+1)*(3+1)) = 16每个参数和整体都要加括号

宏 vs const vs enum:

四、条件编译

条件编译让你在同一段源码里编译出不同的版本——调试版/发布版、Windows/Linux、启用/禁用某功能:

#include <stdio.h>

#define DEBUG 1          // 0 关闭调试,1 开启

int main(void) {
    int x = 42;

    // 条件编译:根据宏的值决定编译哪段代码
    #if DEBUG
        printf("调试: x = %d\n", x);    // DEBUG=1 时编译
    #endif

    // #ifdef:如果定义了某宏(不管值是什么)
    #ifdef DEBUG
        printf("DEBUG 已定义\n");
    #endif

    // #ifndef:如果没定义
    #ifndef RELEASE
        printf("不是 RELEASE 模式\n");
    #endif

    // #if-#elif-#else
    #if DEBUG == 1
        printf("详细日志\n");
    #elif DEBUG == 2
        printf("超详细日志\n");
    #else
        printf("无日志\n");
    #endif

    // 实际用途:
    // 1. 跨平台代码:#ifdef _WIN32 / #elif __linux__
    // 2. 调试开关:#if DEBUG printf(...)
    // 3. 功能开关:#define ENABLE_FEATURE_X
    return 0;
}

主要指令:

编译时也可以从命令行定义宏:gcc -DDEBUG hello.c 等于在文件开头写了 #define DEBUG 1。这是构建系统实现"Debug/Release 配置"的常用方式。

五、预定义宏

编译器内置了一些有用的宏,可以直接用:

#include <stdio.h>

int main(void) {
    // 预定义宏(编译器内置,直接可用)
    printf("文件: %s\n", __FILE__);       // 源文件名
    printf("行号: %d\n", __LINE__);       // 当前行号
    printf("日期: %s\n", __DATE__);       // 编译日期
    printf("时间: %s\n", __TIME__);       // 编译时间
    printf("标准: %ld\n", __STDC__);      // 1 表示遵循 ANSI C

    // C99 新增
    printf("函数: %s\n", __func__);       // 当前函数名

    // 实际用途:
    // 1. 日志宏:printf("[%s:%d] 错误", __FILE__, __LINE__);
    // 2. 调试断言:assert 宏内部就是用 __FILE__/__LINE__
    // 3. 编译时戳:版本信息

    // 自定义日志宏(很常用)
    // #define LOG(msg) printf("[%s:%d] %s\n", __FILE__, __LINE__, msg)
    // LOG("程序启动");
    return 0;
}

最常用的是 __FILE____LINE____func__(C99),用来写日志和断言:

六、高级宏技巧

宏能做的事比想象的多——多行宏、字符串化、拼接、可变参数:

#include <stdio.h>

// 多行宏用反斜杠续行
#define SWAP(type, a, b) do { \
    type tmp = (a); \
    (a) = (b); \
    (b) = tmp; \
} while (0)

// 字符串化运算符 #
#define STR(x) #x
// STR(hello) 展开成 "hello"

// 拼接运算符 ##
#define CONCAT(a, b) a ## b
// CONCAT(foo, bar) 展开成 foobar

// 可变参数宏(C99)
#define LOG(fmt, ...) printf("[LOG] " fmt "\n", __VA_ARGS__)
// __VA_ARGS__ 代表 ... 的所有参数

// do { ... } while (0) 的作用:让宏像一条语句一样使用
// 能跟在 if 后面而不出错

int main(void) {
    int x = 10, y = 20;
    SWAP(int, x, y);
    printf("x=%d, y=%d\n", x, y);    // x=20, y=10

    printf("%s\n", STR(hello world));   // "hello world"
    printf("%s\n", STR(123));           // "123"

    int foobar = 42;
    printf("%d\n", CONCAT(foo, bar));   // 42

    LOG("x = %d", x);                   // [LOG] x = 20
    LOG("简单的消息");                   // [LOG] 简单的消息
    return 0;
}

几个高级运算符:

七、#pragma 编译器指令

#pragma 是给编译器的特殊指令,高度依赖具体编译器(可移植性差):

#include <stdio.h>

// #pragma:编译器特定的指令(可移植性差)
#pragma once                          // 文件只包含一次(替代头文件保护)

// #pragma pack:控制结构体内存对齐
#pragma pack(push, 1)                 // 保存当前对齐,设为 1 字节对齐
struct Packed {
    char c;    // 1 字节
    int i;     // 紧跟在 c 后面,无填充
};              // sizeof = 5(不是 8)
#pragma pack(pop)                     // 恢复之前的对齐

// GCC/Clang 特有的 __attribute__
struct Packed2 {
    char c;
    int i;
} __attribute__((packed));            // 等价于 #pragma pack

// #pragma message:编译时打印消息
#pragma message("正在编译 " __FILE__)

// 常用 #pragma:
// #pragma once              - 头文件只包含一次
// #pragma pack(n)           - 设置对齐
// #pragma GCC diagnostic    - 控制警告
// #pragma comment(lib, "x") - MSVC 链接库

int main(void) {
    printf("Packed 大小: %zu\n", sizeof(struct Packed));    // 5
    return 0;
}

最常用的是 #pragma once(现代头文件保护)和 #pragma pack(控制内存对齐)。其他 pragma 用得少,需要时查编译器文档。

八、宏的副作用陷阱

函数式宏有一个经典陷阱——参数被多次求值:

解决:要么用函数,要么写的时候确保参数没有副作用。这也是为什么 C++ 引入了 inline 函数和 constexpr——它们既有宏的性能,又有函数的类型安全。

九、宏 vs 函数:怎么选?

小结

预处理器做纯文本替换。#include 粘贴文件,#define 定义宏(参数记得加括号),#ifdef 条件编译实现多版本。__FILE__/__LINE__ 用于日志。宏没有类型检查,小心副作用。下一篇看 C 的文件 I/O

← 上一篇 C 动态内存

下一篇 C 文件 I/O

✈️💬