15、项目实战:基于回调的日志系统(不同级别不同处理)
日志系统,说白了就是程序的「黑匣子」。
我做了这么多年嵌入式,几乎每个项目都要自己搭一套日志。为什么不用现成的?因为嵌入式环境太杂了——有的要输出到串口,有的要写到Flash,有的还要通过WiFi发到服务器。而且,不同级别的日志,处理方式完全不一样。
你想想看,调试信息(DEBUG)可能只在开发阶段有用,到了正式版就该关掉。错误信息(ERROR)必须立刻存盘,不能丢。警告信息(WARN)呢?也许闪烁一下LED就够了。
如果用传统的if-else硬编码,每次改需求都得翻源码。我早期一个项目就是这么干的,结果客户要加个「网络日志上传」功能,我改了三天,还改出了bug。后来我学乖了——用回调函数,把「日志怎么处理」和「日志系统本身」解耦。
15.1 需求分析:日志系统要解决什么问题
先理清楚需求。一个实用的嵌入式日志系统,至少需要满足这几点:
- 分级过滤:DEBUG、INFO、WARN、ERROR,每个级别可以独立开关
- 多路输出:同一份日志,可以同时输出到串口、文件、网络……
- 运行时切换:不能改一行代码,就能改变日志的处理方式
- 低开销:嵌入式资源有限,不能动不动就malloc
嗯,这里要注意——回调机制正好能解决「多路输出」和「运行时切换」这两个痛点。
15.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; /* 未找到 */
}
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
15.8 避坑指南
做这个系统时,我踩过几个坑,分享给你:
- 回调函数不要阻塞:比如写Flash可能耗时几十毫秒,如果在中断里调用日志,就会导致中断响应延迟。我的做法是:在回调里把日志消息扔进一个环形缓冲区,由后台任务慢慢处理。
- 注意重入问题:如果两个不同的中断同时调用日志,回调链表可能被破坏。解决方案是加一个简单的自旋锁,或者用无锁队列。
- 格式化缓冲区溢出:vsnprintf虽然安全,但如果消息太长会被截断。我建议把缓冲区设大一点,比如512字节,反正嵌入式里日志消息一般不会太长。
- 注销回调要小心:如果在遍历回调链表时注销了当前节点,会导致野指针。我一般会加一个「延迟注销」机制,标记要删除的节点,等遍历完再统一清理。
我曾经在一个产品里,因为没处理好重入问题,导致日志系统在中断里死锁,整机复位。排查了整整两天才找到原因。从那以后,我对日志系统的线程安全性格外敏感。
15.9 小结
这个基于回调的日志系统,说白了就是把「日志产生」和「日志处理」解耦。你可以在运行时动态增删回调,不用改一行核心代码。而且,每个级别独立管理,灵活度非常高。
我建议你把这个框架作为自己项目的日志基础设施。刚开始可能觉得有点复杂,但用熟了之后,你会发现它比传统的printf大法好用太多了。尤其是当你的项目需要同时输出到串口、文件、网络、LCD……的时候,回调机制的优势就体现出来了。
嗯,今天就到这里。代码我已经整理好了,你可以直接拿去用。记得根据你的硬件平台,把uart_init、flash_init这些底层函数替换掉就行。
核心要点回顾:
- 每个日志级别维护一个回调链表
- 注册/注销回调在运行时动态完成
- 分发器遍历链表,逐个调用回调
- 注意中断安全、重入、阻塞等问题
公众号:蓝海资料掘金营,微信deep3321