3、分区表结构设计:A分区与B分区的布局、分区标识位、分区状态机

好,咱们接着聊。上一章我们把AB分区的整体思路理清了,这一章得落地了。

分区表结构怎么设计?说白了,就是回答三个问题:

  • A区和B区在存储上怎么摆?
  • 系统怎么知道当前该启动哪个区?
  • 分区在不同阶段处于什么状态?

这三个问题搞定了,你的AB分区方案就有了骨架。我个人习惯,在设计阶段就把这些细节敲死,不然后面改起来很痛苦。

3.1 布局:A区与B区的物理与逻辑划分

先看布局。嵌入式设备里,Flash存储空间是有限的。你得把总容量切成两块,一块给A,一块给B。

我见过不少新手,上来就把A区和B区画得一样大,结果发现B区根本用不上那么大空间,白白浪费了。你想想看,如果A区是主运行区,B区只是用来做升级回滚的临时落脚点,那B区其实可以比A区小一点。

我的建议: A区分配总容量的60%,B区分配40%。前提是B区能装下最小可启动系统。如果升级包压缩率高,这个比例很划算。

布局上,通常有两种方式:

布局方式 描述 适用场景
固定偏移布局 A区和B区在Flash中有固定的起始地址和大小。比如A区从0x00000开始,B区从0x80000开始。 分区大小固定,升级包大小可控的场景。简单可靠,我最早的项目就用这个。
动态偏移布局 分区起始地址和大小记录在分区表中,可以动态调整。 需要灵活调整分区大小的场景,比如OTA升级包越来越大,需要动态扩容。

我个人更推荐固定偏移布局。为什么?因为简单。嵌入式系统最怕复杂,越简单越不容易出错。动态偏移虽然灵活,但一旦分区表被写坏,整个系统就废了。

来看一个典型的布局示例:

// Flash 布局示例(假设总容量 2MB)
// 地址范围:0x000000 - 0x1FFFFF

// Bootloader 区域
#define BOOTLOADER_START   0x000000
#define BOOTLOADER_SIZE    0x010000  // 64KB

// A 分区
#define PARTITION_A_START  0x010000
#define PARTITION_A_SIZE   0x0F0000  // 960KB (约60%)

// B 分区
#define PARTITION_B_START  0x100000
#define PARTITION_B_SIZE   0x0A0000  // 640KB (约40%)

// 分区表区域(存放标识位和状态)
#define PARTITION_TABLE_START 0x1A0000
#define PARTITION_TABLE_SIZE  0x010000  // 64KB

嗯,这里要注意:分区表区域一定要单独划出来,别跟A区或B区混在一起。我曾经见过一个项目,把分区标识位放在A区的末尾,结果A区升级失败,标识位也跟着坏了,系统彻底变砖。从那以后,我坚持把分区表放在一个独立的、受保护的区域。

3.2 分区标识位:系统如何知道该启动哪个区?

布局定好了,接下来是关键:系统怎么知道当前该启动A区还是B区?

答案就是——分区标识位。

说白了,就是一个标志变量,存放在非易失性存储里(比如Flash的特定扇区)。系统启动时,Bootloader先读这个标识位,然后决定加载哪个分区。

标识位通常包含以下信息:

  • 当前活动分区:0表示A区,1表示B区。
  • 待升级分区:升级时,标记哪个分区即将被写入。
  • 回滚计数:记录回滚次数,防止无限回滚。
  • 校验值:对整个标识位区域做CRC校验,防止数据损坏。

核心原则: 标识位的写入必须是原子操作。如果写入过程中掉电,要么保持旧值,要么进入安全状态。绝对不能出现半写状态。

我常用的标识位结构体长这样:

typedef struct {
    uint32_t magic;          // 魔数,用于验证标识位有效性
    uint8_t  active_slot;    // 0: A区, 1: B区
    uint8_t  pending_slot;   // 待升级分区
    uint8_t  rollback_count; // 回滚计数
    uint8_t  reserved;       // 保留
    uint32_t crc32;          // 校验值
} partition_table_t;

这里有个坑:魔数一定要选好。我习惯用 0xABABABAB,既好记,又不容易跟其他数据冲突。如果读到的魔数不对,说明标识位区域可能被损坏了,这时候Bootloader应该进入恢复模式,而不是盲目启动。

我曾经踩过的坑: 有一次,我把标识位放在Flash的同一个扇区里,每次升级都要擦除整个扇区。结果擦除过程中掉电,标识位全没了。后来我改成双备份——在Flash里存两份标识位,一份主用,一份备用。启动时先读主用,如果校验失败,再读备用。这个方案救了我好几次。

3.3 分区状态机:从升级到回滚的完整生命周期

有了布局和标识位,还得有状态机来管理分区的生命周期。不然,系统怎么知道当前分区是健康的、正在升级的、还是需要回滚的?

我设计的状态机通常包含以下几个状态:

状态 描述 触发条件
ACTIVE 当前分区正常运行,系统从该分区启动。 启动成功,系统运行正常。
UPDATE_PENDING 升级包已下载完成,准备写入待升级分区。 OTA升级包下载完成,校验通过。
UPDATING 正在向待升级分区写入数据。 开始写入升级包。
UPDATE_DONE 升级包写入完成,等待重启验证。 写入完成,CRC校验通过。
VERIFYING 重启后,从新分区启动,进行功能验证。 系统重启,从新分区启动。
ROLLBACK 新分区验证失败,回滚到旧分区。 验证超时或用户触发回滚。
FAILED 分区损坏或无法恢复。 多次回滚失败或硬件故障。

状态流转的逻辑,我画了一张图,你看一眼就明白了:

分区状态机流转图 分区状态机流转图 ACTIVE UPDATE_PENDING UPDATING UPDATE_DONE VERIFYING ROLLBACK FAILED 升级包就绪 开始写入 写入完成 重启验证 验证成功 验证失败 回滚完成 回滚超限 说明: 绿色箭头表示成功路径 红色箭头表示失败/回滚路径

状态机的核心逻辑其实就一句话:升级后必须验证,验证失败必须回滚。

我在项目中遇到过一个问题:验证阶段怎么定义“成功”?是启动成功就算,还是运行一段时间才算?我后来采用了一个折中方案——延迟确认。系统从新分区启动后,先进入一个“观察期”(比如5分钟),如果期间没有发生严重错误,才把标识位从 VERIFYING 改为 ACTIVE。如果观察期内崩溃了,下次启动时Bootloader发现标识位还是 VERIFYING,就自动触发回滚。

关键设计决策: 回滚操作必须由Bootloader执行,不能依赖应用层。因为应用层可能已经坏了,根本跑不起来。Bootloader是最后的保险。