linux操作系统:虚拟文件系统,文件多了就需要档案管理系统

这里,图书馆的书架,也就是硬盘上的文件系统格式已经搭建好了,现在我们还需要一个图书馆管理与借阅系统,也就是文件管理模块。

进程要想往文件系统里面写数据,需要很多层的组件一起合作:

  • 在应用层,进程在进行文件读写操作时,可以通过系统调用比如sys_open、sys_read、sys_write等
  • 在内核,每个进程都需要为打开的文件,维护一定的数据结构
  • 在内核,整个进程打开的文件,也需要维护一定的数据结构
  • linux可以支持多达数十种不同的文件系统,它们的实现各不相同,因此linux内核向用户空间提供了虚拟文件系统这个统一的接口,来对文件系统进行操作。它提供了常见的文件系统对象模型,比如inode、directory entry、mount等,以及操作这些对象的方法,比如inode operations、directory operations、file operations等
  • 然后就是对接的是真正的文件系统,比如ext4文件系统
  • 为了读写ext4文件系统,要通过块设备IO层,也就是BIO层。这是文件系统层和块设备驱动的接口
  • 为了加快块设备的读写效率,我们还有一个缓存层
  • 最下面是块设备驱动程序

linux操作系统:虚拟文件系统,文件多了就需要档案管理系统
接下来我们逐层解析。

解析系统调用是了解内核架构最有力的一把钥匙,这里我们只要重点关注这几个最重要的系统调用就可以了:

  • mount 系统调用用于挂载文件系统;
  • open 系统调用用于打开或者创建文件,创建要在 flags 中设置 O_CREAT,对于读写要设置 flags 为 O_RDWR;
  • read 系统调用用于读取文件内容;
  • write 系统调用用于写入文件内容。

挂载文件系统

想要操作文件系统,第一件事情就是挂载文件系统。

  • 内核是不是支持某种类型的文件系统,需要我们注册才能知道。比如ext4文件系统,就需要调用register_filesystem进行注册,传入的参数是ext4_fs_type,表示注册的是ext4类型的文件系统。这里面最重要的一个成员变量是ext4_mount
register_filesystem(&ext4_fs_type);
 
 
static struct file_system_type ext4_fs_type = {
	.owner		= THIS_MODULE,
	.name		= "ext4",
	.mount		= ext4_mount,
	.kill_sb	= kill_block_super,
	.fs_flags	= FS_REQUIRES_DEV,
};
  • 如果一种文件系统的类型曾经在内核注册过,这就说明允许你挂载并且使用这个文件系统

mount系统调用的定义如下:

SYSCALL_DEFINE5(mount, char __user *, dev_name, char __user *, dir_name, char __user *, type, unsigned long, flags, void __user *, data)
{
......
	ret = do_mount(kernel_dev, dir_name, kernel_type, flags, options);
......
}

接下来的调用链为:do_mount->do_new_mount->vfs_kern_mount。

struct vfsmount *
vfs_kern_mount(struct file_system_type *type, int flags, const char *name, void *data)
{
......
	mnt = alloc_vfsmnt(name);
......
	root = mount_fs(type, flags, name, data);
......
	mnt->mnt.mnt_root = root;
	mnt->mnt.mnt_sb = root->d_sb;
	mnt->mnt_mountpoint = mnt->mnt.mnt_root;
	mnt->mnt_parent = mnt;
	list_add_tail(&mnt->mnt_instance, &root->d_sb->s_mounts);
	return &mnt->mnt;
}

vfs_kern_mount先是创建struct mount结构,每个挂载的文件系统都对应于这样一个结构

