深入理解 Linux 内核---块设备驱动程序

块设备的处理

深入理解 Linux 内核---块设备驱动程序_第1张图片

一个进程在某个磁盘文件上发出一个 read() 系统调用,内核对进程请求回应的一般步骤:

  1. read() 调用一个适当的 VFS 函数,将文件描述符和文件内的偏移量传递给它。
    虚拟文件系统位于块设备处理体系结构的上层,提供一个通用的文件系统模型,Linux 支持的所有系统均采用该模型。
  2. VFS 函数确定所请求的数据是否已经存在,如有必要,它决定如何执行 read 操作。
    有时候没有必要访问磁盘上的数据,因为内核将大多数最近从快速设备读出或写入其中的数据保存在 RAM 中。
  3. 假设内核从块设备读数据,那么它就必须确定数据的物理位置。因此,内核依赖映射层执行下面步骤:
    a. 内核确定该文件所在文件系统的块大小,并根据文件块的大小计算所请求数据的长度。
    本质上,文件被看作拆分成许多块,因此内核确定请求数据所在的块号(文件开始位置的相对索引)。
    b. 映射层调用一个具体文件系统的函数,它访问文件的磁盘节点,然后根据逻辑块号确定所请求数据在磁盘上的位置。
    因为磁盘也被看作拆分成许多块,所以内核必须确定所请求数据的块对应的号。
    由于一个文件可能存储子磁盘上的不连续块中,因此存放在磁盘索引节点中的数据结构将每个文件块号映射为一个逻辑块号。
  4. 现在内核可以对块设备发出读请求。内核利用通用块层启动 I/O 操作来传送所请求的数据。
    一般,每个 I/O 操作只针对磁盘上一组连续操作的块。
    由于请求的数据不必位于相邻的块中,所以通用层可能启动几次 I/O 操作。
    每次 I/O 操作是由一个“块 I/O”结构描述符,它收集底层组件所需要的所有信息以满足所发出的请求。
  5. 通用块层下面的“I/O 调度程序”根据预先定义的内核策略将待处理的 I/O 数据传送请求进行归类。
    调度程序的作用是把物理介质上相邻的数据请求聚集在一起。
  6. 最后,块设备驱动程序向磁盘控制器的硬件接口发出适当的命令,从而进行实际的数据传送。

块设备中的数据存储涉及了许多内核组件,每个组件采用不同长度的块管理磁盘数据:

  • 硬件块设备控制器:扇区。
  • 虚拟文件系统、映射层和文件系统:块,一个块对应文件系统中的一个最小的磁盘存储单元。
  • 块设备驱动程序:段,一个段就是一个内存页或内存页的一部分,包含磁盘上相邻的数据块。
  • 硬盘高速高速缓存:页,每页正好装在一个页框中。
  • 通用块层将所有的上层和下层的组件组合在一起,了解数据的扇区、块、段和页。
    深入理解 Linux 内核---块设备驱动程序_第2张图片

扇区

块设备的每次数据传输都作用于一组称为扇区的相邻字节。

大部分磁盘设备中,扇区大小为 512 字节。

扇区的下标存放在类型为 sector_t 的 32 位或 64 位的变量中。

扇区是硬件设备传输数据的基本单位,而块是 VFS 和文件系统传输数据的基本单位。

Linux 中,块大小必须是 2 的幂,且不能超过一个页框。
此外,它必须是扇区大小的整数倍,因为每个块必须包含整个扇区。

每个块都需要自己的块缓冲区,它是内核用来存放内容的 RAM 内存区。
缓冲区的首部是一个与每个缓冲区相关的 buffer_head 类型的描述符。

buffer_head 中的某些字段:

  • b_page:块缓冲区所在页框的页描述符地址。如果页框位于高端内存中,那么 b_data 字段存放页中块缓冲区的偏移量;
    否则,存放缓冲区本身的起始线性地址。
  • b_blocknr:存放逻辑块号(如磁盘分区中的块索引)。
  • b_bdev:标识使用缓冲区首部的块设备。

对磁盘的每个 I/O 操作就是在磁盘与一些 RAM 单元间相互传送一些相邻扇区是内容。
大多数情况下,磁盘控制器之间采用 DMA 方式进行数据传送。
块设备驱动程序只要向磁盘控制器发送一些适当的命令就可以触发一次数据传送,完成后,控制器会发出一个中断通知块设备驱动程序。

