3. VFS核心数据结构(上):超级块对象、索引节点对象、目录项对象

好,咱们今天来啃VFS最核心的三个数据结构。说实话,这三个对象搞明白了,整个文件系统的骨架你就拿下了。我当年刚接触VFS时,被这些结构体绕得晕头转向,后来发现——其实它们就是三个不同维度的“描述符”。

3.1 超级块对象(super_block)

超级块,说白了就是整个文件系统的“户口本”。它记录了这个文件系统的全局信息——总大小、块大小、挂载点、操作函数表等等。每个挂载的文件系统,在内存里都有一个对应的超级块实例。

我个人习惯把超级块想象成“文件系统的身份证”。你想想看,内核要管理几十个不同类型的文件系统,ext4、btrfs、xfs……每个都得有个统一的结构来描述它。这就是super_block存在的意义。

核心字段一览:

字段名 类型 含义
s_fs_info void * 指向具体文件系统的私有数据
s_dev dev_t 块设备标识符
s_blocksize unsigned int 块大小(字节)
s_op struct super_operations * 超级块操作函数表
s_root struct dentry * 根目录的目录项
s_inodes struct list_head 所有inode的链表头
s_dirty struct list_head 脏inode链表

这里我要特别提一下 s_fs_info。这个字段太关键了——它是VFS层和具体文件系统之间的“桥梁”。比如ext4的超级块,会把它的ext4_sb_info结构体指针塞到这里。我在项目中遇到过一个问题:忘记在卸载时释放这个私有数据,结果内存泄漏查了两天……嗯,血的教训。

避坑指南: 实现超级块时,一定要在 kill_sb 回调里清理 s_fs_info。我曾经漏掉这一步,导致系统长时间运行后内存耗尽。

3.2 索引节点对象(inode)

inode,索引节点。它描述的是文件系统里的一个“对象”——可以是文件、目录、设备节点、符号链接等等。每个文件都有唯一的inode号,就像人的身份证号一样。

你可能会问:为什么不用文件名来标识?嗯,文件名是给人看的,inode号是给内核用的。文件名可以改,但inode号不变。这就是为什么你可以重命名文件而不影响它的内容。

inode里存的是文件的元数据——权限、大小、时间戳、数据块指针……但不包括文件名。文件名在哪儿?在目录项里。这个分工很清晰:inode管“是什么”,dentry管“叫什么”。

inode的核心字段:

struct inode {
    umode_t          i_mode;      // 文件类型和权限
    uid_t            i_uid;       // 所有者UID
    gid_t            i_gid;       // 所属组GID
    loff_t           i_size;      // 文件大小(字节)
    struct timespec64 i_atime;    // 最后访问时间
    struct timespec64 i_mtime;    // 最后修改时间
    struct timespec64 i_ctime;    // 状态改变时间
    unsigned long    i_ino;       // inode号
    struct super_block *i_sb;     // 所属超级块
    struct address_space *i_mapping; // 地址空间(页缓存)
    const struct inode_operations *i_op; // inode操作函数表
    const struct file_operations  *i_fop; // 文件操作函数表
};

注意看 i_mapping 这个字段。它指向一个 address_space 结构体,负责管理这个文件的页缓存。说白了,你读写文件时,数据先到页缓存里,再通过这个映射刷到磁盘。我在做FUSE驱动时,就靠这个字段实现了用户态缓存策略。

还有一个容易忽略的点:i_mode 不仅存权限,还存文件类型。比如 S_IFREG 表示普通文件,S_IFDIR 表示目录。判断方法是用宏:S_ISREG(mode)S_ISDIR(mode)。别自己手写位运算,容易出错。

注意: inode的生命周期管理很讲究。VFS使用引用计数 i_count 来控制。当 i_count 降到0时,会调用 destroy_inode 释放。我曾经在调试一个文件系统时,发现inode泄漏——原来是某个路径忘了在put之后减引用。这种bug很难复现,但一旦出现,系统会慢慢死掉。

3.3 目录项对象(dentry)

dentry,目录项。它把文件名和inode关联起来。每个dentry代表路径中的一个分量。比如 /home/user/file.txt 这个路径,会分解成四个dentry:根目录、home、user、file.txt。

你想想看,如果没有dentry,内核每次访问文件都要从根目录开始逐层查找,那效率得多低?dentry缓存(dcache)就是为了解决这个问题的。它把最近用过的路径-文件映射关系缓存起来,下次直接命中,不用再走磁盘。

dentry有三种状态:

  • 被使用(used):dentry被VFS引用,且对应的inode有效
  • 负状态(negative):dentry存在,但对应的inode无效(比如文件被删了)
  • 未使用(unused):dentry存在,inode有效,但当前没有被引用

负状态这个设计很有意思。比如你访问一个不存在的文件,内核会创建一个负dentry并缓存起来。下次再访问同一个路径,直接返回“不存在”,省得再去磁盘查。当然,缓存时间不能太长,否则文件创建后你这边还是“不存在”。

dentry的核心字段:

struct dentry {
    struct qstr d_name;           // 文件名(快速字符串)
    struct inode *d_inode;        // 关联的inode(NULL表示负dentry)
    struct dentry *d_parent;      // 父目录项
    struct list_head d_child;     // 兄弟节点链表
    struct list_head d_subdirs;   // 子目录项链表
    const struct dentry_operations *d_op; // dentry操作函数表
    unsigned int d_flags;         // 标志位
};

这里有个细节:d_name 的类型是 struct qstr,它包含名字字符串、长度和哈希值。哈希值是为了快速比较和查找。VFS在查找路径时,会先计算哈希,然后到dcache里找——比字符串逐字节比较快得多。

我在项目中遇到过一个问题:dentry的 d_parent 指针在rename操作时可能会失效。如果你在遍历目录树时没有加锁,可能会访问到野指针。嗯,这个坑我踩过,后来老老实实加了 d_lockrename_lock

3.4 三个对象的关系

咱们用一张图来理清它们的关系:

超级块 (super_block) s_root → dentry s_inodes → inode链表 目录项 (dentry) d_name = "file.txt" d_inode → inode 索引节点 (inode) i_ino = 12345 i_sb → super_block s_root d_inode i_sb 三者关系:超级块管理所有inode,dentry将文件名映射到inode

说白了,超级块是“全局管理者”,inode是“文件本体”,dentry是“名字标签”。你打开一个文件时,内核先通过路径查找dentry,dentry指向inode,inode再通过超级块找到具体文件系统的操作函数。这一套组合拳打下来,任何文件系统都能被VFS统一管理。

我记得有一次调试一个网络文件系统,发现dentry缓存一直不命中,性能极差。后来查出来是dentry的哈希函数没实现好,导致大量冲突。嗯,从那以后我写文件系统驱动,第一件事就是检查dentry_operations里的 d_hashd_compare 是否合理。

个人经验: 实现自定义文件系统时,建议先实现超级块的 fill_super 函数。它负责初始化超级块、创建根目录的inode和dentry。这一步通了,后面的路就好走了。

好了,这三个核心数据结构就讲到这里。它们就像文件系统的“三驾马车”,理解了它们,你就掌握了VFS的骨架。下一节咱们继续聊剩下的几个关键结构体——文件对象和地址空间。


公众号:蓝海资料掘金营,微信deep3321