第1章:日志系统架构设计
做嵌入式系统这么多年,我踩过最大的坑之一,就是日志系统没设计好。记得有一次,一个血氧仪在客户现场出了故障,设备死机了,但没有任何日志可查——因为日志缓冲区早就被冲掉了。那种感觉,就像你开车出了事故,但行车记录仪的内存卡坏了。所以今天这一章,我们来好好聊聊日志系统的架构设计。
1.1 日志级别定义
日志级别这东西,说白了就是给信息分个三六九等。我见过不少团队,一开始图省事,只分两种:普通信息和错误信息。结果到了调试阶段,满屏都是打印,根本分不清哪些是关键的。
我个人习惯用四个级别,够用且清晰:
| 级别 | 名称 | 说明 | 使用场景 |
|---|---|---|---|
| 0 | DEBUG | 调试信息 | 开发阶段,查看变量值、函数调用流程 |
| 1 | INFO | 普通信息 | 设备启动、传感器初始化完成、数据采集周期 |
| 2 | WARN | 警告信息 | 电池电量低、信号质量差、数据异常波动 |
| 3 | ERROR | 错误信息 | 传感器通信失败、存储写入错误、系统崩溃前 |
关键原则:生产环境中只开启INFO及以上级别。DEBUG日志只在开发调试时打开,否则你的Flash很快就会被写满。
我曾经在一个项目中,DEBUG日志没关就发布了。结果用户用了三天,设备就提示存储空间不足。嗯,从那以后,我每次发布前都会检查日志级别的宏定义。
1.2 日志格式规范
日志格式这件事,看起来简单,但做不好会让人抓狂。你想想看,如果每条日志只有消息内容,没有时间戳,你怎么知道故障发生在哪个时间点?
我建议的格式是这样的:
[2025-01-15 14:30:22.456] [INFO] 血氧传感器初始化完成,采样率: 100Hz
[2025-01-15 14:30:22.789] [WARN] 信号质量下降,当前信噪比: 18dB
[2025-01-15 14:30:23.012] [ERROR] SPI通信超时,重试第3次
格式拆解一下:
- 时间戳:精确到毫秒,格式为 yyyy-MM-dd HH:mm:ss.SSS
- 级别标签:用方括号包裹,对齐显示
- 消息体:简洁明了,包含关键数据
小技巧:时间戳的获取在嵌入式系统里是个麻烦事。如果MCU没有RTC,可以用系统滴答定时器(SysTick)来模拟相对时间。我一般会在系统启动时记录一个基准时间,然后每次打日志时计算偏移量。
1.3 环形缓冲区实现
为什么要用环形缓冲区?说白了,就是内存有限。血氧仪这种设备,RAM通常只有几十KB,你不能无限制地存日志。环形缓冲区就像一个循环队列,写满了就覆盖最旧的数据。
来看一个简单的C语言实现:
#define LOG_BUF_SIZE 1024 // 缓冲区大小,单位:字节
#define LOG_ENTRY_MAX 64 // 单条日志最大长度
typedef struct {
char buffer[LOG_BUF_SIZE];
uint16_t head; // 写指针
uint16_t tail; // 读指针
uint16_t count; // 当前有效日志条数
} RingBuffer;
void log_write(RingBuffer *rb, const char *msg) {
uint16_t len = strlen(msg);
if (len > LOG_ENTRY_MAX) len = LOG_ENTRY_MAX;
// 检查剩余空间,不够就覆盖旧数据
for (uint16_t i = 0; i < len; i++) {
rb->buffer[rb->head] = msg[i];
rb->head = (rb->head + 1) % LOG_BUF_SIZE;
if (rb->head == rb->tail) {
rb->tail = (rb->tail + 1) % LOG_BUF_SIZE; // 覆盖旧数据
}
}
rb->buffer[rb->head] = '\0'; // 字符串结束符
rb->count++;
}
注意:环形缓冲区最怕的就是读写指针不同步。我曾经在中断服务函数里直接写日志,结果主循环在读日志时,指针被中断修改了,导致数据错乱。解决方案是:写入时关中断,或者用无锁队列。
1.4 异步日志写入机制
直接往Flash里写日志,速度很慢。一个页写入可能要几十毫秒。如果每次打日志都同步写入,你的血氧仪就别干别的了。异步写入就是解决这个问题的。
基本思路是这样的:
- 日志先写入环形缓冲区(内存操作,微秒级)
- 后台任务定期把缓冲区的内容刷到Flash
- 刷写操作在空闲时进行,不影响主任务
我一般会用两种触发方式:
- 定时触发:每5秒刷一次,适合常规日志
- 条件触发:缓冲区快满时(比如达到80%),立即刷写
void log_flush_task(void *param) {
while (1) {
vTaskDelay(pdMS_TO_TICKS(5000)); // 每5秒执行一次
if (rb.count > 0) {
// 从环形缓冲区读取数据
char log_entry[LOG_ENTRY_MAX];
while (rb.count > 0) {
log_read(&rb, log_entry);
flash_write(log_entry); // 写入Flash
}
}
// 检查是否需要紧急刷写
if (rb.count > LOG_BUF_SIZE * 0.8 / LOG_ENTRY_MAX) {
log_flush_urgent(&rb); // 紧急刷写
}
}
}
避坑指南:我曾经在异步写入时犯过一个错误——刷写任务优先级设得太低。结果缓冲区满了,新日志覆盖了旧日志,而旧日志里正好有系统崩溃前的关键信息。后来我把刷写任务的优先级调高了一级,同时增加了「缓冲区满80%立即刷写」的逻辑,问题就解决了。
小结
日志系统看起来是个小功能,但设计不好真的会让人头疼。四个级别够用就行,别搞太复杂。格式统一,时间戳必须带。环形缓冲区是嵌入式系统的标配,但要注意线程安全。异步写入能保证系统性能,但优先级和触发条件要调好。
下一章,我们会聊聊日志存储的具体实现,包括Flash的分区管理和磨损均衡。到时候我会分享一个我踩过的坑——因为没做磨损均衡,一块Flash芯片用了半年就报废了。嗯,到时候细说。