深入理解linux内核--块设备驱动程序

块设备的处理

块设备驱动程序上的每个操作都涉及很多内核组件;其中最重要的一些如图14-1所示。例如,我们假设一个进程在某个磁盘文件上发出一个read()系统调用——我们将会看到处理write请求本质上采用同样的方式。下面是内核对进程请求给予回应的一般步骤:
深入理解linux内核--块设备驱动程序_第1张图片

  1. read()系统调用的服务例程调用一个适当的VFS函数,将文件描述符和文件内的偏移量传递给它。虚拟文件系统位于块设备处理体系结构的上层,它提供一个通用的文件模型,Linux支持的所有文件系统均采用该模型。
  2. VFS函数确定所请求的数据是否已经存在,如果有必要的话,它决定如何执行read操作。有时候没有必要访问磁盘上的数据,因为内核将大多数最近从块设备读出或写入其中的数据保存在RAM中。
  3. 我们假设内核从块设备读数据,那么它就必须确定数据的物理位置。为了做到这点,内核依赖映射层(mapping layer),主要执行下面两步:
    a. 内核确定该文件所在文件系统的块大小,并根据文件块的大小计算所请求数据的长度。本质上,文件被看作拆分成许多块,因此内核确定请求数据所在的块号(文件开始位置的相对索引)。
    b. 接下来,映射层调用一个具体文件系统的函数,它访问文件的磁盘节点,然后根据逻辑块号确定所请求数据在磁盘上的位置。事实上,磁盘也被看作拆分成许多块,因此内核必须确定存放所请求数据的块对应的号(磁盘或分区开始位置的相对索引)。由于一个文件可能存储在磁盘上的不连续块中,因此存放在磁盘索引节点中的数据结构将每个文件块号映射为一个逻辑块号。
  4. 现在内核可以对块设备发出读请求。内核利用通用块层(generic block Inyer)启动I/O操作来传送所请求的数据。一般而言,每个I/O操作只针对磁盘上一组连续的块。由于请求的数据不必位于相邻的块中,所以通用块层可能启动几次I/O操作。每次I/O操作是由一个“块I/O”(简称“bio”)结构描述,它收集底层组件需要的所有信息以满足所发出的请求。通用块层为所有的块设备提供了一个抽象视图,因而隐藏了硬件块设备间的差异性。几乎所有的块设备都是磁盘,所以通用块层也提供了一些通用数据结构来描述“磁盘”或“磁盘分区”。
  5. 通用块层下面的“I/O调度程序”根据预先定义的内核策略将待处理的I/O数据传送请求进行归类。调度程序的作用是把物理介质上相邻的数据请求聚集在一起。
  6. 最后,块设备驱动程序向磁盘控制器的硬件接口发送适当的命令,从而进行实际的数据传送。如你所见,块设备中的数据存储涉及了许多内核组件;每个组件采用不同长度的块来管理磁盘数据:
    6.1. 硬件块设备控制器采用称为“扇区”的固定长度的块来传送数据。因此,I/O调度程序和块设备驱动程序必须管理数据扇区。
    6.2. 虚拟文件系统、映射层和文件系统将磁盘数据存放在称为“块”的逻辑单元中。
    6.3. 一个块对应文件系统中一个最小的磁盘存储单元。
    我们很快会看到,块设备驱动程序应该能够处理数据的“段”:一个段就是一个内存页或内存页的一部分,它们包含磁盘上物理相邻的数据块。
    6.4.磁盘高速缓存作用于磁盘数据的“页”上,每页正好装在一个页框中。通用块层将所有的上层和下层的组件组合在一起,因此它了解数据的扇区、块、段以及页。即使有许多不同的数据块,它们通常也是共享相同的物理RAM单元。

例如,图14-2显示了一个具有4096字节的页的构造。上层内核组件将页看成是由4个1024字节组成的块缓冲区。块设备驱动程序正在传送页中的后3个块,因此这3块被插入到涵盖了后3072 字节的段中。硬盘控制器将该段看成是由6个512字节的扇区组成。
深入理解linux内核--块设备驱动程序_第2张图片
本章我们介绍处理块设备的下层内核组件:通用块层、I/O调度程序以及块设备驱动程序,因此我们将注意力集中在扇区、块和段上。

扇区

为了达到可接受的性能,硬盘和类似的设备快速传送几个相邻字节的数据。块设备的每次数据传送操作都作用于一组称为扇区的相邻字节。在下面的讨论中,我们假定字节按相邻的方式记录在磁盘表面,这样一次搜索操作就可以访问到它们。尽管磁盘的物理构造很复杂,但是硬盘控制器接收到的命令将磁盘看成一大组扇区。在大部分磁盘设备中,扇区的大小是512字节,但是一些设备使用更大的扇区(1024和2048字节)。

注意,应该把扇区作为数据传送的基本单元;不允许传送少于一个扇区的数据,尽管大部分磁盘设备都可以同时传送几个相邻的扇区。在Linux中,扇区大小按惯例都设为512字节;如果一个块设备使用更大的扇区,那么相应的底层块设备驱动程序将做些必要的变换。因此,对存放在块设备中的一组数据是通过它们在磁盘上的位置来标识,即其首个512字节扇区的下标以及扇区的数目。扇区的下标存放在类型为sector_c的32位或64位的变量中。

扇区是硬件设备传送数据的基本单位,而块是VFS和文件系统传送数据的基本单位。例如,内核访问一个文件的内容时,它必须首先从磁盘上读文件的磁盘索引节点所在的块。该块对应磁盘上一个或多个相邻的扇区,而VFS将其看成是一个单一的数据单元。在Linux中,块大小必须是2的幂,而且不能超过一个页框。此外,它必须是扇区大小的整数倍,因为每个块必须包含整数个扇区。因此,在80×86体系结构中,允许块的大小为512、1024、2048和4096字节。

块设备的块大小不是唯一的。创建一个磁盘文件系统时,管理员可以选择合适的块大小。因此,同一个磁盘上的几个分区可能使用不同的块大小。此外,对块设备文件的每次读或写操作是一种“原始”访问,因为它绕过了磁盘文件系统;内核通过使用最大的块(4096字节)执行该操作。每个块都需要自己的块缓冲区,它是内核用来存放块内容的RAM内存区。当内核从磁盘读出一个块时,就用从硬件设备中所获得的值来填充相应的块缓冲区;同样,当内核向磁盘中写入一个块时,就用相关块缓冲区的实际值来更新硬件设备上相应的一组相邻字节。块缓冲区的大小通常要与相应块的大小相匹配。

缓冲区首部是一个与每个缓冲区相关的buffer_head类型的描述符。它包含内核处理缓冲区需要了解的所有信息;因此,在对每个缓冲区进行操作之前,内核都要首先检查其缓冲区首部。我们将在第十五章中详细介绍缓冲区首部中的所有字段值;但是在本章中我们仅仅介绍其中的一些字段:b_page、b_data、b_blocknr和b_bdev。

b_page字段存放的是块缓冲区所在页框的页描述符地址。如果页框位于高端内存中,那么b_data字段存放页中块缓冲区的偏移量;否则,b_data存放块缓冲区本身的起始线性地址。
b_blocknr字段存放的是逻辑块号(例如磁盘分区中的块索引)。
最后,b_bdev 字段标识使用缓冲区首部的块设备。

我们知道对磁盘的每个I/O操作就是在磁盘与一些RAM单元之间相互传送一些相邻扇区的内容。大多数情况下,磁盘控制器直接采用DMA方式进行数据传送。块设备驱动程序只要向磁盘控制器发送一些适当的命令就可以触发一次数据传送;一旦完成数据的传送,控制器就会发出一个中断通知块设备驱动程序。

