返回列表 发帖

[站长推荐] Linux块设备驱动详解

[站长推荐] Linux块设备驱动详解

块设备驱动(一)

概括:

块设备驱动第一个工作通常是注册自己到内核,是通过register_blkdev完成的,虽然register_blkdev可用来获得一个主设备号,但是它不使磁盘驱动器对系统可用,有一个分开的注册接口你必须使用来管理单独的驱动器,使用这一接口用到block_device_operations和gendisk结构体,在linux内核中,使用gendisk(通用磁盘)结构体来表示1个独立的磁盘设备(或分区)。

块设备驱动的核心是请求处理函数和“制造请求”函数,所谓请求处理函数或“制造请求”函数就是我们所说的IO请求处理,请求处理函数的介绍引入了request结构体,请求函数的原型是void request(request_queue_t *queue);而不使用请求处理或者“制造请求”函数,它的的主要参数是bio结构体;

定义:

系统中能够随机(不需要按顺序)访问固定大小数据片(chunks)的设备被称为块设备。

这些数据片就称为块,最常见的是硬盘,还有软盘驱动器,CD-ROM驱动器和闪存等,注意,它们都是以安装文件系统的方式使用的——这也是块设备一般的访问方式。

与字符设备的区别:

1、  字符设备按照字符流的方式被有序访问,如串口和键盘就都属于字符设备,如果一个硬件设备是以字符流的方式访问的话,那就应该将它归于字符设备,反过来,如果一个设备是随机(无序的)访问的,那么它就属于块设备。

2、  根本区别是它们能否可以被随机访问,也就是说,能否在访问设备时随意的从一个位置跳转到另一个位置。

3、  块设备只能以块为单位接受输入和返回输出,而字符设备以字节为单位。

4、  块设备对于IO请求有对应的缓冲区,因此它们可以选择以什么顺序进行响应,字符设备无须缓冲且被直接读写。

5、  字符设备只能被顺序读写,而块设备可以随机访问。

解剖一个块设备

块设备中最小的可寻址单元是扇区,扇区大小一般是2的整数倍,而最常见的大小是512个字节,扇区的大小是设备的物理属性,扇区是所有块设备的基本单元——块设备无法对比它还小的单元进行寻址和操作,不过许多块设备能够一次就传输多个扇区。

虽然各种软件的用途不同,但是它们都会用到自己的最小逻辑可寻址单元——块,块是文件系统一种抽象——只能基于块来访问文件系统,虽然物理磁盘寻址是按扇区级进行的,但是内核执行的所有磁盘操作都是按照块进行的,由于扇区是设备的最小可寻址单元,所以块不能比扇区还小,只能数倍于扇区大小。

扇区对内核的重要性在于所有设备的IO 操作都必须基于扇区来进行,反过来,块是内核使用的较高层概念,它是比扇区高一层的抽象。

一、块设备的注册与注销

块驱动, 象字符驱动, 必须使用一套注册接口来使内核可使用它们的设备. 概念是类似的, 但是块设备注册的细节是都不同的.

大部分块驱动采取的第一步是注册它们自己到内核. 这个任务的函数是 register_blkdev(在 <linux/fs.h> 中定义):

int register_blkdev(unsigned int major, const char *name);

参数是你的设备要使用的主编号和关联的名子(内核将显示它在 /proc/devices). 如果 major 传递为0, 内核分配一个新的主编号并且返回它给调用者. 如常, 自 register_blkdev 的一个负的返回值指示已发生了一个错误.

取消注册的对应函数是:

int unregister_blkdev(unsigned int major, const char *name);

这里, 参数必须匹配传递给 register_blkdev 的那些, 否则这个函数返回 -EINVAL 并且什么都不注销.

在2.6内核, 对 register_blkdev 的调用完全是可选的. 由 register_blkdev 所进行的功能已随时间正在减少; 这个调用唯一的任务是

(1) 如果需要, 分配一个动态主设备号

(2) 在 /proc/devices 创建一个入口.

在将来的内核, register_blkdev 可能被一起去掉. 同时, 但是, 大部分驱动仍然调用它; 它是惯例.

磁盘注册

虽然 register_blkdev 可用来获得一个主编号, 它不使任何磁盘驱动器对系统可用. 有一个分开的注册接口你必须使用来管理单独的驱动器. 使用这个接口要求熟悉一对新结构, 这就是我们的起点.

块设备操作


字符设备通过 file_ 操作结构使它们的操作对系统可用. 一个类似的结构用在块设备上; 它是 struct block_device_operations, 定义在 <linux/fs.h>.下面是一个对这个结构中的成员的简短的概览;

int (*open)(struct inode *inode, struct file *filp);

int (*release)(struct inode *inode, struct file *filp);

就像它们的字符驱动对等体一样工作的函数; 无论何时设备被打开和关闭都调用它们. 一个字符驱动可能通过启动设备或者锁住门(为可移出的介质)来响应一个 open 调用. 如果你将介质锁入设备, 你当然应当在 release 方法中解锁.当然一个简单的块设备驱动可以不提供open和release函数。

int (*ioctl)(struct inode *inode, struct file *filp, unsigned int cmd, unsigned long arg);

实现 ioctl 系统调用的方法. 但是, 高层的块设备层代码处理了绝大多数ioctl(),因此大部分的块驱动 ioctl 方法相当短,也就是说具体的块设备驱动中不需要实现很多的ioctl命令。

int (*media_changed) (struct gendisk *gd);

被内核调用来检查是否用户已经改变了驱动器中的介质的方法, 如果是这样返回一个非零值. 显然, 这个方法仅适用于支持可移出的介质的驱动器(并且最好给驱动一个"介质被改变"标志); 在其他情况下可被忽略.

struct gendisk 参数是内核任何表示单个磁盘; 我们将在下一节查看这个结构.

int (*revalidate_disk) (struct gendisk *gd);

revalidate_disk 方法被调用来响应一个介质改变; 它给驱动一个机会来进行需要的任何工作使新介质准备好使用. 这个函数返回一个 int 值, 但是值被内核忽略.

struct module *owner;

一个指向拥有这个结构的模块的指针; 它应当常常被初始化为 THIS_MODULE.

专心的读者可能已注意到这个列表一个有趣的省略: 没有实际读或写数据的函数. 在块 I/O 子系统, 这些操作由请求函数处理, 它们应当有它们自己的一节并且在本章后面讨论. 在我们谈论服务请求之前, 我们必须完成对磁盘注册的讨论.

