4、双分区升级策略(A/B分区):A/B分区的工作原理、切换逻辑、回滚机制
双分区升级,圈里人常叫它 A/B 分区,或者无缝升级。说白了,就是设备里准备了两套完整的系统分区——一套在跑,一套等着被升级。
我最早接触这个方案是在做车机项目的时候。客户要求升级不能黑屏,不能中断音乐播放,还得保证万一升级失败能自己恢复。嗯,A/B 分区几乎是唯一的选择。
4.1 核心思想:冗余换安全
传统升级是原地擦写——系统在跑,你把它跑的程序给擦了,再写新的。这就像给飞行的飞机换引擎,风险极高。
A/B 分区的思路完全不同:
- Slot A:当前运行的系统
- Slot B:空闲的备份槽位
升级时,新固件直接写入 Slot B,Slot A 照常运行。写完后重启,引导程序决定从哪个槽启动。如果 Slot B 启动成功,它就变成新的活动槽;如果失败,自动回滚到 Slot A。
关键点:整个升级过程对用户完全透明。用户该干嘛干嘛,升级在后台静默完成。
4.2 工作原理:三阶段流程
我习惯把 A/B 升级拆成三个阶段。每个阶段都有明确的职责和边界。
阶段一:更新写入(Update)
系统在 Slot A 正常运行,升级服务把新固件下载到 Slot B。这个阶段有几个要点:
- 校验先行:下载过程中逐块校验哈希,确保数据完整性
- 断点续传:如果下载中断,下次从断点继续,不用重头来
- 写入保护:写入期间禁止对 Slot B 的意外访问
我在项目中遇到过一个问题:下载完成后没做全量校验,结果有个坏块没发现,重启后系统直接挂了。从那以后,我坚持在写入完成后做一次全分区校验。
阶段二:切换尝试(Switch & Boot)
写入完成后,系统会设置一个 boot_control 标志,告诉引导程序下次启动用 Slot B。然后执行重启。
引导程序(比如 U-Boot 或 ABL)读取这个标志,尝试从 Slot B 启动。这里有一个关键机制——启动计数:
// 伪代码示意
if (slot_b.boot_successful) {
slot_b.active = true;
slot_a.active = false;
} else {
slot_b.tries_remaining--;
if (slot_b.tries_remaining <= 0) {
rollback_to_slot_a();
}
}
一般我会设置 3 次尝试机会。如果连续 3 次启动失败,就判定新系统不可用。
阶段三:提交确认(Commit)
新系统成功启动后,需要显式执行一次 commit 操作,告诉系统:这个槽位是好的,以后就用它了。
注意:如果不做 commit,下次重启可能还会回滚。很多开发者在调试阶段吃过这个亏——明明系统跑得好好的,一重启又回到旧版本了。
4.3 切换逻辑:谁来决定启动哪个槽?
切换逻辑的核心在引导程序里。我画了一张图,把决策流程说清楚。
这个流程里有一个容易被忽略的细节:启动成功的判断标准是什么?
我个人习惯的做法是:
- 引导程序加载内核并传递 boot_slot 参数
- 内核启动后,init 进程检查这个参数
- 关键服务(如网络、存储)启动完成后,调用 update_engine 的 commit 接口
- 如果 30 秒内没有收到 commit,系统自动重启并尝试下一个槽位
4.4 回滚机制:最后的防线
回滚不是简单的「切回去」,它需要处理很多边界情况。我把它分为两种场景。
场景一:主动回滚
新系统虽然启动了,但运行不稳定。比如某个驱动频繁崩溃,或者关键服务反复重启。这时候系统可以主动触发回滚:
// 主动回滚示例
int trigger_rollback() {
// 1. 设置回滚标志
boot_control_set_active_slot(OLD_SLOT);
// 2. 清理新槽位的启动计数
boot_control_reset_tries(NEW_SLOT);
// 3. 重启
sync();
reboot(RB_AUTOBOOT);
return 0;
}
场景二:被动回滚
新系统根本起不来,或者启动到一半就挂了。这时候引导程序会接管:
- 启动计数递减:每次启动失败,计数减 1
- 计数归零触发回滚:引导程序自动切换回旧槽位
- 错误日志记录:在旧槽位的特定分区记录失败原因
经验之谈:启动计数不要设太大。3 次足够了。设多了反而会延长故障恢复时间。我曾经见过一个设备设了 10 次,结果用户等了 10 次重启才回到正常系统,体验极差。
4.5 关键数据结构
A/B 分区的状态信息通常存储在 misc 分区或 boot_control 分区。我常用的数据结构如下:
| 字段 | 大小 | 说明 |
|---|---|---|
| magic | 4 字节 | 校验魔数,用于识别数据结构有效性 |
| active_slot | 1 字节 | 当前活动槽位(0=A,1=B) |
| boot_slot | 1 字节 | 下次启动尝试的槽位 |
| tries_remaining[2] | 2 字节 | 每个槽位的剩余启动尝试次数 |
| successful_boot[2] | 2 字节 | 每个槽位是否已成功启动并 commit |
| crc32 | 4 字节 | 整个结构的校验和 |
这个结构体必须保证原子写入。我一般先写数据,最后写 crc32。读取时先校验 crc32,如果校验失败,使用默认值(从 Slot A 启动)。
4.6 避坑指南
做 A/B 分区这些年,我踩过不少坑。挑几个典型的说说。
坑一:分区大小不一致
我曾经遇到过 Slot A 和 Slot B 大小不一样的情况。新固件刚好比 Slot B 大了一点点,写入时直接越界。从那以后,我强制要求两个槽位必须完全一样大,并且在编译时做大小检查。
坑二:commit 时机太早
有个同事在 init 进程刚启动就调了 commit。结果后面有个关键服务挂了,系统已经回不去了。我建议的 commit 时机是:所有核心业务都确认正常后再提交。对于车机来说,至少要等到仪表盘显示正常、网络连接建立、CAN 总线通信恢复。
坑三:忽略用户数据兼容性
新系统可能改了数据库结构或配置文件格式。如果回滚到旧系统,旧系统可能读不了新格式的数据。我的做法是:在升级前备份用户数据,回滚时一并恢复。或者采用向前兼容的数据格式。
4.7 适用场景与局限
A/B 分区不是银弹。它适合对可用性要求高的场景,但代价也很明显。
| 优势 | 代价 |
|---|---|
| 升级过程不中断业务 | 存储空间翻倍 |
| 升级失败自动恢复 | 引导程序复杂度增加 |
| 支持后台静默升级 | 需要额外的分区管理逻辑 |
| 用户无感知 | 调试和测试成本更高 |
如果你的设备存储空间紧张,或者对成本极度敏感,A/B 分区可能不是最佳选择。但对于车机、医疗设备、工业控制器这类「不能停」的设备,它几乎是标配。
嗯,A/B 分区的内容就这些。核心思路其实很简单——用空间换时间,用冗余换安全。但真正落地的时候,细节决定成败。希望这些经验能帮你少走弯路。