一、宏与日志系统:从基础到实战

日志系统,说白了就是给程序装个「黑匣子」。我做了十几年嵌入式开发,最怕的就是产品在现场出问题,又没法接调试器。这时候,一个好的日志系统就是救命稻草。

今天我们来聊聊怎么用C语言的宏,打造一个轻量级日志库。别小看这个主题,它把可变参数宏、预处理指令、条件编译这些知识点全串起来了。

1.1 可变参数宏:__VA_ARGS__ 的妙用

先看个基础。标准C99引入了可变参数宏,语法很简单:

#define LOG(format, ...)  printf("[LOG] " format, ##__VA_ARGS__)

这里有个细节——##。它的作用是:当可变参数为空时,删除前面的逗号。我刚开始用的时候没加这个,结果每次调用LOG("hello")都会编译报错,因为多了一个逗号。

核心要点:

  • __VA_ARGS__ 代表省略号对应的参数列表
  • ##__VA_ARGS__ 是GCC扩展,解决空参数时的逗号问题
  • 标准C99写法是 __VA_OPT__(, ),但嵌入式编译器支持度不一

举个例子:

LOG("温度: %.1f°C", 25.3);   // 输出: [LOG] 温度: 25.3°C
LOG("系统启动");              // 输出: [LOG] 系统启动

1.2 日志级别控制:该输出什么,我说了算

实际项目中,日志不能全量输出。调试阶段要详细,发布阶段只要错误。这就需要用宏来控制级别。

我一般这样定义:

#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

// 当前日志级别,可以在编译时指定
#ifndef CURRENT_LOG_LEVEL
#define CURRENT_LOG_LEVEL  LOG_LEVEL_DEBUG
#endif

