第20讲 文件系统II

从文件名到虚拟文件系统:操作系统如何访问、缓存与扩展文件

引言

文件系统的第一层任务,是把存储设备上的数据块组织成文件和目录。但文件被组织出来以后,操作系统仍然需要回答一系列更接近实际运行的问题:

  • 一个文件能否拥有多个名字?
  • 进程如何引用已经打开的文件?
  • write()返回以后,数据是否已经写入硬盘?
  • 文件为什么可以像内存一样被访问?
  • 复制大型文件为什么有时能够瞬间完成?
  • Linux如何用同一组接口访问Ext4、FAT、NTFS和网络文件系统?
  • 文件系统是否一定要运行在内核中?

这些问题共同构成了文件系统的运行时机制。

从应用程序的角度看,文件操作通常只是:

1
2
3
4
5
open();
read();
write();
mmap();
close();

但在这些简单接口背后,存在多个不同层次的对象:

1
2
3
4
5
6
7
8
9
10
11
文件名

目录项

inode

打开文件对象

文件描述符

页缓存与数据块

理解这些对象之间的关系,是理解链接、文件描述符、页缓存、mmap、文件克隆、VFS和FUSE的基础。


一、文件名并不是文件本身

在基于inode的文件系统中,目录中保存的核心关系通常是:

1
文件名 → inode编号

inode才负责保存文件的核心信息,例如:

  • 文件类型
  • 文件大小
  • 访问权限
  • 所有者
  • 时间信息
  • 数据块位置
  • 硬链接数量

因此,文件名只是用户进入文件的一个入口,而不是文件实体本身。

更完整的关系是:

一个inode可以被多个目录项引用,因此同一个文件可以拥有多个名字。这正是硬链接能够存在的基础。


1. 硬链接:同一个文件的多个名字

假设执行:

1
ln test.txt test2.txt

文件系统中可能形成如下关系:

1
2
test.txt  ──→ inode 100 ──→ 文件数据
test2.txt ──→ inode 100 ──→ 文件数据

test.txttest2.txt指向同一个inode,因此它们不是两个内容相同的文件,而是同一个文件的两个名称。

这意味着:

  • 二者具有相同的inode编号。
  • 通过任意名称修改内容,另一个名称也能看到修改。
  • 两个名称地位相同。
  • 不存在必须保留的“原文件名”。

如果执行:

1
rm test.txt

文件系统通常只会删除名为test.txt的目录项,并将inode中的硬链接计数减一。

只要test2.txt仍然存在,inode和文件数据就不能被回收。

因此,rm的直接含义通常不是“立即销毁文件数据”,而是:

删除一个文件名到inode的引用关系。

只有当硬链接计数降为零,并且没有进程继续持有该文件的打开引用时,相关inode和数据块才可以真正被释放。


2. 符号链接:保存路径的独立文件

符号链接的机制与硬链接不同。

执行:

1
ln -s test.txt test3.txt

会创建一个新的特殊文件。这个文件拥有自己的inode,其内容主要是一段路径:

1
test.txt

访问test3.txt时,系统会:

  1. 查找到test3.txt对应的inode。
  2. 发现它是符号链接。
  3. 读取其中保存的目标路径。
  4. 按照该路径重新进行文件查找。

符号链接保存的是路径,而不是目标文件的inode。因此,如果目标文件被删除、移动或重命名,符号链接可能失效,成为悬空链接。


3. 硬链接与符号链接的区别

对比点 硬链接 符号链接
本质 新增一个指向同一inode的目录项 新建一个保存目标路径的特殊文件
inode 与目标文件相同 拥有独立inode
文件身份 本来就是同一个文件 是两个不同文件
删除目标名称 其他硬链接仍然有效 可能变成悬空链接
是否依赖路径
跨文件系统 通常不可以 可以
链接目录 通常受限制 可以

硬链接不能轻易跨文件系统,是因为inode编号只在所属文件系统内部具有意义。另一个文件系统拥有独立的inode空间,无法直接引用当前文件系统中的inode。

符号链接保存的是普通路径字符串,只要路径解析最终能够到达目标,就可以跨越不同挂载的文件系统。


4. 为什么FAT难以支持硬链接