新的磁盘控制器支持所谓的分散-聚集 DMA 传送方式:磁盘可与一些非连续的内容区相互传送数据。

启动一次分散-聚集 DMA 传送,块设备驱动程序需要向磁盘控制器发送:

  • 要传送的起始磁盘扇区号和总的扇区数
  • 内存区的描述符链表,其中链表的每项包含一个地址和一个长度

磁盘控制器负责整个数据传送。

为了使用分散-聚集 DMA 传送方式,块设备驱动程序必须能处理称为段的数据存储单元。
一个段就是一个内存页或内存页中的一部分,它们包含一些相邻磁盘扇区中的数据。
因此,一次分散-聚集 DMA 操作可能同时传送几个段。

如果不同的段在 RAM 中相应的页框正好是连续的且在磁盘上相应的数据块也是相邻的,那么通用块层可合并它们,产生更大的物理段。

通用块层

通用块层是一个内核组件,它处理来自系统中的所有块设备发出的请求。
由于该层提供的函数,内核可容易地做到:

  • 将数据缓冲区放在高端内存:仅当 CPU 访问时,才将页框映射为内核中的线性地址空间,并在数据访问后取消映射。
  • 通过一些附加的手段,实现一个所谓的“零-复制”模式,将磁盘数据直接存放在用户态地址空间而不是首先复制到内核内存区;事实上,内核为 I/O 数据传送使用的缓冲区所在的页框就映射在进程的用户态线性地址空间中。
  • 管理逻辑卷,例如由 LVM(逻辑卷管理器)和 RAID(廉价磁盘冗余阵列)使用的逻辑卷:几个磁盘分区,即使位于不同的块设备中,也可被看作一个单一的分区。
  • 发挥大部分新磁盘控制器的高级特性,如大主板磁盘高速缓存、增强的 DMA 性能、I/O 传送请求的相关调度等。

Bio 结构

bio 描述符,通用块的核心数据结构,描述了块设备的 I/O 操作。
每个 bio 结构都包含一个磁盘存储区标识符(存储区中的起始扇区号和扇区数目)和一个或多个描述符与 I/O 操作相关的内存区的段。

bio 中的某些字段:

  • bio_vec:描述 bio 中的每个段。
  • bi_io_vec:bio_vec 数据结构的第一个元素。
  • bi_vcnt:存放了 bio_vec 数组中当前的元素个数。
  • bi_idx:bio_vec 数组中段的当前索引。块 I/O 操作器间 bio 描述符一直保持更新,例如,如果块设备驱动程序在一次分散-聚集 DMA 操作中不能完成全部的数据传送,则 bio 中的 bi_idx 会不断更新来指向待传送的第一个段。为了从索引 bi_idx 指向当前段开始不断重复 bio 中的段,设备驱动程序可以执行 bio_for_each_segment。
  • bi_cnt:bio 的引用计数器值。

当通用块层启动一次新的 I/O 操作时,调用 bio_alloc() 分配一个新的 bio 结构。
bio 结构由 slab 分配器分配,内存不足时,内核也会使用一个备用的 bio 小内存池。
内核也为 bio_vec 分配内存池。

bip_put() 减少 bi_cnt,等于 0 时,释放 bio 结构及相关的 bio_vec 结构。

磁盘和磁盘分区表示

磁盘是一个由通用块层处理的逻辑块设备。
通常一个磁盘对应一个硬件块设备,如硬盘、软盘或光盘,也可是一个虚拟设备,建立在几个物理磁盘分区上或一些 RAM 专用页中的内存区上。
任何情形中,借助通用块层提供的服务,上层内核组件可以同样的方式工作在所在的磁盘上。

磁盘由 gendisk 对象描述,某些字段:

  • flags:存放关于磁盘的信息。如果设置 GENHD_FL_UP 标志,则磁盘将被初始化并可使用。
    如果为软盘或光盘这样的可移动磁盘,则设置 GENHD_FL_REOVABLE 标志。
  • fops:指向表 block_device_operations,该表为块设备的主要操作存放了几个定制的方法。
  • part:磁盘的分区描述符数组。

