一个IO的传奇一生

一个IO的传奇一生

前言
前几天同事提议写一篇文章来仔细分析一下一个IO从创建到消亡的整个过程,我觉得这个想法很好,一个IO从创建到消亡经历了千山万水,从软件到硬件涉及到很多很多的技术。一个看似简单的IO读写操作,其实汇集了从计算机软件技术、硬件技术、电子技术、信号处理等各个方面的内容。所以,我想把IO的一生通过自己的认识把他描述一下,让世人看清在执行一个简单的IO操作究竟汇集了多少的智慧!汇集了工程师、科学家多少的心血!在此,将此系列文章定名为《一个IO的传奇一生》,与大家一起分享。

针对不同的操作系统,IO历程是有所差别的,但是很多基本思想是相同的。在此,我想以Linux操作系统为样本,对整个IO历程进行深入分析,最主要的是设计思想方面的考虑。

1.jpg

上图描述了IO操作中所涉及到的软硬件模块,从这张图中我们可以一窥整个系统还是很庞大的,主要涉及了文件系统、块设备层、SCSI层、PCI层、SAS/Ethernet网络以及磁盘/U盘。本文会根据作者的理解对IO在上述各个层次的游历过程进行详细阐述。

文件基本操作
如果你想保存你的Word资料至本地硬盘,你就会触发一个文件系统写操作。如果你想将一个文件从本地电脑拷贝到U盘时,你会触发一次文件系统的读写过程。大家知道,为了简化用户对文件的管理,操作系统提供了文件系统对数据资料进行了管理,文件系统是操作系统最为重要的组成部分。一旦你想往文件系统写入数据时,一个新的IO请求就会在用户态诞生,但是,其绝大部分的人生旅程都会在内核空间。对于不同的应用类型,IO请求的属性会大相径庭。除了文件本身应该具备的基本属性(读写权限等)之外,我们还需要考虑文件的访问模式:异步IO还是同步IO?对文件系统的Cache是如何控制的?应用程序和内核程序之间是如何交互的?所以,在创建一个IO时,我们需要考虑很多这样的因素。

我们知道,当我们需要进行文件操作的时候,5个API函数是必不可少的。Create,Open,Close,Write和Read函数实现了对文件的所有操作。Create函数用来打开一个文件,如果该文件不存在,那么需要在磁盘上创建该文件。Open函数用于打开一个指定的文件。如果在Open函数中指定O_CREATE标记,那么Open函数同样可以实现Create函数的功能。Close函数用于释放文件句柄。Write和Read函数用于实现文件的读写过程。举个例子,如果用户需要对一个文件进行写操作,那么首先调用Open函数打开想要操作的文件,函数完成之后获取所要操作文件的句柄;然后调用Write函数将数据写入文件;最后采用Close函数释放文件句柄,结束文件写入过程。上述过程大家应该都非常的熟悉,在上述过程中,整个系统到底发生了哪些操作呢?

打开文件
众所周知,用户态的API函数通过系统调用陷入内核。对于Open函数对应了sys_open函数例程。该函数的主要职责是查找指定文件的inode,然后在内核中生成对应的文件对象。在Linux中,Sys_open函数调用do_sys_open完成具体功能。在do_sys_open中通过do_filp_open函数完成文件名解析、inode对象查找,然后创建file对象,最后执行特定文件对应的file->open函数。Do_filp_open过程中的核心处理函数是link_path_walk。该函数完成了基本的文件路径的解析功能,是名字字符串解析处理实现的核心。该函数的实现基于分级解析处理的思想。例如,当需要解析“/dev/mapper/map0”字符串时,其首先需要判断从何处开始解析,根目录还是当前目录?这个例子是从根目录开始解析的,那么首先获取根目录的dentry对象并开始分析后继字符串。处理过程是以‘/’字符为界按序提取字符串。根据规则,首先我们可以提取“dev”字符串,并且计算该字符串的Hash值,通过该Hash值查找dentry下的inode Hash表,就可以很快的找到/dev/目录下的inode对象。Hash值的计算是比较简单的,把所有字符对应的值累加起来就可以得到一个Hash值。根据规则,依此类推,最后解析得到”/dev/mapper/”目录的inode对象以及文件名字符串“map0”。到这一步为止,link_path_walk函数的使命完成,最后可以通过do_last函数获取或者创建文件inode。如果用户态程序设置了O_CREATE标记,那么系统如果找不到用户指定的inode,do_last会创建一个新的文件inode,并且把这些信息以元数据的形式写入磁盘。当指定文件的inode找到之后,另一件很重要的事情就是初始化file文件对象。初始化文件对象通过__dentry_open函数来实现。文件对象通过inode参数进行初始化,并且把inode的操作方法函数集告诉给file对象。一旦file对象初始化成功之后,调用文件对象的open函数执行进一步的初始化工作。

