15、项目实战:基于回调的日志系统(不同级别不同处理)

日志系统,说白了就是程序的「黑匣子」。

我做了这么多年嵌入式,几乎每个项目都要自己搭一套日志。为什么不用现成的?因为嵌入式环境太杂了——有的要输出到串口,有的要写到Flash,有的还要通过WiFi发到服务器。而且,不同级别的日志,处理方式完全不一样。

你想想看,调试信息(DEBUG)可能只在开发阶段有用,到了正式版就该关掉。错误信息(ERROR)必须立刻存盘,不能丢。警告信息(WARN)呢?也许闪烁一下LED就够了。

如果用传统的if-else硬编码,每次改需求都得翻源码。我早期一个项目就是这么干的,结果客户要加个「网络日志上传」功能,我改了三天,还改出了bug。后来我学乖了——用回调函数,把「日志怎么处理」和「日志系统本身」解耦。

15.1 需求分析:日志系统要解决什么问题

先理清楚需求。一个实用的嵌入式日志系统,至少需要满足这几点:

  • 分级过滤:DEBUG、INFO、WARN、ERROR,每个级别可以独立开关
  • 多路输出:同一份日志,可以同时输出到串口、文件、网络……
  • 运行时切换:不能改一行代码,就能改变日志的处理方式
  • 低开销:嵌入式资源有限,不能动不动就malloc

嗯,这里要注意——回调机制正好能解决「多路输出」和「运行时切换」这两个痛点。

15.2 架构设计:回调驱动的日志处理链

我的设计思路是这样的:

日志系统本身只负责「格式化日志消息」和「按级别分发」。至于消息怎么处理,交给注册进来的回调函数。每个日志级别可以挂多个回调,形成一个处理链。

下面这张图,是我画的一个简化版架构:

基于回调的日志系统架构 应用程序 日志API (log_debug / log_error / ...) 日志分发器 (按级别查找回调链表) ERROR WARN DEBUG 回调1: 存Flash 回调1: 闪烁LED 回调1: 串口输出 回调2: 蜂鸣器报警

看到没?每个日志级别都维护一个回调链表。分发器根据日志级别,遍历对应的链表,逐个调用回调函数。这样,你想加一个「网络发送」的处理,只需要注册一个新回调,完全不用动日志核心代码。

15.3 核心数据结构

先定义日志级别和回调函数类型:

/* 日志级别枚举 */
typedef enum {
    LOG_LEVEL_DEBUG = 0,
    LOG_LEVEL_INFO,
    LOG_LEVEL_WARN,
    LOG_LEVEL_ERROR,
    LOG_LEVEL_MAX
} log_level_t;

/* 回调函数类型:接收日志级别、文件名、行号、格式化消息 */
typedef void (*log_callback_t)(log_level_t level, 
                               const char *file, 
                               int line, 
                               const char *msg);

回调节点用链表串起来:

/* 回调链表节点 */
typedef struct log_callback_node {
    log_callback_t callback;
    struct log_callback_node *next;
} log_callback_node_t;

/* 每个级别一个链表头 */
static log_callback_node_t *s_callback_heads[LOG_LEVEL_MAX] = {NULL};

我个人习惯用静态数组管理级别,而不是动态分配。为什么?因为嵌入式系统里,日志级别是固定的,用数组查找快,而且不会产生内存碎片。

15.4 注册与注销回调

注册回调的接口很简单:

int log_register_callback(log_level_t level, log_callback_t cb) {
    if (level >= LOG_LEVEL_MAX || cb == NULL) {
        return -1;  /* 参数错误 */
    }

    /* 创建新节点 */
    log_callback_node_t *node = 
        (log_callback_node_t *)malloc(sizeof(log_callback_node_t));
    if (node == NULL) {
        return -2;  /* 内存不足 */
    }
    node->callback = cb;
    node->next = NULL;

    /* 头插法,简单高效 */
    node->next = s_callback_heads[level];
    s_callback_heads[level] = node;

    return 0;
}

这里我用了头插法。你可能会问,为什么不用尾插?因为日志回调的注册顺序通常不重要,头插法O(1)搞定,尾插还要遍历链表。

注销回调稍微麻烦一点,需要遍历链表找到对应节点:

int log_unregister_callback(log_level_t level, log_callback_t cb) {
    if (level >= LOG_LEVEL_MAX || cb == NULL) {
        return -1;
    }

    log_callback_node_t *prev = NULL;
    log_callback_node_t *curr = s_callback_heads[level];

    while (curr != NULL) {
        if (curr->callback == cb) {
            if (prev == NULL) {
                s_callback_heads[level] = curr->next;
            } else {
                prev->next = curr->next;
            }
            free(curr);
            return 0;
        }
        prev = curr;
        curr = curr->next;
    }

    return -3;  /* 未找到 */
}
注意:注销回调时,一定要确保传入的cb指针和注册时是同一个。我曾经在一个项目里,因为不小心把回调函数地址搞混了,导致注销失败,内存泄漏。后来我加了个「注册计数」才解决。

15.5 日志分发核心

这是整个系统的灵魂:

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

    /* 格式化消息 */
    char msg_buf[256];
    va_list args;
    va_start(args, fmt);
    vsnprintf(msg_buf, sizeof(msg_buf), fmt, args);
    va_end(args);

    /* 遍历回调链表 */
    log_callback_node_t *node = s_callback_heads[level];
    while (node != NULL) {
        node->callback(level, file, line, msg_buf);
        node = node->next;
    }
}

