做嵌入式的同行大概都遇到过这种返修:客户反馈设备用着用着配置丢了,或者 OTA 升级中途断电后板子再也起不来。查下来根因十有八九落在 Flash 文件系统上------要么掉电写坏了 FAT 表,要么某个扇区被写穿。FAT 不抗掉电,裸写 Flash 不耐磨,这俩坑在量产面前迟早要还。这篇文章拆解 LittleFS 怎么从根上解决这俩问题,并拿某款 RISC-V MCU 的 SDK 实测配置做案例,讲清楚每个配置项为什么这么填。
一、为什么嵌入式需要专门的文件系统
SPI NOR Flash 有三条铁律:按页读写、按块擦除、写前必擦。页一般是 256 字节,擦除块是 4096 字节,而且擦写有寿命,一块 Flash 擦写十万次左右就开始出坏块。
FAT 在 PC 上没问题,搬到嵌入式有三个硬伤。最致命的是掉电就损坏:FAT 表和目录项是原地改写的,写一半断电,FAT 表和目录对不上,整个分区就废了。再就是没有磨损均衡,同一个目录反复改,永远写那几个扇区,很快就写穿。还有 RAM 占用随文件数增长,小 MCU 扛不住。
LittleFS 就是冲着这三点设计的:掉电安全、动态磨损均衡、RAM/ROM 有界。它的状态严格受控,RAM 消耗不随文件系统变大而涨,也没有无界递归。

二、LittleFS 的四个核心机制
2.1 CTZ 压缩目录:大目录不会写穿一个块
CTZ 是 count-leading-zeros 的缩写,听起来玄乎,本质是一种可变长跳转的链表编码。LittleFS 的目录不是像 FAT 那样占一段连续扇区的目录表,而是把每个目录条目组织成链表,每个条目里存一个"skip 值"指向后面第几个块。
关键在于这个 skip 值用 CTZ 编码后是变长的,前面几个条目 skip 小、占的位少,越往后 skip 越大、占的位多。结果是目录条目天然分散到多个块上,不会全挤在一个块里。
为什么要这样设计?因为目录是写最频繁的元数据。FAT 的目录表集中在几个连续扇区,你往一个目录里反复增删文件,那几个扇区很快就被写穿了。CTZ 把目录摊开到多个块,配合下面的磨损均衡,目录块的磨损被均摊开。

2.2 磨损均衡:block_cycles 让元数据块轮换着用
LittleFS 的写都是 copy-on-write:要改一个块,不是原地改,而是写一个新块,再把指针指过去。这本身就是磨损均衡的基础,写不固定落在某个块。
但光靠 copy-on-write 还不够。超级块和目录块是"热点",每次挂载要读超级块,每次改目录要写目录块,它们的擦写频率远高于数据块。如果任由它们待在原地,迟早被写穿。
所以 LittleFS 加了 block_cycles 机制:一个元数据块每被擦写 N 次后,就把它整个搬到另一个新块上,旧块释放。本工程配的是 500,意思是元数据块擦写 500 次就换一个家。
官方对 block_cycles 的说法很直白:建议 100-1000,值大性能好但磨损分布不均匀,值小磨损均匀但搬动开销大。500 是个偏中庸的选择。设成 -1 则关闭块级磨损均衡,除非你确信自己的负载不会产生热点元数据块,否则别关。
延迟擦除是另一个细节:旧块被新块取代后不立刻擦,先标记失效,等空闲了再擦,避免擦除阻塞关键路径。