通过上述分析,整个过程看似比较复杂,涉及到dentry,inode以及file对象。其实这个模型还是很简单的。Dentry用来描述文件目录,在磁盘上会采用元数据的方式存储在一个block中,文件目录本身在Linux中也是一个文件。Inode描述一个具体的文件,也通过元数据的方式在磁盘上保存。如果对一个文件系统从根目录开始往下看,整个文件系统是一颗庞大的inode树:

2.jpg

在打开一个文件的过程中,文件系统所要做的事情就是找到指定文件的inode,所以在这个过程中会有磁盘元数据读操作。一旦文件所属的inode被找到,那么需要在内存中初始化一个描述被打开文件的对象,这个对象就是file。所以,dentry,inode之类的信息在磁盘上是永久存储的,file对象是在内存中是临时存在的,它会随着文件的创建而生成,随着文件的关闭而消亡。

在Linux系统中文件类型是多种多样的,一个USB设备也是一个文件,一个普通的Word文档也是一个文件,一个RAID设备也是一个文件。虽然他们在系统中都是文件,但是,他们的操作方式是截然不同的。USB设备可能需要采用字符设备的方式和设备驱动交互;RAID设备可能需要采用块设备的方式和设备驱动进行交互;普通Word文件需要通过cache机制进行性能优化。所以,虽然都是文件,但是,文件表面下的这些设备是不相同的,需要采用的操作方法显然是截然不同的。作为一个通用的文件系统,如何封装不同的底层设备是需要考虑的问题。在Linux中,为了达到这个目的,推出了VFS概念。在VFS层次对用户接口进行了统一封装,并且实现了通用的文件操作功能。例如打开一个文件和关闭一个文件的操作都是相同的。在VFS下面会有针对不同需求的具体文件系统,例如针对Word文档可以采用EXT3文件系统进行操作,对于磁盘设备可以采用bdev块设备文件系统进行操作。在打开一个文件,对文件对象file进行初始化的时候,会将具体的文件系统操作方法关联到file->f_op和file->f_mapping对象。在后面的读写过程中,我们将会看到针对不同的文件类型,会采用不同的f_op和f_mapping方法。

读写文件
当一个文件被打开之后,用户态程序就可以得到一个文件对象,即文件句柄。一旦获取文件句柄之后就可以对其进行读写了。用户态的读写函数write对应内核空间的sys_write例程。通过系统调用可以陷入sys_write。Sys_write函数在VFS层做的工作及其有限,其会调用文件对象中指定的操作函数file->f_op->write。对于不同的文件系统,file->f_op->write指向的操作函数是不同的。对于EXT3文件系统而言,在文件inode初始化的时候会指定ext3_file_operations操作方法集。该方法集说明了EXT3文件系统的读写操作方法,说明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const struct file_operations ext3_file_operations = {
.llseek= generic_file_llseek,
.read= do_sync_read,
.write= do_sync_write,
.aio_read= generic_file_aio_read,
.aio_write= ext3_file_write,
.ioctl= ext3_ioctl,
#ifdef CONFIG_COMPAT
.compat_ioctl= ext3_compat_ioctl,
#endif
.mmap= generic_file_mmap,
.open= generic_file_open,
.release= ext3_release_file,
.fsync= ext3_sync_file,
.splice_read= generic_file_splice_read,
.splice_write= generic_file_splice_write,
};

如果文件设备是一个USB设备,并且采用的是字符设备的接口,那么在初始化文件inode的时候会调用init_special_inode初始化这些特殊的设备文件。对于字符设备会采用默认的def_chr_fops方法集,对于块设备会采用def_blk_fops方法集。不同的文件类型会调用各自的方法集。下面章节会对EXT3文件写和块设备文件写进行详细阐述。由于字符设备类型比较简单,在此进行简单说明。

Def_chr_fops方法集其实就定义了open方法,其它的方法都没有定义。其实字符设备的操作方法都需要字符设备驱动程序自己定义,每个设备驱动程序都需要定义自己的write、read、open和close方法,这些方法保存在字符设备对象中。当用户调用文件系统接口open函数打开指定字符设备文件时,VFS会通过上述讲述的sys_open函数找到设备文件inode中保存的def_chr_fops方法,并且执行该方法中的open函数(chrdev_open),chrdev_open函数完成的一个重要功能就是将文件对象file中采用的方法替换成驱动程序设定的设备操作方法。完成这个偷梁换柱的代码是:

1
filp->f_op = fops_get(p->ops)

