4. 参数存储与加载:EEPROM/Flash存储结构、参数校验机制、默认参数回退策略

校准参数调好了,然后呢?

得存起来。而且得存得稳、读得准、万一坏了还能救回来。

我见过太多项目,算法调得漂漂亮亮,结果一断电重启,参数全乱套了。说白了,存储这块没做好,前面的校准工作全白费。

这一章,我们就来聊聊参数怎么存、怎么校验、怎么兜底。

4.1 存储介质的选择:EEPROM vs Flash

嵌入式系统里,常用的非易失存储就两种:EEPROM 和 Flash。很多人觉得它们差不多,其实差别挺大。

特性 EEPROM Flash
擦写单位 字节 扇区(通常 4KB)
擦写寿命 100万次左右 1万~10万次
写入速度 慢(ms级) 快(us级)
典型容量 1KB ~ 512KB 512KB ~ 32MB+
适用场景 频繁小量更新 批量存储、固件

我个人习惯:校准参数用 EEPROM。为什么?因为参数经常要改,比如用户调了个灵敏度,或者产线做了个微调。EEPROM 按字节擦写,寿命长,改一个参数不用动整个扇区。Flash 呢?改一个字节得先把整个扇区擦掉再重写,万一中间断电,数据就悬了。

我的经验:如果芯片内部没有 EEPROM,只能用 Flash 模拟。那就得做“磨损均衡”——把参数分散写到不同扇区,别逮着一个扇区猛擦。我曾经有个项目,没做均衡,半年就报废了一批设备。

4.2 存储结构设计:别把参数扔进去就完事

参数存进去,不是简单地把结构体 memcpy 到某个地址就完事了。你得设计一个清晰的存储布局。

我常用的结构是这样的:

// 参数存储结构体
typedef struct {
    uint32_t magic;          // 魔数,用于识别有效数据
    uint32_t version;        // 参数版本号
    uint32_t crc32;          // 整个结构体的 CRC32 校验值
    uint32_t timestamp;      // 写入时间戳(可选)
    
    // 实际校准参数
    float gain_x;
    float gain_y;
    float gain_z;
    int16_t offset_x;
    int16_t offset_y;
    int16_t offset_z;
    uint8_t reserved[32];    // 预留空间,方便后续扩展
    
    uint8_t end_marker;      // 结束标记,固定为 0xAA
} CalParam_t;

为什么要有 magic 和 version?

  • magic:用来判断这片存储区域是不是我们的参数。比如我习惯用 0xCAL1,读出来不对,说明要么是空片,要么数据被破坏了。
  • version:驱动版本升级了,参数结构可能变了。有了版本号,加载时就能做兼容处理。

另外,预留空间很重要。你想想看,产品迭代到 V2.0,要加一个温度补偿系数。如果当初没留空间,就得改存储布局,还得处理旧参数的迁移。麻烦得很。

4.3 参数校验机制:别让错误数据跑起来

数据存进去了,怎么保证读出来是对的?

我见过最粗暴的做法:存进去,读出来,直接就用。结果 Flash 某个位翻转了,设备直接跑飞。

校验机制,我一般分三层:

  1. 魔数校验:先看 magic 对不对。不对,直接判定无效。
  2. CRC32 校验:对整个参数块算 CRC,存的时候算一次,读的时候再算一次。不一致,说明数据被篡改或损坏。
  3. 范围合理性校验:这是很多人忽略的。比如加速度计的偏移值,正常范围是 ±2000。如果读出来是 99999,那肯定有问题。我习惯在代码里写死每个参数的上下限。
核心原则:校验失败,绝不使用该参数。直接走默认参数回退流程。

代码示例:

