2. 日志系统基础:日志级别、日志格式、日志缓冲区设计原则

做过多核异构调试的工程师都知道,日志系统是调试的「眼睛」。没有一套好的日志系统,你就像在黑夜里摸黑修电路——全靠运气。今天我就把日志系统最核心的三个东西掰开揉碎了讲:日志级别、日志格式、还有缓冲区设计。

2.1 日志级别:别什么都往串口打

日志级别这东西,说白了就是给日志分个三六九等。我见过不少新手,调试时直接 printf("xxx") 一把梭,结果产品上线后日志满天飞,关键信息反而被淹没了。

我个人习惯用这五个级别,够用且不冗余:

级别 数值 含义 典型场景
FATAL 0 致命错误 系统即将崩溃,必须立即处理
ERROR 1 错误 功能异常,但系统还能跑
WARN 2 警告 潜在风险,比如缓冲区快满了
INFO 3 信息 关键流程节点,如任务启动/结束
DEBUG 4 调试 开发阶段用的详细输出
我的小建议: 产品发布时,把日志级别锁在 INFO 及以上。DEBUG 日志只在开发板启用。我曾经有个项目,就是因为 DEBUG 日志没关,导致串口中断频繁触发,把实时性拖垮了。

2.2 日志格式:统一模板,别搞花活

日志格式这件事,团队里必须统一。你想想看,A 同事打的是 [时间] 信息,B 同事打的是 信息 (时间),后期用脚本分析日志时,光解析格式就够你喝一壶的。

我推荐的标准格式:

[级别] [时间戳] [模块名] [任务ID] 日志内容

举个例子:

[INFO] [2025-01-15 14:23:45.123] [UART_DRV] [TASK_03] DMA传输完成,长度=1024字节

为什么要有任务ID?在多核异构系统里,同一个模块可能被多个核、多个任务同时调用。没有任务ID,你根本分不清这条日志是谁打的。嗯,这里要注意——时间戳最好带毫秒,否则两个核的日志混在一起,你根本排不出先后顺序。

避坑指南: 我曾经遇到过一个坑——两个核的时间基准不同步。A核用系统滴答计时,B核用RTC计时,结果日志时间戳差了十几秒。后来我强制所有核统一使用一个全局时间源,才把这个问题解决。

2.3 日志缓冲区设计原则:别让日志拖死系统

日志缓冲区设计,是嵌入式日志系统里最容易翻车的地方。我见过三种常见的死法:

  • 死法一: 缓冲区太小,日志频繁丢失
  • 死法二: 缓冲区太大,内存被吃光
  • 死法三: 锁竞争严重,日志写入成了性能瓶颈

怎么设计?我总结了三原则:

原则一:环形缓冲区 + 水位线

环形缓冲区是嵌入式日志的标配。它固定大小,写满后覆盖最旧的数据。但要注意——加一个水位线机制。当缓冲区使用量超过 80% 时,主动触发一次批量输出,防止关键日志被覆盖。

原则二:无锁设计或极简锁

多核同时写日志时,锁竞争是性能杀手。我个人的做法是:每个核分配独立的日志缓冲区,核间不共享。这样完全不需要加锁。等日志输出时,由专门的日志任务统一收集。

原则三:异步输出,不阻塞业务

日志写入缓冲区后,立刻返回。真正的输出(写到串口、SD卡或网络)由后台任务处理。这样即使输出设备慢,也不会拖慢主业务。

核心要点: 日志系统是辅助工具,不是主角。它的设计原则是「够用就好,别添乱」。

2.4 知识体系结构图

下面这张图,把日志系统的三个核心模块串起来了。你可以看到它们之间的依赖关系:

日志系统核心模块结构图 日志级别 FATAL / ERROR WARN / INFO / DEBUG 运行时动态过滤 日志格式 [级别][时间][模块] [任务ID] 内容 统一模板,便于解析 缓冲区设计 环形缓冲区 水位线 + 无锁设计 异步输出不阻塞 三者关系 日志级别决定「写不写」 日志格式决定「怎么写」 缓冲区设计决定「能不能写进去且不拖垮系统」

2.5 代码示例:一个轻量级日志模块

下面是我在一个 Cortex-M4 + RISC-V 异构项目里用过的日志模块骨架。它实现了环形缓冲区 + 异步输出:

/* log.h */
#define LOG_LEVEL_FATAL 0
#define LOG_LEVEL_ERROR 1
#define LOG_LEVEL_WARN  2
#define LOG_LEVEL_INFO  3
#define LOG_LEVEL_DEBUG 4

/* 运行时日志级别,可在启动时配置 */
extern int g_log_level;

#define LOG_FATAL(fmt, ...) \
    do { if (g_log_level >= LOG_LEVEL_FATAL) \
        log_output(LOG_LEVEL_FATAL, __MODULE__, fmt, ##__VA_ARGS__); \
    } while(0)

/* log.c */
#define RING_BUF_SIZE 4096
static char ring_buf[RING_BUF_SIZE];
static uint32_t write_idx = 0;
static uint32_t read_idx  = 0;

void log_output(int level, const char* module, const char* fmt, ...) {
    char entry[256];
    va_list args;
    va_start(args, fmt);
    vsnprintf(entry, sizeof(entry), fmt, args);
    va_end(args);
    
    /* 写入环形缓冲区(简化版,实际需处理溢出) */
    uint32_t next = (write_idx + 1) % RING_BUF_SIZE;
    if (next != read_idx) {
        ring_buf[write_idx] = entry[0]; /* 实际应拷贝完整字符串 */
        write_idx = next;
    }
    /* 触发后台任务输出 */
    task_notify(LOG_TASK_ID);
}
经验之谈: 上面的代码只是演示骨架。实际项目中,我还会加一个「紧急输出」机制——当系统即将崩溃时,绕过缓冲区,直接把最后几条日志写到非易失存储里。这个机制救过我一次,那次是电源不稳导致系统反复重启,靠最后几条日志定位到了问题。

日志系统基础就讲到这里。记住三个关键词:级别过滤、格式统一、缓冲区不阻塞。把这三点吃透了,后面讲多核日志同步时,你才能游刃有余。


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