ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

《从零手写操作系统 (44):虚拟文件系统(VFS)统一抽象——挂载点、文件描述符与路径解析》

《从零手写操作系统 (44):虚拟文件系统(VFS)统一抽象——挂载点、文件描述符与路径解析》
前言:让万物皆文件的理想照进现实

在前面的章节中,我们实现了ext2文件系统、管道和TTY设备。但此时的内核代码充斥着if (is_pipe) ... else if (is_tty) ... else ext2_read()这样的类型判断。每增加一种新文件类型(如procfs、socket、devtmpfs),所有I/O路径都要修改。这违背了Unix“一切皆文件”的核心哲学——对使用者而言,文件应该是统一的抽象;对实现者而言,每种文件类型应该独立演化。

VFS(Virtual File System)是内核中的“面向对象接口层”。它不存储任何数据,而是定义了一套标准的操作契约(inode_operations, file_operations, super_operations),让ext2、pipe、tty、procfs等异构后端以相同的面貌呈现给用户态。本章将实现VFS核心框架,让你的OS首次支持多文件系统挂载、统一fd管理和完整的路径名解析,为构建完整的Unix文件语义奠定基础。

本章里程碑:

  • ✅ VFS inode/dentry/superblock三层抽象:分离元数据、缓存与存储后端
  • ✅ file_operations/inode_operations接口:统一read/write/open/lookup语义
  • ✅ 挂载子系统:mount/umount系统调用、挂载点链表、跨文件系统遍历
  • ✅ 路径名解析器:绝对/相对路径、./..、符号链接基础、权限检查
  • ✅ 进程fd表:open/close/dup2、引用计数、CLOEXEC标志
  • ✅ 验证:mount -t proc none /proc && cat /proc/self/status、跨挂载点cd /dev && ls

核心概念:VFS不是“中间件”,而是“类型擦除的运行时多态”
为什么需要dentry缓存而非直接用inode?

因为inode是持久化概念,dentry是运行时概念。inode存储在磁盘上,不包含文件名(文件名属于目录项);而路径解析需要频繁按名字查找子节点。若每次open("/a/b/c")都从磁盘读取三级目录的inode,性能将不可接受。dentry缓存将(父dentry, 名字) → 子inode的映射保存在内存中,使重复路径解析变为O(1)哈希查找。

⚠️关键洞察:dentry的生命周期与inode解耦。一个inode可能有多个dentry(硬链接),也可能没有dentry(已删除但仍被打开的文件)。反之,负dentry(negative dentry)缓存“此名字不存在”的结果,避免重复磁盘查询。如果你的VFS把文件名嵌入inode结构体,你就永远无法正确实现硬链接和负缓存。教学实现中dentry必须包含:name、parent指针、inode指针(可为NULL表示负缓存)、hash链表节点、引用计数。

file_operations vs inode_operations:打开前与打开后的分界

许多初学者混淆这两组接口。它们的职责边界极其清晰:

  • inode_operations:作用于未打开的文件对象,包括lookup(路径解析)、create/mkdir/unlink(命名空间修改)、readlink/getattr(元数据查询)
  • file_operations:作用于已打开的文件描述符,包括read/write/lseek/ioctl/poll(数据流操作)、mmap(内存映射)

这个分离对应了Unix的两阶段文件访问模型:open()阶段通过inode_ops定位并验证文件,返回fd;后续I/O通过file_ops在fd上操作,不再涉及路径解析。若你在read()中还在做路径查找,或在lookup()中分配了file结构体,就说明接口边界被污染了。这种污染会导致缓存失效、安全漏洞和性能退化。

挂载的本质:dentry树的动态嫁接

mount不是“加载驱动程序”,而是将一个文件系统的根dentry嫁接到现有dentry树的某个节点上。挂载点dentry的inode被临时“遮蔽”,其d_child链表中插入新文件系统的根dentry。路径解析遇到挂载点时,透明地切换到新文件系统的dentry空间。umount则是反向操作:移除嫁接、恢复原inode可见性、释放超级块引用。