bool CalParam_Load(CalParam_t *param) {
    // 1. 从 EEPROM 读取原始数据
    EEPROM_Read(PARAM_ADDR, (uint8_t*)param, sizeof(CalParam_t));
    
    // 2. 魔数校验
    if (param->magic != CAL_MAGIC) {
        return false;
    }
    
    // 3. CRC 校验
    uint32_t calc_crc = CRC32_Calc((uint8_t*)param, 
                                   offsetof(CalParam_t, crc32));
    if (calc_crc != param->crc32) {
        return false;
    }
    
    // 4. 范围校验
    if (!CalParam_CheckRange(param)) {
        return false;
    }
    
    return true;
}
注意:计算 CRC 时,要跳过 crc32 字段本身。不然你算出来的 CRC 和存进去的 CRC 永远对不上。

4.4 默认参数回退策略:最后的防线

校验失败了怎么办?

不能直接罢工。设备得能跑起来,哪怕性能差一点。

我一般设计三级回退策略:

级别 触发条件 行为
一级回退 CRC 校验失败 尝试加载备份参数(如果有双备份)
二级回退 备份也失败 加载编译时固化的默认参数
三级回退 默认参数范围异常 加载安全参数(保守值,保证设备不损坏)

我曾经在一个电机驱动项目里,EEPROM 物理损坏了,读出来全是 0xFF。幸好有默认参数回退,设备虽然精度下降,但至少能转起来,没造成产线停线。

默认参数怎么来?

  • 在代码里以 const 数组形式固化。
  • 产线第一次烧录时,把默认参数写入 EEPROM。
  • 如果设备第一次上电,检测到空片,自动写入默认参数。
// 编译时固化的默认参数
const CalParam_t g_default_param = {
    .magic   = CAL_MAGIC,
    .version = CAL_VERSION,
    .crc32   = 0,  // 运行时计算
    .gain_x  = 1.0f,
    .gain_y  = 1.0f,
    .gain_z  = 1.0f,
    .offset_x = 0,
    .offset_y = 0,
    .offset_z = 0,
    .end_marker = 0xAA
};

void CalParam_LoadWithFallback(CalParam_t *param) {
    if (CalParam_Load(param)) {
        return;  // 正常加载
    }
    
    // 尝试加载备份
    if (CalParam_LoadFromBackup(param)) {
        return;
    }
    
    // 使用默认参数
    memcpy(param, &g_default_param, sizeof(CalParam_t));
    param->crc32 = CRC32_Calc((uint8_t*)param, 
                              offsetof(CalParam_t, crc32));
}

4.5 整体流程:一张图说清楚

上面讲了这么多,我画个流程图,把整个参数加载的决策逻辑串起来。

参数加载与回退决策流程 系统上电 从 EEPROM 读取主参数 校验通过? 使用主参数 尝试加载备份 备份有效? 使用备份参数 加载默认参数 默认有效? 使用默认参数 加载安全参数 说明: 安全参数是编译时写死的保守值, 保证设备不损坏、不跑飞。 产线需重新校准并写入 EEPROM。

嗯,这张图基本把整个流程说清楚了。从主参数到备份,再到默认,最后到安全参数,每一层都是对上一层失败的兜底。

4.6 避坑指南

最后,分享几个我踩过的坑:

  • 别在中断里写 EEPROM/Flash。 写入时间不确定,中断上下文里搞这个,系统实时性全毁了。我一般用一个后台任务,或者写请求队列。
  • 写操作要防掉电。 写 Flash 扇区时,如果中途断电,数据可能半残。我习惯先写备份区,再写主区。或者用“写标志位”策略——先写数据,最后写一个“写入完成”标志。如果标志没写成功,下次启动就知道上次写入没完成。
  • 默认参数也要做范围校验。 别以为编译时固化的就绝对安全。我遇到过编译器优化把 const 数组放到了错误地址,读出来全是乱的。所以,默认参数加载后,也要过一遍范围检查。
  • 产线写入后,一定要回读验证。 我曾经有个项目,产线烧录器时序有问题,写进去 100 套,有 3 套的 CRC 是错的。后来加了写入后立即回读校验,才堵住这个漏洞。
一句话总结:参数存储,设计时多花一小时,调试时少花一整天。校验、回退、备份,一个都不能少。