DMA方式传送的是磁盘上相邻扇区的数据。这是一个物理约束:磁盘控制器允许DMA 传送不相邻的扇区数据,但是这种方式的传送速率很低,因为在磁盘表面上移动读/写磁头是相当慢的。老式的磁盘控制器仅仅支持“简单的”DMA传送方式:在这种传送方式中,磁盘必须与RAM中的连续内存单元相互传送数据。但是,新的磁盘控制器也支持所谓的分散-聚集(scatter-gather)DMA传送方式:此种方式中,磁盘可以与一些非连续的内存区相互传送数据。

启动一次分散-聚集DMA传送,块设备驱动程序需要向磁盘控制器发送:

  1. 要传送的起始磁盘扇区号和总的扇区数
  2. 内存区的描述符链表,其中链表的每项包含一个地址和一个长度。磁盘控制器负责整个数据传送;例如,在读操作中控制器从相邻磁盘扇区中获得数据,然后将它们存放到不同的内存区中。为了使用分散-聚集DMA传送方式,块设备驱动程序必须能够处理称为段的数据存储单元。一个段就是一个内存页或内存页中的一部分,它们包含一些相邻磁盘扇区中的数据。因此,一次分散-聚集DMA操作可能同时传送几个段。注意,块设备驱动程序不需要知道块、块大小以及块缓冲区。因此,即使高层将段看成是由几个块缓冲区组成的页,块设备驱动程序也不用对此给予关注。正如我们所见,如果不同的段在RAM中相应的页框正好是连续的并且在磁盘上相应的数据块也是相邻的,那么通用块层可以合并它们。通过这种合并方式产生的更大的内存区就称为物理段。然而,在多种体系结构上还允许使用另一个合并方式:通过使用一个专门的总线电路[如IO-MMU;参见第十三章中的“直接内存访问(DMA)”一节]来处理总线地址与物理地址间的映射。

通过这种合并方式产生的内存区称为硬件段。由于我们将注意力集中在80×86体系结构上,它在总线地址和物理地址之间不存在动态的映射,因此在本章剩余部分我们假定硬件段总是对应物理段。

通用块层

通用块层是一个内核组件,它处理来自系统中的所有块设备发出的请求。由于该层所提供的函数,内核可以容易地做到:

  1. 将数据缓冲区放在高端内存——仅当CPU访问其数据时,才将页框映射为内核中的线性地址空间,并在数据访问完后取消映射。
  2. 通过一些附加的手段,实现一个所谓的“零-复制”模式,将磁盘数据直接存放在用户态地址空间而不是首先复制到内核内存区;事实上,内核为I/O数据传送使用的缓冲区所在的页框就映射在进程的用户态线性地址空间中。
  3. 管理逻辑卷,例如由LVM(逻辑卷管理器)和RAID(廉价磁盘冗余阵列)使用的逻辑卷:几个磁盘分区,即使位于不同的块设备中,也可以被看作是一个单一的分区。
  4. 发挥大部分新磁盘控制器的高级特性,例如大主板磁盘高速缓存、增强的DMA性能、I/O传送请求的相关调度等等。

bio结构

通用块层的核心数据结构是一个称为bio的描述符,它描述了块设备的I/O操作。每个bio结构都包含一个磁盘存储区标识符(存储区中的起始扇区号和扇区数目)和一个或多个描述与I/O操作相关的内存区的段。bio由bio数据结构描述,其各字段如表14-1 所示。
深入理解linux内核--块设备驱动程序_第3张图片
深入理解linux内核--块设备驱动程序_第4张图片
bio中的每个段是由一个bio_vec数据结构描述的,其中各字段如表14-2所示。bio中的bi_io_vec字段指向bio_vec数据结构的第一个元素,bi_vcnt字段则存放了bio_vec数组中当前的元素个数。
深入理解linux内核--块设备驱动程序_第5张图片
在块I/O操作期间bio描述符的内容一直保持更新。例如,如果块设备驱动程序在一次分散-聚集DMA操作中不能完成全部的数据传送,那么bio中的bi_idx字段会不断更新来指向待传送的第一个段。为了从索引bi_idx指向的当前段开始不断重复bio中的段,设备驱动程序可以执行宏bio_for_each_segment。当通用块层启动一次新的I/O操作时,调用bio_alloc()函数分配一个新的bio结构。

通常,bio结构是由slab分配器分配的,但是,当内存不足时,内核也会使用一个备用的bio小内存池。内核也为bio_vec结构分配内存池——毕竟,分配一个bio结构而不能分配其中的段描述符也是没有什么意义的。相应地,bio_put()函数减少bio中引用计数器(bi_cnt)的值,如果该值等于0,则释放bio结构以及相关的bio_vec结构。

磁盘和磁盘分区的表示

磁盘是一个由通用块层处理的逻辑块设备。通常一个磁盘对应一个硬件块设备,例如硬盘、软盘或光盘。但是,磁盘也可以是一个虚拟设备,它建立在几个物理磁盘分区之上或一些RAM专用页中的内存区上。在任何情形中,借助通用块层提供的服务,上层内核组件可以以同样的方式工作在所有的磁盘上。磁盘是由gendisk对象描述的,其中各字段如表14-3所示。
深入理解linux内核--块设备驱动程序_第6张图片
深入理解linux内核--块设备驱动程序_第7张图片
flags字段存放了关于磁盘的信息。其中最重要的标志是GENHD_FL_UP;如果设置它,那么磁盘将被初始化并可以使用。另一个相关的标志是GENHD_FL_REMOVABLE,如果是诸如软盘或光盘这样可移动的磁盘,那么就要设置该标志。gendisk对象的fops字段指向一个表block_device_operations,该表为块设备的主要操作存放了几个定制的方法(如表14-4所示)。
深入理解linux内核--块设备驱动程序_第8张图片通常硬盘被划分成几个逻辑分区。每个块设备文件要么代表整个磁盘,要么代表磁盘中的某一个分区。例如,一个主设备号为3、次设备号为0的设备文件/dev/hda代表的可能是一个主EIDE磁盘;该磁盘中的前两个分区分别由设备文件/dev/hdal和/dev/hda2 代表,它们的主设备号都是3,而次设备号分别为1和2。一般而言,磁盘中的分区是由连续的次设备号来区分的。如果将一个磁盘分成了几个分区,那么其分区表保存在hd_struct结构的数组中,该数组的地址存放在gendisk对象的part字段中。通过磁盘内分区的相对索引对该数组进行索引。hd_struct描述符中的字段如表14-5所示。
深入理解linux内核--块设备驱动程序_第9张图片
深入理解linux内核--块设备驱动程序_第10张图片
当内核发现系统中一个新的磁盘时(在启动阶段,或将一个可移动介质插入一个驱动器中时,或在运行期附加一个外置式磁盘时),就调用alloc_disk()函数,该函数分配并初始化一个新的gendisk对象,如果新磁盘被分成了几个分区,那么alloc_disk()还会分配并初始化一个适当的hd_struct类型的数组。然后,内核调用add_disk()函数将新的gendisk对象插入到通用块层的数据结构中。

提交请求

我们介绍一下当向通用块层提交一个I/O操作请求时,内核所执行的步骤顺序。我们假设被请求的数据块在磁盘上是相邻的,并且内核已经知道了它们的物理位置。第一步是执行bio_alloc()函数分配一个新的bio描述符。然后,内核通过设置一些字段值来初始化bio描述符:

  1. 将bi_sector设为数据的起始扇区号(如果块设备分成了几个分区,那么扇区号是相对于分区的起始位置的)。
  2. 将bi_size设为涵盖整个数据的扇区数目。
  3. 将bi_bdev设为块设备描述符的地址。
  4. 将bi_io_vec设为bio_vec结构数组的起始地址,数组中的每个元素描述了I/0操作中的一个段(内存缓存);此外,将bi_vcnt设为bio中总的段数。
  5. 将bi_rw设为被请求操作的标志。其中最重要的标志指明数据传送的方向:READ (0)或WRITE(1)。
  6. 将bi_end_io设为当bio上的I/O操作完成时所执行的完成程序的地址。

