☰
STM32实战:W25Q16 SPI Flash上移植FATFS与扇区管理优化
2026/10/3 14:37:12 网站建设 项目流程

先声明一下,这篇文章不是泛泛的移植教程。我假设你已经能用HAL库点灯、跑串口,但对SPI和文件系统还处于“知道名字、没真正打通”的状态。我会把从SPI协议本身、HAL库驱动W25Q16、到FATFS移植,再到扇区管理优化这一条完整链路全部拆开讲。尤其是最后一部分,是在实际项目里被逼出来的方案,市面上很少有文章会讲得这么细。如果你是想给板子加个掉电不丢的数据存储区、存字库、存日志,或者单纯想搞懂FATFS底层到底做了什么,这篇文章会非常对胃口。

1. 先想清楚:为什么要在W25Q16上挂文件系统

1.1 从裸地址读写到文件系统的跨越

很多人在STM32上用过W25Q16,基本都是调用封装好的函数:擦除扇区、页编程、读数据。直接操作Flash不是不行,但一旦涉及“数据管理”就会很痛苦。比如你要存一批传感器历史数据,每个记录有固定结构,还要按时间查询,用裸地址读写得自己维护索引、算偏移、防覆盖,稍不留神就会读到脏数据。而文件系统就是来解决这个问题的:它把整块Flash抽象成“文件夹+文件”,你只需要用f_open、f_write、f_read就完成了数据的组织和管理,底层擦写细节全部交给文件系统去调度。

这也是我为什么推荐在W25Q16这类SPI Flash上跑FATFS的原因——虽然很多人觉得FATFS是给SD卡用的,但FATFS本身设计上就是纯逻辑层,底层存储介质它不管,只要能按扇区读写、提供必要参数,它就能跑。W25Q16只有2MB空间,看起来不大,但存配置参数、字库、短日志完全够用。跑上文件系统后,数据管理思路瞬间变清晰了。

1.2 方案选型:SPI、W25Q16与FATFS的适配逻辑

先说结论再分析:我用的是STM32F103系列 + 硬件SPI1 + W25Q16 + 正点原子风格的FATFS迁移方式,最终把FATFS的扇区大小配置成4096字节(4KB)。选这套组合有以下几个考量:

  • SPI Flash为什么不用I2C Flash?速度和容量。W25Q16最高支持104MHz的时钟,实际跑上几十MHz对HAL库来说很轻松,而且SPI四线(SCK、MOSI、MISO、CS)在底层接线和逻辑上都很固定,出问题好排查。
  • 为什么用硬件SPI而不是IO模拟?特指在HAL库场景下,硬件SPI配合DMA的吞吐能力远胜GPIO模拟,而且代码可读性好。虽然模拟SPI可以随便挑引脚,但一旦涉及大文件读写,速度差距会非常明显。
  • 为什么选FATFS而不是LittleFS或SPIFFS?生态和通用性。FATFS跨平台、文档全、Windows直接能读盘,调试时把Flash内容导出,电脑上就能分析;LittleFS在掉电保护和磨损均衡上更强,但调试工具链不如FATFS直接。我倾向于在项目初期用FATFS验证功能,再进行文件系统替换。
  • 扇区大小的选择为什么是个关键决策?这是本篇文章的隐藏主线。W25Q16的最小擦除单位是4KB扇区,而FATFS源码里默认扇区大小是512字节(按SD卡老传统来的)。这两者如果不做适配,后果就是写一个文件时底层擦除次数暴涨、寿命急剧衰减、速度惨不忍睹。后面第3章我会详细讲怎么处理这个问题。

2. 底层驱动:HAL库配置SPI与W25Q16通讯

2.1 SPI协议快速回顾:四根线怎么配合工作

SPI协议本身不复杂,但值得花时间夯实基础,因为W25Q16的所有操作都建立在SPI时序上。SPI是主从结构,STM32作为主机,W25Q16作为从机。四根线分别是:

  • SCK(时钟线):主机产生时钟信号,决定数据同步节奏。
  • MOSI(主机输出从机输入):主机向从机发送数据(比如命令、地址、写数据)。
  • MISO(主机输入从机输出):从机向主机回传数据(比如读到的状态、数据)。
  • CS(片选线):主机通过拉低CS选中目标从机,低电平有效。

