宏中的特殊运算符:#运算符、##运算符、可变参数宏
这一节,我们来聊聊宏定义里三个比较“高级”的玩法。说实话,我刚入行那会儿,看到同事代码里出现 # 和 ## 这两个符号,第一反应是“这什么鬼?” 后来自己啃了几次源码,踩了几个坑,才真正搞明白它们的威力。
这三个东西——字符串化(#)、标记粘贴(##)、可变参数宏(__VA_ARGS__)——是预处理阶段的“瑞士军刀”。用好了,代码能精简到令人发指;用不好,编译报错能让你怀疑人生。
1. # 运算符:把参数变成字符串
# 的作用很简单:把宏参数原封不动地转换成字符串字面量。说白了,就是给参数加个双引号。
举个例子,你写了个调试宏:
#define PRINT_INT(x) printf(#x " = %d\n", x)
int a = 42;
PRINT_INT(a); // 展开为 printf("a" " = %d\n", a);
// 输出:a = 42
你看,#x 变成了 "a"。这里有个细节:相邻字符串字面量会被自动拼接,所以 "a" " = %d\n" 等价于 "a = %d\n"。
核心用途:在日志、断言、调试信息中,自动打印变量名和值。不用手动写字符串,省事又准确。
我在项目中遇到过这样一个场景:需要记录每个函数入口的参数值。如果手动写 printf("param1 = %d, param2 = %f", p1, p2),参数一多就烦了。用 # 可以这样:
#define LOG_PARAM(name, fmt, value) \
printf(#name " = " fmt "\n", value)
LOG_PARAM(temperature, "%.2f", 36.5);
// 输出:temperature = 36.50
嗯,这里要注意:# 只能用于带参数的宏,而且它不会展开参数中的其他宏。比如:
#define STR(x) #x
#define VALUE 100
STR(VALUE) // 结果是 "VALUE",不是 "100"
为什么会这样?因为 # 是在参数替换之前执行的。它看到的是 VALUE 这个标记,而不是展开后的 100。如果你想要展开后的结果,需要加一层间接宏:
#define STR_HELPER(x) #x
#define STR(x) STR_HELPER(x)
STR(VALUE) // 结果是 "100"
这个技巧,我在写编译期版本号字符串时经常用。
2. ## 运算符:把两个标记粘在一起
## 的作用是把左右两个标记合并成一个新的标记。听起来有点抽象,看代码就明白了:
#define CONCAT(a, b) a ## b
int xy = 10;
CONCAT(x, y) = 20; // 展开为 xy = 20;
你想想看,这能干什么?最常见的用途是生成变量名、函数名、结构体成员名。
我记得有一次写一个状态机,需要为每个状态定义处理函数。如果手动写 state_idle()、state_busy()、state_error()…… 那代码量就大了。用 ## 可以这样:
#define STATE_HANDLER(name) void state_ ## name(void)
STATE_HANDLER(idle) { /* 空闲处理 */ }
STATE_HANDLER(busy) { /* 忙碌处理 */ }
STATE_HANDLER(error) { /* 错误处理 */ }
展开后就是三个独立的函数定义。代码整洁,而且改起来方便——只需要改宏定义,所有函数签名自动更新。
注意:## 生成的新标记必须是合法的 C 标识符。你不能把数字和字母粘成非法名字,比如 1 ## 2 会生成 12,这在 C 里是合法的整数常量,但如果你把它当变量名用,编译器会报错。
另外,## 有一个“副作用”:它会移除参数前后的空白。这个特性在某些场景下很有用,比如你想在宏里拼接字符串:
#define STRINGIFY(x) #x
#define PASTE(a, b) a ## b
// 注意:## 不能直接用于字符串拼接
// 但可以结合 # 使用
#define MAKE_STR(a, b) STRINGIFY(a ## b)
MAKE_STR(hello, world) // 结果是 "helloworld"
我个人习惯在写寄存器映射时大量使用 ##。比如定义一组同类型的外设寄存器:
#define REG(base, offset) (*(volatile uint32_t *)((base) + (offset)))
#define UART_REG(uart, reg) REG(UART ## uart ## _BASE, UART_ ## reg)
// 使用
UART_REG(1, DR) // 展开为 REG(UART1_BASE, UART_DR)
UART_REG(2, SR) // 展开为 REG(UART2_BASE, UART_SR)
这样写,代码的可维护性高了很多。如果以后芯片升级,UART 基地址变了,只需要改一个宏定义。
3. 可变参数宏:__VA_ARGS__
C99 标准引入了可变参数宏,允许宏接受不定数量的参数。语法是这样的:
#define DEBUG_PRINT(fmt, ...) printf(fmt, __VA_ARGS__)
DEBUG_PRINT("value = %d\n", 42);
DEBUG_PRINT("name = %s, age = %d\n", "Alice", 30);
__VA_ARGS__ 代表所有可变参数,在展开时原样替换进去。
这里有个坑:如果可变参数为空,比如 DEBUG_PRINT("hello\n"),展开后变成 printf("hello\n", )——多了一个逗号,编译会报错。
GCC 和 Clang 提供了一个扩展来解决这个问题:##__VA_ARGS__。当可变参数为空时,它会自动删除前面的逗号:
#define DEBUG_PRINT(fmt, ...) printf(fmt, ##__VA_ARGS__)
DEBUG_PRINT("hello\n"); // 展开为 printf("hello\n");
DEBUG_PRINT("val = %d\n", 42); // 展开为 printf("val = %d\n", 42);
这个 ## 的用法和前面讲的标记粘贴不同——它在这里是 GNU 扩展,作用是“如果后面的参数为空,就删除前面的逗号”。
小技巧:如果你写跨平台代码,建议用条件编译来处理这个差异:
#ifdef __GNUC__
#define DEBUG_PRINT(fmt, ...) printf(fmt, ##__VA_ARGS__)
#else
#define DEBUG_PRINT(fmt, ...) printf(fmt, __VA_ARGS__)
#endif
我在嵌入式项目中,经常用可变参数宏来封装日志系统。比如:
#define LOG_DEBUG(fmt, ...) \
do { \
printf("[DEBUG] %s:%d: ", __FILE__, __LINE__); \
printf(fmt, ##__VA_ARGS__); \
printf("\n"); \
} while(0)
LOG_DEBUG("Sensor read failed, err = %d", errno);
// 输出:[DEBUG] sensor.c:42: Sensor read failed, err = 5
这样,调试信息里自动包含了文件名和行号,定位问题快多了。
4. 三者结合:一个实战例子
把 #、##、__VA_ARGS__ 组合起来,能写出非常强大的宏。下面是我在一个通信协议栈里用过的:
#define REGISTER_COMMAND(cmd, handler) \
static void handler(void); \
const command_t __cmd_ ## handler = { \
.name = #cmd, \
.func = handler \
}; \
__attribute__((constructor)) \
static void __reg_ ## handler(void) { \
command_register(&__cmd_ ## handler); \
}
// 使用
REGISTER_COMMAND(help, cmd_help);
REGISTER_COMMAND(version, cmd_version);
这个宏做了几件事:
- 用
##生成唯一的变量名和注册函数名 - 用
#把命令名转成字符串,存入结构体 - 用
__attribute__((constructor))让注册函数在 main 之前自动执行
你想想看,如果没有这些特殊运算符,你得手动写多少重复代码?每个命令都要写一遍声明、定义、注册,还容易漏掉。
5. 避坑指南
我曾经在代码里写过这样的宏:
#define BAD_MACRO(x) # x ## _suffix
结果编译报错。为什么?因为 # 和 ## 的优先级问题。# 只能作用于参数,而 ## 可以作用于任意标记。正确的写法是:
#define GOOD_MACRO(x) GOOD_MACRO_HELPER(x)
#define GOOD_MACRO_HELPER(x) #x ## _suffix
// 或者更直接:
#define GOOD_MACRO2(x) #x "_suffix"
另外,## 不能用于生成预处理指令。比如你不能这样:
#define MAKE_INCLUDE(file) #include ## file // 错误!
预处理指令是在宏展开之前处理的,所以 ## 生成的 #include 不会被识别。
总结一下三个原则:
#只能用于宏参数,且不展开参数中的宏##生成的新标记必须是合法的标识符或数字- 可变参数宏的空参数问题,用
##__VA_ARGS__解决(GCC/Clang)
6. 知识体系图
下面这张图帮你理清这三个运算符的关系和典型应用场景:
这三个运算符,说白了就是预处理阶段的“元编程”工具。它们让你在编译之前就能生成代码、转换格式、处理可变参数。用好了,代码的抽象能力和复用性会上一个台阶。
我个人建议,在项目中优先用 # 做日志和断言,用 ## 做代码生成,用 __VA_ARGS__ 做可变参数封装。这三个组合起来,能解决很多重复劳动。
公众号:蓝海资料掘金营,微信deep3321