第三章 Flash驱动深度解析:读/写/擦除操作、等待周期与时序、分区规划策略

做MCU Boot,说白了就是跟Flash打交道。你写的代码最终要烧进去,上电后CPU第一条指令要从Flash取出来。如果Flash驱动没写好,轻则启动慢,重则直接变砖。这一章,我把这些年跟Flash死磕的经验掰开揉碎讲给你听。

核心认知:Flash是Boot的基石。读不对,程序跑飞;写不对,升级失败;擦不对,整片报废。分区规划不好,后面所有逻辑都得推倒重来。

3.1 Flash读操作:看似简单,坑却不少

读Flash,大部分MCU直接寻址就能读。比如STM32,你定义一个const数组指向0x08000000,直接取值就行。但这里有个细节——对齐访问。

我记得有一次,一个同事用uint8_t*去读一个32位数据,结果地址没对齐,硬生生多花了3个总线周期。你想想看,Boot里要校验几十KB甚至几MB的数据,每个地址多3个周期,累积下来就是几百毫秒的延迟。

我的习惯:读Flash时,尽量用32位或64位对齐的指针。如果数据量不大,可以用memcpy,编译器会自动优化成最佳指令序列。

另外,有些MCU在读Flash时,如果总线频率太高,需要插入等待周期。这个后面会细说。

3.2 Flash写操作:不是你想写就能写

写Flash跟写RAM完全是两码事。RAM是直接写地址,Flash得先解锁、再编程、再锁定。而且,Flash只能把1写成0,不能把0写成1。想恢复成1?只能擦除。

我见过不少新手,写Flash前忘了检查是否已擦除,结果数据写进去全是乱的。嗯,这里要注意:写之前一定要确认目标地址是0xFF(擦除后的状态)。

下面是一个典型的Flash写流程,以STM32为例:

// Flash写操作标准流程
void Flash_Write(uint32_t addr, uint32_t data) {
    // 1. 检查地址是否对齐
    if (addr & 0x3) return;  // 32位对齐检查
    
    // 2. 解锁Flash
    FLASH->KEYR = 0x45670123;
    FLASH->KEYR = 0xCDEF89AB;
    
    // 3. 等待上次操作完成
    while (FLASH->SR & FLASH_SR_BSY);
    
    // 4. 检查是否已擦除
    if (*(volatile uint32_t*)addr != 0xFFFFFFFF) {
        // 未擦除,需要先擦除
        Flash_Erase_Sector(addr);
    }
    
    // 5. 写入数据
    FLASH->CR |= FLASH_CR_PG;  // 使能编程
    *(volatile uint32_t*)addr = data;
    while (FLASH->SR & FLASH_SR_BSY);
    FLASH->CR &= ~FLASH_CR_PG;  // 关闭编程
    
    // 6. 锁定Flash
    FLASH->CR |= FLASH_CR_LOCK;
}

我曾经踩过的坑:写Flash时忘了关中断。结果写了一半,中断来了,Flash控制器被中断服务程序又操作了一次,直接导致数据错乱。所以,写Flash前一定要关全局中断,写完了再开。

3.3 Flash擦除操作:最慢、最危险、最容易被忽略

擦除是Flash操作里最慢的。一个扇区(通常4KB或64KB)擦除一次,少则几十毫秒,多则几百毫秒。而且,擦除期间CPU是挂起的——因为Flash控制器忙着擦除,没法响应指令。

为什么会这么慢?因为擦除本质上是给整个扇区加高压,把所有的浮栅晶体管里的电子都赶出去。这个过程物理上就快不了。

擦除的粒度也很重要。有的MCU支持按扇区擦除,有的只能按块擦除。我建议你仔细看芯片手册,搞清楚最小擦除单位。比如:

MCU系列 最小擦除单位 擦除时间(典型) 注意事项
STM32F1 1KB(页) ~20ms 页擦除,粒度小
STM32F4 16KB/64KB/128KB(扇区) ~100ms 不同扇区大小不同
NXP LPC 256B/4KB(扇区) ~25ms 支持部分擦除
GD32 4KB(页) ~30ms 兼容STM32指令

擦除策略上,我个人的经验是:能不擦就不擦,能少擦就少擦。比如OTA升级时,只擦需要更新的扇区,别整片擦。

3.4 Flash等待周期与时序:频率越高,等待越多

Flash的读取速度是有上限的。MCU主频跑得比Flash快,就得插入等待周期(Wait State)。说白了,就是CPU读Flash时,多等几个时钟周期,让Flash把数据准备好。

