起因

块设备之上要有文件系统——G 代码程序、加工参数、日志、长时间数据记录都要落在盘上,还要和 U 盘交换。这一族是 1977 年写成、随 DOS 发扬光大的:FAT 的簇链表结构简单到软盘都跑得动,1996 年的 FAT32 把分区撑到 2TB,单文件却从此卡死在 4GB——长录像、大数据记录面前的硬墙。微软 2006 年为闪存和 SD 卡设计的 exFAT 就是来拆这堵墙的,簇链的骨架留着,其余按大容量闪存重画。选 exFAT:U 盘出厂普遍格式、规范公开(微软 2019 年公开完整规范并加入 OIN 向 Linux 生态开放相关专利,自实现的授权风险显著降低;商业产品如有顾虑可让法务复核一遍专利边界)。这篇从引导扇区讲到读写路径,能给表的全给表。

一、磁盘布局与引导扇区

exFAT 卷四段式:主引导区(含引导扇区+扩展引导)→ FAT 区 → 簇堆(数据)→ 末尾备份引导区。目录树就住在簇堆里,根目录是一条簇链(不是固定区域,这点跟 FAT32 根目录不同)。

引导扇区(第 0 扇区)关键字段:

偏移 字段 说明
0x00 跳转指令 EB 76 90
0x03 卷标志 "EXFAT " 8 字节
0x40 分区偏移 / 0x48 卷长度 8 字节
0x50 FatOffset / 0x54 FatLength 4 字节:FAT 起始扇区和扇区数
0x58 ClusterHeapOffset 簇堆起始扇区
0x5C ClusterCount 簇总数
0x60 FirstClusterOfRootDirectory 根目录首簇
0x68 卷序列号 4 字节
0x6C BytesPerSectorShift 每扇区字节数 = 512 « 此值
0x6D SectorsPerClusterShift 每簇扇区数 = 1 « 此值
0x6E NumberOfFats 通常 1
0x1FE 签名 55 AA

挂载三步:读第 0 扇区验签名和 "EXFAT " → 按移位字段算出簇大小(如 512«9 × 1«3 = 256KB 的盘也常见,工控盘格 32KB 顺手)→ 读根目录首簇开走。簇号从 2 开始编号(0/1 保留),簇号转 LBA:ClusterHeapOffset + (簇号-2) × 每簇扇区数

二、FAT:簇链的规则

每簇一个 32 位 FAT 项(小端):

含义
0x00000000 空闲簇
2 … 0xFFFFFFF7 下一簇号
≥ 0xFFFFFFF8 链尾(EOC,惯例写 0xFFFFFFFF)
bit31 置位 坏簇

文件的真实长度存在目录项里(FAT 只管链不断不管长度),读文件 = 从首簇出发沿 FAT 走到 EOC,按目录项的 DataLength 截断最后一簇。

三、目录项:文件的长相

目录就是一组 32 字节目录项的线性数组,文件以"目录项集合"出现——一个主项带 N 个二级项

类型码 名称 内容要点
0x81 分配位图 指出哪个 bitmap 结构、覆盖哪些簇——找空闲簇查它,比扫 FAT 快
0x82 大写表 文件名大小写折叠表(查找时用)
0x83 卷标
0x85 文件主项 byte1 = SecondaryCount(后面跟着几个二级项);bytes4-5 = 属性(bit0 只读、bit1 隐藏);其后时间戳
0xC0 流扩展项 byte3 = 名字长度(UTF-16 码元数);bytes4-5 = 名字哈希;bytes8-15 = 有效数据长度;bytes20-23 = 首簇号;bytes24-31 = 数据长度
0xC1 文件名项 每项装 15 个 UTF-16LE 码元(bytes2-31),一个名字最多 255 码元