gendisk 结构

struct gendisk (定义于 <linux/genhd.h>) 是单独一个磁盘驱动器的内核表示. 事实上, 内核还使用 gendisk 来表示分区, 但是驱动作者不必知道这点. struct gedisk 中有几个成员, 必须被一个块驱动初始化:

int major; 主设备号

int first_minor; 第一个次设备号

int minors; 最大的次设备数,如果不能分区,则为1

描述被磁盘使用的设备号的成员. 同一磁盘的各个分区共享一个注设备号,而次设备号则不同,至少, 一个驱动器必须使用最少一个次编号. 如果你的驱动会是可分区的, 但是(并且大部分应当是), 你要分配一个次编号给每个可能的分区. 次编号的一个普通的值是 16, 它允许"全磁盘"设备盒 15 个分区. 一些磁盘驱动使用 64 个次编号给每个设备.

char disk_name[32];
应当被设置为磁盘驱动器名子的成员. 它出现在 /proc/partitions 和 sysfs.

struct block_device_operations *fops;
来自前一节的块设备操作集合.

struct request_queue *queue;

被内核用来管理这个设备的 I/O 请求的结构; 我们在"请求处理"一节中检查它.

int flags;

一套标志(很少使用), 描述驱动器的状态. 如果你的设备有可移出的介质, 你应当设置 GENHD_FL_REMOVABLE. CD-ROM 驱动器可设置 GENHD_FL_CD. 如果, 由于某些原因, 你不需要分区信息出现在 /proc/partitions, 设置 GENHD_FL_SUPPRESS_PARTITIONS_INFO.

sector_t capacity;

这个驱动器的容量, 以512-字节扇区来计. sector_t 类型可以是 64 位宽. 驱动不应当直接设置这个成员; 相反, 传递扇区数目给 set_capacity.

void *private_data;

块驱动可使用这个成员作为一个指向它们自己内部数据的指针.

内核提供了一小部分函数来使用 gendisk 结构. 我们在这里介绍它们, 接着看 sbull 如何使用它们来使系统可使用它的磁盘驱动器.

struct gendisk 是一个动态分配的结构, 它需要特别的内核操作来初始化; 驱动不能自己分配这个结构. 相反, 你必须调用:

struct gendisk *alloc_disk(int minors);

minors 参数应当是这个磁盘使用的次编号数目; 注意你不能在之后改变 minors 成员并且期望事情可以正确工作. 当不再需要一个磁盘时, 它应当被释放, 使用:

void del_gendisk(struct gendisk *gd);

一个 gendisk 是一个被引用计数的结构(它含有一个 kobject). 有 get_disk 和 put_disk 函数用来操作引用计数, 但是驱动应当从不需要做这个. 正常地, 对 del_gendisk 的调用去掉了最一个 gendisk 的最终的引用, 但是不保证这样. 因此, 这个结构可能继续存在(并且你的方法可能被调用)在调用 del_gendisk 之后. 但是, 如果你删除这个结构当没有用户时(即, 在最后的释放之后, 或者在你的模块清理函数), 你可确信你不会再收到它的信息.

分配一个 gendisk 结构不能使系统可使用这个磁盘. 要做到这点, 你必须初始化这个结构并且调用 add_disk:

void add_disk(struct gendisk *gd);

这里记住一件重要的事情:一旦你调用add_disk, 这个磁盘是"活的"并且它的方法可被在任何时间被调用. 实际上, 这样的第一个调用将可能发生, 即便在 add_disk 返回之前; 内核将读前几个字节以试图找到一个分区表. 因此你不应当调用 add_disk 直到你的驱动被完全初始化并且准备好响应对那个磁盘的请求.


二、块设备驱动的IO请求处理

每个块驱动的核心是它的请求函数. 这个函数是真正做工作的地方 --或者至少开始的地方; 剩下的都是开销。

对请求方法的介绍

块驱动的请求方法有下面的原型:

void request(request_queue_t *queue);

这个函数不能由驱动自己调用,这个函数被调用, 无论何时内核认为你的驱动是时候处理对设备的读, 写, 或者其他操作. 请求函数在返回之前实际不需要完成所有的在队列中的请求; 实际上, 它可能不完成它们任何一个, 对大部分真实设备. 它必须, 但是, 驱动这些请求并且确保它们最终被驱动全部处理。

每个设备有一个请求队列. 这是因为实际的从和到磁盘的传输可能在远离内核请求它们时发生, 并且因为内核需要这个灵活性来调度每个传送, 在最好的时刻(将影响磁盘上邻近扇区的请求集合到一起, 例如). 并且这个请求函数, 你可能记得, 和一个请求队列相关, 当这个队列被创建时. 让我们回顾 sbull 如何创建它的队列:

dev->queue = blk_init_queue(sbull_request, &dev->lock);

这样, 当这个队列被创建时, 请求函数和它关联到一起. 我们还提供了一个自旋锁作为队列创建过程的一部分. 无论何时我们的请求函数被调用, 内核持有这个锁. 结果, 请求函数在原子上下文中运行; 它必须遵循所有的 5 章讨论过的原子代码的通用规则.

在你的请求函数持有锁时, 队列锁还阻止内核去排队任何对你的设备的其他请求. 在一些条件下, 你可能考虑在请求函数运行时丢弃这个锁. 如果你这样做, 但是, 你必须保证不存取请求队列, 或者任何其他的被这个锁保护的数据结构, 在这个锁不被持有时. 你必须重新请求这个锁, 在请求函数返回之前.

最后, 请求函数的启动(常常地)与任何用户空间进程之间是完全异步的. 你不能假设内核运行在发起当前请求的进程上下文. 你不知道由这个请求提供的 I/O 缓冲是否在内核或者用户空间. 因此任何类型的明确存取用户空间的操作都是错误的并且将肯定引起麻烦. 如你将见到的, 你的驱动需要知道的关于请求的所有事情, 都包含在通过请求队列传递给你的结构中。

下面几节更深入地研究块层如何完成它的工作, 已经这些工作导致的数据结构.

1、请求队列

最简单的说, 一个块请求队列就是: 一个块 I/O 请求的队列. 如果你往下查看, 一个请求队列是一令人吃惊得复杂的数据结构. 幸运的是, 驱动不必担心大部分的复杂性.

