第十二章:日志模块的设计

日志模块,说白了就是给程序装个「黑匣子」。

我做了十几年嵌入式开发,可以负责任地告诉你:一个设计良好的日志模块,能帮你省下至少一半的调试时间。相反,如果日志写得乱七八糟,那它不但帮不上忙,还会拖慢系统、撑爆存储。

今天我们就来聊聊,怎么设计一个真正能用的日志模块。

12.1 日志级别:不是越多越好

日志级别这东西,很多新手喜欢搞七八个级别。我个人习惯是:够用就行,别花里胡哨。

我常用的日志级别定义如下:

typedef enum {
    LOG_LEVEL_NONE  = 0,   // 关闭日志
    LOG_LEVEL_ERROR = 1,   // 错误
    LOG_LEVEL_WARN  = 2,   // 警告
    LOG_LEVEL_INFO  = 3,   // 信息
    LOG_LEVEL_DEBUG = 4,   // 调试
    LOG_LEVEL_TRACE = 5    // 跟踪
} log_level_t;

这里我解释一下每个级别的使用场景:

  • ERROR:系统出错了,必须人工介入。比如内存分配失败、硬件初始化失败。
  • WARN:有问题但系统还能跑。比如某个传感器读数异常,但用了默认值。
  • INFO:记录系统关键状态变化。比如「系统启动完成」「网络连接成功」。
  • DEBUG:开发阶段用的,发布时通常会关掉。比如某个函数的入参和返回值。
  • TRACE:最细粒度的日志,一般只在定位极难复现的bug时才开。
我的经验: 在产品发布时,我通常只保留 ERROR 和 WARN 级别。INFO 看情况保留,DEBUG 和 TRACE 必须关掉。否则日志量会大到让你怀疑人生。

12.2 日志输出接口:统一且灵活

接口设计上,我推荐用宏封装。为什么用宏?因为宏可以保留 __FILE____LINE__ 这些编译期信息。

#define LOG_ERROR(fmt, ...) \
    log_output(LOG_LEVEL_ERROR, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

#define LOG_WARN(fmt, ...) \
    log_output(LOG_LEVEL_WARN, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

#define LOG_INFO(fmt, ...) \
    log_output(LOG_LEVEL_INFO, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

#define LOG_DEBUG(fmt, ...) \
    log_output(LOG_LEVEL_DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

#define LOG_TRACE(fmt, ...) \
    log_output(LOG_LEVEL_TRACE, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

核心输出函数长这样:

void log_output(log_level_t level, const char *file, int line, 
                const char *fmt, ...) {
    // 如果当前级别低于全局设置,直接丢弃
    if (level > g_log_level) {
        return;
    }

    va_list args;
    va_start(args, fmt);

    // 格式化时间戳
    char time_buf[32];
    get_timestamp(time_buf, sizeof(time_buf));

    // 组装日志头:[时间][级别][文件名:行号]
    printf("[%s][%s][%s:%d] ", 
           time_buf, level_to_string(level), file, line);

    // 输出用户消息
    vprintf(fmt, args);
    printf("\n");

    va_end(args);
}

你可能会问:为什么不用 fprintf(stderr, ...)?嗯,在嵌入式环境里,标准输出可能被重定向到串口、LCD、SD卡或者网络。所以最好让底层输出可配置。

12.3 日志模块的线程安全性

这是个大坑。我曾经在一个多线程项目里,日志没加锁,结果两个线程同时写日志,输出全乱套了——一行日志被撕成两半,混在一起根本没法看。

注意: 在多线程环境下,printf 本身不是线程安全的。如果你用的是 RTOS,每个任务都在抢着写日志,不加锁一定会出问题。

解决方案其实不复杂:加一个互斥锁。

static mutex_t log_mutex;

void log_init(void) {
    mutex_init(&log_mutex);
    g_log_level = LOG_LEVEL_INFO;
}

void log_output(log_level_t level, const char *file, int line,
                const char *fmt, ...) {
    if (level > g_log_level) {
        return;
    }

    mutex_lock(&log_mutex);  // 加锁

    va_list args;
    va_start(args, fmt);

    char time_buf[32];
    get_timestamp(time_buf, sizeof(time_buf));

    printf("[%s][%s][%s:%d] ",
           time_buf, level_to_string(level), file, line);
    vprintf(fmt, args);
    printf("\n");

    va_end(args);

    mutex_unlock(&log_mutex);  // 解锁
}

但这里有个性能问题:如果日志输出很频繁,锁竞争会拖慢系统。我见过一个极端案例,日志模块占了CPU 30%的时间。

怎么优化?我常用的方法有两种:

  • 日志缓冲区:先把日志写到环形缓冲区里,由专门的日志任务负责输出。这样写日志几乎不阻塞。
  • 分级开关:在宏层面就做级别判断,避免进入函数调用。比如:
#define LOG_DEBUG(fmt, ...) do { \
    if (LOG_LEVEL_DEBUG <= g_log_level) { \
        log_output(LOG_LEVEL_DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__); \
    } \
} while(0)

这样,如果 DEBUG 级别被关闭,连函数调用都不会发生,锁也不会被争用。

12.4 日志模块的整体架构

下面这张图,是我个人比较推荐的日志模块分层结构:

日志模块分层架构 应用层 LOG_ERROR() / LOG_WARN() / LOG_INFO() / LOG_DEBUG() / LOG_TRACE() 日志接口层 log_output() — 级别过滤、格式化、加锁 输出层 串口输出 / 文件输出 / 网络输出 / LCD显示 底层驱动 / 硬件抽象层

这个架构的好处是:每一层职责清晰,替换底层输出方式时,上层代码完全不用改。

12.5 避坑指南

最后,我把自己踩过的坑总结一下:

  • 别在中断里打日志:中断里调用 printf 或加锁,很容易死锁。我建议在中断里只设标志位,由任务去输出日志。
  • 日志缓冲区别太小:我曾经设了个 128 字节的环形缓冲区,结果日志一多就覆盖了,关键信息全丢了。建议至少 4KB 起步。
  • 日志别带敏感信息:有个同事把 WiFi 密码打进了日志,结果日志文件被上传到服务器……嗯,你懂的。
  • 发布版本记得关 DEBUG:我见过一个产品,DEBUG 日志没关,结果 Flash 被写报废了。

日志模块看起来简单,但真正做好并不容易。它就像你代码里的「仪表盘」——平时不觉得重要,出问题的时候才知道它有多关键。


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