参数区双备份与出厂恢复流程实战:一条”恢复出厂”把客户的标定冲了,我看了一眼恢复命令——不是恢复出了 bug,是我把校准参数和网络参数放在了同一个白名单里

2026年10月28日 0 By admin

上个月售后给我打电话:现场的储能网关按流程做了远程”恢复出厂”,设备是救活了,但客户自己标了三天的充放电曲线系数也没了,客户要求赔偿误工。我把恢复命令的代码翻出来看——恢复逻辑本身没毛病,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 的密钥怎么在产线和现场之间分配,这些都还是空的。校准这条线收个尾。