6、可变参数日志:使用stdarg.h实现类似printf的可变参数日志接口

说实话,日志系统做到这一步,基本功能都有了——时间戳、等级过滤、文件行号定位。但有个问题一直让我不太舒服:每次打印日志都得拼字符串,像这样:

LOG_INFO("温度: ");
LOG_INFO("%d", temperature);
LOG_INFO(" 度, 湿度: ");
LOG_INFO("%d", humidity);

你想想看,这多别扭?一条完整的信息被拆成好几段,日志文件里看着也乱。我当时在做一个数据采集项目,日志量特别大,这种写法直接导致日志可读性暴跌。后来我实在忍不了了,决定用C语言的可变参数机制,搞一个类似printf的日志接口。

6.1 可变参数的核心:stdarg.h

C标准库里的stdarg.h,说白了就是帮我们处理「参数个数不确定」的情况。它提供了几个宏:

  • va_list:声明一个变量,用来遍历参数列表
  • va_start:初始化va_list,告诉它从哪个参数开始取
  • va_arg:取出下一个参数,需要指定类型
  • va_end:清理工作,必须调用

嗯,这里要注意:va_start的第二个参数,必须是最后一个固定参数的名字。这个搞错了,程序直接崩。

6.2 第一个可变参数日志接口

我们先写一个最基础的版本:

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

void log_printf(const char *level, const char *fmt, ...) {
    va_list args;
    time_t now;
    struct tm *tm_info;
    char time_buf[20];

    // 获取当前时间
    time(&now);
    tm_info = localtime(&now);
    strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", tm_info);

    // 打印时间戳和等级
    printf("[%s] [%s] ", time_buf, level);

    // 处理可变参数
    va_start(args, fmt);
    vprintf(fmt, args);
    va_end(args);

    // 换行
    printf("\n");
}

// 使用示例
int main() {
    int temperature = 25;
    int humidity = 60;

    log_printf("INFO", "传感器数据: 温度=%d°C, 湿度=%d%%", 
               temperature, humidity);

    return 0;
}

这里的关键是vprintf——它是printf的可变参数版本,直接接受一个va_list。我个人习惯用vprintf而不是自己解析格式字符串,省心又安全。

6.3 整合到日志系统中

光有打印还不够,得把等级过滤、文件行号这些功能都加进去。我在项目中是这样做的:

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

typedef enum {
    LOG_DEBUG,
    LOG_INFO,
    LOG_WARN,
    LOG_ERROR
} log_level_t;

// 全局日志等级,低于这个等级的不输出
log_level_t g_log_level = LOG_DEBUG;

const char* level_to_string(log_level_t level) {
    switch(level) {
        case LOG_DEBUG: return "DEBUG";
        case LOG_INFO:  return "INFO";
        case LOG_WARN:  return "WARN";
        case LOG_ERROR: return "ERROR";
        default:        return "UNKNOWN";
    }
}

void log_message(log_level_t level, const char *file, 
                 int line, const char *fmt, ...) {
    if (level < g_log_level) {
        return;  // 等级不够,直接跳过
    }

    va_list args;
    time_t now;
    struct tm *tm_info;
    char time_buf[20];

    time(&now);
    tm_info = localtime(&now);
    strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", tm_info);

    // 输出到标准错误,方便重定向
    fprintf(stderr, "[%s] [%s] [%s:%d] ", 
            time_buf, level_to_string(level), file, line);

    va_start(args, fmt);
    vfprintf(stderr, fmt, args);
    va_end(args);

    fprintf(stderr, "\n");
}

// 为了方便使用,定义宏
#define LOG_DEBUG(fmt, ...) \
    log_message(LOG_DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_INFO(fmt, ...) \
    log_message(LOG_INFO, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_WARN(fmt, ...) \
    log_message(LOG_WARN, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...) \
    log_message(LOG_ERROR, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

注意宏定义里的##__VA_ARGS__——这是GCC的一个扩展,作用是:如果可变参数为空,自动去掉前面的逗号。比如你写LOG_INFO("hello"),展开后不会变成log_message(..., "hello", ),而是log_message(..., "hello")

⚠️ 平台兼容性提醒##__VA_ARGS__是GCC/Clang的扩展,MSVC不支持。如果你需要跨平台,可以用__VA_ARGS__,但空参数时会有逗号问题。我一般在嵌入式Linux上用GCC,所以没太纠结这个。

6.4 格式化缓冲区:避免频繁IO

直接调用vfprintf每次都会触发IO操作,在嵌入式系统里这是个大坑。我曾经在一个每秒打印几百条日志的项目里,发现CPU大部分时间都耗在IO上了。

解决方案是:先把格式化后的字符串存到缓冲区,然后一次性写入。用vsnprintf可以实现:

#define LOG_BUF_SIZE 256

void log_message_buffered(log_level_t level, const char *file, 
                          int line, const char *fmt, ...) {
    if (level < g_log_level) {
        return;
    }

    char buf[LOG_BUF_SIZE];
    va_list args;
    int len;
    int offset = 0;

    // 先写时间戳和等级
    time_t now = time(NULL);
    struct tm *tm_info = localtime(&now);
    offset = strftime(buf, sizeof(buf), "[%Y-%m-%d %H:%M:%S] ", tm_info);

    // 写等级和位置
    offset += snprintf(buf + offset, sizeof(buf) - offset, 
                       "[%s] [%s:%d] ", 
                       level_to_string(level), file, line);

    // 写用户消息
    va_start(args, fmt);
    len = vsnprintf(buf + offset, sizeof(buf) - offset, fmt, args);
    va_end(args);

    if (len >= sizeof(buf) - offset) {
        // 缓冲区不够,截断处理
        buf[sizeof(buf) - 1] = '\0';
    }

    // 一次性写入
    fputs(buf, stderr);
    fputc('\n', stderr);
}
💡 我的经验:缓冲区大小建议设为256或512字节。太小了容易截断,太大了浪费内存。如果你要打印特别长的消息,可以考虑动态分配,但嵌入式环境里我一般不用malloc,怕碎片。

6.5 可变参数日志的完整流程图

下面这张图展示了整个可变参数日志的调用流程:

可变参数日志调用流程 LOG_INFO("温度=%d", t) 宏展开 log_message(INFO, file, line, fmt, ...) 等级过滤 level >= g_log_level? vsnprintf 格式化 写入缓冲区 buf fputs(buf, stderr) 一次性IO输出 跳过 结束 用户调用 宏处理 条件判断 格式化 IO输出

6.6 避坑指南

可变参数看着简单,用起来坑不少。我踩过的几个:

  • va_start和va_end必须成对出现:漏掉va_end会导致栈损坏,尤其是在ARM Cortex-M这类平台上,后果很严重。
  • 不要多次调用va_start:如果你需要遍历参数列表两次,得先va_end再va_start。我见过有人想先算长度再格式化,结果va_arg取完一遍就没了。
  • 类型安全:va_arg需要你手动指定类型,传错了不会报错,但结果完全不对。比如你传了一个float,却用va_arg(args, int)去取,拿到的就是垃圾值。
  • 缓冲区溢出:vsnprintf比sprintf安全,但别忘了检查返回值。我曾经因为没检查,日志把栈给写穿了,查了好几天才找到原因。

核心要点:可变参数日志的核心就三步——va_start初始化、vprintf/vsnprintf格式化、va_end清理。加上等级过滤和缓冲区,就是一个生产可用的日志接口。别搞复杂了,够用就行。

好了,可变参数这块就聊到这儿。下一节我们聊聊日志的异步输出——在中断里打日志?那又是另一个故事了。


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