从零写OS内核 | VFS——虚拟文件系统,Linux的”一切皆文件”是怎么实现的

你用 cat /proc/cpuinfo 读 CPU 信息,用 ls /dev/null 查设备节点,用 echo "hello" > /tmp/a.txt 写文件——三个完全不同的操作,却都可以用 open/read/write 这几个系统调用搞定。为什么?

答案就是 VFS(Virtual File System Switch)——Linux 内核用来统一管理所有”看起来像文件的东西”的一层抽象。它让磁盘文件、管道、设备、进程信息、网络 socket 都用同一套接口操作,实现了 Unix 最经典的设计哲学:一切皆文件

今天,我们来搞清楚 VFS 是怎么把完全不同的事物用同一个框架统一起来的。


1. 为什么需要 VFS

没有 VFS 的话:

Bash
没有 VFS 时:
  → 应用程序需要知道文件在哪个具体的文件系统(ext4、btrfs、FAT)
  → 读磁盘用 ext4 的 API,读网络用 socket API,读设备用 ioctl
  → 每个应用程序都要写不同代码处理不同后端

有了 VFS:

Bash
有 VFS 时:
  → 应用程序只调用 open/read/write
  → VFS 层统一处理,路由到具体文件系统的实现(ext4、btrfs...)
  → 写代码时不需要知道底层是什么

Bash
VFS 在系统中的位置:

用户程序
    ↓
系统调用(open/read/write)
    ↓
VFS 层(统一接口)
    ↓ ┌──────────────┬──────────────┬──────────────┬──────────────┐
    → │   ext4       │   btrfs      │   procfs     │   devtmpfs   │
      │  (磁盘文件)   │  (磁盘文件)   │  (进程信息)   │  (设备节点)   │
      └──────────────┴──────────────┴──────────────┴──────────────┘
           ↓              ↓              ↓              ↓
        磁盘          磁盘          /proc           /dev

2. VFS 核心数据结构:inode 和 dentry

VFS 有两个最核心的结构:inode(代表一个文件)和 dentry(代表目录项)。

2.1 inode:代表一个具体的文件/对象

Bash
inode(索引节点)是 VFS 的核心抽象:

每个文件(磁盘、设备、管道等)在内核里都有一个 inode
inode 包含:
  - 文件类型(regular / dir / char / block / link / socket)
  - 文件大小
  - 时间戳(atime/mtime/ctime)
  - 权限位(mode)
  - 文件操作函数表(struct file_operations *f_op)
  - 指向具体文件系统私有数据的指针

inode 不是文件名——文件名在 dentry 里
inode 是文件本身(内容、元数据、操作)

C
// VFS inode 结构(简化)
// 源码:include/linux/fs.h

struct inode {
    umode_t         i_mode;          // 文件类型 + 权限位
    kuid_t          i_uid;           // 所有者 UID
    kgid_t          i_gid;           // 所有者 GID
    loff_t          i_size;          // 文件大小
    struct timespec i_atime;         // 访问时间
    struct timespec i_mtime;         // 修改时间
    struct timespec i_ctime;         // 状态改变时间

    const struct inode_operations *i_op;  // inode 操作函数表
    const struct file_operations *i_fop;  // 文件操作函数表

    void            *i_private;      // 文件系统私有数据
    // ...
};

2.2 dentry:文件名到 inode 的桥梁

Bash
dentry(目录项)是"文件名到 inode 的映射":

  /tmp
   ↓ dentry: "tmp" → inode(tmp目录)
  /tmp/a.txt
   ↓ dentry: "a.txt" → inode(a.txt文件)

dentry 组成:
  - d_name: 文件名("a.txt")
  - d_parent: 父目录的 dentry
  - d_inode: 指向这个文件的 inode
  - d_hash: dentry 缓存的 hash 表
  - d_op: dentry 操作函数表(指针)

VFS 维护一个 dentry cache(dcache)来加速路径解析:
  "从根目录 / 解析 /tmp/a.txt" 的过程:
  → 先找 / 的 dentry → inode → 读目录内容
  → 找 "tmp" → dentry → inode
  → 找 "a.txt" → dentry → inode
  → 找到 inode 后缓存 dentry,下次更快

2.3 file:进程打开的文件实例

Bash
file(文件实例)是"进程打开的文件":

同一个文件可以被多个进程同时打开
每个进程有一个 struct file(文件描述符 fd 的底层结构)
每个进程有独立的:
  - 文件偏移(f_pos)
  - 文件描述符标志(O_NONBLOCK 等)
  - 读写缓冲区状态

但共享同一个:
  - inode(同一个磁盘文件)
  - struct file_operations(操作函数)

