3. Flash驱动与抽象层:Flash读写擦除操作、Flash分区表设计、驱动层封装与接口定义

好,咱们进入第三章。这一章是Bootloader的基石——Flash操作。你想想看,不管你的升级方案设计得多花哨,最终都得落到Flash的读写擦上。这块要是没弄好,后面全是空中楼阁。

我个人习惯把Flash驱动分成两层:底层硬件操作和上层抽象接口。这样做的目的很明确——万一换芯片了,上层代码基本不用动。

3.1 Flash基本操作:读、写、擦除

Flash这玩意儿,说白了有三个核心操作:读、写、擦除。但有个关键点你得记住——写之前必须先擦除。这不是我啰嗦,很多新手就在这上面栽跟头。

我在项目中遇到过一位同事,调试了一整天,发现数据写进去读出来全是0xFF。最后查出来,他忘了先擦除扇区。嗯,从那以后,我每次写Flash前都会加个擦除检查。

3.1.1 读操作

读Flash其实最简单,直接按地址访问就行。大部分MCU都支持字节、半字、字三种读取方式。

// 字节读取示例
uint8_t flash_read_byte(uint32_t addr)
{
    return *(volatile uint8_t*)addr;
}

// 字读取示例(32位)
uint32_t flash_read_word(uint32_t addr)
{
    return *(volatile uint32_t*)addr;
}
小提示:读操作不需要解锁Flash控制器,也不需要等待。但要注意地址对齐——有些MCU对非对齐访问会触发HardFault。

3.1.2 写操作

写Flash就麻烦多了。你得先解锁Flash控制器,然后按页或按字写入。不同MCU的写入粒度不一样,有的支持单字节写入,有的必须按16位或32位写入。

// STM32 Flash写入示例(按字写入)
HAL_StatusTypeDef flash_write_word(uint32_t addr, uint32_t data)
{
    HAL_StatusTypeDef status;
    
    // 1. 解锁Flash
    HAL_FLASH_Unlock();
    
    // 2. 清除标志位
    __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPERR);
    
    // 3. 写入数据
    status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr, data);
    
    // 4. 锁定Flash
    HAL_FLASH_Lock();
    
    return status;
}
注意:写入过程中不能断电!如果写入一半掉电,这个地址的数据就废了。所以双备份升级方案里,我们会在写入前先校验数据完整性。

3.1.3 擦除操作

擦除是Flash操作里最耗时的。擦除的最小单位是扇区(Sector),大小从4KB到128KB不等。有些MCU还支持整片擦除(Mass Erase),但Bootloader里我们一般不用——太危险了。

// 扇区擦除示例
HAL_StatusTypeDef flash_erase_sector(uint32_t sector)
{
    HAL_StatusTypeDef status;
    uint32_t page_error = 0;
    
    // 1. 解锁Flash
    HAL_FLASH_Unlock();
    
    // 2. 执行擦除
    status = HAL_FLASHEx_Erase(&erase_init_struct, &page_error);
    
    // 3. 锁定Flash
    HAL_FLASH_Lock();
    
    return status;
}
关键点:擦除操作会把整个扇区的内容变成0xFF。所以设计分区表时,一定要把Bootloader和应用程序放在不同的扇区里,避免误擦。

3.2 Flash分区表设计

分区表是Bootloader的灵魂。我见过很多项目,分区表设计得乱七八糟,最后升级时各种踩坑。这里我分享一个经过多个项目验证的分区方案。

3.2.1 典型分区结构

分区名称 起始地址 大小 说明
Bootloader 0x08000000 64KB 存放Bootloader固件
参数区 0x08010000 16KB 存放升级标志、版本号等
App A(主分区) 0x08014000 256KB 当前运行的应用程序
App B(备份分区) 0x08054000 256KB 升级时存放新固件
日志区 0x08094000 32KB 记录升级日志和错误信息