这个参数在芯片手册里通常有个表格:

系统时钟频率 等待周期数 Flash访问时间
0 ~ 24 MHz 0 零等待
24 ~ 48 MHz 1 1个时钟周期
48 ~ 72 MHz 2 2个时钟周期
72 ~ 120 MHz 3 3个时钟周期

等待周期越多,CPU执行指令就越慢。因为每条指令都要从Flash取,取指令多等几个周期,流水线就堵住了。

我的优化技巧:如果Boot对启动速度要求高,可以先把关键代码(比如Flash驱动、中断向量表)拷贝到RAM里执行。这样就不用受Flash等待周期影响了。很多MCU都支持这个功能,叫“程序在RAM中运行”。

另外,有些MCU支持预取缓冲(Prefetch Buffer)和指令缓存(I-Cache)。打开这些功能,能显著减少等待周期的影响。我建议你在初始化时钟后,第一时间配置好这些。

3.5 Flash分区规划策略:别等写代码了才想

分区规划是Boot设计里最需要提前想清楚的事。一旦芯片量产了,分区就焊死在Flash里了,想改?只能换芯片。

我一般把Flash分成这么几个区:

  1. Boot区:存放Bootloader代码,通常16KB~64KB。这个区写保护,防止被意外擦除。
  2. 参数区:存放配置参数、校准数据、设备序列号等。通常4KB~16KB,需要频繁读写。
  3. App区:存放用户应用程序。大小取决于你的应用,通常占Flash的60%~80%。
  4. 备份区:OTA升级时,存放新固件。大小跟App区一样,用于安全升级。
  5. 日志区:存放运行日志、错误记录。可选,但强烈建议保留。

下面是一个典型的分区布局:

// Flash分区规划示例(假设总容量1MB)
#define BOOT_ADDR       0x08000000  // Boot区:64KB
#define BOOT_SIZE       0x00010000

#define PARAM_ADDR      0x08010000  // 参数区:16KB
#define PARAM_SIZE      0x00004000

#define APP_ADDR        0x08014000  // App区:512KB
#define APP_SIZE        0x00080000

#define BACKUP_ADDR     0x08094000  // 备份区:512KB
#define BACKUP_SIZE     0x00080000

#define LOG_ADDR        0x08114000  // 日志区:48KB
#define LOG_SIZE        0x0000C000

我曾经犯过的错:分区时没考虑擦除对齐。App区结束地址跟备份区起始地址没对齐到扇区边界,结果擦除备份区时,把App区最后几个字节也擦掉了。所以,分区地址一定要对齐到最小擦除单位。

分区规划还有个原则:Boot区要写保护。很多MCU都有Flash写保护寄存器,把Boot区对应的位设成1,Boot区就只读不写了。这样即使App跑飞了,也不会把Boot区冲掉。

下面这张图展示了Flash分区的整体逻辑:

Flash分区规划结构图 Flash分区规划结构图 Flash总容量(1MB) Boot区(64KB) 0x08000000 - 0x0800FFFF | 写保护 | 存放Bootloader 参数区(16KB) 0x08010000 - 0x08013FFF | 频繁读写 | 配置参数 App区(512KB) 0x08014000 - 0x08093FFF | 用户应用程序 中断向量表重映射到此区域起始地址 备份区(512KB) 0x08094000 - 0x08113FFF | OTA升级新固件存放 大小与App区一致,确保安全升级 日志区(48KB) 0x08114000 - 0x0811FFFF | 运行日志 | 循环写入 关键原则:Boot区写保护、分区对齐扇区边界、备份区与App区等大

3.6 实战中的几个关键决策点

最后,我总结几个实战中必须想清楚的问题:

  • 等待周期设多少? 根据你的主频查手册,别设少了(Flash读错),也别设多了(性能浪费)。
  • 要不要开缓存? 如果Boot里代码量大,开I-Cache能明显提速。但要注意,写Flash时要清缓存,否则读到脏数据。
  • 分区要不要留冗余? 我建议留10%~20%的空闲空间。万一后期要加功能,不用重新规划分区。
  • 擦除策略怎么定? 如果OTA升级频繁,考虑用“双备份”策略:先擦备份区,写入新固件,校验通过后再擦App区,拷贝过去。这样即使升级中断,还能回滚。

我的个人习惯:每次做新项目,我都会先画一张Flash分区图,标清楚每个区的起始地址、大小、用途、写保护状态。然后贴在工位上。这样写代码时不会搞混地址。