Linux 文件系统原理 / 虚拟文件系统VFS
- 虚拟文件系统 VFS
- VFS 定义
- VFS 的对象演绎
- 超级块 super_block
- 索引节点 inode
- 目录项 dentry
- 文件 file
- 打开文件流程
- 参考文献
虚拟文件系统 VFS
VFS 定义
VFS是一个抽象层,其向上提供了统一的文件访问接口,而向下则兼容了各种不不同类型的文件系统。不仅仅是诸如Ext2、Ext3、Ext4、XFS、windows家族的NTFS和Btrfs等常规意义上的文件系统,还可以是比如上图的proc等伪文件系统和设备,也可以是诸如NFS、CIFS等网络文件系统。
VFS 采用标准的Linux系统调用读写位于不同物理介质上的不同文件系统,即为各类文件系统提供了一个统一的操作界面和应用编程接口, VFS是一个内核软件层 。 VFS是一个可以让open()、read()、write()等系统调用不用关心底层的存储介质和文件系统类型就可以工作的 抽象层 ,如下图所示:
VFS 的对象演绎
虚拟文件系统在磁盘中并没有对应的存储的信息。尽管 Linux 支持多达几十种文件系统,但这些真实的文件系统并不是一下子都挂在系统中的,它们实际上是按需挂载的。另外,这些实的文件系统只有安装到系统中,VFS 才予以认可,也就是说, VFS 只管理挂载到系统中的实际文件系统 。
VFS 有 4 个主要对象:
-
超级块(Superblock)
:存放系统中已安装文件系统的有关信息。 -
文件索引节点(inode)
:存放关于具体文件的一般信息。 -
目录项对象(dentry)
:存放目录项与对应文件进行链接的信息。路径中的每一个部分被称作目录项,例如 /home/clj/myfile 中,根目录是 / ,而 home,clj 和文件 myfile 都是目录项。
-
文件对象(file)
:存放打开文件与进程之间进行交互的有关信息。
超级块是对一个 文件系统 的描述 ; 索引节点是对一个 文件物理属性 的描述 ; 而目录项是对一个 文件逻辑属性 的描述 。
超级块 super_block
超级块用来描述整个文件系统的信息,包括文件系统的大小、有多少是空的和已经填满的占多少,以及他们各自的总数和其他诸如此类的信息。超级块占用1号物理块,就是文件系统的控制块 ,要 使用一个分区来进行数据访问,那么第一个要访问的就是超级块 。所以,超级块坏了,那磁盘也就基本没救了。
当内核在对一个文件系统进行初始化和注册时 在内存为其 分配一个超级块 。此时的超级块为 VFS 超级块 。也就是说,VFS 超级块是各种具体文件系统在安装时建立的,并在这些文件系统卸载时被自动删除。 VFS 超级块只存放在内存中 。
对于每个具体的文件系统来说,都有各自的超级块,如 Ext2 超级块和 Ext3 超级块,它存放在磁盘上,内容包括:文件系统的大小、空闲块数目、空闲块索引表、空闲i节点数目、空闲i节点索引表、封锁标记等。超级块是系统为文件分配存储空间、回收存储空间的依据。这一部分的拓扑结构如下图:
其中 Block Group
存储的各部分含义如下:
- indoe bitmap (indoe对照表): 用来记录当前文件系统的indoe哪些是已经使用的,哪些又是未使用的。
- block bitmap (块对照表): 用来记录当前文件系统哪些block已经使用,哪些又是未使用的。
- inode table (inode 表格):inode是用来记录文件的属性以及该文件实际数据所在的block的号码。
- GDT(Global Descriptor Table):用来描述每个block group开始和结束的block号码以及每个区段位于哪一个block号码之间。相当于文件系统描述的是每个block group的信息。
- data blocks:数据块,用于存放数据
超级块的数据结构定义如下:
struct super_block
{
dev_t s_dev; //
unsigned long s_blocksize; // 以字节为单位数据块的大小
unsigned char s_blocksize_bits; // 块大小的值所占用的位数,
...
struct list_head s_list; // 指向超级块链表的指针
struct file_system_type *s_type; // 指向文件系统的 file_system_type 的指针
struct super_operation *s_op; // 指向具体文件系统的用于超级块操作的函数集合
struct mutex s_lock;
struct list_head s_dirty;
...
void *s_fs_info; // 指向具体文件系统的超级块
};
从上面定义的数据结构可知:所有的超级块对象都以双向循环链表的形式链接在一起。链表中第一个元素用 super_blocks 变量来表示。
与超级块关联的方法就是所谓的超级块操作表,其数据结构是 super_operations,定义如下:
struct super_operations
{
void (*write_super) (struct super_block *); // 将超级块的信息写回磁盘
void (*put_super) (struct super_block *); // 释放超级块对象
void (*read_inode) (struct inode *); // 读取某个文件系统的索引节点
void (*write_inode) (struct inode *, int); // 把索引节点写回磁盘
void (*put_inode) (struct inode *); // 逻辑上释放索引节点
void (*delete_inode) (struct inode *); // 从磁盘上删除索引节点
};
索引节点 inode
文件系统处理文件所需要的所有信息都存放在索引节点中。 在同一个文件系统中,每个索引节点号都是唯一的 。具体文件系统的索引节点是存放在磁盘上,是一种静态结构,要使用它,必须调入内存,填写 VFS 的索引节点,因此,也称 VFS 索引节点是 动态节点 。
我们的磁盘在进行分区、格式化的时候会分为两个区域, 一个是 数据区 ,用于存储文件中的数据 ; 另一个是 inode区 ,用于存放 inode table
(inode表) , inode table
中存放的是一个一个的 inode
(也称为inode节点),不同的 inode
就可以表示不同的文件,每一个文件都必须对应一个 inode
, inode
实质上是一个结构体,这个结构体中有很多的元素,不同的元素记录了文件了不同信息,譬如:
- 文件字节大小
- 文件所有者
- 文件对应的读/写/执行权限
- 文件时间戳(创建时间、更新时间等)
- 文件类型
- 文件数据存储的block(块)位置
- ………
inode 结构体
定义在<linux/fs.h>
中,主要包含:存放的内容如下:
struct inode
{
struct list_head i_hash; // 指向哈希表的指针
struct list_head i_list; // 指向索引节点链表的指针
struct list_head i_dentry; // 指向目录项链表的指针
...
unsigned long i_ino; // 索引节点号
umode_t i_mode; // 文件的类型与访问权限
kdev_t i_rdev; // 实际设备标识号
uid_t i_uid; // 文件拥有者标识号
gid_t i_gid; // 文件拥有者所在组的标识号
...
struct inode_operations *i_op; // 指向对该节点进行操作的一组函数
struct super_block *i_sb; // 指向该文件系统超级块的指针
atomic_t i_count; // 当前使用该节点的进程数,计数为0时,表明该节点可丢弃或重新使用
struct file_operations *i_fop; // 指向文件操作的指针
...
struct vm_area_struct *i_op; // 指向对文件进行映射所使用的虚存区指针
unsigned long i_state; // 索引节点的状态标志
unsigned int i_flags; // 文件系统的安装标志
union // 联合结构体,其成员指向具体文件系统的 inode 结构
{
struct minix_inode_info minix_i;
struct Ext2_inode_info Ext2_i;
}
};
inode
的索引流程如图所示(注意, 文件名并不是记录在 inode
中 ):
所以由此可知, inode table
本身也需要占用磁盘的存储空间。在同一个文件系统中,每一个文件都有唯一的一个 inode
,每一个 inode
都有一个与之相对应的数字编号 ,内核可以根据索引节点号的哈希值查找其 inode 结构,前提是内核要知道索引节点号和对应文件所在文件系统的超级块对象的地址。 在Linux系统下,我们可以通过"ls -i"命令查看文件的 inode编号
,如下所示:
上图中 ls
打印出来的信息中,每一行前面的一个数字就表示了对应文件的 inode编号
。除此之外,还可以使用 stat
命令查看,用法如下:
与索引节点关联的方法叫索引节点操作表,由 inode_operations
结构来描述:
struct inode_operations
{
// 创建一个新的磁盘索引节点
int (*create) (struct inode *, struct dentry *, int);
// 查找一个索引节点所在的目录
struct dentry * (*lookup) (struct inode *, struct dentry *);
// 创建一个新的硬链接
int (*link) (struct dentry *, struct inode *, struct dentry *);
// 删除一个硬链接
int (*unlink) (struct inode *, struct dentry *);
// 为符号链接创建一个新的索引节点
int (*symlink) (struct inode *, struct dentry *, const char *);
// 为目录项创建一个新的索引节点
int (*mkdir) (struct inode *, struct dentry *, int);
// 删除一个目录项的索引节点
int (*rmdir) (struct inode *, struct dentry *);
};
目录项 dentry
每个文件除了有一个索引节点inode数据结构外,还有一个目录项dentry数据结构。 目录项反应了文件系统的树状结构,目前主流的操作系统基本都是用树状结构来组织文件的。linux也不例外。dentry表示一个目录项,目录项下面又有子目录。
dentry
结构代表的是逻辑意义上的文件,描述的是文件逻辑上的属性, 目录项对象在磁盘上并没有对应的映像 。inode
结构代表的是物理意义上的文件,记录的是物理上的属性,对于一个具体的文件系统, 其inode结构在磁盘上就有对应的映像 。
**一个索引节点对象可能对应多个目录项对象(因为路径的每一部分称作目录项,而文件的路径很长)。**目录项由dentry
结构体标识,定义在<linux/dcache.h>
中,主要包含:
struct dentry
{
atomic_t d_count; // 目录项引用器
unsigned int d_flags; // 目录项标志
struct inode *d_inode; // 与文件名关联的索引节点
struct dentry *d_parent; // 父目录的目录项
struct list_head d_hash; // 目录项形成的哈希表
struct list_head d_lru; // 未使用的 LRU 链表
struct list_head d_child; // 父目录的子目录项所形成的链表
struct list_head d_subdirs; // 该目录项的子目录所形成的的链表
struct list_head d_alias; // 索引节点别名的链表
int d_mounted; // 目录项的安装点
struct qstr d_name; // 目录项名(可快速查找)
struct dentry_operations *d_op; // 操作目录项的函数
struct super_block *d_sb; // 目录项树的根
unsigned long d_vfs_flags;
void *d_fsdata; // 具体文件系统的数据
unsigned char d_iname[DNAME_INLINE_LEN]; // 短文件名
...
};
目录项有三种状态:
-
被使用:该目录项指向一个有效的索引节点,并有一个或多个使用者,不能被丢弃。
-
未被使用:也对应一个有效的索引节点,但VFS还未使用,被保留在缓存中。如果要回收内存的话,可以撤销未使用的目录项。
-
负状态:没有对应有效的索引节点,因为索引节点被删除了,或者路径不正确,但是目录项仍被保留了。
将整个文件系统的目录结构解析成目录项,是一件费力的工作,为了节省VFS操作目录项的成本,内核会将目录项缓存起来。
文件 file
文件对象是进程打开的文件在内存中的实例。Linux用户程序可以通过open()
系统调用来打开一个文件,通过close()
系统调用来关闭一个文件。由于多个进程可以同时打开和操作同一个文件, 所以同一个文件,在内存中也存在多个对应的文件对象,但对应的索引节点和目录项是唯一的 。
一个进程所处的位置是由 fs_strcut 来描述的 , 而一个进程(或者用户)打开的文件是由 files_struct
/ fdtable
来描述的 , 而整个系统所打开的文件是由 file
结构来描述的 。
文件对象由file
结构体表示,file 结构形成了一个双链表,称为系统打开文件表。其定义在<linux/fs.h>
中,主要包含:
struct file
{
struct list_head f_list; // 所有打开的文件形成一个链表
struct dentry *f_dentry; // 与文件相关的目录项对象
struct vfsmount *f_mount; // 该文件所在的已安装文件系统
struct file_operations *f_op; // 指向文件操作表的指针
mode_t f_mode; // 文件的打开模式
loff_t f_pos; // 文件的当前位置
unsigned short f_flags; // 打开文件时所指定的标志
unsigned short f_count; // 使用该结构的进程数
...
};
对文件进行操作的一组函数叫文件操作表,由 file_operations
结构描述,如下:
struct file_operations
{
// 修改文件指针
loff_t (*llseek) (struct file *, loff_t, int);
// 从文件中读取若干个字节
ssize_t (*read) (struct file *, char *, size_t, loff_t *);
// 给文件中写若干个字节
ssize_t (*write) (struct file *, const char *, size_t, loff_t *);
// 文件到内存的映射
int (*mmap) (struct file *, struct vm_area_struct *);
// 打开文件
int (*open) (struct inode *, struct file *);
// 关闭文件时减少 f_count 计数
int (*flush) (struct file *);
// 释放 file 对象
int (*release) (struct inode *, struct file *);
// 文件在缓冲区的数据写回磁盘
int (*fsync) (struct file *, struct dentry *, int datasync);
...
};
文件描述符是用来描述打开的文件的。每一个进程用一个 files_struct 结构来记录文件描述符的使用情况,即一个进程可以有多个文件描述符,因为一个进程可以打开多个文件。而通过 dup()、dup2() 和 fcntl() 两个文件描述符可以指向同一个打开的文件,数组的两个元素可能指向同一个文件对象。
每个进程都有自己的根目录和当前工作目录,内核使用 struct fs_struct
来记录这些信息,其定义为:
struct fs_struct
{
atomic_t count; // 表示共享同一 fs_struct 表进程数目
rwlock_t lock;
int umask; // 为新创建的文件设置初始文件许可权
struct dentry *root, *pwd, *altroot; // 对目录项的描述
struct vfsmount *rootmnt, *pwdmnt, *altrootmnt; // 目录安装点的描述
};
除了根目录和当前工作目录,进程还需要记录自己打开的文件。进程已经打开的所有文件使用 struct files_struct
来记录,进程描述符的 files
字段便指向该进程的files_struct结构。它是进程的私有数据,其定义如下:
struct files_struct
{
atomic_t count; // 共享该表的进程数
rwlock_t file_lock; // 保护以下的所有域
int max_fds; // 当前文件对象的最大数
int max_fdset; // 当前文件描述符的最大数
int next_fd; // 已分配的文件描述符加 1
struct file ** fd; // 指向文件对象指针数据的指针
fd_set *close_on_exec; // 指向指向 exec() 时需要关闭的文件描述符
fd_set *open_fds; // 指向打开的文件描述符的指针
fd_set close_on_exec_init; // 执行 exec() 时需要关闭的文件描述符的初值集合
fd_set open_fds_init; // 文件描述符的初值集合
struct file *fd_array[32]; // 文件对象指针的初始化数组
};
旧版本的内核中, struct files_struct
中有一个 fd字段
,指向文件对象的指针数组。通常fd指向fd_array,如果进程打开的文件数目多于32个,内核就分配一个新的更大的文件对象的指针数组,并将其地址存放在fd字段中,这个数组所包含的元素数目存放在 max_fds字段
。
新版本的内核将 fd
, max_fds
以及其他几个相关字段组织在一起,增加一个新的独立数据结构 struct fdtable
,称为 文件描述符表 ,定义于 include/linux/fdtable.h
,其主要数据结构定义如下所示:
struct fdtable {
unsigned int max_fds;
struct file __rcu **fd; /* current fd array */
unsigned long *close_on_exec;
unsigned long *open_fds;
struct rcu_head rcu;
};
打开文件流程
文件在没有被打开的情况下一般都是存放在磁盘中的,譬如电脑硬盘、移动硬盘、U盘等外部存储设备, 文件存放在磁盘文件系统中,并且以一种固定的形式进行存放,我们把他们称为 静态文件 。
当我们调用 open
函数去打开文件的时候,内核会申请一段内存(一段缓冲区),并且将静态文件的数据内容从磁盘这些存储设备中读取到内存中进行管理、缓存 (也 把内存中的这份文件数据叫做 动态文件 、内核缓冲区 )。 打开文件后,以后对这个文件的读写操作,都是针对内存中这一份动态文件进行相关的操作,而并不是针对磁盘中存放的静态文件 。
当我们对动态文件进行读写操作后,此时内存中的动态文件和磁盘设备中的静态文件就不同步了, 数据的同步工作由内核完成,内核会在之后将内存这份动态文件更新(同步)到磁盘设备中 。
因为磁盘、硬盘、U盘等存储设备基本都是Flash块设备,因为块设备硬件本身有读写限制等特征,块设备是以一块一块为单位进行读写的(一个块包含多个扇区,而一个扇区包含多个字节), 一个字节的改动也需要将该字节所在的block全部读取出来进行修改,修改完成之后再写入块设备中,所以导致对块设备的读写操作非常不灵活 ;而 内存可以按字节为单位来操作,而且可以随机操作任意地址数据,非常地很灵活 ,所以对于操作系统来说,会先将磁盘中的静态文件读取到内存中进行缓存,读写操作都是针对这份动态文件,而不是直接去操作磁盘中的静态文件,不但操作不灵活,效率也会下降很多,因为内存的读写速率远比磁盘读写快得多。
在Linux系统中,内核会为每个进程设置一个专门的数据结构用于管理该进程,譬如记录进程的状态信息、运行特征等,我们把这个称为 进程控制块(Process control block,PCB)
。
PCB数据结构体中有一个指针指向了 文件描述符表(File descriptors)
,文件描述符表中的每一个元素索引到对应的 文件表(File table)
,文件表也是一个数据结构体,其中记录了很多文件相关的信息,譬如 文件状态标志、引用计数、当前文件的读写偏移量以及i-node指针(指向该文件对应的inode)等
,进程打开的所有文件对应的文件描述符都记录在文件描述符表中,每一个文件描述符都会指向一个对应的文件表,其示意图如下所示:
通过以上介绍可知,打开一个文件,系统内部会将这个过程分为三步:
- 系统找到这个文件名所对应的
inode编号
; - 通过
inode编号
从inode table
中找到对应的inode结构体
; - 根据
inode结构体
中记录的信息,确定文件数据所在的block
,并读出数据。
参考文献
1:文件系统理论详解,Linux操作系统原理与应用_Great Macro的博客-CSDN博客
2:Linux内核的5个子系统 - schips - 博客园
3:理解linux文件系统(VFS主要数据结构及之间的关系) - 周围静地出奇 - 博客园
4:文件系统入门知识_Linux教程_Linux公社-Linux系统门户网站
5:Linux 文件系统详解_hguisu的博客-CSDN博客
6:谈谈linux内核学习:虚拟文件系统(VFS) - 知乎
如有疑问或错误,欢迎和我私信交流指正。
版权所有,未经授权,请勿转载!
Copyright © 2023.05 by Mr.Idleman. All rights reserved.