通常硬盘被划分成几个逻辑分区。每个块设备文件要么代表整个磁盘,要么代表磁盘中的某个分区。

如果将一个磁盘分成几个分区,则分区表保存在 hd_struct 结构的数组中。

alloc_disk():当内核发现系统中一个新的磁盘时(在启动阶段,或将一个可移动介质插入一个驱动器中时,或在运行器附加一个外置式磁盘时),分配并初始化一个新的 gendisk 对象,如果新磁盘被分成几个分区,还会分配并初始化一个适当的 hd_struct 类型的数组。

add_disk():将新的 gendisk 对象插入到通用块层的数据结构中。

提交请求

当向通用块层提交一个 I/O 操作请求时,内核所执行的步骤(假设被请求的数据块在磁盘上相邻,且内核已经知道了它们的物理位置)。

首先,bio_alloc() 分配一个新的 bio 描述符,然后,内核通过设置一些字段初始化 bio 描述符:

  • bi_sector = 数据的起始扇区号(如果块设备分成了几个分区,那么扇区号是相对于分区的起始位置的)。
  • bi_size = 涵盖整个数据的扇区数目。
  • bi_bdev = 块设备描述符的地址。
  • bi_io_vec = bio_vec 结构数组的起始地址,数组中的每个元素描述了 I/O 操作中的一个段(内存缓存)。
  • bi_vcnt = bio 中总的段数。
  • bi_rw = 被请求操作的标志,READ(0)或 WRITE(1)。
  • bi_end_io = 当 bio 上的 I/O 操作完成时所执行的完成程序的地址。

bio 描述符被初始化后,内核调用 generic_make_request(),它是通用块层的主要入口点,该函数执行下列操作:

  1. 如果 bio->bi_sector > 块设备的扇区数,bio->bi_flags = BIO_EOF,打印一条内核出错信息,调用 bio_endio() 并终止。
    bio_endio() 更新 bio 描述符中的 bi_size 和 bi_sector,然后调用 bio 的 bi_end_io 方法。
    bi_end_io 函数依赖于触发 I/O 数据传送的内核组件。
  2. 获取与块设备请求相关的请求队列 q,其地址存放在块设备描述符的 bd_disk 字段,其中的每个元素由 bio->bi_bdev 指向。
  3. 调用 block_wait_queue_running() 检查当前正在使用的 I/O 调度程序是否可被动态取代;如果可以,则让当前进程睡眠直到启动一个新的 I/O 调度程序。
  4. 调用 blk_partition_remap() 检查块设备是否指的是一个磁盘分区(bio->bi_bdev != bio->bi_dev->bd_contains)。
    如果是,从 bio->bi_bdev 获取分区的 hd_struct 描述符,从而执行下面的子操作:
    a. 根据数据传送的方向,更新 hd_struct 描述符中的 read_sectors 和 reads 值 或 write_sectors 和 writes 值。
    b. 调整 bio->bi_sector 值,使得把相对于分区的起始扇区号转变为相对于整个磁盘的扇区号。
    c. bio->bi_bedv = 整个磁盘的块设备描述符(bio->bd_contains)。
    从现在开始,通用块层、I/O 调度程序及设备驱动程序将忘记磁盘分区的存在,直接作用于整个磁盘。
  5. 调用 q->make_request_fn 方法将 bio 请求插入请求队列 q 中。
  6. 返回。

总结:主要是分配并初始化 bio 描述符,以描述符 I/O 操作请求;获取请求队列,将相对于磁盘分区的 I/O 操作请求转换为相对于整个磁盘的 I/O 操作请求;I/O 操作请求入队列。

I/O 调度程序

只要可能,内核就试图把几个扇区合并在一起,作为一个整体处理,以减少磁头的平均移动时间。

当内核组件要读或写一些磁盘数据时,会创建一个块设备请求。
请求描述的时所请求的扇区及要对它执行的操作类型(读或写)。
但请求发出后内核不一定会立即满足它,I/O 操作仅仅被调度,执行会向后推迟。
当请求传送要给新的数据块时,内核检查能否通过稍微扩展前一个一直处于等待状态的请求而满足新的请求。

延迟请求复杂化了块设备的处理。
因为块设备驱动程序本身不会阻塞,否则会阻塞试图访问同一磁盘的任何其他进程。

