一个进程在某个磁盘文件上发出一个 read() 系统调用,内核对进程请求回应的一般步骤:
块设备中的数据存储涉及了许多内核组件,每个组件采用不同长度的块管理磁盘数据:
块设备的每次数据传输都作用于一组称为扇区的相邻字节。
大部分磁盘设备中,扇区大小为 512 字节。
扇区的下标存放在类型为 sector_t 的 32 位或 64 位的变量中。
扇区是硬件设备传输数据的基本单位,而块是 VFS 和文件系统传输数据的基本单位。
Linux 中,块大小必须是 2 的幂,且不能超过一个页框。
此外,它必须是扇区大小的整数倍,因为每个块必须包含整个扇区。
每个块都需要自己的块缓冲区,它是内核用来存放内容的 RAM 内存区。
缓冲区的首部是一个与每个缓冲区相关的 buffer_head 类型的描述符。
buffer_head 中的某些字段:
对磁盘的每个 I/O 操作就是在磁盘与一些 RAM 单元间相互传送一些相邻扇区是内容。
大多数情况下,磁盘控制器之间采用 DMA 方式进行数据传送。
块设备驱动程序只要向磁盘控制器发送一些适当的命令就可以触发一次数据传送,完成后,控制器会发出一个中断通知块设备驱动程序。
新的磁盘控制器支持所谓的分散-聚集 DMA 传送方式:磁盘可与一些非连续的内容区相互传送数据。
启动一次分散-聚集 DMA 传送,块设备驱动程序需要向磁盘控制器发送:
磁盘控制器负责整个数据传送。
为了使用分散-聚集 DMA 传送方式,块设备驱动程序必须能处理称为段的数据存储单元。
一个段就是一个内存页或内存页中的一部分,它们包含一些相邻磁盘扇区中的数据。
因此,一次分散-聚集 DMA 操作可能同时传送几个段。
如果不同的段在 RAM 中相应的页框正好是连续的且在磁盘上相应的数据块也是相邻的,那么通用块层可合并它们,产生更大的物理段。
通用块层是一个内核组件,它处理来自系统中的所有块设备发出的请求。
由于该层提供的函数,内核可容易地做到:
bio 描述符,通用块的核心数据结构,描述了块设备的 I/O 操作。
每个 bio 结构都包含一个磁盘存储区标识符(存储区中的起始扇区号和扇区数目)和一个或多个描述符与 I/O 操作相关的内存区的段。
bio 中的某些字段:
当通用块层启动一次新的 I/O 操作时,调用 bio_alloc() 分配一个新的 bio 结构。
bio 结构由 slab 分配器分配,内存不足时,内核也会使用一个备用的 bio 小内存池。
内核也为 bio_vec 分配内存池。
bip_put() 减少 bi_cnt,等于 0 时,释放 bio 结构及相关的 bio_vec 结构。
磁盘是一个由通用块层处理的逻辑块设备。
通常一个磁盘对应一个硬件块设备,如硬盘、软盘或光盘,也可是一个虚拟设备,建立在几个物理磁盘分区上或一些 RAM 专用页中的内存区上。
任何情形中,借助通用块层提供的服务,上层内核组件可以同样的方式工作在所在的磁盘上。
磁盘由 gendisk 对象描述,某些字段:
通常硬盘被划分成几个逻辑分区。每个块设备文件要么代表整个磁盘,要么代表磁盘中的某个分区。
如果将一个磁盘分成几个分区,则分区表保存在 hd_struct 结构的数组中。
alloc_disk():当内核发现系统中一个新的磁盘时(在启动阶段,或将一个可移动介质插入一个驱动器中时,或在运行器附加一个外置式磁盘时),分配并初始化一个新的 gendisk 对象,如果新磁盘被分成几个分区,还会分配并初始化一个适当的 hd_struct 类型的数组。
add_disk():将新的 gendisk 对象插入到通用块层的数据结构中。
当向通用块层提交一个 I/O 操作请求时,内核所执行的步骤(假设被请求的数据块在磁盘上相邻,且内核已经知道了它们的物理位置)。
首先,bio_alloc() 分配一个新的 bio 描述符,然后,内核通过设置一些字段初始化 bio 描述符:
bio 描述符被初始化后,内核调用 generic_make_request(),它是通用块层的主要入口点,该函数执行下列操作:
总结:主要是分配并初始化 bio 描述符,以描述符 I/O 操作请求;获取请求队列,将相对于磁盘分区的 I/O 操作请求转换为相对于整个磁盘的 I/O 操作请求;I/O 操作请求入队列。
只要可能,内核就试图把几个扇区合并在一起,作为一个整体处理,以减少磁头的平均移动时间。
当内核组件要读或写一些磁盘数据时,会创建一个块设备请求。
请求描述的时所请求的扇区及要对它执行的操作类型(读或写)。
但请求发出后内核不一定会立即满足它,I/O 操作仅仅被调度,执行会向后推迟。
当请求传送要给新的数据块时,内核检查能否通过稍微扩展前一个一直处于等待状态的请求而满足新的请求。
延迟请求复杂化了块设备的处理。
因为块设备驱动程序本身不会阻塞,否则会阻塞试图访问同一磁盘的任何其他进程。
为防止块设备驱动程序被挂起,每个 I/O 操作都是异步处理的。
特别是块设备驱动程序是中断驱动的:
每个块设备驱动程序都维持着自己的请求队列,它包含设备待处理的请求链表。
如果磁盘控制器正在处理几个磁盘,那么通常每个物理块都有一个请求队列。
在每个请求队列上单独执行 I/O 调度,可提供高盘性能。
请求队列由一个大的数据结构 request_queue 表示。
request_queue 中的某些字段:
每个块设备的待处理请求都是用一个请求描述符表示的,存放于 request 数据结构。
对请求描述符的分配进行管理
在重负载和磁盘操作频繁时,固定数目的动态内存将成为进程想把新请求加入请求队列 q 的瓶颈。
为解决该问题,每个 request_queue 描述符包含一个 request_list 数据结构,其中包括:
blk_get_request() 从一个特定请求队列的内存池中获得一个空闲的请求描述符;
如果内存区不足且内存池已经用完,则挂起当前进程,或返回 NULL(不能阻塞内核控制路径)。
如果分配成功,则将请求队列的 request_list 数据结构的地址存放在请求描述符的 rl 字段。
blk_put_request() 释放一个请求描述符;如果该描述符的引用计数器为 0,则将描述符归还回它原来所在的内存池。
避免请求队列拥塞
request_queue 的 nr_requests 字段存放每个数据传送方向所允许处理的最大请求数。
缺省情况下,一个队列至多 128 个待处理读请求和 128 个待处理写请求。
如果待处理的读(写)请求数超过了 nr_requests,设置 request_queue 的 queue_flags 字段的 QUEUE_FLAG_READFULL(QUEUE_FLAG_WRITEFULL)标志将该队列标记为已满,
试图把请求加入某个传送方向的可阻塞进程被放到 request_list 结构所对应的等待队列中睡眠。
如果给定传送方向上的待处理请求数超过了 request 的 nr_congestion_on 字段中的值(缺省为 113),则内核认为该队列是拥塞的,并试图降低新请求的创建速率。
blk_congestion_wait() 挂起当前进程,直到所请求队列都变为不拥塞或超时已到。
延迟激活块设备驱动程序有利于集中相邻块的请求。
这种延迟是通过设备插入和设备拔出技术实现的。
块设备驱动程序被插入时,该驱动程序不被激活,即使在驱动程序队列中有待处理的请求。
blk_plug_device() 插入一个块设备:插入到某个块设备驱动程序的请求队列中。
参数为一个请求队列描述符的地址 q。
设置 q->queue_flags 字段中的 QUEUE_FLAG_PLUGGED 位,然后重启 q->unplub_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()。
因而,唤醒内核线程 kblocked 所操作的工作队列 kblocked_workueue。
kblocked 执行 blk_unplug_work(),其地址存放在 q->unplug_work 中。
接着,该函数会调用请求队列中的 q->unplug_fn 方法,该方法通常由 generic_unplug_device() 实现。
generic_unplug_device() 的功能是拔出块设备:
I/O 调度程序也被称为电梯算法。
Linux 2.6 中提供了四种不同类型的 I/O 调度程序或电梯算法,分别为“预期”算法,“最后期限”算法,“CFQ(完全公平队列)”算法,及“Noop(No Operation)”算法。
对于大多数块设备,内核使用缺省电梯算法可在引导时通过内核参数 elevator=
缺省为“预期”I/O 调度程序。设备驱动程序也可定制自己的 I/O 调度算法。
系统管理员可为一个特定的块设备改变 I/O 调度程序。
如,为了改变第一个 IDE 通道的主磁盘所使用的 I/O 调度程序,管理员可把一个电梯算法的名称写入 sysfs 特殊文件系统的 /sys/block/hada/queue/scheduler 文件中。
请求队列中使用的 I/O 调度算法由一个 elevator_t 类型的 elevator 对象表示,该对象的地址存放在请求队列描述符的 elevator 字段。
elevator 对象包含了几个方法:链接和断开 elevator,增加和合并队列中的请求,从队列中删除请求,获得队列中下一个待处理的请求等。
elevator 也存放了一个表的地址,表中包含了处理请求队列所需的所有信息。
每个请求描述符包含一个 elevator_private 字段,指向一个由 I/O 调度程序用来处理请求的附加数据结构。
一般,所有的算法都使用一个调度队列,队列中包含的所有请求按照设备驱动程序应当处理的顺序排序。
几乎所有的算法都使用另外的队列对请求进行分类和排序。
它们允许设备驱动将 bio 结构增加到已存放请求中,还可合并两个“相邻的”请求。
“Noop”算法
最简单的 I/O 调度算法。没有排序的队列。
“CFQ”算法
目标是在触发 I/O 请求的所有进程中确保磁盘 I/O 带宽的公平分配。
为此,算法使用多个排序队列(缺省为 64)存放不同进程发出的请求。
当处理一个请求时,内核调用一个散列函数将当前进程的线程组标识符换为队列的索引值,然后将一个新的请求插入该队列的末尾。
算法采用轮询方式扫描 I/O 输入队列,选择第一个非空队列,然后将该队列中的一组请求移动到调度队列的末尾。
“最后期限”算法
除了调度队列外,还使用了四个队列。
其中的两个排序队列分别包含读请求和写请求,请求根据起始扇区数排序。
另外两个最后期限队列包含相同的读和写请求,但根据“最后期限”排序。
引入这些队列是为了避免请求饿死。
补充调度队列:
“预期”算法
是 Linux 提供的最复杂的一种 I/O 调度算法。
它是“最后期限”算法的一个演变:两个最后期限队列和两个排序队列;I/O 调度程序在读和写请求之间交互扫描排序队列,不过更倾向于读请求。
扫描基本上是连续的,除非某个请求超时。读请求的缺省超时时间是 125ms,写请求为 250ms。
算法还遵循一些附加的启发式规则:
generic_make_request() 调用请求队列描述符的 make_request_fn 方法向 I/O 调度程序发送一个请求。
通常该方法由 __make_request() 实现,__make_request() 参数为 request_queue 类型的描述符 q、bio 结构的描述符 bio。执行下列操作:
总结:根据请求队列是否为空,不空时是否与已有请求合并,来确定 bio 与现有请求合并还是新分配、初始化一个新的 bio 描述符,并插入请求链表。然后根据需要卸载驱动程序,函数终止。
blk_queue_bounce()
功能是查看 q->bounce_gfp 中的标志及 q->bounce_pfn 中的阈值,从而确定回弹缓冲区是否必须。
通常当请求中的一些缓冲区位于高端内存,而硬件设备不能访问它们时发生该情况。
当处理老式设备时,块设备驱动程序通常更倾向于直接在 ZONE_DMA 内存区分配 DMA 缓冲区。
如果硬件设备不能处理高端内存中的缓冲区,则 blk_queue_bounce() 检查 bio 中的一些缓冲区是否真的必须是回弹的。
如果是,则将 bio 描述符复制一份,接着创建一个回弹 bio;当段中的页框号等于或大于 q->bounce_pfn 时,执行下列操作:
然后 blk_queue_bounce() 设置回弹 bio 中的 BIO_BOUNCED 标志,为其初始化一个特定的 bi_end_io 方法,最后它将存放在 bio 的 bi_private 字段中,该字段指向初始 bio 的指针。
当回弹 bio 上的 I/O 数据传送终止时,bi_end_io 方法将数据复制到高端内存区中(仅适合读操作),并释放该回弹 bio 结构。
块设备驱动程序是 Linux 块子系统中最底层组件。
它们从 I/O 调度程序获得请求,然后按要求处理这些请求。
一个块设备驱动程序可能处理几个块设备。
每个块设备由一个 block_device 结构描述符表示。
block_device 的某些字段:
访问块设备
当内核接收一个打开块设备文件的请求时,必须先确定该设备文件是否已经是打开的。
如果是,则内核没必要创建并初始化一个新的块设备描述符,而是更新已存在的块设备描述符。
然而,真正的复杂性在于具有相同主设备号和次设备号但不同路径名的块设备被 VFS 看作不同的文件。
因此,内核无法通过简单地在一个对象的索引节点高速缓存中检查块设备文件的存在就确定相应的块设备已经在使用。
主、次设备号和相应的块设备描述符之间的关系是通过 bdev 特殊文件系统来维护的。
每个块设备描述符都对应一个 bdev 特殊文件:
块设备描述符的 bd_inode 字段指向相应的 bdev 索引节点;
而该索引节点将为块设备的主、次设备号和相应描述符的地址进行编码。
bdget() 参数为块设备的主设备号和次设备号,在 bdev 文件系统中查询相关的索引节点;
如果不存在这样的节点,则分配一个新索引节点和新块设备描述符。
返回一个与给定主、次设备号对应的块设备描述符的地址。
找到块设备描述符后,内核通过检查 bd_openers 字段来确定块设备当前是否在使用:
如果为正值,则块设备已经在使用(可能通过不同的设备文件)。
同时,内核也维护一个与已打开的块设备文件对应的索引节点对象的链表。
该链表存放在块设备描述符的 bd_inodes 字段;
索引节点对象的 i_devices 字段存放于链接链表中的前后元素的指针。
定义驱动程序描述符
首先,设备驱动程序需要一个 foo_dev_t 类型的自定义描述符 foo,它拥有驱动硬件设备所需的数据。
该描述符存放每个设备的相关信息,如操作设备使用的 I/O 端口、设备发出中断的 IRQ 线、设备的内部状态等。
同时也包含块 I/O 子系统所需的一些字段:
struct foo_dev_t
{
[...]
spinlock_t lock; // 保护 foo 描述符中字段值的自旋锁
struct gendisk *gd; // 指向 gendisk 描述符的指针,该描述符描述由该驱动程序处理的整个块设备
[...]
};
预定主设备号
驱动程序通过 register_blkdev() 预定一个主设备号:
// 预定主设备号 FOO_MAJOR 并将设备名称 foo 赋给它
// 预定的主设备号和驱动程序之间的数据结构还没有建立连接
// 结果为产生一个新条目,该条目位于 /proc/devices 特殊文件的已注册设备号列表中
err = register_blkdev(FOO_MAJOR, "foo");
if(err)
goto error_major_is_busy;
初始化自定义描述符
为初始化于块 I/O 子系统相关的字段,设备驱动程序主要执行下列操作:
spin_lock_init(&foo.lock); // 初始化自旋锁
// 分配一个磁盘描述符,也分配一个存放磁盘分区描述符的数组
// 16 表示驱动程序可支持 16 个磁盘,每个磁盘可包含 15 个分区(0 分区不使用)
foo.gd = alloc_disk(16);
if(!foo.gd)
goto error_no_gendisk;
初始化 gendisk 描述符
接下来,驱动程序初始化 gendisk 描述符的一些字段:
// foo 描述符的地址存放在 gendisk 的 private_data 字段
// 因此被块 I/O 子系统当作方法调用的低级驱动程序函数可迅速查找到驱动程序描述符
// 如果驱动程序可并发地处理多个磁盘,可提高效率
foo.gd->private_data = &foo;
foo.gd->major = FOO_MAJOR;
foo.gd->first_minor = 0;
foo.gd->minors = 16;
// 将 capacity 字段初始化为以 512 字节扇区为单位的磁盘大小,该值也可能在探测硬件并询问磁盘参数时确定。
set_capacity(foo.gd, foo_disk_capacity_in_sectors);
strcpy(foo.gd->disk_name, "foo");
foo.gd->fops = &foo_ops;
初始化块设备操作表
gendisk 描述符的 fops 字段步初始化为自定义的块设备方法表的地址。
类似地,设备驱动程序的 foo_ops 表中包含设备驱动程序的特有函数。
如,如果硬件设备支持可移动磁盘,通用块将调用 media_changed 方法检测自从最后一次安装或打开该设备以来,磁盘是否被更换。
通常通过硬件控制器发送一些低级命令完成该检查,因此,每个设备驱动程序所实现的 media_changed 方法都不同。
类似地,仅当通用块层不知道如何处理 ioctl 命令时才调用 ioctl 方法。
如,当一个 ioctl() 询问磁盘构造时,即磁盘使用的柱面数、磁道数、扇区数即磁头数时,通常用该方法。
因此,每个设备驱动程序所实现的 ioctl 方法也都不同。
分配和初始化请求队列
// 分配一个请求队列描述符,并将其中许多字段初始化为缺省值
// 参数为设备描述符的自旋锁的地址(foo.gd->rq->queue_lock)
// 和设备驱动程序的策略例程的地址(foo.gd->rq->request_fn)
// 也初始化 foo.gd->rq->elevator 字段为缺省的 I/O 调度算法
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);
设置中断处理程序
设备驱动程序为设备注册 IRQ 线:
// foo_interrupt() 是设备的中断处理程序
request_irq(foo_irq, foo_interrupt, SA_INTERRUPT | SA_INTERRUPT | SA_SHIRQ, "foo", NULL);
注册磁盘
最后一步是“注册”和激活磁盘,可简单地通过执行下面的操作完成:
// 参数为 gendisk 描述符的地址
add_disk(foo.gd);
add_disk() 执行下面步骤:
一旦 add_disk() 返回,设备驱动程序就可以工作了。
进程初始化的函数终止;策略例程和中断处理程序开始处理 I/O 调度程序传送给设备驱动程序的每个请求。
策略例程是块设备驱动程序的一个函数或一组函数,它与硬件块设备之间相互作用以满足调度队列中的请求。
通过请求队列描述符中的 request_fn 方法可调用策略例程,如 foo_strategy(),I/O 调度程序层将请求队列描述符 q 的地址传给该函数。
把新的请求插入空的请求队列后,策略例程通常才被启动。
只要块设备驱动程序被激活,就应该对队列中的所有请求进行处理,直到队列为空才结束。
块设备驱动程序采用如下策略:
请求是由几个 bio 结构组成的,而每个 bio 结构又由几个段组成。
基本上,块设备驱动程序以以下方式使用 DMA:
设备驱动程序策略例程的设计依赖块控制器的特性。
如,foo_strategy() 策略例程执行下列操作:
req = elv_next_request(q);
if(!req)
return;
if(!blk_fs_request(req))
goto handle_special_request;
rq_for_each_bio(bio, rq)
bio_for_each_segment(bvec, bio, i)
{
local_irq_save(flags);
// 如果要传送的数据位于高端内存,kmap_atomic() 和 kunmap_atomic() 是必需的
addr = kmap_atomic(bvec->bv_page, KM_BIO_SRC_IRQ);
// 对硬件设备进行编程,以便启动 DMA 数据传送并在 I/O 操作完成时产生一个中断
foo_start_dma_transfer(addr+bvec->bv_offset, bvec->bv_len);
kunmap_atomic(bvec->bv_page, KM_BIO_SRC_IRQ);
local_irq_restore(flags);
}
块设备驱动程序的中断处理程序在 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, uptodata, nr_seectors))
{
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() 接收的参数:
end_that_request_first() 扫描请求中的 bio 结构及每个 bio 中的段,然后采用如下方式更新请求描述符的字段值:
end_that_request_first() 的返回值、中断处理程序相应的处理:
end_that_request_last() 功能:
更新一些磁盘使用统计数,把请求描述符从 I/O 调度程序 rq->elevator 的调度队列中删除,唤醒等待请求描述符完成的任一睡眠进程,并释放删除的那个描述符。
内核打开一个块设备文件的时机:
在所有情况下,内核本质上执行相同的操作:
寻找块设备描述符(如果块设备没有在使用,则分配一个新的描述符),为即将开始的数据传送设置文件操作方法。
仅考虑 open 方法,它由 dentry_open() 调用。
blkdev_open() 参数为 inode 和 filp,分别为索引节点和文件对象的地址,本质上执行下列操作:
// 在 kobject 映射域 bdev_map 上简单地调用 kobj_lookup() 传递设备的主设备号和次设备号
// 如果被打开的块设备是一个分区,则返回的索引值存放在本地变量 part 中;否则,part 为 0
disk = get_gendisk(bdev->bd_dev, &part);