FAT没有独立于目录项存在的inode结构。目录项通常直接保存:

  • 文件名
  • 文件大小
  • 起始簇号
  • 时间等元数据

如果简单创建两个目录项,并让它们指向同一个起始簇:

1
2
FILE1.DAT → 簇2
FILE2.DAT → 簇2

会立即出现一致性问题:

  • 两个目录项都保存文件大小,哪一份是权威数据?
  • 修改文件时间时应该更新哪个目录项?
  • 删除FILE1.DAT时,是否应该释放簇链?
  • 如果释放簇链,FILE2.DAT将指向已被回收的数据。
  • 文件系统如何知道还有多少目录项引用这条簇链?

要支持可靠的硬链接,就需要把共享元数据从目录项中抽离出来,并增加引用计数:

1
2
3
目录项A ─┐
├→ 共享文件对象 → 数据簇
目录项B ─┘

这实际上已经接近inode的设计。


本节核心结论: 文件名只是目录中的引用。硬链接让多个名称引用同一个inode;符号链接则是一个保存目标路径的独立文件。


二、文件描述符:进程如何引用文件

应用程序打开文件时:

1
int fd = open("test.txt", O_RDWR);

open()返回的fd通常是一个较小的整数,例如:

1
3

这个数字不是inode编号,也不是磁盘地址。它是当前进程文件描述符表中的一个索引。

完整关系可以表示为:


1. 为什么不能让fd直接指向inode

inode回答的是:

这是哪个文件?

但一个进程打开文件以后,还需要保存与“本次打开”有关的运行时状态,例如:

  • 当前读写偏移量
  • 打开模式
  • 是否追加写入
  • 状态标志
  • 引用计数

这些信息不属于文件本身,而属于某一次打开操作。

因此,内核需要一个位于文件描述符和inode之间的对象,即打开文件对象。

可以将几个对象的职责区分为:

对象 代表的含义
文件名 用户访问文件时使用的名字
inode 文件的身份和核心元数据
打开文件对象 某一次打开操作的状态
文件描述符 进程引用打开文件对象的整数

2. 两次open为什么可以有独立偏移量

假设执行:

1
2
int fd1 = open("test.txt", O_RDONLY);
int fd2 = open("test.txt", O_RDONLY);

两次调用最终可能访问同一个inode,但通常会建立两个不同的打开文件对象:

1
2
3
fd1 → 打开文件对象1 ─┐
├→ 同一个inode
fd2 → 打开文件对象2 ─┘

因此,fd1fd2可以拥有不同的文件偏移量。

如果:

1
read(fd1, buf, 10);

fd1对应的偏移量向后移动10字节,而fd2仍然可以从文件开头读取。

这说明:

是否共享文件偏移量,取决于是否共享打开文件对象,而不是是否指向同一个inode。


3. fork以后为什么会共享偏移量

假设在fork()之前打开文件:

1
2
int fd = open("test.txt", O_RDONLY);
fork();

fork()会复制父进程的文件描述符表,但复制后的表项仍然指向同一个打开文件对象:

1
2
3
父进程fd ─┐
├→ 同一个打开文件对象 → inode
子进程fd ─┘

打开文件对象中保存着当前文件偏移量,因此父子进程会共享该偏移量。

假设文件内容为:

1
abcdefghijklmnopqrstuvwxyz

父子进程各执行一次:

1
read(fd, buf, 10);

如果父进程先运行,它可能读到:

1
abcdefghij

子进程随后从偏移量10继续读取:

1
klmnopqrst

如果子进程先运行,则两段输出的归属可能反过来。

这里的共享与父子进程是否共享虚拟地址空间无关。fork()后的父子进程通常拥有独立地址空间,但它们的文件描述符仍可引用同一个内核打开文件对象。


4. 常见文件操作接口

接口 作用
open 根据路径打开文件
openat 以目录文件描述符为基准打开文件
read 从当前偏移量读取数据
write 从当前偏移量写入数据
lseek 调整当前文件偏移量
stat 根据路径获取目标文件属性
lstat 获取符号链接本身的属性
fstat 根据文件描述符获取文件属性
close 释放文件描述符对打开文件对象的引用

close(fd)只是关闭当前进程中的一次打开引用,不会删除磁盘上的文件。