为防止块设备驱动程序被挂起,每个 I/O 操作都是异步处理的。
特别是块设备驱动程序是中断驱动的:

  1. 通用块层调用 I/O 调度程序产生一个新的块设备请求,或扩展一个已有的块设备请求,然后终止。
  2. 激活的块设备驱动程序会调用一个策略例程来选择一个待处理的请求,并向磁盘控制器发出一条命令以满足该请求。
  3. 当 I/O 操作终止时,磁盘控制器就产生一个中断,相应的中断处理程序就又调用策略例程去处理队列中的另一个请求。

每个块设备驱动程序都维持着自己的请求队列,它包含设备待处理的请求链表。
如果磁盘控制器正在处理几个磁盘,那么通常每个物理块都有一个请求队列。
在每个请求队列上单独执行 I/O 调度,可提供高盘性能。

请求队列描述符

请求队列由一个大的数据结构 request_queue 表示。

request_queue 中的某些字段:

  • queue_head:请求队列是一个双向链表,其元素是请求描述符(request 数据结构)。queue_head 存放链表的头。
  • queuelist:把任一请求链接到前一个和后一个元素之间。队列链表中元素的排序方式对每个块设备驱动程序是特定的。
  • backing_dev_info:一个 backing_dev_info 类型的小对象,存放了关于基本硬件块设备的 I/O 数据流量的信息。
    如,关于预读及请求队列拥塞状态的信息。

请求描述符

每个块设备的待处理请求都是用一个请求描述符表示的,存放于 request 数据结构。

  • bio、biotail:每个请求包含一个或多个 bio 结构。最初,通用层创建一个仅包含一个 bio 结构的请求。
    然后,I/O 调度程序要么向初始 bio 中增加一个新段,要么将另一个 bio 结构链接到请求,从而“扩展”该请求。
    bio 字段指向第一个 bio 结构,biotail 指向最后一个 bio 结构。
  • nr_sectors:整个请求中还需传输的扇区数。
  • current_nr_sectors:存放当前 bio 结构中还需传输的扇区数。
  • flags:存放很多标志,最重要的一个是 REQ_RW,确定数据传送的方向,READ(0)或 WRITE(1)。
  • rl:指向 request_list 结构的指针。

对请求描述符的分配进行管理

在重负载和磁盘操作频繁时,固定数目的动态内存将成为进程想把新请求加入请求队列 q 的瓶颈。
为解决该问题,每个 request_queue 描述符包含一个 request_list 数据结构,其中包括:

  • 一个指针,指向请求描述符的内存池。
  • 两个计数器,分别记录分配给 READ 和 WRITE 请求的请求描述符。
  • 两个标志,分别标记读或写请求的分配是否失败。
  • 两个等队列,分别存放了为获得空闲的读和写请求描述符而睡眠的进程。
  • 一个等待队列,存放等待一个请求队列被刷新(清空)的进程。

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() 的功能是拔出块设备:

  1. 检查请求队列释放仍然活跃。
  2. 调用 blk_remove_plug()。
  3. 执行策略例程 reuqest_fn 方法开始处理请求队列中的下一个请求。

I/O 调度算法

I/O 调度程序也被称为电梯算法。

Linux 2.6 中提供了四种不同类型的 I/O 调度程序或电梯算法,分别为“预期”算法,“最后期限”算法,“CFQ(完全公平队列)”算法,及“Noop(No Operation)”算法。
对于大多数块设备,内核使用缺省电梯算法可在引导时通过内核参数 elevator= 进行再设置,其中可取值为:as、deadline、cfg 和 noop。
缺省为“预期”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 输入队列,选择第一个非空队列,然后将该队列中的一组请求移动到调度队列的末尾。

“最后期限”算法

除了调度队列外,还使用了四个队列。
其中的两个排序队列分别包含读请求和写请求,请求根据起始扇区数排序。
另外两个最后期限队列包含相同的读和写请求,但根据“最后期限”排序。
引入这些队列是为了避免请求饿死。

