深入理解Linux内核--虚拟文件系统

虚拟文件系统(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时,内核为根目录“/“创建一个目录项对象,为根目录下的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)是进程的标准错误文件。

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方法(如果已定义):
1.减少索引节点对象的i_write count字段的值(如果该文件是可写的)。
2.将文件对象从超级块链表中移走。
3.释放文件对象给slab分配器。
4.最后减少相关的文件系统描述符的目录项对象的引用计数器的值。

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个安装点来访问。尽管同一文件系统可以通过不同的安装点来访问,但是文件系统的的确确是唯一的。因此,不管一个文件系统被安装了多少次,都仅有一个超级块对象。

安装的文件系统形成一个层次:一个文件系统的安装点可能成为第二个文件系统的目录,第二个文件系统又安装在第三个文件系统之上等。把多个安装堆叠在一个单独的安装点上也是可能的。尽管已经使用先前安装下的文件和目录的进程可以继续使用,但在同一安装点上的新安装隐藏前一个安装的文件系统。当最顶层(最后一个)的安装被删除时,下一层的安装再一次变为可见的。你可以想像,跟踪已安装的文件系统很快会变为一场恶梦。对于每个安装操作,内核必须在内存中保存安装点和安装标志,以及要安装文件系统与其他已安装文件系统之间的关系。信息保存在已安装文件系统描述符中;每个描述符是一个具有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

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

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;
  1. 将系统中其他每个进程的namespace字段设置为namespace对象的地址;同时初始化引用计数器namespace->count(缺省情况下,所有的进程共享同一个初始namespace)。
  2. 将进程0的根目录和当前工作目录设置为根文件系统。

阶段2:安装实际根文件系统

根文件系统安装操作的第二阶段是由内核在系统初始化即将结束时进行的。根据内核被编译时所选择的选项,和内核装入程序所传递的启动选项,可以有几种方法安装实际根文件系统。为了简单起见,我们只考虑磁盘文件系统的情况,它的设备文件名已通过“root”启动参数传递给内核。同时我们假定除了rootfs文件系统外,没有使用其他初始特殊文件系统。
prepare_namespace()函数执行如下操作:

  1. 把root_device_name变量置为从启动参数“root”中获取的设备文件名。同样,把ROOT_DEV变量置为同一设备文件的主设备号和次设备号。
  2. 调用mount_root()函数,依次执行如下操作:
    a. 调用sys_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的值。

路径名查找

当进程必须识别一个文件时,就把它的文件路径名传递给某个VFS系统调用,如open()、mkdir()、rename()或stat()。本节我们要说明VFS如何实现路径名查找,也就是说如何从文件路径名导出相应的索引节点。执行这一任务的标准过程就是分析路径名并把它拆分成一个文件名序列。除了最后一个文件名以外,所有的文件名都必定是目录。如果路径名的第一个字符是“/”,那么这个路径名是绝对路径,因此从current->fs->root(进程的根目录)所标识的目录开始搜索。否则,路径名是相对路径,因此从current->fs->pwd(进程的当前目录)所标识的目录开始搜索。

在对初始目录的索引节点进行处理的过程中,代码要检查与第一个名字匹配的目录项,以获得相应的索引节点。然后,从磁盘读出包含那个索引节点的目录文件,并检查与第二个名字匹配的目录项,以获得相应的索引节点。对于包含在路径中的每个名字,这个过程反复执行。

目录项高速缓存极大地加速了这一过程,因为它把最近最常使用的目录项对象保留在内存中。正如我们以前看到的,每个这样的对象使特定目录中的一个文件名与它相应的索引节点相联系。因此在很多情况下,路径名的分析可以避免从磁盘读取中间目录。但是,事情并不像看起来那么简单,因为必须考虑如下的Unix和VFS文件系统的特点:
1.对每个目录的访问权必须进行检查,以验证是否允许进程读取这一目录的内容。
2.文件名可能是与任意一个路径名对应的符号链接;在这种情况下,分析必须扩展到那个路径名的所有分量。
3.符号链接可能导致循环引用;内核必须考虑这个可能性,并能在出现这种情况时将循环终止。
4.文件名可能是一个已安装文件系统的安装点。这种情况必须检测到,这样,查找操作必须延伸到新的文件系统。
5.路径名查找应该在发出系统调用的进程的命名空间中完成。由具有不同命名空间的两个进程使用的相同路径名,可能指定了不同的文件。

路径名查找是由path_lookup()函数执行的,它接收三个参数:

name
	指向要解析的文件路径名的指针。
flags
	标志的值,表示将会怎样访问查找的文件。在后面的表12-16中列出了所允许的标志。
nd
	nameidata数据结构的地址,这个结构存放了查找操作的结果,其字段如表12-15 所示。

当path_lookup()返回时,nd指向的nameidata结构用与路径名查找操作有关的数据来填充。
在这里插入图片描述
dentry和mnt字段分别指向所解析的最后一个路径分量的目录项对象和已安装文件系统对象。这两个字段“描述“由给定路径名表示的文件。由于path_lookup()函数返回的nameidata结构中的目录项对象和已安装文件系统对象代表了查找操作的结果,因此在path_lookup()的调用者完成使用查找结果之前,这两个对象都不能被释放。因此,path_lookup()增加两个对象引用计数器的值。如果调用者想释放这些对象,则调用path_release()函数,传递给它的参数为nameidata结构的地址。flags字段存放查找操作中使用的某些标志的值;它们在表12-16中列出。这些标志中的大部分可由调用者在path_lookup()的flags参数中进行设置。
在这里插入图片描述
path_lookup()函数执行下列步骤:

  1. 如下初始化nd参数的某些字段:
    a.把last_type字段置为LAST_ROOT(如果路径名是一个“/”或“/”序列,那么这是必需的)。
    b.把flags字段置为参数flags的值。
    c.把depth字段置为0。
  2. 为进行读操作而获取当前进程的current->fs->lock读/写信号量。
  3. 如果路径名的第一个字符是“/“,那么查找操作必须从当前根目录开始:获取相应已安装文件对象(current->fs->rootmnt)和目录项对象(current->fs->root)的地址,增加引用计数器的值,并把它们的地址分别存放在nd->mnt和nd->dentry中。
  4. 否则,如果路径名的第一个字符不是“/“,则查找操作必须从当前工作目录开始:获得相应已安装文件系统对象(current->fs->pwdmmt)和目录项对象(current->fs->pwd)的地址,增加引用计数器的值,并把它们的地址分别存放在nd->mnt和nd->dentry中。
  5. 释放当前进程的current->fs->lock读/写信号量。
  6. 把当前进程描述符中的total_link_count字段置为0。
  7. 调用link_path_walk()函数处理正在进行的查找操作:return link_path_walk(name,nd);

