基本概念

Linux支持多种文件系统,为了让应用程序能够对所有系统不加区分的操作,提供了一个抽象层,这个抽象层介于应用程序和具体的文件系统之间,就像一个开关一样将用户请求转换到具体文件系统系统去,让具体文件系统去实现对应的操作,最后再将结果返回给用户。所以VFS就叫做虚拟文件系统开关,确切的也可称之为虚拟文件系统转换,不过我认为开关比较形象。

另一方面,VFS是Linux所有子系统中的一员,其他还有子系统如IPC, SCHED, MM, NET等,它们只会和VFS打交道,而不会去针对具体的文件系统直接操作。也就是说不仅用户空间接受VFS服务,内核空间也接受VFS服务。

VFS核心要素

file_system_type

文件系统类型用于在VFS中注册具体的文件系统,文件系统类型最关键的要提供两个操作,挂载和卸载,对应到file_system_type就是成员函数指针mount()/kill_sb()。

super_block

文件系统可以视为所有节点的集合,在数据结构上我们习惯称之为超级块。文件系统都有一个根节点,我们叫root. 其他的节点都是通过root向下查找而来,查找的手段都是通过文件名匹配。 struct super_block/struct super_operations实现一个超级块对象。

每个超级块实例对应一个挂载的文件系统,如果已经挂载,就是活动超级块,当然一个超级块可以挂载到多个地方。

每个文件系统有一组特征是作用到所有节点上的,比如READ_ONLY文件系统的所有节点都是只读的,还有block_size,即每个块所占用的大小。

文件系统和设备是息息相关的,所以文件系统必须有一个唯一的设备号。有些文件系统是nodev的,也就是不需要真实的设备。

inode

在这里我们简称索引节点为节点。节点代表文件系统中一个基本对象,具体的说就是普通文件/目录/符号链接等。 struct inode/struct inode_operations构成一个节点对象。

索引节点和文件容易混淆,inode和file的设计目的是不一样的, inode主要提供了对文件节点创建、命名、删除等操作,而file则关注文件中数据的读写。

inode的数据可以分为两个部分,一个是文件状态信息,一个是保存的数据,状态信息我们叫元数据。目录也是用inode表示,只不其内容由一对对编号和名字组成。

符号链接和硬链接都是由inode表示,符号链接的inode数据段包含一个路径字符串,指向链接的地址。多个硬链接实际是由同一个inode表示,只不过inode中有一个计数器,记住了总共有多少个硬链接。硬链接不能是目录,因为每个目录由一个inode表示,如果多个目录指向同一个inode,那么从该目录向上查找就会发现由多个parent,这会破坏目录树的结构,更糟糕的是如果把一个目录和它的子目录硬链接会发生什么?进入这个目录就意味着直接进入其子目录,进入子目录又进一步进入孙目录,产生死循环。不过Linux提供了一个mount选项,用于在虚拟文件系统层支持绑定两个已经存在的目录,前提是他们已经存在。

mount --bind /dir1 /dir1/dir2

在Linux上的作用就是把dir1挂载到dir2上,进入dir2将看到dir1的东西,但是dir2原来的东西就被隐藏了,这样从逻辑上就能够完成父目录和子目录的绑定,如dir1是父目录,在进入dir2时就将看到dir1的内容,此时也能看到dir2,全路径为/dir1/dir2/dir2,到这里就能看到dir2的内容了,因为能够区分出二者不同,一个是挂载点,一个不是,所以允许出现这样的绑定方式。

file

在UNIX中我们认为所有的东西都可以看作文件,如字符设备、块设备、管道、套接字、终端等。既然是文件,就可以打开、关闭、读写、IO控制等。 struct file/struct file_operations构成一个文件对象。

文件对象用于描述怎样与一个打开的文件交互,文件对象是文件被打开的时候创建的,文件对象在磁盘上没有对应的映像。定义file是为了让进程对文件的记录是私有的,父子进程对文件共享。由于一个文件可以被多个进程打开,所以文件指针要放在file中而不是inode中。

dentry

名字查找是VFS当中非常复杂的一个部分。前面说了查找inode是通过名字匹配来实现的,但是并不是每个文件系统都能够快速的实现名字到inode的转换,于是VFS实现了dcache, dcache的存在为快速名字查找提供了可靠的保障。 VFS处理了所有文件路径名的管理操作,在底层文件系统能够看到他们之前,将其转换为dcache的入口。唯一的例外是符号链接,VFS不加改动的传递给底层文件系统,由底层文件系统对其解释。