请求队列跟踪等候的块I/O请求. 但是它们也在这些请求的创建中扮演重要角色. 请求队列存储参数, 来描述这个设备能够支持什么类型的请求: 它们的最大大小, 多少不同的段可进入一个请求, 硬件扇区大小, 对齐要求, 等等. 如果你的请求队列被正确配置了, 它应当从不交给你一个你的设备不能处理的请求.

请求队列还实现一个插入接口, 这个接口允许使用多 I/O 调度器(或者电梯). 一个 I/O 调度器的工作是提交 I/O 请求给你的驱动, 以最大化性能的方式. 为此, 大部分 I/O 调度器累积批量的 I/O 请求, 排列它们为递增(或递减)的块索引顺序, 并且以那个顺序提交请求给驱动. 磁头, 当给定一列排序的请求时, 从磁盘的一头到另一头工作, 非常象一个满载的电梯, 在一个方向移动直到所有它的"请求"(等待出去的人)已被满足. 2.6 内核包含一个"底线调度器", 它努力确保每个请求在预设的最大时间内被满足, 以及一个"预测调度器", 它实际上短暂停止设备, 在一个预想中的读请求之后, 这样另一个邻近的读将几乎是马上到达. 到本书为止, 缺省的调度器是预测调度器, 它看来有最好的交互的系统性能.

I/O 调度器还负责合并邻近的请求. 当一个新 I/O 请求被提交给调度器, 它在队列里搜寻包含邻近扇区的请求; 如果找到一个, 并且如果结果的请求不是太大, 这 2 个请求被合并.

请求队列有一个 struct request_queue 或者 request_queue_t 类型. 这个类型, 和许多操作它的函数, 定义在 <linux/blkdev.h>. 如果你对请求队列的实现感兴趣, 你可找到大部分代码在 drivers/block/ll_rw_block.c 和 elevator.c.

2、队列的创建和删除

如同我们在我们的例子代码中见到的, 一个请求队列是一个动态的数据结构, 它必须被块 I/O 子系统创建. 这个创建和初始化一个队列的函数是:

request_queue_t *blk_init_queue(request_fn_proc *request, spinlock_t *lock);

当然, 参数是, 这个队列的请求函数和一个控制对队列存取的自旋锁. 这个函数分配内存(实际上, 不少内存)并且可能失败因为这个; 你应当一直检查返回值, 在试图使用这个队列之前.

作为初始化一个请求队列的一部分, 你可设置成员 queuedata(它是一个 void * 指针 )为任何你喜欢的值. 这个成员是请求队列的对于我们在其他结构中见到的 private_data 的对等体.

为返回一个请求队列给系统(在模块卸载时间, 通常), 调用 blk_cleanup_queue:

void blk_cleanup_queue(request_queue_t *);

这个调用后, 你的驱动从给定的队列中不再看到请求,并且不应当再次引用它.

块设备驱动(二)

3、排队函数

有非常少的函数来操作队列中的请求 -- 至少, 考虑到驱动. 你必须持有队列锁, 在你调用这些函数之前.

返回要处理的下一个请求的函数是 elv_next_request:

struct request *elv_next_request(request_queue_t *queue);

我们已经在简单的 sbull 例子中见到这个函数. 它返回一个指向下一个要处理的请求的指针(由 I/O 调度器所决定的)或者 NULL 如果没有请求要处理. elv_next_request 留这个请求在队列上, 但是标识它为活动的; 这个标识阻止了 I/O 调度器试图合并其他的请求到这些你开始执行的.

为实际上从一个队列中去除一个请求, 使用 blkdev_dequeue_request:

void blkdev_dequeue_request(struct request *req);

如果你的驱动同时从同一个队列中操作多个请求, 它必须以这样的方式将它们解出队列.

如果你由于同样的理由需要放置一个出列请求回到队列中, 你可以调用:

void elv_requeue_request(request_queue_t *queue, struct request *req);


4、队列控制函数

块层输出了一套函数, 可被驱动用来控制一个请求队列如何操作. 这些函数包括:

void blk_stop_queue(request_queue_t *queue);

void blk_start_queue(request_queue_t *queue);

如果你的设备已到到达一个状态, 它不能处理等候的命令, 你可调用 blk_stop_queue 来告知块层. 在这个调用之后, 你的请求函数将不被调用直到你调用 blk_start_queue. 不用说, 你不应当忘记重启队列, 当你的设备可处理更多请求时. 队列锁必须被持有当调用任何一个这些函数时.

void blk_queue_bounce_limit(request_queue_t *queue, u64 dma_addr);

告知内核你的设备可进行 DMA 的最高物理地址的函数. 如果一个请求包含一个超出这个限制的内存引用, 一个反弹缓冲将被用来给这个操作; 当然, 这是一个进行块 I/O 的昂贵方式, 并且应当尽量避免. 你可在这个参数中提供任何可能的值, 或者使用预先定义的符号 BLK_BOUNCE_HIGH(使用反弹缓冲给高内存页), BLK_BOUNCE_ISA (驱动只可 DMA 到 16MB 的 ISA 区), 或者BLK_BOUCE_ANY(驱动可进行 DMA 到任何地址). 缺省值是 BLK_BOUNCE_HIGH.

void blk_queue_max_sectors(request_queue_t *queue, unsigned short max);

void blk_queue_max_phys_segments(request_queue_t *queue, unsigned short max);

void blk_queue_max_hw_segments(request_queue_t *queue, unsigned short max);

void blk_queue_max_segment_size(request_queue_t *queue, unsigned int max);

设置参数的函数, 这些参数描述可被设备满足的请求. blk_queue_max 可用来以扇区方式设置任一请求的最大的大小; 缺省是 255. blk_queue_max_phys_segments 和 blk_queue_max_hw_segments 都控制多少物理段(系统内存中不相邻的区)可包含在一个请求中. 使用 blk_queue_max_phys_segments 来说你的驱动准备处理多少段; 例如, 这可能是一个静态分配的散布表的大小. blk_queue_max_hw_segments, 相反, 是设备可处理的最多的段数. 这 2 个参数缺省都是 128. 最后, blk_queue_max_segment_size 告知内核任一个请求的段可能是多大字节; 缺省是 65,536 字节.

blk_queue_segment_boundary(request_queue_t *queue, unsigned long mask);

