Ext2文件系统彻底分析 | 写数据流程

为了便于理解Ext2文件系统写数据的流程,本文先从整个Linux文件系统的角度分析一下写数据的流程,因此本文包含了部分VFS内容介绍。本文主要包含2部分内容,一部分是从总体上介绍一个写流程是如何从用户态接口到Ext2文件系统的,另一部分是Ext2文件的数据在磁盘上组织及函数流程。

写文件的操作通常由用户态的程序发起,比如在开发的过程中调用系统API(write)。如图是从用户态发起一直到调用Ext2函数的整个调用流程。从用户态到内核态是触发了一个中断,这里我们不关心其具体实现,其作用是触发对内核函数的调用。最开始的内核函数是VFS的函数,VFS是一个中间抽象层,其目的是为用户提供统一的接口,屏蔽不同文件系统间的差异。VFS会调用具体文件系统的函数,对于本文基于的内核版本调用的仍然是VFS的通用函数generic_file_write_iter。后续新版本(例如最新的4.20则是调用ext2_file_write_iter函数,前面很早之前已经改成这个函数了)。能够调用该文件系统的接口是因为inode在初始化的时候填充了该文件系统的一个文件操作的结构体实现,关于具体细节本文不做解释,后续再另起文详述。

图1 整体调用流程

Ext2文件系统文件操作结构体实现如代码所示,这里包含了Ext2文件系统实现的可以对文件进行的操作。包括对文件内容的读写、查找和缓存同步等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const struct file_operations ext2_file_operations = { 
.llseek = generic_file_llseek,
.read_iter = generic_file_read_iter,
.write_iter = generic_file_write_iter,
.unlocked_ioctl = ext2_ioctl,
#ifdef CONFIG_COMPAT
.compat_ioctl = ext2_compat_ioctl,
#endif
.mmap = ext2_file_mmap,
.open = dquot_file_open,
.release = ext2_release_file,
.fsync = ext2_fsync,
.splice_read = generic_file_splice_read,
.splice_write = iter_file_splice_write,
};

在新版本的内核中generic_file_write_iter函数被ext2_file_write_iter替代,但其实只是后者对前者进行了简单的封装,并没有做太多事情。

图1中展示了VFS调用Ext2文件系统的流程,可能不够清晰。下面是VFS文件系统写操作的代码实现,由该代码可以看出在该函数中将调用具体文件系统的写数据的接口。可能涉及2种情况,视文件系统的具体实现而定。

1
2
3
4
5
6
7
8
9
10
ssize_t __vfs_write(struct file *file, const char __user *p, size_t count,
loff_t *pos)
{
if (file->f_op->write)
return file->f_op->write(file, p, count, pos);
else if (file->f_op->write_iter)
return new_sync_write(file, p, count, pos);
else
return -EINVAL;
}

通过上面描述,我们基本知道了用户态的接口调用是如何触发到Ext2文件系统的函数了,也就是如何调到ext2文件系统write_iter函数指针指向的函数,后面我们详细介绍一下文件系统的实现。

公共实现

通过上面分析我们了解到,Ext2写数据由反过来调用VFS的通用写数据的接口generic_file_write_iter。为了保证写数据的安全性,避免进程之间的竟态导致数据的不一致,在写数据的时候会有一些锁。为了抓住主要逻辑,本文暂时不介绍参数合法性检查和锁等其它逻辑内容,这些内容后续专门介绍。

本文接着分析generic_file_write_iter函数。如图2所示,其中绿色的是写流程的主要函数,该函数实现磁盘空间分配和实际的写数据的操作,主要业务逻辑也在该函数中。而函数generic_write_sync则是在文件具备同步刷写属性的情况下,实现缓存写数据的同步刷写,保证数据从缓存刷写到磁盘后在返回。因此,本文重点介绍__generic_file_write_iter函数,这个函数是整个文件写数据的核心。

图2 ext2写流程

该函数并不是ext2文件系统的函数,而是一个公共函数(mm/filemap.c中实现)。在该函数中,针对用户打开文件时设置的属性有两种不同的执行分支,如果设置了O_DIRECT属性,则调用generic_file_direct_write函数进行直写的流程;如果没有设置该属性,则调用函数generic_perform_write执行缓存写的流程。

直写流程

所有文件系统的直写都有一个公共的入口generic_file_direct_write,这个函数在框架中实现(mm/filemap.c)。该函数流程相对比较简单,主要调用了4个函数,具体如图所示。前面两个函数是对目的区域的缓存进行刷写,并使缓存页失效。进行这一步的主要原因是缓存中可能有脏数据,如果不进行处理可能会导致缓存的数据覆盖直写的数据,从而导致数据不一致。第3个函数direct_IO是文件系统的实现,直写真正的写数据操作。最后一个函数在上面已经执行过,主要是避免预读等操作导致缓存数据与磁盘数据的不一致。

图3 直写流程

缓存写流程

