4、VFS与挂载基础:虚拟文件系统架构、挂载系统调用、挂载命名空间
好,我们进入第四章。这一章是理解 rootfs 启动挂载的基石。说白了,你如果不搞懂 VFS 和 mount 系统调用,后面看 initramfs 怎么切根、怎么看挂载过程,就会一头雾水。
我个人习惯把 VFS 看作一层“万能适配器”。它让用户空间的程序不用关心底层是 ext4、btrfs 还是 NFS。你只管 open、read、write,剩下的 VFS 帮你搞定。
4.1 虚拟文件系统(VFS)架构
VFS 是什么?它不是一个真正的文件系统,而是一个抽象层。它定义了一套通用的文件操作接口,比如 open、read、write、mmap 等。每个具体的文件系统(比如 ext4)只要实现了这套接口,就能被 Linux 内核无缝接入。
我记得刚接触内核时,总觉得 VFS 很玄乎。后来我画了一张图,一下就明白了。你看下面这个结构:
这张图我建议你多看几遍。VFS 核心层里有四个关键对象:super_block、inode、dentry 和 file。它们各自负责什么?
| 对象 | 作用 | 我遇到过的坑 |
|---|---|---|
| super_block | 代表一个已挂载的文件系统实例 | 每个挂载点对应一个 super_block,卸载时如果引用计数不为 0 会报错 |
| inode | 代表一个文件或目录的元数据(权限、大小、位置) | 硬链接就是多个 dentry 指向同一个 inode |
| dentry | 代表路径中的一个分量(目录项),用于路径查找 | dentry 缓存(dcache)对性能影响极大,我曾经遇到过 dcache 过大导致 OOM |
| file | 代表一个打开的文件实例,持有当前读写位置 | 多线程共享 fd 时,file 对象的 f_pos 会被竞争 |
核心要点:VFS 让“一切皆文件”成为可能。你执行 cat /proc/cpuinfo 时,VFS 把 procfs 的数据包装成文件流返回给用户空间。底层是内存里的数据结构,不是磁盘上的文件。
4.2 挂载系统调用(mount)
mount 系统调用是 VFS 的“接线员”。它的任务是把一个文件系统实例“接”到目录树的一个节点上。你想想看,没有挂载,磁盘分区就是一堆裸数据,用户空间根本访问不了。
mount 的原型长这样:
#include <sys/mount.h>
int mount(const char *source, // 源设备(如 /dev/sda1)
const char *target, // 挂载点(如 /mnt)
const char *filesystemtype, // 文件系统类型(如 ext4)
unsigned long mountflags, // 挂载标志
const void *data); // 文件系统特有选项
嗯,这里要注意 mountflags。常用的标志有:
- MS_RDONLY:只读挂载。我调试过一个问题,rootfs 被意外以读写方式挂载,导致断电后文件系统损坏。
- MS_NOSUID:忽略 suid 位。安全加固时必加。
- MS_NOEXEC:不允许执行二进制文件。挂载 /tmp 时我习惯加上。
- MS_REMOUNT:重新挂载,修改已有挂载点的标志。
- MS_BIND:绑定挂载,把一个目录树挂到另一个位置。
我曾经在项目中遇到一个诡异的问题:内核启动时 rootfs 挂载失败,打印 VFS: Cannot open root device。查了半天,发现是 mountflags 里传了 MS_RDONLY,但根文件系统所在的块设备需要先写日志才能读。后来我改用 MS_REMOUNT | MS_RDONLY 先读写挂载完成恢复,再重新挂载为只读。
避坑指南:mount 系统调用在内核中对应 do_mount() 函数。它会先解析路径、查找 super_block、调用具体文件系统的 mount 回调。如果回调返回 NULL,mount 就失败。我曾经在调试时发现,某个文件系统的 mount 回调里忘记初始化 super_block 的 s_op,导致后续所有 inode 操作都崩溃。
4.3 挂载命名空间
挂载命名空间(mount namespace)是 Linux 容器技术的核心之一。它让每个进程组拥有独立的挂载点视图。说白了,你在容器里执行 mount,不会影响宿主机,也不会影响其他容器。
我刚开始接触容器时,一直想不通:为什么容器里能看到 /proc,但看不到宿主机的 /home?答案就是挂载命名空间。每个容器启动时,内核会创建一个新的挂载命名空间,并复制一份挂载点列表。容器内的 mount/umount 只影响自己的那份副本。
创建新挂载命名空间的方式:
// 使用 clone() 系统调用
int child_pid = clone(child_func,
child_stack + STACK_SIZE,
CLONE_NEWNS | SIGCHLD, // CLONE_NEWNS 就是新挂载命名空间
NULL);
// 或者使用 unshare()
unshare(CLONE_NEWNS);
挂载命名空间有几个关键特性:
- 私有挂载:新命名空间中的挂载操作不会传播到父命名空间。
- 传播类型:通过
mount --make-shared、mount --make-slave等控制挂载事件的传播范围。 - 绑定挂载:常用于容器场景,把宿主机的某个目录绑定到容器内。
注意:挂载命名空间和 pivot_root 配合使用时,要小心顺序。我在做容器运行时的时候,遇到过先 pivot_root 再 unshare(CLONE_NEWNS) 导致根目录无法切换的问题。正确的做法是先创建新命名空间,再执行 pivot_root。
4.4 三者如何协同工作
好,我们把 VFS、mount 系统调用和挂载命名空间串起来。整个过程是这样的:
- 用户空间调用
mount("/dev/sda1", "/mnt", "ext4", 0, NULL)。 - 内核进入
do_mount(),根据当前进程的挂载命名空间找到挂载点列表。 - VFS 层解析
/mnt路径,找到对应的 dentry 和 inode。 - 内核查找 ext4 文件系统的
file_system_type,调用其mount回调。 - ext4 的 mount 回调读取 super_block,初始化 inode 和 dentry 缓存。
- VFS 把新的挂载点插入当前命名空间的挂载树中。
- 返回 0,挂载成功。
你想想看,如果没有 VFS,每个文件系统都要自己实现路径解析、权限检查、缓存管理,那内核代码得膨胀成什么样?VFS 把这些公共逻辑抽出来,具体文件系统只关心怎么读写磁盘块。
总结一下:VFS 是框架,mount 是动作,挂载命名空间是隔离边界。三者缺一不可。后面讲 initramfs 切根时,你会看到它们如何配合完成 rootfs 的挂载。