9、#define 可变参数宏:__VA_ARGS__、##__VA_ARGS__、自定义日志宏

可变参数宏,说白了就是让宏定义能接受不定数量的参数。这个特性在C99标准里就加入了,但我发现很多工程师用了好几年C语言,居然还不知道它的存在。嗯,今天我们就把它彻底讲透。

9.1 什么是可变参数宏?

先看一个最简单的例子。假设你想写一个调试打印宏,能像printf那样传任意多个参数:

#define DEBUG_PRINT(fmt, ...)  printf("[DEBUG] " fmt, __VA_ARGS__)

这里的 ... 表示可变参数,__VA_ARGS__ 就是这些参数的实际内容。调用时:

DEBUG_PRINT("x = %d, y = %d\n", x, y);
// 展开为:printf("[DEBUG] " "x = %d, y = %d\n", x, y);

我个人习惯把 __VA_ARGS__ 理解为「占位符的替身」。你传了多少个参数,它就原封不动地替换成多少个。

9.2 一个坑:空参数时的逗号问题

这里有个经典问题。如果你只传格式化字符串,不传额外参数:

DEBUG_PRINT("hello\n");  // 没有额外参数

展开后变成什么?

printf("[DEBUG] " "hello\n", );  // 注意末尾多了一个逗号!

多出来的逗号会导致编译错误。我在项目中第一次遇到这个问题时,排查了整整一个下午。后来才知道,这是可变参数宏的「祖传缺陷」。

9.3 解决方案:##__VA_ARGS__

GCC和Clang提供了一个扩展语法——##__VA_ARGS__。它的作用很简单:如果可变参数为空,就把前面的逗号也吃掉。

#define DEBUG_PRINT(fmt, ...)  printf("[DEBUG] " fmt, ##__VA_ARGS__)

DEBUG_PRINT("hello\n");       // 正常:printf("[DEBUG] hello\n");
DEBUG_PRINT("x = %d\n", x);   // 正常:printf("[DEBUG] x = %d\n", x);

注意,##__VA_ARGS__ 不是标准C,而是GNU扩展。不过好消息是,几乎所有嵌入式编译器(GCC、ARMCC、IAR、Clang)都支持它。如果你非要追求标准兼容性,可以用另一种写法:

// C99标准兼容写法:必须至少传一个参数
#define DEBUG_PRINT(fmt, ...)  printf("[DEBUG] " fmt, __VA_ARGS__)
// 调用时必须传至少两个参数
DEBUG_PRINT("hello\n", 0);     // 多传一个无用的0

我个人觉得,在嵌入式开发中,直接用 ##__VA_ARGS__ 更省事。毕竟我们用的编译器基本都支持。

9.4 自定义日志宏:实战案例

好了,理论讲完,我们来写一个真正能用的日志系统。这是我多年项目经验的总结,你直接拿去用就行。

#include <stdio.h>
#include <time.h>

// 日志级别
#define LOG_LEVEL_NONE     0
#define LOG_LEVEL_ERROR    1
#define LOG_LEVEL_WARN     2
#define LOG_LEVEL_INFO     3
#define LOG_LEVEL_DEBUG    4

// 当前日志级别,可以在编译时通过-D选项设置
#ifndef CURRENT_LOG_LEVEL
#define CURRENT_LOG_LEVEL  LOG_LEVEL_DEBUG
#endif

// 获取时间戳(简化版)
#define GET_TIMESTAMP()  \
    do { \
        time_t t = time(NULL); \
        struct tm *tm_info = localtime(&t); \
        printf("%02d:%02d:%02d ", tm_info->tm_hour, \
               tm_info->tm_min, tm_info->tm_sec); \
    } while(0)