关键约束:①挂载点必须是目录;②不能umount正在使用的文件系统(引用计数>0);③同一挂载点可叠加挂载(后挂载者遮蔽前者)。教学实现先支持单层挂载,但数据结构必须预留挂载栈字段。


实战代码
VFS核心数据结构
// kernel/fs/vfs.h // ★ 超级块:文件系统实例的运行时代表 struct super_block { struct super_operations *s_op; void *s_fs_info; // 后端私有数据(如ext2_sb_info) struct dentry *s_root; // 文件系统根dentry uint32_t s_magic; int s_refcount; // 活跃引用计数 struct list_head s_list; // 全局sb链表 }; // ★ dentry:目录项缓存,路径解析的核心单元 struct dentry { char name[NAME_MAX + 1]; struct dentry *d_parent; struct inode *d_inode; // NULL = 负缓存 struct super_block *d_sb; struct list_head d_child; // 父目录的子dentry链表 struct hlist_node d_hash; // dentry hash表节点 uint32_t d_refcount; uint32_t d_flags; // DCACHE_NEGATIVE, DCACHE_MOUNTED等 }; // ★ inode:文件元数据的运行时代表 struct inode { uint32_t i_ino; umode_t i_mode; uid_t i_uid; gid_t i_gid; loff_t i_size; struct inode_operations *i_op; struct file_operations *i_fop; struct super_block *i_sb; void *i_private; // 后端私有数据(如ext2_inode_info) uint32_t i_refcount; }; // ★ file:已打开文件的运行时状态 struct file { struct dentry *f_dentry; struct file_operations *f_op; loff_t f_pos; // 当前读写偏移 uint32_t f_flags; // O_RDONLY, O_CLOEXEC等 uint32_t f_refcount; void *private_data; // 后端私有数据 };
路径名解析器
// kernel/fs/namei.c // ★ 解析单级路径分量 static struct dentry *lookup_one(struct dentry *parent, const char *name) { // 1. 查dentry缓存 struct dentry *dentry = dcache_lookup(parent, name); if (dentry) return dentry; // 2. 缓存未命中,调用后端lookup if (!parent->d_inode || !parent->d_inode->i_op->lookup) return ERR_PTR(-ENOTDIR); dentry = parent->d_inode->i_op->lookup(parent->d_inode, name); if (IS_ERR(dentry)) return dentry; // 3. 插入dentry缓存 dcache_add(parent, dentry); return dentry; } // ★ 完整路径解析(支持绝对/相对路径、./..) struct dentry *path_walk(const char *pathname) { struct dentry *current_dentry; // 确定起始点 if (*pathname == '/') { current_dentry = current->fs->root; pathname++; } else { current_dentry = current->fs->pwd; } dget(current_dentry); // 增加引用 while (*pathname) { // 跳过连续斜杠 while (*pathname == '/') pathname++; if (!*pathname) break; // 提取下一个分量 const char *end = strchr(pathname, '/'); size_t len = end ? (end - pathname) : strlen(pathname); char component[NAME_MAX + 1]; memcpy(component, pathname, len); component[len] = '\0'; pathname += len; // 处理 . 和 .. if (strcmp(component, ".") == 0) continue; if (strcmp(component, "..") == 0) { struct dentry *parent = current_dentry->d_parent; dput(current_dentry); current_dentry = parent; dget(current_dentry); continue; } // ★ 检查是否为挂载点,透明切换 if (current_dentry->d_flags & DCACHE_MOUNTED) { struct vfsmount *mnt = lookup_mount(current_dentry); dput(current_dentry); current_dentry = mnt->mnt_root; dget(current_dentry); } // 解析下一级 struct dentry *next = lookup_one(current_dentry, component); dput(current_dentry); if (IS_ERR(next)) return next; current_dentry = next; } return current_dentry; }
fd表与open系统调用
// kernel/fs/open.c #define MAX_FDS 1024 struct files_struct { spinlock_t lock; struct file *fd[MAX_FDS]; uint32_t open_fds; // bitmap或简单计数 }; // ★ open系统调用 int sys_open(const char *pathname, int flags, mode_t mode) { // 1. 路径解析 struct dentry *dentry = path_walk(pathname); if (IS_ERR(dentry)) return PTR_ERR(dentry); // 2. 权限检查 int ret = permission(dentry->d_inode, flags_to_mode(flags)); if (ret < 0) { dput(dentry); return ret; } // 3. 分配file结构体 struct file *filp = alloc_file(); filp->f_dentry = dentry; filp->f_op = dentry->d_inode->i_fop; filp->f_flags = flags; filp->f_pos = 0; // 4. 调用后端open(可选) if (filp->f_op->open) { ret = filp->f_op->open(dentry->d_inode, filp); if (ret < 0) { free_file(filp); dput(dentry); return ret; } } // 5. 安装到fd表 int fd = install_fd(current->files, filp); if (fd < 0) { if (filp->f_op->release) filp->f_op->release(dentry->d_inode, filp); free_file(filp); dput(dentry); } return fd; } // ★ close系统调用 int sys_close(int fd) { struct file *filp = remove_fd(current->files, fd); if (!filp) return -EBADF; if (filp->f_op->release) filp->f_op->release(filp->f_dentry->d_inode, filp); dput(filp->f_dentry); free_file(filp); return 0; }