即使文件名已经被删除,只要某个进程仍持有打开文件对象,文件数据仍可能继续存在并可被该进程读写。直到最后一个引用被释放,文件系统才能回收数据。


本节核心结论: fd是进程文件描述符表的索引;inode表示文件,打开文件对象表示一次打开操作。偏移量是否共享,取决于打开文件对象是否共享。


三、页缓存:文件访问为什么不必每次读取磁盘

存储设备的速度远低于内存。如果每次read()都直接访问硬盘或SSD,文件访问性能会非常差。

因此,操作系统使用内存中的页缓存保存最近访问过的文件数据。

页缓存使重复读取能够直接从内存完成,也使多个进程访问同一文件时可以共享缓存中的文件页面。


1. 干净页与脏页

页缓存中的页面可以分为干净页和脏页。

干净页

干净页中的数据与持久化设备中的数据一致:

1
页缓存内容 = 存储设备内容

这种页面在内存紧张时可以直接丢弃,因为以后仍然可以从磁盘重新读取。

脏页

应用程序修改了页缓存,但修改还没有写回设备:

1
页缓存内容 ≠ 存储设备内容

此时页面被标记为脏页。

例如:

1
write(fd, "hello", 5);

很多情况下,内核先把数据写入页缓存,然后将页面标记为脏页。真正的设备写入可能由后台回写机制稍后完成。


2. write成功意味着什么

write()返回成功通常意味着:

内核已经接受了数据。

它不一定意味着:

数据已经稳定地写入持久化介质。

如果数据仍然停留在脏页中,此时机器突然断电,修改可能丢失。

因此,系统需要区分两个时刻:

1
2
3
4
5
6
7
数据进入内核页缓存

write返回

数据写入存储设备

设备确认持久化

这几个步骤不一定同步发生。


3. fsync的作用

当应用程序需要更强的持久性保证时,可以调用:

1
fsync(fd);

它要求操作系统将该文件相关的脏数据以及必要的元数据同步到持久化设备。

典型过程是:

数据库、日志系统和关键配置程序不能只看到write()成功就认为数据绝对安全,而需要设计合适的同步策略。

不过,频繁调用fsync()会增加设备写入次数并降低性能。因此,持久性与性能之间通常需要权衡。


4. 页缓存的双重意义

页缓存不仅用于读取,也用于写入。

  • 对读取而言,它减少重复磁盘访问。
  • 对写入而言,它允许系统合并、延迟和批量执行I/O。

延迟写回可以:

  • 合并多个小写入。
  • 对写入顺序进行优化。
  • 减少设备操作次数。
  • 让应用程序更快返回。

代价是系统中会暂时存在尚未持久化的脏数据。


本节核心结论: write()成功通常只表示数据已进入内核;页缓存通过脏页延迟写回提高性能,fsync()用于在需要时要求数据持久化。


四、mmap:把文件接入虚拟内存

普通文件接口要求程序显式调用read()write()mmap()提供了另一种方式:将文件的一部分映射到进程虚拟地址空间。

1
2
3
4
5
6
7
8
void *addr = mmap(
NULL,
length,
PROT_READ | PROT_WRITE,
MAP_SHARED,
fd,
0
);

映射建立以后,程序可以像访问普通内存一样访问文件:

1
2
char x = ((char *)addr)[100];
((char *)addr)[200] = 'A';

1. mmap并不会立即读取整个文件

调用mmap()时,内核通常不会马上把整个文件装入物理内存。

它首先在进程虚拟地址空间中建立一个虚拟内存区域,并记录:

  • 起始虚拟地址
  • 映射长度
  • 访问权限
  • 对应文件
  • 文件起始偏移量
  • 映射方式

此时,部分虚拟页可能尚未建立有效页表映射。

可以抽象为:

1
2
3
4
5
6
虚拟内存区域
├── 起始地址
├── 结束地址
├── 读写权限
├── 对应文件
└── 文件偏移

2. 第一次访问时发生什么

假设程序第一次访问映射区域中的某个地址:

1
char x = ((char *)addr)[5000];

如果相应页面尚未加载,CPU会触发缺页异常。

