13、日志系统架构:分层设计(接口层、核心层、输出层),模块解耦
日志系统这东西,说白了就是给嵌入式程序装个「黑匣子」。我早年做的一个车载项目,程序跑着跑着突然死机,连个错误码都没留下。那时候我就想,要是有一套靠谱的日志系统,哪至于拿着示波器一根根引脚去量?
今天咱们聊聊日志系统的分层架构。我个人习惯把日志系统拆成三层:接口层、核心层、输出层。为什么要这么分?你想想看,如果所有代码都揉在一起,改个输出格式就得翻遍整个工程,那得多痛苦。
核心思想:分层设计就是为了解耦。接口层只管「谁调了我」,核心层只管「日志该怎么处理」,输出层只管「往哪儿写」。各层各司其职,互不干扰。
13.1 三层架构长什么样?
先看一张图,这是我画的分层架构图。别嫌丑,逻辑是清楚的。
嗯,这张图其实已经说清楚了。接口层在最上面,应用程序只管调用 LOG_XXX 宏。核心层在中间,负责过滤、格式化、加时间戳。输出层在最底下,爱往哪儿写往哪儿写。
13.2 接口层:给应用程序用的「门面」
接口层说白了就是一组宏定义。我见过不少新手直接把 printf 当日志用,结果产品发布时还得一个个去删。正确的做法是:
/* log_interface.h */
#ifndef LOG_INTERFACE_H
#define LOG_INTERFACE_H
#include <stdint.h>
/* 日志级别枚举 */
typedef enum {
LOG_LEVEL_DEBUG = 0,
LOG_LEVEL_INFO,
LOG_LEVEL_WARN,
LOG_LEVEL_ERROR,
LOG_LEVEL_FATAL
} log_level_t;
/* 用户只需调用这些宏 */
#define LOG_DEBUG(fmt, ...) \
log_output(LOG_LEVEL_DEBUG, __FILE__, __LINE__, __FUNCTION__, fmt, ##__VA_ARGS__)
#define LOG_INFO(fmt, ...) \
log_output(LOG_LEVEL_INFO, __FILE__, __LINE__, __FUNCTION__, fmt, ##__VA_ARGS__)
#define LOG_WARN(fmt, ...) \
log_output(LOG_LEVEL_WARN, __FILE__, __LINE__, __FUNCTION__, fmt, ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...) \
log_output(LOG_LEVEL_ERROR, __FILE__, __LINE__, __FUNCTION__, fmt, ##__VA_ARGS__)
#define LOG_FATAL(fmt, ...) \
log_output(LOG_LEVEL_FATAL, __FILE__, __LINE__, __FUNCTION__, fmt, ##__VA_ARGS__)
/* 核心层提供的接口 */
void log_output(log_level_t level, const char *file,
int line, const char *func,
const char *fmt, ...);
#endif /* LOG_INTERFACE_H */
我的习惯:宏定义里一定要带上 __FILE__、__LINE__、__FUNCTION__。这样出问题时,一眼就能定位到是哪个文件的哪一行。我曾经因为少写了 __LINE__,排查一个bug花了整整两天。
13.3 核心层:日志的「大脑」
核心层负责三件事:过滤、格式化、缓存。咱们一个一个说。
13.3.1 日志级别过滤
产品发布时,你肯定不想让 DEBUG 级别的日志刷屏。所以核心层要维护一个全局的日志级别阈值:
/* log_core.c */
static log_level_t g_log_threshold = LOG_LEVEL_DEBUG;
void log_set_threshold(log_level_t level) {
g_log_threshold = level;
}
void log_output(log_level_t level, const char *file,
int line, const char *func,
const char *fmt, ...) {
/* 级别过滤:低于阈值的直接丢弃 */
if (level < g_log_threshold) {
return;
}
/* 格式化与输出... */
}
你看,就这么几行代码,就能在运行时动态开关日志。我在项目中遇到过一种情况:现场设备出问题了,远程把日志级别调到 DEBUG,复现问题后再调回 ERROR。省去了来回跑现场的麻烦。
13.3.2 格式化与时间戳
日志不能光秃秃地打印字符串。我习惯的格式是:
[2025-01-15 14:30:22.456] [INFO] [main.c:42] [app_task] 系统初始化完成,内存剩余: 128KB
这个格式包含了时间、级别、文件位置、任务名。核心层里用 vsnprintf 来组装:
void log_output(log_level_t level, const char *file,
int line, const char *func,
const char *fmt, ...) {
if (level < g_log_threshold) return;
char buffer[LOG_BUFFER_SIZE];
char time_str[32];
const char *level_str[] = {"DEBUG", "INFO", "WARN", "ERROR", "FATAL"};
/* 获取时间戳 */
log_get_timestamp(time_str, sizeof(time_str));
/* 组装头部 */
int pos = snprintf(buffer, sizeof(buffer),
"[%s] [%s] [%s:%d] [%s] ",
time_str, level_str[level],
file, line, func);
/* 组装用户消息 */
va_list args;
va_start(args, fmt);
vsnprintf(buffer + pos, sizeof(buffer) - pos, fmt, args);
va_end(args);
/* 交给输出层 */
log_output_driver(buffer);
}
注意:vsnprintf 在缓冲区不足时会截断字符串。我建议把 LOG_BUFFER_SIZE 设成 256 字节以上。曾经有个同事设成 64 字节,结果长日志全被截断了,排查问题时关键信息丢失,教训深刻。
13.4 输出层:爱往哪儿写往哪儿写
输出层是插件化的。什么意思?就是你可以注册多个输出通道,互不干扰。
/* log_output.h */
typedef void (*log_output_func_t)(const char *msg);
typedef struct {
log_output_func_t output;
const char *name;
} log_output_channel_t;
/* 注册一个输出通道 */
int log_register_output(log_output_channel_t *channel);
/* 注销 */
void log_unregister_output(const char *name);
举个例子,开发阶段同时输出到串口和 RTT:
/* 串口输出 */
void uart_output(const char *msg) {
/* 调用硬件UART发送 */
uart_send_string(UART_PORT, msg);
}
/* RTT输出 */
void rtt_output(const char *msg) {
SEGGER_RTT_WriteString(0, msg);
}
/* 注册 */
log_output_channel_t uart_ch = {.output = uart_output, .name = "uart"};
log_output_channel_t rtt_ch = {.output = rtt_output, .name = "rtt"};
log_register_output(&uart_ch);
log_register_output(&rtt_ch);
产品发布时,把串口通道注销掉,只保留文件系统通道。代码一行都不用改。
13.5 模块解耦:各层之间的契约
三层之间靠什么联系?靠接口函数和回调函数。接口层只调用核心层的 log_output,核心层只调用输出层的回调。谁都不需要知道对方内部怎么实现。
| 层级 | 对外暴露 | 依赖关系 |
|---|---|---|
| 接口层 | LOG_DEBUG 等宏 | 依赖核心层的 log_output |
| 核心层 | log_output, log_set_threshold | 依赖输出层的回调函数 |
| 输出层 | log_register_output | 不依赖任何上层 |
这种设计的好处是:你想换输出方式?写个新通道注册进去就行。想改日志格式?只改核心层的格式化代码。想加个日志加密?在核心层和输出层之间插个中间件。
避坑指南:我曾经在一个项目里把日志输出直接写死在接口层的宏里。后来要加网络日志输出,不得不改所有 LOG_XXX 宏,改完还漏了几个地方。从那以后,我再也不敢让接口层直接操作硬件了。
13.6 实际项目中的配置示例
最后给一个完整的配置示例。假设我们有一个 FreeRTOS 项目:
/* main.c */
#include "log_interface.h"
int main(void) {
/* 初始化硬件 */
uart_init(115200);
/* 注册输出通道 */
log_output_channel_t uart_ch = {.output = uart_output, .name = "uart"};
log_register_output(&uart_ch);
/* 设置日志级别:开发阶段用 DEBUG */
log_set_threshold(LOG_LEVEL_DEBUG);
/* 开始干活 */
LOG_INFO("系统启动,CPU频率: %d MHz", 80);
while(1) {
/* 业务逻辑 */
LOG_DEBUG("心跳包发送完成");
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
嗯,这样一套分层日志系统就搭好了。代码量不大,但扩展性极强。你想想看,以后要加什么新功能,都是在自己的层里加,不会动到别人的代码。这就是解耦的魅力。
公众号:蓝海资料掘金营,微信deep3321