我们现在准备描述路径名查找操作的核心,也就是link_path_walk()函数。它接收的参数为要解析的路径名指针name和nameidata数据结构的地址nd。为了简单起见,我们首先描述当LOOKUP_PARENT未被设置且路径名不包含符号链接时,link_path_walk()做些什么(标准路径名查找)。接下来,我们讨论LOOKUP_PARENT 被设置的情况:这种类型的查找在创建、删除或更名一个目录项时是需要的,也就是在父目录名查找过程中是需要的。最后,我们阐明该函数如何解析符号链接。

标准路径名查找

当LOOKUP_PARENT标志被清零时,link_path_walk()执行下列步骤:

  1. 用nd->flags初始化lookup_flags局部变量。
  2. 跳过路径名第一个分量前的任何斜杠(/)。
  3. 如果剩余的路径名为空,则返回0。在nameidata数据结构中,dentry和mnt字段指向原路径名最后一个所解析分量对应的对象。
  4. 如果nd描述符中的depth字段的值为正,则把lookup_flags局部变量置为LOOKUP_FOLLOW标志。
  5. 执行一个循环,把name参数中传递的路径名分解为分量(中间的“/”被当作文件名分隔符对待);对于每个找到的分量,该函数:
    a. 从nd->dentry->d_inode检索最近一个所解析分量的索引节点对象的地址(在第一次循环中,索引节点指向开始路径名查找的目录)。
    b. 检查存放到索引节点中的最近那个所解析分量的许可权是否允许执行(在Unix中,只有目录是可执行的,它才可以被遍历)。如果索引节点有自定义的permission方法,则执行它;否则,执行exec_permission_lite()函数,该函数检查存放在索引节点i_mode字段的访问模式和运行进程的特权。在两种情况中,如果最近所解析分量不允许执行,那么link_path_walk()跳出循环并返回一个错误码。
    c. 考虑要解析的下一个分量。从它的名字,函数为目录项高速缓存散列表计算一个32位的散列值。
    d. 如果“/”终止了要解析的分量名,则跳过“/”之后的任何尾部“/”。
    e. 如果要解析的分量是原路径名中的最后一个分量,则跳到第6步。
    f. 如果分量名是一个“.“(单个圆点),则继续下一个分量(“.“指的是当前目录,因此,这个点在目录内没有什么效果)。
    g. 如果分量名是“…“(两个圆点),则尝试回到父目录:
    (1) 如果最近解析的目录是进程的根目录(nd->dentry等于current->fs->root,而nd->mnt等于current->fs->rootmnt),那么再向上追踪是不允许的:在最近解析的分量上调用follow_mount()(见下面),继续下一个分量。
    (2) 如果最近解析的目录是nd->mnt文件系统的根目录(nd->dentry等于nd->mnt->mnt_root),并且这个文件系统也没有被安装在其他文件系统之上(nd->mnt等于nd->mnt->mnt_parent),那么nd->mnt文件系统通常就是命名空间的根文件系统:在这种情况下,再向上追踪是不可能的,因此在最近解析的分量上调用follow_mount()(参见下面),继续下一个分量。
    (3) 如果最近解析的目录是nd->mnt文件系统的根目录,而这个文件系统被安装在其他文件系统之上,那么就需要文件系统交换。因此,把nd->dentry置为nd->mnt->mnt_mountpoint(这个是在上一级文件系统下的路径),且把nd->mnt置为nd->mnt->mnt_parent,然后重新开始第5g步(回想一下,几个文件系统可以安装在同一个安装点上)。这样进入一个新的文件系统,在此系统中执行…逻辑。
    (4) 如果最近解析的目录不是已安装文件系统的根目录,那么必须回到父目录:把nd->dentry置为nd->dentry->d_parent,在父目录上调用follow_mount(),继续下一个分量。

follow_mount()函数检查nd->dentry是否是某文件系统的安装点(nd->dentry->d_mounted的值大于0);如果是,则调用lookup_mnt()搜索目录项高速缓存中已安装文件系统的根目录,并把nd->dentry和nd->mnt更新为相应已安装文件系统的对象地址;然后重复整个操作(几个文件系统可以安装在同一个安装点上)。从本质上说,由于进程可能从某个文件系统的目录开始路径名的查找,而该目录被另一个安装在其父目录上的文件系统所隐藏,那么当需要回到父目录时,则调用follow_mount()函数。

