VFS序

这篇导读是后面才回来写的,是因为有些问题觉得提前说出来会让读者更容易理解,自己以后回来复习的时候更好理解。我先提出新人最开始好奇的问题(以自己为例,以前这些问题都是最开始自己非常好奇的 ),然后简单(让人听懂)的回答,之后在后的文章补充。

FAQ

1. 我们读写普通文件(path/to/file),跟读写设备节点(/dev/sda)有区别吗?

没区别,首先明确一点,在文件系统的眼里,这些都是文件,只是不同的类型,我们设备的节点
在关机之后如果文件系统不会删除,那就跟普通文件,那么区别到底在哪里?
处理方式不一样。
open:
    if( file is normal file ) {
        file->ops = &normal_ops;
    } else if( device file ) {
        file->ops = &device_ops;
    }
    
ops 就是一系列的函数指针,当我们打开一个文件的时候
文件系统的open函数初始化的

/*
 * NOTE:
 * read, write, poll, fsync, readv, writev, unlocked_ioctl and compat_ioctl
 * can be called without the big kernel lock held in all filesystems.
 */
struct file_operations {
	...
	loff_t (*llseek) (struct file *, loff_t, int);
	ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
	ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
	int (*readdir) (struct file *, void *, filldir_t);
	unsigned int (*poll) (struct file *, struct poll_table_struct *);
	int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
	...
	int (*mmap) (struct file *, struct vm_area_struct *);
	int (*open) (struct inode *, struct file *);
	int (*flush) (struct file *, fl_owner_t id);
	...
};

对于普通文件,read/write 最终就是从硬盘中读取数据,而对于设备文件,则是从“设备”读取文件,其实读者发现了,硬盘不就是一个设备,所以两者没有任何区别,只是数据的获取方式不一样。

2. 文件操作函数怎么注册的?

冤有头,债有主,文件系统的模块提供的,也可以说是驱动提供的,思考一问题,这不就是一个函数指针的数组吗,只要操作系统可以找到不就好了,最简单的办法,在操作系统里面增加一个map

当然,Linux 有一个相对复杂的注册机制,但是本质就是获得了文件操作函数,所以可以使得一个文件可以正确被打开。

3. 操作系统如何知道一个文件的文件系统

任意一个目录下,都可能挂载一个不同文件系统,那么到底操作系统如何知道的呢?其实也就是说,如何为一个文件获取一个正确的 file_operations

还是一样的,冤有头债有主,考虑一个所谓的目录结构,是怎么出现的。

很多时候,我们不指定文件系统类型,实际上内核帮我们检测了,一个一个去试直到得到一个正确的。

举个例子,假设我们在 /mnt/ 目录下,mount 了一个 ext2 文件系统,来自一个全新的硬盘,里面什么信息都没有,刚刚被我们格式化为 ext2 。

这里是一个树结构,当我们在一个目录下挂载之后,就会出现这个情况。现在我们设计一个简单的结构来表示它。

当我们 mount 的时候就会这样,首先先搜索找到 "mnt" 的 file 结构

如此一来,就实现了挂载,当然这是简化版,关键我们理解,我们其实通过了一个新建了子根目录,并且利用了一下结构,来让操作系统明白,这里有新的文件系统。

一般来说,一个文件系统的加载必然要涉及到读取相关的块设备,也就是涉及到块设备的知识,我们这里直接介绍到 VFS,不涉及具体的文件系统~

考虑前一条的切换当前目录的,我们写一个简单的demo来处理。

其实上面的函数非常的简单,当然忽略了非常多的错误处理,搜索file结构的过程其实是数据结构的知识,取决于我们如何设计它们的结构,关键在于,判断新的文件系统挂载于一个新目录的时候,我们的处理就是得到那个挂载文件系统的 根目录 的相关结构,这里就真正实现了跨越了文件系统,但是对上层则是透明的。

现在来考虑下一条指令

关键是什么,是调用了 ext2 提供的文件操作函数的指针,这也是为什么一定要切换到 ext2 的根目录的原因所在,就是因为之后对当前目录的所有操作,都是和具体的文件系统挂钩的,只有正确切换了,才能实现 VFS

VFS 的实现关键就在于,1. 对于路径的搜索需要有机制来处理跨越文件系统的情况 2. 对不同的文件系统提供一个统一的抽象和接口,内核只负责调用接口,这样就可以屏蔽文件系统的存在

文件系统的跨越,本质就是为了隔离不同文件系统的区别,抽象出一个通用的接口,因为不同的文件系统有不同的处理方式,就比如上面的 mkdir,最终可能是,写一个具体的数据到了硬盘,具体怎么写,都是 file system-specific 的,内核不可能知晓,所以得文件系统本身挂载的时候,就要求文件系统的 操作函数指针 必须得注册在内核,提供一个通用的接口。

实现跨越文件系统,就是在路径搜索的过程,比如上面设置一个 mount point 来判断,虽然实现起来可能会很复杂,比如权限的判断,但是本质绝对一样。

实际上就是面向对象( OOP )的思想,内核的 VFS 就是一个 caller ,至于到底怎么操作,那是取决于文件系统本身。

这里仅仅介绍了 mkdir,实际上,对于 open,write,read...等操作,都是一个通用的接口。

现在,我们只需要把文件系统给内核,然后挂载到相关的目录下,如此,这个目录下创建的文件,当我们 read 它的时候,必然是返回这句话。

ramfs 意味着在内存上的文件系统,其实就意味着它的 write 并不会真正的写到磁盘上,而只会在内存上,其实我们上面的这个实例函数,也算是,因为我们也没有从什么硬盘读出相关数据。

相信到此,读者已经有点理解,其实所谓写一个新的文件系统,就是自己写一个配合内核规范( VFS调用规范 )的函数,然后注册到内核,这样内核就可以成功调用。

如果有读者,写过一些 callback 函数的实现,其实很类似,这些思想都是一样的。

END

具体到了真正 linux 下的实现,非常的复杂,因为经过长时间的演变,有时候为了适应具体的要求,衍生出了非常复杂的数据结构,总体来说有 inode dentry address_space vfs_mount等,但是它们的思想和早期没有区别,都是为了隔离,是不同文件系统对于上层的用户是透明的。

我们 read 一个设备文件和一个 proc 文件系统的文件,或者一个真正意义上的文件,处理都是一样的,根本的原因,就是挂载文件系统的时候,注册了与其对应的函数指针,VFS 最终落实到了这些函数的调用。

所以在其他的文章,我也说过了,文件其实只是沟通的媒介,真正的实现是落实在一个具体的函数的。是否要与 块设备沟通,又或者是只是在内存中,都由这个注册的函数来决定。

建议读者再去看看一个简单的字符设备的驱动实现,那里其实就体现了一个内核封装等面向对象的思想。

Last updated