一些设备无法处理跨越一个特殊大小内存边界的请求; 如果你的设备是其中之一, 使用这个函数来告知内核这个边界. 例如, 如果你的设备处理跨 4-MB 边界的请求有困难, 传递一个 0x3fffff 掩码. 缺省的掩码是 0xffffffff.

void blk_queue_dma_alignment(request_queue_t *queue, int mask);

告知内核关于你的设备施加于 DMA 传送的内存对齐限制的函数. 所有的请求被创建有给定的对齐, 并且请求的长度也匹配这个对齐. 缺省的掩码是 0x1ff, 它导致所有的请求被对齐到 512-字节边界.

void blk_queue_hardsect_size(request_queue_t *queue, unsigned short max);

告知内核你的设备的硬件扇区大小. 所有由内核产生的请求是这个大小的倍数并且被正确对齐. 所有的在块层和驱动之间的通讯继续以 512-字节扇区来表达, 但是.

请求的分析

在我们的简单例子里, 我们遇到了这个请求结构. 但是, 我们未曾接触这个复杂的数据结构. 在本节, 我们看, 详细地, 块 I/O 请求在 Linux 内核中如何被表示.

每个请求结构代表一个块 I/O 请求, 尽管它可能是由几个独立的请求在更高层次合并而成. 对任何特殊的请求而传送的扇区可能分布在整个主内存, 尽管它们常常对应块设备中的多个连续的扇区. 这个请求被表示为多个段, 每个对应一个内存中的缓冲. 内核可能合并多个涉及磁盘上邻近扇区的请求, 但是它从不合并在单个请求结构中的读和写操作. 内核还确保不合并请求, 如果结果会破坏任何的在前面章节中描述的请求队列限制.

基本上, 一个请求结构被实现为一个 bio 结构的链表, 结合一些维护信息来使驱动可以跟踪它的位置, 当它在完成这个请求中. 这个 bio 结构是一个块 I/O 请求移植的低级描述; 我们现在看看它.

1、 bio 结构

当内核, 以一个文件系统的形式, 虚拟文件子系统, 或者一个系统调用, 决定一组块必须传送到或从一个块 I/O 设备; 它装配一个 bio 结构来描述那个操作. 那个结构接着被递给这个块 I/O 代码, 这个代码合并它到一个存在的请求结构, 或者, 如果需要, 创建一个新的. 这个 bio 结构包含一个块驱动需要来进行请求的任何东西, 而不必涉及使这个请求启动的用户空间进程.

bio 结构, 在 <linux/bio.h> 中定义, 包含许多成员对驱动作者是有用的:

sector_t bi_sector;

这个 bio 要被传送的第一个(512字节)扇区.

unsigned int bi_size;

被传送的数据大小, 以字节计. 相反, 常常更易使用 bio_sectors(bio), 一个给定以扇区计的大小的宏.

unsigned long bi_flags;

一组描述 bio 的标志; 最低有效位被置位如果这是一个写请求(尽管宏 bio_data_dir(bio)应当用来代替直接加锁这个标志).

unsigned short bio_phys_segments;

unsigned short bio_hw_segments;

包含在这个 BIO 中的物理段的数目, 和在 DMA 映射完成后被硬件看到的段数目, 分别地.

一个 bio 的核心, 但是, 是一个称为 bi_io_vec 的数组, 它由下列结构组成:

struct bio_vec {

struct page  *bv_page;

unsigned int  bv_len;

unsigned int  bv_offset;

};

图 bio 结构显示了这些结构如何结合在一起. 如同你所见到的, 在一个块 I/O 请求被转换为一个 bio 结构后, 它已被分为单独的物理内存页. 所有的一个驱动需要做的事情是步进全部这个结构数组(它们有 bi_vcnt 个), 和在每个页内传递数据(但是只 len 字节, 从 offset 开始).

直接使用 bi_io_vec 数组不被推荐, 为了内核开发者可以在以后改变 bio 结构而不会引起破坏. 为此, 一组宏被提供来简化使用 bio 结构. 开始的地方是 bio_for_each_segment, 它简单地循环 bi_io_vec 数组中每个未被处理的项. 这个宏应当如下用:

int segno;

struct bio_vec *bvec;


