3. Flash驱动开发:从存储原理到磨损均衡

Flash存储器,说白了就是嵌入式系统里那个“断电不丢数据”的家伙。Bootloader要存、固件要放、日志要写,全都离不开它。我做了这么多年嵌入式,跟Flash打交道的时间比跟家人还多——嗯,这玩意儿看着简单,坑可不少。

3.1 Flash存储器的类型与特性

先搞清楚我们面对的是什么。嵌入式里最常见的Flash分两种:NOR和NAND。名字像,脾气完全不同。

特性 NOR Flash NAND Flash
读取速度 快(随机读取,类似RAM) 较快(页读取,顺序访问)
写入速度 慢(字节/字编程) 快(页编程,通常2KB/页)
擦除粒度 扇区(64KB~256KB) 块(128KB~1MB)
坏块 极少,出厂无坏块 出厂就有坏块,需管理
接口 并行/SPI/QSPI 并行/SPI/ONFI/Toggle
典型用途 Bootloader、代码就地执行 大容量存储、文件系统

我个人习惯:Bootloader用NOR,因为可以XIP(就地执行),上电就能跑。数据存储用NAND,容量大、成本低。但如果你用SPI NAND做Bootloader,那就得先加载到RAM再执行——多了一步,麻烦。

关键认知: Flash写入前必须先擦除,且擦除只能按扇区/块进行。这是所有Flash驱动设计的根本约束。

3.2 读/写/擦除操作实战

操作Flash,其实就是三件事:读、写、擦。但每件事都有讲究。

3.2.1 读取操作

NOR Flash读起来最简单——直接给地址,数据就出来了。SPI接口的话,发个读命令(0x03),后面跟3字节地址,然后收数据。

// SPI NOR Flash 读取示例
uint8_t spi_nor_read(uint32_t addr, uint8_t *buf, uint32_t len) {
    uint8_t cmd[4];
    cmd[0] = 0x03;          // 读命令
    cmd[1] = (addr >> 16) & 0xFF;
    cmd[2] = (addr >> 8) & 0xFF;
    cmd[3] = addr & 0xFF;
    
    spi_select();           // 片选拉低
    spi_transmit(cmd, 4);   // 发命令+地址
    spi_receive(buf, len);  // 读数据
    spi_deselect();         // 片选拉高
    return 0;
}

NAND Flash读取不一样。它按页读,先发命令0x00,再发5字节地址(列地址+行地址),然后发命令0x30等待数据就绪。

我的经验: 读操作一般不会出错,但SPI速率高的时候要注意时序。我曾经在50MHz SPI下读数据,发现前几个字节总是错的——后来加了片选后的延时才解决。

3.2.2 写入操作(编程)

写入前必须确保目标区域已被擦除。NOR Flash可以按字节或字写,NAND只能按页写。

// SPI NOR Flash 页写入示例
uint8_t spi_nor_page_program(uint32_t addr, uint8_t *data, uint32_t len) {
    // 1. 写使能
    spi_nor_write_enable();
    
    // 2. 发页编程命令
    uint8_t cmd[4];
    cmd[0] = 0x02;          // 页编程命令
    cmd[1] = (addr >> 16) & 0xFF;
    cmd[2] = (addr >> 8) & 0xFF;
    cmd[3] = addr & 0xFF;
    
    spi_select();
    spi_transmit(cmd, 4);
    spi_transmit(data, len);
    spi_deselect();
    
    // 3. 等待完成
    while (spi_nor_busy()) {
        // 轮询状态寄存器
    }
    return 0;
}
注意: NAND Flash写入时可能出现位翻转(bit flip),这是正常现象。读出来发现某一位不对,别慌,用ECC纠正就行。我遇到过新手看到位翻转直接报硬件故障的……

3.2.3 擦除操作

擦除是最慢的操作,也是Flash驱动里最需要小心的部分。NOR Flash擦一个扇区(64KB)大概要几百毫秒到几秒,NAND擦一个块也要几毫秒。

// SPI NOR Flash 扇区擦除
uint8_t spi_nor_sector_erase(uint32_t sector_addr) {
    // 1. 写使能
    spi_nor_write_enable();
    
    // 2. 发扇区擦除命令 (0x20)
    uint8_t cmd[4];
    cmd[0] = 0x20;
    cmd[1] = (sector_addr >> 16) & 0xFF;
    cmd[2] = (sector_addr >> 8) & 0xFF;
    cmd[3] = sector_addr & 0xFF;
    
    spi_select();
    spi_transmit(cmd, 4);
    spi_deselect();
    
    // 3. 等待擦除完成(可能长达3秒)
    while (spi_nor_busy()) {
        // 可以在这里喂看门狗
    }
    return 0;
}

