多MCU固件升级

前言:
  一块主控板上如果有多个 MCU,固件升级就不再是“把一个 bin 文件写进去”这么简单。升级数据怎么从对外的 MCU 分发到其他 MCU、每个 MCU 的固件存在哪里、掉电后怎么恢复、上电后怎么保证四个 MCU 的版本组合一致,这些问题需要在架构阶段就定清楚。本文以四个 MCU 的典型板卡为例,讨论一种基于“主 MCU 统一对账 + 分组存储 + loader 兜底”的多 MCU 固件升级方案。

1 系统背景与总体架构

  假设一块主控板上有 4 个 MCU,分别记为 A、B、C、D:

  • A 通过对外通讯链路与外部设备连接;
  • A 与 B、A 与 C、C 与 D 通过内部通讯链路连接;
  • A 和 C 各挂一片外挂 Flash;
  • B 和 D 没有外挂 Flash;
  • B 由 A 加载启动,D 由 C 加载启动。

  升级时,外部把包含 A、B、C、D 四个 app 固件的大包发送给 A。A 解析大包,把 A、B 的 app 镜像写入 A 的外挂 Flash,把 C、D 的 app 镜像发送给 C,由 C 写入 C 的外挂 Flash。B、D 不直接接收升级数据,它们在复位后分别由 A、C 重新加载。

  A/C 的外挂 Flash 里除了 app 镜像,还按对应关系保存 A/B、C/D 的 loader 镜像。loader 镜像在贴片时写入,后续不参与 OTA,也不会被 app 升级擦除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
                 外部通讯链路
│
▼
┌─────┐
│ A │──────── 内部链路 ──────── B
└─────┘
│
内部链路
│
┌─────┐
│ C │──────── 内部链路 ──────── D
└─────┘

A 外挂 Flash:A loader、A app、B loader、B app
C 外挂 Flash:C loader、C app、D loader、D app
B、D:无外挂 Flash,由 A、C 加载

  这个架构里有两个角色:

  • A 是升级网关和总协调者:接收外部大包、解析小包、对 A/B 做本地升级、把 C/D 镜像转发给 C、协调整板复位。
  • C 是子主机:接收 A 发来的 C/D 镜像,写入自己的外挂 Flash,并在复位后加载 C、D。

  B、D 是“被加载节点”。它们没有本地固件存储,升级时不需要自己接收和写入固件,只负责运行、上报版本、被 A/C 重新加载。这是本文方案和“每个 MCU 各自接收、各自写 Flash”方案最大的区别。

2 Loader + App 的运行模型

  每个 MCU 的固件分为 loader 和 app 两部分:

  • loader 在芯片贴片时固化,后续不再修改;它放在片内 Flash/ROM,或放在 A/C 外挂 Flash 的独立 loader 区;
  • app 在复位时由 loader 从 Flash 搬到 RAM 运行;
  • app 运行在 RAM,而不是直接从 Flash 取指。

  这个运行模型带来两个重要性质。

2.1 写坏 app 不会变砖

  升级时,app 或 loader 把新的 app 镜像写入 Flash。即使写到一半掉电,Flash 中的 app 可能是不完整的,但 loader 本身还在。复位后 loader 发现 app 无效,会停在 loader 中;loader 仍然具备基本的通讯和更新 app 的能力,可以重新接收镜像,因此不会变砖。

  这里的前提是:

  • loader 放在片内 Flash/ROM,或放在外挂 Flash 的独立受保护区;不能和 app 升级区混在一起;
  • loader 自身有写保护;
  • loader 在跳转 app 前会校验 app 的完整性;
  • loader 在 app 无效时不会盲目跳转。

2.2 App 可以擦写自己的 Flash

  因为 app 从 RAM 执行,所以它可以在运行期间擦写自己所在的 Flash 区域。旧 app 仍然在 RAM 中继续运行,Flash 只是“程序存储”。写完后,Flash 里已经是新镜像,但当前运行的仍是旧 app;只有复位后,loader 才会把新镜像搬到 RAM。

  这样设计有两个好处:

  • 升级过程中节点仍然可用,不需要先停下来进入 loader;
  • 不需要整镜像缓存,边收、边校验、边写 Flash,RAM 占用很小。

  但“RAM 运行”也带来一个必须满足的约束:擦写 Flash 期间,所有可能被执行的代码、常量、中断向量表、中断服务程序、Flash 擦写函数都必须在 RAM 中,VTOR 要指向 RAM 中的向量表。只要有一个中断或常量落在被擦除的 Flash 区域,擦除期间就可能取到无效数据而跑飞。

