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——除非你需要频繁升级协议
- 永远加CRC——Flash比特翻转不是小概率事件
- 写前比较——减少无效擦写,延长Flash寿命
- 日志式存储——把磨损分散到整个扇区
- 预留备份区——写入过程中掉电,至少还有一份完整数据
我记得有一次做车载设备,客户要求数据存储10年不丢。当时Flash擦写寿命只有1万次,按每天存一次算,3年就挂了。后来用了差分存储+日志式轮换,把每天写入量从128字节降到8字节,寿命直接拉到10年以上。所以说,优化不光是技术问题,更是产品生命线的保障。
嵌入式序列化没有银弹。你得根据RAM大小、Flash寿命、数据更新频率来权衡。但记住一点:简单就是可靠。别在MCU上搞花里胡哨的协议栈,能用memcpy解决的问题,就别引入解析器。
公众号:蓝海资料掘金营,微信deep3321