4. 日志系统架构设计:日志级别、分类、环形缓冲区与异步写入
好,咱们今天聊聊日志系统。说实话,在医疗嵌入式系统里,日志这东西,平时你可能觉得它不起眼。但一旦设备在现场出了故障,它就是唯一的“黑匣子”。我见过太多项目,因为日志没设计好,出了问题只能干瞪眼,连复现都难。
所以,这一章咱们把日志系统的几个核心点掰开揉碎讲清楚:日志级别怎么定、日志怎么分类、环形缓冲区怎么设计、以及如何实现异步写入。嗯,这四点搞定了,你的日志系统基本就稳了。
4.1 日志级别定义:别什么都往里面塞
日志级别,说白了就是给每条日志贴个“重要性标签”。我个人习惯,在医疗设备里,一般用这五个级别就够了:
| 级别 | 数值 | 含义 | 典型场景 |
|---|---|---|---|
| FATAL | 0 | 致命错误 | 系统崩溃、看门狗复位、内存校验失败 |
| ERROR | 1 | 错误 | 传感器通信超时、参数越界、任务挂起 |
| WARN | 2 | 警告 | 电池电量低、温度接近阈值、重试次数过多 |
| INFO | 3 | 信息 | 设备启动、模式切换、校准完成 |
| DEBUG | 4 | 调试 | 变量值打印、函数进入/退出、状态机跳转 |
你可能会问,为什么不用 TRACE?我的经验是,在医疗嵌入式系统里,DEBUG 级别已经够用了。TRACE 太细,容易把 Flash 写穿。我曾经在一个项目里,DEBUG 日志一天写了 2MB,后来发现是某个循环里忘了关日志打印……嗯,从那以后我对日志量就特别敏感。
核心原则:生产环境只保留 FATAL、ERROR、WARN 三个级别。INFO 和 DEBUG 只在开发调试阶段开启。通过编译宏或者运行时开关来控制。
4.2 日志分类:按模块切分,方便定位
光有级别还不够。你想想看,一个医疗设备里,有电源管理、传感器采集、通信协议、用户界面……如果所有日志都混在一起,出了事你根本不知道从哪查起。
所以,我建议按模块分类。每个日志条目都带一个“模块标签”。举个例子:
// 日志分类枚举
typedef enum {
LOG_MOD_SYS, // 系统模块(启动、时钟、内存)
LOG_MOD_PWR, // 电源管理
LOG_MOD_SENSOR, // 传感器
LOG_MOD_COMM, // 通信(蓝牙、WiFi、串口)
LOG_MOD_UI, // 用户界面
LOG_MOD_ALGO, // 算法模块
LOG_MOD_MAX
} log_module_t;
这样,你在分析日志时,可以快速过滤出某个模块的日志。比如传感器异常,直接搜 LOG_MOD_SENSOR 相关的条目就行。我在做呼吸机项目时,就靠这个分类,半小时内定位到了一个压力传感器采样时序问题。
小技巧:每个模块可以单独设置日志级别。比如算法模块在调试阶段可以开 DEBUG,但通信模块只开 WARN 以上。这样既保证了调试深度,又控制了总日志量。
4.3 环形缓冲区设计:有限空间,无限循环
好,级别和分类定好了,接下来就是日志往哪存。在嵌入式系统里,内存是稀缺资源。你不能像 PC 那样开个几百 MB 的日志文件。所以,环形缓冲区(Ring Buffer)就成了标配。
它的原理很简单:一块固定大小的内存,写指针一直往前跑,跑到头就绕回开头。如果写满了,就覆盖最旧的数据。嗯,说白了就是“丢旧保新”。
我给出一个典型的设计:
#define LOG_BUF_SIZE 4096 // 4KB 环形缓冲区
typedef struct {
uint8_t buffer[LOG_BUF_SIZE];
uint32_t head; // 写指针
uint32_t tail; // 读指针
uint32_t count; // 当前有效条目数
} ring_buffer_t;
// 写入一条日志
int log_write(ring_buffer_t *rb, const uint8_t *data, uint32_t len) {
// 检查剩余空间,如果不够,就移动 tail 腾出空间
while ((rb->head + len) % LOG_BUF_SIZE > rb->tail) {
// 覆盖最旧的数据
rb->tail = (rb->tail + 1) % LOG_BUF_SIZE;
rb->count--;
}
// 写入数据
for (uint32_t i = 0; i < len; i++) {
rb->buffer[rb->head] = data[i];
rb->head = (rb->head + 1) % LOG_BUF_SIZE;
}
rb->count++;
return 0;
}
注意:环形缓冲区最怕的就是“写覆盖读”。如果读指针(tail)追上了写指针(head),数据就丢了。所以,一定要在写入前检查剩余空间。我见过一个案例,因为没做这个检查,导致日志缓冲区被写坏,系统直接跑飞了。
我个人建议,环形缓冲区的大小根据你的 Flash 写入寿命来定。比如 Flash 擦写次数是 10 万次,你每天写 100 次日志,那缓冲区至少能撑 2-3 年。别太小,否则频繁擦写会缩短 Flash 寿命。
4.4 异步日志写入:别让日志拖慢主流程
最后一个关键点:异步写入。你想想看,如果每次写日志都要等 Flash 写入完成,那主任务就被卡住了。尤其是在医疗设备里,传感器采样是硬实时任务,延迟几毫秒都可能出问题。
所以,日志写入必须异步。基本思路是这样的:
- 日志生产者(各个任务)把日志丢进环形缓冲区,立刻返回。
- 日志消费者(一个专门的日志任务)从缓冲区取出日志,批量写入 Flash 或通过串口输出。
代码骨架如下:
// 日志生产者:只写缓冲区,不写 Flash
void log_info(const char *module, const char *fmt, ...) {
// 格式化日志字符串
// 写入环形缓冲区
ring_buffer_write(&g_log_rb, log_entry, len);
}
// 日志消费者:后台任务,定期刷 Flash
void log_flush_task(void *param) {
while (1) {
// 从环形缓冲区读取一批日志
// 批量写入 Flash(或者通过 DMA 发送到串口)
flash_write_batch(log_batch, batch_len);
// 延时 100ms,避免频繁擦写
vTaskDelay(pdMS_TO_TICKS(100));
}
}
关键点:异步写入的核心是“解耦”。生产者和消费者通过环形缓冲区通信,互不阻塞。生产者只管快,消费者保证数据最终落盘。这样,即使 Flash 写入慢,也不会影响主任务的实时性。
我曾经在一个心电监护仪项目里,因为日志写入是同步的,导致心电数据采集出现了周期性抖动。后来改成异步,问题立刻消失。嗯,从那以后,我所有项目的日志系统都是异步的。
总结一下
日志系统设计,说白了就是四个字:分级、分类、缓冲、异步。
- 分级:FATAL/ERROR/WARN/INFO/DEBUG,生产环境只开前三个。
- 分类:按模块切分,方便快速定位问题。
- 缓冲:环形缓冲区,固定大小,循环利用,注意防覆盖。
- 异步:生产者写缓冲区,消费者批量落盘,不阻塞主流程。
这套架构,我在多个医疗产品里验证过,稳定可靠。你照着这个思路去设计,基本不会出大问题。当然,具体实现时还要考虑 Flash 的磨损均衡、日志的加密存储(医疗数据有隐私要求)等细节,那些咱们后面章节再聊。