一旦bio描述符被进行了适当的初始化,内核就调用generic_make_request()函数,它是通用块层的主要入口点。该函数主要执行下列操作:

  1. 检查bio->bi_sector没有超过块设备的扇区数。如果超过,则将bio->bi_flags设置为BIO_EOF标志,然后打印一条内核错误信息,调用bio_endio()函数,并终止。bio_endio()更新bio描述符中的bi_size和bi_sector值,然后调用bio的bi_end_io方法。bi_end_io函数的实现本质上依赖于触发I/O数据传送的内核组件;我们将在下面的章节中看到bi_end_io方法的一些例子。
  2. 获取与块设备相关的请求队列q;其地址存放在块设备描述符的bd_disk字段中,其中的每个元素由bio->bi_bdev指向。
  3. 调用block_wait_queue_running()函数检查当前正在使用的I/O调度程序是否可以被动态取代;若可以,则让当前进程睡眠直到启动一个新的I/O调度程序。
  4. 调用blk_partition_remap()函数检查块设备是否指的是一个磁盘分区(bio->bi_bdev不等于bio->bi_dev->bd_contains)。如果是,则从bio->bi_bdev获取分区的hd_struct描述符,从而执行下面的子操作:
    a. 根据数据传送的方向,更新hd_struct描述符中的read_sectors和reads值,或write_sectors和writes值。
    b. 调整bio->bi_sector值使得把相对于分区的起始扇区号转变为相对于整个磁盘的扇区号。
    c. 将bio->bi_bdev设置为整个磁盘的块设备描述符(bio->bd_contains)。从现在开始,通用块层、I/O调度程序以及设备驱动程序将忘记磁盘分区的存在,直接作用于整个磁盘。
  5. 调用q->make_request_fn方法将bio请求插入请求队列q中。
  6. 返回。
    在本章后面的“向I/O调度程序发出请求”一节中我们将讨论make_request_fn方法典型实现。

I/O调度程序

虽然块设备驱动程序一次可以传送一个单独的扇区,但是块I/O层并不会为磁盘上每个被访问的扇区都单独执行一次I/O操作;这会导致磁盘性能的下降,因为确定磁盘表面上扇区的物理位置是相当费时的。取而代之的是,只要可能,内核就试图把几个扇区合并在一起,并作为一个整体来处理,这样就减少了磁头的平均移动时间。当内核组件要读或写一些磁盘数据时,实际上创建一个块设备请求。从本质上说,请求描述的是所请求的扇区以及要对它执行的操作类型(读或写)。然而,并不是请求一发出,内核就满足它——I/O操作仅仅被调度,执行会向后推迟。这种人为的延迟是提高块设备性能的关键机制。

当请求传送一个新的数据块时,内核检查能否通过稍微扩展前一个一直处于等待状态的请求而满足新请求(也就是说,能否不用进一步的寻道操作就能满足新请求)。由于磁盘的访问大都是顺序的,因此这种简单机制就非常高效。延迟请求复杂化了块设备的处理。
例如,假设某个进程打开了一个普通文件,然后,文件系统的驱动程序就要从磁盘读取相应的索引节点。块设备驱动程序把这个请求加入一个队列,并把这个进程挂起,直到存放索引节点的块被传送为止。然而,块设备驱动程序本身不会被阻塞,因为试图访问同一磁盘的任何其他进程也可能被阻塞。为了防止块设备驱动程序被挂起,每个I/O操作都是异步处理的。

特别是块设备驱动程序是中断驱动的;通用块层调用I/O调度程序产生一个新的块设备请求或扩展一个已有的块设备请求,然后终止。随后激活的块设备驱动程序会调用一个所谓的策略例程(strategy routine)选择一个待处理的请求,并向磁盘控制器发出一条适当的命令来满足这个请求。

当I/O操作终止时,磁盘控制器就产生一个中断,如果需要,相应的中断处理程序就又调用策略例程去处理队列中的另一个请求。 每个块设备驱动程序都维持着自己的请求队列,它包含设备待处理的请求链表。如果磁盘控制器正在处理几个磁盘,那么通常每个物理块设备都有一个请求队列。在每个请求队列上单独执行I/O调度,这样可以提高磁盘的性能。

请求队列描述符

请求队列是由一个大的数据结构request_queue表示的,其字段如表14-6所示。
深入理解linux内核--块设备驱动程序_第11张图片
深入理解linux内核--块设备驱动程序_第12张图片
深入理解linux内核--块设备驱动程序_第13张图片
深入理解linux内核--块设备驱动程序_第14张图片
深入理解linux内核--块设备驱动程序_第15张图片

实质上,请求队列是一个双向链表,其元素就是请求描述符(也就是request数据结构)。请求队列描述符中的queue_head字段存放链表的头(第一个伪元素),而请求描述符中queuelist字段的指针把任一请求链接到链表的前一个和后一个元素之间。
队列链表中元素的排序方式对每个块设备驱动程序是特定的;然而,I/O调度程序提供了几种预先确定好的元素排序方式,这将在后面的“I/O调度算法”一节中讨论。backing_dev_info字段是一个backing_dev_info类型的小对象,它存放了关于基本硬件块设备的I/O数据流量的信息。例如,它保存了关于预读以及关于请求队列拥塞状态的信息。

请求描述符

每个块设备的待处理请求都是用一个请求描述符来表示的,请求描述符存放在如表14-7所示的request数据结构中。
深入理解linux内核--块设备驱动程序_第16张图片
深入理解linux内核--块设备驱动程序_第17张图片
深入理解linux内核--块设备驱动程序_第18张图片
深入理解linux内核--块设备驱动程序_第19张图片
每个请求包含一个或多个bio结构。最初,通用块层创建一个仅包含一个bio结构的请求。然后,I/O调度程序要么向初始的bio中增加一个新段,要么将另一个bio结构链接到请求中,从而“扩展”该请求。可能存在新数据与请求中已存在的数据物理相邻的情况。请求描述符的bio字段指向请求中的第一个bio结构,而biotail字段则指向最后一个bio结构。rq_for_each_bio宏执行一个循环,从而遍历请求中的所有bio结构。
请求描述符中的几个字段值可能是动态变化的。例如,一旦bio中引用的数据块全部传送完毕,bio字段立即更新从而指向请求链表中的下一个bio。在此期间,新的bio可能被加入到请求链表的尾部,所以biotail的值也可能改变。当磁盘数据块正在传送时,请求描述符的其它几个字段的值由I/O调度程序或设备驱动程序修改。

例如,nr_sectors存放整个请求还需传送的扇区数,current_nr_sectors 存放当前bio结构中还需传送的扇区数。flags中存放了很多标志,如表14-8中所示。到目前为止,最重要的一个标志是REQ_RW,它确定数据传送的方向。
深入理解linux内核--块设备驱动程序_第20张图片
深入理解linux内核--块设备驱动程序_第21张图片
深入理解linux内核--块设备驱动程序_第22张图片

对请求描述符的分配进行管理

在重负载和磁盘操作频繁的情况下,固定数目的动态空闲内存将成为进程想要把新请求加入请求队列q的瓶颈。为了解决这种问题,每个request_queue描述符包含一个request_list数据结构,其中包括:

  1. 一个指针,指向请求描述符的内存池。
  2. 两个计数器,分别用于记录分配给READ和WRITE请求的请求描述符数。
  3. 两个标志,分别用于标记为读或写请求的分配是否失败。
  4. 两个等待队列,分别存放了为获得空闲的读和写请求描述符而睡眠的进程。一个等待队列,存放等待一个请求队列被刷新(清空)的进程。