补充调度队列:

  1. 首先确定下一个请求的数据方向。
    如果同时要调度读和写两请求,算法会选择“读”方向,除非“写”方向已经被放弃很多次了。
  2. 检查与被选择方向相关的最后期限队列:如果队列中的第一个请求的最后期限已用完,那么将该请求移到调度队列的末尾;也可从超时的那个请求开始移动来自排序队列的一组请求。
    如果将要移动的请求在磁盘上物理相邻,则组的长度会变长,否则变短。
  3. 如果没有请求超时,算法对来自排序队列的最后一个请求之后的一组请求进行调度。
    当指针到达排序队列的末尾时,搜索又从头开始(“单方向算法”)。

“预期”算法

是 Linux 提供的最复杂的一种 I/O 调度算法。
它是“最后期限”算法的一个演变:两个最后期限队列和两个排序队列;I/O 调度程序在读和写请求之间交互扫描排序队列,不过更倾向于读请求。
扫描基本上是连续的,除非某个请求超时。读请求的缺省超时时间是 125ms,写请求为 250ms。
算法还遵循一些附加的启发式规则:

  • 有些情况下,算法可能在排序队列当前位置之后选择一个请求,从而强制磁头从后搜索。
    这通常发生在该请求之后的搜索距离小于在排序队列当前位置之后对该请求搜索距离的一半时。
  • 算法统计系统中每个进程触发的 I/O 操作种类。
    当刚刚调度了由某个进程 p 发出的一个读请求后,立马检查排序队列中下一个请求是否来自同一进程 p。
    如果是,立即调度下一请求。否则,查看关于该进程 p 的统计信息:如果确定 p 可能很快发出另一个读请求,则延迟一小段时间(缺省约 7ms)。
    因此,算法预测进程 p 发出的读请求与刚被调度的请求在磁盘上可能是“近邻”。

向 I/O 调度程序发出请求

generic_make_request() 调用请求队列描述符的 make_request_fn 方法向 I/O 调度程序发送一个请求。
通常该方法由 __make_request() 实现,__make_request() 参数为 request_queue 类型的描述符 q、bio 结构的描述符 bio。执行下列操作:

  1. 如果需要,调用 blk_queue_bounce() 建立一个回弹缓冲区。然后,对该缓冲区而不是原先的 bio 结构进行操作。
  2. 调用 I/O 调度程序的 elv_queue_empty() 检查请求队列中是否存在待处理请求。调度队列可能是空的,但 I/O 调度程序的其他队列可能包含待处理请求。如果没有,调用 blk_plug_device() 插入请求队列,然后跳到第 5 步。
  3. 插入的请求队列包含待处理请求。
    调用 I/O 调度程序的 elv_merge() 检查新的 bio 结构是否可以并入已存在的请求中,将返回三个可能值:
  • ELEVATOR_NO_MERGE:已经存放在的请求中不能包含 bio 结构,跳到第 5 步。
  • ELEVATOR_BACK_MERGE:bio 结构可作为末尾的 bio 而插入到某个请求 req 中,调用 q->back_merge_fn 方法检查是否可扩展该请求。
    如果不行,跳到第 5 步;否则,将 bio 描述符插入 req 链表的末尾并更新 req 的相应字段值。
    然后,函数试图将该请求与后面的请求合并。
  • ELEVATOR_FRONT_MERGE:bio 结构可作为某个请求 req 的第一个 bio 被插入,函数调用 q->front_merge_fn 方法检查是否可扩展该请求。
    如果不行跳到第 5 步;否则,将 bio 描述符插入 req 链表的首部并更新 req 的相应字段值。
    然后,试图将该请求与前面的请求合并。
  1. bio 已经被并入存放在的请求中,跳到第 7 步终止函数。
  2. bio 必须被插入一个新的请求中。分配一个新的请求描述符。
    如果没有空闲的内存,那么挂起当前进程,直到设置了 bio->bi_rw 中的 BIO_RW_AHEAD 标志,表明这个 I/O 操作是一次预读;这种情形下,函数调用 bio_endio() 并终止:不执行数据传输。
  3. 初始化请求描述符中的字段,主要有:
    a. 根据 bio 描述符的内容初始化各个字段,包括扇区数、当前 bio 及当前段。
    b. 设置 flags 字段中的 REQ_CMD 标志。
    c. 如果第一个 bio 段的页框存放在低端内存,则将 buffer 字段设置为缓冲区的线性地址。
    d. rq_disk = bio->bi_bdev->bd_disk 的地址。
    e. 将 bio 插入请求链表。
    f. start_time = jiffies 值。
  4. 所有操作都完成。终止前,检查是否设置了 bio->bi_rw 中的 BIO_RW_SYNC 标志,如果是,对请求队列调用 generic_unplug_device() 卸载设备驱动程序。
  5. 函数终止。

