适用对象:学过编译链接过程,想深入理解预处理阶段细节的同学
本章目标:掌握 #define 宏的各种用法 + 理解条件编译 + 学会头文件防卫 + 理解 # 和 ## 的作用
📖 引入:预处理到底是个啥?
上一篇我们讨论了编译和链接的全过程,其中有一个关键步骤被一笔带过了------预处理(Preprocessing)。今天我们就专门把这个环节拆解清楚,认真讲一讲。
打个比方:你准备写一篇作文,老师让你先打个草稿,把错别字改一改、把段落顺序调一调、把标点符号整理规范------但还没让你正式定稿。预处理就是这个"草稿整理"阶段,它在真正的编译之前 对源代码进行处理,把 #include、#define、#ifdef 这些带 # 的指令先处理干净,输出一个"纯净版"的代码,再交给编译器。
🔥 敲黑板:预处理阶段完全不检查语法!它只做文本替换和条件选择。语法检查是编译器阶段的事情。
用编译流程串联起来就是这样:
源代码 (.c/.h)
↓ 【预处理阶段】← 今天的主角
↓
处理后的代码 (.i)
↓ 【编译阶段】
↓
汇编文件 (.s)
↓ 【汇编阶段】
↓
目标文件 (.o)
↓ 【链接阶段】
↓
可执行文件
预处理做的事情概括起来就三件:
- 展开头文件 (
#include) - 文本替换 (
#define宏展开) - 条件编译 (
#if/#ifdef/#ifndef等)
下面我们一项一项来讲解。
一、#define 定义常量:最基础的操作
1.1 基本语法
用 #define 定义常量,是预处理最常见、最简单的用法:
c
#define MAX 100
#define PI 3.14159
#define VERSION "1.0.2"
预处理阶段,编译器看到 MAX 的地方就原封不动地换成 100,看到 PI 的地方就换成 3.14159,无需任何运行时计算。
1.2 生活比喻:复印机替身
想象你有一份重要文件要复印100份,文件上有个签名栏,每次复印都要手填------太麻烦了。你让助手先把所有文件上的签名栏统一印上你的名字,再去复印 100 份。这个"提前填好签名栏"的动作,就相当于 #define,省掉了重复劳动。
1.3 ⚠️ 提醒:末尾不加分号!
这是新手最容易踩的第一个坑。
c
// ✅ 正确写法
#define MAX 100
// ❌ 错误写法------分号会被一起替换进去!
#define MAX 100;
如果写成后者:
c
int arr[MAX]; // 展开成 → int arr[100;];
// 编译器:???这是个什么语法?
那个多余的分号会跟着 MAX 一起被替换进代码,导致语法错误。预处理是个"无脑文本替换工",它不懂语法,只管原样替换。
🔥 掌握这一知识点 :
#define末尾绝对不要加分号,这是一个必须养成的习惯。
1.4 作用域规则
#define 的作用域很特殊------它从定义处开始,一直到文件末尾 ,不受任何代码块 {} 限制:
c
void foo(void)
{
#define BUFFER_SIZE 1024 // 在函数内部定义
char buf[BUFFER_SIZE]; // 可以用
}
void bar(void)
{
char buf[BUFFER_SIZE]; // 同样可以用!作用域是整个文件
}
这跟普通变量的作用域完全不同。普通变量受 {} 块限制,而宏定义是文件级别 的。所以定义宏通常放在 .h 头文件顶部或 .c 文件最前面。
1.5 #undef:取消宏定义
如果想在某个位置取消一个已定义的宏,用 #undef:
c
#define MAX 100
int a = MAX; // a = 100
#undef MAX // 取消 MAX 的定义
#define MAX 200 // 重新定义 MAX
int b = MAX; // b = 200
#undef 的典型用途有两个:
- 限制宏的作用域(配合条件编译使用,后文会详细讲)
- 重新定义宏,避免冲突
二、#define 定义宏:带参数的替换
2.1 基本概念
宏不仅可以定义常量,还可以定义带参数的替换模板,写法上类似函数,但本质上是纯文本替换:
c
#define SQUARE(x) ((x) * (x))
#define MAX(x, y) ((x) > (y) ? (x) : (y))
#define DOUBLE(x) ((x) + (x))
调用 SQUARE(5) → 预处理后 → ((5) * (5)) → 运行时 → 25
2.2 核心区别:宏不是函数!
这是预处理最核心、最容易搞混的概念。宏在预处理阶段完成替换,函数在运行时被调用。
| 对比项 | #define 宏 |
inline 函数 |
|---|---|---|
| 替换时机 | 预处理(编译前) | 编译阶段 |
| 调用开销 | 无(纯文本替换) | 极低(编译器内联优化) |
| 类型检查 | 无 | 有(编译器检查) |
| 代码膨胀 | 会(每次调用处都展开) | 会 |
| 调试友好度 | 低(替换后难以对应源码) | 较高 |
2.3 宏优于函数的场景
宏在以下场景比函数更合适:
场景一:极短小的操作,省掉函数调用开销
c
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int x = MAX(a, b); // 直接展开为 ((a) > (b) ? (a) : (b))
// 没有函数调用、没有参数压栈、没有返回值
对于 MAX 这种三目运算符就能实现的逻辑,调用一个真正的函数反而更慢(需要保存现场、跳转、返回)。
场景二:类型无关的操作
c
#define SQUARE(x) ((x) * (x))
int a = SQUARE(5); // 展开成 ((5) * (5))
double b = SQUARE(3.14); // 展开成 ((3.14) * (3.14))
同一个宏可以用于 int、double、float 等多种类型,而函数需要为每种类型单独定义(或者用模板/泛型)。
🔥 经典口诀 :宏能做函数做不到的事------同时适用于多种类型。
2.4 ⚠️ 提醒:括号!括号!括号!
定义带参数的宏时,给参数加括号、给整个表达式加括号,是避免 bug 的铁律:
c
// ✅ 正确写法------全方位括号保护
#define SQUARE(x) ((x) * (x))
#define DOUBLE(x) ((x) + (x))
#define MAX(x, y) ((x) > (y) ? (x) : (y))
// ❌ 危险写法------可能出错
#define SQUARE(x) x * x
#define DOUBLE(x) x + x
为什么?来看一个经典的坑:
c
#define SQUARE(x) x * x // 没加括号
int a = SQUARE(3 + 1); // 期望: (3+1)*(3+1) = 16
// 实际: 3 + 1 * 3 + 1 = 3 + 3 + 1 = 7
// 完全错了!
乘法优先级高于加法,导致展开结果完全不对。加上括号后:
c
#define SQUARE(x) ((x) * (x))
int a = SQUARE(3 + 1); // ((3 + 1) * (3 + 1)) = 16 ✅
再看一个更隐蔽的坑:
c
#define DOUBLE(x) ((x) + (x))
int b = 5 * DOUBLE(3); // 期望: 5 * (3+3) = 30
// 如果写成 #define DOUBLE(x) x + x
// 展开成: 5 * 3 + 3 = 18 ❌
// 正确写法 ((x) + (x)) 展开成: 5 * ((3) + (3)) = 30 ✅
再来看 MAX 宏的括号:
c
#define MAX(x, y) ((x) > (y) ? (x) : (y))
int c = 10 * MAX(3, 5); // 期望: 10 * 5 = 50
// 如果写成 #define MAX(x, y) x > y ? x : y
// 展开成: 10 * 3 > 5 ? 3 : 5 = 10 * 3 > 5 ? 3 : 5
// 由于 * 优先级高于 ? :
// 实际变成: (10 * 3) > 5 ? 3 : 5 = 30 > 5 ? 3 : 5 = 3 ❌
// 正确写法 ((x) > (y) ? (x) : (y)) 展开成: 10 * ((3) > (5) ? (3) : (5)) = 10 * 5 = 50 ✅
🔥 掌握这一知识点 :宏定义的括号规则------每个参数加括号 + 整个表达式加括号,缺一不可。这是保证代码安全的关键习惯。
2.5 宏参数的特殊用法:多语句宏
有时候需要用宏包装多条语句,这时候需要用 do { ... } while(0) 技巧:
c
#define SWAP(a, b) do { \
typeof(a) _temp = (a); \
(a) = (b); \
(b) = _temp; \
} while(0)
// 使用时:
SWAP(x, y); // 正确------作为一个完整语句
if (condition)
SWAP(x, y); // 正确------if 只管到 while(0) 结束的分号
else
printf("error\n");
为什么非要用 do { } while(0) 包裹?
c
// ❌ 不用 do-while 的危险写法
#define SWAP(a, b) { \
typeof(a) _temp = (a); \
(a) = (b); \
(b) = _temp; \
}
// 使用时:
if (condition)
SWAP(x, y); // 展开成 if (condition) { ... }; ← 注意这个多余的分号
else // 编译器:这个 else 找不到配对的 if!
printf("error\n");
do { } while(0) 的技巧确保宏展开后是一个完整的单语句,可以安全地用于 if、for、while 等控制结构后面。
三、副作用陷阱:宏的暗器
3.1 最重要的问题:重复求值
这是 #define 宏最危险的地方,也是面试超高频考点。宏参数没有求值顺序控制,同一个参数可能会被求值多次。
来看最经典的例子------MAX 宏的副作用:
c
#define MAX(x, y) ((x) > (y) ? (x) : (y))
int i = 5, j = 3;
int result = MAX(i++, j++);
逐步展开看看发生了什么:
c
// 原始调用
int result = MAX(i++, j++);
// 预处理展开后
int result = ((i++) > (j++) ? (i++) : (j++));
🔥 危险!
i++和j++各执行了两次:
- 比较
i++ > j++:i 从 5 变成 6,j 从 3 变成 4,比较的是 5 > 3 为真- 条件为真,返回
(i++):i 再从 6 变成 7- 最终:
result = 7(而如果按预期逻辑,result 应该是 6)
如果你以为 result = 6(5 和 3 的较大值是 5,然后 5 自增变成 6),那就大错特错了。
3.2 常见的副作用"雷区"
以下这些调用模式都是危险的:
c
#define MAX(x, y) ((x) > (y) ? (x) : (y))
// ❌ 危险!
MAX(i++, j++); // i 和 j 各自增两次
MAX(a, b = 10); // b 只会在比较阶段被赋值一次,结果不确定
MAX(func(), 5); // func() 被调用两次!
#define SQUARE(x) ((x) * (x))
// ❌ 危险!
SQUARE(i++); // i 自增两次:(i++) * (i++)
#define DOUBLE(x) ((x) + (x))
// ❌ 危险!
DOUBLE(i++); // i 自增两次:((i++) + (i++))
3.3 ⚠️ 提醒:解决之道
第一条:永远不要在宏参数中使用自增/自减表达式。
c
// ✅ 安全调用
int temp = i;
MAX(temp, j); // 先把 i++ 的结果存起来再调用
第二条:用编译器推荐的替代品。
现代 C 语言(C99/C11)的 inline 函数和 _Generic 选择可以做到"类型安全 + 无副作用":
c
// 用 inline 函数替代宏
static inline int max_int(int a, int b) {
return a > b ? a : b;
}
// 或者用 GNU C 的 typeof 扩展
#define MAX(a, b) ({ \
typeof(a) _a = (a); \
typeof(b) _b = (b); \
_a > _b ? _a : _b; \
})
GCC 的 ({ }) 语法(语句表达式)可以创建临时变量,每个参数只求值一次,完美规避副作用问题。不过这是 GCC/Clang 扩展,标准 C 编译器不一定支持。
🔥 掌握这一知识点 :宏的副作用是经典的面试雷区 。写
MAX(i++, j++)的人往往会"自信满满"地认为自己懂了,实际上他根本不懂展开后发生了什么。
四、# 和 ## 运算符:字符串化和拼接
4.1 # 运算符:把参数变成字符串
# 运算符的作用很直接------将宏参数转换为字符串常量(字符串化):
c
#define STRINGIFY(x) #x
int a = 42;
printf("%s\n", STRINGIFY(a)); // 输出: a
printf("%s\n", STRINGIFY(100)); // 输出: 100
printf("%s\n", STRINGIFY(3.14)); // 输出: 3.14
展开过程:
c
STRINGIFY(a) → "a"
STRINGIFY(100) → "100"
注意看------即使传入的是变量名 a,经过 # 转换后得到的是字符串 "a",而不是变量 a 的值 "42"。
4.2 实用技巧:打印变量名和值
这是 # 运算符最经典的使用场景------同时打印变量名和值:
c
#define PRINT_INT(n) printf(#n " = %d\n", n)
int apple = 10;
int banana = 20;
PRINT_INT(apple); // 展开成 → printf("apple" " = %d\n", apple)
// C语言相邻字符串字面量会自动拼接
// 输出 → apple = 10
PRINT_INT(banana); // 输出 → banana = 20
再来看更复杂一点的例子:
c
#define PRINT(n) printf("" #n " = %d\n", n)
int score = 95;
PRINT(score); // printf("" "score" " = %d\n", score)
// 相邻字符串拼接后 → printf("score = %d\n", score)
// 输出 → score = 95 ✅
注意 "" #n "" 中的空字符串 "" 是用来连接 #n 前后的内容,确保展开正确。
4.3 ⚠️ 提醒:字符串化的细微规则
字符串化有几个需要注意的地方:
c
#define S(x) #x
S(Hello World) // ❌ 预处理会报错:字符串中不能直接换行
// 正确做法:S(Hello\ World) 或直接 S(Hello World) 本身
S("hello") // → "\"hello\"" (字符串中的引号会被转义)
S(__LINE__) // → "__LINE__" (宏名本身被字符串化,不会展开)
// 注意:如果想让 __LINE__ 展开,需要两层间接
// 经典技巧:先展开再字符串化
#define STRINGIFY(x) STRINGIFY_IMPL(x)
#define STRINGIFY_IMPL(x) #x
__LINE__ 在 S() 中:S(__LINE__) → "__LINE__"(不展开)
在 STRINGIFY 中:STRINGIFY(__LINE__) → STRINGIFY_IMPL(42) → "42"(先展开再字符串化)
4.4 ## 运算符:连接两个符号
## 的作用是将两个符号(token)拼接成一个符号。它发生在预处理阶段 token 化之后,所以可以拼接标识符、关键字、数字等:
c
#define CAT(x, y) x##y
int xy = 100;
printf("%d\n", CAT(x, y)); // 展开成 printf("%d\n", xy)
// 输出 100 ✅
printf("%d\n", CAT(12, 34)); // 展开成 printf("%d\n", 1234)
// 输出 1234 ✅
4.5 经典用法:构造变量名和函数名
## 最实用的场景是批量生成相关变量或函数:
c
#define DEFINE_VARIABLE(name, value) int name##_id = value
#define CALL_FUNCTION(name) name##_handler()
DEFINE_VARIABLE(user, 1); // 展开成 → int user_id = 1
DEFINE_VARIABLE(product, 2); // 展开成 → int product_id = 2
再看一个更高级的技巧------生成寄存器名称:
c
// 假设有三个传感器的寄存器地址
#define SENSOR_REG(n) (*(volatile uint32_t *)(SENSOR##n##_BASE))
SENSOR_REG(1); // 展开成 → (*(volatile uint32_t *)(SENSOR1_BASE))
SENSOR_REG(2); // 展开成 → (*(volatile uint32_t *)(SENSOR2_BASE))
SENSOR_REG(3); // 展开成 → (*(volatile uint32_t *)(SENSOR3_BASE))
4.6 ⚠️ 提醒:## 的边界情况
c
// ❌ 拼接后不是合法标识符
#define MKID(x, y) x##y
MKID(int, var); // 展开成 → intvar(不是合法标识符!)
// ❌ ## 不能拼接字符串
#define CONCAT(a, b) a##b
CONCAT("hello", "world"); // ❌ 错误!## 只能拼接 token,不能拼接字符串字面量
// ✅ ## 的正确用法:拼接标识符/数字/宏名
#define MAKE_GETTER(name) get_##name(void)
MAKE_GETTER(temperature); // → get_temperature(void)
#define VERSION 1
#define STR(x) #x
#define XSTR(x) STR(x) // 两层间接:先展开 VERSION,再字符串化
XSTR(VERSION); // → STR(1) → "1" ✅
五、条件编译:让代码"看情况"编译
5.1 为什么需要条件编译?
条件编译是预处理最强大的武器之一------它允许代码在编译前就决定哪些内容参与编译,哪些直接丢弃。就像你写小说时用荧光笔标记"这段只在出版版出现"、"这段只在网络版出现",预处理就是那个根据出版要求决定最终内容的编辑。
5.2 四种条件编译指令
第一种:#if / #endif
c
#if 常量表达式
// 代码块
#endif
常量表达式在预处理阶段求值(所以必须是编译期常量):
c
#define VERSION 2
#if VERSION >= 2
printf("Running version 2 or higher features.\n");
#endif
#if 0 // 相当于注释,用于临时禁用代码
printf("这段代码永远不会执行。\n");
#endif
第二种:#ifdef(最常用!)
c
#ifdef 标识符
// 如果标识符已定义,执行这里
#endif
第三种:#ifndef(同样常用,甚至更常用!)
c
#ifndef 标识符
// 如果标识符未定义,执行这里
#endif
#ifndef 是 #if not defined 的缩写,"如果没定义,就执行",常用于头文件防卫(后面会详细讲)。
第四种:#elif 和 #else
c
#if条件1
// 条件1满足时
#elif条件2
// 条件1不满足,条件2满足时
#else
// 所有条件都不满足时
#endif
5.3 实际用途一:调试开关
这是条件编译最常见的用法之一:
c
#define DEBUG // 调试时打开,发布时注释掉
#ifdef DEBUG
#define LOG(fmt, ...) printf("[DEBUG] " fmt "\n", ##__VA_ARGS__)
#else
#define LOG(fmt, ...) // 空实现,不产生任何代码
#endif
LOG("x = %d, y = %d", x, y); // DEBUG 模式下输出信息,发布模式下完全消失
🔥 掌握这一知识点 :
DEBUG宏被注释掉后,所有LOG调用在预处理阶段就完全消失,既不占用运行时间,也不占用内存,是最高效的调试手段。
5.4 实际用途二:跨平台支持
这是条件编译在工程中的核心价值:
c
#ifdef _WIN32
#include <windows.h>
#define CLEAR_SCREEN() system("cls")
#elif __linux__
#include <unistd.h>
#define CLEAR_SCREEN() system("clear")
#elif __APPLE__
#include <mach-o/dyld.h>
#define CLEAR_SCREEN() system("clear")
#else
#error "Unsupported platform!"
#endif
常见系统宏一览:
| 宏名 | 平台 |
|---|---|
_WIN32 / _WIN64 |
Windows |
__linux__ |
Linux |
__APPLE__ |
macOS / iOS |
__unix__ |
通用 Unix |
__STDC__ |
标准 C 编译器 |
5.5 ⚠️ 提醒:调试开关的另一种写法
有时候我们不想改动代码,直接在编译时传入宏定义:
bash
gcc -c main.c # 没有 DEBUG,不输出调试信息
gcc -DDEBUG -c main.c # -D 定义了 DEBUG,输出调试信息
-D 编译器选项可以在编译时定义宏,等价于在代码里写了 #define DEBUG。这是工程中常用的做法,不需要改代码就能切换调试模式。
六、头文件重复包含问题:差点让编译器崩溃的经典 Bug
6.1 问题描述
在大型项目中,.c 文件往往 #include 很多头文件,而这些头文件之间又互相包含:
main.c
└── #include "a.h"
└── #include "b.h"
└── #include "common.h"
└── #include "common.h" // 重复包含了!
如果 common.h 里有 #define VALUE 42 这样的宏定义,重复包含会导致:
- 宏被重复定义(如果两次定义相同,浪费编译时间;如果定义不同,报错)
- 结构体被重复定义 (
typedef struct { ... } Node;重复会导致编译错误) - 编译时间大幅增加(头文件越大、包含越深,浪费越严重)
6.2 生活比喻:复印店的连锁反应
想象你去复印店复印身份证,正反面都要。店员A复印了正面,店员B说"我也帮你复印一份"------结果正面被复印了两遍。如果你叫了两个店员同时处理,身份证正反面都被复印了两次,效率低不说,还可能出问题(有些机器复印两次字就模糊了)。头文件重复包含就是这种"多人重复复印"的情况。
6.3 解决方案一:传统的 #ifndef 防卫(最重要!)
核心思想:如果这个宏没定义过,就处理这个头文件的内容;如果已经定义过,说明之前处理过,直接跳过。
c
// common.h
#ifndef __COMMON_H__ // 第一步:如果 __COMMON_H__ 还没定义
#define __COMMON_H__ // 第二步:定义它("我处理过这个文件了")
// 头文件的具体内容
#define VALUE 42
typedef struct {
int x;
int y;
} Point;
// ...
#endif // 结束 ifndef
工作流程:
- 第一次包含
common.h:__COMMON_H__未定义 → 进入#ifndef块 →#define定义它 → 处理内容 - 第二次包含
common.h:__COMMON_H__已定义 → 跳过#ifndef块 → 完美避免重复
🔥 掌握这一知识点 :头文件防卫是 C 语言工程中的必会技能 。每个头文件都应该用
#ifndef包裹,否则在大项目中几乎必然遇到重复定义错误。
命名规范:通常用 __HEADERNAME_H__ 或 HEADERNAME_H_INCLUDED 的形式。注意用双下划线 __ 前缀是系统保留的,但这是业界最通用的约定。
6.4 解决方案二:#pragma once(更简洁)
现代编译器支持一种更简洁的写法:
c
// common.h
#pragma once
// 头文件内容...
#pragma once 告诉编译器"这个头文件只处理一次",效果和 #ifndef 防卫相同,但更简洁。
6.5 ⚠️ 提醒:两种方案的对比
| 对比项 | #ifndef 防卫 |
#pragma once |
|---|---|---|
| 兼容性 | 100% 兼容所有 C/C++ 编译器 | 需要编译器支持(现代编译器基本都支持) |
| 跨平台 | ✅ 绝对安全 | ⚠️ 极少数老编译器不支持 |
| 防止符号冲突 | ✅ 可以自定义宏名 | ❌ 无法自定义 |
| 推荐场景 | 生产代码(推荐用这个) | 小型项目、快速原型 |
生产环境推荐用 #ifndef 防卫,理由很简单:它有 100% 的兼容性保证,而且有些 IDE 工具(CLang-Tidy 等)会自动检查头文件是否有防卫。
6.6 完整示例:标准头文件结构
c
// point.h
#ifndef __POINT_H__
#define __POINT_H__
// ---- 类型定义 ----
typedef struct {
int x;
int y;
} Point;
// ---- 函数声明 ----
void point_init(Point *p, int x, int y);
void point_move(Point *p, int dx, int dy);
double point_distance(const Point *a, const Point *b);
#endif // __POINT_H__
七、实战综合示例
把上面所有知识点串起来,做一个完整的小程序:
c
// config.h
#ifndef __CONFIG_H__
#define __CONFIG_H__
// ========== 平台检测 ==========
#ifdef _WIN32
#define PLATFORM "Windows"
#define CLEAR_SCREEN() system("cls")
#elif __linux__
#define PLATFORM "Linux"
#define CLEAR_SCREEN() system("clear")
#else
#define PLATFORM "Unknown"
#define CLEAR_SCREEN() ((void)0)
#endif
// ========== 调试开关 ==========
// 在编译时用 -DDEBUG 可以开启调试模式
#ifdef DEBUG
#define LOG(fmt, ...) printf("[%s] " fmt "\n", PLATFORM, ##__VA_ARGS__)
#else
#define LOG(fmt, ...) ((void)0) // 什么都不做
#endif
// ========== 实用宏 ==========
#define SQUARE(x) ((x) * (x)) // 安全平方
#define MAX(a, b) ((a) > (b) ? (a) : (b)) // 安全取大
#define STRINGIFY(x) #x // 字符串化
#define CONCAT(a, b) a##b // 拼接
#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0])) // 数组长度
// ========== 常量定义 ==========
#define WINDOW_WIDTH 800
#define WINDOW_HEIGHT 600
#define VERSION_STRING "2.1.0"
#endif // __CONFIG_H__
c
// main.c
#include <stdio.h>
#include "config.h"
int main(void)
{
// 使用平台相关功能
CLEAR_SCREEN();
printf("Platform: %s\n", PLATFORM);
printf("Version: %s\n", VERSION_STRING);
// 使用宏计算
int arr[] = {1, 2, 3, 4, 5};
printf("Array size: %d\n", ARRAY_SIZE(arr));
printf("Square of 7: %d\n", SQUARE(7));
printf("MAX(3+4, 9-2): %d\n", MAX(3+4, 9-2));
// 使用字符串化宏
int counter = 42;
printf(STRINGIFY(counter) " = %d\n", counter);
// 使用拼接宏
int test_value = 999;
printf("test" "_value = %d\n", CONCAT(test, _value));
// 使用日志宏(调试模式)
LOG("Counter is %d", counter);
return 0;
}
编译时选择是否开启调试模式:
bash
gcc -o main.exe main.c # 正式版(LOG 宏不输出)
gcc -DDEBUG -o main_debug.exe main.c # 调试版(LOG 宏输出)
八、面试题专题
面试题一:#define 与 const 的区别
| 对比维度 | #define |
const |
|---|---|---|
| 替换阶段 | 预处理(纯文本替换,无类型) | 编译(编译器处理,有类型) |
| 类型检查 | ❌ 无(只是字符串替换) | ✅ 有(编译器强制检查) |
| 内存占用 | ❌ 不占运行时内存(纯文本替换) | ✅ 占内存(运行时变量) |
| 作用域 | 文件级别(从定义处到文件末尾) | 受 {} 代码块限制 |
| 调试友好度 | ❌ 低(宏展开后难以对应源码行) | ✅ 高(是正常变量,可调试) |
| 能用场景 | 条件编译、编译期常量 | 需要类型安全、需要取地址的场景 |
c
// 举例说明
#define PI 3.14159 // 预处理替换:任何出现 PI 的地方变成 3.14159
const double pi = 3.14159; // 运行时变量:系统为其分配内存
🔥 经典回答模板 :
#define是编译前文本替换,没有类型、不占内存、作用域是文件级别的;const是真正的只读变量,有类型检查、占内存、受作用域限制。在能用const的场景优先用const,在需要条件编译或类型无关的宏时才用#define。
面试题二:宏为什么比函数效率高?
回答思路:函数调用的开销 vs 宏的零开销。
函数调用有固定开销:
- 参数压栈(将参数复制到栈上)
- 保存当前运行环境(寄存器、返回地址)
- 跳转到函数入口
- 执行函数体
- 保存返回值
- 恢复现场
- 返回调用点
每一步都需要 CPU 指令。调用次数少还好,调用几千上万次就不可忽略了。
宏在预处理阶段直接展开为内联代码,没有跳转、没有压栈、没有调用返回,完全消除了函数调用的开销。
但是!宏不是免费的午餐:
c
#define SQUARE(x) ((x) * (x))
// 调用 1000 次 SQUARE(5):
int result = SQUARE(5); // 预处理后:int result = ((5) * (5));
// 展开 1000 次 → 代码体积 × 1000
如果 SQUARE 是真正的函数:
c
int square(int x) { return x * x; }
int result = square(5); // 整个程序只有一份 square() 函数代码
// 代码体积不膨胀
🔥 经典回答模板 :宏消除了函数调用的参数压栈、跳转、保存现场等开销,所以"看起来"更快。但代价是代码膨胀 ------每次调用处都会复制一份宏展开后的代码。如果宏逻辑复杂或调用频繁,反而会增加指令缓存(I-cache)压力,得不偿失。现代编译器推荐用
inline函数来兼顾两者优点。
面试题三:#ifdef DEBUG 的作用是什么?
作用:在预处理阶段有条件地保留或丢弃调试代码。
c
#ifdef DEBUG
fprintf(stderr, "[DEBUG] x = %d, y = %d\n", x, y);
assert(x > 0 && "x must be positive");
#endif
三种使用方式:
- 代码中定义 :
#define DEBUG - 编译时定义 :
gcc -DDEBUG main.c - CMake/GNU Make 传入 :
gcc -DDEBUG=1 main.c
为什么要用条件编译而不是普通注释:
- 注释里的代码仍然会被编译器部分处理(影响编译速度)
#ifdef内的代码在预处理阶段就被完全丢弃,编译器根本看不到它,不消耗任何编译时间
面试题四:# 和 ## 的作用分别是什么?
# 的作用:字符串化(Stringification),将宏参数转换为字符串字面量。
c
#define STRINGIFY(x) #x
STRINGIFY(hello) // → "hello"
## 的作用: token 拼接(Token Pasting / Concatenation),将两个 token 连接成一个 token。
c
#define CONCAT(a, b) a##b
CONCAT(font, size) // → fontsize(一个合法的标识符)
经典陷阱 :单独使用 __LINE__ 进行字符串化不会得到行号:
c
#define TOSTRING(x) #x
TOSTRING(__LINE__) // → "__LINE__"(得到的是字符串"__LINE__",而不是"42")
// 正确做法:两层间接
#define _TOSTRING(x) #x
#define TOSTRING(x) _TOSTRING(x)
TOSTRING(__LINE__) // → "42"(先展开 __LINE__,再字符串化)
九、总结
这篇预处理的内容比较多,我们做一个分点总结:
📌 预处理是什么
- 编译前的"草稿整理"阶段,纯文本操作,不做语法检查
- 三大任务:展开 include → 宏替换 → 条件编译
📌 #define 的用法
- 定义常量 :
#define MAX 100(末尾不加分号!) - 定义宏 :
#define SQUARE(x) ((x) * (x)) - 括号规则:每个参数加括号 + 整体加括号
- 多语句宏 :用
do { } while(0)包裹
📌 #undef 的用法
- 取消已定义的宏,限制其作用域
📌 副作用陷阱(重要!)
MAX(i++, j++)中 i 和 j 会被求值两次- 永远不要在宏参数中使用自增/自减表达式
📌 # 和 ##
#:字符串化 ,把参数变成"参数"##:拼接,把两个 token 合成一个 token
📌 条件编译
#if/#ifdef/#ifndef/#elif/#else/#endif- 调试开关:
#ifdef DEBUG - 跨平台:
#ifdef _WIN32/#elif __linux__ - 编译时定义:
gcc -DDEBUG
📌 头文件防卫
#ifndef __HEADER_H__+#define __HEADER_H__+ 内容 +#endif- 或使用
#pragma once(现代编译器)
🔥 温言提醒:预处理是 C 语言最接近"底层操作"的地方,它让你能在编译前就控制代码的形态。理解透了预处理,你就理解了编译器的前半段工作逻辑。下次看到编译报错提到"宏展开后"之类的信息,你就明白是怎么回事了。