blk_get_request()函数试图从一个特定请求队列的内存池中获得一个空闲的请求描述符;如果内存区不足并且内存池已经用完,则要么挂起当前进程,要么返回NULL(如果不能阻塞内核控制路径)。如果分配成功,则将请求队列的request_list数据结构的地址存放在请求描述符的r1字段中。blk_put_request()函数则释放一个请求描述符;如果该描述符的引用计数器的值为0,则将描述符归还回它原来所在的内存池。

避免请求队列拥塞

每个请求队列都有一个允许处理的最大请求数。请求队列描述符的nr_requests字段存放了每个数据传送方向所允许处理的最大请求数。缺省情况下,一个队列至多有128个待处理读请求和128个待处理写请求。

如果待处理的读(写)请求数超过了nr_requests 值,那么通过设置请求队列描述符的queue_flags字段的QUEUE_FLAG_READFULL (QUEUE_FLAG_WRITEFULL)标志将该队列标记为已满,试图把请求加入到某个传送方向的可阻塞进程被放置到request_list结构所对应的等待队列中睡眠。一个填满的请求队列对系统性能有负面影响,因为它会强制许多进程去睡眠以等待I/O 数据传送的完成。因此,如果给定传送方向上的待处理请求数超过了存放在请求描述符的nr_congestion_on字段中的值(缺省值为113),那么内核认为该队列是拥塞的,并试图降低新请求的创建速率。

当待处理请求数小于nr_congestion_off的值(缺省值为111)时,拥塞的请求队列才变为不拥塞。blk_congestion_wait()函数挂起当前进程,直到所有请求队列都变为不拥塞或超时已到。

激活块设备驱动程序

正如我们在前面已经看到的一样,延迟激活块设备驱动程序有利于把相邻块的请求进行集中。这种延迟是通过所谓的设备插入和设备拔出技术来实现的。在块设备驱动程序被插入时,该驱动程序并不被激活,即使在驱动程序队列中有待处理的请求。blk_plug_device()函数的功能是插入一个块设备——更准确地说,插入到某个块设备驱动程序处理的请求队列中。

本质上,该函数接收一个请求队列描述符的地址q作为其参数。它设置q->queue_flags字段中的QUEUE_FLAG_PLUGGED位;然后,重新启动q->unplug_timer字段中的内嵌动态定时器。blk_remove_plug()则拔去一个请求队列q;清除QUEUE_FLAG_PLUGGED标志并取消q->unplug_timer动态定时器的执行。当“视线中”所有可合并的请求都被加入请求队列时,内核就会显式地调用该函数。此外,如果请求队列中待处理的请求数超过了请求队列描述符的unplug_thresh字段中存放的值(缺省值为4),那么I/O调度程序也会去掉该请求队列。

如果一个设备保持插入的时间间隔为q->unplug_delay(通常为3ms),那么说明由blk_plug_device()函数激活的动态定时器的时间已用完,因此就会执行blk_unplug_timeout()函数。因而,唤醒内核线程kblockd所操作的工作队列kblockd_workqueue。kblockd执行blk->unplug_work()函数,其地址存放在q->unplug_work结构中。接着,该函数会调用请求队列中的q->unplug_fn方法,通常该方法是由generic_unplug_device()函数实现的。

generic_unplug_device()函数的功能是拔出块设备:
首先,检查请求队列是否仍然活跃;
然后,调用blk_remove_plug()函数;
最后,执行策略例程request_fn方法来开始处理请求队列中的下一个请求。

IO调度算法

当向请求队列增加一条新的请求时,通用块层会调用I/O调度程序来确定请求队列中新请求的确切位置。I/O调度程序试图通过扇区将请求队列排序。如果顺序地从链表中提取要处理的请求,那么就会明显减少磁头寻道的次数,因为磁头是按照直线的方式从内磁道移向外磁道(反之亦然),而不是随意地从一个磁道跳跃到另一个磁道。这可以从电梯算法中得到启发,回想一下,电梯算法处理来自不同层的上下请求。电梯是往一个方向移动的;当朝一个方向上的最后一个预定层到达时,电梯就会改变方向而开始向相反的方向移动。因此,I/O调度程序也被称为电梯算法(elevator)。

在重负载情况下,严格遵循扇区号顺序的I/O调度算法运行的并不是很好。在这种情形下,数据传送的完成时间主要取决于磁盘上数据的物理位置。因此,如果设备驱动程序处理的请求位于队列的首部(小扇区号),并且拥有小扇区号的新请求不断被加入队列中,那么队列末尾的请求就很容易会饿死。因而I/O调度算法会非常复杂。
当前,Linux 2.6中提供了四种不同类型的I/O调度程序或电梯算法,分别为“预期(Anticipatory)”算法、“最后期限(Deadline)”算法、“CFQ(Complete Fairness Queueing,完全公平队列)”算法以及“Noop(No Operation)”算法。对大多数块设备而言,内核使用的缺省电梯算法可在引导时通过内核参数elevator=进行再设置,其中值可取下列任何一个:as、deadline、cfq和noop。如果没有给定引导参数,那么内核缺省使用“预期”I/O调度程序。总之,设备驱动程序可以用任何一个调度程序取代缺省的电梯算法;设备驱动程序也可以自己定制I/O调度算法,但是这种情况很少见。此外,系统管理员可以在运行时为一个特定的块设备改变I/O调度程序。例如,为了改变第一个IDE通道的主磁盘所使用的I/O调度程序,管理员可把一个电梯算法的名称写入sysfs特殊文件系统的/sys/block/hda/queue/scheduler文件中。

请求队列中使用的I/O调度算法是由一个elevator_t类型的elevator对象表示的;该对象的地址存放在请求队列描述符的elevator字段中。elevator对象包含了几个方法,它们覆盖了elevator所有可能的操作:链接和断开elevator,增加和合并队列中的请求,从队列中删除请求,获得队列中下一个待处理的请求等等。elevator对象也存放了一个表的地址,表中包含了处理请求队列所需的所有信息。而且,每个请求描述符包含一个elevator_private字段,该字段指向一个由I/O调度程序用来处理请求的附加数据结构。

现在我们从易到难简要地介绍一下四种I/O调度算法。注意,设计一个I/O调度程序与设计一个CPU调度程序很相似:启发算法和采用的常量值是测试和基准外延量的结果。一般而言,所有的算法都使用一个调度队列(dispatch queue),队列中包含的所有请求按照设备驱动程序应当处理的顺序进行排序——也即设备驱动程序要处理的下一个请求通常是调度队列中的第一个元素。

调度队列实际上是由请求队列描述符的queue_head 字段所确定的请求队列。几乎所有的算法都使用另外的队列对请求进行分类和排序。它们允许设备驱动程序将bio结构增加到已存在请求中,如果需要,还要合并两个“相邻的”请求。

“Noop”算法

这是最简单的I/O调度算法。它没有排序的队列:新的请求通常被插在调度队列的开头或末尾,下一个要处理的请求总是队列中的第一个请求。

“CFQ”算法

“CFQ(完全公平队列)”算法的主要目标是在触发I/O请求的所有进程中确保磁盘I/O 带宽的公平分配。为了达到这个目标,算法使用许多个排序队列,它们存放了不同进程发出的请求。

当算法处理一个请求时,内核调用一个散列函数将当前进程的线程组标识符(通常,它对应其PID,参见第三章“标识一个进程”一节)转换为队列的索引值;然后,算法将一个新的请求插入该队列的末尾。因此,同一个进程发出的请求通常被插入相同的队列中。为了再填充调度队列,算法本质上采用轮询方式扫描I/O输入队列,选择第一个非空队列,然后将该队列中的一组请求移动到调度队列的末尾。

“最后期限”算法

