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 运行时:如何选择?

这个问题没有标准答案,我根据自己的经验画了张决策图:

日志级别控制策略决策流程 开始选择日志策略 Flash空间 是否紧张? 编译时控制 宏定义 + -D选项 运行时控制 全局变量 + 接口 优点 • 节省Flash空间 • 无运行时开销 • 代码更安全 缺点 • 改级别需重新编译 • 现场调试不灵活 优点 • 灵活调整级别 • 现场调试方便 • 支持远程配置 缺点 • 占用更多Flash • 有运行时开销

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