前言
最近给自己定了个小目标,把 erofs 整明白。在此之前,需要将 linux 内核关于文件系统的内容进行梳理。本篇博客主要记录一下自己对文件系统的理解,并对比 linux 0.12 与 2.6 版本之间关于文件系统的实现上有何不同
简述文件管理流程
尽管 2.6 版本的实现相较于 0.12 复杂很多,但不同实现的本质还是不变的。以块设备为例,主要围绕如何管理文件并存储与块设备中。
总述
每个进程的 task_struct 维护一个文件描述符表,文件描述符表的每个元素为一个文件结构体 file。而文件描述符表的索引即为常说的文件描述符 fd。因此,打开一个文件,需要为其在当前进程的文件描述符表中申请到一个位置,并将对应的 fd 返回,这样以后要使用时,只需要借助 fd 便可找打开的文件。
file 结构体会与某个文件关联,结构体属性记录了当前进程对该文件的操作,如操作位置 pos 等。并且指向该文件的唯一标识 inode。意味着,每个进程可能会使用相同地文件描述符 fd ,在各自的文件描述符表上索引出一个 file 结构体,不同的 file 结构体可能指向同一个 inode,但由于 file 记录的 pos 不同,每个进程对该文件的操作状态不同。
inode 只是一个文件的标识,对于块设备而言,inode 上需要记录文件在哪个设备 dev 上的哪个物理块号中。如此一来,通过 inode 便能找到文件对应存储路径,并对其进行读写。此时说的 inode 都是位于内存的,如果关机了,inode就没了,文件也没了。因此,需要将 inode 持久化到块设备中,下次重新安装块设备后,读取块设备上的 inode ,便能知道块设备中存放的文件内容。
由于有持久化到块设备的需求,因此设计一个超级块 super_block 对这些持久化的元数据进行汇总,便于安装块设备时,内核能够快速感知块设备中的内容,从而完成初始化。
根据用户需求,用户在打开文件时只会输入一个路径名 pathname,如 /usr/etc/hello.c。如何快速解析 pathname 并获取到 hello.c 对应的 inode 也是文件系统需要解决的问题。对此,设计了目录文件,每个目录文件中记录了多个目录项 dir_entry,目录项存储了{文件名,i节点号}。因此,寻找的过程变为,先根据根目录 / 的 inode 获取到其存储的内容,即目录项,从目录项中找寻文件名为 usr 的目录项并获取其对应 i节点号,通过 i节点号能够获取 usr 的 inode,重复这一过程就能找到 hello.c 的 inode。
文件系统被格式化时,只有根目录 / 。因此每当创建新的文件时,需要文件系统维护其对应的 inode 以及能够索引到它的目录项 dir_entry。由于目录本身也是个文件,也会被持久化到块设备中。因此,寻找 pathname 对应目标的 inode 时,既可以从内存中的 dir_entry 进行匹配,也可以读取块设备中持久化的 dir_entry 进行匹配。在内存充足的情况下,肯定是采用第一种方式,而内存不足的情况下(如 0.12 版本)采取了第二种方式
对于块设备而言,文件系统的目的主要还是能够让用户将其想持久化的内容写入块设备如磁盘中。根据上述提及内容,进程可以通过文件描述符表找到对应文件的 inode,从而获知其在哪个设备的哪个块中。基于此,已经可以对数据进行持久化。但是问题在于,这种持久化的方式涉及内存与磁盘的 IO,速度是十分慢的,影响用户体验。为此,内核采用了先写后刷,即所谓异步刷盘的方式进行块设备的持久化。
先写,写哪里?写在高速缓冲区 page cache 中,并由 buffer head 进行管理。后刷,怎么刷?将刷盘封装成一个请求,放入刷盘工作队列中。由刷盘线程将其进行回写。这里需要说明的是,刷盘策略与文件系统无关,因为刷盘只需要知道往哪个块设备的哪个块号进行刷盘即可,这只与块设备类型有关,与文件系统类型无关。那么也就意味着对文件系统的梳理到page cache 就可以结束了。
综上可知,由于 linux 使用的都是 /usr/etc 这种带斜杠的目录表示方式,因此每类文件系统实现目录项 dir_entry 匹配都是相同地,以及根据超级块读取持久化汇总信息等操作。所以就有了 VFS,虚拟文件系统,主要对于 super_block , inode , dir_entry 进行管理。文件系统可以注重于对于 open , write,read 等 file_operation 进行不同的实现
补充:
在 2.6 版本中, inode 通过 address_space 管理文件对应的 page cache。在 address_space 中,通过 struct radix_tree_root page_tree 为与之关联的 page cache 构建了一棵 radix 树便于管理。并且,当读文件过程中,发现 page cache 没有命中,则通过 address_space_operations 调用 readpage 从对应块设备中将内容读入 page cache 中
打开文件
根据上述设计,若要打开一个文件,需要执行以下步骤
- 获取进程 task_struct 中的文件描述符表
- 若有空位,则为其申请一个 file 并放入,对应索引为返回值文件描述符 fd
- 根据传入的 pathname ,找出其对应的 inode ,并与 file 关联
写入文件
根据上述设计,若要往一个文件内写数据,需要执行以下步骤
- 获取进程 task_struct 中的文件描述符表
- 根据传入的 fd 文件描述符,从表上索引出目标文件 file
- 根据 file 获得 inode
- 为了能够往磁盘写数据,需要为 inode 申请 page cache ,并建立 page cache 与块设备某些块的映射
- 根据 inode 对应的文件系统类型,调用其 file_operation 的 write 执行写操作,将用户缓冲区数据拷贝至 page cache
读入文件
根据上述设计,若从一个文件中读数据,需要执行以下步骤
- 获取进程 task_struct 中的文件描述符表
- 根据传入的 fd 文件描述符,从表上索引出目标文件 file
- 根据 file 获得 inode ,找到与其关联的 page cache ,若命中则可直接读取。否则要去块设备对应位置进行读取
代码实现
对于 linux 0.12,在《Linux内核学习系列(6)——文件系统》中,笔者进行了梳理。在此不详细展开。后续梳理都是对 linux 2.6 展开
打开文件 open
系统调用 open --> sys_open
--- do_sys_open
--- do_filp_open
--- open_namei
--- vfs_create
--- dir->i_op->create
--- nameidata_to_filp
open_namei :
在 linux0.12 版本中有同名函数,做的事情也类似,为 filename 目标在指定的位置创建目标项以及申请 inode。由于 2.6 有 VFS,因此创建 inode 变成一个接口,由 vfs_create 调用不同文件系统的 inode_operation 中的 create 函数。
这里还需要注意的是,需要先根据 filename 匹配到与目标最近的目录文件,获得其 inode,为 dir。因为只有该目录 inode 才具备创建其目录内文件的方法。以 ext2 为例,dir->i_op->create 实际上调用的是 ext2_dir_inode_operations 中为 create 定义的 ext2_create。
ext2_create 用于创建一个普通文件
inode ,并为其设置对应的属性。如下所示,i_op 设置为ext2_file_inode_operations,注意并不是 ext2_dir_inode_operations。在 ext2_file_inode_operations 中是没有实现 create 接口的,因为普通文件无法创建普通文件。只有目录文件才需要 create 接口。同时,i_mapping->a_ops 也在此被设置,后续读文件过程会用到。
static int ext2_create (struct inode * dir, struct dentry * dentry, int mode, struct nameidata *nd)
{
struct inode * inode = ext2_new_inode (dir, mode);
int err = PTR_ERR(inode);
if (!IS_ERR(inode)) {
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;
}
mark_inode_dirty(inode);
err = ext2_add_nondir(dentry, inode);
}
return err;
}
既然已经知道了 ext2_create 只是创建普通文件的,那目录文件何时创建呢?当然就是 mkdir 时创建,对应 ext2_mkdir。可以看到在该函数中,为新建的 inode 设置的 i_op 为 ext2_dir_inode_operations。该 operations 的 create 实现就是上述的 ext2_create
static int ext2_mkdir(struct inode * dir, struct dentry * dentry, int mode)
{
struct inode * inode;
int err = -EMLINK;
if (dir->i_nlink >= EXT2_LINK_MAX)
goto out;
inode_inc_link_count(dir);
inode = ext2_new_inode (dir, S_IFDIR | mode);
err = PTR_ERR(inode);
if (IS_ERR(inode))
goto out_dir;
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;
inode_inc_link_count(inode);
err = ext2_make_empty(inode, dir);
if (err)
goto out_fail;
err = ext2_add_link(dentry, inode);
if (err)
goto out_fail;
d_instantiate(dentry, inode);
out:
return err;
out_fail:
inode_dec_link_count(inode);
inode_dec_link_count(inode);
iput(inode);
out_dir:
inode_dec_link_count(dir);
goto out;
}
由此,我们可以理顺 inode 是在什么时候被初始化,并为其设置对应 inode_operations 和 file_operations 的。当然,除了在调用 open 时会涉及 create 的创建,我们知道,inode 也是会被持久化到块设备中的。因此,当块设备的文件系统挂载到内核时,内核会通过超级块获取到块设备中持久化的 inode。并且为其初始化 i_op 和 i_fop。该过程对应了 super_block 结构体中的 super_operation,在 super_operation 中定义了 read_inode 接口,用于内核读取持久化的inode
以 ext2 为例,read_inode 的实现为 ext2_read_inode 。可以看到,其实现会根据 inode 的类型,为其设置 i_op 和 i_fop 等属性
void ext2_read_inode (struct inode * inode)
{
////省略部分代码
} 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])));
}
brelse (bh);
ext2_set_inode_flags(inode);
return;
bad_inode:
make_bad_inode(inode);
return;
}
写文件 write
读文件 read
系统调用 read
--- fget_light : 通过 fd 获取 file 结构体
--- vfs_read : 根据 file ,将文件内容读取至用户空间的 buf 中,从 pos 处开始
--- file->f_op->read : 调用 file 对应的 f_op 的 read 实现。以 ext2_file_operation 为例,其使用 do_sync_read
(do_sync_read)
--- filp->f_op->aio_read(generic_file_aio_read)
---do_generic_file_read 涉及将块设备内容读入 page cache 的操作
对于 do_generic_file_read 的分析很长,笔者直接参考 《Linux内核探秘 深入解析文件系统》 一书的 10.4 节
该函数对于需要读取的文件内容不在 page cache 的情况,会执行 readpage 部分,即调用 mapping->a_ops->readpage,将块设备对应内容读取至 page cache 中。
readpage:
/*
* A previous I/O error may have been due to temporary
* failures, eg. multipath errors.
* PG_error will be set again if readpage fails.
*/
ClearPageError(page);
/* Start the actual read. The read will unlock the page. */
error = mapping->a_ops->readpage(filp, page);
而 mapping 对应的正是 inode 中的 address_map,其类型为 address_space,对应的 a_ops 为 address_space_operations。其与 file_operation 一样,定义了很多关于 page cache 和块设备之间的操作。因此,可以推测 address_space_operations 也需要在初始化 inode 时进行指定。
void ext2_read_inode (struct inode * inode)
{
……
if (S_ISREG(inode->i_mode)) {
inode->i_op = &ext2_file_inode_operations;
inode->i_fop = &ext2_file_operations;
if (test_opt(inode->i_sb, NOBH))
inode->i_mapping->a_ops = &ext2_nobh_aops;
else
inode->i_mapping->a_ops = &ext2_aops;
}
小结
根据上述探究,基本理顺了 VFS 充当的角色,以及各个 operations 被触发的时机。而 operations 是每个文件系统最有差异化的地方,因此,如果要对比文件系统的不同,则可以从 operations 的实现入手。
本篇笔记来来回回翻阅源码好几次才能理清,因此逻辑上可能不太通顺,主要还是作为自己看的一个随笔吧。主要参考了 《Linux 内核探秘》和 《深入Linux 内核架构 》。