# 和 ## 运算符:字符串化与标记粘贴

各位嵌入式开发者,今天我们来聊聊预处理中两个非常实用、但也容易让人困惑的运算符——###

说实话,我刚入行那会儿,看到这两个符号在宏定义里出现,第一反应是「这什么鬼?」。后来在项目中踩过几次坑,才真正体会到它们的威力。今天我就把这两把「瑞士军刀」的用法掰开揉碎了讲给你听。

一、# 运算符:字符串化操作符

# 的作用很简单:把宏参数变成字符串字面量。说白了,就是给参数加一对双引号。

来看个最基础的例子:

#define STR(x)   #x

printf("%s\n", STR(hello));   // 输出: hello
printf("%s\n", STR(123));     // 输出: 123

你可能会问:「这有啥用?我直接写字符串不就行了?」

嗯,别急。在实际项目中,它的价值体现在调试信息输出错误处理上。我举个例子:

#define ASSERT(cond) \
    do { \
        if (!(cond)) { \
            printf("断言失败: %s, 文件: %s, 行号: %d\n", \
                   #cond, __FILE__, __LINE__); \
            abort(); \
        } \
    } while(0)

// 使用
int value = 5;
ASSERT(value > 10);
// 输出: 断言失败: value > 10, 文件: test.c, 行号: 42

看到没?#cond 把条件表达式原封不动地变成了字符串。这样你一眼就能看出是哪个条件没通过,不用再去翻代码。

我的习惯:在调试宏中,我通常会把参数名、参数值、表达式一起打印出来。比如:
#define DEBUG_VAR(var) \
    printf(#var " = %d [%s:%d]\n", var, __FILE__, __LINE__)
这样调试信息一目了然。

二、## 运算符:标记粘贴操作符

## 的作用是把两个标记「粘」在一起,形成一个新的标记。它不能出现在宏定义的末尾,也不能单独使用——必须左右都有东西。

我最常用的场景是生成变量名函数名。比如:

#define MAKE_VAR(name, index)  name##_##index

int MAKE_VAR(temp, 1) = 10;   // 展开为: int temp_1 = 10;
int MAKE_VAR(temp, 2) = 20;   // 展开为: int temp_2 = 20;

你想想看,如果有一组结构体需要注册回调函数,用 ## 能省多少重复代码:

#define REGISTER_DRIVER(type) \
    static int type##_init(void); \
    static int type##_deinit(void); \
    static struct driver_ops type##_ops = { \
        .init = type##_init, \
        .deinit = type##_deinit, \
    };

REGISTER_DRIVER(uart)   // 生成 uart_init, uart_deinit, uart_ops
REGISTER_DRIVER(spi)    // 生成 spi_init, spi_deinit, spi_ops
REGISTER_DRIVER(i2c)    // 生成 i2c_init, i2c_deinit, i2c_ops

我在做嵌入式驱动框架时,就是用这种方式统一了所有外设的注册接口。后来团队新来的同事看到这段代码,第一反应是「这代码是自动生成的吗?」——其实就是一个宏搞定的。

三、# 和 ## 的联合使用

这两个运算符可以组合使用,威力更大。来看一个我项目中实际用过的例子:

#define LOG_ERROR(mod, fmt, ...) \
    do { \
        printf("[ERROR][%s] " fmt "\n", #mod, ##__VA_ARGS__); \
    } while(0)

LOG_ERROR(uart, "发送超时, 重试次数: %d", retry);
// 输出: [ERROR][uart] 发送超时, 重试次数: 3

这里 #mod 把模块名变成字符串,##__VA_ARGS__ 是 GNU C 的扩展用法——当可变参数为空时,它会「吃掉」前面的逗号,避免编译错误。

我曾经踩过的坑:
  • ## 不能生成注释标记(///* */),预处理器在宏展开前已经处理了注释
  • ## 不能生成预处理指令(如 #include#define),这些指令在宏展开前已经执行
  • 嵌套宏中,### 的展开顺序有讲究——先展开参数,再应用 ###

四、知识体系总览

下面这张图帮你理清这两个运算符的核心逻辑:

# 和 ## 运算符知识体系 预处理运算符 # 字符串化操作符 将宏参数转换为字符串字面量 典型应用:断言、日志、调试宏 注意:不能用于注释或预处理指令 ## 标记粘贴操作符 将两个标记拼接成一个新标记 典型应用:生成变量名、函数名 注意:不能位于宏定义末尾 联合使用可实现强大的代码生成与调试功能

五、实战中的注意事项

讲完了原理,我再分享几个实战中容易踩的坑:

  1. 宏参数展开顺序:如果宏参数本身也是宏,### 会先展开参数,再应用运算符。但如果你用 ### 阻止了展开,那就不会展开。这个细节容易让人困惑。
  2. 空格处理# 在生成字符串时,会保留参数中的空格,但会忽略参数前后的空格。比如 STR( hello world ) 会变成 "hello world"
  3. 与可变参数配合:GCC 的 ##__VA_ARGS__ 扩展很实用,但不是标准 C。如果你写跨平台代码,建议用 __VA_OPT__(C23 标准)。

我的建议:

  • 调试宏中多用 #,让日志信息更清晰
  • 驱动框架、注册表中多用 ##,减少重复代码
  • 两个运算符一起用时,先写小例子验证展开结果
  • 可以用 gcc -E 查看预处理后的代码,这是调试宏的利器

好了,关于 ### 的内容就讲到这里。这两个运算符虽然看起来简单,但用好了能让你的代码更简洁、更优雅。下次写调试宏或者驱动框架时,不妨试试它们。


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