拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

W25Q16与FATFS移植实战:从SPI协议到扇区管理优化

W25Q16与FATFS移植实战:从SPI协议到扇区管理优化

前阵子有个做物联网网关的朋友又来找我,说照着网上教程把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 EEPROM2KB~256KB约100万次慢少量频繁改写参数
W25Q162MB约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 移植前必须想清楚的四层结构

作为一个做过两三次移植的人,我强烈建议动手前先把层次关系在脑子里过一遍。从底往上依次是:

  1. SPI硬件层:负责把字节按SPI时序发出去
  2. W25Q16驱动层:把SPI收发封装成Read、Write、Erase这种Flash操作
  3. FATFS diskio层:把Flash操作包装成FATFS能识别的disk_read、disk_write、disk_ioctl
  4. 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真正必须的只有五个:

指令码功能说明
0x03Read Data从指定24位地址连续读数据
0x06Write Enable擦写操作前必须发,置位状态寄存器WEL位
0x02Page Program向指定地址写入1~256字节
0x05Read Status Register读状态寄存器,bit0为BUSY
0x20Sector 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尾部划一个备份块,写入流程改成:

  1. 把旧块内容复制到备份块
  2. 按原流程擦写目标块
  3. 写入完成后更新备份区的"有效版本号"

上电启动时检查目标块和备份块的有效版本号,如果发现目标块擦写到一半(版本号对不上),就用备份块恢复。这个方案在嵌入式设备里叫双备份,代码量不大,但能显著提高可靠性。如果你做的是电池供电、随时可能掉电的设备,这个优化比前面的磨损均衡还重要。

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那一层。你在这个函数里多花一天做读改写和优化,设备在用户手里就能多活几年。移植完成后,建议专门写一个压力测试程序,循环写入、随机掉电、再上电校验数据完整性,只要这个测试能连续跑几天不出问题,这版移植才算真正合格。

返回列表