与直写类似,缓存写也有一个公共函数,其名称为generic_perform_write。缓存写整体的主要流程也有4个主要步骤,分配磁盘空间和缓存页,将数据从用户态拷贝到内核态内存、收尾和页缓存均衡

图4 缓存写调用流程

其中分配磁盘空间和缓存页以及收尾工作实在具体文件系统中做的。页缓存均衡的作用是检查目前页缓存的容量,保证页缓存的总容量不超过设置的水线大小,避免占用系统内存太多。上图中是函数指针方式的调用,具体到文件系统有各自的实现,对于ext2来说实现如下代码所示(在aops.c中)。

1
2
3
4
5
6
7
8
9
10
11
12
13
const struct address_space_operations ext2_aops = {
.readpage = ext2_readpage,
.readpages = ext2_readpages,
.writepage = ext2_writepage,
.write_begin = ext2_write_begin,
.write_end = ext2_write_end,
.bmap = ext2_bmap,
.direct_IO = ext2_direct_IO,
.writepages = ext2_writepages,
.migratepage = buffer_migrate_page,
.is_partially_uptodate = block_is_partially_uptodate,
.error_remove_page = generic_error_remove_page,
};

上面都是公共框架的工作,对于所有文件系统都是一样的,整体逻辑也比较简单。了解了公共框架的部分的工作后,我们后续介绍ext2文件系统所做的工作。对于直写部分的逻辑本文暂时不做介绍,主要集中在缓存写逻辑的相关内容。

写数据流程

在介绍Ext2写数据的流程之前,我们先介绍一下文件中的数据在磁盘上是如何进行布局的。了解布局之后,对于我们了解文件数据的读写流程非常有益。

文件内数据的存放位置由inode结构体(ext2_inode)的i_blcok成员变量指定。该变量是一个32整型数组,共有15个成员,其中前12成员的值直接指向存储文件数据的磁盘的逻辑地址。后面3个成员指向的磁盘逻辑块中存储的数据并不是文件的数据,而是指针数据。

对于小文件来说,通过直接引用就可以完成数据的存储和查找。比如格式化的时候文件逻辑块大小是4K,对于48K(4K×12)以内的文件都可以通过直接引用完成。但是,如果文件大于48K,则直接引用无法容纳所有的数据,则48K以外的数据需要通过一级间接引用进行存储。以此类推,当超过本级存储空间的最大值时,则启用后面的存储方式。

间接引用通过后面3个成员完成。对于第12个成员来说,它是一级间接引用模式,也就是该成员指向的磁盘逻辑块中存储的是指向文件数据的指针,而指针指向的是存储文件数据的磁盘逻辑块的地址。而有对于第13个成员来说,它是二级间接引用模式,也就是该成员指向的磁盘逻辑块中存储的是指针,而指针指向的仍然是存储指针的磁盘逻辑块,再之后该指针指向的才是文件数据的磁盘逻辑块的地址。以此类推,三级间接也用就是成员与存储文件数据的逻辑块之间有3层指针。如图5是Ext2文件数据在3级间接引用情况下的数据布局示意图。

图5 文件数据索引示意图

在前面整体流程中我们知道VFS调用Ext2文件系统的write_begin和write_end函数指针。其中write_begin的具体函数是ext2_write_begin。如图6所示,该函数调用block_write_begin函数,并传入一个ext2_get_block参数,该参数是Ext2文件系统分配磁盘空间的具体实现。

图6 Ext2写数据函数调用图

函数ext2_write_begin是文件系统写数据的重点实现,总结一下就是这里分配页缓存、分配磁盘空间和建立页缓存与磁盘块的关系。这样,在后面就可以实现数据的刷写,将数据从缓存刷写到磁盘。如下是block_write_begin函数的代码实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int block_write_begin(struct address_space *mapping, loff_t pos, unsigned len,
unsigned flags, struct page **pagep, get_block_t *get_block)
{
pgoff_t index = pos >> PAGE_CACHE_SHIFT;
struct page *page;
int status;
/* 分配存储数据的缓存页 */
page = grab_cache_page_write_begin(mapping, index, flags);
if (!page)
return -ENOMEM;
/* 通过get_block获取磁盘空间,并建立与缓存页的映射关系 */
status = __block_write_begin(page, pos, len, get_block);
if (unlikely(status)) {
unlock_page(page);
page_cache_release(page);
page = NULL;
}

*pagep = page;
return status;
}

也就是对于Ext2文件系统来说,分配磁盘空间的关键函数是ext2_get_block,该函数会根据请求的大小和位置等信息计算出应该使用几级间接引用块,然后分配必须的空间。同时该函数还会填充inode的i_block数组的成员。该函数实现相对复杂,后面我们单独写一篇关于Ext2磁盘空间分配的文章。

至此,我们分析了Ext2文件系统写数据的流程,总结起来也比较简单,概括起来就是分配磁盘空间和缓存页,将数据从用户态拷贝到内核态内存、收尾和页缓存均衡。由于分配磁盘空间是比较复杂的,因此后续再另行起文分析。