前阵子有个做物联网网关的朋友又来找我,说照着网上教程把FATFS挂到W25Q16上,f_mount返回FR_OK,建文件也正常,可跑了个把小时,往里面写日志后再上电,文件系统直接挂了,返回FR_NO_FILESYSTEM。我远程看了他的底层代码,问题一目了然:disk_write里把512字节的逻辑扇区直接当成了Flash物理扇区,也没做擦除,纯靠内存里的数据"以为写进去了"。这个坑其实很多人都踩过。
W25Q16这种SPI NOR Flash,容量不大(2MB),但胜在便宜、接口简单、引脚占用少,很适合在STM32项目里做日志存储、字库存储、固件升级缓存这类活。而FATFS是嵌入式领域最常用的文件系统,两者组合是经典搭配。但有个关键矛盾很多人没意识到:FATFS的读写单位是512字节的逻辑扇区,W25Q16的擦除单位却是4KB的物理扇区,两者之间差了8倍。这个不对齐,不只是性能问题,而是直接决定你的Flash会不会在几个月内被写报废、掉电后文件系统会不会损坏。
这篇文章我就从头到尾把整条链路走一遍,SPI通信怎么配、W25Q16驱动怎么写、diskio怎么移植、以及标题里那个"扇区管理优化方案"到底优化了什么。整个过程我会用HAL库的写法给完整代码,也会讲清楚每一步背后的原因。
1. 为什么是W25Q16+FATFS:存储需求与选型逻辑
1.1 W25Q16的基本盘:2MB能干什么
先看芯片本身。W25Q16是华邦的SPI NOR Flash,16Mbit,也就是2MB。别嫌小,在MCU场景里这个容量能干的活不少:
- 存中文字库:16x16点阵的GB2312全字库大约2MB左右,W25Q16刚好能放下常用字库子集
- 存运行日志:按每条约100字节算,2MB能存约2万条,配合循环覆盖可以跑很久
- 存固件升级包:STM32F103这类芯片的固件一般几十到几百KB,2MB绰绰有余
- 存参数配置和BootLoader备份区:这反而是它最擅长的场景
它和SD卡、EEPROM、内部Flash的对比也值得说一下,我平时选型基本按这个逻辑:
| 存储介质 | 容量 | 擦写寿命 | 写入速度 | 适合场景 |
|---|---|---|---|---|
| 内部Flash | 几十~几百KB | 约1万次 | 较快 | 参数存储、固件 |
| I2C EEPROM | 2KB~256KB | 约100万次 | 慢 | 少量频繁改写参数 |
| W25Q16 | 2MB | 约10万次 | 中等 | 字库、日志、升级包 |
| SD卡 | 大 | 与磨损均衡相关 | 快 | 大容量数据采集 |
注意一个数字:W25Q16的擦写寿命标称是10万次(每个扇区)。这个数字看着不小,但在文件系统场景下如果实现得不好,几天就能耗尽,后面第五节我会专门算这笔账。
1.2 为什么需要FATFS:裸Flash的痛点
如果不加文件系统,直接往W25Q16里读写数据,你会立刻碰到几个问题:
- 不知道哪些地址被用了,哪些是空的
- 数据长度变了之后,旧数据和文件头对不上
- 想按文件名找数据?完全没有这个概念
- 写了一半掉电,整个存储区域变成垃圾,而且不知道哪里是垃圾
FATFS干的事就是把这一切封装起来。它帮你维护FAT表、目录项、簇的分配与释放,你只需要f_open、f_write、f_read、f_close,跟用电脑上的文件系统一样。在MCU上跑FATFS是嵌入式开发里的标配操作,而且R0.15版本对资源要求不高,一个STM32F103就完全带得动。
1.3 移植前必须想清楚的四层结构
作为一个做过两三次移植的人,我强烈建议动手前先把层次关系在脑子里过一遍。从底往上依次是:
- SPI硬件层:负责把字节按SPI时序发出去
- W25Q16驱动层:把SPI收发封装成Read、Write、Erase这种Flash操作
- FATFS diskio层:把Flash操作包装成FATFS能识别的disk_read、disk_write、disk_ioctl
- FATFS应用层:直接调用f_mount、f_open、f_write等API
这篇文章的标题"从SPI协议到文件系统",本质上就是在讲这四层怎么打通。很多人移植失败,就是因为跳过了某一层——比如以为FATFS可以直接调HAL的SPI接口,或者把disk_write实现成"直接写Flash不管擦除",那后面出问题就一点都不奇怪了。
2. 移植前的通信底座:SPI协议与HAL库配置细节
2.1 SPI四线制与时序模式的选择
SPI全称是Serial Peripheral Interface,四根线:SCK(时钟)、MOSI(主输出从输入)、MISO(主输入从输出)、CS(片选,低有效)。它是全双工通信,主设备发时钟的同时,Master发一个字节的同时也能收一个字节。这个特性在下面读Flash的时候会用到,HAL库对应的接口是HAL_SPI_TransmitReceive,收发同时完成。
SPI有两个关键参数:时钟极性(CPOL)和时钟相位(CPHA)。CPOL决定空闲时SCK是高还是低,CPHA决定数据在第一个还是第二个边沿采样。组合出来四种模式。W25Q16数据手册明确写了支持SPI Mode 0和Mode 3,也就是CPOL=0/CPHA=0和CPOL=1/CPHA=1。实测中两种都能正常通信,但网上绝大多数教程默认用Mode 0。我的建议是跟着用Mode 0,省得后续和别人代码对比时还要纠结时序。
判断SPI配置是否正确的标准方式不是看示波器,而是发一条JEDEC读ID指令(0x9F),能返回0xEF 0x40 0x15就说明时序通了。
2.2 CubeMX里的SPI配置要点
在STM32CubeMX里配置SPI1(这里以SPI1为例),关键项如下:
- Mode:Full-Duplex Master
- Clock Polarity:Low
- Clock Phase:1 Edge
- Prescaler:根据主频算,确保SCK不超过W25Q16支持的最大时钟(W25Q16JV标称104MHz,但STM32F103的SPI1最多跑到18MHz左右,所以基本不构成瓶颈)
- Data Size:8 Bit
- First Bit:MSB First
- NSS:Disable(这个特别重要)
关于NSS,我专门说一下为什么很多教程都建议用软件片选。硬件NSS在HAL库里的行为比较迷,容易在主从切换时产生意料之外的片选拉低,而且多设备挂同一SPI总线时软件控制更灵活。CubeMX里把NSS设成Disable,然后在代码里随便找个GPIO当CS用,代码里手动拉低拉高,这是最省心的方式。
2.3 轮询、中断还是DMA:HAL库API的选择
HAL库给SPI提供了三套收发接口:轮询(HAL_SPI_Transmit)、中断(HAL_SPI_Transmit_IT)、DMA(HAL_SPI_Transmit_DMA)。在W25Q16这种纯命令应答式的设备上,我的建议是:
- 移植阶段用轮询,逻辑简单,出错好排查
- 如果在RTOS环境里,或者需要长时间连续读写数据(比如往Flash里批量写固件),用DMA配合信号量,别用轮询阻塞任务
还有一点想顺带提一下:如果你对时序要求极苛刻,可以考虑SPI用LL库,LL库的收发是寄存器级操作,比HAL少一层状态机判断,吞吐量能高一些。但对W25Q16这种IO密集型外设来说,瓶颈通常不在HAL库的CPU开销,而是在Flash本身的擦写时间上。所以大部分场景HAL轮询就够用,没必要一上来就折腾LL。
2.4 用读ID验证通信链路
CubeMX生成工程后,先把SPI链路验证通了再往下走。读ID的代码很简单:
uint32_t W25Q16_ReadJEDEC_ID(void) { uint8_t tx[4] = {0x9F, 0x00, 0x00, 0x00}; uint8_t rx[4] = {0}; W25Q16_CS_Low(); HAL_SPI_TransmitReceive(&hspi1, tx, rx, 4, 100); W25Q16_CS_High(); // W25Q16 期望返回:0xEF 0x40 0x15 return (rx[1] << 16) | (rx[2] << 8) | rx[3]; }用调试器看返回值,如果返回0xEF4015,说明SPI时序、引脚连接、CS控制全部正常。如果读到0xFFFFFF或者0x000000,优先检查MISO/MOSI是不是接反了、CS有没有正确拉低、SPI模式是不是选错了。这一步排查成本最低,后面所有问题都是基于链路正常的前提。
3. W25Q16驱动层:指令集与HAL库实现要点
3.1 必须掌握的五个Flash操作指令
W25Q16数据手册指令很多,但移植FATFS真正必须的只有五个:
| 指令码 | 功能 | 说明 |
|---|---|---|
| 0x03 | Read Data | 从指定24位地址连续读数据 |
| 0x06 | Write Enable | 擦写操作前必须发,置位状态寄存器WEL位 |
| 0x02 | Page Program | 向指定地址写入1~256字节 |
| 0x05 | Read Status Register | 读状态寄存器,bit0为BUSY |
| 0x20 | Sector Erase | 擦除4KB扇区 |
抓住一个核心逻辑就全通了:NOR Flash写入只能把1写成0,要把0改回1必须先擦除。所以每次写之前,要么目标地址本来就是0xFF(没写过),要么必须先擦除整个扇区。这是后面所有优化的出发点。
3.2 状态寄存器与WaitBusy
Flash执行擦除和编程需要时间,页编程典型0.7ms,扇区擦除典型150ms(最大400ms)。判断操作完成的方式是轮询状态寄存器(0x05)的bit0:
static void W25Q16_WaitBusy(void) { uint8_t tx[2] = {0x05, 0x00}; uint8_t rx[2] = {0}; uint32_t timeout = 0xFFFFFF; do { W25Q16_CS_Low(); HAL_SPI_TransmitReceive(&hspi1, tx, rx, 2, 100); W25Q16_CS_High(); } while ((rx[1] & 0x01) && --timeout); if (timeout == 0) { // 处理超时,这里根据项目需要加错误上报 } }这里有个细节值得注意:发完0x05之后HAL的TransmitReceive第二个字节同时收回了状态值,所以rx[1]才是状态寄存器的内容。很多新手容易把tx[0]当成rx的内容,调半天发现busy标志永远不对。
3.3 页编程的边界回卷坑
W25Q16一次最多写256字节(一个Page),但还有一个坑很多人不知道:如果写入长度跨越了页边界,Flash内部地址会自动回卷到本页开头,而不是自动换页。也就是说,你往地址0x1F0写入40字节,最后16字节不会写到0x220,而是回到0x200覆盖你之前写的数据。这种错误非常隐蔽,因为单次读取时数据好像"对"的,只有对比尾部数据时才会发现丢了。
解决方法是驱动层按页切分写入:
static void W25Q16_WriteData(uint32_t addr, const uint8_t *pData, uint32_t len) { while (len > 0) { uint16_t page_remain = 256 - (addr & 0xFF); // 当前页剩余字节 uint16_t chunk = (len < page_remain) ? (uint16_t)len : page_remain; W25Q16_WriteEnable(); uint8_t hdr[4] = {0x02, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr}; W25Q16_CS_Low(); HAL_SPI_Transmit(&hspi1, hdr, 4, 100); HAL_SPI_Transmit(&hspi1, (uint8_t *)pData, chunk, 100); W25Q16_CS_High(); W25Q16_WaitBusy(); addr += chunk; pData += chunk; len -= chunk; } }有这个函数之后,往任意地址写任意长度都安全,FATFS的那些512B级别的写入都会被这个函数安全拆分。
3.4 擦除操作:扇区擦除就够了
W25Q16提供三种擦除粒度:Sector Erase(4KB)、Block Erase(64KB)、Chip Erase(整片)。日常开发中90%的场景用扇区擦除就够了。
void W25Q16_EraseSector(uint32_t addr) { uint8_t cmd[4] = {0x20, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr}; W25Q16_WriteEnable(); W25Q16_CS_Low(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); W25Q16_CS_High(); W25Q16_WaitBusy(); }注意擦除地址必须4KB对齐。FATFS的disk_write里你可能会传入任意512B对齐的地址,所以这一步的地址对齐逻辑要放在diskio层去处理,后面会说。
4. FATFS diskio移植:512字节逻辑扇区与4KB物理块的映射
4.1 先搞懂FATFS的扇区模型
FATFS把存储介质抽象成一个一个的逻辑扇区(Logical Block Address),默认每个扇区512字节。对FATFS而言,整个磁盘就是LBA 0到LBA N-1的线性地址空间,它不关心底层介质物理上是怎么组织擦除的。
而W25Q16呢,物理上擦除单位是4KB扇区,读/写单位是字节(写受页限制)。这中间就差出一个关键映射问题:FATFS认为写LBA 100只影响LBA 100那512字节,但底层Flash要写这512字节,可能得先擦除整个4KB块。如果实现不当,每次写512字节都擦4KB,性能差不说,寿命损耗直接放大8倍。
W25Q16 2MB容量对应4096个512B逻辑扇区,对应512个4KB物理块。映射关系是:
- 逻辑扇区sector对应的物理块号:
sector >> 3 - 逻辑扇区在物理块内的偏移:
(sector & 7) << 9(也就是低3位扇区号乘以512) - 物理地址:
(sector >> 3) << 12再加上块内偏移
4.2 ffconf.h关键配置
FATFS移植第一步是配置ffconf.h。针对W25Q16这个使用场景,需要重点确认的配置项:
#define FF_USE_MKFS 1 // 允许格式化,新Flash第一次挂载前必须格式化成文件系统 #define FF_USE_STRFUNC 0 // 默认关掉printf格式化写文件,节省代码空间 #define FF_USE_LFN 1 // 支持长文件名,默认1;如果存中文文件名建议开 #define FF_MIN_SS 512 #define FF_MAX_SS 512 // 固定512字节逻辑扇区 #define FF_VOLUMES 1 // 只挂载一个卷4.3 四个diskio函数怎么实现
FATFS通过diskio.c里的四个函数访问底层介质。这里给出一个能正确工作的最小实现,重点看disk_write。
disk_status和disk_initialize很简单,固定返回正常:
DSTATUS disk_status(BYTE pdrv) { if (pdrv != 0) return STA_NOINIT; return RES_OK; } DSTATUS disk_initialize(BYTE pdrv) { if (pdrv != 0) return STA_NOINIT; return RES_OK; }disk_read实现相对简单,因为读操作不需要先擦除,W25Q16也支持连续跨页读取:
DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv != 0) return RES_PARERR; W25Q16_ReadData((uint32_t)sector << 9, buff, (uint32_t)count << 9); return RES_OK; }disk_write是整个移植的核心,直接决定了正确性和寿命。下面这段代码实现了一个"按4KB物理块读改写"的方案:
static uint8_t blk_cache[4096]; // 4KB缓存 DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { if (pdrv != 0) return RES_PARERR; while (count > 0) { LBA_t cur_block = sector >> 3; // 当前物理块号 uint32_t blk_addr = (uint32_t)cur_block << 12; // 物理块起始地址 uint32_t off = (sector & 7) << 9; // 块内偏移(字节) uint32_t remain = 4096 - off; // 块内剩余字节 uint32_t chunk = (uint32_t)count << 9; // 本次要写的字节数 if (chunk > remain) chunk = remain; // 情况A:整块写满4KB,直接擦除+写入 if (off == 0 && chunk == 4096) { W25Q16_EraseSector(blk_addr); W25Q16_WriteData(blk_addr, buff, 4096); } // 情况B:没写满,读改写 else { W25Q16_ReadData(blk_addr, blk_cache, 4096); memcpy(blk_cache + off, buff, chunk); W25Q16_EraseSector(blk_addr); W25Q16_WriteData(blk_addr, blk_cache, 4096); } sector += (chunk >> 9); buff += chunk; count -= (chunk >> 9); } return RES_OK; }这段代码逻辑上分了情况A和情况B。情况A是理想情况,FATFS恰好连续写了8个逻辑扇区,我们把整个4KB块擦掉再写,干净利落。但实际FATFS经常只写1~2个逻辑扇区(比如更新一个目录项),这时就得走情况B的读改写流程:先把整个4KB块读到缓存,在RAM里修改对应的512字节,再擦除,最后整块写回。这样虽然每次还是擦了一个4KB块,但至少不会破坏块内其他扇区的数据。
disk_ioctl提供容量和几何信息:
DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { if (pdrv != 0) return RES_PARERR; switch (cmd) { case CTRL_SYNC: return RES_OK; case GET_SECTOR_COUNT: *(LBA_t *)buff = 4096; // 2MB / 512B = 4096个逻辑扇区 break; case GET_SECTOR_SIZE: *(WORD *)buff = 512; // 逻辑扇区大小 break; case GET_BLOCK_SIZE: *(DWORD *)buff = 8; // 一个物理块包含8个逻辑扇区 break; default: return RES_PARERR; } return RES_OK; }GET_BLOCK_SIZE返回8这个值很重要,FATFS格式化时会根据它尽量把簇对齐到4KB物理边界,能显著减少跨块读改写,后面优化里再展开说。
4.4 第一次挂载和格式化的顺序问题
新买的Flash默认全是0xFF,没有文件系统,直接f_mount会返回FR_NO_FILESYSTEM。正确流程是先格式化再挂载(或者挂载失败后格式化):
FATFS fs; BYTE work[4096]; void Fatfs_Init(void) { FRESULT res = f_mount(&fs, "", 1); if (res == FR_NO_FILESYSTEM) { res = f_mkfs("", FM_ANY, 0, work, sizeof(work)); if (res == FR_OK) { f_mount(NULL, "", 0); f_mount(&fs, "", 1); } } }注意f_mkfs需要一个4KB的工作缓冲(至少FF_MAX_SS大小),这个缓冲可以放全局数组或者malloc都行。格式化2MB的卷,FATFS会自动选择FAT16或者FAT12,不用管它。
5. 扇区管理优化:从"能跑"到"抗用"的四个关键手段
5.1 先算一笔账:为什么直接读改写会快速耗死Flash
W25Q16标称10万次擦写寿命。假设你做一个数据采集设备,每秒记录一条日志,并调用f_sync确保数据落盘。每秒一次sync,FATFS通常至少涉及两次写操作:一次写文件数据区(512B),一次更新FAT表(512B)。在未优化的情况下,这两次都会触发读改写,也就是每秒擦除两个4KB物理块。
一天有86400秒,一天擦除约17万次。即使这些擦写均匀分散在8个物理块上,每块每天也有约2.1万次擦写。按10万次寿命算,不到5天,FAT表所在的物理块就会达到寿命上限。而实际上FAT表更新通常集中在固定的几个块上,寿命消耗比这个模型还要快。
这就是标题里说的"扇区管理优化方案"的核心价值:不是让Flash能用,而是让它能用得久。
5.2 手段一:通过GET_BLOCK_SIZE对齐减少跨块写
前面disk_ioctl里我返回了GET_BLOCK_SIZE=8。这个值的作用是告诉FATFS:"底层最小的擦除块等于8个逻辑扇区"。FATFS在f_mkfs格式化时,会倾向于把簇大小设成8的整数倍,让FAT表、根目录、数据簇都尽量落在4KB物理边界上。这样FATFS在写入一个簇时,大概率命中完整的一个4KB物理块,能走disk_write里的情况A(整块擦写),避免读改写。
实际操作中你会发现格式化后簇大小自动变成了4KB或8KB,这就是对齐生效了。如果你的代码里GET_BLOCK_SIZE返回1或者忘了实现,格式化出来的文件系统很可能会出现一个簇跨两个物理块的情况,写一次数据擦两个块,寿命直接腰斩。
5.3 手段二:脏块缓存合并小写
读改写能保证正确性,但频繁小写还是会带来大量无谓擦除。一个更进一步的优化是在diskio层加一个"脏块缓存":把最近写入的几个物理块缓存在RAM里,只有满足以下条件时才真正落盘:
- 缓存块被写满4KB,直接擦除写入
- FATFS调用了CTRL_SYNC(对应f_sync/f_close/f_unmount)
- 缓存块即将被换出(LRU淘汰)
这个方案类似于操作系统的页缓存。对于日志型应用效果非常明显:FATFS先写数据区512B,再写FAT表512B,如果这两个512B落在不同物理块,脏块缓存不会立即擦写,而是等数据积累到4KB或f_sync时统一落盘,擦除次数可以降一个数量级。
实现起来会增加一个块管理结构和LRU链表,代码量不小。如果你的应用日志是周期性批量写的(比如一分钟攒一批),建议值得做。如果只是偶尔写几条数据,读改写就够了。
5.4 手段三:应用层的磨损均衡思路
如果你做过长周期运行的设备,会发现即使上面都做了,FAT表所在的物理块还是比数据区磨损快得多。FATFS本身没有磨损均衡机制,这是它的设计缺陷。在W25Q16这种小容量Flash上,一个务实的方案是在应用层做规避:
- 不要把FATFS卷铺满整个2MB,留出备用区块
- 日志文件按固定大小滚动:写满一个日志文件后关闭,写入下一个,形成环形文件组
- 定期通过f_unlink删除最老的日志文件
这样做的意义在于:文件数据通过FATFS的簇分配会散布到整个卷的不同物理块上,不会长时间集中写同一块。以2MB卷为例,日志区域有约512个物理块,每块10万次寿命,如果写入流量均匀分布,整体寿命能达到约5000万次擦写级,这对绝大多数嵌入式设备都够用了。
实现上只需要在应用层维护日志文件编号逻辑:
char log_path[16]; snprintf(log_path, sizeof(log_path), "log%03d.dat", file_index); // 写入到固定大小后关闭 // 当 file_index 到达上限时回绕到0,删除旧文件5.5 手段四:掉电保护
SPI NOR Flash最怕的就是擦除过程中掉电。情况A里的流程是"先擦后写",如果擦除完、数据还没写回去就掉电,这个物理块就变成一整个0xFF,块内所有文件数据全完。FATFS对这类损坏没有恢复能力,结果就是整个分区挂掉。
对关键数据(比如FAT表、根目录区),可以在Flash尾部划一个备份块,写入流程改成:
- 把旧块内容复制到备份块
- 按原流程擦写目标块
- 写入完成后更新备份区的"有效版本号"
上电启动时检查目标块和备份块的有效版本号,如果发现目标块擦写到一半(版本号对不上),就用备份块恢复。这个方案在嵌入式设备里叫双备份,代码量不大,但能显著提高可靠性。如果你做的是电池供电、随时可能掉电的设备,这个优化比前面的磨损均衡还重要。
6. 移植路上的真实坑:三个典型故障与排查链路
6.1 故障一:f_mount返回FR_DISK_ERR
现象:挂载失败,错误码为FR_DISK_ERR(也就是1)。
排查过程:FR_DISK_ERR来自diskio层返回的错误。顺着排查顺序走:
第一步,确认disk_read正常。单独读LBA 0的前几个字节,如果读出来全是0xFF,说明Flash确实还没格式化,但也说明读路径是通的。
第二步,确认disk_write正常。手动构造一个小测试:向LBA 0写512字节,再读回来对比。如果数据不一致,基本是写路径的擦除逻辑有问题。最常见的是disk_write里直接调W25Q16_WriteData,但没先擦除扇区。NOR Flash的特性决定了,不擦除就往非0xFF区域写,读出来的数据就是错乱的。
第三步,如果读写都正常还是FR_DISK_ERR,检查disk_ioctl是否实现了GET_SECTOR_COUNT。FATFS第一次挂载时要读介质容量,返回0或者没实现会导致FATFS内部直接判失败。
6.2 故障二:能挂载能写,掉电后文件系统损坏
现象:写日志写入半天,断电重启,f_mount返回FR_NO_FILESYSTEM。
这是很多人栽过的坑,根因往往是f_sync不够及时。FATFS默认有写缓存,f_write的数据先放在内存,要等f_sync或者f_close才真正落到disk_write。如果设备在没落盘时断电,丢的还只是几行日志;但如果在disk_write的"擦除后、写回前"掉电,整个物理块里的数据就没了。
修改建议:
- 日志类应用,每写几条就调一次f_sync
- 关键文件写完立即f_close
- 按照5.5节的方式给FAT表或整个卷做双备份
我见过有些项目组为了"性能"把f_sync频率降到很低,然后现场频繁掉电,几个月内损坏率居高不下。做数据记录设备,可靠性优先级应该高于写入性能。
6.3 故障三:擦写操作一直失败,读ID正常
现象:SPI读ID正常,但每次写数据或擦除后读回来还是0xFF,好像根本写不进去。
这种情况的排查要点是检查芯片的写保护位(BP3、BP2、BP1、BP0)和状态寄存器保护(SRP)。W25Q16的写保护状态存在状态寄存器里,出厂时默认不保护,但如果芯片之前被别的代码改过状态寄存器,或者误操作过Block Protection,就会出现"能读不能写"的诡异现象。
检查方式:
uint8_t W25Q16_ReadSR(void) { uint8_t tx[2] = {0x05, 0x00}; uint8_t rx[2] = {0}; W25Q16_CS_Low(); HAL_SPI_TransmitReceive(&hspi1, tx, rx, 2, 100); W25Q16_CS_High(); return rx[1]; }如果返回的bit7~bit2有非零值,说明有块保护生效。解除保护需要先写使能,再写状态寄存器清除保护位:
void W25Q16_DisableBlockProtect(void) { uint8_t cmd = 0x01; uint8_t sr_val = 0x00; W25Q16_WriteEnable(); W25Q16_CS_Low(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); HAL_SPI_Transmit(&hspi1, &sr_val, 1, 100); W25Q16_CS_High(); W25Q16_WaitBusy(); }你可能会问,项目里的Flash为什么会突然出现写保护?大概率是代码里有代码路径意外写了状态寄存器,或者芯片本身是拆机件/翻新件,状态寄存器被人为设置过。这也是为什么我建议新板子贴片后先做一次读SR的操作,确认芯片初始状态正常再开发上层功能。
6.4 通用的排查手段
最后分享一个排查手段:保留一个"裸Flash测试"的调试入口,绕开FATFS直接调W25Q16_ReadData和W25Q16_EraseSector。一旦FATFS层出问题,可以随时用这个入口确认底层驱动是否正常。底层正常了,问题就锁定在diskio映射或者FATFS层,排查范围缩小一大半。我在实际项目里一直保留这个调试入口,效果很好。
根据我个人的实践经验,这个W25Q16+FATFS的项目,最值得花时间的不是SPI配置也不是FATFS API,而是disk_write那一层。你在这个函数里多花一天做读改写和优化,设备在用户手里就能多活几年。移植完成后,建议专门写一个压力测试程序,循环写入、随机掉电、再上电校验数据完整性,只要这个测试能连续跑几天不出问题,这版移植才算真正合格。