ARTICLE DETAIL

资讯详情

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

STM32实战:W25Q16 SPI Flash上移植FATFS与扇区管理优化

STM32实战:W25Q16 SPI Flash上移植FATFS与扇区管理优化 先声明一下这篇文章不是泛泛的移植教程。我假设你已经能用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支持方式0CPOL0CPHA0和方式3CPOL1CPHA1这是Flash手册里明确写的。我用的是模式0空闲时SCK为低电平数据在上升沿采样。这种模式最通用和很多传感器芯片都能兼容排查问题也方便。2.2 HAL库SPI初始化配置实战在STM32CubeMX里配置SPI1的步骤如下每一步后面跟我的实际操作说明Mode选择Full-Duplex Master因为W25Q16需要读也需要写。Hardware NSS建议Disable。W25Q16的CS脚用普通GPIO软件控制就行硬件NSS在多从机场景下反而容易出问题。Data Size8 BitsW25Q16的命令、地址、数据都是按字节组织的。CPOL/CPHALow/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 Flash3.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。排查顺序有讲究先查接线VCC和GND有没有接错CS有没有上拉特别是用杜邦线连接时线序很容易反。再查SCK极性相位确认CPOL0、CPHA0对应HAL配置的SPI_POLARITY_LOW和SPI_PHASE_1EDGE。然后查时钟频率如果分频太小、SPI时钟太快信号在板子走线较长超过10cm时容易振铃导致采样错位。先把分频调大到16或32试一下能通再把频率提上去。最好用示波器抓包没有示波器就通过串口打印的方式排查——先单独把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_SIZEFATFS的格式化流程可能能走完但挂载时读到错误的扇区大小就会报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和复杂映射。把每一步都验证扎实了后面出问题时你才知道到底是哪一层在捣乱。
返回列表