5、#与##运算符:字符串化与标记粘贴的魔法

说实话,C语言里让我觉得最像「黑魔法」的,就是 ### 这两个预处理运算符。刚学的时候我觉得它们可有可无,直到我在一个嵌入式日志系统项目里被代码量逼疯了,才真正体会到这两个运算符的威力。

今天我们就来彻底搞懂它们。我会结合我踩过的坑,把这两个运算符的用法、原理和实战场景讲清楚。

5.1 字符串化运算符 #

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

看个最基础的例子:

#define STR(x)   #x

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

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

嗯,这里要注意——# 的真正价值在于:它能把宏参数的名字变成字符串,而不是参数的值。这在调试和日志场景下特别有用。

核心区别:

  • #x 得到的是参数名字的字符串
  • 直接写字符串得到的是固定文本

5.1.1 我在项目中遇到的典型场景

我曾经维护过一个老旧的嵌入式项目,里面充斥着这样的代码:

printf("temp = %d\n", temp);
printf("pressure = %d\n", pressure);
printf("speed = %d\n", speed);

每个变量都要写两遍:一遍是名字的字符串,一遍是变量本身。如果变量名改了,还得同步改字符串,特别容易漏掉。

# 改造后:

#define PRINT_VAR(var)  printf(#var " = %d\n", var)

PRINT_VAR(temp);     // 输出 "temp = 23"
PRINT_VAR(pressure); // 输出 "pressure = 1013"
PRINT_VAR(speed);    // 输出 "speed = 88"

你看,变量名只用写一次。宏展开时 #var 自动变成了字符串。我个人习惯把这种宏叫做「自描述打印宏」。

5.1.2 字符串化与拼接的细节

这里有个小坑:C语言会自动拼接相邻的字符串字面量。所以 #var " = %d\n" 会被编译器合并成一个字符串。这也是上面代码能工作的原因。

再来看一个更实用的例子——断言宏:

