第2章:日志系统设计——级别、格式与环形缓冲区
日志系统,说白了就是嵌入式系统的「黑匣子」。我做了十几年嵌入式开发,见过太多产品出问题后只能干瞪眼的场景——没有日志,你连问题出在哪都不知道。这一章,咱们就把日志系统的基础打牢。
2.1 日志级别:DEBUG / INFO / WARN / ERROR
日志级别不是拍脑袋定的。我习惯把级别看作「严重程度」和「输出开关」的组合。你想想看,如果所有日志都打开,系统跑起来会慢得让你怀疑人生。所以,分级是必须的。
| 级别 | 含义 | 典型场景 | 我建议的开关策略 |
|---|---|---|---|
| DEBUG | 调试信息,开发阶段用 | 变量值、函数入口/出口、循环计数 | 发布版默认关闭,调试版打开 |
| INFO | 正常运行时的重要事件 | 系统启动、任务切换、外设初始化成功 | 发布版保留,但控制输出频率 |
| WARN | 异常但不影响运行 | 内存接近上限、重试次数超阈值 | 始终打开,用于预警 |
| ERROR | 严重错误,需要立即处理 | 硬件故障、看门狗复位、任务挂死 | 强制输出,不可关闭 |
我的经验:DEBUG 级别千万别在中断服务函数里用。我曾经在一个定时器中断里打印调试信息,结果系统直接卡死——printf 本身是阻塞的,中断里调用等于自杀。
2.2 日志格式规范:时间戳 + 模块 + 内容
日志格式不统一,分析起来就是灾难。我见过最离谱的日志,只有一行数字,连是谁打的都不知道。所以,格式必须定死。
我个人习惯的格式模板:
[时间戳] [级别] [模块] [文件名:行号] 内容
举个例子:
[2025-03-21 14:32:18.456] [INFO] [UART_DRV] [uart.c:128] 波特率配置完成: 115200
为什么要有时间戳?因为你要知道事件发生的顺序。为什么要有模块?因为你要快速定位是哪个子系统出了问题。为什么要有文件名和行号?因为代码改了之后,你还能找到原始位置。
注意:时间戳的精度要跟系统时钟匹配。如果系统只有毫秒级定时器,就别硬写微秒。我曾经在项目里看到有人用 RTC 的秒级时间戳,结果两个事件间隔 200ms,日志里显示同一秒——根本没法分析。
2.3 环形缓冲区实现
日志输出不能直接写文件,因为嵌入式系统没有文件系统,或者 Flash 寿命有限。环形缓冲区(Ring Buffer)是标准解法。说白了,就是一个固定大小的数组,写满了从头覆盖。
我画了一张图,帮你理解环形缓冲区的核心逻辑:
代码实现其实不复杂。我给出一个最简版本,你可以在项目里直接套用:
#define LOG_BUF_SIZE 256
typedef struct {
char buffer[LOG_BUF_SIZE][128]; // 128字节每行
uint16_t head; // 写指针
uint16_t tail; // 读指针
uint16_t count; // 当前条目数
} ring_log_t;
ring_log_t log_ring;
void log_write(const char *msg) {
strncpy(log_ring.buffer[log_ring.head], msg, 127);
log_ring.head = (log_ring.head + 1) % LOG_BUF_SIZE;
if (log_ring.count < LOG_BUF_SIZE) {
log_ring.count++;
} else {
// 缓冲区满了,tail 也要跟着移动
log_ring.tail = (log_ring.tail + 1) % LOG_BUF_SIZE;
}
}
int log_read(char *out, uint16_t size) {
if (log_ring.count == 0) return -1;
strncpy(out, log_ring.buffer[log_ring.tail], size - 1);
log_ring.tail = (log_ring.tail + 1) % LOG_BUF_SIZE;
log_ring.count--;
return 0;
}
核心要点:环形缓冲区最怕的就是「写覆盖读」。我建议在写入时判断缓冲区是否已满,如果满了就丢弃最旧的数据(覆盖 tail),而不是阻塞等待。日志系统不能影响主业务逻辑。
2.4 避坑指南
- 日志字符串太长怎么办?我建议每条日志控制在 128 字节以内。超过的部分截断,或者分多条输出。我曾经见过有人一条日志打了 2KB,直接把环形缓冲区撑爆了。
- 多任务环境怎么保证线程安全?写入操作必须加锁。我习惯用关中断的方式保护临界区,因为日志写入时间很短,关中断不会影响实时性。
- 日志输出到串口还是缓冲区?我的做法是:调试阶段同时输出到串口和缓冲区;发布阶段只写缓冲区,串口关闭。这样既不影响性能,又能在出问题时通过串口导出日志。
血的教训:我曾经在一个项目里,日志缓冲区大小设成了 64 条,结果系统跑了一天后,最早的日志已经被覆盖了 100 多次。等出问题想查日志时,关键信息早就没了。所以,缓冲区大小要根据系统运行时间和日志产生频率来算。一般建议至少能保存 5 分钟以上的日志。