文件系统是操作系统中文件的管理者。对上层用户和应用程序来说,文件系统提供文件抽象并实现文件访问所需要的接口。对下层存储设备来说,文件系统以特定格式在存储设备上维护着每个文件的数据和元数据。
通常来说,文件系统将文件保存在存储设备中。操作系统将这些存储设备抽象为块设备(block device),以方便文件系统使用统一的接口访问。块设备上的存储空间在逻辑上被划分为固定大小的存储块(block)。块是块设备读写的最小单元,一般为 512 字节或 4KB。每个存储块均有一个地址,称为块号。文件系统在请求中指定块号,操作系统负责对块设备中的指定块号进行数据写入和读取操作。
在 Linux 中应用程序访问文件系统的流程如下所示:
- 应用程序向内核发出创建文件的系统调用请求;
- 内核中的 VFS 先处理该请求。VFS 负责管理具体的文件系统,并提供一系列服务,如页缓存、inode 缓存、目录项缓存等。
- VFS 根据系统调用的内容和上下文,发现对应的文件系统为 Ext4,则调用 Ext4 的接口进一步处理请求,比如对数据进行读取或修改(此时对页缓存进行操作)。
- 当需要访问存储设备上的数据时,文件系统会创建对存储设备的访问请求,IO 调度器根据预定策略对这些请求进行调度,以一定的顺序将请求发送给设备驱动。
- 设备驱动与存储设备进行交互并完全请求。
1. 虚拟文件系统
虚拟文件系统(Virtual File system,VFS)对多种文件系统进行管理和协调,允许它们在一个操作系统上共同工作。不同文件系统在存储设备上使用不同的数据结构和方式保存文件。为了让这些文件系统可以工作在一个操作系统之上,VFS 定义了一系列内存数据结构,并要求底层的不同文件系统提供指定的方法,然后利用这些方法将(存储设备上的)不同文件系统的元数据统一转换为 VFS 的内存数据结构。VFS 通过这些数据结构,向上为应用程序提供统一的文件系统服务。
接下去都将以 Linux 中的 VFS 为例进行讲解。
1.1. 面向应用程序的接口
虚拟文件系统与应用程序之间的文件接口。
int open( const char * pathname, int flags);
int open( const char * pathname,int flags, mode_t mode);
int close(int fd);
ssize_t read(int fd,void * buf ,size_t count);
ssize_t write (int fd,const void * buf,size_t count);
off_t lseek(int fildes,off_t offset ,int whence);
int fsync(int fd);
1.1.1. 路径解析
在操作准备操作文件的时候,首先会使用 open 接口将文件路径转换为一个文件描述符,此后使用该文件描述符进行文件上的其他操作。在调用 open 接口的时候,libc 会将其转换为 SYS_open 系统调用,由内核中的 VFS 进行处理,VFS 在处理 SYS_open 时,需要先解析应用程序提供的路径以找到对应文件,这个过程也就是路径解析。
在进行解析的时候,用户程序提供的路径被拆分成多个部分,每一个部分对应一个文件名。比如 /home/dawn/file.tex
会被拆分成 home、dawn、file.tex 。由于路径是以 /
开头的绝对路径,路径查找从整个操作系统的根目录开始。VFS 首先在其维护的各种缓存中进行查找,如果缓存中不存在此文件,则调用具体的文件系统接口进行查找。若文件系统中也无法找到该文件,则打开失败。当 home 文件被找到之后,VFS 还会进行一系列检查,如检查当前应用程序是否有访问该文件的权限、该文件是否为一个目录文件等。如果该文件是一个符号链接,VFS 还需要先解析该符号链接,再继续后面的路径查找工作。如果该文件是一个挂载点,则还需要更新当前路径的文件系统信息。
在这些都完成之后,VFS 可以开始继续下一步查找,即在 home 目录中查找继续 dawn 文件,过程与前述类似。当查找 file.tex 这一步的时候,如果 open 调用中指定了 O_CREAT 标记,那么当 file.tex 不存在的时候 VFS 会创建一个空文件。
在路径解析完成后,VFS 得到了 file.tex 文件所对应的 inode。然而,VFS 并不直接将 inode 返回给应用程序,而是在其中增加一层抽象,即文件描述符。
1.1.2. 文件描述符
文件描述符实际上是一个整数,每个进程维护了一个文件描述符表,这个表以文件描述符为索引,保存了一组文件描述结构。每个文件描述结构中记录了一个被打开文件的各种信息,如目标 inode、文件当前的读写位置和文件打开的模式等。
VFS 通过路径解析找到要打开的文件的 inode 后,VFS 会分配一个文件描述符和对应的文件描述符结构并进行初始化(将 inode 信息记录在文件描述符结构中、将文件读写位置设置为 0、将文件打开的模式也记录在文件描述符结构中。最后,VFS 将文件描述符返回给应用程序,表示文件已成功打开。
当应用程序使用文件描述符调用文件操作(read、write)时,VFS 使用文件描述符在该进程的文件描述符表中找到对应的文件描述结构。通过文件描述结构中保存的信息,VFS 可以继续执行应用程序所请求的操作。
文件描述符是应用程序和操作系统之间建立起的一个认证关系。应用程序首先使用文件路径换取文件描述符,此后使用文件描述符对该文件进行其他操作。文件描述符使得 VFS 无须每次都进行路径解析和各种检查操作,在一定程度上提高了效率。同时,通过维护的文件描述符表,操作系统可以监视和控制每个进程正打开的文件,对资源使用的情况进行控制。
1.1.3. 相对路径与当前目录
绝对路径以 /
开头,表示其查找从操作系统维护的根目录开始。
不以 /
开头的路径,均为相对路径。相对路径指代哪个文件,取决于其工作目录。在对相对路径进行解析时,是从其工作目录开始进行查找。在没有指定工作目录时,VFS 会默认使用每个进程的当前工作目录。可以通过 getcwd 和 chdir 获取和修改当前工作目录。
open 接口中,假如使用了相对路径,那么则是从当前工作目录开始进行路径解析。
openat 接口中(以 at 结尾),用户可以指定一个打开的目录的文件描述符 fd,让 VFS 从这个 fd 指定的目录开始进行路径解析。
1.1.4. 文件打开、统计、关闭接口
open、fstat、close 在使用这三个接口查看文件相关信息的时候会比较麻烦。
可以使用,lstat 和 stat 函数,这两个函数在目标文件为符号链接时会有所区别,lstat 会返回符号链接的信息,而stat 会继续跟随符号链接,返回其保存的目标文件的信息。
1.1.5. 文件读写接口
read 和 write 操作会从文件描述结构中记录的文件当前读写位置开始读写,读写操作都会影响这个当前读写位置,因为当前读写位置针对读写操作是共享的。
lseek 接口可以调整文件描述符结构中保存的文件读写位置。
pread、pwrite 可以从指定位置开始读写文件数据。
读写流程大致如下:
-
VFS 在处理文件读写请求时,通过文件描述符找到对应的文件读写位置和文件的 inode 结构。
-
随后,通过 inode 结构中保存的信息,定位到需要读写的文件的位置。文件系统先通过要读写的位置,确定文件是保存在直接数据块中还是保存在一级间接指针或者二级间接指针最终指向的数据块中,即确定要读写的数据块。
-
找到读写块之后,文件系统计算读写位置在目标数据块中的偏移量,并通过数据拷贝进行文件写入或者读取的操作。即将文件数据块中的数据拷贝到应用程序提供的缓冲区中,或将数据从应用程序提供的缓冲区拷贝到文件的数据块中。
-
对于写入操作,若写入的位置超出了文件当前的末尾,则文件的大小会增大。如果要写入的数据超出了文件的最后一个数据块,则文件系统会首先从存储设备中分配新的数据块,并将新的数据块记录在 inode 的文件索引结构中。这样,文件系统就可以进行正常的数据拷贝操作。
1.1.6. 目录操作接口
1.1.7. 链接相关接口
1.2. 面向文件系统的接口
虚拟文件系统与文件系统之间的接口。
1.2.1. VFS 定义的内存数据结构
1.2.1.1. VFS 中的超级块
VFS 定义了自己的内存超级块结构,其中保存了文件系统的通用元数据信息,如文件系统的类型、版本、挂载点信息等。每个挂载的文件系统实例均在内存中维护了一个 VFS 超级块结构。VFS 通过这些超级块结构中的通用的元数据信息对多个文件系统实例进行管理。除了这些通用元数据信息之外,VFS 的超级块结构还预留了一个指针。这个指针可以指向其特有的超级块信息。这种设计既达到了统一数据结构的目的,又保留了不同文件系统的特有信息,增加了 VFS 下文件系统的灵活性。
1.2.1.2. VFS 中的 inode
保存基本的文件元数据,若文件系统想要在内存 inode 结构中记录额外信息,其需要在为 VFS 的 inode 分配空间的时候多分配一些空间,之后通过计算偏移量的方式将额外信息保存在 VFS 的 inode 结构体之外。为了快速定位和使用 inode,VFS 维护了一个 inode 缓存(icache)。VFS 的 inode 缓存使用哈希表保存了操作系统中所有的 inode 结构。在应用程序或者文件系统需要使用某个 inode 时,那么可以直接从缓存中访问该 inode,当缓存中不存在的时候再去存储设备中访问。
1.2.1.3. VFS 中的文件数据管理
每个 VFS 的 inode 会使用基数树(radix tree)表示一个文件的数据。基数树中的每个叶子节点为一个内存页,保存了文件数据的一部分。这个基数树为这个文件的页缓存建立了索引。
1.2.1.4. VFS 中的目录项
VFS 中的目录项是在内存中保存文件名和目标 inode 号的结构,正如 inode 缓存一样,VFS 在内存中为目录项维护了一个缓存,称为目录项缓存(dcache)。
1.2.2. VFS 定义的文件系统方法
VFS 以函数指针的方式定义了底层的文件系统应该提供的方法。如底层文件系统被加载和初始化时,VFS 需要知道文件系统类型,同时还会挂载底层的文件系统,所以 VFS 定义了这些方法对应的函数指针,比如挂载方法的函数指针。
struct file_system_type {
const char *name;
...
struct dentry *(*mount) (struct file_system_type *, int, const char *, void *);
void (*kill_sb) (struct super_block *);
struct module *owner;
struct file_system_type * next;
struct hlist_head fs_supers;
...
};
同理,VFS 还定义了文件结构相关操作所需要的函数指针,主要包括文件的 open、read、write、llseek(定位)、mmap(内存映射)、fsync(同步写回)等。
struct file_operations {
struct module *owner;
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 *);
ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
int (*iterate) (struct file *, struct dir_context *);
int (*iterate_shared) (struct file *, struct dir_context *);
__poll_t (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
unsigned long mmap_supported_flags;
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **, void **);
long (*fallocate)(struct file *file, int mode, loff_t offset, loff_t len);
void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
unsigned (*mmap_capabilities)(struct file *);
#endif
ssize_t (*copy_file_range)(struct file *, loff_t, struct file *, loff_t, size_t, unsigned int);
int (*clone_file_range)(struct file *, loff_t, struct file *, loff_t, u64);
int (*dedupe_file_range)(struct file *, loff_t, struct file *, loff_t, u64);
int (*fadvise)(struct file *, loff_t, loff_t, int);
};
VFS 定义了涵盖文件系统的所有操作,当应用程序需要访问某个文件时,VFS 可以通过这些函数指针,调用相应的底层的文件系统的方法,从而实现对底层文件系统中数据的请求。
1.3. 缓存机制
1.3.1. 页缓存
大多数存储设备都是块接口,读写粒度都是一个块,大小通常为 512 个字节或者 4096 个字节。然而文件系统所进行的更改往往并非对齐到块的边界,其读写的字节数也并非恰好为块大小的整数倍。比如文件系统要修改存储设备中的 8 个字节而已,那么就需要先把这 8 个字节所在的块中的数据读入到一个 4096 字节大小的内存页之后。文件系统对内存页中的这 8 个字节进行修改,并将修改之后的内存页通过驱动写回存储设备之中。然而,一方面文件的访问具有时间局限性:当文件的一部分被访问后,有较高的概率会再次被使用。因此,当文件系统从设备中读取了某个文件的数据之后,可以让这些数据继续保留在内存中一段时间。
另一方面,存储设备的访问速度慢,与内存相比要慢几个数量级。如果每个写请求都等待写入设备完成,文件写操作的延时变长,吞吐量严重下降,从而影响整个系统的性能。为了获得更好的性能,在一个文件写请求返回到应用程序之后,允许其修改的数据暂时不持久化到存储设备中,后台慢慢地将这些数据持久化到存储设备上。在此期间,如果多次修改都是在同一个存储块中的话,那么还可以将多次修改合并。
除了后台将数据持久化到存储设备上之外,用户也可以使用 fsync 调用将写缓存区内存页中的数据写回到存储设备对应的存储块之中。
int fsync(int fd);
在 Linux 内核中,读缓存与写缓冲区的功能合并起来管理,被称为页缓存。页缓存就是以内存页为单位,将存储设备中的存储位置映射到内存中。底层文件系统通过 VFS 提供的相应接口对页缓存进行操作。
当一个文件被读取时,底层文件系统会检查其内容是否已经保存在页缓存中。如果文件数据已经保存在页缓存中,则底层文件系统直接从页缓存中读取数据返回给应用程序;如果没有底层文件系统会在页缓存中创建新的内存页,并从存储设备中读取相关的数据,然后将其保存在创建的内存页中。之后底层文件系统从内存页中读取相应的数据,返回给应用程序。
在进行文件修改的时候,底层文件系统同样会首先检查页缓存。如果要修改的数据已经在页缓存中,底层文件系统直接修改页缓存中的数据,并将该页标记为脏页。若不在页缓存中,底层文件系统同样会先创建页缓存并从存储设备中读取数据,然后在页缓存中进行修改并标记该页为脏页。标记为脏页的缓存会由底层文件系统定期写回到存储设备之中。当内存不足或者 fsync 被调用时脏页中的数据也会被写回到存储设备中。
1.3.2. 直接/缓存 IO
页缓存是持久化和性能之间权衡的产物。大多数情况下可以提升文件系统性能。但是有些情况下并不希望使用系统的页缓存。比如有些应用对持久化有较强的要求,不希望文件的修改内容缓存在页缓存中。如果使用系统的页缓存机制,这些应用需要在每次修改之后立即执行 fsync 操作,这会影响应用程序的性能。另一方面,一些应用程序(如数据库)会自己实现缓存机制对数据进行缓存和管理。由于应用程序更加了解自己对数据的需求,在这种情况下,系统提供的页缓存机制是冗余的,且会带来额外的性能开销。
因此,这些情况可以使用直接 IO。应用程序可以在打开文件的时候使用 O_DIRECT 标志,提示文件系统不要使用页缓存,这就是直接 IO。而相应的,使用页缓存的文件请求称为缓存 IO。
1.3.3. 内存映射---另于 read/write 方式的读写文件
除了 read 和 write 接口之外,应用程序还可以通过内存映射机制,以访问内存的形式访问文件内容。Linux 在页缓存的基础上实现了文件的内存映射机制。
首先应用程序先打开目标文件,获得其文件描述符。随后,应用程序调用 mmap 接口建立文件内存映射,调用时需要提供映射的目标虚拟地址、长度、属性、标志位、文件描述符和起始位置在文件中的偏移量。在处理内存映射时,VFS 会分配对应的 VMA 结构,并通过在 VMA 结构中记录目标文件的 inode 和映射时的属性,将 VMA 对应的虚拟地址空间与文件 inode 进行关联,最后返回起始虚拟地址给应用程序。
由于此时并未更新页表,当应用程序首次访问映射后的虚拟地址时,会触发缺页中断。Linux 在处理缺页中断时,会根据 VMA 中记录的 inode 信息,调用对应的底层文件系统进行处理。底层文件系统从页缓存中找到对应的内存页返回给 VFS。VFS 最终将页缓存中内存页的物理地址填入页表,并返回到应用程序继续执行。由于页表中的映射已经建立,应用程序对于同一虚拟页的后续访问不会再触发缺页中断(PS:就相当于将映射区域的虚拟地址映射到页缓存内存页对应的物理地址)。
需要注意的是,在进行内存映射之后,应用程序在进行访问时,访问的是页缓存对应的内存页。其修改的数据保存在页缓存中,可能并未写回到存储设备中。因此,为了保证修改的数据被写回到存储设备上,应用程序可以调用 msync 接口,请求 VFS 对指定的内存映射区域进行同步写回操作。
当所有的操作都完成之后,应用程序还可以调用 munmap,移除指定的虚拟内存地址区域上的内存映射。
1.4. VFS 对多种文件系统的组织和管理
1.4.1. 支持的文件系统
VFS 还需要对多种文件系统进行组织和管理。比如 Linux 中的 VFS 通过前面定义的一系列内存数据结构对多种文件系统进行管理。之外,Linux 还会将其支持的所有文件系统保存起来,包括内嵌的文件系统和在运行时作为模块加载的文件系统。如下所以,name 是文件系统类型的标识符,next 作为链表指针,指向支持的下一种文件系统。在进行存储设备挂载的时候,VFS 通过指定的文件系统标识符,找到对应的 file_system_type 结构,然后调用相应的挂载操作。
struct file_system_type {
const char *name;
...
struct dentry *(*mount) (struct file_system_type *, int, const char *, void *);
void (*kill_sb) (struct super_block *);
struct module *owner;
struct file_system_type * next;
struct hlist_head fs_supers;
struct lock_class_key s_lock_key;
struct lock_class_key s_umount_key;
struct lock_class_key s_vfs_rename_key;
struct lock_class_key s_writers_key[SB_FREEZE_LEVELS];
struct lock_class_key i_lock_key;
struct lock_class_key i_mutex_key;
struct lock_class_key i_mutex_dir_key;
};
1.4.2. 挂载
一般来说,每个文件系统在设计时都会有其本身的内部结构,这些结构均会有一个固定的入口保存在超级块中(底层文件系统中的超级块)。通常这个入口作为文件系统的根目录,即相当于每个文件系统都有自己的根目录。
Linux 中的 VFS 维护一个统一的 VFS 文件系统树(逻辑上的),通过挂载的方式将多个文件系统挂载到这颗树中。在操作系统启动的时候,会有一个根文件系统(底层的文件系统),这个根文件系统作为 VFS 文件系统树的基础。其他的底层文件系统可以自由地挂载到 VFS 文件系统树上的任何一个目录之上。这些作为挂载目标的目录,便被称为挂载点。当挂载成功之后,一旦文件访问操作到达挂载点,便会跳到被挂载文件系统的根目录处继续访问。当应用程序访问到挂载点及其子节点的时候,其实访问的是被挂载文件系统中的数据。
挂载点在挂载时不一定要是空目录,挂载点原有的内容会被临时“覆盖”,但并不会丢失。当所挂载的文件系统被卸载后,这些原有内容又可以再次被访问到。
Linux 的 VFS 中用于保存挂载信息的部分数据结构如下所示,每个挂载的文件系统都有一个 mount 结构,其中保存了挂载点信息(mnt_mp)和挂载的文件系统信息(mnt)。其中挂载点信息记录了挂载点对应的目录项等信息。挂载的文件系统信息则包含挂载文件系统的超级块、挂载文件系统的根目录和挂载选项。
通过这种对挂载点的维护,Linux 的 VFS 可以灵活地挂载多种文件系统,让不同的文件系统在相同的操作系统下共同工作。
struct mount {
struct hlist_node mnt_hash;
struct mount *mnt_parent;
struct dentry *mnt_mountpoint;
struct vfsmount mnt;
...
struct mountpoint *mnt_mp; /* 挂载点 */
struct hlist_node mnt_mp_list; /* 相同挂载点上挂载的其他文件系统 */
struct list_head mnt_umounting; /* list entry for umount propagation */
...
};
struct mountpoint {
struct hlist_node m_hash;
struct dentry *m_dentry;
struct hlist_head m_list;
int m_count;
};
struct vfsmount {
struct dentry *mnt_root; /* root of the mounted tree */
struct super_block *mnt_sb; /* pointer to superblock */
int mnt_flags;
};
附:Windows 采用的方式是将所有的文件系统放在固定的位置,使其相互独立。比如 Windows 中的 C、D、E 盘,这些都是一个个挂载的文件系统。这种情况下,新挂载的文件系统与其他文件系统在空间上形成隔离,拥有独立的一套路径空间。
1.5. 伪文件系统
Linux 实现了一些不用于保存文件数据的文件系统,称为伪文件系统。通过使用文件的抽象,伪文件系统能够直接获得内核中 VFS 所提供的命名、权限检查等功能。同时,由于文件接口的简单易用性,用户可以使用诸如文件管理、查看、监控等工具,与伪文件系统进行交互。伪文件系统的一个常见用途是:允许用户态程序通过读取文件的方式读取内核提供的信息,并通过写入文件的方式对内核进行配置与调整。 常见的伪文件系统如下所示,
通过读取 /proc/filesystem 查看当前 Linux 内核中支持的所有文件系统。其中 nodev(即 no device)开头的文件系统大多为伪文件系统,即它们不直接从设备中读取数据和保存数据到设备中。
$ cat /proc/filesystems
nodev sysfs
nodev rootfs
nodev ramfs
nodev bdev
nodev proc
nodev cpuset
nodev cgroup
nodev cgroup2
nodev tmpfs
nodev devtmpfs
nodev configfs
nodev debugfs
nodev tracefs
nodev securityfs
nodev sockfs
nodev dax
nodev bpf
nodev pipefs
nodev hugetlbfs
nodev devpts
ext3
ext2
ext4
squashfs
vfat
nodev ecryptfs
fuseblk
nodev fuse
nodev fusectl
nodev pstore
nodev mqueue
nodev autofs
nodev binfmt_misc
nodev overlay
nodev aufs
伪文件系统同样使用 VFS 所定义的内存数据结构和方法。当读取伪文件系统中的文件时,VFS 在进行路径解析的时候会发现目标文件被相应伪文件系统管理。因此,VFS 会通过记录在内存数据结构中的文件系统方法,将文件请求交由该伪文件系统进行处理。伪文件系统根据文件名等特征进行特定的内核操作。比如上述 /proc/filesystems 文件被读取时,/proc 文件系统会遍历保存在内存中的所有文件系统类型(file_system_type 结构),并将其逐一写入到用户态传递的缓冲区中。同理,当伪文件系统中的文件被写入时,伪文件系统会根据请求类型对内核状态进行改变,比如直接修改内核中相应的数据结构。
伪文件系统在系统调用之外提供了一种应用程序和内核交互的方法,提高了操作系统的灵活性。同时,由于用户态有大量文件操作的工具(cat、echo、重定向等)和脚本,系统管理员可以使用其熟悉的工具,非常简便地通过伪文件系统的文件接口检查和管理内核状态。
这个伪文件系统其实并没有实际的文件数据存在于内存的某个区域,它的本质就是针对某些数据结构的操作。比如我们读取 /proc/filesystems 的时候,那么 VFS 会通过函数指针的方式最终调用了遍历保存在内存中 数据结构的函数。同理,在写入的时候,VFS 会通过函数指针的方式最终调用修改内存中数据结构的函数,从而管理内核状态。
1.6. 巨人的肩膀
- 《现代操作系统原理与实现》-陈海波,夏虞斌