参数区双备份与出厂恢复流程实战:一条”恢复出厂”把客户的标定冲了,我看了一眼恢复命令——不是恢复出了 bug,是我把校准参数和网络参数放在了同一个白名单里
上个月售后给我打电话:现场的储能网关按流程做了远程”恢复出厂”,设备是救活了,但客户自己标了三天的充放电曲线系数也没了,客户要求赔偿误工。我把恢复命令的代码翻出来看——恢复逻辑本身没毛病,CRC 校验、掉电保护、写入路径全都对。毛病在更上游:我的恢复命令只有一个 scope,把”网络参数、业务参数、用户偏好、校准参数”四类东西装在了同一个白名单里,一键全冲。
这一篇把参数区的双备份和出厂恢复流程一次讲透,正面回答上一篇(《把文件系统搬到外部 SPI Flash 实测》)文末预告的那个问题:双备份的”两份”,擦除账到底翻不翻倍。 先给结论:看你把 A/B 用在哪——套错了地方不是 2 倍,是 53 倍。
一、先分清三种”参数区”,它们防的根本不是同一种事
「参数区双备份」这六个字,在三个完全不同的语境下出现过,混着聊必然算错账:
| 存储形态 | 防的是什么 | 掉电/损坏时的最坏损失 | 代表 |
|---|---|---|---|
| 追加日志(seq+CRC) | 半条写入、掉电、单条损坏 | 丢最后一条未完成写入 | 《Flash 存储与参数管理实战》 的参数日志 |
| 整集快照(原地更新) | 读到”改了一半的集合” | 整集丢失(擦后、写前掉电) | 单页/双页存一整个参数集 |
| A/B 双区切换 | 上面那一条 + 恢复逻辑要 O(1) | 不丢(旧区还在) | 本篇主角 |
关键认知第一条:《Flash 存储与参数管理实战》 那套追加日志,本身就是”多版本备份”——最近 224 条记录全都在 Flash 里躺着,CRC 过、seq 最大的那条就是现值,旧值天然是它的回滚点。给它再套一层 A/B,不是加强,是画蛇添足(第三节有账)。
A/B 双区真正服务的对象是整集快照式存储:一整个参数集(我这里是 60 条 × 64B + 64B 集头 = 3904B,占 2 页)作为一个原子单位写入,要么全旧要么全新。它的命门是”擦除目标区到编程完成”之间那个几百毫秒的窗口——这个窗口里掉电,单区方案的整集就没了。A/B 的全部意义就是把窗口里的数据搬到另一个区去躲。
二、事故复盘:恢复命令没有 scope,是流程问题不是代码问题
先把事故的结构拆开。恢复出厂的调用链是这样的:
Modbus 0x0065 寄存器 = 0xA55A
-> restore_factory(defaults_all) // 问题在这一行
-> for tag in ALL_TAGS: write(default[tag])
ALL_TAGS 里混着四类东西:
- L0 网络参数:IP、MQTT broker、证书路径。冲了能自愈,重连就行。
- L1 业务参数:充电限流、温度阈值(《逆变器 Modbus 写控制与参数掉电保持实战》 里那些)。冲了设备按默认值跑,安全但丢配置。
- L2 用户偏好:HMI 亮度、语言。冲了体验受损,无安全影响。
- L3 校准参数:增益、偏移、充放电曲线系数。冲了 = 客户几天的标定工作蒸发,且设备计量精度回到出厂状态——在储能场景这是计量事故。
四类参数的风险等级、恢复触发方、确认强度完全不同,但我的恢复命令只有一个入口。售后工程师面对一台起不来的设备,他的合理动作就是”恢复出厂”,他不知道也无法知道这个设备里有客户三天的标定成果。流程设计不能建立在”操作者知道自己在干什么”的假设上——这句话我在这行听了十年,这次轮到自己交学费。
顺带说掉电安全性:恢复动作本身的写入路径是干净的——我走的还是 《Flash 存储与参数管理实战》 的追加日志,每条默认值都是一条新记录,任意时刻掉电最多是”恢复到一半”,重启后设备带着一部分默认值一部分旧值运行。这不难看但也不致命。致命的从来是”恢复范围”这一层。
三、A/B 双区的擦除账:两份不是 2 倍,是 7 倍——套错了地方是 53 倍
这是本篇的正面回答。五种方案,同一套基准(60 参数整集、64B 记录、2KB 页、10k 次擦写寿命、96 次写/天的稳态负载,全部与 《Flash 存储与参数管理实战》/《把文件系统搬到外部 SPI Flash 实测》 对齐):
| 方案 | 页擦/天 | 擦/页/年 | 寿命 | 备注 |
|---|---|---|---|---|
| 甲:单区 7 页追加环形(131 现状) | 3.0 | 156 | 63.9 年 | 基准 |
| 乙:A/B 整集快照,6 会话/天(写合并后) | 12.0 | 1095 | 9.1 年 | 共 4 页 |
| 乙′:A/B 整集快照,96 会话/天(不合并) | 192 | 17520 | 208 天 | 反面教材 |
| 丙:A/B 套在追加日志上(区满切换搬有效值) | 180.7 | 8245 | 1.2 年 | 反面教材 |
| 戊:双环形同步写(每条写进两本日志) | 6.0 | 156 | 63.9 年 | 共 14 页 |
五个数字,五句话:
乙:A/B 快照的正确用法,代价 7 倍。 每个写会话把 3904B 整集重写到对区(先擦 2 页再编程 2 页),两个会话才轮到同一页擦一次。写合并到每天 6 个会话,是 9.1 年——比甲的 63.9 年差 7 倍。换来的是”任意时刻都有一份 CRC 完整的整集”和 O(1) 的开机加载。
乙′:不合并的快照是灾难。 96 次写/天如果每次都触发整集重写,寿命 208 天,比设备质保期还短。A/B 快照模式必须配写合并——把一段时间内的变更攒起来,会话结束时落一次盘。这不是可选项,是模式成立的前提。
丙:给追加日志套 A/B,是我见过最贵的”加固”。 推演一下就知道为什么:活动区 128 条写满后要切换,把 60 条有效值搬进新占的槽位,净吸收的用户写只剩 128−60=68 条,然后整个旧区 4 页(128 条/32 条每页)全部擦掉。每 68 条用户写付 128 页擦,而甲的环形是每 32 条写付 1 页擦——差 52.7 倍,寿命从 63.9 年掉到 1.2 年。 追加日志的历史记录就是它的备份,再套 A/B 是把备份复制了一份然后用擦除寿命付账。
戊:唯一”双份恰好 2 倍”的设计,而且单页寿命不变。 每条记录同步写进两本独立的 7 页环形日志。总擦除翻倍(6 页/天),但页数也翻倍(14 页),每页的擦频纹丝不动——156 擦/页/年,寿命还是 63.9 年。代价是 2 倍空间 + 2 倍编程时间,换来的是对”单页损坏”的最强防护:一本日志里某页坏了,另一本还开着。这是校准参数这种”丢了赔钱”的数据该有的待遇。
所以《把文件系统搬到外部 SPI Flash 实测》文末那个担心——”双份会推翻 136 的寿命余量吗”——答案是:不会,前提是你选戊不选丙。 外部 SPI Flash 那颗 W25Q64 有 8MB,双环形多花 14KB 连零头都算不上;而 A/B 快照的 7 倍代价,只在”整集原子性 + 快速开机加载”这两个需求真实存在时才值得付。我的参数区两条都不缺:开机折叠 224 条日志只要 4.6ms(读 0.59ms + 查表 CRC 3.98ms,72MHz),O(1) 加载是伪需求;原子整集在追加日志的”seq 最大且 CRC 过”判据下也是伪需求。最后我的选择是:主参数区维持 131 的单环形不动,校准参数挪去戊方案的双环形 + 种子页,一共多花 14KB。
四、掉电窗口全枚举:seq 即标志,别再造标志页
A/B 快照(乙方案)的提交时序,我把每一步的掉电结果都枚举了。记录布局沿用 131:payload 56B 在前,seq u32 居中,crc u32 最后写——crc 过了,记录才算存在:
| 窗口 | 掉电点 | 判别依据 | 恢复结果 |
|---|---|---|---|
| W1 | 擦目标区(对区)中途 | 对区非活动,CRC 不全判无效 | 旧集,零损失 |
| W2 | 编程 payload 途中 | 记录无 crc 或 crc 不符 | 旧集,零损失 |
| W3 | seq 已写、crc 未写 | crc 缺失 → 记录无效 | 旧集(本次更新丢失,重启重试) |
| W4 | crc 编程中途 | 半字编程原子,crc 全对或全错 | 旧集或新集,二者必居其一 |
| W5 | 旧区退役擦除中途 | 旧区 seq 更旧,永远判负 | 新集,零损失 |
三个设计要点:
第一,活动区标志不需要独立存储。 常见实现是再开一页写”当前活动区指针”,那一页自己也成了单点,得再给指针页做 A/B——套娃。正确做法:两区各自最新的有效记录(CRC 过)里,seq 大的那区就是活动区。 seq 单调递增是 131 日志本来就有的性质,直接复用。
第二,两区全空(0xFF)是合法状态。 出厂首次上电两区都没有有效记录,此时加载出厂默认值(第六节讲默认值放哪)。判据要显式写进代码:if (no_valid_record(A) && no_valid_record(B)) load_defaults();——忘了这条,首次上电就是读一堆 0xFFFFFFFF 当参数用。
第三,W4 的”二者必居其一”依赖半字编程原子性。 F1 系列按 16 位半字编程,一个半字要么写上要么没写,不存在写了一半的半字。crc u32 占两个半字,最坏情况是”低半字新、高半字旧”,CRC 校验直接抓出来。这个性质要写进设计文档——它是整条掉电安全链的地基,换了按字节编程的器件要重验。
《Flash 上跑 littlefs 落地实测》/《把文件系统搬到外部 SPI Flash 实测》 讲过文件系统方案的掉电语义,这里补一句边界:littlefs 的 CoW 提交同样给出”旧集或新集”的保证,但它的保证单位是”文件内容一致”,不给你”哪个版本更新”的判断——版本判别还是得靠你自己写在记录里的 seq。文件系统不是掉电安全的免死金牌。
五、出厂默认值放哪:常量段、种子页、文件,各管一段
“恢复出厂”恢复出来的值得有个来源。三个去处,功能不重叠:
| 方案 | 占用 | 改默认值 | 校准兼容 | 适用 |
|---|---|---|---|---|
| 常量段(编译进 App) | 0 页 | 发固件 | 逐台不同 → 不可用 | 通用默认 |
| 种子页(独立 2 页,出厂烧录) | 2 页 | 升级固件不动它 | 逐台产线烧录 ✓ | 校准/标定 |
| 文件(littlefs,《Flash 上跑 littlefs 落地实测》) | 按需 | 随包下发 | 可,但依赖 FS 完好 | 用户配置副本 |
常量段放”出厂通用默认”:所有设备都一样的那些值。改默认值 = 发固件,听起来笨,但对通用默认来说是优点——默认值变更走固件版本管理,可追溯、可回滚,比”远程改默认值区”干净得多。
种子页放”逐台校准”:每台设备的增益偏移不一样,只能产线逐台烧。独立 2 页、运行期只读、加 WRP 写保护。WRP 这道保护在 《固件读出保护与签名固件升级实战》 里防的是 USB-TTL 把固件整锅端走,在这里防的是固件 bug 里的野指针把校准页擦了——同一页保护,两份价值。种子页升级固件时绝对不能动:bootloader 的擦除范围必须用地址表显式圈定,任何”擦除 App 区”的循环如果写成”擦到参数区为止”,一次升级就是一次全厂校准数据团灭。
文件放”用户配置的副本”:比如导出导入功能的工作文件。注意它不能当默认值唯一来源——文件系统损坏时(《Flash 上跑 littlefs 落地实测》 实测过挂载失败路径)默认值也跟着丢,必须另有兜底。
三段式落位之后,”恢复出厂”变成三个不同源头的拷贝:L0/L1/L2 从常量段取,L3 从种子页取(而且 L3 默认根本不在恢复白名单里,见下一节)。
六、恢复出厂分级:把校准从白名单里摘出去
修完事故的恢复命令长这样:
0xA55A -> arm_restore(scope, token) // 第一步:武装,30s 有效
0xA55A + scope -> do_restore(scope) // 第二步:30s 内确认才执行
| 级别 | 内容 | 触发方 | 确认方式 | 后果 |
|---|---|---|---|---|
| L0 网络 | IP/MQTT broker/证书路径 | 远程 + 本地 | 两步确认 | 重连后自愈 |
| L1 业务 | 充电限流/温度阈值 | Modbus + 两步确认 | magic 0xA55A | 走日志可回滚 |
| L2 用户 | HMI 偏好/语言/亮度 | 本地 HMI | 菜单确认 | 低风险 |
| L3 校准 | 增益/偏移/曲线系数 | 仅产线模式 | HMAC token | 远程一律 NAK |
四条规则,每条都是拿事故换的:
L3 永不出现在远程白名单里。 远程恢复命令的 scope 枚举里物理上没有 L3 这个值,传入非法 scope 返回 NAK 并记日志。不要用”参数校验”来拦——用枚举不存在来拦。校准恢复只存在于产线模式,凭 HMAC(SN+日期) token 进入,没有任何硬编码密码。
L1 的恢复走追加日志。 每条默认值是一条新记录,seq 单调,CRC 保护。恢复错了?旧值还在日志的历史区段里, seq 往回找就是回滚点。恢复动作不原地擦任何东西,这条底线和 131 的写入路径完全共用。
恢复前自动落一条”restore_begin”标记记录,完成后落”restore_complete”。 重启自检时看到 begin 没有 complete,说明上次恢复做了一半——此时给出告警并允许重做,而不是带着半套混合参数沉默运行。
“用户改过的参数被恢复成默认值”怎么防,答案是”恢复的只是白名单子集 + 全程可回滚”。 不存在能防住”用户明确要求恢复出厂又后悔”的机制,但存在能让后悔代价从”数据蒸发”降到”翻日志找回”的机制。日志里 96 次/天的写合并后仍有全部历史,找一条 3 天前的曲线系数记录,工具链十几行 Python 的事。
七、维修后门:不开后门,开限时窗口
售后总有人要求”给我留个万能入口,现场好修”。硬编码万能密码的坑不用我再讲——《固件读出保护与签名固件升级实战》 里那 30 台被端走固件的网关,后门一旦进了逆向工程者的字典,全量设备裸奔。我的替代设计:
- ☐ 进入条件:按住 KEY1 上电 + 上电后 3 秒内释放。三要素缺一不可,正常上电路径永远不经过这个分支。
- ☐ 限时 60 秒:窗口内 LED 快闪提示,超时自动退出,重进需重新断电上电。
- ☐ 权限只到 L0/L1:窗口内 UART 只开放网络参数和业务参数的读写,校准接口在固件层面不存在(不是关着,是编译不进去——产线固件和现场固件用同一份代码,用宏裁剪)。
- ☐ 产线动作凭 token 不凭密码:HMAC(SN + 日期, 主密钥) 校验通过才开 L3。主密钥只在产线工装和安全存储里,固件里只有校验逻辑没有密钥本体。
- ☐ 审计:每次进入维修模式写一条日志记录(seq+CRC,与参数同区),谁在什么时候进去过,事后查得到。
60 秒够干的事:改 IP、刷业务参数、读故障日志。不够干的事:把设备拆走研究。窗口设计的本质是把”后门的便利”和”后门的暴露面”拆开——便利留给合法维修,暴露面交给时间。
八、固件升级时的参数迁移:TLV + 未知 tag 保留
参数区还有最后一个坑:固件升级后参数定义变了。《STM32 Bootloader 与 IAP 远程升级实战》 的 IAP 升级和 《固件读出保护与签名固件升级实战》 的双槽签名升级解决的是”代码怎么换”,没解决”代码换完,旧代码写的参数新代码认不认”。
我的记录是裸 tag+value,这次事故后统一改成 TLV(tag u16 + len u8 + value),迁移规则四条:
- 旧固件 58 个 tag,新固件 63 个:保留 57、新增 6(给默认值)、删除 1。
- 未知 tag 原样保留,不删不报错。 这是向前兼容的全部:新固件遇到自己不认识的 tag 静默留着,降级回旧固件时参数还在。删掉未知 tag 的固件,烧掉了用户降级的退路。
- 迁移在 boot 完成签名验证之后、跳转 App 之前一次性执行,迁移结果本身走追加日志——迁移过程掉电,重启重做,幂等。
- 迁移失败(比如参数区 CRC 全挂)→ 挂起迁移并回退旧固件槽(130 的双槽布局在这里接上):参数是 App 的输入,输入没了就别让新 App 起来,回退到还能读懂旧参数的那一版。
迁移逻辑单独一条纪律:迁移表随固件发布、逐版本链式执行(v1→v2→v3),禁止跨版本直迁。 跨两级升级的设备,参数要走过每一版迁移表,缺一版就是静默数据截断。
九、汇总与 triage
| 决策点 | 选 | 不选 | 一句话理由 |
|---|---|---|---|
| 高频参数区(≥50 写/天) | 追加日志(131) | A/B 快照 | 快照不合并 208 天,合并后也只 9.1 年 |
| 要双份冗余 | 双环形同步写(戊) | A/B 套日志 | 戊单页寿命不变;丙 1.2 年,53 倍代价 |
| 校准参数 | 种子页 + WRP + 产线模式 | 常量段/远程恢复 | 逐台不同、丢了赔钱、远程不可达 |
| 通用默认值 | 常量段随固件走 | 独立”默认值区” | 版本管理免费拿 |
| 恢复范围 | scope 白名单四分级 | 一键全恢复 | 本次事故的直接根因 |
| 恢复触发 | 两步确认 + 30s 窗口 | 单命令 | 误触发上限从”随时”降到”30s 内 2 次机会” |
| 活动区判别 | seq 大者 | 独立标志页 | 标志页自己是单点,套娃 |
| 维修入口 | 限时窗口 + HMAC token | 硬编码后门 | 130 篇的教训 |
| 升级迁移 | TLV + 未知 tag 保留 | 裸 tag + 清区重写 | 保留降级退路,幂等可重做 |
- ☐ 追加日志不需要 A/B——它的历史就是备份,套 A/B 是 53 倍擦除的画蛇添足
- ☐ A/B 快照必须配写合并,且想清楚 7 倍寿命代价换的两样东西是不是真需要
- ☐ 校准参数:种子页 + WRP,物理上不在远程恢复的白名单里
- ☐ 恢复动作走追加日志,begin/complete 标记记录,可回滚、可续做
- ☐ 两步确认 + scope 枚举拦截面,不靠参数校验拦截
- ☐ 维修模式 60 秒窗口,审计落日志,产线凭 token
- ☐ TLV 迁移逐版本链式执行,未知 tag 保留
本篇的计算器脚本照例留在 drafts/_tools/param_backup_factory_calc.py(wear/ab/fold/duplex/factory/scope/migrate 等模式,selftest 34 项全过),换器件、换写频、换参数条数,改三个常量重跑就有新账。
(文中参数均为本机实验与手册数值推演,换平台换器件请以实测为准;恢复出厂会清除白名单内配置,动手前先翻一遍 scope 表。)
下一篇预告:《产线校准数据注入与防篡改:种子页烧录、WRP 与 HMAC token 的完整链路》——本篇把校准参数挪进了种子页和产线模式,但产线工装怎么把校准数据安全写进去、WRP 什么时候锁、锁了之后固件升级怎么绕开种子页、HMAC token 的密钥怎么在产线和现场之间分配,这些都还是空的。校准这条线收个尾。