内核随后执行:

  1. 根据异常地址查找对应虚拟内存区域。
  2. 检查此次访问是否合法。
  3. 判断该区域对应哪个文件。
  4. 计算对应的文件页和文件偏移。
  5. 检查页缓存中是否已经存在该页面。
  6. 必要时从存储设备读取数据。
  7. 建立虚拟页到物理页的页表映射。
  8. 重新执行原来的内存访问指令。

因此,mmap()把文件系统与虚拟内存连接在一起:文件偏移最终通过缺页异常转化为物理内存页面。


3. MAP_SHARED与文件修改

如果使用:

1
MAP_SHARED

对映射页面的修改会反映到共享的文件页面中,并最终写回文件。

例如:

1
((char *)addr)[0] = 'A';

可能使对应页缓存页面变脏。之后,内核可以自动回写,也可以由程序调用:

1
msync(addr, length, MS_SYNC);

请求同步。

munmap()只会解除虚拟地址映射:

1
munmap(addr, length);

它不等于删除文件,也不意味着文件数据一定被清除。


4. mmap的优势

随机访问更自然

普通接口可能需要:

1
2
lseek(fd, offset, SEEK_SET);
read(fd, buf, size);

使用映射后可以直接:

1
value = addr[offset];

减少频繁系统调用

建立映射后,每次读取一个小范围数据不必都显式调用read()

减少一次用户态复制

普通读取常涉及:

1
页缓存 → 用户缓冲区

mmap()可以让页缓存中的文件页面直接映射进进程虚拟地址空间,从而减少这一步复制。

但这不代表数据完全不需要移动。文件数据仍然必须从存储设备进入物理内存。

支持按需加载

只访问文件的一部分时,系统只需要加载涉及的页面,而不必读取完整文件。


5. mmap不一定总是更快

mmap()并非read()write()的绝对替代品。

它会引入:

  • 缺页异常处理
  • 页表建立与维护
  • 虚拟内存区域管理
  • 映射异常处理
  • 地址空间占用

对于简单的顺序流式读取,普通read()配合预读和较大缓冲区可能更加直接。

对比点 read/write mmap
使用方式 显式系统调用 通过内存地址访问
文件位置 文件偏移量 虚拟地址偏移
页面加载 read触发 缺页异常触发
数据复制 通常复制到用户缓冲区 可直接映射文件页面
错误表现 系统调用返回错误 访问期间可能产生异常
适合场景 顺序I/O、简单流式操作 随机访问、大文件、共享页面

本节核心结论: mmap()不是立即加载整个文件,而是建立文件到虚拟地址空间的映射;实际页面通常在首次访问时按需载入。


五、文件克隆与写时拷贝

普通文件复制必须把源文件内容完整读取并写入目标文件:

1
源文件 → 页缓存 → 用户缓冲区 → 页缓存 → 目标文件

因此复制时间大致与文件大小成正比。

即使使用mmap()配合memcpy(),虽然可以减少系统调用和显式缓冲区管理,但文件数据仍然需要真正复制。

现代文件系统可以提供文件克隆,也称reflink。克隆时,系统创建一个新的文件对象,但暂时让两个文件共享相同的数据块。

1
2
3
文件A inode ─┐
├→ 共享数据块
文件B inode ─┘

刚完成克隆时:

  • A和B拥有不同inode。
  • A和B是两个独立文件。
  • 两者暂时引用相同数据块。
  • 文件内容不需要立即完整复制。

因此,大型文件的初始克隆可以几乎瞬间完成。


1. 写时拷贝

如果文件B要修改共享数据块,不能直接原地覆盖,否则文件A也会被改变。

文件系统会在写入发生时执行复制:

1
2
3
4
5
修改前:

A ─┐
├→ 数据块X
B ─┘

B修改数据块X时:

  1. 分配新数据块Y。
  2. 把X的旧内容复制到Y。
  3. 修改Y。
  4. 让B指向Y。
  5. A继续指向X。
1
2
3
4
修改后:

A → 数据块X
B → 数据块Y

这种机制称为Copy-on-Write,即写时拷贝。

其核心思想是:

只在真正发生写入时复制被修改的部分,而不是提前复制整个文件。


2. 文件克隆与硬链接

