VFS 虚拟文件系统(下)

目录

 文件系统安装

安装普通文件系统

do_kern_mount()函数

分配超级块对象

安装根文件系统

阶段1:安装rootfs文件系统

init_rootfs

init_mount_tree

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

卸载文件系统

路径名查找

标准路径名查找

父路径名查找

符号链接的查找

VFS系统调用的实现

open()系统调用

read()和write()系统调用

close()系统调用

文件加锁

Linux文件加锁

文件锁的数据结构

FL_FLOCK锁

FL_POSIX锁

Reference


 文件系统安装

在大多数传统的类Unix内核中,每个文件系统只能安装一次。假定存放在/dev/fd0软磁盘上的Ext2文件系统通过如下命令安装在/flp:mount -t ext2 /dev/fd0 /flp在用umount命令卸载该文件系统前,所有其他作用于/dev/fd0的安装命令都会失败。然而,Linux有所不同:同一个文件系统被安装多次是可能的。当然,如果一个文件系统被安装了n次,那么它的根目录就可通过n个安装点来访问。尽管同一文件系统可以通过不同的安装点来访问,但是文件系统的的确确是唯一的。因此,不管一个文件系统被安装了多少次,都仅有一个超级块对象。

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

字段说明
mnt_hash用于散列表链表的指针
mntparent指向父文件系统,这个文件系统安装在其上
mnt_mountpoint指向这个文件系统安装点目录的dentry
mnt_root指向这个文件系统根目录的dentry
mnt_sb指向这个文件系统的超级块对象
mnt_mounts包含所有文件系统描述符链表的头(相对于这 个文件系统)
mnt_child用于已安装文件系统链表mnt_mounts的指针
mnt_mounts已安装文件系统链表的指针
mnt_count引用计数器(增加该值以禁止文件系统被卸载)
mnt_flags标志
mnt_expiry_mark如果文件系统标记为到期,那么就设置该标志 为true(如果设置了该标志,并且没有任何人 使用它,那么就可以自动卸载这个文件系统
mnt_devmame设备文件名
mnt_list已安装文件系统描述符的namespace链表的指针
mnt fslink具体文件系统到期链表的指针
mnt_namespace指向安装了文件系统的进程命名空间的指针

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所示。

名称说明
MNT_NOSUID在已安装文件系统中禁止标志setuid和setgid
MNT_NODEV在已安装文件系统中禁止访问设备
MNT_NOEXEC在已安装新文件系统中不允许程序的执行

下列函数处理已安装文件系统描述符:

  • alloc_vfsmnt(name): 分配和初始化一个已安装文件系统的描述服务

  • free_vfsmnt(mnt): 释放已有以mnt指向的已安装文件系统描述符

  • lookup_mnt(mnt, dentry):在散列表中查找一个描述符并且返回它的地址

安装普通文件系统

我们现在描述安装一个文件系统时内核所要执行的操作。我们首先考虑一个文件系统将被安装在一个已安装文件系统之上的情形(在这里我们把这种新文件系统看作“普通的”)。mount()系统调用被用来安装一个普通文件系统;它的服务例程sys_mount()作用于以下参数:

  • 文件系统所在的设备文件的路径名,或者如果不需要的话就为空

  • 文件系统被安装器上的某个目录的目录路径

  • 文件系统的类型,必须是已注册文件系统的名称

  • 安装标志

  • 指向一个与文件系统相关的数据结构的指针

/* 
 * These are the fs-independent mount-flags: up to 32 flags are supported 
 */  
#define MS_RDONLY        1         /* 对应-o ro/rw */  
#define MS_NOSUID        2         /* 对应-o suid/nosuid */  
#define MS_NODEV         4         /* 对应-o dev/nodev */  
#define MS_NOEXEC        8         /* 对应-o exec/noexec */  
#define MS_SYNCHRONOUS  16         /* 对应-o sync/async */  
#define MS_REMOUNT      32         /* 对应-o remount,告诉mount这是一次remount操作 */  
#define MS_MANDLOCK     64         /* 对应-o mand/nomand */  
#define MS_DIRSYNC      128        /* 对应-o dirsync */  
#define MS_NOATIME      1024       /* 对应-o atime/noatime */  
#define MS_NODIRATIME   2048       /* 对应-o diratime/nodiratime */  
#define MS_BIND         4096       /* 对应-B/--bind选项,告诉mount这是一次bind操作 */  
#define MS_MOVE         8192       /* 对应-M/--move,告诉mount这是一次move操作 */  
#define MS_REC          16384      /* rec是recursive的意思,这个flag一般不单独出现,都是伴随这其它flag,表示递归的进行操作 */  
#define MS_VERBOSE      32768      /* 对应-v/--verbose */  
#define MS_SILENT       32768      /* 对应-o silent/loud */  
#define MS_POSIXACL     (1<<16)    /* 让VFS不应用umask,如NFS */  
#define MS_UNBINDABLE   (1<<17)    /* 对应--make-unbindable */  
#define MS_PRIVATE      (1<<18)    /* 对应--make-private */  
#define MS_SLAVE        (1<<19)    /* 对应--make-slave */  
#define MS_SHARED       (1<<20)    /* 对应--make-shared */  
#define MS_RELATIME     (1<<21)    /* 对应-o relatime/norelatime */  
#define MS_KERNMOUNT    (1<<22)    /* 这个一般不在应用层使用,一般内核挂载的文件系统如sysfs使用,表示使用kern_mount()进行挂载 */  
#define MS_I_VERSION    (1<<23)    /* 对应-o iversion/noiversion */  
#define MS_STRICTATIME  (1<<24)    /* 对应-o strictatime/nostrictatime */  
#define MS_LAZYTIME     (1<<25)    /* 对应 -o lazytime/nolazytime*/

/* 下面这几个flags都是内核内部使用的,不由mount系统调用传递 */
#define MS_SUBMOUNT     (1<<26)
#define MS_NOREMOTELOCK (1<<27)
#define MS_NOSEC        (1<<28)
#define MS_BORN         (1<<29)
#define MS_ACTIVE       (1<<30)
#define MS_NOUSER       (1<<31)
/* 
 * Superblock flags that can be altered by MS_REMOUNT 
 */  
#define MS_RMT_MASK     (MS_RDONLY|MS_SYNCHRONOUS|MS_MANDLOCK|MS_I_VERSION|\                   
                         MS_LAZYTIME)  // 可以在remount时改变的flags  
  