SPI没有像I2C那样的应答机制,它靠时钟边缘采样数据。数据在SCK的上升沿或下降沿被锁存,具体是哪个沿,由CPOL(时钟极性)和CPHA(时钟相位)决定。W25Q16支持方式0(CPOL=0,CPHA=0)和方式3(CPOL=1,CPHA=1),这是Flash手册里明确写的。我用的是模式0:空闲时SCK为低电平,数据在上升沿采样。这种模式最通用,和很多传感器芯片都能兼容,排查问题也方便。

2.2 HAL库SPI初始化配置实战

在STM32CubeMX里配置SPI1的步骤如下,每一步后面跟我的实际操作说明:

  • Mode:选择Full-Duplex Master,因为W25Q16需要读也需要写。
  • Hardware NSS:建议Disable。W25Q16的CS脚用普通GPIO软件控制就行,硬件NSS在多从机场景下反而容易出问题。
  • Data Size:8 Bits,W25Q16的命令、地址、数据都是按字节组织的。
  • CPOL/CPHA:Low/1Edge,对应模式0。
  • BaudRate Prescaler:分频系数看你的系统时钟。我当前系统时钟72MHz,预分频4,得到18MHz的SPI时钟。W25Q16完全支持这个频率,但如果你的板子走线比较长或者杜邦线连接,时钟频率太高容易在上升沿采样到不稳定信号,此时可以适当将分频系数调大,比如8或16,牺牲速度换稳定。

代码层面,初始化后核心就是HAL_SPI_Transmit、HAL_SPI_Receive和HAL_SPI_TransmitReceive这三个函数。这里有一个关键的坑:HAL库的SPI收发函数是“阻塞式”的,调用时会把整包数据发完才返回。对于W25Q16这种发一个命令、回一长串数据的芯片,单纯交替使用Transmit和Receive容易出问题——因为W25Q16的时序要求主机持续给SCK,从机才能把数据推出来。正确做法是使用HAL_SPI_TransmitReceive同时发送和接收:发什么无所谓(通常发0xFF),重要的是提供时钟。

我封装了底层SPI收发函数,长这样:

// 读取W25Q16一字节:发0xFF提供时钟,同时收数据 static uint8_t W25Q16_SPI_ReadByte(void) { uint8_t tx_data = 0xFF; uint8_t rx_data = 0x00; HAL_SPI_TransmitReceive(&hspi1, &tx_data, &rx_data, 1, HAL_MAX_DELAY); return rx_data; }

2.3 W25Q16核心指令实现:读ID、写使能、页编程与扇区擦除

W25Q16的指令定义在数据手册第25页左右有一张大表,实际用到的其实就那么几个。我列一下最核心的:

功能指令码说明
JEDEC ID读0x9F读取厂家ID、内存类型、容量,用于识别芯片
读数据0x03任意地址读,最常用
写使能0x06写操作前必须先发该指令
页编程0x02以页为单位(256字节)写入,从任意地址开始,但一页内连续写,跨页要拆分
扇区擦除0x20擦除4KB扇区,擦后该扇区全部读为0xFF
块擦除0xD8擦除64KB块,很少用到
读状态寄存器10x05检查是否忙(BUSY位)

读ID是最简单的验证手段。拉低CS,发送0x9F,然后连续读三个字节。W25Q16的JEDEC ID通常是0xEF 0x40 0x15。我每次初始化第一件事就是读ID,如果返回全0xFF或者全0x00,直接报错,不再往下走——这一步能筛掉绝大部分SPI接线和配置错误。

