第十章:日志接口抽象——设计一个可插拔的日志系统

日志这东西,说简单也简单,说复杂也复杂。我见过太多项目,一开始随便写几个 printf 就完事了。等到系统跑起来,问题一多,才发现日志根本没法用——要么信息不全,要么输出太乱,要么想关掉某个模块的日志得改代码重新编译。

说白了,日志系统是嵌入式开发里最容易「凑合」的部分。但恰恰是这种「凑合」,会在后期让你付出惨痛代价。我曾经在一个工业控制项目里,就因为日志接口没设计好,现场排查故障时硬是花了三天才定位到问题。从那以后,我对日志接口的设计就再也不敢马虎了。

10.1 日志系统的核心需求

先想想看,一个真正好用的日志系统,应该满足哪些条件?

  • 分级输出:DEBUG、INFO、WARN、ERROR,至少这四个级别得有
  • 可开关:每个模块、每个级别都能独立控制是否输出
  • 可插拔:输出方式能灵活切换——串口、文件、网络、LCD 屏,想换就换
  • 低开销:日志不能拖慢系统,尤其在高频调用的地方

嗯,这四点看起来简单,但要做到优雅,还真得花点心思。

10.2 接口设计:从回调函数说起

我个人习惯的做法是:把日志的输出行为抽象成一组回调函数。这样,日志模块本身只负责格式化、分级过滤,至于最终输出到哪里,由上层注册的回调决定。

来看一个我常用的接口定义:

/* log.h */
#ifndef LOG_H
#define LOG_H

#include <stdint.h>
#include <stdarg.h>

/* 日志级别 */
typedef enum {
    LOG_LEVEL_DEBUG = 0,
    LOG_LEVEL_INFO,
    LOG_LEVEL_WARN,
    LOG_LEVEL_ERROR,
    LOG_LEVEL_NONE   /* 关闭所有日志 */
} log_level_t;

/* 日志输出回调类型 */
typedef void (*log_output_fn)(log_level_t level, const char *msg, uint32_t len);

/* 日志过滤回调类型:返回 1 表示允许输出,0 表示禁止 */
typedef int  (*log_filter_fn)(log_level_t level, const char *module);

/* 初始化日志系统 */
void log_init(log_output_fn output, log_filter_fn filter);

/* 设置全局日志级别 */
void log_set_level(log_level_t level);

/* 核心日志输出函数 */
void log_write(log_level_t level, const char *module, const char *fmt, ...);

