3、双备份架构(A/B系统):原理、实现方式、切换逻辑、优缺点分析
双备份架构,圈里人更喜欢叫它 A/B 系统。说白了,就是在设备里准备两套完整的固件副本——一个在干活(Active),一个在待命(Standby)。
我最早接触这个方案是在做智能电表项目的时候。那会儿客户要求固件升级失败后设备必须能自动恢复,不能派人去现场刷机。你想想看,几万只电表分布在全省各地,要是升级挂了还得挨个跑,那画面太美我不敢看。
A/B 系统的核心思路其实很简单:永远保证有一个可启动的固件存在。不管你怎么折腾,系统里总有一份能跑起来的代码。
3.1 原理:为什么需要两份固件?
传统单分区升级有个致命问题——写入即覆盖。升级过程中一旦断电、通信中断或者固件校验失败,原来的固件就没了,新的固件又不完整。设备直接变砖。
A/B 系统把存储区一分为二:
- Slot A:当前运行的固件
- Slot B:备份/待升级的固件
升级时,Bootloader 把新固件写入 非活动分区。写入完成后做完整性校验,校验通过才切换启动分区。如果校验失败或者新固件启动不了,Bootloader 自动回退到原来的分区。
核心原则:任何时候,至少有一个分区包含可启动的固件。
嗯,这里要注意:A/B 系统不是简单的「存两份」,关键在于 切换逻辑 和 回滚机制。我见过不少团队把两份固件写进去就完事了,结果切换逻辑没写好,该回退的时候回退不了,照样变砖。
3.2 实现方式:存储布局与 Bootloader 设计
A/B 系统的实现主要涉及两个层面:存储分区布局 和 Bootloader 逻辑。
3.2.1 存储分区布局
典型的 Flash 布局长这样:
| 分区名称 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 64 KB | 启动加载、分区选择、回滚逻辑 |
| Slot A | 0x08010000 | 512 KB | 固件副本 A(活动/待命) |
| Slot B | 0x08090000 | 512 KB | 固件副本 B(活动/待命) |
| 参数区 | 0x08110000 | 16 KB | 启动计数、升级状态、校验标志 |
| 用户数据 | 0x08114000 | 剩余空间 | 配置、日志、文件系统 |
个人经验:参数区一定要用独立的 Flash 页,别跟固件分区混在一起。我曾经遇到一个项目,参数区和 Slot B 共用同一块擦除块,升级时参数被连带擦掉,Bootloader 读不到启动标志,直接死循环了。
3.2.2 Bootloader 切换逻辑
Bootloader 的决策流程,我习惯用状态机来描述:
// 伪代码:A/B 系统 Bootloader 切换逻辑
enum BootState {
BOOT_FROM_A,
BOOT_FROM_B,
BOOT_UNKNOWN
};
BootState selectBootPartition() {
// 1. 读取参数区中的启动计数和状态标志
uint8_t bootCount = readParam(BOOT_COUNT);
uint8_t upgradeFlag = readParam(UPGRADE_FLAG);
// 2. 判断是否有待完成的升级
if (upgradeFlag == UPGRADE_PENDING) {
// 尝试从新分区启动
if (validateFirmware(SLOT_B)) {
setParam(BOOT_PARTITION, SLOT_B);
setParam(BOOT_COUNT, 0);
return BOOT_FROM_B;
} else {
// 新固件校验失败,回滚
setParam(UPGRADE_FLAG, UPGRADE_FAILED);
setParam(BOOT_PARTITION, SLOT_A);
return BOOT_FROM_A;
}
}
// 3. 正常启动:读取上次成功启动的分区
uint8_t lastGood = readParam(BOOT_PARTITION);
if (lastGood == SLOT_A) {
return BOOT_FROM_A;
} else {
return BOOT_FROM_B;
}
}
这里有个关键点:启动计数。每次 Bootloader 启动一个新分区,先把计数加 1。如果应用层启动成功,会主动把计数清零。如果计数超过阈值(比如 3 次),Bootloader 就认为这个分区有问题,自动切回另一个分区。
避坑指南:我曾经在某个项目里忘了实现「应用层确认启动成功」这个环节。结果 Bootloader 每次启动都计数,明明固件没问题,计数却一直涨,到阈值后自动回滚了。用户反馈说「设备每隔几天就自己回退版本」,排查了两天才找到原因。
3.3 切换逻辑:什么时候切?怎么切?
A/B 系统的切换逻辑,说白了就三种场景:
- 正常升级切换:新固件写入完成 → 校验通过 → 标记待切换 → 重启 → Bootloader 切到新分区 → 应用层确认启动成功
- 升级失败回滚:新固件写入失败/校验不通过 → Bootloader 保持原分区启动 → 标记升级失败
- 运行时回滚:新固件启动后运行异常(看门狗复位、应用层主动请求)→ Bootloader 检测到启动计数超限 → 切回旧分区
我个人比较推荐 「延迟切换」 策略。什么意思呢?新固件写入完成后,不立即切换,而是先标记为「待验证」。设备重启后从新分区启动,但旧分区保留不动。应用层运行一段时间(比如 5 分钟)后,确认一切正常,再提交「切换确认」。这样做的好处是:
- 如果新固件有运行时 bug,设备会在 5 分钟内触发看门狗复位,Bootloader 自动回滚
- 用户数据不会因为频繁切换而丢失
- 给了应用层一个「反悔」的机会
3.4 优缺点分析
没有银弹。A/B 系统也不是万能的,咱们客观聊聊它的优缺点。
| 维度 | 优点 | 缺点 |
|---|---|---|
| 可靠性 | 升级失败可自动回滚,几乎不会变砖 | 需要额外的 Flash 空间(翻倍) |
| 升级速度 | 升级过程中设备可继续运行,零停机 | 升级完成后需要重启一次 |
| 实现复杂度 | 逻辑清晰,Bootloader 代码量不大 | 需要管理分区表、启动计数、状态标志 |
| 成本 | 无需外部硬件支持,纯软件方案 | Flash 容量需求翻倍,芯片成本上升 |
| 维护性 | 版本回退方便,运维友好 | 固件打包工具需要支持双分区镜像 |
我的建议:如果你的产品 Flash 容量够用(剩余空间 > 固件大小的 2.5 倍),优先上 A/B 系统。它是最成熟、最可靠的升级失败恢复方案。但如果 Flash 很紧张(比如低成本 MCU),那就得考虑其他方案了,比如后面会讲到的恢复模式。
3.5 实际项目中的注意事项
做了这么多年固件,踩过的坑不少。关于 A/B 系统,有几个点我想特别提醒你:
- 参数区要防写坏:Flash 写入有寿命限制,启动计数频繁写入会磨损参数区。我一般用「写一次擦一次」的策略,或者把计数分散到多个地址。
- 校验要覆盖完整:别只校验固件头部的 CRC,要校验整个固件镜像。我见过有人只校验了 256 字节的头部,结果固件主体写坏了,Bootloader 照样放行。
- 考虑跨版本兼容:如果新固件改了分区布局或者数据结构,回滚后旧固件可能不兼容新数据。这时候需要额外的数据迁移逻辑。
- 测试要覆盖异常场景:升级过程中断电、升级完成后立即断电、新固件启动后反复崩溃……这些场景都要在测试中覆盖到。
嗯,A/B 系统就聊到这儿。它不是什么新技术,但确实是最实用的。下次你设计固件升级方案的时候,不妨先问问自己:我的 Flash 够放两份固件吗?如果够,那就别犹豫了。