2.3 B、D 的 loader 在哪里

  B、D 没有外挂 Flash,但它们的 loader 镜像也需要有存放位置:可以放在 B/D 的片内 Flash/ROM 中,也可以放在 A/C 的外挂 Flash 中按对应关系保存,由 A/C 在启动时加载。本文按后一种方式描述:A 的外挂 Flash 保存 A/B 的 loader 和 app 镜像,C 的外挂 Flash 保存 C/D 的 loader 和 app 镜像;loader 区在贴片时写入,后续不参与 OTA。

  因此,A/C 的 loader 必须具备两种能力:

  1. 加载自己的 app;
  2. 把 B/D 的镜像从 A/C 的外挂 Flash 加载到 B/D,或者把镜像服务给 B/D 的 loader。

  如果 A 的 app 坏了,A 的 loader 不能只恢复 A;否则 B 也跟着起不来。C 和 D 同理。

3 固件包与存储布局

3.1 大包结构

  外部发给 A 的大包由 A、B、C、D 四个 app 小包组成,大包头中包含每个小包的长度、偏移等解析信息。

  建议把大包头放在最前面。这样 A 可以先读头,拿到每个小包的长度和偏移,然后边收边把对应小包写入存储,不需要把整个大包缓存下来。

  A 不需要长期保存原始大包,解析一次后只把四个 app 小包写入对应存储即可。每个 app 小包自身带有头部信息:目标 MCU、板型/硬件版本、镜像版本、build ID/hash、长度。loader 在装载前用这些信息识别和校验镜像。

3.2 A、C 的存储布局

1
2
3
4
5
A 外挂 Flash:
[A loader] [A app] [B loader] [B app]

C 外挂 Flash:
[C loader] [C app] [D loader] [D app]

  布局上需要保证:loader 区和 app 区按 Flash 擦除块独立对齐,写 app 不能擦到 loader,也不能擦到同组的另一个 MCU 镜像;C 外挂 Flash 同理。每个镜像(包括 loader 和 app)保留自己的头部,A/C 通过头部读取版本和 hash。loader 镜像在贴片时写入,后续不参与 OTA。

3.3 写入与校验

  A 解析大包后,把 A/B 的 app 小包写入 A 的外挂 Flash;把 C/D 的 app 小包转发给 C,由 C 写入 C 的外挂 Flash。loader 镜像不在大包中,也不参与 OTA;写 app 时只能操作 app 区,不能碰 loader 区。每个 app 小包写完后校验长度和 hash;写坏或掉电时,下一次由 loader 停在 loader,等待重新发送该小包即可。

  按本文的存储分配,A 只保存 A/B 的 loader 和 app,C 只保存 C/D 的 loader 和 app:

  • A 可以离线恢复 A/B;C 可以离线恢复 C/D;
  • 如果只是 C 的 app 写入失败,loader 仍在,A 可以重新发送 C/D 的 app;
  • 如果 C 的外挂 Flash 整体损坏,C/D 的 loader 也丢了,需要工厂或专用恢复流程先恢复 loader,再下发 app;
  • 反过来,A 的外挂 Flash 整体损坏时,C/D 仍可从 C 的外挂 Flash 启动,A/B 需要先恢复 loader,再下发 app。

  掉电恢复依赖 loader 停在 loader 后重新接收小包;loader 区损坏则不属于普通 OTA 能恢复的范围。

4 升级流程与版本对账