uint8_t W25Q16_ReadID(void) { uint8_t id[3] = {0}; CS_LOW(); W25Q16_SPI_ReadWriteByte(0x9F); // 发送读ID命令 id[0] = W25Q16_SPI_ReadWriteByte(0xFF); id[1] = W25Q16_SPI_ReadWriteByte(0xFF); id[2] = W25Q16_SPI_ReadWriteByte(0xFF); CS_HIGH(); return (id[0] == 0xEF) ? 1 : 0; }

写操作的时序是:写使能 → 发送页编程命令0x02 → 发送24位地址 → 发送数据。注意Flash特性:只能把1写成0,不能直接写回1。所以写入地址对应的扇区如果已经有数据,必须先擦除。这也是后面第4章优化方案的底层根源。

扇区擦除就一个命令加地址,但需要等待内部操作完成。判断依据是读状态寄存器的BUSY位(bit 0),忙的时候为1,闲下来为0。等待超时需要设置,防止芯片异常卡死。

void W25Q16_EraseSector(uint32_t sector_addr) { W25Q16_WriteEnable(); CS_LOW(); W25Q16_SPI_ReadWriteByte(0x20); // 扇区擦除命令 W25Q16_SPI_ReadWriteByte((sector_addr >> 16) & 0xFF); W25Q16_SPI_ReadWriteByte((sector_addr >> 8) & 0xFF); W25Q16_SPI_ReadWriteByte(sector_addr & 0xFF); CS_HIGH(); W25Q16_WaitBusy(); }

3. FATFS移植:把文件系统“塞”进2MB Flash

3.1 FATFS的目录结构与移植要做的事

FATFS源码下载下来之后,核心目录结构大概是这样的:ff.c、ff.h、diskio.c、diskio.h、ffconf.h。其中ff.c是文件系统逻辑实现,正常情况下你不需要动它;ffconf.h是配置头文件,决定FATFS的裁剪程度和功能开关;diskio.c是你唯一需要完全自己写的文件,它实现了FATFS和底层硬件的桥梁。

移植FATFS这件事,本质就是实现diskio里这几个函数:

  • disk_initialize:初始化底层存储介质(调用W25Q16_Init)。
  • disk_status:返回存储介质状态(正常就返回0)。
  • disk_read:从指定扇区读取数据。
  • disk_write:向指定扇区写数据。
  • disk_ioctl:控制函数,获取扇区大小、扇区数量、块擦除大小等,还会接收同步请求。
  • get_fattime:获取当前时间(如果开启了FF_FS_RTC)。

3.2 diskio底层接口逐个实现

我把最关键的disk_read和disk_write单独拿出来说。

disk_read的入参是缓冲区指针pdrv(物理驱动器编号)、buff(数据缓冲区)、sector(起始扇区号)、count(扇区数量)。对W25Q16来说,我需要把扇区号换算成字节地址:

#define FLASH_SECTOR_SIZE 4096 // 和FATFS配置保持一致 DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv != 0) return RES_PARERR; uint32_t addr = sector * FLASH_SECTOR_SIZE; W25Q16_ReadData(addr, buff, count * FLASH_SECTOR_SIZE); return RES_OK; }

disk_write同理,但这里藏着一个大坑:FATFS会调用disk_write来写数据,但底层Flash必须先擦除才能写。如果disk_write直接调用页编程,写入之前没有擦除,那么原本里面是0xFF的位置会变成你写入的数据,但原本是0x00的位置永远写不回1。最终读出来的数据就是错的。

我最初实现时犯了所有新手都会犯的错:直接在disk_write里先擦除扇区再写数据。这样确实能保证数据正确,但问题巨严重——文件系统写入一个文件时往往只改几个字节,我却把它所在的整个4KB扇区擦掉再重写,哪怕只写了一个字节也要擦除一次。日志型应用写不了几年Flash就废了。这也是为什么第4章的优化方案更重要。先看基础版本实现,优化方案放在第4章统一讲:

DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { if (pdrv != 0) return RES_PARERR; uint32_t addr = sector * FLASH_SECTOR_SIZE; // 注意:此为基础实现,未做擦写优化,实际使用时需配合缓存机制 for (UINT i = 0; i < count; i++) { W25Q16_EraseSector(addr + i * FLASH_SECTOR_SIZE); W25Q16_PageProgram(addr + i * FLASH_SECTOR_SIZE, (uint8_t *)buff + i * FLASH_SECTOR_SIZE, FLASH_SECTOR_SIZE); } return RES_OK; }

3.3 扇区大小难题:为什么要把FATFS配置成4096字节扇区

FATFS源码默认FF_MIN_SS和FF_MAX_SS都是512字节,因为SD卡的扇区就是512字节。但W25Q16的最小擦除单元是4096字节。这里存在两个选择:

方案一:让FATFS仍然使用512字节扇区,底层disk_write负责映射。不只是扇区擦除的粒度问题:W25Q16的页编程最多256字节,而FATFS按512字节扇区读写,这意味着每次扇区写至少要拆成两到三次页编程。更要命的是,一个文件系统的逻辑扇区只有512字节,但底层的物理擦除单位是4KB,文件系统完全不知道这4KB内其他512字节扇区是否需要保留,如果直接擦除会牺牲数据。你必须在RAM里开一个4KB的缓存扇区进行“读-改-写”,相当麻烦。

方案二:把FATFS的扇区大小也设为4096字节。这是我认为更合理的方案。在ffconf.h中把FF_MIN_SS和FF_MAX_SS都改成4096,这样文件系统层面的最小寻址单位就和Flash物理擦除单元严格对齐。FATFS在写数据时,一次disk_write的count单位就是4KB,底层直接一个扇区擦除+整扇区写入,逻辑清晰、效率高。

当然代价是:文件系统的簇大小也会变大(通常4096字节起步),存小文件时空间浪费会多一些。比如一个100字节的配置文件,按4096字节分配,实际占用的空间就是4096字节。2MB空间能存的文件数量也会受限于文件系统开销。但对于嵌入式场景,这个代价换来的寿命和速度提升完全值得。

具体配置如下:

// ffconf.h 中关键配置 #define FF_MIN_SS 4096 #define FF_MAX_SS 4096 #define FF_USE_MKFS 1 // 使能格式化功能 #define FF_FS_RTC 1 // 使能RTC时间,需要实现get_fattime

格式化时用f_mkfs,也要指定扇区大小:

FATFS fs; f_mkfs("", FM_FAT | FM_SFD, 4096, work_area, sizeof(work_area)); f_mount(&fs, "", 1);

这里FM_SFD表示不创建分区表,直接把FAT文件系统放到整个Flash上,因为2MB空间再建分区表纯属浪费。

4. 扇区管理优化:延长Flash寿命的关键方案

4.1 直接读写的隐患:擦除次数与写放大

W25Q16的擦写寿命标称是10万次,这个数字看起来不少,但实际应用里很容易被“写放大”效应消耗光。举个例子:设备每10秒写一条日志,每条日志追加到文件末尾。FATFS会更新文件大小信息,文件大小信息在目录项里,目录区所在扇区就会被频繁改写。如果没有优化,每次追加日志都可能触发一次整个4KB扇区的擦除加上重新写入。一天大约8640次追加,10万次寿命理论上11天半就耗尽了。实测当然不会这么极端,因为FATFS的写入缓存和文件系统更新策略会合并部分写入,但长期运行下的磨损依然非常可观。

所以这块的优化思路核心就是:尽量减少物理擦除次数,把随机小写入转化为连续大写入。

4.2 读写缓存合并与延迟擦除

我采用的方案是“RAM扇区缓存+延迟擦除”的组合拳,这是业界常见的做法,也是标题里“扇区管理优化方案”的主要实现。思路如下:

  • 在RAM里开辟一个4KB的缓存扇区(加上4096字节的缓冲,2MB的Flash配4KB RAM,完全可以接受)。
  • 当FATFS要写入数据时,不直接操作Flash,而是先把数据写入RAM缓存。
  • 当缓存被写满,或者文件系统做同步/关闭操作时,才执行一次扇区擦除和整扇区写入。

这样做的好处非常明显:假设FATFS在一个扇区里连续写了8次,每次512字节,第一次缓存加擦写要刷一整扇区,后面7次都命中缓存不触发擦写,物理擦除次数直接降为原来的1/8。如果应用层有顺序写入特性(比如日志),这个提升会更夸张,能到几十倍。

延迟擦除还要配一个脏扇区标记:RAM缓存被修改了但还没同步回Flash,此时这个扇区在Flash里仍是旧数据。只有在掉电前或定期调用f_sync、f_close时强制刷写缓存,才能保证数据不丢。这个机制在掉电保护上不如LittleFS的日志式设计,但配合超级电容或大电容给掉电留出刷写时间,应用层体验完全够用。

我实现的缓存概念图(不画mermaid,直接写文字流程):

写入流程:FATFS调用disk_write → 判断目标扇区号是否命中缓存 → 命中则直接在RAM缓存中修改 → 未命中则先把缓存刷回Flash,再把目标扇区读到缓存并修改 → 标记为脏。

同步/关闭流程:应用调用f_sync/f_close → 触发disk_ioctl中的CTRL_SYNC → 检查脏标记 → 如果脏则擦除对应Flash扇区并写入整个缓存。

代码框架可以这样搭建:

typedef struct { uint32_t sector_no; // 当前缓存的扇区号 uint8_t dirty; // 脏标记 uint8_t valid; // 缓存有效标记 uint8_t data[4096]; } flash_cache_t; static flash_cache_t s_cache; #define CACHE_INVALID_SECTOR 0xFFFFFFFF void cache_init(void) { s_cache.sector_no = CACHE_INVALID_SECTOR; s_cache.dirty = 0; s_cache.valid = 0; } DRESULT cache_flush(void) { if (s_cache.dirty && s_cache.valid) { W25Q16_EraseSector(s_cache.sector_no * FLASH_SECTOR_SIZE); W25Q16_PageProgram(s_cache.sector_no * FLASH_SECTOR_SIZE, s_cache.data, FLASH_SECTOR_SIZE); s_cache.dirty = 0; } return RES_OK; }

注意:页编程一次最多256字节,4KB数据需要分16次写入。写完后读回校验会进一步提高可靠性,但会牺牲写速度,实际项目里看需求取舍。

4.3 磨损均衡与坏块隔离思路

缓存机制能显著减少擦除次数,但还有一个问题:FATFS在格式化后,FAT表区和目录区通常位于Flash的低地址区域,这些区域的擦写频率远高于其他区域。即使缓存合并了很多写入,FAT表和根目录区域依然是磨损热点。

做磨损均衡的一个简单思路是“搬运热点”:定期把高频写入的扇区内容复制到另一个擦写次数少的扇区,并更新映射表。FATFS本身没有这个机制,需要你在disk_ioctl层做逻辑地址到物理地址的映射处理。这个方案在小容量Flash上,映射表开销会被放大,2MB空间做静态映射要占用不少RAM,所以需要合理评估。

另一种更实际的思路是针对“日志型写入”做特殊优化:日志数据不放在FATFS可见的普通文件里,而是以“顺序写循环缓冲区”的方式直接写在Flash的专用分区中。因为日志是顺序写入,天然适合Flash特性,也避免了FATFS频繁更新目录项造成的热点磨损。这属于应用层重新设计,但如果你真的需要在超长周期下稳定运行,这个思路值得认真考虑。

坏块隔离方面,因为W25Q16这类NOR Flash极少出现坏块,但极限擦写后也会有个别扇区失效。可以在写入时先擦除再写,然后读回校验,如果校验失败就标记该扇区“坏”,在逻辑映射表中把它映射到空闲扇区。FATFS本身不感知这个动作,所以必须在disk层实现地址重映射。受限于篇幅和实用性,这里不展开讲完整坏块管理代码,只提一个核心思路:在disk_write之前检查扇区是否在坏块表里,如果命中了就替换到备用扇区。

5. 常见问题与排查实录

5.1 读ID失败、数据全0xFF这类SPI问题

这是所有人移植W25Q16都会遇到的头号问题。现象往往是W25Q16_ReadID()返回的不是0xEF,而是0xFF或0x00。排查顺序有讲究:

  1. 先查接线:VCC和GND有没有接错,CS有没有上拉,特别是用杜邦线连接时,线序很容易反。
  2. 再查SCK极性相位:确认CPOL=0、CPHA=0,对应HAL配置的SPI_POLARITY_LOW和SPI_PHASE_1EDGE。
  3. 然后查时钟频率:如果分频太小、SPI时钟太快,信号在板子走线较长(超过10cm)时容易振铃,导致采样错位。先把分频调大到16或32试一下,能通再把频率提上去。
  4. 最好用示波器抓包:没有示波器就通过串口打印的方式排查——先单独把CS拉低,连续读几个字节,看返回的数据是否有规律。如果全0xFF,说明MOSI上的命令根本没送进芯片或者芯片没供电;如果全0x00,可能是MISO引脚配置错误或者芯片进入了异常状态。

我最开始踩的坑就是“在SPI初始化之前就调用W25Q16读ID”,结果是全0xFF,排查了大半天,最后才发现芯片没有上电就初始化SPI,当然失败。初始化顺序是先确认芯片供电,再初始化SPI外设,最后操作Flash。

5.2 FATFS挂载/格式化失败排查

f_mount返回错误码时,很多人第一反应是查diskio实现,但实际踩得最多的坑往往是ffconf.h配置问题。这里列一下我遇到的高频错误和对应处理:

错误码含义常见原因解决办法
FR_NOT_ENABLED未挂载f_mount的第3个参数为0,或挂载前未调用f_mkfsf_mount第3个参数设为1,先格式化再挂载
FR_NO_FILESYSTEM无文件系统Flash里没有有效的FAT引导扇区,或扇区大小配置和格式化时不匹配调用f_mkfs格式化,确认FF_MIN_SS/FF_MAX_SS与f_mkfs参数一致
FR_MKFS_ABORTED格式化中止扇区数量太少、FAT类型不支持、work_area太小2MB的Flash用FM_FAT没问题;work_area建议不小于1024字节
FR_NOT_ENOUGH_CORE内存不足FF_USE_LFN开了长文件名但RAM不足关掉FF_USE_LFN或改用静态堆

特别提醒一个看不见的坑:如果在disk_ioctl里没有正确实现GET_SECTOR_SIZE,FATFS的格式化流程可能能走完,但挂载时读到错误的扇区大小就会报FR_NO_FILESYSTEM。实现的返回值必须和实际的扇区大小一致,多少就是多少,别在底层偷偷搞“逻辑扇区”而不告诉FATFS。

5.3 DMA传输中断与数据错乱问题

代码里加上DMA传输后,我遇到过两类典型问题。第一类是HAL_SPI_TransmitReceive_DMA只发一次再调用就失败,原因是DMA传输完成中断里没有清标志,或者是DMA的Normal模式在完成一次传输后通道会被关闭,需要每次传输前重新调用__HAL_DMA_ENABLE或重开HAL_SPI_TransmitReceive_DMA。建议在DMA完成中断里进行状态检查,并保证同一时刻只有一个DMA传输在进行。

第二类问题是数据错乱,其实是缓冲区对齐。W25Q16按4KB扇区读写,如果disk_read传入的buff首地址没有按4字节对齐,DMA搬运时会出问题。解决办法有两个:要么使用align(4)属性定义缓冲区对齐,要么每次DMA都使用内部定义的中间缓冲区,再memcpy到用户缓冲。

__attribute__((aligned(4))) uint8_t dma_buffer[4096];

使用DMA时还要注意SPI的方向。W25Q16读数据时,主机持续发0xFF给从机提供时钟,同时接收数据。在HAL_SPI_TransmitReceive_DMA里,TX和RX缓冲区要同时准备好,TX缓冲区不够了会自动停止时钟,读到的数据就不完整。所以我读一整个扇区时,会用一个循环把4KB数据分成若干次DMA传输,或者直接把DMA的传输长度设为4096并准备等长的TX缓冲区。后者更简单,我用的是后者。

关于优化方案的实测数据

最后分享一组实际测试中的对比。同样的W25Q16、同样的FATFS配置、同样的日志写入场景(每10秒追加一条32字节日志),不经扇区缓存优化直接写,W25Q16的扇区擦除次数以肉眼可见的速度增长,模拟运行一个月的擦写接近18000次;加了RAM缓存合并写入后,擦写次数降到约2400次,寿命延长了7倍以上。这还是在只用了基础缓存方案、没做磨损均衡的前提下。搭配4096字节扇区对齐,整个日志系统的写入速度和稳定性都有了明显改善。

如果你也在做类似的事情,我建议先跑通基础链路再考虑优化。第一步先拿到正确读ID,第二步用串口打印验证FATFS文件创建和读写,第三步再加入扇区缓存,千万别一上来就堆DMA和复杂映射。把每一步都验证扎实了,后面出问题时你才知道到底是哪一层在捣乱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询