Linux VFS 虚拟文件系统:架构设计与内核工程全链路实战

一、为什么需要 VFS

在 Linux 的世界中,"一切皆文件"不仅仅是一句口号,而是 VFS(Virtual File System)赋予系统的统一抽象能力。无论是 ext4、XFS、Btrfs 这样的磁盘文件系统,还是 procfs、sysfs、debugfs 这样的伪文件系统,甚至是 socket、pipe、设备节点,都通过同一套 open/read/write/close 语义暴露给用户空间。这种统一抽象让 Linux 的文件 API 成为操作系统中最稳定、最向后兼容的接口之一。

1.1 设计哲学

VFS 的核心目标是:解耦用户空间的文件操作语义与底层文件系统的具体实现。为此,VFS 定义了一组"契约"(接口),所有具体的文件系统必须实现这组契约:

  • super_operations:管理文件系统级别的挂载、卸载、统计信息
  • inode_operations:目录级别的创建、查找、删除、链接操作
  • file_operations:文件级别的 open、read、write、mmap、ioctl 等操作
  • address_space_operations:页面缓存的读写、回写策略
  • dentry_operations:目录项缓存的哈希、比较、删除回调

二、VFS 四大核心对象

2.1 super_block — 文件系统实例

每个挂载的文件系统在内核中对应一个 struct super_block,它描述了整个文件系统的全局状态:

struct super_block {
    struct list_head    s_list;         // 全局链表
    dev_t               s_dev;          // 设备标识
    unsigned long       s_blocksize;    // 块大小
    loff_t              s_maxbytes;     // 最大文件大小
    struct file_system_type *s_type;    // 文件系统类型
    const struct super_operations *s_op; // 超级块操作
    struct dentry       *s_root;        // 根目录 dentry
    struct list_head    s_inodes;       // 所有 inode 链表
    struct list_head    s_dirty;        // 脏 inode 链表
    struct rw_semaphore s_umount;       // 卸载信号量
    int                 s_count;        // 引用计数
    ...
};

当执行 mount -t ext4 /dev/sda1 /mnt 时,内核调用 sget() 或 mount_bdev() 创建 super_block,然后调用文件系统的 fill_super() 填充根 inode 和根 dentry。

2.2 inode — 文件的元数据

struct inode 是文件的"身份证",它不包含文件名,只包含文件的实际属性:

struct inode {
    umode_t             i_mode;         // 权限与类型
    uid_t               i_uid;          // 所有者
    gid_t               i_gid;          // 组
    unsigned long       i_ino;          // inode 号
    struct super_block  *i_sb;          // 所属超级块
    const struct inode_operations *i_op; // inode 操作
    const struct file_operations   *i_fop; // 默认 file 操作
    struct address_space i_data;        // 页面缓存
    struct list_head    i_sb_list;      // 超级块 inode 链表
    union {
        struct pipe_inode_info  *i_pipe; // pipe 专用
        struct block_device     *i_bdev; // 块设备
        struct cdev             *i_cdev; // 字符设备
    };
    ...
};

关键理解:inode 是跨硬链接的——多个 dentry 可以指向同一个 inode,这就是硬链接的实现基础。

2.3 dentry — 目录项缓存

struct dentry 代表路径中的一个组件,VFS 通过 dentry 缓存(dcache)加速路径查找:

struct dentry {
    unsigned int        d_flags;
    struct inode        *d_inode;       // 关联的 inode
    struct qstr         d_name;         // 组件名
    struct dentry       *d_parent;      // 父目录
    struct list_head    d_child;        // 父目录的子节点链表
    struct list_head    d_subdirs;      // 子节点链表(如果是目录)
    struct hlist_bl_node d_hash;        // 全局哈希表
    struct dentry_operations *d_op;
    ...
};

当你执行 open("/home/user/data.txt") 时,VFS 逐级查找 / → home → user → data.txt,每一步都在 dcache 中查找对应的 dentry。如果 cache 未命中,则调用具体文件系统的 lookup() 从磁盘读取。

2.4 file — 打开的文件实例

struct file 代表一个进程的文件描述符(fd)对应的内核态对象:

struct file {
    struct path         f_path;         // dentry + vfsmount
    const struct file_operations *f_op;  // 文件操作表
    atomic_long_t       f_count;        // 引用计数
    loff_t              f_pos;          // 当前读写偏移
    struct fown_struct  f_owner;        // SIGIO 异步通知
    struct address_space *f_mapping;    // 页面缓存映射
    void                *private_data;  // 驱动私有数据
    ...
};

f_pos 是读写位置的体现——每次 read() 或 write() 都会修改 f_pos,除非设置了 O_APPEND。多个 fd 可能指向同一个 file(通过 dup()),但它们共享 f_pos。

三、从 open() 到文件操作:全链路解析

3.1 路径查找(Path Lookup)

