《从零手写操作系统 (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未dget | dump挂载点dentry的flags;验证mount时设置了DCACHE_MOUNTED;测试mount && ls /mnt最简跨fs操作 |
| close后仍能read | fd表未清除/file引用计数错/dput遗漏 | dump进程fd表确认slot为NULL;跟踪file->f_refcount变化;验证close路径中dput和free_file均执行 |
| 路径解析UAF | dget/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_nlink | stat验证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树可视化+引用审计双重验证每一步的一致性,是避免在抽象迷宫中迷失的关键纪律。 下一章,我们让内核从“黑盒引擎”走向“可观测系统”!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)