2.3 掉电保护:三步 copy-on-write 保证任意时刻可回退
这是 LittleFS 最值钱的设计。一次写操作拆成三步:
- 写新块:把新数据写到空闲块
- 更新指针:原子地把元数据指针从旧块指到新块
- 释放旧块:旧块标记为空闲
任意时刻掉电,文件系统都能回到上一个一致状态: - 掉在第 1 步:新块写了一半,指针还指着旧块,旧数据完好,新块那半截下次 GC 回收 - 掉在第 2 步:指针要么没更新(回退到旧块),要么更新了(用新块),二选一不会乱 - 掉在第 3 步:指针已经指新块了,旧块没释放只是多了个垃圾块,不影响正确性
对比 FAT:FAT 表是原地改写的,掉电时 FAT 表和目录可能各改了一半,对不上就损坏。这是 FAT 在嵌入式不抗掉电的根因。
源文档里有个实测很能说明问题:往文件写两段数据但不 close,直接拔电,重新上电后读到的还是上一次成功 close 的内容,不会读到半截损坏数据。LittleFS 保证回到上一个一致状态。
2.4 元数据双副本:单块损坏不影响挂载
关键元数据(挂载点、根目录)写两份,交替更新。这次写副本 A,下次写副本 B,再下次又写 A。两份里只要有一份是好的,就能挂载。
为什么需要双副本?元数据是单点,超级块坏了整个文件系统都认不出来。双副本把单点故障概率降一个数量级,配合 CRC 校验,一个副本损坏能被检测到并切到另一个副本。
三、lfs_config 每个字段都在说什么
这是新手最容易踩坑的地方。lfs_config 不是随便填的,每个字段都要和底层 SPI Flash 的物理参数对齐,填错轻则性能差,重则挂载失败、数据损坏。
某 RISC-V MCU SDK 的实测配置(适配层 ak_lfs(化名)里)长这样:
static struct lfs_config cfg = {
.read = block_device_read, // 读:XIP memcpy
.prog = block_device_prog, // 写:spi_flash_write
.erase = block_device_erase, // 擦:spi_flash_sector_erase
.read_size = 256, // Flash 页大小
.prog_size = 256, // Flash 页大小
.block_size = 4096, // Flash 扇区大小
.block_count = 25, // 挂载时按分区大小重算
.cache_size = 256, // RAM 缓存
.lookahead_size = 16, // 块分配位图前瞻
.block_cycles = 500, // 元数据磨损均衡周期
};
逐个说。
read_size / prog_size = 256:读和写的最小粒度,必须等于 Flash 的页大小。SPI NOR Flash 一页 256 字节,跨页读要发多条命令,所以填页大小最经济。填小于页没意义(底层还是按页读),填大于页会要求底层做超出物理能力的对齐。
block_size = 4096:擦除块大小,等于 Flash 的扇区大小。官方注释强调,这个值不影响 RAM 消耗,但非内联文件至少占一整个块,所以 block_size 越大,小文件越浪费空间。它必须是 read_size 和 prog_size 的倍数。
block_count :文件系统的块数。这里填 25 只是占位,挂载时适配层会按分区实际大小重算:block_count = 分区字节数 / 4096。填 0 也行,LittleFS 会读磁盘上存的 block_count。
cache_size = 256:RAM 里的块缓存大小。LittleFS 需要一个读缓存、一个写缓存,外加每个打开的文件一个缓存。必须是 read_size/prog_size 的倍数,且是 block_size 的因数。本工程填 256(一页),省 RAM;想提速可以填大,比如 4096(一整块),缓存命中率高但多吃 4KB RAM。
lookahead_size = 16:块分配的前瞻缓冲。它是个紧凑位图,1 字节能跟踪 8 个块。16 字节就能前瞻 128 个块。分配新块时一次扫一批,比一块一块找快。值大找块快但多吃 RAM。
block_cycles = 500:前面讲过的元数据块轮换周期。100-1000 之间取舍。
记住一条:这三个参数(4096 / 256 / 256)必须和生成文件系统镜像时的参数完全一致,否则挂载失败。源文档里生成空镜像用的命令是 mklfs -b 4096 -p 256 -r 256,和配置里的 block_size/prog_size/read_size 一一对应。

四、分区挂载:LittleFS 不懂"分区",适配层来填
这是个容易混淆的点:LittleFS 本身没有"分区"概念。它只认一个从第 0 块到第 N 块的块设备,不知道也不关心这些块在 Flash 上的物理位置。
但实际产品里,一块 SPI Flash 会被切成多个分区,ROMFS 放只读资源、LittleFS 放可读写数据、还有 OTA 分区。LittleFS 只该管自己那段。把"分区名"映射到 Flash 物理位置,是适配层的活。
某 SDK 的适配层 ak_lfs(化名)干的就是这件事,挂载流程是:
ak_lfs_mount("UDATA")
→ partition_get_bin_info(&binInfo) // 按"UDATA"查 flash 头部分区表
→ lfs_info.start_page = binInfo.file_start_page // 分区起始页
→ cfg.block_count = binInfo.file_len / 4096 // 按分区大小算块数
→ lfs_mount(&lfs, &cfg) // 挂载
分区表存在 Flash 头部(第 0 页附近),由烧录工具在烧录时写入。适配层按分区名字符串匹配,返回该分区的起始页、长度、XIP 映射地址。
读写路径还分两条:读走 XIP(eXecute In Place),把 Flash 物理地址映射到 CPU 能直接 memcpy 的地址,读不经过 SPI 命令开销;写和擦走 spi_flash_write / spi_flash_sector_erase,按页写、按扇区擦。读快写慢,这跟 Flash 物理特性一致。
为什么必须要有这层适配?因为 LittleFS 的块设备接口是抽象的,它说"读第 K 块",但"第 K 块"在 Flash 上的真实地址是 分区起始页 + K*4096,这个换算只有适配层知道。没有适配层,LittleFS 会从 Flash 第 0 块开始用,直接踩到分区表和 ROMFS 上去。

