Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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用过它
  • 还有UBIFSLittleFS-2nx-ffs……

你盯着屏幕。六个黑盒子。你不知道拆哪个。

这一节就是帮你拆的。


一张全景地图

让我们先拉开一张全景图。这张表是你不做决定不应该离开的地方。

FS目标介质数据结构磨损均衡掉电安全代码量RAM最小
LittleFSNOR/NANDmetadata pair, CTZ skip-list动态~14KB~1KB
SPIFFSNORflat page index (no directories)静态~15KB~4KB
FATFS磁盘/带FTL FlashFAT表+目录树~8KB~1KB
YAFFS2NAND (OOB area)T-node tree, chunk-based动态+静态中等~30KB~2KB
JFFS2NOR+NAND (MTD)纯日志结构, jeb-based动态~30KB~256B
F2FSNAND SSD, eMMC/UFSNAT(节点地址表), 多段日志多重(动态+静态)~60KB~16KB
UBIFSNAND (MTD UBI)wandering tree, LEB-basedUBI提供(动态+静态)~40KB~32KB
KnotFSNOR, 64KB-8MBLog+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的块来装它?剩下的三千多字节,拿来做什么了?