C
// VFS file 结构
struct file {
    struct path         f_path;          // 路径(dentry + vfsmount)
    const struct file_operations *f_op; // 文件操作函数表
    loff_t             f_pos;           // 当前文件偏移
    void               *private_data;   // 文件系统私有数据

    struct fown_struct f_owner;         // 用于 SIGIO
    unsigned int        f_flags;         // open 时的标志
    // ...
};

3. file_operations:操作函数表

这是 VFS 最巧妙的设计——函数指针表

C
// file_operations 结构(每个 inode 有一份)
// 源码:include/linux/fs.h

struct file_operations {
    // 读/写
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);

    // 打开/释放
    int     (*open)  (struct inode *, struct file *);
    int     (*release)(struct inode *, struct file *);

    // 目录操作
    int     (*iterate)(struct file *, struct dir_context *);

    // 偏移
    loff_t  (*llseek)(struct file *, loff_t, int);

    // I/O 控制
    long    (*unlocked_ioctl)(struct file *, unsigned int, unsigned long);

    // mmap
    int     (*mmap)  (struct file *, struct vm_area_struct *);

    // 其他
    int     (*fsync) (struct file *, loff_t, loff_t, int);
    // ...
};

为什么函数指针表能统一不同文件系统?

Bash
ext4 的 file_operations:
  ext4_read() = ext4 文件系统读
  ext4_write() = ext4 文件系统写

procfs 的 file_operations:
  proc_read() = 从内核数据结构读进程信息
  proc_write() = 不支持(写入 /proc 一般是错误)

devtmpfs 的 file_operations:
  dev_read() = 从设备驱动读数据
  dev_write() = 写数据到设备驱动

VFS 统一调用:
  file->f_op->read(file, ...)  ← 不管是 ext4/procfs/dev
  file->f_op->write(file, ...) ← VFS 不需要知道具体是哪种文件系统

4. VFS 路径解析:从路径名到 inode

当你说 open("/tmp/a.txt", O_RDONLY) 时,VFS 做了以下事情:

Bash
路径解析流程(以 /tmp/a.txt 为例):

Step 1: 解析 / 的 dentry
  → 从当前进程的 root 目录开始
  → 在 dcache 里找 / 的 dentry
  → 找到 / inode

Step 2: 查找 "tmp"(在 / 目录下)
  → 调用 / inode 的 lookup() 方法(目录读)
  → ext4_lookup() 读 / 目录的内容
  → 找到 "tmp" 对应的 dentry → inode(tmp目录)

Step 3: 查找 "a.txt"(在 /tmp 目录下)
  → 调用 tmp inode 的 lookup() 方法
  → ext4_lookup() 读 /tmp 目录的内容
  → 找到 "a.txt" dentry → inode(a.txt)

Step 4: 分配 file 结构
  → 把 file->f_op = inode->i_fop
  → file->private_data = inode 的私有数据

Step 5: 返回 fd(文件描述符)

C
// VFS open 系统调用简化流程
int do_sys_open(int dfd, const char *pathname, int flags, mode_t mode) {
    struct filename *name = getname(pathname);

    // 解析路径到 dentry
    struct path path;
    path.dentry = path_lookup(name, flags, &path);

    // 获取 inode
    struct inode *inode = path.dentry->d_inode;

    // 分配 file 结构
    struct file *f = do_filp_open(dfd, name, flags, mode);

    // 设置 file->f_op = inode->i_fop
    f->f_op = inode->i_fop;

    // 调用文件系统的 open
    if (f->f_op->open)
        error = f->f_op->open(inode, f);

    // 返回 fd
    fd = get_unused_fd_flags(flags);
    fdinstall(fd, f);
    return fd;
}

5. 特殊文件系统:/proc 和 /dev

5.1 /proc:进程信息的文件系统接口

Bash
/proc 的特点:

目录结构:
  /proc/1/         → 进程 PID=1 的信息
  /proc/meminfo    → 系统内存信息
  /proc/cpuinfo    → CPU 信息
  /proc/diskstats  → 磁盘状态
  ...

/proc 的 inode_operations 和 file_operations:
  proc_lookup() → 读 /proc/PID/stat 之类的文件
  proc_read() → 从内核数据结构(task_struct)读出进程信息

所以读 /proc/cpuinfo 的流程:
  open("/proc/cpuinfo") → VFS 路径解析 → proc inode
    → proc_file_read() → 读取内核变量 boot_cpu_data
    → 返回给用户

5.2 /dev:设备节点的文件系统接口

Bash
/dev 的特点:

/dev/null    → 设备驱动(null_write 直接丢弃,null_read 返回0)
/dev/zero    → 读返回全0,写直接丢弃
/dev/urandom → 读返回随机数(加密安全)
/dev/tty     → 当前终端设备

设备文件有两种:
  字符设备(c):按字符流处理(键盘、串口)
  块设备(b):按块处理(磁盘)

设备号:
  - 主设备号(major):标识设备驱动
  - 次设备号(minor):标识驱动管理的具体设备

/dev/null 的主设备号 = 1,次设备号 = 3
内核里注册了 misc 驱动,major=1,处理所有 misc 设备

6. Linux 实践:观察 VFS

Bash
# 查看文件系统类型
df -T

# 查看具体文件的 inode 信息
ls -li /tmp/a.txt

# 查看某文件系统的挂载信息
mount | grep ext4

# 查看所有文件系统(/proc/mounts)
cat /proc/mounts

Bash
# 用 strace 观察 VFS 操作
strace -e trace=open,read,write cat /etc/hostname

# 观察目录项缓存(dcache)命中率
cat /proc/sys/vr/dentry-state

# 观察 inode 缓存
cat /proc/sys/vr/inode-state

Bash
# 用 lsof 观察进程打开的文件
lsof -p $$

# 观察具体文件被哪些进程打开
lsof /tmp/a.txt

Bash
# 用 debugfs 观察 ext4 内部结构(需要 root)
# debugfs -w /dev/sda1
# debugfs:  stat /tmp/a.txt
# 显示 inode 号、块位置、时间戳等

7. 从零实现:wandos 的 VFS

⚠️ wandos 当前状态kernel/fs/ 目录下有 VFS 实现,wandos 用简化版的 inode + file 结构管理所有文件系统。

7.1 VFS 层结构(kernel/fs/vfs.c)

Cpp
// wandos VFS 结构
// 源码:kernel/fs/vfs.c

// 文件描述符表(每个进程一份)
struct file_descriptor {
    bool used;                         // 是否占用
    struct vnode *node;               // 指向 VFS 节点
    int flags;                        // O_RDONLY, O_WRONLY 等
    loff_t pos;                       // 当前偏移
};

struct process_file_table {
    struct file_descriptor fd[64];    // 最多 64 个 fd
};

// VFS 节点(inode 的简化版)
struct vnode {
    char name[256];                   // 文件名
    enum vnode_type type;             // FILE / DIR / DEVICE / PIPE

    struct file_operations *fops;     // 操作函数表
    void *private_data;               // 文件系统私有数据

    uint64_t size;                    // 文件大小
    uint64_t inode_num;               // inode 号

    struct dentry *parent;            // 父目录
    struct list_head children;        // 子目录/文件
};

// file_operations(和 Linux 类似)
struct file_operations {
    int (*open)(struct vnode *, struct file_descriptor *);
    int (*close)(struct file_descriptor *);
    ssize_t (*read)(struct file_descriptor *, char *buf, size_t count);
    ssize_t (*write)(struct file_descriptor *, const char *buf, size_t count);
    loff_t (*lseek)(struct file_descriptor *, loff_t off, int whence);
};

7.2 VFS open 实现

Cpp
// wandos VFS open(简化版)
int vfs_open(const char *pathname, int flags) {
    // 路径解析(简化版:不支持相对路径)
    struct vnode *node = path_walk(pathname);
    if (!node) return -ENOENT;  // 文件不存在

    // 检查权限
    if ((flags & O_WRONLY) && (node->type == VNODE_DIR))
        return -EISDIR;  // 不能写目录

    // 分配 fd
    int fd = allocate_fd();
    if (fd < 0) return -EMFILE;  // fd 表满

    // 初始化 fd
    fd_table[fd].node = node;
    fd_table[fd].flags = flags;
    fd_table[fd].pos = 0;
    fd_table[fd].used = true;

    // 调用文件系统的 open
    if (node->fops->open)
        node->fops->open(node, &fd_table[fd]);

    return fd;
}

7.3 设备驱动注册

Cpp
// wandos 设备驱动注册(简化版)
void devtmpfs_init(void) {
    // 注册 /dev/null (major=1, minor=3)
    struct vnode *null = alloc_vnode();
    null->name = "null";
    null->type = VNODE_DEVICE;
    null->fops = &null_fops;    // null_fops 指向 null_read/null_write
    null->private_data = NULL;
    devtmpfs_register(null);

    // 注册 /dev/zero
    struct vnode *zero = alloc_vnode();
    zero->name = "zero";
    zero->type = VNODE_DEVICE;
    zero->fops = &zero_fops;
    devtmpfs_register(zero);
}

