13、日志系统架构:分层设计(接口层、核心层、输出层),模块解耦

日志系统这东西,说白了就是给嵌入式程序装个「黑匣子」。我早年做的一个车载项目,程序跑着跑着突然死机,连个错误码都没留下。那时候我就想,要是有一套靠谱的日志系统,哪至于拿着示波器一根根引脚去量?

今天咱们聊聊日志系统的分层架构。我个人习惯把日志系统拆成三层:接口层、核心层、输出层。为什么要这么分?你想想看,如果所有代码都揉在一起,改个输出格式就得翻遍整个工程,那得多痛苦。

核心思想:分层设计就是为了解耦。接口层只管「谁调了我」,核心层只管「日志该怎么处理」,输出层只管「往哪儿写」。各层各司其职,互不干扰。

13.1 三层架构长什么样?

先看一张图,这是我画的分层架构图。别嫌丑,逻辑是清楚的。

日志系统三层架构 接口层(API) LOG_DEBUG() / LOG_INFO() / LOG_ERROR() 核心层(Core) 日志级别过滤 | 格式化 | 缓存管理 时间戳生成 | 任务ID绑定 输出层(Output) 串口输出 | 文件系统 | RTT | 网络日志 | LCD显示 调用方向:上层 → 下层 解耦方式:接口层不依赖具体输出

嗯,这张图其实已经说清楚了。接口层在最上面,应用程序只管调用 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