文件克隆和硬链接都可能共享底层数据,但二者的文件身份完全不同。

对比点 硬链接 文件克隆
inode 相同 不同
文件数量 一个文件,多个名字 两个独立文件
修改内容 所有名称看到相同变化 修改时写时拷贝并分离
删除一个名称 只减少硬链接计数 删除一个独立文件
共享原因 本来就是同一个inode 文件系统主动共享数据块

一句话概括:

硬链接是同一个文件拥有多个名字;文件克隆是多个文件暂时共享相同数据。


六、快照:保存某一时刻的文件系统状态

快照用于记录文件系统或子卷在某个时刻的状态。

假设:

1
2
3
t0:创建快照
t1:继续修改文件
t2:希望恢复到t0状态

最直接的方法是完整复制全部数据,但代价很高。

现代快照通常利用写时拷贝。创建快照时,系统不立即复制所有数据,而是让快照与当前文件系统共享原有数据块和元数据节点。

1
2
3
快照状态 ─┐
├→ 共享旧数据
当前状态 ─┘

之后,如果当前文件系统要修改某个共享对象,就复制该对象并在新副本上修改。快照继续引用旧版本。


1. 基于inode的快照思路

一种简化方案是:

  1. 保存一份inode表或关键元数据。
  2. 快照与当前文件系统共享现有数据块。
  3. 修改数据时执行写时拷贝。

这样,快照创建速度主要取决于元数据处理,而不是整个文件系统的数据量。


2. 树形文件系统中的快照

如果文件系统使用树结构管理元数据,可以复制一个新的树根,并让新旧树根共享绝大多数子节点。

修改某个节点时,系统只需要复制从树根到被修改节点相关的路径,而不用复制整棵树。

这使快照具备两个特点:

  • 创建速度快。
  • 初始空间开销小。

随着后续修改增加,快照与当前文件系统之间共享的数据逐渐减少,额外空间占用才逐渐上升。


3. 克隆与快照

对比点 文件克隆 快照
作用范围 通常是单个文件 文件系统、子卷或目录树
目标 创建独立文件副本 保存某一时刻的整体状态
共享对象 文件数据块 元数据树和数据块
分离方式 写时拷贝 写时拷贝
典型用途 快速复制大文件 备份、回滚、系统恢复

本节核心结论: 文件克隆和快照都通过共享旧数据并在写入时复制变化部分,避免创建时进行完整数据复制。


七、VFS:统一不同文件系统

一台Linux机器可能同时使用:

  • Ext4
  • FAT32
  • exFAT
  • NTFS
  • 网络文件系统
  • FUSE文件系统

这些文件系统在磁盘格式、目录结构和数据定位方式上可能完全不同,但应用程序仍然使用相同接口:

1
2
3
4
5
open();
read();
write();
mkdir();
unlink();

实现这种统一性的核心是VFS,即Virtual File System,虚拟文件系统。


1. 挂载:把多个文件系统接入一棵目录树

Linux不使用独立盘符区分文件系统,而是把所有文件系统接入统一目录树。

例如:

1
2
3
4
5
6
/
├── home
├── var
└── mnt
├── usb
└── windows

其中:

  • 根文件系统可能是Ext4。
  • /mnt/usb可能挂载FAT32。
  • /mnt/windows可能挂载NTFS。

把一个文件系统的根目录连接到现有目录树中的某个目录,称为挂载。

挂载以后,应用程序只看到一棵连续目录树,不需要知道路径跨越了几个不同文件系统。


2. VFS是什么

VFS是Linux内核中的文件系统抽象层。

它定义一组统一对象和操作接口,具体文件系统负责实现这些接口。

可以类比为:

1
2
VFS:抽象接口
Ext4、FAT、NTFS、FUSE:具体实现

应用程序调用read()时,并不直接调用ext4_read()fat_read()。VFS根据文件所属的具体文件系统,选择相应实现。


3. VFS中的核心对象

superblock

代表一个已经挂载的文件系统实例。

它通常保存:

  • 文件系统类型
  • 块大小
  • 根目录
  • 状态信息
  • 文件系统操作函数

它回答的是:

当前正在管理的是哪个文件系统实例?

inode

代表一个具体文件或目录的身份和核心元数据。