总结:根据请求队列是否为空,不空时是否与已有请求合并,来确定 bio 与现有请求合并还是新分配、初始化一个新的 bio 描述符,并插入请求链表。然后根据需要卸载驱动程序,函数终止。

blk_queue_bounce()

功能是查看 q->bounce_gfp 中的标志及 q->bounce_pfn 中的阈值,从而确定回弹缓冲区是否必须。
通常当请求中的一些缓冲区位于高端内存,而硬件设备不能访问它们时发生该情况。

当处理老式设备时,块设备驱动程序通常更倾向于直接在 ZONE_DMA 内存区分配 DMA 缓冲区。

如果硬件设备不能处理高端内存中的缓冲区,则 blk_queue_bounce() 检查 bio 中的一些缓冲区是否真的必须是回弹的。
如果是,则将 bio 描述符复制一份,接着创建一个回弹 bio;当段中的页框号等于或大于 q->bounce_pfn 时,执行下列操作:

  1. 根据分配的标志,在 ZONE_NORMAL 或 ZNOE_DMA 内存区中分配一个页框。
  2. 更新回弹 bio 中段的 bv_page 字段,使其指向新页框的描述符。
  3. 如果 bio->bio_rw 代表一个写操作,则调用 kmap() 临时将高端内存页映射到内核地址空间中,然后将高端内存页复制到低端内存页上,最后调用 kunmap() 释放该映射。

然后 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 的某些字段:

  • bd_list:所有块设备的描述符被插入一个全局链表中,链表首部由变量 all_bdevs 表示;
    链表链接所用的指针位于块设备描述符的 bd_list 字段。
  • bd_contains、bd_part、bd_part_count:如果块设备描述符对应一个磁盘分区,则 bd_contains 指向与整个磁盘相关的块设备描述符;bd_part 指向 hd_struct 分区描述符。
    否则,bd_contains 指向块设备描述符本身,bd_part_count 记录磁盘上的分区已经被打开了多少次。
  • bd_holder:代表块设备持有者的线性地址。
    持有者不是进行 I/O 数据传送的块设备驱动程序,而是一个内核组件,典型为安装在该设备上的文件系统。
    当块设备文件被打开进行互斥访问时,持有者就是对应的文件对象。
  • bd_holders:bd_claim() 将 bd_holder 设置为一个特定的地址;bd_release() 将该字段重新设置为 NULL。
    同一内核组件可多次调用 bd_claim(),每次调用都增加 bd_holders 值;
    为释放块设备,内核组件必须调用 bd_release() bd_holders 次。

深入理解 Linux 内核---块设备驱动程序_第3张图片

访问块设备

当内核接收一个打开块设备文件的请求时,必须先确定该设备文件是否已经是打开的。
如果是,则内核没必要创建并初始化一个新的块设备描述符,而是更新已存在的块设备描述符。
然而,真正的复杂性在于具有相同主设备号和次设备号但不同路径名的块设备被 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() 执行下面步骤:

  1. 设置 gd->flags 的 GENHD_FL_UP 标志。
  2. 调用 kobj_map() 建立设备驱动程序和设备的主设备号(连同相关范围内的次设备号)之间的连接。
  3. 注册设备驱动程序模型的 gendisk 描述符的 kobject 结构,它作为设备驱动程序处理的一个新设备(如 /sys/block/foo)。
  4. 如果需要,扫描磁盘中的分区表;对于查找到的每个分区,适当地初始化 foo.gd->part 数组中相应的 hd_struct 描述符。
    同时注册设备驱动程序模型中的分区(如 /sys/block/foo/foo1)。
  5. 注册设备驱动程序模型的请求队列描述符中内嵌的 kobject 结构(如 /sys/block/foo/queue)。

一旦 add_disk() 返回,设备驱动程序就可以工作了。
进程初始化的函数终止;策略例程和中断处理程序开始处理 I/O 调度程序传送给设备驱动程序的每个请求。