// 核心日志宏
#define LOG_ERROR(fmt, ...)  \
    do { \
        if (CURRENT_LOG_LEVEL >= LOG_LEVEL_ERROR) { \
            GET_TIMESTAMP(); \
            printf("[ERROR] " fmt "\r\n", ##__VA_ARGS__); \
        } \
    } while(0)

#define LOG_WARN(fmt, ...)   \
    do { \
        if (CURRENT_LOG_LEVEL >= LOG_LEVEL_WARN) { \
            GET_TIMESTAMP(); \
            printf("[WARN]  " fmt "\r\n", ##__VA_ARGS__); \
        } \
    } while(0)

#define LOG_INFO(fmt, ...)   \
    do { \
        if (CURRENT_LOG_LEVEL >= LOG_LEVEL_INFO) { \
            GET_TIMESTAMP(); \
            printf("[INFO]  " fmt "\r\n", ##__VA_ARGS__); \
        } \
    } while(0)

#define LOG_DEBUG(fmt, ...)  \
    do { \
        if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG) { \
            GET_TIMESTAMP(); \
            printf("[DEBUG] " fmt "\r\n", ##__VA_ARGS__); \
        } \
    } while(0)

使用示例:

int main() {
    int temperature = 85;
    const char *sensor = "LM75";
    
    LOG_INFO("系统启动完成");
    LOG_DEBUG("传感器 %s 当前温度: %d°C", sensor, temperature);
    LOG_WARN("温度偏高,建议检查散热");
    LOG_ERROR("传感器 %s 通信超时!", sensor);
    
    return 0;
}

输出效果:

14:23:45 [INFO]  系统启动完成
14:23:45 [DEBUG] 传感器 LM75 当前温度: 85°C
14:23:45 [WARN]  温度偏高,建议检查散热
14:23:45 [ERROR] 传感器 LM75 通信超时!

为什么用 do { ... } while(0) 包裹?

这是为了防止宏在 if-else 语句中出错。比如:

if (error)
    LOG_ERROR("出错了");
else
    do_something();

如果没有 do-while 包裹,宏展开后可能破坏 if-else 的结构。这是C语言宏定义的老生常谈,但每次都能坑到新人。

9.5 进阶技巧:带参数个数的日志宏

有时候你想知道当前日志调用传了几个参数。可以用一个小技巧:

#define VA_NUM_ARGS(...)  \
    VA_NUM_ARGS_IMPL(__VA_ARGS__, 5, 4, 3, 2, 1)
#define VA_NUM_ARGS_IMPL(a, b, c, d, e, N, ...)  N

// 测试
VA_NUM_ARGS(x)           // 返回 1
VA_NUM_ARGS(x, y)        // 返回 2
VA_NUM_ARGS(x, y, z)     // 返回 3

这个技巧的原理是利用了宏展开时的参数计数。不过说实话,我在实际项目中很少用到这个,因为大多数情况下我们不需要知道参数个数。但如果你在写一些高级的日志框架,可能会用到。

9.6 避坑指南

我曾经在一个项目里犯过一个低级错误——在日志宏里用了可变参数,但忘了加 ##。结果某个模块的日志调用恰好没有传额外参数,编译直接报错。当时项目赶进度,我硬是花了两个小时才定位到问题。

所以我的建议是:

  • 默认加上 ##:除非你明确知道所有调用都会传至少一个额外参数
  • 用 do-while 包裹:避免宏在复杂控制流中出问题
  • 日志级别用宏常量:不要用枚举,因为枚举在预处理阶段不可用
  • 加换行符:嵌入式串口输出通常需要 \r\n 才能正确换行

注意:不要在日志宏里做复杂计算

比如:LOG_DEBUG("value = %d", expensive_function());

即使日志级别被关闭,expensive_function() 仍然会被调用!因为宏只是条件判断,参数表达式在调用前就已经求值了。

解决方案:要么确保函数没有副作用,要么用更高级的编译期判断。

9.7 知识体系图

下面这张图总结了可变参数宏的核心知识点和它们之间的关系:

可变参数宏知识体系 可变参数宏 __VA_ARGS__ ##__VA_ARGS__ do { } while(0) 标准C99语法 • 直接替换可变参数 • 空参数时遗留逗号 • 所有编译器都支持 GNU扩展语法 • 空参数时吃掉逗号 • GCC/Clang/IAR支持 • 嵌入式开发推荐 安全包裹技巧 • 防止if-else结构破坏 • 确保宏语句完整性 • 必须加分号 典型应用:自定义日志系统、调试打印、断言宏 ⚠ 注意:参数表达式在宏调用时已求值,避免副作用

9.8 小结

可变参数宏是C语言预处理阶段最强大的特性之一。它让我们能写出灵活、可配置的日志系统,而且完全零运行时开销。你想想看,一个宏展开后就是几行条件判断和printf调用,比任何日志库都轻量。

我个人建议,每个嵌入式项目都应该有一套自己的日志宏。不需要多复杂,能控制日志级别、带时间戳、支持可变参数就够了。上面给的代码模板,你直接复制到项目里,改改输出格式就能用。

小技巧:如果你在调试时想临时关闭所有日志,只需把 CURRENT_LOG_LEVEL 定义为 LOG_LEVEL_NONE。所有日志宏的条件判断在编译期就会被优化掉,生成的代码里不会有任何日志痕迹。

好了,可变参数宏就讲到这里。记住三个关键点:__VA_ARGS__ 是标准语法,##__VA_ARGS__ 解决空参数问题,do-while 保证宏安全。下次写日志系统时,试试看能不能一次写对,别再被逗号坑了。


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