本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

嵌入式参数存储设计:
Flash 分区、双区备份与 CRC 校验

“把参数存进 Flash”这件事,写起来往往只有十几行,出问题却常常发生在半年以后: 结构体改了一个成员、升级固件之后参数全乱、写到一半掉电设备再也起不来。 这篇笔记以我用过的一块 STM32G030C8T6(64 KB Flash / 8 KB RAM)的 Bootloader 项目为例, 把分区规划、寿命算术、结构体 + CRC 的脆弱点、双区备份的做法和两次真实的排查记录整理在一起。

Flash 参数存储 CRC STM32G030 Bootloader 已发布

一、参数存储为什么值得单独设计

在嵌入式项目里,“参数”这个词其实混着两类完全不同的东西:

  • 几乎不变的配置:设备地址、标定系数、硬件号、软件号、量程上下限。它们可能几个月都不改一次。
  • 一直在变的状态:累计运行时间、剩余量、最后一次故障码、计费累计值。它们可能每秒都在动。

我早期偷懒,把这两类东西塞进同一个结构体,一起写进片上 Flash。 结果是:一块本来能用很多年的设备,因为“运行时间”每十分钟存一次,把 Flash 的擦写次数很快耗掉了。 从那以后我把参数存储当成一个需要单独设计的模块,而不是“随手存一下”。

它值得单独设计,还有一个更现实的原因:参数存储是少数几个“出错就很难远程挽回”的地方。 通信错了可以重发,采集错了可以滤波,但参数区一旦被写坏,设备可能连启动都起不来, 而且它出问题的时刻往往是固件升级之后——那正是你最不想跑现场的时候。

这篇笔记里的例子来自一块 STM32G030C8T6 的 Bootloader 项目(64 KB Flash、8 KB RAM), 带双区参数存储;对比项则是我另一块 HC32F030 的采集小板,用的是最简单的一区整体落盘, 那块的实现细节我写在 《两路液位 + 两路温度采集小板》里, 那块板子的通信部分(也就是“参数最后是为谁服务的”)则整理在 《Modbus RTU 从站实现笔记:寄存器表、CRC 与异常码》中。 两块板子思路不同,正好能说明“什么时候简单方案够用、什么时候不够”。

顺带说一句:参数存储和采集调度在资源上是互相牵制的。我那块采集小板之所以能一边密集通信、 一边稳定采集,靠的是时间片轮询把两类工作错开;而写 Flash 恰好是少数几个“会长时间占用 CPU”的操作, 放在哪个时间片里、要不要临时关中断,都要一起考虑。时间片那套写法的整理在 《不用 RTOS 的固件怎么写:时间片轮询 + 消息队列》里。

二、Flash 的物理约束:先算清楚能写多少次

Flash 和 EEPROM 最大的区别是:Flash 能按字节(或按双字)编程,但只能按页擦除, 而擦除次数是有上限的。STM32G030C8T6 的数据手册给出的 Flash 擦除次数是 最低 1000 次。注意这个“最低”——它是保证值,不是典型值,做设计时应该按它算。

1000 次听起来很少,但把“保存频率”代进去算一下,就知道它到底意味着什么:

保存频率一天的擦写次数1000 次能用多久结论
每 10 分钟一次144 次约 7 天绝对不行
每小时一次24 次约 41 天仍然不行
每天一次1 次约 1000 天(近 3 年)勉强,但不保险
只在参数被修改时写取决于人工操作通常按年计这才是参数该有的写法

这张表是我自己在纸上算过一遍之后才真正记住的:绝不能把“频繁变化的状态”存进片上 Flash。 真正适合放 Flash 的是那些几乎不变的配置——设备地址、标定系数、硬件号、软件号。 如果确实需要频繁记录(比如累计量、运行日志),有三条路:

  1. 外挂 EEPROM 或 FRAM。FRAM 的擦写次数比 Flash 高好几个数量级,写起来也不用心疼。
  2. 做写均衡:把一页切成若干槽轮流写,写满一整页才擦一次,相当于把擦除次数摊薄。
  3. 只在掉电瞬间写一次:靠电源检测中断 + 储能电容撑住最后一次写入。这条对硬件有要求,我没做过。

