Flash 存储与参数管理实战:运行两年的网关参数全变默认,日志里 0x0807E000 那一页擦除失败——不是芯片不行,是我让一页 Flash 三天干完了十年的活
上一篇《固件读出保护与签名固件升级》把 512KB 网关的布局账算完了:bootloader 16KB、meta 2KB、双升级槽 240KB×2、参数区 14KB。当时参数区只有一句话带过——”参数 14KB”,本文就欠着。再往前,《逆变器 Modbus 写控制与参数掉电保持》讲了写链路的 W0~W5 六层,把”EEPROM 写寿命有上限”这句话挂在了 W4 那一层,同样没展开。这一篇把两处欠账一起还掉:Flash 这块存储介质本身的账。
结论先摆出来,五条:
- Flash 改 1 字节的最小代价是擦一整页。 编程只能把 1 写成 0,把 0 变回 1 只有整页擦除一条路。这个物理规则决定了后面所有的设计——参数区的一切技巧(追加日志、环形轮转、写合并)都是在给”擦页”这个动作摊销成本。
- 同样 86400 次/天的写入:固定单页 3.7 天写死,7 页环形 25.9 天,加写合并后 63.9 年。 三个数字差 6000 倍,芯片一颗没换——差的全部是软件设计。
- 写合并比磨损均衡重要一个量级。 环形轮转的收益上限就是页数(7 页=7 倍),写合并的收益是 900 倍。先合并,再轮转,顺序不能反。
- MCU 内部 Flash 出厂保证 0 坏块,坏块全是使用中长出来的。 检测靠”擦后全页回读 0xFF + 编程后回读比对”,处置靠备块池——参数 7 页至少配 1 页备块。
- 掉电恢复的钥匙不是 CRC 是 seq。 CRC32 理想漏检上限 2⁻³²,但半条记录、错位记录、旧记录这些结构性错误,靠”seq 必须严格单调递增 + 记录定长”挡住,比 CRC 更早、更可靠。
工具照例全离线:drafts/_tools/flash_param_calc.py,13 个模式、24 项自检断言,文中每个数都能复算。与59 篇《STM32 Bootloader 与 IAP 远程升级》、93 篇《MCUboot 安全启动与签名链》的口径衔接处在文中标出。
一、现场:两年整机,参数区先死
还是 130 篇那台 512KB 的 4G 数据网关:STM32F103 高容量型号,2KB/页,数据手册页擦写寿命 10K 次(85℃ 规格)。运行两年,某天现场反映:充电桩运营参数全部回到出厂默认,告警一片,远程重启无效。
J-Link 连上去(读保护是后来才开的,当时还是 0),把参数区 14KB 读出来:0x0807E000 起的那一页读出来是 0xAAAA... 与 0xFF 的混合体,不是任何合法记录;前后六页数据完好。固件日志里躺着三个月前的一条记录:FLASH_ERASE_TIMEOUT,addr=0x0807E000。
根因链条很短:
- 业务层有一个”累计电量缓存”,每次电表抄表帧解析完就写一次参数区,抄表周期 1 秒——86400 次/天;
- 参数区当时的设计是”固定单页 + 直接改写”:改哪个参数,把整页读到 RAM,改完擦除、整页写回;
- 10K 次擦写寿命 ÷ 86400 次/天 × 32(一条记录占 1/32 页,直接改写是每条擦一次)……先不急着算,第二节和第三节把账摊开算。
这台设备其他功能两年零故障。参数区不是被用坏的,是被设计死的——芯片规格书从头到尾没骗人,是我们让一页 Flash 以 27 万倍于设计的节奏干活。
二、Flash 的物理账:改 1 字节为什么必须擦 2KB
NOR Flash 单元的编程是把电子打进浮栅(位从 1 变 0),擦除是把电子拉出来(位从 0 变 1)。编程可以按字节/半字/字粒度做,但擦除做不到——擦除以页为最小单位,一次一整页。于是”改 1 个字节”的真实代价是:
读整页到 RAM(2KB)
改 RAM 里那 1 字节
擦除整页(典型 20~40ms,CPU 停等或总线仲裁)
整页编程写回(典型 2~4ms)
这笔账摊到参数头上,就是写放大:
| 项 | 数值 | 说明 |
|---|---|---|
| 页大小 | 2048 B | STM32F1 高容量 2KB/页 |
| 参数记录总长 | 64 B | 8B 头(seq + CRC32)+ 56B 载荷 |
| 有效载荷 | 56 B | 真正的参数内容 |
| 每页记录数 | 32 条 | 2048 ÷ 64 |
| 写放大(程序量口径) | 32× | 56B 数据实际占用 2KB 页 |
| 改 1 字节最小代价 | 擦 1 页 + 编程 1 页 | 约 25~45ms,期间中断延迟要单独评估 |
两个工程后果提前记下:一是擦页期间的中断响应,F1 擦除时如果代码也在同一 bank 执行,总线会停等——擦 40ms,高频率中断(比如 1ms 的 PWM 环)就断 40ms,这个坑[59 篇]( https://miuad.com/2026/09/15/stm32-bootloader-%e4%b8%8e-iap-%e8%bf%9c%e7%a8%8b%e5%8d%87%e7%ba%a7%e5%ae%9e%e6%88%98%ef%bc%9a%e8%ae%a9%e8%ae%be%e5%a4%87%e5%9c%a8%e7%8e%b0%e5%9c%ba%e8%b6%8a%e7%94%a8%e8%b6%8a%e5%a5%bd/)擦槽升级时踩过,参数区高频擦除时同样会踩;二是擦写寿命是按”页”计的,不是按芯片计——10K 次是每一页各自的额度,这正是磨损均衡有账可算的前提。
三、三个数字:固定页 3.7 天,环形 26 天,合并后 64 年
同样的 86400 次/天写入,三种设计,寿命差三个数量级。
设计一:固定单页 + 直接改写(出事那台)
每次改动 = 擦 1 页写 1 页,写入 86400 次/天:
| 项 | 数值 |
|---|---|
| 每年擦除次数(同一页) | 86400 × 365 = 31,536,000 次 |
| 页寿命 | 10,000 ÷ 31,536,000 × 365 = 3.7 天 |
对,就是 3.7 天。这台设备两年才死,是因为实际抄表成功率高峰远低于 1Hz 且当晚固件已有降频重试——但方向没变,固定页设计在这个写入量下是按天计寿的。
设计二:追加日志 + 环形轮转(无合并)
把”原地改”改成”追加写”:每条参数变更作为一条新记录追加到当前页尾,页满换下一页,7 页转完回到第一页——这就是最朴素的动态磨损均衡,每页摊到的擦除次数降为原来的 1/7:
| 项 | 数值 |
|---|---|
| 环形总容量 | 7 页 × 10,000 擦 × 32 条 = 2,240,000 条 |
| 均衡后每页每年擦除 | 140,786 次(无均衡为 985,500 次) |
| 均衡收益 | 7 倍(= 页数,这是环形的上限) |
| 整体寿命 | 2,240,000 ÷ 31,536,000 × 365 = 25.9 天 |
7 倍救不了 3.7 天。追加日志解决”怎么写”,解决不了”写多少”。
设计三:追加日志 + 写合并
业务上那 86400 次/天里,绝大多数是”同一个参数的新值”——累计电量本来就是一个不断被覆盖的量。合并规则:RAM 里维护一份影子参数 + 脏标记,变更只改 RAM 并置脏;一个 15 分钟的定时器(或事件触发)检查脏标记,脏了才追加一条记录落盘:
| 项 | 数值 |
|---|---|
| 合并后写入 | 最多 96 次/天(15min 一次) |
| 合并收益 | 86400 ÷ 96 = 900 倍 |
| 每页每年擦除 | 156.4 次 |
| 整体寿命 | 2,240,000 ÷ 35,040 = 63.9 年 |
3.7 天 → 25.9 天 → 63.9 年,芯片一颗没换。代价说清楚:RAM 影子一份(几百字节)、掉电最多丢一个回刷周期(15 分钟)的增量。对”累计电量”这类量,15 分钟丢增量通常可接受(电表侧有计量原件兜底);对不可丢的量(比如反回滚计数),第六节单独处理。
顺序再强调一遍:先写合并,再磨损均衡。只做均衡不做合并,是把 3.7 天延寿到 26 天;只做合并不做均衡,96 次/天打在固定页上也有 10,000 ÷ 1,095 ≈ 9.1 年——但设备寿命通常是 10 年起,均衡仍不可省,两件事都做,谁都不贵。
四、坏块:怎么来、怎么发现、怎么处置
MCU 内部 Flash 出厂保证 0 坏块(坏块管理是 SPI NAND/部分 SPI NOR 的话题,出厂标记那套这里不展开),内部 Flash 的坏块全部是使用中长出来的,三个途径:
- 擦除失败:擦除超时,或擦后整页回读不是全 0xFF;
- 编程失败:编程后回读与期望值不符(个别位打不进去);
- 过度使用后的概率性失效:10K 次是数据手册规格,接近或超过规格后失效率上升——这正是 3.7 天那种设计最阴险的地方:不是写满了就停,是先开始坏。
检测和处置的完整流程:
- ☐ 每次擦除后:整页回读,必须全 0xFF,否则标记坏块
- ☐ 每次编程后:整页回读比对(成本:每页 2 次 2KB 读,挂在写入路径上可接受)
- ☐ 坏块标记:页头写 4 字节魔数(如 0xDEADBEEF,非 0xFF 即”已用”语义的哨兵)
- ☐ 环形指针跳过坏块,永不回退——回退会复用已有记录的页,恢复规则会被污染
- ☐ 备块池:参数 7 页至少配 1 页独立备块(不参与轮转),主池坏一块替补顶上
- ☐ 备块用尽:参数区转只读模式 + 持久告警上报,业务参数维持 RAM 运行
备块池的账:7 主 + 1 备,备块占比 12.5%。寿命内主池总擦除 70,000 次,假设单次擦除失效率 p=10⁻⁶(这是输入假设,不是器件承诺,实际值与温度、电压、工艺有关),期望坏块 0.07 块——1 页备块够了;如果是高温柜内(85℃ 长期)应用,把这个假设调大一两个量级再算,结论可能变成 2 页备块。账怎么算比数取多少重要:p 是假设,池子大小是决策。
五、记录格式与掉电恢复:seq + CRC + 追加
追加日志的每条记录定长 64B:
偏移 长度 内容
0 4 seq u32,严格单调递增(含跨页)
4 4 crc u32,对后 56B 载荷计算
8 56 payload 参数变更记录(参数 id + 新值)
掉电窗口枚举——追加式设计里每个窗口都有确定的恢复结果:
| 掉电时刻 | 现场状态 | 恢复结果 |
|---|---|---|
| 编程启动前 | 页内无痕迹 | 按旧记录,无损 |
| 编程中途(半条) | 尾部 0xFF | 半条 CRC 不通过 → 判无效,按前一条 |
| 编程完成后 | 完整记录 | CRC 通过 → 按新值 |
| 页满擦除中(新页) | 新页部分擦除 | 新页整页弃用(按坏块流程标记),日志回退到备块 |
| compaction 中 | — | 参数区不做原地 compaction,天然无此窗口(见下) |
恢复算法只有一行:扫描全部记录,取”CRC 通过且 seq 最大”的那条。两个结构性细节比 CRC 本身更关键:
- seq 严格单调:CRC32 理想漏检上限是 2⁻³²/条,86400 条/天的写入量下期望漏检 7.3×10⁻³ 条/年,合并不后只有 8.2×10⁻⁶ 条/年——数字很小,但 CRC 漏检的含义是”错误数据被当成正确数据”,而 seq 不连续(新记录的 seq 必须 = 旧最大 seq + 1)能把这个概率压到”攻击者/故障恰好伪造出连续 seq”,工程上视为 0。CRC 管”内容对不对”,seq 管”新旧对不对”,缺一不可。
- 全 0xFF 判空 + 定长记录:读到第一条全 0xFF 记录即停止扫描(后面的必然也是空的),半条、错位、跨页损坏全被结构性检查先挡一层。
参数区不做原地 compaction(把有效记录搬到新页再擦旧页)——7 页环里”最新记录”天然就是有效参数集,读最新一条即完成装载,compaction 是文件系统才需要的动作,单一参数流用不上。
六、防回滚计数器放哪:别给它单开页
130 篇的签名升级链要求版本号单调、反回滚计数不能回退——那个计数器落在哪,是本篇的账:
| 方案 | 擦写账 | 结论 |
|---|---|---|
| 独立 2KB 页,每 4 次/天(重启/掉电)记 1 次 | 1460 擦/年 → 6.8 年 | 不够,先于设备寿命死 |
| 并入参数日志(追加 64B 记录,7 页环) | 占参数环容量 0.07%,与参数区同寿(63.9 年) | 正解 |
| 独立页 + 本节环形方案 | 等于把参数区复制一份小环,代码双份 | 过度设计 |
更妙的做法:反回滚计数不需要单独的记录——参数日志里 seq 最大的那条有效记录本身就是单调的,把”固件首次启动写入的版本号”作为一条普通参数记录追加即可,seq 的单调性就是反回滚的单调性,一份机制两份用途。唯一要注意:版本号变更(升级成功确认)要立即追加落盘,不等 15 分钟回刷——这是第六节合并策略里说的”不可丢的量”。
七、自建环形页、littlefs、FatFS 怎么选
写到这里,环形页的全部机制不到 300 行 C。什么时候该停下来直接上文件系统:
| 方案 | 代码量 | RAM | 断电安全 | 磨损均衡 | 适用 |
|---|---|---|---|---|---|
| 自建环形页 | <1KB | ~100B | 天然(追加+seq) | 动态(环形即均衡) | 一类参数流 + 一类日志流,本篇场景 |
| littlefs | 4~8KB 量级 | 数百 B~1KB(含 lookahead,随块数) | CoW 元数据对 | 动态均衡 | 记录种类 ≥3、需要目录、要在线改配置文件 |
| FatFS | 较大 | 中 | FAT 表掉电易损,需外部事务层 | 无(依赖介质) | 可拔插介质(SD/U 盘)、PC 互通 |
判据三句话:记录种类单一 → 环形页,别为 3 个参数上文件系统;需要目录和多种文件 → littlefs,它是为掉电就地设备设计的;介质可拔插 → FatFS,且掉电安全责任移交给介质侧。 两者共存也常见:参数 7 页自管,剩下一块独立分区挂 littlefs 放冻结数据和日志文件——物理上分页池即可,互不干扰。
但反过来提醒一句:littlefs 的 CoW 写放大和 lookahead 维护,对”一条 64B 参数记录”这种负载是杀鸡用牛刀——先算擦写账,再选存储方案,顺序不能反。第三节的账算完是 63.9 年,任何文件系统都不会给你更好的数字。
八、512KB 全片擦写预算总表
把 130 篇的布局和本篇的账合成一张表——每个区的擦写驱动源不同,账要分开算:
| 区 | 大小 | 擦写驱动 | 频次 | 寿命 |
|---|---|---|---|---|
| bootloader | 16 KB | 仅升级时改写 | 极低 | 与设备同寿 |
| meta 页 | 2 KB | 若每次重启记 1 次(反例) | 4 次/天 | 6.8 年——计数并入参数日志 |
| app 槽 A/B | 240 KB ×2 | 每次升级擦 1 次 | 1 次/天 | 27.4 年(与 130 篇锚点一致) |
| 参数区 | 14 KB = 7 页 | 合并后 96 条/天 | 96 条/天 | 63.9 年(含 1 页备块) |
| 合计 | 512 KB | — | — | 布局核对 PASS |
全片唯一的”热区”就是参数区。升级频率 1 次/天已经是激进假设(27.4 年),而参数区哪怕经过合并+均衡,63.9 年的数字也建立在”96 次/天不再膨胀”上——预算表的每一行都要写清驱动源和假设,半年后业务加个”每分钟记一次运行时长”的需求时,这张表是唯一能拦住它的东西。
九、验收清单
- ☐ 参数区有独立的擦写预算表:每区的驱动源、频次、寿命数字齐全,且业务写入频次变更时表随之更新
- ☐ 写合并已实施:高频覆盖型量的落盘频次 ≤ 参数区寿命允许值(不是”尽量少”,是有数字)
- ☐ 追加日志 seq 严格单调,恢复算法为”CRC 通过且 seq 最大”
- ☐ 每个掉电窗口有对应的恢复结果表,断电测试逐窗口实测过(不是只测”写到一半拔电”一种)
- ☐ 擦除后全页 0xFF 校验、编程后回读比对,两道检查都在写入路径上
- ☐ 备块池 ≥1 页,坏块标记、跳过、替补、用尽转只读全流程实测过
- ☐ 防回滚计数(若升级链需要)未独占页,且升级确认立即落盘不等回刷
- ☐ 擦页期间的 ISR 延迟实测过(尤其 1ms 级控制环),或擦除安排在低负载窗口
最后收三句话。第一,参数区的所有设计都在回答同一个问题:怎么让”擦一整页”这个昂贵的动作被摊薄到可以忽略。 固定页是每条记录付一次全款,环形是 32 条分摊,写合并是把账单本身砍掉 900 倍——三招全是给同一个物理约束打工。
第二,寿命是算出来的,不是测出来的。 10K 次、64B、32 条/页、96 次/天,四个数一乘一除就是 63.9 年;而 86400 次/天对应的 3.7 天,同样四个数——差别只在你有没有在第一周就算这笔账。第三节的三个数字值得背下来,它们是 Flash 参数区的全部经济学。
第三,坏块和掉电不是异常,是设计输入。 出厂 0 坏块不等于终身 0 坏块,追加日志不是”概率上安全”而是”每个掉电窗口都有确定恢复结果”。把这两件事当成需求而不是风险,参数区才配得上”两年零故障的其他功能”。
工具:drafts/_tools/flash_param_calc.py(13 个模式、24 项自检断言,纯标准库、全离线)。口径:页大小/擦写寿命按 STM32F1 高容量数据手册(2KB 页、10K 次 @85℃);记录 64B(8B 头 + 56B 载荷)为本文自定义口径;CRC32 漏检按理想上限 2⁻³²;坏块失效率 p 为输入假设非器件承诺;littlefs/FatFS 代码与 RAM 为公开项目量级经验值,具体版本请实测。文中现场案例按口径重构,不是原始日志。
文中设备型号与参数为写作口径下的重构,不针对任何具体厂商产品;文内数字均可用配套工具复算,与你的器件数据手册冲突时以数据手册为准。
下一篇预告《固件防逆向与混淆》——130 篇留下了站内空白的后半:RDP 和签名管住了”读出”和”推包”,管不住拿到固件后的静态分析与行为逆向。下一篇讲字符串表怎么泄密、符号表该裁到什么程度、控制流平坦化对 M3 性能的真实代价,以及哪些混淆手段在裸机上得不偿失。