日志系统设计:日志等级、格式规范与环形缓冲区
日志系统,说白了就是嵌入式系统的「黑匣子」。我做了十几年嵌入式开发,见过太多系统跑着跑着就挂了,没有日志,你连死在哪都不知道。今天咱们就把日志系统的基础打牢。
一、日志等级定义: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 修饰,因为中断和主循环会同时访问。但注意,这个实现是单生产者单消费者的,不需要加锁。如果是多生产者场景,就得用原子操作或关中断了。
四、日志系统的整体架构
下面这张图展示了日志从产生到输出的完整链路:
五、实际项目中的避坑经验
日志系统看着简单,但坑不少。我挑几个常见的说说:
- 缓冲区大小怎么定? 我一般按最大日志条数 × 平均长度来算。比如系统峰值每秒产生 100 条日志,每条平均 64 字节,那缓冲区至少 6.4KB。再留 50% 余量,10KB 起步。
- 日志丢了吗? 环形缓冲区满的时候,我选择丢弃最老的日志,而不是阻塞写入。系统都出错了,最新的日志比老的重要。
- 时间戳从哪里来? 如果 MCU 没有硬件 RTC,我就在系统 tick 中断里维护一个软件计数器,精度到毫秒。别用 delay 去读时间,那是阻塞的。
我曾经踩过的坑:有一次在中断里直接调用日志打印函数,结果日志函数里用了互斥锁,直接死锁。后来我规定:中断里只往环形缓冲区写数据,绝不在中断里做格式化或输出。格式化放到任务级去做。