10、条件编译: #ifdef / #ifndef / #if / #elif / #endif 的实际应用
条件编译,说白了就是让编译器“挑着看代码”。
你写了一大堆源码,但有些部分只在特定平台、特定版本、或者调试时才需要。如果每次都手动删改,那简直是灾难。我早年在一个跨平台项目里就吃过这个亏——Windows 和 Linux 的底层接口完全不同,没有条件编译,代码根本没法维护。
10.1 为什么需要条件编译?
嵌入式开发里,条件编译几乎是标配。原因很简单:
- 硬件差异:同一套代码要跑在 STM32F1 和 STM32F4 上,外设寄存器地址不同。
- 调试与发布:调试时打印日志,发布时去掉,避免性能损耗。
- 功能开关:比如低配版不带蓝牙,高配版带蓝牙,用宏控制。
- 跨平台:Windows 用
Sleep(),Linux 用usleep()。
嗯,这里要注意:条件编译是在预处理阶段完成的,不是运行时。所以它不会增加代码体积,也不会影响执行速度——这是它最大的优势。
核心原则:条件编译只影响“哪些代码被编译”,不影响“代码怎么运行”。
10.2 基本语法与用法
先看最常用的几个指令:
| 指令 | 含义 | 典型场景 |
|---|---|---|
#ifdef MACRO |
如果定义了 MACRO,则编译后续代码 | 调试开关、平台判断 |
#ifndef MACRO |
如果没定义 MACRO,则编译后续代码 | 头文件保护、默认值设置 |
#if 表达式 |
如果表达式为真(非0),则编译后续代码 | 版本号比较、数值判断 |
#elif 表达式 |
否则如果表达式为真 | 多分支选择 |
#else |
否则 | 默认分支 |
#endif |
结束条件编译块 | 必须成对出现 |
举个例子,我在一个传感器驱动里这样用:
#define USE_I2C_DRIVER 1 // 1: 使用I2C, 0: 使用SPI
void sensor_init(void) {
#if USE_I2C_DRIVER
i2c_init();
#else
spi_init();
#endif
}
你看,改一个宏定义,底层驱动就换了。不用改函数调用,也不用动业务逻辑。
10.3 #ifdef 与 #if defined() 的区别
很多人觉得 #ifdef 和 #if defined() 是一样的。其实不然。
#ifdef 只能判断“是否定义”,不能判断“定义的值是多少”。而 #if defined() 可以配合逻辑运算符:
#define VERSION 2
#if defined(VERSION) && (VERSION > 1)
// 版本大于1时才编译
printf("Advanced feature enabled\n");
#endif
我个人习惯用 #if defined(),因为它更灵活。尤其是需要同时判断多个宏时:
#if defined(DEBUG) && defined(VERBOSE)
// 调试且详细模式
#endif
小技巧:#ifdef 不能写成 #ifdef MACRO && OTHER,但 #if defined(MACRO) && defined(OTHER) 可以。记住这个区别,能省不少调试时间。
10.4 #ifndef 的经典用途:头文件保护
这个你应该很熟悉了。每个头文件几乎都要加:
#ifndef __MY_HEADER_H__
#define __MY_HEADER_H__
// 头文件内容
#endif // __MY_HEADER_H__
为什么要这样?因为 C 语言不允许同一个结构体、函数声明被定义两次。如果两个 .c 文件都 #include 了同一个头文件,没有保护就会报重复定义错误。
我曾经见过一个项目,头文件嵌套了五六层,结果某个结构体被定义了三次。编译报错信息长达几百行,最后发现就是忘了加 #ifndef。嗯,从那以后我写头文件的第一件事就是加保护。
10.5 #if / #elif / #else 的多分支应用
当你有多个条件需要判断时,#if / #elif / #else 比一堆 #ifdef 嵌套更清晰。
举个例子,我在一个通信协议栈里处理不同平台的内存对齐:
#define PLATFORM_ARM 1
#define PLATFORM_X86 2
#define PLATFORM_RISCV 3
#define CURRENT_PLATFORM PLATFORM_ARM
#if CURRENT_PLATFORM == PLATFORM_ARM
#pragma pack(1)
typedef struct {
uint8_t header;
uint32_t data;
} __attribute__((packed)) Packet;
#elif CURRENT_PLATFORM == PLATFORM_X86
#pragma pack(1)
typedef struct {
uint8_t header;
uint32_t data;
} Packet;
#else
#error "Unsupported platform!"
#endif
注意最后那个 #error。如果平台没匹配上,编译直接报错,而不是默默用错误的结构体。这个习惯我强烈推荐——宁可编译失败,也不要运行时崩溃。
10.6 条件编译的避坑指南
我用过不少条件编译,踩过的坑也不少。挑几个典型的说说:
- 宏定义拼写错误:
#ifdef DEBUGG少写一个 G,结果调试代码永远不编译。排查了半天才发现。 - 忘记 #endif:条件编译块必须成对出现。漏掉一个,后面的代码全乱套。
- 在 #if 里用变量:
#if (x > 0)是不行的,x 必须是宏,不能是变量。预处理阶段变量还没出生呢。 - 嵌套过深:条件编译嵌套超过三层,可读性急剧下降。我建议最多嵌套两层,再多就拆函数。
警告:不要在 #if 表达式里使用 sizeof 或强制类型转换。预处理阶段不识别这些,结果永远是 0 或报错。
10.7 实战:调试日志的条件编译
嵌入式开发里,调试日志是最常见的条件编译场景。我一般这样设计:
// debug.h
#ifndef __DEBUG_H__
#define __DEBUG_H__
#define DEBUG_LEVEL_NONE 0
#define DEBUG_LEVEL_ERROR 1
#define DEBUG_LEVEL_WARN 2
#define DEBUG_LEVEL_INFO 3
#ifndef DEBUG_LEVEL
#define DEBUG_LEVEL DEBUG_LEVEL_ERROR // 默认只打印错误
#endif
#if DEBUG_LEVEL >= DEBUG_LEVEL_ERROR
#define LOG_ERROR(fmt, ...) printf("[ERROR] " fmt "\n", ##__VA_ARGS__)
#else
#define LOG_ERROR(fmt, ...) // 空定义,不产生任何代码
#endif
#if DEBUG_LEVEL >= DEBUG_LEVEL_WARN
#define LOG_WARN(fmt, ...) printf("[WARN] " fmt "\n", ##__VA_ARGS__)
#else
#define LOG_WARN(fmt, ...)
#endif
#if DEBUG_LEVEL >= DEBUG_LEVEL_INFO
#define LOG_INFO(fmt, ...) printf("[INFO] " fmt "\n", ##__VA_ARGS__)
#else
#define LOG_INFO(fmt, ...)
#endif
#endif // __DEBUG_H__
使用时,在编译选项里定义 DEBUG_LEVEL:
// 调试阶段:打印所有日志
gcc -DDEBUG_LEVEL=3 main.c
// 发布阶段:只打印错误
gcc -DDEBUG_LEVEL=1 main.c
你看,改一个编译参数,日志级别就变了。而且没被编译的日志宏是空定义,不会生成任何代码,也不会影响性能。
10.8 知识体系图
下面这张图总结了条件编译的核心逻辑和常见应用场景:
10.9 总结
条件编译是 C 语言里少有的“编译期决策”工具。用好了,一套代码能适配多个平台、多个版本、多种功能组合。用不好,代码里全是 #ifdef 的迷宫,谁也看不懂。
我个人建议:
- 能用
#if就别用#ifdef,因为#if可以判断值,更精确。 - 条件编译块不要太长,超过 20 行就考虑拆成独立函数。
- 每个
#if都要有对应的#else或#error,避免遗漏。 - 头文件保护是必须的,别偷懒。
记住:条件编译是给编译器看的,不是给人看的。所以写的时候要想着“别人能不能看懂”。