操作系统-虚拟文件系统
进程想要往文件系统里面读写数据,需要很多层的组件一起合作。具体的操作流程如下:
在应用层,进程在进行文件读写操作时,可以通过系统调用sys_open、sys_read、sys_write等。
在内核,每个进程都需要为打开的文件,维护一定的数据结构。
在内核,整个系统打开的文件,也需要维护一定的数据结构。
Linux可以支持多达十几种不同的文件系统。他们的实现各不相同,因此Linux内核向用户空间提供了虚拟文件系统这个统一的接口,来对文件系统进行操作。它提供了常见的文件系统对象模型,例如inode、directory entry、mount等,以及操作这些对象的方法,例如inode operations、directory operations和file operations等。
然后对接的才是真正的文件系统,例如ext4文件系统。
为了读写ext4文件系统,要通过块设备IO层。这是文件系统层和块设备驱动的接口。
为了加快块设备的读写效率,我们还有一个缓存层。
最下层是块设备驱动程序。
这个流程中会涉及到几个重要的系统调用:
mount系统调用用于挂载文件系统。
open系统调用用于打开或者创建文件,创建要在flags中设置O_CREAT,对于读写要设置flags为O_RDWR。
read系统调用用于读取文件内容。
write系统调用用于写入文件内容。
打开文件
从open系统调用说起,我们知道,在进程里面通过open系统调用打开文件,最终会调用到内核的系统调用实现sys_open。
SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
......
return do_sys_open(AT_FDCWD, filename, flags, mode);
}
long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
{
......
fd = get_unused_fd_flags(flags);
if (fd >= 0) {
struct file *f = do_filp_open(dfd, tmp, &op);
if (IS_ERR(f)) {
put_unused_fd(fd);
fd = PTR_ERR(f);
} else {
fsnotify_open(f);
fd_install(fd, f);
}
}
putname(tmp);
return fd;
}
要打开一个文件,首先要通过get_unused_fd_flags得到一个没有被使用的文件描述符。如果获取这个文件描述符呢?在每一个进程的task_struct中,有一个指针files,类型是files_struct。
struct files_struct *files;
files_struct里面最重要的是一个文件描述符列表,每打开一个文件,就会在这个列表中分配一项,下标就是文件描述符。
struct files_struct {
......
struct file __rcu * fd_array[NR_OPEN_DEFAULT];
};
对于任何一个进程,默认情况下,文件描述符0表示stdin标准输入,文件描述符1表示stdout标准输出,文件描述符2表示stderr标准错误输出。另外,再打开的文件,都会从这个列表中找一个空闲位置分配给它。
文件描述符列表的每一项都是一个指向struct file的指针,也就是说,每打开一个文件,都会有一个struct file对应。
do_sys_open中调用do_filp_open,就是创建这个struct file结构,然后fd_install(fd,f)是将文件描述符和这个结构关联起来。
struct file *do_filp_open(int dfd, struct filename *pathname,
const struct open_flags *op)
{
......
set_nameidata(&nd, dfd, pathname);
filp = path_openat(&nd, op, flags | LOOKUP_RCU);
......
restore_nameidata();
return filp;
}
do_filp_open里面首先初始化了struct nameidata这个结构。我们知道,文件都是一串路径名称,需要逐个解析。这个结构就是解析和查找路径的时候做辅助作用的。
在struct nameidata里面有一个关键的成员变量struct path。
struct path {
struct vfsmount *mnt;
struct dentry *dentry;
} __randomize_layout;
其中struct vfsmount和文件系统的挂载有关。另一个struct dentry,除了上面说的作用于标识目录之外,还可以表示文件名,还会建立文件名及其inode之间的关联。
接下来就调用path_openat,主要做了以下几件事情:
get_empty_filp生成一个struct file结构。
path_init初始化nameidata,准备开始节点路径查找。
link_path_walk对于路径名逐层进行节点路径查找,这里面有一个大的循环,用"/"分隔逐层处理。
do_last获取文件对应的inode对象,并且初始化file对象。
static struct file *path_openat(struct nameidata *nd,
const struct open_flags *op, unsigned flags)
{
......
file = get_empty_filp();
......
s = path_init(nd, flags);
......
while (!(error = link_path_walk(s, nd)) &&
(error = do_last(nd, file, op, &opened)) > 0) {
......
}
terminate_walk(nd);
......
return file;
}
例如,文件“/root/hello/world/data”,link_path_walk 会解析前面的路径部分“/root/hello/world”,解析完毕的时候 nameidata 的 dentry 为路径名的最后一部分的父目录“/root/hello/world”,而 nameidata 的 filename 为路径名的最后一部分“data”。最后一部分的解析和处理,我们交给 do_last。
static int do_last(struct nameidata *nd,
struct file *file, const struct open_flags *op,
int *opened)
{
......
error = lookup_fast(nd, &path, &inode, &seq);
......
error = lookup_open(nd, &path, file, op, got_write, opened);
......
error = vfs_open(&nd->path, file, current_cred());
......
}
在这里面,我们需要先查找文件路径最后一部分对应的dentry。如何查找呢?
Linux为了提高目录项对象的处理效率,设计与实现了目录项高速缓存dentry cache,简称dcache。它主要由两个数据结构组成。
哈希表dentry_hashtable:decache中的所有dentry对象都通过d_hash指针链到相应的dentry哈希链表中。
未使用的dentry对象链头s_dentry_lru:dentry对象通过其d_lru指针链入LRU链表中。LRU的意思是最近最少使用,只要有它,就说明长时间不使用,就应该释放。
这两个列表之间会产生复杂的关系:
引用为0:一个在散列表中的dentry变成没有人引用了,就会被加到LRU表中去。
再次被引用:一个在LRU表中的dentry再次被引用了,则从LRU表中移除。
分配:当dentry在散列表中没有找到,则从Slub分配器中分配一个。
过期归还:当LRU表中最长时间没有使用的dentry应该释放回Slub分配器。
文件删除:文件被删除了,相应的dentry应该释放回Slub分配器。
结构复用:当需要分配一个dentry,但是无法分配新的,就从LRU表中取出一个来复用。
所以,do_last()在查找dentry的时候,当然先从缓存中查找,调用的是lookup_fast。
如果缓存中没有找到,就需要真的到文件系统里面去找了,lookup_open 会创建一个新的 dentry,并且调用上一级目录的 Inode 的 inode_operations 的 lookup 函数,对于 ext4 来讲,调用的是 ext4_lookup到文件系统里面去找 inode。最终找到后将新生成的 dentry 赋给 path 变量。
static int lookup_open(struct nameidata *nd, struct path *path,
struct file *file,
const struct open_flags *op,
bool got_write, int *opened)
{
......
dentry = d_alloc_parallel(dir, &nd->last, &wq);
......
struct dentry *res = dir_inode->i_op->lookup(dir_inode, dentry,
nd->flags);
......
path->dentry = dentry;
path->mnt = nd->path.mnt;
}
const struct inode_operations ext4_dir_inode_operations = {
.create = ext4_create,
.lookup = ext4_lookup,
...
do_last()的最后一步是调用vfs_open真正打开文件。
int vfs_open(const struct path *path, struct file *file,
const struct cred *cred)
{
struct dentry *dentry = d_real(path->dentry, NULL, file->f_flags, 0);
......
file->f_path = *path;
return do_dentry_open(file, d_backing_inode(dentry), NULL, cred);
}
static int do_dentry_open(struct file *f,
struct inode *inode,
int (*open)(struct inode *, struct file *),
const struct cred *cred)
{
......
f->f_mode = OPEN_FMODE(f->f_flags) | FMODE_LSEEK |
FMODE_PREAD | FMODE_PWRITE;
path_get(&f->f_path);
f->f_inode = inode;
f->f_mapping = inode->i_mapping;
......
f->f_op = fops_get(inode->i_fop);
......
open = f->f_op->open;
......
error = open(inode, f);
......
f->f_flags &= ~(O_CREAT | O_EXCL | O_NOCTTY | O_TRUNC);
file_ra_state_init(&f->f_ra, f->f_mapping->host->i_mapping);
return 0;
......
}
const struct file_operations ext4_file_operations = {
......
.open = ext4_file_open,
......
};
vfs_open里面最终要做的一件事情是,调用f_op->open,也就是调用ext4_file_open。另外一件重要的事情是将打开文件的所有信息,填写到struct file这个结构里面。
struct file {
union {
struct llist_node fu_llist;
struct rcu_head fu_rcuhead;
} f_u;
struct path f_path;
struct inode *f_inode; /* cached value */
const struct file_operations *f_op;
spinlock_t f_lock;
enum rw_hint f_write_hint;
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode;
struct mutex f_pos_lock;
loff_t f_pos;
struct fown_struct f_owner;
const struct cred *f_cred;
......
struct address_space *f_mapping;
errseq_t f_wb_err;
}
总结
对于每一个进程,打开的文件都有一个文件描述符,在files_struct里面会有文件描述符数组。每一个文件描述符是这个数组的下标,里面的内容指向一个file 结构,表示打开的文件。这个结构里面有这个文件对应的inode,最重要的是这个文件对应的操作file_operation。如何操作这个文件,就看这个file_operation里面的定义了。
对于每一个打开的文件,都有一个dentry对应,虽然叫作directory entry,但是不仅仅表示文件夹,也表示文件。它最重要的作用就是指向这个文件对应的inode。
file结构是一个文件打开以后才创建的,dentry是放在一个dentry cache里面的,文件关闭了,它依然存在,因而它可以更长期的维护内存中文件的表示和硬盘上文件的表示之间的关系。
inode结构就表示硬盘上的inode,包括块设备号等。
几乎每一种结构都有自己对应的operation结构,里面都是一些方法,因而当后面遇到对于某种结构进行处理的时候,如果不容易找到相应的处理函数,就先找这个operation结构再进一步分析。