日志系统设计:日志等级、格式规范与环形缓冲区

日志系统,说白了就是嵌入式系统的「黑匣子」。我做了十几年嵌入式开发,见过太多系统跑着跑着就挂了,没有日志,你连死在哪都不知道。今天咱们就把日志系统的基础打牢。

一、日志等级定义:DEBUG / INFO / WARN / ERROR

日志等级不是随便拍脑袋定的。我习惯用四级制,够用且不啰嗦:

等级 含义 典型场景 是否默认输出
DEBUG 调试信息 变量值、函数入口/出口、中间计算结果 否(发布版关闭)
INFO 正常信息 系统启动、任务切换、外设初始化成功 是
WARN 警告 资源接近耗尽、重试次数超限、非致命异常 是
ERROR 错误 硬件故障、内存分配失败、协议解析异常 是
我的习惯:DEBUG 日志只在开发阶段打开。发布版本里,我会用宏把 DEBUG 日志整个编译掉,省空间也省性能。你想想看,一个跑在 72MHz 的 STM32,每打印一条 DEBUG 日志就要几十微秒,关键路径上扛不住。

二、日志格式规范:一眼就能定位问题

日志格式不统一,后期分析就是灾难。我曾经接手过一个项目,有人用逗号分隔,有人用空格,还有人直接写「这里出错了」——你根本没法写脚本去解析。

我推荐的格式:

[时间戳] [等级] [模块] [文件名:行号] 消息内容

举个例子:

[2025-03-21 14:32:18.456] [ERROR] [UART] [uart_driver.c:127] 接收缓冲区溢出,丢失 3 字节

为什么这么设计?

  • 时间戳:精确到毫秒,方便还原时序
  • 等级:一眼过滤,只看 ERROR 或 WARN
  • 模块:快速定位是哪个子系统出的问题
  • 文件名:行号:不用猜,直接跳代码
  • 消息内容:具体描述,带关键参数
避坑指南:我曾经见过有人把时间戳格式写成「14:32:18.456789」,精度到微秒。但实际 MCU 的 RTC 根本没那么准,反而浪费了 3 个字节。毫秒级就够了,别过度设计。

三、环形缓冲区实现:轻量、无锁、高效

嵌入式系统里,直接往串口打印日志会阻塞任务。我一般用环形缓冲区(Ring Buffer)做异步日志:日志先写进缓冲区,再由一个低优先级任务或 DMA 慢慢往外吐。

环形缓冲区的核心结构:

typedef struct {
    uint8_t *buffer;    // 数据区
    uint32_t size;      // 缓冲区大小(2的幂)
    volatile uint32_t head;  // 写指针
    volatile uint32_t tail;  // 读指针
} ring_buffer_t;

为什么 size 要取 2 的幂?因为可以用位运算代替取模,快很多:

// 写一个字节
bool ring_buf_put(ring_buffer_t *rb, uint8_t data) {
    uint32_t next = (rb->head + 1) & (rb->size - 1);
    if (next == rb->tail) {
        return false;  // 缓冲区满
    }
    rb->buffer[rb->head] = data;
    rb->head = next;
    return true;
}

// 读一个字节
bool ring_buf_get(ring_buffer_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return false;  // 缓冲区空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) & (rb->size - 1);
    return true;
}
关键点:head 和 tail 用 volatile 修饰,因为中断和主循环会同时访问。但注意,这个实现是单生产者单消费者的,不需要加锁。如果是多生产者场景,就得用原子操作或关中断了。

四、日志系统的整体架构

下面这张图展示了日志从产生到输出的完整链路:

日志系统架构图 应用代码 中断服务 协议栈 日志宏封装(等级判断 + 格式化) 环形缓冲区(Ring Buffer) head → tail,无锁单生产者单消费者 UART DMA 输出 SD 卡文件写入 网络日志上传

五、实际项目中的避坑经验

日志系统看着简单,但坑不少。我挑几个常见的说说:

  • 缓冲区大小怎么定? 我一般按最大日志条数 × 平均长度来算。比如系统峰值每秒产生 100 条日志,每条平均 64 字节,那缓冲区至少 6.4KB。再留 50% 余量,10KB 起步。
  • 日志丢了吗? 环形缓冲区满的时候,我选择丢弃最老的日志,而不是阻塞写入。系统都出错了,最新的日志比老的重要。
  • 时间戳从哪里来? 如果 MCU 没有硬件 RTC,我就在系统 tick 中断里维护一个软件计数器,精度到毫秒。别用 delay 去读时间,那是阻塞的。
我曾经踩过的坑:有一次在中断里直接调用日志打印函数,结果日志函数里用了互斥锁,直接死锁。后来我规定:中断里只往环形缓冲区写数据,绝不在中断里做格式化或输出。格式化放到任务级去做。

六、小结