4、Flash分区设计原则:分区粒度、对齐要求、冗余与备份、分区表结构设计
Flash分区这事儿,看着简单,其实坑不少。我见过太多项目,前期分区拍脑袋,后期升级时发现空间不够,或者擦写次数不均导致整片Flash提前报废。说白了,分区设计就是给固件画地皮——画大了浪费,画小了憋屈,画歪了系统跑不起来。
这一节,我把这些年积累的分区原则掰开揉碎讲给你听。你想想看,一个Bootloader要管多少个区?至少得有个启动区、主程序区、配置区、日志区吧?要是做OTA,还得有备份区。每个区怎么切、怎么对齐、怎么冗余,都是有讲究的。
4.1 分区粒度:别太碎,也别太糙
分区粒度,就是每个分区的最小单位。我习惯把粒度控制在Flash物理擦除块(Sector/Block)的整数倍。为什么?因为Flash擦除是按块来的,你一个分区只占半个块,擦的时候照样得擦整个块,那另一半空间就浪费了。
举个例子,某款Flash的擦除块是4KB。你分一个3KB的配置区,看着省了1KB,实际上每次擦写还是得擦4KB,那1KB永远用不上。我在一个IoT项目里就吃过这个亏——当时为了省空间,把日志区分成了2KB一个,结果Flash的擦除块是4KB,日志区频繁擦写导致相邻分区数据被干扰,排查了整整两天。
分区粒度的选择,我建议参考这张表:
| 分区类型 | 推荐粒度 | 理由 |
|---|---|---|
| Bootloader区 | 1~2个擦除块 | 极少更新,固定大小即可 |
| 主程序区 | 4~16个擦除块 | 需要容纳完整固件镜像 |
| 配置参数区 | 1个擦除块 | 频繁读写,越小越好 |
| 日志/数据区 | 2~8个擦除块 | 循环写入,需要磨损均衡 |
| OTA备份区 | 与主程序区相同 | 必须能容纳完整镜像 |
4.2 对齐要求:地址不对齐,性能打对折
对齐,说白了就是分区的起始地址和结束地址,必须落在Flash的擦除块边界上。你想想看,如果一个分区从块中间开始,那这个块就同时属于两个分区——擦写任何一个分区都会影响到另一个,这不乱套了吗?
我遇到过最典型的案例:某工程师把配置区起始地址设在了0x0800 1000,而Flash的擦除块大小是4KB(0x1000)。0x0800 1000正好是第4个块的中间位置(0x0800 0000 + 4×0x1000 = 0x0800 4000才是块边界)。结果每次更新配置,都要先读出整个块、修改其中一部分、再擦除整个块写回去——相邻分区的主程序数据就这么被反复擦写,寿命急剧下降。
对齐检查的代码,我一般这么写:
// 检查分区对齐
#define FLASH_SECTOR_SIZE 4096 // 4KB擦除块
int check_partition_alignment(uint32_t start, uint32_t size) {
if (start % FLASH_SECTOR_SIZE != 0) {
return -1; // 起始地址未对齐
}
if (size % FLASH_SECTOR_SIZE != 0) {
return -2; // 大小未对齐
}
return 0; // 对齐通过
}
4.3 冗余与备份:别让系统死在升级路上
冗余设计,是Bootloader的保命符。我参与过一个车载项目,OTA升级到一半断电了,主程序区只写了一半,系统直接变砖。从那以后,我设计的每个Bootloader都必须有冗余备份机制。
常见的冗余策略有三种:
- A/B分区(双备份):两个完全相同的程序区,一个运行,一个待升级。升级失败就回滚到另一个。
- 备份区+恢复区:一个主程序区,一个备份区。升级时先写备份区,校验通过再复制到主区。
- 增量备份:只备份关键配置和启动参数,程序区损坏时从外部存储恢复。
我个人最推荐A/B分区方案。虽然多占一倍的Flash空间,但可靠性最高。你看手机系统升级,基本都是A/B分区——升级失败自动回滚,用户完全无感。
A/B分区的状态管理,我习惯用两个标志位:
// A/B分区状态定义
#define PARTITION_A_ACTIVE 0x01
#define PARTITION_B_ACTIVE 0x02
#define PARTITION_UPDATE 0x04 // 正在升级
typedef struct {
uint8_t status; // 当前活动分区
uint8_t boot_count; // 启动次数(用于看门狗回滚)
uint16_t crc; // 分区表校验
} partition_status_t;
4.4 分区表结构设计:一张表管所有区
分区表,就是Flash的"房产证"。它记录了每个分区的名字、起始地址、大小、属性和校验值。Bootloader启动时第一件事就是读分区表,确认所有分区都合法。
我设计分区表时,会把它放在Flash的固定位置——通常是第一个擦除块的起始地址。这样无论程序怎么更新,分区表的位置不变,Bootloader总能找到它。
一个典型的分区表结构长这样:
| 字段 | 大小 | 说明 |
|---|---|---|
| magic | 4字节 | 魔数,用于识别分区表有效性(如0x50415254) |
| version | 2字节 | 分区表版本号,用于兼容性检查 |
| entry_count | 2字节 | 分区条目数量 |
| entries[] | N×32字节 | 分区条目数组,每个条目32字节 |
| crc32 | 4字节 | 整个分区表的CRC32校验 |
每个分区条目(32字节)的结构:
typedef struct {
char name[16]; // 分区名称,如"boot", "app", "config"
uint32_t start_addr; // 起始地址
uint32_t size; // 分区大小
uint32_t flags; // 属性标志(只读、可升级、加密等)
uint16_t type; // 分区类型(0=普通, 1=备份, 2=日志)
uint16_t reserved; // 保留
} partition_entry_t;
分区表的校验,我一般用CRC32。启动时先读分区表,计算CRC,跟存储的CRC比对。不一致就直接进入恢复模式——宁可系统不启动,也不能用错误的分区表。
下面这张图,是我常用的分区布局结构:
嗯,这张图基本概括了我常用的分区策略。分区表在最前面,紧接着是Bootloader,然后是A/B两个主程序区各自带着配置区,最后是日志区和保留区。每个分区都严格按擦除块对齐,A/B分区大小完全一致。
最后说一句:分区设计没有银弹。你得根据具体的Flash型号、MCU性能、升级策略来权衡。但不管怎么变,对齐、冗余、可扩展这三个原则,永远不能丢。