9.7 文件系统与虚拟抽象
在操作系统中,底层存储介质(如软盘、机械硬盘、SATA SSD 或 NVMe 驱动器)以固定的物理块或逻辑扇区(LBA)为单位组织数据。然而,直接面向连续扇区号编写读写代码对于应用程序而言极其晦涩且脆弱。
文件系统(File System)正是构建在块设备驱动之上的核心软件层。它的主要职责在于有效、安全且结构化地管理非易失性存储资源,将杂乱离散的磁盘扇区抽象为具备层次结构的目录树与连续的逻辑数据流(即文件)。
在现代操作系统生态中,根据应用场景、容错级别与存储介质特性的不同,演进出多样化的文件系统规范:
- FAT 家族(FAT12、FAT16、FAT32 / VFAT):基于文件分配表(File Allocation Table)构建的链式存储结构,逻辑简明、跨平台通用性极佳,是自制操作系统最初期挂载与读取内核资源的理想靶标;
- Unix / Linux 原生系统(ext2、ext3、ext4、Btrfs、XFS):基于索引节点(Inode)与多级间接块构建,现代版本引入了日志事务机制(Journaling)与写时复制(COW),提供极高的可靠性与吞吐性能;
- 内存与特殊介质文件系统(RAMFS、tmpfs、ISO9660 光盘系统、NFS 网络文件系统):将非磁盘载体或网络协议透明封装为标准的文件操作形态。
1. 核心存储单元与数据抽象
在文件系统的体系设计中,存在两项最为基本的逻辑构成元素:
- 文件(File):文件是持久化存储在逻辑层面的最小独立管理单元。对上层应用程序而言,文件表现为一段可以随机寻址、按需截断的连续字节序列;对底层文件系统而言,文件由两部分构成:一是存储具体内容的物理数据块(或簇 Cluster),二是描述该文件属性的元数据(Metadata,包含文件名、尺寸、创建/访问时间戳以及访问权限等)。
- 目录(Directory):目录是构建树状命名空间的容器。在类 Unix 系统设计中,“目录也被抽象为一种特殊的文件”,其数据块内部存储着该层级下所有子文件与子目录的目录项记录(Directory Entries),记录了文件名与其物理节点索引(如起始簇号或 Inode 编号)之间的对应关系。
2. 虚拟文件系统(VFS)架构
当操作系统需要同时支持多种异构文件系统(例如根目录采用 ext4,挂载的 U 盘为 FAT32,配置信息存放在内存文件系统)时,若直接让上层系统调用处理每种文件系统的底层差异,内核代码将陷入无法维护的混乱。
为此,现代内核引入了虚拟文件系统(Virtual File System,简称 VFS)作为统一抽象层:
- 抽象接口与多态分发:VFS 自身并不直接负责磁盘扇区的数据编排,而是定义了一组标准的抽象节点结构(如
vfs_node/inode)以及挂载在节点上的函数指针操作表(如包含open、read、write、readdir的vfs_ops)。 - 统一挂载模型(Mounting):所有的物理文件系统或伪文件系统,只需实现这套函数指针契约,便可通过挂载机制与全局命名空间树绑定。当用户程序调用统一的
read()系统调用时,VFS 根据路径走查自动路由到目标节点,并借助函数指针分发至具体文件系统的驱动实现。
3. 广义设备与伪文件系统(devfs / pipefs)
类 Unix 系统最为深邃的设计理念之一便是“一切皆文件”(Everything is a file)。基于 VFS 强大的多态分发能力,操作系统可以将许多非持久化的系统资源与外设接口无缝接入文件树:
- 设备文件系统(devfs):将底层外设控制器(如用于交互的串行终端
/dev/console、始终产生零字节流的伪设备/dev/zero、随机数源/dev/urandom等)统一呈现为/dev目录下的特殊文件。驱动开发者只需为该设备实现read或write操作函数,应用程序即可通过常规的文件 I/O 接口与底层硬件通信; - 管道文件系统(pipefs):将进程间通信(IPC)机制中的管道抽象为内存中的一组读写双端节点。数据通过环形缓冲区在内存中暂存与流转,对上层执行读写的进程而言,其操作逻辑与读写常规磁盘文件完全一致。
4. 习题
这一页只给了文件系统的骨架:文件与目录是什么、VFS 起什么作用、devfs 与 pipefs 为什么能当成文件。要写一个能挂载、能读出内容的文件系统,还差三样东西:磁盘上的数据结构、路径到节点的查找、以及把节点操作接到一组函数指针上。下面四道题沿这条线走,前两道在主机上练数据结构和目录项的解析,后两道把它们连成一个小 VFS。
4.1 巩固
FAT12 的根目录是一串 32 字节的目录项,最后以首字节 0x00 的项结尾。下面是某个根目录扇区的前 192 字节,卷参数为每扇区 512 字节、每簇 1 扇区、1 份 FAT、根目录 16 项:
static const uint8_t dir_sector[192] = {
0x41, 0x6D, 0x00, 0x64, 0x00, 0x72, 0x00, 0x20, 0x00, 0x6C, 0x00, 0x0F,
0x00, 0x22, 0x6F, 0x00, 0x6E, 0x00, 0x67, 0x00, 0x2E, 0x00, 0x74, 0x00,
0x78, 0x00, 0x00, 0x00, 0x74, 0x00, 0x00, 0x00, 0x4D, 0x44, 0x52, 0x20,
0x20, 0x20, 0x20, 0x20, 0x54, 0x58, 0x54, 0x20, 0x00, 0x00, 0x00, 0x60,
0x2F, 0x5B, 0x2F, 0x5B, 0x00, 0x00, 0x00, 0x60, 0x2F, 0x5B, 0x02, 0x00,
0x2C, 0x01, 0x00, 0x00, 0x53, 0x59, 0x53, 0x54, 0x45, 0x4D, 0x20, 0x20,
0x42, 0x49, 0x4E, 0x27, 0x00, 0x00, 0x00, 0x60, 0x2F, 0x5B, 0x2F, 0x5B,
0x00, 0x00, 0x00, 0x60, 0x2F, 0x5B, 0x05, 0x00, 0x00, 0x04, 0x00, 0x00,
0x53, 0x55, 0x42, 0x44, 0x49, 0x52, 0x20, 0x20, 0x20, 0x20, 0x20, 0x10,
0x00, 0x00, 0x00, 0x60, 0x2F, 0x5B, 0x2F, 0x5B, 0x00, 0x00, 0x00, 0x60,
0x2F, 0x5B, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE5, 0x4F, 0x4E, 0x45,
0x20, 0x20, 0x20, 0x20, 0x54, 0x58, 0x54, 0x20, 0x00, 0x00, 0x00, 0x60,
0x2F, 0x5B, 0x2F, 0x5B, 0x00, 0x00, 0x00, 0x60, 0x2F, 0x5B, 0x03, 0x00,
0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
};2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
同样的数据也在 code/项目/mdrOS/exercises/9_7/dir_sector.h,写程序时可以直接包含。
(1) 6 个目录项各是哪 32 个字节?按偏移 0(8 字节名字)、8(3 字节扩展名)、11(属性)、26(起始簇,2 字节)、28(大小,4 字节)把偏移 0x020 的那一项切开,逐个字段读出数值。
(2) 偏移 0x000 的那一项首字节是 0x41,第 11 字节是 0x0F,第 13 字节是 0x22。这三处各是什么含义,0x22 是拿什么算出来的(算法:对紧跟其后的 11 字节短名,从 0 开始每字节做一次 sum = ((sum & 1) << 7) + (sum >> 1) + c,取低 8 位)?这一项的名字要按什么规则从哪几段字节里拼出来?
(3) 偏移 0x080 的那一项首字节是 0xE5,其余 7 个名字字节是 ONE 。原来的文件名还能不能完整恢复?它的簇号是 3、大小是 7,说明删除一个文件时 FAT 表和目录项各发生了什么。
(4) 偏移 0x040 的那一项在磁盘上是 SYSTEM BIN,用空格补齐到 8 和 3。把它还原成 SYSTEM.BIN 要裁掉哪些字节?属性 0x27 置了哪几位,各表示什么?
code/项目/mdrOS/exercises/9_7/mkfat12.c 用 gcc -std=c17 -Wall -Wextra mkfat12.c -o mkfat12 编译运行后生成 fat12.img,一个 6144 字节的 FAT12 卷:每扇区 512 字节,每簇 1 扇区,保留扇区 1 个,1 份 FAT 占 1 扇区,根目录 16 项占 1 扇区,数据区从第 3 扇区开始。卷里有两个文件,簇链故意不连续:
HELLO.TXT 700 字节,簇链 2 -> 4
SYSTEM.BIN 1200 字节,簇链 5 -> 7 -> 82
簇 3 和簇 6 各有一段看起来像文件开头的内容,但不在任何簇链上。
写一个 fatdump.c,只读 fat12.img,用 gcc -std=c17 -Wall -Wextra 编译:
(1) 从引导扇区解出每扇区字节数、每簇扇区数、保留扇区数、FAT 份数、根目录项数、FAT 扇区数,算出根目录起始扇区与数据区起始扇区,打印。期望 root_dir_sector=2、data_sector=3。
(2) 列出根目录的三个条目。第三项是卷标(属性 0x08),不要当成文件。打印每个条目的名字、属性、起始簇、字节数。
(3) 实现 fat12_next():FAT12 的项占 12 位,偶数项取 fat[i*3/2] 与下一字节的低 4 位,奇数项取 fat[i*3/2] 的高 4 位与下一字节。给定起始簇与文件长度,顺着簇链读满一个缓冲区。
(4) 对两个文件各算一次 FNV-1a(32 位,初值 2166136261,每字节 h = (h ^ b) * 16777619),打印字节数与散列。正确结果是 HELLO.TXT 700 0x18AC0AD9、SYSTEM.BIN 1200 0x43186CF5。
(5) 把 fat12_next 故意改成「簇号加 1」再跑一次,回答:两个文件的散列各变成多少?HELLO.TXT 变成 0x331F1731、SYSTEM.BIN 变成 0x10DC3CF5,为什么后者的偏差更大?
验收:第 (4) 问的两个散列与上面一致;把镜像的簇 3 整簇改写成 0x00,正确实现读出的两个散列都不变。
4.2 编程
把 9.7 的三节连起来:VFS 是统一接口,devfs 与 pipefs 是「把一切做成文件」的两个例子。在主机上写 vfs.c,不要用系统文件接口冒充,自己定义节点与操作表:
struct vfs_node;
struct vfs_ops {
long (*read)(struct vfs_node *n, void *buf, size_t count);
long (*write)(struct vfs_node *n, const void *buf, size_t count);
int (*readdir)(struct vfs_node *n, size_t index, char *name, size_t cap);
};
struct vfs_node {
const char *name;
int is_dir;
const struct vfs_ops *ops;
void *data;
struct vfs_node *children;
struct vfs_node *sibling;
};2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
要求:
(1) 实现 vfs_open(path):在挂载表里找最长的前缀(/dev 比 / 长),把剩余路径按 / 切开逐级查孩子。挂载点要判边界,/devx/motd 不能匹配 /dev。
(2) 实现 ramfs:struct ram_file { char text[32]; size_t len; } 挂在节点的 data 上,read、write 都按 len 截断。在 /etc/motd 放一段文字,读出打印。
(3) 实现 devfs 的两个节点:/dev/zero 的 read 往缓冲区写 0,/dev/console 的 write 直接 fwrite 到标准输出。两者各自不提供另一个方向的函数,调用方要检查函数指针是否为空。
(4) 实现 pipefs 的最小版本:pipe_create() 返回一读一写两个节点,共用 128 字节的环形缓冲区。满时 write 返回已写入的字节数,空时 read 返回 0。
(5) main 依次演示:读 /etc/motd、读 /dev/zero 四字节、往 /dev/console 写一行。然后按下面的顺序操作管道,每一步都打印返回值与当前缓冲区里的字节数:
write 300 字节 -> 128
read 50 字节 -> 50
write 剩下的 172 字节 -> 50
read 100 字节 -> 100
write 剩下的 122 字节 -> 100
read 200 字节 -> 128
write 剩下的 22 字节 -> 22
read 100 字节 -> 22
read 10 字节 -> 02
3
4
5
6
7
8
9
验收:/devx/motd 查到空;上面九步的返回值与注释一致;读出的 300 字节与写入数据逐字节相等,散列均为 0x69A1C951(数据为 data[i] = (uint8_t)(i * 37 + 11))。
4.3 综合与挑战
给上一题的 VFS 加挂载与卸载,并写一个能放进内核的路径解析器。
(1) 挂载表从固定数组改成用 kmalloc 与 kfree 分配的双向链表(主机上用 malloc 代替)。挂载点要能嵌套:挂 /,再挂 /dev,再挂 /dev/shm,vfs_open("/dev/shm/a.txt") 要走到第三层。
(2) 给每个节点加 refs 计数,vfs_open 加一,vfs_close 减一。vfs_unmount(path) 只允许卸载没有打开文件的挂载点,有引用时返回 -EBUSY(自己定义这个常量)。
(3) 路径解析改成迭代版。要求 /a/./b/../c 规范化为 /a/c,.. 在根处停在根。写出你的规范化函数与它处理空段、.、.. 的分支。
(4) 压力测试:建 200 个目录、名字长 10 个字符、深度 8 层,随机打开 10000 次,打印命中次数与耗时。用 -O2 与 -O0 各跑一次,说明差距主要来自哪里。
(5) 与 Linux 对照:/proc/mounts 的内容来自内核的挂载链表,卸载用引用计数拒绝忙的挂载点。说明为什么卸载一个还有打开文件的挂载点会出事,内核里除了引用计数还需要什么才能安全卸载。
验收:嵌套挂载能解析;vfs_unmount("/dev") 在 /dev/zero 还被引用时返回 -EBUSY,释放后成功;/a/./b/../c 规范化成 /a/c;-Wall -Wextra 无警告。