关于“10 万次循环测试”我要说清楚一件事

同一份测试计划里还有一项我认为很值得借鉴:不同电压下的参数存储稳定性测试, 覆盖 2.7 V ~ 3.6 V 这个范围。这一条很容易被忽略, 但低压下的 Flash 写入失败率确实会变高——如果设备靠电池供电、或者掉电瞬间还在写参数, 低压写入是否可靠就直接决定了参数会不会丢。

三、分区规划:把参数区和代码区分开

参数存储的第一步不是写代码,而是切地址。STM32G030C8T6 有 64 KB Flash, 我在链接脚本 STM32G030C8Tx_FLASH.ld 里把它切成三块:

区域地址范围大小说明
Bootloader0x08000000 ~ 0x08003FFF16 KB上电先跑它,负责跳转与升级
应用程序区0x08004000 ~ 0x0800F7FF约 46 KB真正的业务固件
参数段0x0800F800 起约 2 KB双区参数存储的落脚点

这样切分的好处是:升级应用程序时,参数区不会被碰到。 如果参数和代码混在同一段里,一次固件升级就可能把参数一起擦掉, 用户会发现“每次升级都要重新标定”——这种问题在现场非常招人烦。

写入:为什么是按 8 字节

分区定好之后,写入接口也必须跟着硬件约束走。STM32G0 的 Flash 编程最小单位是 双字(64 位,也就是 8 字节),不能真的按单字节写。 所以我的 STMFLASH_Write_Byte() 对外虽然叫“写字节”,内部做的是 8 字节对齐写入:调用者给多少数据都行,不足 8 字节的部分补 0xFF。

/* 对外按字节调用,内部凑满 8 字节再写:STM32G0 的 Flash 编程最小单位是双字 */
u8 buf[8];
u8 i, j;

for (i = 0; i < len; i += 8) {
    for (j = 0; j < 8; j++) {
        /* 不足 8 字节的部分补 0xFF(Flash 擦除后的状态就是 0xFF) */
        buf[j] = ((i + j) < len) ? data[i + j] : 0xFF;
    }
    /* 按双字对齐写入 addr + i 开始的 8 个字节 */
    STMFLASH_Write_DoubleWord(addr + i, buf);
}

配套的测试用例也很具体:传入 63 字节数据,程序应当自动补 1 字节, 并且按 8 字节对齐写入。这类“边界长度”用例比“传 64 字节”有用得多, 因为真正会出问题的永远是那个除不尽的情况。

擦除:一次真实的 HAL 报错

擦除是按页做的,我封装成 stm32_FLASH_ErasePage()。 这个函数后来被我加了一段地址合法性检查,原因是一个实际踩到的坑:

/* 擦除按页进行:先把无效请求拦在自己的封装层里,再交给 HAL */
u8 stm32_FLASH_ErasePage(u32 addr)
{
    if ((addr % FLASH_PAGE_SIZE) != 0) {          /* 非页对齐:无效请求 */
        return FLASH_ERR_ALIGN;                   /* 提前拦截,返回明确错误 */
    }
    if (addr < APP_FLASH_BASE || addr >= PARAM_FLASH_END) {
        return FLASH_ERR_RANGE;                   /* 越界:不允许擦代码区 */
    }
    if (HAL_FLASHEx_Erase(&eraseInit, &pageError) != HAL_OK) {
        return FLASH_ERR_HW;                      /* 到这里才是真的硬件/时序问题 */
    }
    return FLASH_OK;
}
一条纵向地址轴,从上到下划分:引导区、应用程序区、参数区,每块标注大小与是否可写,参数区单独放大显示内部布局
图 1 · Flash 分区示意图

四、结构体 + CRC:一个“够用但很脆”的方案

具体的数据组织方式,我用的是最常见的做法:把设置参数放在结构体 SysSet 里, 状态量放在 SysSta 里,两者在参数区里的偏移由 system_param.c 中的 STM32_FLASH_PARAM_OFFSET 定义,整个参数系统按双区存放。 上电启动时调用一次 ParameterIni() 完成加载。

校验部分非常省事,因为 STM32G0 自带 CRC 外设,直接调 HAL 就行:

/* 把结构体的最后一个字当作 CRC 存放位置,前面所有字参与计算 */
parCRC = HAL_CRC_Calculate(&hcrc, ptr, sizeof(SysSet)/4 - 1);

if (ptr[sizeof(SysSet)/4 - 1] == parCRC) {
    /* 校验通过,加载参数 */
} else {
    defaultParameter();      /* 校验失败:恢复默认参数 */
}

这个写法有个很讨巧的地方:CRC 不需要单独规划存储位置, 它天然就占在结构体末尾的最后一个字上。代价是——参数区里 CRC 的位置会随结构体大小浮动。 下面这三点,是我用下来觉得必须提前知道的脆弱之处。

脆弱点一:sizeof 把填充字节也算进去了

这是最容易中招的一条。sizeof(SysSet) 统计的是结构体在内存里的实际占用, 包含编译器为了对齐而插入的填充字节。如果结构体里混用了 u8 / u16 / u32, 这些填充字节的内容是未定义的——它们可能来自栈上的残留值,也可能是上一次用过的内存里的旧数据。 写进 Flash 的时候它们是某个确定的值,读回来重新算 CRC 时如果填充字节已经被覆盖, 校验就会莫名其妙地失败。

脆弱点二:布局会变

成员顺序调整、增删成员、换一个编译器、甚至只是换一个优化等级, sizeof 和各个成员的偏移都可能变。老设备里存的是旧布局的数据, 新固件按新布局去读,读出来的就是一堆错位的值—— 而且程序不会报错,因为它只是忠实地把字节解释错了。

脆弱点三:CRC 位置浮动,双区不好做

因为 CRC 放在结构体尾部,结构体一变长,CRC 的位置就跟着往后挪。 做双区备份时,两个区的布局也跟着变,很容易出现“A 区按新布局、B 区还是老布局”的尴尬局面。

五、双区备份与原子切换

双区备份要解决的是一个很物理的问题:Flash 只能“先擦后写”。 擦除和写入之间有一段时间窗,如果这中间掉电,这一区就废了—— 只剩下半页数据,CRC 必然对不上。有 A/B 两个区的时候,任何时刻都至少有一份完整数据, 这就是双区存在的全部理由。

具体实现上,我见过和用过的做法大致有三种:

做法怎么判断用哪一份优点代价
带序号的双区 每区头部存一个递增序号,启动时选序号大且 CRC 正确的那份 逻辑清晰,写入次数天然均摊到两个区 每次写入要改写序号,需要多一次擦写
主备 + 有效标志 平时只写备用区,写完置“有效”标志,再互换主备角色 写入路径固定,出问题时主区始终没被碰过 标志位本身也要原子地写,实现细节更多
日志式 / 槽式 一页切成 N 个槽顺序写,读的时候取最后一个有效槽 本质是简单的写均衡,擦除次数被摊薄 N 倍 需要管理槽状态,读取逻辑比前两种复杂

无论选哪一种,判断“哪一份有效”的原则都是一句话: 先看 CRC,再看序号。序号大的那一份如果 CRC 坏了, 要能自动回退到序号小的那一份,而不是直接报错或者加载一堆垃圾数据。 回退能力才是双区的价值所在——如果两份都坏了,那双区和单区没有区别。

并排 A、B 两个参数区,每区头部有递增序号与校验值,流程图展示:启动 → 读两区序号与校验 → 选序号大且校验通过的一
图 2 · 双区备份与原子切换流程图

六、默认值回退:让设备永远能开机

参数校验失败怎么办?我的做法很保守:恢复默认参数,也就是调用 defaultParameter()。宁可让设备用一套保守的默认值先跑起来, 也不要让它卡在启动阶段黑屏——那意味着用户连“进去改回来”的机会都没有。

但“有默认值回退”并不等于“参数一定正确”。这一点是我用一次实际的故障换来的:

这件事之后我给自己定了一条规矩:新增任何一个参数成员, 必须同时在三处出现——结构体定义、默认值赋值、合法性范围检查。 少一处,就等于埋了一个“只会在别人机器上出现”的问题。

七、参数版本号:为将来留一条路