// /dev/null 的读写实现
ssize_t null_read(struct file_descriptor *fd, char *buf, size_t count) {
    return 0;  // 读 null 返回 0(EOF)
}

ssize_t null_write(struct file_descriptor *fd, const char *buf, size_t count) {
    return count;  // 写 null 丢弃,返回写入字节数
}

static struct file_operations null_fops = {
    .open = NULL,  // 设备节点不需要 open
    .read = null_read,
    .write = null_write,
};

7.4 wandos 和 Linux 的主要差异

  1. 没有 dentry cache:Linux 维护完整的 dcache 来加速路径解析;wandos 的 path_walk 每次都重新解析,没有缓存
  2. 没有页缓存(page cache):Linux 把磁盘文件缓存在内存里,读写先操作 page cache;wandos 直接操作磁盘,没有缓存层
  3. 没有 writeback:Linux 有延迟写机制(dirty page);wandos 写操作直接落盘(慢,但可靠)
  4. 没有权限检查:Linux 在 VFS 层做权限检查(capability、ACL);wandos 没有这层

8. 动手环节:实现一个简单的字符设备驱动

今天的目标:在 os-kernel-from-scratch 里实现一个 /dev/hello 设备节点,读它返回 “Hello from kernel!”,写它打印到 VGA 屏幕。

任务 1:定义 file_operations

C
struct file_operations hello_fops = {
    .read = hello_read,
    .write = hello_write,
};

任务 2:实现 hello_read / hello_write

hello_read 把 “Hello from kernel!n” 写入用户 buffer。hello_write 把用户 buffer 内容打印到 VGA 屏幕。

任务 3:注册设备到 VFS

在模块初始化时调用 register_device("hello", &hello_fops),在 /dev 下创建 hello 节点。

验收标准

  • cat /dev/hello 输出 “Hello from kernel!”
  • echo "world" > /dev/hello 在屏幕上显示 “world”
  • ls -l /dev/hello 显示设备类型(c 或 b)

9. 踩坑与注意事项

坑 1:硬链接和软链接的区别

硬链接(ln)是在同一个文件系统里创建另一个 dentry,指向同一个 inode。没有”原始文件”和”链接文件”的区别——它们是平等的。删除任何一个,inode 引用计数减 1,直到计数为 0 才真正删除文件。软链接(ln -s)是一个特殊文件,里面存了路径字符串,可以跨文件系统,删除原文件后软链接仍然存在(指向不存在的文件)。

坑 2:目录也是 inode

目录也是一种 inode,有自己的 file_operations。目录的 read() 读出来的是 dirent 结构(目录项列表)。ls 就是通过读目录的 dirent 列表来显示文件名。

坑 3:page cache 和 buffer cache 混淆

Linux 有两层缓存:page cache(页缓存,用于文件 I/O)和 buffer cache(缓冲区缓存,用于块设备元数据)。两者可以重叠——读文件时先查 page cache,块设备读写时用 buffer cache。现代 Linux 统一用 page cache,buffer cache 已被淘汰。不要混淆。

坑 4:fd 和 inode 混淆

文件描述符(fd)是进程打开文件表里的索引(0-1023),每个进程有自己的 fd 表。inode 是文件系统里的唯一标识,两个进程可以各自打开同一个文件(得到不同的 fd),但共享同一个 inode。


写在最后

VFS 是 Linux 最优雅的设计之一——它用函数指针表把完全不同的事物(磁盘文件、进程信息、设备节点、网络 socket)用同一套接口(open/read/write/ioctl)统一起来,让应用程序不需要知道底层是什么。”一切皆文件”不是一句口号,而是通过 VFS 的 inode/dentry/file 三层结构实实在在实现的。

理解 VFS,你才算真正理解为什么 cat /proc/cpuinfocat /dev/zero 能用同一个 read 系统调用搞定,以及为什么 Unix 的哲学如此强大。

下篇预告:同步原语——spinlock 与信号量的实现


相关阅读


动手环节

想深入理解本文内容?动手实践是最好的方式:

今天的目标:下载 wandos 代码仓库,添加一个自定义设备驱动,理解 Linux vs wandos 的差异。

  1. 下载 wandos

    Bash
    git clone https://github.com/zhangfuwen/wandos.git
    cd wandos
  2. 找到对应模块:查看 kernel/fs/vfs.c

  3. 实现作业:根据文中”动手环节”章节的要求,完成代码编写

  4. 提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request


仓库:https://github.com/golang12306/os-kernel-from-scratch

最后修改: 2024年10月18日

作者

评论

发表评论

您的邮箱地址不会被公开。