它回答的是:

这是哪个文件?

dentry

代表路径中的一个名称及其与inode的对应关系。

例如路径:

1
/home/aster/test.txt

在路径解析过程中会涉及:

1
home → aster → test.txt

这些名称关系由dentry表示,并可被dentry缓存加速。

file

代表一次具体打开操作,通常保存:

  • 当前文件偏移量
  • 打开标志
  • 文件操作函数
  • 指向路径或inode的引用

它回答的是:

当前进程怎样使用这个文件?

mount

表示文件系统之间的挂载关系,例如:

  • 当前文件系统挂载到哪个目录项。
  • 父挂载与子挂载是什么关系。
  • 路径解析何时跨越文件系统边界。

4. 函数表与多态分发

VFS为不同对象定义统一操作,例如inode操作可能包含:

1
2
3
4
5
6
7
8
struct inode_operations {
int (*create)(...);
int (*link)(...);
int (*unlink)(...);
int (*mkdir)(...);
int (*rmdir)(...);
int (*rename)(...);
};

Ext4可以提供自己的实现:

1
2
3
4
5
6
7
const struct inode_operations ext4_dir_inode_operations = {
.create = ext4_create,
.link = ext4_link,
.unlink = ext4_unlink,
.mkdir = ext4_mkdir,
.rmdir = ext4_rmdir,
};

当VFS需要在Ext4目录中创建文件时,它调用统一的create操作,但函数指针最终指向ext4_create

如果目标目录属于其他文件系统,就调用该文件系统提供的实现。

这是一种典型的运行时多态:

1
2
3
统一接口

根据具体文件系统选择实现

5. 一次read的大致过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
应用调用read(fd, buf, size)

进入系统调用层

根据fd找到打开文件对象

通过file对象找到对应操作函数

VFS调用具体文件系统实现

检查页缓存

必要时读取块设备

把数据返回应用程序

如果目标文件属于FUSE文件系统,后续请求还会被转发给用户态文件系统程序。


6. 磁盘结构与内存结构不是同一件事

文件系统在磁盘上保存的是持久化格式,例如:

  • 超级块
  • inode表
  • 目录数据
  • 分配位图
  • 数据块

但Linux运行时还会建立大量内存结构:

  • inode缓存
  • dentry缓存
  • 页缓存
  • 挂载对象
  • 哈希表
  • 树结构
  • 块分配器状态

磁盘结构适合长期保存,内存结构适合快速查找和动态管理。

因此不能认为磁盘上的inode与内存中的inode对象完全等价。内核中的对象通常还包含锁、引用计数、缓存状态和函数指针等运行时信息。


本节核心结论: VFS不是一种磁盘格式,而是内核中的统一抽象层。它通过统一对象和函数表,让不同文件系统能够使用相同系统调用接口。


八、FUSE:把文件系统逻辑移到用户态

传统文件系统通常运行在内核中。这样可以减少上下文切换并获得较高性能,但内核开发存在明显风险:

  • 调试困难。
  • 错误可能导致整个系统崩溃。
  • 很难使用普通用户态库。
  • 开发和实验门槛较高。

FUSE,即Filesystem in Userspace,允许开发者在用户态实现文件系统逻辑。


1. FUSE的结构

FUSE通常包括:

1
2
3
4
5
6
7
8
用户态:
应用程序
FUSE文件系统进程
FUSE库

内核态:
VFS
FUSE内核驱动

用户态文件系统程序实现诸如:

  • getattr
  • open
  • read
  • write
  • mkdir
  • unlink

等操作。


2. 一次FUSE读取请求

与内核文件系统相比,FUSE请求需要额外经历内核态与用户态之间的切换,有时还会产生额外数据复制。


3. FUSE的优缺点

优点 代价
开发方便 请求路径更长
容易调试 用户态与内核态切换更多
错误通常不会直接导致内核崩溃 可能增加数据复制
可以使用普通用户态库 高并发性能需要额外优化
适合快速原型 通常低于纯内核实现性能

FUSE体现的是一种系统设计权衡:

用一部分性能换取开发效率、隔离性和灵活性。


4. 文件系统接口不一定对应本地磁盘

FUSE使“文件和目录”可以成为其他资源的统一接口。

