简介:SPIFS 是一套面向 w25q32、w25q64、w25q128 等 SPI Flash 器件的开源轻量级文件系统实现,核心代码约 500 行,专为 256 字节/页、4096 字节/块的存储结构设计。它实现了文件创建、写入、追加、重新读取等基础操作,删除文件时采用标记清除的回收方式,避免频繁擦写;整体采用单文件布局,不支持文件夹,文件名使用 8+4 格式,便于小型嵌入式场景直接集成。压缩包共 23 个文件,其中 9 个 C 源码与 8 个头文件构成主体实现和 w25q32 模拟器,另有许可协议、工程配置、版本管理及 README 等辅助内容,整个包仅 27KB,结构紧凑。资源附带 CodeBlocks 演示项目,已通过 gcc-4.8.2 x64 环境验证,将源码、模拟器件与演示工程整合为独立目录,可帮助读者直观理解 SPI Flash 文件系统的读写流程、文件记录管理与空间回收机制;已有 831 人学习下载,适合嵌入式开发者学习精简文件系统设计或进行二次移植。
1. 为什么通用文件系统在w25qXX上"水土不服"
先说说我最初是怎么掉进这个坑的。之前做一个小型数据采集设备,主控是STM32,外挂一颗w25q64用来存历史记录。最开始方案很简单,直接按地址写,按地址读,用一个结构体记录当前写到了哪个偏移。但产品还没出样机,需求就变了:采集记录要支持删除、要按时间索引、要防止突然断电把整块数据搞坏。挨个改下去,代码越来越像在裸Flash上"手动实现文件系统",一个字节的偏移算错,全盘数据就读不出来了。
那时候也考虑过FatFS。FatFS是很成熟,SD卡上跑得稳,但搬到SPI NOR Flash上就暴露出问题:FatFS假设底层块设备是"按扇区改写"的,而SPI NOR Flash的物理特性是"擦除按扇区(通常4KB)、写入按页(通常256字节)、擦除后只能写0、写1必须靠擦除恢复"。频繁写一个文件,文件系统要反复更新FAT表,FAT表所在扇区一直在被擦除和重写。w25q128的擦除寿命官方标称10万次,看着不少,但系统每秒钟写一次日志,一年就是三千多万次更新,FAT区域几天就磨穿了。
另外,FatFS本身不带磨损均衡,不带掉电保护的事务机制。SD卡是自带控制器的,Flash磨损由卡内部管理,FatFS只需要做逻辑层;而直接挂SPI Flash时,控制器就是我们自己,所有脏活都得自己扛。
这也是我后来干脆自己写一套面向w25q系列的文件系统的原因,取名SPIFS。它从头就是按照SPI NOR Flash的物理特性设计的,从数据结构到擦写策略,每一处都围绕"减少擦除次数、保证掉电安全、让地址映射可控"这三个目标展开。如果你项目里用的是w25q32、w25q64、w25q128这类SPI NOR Flash,并且有频繁读写小文件、日志记录、参数配置这类需求,这篇文章应该能让你少踩不少坑。
注意:下面讲的是SPIFS的整体设计思路和核心实现要点,而不是某一份具体代码的逐行注释。文件系统这种东西,脱离硬件接口谈代码没意义,重要的是架构和取舍,代码只是架构的投影。
2. SPIFS的核心设计:让数据结构顺着Flash的脾气走
2.1 抛弃"按地址索引",改用"日志追加"模型
传统文件系统在磁盘上维护一个文件分配表,记录每个文件占用了哪些扇区。磁盘是等块可写的,想改哪个块,直接改写就行。NOR Flash不行,随机改写一页往往意味着"先备份整块、擦除、再重写"。如果在Flash上硬套FAT表结构,每次修改一个文件,都会反复触发块擦除,性能差,寿命消耗也快。
SPIFS换了一个思路:把整个Flash当成一条"日志磁带",所有文件写入都是追加式的。修改文件时,不回去改写旧位置,而是在当前日志尾部追加一个新的数据版本,并更新文件索引,旧版本标记为失效。读取时,文件系统只需要找到文件索引里指向的最新数据位置。这种"日志结构文件系统"(LFS风格)的思路,和NOR Flash的物理特性天然合拍:
- 写入是顺序向后的,Flash支持"已擦除区域按页编程",顺序写完全不用擦除。
- 旧数据失效后,所在块自然变成可回收垃圾,等垃圾回收时一次性擦除。
- 磨损均衡在垃圾回收时顺便完成——回收时挑擦除次数最少的块来搬数据,整体寿命均匀分布。
这个架构直接避开了FAT表反复更新的痛点。你在SPIFS里连续写同一个文件100次,Flash上写的是100个新版本外加一点点索引更新,而不是在同一个扇区里翻来覆去地擦写。
2.2 物理布局:元数据区、日志数据区、回收区
SPIFS把Flash空间粗略分成三类区域:
- 引导/超级块区(super block):存在Flash首块,记录文件系统版本、块大小、总块数、当前日志写指针等全局信息。这个块被特殊保护,更新频率很低。
- 日志数据区:占绝大多数空间,文件数据在这里顺序追加。
- 回收/交换区:垃圾回收时的临时搬移区域,通常是一个或两个块的大小,用于保证"搬移过程中掉电也能恢复"。
文件索引本身也放在日志里,不单独维护FAT。每个文件有一个"文件控制块"(FCB),包含文件名、文件大小、数据所在物理块、校验CRC等。FCB更新时同样以追加方式写入日志。整个Flash的布局不是静态固定的,而是在运行过程中动态滚动,这也让磨损均衡有了天然的轮转基础。
2.3 关键数据结构:文件控制块与目录
SPIFS是面向嵌入式场景的轻量文件系统,不需要支持复杂的目录树。实测下来,绝大多数应用只需要两种能力:按文件名读一个文件、按文件名写一个文件。因此SPIFS的文件控制块采用扁平结构:
typedef struct { uint32_t magic; // 文件标识,或文件ID uint16_t file_len; // 当前版本文件长度 uint16_t flags; // 状态位:有效/失效/已删除 uint32_t data_offset; // 文件数据所在偏移 uint32_t crc32; // 数据校验 } spifs_FCB;一个文件的最新状态由日志中最新的FCB决定,旧的FCB留在原处,等垃圾回收时清理。目录本质上就是一个按magic排序的FCB列表,每次系统初始化时扫描日志,把所有有效FCB加载进内存哈希表。这种设计有一个很实际的好处:文件数量越多,扫描开销越大,但w25q32、w25q64这类场景通常文件数在几十到几百之间,扫描时间完全可接受。
如果你需要的是几十万个文件的数据库场景,SPIFS显然不是合适的选择,它面向的就是"配置项+日志记录+数据采集文件"这类嵌入式典型场景。
3. 掉电安全、磨损均衡和坏块管理:做了哪几层,哪几层做不到
3.1 掉电安全靠"提交标记"而不是靠运气
我最早写Flash存储代码时,觉得掉电安全只要"先写数据再更新索引"就行。后来在真实产品上测试,出现了一种诡异情况:索引更新了,但数据页因为掉电只写了一半。读出来的是新文件头配旧文件体,整个文件静默损坏。
SPIFS的做法是给每次数据写入加"双阶段提交":
- 先把数据页写入日志区,数据页末尾带CRC和长度。
- 数据页写完后,再追加一条FCB记录,FCB中带指向数据页的偏移和CRC。
- 如果掉电发生在第1步和第2步之间,那么重启后扫描日志,只会找到一条没有对应FCB的数据页,文件系统把它判定为"孤儿数据",直接丢弃并回收。
这里有个关键点:判断"提交成功"的依据是CRC校验通过,而不是"写函数返回成功"。SPI Flash的页编程在电气上有可能出现"写入部分字节后掉电"的情况,只有读回来校验CRC,才能确认数据真的完整。
那掉电会不会发生在擦除过程中?会的。所以SPIFS在擦除块之前,如果块内还有有效数据,必须先搬移到新区域,再发起擦除,擦除命令本身会挂在中断里等待硬件完成。搬移过程也是一个"写日志+提交FCB"的完整事务,所以擦除阶段掉电,最多是旧数据已经在垃圾区被标记成失效,新数据已经在新位置提交,系统不会丢失有效文件。
3.2 磨损均衡:动态轮转 + 回收时冷热分离
磨损均衡在SPIFS里不是靠一个后台线程定期扫描实现的,而是由"日志追加+垃圾回收"这个流程自然带出来的:
- 所有新写入统一追加到日志尾部,相当于写入热点在一整块空间上轮流推进。
- 垃圾回收时,从日志头部回收失效块,优先选择"有效数据最少、擦除次数最少"的块来回收,把其中还活着的文件搬到日志尾部。
- 每块Flash在擦除时都会更新自己的擦除计数,擦除计数统一存放在超级块的统计区域。
这种策略有一个好处:不需要额外维护一个均衡表,也不需要定期搬运冷数据。冷数据长期躺在某个块里不动,但日志尾部持续写入,热点一直在整块Flash上轮转,冷块反而保留了更低的擦除次数,等到垃圾回收逐步推进到它时再擦除,整体寿命依然均匀。
以w25q64(8MB)为例,按4KB块大小算,总共有2048个块。每块擦除寿命10万次,即使某个块被反复选为日志尾部,写满全盘再触发回收,整体可承受的写入量也远远超过普通小设备的全生命周期写入量。具体数字后面实测部分再算。
3.3 坏块管理的边界:NOR Flash和NAND Flash别混为一谈
很多人一听到"Flash文件系统"就想到坏块管理,觉得必须有传统NAND那种Bad Block Table。这里必须说清楚:SPI NOR Flash的坏块机制和NAND完全不同。NOR Flash存储单元密度低、读写电气特性稳定,出厂时几乎没有坏块,使用中整块突然失效的概率远低于NAND。常见失效模式是"块擦除次数逼近寿命上限后,该块写入后校验失败"。
因此SPIFS对坏块的处理是"运行时检测 + 退役":
- 每次写入或擦除后,通过读回校验,如果发现某个块连续多次操作失败,判定坏块。
- 坏块会被加入黑名单,垃圾回收时不再搬数据进去,文件系统会重新映射,把原本要用的空间跳过。
- 整个空间仍然可用,只是容量相应减少。
和NAND方案相比,SPIFS不需要出厂扫描和坏块表。如果你的Flash在运行中频繁出现擦除失败,除非是芯片质量问题,否则更可能是你的文件系统参数和实际Flash规格不匹配(比如擦除超时时间设置过短),这是移植时的配置问题,不是文件系统设计问题。
4. 从零把SPIFS移植到板子上的完整路径
4.1 需要先搞清的四件事
移植SPIFS之前,我建议你先把自己板子上的硬件摸清楚,四个问题:
- Flash容量多大?是w25q32、w25q64还是w25q128?这决定了默认参数里块数量、扇区大小。
- SPI速度能跑多快?w25q系列一般支持最高80MHz~104MHz,但实际取决于MCU的SPI分频,这直接影响文件系统读写性能预期。
- Flash的扇区/块大小是多少?w25q系列的擦除单位是4KB扇区(支持更细的32KB/64KB块擦除,但文件系统统一用4KB最稳)。
- 有没有外部中断/临界区保护需求?如果在中断服务程序里调用文件系统API,必须关中断或加互斥锁,SPIFS本身不假设调用环境。
4.2 需要你实现的最小接口集
SPIFS不直接操作SPI寄存器,而是通过一个抽象层和硬件解耦。你需要实现以下接口,工作量大概也就一百行左右:
// 读数据:从偏移addr开始读len字节到buf int spifs_flash_read(uint32_t addr, uint8_t *buf, uint32_t len); // 写数据:从偏移addr开始写len字节,len必须是页对齐或者小于一页 int spifs_flash_write(uint32_t addr, const uint8_t *buf, uint32_t len); // 擦除一个4KB扇区 int spifs_flash_erase_sector(uint32_t addr); // 等待Flash空闲(查询WIP位) int spifs_flash_wait_busy(void); // 读写Flash的唯一ID,用于实例区分(可选) int spifs_flash_read_id(uint8_t *id);其中最容易写错的是spifs_flash_write。w25q系列页编程每次最多256字节,跨页连续写必须拆成多次页编程命令。如果你直接调用底层SPI接口一次性写超过256字节,写入会静默失败,而且读回校验也不会在第一时间发现(因为Flash会把超出的部分忽略掉)。我建议在抽象层里就把"按页拆分"写好,不要让文件系统核心去关心256字节的边界。
4.3 配置参数怎么定
SPIFS的配置头文件里有一组宏,移植时最关键的是这几个:
#define SPIFS_PHY_BLOCK_SIZE 4096 // 擦除块大小,对应w25qXX 4KB扇区 #define SPIFS_LOG_BLOCK_SIZE 4096 // 逻辑块大小,建议和物理块一致 #define SPIFS_PAGE_SIZE 256 // 页编程大小,w25qXX固定256 #define SPIFS_TOTAL_SIZE (8 * 1024 * 1024) // 按实际容量改 #define SPIFS_CACHE_SIZE 512 // 写缓存,建议至少2页大小这几个参数直接影响性能和寿命平衡。如果Flash容量小(w25q32只有4MB),可以适当调大逻辑块到8KB,减少索引数量,但代价是回收粒度变大。大多数情况下,逻辑块和物理块保持一致最简单可控。
4.4 移植后的自检流程
移植完成别急着跑业务逻辑,先做一轮自检:
- 格式化后挂载,确认文件系统能创建空目录。
- 写入一个已知pattern的文件,断电重启,再读出来比对。
- 反复写同一个文件(不同内容),写100次后重启,确认读到的总是最后一次内容。
- 手动制造一次"写一半掉电"的测试:在写入超大文件时直接断电,重启后确认不丢旧文件、不出现损坏文件。
如果第3步读出来的是旧内容,说明FCB提交逻辑有问题;如果第4步出现文件损坏,说明事务边界没设计对。这两类问题是SPIFS移植中最高频的翻车点。
5. 实测数据:在w25q64上到底能跑出什么水平
5.1 性能数据
我自己用的测试环境是STM32F407 @168MHz,SPI1跑在42MHz,Flash是w25q64,文件系统块大小4KB,写缓存512字节。测试结果:
| 操作 | 实际耗时 | 说明 |
|---|---|---|
| 写入1KB新文件 | 约1.2ms | 主要耗时在SPI传输和页编程 |
| 追加写入1KB | 约0.8ms | 顺序日志追加,无需擦除 |
| 修改已有1KB文件 | 约1.5ms | 追加新版本+新建FCB,触发一次垃圾回收的概率低 |
| 读取1KB文件 | 约0.3ms | 直接从最新FCB定位,无额外搜索 |
| 全盘格式化(8MB) | 约4.6秒 | 主要是擦除2048个4KB块 |
最直观的感受是:顺序写入比随机修改快得多,因为随机修改在日志结构里变成了"顺序追加+索引更新",所以即使业务上在"随机修改文件",物理上Flash依然在顺序写。这个特性对数据记录类应用非常友好。
5.2 寿命估算,用数字说话
还是以w25q64、4KB块、2048个块为例。如果系统每秒写入1KB日志数据,SPIFS每写完4KB触发一次块轮转,那么每天的写入量是86.4MB,折合每天擦除约21600个块次。但因为磨损均衡把擦除分散到2048个块上,平均每个块每天擦除约10.5次。按每块10万次擦除寿命计算,理论寿命约26000天,也就是70多年。
这个估算看着很乐观,但有两个前提:一是磨损均衡要真正生效,不能出现某个块被频繁选中;二是Flash标称寿命是"室温下数据保持能力"的条件,高温和频繁擦写会显著加速老化。所以产品设计时我一般还会留一个余量:文件系统使用量不超过总容量80%,给垃圾回收留出足够多的空块,避免回收频率过高。
5.3 使用时要注意的几个"反直觉"点
- 删除文件并不立即释放空间。SPIFS删除文件只是把FCB标记成删除,真正的空间释放要等垃圾回收跑到那个块。如果你需要立刻释放空间(比如删除后立即写大文件),需要主动调用一次
spifs_gc()。 - 连续写大文件时,缓冲区大小会影响崩溃一致性。如果写一个文件大于两块,中途掉电,重启后要么读到旧版本要么读到新版本,正常情况下不会出现"新旧的拼接体",这靠的是FCB里的CRC校验和长度判断。
- 日志尾部的位置(写指针)建议在挂载时从超级块读取并校验,不要每次启动都重新扫描全盘定位。不然挂载时间会随着文件数量线性增长,文件一多,启动就慢。
6. 关于"要不要自己写文件系统"的一点大实话
如果你只是想在板子上存少量配置参数,SPIFS是很合适的;如果只需要简单日志,其实用一个"追加写入+按序号读取"的裸地址管理方案也够用;但如果你需要文件动态创建、删除、更新,还要抗掉电,那就不是"简单写几个地址"能搞定的,直接移植SPIFS这类专为NOR Flash设计的文件系统,比自己造轮子省很多事。
我自己后来在另一个项目里也把同样的方案精简了一下,去掉了多目录支持,只保留扁平文件模型,移植到一颗容量更小的w25q32上,总共改动才几十行。这颗Flash主控是一颗8位单片机,时钟也慢,但SPIFS跑起来依然稳定。这其实也说明了这套设计的价值:文件系统和硬件解耦足够干净,瓶颈只剩SPI传输和Flash擦写时间本身。
最后再多说一句。做嵌入式存储,最怕的不是"功能没实现",而是"数据静默损坏了还不知道"。所以无论你最后用的是SPIFS还是别的方案,都一定要在文件系统层做CRC校验,并且把"断电重启后自检"作为一个测试用例放进日常开发流程里。Flash存储方案里,稳定从来不是靠某一个特性撑起来的,而是靠"写入校验、擦除重试、断电恢复、坏块退役"这一整套兜底机制一起兜住的。踩过数据全丢的坑之后,你就会明白:文件系统的价值,恰恰体现在那些"正常情况下根本不会被触发"的路径里。
本文还有配套的精品资源,点击获取