五、和 EasyFlash 比,什么时候选谁
先说清楚一个前提:本 SDK 并没有集成 EasyFlash,全仓搜不到任何 easyflash 源码。下面的对比是基于 EasyFlash 公开机制(armink/EasyFlash v4.x)的理论推演,用来理解选型逻辑,不是实测对比。
两者定位完全不同。LittleFS 是文件系统,有目录、文件、路径,数据模型是 path → bytes;EasyFlash 是 KV 存储库,核心是 ef_env 的 key → value,外加日志和 IAP,数据模型是扁平的键值对。
| 场景 | LittleFS | EasyFlash |
|---|---|---|
| 存大量结构化数据(文件、音频、OTA 包) | 强 | 弱,KV 不适合大块二进制 |
| 存少量配置键值对(几十个参数) | 能用但重 | 强,ef_env_set/get 一行搞定 |
| 需要目录/路径组织 | 强 | 无 |
| 代码体积 | 较大(编译后约 20KB+) | 较小(核心 env 几 KB) |
本工程为什么选 LittleFS?因为 OTA、音频、指纹这些要存的是文件,用文件 API(open/read/write/close)语义统一,上层代码能复用。而且锁类设备掉电频繁,LittleFS 的 copy-on-write 保护任意文件,比 EasyFlash 的 KV 备份更通用。本工程的配置参数走自有存储,没用到 EasyFlash 的 env 模型,引入 EasyFlash 反而多一层抽象。
说到底就一条:要存文件选 LittleFS,要存几十个配置参数且追求极小体积选 EasyFlash 的 ef_env。
六、实测:一条 CLI 命令跑通 LittleFS
源文档里在 ez_ble_test.c(化名)新增了一条 ez_lfs CLI 测试命令,设计很简洁:单命令形式为 ez_lfs 加操作符和参数,用 static 状态机管理当前文件句柄和挂载标志。参考了 SDK 里已有的多命令测试,但改成单命令形式,省去注册一堆命令。
基本读写测试跑下来是这样:
msh > ez_lfs mount
lfs mount success
msh > ez_lfs open test.txt
msh > ez_lfs write hello_lfs_123
lfs write success, len:13, pos:13
msh > ez_lfs seek 0 0
msh > ez_lfs read 32
lfs read success, len:13, data:hello_lfs_123
msh > ez_lfs close
掉电保护测试更有意思:mount 后 open 一个文件,写两段数据但不 close,直接拔板子电源模拟写中断。重新上电 mount 后读,读到的是上一次成功 close 的内容(或空),不会读到半截损坏数据。这正是 2.3 节讲的三步 copy-on-write 在起作用。
实测踩了三个坑值得说。第一个是 UDATA 分区要单独烧,默认烧录配置不含它,mount 会因找不到分区失败,得先用另一套烧录配置把 UDATA 分区和 lfs.bin 烧进去。第二个是写后必须 close,littlefs 的写在 close 或 sync 前不落盘,写完直接断电数据不保证持久化,掉电测试观察的正是这点。第三个是单 fd 设计,命令只维护一个当前句柄,open 新文件会先关旧文件,多文件并发要自己扩展成句柄表。
构建验证也印证了机制:未加 ez_lfs 命令前,因为开了 --gc-sections 且无人引用,littlefs 库整个被链接器丢弃,elf 里 0 个 lfs 符号;加了命令把符号拉回后,87 个 littlefs v2.50 符号全部链接进来,bin 大了约 20KB(即 lfs 库体积)。

写在最后
选文件系统这事,本质是在"掉电安全、磨损均衡、资源占用"之间取舍。LittleFS 用 copy-on-write + 双副本 + CTZ 目录把前两个做到了嵌入式能用的程度,代价是 20KB 左右的代码体积和一点 RAM 缓存。对要存文件、会掉电的产品,这笔账划算。
你的产品上用什么文件系统?掉电保护实测过吗,还是只在实验室跑通就上了?评论区聊聊,有用的话点个在看,让更多踩过 Flash 损坏坑的同行看到。
嵌入式 #文件系统 #LittleFS #Flash存储 #单片机