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.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_hifirst_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)的理论基石。

悬念留给:如果一个文件系统从来不做“原地修改“,那么“删除文件“这件事还有意义吗?一个从不删除的文件系统,会不会很快被垃圾撑爆?