块设备驱动程序简介
通常,解释设备驱动程序的作者会从完整解释字符设备开始,将块设备驱动程序留到后面的章节。为了解释为什么会这样,我需要简要介绍一下字符设备。为此,我将简要介绍一下历史。
当 Unix 在 25 年前编写时,它的设计是折衷的。一个不寻常的设计特点是,连接到计算机的每个物理设备都表示为一个文件。这是一个大胆的决定,因为许多设备彼此非常不同,尤其是在乍一看时。为什么要使用相同的接口与打印机和磁盘驱动器对话?
简短的答案是,虽然设备非常不同,但可以将它们视为具有与文件大部分相同的特性。然后,通过仅使用一个带有少量扩展的接口,使整个系统保持更小更简单。
这很好,但它掩盖了设备之间重要的差异。例如,可以在任何时间读取磁盘上的任何字节,但只能从终端读取下一个字节。
还有其他差异,但这是最根本的差异之一:某些设备(如磁盘)是随机访问的,而另一些设备(如终端)是顺序访问的。当然,可以假装随机访问设备是顺序访问设备,但反过来则不行。
这种差异的实际影响是,文件系统只能挂载在块设备上,而不能挂载在字符设备上。例如,大多数磁带都是字符设备。可以将原始的、静止的(未挂载且未被修改)文件系统的内容复制到磁带上,但即使磁带包含与磁盘相同的信息,您也无法挂载磁带。
大多数教科书和教程都从解释字符设备(顺序访问设备)开始,因为编写一个最小的字符设备驱动程序比编写一个最小的块设备驱动程序更容易。我自己的Linux 内核黑客指南(KHG)也是以相同的方式编写的。
我用块设备(随机访问设备)开始本专栏的原因是,KHG 比块设备更好地解释了简单的字符设备,而且我认为现在更需要关于块设备的信息。此外,真正的字符设备驱动程序可能非常复杂,就像块设备驱动程序一样复杂,而且很少有人知道如何编写块设备驱动程序。
我不会在这里给出一个完整的设备驱动程序示例。我将解释重要的部分,并让您通过检查 Linux 源代码来发现其余部分。阅读本文和 ramdisk 驱动程序 (drivers/block/ramdisk.c),以及 KHG 的某些部分,应该使您能够编写一个简单的、非中断驱动的块设备驱动程序,足以在上面挂载文件系统。要编写中断驱动的驱动程序,请阅读 drivers/block/hd.c,AT 硬盘驱动程序,并跟随学习。我也在本文中包含了一些提示。
字符设备驱动程序提供直接从它们驱动的设备读取和写入数据的过程,而块设备则不提供。相反,它们提供一个用于读取和写入的单一 request() 过程。有通用的 block_read() 和 block_write() 过程,它们知道如何调用 request() 过程,但您需要了解的关于这些函数的所有内容是将对它们的引用放在正确的位置,这将在稍后介绍。
request() 过程(对于一个旨在执行 I/O 的函数来说可能令人惊讶)不带任何参数,并且返回 void。它没有显式的输入和返回值,而是查看 I/O 请求队列,并按顺序一次处理一个请求。(请求在 request() 函数读取队列时已经排序。)当调用它时,如果它不是中断驱动的,它会处理从设备读取块的请求,直到它耗尽所有待处理的请求。(通常,队列中只有一个请求,但 request() 过程应检查直到队列为空。请注意,在处理当前请求时,其他进程可能会向队列添加其他请求。)
另一方面,如果设备是中断驱动的,则 request() 过程通常会安排发生中断,然后让中断处理过程调用 end_request()(稍后会详细介绍 end_request()),然后再次调用 request() 过程以安排要处理的下一个请求(如果有)。
理想化的非中断驱动 request() 过程看起来像这样
static void do_foo_request(void) { repeat: INIT_REQUEST; /* check to make sure that the request is for a valid physical device */ if (!valid_foo_device(CURRENT->dev)) { end_request(0); goto repeat; } if (CURRENT->cmd == WRITE) { if (foo_write( CURRENT->sector, CURRENT->buffer, CURRENT->nr_sectors < 9)) { /* successful write */ end_request(1); goto repeat; } else end_request(0); goto repeat; } if (CURRENT->cmd == READ) { if (foo_read( CURRENT->sector, CURRENT->buffer, CURRENT->nr_sectors << 9)) { /* successful read */ end_request(1); goto repeat; } else end_request(0); goto repeat; } } }
您首先注意到的这个函数可能是它永远不会显式返回。它不会运行到末尾并返回,也没有 return 语句。这不是错误;INIT_REQUEST 宏为我们处理了这个问题。它检查请求队列,如果队列中没有请求,则返回。如果队列中有另一个请求要设置为 CURRENT,它会对新的 CURRENT 请求进行一些简单的健全性检查。
CURRENT 默认定义为
blk_dev[MAJOR_NR].current_request
在 drivers /block/blk.h 中。(我们稍后将介绍 MAJOR_NR 和 blk.h。)这是当前请求,即正在处理的请求队列头部的请求。请求结构包含处理请求所需的所有信息,包括设备、命令(读取或写入;我们在这里假设为读取)、正在读取的扇区、要读取的扇区数、存储数据的内存指针以及指向下一个请求的指针。还有更多内容,但这只是我们关心的全部内容。
sector 变量包含块号。扇区的长度在设备初始化时指定(稍后详细介绍),扇区从 0 开始连续编号。如果物理设备通过扇区以外的其他方式寻址,则 request() 过程负责转换。
在某些情况下,一个命令可能读取或写入多个扇区。在这些情况下,nr_sectors 变量包含要读取或写入的连续扇区数。
每当 CURRENT 请求被处理时(无论是满足还是中止),都会调用 end_request()。
如果请求已满足,则调用它并带参数 1,如果请求已中止,则调用它并带参数 0。如果请求已中止,它会发出抱怨,对缓冲区缓存进行魔法处理,从队列中删除已处理的请求,如果请求用于交换,则“向上”一个信号量,并唤醒所有等待请求完成的进程。
如果需要,它可能允许发生任务切换。
end_request() 是在 blk.h 中定义的静态函数。每个块设备驱动程序都编译了一个单独的版本,使用在 blk.h 和块设备驱动程序中使用的特殊 #define 值。这使我们进入...
我们已经看到了几个在编写块设备驱动程序时非常有用的宏。其中许多宏在 drivers/block/blk.h 中定义,并且必须进行特殊设置。
在设备驱动程序的顶部,在包含您的驱动程序需要的标准头文件(必须包括 linux/major.h 和 linux/blkdrv.h)之后,您应该编写以下行
#define MAJOR_NR FOO_MAJOR #include "blk.h"
反过来,这要求您在 linux/major.h 中将 FOO_MAJOR 定义为您正在编写的设备的主设备号。
现在您需要编辑 blk.h。blk.h 的一个部分,就在顶部附近,包含依赖于 MAJOR_NR 定义的宏的定义。在末尾添加一个如下所示的条目
#elif (MAJOR_NR == FOO_MAJOR) #define DEVICE_NAME "foobar" #define DEVICE_REQUEST do_foo_request #define DEVICE_NR(device) (MINOR(device) >> 6) #define DEVICE_ON(device) #define DEVICE_OFF(device) #endif
这些是每个块设备驱动程序所需的宏。还有更多可以定义的宏;它们在 KHG 中进行了解释。
DEVICE_NAME 是驱动程序的名称。AT 硬盘驱动程序在大多数地方使用缩写“hd”;例如,request() 过程称为 do_hd_request()。但是,它的 DEVICE_NAME 是“harddisk”。类似地,软盘驱动程序“fd”的 DEVICE_NAME 是“floppy”。其他驱动程序更具描述性;阅读 blk.h 并效仿。
DEVICE_REQUEST 是驱动程序的 request() 过程。
DEVICE_NR 用于确定实际的物理设备。例如,标准的 AT 硬盘驱动程序为每个物理设备使用 64 个次设备号,因此 DEVICE_NR 定义为 (MINOR(device)>6)。SCSI 磁盘驱动程序每个物理设备使用 16 个次设备号,因此对于它,DEVICE_NR 定义为 (MINOR(device)>4)。如果每个物理设备只有一个次设备号,则将 DEVICE_NR 定义为 (MINOR(device))。
DEVICE_ON 和 DEVICE_OFF 仅用于必须打开和关闭的设备。软盘驱动程序是唯一使用此功能的驱动程序。您很可能希望将这些定义为空。
所有这些宏以及许多其他宏都可以在您的驱动程序中适当地使用。blk.h 包含许多宏,研究它们在其他驱动程序中的使用方式将有助于您在自己的驱动程序中使用它们。我不会在这里完整地记录它们,但我会简要提及其中一些,以使您的生活更轻松。
DEVICE_INTR、SET_INTR 和 CLEAR_INTR 使对中断驱动设备的支持更加容易。DEVICE_TIMEOUT、SET_TIMER 和 CLEAR_TIMER 帮助您设置满足请求可能花费的时间限制。
我把第一件,也许也是最重要的事情留到了最后。在您可以读取或写入单个块之前,必须通知内核设备的存在。所有设备驱动程序都需要实现初始化函数,并且对于块设备驱动程序有一些特殊要求。这是一个理想化的初始化函数示例
long foo_init(long mem_start, int length) { if (register_blkdev(FOO_MAJOR,"foo", & foo_fops)) { printk("FOOBAR: Unable to get major %d.\n", FOO_MAJOR); return 0; } if (!foo_exists()) { /* the foobar device doesn't exist */ return 0; } /* initialize hardware if necessary */ /* notify user device found */ printk("FOOBAR: Found at address %d.\n", foo_addr()); /* tell buffer cache how to process requests */ blk_dev[FOO_MAJOR].request_fn = DEVICE_REQUEST; /* specify the blocksize */ blksize_size[MAJOR_NR] = 1024; return(size_of_memory_reserved); }
这里特定于块设备驱动程序的三个事项是
register_blkdev() 向虚拟文件系统交换机 (VFS) 注册文件操作结构,VFS 是管理文件访问的系统。
blk_dev 告诉缓冲区缓存请求过程的位置。
blksize_size 告诉缓冲区缓存要请求的块大小。
值得注意的是,硬件设备检测和初始化(我在这里表示为 foo_exists())是非常精细的代码。如果您可以依赖计算机 BIOS 中的某个字符串来确定设备是否存在及其位置,那相对容易。但是,如果您必须检查各种 I/O 端口,您可能会因向错误的端口写入错误的值,甚至读取错误的端口而使计算机挂起。如果必须检查端口,则仅检查众所周知的端口,并为其他端口提供内核命令行参数。为此,请阅读 init/main.c 并添加您自己的部分。如果您无法弄清楚如何操作,则将在 KHG 的下一个版本中进行解释。
当然,如果从未调用 foo_init(),则不会发生任何初始化。将原型添加到 blk.h 的顶部与其他原型一起,并在 ll_rw_blk.c 的 blk_dev_init() 函数中添加对 foo_init() 的调用。该调用应受到 #ifdef CONFIG_FOO 的保护,就像那里的其余 *_init() 函数一样,并且应在 config.in 文件中添加相应的行
bool `Foobar disk support' CONFIG_FOO y
drivers/block/Makefile 应该添加一个看起来像这样的部分
ifdef CONFIG_FOO OBJS := $(OBJS) foo.o SRCS := $(SRCS) foo.c endif
完成此操作后,配置应该可以正常工作。您的设备驱动程序文件不需要对 CONFIG_FOO 有任何引用;对它的唯一特定引用在 ll_rw_blk.c 中被注释掉了,并且 makefile 仅在配置了它时才构建它。
现在您要做的就是编写和调试您自己的新块设备驱动程序。我祝您好运,并希望这次旋风之旅为您提供了一个良好的开端。
Michael K. Johnson 是 Linux Journal 的编辑,也是 Linux 内核黑客指南 (KHG) 的作者。他正在使用本专栏来开发和扩展 KHG。