除了调度队列外,“最后期限”算法还使用了四个队列。其中的两个排序队列分别包含读请求和写请求,其中的请求是根据起始扇区数排序的。另外两个最后期限队列包含相同的读和写请求,但这是根据它们的“最后期限”排序的。引入这些队列是为了避免请求饿死,由于电梯策略优先处理与上一个所处理的请求最近的请求,因而就会对某个请求忽略很长一段时间,这时就会发生这种情况。请求的最后期限本质上就是一个超时定时器,当请求被传给电梯算法时开始计时。

缺省情况下,读请求的超时时间是500ms,写请求的超时时间是5s——读请求优先于写请求,因为读请求通常阻塞发出请求的进程。最后期限保证了调度程序照顾等待很长一段时间的那个请求,即使它位于排序队列的末尾。当算法要补充调度队列时,首先确定下一个请求的数据方向。如果同时要调度读和写两个请求,算法会选择“读”方向,除非该“写”方向已经被放弃很多次了(为了避免写请求饿死)。接下来,算法检查与被选择方向相关的最后期限队列:如果队列中的第一个请求的最后期限已用完,那么算法将该请求移到调度队列的末尾;也可以从超时的那个请求开始移动来自排序队列的一组请求。如果将要移动的请求在磁盘上物理相邻,那么组的长度会变长,否则就变短。

最后,如果没有请求超时,算法对来自于排序队列的最后一个请求之后的一组请求进行调度。当指针到达排序队列的末尾时,搜索又从头开始(“单方向算法”)。

“预期”算法

“预期”算法是Linux提供的最复杂的一种I/O调度算法。基本上,它是“最后期限”算法的一个演变,借用了“最后期限”算法的基本机制:两个最后期限队列和两个排序队列;I/O调度程序在读和写请之间交互扫描排序队列,不过更倾向于读请求。扫描基本上是连续的,除非有某个请求超时。读请求的缺省超时时间是125ms,写请求的缺省超时时间是250ms。但是,该算法还遵循一些附加的启发式准则:

  1. 有些情况下,算法可能在排序队列当前位置之后选择一个请求,从而强制磁头从后搜索。这种情况通常发生在这个请求之后的搜索距离小于在排序队列当前位置之后对该请求搜索距离的一半时。
  2. 算法统计系统中每个进程触发的I/O操作的种类。当刚刚调度了由某个进程p发出的一个读请求之后,算法马上检查排序队列中的下一个请求是否来自同一个进程p。如果是,立即调度下一个请求。否则,查看关于该进程p的统计信息:如果确定进程p可能很快发出另一个读请求,那么就延迟一小段时间(缺省大约为7ms)。
    因此,算法预测进程p发出的读请求与刚被调度的请求在磁盘上可能是“近邻”。

向I/O调度程序发出请求

正如我们在本章前面的“提交请求”一节中所看到的,generic_make_request()函数调用请求队列描述符的make_request_fn方法向I/O调度程序发送一个请求。通常该方法是由__make_request()函数实现的;该函数接收一个request_queue类型的描述符q和一个bio结构的描述符bio作为其参数,然后执行如下操作:

  1. 如果需要,调用blk_queue_bounce()函数建立一个回弹缓冲区(参见后面)。如果回弹缓冲区被建立,__make_request()函数将对该缓冲区而不是原先的bio结构进行操作。
  2. 调用I/O调度程序的elv_queue_empty()函数检查请求队列中是否存在待处理请求。注意,调度队列可能是空的,但是I/O调度程序的其他队列可能包含待处理请求。如果没有待处理请求,那么调用blk_plug_device()函数插入请求队列,然后跳转到第5步。
  3. 插入的请求队列包含待处理请求。调用I/O调度程序的elv_merge()函数检查新的bio结构是否可以并入已存在的请求中。该函数将返回三个可能值:
    3.1. ELEVATOR_NO_MERGE:已经存在的请求中不能包含bio结构;这种情况下,跳转到第5步。
    3.2. ELEVATOR_BACK_MERGE:bio结构可作为末尾的bio而插入到某个请求req中;这种情形下,函数调用q->back_merge_fn方法检查是否可以扩展该请求。如果不行,则跳转到第5步。否则,将bio描述符插入req链表的末尾并更新req的相应字段值。然后,函数试图将该请求与其后面的请求合并(新的bio可能填充在两个请求之间)。
    3.3. ELEVATOR_FRONT_MERGE:bio结构可作为某个请求req的第一个bio被插入;这种情形下,函数调用q->front_merge_fn方法检查是否可以扩展该请求。如果不行,则跳转到第5步。否则,将bio描述符插入req链表的首部并更新req的相应字段值。然后,试图将该请求与其前面的请求合并。
  4. bio已经被并入存在的请求中,跳转到第7步终止函数。
  5. bio必须被插入到一个新的请求中。分配一个新的请求描述符。如果没有空闲的内存,那么挂起当前进程,直到设置了bio->bi_rw中的BIO_RW_AHEAD标志, 该标志表明这个I/O操作是一次预读;在这种情形下,函数调用bio_endio()并终止:此时将不会执行数据传送。
  6. 初始化请求描述符中的字段。主要有:
    a. 根据bio描述符的内容初始化各个字段,包括扇区数、当前bio以及当前段。
    b. 设置flags字段中的REQ_CMD标志(一个标准的读或写操作)。
    c. 如果第一个bio段的页框存放在低端内存,则将buffer字段设置为缓冲区的线性地址。
    d. 将rq_disk字段设置为bio->bi_bdev->bd_disk的地址。
    e. 将bio插入请求链表。
    f. 将start_time字段设置为jiffies的值。
  7. 所有操作全部完成。但是,在终止之前,检查是否设置了bio->bi_rw中的BIO_RW_SYNC标志。如果是,则对“请求队列”调用generic_unplug_device()函数以卸载设备驱动程序。
  8. 函数终止。

如果在调用__make_request()函数之前请求队列不是空的,那么说明该请求队列要么已经被拔掉过,要么很快将被拔掉——因为每个拥有待处理请求的插入请求队列q都有一个正在运行的动态定时器q->unplug_timer。另一方面,如果请求队列是空的,则__make_request()函数插入请求队列。或迟(最坏的情况是当拔出的定时器到期了)或早(从__make_request()中退出时,如果设置了bio的BIO_RW_SYNC标志),该请求队列都会被拔掉。任何情形下,块设备驱动程序的策略例程最后都将处理调度队列中的请求。

blk_queue_bounce()函数

blk_queue_bounce()函数的功能是查看q->bounce_gfp中的标志以及q->bounce_pfn 中的阈值,从而确定回弹缓冲区(buffer bouncing)是否是必需的。通常当请求中的一些缓冲区位于高端内存而硬件设备不能访问它们时发生这种情况。

ISA总线使用的老式DMA方式只能处理24位的物理地址。因此,回弹缓冲区的上限设为16 MB,也就是说,页框号为4096。然而,当处理老式设备时,块设备驱动程序通常不依赖回弹缓冲区;相反,它们更倾向于直接在ZONE_DMA内存区中分配DMA缓冲区。如果硬件设备不能处理高端内存中的缓冲区,则blk_queue_bounce()函数检查bio中的一些缓冲区是否真的必须是回弹的。

如果是,则将bio描述符复制一份,接着创建一个回弹bio;然后,当段中的页框号等于或大于q->bounce_pfn的值时,执行下列操作

  1. 根据分配的标志,在ZONE_NORMAL或ZONE_DMA内存区中分配一个页框。
  2. 更新回弹bio中段的bv_page字段的值,使其指向新页框的描述符。
  3. 如果bio->bio_rw代表一个写操作,则调用kmap()临时将高端内存页映射到内核地址空间中,然后将高端内存页复制到低端内存页上,最后调用kunmap()释放该映射。然后blk_queue_bounce()函数设置回弹bio中的BIO_BOUNCED标志,为其初始化一个特定的bi_end_io方法,最后将它存放在回弹bio的bi_private字段中,该字段是指向初始bio的指针。当在回弹bio上的I/O数据传送终止时,函数执行bi_end_io方法将数据复制到高端内存区中(仅适合读操作),并释放该回弹bio结构。

