Skip to content

文件系统

文件系统是操作系统管理持久化数据的方式。它将物理存储设备(磁盘)的块组织成文件和目录,提供方便的访问接口。

为什么需要文件系统?

直接操作磁盘的问题

如果让用户直接读写磁盘块:

  1. 记住块号:用户需要知道文件在哪些块
  2. 处理碎片:文件增长时需要重新分配块
  3. 无命名空间:没有目录结构
  4. 无权限控制:任何人可以修改任何数据

文件系统解决了这些问题。

文件系统的抽象层次

┌─────────────────────────────────────────┐
│         应用程序                         │
│    open("/home/user/file.txt")          │
├─────────────────────────────────────────┤
│         虚拟文件系统(VFS)              │
│    统一接口,屏蔽底层差异                │
├───────────────┬─────────────────────────┤
│   ramfs       │   ext2/fat32/...        │
│  内存文件系统  │   磁盘文件系统          │
├───────────────┴─────────────────────────┤
│         块设备层                         │
│    磁盘 / Flash / 虚拟块设备             │
└─────────────────────────────────────────┘

VFS 是关键:它提供统一的接口,让上层不用关心具体的文件系统实现。

VFS:虚拟文件系统的设计

核心概念

VFS 定义了几个核心对象:

对象 含义 生命周期
inode 文件的元数据(索引节点) 与文件同生灭
dentry 路径中的一个组件 缓存,可回收
file 打开的文件实例 打开期间
super_block 挂载的文件系统 挂载期间

inode:文件的元数据

inode 存储文件的元数据:

  • 文件大小
  • 权限(读/写/执行)
  • 所有者(用户/组)
  • 时间戳(创建/修改/访问)
  • 数据块位置

关键点:inode 不包含文件名!文件名在 dentry 中。

为什么分离?因为一个文件可以有多个名字(硬链接),但只有一个 inode。

dentry:目录项缓存

dentry 表示路径中的一个组件,如 /home/user/file.txt 分解为:

dentry: "/"      → inode(根目录)
dentry: "home"   → inode(home 目录)
dentry: "user"   → inode(user 目录)
dentry: "file.txt" → inode(file.txt 文件)

dentry 缓存加速路径解析:

  1. 第一次访问 /home/user,需要从磁盘读取
  2. 后续访问直接从缓存查找,无需磁盘 I/O

file:打开的文件实例

每次打开文件创建一个 file 结构:

  • 指向哪个 dentry
  • 当前读写位置
  • 打开模式(读/写/追加)
  • 引用计数

同一个文件可以打开多次,每次有独立的 file 实例和独立的读写位置。

super_block:文件系统实例

每个挂载的文件系统有一个 super_block:

  • 文件系统类型
  • 块大小
  • 总块数/已用块数
  • 根目录 inode

操作函数表

VFS 通过函数指针实现多态:

inode_operations:
    - lookup:查找目录项
    - create:创建文件
    - mkdir:创建目录
    - unlink:删除文件

file_operations:
    - read:读取
    - write:写入
    - llseek:定位

不同的文件系统提供不同的实现,但接口统一。

路径解析:从字符串到 inode

路径解析的过程

解析 /home/user/file.txt

1. 从根目录 inode 开始
2. 在根目录中查找 "home"
   → 获取 home 的 inode
3. 在 home 目录中查找 "user"
   → 获取 user 的 inode
4. 在 user 目录中查找 "file.txt"
   → 获取 file.txt 的 inode

每一步都是"在目录中查找名字"的操作。

目录是如何存储的?

目录本质上是一个文件,内容是"名字 → inode 号"的映射表:

目录 /home 的内容:
┌──────────┬───────────┐
│ 名字     │ inode 号  │
├──────────┼───────────┤
│ "."      │ 100       │ 自己
│ ".."     │ 50        │ 父目录
│ "user"   │ 101       │ 子目录
│ "guest"  │ 102       │ 子目录
└──────────┴───────────┘

查找 "user" 就是遍历这张表,找到 inode 号 101。

绝对路径与相对路径

  • 绝对路径:从根目录开始,如 /home/user
  • 相对路径:从当前目录开始,如 ./file.txt

相对路径解析需要知道"当前目录"(pwd),每个进程有自己的当前目录。

