宏中的特殊运算符:#运算符、##运算符、可变参数宏

这一节,我们来聊聊宏定义里三个比较“高级”的玩法。说实话,我刚入行那会儿,看到同事代码里出现 ### 这两个符号,第一反应是“这什么鬼?” 后来自己啃了几次源码,踩了几个坑,才真正搞明白它们的威力。

这三个东西——字符串化(#)标记粘贴(##)可变参数宏(__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__ 可变参数 参数 → 字符串字面量 不展开参数中的宏 相邻字符串自动拼接 左右标记合并为新标记 生成变量名/函数名 移除参数前后空白 接受不定数量参数 ##__VA_ARGS__ 去逗号 C99标准引入 典型应用场景 日志/断言/调试信息 寄存器映射/状态机 日志系统封装 ⚠ 注意:## 不能生成预处理指令;# 不展开参数中的宏

这三个运算符,说白了就是预处理阶段的“元编程”工具。它们让你在编译之前就能生成代码、转换格式、处理可变参数。用好了,代码的抽象能力和复用性会上一个台阶。

我个人建议,在项目中优先用 # 做日志和断言,用 ## 做代码生成,用 __VA_ARGS__ 做可变参数封装。这三个组合起来,能解决很多重复劳动。


公众号:蓝海资料掘金营,微信deep3321