1. VFS概念
为了保证Linux的开放性,设计人员必须考虑如何使Linux除支持Ext2文件系统外,还能支持其他各种不同的文件系统。为此,就必须将各种不同文件系统的操作和管理纳入到一个统一的框架中,使得用户程序可以通过同一个文件系统界面,也就是同一组系统调用,能够对各种不同的文件系统以及文件进行操作。这样,用户程序就可以不关心各种不同文件系统的实现细节,而使用系统提供的统一、抽象、虚拟的文件系统界面。这种统一的框架就是所谓的虚拟文件系统转换(Virtual Filesystem Switch) ,一般简称虚拟文件系统(VFS)。
VFS是一个抽象层,其向上提供了统一的文件访问接口,而向下则兼容了各种不同的文件系统。不仅仅是诸如Ext2、Ext4、XFS和Btrfs等常规意义上的文件系统,还包括伪文件系统和设备等等内容。由图1可以看出,虚拟文件系统位于应用与具体文件系统之间,其主要起适配的作用。对于应用程序来说,其访问的接口是完全一致的(例如open、read和write等),并不需要关系底层的文件系统细节。也就是一个应用可以对一个文件进行任何的读写,不用关心文件系统的具体实现。另外,VFS实现了一部分公共的功能,例如页缓存和inode缓存等,从而避免多个文件系统重复实现的问题。
VFS与具体文件系统的关系
1.1 VFS存在的意义
-
向上,对应用层提供一个标准的文件操作接口;
-
对下,对文件系统提供一个标准的接口,以便其他操作系统的文件系统可以方便的移植到Linux上;
-
VFS内部则通过一系列高效的管理机制,比如inode cache, dentry cache 以及文件系统的预读等技术,使得底层文件系统不需沉溺到复杂的内核操作,即可获得高性能;
-
此外VFS把一些复杂的操作尽量抽象到VFS内部,使得底层文件系统实现更简单。
1.2 VFS中的4个主要对象
超级块,文件,目录,索引节点
2. 超级块
超级块用来描述整个文件系统的信息。对每个具体的文件系统来说,都有各自的超级块,如Ext2超级块和Ext3超级块,它们存放于磁盘上。
当内核在对一个文件系统进行初始化和注册时在内存为其分配一个超级块,这就是VFS超级块。
也就是说,VFS超级块是各种具体文件系统在安装时建立的,并在这些文件系统卸载时被自动删除,可见,VFS超级块只存在于内存中。
/*
* 超级块结构中定义的字段非常多,
* 这里只介绍一些重要的属性
*/
struct super_block
{
struct list_head s_list; /* 指向所有超级块的链表 */
const struct super_operations *s_op; /* 超级块方法 */
struct dentry *s_root; /* 目录挂载点 */
struct mutex s_lock; /* 超级块信号量 */
struct list_head s_inodes; /* inode链表 */
struct mtd_info *s_mtd; /* 存储磁盘信息 */
...
void *s_fs_info; /*指向具体文件系统的超级块*/
};
struct super_operations
{
void (*read_inode) (struct inode *); /* 从磁盘读取某个文件系统的inode */
int (*write_inode) (struct inode *, int); /* 将索引节点写入磁盘,wait表示写操作是否需要同步 */
void (*put_inode) (struct inode *); /* 逻辑上释放索引节点 */
void (*delete_inode) (struct inode *); /* 从磁盘上删除索引节点 */
void (*put_super) (struct super_block *); /* 卸载文件系统时由VFS调用,用来释放超级块 */
void (*write_super) (struct super_block *); /* 用给定的超级块更新磁盘上的超级块 */
...
};
与超级块关联的方法就是超级块操作表, 由super_operations来描述。
所有超级块对象都以双向循环链表的形式链接在一起。
3. 文件
每个打开的文件都用一个32位的数字来表示下一个读写的字节位置,这个数字叫做文件位置或偏移量。
每次打开一个文件,文件位置一般都被置为0,此后的读或写操作都将从文件的开始处进行,但是可以通过执行系统调用 lseek(随机定位)
对这个文件位置进行修改。Linux在 file文件对象中保存了打开文件的文件位置,这个对象称为打开的文件描述(Open File Description)。
那么,为什么不把文件位置存放在索引结点中,而要设一个 file数据结构呢?
struct file
{
struct list_head f_list; /* 所有打开的文件形成一个链表 */
struct dentry *f_dentry; /* 与文件相关的目录项对象 */
struct vfsmount *f_vfsmnt; /* 该文件所在的已安装文件系统 */
loff_t f_pos; /* 文件的当前位置 */
const struct file_operations *f_op; /* 文件操作函数 */
unsigned short f_count; /* 文件对象引用计数 */
};
struct file_operations
{
/* 用于更新偏移量指针,由系统调用lleek()调用它 */
loff_t (*llseek) (struct file *, loff_t, int);
/* 由系统调用read()调用它 */
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
/* 由系统调用write()调用它 */
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
/* 由系统调用 aio_read() 调用它 */
ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
/* 由系统调用 aio_write() 调用它 */
ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
/* 将给定文件映射到指定的地址空间上,由系统调用 mmap 调用它 */
int (*mmap) (struct file *, struct vm_area_struct *);
/* 创建一个新的文件对象,并将它和相应的索引节点对象关联起来 */
int (*open) (struct inode *, struct file *);
/* 当已打开文件的引用计数减少时,VFS调用该函数 */
int (*flush) (struct file *, fl_owner_t id);
};
4. 目录
每个文件除了有一个索引结点inode数据结构外,还有一个目录项dentry数据结构,dentry结构中有个d_inode指针指向相应的inode结构。
那么,既然inode结构和dentry结构都是对文件各方面属性的描述,那为什么不把这两个结构合二为一呢?
这是因为二者所描述的目标不同:
dentry结构代表的是逻辑意义上的文件,所描述的是文件逻辑上的属性,因此,目录项对象在磁盘上并没有对应的映像;
而inode结构代表的是物理意义上的文件,记录的是物理上的属性,对于一个具体的文件系统,它在磁盘上就有对应的映像。
所以说,一个索引结点对象可能对应多个目录项对象。
在使用的时候在内存中创建目录项对象,其实通过索引节点已经可以定位到指定的文件,
但是索引节点对象的属性非常多,在查找,比较文件时,直接用索引节点效率不高,所以引入了目录项的概念。
每个目录项对象都有三种状态:被使用,未使用和负状态
被使用:对应一个有效的索引节点,并且该对象由一个或多个使用者
未使用:对应一个有效的索引节点,但是VFS当前并没有使用这个目录项
负状态:没有对应的有效索引节点(可能索引节点被删除或者路径不存在了)
目录项的目的就是提高文件查找,比较的效率,所以访问过的目录项都缓存在slab中。
/* 目录项对象结构 */
struct dentry
{
atomic_t d_count; /* 使用计数 */
unsigned int d_flags; /* 目录项标识 */
spinlock_t d_lock; /* 单目录项锁 */
int d_mounted; /* 是否登录点的目录项 */
struct inode *d_inode; /* 相关联的索引节点,通过这个索引节点就可以读取到文件数据 */
struct hlist_node d_hash; /* 目录项形成的哈希表 */
struct dentry *d_parent; /* 父目录的目录项对象 */
struct qstr d_name; /* 目录项名称 */
struct list_head d_lru; /* 未使用的链表 */
struct list_head d_subdirs; /* 子目录链表 */
struct list_head d_alias; /* 索引节点别名链表 */
unsigned long d_time; /* 重置时间 */
const struct dentry_operations *d_op; /* 目录项操作相关函数 */
struct super_block *d_sb; /* 文件的超级块 */
void *d_fsdata; /* 文件系统特有数据 */
unsigned char d_iname[DNAME_INLINE_LEN_MIN]; /* 短文件名 */
...
};
struct dentry_operations
{
/* 该函数判断目录项对象是否有效。VFS准备从dcache中使用一个目录项时会调用这个函数 */
int (*d_revalidate)(struct dentry *, struct nameidata *);
/* 为目录项对象生成hash值 */
int (*d_hash) (struct dentry *, struct qstr *);
/* 比较 qstr 类型的2个文件名 */
int (*d_compare) (struct dentry *, struct qstr *, struct qstr *);
/* 当目录项对象的 d_count 为0时,VFS调用这个函数 */
int (*d_delete)(struct dentry *);
/* 当目录项对象将要被释放时,VFS调用该函数 */
void (*d_release)(struct dentry *);
/* 当目录项对象丢失其索引节点时(也就是磁盘索引节点被删除了),VFS会调用该函数 */
void (*d_iput)(struct dentry *, struct inode *);
char *(*d_dname)(struct dentry *, char *, int);
...
};
与目录项关联的方法就是目录项操作表, 由dentry_operations来描述。
一个有效的dentry结构必定有一个inode结构,这是因为一个目录项要么代表一个文件,要么代表一个目录,而目录实际上也是文件。所以,只要dentry结构是有效的,则其指针d_inode必定指向一个inode结构。但是,一个inode却可能对应着不止一个dentry结构;也就是说,一个文件可以有不止一个文件名或路径名。所以在inode结构中有一个队列i_dentry;凡是代表着同一个文件的所有目录项都通过其dentry结构中的d_alias域挂入相应inode结构中的i_dentry队列。
5. 索引节点
文件系统处理文件所需要的所有信息都放在称为索引结点的数据结构中。
文件名可以随时更改,但是索引结点对文件是唯一的,并且随文件的存在而存在。
具体文件系统的索引结点是存放在磁盘上的,是一种静态结构,要使用它,必须调入内存,填写VFS的索引结点。
因此,也称VFS索引结点是动态结点。
/*
* 索引节点结构中定义的字段非常多,
* 这里只介绍一些重要的属性
*/
struct inode
{
struct hlist_node i_hash; /* 散列表,用于快速查找inode */
struct list_head i_list; /* 索引节点链表 */
struct list_head i_sb_list; /* 超级块链表超级块 */
struct list_head i_dentry; /* 目录项链表 */
unsigned long i_ino; /* 节点号 */
unsigned int i_nlink; /* 硬链接数 */
uid_t i_uid; /* 使用者id */
gid_t i_gid; /* 使用组id */
struct timespec i_atime; /* 最后访问时间 */
struct timespec i_mtime; /* 最后修改时间 */
struct timespec i_ctime; /* 最后改变时间 */
const struct inode_operations *i_op; /* 索引节点操作函数 */
const struct file_operations *i_fop; /* 缺省的索引节点操作 */
struct super_block *i_sb; /* 相关的超级块 */
struct address_space *i_mapping; /* 相关的地址映射 */
struct address_space i_data; /* 设备地址映射 */
unsigned int i_flags; /* 文件系统标志 */
void *i_private; /* fs 私有指针 */
...
};
struct inode_operations
{
/* 创造一个新的磁盘索引节点 */
int (*create) (struct inode *,struct dentry *,int);
/* 在特定文件夹中寻找索引节点,该索引节点要对应于dentry中给出的文件名 */
struct dentry * (*lookup) (struct inode *,struct dentry *, struct nameidata *);
/* 创建硬链接 */
int (*link) (struct dentry *,struct inode *,struct dentry *);
/* 从一个符号链接查找它指向的索引节点 */
void * (*follow_link) (struct dentry *, struct nameidata *);
/* 在 follow_link调用之后,该函数由VFS调用进行清除工作 */
void (*put_link) (struct dentry *, struct nameidata *, void *);
/* 该函数由VFS调用,用于修改文件的大小 */
void (*truncate) (struct inode *);
...
};
与索引节点关联的方法就是索引节点操作表, 由inode_operations来描述。
不同的文件系统,其每个函数的具体实现是不同的,也不是每个函数都必须实现,没有实现的函数对应的域应置为NULL。
6. 文件处理流程示例
我们都知道,在用户态打开一个文件是返回的是一个文件描述符,其实也就是一个整数值。同时,访问文件也是通过这个文件描述符进行的,如下面代码所示的函数原型。那么操作系统是怎么通过这个整数值实现不同类型文件系统的访问呢?前文我们知道不同文件系统的差异其实就是inode中初始化的函数指针的差异,因此问题的关键是这个文件描述符和inode是怎么关联起来的。
int fd = open(const char *pathname,int flags,mode_t mode);
ssize_t read(int fd, void * buf, size_t count);
在Linux操作系统中,文件的打开必须要与进程(或者线程)关联,也就是说一个打开的文件必须隶属于某个进程。在linux内核当中一个进程通过task_struct结构体描述,而打开的文件则用file结构体描述,打开文件的过程也就是对file结构体的初始化的过程。在打开文件的过程中会将inode部分关键信息填充到file中,特别是文件操作的函数指针。在task_struct中保存着一个file类型的数组,而用户态的文件描述符其实就是数组的下标。这样通过文件描述符就可以很容易到找到file,然后通过其中的函数指针访问数据。
图5 进程与文件
例如我们以Ext2文件系统的写数据为例,在调用用户态的写数据接口的时候,需要传入文件描述符。内核根据文件描述符找到file,然后调用函数接口(file->f_op->write)文件磁盘数据。其中file结构体的f_op指针就是在打开文件的时候通过inode初始化的。