#define LOG_ERROR(format, ...) \
    do { \
        if (CURRENT_LOG_LEVEL >= LOG_LEVEL_ERROR) \
            printf("[ERROR] " format, ##__VA_ARGS__); \
    } while(0)

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

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

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

我的习惯:发布版本把 CURRENT_LOG_LEVEL 设为 LOG_LEVEL_ERROR,这样只有错误日志会输出。调试版本设为 LOG_LEVEL_DEBUG,所有信息都看得到。

1.3 文件行号自动注入:__FILE__ 和 __LINE__

光有日志内容还不够。出问题了你得知道是哪个文件、哪行代码打的日志。C语言预定义了两个宏:__FILE____LINE__

把它们加进去:

#define LOG_ERROR(format, ...) \
    do { \
        if (CURRENT_LOG_LEVEL >= LOG_LEVEL_ERROR) \
            printf("[ERROR] [%s:%d] " format, __FILE__, __LINE__, ##__VA_ARGS__); \
    } while(0)

输出效果:

[ERROR] [main.c:42] 传感器读取失败,错误码: 0x03

嗯,这里要注意:__FILE__ 是完整路径,嵌入式项目里路径可能很长。我一般用 __FILE__ 的最后一个文件名部分,或者用 __BASE_FILE__(如果编译器支持)。

1.4 实战:轻量级日志库

好了,我们把上面的知识点整合起来,写一个完整的日志库。这个库我用了好几年,在多个STM32和ESP32项目上验证过。

先看头文件 log.h

#ifndef __LOG_H__
#define __LOG_H__

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

// 日志级别
typedef enum {
    LOG_LEVEL_NONE  = 0,
    LOG_LEVEL_ERROR = 1,
    LOG_LEVEL_WARN  = 2,
    LOG_LEVEL_INFO  = 3,
    LOG_LEVEL_DEBUG = 4
} log_level_t;

// 默认日志级别
#ifndef LOG_LEVEL
#define LOG_LEVEL LOG_LEVEL_DEBUG
#endif

// 获取文件名(去掉路径)
#define __FILENAME__ (strrchr(__FILE__, '/') ? strrchr(__FILE__, '/') + 1 : __FILE__)

// 核心日志宏
#define LOG(level, tag, format, ...) \
    do { \
        if (level <= LOG_LEVEL) { \
            printf("[%s] [%s] [%s:%d] " format "\r\n", \
                   log_level_str(level), \
                   tag, \
                   __FILENAME__, \
                   __LINE__, \
                   ##__VA_ARGS__); \
        } \
    } while(0)

// 便捷宏
#define LOGE(tag, format, ...) LOG(LOG_LEVEL_ERROR, tag, format, ##__VA_ARGS__)
#define LOGW(tag, format, ...) LOG(LOG_LEVEL_WARN,  tag, format, ##__VA_ARGS__)
#define LOGI(tag, format, ...) LOG(LOG_LEVEL_INFO,  tag, format, ##__VA_ARGS__)
#define LOGD(tag, format, ...) LOG(LOG_LEVEL_DEBUG, tag, format, ##__VA_ARGS__)

// 级别转字符串
static inline const char* log_level_str(log_level_t level) {
    switch(level) {
        case LOG_LEVEL_ERROR: return "ERROR";
        case LOG_LEVEL_WARN:  return "WARN ";
        case LOG_LEVEL_INFO:  return "INFO ";
        case LOG_LEVEL_DEBUG: return "DEBUG";
        default:              return "UNKN ";
    }
}

#endif // __LOG_H__

使用示例:

#include "log.h"

#define TAG "MAIN"

int main() {
    LOGE(TAG, "系统启动失败,错误码: %d", -1);
    LOGW(TAG, "电池电量低: %.1f%%", 15.2);
    LOGI(TAG, "WiFi已连接,IP: %s", "192.168.1.100");
    LOGD(TAG, "当前堆栈使用: %u bytes", 2048);
    
    return 0;
}

输出效果:

[ERROR] [MAIN] [main.c:5] 系统启动失败,错误码: -1
[WARN ] [MAIN] [main.c:6] 电池电量低: 15.2%
[INFO ] [MAIN] [main.c:7] WiFi已连接,IP: 192.168.1.100
[DEBUG] [MAIN] [main.c:8] 当前堆栈使用: 2048 bytes

避坑指南:我曾经在某个项目里把 do { ... } while(0) 写成了 { ... },结果在 if-else 语句里用日志宏时,else 匹配出了问题。记住,do-while(0) 是宏定义的标准写法,能保证宏展开后语法正确。

1.5 知识体系总览

下面这张图把本章的核心知识点串起来了:

宏与日志系统 · 知识体系 轻量级日志库 可变参数宏 __VA_ARGS__ ##__VA_ARGS__ 去逗号 __VA_OPT__ (C23标准) 日志级别控制 ERROR / WARN / INFO / DEBUG 条件编译控制输出 发布版只保留 ERROR 文件行号注入 __FILE__ 文件名 __LINE__ 行号 __DATE__ / __TIME__ 实战应用 do-while(0) 安全宏 TAG 标签分类 嵌入式资源优化

1.6 进阶技巧:性能与资源考量

嵌入式环境资源有限,日志系统不能太「重」。我总结了几条经验:

问题 解决方案 说明
字符串占用ROM 使用 #if 编译时裁剪 不用的日志级别,连字符串都不编译进去
printf 太慢 改用 fputs 或自定义输出 printf 解析格式串开销大,批量日志时明显
日志缓冲区溢出 环形缓冲区 + 中断安全 我遇到过日志把任务栈撑爆的情况
时间戳精度 使用系统滴答计数器 不用RTC,用 HAL_GetTick()clock()

我的建议:别在中断服务函数里直接调用日志宏。中断里应该只做标记,把日志输出放到任务级处理。否则一旦日志输出阻塞,整个系统就挂了。

好了,这一章的内容就到这里。日志系统看似简单,但用好宏定义能让它既灵活又高效。下一章我们聊聊条件编译的更多玩法,到时候见。


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