一旦这个过程完成,后继用户程序通过文件系统的write方法都将会调用字符设备驱动程序设定的write方法。即对于字符设备文件而言,在VFS的sys_write函数将直接调用字符设备驱动程序的write方法。所以,对于字符设备驱动程序而言,整个过程很简单,用户态程序可以直接通过系统调用执行字符设备驱动程序的代码。而对于块设备和普通文件,这个过程将会复杂的多。

在用户程序发起写请求的时候,通常会考虑如下三个问题:第一个问题是用户态数据如何高效传递给内核?第二个问题是采用同步或者异步的方式执行IO请求。第三个问题是如果执行普通文件操作,需不需要文件Cache?

第一个问题是数据拷贝的问题。对于普通文件,如果采用了page cache机制,那么这种拷贝合并在很大程度上是避免不了的。但是对于网卡之类的设备,我们在读写数据的时候,需要避免这样的数据拷贝,否则数据传输效率将会变的很低。我第一次关注这个问题是在做本科毕业设计的时候,那时候我设计了一块PCI数据采集卡。在PCI采集卡上集成了4KB的FIFO,数据采集电路会将数据不断的压入FIFO,当FIFO半满的时候会对PCI主控芯片产生一个中断信号,通知PCI主控制器将FIFO中的2KB数据DMA至主机内存。CPU接收到这个中断信号之后,分配DMA内存,初始化DMA控制器,并且启动DMA操作,将2KB数据传输至内存。并且当DMA完成操作之后,会对CPU产生一个中断。板卡的设备驱动程序在接收到这个中断请求之后,面临一个重要的问题:如何将内核空间DMA过来的数据传输给用户空间?通常有两种方法:一种是直接将内核内存映射给用户程序;另一种是进行数据拷贝的方式。对于PCI数据采集卡而言,一个很重要的特性是实时数据采集,在板卡硬件FIFO很小的情况下,如果主机端的数据传输、处理耗费太多的时间,那么整条IO流水线将无法运转,导致FIFO溢出,数据采集出现漏点的情况。所以,为了避免这样的情况,在这些很严格应用的场合只能采用内存映射的方法,从而实现数据在操作系统层面的零拷贝。在Linux中,我们可以采用memory map的方法将内核空间内存映射给用户程序,从而实现用户程序对内核内存的直接访问。在Windows操作系统中,这种内核空间和用户空间的数据交互方式定义成两种:Map IO和Direct IO。Map IO就是采用内存拷贝的方式,Direct IO就是采用MDL内存映射的方式。在编写WDM Windows设备驱动程序的时候经常会用到这两种数据传输模式。值得注意的是,Windows中的Direct IO和Linux中的Direct IO是完全不同的两个概念。在Linux中Direct IO是指写穿page cache的一种IO方法。

第二个问题是异步IO和同步IO的问题。对于普通文件而言,为了提高效率,通常会采用page cache对文件数据在内存进行缓存。Cache虽然提高了效率,但是有些应用一旦发出写请求,并且执行完毕之后,其期望是将数据写入磁盘,而不是内存。例如,有些应用会有一些元数据操作,在元数据操作的过程中,通常期望将数据直接刷新至磁盘,而不是Cache在内存。这就提出了同步IO的需求。为了达到这个效果,可以在打开文件的时候设置O_SYNC标记。当数据在page cache中聚合之后,如果发现O_SYNC标记被设置,那么就会将page cache中的数据强制的刷新到磁盘。对于EXT3文件系统,该过程在ext3_file_write函数中实现。

第三个问题是普通文件的cache问题。对于普通文件,由于磁盘性能比较低,为了提高读写性能,通常会采用内存作为磁盘的cache。文件系统会采用预读等机制对文件读写性能进行优化,避免磁盘随机IO性能过低对文件读写性能造成影响。但是,page cache虽然提高了性能,但是也会对文件系统的可靠性造成一定影响。例如,当数据已经被写入内存之后,系统Crash,内存中的磁盘数据将会遭到破坏。为了避免这种情况,Linux文件系统提供了Direct IO的IO方式。该方式就是让一次IO过程绕过page cache机制,直接将文件内容刷新到磁盘。与上面的同步IO相比,Direct IO达到的效果似乎有点类似。其实,同步IO是一种write through的Cache机制,而Direct IO是完全把Cache抛弃了。同步IO的数据在内存还是有镜像的,而Direct IO是没有的,这是两者的区别。在Linux中的__generic_file_aio_write_nolock函数中,会判断O_DIRECT标记是否被设置,如果该标记被设置,那么调用generic_file_direct_write函数完成数据磁盘写入过程。如果该标记不存在,那么调用generic_file_buffered_write函数将数据写入page cache。

Leave a Reply

Your email address will not be published. Required fields are marked *