在前面普通文件和块设备文件的readpage方法中介绍到,对于一个普通文件,要读取相对于文件头的ppos处开始size个连续的字节,就必须计算成对应的页面缓存在内存中;如果存在不连续的情况,如“文件的洞”,就调用块设备的readpage方法建立块设备页高速缓存存放不来连续的块。
不管怎样,最终都将封装一个bio结构,并把请求传递给函数 generic_make_request ,并由 generic_make_request 函数将请求提交给通用块层处理。在进入通用块层之前,先把块设备的一些预备知识清理了。
块设备主要是指那些磁盘、U盘等一些用来存储的设备,他们的主要特点是,CPU和总线读写数据所花时间与磁盘硬件的速度不匹配。块设备的平均访问时间很长。每个操作都需要几个毫秒才能完成,主要是因为磁盘控制器必须在磁盘表面将磁头移动到记录数据的确切位置。但是,当磁头到达正确位置时,数据传送就可以稳定在每秒几十MB的速率。
块设备驱动程序上的每个操作都涉及很多内核组件,我们来看ULK-3的一幅经典的图:
我们前面所有的内容都在讲一个进程在某个磁盘文件上发出一个read()系统调用——其实write请求本质上采用同样的方式。下面咱们就站在设备管理的高度对通用块层之前的步骤进行一下总结和回顾:
1. read()系统调用的服务例程调用一个适当的VFS函数,也就是前面的generic_file_read,将文件描述符和文件内的偏移量传递给它。
2. generic_file_read函数确定所请求的数据是否已经存在于页高速缓存中(address_space对应的基树中)。有时候没有必要访问磁盘上的数据,因为内核将大多数最近从块设备读出或写人其中的数据保存在页高速缓存中。
3. 我们假设内核需要从块设备读数据,那么它就必须确定数据的物理位置。为了做到这点,内核依赖映射层(mapping layer),主要执行下面两步:
a) 内核确定该文件所在文件系统的块大小,并根据文件块的大小计算所请求数据的长度。本质上,文件被看作拆分成许多块,因此调用do_generic_mapping_read确定请求数据所在的块号(文件开始位置的相对索引)。
b) 接下来,映射层调用一个具体文件系统的函数——ext2_get_block,它访问缓存在内存中的文件的磁盘索引节点,然后根据逻辑块号确定所请求数据在磁盘上的位置。
好了,现在我们已经走到这一步了,现在内核可以对块设备发出读请求。内核利用通用块层(generic block lnyer)启动I/O操作函数generic_make_request来传送所请求的数据。一般而言,每个I/O操作只针对磁盘上一组连续的块。由于请求的数据不必位于相邻的块中,所以通用块层可能启动几次I/O操作。每次I/O操作是由一个“块I/O”(简称“bio”)结构描述,它收集底层组件需要的所有信息以满足所发出的请求。
通用块层为所有的块设备提供了一个抽象视图,因而隐藏了硬件块设备间的差异性。几乎所有的块设备都是磁盘,所以通用块层也提供了一些通用数据结构来描述“磁盘”或“磁盘分区”。
通用块层下面的“I/O调度程序”根据预先定义的内核策略将待处理的I/O数据传送请求进行归类。调度程序的作用是把物理介质上相邻的数据请求聚集在一起。我们将在后面的“I/O调度程序”一节中介绍调度程序。
最后,块设备驱动程序向磁盘控制器的硬件接口发送适当的命令,从而进行实际的数据传送。我们将在后面的“块设备底层驱动程序”一节介绍通用块设备驱动程序的总体组织结构。
我们看到,块设备中的数据存储涉及了许多内核组件;每个组件采用不同长度的块来管理磁盘数据:
l 硬件块设备控制器采用称为“扇区”(sector)的固定长度的块来传送数据,这个长度对于大多数磁盘都是512个字节,特别是SCSI硬盘、U盘和光盘。因此,I/O调度程序和块设备驱动程序必须管理数据扇区。
l 虚拟文件系统、映射层和文件系统将磁盘数据存放在称为“块”(block)的逻辑单元中。一个块对应文件系统中一个最小的磁盘存储单元。
l 块设备驱动程序应该能够处理数据的“段”(segment):一个段就是一个内存页或内存页的一部分,它们包含磁盘上物理相邻的数据块。
l 磁盘高速缓存作用于磁盘数据的“页”(page)上,每页正好装在一个页框中。
通用块层将所有的上层和下层的组件组合在一起,因此它了解数据的扇区、块、段以及页。即使有许多不同的数据块,它们通常也是共享相同的物理RAM单元。
我们看到上图就显示了一个页(说死了,4096字节)的构造。上层内核组件将页看成是由4个1024字节组成的块缓冲区。块设备驱动程序正在传送页中的后3个块,因此这3块被插人到涵盖了后3072字节的段中。硬盘控制器将该段看成是由6个512字节的扇区组成。
从本节开始我们就要往块设备的下层内核组件走了:通用块层、I/O调度程序以及块设备驱动程序,因此我们将注意力集中在扇区、块和段上。
扇区
为了达到可接受的性能,硬盘和类似的设备快速传送几个相邻字节的数据。块设备的每次数据传送操作都作用于一组称为扇区的相邻字节。在下面的讨论中,我们假定字节按相邻的方式记录在磁盘表面,这样一次搜索操作就可以访问到它们。尽管磁盘的物理构造很复杂,但是硬盘控制器接收到的命令将磁盘看成一大组扇区。
在大部分磁盘设备中,扇区的大小是512字节,但是一些设备使用更大的扇区(1024和2048字节)。注意,应该把扇区作为数据传送的基本单元;不允许传送少于一个扇区的数据,尽管大部分磁盘设备都可以同时传送几个相邻的扇区。
在Linux中,扇区大小按惯例都设为512字节;如果一个块设备使用更大的扇区,那么相应的底层块设备驱动程序将做些必要的变换。因此,对存放在块设备中的一组数据是通过它们在磁盘上的位置来标识,即其首个512字节扇区的下标以及扇区的数目。扇区的下标存放在类型为sector_t的32位或64位的变量中。
块
扇区是硬件设备传送数据的基本单位,而块是VFS和文件系统传送数据的基本单位。内核访问一个文件的内容时,它必须首先从磁盘上读文件的磁盘索引节点所在的块。该块对应磁盘上一个或多个相邻的扇区,而VFS将其看成是一个单一的数据单元。
块设备的块大小不是唯一的。创建一个磁盘文件系统时,管理员可以选择合适的块大小。因此,同一个磁盘上的几个分区可能使用不同的块大小。
每个块都需要自己的块缓冲区,它是内核用来存放块内容的RAM内存区。当内核从磁盘读出一个块时,就用从硬件设备中所获得的值来填充相应的块缓冲区;同样,当内核向磁盘中写入一个块时,就用相关块缓冲区的实际值来更新硬件设备上相应的一组相邻字节。块缓冲区的大小通常要与相应块的大小相匹配。
缓冲区首部是一个与每个缓冲区相关的buffer_head类型的描述符。它包含内核处理缓冲区需要了解的所有信息;因此,在对每个缓冲区进行操作之前,内核都要首先检查其缓冲区首部。其中,b_page字段存放的是块缓冲区所在页框的页描述符地址。如果页框位于高端内存中,那么b_data字段存放页中块缓冲区的偏移量;否则,b_data存放块缓冲区本身的起始线性地址。b_blocknr字段存放的是逻辑块号(例如磁盘分区中的块索引)。最后,b_bdev字段标识使用缓冲区首部的块设备。
段
磁盘的每个I/O操作的实质是在磁盘与一些RAM单元之间相互传送一些相邻扇区的内容。大多数情况下,磁盘控制器直接采用DMA方式进行数据传送。DMA方式的特点是,磁盘控制器就像一个外置CPU一样,块设备驱动程序只要向磁盘控制器发送一些适当的命令就可以触发一次数据传送;一旦完成数据的传送,控制器就会发出一个中断通知块设备驱动程序。
DMA方式传送的是磁盘上相邻扇区的数据。这是一个物理约束:磁盘控制器允许DMA传送不相邻的扇区数据,但是这种方式的传送速率很低,因为在磁盘表面上移动读/写磁头是相当慢的。
老式的磁盘控制器仅仅支持“简单的”DMA传送方式:在这种传送方式中,磁盘必须与RAM中的连续内存单元相互传送数据。但是,新的磁盘控制器,也就是我们即将讲到的SCSI磁盘控制器,支持所谓的分散-聚集(scatter-gather)DMA传送方式。此种方式中,磁盘可以与一些非连续的内存区相互传送数据。
启动一次分散-聚集DMA传送,块设备驱动程序需要向磁盘控制器发送:
(1)要传送的起始磁盘扇区号和总的扇区数
(2)内存区的描述符链表,其中链表的每项包含一个地址和一个长度
磁盘控制器则负责整个数据传送;例如,在读操作中控制器从相邻磁盘扇区中获得数据,然后将它们存放到不同的内存区中。为了使用分散-聚集DMA传送方式,块设备驱动程序必须能够处理称为段的数据存储元。一个段就是一个内存页或内存页中的一部分,它们包含一些相邻磁盘扇区中的数据。因此,一次分散-聚集DMA操作可能同时传送几个段。
注意,块设备驱动程序不需要知道块、块大小以及块缓冲区。因此,即使高层将段看成是由几个块缓冲区组成的页,块设备驱动程序也不用对此给予关注。
如果,不同的段在RAM中相应的页框正好是连续的并且在磁盘上相应的数据块也是相邻的,那么通用块层可以合并它们。通过这种合并方式产生的更大的内存区就称为物理段。
然而,在多种体系结构上还允许使用另一个合并方式:通过使用一个专门的总线电路来处理总线地址与物理地址间的映射。通过这种合并方式产生的内存区称为硬件段。由于我们将注意力集中在80 x 86体系结构上,它在总线地址和物理地址之间不存在动态的映射,因此在本章剩余部分我们假定硬件段总是对应物理段。