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频繁升级时,同一个扇区擦个几千次就危险了。
  • 写入前必须擦除:这是新手最容易忘的。直接往有数据的地址写,结果就是数据错乱。
核心原则:Flash操作三步骤——擦除 → 写入 → 读取验证。顺序不能乱,一步都不能省。

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;
}
避坑指南:我曾经在一个量产项目里,因为擦除超时时间设得太短(只给了100ms),结果在高温环境下Flash擦除变慢,导致升级失败率高达30%。后来改成根据芯片手册最大时间×2,问题解决。记住:Flash操作时间会随温度和电压变化。

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));
}
我的经验:磨损均衡的槽位数量不是越多越好。槽位太多,查找有效数据的时间变长。我一般控制在16-32个槽位,既保证寿命,又保证查找速度。另外,每次启动时做一次全槽位扫描,把有效数据搬到RAM缓存,后续读写都操作缓存,只在掉电前写回Flash。

4.4 Flash操作的知识体系

下面这张图,把本章的核心逻辑串起来了。你可以把它当作Flash操作的“地图”:

Flash驱动与操作知识体系 Flash物理特性 擦除粒度大(扇区) 写入只能1→0 擦除有寿命限制 驱动API三层架构 擦除API(扇区级) 写入API(页级/对齐) 读取API(内存映射) 磨损均衡与扇区管理 滑动窗口槽位分配 序列号+魔数校验 全擦除后循环复用 核心流程:物理特性 → 驱动API → 磨损均衡 → 扇区管理,环环相扣

4.5 常见故障与排查

最后,聊聊我踩过的坑。Flash操作出问题,症状往往很诡异——系统跑着跑着就挂了、升级到一半卡死、校验总是不通过。下面这几个排查方向,你可以直接拿去用:

  1. 写入前没擦除:读回的数据跟写入的不一致。解决办法:写入前先调用擦除接口。
  2. 地址越界:写到了Bootloader区域,导致下次启动失败。解决办法:在驱动层加地址范围检查。
  3. 擦除超时:系统在擦除时被高优先级中断打断,导致超时。解决办法:擦除期间关中断,或使用DMA方式。
  4. 磨损均衡失效:参数区频繁写入,某个扇区先挂了。解决办法:检查磨损均衡算法,确认槽位轮转正常。
  5. 电源不稳:写入过程中掉电,数据写了一半。解决办法:加掉电检测,配合备份区做回滚。
我曾经踩过的一个大坑:在一个量产项目中,Flash擦除操作在低温(-20°C)下耗时增加了3倍,导致看门狗复位。排查了两天才发现,是擦除超时时间没考虑温度余量。从那以后,我的所有Flash操作都加了动态超时——根据温度传感器读数调整等待时间。

嗯,Flash驱动这块,说难不难,说简单也不简单。关键是把物理特性吃透,API封装好,磨损均衡做到位。做到这三点,你的固件升级就稳了一大半。