策略例程

策略例程是块设备驱动程序的一个函数或一组函数,它与硬件块设备之间相互作用以满足调度队列中的请求。
通过请求队列描述符中的 request_fn 方法可调用策略例程,如 foo_strategy(),I/O 调度程序层将请求队列描述符 q 的地址传给该函数。

把新的请求插入空的请求队列后,策略例程通常才被启动。
只要块设备驱动程序被激活,就应该对队列中的所有请求进行处理,直到队列为空才结束。

块设备驱动程序采用如下策略:

  • 策略例程处理队列中的第一个请求并设置块设备控制器,以便在数据传送完成时产生一个中断。然后策略例程终止。
  • 当磁盘控制器产生中断时,中断控制器重新调度策略例程。
    策略例程要么为当前请求再启动一次数据传送,要么当请求的所有数据块已经传送完成时,把该请求从调度队列中删除然后开始处理下一个请求。

请求是由几个 bio 结构组成的,而每个 bio 结构又由几个段组成。
基本上,块设备驱动程序以以下方式使用 DMA:

  • 驱动程序建立不同的 DMA 传送方式,为请求的每个 bio 结构的每个段进行服务。
  • 驱动程序建立以一种单独的分散-聚集 DMA 传送方式,为请求的所有 bio 中的所有段服务。

设备驱动程序策略例程的设计依赖块控制器的特性。

如,foo_strategy() 策略例程执行下列操作:

  1. 通过调用 I/O 调度程序的辅助函数 elv_next_request() 从调度队列中获取当前的请求。如果调度队列为空,就结束这个策略例程:
req = elv_next_request(q);
if(!req) 
	return;
  1. 执行 blk_fs_request 宏检测是否设置了请求的 REQ_CMD 标志,即请求是否包含一个标准的读或写操作:
if(!blk_fs_request(req))
	goto handle_special_request;
  1. 如果块设备控制器支持分散-聚集 DMA,那么对磁盘控制器进行编程,以便为整个请求执行数据传送并再传送完成时产生一个中断。
    blk_rq_map_sg() 辅助函数返回一个可以立即被用来启动数据传送的分散-聚集链表。
  2. 否则,设备驱动程序必须一段一段地传送数据。
    这种情形下,策略例程执行 rq_for_each_bio 和 bio_for_each_segment 两个宏,分别遍历 bio 链表和每个 bio 中的链表:
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);
	}
  1. 返回。

中断处理程序

块设备驱动程序的中断处理程序在 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() 接收的参数:

  • 一个请求描述符
  • 一个指示 DMA 数据传送完成的标志
  • DMA 所传送的扇区数

end_that_request_first() 扫描请求中的 bio 结构及每个 bio 中的段,然后采用如下方式更新请求描述符的字段值:

  • 修改 bio 字段,使其执行请求中的第一个未完成的 bio 结构。
  • 修改未完成 bio 结构的 bi_idx 字段,使其指向第一个未完成的段。
  • 修改未完成的 bv_offset 和 bv_len 字段,使其指定仍需传送的数据。

end_that_request_first() 的返回值、中断处理程序相应的处理:

  • 0,已经完成请求中的所有数据块。
    中断处理程序把请求从请求队列中删除(主要由 blkdev_dequeue_request() 完成),然后调用 end_that_request_last(),并再次调用策略例程处理调度队列中的下一个请求。
  • 1,中断处理程序重新调用策略例程,继续处理该请求。

end_that_request_last() 功能:
更新一些磁盘使用统计数,把请求描述符从 I/O 调度程序 rq->elevator 的调度队列中删除,唤醒等待请求描述符完成的任一睡眠进程,并释放删除的那个描述符。

打开块设备文件

内核打开一个块设备文件的时机:

  • 一个文件系统被映射到磁盘或分区上时
  • 激活一个交换分区时
  • 用户态进程向块设备文件发出一个 open() 系统调用时

在所有情况下,内核本质上执行相同的操作:
寻找块设备描述符(如果块设备没有在使用,则分配一个新的描述符),为即将开始的数据传送设置文件操作方法。