“这个结构体以后会不会改?”——我的经验是:只要项目还活着,它就会改。 所以与其祈祷它不变,不如一开始就在参数区头部留出几个字段:

/* 参数区头部:先校验魔数、版本、长度,三项都过了再校验 CRC */
typedef struct {
    u32 magic;      /* 固定魔数:确认这一区确实是参数区,而不是被别的东西占了 */
    u16 version;    /* 参数版本号:用来判断需不需要做迁移 */
    u16 length;     /* 参数正文长度:布局一变,这里就对不上,能第一时间发现 */
    u32 crc;        /* 覆盖以上字段 + 参数正文 */
} param_header_t;

这四个字段各有用处,缺一不可:

  • 魔数:Flash 擦除后的状态是全 0xFF,一块全新的板子读出来就是一片 0xFF。 魔数能让你区分“这是空的”和“这是坏数据”——这两种情况的处理策略完全不同。
  • 版本号:老版本数据遇到新固件时,可以选择迁移、可以采用默认值、 也可以只保留还能对得上的字段,而不是一律丢弃。
  • 长度:这是最实用的一项。结构体变了,长度通常会变, 加载时能立刻发现“这份数据和我的结构体不是一个尺寸”,比 CRC 失败更早、更明确。
  • CRC:放在最后算,覆盖前面所有字段和正文,防止任何一处被写坏。

有了版本号,“参数结构体改了”这件事才从“灾难”变成“一次正常的迁移”。 这也是我在两块板子上得到的最实际的对比结论:

对比项HC32F030 采集小板STM32G030 Bootloader 项目
存储方式结构体整体按字节写入 Flash双区存储,设置参数与状态量分开
参数区起始地址 0x0000FE00,只有一份参数段约 2 KB,分两个区
校验无CRC 外设计算,逐项加载
版本号无有(这是我坚持要补上的部分)
代码量约十几行明显更多,但排查路径清晰
怕什么结构体布局一变就全乱;擦写中途掉电就丢怕实现不完整(我的原子切换就还没做完)

所以我的判断标准不是“项目大小”,而是“这个结构体以后会不会改”: 参数极少、结构体基本不动的小项目,单区整体落盘完全够用,十几行代码也没什么不好; 但只要参数结构体会随着需求演进,就必须上“版本号 + 双区”。 因为这时候省下的那点代码量,会在第一次固件升级时连本带利地还回去。

八、几条我反复用到的经验

  1. 先算寿命,再决定存哪。把保存频率乘上 1000 次,看看能撑几天;算过一次就再也不会忘记。
  2. 参数区必须和代码区分开。这是升级固件时参数不被擦掉的前提,在链接脚本里一次切开就好。
  3. 把手册上的“最低 1000 次”当设计依据,把“10 万次循环测试”当余量摸底,两者不要混为一谈。
  4. 封装层要做参数校验,返回自己的错误码。不要把 HAL_ERROR 直接透传给上层。
  5. 不要依赖结构体的内存布局。要么显式打包,要么在头部放魔数 + 版本号 + 长度。
  6. CRC 通过不等于数据正确。每个成员都要有默认值来源和范围检查——这是 NTC 那次故障教我的。
  7. 先看 CRC 再看序号。双区备份的价值在于“能回退到旧的那一份”,而不是“有两份”。
  8. 参数类的改动,一定要连着重启验证。我那次 NTC 的问题,重启 10 次才让我确信修好了。

参考资料与说明

  • STM32G030 系列数据手册(Flash 存储器特性、擦写次数、编程单位)与参考手册(Flash 编程/擦除流程、CRC 外设)。
  • HC32F030 系列用户手册,用于确认 Flash 控制器与参数区地址。
  • DS18B20 数据手册,用于确认温度传感器相关参数的取值依据。
  • ST 官方 AN 系列应用笔记中关于“用 Flash 模拟 EEPROM / 双区参数存储与写均衡”的公开文档, 这类文档对分区、擦写寿命与掉电保护的讨论比任何教程都细致。
  • 文中涉及的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。
  • 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 参数地址、结构体成员与错误码均为说明性示例,实际实现请以自己的手册与代码为准。