4. 双分区架构设计:A/B分区原理、双分区切换逻辑、分区表设计

双分区架构,圈内人常叫它 A/B 系统。说白了,就是设备里准备两套完整的系统固件,一套在跑,另一套闲着。我最早接触这个设计是在做车机 OTA 的时候,客户要求升级失败不能变砖,还得能回滚。嗯,A/B 分区就是干这个的。

4.1 A/B 分区原理

传统单分区升级,就像在独木桥上换轮胎。升级过程中一旦断电或写错,系统就起不来了。A/B 分区不一样,它把存储分成两个完全独立的槽位(Slot)。

  • Slot A(当前槽):正在运行的固件,包含 boot、system、vendor 等分区。
  • Slot B(备用槽):存放新固件或旧固件的备份,平时不参与启动。

升级时,系统在后台把新固件写入 Slot B。写完后,通过 Bootloader 切换启动槽位。下次重启,设备就从 Slot B 启动了。如果 Slot B 启动失败,Bootloader 自动回退到 Slot A。

核心思想:升级过程不影响当前运行的系统。用户甚至感觉不到设备在升级。

我在项目中遇到过一个问题:某款 IoT 网关,Flash 只有 256MB。A/B 分区要存两套系统,空间捉襟见肘。后来我们裁剪了内核模块,把不必要的驱动全去掉,才勉强塞下。所以,选型时一定要算好 Flash 容量。

4.2 双分区切换逻辑

切换逻辑是 Bootloader 的活。我习惯把切换逻辑分成三个步骤:标记、尝试、确认。

  1. 标记新槽位:升级完成后,系统在 misc 分区写入一个标志,告诉 Bootloader 下次启动用 Slot B。
  2. 尝试启动:Bootloader 读取标志,从 Slot B 加载内核。如果内核启动成功,用户空间的服务会发一个「健康确认」信号。
  3. 确认切换:健康服务收到信号后,把 misc 分区里的「尝试计数」清零,正式确认切换。如果启动失败,Bootloader 把计数加 1,超过最大尝试次数(比如 3 次),就回退到 Slot A。

避坑指南:我曾经把「尝试计数」放在文件系统里,结果文件系统挂载失败,计数读不出来,Bootloader 直接回滚了。后来我改成放在独立的 misc 分区,用裸读写,再也没出过问题。

为什么会设计「尝试计数」?因为有些故障不是立即暴露的。比如 Wi-Fi 驱动加载后,要等几秒才能连上网络。如果连不上,用户可能觉得设备坏了。所以,我一般给 3 次尝试机会,每次重启都重新尝试。

下面这张图展示了 A/B 分区的切换流程:

A/B 分区切换流程图 系统运行在 Slot A 后台下载新固件 写入 Slot B 标记 Slot B 为启动槽 重启,Bootloader 读取标志 启动成功? 确认切换,Slot B 运行 回退到 Slot A 尝试计数 < 3 次 超过则回退

4.3 分区表设计

分区表是 A/B 架构的骨架。设计不好,后面全是坑。我一般把分区表分成三类:核心系统分区、数据分区、元数据分区。

分区名称 大小(典型值) 作用 是否 A/B
boot_a / boot_b 32MB 存放内核和 dtb 是
system_a / system_b 256MB - 1GB 根文件系统 是
vendor_a / vendor_b 64MB 硬件驱动和固件 是
misc 1MB 存储启动标志、尝试计数 否
data 剩余空间 用户数据、应用配置 否
cache 64MB 升级包缓存、日志 否

注意:misc 分区绝对不能放在 A/B 槽里。如果 misc 也做双备份,Bootloader 就不知道该从哪个槽启动了。这是个死循环。

分区表在代码里怎么定义?以 U-Boot 为例,我习惯用 CONFIG_PARTITION_UUIDS 配合 GPT 分区表。下面是一个简化版的分区表定义:

// 以 512 字节扇区为单位
// 假设 Flash 总大小 4GB

partitions {
    compatible = "fixed-partitions";
    #address-cells = <2>;
    #size-cells = <2>;

    // misc 分区,固定偏移
    partition@0 {
        label = "misc";
        reg = <0x0 0x200000>;  // 2MB
    };

    // Slot A
    partition@200000 {
        label = "boot_a";
        reg = <0x0 0x2000000>;  // 32MB
    };
    partition@2200000 {
        label = "system_a";
        reg = <0x0 0x20000000>; // 512MB
    };

    // Slot B
    partition@22200000 {
        label = "boot_b";
        reg = <0x0 0x2000000>;  // 32MB
    };
    partition@24200000 {
        label = "system_b";
        reg = <0x0 0x20000000>; // 512MB
    };

    // 数据分区
    partition@44200000 {
        label = "data";
        reg = <0x0 0xFFFFFFFF>; // 剩余空间
    };
};

你想想看,如果分区大小没对齐 Flash 的擦除块(比如 NAND 的 128KB 块),写分区时就会跨块,性能下降不说,还可能写坏。我建议分区起始地址和大小都按擦除块对齐。

个人经验:我习惯在分区表里预留 1-2 个空分区,叫 reserved_a 和 reserved_b。万一后期要加新功能(比如安全固件分区),不用重新烧写整个分区表。

最后说一句,分区表设计不是一次性的。产品迭代过程中,分区大小可能会变。我见过一个团队,升级包写死了分区偏移,结果新版本固件大了 10MB,直接覆盖了后面的 data 分区。嗯,数据全丢了。所以,分区表最好用 GPT,支持动态调整,别用古老的 MBR。