h. 分量名既不是“.”,也不是“…”,因此函数必须在目录项高速缓存中查找它。如果低级文件系统有一个自定义的d_hash目录项方法,则调用它来修改已在第5c步计算出的散列值。
i. 把nd->flags字段中LOOKUP_CONTINUE标志对应的位置位,这表示还有下一个分量要分析。
j. 调用do_lookup(),得到与给定的父目录(nd->dentry)和文件名(要解析的路径名分量)相关的目录项对象。该函数本质上首先调用__d_lookup()在目录项高速缓存中搜索分量的目录项对象。如果没有找到这样的目录项对象,则调用real_lookup()。而real_lookup()执行索引节点的lookup方法从磁盘读取目录,创建一个新的目录项对象并把它插入到目录项高速缓存中,然后创建一个新的索引节点对象并把它插入到索引节点高速缓存中。在这一步结束时,next局部变量中的dentry和mnt字段将分别指向这次循环要解析的分量名的目录项对象和已安装文件系统对象。
k. 调用follow_mount()函数检查刚解析的分量(next.dentry)是否指向某个文件系统安装点的一个目录(next.dentry->d_mounted值大于0)。follow_mount()更新next.dentry和next.mnt的值,以使它们指向由这个路径名分量所表示的目录上安装的最上层文件系统的目录项对象和已安装文件系统对象。
l. 检查刚解析的分量是否指向一个符号链接(next.dentry->d_inode具有一个自定义的follow_link方法)。
m. 检查刚解析的分量是否指向一个目录(next.dentry->d_inode具有一个自定义的lookup方法)。如果没有,返回一个错误码-ENOTDIR,因为这个分量位于原路径名的中间。
n. 把nd->dentry和nd->mnt分别置为next.dentry和next.mnt,然后继续路径名的下一个分量。
6. 现在,除了最后一个分量,原路径名的所有分量都被解析。清除nd->flags中的LOOKUP_CONTINUE标志。
7. 如果路径名尾部有一个“/”,则把lookup_flags局部变量中LOOKUP_FOLLOW和LOOKUP_DIRECTORY标志对应的位置位,以强制由后面的函数来解释最后一个作为目录名的分量。
8. 检查lookup_flags变量中LOOKUP_PARENT标志的值。下面假定这个标志被置为0,并把相反的情况推迟到下一节介绍。
9. 如果最后一个分量名是“.”(单个圆点),则终止执行并返回值0(无错误)。在nd指向的nameidata数据结构中,dentry和mnt字段指向路径名中倒数第二个分量对应的对象(任何分量“.”在路径名中没有效果)。
10. 如果最后一个分量名是“…”(两个圆点),则尝试回到父目录:
a. 如果最后解析的目录是进程的根目录(nd->dentry等于current->fs->root,nd->mnt等于current->fs->rootmnt),则在倒数第二个分量上调用follow_mount(),终止执行并返回值0(无错误)。nd->dentry和nd->mnt指向路径名的倒数第二个分量对应的对象,也就是进程的根目录。
b. 如果最后解析的目录是nd->mnt文件系统的根目录(nd->dentry等于nd->mnt->mnt_root),并且该文件系统没有被安装在另一个文件系统之上(nd->mnt等于nd->mnt->mnt_parent),那么再向上搜索是不可能的,因此在倒数第二个分量上调用follow_mount(),终止执行并返回值0(无错误)。
c. 如果最后解析的目录是nd->mnt文件系统的根目录,并且该文件系统被安装在其他文件系统之上,那么把nd->dentry和nd->mnt分别置为nd->mnt->mnt_mountpoint和nd->mnt->mnt_parent,然后重新执行第10步。
d. 如果最后解析的目录不是已安装文件系统的根目录,则把nd->dentry置为nd->dentry->d_parent,在父目录上调用follow_mount(),终止执行并返回值0(无错误)。nd->dentry和nd->mnt指向前一个分量(即路径名倒数第二个分量)对应的对象。
11. 路径名的最后分量名既不是“.”也不是“…”,因此,必须在高速缓存中查找它。如果低级文件系统有自定义的d_hash目录项方法,则该函数调用它来修改在第5c步已经计算出的散列值。
12. 调用do_lookup(),得到与父目录和文件名相关的目录项对象(参见第5j步)。在这一步结束时,next局部变量存放的是指向最后分量名对应的目录项和已安装文件系统描述符的指针。
13. 调用follow_mount()检查最后一个分量名是否是某个文件系统的一个安装点,如果是,则把next局部变量更新为最上层已安装文件系统根目录对应的目录项对象和已安装文件系统对象的地址。
14. 检查在lookup_flags中是否设置了LOOKUP_FOLLOW标志,且索引节点对象next.dentry->d_inode是否有一个自定义的follow_link方法。如果是,分量就是一个必须进行解释的符号链接。
15. 要解析的分量不是一个符号链接或符号链接不该被解释。把nd->mnt和nd->dentry字段分别置为next.mnt和next.dentry的值。最后的目录项对象就是整个查找操作的结果。
16. 检查nd->dentry->d_inode是否为NULL。这发生在没有索引节点与目录项对象关联时,通常是因为路径名指向一个不存在的文件。在这种情况下,返回一个错误码-ENOENT。
17. 路径名的最后一个分量有一个关联的索引节点。如果在lookup_flags中设置了LOOKUP_DIRECTORY标志,则检查索引节点是否有一个自定义的lookup方法,也就是说它是一个目录。如果没有,则返回一个错误码-ENOTDIR。
18. 返回值0(无错误)。nd->dentry和nd->mnt指向路径名的最后分量。

父路径名查找

在很多情况下,查找操作的真正目的并不是路径名的最后一个分量,而是最后一个分量的前一个分量。例如,当文件被创建时,最后一个分量表示还不存在的文件的文件名,而路径名中的其余路径指定新链接必须插入的目录。因此,查找操作应当取回最后分量的前一个分量的目录项对象。另举一个例子,把路径名/foo/bar表示的文件bar拆分出来就包含从目录foo中移去bar。因此,内核真正的兴趣在于访问文件目录foo而不是bar。当查找操作必须解析的是包含路径名最后一个分量的目录而不是最后一个分量本身时,使用LOOKUP_PARENT标志。当LOOKUP_PARENT标志被设置时,link_path_walk()函数也在nameidata数据结构中建立last和last_type字段。last字段存放路径名中的最后一个分量名。last_type 字段标识最后一个分量的类型;可以把它置为如表12-17所示的值之一。
在这里插入图片描述