用户执行 open("/var/log/syslog", O_RDONLY),内核的调用链为:

  1. sys_open() → do_sys_open()
  2. getname() 将路径从用户空间拷贝到内核
  3. get_unused_fd_flags() 分配文件描述符
  4. do_sys_openat2() → do_filp_open()
  5. path_openat() — 核心路径查找逻辑
  6. link_path_walk() 逐级解析路径组件
  7. 每步调用 lookup_fast()(dcache 命中)或 lookup_slow()(调用 ->lookup())
  8. lookup_open() 创建 file 对象,调用 fops->open()
  9. fd_install() 将 file 与 fd 绑定

dcache 的查找效率极高:使用哈希表 + LRU 链表,命中率通常在 80%-98% 之间。可以通过 sysctl vm.vfs_cache_pressure 调整 dentry/inode 缓存的回收倾向。

3.2 文件打开与 inode 权限检查

open() 会进行多层权限检查:

  1. capable(CAP_DAC_OVERRIDE):root 进程默认跳过 DAC
  2. inode_permission():调用 i_op->permission() 检查 inode 权限
  3. LSM(SELinux/AppArmor):安全模块的钩子函数
  4. IMA/EVM:完整性检查

如果文件不存在且设置了 O_CREAT,VFS 会调用 vfs_create() → inode_ops->create() 创建新文件。

四、Page Cache:文件 IO 的性能基石

4.1 读写流程

read() 的标准路径:

  1. sys_read() → vfs_read()
  2. 检查 f_op->read_iter(现代文件系统都使用 read_iter)
  3. 调用 generic_file_read_iter() → filemap_read()
  4. 先查 page cache:通过 inode 的 address_space(即 radix tree / XArray)按页索引查找
  5. cache 命中:直接 copy_to_user,返回
  6. cache 未命中:调用 page_cache_sync_readahead() 或 page_cache_async_readahead() 预读页面
  7. 触发磁盘 IO,等待页面锁定解除
  8. copy_to_user 返回用户空间

write() 同理:

  1. vfs_write() → new_sync_write() → call_write_iter()
  2. generic_file_write_iter() → __generic_file_write_iter()
  3. 预分配:调用 file_update_time() 更新 mtime/ctime
  4. 写 page cache:调用 iov_iter_zero() 或 copy_page_from_iter() 写入页面
  5. 页面被标记为 脏页(dirty page)
  6. 在合适时机由内核 writeback 线程写回磁盘

4.2 Writeback 与脏页回写

Linux 通过参数控制脏页刷回磁盘的策略:

  • vm.dirty_background_ratio:脏页占总内存多少比例时启动后台回写(如 10%)
  • vm.dirty_ratio:强制同步写回的阈值(如 20%)
  • vm.dirty_writeback_centisecs:writeback 线程唤醒周期(500 = 5秒)
  • vm.dirty_expire_centisecs:脏页超过此时间必须回写(3000 = 30秒)

writeback 的核心机制:内核 kworker 线程调用 wb_workfn() → wb_writeback(),遍历脏 inode 列表,将数据写入对应的块设备映射(通过 address_space_operations->writepage())。

4.3 Direct IO 与 O_DIRECT

当应用设置 O_DIRECT 标志时,内核跳过 page cache,直接在用户空间 buffer 和磁盘之间传输数据。这要求:

  • 内存页对齐(通常是 512 字节或 4KB 边界)
  • IO 大小对齐
  • 文件系统支持(如 ext4 需要 dioread_nolock 挂载选项开启并行 DIOC)

Direct IO 的典型场景是数据库(如 MySQL InnoDB),它们自己管理缓存,避免双重缓存浪费内存。

五、内存映射文件:mmap 机制深度剖析

mmap() 将文件直接映射到进程的地址空间,实现"文件即内存"的编程模型:

void *addr = mmap(NULL, length, PROT_READ, MAP_PRIVATE, fd, offset);
// 读写内存即读写文件 strcpy(addr, "hello");
munmap(addr, length);

5.1 mmap 的底层实现

  1. sys_mmap() → vm_mmap_pgoff()
  2. do_mmap() 获取未映射的虚拟地址空间(VMAs)
  3. 创建 struct vm_area_struct 注册 vm_ops(如 generic_file_vm_ops)
  4. 此时不分配物理内存——采用懒加载策略
  5. 首次访问触发 缺页异常(page fault) → filemap_fault()
  6. filemap_fault() 查找/分配 page cache 页面,触发读磁盘
  7. 修改后的页面通过 writeback 或 msync() 同步回文件

5.2 MAP_SHARED vs MAP_PRIVATE

  • MAP_SHARED:写入直接映射到 page cache,其他进程可见,最终会刷回文件
  • MAP_PRIVATE:写时复制(COW)——修改触发 COW 创建私有副本,不会写回原文件,fork 时使用以节省内存

六、文件锁机制:flock vs fcntl vs lockf

VFS 层统一处理三种文件锁:

6.1 POSIX 锁(fcntl)

建议性锁,记录在 file->f_path.dentry->d_inode->i_flctx 链表中。最重要特性:

  • 作用于进程+文件对:同一进程重复加锁,新锁替换旧锁;close 文件时自动释放该进程持有的所有 POSIX 锁
  • 跨 fork 不继承(子进程不继承父进程的 POSIX 锁)
  • 范围锁:可以加锁文件的某个字节范围

6.2 BSD 锁(flock)

作用于 struct file 级别,即 open 文件描述:

  • fork 后子进程不继承
  • dup() 共享同一个 file,锁共享
  • 支持 LOCK_EX(排他)、LOCK_SHARED(共享)、LOCK_UN
  • 整个文件加锁,不能加部分范围

6.3 lease(租用锁)

  • 持有者被通知(SIGIO)有其他进程尝试打开/截断该文件
  • 持有者可以"降级"或"放弃"租用
  • NFS 的重要机制

七、文件系统与块层的衔接

7.1 Bio 与请求队列

VFS 层产生的 IO 请求最终通过 struct bio 提交到块设备层:

struct bio {
    struct bio_vec      *bi_io_vec;     // 段数组(页+偏移+长度)
    unsigned short      bi_vcnt;        // 段数量
    unsigned short      bi_max_vecs;    // 最大段数
    struct block_device *bi_bdev;       // 目标块设备
    struct bvec_iter    bi_iter;        // 当前迭代位置
    bio_end_io_t        *bi_end_io;     // 完成回调
    ...
};

VFS 与块层的关键衔接点:submit_bio() → generic_make_request() → 进入 IO 调度器(mq-deadline/kyber/none)→ 驱动队列 → 硬件中断完成 → 回调通知。

7.2 io_uring:新一代异步 IO

Linux 5.1 引入的 io_uring,彻底改变了 Linux 异步 IO 的游戏规则:

  • 共享内存 SQ/CQ 环:内核与用户空间通过两个无锁环形队列通信
  • 批量提交/收割:单次 syscall 可提交收割数百个 IO
  • 固定缓冲区(fixed buffer):减少 pin/unpin 开销
  • 轮询模式(IORING_SETUP_IOPOL):完全绕过中断,达到百万 IOPS
  • VFS 已集成:file_operations uring_cmd 支持 uring 调用文件操作

八、工程实战:VFS 性能调优与故障排查

8.1 性能调优参数

# 减少 page cache 回收倾向(缓存更多文件页)
sysctl -w vm.vfs_cache_pressure=50

# 调整脏页回写策略
sysctl -w vm.dirty_ratio=40
sysctl -w vm.dirty_background_ratio=10
sysctl -w vm.dirty_writeback_centisecs=1500

# 增加最大文件句柄
sysctl -w fs.file-max=2097152
sysctl -w fs.nr_open=1048576

# epoll 最大实例数
sysctl -w fs.epoll.max_user_watches=1048576

# inotify 监控限制
sysctl -w fs.inotify.max_user_watches=524288
sysctl -w fs.inotify.max_user_instances=1024

8.2 排查工具

  • strace -e trace=file,desc:追踪文件系统调用
  • lsof -p <pid>:查看进程打开的文件
  • vmtouch /path/file:查看文件 page cache 命中情况
  • /proc/meminfo:SReclaimable(可回收 slab)、Cached(页缓存)
  • cat /proc/slabinfo | grep -i dentry:查看 dentry 缓存占用
  • fatrace:实时文件访问追踪(基于 fanotify)
  • bcc 工具:vfsstat, vfscount, filetop, filelife 用于实时 VFS 统计

8.3 常见陷阱

  • TOCTOU 竞态:access() + open() 之间文件可能变化,应直接以期望模式尝试 open 并检查错误
  • rename 原子性:同一文件系统内的 rename 是原子的,可安全用于"写后替换"模式(tmpfile + rename)
  • fsync vs fdatasync:fsync 同步文件数据和元数据;fdatasync 只同步数据(更快)
  • O_DSYNC vs O_SYNC:O_DSYNC 同步数据,O_SYNC 同步数据+元数据
  • NFS 的 close-to-open 语义:不保证写入立即可见,需要 close 后才对其他客户端可见

九、总结

VFS 是 Linux 内核中最复杂的子系统之一,它通过四大对象(super_block、inode、dentry、file)构建起统一的文件抽象,通过 page cache 和 writeback 机制平衡性能与可靠性,通过多样的锁机制支持并发访问。理解 VFS 不仅是内核开发的必备知识,也是架构设计、性能优化、故障排查的基石。

从早期 UNIX 的 file 结构,到 Linux 的 VFS 架构,文件系统的抽象演进始终围绕着一个核心问题:如何在性能、可靠性、兼容性之间找到最佳平衡。掌握 VFS 的深层机制,就掌握了对 Linux 文件行为进行预测和优化的钥匙。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部