/* 
 * Old magic mount flag and mask 
 */  
#define MS_MGC_VAL 0xC0ED0000      /* magic number */  
#define MS_MGC_MSK 0xffff0000      /* flags mask */

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. 检查安装标志以决定必须做什么。尤其是:

    1. 如果MS_REMOUNT标志被指定,其目的通常是改变超级块对象s_flags字段的安装标志,以及已安装文件系统对象mnt_flags字段的安装文件系统标志。do_remount()函数执行这些改变。

    2. 否则,检查MS_BIND标志。如果它被指定,则用户要求在在系统目录树的另一个安装点上的文件或目录能够可见。

    3. 否则,检查MS_MOVE标志。如果它被指定,则用户要求改变已安装文件系统的安装点。do_move_mount()函数原子地完成这一任务。

    4. 否则,调用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读/写信号量并返回。

  1. 调用path_release()终止安装点的路径名查找并返回0。

do_kern_mount()函数

安装操作的核心是do_kern_mount()函数,它检查文件系统类型标志以决定安装操作是如何完成的。该函数接收下列参数:

fstype: 要安装的文件系统的类型名

flags:安装标志

name: 存放文件系统的块设备的路径名

data: 指向传递给文件系统的readsuper方法的附加数据的指针

本质上,该函数通过执行下列操作实现实际的安装操作:

  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字段置为合适的值)。

  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, flags I MS_NOUSER, data, ramfs_fill_super);
}

get_sb_nodev()函数执行如下步骤:

  1. 调用sget()函数分配新的超级块,传递set_anon_super()函数的地址作为参数。接下来,用合适的方式设置超级块的s_dev字段:主设备号为0,次设备号不同于其他已安装的特殊文件系统的次设备号。

  2. 将flags参数的值拷贝到超级块的s_flags字段中。

  3. 调用ramfs_fill_super()函数分配索引节点对象和对应的目录项对象,并填充超级块字段值。由于rootfs是一种特殊文件系统,没有磁盘超级块,因此只需执行两个超级块操作。

  4. 返回新超级块的地址。

  1. 为进程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()函数,依次执行如下操作:

    1. 调用sys_mknod()在rootfs初始根文件系统中创建设备文件/dev/root,其主、次设备号与存放在ROOT_DEV中的一样。

    2. 分配一个缓冲区并用文件系统类型名链表填充它。该链表要么通过启动参数“rootfstype”传送给内核,要么通过扫描文件系统类型单向链表中的元素建立。

    3. 扫描上一步建立的文件系统类型名链表。对每个名字,调用sys_mount()试图在根设备上安装给定的文件系统类型。由于每个特定于文件系统的方法使用不同的魔数,因此,对get_sb()的调用大都会失败,但有一个例外,那就是用根设备上实际使用过的文件系统的函数来填充超级块的那个调用,该文件系统被安装在rootfs文件系统的/root目录上。

    4. 调用sys_chdir(“/root”)改变进程的当前目录。此目录项通过目录项所在的文件系统挂载点,路径对应的目录项对象唯一确定。

  3. 移动rootfs文件系统根目录上的已安装文件系统的安装点。

