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再执行——多了一步,麻烦。
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等待数据就绪。
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;
}
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芯片只需要改驱动层。抽象层的接口不变,上层代码一行都不用动。我在一个项目里从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万次,有些才几百次——静态数据块一直没动。后来加了静态均衡,才把差距拉回来。
3.4.2 坏块管理(Bad Block Management)
NAND Flash出厂就有坏块,使用过程中还会产生新坏块。坏块管理是必须的,不是可选项。
坏块管理分两步:
- 扫描坏块: 上电时扫描所有块,读取每个块第一个页的OOB区域。如果OOB的第一个字节不是0xFF,就是出厂坏块。
- 运行时检测: 每次写/擦操作后检查状态寄存器。如果返回失败,标记为坏块,把数据搬移到备用块。
// 坏块检查示例(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;
}
3.4.3 磨损均衡与坏块管理的协同
这两个机制要配合好。我的做法是:
- 磨损均衡模块维护一个“擦除次数表”,记录每个块的擦除次数
- 坏块管理模块维护一个“坏块表”,记录所有坏块
- 分配写入块时,先查坏块表,跳过坏块;再从剩余块中选擦除次数最少的
说起来简单,实现时要注意:擦除次数表本身也要做磨损均衡,不然它自己先挂了。我见过一个产品,擦除次数表存在固定块里,结果那个块先报废了,整个设备跟着瘫痪。
好了,Flash驱动开发的核心内容就这些。从类型选择到读写擦操作,从分层架构到磨损均衡和坏块管理,每一步都有讲究。做Bootloader时,Flash驱动是基础中的基础——驱动写不好,后面所有功能都是空中楼阁。