/* 便捷宏 */
#define LOG_DEBUG(mod, fmt, ...) \
    log_write(LOG_LEVEL_DEBUG, mod, fmt, ##__VA_ARGS__)
#define LOG_INFO(mod, fmt, ...)  \
    log_write(LOG_LEVEL_INFO,  mod, fmt, ##__VA_ARGS__)
#define LOG_WARN(mod, fmt, ...)  \
    log_write(LOG_LEVEL_WARN,  mod, fmt, ##__VA_ARGS__)
#define LOG_ERROR(mod, fmt, ...) \
    log_write(LOG_LEVEL_ERROR, mod, fmt, ##__VA_ARGS__)

#endif /* LOG_H */

你看,这个接口设计得很干净。调用方只需要关心「我要输出什么级别的日志」,至于日志最终去了哪里,完全由回调决定。

核心思想:日志模块只做「格式化」和「路由」,不做「输出」。输出是别人的事。

10.3 实现一个可插拔的日志引擎

接口定好了,接下来是实现。我习惯把实现放在 log.c 里,保持接口文件只声明不实现,方便以后换方案。

/* log.c */
#include "log.h"
#include <stdio.h>
#include <string.h>

static log_output_fn s_output = NULL;
static log_filter_fn s_filter = NULL;
static log_level_t   s_level  = LOG_LEVEL_DEBUG;

/* 级别名称映射 */
static const char *level_names[] = {
    "DEBUG", "INFO", "WARN", "ERROR"
};

void log_init(log_output_fn output, log_filter_fn filter)
{
    s_output = output;
    s_filter = filter;
}

void log_set_level(log_level_t level)
{
    s_level = level;
}

void log_write(log_level_t level, const char *module, const char *fmt, ...)
{
    char buf[256];
    va_list args;
    uint32_t len;

    /* 1. 级别过滤 */
    if (level < s_level || level >= LOG_LEVEL_NONE) {
        return;
    }

    /* 2. 自定义过滤回调 */
    if (s_filter != NULL && !s_filter(level, module)) {
        return;
    }

    /* 3. 格式化消息 */
    va_start(args, fmt);
    len = vsnprintf(buf, sizeof(buf), fmt, args);
    va_end(args);

    if (len > sizeof(buf) - 1) {
        len = sizeof(buf) - 1;
    }

    /* 4. 输出回调 */
    if (s_output != NULL) {
        s_output(level, buf, len);
    }
}

这里有个细节要注意:vsnprintf 的返回值处理。不同平台的行为略有差异,我在 STM32 上就踩过坑——有些库返回的是实际写入长度,有些返回的是「如果缓冲区够大应该写入的长度」。保险起见,我做了截断处理。

10.4 插拔不同的输出后端

接口和实现都好了,现在来看看怎么「插拔」。假设我们有三种输出需求:串口、文件、网络。

/* 串口输出回调 */
void uart_output(log_level_t level, const char *msg, uint32_t len)
{
    /* 假设 uart_send 是底层串口发送函数 */
    uart_send((uint8_t *)msg, len);
    uart_send((uint8_t *)"\r\n", 2);
}

/* 文件输出回调 */
void file_output(log_level_t level, const char *msg, uint32_t len)
{
    /* 假设 file_write 是文件系统写入函数 */
    file_write("log.txt", (uint8_t *)msg, len);
    file_write("log.txt", (uint8_t *)"\n", 1);
}

/* 网络输出回调 */
void net_output(log_level_t level, const char *msg, uint32_t len)
{
    /* 假设 net_send 是 TCP/UDP 发送函数 */
    net_send((uint8_t *)msg, len);
}

/* 使用示例 */
int main(void)
{
    /* 初始化:输出到串口,不过滤 */
    log_init(uart_output, NULL);
    log_set_level(LOG_LEVEL_DEBUG);

    LOG_INFO("MAIN", "系统启动完成");

    /* 切换到文件输出 */
    log_init(file_output, NULL);
    LOG_WARN("MAIN", "切换到文件日志模式");

    /* 再切换到网络输出,并添加过滤:只输出 ERROR 级别 */
    log_init(net_output, my_filter);
    LOG_ERROR("MAIN", "发生严重错误,已上报");

    return 0;
}

你看,切换输出后端只需要调用一次 log_init,传入不同的回调函数即可。这就是「可插拔」的魅力——日志模块本身不需要知道任何输出细节。

小技巧:如果系统支持动态加载,你甚至可以把输出回调放在不同的 .c 文件里,通过链接时选择不同的目标文件来实现「编译时插拔」。这样连代码都不用改。

10.5 过滤机制的灵活运用

过滤回调是个好东西。我曾经在一个多模块的项目里,用过滤回调实现了「按模块按级别」的精细控制:

/* 模块级别的过滤表 */
typedef struct {
    const char *module;
    log_level_t min_level;
} module_filter_t;

static module_filter_t filters[] = {
    {"USB",   LOG_LEVEL_WARN},   /* USB 模块只输出 WARN 及以上 */
    {"FATFS", LOG_LEVEL_INFO},   /* 文件系统输出 INFO 及以上 */
    {"MAIN",  LOG_LEVEL_DEBUG},  /* 主模块全部输出 */
    {NULL,    LOG_LEVEL_ERROR}   /* 其他模块只输出 ERROR */
};

int my_filter(log_level_t level, const char *module)
{
    for (int i = 0; filters[i].module != NULL; i++) {
        if (strcmp(module, filters[i].module) == 0) {
            return level >= filters[i].min_level;
        }
    }
    /* 未匹配的模块,使用默认规则 */
    return level >= LOG_LEVEL_ERROR;
}

嗯,这个设计在实际项目中非常实用。调试阶段把 USB 模块的日志全开,发布阶段只保留 ERROR,完全不用改代码。

10.6 性能考量:别让日志拖垮系统

嵌入式系统资源有限,日志不能写得肆无忌惮。我总结了几条经验:

  • 关掉不用的级别:发布版本把 DEBUG 和 INFO 级别关掉,用 log_set_level(LOG_LEVEL_WARN) 就行
  • 避免在中断里写日志:格式化字符串和回调调用都可能耗时,中断里最好只设标志位
  • 使用静态缓冲区:上面的实现用了栈上的 buf,避免了动态内存分配,但要注意栈大小
  • 考虑异步输出:如果日志量很大,可以把格式化后的消息扔进队列,由后台任务慢慢输出

警告:千万不要在日志回调里做阻塞操作!比如等待串口发送完成、等待文件写入完成。我曾经见过一个同事在日志回调里加了延时,结果系统响应直接崩了。日志应该是「尽力而为」的,不能影响主流程。

10.7 知识体系总览

下面这张图,把整个日志系统的设计思路串起来了:

可插拔日志系统架构 应用模块(USB、FATFS、MAIN...) 日志接口层(log_write / LOG_DEBUG 宏) 日志引擎(级别过滤 → 自定义过滤 → 格式化 → 回调) 串口输出回调 文件输出回调 网络输出回调

从这张图可以看得很清楚:应用层只管调用宏,日志引擎负责过滤和格式化,输出后端通过回调插拔。每一层各司其职,互不干扰。

10.8 写在最后

日志接口的设计,本质上是对「变化点」的封装。输出方式会变,过滤规则会变,但「记录日志」这个行为不会变。把不变的部分稳定下来,把变化的部分留给回调,这就是接口抽象的精髓。

我见过很多团队在日志上反复折腾,今天用串口明天改文件,后天又要加网络。其实只要一开始把接口设计好,后面再怎么换输出后端,都是几分钟的事。嗯,这就是「可插拔」的价值——不是技术多高深,而是设计思路对了,后面就省心了。


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