例如:

  • SSHFS把远程服务器目录表现为本地目录。
  • 网盘客户端把云端对象表现为文件。
  • 数据库内容可以表现为目录和文件。
  • 动态生成的信息也可以通过文件接口访问。

这说明文件系统不只是一种磁盘数据格式,也是一种通用命名与访问接口。

用户程序可以继续使用熟悉的:

1
2
3
open();
read();
write();

而不必理解底层资源究竟来自本地磁盘、网络服务还是数据库。


本节核心结论: FUSE并没有绕过内核,而是让VFS把具体文件系统请求转发给用户态程序,以性能成本换取可开发性和隔离性。


九、现代文件系统的扩展能力

现代文件系统除了保存和读取文件,还可能提供多种高级功能。

1. 加密

文件系统可以对数据进行加密,使存储设备上的内容以密文形式存在。没有正确密钥,即使直接读取磁盘数据,也无法恢复原始内容。

2. 压缩

文件写入前可以先压缩,从而:

  • 减少存储空间占用。
  • 减少实际I/O数据量。

代价是增加CPU计算,并可能使随机修改更加复杂。

3. 去重

文件系统可以检测相同数据块,并只保存一份内容:

1
2
3
文件A ─┐
├→ 相同数据块
文件B ─┘

去重与文件克隆都可能共享数据块,但去重通常是系统检测内容相同后自动合并,而克隆是显式创建共享副本。

4. 校验

文件系统可以为数据或元数据保存校验信息,用于检测:

  • 介质损坏
  • 位翻转
  • 错误写入
  • 元数据破坏

校验可以发现错误,但是否能够恢复,还取决于是否存在冗余副本。

5. 配额

配额限制用户、用户组或子系统能够使用的存储资源,例如:

  • 最大空间
  • 最大文件数
  • 最大inode数量

6. 多设备与软件RAID

文件系统或存储层可以把多个设备组合起来,实现:

  • 容量扩展
  • 并行读写
  • 数据冗余
  • 故障容忍

7. 子卷

子卷是在同一文件系统内部建立的逻辑独立区域。它们可以拥有独立的快照、配额和管理策略,但不一定需要单独分区。

8. 事务与日志

文件系统操作通常会同时修改多个结构,例如创建文件可能涉及:

  • inode分配
  • 目录项插入
  • 数据块分配
  • 位图更新

如果操作执行到一半突然崩溃,文件系统可能进入不一致状态。

事务或日志机制试图保证:

1
2
一组修改要么全部完成
要么恢复到修改前状态

这并不必然保证用户数据完全不丢失,但可以保护文件系统结构不被部分更新破坏。


十、从规约到文件系统代码

文件系统属于复杂系统软件。它涉及:

  • 数据结构
  • 并发控制
  • 锁顺序
  • 错误恢复
  • 模块依赖
  • 持久化一致性

直接用模糊自然语言要求大模型“生成一个正确文件系统”,风险很高。

大模型可能:

  • 忘记加锁。
  • 在错误路径上漏掉解锁。
  • 调用不存在的函数。
  • 破坏数据结构不变量。
  • 忽略模块之间的依赖。
  • 生成可以编译但无法正确运行的代码。

因此,更可靠的思路是先提供精确规约,再让模型生成或补全实现。


1. 功能规约

功能规约可以描述:

前置条件

函数调用前必须成立的条件。

例如:

1
2
3
目标目录已经加锁
文件名符合长度限制
输入路径合法

后置条件

函数结束后应当成立的条件。

例如:

1
2
3
4
5
6
7
8
9
成功时:
- 创建新inode
- 插入目录项
- 返回0

失败时:
- 不留下半初始化对象
- 不修改原目录状态
- 返回错误码

不变量

系统运行过程中始终必须保持的性质。

例如:

1
2
3
根inode始终存在
同一数据块不能被错误分配给两个独立文件
目录项不能指向已经释放的inode

2. 并发规约

文件系统代码大量涉及共享状态,因此只描述输入输出还不够,还必须描述锁状态:

  • 调用前持有哪些锁。
  • 函数内部允许获取哪些锁。
  • 返回时仍持有哪些锁。
  • 错误路径是否释放所有锁。
  • 可以与哪些操作并发。
  • 锁的获取顺序是什么。