bio_for_each_segment(bvec, bio, segno) {

/* Do something with this segment

}

在这个循环中, bvec 指向当前的 bio_vec 项, 并且 segno 是当前的段号. 这些值可被用来设置 DMA 发送器(一个使用 blk_rq_map_sg 的替代方法在"块请求和 DMA"一节中描述). 如果你需要直接存取页, 你应当首先确保一个正确的内核虚拟地址存在; 为此, 你可使用:

char *__bio_kmap_atomic(struct bio *bio, int i, enum km_type type);

void __bio_kunmap_atomic(char *buffer, enum km_type type);

这个底层的函数允许你直接映射在一个给定的 bio_vec 中找到的缓冲, 由索引 i 所指定的. 一个原子的 kmap 被创建; 调用者必须提供合适的来使用的槽位(如同在 15 章的"内存映射和 struct page"一节中描述的).

块层还维护一组位于 bio 结构的指针来跟踪请求处理的当前状态. 几个宏来提供对这个状态的存取:

struct page *bio_page(struct bio *bio);

返回一个指向页结构的指针, 表示下一个被传送的页.

int bio_offset(struct bio *bio);

返回页内的被传送的数据的偏移.

int bio_cur_sectors(struct bio *bio);

返回要被传送出当前页的扇区数.

char *bio_data(struct bio *bio);

返回一个内核逻辑地址, 指向被传送的数据. 注意这个地址可用仅当请求的页不在高内存中; 在其他情况下调用它是一个错误. 缺省地, 块子系统不传递高内存缓冲到你的驱动, 但是如果你已使用 blk_queue_bounce_limit 改变设置, 你可能不该使用 bio_data.

char *bio_kmap_irq(struct bio *bio, unsigned long *flags);

void bio_kunmap_irq(char *buffer, unsigned long *flags);

bio_kmap_irq 给任何缓冲返回一个内核虚拟地址, 不管它是否在高或低内存. 一个原子 kmap 被使用, 因此你的驱动在这个映射被激活时不能睡眠. 使用 bio_kunmap_irq 来去映射缓冲. 注意因为使用一个原子 kmap, 你不能一次映射多于一个段.

刚刚描述的所有函数都存取当前缓冲 -- 还未被传送的第一个缓冲, 只要内核知道. 驱动常常想使用 bio 中的几个缓冲, 在它们任何一个指出完成之前(使用 end_that_request_first, 马上就讲到), 因此这些函数常常没有用. 几个其他的宏存在来使用 bio 结构的内部接口(详情见 <linux/bio.h>).

2、 请求结构成员

现在我们有了 bio 结构如何工作的概念, 我们可以深入 struct request 并且看请求处理如何工作. 这个结构的成员包括:

sector_t hard_sector;

unsigned long hard_nr_sectors;

unsigned int hard_cur_sectors;

追踪请求硬件完成的扇区的成员. 第一个尚未被传送的扇区被存储到 hard_sector, 已经传送的扇区总数在 hard_nr_sectors, 并且在当前 bio 中剩余的扇区数是 hard_cur_sectors. 这些成员打算只用在块子系统; 驱动不应当使用它们.

struct bio *bio;

bio 是给这个请求的 bio 结构的链表. 你不应当直接存取这个成员; 使用 rq_for_each_bio(后面描述) 代替.

char *buffer;

在本章前面的简单驱动例子使用这个成员来找到传送的缓冲. 随着我们的深入理解, 我们现在可见到这个成员仅仅是在当前 bio 上调用 bio_data 的结果.

unsigned short nr_phys_segments;

被这个请求在物理内存中占用的独特段的数目, 在邻近页已被合并后.

struct list_head queuelist;

链表结构(如同在 11 章中"链表"一节中描述的), 连接这个请求到请求队列. 如果(并且只是)你从队列中去除 blkdev_dequeue_request, 你可能使用这个列表头来跟踪这个请求, 在一个被你的驱动维护的内部列表中.

请求完成函数

如同我们将见到的, 有几个不同的方式来使用一个请求结构. 它们所有的都使用几个通用的函数, 但是, 它们处理一个 I/O 请求或者部分请求的完成. 这 2 个函数都是原子的并且可从一个原子上下文被安全地调用.

当你的设备已经完成传送一些或者全部扇区, 在一个 I/O 请求中, 它必须通知块子系统, 使用:

int end_that_request_first(struct request *req, int success, int count);

这个函数告知块代码, 你的驱动已经完成 count 个扇区地传送, 从你最后留下的地方开始. 如果 I/O 是成功的, 传递 success 为 1; 否则传递 0. 注意你必须指出完成, 按照从第一个扇区到最后一个的顺序; 如果你的驱动和设备有些共谋来乱序完成请求, 你必须存储这个乱序的完成状态直到介入的扇区已经被传递.

从 end_that_request_first 的返回值是一个指示, 指示是否所有的这个请求中的扇区已经被传送或者没有. 一个 0 返回值表示所有的扇区已经被传送并且这个请求完成. 在这点, 你必须使用 blkdev_dequeue_request 来从队列中解除请求(如果你还没有这样做)并且传递它到:

void end_that_request_last(struct request *req);

end_that_request_last 通知任何在等待这个请求的人, 这个请求已经完成并且回收这个请求结构; 它必须在持有队列锁时被调用.

在我们的简单的 sbull 例子里, 我们不使用任何上面的函数. 相反, 那个例子, 被称为 end_request. 为显示这个调用的效果, 这里有整个的 end_request 函数, 如果在 2.6.10 内核中见到的:

void end_request(struct request *req, int uptodate)

{

if (!end_that_request_first(req, uptodate, req->hard_cur_sectors)) {

add_disk_randomness(req->rq_disk);

blkdev_dequeue_request(req);

end_that_request_last(req);

}

}

函数 add_disk_randomness 使用块 I/O 请求的定时来贡献熵给系统的随机数池; 它应当被调用仅当磁盘的定时是真正的随机的. 对大部分的机械设备这是真的, 但是对一个基于内存的虚拟设备它不是真的, 例如 sbull. 因此, 下一节中更复杂的 sbull 版本不调用 add_disk_randomness.

1、 使用 bio

现在你了解了足够多的来编写一个块驱动, 可直接使用组成一个请求的 bio 结构. 但是, 一个例子可能会有帮助. 如果这个 sbull 驱动被加载为 request_mode 参数被设为 1, 它注册一个知道 bio 的请求函数来代替我们上面见到的简单函数. 那个函数看来如此:

static void sbull_full_request(request_queue_t *q)

{

        struct request *req;

        int sectors_xferred;

        struct sbull_dev *dev = q->queuedata;

        while ((req = elv_next_request(q)) != NULL) {

                if (! blk_fs_request(req)) {

                        printk (KERN_NOTICE "Skip non-fs request/n");



                        end_request(req, 0);

                        continue;

                }

                sectors_xferred = sbull_xfer_request(dev, req);

                if (! end_that_request_first(req, 1, sectors_xferred)) {

                        blkdev_dequeue_request(req);

                        end_that_request_last(req);

                }

        }

}

这个函数简单地获取每个请求, 传递它到 sbull_xfer_request, 接着使用 end_that_request_first 和, 如果需要, end_that_request_last 来完成它. 因此, 这个函数在处理高级队列并且请求管理部分问题. 真正执行一个请求的工作, 但是, 落入 sbull_xfer_request:

static int sbull_xfer_request(struct sbull_dev *dev, struct request *req)

{

        struct bio *bio;

        int nsect = 0;



        rq_for_each_bio(bio, req)

        {

                sbull_xfer_bio(dev, bio);

                nsect += bio->bi_size/KERNEL_SECTOR_SIZE;



        }

        return nsect;

}

这里我们介绍另一个宏: rq_for_each_bio. 如同你可能期望的, 这个宏简单地步入请求中的每个 bio 结构, 给我们一个可传递给 sbull_xfer_bio 用于传输的指针. 那个函数看来如此:

static int sbull_xfer_bio(struct sbull_dev *dev, struct bio *bio)

{

        int i;

        struct bio_vec *bvec;

        sector_t sector = bio->bi_sector;



        /* Do each segment independently. */

        bio_for_each_segment(bvec, bio, i)

        {

                char *buffer = __bio_kmap_atomic(bio, i, KM_USER0);

                sbull_transfer(dev, sector, bio_cur_sectors(bio),



                               buffer, bio_data_dir(bio) == WRITE);

                sector += bio_cur_sectors(bio);

                __bio_kunmap_atomic(bio, KM_USER0);



        }

        return 0; /* Always "succeed" */

}

