第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里写日志,速度很慢。一个页写入可能要几十毫秒。如果每次打日志都同步写入,你的血氧仪就别干别的了。异步写入就是解决这个问题的。

基本思路是这样的:

  1. 日志先写入环形缓冲区(内存操作,微秒级)
  2. 后台任务定期把缓冲区的内容刷到Flash
  3. 刷写操作在空闲时进行,不影响主任务

我一般会用两种触发方式:

  • 定时触发:每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芯片用了半年就报废了。嗯,到时候细说。