块设备驱动程序

块设备驱动程序是Linux块子系统中的最底层组件。它们从I/O调度程序中获得请求,然后按要求处理这些请求。当然,块设备驱动程序是设备驱动程序模型的组成部分。因此,每个块设备驱动程序对应一个device_driver类型的描述符;此外,设备驱动程序处理的每个磁盘都与一个device描述符相关联。但是,这些描述符没有什么特别的:块I/O子系统必须为系统中的每个块设备存放附加信息。

块设备

一个块设备驱动程序可能处理几个块设备。例如,IDE设备驱动程序可以处理几个IDE 磁盘,其中的每个都是一个单独的块设备。而且,每个磁盘通常是被分区的,每个分区又可以被看作是一个逻辑块设备。很明显,块设备驱动程序必须处理在块设备对应的块设备文件上发出的所有VFS系统调用。每个块设备都是由一个block_device结构的描述符来表示的,其字段如表14-9所示。
深入理解linux内核--块设备驱动程序_第23张图片
深入理解linux内核--块设备驱动程序_第24张图片
所有的块设备描述符被插入一个全局链表中,链表首部是由变量all_bdevs表示的;链表链接所用的指针位于块设备描述符的bd_list字段中。如果块设备描述符对应一个磁盘分区,那么bd_contains字段指向与整个磁盘相关的块设备描述符,而bd_part字段指向hd_struct分区描述符。否则,若块设备描述符对应整个磁盘,那么bd_contains字段指向块设备描述符本身,bd_part_count字段用于记录磁盘上的分区已经被打开了多少次。bd_holder字段存放代表块设备持有者的线性地址。持有者并不是进行I/O数据传送的块设备驱动程序;准确地说,它是一个内核组件,使用设备并拥有独一无二的特权(例如,它可以自由使用块设备描述符的bd_private字段)。

典型地,块设备的持有者是安装在该设备上的文件系统。当块设备文件被打开进行互斥访问时,另一个普遍的问题出现了:持有者就是对应的文件对象。bd_claim()函数将bd_holder字段设置为一个特定的地址;相反,bd_release()函数将该字段重新设置为NULL。然而,值得注意的是,同一个内核组件可以多次调用bd_claim()函数,每调用一次都增加bd_holders的值。为了释放块设备,内核组件必须调用bd_release()函数bd_holders次。图14-3对应的是一个整盘,它说明了块设备描述符是如何被链接到块I/O子系统的其他重要数据结构上的。

访问块设备

当内核接收一个打开块设备文件的请求时,必须首先确定该设备文件是否已经是打开的。事实上,如果文件已经是打开的,内核就没有必要创建并初始化一个新的块设备描述符;相反,内核应该更新这个已经存在的块设备描述符。然而,真正的复杂性在于具有相同主设备号和次设备号但有不同路径名的块设备文件被VFS看作不同的文件,但是它们实际上指向同一个块设备。因此,内核无法通过简单地在一个对象的索引节点高速缓存中检查块设备文件的存在就确定相应的块设备已经在使用。
深入理解linux内核--块设备驱动程序_第25张图片
主、次设备号和相应的块设备描述符之间的关系是通过bdev特殊文件系统来维护的。每个块设备描述符都对应一个bdev特殊文件:块设备描述符的bd_inode字段指向相应的bdev索引节点;而该索引节点则将块设备的主、次设备号和相应描述符的地址进行编码。bdget()接收块设备的主设备号和次设备号作为其参数:在bdev文件系统中查寻相关的索引节点;如果不存在这样的节点,那么就分配一个新索引节点和新块设备描述符。在任何情形下,函数都返回一个与给定主、次设备号对应的块设备描述符的地址。一旦找到了块设备的描述符,那么内核通过检查bd_openers字段的值来确定块设备当前是否在使用:如果值是正的,说明块设备已经在使用(可能通过不同的设备文件)。同时内核也维护一个与已打开的块设备文件对应的索引节点对象的链表。该链表存放在块设备描述符的nd_inodes字段中;索引节点对象的i_devices字段存放用于链接链表中的前后元素的指针。

注册和初始化设备驱动程序

现在我们来说明一下为一个块设备设计一个新的驱动程序所涉及的基本步骤。显然,其描述是比较简单的,但是理解何时并怎样初始化块I/O子系统使用的主要数据结构是很有用的。我们省略了所有块设备驱动程序需要的但在第十三章中已经讲过的步骤。例如,我们跳过了注册一个驱动程序本身的所有步骤。通常,块设备属于一个诸如PCI或SCSI这样的标准总线体系结构,内核提供了相应的辅助函数,作为一个辅助作用,就是在驱动程序模型中注册驱动程序。

自定义驱动程序描述符

首先,设备驱动程序需要一个foo_dev_t类型的自定义描述符foo,它拥有驱动硬件设备所需的数据。该描述符存放每个设备的相关信息,例如操作设备时使用的I/O端口、设备发出中断的IRQ线、设备的内部状态等等。同时它也包含块I/O子系统所需的一些字段:

struct foo_dev_t {
[...]
spinlock_t lock;
struct gendisk *gd;
[...]
} foo;

lock字段是用来保护foo描述符中字段值的自旋锁;通常将其地址传给内核辅助函数,从而保护对驱动程序而言特定的块I/O子系统的数据结构。gd字段是指向gendisk描述符的指针,该描述符描述由这个驱动程序处理的整个块设备(磁盘)。

预订主设备号

设备驱动程序必须自己预订一个主设备号。传统上,该操作通过调用register_blkdev()函数完成:

err = register_blkdev(FOO_MAJOR, "foo");
if(err) goto error_major_is_busy;

该函数类似于第十三章的“分配设备号”一节中出现的register_chrdev()函数:预订主设备号FOO_MAJOR并将设备名称foo赋给它。注意,这不能分配次设备号范围,因为没有类似的register_chrdev_region()函数;此外,预订的主设备号和驱动程序的数据结构之间也没有建立链接。register_blkdev()函数产生的唯一可见的效果是包含一个新条目,该条目位于/proc/devices特殊文件的已注册主设备号列表中。

初始化自定义描述符

在使用驱动程序之前必须适当地初始化foo描述符中的所有字段。为了初始化与块I/O子系统相关的字段,设备驱动程序主要执行如下操作:
spin_lock_init(&foo.lock);
foo.gd = alloc_disk(16);
if(!foo.gd) goto error_no_gendisk;
驱动程序首先初始化自旋锁,然后分配一个磁盘描述符。正如在前面的图14-3中所看到的,gendisk结构是块I/O子系统中最重要的数据结构,因为它涉及许多其他的数据结构。alloc_disk()函数也分配一个存放磁盘分区描述符的数组。该函数所需要的参数是数组中hd_struct结构的元素个数;16表示驱动程序可以支持16个磁盘,而每个磁盘可以包含15个分区(0分区不使用)。

初始化gendisk描述符

接下来,驱动程序初始化gendisk描述符的一些字段:
foo.gd->private_data =&foo;
foo.gd->major = FOO_MAJOR;
foo.gd->first_minor = 0;
foo.gd->minors = 16;
set_capacity(foo.gd,foo_disk_capacity_in_sectors);strcpy(foo.gd->disk_name,“foo”);
foo.gd->fops =&foo_ops;
foo描述符的地址存放在gendisk结构的private_data字段中,因此被块I/O子系统当作方法调用的低级驱动程序函数可以迅速地查找到驱动程序描述符如果驱动程序可以并发地处理多个磁盘,那么这种方式可以提高效率。set_capacity()函数将capacity字段初始化为以512字节扇区为单位的磁盘大小,这个值也可能在探测硬件并询问磁盘参数时确定