这个函数简单地步入每个 bio 结构中的段, 获得一个内核虚拟地址来存取缓冲, 接着调用之前我们见到的同样的 sbull_transfer 函数来拷贝数据.

每个设备有它自己的需要, 但是, 作为一个通用的规则, 刚刚展示的代码应当作为一个模型, 给许多的需要深入 bio 结构的情形.
2、块请求和 DMA

如果你工作在一个高性能块驱动上, 你有机会使用 DMA 来进行真正的数据传输. 一个块驱动当然可步入 bio 结构, 如同上面描述的, 为每一个创建一个 DMA 映射, 并且传递结构给设备. 但是, 有一个更容易的方法, 如果你的驱动可进行发散/汇聚 I/O. 函数:

int blk_rq_map_sg(request_queue_t *queue, struct request *req, struct scatterlist *list);

使用来自给定请求的全部段填充给定的列表. 内存中邻近的段在插入散布表之前被接合, 因此你不需要自己探测它们. 返回值是列表中的项数. 这个函数还回传, 在它第 3 个参数, 一个适合传递给 dma_map_sg 的散布表.(关于 dma_map_sg 的更多信息见 15 章的"发散-汇聚映射"一节).

你的驱动必须在调用 blk_rq_map_sg 之前给散布表分配存储. 这个列表必须能够至少持有这个请求有的物理段那么多的项; struct request 成员 nr_phys_segments 持有那个数量, 它不能超过由 blk_queue_max_phys_segments 指定的物理段的最大数目.

如果你不想 blk_rq_map_sg 来接合邻近的段, 你可改变这个缺省的行为, 使用一个调用诸如:

clear_bit(QUEUE_FLAG_CLUSTER, &queue->queue_flags);

一些 SCSI 磁盘驱动用这样的方式标识它们的请求队列, 因为它们没有从接合请求中获益.

块设备驱动(三)


三、不用一个请求队列

前面, 我们已经讨论了内核所作的在队列中优化请求顺序的工作; 这个工作包括排列请求和, 或许, 甚至延迟队列来允许一个预期的请求到达. 这些技术在处理一个真正的旋转的磁盘驱动器时有助于系统的性能. 但是, 使用一个象 sbull 的设备它们是完全浪费了. 许多面向块的设备, 例如闪存阵列, 用于数字相机的存储卡的读取器, 并且 RAM 盘真正地有随机存取的性能, 包含从高级的请求队列逻辑中获益. 其他设备, 例如软件 RAID 阵列或者被逻辑卷管理者创建的虚拟磁盘, 没有这个块层的请求队列被优化的性能特征. 对于这类设备, 它最好直接从块层接收请求, 并且根本不去烦请求队列.

对于这些情况, 块层支持"无队列"的操作模式. 为使用这个模式, 你的驱动必须提供一个"制作请求"函数, 而不是一个请求函数. make_request 函数有这个原型:

typedef int (make_request_fn) (request_queue_t *q, struct bio *bio);

注意一个请求队列仍然存在, 即便它从不会真正有任何请求. make_request 函数用一个 bio 结构作为它的主要参数, 这个 bio 结构表示一个或多个要传送的缓冲. make_request 函数做 2 个事情之一: 它可或者直接进行传输, 或者重定向这个请求到另一个设备.

直接进行传送只是使用我们前面描述的存取者方法来完成这个 bio. 因为没有使用请求结构, 但是, 你的函数应当通知这个 bio 结构的创建者直接指出完成, 使用对 bio_endio 的调用:

void bio_endio(struct bio *bio, unsigned int bytes, int error);

这里, bytes 是你至今已经传送的字节数. 它可小于由这个 bio 整体所代表的字节数; 在这个方式中, 你可指示部分完成, 并且更新在 bio 中的内部的"当前缓冲"指针. 你应当再次调用 bio_endio 在你的设备进行进一步处理时, 或者当你不能完成这个请求指出一个错误. 错误是通过提供一个非零值给 error 参数来指示的; 这个值通常是一个错误码, 例如 -EIO. make_request 应当返回 0, 不管这个 I/O 是否成功.

如果 sbull 用 request_mode=2 加载, 它操作一个 make_request 函数. 因为 sbull 已经有一个函数看传送单个 bio, 这个 make_request 函数简单:

static int sbull_make_request(request_queue_t *q, struct bio *bio)

{

        struct sbull_dev *dev = q->queuedata;

        int status;

        status = sbull_xfer_bio(dev, bio);

        bio_endio(bio, bio->bi_size, status);

        return 0;

}

请注意你应当从不调用 bio_endio 从一个通常的请求函数; 那个工作由 end_that_request_first 代替来处理.

一些块驱动, 例如那些实现卷管理者和软件 RAID 阵列的, 真正需要重定向请求到另一个设备来处理真正的 I/O. 编写这样的一个驱动超出了本书的范围. 我们, 但是, 注意如果 make_request 函数返回一个非零值, bio 被再次提交. 一个"堆叠"驱动, 可, 因此, 修改 bi_bdev 成员来指向一个不同的设备, 改变起始扇区值, 接着返回; 块系统接着传递 bio 到新设备. 还有一个 bio_split 调用来划分一个 bio 到多个块以提交给多个设备. 尽管如果队列参数被之前设置, 划分一个 bio 几乎从不需要.

任何一个方式, 你都必须告知块子系统, 你的驱动在使用一个自定义的 make_request 函数. 为此, 你必须分配一个请求队列, 使用:

request_queue_t *blk_alloc_queue(int flags);

这个函数不同于 blk_init_queue, 它不真正建立队列来持有请求. flags 参数是一组分配标志被用来为队列分配内存; 常常地正确值是 GFP_KERNEL. 一旦你有一个队列, 传递它和你的 make_request 函数到 blk_queue_make_request:

void blk_queue_make_request(request_queue_t *queue, make_request_fn *func);

sbull 代码来设置 make_request 函数, 象:

dev->queue = blk_alloc_queue(GFP_KERNEL);

if (dev->queue == NULL)

        goto out_vfree;

blk_queue_make_request(dev->queue, sbull_make_request);

对于好奇的人, 花些时间深入 drivers/block/ll_rw_block.c 会发现, 所有的队列都有一个 make_request 函数. 缺省的版本, generic_make_request, 处理 bio 和一个请求结构的结合. 通过提供一个它自己的 make_request 函数, 一个驱动真正只覆盖一个特定的请求队列方法, 并且排序大部分工作。