这个分区方案的好处很明显:Bootloader和App完全隔离,升级时先写App B,校验通过后再切换。万一升级失败,还能回滚到App A。

3.2.2 分区表定义

我建议把分区表定义成一个结构体,放在头文件里统一管理。这样改分区时,只需要改一个地方。

// 分区表定义
typedef struct {
    uint32_t start_addr;
    uint32_t size;
    uint8_t  sector_num;
    char     name[16];
} flash_partition_t;

// 分区表实例
const flash_partition_t g_partition_table[] = {
    {0x08000000, 0x10000, 0, "Bootloader"},
    {0x08010000, 0x04000, 1, "Parameter"},
    {0x08014000, 0x40000, 2, "App_A"},
    {0x08054000, 0x40000, 6, "App_B"},
    {0x08094000, 0x08000, 10, "Log"},
};
设计建议:分区大小最好是扇区大小的整数倍。比如扇区是16KB,那每个分区就按16KB的倍数来分配。这样擦除时不会影响到相邻分区。

3.3 驱动层封装与接口定义

驱动层封装,说白了就是给上层提供一个干净、统一的接口。我习惯把所有Flash操作抽象成下面这几个函数:

3.3.1 接口定义

// flash_drv.h
#ifndef __FLASH_DRV_H__
#define __FLASH_DRV_H__

#include <stdint.h>

// 初始化Flash驱动
int32_t flash_drv_init(void);

// 读取数据
int32_t flash_drv_read(uint32_t addr, uint8_t *buf, uint32_t len);

// 写入数据(内部包含擦除操作)
int32_t flash_drv_write(uint32_t addr, const uint8_t *buf, uint32_t len);

// 擦除指定扇区
int32_t flash_drv_erase(uint32_t sector_start, uint32_t sector_count);

// 获取分区信息
int32_t flash_drv_get_partition_info(uint8_t part_id, flash_partition_t *info);

// 校验数据完整性(CRC32)
int32_t flash_drv_verify(uint32_t addr, const uint8_t *buf, uint32_t len, uint32_t crc);

#endif

3.3.2 实现要点

写驱动时,有几个坑你得注意:

  • 地址检查:每次操作前都要检查地址是否在合法范围内。我曾经见过一个Bug,写入地址越界,结果把Bootloader区给覆盖了——设备直接变砖。
  • 写保护:有些MCU有写保护寄存器,操作前要确保目标区域没有被保护。
  • 超时处理:擦除操作可能耗时几百毫秒,要加超时机制,防止死等。
  • 错误恢复:写入或擦除失败后,要能恢复到安全状态。
避坑指南:我曾经在一个项目里,因为没检查Flash操作返回值,导致升级失败后系统还继续运行。结果用户数据全丢了。从那以后,我每个Flash操作后面都跟着状态检查,绝不偷懒。

3.4 整体架构图

下面这张图展示了Flash驱动层的整体架构。你可以看到,上层应用(Bootloader主逻辑、升级管理器)只跟抽象接口层打交道,不直接操作硬件。

Flash驱动层架构图 Flash驱动层架构图 上层应用(Bootloader主逻辑 / 升级管理器) 抽象接口层(flash_drv_read / write / erase / verify) 硬件抽象层(HAL / LL 驱动) 硬件层(Flash控制器 / 寄存器) 分区表管理 g_partition_table[] 地址范围检查 扇区映射 错误处理机制 超时检测 写保护检查 错误恢复

这张图把Flash驱动分成了四层。最上层是业务逻辑,最下层是硬件寄存器。中间的两层——抽象接口层和硬件抽象层——就是我们今天要重点实现的。

3.5 总结

这一章我们聊了Flash驱动的三个核心:读写擦操作、分区表设计、接口封装。记住几个关键点:

  • 写之前必须擦除,这是Flash的物理特性决定的
  • 分区表要按扇区对齐,预留足够的隔离空间
  • 接口封装要抽象,方便移植和测试
  • 错误处理不能偷懒,每个操作都要检查返回值