第14章 文件管理系统
第14章 文件管理系统
对于大多数计算机用户来说,"文件"是他们与操作系统打交道最频繁的概念。打开一张照片、编辑一份文档、播放一段视频,这些日常操作的背后,都涉及文件系统在磁盘上的读写定位和数据组织。但从操作系统开发者的视角来看,文件系统远不止"把数据存到磁盘上"这么简单——它是一套精心设计的数据结构和算法体系,负责将连续的磁盘扇区组织成层次化的目录和文件,提供命名、权限、并发访问控制等一系列抽象,同时还要在断电、崩溃等异常情况下保证数据的完整性。
更值得注意的是,现代操作系统往往需要同时支持多种文件系统——硬盘上的NTFS或ext4、U盘上的FAT32或exFAT、光盘上的ISO 9660、网络上的NFS或SMB。如何用统一的接口屏蔽不同文件系统之间的差异,让应用程序可以用相同的open/read/write/close调用访问任意类型的文件系统,这就是虚拟文件系统层要解决的问题。
本章将从文件系统的基本概念出发,以FAT32为具体案例深入分析一个真实文件系统的磁盘数据结构和操作逻辑,实现基于路径名称的文件查找功能,最后探讨虚拟文件系统(VFS)的设计理念。
14.1 文件管理系统总览
文件系统要解决什么问题
一块硬盘或固态硬盘,从硬件层面看,不过是一大片按扇区编号排列的存储空间。传统机械硬盘的扇区大小是512字节,现代硬盘和固态硬盘通常使用4096字节(4KB)的物理扇区。一块1TB的硬盘大约有20亿个512字节的扇区,或者2.5亿个4KB的扇区。
如果没有文件系统,操作系统和用户只能通过扇区编号来访问数据——"把这段数据写到第1048576号扇区开始的100个扇区"。这显然不是普通人能接受的交互方式。
文件系统在原始的扇区空间上建立了一层抽象,解决以下核心问题:
命名——用人类可读的名字("报告.docx"、"/home/user/photos/vacation.jpg")来标识数据,而不是扇区编号。名字有层次结构(目录树),便于组织和查找。
空间管理——跟踪哪些磁盘区域已经被使用、哪些是空闲的。当需要创建新文件或扩展现有文件时,从空闲空间中分配;当删除文件时,将其占用的空间归还为空闲。
元数据管理——除了文件的实际数据内容外,还需要记录文件的属性信息:文件大小、创建时间、修改时间、访问权限、所有者等。这些信息统称为元数据(metadata)。
数据定位——给定一个文件名,能够快速找到该文件的数据存储在磁盘的哪些位置。文件的数据在磁盘上不一定是连续存放的(可能分散在不同的位置),文件系统需要维护从"文件的第N个字节"到"磁盘的第M个扇区"的映射关系。
可靠性——在系统崩溃或突然断电后,文件系统不应该处于不一致的状态。现代文件系统通过日志(journaling)、写时复制(copy-on-write)等技术来保证数据的一致性。
文件系统的层次结构
一个完整的文件系统实现通常分为多个层次:
最底层是块设备驱动层。它直接与硬件交互——向IDE/SATA/NVMe控制器发送命令,将数据从磁盘读入内存或从内存写入磁盘。块设备驱动提供的接口是"读/写第N个扇区"。
往上是块缓存层(Block Buffer Cache或Page Cache)。直接访问磁盘是非常慢的——即使是最快的NVMe固态硬盘,随机读取延迟也在几十微秒级别,机械硬盘更是在毫秒级别。而内存访问只需要几十纳秒。块缓存层在内存中保存最近访问过的磁盘块的副本,当上层需要读取某个磁盘块时,先检查缓存中是否已有——如果有(缓存命中),直接从内存返回,速度极快;如果没有(缓存未命中),才实际访问磁盘,并将读取的数据放入缓存供后续使用。写入操作也先写到缓存中,由内核在适当的时候(或者被显式地sync/flush)批量写回磁盘——这叫做"写回"(write-back)策略。
Linux的Page Cache是文件系统性能的关键组件——它可以占用系统中几乎所有的空闲内存来缓存文件数据。free命令中看到的"buff/cache"就是Page Cache占用的内存。当应用程序需要更多内存时,内核会按需回收Page Cache的内存页。
再往上是具体的文件系统实现层。FAT32、ext4、NTFS、XFS、Btrfs等,每种文件系统都有自己特定的磁盘数据结构和操作算法。这一层将底层的"磁盘块"组织成"文件和目录"。
最上面是虚拟文件系统层(VFS)。VFS定义了一组通用的数据结构和操作接口,所有具体的文件系统都要适配这组接口。应用程序的文件操作系统调用(open、read、write、close、mkdir、unlink等)首先到达VFS,VFS根据文件所在的挂载点确定应该调用哪个具体文件系统的操作函数,然后将请求转发过去。
常见文件系统一览
操作系统领域存在大量不同的文件系统,各有其设计目标和适用场景:
FAT(File Allocation Table)系列——包括FAT12、FAT16、FAT32和exFAT。最初由微软在1977年为MS-DOS设计。FAT的最大优点是简单——结构简单意味着容易实现、兼容性好。几乎所有操作系统都支持FAT32,数码相机、U盘、SD卡等移动存储设备默认使用FAT32或exFAT格式。FAT的缺点也很明显——没有权限控制、没有日志、空间利用效率不高(FAT32的最大文件大小限制为4GB减1字节,单个分区最大2TB)。exFAT是FAT的现代化版本,去掉了4GB文件大小限制,是SDXC卡的标准文件系统。
NTFS(New Technology File System)——微软从Windows NT 3.1开始使用的文件系统。NTFS支持文件权限(ACL)、日志、压缩、加密(EFS)、磁盘配额、稀疏文件、硬链接和符号链接等高级特性。NTFS使用B+树来组织目录,目录查找效率比FAT的线性扫描高得多。NTFS的MFT(Master File Table)记录了每个文件的所有元数据信息,小文件的数据甚至可以直接内嵌在MFT记录中(resident data)。Windows的C:盘几乎都使用NTFS格式。
ext系列(Extended File System)——Linux的"原生"文件系统。ext2(1993年)是一个经典的Unix文件系统,使用inode和块位图来管理文件和空间。ext3(2001年)在ext2基础上增加了日志功能。ext4(2008年)进一步扩展了容量限制(最大16TB文件、1EB分区)、引入了extent(连续块范围)代替传统的间接块映射、支持延迟分配(delayed allocation)等。ext4是目前大多数Linux发行版的默认文件系统。
XFS——最初由SGI为IRIX操作系统开发(1993年),后移植到Linux。XFS特别擅长处理大文件和高并发I/O——它使用B+树来管理空间和目录,在大文件随机写入和并行I/O方面性能出色。RHEL/CentOS 7开始将XFS作为默认文件系统。
Btrfs(B-tree File System)——由Oracle开发的下一代Linux文件系统。Btrfs采用写时复制(Copy-on-Write)的设计,天然支持快照、子卷、在线压缩、在线碎片整理、RAID、数据校验(checksum)等高级功能。Btrfs的野心很大——它想同时替代文件系统和卷管理器(LVM)。SUSE Linux Enterprise将Btrfs作为默认文件系统,Fedora也从Fedora 33开始使用Btrfs。
ZFS——由Sun Microsystems开发(2005年),现在主要由OpenZFS项目维护。ZFS同样采用写时复制设计,是功能最丰富的文件系统之一——128位地址空间(理论上支持的存储容量大到荒谬)、端到端数据校验、透明压缩和去重、快照和克隆、内置RAID-Z等。ZFS最初在Solaris上运行,现在广泛用于FreeBSD和Linux(通过内核模块或FUSE)。由于许可证问题(CDDL与GPL不兼容),ZFS不能直接合入Linux主线内核。
APFS(Apple File System)——苹果在2017年推出的文件系统,取代了使用了近20年的HFS+。APFS针对闪存存储优化,支持快照、克隆、空间共享、加密等特性。所有苹果平台(macOS、iOS、iPadOS、watchOS、tvOS)都使用APFS。
ReFS(Resilient File System)——微软为Windows Server开发的下一代文件系统。ReFS主打数据完整性——通过校验和检测和自动修复数据损坏(配合Storage Spaces使用时)。ReFS放弃了NTFS的一些功能(如压缩、EFS加密)以换取更高的可靠性。
对于我们的64位操作系统,选择首先支持FAT32有充分的理由:FAT32结构简单,容易理解和实现;它是最通用的文件系统格式,便于在宿主机和虚拟机之间交换数据(UEFI固件的ESP分区就是FAT32格式);而且FAT32的规范是公开的,没有专利问题。掌握了FAT32的实现之后,再去实现更复杂的文件系统(ext4等)就有了坚实的基础。
14.2 剖析FAT32文件系统
14.2.1 FAT32文件系统入门
FAT文件系统的历史
FAT文件系统的历史几乎与个人电脑的历史一样长。1977年,比尔盖茨和马克麦克唐纳为微软的Standalone Disk BASIC设计了最初的FAT文件系统。1980年,蒂姆帕特森将FAT用于其开发的QDOS操作系统(后来被微软收购并改名为MS-DOS),从此FAT成为了DOS和早期Windows的标准文件系统。
FAT文件系统的核心数据结构是文件分配表(File Allocation Table),这也是"FAT"名称的由来。文件分配表是一个数组,数组的每个元素对应磁盘上的一个"簇"(cluster,连续的若干扇区),元素的值指示该簇的使用状态——空闲、已占用(指向文件的下一个簇)、文件结束标记、坏簇标记等。文件的数据通过簇链(cluster chain)来组织——文件的第一个簇号记录在目录项中,该簇对应的FAT表项指向文件的第二个簇,第二个簇的FAT表项指向第三个簇,依此类推,直到遇到文件结束标记。
FAT的三个版本——FAT12、FAT16、FAT32——区别在于FAT表项的位数:
FAT12:每个表项12位,最多支持4084个簇。用于软盘和非常小的分区(小于16MB)。由于12位不对齐字节边界,FAT12的解析比较麻烦。
FAT16:每个表项16位,最多支持65524个簇。配合32KB的簇大小,最大支持2GB的分区。FAT16曾是Windows 95之前的标准文件系统。
FAT32:每个表项32位(实际上只用了28位,高4位保留),最多支持约2.68亿个簇。配合32KB的簇大小,理论上支持8TB的分区(实际上Windows限制为2TB)。FAT32从Windows 95 OSR2开始引入,解决了FAT16的容量限制。
FAT32的磁盘布局
一个FAT32分区的磁盘布局从前往后依次是:
asciidoc
+------------------+
| 保留区域 | (引导扇区 + FSInfo + 备份引导扇区 + 保留扇区)
+------------------+
| FAT区域 | (FAT1 + FAT2,两份完全相同的文件分配表)
+------------------+
| 数据区域 | (簇2开始的所有簇,存放文件和目录的实际数据)
+------------------+
保留区域的第一个扇区是引导扇区(Boot Sector),也叫BPB(BIOS Parameter Block)。引导扇区包含了文件系统的所有关键参数——每扇区字节数、每簇扇区数、保留扇区数、FAT表数、FAT大小等。解析FAT32文件系统的第一步就是读取并解析引导扇区。
c
// FAT32引导扇区结构(BPB + 扩展引导记录)
struct fat32_boot_sector {
// 跳转指令(3字节)——跳过BPB执行引导代码
unsigned char jmp_boot[3];
// OEM名称(8字节)——通常是"MSWIN4.1"或格式化工具的名字
unsigned char oem_name[8];
// === BPB(BIOS Parameter Block)===
unsigned short bytes_per_sector; // 每扇区字节数(通常512)
unsigned char sectors_per_cluster; // 每簇扇区数(1,2,4,8,16,32,64)
unsigned short reserved_sectors; // 保留扇区数(FAT32通常为32)
unsigned char num_fats; // FAT表数量(通常为2)
unsigned short root_entry_count; // 根目录项数(FAT32中为0)
unsigned short total_sectors_16; // 总扇区数16位(FAT32中为0)
unsigned char media_type; // 媒体描述符(硬盘为0xF8)
unsigned short fat_size_16; // 每个FAT的扇区数16位(FAT32中为0)
unsigned short sectors_per_track; // 每磁道扇区数
unsigned short num_heads; // 磁头数
unsigned int hidden_sectors; // 隐藏扇区数(分区起始LBA)
unsigned int total_sectors_32; // 总扇区数32位
// === FAT32扩展BPB ===
unsigned int fat_size_32; // 每个FAT的扇区数32位
unsigned short ext_flags; // 扩展标志
unsigned short fs_version; // 文件系统版本(通常0.0)
unsigned int root_cluster; // 根目录起始簇号(通常为2)
unsigned short fs_info; // FSInfo扇区号(通常为1)
unsigned short backup_boot_sector; // 备份引导扇区号(通常为6)
unsigned char reserved[12]; // 保留
unsigned char drive_number; // 驱动器号
unsigned char reserved1; // 保留
unsigned char boot_sig; // 扩展引导标记(0x29)
unsigned int volume_id; // 卷序列号
unsigned char volume_label[11]; // 卷标
unsigned char fs_type[8]; // 文件系统类型字符串"FAT32 "
// 引导代码和结束标记
unsigned char boot_code[420];
unsigned short signature; // 0xAA55
} __attribute__((packed));
每个字段都有明确的含义,让我逐一解释最重要的几个:
bytes_per_sector——每个扇区包含多少字节。几乎所有的FAT32分区都使用512字节的扇区。虽然现代硬盘的物理扇区是4KB,但它们通常在固件层面模拟512字节的逻辑扇区。
sectors_per_cluster——每个簇包含多少个扇区。簇是FAT文件系统分配空间的最小单位——即使一个文件只有1字节,也要占用整个簇的空间。簇大小通常由分区容量决定——分区越大,簇越大。8GB的分区通常用4KB的簇(8个扇区),32GB的分区用32KB的簇(64个扇区)。簇太小会导致FAT表太大、簇链太长;簇太大会导致空间浪费(小文件占用过多空间)。
reserved_sectors——保留区域占多少个扇区。FAT32通常保留32个扇区。FAT区域紧跟在保留区域之后。
num_fats——FAT表的数量。标准值是2,也就是说磁盘上维护两份完全相同的FAT表。第二份是第一份的备份,当第一份损坏时可以用第二份恢复。每次修改FAT时,两份表都要同步更新。
fat_size_32——每个FAT表占多少个扇区。知道这个值和num_fats,就能算出数据区域的起始位置:
数据区起始扇区 = reserved_sectors + num_fats * fat_size_32
root_cluster——根目录的起始簇号。在FAT12/FAT16中,根目录有固定的大小和位置。FAT32改进了这一点——根目录作为一个普通的目录文件存储在数据区域中,可以动态增长。根目录的起始簇号通常是2(即数据区域的第一个簇)。
有了这些参数,就可以计算出从簇号到扇区号的映射关系。这是FAT32文件系统中最基础也最重要的计算:
c
// 从簇号计算对应的第一个扇区号
unsigned int cluster_to_sector(struct fat32_info *fs, unsigned int cluster)
{
// 数据区从簇号2开始
// 数据区起始扇区 = 保留扇区 + FAT表总大小
unsigned int data_start = fs->reserved_sectors +
fs->num_fats * fs->fat_size;
// 簇号减去2(因为簇号从2开始计数),乘以每簇扇区数
return data_start + (cluster - 2) * fs->sectors_per_cluster;
}
为什么簇号从2开始?因为FAT表的前两个表项(0号和1号)被保留用于特殊标记——0号表项存放媒体描述符,1号表项存放文件系统状态信息(是否"干净卸载"等)。
FSInfo扇区
FAT32在保留区域中还有一个FSInfo(File System Information)扇区,通常在第1号扇区(紧跟引导扇区之后)。FSInfo扇区保存两个有用的信息:
c
struct fat32_fsinfo {
unsigned int lead_sig; // 前导签名:0x41615252
unsigned char reserved1[480]; // 保留
unsigned int struct_sig; // 结构签名:0x61417272
unsigned int free_count; // 空闲簇数量(可能不准确)
unsigned int next_free; // 下一个可用簇号的提示
unsigned char reserved2[12]; // 保留
unsigned int trail_sig; // 尾部签名:0xAA550000
} __attribute__((packed));
free_count记录了当前空闲簇的数量,next_free提示从哪个簇号开始搜索空闲簇。这两个值可以加速空间分配——不需要扫描整个FAT表就能知道剩余空间和一个可能的起始搜索位置。但这两个值只是"提示",可能不准确(比如在非正常卸载后),使用前需要验证。
FAT表的结构
FAT表是一个32位整数数组(对于FAT32来说),数组的下标就是簇号,数组元素的值表示该簇的状态:
asciidoc
FAT表项值 含义
--------------------------------------------
0x00000000 空闲簇
0x00000001 保留
0x00000002-0x0FFFFFEF 已占用,值为文件的下一个簇号
0x0FFFFFF0-0x0FFFFFF6 保留
0x0FFFFFF7 坏簇
0x0FFFFFF8-0x0FFFFFFF 文件结束标记(EOF)
注意,FAT32的表项虽然是32位,但高4位被保留(在读取时应该屏蔽,在写入时应该保持原值不变)。实际有效的是低28位。
c
// 读取FAT表项
unsigned int fat32_read_fat_entry(struct fat32_info *fs, unsigned int cluster)
{
// 计算该表项在FAT中的字节偏移
unsigned int fat_offset = cluster * 4;
// 计算所在的扇区号和扇区内偏移
unsigned int fat_sector = fs->reserved_sectors +
(fat_offset / fs->bytes_per_sector);
unsigned int entry_offset = fat_offset % fs->bytes_per_sector;
// 读取该扇区
unsigned char sector_buf[512];
read_sector(fat_sector, sector_buf);
// 提取32位表项,屏蔽高4位
unsigned int entry = *(unsigned int *)(sector_buf + entry_offset);
return entry & 0x0FFFFFFF;
}
// 写入FAT表项
void fat32_write_fat_entry(struct fat32_info *fs, unsigned int cluster,
unsigned int value)
{
unsigned int fat_offset = cluster * 4;
unsigned int fat_sector = fs->reserved_sectors +
(fat_offset / fs->bytes_per_sector);
unsigned int entry_offset = fat_offset % fs->bytes_per_sector;
unsigned char sector_buf[512];
read_sector(fat_sector, sector_buf);
// 保留高4位,只修改低28位
unsigned int *entry = (unsigned int *)(sector_buf + entry_offset);
*entry = (*entry & 0xF0000000) | (value & 0x0FFFFFFF);
// 写回FAT1
write_sector(fat_sector, sector_buf);
// 同步写入FAT2(备份表)
unsigned int fat2_sector = fat_sector + fs->fat_size;
write_sector(fat2_sector, sector_buf);
}
举一个具体的例子来理解簇链。假设一个文件占用了簇3、簇7、簇8、簇12,那么:
apache
FAT[3] = 0x00000007 (下一个簇是7)
FAT[7] = 0x00000008 (下一个簇是8)
FAT[8] = 0x0000000C (下一个簇是12,0x0C=12)
FAT[12] = 0x0FFFFFF8 (文件结束)
要读取这个文件的全部数据,就要沿着簇链依次读取簇3、簇7、簇8、簇12的内容。
这种链表式的结构有一个明显的缺点——随机访问效率很低。如果要读取文件中间某个位置的数据,需要从头遍历簇链直到找到对应的簇。这对于大文件来说是个严重的性能问题。ext4使用的extent结构(记录"从簇M开始的连续N个簇")和NTFS使用的运行(run)列表解决了这个问题——连续存储的数据只需要一条记录,而不是每个簇一条FAT表项。
目录项结构
FAT32的目录本身也是一个文件——一个簇链,里面存储的是一系列32字节的目录项。每个目录项描述一个文件或子目录:
c
// FAT32短文件名目录项(32字节)
struct fat32_dir_entry {
unsigned char name[8]; // 文件名(空格填充,不含扩展名)
unsigned char ext[3]; // 扩展名(空格填充)
unsigned char attr; // 属性
unsigned char nt_reserved; // Windows NT保留(用于大小写信息)
unsigned char create_time_tenth; // 创建时间的十分之一秒
unsigned short create_time; // 创建时间
unsigned short create_date; // 创建日期
unsigned short access_date; // 最后访问日期
unsigned short cluster_high; // 起始簇号高16位
unsigned short modify_time; // 最后修改时间
unsigned short modify_date; // 最后修改日期
unsigned short cluster_low; // 起始簇号低16位
unsigned int file_size; // 文件大小(字节)
} __attribute__((packed));
// 属性标志
#define ATTR_READ_ONLY 0x01
#define ATTR_HIDDEN 0x02
#define ATTR_SYSTEM 0x04
#define ATTR_VOLUME_ID 0x08 // 卷标
#define ATTR_DIRECTORY 0x10 // 目录
#define ATTR_ARCHIVE 0x20 // 归档
#define ATTR_LONG_NAME 0x0F // 长文件名标记(前四个属性的组合)
短文件名(SFN)采用经典的"8.3"格式——8个字符的主文件名加3个字符的扩展名。文件名全部大写,不允许空格和大多数特殊字符。如果文件名短于8个字符,用空格(0x20)填充右侧。
这个"8.3"限制在现代看来简直不可接受——谁愿意把文件命名为"MYDOCU~1.TXT"?于是微软在Windows 95中引入了长文件名(LFN)支持——通过在短文件名目录项之前插入一系列特殊的"长文件名目录项"来存储完整的长文件名。
c
// FAT32长文件名目录项(32字节)
struct fat32_lfn_entry {
unsigned char order; // 序号(0x01-0x14,最后一个标记0x40)
unsigned short name1[5]; // 文件名字符1-5(UTF-16LE)
unsigned char attr; // 属性(固定为0x0F)
unsigned char type; // 类型(0=长文件名子目录项)
unsigned char checksum; // 短文件名的校验和
unsigned short name2[6]; // 文件名字符6-11(UTF-16LE)
unsigned short cluster; // 固定为0
unsigned short name3[2]; // 文件名字符12-13(UTF-16LE)
} __attribute__((packed));
每个长文件名目录项可以存储13个UTF-16字符(5+6+2=13)。长文件名最长可以有255个字符,因此最多需要20个LFN目录项(255/13=19.6,向上取整为20)。
长文件名目录项按逆序排列——最后一组字符的LFN项排在第一个(并且order字段设置了0x40位),然后依次往前,最后一个LFN项(order=1)紧跟着短文件名目录项。
举个例子,文件名"Meeting Notes 2024.docx"在磁盘上的存储方式:
apache
LFN #2 (order=0x42): "024.docx" (最后一组,0x40|2=0x42)
LFN #1 (order=0x01): "Meeting Note"+"s 2"
SFN: "MEETIN~1.DOC" (自动生成的短文件名)
旧版本的DOS或不支持LFN的工具看到的是"MEETIN~1.DOC",而Windows和Linux看到的是完整的"Meeting Notes 2024.docx"。这种向后兼容的设计是FAT32长文件名的一大亮点。
时间和日期字段采用紧凑的位域编码:
c
// 时间字段编码(16位)
// 位15-11: 小时(0-23)
// 位10-5: 分钟(0-59)
// 位4-0: 秒/2(0-29,即0-58秒,精度2秒)
// 日期字段编码(16位)
// 位15-9: 年份-1980(0-127,即1980-2107)
// 位8-5: 月份(1-12)
// 位4-0: 日(1-31)
unsigned short encode_time(int hour, int minute, int second)
{
return (hour << 11) | (minute << 5) | (second / 2);
}
unsigned short encode_date(int year, int month, int day)
{
return ((year - 1980) << 9) | (month << 5) | day;
}
void decode_time(unsigned short t, int *hour, int *minute, int *second)
{
*hour = (t >> 11) & 0x1F;
*minute = (t >> 5) & 0x3F;
*second = (t & 0x1F) * 2;
}
void decode_date(unsigned short d, int *year, int *month, int *day)
{
*year = ((d >> 9) & 0x7F) + 1980;
*month = (d >> 5) & 0x0F;
*day = d & 0x1F;
}
注意时间精度只有2秒——这是因为秒数字段只有5位(0-31),乘以2可以表示0-58秒。create_time_tenth字段提供了额外的0-199范围(十分之一秒精度),只用于创建时间。修改时间没有这个精度补偿,所以精度只有2秒。
和ext4的纳秒级时间戳相比,FAT32的时间精度非常粗糙。这也是FAT32不适合用作服务器文件系统的原因之一——在高频率文件操作的场景下,2秒的时间戳精度会导致无法准确判断文件的修改顺序。
14.2.2 以实际案例深入剖析FAT32文件系统
准备实验环境
理解文件系统最好的方式就是动手操作——创建一个FAT32磁盘镜像,往里面放一些文件,然后用十六进制编辑器查看原始磁盘数据,对照规范逐字节分析。
bash
# 创建一个32MB的空文件作为磁盘镜像
dd if=/dev/zero of=fat32.img bs=1M count=32
# 将其格式化为FAT32(Linux下使用mkfs.fat)
mkfs.fat -F 32 -n "TESTDISK" fat32.img
# 挂载镜像文件
sudo mkdir -p /mnt/fat32
sudo mount -o loop fat32.img /mnt/fat32
# 创建一些测试文件和目录
sudo mkdir /mnt/fat32/documents
sudo mkdir /mnt/fat32/pictures
echo "Hello, FAT32 World!" > /tmp/hello.txt
sudo cp /tmp/hello.txt /mnt/fat32/
sudo cp /tmp/hello.txt /mnt/fat32/documents/readme.txt
# 创建一个较大的文件(跨越多个簇)
dd if=/dev/urandom of=/tmp/bigfile.bin bs=1K count=100
sudo cp /tmp/bigfile.bin /mnt/fat32/
# 卸载
sudo umount /mnt/fat32
现在用十六进制编辑器(如hexdump或xxd)查看镜像文件的原始内容:
bash
# 查看引导扇区(前512字节)
xxd -l 512 fat32.img
解析引导扇区
假设hexdump输出如下(简化版):
apache
00000000: eb58 904d 534d 494e 342e 3100 0208 2000 .X.MSWIN4.1... .
00000010: 0200 0000 00f8 0000 3f00 ff00 0000 0000 ........?.......
00000020: 0000 0100 0002 0000 0000 0000 0200 0000 ................
00000030: 0100 0600 0000 0000 0000 0000 0000 0000 ................
根据BPB结构定义逐字段解析:
偏移0x00-0x02(jmp_boot):EB 58 90——跳转指令,跳到偏移0x5A处执行引导代码。
偏移0x03-0x0A(oem_name):4D 53 57 49 4E 34 2E 31——"MSWIN4.1",这是Windows格式化工具留下的标记。
偏移0x0B-0x0C(bytes_per_sector):00 02——512(小端序,0x0200=512)。
偏移0x0D(sectors_per_cluster):08——每簇8个扇区,即4KB的簇。
偏移0x0E-0x0F(reserved_sectors):20 00——32个保留扇区。
偏移0x10(num_fats):02——两份FAT表。
偏移0x11-0x12(root_entry_count):00 00——FAT32中此字段为0。
偏移0x13-0x14(total_sectors_16):00 00——FAT32中此字段为0。
偏移0x15(media_type):F8——硬盘。
偏移0x16-0x17(fat_size_16):00 00——FAT32中此字段为0。
偏移0x20-0x23(total_sectors_32):00 00 01 00——65536个扇区(0x00010000),即32MB。
偏移0x24-0x27(fat_size_32):00 02 00 00——512个扇区(0x00000200),每个FAT表占256KB。
偏移0x2C-0x2F(root_cluster):02 00 00 00——根目录起始簇号为2。
偏移0x30-0x31(fs_info):01 00——FSInfo在第1号扇区。
偏移0x32-0x33(backup_boot_sector):06 00——备份引导扇区在第6号扇区。
现在可以计算出磁盘各区域的位置:
apache
保留区域:扇区0-31(32个扇区)
FAT1:扇区32-543(512个扇区)
FAT2:扇区544-1055(512个扇区)
数据区域:扇区1056开始
簇号到扇区号的转换:
扇区号 = 1056 + (簇号 - 2) * 8
簇2 -> 扇区1056-1063
簇3 -> 扇区1064-1071
簇4 -> 扇区1072-1079
用代码来做这些计算:
c
struct fat32_info {
unsigned int bytes_per_sector;
unsigned int sectors_per_cluster;
unsigned int reserved_sectors;
unsigned int num_fats;
unsigned int fat_size; // 每个FAT的扇区数
unsigned int root_cluster;
unsigned int total_sectors;
// 计算得出的值
unsigned int fat_start; // FAT1起始扇区
unsigned int data_start; // 数据区起始扇区
unsigned int cluster_size; // 每簇字节数
unsigned int total_clusters; // 总簇数
};
void fat32_parse_boot_sector(struct fat32_info *fs,
struct fat32_boot_sector *bs)
{
fs->bytes_per_sector = bs->bytes_per_sector;
fs->sectors_per_cluster = bs->sectors_per_cluster;
fs->reserved_sectors = bs->reserved_sectors;
fs->num_fats = bs->num_fats;
fs->fat_size = bs->fat_size_32;
fs->root_cluster = bs->root_cluster;
fs->total_sectors = bs->total_sectors_32;
fs->fat_start = fs->reserved_sectors;
fs->data_start = fs->reserved_sectors + fs->num_fats * fs->fat_size;
fs->cluster_size = fs->bytes_per_sector * fs->sectors_per_cluster;
unsigned int data_sectors = fs->total_sectors - fs->data_start;
fs->total_clusters = data_sectors / fs->sectors_per_cluster;
printk("FAT32: %u bytes/sector, %u sectors/cluster, "
"%u reserved sectors\n",
fs->bytes_per_sector, fs->sectors_per_cluster,
fs->reserved_sectors);
printk("FAT32: FAT start=%u, data start=%u, "
"cluster size=%u bytes\n",
fs->fat_start, fs->data_start, fs->cluster_size);
printk("FAT32: total clusters=%u, root cluster=%u\n",
fs->total_clusters, fs->root_cluster);
}
解析根目录
根目录从簇2开始,对应扇区1056。读取这些扇区就能看到目录项:
c
void fat32_list_root_directory(struct fat32_info *fs)
{
unsigned int cluster = fs->root_cluster;
unsigned char *buf = kmalloc(fs->cluster_size);
while (cluster >= 2 && cluster < 0x0FFFFFF8) {
// 读取簇数据
unsigned int sector = cluster_to_sector(fs, cluster);
for (unsigned int i = 0; i < fs->sectors_per_cluster; i++) {
read_sector(sector + i, buf + i * fs->bytes_per_sector);
}
// 遍历目录项
int entries_per_cluster = fs->cluster_size / 32;
struct fat32_dir_entry *entry = (struct fat32_dir_entry *)buf;
for (int i = 0; i < entries_per_cluster; i++, entry++) {
// 检查目录项是否有效
if (entry->name[0] == 0x00)
goto done; // 目录结束
if (entry->name[0] == 0xE5)
continue; // 已删除的目录项
if (entry->attr == ATTR_LONG_NAME)
continue; // 长文件名项(稍后处理)
if (entry->attr & ATTR_VOLUME_ID)
continue; // 卷标
// 提取文件名
char filename[13];
format_83_name(entry->name, entry->ext, filename);
// 提取簇号
unsigned int start_cluster =
((unsigned int)entry->cluster_high << 16) |
entry->cluster_low;
// 打印信息
printk("%s %s cluster=%u size=%u\n",
(entry->attr & ATTR_DIRECTORY) ? "<DIR>" : " ",
filename,
start_cluster,
entry->file_size);
}
// 读取下一个簇号
cluster = fat32_read_fat_entry(fs, cluster);
}
done:
kfree(buf);
}
void format_83_name(unsigned char *name, unsigned char *ext, char *output)
{
int pos = 0;
// 复制文件名部分,去掉末尾空格
for (int i = 0; i < 8 && name[i] != ' '; i++)
output[pos++] = name[i];
// 如果有扩展名,加点号和扩展名
if (ext[0] != ' ') {
output[pos++] = '.';
for (int i = 0; i < 3 && ext[i] != ' '; i++)
output[pos++] = ext[i];
}
output[pos] = '\0';
}
解析长文件名
长文件名的解析稍微复杂一些——需要收集短文件名目录项前面的所有LFN目录项,按顺序拼接成完整的文件名:
c
// 解析长文件名
int fat32_read_lfn(struct fat32_dir_entry *entries, int index,
char *lfn_buf, int buf_size)
{
// 从当前位置向前查找LFN目录项
// LFN项在短文件名项之前,按逆序排列
struct fat32_lfn_entry *lfn;
int lfn_count = 0;
unsigned short lfn_chars[256];
int char_pos = 0;
// 向前扫描LFN项
int scan = index - 1;
while (scan >= 0) {
lfn = (struct fat32_lfn_entry *)&entries[scan];
if (lfn->attr != ATTR_LONG_NAME)
break;
// 提取这个LFN项中的字符
int seq = (lfn->order & 0x3F); // 序号(去掉最后一个标志位)
int base = (seq - 1) * 13; // 这组字符在文件名中的起始位置
// 从三个字段中提取字符
for (int i = 0; i < 5; i++)
lfn_chars[base + i] = lfn->name1[i];
for (int i = 0; i < 6; i++)
lfn_chars[base + 5 + i] = lfn->name2[i];
for (int i = 0; i < 2; i++)
lfn_chars[base + 11 + i] = lfn->name3[i];
if (lfn->order & 0x40) {
// 这是最后一组(最高序号),计算总字符数
char_pos = base + 13;
}
lfn_count++;
scan--;
}
if (lfn_count == 0)
return 0; // 没有长文件名
// 将UTF-16LE转换为ASCII(简化处理,忽略非ASCII字符)
int out_pos = 0;
for (int i = 0; i < char_pos && out_pos < buf_size - 1; i++) {
if (lfn_chars[i] == 0x0000 || lfn_chars[i] == 0xFFFF)
break;
if (lfn_chars[i] < 0x80)
lfn_buf[out_pos++] = (char)lfn_chars[i];
else
lfn_buf[out_pos++] = '?'; // 非ASCII字符用问号代替
}
lfn_buf[out_pos] = '\0';
return lfn_count;
}
UTF-16到UTF-8的完整转换在实际实现中是必要的,因为文件名可能包含中文、日文、韩文等多字节字符。上面的代码为了简化只处理了ASCII字符。
读取文件内容
有了FAT表的簇链和数据区的位置映射,读取文件内容的逻辑就很直接了:
c
// 读取整个文件到内存
int fat32_read_file(struct fat32_info *fs, unsigned int start_cluster,
unsigned int file_size, unsigned char *buffer)
{
unsigned int cluster = start_cluster;
unsigned int remaining = file_size;
unsigned int offset = 0;
unsigned char *cluster_buf = kmalloc(fs->cluster_size);
while (remaining > 0 && cluster >= 2 && cluster < 0x0FFFFFF8) {
// 读取当前簇
unsigned int sector = cluster_to_sector(fs, cluster);
for (unsigned int i = 0; i < fs->sectors_per_cluster; i++) {
read_sector(sector + i,
cluster_buf + i * fs->bytes_per_sector);
}
// 复制数据(最后一个簇可能不完全使用)
unsigned int copy_size = fs->cluster_size;
if (copy_size > remaining)
copy_size = remaining;
memcpy(buffer + offset, cluster_buf, copy_size);
offset += copy_size;
remaining -= copy_size;
// 沿簇链前进
cluster = fat32_read_fat_entry(fs, cluster);
}
kfree(cluster_buf);
return offset;
}
// 按偏移量和长度读取文件的一部分(支持随机读取)
int fat32_read_file_partial(struct fat32_info *fs, unsigned int start_cluster,
unsigned int file_size,
unsigned int read_offset, unsigned int read_length,
unsigned char *buffer)
{
if (read_offset >= file_size)
return 0;
if (read_offset + read_length > file_size)
read_length = file_size - read_offset;
// 计算起始簇——需要跳过前面的簇
unsigned int cluster = start_cluster;
unsigned int skip_clusters = read_offset / fs->cluster_size;
// 沿簇链跳过前面的簇
for (unsigned int i = 0; i < skip_clusters; i++) {
cluster = fat32_read_fat_entry(fs, cluster);
if (cluster >= 0x0FFFFFF8)
return 0; // 文件结束
}
unsigned int cluster_offset = read_offset % fs->cluster_size;
unsigned int bytes_read = 0;
unsigned char *cluster_buf = kmalloc(fs->cluster_size);
while (bytes_read < read_length && cluster >= 2 && cluster < 0x0FFFFFF8) {
// 读取当前簇
unsigned int sector = cluster_to_sector(fs, cluster);
for (unsigned int i = 0; i < fs->sectors_per_cluster; i++) {
read_sector(sector + i,
cluster_buf + i * fs->bytes_per_sector);
}
// 计算本簇中要复制的数据范围
unsigned int start_in_cluster = cluster_offset;
unsigned int available = fs->cluster_size - start_in_cluster;
unsigned int to_copy = read_length - bytes_read;
if (to_copy > available)
to_copy = available;
memcpy(buffer + bytes_read, cluster_buf + start_in_cluster, to_copy);
bytes_read += to_copy;
cluster_offset = 0; // 后续簇从头开始
cluster = fat32_read_fat_entry(fs, cluster);
}
kfree(cluster_buf);
return bytes_read;
}
这段代码清楚地展示了FAT32随机读取的效率问题——fat32_read_file_partial函数中的"跳过前面的簇"循环,时间复杂度与偏移量成正比。如果要读取一个1GB文件末尾的数据,需要遍历整条簇链才能找到对应的簇。这在实际使用中可以通过缓存簇链来缓解——将文件的整条簇链在第一次访问时缓存在内存中,后续的随机读取就可以直接定位。
文件删除的机制
FAT32删除文件的操作非常简单——将目录项的第一个字节设为0xE5(删除标记),然后将文件占用的所有簇在FAT表中标记为空闲(设为0x00000000)。文件的实际数据仍然保留在磁盘上,只是被标记为可覆盖。
这就是为什么文件删除后可以被恢复工具找回的原因——只要被删除文件占用的磁盘区域没有被新数据覆盖,旧数据就还在那里。数据恢复软件(如Recuva、TestDisk等)通过扫描目录中标记为0xE5的目录项,尝试根据目录项中记录的起始簇号和文件大小来恢复文件数据。如果文件的簇是连续分配的,恢复的成功率很高;如果文件碎片化严重(簇链被其他文件覆盖),恢复就困难得多。
这也是为什么涉及敏感数据的场景需要"安全删除"——不仅标记为空闲,还要用随机数据覆盖文件占用的磁盘区域。美国国防部的DoD 5220.22-M标准要求对数据区域进行多次覆写。不过对于现代固态硬盘来说,由于磨损均衡和垃圾回收机制的存在,传统的覆写方法可能无法保证数据被彻底清除——这时候需要使用SSD自带的Secure Erase命令。
14.2.3 开发基于路径名称的文件检索功能
路径解析的基本思路
用户访问文件时使用的是路径名称,如"/documents/reports/annual_report.pdf"。文件系统需要将这个路径名解析为具体的磁盘位置——即找到文件对应的目录项和起始簇号。
路径解析的过程就是沿着目录树逐层查找:
从根目录开始(对FAT32来说,根目录的起始簇号在BPB的root_cluster字段中)。
将路径按分隔符"/"拆分为各级组件:["documents", "reports", "annual_report.pdf"]。
在当前目录中查找名为"documents"的目录项。如果找到并且它是一个目录,以它的起始簇号为新的当前目录。
在新的当前目录中查找"reports"。同样,如果找到并且是目录,继续深入。
在"reports"目录中查找"annual_report.pdf"。找到后返回其目录项信息(起始簇号、文件大小等)。
如果在任何一步中找不到对应的名称,返回"文件不存在"的错误。
c
// 在指定目录中查找文件名匹配的目录项
int fat32_find_entry(struct fat32_info *fs, unsigned int dir_cluster,
const char *name, struct fat32_dir_entry *result)
{
unsigned char *buf = kmalloc(fs->cluster_size);
unsigned int cluster = dir_cluster;
while (cluster >= 2 && cluster < 0x0FFFFFF8) {
// 读取目录簇
unsigned int sector = cluster_to_sector(fs, cluster);
for (unsigned int i = 0; i < fs->sectors_per_cluster; i++) {
read_sector(sector + i, buf + i * fs->bytes_per_sector);
}
int entries_per_cluster = fs->cluster_size / 32;
struct fat32_dir_entry *entries = (struct fat32_dir_entry *)buf;
for (int i = 0; i < entries_per_cluster; i++) {
if (entries[i].name[0] == 0x00) {
// 目录结束
kfree(buf);
return -1; // 未找到
}
if (entries[i].name[0] == 0xE5)
continue; // 已删除
if (entries[i].attr == ATTR_LONG_NAME)
continue; // 跳过LFN项(后面统一处理)
if (entries[i].attr & ATTR_VOLUME_ID)
continue; // 跳过卷标
// 尝试匹配长文件名
char lfn_name[256];
int lfn_count = fat32_read_lfn(entries, i, lfn_name, sizeof(lfn_name));
if (lfn_count > 0) {
// 有长文件名——与长文件名比较(不区分大小写)
if (strcasecmp(lfn_name, name) == 0) {
*result = entries[i];
kfree(buf);
return 0; // 找到了
}
}
// 同时尝试匹配短文件名
char sfn_name[13];
format_83_name(entries[i].name, entries[i].ext, sfn_name);
if (strcasecmp(sfn_name, name) == 0) {
*result = entries[i];
kfree(buf);
return 0; // 找到了
}
}
cluster = fat32_read_fat_entry(fs, cluster);
}
kfree(buf);
return -1; // 未找到
}
有了单级目录查找功能,路径解析就可以实现了:
c
// 路径解析——将路径名解析为目录项
int fat32_lookup_path(struct fat32_info *fs, const char *path,
struct fat32_dir_entry *result)
{
// 跳过开头的斜杠
if (*path == '/')
path++;
if (*path == '\0') {
// 路径为"/"——返回根目录的虚拟目录项
memset(result, 0, sizeof(*result));
result->attr = ATTR_DIRECTORY;
result->cluster_high = (fs->root_cluster >> 16) & 0xFFFF;
result->cluster_low = fs->root_cluster & 0xFFFF;
return 0;
}
unsigned int current_cluster = fs->root_cluster;
char component[256];
while (*path) {
// 提取路径的下一个组件
int len = 0;
while (*path && *path != '/') {
component[len++] = *path++;
}
component[len] = '\0';
// 跳过斜杠
while (*path == '/')
path++;
// 在当前目录中查找该组件
struct fat32_dir_entry entry;
int ret = fat32_find_entry(fs, current_cluster, component, &entry);
if (ret < 0)
return -ENOENT; // 组件不存在
if (*path != '\0') {
// 路径还有后续组件——当前组件必须是目录
if (!(entry.attr & ATTR_DIRECTORY))
return -ENOTDIR; // 不是目录
// 进入子目录
current_cluster = ((unsigned int)entry.cluster_high << 16) |
entry.cluster_low;
} else {
// 这是路径的最后一个组件——返回结果
*result = entry;
return 0;
}
}
return -ENOENT;
}
"."和".."目录项
每个目录(根目录除外)的前两个目录项固定是"."(当前目录)和".."(父目录):
"."的起始簇号指向本目录自身的起始簇。
".."的起始簇号指向父目录的起始簇。如果父目录是根目录,簇号为0。
这两个特殊目录项使得相对路径解析成为可能——"../sibling_dir/file.txt"首先通过".."回到父目录,然后进入"sibling_dir"子目录,最后找到"file.txt"。
c
// 路径解析中处理"."和".."
if (strcmp(component, ".") == 0) {
// 当前目录——不需要改变current_cluster
continue;
}
if (strcmp(component, "..") == 0) {
// 父目录
struct fat32_dir_entry dotdot;
int ret = fat32_find_entry(fs, current_cluster, "..", &dotdot);
if (ret < 0)
return -ENOENT;
unsigned int parent_cluster =
((unsigned int)dotdot.cluster_high << 16) | dotdot.cluster_low;
// 特殊情况:如果parent_cluster为0,表示根目录
if (parent_cluster == 0)
parent_cluster = fs->root_cluster;
current_cluster = parent_cluster;
continue;
}
创建文件和目录
创建文件需要以下步骤:
分配簇:从FAT表中找到一个空闲簇(FAT表项为0的簇),将其标记为文件结束(0x0FFFFFF8)。如果文件需要多个簇,分配多个并用簇链连接起来。
创建目录项:在目标目录中找到一个空闲的目录项位置(name[0]为0x00或0xE5的位置),写入文件的信息——名称、属性、起始簇号、大小、时间戳等。如果需要长文件名,还要在短文件名目录项之前写入LFN目录项。
如果目录已满(当前簇链中没有空闲的目录项位置),需要为目录分配新的簇来扩展目录。
c
// 在FAT表中分配一个空闲簇
unsigned int fat32_alloc_cluster(struct fat32_info *fs)
{
// 从FSInfo中获取搜索起点(加速搜索)
unsigned int start = fs->next_free_hint;
if (start < 2 || start >= fs->total_clusters + 2)
start = 2;
// 遍历FAT表寻找空闲簇
for (unsigned int i = start; i < fs->total_clusters + 2; i++) {
unsigned int entry = fat32_read_fat_entry(fs, i);
if (entry == 0x00000000) {
// 找到空闲簇——标记为已使用(文件结束)
fat32_write_fat_entry(fs, i, 0x0FFFFFF8);
fs->next_free_hint = i + 1;
fs->free_cluster_count--;
return i;
}
}
// 如果从start开始没找到,从头再搜索
for (unsigned int i = 2; i < start; i++) {
unsigned int entry = fat32_read_fat_entry(fs, i);
if (entry == 0x00000000) {
fat32_write_fat_entry(fs, i, 0x0FFFFFF8);
fs->next_free_hint = i + 1;
fs->free_cluster_count--;
return i;
}
}
return 0; // 没有空闲簇——磁盘满
}
// 为文件追加簇
unsigned int fat32_extend_chain(struct fat32_info *fs, unsigned int last_cluster)
{
unsigned int new_cluster = fat32_alloc_cluster(fs);
if (new_cluster == 0)
return 0; // 分配失败
// 将上一个簇的FAT表项指向新簇
fat32_write_fat_entry(fs, last_cluster, new_cluster);
// 新簇标记为文件结束
fat32_write_fat_entry(fs, new_cluster, 0x0FFFFFF8);
// 清零新簇的数据
unsigned char zero_buf[512];
memset(zero_buf, 0, 512);
unsigned int sector = cluster_to_sector(fs, new_cluster);
for (unsigned int i = 0; i < fs->sectors_per_cluster; i++) {
write_sector(sector + i, zero_buf);
}
return new_cluster;
}
// 在目录中创建新的目录项
int fat32_create_entry(struct fat32_info *fs, unsigned int dir_cluster,
const char *name, unsigned char attr,
unsigned int start_cluster, unsigned int file_size)
{
unsigned char *buf = kmalloc(fs->cluster_size);
unsigned int cluster = dir_cluster;
unsigned int prev_cluster = 0;
// 生成短文件名
char sfn[11];
generate_short_name(fs, dir_cluster, name, sfn);
// 计算需要多少个目录项(1个SFN + N个LFN)
int name_len = strlen(name);
int lfn_entries = (name_len + 12) / 13;
int total_entries = lfn_entries + 1;
while (cluster >= 2 && cluster < 0x0FFFFFF8) {
unsigned int sector = cluster_to_sector(fs, cluster);
for (unsigned int i = 0; i < fs->sectors_per_cluster; i++) {
read_sector(sector + i, buf + i * fs->bytes_per_sector);
}
int entries_per_cluster = fs->cluster_size / 32;
struct fat32_dir_entry *entries = (struct fat32_dir_entry *)buf;
// 寻找连续的空闲目录项
int free_start = -1;
int free_count = 0;
for (int i = 0; i < entries_per_cluster; i++) {
if (entries[i].name[0] == 0x00 || entries[i].name[0] == 0xE5) {
if (free_start < 0)
free_start = i;
free_count++;
if (free_count >= total_entries) {
// 找到足够的连续空间
write_lfn_entries(entries, free_start, name, sfn,
lfn_entries);
write_sfn_entry(&entries[free_start + lfn_entries],
sfn, attr, start_cluster, file_size);
// 写回磁盘
for (unsigned int j = 0; j < fs->sectors_per_cluster; j++) {
write_sector(sector + j,
buf + j * fs->bytes_per_sector);
}
kfree(buf);
return 0;
}
} else {
free_start = -1;
free_count = 0;
}
}
prev_cluster = cluster;
cluster = fat32_read_fat_entry(fs, cluster);
}
// 目录簇链已满——需要扩展目录
unsigned int new_cluster = fat32_extend_chain(fs, prev_cluster);
if (new_cluster == 0) {
kfree(buf);
return -ENOSPC;
}
// 在新簇中创建目录项...
// (递归调用或直接在新簇中写入)
kfree(buf);
return 0;
}
短文件名的生成是一个有趣的算法——需要将长文件名转换为合法的8.3格式:
将文件名转为大写。
移除所有不允许的字符(空格、特殊字符等)。
如果主文件名超过8个字符,截断到6个字符后加"~1"(如果"~1"已存在则使用"~2",依此类推)。
扩展名截断到3个字符。
c
void generate_short_name(struct fat32_info *fs, unsigned int dir_cluster,
const char *long_name, char sfn[11])
{
// 初始化为空格填充
memset(sfn, ' ', 11);
// 找到扩展名的位置(最后一个点)
const char *dot = strrchr(long_name, '.');
const char *base = long_name;
int base_len = dot ? (dot - long_name) : strlen(long_name);
int ext_len = dot ? strlen(dot + 1) : 0;
// 复制文件名主体(最多8字符,转大写)
int pos = 0;
for (int i = 0; i < base_len && pos < 8; i++) {
char c = toupper(base[i]);
if (c == ' ' || c == '.')
continue; // 跳过空格和点
sfn[pos++] = c;
}
// 复制扩展名(最多3字符,转大写)
if (dot) {
for (int i = 0; i < ext_len && i < 3; i++) {
sfn[8 + i] = toupper(dot[1 + i]);
}
}
// 如果长文件名不等于短文件名(需要数字后缀)
if (base_len > 8 || needs_lfn(long_name)) {
// 检查"~1"是否冲突,如果冲突则递增
for (int n = 1; n < 1000; n++) {
char tail[8];
int tail_len = snprintf(tail, sizeof(tail), "~%d", n);
int keep = 8 - tail_len;
// 截断文件名并加上尾部
memcpy(sfn + keep, tail, tail_len);
// 检查是否已存在同名的短文件名
if (!sfn_exists(fs, dir_cluster, sfn))
break;
}
}
}
Windows的资源管理器在处理短文件名冲突时使用了更复杂的算法——当冲突数超过4个时,会使用哈希而不是简单递增来生成后缀,以减少扫描次数。
写入文件数据
写入文件数据涉及分配新簇和写入磁盘两个操作:
c
int fat32_write_file(struct fat32_info *fs, const char *path,
const unsigned char *data, unsigned int size)
{
// 解析路径,找到目标目录和文件名
char dir_path[256], file_name[256];
split_path(path, dir_path, file_name);
// 找到目标目录
struct fat32_dir_entry dir_entry;
int ret = fat32_lookup_path(fs, dir_path, &dir_entry);
if (ret < 0)
return ret;
unsigned int dir_cluster =
((unsigned int)dir_entry.cluster_high << 16) | dir_entry.cluster_low;
// 检查文件是否已存在
struct fat32_dir_entry file_entry;
ret = fat32_find_entry(fs, dir_cluster, file_name, &file_entry);
if (ret == 0) {
// 文件已存在——需要先释放旧的簇链
unsigned int old_cluster =
((unsigned int)file_entry.cluster_high << 16) |
file_entry.cluster_low;
fat32_free_chain(fs, old_cluster);
}
// 分配簇并写入数据
unsigned int first_cluster = 0;
unsigned int prev_cluster = 0;
unsigned int remaining = size;
unsigned int offset = 0;
while (remaining > 0) {
unsigned int new_cluster = fat32_alloc_cluster(fs);
if (new_cluster == 0)
return -ENOSPC;
if (first_cluster == 0)
first_cluster = new_cluster;
if (prev_cluster != 0)
fat32_write_fat_entry(fs, prev_cluster, new_cluster);
// 写入数据到这个簇
unsigned int write_size = fs->cluster_size;
if (write_size > remaining)
write_size = remaining;
unsigned char *cluster_buf = kmalloc(fs->cluster_size);
memset(cluster_buf, 0, fs->cluster_size);
memcpy(cluster_buf, data + offset, write_size);
unsigned int sector = cluster_to_sector(fs, new_cluster);
for (unsigned int i = 0; i < fs->sectors_per_cluster; i++) {
write_sector(sector + i,
cluster_buf + i * fs->bytes_per_sector);
}
kfree(cluster_buf);
offset += write_size;
remaining -= write_size;
prev_cluster = new_cluster;
}
// 最后一个簇标记为EOF
if (prev_cluster != 0)
fat32_write_fat_entry(fs, prev_cluster, 0x0FFFFFF8);
// 创建或更新目录项
if (ret < 0) {
// 文件不存在——创建新目录项
fat32_create_entry(fs, dir_cluster, file_name, ATTR_ARCHIVE,
first_cluster, size);
} else {
// 文件已存在——更新目录项
fat32_update_entry(fs, dir_cluster, file_name,
first_cluster, size);
}
return 0;
}
// 释放簇链
void fat32_free_chain(struct fat32_info *fs, unsigned int cluster)
{
while (cluster >= 2 && cluster < 0x0FFFFFF8) {
unsigned int next = fat32_read_fat_entry(fs, cluster);
fat32_write_fat_entry(fs, cluster, 0x00000000);
fs->free_cluster_count++;
cluster = next;
}
}
这里有一个重要的数据一致性问题——如果在写入过程中系统崩溃或断电,文件系统可能处于不一致的状态。比如FAT表已经更新了(簇被分配),但目录项还没更新(目录项中的起始簇号还是旧值),这些新分配的簇就成了"孤儿簇"——被标记为已使用但没有任何文件引用它们。
FAT32没有日志机制来处理这种不一致。Windows的chkdsk和Linux的fsck.fat可以在启动时扫描并修复这些问题——找出孤儿簇、修复交叉链接(两个文件共享同一个簇链,这是非常严重的错误)、删除无效的目录项等。但修复过程可能导致数据丢失。
这也是为什么U盘应该"安全弹出"而不是直接拔出——安全弹出操作会确保所有缓存的写入都已经同步到磁盘上,文件系统处于一致的状态。
ext3/ext4的日志机制在这方面好得多——每次修改文件系统之前,先将修改操作记录到日志区域。如果中途崩溃,重新挂载时只需要重放日志就能恢复到一致状态,不需要扫描整个文件系统。这大大加快了恢复速度(几秒而不是几分钟甚至几小时),也大大降低了数据不一致的风险。
NTFS同样有日志功能——$LogFile记录了所有元数据修改操作。Windows在非正常关机后的快速启动也依赖于这个日志。
Btrfs和ZFS走得更远——它们使用写时复制(Copy-on-Write)策略:修改数据时不覆盖原来的位置,而是写到新的位置,最后通过原子地更新根指针来切换到新版本。这保证了任何时刻磁盘上都有一个完整、一致的文件系统状态。即使在写入过程中断电,最坏的情况也只是最后的几次写入丢失,文件系统本身不会损坏。
14.3 虚拟文件系统层
14.3.1 Linux VFS机制概述
为什么需要VFS
一台计算机上可能同时挂载着多种不同的文件系统:
系统盘是ext4格式。
插入的U盘是FAT32格式。
挂载的网络共享是NFS或CIFS/SMB格式。
/proc是procfs(伪文件系统,提供内核和进程信息)。
/sys是sysfs(伪文件系统,提供设备和驱动信息)。
/tmp可能是tmpfs(内存文件系统)。
/dev是devtmpfs(设备节点文件系统)。
用户希望用相同的命令和接口访问所有这些文件系统中的文件——用同样的cat命令读取ext4上的文本文件和FAT32 U盘上的文本文件;用同样的cp命令在ext4和NFS之间复制文件。应用程序只需要调用open()、read()、write()、close(),不需要关心底层是什么文件系统。
VFS(Virtual File System)就是实现这种统一抽象的中间层。VFS定义了一组通用的对象和操作接口,每种具体的文件系统实现这些接口,VFS负责将用户的请求路由到正确的文件系统实现。
这是一种经典的面向对象设计——虽然是用C语言实现的,但使用了函数指针来实现多态(不同文件系统的同名操作有不同的实现),使用结构体来封装状态。
VFS的四个核心对象
Linux VFS定义了四个核心对象,它们构成了文件系统抽象的基础:
超级块(Superblock)——代表一个已挂载的文件系统实例。每挂载一个文件系统就创建一个超级块对象。超级块包含文件系统的全局信息——块大小、文件系统类型、挂载选项、根目录的inode等。
c
struct super_block {
struct list_head s_list; // 所有超级块链表
unsigned long s_blocksize; // 块大小
unsigned char s_blocksize_bits; // 块大小的位数(log2)
unsigned long long s_maxbytes; // 最大文件大小
struct file_system_type *s_type; // 文件系统类型
struct super_operations *s_op; // 超级块操作函数
unsigned long s_flags; // 挂载标志
unsigned long s_magic; // 文件系统魔数
struct dentry *s_root; // 根目录的dentry
// 文件系统私有数据(FAT32的fs_info、ext4的sb_info等)
void *s_fs_info;
};
struct super_operations {
// 分配新的inode
struct inode *(*alloc_inode)(struct super_block *sb);
// 释放inode
void (*destroy_inode)(struct inode *inode);
// 从磁盘读取inode信息
struct inode *(*read_inode)(struct super_block *sb, unsigned long ino);
// 将inode信息写回磁盘
int (*write_inode)(struct inode *inode, int sync);
// 删除inode(文件被删除时调用)
void (*delete_inode)(struct inode *inode);
// 同步文件系统(sync命令)
int (*sync_fs)(struct super_block *sb, int wait);
// 获取文件系统统计信息(df命令)
int (*statfs)(struct super_block *sb, struct kstatfs *buf);
};
索引节点(Inode)——代表文件系统中的一个对象(文件、目录、符号链接、设备节点等)。Inode包含文件的元数据——大小、权限、所有者、时间戳、数据在磁盘上的位置等。每个文件在磁盘上都有唯一的inode号。
"Inode"这个名字是Unix的传统术语——index node(索引节点)的缩写。FAT32实际上没有inode的概念(文件信息存在目录项中),但VFS为FAT32也创建了虚拟的inode对象,把目录项中的信息映射到inode字段中。NTFS的MFT记录、ext4的磁盘inode,都对应到VFS的inode对象。
c
struct inode {
unsigned long i_ino; // inode号
unsigned int i_mode; // 文件类型和权限
unsigned int i_nlink; // 硬链接计数
unsigned int i_uid; // 所有者用户ID
unsigned int i_gid; // 所有者组ID
unsigned long long i_size; // 文件大小
struct timespec i_atime; // 最后访问时间
struct timespec i_mtime; // 最后修改时间
struct timespec i_ctime; // 状态改变时间
struct inode_operations *i_op; // inode操作函数
struct file_operations *i_fop; // 默认的文件操作函数
struct super_block *i_sb; // 所属超级块
struct address_space *i_mapping; // 页缓存映射
void *i_private; // 文件系统私有数据
};
struct inode_operations {
// 在目录中查找文件名
struct dentry *(*lookup)(struct inode *dir, struct dentry *dentry,
unsigned int flags);
// 创建文件
int (*create)(struct inode *dir, struct dentry *dentry,
unsigned int mode);
// 创建硬链接
int (*link)(struct dentry *old_dentry, struct inode *dir,
struct dentry *new_dentry);
// 删除文件
int (*unlink)(struct inode *dir, struct dentry *dentry);
// 创建符号链接
int (*symlink)(struct inode *dir, struct dentry *dentry,
const char *symname);
// 创建目录
int (*mkdir)(struct inode *dir, struct dentry *dentry, unsigned int mode);
// 删除目录
int (*rmdir)(struct inode *dir, struct dentry *dentry);
// 重命名
int (*rename)(struct inode *old_dir, struct dentry *old_dentry,
struct inode *new_dir, struct dentry *new_dentry);
};
目录项(Dentry)——代表路径中的一个组件。比如路径"/home/user/file.txt"包含四个dentry:"/"、"home"、"user"、"file.txt"。Dentry将文件名与inode关联起来,并维护目录树的层次结构。
Dentry的一个关键作用是缓存路径查找结果。每次路径解析的结果都会缓存在dentry cache(dcache)中——下次访问相同路径时不需要再访问磁盘,直接从dcache中获取inode。Dcache是Linux内核中最重要的缓存之一,对文件系统性能有巨大影响。
c
struct dentry {
struct inode *d_inode; // 关联的inode
struct dentry *d_parent; // 父目录的dentry
struct qstr d_name; // 目录项名称
struct list_head d_child; // 同级目录项链表
struct list_head d_subdirs; // 子目录项链表
struct dentry_operations *d_op; // dentry操作函数
struct super_block *d_sb; // 所属超级块
// dcache相关
struct hlist_node d_hash; // 哈希表节点
int d_count; // 引用计数
};
struct dentry_operations {
// 比较dentry名称(FAT32不区分大小写)
int (*d_compare)(const struct dentry *dentry,
unsigned int len, const char *str,
const struct qstr *name);
// 检查缓存的dentry是否仍然有效
int (*d_revalidate)(struct dentry *dentry, unsigned int flags);
// dentry被释放时的清理操作
void (*d_release)(struct dentry *dentry);
};
文件对象(File)——代表一个进程打开的文件实例。File对象包含了与打开操作相关的信息——当前读写位置(偏移量)、打开模式(只读/读写/追加等)。注意,同一个文件可以被多个进程同时打开,每个打开操作产生一个独立的File对象(各有各的偏移量),但它们共享同一个inode。
c
struct file {
struct path f_path; // 文件路径(vfsmount + dentry)
struct inode *f_inode; // 关联的inode
struct file_operations *f_op; // 文件操作函数
unsigned int f_flags; // 打开标志
unsigned int f_mode; // 访问模式
long long f_pos; // 当前文件偏移量
void *private_data; // 文件系统私有数据
};
struct file_operations {
// 修改文件偏移量(lseek系统调用)
long long (*llseek)(struct file *file, long long offset, int whence);
// 读取文件数据(read系统调用)
ssize_t (*read)(struct file *file, char *buf, size_t count,
long long *pos);
// 写入文件数据(write系统调用)
ssize_t (*write)(struct file *file, const char *buf, size_t count,
long long *pos);
// 读取目录内容(getdents系统调用)
int (*readdir)(struct file *file, struct dir_context *ctx);
// 内存映射(mmap系统调用)
int (*mmap)(struct file *file, struct vm_area_struct *vma);
// 打开文件
int (*open)(struct inode *inode, struct file *file);
// 关闭文件
int (*release)(struct inode *inode, struct file *file);
// 同步文件数据到磁盘(fsync系统调用)
int (*fsync)(struct file *file, int datasync);
};
系统调用到VFS的流程
当用户程序调用open("/mnt/usb/photos/sunset.jpg", O_RDONLY)时,内核中发生的事情大致如下:
系统调用入口将请求交给VFS层的do_sys_open()函数。
VFS调用路径查找函数path_lookup(),沿着路径逐级解析:
- 从根目录"/"开始,找到"mnt"的dentry和inode。
- 进入"mnt"目录,找到"usb"的dentry。这里VFS发现"usb"是一个挂载点——有一个文件系统挂载在/mnt/usb上(比如FAT32 U盘)。VFS切换到这个文件系统的超级块和根dentry。
- 在FAT32文件系统中查找"photos"目录。VFS调用FAT32的inode_operations->lookup()函数,该函数在FAT32的目录结构中搜索名为"photos"的目录项。
- 在"photos"目录中查找"sunset.jpg"。同样调用FAT32的lookup()。
VFS创建一个File对象,关联到找到的inode上,设置f_op为FAT32的file_operations。
返回一个文件描述符给用户程序。
之后用户调用read(fd, buf, 4096)时:
VFS通过文件描述符找到File对象。
调用File对象的f_op->read()函数——这是FAT32文件系统注册的读取函数。
FAT32的read函数根据f_pos(当前偏移量)计算对应的簇号和簇内偏移,读取数据返回给用户。
f_pos自动前移read_count个字节。
整个过程中,VFS处理了挂载点切换、路径缓存(dcache)、权限检查等通用逻辑,而具体的磁盘数据读取则委托给了FAT32文件系统的实现。如果sunset.jpg在ext4分区上,VFS会调用ext4的操作函数——对用户程序来说完全透明。
注册文件系统
每种文件系统通过register_filesystem()函数向VFS注册自己:
c
// 文件系统类型描述结构
struct file_system_type {
const char *name; // 文件系统名称("fat32", "ext4"等)
int fs_flags; // 标志
// 挂载文件系统——读取超级块,创建super_block对象
struct super_block *(*mount)(struct file_system_type *type,
int flags, const char *dev_name,
void *data);
// 卸载文件系统
void (*kill_sb)(struct super_block *sb);
struct file_system_type *next; // 链表
};
// FAT32文件系统类型注册
struct file_system_type fat32_fs_type = {
.name = "vfat",
.mount = fat32_mount,
.kill_sb = fat32_kill_sb,
};
void fat32_init(void)
{
register_filesystem(&fat32_fs_type);
}
// FAT32的mount实现
struct super_block *fat32_mount(struct file_system_type *type,
int flags, const char *dev_name,
void *data)
{
// 打开块设备
struct block_device *bdev = open_block_device(dev_name);
// 分配超级块
struct super_block *sb = alloc_super_block();
sb->s_type = type;
// 读取并解析BPB
struct fat32_info *fs_info = kmalloc(sizeof(struct fat32_info));
unsigned char boot_sector[512];
bdev_read_sector(bdev, 0, boot_sector);
fat32_parse_boot_sector(fs_info, (struct fat32_boot_sector *)boot_sector);
sb->s_fs_info = fs_info;
// 设置操作函数
sb->s_op = &fat32_super_operations;
sb->s_blocksize = fs_info->bytes_per_sector;
// 创建根目录的inode和dentry
struct inode *root_inode = fat32_read_root_inode(sb);
sb->s_root = d_make_root(root_inode);
return sb;
}
挂载机制
mount命令(或mount系统调用)将一个文件系统实例附加到目录树的某个挂载点上。挂载后,访问挂载点目录就是在访问新挂载的文件系统的根目录。
c
struct vfsmount {
struct dentry *mnt_mountpoint; // 挂载点的dentry
struct dentry *mnt_root; // 挂载的文件系统的根dentry
struct super_block *mnt_sb; // 超级块
struct list_head mnt_list; // 挂载链表
};
路径解析过程中,VFS在每一步都检查当前dentry是否是某个文件系统的挂载点。如果是,就"跳转"到该文件系统——用挂载的文件系统的根dentry替换当前dentry,后续的查找在新文件系统中进行。
这就是为什么在Linux中可以将任何文件系统挂载到任何目录下——挂载只是在dentry缓存中建立了一个"跳转点",不需要修改已有文件系统中的任何数据。
Windows使用了不同的命名方式——每个分区分配一个驱动器号(C:、D:、E:等),而不是挂载到统一的目录树中。这种方式比Unix的挂载方式更直观(用户很容易理解"C盘"和"D盘"),但也有局限性——驱动器号只有26个,不够灵活。Windows从Windows 2000开始也支持将分区挂载到空的NTFS目录(Volume Mount Points),类似于Unix的挂载语义。
macOS同时使用了两种方式——外部存储设备自动挂载到/Volumes/目录下(如/Volumes/USBDISK),用户在Finder中看到的是独立的"磁盘图标"。
伪文件系统
VFS的一个优雅之处在于它不仅适用于磁盘文件系统——任何东西只要实现了VFS接口,都可以作为文件系统挂载。Linux充分利用了这一点,创建了大量的伪文件系统(不对应任何物理存储设备):
procfs(/proc)——展现内核和进程的运行时信息。/proc/cpuinfo显示CPU信息,/proc/meminfo显示内存信息,/proc/[pid]/status显示指定进程的状态。这些"文件"的内容不存储在磁盘上,而是在read()时由内核实时生成。
sysfs(/sys)——展现设备和驱动的层次结构。/sys/class/net/列出所有网络接口,/sys/block/sda/展示硬盘信息。sysfs也支持写入——向某些文件写入值可以配置内核参数。
devtmpfs(/dev)——设备节点文件系统。/dev/sda表示第一块硬盘,/dev/tty表示当前终端,/dev/null是空设备(写入的数据被丢弃,读取立即返回EOF)。设备节点通过主设备号和次设备号关联到内核中的设备驱动。
tmpfs——内存文件系统。数据完全存储在内存中(和Page Cache中),不写入磁盘。读写速度极快,但系统重启后数据消失。/tmp、/run等临时目录通常使用tmpfs。
这种"万物皆文件"的设计哲学是Unix的核心思想之一——通过将各种系统资源抽象为文件,可以用统一的接口(open/read/write/close)访问它们,shell脚本和命令行工具可以方便地操作系统的各个方面。比如查看CPU温度只需要cat /sys/class/thermal/thermal_zone0/temp,调整屏幕亮度可以echo 50 > /sys/class/backlight/intel_backlight/brightness。
Windows采用了不同的方式来暴露系统信息——通过WMI(Windows Management Instrumentation)、注册表、设备管理器API等,而不是文件系统接口。这些方式功能上等价,但使用起来通常比文件接口更复杂。
Plan 9操作系统(贝尔实验室继Unix之后开发的系统)将"万物皆文件"的哲学推向了极致——连网络协议栈、图形界面、甚至远程计算机的资源都通过文件系统接口访问。虽然Plan 9没有获得商业成功,但它的许多设计理念(包括VFS和名字空间的概念)深刻影响了后来的Linux和其他系统。
文件系统是操作系统中与用户关系最密切的子系统。本章从文件系统的基本功能开始,以FAT32为例详细介绍了一个真实文件系统的磁盘数据结构——引导扇区、文件分配表、目录项——以及基于这些结构的文件读写和路径解析操作。通过实际的十六进制数据分析和代码实现,揭示了看似简单的"打开一个文件"背后涉及的复杂逻辑。
虚拟文件系统层的引入则将视野提升到了架构层面——通过定义统一的接口抽象,让不同的文件系统实现可以无缝地集成到同一个操作系统中。VFS的四个核心对象(超级块、索引节点、目录项、文件对象)构成了一个清晰的分层模型,既为上层应用提供了一致的访问接口,又为底层文件系统保留了充分的实现自由度。
FAT32虽然古老且功能有限,但它的简洁性使其成为学习文件系统原理的绝佳起点。理解了FAT32的设计思路和实现细节后,再去研究ext4的extent树、NTFS的MFT、Btrfs的写时复制B树等更复杂的数据结构,就有了坚实的基础和清晰的参照系。毕竟所有文件系统面对的根本问题是相同的——如何在有限的存储空间上高效、可靠、灵活地组织数据。不同的文件系统只是在这个问题的各个维度上做出了不同的权衡选择。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)