3、双分区架构设计:A/B分区原理、分区表设计、启动引导器配置
好,咱们进入双分区架构设计。这是整个OTA回滚机制的基石,说白了就是给系统准备两套“副本”。你想想看,如果只有一个系统分区,升级写到一半断电了,那设备就变砖了。双分区就是为了解决这个“升级中途挂了怎么办”的问题。
3.1 A/B分区原理
A/B分区,也叫无缝升级(Seamless Update)。核心思想很简单:系统有两个完全独立的系统分区,一个叫slot A,一个叫slot B。当前运行的是A,那升级就写到B。升级完成后,重启时引导加载器(Bootloader)检查B是否可用,如果可用就切到B启动。如果B启动失败,自动回滚到A。
我在项目中遇到过最典型的场景:某款车机在升级过程中,用户突然拔掉电源。如果是单分区方案,这设备基本就废了。但用了A/B分区,升级包写到B分区,A分区纹丝不动。重启时发现B分区校验失败,Bootloader直接切回A,用户甚至没感觉到升级过。
关键点:升级过程中,当前运行的系统完全不参与写操作。你想想看,这从根本上杜绝了“把自己写死”的可能性。
A/B分区的核心流程是这样的:
- 系统在A分区正常运行
- OTA下载升级包到B分区(包括系统、内核、dtb等)
- 升级完成后,设置B分区为“可启动”状态
- 重启,Bootloader读取启动状态,尝试从B启动
- 如果B启动成功,标记B为“成功”,下次升级就写A
- 如果B启动失败,Bootloader回退到A,并标记B为“失败”
嗯,这里要注意:A/B分区不仅仅是两个系统分区,还包括对应的数据分区。我个人习惯把userdata也做成A/B,但有些场景下userdata可以共享,这取决于你的业务需求。
3.2 分区表设计
分区表设计是双分区架构的“骨架”。我见过不少团队,原理都懂,但分区表设计得一塌糊涂,导致后面启动引导器配置时各种踩坑。
一个典型的A/B分区表长这样:
Partition Table (GPT):
- boot_a : 64MB (内核+dtb+ramdisk)
- boot_b : 64MB
- system_a : 1.5GB (系统文件)
- system_b : 1.5GB
- vendor_a : 256MB (硬件相关驱动)
- vendor_b : 256MB
- userdata : 剩余空间 (共享分区)
- misc : 16MB (启动控制信息)
- metadata : 8MB (分区元数据)
为什么这么设计?我解释一下:
- boot分区单独拆分:内核和dtb经常需要单独更新,和system分开更灵活。我在一个项目中就吃过亏,把内核塞在system里,结果每次改内核都要刷整个system,浪费时间。
- vendor单独分区:硬件厂商的驱动和系统解耦,方便OEM单独更新。说白了,高通或MTK的驱动更新,不需要等系统一起发版。
- misc分区:这是“大脑”。里面存着当前哪个slot是active、哪个是unbootable、升级次数等关键信息。Bootloader启动时第一件事就是读misc分区。
- userdata共享:用户数据不分区,两个系统共用。这样用户升级后,应用、设置、账号信息都在,体验好。
警告:分区大小一定要留余量。我曾经遇到一个项目,system分区只留了1GB,结果某次系统更新多了几个预装应用,直接写不下了。升级失败后回滚,用户数据还丢了。建议至少留20%的余量。
分区表设计时,还有几个细节:
- 分区起始地址要对齐(通常4KB对齐),否则性能会受影响
- boot分区建议放在最前面,因为Bootloader读取时寻址快
- metadata分区要放在安全区域,最好有ECC保护
3.3 启动引导器配置
启动引导器(Bootloader)是回滚机制的“裁判”。它负责判断:该从哪个分区启动?上次启动成功了吗?要不要回滚?
我个人习惯用U-Boot,因为它开源、灵活,而且社区支持好。配置U-Boot支持A/B分区,核心是修改环境变量和启动逻辑。
先看环境变量配置:
# U-Boot环境变量示例
boot_slot_active=a
boot_slot_successful=1
boot_slot_unbootable=0
# 启动命令
if test "${boot_slot_active}" = "a"; then
setenv bootargs "root=/dev/mmcblk0p5 slot=a"
load mmc 0:1 ${kernel_addr_r} boot_a/uImage
load mmc 0:1 ${fdt_addr_r} boot_a/dtb
else
setenv bootargs "root=/dev/mmcblk0p6 slot=b"
load mmc 0:1 ${kernel_addr_r} boot_b/uImage
load mmc 0:1 ${fdt_addr_r} boot_b/dtb
fi
bootm ${kernel_addr_r} - ${fdt_addr_r}
嗯,这里要注意:环境变量中的boot_slot_successful很关键。系统启动后,init进程会检查这个标志。如果当前slot是第一次启动,init会等待一段时间(比如30秒),确认系统正常运行后,才把boot_slot_successful设为1。如果在这30秒内系统崩溃或用户强制重启,Bootloader就会认为启动失败,触发回滚。
提示:我建议在init中加一个“启动成功确认”服务。这个服务在系统完全就绪后(比如网络可用、关键服务启动完成),才写入成功标志。别在kernel刚启动时就写,那太早了,万一后面挂了呢?
Bootloader的回滚逻辑,我总结成伪代码:
bootloader_main() {
slot = read_misc_partition().active_slot;
if (slot_is_unbootable(slot)) {
// 当前slot不可用,强制回滚
slot = switch_to_other_slot(slot);
update_misc_partition(slot, UNBOOTABLE_CLEAR);
}
if (slot_boot_count(slot) > MAX_RETRY_COUNT) {
// 启动次数超限,标记为失败
mark_slot_unbootable(slot);
slot = switch_to_other_slot(slot);
}
try_boot_from_slot(slot);
if (boot_failed) {
mark_slot_unbootable(slot);
slot = switch_to_other_slot(slot);
try_boot_from_slot(slot);
}
}
这里有个坑:MAX_RETRY_COUNT设多少合适?我建议设3次。太少了,比如1次,可能因为偶然的硬件抖动就回滚了;太多了,比如10次,用户可能等得不耐烦。3次是个平衡点。
另外,Bootloader还要处理“升级中断”的情况。比如升级写到一半断电了,B分区的数据是不完整的。Bootloader怎么知道?靠校验和。我建议在升级完成后,立即计算整个分区的哈希值(比如SHA256),存到misc分区。Bootloader启动前先校验哈希,不一致就直接回滚。
核心原则:Bootloader要“悲观”。它默认任何分区都可能坏,所以每次启动都要校验。宁可多花几毫秒校验,也不要让设备变砖。
最后,说一个我踩过的坑:双分区设计时,别忘了考虑“升级失败后的日志保留”。我曾经有个项目,升级失败回滚后,开发人员完全不知道失败原因。后来我在misc分区里加了一个“升级日志”区域,每次升级操作都写日志。回滚后,系统可以读取这个日志,上报给服务器。这个设计后来救了我们好几次。
好了,双分区架构设计就讲到这里。下一章我们聊聊升级包的生成和签名,那是保证升级安全的关键一环。