例如:

1
2
3
4
5
6
7
8
9
调用locate前:
持有cur的锁

成功返回target时:
释放cur
持有target

失败返回NULL时:
不持有任何锁

这种规约比“注意线程安全”精确得多。


3. 模块化规约

大型文件系统不能由一个模块独立完成。不同模块之间需要明确约定。

Rely

当前模块依赖其他模块保证什么。

例如:

  • 块分配器不会重复分配正在使用的块。
  • 路径解析模块返回的inode已增加引用计数。
  • 缓存模块保证页面锁定期间不会被回收。

Guarantee

当前模块向其他模块保证什么。

例如:

  • 返回成功时目录项一定已经可见。
  • 返回失败时不修改共享状态。
  • 所有错误路径都会释放锁。
  • inode引用计数变化符合接口约定。

这种方式可以降低模块耦合,使不同开发者或智能体只需要理解有限范围。


4. 多智能体生成

复杂文件系统可以按模块拆分:

1
2
3
4
路径解析规约 → Agent A → 路径模块
inode规约 → Agent B → inode模块
块分配规约 → Agent C → 分配模块
缓存规约 → Agent D → 缓存模块

之后再通过:

  • 编译检查
  • 接口检查
  • 单元测试
  • 并发测试
  • 崩溃测试
  • 规约一致性检查

发现并修复模块之间的问题。

多智能体的价值不只是“同时生成更多代码”,而是利用清晰接口把系统分解成可以分别验证的部分。


5. 规约不能替代验证

规约可以减少歧义,但不能自动保证实现正确。

生成后的文件系统仍需要:

  • 单元测试
  • 集成测试
  • 并发压力测试
  • 崩溃恢复测试
  • 与参考实现进行行为对比
  • 人工代码审查
  • 必要时进行形式化验证

正确关系是:

1
2
3
4
5
6
7
规约

提高生成质量

测试与验证

确认实现是否满足要求

而不是:

1
2
3
写出规约

代码自动绝对正确

本节核心结论: 大模型可以辅助生成文件系统代码,但必须以精确规约、模块接口和验证体系约束生成过程。


十一、核心概念的统一关系

文件系统运行时涉及多个容易混淆的对象。

对象 所在层次 作用
文件名 目录结构 用户访问文件的名称
dentry 内核内存 表示名称到inode的路径关系
inode 文件系统与内核 表示文件身份和核心元数据
打开文件对象 内核内存 保存一次打开操作的偏移量和状态
文件描述符 进程 引用打开文件对象的整数
页缓存 内核内存 缓存文件内容
数据块 存储设备 持久保存文件内容

完整访问链条可以概括为:

其中:

  • 路径名解决“怎样找到文件”。
  • inode解决“这是哪个文件”。
  • 打开文件对象解决“这一次怎样使用文件”。
  • fd解决“进程用什么编号引用它”。
  • 页缓存解决“怎样减少存储I/O”。
  • 数据块负责最终持久化。

结论

文件系统的运行机制远不只是把数据写进磁盘。

文件被创建以后,操作系统还需要建立一套完整的运行时抽象:

  • 目录项与inode分离文件名和文件身份。
  • 硬链接允许一个inode拥有多个名字。
  • 符号链接通过路径间接引用其他文件。
  • 文件描述符让进程用简单整数引用内核打开文件对象。
  • 打开文件对象保存偏移量和打开状态。
  • 页缓存通过延迟写回提高I/O性能。
  • fsync()在需要时强化持久性保证。
  • mmap()把文件页面接入虚拟地址空间。
  • 写时拷贝让文件克隆和快照快速创建。
  • VFS统一不同文件系统的接口。
  • FUSE允许文件系统逻辑运行在用户态。
  • 规约与验证为自动生成复杂文件系统代码提供约束。

这些机制共同说明,文件系统不是单纯的磁盘数据格式,而是一套贯穿路径解析、进程接口、虚拟内存、缓存管理、持久化和系统扩展的综合抽象。

文件系统真正提供的,不只是存储空间,而是一套让进程能够命名、访问、共享、缓存、映射和持久化数据的统一运行环境。