Linux虚拟文件系统简介
Table of Contents
基本概念
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
大致执行流程为:
- shell fork() 一个进程A
- A调用pipe()创建管道,得到fd1和fd2
- A调用两次fork()产生两个子进程A1和A2
- A1调用
dup2(fd2, 1)
将写文件描述符定向到标准输出,然后用execv()
执行ls - A2调用
dup2(fd1, 0)
将读文件描述符定向到标准输入,然后用execv()
执行more
- A1调用
- A关闭fd1和fd2