当整个路径名的查找操作开始时,LAST_ROOT标志是由path_lookup()设置的缺省值。如果路径名正好是“/”,则内核不改变last_type字段的初始值。last_type字段的其他值在LOOKUP_PARENT标志置位时由link_path_walk()设置;
在这种情况下,函数执行前一节描述的步骤,直到第8步。不过,从第8步往后,路径名中最后一个分量的查找操作是不同的:

  1. 把nd->last置为最后一个分量名。
  2. 把nd->last_type初始化为LAST_NORM。
  3. 如果最后一个分量名为“.”(一个圆点),则把nd->last_type置为LAST_DOT。
  4. 如果最后一个分量名为“…”(两个圆点),则把nd->last_type置为LAST_DOTDOT。
  5. 通过返回值0(无错误)终止。
    你可以看到,最后一个分量根本就没有被解释。因此,当函数终止时,nameidata数据结构的dentry和mnt字段指向最后一个分量所在目录对应的对象。

符号链接的查找

回想一下,符号链接是一个普通文件,其中存放的是另一个文件的路径名。路径名可以包含符号链接,且必须由内核来解析。例如,如果/foo/bar是指向(包含路径名)…/dir的一个符号链接,那么,/foo/bar/file 路径名必须由内核解析为对/dir/file文件的引用。在这个例子中,内核必须执行两个不同的查找操作。第一个操作解析/foo/bar,当内核发现bar是一个符号链接名时,就必须提取它的内容并把它解释为另一个路径名。第二个路径名操作从第一个操作所达到的目录开始,继续到符号链接路径名的最后一个分量被解析。接下来,原来的查找操作从第二个操作所达到的目录项恢复,且有了原目录名中紧随符号链接的分量。

对于更复杂的情景,含有符号链接的路径名可能包含其他的符号链接。你可能认为解析这类符号链接的内核代码是相当难理解的,但并非如此;代码实际上是相当简单的,因为它是递归的。

然而,难以驾驭的递归本质上是危险的。例如,假定一个符号链接指向自己。当然,解析含有这样符号链接的路径名可能导致无休止的递归调用流,这又依次引发内核栈的溢出。当前进程的描述符中的link_count字段用来避免这种问题:每次递归执行前增加这个字段的值,执行之后减少其值。如果该字段的值达到6,整个循环操作就以错误码结束。因此,符号链接嵌套的层数不超过5。

此外,当前进程的描述符中的total_link_count字段记录在原查找操作中有多少符号链接(甚至非嵌套的)被跟踪。如果这个计数器的值到40,则查找操作中止。没有这个计数器,怀有恶意的用户就可能创建一个病态的路径名,让其中包含很多连续的符号链接,使内核在无休止的查找操作中冻结。

这就是代码基本工作的方式:一旦link_path_walk()函数检索到与路径名分量相关的目录项对象,就检查相应的索引节点对象是否有自定义的follow_link方法(参见“标准路径名查找“一节中的第51步和第14步)。如果是,索引节点就是一个符号链接,在原路径名的查找操作进行之前就必须先对这个符号链接进行解释。

在这种情况下,link_path_walk()函数调用do_follow_link(),前者传递给后者的参数为符号链接目录项对象的地址dentry和nameidata数据结构的地址nd。

do_follow_link()依次执行下列步骤:

  1. 检查current->link_count小于5;否则,返回错误码-ELOOP。
  2. 检查current->total_link_count小于40;否则,返回错误码-ELOOP。
  3. 如果当前进程需要,则调用cond_resched()进行进程交换(设置当前进程描述符thread_info中的TIF_NEED_RESCHED标志)。
  4. 递增current->link_count、current->total_link_count和nd->depth的值。
  5. 更新与要解析的符号链接关联的索引节点的访问时间。
  6. 调用与具体文件系统相关的函数来实现follow_link方法,给它传递的参数为dentry和nd。它读取存放在符号链接索引节点中的路径名,并把这个路径名保存在nd->saved_names数组的合适项中。
  7. 调用__vfs_follow_link()函数,给它传递的参数为地址nd和nd->saved_names数组中(参见下面)路径名的地址。
  8. 如果定义了索引节点对象的put_link方法,就执行它,释放由follow_link方法分配的临时数据结构。
  9. 减少current->link_count和nd->depth字段的值。
  10. 返回由__vfs_follow_link()函数返回的错误码(0表示无错误)。

__vfs_follow_link()函数本质上依次执行下列操作:
a. 检查符号链接路径名的第一个字符是否是“/“:在这种情况下,已经找到一个绝对路径名,因此没有必要在内存中保留前一个路径的任何信息。 如果是,对nameidata数据结构调用path_release(),因此释放由前一个查找步骤产生的对象; 然后,设置nameidata数据结构的dentry和mnt字段,以使它们指向当前进程的根目录。
b. 调用link_path_walk()解析符号链的路径名,传递给它的参数为路径名和nd。
c. 返回从link_path_walk()取回的值。

当do_follow_link()最后终止时,它把局部变量next的dentry字段设置为目录项对象的地址,而这个地址由符号链接传递给原先就执行的link_path_walk()。link_path_walk()函数然后进行下一步 。

VFS系统调用的实现

为了简短起见,我们不打算对表12-1中列出的所有VFS系统调用的实现进行讨论。不过,概略叙述几个系统调用的实现还是有用的,这里仅仅说明VFS的数据结构怎样互相作用。让我们重新考虑一下在本章开始所提到的例子,用户发出了一条shell命令:把/floppy/TEST中的MS-DOS文件拷贝到/tmp/test中的Ext2文件中。命令shell调用一个外部程序(如cp),我们假定cp执行下列代码片段:

inf = open(“/floppy/TEST“, O_RDONLY, 0);
outf = open(“/tmp/test“, 0_WRONLY I O_CREATIO_TRUNC, 0600);
do {
	len =read(inf, buf, 4096);
	write(outf, buf, len);
} while(len);
close(outf);
close(inf);

实际上,真正的cp程序的代码要更复杂些,因为它还必须检查由每个系统调用返回的可能的出错码。在我们的例子中,我们只把注意力集中在拷贝操作的“正常“行为上。

open()系统调用

open()系统调用的服务例程为sys_open()函数,该函数接收的参数为:要打开文件的路径名filename、访问模式的一些标志flags,以及如果该文件被创建所需要的许可权位掩码mode。如果该系统调用成功,就返回一个文件描述符,也就是指向文件对象的指针数组current->files->fd中分配给新文件的索引;否则,返回-1。

