操作系统的很多核心组件都是相互关联的,比如虚拟内存管理,物理内存管理,文件系统,缓存系统,IO,设备管理等等,都要放在一起来看才能从整体上理解各个模块到底是如何交互和工作的。这个系列的目的也就是从整体上来理解计算机底层硬件和操作系统的一些重要的组件是如何工作的,从而来指导应用层的开发。这篇讲讲文件系统的重要概念,为后面的IO系统做铺垫。
文件系统主要有三类
1. 位于磁盘的文件系统,在物理磁盘上存储文件,比如NTFS, FAT, ext3, ext4
2. 虚拟文件系统,在内核中生成,没有物理的存储介质
3. 网络文件系统,位于远程主机上的文件系统,通过网络访问
一个操作系统可以支持多种底层不同的文件系统,为了给内核和用户进程提供统一的文件系统视图,Linux在用户进程和底层文件系统之间加入了一个抽象层,即虚拟文件系统(Virtual File System, VFS),进程所有的文件操作都通过VFS,由VFS来适配各种底层不同的文件系统,完成实际的文件操作。
通俗的说,VFS就是定义了一个通用文件系统的接口层和适配层,一方面为用户进程提供了一组统一的访问文件,目录和其他对象的统一方法,另一方面又要和不同的底层文件系统进行适配。
VFS采用了面向对象的思路来设计它的核心组件,只是VFS是用C写的,没有对象的语法,只能用struct来表示。我们按照面向对象的思路来理解VFS。
它有4个主要的对象类型:
1. 超级块对象,代表一个具体的已安装(mount)的文件系统
2. inode对象,表示一个具体的文件
3. 目录项对象,代表一个目录项,是路径的一部分,比如一个路径 /home/foo/hello.txt,那么目录项有home, foo, hello.txt
4. 打开文件对象,表示一个打开的文件,有读写的pos位置,也叫文件句柄,说白了就是open系统调用在内核创建的一个数据结构
VFS给每个对象都定义了一组操作对象(函数指针),给出了这些操作的默认实现,底层不同的文件系统可以重写(override)VFS的操作函数来给出自己的具体操作实现,也可以复用VFS的默认实现。实际情况是底层文件系统部分操作由自己单独实现,部分复用了VFS的默认实现。
操作对象有:
1. super_operations对象,针对超级块对象,包含了内核对特定文件系统所能调用的方法,比如wirte_inode(),sync_fs()等
2. inode_operations对象,针对inode对象,包含了内核对特定文件所能调用的方法,比如create(), link()等
3. dentry_operations对象(directory entry),针对目录项对象,包含了内核对特定目录所能调用的方法,比如d_compare()和d_delete()方法等
4. file_operations对象,针对打开文件对象,包含了进程对打开文件对象所能调用的方法,比如read()和write()等
文件系统说白了就是文件内容和存储系统对应的块的映射关系,是来管理文件的存储的。inode-block结构把文件分为了两部分,inode表示元数据,block表示存储文件内容的具体的逻辑块。VFS没有用单独的对象来表示block,block的属性在超级块和inode块中包含了。
下面这张图包含了VFS的主要对象和操作对象,以及对象之间的指针指向关系,
1. 可以看到对象都维护了一个X_op指针指向它所对应的操作对象。
2. 超级块维护了一个s_files指针指向了内核所有的打开文件对象的链表,这个信息是所有进程共享的
3. 目录向对象和inode对象都维护了一个X_sb指针指向超级块对象,从而可以获得整个文件系统的元数据信息
4. 目录项对象和inode对象各自维护了指向对方的指针,可以找到对方的数据
5. 打开文件对象维护了一个f_dentry对象,指向了它对应的目录项对象,从而可以根据目录项对象找到它对应的inode信息
6.
task_struct表示进程对象,维护了一个files指针,指向了进程打开的文件链表,这个是进程单独的视图,进程还维护了文件描述符表(file descriptor, fd),所谓的文件描述符就是一个整数,这个数字就是文件描述符表的索引,表项里面存着对应的打开文件对象的指针,所以进程操作打开文件的系统调用只需要传递一个文件描述符即可。由内核来维护打开文件对象,进程只能看到文件描述符这个整数
7. address_space也是一个重要的对象,它表示一个文件在页缓存中已经缓存了的物理页,内部维护了一个树结构来指向所有的物理页结构page,同时维护了一个host指针指向inode对象来获得文件的元数据。会在说页缓存的时候再来看address_space
超级块
先来看一下超级块,它包含了一个文件系统的元数据。超级块到底是如何存储在磁盘上的呢?在这篇计算机底层知识拾遗(三)理解磁盘的机制 我们说了磁盘的最小物理单元是扇区,一个扇区512个字节。块就是这样说的block,是表示磁盘数据的最小逻辑单元,1个块一般有1kb, 2kb, 4kb, 8kb等,所以1个逻辑块block对应多个物理扇区。整个磁盘的第一个扇区存放着计算机的引导(boot)信息MBR(Master Boot Record),MBR存放着磁盘的逻辑分区表,如果磁盘的第一个扇区坏了导致分区表丢失,那么整个计算机就启动不了了。操作系统把逻辑分区也认为是单独的逻辑磁盘,所以实际上每个逻辑分区的第一个扇区也可以存放MBR,这也是为什么一台计算机可以安装多个操作系统的原因。
除了第一个启动扇区,其他的扇区都被逻辑上划分到不同的块组Block Group了,如下图所示
而每个块组则包括了超级块和这个块组内的inode, block数据。一个块组的数据在物理上也是连续的,所以实际给文件分配block时会优先在同一个块组分配。
我们说了一个文件系统只有一个超级块,所以只有第一个块组的第一个块是超级块,其他块组都是超级块的备份,防止超级块损坏导致整个文件系统损坏。
我们可以看到块组的结构如下:
1. 超级块,存放着整个文件系统的元数据
2. 块组描述信息,存放这该块组的元数据,可以在后面的实例看到
3. block位图,磁盘采用了位图的方式来记录哪些块被使用了,哪些块未被使用,位图中的1位表示一个块的块号
4. inode位图,同样inode位图表示了哪些inode被使用了,哪些未被使用,位图中的1位表示一个inode的号
5. inode表,是该块组所有的inode实际的存储块
6. block块,是该块组所有的block块
可以用dumpe2fs命令来查看超级块的信息和所有的块组信息,我们来看个例子
首先用df命令来看文件系统是如何挂载的, 我们看到安装文件系统的逻辑分区/dev/sda6 挂载在了/根目录
然后用 sudo dumpe2fs /dev/sda6来查看超级块和块组信息, 超级块的信息可以看到整个文件系统的inode和block数量,未使用的inode和block数量,block大小,这里是4KB
再看block group的数据,我们可以看到inode和block的数量/号是均匀分配在不同的block group里面的,同时每个block group还记录了该组的inode和block使用情况。
由于block大小是固定,扇区的大小也是固定的,所以可以很方便地计算出某个块号对应着哪个扇区号,知道了这个信息,磁盘控制器就能很快地根据块号去对应的扇区读写数据。
要记住,对于一个设备来说,inode号和block块号都是唯一的
超级块的这些数据存放在第一个块组的第一个块,所以操作系统很容易就把超级块的数据加载到内存中,用超级块对象结构来对应超级块的实际数据。超级块对象是常驻内存的,并被缓存的。因为超级块维护着整个文件系统的元数据信息,所以文件系统的任意元数据修改都要修改超级块对象。
来看一下超级块的数据结构,我们上面说了s_file的作用,再说一下另一个重要的字段 s_dirty,它指向了所有脏的inode链表,这样当要回写所有脏的inode到磁盘时,不需要去遍历所有的inode,只需要通过s_dirty来遍历脏的inode链表
再看一下超级块对应的操作对象super_operations,它定义了内核可以对超级块的操作
inode
Linux的文件系统把inode当做文件的唯一标识,一个文件对应一个inode,如果inode用完了,那么就不能再新建文件了。这篇Java中如何获得文件的inode信息 说了如何在Java中获得inode信息。
inode结构保存了一个文件的元数据信息,以及这个文件的内存所在的block,从inode可以找到这个文件所有的block。我们先看一下inode的结构定义
有几个重要的属性说一下
1. i_ino记录了这个inode的编号,这个编号是唯一的
2. i_size记录了这个文件按字节计算的大小, i_blocks记录了按块记录的这个文件的块数,这样根据单个块的长度就能计算出按块计算的长度。我们用ls命令列出的文件大小通常就是按块计算的长度,因为单个块只能记录属于一个文件的内容
3. i_atime记录了最后访问这个文件的时间 i_mtime记录最后修改这个文件内容的时间 i_ctime记录了最后修改inode的时间,即修改文件元数据的时间
4. i_count是引用计数,即有多少进程访问这个inode。当i_count为0的时候这个inode结构才能从内存中被消除。 i_nlink是指向这个文件的硬链接的计数,硬链接是不会新建inode的
5. Linux几乎把一切设备和IO都当做文件(除了网络设备),所以使用了一个联合来标识设备,i_pipe指向管道,i_bdev指向inode所在的块设备,i_cdev指向字符设备
6. i_dentry指针指向和这个inode对应的目录项,目录和普通文件都是文件,都有Inode,也都有d_entry结构
7. i_sb指针指向超级块,来获得文件系统的元数据
8. i_mode维护了该文件的读写权限信息, i_uid和i_gid记录了用户和组的信息
9. i_mapping指向了address_space,记录了这个文件被映射的信息
从内存的角度来看inode,一个inode只可能处于3种状态之一
1. inode存于内存中,没有被任何进程引用,不处于活动使用状态,也没有被修改过
2. inode存于内存中,正被一个或多个进程引用,即它的i_count和i_nlink都大于0,且文件内容和Inode元数据内容都没有被修改过
3. inode处于内存中,内容或元数据被修改过,即inode是脏的
内核提供了3个全局的链表来管理这3种状态的inode,inode_unused对应于第一种情况,inode_in_use对应于第二种情况,超级块的s_dirty链表对应第三种情况。任何时刻内存中的inode只能在这3个链表之一,使用了i_list指针指向它所在的链表。维护这3个链表的好处是,当脏数据写回到磁盘时,只需要遍历超级块 super_block -> s_dirty上所有的inode就行。
VFS并没有专门的block对象来表示磁盘上块,inode对象也没有记录它对应的具体的块号,只记录了所占的块数。那么一个文件的内容实际存储的块的数据是如何记录的呢?这个是记录在磁盘中的,并且由磁盘控制器去管理的,磁盘控制器根据一个文件的块号,就能找到实际物理存储的块的位置。
上面描述磁盘块组结构的图中可以看到,每个inode号位于哪个块组是可以很方便计算出来的,每个块组维护了一个inode表,一个inode的长度是固定的,在是128个字节,那么可以很快找到给定的inode号的inode存储的物理区域。在磁盘上存储的inode数据出来文件的元数据以外,还存储了这个文件的所有block块号。
问题来了,既然inode是128个字节,记录一个block编号就要4字节,那么1个inode根本存不了几个block编号。所以inode的设计采用了间接映射的方法
1. 1个inode存储12个直接映射的block编号,占用48个字节
2. 1个inode存储一个一次间接块编号,占用4字节,间接块对应的实际物理块不存储文件的内容,而是用来存储block编号,比如4KB的块大小,就可以存储1000个block编号
3. 1个inode存储一个二次间接块编号,占用4字节,二次间接块的第一层记录第二次间接块的编号,第二次间接块存储实际的block编号
4. 1个inode存储一个三次间接块编号,占用4字节,比二次间接再多一次间接
这种间接的设计在计算机领域很常用,比如页面也是采用了类似的结构,把一个线性结构变成一个层次结构,一方面可以压缩存储空间,另一方面可以表示很大的地址空间。
同样,我们可以把这个层次结构还原成一个线性结构,可以理解成一个数组,只要提供一个文件的块号,磁盘控制器就可以快速地定位到具体的存储块号的位置,再找到实际存储的磁盘的块,也就找到了实际存储的磁盘扇区位置。
如果块是1KB大小的话,inode能够表示的单个最大文件是16G,如果是2KB的块,能够表示的单个最大文件是256G,如果是4KB的块,能够表示的单个最大文件是4TB。现在很多服务器都是8KB的块,可以表示的单个最大文件足够大了
inode_operations定义的函数如下
目录项
目录和普通文件一样,都是文件,都有inode,区别是目录的块存储的是这个目录下的所有的文件的inode号和文件名等信息。操作系统检索一个文件,都是从根目录开始,按层次解析路径中的所有目录,直到定位到文件。所以目录的解析是一个非常频繁的操作。
VFS抽象了目录项对象来表示查找路径中的目录和文件,查找一个文件都是通过目录项来的。内核还建立了目录项缓存来优化查找速度。
目录项 d_entry的结构定义如下,它维护了目录操作需要的元数据信息,
1. 能通过 d_inode找到该目录项对应的目录或文件的inode
2. 通过d_sb找到超级块
3. 通过d_parent来找到父目录项,从而构成一个树形结构
4. d_name记录了目录/文件的名称
和inode一样,内核维护了两个全局数据结构来快速寻找所有的d_entry对象,实现了d_entry对象的缓存功能
1. dentry_hashtable存放了所有活动的d_entry对象
2. dentry_unused存放了所有非活动的(d_count引用计数为0)的d_entry对象,这是一个LRU链表结构,可以方便地快速释放非活动的d_entry对象
打开文件对象
所谓的打开文件对象是由open系统调用在内核中创建的,也叫文件句柄。open系统调用返回一个文件描述符,用户进程所有对文件读写操作系统调用都是基于给定的文件描述符进行的,换句话说,所有对文件的读写操作,必须基于打开文件对象进行。
多个进程可以同时指向一个打开文件对象(fork时),多个打开文件对象可以指向同一个文件inode。
打开文件对象的结构定义如下
1. f_dentry指向了打开文件对应的目录项,目录项又指向了对应的inode,从而把打开文件和inode关联起来。打开文件对象没有实际对应的磁盘数据,所以它也不需要表示打开文件对象是否脏,是否需要写回等标志位
2. f_pos表示文件当前的偏移量
3. f_count表示文件对象的使用计数
4. 文件锁相关的属性
从打开文件对象的结构定义可以看出,它只是用来表示打开一个文件的状态的抽象,实际文件内容的读写是通过read(), write()系统调用完成的,数据的修改存放在页缓存中,后面会专门讲页缓存的机制。
我们之前说了内核使用 task_struct来表示单个进程的描述符,每个进程维护了它的打开文件信息和文件描述符信息,我们来看一下进程相关的打开文件信息是如何表示的。
task_struct中维护了一个 files_struct的指针来指向它的文件描述符表和打开的文件对象信息
下面看看files_struct的结构
1. fd_array数组是file结构的数组,即表示打开文件对象的数组。NR_OPEN_DEFAULT在64位机器下默认是64,它的目的是方便快速找到64个最初的打开文件对象
2. next_fd用来存储下一个要生成文件描述符的编号
3. 当进程要打开多于64个文件时怎么办呢,比如网络编程中,每个socket请求就是一个打开的文件,如何处理多于64的情况呢,这就得依靠fdtable这个结构,也就是常说的文件描述符表
看一下fdtable 文件描述符表的结构定义
1. max_fds表示当前进程可以打开的最大的文件描述符的数量,这个值不是固定的,是可以调节的(Rlimit)
2. fd是一个指针数组,指向所有打开的文件,数组的索引就是所谓的文件描述符fd(file descriptor)
3. open_fds是一个用位图表示的当前打开的文件描述符,可以方便快速遍历空闲的文件描述符
4. next指针可以指向下一个fdtable结构,这样文件描述符表可以表示成链表结构,也就是支持动态扩展的,可以保证单个进程可以打开足够多的文件
进程维护的文件描述符信息和打开文件信息可以用下图表示
mount
最后再说说文件系统的 mount操作到底做了什么工作。内核维护了一个树形结构表示文件系统层次结构,文件系统可以挂载到树形结构之上
理论上目树上的每个目录都可以成为装载点,装载点可以用来把新的文件系统加入到文件系统的目录树上。装载动作由 mount系统调用完成。每个文件系统都有一个根目录,当它装载到装载点时,会把根目录的内容替换到装载点。每个装载的文件系统都维护了一个vfsmount的结构
1. mnt_mountpoint记录了该文件系统的装载点在父文件系统中的dentry对象,即它对应的目录
2. mnt_root记录了当前文件系统的根目录的dentry对象,实际上它和mnt_mountpoint都指向同一个dentry对象,即装载点
3. mnt_sb指向了这个文件系统的超级块,我们知道超级块记录了这个文件系统的元数据信息
4. mnt_parent指向了父文件系统的vfsmount对象,可以获得父文件系统的一些装载信息
关于文件操作相关的更多内容会单独说文件IO的时候再涉及。