核心内容摘要
mhgngfmhx
前言在学习**虚拟文件系统VFS**之前我们应该先了解一下它的出现是为了解决什么问题。
不论是在以前还是现在Linux 都支持着好几十种文件系统类型我们在 Shell 里输入ls -l看到的都是一行描述文件信息的字但它们背后的世界却完全不同下面简单介绍几个文件系统EXT4文件Linux 的标准文件系统。
它是硬盘盘片上磁性颗粒的翻转或者是 SSD 里的电荷状态。
我们读取这类文件时需要驱动程序去操作SATA/NVMe 控制器向传统机械硬盘或者现代高性能固态硬盘发送指令把数据从磁盘扇区读到内存。
这种文件的特点是断电后数据还在。
FAT32文件一种比较古老的标准但至今仍然广泛用于嵌入式系统和 U 盘它的设计原理是链表结构。
相比 Linux 原生文件系统最大的区别是FAT32 没有 Inode元数据直接塞在了目录项中。
此外由于它自身链表结构的局限性读取文件尾部数据的效率极低因为必须从头遍历链表。
PROCFS文件相比前面介绍的两个文件系统procfs文件通常挂载在/proc完全打破了我们对文件的固有认知它是无实体的。
举个例子我们在/proc/meminfo文件中看到的每一个字节都不存在于磁盘上面。
可能会有人好奇文件里面的数据怎么来的这里简单介绍一下procfs是一种接口映射当对/proc下的文件发起read()请求时内核并不会去读硬件而是触发了一系列回调函数函数中会统计需要的信息并转换为字符串拷贝给用户程序。
打个比方吧对于普通的文件可以把他当做一个仓库你打开仓库门东西就在里面放着。
而对于这类文件它只是一个类似于传送门的东西门后面什么都没有你打开门的这个操作会触发一系列连锁反应从而将你需要的数据传送过来。
这里只介绍这三种比较经典的文件系统。
现在试着想象一下你在编写内核的sys_read系统调用我们需要对上面的三种情况分别进行处理对于 EXT4 文件需要读磁盘解析 B-Tree 结构操作 Inode。
对于 FAT32 文件需要读磁盘解析 FAT 链表而且由于没有 InodeLinux 为了统一管理需要强行给 FAT32 创建一个 Inode。
对于 PROCFS 文件不能读磁盘需要去调用内核内部的某个统计函数来获取信息。
这不仅意味着每增加一种新的文件系统都要重写内核核心代码还意味着应用程序的开发者需要了解底层的每一个细节这对应用程序的开发也会带来巨大的不便。
这就是 VFS 诞生的原因它不是为了创造一种新的文件存储格式而是为了平息这场各种文件系统百家争鸣的混乱局面强行制定一种通用的标准的文件系统层也就是 VFS 虚拟文件系统。
显然VFS 作为 Linux 内核提供的软件抽象层必然是位于系统调用之下各种不同文件系统之上的。
对上VFS 向用户程序提供统一的视角抹平底层文件系统的差异。
对下要求 EXT
FAT
PROCFS 必须适配这套接口。
哪怕 FAT32 没有 Inode驱动程序也必须在内存里伪造一个给 VFS 看。
下面我们将结合内核源码看看 VFS 是如何实现这一设计的。
VFS 核心架构
的内容中提到过 VFS 是一个抽象层这往往意味着我们需要定义一套通用的接口让底层去设计具体的实现方式。
熟悉 C 和 java 的可能已经知道了这其实就是多态定义一个基类再写几个虚函数不就行了逻辑上说得过去但是 Linux 内核是用 C 语言写的而 C 语言本身并不支持类和继承。
那么编写 Linux 内核的那些大佬们是如何用 C 语言实现一套面向对象架构的呢答案就是极其朴素的结构体加上函数指针。
1 四个核心对象为了管理所有的文件系统VFS 抽象出了四个核心对象无论底层是 EXT
FAT32 还是 NFS在 VFS 这一层看来都必须被转换成这四个对象。
这四个对象实际上构成了 VFS 的骨架他们分别是Superblock 超级块 代表整个文件系统。
Inode 索引节点代表一个具体的文件。
Dentry 目录项代表路径和文件名。
File 文件对象代表进程打开的一个文件。
这四个概念尤其对于新手来说非常容易混淆在理解上也会存在不少误区这一小节我们结合内核源码详细拆解一下。
此外还需要提一点我使用的内核源码是 Linux
10 版本由于这四个对象涉及到的结构体通常都比较庞大我会对相关结构体的成员进行缩减只保留我们需要了解的核心的成员并以代码块的形式放在文中。
2.
1 超级块 struct super_block这是一个文件系统的根对象表示一个已经挂载的文件系统记录了块大小文件系统类型根目录等内容。
当我们在 Shell 中执行下面命令mount-t ext4 /dev/sda1 /mnt我们首先来拆解一下这个命令到底在干什么这条命令的作用是将/dev目录下的sda1这个文件挂载到/mnt目录下-t选项是type的缩写用来指定文件系统的类型。
总结一下就是把类型为EXT4的文件系统sda1挂载到/mnt目录下。
拆解完了这个命令我们继续往下。
在挂载的这条命令执行的同时内核会在内存中创建一个struct super_block结构体来代表这个挂载的具体文件系统实例而这个结构体存储的就是我们已经挂载的文件系统sda1的相关信息。
该结构体在内核源码中的位置为include/linux/fs.h。
核心成员参考下面代码块同时我添加了一些注释方便大家理解structsuper_block{structlist_heads_list;//系统中所有superblock的链表dev_ts_dev;//存储设备的设备号unsignedchars_blocksize_bits;//块大小的位数如4k就是12位2^124Kunsignedlongs_blocksize;//块大小以字节为单位loff_ts_maxbytes;//该文件系统支持的最大文件大小structfile_system_type*s_type;//指向该文件系统类型驱动conststructsuper_operations*s_op;//超级块的操作函数集structdentry*s_root;//指向该文件系统根目录的dentrystructlist_heads_inodes;//该super_block下所有inode的链表void*s_fs_info;//指向具体文件系统的私有数据/*其余成员省略*/};由于 Linux 系统需要统一集中管理超级块所以将所有struct super_block结构体都用链表串起来。
要注意这里使用的是侵入式链表在Linux 内核中经常使用这种链表我前面还写过一篇这个相关的内容想了解侵入式链表具体实现的可以去看看。
还包含一些内容就是我们前面提到的文件系统的块大小文件系统类型等信息。
一个重要的成员就是s_op它是一个函数指针表。
它的作用是当 VFS 需要写回脏的superblock或者分配inode时它就会调用sb-s_op-write_super()或sb-s_op-alloc_inode()。
这种函数指针表在 Linux 内核中很多结构体中都会出现对应的成员通常都是以_op结尾的后面我们还会看到几个。
这种操作函数集本质上就是将某个操作与相应的函数对应起来。
还有一个重要的成员是s_fs_info它是 VFS 实现抽象的关键。
打个比方说明一下它的作用VFS 其实并不知道 EXT4 的磁盘布局但 EXT4 需要在内存里存一些只有它自己懂的全局信息比如inode table在磁盘的哪一块。
EXT4 会分配一个struct ext4_sb_info用来存储那些只有它懂的信息并将s_fs_info指针指向它这就是 C 语言实现继承的方式。
2.
2 索引节点 struct inodestruct inode是 VFS 中最核心的概念它代表了文件在磁盘上的实体。
该结构体存储的内容包含文件的元数据大小权限所有者时间戳以及数据块在磁盘上的位置等。
但是要特别强调一点它并不存储文件名称。
这个其实很好理解就拿我们每个人自己来举例struct inode代表的是这个人的实体也就是这个人本身的一些属性比如身高体重多少肺活量等等而名字只是一个称呼而已它不论叫张三还是李四这些固有属性是不会变的。
记得
说的吗FAT32 磁盘上没有 Inode但 VFS 又必须要 Inode。
所以当 Linux 挂载 FAT32 时内核会在内存中现场捏造一个struct inode结构体填入必要的信息这就是 VFS 标准化的威力——不管你底层有没有这个东西上层需要你就必须有。
该结构体在源码目录的位置如下include/linux/fs.h。
structinode{umode_ti_mode;//访问权限和文件类型kuid_ti_uid;//所有者IDkgid_ti_gid;//组IDconststructinode_operations*i_op;//inode的操作集structsuper_block*i_sb;//反向指针指向所属的super_blockstructaddress_space*i_mapping;//指向页缓存的核心结构unsignedlongi_ino;//inode号loff_ti_size;//文件大小structtimespec64i_atime;//访问时间structtimespec64i_mtime;//修改时间structtimespec64i_ctime;//状态改变时间conststructfile_operations*i_fop;//默认的文件操作集, open, read, writestructaddress_spacei_data;union{structhlist_headi_dentry;//指向引用该inode的dentry链表structrcu_headi_rcu;};void*i_private;//具体文件系统的私有数据/*其他成员*/};在同一个super_block中i_ino唯一标识一个struct inode内核会在内存中缓存struct inode。
这里有一个容易混淆的点i_op和i_fop。
i_op涉及inode 本身或目录项创建的操作比如mkdirunlinklookup等等。
i_fop涉及文件内容的操作当文件被open后这个指针会被复制到struct file中struct file是我们后面要讲的i_fop的相关操作都有readwriteioctlmmap等。
i_mapping指向struct address_space结构体管理着文件的Page Cache页缓存对于普通文件它通常指向struct inode自身的i_data对于块设备文件它可能指向块设备的struct address_space。
2.
3 目录项 struct dentry上面我们已经讲过文件名不在struct inode里面那么它在哪呢答案是struct dentry目录项中。
Linux 为了加速文件查找不仅仅将目录看作一种特殊的文件还专门引入了struct dentry结构体在内存中缓存路径与struct inode的映射关系。
这里要先声明一下虽然我们把dentry叫做目录项但并不是只有目录才有dentry每一个文件都有自己的dentry。
DcacheDentry Cache是 VFS 性能的关键内核不会每次都去读取磁盘上的目录文件来解析路径而是将解析过的路径缓存在内存中的dentry树中以便下次访问。
strcut denrty这个结构体的定义位于内核源码目录include/linux/dcache.h。
主要成员如下structdentry{unsignedintd_flags;seqcount_spinlock_td_seq;//锁机制用于无锁查找structhlist_bl_noded_hash;//用于在dcache哈希表中查找structdentry*d_parent;//指向父目录的dentrystructqstrd_name;//文件名这里存储了字符串structinode*d_inode;//指向该dentry对应的inodeunsignedchard_iname[DNAME_INLINE_LEN];//短文件名直接存在这里不用分配内存structlockrefd_lockref;conststructdentry_operations*d_op;//dentry操作集structsuper_block*d_sb;//指向所属的superblockstructlist_headd_child;//挂入父目录的子节点链表structlist_headd_subdirs;//本目录下的子节点链表/*其他成员*/};d_name是一个快速字符串结构包含指向字符串的指针和字符串的哈希值VFS 在查找文件时先算哈希值再去d_hash哈希表中找速度极快。
对于d_inode有下面两种情况Positive Dentry:d_inode指向一个有效的inode。
Negative Dentry:d_inode为NULL这非常重要。
当你ls /tmp/bu_cun_zai_de_wen_jian时内核解析后发现文件不存在它依然会创建一个dentry但把d_inode设为NULL。
下次你再访问这个不存在的文件内核直接看缓存就知道不存在不用去读盘这也是一个性能优化的方式。
而d_parentd_childd_subdirs这些指针共同构成了内存中的目录树结构。
2.
4 文件对象 struct file这是用户态程序接触最多的对象当你调用open()系统调用成功后内核就会创建一个struct file结构体。
它代表一个打开的文件实例也就是进程与文件的一次交互对话。
该结构体在内核源码中的位置如下include/linux/fs.h。
structfile{union{structllist_nodefu_llist;structrcu_headfu_rcuhead;}f_u;structpathf_path;//包含{mnt, dentry}structinode*f_inode;//指向对应的inode也就是f_path.dentry-d_inodeconststructfile_operations*f_op;//操作函数集read, writespinlock_tf_lock;//锁atomic_long_tf_count;//引用计数unsignedintf_flags;//open时的标志(O_RDONLY, O_NONBLOCK等)fmode_tf_mode;//读写模式(FMODE_READ, FMODE_WRITE)loff_tf_pos;//当前文件读写位置structfown_structf_owner;void*private_data;//具体驱动或文件系统的私有数据structaddress_space*f_mapping;//指向 inode-i_mapping}__randomize_layout;在 Linux
10 内核版本dentry和vfsmount指针被封装在struct path中structpath{structvfsmount*mnt;//属于哪个挂载点structdentry*dentry;//指向哪个目录项};这充分说明了唯一确定一个打开的文件不仅需要知道它是哪个文件(dentry)还需要知道它是从哪个挂载点(mnt)访问的。
f_pos是struct file存在的最大意义之一两个进程打开同一个文件它们共享同一个struct inode但各自拥有独立的struct file和独立的f_pos。
所以进程 A 读前 100 字节不会影响进程 B 从头读取。
对于f_op 当open发生时VFS 会从inode-i_fop拷贝指针到file-f_op。
之后该文件的read/write操作都直接用file-f_op这允许驱动程序在open时动态替换操作集。
2 四个对象之间的协作关系上一节我们已经分别了解了四个核心结构体的功能这一节我们把他们串起来看看他们是怎样动态协作的。
最经典的场景莫过于两个进程打开同一个文件了。
为了搞懂这个问题我们需要理清从用户态的文件描述符fd到磁盘inode的完整索引链。
请看下图大家可以结合上图的箭头指向关系来理解下面的内容用户空间使用open打开一个文件得到这个打开的文件实例对应的文件描述符fd该fd存放在进程控制块(task_struct)中的文件描述符表中。
当用户空间进行系统调用read(fd, buf, len)时CPU 陷入内核态。
内核通过进程控制块task_struct中的文件描述符表files_struct用fd作为索引找到了一个指向struct file的指针。
找到了struct file就等于找到了这个打开的文件实例。
上面已经提到过struct file中存放着f_pos也就是当前读写位置。
这就是为什么进程 A 读到了第 100 字节而进程 B 刚打开文件还是从 0 开始读因为它们各自拥有独立的struct file结构体他们的读写位置f_pos也是独立的。
这也恰恰说明了为什么我们会把struct file对应一个打开的文件实例大家可以仔细体会一下**“打开的文件实例”**这个词。
我们知道struct file中并没有文件名内核是通过f_path.dentry指针找到struct dentry的。
在struct dentry中内核确认了文件名并确认了它在目录树中的位置。
如果这是一个硬链接文件它有着自己独立的struct dentry这就意味着它也有着自己独立的文件名但这个硬链接的struct dentry和原文件的struct dentry最终都会指向同一个struct inode。
到现在我们终于找到了文件的真身struct inode。
不管有多少个进程打开它也不管有多少个硬链接指向它在内存中针对该物理文件struct inode 永远只有一个。
这里存储着文件的物理大小、权限以及操作磁盘的函数集合i_op。
如果需要读取磁盘数据它会通过struct inode中的i_sb指针找到所属的super_block从而获取块大小、文件系统类型等全局信息最终驱动硬件完成数据拷贝。
3 以VFS 的视角看Linux 的文件特性理解了上面那张图就基本掌握了 Linux 文件系统。
很多看似玄学的 Linux 文件特性如果从 VFS 的数据结构出发一切都变得合理甚至有趣起来。
我们从 VFS 的角度重新审视几个经典的场景
2.
1 进程间共享文件进程 A 和进程 B 同时打开了同一个日志文件a.log进程 A 正在写入第 100 行进程 B 正在读取第 1 行。
为什么它们不会干扰到对方的工作站在 VFS 的角度来分析这个场景这个文件对于两个进程来说既是隔离的又是共享的。
隔离由于两个进程 A 和 B 都打开了a.log文件因此他们各自都拥有一个独立的struct file每个struct file内部都有一个f_pos成员来记录当前的读写偏移量。
A 的f_pos指向文件尾B 的f_pos指向文件头互不影响。
共享两个进程独立的struct file最终都指向同一个struct inode这意味着它们操作的是同一块物理磁盘空间如果 A 修改了文件内容B 读到的也会是修改后的内容这就涉及到页缓存 Page Cache 的同步机制不是我们本篇文章的重点。
2.
2 硬链接本质我们在 Shell 中执行命令ln a.txt b.txt会发现a.txt和b.txt拥有相同的Inode号修改其中一个另一个也变了删除其中一个文件数据却还在。
这是因为硬链接的本质是N 个 Dentry 指向 1 个 Inode。
当你创建硬链接时内核并没有复制文件数据仅仅是新建了一个struct dentry名为b.txt并将它的d_inode指针指向了原有的那个struct inode同时该Inode内部的引用计数i_nlink加 1。
当你执行rm a.txt时内核只是删除了a.txt这个dentry并将inode-i_nlink减 1。
只有当i_nlink变为 0 时内核才会真正释放Inode和磁盘上的数据块。
理解了引用计数的原理就理解了为什么删除原文件a.txt之后硬链接a.txt依然能够访问文件数据。
大家再来思考一下另一个问题为什么 Linux 不允许给目录做硬链接为什么硬链接不能跨分区其实原理前面也讲过了每个 Superblock 超级块都管理着自己的 Inode 编号也就是说对于两个不同的 Superblock即使 Inode 相同那也完全是两码事。
而 dentry 只能指向同一个 Superblock 下的 Inode没法指到别人的地盘去。
至于为什么目录不能做硬链接原理就更简单了想象一下你现在已经给一个目录做了硬链接并且你正处于该目录的一个子目录中这时你执行cd ..命令会切换到那个文件答案是不确定。
要想追究其深层原理我们需要知道文件系统需要保证从根目录/到任何文件只有唯一一条路径。
如果可以给目录做硬链接那么显然违背了这条定理。
2.
3 软链接本质我们执行命令ln -s a.txt link_a就创建了a.txt文件的软链接link_a这类似于 Windows 的快捷方式。
软链接和硬链接完全不同它是一个独立的文件。
软链接有自己独立的struct dentry也有自己独立的struct inode。
普通文件的Data Block存的是文件内容而软链接文件的 Data Block 存的是目标文件的路径字符串。
当 VFS 访问软链接时发现其 Inode 类型是S_IFLNK于是内核会读取它存储的路径触发一次重定向重新去解析那个新的路径。
2.
4 mv 移动文件原理假如你有一个 100GB 的大文件在同一个磁盘分区内把它从/download目录移动到/movie目录这通常是瞬间完成的。
为什么能瞬间完成呢这是因为根本没有发生数据搬运。
mv在 VFS 层面只是修改了目录树的指针关系内核把代表电影文件的dentry从/download的子节点链表摘下来挂到了/movie的子节点链表上文件背后的 Inode 和磁盘上的 100GB 数据纹丝不动。
注意如果是跨分区移动比如mv /dev/sda1/file /dev/sdb1/file那就必须先拷贝数据创建新的 Inode再删除旧文件这时候就很慢了。
原理上面也讲过dentry不能指向别的分区。
3.
总结到现在本篇文章的内容也已经不少了但要说讲了什么其实重点并不多仅仅是四个核心结构体大部分的文字都是对这些结构体的解释以及一些例子帮助大家能够理解这四个结构体的协作关系。
还讲了在 VFS 视角下我们常见的一些场景相信大家读完这篇文章那些概念已经不再是一些冰冷的文字组合而是实实在在的刻在脑海中的对 Linux 虚拟文件系统的理解。
以后当别人问起你对硬链接的看法时你不再是机械式的背诵书本上的概念而是从创建一个硬链接说起一直到删除这个硬链接时的引用计数减一详细的描述内核到底干了什么。
这就是我们学习底层原理要达到的效果。
本篇文章是我们深入学习 VFS 的上篇在这篇文章中虽然我们已经对 VFS 有了一个大致的了解但还只是停留在比较浅的层次。
稍后发的下篇内容将会更加深入内核源码从具体的系统调用入手看看系统是怎样查找一个具体文件的路径的。
本篇完。