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


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 最终落实到了这些函数的调用。

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

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

Last updated