struct mount {
	struct hlist_node mnt_hash;
	struct mount *mnt_parent;
	struct dentry *mnt_mountpoint;
	struct vfsmount mnt;
	union {
		struct rcu_head mnt_rcu;
		struct llist_node mnt_llist;
	};
	struct list_head mnt_mounts;	/* list of children, anchored here */
	struct list_head mnt_child;	/* and going through their mnt_child */
	struct list_head mnt_instance;	/* mount instance on sb->s_mounts */
	const char *mnt_devname;	/* Name of device e.g. /dev/dsk/hda1 */
	struct list_head mnt_list;
......
} __randomize_layout;
 
 
struct vfsmount {
	struct dentry *mnt_root;	/* root of the mounted tree */
	struct super_block *mnt_sb;	/* pointer to superblock */
	int mnt_flags;
} __randomize_layout;
  • mnt_parent是装载点所在的父文件系统
  • mnt_mountpoint是装载点在父文件系统中的dentry
  • struct dentry表示目录,并且与目录的inode关联
  • mnt_root 是当前文件系统根目录的dentry,mnt_sb是指向超级块的指针

接下来,我们来看调用 mount_fs 挂载文件系统。

struct dentry *
mount_fs(struct file_system_type *type, int flags, const char *name, void *data)
{
	struct dentry *root;
	struct super_block *sb;
......
	root = type->mount(type, flags, name, data);
......
	sb = root->d_sb;
......
}

这里调用大的是ext4_fs_type的mount函数,也就是咱们上面提到的ext4_mount,从文件系统里面读取超级块。在文件系统的实现中,每个在硬盘上的结构,在内存中也对应相同格式的结构。当所有的数据结构都读到内存里面,内核就可以通过操作这些数据结构,来操作文件系统了。

打开文件

接下来,我们从分析 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_openaat,主要做了下面几件事情:

  • 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:dcache中的所有dentry对象都通过d_hash执行链到相应的dentry哈希链表中
  • 未使用的dentry对象链表s_dentry_lru:dentry对象通过其d_lru指针链入LRU链表中。LRU 的意思是最近最少使用。只要有它,就说明长时间不使用,就应该释放了。

linux操作系统:虚拟文件系统,文件多了就需要档案管理系统

这两个列表之间会产生复杂的关系:

  • 引用为0:一个在散列表中的dentry变成没有人引用了,就会被加到LRU表中去
  • 再次被引用:一个在LRU表中的dentry再次被引用了,则从LRU表中移除
  • 分配:当dentry在散列表中没有找到,则从Slub分配器中分配一个
  • 过期归还:当LRU表中最长时间没有使用的dentry应该释放回slub分配器
  • 文件删除:文件被删除了,相应的dentry应该释放回slub分配器
  • 结构复用:当需要分配一个dentry,但是无法分配新的,就从LRU表中取出一个来复用

所以,do_last()在查找dentry的时候,当然先从缓存中查找,调用的是lookup_fast。

如果缓存中没有找到,就需要真的到文件系统里面去找了,lookup_open会创建一个新的dentry,并且调用上一级目录的lnode的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;
}

总结

  • 对于每一个进程,打开的文件都有一个文件描述符,在file_struct里面会有文件描述符数组。每一个文件描述符是这个数组的下标,里面的内容指向一个file结构,表示打开的文件。这个结构里面有这个文件对应的inode,以及这个文件对应的操作file_operation。如果操作这个文件,就看这个file_operation里面的定义了
  • 对于每一个打开的文件,都有一个dentry对应,虽然叫做directory entry,但是不仅仅表示文件夹,也表示文件。它最重要的作用是指向这个文件对应的inode
  • 如果说file结构是一个文件打开之后才创建的,dentry是放在一个dentry cache里面的,文件关闭了,它依然存在,因而它可以更长期的维护内存中的文件的表示以及硬盘上文件的表示之间的关系
  • inode 结构就表示硬盘上的 inode,包括块设备号等。
  • 几乎每一种结构都有自己对应的 operation 结构,里面都是一些方法
    linux操作系统:虚拟文件系统,文件多了就需要档案管理系统
上一篇:爬虫日记2:爬取好看的图片


下一篇:Android11 小米10Pro open failed: EPERM (Operation not permitted)