嵌入式文件系统选型
FatFs / LittleFS / JFFS2 / YAFFS2 / UBIFS / F2FS / ext4 / RomFS / SquashFS 九个系统的横向对照 —— 从介质、掉电承诺、授权约束到资源生态的一份选型方法论参考 | 编制日期 2026-10-14 | 版本号 Rev 1.0
嵌入式产品要往闪存里存东西时,几乎一定会遇到一个问题:该用哪个文件系统?这个问题看起来简单,实际答案却经常是错的——不是因为选错了算法,而是因为问题本身被问错了。有人问「哪个最快」,有人问「哪个最稳」,有人问「哪个名字最熟」,这三个问题都不构成选型依据。
这份文档给出的是一条固定的提问顺序:先看介质是什么,再看掉电要承诺到哪一层,然后看授权能不能接受,最后才在剩下的候选里比资源占用与生态。顺序不能颠倒,因为后一个问题永远依赖前一个问题的答案。介质没定就谈算法,是把块设备的设计硬套到 NOR 上;掉电承诺没定就谈性能,是拿一个不要求原子提交的系统去和另一个比较写入延迟。
围绕这四问,本文把九个系统逐一拆开:它们为哪种介质设计、用什么机制保证一致性、掉电时承诺到哪一层、资源占用在什么量级、授权条款属于哪一类。结论不落在「谁最好」,而落在「在什么条件下谁是对的」。
结论: 嵌入式文件系统选型不是挑「最好的那个」,而是按顺序回答四个问题——介质是什么、掉电要承诺什么、能不能接受闭源约束、资源与生态哪个优先。 介质是第一性问题:NOR、NAND、块设备各自对应一套不同的候选集合,把块设备系统硬挂到 NOR 上,写放大与各种怪问题都由此而来。 掉电承诺分三层(数据完整 / 元数据一致 / 事务原子),多数选型失败不是算法问题,而是要承诺的层级与介质实际能给的层级不匹配。 授权维度是最容易被忽略的硬约束:随内核分发的系统普遍采用 GPL-2.0 条款,闭源 MCU 产品的现实候选常常只剩 LittleFS 与 FatFs。 而在嵌入式 Linux 上,只读根加可写层往往比在 ext4 与 SquashFS 之间二选一更贴近真实需求。
读者路径
- 刚开始做方案,要定文件系统:直接看第 11 章(横向对照 + 决策树 + 检查清单),再回头读第 2 章确认介质判断是否正确。
- NOR Flash 上做数据记录:第 2 章(介质约束)→ 第 4 章(LittleFS)→ 第 6 章(JFFS2),重点是掉电承诺层级的选择。
- NAND 上做大容量可写分区:第 2 章 → 第 7 章(YAFFS2)→ 第 8 章(UBIFS),重点是预留块与压缩的取舍。
- 嵌入式 Linux 根文件系统:第 10 章(只读根 + 可写层)→ 第 9 章(F2FS 与 ext4 对照)。
- 遇到掉电后文件系统损坏:第 3 章(先分清承诺层级)→ 附录 C(按现象排查)。
- 受授权条款限制:第 11.4 节直接给结论与判断路径,附录 D 说明本文的边界。
- 只想要速查:附录 B(参数对照)+ 第 12 章(结论与速查)就够了。
1. 文件系统在嵌入式系统里的位置
这一章不比较任何具体系统,只先把「文件系统在嵌入式产品里到底承担什么」讲清楚。很多选型争论之所以谈不拢,是因为双方对「文件系统该做什么」的预期根本不同——有人把它当成目录树,有人把它当成掉电保护机制,有人把它当成寿命管理手段。先把预期对齐,后面的比较才有意义。
1.1 为什么嵌入式产品几乎都要一个文件系统
最早的单片机固件确实不需要文件系统:参数存在固定偏移,日志写进一段预留的数组,升级时把整段固件烧回去。这种做法在功能简单时非常省事,代码量小、没有额外 RAM 开销、行为完全可预测。
但产品一旦越过某个节点,这种做法就开始失效。触发点通常来自下面几类需求中的任意一条:
- 固件需要 OTA 升级:升级包、备份镜像、版本元数据需要被命名和定位,不能靠硬编码偏移。
- 需要保存用户数据:配置、计量数据、日志、证书,量不大但必须掉电后还在。
- 需要存放资源:字库、图片、字体、模型参数,容量上兆字节,手工分配偏移既费事又易错。
- 需要多任务共享数据:多个线程或多个进程同时读写同一份数据,需要一套统一的加锁与一致性规则。
- 需要对外交付数据:设备生成的文件要能被上位机工具、上位机操作系统或产线设备直接读取。
这些需求合在一起,指向同一组缺失的能力:把一段无名的字节变成有名字的对象,并且在掉电后还能把名字和内容对应关系恢复出来。这正是文件系统提供的东西。换句话说,文件系统不是因为「嵌入式也需要」而被引入,而是因为手写偏移管理在这些需求面前已经不可维护。
| 产品需求 | 没有文件系统时的做法 | 引入文件系统后 | 代价 |
|---|---|---|---|
| 固件 OTA 升级 | 硬编码偏移 + 固定长度 | 按文件名定位镜像与元数据 | 多一次挂载与空间管理 |
| 保存配置与计量数据 | 预留固定区域 + 手动校验 | 按路径读写,自动分配与回收 | 元数据本身需要被保护 |
| 存放字库与资源 | 数组常量烧进代码段 | 作为文件随镜像分发 | 占用可写空间或需只读挂载 |
| 多线程共享数据 | 自建互斥量与缓冲池 | 统一的打开、读写、关闭语义 | 调用开销与栈占用增加 |
| 对外交付数据文件 | 自定义二进制格式 | 标准格式,上位机可直接读 | 格式兼容性与簇链碎片 |
1.2 文件系统解决的三件事:命名、持久化、介质抽象
剥掉所有实现差异,文件系统做的无非是三件事。理解这三件事的分工,比记住任何一个文件系统的特性列表都有用。
第一件是命名:把「一段字节」和「一个路径」绑定起来,并且让这个绑定关系可以查询、遍历、修改。它带来目录、文件、长度、属性这些概念,让上层不必自己记录「这段数据存在哪」。命名是文件系统最古老也最核心的功能——今天所有文件系统的复杂度,很大一部分都是从「目录树在掉电后如何保持一致」这一件事长出来的。
第二件是持久化:把命名关系和内容真正写进介质,并且在断电后还能恢复。这一件是嵌入式场景里最难的,因为介质只保证「写进去的比特能读出来」,不保证「写到一半断电后结构还是合法的」。文件系统的绝大部分代码量都花在处理这一件事上,日志、影子页、事务、原子提交都是为了它。
第三件是介质抽象:向上层提供统一的按字节或按块读写接口,屏蔽「NOR 可以随机改写单字、NAND 必须先擦除整块、块设备完全不告诉你内部结构」这些差异。这一件做得好,上层应用就不用关心底层是哪种介质;做得不好,写放大、寿命消耗、异常行为都会渗透到应用层。
| 职责 | 解决的问题 | 做不好的后果 | 各系统的差异主要在哪 |
|---|---|---|---|
| 命名 | 字节与路径的绑定关系 | 应用要自己记偏移,代码难维护 | 目录结构扁平度、长文件名支持方式 |
| 持久化 | 断电后绑定关系仍成立 | 掉电即损坏,可能整盘不可挂载 | 日志、影子页、事务边界的实现策略 |
| 介质抽象 | 屏蔽擦除与坏块差异 | 写放大失控、寿命提前耗尽 | 磨损均衡与回收策略、是否压缩 |
1.3 嵌入式文件系统的四个特殊约束
通用文件系统(ext4、F2FS 这类)为服务器和 PC 设计,那两个环境有大量资源可以挥霍。嵌入式环境的约束完全不同,以下四条会直接淘汰掉大量方案。
| 约束 | 具体表现 | 被它淘汰的做法 | 仍然可行的做法 |
|---|---|---|---|
| 资源受限 | RAM 紧张,代码段与 RAM 都要省 | 为每个文件维护内存索引树 | 把索引放在介质上,读时按需加载 |
| 掉电频繁 | 使用中拔电、低压复位是常态 | 假设一次写入总能完整落盘 | 所有元数据更新都要有掉电可恢复路径 |
| 介质不完美 | 出厂坏块、擦除不均、寿命有限 | 忽略预留与磨损均衡 | 预留空间 + 动态均衡写放大 |
| 生命周期长 | 设备在工作现场可能跑很多年 | 把 RAM 索引全部固化在镜像里 | 紧凑编码 + 按需恢复的元数据 |
这四条约束里,「掉电频繁」和「生命周期长」这一对是最容易被低估的。很多团队在实验室里用不断电的调试环境开发,文件系统在台式机上跑得好好的,一到现场频繁掉电就出问题。原因不在算法写错,而在于算法假设了一个比现场更温和的环境。
另一条容易被低估的是「介质不完美」。闪存的出厂坏块不是异常,而是规格的一部分;擦除次数有限也是物理事实。一个不处理坏块、不控制写放大的文件系统,哪怕功能完全正确,也会在寿命上先垮掉。
把通用文件系统直接裁剪后搬到 MCU 上,是一条看起来很自然、实际上很难走通的路。原因不在代码量,而在于设计前提不同:通用文件系统假设「块设备上的一个块被写入后可以被原地改写」,而 NOR 与 NAND 都做不到;通用文件系统假设「有足够的 RAM 缓存目录与索引」,而 MCU 常常只有几十 KB。
裁剪能省下代码,但省不掉这些前提。要真正可用,必须换成从一开始就是为闪存和小型设备设计的方案,而不是把为块设备设计的方案改小。
1.4 本文覆盖的九个系统与各自定位
本文横向对照九个系统。它们不是同一类东西:有的是为 MCU 写的库,有的是随内核分发的文件系统,有的是只读镜像格式。放在一张表里对照之前,先明确各自站在什么位置。
| 系统 | 宿主 | 设计出发点 | 一句话定位 |
|---|---|---|---|
| FatFs | MCU / RTOS / 裸机 | 兼容 FAT 生态,向上提供标准磁盘结构 | 通用性与兼容性优先的轻量方案 |
| LittleFS | MCU / RTOS / 裸机 | 为闪存特性重做设计,资源占用极低 | 小资源闪存场景的默认候选 |
| JFFS2 | Linux | 在 MTD 上做日志式可写文件系统 | NOR 与 NAND 上都很成熟的日志式选择 |
| YAFFS2 | Linux | 为 NAND 的页与块结构重新设计 | NAND 专用日志式,写入效率高 |
| UBIFS | Linux | 在 UBI 逻辑卷(MTD 之上)做日志式与压缩 | 大容量可写分区的常用主力 |
| F2FS | Linux | 用段与检查点适配闪存访问模式 | 顺序写为主场景的闪存优化方案 |
| ext4 | Linux | 通用块设备上的成熟日志式文件系统 | 默认选项,通用性强 |
| RomFS | Linux | 把镜像直接编进内存,不做动态管理 | 极简只读根文件系统 |
| SquashFS | Linux | 压缩只读镜像,按需解压读取 | 容量效率优先的只读根文件系统 |
1.5 本文要回答的问题与主线
本文的组织方式就是一条提问顺序。每一章回答其中一个问题,附录给出速查与排查。
| 顺序 | 要回答的问题 | 答错的后果 | 对应章节 |
|---|---|---|---|
| 第一问 | 介质是什么:NOR、NAND 还是块设备 | 把块设备系统硬挂到闪存上,写放大失控 | 第 2 章 |
| 第二问 | 掉电要承诺到哪一层 | 承诺强度与介质实际能力不匹配 | 第 3 章 |
| 第三问 | 能否接受闭源授权约束 | 闭源 MCU 产品只剩两个候选 | 第 11.4 节 |
| 第四问 | 资源占用与生态哪个优先 | 在等量候选里选错方向 | 第 11 章 |
四问之间是严格的依赖关系,不能挑着问。
介质决定候选集合,因为文件系统为哪种介质设计是写在它的结构里的,不是配置项。为块设备设计的系统在闪存上要靠介质抽象层打补丁,写放大与根因都由此而来。先谈算法等于在没有候选的地方比较优劣。
掉电承诺决定机制要求。承诺「元数据一致」的系统可以只做日志与影子页;承诺「事务原子」的系统必须处理写时复制与回滚。不问清要承诺什么,就无法判断某个机制是必需还是浪费。
授权约束决定剩余候选。这一问不涉及技术,却可能在最后一步把候选砍到只剩两个,此时前面所有技术比较都变成背景。
资源与生态是最后一层取舍,只有在前面三问筛完之后才有意义——对一个已经被排除的系统讨论 RAM 占用,是无效工作。
2. 第一性问题:介质决定候选集合
这一章确立全文的第一个筛选器:介质是什么,直接决定哪些文件系统可以进入候选,哪些必须被排除在集合之外。这一章要说明的不是「哪个介质更好」,而是「不同的物理特性如何改写文件系统的设计前提」,以及为什么把块设备系统硬挂到闪存上一定会付出代价。
2.1 NOR 与 NAND 的差别如何改写文件系统设计
NOR 与 NAND 都是非易失存储,但两者在读取方式、擦除粒度、坏块特性三个维度上的差别,会直接改写文件系统的结构。理解这三处差别,就理解了为什么面向 NOR 的文件系统与面向 NAND 的文件系统长得完全不一样。
- 读取方式:NOR 支持随机读,可以像内存一样按地址直接读出任意位置;NAND 必须先把一整页读进页缓存,再从缓存里取字节。这个差别意味着:NAND 上的文件系统不能假设「读一个字节只要一次寻址」,任何按需加载的机制都要按页为单位设计。
- 擦除粒度:NOR 的擦除单位通常较小,且写入可以先擦除再写,也可以就地把 1 编程为 0(任意改写仍需整块擦除);NAND 的擦除单位是块,量级远大于页,且改写前必须先擦除整块。这意味着 NAND 上「原地修改一个已有块」是不存在的操作,文件系统必须永远写到新块。
- 坏块特性:NOR 出厂时坏块很少或没有;NAND 出厂即允许存在坏块,且随擦写次数增加可能新增。这意味着 NAND 上的文件系统必须自带预留块与替换机制,否则写到坏块上就是永久丢数据。
把这三条合起来看:NOR 上的文件系统可以把「块」当作可以随机改写的空间来管理,NAND 上的文件系统必须把「块」当作只能顺序写、只能整块擦除的容器来管理。前者的复杂度集中在掉电一致性与目录组织,后者的复杂度还额外包含预留、回收与寿命均衡。
| 维度 | NOR Flash | NAND Flash | 对文件系统设计的影响 |
|---|---|---|---|
| 随机读 | 支持按地址直接读 | 需整页读入缓存后取 | 按需加载必须以页为单位设计 |
| 写操作 | NOR 可就地把 1 编程为 0(无需擦除);任意改写仍需整块擦除 | 须先擦除整块再写 | 不存在原地修改,写操作必须前进 |
| 擦除粒度 | 较小 | 较大,按块 | 块大小决定空间分配与回收的最小单位 |
| 坏块 | 很少或无 | 出厂即有,随使用增加 | 必须预留并做坏块替换 |
| 页与块关系 | 通常无明显页概念 | 页远小于块 | 写入以页推进,擦除以块为单位 |
| 典型形态 | 代码存储、小容量数据 | 大容量数据存储 | 前者要省资源,后者要控寿命 |
2.2 擦除粒度与块大小的关系
块大小与文件系统的块大小之间有一个必须注意的比例关系:文件系统块不能大于介质擦除块。否则一次文件系统级的写入会横跨多个介质擦除块,要么无法原子完成,要么需要额外的读-改-写流程,把写放大推到不可接受的水平。
反过来,文件系统块远小于擦除块也是问题。举一个量级上的例子:当文件系统块远小于介质擦除块时,一次改写一个小文件的数据,会把整个擦除块读出、修改、再整块写回。写入量是实际修改量的很多倍,这就是写放大的最朴素来源。
因此「文件系统块大小」这个参数从来不是随便设的,它必须在空间利用率、索引粒度、掉电原子性、写放大之间取平衡。不同系统给出的默认值不同,但设计的取舍逻辑是同一个:让文件系统块与介质擦除块建立整数倍关系,让一次数据落盘尽量对齐到一次擦除操作。
具体到数值,本文只给量级判断:介质擦除块在数十 KB 到数百 KB 量级,文件系统块通常取其整数分之一或与之相当,具体取值以介质手册与系统文档为准。任何「推荐设为某个固定值」的说法都应结合实际介质核对。
2.3 块设备的三个假设哪些可以去掉
为块设备(SD 卡、eMMC)设计的文件系统,建立在三条假设之上。这三条假设在闪存介质上都不成立,所以直接挂载必然出问题。
第一条假设:块设备上的块可以被原地改写。SD 卡与 eMMC 对主机呈现的是块设备语义,主机可以读一个块、改几个字节、再写回同一个块。这个模型之所以成立,是因为卡内控制器维护了 FTL(映射表),把主机的逻辑改写转成「写到别处、更新映射」。把文件系统挂到裸闪存上时,这个 FTL 并不存在——要么你自己实现一个(那就是 YAFFS2/UBIFS 之类在做的事),要么让文件系统自己去处理「不能原地改写」这件事。后者如果没设计好,写放大和寿命问题会同时出现。
第二条假设:块地址直达介质,不需要预留。块设备已经把坏块处理掉了,文件系统不需要预留空间。而裸 NAND 上,坏块标记本身要占空间,垃圾回收要占空间,文件系统必须自己划出一部分容量作为「工作空间」,这部分容量对用户不可见。跳过预留直接挂载,是最常见的现场事故来源之一。
第三条假设:介质抽象层可以不管。ext4 这类系统假设下面有一个已经把磨损均衡、坏块重映射、TRIM 都做好的块设备。如果下面是裸闪存,它不会为裸闪存的特性做任何优化,写放大与寿命消耗会直接体现在应用层。
反过来说,为闪存设计的文件系统(LittleFS、JFFS2、YAFFS2、UBIFS、F2FS)挂到块设备上通常也是浪费——它们自己实现了介质管理,块设备的 FTL 又做了一遍,两层叠加会让写放大不降反升。每层只做自己该做的事,这是嵌入式存储栈的基本纪律。
| 块设备的假设 | 为什么在裸闪存上不成立 | 直接挂载的表现 | 正确做法 |
|---|---|---|---|
| 块可原地改写 | 闪存改写前须先擦除整块 | 写入不生效或静默丢失 | 用闪存原生文件系统,或自己实现 FTL |
| 无需预留空间 | 裸 NAND 有坏块与回收开销 | 写到坏块或空间莫名不足 | 按机制要求的比例预留,比例以文档为准 |
| 下层已做介质管理 | 裸闪存没有 FTL | 写放大高、寿命快速消耗 | 让文件系统或中间层负责其中一层 |
2.4 介质抽象层与挂载方式
同一套文件系统代码,能跑在 MCU 的外部 SPI NOR 上,也能跑在 Linux 的 eMMC 上,差别在于中间的抽象层。这个抽象层把「怎么把一段字节写进介质」这件事从文件系统里剥出来。
Linux 侧的常见做法是:裸闪存之上放一层设备映射(把物理块组织成逻辑卷),文件系统再挂在逻辑卷上。这层映射负责坏块替换与磨损均衡。UBIFS 这类系统的定位正是「逻辑卷之上的文件系统」,它假设下面那层已经把物理块的问题解决了;F2FS 与 ext4 也走同一条路径。
MCU 侧的常见做法不同:文件系统直接读写闪存的页与块,内部自己做介质管理。LittleFS 就是这样——它只要求你提供「读一个块」「擦除一个块」「写一个块」三个原语,剩下全由它负责。FatFs 更保守:它只要求你提供「按扇区读写」,介质管理完全推给下面的驱动。
这三种形态对应三种不同的责任划分:FatFs 把介质管理完全推出去,LittleFS 只做块级抽象、F2FS 与 ext4 把全部介质管理交给下层、而 YAFFS2 与 UBIFS 自己做了其中一部分。选型时先确认这一层的责任归属,比比较功能列表更能预测实际行为。
| 形态 | 代表系统 | 文件系统要求下层做什么 | 谁负责磨损均衡与坏块 | 移植工作量 |
|---|---|---|---|---|
| 扇区接口 | FatFs | 按固定大小读写扇区、给出容量 | 使用者(写驱动时补齐) | 最小,但要自己补的最多 |
| 块级原语 | LittleFS | 擦除一块、读一块、写一块 | 文件系统自己做一部分 | 中等,需理解块与页的关系 |
| 块级原语(更完整) | YAFFS2 | 擦除、读页、写页、查询坏块 | 文件系统与下层分工 | 中等,页语义必须准确 |
| 逻辑卷 | UBIFS | 提供逻辑卷与其上的空闲块管理 | 中间层(设备映射) | 较大,需先把逻辑卷搭起来 |
| 块设备 | F2FS / ext4 | 标准块设备语义 | 设备内部控制器 | 最小,但前提是设备已就绪 |
2.5 按介质给出的候选集合
把前四节合起来,按介质给出一个初始候选集合。这个集合只做排除,不做推荐——它排除掉那些结构上不可能在这个介质上正常工作的系统,剩下的还要继续回答后面三问。
| 介质 | 优先进入候选 | 结构上不适合 | 排除的理由 |
|---|---|---|---|
| NOR Flash | LittleFS、FatFs、JFFS2、RomFS | ext4(无 MTD 直挂路径) | 4 KB 块远小于擦除块,读改写放大严重 |
| 并行 / SPI NAND | YAFFS2、UBIFS、JFFS2、LittleFS | RomFS(镜像放不进容量) | 只读格式无法承载运行期写入 |
| SD 卡 / eMMC | ext4、F2FS、SquashFS | JFFS2 / UBIFS(均面向 MTD/UBI,块设备路径不对应) | 缺 MTD/UBI 层,块设备路径不对应 |
| 内存(无掉电) | RomFS、SquashFS | 全部可写系统 | 无掉电约束时一致性机制是纯开销 |
| 宿主机文件系统 | ext4、F2FS | 全部 MCU 侧库 | 这些系统没有宿主机移植层 |
结论:介质是筛选器。为块设备设计的系统(ext4、F2FS、UBIFS)挂到 NOR 上,必须靠介质抽象层打补丁;写放大、性能异常与寿命问题都由此而来,不是算法 bug。
这一章没有回答的问题是「要承诺什么」。介质只规定了「能不能工作」,没有规定「工作到什么程度」。同在 NAND 上,要求元数据一致的系统与要求事务原子的系统,需要的机制完全不同。
也没有回答「能不能接受闭源约束」。上面的候选集合里,有相当一部分随内核分发、以 GPL-2.0 条款提供。如果产品必须闭源,这个集合在第三问就会进一步收窄——这一条放在第 11.4 节展开,因为它虽然与技术无关,却常常是最后生效的那一刀。
3. 掉电模型:文件系统承诺什么
掉电是嵌入式文件系统唯一真正困难的场景。本章把「掉电安全」这个笼统的说法拆成三个可以分别选择的承诺层级,并说明每种机制分别能兑现哪一层。本章的核心判断是:选型失败多数不是算法问题,而是要承诺的层级与介质实际能给的层级不匹配。
3.1 掉电时到底会坏在哪一层
一次文件写入在系统内部要经过若干环节,掉电可能发生在任何一个环节上。不同环节的断电,后果完全不同——把它们混为一谈,是「我们的文件系统应该做到不丢数据」这类需求无法落地的根本原因。
- 数据还在缓冲区时断电:介质上什么都没变,文件保持旧内容。对文件系统来说这是最轻松的一种断电,什么都不需要恢复。
- 元数据改了一半时断电:目录项或索引指向了一个还没写完的区域。这是最危险的一种,因为结构本身已经不合法,挂载可能直接失败,或者更糟——挂载成功但读出乱码。
- 数据写到一半时断电:目标位置的内容是不确定的。如果文件系统之前已经把元数据指向了这里,就会读到半新半旧的数据。
- 写入已返回、但介质缓存尚未固化时断电:上层认为写成功了,介质上前一个版本还在。这一种最隐蔽,因为没有任何错误码能提示它。
前三种,文件系统可以通过机制设计做到「可恢复」;第四种,取决于介质与控制器是否实现了掉电保护,文件系统层面无能为力,只能依赖下层的Flush 命令语义或带掉电保护的器件。
| 断电发生的环节 | 介质上的状态 | 文件系统能否恢复 | 需要什么机制 |
|---|---|---|---|
| 缓冲区未落盘 | 完全未改动 | 无需处理,直接保持旧内容 | 无 |
| 元数据改写中途 | 结构不合法 | 可以恢复到上一个一致点 | 日志重放或双副本校验 |
| 数据写入中途 | 目标区内容不确定 | 元数据不能指向未完成区域 | 先写数据后提交元数据 |
| 返回成功但缓存未固化 | 仍是旧版本 | 文件系统层面无能为力 | 依赖下层掉电保护语义 |
3.2 三个层级:数据完整、元数据一致、事务原子
「掉电安全」不是一个二元属性。把它的要求拆成三个递进的层级后,选型对话才能变得具体——你可以明确说出产品需要哪一层,而不是笼统地说「要可靠」。
第一层是数据完整:已经写进去的字节,读回来还是对的。这是最基本的承诺,绝大多数文件系统都能做到——只要介质本身可靠。这一层不讲掉电,讲的是介质可靠性与错误校验。
第二层是元数据一致:掉电后重新挂载,文件系统的结构必须仍然是合法的。目录树不能出现悬空指针,索引不能指向不存在的块,分配表不能自相矛盾。这一层是嵌入式文件系统的基本要求,也是日志式与双副本机制真正在解决的问题。
第三层是事务原子:一个「写操作」要么全部生效,要么完全不生效,中间不存在「一半」的可见状态。这一层比前两层严格得多——它要求多个文件、多个块之间的修改作为一个整体提交或放弃,绝大多数文件系统在单文件范围内能做到,跨文件范围就很少有系统提供。
三层的实现代价是递增的。产品实际需要哪一层,取决于业务能接受什么后果:只是记录传感器采样值,第一层足够;维护一份需要长期累积的日志文件,第二层是底线;维护一个需要「要么全做完要么全没做」的配置事务,才需要第三层。
| 层级 | 承诺内容 | 典型要求 | 实现代价 | 常见误解 |
|---|---|---|---|---|
| 数据完整 | 写入的字节读回一致 | 错误校验、坏块处理 | 低 | 以为它等于掉电安全 |
| 元数据一致 | 掉电后结构仍然合法 | 日志、影子页、原子提交 | 中 | 以为有了它就丢不了数据 |
| 事务原子 | 一组修改要么全成要么全不成 | 写时复制、回滚、多副本 | 高 | 以为所有文件系统都提供 |
把「我们要掉电安全」直接交给文件系统选型,几乎得不到有用的回答。改成下面三句,每一句都能对应到具体的机制判断:
- 第一句:设备运行中可能被直接断电,断电后设备必须能自动重启并挂载成功,不能要求人工干预。这对应第二层(元数据一致),是绝大多数嵌入式产品的实际要求。
- 第二句:断电时正在写的那个文件,允许丢失,也可以退回到上一个完整版本。这仍然是第二层,只是明确了边界。
- 第三句:断电不能损坏已经关闭的文件,也不能让已确认保存的数据消失。这仍然主要是第二层,但要求「提交」边界清晰。
- 只有当业务确实需要「跨多个文件的一次操作不能做一半」时,才进入第三层的讨论。
把层级讲清楚之后,选型就变成一个可以查表的问题:这个系统的机制覆盖到第几层,覆盖不到的代价是什么。
3.3 日志式:把变化先顺序追加
日志式的核心思想只有一句:不要原地修改任何东西,把「我要做什么」按顺序追加写到介质末尾,重启时从头重放一遍。因为写入是纯追加的,每个块在被写满之前不会被重复写,所以掉电最多丢掉最后一条不完整的记录,前面的记录依然有效。
这个思路把「修改一个已存在的结构」变成了「追加一条描述这个修改的记录」,后者在顺序写的介质上几乎免费。它的代价同样明显:
- 需要额外空间:日志区要占容量,而且必须保证日志区本身写满前有空间可用。
- 挂载要重放:重启时要把日志从头扫一遍,介质上数据越多,挂载越慢。
- 写放大:日志满了之后要做垃圾回收,把仍然有效的数据合并写回,这一步是主要写放大的来源。
JFFS2、YAFFS2、UBIFS、F2FS 都属于这一类。它们之间的差别,不在「要不要日志」这个根本选择上,而在日志的记录粒度、垃圾回收的触发策略、以及是否压缩这三个细节上。
3.4 双副本与影子页:靠冗余而不是靠日志
另一条路径是双副本:同一份关键结构存两份,写的时候只改其中一份,另一份保持完整;两份都不可信时才用第三份或退回默认值。恢复时读出所有副本,选出校验通过且计数最大的那一份。
这条路径的特点是恢复极快——不需要从头扫描介质,只要读几个已知位置的块,比较计数即可。这对启动时间敏感的设备(电机、控制器、仪表)很有价值:挂载耗时基本与文件数量无关。
它的代价也很清楚:
- 需要预留副本空间,这部分容量不可用于存数据。
- 更新次数受限:只有两份副本时,写满两份才能回收最旧的一份,这个循环要求有额外的空闲块存在,否则文件系统只能进入只读状态。
- 只保护被显式复制的结构:目录项与文件数据本身如果不在副本范围内,仍然需要别的机制保护。
LittleFS 是把这条路径用到比较彻底的系统:它的每一个文件与目录都有一对元数据块,因此挂载开销与文件总数基本无关,掉电恢复也只需要读块头。
3.5 原子写与写时复制
前面两种机制解决的是「结构不损坏」,但还有一个更细的问题:能不能让一次修改在外部看来是不可分割的。这就是原子写。
实现原子写的手段基本只有一种:写时复制。把要改的内容写到一份新的副本上,写完之后再用一个原子的动作把「指向哪一份」切换过去。旧副本在切换成功之前一直是有效的,因此任何时刻掉电,要么看到旧的,要么看到新的,不存在中间态。
写时复制的实现代价是空间与时间都要翻倍:修改一个已有文件需要额外空间存放新副本,写完还要清理旧副本。对嵌入式产品来说,这意味着两件事——容量要留出余量,写延迟会增加。
需要区分的是两种不同粒度的原子性:
- 文件内原子写:一个文件的内容要么是旧版本要么是新版本。多数日志式文件系统都能做到,因为文件内容本身就是按顺序写的。
- 跨文件原子写:多个文件的修改作为一个整体提交。实现这个需要在文件之上再加一层事务机制,多数通用文件系统并不提供。
F2FS 提供了一种较特别的机制:同一文件内的多次小写入可以在写时复制配合回滚的情况下表现为一次原子更新,这也是它被大量用于手机存储的原因之一。但这个能力的边界仍然是文件内,不是跨文件。
| 原子性粒度 | 能做到的系统 | 实现代价 | 典型需求 |
|---|---|---|---|
| 单次写入不撕裂 | 全部九个系统 | 低,写入本身按最小单位完成 | 单条记录写入 |
| 单文件内多次写作为一次 | F2FS、部分日志式系统 | 中,需版本号与回滚 | 频繁改写同一配置文件 |
| 单文件更新可回退到旧版 | F2FS | 中,需保留旧版本位置 | 状态文件、计数器累加 |
| 跨文件事务 | 需更上层机制 | 高,需应用层或专门机制 | 多个配置项要么全改要么全不改 |
| 跨文件加跨目录事务 | 极少数系统,需定制 | 很高 | 目录与内容必须同时切换 |
| 机制 | 保护的对象 | 恢复方式 | 主要代价 | 典型代表 |
|---|---|---|---|---|
| 日志重放 | 目录、索引、分配表 | 从头扫描并重放 | 挂载慢、写放大 | JFFS2 / YAFFS2 / UBIFS |
| 多副本 | 被显式复制的关键结构 | 比较计数选最新 | 预留空间 | LittleFS 元数据对 |
| 写时复制 | 被替换的旧数据 | 切换指向即可 | 空间与延迟翻倍 | 带原子写支持的日志式系统 |
| 校验 + 纠错 | 数据内容本身 | 读时纠正 | 额外开销 | 依赖下层 ECC |
3.6 「不丢数据」与「不丢已提交的数据」不是一回事
这是本章最重要的一条,也是需求沟通里最容易出错的地方。
「不丢数据」的字面含义是:任何时刻断电,之前写过的每一个字节都还在。文件系统无法兑现这个要求,因为写入可能根本没有落到介质上——数据还在缓冲区里,或者在介质控制器的缓存里,断电就没了。这不是文件系统实现得不好,是物理事实。
「不丢已提交的数据」是一个可兑现的要求:凡是已经向调用方返回「写成功」的写入,即使随后立刻断电,内容也必须读得回来。这个要求能够满足,但代价是:每次写入都必须真正固化到介质,操作会明显变慢,而且这个保证的强度取决于介质与控制器是否实现了掉电保护。
两者的差距就是「返回成功」到「真正固化」之间的那一段时间。消除这段差距的唯一办法是在返回前强制固化,而这正是同步写模式的代价。
不要写「文件系统必须保证掉电不丢数据」。这句话无法验收,因为没人能定义「返回成功」的确切时刻。
改成下面这种写法,就能直接查表验收:
- 允许丢失的范围:正在写入中的文件内容、尚未调用关闭的文件、未同步的缓冲区,明确列为可丢。
- 必须保留的范围:已经关闭的文件、已经返回成功的配置写入,明确列为不可丢。
- 一致性要求:断电重启后必须能自动挂载,不允许出现「需要格式化才能使用」的情况。
- 边界条件:掉电发生在写入中途时的期望行为,是退回上一个版本还是整个文件损坏,两种都可以接受,但要选定一种并验证。
这样写出来的需求,可以直接对应到文件系统需要提供的机制层级,也可以在验收时用断电测试逐条验证。而「掉电安全」四个字什么都验证不了。
4. LittleFS:面向闪存的小型文件系统
LittleFS 是 MCU 侧最常被推荐的闪存文件系统,本章拆开它的四个关键设计。它的价值不在于某项指标最优,而在于把「掉电可恢复、磨损均衡、极小 RAM 占用」这三件难事用同一套机制同时做到了——这个机制就是元数据对。理解了它,后面所有特性都能顺下来。
4.1 设计目标与定位
LittleFS 从设计之初就明确了三件事,因此它的形态与其他系统差别明显。
- 为闪存设计,不是为块设备裁剪。它假设介质只能整块擦除、只能顺序写、没有原地改写。这不是妥协,而是把它做对的前提。
- RAM 占用与文件数量无关。目录、索引、属性全部存放在介质上的元数据块里,内存里只保留当前访问路径的少量缓存。这一点在小内存 MCU 上是决定性的——传统设计要为每个文件在 RAM 里留结构体,文件一多就撑不住。
- 掉电安全来自结构本身,不是来自附加的日志。元数据成对存放、写新不写旧,使得任何时刻断电都能读出一个完整版本。
它的宿主形态是一个可以被链接进固件的库,不依赖操作系统,不依赖多线程保护(同一实例不可并发访问),这一点与 Linux 侧的文件系统有本质区别。
4.2 块与元数据对的组织
LittleFS 把介质切成等大的块,然后把两个块绑定成一个「元数据对」。每个文件、每个目录都至少占用一对。目录本身也是一种元数据,存放在元数据对里,这解决了「目录在哪里」的问题。
元数据对的核心规则只有一条:更新时永远往计数较旧的那一块写,写完把计数加一;两块都写过之后,才擦除最旧的一份开始新的一轮。这条规则同时产生了三个效果:
- 掉电安全:写新块时旧块仍然完整,读到旧版本就是一个合法的一致状态。
- 磨损均衡:同一份元数据被反复更新时,写入位置在两块之间来回轮换,之后又扩展到整个介质,擦除分布天然均匀。
- 恢复简单:挂载时只需要读块头比较计数,不需要扫描整个介质。
元数据块里除了计数,还带有头部标记与内容校验。校验的作用是区分「这一块写完了」和「这一块写到一半断电了」——没有校验就无法判断该信任哪一份。
4.3 动态磨损均衡
静态磨损均衡(周期性搬移冷数据,使长期不写的块也参与磨损分摊)实现简单但效果有限;LittleFS 走的是动态路线:磨损均衡不靠周期性搬移冷数据,而是内建于「元数据对的轮换写」本身。
这个设计带来一个实际后果:小文件频繁更新的场景下,写放大被有效控制住,不会出现「改一个字节要擦一整块」的情况。因为元数据块本身已经很大,一次更新只写一个元数据块而不是整个文件。
但它也有一个明确的短板:数据块本身不是元数据对的一部分。如果某个文件的数据部分反复被覆写,LittleFS 会通过「写新块 + 更新元数据指向 + 回收旧块」来处理,回收不完全时会产生写放大。在数据量远大于元数据量的场景里,这一点比元数据侧的均衡更值得实测关注。
4.4 掉电恢复与预留块
掉电恢复的过程很短:读每个元数据对的两个块头,比较计数与校验,选出可信的那一份。如果两个都不可信,说明这一对已经彻底损坏——在正常设计下这不应该发生,因为两份同时写坏需要连续两次故障。
这里引出一个必须理解的约束:LittleFS 需要预留一部分空闲块,作用是给回收过程提供落脚的临时位置。预留不足的直接表现是文件系统进入只读状态——它检测到没有足够空间完成一次安全的元数据更新,于是拒绝写入以避免破坏自身。
所以「预留块」在 LittleFS 里不是可选优化,而是保证系统还能继续运行的前提条件。介质被写满时,只有预留块还在,文件系统就还能正常卸载、正常重新挂载。把这个行为误解成「文件系统坏了」是很常见的现场误判。
| 预留不足时的表现 | 系统实际发生了什么 | 该怎么处理 | 不该怎么处理 |
|---|---|---|---|
| 挂载后变成只读 | 文件系统拒绝写入以自保 | 检查剩余容量与预留比例 | 反复重试写入 |
| 卸载时报错 | 无法完成最后一次元数据提交 | 清理数据后重新挂载 | 强行擦除介质 |
| 新建文件失败 | 没有可用空间完成元数据更新 | 按产品需求清理旧数据 | 降低预留比例硬撑 |
| 重新挂载正常 | 说明只是瞬时空间不足 | 增加数据清理策略 | 认为文件系统有 bug |
4.5 RAM 占用与静态镜像生成流程
LittleFS 的 RAM 占用主要由两部分构成:文件读写缓冲区(每个打开的文件需要一份)与挂载时的少量全局状态。由于目录与索引不常驻内存,RAM 占用基本不随文件数量增长——这是它在小内存 MCU 上最实际的价值。
LittleFS 还有一个容易被忽略但对产品化很关键的特性:可以把整个文件系统生成成一个静态镜像数组,编译进固件。这样一来,固件里就带着一份已经格式化好的文件系统,开机直接使用,连首次格式化的时间都省掉了。
生成流程大致是:在 PC 上运行一份宿主工具,把源目录的内容打包成 C 数组文件,然后在固件编译时把它链接进去。这套流程的价值在于——资源文件(字库、默认配置、证书)的更新不需要额外的下载流程,直接随固件升级一起走。
| 步骤 | 在哪台机器上做 | 做什么 | 常见坑 |
|---|---|---|---|
| 准备源目录 | PC | 按目标路径组织好资源文件 | 路径层级与运行时不一致 |
| 生成镜像 | PC(宿主工具) | 把目录转成可写入介质的镜像文件 | 工具版本与固件侧不匹配 |
| 转成数组 | PC(宿主工具) | 把镜像编码成 C 源文件 | 生成后源目录又改了,未重生成 |
| 编译链接 | 固件构建 | 把数组与固件一起编译 | 数组体积撑爆 RAM |
| 运行时挂载 | 设备 | 直接挂载,不需要格式化 | 误以为能在设备上重新生成 |
4.6 优势、限制与典型陷阱
优势集中在三点:RAM 占用与文件数无关;掉电恢复只读块头,速度快且不依赖文件数量;结构上不需要额外的日志区,介质利用率较高。
限制同样明确:
- 不是 FAT 格式。PC、相机、手机无法直接读取。这意味着通过 USB 导出文件时需要额外的转换层。
- 单实例不可并发。同一文件系统实例不能被多线程同时操作,跨线程共享需要自己加锁。
- 需要预留块。空间利用率不是满的,介质接近满时的行为需要提前设计好应对。
- 大容量与小文件场景要实测。目录层级很深或文件数量极大时,元数据对的组织会带来额外开销,具体影响随配置变化。
陷阱一:以为它是 FAT。LittleFS 不产生 PC 能读的目录。把含 LittleFS 分区的存储卡插到电脑上,操作系统会认为它没有文件系统。如果产品需要用户直接取数据,必须在导出环节做格式转换,或者改用 FatFs。
陷阱二:忘记预留块。预留不足时文件系统进入只读,看起来像「存储坏了」。正确做法是在设计阶段就按使用模式估算峰值占用,把清理策略一起设计进去。
陷阱三:多线程直接共享实例。LittleFS 的设计假设是单线程访问。多线程场景下不加锁直接调用,出现的是随机的元数据损坏,排查成本很高。要么在封装层加一把全局锁,要么每个线程用独立实例。
| 对比项 | LittleFS | FatFs | 选型提示 |
|---|---|---|---|
| 介质假设 | 闪存原生,整块擦除 | 扇区读写,介质无关 | 前者更适合裸闪存 |
| 掉电机制 | 元数据对轮换写 | FAT 双备份 + 写入顺序 | 两者都能到元数据一致层 |
| RAM 占用 | 与文件数基本无关 | 与文件数与目录项相关 | 小内存优先前者 |
| 格式兼容 | 自有格式 | 标准 FAT | 需要 PC 直读选后者 |
| 静态镜像 | 支持生成 C 数组 | 需自行实现 | 资源随固件分发的场景前者省事 |
| 授权条款 | BSD 类 | 官方自定义免费条款 | 均适合闭源, specifics 以官方条款为准 |