嗯,这里有个细节——我用了固定大小的缓冲区(256字节)。为什么不用动态分配?因为日志系统可能在中断里被调用,malloc在中断里是不安全的。固定缓冲区虽然浪费一点,但胜在稳定。

为了方便调用,再封装几个宏:

#define log_debug(fmt, ...) \
    log_dispatch(LOG_LEVEL_DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

#define log_info(fmt, ...) \
    log_dispatch(LOG_LEVEL_INFO, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

#define log_warn(fmt, ...) \
    log_dispatch(LOG_LEVEL_WARN, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

#define log_error(fmt, ...) \
    log_dispatch(LOG_LEVEL_ERROR, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

15.6 实战:实现三个不同的回调处理

光有框架不行,得真刀真枪写几个回调看看效果。

回调1:串口输出

void callback_uart(log_level_t level, const char *file, int line, const char *msg) {
    static const char *level_names[] = {"DEBUG", "INFO", "WARN", "ERROR"};
    printf("[%s] %s:%d - %s\n", level_names[level], file, line, msg);
}

回调2:错误级别存Flash

void callback_flash(log_level_t level, const char *file, int line, const char *msg) {
    if (level != LOG_LEVEL_ERROR) {
        return;  /* 只处理ERROR级别 */
    }
    /* 假设flash_write是底层写Flash的函数 */
    flash_write(LOG_FLASH_ADDR, msg, strlen(msg) + 1);
}

回调3:调试级别闪烁LED

void callback_led(log_level_t level, const char *file, int line, const char *msg) {
    if (level != LOG_LEVEL_DEBUG) {
        return;
    }
    /* 快速闪烁3次,表示有调试信息 */
    for (int i = 0; i < 3; i++) {
        LED_ON();
        delay_ms(50);
        LED_OFF();
        delay_ms(50);
    }
}

15.7 组装与测试

在主函数里注册回调:

int main(void) {
    /* 初始化硬件 */
    uart_init();
    flash_init();
    led_init();

    /* 注册回调 */
    log_register_callback(LOG_LEVEL_DEBUG, callback_uart);
    log_register_callback(LOG_LEVEL_DEBUG, callback_led);
    log_register_callback(LOG_LEVEL_INFO,  callback_uart);
    log_register_callback(LOG_LEVEL_WARN,  callback_uart);
    log_register_callback(LOG_LEVEL_ERROR, callback_uart);
    log_register_callback(LOG_LEVEL_ERROR, callback_flash);

    /* 测试 */
    log_debug("系统初始化完成,内存使用: %d bytes", 1024);
    log_info("传感器数据: temp=%.2f, humi=%.2f", 25.3, 60.1);
    log_warn("电池电压偏低: %.2fV", 3.2);
    log_error("Flash写入失败,地址: 0x%08X", 0x08010000);

    while(1) {
        /* 主循环 */
    }
}

运行结果会怎样?

  • DEBUG消息:串口输出 + LED闪烁
  • INFO消息:只串口输出
  • WARN消息:只串口输出
  • ERROR消息:串口输出 + 写入Flash
我的经验:实际项目中,我通常会把「串口输出」注册到所有级别,方便调试。但正式发布时,只保留ERROR级别的串口输出,其他级别全部注销,或者干脆把回调链表清空。这样既不影响性能,又能保证关键错误不丢失。

15.8 避坑指南

做这个系统时,我踩过几个坑,分享给你:

  1. 回调函数不要阻塞:比如写Flash可能耗时几十毫秒,如果在中断里调用日志,就会导致中断响应延迟。我的做法是:在回调里把日志消息扔进一个环形缓冲区,由后台任务慢慢处理。
  2. 注意重入问题:如果两个不同的中断同时调用日志,回调链表可能被破坏。解决方案是加一个简单的自旋锁,或者用无锁队列。
  3. 格式化缓冲区溢出:vsnprintf虽然安全,但如果消息太长会被截断。我建议把缓冲区设大一点,比如512字节,反正嵌入式里日志消息一般不会太长。
  4. 注销回调要小心:如果在遍历回调链表时注销了当前节点,会导致野指针。我一般会加一个「延迟注销」机制,标记要删除的节点,等遍历完再统一清理。

我曾经在一个产品里,因为没处理好重入问题,导致日志系统在中断里死锁,整机复位。排查了整整两天才找到原因。从那以后,我对日志系统的线程安全性格外敏感。

15.9 小结

这个基于回调的日志系统,说白了就是把「日志产生」和「日志处理」解耦。你可以在运行时动态增删回调,不用改一行核心代码。而且,每个级别独立管理,灵活度非常高。

我建议你把这个框架作为自己项目的日志基础设施。刚开始可能觉得有点复杂,但用熟了之后,你会发现它比传统的printf大法好用太多了。尤其是当你的项目需要同时输出到串口、文件、网络、LCD……的时候,回调机制的优势就体现出来了。

嗯,今天就到这里。代码我已经整理好了,你可以直接拿去用。记得根据你的硬件平台,把uart_init、flash_init这些底层函数替换掉就行。

核心要点回顾:

  • 每个日志级别维护一个回调链表
  • 注册/注销回调在运行时动态完成
  • 分发器遍历链表,逐个调用回调
  • 注意中断安全、重入、阻塞等问题

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