初始化块设备操作表

gendisk描述符的fops字段被初始化为自定义的块设备方法表的地址。类似地,设备驱动程序的foo_ops表中包含设备驱动程序的特有函数。例如,如果硬件设备支持可移动磁盘,通用块层将调用media_changed方法检查自从最后一次安装或打开该块设备以来磁盘是否被更换。通常通过向硬件控制器发送一些低级命令来完成该检查,因此,每个设备驱动程序所实现的media_changed方法都是不同的。类似地,仅当通用块层不知道如何处理ioctl命令时才调用ioctl方法。例如,当一个ioctl()系统调用询问磁盘构造时,也就是磁盘使用的柱面数、磁道数、扇区数以及磁头数时,通常调用该方法。因此,每个设备驱动程序所实现的ioctl方法也都是不同的。

分配和初始化请求队列

我们勇敢的设备驱动程序设计者现在将要建立一个请求队列,该队列用于存放等待处理的请求。可以通过如下操作轻松地建立请求队列:

foo.gd->rq = blk_init_queue(foo_strategy,&foo.lock);
if(!foo.gd->rq) goto error_no_request_queue;
blk_queue_hardsect_size(foo.gd->rd, foo_hard_sector_size);
blk_queue_max_sectors(foo.gd->rd, foo_max_sectors);
blk_queue_max_hw_segments(foo.gd->rd, foo_max_hw_segments);
blk_queue_max_phys_segments(foo.gd->rd, foo_max_phys_segments);

blk_init_queue()函数分配一个请求队列描述符并将其中许多字段初始化为缺省值。它接收的参数为设备描述符的自旋锁的地址(foo.gd->rq->queue_lock字段值)和设备驱动程序的策略例程(参见下一节“策略例程”)的地址(foo.gd->rq->request_fn字段值)。
该函数也初始化foo.gd->rq->elevator字段,并强制驱动程序使用缺省的I/O 调度算法。如果设备驱动程序想要使用其他的调度算法,可在稍后覆盖elevator字段的地址。接下来,使用几个辅助函数将请求队列描述符的不同字段设为设备驱动程序的特征值(参考表14-6中的类似字段)。

设置中断处理程序

正如第四章的“I/O中断处理”一节中所介绍的,设备驱动程序需要为设备注册IRQ线。这可以通过如下操作完成:

request_irq(foo_irq, foo_interrupt, SA_INTERRUPTISA_SHIRQ, "foo", NULL);

foo_interrupt()函数是设备的中断处理程序;

注册磁盘

最后,设备驱动程序的所有数据结构已经准备好了:初始化阶段的最后一步就是“注册”和激活磁盘。这可以简单地通过执行下面的操作完成:add_disk(foo.gd);
add_disk()函数接收gendisk描述符的地址作为其参数,主要执行下列操作:

  1. 设置gd->flags的GENHD_FL_UP标志。
  2. 调用kobj_map()建立设备驱动程序和设备的主设备号(连同相关范围内的次设备号)之间的连接(注意,在这种情况下,kobject映射域由bdev_map变量表示)。
  3. 注册设备驱动程序模型的gendisk描述符中的kobject结构,它作为设备驱动程序处理的一个新设备(例如/sys/block/foo)。
  4. 如果需要,扫描磁盘中的分区表;对于查找到的每个分区,适当地初始化foo.gd->part数组中相应的hd_struct描述符。同时注册设备驱动程序模型中的分区(例如/sys/block/foo/foo1)。
  5. 注册设备驱动程序模型的请求队列描述符中内嵌的kobject结构(例如/sys/block/foo/queue)。一旦add_disk()返回,设备驱动程序就可以工作了。进行初始化的函数终止;策略例程和中断处理程序开始处理I/O调度程序传送给设备驱动程序的每个请求。

策略例程

策略例程是块设备驱动程序的一个函数或一组函数,它与硬件块设备之间相互作用以满足调度队列中所汇集的请求。通过请求队列描述符中的request_fn方法可以调用策略例程——例如前面一节介绍的foo_strategy()函数,I/O调度程序层将请求队列描述符q的地址传递给该函数。如前所述,在把新的请求插入到空的请求队列后,策略例程通常才被启动。只要块设备驱动程序被激活,就应该对队列中的所有请求都进行处理,直到队列为空才结束。策略例程的简单实现如下:对于调度队列中的每个元素,与块设备控制器相互作用共同为请求服务,等待直到数据传送完成,然后把已经服务过的请求从队列中删除,继续处理调度队列中的下一个请求。这种实现效率并不高。即使假设可以使用DMA传送数据,策略例程在等待I/O操作完成的过程中也必须自行挂起。也就是说策略例程应该在一个专门的内核线程上执行(我们不想处罚毫不相关的用户进程)。而且,这样的驱动程序也不能支持可以一次处理多个I/O数据传送的现代磁盘控制器。因此,很多块设备驱动程序都采用如下策略:

  1. 策略例程处理队列中的第一个请求并设置块设备控制器,以便在数据传送完成时可以产生一个中断。然后策略例程就终止。
  2. 当磁盘控制器产生中断时,中断控制器重新调用策略例程(通常是直接的,有时也通过激活一个工作队列)。策略例程要么为当前请求再启动一次数据传送,要么当请求的所有数据块已经传送完成时,把该请求从调度队列中删除然后开始处理下一个请求。
    请求是由几个bio结构组成的,而每个bio结构又是由几个段组成的。基本上,块设备驱动程序以以下两种方式使用DMA:
  3. 驱动程序建立不同的DMA传送方式,为请求的每个bio结构中的每个段进行服务。
  4. 驱动程序建立一种单独的分散-聚集DMA传送方式,为请求的所有bio中的所有段服务。
    最后,设备驱动程序策略例程的设计依赖块控制器的特性。每个物理块设备都有不同于其他物理块设备的固有特性(例如,软盘驱动程序把磁道上的块分组为磁道,一次单独的I/O操作传送整个磁道),因此对设备驱动程序怎样为每个请求进行服务而做一般假设并没有多大意义。

在我们的例子中,foo_strategy()策略例程应该执行以下操作:

  1. 通过调用I/O调度程序的辅助函数elv_next_request()从调度队列中获取当前的请求。如果调度队列为空,就结束这个策略例程:req = elv_next_request(q); if(!req) return;
  2. 执行blk_fs_request宏检查是否设置了请求的REQ_CMD标志,也即,请求是否包含一个标准的读或写操作:
if(!blk_fs_request(req))
	goto handle_special_request;
  1. 如果块设备控制器支持分散-聚集DMA,那么对磁盘控制器进行编程,以便为整个请求执行数据传送并在传送完成时产生一个中断。blk_rq_map_sg()辅助函数返回一个可以立即被用来启动数据传送的分散-聚集链表。
  2. 否则,设备驱动程序必须一段一段地传送数据。在这种情形下,策略例程执行rq_for_each_bio和bio_for_each_segment两个宏,分别遍历bio链表和每个bio中的段链表。