dcache称之为高速目录缓存,由许多dentry组成,每个dentry对应到系统中的一个文件名。当前活动的文件名字和最近使用的文件名字都缓存在dcache中。

每个dentry的父节点必须在dcache中,dentry还记载了文件系统的装载关系。只要在dcache中存在一个目录项,那么相应的索引节点就在索引节点高速缓存。反过来,如果索引节点在索引节点高速缓存,那么它一定引用dcache中的一个dentry.

也就是可以理解为dcache的存在就是为了加速文件名字到具体inode的转换, VFS三个字母中的S-switch和这个功能息息相关。

dcache是一个树状结构,每个dcache节点对应一个目录,也就是指定名称的inode。一个inode可以和树中的多个dcache节点联系,因为硬链接可以在多个地方指向同一个节点。

每一个dcache节点用struct dentry来表示,我们习惯称dentry为目录项,请注意和目录区别,目录不过是inode的一种形式。 struct dentry/struct dentry_operations实现一个目录项对象。

一个打开的文件一定会指向dentry,而一个dentry又会指向inode,所以dentry可以看作是file到inode的switch.

注册与注销文件系统

注册文件系统

如果文件系统没有注册,那么自然就无法使用。而文件系统注册可以有两种方式,一种是将文件系统编译到内核里,这样系统启动就会自动完成注册,一种是将文件系统编译成模块,在模块载入的时候注册。当文件系统编译到内核时,在start_kernel()就会调用注册函数,如rootfs、proc等,而永远不会调用注销函数。当文件编译为模块时,在模块的init()函数中就会调用注册函数,在模块的exit()函数中会调用注销函数。

注册文件系统并不复杂,所以我们可以直接看源代码。但是要理解原理需要先理解数据结构。

struct file_system_type {
    const char *name;                   // 文件系统的名字:rootfs, ext2...
    int fs_flags;                       // 文件系统特征
#define FS_REQUIRES_DEV         1       // 需要具体的设备,不是仿真的文件系统。
#define FS_BINARY_MOUNTDATA     2       // 挂载数据为二进制
#define FS_HAS_SUBTYPE          4       // 具有子文件系统
#define FS_USERNS_MOUNT         8       // can be mounted by userns root
#define FS_USERNS_DEV_MOUNT     16      // userns mount not imply MNT_NODEV
#define FS_RENAME_DOES_D_MOVE   32768   // rename的时候由FS执行d_move()

    // 挂载文件系统
    // @dev_path: 文件系统需要一个设备路径,以便根据设备路径找到块设备。
    // @data: 实际是传递给mount的选项,也就是字符串。
    struct dentry *(*mount) (struct file_system_type *fs_type, int flags,
                             const char *dev_path, void *data);
    // 卸载文件系统
    void (*kill_sb) (struct super_block *sb);

    // 一般都是设置为THIS_MODULE,对应到具体文件系统的模块。
    struct module *owner;
    // 用来指向下一个文件系统类型,系统中所有文件系统类型会形成一个单向链表,
    // 在注册的时候会找到最后一个文件系统,并将其next指向新注册的文件系统。
    // 链表头:static struct file_system_type *file_systems;
    // 保护锁:static DEFINE_RWLOCK(file_systems_lock);
    struct file_system_type *next;
    // 该文件系统所有的super_block实例链表头节点。
    // 链表点:sb->s_instances
    // 保护锁:DEFINE_SPINLOCK(sb_lock)
    struct hlist_head fs_supers;

    struct lock_class_key s_lock_key;       // 未使用,将来可能被删除
    struct lock_class_key s_umount_key;     // sb->s_umount
    struct lock_class_key s_vfs_rename_key; // sb->s_vfs_rename_mutex
    // sb->s_writers.lock_map[SB_FREEZE_LEVELS]
    struct lock_class_key s_writers_key[SB_FREEZE_LEVELS];

    struct lock_class_key i_lock_key;       // inode->i_lock
    struct lock_class_key i_mutex_key;      // inode->i_mutex
    // 针对inode是目录的情况
    struct lock_class_key i_mutex_dir_key;  // inode->i_mutex
};

