实战案例1:嵌入式设备日志系统设计

资源受限环境,Flash存储,低功耗——这三个词凑在一起,基本就是嵌入式开发的日常了。我这些年做过的项目,十有八九都跑在几KB的RAM上,Flash也就几十KB。在这种环境下搞日志系统,说白了就是戴着镣铐跳舞。

但日志又不能不搞。产品出问题的时候,没有日志,你连问题在哪都摸不着。我有个朋友,做智能水表,设备在现场跑了大半年,突然某天上报数据异常。他跑过去一看,日志缓冲区早就被冲烂了,啥也没留下。嗯,从那以后,他再也不敢轻视日志系统了。

一、需求分析:资源受限到底有多受限?

先看看我们面对的是什么环境。我拿一个典型的MCU项目举例:

资源项 典型值 说明
RAM 4KB ~ 32KB 运行时数据、堆栈全挤在这里
Flash 32KB ~ 256KB 代码+常量+日志存储共用
CPU主频 8MHz ~ 72MHz 写Flash时CPU会卡顿
功耗 uA级待机 频繁写Flash会显著增加功耗

你看,RAM才几KB,你不可能在内存里攒太多日志。Flash虽然大一点,但写入次数有限(通常1万~10万次),而且写入时电流大、时间长。低功耗设备如果每秒钟写一次Flash,电池撑不了几天。

二、核心设计思路:环形缓冲区 + 批量刷写

我个人的习惯是,在RAM里开一个环形缓冲区,日志先写到RAM里。等缓冲区满了,或者系统空闲时,再一次性刷到Flash里。这样既减少了Flash写入次数,又避免了频繁擦写带来的功耗问题。

说白了,就是用RAM换Flash寿命和功耗。你想想看,RAM写一次才几个时钟周期,Flash写一次要几毫秒,电流还大。这笔账怎么算都划算。

核心原则:

  • 日志先写RAM,满了再刷Flash
  • Flash采用追加写入,避免擦除操作
  • 系统休眠前,强制刷空RAM缓冲区
  • 日志等级可配置,低等级日志直接丢弃

三、数据结构设计

先定义日志条目。我一般用固定长度的结构体,这样方便在环形缓冲区里管理。

/* 日志等级 */
typedef enum {
    LOG_LEVEL_ERROR = 0,
    LOG_LEVEL_WARN,
    LOG_LEVEL_INFO,
    LOG_LEVEL_DEBUG
} log_level_t;

/* 日志条目结构体 */
typedef struct {
    uint32_t timestamp;   /* 时间戳,系统滴答计数 */
    uint8_t  level;       /* 日志等级 */
    uint8_t  module_id;   /* 模块ID,比如0x01表示传感器 */
    uint8_t  msg_id;      /* 消息ID,预定义的字符串索引 */
    uint8_t  param[4];    /* 最多4个字节的参数 */
} log_entry_t;

这里有个细节:我没有用字符串存日志内容,而是用msg_id。为什么?因为字符串太占Flash了。我在项目中遇到过,一个"Temperature sensor read failed"字符串就占了30多个字节。如果每条日志都带字符串,Flash很快就爆了。

我的做法是,在代码里维护一个字符串表,msg_id对应具体的字符串。输出日志时,通过msg_id查表还原。这样每条日志只占8个字节(时间戳4字节+等级1字节+模块ID1字节+消息ID1字节+参数1字节),非常紧凑。

四、环形缓冲区实现

环形缓冲区,说白了就是一个固定大小的数组,加上读写指针。写指针追着读指针跑,追上了就覆盖最旧的日志。

#define LOG_BUF_SIZE  64   /* 64条日志条目 */

static log_entry_t log_buf[LOG_BUF_SIZE];
static uint8_t     write_idx = 0;
static uint8_t     read_idx  = 0;
static uint8_t     count     = 0;   /* 当前缓冲区中的日志条数 */

void log_write(log_entry_t *entry) {
    /* 如果缓冲区满了,覆盖最旧的 */
    if (count == LOG_BUF_SIZE) {
        read_idx = (read_idx + 1) % LOG_BUF_SIZE;
        count--;
    }
    log_buf[write_idx] = *entry;
    write_idx = (write_idx + 1) % LOG_BUF_SIZE;
    count++;
}

嗯,这里要注意一个坑:多任务环境下,读写指针的访问需要加临界区保护。我一般用关中断的方式,因为MCU上很少跑RTOS,或者RTOS的临界区开销太大。

五、Flash存储策略

Flash的写入,我采用「扇区循环」的方式。什么意思呢?就是预留几个扇区,轮流写。每个扇区写满了,就切换到下一个。所有扇区都写满了,就覆盖最旧的那个扇区。

#define FLASH_SECTOR_SIZE  4096   /* 4KB一个扇区 */
#define FLASH_LOG_SECTORS  4      /* 预留4个扇区 */

static uint8_t  current_sector = 0;
static uint16_t sector_offset  = 0;  /* 当前扇区内的偏移 */

