第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 本章知识体系
下面这张图,帮你理清日志级别的核心逻辑:
这张图展示了从级别定义到最终应用的完整链路。每一层都依赖上一层,环环相扣。我个人觉得,理解了这个架构,日志系统就算入门了。
重要提醒:条件编译虽然好用,但别滥用。如果一个日志宏在几十个文件里被调用,修改它的定义会导致全项目重新编译。我建议把日志宏单独放在一个头文件里,并且只在需要调试的模块中引用。
好了,日志级别的实现就讲到这里。记住:好的日志系统,不是打印得多,而是打印得巧。下一章我们会聊聊日志输出的实际接口——怎么把日志送到串口、文件或者网络上去。
公众号:蓝海资料掘金营,微信deep3321