1. 两个文件系统,摆在一起比什么
搞嵌入式的,只要做过SD卡存储、SPI Flash日志这类活,基本绕不开FatFS和LittleFS这两个小型文件系统。很多朋友选型时习惯把两者放在一块儿比,但说实话,它俩虽然都叫“文件系统”,出身、设计目标、适合干的活完全不是一路。把它俩硬拉到同一个擂台上,问“哪个更好”,本身就是个伪命题。真正该问的是:我的项目里,存储介质是什么,断电可靠性要求多高,数据要不要拿下来给PC直接读,这几个问题回答完了,选谁基本就定了。
FatFS是ChaN老师写的老牌开源文件系统,主打FAT12/16/32/exFAT,跑在裸机或者RTOS上,跟SD卡、U盘这类块设备是天作之合。它的最大底气是“兼容”:你往SD卡里写个文件,拔下来插电脑上,Windows和Linux直接认。就冲这一点,很多数据采集、音视频记录、车载黑匣子项目到现在还是认准它。
LittleFS则是ARM牵头搞出来的嵌入式文件系统,最初伴随Mbed OS推出来,目标非常明确:给NOR Flash这类“先擦后写、按块擦除”的介质用。它自带掉电保护、磨损均衡、坏块处理,不是靠外部驱动兜底,而是把可靠性做进了文件系统内部。用一句话概括,FatFS更像是“移植到MCU上的PC文件系统规范”,LittleFS则是“从Flash物理特性出发长出来的专用方案”。
这篇文章我会把两者从设计机制、资源占用、移植实操、选型逻辑到掉电测试踩坑一条龙讲透,适合正在做存储方案选型、或者已经在某一条路上踩过坑想换路线的朋友。看完你至少能回答一个问题:手头这个项目,拔电后丢数据能不能忍,文件要不要拿出去给别的设备读,这两个条件一确定,答案就出来一大半了。
2. FAT表 vs 写时复制:两种截然不同的底层哲学
2.1 FatFS靠“账本”记账,快但怕撕账本
FatFS底层对应的是FAT文件系统,核心结构是FAT表,我习惯把它叫“账本”。文件数据按簇分配,FAT表记录每个簇的编号链,谁连着谁,链子断没断,全看这张表。目录项则是另一个“目录账本”,记录文件名、起始簇号、文件大小这些属性。
写一个文件时,FatFS至少要干三件事:先写数据到空闲簇,再去更新FAT表里对应的簇链,最后更新目录项。问题在于,这三步不是原子的。如果写数据写到一半断电,数据簇可能已经占了,但FAT表和目录项还没更新完,轻则产生孤立簇,重则整个文件链断掉,目录项指向一个不存在的起始簇。这就是典型的“账本和仓库对不上”。
在很多存储介质上,这个问题不算致命,因为坏账可以靠扫描、修复工具处理。但在嵌入式裸Flash上,FAT表还有另一个更隐蔽的坑:表的位置是固定的,每写一个文件都要去刷FAT区域,相当于你有一块橡皮,天天就逮着同一个角使劲擦,擦个几千次那个区域就先报废了。SD卡内部有FTL层(Flash Translation Layer),把逻辑扇区映射到物理块,同时做了坏块管理,所以FatFS在SD卡上能跑得很稳,但这其实是SD卡替FatFS扛住了闪存管理的脏活累活。如果你直接把FatFS挪到一片裸SPI NOR上跑,磨损集中和掉电损坏的风险就全暴露出来了。
2.2 LittleFS不覆盖旧数据,掉电最多“白写一次”
LittleFS内部走的是日志结构(log-structured)路线,核心操作是写时复制(Copy-on-Write,COW)。说人话:它从来不直接在旧数据的位置上原地修改,而是把新内容写到一个空闲块里,等写入完全成功、校验没问题,再把文件系统的“指针”一次性切换过去。
这就好比你在笔记本上改文章,不拿橡皮擦掉旧字,而是在下一页写一个新版本,写完后把目录里的索引撕下来换成新页码。断电最坏的结果是:新版本没写完,索引还是指向旧版本,文件系统整体还是上一版的完整状态。不会出现“新数据写了一半,旧数据已经被覆盖没了”这种两头空的情况。
为了进一步防元数据损坏,LittleFS还搞了metadata pair(元数据对)。关键元数据不是存一份,而是同时在两个块里各写一份,并且带序列号和CRC校验。挂载时比较两份,选有效且序号更新的那份。即使掉电正好落在提交元数据的刹那间,也能靠另一份恢复,不会读到半个目录项。同时对上层是透明的,你不感觉它的存在,但它确实把“掉电安全”这件事,从“靠运气”变成了“靠设计”。
2.3 磨损均衡和坏块管理:一个靠文件系统,一个靠底层驱动
磨损均衡是我在帮人评估方案时必问的一条。NOR Flash的擦写寿命一般也就1万到10万次,如果一个区域反复被写,很快会先于其它区域报废。FatFS本身完全没有均衡概念,它只把块设备当成一个可以随机读写的“磁盘”,因为目标介质是SD卡/硬盘这类自带FTL的设备,坏块和磨损是设备自己处理的。一旦换到裸Flash,底层没有FTL,磨损就集中在FAT表和数据频繁更新的固定区域。
LittleFS则把动态磨损均衡做进了分配算法里:在为新数据找块时,会倾向于选择擦写次数较少的块,把写入压力尽量摊到全盘。虽然它不是严格意义上的静止磨损均衡,但用于MCU掉电存储这类场景,已经能把寿命表现拉高一个量级。坏块处理也一样,FatFS遇到擦除或写入失败基本只能报错返回,而LittleFS会把这个块标记成bad block,重新找一块继续写。这就是为什么很多产品把“参数存储、日志存储”这类重度写操作交给LittleFS,而不是自己用FatFS硬扛。
3. 资源占用与性能实测:小文件系统的硬指标
3.1 ROM、RAM占用对比
选型时最常被问到的是“费不费资源”。FatFS的代码量受配置项影响非常大,如果只开最核心的FAT16/32、不开长文件名、不做格式化,ROM占用大概在10KB上下;如果把exFAT、长文件名、多卷、各种高级选项全开,二三十KB也很正常。RAM方面,FatFS通常需要一个扇区大小的缓冲,512B到4KB不等,文件对象本身也有几十字节的开销,多开几个文件句柄,RAM就蹭蹭往上涨。
LittleFS在这方面走的是“预算RAM,封顶可控”的路线。官网给过数据,代码区可以压到几KB,运行RAM也常常在几百字节到几KB这个区间,具体取决于cache_size和lookahead_size配置。关键是LittleFS的RAM开销是可以在配置阶段算死的,不会因为文件数量增多、路径变深而动态上涨。对于RAM按字节省的MCU项目,这个特性非常值钱。
3.2 实测表现:不同场景跑出来的速度完全不一样
我在STM32F103平台上分别搭了FatFS+SD卡(SPI模式)和LittleFS+W25Q64做对比,下面是我自己条件下的结果,供参考,不是绝对指标。
| 项目 | FatFS + SD卡(SPI模式) | LittleFS + W25Q64(SPI模式) |
|---|---|---|
| 顺序写大文件 | 较快,能跑到几百KB/s级别 | 取决于Flash擦写和文件系统开销,通常几十KB/s,但可优化 |
| 小块频繁改写 | 受FAT表更新拖累,且磨损集中 | 主要开销是块迁移和元数据提交,写放大存在但可靠 |
| 掉电保护 | 依赖上层日志/双备份方案 | 文件系统内建,断电后挂载不坏,未完成写操作会被回滚 |
| 挂载/格式化时间 | SD卡FAT挂载快,格式化也快 | 需要扫描元数据对,容量越大挂载稍慢,但通常可接受 |
| 兼容性 | 拔出即是一个标准U盘 | 需要专用工具或FUSE驱动才能被PC读取 |
如果你是“往SD卡里写摄像头视频、采集大块传感器数据”这类应用,FatFS的顺序写吞吐确实占优,因为它就是照着块设备的逻辑设计的,PC标准协议栈对它没有任何额外负担。如果你的需求是“设备随时掉电、频繁改几个小配置文件、每5秒追加一条日志”,LittleFS的可靠性优势远大于那点额外开销。很多项目真正需要的是“写参数别丢、别让Flash死太快”,这时候顺序写速度反而没那么关键。
3.3 碎片和空间碎屑:两种文件系统都得面对
碎片问题经常被忽略,但实际用久了都会遇到。FatFS的FAT链结构天然允许文件离散存储,簇号不连续也没关系,但读大文件时要不停跳转,性能会降。更麻烦的是频繁删改文件后FAT表里会产生大量零散的“空洞”,空间明明够,却很难找到一整段连续区域。
LittleFS因为是COW机制,旧版本的数据块被新版本替代后,原来的块就变成垃圾块,需要靠后台/下一次写入时回收。这个过程就是元数据压缩和块回收,内部会自动做,但如果你把空间用到99%,文件系统会在写入时频繁触发回收,速度急剧下降。所以不管用哪个,预留10%到20%的空闲空间,都是我在项目里强制的规矩,这条比选哪个文件系统更能保住产品寿命。
4. 移植与配置实操:STM32+SD卡和STM32+SPI Flash
4.1 FatFS在STM32+SD卡上的标准流程
FatFS这些年已经被移植烂了,基本套路是固定的。你从官网下的包里有一层“diskio”抽象层,要自己实现disk_initialize、disk_status、disk_read、disk_write、disk_ioctl这5个函数,外加一个获取当前时间的get_fattime。SD卡驱动用SPI还是SDIO都可以,只要保证底层能正确读写扇区、能获取卡容量和扇区大小即可。
第一次使用建议按这个顺序走:先单独测SD卡驱动,确保能读扇区0得到MBR数据;再调disk_ioctl的GET_SECTOR_COUNT、GET_SECTOR_SIZE、GET_BLOCK_SIZE,FatFS要依赖这几个参数做容量判断和格式化;最后再挂载卷,f_mount成功后用f_getfree确认容量。很多新手一上来就f_open,失败也不知道卡在驱动还是文件系统层,排查起来很痛苦。
ffconf.h里几个关键配置你要先想清楚:
- FF_USE_LFN:不开长文件名,中文文件名基本就别想了,开了会增加RAM开销。
- FF_MAX_SS:建议直接设4096,兼容512B和4KB扇区的卡,但缓冲区会变大。
- FF_USE_MKFS:需要格式化SD卡时打开,量产工具里通常要自带的。
- FF_FS_RPATH:开相对路径会方便些,但RAM和代码量都会涨。
如果追求兼容性,还要记得一点:4GB以上的SD卡默认出厂是FAT32,PC上会显示为一个标准可移动磁盘;但如果你的板子跑的是老版本FAT,可能不识别exFAT,大卡请确认驱动支持sd卡类型。
4.2 LittleFS在W25Q64上的移植要点
LittleFS只有4个核心接口:read、prog、erase、sync,比FatFS还简洁。W25Q64这类SPI NOR Flash,block_size一般按4096字节设,prog_size按256字节设,因为它是页编程模式,一页最多写256字节,写之前还得先擦除整个4KB扇区。
下面是一个精简的设备层示例框架:
static int w25q_read(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, void *buffer, lfs_size_t size) { // 底层SPI读,从 block * c->block_size + off 地址读size字节 w25q_read_data(block * c->block_size + off, buffer, size); return LFS_ERR_OK; } static int w25q_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size) { // 页编程,注意W25Q一页256字节,size不能超过页边界 w25q_page_program(block * c->block_size + off, buffer, size); return LFS_ERR_OK; } static int w25q_erase(const struct lfs_config *c, lfs_block_t block) { // 扇区擦除,W25Q64按4KB擦除 w25q_erase_sector(block * c->block_size); return LFS_ERR_OK; } static int w25q_sync(const struct lfs_config *c) { // 对NOR Flash而言,写完了自然落盘,没有cache同步问题,返回OK即可 return LFS_ERR_OK; }LFS配置结构体也要仔细填。以下是我在W25Q64上的典型配置:
struct lfs_config cfg = { .read_size = 1, .prog_size = 256, .block_size = 4096, .block_count = 2048, // 8MB / 4KB .cache_size = 256, .lookahead_size = 256, .read = w25q_read, .prog = w25q_prog, .erase = w25q_erase, .sync = w25q_sync, };block_count = 8MB / 4KB = 2048,这一个值别填错,填错容量直接不对。lookahead_size影响块分配算法扫描空闲块的范围,太小会导致分配效率下降,256足够了。
挂载逻辑也要有一个标准姿势:先试lfs_mount,如果返回错误,说明Flash上还没有有效文件系统,这时要调用lfs_format,然后再lfs_mount。很多项目第一次上电会卡在这里,因为出厂Flash全是0xFF,不格式化就挂载肯定失败。量产时如果不想让首启做格式化,可以用镜像工具直接把文件系统镜像烧录进Flash,但前提是文件系统版本匹配。
4.3 真正影响可靠性的隐藏参数和注意事项
LittleFS不是拿过来随便改个block_size就能跑的,有几个隐藏细节很容易踩:
- prog_size如果小于Flash的页写入长度(比如W25Q是256B),写小块数据时会很慢,甚至底层驱动不支持部分页编程;建议设成与Flash页大小一致。
- cache_size建议是prog_size的整数倍,否则文件系统内部缓存读写会频繁边界对齐失败,性能脑溢血。
- block_size必须与Flash的擦除块大小严格对应,W25Q系列常用4KB,也有16KB、64KB的变体,一定要查对应型号的datasheet。
- 掉电安全不代表“写一条日志就永远不会丢”,如果你在文件里攒了一堆数据还没lfs_file_sync,断电压根没到Flash上。需要每条都保证落地,就每次写完调sync,本质上是牺牲性能换可靠性。
FatFS侧也有对应的“坑”:掉电后文件大小可能停在旧值,因为FAT表和目录项没刷盘。ff_sync不能省,它对标的就是LittleFS的lfs_file_sync,不调sync就拔电,丢数据是大概率事件。很多“写完就拔电,文件怎么是0KB”的反馈,都是没在写完数据后做同步。
5. 选型决策指南:什么项目用什么,基本不纠结
5.1 按存储介质选:SD卡闭眼FatFS,裸Flash认真考虑LittleFS
先说最直接的判断标准:介质是什么。
如果你用的是SD卡、TF卡、U盘这种自带控制器的块设备,优先选FatFS,几乎不用犹豫。原因很简单:SD卡内部已经有FTL,负责坏块管理和磨损均衡;FatFS只负责记录文件结构,两边分工明确。而且这种卡最大的价值是“可移动”,文件要拿到电脑上处理,那FAT兼容性就是你无论如何都不能丢的核心需求。
如果你用的是板载SPI NOR Flash(比如W25Q系列)、内部Flash余量、或者NAND Flash,情况就不一样了。这些裸Flash没有FTL,擦写寿命有限,坏块只能靠你自己管。FatFS在这里属于“能跑但不推荐长期跑”,磨损和掉电风险都高;LittleFS针对Flash特性设计,自动均衡、坏块重映射、掉电回滚,都是你需要的,所以裸Flash场景我建议优先考虑LittleFS。
5.2 按可靠性需求选:能不能容忍“数据稀里糊涂没了”
当设备用在工业、车载、电表、医疗这些场景,断电是不可控事件,而且往往断电时机最刁钻,偏赶上写入那一刻。FatFS在这种情况下很难保证“文件系统不坏、文件不丢”,你需要自己设计日志式写入、双备份区、上电自检修复这些机制,工作量非常大。LittleFS至少把“文件系统层面不会坏”给你兜住了,剩下的业务数据一致性,只要应用层做简单的双缓存+CRC校验,就能达到相当高的可靠性。
反过来,如果掉电场景很少,或者设备有稳定供电,又或者你接受“断电丢最后1条数据”以换取更快写速,FatFS完全可以胜任。很多数据记录仪确实就是这么干的:定期sync一次,即使掉电,损失范围也就是上次sync之后那点数据,业务可接受。
5.3 一个项目里“两个文件系统共存”一点不冲突
不少人会把FatFS和LittleFS看成二选一的对立选项,实际上在一个MCU工程里同时编译两个文件系统很常见。比如一个产品有SD卡,也有板载Flash:SD卡上放音频、图片、录像,走FatFS,方便用户拷出来;板载Flash放设备配置、运行日志、升级临时文件,走LittleFS,保证频繁写和意外掉电时不出问题。
应用层可以自己做一层轻量封装,类似一个微型VFS:
int fs_open(const char *path, int mode) { if (path和SD卡目录匹配) return fatfs_open_handler(path, mode); else return lfs_open_handler(path, mode); }在Linux里这叫虚拟文件系统层,嵌入式裸机上当然没这么高大上,但思路是一样的:把设备类型和文件系统类型解耦。上层业务只调统一的open/read/write/sync,底层根据路径前缀分发到FatFS或LittleFS。这么做的好处是,后续换存储介质、换文件系统,应用层代码几乎不用动,调底层分发逻辑就行。很多RTOS SDK实际上是这么干的,只是你没留意。
5.4 常见选型误区
根据我看到的咨询和踩坑案例,大多数人选错不是技术不行,而是被惯性思维带偏。三维误区反复出现:
第一个误区是“FatFS成熟,所以更可靠”。FatFS确实稳定,但那是在它适用的介质上稳定;跑到裸NOR上,频繁小文件写入会让FAT表区域先死为敬。成熟解决的是协议逻辑Bug,不解决Flash物理磨损和掉电原子性。
第二个误区是“LittleFS是给低端MCU用的,性能不行”。LittleFS的目标是可靠性和资源可控,不代表它撑不起吞吐需求。合理调大cache_size、用DMA、做页对齐写入之后,顺序读写在SPI Flash上也能跑出不错的成绩。而且性能瓶颈往往在SPI时钟频率和擦除时间上,不在文件系统本身。
第三个误区是“LittleFS可以在PC上自由读取”。它默认只能被LittleFS自己的工具链读取,PC上要么用FUSE驱动挂载镜像,要么用官方提供的Python工具,不像FAT那样拔下来插上就能读。如果这个需求是硬性的,那就老老实实回FatFS。
6. 踩坑实录:掉电测试、坏块处理、性能排查
6.1 掉电测试怎么做,最容易踩什么坑
很多人测试掉电就是“写文件,然后按电源键”,这种测试测不出问题,因为正常断电时序里Flash基本已经写完。真正有价值的掉电测试,要随机、要压着写入窗口、要反复循环。
我这边实际做过的测试方法很简单:MCU在循环里不断写一条带序号的结构化日志,每次写完随机延时0到20ms;外部用一个继电器或MOS管控制板卡电源,在MCU工作的任意相位随机断电;上电后读取日志,检查:文件系统能不能正常挂载?已写入的记录有没有出现“尾记录损坏”?有没有中间缺号?循环做2000次以上,才能说方案扛住了掉电。
这个过程中最容易暴露的问题,不是文件系统本身的Bug,而是“你以为写完其实还在缓冲区里面”这种问题。所以测试脚本里要刻意加入“写完立即断电”的高危场景,很多没有做sync的FatFS方案到这里就原形毕露了。对于LittleFS,因为掉电安全内建,通常挂载没问题,但业务数据一致性仍然要靠应用层协议,比如每条记录尾部放CRC,读取时校验。把这个想清楚,你就不会指望文件系统帮你解决一切。
6.2 Flash坏块与坏道:文件系统层面能屏蔽多少
话题一聊到“屏蔽坏道”,很多人会想成机械硬盘的坏道扫描。实际上在嵌入式Flash上,这个问题分情况:SD卡内部的FTL已经把坏块处理掉了,表现为“透明修复”,你在上层根本感知不到,文件系统不会也不该看到坏块;但对裸Flash,如果没有坏块管理,写入失败就会暴露成“文件写入返回错误”或文件系统异常。
LittleFS对待坏块的策略是:擦除或写入某个块失败时,把它标记为bad block,重新选择一个块继续操作。这样坏块从逻辑上被“屏蔽”了,不需要你在应用层感知。但你别指望它处理所有问题,比如Flash出现大量坏块、寿命到头、或者因为设计不当导致同区域被写到“上班”,文件系统能做的只是尽量隔离,救不回寿命。
NOR Flash出厂通常没有大量坏块,但是擦写次数逼近极限时坏块会增多。项目里最好在固件里做一个Flash健康状态统计,记录每个块的擦写次数分布,发现异常提前报警。LittleFS不直接提供这种接口,需要你在底层prog/erase函数里做计数,成本不高,但对售后预判帮助非常大。
6.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| SD卡挂载失败 | SPI时序不稳、卡供电不足、卡类型不兼容 | 先读扇区0,确认MBR能读到;换卡交叉测试 |
| 写SD卡后拔电,文件为0KB | 写完没有ff_sync,FAT表和目录项未落盘 | 写完文件务必调用ff_sync再断电 |
| LittleFS第一次上电挂载失败 | Flash上没有格式化过的文件系统 | 先lfs_format再lfs_mount,或出厂烧写镜像 |
| LittleFS写入速度越来越慢 | 空间利用率过高、触发频繁块回收 | 预留10%-20%空闲空间,检查lookahead_size |
| Flash某区域先报废 | 没有磨损均衡,写入集中在固定区域 | 换LittleFS,或底层自己做地址映射和均衡 |
| 空间满了但f_getfree显示异常 | FAT碎片、孤立簇未回收 | 格式化重建文件系统,应用层做定期维护 |
| 掉电后LittleFS挂载成功但文件内容异常 | 文件系统没坏,但业务数据不完整 | 应用层加CRC、双份记录、事务标志 |
看到表格里这些场景你可能会发现,真正把项目搞崩的往往不是“文件系统选错了”,而是“没有认真做掉电设计、没有留够空间、没有做同步”。文件系统只能保证它职责范围内的东西,职责之外的,还是得靠你的整体软件架构来兜底。
最后再分享一个我自己的习惯:不管用FatFS还是LittleFS,在项目定型前,我都会先做一个压力测试板,跑一遍掉电测试、高低温写入测试、空间写满再删再写循环测试。这听起来费时间,但存储类问题大多是概率性的,测试样本不够,问题就永远躺在产线上等着爆发。文件系统选型只是第一步,把测试流程固化下来,才是真正让产品“稳”的关键。