Linux中的VFS实现 [二]
Linux中的VFS实现[一]
基础操作
内核不能感知一个文件的内容和结构,它只是把文件简单地看做字节的集合,并提供了对其内容的字节流访问。对一个文件的常规操作包括创建、删除和读写等。
【打开文件】
一个进程想要读取或是写入一个文件,必须先建立和文件inode之间的通道,方式是通过open()函数。
int open (const char *pathname, int flags, mode_t mode);
这里传入的参数是文件所在的路径明(即"pathname"),那如何根据这个"pathname"找到对应的inode呢?这就要依靠内核提供的dentry (directory entry)了。
dentry
dentry用于建立路径名和inode之间的关联,和super_block以及inode不同,dentry是一个内存结构,并没有对应的磁盘数据。
struct dentry {
struct dentry d_parent; / parent directory /
struct list_head d_child; / child of parent list /
struct list_head d_subdirs; / our children */
…
}
dentry并不等同于directory,但确实和directory存在着相当的关系。虽然directory被视作文件,但directory本身需要一些特殊操作,比如路径名的查找,而dentry概念的提出就是为了使查找的过程更加便利。
比如在上图所示的这样一个结构中,inode一共有4个,包括2个目录"/", “foo"和2个普通文件"bar”, “bar2”。而dentry有3个,一个将"bar"链接到"foo",一个将"bar2"链接到"foo",还有一个将"foo"链接到根目录节点。
查找的起点通常是根目录("/")或者当前目录,一级一级往下。对".“表示的同级目录就跳过解析,对”…“表示的上级目录就使用"dentry->d_parent”。有访问"parent"的权限,才能进入其对应的目录,然后校验了"child"的访问权限后,将其转变为下轮查找的"parent"。如果遇到了symbol link,就跳到其指向的目录继续查找。
通常会觉得一个文件只有一个路径是吧?别忘了上文介绍的hard link,对一个inode每增加一个hard link,该inode的路径指向就增加一个。
struct inode d_inode; / Where the name belongs to /
struct hlist_node d_alias; / inode alias list */
因此,一个inode会对应多个dentry(通过"i_dentry"链表组织),而一个dentry只会对应一个inode(即"d_inode")。
一层一层的解析路径是非常耗时的,如果每次打开文件都重新解析一次路径,实在是效率堪忧。所以啊,已经解析过的路径会存在dentry cache(简称dcache)里,关于dcache的详细介绍将放在这篇文章中。
vfsmount 和 path
对于一个文件系统来说,其挂载点也是一个路径,因此将表示路径的"dentry"和表示文件系统实体的"super_block"结合起来,就成了描述“挂载点”的数据结构"vfsmount"。
struct vfsmount {
struct dentry mnt_root; / root of the mounted tree */
struct super_block mnt_sb; / pointer to superblock */
…
}
而对于一个非文件系统挂载点的普通路径来说,其必然处于一个文件系统的子路径中,因而诞生了一个名为"path"的数据结构,它既拥有路径"dentry"本身的信息,还包含了其所在文件系统的挂载点"vfsmount"的信息。
struct path {
struct vfsmount *mnt;
struct dentry *dentry;
}
比如在"/mnt/opt"目录下挂载一个新的文件系统,而后在该目录下创建一个名为"foo"的文件夹,进入这个文件夹并创建一个名为"bar"的文件,那么对于这个新建文件的路径来说,其"dentry->d_name"是"/mnt/opt/foo/bar",而"vfsmount"中的"dentry->d_name"则是"/mnt/opt"。
file
打开文件成功后,就会生成一个代表已打开文件的struct file对象。
struct file {
struct path f_path;
struct inode f_inode; / cached value */
…
}
"file"结构体借助"f_path->dentry"和对应的inode关联起来,由于文件可以被多次打开,因此通过同一路径打开的"file"会关联到同一dentry上。
fd table
此外,内核还会向调用open()的进程返回一个per-process的文件描述符(file descriptor,简称fd),代表该进程与文件的一个独立会话,由struct file对象保存着该会话的内容,包括打开文件的方式(即"f_flags"和"f_mode",对应open函数中的第二和第三个参数)和下一次读写时的偏移位置(即"f_pos")等。
unsigned int f_flags;
fmode_t f_mode;
loff_t f_pos;
fd是一个从0开始的非负整数,同一进程获取的所有fd构成了这个进程的文件描述符表的数组索引,而数组元素就是指向struct file对象的指针。
根据POSIX标准,当获取一个新的fd时,要返回数值最小的可用描述符。内核会使用bitmap来记录每个进程的fd的分配情况,并据此找到这个最小的可用描述符。
task_struct->files->fdt->fd[fd];
在文件已经打开后,其名称就没什么用处了,它现在由其fd唯一标识,之后的系统调用都将以这个fd为参数。这样,在一次open()执行完路径名的解析后,接下来的每次文件访问都不再需要重复这一过程,使用fd即可快速定位到已打开文件的对象instance。
在其他一些类Unix系统中,还存在一个全局的Open File Table(简称OFT),每打开一个文件,OFT中将增加一个entry,指向全局的inode table中对应的inode。
文件描述符和OFT中的entry通常是一一对应的,但是在两种情况下,OFT中的一个entry会被多个文件描述符共享。
一是使用fork()创建子进程后,子进程将共享父进程在OFT中的所有entries。
二是使用dup(),它允许为进程创建一个新的描述符,指向已经打开且已经有了描述符的文件,其结果就是这两个描述符指向了OFT中的同一个entry。
int fd = open(“README”, O_RDONLY);
int fd2 = dup(fd);
dup()主要用在输出重定向中,共享同一entry的文件描述符(“fd"和"fd2”)可互换使用。
【读写文件】
通过dentry可以找到inode,而inode记录了文件属性和文件数据的关联(参考这篇文章),因而最终可以找到文件的user data,实现对文件数据的读写。
文件打开后,起始的访问位置是文件的开头。因为文件默认是按顺序访问的,所以偏移位置会随着文件的读写被自动更新,具体的offset数值记录在struct file对象的"f_pos"域中。
调用read()读取一个文件的内容后,除了文件的offset,inode的atime也会被更新。
当读到一个文件的末尾时,将返回0,但返回0不一定是读到末尾了(参考这个回答)。最后,vfs_read()实际调用的是文件所在的文件系统事先注册的read函数(称为“回调”)。
ssize_t __vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
if (file->f_op->read)
return file->f_op->read(file, buf, count, pos);
…
}
Linux内核虽然主要是用C语言编写的,但是其实现充分借鉴了面向对象的思想,使得结构和逻辑看起来更加简洁,VFS的设计就是一个很好的例证。每个结构体都包含若干的function pointers,对于一个具体的文件系统,这些function pointer指向文件系统具体的实现。
除了默认的顺序访问,还可使用lseek()强制改变offset的位置,以实现文件的随机访问。需要注意的是,文件offset只是一个软件的概念,因而lseek并不会引起任何真正的I/O操作。
如果同一进程(或者不同进程)打开同一个文件两次,会生成两个不同的文件描述符,它们有各自独立的offset,互不影响。
write()用于写入,它会更新一个inode的mtime,还可能涉及到磁盘上block的分配。
ssize_t __vfs_write(struct file *file, const char __user *p, size_t count, loff_t *pos)
下一次的读写默认从当前的offset位置开始,如果需要显式地传入偏移量,可使用pread()和pwrite(),这两个函数相当于lseek()加上read()/write()的组合。如果需要多个buffer的内容被依次传送,应使用scatter-gather模式的readv()和writev()。
为了加快读写文件的的速度,磁盘上文件的内容会在内存中形成一份copy,且最近访问过的部分会驻留在内存中,也就是page cache。
struct inode中与page cache相关的部分包括:管理了一个文件在RAM中缓存的所有pages的"address_space",用于page writeback的dirty时间记录的"dirtied_when"和后备存储器管理的"i_wb_list"等。
struct inode {
struct address_space *i_mapping;
unsigned long dirtied_when; /* jiffies of first dirtying */
struct list_head i_wb_list; /* backing dev writeback list */
...
}
page cache可以提高对文件内容的访问速度,但由于其驻留在内存,因此存在一点掉电时数据丢失的风险,所以writeback机制除了用于释放并回收内存,也可视作是一种durability和performace之间的trade-off。
【关闭文件】
调用close()关闭文件,将释放文件描述符,同时将inode的引用计数减1。如上文所讨论的,当引用计数为0时,意味着一个文件的所有hard link都被移除,并且没有一个进程正在使用该文件,则文件内容和inode才会被真正删除。那如果进程在退出时忘记了关闭会怎样呢?
对于普通进程,这没有关系,因为进程退出时Linux内核会自动关闭文件,释放内存。但对于一个常驻进程来说,如果文件描述符始终不释放,其个数迟早会达到上限。用于文件管理的内存结构没有被释放,也将造成资源的泄露。一个解决办法是通过"lsof"命令查看当前系统中存在哪些未正确关闭的文件。
参考:
https://www.kernel.org/doc/Documentation/filesystems/vfs.txt
Linux内核文件描述符表的演变
http://www.fieldses.org/~bfields/kernel/vfs.txt
原创文章,转载请注明出处。