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分区的核心流程是这样的:

  1. 系统在A分区正常运行
  2. OTA下载升级包到B分区(包括系统、内核、dtb等)
  3. 升级完成后,设置B分区为“可启动”状态
  4. 重启,Bootloader读取启动状态,尝试从B启动
  5. 如果B启动成功,标记B为“成功”,下次升级就写A
  6. 如果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分区里加了一个“升级日志”区域,每次升级操作都写日志。回滚后,系统可以读取这个日志,上报给服务器。这个设计后来救了我们好几次。

好了,双分区架构设计就讲到这里。下一章我们聊聊升级包的生成和签名,那是保证升级安全的关键一环。