关键细节解析

1. 为什么dentry必须有独立的引用计数而非借用inode引用?

因为dentry和inode的生命周期完全独立。考虑场景:进程A打开了/a/b,此时另一进程删除了/a/b。inode因仍有打开引用而不释放,但dentry必须从父目录的子链表中摘除(否则ls仍能看到已删除文件)。若dentry借用inode引用,就无法表达“inode存活但dentry已死”的状态。dget/dput管理的是“这个名字在缓存中的存在权”,iget/iput管理的是“这个文件元数据的存活权”。两者正交,缺一不可。

2. 为什么路径解析必须在持有dentry引用的情况下进行?

因为并发unlink可能在解析过程中删除中间目录。若path_walk("/a/b/c")在解析完/a/b后释放了b的dentry引用,另一线程可能立即unlink/a/b并释放其inode。当path_walk继续用b的dentry解析c时,就会访问已释放内存。正确的做法是:每解析一级,先dget新dentry再dput旧dentry,保证手中始终持有一个有效引用。这形成了“引用传递链”,确保整个解析过程的内存安全。教学实现中务必在每一步dput前验证新dentry非ERR。

3. 为什么file结构体不能嵌入dentry或inode?

因为同一个文件可以被多次打开,每次打开有独立的f_pos和f_flags。若file嵌入inode,两个进程同时open同一文件会共享读写位置,导致数据交错。file是“打开实例”的运行时状态,dentry/inode是“文件本身”的持久化+缓存状态。三者是1:N:M的关系:一个inode对应多个dentry(硬链接),一个dentry对应多个file(多次open)。将它们混为一谈是对Unix文件模型的根本误解。


