块设备驱动程序上的每个操作都涉及很多内核组件;其中最重要的一些如图14-1所示。例如,我们假设一个进程在某个磁盘文件上发出一个read()系统调用——我们将会看到处理write请求本质上采用同样的方式。下面是内核对进程请求给予回应的一般步骤:
例如,图14-2显示了一个具有4096字节的页的构造。上层内核组件将页看成是由4个1024字节组成的块缓冲区。块设备驱动程序正在传送页中的后3个块,因此这3块被插入到涵盖了后3072 字节的段中。硬盘控制器将该段看成是由6个512字节的扇区组成。
本章我们介绍处理块设备的下层内核组件:通用块层、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传送,块设备驱动程序需要向磁盘控制器发送:
通过这种合并方式产生的内存区称为硬件段。由于我们将注意力集中在80×86体系结构上,它在总线地址和物理地址之间不存在动态的映射,因此在本章剩余部分我们假定硬件段总是对应物理段。
通用块层是一个内核组件,它处理来自系统中的所有块设备发出的请求。由于该层所提供的函数,内核可以容易地做到:
通用块层的核心数据结构是一个称为bio的描述符,它描述了块设备的I/O操作。每个bio结构都包含一个磁盘存储区标识符(存储区中的起始扇区号和扇区数目)和一个或多个描述与I/O操作相关的内存区的段。bio由bio数据结构描述,其各字段如表14-1 所示。
bio中的每个段是由一个bio_vec数据结构描述的,其中各字段如表14-2所示。bio中的bi_io_vec字段指向bio_vec数据结构的第一个元素,bi_vcnt字段则存放了bio_vec数组中当前的元素个数。
在块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所示。
flags字段存放了关于磁盘的信息。其中最重要的标志是GENHD_FL_UP;如果设置它,那么磁盘将被初始化并可以使用。另一个相关的标志是GENHD_FL_REMOVABLE,如果是诸如软盘或光盘这样可移动的磁盘,那么就要设置该标志。gendisk对象的fops字段指向一个表block_device_operations,该表为块设备的主要操作存放了几个定制的方法(如表14-4所示)。
通常硬盘被划分成几个逻辑分区。每个块设备文件要么代表整个磁盘,要么代表磁盘中的某一个分区。例如,一个主设备号为3、次设备号为0的设备文件/dev/hda代表的可能是一个主EIDE磁盘;该磁盘中的前两个分区分别由设备文件/dev/hdal和/dev/hda2 代表,它们的主设备号都是3,而次设备号分别为1和2。一般而言,磁盘中的分区是由连续的次设备号来区分的。如果将一个磁盘分成了几个分区,那么其分区表保存在hd_struct结构的数组中,该数组的地址存放在gendisk对象的part字段中。通过磁盘内分区的相对索引对该数组进行索引。hd_struct描述符中的字段如表14-5所示。
当内核发现系统中一个新的磁盘时(在启动阶段,或将一个可移动介质插入一个驱动器中时,或在运行期附加一个外置式磁盘时),就调用alloc_disk()函数,该函数分配并初始化一个新的gendisk对象,如果新磁盘被分成了几个分区,那么alloc_disk()还会分配并初始化一个适当的hd_struct类型的数组。然后,内核调用add_disk()函数将新的gendisk对象插入到通用块层的数据结构中。
我们介绍一下当向通用块层提交一个I/O操作请求时,内核所执行的步骤顺序。我们假设被请求的数据块在磁盘上是相邻的,并且内核已经知道了它们的物理位置。第一步是执行bio_alloc()函数分配一个新的bio描述符。然后,内核通过设置一些字段值来初始化bio描述符:
一旦bio描述符被进行了适当的初始化,内核就调用generic_make_request()函数,它是通用块层的主要入口点。该函数主要执行下列操作:
虽然块设备驱动程序一次可以传送一个单独的扇区,但是块I/O层并不会为磁盘上每个被访问的扇区都单独执行一次I/O操作;这会导致磁盘性能的下降,因为确定磁盘表面上扇区的物理位置是相当费时的。取而代之的是,只要可能,内核就试图把几个扇区合并在一起,并作为一个整体来处理,这样就减少了磁头的平均移动时间。当内核组件要读或写一些磁盘数据时,实际上创建一个块设备请求。从本质上说,请求描述的是所请求的扇区以及要对它执行的操作类型(读或写)。然而,并不是请求一发出,内核就满足它——I/O操作仅仅被调度,执行会向后推迟。这种人为的延迟是提高块设备性能的关键机制。
当请求传送一个新的数据块时,内核检查能否通过稍微扩展前一个一直处于等待状态的请求而满足新请求(也就是说,能否不用进一步的寻道操作就能满足新请求)。由于磁盘的访问大都是顺序的,因此这种简单机制就非常高效。延迟请求复杂化了块设备的处理。
例如,假设某个进程打开了一个普通文件,然后,文件系统的驱动程序就要从磁盘读取相应的索引节点。块设备驱动程序把这个请求加入一个队列,并把这个进程挂起,直到存放索引节点的块被传送为止。然而,块设备驱动程序本身不会被阻塞,因为试图访问同一磁盘的任何其他进程也可能被阻塞。为了防止块设备驱动程序被挂起,每个I/O操作都是异步处理的。
特别是块设备驱动程序是中断驱动的;通用块层调用I/O调度程序产生一个新的块设备请求或扩展一个已有的块设备请求,然后终止。随后激活的块设备驱动程序会调用一个所谓的策略例程(strategy routine)选择一个待处理的请求,并向磁盘控制器发出一条适当的命令来满足这个请求。
当I/O操作终止时,磁盘控制器就产生一个中断,如果需要,相应的中断处理程序就又调用策略例程去处理队列中的另一个请求。 每个块设备驱动程序都维持着自己的请求队列,它包含设备待处理的请求链表。如果磁盘控制器正在处理几个磁盘,那么通常每个物理块设备都有一个请求队列。在每个请求队列上单独执行I/O调度,这样可以提高磁盘的性能。
请求队列是由一个大的数据结构request_queue表示的,其字段如表14-6所示。
实质上,请求队列是一个双向链表,其元素就是请求描述符(也就是request数据结构)。请求队列描述符中的queue_head字段存放链表的头(第一个伪元素),而请求描述符中queuelist字段的指针把任一请求链接到链表的前一个和后一个元素之间。
队列链表中元素的排序方式对每个块设备驱动程序是特定的;然而,I/O调度程序提供了几种预先确定好的元素排序方式,这将在后面的“I/O调度算法”一节中讨论。backing_dev_info字段是一个backing_dev_info类型的小对象,它存放了关于基本硬件块设备的I/O数据流量的信息。例如,它保存了关于预读以及关于请求队列拥塞状态的信息。
每个块设备的待处理请求都是用一个请求描述符来表示的,请求描述符存放在如表14-7所示的request数据结构中。
每个请求包含一个或多个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,它确定数据传送的方向。
在重负载和磁盘操作频繁的情况下,固定数目的动态空闲内存将成为进程想要把新请求加入请求队列q的瓶颈。为了解决这种问题,每个request_queue描述符包含一个request_list数据结构,其中包括:
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方法来开始处理请求队列中的下一个请求。
当向请求队列增加一条新的请求时,通用块层会调用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结构增加到已存在请求中,如果需要,还要合并两个“相邻的”请求。
这是最简单的I/O调度算法。它没有排序的队列:新的请求通常被插在调度队列的开头或末尾,下一个要处理的请求总是队列中的第一个请求。
“CFQ(完全公平队列)”算法的主要目标是在触发I/O请求的所有进程中确保磁盘I/O 带宽的公平分配。为了达到这个目标,算法使用许多个排序队列,它们存放了不同进程发出的请求。
当算法处理一个请求时,内核调用一个散列函数将当前进程的线程组标识符(通常,它对应其PID,参见第三章“标识一个进程”一节)转换为队列的索引值;然后,算法将一个新的请求插入该队列的末尾。因此,同一个进程发出的请求通常被插入相同的队列中。为了再填充调度队列,算法本质上采用轮询方式扫描I/O输入队列,选择第一个非空队列,然后将该队列中的一组请求移动到调度队列的末尾。
除了调度队列外,“最后期限”算法还使用了四个队列。其中的两个排序队列分别包含读请求和写请求,其中的请求是根据起始扇区数排序的。另外两个最后期限队列包含相同的读和写请求,但这是根据它们的“最后期限”排序的。引入这些队列是为了避免请求饿死,由于电梯策略优先处理与上一个所处理的请求最近的请求,因而就会对某个请求忽略很长一段时间,这时就会发生这种情况。请求的最后期限本质上就是一个超时定时器,当请求被传给电梯算法时开始计时。
缺省情况下,读请求的超时时间是500ms,写请求的超时时间是5s——读请求优先于写请求,因为读请求通常阻塞发出请求的进程。最后期限保证了调度程序照顾等待很长一段时间的那个请求,即使它位于排序队列的末尾。当算法要补充调度队列时,首先确定下一个请求的数据方向。如果同时要调度读和写两个请求,算法会选择“读”方向,除非该“写”方向已经被放弃很多次了(为了避免写请求饿死)。接下来,算法检查与被选择方向相关的最后期限队列:如果队列中的第一个请求的最后期限已用完,那么算法将该请求移到调度队列的末尾;也可以从超时的那个请求开始移动来自排序队列的一组请求。如果将要移动的请求在磁盘上物理相邻,那么组的长度会变长,否则就变短。
最后,如果没有请求超时,算法对来自于排序队列的最后一个请求之后的一组请求进行调度。当指针到达排序队列的末尾时,搜索又从头开始(“单方向算法”)。
“预期”算法是Linux提供的最复杂的一种I/O调度算法。基本上,它是“最后期限”算法的一个演变,借用了“最后期限”算法的基本机制:两个最后期限队列和两个排序队列;I/O调度程序在读和写请之间交互扫描排序队列,不过更倾向于读请求。扫描基本上是连续的,除非有某个请求超时。读请求的缺省超时时间是125ms,写请求的缺省超时时间是250ms。但是,该算法还遵循一些附加的启发式准则:
正如我们在本章前面的“提交请求”一节中所看到的,generic_make_request()函数调用请求队列描述符的make_request_fn方法向I/O调度程序发送一个请求。通常该方法是由__make_request()函数实现的;该函数接收一个request_queue类型的描述符q和一个bio结构的描述符bio作为其参数,然后执行如下操作:
如果在调用__make_request()函数之前请求队列不是空的,那么说明该请求队列要么已经被拔掉过,要么很快将被拔掉——因为每个拥有待处理请求的插入请求队列q都有一个正在运行的动态定时器q->unplug_timer。另一方面,如果请求队列是空的,则__make_request()函数插入请求队列。或迟(最坏的情况是当拔出的定时器到期了)或早(从__make_request()中退出时,如果设置了bio的BIO_RW_SYNC标志),该请求队列都会被拔掉。任何情形下,块设备驱动程序的策略例程最后都将处理调度队列中的请求。
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的值时,执行下列操作
块设备驱动程序是Linux块子系统中的最底层组件。它们从I/O调度程序中获得请求,然后按要求处理这些请求。当然,块设备驱动程序是设备驱动程序模型的组成部分。因此,每个块设备驱动程序对应一个device_driver类型的描述符;此外,设备驱动程序处理的每个磁盘都与一个device描述符相关联。但是,这些描述符没有什么特别的:块I/O子系统必须为系统中的每个块设备存放附加信息。
一个块设备驱动程序可能处理几个块设备。例如,IDE设备驱动程序可以处理几个IDE 磁盘,其中的每个都是一个单独的块设备。而且,每个磁盘通常是被分区的,每个分区又可以被看作是一个逻辑块设备。很明显,块设备驱动程序必须处理在块设备对应的块设备文件上发出的所有VFS系统调用。每个块设备都是由一个block_device结构的描述符来表示的,其字段如表14-9所示。
所有的块设备描述符被插入一个全局链表中,链表首部是由变量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看作不同的文件,但是它们实际上指向同一个块设备。因此,内核无法通过简单地在一个对象的索引节点高速缓存中检查块设备文件的存在就确定相应的块设备已经在使用。
主、次设备号和相应的块设备描述符之间的关系是通过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描述符的一些字段:
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描述符的地址作为其参数,主要执行下列操作:
策略例程是块设备驱动程序的一个函数或一组函数,它与硬件块设备之间相互作用以满足调度队列中所汇集的请求。通过请求队列描述符中的request_fn方法可以调用策略例程——例如前面一节介绍的foo_strategy()函数,I/O调度程序层将请求队列描述符q的地址传递给该函数。如前所述,在把新的请求插入到空的请求队列后,策略例程通常才被启动。只要块设备驱动程序被激活,就应该对队列中的所有请求都进行处理,直到队列为空才结束。策略例程的简单实现如下:对于调度队列中的每个元素,与块设备控制器相互作用共同为请求服务,等待直到数据传送完成,然后把已经服务过的请求从队列中删除,继续处理调度队列中的下一个请求。这种实现效率并不高。即使假设可以使用DMA传送数据,策略例程在等待I/O操作完成的过程中也必须自行挂起。也就是说策略例程应该在一个专门的内核线程上执行(我们不想处罚毫不相关的用户进程)。而且,这样的驱动程序也不能支持可以一次处理多个I/O数据传送的现代磁盘控制器。因此,很多块设备驱动程序都采用如下策略:
在我们的例子中,foo_strategy()策略例程应该执行以下操作:
if(!blk_fs_request(req))
goto handle_special_request;
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中的段,然后采用如下方式更新请求描述符的字段值:
通过描述打开一个块设备文件时VFS所执行的操作,我们将总结本章的内容。每当一个文件系统被映射到磁盘或分区上时,每当激活一个交换分区时,每当用户态进程向块设备文件发出一个open()系统调用时,内核都会打开一个块设备文件。在所有情况下,内核本质上执行相同的操作:寻找块设备描述符(如果块设备没有在使用,那么就分配一个新的描述符),为即将开始的数据传送设置文件操作方法。它的f_op字段设置为表def_blk_fops的地址,该表的内容如表14-10所示。
我们仅仅考虑open方法,它由dentry_open()函数调用。blkdev_open()接收inode和filp作为其参数,它们分别存放了索引节点和文件对象的地址;该函数本质上执行下列操作:
blkdev_open()函数一旦终止,open()系统调用如往常一样继续进行。对已打开的文件上将来发出的每个系统调用都将触发一个缺省的块设备文件操作。