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_lock 和 rename_lock。
3.4 三个对象的关系
咱们用一张图来理清它们的关系:
说白了,超级块是“全局管理者”,inode是“文件本体”,dentry是“名字标签”。你打开一个文件时,内核先通过路径查找dentry,dentry指向inode,inode再通过超级块找到具体文件系统的操作函数。这一套组合拳打下来,任何文件系统都能被VFS统一管理。
我记得有一次调试一个网络文件系统,发现dentry缓存一直不命中,性能极差。后来查出来是dentry的哈希函数没实现好,导致大量冲突。嗯,从那以后我写文件系统驱动,第一件事就是检查dentry_operations里的 d_hash 和 d_compare 是否合理。
个人经验: 实现自定义文件系统时,建议先实现超级块的 fill_super 函数。它负责初始化超级块、创建根目录的inode和dentry。这一步通了,后面的路就好走了。
好了,这三个核心数据结构就讲到这里。它们就像文件系统的“三驾马车”,理解了它们,你就掌握了VFS的骨架。下一节咱们继续聊剩下的几个关键结构体——文件对象和地址空间。
公众号:蓝海资料掘金营,微信deep3321