Flash存储器基础:物理特性、读写擦除与磨损均衡

做嵌入式这么多年,我接触过的Flash少说也有几十种。从NOR到NAND,从SPI到并行,形态各异但底层逻辑相通。今天咱们就把它掰开揉碎,看看Flash到底是怎么工作的。

Flash的物理特性

Flash存储器的基本单元是浮栅晶体管。说白了,就是在一个普通MOS管的栅极下面,多了一层被氧化物包裹的浮栅。这层浮栅可以存储电荷,电荷的存在与否就代表了0和1。

我个人习惯把Flash的物理特性归纳为三点:

  • 非易失性:掉电不丢数据。浮栅里的电荷被氧化物包裹,理论上可以保持10年以上。
  • 块结构:Flash不能像RAM那样按字节改写。它被划分为多个块(Block),每个块又包含若干页(Page)。
  • 有限寿命:每次擦除操作都会对氧化物造成损伤。NOR Flash一般10万次,NAND Flash在1万到10万次之间。

关键认知:Flash的读、写、擦除是三种完全不同的操作。读最快,写次之,擦除最慢。而且擦除必须按块进行,不能只擦一个字节。

我在项目中遇到过一个问题:某款产品用了3年突然无法启动。排查下来,是Bootloader所在的Flash块擦写次数超标了。嗯,这就是没理解Flash物理特性的后果。

读写擦除操作详解

咱们以最常见的SPI NOR Flash为例,看看这三种操作到底怎么玩。

读操作

读操作最简单。你给Flash发一个读命令(通常是0x03或0x0B),再给个地址,它就把数据吐出来。读操作不改变Flash内部状态,所以没有寿命限制。你读一亿次也没问题。

// SPI NOR Flash 读操作示例
uint8_t read_byte(uint32_t addr) {
    uint8_t cmd = 0x03;  // 读命令
    uint8_t data;
    
    CS_LOW();
    spi_transfer(cmd);
    spi_transfer((addr >> 16) & 0xFF);
    spi_transfer((addr >> 8) & 0xFF);
    spi_transfer(addr & 0xFF);
    data = spi_transfer(0x00);  // 读一个字节
    CS_HIGH();
    
    return data;
}

写操作

写操作就麻烦多了。Flash只能把1写成0,不能把0写成1。要把0变回1,必须擦除。所以写操作之前,目标区域必须是已擦除状态(全1)。

写操作以页为单位,常见的页大小是256字节。写之前要先发写使能命令(0x06),然后发页编程命令(0x02),再跟上地址和数据。

避坑指南:我曾经在量产测试时发现,一批板子的Flash写入总是失败。查了两天才发现,是写操作前忘了检查Flash的忙状态。写入命令发出后,Flash需要时间把数据真正存进去,这段时间你不能发新命令。一定要轮询状态寄存器,等它不忙了再继续。

擦除操作

擦除是Flash最耗时的操作。擦除一个64KB的块,可能需要几百毫秒甚至几秒。擦除命令也分几种:

擦除类型 命令码 典型耗时 适用场景
扇区擦除(4KB) 0x20 45ms 小范围更新
块擦除(32KB) 0x52 120ms 中等范围
块擦除(64KB) 0xD8 250ms 大范围更新
整片擦除 0xC7 数秒 出厂初始化

你想想看,如果Bootloader升级时用整片擦除,那得等好几秒。用户可能以为设备死机了。所以实际项目中,我通常只用扇区擦除,一次只擦4KB,配合进度条给用户反馈。

Flash磨损均衡概念

磨损均衡(Wear Leveling)这个概念,最早是从NAND Flash那边来的。NOR Flash虽然寿命长一些,但在Bootloader场景下,频繁更新的分区同样面临这个问题。

说白了,磨损均衡就是不让某个块被频繁擦写,而其他块闲着。把擦写操作均匀分布到所有块上。

为什么需要这个?我举个例子:

  • Bootloader的参数区,每次启动都可能写一次
  • 日志区,每记录一条日志就写一次
  • OTA升级的临时存储区,每次升级都擦写

如果不做均衡,参数区可能一年就报废了,而其他区还是全新的。

常见的磨损均衡策略

我总结了几种常用的策略,按复杂度排序:

  1. 静态映射:把固定地址映射到不同物理块。简单但不够灵活。
  2. 动态映射:每次写操作都找擦写次数最少的块。需要维护一张映射表。
  3. 日志结构:所有写操作都追加到空闲块,旧块标记为无效。垃圾回收时再擦除。

实战建议:在Bootloader场景下,我推荐用动态映射。因为Bootloader的代码量不大,参数区通常只有几个块。维护一张小映射表,开销可以接受。我曾经在一个STM32项目上实现了这个方案,映射表放在Flash的最后两个块,每次启动时加载到RAM里。

Flash内部结构示意图

下面这张图展示了Flash存储器的层级结构,从芯片到存储单元:

Flash存储器层级结构 Flash芯片 容量:8MB ~ 64MB | 接口:SPI/QSPI/Parallel 块(Block) 大小:64KB | 数量:128个 | 擦除单位 擦除寿命:NOR 10万次 | NAND 1万次 页(Page) 大小:256字节 | 写入单位 每个块包含 256 个页 存储单元(浮栅晶体管) 存储1位数据 | SLC/MLC/TLC 擦除以块为单位 写入以页为单位 读取以字节为单位

Bootloader场景下的Flash分区策略

讲完基础,咱们回到Bootloader这个场景。我一般把Flash分成这么几个区:

分区名称 起始地址 大小 擦写频率 磨损均衡需求
Bootloader区 0x000000 64KB 几乎不擦写 无
参数区 0x010000 64KB 每次启动 高
应用区A 0x020000 512KB OTA升级时 低
应用区B 0x0A0000 512KB OTA升级时 低
日志区 0x120000 128KB 频繁 高

参数区和日志区是磨损均衡的重点。我习惯在参数区用动态映射,每次写参数都换一个页。日志区用日志结构,只追加不覆盖,满了再整体垃圾回收。

重要提醒:磨损均衡不是万能的。它只能延长Flash寿命,不能消除物理限制。设计时一定要留足余量。我一般按理论寿命的20%来设计,比如标称10万次,我就按2万次来规划。这样即使有意外情况,也不会在保修期内出问题。

好了,Flash的基础知识就聊到这儿。这些概念在后续的CRC校验实现和分区策略验证中都会用到。特别是磨损均衡,在Bootloader的可靠性和寿命设计中,是个绕不开的坎儿。