3、日志级别控制:通过宏定义控制日志输出级别,运行时动态调整日志级别
日志级别控制,说白了就是给日志信息贴个标签。什么信息该打印,什么信息该忽略,全凭这个标签说了算。我刚开始写嵌入式程序时,总觉得日志越多越好,结果调试信息刷屏,真正有用的错误信息反而被淹没了。后来才明白——没有级别控制的日志系统,就是一场灾难。
3.1 为什么需要日志级别?
你想想看,一个成熟的嵌入式系统,运行时的信息五花八门:
- 系统启动时的初始化信息
- 正常运行的周期性状态
- 偶尔出现的警告
- 必须处理的错误
- 还有那些只在调试阶段才关心的细节
如果全部打印出来,串口会被刷爆,Flash日志区很快写满,关键信息反而找不到了。所以我们需要给日志分个三六九等。
核心思想:日志级别就是一道过滤器。级别越高,信息越重要;级别越低,信息越琐碎。我们可以通过控制过滤器的阈值,来决定哪些信息能通过。
3.2 常见的日志级别定义
我个人习惯用5级制,从低到高分别是:
| 级别 | 宏定义 | 数值 | 含义 | 使用场景 |
|---|---|---|---|---|
| DEBUG | LOG_LEVEL_DEBUG | 0 | 调试信息 | 开发阶段,跟踪变量、函数调用 |
| INFO | LOG_LEVEL_INFO | 1 | 普通信息 | 系统状态、运行记录 |
| WARN | LOG_LEVEL_WARN | 2 | 警告 | 非致命异常,系统仍可运行 |
| ERROR | LOG_LEVEL_ERROR | 3 | 错误 | 功能失效,需要人工介入 |
| FATAL | LOG_LEVEL_FATAL | 4 | 致命错误 | 系统崩溃,必须复位 |
数值越小,级别越低。这样设计有个好处:我们只需要设置一个阈值,所有数值小于等于阈值的日志都会被输出。比如阈值设为2(WARN),那么DEBUG和INFO就被过滤掉了,只输出WARN及以上的信息。
3.3 编译时级别控制:宏定义大法
编译时控制,就是用宏定义在代码编译阶段决定哪些日志代码被保留。这是嵌入式系统最常用的方式——毕竟,去掉不需要的日志代码,能省下不少Flash空间和CPU时间。
先看一个经典实现:
/* log_level.h */
#ifndef LOG_LEVEL_H
#define LOG_LEVEL_H
/* 定义日志级别 */
#define LOG_LEVEL_DEBUG 0
#define LOG_LEVEL_INFO 1
#define LOG_LEVEL_WARN 2
#define LOG_LEVEL_ERROR 3
#define LOG_LEVEL_FATAL 4
/* 默认级别:INFO,可以在编译时通过 -D 选项覆盖 */
#ifndef CURRENT_LOG_LEVEL
#define CURRENT_LOG_LEVEL LOG_LEVEL_INFO
#endif
/* 日志输出宏 */
#define LOG_DEBUG(fmt, ...) \
do { \
if (LOG_LEVEL_DEBUG >= CURRENT_LOG_LEVEL) { \
printf("[DEBUG] " fmt "\r\n", ##__VA_ARGS__); \
} \
} while(0)
#define LOG_INFO(fmt, ...) \
do { \
if (LOG_LEVEL_INFO >= CURRENT_LOG_LEVEL) { \
printf("[INFO] " fmt "\r\n", ##__VA_ARGS__); \
} \
} while(0)
#define LOG_WARN(fmt, ...) \
do { \
if (LOG_LEVEL_WARN >= CURRENT_LOG_LEVEL) { \
printf("[WARN] " fmt "\r\n", ##__VA_ARGS__); \
} \
} while(0)
#define LOG_ERROR(fmt, ...) \
do { \
if (LOG_LEVEL_ERROR >= CURRENT_LOG_LEVEL) { \
printf("[ERROR] " fmt "\r\n", ##__VA_ARGS__); \
} \
} while(0)
#define LOG_FATAL(fmt, ...) \
do { \
if (LOG_LEVEL_FATAL >= CURRENT_LOG_LEVEL) { \
printf("[FATAL] " fmt "\r\n", ##__VA_ARGS__); \
} \
} while(0)
#endif /* LOG_LEVEL_H */
这段代码里,do { ... } while(0) 是个经典技巧。为什么要这么写?因为宏展开后,它仍然是一个完整的语句,不会跟周围的代码打架。我曾经见过有人直接写 #define LOG(x) if(...) x,结果在 if-else 结构里出了大问题——宏展开后语法就乱了。
小技巧:编译时通过 -DCURRENT_LOG_LEVEL=LOG_LEVEL_WARN 来覆盖默认级别。Release版本直接设成 LOG_LEVEL_ERROR,只保留错误和致命信息。Debug版本设成 LOG_LEVEL_DEBUG,所有信息全开。
3.4 运行时动态调整:更灵活的方式
编译时控制虽然省资源,但有个硬伤——改级别必须重新编译。产品交付后,现场工程师想临时打开调试信息?对不起,得重新烧固件。
所以,我建议在资源允许的情况下,加上运行时动态调整的能力。说白了,就是把日志级别阈值存到一个全局变量里,运行时通过命令或配置修改它。
/* log_level_runtime.h */
#ifndef LOG_LEVEL_RUNTIME_H
#define LOG_LEVEL_RUNTIME_H
/* 全局日志级别变量 */
extern int g_current_log_level;
/* 设置日志级别 */
void log_set_level(int level);
/* 获取当前日志级别 */
int log_get_level(void);
/* 日志输出宏(运行时版本) */
#define RLOG_DEBUG(fmt, ...) \
do { \
if (LOG_LEVEL_DEBUG >= g_current_log_level) { \
printf("[DEBUG] " fmt "\r\n", ##__VA_ARGS__); \
} \
} while(0)
#define RLOG_INFO(fmt, ...) \
do { \
if (LOG_LEVEL_INFO >= g_current_log_level) { \
printf("[INFO] " fmt "\r\n", ##__VA_ARGS__); \
} \
} while(0)
/* ... 其他级别类似 ... */
#endif
/* log_level_runtime.c */
#include "log_level_runtime.h"
/* 默认级别:INFO */
int g_current_log_level = LOG_LEVEL_INFO;
void log_set_level(int level) {
if (level < LOG_LEVEL_DEBUG) {
level = LOG_LEVEL_DEBUG;
}
if (level > LOG_LEVEL_FATAL) {
level = LOG_LEVEL_FATAL;
}
g_current_log_level = level;
printf("[LOG] 日志级别已设置为: %d\n", level);
}
int log_get_level(void) {
return g_current_log_level;
}
运行时控制的核心就两点:一个全局变量存级别,一个函数改级别。简单吧?但要注意,每次日志输出都要读这个全局变量,会多一次内存访问。在性能敏感的场景下,这个开销需要考虑。
注意:运行时控制虽然灵活,但所有日志代码都编译进了固件。如果Flash空间紧张,或者CPU性能有限,建议还是用编译时控制。我曾在某款Cortex-M0芯片上做过测试,运行时版本比编译时版本多占了约2KB的代码空间。
3.5 编译时 vs 运行时:如何选择?
这个问题没有标准答案,我根据自己的经验画了张决策图:
3.6 混合策略:两全其美的方案
其实,编译时和运行时不是非此即彼。我做过一个项目,用了混合策略:
- 编译时:通过宏
ENABLE_RUNTIME_LOG_LEVEL控制是否启用运行时调整功能 - 如果启用:日志级别存储在全局变量中,支持运行时修改
- 如果禁用:日志级别在编译时固定,所有运行时相关代码被优化掉
这样,同一个代码库,既能满足开发阶段的灵活性,又能满足产品阶段的资源约束。
/* 混合策略示例 */
#ifdef ENABLE_RUNTIME_LOG_LEVEL
extern int g_current_log_level;
#define GET_LOG_LEVEL() g_current_log_level
#else
#define GET_LOG_LEVEL() CURRENT_LOG_LEVEL
#endif
#define LOG_DEBUG(fmt, ...) \
do { \
if (LOG_LEVEL_DEBUG >= GET_LOG_LEVEL()) { \
printf("[DEBUG] " fmt "\r\n", ##__VA_ARGS__); \
} \
} while(0)
我的建议:新项目起步时,先用编译时控制。等系统稳定了,如果发现现场调试需求频繁,再考虑加入运行时控制。别一开始就上大而全的方案——你想想看,嵌入式开发最怕的就是过度设计。
3.7 避坑指南
最后,分享几个我踩过的坑:
- 我曾经在中断服务函数里调用日志宏,结果日志级别判断里有个全局变量,中断和主循环同时访问导致数据不一致。解决方案:中断里用编译时控制,或者加临界区保护。
- 我曾经把日志级别设成DEBUG就发布了,结果客户现场串口每秒输出几百条调试信息,系统性能直接掉了一半。从那以后,我强制要求Release版本默认级别不低于WARN。
- 注意:运行时修改日志级别时,最好加个权限校验。别让随便哪个串口命令就把级别改了——我在某款工控设备上见过,操作员误操作把日志级别改成DEBUG,日志文件一天就写满了Flash。
嗯,日志级别控制就聊到这儿。记住一句话:没有级别的日志是噪音,有级别的日志才是信息。
公众号:蓝海资料掘金营,微信deep3321