字符驱动程序的接口相对清晰而且易于使用,但相反的是, 块驱动程序的接口要稍微复杂一些,内核开发人员为此经常心生抱怨。出现这种情况的原因有两个:其一是因为其简单的历史――块驱动程序接口从 Linux 第一个版本开始就一直存在于每个版本的核心,并且已经证明很难修改或改进;其二是因为性能,一个慢字符设备驱动程序虽然不受欢迎,但仍可以接受,但一个慢 的块驱动程序将影响整个系统的性能。因此,块驱动程序的接口设计经常受到速度要求的影响。
在 Linux 内核开发过程中,块驱动程序接口发生过重大的演变。和本书其余部分一样,本章将主要讲述 2.4 内核版本中的接口,而在最后讨论与其它早期版本之间的区别。但需要说明的是,本章的示例驱动程序能够在 2.0 和 2.4 之间的任意一个内核上运行。
本 章利用两个新的示例驱动程序讲述块驱动程序的创建。第一个称为 sbull(Simple Block Utility for Loading Localities),该驱动程序实现了一个使用系统内存的块设备,从本质上讲,属于一种 RAM 磁盘驱动程序。随后,我们将介绍该驱动程序的变种,称为 spull,该驱动程序说明了如何处理分区表。
上述示例驱动程序避免了许多实际的块驱动程序会遇到的问题,其目的主要是为了演示这类驱动程序必须处理的接口。实际的驱动程序需要处理复杂的硬件,因此,第 8 章和第 9 章中的内容会对读者有所帮助。
这里,我们需要做一点技术上的说明:本书所使用的“块”这一术语,指的是由内核决定的一个数据块。通常来讲,块的大小是 2 的幂,但不同的磁盘可能具有不同的块大小。而“扇区”则是由底层硬件决定的一个固定大小的数据单位,一个扇区通常都是 512 字节长。
12.1 注册驱动程序
和字符驱动程序一样,内核使用主设备号来标识块驱动程序,但块主设备号和字符主设备号是互不相干的。一个主设备号为 32 的块设备可以和具有相同主设备号的字符设备同时存在,因为它们具有各自独立的主设备号分配空间。
用来注册和注销块设备驱动程序的函数,与用于字符设备的函数看起来很类似,如下所示:
|
上述函数中的参数意义和字符设备相同,而且可以通过一样的方式动态赋予主设备号。因此,注册 sbull 设备时所使用的方法几乎和 scull 设备一模一样:
|
然 而,类似之处到此为止。我们已经看到了一个明显的不同:register_chrdev 使用一个指向 file_operations 结构的指针,而 register_blkdev 则使用 block_device_operations 结构的指针――这个变化从 2.3.38 版本就有了。在一些块驱动程序中,该接口有时仍然被称为 fops,但我们将称之为 dbops,以便更加贴近该结构本身的含义,并遵循推荐的命名方式。该结构的定义如下:
|
这 里列出的 open、release 和 ioctl 方法和字符设备的对应方法相同。其它两个方法是块设备所特有的,将在本章后面讨论。需要注意的是,该结构中没有 owner(所有者)成员,就算在 2.4 内核当中,我们仍然要手工维护块设备驱动程序的使用计数。
sbull 使用的 bdops 接口定义如下:
|
请 读者注意,block_device_operations 接口中没有 read 或者 write 操作。所有涉及到块设备的 I/O 通常由系统进行缓冲处理(唯一的例外是下一章要讲到的“raw(裸)”设备),用户进程不会对这些设备执行直接的 I/O 操作。在用户模式下对块设备的访问,通常隐含在对文件系统的操作当中,而这些操作能够从 I/O 缓冲当中获得明显的好处。但是,对块设备的“直接”I/O 访问,比如在创建文件系统时的 I/O 操作,也一样要通过 Linux 的缓冲区缓存*。为此,内核为块设备提供了一组单独的读写函数,驱动程序不必理会这些函数。
|
显 然,块驱动程序最终必须提供完成实际块 I/O 操作的机制。在 Linux 当中,用于这些 I/O 操作的方法称为“request(请求)”,它和其它许多 Unix 系统当中的 strategy 函数等价。request 方法同时处理读取和写入操作,因此要复杂一些。我们稍后将详细讲述 request。
但在块设备的注册过程中,我们必须告诉内核实际的 request 方法。然而,该方法并不在 block_device_operations 结构中指定(这出于历史和性能两方面的考虑),相反,该方法和用于该设备的挂起 I/O 操作队列关联在一起。默认情况下,对每个主设备号并没有这样一个对应的队列。块驱动程序必须通过 blk_init_queue 初始化这一队列。队列的初始化和清除接口定义如下:
|
init 函数建立队列,并将该驱动程序的 request 函数(通过第二个参数传递)关联到队列。在模块的清除阶段,应调用 blk_cleanup_queue 函数。sbull 驱动程序使用下面的代码行初始化它的队列:
|
每 个设备有一个默认使用的请求队列,必要时,可使用 BLK_DEFAULT_QUEUE(major) 宏得到该默认队列。这个宏在 blk_dev_struct 结构形成的全局数组(该数组名为 blk_dev)中搜索得到对应的默认队列。blk_dev 数组由内核维护,并可通过主设备号索引。blk_dev_struct 接口定义如下:
|
request_queue 成员包含了初始化之后的 I/O 请求队列,我们将很快看到队列的成员。data 成员可由驱动程序使用,以便保存一些私有数据,但很少有驱动程序使用该成员。
图 12-1 说明了注册和注销一个驱动程序模块时所要执行的主要步骤。如果和图 2-1 相比较,将清楚地看到两者之间的相同点和不同点。
图 12-1:注册块设备驱动程序
除了 blk_dev 之外,还有一些全局数组保存了块设备驱动程序的信息,这些数组通过主设备号索引,有时也通过次设备号索引。这些数组在 drivers/block/ ll_rw_block.c 中声明和描述。
int blk_size[][];
该数组通过主设备号和次设备号索引。它描述了每个设备的大小,以千字节为单位。如果 blk_size[major] 为 NULL,则不会检查该设备的大小(也就是说,内核可以访问超过设备尾部的数据)。
int blksize_size[][];
该 数组包含了每个设备所使用的块大小,以字节为单位。和前述数组一样,该二维数组也通过主设备号和次设备号索引。如果 blksize_size[major] 为空指针,则假定块的尺寸为 BLOCK_SIZE(当前定义为 1 KB)。设备的块大小必须是 2 的幂,这是因为内核使用位移操作将偏移量转换成块编号。
int hardsect_size[][];
和上述数组一样,该数组也通过主设备号和次设备号索引。默认的硬件扇区大小为 512 字节,2.2 和 2.4 内核也支持不同的扇区大小,但必须始终是一个大于或者等于 512 字节的 2 的幂。
int read_ahead[];
int max_readahead[][];
这两个数组定义了在顺序读取一个文件时,内核要预先读入的扇区数目。read_ahead 应用于某个给定类型的所有设备,并由主设备号索引;max_readahead 应用于单独的设备,并由主设备号和次设备号索引。
如果进程在真正读取某个数据之前预先读入该数据,则有助于提高系统性能和整体的吞吐率。在比较慢的设备上,应该指定一个较大的 read-ahead 值,而在较快的设备上应该指定一个较小的值。read-ahead 值越大,缓冲区缓存所使用的内存就越多。
这 两个数组之间的主要不同在于:read_ahead 应用于块 I/O 级,并控制在当前请求之前,应该从磁盘上顺序读入多少数据块;max_readahead 工作在文件系统级,指的是文件中的块,而这些块在磁盘上并不一定是顺序存放的。内核开发正在从块 I/O 级的预读转向文件系统级的预读。但在 2.4 内核中,预读仍然在两个级别完成,因此要同时使用这两个数组。
每个主设备号有一个对应的 read_ahead[] 值,并应用于所有的次设备号,而 max_readahead 对每个设备只有一个值,这些值均可通过设备驱动程序的 iotcl 方法进行改变。硬盘驱动程序通常将 read_ahead 设置为 8 个扇区,对应于 4 KB。相反,max_readahead 值则很少由驱动程序设置,它默认设置为 MAX_READAHEAD,当前为 31 页。
int max_sectors[][];
该数组限制单个请求的最大尺寸。它通常应该设置为硬件所能处理的最大传输尺寸。
int max_segments[];
该数组控制一个集群请求中能够出现的段的数量,但该数组在 2.4 内核发布之前已被删除。(有关集群请求的详细信息,参阅本章“集群请求”一节)。
sbull 设备允许我们在装载期间设置这些值,而且这些值将应用于该示例驱动程序的所有次设备号。sbull 所使用的变量名及其默认值定义如下:
size=2048 (kilobytes)
sbull 所建立的每个 RAM 磁盘使用两兆字节的 RAM。
blksize=1024 (bytes)
该模块所使用的软件“块”大小为一千字节,和系统默认值一样。
hardsect=512 (bytes)
sbull 的扇区大小为通常的 512 字节。
rahead=2 (sectors)
因为 RAM 磁盘是一个快速设备,默认的 read-ahead 值很小。
sbull 设备也允许我们选择要安装的设备个数。devs,即设备个数,默认设置为 2,这样,默认的内存使用为 4 兆字节,因为每个磁盘使用 2 兆字节。
在 sbull 中,上述数组的初始化过程如下:
|
出于简化,错误处理代码(即 goto 语句的 fail_malloc 目标)被忽略了,这段代码其实释放了所有已经成功分配的内存,然后注销设备,并返回一个失败状态。
最后一件事情就是要注册该驱动程序所提供的所有“磁盘”设备。sbull 如下调用 register_disk 函数:
|
在 2.4.0 内核当中,register_disk 函数在上述调用方式下不做任何事情。register_disk 的真正目的是用来设置分区表,但 sbull 并不支持分区。但是,所有的块设备驱动程序都需要调用这个函数,不管它们是否支持分区,这是因为将来可能需要所有的块设备都必须支持分区。在 2.4.0 当中,没有分区的块驱动程序不需要调用这个函数就能工作,但调用该函数更加安全一些。在本章后面讲到分区时,我们将详细讲述 register_disk 函数,
sbull 所使用的清除函数定义如下:
|
这里,对 fsync_dev 函数的调用是必须的,它将释放由内核保存在各种缓存当中的对该设备的引用。fsync_dev 是 block_fsync 的实现,而 block_fsync 则是用于块设备的 fsync 方法。
12.2 头文件 blk.h
所有的块设备驱动程序都应该包含头文件
实 际上,blk.h 头文件有点与众不同,因为它在符号 MAJOR_NR 的基础上定义了若干符号,而 MAJOR_NR 必须在包含该头文件之前由驱动程序声明。这一约定出现在早期的 Linux 当中,而那时所有的块设备必须具有预先确定的主设备号,而不支持模块化的块驱动程序。
如果阅读 blk.h 头文件,将看到许多设备相关的符号是根据 MAJOR_NR 的值声明的,因此,需要预先知道 MAJOR_NR 的值。但是,如果主设备号被动态赋予,驱动程序就无法在编译时知道被赋于的主设备号,因此就不能正确定义 MAJOR_NR。如果 MAJOR_NR 没有被定义,blk.h 就不能正确建立操作请求队列的某些宏。幸运的是,MAJOR_NR 可以被定义为一个整型变量,这样,动态的块设备驱动程序就能正常工作了。
blk.h 还使用了其它一些预先定义的、驱动程序相关的符号。下面描述了包含在
MAJOR_NR
该符号用来访问几个数组,尤其是 blk_dev 和 blksize_size。类似 sbull 这样的定制驱动程序,不能赋于该符号一个固定值,而必须将其 #define 为保存主设备号的变量。对 sbull 而言,该变量为 sbull_major。
DEVICE_NAME
将要创建的设备名称。该字符串用于打印错误信息。
DEVICE_NR(kdev_t device)
该符号用于从 kdev_t 设备编号中获得物理设备的顺序号。该符号还被用来声明 CURRENT_DEV,后者可在 request 函数中使用,用来确定哪个硬件设备拥有与某个数据传输请求相关联的次设备号。
这 个宏的值可以是 MINOR(device) 或者其它表达式,这随着赋于设备和分区以次设备号的方式的不同而不同。对同一物理设备上的所有分区,这个宏应该返回相同的设备编号,也就是 说,DEVICE_NR 代表的是磁盘编号,而不是分区编号。可分区设备将在本章后面介绍。
DEVICE_INTR
该符号用来声明一个指向当前底半处理程序的指针变量。可使用 SET_INTR(intr) 和 CLEAR_INTR 宏来对该变量赋值。当设备需要处理具有不同含义的中断时,使用多个处理程序是很方便的。
DEVICE_ON(kdev_t device)
DEVICE_OFF(kdev_t device)
这 两个宏用来帮助设备在执行一组数据传输之前或之后执行其它附加处理。比如,软盘驱动程序可利用这两个宏在执行 I/O 之前启动驱动电机,或者在执行 I/O 之后关闭电机。现代的驱动程序不再使用这两个宏,而且根本就没有机会去调用 DEVICE_ON。但是,可移植的驱动程序应该定义这两个宏(作为空符号),否则,在 2.0 和 2.2 内核上将出现编译错误。
DEVICE_NO_RANDOM
默 认情况下,end_request 函数对系统熵(收集到的“随机性”总和)起作用,而系统熵将被 /dev/random 用来产生随机数。如果设备不能对随机设备贡献足够多的熵,则应该定义 DEVICE_NO_RANDOM。/dev/random 在第 9 章的“安装中断处理程序”中有过介绍,并解释了SA_SAMPLE_RANDOM。
DEVICE_REQUEST
用来指定驱动程序所使用的 request 函数名称。定义 DEVICE_REQUEST 之后,将立即声明一个 request 函数,除此之外,没有其它效果。这算是一个历史遗留问题,大多数(或者所有)的驱动程序无需考虑这个符号。
sbull 驱动程序如下声明这些符号:
|
blk.h 头文件使用上面列出的宏定义了驱动程序所使用的其它一些宏,在下面的章节当中,我们将描述这些宏。
12.3 请求处理简介
块驱动程序中最重要的函数就是 request 函数,该函数执行数据读写相关的低层操作。这一小节我们将讨论 request 函数的基本设计方法。
12.3.1 请求队列
在内核安排一次数据传输时,它首先在一个表中对该请求排队,并以最大化系统性能为原则进行排序。然后,请求队列被传递到驱动程序的 request 函数,该函数的原型如下:
|
request 函数就队列中的每个请求执行如下任务:
1. 测试请求的有效性。该测试通过定义在 blk.h 中的 INIT_REQUEST 完成,用来检查系统的请求队列处理当中是否出现问题。
2. 执行实际的数据传输。CURRENT 变量(实际是一个宏)可用来检索当前请求的细节信息。CURRENT 是指向 struct request 结构的指针,我们将在下一小节当中描述该结构的成员。
3. 清除已经处理过的请求。该操作由 end_request 函数执行,该函数是一个静态函数,代码位于 blk.h 文件中。end_request 管理请求队列并唤醒等待 I/O 操作的进程。该函数同时管理 CURRENT 变量,确保它指向下一个未处理的请求。驱动程序只给该函数传递一个参数,成功时为 1,失败时为 0。当 end_request 在参数为 0 时调用,则会向系统日志(使用 printk 函数)递交一条“I/O error”消息。
4. 返回开头,开始处理下一条请求。
根据前面的描述,一个并不进行实际数据传输的最小 request 函数,应该如下定义:
|
尽 管上面的代码除了打印信息之外不做任何的事情,但我们能够从这个函数当中看到数据传输代码的基本结构。上述代码还演示了
使用前述 request 函数的块驱动程序马上就能真正工作了。我们可以在该设备上建立一个文件系统,只要数据被保留在系统缓冲区缓存中,我们就可以访问该设备上的数据。
通 过在编译阶段定义 SBULL_EMPTY_REQUEST 符号,我们仍可以在 sbull 中运行这个空的(但却罗嗦的)request 函数。如果读者想理解内核处理不同块大小的方法,可以在 insmod 的命令行尝试使用 blksize= 这个参数。空的 request 函数打印了每个请求的详细信息,借此可以看到内核的内部工作情况。
request 函数有一个非常重要的限制:它必须是原子的。通常,request 并不在响应用户请求时直接调用,并且也不会在任何特定进程的上下文中运行。它可能在处理中断时被调用,也可能从 tasklet 中,或者从其它许多地方调用。这样,在执行其任务时,该函数不能进入睡眠状态。
12.3.2 执行实际的数据传输
为 了理解如何为 sbull 建立一个能工作的 request 函数,首先我们要分析内核是如何在 struct requesst 结构中描述一个请求的。该结构在
|
CURRENT 其实是一个指向 blk_dev[MAJOR_NR].request_queue 的指针。下面描述的这些结构成员保存有 request 函数经常用到的一些信息:
kdev_t rq_dev;
请 求所访问的设备。默认情况下,某个特定驱动程序所管理的所有设备会使用相同的 request 函数。也就是说,单个 request 函数将处理所有的次设备号,这时,rq_dev 可用来表示实际操作的次设备。CURRENT_DEV 宏被简单定义为 DEVICE_NR(CURRENT->rq_dev)。
int cmd;
该成员描述了要执行的操作,它可以是 READ(从设备中读取),或者 WRITE(向设备写入)。
unsigned long sector;
表示本次请求要传输的第一个扇区编号。
unsigned long current_nr_sectors;
unsigned long nr_sectors;
表示当前请求要传输的扇区数目。驱动程序应该使用 current_nr_sectors 而忽略 nr_sectors(该变量只是为了完整性才列在这里)。有关 nr_sectors 的详细描述,可参阅本章后面的“集群请求”一节。
char *buffer;
数据要被写入(cmd==READ),或者要被读出(cmd==WRITE)的缓冲区缓存区域。
struct buffer_head *bh;
该结构描述了本次请求对应的缓冲区链表的第一个缓冲区,即缓冲区头。缓冲区头在进行缓冲区缓存管理时使用,我们稍后将在“请求结构和缓冲区缓存”中详细描述。
该结构中还有其它一些成员,但绝大部分由内核内部使用,驱动程序没有必要使用这些成员。
sbull 设备中能够完成实际工作的 request 函数列在下面。在下面的代码中,Sbull_Dev 和第 3 章的“scull 的内存使用”中介绍的 Scull_Dev 的功能相同。
|
上面的代码和前面给出的空 request 函数几乎没有什么不同,该函数本身集中于请求队列的管理上,而将实际的工作交给其它函数完成。第一个函数是 sbull_locate_device,检索请求当中的设备编号,并找出正确的 Sbull_Dev 结构:
|
该 函数唯一“陌生”的功能是限制打印五次错误的条件语句。这是为了避免在系统日志当中生成太多的消息,因为 end_request(0) 本身会在请求失败时打印一条“I/O error”消息。静态的计数器(变量 count)是内核中经常用到的用来限制消息打印的一个标准方法。
请求的实际 I/O 由 sbull_transfer 函数完成:
|
因为 sbull 只是一个 RAM 磁盘,因此,该设备的“数据传输”只是一个 memcpy 调用而已。
12.4 请求处理详解
先 前讲述的 sbull 驱动程序能够很好地工作。在类似 sbull 这样的简单情形下,可使用
12.4.1 I/O 请求队列
每个块驱动程序至少拥有一个 I/O 请求队列。在任意给定时刻,该队列包含了内核想在该驱动程序的设备上完成的所有 I/0 操作。该队列的管理是复杂的,因此,系统性能依赖于队列的管理方式。
该队列是根据物理磁盘驱动器设计的。在磁盘中,传输一个数据块所需要的时间总量通常相对较短,但定位磁头到达传输位置(seek)的操作所需的时间量却往往很长。这样,Linux 内核要试图最小化设备定位的次数和长度。
为 了达到这个目标,需要完成两件事情。其一,需要将请求集群到相邻的磁盘扇区上。大部分现代的文件系统会试图将文件保存在连续的扇区上,这样,位于磁盘相邻 部分的请求将会很多。其二,内核在处理请求时使用“电梯”算法。摩天大楼中的电梯不是升就是降,而且在满足所有“请求”(乘客上下)之前,不会改变移动的 方向。和电梯一样,内核也试图尽可能在一个方向移动磁头,这个方法在保证所有的请求最终被满足的同时,趋向于最小化定位时间。
Linux 的每个 I/O 请求队列由一个 request_queue 类型的结构表示,该结构在
这些请求具有 request 结构类型,前面我们已经提到过该结构中的一些成员。request 结构实际上要更加复杂一些,但是,要理解这个结构,首先要理解 Linux 的缓冲区缓存结构。
request 结构和缓冲区缓存
request 结构的设计和 Linux 内存管理方法有关。类似大部分的类 Unix 系统,Linux 维护一个缓冲区缓存,它是一个内存区域,保存有磁盘数据块的复本。在内核的更高级别,会执行大量的“磁盘”操作(比如在文件系统部分的代码中),但这些操 作只在缓冲区缓存上进行,却不会生成任何实际的 I/O 操作。通过主动缓存,内核能够避免许多读取操作,而且多个写入操作也经常可以合并为单个物理的磁盘写操作。
但是,缓冲区缓存不能避免的 方面是,磁盘上相邻的数据块,在内存中肯定不会是相邻的。缓冲区缓存是一个动态的东西,最终,内存中的数据块将大大分散。为了跟踪所有的事情,内核通过 buffer_head 结构管理缓冲区缓存,每个数据缓冲区关联有一个 buffer_head,该结构包含有大量的成员,但大部分成员和驱动程序编写者无关。但是,其中还是有一些重要的成员,如下所示:
char *b_data;
与该缓冲区头相关联的实际数据块。
unsigned long b_size;
b_data 所指向的数据块大小。
kdev_t b_rdev;
该缓冲区头所代表的数据块所在的设备。
unsigned long b_rsector;
该数据块在磁盘上的扇区编号。
struct buffer_head *b_reqnext;
指向请求队列中缓冲区头结构链表的指针,
void (*b_end_io)(struct buffer_head *bh, int uptodate);
指向一个函数的指针,当该缓冲区上的 I/O 操作结束时将调用这个函数。bh 是缓冲区头本身,而 uptodate 在 I/O 成功时取非零值。
传 递到驱动程序 request 函数中的每个数据块,要么保存在缓冲区缓存中,要么在极少的情况下保存在其它地方,但是却要使其看起来保存在缓冲区缓存中*。这样,传递到驱动程序的每个 请求处理一个或更多的 buffer_head 结构。request 结构包含有一个称为 bh 的成员,该成员指向由这些结构组成的一个链表。为满足该请求,需要在该链表的每个缓冲区上执行指定的 I/O 操作。图 12-2 描述了请求队列和 buffer_head 结构之间的关系。
|
图 12-2:I/O 请求队列当中的缓冲区
请 求并不是由随机的缓冲区链表组成的,相反,所有关联到某单个请求的缓冲区头属于磁盘上一系列相邻的数据块。这样,在某种意义上,一个请求将是针对磁盘上一 组(也许很长)数据块的单个操作。分组之后的数据块称为“集群”,在结束请求链表的讨论之后,我们将详细讨论集群请求。
操作请求队列
在 头文件
struct request *blkdev_entry_next_request(struct list_head *head);
返回请求链表中的下一个入口。通常,head 参数是 request_queue 结构的 queue_head 成员,这种情况下,该函数返回队列中的第一个入口。该函数使用 list_entry 宏在链表中执行检索。
struct request *blkdev_next_request(struct request *req);
struct request *blkdev_prev_request(struct request *req);
给定一个 request 结构,返回请求队列中的下一个或者前一个结构。
blkdev_dequeue_request(struct request *req);
从请求队列中删除一个请求。
blkdev_release_request(struct request *req);
在 一个请求被完整执行后,将该 request 结构释放给内核。每个请求队列维护有它自己的空闲 request 结构链表(实际上有两个:一个用于读取,一个用于写入),该函数把要释放的 request 结构放回对应的空闲链表。blkdev_release_request 同时会唤醒任何等待在空闲请求结构上的进程。
上述所有函数均需要拥有 io_request_lock,下面我们将讨论这个请求锁。
I/O 请求锁
I/O 请求队列是一个复杂的数据结构,内核许多地方都要访问该结构。当你的驱动程序正在删除一个请求的同时,内核完全有可能正要往队列中添加更多的请求。因此,该队列成为通常所说的竞态,为此,必须采取适当的保护措施。
在 Linux 2.2 和 2.4 中,所有的请求队列通过一个单独的全局自旋锁 io_request_lock 来保护。所有需要操作请求队列的代码,都必须拥有该锁并禁止中断,只有一个小的例外:请求队列当中的第一个入口(默认情况下)被认为是由驱动程序所拥有。 在操作请求队列之前未获取 io_request_lock,将导致该队列被破坏,随之而来的会是系统的崩溃。
前面那个简单的 request 函数不需要考虑这个锁,因为内核在调用 request 函数的时候已经获取了 io_request_lock。这样,驱动程序就不会破坏请求队列,同时,也避免了对 request 函数的重入调用。这种方法保证了未考虑 SMP 的驱动程序能够在多处理器系统上正常工作。
然而,我们需要注意的是,io_request_lock 是一个昂贵的资源。在驱动程序拥有这个锁的同时,其它任何请求都不能排队到系统中的任何块设备上,也不会有其它任何 request 函数被调用。长时间拥有这个锁的驱动程序将最终降低整个系统的运行速度。
因 此,好的块驱动程序经常在尽可能短的时间内释放这个锁,我们马上会看到这种操作的示例。但是,主动释放 io_request_lock 的块驱动程序必须处理两个重要的事情。首先,在 request 函数返回之前,必须重新获得该锁,因为调用 request 的代码期望 request 仍然拥有这个锁。另外一个需要注意的是,一旦 io_request_lock 被释放,对该 request 函数的重入调用就可能发生,因此,该函数必须能够处理这种可能性。
后面这种情况也可能在另外一种情形下发生,即当某个 I/O 请求仍然活动(正在被处理)的情况下,request 函数返回。许多针对实际硬件的驱动程序会启动一个 I/O 操作然后返回,而该操作将会在驱动程序的中断处理程序中完成。在本章后面我们将详细讨论中断驱动的块 I/O,但此处仍然要提醒读者,request 函数可能在这些操作正在进行时被调用。
许多驱动程序通过维护一个内部的请求队列来处理 request 函数的重入性。request 函数只是简单地将新请求从 I/O 请求队列当中删除,并将这些请求添加到内部的队列,然后,通过组合 tasklet 和中断处理程序来处理这个内部队列。
blk.h 中的宏和函数是如何工作的
在 我们前面那个简单的 request 函数中,我们没有考虑 buffer_head 结构或者链表。
我们早先看到的 request 结构的几个成员,即 sector、current_nr_sectors 以及 buffer,实际是保存在该链表第一个 buffer_head 结构中类似信息的复本。这样,一个通过 CURRENT 指针使用这些信息的 request 函数,实际处理的是该请求当中可能存在的许多缓冲区的第一个。将多个缓冲区请求分离成表面上独立的单个缓冲区请求的任务,由
其中,INIT_REQUEST 更为简单一些,它所做的一切工作,实际就是在请求队列上完成几个一致性检查,并且在队列为空时从 request 函数中返回。它仅仅确保还要进行其它的处理工作。
大量的队列管理工作由 end_request 完成。需要记住的是,该函数在驱动程序处理完单个“请求”(实际是一个缓冲区)时调用,它要执行如下几个任务:
1. 完成当前缓冲区上的 I/O 处理。它传递当前操作的状态并调用 b_end_io 函数,该函数将唤醒任何睡眠在该缓冲区上的进程。
2. 从请求的链表中删除该缓冲区。如果还有其它缓冲区需要处理,request 结构中的 sector、current_nr_sectors 和 buffer 成员将被更新,以便反映出链表中的下一个 buffer_head 结构。在这种情况下(即还有其它缓冲区需要传输),end_request 将结束本次跌代而不会执行第 3 步和第 5 步。
3. 调用 add_blkdev_randomness 更新熵池,除非 DEVICE_NO_RANDOM 已被定义(sbull 驱动程序就定义了这个宏)。
4. 调用 blkdev_dequeue_request 函数,从请求队列当中删除已完成的请求。这个步骤将修改请求队列,因此,一定要在拥有 io_request_lock 的情况下执行。
5. 将已完成的请求释放给系统,这里,io_request_lock 也必须被获得。
内核为 end_request 定义了两个辅助函数,它们可完成大部分的工作。第一个称为 end_that_request_first,它处理上面描述的前两个步骤。其原型为:
|
status 是传递给 end_request 的请求状态,name 参数是设备名称,用于打印错误消息。如果当前请求中没有其它缓冲区需要处理,该函数返回非零值,这时,整个工作结束。否则,要调用 end_that_request_last 解除排队,并释放请求。end_that_request_last 的原型如下:
|
在 end_request 中,这个步骤由下面的代码完成:
|
这就是 end_request 函数的所有信息了。
12.4.2 集群请求
现 在起,我们将讨论如何应用上面这些背景知识编写更好的块驱动程序,首先要讨论的是集群请求的处理。先前曾提到,集群是将磁盘上相邻数据块的操作请求合并起 来的一种方法。集群能带给我们两个好处,首先,集群能够加速数据传输;其次,通过避免分配冗余的请求结构,集群可以节省内核中内存的使用。
我 们知道,块驱动程序根本不用关心集群,
为 了获得集群带来的好处,块驱动程序必须直接检查附加到某个请求上的 buffer_head 结构链表。CURRENT->bh 指向该链表,而随后的缓冲区可通过每个 buffer_head 结构中的 b_reqnext 指针找到。执行集群 I/O 的驱动程序应该大体采用下列顺序来操作集群中的每个缓冲区:
1. 准备传输地址 bn->b_data 处的数据块,该数据块大小为 bh->b_size 字节,数据传输方向为 CURRENT->cmd(要么为 READ,要么为 WRITE)。
2. 检索链表中的下一个缓冲区头:bn->b_reqnext,然后,从链表中解除已传输的缓冲区,可通过对其 b_reqnext(刚刚检索到的指向新缓冲区的指针)指针的清零而实现。
3. 更新 request 结构,以反映出被删除缓冲区上的 I/O 结束。CURRENT->hard_nr_sectors 和 CURRENT->nr_sectors 应该减去从该缓冲区中传输的扇区数(不是块数目);而 CURRENT->hard_sector 和 CURRENT->sector 则应该加上相同的扇区数。执行上述操作可保持 request 结构的一致性。
4. 返回开头以传输下一个相邻数据块。
每个缓冲区上的 I/O 完成后,驱动程序应该调用该缓冲区的 I/O 结束例程来通知内核:
|
在操作成功时,应传递 status 为非零值。当然,我们还得从队列当中将已完成操作的请求结构删除掉。上述的步骤可在未获取 io_request_lock 的情况下完成,但对队列本身的修改,必须在获得该锁的情况下进行。
在 I/O 操作结束时,驱动程序仍然可以使用 end_request(和直接操作队列相反),只要注意正确设置 CURRENT->bh 指针即可。该指针应该取 NULL,或者指向最后一个被传输的 buffer_head 结构。在后面这种情况下,不应该在最后的缓冲区上调用 b_end_io 函数,因为 end_request 将对该缓冲区调用这个函数。
功能完整的集群实现可见 drivers/block/floppy.c,而所需的操作总结可见 blk.h 中的 end_request 函数。floppy.c 和 blk.h 都不太容易理解,但后者较为容易一些。
活动的队列头
另 外一个关于 I/O 请求队列的细节涉及到处理集群的块驱动程序。它必须处理队列头,也就是队列中的第一个请求。考虑到历史遗留的兼容性原因,内核(几乎)总是假定块驱动程序 会处理请求队列当中的第一个入口。为避免因为冲突而导致破坏性的结果,一旦内核获得某个请求队列的头,就不会修改这个请求。在该请求上,不会发生其它的集 群,而电梯算法代码也不会将其它请求放到它的前面。
许多块驱动程序在开始处理某个队列当中的请求之前,首先要删除这些请求。如果读者的驱动程序恰好以这种方式工作,则应该特殊处理位于队列头部的请求。这种情况下,驱动程序应该调用 blk_queue_headactive 通知内核队列头不是活动的:
|
如果 active 是 0,内核可以对请求队列头进行修改。
12.4.2 多队列的块驱动程序
前面提到,内核默认时为每个主设备号维护一个单独的 I/O 请求队列。单个的队列对类似 sbull 这样设备来讲是足够了,但在实际情况下,单个队列总不是最优的。
考虑一个处理实际磁盘设备的驱动程序,每个磁盘能够独立操作,如果驱动程序能够并行工作,则系统性能肯定会更好一些。基于单个队列的简单驱动程序不可能实现这一目标,因为它每次只能在单个设备上执行操作。
对 驱动程序而言,遍历请求队列,并找出独立驱动器的请求并不困难。但 2.4 内核通过允许驱动程序为每个设备建立独立的队列而使得实现这个目标更加容易。许多高性能的驱动程序利用了这个多队列功能。实现多队列并不困难,但仅仅
如果在编译时定义了 SBULL_MULTIQUEUE 符号,则 sbull 驱动程序将在多队列模式下操作。它不使用
要在多队列模式下工作,块驱动程序必须定义它自己的请求队列。为此,sbull 在 Sbull_Dev 结构中添加了一个队列成员:
|
busy 标志用来保护 request 函数的重入,具体将在下面讲到。
当然,请求队列必须被初始化。sbull 以下面的方式初始化它的设备相关队列:
|
对 blk_init_queue 的调用和我们先前见过的一样,只是现在传递了设备相关的队列,而不是用于我们这个主设备编号的默认队列。上面的代码同时将队列标记为没有活动的队列头。
读 者也许想知道内核是如何找到这些请求队列的,因为它们看起来隐藏在设备相关的私有结构当中。这里的关键在于上述代码中的最后一行,它设置了全局 blk_dev 结构当中的 queue 成员,该成员指向一个函数,这个函数为给定的设备编号找出正确的请求队列。使用默认队列的设备没有这样的函数,但多队列设备必须实现这个函数:
|
和 request 函数类似,sbull_find_queue 必须是原子的(不允许睡眠)。
每 个队列有自己的 request 函数,尽管通常驱动程序会对所有的队列使用相同的函数。内核会将实际的请求队列作为参数传递到 request 函数中,这样,request 函数就能够知道它正在操作的设备是哪一个。sbull 当中使用的多队列的 request 函数,看起来和我们先前看到的不太一样,这是因为这个 request 函数直接操作请求队列,同时,它还在执行传输时解除 io_request_lock 锁,以便内核能够执行其它的块操作。最后,代码还要注意避免两个独立的危险:对 request 函数的多次调用以及对设备本身的冲突访问。
|
上 面 request 函数使用链表函数 list_empty 来测试特定的请求队列,而没有使用 INIT_REQUEST。只要有请求存在,它使用 blkdev_dequeue_request 函数从队列中删除每个请求,紧接着,一旦删除过程结束,就可以解除 io_request_lock 并获得设备相关的锁。实际的数据传输通过 sbull_transfer 完成,我们在前面讨论过这个函数。
每次对 sbull_transfer 的调用将处理附加到该请求上的一个 buffer_head 结构。然后,这个函数调用 end_that_request_first 处置已传输的缓冲区,如果该请求全部完成,则调用 end_that_request_last 函数整个清除该请求。
有必要了解一下这里的并行管理。busy 标志用于避免对 sbull_request 的多次调用。因为 sbull_request 始终在拥有 io_request_lock 的时候被调用,因此,不需要其它额外的保护就可以测试并设置 busy 标志。(否则,就需要使用 atomic_t)。在获得设备相关的锁之前,io_request_lock 被解除。这里,我们可以获得多个锁,而不需要冒死锁的风险,但实现起来很困难,所以,在条件允许的情况下,最好还是在获得另外一个锁之前释放当前的锁。
end_that_request_first 在不拥有 io_request_lock 的情况下被调用。因为该函数仅仅在给定的 request 结构上进行操作,因此,这样的调用是安全的――只要该请求不在队列上。但是,对 end_that_request_last 的调用,却需要获得 io_request_lock 锁,因为它会把该请求释放到请求队列的空闲链表中。同时,该函数始终从外层循环中退出,这时,驱动程序拥有 io_request_lock,但释放了设备锁。
当然,多队列驱动程序必须在删除模块时清除所有的队列:
|
但 是,这段代码可以编写得更加有效一些。现在,代码在初始化阶段就分配所有的请求队列,不管其中一些是否会在将来用到。请求队列是非常大的数据结构,因为在 队列初始化的时候,可能会分配许多(也许是上千个)request 结构。更加巧妙的做法是在 open 方法或者 queue 函数中,只有在必要的时候才分配请求队列。为了避免使代码复杂化,对 sbull 驱动程序,我们选择了一种更为简单的实现方法。
本小节讲述了多队列驱动程序机制。当然,处理实际硬件的驱动程序可能还有其它需要处理的问题,比如,对控制器的串行化访问等等。但是,多队列驱动程序的基本结构就是这样的。
12.4.4 没有请求队列的情况
到目前为止,我们一直在围绕 I/O 请求队列进行讨论。请求队列的目的,是为了通过让驱动器进行异步操作,或者更关键的,通过合并(磁盘上)相邻的操作而提高性能。对于通常的磁盘设备,连续块上的操作很常见,因此,这种优化是必要的。
但 是,并不是所有的块设备都能从请求队列中获得好处。比如 sbull,进程同步请求而且在寻址时间上没有任何问题。其实,对 sbull 来讲,请求队列实际上降低了数据传输的速度。其它类型的块设备也可以在没有请求队列的情况下工作得更好。例如,由多个磁盘组成的 RAID 设备,经常将“连续”的块分布在多个物理设备上。通过逻辑卷管理器(logical volume manager,LVM)功能(首次出现在 2.4 中)实现的块设备,比起提供给内核其它部分的块结构来讲,也具有更加复杂的实现。
在 2.4 内核中,块 I/O 请求由函数 _ _make_request 放在队列当中,该函数还负责调用驱动程序的 request 函数。但是,需要对请求的排队过程进行更多控制的块驱动程序,可以利用自己的“make request”函数替代这个函数。RAID 和 LVM 驱动程序就是这样做的,这样,它们最终就可以将每个 I/O 请求(根据不同的块设备号)重新排队到适当的底层设备上。而一个 RAM 磁盘驱动程序,则可以直接执行 I/O 操作。
在 2.4 系统上使用 noqueue=1 选项装载 sbull 时,它将提供自己的“make request”函数,并在没有请求队列的情况下工作。这种情况下,第一步首先要替换 _ _make_request 函数,“make request”函数指针保存在请求队列当中,可通过 blk_queue_make_request 函数改变:
|
其中,make_request_fn 类型的定义如下:
|
“make request”函数必须安排传输给定的数据块,并在传输完成时调用 b_end_io 函数。在调用 make_request_fn 函数的时候,内核并不获得 io_request_lock 锁,因此,如果该函数要自己操作请求队列,则必须获取这个锁。如果传输已经建立(不一定完成),则该函数应该返回 0。
这里所说的“安 排传输”这一短语,是经过仔细斟酌的。通常,驱动程序自己的“make request”函数不会真正传输数据。考虑 RAID 设备的驱动程序,它的“make request”函数需要做的就是将 I/O 操作映射到它的组成设备上,然后调用那个设备的驱动程序完成实际的工作。这个映射通过将 buffer_head 结构中的 b_rdev 成员设置成完成传输的“真实”设备的编号而实现,然后,通过返回一个非零值来表示该数据块仍然需要被写入。
当内核从“make request”函数中获得一个非零值时,它判断该工作尚未完成并且需要重试。但是,它首先要查找 b_rdev 成员所代表的设备的“make request”函数。这样,对 RAID 设备来讲,RAID 驱动程序的“make request”函数不会再次被调用,相反,内核将把这个数据块传递到底层设备的对应函数。
在初始化阶段,sbull 用下面的代码设置自己的“make request”函数:
|
在这种模式下,sbull 并没有调用 blk_init_queue,因为我们不会使用请求队列。
当内核产生一个对 sbull 设备的请求时,它将调用 sbull_make_request,该函数的定义如下:
|
上面的大部分代码一定看似熟悉。它包含了通常的计算以确定块在 sbull 中的位置,并使用 memcpy 执行操作。因为该操作将立即结束,所以它调用 bh->b_end_io 函数表示操作已完成,然后给内核返回 0。
但 是,这里有一个“make request”函数必须要注意的细节。要传输的缓冲区可能位于内存不能直接访问的高端内存。高端内存的具体内容将在第 13 章中讲述,这里不会重复。读者只要知道处理这个问题的一个办法就是,将高端内存中的缓冲区用一个可访问内存中的缓冲区替代。create_bounce 函数就是用来完成这个工作的,并且对驱动程序来讲是透明的。内核通常在将缓冲区放到驱动程序的请求队列之前使用 create_bounce,但是,如果驱动程序实现了自己的 make_request_fn 函数,则必须由自己完成这个工作。
12.5 挂装和卸载是如何工作的
块 设备和字符设别及普通文件之间有着明显的不同――块设备可以被挂装到系统的文件系统上。挂装提供了对字符设备来讲不可见的间接方法,后者通常通过由特定进 程所拥有的 struct file 指针来访问。而当文件系统被挂装时,没有任何进程拥有对应的 file 结构。
在内核挂装文件系 统中的某个设备时,它调用标准的 open 方法访问驱动程序。但在这种情况下,用以调用 open 的两个参数 filp 和 inode 均为哑变量。在 file 结构中,只有 f_mode 和 f_flags 成员保存着有意义的值,而 inode 结构中,也只会用到 i_rdev。其余的成员含有随机值,因此不应该使用这些成员。f_mode 的值告诉驱动程序,以只读方式(f_mode == FMODE_READ)还是以读/写方式(f_mode == (FMODE_READ|FMODE_WRITE))挂装设备。
这 样,open 接口看起来有点奇怪,但有两个原因促使内核这样做。首先,进程可以以标准方式调用 open 来直接访问设备,比如 mkfs 工具。另外一个原因源于历史遗留问题:块驱动程序使用了与字符驱动程序一样的 file_operations 结构,因此,不得不遵循相同的接口。
除 了传递给 open 方法的有限参数以外,驱动程序看不到任何挂装文件系统期间所发生的其它东西。设备被打开之后,内核就会调用 request 方法传输数据块,驱动程序其实无法了解发生在各种操作之间的区别,到底是在响应独立的进程(比如 fsck),还是在处理源于内核文件系统层的操作。
对 umount 来讲,它只是刷新缓冲区缓存然后调用驱动程序的 release 方法。因为没有任何有具体含义的 filp 可传递给 release 方法,所以,内核使用 NULL。因此,块驱动程序的 release 实现,不能使用 filp->private_data 来访问设备信息,而只能使用 inode->i_rdev 来区别不同的设备。这样,sbull 的 release 方法如下实现:
|
其它的驱动程序函数不会受到“不存在的 filp”问题的影响,因为它们根本不会涉及到文件系统的挂装和卸载。例如,ioctl 只会被显式调用了 open 方法打开设备的进程调用。
12.6 ioctl 方法
和字符设备类似,我们也可以通过 ioctl 系统调用来操作块设备。块驱动程序和字符驱动程序在 ioctl 实现上的唯一不同,就是块设备驱动程序共享了大量常见的 ioctl 命令,大多数驱动程序都会支持这些命令。
块驱动程序通常要处理的命令如下所示(在
BLKGETSIZE
检索当前设备的大小,以扇区数表示。系统调用传递的 arg 参数是一个指向长整数的指针,用来将设备大小值复制到用户空间变量中。mkfs 可利用该 ioctl 命令了解将要创建的文件系统大小。
BLKFLSBUF
从字面上看,该命令的含义是“刷新缓冲区”。该命令的实现对所有设备来讲都是一样的,其代码可在下面的全局 ioctl 命令示例代码中找到。
BLKRRPART
重新读取分区表。该命令仅对可分区设备有效,将在本章后面介绍。
BLKRAGET
BLKRASET
用来获取或者修改设备当前的块级预读值(即保存在 read_ahead 数组中的值)。对 GET,应该使用传递到 ioctl 的 arg 参数的指针,将当前值写入用户空间的长整型变量;而对 SET,新的值作为参数传递。
BLKFRAGET
BLKFRASET
获取或设置设备的文件系统级预读值(保存在 max_readahead 数组中的值)。
BLKROSET
BLKROGET
上述命令用来修改或者检查设备的只读标志。
BLKSECTGET
BLKSECTSET
上述命令检索或设置每个请求的最大扇区数(保存在 max_sectors)。
BLKSSZGET
通过指向调用者的整型变量指针,返回当前块设备的扇区大小,该大小值直接从 hardsect_size 数组中获得。
BLKPG
BLKPG 命令允许用户模式的程序添加或者删除分区。它由 blk_ioctl(很快就会讲到)实现,内核中的驱动程序不需要提供它们自己的实现。
BLKELVGET
BLKELVSET
通过这些命令可控制电梯请求排序算法的工作方式。和 BLKPG 类似,驱动程序不需要直接实现该命令。
HDIO_GETGEO
定义在
HDIO_GETGEO 命令是
对 所有的块驱动程序,上述 ioctl 命令几乎以相同的方式实现。2.4 内核提供了一个函数,即 blk_ioctl,可调用该函数实现常见命令,该函数在
sbull 设备只支持刚刚列出的常用命令,因为设备特有命令的实现方法,和字符驱动程序的实现方法没有任何区别。sbull 的 ioctl 实现如下:
|
该函数开头的 PDEBUG 语句被保留,这样,在编译该模块时,可打开调试选项,从而能够看到该设备上发生的 ioctl 命令。
12.7 可移动设备
到目前为止,我们一直忽略了 block_device_operations 结构中的两个文件操作,它们用于支持可移动介质设备,现在我们介绍这两个操作。sbull 其实根本不是一种移动设备,但它伪装成了移动设备,因此,需要实现这两个操作。
这两个操作是 check_media_change 和 revilidate。前者用于检查自从上次访问以来,设备是否发生过变化,而后者在磁盘变化之后,重新初始化驱动程序状态。
对 sbull 而言,在其使用计数减小为零后,稍后就会释放与某个设备相关联的数据区域,这样,就可以通过保持设备被卸载(或关闭)足够长的时间来模拟磁盘变化,下一次对设备的访问,将分配一块新的内存区域。
这种类型的“适时过期”方法使用内核定时器实现。
check_media_change
这个检查函数只有一个 kdev_t 型参数,用来标识设备。返回值为 1 表明介质变化,反之返回 0。不支持移动设备的块驱动程序可将 bdops->check_media_change 设置为 NULL,从而无需声明该函数。
值得注意的是,当设备是可移动的,但又无法知道是否发生变化时,返回 1 是一种安全的选择。这正是 IDE 驱动程序处理可移动磁盘的方法。
sbull 的实现是在设备因为定时器到期而从内存中删除时返回 1,而在数据仍然有效时返回 0。如果打开调试选项,则会向系统日志打印一条消息,这样,用户可以验证该方法是由内核调用的。
|
12.7.1 revalidation
revalidation 函数在检测到磁盘变化时调用。内核 2.1 版本中实现的各种 stat 系统调用也会调用这个函数。该函数的返回值目前还没有被使用,为了安全起见,应该返回 0 以表示成功,返回负的错误值以表示错误。
由 revalidation 执行的操作是设备特有的,但 revalidation 通常用来更新内部的状态信息以便反映出新的设备。
sbull 的 revalidation 方法在没有合法内存区域的情况下,将试着分配一个新的数据区。
|
12.7.2 需要特别注意的事项
移动设备的驱动程序应该在设备被打开时检查磁盘变化情况。内核提供了一个函数,可致使检查的发生:
|
如果检测到磁盘变化,则返回非零值。内核在挂装期间会自动调用 check_disk_change,但不会在 open 期间自动调用。
但 是,某些程序会直接访问磁盘数据,而不会首先挂装设备,比如:fsck、mcopy 和 fdisk 等等。如果驱动程序在内存中保留了移动设备的状态信息,则应该在第一次打开设备时调用内核的 check_disk_change 函数。该函数使用驱动程序的 check_media_change 和 revalidation 方法,因此,没有必要在 open 本身中实现特殊的代码。
下面是 sbull 的 open 方法实现,该方法处理了磁盘的变化情况:
|
驱 动程序不需要为磁盘变化做其它额外的工作。如果在打开计数仍然大于零的情况下发生磁盘变化,则数据将会被破坏。驱动程序能够避免发生这个问题的唯一方法, 是利用使用计数控制介质的门锁,当然,物理设备要支持介质门的锁定。这样,open 和 close 就能够适当地禁止或者打开这个物理锁。
12.8 可分区设备
大 多数块设备不会以整块方式使用,相反,系统管理员通常希望对该设备进行分区,也就是说,将整个设备划分成若干独立的伪设备。如果读者试图在 sbull 设备上利用 fdisk 建立分区,就会遇到问题。fdisk 程序称这些分区为 /dev/sbull01、/dev/sbull02 等等,但这些名称根本就不存在。还要指出的是,目前还没有一种机制将这些名称和 sbull 设备当中的分区绑定在一起,因此,在一个块设备能够被分区之前,必须完成一些准备工作。
为了演示如何支持分区,我们引入一个新的设备, 称为“spull”,表示“Simple Paritionable Utility”。这个设备比起 sbull 来更为简单,因为它缺少请求队列的管理以及其它一些灵活性(比如改变硬扇区大小的能力)。该设备保存在 spull 目录中,虽然它和 sbull 共享某些代码,但和 sbull 没有任何关系。
为了在某个设备上支持分区,我们必须赋于每个物理设备若干个次设备号。一个设备号用来 访问整个设备(例如,/dev/hda),而另外一些用来访问不同的分区(比如 /dev/hda1、/dev/hda2 等)。因为 fdisk 通过在磁盘设备的整体名称后添加数字后缀来建立分区名称,因此,我们会在 spull 驱动程序中遵循同样的命名习惯。
由 spull 实现的设备结点称为 pd,表示“partionable disk(可分区磁盘)”。四个整体设备(也称为“单元(unit)”)分别命名为 /dev/pda 到 /dev/pdd,每个设备至多支持 15 个分区。次设备号的含义如下:低四位代表分区编号(0 表示整个设备),高四位表示单元编号。这一约定在源文件中通过下面的宏表示:
|
spull 驱动程序同时将硬扇区大小硬编码在代码中,以便简化编程:
|
12.8.1 一般性硬盘
所有的可分区设备都需要知道具体的分区结果,该信息可从分区表中获得,其初始化过程的一部分包括对分区表的解码,并更新内部数据结构来反映出分区信息。
解 码并不简单,但所幸的是内核提供了可被所有块设备使用的“一般性硬盘(generic hard disk)”支持。这种支持最终减少了驱动程序中用以处理分区的代码量。这种一般性支持的另外一个好处是,驱动程序编写者不需要理解具体的分区方法,而且 还可以在无需修改驱动程序代码的情况下,在内核中添加新的分区方案。
支持分区的块驱动程序必须包含
在继续我们的讨论之前,首先了解一下 struct gendisk 中的一些成员。在利用一般性设备支持之前,我们需要首先理解这些成员。
int major
该结构所指的主设备编号。
const char *major_name
属 于该主设备号的设备基本名称(base name)。每个设备的名称通过在基本名称之后添加一个表示单元的字母,以及一个表示分区的编号而形成。例如,“hd”是用来建立 /dev/hda1 和 /dev/hdb3 的基本名称。在现代模块中,磁盘名称的总长度可达 32 个字符,但 2.0 内核要小一些。如果驱动程序希望能够移植到 2.0 内核,则应该将 major_name 成员限制在五个字符之内。spull 的基本名称是 pd(“partitionable disk”)。
int minor_shift
minor_shift 表示从次设备编号中得出驱动器编号时的位移数。在 spull 中,该数值为 4。该成员的值应该和宏 DEVICE_NR(device) (见“头文件 blk.h”一节)的定义保持一致。spull 中,这个宏将展开成 device>>4。
int max_p
分区的最大个数。在我们的例子中,max_p 是 16,或者更为一般些,即 1 << minor_shift。
struct hd_struct *part
该 设备解码后的分区表。驱动程序可以利用这一成员确定通过每个次设备号能够访问的磁盘扇区范围。驱动程序负责分配和释放该数组,大多数驱动程序将其实现为静 态的数组,数组中共有 max_nr << minor_shift 个结构。在内核解码分区表之前,驱动程序应该将该数组初始化为零。
int *sizes
是个整型数组,其中包含了与全局 blk_size 数组一样信息,实际上,它们经常是同一个数组。驱动程序负责分配和释放 sizes 数组。注意设备的分区检查代码将把该指针复制到 blk_size,因此,处理可分区设备的驱动程序不必分配后一个数组。
int nr_real
实际存在的设备(单元)个数。
void *real_devices
驱动程序可使用此成员保存任何附加的私有数据。
void struct gendisk *next
用来实现一般性硬盘结构链表的指针。
struct block_device_operations *fops;
指向设备块操作结构的指针。
许多 gendisk 结构中的成员在初始化阶段进行设置,因此,编译阶段的设置相对简单一些:
|
12.8.2 分区检测
在 模块初始化其本身时,它必须为分区检测进行适当的设置。首先,spull 为 gendisk 结构设置 spull_sizes 数组(该数组也将赋于 blk_size[MAJOR_NR] 以及 gendisk 结构的 sizes 成员)以及 spull_partitions 数组,该数组保存了实际的分区信息(也将赋于 gendisk 结构的 part 成员)。这两个数组在这个阶段被初始化为零,其代码如下:
|
驱动程序也应该将其 gendisk 结构包含到全局链表中。因为内核没有提供函数添加 gendisk 结构到该链表中,因此,必须手工完成:
|
在实际系统中,系统仅仅利用该链表实现了 /proc/partitions。
我们在前面看到的 register_disk 函数,用来读取磁盘的分区表。
|
这里,gd 就是我们先前准备好的 gendisk 结构,drive 是设备编号,minors 是所支持的分区个数,ops 是驱动程序的 block_device_operations 结构,而 size 则是设备以扇区计的大小。
固 定磁盘可在模块初始化阶段以及 BLKRRPART 被调用时读取分区表,而移动设备的驱动程序还需要在 revalidate 方法中调用该函数。不管是哪种方法,都需要注意 register_disk 将调用驱动程序的 request 函数读取分区表,因此,驱动程序都应该在这点上经过足够的初始化以处理请求。我们还需注意不能在这时拥有任何可能与 request 函数中获得的锁冲突的锁。register_disk 必须为系统中实际存在的每个磁盘调用一次。
spull 在 revalidate 方法中建立分区:
|
值得注意的是,register_disk 通过重复调用 printk 函数来打印分区信息:
|
这就是为什么 spull 会打印一个前导字符串的原因,这些额外的上下文信息也许会填满系统日志。
在卸载可分区模块时,驱动程序应该对每对它所支持的主/次设备编号调用 fsync_dev,以便刷新所有的分区,当然,还应该释放所有相关的内存。spull 的清除函数定义如下:
|
还需要从全局链表中删除 gendisk 结构。因为没有提供函数来完成该工作,因此需要手工完成:
|
注意没有和 register_disk 函数相对应的 unregister_disk 函数。register_disk 得到的所有结果保存在驱动程序自己的数组中,所以在卸载阶段没有任何清除工作需要完成。
12.8.3 使用 initrd 完成分区检测
如 果我们想从某个设备上挂装根文件系统,而该设备的驱动程序只以模块形式存在,这时,我们就必须使用现代 Linux 内核提供的 initrd 设施。我们不会在这里介绍 initrd,所以,这个小节是针对了解 initrd,并且想知道它是如何影响块驱动程序的读者的。initrd 的详细信息可在内核源代码的 Documentation/initrd.txt 中找到。
当我们使用 initrd 引导内核时,它会在挂装实际的根文件系统之前建立一个临时的运行环境。通常,我们从用作临时根文件系统的 RAM 磁盘上装载模块。
因 为 initrd 过程在所有引导阶段的初始化完成之后(但在挂装实际的根文件系统之前)运行,因此,在装载一个通常的模块与装载一个存在于 initrd RAM 磁盘上的模块之间,没有任何的区别。如果能够正确装载并以模块的方式使用某个驱动程序,所有支持 initrd 的 Linux 发行版就会将该驱动程序包含在安装磁盘中,而不需要我们自己去 hack 内核源代码。
12.8.4 spull 的设备方法
我 们已经看到如何初始化可分区设备,但还不知道如何访问分区中的数据。为此,我们需要使用由 register_disk 保存在 gendisk->part 数组中的分区信息。该数组由 hd_struct 结构组成,并由次设备号索引。hd_struct 有两个值得注意的成员:start_sect 告诉我们给定分区在该磁盘上的起始位置,而 nr_sects 给出了该分区的大小。
这里我们将描述 spull 如何使用这些信息。下面的代码仅仅包含了 spull 不同于 sbull 的那些代码,因为大部分代码其实是一样的。
首先,open 和 close 保持每个设备的使用计数。因为使用计数是针对物理设备(单元)的,因此,下面的声明和赋值用于 dev 变量:
|
这里使用的 DEVICE_NR 宏是必须在包含
尽 管几乎每个设备方法都可以将物理设备作为一个整体而处理,但 ioctl 需要访问每个分区特有的信息。例如,当 mkfs 调用 ioctl 检索要建立文件系统的设备大小时,它应该告诉 mkfs 对应分区的大小,而不是整个设备的大小。下面的代码说明了 ioctl 的 BLKGETSIZE 命令,如何受到每设备一个次设备号到多个次设备号这一改变的影响的。读者可能会想到,spull_gendisk->part 将用来获得分区大小。
|
另外一个类似的 ioctl 命令是 BLKRRPART。对可分区设备来讲,重新读取分区表非常有意义,并且等价于在磁盘发生变化时的重生成(revalidate)操作:
|
然 而,sbull 和 spull 之间的最大不同在于 request 函数。在 spull 中,request 函数要使用分区信息以便从不同的次设备中传输数据。传输的定位,只需在请求所提供的扇区上加上分区的起始扇区,分区的大小信息也可用来确保请求发生在分区 内部。在上述工作完成之后,其余的实现和 sbull 是一样的。
下面是 spull_request 中的相关代码行:
|
扇区数乘以硬件的扇区大小(spull 中,该数值是硬编码的)可获得分区以字节计的大小。
12.9 中断驱动的块驱动程序
在一个驱动程序控制真正的硬件设备时,其操作通常是由中断驱动的。使用中断,可以在执行 I/O 操作过程中释放处理器,从而帮助提高系统性能。为了 I/O 能够以中断驱动的方式工作,所控制的设备必须能够异步传输数据并产生中断。
如 果驱动程序是中断驱动的,request 函数应该提交一次数据传输并立即返回,而无需调用 end_request。但是,在没有调用 end_request(或其组成部分)之前,不会认为请求已经完成。因此,在设备告诉驱动程序已完成数据传输时,顶半或底半中断处理程序需要调用 end_request。
sbull 和 spull 在不使用系统微处理器的情况下,都无法传输数据,但是,如果用户在装载 spull 的时候指定 irq=1 选项,则 spull 可以模拟中断驱动的操作。当 irq 为非零值时,驱动程序使用内核定时器来延迟当前请求的满足,延迟的长度就是 irq 的值:其值越大,延迟越长。
块的传输始终在内核调 用驱动程序的 request 函数时开始。中断驱动设备的 request 函数指示硬件执行传输,然后返回,而不会等待传输的完成。spull 的 request 函数执行通常的错误检查,然后调用 spull_transfer 传输数据(相当于驱动程序指示实际的硬件执行异步传输),然后,spull 延迟请求完成确认,直到发生中断的那一刻:
|
在 设备处理当前请求时,还可以累积新的请求。因为在这种情况下,几乎总会发生重入调用,因此,request 函数设置 spull_busy 标志,以确保给定时间内只发生一次传输。因为整个函数在拥有 io_request_lock 锁(内核在调用 request 函数前获取该锁)的情况下运行,因此无需对该忙标志使用测试并设置操作。否则,为了避免竞态的发生,我们必须使用 atomic_t 类型的变量,而不是 int 变量。
中断处理程序要执行许多任务。首先,它必须检查未完成传输的状态,并清除该请求。然后,如果还有其它需要处理的请求, 中断处理程序就要负责获得下一个已启动的请求。为了避免代码的重复,处理程序通常会调用 request 函数来启动下一个传输。需要注意的是,request 函数希望调用者拥有 io_request_lock 锁,因此,中断处理程序必须获得该锁。当然,end_reqeust 函数也需要获得该锁。
在我们的示例模块中,中断处理程序的角色由 定时器到期时所调用的函数担当,该函数调用 end_request 并调用 request 函数安排下一个数据传输。在这段简单的代码中,spull 的中断处理程序在“中断”期间执行其所有的工作,一个实际的驱动程序几乎肯定会推迟这些工作,并在任务队列或者 tasklet 中执行。
|
如果读者要让 spull 模块以中断驱动方式运行,几乎不可能注意到我们添加的延迟。该设备几乎和先前的一样快,因为缓冲区缓存避免了内存和设备之间的大多数数据传输。如果读者想感受到慢设备的行为,则可以在装载 spull 时为 irq= 指定一个较大的值。
12.10 向后兼容性
块设备层已经发生了许多改变,大部分变化发生在 2.2 和 2.4 稳定版本之间。这一小节将总结前面版本的不同之处。读者可以阅读示例源代码中可运行在 2.0、2.2 和 2.4 上的驱动程序,这样能看到移植性是如何处理的。
Linux 2.2 中不存在 block_device_operations 结构,相反,块驱动程序使用的是和字符驱动程序一样的 file_operations 结构,check_media_change 和 revalidate 也是该结构的一部分。内核同时提供了一组一般性函数,包括 block_read、block_write 和 block_fsync,大部分驱动程序可以在其 file_operations 结构中使用这些函数。2.2 或2.0 file_operations 结构的典型初始化代码如下:
|
需要注意的是,块驱动程序也一样经历了 2.0 和 2.2 版本之间的 file_operations 原型变化,这和字符驱动程序是一样的。
在 2.2 及其先前内核中,request 函数保存在 blk_dev 全局数组中,因此,初始化时需要下面代码行:
|
因为该方法仅仅允许每个主设备号拥有一个队列,因此,2.4 内核中的多队列能力在先前版本中并不存在。因为只有一个队列,request 函数不需要将队列作为一个参数,所以该函数没有任何参数,它的原型如下:
|
同时,所有的队列都拥有活动头,因此 blk_queue_headactive 也不存在。
在 2.2 及其先前版本中没有 blk_ioctl 函数。但是,有个称为 RO_IOCTLS 的宏可插入 switch 语句来实现 BLKROSET 和 BLKROGET。示例源代码中的 sysdep.h 包含了一个使用 RO_IOCTLS 的 blk_ioctl 实现,并且实现了其它一些标准的 ioctl 命令:
|
BLKFRAGET、BLKFRASET、BLKSECTGET、BLKSECTSET、BLKELVGET 和 BLKELVSET 命令是 Linux 2.2 添加的,BLKPG 是在 2.4 中添加的。
Linux 2.0 中没有 max_readahead 数组,而是有一个 max_segments 数组,并在 Linux 2.0 和 2.2 中使用该数组,但设备驱动程序通常不需要设置这个数组。
最后,register_disk 直到在 Linux 2.4 中才出现。前面版本中有一个称为 resetup_one_dev 的函数,可完成类似的功能:
|
sysdep.h 利用下面的代码来模拟 register_disk 函数:
|
当然,因为 Linux 2.0 中不存在任何类型的 SMP 支持,所以没有 io_request_lock,也不需要为 I/O 请求队列的并行访问而担心。
最后还有一点需要提醒:尽管还没有人知道 2.5 开发系列版本中会发生什么,但块设备处理出现一次大的整修则是肯定的。许多人不太喜欢这个层的设计,因此有许多压力迫使内核开发人员重新编写块设备处理层。
12.11 快速参考
这里将总结编写块驱动程序时要用到的最重要的函数和宏,但是,为了节省篇幅,我们并不会列出 struct request、struct buffer_head 以及 struct genhd 的成员,而且还略去了预定义的 ioctl 命令。
#include
int register_blkdev(unsigned int major, const char *name, struct block_device_operations *bdops);
int unregister_blkdev(unsigned int major, const char *name);
这些函数负责设备注册(在模块的初始化函数中)和删除设备(在模块的清除函数中)。
#include
blk_init_queue(request_queue_t *queue, request_fn_proc *request);
blk_cleanup_queue(request_queue_t *queue);
第一个函数初始化队列并建立 request 函数,第二个函数在清除阶段使用。
BLK_DEFAULT_QUEUE(major)
整个宏返回给定主设备号的默认 I/O 请求队列。
struct blk_dev_struct blk_dev[MAX_BLKDEV];
该数组由内核用来检索给定请求的适当队列。
int read_ahead[];
int max_readahead[][];
read_ahead 包含每个主设备号的块级预读值。对硬盘这样的设备,取值为 8 是比较合理的;对比较慢的介质,该值应该取得较大。max_readahead 包含了每个主设备号和次设备号的文件系统级预读值,通常无需改变系统所设置的默认值。
int max_sectors[][];
该数组由主设备号和次设备号索引,含有可合并到单个 I/O 请求中的最大扇区数。
int blksize_size[][];
int blk_size[][];
int hardsect_size[][];
这些二维数组由主设备号和次设备号索引。驱动程序负责分配和释放矩阵中与其主设备号相关联的行。这些数组分别代表设备块以字节计的大小(通常为 1 KB)、每个次设备以千字节计的大小(不是块),以及硬件扇区以字节计的大小。
MAJOR_NR
DEVICE_NAME
DEVICE_NR(kdev_t device)
DEVICE_INTR
#include
驱 动程序必须在包含
spinlock_t io_request_lock;
操作 I/O 请求队列时必须获得的自旋锁。
struct request *CURRENT;
在使用默认队列时,这个宏指向当前请求。request 结构描述了要传输的数据块,并在驱动程序的 request 函数中使用。
INIT_REQUEST;
end_request(int status);
INIT_REQUEST 检查队列中的下一个请求,并在没有其它请求需要处理时返回。end_request 在块请求完成时调用。
spinlock_t io_request_lock;
在操作请求队列时,必须获得这个 I/O 请求锁。*
|
struct request *blkdev_entry_next_request(struct list_head *head);
struct request *blkdev_next_request(struct request *req);
struct request *blkdev_prev_request(struct request *req);
blkdev_dequeue_request(struct request *req);
blkdev_release_request(struct request *req);
用来处理 I/O 请求队列的各种函数。
blk_queue_headactive(request_queue_t *queue, int active);
该函数指出队列中的第一个请求是否正在由驱动程序处理,即活动头。
void blk_queue_make_request(request_queue_t *queue, make_request_fn *func);
该函数指定使用某个函数超越内核而直接处理块 I/O 请求。
end_that_request_first(struct request *req, int status, char *name);
end_that_request_last(struct request *req);
上述函数用于块 I/O 请求完成的阶段。end_that_request_last 在请求中的所有缓冲区被处理后调用,也就是当 end_that_request_first 返回 0 时。
bh->b_end_io(struct buffer_head *bh, int status);
通知内核给定缓冲区上的 I/O 操作已结束。
int blk_ioctl(kdev_t dev, unsigned int cmd, unsigned long arg);
实现大部分标准块设备 ioctl 命令的辅助函数。
int check_disk_change(kdev_t dev);
该函数检查给定设备上是否发生介质变化,在检测到变化时,将调用驱动程序的 revalidation 方法。
#include
struct gendisk;
struct gendisk *gendisk_head;
一般性硬盘可让 Linux 轻松支持可分区设备。gendisk 结构描述一个一般性磁盘,gendisk_head 是 gendisk 结构形成的链表,用来描述系统中所有的一般性磁盘。
void register_disk(struct gendisk *gd, int drive, unsigned minors, struct block_device_operations *ops, long size);
该函数扫描磁盘的分区表并重写 genhd->part 以反映出新的分区情况。