在我们的例子中,open()被调用两次;第一次是为读(O_RDONLY标志)而打开/floppy/TEST,第二次是为写(O_WRONLY标志)而打开/mp/test。如果/mp/test不存在,则该文件被创建(0_CREAT标志),文件主对该文件具有独占的读写访问权限(在第三个参数中的八进制数0600)。

相反,如果该文件已经存在,则从头开始重写它(0_TRUNC标志)。表12-18列出了open()系统调用的所有标志。
在这里插入图片描述

在这里插入图片描述

下面来描述一下sys_open()函数的操作。它执行如下操作:

  1. 调用getname()从进程地址空间读取该文件的路径名。
  2. 调用get_unused_fd()在current->files->fd中查找一个空的位置。相应的索引(新文件描述符)存放在fd局部变量中。
  3. 调用filp_open()函数,传递给它的参数为路径名、访问模式标志以及许可权位掩码。这个函数依次执行下列步骤:
    a. 把访问模式标志拷贝到namei_flags标志中,但是,用特殊的格式对访问模式标志O_RDONLY、O_WRONLY和O_RDWR进行编码:如果文件访问需要读特权,那么只设置namei_flags标志的下标为0的位(最低位);类似地,如果文件访问需要写特权,就只设置下标为1的位。注意,不可能在open()系统调用中不指定文件访问的读或写特权;不过,这种情况在涉及符号链接的路径名查找中则是有意义的。
    b. 调用open_namei(),传递给它的参数为路径名、修改的访问模式标志以及局部nameidata数据结构的地址。该函数以下列方式执行查找操作:
    b.1. 如果访问模式标志中没有设置0_CREAT,则不设置LOOKUP_PARENT标志而设置LOOKUP_OPEN标志后开始查找操作。
    b.2. 只有0_NOFOLLOW被清零,才设置LOOKUP_FOLLOW标志。
    b.3. 只有设置了0_DIRECTORY标志,才设置LOOKUP_DIRECTORY标志。
    b.4. 如果在访问模式标志中设置了0_CREAT,则以LOOKUP_PARENT、LOOKUP_OPEN和LOOKUP_CREATE标志的设置开始查找操作。一旦path_lookup()函数成功返回,则检查请求的文件是否已存在。如果不存在,则调用父索引节点的create方法分配一个新的磁盘索引节点。open_namei()函数也在查找操作确定的文件上执行几个安全检查。例如,该函数检查与已找到的目录项对象关联的索引节点是否存在、它是否是一个普通文件,以及是否允许当前进程根据访问模式标志访问它。如果文件也是为写打开的,则该函数检查文件是否被其他进程加锁。

c. 调用dentry_open()函数,传递给它的参数为访问模式标志、目录项对象的地址以及由查找操作确定的已安装文件系统对象。该函数依次执行下列操作:
(1). 分配一个新的文件对象。
(2). 根据传递给open()系统调用的访问模式标志初始化文件对象的f_flags和f_mode字段。
(3). 根据作为参数传递来的目录项对象的地址和已安装文件系统对象的地址初始化文件对象的f_fentry和f_vfsmnt字段。
(4). 把f_op字段设置为相应索引节点对象i_fop字段的内容。这就为进一步的文件操作建立起所有的方法。
(5). 把文件对象插入到文件系统超级块的s_files字段所指向的打开文件的链表。
(6). 如果文件操作的open方法被定义,则调用它。
(7). 调用file_ra_state_init()初始化预读的数据结构(参见第十六章)。
(8). 如果O_DIRECT标志被设置,则检查直接I/O操作是否可以作用于文件(参见第十六章)。
(9). 返回文件对象的地址。
d… 返回文件对象的地址。
4. 把current->files->fd[fd]置为由dentry_open()返回的文件对象的地址。
5. 返回fd。

read()和write()系统调用

让我们再回到cp例子的代码。open()系统调用返回两个文件描述符,分别存放在inf 和outf变量中。然后,程序开始循环。在每次循环中,/floppy/TEST文件的一部分被拷贝到本地缓冲区(read()系统调用)中,然后,这个本地缓冲区中的数据又被拷贝到/tmp/test文件(write()系统调用)。read()和write()系统调用非常相似。它们都需要三个参数:一个文件描述符fd、一个内存区的地址buf(该缓冲区包含要传送的数据),以及一个数count(指定应该传送多少字节)。当然,read()把数据从文件传送到缓冲区,而write()执行相反的操作。两个系统调用都返回所成功传送的字节数,或者发送一个错误条件的信号并返回-1。

返回值小于count并不意味着发生了错误。即使请求的字节没有都被传送,也总是允许内核终止系统调用,因此用户应用程序必须检查返回值并重新发出系统调用(如果必要)。在以下几种典型情况下返回小的值:当从管道或终端设备读取时,当读到文件的末尾时,或者当系统调用被信号中断时。文件结束条件(EOF)很容易从read()的空返回值中判断出来。这个条件不会与因信号引起的异常终止混淆在一起,因为如果读取数据之前read()被一个信号中断,则发生一个错误。

读或写操作总是发生在由当前文件指针所指定的文件偏移处(文件对象的f_pos字段)。两个系统调用都通过把所传送的字节数加到文件指针上而更新文件指针。简而言之,sys_read()(read()的服务例程)和sys_write()(write()的服务例程)几乎都执行相同的步骤:

  1. 调用fget_light()从fd获取相应文件对象的地址file(参见前面的“与进程相关的文件“一节)。
  2. 如果file->f_mode中的标志不允许所请求的访问(读或写操作),则返回一个错误码-EBADF。
  3. 如果文件对象没有read()或aio_read()(write()或aio_write())文件操作,则返回一个错误码-EINVAL。
  4. 调用access_ok()粗略地检查buf和count参数(参见第十章的“验证参数“一节)。
  5. 调用rw_verify_area()对要访问的文件部分检查是否有冲突的强制锁。如果有,则返回一个错误码,如果该锁已经被F_SETLKW命令请求,那么就挂起当前进程(参见本章后面的“文件加锁“一节)。
  6. 调用file->f_op->read或file->f_op->write方法(如果已定义)来传送数据;否则,调用file->f_op->aio_read或file->f_op->aio_write方法。所有这些方法(在第十六章讨论)都返回实际传送的字节数。另一方面的作用是,文件指针被适当地更新。
  7. 调用fput_light()释放文件对象。
  8. 返回实际传送的字节数。

