把Linux中的VFS对象串联起来
把Linux中的VFS对象串联起来
2010年06月21日
上一篇博文讲到了内核提供的通用虚拟文件模型中四大数据结构极其操作,那么,为了使各种文件系统和谐相处,这些对象又是怎么串联起来的呢。本博,我们就重点讨论他们怎样与内核交互,包括如何与进程打交道,以及介绍一些相关的缓存机制。 首先,文件必须由进程打开,每个进程都有它自己当前的工作目录和它自己的根目录。这仅仅是内核用来表示进程与文件系统相互作用所必须维护的数据中的两个例子。类型为fs_struct的整个数据结构就用于此目的,且每个进程描述符task_struct的fs字段就指向进程的fs_struct结构:
struct fs_struct {
atomic_t count;
rwlock_t lock;
int umask;
struct dentry * root, * pwd, * altroot;
struct vfsmount * rootmnt, * pwdmnt, * altrootmnt;
};
其中:
count:共享这个表的进程个数
lock:用于表中字段的读/写自旋锁
umask:当打开文件设置文件权限时所使用的位掩码
root:根目录的目录项
pwd:当前工作目录的目录项
altroot:模拟根目录的目录项(在80x86结构上始终为NULL)
rootmnt:根目录所安装的文件系统对象
pwdmnt:当前工作目录所安装的文件系统对象
altrootmnt:模拟根目录所安装的文件系统对象(在80x86结构上始终为NULL)
第二个表表示进程当前打开的文件,表的地址存放于进程描述符task_struct的files字段。该表的类型为files_struct结构:
struct files_struct {
atomic_t count;
struct fdtable *fdt;
struct fdtable fdtab;
spinlock_t file_lock ____cacheline_aligned_in_smp;
int next_fd;
struct embedded_fd_set close_on_exec_init;
struct embedded_fd_set open_fds_init;
struct file * fd_array[NR_OPEN_DEFAULT];
};
struct fdtable {
unsigned int max_fds;
int max_fdset;
struct file ** fd; /* current fd array */
fd_set *close_on_exec;
fd_set *open_fds;
struct rcu_head rcu;
struct files_struct *free_files;
struct fdtable *next;
};
#define NR_OPEN_DEFAULT BITS_PER_LONG
#define BITS_PER_LONG 32 /* asm-i386 */
fdtable结构嵌入在files_struct中,并且由它的fdt指向。
fdtable结构的fd字段指向文件对象的指针数组。该数组的长度存放在max_fds字段中。通常,fd字段指向files_struct结构的fd_array字段,该字段包括32个文件对象指针。如果进程打开的文件数目多于32,内核就分配一个新的、更大的文件指针数组,并将其地址存放在fd字段中,内核同时也更新max_fds字段的值,如图所示:
对于在fd数组中所有元素的每个文件来说,数组的索引就是文件描述符(file descriptor)。通常,数组的第一个元素(索引为0)是进程的标准输入文件,数组的第二个元素(索引为1)是进程的标准输出文件,数组的第三个元素(索引为2)是进程的标准错误文件。请注意,借助于dup()、dup2()和fcntl()系统调用,两个文件描述符可以指向同一个打开的文件,也就是说,数组的两个元素可能指向同一个文件对象。当用户使用shell结构(如2>&1)将标准错误文件重定向到标准输出文件上时,用户也能看到这一点。
进程不能使用多于NR_OPEN(通常为1 048 576)个文件描述符。内核也在进程描述符的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()使文件对象引用计数器fcount的值增1:
struct file fastcall *fget(unsigned int fd)
{
struct file *file;
struct files_struct *files = current->files;
rcu_read_lock();
file = fcheck_files(files, fd);
if (file) {
if (!atomic_inc_not_zero(&file->f_count)) {
/* File object ref couldn't be taken */
rcu_read_unlock();
return NULL;
}
}
rcu_read_unlock();
return file;
}
static inline struct file * fcheck_files(struct files_struct *files, unsigned int fd)
{
struct file * file = NULL;
/* 不考虑RCU机制,files_fdtable宏就是返回files->fdt */
struct fdtable *fdt = files_fdtable(files);
if (fd max_fds)
file = rcu_dereference(fdt->fd[fd]);
return file;
}
当内核控制路径完成对文件对象的使用时,调用内核提供的fput()函数。该函数将文件对象的地址作为参数,并减少文件对象引用计数器f_count的值。另外,如果这个字段变为0,该函数就调用文件操作的release方法(如果已定义),减少索引节对象的i_writecount字段的值(如果该文件是可写的),将文件对象从超级块链表中移走,释放文件对象给slab分配器,最后减少相关的文件系统描述符的目录项对象的引用计数器的值:
void fastcall fput(struct file *file)
{
if (atomic_dec_and_test(&file->f_count))
__fput(file);
}
void fastcall __fput(struct file *file)
{
struct dentry *dentry = file->f_dentry;
struct vfsmount *mnt = file->f_vfsmnt;
struct inode *inode = dentry->d_inode;
might_sleep();
fsnotify_close(file);
/*
* The function eventpoll_release() should be the first called
* in the file cleanup chain.
*/
eventpoll_release(file);
locks_remove_flock(file);
if (file->f_op && file->f_op->release)
file->f_op->release(inode, file);
security_file_free(file);
if (unlikely(S_ISCHR(inode->i_mode) && inode->i_cdev != NULL))
cdev_put(inode->i_cdev);
fops_put(file->f_op);
if (file->f_mode & FMODE_WRITE)
put_write_access(inode);
file_kill(file);
file->f_dentry = NULL;
file->f_vfsmnt = NULL;
file_free(file);
dput(dentry);
mntput(mnt);
}
fget_light()和fget_light()函数是fget()和fput()的快速版本:内核要使用它们,前提是能够安全地假设当前进程已经拥有文件对象,即进程先前已经增加了文件对象引用计数器的值。例如,它们由接收一个文件描述符作为参数的系统调用服务例程使用,这是由于先前的open()系统调用已经增加了文件对象引用计数器的值。 VFS用了一个高速缓存来加快对索引节点的访问,和我们以后将会谈到的页高速缓存不同的一点是,每个缓冲区不用再分为两个部分了,因为inode结构中已经有了类似于块高速缓存中缓冲区首部的域。索引节点高速缓存的实现代码全部在fs/inode.c,这部分代码并没有随着内核版本的变化做很多的修改。
每个索引节点可能处于哈希表中,也可能同时处于下列"类型"链表的一种中:
. "in_use" - 有效的索引节点,即 i_count > 0且i_nlink > 0(参看前面的inode结构)
. "dirty" - 类似于 "in_use" ,但还"脏"
. "unused" - 有效的索引节点但还没使用,即 i_count = 0。
这几个链表定义如下:
static LIST_HEAD(inode_in_use);
static LIST_HEAD(inode_unused);
static struct hlist_head *inode_hashtable;
static LIST_HEAD(anon_hash_chain); /* for inodes with NULL i_sb */
因此,索引节点高速缓存的结构概述如下:
. 全局哈希表inode_hashtable,其中哈希值是根据每个超级块指针的值和32位索引节点号而得。对没有超级块的索引节点(inode->i_sb == NULL),则将其加入到
anon_hash_chain链表的首部。我们通过insert_inode_hash函数将一个inode结构插入到这个散列表中。
. 正在使用的索引节点链表。全局变量inode_in_use指向该链表中的首元素和尾元素。一般通过new_inode函数新分配的索引节点就加入到这个链表中。
. 未用索引节点链表。全局变量inode_unused的next域和prev域分别指向该链表中的首元素和尾元素。
. 脏索引节点链表。由相应超级块的s_dirty域指向该链表中的首元素和尾元素。
. 对inode对象的缓存,定义如下:static kmem_cache_t * inode_cachep,这是一个Slab缓存,用于分配和释放索引节点对象。
如上图所示,索引节点的i_hash域指向哈希表,i_list指向in_use、unused 或 dirty某个链表。所有这些链表都受单个自旋锁inode_lock的保护。索引节点高速缓存的初始化是由inode_init()实现的,而这个函数是在系统启动时由init/main.c中的start_kernel()函数调用的。inode_init(unsigned long mempages)只有一个参数,表示索引节点高速缓存所使用的物理页面数。因此,索引节点高速缓存可以根据可用物理内存的大小来进行配置,例如,如果物理内存足够大的话,就可以创建一个大的哈希表。
索引节点状态的信息存放在数据结构inodes_stat_t中,在linux/Fs.h中定义如下:
struct inodes_stat_t {
int nr_inodes;
int nr_unused;
int dummy[5];
};
extern struct inodes_stat_t inodes_stat
用户程序可以通过/proc/sys/fs/inode-nr 和 /proc/sys/fs/inode-state获得索引节点高速缓存中索引节点总数及未用索引节点数。 由于从磁盘读入一个目录项并构造相应的目录项对象需要花费大量的时间,所以在完成对目录项对象的操作后,可能后面还要使用它,因此,跟上面的索引节点一样,在内存中保留它有重要的意义。
为了最大限度地提高处理这些目录项对象的效率,Linux使用目录项高速缓存,它由两种类型的数据结构组成:
- 一个处于正在使用、未使用或负状态的目录项对象的集合。
- 一个散列表,从中能够快速获取与给定的文件名和目录名对应的目录项对象。同样,如果访问的对象不在目录项高速缓存中,则散列函数返回一个空值。
如图所示:
所有"未使用"目录项对象都存放在一个"最近最少使用(Least Recently used,LRU)"的双向链表中,该链表按照插入的时间排序。换句话说,最后释放的目录项对象放在链表的首部,所以最近最少使用的目录项对象总是靠近链表的尾部。一旦目录项高速缓存的空间开始变小,内核就从链表的尾部删除元素,使得最近最常使用的对象得以保留。LRU链表的首元素和尾元素的地址存放在list_head类型的dentry_unused变量的next字段和prev字段中。目录项对象的d_lru字段包含指向链表中相邻目录项的指针。
每个"正在使用"的目录项对象都被插入一个双向链表中,该链表由相应索引节点对象的i_dentry字段所指向(由于每个索引节点可能与若干硬链接关联,所以需要一个链表)。目录项对象的d_alias字段存放链表中相邻元素的地址。这两个字段的类型都是struct list_head。
当指向相应文件的最后一个硬链接被删除后,一个"正在使用"的目录项对象可能变成"负"状态。在这种情况下,该目录项对象被移到"未使用"目录项对象组成的LRU链表中。每当内核缩减目录项高速缓存时,"负"状态目录项对象就朝着LRU链表的尾部移动,这样一来,这些对象就逐渐被释放。
散列表是由dentry_hashtable数组实现的。数组中的每个元素是一个指向链表的指针,这种链表就是把具有相同散列表值的目录项进行散列而形成的。该数组的长度取决于系统已安装RAM的数量;缺省值是每兆字节RAM包含256个元素。目录项对象的d_hash字段包含指向具有相同散列值的链表中的相邻元素。散列函数产生的值是由目录的目录项对象及文件名计算出来的。
dcache_lock自旋锁保护目录项高速缓存数据结构免受多处理器系统上的同时访问。d_lookup()函数在散列表中查找给定的父目录项对象和文件名;为了避免发生竞争,使用顺序锁(seqlock)。__d_lookup()函数与之类似,不过它假定不会发生竞争,因此不使用顺序锁。 假设具体的文件系统是ext2,那么,执行mount -t ext2 /dev/sda2 /mnt/test命令后,将会调用do_mount()函数。该函数执行文件系统安装的实务操作,具体的代码分析请查看"文件系统安装"博文,这里我们只做一个简单的介绍一下:do_mount()函数最后会走到vfs_kern_mount函数,该函数调用依赖具体文件系统的get_sb方法:
static struct file_system_type ext2_fs_type = {
.owner = THIS_MODULE,
.name = "ext2",
.get_sb = ext2_get_sb,
.kill_sb = kill_block_super,
.fs_flags = FS_REQUIRES_DEV | FS_HAS_FIEMAP,
};
以上是ext2具体文件系统的文件系统类型描述符,至于文件类型的定义,请查看博文"文件系统注册"。我们看到,ext2文件系统get_sb的具体方法是ext2_get_sb,这个函数其实只有一行代码:
return get_sb_bdev(fs_type, flags, dev_name, data, ext2_fill_super, mnt);
get_sb_bdev函数打开传进来的设备文件名,也就是前面mount命令中的/dev/sda2;获得该已注册文件系统的空闲超级快对象;然后调用当做参数传递进来的ext2_fill_super函数将Ext2磁盘上的超级块的一些信息读入内存中。具体的实现细节我们会在博客"Ext2的超级块对象"中讨论,这里只把其中关键的一步提出来:
sb->s_op = &ext2_sops;
sb->s_export_op = &ext2_export_ops;
sb->s_xattr = ext2_xattr_handlers;
root = iget(sb, EXT2_ROOT_INO);
sb->s_root = d_alloc_root(root);
于是,当ext2_fill_super函数返回后,/dev/sda2对应的那个超级快的s_op字段就被赋予了以下数据结构:
static struct super_operations ext2_sops = {
.alloc_inode = ext2_alloc_inode,
.destroy_inode = ext2_destroy_inode,
.read_inode = ext2_read_inode,
.write_inode = ext2_write_inode,
.put_inode = ext2_put_inode,
.delete_inode = ext2_delete_inode,
.put_super = ext2_put_super,
.write_super = ext2_write_super,
.statfs = ext2_statfs,
.remount_fs = ext2_remount,
.clear_inode = ext2_clear_inode,
.show_options = ext2_show_options,
#ifdef CONFIG_QUOTA
.quota_read = ext2_quota_read,
.quota_write = ext2_quota_write,
#endif
};
当然,最后get_sb_bdev返回后,那个超级快就以s_instances为头加入了ext2文件系统对应的那个file_system_type的fs_supers字段链表中。
为了帮助大家理解索引节点高速缓存如何帮助一个具体的文件系统工作的,我们再来研究一下在打开Ext2文件系统的一个常规文件时,相应索引节点的作用。还记得我们在最早的那篇博文"Linux内核入门(一)--体系架构"中的那个例子吗:
fd = open("file", O_RDONLY);
close(fd);
open()系统调用是由fs/open.c中的sys_open函数实现的,而真正的工作是由fs/open.c中的do_filp_open()函数完成的,do_filp_open()函数的具体实现只要依赖一种叫做nameidata的数据结构。
这个数据结构是临时性的,其中,我们主要关注它的dentry和mnt域。dentry结构,目录项对象,我们已经在前面介绍过;而vfsmount结构记录着所属文件系统的安装信息,例如文件系统的安装点、文件系统的根节点等,后面博文我们会详细探讨。
do_filp_open()的相关代码我们将会在"VFS系统调用的实现"博文中详细分析,这里只讨论它主要调用的两个函数:
(1) open_namei():填充目标文件所在目录的dentry结构和所在文件系统的vfsmount结构,并将信息保存在nameidata结构中。在dentry结构中dentry->d_inode就指向目标文件的索引节点。这个函数比较复杂和庞大,在后面的博客会详细介绍。
(2) dentry_open():建立目标文件的一个"上下文",即file数据结构,并让它与当前进程的task_strrct结构挂上钩。同时,在这个函数中,调用了具体文件系统的打开函数,即f_op->open()。该函数返回指向新建立的file结构的指针,为了突出重点,这里也暂不详细分析这个函数,后面的博文会讨论到的。
我们在前面看到ext2_fill_super函数中,当初始化超级快后,有一步是调用iget将索引节点号为EXT2_ROOT_INO(一般为2)的索引节点分配给该ext2磁盘分区的根目录项。
static inline struct inode *iget(struct super_block *sb, unsigned long ino)
{
struct inode *inode = iget_locked(sb, ino);
if (inode && (inode->i_state & I_NEW)) {
sb->s_op->read_inode(inode);
unlock_new_inode(inode);
}
return inode;
}
ext2_read_inode就是ext2超级块的s_op->read_inode具体实现函数,该函数会调用ext2_get_inode函数,从一个页高速缓存中读入一个磁盘索引节点结构ext2_inode,然后初始化VFS的inode。其中,最重要的初始化代码我们提取一段,如下:
if (S_ISREG(inode->i_mode)) { /* 普通文件操作 */
inode->i_op = &ext2_file_inode_operations;
if (ext2_use_xip(inode->i_sb)) {
inode->i_mapping->a_ops = &ext2_aops_xip;
inode->i_fop = &ext2_xip_file_operations;
} else if (test_opt(inode->i_sb, NOBH)) { /* 不启动页高速缓存 */
inode->i_mapping->a_ops = &ext2_nobh_aops;
inode->i_fop = &ext2_file_operations;
} else {
inode->i_mapping->a_ops = &ext2_aops;
inode->i_fop = &ext2_file_operations;
}
} else if (S_ISDIR(inode->i_mode)) {/* 目录文件操作 */
inode->i_op = &ext2_dir_inode_operations;
inode->i_fop = &ext2_dir_operations;
if (test_opt(inode->i_sb, NOBH))
inode->i_mapping->a_ops = &ext2_nobh_aops;
else
inode->i_mapping->a_ops = &ext2_aops;
} else if (S_ISLNK(inode->i_mode)) {/* 符号链接文件操作 */
if (ext2_inode_is_fast_symlink(inode))
inode->i_op = &ext2_fast_symlink_inode_operations;
else {
inode->i_op = &ext2_symlink_inode_operations;
if (test_opt(inode->i_sb, NOBH))
inode->i_mapping->a_ops = &ext2_nobh_aops;
else
inode->i_mapping->a_ops = &ext2_aops;
}
} else {/* 其他特殊文件文件操作 */
inode->i_op = &ext2_special_inode_operations;
if (raw_inode->i_block[0])
init_special_inode(inode, inode->i_mode,
old_decode_dev(le32_to_cpu(raw_inode->i_block[0]))) ;
else
init_special_inode(inode, inode->i_mode,
new_decode_dev(le32_to_cpu(raw_inode->i_block[1]))) ;
}
当然,我们只关注最一般的情况咯,即普通文件在启用高速缓存时,并且不使用xip时的相关操作:
(1)普通文件索引节点操作:
struct inode_operations ext2_file_inode_operations = {
.truncate = ext2_truncate,
#ifdef CONFIG_EXT2_FS_XATTR
.setxattr = generic_setxattr,
.getxattr = generic_getxattr,
.listxattr = ext2_listxattr,
.removexattr = generic_removexattr,
#endif
.setattr = ext2_setattr,
.permission = ext2_permission,
.fiemap = ext2_fiemap,
};
(2)普通文件操作
const struct file_operations ext2_file_operations = {
.llseek = generic_file_llseek,
.read = generic_file_read,
.write = generic_file_write,
.aio_read = generic_file_aio_read,
.aio_write = generic_file_aio_write,
.ioctl = ext2_ioctl,
.mmap = generic_file_mmap,
.open = generic_file_open,
.release = ext2_release_file,
.fsync = ext2_sync_file,
.readv = generic_file_readv,
.writev = generic_file_writev,
.sendfile = generic_file_sendfile,
.splice_read = generic_file_splice_read,
.splice_write = generic_file_splice_write,
};
(3)普通文件页高速缓存操作:
const struct address_space_operations ext2_aops = {
.readpage = ext2_readpage,
.readpages = ext2_readpages,
.writepage = ext2_writepage,
.sync_page = block_sync_page,
.prepare_write = ext2_prepare_write,
.commit_write = generic_commit_write,
.bmap = ext2_bmap,
.direct_IO = ext2_direct_IO,
.writepages = ext2_writepages,
.migratepage = buffer_migrate_page,
};
同样,在open_namei()函数中,通过path_lookup()跟对应的目录项高速缓存打交道获得父目录项,而path_lookup()又调用父索引节点的inode_operations->lookup()方法,也就是我们的ext2_lookup;该方法从磁盘找到并读入当前节点的目录项,然后通过iget(sb, ino),根据索引节点号从磁盘读入相应索引节点并在内存建立起相应的inode结构,这就到了我们讨论过的索引节点高速缓存。path_lookup是VFS系统最重要的函数之一,我们会在"路径名查找"博文详细讨论。
如果在访问模式标志中设置了O_CREAT,则以LOOKUP_PARENT、LOOKUP_OPEN和LOOKUP_CREATE标志的设置开始查找操作。一旦path_lookup()函数成功返回,则检查请求的文件是否已存在。如果不存在,则调用父索引节点的create方法,也就是ext2_create分配一个新的磁盘索引节点。
当索引节点读入内存后,通过调用d_add(dentry, inode),就将dentry结构和inode结构之间的链接关系建立起来。两个数据结构之间的联系是双向的。一方面,dentry结构中的指针d_inode指向inode结构,这是一对一的关系,因为一个目录项只对应着一个文件。反之则不然,同一个文件可以有多个不同的文件名或路径(通过系统调用link()建立,注意与符号连接的区别,那是由symlink()系统调用建立的),所以从inode结构到dentry结构的方向是一对多的关系。因此, inode结构的i_dentry是链表,dentry结构通过其队列头部
d_alias挂入相应inode结构的i_dentry队列中。
当dentry_open返回后,open系统调用也就到了尾声了,这时候,几乎所有的VFS对象都串联成了一个"小团队"。
最后,我们用一个大图描述VFS对象串联起来后的场景,并结束本文: