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 写入完成,那主任务就被卡住了。尤其是在医疗设备里,传感器采样是硬实时任务,延迟几毫秒都可能出问题。

所以,日志写入必须异步。基本思路是这样的:

  1. 日志生产者(各个任务)把日志丢进环形缓冲区,立刻返回。
  2. 日志消费者(一个专门的日志任务)从缓冲区取出日志,批量写入 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 的磨损均衡、日志的加密存储(医疗数据有隐私要求)等细节,那些咱们后面章节再聊。