双备份区架构:A/B分区设计原理

双备份区,圈里人常叫它 A/B 分区。说白了,就是给固件准备两个“窝”——一个跑着,一个闲着。万一跑着的那个出事了,Bootloader 立马把闲着的拉上来顶班。

我最早接触这个设计,是在做一款工业网关的时候。OTA 升级到一半,突然断电了。重启后设备变砖,客户差点把售后电话打爆。从那以后,A/B 分区就成了我项目里的标配。

为什么需要两个分区?

你想想看,单分区架构下,升级就是“擦除→写入”。这个过程中一旦掉电、复位、或者传输数据出错,固件就残了。设备连启动都做不到,只能进厂返修。

A/B 分区解决了这个痛点:

  • 原子性切换——要么完整升级,要么不动当前系统
  • 即时回滚——新固件跑不起来,自动切回旧版本
  • 零停机升级——后台下载到 B 区,不影响 A 区运行

核心原则:任何时候,至少有一个分区是可启动的完整固件。

分区表布局

分区表是 Bootloader 的“地图”。它告诉芯片:固件放哪、多大、哪个是当前激活的。

我习惯把 Flash 规划成下面这样:

区域 起始地址 大小 说明
Bootloader 0x08000000 64 KB 启动加载器,不可被应用固件覆盖
分区表 0x08010000 4 KB 记录 A/B 状态、版本号、CRC
Slot A 0x08011000 512 KB 主固件区 A
Slot B 0x08091000 512 KB 备份固件区 B
配置区 0x08111000 16 KB 用户参数、校准数据

我的习惯:分区表里额外存一个 32 位的 CRC 校验值。每次启动时 Bootloader 先读分区表,校验通过才去加载固件。这能防止分区表本身被写坏。

Bootloader 的角色

Bootloader 是整个回滚机制的“裁判”。它不参与业务逻辑,只做三件事:

  1. 校验——检查 A/B 分区的完整性(CRC、签名)
  2. 决策——根据分区表状态,决定启动哪个分区
  3. 切换——更新分区表,标记下次启动的目标

我见过不少团队把 Bootloader 写得很重,塞了驱动、协议栈、甚至文件系统。我个人建议:Bootloader 越轻越好。它只负责启动,其他事交给应用固件去做。

曾经踩过的坑:有一次我在 Bootloader 里加了 USB 升级功能,结果 USB 驱动本身有 bug,导致设备连 Bootloader 都进不去。从那以后,我的 Bootloader 只支持最简单的 UART 或 SPI 升级,复杂协议一律放应用层。

A/B 切换流程

下面这张图展示了完整的切换逻辑:

A/B 分区切换流程图 上电复位 Bootloader 启动 读取分区表 A 区有效? 启动 Slot A 启动 Slot B B 也无效 → 紧急模式 紧急模式:等待串口/网络恢复固件

分区状态管理

分区表里除了地址和大小,还要记录状态。我常用的状态机是这样的:

状态 值 含义
INVALID 0x00 分区内容无效,不可启动
VALID 0x01 固件完整,可正常启动
UPDATING 0x02 正在写入新固件,此时不可断电
COMMITTED 0x03 新固件已确认运行正常,锁定为当前版本

避坑指南:我曾经把状态值定义成 0x00~0x03,结果 Flash 擦除后默认是 0xFF,导致 Bootloader 误判为 INVALID。后来我改成:VALID=0xA5, INVALID=0x00,擦除后的 0xFF 直接视为无效,安全多了。

回滚触发条件

什么时候触发回滚?我总结了三种场景:

  • 启动超时——应用固件启动后,5 秒内没有向 Bootloader 发送“心跳”确认
  • 看门狗复位——连续三次看门狗复位,说明新固件不稳定
  • 用户强制回滚——通过外部按键或串口命令,主动切回旧版本

嗯,这里要注意:不要一复位就回滚。有时候只是电源波动导致的偶然复位。我一般设置一个“失败计数器”,连续复位超过 3 次才触发回滚。

代码示例:分区表结构体

下面是我常用的分区表定义,C 语言实现:

typedef struct {
    uint32_t magic;          // 魔数,用于识别分区表有效性
    uint32_t version;        // 分区表版本
    uint32_t slot_a_addr;    // Slot A 起始地址
    uint32_t slot_a_size;    // Slot A 大小
    uint32_t slot_a_crc;     // Slot A 固件 CRC32
    uint8_t  slot_a_status;  // Slot A 状态
    uint8_t  slot_b_status;  // Slot B 状态
    uint16_t boot_count;     // 当前分区启动失败计数
    uint32_t reserved[4];    // 保留字段
} partition_table_t;

关键点:magic 字段我固定为 0xABABABAB。Bootloader 启动时先检查 magic,不对就直接进紧急模式。这能防止 Flash 全空或全 0xFF 时误判。

实际项目中的考量

A/B 分区不是银弹。它需要双倍 Flash 空间,对成本敏感的 MCU 来说是个负担。我做过一个项目,Flash 只有 256 KB,硬塞 A/B 分区后,应用代码只剩 80 KB 可用空间。最后只能退而求其次,用单分区 + 外部备份 Flash 的方案。

另外,分区表的位置要固定。我习惯把它放在 Bootloader 之后、第一个分区之前。这样 Bootloader 升级时不会覆盖分区表,应用固件升级时也不会误写。

曾经踩过的坑:有一次我把分区表放在 Flash 末尾,结果应用固件升级时写越界,把分区表覆盖了。重启后 Bootloader 找不到有效分区,设备彻底变砖。后来我强制规定:分区表必须放在 Bootloader 后面,且应用固件写入时做边界检查。