4.1 对比当前运行版本

  版本对账的对象很明确:拿每个 MCU 当前运行的小包版本,和大包里解析出来的对应小包版本比较。当前运行版本就是 loader 从 Flash 中选中并搬到 RAM 运行的那份镜像的版本;在稳定状态下,它也就是 Flash 中当前有效小包的版本。

  对 B、D 来说,它们由 A/C 加载,当前运行版本就是 A/C 的 Flash 中对应小包被加载后的版本;A/C 可以直接知道,也可以让 B/D 上报。对 A、C 自己来说,当前运行版本就是各自 RAM 中的 app 版本,而它来自各自 Flash 中的有效镜像。

  比较时不能只看版本号字符串,至少要看:

  • 镜像版本;
  • build ID 或镜像 hash;
  • 板型/硬件版本。

  只有这些都一致,才认为“当前运行的小包”和“大包中的小包”是同一个镜像。

4.2 未激活的镜像不算当前运行版本

  有一种瞬态需要说明:新镜像已经写进 Flash,但板子还没复位,此时 RAM 里运行的仍是旧 app。这个新镜像还不是“当前运行版本”,它只是待激活镜像。实际实现时可以用 pending/active 标志区分:

  • 当前运行版本:loader 当前选中并加载的版本;
  • pending 镜像:已经写入 Flash、等待复位后加载的版本。

  如果升级流程保证“写完统一复位,复位后再对账”,那么对账时不存在 pending 状态,直接比较当前运行版本即可。若在升级过程中也要对账,则必须先看 pending/active 标志,避免把刚写入但未激活的镜像误认为当前运行版本,从而重复传输或重复擦写。

4.3 总体流程

  1. 外部把大包发送给 A。A 校验整包,解析大包头和四个 app 小包。
  2. A 对比 A 的当前运行版本和大包中的 A 版本;不一致时写 A 的外挂 Flash app 区。
  3. A 对比 B 的当前运行版本和大包中的 B 版本;不一致时把 B 的 app 镜像写入 A 的外挂 Flash。B 不直接接收升级数据。
  4. A 向 C 查询 C、D 的当前运行版本,分别和大包中的 C、D 版本比较:
    • 不一致:A 把对应 app 镜像发送给 C,由 C 写入自己的外挂 Flash app 区;
    • 一致:不传输、不擦写。
  5. A 保留 A/B 的 app 小包,C 保留 C/D 的 app 小包;loader 镜像独立保存且不参与 OTA;各 app 小包的版本和 hash 从其自身头部读取。
  6. 所有写入的 app 镜像校验通过后,A 协调整板复位。
  7. 复位后 A 再次对账。全部匹配则进入正常业务;仍有不匹配则重试,超过重试上限进入维护/故障态并上报。

  B、D 在这个流程里不接收升级数据。A/C 直接把 B/D 的镜像写进自己的外挂 Flash,B/D 在复位后被 A/C 重新加载。这样 B/D 不需要实现自己的固件接收和 Flash 写入逻辑,升级数据链路也更简单。

4.4 流式写入

  A 写 A/B 镜像、C 写 C/D 镜像时,都不需要缓存完整镜像。可以采用流式写入:

1
2
3
4
5
6
接收一帧
→ 帧 CRC 校验
→ 放入页缓冲
→ 攒满一个 Flash 页后写入
→ 同时更新整镜像 hash
→ 页 ACK

  关键点:

  • 帧 CRC:收到就校验,坏帧 NACK,不写入;
  • 页缓冲:Flash 写入有页/块粒度,至少需要一个页缓冲,不是按字节写;
  • 页 ACK/重传:发送端要能等 Flash 擦写,不能只管发;
  • 整镜像 hash:按偏移计算,重传的帧不能重复计入;
  • 有效标志:擦除前先清 app 有效标志,全部校验通过后再置有效;
  • 回读校验:流式 CRC 只能证明“收到的数据对”,不能证明“写进 Flash 的数据对”。loader 在把 app 从 Flash 搬到 RAM 时顺便计算 hash,只有校验通过才跳转,这天然形成了回读校验。

  因为 app 运行在 RAM,写入自身 Flash 期间旧 app 仍然可以继续运行、继续通讯。写坏 Flash 不会影响当前运行,只会影响下一次复位后的启动;下一次复位时 loader 发现镜像无效,停在 loader 等待重传即可。

5 上电对账与自愈

  每次上电,A 都应该做一次版本对账:

1
2
3
4
5
6
7
8
9
上电
→ A 校验自己的 A/B app 小包;C 校验自己的 C/D app 小包
→ C 把 C/D app 小包版本上报给 A
→ 读取 A/B/C/D 的当前运行版本
→ 与各自存储的小包版本比较
→ 全部匹配:正常启动,不升级、不复位
→ 有差异:进入维护/安全态,只处理差异节点
→ 全部处理成功:整板复位,再次对账
→ 失败:重试;超过上限进入故障态并上报

  几个细节:

  • 启动安全态:对账完成前,其他节点不要进入正常业务,避免“旧 app 已经干活、新 app 还没升级”的混合运行;
  • 查询重试:上电后下游节点可能还没启动完成,版本查询失败要先重试,不能立即判定故障;
  • 重试上限:升级失败不能让系统陷入“升级 → 复位 → 再升级”的死循环,失败到一定次数后应停在维护态并上报;
  • 版本身份:对账不要只比版本号,至少比较 version + build ID/hash + 板型/硬件版本;
  • pending 状态:如果新镜像已经写入 Flash 但还没复位,它不算当前运行版本;对账时要看 pending/active 标志,或者等复位后再对账;
  • loader 模式:节点停在 loader 时也要能响应查询,并明确上报“app 无效”。这种节点无论版本号是多少都必须升级;
  • 降级策略:如果节点版本比大包新,是降级还是保留,需要提前定义。如果要求版本组合严格符合大包,就是允许降级;如果有防回滚,策略要一致。

  如果 C 不响应,A 也拿不到 D 的版本。这时不能假设 D 正常,应该先恢复 C,再查询/升级 D。也就是故障恢复时按“头 → 尾”的顺序处理,避免中间节点卡住导致下游全部不可达。

6 Loader 的职责与恢复链

  这个架构的恢复能力,核心不在 app,而在 loader。A/C 的 loader 至少要承担以下职责:

  • 从外挂 Flash 读取并校验自己的 app,loader 区保持固定;
  • 把 B/D 的 loader/app 镜像从外挂 Flash 加载到 B/D,或者把镜像服务给 B/D 的 loader;
  • 在自己的 app 无效时,仍然能够通过通讯链路接收恢复镜像;
  • 写入小包后校验长度和 hash,校验通过后才允许装载。

  最重要的要求是:host app 坏了,不能把卫星节点一起带下去。

  • A 的 app 坏了,A 的 loader 仍然要能把 B 拉起来;
  • C 的 app 坏了,C 的 loader 仍然要能把 D 拉起来。

  如果 B/D 有自己的片内 loader,A/C 的 loader 可以只负责把镜像“服务”给 B/D;如果 B/D 没有 loader,A/C 的 loader 就必须直接加载 B/D。两种情况下,A/C 的 loader 都要知道 B/D 的 loader/app 镜像在 Flash 中的存储位置和装载格式。

  几个典型故障场景:

故障 影响 恢复路径
A 的 app 损坏 A 停在 loader;B 仍可由 A 的 loader 加载;C/D 独立运行 外部通过对外通讯链路给 A 的 loader 重发 A/B 镜像
A 的外挂 Flash 损坏 A/B 的 loader 和 app 镜像丢失;C/D 不受影响 需要工厂或专用恢复流程先恢复 A/B 的 loader,再通过外部下发 app;B 等 A 恢复后加载
C 的 app 损坏 C 停在 loader;D 仍可由 C 的 loader 加载;A/B 独立运行 A 通过内部链路给 C 的 loader 重发 C/D 的 app 镜像
C 的外挂 Flash 损坏 C/D 的 loader 和 app 镜像丢失;A/B 不受影响 A 不保存 C/D,需要工厂或专用恢复流程恢复 loader,再下发 app
B 或 D 无响应 该节点未运行 A/C 重新加载对应镜像;如果存储在 Flash 中已有效,直接重载

  这里有一个存储上的取舍:A 只存 A/B 的 loader 和 app,C 只存 C/D 的 loader 和 app。这样存储压力分散,但 C 的外挂 Flash 整体损坏时,A 无法独立恢复 C/D 的 loader,需要工厂或专用恢复流程。如果系统要求 A 能从自己的存储恢复整板,A 就必须额外保存 C/D 的 loader 和 app 镜像;如果接受“工厂恢复 loader + 外部下发 app”,就可以保持当前分配。

  B、D 没有外挂 Flash,A/C 的外挂 Flash 是它们 loader 和 app 镜像的唯一副本。如果只是 app 副本写坏,loader 仍在,B/D 等 A/C 重新收到 app 后就能起来;如果 loader 区也坏了,就需要工厂恢复。无论哪种情况,只要 loader 还在,普通升级都不会把节点变成砖。