close()系统调用

在我们例子的代码中,循环结束发生在read()系统调用返回0时,也就是说,发生在/floppy/TEST中的所有字节被拷贝到/tmp/test中时。然后,程序关闭打开的文件,这是因为拷贝操作已经完成。close()系统调用接收的参数为要关闭文件的文件描述符fd。sys_close()服务例程执行下列操作:

  1. 获得存放在current->files->fd[fd]中的文件对象的地址;如果它为NULL,则返回一个出错码。
  2. 把current->files->fd[fd]置为NULL。释放文件描述符fd,这是通过清除current->files中的open_fds和close_on_exec字段的相应位来进行的(参见第二十章中有关关闭执行标志的内容)。
  3. 调用filp_close(),该函数执行下列操作:
    a. 调用文件操作的flush方法(如果已定义)。
    b. 释放文件上的任何强制锁(参见下一节)。
    c. 调用fput()释放文件对象。
  4. 返回0或一个出错码。出错码可由flush方法或文件中的前一个写操作错误产生。

文件加锁

当一个文件可以被多个进程访问时,就会出现同步问题。如果两个进程试图对文件的同一位置进行写会出现什么情况?或者,如果一个进程从文件的某个位置进行读而另一个进程正在对同一位置进行写会出现什么情况?

在传统的Unix系统中,对文件同一位置的同时访问会产生不可预料的结果。但是,Unix 系统提供了一种允许进程对一个文件区进行加锁的机制,以使同时访问可以很容易地被避免。POSIX标准规定了基于fcntl()系统调用的文件加锁机制。这样就有可能对文件的任意一部分(甚至一个字节)加锁或对整个文件(包含以后要追加的数据)加锁。因为进程可以选择仅仅对文件的一部分加锁,因此,它也可以在文件的不同部分保持多个锁。

这种锁并不把不知道加锁的其他进程关在外面。与用于保护代码中临界区的信号量类似,可以认为这种锁起“劝告“的作用,因为只有在访问文件之前其他进程合作检查锁的存在时,锁才起作用。因此,POSIX的锁被称为劝告锁(advisory lock)。

传统的BSD变体通过flock()系统调用来实现劝告锁。这个调用不允许进程对文件的一个区字段进行加锁,而只能对整个文件进行加锁。传统的System V变体提供了lockf()库函数,它仅仅是fcntl()的一个接口。

更重要的是,System V Release3引入了强制加锁(mandatory locking);内核检查open()、read()和write()系统调用的每次调用都不违背在所访问文件上的强制锁。因此,强制锁甚至在非合作的进程之间也被强制加上(注9)。

不管进程是使用劝告锁还是强制锁,它们都可以使用共享读锁和独占写锁。在文件的某个区字段上,可以有任意多个进程进行读,但在同一时刻只能有一个进程进行写。此外,当其他进程对同一个文件都拥有自己的读锁时,就不可能获得一个写锁,反之亦然。

Linux文件加锁

