17、序列化与嵌入式系统:资源受限环境下的序列化、Flash存储优化

嵌入式系统,说白了就是资源处处受限。RAM就那么几K,Flash也就几十K,CPU主频还低得可怜。在这种环境下搞序列化,跟PC上完全是两码事。我早年做一款工业采集器时,就吃过这个亏——把PC上的JSON库直接搬过去,结果RAM爆了,板子直接死机。

嗯,今天咱们就聊聊,在资源受限的嵌入式环境里,序列化到底该怎么玩。

嵌入式序列化的核心痛点

先列几个我踩过的坑:

  • RAM太小:动态内存分配容易碎片化,malloc几次就崩了
  • Flash写入寿命有限:Nor Flash一般10万次擦写,频繁写会报废
  • CPU算力不足:解析复杂协议(比如XML)能把MCU跑死
  • 没有文件系统:很多裸机项目直接操作Flash地址,没有文件层缓冲

所以,嵌入式序列化的第一原则是——能静态分配就别动态分配,能二进制就别文本化

方案一:紧凑二进制结构体(我最常用的方式)

说白了,就是直接拿结构体当序列化格式。你想想看,结构体在内存里本来就是二进制排布的,直接memcpy到Flash里,读回来再memcpy回来,多省事。

// 定义一个紧凑的结构体
#pragma pack(push, 1)  // 强制1字节对齐
typedef struct {
    uint8_t  type;       // 1字节
    uint16_t value;      // 2字节
    uint32_t timestamp;  // 4字节
    uint8_t  crc;        // 1字节
} SensorData_t;
#pragma pack(pop)

// 序列化:直接写入Flash
void save_to_flash(uint32_t addr, SensorData_t *data) {
    // 计算CRC
    data->crc = calc_crc8((uint8_t*)data, sizeof(SensorData_t) - 1);
    // 直接写Flash(注意Flash要先擦除)
    flash_write(addr, (uint8_t*)data, sizeof(SensorData_t));
}

// 反序列化:直接读取
void load_from_flash(uint32_t addr, SensorData_t *data) {
    flash_read(addr, (uint8_t*)data, sizeof(SensorData_t));
    // 校验CRC
    uint8_t crc = calc_crc8((uint8_t*)data, sizeof(SensorData_t) - 1);
    if (crc != data->crc) {
        // 数据损坏,走错误处理
        error_handler();
    }
}

我个人习惯:结构体里一定要加CRC字段。Flash存储有比特翻转的风险,不加校验,数据读出来是错的你都不知道。我曾经有一批设备在高温环境下运行,Flash数据偶尔会跳变,全靠CRC兜底。

方案二:TLV格式(灵活性与资源消耗的平衡)

结构体方式虽然快,但有个问题——版本升级时字段增减很麻烦。TLV(Type-Length-Value)格式就灵活多了。

// TLV格式定义
typedef struct {
    uint8_t  type;   // 字段类型
    uint16_t length; // 数据长度
    uint8_t  value[]; // 柔性数组,实际数据
} TlvItem_t;

// 序列化示例:写入一个温度值
uint8_t buffer[64];
uint8_t *p = buffer;

// 写入Type
*p++ = 0x01;  // 温度类型
// 写入Length(小端模式)
*p++ = 4;     // float占4字节
*p++ = 0;
// 写入Value
float temp = 25.6f;
memcpy(p, &temp, 4);
p += 4;

// 最终buffer长度 = p - buffer
uint16_t total_len = p - buffer;

TLV的好处是:解析时可以跳过不认识的Type,向前兼容。坏处是:每个字段多2-3字节开销,解析速度比结构体慢。

避坑指南:我曾经在STM32上实现TLV解析器,一开始用递归方式,结果栈溢出了。后来改成迭代+状态机,稳得很。嵌入式里尽量别用递归,栈空间太金贵。

Flash存储优化策略