7 复位与启动

  所有镜像写入并校验通过后,A 协调整板复位。复位顺序可以按“先子主机、后主主机”:

  1. A 通知 C 复位,C 的 loader 加载新的 C/D app;
  2. C 启动完成后通知 A;
  3. A 复位,A 的 loader 加载新的 A/B app;
  4. B/D 分别由 A/C 加载;
  5. A 再次对账,确认四个 MCU 的当前运行版本与各自存储的小包版本一致。

  如果有硬件总复位线,可以直接统一复位,最干净。没有总复位线时,要避免 A 先复位导致 C/D 的升级链路中断;A 最后复位更稳妥。

  如果只有 B 或 D 需要换版本,理论上可以只重载对应节点,不一定要整板复位;但整板复位最简单,也能保证四个 MCU 从同一组镜像启动。是否做局部重载,可以根据业务停机时间决定。

  全部版本匹配时不要复位。上电对账通过后直接进入正常业务,避免每次上电都无谓重启。

8 完整性与安全

  如果系统对固件完整性有要求,可以让每个小包自带 hash,A/C 写入前后校验,loader 装载前再校验一次;如果要求防篡改,签名随小包一起保存。本文的基础方案只依赖小包自身的版本和 hash。

  如果要求防回滚,可以在小包头部带安全版本号,由 loader 或 app 检查版本下限。安全策略要和版本对账策略保持一致,避免 loader 禁止降级而大包要求回退。

9 设计要点总结

  多 MCU 固件升级的核心不是一个单独的数据传输协议,而是一套“存储 + 对账 + 恢复”的系统设计。本文方案的要点可以归纳为:

  1. A 做升级网关,C 做子主机:A 接收外部大包,负责 A/B 的本地升级和 C/D 的转发;C 负责 C/D 的存储和加载。
  2. B、D 不自己收固件:它们没有外挂 Flash,app 镜像由 A/C 写入自己的 Flash,B/D 只在复位后被重新加载;loader 镜像独立保存,不参与 OTA。
  3. loader 必须能兜底:loader 在片内 Flash/ROM 或外挂 Flash 的独立保护区,写保护,不可变;A/C 外挂 Flash 中同时保存 A/B、C/D 的 loader 镜像;app 写坏后停在 loader,仍能通过通讯链路恢复。
  4. app 运行在 RAM:可以在线擦写自己的 Flash;但擦写期间所有代码、常量、中断向量、ISR 都必须在 RAM。
  5. 对账看当前运行版本:拿每个 MCU 当前运行的小包版本和大包中的对应版本比较;版本身份要包含 build ID/hash 和板型。写入但未复位的 pending 镜像不算当前运行版本。
  6. 只处理差异节点:全部匹配不复位;只有当前运行版本不一致的节点才传输、擦写并复位;避免无意义的传输和擦写。
  7. 写入后校验:每个 app 小包写完后校验长度和 hash;写坏时 loader 停在 loader,等待重新发送该 app 小包。
  8. host loader 要能加载卫星:A 的 app 坏了,A 的 loader 仍要能拉起 B;C 的 app 坏了,C 的 loader 仍要能拉起 D。
  9. 故障恢复按头到尾:C 不通时先恢复 C,再查/升 D;B 不通时由 A 重新加载。
  10. 重试要有上限:升级失败不能无限复位循环,超过上限停在维护态并上报。

  最终,系统追求的不是“四个 MCU 在同一毫秒完成切换”,而是“任何时刻都能通过 loader 和存储中的镜像,把整板收敛回存储中的小包版本组合”。只要这条恢复链完整,单次升级过程中掉电、链路中断、某个节点写坏,都不会让整板失去升级能力。