Linux支持所有的文件加锁方式:劝告锁和强制锁,以及fcntl()、flock(〉和lockf()系统调用。不过,lockf()系统调用仅仅是一个标准的库函数。flock()系统调用不管MS_MANDLOCK安装标志如何设置,只产生劝告锁。这是任何类Unix操作系统所期望的系统调用行为。在Linux中,增加了一种特殊的flock()强制锁,以允许对专有的网络文件系统的实现提供适当的支持。这就是所谓的共享模式强制锁;当这个锁被设置时,其他任何进程都不能打开与锁访问模式冲突的文件。不鼓励本地Unix应用程序中使用这个特征,因为这样加锁的源代码是不可移植的。

在Linux中还引入了另一种基于fcntl()的强制锁,叫做租借锁(lease)。当一个进程试图打开由租借锁保护的文件时,它照样被阻塞。然而,拥有锁的进程接收到一个信号。一旦该进程得到通知,它应当首先更新文件,以使文件的内容保持一致,然后释放锁。如果拥有者不在预定的时间间隔(可以通过在/proc/sys/fs/lease-break-time文件中写入秒数来进行调整,通常为45s)内这么做,则租借锁由内核自动删除,且允许阻塞的进程继续执行。

进程可以采用以下两种方式获得或释放一个文件劝告锁:
· 发出flock()系统调用。传递给它的两个参数为文件描述符fd和指定锁操作的命令。该锁应用于整个文件。
· 使用fcntl()系统调用。传递给它的三个参数为文件描述符fd、指定锁操作的命令以及指向flock结构的指针(参见表12-20)。flock结构中的几个字段允许进程指定要加锁的文件部分。因此进程可以在同一文件的不同部分保持几个锁。

fcntl()和flock()系统调用可以在同一文件上同时使用,但是通过fcntl()加锁的文件看起来与通过flock()加锁的文件不一样,反之亦然。这样当应用程序使用一种依赖于某个库的锁,而该库同时使用另一种类型的锁时,可以避免发生死锁。

处理强制文件锁要更复杂些。步骤如下:

  1. 安装文件系统时强制锁是必需的,可使用mount命令的-o mand选项在mount()系统调用中设置MS_MANDLOCK标志。缺省操作是不使用强制锁。
  2. 通过设置文件的set-group位(SGID)和清除group-execute许可权位将它们标记为强制锁的候选者。因为当group-execute位为0时,set-group位也没有任何意义,因此内核将这种合并解释成使用强制锁而不是劝告锁。
  3. 使用fcntl()系统调用(参见下面)获得或释放一个文件锁。处理租借锁比处理强制锁要容易得多;调用具有F_SETLEASE或F_GETLEASE命令的系统调用fcntl()就足够了。使用另一个带有F_SETSIG命令的fcntl()系统调用可以改变传送给租借锁进程拥有者的信号类型。

当维护所有可以修改文件内容的系统调用时,除了read()和write()系统调用中的检查以外,内核还需要考虑强制锁的存在性。例如,如果文件中存在任何强制锁,那么带有O_TRUNC标志的open()系统调用就会失效。下一节描述内核使用的主要数据结构,它们用于处理由flock()(FL_FLOCK锁)和fcntl()系统调用(FL_POSIX锁)实现的文件锁。

文件锁的数据结构

Linux中所有类型的锁都是由相同的file_lock数据结构描述的,它的字段如表12-19 所示。
在这里插入图片描述
指向磁盘上同一文件的所有lock_file结构都被收集在一个单向链表中,其第一个元素由索引节点对象的i_flock字段所指向。file_lock结构的fl_next字段指向链表中的下一个元素。

当发出阻塞系统调用的进程请求一个独占锁而同一文件也存在共享锁时,该请求不能立即得到满足,并且进程必须被挂起。因此该进程被插入到由阻塞锁file_lock结构的fl_wait字段指向的等待队列中。使用两个链表区分已满足的锁请求(活动锁)和那些不能立刻得到满足的锁请求(阻塞锁)。所有的活动锁被链接在“全局文件锁链表”中,该表的首元素被存放在file_lock_list 变量中。类似地,所有的阻塞锁被链接在“阻塞链表“中,该表的首元素被存放在blocked_list变量中。使用fl_link字段可把lock_file结构插入到上述任何一个链表中。最后的一项要点是,内核必须跟踪所有与给定活动锁(“blocker”)关联的阻塞锁(“waiters”);这就是为什么要使用链表根据给定的blocker把所有的waiter链接在一起的原因。blocker的fl_block字段是链表的伪首部,而waiter的fl_block字段存放了指向链表中相邻元素的指针。

FL_FLOCK锁

FL_LOCK锁总是与一个文件对象相关联,因此由一个打开该文件的进程(或共享同一打开文件的子进程)来维护。当一个锁被请求或允许时,内核就把进程保持在同一文件对象上的任何其他锁都替换掉。这只发生在进程想把一个已经拥有的读锁改变为一个写锁,或把一个写锁改变为一个读锁时。此外,当fput()函数正在释放一个文件对象时,对这个文件对象加的所有FL_LOCK锁都被撤销。不过,也有可能由其他进程对这同一文件(索引节点)设置了其他FL_LOCK读锁,它们依然是有效的。

flock()系统调用允许进程在打开文件上申请或删除劝告锁。它作用于两个参数:要加锁文件的文件描述符fd和指定锁操作的参数cmd。如果cmd参数为LOCK_SH,则请求一个共享的读锁;为LOCK_EX,则请求一个互斥的写锁;为LOCK_UN,则释放一个锁(注10)。

如果请求不能立即得到满足,系统调用通常阻塞当前进程,例如,如果进程请求一个独占锁而其他某个进程已获得了该锁。不过,如果LOCK_NB标记与LOCK_SH或LOCK_EX 操作进行“或“,则这个系统调用不阻塞;换句话说,如果不能立即获得该锁,则该系统调用就返回一个错误码。当sys_flock()服务例程被调用时,则执行下列步骤:

  1. 检查fd是否是一个有效的文件描述符;如果不是,就返回一个错误码。否则,获得相应文件对象filp的地址。
  2. 检查进程在打开文件上是否有读和/或写权限;如果没有,就返回一个错误码。
  3. 获得一个新的file_lock对象锁并用适当的锁操作初始化它:根据参数cmd的值设置fl_type字段,把fl_file字段设为文件对象filp的地址,fl_flags字段设为FL_FLOCK,fl_pid字段设为current->tgid,并把fl_end字段设为-1,这表示对整个文件(而不是文件的一部分)加锁的事实。
  4. 如果参数cmd不包含LOCK_NB位,则把FL_SLEEP标志加入fl_flags字段。
  5. 如果文件具有一个flock文件操作,则调用它,传递给它的参数为文件对象指针filp、一个标志(F_SETLKW或F_SETLK,取决于LOCK_NB位的值)以及新的file_lock对象锁的地址。
  6. 否则,如果没有定义flock文件操作(通常情况下),则调用flock_lock_file_wait()试图执行请求的锁操作。传递给它的两个参数为:文件对象指针filp和在第3步创建的新的file_lock对象的地址lock。
  7. 如果上一步中还没有把file_lock描述符插入活动或阻塞链表中,则释放它。
  8. 返回0(成功)。
    flock_lock_file_wait()函数执行下列循环操作:
  9. 调用flock_lock_file(),传递给它的参数为文件对象指针filp和新的file_lock对象锁的地址lock。 这个函数依次执行下列操作:
    a. 搜索filp->f_dentry->d_inode->i_flock指向的链表。如果在同一文件对象中找到FL_FLOCK锁,则检查它的类型(LOCK_SH或LOCK_EX):如果该锁的类型与新锁相同,则返回0(什么也没有做)。 否则,从索引节点锁链表和全局文件锁链表中删除这个file_lock元素,唤醒fl-block链表中在该锁的等待队列上睡眠的所有进程,并释放file_lock结构。
    b. 如果进程正在执行开锁(LOCK_UN),则什么事情都不需要做:该锁已不存在或已被释放,因此返回0。
    c. 如果已经找到同一个文件对象的FL_FLOCK锁——表明进程想把一个已经拥有的读锁改变为一个写锁(反之亦然),那么调用cond_resched()给予其他更高优先级进程(特别是先前在原文件锁上阻塞的任何进程)一个运行的机会。
    d. 再次搜索索引节点锁链表以验证现有的FL_FLOCK锁并不与所请求的锁冲突。
    在索引节点链表中,肯定没有FL_FLOCK写锁,此外,如果进程正在请求一个写锁,那么根本就没有FL_FLOCK锁。
    e. 如果不存在冲突锁,则把新的file_lock结构插入索引节点锁链表和全局文件锁链表中,然后返回0(成功)。
    f. 发现一个冲突锁:如果fl_flags字段中FL_SLEEP对应的标志位置位,则把新锁(waiter锁)插入到blocker锁循环链表和全局阻塞链表中。
    g. 返回一个错误码-EAGAIN。
  10. 检查flock_lock_file()的返回码:
    a. 如果返回码为0(没有冲突迹象),则返回0(成功)。
    b. 不相容的情况。如果fl_flags字段中的FL_SLEEP标志被清除,就释放file_lock锁描述符,并返回一个错误码-EAGAIN。
    c. 否则,不相容但进程能够睡眠的情况:调用wait_event_interruptible()把当前进程插入到lock->fl_wait等待队列中并挂起它。当进程被唤醒时(正好在释放blocker锁后),跳转到第1步再次执行这个操作。

FL_POSIX锁

FL_POSIX锁总是与一个进程和一个索引节点相关联。当进程死亡或一个文件描述符被关闭时(即使该进程对同一文件打开了两次或复制了一个文件描述符),这种锁会被自动地释放。此外,FL_POSIX锁绝不会被子进程通过fork()继承。

当使用fcnt1()系统调用对文件加锁时,该系统调用作用于三个参数:要加锁文件的文件描述符fd、指向锁操作的参数cmd,以及指向存放在用户态进程地址空间中的flock 数据结构(注11)的指针f1。flock结构中的字段如表12-20所示。
在这里插入图片描述
sys_fcnt1()服务例程执行的操作取决于在cmd参数中所设置的标志值:

F_GETLK
	确定由flock结构描述的锁是否与另一个进程已获得的某个FL_POSIX锁互相冲突。
	在冲突的情况下,用现有锁的有关信息重写flock结构。
F_SETLK
	设置由flock结构描述的锁。如果不能获得该锁,则这个系统调用返回一个错误码。
F_SETLKW
	设置由flock结构描述的锁。如果不能获得该锁,则这个系统调用阻塞,
	也就是说,调用进程进入睡眠状态直到该锁可用时为止。
F_GETLK64,F_SETLK64,F_SETLKW64
	与前面描述的几个标志相同,但是使用的是flock64结构而不是flock结构。

sys_fcntl()服务例程首先获取与参数fd对应的文件对象,然后调用fcntl_getlk()或fcntl_setlk()函数(这取决于传递的参数:F_GETLK表示前一个函数,F_SETLK 或F_SETLKW表示后一个函数)。我们仅仅考虑第二种情况。fcntl_setlk()函数作用于三个参数:指向文件对象的指针filp、cmd命令(F_SETLK 或F_SETLKW),以及指向flock数据结构的指针。该函数执行下列操作:

  1. 读取局部变量中的参数f1所指向的flock结构。
  2. 检查这个锁是否应该是一个强制锁,且文件是否有一个共享内存映射(参见第十六章的“内存映射“一节)。在肯定的情况下,该函数拒绝创建锁并返回-EAGAIN出错码,说明文件正在被另一个进程访问。
  3. 根据用户flock结构的内容和存放在文件索引节点中的文件大小,初始化一个新的file_lock结构。
  4. 如果命令cmd为F_SETLKW,则该函数把file_lock结构的fl_flags字段设为FL_SLEEP标志对应的位置位。
  5. 如果flock结构中的1_type字段为F_RDLCK,则检查是否允许进程从文件读取;类似地,如果1_type为F_WRLCK,则检查是否允许进程写入文件。如果都不是,则返回一个出错码。
  6. 调用文件操作的lock方法(如果已定义)。对于磁盘文件系统,通常不定义该方法。
  7. 调用__posix_lock_file()函数,传递给它的参数为文件的索引节点对象地址以及file_lock对象地址。该函数依次执行下列操作:
    a. 对于索引节点的锁链表中的每个FL_POSIX锁,调用posix_locks_conflict()。
    该函数检查这个锁是否与所请求的锁互相冲突。从本质上说,在索引节点的链表中,必定没有用于同一区的FL_POSIX写锁,并且,如果进程正在请求一个写锁,那么同一个区字段也可能根本没有FL_POSIX锁。但是,同一个进程所拥有的锁从不会冲突;这就允许进程改变它已经拥有的锁的特性。
    b. 如果找到一个冲突锁,则检查是否以F_SETLKW标志调用fcnt1()。如果是,当前进程应当被挂起:这种情况下,调用posix_locks_deadlock()来检查在等待FL_POSIX锁的进程之间没有产生死锁条件,然后把新锁(waiter锁)插入到冲突锁(blocker锁)blocker链表和阻塞链表中,最后返回一个出错码。否则,如果以F_SETLK标志调用fcntl(),则返回一个出错码。
    c. 只要索引节点的锁链表中不包含冲突的锁,就检查把文件区重叠起来的当前进程的所有FL_POSIX锁,当前进程想按需要对文件区中相邻的区字段进行锁定、组合及拆分。例如,如果进程为某个文件区请求一个写锁,而这个文件区落在一个较宽的读锁区字段内,那么,以前的读锁就会被拆分为两部分,这两部分覆盖非重叠区域,而中间区域由新的写锁进行保护。在重叠的情况下,新锁总是代替旧锁。
    d. 把新的file_lock结构插入到全局锁链表和索引节点链表中。
    e. 返回值0(成功)。
  8. 检查__posix_lock_file()的返回码
    a. 如果返回码为0(没有冲突迹象),则返回0(成功)。
    b. 不相容的情况。如果fl_flags字段的FL_SLEEP标志被清除,就释放新的file_lock描述符,并返回一个错误码-EAGAIN。
    c. 否则,如果不相容但进程能够睡眠时,调用wait_event_interruptible()把当前进程插入到lock->f1_wait等待队列中并挂起它。当进程被唤醒时(正好在释放blocker锁后),跳转到第7步再次执行这个操作。

猜你喜欢

转载自blog.csdn.net/x13262608581/article/details/132246035