// param1:dev_path---/root
// param2:mount_path---上一级文件系统根目录
// 这样新安装的文件系统成为全局根文件系统
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(一组标志)。该函数执行下列操作:

    1. 从已安装文件系统对象的mnt_sb字段检索超级块对象sb的地址。

    2. 如果用户要求强制卸载操作,则调用umount_begin超级块操作中断任何正在进行的安装操作。

    3. 如果要卸载的文件系统是根文件系统,且用户并不要求真正地把它卸载下来,则调用do_remount_sb()重新安装根文件系统为只读并终止。

    4. 为进行写操作而获取当前进程的namespace->sem读/写信号量和vfsmount_lock自旋锁。

    5. 如果已安装文件系统不包含任何子安装文件系统的安装点,或者用户要求强制卸载文件系统,则调用umount_tree()卸载文件系统(及其所有子文件系统)。

    6. 释放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已安装文件系统对象的地址
last路径名的最后一个分量(当LOOKUP PARENT标志被设置时使用)
flags查找标志
last_type径名最后一个分量的类型(当LOOKUP PARENT标志被设置时使用)
depth号链接嵌套的当前级别,它必须小于6
saved_names与嵌套的符号链接关联的路径名数组
intent单个成员联合体指定如何访问文件

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

说明
LOOKUP_FOLLOW如果最后一个分量是符号链接,则解释(追踪)它
LOOKUP_DIRECTORY最后一个分量必须是自录
LOOKUP_CONTINUE在路径名中还有文件名要检查
LOOKUPPARENT查找最后一个分量名所在的目录
LOOKUP_NOALT不考虑模拟根目录
LOOKUP_OPEN试图打开一个文件
LOOKUP_CREATE试图创建一个文件(如果不存在)
LOOKUP_ACCESS试图为一个文件检查用户的权限

path_lookup()函数执行下列步骤:

  1. 如下初始化nd参数的某些字段:

    1. 把last_type字段置为LAST_ROOT(如果路径名是一个“/”或“/”序列,那么这是必需的)。

    2. 把flags字段置为参数flags的值。

    3. 把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指向前一个分量(即路径名倒数第二个分量)对应的对象。

  1. 路径名的最后分量名既不是“.”也不是“…”,因此,必须在高速缓存中查找它。如果低级文件系统有自定义的d_hash目录项方法,则该函数调用它来修改在第5c步已经计算出的散列值。

  2. 调用do_lookup(),得到与父目录和文件名相关的目录项对象(在这一步结束时,next局部变量存放的是指向最后分量名对应的目录项和已安装文件系统描述符的指针。)

  3. 调用follow_mount()检查最后一个分量名是否是某个文件系统的一个安装点,如果是,则把next局部变量更新为最上层已安装文件系统根目录对应的目录项对象和已安装文件系统对象的地址。

  4. 检查在lookup_flags中是否设置了LOOKUP_FOLLOW标志,且索引节点对象next.dentry->d_inode是否有一个自定义的follow_link方法。如果是,分量就是一个必须进行解释的符号链接。

  5. 要解析的分量不是一个符号链接或符号链接不该被解释。把nd->mnt和nd->dentry字段分别置为next.mnt和next.dentry的值。最后的目录项对象就是整个查找操作的结果。

  6. 检查nd->dentry->d_inode是否为NULL。这发生在没有索引节点与目录项对象关联时,通常是因为路径名指向一个不存在的文件。在这种情况下,返回一个错误码-ENOENT。

  7. 路径名的最后一个分量有一个关联的索引节点。如果在lookup_flags中设置了LOOKUP_DIRECTORY标志,则检查索引节点是否有一个自定义的lookup方法,也就是说它是一个目录。如果没有,则返回一个错误码-ENOTDIR。

  8. 返回值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_NORM最后一个分量是普通文件名
LAST_ROOT最后一个分量是“/”(也就是整个路径名为“/”)
LAST_DOT最后一个分量是“."
LAST_DOTDOT最后一个分量是“..”
LAST_BIND最后一个分量是链接到特殊文件系统的符号链接

