这篇导读是后面才回来写的,是因为有些问题觉得提前说出来会让读者更容易理解,自己以后回来复习的时候更好理解。我先提出新人最开始好奇的问题(以自己为例,以前这些问题都是最开始自己非常好奇的 ),然后简单(让人听懂)的回答,之后在后的文章补充。
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
static MAP map[] {
{"ext2", ext2_file_operations},
{"minix", minix_file_operations},
....
}
当然,Linux 有一个相对复杂的注册机制,但是本质就是获得了文件操作函数,所以可以使得一个文件可以正确被打开。
3. 操作系统如何知道一个文件的文件系统
任意一个目录下,都可能挂载一个不同文件系统,那么到底操作系统如何知道的呢?其实也就是说,如何为一个文件获取一个正确的 file_operations。
还是一样的,冤有头债有主,考虑一个所谓的目录结构,是怎么出现的。
mount('/anywhere/we/want/0.0/:=)/', name_of_a_filesystem, dev);
可能不是根目录,但无论如何处理,都一定有几个参数,也就是
挂载的目录,还有 挂载的文件系统 以及 存储了文件的一个设备
很多时候,我们不指定文件系统类型,实际上内核帮我们检测了,一个一个去试直到得到一个正确的。
举个例子,假设我们在 /mnt/ 目录下,mount 了一个 ext2 文件系统,来自一个全新的硬盘,里面什么信息都没有,刚刚被我们格式化为 ext2 。
"/" - 内核根文件系统
/
"mnt" - 内核根文件系统
/
"/" - ext2 文件系统
这里是一个树结构,当我们在一个目录下挂载之后,就会出现这个情况。现在我们设计一个简单的结构来表示它。
struct file {
struct file * parent;
struct file * children;
struct file * sibling;
int mount_point; /* 1 - mountpoint 说明有文件系统挂载在这 */
struct file * root;
void * private; /* */
}
struct filesystem_type {
char * name;
struct file_operations * f_ops;
struct file_operations * dir_ops;
}
struct filesystem_type ext2 {
"ext2",
ext2_f_ops,
ext2_dir_ops,
}
当我们 mount 的时候就会这样,首先先搜索找到 "mnt" 的 file 结构
void demo_mount(struct file * parent, struct filesystem_type * type ) {
struct file * new_root = malloc( sizeof(*new_root) );
new_root->parent = parent; // 设置父母
set_name(new_root, "/"); // 名字
new_root->private = type; // 储存文件系统信息
new_root->parent = new_root; // 根目录指向自己
attach_child(parent, new_root); // 添加到链表
parent->mountpoint = 1; // 这个目录下挂载了新的文件系统
}
如此一来,就实现了挂载,当然这是简化版,关键我们理解,我们其实通过了一个新建了子根目录,并且利用了一下结构,来让操作系统明白,这里有新的文件系统。
一般来说,一个文件系统的加载必然要涉及到读取相关的块设备,也就是涉及到块设备的知识,我们这里直接介绍到 VFS,不涉及具体的文件系统~
cd /mnt/ && mkdir test
考虑前一条的切换当前目录的,我们写一个简单的demo来处理。
PCB 是当前任务的一个结构,这里简化处理
const struct file * namei(char * path ) {
struct file * curr, * child;
char * child_name;
curr = PCB->current;
// 如果路径是根目录
if( *path++ == '\\' ) {
curr = PCB->root;
}
while( *path ) {
child_name = get_childname(&path);
// 简单的处理函数,这里的例子 返回的就是 "mnt"
child = get_file(curr, child_name);
// 从当前的父目录,得到子目录的一个file结构
// 同时 path 也更新了它的位置
if( child->mount_point == 1 ) {
// 说明这个子目录下也挂载了一个新的文件系统
child = get_file(child, "//");
// 及获取新的文件系统的根目录的file结构
}
curr = child;
}
return curr;
}
其实上面的函数非常的简单,当然忽略了非常多的错误处理,搜索file结构的过程其实是数据结构的知识,取决于我们如何设计它们的结构,关键在于,判断新的文件系统挂载于一个新目录的时候,我们的处理就是得到那个挂载文件系统的 根目录 的相关结构,这里就真正实现了跨越了文件系统,但是对上层则是透明的。
cd /mnt /
^ 此处跨越了一个文件系统
如今 curr 得到是 ext2 文件系统的 ·根目录· 的 file 结构
现在来考虑下一条指令
void demo_vfs_mkdir( const char * name ) {
struct file * parent, * child;
struct filesystem_type * fs;
parent = PCB->current;
经过刚才的 cd,已经指向 ext2 的根目录了
fs = file->root->private;
在挂载的时候,这个值赋值的就是相关的文件结构
/**
struct filesystem_type {
char * name;
struct file_operations * f_ops;
struct file_operations * dir_ops;
}
*/
fs->dir_ops->mkdir(parent, name);
child = malloc( sizeof(*child) );
child->parent = parent; // 设置父母
set_name(child, name); // 名字
attach_child(parent, child); // 添加到链表
}
关键是什么,是调用了 ext2 提供的文件操作函数的指针,这也是为什么一定要切换到 ext2 的根目录的原因所在,就是因为之后对当前目录的所有操作,都是和具体的文件系统挂钩的,只有正确切换了,才能实现 VFS
VFS 的实现关键就在于,1. 对于路径的搜索需要有机制来处理跨越文件系统的情况 2. 对不同的文件系统提供一个统一的抽象和接口,内核只负责调用接口,这样就可以屏蔽文件系统的存在
文件系统的跨越,本质就是为了隔离不同文件系统的区别,抽象出一个通用的接口,因为不同的文件系统有不同的处理方式,就比如上面的 mkdir,最终可能是,写一个具体的数据到了硬盘,具体怎么写,都是 file system-specific 的,内核不可能知晓,所以得文件系统本身挂载的时候,就要求文件系统的 操作函数指针 必须得注册在内核,提供一个通用的接口。
实现跨越文件系统,就是在路径搜索的过程,比如上面设置一个 mount point 来判断,虽然实现起来可能会很复杂,比如权限的判断,但是本质绝对一样。
实际上就是面向对象( OOP )的思想,内核的 VFS 就是一个 caller ,至于到底怎么操作,那是取决于文件系统本身。
这里仅仅介绍了 mkdir,实际上,对于 open,write,read...等操作,都是一个通用的接口。
file_operations->read(x,x,x)/write(x,x,x)
比如我自己写的 read 函数
ssize_t demo_read(struct file * f, char __user * user_buf, size_t, loff_t * off_t) {
char * s = "wa~ 0.0 ~";
sprinf(user_buf, s);
return strlen(s);
}
struct file_oprations demo_fops {
...
.read = demo_read,
...
}
现在,我们只需要把文件系统给内核,然后挂载到相关的目录下,如此,这个目录下创建的文件,当我们 read 它的时候,必然是返回这句话。
ramfs 意味着在内存上的文件系统,其实就意味着它的 write 并不会真正的写到磁盘上,而只会在内存上,其实我们上面的这个实例函数,也算是,因为我们也没有从什么硬盘读出相关数据。
相信到此,读者已经有点理解,其实所谓写一个新的文件系统,就是自己写一个配合内核规范( VFS调用规范 )的函数,然后注册到内核,这样内核就可以成功调用。
如果有读者,写过一些 callback 函数的实现,其实很类似,这些思想都是一样的。
END
具体到了真正 linux 下的实现,非常的复杂,因为经过长时间的演变,有时候为了适应具体的要求,衍生出了非常复杂的数据结构,总体来说有 inode dentry address_space vfs_mount等,但是它们的思想和早期没有区别,都是为了隔离,是不同文件系统对于上层的用户是透明的。
我们 read 一个设备文件和一个 proc 文件系统的文件,或者一个真正意义上的文件,处理都是一样的,根本的原因,就是挂载文件系统的时候,注册了与其对应的函数指针,VFS 最终落实到了这些函数的调用。
所以在其他的文章,我也说过了,文件其实只是沟通的媒介,真正的实现是落实在一个具体的函数的。是否要与 块设备沟通,又或者是只是在内存中,都由这个注册的函数来决定。
建议读者再去看看一个简单的字符设备的驱动实现,那里其实就体现了一个内核封装等面向对象的思想。