块设备总结一

块设备与字符设备的区别

1、  从字面上理解,块设备和字符设备最大的区别在于读写数据的基本单元不同。块设备读写数据的基本单元为块,例如磁盘通常为一个sector,而字符设备的基本单元为字节。所以Linux中块设备驱动往往为磁盘设备的驱动,但是由于磁盘设备的IO性能与CPU相比很差,因此,块设备的数据流往往会引入文件系统的Cache机制。

2、 从实现角度来看,Linux为块设备和字符设备提供了两套机制。字符设备实现的比较简单,内核例程和用户态API一一对应,用户层的Read函数直接对应了内核中的Read例程,这种映射关系由字符设备的file_operations维护。块设备接口相对于字符设备复杂,read、write API没有直接到块设备层,而是直接到文件系统层,然后再由文件系统层发起读写请求。

块设备读写流程

在学习块设备原理的时候,我最关系块设备的数据流程,从应用程序调用Read或者Write开始,数据在内核中到底是如何流通、处理的呢?然后又如何抵达具体的物理设备的呢?下面对一个带Cache功能的块设备数据流程进行分析。

1、  用户态程序通过open()打开指定的块设备,通过systemcall机制陷入内核,执行blkdev_open()函数,该函数注册到文件系统方法(file_operations)中的open上。在blkdev_open函数中调用bd_acquire()函数,bd_acquire函数完成文件系统inode到块设备bdev的转换,具体的转换方法通过hash查找实现。得到具体块设备的bdev之后,调用do_open()函数完成设备打开的操作。在do_open函数中会调用到块设备驱动注册的open方法,具体调用如下:gendisk->fops->open(bdev->bd_inode, file)。

2、  用户程序通过read、write函数对设备进行读写,文件系统会调用相应的方法,通常会调用如下两个函数:generic_file_read和blkdev_file_write。在读写过程中采用了多种策略,首先分析读过程。

3、 用户态调用了read函数,内核执行generic_file_read,如果不是direct io方式,那么直接调用do_generic_file_read->do_generic_mapping_read()函数,在do_generic_mapping_read(函数位于filemap.c)函数中,首先查找数据是否命中Cache,如果命中,那么直接将数据返回给用户态;否则通过address_space->a_ops->readpage函数发起一个真实的读请求。在readpage函数中,构造一个buffer_head,设置bh回调函数end_buffer_async_read,然后调用submit_bh发起请求。在submit_bh函数中,根据buffer_head构造bio,设置bio的回调函数end_bio_bh_io_sync,最后通过submit_bio将bio请求发送给指定的快设备。

4、  如果用户态调用了一个write函数,内核执行blkdev_file_write函数,如果不是direct io操作方式,那么执行buffered write操作过程,直接调用generic_file_buffered_write函数。Buffered write操作方法会将数据直接写入Cache,并进行Cache的替换操作,在替换操作过程中需要对实际的快设备进行操作,address_space->a_ops提供了块设备操作的方法。当数据被写入到Cache之后,write函数就可以返回了,后继异步写入的任务绝大部分交给了pdflush daemon(有一部分在替换的时候做了)

5、  数据流操作到这一步,我们已经很清楚用户的数据是如何到内核了。与用户最接近的方法是file_operations,每种设备类型都定义了这一方法(由于Linux将所有设备都看成是文件,所以为每类设备都定义了文件操作方法,例如,字符设备的操作方法为def_chr_fops,块设备为def_blk_fops,网络设备为bad_sock_fops)。每种设备类型底层操作方法是不一样的,但是通过file_operations方法将设备类型的差异化屏蔽了,这就是Linux能够将所有设备都理解为文件的缘由。到这里,又提出一个问题:既然这样,那设备的差异化又该如何体现呢?在文件系统层定义了文件系统访问设备的方法,该方法就是address_space_operations,文件系统通过该方法可以访问具体的设备。对于字符设备而言,没有实现address_space_operations方法,也没有必要,因为字符设备的接口与文件系统的接口是一样的,在字符设备open操作的过程中,将inode所指向的file_operations替换成cdev所指向的file_operations就可以了。这样用户层读写字符设备可以直接调用cdev中file_operations方法了。

6、  截至到步骤(4),读操作在没有命中Cache的情况下通过address_space_operations方法中的readpage函数发起块设备读请求;写操作在替换Cache或者Pdflush唤醒时发起块设备请求。发起块设备请求的过程都一样,首先根据需求构建bio结构,bio结构中包含了读写地址、长度、目的设备、回调函数等信息。构造完bio之后,通过简单的submit_bio函数将请求转发给具体的块设备。从这里可以看出,块设备接口很简单,接口方法为submit_bio(更底层函数为generic_make_request),数据结构为struct bio。

7、  submit_bio函数通过generic_make_request转发bio,generic_make_request是一个循环,其通过每个块设备下注册的q->make_request_fn函数与块设备进行交互。如果访问的块设备是一个有queue的设备,那么会将系统的__make_request函数注册到q->make_request_fn中;否则块设备会注册一个私有的方法。在私有的方法中,由于不存在queue队列,所以不会处理具体的请求,而是通过修改bio中的方法实现bio的转发,在私有make_request方法中,往往会返回1,告诉generic_make_request继续转发比bio。Generic_make_request的执行上下文可能有两种,一种是用户上下文,另一种为pdflush所在的内核线程上下文。

8、 通过generic_make_request的不断转发,最后请求一定会到一个存在queue队列的块设备上,假设最终的那个块设备是某个scsi disk(/dev/sda)。generic_make_request将请求转发给sda时,调用__make_request,该函数是Linux提供的块设备请求处理函数。在该函数中实现了极其重要的操作,通常所说的IO Schedule就在该函数中实现。在该函数中试图将转发过来的bio merge到一个已经存在的request中,如果可以合并,那么将新的bio请求挂载到一个已经存在request中。如果不能合并,那么分配一个新的request,然后将bio添加到其中。这一切搞定之后,说明通过generic_make_request转发的bio已经抵达了内核的一个站点——request,找到了一个临时归宿。此时,还没有真正启动物理设备的操作。在__make_request退出之前,会判断一个bio中的sync标记,如果该标记有效,说明请求的bio是一个是实时性很强的操作,不能在内核中停留,因此调用了__generic_unplug_device函数,该函数将触发下一阶段的操作;如果该标记无效的话,那么该请求就需要在queue队列中停留一段时间,等到queue队列触发闹钟响了之后,再触发下一阶段的操作。__make_request函数返回0,告诉generic_make_request无需再转发bio了,bio转发结束。