void flash_log_write(log_entry_t *entry) {
    /* 检查当前扇区是否已满 */
    if (sector_offset + sizeof(log_entry_t) > FLASH_SECTOR_SIZE) {
        /* 切换到下一个扇区 */
        current_sector = (current_sector + 1) % FLASH_LOG_SECTORS;
        sector_offset = 0;
        /* 擦除新扇区 */
        flash_erase_sector(FLASH_BASE + current_sector * FLASH_SECTOR_SIZE);
    }
    /* 写入日志条目 */
    flash_write(FLASH_BASE + current_sector * FLASH_SECTOR_SIZE + sector_offset,
                (uint8_t*)entry, sizeof(log_entry_t));
    sector_offset += sizeof(log_entry_t);
}

这里有个关键点:擦除操作很慢,而且会消耗大量电流。所以我只在切换扇区时才擦除,平时只做写入。另外,擦除操作最好放在系统空闲时做,避免影响实时任务。

六、低功耗处理

低功耗设备最怕什么?频繁唤醒。每次写Flash,MCU都要从休眠状态醒来,进入高频模式,写完再睡回去。这个过程的功耗开销,比写Flash本身还大。

我的策略是:

  • 日志先攒在RAM里,不急着写Flash
  • 只有当RAM缓冲区满,或者系统即将休眠时,才批量刷写
  • 刷写时,一次性写完所有待写日志,然后立即进入休眠

我曾经在一个NB-IoT水表项目里,用这个策略把Flash写入次数从每天几百次降到了每天几次。电池寿命从半年延长到了两年多。你想想看,效果还是很明显的。

小技巧:系统休眠前,检查一下RAM缓冲区里有没有未写入的日志。如果有,先刷到Flash再休眠。否则下次上电时,这些日志就丢了。

七、日志读取与导出

日志写进去了,怎么读出来?我一般提供两种方式:

  1. 串口导出:设备上电后,通过串口发送特定命令,设备就把Flash里的日志全部打印出来。这个适合开发调试阶段。
  2. 远程读取:对于联网设备,可以通过MQTT或CoAP协议,把日志上传到服务器。这个适合现场运维。

读取时,需要把msg_id还原成字符串。我一般维护一个字符串表:

const char* log_msg_table[] = {
    [0x01] = "Sensor read failed",
    [0x02] = "Communication timeout",
    [0x03] = "Battery voltage low",
    [0x04] = "Firmware update started",
    /* ... 更多消息 */
};

void log_print_entry(log_entry_t *entry) {
    printf("[%u] [%s] %s: %02x %02x %02x %02x\n",
           entry->timestamp,
           log_level_str(entry->level),
           log_msg_table[entry->msg_id],
           entry->param[0], entry->param[1],
           entry->param[2], entry->param[3]);
}

八、整体架构图

下面这张图,展示了整个日志系统的数据流。从应用层产生日志,到RAM缓冲区暂存,再到Flash持久化,最后通过串口或网络导出。我画这张图的时候,特意把关键路径标红了。

嵌入式日志系统架构图 应用模块 产生日志事件 日志API接口 log_write() / log_flush() RAM环形缓冲区 64条日志条目暂存 Flash扇区循环 4个扇区轮流写入 日志导出接口 串口 / MQTT / CoAP 调用 写入 批量刷写 读取 离线读取 图例: 应用层 接口层 RAM缓冲区 Flash存储 导出接口 实线:实时路径 | 虚线:离线路径

九、避坑指南

做这个系统,有几个坑我踩过,分享给你:

我曾经踩过的坑:

  • Flash写入时掉电:写入过程中如果掉电,Flash里的数据可能损坏。我的做法是,写入前先写一个「开始标记」,写入完成后写一个「结束标记」。上电时检查这两个标记,如果不一致,说明上次写入未完成,丢弃该条日志。
  • 缓冲区溢出:如果日志产生速度远大于Flash写入速度,RAM缓冲区会频繁被覆盖。我一般会加一个「丢弃计数」,记录被覆盖的日志条数。这样至少知道丢了日志,而不是闷头干活。
  • 时间戳同步:MCU的时间戳通常来自系统滴答,但系统休眠时滴答会停。我建议在休眠前记录一个「休眠时间戳」,醒来后补偿。否则日志里的时间戳会乱掉。

十、总结

嵌入式日志系统,说白了就是在「资源」和「功能」之间找平衡。RAM不够,就用Flash;Flash寿命有限,就用RAM缓冲;功耗敏感,就批量写入。没有银弹,只有权衡。

我做了这么多年嵌入式,最大的体会就是:日志系统不是写出来的,是「磨」出来的。一开始可能很简单,随着项目推进,你会不断发现新的边界情况,然后一点点打补丁。嗯,这很正常。关键是,你要有一个清晰的架构,让这些补丁有地方可打。

最后说一句:别等到产品出问题了才想起日志。那时候,黄花菜都凉了。


公众号:蓝海资料掘金营,微信deep3321