解析一个文件的完整画像:读到 0x85 主项 → 数着 SecondaryCount 收二级项 → 流扩展项给首簇和长度 → 文件名项拼接出 UTF-16 名字 → 转成内部 UTF-8 比较/显示(中文文件名天然支持,名字本来就是 UTF-16 存的)。写新文件时这套要反过来拼,且主项 bytes2-3 是集合校验和(对集合内所有项按规范算法算,写错其它工具认不出)。

流扩展项里那个名字哈希(bytes4-5)只做加速用的预筛:先比哈希再比全名,哈希不同直接跳过。别拿哈希相等当名字相等——哈希是 16 位,几千个文件的目录里碰撞是必然事件,全名比对一步都不能省。

四、读写路径

读路径(打开 PART001.NC):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fn open(fs: &Exfat, path: &str) -> File {
    let mut cluster = fs.root_first_cluster;      // 从根目录簇链开走
    loop {
        for entry in iter_dir_entries(fs, cluster)? {
            if entry.type == 0x85 && name_matches(&entry, path) {
                return File { first: entry.stream.first_cluster,
                              len: entry.stream.data_length, .. };
            }
        }
        cluster = fs.fat_next(cluster);            // 沿 FAT 翻页
        if is_eoc(cluster) { return Err(NotFound); }
    }
}
// 之后 read() 沿簇链读,越过 len 截断

写路径(追加/新建)纪律是落盘顺序:先写数据簇 → 再更新 FAT(链尾接上)→ 再更新分配位图 → 最后回写目录项(DataLength/首簇)。中途断电,坏的方向是"数据写了目录项没更新"(文件看起来还是旧的,可容忍);反过来则是"目录项指着一堆垃圾"(灾难)。掉电一致性不追求"永不损坏",追求"损坏可检测、可恢复到上一个一致点"。

开机自检-lite:FAT 空闲标记 vs 位图互验、每文件簇链走一遍校验(无环、EOC 收尾)、DataLength 与簇数匹配——异常文件标记隔离并报警,不静默"修复"。exFAT 的事务扩展(TexFAT)没有通用生态,我们也不造日志文件系统这个轮子。

五、扇区缓存与挂载点

块设备之上一层小缓存:64 扇区 LRU 写穿。工控负载是小文件为主(几 KB 的程序、几十字节的参数),命中率极高;写穿策略让"掉电窗口"只有缓存到 Flush 之间那一小段,配合 NVMe 篇的关键节点 Flush 纪律,风险收敛到可接受。

缓存之下就是 BlockDevice trait(NVMe 篇定义):本篇代码一行不改就能同时服务 NVMe 盘和 U 盘(U 盘那篇的 MSC+SCSI 实现的是同一个 trait,见U盘驱动实战)。

六、时间戳:接上系统时间

文件项的时间戳格式跟 DOS 一脉相承:4 字节时间戳 = DOS 日期(高 16 位)+ DOS 时间(低 16 位),粒度 2 秒,纪元 1980;旁边跟一个 10ms 增量字节补精度,再加一个时区偏移字节(15 分钟一格)。写入时从系统时间转换(APIC 篇的 RTC+单调钟在这里派上用场:加工记录的时间戳要经得起对账);时区字节工控场景统一写 0(UTC)或本地值,别一台机器一个写法。

总结

实战清单:引导扇区偏移表挂载验卷、移位字段算簇大小、簇号-2 转 LBA、FAT 项语义表、目录项集合(0x85+0xC0+0xC1×n)解析出首簇/长度/UTF-16 名字、位图找空簇、写路径四步落盘顺序、开机自检-lite、64 扇区 LRU 写穿、时间戳接系统钟。跑通标志:QEMU 里 mkfs.exfat 一个镜像,我们的内核列目录、读出文件内容与宿主机一致;写入后挂回 Linux 能读出。到这里存储这条线闭环了:块设备、文件系统、U 盘交换都是自己的代码。下一篇把网络拉进来:e1000 有线网卡驱动和自研 TCP/IP 协议栈。

参考链接