第十章:日志接口抽象——设计一个可插拔的日志系统
日志这东西,说简单也简单,说复杂也复杂。我见过太多项目,一开始随便写几个 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 知识体系总览
下面这张图,把整个日志系统的设计思路串起来了:
从这张图可以看得很清楚:应用层只管调用宏,日志引擎负责过滤和格式化,输出后端通过回调插拔。每一层各司其职,互不干扰。
10.8 写在最后
日志接口的设计,本质上是对「变化点」的封装。输出方式会变,过滤规则会变,但「记录日志」这个行为不会变。把不变的部分稳定下来,把变化的部分留给回调,这就是接口抽象的精髓。
我见过很多团队在日志上反复折腾,今天用串口明天改文件,后天又要加网络。其实只要一开始把接口设计好,后面再怎么换输出后端,都是几分钟的事。嗯,这就是「可插拔」的价值——不是技术多高深,而是设计思路对了,后面就省心了。
公众号:蓝海资料掘金营,微信deep3321