4. Flash驱动与操作:擦除、写入、读取与磨损均衡
Flash操作,说白了就是固件升级的“地基”。你想想看,不管你的升级协议多花哨,校验算法多牛,最后不都得落到“把新固件写进Flash”这一步?我见过太多项目,前面一切顺利,一到Flash操作就翻车——写一半掉电、擦除超时、扇区越界……嗯,今天咱们就把这块硬骨头啃透。
4.1 Flash的物理特性与操作约束
先搞清楚Flash的脾气。它跟RAM不一样,不能像改内存那样随便写。Flash有几个硬约束:
- 擦除粒度大:NOR Flash通常按扇区(Sector)擦除,一个扇区4KB或64KB。你不能只擦一个字节。
- 写入只能把1变0:Flash的写入操作只能把bit从1改成0。想把0改回1?必须擦除整个扇区。
- 擦除有寿命:典型NOR Flash擦写次数在10万次左右。别小看这个数,OTA频繁升级时,同一个扇区擦个几千次就危险了。
- 写入前必须擦除:这是新手最容易忘的。直接往有数据的地址写,结果就是数据错乱。
4.2 Flash驱动API设计与实现
我个人习惯把Flash驱动封装成三层:硬件抽象层、操作层、应用层。这样换芯片时只改底层,上层代码纹丝不动。
4.2.1 底层硬件接口
以STM32为例,标准库或HAL库已经提供了基础接口。但直接调用HAL_Flash_Program()做OTA?太慢了,而且容易阻塞系统。我建议自己封装一套:
/* flash_drv.h */
typedef enum {
FLASH_OK = 0,
FLASH_ERR_BUSY = -1,
FLASH_ERR_TIMEOUT = -2,
FLASH_ERR_PROTECT = -3
} flash_status_t;
flash_status_t flash_erase_sector(uint32_t sector_addr);
flash_status_t flash_write_page(uint32_t addr, const uint8_t *data, uint32_t len);
flash_status_t flash_read(uint32_t addr, uint8_t *buf, uint32_t len);
注意看,我把擦除和写入分开了。为什么?因为擦除是耗时操作,一个64KB扇区可能要几百毫秒。在RTOS环境下,我会把擦除放到后台任务里,或者拆成多次小擦除。
4.2.2 擦除实现细节
擦除时有个坑:必须确保目标扇区没有被代码执行占用。我曾经遇到过,在Flash里跑代码,然后擦除同一块区域——直接死机。解决方案有两种:
- 搬移到RAM执行:把擦除函数放到RAM里跑,避免指令预取冲突。
- 双Bank架构:如果MCU支持双Bank,可以在Bank2跑代码,擦除Bank1。
/* 擦除函数示例 - 带超时保护 */
flash_status_t flash_erase_sector(uint32_t addr) {
uint32_t timeout = 1000; /* 1秒超时 */
/* 解锁Flash */
HAL_FLASH_Unlock();
/* 擦除扇区 */
FLASH_Erase_Sector(addr_to_sector(addr), VOLTAGE_RANGE_3);
/* 等待操作完成或超时 */
while (__HAL_FLASH_GET_FLAG(FLASH_FLAG_BSY) && timeout--) {
delay_ms(1);
}
if (timeout == 0) {
HAL_FLASH_Lock();
return FLASH_ERR_TIMEOUT;
}
HAL_FLASH_Lock();
return FLASH_OK;
}
4.2.3 写入与读取
写入时要注意对齐。大部分MCU要求写入地址按字(4字节)或半字(2字节)对齐。不对齐?硬件直接报错。
/* 写入函数 - 带对齐处理 */
flash_status_t flash_write_page(uint32_t addr, const uint8_t *data, uint32_t len) {
/* 检查地址对齐 */
if (addr % 4 != 0) {
return FLASH_ERR_PROTECT;
}
HAL_FLASH_Unlock();
for (uint32_t i = 0; i < len; i += 4) {
uint32_t word = *(uint32_t *)(data + i);
if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + i, word) != HAL_OK) {
HAL_FLASH_Lock();
return FLASH_ERR_BUSY;
}
}
HAL_FLASH_Lock();
return FLASH_OK;
}
/* 读取就简单了 - 直接内存访问 */
flash_status_t flash_read(uint32_t addr, uint8_t *buf, uint32_t len) {
memcpy(buf, (uint8_t *)addr, len);
return FLASH_OK;
}
读取为什么这么简单?因为Flash在读取模式下就是内存映射的,直接指针访问就行。但要注意:如果读取时Flash正在擦除或写入,读到的数据可能是错的。所以读写要互斥。
4.3 Flash磨损均衡与扇区管理
磨损均衡(Wear Leveling)这个词,做存储的人不陌生。但在MCU固件升级里,很多人忽略了它。你想想看,如果每次升级都写同一个扇区,那个扇区很快就报废了。我见过一个产品,OTA升级1000次后,同一个扇区就挂了——因为没做磨损均衡。
4.3.1 扇区分配策略
我建议把Flash分区规划好。以1MB Flash为例:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 64KB | 启动加载器,不可擦除 |
| App Primary | 0x08010000 | 384KB | 主固件区 |
| App Backup | 0x08070000 | 384KB | 备份固件区 |
| Parameter | 0x080D0000 | 64KB | 参数存储,带磨损均衡 |
| OTA Status | 0x080E0000 | 4KB | 升级状态标志 |
注意参数区。参数经常被改写(比如WiFi配置、校准数据),如果不做磨损均衡,这个区很快就废了。
4.3.2 简单的磨损均衡实现
对于MCU场景,不需要像SSD那样复杂的算法。我常用一种“滑动窗口”方式:
- 把参数区划分为N个槽位(比如16个,每个4KB)
- 每次写参数时,找下一个空闲槽位写入
- 记录当前有效槽位的索引
- 所有槽位写满后,擦除整个区,从头开始
/* 磨损均衡参数存储示例 */
#define SLOT_COUNT 16
#define SLOT_SIZE 4096
typedef struct {
uint32_t magic; /* 魔数,标识有效数据 */
uint32_t seq; /* 序列号,越大越新 */
uint8_t data[4084]; /* 实际参数数据 */
} param_slot_t;
/* 查找最新有效槽位 */
int find_valid_slot(void) {
int best_idx = -1;
uint32_t max_seq = 0;
for (int i = 0; i < SLOT_COUNT; i++) {
param_slot_t *slot = (param_slot_t *)(PARAM_BASE + i * SLOT_SIZE);
if (slot->magic == PARAM_MAGIC && slot->seq > max_seq) {
max_seq = slot->seq;
best_idx = i;
}
}
return best_idx;
}
/* 写入参数 - 自动磨损均衡 */
flash_status_t param_write(const uint8_t *data, uint32_t len) {
int next_slot = find_next_free_slot();
if (next_slot < 0) {
/* 所有槽位已满,擦除整个区 */
flash_erase_sector(PARAM_BASE);
next_slot = 0;
}
uint32_t addr = PARAM_BASE + next_slot * SLOT_SIZE;
param_slot_t slot;
slot.magic = PARAM_MAGIC;
slot.seq = get_next_seq();
memcpy(slot.data, data, len);
return flash_write_page(addr, (uint8_t *)&slot, sizeof(param_slot_t));
}
4.4 Flash操作的知识体系
下面这张图,把本章的核心逻辑串起来了。你可以把它当作Flash操作的“地图”:
4.5 常见故障与排查
最后,聊聊我踩过的坑。Flash操作出问题,症状往往很诡异——系统跑着跑着就挂了、升级到一半卡死、校验总是不通过。下面这几个排查方向,你可以直接拿去用:
- 写入前没擦除:读回的数据跟写入的不一致。解决办法:写入前先调用擦除接口。
- 地址越界:写到了Bootloader区域,导致下次启动失败。解决办法:在驱动层加地址范围检查。
- 擦除超时:系统在擦除时被高优先级中断打断,导致超时。解决办法:擦除期间关中断,或使用DMA方式。
- 磨损均衡失效:参数区频繁写入,某个扇区先挂了。解决办法:检查磨损均衡算法,确认槽位轮转正常。
- 电源不稳:写入过程中掉电,数据写了一半。解决办法:加掉电检测,配合备份区做回滚。
嗯,Flash驱动这块,说难不难,说简单也不简单。关键是把物理特性吃透,API封装好,磨损均衡做到位。做到这三点,你的固件升级就稳了一大半。