#define ASSERT(cond) \
    do { \
        if (!(cond)) { \
            printf("ASSERT failed: %s, file %s, line %d\n", \
                   #cond, __FILE__, __LINE__); \
            abort(); \
        } \
    } while(0)

ASSERT(ptr != NULL);
// 如果失败,输出:ASSERT failed: ptr != NULL, file main.c, line 42

避坑指南:我曾经在 # 后面跟了带空格的参数,结果字符串里也带了空格。比如 STR( hello ) 会变成 " hello "。所以建议宏参数不要加多余空格。

5.2 标记粘贴运算符 ##

## 的作用是把两个标记拼接成一个新的标记。它发生在预处理阶段,拼接后的结果必须是一个合法的C语言标记(变量名、函数名、类型名等)。

基础语法:

#define CONCAT(a, b)  a ## b

int xy = 100;
printf("%d\n", CONCAT(x, y));  // 输出 100

宏展开后 CONCAT(x, y) 变成了 xy,也就是那个变量名。

5.2.1 为什么需要 ##?

你可能会想:我直接写 xy 不就行了?何必多此一举?

原因很简单:当名字需要动态生成时,## 就是唯一的选择。比如你要生成一系列结构相似的函数或变量名。

我记得有一次需要为多个外设驱动编写初始化函数:

#define INIT_DEVICE(name) \
    void init_ ## name(void) { \
        printf("Initializing %s device...\n", #name); \
        /* 设备初始化代码 */ \
    }

INIT_DEVICE(uart)   // 生成 void init_uart(void)
INIT_DEVICE(spi)    // 生成 void init_spi(void)
INIT_DEVICE(i2c)    // 生成 void init_i2c(void)

你看,一个宏定义就生成了三个不同的函数。如果没有 ##,你得手动写三遍几乎一样的函数体。

5.3 实用案例:日志宏

### 结合起来,就能做出非常强大的日志系统。这是我个人最常用的组合。

5.3.1 带级别的日志宏

#define LOG(level, fmt, ...) \
    printf("[%s] " fmt "\n", #level, ##__VA_ARGS__)

LOG(INFO, "System started, version %d", 1);
LOG(WARN, "Temperature high: %d°C", temp);
LOG(ERROR, "Sensor %d failed", sensor_id);

输出效果:

[INFO] System started, version 1
[WARN] Temperature high: 85°C
[ERROR] Sensor 3 failed

这里有个关键点:##__VA_ARGS__ 中的 ## 是GCC扩展,作用是当可变参数为空时,删除前面的逗号。这样你写 LOG(INFO, "Hello") 也不会编译报错。

注意:标准C的可变参数宏不支持 ## 删除逗号。如果你的编译器不支持GCC扩展,可以用 __VA_OPT__(C23标准)或者手动处理。

5.3.2 带文件名和行号的日志

#define LOG_DEBUG(fmt, ...) \
    printf("[DEBUG] %s:%d: " fmt "\n", \
           __FILE__, __LINE__, ##__VA_ARGS__)

LOG_DEBUG("Entering function, param=%d", x);
// 输出:[DEBUG] main.c:42: Entering function, param=10

5.4 实用案例:通用数据结构

这是 ## 最硬核的应用——用宏实现泛型编程。C语言没有模板,但我们可以用宏+## 来模拟。

5.4.1 通用链表节点

#define DEFINE_LIST(type) \
    typedef struct list_ ## type { \
        type data; \
        struct list_ ## type *next; \
    } list_ ## type

DEFINE_LIST(int);    // 生成 list_int 结构体
DEFINE_LIST(float);  // 生成 list_float 结构体
DEFINE_LIST(char*);  // 生成 list_char_ptr 结构体

展开后相当于:

typedef struct list_int {
    int data;
    struct list_int *next;
} list_int;

typedef struct list_float {
    float data;
    struct list_float *next;
} list_float;
// ... 以此类推

5.4.2 通用函数生成

#define DEFINE_PUSH_FUNC(type) \
    void push_ ## type(list_ ## type **head, type value) { \
        list_ ## type *node = malloc(sizeof(list_ ## type)); \
        node->data = value; \
        node->next = *head; \
        *head = node; \
    }

DEFINE_PUSH_FUNC(int);    // 生成 push_int
DEFINE_PUSH_FUNC(float);  // 生成 push_float

使用起来就像模板一样:

list_int *head = NULL;
push_int(&head, 42);
push_int(&head, 100);

list_float *fhead = NULL;
push_float(&fhead, 3.14f);

我的经验:这种写法在小型嵌入式项目中特别实用。但要注意,宏展开后的代码量会翻倍,如果类型太多,编译后的二进制体积会变大。我一般控制在5种类型以内。

5.5 知识体系总览

下面这张图总结了 ### 的核心用法和关系:

# 与 ## 运算符知识体系 # 字符串化运算符 ## 标记粘贴运算符 参数→字符串 自描述打印 断言宏 拼接标记 生成函数名 生成类型名 组合应用:日志宏 + 通用数据结构 核心思想:用预处理阶段的文本操作,减少运行时开销和重复代码

5.6 避坑指南与最佳实践

最后,分享几个我这些年积累的经验:

  1. 不要嵌套太深。宏嵌套超过3层,调试时根本看不清展开结果。我一般控制在2层以内。
  2. 多用括号。宏参数和整个表达式都要加括号,避免运算符优先级问题。这是老生常谈了,但每次都能遇到有人踩坑。
  3. do { ... } while(0) 包裹多语句宏。这样宏在使用时就像普通函数一样,不会因为分号或 if 语句而出问题。
  4. 调试时用 -E 选项查看预处理结果。比如 gcc -E test.c,能看到宏展开后的真实代码。我曾经靠这个找到了一个隐藏了两周的宏嵌套bug。
  5. 命名规范要统一。我习惯用全大写命名宏,这样一眼就能看出是宏而不是函数。

我曾经踩过的一个坑:## 拼接时,如果拼接结果不是一个合法的C标记,编译器会报错。比如 CONCAT(123, abc) 会变成 123abc,这在C语言里不是合法的标识符(不能以数字开头)。所以一定要确保拼接结果符合C语言的命名规则。

好了,关于 ### 的内容就讲到这里。这两个运算符虽然看起来简单,但用好了能让你的代码简洁很多。下次写日志系统或者需要生成重复代码时,不妨试试它们。


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