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 架构分层图 VFS 架构分层图 用户空间(User Space) 系统调用层(sys_open / sys_read / sys_write) VFS 核心层 dentry / inode / super_block / file 对象 具体文件系统层(ext4 / btrfs / xfs / tmpfs / nfs) file_operations / inode_operations / super_operations 块设备层(Block Layer) 硬件层(SD / eMMC / NVMe / SATA) VFS 核心层通过 file_operations 回调,将系统调用分发到具体文件系统的实现函数

这张图我建议你多看几遍。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 系统调用和挂载命名空间串起来。整个过程是这样的:

  1. 用户空间调用 mount("/dev/sda1", "/mnt", "ext4", 0, NULL)。
  2. 内核进入 do_mount(),根据当前进程的挂载命名空间找到挂载点列表。
  3. VFS 层解析 /mnt 路径,找到对应的 dentry 和 inode。
  4. 内核查找 ext4 文件系统的 file_system_type,调用其 mount 回调。
  5. ext4 的 mount 回调读取 super_block,初始化 inode 和 dentry 缓存。
  6. VFS 把新的挂载点插入当前命名空间的挂载树中。
  7. 返回 0,挂载成功。

你想想看,如果没有 VFS,每个文件系统都要自己实现路径解析、权限检查、缓存管理,那内核代码得膨胀成什么样?VFS 把这些公共逻辑抽出来,具体文件系统只关心怎么读写磁盘块。

总结一下:VFS 是框架,mount 是动作,挂载命名空间是隔离边界。三者缺一不可。后面讲 initramfs 切根时,你会看到它们如何配合完成 rootfs 的挂载。