9、  到目前为止,文件系统(pdflush或者address_space_operations)发下来的bio已经merge到request queue中,如果为sync bio,那么直接调用__generic_unplug_device,否则需要在unplug timer的软中断上下文中执行q->unplug_fn。后继request的处理方法应该和具体的物理设备相关,但是在标准的块设备上如何体现不同物理设备的差异性呢?这种差异性就体现在queue队列的方法上,不同的物理设备,queue队列的方法是不一样的。举例中的sda是一个scsi设备,在scsi middle level将scsi_request_fn函数注册到了queue队列的request_fn方法上。在q->unplug_fn(具体方法为:generic_unplug_device)函数中会调用request队列的具体处理函数q->request_fn。Ok,到这一步实际上已经将块设备层与scsi总线驱动层联系在了一起,他们的接口方法为request_fn(具体函数为scsi_request_fn)。

10、明白了第(9)点之后,接下来的过程实际上和具体的scsi总线操作相关了。在scsi_request_fn函数中会扫描request队列,通过elv_next_request函数从队列中获取一个request。在elv_next_request函数中通过scsi总线层注册的q->prep_rq_fn(scsi层注册为scsi_prep_fn)函数将具体的request转换成scsi驱动所能认识的scsi command。获取一个request之后,scsi_request_fn函数直接调用scsi_dispatch_cmd函数将scsi command发送给一个具体的scsi host。到这一步,有一个问题:scsi command具体转发给那个scsi host呢?秘密就在于q->queuedata中,在为sda设备分配queue队列时,已经指定了sda块设备与底层的scsi设备(scsi device)之间的关系,他们的关系是通过request queue维护的。

11、 在scsi_dispatch_cmd函数中,通过scsi host的接口方法queuecommand将scsi command发送给scsi host。通常scsi host的queuecommand方法会将接收到的scsi command挂到自己维护的队列中,然后再启动DMA过程将scsi command中的数据发送给具体的磁盘。DMA完毕之后,DMA控制器中断CPU,告诉CPU DMA过程结束,并且在中断上下文中设置DMA结束的中断下半部。DMA中断服务程序返回之后触发软中断,执行SCSI中断下半部。

12、     在SCSi中断下半部中,调用scsi command结束的回调函数,这个函数往往为scsi_done,在scsi_done函数调用blk_complete_request函数结束请求request,每个请求维护了一个bio链,所以在结束请求过程中回调每个请求中的bio回调函数,结束具体的bio。Bio又有文件系统的buffer head生成,所以在结束bio时,回调buffer_head的回调处理函数bio->bi_end_io(注册为end_bio_bh_io_sync)。自此,由中断引发的一系列回调过程结束,总结一下回调过程如下:scsi_done->end_request->end_bio->end_bufferhead。

13、  回调结束之后,文件系统引发的读写操作过程结束。

块设备驱动编写总结二
初始化一个块设备


每个块设备都拥有一个操作接口:struct block_device_operations,该接口定义了open、close、ioctl等函数接口,但没有,也没有必要定义read、write函数接口。

初始化一个块设备的过程如下:

int setup_device(block_dev_t *dev, int minor)

{

       int hardsect_size = HARDSECT_SIZE;

       int chunk_size;

       sector_t dev_size;

       /* 分配一个请求队列 */

       dev->queue = blk_alloc_queue(GFP_KERNEL);

       if (dev->queue == NULL) {

              printk(ERROR, "blk_alloc_queue failure!/n");

              return -ENOMEM;

       }


       chunk_size = dev->chunk_size >> 9;    //sectors

       /* 将block_make_request注册到q->make_request上 */

       blk_queue_make_request(dev->queue, block_make_request);

       blk_queue_max_sectors(dev->queue, chunk_size);

       blk_queue_hardsect_size(dev->queue, hardsect_size);

       blk_queue_merge_bvec(dev->queue, block_mergeable_bvec);

       dev->queue->queuedata = dev;

       /* 将block_unplug注册到q->unplug_fn上 */

       dev->queue->unplug_fn = block_unplug;

       /* 分配一个gendisk */

       dev->gd = alloc_disk(1);

       if (!dev->gd) {

              prink(ERROR, "alloc_disk failure!/n");

              blk_cleanup_queue(dev->queue);

              return -ENOMEM;

       }

       dev->gd->major = block_major;               /* 设备的major号 */

       dev->gd->first_minor = minor;            /* 设备的minor号 */

       dev->gd->fops = &block_ops;                 /* 块设备的操作接口,open、close、ioctl */

       dev->gd->queue = dev->queue;           /* 块设备的请求队列 */

       dev->gd->private_data = dev;                    

       snprintf(dev->gd->disk_name, 32, dev->block_name);

       dev_size = (sector_t) dev->dev_size >> 9;  

       set_capacity(dev->gd, dev_size);         /* 设置块设备的容量 */

       add_disk(dev->gd);                                   /* 添加块设备 */

       return 0;
}


注册/释放一个块设备

       通过register_blkdev函数将块设备注册到Linux系统。示例代码如下:

static int blockdev_init(void)

{

       …

block_major = register_blkdev(block_major, "blockd");

       if (block_major <= 0) {

              printk(ERROR, "blockd: cannot get major %d/n", block_major);

              return -EFAULT;

       }


}

       通过unregister_blkdev函数清除一个块设备。示例代码如下:

static int blockdev_cleanup(void)

{

       …

unregister_blkdev(block_major, "blockd");



}
make_request函数

make_request函数是块设备中最重要的接口函数,每个块设备都需要提供make_request函数。如果块设备为有请求队列的实际设备,那么make_request函数被注册为__make_request,该函数由Linux系统提供;反之,需要用户提供私有函数。__make_request函数功能在前文已述。

在用户提供的私有make_request函数中往往对bio进行过滤处理,这样的驱动在Linux中有md(raid0、raid1、raid5),过滤处理完毕之后,私有make_request函数返回1,告诉generic_make_request函数进行bio转发。
分享到: QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友

返回列表
网页右侧QQ悬浮滚动在线客服
网页右侧QQ悬浮滚动在线客服