仅考虑 open 方法,它由 dentry_open() 调用。
blkdev_open() 参数为 inode 和 filp,分别为索引节点和文件对象的地址,本质上执行下列操作:

  1. 执行 bd_acquire(inode) 从而获得块设备描述符 bdev 的地址。该函数参数为索引节点对象的地址,执行下列主要步骤:
    a. 如果索引节点对象的 inode->i_bdev 字段不为 NULL,表明块设备文件已经打开,该字段存放了相应块描述符的地址。
    增加与块设备相关联的 bdev 特殊文件系统的 inode->i_bdev->bd_inode 索引节点的引用计数器值,并返回描述符 inode->i_bdev 的地址。
    b. 否则,块设备文件没有被打开。
    根据块设备相关联的主设备号和次设备号,执行 bdget(inode->i_rdev) 获取块设备描述符的地址。
    如果描述符不存在,bdget() 就分配一个。
    c. inode->i_bdev = 块设备描述符的地址,以便加速将来对相同块设备文件的打开操作。
    d. inode->i_mapping = bdev 索引节点中相应字段的值。inode->i_mapping 指向地址空间对象。
    e. 把索引节点插入到 bdev->bd_inodes 确立的块设备描述符的已打开索引节点链表中。
    f. 返回描述符 bdev 的地址。
  2. filp->i_mapping = inode->i_mapping
  3. 获取与这个块设备相关的 gendisk 描述符的地址
// 在 kobject 映射域 bdev_map 上简单地调用 kobj_lookup() 传递设备的主设备号和次设备号
// 如果被打开的块设备是一个分区,则返回的索引值存放在本地变量 part 中;否则,part 为 0
disk = get_gendisk(bdev->bd_dev, &part);  
  1. 如果 bdev->bd_openers != 0,说明块设备已经被打开。检查 bdev->bd_contains 字段:
    a. 如果等于 bdev,那么块设备是一个整盘:调用块设备方法 bdev->bd_disk->fops->open(如果定义了),然后检查 bdev->bd_invalidated 的值,需要时调用 rescan_partitions()。
    b. 如果不等于 bdev,那么块设备是一个分区:bdev->bd_contains->bd_part_count++,跳到第 8 步。
  2. 这里的块设备是第一次被访问。初始化 bdev->bd_disk 为 gendisk 描述符的地址 disk。
  3. 如果块设备是一个整盘(part == 0),则执行下列子步骤:
    a. 如果定义了 disk->fops->open 块设备方法,就执行它:
    该方法由块设备驱动程序定义的定制函数,它执行任何特定的最后一分钟初始化。
    b. 从 disk->queue 请求队列的 hardsect_size 字段中获取扇区大小(字节数),用该值适当地设置 bdev->bd_block_size 和 bdev->bd_inode->i_blkbits。
    同时从 disk->capacity 中计算来的磁盘大小设置 bdev->bd_inode->i_size 字段。
    c. 如果设置了 bdev->bd_invalidated 标志,则调用 rescan_partitions() 扫描分区表并更新分区描述符。
    该标志是由 check_disk_change 块设备方法设置的,仅适用于可移动设备。
  4. 否则,如果块设备是一个分区,则执行下列子步骤:
    a. 再次调用 bdget(),这次是传递 disk->first_minor 次设备号,获取整盘的块描述符地址 whole。
    b. 对整盘的块设备描述符重复第 3 步 ~ 第 6 步,如果需要则初始化该描述符。
    c. bdev->bd_contains = 整盘描述符的地址。
    d. whole->bd_part_count++,从而说明磁盘分区上新的打开操作。
    e. bdev->bd_part = disk->part[part-1], disk->part[part-1] 是分区描述符 hd_struct 的地址。
    同样,执行 kobject_get(&bdev->bd_part->kobj) 增加分区引用计数器的值。
    f. 与第 6b 步中一样,设置索引节点中表示分区大小和扇区大小的字段。
  5. bdev->bd_openers++
  6. 如果块设备文件以独占方式被打开(设置了 filp->f_flags 中的 O_EXCL 标志),则调用 bd_claim(bdev, filp) 设置块设备的持有者。
    如果块设备已经有一个持有者,则释放该块设备描述符并返回要给错误码 -EBUSY。
  7. 返回 0(成功)终止。

你可能感兴趣的:(深入理解,Linux,内核笔记)