第四章 LittleFS——一个为嵌入式而生的文件系统
4.1 LittleFS设计哲学——最小的元数据,最大的可靠性
你在一个32KB内存的芯片上写文件
想象一个场景:你面前是一块Cortex-M4微控制器,32KB RAM,512KB ROM。它挂着一颗4MB的SPI NOR Flash,通过四条线(MISO/MOSI/SCK/CS)与主控通信。你的任务是——在这个设备上提供一个完整的、支持目录结构的、掉电不丢数据的文件系统。
你脑子里的第一反应大概率是:这怎么可能?
操作系统课上学过的ext4,内核代码量以十万行计,内存占用以MB计。FAT系列虽然简单,但它的FAT表需要在内存中维护——你的32KB RAM连一个4MB空间的FAT表都装不下。SPIFFS是为嵌入式设计的,但它对NOR Flash的写入特性做了过于乐观的假设——它依赖NOR Flash支持按字节累写的特性来做metadata更新,换个Flash型号就可能出问题。YAFFS是为NAND设计的,它的大页假设和OOB(Out-Of-Band)区域概念在NOR上完全不适用。
在嵌入式存储领域,文件系统的设计不是“哪个最好“的问题,而是“哪个约束最少地折磨你“的问题。
在第三章我们建立了文件系统设计的理论基础——块设备抽象、FAT 的教训、LFS 的日志哲学、元数据的三重保护、磨损均衡、掉电安全。在第五章我们将亲手实现 KnotFS——一个将这些理论付诸代码的教学级文件系统。
而夹在这之间的第四章是一个“对比实验室“。我们研读 LittleFS——不是因为它比 KnotFS“更好“(它们是两个不同约束下的产物),而是因为 LittleFS 是目前最成功的嵌入式文件系统开源实现之一。它的 Metadata Pair、CTZ Skip-List、lookahead 分配器——每一个数据结构都是对第三章理论的工业级回应。读懂 LittleFS 的设计决策,你在第五章写 KnotFS 时就知道:哪些地方我照抄工业答案、哪些地方我的约束不同要另辟蹊径、哪些地方工业级的复杂度是我的教学版刻意砍掉的。
这就是LittleFS诞生的背景。2017年,ARM Mbed团队(现归属Linaro社区维护)面对着一个经典的嵌入式困境:他们需要一个文件系统,但它必须同时满足三个几乎互相矛盾的需求。
三个互相掐架的需求
让我们把这三个需求摊在桌面上:
第一:掉电安全(Power-Loss Resilience)。
嵌入式设备没有关机流程。你的冰箱控制板、无人机飞控、工业传感器、智能穿戴设备——它们可能在任何一个指令周期被拉断电源。如果在断电的瞬间,文件系统正在写入元数据——比如正在把某个文件的“目录项“从旧位置复制到新位置——那结果可能是灾难性的:目录结构损坏、文件孤儿化、甚至整个文件系统无法挂载。
这不是概率问题。如果设备设计寿命是10年,每天断电1次,那就是3650次写入-断电竞态。只要有一次撞上元数据更新的“窗口期“,设备就废了。
写入元数据的危险窗口
============================
[擦除旧块] [写入新数据] [更新指针] [写入校验和]
^ ^
| |
└──── 断电窗口 ———— 在这个区间内断电, ─────┘
旧数据已擦除,新数据未完成,文件系统损坏
而在嵌入式系统中,你无法通过“加一个UPS“来解决这个问题。你必须保证——在任何时刻断电,文件系统要么回到上一个完整状态,要么到达下一个完整状态,绝不停留在中间态。
第二:磨损均衡(Wear Leveling)。
NOR Flash的每个擦除块(通常是4KB)有擦除寿命限制。消费级芯片大约10万次擦除,工业级可能更少。如果一个文件系统反复写同一个块——比如FAT文件系统每次更新文件时都重写FAT表所在的扇区——那这个块的寿命就是整个设备的寿命。
更隐蔽的问题在于:磨损不是均匀的。元数据块(superblock、FAT表、inode表、目录区)的更新频率远高于数据块。一个根目录的目录块可能每分钟被更新一次,而一个日志文件的数据块可能一天才写一次。这意味着——元数据区会比数据区早几十倍磨损殆尽。
磨损不均匀的可怕真相
=========================================
元数据块(superblock): ████████████████████ 每分钟都在写
元数据块(目录): ████████████████████ 每小时写几十次
数据块(日志文件): ████ 每天写几次
数据块(配置文件): ██ 几乎不写
元数据块死的时候,设备就死了。
哪怕99%的数据块还完好如新。
第三:有界RAM/ROM(Bounded RAM/ROM)。
这是最狠的一条。你的RAM只有32KB。你不能像ext4那样在内存中维护一个inode缓存池,不能像btrfs那样构建一棵B树来索引空闲块,不能用“先扫描一遍全盘“的方式来初始化——因为扫描意味着遍历所有数据,遍历意味着缓存,缓存意味着RAM。
而且,RAM的使用量必须在运行时保持恒定——不管文件系统上有1个文件还是10000个文件,不管空闲空间是99%还是1%,你占用的RAM必须是一样的。这个要求把所有“分配一个动态数组来缓存什么什么东西“的方案都毙了。
LittleFS的核心答案:把日志和COW焊在一起
面对这三个需求,LittleFS的设计者做了一个非常聪明的观察——
先看两类极端的设计:
纯日志文件系统(SPIFFS, JFFS2, YAFFS):
日志文件系统把整个存储空间当作一个环形缓冲区。每次写操作都是在这个缓冲区的末尾追加一条新记录。因为从不原地覆盖,掉电安全天然保证——断电时最多丢掉最后一条没写完的追加记录,而之前的状态完整保留。同时,因为数据在环形缓冲区里不停地“流动“,磨损天然均匀。
但日志文件系统的致命弱点是性能。读一个文件需要从日志头遍历到日志尾——你相当于在读取“这个文件从创建到现在的全部历史“。SPIFFS尝试用NOR Flash的可累写特性来优化这一点,但代价是绑定了特定物理介质。YAFFS用RAM缓存来加速,但代价是O(n)的内存增长。纯日志文件系统的运行时要么是O(n²),要么需要O(n)的RAM——两者在嵌入式环境都是不可接受的。
纯日志文件系统
=========================================
设备:
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ C │ newB │ newA │ │ A │ B │ │ │
│ │ │ │ → │ │ │ │ │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
↑
写入方向(环形)
要读文件A?需要从最新的"newA"记录一路回溯到最初的"A"记录
COW(Copy-On-Write)文件系统(btrfs, ZFS):
COW文件系统的核心原则是“永远不原地修改“。当你需要更新一个数据块时,你把它复制到一个新块上,在新块上做修改,然后更新指向它的指针——但指针也被存在块里,所以指针的修改也触发了一次复制……这个过程一直向上传播,直到到达根节点。
COW的性能很好——因为读取路径和普通文件系统一样是O(log n)。但问题在于:(1)一次写入可能触发一连串的逐级复制,放大了写入量;(2)写入向上传播会把磨损集中到树的上层——根节点块的磨损会比叶子节点严重得多。
COW文件系统
=========================================
┌──────┐ ┌──────────┐
│ root │ 写入文件 │ new root │
│ │ ==> │ │
└──────┘ └──────────┘
┌─┘ └─┐ ┌─┘ │
│ │ │ ┌───────┘
v v │ v
┌──────┐ ┌──────┐ │ ┌──────────┐
│ A │ │ B │ │ │ new B │ ← 复制了B
│ │ │ │ │ │ │
└──────┘ └──────┘ │ └──────────┘
┌─┘ ┌─┘ └─┐ │ ┌─┘ │
│ │ │ │ │ ┌───────┘
v v v │ v v
┌──────┐ ┌──────┐ ┌──────┐ │ ┌──────────┐
│ C │ │ D │ │ E │ │ │ new D │ ← 复制了D
│ │ │ │ │ │ │ │ │
└──────┘ └──────┘ └──────┘ │ └──────────┘
D被修改了 → B被复制了 → root被复制了 → 磨损集中在root
LittleFS的洞察是:这两者的弱点互为解药。
日志的弱点是运行时效率差——但如果把日志的大小限制在两块(metadata pair),那日志的操作就变成了O(1)(因为输入有界)。
COW的弱点是磨损向上传播——但如果把“写一次就复制“改成“写N次才复制“(Copy-on-Bounded-Writes, CObW),那磨损传播在每一层上都被除以N。当N足够大(大于分支因子)时,上层不再比下层磨损更快。
而两者的结合创造了奇迹:metadata pair(小日志)提供了任意位置的原子更新能力;CObW树提供了紧凑的数据存储和读性能。两者分别解决了对方的问题,合在一起形成了一个完整的、优雅的解决方案。
LittleFS = 日志 + COW 的焊接
=========================================
root ← 这是一个metadata pair(两块的日志)
┌──────────┬──────────┐
│ A'│ B' │ │
│ │ │ → │
└──────────┴──────────┘
┌────┘ └─────────.
A v B v
┌──────────┬──────────┐ ┌──────────┬──────────┐
│ C'│ D' │ │ │ E'│new │ │
│ │ │ → │ │ │ E' │ → │
└──────────┴──────────┘ └──────────┴──────────┘
┌─┘ └─┐ ┌─┘ └──────────.
v v │ v
┌────────┐ ┌────────┐ ┌─┘ ┌────────┐
│ C │ │ D │ v │ new E │ ← COW数据块
└────────┘ └────────┘ ┌────────┐ └────────┘
│ E │
└────────┘
这就是LittleFS的核心设计哲学:Metadata Pair(两页互为备份的日志)负责原子元数据提交,CTZ Skip-List(COW跳表)提供紧凑数据存储,而Lookahead Buffer(固定大小的预搜索缓冲区)则把块分配限制在有界内存中。
“Strong Guarantee”——LittleFS的可靠性哲学
在DESIGN.md的开篇,LittleFS的设计者写了一段话,可以被视为这个文件系统的“宪法“:
“The question was: How would you build a filesystem that is resilient to power-loss and flash wear without using unbounded memory?”
翻译过来就是:“如何在不用无限内存的前提下,构建一个既抗掉电又抗磨损的文件系统?”
这个问题定义了LittleFS的全部设计决策。它不是一个“我要做个性能最好的文件系统“,也不是“我要做个功能最多的文件系统“。它的目标极其聚焦:在最恶劣的嵌入式环境下,保证数据的完整性。
让我们看看LittleFS提供的“强保证“具体是什么:
1. 原子目录提交。 LittleFS不是“尽量原子“——它保证目录操作的原子性。一次目录提交(创建文件、删除文件、重命名文件……)要么完整发生,要么完全不发生。这是通过metadata pair的两块交替机制实现的:提交写到非活跃块,写完后翻转活跃状态。在任何时刻断电,总有一块是完整的。
2. 有界RAM。 LittleFS的RAM使用量与文件系统的大小、文件数量、文件大小——通通无关。无论你有1GB还是1MB的存储空间,LittleFS占用的RAM是固定的,由编译时配置决定。这一条的达成非常不易——它意味着你不能缓存文件列表、不能缓存inode、不能缓存空闲块位图……每一种常见的优化手段都被禁止了。
3. 动态磨损均衡。 LittleFS不维护每个块的精确擦除计数(那需要额外内存),而是通过统计分布来实现磨损均衡。块分配器在分配空闲块时总是从上次停止的位置继续向前扫描,形成一个环绕设备的均匀分配模式。同时在每次挂载时,用一个基于磁盘CRC的随机种子来确定分配的起始偏移——保证每次上电后分配模式都不同。
4. 坏块检测与恢复。 每次写入后,LittleFS立即把写进去的数据读回来比对。如果不一致,标记坏块,分配新块,重写数据。这是通过CObW结构的天然能力实现的——任何块都可以在COW操作中无损替换。
这四条保证构成了LittleFS的可靠性底线。它不承诺最快的速度,不承诺最少的写入放大,但它承诺——在任何掉电场景下,你的数据要么是完整的,要么是可恢复的。
与FAT、SPIFFS、YAFFS的正面对比
一张表胜过千言万语:
文件系统对比
======================================================
LittleFS FAT32 SPIFFS YAFFS2
------------------------------------------------------
掉电安全 ✅ 原子 ❌ 危险 ✅ 日志 ✅ 日志
磨损均衡 ✅ 动态 ❌ 无 ✅ 完美 ✅ 完美
有界RAM ✅ 是 ⚠️ 可配 ❌ 否 ❌ 否
代码量 ~7K 行 ~3K 行 ~4K 行 ~15K 行
目标介质 NOR/NAND 磁盘 NOR NAND
动态坏块管理 ✅ 是 ❌ 无 ❌ 无 ✅ 是
目录结构 树+链表 树 扁平 树
文件名长度 最多255 8+3/255 最多255 最多255
文件大小上限 2GB 4GB 不限制 不限制
需要闭操作 ✅ sync ✅ 是 ✅ 是 ✅ 是
======================================================
FAT是一个“意外成为嵌入式标准的文件系统“。它简单、被广泛支持,但它的FAT表是单点故障——FAT表损坏就意味着整个文件系统的目录结构丢失。而且FAT没有任何掉电保护,原地修改FAT表时断电等于自杀。
SPIFFS是专门为SPI NOR Flash设计的,它利用了NOR Flash的一个特性:将1变成0不需要擦除(只需要编程),所以它可以在一个已擦除的页上渐进地累写数据。这让SPIFFS可以做到无RAM消耗的日志文件系统。但这一策略绑定了物理特性——不能用在NAND上,也不能用在某些不保证“累写不变“的NOR芯片上。而且SPIFFS的垃圾回收需要O(n²)时间,在大文件系统上性能退化严重。
YAFFS是NAND世界的经典,它利用NAND的OOB(备用区)存储元数据标签,实现了高度优化的日志结构。但它的设计深度绑定NAND页结构(2KB数据+64B OOB),在NOR Flash上完全无法工作。
LittleFS选择了一条不同的路:它不绑定任何物理介质的特殊性质。它只用read、prog、erase三个回调——这是所有Flash都支持的三个基础操作。这个极简的抽象让它可以在NOR、NAND、eMMC、SD卡、甚至RAM盘上运行。而它的可靠性是通过纯软件算法(metadata pair + CRC + 原子提交)实现的,不依赖硬件的任何特殊能力。
数据结构全景:从Superblock到CTZ Skip-List
LittleFS的盘上数据结构形成了一套层次分明、各司其职的体系:
LittleFS数据结构总览
=========================================
┌──────────────────────┐
│ lfs_t (RAM) │ ← 运行时状态
│ root[2], 缓存, │
│ 全局状态, lookahead │
└──────────┬───────────┘
│
┌──────────┴───────────┐
│ Superblock Pair │ ← 两块,block 0 和 block 1
│ (内嵌在root目录中) │ 存储版本号、块大小、块数等
└──────────┬───────────┘
│
┌──────────┴───────────┐
│ Metadata Pair │ ← 每个目录一个
│ (两个block交替) │ 原子提交的日志
│ revision + tag + data │
└──────────┬───────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐
│ File Entry │ │ File Entry │ │ Dir Entry │ ← 目录项
│ name+CTZ │ │ name+CTZ │ │ name+pair │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐
│ CTZ Skip- │ │ Inline │ │ 子目录的 │
│ List (COW) │ │ 小文件 │ │ Metadata │
│ │ │ (直接存目录)│ │ Pair │
└─────────────┘ └─────────────┘ └─────────────┘
核心概念拆解:
Superblock: LittleFS的超级块不是独立的两个块,而是“嵌入在根目录的metadata pair中的一个特殊entry“。它的内容很简单——文件系统版本号、块大小、块数量、名字最大长度、文件最大大小、属性最大大小——总共6个字段,24字节。这体现了LittleFS“元数据最小化“的原则。
// lfs.h 中的 superblock 结构体
typedef struct lfs_superblock {
uint32_t version;
lfs_size_t block_size;
lfs_size_t block_count;
lfs_size_t name_max;
lfs_size_t file_max;
lfs_size_t attr_max;
} lfs_superblock_t;
是的,就这么多。没有魔数、没有挂载计数、没有最后检查时间、没有卷标——没有任何冗余信息。教学级实现(第三章的KnotFS)的superblock相比之下“臃肿“得多(包含了版本号、块大小、卷标、挂载状态等),但二者的设计上下文不同——KnotFS的superblock是独立的两个块,而LittleFS的superblock只是根目录中的一个条目,根目录本身的metadata pair已经提供了CRC校验和revision保护。
Metadata Pair: 这是LittleFS最核心的发明。每个目录(包括根目录)都关联一个metadata pair——两个物理块,一个活跃、一个备用。所有对该目录的修改(创建文件、删除文件、更新文件大小……)都以“追加条目“的方式写入活跃块,当活跃块满时触发compaction(紧凑化),把仍然有效的条目复制到备用块上,然后翻转备用块为新的活跃块,擦除旧块。
这个过程天然保证了原子性:在compaction的最后一步——写入CRC校验和之前——如果掉电,旧块上的数据仍然完好无损。写入CRC之后,新块变成活跃块,旧块的内容虽然还完整但不再是“最新版本“——而这是安全的状态:你只有“旧数据“和“新数据“两种选择,不存在第三种“部分写入“的损坏状态。
CTZ Skip-List: 这是一个纯COW的数据结构,专门为文件的追加写入和随机读取而设计。块之间以“后向跳表“的形式链接——每个块包含若干指向前面的块的指针,指针数量由该块的索引的二进制尾零(CTZ, Count Trailing Zeros)决定。这让追加操作是O(1)的(只需要新建最后一个块),随机读取是O(log n)的(通过跳表快速跳过不相关的块)。
CTZ跳表还有一个精妙的设计:只需要一个指针(head)和一个大小(size)就能唯一确定整个跳表。 因为给定block index,你就能通过CTZ公式算出它应该有多少个指针,进而推算出每个字节的位置。这让元数据的存储开销降到了最低——在目录中存储一个文件,只需要记录它的名字、类型、以及(ctz_head, ctz_size)两个值。
你必须接受的那些权衡
任何设计都是权衡。LittleFS的设计哲学是“要可靠、要省内存、要抗磨损“,这三个目标排序明显高于“要快“和“要省存储空间“。但你必须清楚这些权衡对你意味着什么:
1. 存储利用率。 Metadata pair的两块结构意味着——在最好的情况下(不分裂),你的元数据空间利用率是50%(一块活跃、一块备用)。再加上compaction的50%阈值(到达50%就开始分裂而不是等100%),实际元数据利用率大约是25%。这意味着如果你有128个4KB的块,根目录的metadata pair至少占用8KB,而实际能存的元数据大约只有2KB——够存几十个目录项。
2. 追加性能。 CTZ跳表的追加是O(1)的——但每次追加一个新块,需要分配块、擦除块、在新块中写入跳表指针、把旧块的部分数据复制过来。与原地修改(如FAT)相比,写入放大因子是1.5-2倍。
3. 随机写入的代价。 如果你在文件中间修改一块数据,CTZ跳表需要从那一点开始重新创建整条链——每个后续块都要复制。随机写入是O(n)的。这反映了LittleFS对“文件主要是追加写入的“这一假设——对于日志文件、配置文件、数据采集场景,假设成立;对于数据库文件、频繁修改的二进制文件,你需要心理准备。
4. 孤儿遍历的初始化开销。 挂载时,LittleFS会遍历整个目录链表来检查是否有孤儿目录。这个操作的时间复杂度是O(n²)——因为它需要对每个目录对和每个目录项做交叉比较。对于有大量目录的文件系统,挂载时间可能很长。好在LittleFS设计了global state机制来避免大多数情况下的全量检查。
从设计文档到6500行C代码
LittleFS的DESIGN.md堪称嵌入式软件设计文档的典范——它不告诉你“代码怎么写的“,而是告诉你“为什么这么写“。从“为什么metadata pair是两块而不是三块“,到“CTZ公式的OEIS数学推导“,到“随机数种子的xorshift熵源选择“,每一处设计决策都有清晰的理由。
而这6500行的lfs.c,则是设计文档的忠实执行——几乎所有的核心逻辑都在一个文件中,没有任何动态内存分配的“魔法“,每一个函数都小而清晰,每一个数据结构都有着明确的盘上格式。
在接下来四节中,我们将一层一层剥开LittleFS的设计:单文件代码结构(4.2)、Metadata Pair的原子提交机制(4.3)、CTZ跳表的COW数学(4.4)、以及Lookahead块分配器的磨损均衡策略(4.5)。
下集预告
LittleFS把6500行代码塞进一个lfs.c,不是偷懒——是一种工程上的深思熟虑。为什么要把块设备抽象、元数据管理、文件操作、CTZ跳表、块分配器全写在一个文件里?这种“单文件设计“为嵌入式编译带来了什么好处?下一节,我们从lfs.c的第一行走到最后一行,看看这个文件如何用精巧的分层结构,把一个复杂的文件系统组织得井井有条。
悬念留给:6500 行全是 static 函数——这意味着没有外部头文件暴露实现细节。好处是什么?坏处呢?4.2 从一个编译单元的内部切割开始,看看 LittleFS 怎么用 LFS_*_TRACE 宏在一个文件中做出清晰的分层。