文件描述符:进程视角的文件

文件描述符表

每个进程有一个文件描述符表:

进程的 fd_table:
┌──────┬────────────────┐
│ fd 0 │ stdin          │ 标准输入
│ fd 1 │ stdout         │ 标准输出
│ fd 2 │ stderr         │ 标准错误
│ fd 3 │ /home/user/f1  │ 用户打开的文件
│ fd 4 │ /tmp/data      │ 用户打开的文件
└──────┴────────────────┘

open 返回一个小的非负整数(fd),后续操作用 fd 而不是文件名。

fork 与文件描述符

fork 创建子进程时,复制父进程的文件描述符表:

父进程:                     子进程:
fd 0 → stdin               fd 0 → stdin (共享)
fd 1 → stdout              fd 1 → stdout (共享)
fd 3 → /home/user/f1       fd 3 → /home/user/f1 (共享)

共享意味着: - 父子进程共享同一个 file 结构 - 一个进程读写会影响另一个进程的位置

引用计数

file 结构有引用计数:

  • open:计数 = 1
  • fork:计数 = 2(父 + 子)
  • close:计数减 1
  • 计数为 0 时才真正释放

缓冲区缓存:加速块访问

为什么需要缓存?

磁盘访问很慢(毫秒级),内存访问很快(纳秒级)。缓存常用块可以避免重复读取磁盘。

缓存的工作方式

读块 100:
    1. 查缓存:块 100 在缓存中?
       - 是:直接返回
       - 否:从磁盘读取,放入缓存

写块 100:
    1. 找到对应的缓存块
    2. 修改内存中的数据
    3. 标记为 dirty
    4. 等待合适时机写回磁盘

延迟写入

不是每次写入都立即写磁盘,而是:

  1. 修改内存中的缓存
  2. 标记 dirty
  3. 定期或缓存满时写回磁盘

优点: - 减少磁盘 I/O - 合并多次写入

缺点: - 崩溃可能丢失数据 - 需要显式同步(fsync

缓存替换

缓存有限,需要淘汰旧块。常用 LRU(最近最少使用)策略:

  • 每次访问,把块移到"最近使用"列表头部
  • 淘汰时,从"最久未用"列表尾部取出

ramfs:内存文件系统

为什么需要 ramfs?

ramfs 是最简单的文件系统:所有数据存在内存中,不写入磁盘。

用途: - 临时文件 - 系统启动时的根文件系统 - 测试和调试

ramfs 的实现

inode 数据:直接用 kmalloc 分配,存在内核内存。

目录内容:用链表存储 dentry,查找时遍历链表。

数据存储:用 kmalloc 分配缓冲区,文件大小就是缓冲区大小。

简单但有限制

ramfs 的限制:

  1. 无持久化:重启后数据丢失
  2. 占用内存:文件越多,占用越多
  3. 无大小限制:可能耗尽内存

文件系统的挂载

挂载的概念

挂载是将一个文件系统关联到一个目录:

挂载前:
/ (ramfs)

挂载后:
/ (ramfs)
└── mnt
    └── (disk filesystem)

访问 /mnt/file.txt 实际访问的是磁盘文件系统。

根文件系统

内核启动后第一个挂载的文件系统叫根文件系统,是所有路径的起点。

NoobKernel 当前挂载 ramfs 作为根文件系统。

设计权衡

为什么用 VFS 而不是直接实现一个文件系统?

VFS 的好处:

  1. 扩展性:可以添加新文件系统
  2. 统一性:所有文件系统接口一致
  3. 代码复用:缓存、路径解析等可共享

代价是实现更复杂,但对于真实操作系统是必要的。

为什么选择 ramfs 而不是 ext2?

ramfs 更简单: - 不需要磁盘布局 - 不需要块分配算法 - 不需要处理磁盘故障

ext2 的实现需要处理: - 块位图 - inode 位图 - 间接块 - 目录项格式

ramfs 适合作为理解 VFS 框架的第一步。

思考题

  1. 为什么 inode 不包含文件名?硬链接是如何实现的?
  2. 如果 dentry 缓存满了,应该淘汰哪些 dentry?
  3. 进程打开文件后,文件被删除,进程还能访问吗?
  4. 延迟写入导致崩溃丢失数据,如何权衡性能和可靠性?

下一步