3.5 嵌入式Flash文件系统图谱
你的面前摆着六个黑盒子
想象一个场景:你是一个嵌入式工程师,下周要提交一个产品固件。产品用了一颗16MB的SPI NOR Flash,跑FreeRTOS,Cortex-M4。你需要一个文件系统。
你打开GitHub。你搜索“embedded file system flash“。你看到了:
- LittleFS — ARM Mbed团队出品,已合并到Linux主线,Python binding都有
- SPIFFS — ESP8266/ESP32的出厂文件系统,简单到让人害怕
- FATFS — 你熟悉的FAT,但移植到了嵌入式。你知道它不适合裸Flash,但它有“兼容性“
- YAFFS — 老牌NAND文件系统,Linux内核上跑了二十年
- JFFS2 — Linux MTD子系统的默认文件系统,日志结构的,但mount慢得像在数沙子
- F2FS — Samsung出的,专为高容量Flash设计,Android用过它
- 还有UBIFS、LittleFS-2、nx-ffs……
你盯着屏幕。六个黑盒子。你不知道拆哪个。
这一节就是帮你拆的。
一张全景地图
让我们先拉开一张全景图。这张表是你不做决定不应该离开的地方。
| FS | 目标介质 | 数据结构 | 磨损均衡 | 掉电安全 | 代码量 | RAM最小 |
|---|---|---|---|---|---|---|
| LittleFS | NOR/NAND | metadata pair, CTZ skip-list | 动态 | 强 | ~14KB | ~1KB |
| SPIFFS | NOR | flat page index (no directories) | 静态 | 弱 | ~15KB | ~4KB |
| FATFS | 磁盘/带FTL Flash | FAT表+目录树 | 无 | 弱 | ~8KB | ~1KB |
| YAFFS2 | NAND (OOB area) | T-node tree, chunk-based | 动态+静态 | 中等 | ~30KB | ~2KB |
| JFFS2 | NOR+NAND (MTD) | 纯日志结构, jeb-based | 动态 | 好 | ~30KB | ~256B |
| F2FS | NAND SSD, eMMC/UFS | NAT(节点地址表), 多段日志 | 多重(动态+静态) | 强 | ~60KB | ~16KB |
| UBIFS | NAND (MTD UBI) | wandering tree, LEB-based | UBI提供(动态+静态) | 强 | ~40KB | ~32KB |
| KnotFS | NOR, 64KB-8MB | Log+CoW hybrid, compact metadata | 动态 | 强(双SB槽位+Log回放) | ~6KB(ARM) | ~2KB |
停下来,把这张表仔细看一遍。每一个工程选择背后,都有一段血泪故事。
LittleFS:给微控制器的一封情书
LittleFS是ARM Mbed团队在2017年推出的作品。它的设计目标是一个独特的组合:支持NOR和NAND,但RAM占用要降到1KB量级。
它是怎么做到的?
Metadata Pair(元数据对)——两个块撑起一个文件系统。
LittleFS Metadata Pair 原理
=======================
┌────────────┐ ┌────────────┐
│ Metadata │ │ Metadata │
│ Block A │ │ Block B │
│ (version 5) │ │ (version 4) │
└────────────┘ └────────────┘
有效 旧版本
更新流程:
1. 读A和B,比较版本号 → A(5) > B(4) → A是有效版本
2. 在内存中修改元数据
3. 将修改后的元数据写入 B(覆盖B的旧版本,B变成version 6)
4. 下一次修改时,写入 A(A变成version 7)
5. 循环往复
为什么只用两个块?
- RAM占用极低:只需要在内存里缓存两个block的元数据
- 磨损自动分布:A和B交替被写
- 掉电安全:总是保留至少一个有效版本
- 缺点:逻辑空间受限于两个物理块的大小
LittleFS的文件数据使用CTZ skip-list(Counting Trailing Zeros跳表)——一种借鉴了B树思想的链式结构。它的inode不是固定大小的,而是随着文件增大逐层“长高“:
LittleFS CTZ Skip-List 结构
=======================
小文件 (≤ 1 block):
inode → [数据块]
直接指向数据块,1层
中等文件 (≤ N blocks):
inode → [CTZ块] → [数据块1│数据块2│...│数据块N]
2层,一个CTZ块指向N个数据块
大文件 (≤ N×N blocks):
inode → [CTZ块] → [CTZ块'] → [数据块...]
3层或多层
CTZ块内部是一个紧凑的指针表(block号+CRC校验)。
它不固定大小——随着文件增长,在尾部追加新的指针条目。
LittleFS最精妙的设计是它的CRC校验。 每一个数据块都自带一个CRC-32校验和。当文件系统读取一个块时,它先检查CRC——如果不对,说明这一块被磨损了或掉电打断了,文件系统自动回退到旧版本。
LittleFS CRC 校验链
=======================
inode:
[文件名│大小│CTZ结构│CRC] ← inode自带CRC
CTZ块:
[ptr0│CRC0][ptr1│CRC1][ptr2│CRC2] ← 每个指针条目自带CRC
数据块:
[数据 │CRC] ← 数据块自带CRC
读取路径:
读inode → 验证inode CRC ✓
→ 读CTZ块 → 验证CTZ CRC ✓
→ 读数据块 → 验证数据CRC ✓
如果任何一个CRC不匹配 → 从这个条目开始往后都不可信
→ 回退到前一个有效条目 → 继续读
LittleFS的CRC设计让它能在NOR上跑(NOR没有内置ECC),也能在低质量NAND上跑(NAND的部分页可能损坏)。
但LittleFS也有明显的代价:
- 它的metadata pair只能装在两个block里 → 元数据量受限于8KB(两个4KB block)
- CTZ结构对随机访问不友好——读文件中间某个位置要遍历整个链
- 没有原生的“目录“概念(用metadata pair里的条目模拟)
对于4KB RAM的Cortex-M0微控制器,这是一个优雅的工程权衡。
SPIFFS:粗糙但有效
SPIFFS(SPI Flash File System)是Espressif为ESP8266开发的。它是嵌入式文件系统里最简单粗暴的一个——但也是出货量最大的之一(每颗ESP8266上都跑着它)。
SPIFFS 的核心数据结构
=======================
SPIFFS 索引表(在RAM中):
文件ID │ 起始页 │ 大小 │ 属性
─────────────────────────
0x0001 │ 页3 │ 1024 │ 可读写
0x0002 │ 页12 │ 4096 │ 只读
...
每个物理页(256B 或 4096B,取决于芯片):
┌─────────────────────┐
│ Header (对象ID, span_ix, 长度) │
│ 数据 (剩余空间) │
└─────────────────────┘
文件横跨多个页:
文件0x0001:
页3: [header: id=0x0001, span_ix=0] [数据: 0~220字节] ← 220B
页7: [header: id=0x0001, span_ix=1] [数据: 221~440字节] ← 220B
页15:[header: id=0x0001, span_ix=2] [数据: 441~1024字节] ← End
SPIFFS没有目录。文件名直接映射成一个唯一的object ID(通过对文件名做哈希)。“查找一个文件“实际上是在RAM里的索引表中扫描——所有文件的索引在mount时全部加载到RAM中。
SPIFFS 查找流程:
lookup("config.txt"):
1. 对"config.txt"做hash → 得到 object_id = 0x003F
2. 在RAM中的lookup表里查 object_id=0x003F → 找到: 起始页=页3
3. 读页3 → 解码header → 得到数据
优势:
查找 O(1)(哈希)+ 一次Flash读取
简单到不能再简单
代价:
所有文件的索引必须全部加载到RAM
对于大Flash(比如16MB),索引表可能占用大量RAM
没有目录结构 → 文件名是扁平的
磨损均衡是静态的 — GC是被动的(等空闲页不够了才触发)
SPIFFS证明了:有时候最简单的方案就是最合适的方案。 在ESP8266这样的Wi-Fi微控制器上(RAM只有160KB,Flash 4-16MB,没有MMU),你不需要一个复杂的分层文件系统。你只需要一个能在重启后找到配置文件的简单工具。
YAFFS 和 JFFS2:两个 Linux 老将
YAFFS(Yet Another Flash File System)和JFFS2(Journaling Flash File System Version 2)是Linux内核上最老牌的Flash文件系统。
JFFS2的设计哲学是“纯日志“——磁盘上所有东西都是追加到日志流的条目。这带来了极高的掉电安全性,但也带来了mount时间噩梦:
JFFS2 mount 过程
=======================
1. 从头到尾扫描整个Flash
→ 读取每个erase block的header
→ 辨识有效数据和垃圾数据
→ 构建内存中的inode树
2. 扫描时间 ∝ Flash 总容量
16MB NOR Flash: ~0.5秒(可以接受)
128MB NAND Flash: ~5-10秒(勉强)
1GB NAND Flash: ~30-60秒(不可接受)
3. 这是JFFS2最大的骂名:
"系统启动时,JFFS2在慢慢数沙子。"
JFFS2的另一个问题是它的垃圾回收(GC)是不可预知的——GC可能在系统运行的任何时刻触发,暂停所有I/O操作。对于需要确定性响应的嵌入式设备,这是一个问题。
YAFFS(特别YAFFS2)专为NAND Flash设计,利用了NAND的OOB(Out-Of-Band)区域来存储校验信息。它的数据结构更接近传统的Unix FS(inode用T-node树组织),但所有的更新都通过日志追加。
YAFFS2 T-node 树
=======================
inode
└── T-node (第0层) → 直接指向 16 个 chunks
└── T-node (第1层) → 每个指向下一层的 16 个 T-nodes
└── T-node (第2层) → ...
每个 chunk = 一个 NAND page (2KB~16KB)
最大文件大小由T-node的层数决定
YAFFS和JFFS2都面临同一个尴尬:Linux内核社区已经把目光转向了UBIFS和F2FS。 这两个老将在各自擅长的领域(JFFS2在小型NOR上,YAFFS在工业级NAND上)仍然服役,但新的设计已经覆盖了它们的全部使用场景。
F2FS:三星为闪存准备的高速公路
F2FS(Flash-Friendly File System)是三星为高容量Flash存储(eMMC/UFS/SSD)设计的日志结构化文件系统。它运行在Android上,在数亿台设备上服役。
F2FS的设计规模感和小型Flash文件系统完全不同:
F2FS 的宏大规模设计
=======================
F2FS 将整个卷划分为6个区域:
┌──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐
│Superblock│Checkpoint│ Seg.Info │Node Addr │ Node │ Data │
│(2个副本) │ (CP区域) │ Table │ Table │ (inode) │ Segments │
└──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘
核心思想:冷热分离
- 热数据(频繁修改):放在日志头(CURSEG)
- 温数据(偶尔修改):中间区
- 冷数据(几乎不改):长期稳定区
- Node区域和Data区域分开 → 不同访问模式的元数据和数据分散在不同segment中
多日志头机制:
┌─ Node日志头(热)─┐ ┌─ Data日志头(热)─┐
│ [inode'] [inode'] │ │ [数据'] [数据'] │
└───────────────────┘ └───────────────────┘
每个日志头独立GC,互不干扰
F2FS的NAT(Node Address Table)是其最关键的数据结构——它类似于LFS的imap,是一个巨大的“inode ID → 物理位置“映射表。NAT自身也是分段存储的,支持增量更新。
F2FS NAT 映射
=======================
NAT: inode_ID │ 物理块地址 │ 状态
───────────────────────────
0x00001 │ 0x000A3F │ 有效
0x00002 │ 无效 │ 已删除
0x00003 │ 0x002B12 │ 有效
...
每次inode更新:
1. 将新inode写入Node区域的当前活动segment
2. 更新NAT表项:inode_ID → 新的物理地址
3. NAT更新是批量累积的(减少NAT区域的写放大)
F2FS的设计是为数十GB到数百GB的Flash优化的。它不适合嵌入式MCU——内核态的F2FS代码量超过6万行。
KnotFS:它在这张地图上的位置
那么KnotFS在这张地图的哪里?
它不在任何竞争维度上。
KnotFS 的独特定位
=======================
LittleFS: 目标是"在Cortex-M0上跑文件系统"(通用性优先)
KnotFS: 目标是"在Cortex-R5上教文件系统设计"(可理解性优先)
SPIFFS: 目标是"在ESP8266上存配置文件"(简洁性优先)
KnotFS: 目标是"在ECU上安全修改标定数据"(安全性优先)
FATFS: 目标是"兼容PC的文件交换格式"(兼容性优先)
KnotFS: 目标是"理解Flash文件系统的每一行代码"(教学性优先)
YAFFS: 目标是"NAND上的工业量产可靠性"(产量优先)
KnotFS: 目标是"在64KB Flash上实现动态磨损均衡+CoW掉电安全"(全功能优先)
JFFS2: 目标是"Linux MTD上的通用日志文件系统"(历史兼容优先)
KnotFS: 目标是"为读者拆解日志结构化的最小实现"(最小实现优先)
F2FS: 目标是"数亿台手机的闪存性能优化"(规模优先)
KnotFS: 目标是"128个块×4KB的工程实验室"(原则验证优先)
KnotFS不试图在性能、功能或规模上竞争。它的目的是让你看到一颗裸NOR Flash上的文件系统的每一块骨头。
它的128个块、4KB block size、紧凑元数据日志、动态磨损均衡——这些设计选择不是随机做出的。它们是为了让你在一个足够简单但足够真实的系统上,理解所有Flash文件系统共用的那些核心原理。
这些原理在LittleFS里藏在metadata pair的翻转逻辑中、在F2FS里藏在NAT表的批量更新中、在SPIFFS里藏在静态磨损均衡的随机性中——但在KnotFS里,它们被刻意地暴露出来、给予名字、赋予状态机。
下集预告
六张地图看完了。你大概知道了谁在用什么样的数据结构保护数据的可查找性。但回到一个更深的问题:管理数据的数据——元数据——为什么是整个文件系统里最难管的部分?
数据丢了,你丢了一个文件。元数据丢了,你丢了所有的文件——即使所有这些文件的数据都完好无损。下一节,我们直面“元数据的困境“,看看SB Log、双槽位Compact和双副本这三大利器如何联手守住文件系统最后的底线。
悬念留给:如果超级块(Superblock)只占512字节,为什么KnotFS要用一整个4KB的块来装它?剩下的三千多字节,拿来做什么了?