一、参数存储为什么值得单独设计
在嵌入式项目里,“参数”这个词其实混着两类完全不同的东西:
- 几乎不变的配置:设备地址、标定系数、硬件号、软件号、量程上下限。它们可能几个月都不改一次。
- 一直在变的状态:累计运行时间、剩余量、最后一次故障码、计费累计值。它们可能每秒都在动。
我早期偷懒,把这两类东西塞进同一个结构体,一起写进片上 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 的是那些几乎不变的配置——设备地址、标定系数、硬件号、软件号。 如果确实需要频繁记录(比如累计量、运行日志),有三条路:
- 外挂 EEPROM 或 FRAM。FRAM 的擦写次数比 Flash 高好几个数量级,写起来也不用心疼。
- 做写均衡:把一页切成若干槽轮流写,写满一整页才擦一次,相当于把擦除次数摊薄。
- 只在掉电瞬间写一次:靠电源检测中断 + 储能电容撑住最后一次写入。这条对硬件有要求,我没做过。
关于“10 万次循环测试”我要说清楚一件事
同一份测试计划里还有一项我认为很值得借鉴:不同电压下的参数存储稳定性测试, 覆盖 2.7 V ~ 3.6 V 这个范围。这一条很容易被忽略, 但低压下的 Flash 写入失败率确实会变高——如果设备靠电池供电、或者掉电瞬间还在写参数, 低压写入是否可靠就直接决定了参数会不会丢。
三、分区规划:把参数区和代码区分开
参数存储的第一步不是写代码,而是切地址。STM32G030C8T6 有 64 KB Flash,
我在链接脚本 STM32G030C8Tx_FLASH.ld 里把它切成三块:
| 区域 | 地址范围 | 大小 | 说明 |
|---|---|---|---|
| Bootloader | 0x08000000 ~ 0x08003FFF | 16 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_ERROR
- 现象:传入非页对齐的地址时,擦除直接返回
HAL_ERROR,什么也擦不掉。 - 原因:Flash 擦除的最小单位是页,非对齐地址本身就是无效请求; 我原来的实现把地址直接透传给 HAL,于是只拿到一个笼统的错误。
- 办法:在
stm32_FLASH_ErasePage()里增加地址合法性检查, 提前拦截并返回明确的错误码,而不是等 HAL 告诉我“失败了”。 - 验证:改完之后跑随机地址写入测试,通过率 100%。
HAL_ERROR 直接透传到上层,上层根本分不清是“参数传错了”还是“Flash 真的坏了”。
在自己的封装层做参数校验、返回自己的错误码,多写的这十几行很值。
/* 擦除按页进行:先把无效请求拦在自己的封装层里,再交给 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;
}
四、结构体 + 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 区还是老布局”的尴尬局面。
- 显式对齐或打包:用
__attribute__((packed)),或者干脆全用固定宽度成员并手工填充, 让填充字节变成“你自己写的确定值”。 - 在结构体头部放 魔数 + 版本号 + 长度,加载时先校验这三项,再校验 CRC。
- 参数区预留足够空间,给将来的扩展留出余量——参数区只有 2 KB 的时候, 加两个成员就可能挤爆。
五、双区备份与原子切换
双区备份要解决的是一个很物理的问题:Flash 只能“先擦后写”。 擦除和写入之间有一段时间窗,如果这中间掉电,这一区就废了—— 只剩下半页数据,CRC 必然对不上。有 A/B 两个区的时候,任何时刻都至少有一份完整数据, 这就是双区存在的全部理由。
具体实现上,我见过和用过的做法大致有三种:
| 做法 | 怎么判断用哪一份 | 优点 | 代价 |
|---|---|---|---|
| 带序号的双区 | 每区头部存一个递增序号,启动时选序号大且 CRC 正确的那份 | 逻辑清晰,写入次数天然均摊到两个区 | 每次写入要改写序号,需要多一次擦写 |
| 主备 + 有效标志 | 平时只写备用区,写完置“有效”标志,再互换主备角色 | 写入路径固定,出问题时主区始终没被碰过 | 标志位本身也要原子地写,实现细节更多 |
| 日志式 / 槽式 | 一页切成 N 个槽顺序写,读的时候取最后一个有效槽 | 本质是简单的写均衡,擦除次数被摊薄 N 倍 | 需要管理槽状态,读取逻辑比前两种复杂 |
无论选哪一种,判断“哪一份有效”的原则都是一句话: 先看 CRC,再看序号。序号大的那一份如果 CRC 坏了, 要能自动回退到序号小的那一份,而不是直接报错或者加载一堆垃圾数据。 回退能力才是双区的价值所在——如果两份都坏了,那双区和单区没有区别。
STM32_FLASH_PARAM_OFFSET 定义),
但我并没有实现完整的原子切换——也就是说,双区的位置有了,
“写完一份、校验通过、再切换有效标记”这一整套流程我没有做完整,
目前更接近“两个区都在,但选择逻辑还很简单”。
这是我知道的、也是我下一个版本必须补上的部分。把它写出来,是因为我不想让这篇笔记读起来
像一套已经落地的成熟方案。
六、默认值回退:让设备永远能开机
参数校验失败怎么办?我的做法很保守:恢复默认参数,也就是调用
defaultParameter()。宁可让设备用一套保守的默认值先跑起来,
也不要让它卡在启动阶段黑屏——那意味着用户连“进去改回来”的机会都没有。
但“有默认值回退”并不等于“参数一定正确”。这一点是我用一次实际的故障换来的:
- 现象:设备第一次启动时,NTC 相关参数是 0 或 0xFF,业务上完全不合理。
- 原因:参数结构体的整体 CRC 校验是通过的, 但其中某个成员从来没有人给它赋过默认值——它不是“被写坏了”,而是“从来没对过”。
- 修复:在
system_param.c里补上ps->ntc_tr = 10000;, 给这个成员一个明确的默认值。 - 验证:连续重启 10 次,参数保持稳定。
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 外设计算,逐项加载 |
| 版本号 | 无 | 有(这是我坚持要补上的部分) |
| 代码量 | 约十几行 | 明显更多,但排查路径清晰 |
| 怕什么 | 结构体布局一变就全乱;擦写中途掉电就丢 | 怕实现不完整(我的原子切换就还没做完) |
所以我的判断标准不是“项目大小”,而是“这个结构体以后会不会改”: 参数极少、结构体基本不动的小项目,单区整体落盘完全够用,十几行代码也没什么不好; 但只要参数结构体会随着需求演进,就必须上“版本号 + 双区”。 因为这时候省下的那点代码量,会在第一次固件升级时连本带利地还回去。
八、几条我反复用到的经验
- 先算寿命,再决定存哪。把保存频率乘上 1000 次,看看能撑几天;算过一次就再也不会忘记。
- 参数区必须和代码区分开。这是升级固件时参数不被擦掉的前提,在链接脚本里一次切开就好。
- 把手册上的“最低 1000 次”当设计依据,把“10 万次循环测试”当余量摸底,两者不要混为一谈。
- 封装层要做参数校验,返回自己的错误码。不要把
HAL_ERROR直接透传给上层。 - 不要依赖结构体的内存布局。要么显式打包,要么在头部放魔数 + 版本号 + 长度。
- CRC 通过不等于数据正确。每个成员都要有默认值来源和范围检查——这是 NTC 那次故障教我的。
- 先看 CRC 再看序号。双区备份的价值在于“能回退到旧的那一份”,而不是“有两份”。
- 参数类的改动,一定要连着重启验证。我那次 NTC 的问题,重启 10 次才让我确信修好了。
参考资料与说明
- STM32G030 系列数据手册(Flash 存储器特性、擦写次数、编程单位)与参考手册(Flash 编程/擦除流程、CRC 外设)。
- HC32F030 系列用户手册,用于确认 Flash 控制器与参数区地址。
- DS18B20 数据手册,用于确认温度传感器相关参数的取值依据。
- ST 官方 AN 系列应用笔记中关于“用 Flash 模拟 EEPROM / 双区参数存储与写均衡”的公开文档, 这类文档对分区、擦写寿命与掉电保护的讨论比任何教程都细致。
- 文中涉及的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。
- 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 参数地址、结构体成员与错误码均为说明性示例,实际实现请以自己的手册与代码为准。