3.3 FAT文件系统——最简单的方案,最致命的缺陷
你拿到了一张格式化过的SD卡
想象一个场景:你刚从电子城买了一张64GB的SD卡。你把它插进电脑的读卡器,弹出一个窗口:“此磁盘需要格式化”。你点确定。一秒钟后,电脑告诉你格式化完成,现在它是一个干干净净的FAT32卷。
FAT(File Allocation Table)文件系统诞生于1977年,被微软的Bill Gates和Marc McDonald为BASIC-80开发。它后来被用在MS-DOS、Windows、U盘、SD卡、甚至一些嵌入式设备上。它是地球上装机量最大的文件系统。
它也是地球上最不适合裸NOR Flash的文件系统。
为什么?让我们把它拆开看看。
FAT的三层结构:一部1977年的设计电影
FAT文件系统的磁盘布局极其简洁——简洁到你可以在一张餐巾纸上画出来:
FAT32 磁盘布局
=======================
┌─────────┬────────────────┬────────────────┬──────────────────┐
│Boot Sec.│ FAT 表 (×2) │ Root Dir. │ Data Clusters │
│ (1扇区) │ (n扇区×2份) │ (若干扇区) │ (剩余全部扇区) │
└─────────┴────────────────┴────────────────┴──────────────────┘
↑载入内存 ↑文件链表的脊骨 ↑根目录的起点 ↑真实数据存放处
识别分区 "簇链"的核心 存放文件名和属性
我们一层一层拆解。
第一层:Boot Sector(启动扇区)
Boot Sector是FAT卷的第一个扇区(512字节或4KB)。它里面包含了一张“参数表“:
// FAT32 Boot Sector 的核心参数(BIOS Parameter Block)
struct fat32_bpb {
uint16 bytes_per_sector; // 每扇区字节数,通常512
uint8 sectors_per_cluster; // 每簇扇区数,比如8
uint16 reserved_sectors; // 保留扇区数(Boot之后到FAT之间的扇区)
uint8 num_fats; // FAT表的份数,通常是2
uint32 total_sectors; // 卷总扇区数
uint32 sectors_per_fat; // 每份FAT表占多少扇区
uint32 root_cluster; // 根目录的起始簇号
// ... 还有更多字段
};
这些参数一旦写定,整个卷的“几何形状“就确定了。你不能在运行时改变cluster的大小,不能移动FAT表和Root Directory的相对位置。它是一次格式化、终身固定的。
第二层:FAT表——文件系统的脊骨
FAT表是FAT文件系统的心脏。它核心就做一件事:把文件的簇串成一条链。
FAT 表的"簇链"原理
=======================
假设一个文件占用了簇3、簇5、簇8:
FAT表:
簇0: [保留]
簇1: [保留]
簇2: [0x0FFFFFFF] ← 文件1的起点
簇3: [0x00000005] ← 文件1 → 下一个簇是5
簇4: [0x00000000] ← 空闲簇
簇5: [0x00000008] ← 文件1 → 下一个簇是8
簇6: [0x00000009] ← 文件2 → 下一个簇是9
簇7: [0x00000000] ← 空闲簇
簇8: [0x0FFFFFFF] ← 文件1 → 链尾(EOF)
簇9: [0x0FFFFFFF] ← 文件2 → 链尾(EOF)
...
文件1的簇链: 3 → 5 → 8 → EOF
文件2的簇链: 6 → 9 → EOF
读取文件1时,FAT32驱动做这个循环:
cluster = file->start_cluster; // 从目录项拿到起始簇号3
while (cluster < 0x0FFFFFF8) {
read_cluster(cluster, buffer);
cluster = fat[cluster]; // 从FAT表里查到下一个簇号
}
简单、直观、高效。在磁盘上,这种链表结构的美妙之处在于:文件不需要连续存放。你可以把一个大文件散落在磁盘的各个角落——只要FAT表里的链不断就行。
第三层:目录项——名字和位置之间的桥梁
目录也是一个“文件“——一个由32字节条目组成的链表:
// FAT32 目录项(短文件名版本)
struct fat32_dir_entry {
uint8 name[11]; // 8.3 格式文件名
uint8 attr; // 属性:只读/隐藏/系统/卷标/目录/归档
uint8 reserved;
uint8 create_time_tenth;
uint16 create_time;
uint16 create_date;
uint16 access_date;
uint16 first_cluster_hi; // 起始簇号的高16位
uint16 modify_time;
uint16 modify_date;
uint16 first_cluster_lo; // 起始簇号的低16位
uint32 file_size; // 文件大小(字节)
};
每个文件/子目录对应一个目录项。目录项里最关键的信息是first_cluster_hi和first_cluster_lo——合起来就是文件的起始簇号。拿到这个簇号,就可以去FAT表里顺着链表往下读。
FAT的优雅——和它的死亡倒计时
FAT的设计,在磁盘上极其优雅。但在Flash上,它是一个精确瞄准了Flash弱点的自杀装置。
来看看一次最简单的文件修改操作——修改文件中的一个字节——在FAT+Flash组合下会发生什么:
修改 1 个字节的 FAT 噩梦
=======================
场景:文件 report.txt,大小 1000 字节,存储在簇 3→5→8 链中
操作:修改文件第一个字节,从 'H' 变成 'h'
步骤 0:应用层调用 write(),偏移0,长度1,数据'h'
步骤 1:文件系统找到文件的起始簇号(簇3)
→ 从FAT表查簇3的链:3→5→8→EOF
步骤 2:读入簇3对应的扇区
→ 在缓冲区中修改第一个字节 'H'→'h'
步骤 3:写回簇3对应的扇区 ← 这里是关键:
如果这一扇区所在的物理块没被擦除过:
A. 擦除该物理块(包含簇3的整个4KB扇区)
← 耗时 200ms(NOR)或 3ms(NAND)
B. 将缓冲区数据编程回该物理块
← 耗时 ~58ms(NOR)
步骤 4:文件大小没变,但FAT表里记录了"上次修改时间"需要更新
→ FAT表的对应扇区需要修改 2 字节(时间戳字段)
步骤 5:擦除FAT表所在扇区 → 编程新FAT表数据
← 又一次擦除!
总代价:
1 字节修改
→ 2 次擦除(数据扇区1次 + FAT扇区1次)
→ 每个擦除都烧掉一次P/E Cycle
你修改了1个字节,触发了至少2次擦除,烧掉了2个P/E周期。
如果一个应用每秒修改一次文件(比如系统日志写入),每天就有86,400次FAT扇区擦除。而NOR Flash的一个扇区寿命是100,000次P/E周期。
86,400 次/天 ÷ 100,000 次寿命 = 0.864
FAT扇区在 1.16 天内就被烧死了。
这还不是最糟的。FAT表通常是两份(FAT1和FAT2),一模一样的备份。但问题是——它们在同一条闪存总线上,通常位于相邻的物理地址。 FAT1被烧死的同时,FAT2往往也差不多了。你的“冗余“并不能救你。
FAT区的热岛效应
FAT表是一个“热区“——类似城市里的热岛效应。大部分数据的写入都是分散的(不同的文件、不同的位置),但FAT表的修改集中在它的几个扇区上。这造成了Flash磨损的高度集中:
FAT区的磨损集中效应
=======================
磨损次数
========
Boot Sector: ████ 3500 次
FAT1 扇区0: ████████████████████████████████████████ 98,000次 ← 快死了
FAT1 扇区1: ████████████████ 42,000次
FAT1 扇区2: █████ 15,000次
FAT2 扇区0: ████████████████████████████████████████ 97,500次 ← 快死了
Root Dir: ███████████ 30,000次
数据簇3: ██ 5400次
数据簇5: █ 800次
数据簇8: █ 1200次
...
数据簇999: █ 3次 ← 几乎没被写过!
健康差距: 98,000 ÷ 3 = 32,667 倍的不均衡
这种不均衡严重到什么程度?同一颗Flash芯片上,FAT扇区可能已经擦写了98,000次(濒临死亡),而最后一个数据簇只擦写了3次(几乎是新的)。
但文件系统不知道。它只知道“这个扇区属于FAT表,我需要修改它“。它持续向FAT扇区发送擦除-编程指令。驱动层也不会阻止——它不知道“这个扇区快死了“,它只知道“执行用户命令“。
直到第100,001次擦除——某个浮栅晶体管终于无法编程了。数据写入失败。FAT表损坏。整个文件系统崩溃。
不是因为数据区满了,不是因为文件太多,不是因为掉电。只是因为FAT表的一个扇区——被过度使用的那个扇区——寿终正寝了。
小写放大:FAT的数学诅咒
FAT的问题不只是“热区集中“。还有一个更隐蔽的问题——小写放大(Small-Write Amplification)。
在FAT中,一个文件的任何属性变化——哪怕是修改时间、哪怕是一个字节的内容——都需要更新FAT表项。而FAT表的最小写入单位是整个扇区。如果你的扇区是4KB(4,096字节),而一个FAT表项只有4字节(FAT32):
小写放大 = 扇区大小 ÷ 表项大小 × 修改次数
= 4,096 ÷ 4 × 1
= 1,024 倍
为了修改4个字节的FAT表项,
你需要擦除并重写整个4KB扇区。
物理写入量 = 逻辑修改量的 1,024 倍。
如果考虑到FAT表有两个副本(FAT1和FAT2),并且每次修改要同时更新两者,写放大还要再翻一番:
双FAT表的小写放大:
修改1个FAT表项(4字节)
→ 擦除+重写FAT1扇区(4,096字节)
→ 擦除+重写FAT2扇区(4,096字节)
物理写入 = 8,192 字节
逻辑修改 = 4 字节
写放大 = 8,192 ÷ 4 = 2,048 倍
修改一个4字节的FAT表项,你实际往Flash写了8192个字节。写放大了2048倍。
这还不是极端情况。考虑一个嵌入式ECU的场景:系统有一个100字节的“运行状态“文件,每秒写入一次。每次写入:
- 原始数据写放大:100字节内容修改,但必须擦除一整块(4KB)→ 写放大40倍
- FAT表写放大:1个FAT表项(4字节)→ 擦除FAT扇区(4KB)×2 → 写放大2048倍
- 总写放大 > 2088倍
每秒一次写入,一天86,400秒:
- 有效写入:100B × 86,400 = 8.64 MB/天
- 物理写入:8.64 MB × 2,088 ≈ 18 GB/天
- 你的512KB NOR Flash一天之内要被完整写满35,000次以上
你的Flash会在一天之内死亡。
如果FAT那么差,为什么U盘还在用
你可能会问:如果FAT在Flash上这么惨,为什么全世界的U盘、SD卡、CF卡都格式化成了FAT32?
答案有三个字:FTL(Flash Translation Layer)。
消费级Flash存储设备(U盘、SD卡、SSD)内部都有一个微控制器,运行FTL固件。FTL在FAT和物理NAND之间插了一层逻辑-物理映射:
消费级Flash存储的 FTL 隔离层
=======================
你的 FAT32 文件系统:
"把我这4个字节写到FAT扇区5"
↓
FTL 控制器(运行在Flash芯片内部的ARM核上):
"收到逻辑扇区号5的写入请求"
"扇区5当前映射到物理块A的第3页"
"分配一个新的物理块B"
"把新数据写到块B的第0页"
"把块A里其他有效数据复制到块B"
"更新映射表:逻辑扇区5 → 物理块B"
"标记块A为待回收"
"通知你写入完成"
↓
你的 FAT32 文件系统:
"谢谢,我完全不知道你在背后做了这些"
FTL用Copy-on-Write和磨损均衡,替FAT背负了Flash的全部原罪。
你的U盘不是你想象的那样——你的FAT32直接裸跑在NAND上。在FAT32和NAND之间,有一个你看不见的“微型嵌入式操作系统“,在拼命地做异地更新、垃圾回收、磨损均衡、坏块管理。
这就是为什么你可以把FAT32用在U盘上但不能用在裸NOR Flash上——裸NOR Flash没有FTL。你必须自己负责所有的事情。
嵌入式系统中的裸NOR Flash控制——不经过FTL,直接操作物理地址——正是KnotFS要解决的问题。
FAT的寓言:为一种介质设计的系统,死在另一种介质上
FAT的悲剧不是设计错误。它的设计在1977年——在8英寸软盘上——是完美的。那时的介质允许原地修改、没有擦除限制、没有磨损概念。FAT的结构(Boot→FAT→Root→Data)映射到了软盘的物理布局(磁道0→FAT区→目录区→数据区),达到了最优的寻道效率。
问题是Flash不是软盘。
1977年的设计假设在2026年的介质上失效了。这不是设计的失败,这是物理的根本不兼容。
FAT 设计的核心假设 vs Flash 物理现实
=======================
FAT 假设 Flash 物理现实
────────────────────────────────────────────────────
可以原地修改 → ✗ 不能,只能1→0
写入前不需要擦除 → ✗ 必须擦除才能写入
读写粒度相同 → ✗ 读=字节,写=块,擦=扇区
介质不会磨损 → ✗ P/E Cycle 10万次
所有扇区寿命相同 → ✗ 热区集中导致提前死亡
可以"修改一个字节" → ✗ 修改=擦除+重写整个扇区
FAT表的双份=高可靠 → ✗ 双份在同一个物理芯片上,都死
这十一条不兼容中,任何一条都足以让FAT在Flash上表现糟糕。十一条一起作用,直接让它成为Flash的兼容性灾难。
KnotFS 从 FAT 的失败中学到了什么
既然FAT在裸Flash上活不下来,KnotFS就必须走一条截然不同的路。从FAT的失败中,可以总结出三条必须恪守的铁律:
FAT 的教训 → KnotFS 的对策
=======================
教训1:不要有集中热区(FAT表)
→ KnotFS:元数据分散存储
- Superblock 使用 Log Append 而非原地更新
- File Table 嵌入在 Superblock 中(nodes[8]),双副本由SB双槽位保证
- 没有单独的"分配表"——用 free_map + SB Log 记录映射
教训2:不要原地修改任何东西
→ KnotFS:全系统 Copy-on-Write
- 数据块:CoW到新分配的块
- 元数据:CoW通过Log Append实现
- 间接块:CoW(修改的指针位不同)
- 整个系统不存在"原地修改"操作
教训3:不要让小写入放大失控
→ KnotFS:Log-Structured 元数据
- 一次元数据修改 = 追加一条 8-16 字节的log记录
- 不需要擦除整个元数据扇区
- 写放大接近 1:1(元数据层面)
- 数据层面依赖于GC的效率
FAT的死因是“在一个无法原地修改的介质上,设计了一套严重依赖原地修改的数据结构“。KnotFS的解药是:“完全接受无法原地修改的物理现实,用日志追加和Copy-on-Write来适应它。”
这是生存需要,不是设计选择。
下集预告
FAT教会了我们一件事:在Flash上,你不能“修改“任何东西——你只能“追加“。这一洞见直接通向了1992年一篇划时代的论文——Rosenblum和Ousterhout的《日志结构化文件系统》(LFS)。LFS提出了一个大胆的想法:把整个文件系统变成一个无限的日志流。所有写入都追加到日志的末尾。永远不要覆盖旧数据。
下一节,我们研读LFS的核心思想,看看这个“只追加,不覆盖“的哲学如何成为了所有现代Flash文件系统(包括KnotFS)的理论基石。
悬念留给:如果一个文件系统从来不做“原地修改“,那么“删除文件“这件事还有意义吗?一个从不删除的文件系统,会不会很快被垃圾撑爆?