第4章:日志级别实现

日志级别这东西,说白了就是给日志信息贴个标签。我刚开始做嵌入式时,觉得这玩意儿可有可无——反正都是打印,分什么级别?直到有一次,产品量产前发现调试信息刷爆了串口缓冲区,导致关键数据丢失...嗯,从那以后,我再也不敢小看日志级别了。

4.1 为什么要分级别?

你想想看,一个嵌入式系统跑起来,可能同时产生几十种信息:

  • 系统启动时的初始化信息
  • 传感器采集到的原始数据
  • 某个函数执行了多久的调试信息
  • 内存分配失败的严重错误
  • 按键被按下的普通事件

如果所有信息都一股脑儿输出,调试时根本找不到重点。更可怕的是,大量日志输出会拖慢系统响应——我在一个STM32项目里就吃过这个亏,每秒输出几百条调试信息,结果中断响应延迟了3毫秒。

所以,我们需要给日志分个三六九等。常用的级别定义如下:

级别 宏定义 数值 说明
ERROR LOG_LEVEL_ERROR 1 致命错误,必须处理
WARNING LOG_LEVEL_WARN 2 异常情况,但系统可继续运行
INFO LOG_LEVEL_INFO 3 关键运行信息
DEBUG LOG_LEVEL_DEBUG 4 调试用详细信息
TRACE LOG_LEVEL_TRACE 5 最细粒度的跟踪信息

我个人习惯用数值大小来控制级别开关——数值越大,信息越详细。这样在发布版本时,只需要把日志级别设成2(WARNING),所有DEBUG和TRACE信息就自动消失了。

4.2 用宏定义实现级别控制

先看代码,再解释:

/* log_level.h */
#ifndef LOG_LEVEL_H
#define LOG_LEVEL_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
#define LOG_LEVEL_TRACE    5

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

/* 条件编译宏 */
#if CURRENT_LOG_LEVEL >= LOG_LEVEL_ERROR
#define LOG_ERROR(fmt, ...) \
    printf("[ERROR] " fmt "\r\n", ##__VA_ARGS__)
#else
#define LOG_ERROR(fmt, ...)
#endif

#if CURRENT_LOG_LEVEL >= LOG_LEVEL_WARN
#define LOG_WARN(fmt, ...) \
    printf("[WARN]  " fmt "\r\n", ##__VA_ARGS__)
#else
#define LOG_WARN(fmt, ...)
#endif

#if CURRENT_LOG_LEVEL >= LOG_LEVEL_INFO
#define LOG_INFO(fmt, ...) \
    printf("[INFO]  " fmt "\r\n", ##__VA_ARGS__)
#else
#define LOG_INFO(fmt, ...)
#endif

#if CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG
#define LOG_DEBUG(fmt, ...) \
    printf("[DEBUG] " fmt "\r\n", ##__VA_ARGS__)
#else
#define LOG_DEBUG(fmt, ...)
#endif

#if CURRENT_LOG_LEVEL >= LOG_LEVEL_TRACE
#define LOG_TRACE(fmt, ...) \
    printf("[TRACE] " fmt "\r\n", ##__VA_ARGS__)
#else
#define LOG_TRACE(fmt, ...)
#endif

#endif /* LOG_LEVEL_H */

这里有个关键点:##__VA_ARGS__ 是GCC的扩展语法,允许可变参数为空。如果你用标准C,可以改成 __VA_ARGS__,但空参数时会有逗号问题。我在IAR和Keil上都踩过这个坑——不同编译器对可变参数宏的处理不一样,移植时一定要测试。

4.3 条件编译的妙用

为什么用条件编译而不是运行时判断?原因很简单:

  • 零开销:不满足条件的日志宏,在预处理阶段就被删掉了,连函数调用都不会生成
  • 节省代码空间:发布版本中,所有DEBUG代码根本不存在
  • 安全性:敏感调试信息不会意外泄露

我曾经在一个资源紧张的Cortex-M0项目里,通过条件编译把日志代码从8KB压缩到1.2KB。你想想看,对于只有64KB Flash的芯片,这7KB空间能多放多少功能代码?

核心原则:发布版本只保留ERROR和WARN级别,开发版本可以开到DEBUG甚至TRACE。通过修改CURRENT_LOG_LEVEL的值,一键切换。

4.4 不同级别的输出格式

光有级别还不够,输出格式也得讲究。我见过太多日志长这样:

temp = 25
adc = 1024
error!

这种日志,三天后你自己都看不懂。好的日志格式应该包含:时间戳、级别标签、文件名、行号、函数名。看个例子:

/* 增强版日志宏 */
#define LOG_ERROR(fmt, ...) \
    do { \
        printf("[%lu][ERROR][%s:%d] " fmt "\r\n", \
               get_tick(), __FILE__, __LINE__, ##__VA_ARGS__); \
    } while(0)

#define LOG_INFO(fmt, ...) \
    do { \
        printf("[%lu][INFO ][%s:%d] " fmt "\r\n", \
               get_tick(), __FILE__, __LINE__, ##__VA_ARGS__); \
    } while(0)

输出效果:

[001234][ERROR][main.c:56] Sensor init failed!
[001235][INFO ][sensor.c:102] Temperature: 25.3°C

这里用 do { ... } while(0) 包裹宏,是为了防止在 if 语句中出错。嗯,这是C语言宏定义的老生常谈了,但确实重要。

个人建议:时间戳用系统滴答时钟(SysTick)的计数值,单位毫秒。这样既能看出事件顺序,又能计算时间间隔。我在调试一个电机控制算法时,就是靠时间戳发现某个函数执行时间波动太大,最终定位到中断优先级配置问题。

4.5 避坑指南

我曾经犯过一个低级错误:在头文件里直接写 printf,结果在中断服务函数里调用日志宏,导致系统死锁。后来我加了中断安全判断:

#define LOG_SAFE_PRINT(fmt, ...) \
    do { \
        if (is_in_isr()) { \
            /* 中断中只缓存,不直接输出 */ \
            log_buffer_push(fmt, ##__VA_ARGS__); \
        } else { \
            printf(fmt, ##__VA_ARGS__); \
        } \
    } while(0)

另外要注意:

  • 不要在日志宏里做复杂计算,比如 LOG_DEBUG("result = %d", heavy_function())——即使条件编译会删掉,但参数表达式仍然会被求值
  • 字符串常量尽量用 const 修饰,避免占用RAM
  • 不同级别的日志,建议用不同颜色输出(如果终端支持ANSI转义码)

4.6 本章知识体系

下面这张图,帮你理清日志级别的核心逻辑:

日志级别实现核心架构 级别定义层 #define LOG_LEVEL_ERROR 1 ... LOG_LEVEL_TRACE 5 条件编译控制层 #if CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG ... #endif 输出格式层 [时间戳][级别][文件名:行号] 消息内容 应用场景 开发版 → 全级别输出 | 发布版 → 仅ERROR/WARN

这张图展示了从级别定义到最终应用的完整链路。每一层都依赖上一层,环环相扣。我个人觉得,理解了这个架构,日志系统就算入门了。

重要提醒:条件编译虽然好用,但别滥用。如果一个日志宏在几十个文件里被调用,修改它的定义会导致全项目重新编译。我建议把日志宏单独放在一个头文件里,并且只在需要调试的模块中引用。

好了,日志级别的实现就讲到这里。记住:好的日志系统,不是打印得多,而是打印得巧。下一章我们会聊聊日志输出的实际接口——怎么把日志送到串口、文件或者网络上去。


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