rq_for_each_bio(bio, rq)
bio_for_each_segment(bvec, bio, i) {
	/* transfer the i-th segment bvec */
	local_irq_save(flags);
	addr = kmap_atomic(bvec->bv_page, KM_BIO_SRC_IRQ);
	foo_start_dma_transfer(addr + bvec->bv_offset, bvec->bv_len);
	kunmap_atomic(bvec->bv_page, KM_BIO_SRC_IRQ);
	local_irq_restore(flags);

如果要传送的数据在高端内存中,那么kmap_atomic()和kunmap_atomic()两个函数就是必需的。foo_start_dma_transfer()函数对硬件设备进行编程,以便启动DMA数据传送并在I/O操作完成时产生一个中断。
5. 返回。

中断处理程序

块设备驱动程序的中断处理程序是在DMA数据传送结束时被激活的。它检查是否已经传送完请求的所有数据块。如果是,中断处理程序就调用策略例程处理调度队列中的下一个请求。否则,中断处理程序更新请求描述符的相应字段并调用策略例程处理还没有完成的数据传送。我们的设备驱动程序foo的中断处理程序的一个典型片段如下:

irqreturn_t foo_interrupt(int irq, void *dev_id, struct pt_regs *regs)
{
	struct foo_dev_t *p =(struct foo_dev_t *)dev_id;
	struct request_queue *rq= p->gd->rq;
	[...]
	if(!end_that_request_first(rq,uptodate,nr_sectors)){
		blkdev_dequeue_request(rq);
		end_that_request_last(rq);
	}
	rq->request_fn(rq);
	[...]
	return IRQ_HANDLED;
}

end_that_request_first()和end_that_request_last()两个函数共同承担结束一个请求的任务。end_that_request_first()函数接收的参数为一个请求描述符、一个指示DMA数据传送成功完成的标志以及DMA所传送的扇区数(end_that_request_chunk()函数类似,只不过该函数接收的是传送的字节数而不是扇区数)。本质上,它扫描请求中的bio 结构以及每个bio中的段,然后采用如下方式更新请求描述符的字段值:

  1. 修改bio字段使其指向请求中的第一个未完成的bio结构。
  2. 修改未完成bio结构的bi_idx字段使其指向第一个未完成的段。
  3. 修改未完成段的bv_offset和bv_len两个字段使其指定仍需传送的数据。该函数也在每个已经完成数据传送的bio结构上调用bio_endio()函数。如果已经传送完请求中的所有数据块,那么end_that_request_first()返回0;否则返回1。如果返回值是1,则中断处理程序重新调用策略例程,继续处理该请求。否则,中断处理程序把请求从请求队列中删除(主要由blkdev_dequeue_request()完成),然后调用end_that_request_last()辅助函数,并再次调用策略例程处理调度队列中的下一个请求。end_that_request_last()函数的功能是更新一些磁盘使用统计数,把请求描述符从I/O调度程序rq->elevator的调度队列中删除,唤醒等待请求描述符完成的任一睡眠进程,并释放删除的那个描述符。

打开块设备文件

通过描述打开一个块设备文件时VFS所执行的操作,我们将总结本章的内容。每当一个文件系统被映射到磁盘或分区上时,每当激活一个交换分区时,每当用户态进程向块设备文件发出一个open()系统调用时,内核都会打开一个块设备文件。在所有情况下,内核本质上执行相同的操作:寻找块设备描述符(如果块设备没有在使用,那么就分配一个新的描述符),为即将开始的数据传送设置文件操作方法。它的f_op字段设置为表def_blk_fops的地址,该表的内容如表14-10所示。
深入理解linux内核--块设备驱动程序_第26张图片
深入理解linux内核--块设备驱动程序_第27张图片
我们仅仅考虑open方法,它由dentry_open()函数调用。blkdev_open()接收inode和filp作为其参数,它们分别存放了索引节点和文件对象的地址;该函数本质上执行下列操作:

  1. 执行bd_acquire(inode)从而获得块设备描述符bdev的地址。该函数接收索引节点对象的地址并执行下列主要步骤:
    a. 检查索引节点对象的inode->i_bdev字段是否不为NULL;如果是,表明块设备文件已经打开了,该字段存放了相应块描述符的地址。在这种情况下,增加与块设备相关联的bdev特殊文件系统的inode->i_bdev->bd_inode索引节点的引用计数器的值,并返回描述符inode->i_bdev的地址。
    b. 块设备文件没有被打开的情况。根据块设备文件的主设备号和次设备号,执行bdget( inode->i_rdev)获取块设备描述符的地址。如果描述符不存在,bdget()就分配一个;但是,要注意的是描述符可能已经存在,例如其他块设备文件已经访问了该块设备。
    c. 将块设备描述符的地址存放在inode->i_bdev中,以便加速将来对相同块设备文件的打开操作。
    d. 将inode->i_mapping字段设置为bdev索引节点中相应字段的值。该字段指向地址空间对象。
    e. 把索引节点插入到由bdev->bd_inodes确立的块设备描述符的已打开索引节点链表中。
    f. 返回描述符bdev的地址。
  2. 将filp->i_mapping字段设置为inode->i_mapping的值(参见前面的第1d步)。
  3. 获取与这个块设备相关的gendisk描述符的地址:disk = get_gendisk(bdev->bd_dev, &part);如果被打开的块设备是一个分区,则返回的索引值存放在本地变量part中;否则,part为0。get_gendisk()函数在kobject映射域bdev_map上简单地调用kobj_lookup()来传递设备的主设备号和次设备号。
  4. 如果bdev->bd_openers的值不等于0,表明块设备已经被打开了。检查bdev->bd_contains字段:
    a. 如果值等于bdev,那么块设备是一个整盘:调用块设备方法bdev->bd_disk->fops->open(如果定义了),然后检查bdev->bd_invalidated字段的,如果需要,调用rescan_partitions()函数。
    b. 如果不等于bdev,那么块设备是一个分区:增加bdev->bd_contains->bd_part_count计数器的值。然后跳到第8步。
  5. 这里块设备是第一次被访问。初始化bdev->bd_disk为gendisk描述符的地址disk。
  6. 如果块设备是一个整盘(part等于0),则执行下列子步骤:
    a. 如果定义了disk->fops->open块设备方法,就执行它:该方法是由块设备驱动程序定义的定制函数,它执行任何特定的最后一分钟初始化。
    b. 从disk->queue请求队列的hardsect_size字段中获取扇区大小(字节数),使用该值适当地设置bdev->bd_block_size和bdev->bd_inode->i_blkbits两个字段。同时用从disk->capacity中计算来的磁盘大小设置bdev->bd_inode->i_size字段。
    c. 如果设置了bdev->bd_invalidated标志,那么调用rescan_partitions()扫描分区表并更新分区描述符。该标志是由check_disk_change块设备方法设置的,仅适用于可移动设备。
  7. 否则如果块设备是一个分区,则执行下列子步骤:
    a. 再次调用bdget()——这次是传递disk->first_minor次设备号——获取整盘的块描述符地址whole。
    b. 对整盘的块设备描述符重复第3步~第6步,如果需要则初始化该描述符。
    c. 将bdev->bd_contains设置为整盘描述符的地址。
    d. 增加whole->bd_part_count的值从而说明磁盘分区上新的打开操作。
    e. 用disk->part[part - 1]中的值设置bdev->bd_part;它是分区描述符hd_struct的地址。同样,执行kobject_get(&bdev->bd_part->kobj)增加分区引用计数器的值。
    f. 与第6b步中的一样,设置索引节点中表示分区大小和扇区大小的字段。
  8. 增加bdev->bd_openers计数器的值。
  9. 如果块设备文件以独占方式被打开(设置了filp->f_flags中的O_EXCL标志),那么调用bd_claim( bdev, filp)设置块设备的持有者。万一出错——块设备已经有一个拥有者——释放该块设备描述符并返回一个错误码-EBUSY。
  10. 返回0(成功)终止。

blkdev_open()函数一旦终止,open()系统调用如往常一样继续进行。对已打开的文件上将来发出的每个系统调用都将触发一个缺省的块设备文件操作。

你可能感兴趣的:(3.4.系统-Linux实现,块设备驱动,I/O调度,请求队列,驱动例程与中断处理)