固件 OTA 与双分区 / A-B 更新架构 · 技术探讨
更新这个词被用得太随意了 —— 本文把它拆成传输、写入、切换三件互不相同的事,分别讲清它们的原子性关口、判据与回滚手段。面向嵌入式存储与固件工程参考。 | 编制日期 2026-10-14 | 版本号 Rev 1.0
绝大多数「OTA 方案」文章讲的是怎么把包传下去:用什么协议、怎么断点续传、服务器怎么编排灰度。这些内容重要,但它们解决的是失败代价最低的那一半问题 —— 传输失败无非重来一次,设备在原地运行得好好的。
本文讲的是另外两件更难的事:写到哪里、按什么顺序落盘,以及什么时候新的那一份才算真正生效、生效不了怎么退回来。核心矛盾只有一句:更新要求原子,但闪存的擦写本身不是原子的,而且设备随时可能在任何一个字节之后断电。
全文围绕「三个原子性关口」这个框架展开:传输关口、写入关口、切换关口。任何一处把两步并成一步来省事,都会在断电时刻留下无法判定的半写状态。
前言
做固件的人迟早会遇到同一个噩梦:设备在现场更新到一半断电了,从此再也起不来。事后复盘时,往往发现所有「该做的」都做了 —— 用了 TLS 通道、做了分块校验、写了重试逻辑 —— 唯独没有回答一个问题:假如现在这一刻掉电,上电后第一段代码会读到什么?
这个问题问的已经不是传输,而是可信性:系统在任何时刻被切断电源,重新上电时都必须落在一个可以被明确判定的状态里 —— 要么明确地跑旧版本,要么明确地跑新版本,要么明确地进入恢复流程;绝不能出现「看情况,取决于运气」。
与第 2 章配合的这个视角,也划清了本文与《嵌入式启动介质与启动架构》篇的边界:那篇回答「谁负责把第一段代码读起来」,本文回答「当读到的东西是坏的、或者刚刚被换过一轮的时候,谁负责改判、按什么凭据改判」。
结论: OTA 的核心不是把新固件传下去,而是保证更新过程中的任何一次中断都不会把设备变成砖。 做法是:把它拆成传输、写入、切换三件彼此独立的事,分别为它们建一道原子性关口 —— 传输关口允许任意次失败且零代价;写入关口保证「旧镜像在任何一个断电时刻都仍然完整」; 切换关口由一个持久化的、可被下一段代码读懂的状态来决定「现在跑哪一份」,并且任何没能在确认窗口内证明自己活着的新版本都必须能自动退回。
读者路径
1. 为什么 OTA 是一个可信性问题而不是传输问题
这一章给出全文的分析框架。结论先行:把 OTA 当成传输问题的团队,会把绝大部分精力花在代价最低的那一段上,而在代价最高的那一段裸奔。
1.1 OTA 失败的三种代价
「更新失败了」是一句信息量极低的描述。同样是失败,发生在还没有开始写的那一刻,和发生在已经把旧镜像的前 4 KB 擦掉的那一刻,是两种完全不同的事件。要讨论设计,先把代价量化。
工程上把 OTA 失败的代价分三类,它们的数量级相差好几个数量级,这决定了资源应该怎么分配:
| 代价类别 | 典型表现 | 是否需要人到场 | 谁来承担 | 数量级 |
|---|---|---|---|---|
| ① 可用性代价 | 设备重启一次或几次,服务中断几十秒到几分钟;最终回到旧版本继续提供服务 | 不需要,系统自愈 | 产品口碑,通常可控 | 最轻:可接受 |
| ② 返工代价 | 设备无法自恢复,需要现场拆机、接串口或返厂;单台处理成本远高于设备本身的毛利 | 需要,且往往要人到现场 | 售后与供应链,可能侵蚀全部利润 | 重:可能击穿项目 |
| ③ 信任代价 | 信任常被误以为只是安全问题,实际还包括:回滚到了有已知漏洞的旧版本;版本号被恶意降级;更新通道被当作投放入口 | 可能需要召回级处置 | 安全团队与合规,可能上升到法律责任 | 最不可控 |
这里值得强调的是第三种代价常被漏掉。很多团队的防御清单里只有「不能变砖」,却没有「不能悄悄退回到有漏洞的旧版本」和「不能让人往里投放任意固件」。这两条属于更新机制自身的Trustworthy,与传输是否可靠无关 —— 用最结实的 TLS 通道送一个被降级签名的旧固件,攻击照样成立。第 6 章会把这条展开。
可用性代价主要由切换机制决定(有没有明确的判据、能不能自动退回);返工代价主要由写入路径与恢复手段决定(有没有第二份、有没有 ROM 兜底通道);信任代价主要由签名、版本单调性与密钥管理决定。
一个只在传输层层层加固、而在另外三处裸奔的方案,三种代价一个都不会少。
1.2 「传下去」与「变得可用」是两个独立问题
把「更新」这个词拆开,它其实包含三件性质完全不同的工作:
- 传输:把服务端产出的字节串,通过某个通道(蜂窝、以太网、串口、本地总线、离线 USB)搬到设备端的一块暂存区。
- 写入:把暂存区里的字节串,按某种顺序写进某个非易失的目标位置,并且在这个过程中不动正在运行的那一套代码。
- 切换:在某个明确的时刻,让下一次上电(或下一次跳转)跑到新的那一套代码上去,并且为「它跑不起来」准备退路。
关键区别在于失败的可重试性:传输失败,重试的代价只是时间和流量,设备的状态完全没有被修改;写入与切换失败,设备的非易失状态已经被修改了,重试未必能把状态还原到修改之前。
| 维度 | 传输 | 写入 | 切换 |
|---|---|---|---|
| 责任主体 | 服务端 + 设备上的通信栈 | 设备上的更新守护进程 / Loader | 启动判决者(Loader 或引导加载程序) |
| 失败的判据 | 超时、连接中断、包序号不连续 | 写入/擦除返回失败、回读不一致 | 启动后未在窗口内自确认、或崩溃 |
| 失败时代设备的状态 | 没有被修改,旧版本在跑 | 目标分区已被修改,可能是半写状态 | 持久状态:已经改了指向,但还不知道它能不能跑 |
| 重试是否有副作用 | 没有,可任意次重试 | 有,整槽重写前必须先擦除 | 有,需要靠尝试计数来防止无限重试 |
| 代价上限 | 时间与流量 | 备用槽成为废数据 | 设备起不来 —— 但有自动回滚则为可用性代价 |
| 用什么证明做对了 | 整包摘要与签名校验 | 回读校验 + 顺序化的提交 | 断电注入测试 + 尝试计数触底测试 |
这张表里最需要记住的是第三行:只有传输阶段的失败是「零副作用」的。写入与切换一旦开始,设备的持久状态就已经在被修改,此后的一切设计目标就变成了「让每个中间状态都可判定」。
这张图是全文的骨架。左侧那一列无论失败多少次都不留痕迹;右侧那一列从第一个字节写进闪存开始,设备就有可能被断电,而每一次断电都会留下一个需要被解读的中间状态。
1.3 更新过程的三个原子性关口
「原子性」用在嵌入式更新里,含义与数据库事务并不完全相同。这里把它定义为一个可判定性要求:
更新在任意时刻被中断后,重新上电的那段代码必须能够回答两个问题:现在这个状态属于哪一类?接下来该做什么?如果答案依赖于「运气」或者「写得快不快」,这套方案就没有原子性。
要满足这个要求,只需要盯住三个关口。每个关口都有明确的跨越动作、成功判据和中断后果:
| 关口 | 跨越它意味着什么 | 成功的判据(必须可写下来) | 在此中断的典型后果 | 恢复手段 |
|---|---|---|---|---|
| 关口一 · 传输 | 设备端已拥有一份与服务端逐字节一致的镜像 | 整包摘要通过、签名通过、长度与头部一致 | 暂存区残缺,重启后只是重新下载 | 重传,无副作用 |
| 关口二 · 写入 | 备用槽里是一份完整且自洽的镜像,且元数据已宣告它的存在 | 「旧槽头部 + 旧槽体」完好,且新槽头部最后落盘 | 备用槽成为未完成的字节序列 | 整槽擦除后重写 |
| 关口三 · 切换 | 下一次启动会跑到新槽,并且有办法知道它跑起来了没有 | 自确认标记写入成功;或尝试计数触底并成功回落到另一槽 | 可能起不来 —— 这是唯一需要回滚机制的关口 | 尝试计数 + 自动回落 + 把相关槽标记为不可用 |
典型反例是「一边接收一边写入(streaming write)」:为了省掉暂存区,直接把收到的字节写进应用分区。这样做等于把传输关口的失败变成了写入关口的失败 —— 网络抖一下,设备的非易失状态就被修改了。
正确的做法是:先完整拿到、先校验通过(关口一),再开始动非易失介质并确保它不会伤到还在跑的旧那份(关口二),最后才改指向(关口三)。暂存区的那份存储介质容量或 RAM,就是为了让这三个关口可以分开而必须付出的门票。
1.4 与启动介质篇的分工:谁负责读起第一段代码
本系列里还有一篇《嵌入式启动介质与启动架构》,它和本文看的是同一条芯片上电到应用跑起来的过程,但角度完全不同。混读容易串线,这里先划清边界:
| 问题 | 《启动介质与启动架构》篇负责 | 本文(OTA 与 A/B 更新)负责 | 为什么必须分开 |
|---|---|---|---|
| 第一段代码怎么被读起来 | 是。介质初始化、XIP 还是拷贝运行、BootROM 做什么 | 不。假定这条链路已经存在且能工作 | 启动链路本身的正确性,与更新期间如何不被破坏,是两件正交的事 |
| 读到坏镜像时怎么办 | 提到「失败怎么办」这一层为止 | 是。谁改判、按什么凭据改判、改判几次 | 这是本文的核心:启动路径本身可以被复用,判决逻辑才是更新的实质 |
| 介质选型(NOR / NAND / eMMC / UFS) | 是 | 只在影响写入粒度与寿命时引用 | 选型结论在那边,本文只消费它的结论 |
| 更新过程中系统能不能保持连续服务 | 不涉及 | 是。三关口模型、A/B 与恢复分区 | 这是可用性工程问题 |
一句话概括分工:那边负责「读起来」,这边负责「读到的东西换了、或者坏了的时候,改由谁裁决」。
这里有一个容易踩的坑值得单独点出来 —— 有些人把所有期望寄托在不可更新的那一层(BootROM 或掩膜固化的第一级引导)上,希望它来做版本判决、降级保护和回滚。这不成立,原因很简单:不可更新就意味着它对「版本」的理解永远停留在出厂那一天。版本单调性、密钥轮换、新引入的镜像格式,它一概看不懂。把判决逻辑写死在不可更新的层里,等于在出厂的那一刻给整个产品线判了刑。
凡是需要随产品演进的策略(版本比较规则、哪一种摘要算法、允许哪些槽、降级是否允许),都必须放在可更新的层里;不可更新的层只保留一种能力:在下一层全部失败时,提供一条把字节写进来的恢复通道,以及一个足够保守的默认读取顺序。
1.5 本文主线与读者路径
全文的主线是一条:说清楚「任何时候断电,设备为什么一定还能起来」。后续各章按这条主线推进:
关于数值:本文刻意不写寄存器地址、位域、操作码数值与厂商商业数据。所有涉及具体数值的地方(时间、擦写次数、容量开销)都只给量级与关系,落地时请以正式 datasheet / 规范为准。
2. 启动链视角:更新能挂在哪些环节
要讨论更新安全,必须先把「从复位到应用跑起来」这段路拆成层,然后问每一层同一个问题:它能不能被现场改写?写坏了能不能退回来?
2.1 从 ROM Boot 到应用的分层
不同芯片、不同 SoC 的分层名称各不相同(ROM / SPL / SBL / 二级 Loader / bootloader / 应用),但抽象结构是稳定的:每一层负责把机器收拾到一个「能拿下一层」的状态,然后把控制权交出去。更新的绝大部分工作发生在最后两三层。
| 层级 | 典型载体位置 | 这一层负责什么 | 交给下一层的产出 | 能否现场更新 |
|---|---|---|---|---|
| ① BootROM / 掩膜固化引导 | 芯片内部掩膜 ROM,或出厂一次性烧录的受保护区 | 最小介质初始化、按固定顺序找候选镜像、做最基本的完整性检查、进入下载模式 | 一块可以从中找出候选镜像的可读窗口 | 不可(这是它的定义) |
| ② 第一级引导(SPL / boot 块) | 外部介质的固定起始位置,或介质的专用引导分区 | 把外部存储控制器 / 接口初始化到可用、把下一级搬进内部 RAM | 一块已经加载进 RAM 并可以跳转的代码 | 通常不可,少数方案支持受限更新 |
| ③ 第二级 Loader | 介质的普通分区(可能是双份) | 读元数据、选槽、做完整校验与签名验证、加载并跳转、维护尝试计数 | 一个已经验证过、明确知道入口点的应用镜像 | 可 —— 它是更新机制的载体 |
| ④ 应用 / 操作系统 | 应用槽 A / B | 业务逻辑本身 | 不再移交,自己跑到底 | 可 —— 这是被更新频率最高的部分 |
| ⑤ 数据与配置 | 独立的数据分区 / 文件系统 | 运行时状态、配置、日志 | 不参与启动判决 | 可,但要与版本兼容 |
这张表里最值得注意的不是某一层的职责,而是层与层之间的交接面要尽可能窄。理想情况下,上一层只需要知道「下一层放在哪里、多大、头部长什么样、从哪里开始执行」这四件事,其余一概不问。交接面越窄,就越容易做校验,也越容易在未来替换某一层的实现。
2.2 不可更新的第一级能兜住什么
BootROM 与掩膜固化的第一级引导有一个共同点:它们的行为在产品生命周期内是不可变的。这个性质带来一个极强的推论 —— 它们所有的能力都是出厂那一刻的快照,无法吸收后来出现的任何新需求。
因此,正确的设计思路是给它们做减法:把能做的事情做到最小且足够健壮,把不能做的事情明确地不承担,留给可以更新的下一层。
| 候选能力 | 不可更新的第一级是否能做 | 如果指望它做会发生什么 | 应该由谁补位 |
|---|---|---|---|
| 按固定顺序扫描若干候选镜像 | 能,而且这是它最该做的事 | 没有 —— 这是它的本分 | 无需补位 |
| 最基本的完整性检查(长度 + 简单校验) | 部分能,取决于芯片能力 | 指望它做完整加密验证,会导致新算法上线当天全部设备拒绝启动 | 第二级 Loader 做完整的摘要与签名验证 |
| 进入下载模式(串口 / USB / 网络) | 通常能,且这是出厂唯一留下的救命通道 | 若连这条通道也依赖可被覆盖的配置,通道本身会一起坏掉 | 保留不可覆盖的默认触发方式(管脚 / 按键时序) |
| 理解版本号、比较版本单调性 | 不该做 | 版本规则一改,老版本策略与新策略冲突 | 第二级 Loader 读持久化的版本基线 |
| 理解业务语义的多镜像选择 | 不该做 | 分区语义一变,第一级就读偏 | 第二级 Loader 读分区表与槽位元数据 |
把「优先启动哪一个镜像」这个决定硬编码在不可更新的第一级里。这个决定一旦出货就永远无法改进:后来想引入三槽轮换、想支持动态 slot 优先级、想按健康标记剔除某个槽,全部做不到。
正确的做法是让第一级只做无状态的事情:按既定的几个候选依次尝试,谁能通过最基本的检查就交给谁;而「优先谁」这个信息,放在可以被解析、可以被演进的元数据里。
2.3 可更新的第二级 Loader
第二级 Loader 处在一个尴尬的位置:它是整个更新机制的执行者,同时它自己也是一个需要被更新的固件。它负责校验别的镜像,那谁校验它?它负责在应用挂的时候回滚,那它自己挂了怎么办?
这个问题没有免费的答案,只有三种各有代价的解法:
| 策略 | 做法 | 好处 | 代价与风险 | 适合谁 |
|---|---|---|---|---|
| A · Loader 出厂后冻结 | Loader 永远不参与常规更新,只更新应用层 | 最简单,风险最低;Loader 的行为在整个产品生命周期里稳定 | Loader 里的 bug 无法通过 OTA 修复;功能上限受出厂版本约束 | 资源紧张、生命周期短、或已经把 Loader 打磨到足够稳的量产产品 |
| B · Loader 双份 + 由下一层选择 | Loader 也做 A/B,或者在 Loader 前再加一层极薄的选择器 | Loader 可以 OTA 演进,且写坏一份还能退回另一份 | 多一份容量开销;多一层选择逻辑,也就多一处需要考虑断电的顺序 | 生命周期长、后期还要加新算法或新外设的网关、车载类产品 |
| C · Loader 只能走恢复流程更新 | 常规走应用层,Loader 只在专门的维护模式下更新 | 常规路径足够简单;Loader 更新被视为一次有监督的维护操作 | Loader 的缺陷无法远程自愈,必须触发一次专门的维护流程 | 有条件做现场维护、或法规要求关键固件变更需受控执行的产品 |
无论选哪一种,有三条纪律是共通的:
- Loader 必须验证它要加载的东西,不能把信任交给「上一次似乎成功了」。
- Loader 自身的更新必须走与业务镜像同样严格(通常更严格)的流程,因为它是最不能被写坏的那一份。
- Loader 必须能在「自己刚刚被更新过,但应用还没起来」这个状态下被识别 —— 也就是说,Loader 的更新也必须有「待确认」语义,只不过它的确认者通常是紧接着的一次成功启动。
2.4 应用镜像的位置与启动判决
「应用镜像放在哪里」看似是个地址问题,实际上是个元数据驱动问题。硬编码绝对地址的好处是简单,代价是:一旦分区布局调整,整个启动链都要跟着改,而分区布局恰恰是产品演进过程中最容易变的东西之一。
更稳的模型是:槽位是逻辑概念,物理位置由分区表给出,当前该跑哪个槽由持久化元数据给出。判决的执行者(第二级 Loader)只做三件事:读元数据 → 选候选 → 验证候选。
这里有一个关键的顺序问题常被搞反:是先「判决」再「校验」,还是先「校验」再「判决」?
正确的答案是先读元数据选出一个候选,再对候选做校验,校验不通过就换下一个候选。原因是:元数据本身很小、校验很快、而且可以有冗余;而逐个校验所有候选镜像的代价太大(尤其是要验签名的时候)。把元数据作为「先看哪里」的索引,把真正的重验证留给被选中的那一个,既能保证安全性,也把启动时间压了下来。
这条结论还有一个隐含要求:元数据自身必须是可校验的,否则「元数据说 A 槽坏了」这句话本身可能是字节翻转的产物。第 4 章会把元数据的冗余、校验与落盘顺序展开。
2.5 各环节的可写性与回滚能力对照
把前面几节合并成一个可以贴在评审会上的结论表:
| 环节 | 能否现场改写 | 写坏之后的直接后果 | 是否有第二次机会 | 必须具备的保护 |
|---|---|---|---|---|
| BootROM / 掩膜固化引导 | 不能 | 芯片级失效,通常不可现场修复 | 没有 | 不需要保护机制,只需要它自身极度保守 |
| 第一级引导(SPL / boot 块) | 通常不能,少数受限支持 | 设备连第一段 Loader 都拉不起来 | 基本没有 | 写保护、默认候选顺序、不可覆盖的下载通道 |
| 第二级 Loader | 能,但应当谨慎 | 失去判决能力,即便应用完好也起不来 | 取决于是否做了双份 | 双份或恢复分区、待确认语义、清晰的失败判据 |
| 应用槽 A / B | 能,且是常规操作 | 该槽无法启动 | 有,另一槽完好 | 尝试计数、自动回落、写前整槽擦除 |
| 元数据区 | 能,且写得非常频繁 | 判决失去依据 —— 后果比单个应用槽坏更严重 | 靠冗余与双份 | 双份、序号、自身校验、原子更新 |
| 数据与配置 | 能 | 应用起来但行为异常 | 有,靠格式本身的向后兼容 | 版本迁移脚本、默认值兜底 |
越靠下的层越不可更新,所以它必须越保守;越靠下的层越承担「给出下一个候选」的责任,所以它的判断依据(让对方能读懂的那点元数据)必须越小、越冗余、越容易被重新算出来。
3. 分区方案:单区、双区与 A/B
分区方案回答的是一个非常具体的问题:当我要写新镜像的那一刻,正在运行的那个镜像放在哪里、它会不会被这次写入碰到。
3.1 单区就地更新的三种失败模式
单区方案(就地更新 / in-place update)的定义很简单:整个介质上只有一份应用镜像,更新就是把新的字节覆盖到旧的字节上面去。它的优点是几乎不占额外空间,实现也最短 —— 一个发送端、一个写介质的循环、一个跳转地址。
它的缺点同样简单:从「擦除命令生效」到「新镜像写完并通过校验」之间,介质上不存在任何一份完整的、可启动的应用镜像。这段时间里只要断电,设备的凭据就断了。
需要区分的是三种后果完全不同的失败模式:
| 失败模式 | 中断点在哪里 | 介质上剩下什么 | 现场表现 | 能否自愈 |
|---|---|---|---|---|
| 模式① · 擦了旧的没写完新的 | 擦除完成之后、写入完成之前 | 既没有完整旧镜像,也没有完整新镜像(可能连头部都被抹掉) | 上层 Loader 能起来,但没有任何应用可跳 | 不能,只能靠下载模式或恢复分区 |
| 模式② · 写完了但接管信息不一致 | 镜像体写完之后、头部或长度 / 入口信息更新完成之前 | 新旧字节混在同一块区域里,摘要校验通不过 | 可能被判为成功也可能直接卡死,取决于校验做在哪一步 | 不能,而且极易被误判为成功 |
| 模式③ · 起来了但没人替它打包票 | 新版本实际运行之后 | 只有新版本,旧版本已经被无可挽回地覆盖 | 反复重启、功能异常、跑一阵就崩 | 不能 —— 唯一的出路是重新刷写 |
模式①最直观,也最让人警觉;真正阴险的是模式②和模式③。模式②的问题在于「看似成功」—— 如果只检查「写入返回 OK」而不做回读和摘要比对,设备会把一个半新半旧的镜像当成好东西来启动。模式③的问题则在于它根本不存在退路:即使新版本明显跑不稳,也没有另一份可以回去。
只有在满足以下全部条件时,单区就地更新才是可以接受的方案:
- 存在一条不依赖任何可更新内容的恢复通道(例如出厂固化在 ROM 里的串口 / USB 下载模式,且有不可被覆盖的触发方式);
- 更新窗口可以被人为控制在「设备不会被断电」的场合,或者说设备有人值守、有可靠供电;
- 更新失败的代价可接受 —— 例如开发样机、短期试用机、或本来就有现场维护排期的设备。
任何一条不满足,都应该认真考虑双区。
3.2 双区(A/B)的切换模型
双区(A/B,也常写作双槽、slot A / slot B)的核心思想只有一句:任何时刻都只有一份镜像在被运行,而写入永远只发生在当前不运行的那一份上。
这句话的威力在于它把一个复杂的顺序问题变成了一个简单的归属问题:既然正在跑的那一槽不会被碰,那么「更新写到一半断电」这件事在语义上就不可能破坏正在运行的系统。剩下要回答的只有两个小问题:新写的这一份什么时候生效,以及它如果不争气怎么退回来。
| 阶段 | 槽 A 角色 | 槽 B 角色 | 此刻写入会发生什么 | 最坏后果 |
|---|---|---|---|---|
| 常态 | 活跃 · 正在运行 | 备用 · 内容无意义 | 还没有开始写 | 无 |
| 写入中 | 活跃 · 继续运行 | 正在被写入新版本 | 只写 B,A 一个字节都不碰 | B 作废,A 照常提供服务 |
| 切换点 | 仍是退路,暂时不被指向 | 待确认 · 尚未被证明能活下来 | 改写一个持久化的指向与标记 | 元数据写坏 —— 这正是元数据必须冗余的原因 |
| 确认后 | 备用 · 下次的写入目标 | 活跃 · 已自确认 | 可以开始下一轮更新 | 无 |
注意表里第四行:切换本身几乎不搬任何东西。这正是双区方案在工程上可行的原因 —— 真正耗时的部分(擦除、写入、回读校验)全都发生在「不急着生效」的阶段,而真正决定生死的那个动作只是一个很小的写操作。
这个性质带来一个重要推论:如果切换动作本身做不到原子且可靠,那么前面搬了多少字节都不会让设备更安全。所以第 4 章要专门讨论这「很小的一步」。
3.3 双区的容量代价与「两份都放不下」
双区不是免费的。它最直接的成本是一句简单的算术:假设介质总容量减去引导区、元数据区、数据区之后,剩下来的可用空间是 S,那么双区方案下单个应用镜像可用的上限约为 S 的一半。镜像尺寸稍一膨胀,就会撞上这堵墙。
需要强调:这里的「约一半」说的是关系,不是精确数字。真实开销还要算上擦除块的对齐损耗、坏块预留、旧布局切换期的浪费,以及更新机制自身(例如第二份元数据槽)所占的地皮。具体请以正式 datasheet 与实际布局为准。
| 布局 | 应用镜像可用上限(相对关系) | 额外的退路来自哪里 | 典型适用场景 |
|---|---|---|---|
| 单区就地更新 | 最高的那一个:扣掉固定开销后几乎全给应用 | 介质里没有退路,只有介质之外的下载通道 | 镜像很小、或有人值守、或设备本身不重要的场合 |
| 双区 A / B | 大约是可用空间的一半 | 另一槽始终保留着上一份可用镜像 | 绝大多数需要远程维护的产品 |
| 双区 + 恢复分区 | 一半再多扣一份专门给恢复用 | 除另一槽外,还有一个不受常规更新影响的最小系统 | 镜像构成复杂、或要求「两槽都坏也能起」的场合 |
| 「两份放不下」时的解法 | 怎么做 | 省下多少 | 代价 | 何时不该用 |
|---|---|---|---|---|
| 把镜像本身做小 | 裁剪功能、资源外置、压缩后启动时解压 | 取决于镜像构成,通常见效最直观 | 功能被一次性约束:发布后没法临时加回来 | 产品功能仍处于快速扩张期时 |
| 第二份放到外部介质 | 备用槽放在 SPI NOR / SD 卡 / 另一颗裸片上 | 主介质的可用空间基本还给应用 | 多一颗器件、多一组布线、多一处可靠性薄弱点 | 对成本与面积极度敏感时 |
| 第二份只放压缩保底镜像 | 备用槽只存「能启动且能再更新」的最小系统 | 省得最多 —— 保底镜像可能只有完整镜像的很小一部分 | 恢复过程要一次解压与搬运,比纯 A/B 慢 | 要求恢复必须在极短时间内完成时 |
| 拆核心包与功能包 | 核心系统与加载器走 A/B,业务包单份可重拉 | 只需要为较小的核心部分付双份的钱 | 两部分接口必须极其清晰,且业务包要能安全降级 | 业务包坏了设备就完全没服务时 |
「两份放不下」几乎总是一个可以提前算出的结论:把镜像当前尺寸、未来一到两年的功能规划、以及布局里的固定开销列在一张表里,就能立刻知道会不会撞墙。
最糟的处理方式是先按单区量产,等产品要做远程更新时才改双区 —— 那通常意味着换一颗更大容量的器件,并把整套验证重走一遍。
3.4 恢复分区与工厂分区
「恢复分区」和「工厂分区」这两个词经常被混用,但它们承担的职责不同,混用会导致保护策略搞错:
| 分区类型 | 里面放的是什么 | 谁会去动它 | 是否需要常规写保护 | 它消失了会怎么样 |
|---|---|---|---|---|
| 恢复分区(recovery) | 一个功能被裁剪到最小、但具备「再更新一次」能力的小系统 | 更新框架:正常启动跳过它,只在恢复流程里进入 | 建议保护 —— 常规路径不该有机会改写它 | 失去自我修复能力,兜底手段下降一档 |
| 工厂分区(factory / golden) | 出厂时的完整镜像,用于「无论如何回到出厂状态」 | 基本没人动;只有特殊维护流程才可能改写 | 应该强保护 —— 理想状态下常规固件完全擦不动它 | 失去最后那条退路,但不影响日常可靠性 |
| 暂存 / 下载区(staging) | 传输阶段临时落脚的那个包 | 每一次更新都会写,写完全就可以丢 | 不需要保护 —— 它本来就是可牺牲的 | 只是本次更新要重来,设备不受影响 |
| 出厂校准与设备身份区 | 序列号、校准参数、设备证书一类一旦丢失就无法复现的数据 | 产线写一次,之后基本只读 | 必须强保护,且要有独立备份 | 设备身份丢失,可能引发归属与售后纠纷 |
这里最容易被忽略的是最后一行。更新机制再完美,也架不住一次分区表错误把序列号与校准区擦掉 —— 那类数据一旦丢了,设备即便还能启动也已经不再是原来那台设备。
一条实用规则:凡是不能被重新生成的数据,都必须放在更新流程永远不会写入的区域,并且独立冗余。「我们不会去写那里」这种口头承诺不构成保护,必须是布局上写不到、或者硬件上写不动。
3.5 分区表自身如何更新
分区表是一个很特殊的对象:它描述的正是「别的更新该怎么落盘」这件事本身。如果分区表自身的更新不安全,那么再好的镜像更新流程也建立在一个可能随时被改写的基础上。
常见做法按安全程度从低到高排列:
| 做法 | 怎么操作 | 风险 | 适用场合 |
|---|---|---|---|
| 就地改写单一分区表 | 直接在原位更新那一份描述信息 | 最高:写一半断电就等于不知道还有几个槽 | 不建议用于任何需要远程更新的产品 |
| 双份 + 序号 + 自身校验 | 写第二份时用递增序号与校验;读取时取序号最大且校验通过的那份 | 低:任何时刻至少有一份可读 | 推荐作为默认做法 |
| 分区表放在受保护区、出厂后不变 | 把布局信息写死在很少被更新的区域,其它都是固定偏移 | 很低,但布局灵活性也最低 | 布局在产品生命周期内确实不会变的产品 |
| 追加式世代记录 | 每次布局变更写一条新记录,读取时回溯到最后一条完整记录 | 低,但要考虑空间回收与写满之后的处理 | 布局变动比较频繁、且介质允许追加写的产品 |
这是一条经验性禁令:分区表与镜像内容最好不要在同一个断电周期内一起更新。原因是两者的组合状态数量会成倍增加,而其中至少有一种组合是无法判定的(分区表已经指向新槽位,镜像却还是旧的那一套)。
非要一起改时,可靠的做法是分两次上电完成:第一次只把新布局安全地写好并保证旧布局仍可读;确认之后,下一次上电才使用新布局去写镜像。
3.6 分区方案对照
把三种主要方案放在一起对照:
| 维度 | 单区就地更新 | 双区 A/B | 双区 + 恢复分区 |
|---|---|---|---|
| 应用可用容量 | 最高 | 约一半 | 一半再多扣一份 |
| 写入失败会不会影响运行中的系统 | 会 —— 这正是它的问题所在 | 不会 | 不会 |
| 是否存在「切换」这个动作 | 没有,写完就是它 | 有,且必须被单独设计 | 有,且要能进恢复 |
| 回滚能力 | 无 | 有 —— 换回另一槽即可 | 有,且多一档兜底 |
| 实现复杂度 | 最低 | 中等 —— 多一个 Loader 的判决逻辑 | 较高 —— 多一个系统要维护 |
| 验证工作量 | 小 | 中等:要覆盖三个关口的断电注入 | 大:要额外验证恢复流程本身 |
| 适合承担多大代价的失败 | 几乎不承担 | 承担得起单机软件层面的失败 | 承担得起「连更新框架本身都坏」的情况 |
4. 切换机制:什么时候新固件才算生效
这一章是全文技术密度最高的部分。前面所有搬字节的工作,最后都要落到「新固件到底在哪一瞬间算数」这个点上;而这个点本身,是全过程中唯一必须做得绝对可靠的一步。
4.1 切换点的三个候选
「什么时候新固件才算生效」这个问题有几个天然的候选答案。它们的差别看起来只是措辞不同,实际决定了整套方案的失败代价:
| 候选切换点 | 表述 | 为什么会被想到 | 它到底错在哪里 | 结论 |
|---|---|---|---|---|
| 候选① · 最后一个字节下载完成 | 包收完了,就算更新成功 | 实现最简单,逻辑最顺 | 此时新镜像还在暂存区,介质上的运行内容压根没动 —— 它描述的是「传输完成」而不是「更新完成」 | 错:这只是关口一,不是关口三 |
| 候选② · 写入非易失存储的那一刻 | 写完那一刻起,新固件就是「当前的」了 | 考虑了持久化,看起来更认真 | 写入是一个持续过程,没有「那一刻」;而且写完之后设备仍在跑旧版本,两套东西同时宣称自己是当前版本 | 更接近但仍然不成立:必须有一个离散的、可判定的瞬间 |
| 候选③ · 下一次上电时由判决者读取元数据 | 谁读到这个状态,就从哪一槽启动 | 把「生效」定义为一个可以被重复读出的持久状态 | 它需要额外准备一套元数据,以及一个能在读到坏状态时改判的判决者 | 正确:这是唯一可判定的切换点 |
关键区别在于可重复性。候选①依赖的是一个稍纵即逝的事件,断电之后没有人知道它发生过没有;候选②依赖的是一个持续过程的中间时刻,本身就定义不清;只有候选③把「当前跑哪一份」变成了一个写在非易失载体上、每次上电都会被读取、读完就能作出决定的状态。
换句话说:生效不是一个事件,而是一个持久状态被下一次启动读到。
4.2 启动标志与尝试计数
要让「当前生效的活动 slot」这个状态能被可靠地读出,至少需要两个持久化变量:
- 待确认标记(pending / trial flag):表示「下一次上电要走一次可能是新的路径」。它区分了「稳定运行的常规启动」与「刚刚改判过的一次尝试」。
- 尝试计数(attempt counter / rollback index):表示「这条路已经失败过几次」。它是防止系统陷入无限重启的唯一手段。
这两个变量都必须写在断电后会保留的地方,而且必须能被原子地更新。把它们放在内存里、或者放在会随内核重启而重建的地方,是这一类设计里最常见也最致命的错误 —— 断电之后那里什么都不剩。
| 持久化字段 | 取值 | 由谁写入 | 由谁读取 | 写坏或读不到会怎样 |
|---|---|---|---|---|
| 待确认标记 | 有 / 无 | 更新框架在改判那一刻写入;自确认成功后清除 | 启动判决者在每次上电时读取 | 读不到就该走「保守到上一层」的路径,而不是随便猜一个 |
| 尝试计数 | 整数,通常与上限比较 | 判决者在每次未能确认时递增;成功后归零 | 判决者 | 读不到时应按最坏情况处理 —— 当作已经触限 |
| 槽位状态 | 活跃 / 备用 / 待确认 / 已标坏 | 更新框架与判决者共同维护 | 判决者选择候选时使用 | 应当拒绝启动该槽,而不是「勉强试一下」 |
| 版本信息 | 各槽各自的版本,以及一条防回滚基线 | 更新框架在安装成功时更新 | 安全校验环节读取 | 基线的处理要格外保守,见第 6 章 |
递增通常在「没跑起来」的时候发生,比较容易想到。真正容易漏的是归零:很多实现只在「自确认成功」时归零,却忘了设备因为其它原因(人为重启、远程命令重启、正常关机再开机)而重启的情形。
更稳的约定是把「寿命计数」绑定在一次具体的更新尝试上:只要不是在这次尝试的上下文里失败,就不该累加。判据写不清,现场就会表现为「设备用得好好的,某天突然回滚了」。
4.3 回滚触发的判据
有了尝试计数,接下来要回答的是:什么叫做「这次没通过」?这个问题没有唯一答案,它取决于产品能容忍什么,也取决于做可行性权衡时的取舍。这里给出常见的几种判据与它们的副作用:
| 判据 | 怎么检测到 | 优点 | 副作用 / 风险 | 适合 |
|---|---|---|---|---|
| 看门狗复位且未自确认 | 复位原因指示 + 待确认标记仍在 | 实现简单,覆盖面广:跑飞、死锁都能抓到 | 无法区分「固件有问题」与「外部偶发干扰」 | 作为基础判据,几乎所有方案都会配上 |
| 确认窗口超时 | 启动后的一段时间内没有收到自确认 | 不依赖复位原因,逻辑清晰 | 窗口取短了会误杀慢启动的版本;取长了会延长故障暴露时间 | 主判据,通常与前者组合 |
| 应用主动上报失败 | 应用自检不过时主动写一个失败标记再重启 | 误判率低 —— 是应用自己说自己不行 | 依赖应用本身的配合;应用如果崩得早,就没有机会上报 | 作为补充判据 |
| 与服务端握手成功 | 规定时间内连上服务器并报告版本 | 把「能用」定义得非常贴近业务真实可用 | 网络不可用会被误判为固件不可用 | 联网设备谨慎使用,需与网络状态解耦 |
| 关键外设自检通过 | 业务关键的传感器 / 通信口初始化成功 | 贴近该设备的实际功能定义 | 容易把环境问题误判成固件问题 | 作为补充,不宜单独作为回滚判据 |
一条重要的设计原则:判据应当「宁可迟钝,不要冲动」。冲动地回滚会把「偶发的、本来能自愈的问题」升级成「一次完整的服务中断」,而且一旦开始回滚,往往在很短的时间内连续波及整批次设备。判据越激进,誤杀率越高,这在第 10 章讨论批量更新时会变得更严重。
4.4 元数据的落盘顺序
这是本章最核心的一节。前面说过,闪存的擦写本身不是原子的:擦了不一定写完,写了不一定写完头部,写完头部不一定写完指向。我们能做的最有力的事情,不是去消除这些中间状态(做不到),而是把它们的发生顺序安排成「任何一种中间状态都能被判定」。
核心规律:先写「没有它也能活」的东西,后写「唯一的那个开关」。切换开关必须是整个过程中最后一个落盘的字节。
把这张图读成一句话:越往后的一步,它的失败代价越高,所以它必须写得越小、越靠后。第③步是整个更新过程中唯一在语义上不可替代的一步 —— 在它之前,任何中断都只是「白干一场」;在它之后,一切由尝试计数负责。
由此还可以推出几条实用的纪律:
- 镜像体先写,头部最后写。头部是这段字节串的「自我描述」,它的存在与否就是这支镜像是否成立的唯一凭据。
- 槽元数据(这一槽的存在与健康状况)先于全局指向。先让世界知道「这份东西存在且完整」,再让判决者考虑要不要用它。
- 永远不要试图「同时」更新两件东西。没有同时,只有顺序;凡是以为在同时更新的地方,实际都是在赌运气。
- 任何写完之后都必须回读校验。写入返回成功只是说命令被接受,不代表字节落到位了。
4.5 元数据自身的冗余与校验
元数据有一个残酷的属性:它比它描述的对象更值得关心。一个槽里的镜像坏了,判决者还能去挑另一个槽;元数据坏了,判决者连「有哪几个槽」都不知道。
| 保护手段 | 做法 | 能防住什么 | 防不住什么 | 备注 |
|---|---|---|---|---|
| 双份(或两份以上)冗余 | 同一份元数据保存两遍,各自独立更新 | 单份被写坏 —— 另一份仍然可读 | 两份恰好在同一断电周期内被先后更新的情形 | 必须配合「不同时刻写」的顺序使用,不能同一次断电周期内全写 |
| 递增序号 | 每次更新后序号加一,读取时取序号最大且合法的那份 | 分不清谁更新的问题 | 序号本身回绕 —— 需要预留足够宽度 | 与校验配套使用才有意义 |
| 自身校验和 / 摘要 | 元数据自带一份校验,读出来先算一遍 | 比特翻转、半写状态 | 恶意篡改 —— 校验不是签名 | 校验代替不了信任,见第 6 章 |
| 写完后回读比对 | 写完立刻读出来比对再确认这一步完成 | 写入没有真正落盘的情形 | 回读本身被干扰的极小概率情形 | 成本很低,收益很高的习惯 |
| 读到不可用时的保守默认 | 读不到或通不过校验时,按预先写死的保守策略处理 | 元数据整体不可用 | 无 —— 这是最后一道兜底 | 默认策略必须提前定义,不能临时猜 |
很多实现在发现一份元数据坏了但另一份好的时候,会顺手把坏的修回去。这个动作看似贴心,实际会抹掉一次重要的现场证据:到底是哪一个环节在写坏它,从此无从追查。
正确的做法是:使用好的那份继续工作,同时把「坏的这一份曾经坏过」这件事记录下来,并上报。修复动作可以延后到下一次正常的元数据写入时自然完成。
4.6 失败注入测试要点
更新机制的正确性无法靠正常路径的测试来证明,只能靠刻意的失败注入。以下是必须覆盖的项目:
| 测试项 | 怎么注入 | 期望结果 | 不通过时的典型症状 |
|---|---|---|---|
| 写入过程中断电扫描 | 在写入过程的多个点上切断电源,逐步扫描整个写入区间 | 任何时刻上电都能启动到某一完整版本,且不出现不可判定状态 | 出现「起不来且没有进入恢复」的状态 —— 这是最高优先级的缺陷 |
| 元数据更新瞬间断电 | 在改写全局指向的那一步断电,且扫描该步内部 | 要么用旧的指向启动,要么用新的指向启动,二者必居其一且都能继续运行 | 读到半个元数据后行为随机,或干脆拒绝启动 |
| 确认窗口内反复断电 | 在新版本已经启动但尚未自确认时反复断电 | 尝试计数稳定递增,触限后自动回落到另一槽 | 计数不递增(无限重启)或过早触限(一两次就回滚) |
| 强制写入残缺或缺校验的版本 | 人为破坏镜像体某个字节,或在头部写错长度 | 校验不通过,切换到另一个候选或进入恢复 | 带着坏镜像强行启动;或者反过来,好镜像被误判为坏 |
| 元数据单份/双份损坏 | 逐份写入已知非法内容 | 使用剩余合法的那份继续工作,并留下记录 | 直接拒绝启动,或静默接受坏的那份 |
| 批量回滚压力测试 | 让一批设备同时进入「更新后不可用」状态 | 回滚比例符合预期,服务端有明确的熔断与观察窗口 | 短时间内整批设备一起回滚,服务端失控 |
更新过程中唯一的原子性来源,是顺序:把「可以没有的东西」写在前,把「唯一的开关」写在后。剩下的事情由待确认标记与尝试计数这两个持久化变量接管,它们决定了设备在不知道新版本好坏的时候应该做什么。