Flash写入慢、寿命短,优化不好就是灾难。我总结了几条实战经验:

1. 写前检查,避免无效写入

// 愚蠢的写法:不管数据变没变,直接擦写
flash_erase_sector(addr);
flash_write(addr, data, len);

// 聪明的写法:先比较,有变化再写
uint8_t old_data[64];
flash_read(addr, old_data, len);
if (memcmp(old_data, data, len) != 0) {
    flash_erase_sector(addr);
    flash_write(addr, data, len);
}

你想想看,如果数据没变还去擦写,一次擦写就消耗一次寿命。这个优化在频繁保存场景下特别管用。

2. 日志式存储(Wear Leveling)

Flash的擦除单位是扇区(通常4KB),不能只擦一个字节。如果每次都往同一个地址写,那个扇区很快就废了。

我的做法是:把扇区当成环形缓冲区,每次写新数据都追加到下一个空闲位置。扇区写满了再整体擦除。

// 日志式存储示意
#define SECTOR_SIZE  4096
#define RECORD_SIZE  16

static uint16_t write_index = 0;

void log_write(uint8_t *record) {
    // 查找下一个空闲位置
    while (write_index < SECTOR_SIZE / RECORD_SIZE) {
        uint32_t addr = SECTOR_BASE + write_index * RECORD_SIZE;
        uint8_t header;
        flash_read(addr, &header, 1);
        if (header == 0xFF) {  // 空闲位置
            flash_write(addr, record, RECORD_SIZE);
            write_index++;
            return;
        }
        write_index++;
    }
    // 扇区满了,擦除后从头写
    flash_erase_sector(SECTOR_BASE);
    write_index = 0;
    flash_write(SECTOR_BASE, record, RECORD_SIZE);
    write_index++;
}

注意:日志式存储需要定期做垃圾回收。如果记录频繁写入,扇区擦除次数会集中在最后一个扇区。我一般会多留几个扇区做轮换,把磨损均匀分布。

3. 差分存储

很多嵌入式设备的数据是缓慢变化的(比如温度、湿度)。每次都存完整数据太浪费。我只存变化的部分。

// 差分存储格式
typedef struct {
    uint8_t  type;      // 0x01: 全量, 0x02: 差分
    uint8_t  field_id;  // 哪个字段变了
    uint8_t  data[4];   // 变化后的值
} DeltaRecord_t;

恢复数据时,先加载一个全量快照,然后按时间顺序回放差分记录。这样Flash写入量能减少80%以上。

知识体系总览

下面这张图总结了嵌入式序列化的核心逻辑:

嵌入式序列化核心知识体系 序列化方案 结构体直写 TLV格式 优点:速度快 缺点:难扩展 Flash存储优化 写前检查 日志式存储 磨损均衡 差分存储 可靠性保障 CRC校验 备份机制 掉电保护 版本兼容 核心原则 静态分配 > 动态分配 | 二进制 > 文本 | 写前检查 > 直接写入

实战建议总结

做了这么多年嵌入式,我总结了几条铁律:

  1. 能用结构体就别用TLV——除非你需要频繁升级协议
  2. 永远加CRC——Flash比特翻转不是小概率事件
  3. 写前比较——减少无效擦写,延长Flash寿命
  4. 日志式存储——把磨损分散到整个扇区
  5. 预留备份区——写入过程中掉电,至少还有一份完整数据

我记得有一次做车载设备,客户要求数据存储10年不丢。当时Flash擦写寿命只有1万次,按每天存一次算,3年就挂了。后来用了差分存储+日志式轮换,把每天写入量从128字节降到8字节,寿命直接拉到10年以上。所以说,优化不光是技术问题,更是产品生命线的保障。

嵌入式序列化没有银弹。你得根据RAM大小、Flash寿命、数据更新频率来权衡。但记住一点:简单就是可靠。别在MCU上搞花里胡哨的协议栈,能用memcpy解决的问题,就别引入解析器。


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