Linux 虚拟文件系统(一)概述
tags: Linux源码
文章梗概
本文首先以“尽量不涉及源代码”的方式讨论Linux虚拟文件系统
的存在的意义、实现方式;后续文章中以读文件
为例从面到点更有针对性的讨论其实现。在讨论的过程中有一些地方可能说的不够全面,一是能力有限;另一方面是希望不要陷入过多的细节之中,将注意力集中在框架的设计上。希望读者有一些编程基础、操作系统概念。本文讨论的Linux内核版本为2.6.24
。
正文
文件系统
常用的文件的读写的方式有同步读写
、异步读写
和内存映射
。前两种的主要区别是,同步读写
会在进程向操作系统发出读写请求后被阻塞,直至所请求的操作完成时读写的函数才会返回。也就是说,函数返回的时候数据已经从磁盘中读到内存中了,或者已经从内存中写入到磁盘上去了;异步读写
在发出读写请求后,函数会立即返回并不等待操作完成,这样的话一般有一个回调函数的存在,操作完成时操作系统会调用用户设置的回调函数;至于内存映射
使用的则是缺页机制。这篇文章中主要围绕同步读写
的方式,也是最常用的方式,讨论Linux的虚拟文件系统的实现原理。
要想说明白虚拟文件系统的作用就必须要了解文件系统的作用。对于一个特定的文件系统来说,最主要的的设计方面就是数据在磁盘上的存储方式
,这个功能从应用程序猿的角度来看就是文件名到数据的映射
: 通过对 以文件名为主要的参数调用特定的函数(不同的语言、平台函数不同)完成文件的读写操作。而磁盘基本上可以看做一个以扇区
(通常为521Byte)为基本存储单位的超大数组。如果有如下的极其简单的文件系统,
扇区0存放着一整块磁盘的属性,如块大小、文件系统标记;1~6中存放着文件的属性和位置信息,每个文件目录项占两个磁盘块,也就是说该文件系统的设计最多可以存放三个文件;1、2中存放着文件test.txt
的属性信息(日期、大小、归属)和位置信息;3、4存放着艳照门1.png
的属性信息和位置信息;7~15中存放着实际的数据,不同的数据的颜色不同。如图示的文件系统最多只能存放三个文件,很难有实际的用途,不过用来说明文件系统的作用是很合适的,避免陷入过多的细节之中。
加入现在用户想要读取查看艳照门1.png
,那么文件系统要做的事情是这样的 :
- 产生读入1、2块磁盘数据的请求,将请求发送给磁盘的驱动程序,等待数据读入到内存后,查看文件的名字,发现文件的名字并不是
艳照门1.png
。 - 产生读入3、4块磁盘数据的请求,将请求发送给磁盘的驱动程序,等待数据读入到内存后,查看文件的名字,发现名字匹配正确。然后分析目录项数据,得知
艳照门1.png
主要存储在12~15四个磁盘块上。 - 产生读入12~15四个磁盘块的请求,将请求发送给磁盘的驱动程序,等待数据读入到内存后即可按照
png
文件的格式解析数据然后将图像呈现在用户面前。
这里有几点点需要说明一下
- 真正的读写磁盘的操作并不是文件系统来完成的,而是特定的磁盘的驱动程序来完成的。磁盘的驱动程序需要的参数是
逻辑磁盘块号
、对应的内存位置
和是读请求还是写请求
。由磁盘驱动程序来完成读写的目的是,实现文件系统的设计和具体磁盘结构的分离。也就是说,我们上面设计的极简的文件系统在驱动程序的帮助下无论是在机械硬盘上面,还是在固态硬盘上面实现方式是相同的。磁盘驱动程序更加的贴近硬件设备,更加的“了解”硬件。譬如,如果使用的是固态硬盘那么驱动程序则不会对读写请求进行排序;如果使用的是机械硬盘,则会对读写请求进行排序。参考机械硬盘内部硬件结构和工作原理详解可知对于机械硬盘,按照顺序1、2、3
读取磁盘块的速度是快于1、3、2
速度的,所以磁盘驱动程序读写请求排序所做的事情简单讲就是将1、3、2
这样的读写请求排序成为1、2、3
这样的顺序,然后发送给磁盘控制器
(硬件)。而固态硬盘更贵的原因在于其读写过程不包含物理属性(移动磁头),请求1、2、3
、1、3、2
和请求2、3、1
的速度都是一样的,所以不需要重排请求。- 1 中提到的请求排序的专业术语叫做
I/O调度
,这一工作应该是由驱动程序完成的,只不过内核提供了一些的常用算法的实现,方便了驱动程序的开发而已。- 一个对特定文件的读写请求可能会产生很多个对磁盘的读写请求。如上所说,要读文件首先要把文件的固有属性读到内存中加以分析,然后才能去读实际的文件。
虚拟文件系统架构
上文以比较易懂的方式说明了文件系统一个最重要的工作文件名到磁盘数据的映射
,可是单从这一个方面似乎看不出来虚拟文件系统存在的意义。那是因为,实际上文件系统要考虑很多的事情 :
- 首先要考虑的一个重要的方面就是,磁盘和内存的速度差距。众所周知,磁盘的速度慢但是每单位空间价格更加便宜,而内存的速度基本上是磁盘的 105 倍,这之间的差距还是非常大的。用户读写文件的时候经常存在着这样的行为,刚刚读写过的文件,很有可能再次读写,所以引入缓存的概念是十分必要的。缓存所做的就是在读文件数据到内存之后,找个地方把数据存起来,内存不紧张的话,尽可能长时间将数据保存在内存之中。如果再次读文件,文件系统就去查看是不是在缓存中已经有对应的数据了,如有的话就不需要再次去磁盘上读取数据;实在没有的话,才需要向驱动程序发出读请求。用户是意识不到缓存的存在,所以从用户的角度来看这将极大的加快了数据的读取速度。不过要说缺点的话,这样的设计可能会占用比较多的内存空间,使文件系统和内存管理变得更加复杂。不过现阶段的PC平台内存并不是性能瓶颈,而且为了速度和用户体验这些都是值得的。
- 可想而知,缓存的单位指定不能是字节。如果是字节的话需要记录文件的每个字节缓存在内存的哪个位置,这样是不现实的。缓存的单位至少也是和
磁盘扇区
一样大的,或者是多个连续的扇区存放在一块。以上面的最简文件系统为例,可以很轻松的设计一个这样的数据结构来记录艳照门1.png的第一块
也就是磁盘索引的12逻辑块
存放在内存的 0xFFAA 处、第二块到第四块
存放在 0xAABB 处。为了和内存管理模块更好的交互,Linux中的针对文件数据的缓存的单位为页
,通常大小为 4KB ,也正是内存管理模块使用的内存单位。这里需要读者有一定的操作系统的基础,能够大体上理解分页机制存在的原因以及做了什么事情,由于篇幅原因这里只需要知道在Linux中,内核使用struct page
结构体来描述内存中的每一页。找到一页对应的page结构体
就相当于知道了对应的物理页的物理地址、是否被占用等等很重要的属性。这些文字是想说明,文件系统需要和内存管理模块协调工作,毕竟分配
、回收
缓存的内存空间等操作都是需要内存管理模块支持的。 - 第三个要考虑的部分和第一个类似,这个机制叫做“预读机制”。类似于,在浏览网页的时候有些浏览器能够提供预读,看完当前的网页点击下一页之后,下一页面能够非常快的呈现在面前。这里的预读机制也就是这个意思,尤其是对于一些比较大的文件。用户第一次想要的可能就是前 1kb ,这个时候文件系统向驱动程序发出的读请求是大于 1kb 的,可能是 4kb 、可能是 1mb ,多读出来的数据就存在上面提到的缓存。可以看出来预读机制和缓存机制是完美结合的,协同工作的。
- 1中提到的这个缓存指的是对文件内容的缓存,还有一种缓存是针对目录项的缓存。回顾上文,如果读取
艳照门1.png
之后,用户又想查看文件test.txt
,文件系统又要多次读取1~6块磁盘来查找是不是有test.txt
这个文件。这样的话,引入一个针对文件名的缓存也是很重要的,这个缓存在Linux内核中叫做目录项缓存。和上面的缓存类似,只不过是针对目录项的。 - 权限检测也是文件系统要做的很重要的一个工作,尤其是在Linux系统上面,经常会遇到读写文件时
permission denied
的情况。如果没有管理员权限,操作系统(文件系统)是不会让你去读写一些受保护的文件的,window中可能感受不是那么强烈。不过除了这中常规意义上的限制,权限检测还包括:文件是否越界、是否在读写一个目录项文件等操作。 - 每个进程都有都会以自己特定的方式(读、写)打开文件,且文件读取的当前位置也是特定于进程的,这些属性是需要记录的。而且对于一个文件很多时候可能会有多个进程同时去读写的情况,如何协调多个进程之间的读写关系,不至于出现错误的情况,也是文件系统设计时需要考虑的一个关键方面。
- 锁机制、命名空间等其他的一些部分。
回顾这么多设计一个文件系统需要注意的地方可以发现,上面的几条对于每一个文件系统都是需要的。也就是说,不管是上面提到的极简文件系统还是在实际使用中的FAT、EXT文件系统,都需要考虑上面的这些因素。为了加快内核的开发、方便后续内核的扩展重构、使内核设计的更加优雅这才提出了虚拟文件系统的概念。虚拟文件系统做的事情就是实现了这样一个框架,在这个框架中上面提及的这些重要因素都有了默认的实现,需要特定的文件系统实现的为
文件相对块号到磁盘逻辑块号的映射关系
、目录项的解析方式
等。
文件相对块号就是指的相对于文件来说是第几块,磁盘逻辑块号指的是相对于磁盘来说是第几块。也就是说,如果想让内核识别
极简文件系统
需要该文件系统的设计人员严格按照虚拟文件系统的架构编写需要的函数(都是函数指针的技术实现的),然后将文件系统注册到内核中去。
(在这之前文件系统和虚拟文件系统的概念界限是有点模糊的,从这里开始一直到文章的最后,文件系统只是表示磁盘上的数据存储的结构,其他的部分都算在虚拟文件系统里面的 )
虚拟文件系统之所以没有实现这两个方面是因为这些性质是特定于一个文件系统的。还是上面读取
艳照门1.png
的例子,在极简文件系统中艳照门1.png第一块
是存放在磁盘逻辑12块
中的,也就是文件相对块号1->磁盘逻辑块号12
。而如果使用的是FAT文件系统那么就可是其他的映射关系。目录项的解析方式
需要特定的文件系统来实现就更好理解了,不同的文件系统其目录项的字段设置、顺序、长度通常是不一样的,所以需要让特定的文件系统来解析目录项。解析完之后返回一个统一的文件的表示,也就是大名鼎鼎的inode
。struct inode
的字段非常之多,对于一些没那么复杂的文件系统来说可能是一种浪费,因为它根本用不到那些复杂的字段。但是虚拟文件系统的设计理念是宁滥勿缺
: 毕竟要尽可能地覆盖所有的文件系统,多的字段你可以不用,但是如果想用却没有那就麻烦大了。
这个时候看一下Linux虚拟文件系统的整体结构,再合适不过了。
虚拟文件系统(VFS,virtual file system)需要和各个实际的文件系统ext3
、… 、reiser
、proc
交互,大多数文件系统都需要虚拟文件系统提供的缓存机制(Buffer Cache),而proc
文件系统不需要缓存机制是因为其是基于内存的文件系统。那么按照上面的讨论其需要做的就是完成文件逻辑块号
到内存中数据块
之间的映射关系。再往下一层就是具体的设备驱动层,实际的读写操作都是需要设备驱动层去完成的,它下一层就是实际的物理设备了。
虚拟文件系统如何知道可用的文件系统有哪些的
上面提到的特定于文件系统的操作是通过注册的方式让虚拟文件系统知道当前内核中支持哪些操作系统,注册的主要参数有文件系统的名字
、解析inode的函数(解析目录项)
、解析文件相对块号到磁盘逻辑块号的函数
,这都是上面讨论过的关键点。对于一些常用的文件系统不用注册也能够使用,这是由操作系统去注册的。注册使用的技术就是C语言中的函数指针
。注册完成之后,就可以通过挂载
的方式去使用一个具体的文件系统了。挂载需要的参数有被挂在的设备(本文讨论中限定为磁盘)
、该设备使用的文件系统(必须已经注册过)
、挂载点
。如果明白前文的讨论,那么挂载也是很好理解的。以上文的极简文件系统为例,艳照门1.png
存放在磁盘A
中,在磁盘A
没有被挂在之前操作系统(或者说虚拟文件系统)并不知道磁盘A
使用的什么文件系统,所以没有办法去读取它上面的数据并解析之。在用户执行了操作以极简文件系统挂载磁盘A到 /home/jingjinhao/片 下
之后,在访问/home/jiangjinhao/片/艳照门1.png
的时候虚拟文件系统就会调用极简系统注册的函数,去执行前文讨论过的寻找艳照门1.png
的过程。毫无疑问操作系统需要维护一个目录之间的层级关系以及不同的文件系统之间的挂载关系,这正是struct dentry
和struct vfsmound
的作用。这之间的具体图示关系请参考mount过程分析之六——挂载关系(图解) 。感觉自己没有能力写出来一篇比这还好的文章,推荐看一下这篇文章。
不太喜欢的环节
上文提到struct inode
、strcut dentry
和struct vfsmound
这三个数据结构都是虚拟文件系统非常重要的部分。虽然不大喜欢扣数据结构,不过为了下文更好的讨论这里还是尽量从原理上罗列一下核心数据结构。
struct address_space
这个数据结构是对上文讨论过的缓存
的抽象。该数据结构可以提供查找缓存、添加缓存的方法,也就是说对于一个文件找到了其对应的struct address_space
就能够增删改查
缓存的内容。暂时不必关心起底层的实现是链表、数组还是树(其实是基数树),无论是什么其提供的功能总是不变的,只不过速度上可能会有差别。查找使用的参数是文件相对页号
,成功返回对应的物理页帧描述结构struct page
的指针(上文描述过),没有找到的话返回null
。这里的文件相对页号很好理解,举例来说在页大小为4KB的情况下,0~4KB对也相对页号为0,4KB~8Kb对应的相对页号为1,以此类推。struct inode
是对一个文件的抽象,所以其中包含的主要字段有 : 文件的大小、日期、所有者等固有属性;指向缓存的指针struct address_space *
;指向块设备驱动程序的指针block_device*
,因为文件系统并不负责实际的读写,需要依靠驱动程序的帮助;一些锁。这几大类字段,在上文的讨论下都是比较好理解的,需要说明的一点就是inode
中是没有文件的名字这个字段,文件的名字包含在下面的dentry
中,所以取而代之的是指向对应文件的struct dentry
的指针。这并不是说一定不能把文件名存储在inode
中,只不过当前虚拟文件系统的设计使然,再在inode
中存储的话就有点啰嗦了。struct dentry
首先实现了对目录层次结构的抽象,如下图内存中每个打开的的每个节点都对应一个
struct dentry
的实例,需要强调的不仅仅目录有对应的detry
实例,普通的文件也有对应的detry
,只不过普通文件的detry
实例没有子节点罢了。只有打开的文件或目录才有对应的节点,所以内存中树结构的完整度是 ≤ <script type="math/tex" id="MathJax-Element-10">≤</script>磁盘上树结构的完整度的;没有在inode
中而是在detry
存储文件的名字的一大原因struct detry
负责建立起前文讨论过的目录项缓存
(以Hash表的方式)。也就是说在打开一个文件的时候,虚拟文件系统会首先通过文件名
查找是否存在一个打开的detry
了,如果有的话就大可返回了;detry
最后一个重要的作用就是结合struct vfsmount
完成了挂载操作的数据结构的支持。上图中的示例在vfsmount
的视图中如下图示
此外硬链接的实现也是需要detry
的支持的。- 正如上面所说虚拟文件系统需要记录某个进程操作一个文件的方式、当前位置等属性,这些属性是特定于一个进程的,一个文件可能同时存在多种被打开的状态,由此引入了
struct file
结构体。该结构体是对一次文件操作的抽象,刚刚提到的几个方面外,file
中还包含了预读相关的一些字段。每个进程控制块struct task
中都包含一个struct file *
的数组,进程打开的每个文件对应其中的一项,这也解释了为什么fopen
返回的是一个无符号整型了(数组的索引)。 - 除了上面讨论的四大方面的属性,还有一个方面的属性可以用一个单独的数据结构抽象出来,这就是特定于一个文件系统的属性,譬如极简文件系统的
最多只能存三个文件
这类似的属性,这个结构体叫做struct super_block
。以极简文件系统为例其对应着磁盘逻辑地址0的块中的数据。
通过上面的讨论就可以通过下图来纵观虚拟文件系统的结构了,该图引自深入Linux内核架构中文版418页,请暂时忽略各个*_operations
,其余的不外乎刚刚讨论过的五个结构体,希望读者能够认真看一下这个图片。
图中的几个*_operations
都是一些函数指针
的结构体,注册文件系统的精髓就是将自己实现的功能函数以函数指针的形式传递给虚拟文件系统。
譬如对于ext2
文件系统其对应的address_space_operations
为
const struct address_space_operations ext2_aops = {
.readpage = ext2_readpage,
.readpages = ext2_readpages,
.writepage = ext2_writepage,
.sync_page = block_sync_page,
.write_begin = ext2_write_begin,
.write_end = generic_write_end,
.bmap = ext2_bmap,
.direct_IO = ext2_direct_IO,
.writepages = ext2_writepages,
.migratepage = buffer_migrate_page,
};
//重
static int ext2_readpage(struct file *file, struct page *page)
{
//@ page 要读的相对于文件的页号
//特定于ext2文件系统的 相对文件块号->磁盘逻辑块号 映射关系 函数
return mpage_readpage(page, ext2_get_block);
}
其中比较重要的下篇文章可能用到的为ext2_readpage
函数,该函数直接调用了mpage_readpage
,这个函数是虚拟文件系统提供的,ext2_get_block是ext2
文件系统提供的。下篇文章还有相关的讨论,这里不再赘述。
另一个比较重要的函数为inode_operations->look_up = ext2_lookup
这个函数就上文一再强调的解析目录项的函数。
引用
本文使用的引用基本上都在文中以超链的方式给出了,侵删。还需要声明一下,更多的是对现有博客和书籍的补充,具体的实现请参照Linux三本经典书籍。