文件系统¶
文件系统是操作系统管理持久化数据的方式。它将物理存储设备(磁盘)的块组织成文件和目录,提供方便的访问接口。
为什么需要文件系统?¶
直接操作磁盘的问题¶
如果让用户直接读写磁盘块:
- 记住块号:用户需要知道文件在哪些块
- 处理碎片:文件增长时需要重新分配块
- 无命名空间:没有目录结构
- 无权限控制:任何人可以修改任何数据
文件系统解决了这些问题。
文件系统的抽象层次¶
┌─────────────────────────────────────────┐
│ 应用程序 │
│ 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 缓存加速路径解析:
- 第一次访问
/home/user,需要从磁盘读取 - 后续访问直接从缓存查找,无需磁盘 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:计数 = 1fork:计数 = 2(父 + 子)close:计数减 1- 计数为 0 时才真正释放
缓冲区缓存:加速块访问¶
为什么需要缓存?¶
磁盘访问很慢(毫秒级),内存访问很快(纳秒级)。缓存常用块可以避免重复读取磁盘。
缓存的工作方式¶
读块 100:
1. 查缓存:块 100 在缓存中?
- 是:直接返回
- 否:从磁盘读取,放入缓存
写块 100:
1. 找到对应的缓存块
2. 修改内存中的数据
3. 标记为 dirty
4. 等待合适时机写回磁盘
延迟写入¶
不是每次写入都立即写磁盘,而是:
- 修改内存中的缓存
- 标记 dirty
- 定期或缓存满时写回磁盘
优点: - 减少磁盘 I/O - 合并多次写入
缺点:
- 崩溃可能丢失数据
- 需要显式同步(fsync)
缓存替换¶
缓存有限,需要淘汰旧块。常用 LRU(最近最少使用)策略:
- 每次访问,把块移到"最近使用"列表头部
- 淘汰时,从"最久未用"列表尾部取出
ramfs:内存文件系统¶
为什么需要 ramfs?¶
ramfs 是最简单的文件系统:所有数据存在内存中,不写入磁盘。
用途: - 临时文件 - 系统启动时的根文件系统 - 测试和调试
ramfs 的实现¶
inode 数据:直接用 kmalloc 分配,存在内核内存。
目录内容:用链表存储 dentry,查找时遍历链表。
数据存储:用 kmalloc 分配缓冲区,文件大小就是缓冲区大小。
简单但有限制¶
ramfs 的限制:
- 无持久化:重启后数据丢失
- 占用内存:文件越多,占用越多
- 无大小限制:可能耗尽内存
文件系统的挂载¶
挂载的概念¶
挂载是将一个文件系统关联到一个目录:
挂载前:
/ (ramfs)
挂载后:
/ (ramfs)
└── mnt
└── (disk filesystem)
访问 /mnt/file.txt 实际访问的是磁盘文件系统。
根文件系统¶
内核启动后第一个挂载的文件系统叫根文件系统,是所有路径的起点。
NoobKernel 当前挂载 ramfs 作为根文件系统。
设计权衡¶
为什么用 VFS 而不是直接实现一个文件系统?¶
VFS 的好处:
- 扩展性:可以添加新文件系统
- 统一性:所有文件系统接口一致
- 代码复用:缓存、路径解析等可共享
代价是实现更复杂,但对于真实操作系统是必要的。
为什么选择 ramfs 而不是 ext2?¶
ramfs 更简单: - 不需要磁盘布局 - 不需要块分配算法 - 不需要处理磁盘故障
ext2 的实现需要处理: - 块位图 - inode 位图 - 间接块 - 目录项格式
ramfs 适合作为理解 VFS 框架的第一步。
思考题¶
- 为什么 inode 不包含文件名?硬链接是如何实现的?
- 如果 dentry 缓存满了,应该淘汰哪些 dentry?
- 进程打开文件后,文件被删除,进程还能访问吗?
- 延迟写入导致崩溃丢失数据,如何权衡性能和可靠性?