擦除时间太长,容易触发看门狗复位。我一般会在擦除循环里加个计数器,每轮询100次就喂一次狗。

3.3 Flash驱动架构设计

驱动架构怎么搭?我习惯分层设计,这样换Flash型号时不用重写所有代码。

Flash驱动分层架构 应用层(FATFS / 固件升级 / 日志系统) Flash抽象层(读/写/擦除/坏块管理接口) 驱动层(SPI/QSPI/ONFI 时序控制、命令封装) 硬件层(Flash芯片、PCB走线、电源) 设计要点: • 抽象层定义统一接口,屏蔽不同Flash的差异 • 驱动层只处理时序和命令,不关心上层业务

你看,这样分层之后,换Flash芯片只需要改驱动层。抽象层的接口不变,上层代码一行都不用动。我在一个项目里从W25Q64换到GD25Q64,只改了驱动层的命令表,半天搞定。

3.3.1 抽象层接口设计

抽象层接口要够通用,但又不能太抽象。我一般这样定义:

// Flash抽象层接口定义
typedef struct {
    int (*init)(void);
    int (*read)(uint32_t addr, uint8_t *buf, uint32_t len);
    int (*write)(uint32_t addr, const uint8_t *buf, uint32_t len);
    int (*erase)(uint32_t addr, uint32_t len);
    int (*sync)(void);  // 确保数据落盘
    int (*ioctl)(uint32_t cmd, void *arg);  // 控制命令
} flash_ops_t;

ioctl用来处理一些特殊操作,比如获取芯片ID、设置保护区域、查询状态等。这样接口不会太臃肿。

3.4 磨损均衡与坏块管理

Flash有寿命限制——NOR大概10万次擦写,NAND只有几千到几万次。如果不做磨损均衡,某些区域频繁写入会先挂掉。

3.4.1 磨损均衡(Wear Leveling)

说白了,就是把写操作均匀分布到所有块上。常见策略有两种:

  • 动态磨损均衡: 每次写数据时,找擦除次数最少的块来写。实现简单,但静态数据(很少改动的数据)所在的块永远不被均衡。
  • 静态磨损均衡: 连静态数据也定期搬移,让所有块的擦除次数趋于一致。效果好,但算法复杂,有额外开销。

我做过一个数据采集器,每天写几百KB日志。用动态磨损均衡跑了两年,发现有些块擦除了5万次,有些才几百次——静态数据块一直没动。后来加了静态均衡,才把差距拉回来。

经验数据: 对于NAND Flash,建议将磨损均衡阈值设为块最大擦除次数的80%。比如标称10万次,8万次时就标记为“接近寿命”,减少写入。

3.4.2 坏块管理(Bad Block Management)

NAND Flash出厂就有坏块,使用过程中还会产生新坏块。坏块管理是必须的,不是可选项。

坏块管理分两步:

  1. 扫描坏块: 上电时扫描所有块,读取每个块第一个页的OOB区域。如果OOB的第一个字节不是0xFF,就是出厂坏块。
  2. 运行时检测: 每次写/擦操作后检查状态寄存器。如果返回失败,标记为坏块,把数据搬移到备用块。
// 坏块检查示例(NAND Flash)
uint8_t nand_is_bad_block(uint32_t block_addr) {
    uint8_t oob_data[64];
    uint32_t page_addr = block_addr * pages_per_block;
    
    // 读取第一个页的OOB区域
    nand_read_oob(page_addr, oob_data, 64);
    
    // 出厂坏块标记:OOB第一个字节不为0xFF
    if (oob_data[0] != 0xFF) {
        return 1;  // 坏块
    }
    return 0;
}
千万别踩的坑: 坏块表要保存在Flash里,但保存坏块表的块本身也可能变坏。我一般存两份坏块表,一份在主区域,一份在备份区域。如果主表校验失败,就用备份表重建。

3.4.3 磨损均衡与坏块管理的协同

这两个机制要配合好。我的做法是:

  • 磨损均衡模块维护一个“擦除次数表”,记录每个块的擦除次数
  • 坏块管理模块维护一个“坏块表”,记录所有坏块
  • 分配写入块时,先查坏块表,跳过坏块;再从剩余块中选擦除次数最少的

说起来简单,实现时要注意:擦除次数表本身也要做磨损均衡,不然它自己先挂了。我见过一个产品,擦除次数表存在固定块里,结果那个块先报废了,整个设备跟着瘫痪。

我的建议: 擦除次数表用日志结构存储,每次更新追加写到新位置。这样既均衡了磨损,又防止了单点故障。

好了,Flash驱动开发的核心内容就这些。从类型选择到读写擦操作,从分层架构到磨损均衡和坏块管理,每一步都有讲究。做Bootloader时,Flash驱动是基础中的基础——驱动写不好,后面所有功能都是空中楼阁。