虚拟文件系统(VFS)的作用
虚拟文件系统(Virtual Filesystem)也可以称之为虚拟文件系统转换(Virtual Filesystem Switch,VFS),
是一个内核软件层,
用来处理与Unix标准文件系统相关的所有系统调用。
其健壮性表现在能为各种文件系统提供一个通用的接口。
VFS支持的文件系统可以划分为三种主要类型:
1.磁盘文件系统
这些文件系统管理在本地磁盘分区中可用的存储空间
或者其他可以起到磁盘作用的设备(比如说一个USB闪存)。
VFS支持的基于磁盘的某些著名文件系统还有:
2.网络文件系统
这些文件系统允许轻易地访问属于其他网络计算机的文件系统所包含的文件。
虚拟文件系统所支持的一些著名的网络文件系统有:
NFS、Coda、AFS(Andrew文件系统)、
CIFS(用于Microsoft Windows的通用网络文件系统)
以及NCP(Novell 公司的NetWare Core Protocol)。
3.特殊文件系统
这些文件系统不管理本地或者远程磁盘空间。
/proc文件系统是特殊文件系统的一个典型范例(参见稍后“特殊文件系统“一节)。
根目录包含在根文件系统(root filesystem)中,
在Linux中这个根文件系统通常就是Ext2或Ext3类型。
其他所有的文件系统都可以被“安装“在根文件系统的子目录中
基于磁盘的文件系统通常存放在硬件块设备中,
如硬盘、软盘或者CD-ROM。
Linux VFS 的一个有用特点是能够处理如/dev/loop0这样的虚拟块设备,
这种设备可以用来安装普通文件所在的文件系统。
作为一种可能的应用,用户可以保护自己的私有文件系统,
这可以通过把自己文件系统的加密版本存放在一个普通文件中来实现。
通用文件模型
VFS所隐含的主要思想在于引入了一个通用的文件模型(common file model),
这个模型能够表示所有支持的文件系统。
该模型严格反映传统Unix文件系统提供的文件模型。
这并不奇怪,因为Linux希望以最小的额外开销运行它的本地文件系统。
不过,要实现每个具体的文件系统,
必须将其物理组织结构转换为虚拟文件系统的通用文件模型。
例如,在通用文件模型中,每个目录被看作一个文件,
可以包含若干文件和其他的子目录。
但是,存在几个非Unix的基于磁盘的文件系统,
它们利用文件分配表(File Allocation Table,FAT)存放每个文件在目录树中的位置,
在这些文件系统中,存放的是目录而不是文件。
为了符合VFS的通用文件模型,
对上述基于FAT的文件系统的实现,
Linux必须在必要时能够快速建立对应于目录的文件。
这样的文件只作为内核内存的对象而存在。
从本质上说,
Linux内核不能对一个特定的函数进行硬编码来执行诸如read()或ioct1()这样的操作,
而是对每个操作都必须使用一个指针,指向要访问的具体文件系统的适当函数。
为了进一步说明这一概念,
参见图12-1,其中显示了内核如何把read()转换为专对MS-DOS文件系统的一个调用。
应用程序对read()的调用引起内核调用相应的sys_read()服务例程,
这与其他系统调用完全类似。
我们在本章后面会看到,
文件在内核内存中是由一个file数据结构来表示的。
这种数据结构中包含一个称为f_op的字段,
该字段中包含一个指向专对MS-DOS文件的函数指针,
当然还包括读文件的函数。
sys_read()查找到指向该函数的指针,并调用它。
这样一来,应用程序的read()就被转化为相对间接的调用:
file->f_op->read(...);
与之类似,write()操作也会引发一个与输出文件相关的Ext2写函数的执行。
简而言之,内核负责把一组合适的指针分配给与每个打开文件相关的file变量,
然后负责调用针对每个具体文件系统的函数(由f_op字段指向)。
如图12-2所示是一个简单的示例,说明进程怎样与文件进行交互。
三个不同进程已经打开同一个文件,
其中两个进程使用同一个硬链接。
在这种情况下,其中的每个进程都使用自己的文件对象,
但只需要两个目录项对象,
每个硬链接对应一个目录项对象。
这两个目录项对象指向同一个索引节点对象,
该索引节点对象标识超级块对象,以及随后的普通磁盘文件。
VFS除了能为所有文件系统的实现提供一个通用接口外,
还具有另一个与系统性能相关的重要作用。
最近最常使用的目录项对象被放在所谓目录项高速缓存(dentrycache)的磁盘高速缓存中,
以加速从文件路径名到最后一个路径分量的索引节点的转换过程。
一般说来,磁盘高速缓存(diskcache)属于软件机制,
它允许内核将原本存在磁盘上的某些信息保存在RAM中,
以便对这些数据的进一步访问能快速进行,而不必慢速访问磁盘本身。
注意,磁盘高速缓存不同于硬件高速缓存或内存高速缓存,
后两者都与磁盘或其他设备无关。
硬件高速缓存是一个快速静态RAM,
它加快了直接对慢速动态RAM的请求(参见第二章中的“硬件高速缓存”一节)。
内存高速缓存是一种软件机制,
引入它是为了绕过内核内存分配器(参见第八章中的“slab分配器”一节)。
除了目录项高速缓存和索引结点高速缓存之外,
Linux还使用其他磁盘高速缓存。
其中最重要的一种就是所谓的页高速缓存,我们将在第十五章中进行详细介绍。
VFS所处理的系统调用
表12-1列出了VFS的系统调用,
这些系统调用涉及文件系统、普通文件、目录文件以及符号链接文件。
另外还有少数几个由VFS处理的其他系统调用,
诸如ioperm()、ioct1()、pipe()和mknod(),涉及设备文件和管道文件,
这些将在后续章节中讨论。
最后一组由VFS处理的系统调用,
诸如socket()、connect()和bind()属于套接字系统调用,
并用于实现网络功能。
与表12-1列出的系统调用对应的一些内核服务例程,
我们会在本章或第十八章中陆续进行讨论。
前面我们已经提到,
VFS是应用程序和具体文件系统之间的一层。
不过,在某些情况下,一个文件操作可能由VFS本身去执行,无需调用低层函数。
例如,当某个进程关闭一个打开的文件时,并不需要涉及磁盘上的相应文件,
因此VFS只需释放对应的文件对象。
类似地,当系统调用lseek()修改一个文件指针,
而这个文件指针是打开文件与进程交互所涉及的一个属性时,
VFS就只需修改对应的文件对象,而不必访问磁盘上的文件,
因此,无需调用具体文件系统的函数。
从某种意义上说,
可以把VFS看成“通用“文件系统,它在必要时依赖某种具体文件系统。
VFS的数据结构
每个VFS对象都存放在一个适当的数据结构中,
其中包括对象的属性和指向对象方法表的指针。
内核可以动态地修改对象的方法,
因此可以为对象建立专用的行为。下面几节详细介绍VFS的对象及其内在关系。
超级块对象
超级块对象由super_block结构组成,表12-2列举了其中的字段。
所有超级块对象都以双向循环链表的形式链接在一起。
链表中第一个元素用super_blocks变量来表示,
而超级块对象的s_list字段存放指向链表相邻元素的指针。
sb_lock自旋锁保护链表免受多处理器系统上的同时访问。
s_fs_info字段指向属于具体文件系统的超级块信息;
例如,我们在第十八章将会看到,
假如超级块对象指的是Ext2文件系统,
该字段就指向ext2_sb_info数据结构,
该结构包括磁盘分配位掩码和其他与VFS的通用文件模型无关的数据。
通常,为了效率起见,由s_fs_info字段所指向的数据被复制到内存。
任何基于磁盘的文件系统都需要访问和更改自己的磁盘分配位图,
以便分配或释放磁盘块。
VFS允许这些文件系统直接对内存超级块的s_fs_info字段进行操作,
而无需访问磁盘。
但是,这种方法带来一个新问题:
有可能VFS超级块最终不再与磁盘上相应的超级块同步。
因此,有必要引入一个s_dirt标志来表示该超级块是否是脏的
——那磁盘上的数据是否必须要更新。
缺乏同步还会导致产生我们熟悉的一个问题:
当一台机器的电源突然断开而用户来不及正常关闭系统时,
就会出现文件系统崩溃。
我们将会在第十五章的“把脏页写入磁盘“一节中看到,
Linux是通过周期性地将所有“脏“的超级块写回磁盘来减少该问题带来的危害。
与超级块关联的方法就是所谓的超级块操作。
这些操作是由数据结构super_operations 来描述的,
该结构的起始地址存放在超级块的s_op字段中。
每个具体的文件系统都可以定义自己的超级块操作。
当VFS需要调用其中一个操作时,比如说read_inode(),
它执行下列操作:
sb->s_op->read_inode(inode);
这里sb存放所涉及超级块对象的地址。
super_operations表的read_inode字段存放这一函数的地址,
因此,这一函数被直接调用。
让我们简要描述一下超级块操作,
其中实现了一些高级操作,
比如删除文件或安装磁盘。
下面这些操作按照它们在super_operation表中出现的顺序来排列:
前述的方法对所有可能的文件系统类型均是可用的。
但是,只有其中的一个子集应用到每个具体的文件系统;
未实现的方法对应的字段置为NULL。
注意,系统没有定义get_super方法来读超级块,
那么,内核如何能够调用一个对象的方法而从磁盘读出该对象?
我们将在描述文件系统类型的另一个对象中找到等价的get_sb方法
(参见后面的“文件系统类型注册“一节)。
索引节点对象
文件系统处理文件所需要的所有信息都放在一个名为索引节点的数据结构中。
文件名可以随时更改,但是索引节点对文件是唯一的,
并且随文件的存在而存在。
内存中的索引节点对象由一个inode数据结构组成,其字段如表12-3所示。
每个索引节点对象都会复制磁盘索引节点包含的一些数据,
比如分配给文件的磁盘块数。
如果i_state字段的值等于I_DIRTY_SYNC、I_DIRTY_DATASYNC或I_DIRTY_PAGES,该索引节点就是“脏“的,
也就是说,对应的磁盘索引节点必须被更新。
I_DIRTY宏可以用来立即检查这三个标志的值(详细内容参见后面)。
i_state字段的其他值有I_LOCK(涉及的索引节点对象处于I/O传送中)、I_FREEING(索引节点对象正在被释放)、
I_CLEAR(索引节点对象的内容不再有意义)以及I_NEW(索引节点对象已经分配但还没有用从磁盘索引节点读取来的数据填充)。
每个索引节点对象总是出现在下列双向循环链表的某个链表中(所有情况下,指向相邻元素的指针存放在i_list字段中):
1.有效未使用的索引节点链表,
典型的如那些镜像有效的磁盘索引节点,且当前未被任何进程使用。
这些索引节点不为脏,且它们的i_count字段置为0。
链表中的首元素和尾元素是由变量inode_unused的next字段和prev字段分别指向的。
这个链表用作磁盘高速缓存。
2.正在使用的索引节点链表,也就是那些镜像有效的磁盘索引节点,
且当前被某些进程使用。
这些索引节点不为脏,但它们的i_count字段为正数。
链表中的首元素和尾元素是由变量inode_in_use引用的。
3.脏索引节点的链表。
链表中的首元素和尾元素是由相应超级块对象的s_dirty字段引用的。
这些链表都是通过适当的索引节点对象的i_list字段链接在一起的。
此外,每个索引节点对象也包含在每文件系统(per-filesystem)的双向循环链表中,
链表的头存放在超级块对象的s_inodes字段中;
索引节点对象的i_sb_list字段存放了指向链表相邻元素的指针。
最后,索引节点对象也存放在一个称为inode_hashtable的散列表中。
散列表加快了对索引节点对象的搜索,
前提是系统内核要知道索引节点号及文件所在文件系统对应的超级块对象的地址。
由于散列技术可能引发冲突,
所以索引节点对象包含一个i_hash字段,
该字段中包含向前和向后的两个指针,
分别指向散列到同一地址的前一个索引节点和后一个索引节点;
该字段因此创建了由这些索引节点组成的一个双向链表。
与索引节点对象关联的方法也叫索引节点操作。
它们由inode_operations结构来描述,该结构的地址存放在i_op字段中。
以下是索引节点的操作,以它们在inode_operations表中出现的次序来排列:
上述列举的方法对所有可能的索引节点和文件系统类型都是可用的。
不过,只有其中的一个子集应用到某一特定的索引节点和文件系统;
未实现的方法对应的字段被置为NULL。
文件对象
文件对象描述进程怎样与一个打开的文件进行交互。
文件对象是在文件被打开时创建的,由一个file结构组成,
其中包含的字段如表12-4所示。
注意,文件对象在磁盘上没有对应的映像,因此file结构中没有设置“脏“字段来表示文件对象是否已被修改。
存放在文件对象中的主要信息是文件指针,
即文件中当前的位置,下一个操作将在该位置发生。
由于几个进程可能同时访问同一文件,因此文件指针必须存放在文件对象而不是索引节点对象中。
文件对象通过一个名为filp的slab高速缓存分配,
filp描述符地址存放在filp_cachep 变量中。
由于分配的文件对象数目是有限的,因此files_stat变量在其max_files字段中指定了可分配文件对象的最大数目,
也就是系统可同时访问的最大文件数(注4)。
“在使用“文件对象包含在由具体文件系统的超级块所确立的几个链表中。
每个超级块对象把文件对象链表的头存放在s_files字段中;
因此,属于不同文件系统的文件对象就包含在不同的链表中。
链表中分别指向前一个元素和后一个元素的指针都存放在文件对象的f_list字段中。
files_lock自旋锁保护超级块的s_files链表免受多处理器系统上的同时访问。
文件对象的f_count字段是一个引用计数器:
它记录使用文件对象的进程数(记住,以CLONE_FILES标志创建的轻量级进程共享打开文件表,因此它们可以使用相同的文件对象)。
当内核本身使用该文件对象时也要增加计数器的值——例如,把对象插入链表中或发出dup()系统调用时。
当VFS代表进程必须打开一个文件时,
它调用get_empty_filp()函数来分配一个新的文件对象。
该函数调用kmem_cache_alloc()从filp高速缓存中获得一个空闲的文件对象,
然后初始化这个对象的字段,如下所示:
memset(f,0,sizeof(*f));
INIT_LIST_HEAD(&f->f_ep_links);
spin_lock_init(&f->f_ep_lock);
atomic_set(&f->f_count,1);
f->f_uid = current->fsuid;
f->f_gid = current->fsgid;
f->f_owmer.lock = RW_LOCK_UNLOCKED;INIT_LIST_HEAD(&f->f_list〉;
f->f_maxcount = INT_MAX;
正如在“通用文件模型“一节中讨论过的那样,
每个文件系统都有其自己的文件操作集合,
执行诸如读写文件这样的操作。
当内核将一个索引节点从磁盘装入内存时,
就会把指向这些文件操作的指针存放在file_operations结构中,
而该结构的地址存放在该索引节点对象的i_fop字段中。
当进程打开这个文件时,
VFS就用存放在索引节点中的这个地址初始化新文件对象的f_op字段,
使得对文件操作的后续调用能够使用这些函数。
如果需要,VFS随后也可以通过在f_op字段存放一个新值而修改文件操作的集合。
下面的列表描述了文件的操作,以它们在file_operations表中出现的次序来排列:
以上描述的方法对所有可能的文件类型都是可用的。
不过,对于一个具体的文件类型,只使用其中的一个子集;
那些未实现的方法对应的字段被置为NULL。
目录项对象
在“通用文件模型“一节中我们曾提到,
VFS把每个目录看作由若干子目录和文件组成的一个普通文件。
在第十八章我们将会讨论如何在具体的文件系统上实现目录。
然而,一旦目录项被读入内存,VFS就把它转换成基于dentry结构的一个目录项对象,
该结构的字段如表12-5所示。
对于进程查找的路径名中的每个分量,内核都为其创建一个目录项对象;
目录项对象将每个分量与其对应的索引节点相联系。
例如,在查找路径名/tmp/test时,
内核为根目录“1“创建一个目录项对象,
为根目录下的tmp项创建一个第二级目录项对象,
为/tmp目录下的test项创建一个第三级目录项对象。
请注意,目录项对象在磁盘上并没有对应的映像,
因此在dentry结构中不包含指出该对象已被修改的字段。
目录项对象存放在名为dentry_cache的slab分配器高速缓存中。
因此,目录项对象的创建和删除是通过调用kmem_cache_alloc()和kmem_cache_free()实现的。
每个目录项对象可以处于以下四种状态之一:
1.空闲状态(free)
处于该状态的目录项对象不包括有效的信息,且还没有被VFS使用。对应的内存区由slab分配器进行处理。
2.未使用状态(unused)
处于该状态的目录项对象当前还没有被内核使用。
该对象的引用计数器d_count的值为0,但其d_inode字段仍然指向关联的索引节点。
该目录项对象包含有效的信息,但为了在必要时回收内存,它的内容可能被丢弃。
3.正在使用状态(in use)
处于该状态的目录项对象当前正在被内核使用。
该对象的引用计数器d_count的值为正数,其d_inode字段指向关联的索引节点对象。
该目录项对象包含有效的信息,并且不能被丢弃。
4.负状态(negative)
与目录项关联的索引节点不复存在,那是因为相应的磁盘索引节点已被删除,
或者因为目录项对象是通过解析一个不存在文件的路径名创建的。
目录项对象的d_inode字段被置为NULL,但该对象仍然被保存在目录项高速缓存中,
以便后续对同一文件目录名的查找操作能够快速完成。
术语“负状态“容易使人误解,因为根本不涉及任何负值。
与目录项对象关联的方法称为目录项操作。
这些方法由dentry_operations结构加以描述,该结构的地址存放在目录项对象的d_op字段中。
尽管一些文件系统定义了它们自己的目录项方法,
但是这些字段通常为NULL,
而VFS使用缺省函数代替这些方法。
以下按照其在dentry_operations表中出现的顺序来列举一些方法。
目录项高速缓存
由于从磁盘读入一个目录项并构造相应的目录项对象需要花费大量的时间,
所以,在完成对目录项对象的操作后,可能后面还要使用它,
因此仍在内存中保留它有重要的意义。
例如,我们经常需要编辑文件,随后编译它,或者编辑并打印它,
或者复制它并编辑这个拷贝,在诸如此类的情况中,同一个文件需要被反复访问。
为了最大限度地提高处理这些目录项对象的效率,Linux使用目录项高速缓存,它由两种类型的数据结构组成:
目录项高速缓存的作用还相当于索引节点高速缓存(inode cache)的控制器。
在内核内存中,并不丢弃与未用目录项相关的索引节点,
这是由于目录项高速缓存仍在使用它们。因此,这些索引节点对象保存在RAM中,并能够借助相应的目录项快速引用它们。
所有“未使用“目录项对象都存放在一个“最近最少使用(Least Recently used,LRU)“的双向链表中,
该链表按照插入的时间排序。
换句话说,最后释放的目录项对象放在链表的首部,
所以最近最少使用的目录项对象总是靠近链表的尾部。
一旦目录项高速缓存的空间开始变小,内核就从链表的尾部删除元素,
使得最近最常使用的对象得以保留。
LRU链表的首元素和尾元素的地址存放在list_head类型的dentry_unused变量的next字段和prev字段中。
目录项对象的d_1ru字段包含指向链表中相邻目录项的指针。
每个“正在使用“的目录项对象都被插入一个双向链表中,
该链表由相应索引节点对象的i_dentry字段所指向(由于每个索引节点可能与若干硬链接关联,所以需要一个链表)。
目录项对象的d_alias字段存放链表中相邻元素的地址。这两个字段的类型都是struct list_head。
当指向相应文件的最后一个硬链接被删除后,
一个“正在使用“的目录项对象可能变成“负“状态。
在这种情况下,该目录项对象被移到“未使用“目录项对象组成的LRU链表中。
每当内核缩减目录项高速缓存时,“负“状态目录项对象就朝着LRU链表的尾部移动,
这样一来,这些对象就逐渐被释放(参见第十七章中的“回收可压缩磁盘高速缓存的页“一节)。
散列表是由dentry_hashtable数组实现的。数组中的每个元素是一个指向链表的指针,
这种链表就是把具有相同散列表值的目录项进行散列而形成的。
该数组的长度取决于系统已安装RAM的数量;
缺省值是每兆字节RAM包含256个元素。
目录项对象的d_hash 字段包含指向具有相同散列值的链表中的相邻元素。
散列函数产生的值是由目录的目录项对象及文件名计算出来的。
dcache_lock自旋锁保护目录项高速缓存数据结构免受多处理器系统上的同时访问。
d_lookup()函数在散列表中查找给定的父目录项对象和文件名;
为了避免发生竞争,使用顺序锁(seqlock)(参见第五章中的“顺序锁“一节)。
__d_lookup()函数与之类似,不过它假定不会发生竞争,因此不使用顺序锁。
与进程相关的文件
每个进程都有它自己当前的工作目录和它自己的根目录。
这仅仅是内核用来表示进程与文件系统相互作用所必须维护的数据中的两个例子。
类型为fs_struc的整个数据结构就用于此目的(参见表12-6),且每个进程描述符的fs字段就指向进程的fs_struc结构。
第二个表表示进程当前打开的文件表的地址存放于进程描述符的files字段。该表的类型为files_struct结构,它的各个字段如表12-7所示。
fd字段指向文件对象的指针数组。该数组的长度存放在max_fds字段中。
通常,fd字段指向files_struct结构的fd_array字段,
该字段包括32个文件对象指针。
如果进程打开的文件数目多于32,内核就分配一个新的、更大的文件指针数组,
并将其地址存放在fd字段中,内核同时也更新max_fds字段的值。
对于在fd数组中有元素的每个文件来说,数组的索引就是文件描述符(file descriptor)。
通常,数组的第一个元素(索引为0)是进程的标准输入文件,
数组的第二个元素(索引为1)是进程的标准输出文件,
数组的第三个元素(索引为2)是进程的标准错误文件(参见图12-3)。
Unix进程将文件描述符作为主文件标识符。
请注意,借助于dup()、dup2()和fcntl()系统调用,
两个文件描述符可以指向同一个打开的文件,
也就是说,数组的两个元素可能指向同一个文件对象。
当用户使用shell结构(如2>&1)将标准错误文件重定向到标准输出文件上时,
用户总能看到这一点。
进程不能使用多于NR_OPEN(通常为1048576)个文件描述符。
内核也在进程描述符的signal->rlim[RLIMIT_NOFILE]结构上强制动态限制文件描述符的最大数;
这个值通常为1024,但是如果进程具有超级用户特权,就可以增大这个值。
open_fds字段最初包含open_fds_init字段的地址,
open_fds_init字段表示当前已打开文件的文件描述符的位图。
max_fdset字段存放位图中的位数。
由于fd_set数据结构有1024位,所以通常不需要扩大位图的大小。
不过,如果确有必要的话,内核仍能动态增加位图的大小,这非常类似于文件对象的数组的情形。
当内核开始使用一个文件对象时,内核提供fget()函数以供调用。
这个函数接收文件描述符fd作为参数,返回在current->files->fd[fd]中的地址,即对应文件对象的地址,
如果没有任何文件与fd对应,则返回NULL。
在第一种情况下,fget()使文件对象引用计数器f_count的值增1。
当内核控制路径完成对文件对象的使用时,调用内核提供的fput()函数。
该函数将文件对象的地址作为参数,并减少文件对象引用计数器f_count的值。
另外,如果这个字段变为0,
该函数就调用文件操作的release方法(如果已定义),减少索引节点对象的i_write count字段的值(如果该文件是可写的),
将文件对象从超级块链表中移走,释放文件对象给slab分配器,
最后减少相关的文件系统描述符的目录项对象的引用计数器的值(参见“文件系统安装”一节)。
fget_light()和fget_light()函数是fget()和fput()的快速版本:
内核要使用它们,前提是能够安全地假设当前进程已经拥有文件对象,
即进程先前已经增加了文件对象引用计数器的值。
例如,它们由接收一个文件描述符作为参数的系统调用服务例程使用,
这是由于先前的open()系统调用已经增加了文件对象引用计数器的值。
文件系统类型
Linux内核支持很多不同的文件系统类型。
在下面的内容中,我们介绍一些特殊的文件系统类型,它们在Linux内核的内部设计中具有非常重要的作用。
接下来,我们将讨论文件系统注册——也就是通常在系统初始化期间并且在使用文件系统类型之前必须执行的基本操作。
一旦文件系统被注册,其特定的函数对内核就是可用的,因此文件系统类型可以安装在系统的目录树上。
特殊文件系统
当网络和磁盘文件系统能够使用户处理存放在内核之外的信息时,
特殊文件系统可以为系统程序员和管理员提供一种容易的方式来操作内核的数据结构并实现操作系统的特殊特征。
表12-8列出了Linux中所用的最常用的特殊文件系统;对于其中的每个文件系统,表中给出了它的安装点和简短描述。
注意,有几个文件系统没有固定的安装点(表中的关键词“任意”)。
这些文件系统可以由用户自由地安装和使用。
此外,一些特殊文件系统根本没有安装点(表中的关键词“无”)。
它们不是用于与用户交互,但是内核可以用它们来很容易地重新使用VFS层的某些代码;
例如,我们在第十九章会看到,有了pipefs特殊文件系统,就可以把管道和FIFO文件以相同的方式对待。
特殊文件系统不限于物理块设备。
然而,内核给每个安装的特殊文件系统分配一个虚拟的块设备,
让其主设备号为0而次设备号具有任意值(每个特殊文件系统有不同的值)。
set_anon_super()函数用于初始化特殊文件系统的超级块;
该函数本质上获得一个未使用的次设备号dev,
然后用主设备号0和次设备号dev设置新超级块的s_dev字段。
而另一个kill_anon_super()函数移走特殊文件系统的超级块。
unnamed_dev_idr变量包含指向一个辅助结构(记录当前在用的次设备号)的指针。
尽管有些内核设计者不喜欢虚拟块设备标识符,但是这些标识符有助于内核以统一的方式处理特殊文件系统和普通文件系统。
我们在后面“安装普通文件系统“一节会看到内核如何定义和初始化一个特殊文件系统的实际例子。
文件系统类型注册
通常,用户在为自己的系统编译内核时可以把Linux配置为能够识别所有需要的文件系统。
但是,文件系统的源代码实际上要么包含在内核映像中,
要么作为一个模块被动态装入(参见附录二)。
VFS必须对代码目前已在内核中的所有文件系统的类型进行跟踪。
这就是通过进行文件系统类型注册来实现的。
每个注册的文件系统都用一个类型为file_system_type的对象来表示,该对象的所有字段在表12-9中列出。
所有文件系统类型的对象都插入到一个单向链表中。
由变量file_systems指向链表的第一个元素,
而结构中的next字段指向链表的下一个元素。
file_systems_lock读/写自旋锁保护整个链表免受同时访问。
fs_supers字段表示给定类型的已安装文件系统所对应的超级块链表的头(第一个伪元素)。
链表元素的向后和向前链接存放在超级块对象的s_instances字段中。
get_sb字段指向依赖于文件系统类型的函数,
该函数分配一个新的超级块对象并初始化它(如果需要,可读磁盘)。
而kill_sb字段指向删除超级块的函数。
fs_flags字段存放几个标志,如表12-10所示。
在系统初始化期间,调用register_filesystem()函数来注册编译时指定的每个文件系统;
该函数把相应的file_system_type对象插入到文件系统类型的链表中。
当实现了文件系统的模块被装入时,也要调用register_filesystem()函数。
在这种情况下,当该模块被卸载时,对应的文件系统也可以被注销(调用unregister_filesystem()函数)。
get_fs_type()函数(接收文件系统名作为它的参数)扫描已注册的文件系统链表以查找文件系统类型的name字段,
并返回指向相应的file_system_type对象(如果存在)的指针。
文件系统处理
就像每个传统的Unix系统一样,
Linux也使用系统的根文件系统(system's rootfilesystem):
它由内核在引导阶段直接安装,并拥有系统初始化脚本以及最基本的系统程序。
其他文件系统要么由初始化脚本安装,要么由用户直接安装在已安装文件系统的目录上。
作为一个目录树,每个文件系统都拥有自己的根目录(root directory)。
安装文件系统的这个目录称之为安装点(mount point)。
已安装文件系统属于安装点目录的一个子文件系统。
例如,
/proc虚拟文件系统是系统的根文件系统的孩子(且系统的根文件系统是/proc的父亲)。
已安装文件系统的根目录隐藏了父文件系统的安装点目录原来的内容,
而且父文件系统的整个子树位于安装点之下。
文件系统的根目录有可能不同于进程的根目录:
正如我们在前面“与文件相关的进程“一节所见,
进程的根目录是与“/“路径对应的目录。
缺省情况下,进程的根目录与系统的根文件系统的根目录一致
(更准确地说是与进程的命名空间中的根文件系统的根目录一致,这一点将在下一节描述),
但是可以通过调用chroot()系统调用改变进程的根目录。
命名空间
在传统的Unix系统中,只有一个已安装文件系统树:
从系统的根文件系统开始,
每个进程通过指定合适的路径名可以访问已安装文件系统中的任何文件。
从这个方面考虑,Linux 2.6更加的精确:每个进程可拥有自己的已安装文件系统树——叫做进程的命名空间(namespace)。
通常大多数进程共享同一个命名空间,即位于系统的根文件系统且被init进程使用的已安装文件系统树。
不过,如果clone()系统调用以CLONE_NEWNS标志创建一个新进程,
那么进程将获取一个新的命名空间(参见第三章的“clone()、fork()及vfork()系统调用“一节)。
这个新的命名空间随后由子进程继承(如果父进程没有以CLONE_NEWNS标志创建这些子进程)。
当进程安装或卸载一个文件系统时,仅修改它的命名空间。
因此,所做的修改对共享同一命名空间的所有进程都是可见的,
并且也只对它们可见。
进程甚至可通过使用Linux 特有的pivot_root()系统调用来改变它的命名空间的根文件系统。
进程的命名空间由进程描述符的namespace字段指向的namespace结构描述。该结构的字段如表12-11所示。
list字段是双向循环链表的头,该表聚集了属于命名空间的所有已安装文件系统。
root 字段表示已安装文件系统,它是这个命名空间的已安装文件系统树的根。
正如我们在下一节将看到的,已安装文件系统由vfsmount结构描述。
文件系统安装
在大多数传统的类Unix内核中,每个文件系统只能安装一次。
假定存放在/dev/fd0软磁盘上的Ext2文件系统通过如下命令安装在/flp:
mount -t ext2 /dev/fd0 /flp
在用umount命令卸载该文件系统前,所有其他作用于/dev/fd0的安装命令都会失败。
然而,Linux有所不同:
同一个文件系统被安装多次是可能的。
当然,如果一个文件系统被安装了n次,
那么它的根目录就可通过n个安装点来访问。
尽管同一文件系统可以通过不同的安装点来访问,但是文件系统的的确确是唯一的。
因此,不管一个文件系统被安装了多少次,都仅有一个超级块对象。
安装的文件系统形成一个层次:
一个文件系统的安装点可能成为第二个文件系统的目录,而第二个文件系统又安装在第三个文件系统之上,等等(注6)。
把多个安装堆叠在一个单独的安装点上也是可能的。
尽管已经使用先前安装下的文件和目录的进程可以继续使用,但在同一安装点上的新安装隐藏前一个安装的文件系统。
当最顶层的安装被删除时,下一层的安装再一次变为可见的。
你可以想像,跟踪已安装的文件系统很快会变为一场恶梦。
对于每个安装操作,内核必须在内存中保存安装点和安装标志,
以及要安装文件系统与其他已安装文件系统之间的关系。
这样的信息保存在已安装文件系统描述符中;
每个描述符是一个具有vfsmount 类型的数据结构,其字段如表12-12所示。
vfsmount数据结构保存在几个双向循环链表中:
1.由父文件系统vfsmount描述符的地址和安装点目录的目录项对象的地址索引的散列表。
散列表存放在mount_hashtable数组中,其大小取决于系统中RAM的容量。
表中每一项是具有同一散列值的所有描述符形成的双向循环链表的头。
描述符的mnt_hash字段包含指向链表中相邻元素的指针。
2.对于每一个命名空间,所有属于此命名空间的已安装的文件系统描述符形成了一个双向循环链表。
namespace结构的list字段存放链表的头,vfsmount描述符的mnt_list字段包含链表中指向相邻元素的指针。
3.对于每一个已安装的文件系统,所有已安装的子文件系统形成了一个双向循环链表。
每个链表的头存放在已安装的文件系统描述符的mnt_mounts字段;
此外,描述符的mnt_child字段存放指向链表中相邻元素的指针
vfsmount_lock自旋锁保护已安装文件系统对象的链表免受同时访问。
描述符的mnt_flags字段存放几个标志的值,用以指定如何处理已安装文件系统中的某些种类的文件。
这些标志可通过mount命令的选项进行设置,其标志如表12-13所示。
下列函数处理已安装文件系统描述符:
安装普通文件系统
我们现在描述安装一个文件系统时内核所要执行的操作。
我们首先考虑一个文件系统将被安装在一个已安装文件系统之上的情形(在这里我们把这种新文件系统看作“普通的”)。
mount()系统调用被用来安装一个普通文件系统;它的服务例程sys_mount()作用于以下参数:
sys_mount()函数把参数的值拷贝到临时内核缓冲区,获取大内核锁,并调用do_mount()函数。
一旦do_mount()返回,则这个服务例程释放大内核锁并释放临时内核缓冲区。
do_mount()函数通过执行下列操作处理真正的安装操作:
1. 如果安装标志MS_NOSUID、MS_NODEV或MS_NOEXEC中任一个被设置,
则清除它们,并在已安装文件系统对象中设置相应的标志(MNT_NOSUID、MNT_NODEV、MNT_NOEXEC)。
2. 调用path_lookup()查找安装点的路径名;
该函数把路径名查找的结果存放在nameidata类型的局部变量nd中(参见后面的“路径名查找”一节)。
3. 检查安装标志以决定必须做什么。尤其是:
a.如果MS_REMOUNT标志被指定,其目的通常是改变超级块对象s_flags字段的安装标志,
以及已安装文件系统对象mnt_flags字段的安装文件系统标志。
do_remount()函数执行这些改变。
b.否则,检查MS_BIND标志。
如果它被指定,则用户要求在在系统目录树的另一个安装点上的文件或目录能够可见。
c.否则,检查MS_MOVE标志。
如果它被指定,则用户要求改变已安装文件系统的安装点。
do_move_mount()函数原子地完成这一任务。
d. 否则,调用do_new_mount()。
这是最普通的情况。
当用户要求安装一个特殊文件系统或存放在磁盘分区中的普通文件系统时,触发该函数。
它调用do_kern_mount()函数,给它传递的参数为文件系统类型、安装标志以及块设备名。
do_kern_mount()处理实际的安装操作并返回一个新安装文件系统描述符的地址(如下描述)。
然后,do_new_mount()调用do_add_mount(),后者本质上执行下列操作;
(1)获得当前进程的写信号量namespace->sem,因为函数要更改namespace结构。
(2)do_kern_mount()函数可能让当前进程睡眠;
同时,另一个进程可能在完全相同的安装点上安装文件系统或者甚至更改根文件系统(current->namespace->root)。
验证在该安装点上最近安装的文件系统是否仍指向当前的namespace;
如果不是,则释放读/写信号量并返回一个错误码。
(3)如果要安装的文件系统已经被安装在由系统调用的参数所指定的安装点上,
或该安装点是一个符号链接,则释放读/写信号量并返回一个错误码。
(4)初始化由do_kern_mount()分配的新安装文件系统对象的mnt_flags字段的标志。
(5)调用graft_tree()把新安装的文件系统对象插入到namespace链表、散列表及父文件系统的子链表中。
(6)释放namespace->sem读/写信号量并返回。
4. 调用path_release()终止安装点的路径名查找(参见后面的“路径名查找“一节)并返回0。
do_kern_mount()函数
安装操作的核心是do_kern_mount()函数,它检查文件系统类型标志以决定安装操作是如何完成的。该函数接收下列参数:
本质上,该函数通过执行下列操作实现实际的安装操作:
1. 调用get_fs_type()在文件系统类型链表中搜索并确定存放在fstype参数中的名字的位置;
返回局部变量type中对应file_system_type描述符的地址。
2. 调用alloc_vfsmnt()分配一个新的已安装文件系统的描述符,
并将它的地址存放在mnt局部变量中。
3. 调用依赖于文件系统的type->get_sb()函数分配,并初始化一个新的超级块(参见下面)。
4. 用新超级块对象的地址初始化mnt->mnt_sb字段。
5. 将mnt->mnt_root字段初始化为与文件系统根目录对应的目录项对象的地址,
并增加该目录项对象的引用计数器值。
6. 用mnt中的值初始化mnt->mnt_parent字段
(对于普通文件系统,当graft_tree()把已安装文件系统的描述符插入到合适的链表中时,要把mnt_parent字段置为合适的值;
参见do_mount()的第3d5步)。
7. 用current->namespace中的值初始化mnt->mnt_namespace字段。
8. 释放超级块对象的读/写信号量s_umount(在第3步中分配对象时获得)。
9. 返回已安装文件系统对象的地址mnt。
分配超级块对象
文件系统对象的get_sb方法通常是由单行函数实现的。例如,在Ext2文件系统中该方法的实现如下:
struct super_block * ext2_get_sb(struct file_system_type *type, int flags,const char *dev_name,void *data)
{
return get_sb_bdev(type,flags,dev_name,data,ext2_fill_super);
}
get_sb_bdev()VFS函数分配并初始化一个新的适合于磁盘文件系统的超级块;
它接收ext2_fill_super()函数的地址,该函数从Ext2磁盘分区读取磁盘超级块。
为了分配适合于特殊文件系统的超级块,
VFS也提供
get_sb_pseudo()函数(对于没有安装点的特殊文件系统,例如pipefs)、
get_sb_single()函数(对于具有唯一安装点的特殊文件系统,例如sysfs)
以及get_sb_nodev()函数(对于可以安装多次的特殊文件系统,例如tmpfs;参见下面)。
get_sb_bdev()执行的最重要的操作如下:
1. 调用open_bdev_excl()打开设备文件名为dev_name的块设备(参见第十三章的“字符设备驱动程序“一节)。
2. 调用sget()搜索文件系统的超级块对象链表(type->fs_supers,参见前面的“文件系统类型注册”一节)。
如果找到一个与块设备相关的超级块,则返回它的地址。
否则,分配并初始化一个新的超级块对象,把它插入到文件系统链表和超级块全局链表中,并返回其地址。
3. 如果不是新的超级块(它不是上一步分配的,因为文件系统已经被安装),则跳到第6步。
4. 把参数flags中的值拷贝到超级块的s_flags字段,并将s_id、s_old_blocksize以及s_blocksize字段设置为块设备的合适值。
5. 调用依赖文件系统的函数(该函数作为传递给get_sb_bdev()的最后一个参数)访问磁盘上的超级块信息,并填充新超级块对象的其他字段。
6. 返回新超级块对象的地址。
安装根文件系统
安装根文件系统是系统初始化的关键部分。
这是一个相当复杂的过程,因为Linux内核允许根文件系统存放在很多不同的地方,
比如硬盘分区、软盘、通过NFS共享的远程文件系统,甚至保存在ramdisk中(RAM中的虚拟块设备)。
为了使叙述变得简单,让我们假定根文件系统存放在硬盘分区(毕竟这是最常见的情况)。
当系统启动时,内核就要在变量ROOT_DEV中寻找包含根文件系统的磁盘主设备号(参见附录一)。
当编译内核时,或者向最初的启动装入程序传递一个合适的“root”选项时,
根文件系统可以被指定为/dev目录下的一个设备文件。
类似地,
根文件系统的安装标志存放在root_mountflags变量中。
用户可以指定这些标志,或者通过对已编译的内核映像使用rdev外部程序,
或者向最初的启动装入程序传递一个合适的rootflags选项来达到(参见附录一)。
安装根文件系统分两个阶段,如下所示:
1. 内核安装特殊rootfs文件系统,该文件系统仅提供一个作为初始安装点的空目录。
2. 内核在空目录上安装实际根文件系统。
为什么内核不怕麻烦,要在安装实际根文件系统之前安装rootfs文件系统呢?
我们知道,rootfs文件系统允许内核容易地改变实际根文件系统。
事实上,在某些情况下,内核逐个地安装和卸载几个根文件系统。
例如,
一个发布版的初始启动光盘可能把具有一组最小驱动程序的内核装入RAM中,
内核把存放在ramdisk中的一个最小的文件系统作为根安装。
接下来,在这个初始根文件系统中的程序探测系统的硬件(例如,它们判断硬盘是否是EIDE、SCSI等等),装入所有必需的内核模块,
并从物理块设备重新安装根文件系统。
阶段1:安装rootfs文件系统
第一阶段是由init_rootfs()和init_mount_tree()函数完成的,它们在系统初始化过程中执行。
init_rootfs()函数注册特殊文件系统类型rootfs;
struct file_system_type rootfs_fs_type ={
.name ="rootfs“;
·get_sb = rootfs_get_sb;
.kill_sb= kill_litter_super;
};
register_filesystem(&rootfs_fs_type);
init_mount_tree()函数执行如下操作:
1. 调用do_kern_mount()函数,
把字符串“rootfs”作为文件系统类型参数传递给它,
并把该函数返回的新安装文件系统描述符的地址保存在mnt局部变量中。
正如前一节所介绍的,do_kern_mount()最终调用rootfs文件系统的get_sb方法,也即rootfs_get_sb()函数:
struct superblock *rootfs_get_sb(struct file_system_type *fs_type, int flags,const char *dev_name,void *data)
{
return get_sb_nodev(fs_type,flagsIMS_NOUSER,data, ramfs_fill_super);
}
get_sb_nodev()函数执行如下步骤:
a.调用sget()函数分配新的超级块,传递set_anon_super()函数的地址作为参数(参见前面的“特殊文件系统“一节)。
接下来,用合适的方式设置超级块的s_dev字段:
主设备号为0, 次设备号不同于其他已安装的特殊文件系统的次设备号。
b.将flags参数的值拷贝到超级块的s_flags字段中。
c.调用ramfs_fill_super()函数分配索引节点对象和对应的目录项对象,
并填充超级块字段值。
由于rootfs是一种特殊文件系统,没有磁盘超级块,因此只需执行两个超级块操作。
d.返回新超级块的地址。
2. 为进程0的命名空间分配一个namespace对象,并将它插入到由do_kern_mount()函数返回的已安装文件系统描述符中:
namespace = kmalloc(sizeof(*namespace〉,GFP_KERNEL);
list_add(&mnt->mnt_list,&namespace->list);
namespace->root = mnt;
mnt->mnt_namespace = init_task.namespace = namespace;
3. 将系统中其他每个进程的namespace字段设置为namespace对象的地址;
同时初始化引用计数器namespace->count(缺省情况下,所有的进程共享同一个初始namespace)。
4. 将进程0的根目录和当前工作目录设置为根文件系统。
阶段2:安装实际根文件系统
根文件系统安装操作的第二阶段是由内核在系统初始化即将结束时进行的。
根据内核被编译时所选择的选项,和内核装入程序所传递的启动选项,
可以有几种方法安装实际根文件系统。
为了简单起见,我们只考虑磁盘文件系统的情况,
它的设备文件名已通过“root”启动参数传递给内核。
同时我们假定除了rootfs文件系统外,没有使用其他初始特殊文件系统。
prepare_namespace()函数执行如下操作:
1. 把root_device_name变量置为从启动参数“root”中获取的设备文件名。同样,
把ROOT_DEV变量置为同一设备文件的主设备号和次设备号。
2. 调用mount_root()函数,依次执行如下操作:
a.调用sys_mknod()(mknod()系统调用的服务例程)在rootfs初始根文件系统中创建设备文件/dev/root,
其主、次设备号与存放在ROOT_DEV中的一样。
b.分配一个缓冲区并用文件系统类型名链表填充它。
该链表要么通过启动参数“rootfstype”传送给内核,
要么通过扫描文件系统类型单向链表中的元素建立。
c.扫描上一步建立的文件系统类型名链表。
对每个名字,调用sys_mount()试图在根设备上安装给定的文件系统类型。
由于每个特定于文件系统的方法使用不同的魔数,
因此,对get_sb()的调用大都会失败,
但有一个例外,那就是用根设备上实际使用过的文件系统的函数来填充超级块的那个调用,
该文件系统被安装在rootfs文件系统的/root目录上。
d.调用sys_chdir(“/root”)改变进程的当前目录。
3. 移动rootfs文件系统根目录上的已安装文件系统的安装点。
sys_mount(".”,“/",NULL,MS_MOVE,NULL);
sys_chroot(".");
注意,rootfs特殊文件系统没有被卸载:它只是隐藏在基于磁盘的根文件系统下了。
卸载文件系统
umount()系统调用用来卸载一个文件系统。
相应的sys_umount()服务例程作用于两个参数:文件名(多是安装点目录或是块设备文件名)和一组标志。
该函数执行下列操作:
1. 调用path_lookup()查找安装点路径名;该函数把返回的查找操作结果存放在nameidata类型的局部变量nd中(参见下一节)。
2. 如果查找的最终目录不是文件系统的安装点,
则设置retval返回码为-EINVAL并跳到第6步。
这种检查是通过验证nd->mnt->mnt_root(它包含由nd.dentry指向的目录项对象地址)进行的。
3. 如果要卸载的文件系统还没有安装在命名空间中,
则设置retval返回码为-EINVAL并跳到第6步(回想一下,某些特殊文件系统没有安装点)。
这种检查是通过在nd->mnt上调用check_mnt()函数进行的。
4. 如果用户不具有卸载文件系统的特权,
则设置retval返回码为-EPERM并跳到第6步。
5. 调用do_umount(),传递给它的参数为nd.mnt(已安装文件系统对象)和flags(一组标志)。该函数执行下列操作:
a.从已安装文件系统对象的mnt_sb字段检索超级块对象sb的地址。
b.如果用户要求强制卸载操作,则调用umount_begin超级块操作中断任何正在进行的安装操作。
c.如果要卸载的文件系统是根文件系统,且用户并不要求真正地把它卸载下来,
则调用do_remount_sb()重新安装根文件系统为只读并终止。
d.为进行写操作而获取当前进程的namespace->sem读/写信号量和vfsmount_lock自旋锁。
e.如果已安装文件系统不包含任何子安装文件系统的安装点,
或者用户要求强制卸载文件系统,则调用umount_tree()卸载文件系统(及其所有子文件系统)。
f.释放vfsmount_lock自旋锁和当前进程的namespace->sem读/写信号量。
6. 减少相应文件系统根目录的目录项对象和已安装文件系统描述符的引用计数器值;这些计数器值由path_lookup()增加。
7. 返回retval的值。