当整个路径名的查找操作开始时,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方法。如果是,索引节点就是一个符号链接,在原路径名的查找操作进行之前就必须先对这个符号链接进行解释。

在这种情况下,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);
12345678

实际上,真正的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()系统调用的所有标志。

  • O_RDONLY :以只读方式打开文件

  • O_WRONLY :以只写方式打开文件

  • O_RDWR :以可读可写方式打开文件

  • O_APPEND:每次进行写操作时,内核都会先定位到文件尾,再执行写操作。

  • O_ASYNC:使用异步 I/O 模式。

  • O_CLOEXEC :在打开文件的时候,就为文件描述符设置 FD_CLOEXEC 标志。这是一个新的选项,用于解决在多线程下 fork 与用 fcntl 设置 FD_CLOEXEC 的竞争问题。某些应用使用 fork 来执行第三方的业务,为了避免泄露已打开文件的内容, 那些文件会设置 FD_CLOEXEC 标志。但是 fork 与 fcntl 是两次调用,在多线程下, 可能会在 fcntl 调用前,就已经 fork 出子进程了,从而导致该文件句柄暴露给子进程。关于 O_CLOEXEC 的用途。

  • O_CREAT:当文件不存在时,就创建文件。

  • O_DIRECT:对该文件进行直接 I/O,不使用 VFS Cache。

  • O_DIRECTORY:要求打开的路径必须是目录。

  • O_EXCL:该标志用于确保是此次调用创建的文件,需要与 O_CREAT 同时使用; 当文件已经存在时,open 函数会返回失败。

  • O_LARGEFILE:表明文件为大文件。

  • O_NOATIME:读取文件时,不更新文件最后的访问时间。

  • O_NONBLOCK、O_NDELAY:将该文件描述符设置为非阻塞的(默认都是阻塞的)。

  • O_SYNC :设置为 I/O 同步模式,每次进行写操作时都会将数据同步到磁盘,然后write 才能返回。

  • O_TRUNC:在打开文件的时候,将文件长度截断为0,需要与O_RDWR或O_WRONLY同时使用。在写文件时,如果是作为新文件重新写入,一定要使用O_TRUNC标志,否则可能会造成旧内容依然存在于文件中的错误,如生成配置文件、pid文件等。

下面来描述一下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. 如果访问模式标志中没有设置O_CREAT,则不设置LOOKUP_PARENT标志而设置LOOKUP_OPEN标志后开始查找操作。 b.2. 只有O_NOFOLLOW被清零,才设置LOOKUP_FOLLOW标志。 b.3. 只有设置了O_DIRECTORY标志,才设置LOOKUP_DIRECTORY标志。 b.4. 如果在访问模式标志中设置了O_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… 返回文件对象的地址。

  1. 把current->files->fd[fd]置为由dentry_open()返回的文件对象的地址。

  2. 返回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()系统调用的每次调用都不违背在所访问文件上的强制锁。因此,强制锁甚至在非合作的进程之间也被强制加上。

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

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 所示。

struct file_lock*fl_next与索引节点相关的锁列表中下一个元素
struct list_headfl_link指向活跃列表或者被阻塞列表
struct list_headfl_block指向锁等待列表
struct files_struct *fl_owner锁拥有者的 files_struct
unsigned charfl_flags锁标识
unsigned charfl_type锁类型
unsigned intfl_pid进程拥有者的 pid
wait_queue_head_tfl_wait被阻塞进程的等待队列
struct file *fl_file指向文件对象
loff_tfl_start被锁区域的开始位移
loff_tfl_end被锁区域的结束位移
struct fasync_struct *fl_fasync用于租借暂停通知
unsigned longfl_break_time租借的剩余时间
struct file_lock_operations *fl_ops指向文件锁操作
struct lock_manager_operations *fl_mops指向锁管理操作
unionfl_u文件系统特定信息

指向磁盘上同一文件的所有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,则释放一个锁。