调试Checklist:VFS与路径解析排查
症状可能原因排查方法
open返回ENOENT但文件存在dcache_lookup哈希冲突/path_walk分量提取错/负缓存未失效dump dentry hash桶验证碰撞;kprintf每级解析的component字符串;测试touch后立即open验证负缓存清除
跨挂载点cd失败DCACHE_MOUNTED标志未设置/lookup_mount返回NULL/mnt_root未dgetdump挂载点dentry的flags;验证mount时设置了DCACHE_MOUNTED;测试mount && ls /mnt最简跨fs操作
close后仍能readfd表未清除/file引用计数错/dput遗漏dump进程fd表确认slot为NULL;跟踪file->f_refcount变化;验证close路径中dput和free_file均执行
路径解析UAFdget/dput顺序错/并发unlink竞争/ERR_PTR未检查启用KASAN等价物检测use-after-free;stress test并发mkdir+rmdir+open;验证每步IS_ERR检查
mount后原目录内容仍可见挂载点dentry未标记MOUNTED/path_walk未检查该标志hexdump挂载点dentry.flags;验证path_walk中MOUNTED检查位于lookup之前;测试mount后立即ls
硬链接行为异常dentry与inode引用耦合/lookup未创建新dentry/link未增i_nlinkstat验证nlink计数;测试ln a b && rm a && cat b;dump inode.i_refcount与关联dentry数量

🔧黄金法则:VFS调试的终极武器是dentry树可视化器+引用计数审计日志。实现debug_dump_dentry_tree(root)以缩进格式打印整棵dentry树(含负缓存和挂载标记),同时实现debug_audit_refs(pid)记录每个dget/dput/iget/iput的调用栈和计数变化。VFS bug几乎总是“引用计数泄漏或提前释放”:少一个dget导致缓存悬空,多一个dput导致内存泄漏,dget/dput顺序颠倒导致并发UAF。只有将抽象的树结构与具体的引用计数时间线叠加验证,才能发现这些隐藏在缓存生命周期中的幽灵错误。不要在VFS代码里加printf调试——你看到的只是单次操作的投影,真正的正确性存在于dentry树的全局一致性中。


本章小结与下一步

今天我们让文件系统拥有了统一的灵魂:

  • ✅ VFS三层抽象(super/dentry/inode)彻底分离存储、缓存与元数据
  • ✅ file_operations/inode_operations接口实现运行时多态
  • ✅ 挂载子系统支持跨文件系统透明遍历
  • ✅ 路径解析器正确处理绝对/相对路径、./..、挂载点切换
  • ✅ fd表管理open/close/dup2,引用计数保证生命周期安全
  • ✅ 验证:procfs挂载、跨挂载点操作、硬链接语义全部正确

从此,你的操作系统拥有了真正的“一切皆文件”架构。当你第一次看到cat /proc/self/status通过VFS接口读取内核信息、cd /dev && ls无缝跨越文件系统边界、多个进程同时打开同一文件各自维护独立偏移时,你见证的是OS从“单一文件系统驱动”到“统一文件抽象平台”的架构成熟时刻。

下一章预告:《procfs与sysfs:内核信息的文件系统视图》

当前的内核状态只能通过调试函数查看。下一章将实现procfs,将进程信息、内存统计、设备列表暴露为标准文件接口,让用户态工具(ps/top/free)无需特殊系统调用即可 introspect 内核。


参考资料
  • Understanding the Linux Kernel Ch.12 (The Virtual Filesystem)
  • Linux Kernel:fs/namei.c,fs/dcache.c,fs/super.c,fs/file_table.c
  • Marshall Kirk McKusick, "The Design of the BSD Fast Filesystem" (VFS设计起源)
  • Al Viro, "Pathname Resolution in Linux" (现代path_walk演进)
  • 本系列完整代码:[你的GitHub仓库链接](Commit:v1f2s3v)

📝作者注:这是《从零手写操作系统》系列的第44篇。VFS是整个教程中抽象层次最高、接口契约最严格、重构成本最大的章节。它要求你同时理解文件系统、内存缓存、并发控制和用户态ABI四重维度。强烈建议先用纸笔画出super/dentry/inode/file四者的关系图和引用计数流转图,确认你对生命周期的理解无歧义后再编码。把“数据结构定义”、“路径解析正确性”、“挂载透明性”、“fd生命周期”分成四个独立里程碑,并用dentry树可视化+引用审计双重验证每一步的一致性,是避免在抽象迷宫中迷失的关键纪律。下一章,我们让内核从“黑盒引擎”走向“可观测系统”!

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进