思路上非常简单,如果找到同名字的文件系统就说明已经注册了,返回-EBUSY。反之find_filesystem()会获取到最后一个文件系统的next指针,将其指向新的文件系统就完成注册了。

int register_filesystem(struct file_system_type *fs)
{
    int res = 0;
    struct file_system_type **p;

    BUG_ON(strchr(fs->name, '.'));
    if (fs->next)
        return -EBUSY;
    write_lock(&file_systems_lock);
    p = find_filesystem(fs->name, strlen(fs->name));
    if (*p)
        res = -EBUSY;
    else
        *p = fs;
    write_unlock(&file_systems_lock);
    return res;
}

不妨看一下find_filesystem()的实现。所有的文件系统类型形成一个链表,链表头存放在一个叫file_system的全局变量中。所以不需要特殊的参数来传递链表头。

static struct file_system_type **find_filesystem(const char *name, unsigned len)
{
    struct file_system_type **p;
    for (p = &file_systems; *p; p = &(*p)->next)
        if (strlen((*p)->name) == len &&
            strncmp((*p)->name, name, len) == 0)
            break;
    return p;
}

注销文件系统

注销文件系统的代码也比较简单,直接看源代码。这里tmp作为一个指针的指针,它会向后移动,假设这里移动到了fs,这时*tmp和fs指向同一位置,需要注意的是tmp实际上是上一个节点的next地址,因此*tmp = fs->next实际上是改变上一个节点next的指向,也就是让其跳过fs。接下来由于fs已经被file_system链表所抛弃,我们必须将fs->next清空。

int unregister_filesystem(struct file_system_type *fs)
{
    struct file_system_type **tmp;

    write_lock(&file_systems_lock);
    tmp = &file_systems;
    while (*tmp) {
        if (fs == *tmp) {
            *tmp = fs->next;
            fs->next = NULL;
            write_unlock(&file_systems_lock);
            synchronize_rcu();
            return 0;
        }
        tmp = &(*tmp)->next;
    }
    write_unlock(&file_systems_lock);

    return -EINVAL;
}

装载与卸载文件系统

装载文件系统

装载文件系统是用户通过mount命令来实现的,当然也可以将配置写道/etc/fstab中。装载文件系统至少应该提供三个信息:文件系统名称、设备节点、挂载点。

mount -t vfat /dev/sdb /media/usb
  • VFS会根据提供的文件系统类型vfat去查找file_systems链表,如果找到说明已经注册,如果没找到会尝试加载模块,如果成功注册就可以开始执行挂载操作。
  • 查看设备节点是否存在,设备节点是否已经被安装了。
  • 查看挂载点是否存在,挂载点是否已经被其它文件系统挂载占用。
  • 为文件系统分配超级块。
  • 读取文件系统设备中的信息填充超级块。

一个文件系统可以在多个地方安装,毕竟我们可以根据路径名来找到文件系统,但是即便如此,一个文件系统还是只有一个超级块。

反过来多个文件系统可以安装到一个地方,只不过后面的文件系统会覆盖之前的文件系统。一旦被覆盖那么进程就不能访问到之前的文件系统,如果在安装后一个系统之前,已经有进程在访问之前的文件系统,那么它可以继续访问。当后一个文件系统卸载之后,之前的文件系统就会显示出来。

卸载文件系统

  • 检查文件系统是否正在被使用。
  • 同步文件系统。
  • 释放超级块。

常见文件系统

pipefs

在Linux中管道是只存在于内存中的特殊文件,一个管道实际就是一个索引节点,但是包含两个或多个file对象,一个用于读,一个用于写。管道的缓冲区通常为一页大小,如果多个进程要的写入是小于一页的,那么写入就是原子的,如果写入大于一页,就会将写入进行分割,因而会出现多个进程交叉写入。命名管道有对应的磁盘索引节点,可以被任何进程打开使用。

对于ls | more大致执行流程为:

  1. shell fork() 一个进程A
  2. A调用pipe()创建管道,得到fd1和fd2
  3. A调用两次fork()产生两个子进程A1和A2
    1. A1调用dup2(fd2, 1)将写文件描述符定向到标准输出,然后用execv()执行ls
    2. A2调用dup2(fd1, 0)将读文件描述符定向到标准输入,然后用execv()执行more
  4. A关闭fd1和fd2