双分区(A/B)系统架构:原理、切换逻辑与回退机制
聊到OTA升级,就绕不开A/B分区。我入行那会儿,很多设备还是单分区升级,一旦升级过程中断电或者网络断了,设备就变砖了。后来接触A/B分区,才觉得这玩意儿真是为工业级可靠性设计的。
说白了,A/B分区就是给系统准备了两套完整的运行环境。一套在跑,另一套闲着。升级的时候,往闲的那套写新固件,写完了再切过去。整个过程不影响当前业务,就算升级失败,也能立刻回退。
A/B分区的核心原理
先看一张整体架构图,我习惯用这张图给团队新人讲清楚A/B分区的布局。
你看这张图,A和B两套分区是完全对称的。每个分区都包含boot、system、vendor等子分区,还有独立的用户数据区。我见过不少新手问:为什么用户数据也要分两份?其实是为了保证切换后应用层也能无缝衔接。
核心要点:A/B分区的本质是「空间换时间」——用双倍存储空间,换取零停机升级和即时回退能力。
切换逻辑:Bootloader如何决定启动哪个分区
切换逻辑的核心在Bootloader里。设备上电后,Bootloader会读取一个叫「misc分区」或者「BCB(Bootloader Control Block)」的区域,里面存着当前应该启动哪个槽位。
我参与过一个车机项目,当时用的就是这种机制。Bootloader的决策流程大致如下:
- 读取槽位状态:检查A/B分区的「健康标记」和「重试计数」。
- 判断优先级:如果当前槽位标记为成功,直接启动;如果标记为失败,切换到另一个槽位。
- 更新计数:每次启动尝试,重试计数减1。减到0还没成功,就标记为失败。
- 提交确认:系统正常启动后,用户空间程序会向Bootloader发送「提交成功」信号,锁定当前槽位。
这里有个细节,我当年踩过坑——提交确认的时机。如果系统刚启动就提交,万一后面几分钟内崩溃了,就没机会回退了。所以我建议等关键服务都启动完毕,再执行提交操作。
我的经验:提交确认最好延迟到系统启动后30秒到1分钟,等网络、显示、核心服务都稳定了再提交。这样能避免「假启动成功」导致的回退失效。
回退机制:升级失败后的保命手段
回退机制是A/B分区最大的价值所在。升级过程中,如果出现以下情况,系统会自动触发回退:
- 固件校验失败(哈希值不匹配)
- 升级过程中断电或通信中断
- 新系统启动后关键服务崩溃
- 用户手动触发回退(比如通过Recovery模式)
回退的流程其实很简单:Bootloader检测到当前槽位启动失败,直接切到另一个槽位。因为另一个槽位还是旧版本,所以设备能立刻恢复到升级前的状态。
我曾经处理过一个案例:某款智能音箱在升级过程中网络闪断,导致system_b分区只写了一半。设备重启后,Bootloader发现system_b的哈希校验失败,自动切回system_a。用户完全没感觉到升级失败,只是第二天发现版本号没变。
注意:回退机制不是万能的。如果两个分区都损坏了(比如Flash物理坏块),设备还是会变砖。所以A/B分区通常配合「恢复模式」和「工厂镜像」一起使用,形成三重保险。
槽位切换的完整状态机
为了让你更清楚地理解切换过程,我整理了一张状态表。这张表在我做过的几个项目里都直接拿来当设计文档用。
| 状态 | 描述 | 触发条件 | 下一步动作 |
|---|---|---|---|
| Active | 当前正在运行的槽位 | 设备正常启动 | 等待升级指令 |
| UpdatePending | 另一个槽位已写入新固件,等待切换 | 升级包下载并校验完成 | 重启后尝试启动新槽位 |
| Try | 新槽位正在试运行 | Bootloader切换到新槽位 | 等待提交或超时回退 |
| Successful | 新槽位被确认为稳定版本 | 用户空间提交成功信号 | 锁定槽位,下次继续启动 |
| Failed | 新槽位启动失败 | 重试计数归零或校验失败 | 自动切回旧槽位 |
嗯,这里要注意「Try」状态。这个状态是回退机制的关键窗口期。在Try状态下,如果设备崩溃或者用户强制重启,Bootloader会认为新版本不稳定,自动切回旧槽位。只有收到「Successful」信号后,新版本才算正式转正。
实际项目中的切换代码示例
下面这段代码是我在一个Linux嵌入式项目中实际用过的槽位切换逻辑,简化后分享给你。它展示了Bootloader如何读取槽位信息并做出决策。
/* 槽位控制块结构体 */
struct slot_control {
uint8_t slot_a_retry; /* A槽位剩余重试次数 */
uint8_t slot_b_retry; /* B槽位剩余重试次数 */
uint8_t active_slot; /* 当前活动槽位:0=A, 1=B */
uint8_t slot_a_successful; /* A槽位是否已提交成功 */
uint8_t slot_b_successful; /* B槽位是否已提交成功 */
};
/* Bootloader启动决策函数 */
int select_boot_slot(struct slot_control *ctrl) {
/* 1. 先检查当前活动槽位是否有效 */
if (ctrl->active_slot == 0) {
if (ctrl->slot_a_successful) {
return 0; /* A槽位已确认,直接启动 */
}
if (ctrl->slot_a_retry > 0) {
ctrl->slot_a_retry--;
return 0; /* A槽位还有重试机会 */
}
/* A槽位重试用完,标记失败,切到B */
ctrl->active_slot = 1;
return 1;
} else {
/* B槽位逻辑与A对称 */
if (ctrl->slot_b_successful) {
return 1;
}
if (ctrl->slot_b_retry > 0) {
ctrl->slot_b_retry--;
return 1;
}
ctrl->active_slot = 0;
return 0;
}
}
这段代码看着简单,但实际项目中要考虑的东西更多。比如重试次数设多少合适?我一般设3次。太少了容易误判,太多了会反复尝试损坏的固件,浪费启动时间。
A/B分区的优缺点对比
任何架构都有取舍。我整理了一个对比表,方便你评估是否适合你的项目。
| 维度 | 优势 | 劣势 |
|---|---|---|
| 可靠性 | 升级失败可即时回退,几乎零风险 | 双分区同时损坏时仍可能变砖 |
| 用户体验 | 升级过程无感,不中断当前业务 | 升级后首次启动需要切换,略有延迟 |
| 存储开销 | — | 需要双倍存储空间(对低端设备不友好) |
| 实现复杂度 | 逻辑清晰,Bootloader改动较小 | 需要维护两套分区表,OTA包生成稍复杂 |
| 适用场景 | 车机、医疗设备、工业控制器等 | IoT传感器、内存小于128MB的设备 |
我个人觉得,如果你的设备存储空间够用(至少256MB以上),A/B分区是首选方案。它带来的可靠性提升,远超过那点存储成本。
避坑指南:我踩过的几个坑
最后分享几个实战中容易忽略的问题,都是我曾经的血泪教训。
- 分区大小不一致:A和B分区必须完全一样大。我见过有人为了省空间,把B分区做小了,结果新固件写不进去。记住,对称是铁律。
- 忽略data分区:有些方案只给system做双分区,data不分。结果切换后应用数据全乱了。我建议data也做双分区,或者至少做好迁移逻辑。
- 重试计数不持久化:重试计数必须写在非易失存储里。如果只存在内存里,掉电后就失效了,回退机制形同虚设。
- 提交确认太早:前面提过,等系统完全稳定了再提交。我一般等网络连接成功、核心服务注册完毕后再发提交信号。