如果请求不能立即得到满足,系统调用通常阻塞当前进程,例如,如果进程请求一个独占锁而其他某个进程已获得了该锁。不过,如果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()函数执行下列循环操作:

  1. 调用flock_lock_file(),传递给它的参数为文件对象指针filp和新的file_lock对象锁的地址lock。 这个函数依次执行下列操作:

    1. 搜索filp->f_dentry->d_inode->i_flock指向的链表。如果在同一文件对象中找到FL_FLOCK锁,则检查它的类型(LOCK_SH或LOCK_EX):如果该锁的类型与新锁相同,则返回0(什么也没有做)。 否则,从索引节点锁链表和全局文件锁链表中删除这个file_lock元素,唤醒fl_block链表中在该锁的等待队列上睡眠的所有进程,并释放file_lock结构。

    2. 如果进程正在执行开锁(LOCK_UN),则什么事情都不需要做:该锁已不存在或已被释放,因此返回0。

    3. 如果已经找到同一个文件对象的FL_FLOCK锁——表明进程想把一个已经拥有的读锁改变为一个写锁(反之亦然),那么调用cond_resched()给予其他更高优先级进程(特别是先前在原文件锁上阻塞的任何进程)一个运行的机会。

    4. 再次搜索索引节点锁链表以验证现有的FL_FLOCK锁并不与所请求的锁冲突。 在索引节点链表中,肯定没有FL_FLOCK写锁,此外,如果进程正在请求一个写锁,那么根本就没有FL_FLOCK锁。

    5. 如果不存在冲突锁,则把新的file_lock结构插入索引节点锁链表和全局文件锁链表中,然后返回0(成功)。

    6. 发现一个冲突锁:如果fl_flags字段中FL_SLEEP对应的标志位置位,则把新锁(waiter锁)插入到blocker锁循环链表和全局阻塞链表中。返回一个错误码-EAGAIN。

  2. 检查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()继承。

当使用fcntl()系统调用对文件加锁时,该系统调用作用于三个参数:要加锁文件的文件描述符fd、指向锁操作的参数cmd,以及指向存放在用户态进程地址空间中的flock 数据结构的指针f1。

sys_fcntl()服务例程执行的操作取决于在cmd参数中所设置的标志值:

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

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结构中的l_type字段为F_RDLCK,则检查是否允许进程从文件读取;类似地,如果l_type为F_WRLCK,则检查是否允许进程写入文件。如果都不是,则返回一个出错码。

  6. 调用文件操作的lock方法(如果已定义)。对于磁盘文件系统,通常不定义该方法。

  7. 调用__posix_lock_file()函数,传递给它的参数为文件的索引节点对象地址以及file_lock对象地址。该函数依次执行下列操作:

    1. 对于索引节点的锁链表中的每个FL_POSIX锁,调用posix_locks_conflict()。 该函数检查这个锁是否与所请求的锁互相冲突。从本质上说,在索引节点的链表中,必定没有用于同一区的FL_POSIX写锁,并且,如果进程正在请求一个写锁,那么同一个区字段也可能根本没有FL_POSIX锁。但是,同一个进程所拥有的锁从不会冲突;这就允许进程改变它已经拥有的锁的特性。

    2. 如果找到一个冲突锁,则检查是否以F_SETLKW标志调用fcntl()。如果是,当前进程应当被挂起:这种情况下,调用posix_locks_deadlock()来检查在等待FL_POSIX锁的进程之间没有产生死锁条件,然后把新锁(waiter锁)插入到冲突锁(blocker锁)blocker链表和阻塞链表中,最后返回一个出错码。否则,如果以F_SETLK标志调用fcntl(),则返回一个出错码。

    3. 只要索引节点的锁链表中不包含冲突的锁,就检查把文件区重叠起来的当前进程的所有FL_POSIX锁,当前进程想按需要对文件区中相邻的区字段进行锁定、组合及拆分。例如,如果进程为某个文件区请求一个写锁,而这个文件区落在一个较宽的读锁区字段内,那么,以前的读锁就会被拆分为两部分,这两部分覆盖非重叠区域,而中间区域由新的写锁进行保护。在重叠的情况下,新锁总是代替旧锁。

    4. 把新的file_lock结构插入到全局锁链表和索引节点链表中。

    5. 返回值0(成功)。

  8. 检查__posix_lock_file()的返回码

    1. 如果返回码为0(没有冲突迹象),则返回0(成功)。

    2. 不相容的情况。如果fl_flags字段的FL_SLEEP标志被清除,就释放新的file_lock描述符,并返回一个错误码-EAGAIN。

    3. 否则,如果不相容但进程能够睡眠时,调用wait_event_interruptible()把当前进程插入到lock->fl_wait等待队列中并挂起它。当进程被唤醒时(正好在释放blocker锁后),跳转到第7步再次执行这个操作。

Reference

[高级操作系统] VFS详解(虚拟文件系统)_操作系统vfs层-CSDN博客

Linux VFS机制简析(一) - 舰队 - 博客园 (cnblogs.com)

狼烟 / Linux内核源码分析:文件锁 (hongxiaolong.com)

  • 17
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值