Linux-块设备驱动程序 - STEMHA's Blog

Linux-块设备驱动程序

概述

Linux块设备处理程序的组织是相当复杂的,在此不可能详细介绍内核块设备I/O子系统中包含的所有函数
我们主要说明下面几个问题:

  • Linux块设备I/O子系统的体系结构是什么?
  • 块设备I/O子系统的主要组件有哪些?有哪些作用?
  • 打开一个块设备文件时内核执行的步骤有哪些?
  • 内核如何对块设备和块设备的请求进行管理?->这部分在内核中称为块I/O层

块设备与字符设备

块设备:系统中能够随机(不需要按顺序)访问固定大小数据片(chunks)的设备被称作块设备,这些数据片就称作

  • 最常见的块设备是硬盘,除此以外,还有软盘驱动器、CD-ROM驱动器和闪存等等许多其他块设备。
  • 注意,它们都是以安装文件系统的方式使用的——这也是块设备的一般访问方式。

字符设备:另一种基本的设备类型是字符设备

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

两种类型的设备的根本区别在于它们是否可以被随机访问——也就是能否在访问设备时随意地从一个位置跳转到另一个位置。

为什么要使用专门的内核子系统来进行块设备的管理?对块设备的优化带来什么好处?

内核管理块设备要比管理字符设备细致得多,需要考虑的问题和完成的工作相比字符设备来说要复杂许多。这是因为字符设备仅仅需要控制一个位置—当前位置—而块设备访问的位置必须能够在介质的不同区间前后移动。所以事实上内核不必提供一个专门的子系统来管理字符设备,但是对块设备的管理却必须要有一个专门的提供服务的子系统。不仅仅是因为块设备的复杂性远远高于字符设备, 更重要的原因是块设备对执行性能的要求很高;对硬盘每多一分利用都会对整个系统的性能带来提升,其效果要远远比键盘吞吐速度成倍的提高大得多。另外,我们将会看到,块设备的复杂性会为这种优化留下很大的施展空间。

块设备的扇区/内核的块

扇区

扇区是什么?

  • 块设备中最小的可寻址单元是扇区。扇区大小一般是2的整数倍,而最常见的是512字节。
  • 扇区的大小是设备的物理属性
    • 扇区是所有块设备的基本单元,块设备无法对比它还小的单元进行寻址和操作,不过许多块设备能够一次就传输多个扇区。
    • 虽然大多数块设备的扇区大小都是512字节,不过其它大小的扇区也很常见, 比如,很多CD-ROM盘的扇区都是2K大小。
    • 在Linux中,扇区大小按惯例都设为512字节;如果一个块设备使用更大的扇区,那么相应的底层设备驱动程序将做些必要的变换。

为什么提出扇区?扇区的作用是什么?

  • 为了达到可接受的性能,硬盘和类似的设备快速传送几个相邻字节的数据(也就是传递多个字节的数据会提高性能)。块设备的每次数据传送操作都作用于一组称为扇区的相邻字节。
  • 尽管磁盘的物理构造很复杂,但是硬盘控制器接收到的命令将磁盘看成一大组扇区。应该把扇区作为数据传送的基本单元,不允许传送少于一个扇区的数据,大部分磁盘设备都可以同时传送几个相邻的扇区。
  • 对存放在块设备中的一组数据是通过他们在磁盘上的位置来标识,即其首个512字节扇区的下标以及扇区的数目。扇区的下标存放在类型为sector_t的32位或64位的变量中。

块:

  • 虽然各种软件的用途不同,但是它们都会用到自己的最小逻辑可寻址单元—块。
  • 块是文件系统的一种抽象,只能基于块来访问文件系统。虽然物理磁盘寻址是按照扇区来级进行的,但是内核执行的所有磁盘操作都是按照块进行的。
  • 由于扇区是设备的最小可寻址单元,所以块不能比扇区还小,只能数倍于扇区大小。对有扇区的硬件设备,内核还要求块大小是2的整数倍,且不能超过一个页的长度。
  • 对块大小的要求最终如下:
    • 必须是扇区大小的2的整数倍,并且要小于页面大小(页框大小),所以通常块大小是512字节,1KB或者4KB
    • 在80X86体系结构中,允许块的大小为512,1024,2048,4096字节。
    • 块设备的块大小不是唯一的,创建一个磁盘文件系统时,管理员可以选择合适的块大小。因此,同一个磁盘上的几个分区可能使用不同的块大小。
    • 此外,对块设备文件的每次读或写操作是一种“原始”访问,因为他绕过了磁盘文件系统;内核通过使用最大的块(4096字节)执行该操作。

块缓冲区:每个块都需要自己的块缓冲区,它是内核用来存放块内容的RAM空间。

  • 当内核从磁盘读出一个块时,就用从硬件设备中所获得的数据来填充相应的缓冲区;同样,当内核向磁盘中写入一个块时,就用相关块缓冲区的数据来更新硬件设备上相应的一组相邻字节。
  • 块缓冲区的大小通常要与相应块的大小相匹配。
  • 缓冲区首部是一个与每个缓冲区相关的buffer_head类型的描述符。它包含内核处理缓冲区需要了解的所有信息;因此,在对每个缓冲区进行操作之前,内核都要首先检查其缓冲区首部;因此,在对每个缓冲区进行操作之前,内核都要首先检查其缓冲区首部。

扇区与块的关系

图1是扇区与块之间的关系图:

图1. 扇区与块之间的关系

扇区和块的别称:

  • 扇区:设备的最小寻址单元,或称为“硬扇区”“设备块”。
  • :文件系统的最小寻址单元,或称为“文件块”“I/O块”。

扇区和块的区别:

  • 扇区是硬件设备传送数据的基本单位,而块是VFS和文件系统传送数据的基本单位。
  • 例如:内核访问一个文件的内容时,它必须首先从磁盘上读文件的磁盘索引节点所在的块。该块对应磁盘上一个或多个相邻的扇区,而VFS将其看成是一个单一的数据单元。

扇区这一个概念之所以对内核重要,是因为所有设备的I/O必须以扇区为单位进行操作,内核所使用的“块”这一个高级概念就是建立在扇区之上的。

深入理解linux

块设备的处理

在本节我们来说明一下Linux块设备I/O子系统的体系结构。块设备驱动程序上的每个操作都涉及很多内核组件,其中最重要的一些如图2所示:

图2. 块设备操作涉及的内核组件

例如,我们假设一个进程在某个磁盘文件上发出一个read()系统调用(write()系统调用本质上采用同样的方式)。下面是内核对进程请求给予回应的一般步骤:

  1. read()系统调用的服务例程调用一个适当的函数,将文件描述符文件内的偏移量传递给它。虚拟文件系统位于块设备处理体系结构的上层,他提供一个通用的文件模型,Linux支持的所有文件系统均采用该模型。
  2. VFS函数确定所请求的数据是否存在,比如文件指针的位置的合法性等,如果有必要的话,它决定如何执行read操作(但均通过具体的文件系统提供的文件操作来执行)。有时候没有必要访问磁盘上的数据,因为内核将大多数最近从块设备读出或写入其中的数据保存在RAM中,及通过磁盘高速缓存来获得数据。
  3. 我们假设内核从块设备读数据,那么它就必须确定数据的物理位置。为了做到这点,内核依赖映射层(mapping layer),主要执行下面两步:
    3.1. 内核确定该文件所在文件系统的块大小,并根据文件块的大小计算所请求数据的长度。本质上,文件被看作许多数据块的集合,因此内核确定请求数据所在的块号(文件开始位置的块索引)。
    3.2. 接下来,映射层调用一个具体文件系统的函数,它访问文件的磁盘索引节点,然后根据逻辑块号确定所请求数据在磁盘上的位置。事实上,磁盘也被看作数据块的数组,因此内核必须确定存放所请求数据的块对应的号(磁盘或分区开始位置的相对索引)。由于一个文件可能存储在磁盘上的不连续块中,因此存放在磁盘索引节点中的数据结构将每个文件块号映射为一个逻辑块号。
    3.3. 如果是从原始设备文件进行读访问,映射层就不调用具体文件系统的方法,而是把块设备文件中的偏移量转换成在磁盘或者在对应该设备文件的磁盘分区中的位置。
  4. 现在内核可以对块设备发出读请求。内核利用通用块层(generic block layer)启动I/O操作来传送所请求的数据。一般而言,每个I/O操作只针对磁盘上一组连续的块。由于请求的数据不必位于相邻的块中,所以通用块层可能启动几次I/O操作。每次I/O操作是由一个“块I/O”(简称“bio”)结构描述,它收集底层组件需要的所有信息以满足所发出的请求。通用块层为所有的块设备提供了一个抽象视图,因而隐藏了硬件块设备间的差异性。几乎所有的块设备都是磁盘。
  5. 通用块层下面的“I/O调度程序”根据预先定义的内核策略将待处理的I/O数据传送请求归类。调度程序的作用是把物理介质上相邻的数据请求聚集在一起。
  6. 最后块设备驱动程序向磁盘控制器的硬件接口发送适当的命令,从而进行实际的数据传送。

块设备内核组件管理数据的方式

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

  • 硬件块设备控制器采用称为“扇区”的固定长度的块来传送数据。因此,I/O调度程序和块设备驱动程序必须管理数据扇区。
  • 虚拟文件系统、映射层和具体文件系统将磁盘数据存放在成为“块”的逻辑单元中。一个块对应文件系统中一个最小的磁盘存储单元。
  • “段”: 块设备驱动程序应该能够处理数据的“段”。一个段就是一个内存页或内存页的一部分,它们包含磁盘上物理相邻的数据块。
  • 磁盘高速缓存作用于磁盘数据的“页”上,每页正好装在一个页框中。
  • 通用块层将所有的上层和下层的组件组合在一起,因此它了解数据的扇区、块、段以及页。即使有许多不同的数据块,它们通常也是共享相同的物理RAM单元。

管理数据方式示例

例如,图3显示了一个具有4KB字节的的构造。上层内核组件将页看成是4个1024字节组成的块缓冲区。块设备驱动程序正在传送页中的后三个,因此这3块被插入到涵盖了后3072字节的中。硬盘控制器将该段看成是由6个512字节的扇区组成。

图3. 包含磁盘数据的页的典型构造

磁盘的每个I/O操作的实质是在磁盘与一些RAM单元之间相互传送一些相邻扇区的内容。

大多数情况下,磁盘控制器直接采用DMA方式进行数据传送。

  • DMA方式的特点是,磁盘控制器就像一个外置CPU一样,块设备驱动程序只要向磁盘控制器发送一些适当的命令就可以触发一次数据传送;一旦完成数据的传送,控制器就会发出一个中断通知块设备驱动程序。
  • DMA方式传送的是磁盘上相邻扇区的数据。这是一个物理约束:磁盘控制器允许DMA传送不相邻的扇区数据,但是这种方式的传送速率很低,因为在磁盘表面上移动读/写磁头是相当慢的。

老式的磁盘控制器仅仅支持“简单的”DMA传送方式:在这种传送方式中,磁盘必须与RAM中的连续内存单元相互传送数据。但是,新的磁盘控制器,也就是我们即将讲到的SCSI磁盘控制器,支持所谓的分散-聚集(scatter-gather)DMA传送方式。此种方式中,磁盘可以与一些非连续的内存区相互传送数据。启动一次分散-聚集DMA传送,块设备驱动程序需要向磁盘控制器发送:

  1. 要传送的起始磁盘扇区号和总的扇区数(要传的数据位置)
  2. 内存区的描述符链表,其中链表的每项包含一个地址和一个长度(需要传送到的位置)

磁盘控制器则负责整个数据传送,例如:

  • 在读操作中控制器从相邻磁盘扇区中获得数据,然后将它们存放到不同的内存区中。
  • 为了使用分散-聚集DMA传送方式,块设备驱动程序必须能够处理称为的数据存储元。
  • 一个就是一个内存页或内存页中的一部分,它们包含一些相邻磁盘扇区中的数据。
  • 因此,一次分散-聚集DMA操作可能同时传送几个段。

注意,块设备驱动程序不需要知道块、块大小以及块缓冲区。因此,即使高层将段看成是由几个块缓冲区组成的页,块设备驱动程序也不用对此给予关注。

如果,不同的段在RAM中相应的页框正好是连续的并且在磁盘上相应的数据块也是相邻的,那么通用块层可以合并它们。通过这种合并方式产生的更大的内存区就称为物理段。然而,在多种体系结构上还允许使用另一个合并方式:通过使用一个专门的总线电路来处理总线地址与物理地址间的映射。通过这种合并方式产生的内存区称为硬件段。由于我们将注意力集中在80 x 86体系结构上,它在总线地址和物理地址之间不存在动态的映射,因此在本章剩余部分我们假定硬件段总是对应物理段。

缓冲区与缓冲区头

当一个块被调入内存时(也就是,在读入后或者等待写出的时候),它要存储在一个缓冲区中。每个缓冲区和一个块对应,它相当于磁盘块在内存中的表示。一个块小于一个页,所以一页可以容纳一个或多个内存中的块。

由于内核在处理数据时需要一些相关的控制信息,每个缓冲区都有一个对应的描述符。该描述符buffer_head结构体表示,被称作缓冲区头,它包含了内核操作缓冲区所需的全部信息:

buffer_head结构体

buffer_head结构体在include/linux/buffer_head.h中定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*
* Historically, a buffer_head was used to map a single block
* within a page, and of course as the unit of I/O through the
* filesystem and block layers. Nowadays the basic I/O unit
* is the bio, and buffer_heads are used for extracting block
* mappings (via a get_block_t call), for tracking state within
* a page (via a page_mapping) and for wrapping bio submission
* for backward compatibility reasons (e.g. submit_bh).
*/
struct buffer_head {
unsigned long b_state; //缓冲区状态标志 /* buffer state bitmap (see above) */
struct buffer_head *b_this_page; //页面中的缓冲区 /* circular list of page's buffers */
struct page *b_page; //存储缓冲区的页面 /* the page this bh is mapped to */

sector_t b_blocknr; //起始块号 /* start block number */
size_t b_size; //映像的大小 /* size of mapping */
char *b_data; //页面内的数据指针 /* pointer to data within the page */

struct block_device *b_bdev; //相关联的块设备
bh_end_io_t *b_end_io;//I/O完成方法 /* I/O completion */
void *b_private; /* reserved for b_end_io */
struct list_head b_assoc_buffers; /*相关的映射链表 associated with another mapping */
struct address_space *b_assoc_map; /*相关的地址空间 mapping this buffer is associated with */
atomic_t b_count; /*缓冲区使用计数 users using this buffer_head */
};

说明:

  • b_count域表示缓冲区的使用记数,可通过两个定义在文件include/linux/buffer_head.h中的内联函数对此域进行增减。

    • 点击展开对b_count域进行增减的内联函数 >folded
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      static inline void get_bh(struct buffer_head *bh)
      {
      atomic_inc(&bh->b_count);
      }

      static inline void put_bh(struct buffer_head *bh)
      {
      smp_mb__before_atomic_dec();
      atomic_dec(&bh->b_count);
      }
    • 在操作缓冲区头之前,应该先使用get_bh()函数增加缓冲区头的引用计数,确保该缓冲区头不会再被分配出去;当完成对缓冲区头的操作之后,还必须使用put_bh()函数减少引用计数。
  • b_blocknr域:与缓冲区对应的磁盘物理块由b_blocknr域索引,该值是b_bdev域指明的块设备中的逻辑块号。

  • 与缓冲区对应的内存物理页由b_page域表示,另外,b_data域直接指向相应的块(它位于b_page域所指明的页面中的某个位置上),块的大小由b_size域表示,所以块在内存中的起始位置在b_data处,结束位置在(b_data + b_size)处。

  • b_state域表示缓冲区状态标志,可以是下表中一种或者多种标志的组合。合法的标志存放在bh_state_bits枚举中,在include/linux/buffer_head.h中定义

点击展开bh_state_bits枚举列表 >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
enum bh_state_bits {
BH_Uptodate, /* 该缓冲区包含可用数据Contains valid data */
BH_Dirty, /* 该缓冲区是脏的,缓冲区的内容比磁盘中的块内容要新,所以缓冲区的内容必须被写回磁盘Is dirty */
BH_Lock, /* 该缓冲区正在被I/O操作访问,被锁定以防止并发访问Is locked */
BH_Req, /* 该缓冲区有I/O请求操作Has been submitted for I/O */
BH_Uptodate_Lock,/* Used by the first bh in a page, to serialise
* IO completion of other buffers in the page
*/

BH_Mapped, /* 该缓冲区是映射磁盘块的可用缓冲区Has a disk mapping */
BH_New, /* 缓冲区是通过get_block()刚刚映射的,尚且不能访问Disk mapping was newly created by get_block */
BH_Async_Read, /*该缓冲区正通过end_buffer_async_read()被异步I/O读操作使用 Is under end_buffer_async_read I/O */
BH_Async_Write, /*该缓冲区正通过end_buffer_async_write()被异步写操作使用 Is under end_buffer_async_write I/O */
BH_Delay, /* 该缓冲区尚未与磁盘块关联Buffer is not yet allocated on disk */
BH_Boundary, /* 该缓冲区片于连续块区的边界,下一个块不再连续 Block is followed by a discontiguity */
BH_Write_EIO, /* 该缓冲区在写的时候遇到I/O错误 I/O error on write */
BH_Unwritten, /* 该缓冲区在硬盘上的空间已经被申请但是没有实际数据写出Buffer is allocated on disk but not written */
BH_Quiet, /* 该缓冲区禁止错误Buffer Error Prinks to be quiet */
BH_Meta, /* Buffer contains metadata */
BH_Prio, /* Buffer should be submitted with REQ_PRIO */

BH_PrivateStart,/* not a state bit, but the first bit available
* for private allocation by other entities
*/
};

说明:

  • BH_PrivateStart:bh_state_bits列表还包含了一个特殊标志——BH_PrivateStart,该标志不是可用状态标志,使用它是为了指明可被其他代码使用的起始位。块I/O层不会使用BH_PrivateStart或更高的位。那么某个驱动程序希望通过b_state域存储信息时就可以安全地使用这些位。驱动程序可以在这些位中定义自己的状态标志,只要保证自定义的状态标志不与块I/O层的专用位发生冲突就可以了。

块缓冲区头、块缓冲区以及页框的关系

每个块缓冲区都对应一个块缓冲区头buffer_head,二者的关系类似于物理页框和物理页框描述符,前者用来存储数据,后者是对前者的属性以及控制信息的描述。
块缓冲区头、块缓冲区以及页框的关系如下:

图4. 块缓冲区头、块缓冲区以及页框的关系

总结一下:

  • 缓冲区:磁盘块在物理内存中的表示形式
  • 缓冲区描述符:对缓冲区的相关信息的描述,描述了缓冲区与磁盘块的映射关系
  • 缓冲区头的目的在于描述磁盘块和物理内存缓冲区(在特定页面上的字节序列)之间的映射关系。这个结构体在内核中只扮演一个描述符的角色,说明从缓冲区到块的映射关系。

通用块层

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

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

深入理解linux

bio结构体

通用块层的核心数据结构是一个称为bio的描述符,它描述了块设备的I/O操作。目前内核中块I/O操作的基本容器由bio结构体表示。在更上层的具体的文件系统的读写操作方法中,构造bio结构,并通过通用块层来提交各块设备驱动程序来进行实际的数据传输。

它定义在文件include/linux/blk_types.h中:

  • 该结构体代表了正在活动的以段(segment)链表形式组织的块I/O操作。一个段是一小块连续的内存缓冲区。这样,单个缓冲区就不一定要连续。所以使用段来描述缓冲区,即使一个缓冲区分散在内存的多个位置上,bio结构体也能对内核保证I/O操作的执行。这样的向量I/O称为分散-聚合I/O
  • 每个bio结构都包含一个磁盘存储区表示符(存储区中的起始扇区号和扇区数目)和一个或多个描述与I/O操作相关的内存区的段。

bio结构定义如下:

点击展开bio结构体 >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/* struct bio, bio_vec and BIO_* flags are defined in blk_types.h */
/*
* main unit of I/O for the block layer and lower layers (ie drivers and
* stacking drivers)
*/
struct bio {
sector_t bi_sector; /* 磁盘上相关的扇区,块I/O操作的第一个扇区 device address in 512 byte sectors */
struct bio *bi_next; /* 请求链表,链接到请求队列中的下一个bio request queue link */
struct block_device *bi_bdev; //指向块设备描述符的指针
unsigned long bi_flags; /* 状态和命令标志status, command, etc */
unsigned long bi_rw; /* 读还是写,I/O操作标志 bottom bits READ/WRITE,
* top bits priority
*/

unsigned short bi_vcnt; /* bio的biovec数组中段的数目,bio_vec的偏移个数 how many bio_vec's */
unsigned short bi_idx; /* bio的biovec数组中段的当前索引值 current index into bvl_vec */

/* Number of segments in this BIO after
* physical address coalescing is performed.
*/
unsigned int bi_phys_segments; //合并之后的硬件段数目
unsigned int bi_size; /* I/O计数 residual I/O count */

/*
* To keep track of the max segment size, we account for the
* sizes of the first and last mergeable segments in this bio.
*/
unsigned int bi_seg_front_size; //第一个可合并段的大小,硬件段合并算法使用
unsigned int bi_seg_back_size; //最后一个可合并段的大小,硬件段合并算法使用

bio_end_io_t *bi_end_io; //bio的I/O操作结束时调用的方法
void *bi_private; //拥有者的私有方法。通用块层和块设备驱动程序的I/O完成方法所使用的指针
#ifdef CONFIG_BLK_CGROUP
/*
* Optional ioc and css associated with this bio. Put on bio
* release. Read comment on top of bio_associate_current().
*/
struct io_context *bi_ioc;
struct cgroup_subsys_state *bi_css;
#endif
#if defined(CONFIG_BLK_DEV_INTEGRITY)
struct bio_integrity_payload *bi_integrity; /* data integrity */
#endif
/*
* Everything starting with bi_max_vecs will be preserved by bio_reset()
*/
unsigned int bi_max_vecs; /* bio中的bio_vec数组中允许的最大段数 max bvl_vecs we can hold */
atomic_t bi_cnt; /* bio的引用计数器 pin count */
struct bio_vec *bi_io_vec; /* 指向bio中的bio_vec数组中段的指针 the actual vec list */
struct bio_set *bi_pool;

/*
* We can inline a number of vecs at the end of the bio, to avoid
* double allocations for a small number of bio_vecs. This member
* MUST obviously be kept at the very end of the bio.
*/
struct bio_vec bi_inline_vecs[0]; //内嵌bio向量
};

说明:

  • bio结构体描述的都只是元数据部分,实际数据都包含在紧跟其后的 bio_vec结构体 中。bio中的每一个段是由bio_vec结构体描述的
  • bio结构体中的bi_io_vec字段指向bio_vec数据结构的第一个元素,bi_vcnt则存放了bio_vec数据结构数组中当前元素的个数。
  • bi_private域,这是一个属于拥有者(创建者)的私有域,只有创建了bio结构的拥有者可以读写该域。
    bio结构体中的主要成员变量都是用来管理I/O操作执行的相关信息的,其中最重要的几个成员变量是bi_io_vecbi_vcntbi_idx。下图显示了bio结构体及相关结构体之间的关系:
图5. bio结构体及bio_vec, bi_vcnt和bi_idx之间的关系

说明:

  • 在每个给定的块I/O操作中,bi_vcnt域用来描述bi_io_vec所指向的vio_vec数组中的向量数目。当块I/O操作执行完毕后,bi_idx域指向数组的当前索引。
  • 在块I/O操作期间bio描述符的内容一直保持更新。例如,如果块设备驱动程序在一次分散-聚集DMA操作中不能完成全部的数据传送,那么bio中的bi_idx字段会不断更新来指向待传送的第一个段。为了从索引bi_idx指向的当前段开始不断遍历bio中的段,设备驱动程可以执行宏bio_for_each_segment().

当通用块层启动一次新的I/O操作时,调用bio_alloc()函数分配一个新的bio结构。内核使用fs_bio_set结构类型来管理bio结构相关内存分配的缓冲区,这个结构由几个用于引用内存池或slab缓冲的指针组成。
fs_bio_set中的一个成员bio_pool用于引用分配bio结构的内存池,而在这个缓冲区中分配的内存块的单位并不是sizeof(struct bio),而是sizeof(struct bio) + BIO_INLINE_VECS* sizeof(struct bio_vec)个字节。在分配了bio结构之后,通常要为bio分配bio_vec结构,当bio_vec结构数小于BIO_INLINE_VECS时则可以通过bio结构的最后一个成员bi_inline_vecs来引用这些bio_vec结构。

内核同时创建了6个slab缓冲区用于分配数量不等的bio_vec结构,当所需的bio_vec结构数大于BIO_INLINE_VECS,则从这些缓冲区中分配内存。

I/O 向量

bi_io_vec域指向一个bio_vec结构体数组,该结构体链表包含一个特定I/O操作所需要使用的所有片段。

  • 每个bio_vec结构都是一个形式为<page, offset, len>的向量,它描述的是一个特定的片段。段所在的物理页、块在物理页中的偏移位置、从给定偏移量开始的块长度。

整个bio_vec结构体数组表示一个完整的缓冲区。bio_vec结构定义在include/linux/blk_types.h

1
2
3
4
5
6
7
8
/*
* was unsigned short, but we might as well be ready for > 64kB I/O pages
*/
struct bio_vec {
struct page *bv_page; //指向这个缓冲区所驻留的物理页。指向段的页框中页描述符的指针
unsigned int bv_len; //这个缓冲区以字节为单位的大小。段的字节长度
unsigned int bv_offset; //缓冲区所驻留的页以字节为单位的偏移量,页框中段数据的偏移量。
};

总而言之,每一个块IO请求都通过一个bio结构体表示。

  • 每个请求包括一个或多个块,这些块存储在bio_vec结构体数组中。
  • 这些结构体描述了每个片段在物理页中的实际位置,并且像向量一样被组织在一起。
  • IO操作的第一个片段由bio_io_vecs指针所指向,其他的片段在其后一次防止,共有bi_vcnt个片段。
  • 当块IO层开始执行请求、需要使用各个片段时,bi_idx域会不断更新,指向当前片段。
  • 块IO层通过bi_idx可以跟踪IO操作的完成进度。但该域更重要的作用在于分割bio结构体。

bi_cnt域记录bio结构体的使用计数,如果为0就销毁该结构体,并释放内存。通过下面的函数管理使用计数:

  • bio_put()代码在fs/bio.c
  • 点击展开bio_put()代码 >folded
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    /**
    * bio_put - release a reference to a bio
    * @bio: bio to release reference to
    *
    * Description:
    * Put a reference to a &struct bio, either one you have gotten with
    * bio_alloc, bio_get or bio_clone. The last put of a bio will free it.
    **/
    void bio_put(struct bio *bio)
    {
    BIO_BUG_ON(!atomic_read(&bio->bi_cnt));

    /*
    * last put frees it
    */
    if (atomic_dec_and_test(&bio->bi_cnt))
    bio_free(bio);
    }
    EXPORT_SYMBOL(bio_put);
  • bio_get()代码在include/linux/bio.h
  • 点击展开bio_get()代码 >folded
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    /*
    * get a reference to a bio, so it won't disappear. the intended use is
    * something like:
    *
    * bio_get(bio);
    * submit_bio(rw, bio);
    * if (bio->bi_flags ...)
    * do_something
    * bio_put(bio);
    *
    * without the bio_get(), it could potentially complete I/O before submit_bio
    * returns. and then bio would be freed memory when if (bio->bi_flags ...)
    * runs
    */
    #define bio_get(bio) atomic_inc(&(bio)->bi_cnt)
    前者增加使用计数,后者减少使用计数(如果为0就销毁该结构体,并释放内存)在操作正在活动的bio结构体时,一定要首先增加它的使用计数,以免在操作过程中该bio结构体被释放。

缓冲区头与bio结构体方法对比

缓冲区头和新的bio结构体之间存在显著差别:

  • bio结构体代表的是I/O操作,它可以包括内存中的一个或多个页。由于bio结构体是轻量级的,它描述的块可以不需要连续的存储区,并且不需要分隔I/O操作。
  • buffer_head结构体代表的是一个缓冲区,它描述的仅仅是磁盘中的一个块。因为缓冲区头关联的是单独页中的单独磁盘块,所以它可能会引起不必要的分隔,将请求按块为单位划分,只能靠以后再重新组合。

利用bio代替buffer_head好处有:

  • 不需要连续存储区,也不需要分割I/O操作
  • bio很容易处理高端内存,因为它处理的是物理页而不是直接指针
  • bio既可以代表普通页,也可以代表直接I/O
  • bio便于执行分散——集中(矢量化)块I/O操作,操作中的数据可以来自多个物理页
  • bio相比缓冲区头属于轻量级结构体。因为它只需要包含块I/O操作所需的信息就行了,不用包含与缓冲区本身相关的不必要信息。

但是还是需要缓冲区头这个概念,毕竟它还负责描述磁盘块到页面的映射。
bio结构体不包含任何和缓冲区相关的状态信息——它仅仅是一个矢量数组,描述一个或多个单独块I/O操作的数据片段和相关信息。
在当前设置中,当bio结构体描述当前正在使用的I/O操作时,buffer_head结构体仍然需要包含缓冲区信息。
内核通过这两种结构分别保存各自的信息,可以保证每种结构所含的信息量尽可能少。

请求队列

块设备将它们挂起的块I/O请求保存在请求队列中,该队列由request_queue结构体表示,定义在文件linux/blkdev.h中,包含一个双向请求链表以及相关控制信息。

点击展开request_queue结构体代码 >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
struct request_queue {
/*
* Together with queue_head for cacheline sharing
*/
struct list_head queue_head;
struct request *last_merge;
struct elevator_queue *elevator;
int nr_rqs[2]; /* # allocated [a]sync rqs */
int nr_rqs_elvpriv; /* # allocated rqs w/ elvpriv */

/*
* If blkcg is not used, @q->root_rl serves all requests. If blkcg
* is used, root blkg allocates from @q->root_rl and all other
* blkgs from their own blkg->rl. Which one to use should be
* determined using bio_request_list().
*/
struct request_list root_rl;

request_fn_proc *request_fn;
make_request_fn *make_request_fn;
prep_rq_fn *prep_rq_fn;
unprep_rq_fn *unprep_rq_fn;
merge_bvec_fn *merge_bvec_fn;
softirq_done_fn *softirq_done_fn;
rq_timed_out_fn *rq_timed_out_fn;
dma_drain_needed_fn *dma_drain_needed;
lld_busy_fn *lld_busy_fn;

/*
* Dispatch queue sorting
*/
sector_t end_sector;
struct request *boundary_rq;

/*
* Delayed queue handling
*/
struct delayed_work delay_work;

struct backing_dev_info backing_dev_info;

/*
* The queue owner gets to use this for whatever they like.
* ll_rw_blk doesn't touch it.
*/
void *queuedata;

/*
* various queue flags, see QUEUE_* below
*/
unsigned long queue_flags;

/*
* ida allocated id for this queue. Used to index queues from
* ioctx.
*/
int id;

/*
* queue needs bounce pages for pages above this limit
*/
gfp_t bounce_gfp;

/*
* protects queue structures from reentrancy. ->__queue_lock should
* _never_ be used directly, it is queue private. always use
* ->queue_lock.
*/
spinlock_t __queue_lock;
spinlock_t *queue_lock;

/*
* queue kobject
*/
struct kobject kobj;

#ifdef CONFIG_PM_RUNTIME
struct device *dev;
int rpm_status;
unsigned int nr_pending;
#endif

/*
* queue settings
*/
unsigned long nr_requests; /* Max # of requests */
unsigned int nr_congestion_on;
unsigned int nr_congestion_off;
unsigned int nr_batching;

unsigned int dma_drain_size;
void *dma_drain_buffer;
unsigned int dma_pad_mask;
unsigned int dma_alignment;

struct blk_queue_tag *queue_tags;
struct list_head tag_busy_list;

unsigned int nr_sorted;
unsigned int in_flight[2];
/*
* Number of active block driver functions for which blk_drain_queue()
* must wait. Must be incremented around functions that unlock the
* queue_lock internally, e.g. scsi_request_fn().
*/
unsigned int request_fn_active;

unsigned int rq_timeout;
struct timer_list timeout;
struct list_head timeout_list;

struct list_head icq_list;
#ifdef CONFIG_BLK_CGROUP
DECLARE_BITMAP (blkcg_pols, BLKCG_MAX_POLS);
struct blkcg_gq *root_blkg;
struct list_head blkg_list;
#endif

struct queue_limits limits;

/*
* sg stuff
*/
unsigned int sg_timeout;
unsigned int sg_reserved_size;
int node;
#ifdef CONFIG_BLK_DEV_IO_TRACE
struct blk_trace *blk_trace;
#endif
/*
* for flush operations
*/
unsigned int flush_flags;
unsigned int flush_not_queueable:1;
unsigned int flush_queue_delayed:1;
unsigned int flush_pending_idx:1;
unsigned int flush_running_idx:1;
unsigned long flush_pending_since;
struct list_head flush_queue[2];
struct list_head flush_data_in_flight;
struct request flush_rq;

struct mutex sysfs_lock;

int bypass_depth;

#if defined(CONFIG_BLK_DEV_BSG)
bsg_job_fn *bsg_job_fn;
int bsg_job_size;
struct bsg_class_device bsg_dev;
#endif

#ifdef CONFIG_BLK_CGROUP
struct list_head all_q_node;
#endif
#ifdef CONFIG_BLK_DEV_THROTTLING
/* Throttle data */
struct throtl_data *td;
#endif
struct rcu_head rcu_head;
};

通过内核中像文件系统这样的高层的代码将请求加入到队列中。请求队列只要不为空,队列对应的块设备驱动程序就会从队列头获取请求,然后将其送入对应的块设备上去。

  • 请求队列表中的每一项都是一个单独的请求,由request结构体表示。
  • 队列中的请求由结构体request表示,因为一个请求可能要操作多个连续的磁盘块,所以每个请求可以由多个bio结构体组成。
  • 注意,虽然磁盘上的块必须连续,但是在内存中这些块并不一定要连续_每个bio结构体都可以描述多个段(段是内存中的连续的小区域),而每个请求也可以包含多个bio结构体。

I/O调度程序(块I/O调度层)

如果简单地以内核产生的请求的次序直接将请求发向块设备的话,性能会很差。磁盘寻址是整个计算机中最慢的操作之一,每次寻址(定位磁盘磁头到特定块上的某个位置)需要花费不少时间,所以尽量缩短寻址时间无疑提高系统性能。

为了优化寻址操作,内核既不会简单地按请求接受次序,也不会立即将其提交给磁盘。相反,它会在提交前,先执行名为合并排序预操作,这种预操作可以极大地提高系统的整体性能。

在内核中负责提交I/O请求的子系统称为I/O调度程序:

  • I/O调度程序将磁盘I/O资源分配给系统中所有挂起的块I/O请求。这种资源分配是通过将请求队列中挂起的请求合并和排序来完成的。

进程调度程序与I/O调度程序的共同点与区别:

  • 二者都是将一个资源虚拟给多个对象
  • 进程调度程序的作用是将处理器资源分配给系统中的运行进程。处理器被进程调度程序虚拟给系统中的运行进程共享。
  • I/O调度程序虚拟块设备给多个磁盘请求,以便降低磁盘寻址时间,确保磁盘性能地最优化。

I/O调度程序的工作

I/O调度程序的工作是管理块设备的请求队列。

  • 它决定队列中的请求排列顺序以及在什么时候派发请求到块设备。这样做有利于减少磁盘寻址时间,从而提高全局吞吐量。
  • I/O调度器提高的是系统整体性能,对个别请求可能不公平。

I/O调度程序通过两种方法减少磁盘寻址时间:合并与排序。

  • 合并:指将两个或多个请求结合成一个请求。通过合并请求,I/O调度程序将多次请求的开销压缩成一次请求的开销。更重要的是,请求合并后只需要传递给磁盘一条寻址命令,就可以访问到请求合并前必须多次寻址才能访问完的磁盘区域了,因此合并请求显然能减少系统开销和磁盘寻址次数。
    • 比如文件系统提交请求到请求队列——请求是从文件中读取一个数据区。(当然,最终所有的操作都是针对扇区和块进行的,而不是文件,还假定请求的块都是来自文件块)。如果这时队列中已经存在一个请求,它访问的磁盘扇区和当前请求访问的磁盘扇区相邻(比如,同一个文件中早些时候被读取的数据区),那么这两个请求就可以合并为一个对单个和多个相邻磁盘扇区操作的新请求。
  • 排序:如果存在一个请求,它要操作的磁盘扇区位置与当前请求比较接近,那么是不是该让这两个请求在请求队列上也相邻呢?事实上,I/O调度程序的确是这样处理上述情况的,整个请求队列将按扇区增长方向有序排列。使所有请求按硬盘上扇区的排列顺序有序排列(尽可能的)的目的不仅是为了缩短单独一次请求的寻址时间,更重要的优化在于,通过保持磁盘头以直线方向移动,缩短了所有请求的磁盘寻址时间。该排序算法类似于电梯调度(A–>B…..A—>B)。

访问块设备

字符设备的实现比较简单,内核例程和用户态API一一对应,这种映射关系由字符设备的file_operations维护。块设备接口则相对复杂,读写API没有直接到块设备层,而是直接到文件系统层,然后再由文件系统层发起读写请求。
对于块设备来说,读写操作是以数据块为单位进行的,为了使高速的 CPU 同低速块设备能够协调工作,提高读写效率,操作系统设置了缓冲机制。当进行读写的时候,首先对缓冲区读写,只有缓冲区中没有需要读的数据或是需要写的数据没有地方写时,才真正地启动设备控制器去控制设备本身进行数据交换,而对于设备本身的数据交换同样也是同缓冲区打交道。

块设备驱动程序的注册

register_blkdev()函数

块设备驱动中的第1个工作通常是注册它们自己到内核,申请设备号,完成这个任务的函数是register_blkdev(),代码在/block/genhd.c

点击展开register_blkdev()函数代码 >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/**
* register_blkdev - register a new block device
*
* @major: the requested major device number [1..255]. If @major=0, try to
* allocate any unused major number.
* @name: the name of the new block device as a zero terminated string
*
* The @name must be unique within the system.
*
* The return value depends on the @major input parameter.
* - if a major device number was requested in range [1..255] then the
* function returns zero on success, or a negative error code
* - if any unused major number was requested with @major=0 parameter
* then the return value is the allocated major number in range
* [1..255] or a negative error code otherwise
*/
int register_blkdev(unsigned int major, const char *name)
{ //传入的参数是要注册的主设备号和设备名称
struct blk_major_name **n, *p;
int index, ret = 0;

mutex_lock(&block_class_lock);

/* temporary */
//检查传入的主设备号是否为0,如果为0怎么办?
//如果为0的话内核会自动去major_name这个指针数组中为你分配一个主设备号
//如果已经没有空闲的设备号的话,就会返回一个错误
if (major == 0) {
for (index = ARRAY_SIZE(major_names)-1; index > 0; index--) {
if (major_names[index] == NULL)
break;
}

if (index == 0) {
printk("register_blkdev: failed to get major for %s\n",
name);
ret = -EBUSY;
goto out;
}
major = index;
ret = major;
}
//如果主设备号不为0 ,我们就可以申请一个struct blk_major_name的结构体,并将它初始化,主要是将设备号和设备名称存储到这个结构体中
p = kmalloc(sizeof(struct blk_major_name), GFP_KERNEL);
if (p == NULL) {
ret = -ENOMEM;
goto out;
}

p->major = major;
strlcpy(p->name, name, sizeof(p->name));
p->next = NULL;
index = major_to_index(major);
//这个函数是做模运算用来得到一个索引值(0-255),也许你要问了,如果我的主设备号大于255,是不是会将major_names指针数组中的元素覆盖掉?
//看看下面的for循环就明白了。假定主设备号是288,那么index = 288%255,就是33

//内核首先拿到索引值为33的元素,用*n判断此元素是否为空,如果为空,直接跳出for循环,将上面开辟的结构体赋值给*n好了,这样就将设备的信息注册到了全局数组中去了
//如果此元素不为空,也就是已经有一个设备注册到这了,怎么办?那内核就会去比较已经注册的主设备号与我们将要注册的主设备号是否一致,如果一致,内核就会提示我们该设备号已经被占用了。
//如果不一致呢?有一个next指针,这个指针是指向下一个设备的结构体,直到找到一个空的结构体,并将前面申请的结构体进行赋值。
for (n = &major_names[index]; *n; n = &(*n)->next) {
if ((*n)->major == major)
break;
}
if (!*n)
*n = p;
else
ret = -EBUSY;

if (ret < 0) {
printk("register_blkdev: cannot get major %d for %s\n",
major, name);
kfree(p);
}
out:
mutex_unlock(&block_class_lock);
return ret;
}

EXPORT_SYMBOL(register_blkdev);

参数说明:

  • major参数是块设备要使用的主设备号,name为设备名,它会显示在/proc/devices中。如果major为0,内核会自动分配一个新的主设备号,register_blkdev()函数的返回值就是这个主设备号。
  • 如果register_blkdev()返回一个负值,表明发生了一个错误。
  • /proc/devices是一个文件,这个文件列出字符和块设备的主设备号,以及分配到这些设备号的设备名称。示例如下:
  • 点击展开/proc/devices示例 >folded
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    [root@master ~]# cat /proc/devices 
    Character devices:
    1 mem
    4 /dev/vc/0
    4 tty
    4 ttyS
    5 /dev/tty
    5 /dev/console
    5 /dev/ptmx
    7 vcs
    10 misc
    13 input
    21 sg
    29 fb
    128 ptm
    136 pts
    162 raw
    180 usb
    188 ttyUSB
    189 usb_device
    202 cpu/msr
    203 cpu/cpuid
    226 drm
    231 infiniband_mad
    231 infiniband_verbs
    231 infiniband_cm
    235 infiniband_cm
    236 infiniband_mad
    237 mlx5_fpga_tools
    238 mei
    239 ipmidev
    240 infiniband_verbs
    241 aux
    242 nvme
    243 megaraid_sas_ioctl
    244 ptp
    245 pps
    246 hidraw
    247 usbmon
    248 bsg
    249 hmm_device
    250 watchdog
    251 iio
    252 rtc
    253 dax
    254 tpm

    Block devices:
    259 blkext
    8 sd
    9 md
    11 sr
    65 sd
    66 sd
    67 sd
    68 sd
    69 sd
    70 sd
    71 sd
    128 sd
    129 sd
    130 sd
    131 sd
    132 sd
    133 sd
    134 sd
    135 sd
    254 mdp

major_names数组

代码在block/genhd.c

1
2
3
4
5
static struct blk_major_name {
struct blk_major_name *next;
int major;
char name[16];
} *major_names[BLKDEV_MAJOR_HASH_SIZE];

说明:

  • BLKDEV_MAJOR_HASH_SIZE = 255
  • 这是一个指针数组,其中的每一个元素都指向了一个struct blk_major_name的结构体
  • struct blk_major_name结构体就是用来存放设备的主设备号和设备名称的
  • 更加详细的解释看参考文献。

unregister_blkdev()函数

unregister_blkdev()代码在/block/genhd.c

点击展开unregister_blkdev()函数代码 >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void unregister_blkdev(unsigned int major, const char *name)
{
struct blk_major_name **n;
struct blk_major_name *p = NULL;
int index = major_to_index(major);

mutex_lock(&block_class_lock);
for (n = &major_names[index]; *n; n = &(*n)->next)
if ((*n)->major == major)
break;
if (!*n || strcmp((*n)->name, name)) {
WARN_ON(1);
} else {
p = *n;
*n = p->next;
}
mutex_unlock(&block_class_lock);
kfree(p);
}

EXPORT_SYMBOL(unregister_blkdev);

说明:传递给register_blkdev()的参数必须与传递给unregister_blkdev()的参数匹配,否则这个函数返回-EINVAL。

两个函数的声明代码在include/linux/fs.h

1
2
extern int register_blkdev(unsigned int, const char *);
extern void unregister_blkdev(unsigned int, const char *);

每种具体的块设备都有一套具体的操作,因而各自有一个类似于file_operations 那样的数据结构,称为block_device_operations 结构。它是对块设备操作的集合,其定义为,代码在include/linux/blkdev.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct block_device_operations {
//打开和释放
int (*open) (struct block_device *, fmode_t);
void (*release) (struct gendisk *, fmode_t);

//I/O控制
int (*ioctl) (struct block_device *, fmode_t, unsigned, unsigned long);
int (*compat_ioctl) (struct block_device *, fmode_t, unsigned, unsigned long);

int (*direct_access) (struct block_device *, sector_t,
void **, unsigned long *);

//介质改变
unsigned int (*check_events) (struct gendisk *disk,
unsigned int clearing);
/* ->media_changed() is DEPRECATED, use ->check_events() instead */
int (*media_changed) (struct gendisk *);

void (*unlock_native_capacity) (struct gendisk *);

//使介质有效
int (*revalidate_disk) (struct gendisk *);

//获得驱动器信息
int (*getgeo)(struct block_device *, struct hd_geometry *);

/* this callback is with swap_lock and sometimes page table lock held */
void (*swap_slot_free_notify) (struct block_device *, unsigned long);、

//模块指针,一个指向拥有这个结构体的模块的指针,它通常被初始化为THIS_MODULE
struct module *owner;
};

如果说file_operation 结构是连接虚拟的VFS 文件的操作与具体文件系统的文件操作之间的枢纽,那么block_device_operations就是连接抽象的块设备操作与具体块设备操作之间的枢纽。

具体的块设备是由主设备号唯一确定的,因此,主设备号唯一地确定了一个具体的block_device_operations 数据结构

那么,块设备注册到系统以后,怎样与文件系统联系起来呢,也就是说,文件系统怎么调用已注册的块设备,这还得从file_operations 结构说起。

先来看一下块设备的file_operations 结构的定义,变量名为def_blk_fops,其位于fs/block_dev.c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const struct file_operations def_blk_fops = {
.open = blkdev_open,
.release = blkdev_close,
.llseek = block_llseek,
.read = do_sync_read,
.write = do_sync_write,
.aio_read = blkdev_aio_read,
.aio_write = blkdev_aio_write,
.mmap = generic_file_mmap,
.fsync = blkdev_fsync,
.unlocked_ioctl = block_ioctl,
#ifdef CONFIG_COMPAT
.compat_ioctl = compat_blkdev_ioctl,
#endif
.splice_read = generic_file_splice_read,
.splice_write = generic_file_splice_write,
};

以open()系统调用为例,说明用户进程中的一个系统调用如何最终与物理块设备的操作联系起来。在此,我们仅仅给出几个open()函数的调用关系,如图所示。

图6.几个open函数的调用关系
  • 当调用open()系统调用时,其最终会调用到def_blk_fops的blkdev_open() 函数。
  • blkdev_open()函数的任务:根据主设备号找到对应的block_device_operations结构,然后再调用block_device_operations结构中的函数指针open所指向的函数,如果open 所指向的函数非空,就调用该函数打开最终的物理块设备。

这就简单地说明了块设备注册以后,从最上层的系统调用到具体地打开一个设备的过程。

块驱动程序的体系结构

块设备驱动程序通常分为两部分,即高级驱动程序和低级驱动程序,前者处理VFS 层,后者处理硬件设备

图7.块设备驱动程序的体系结构

假设进程对一个设备文件发出read()或write()系统调用。
VFS 执行对应文件对象的read 或write方法,由此就调用高级块设备处理程序中的一个过程。这个过程执行的所有操作都与对这个硬件设备的具体读写请求有关。
内核提供两个名为generic_file_read()和generic_file_write()通用函数来留意所有事件的发生。
因此,在大部分情况下,高级硬件设备驱动程序不必做什么,而设备文件的read和write方法分别指向generic_file_read()和generic_file_write()方法。但是,有些块设备的处理程序需要自己专用的高级设备驱动程序。

即使高级设备驱动程序有自己的read 和write 方法,但是这两个方法通常最终还会调用generic_file_read ( )和generic_file_write ( )函数。这些函数把对I/O 设备文件的访问请求转换成对相应硬件设备的块请求。

  • 所请求的块可能已在主存, 因此generic_file_read ( )和generic_file_write ( )函数调用getblk()函数来检查缓冲区中是否已经预取了块,还是从上次访问以来缓冲区一直都没有改变。
  • 如果块不在缓冲区中,getblk()就必须调用ll_rw_block()继续从磁盘中读取这个块,后面这个函数激活操纵设备控制器的低级驱动程序,以执行对块设备所请求的操作。

在VFS 直接访问某一块设备上的特定块时,也会触发缓冲区I/O 操作。

  • 例如,如果内核必须从磁盘文件系统中读取一个索引节点,那么它必须从相应磁盘分区的块中传送数据 。
  • 对于特定块的直接访问是由bread()和breada()函数来执行的,这两个函数又会调用前面提到过的getblk()和ll_rw_block()函数。

由于块设备速度很慢,因此缓冲区I/O 数据传送通常都是异步处理的:

  • 低级设备驱动程序对DMAC和磁盘控制器进行编程来控制其操作,然后结束。当数据传送完成时,就会产生一个中断,从而第2次激活这个低级设备驱动程序来清除这次I/O 操作所涉及的数据结构。

块设备基于缓冲区的数据交换

对于块设备来说,读写操作是以数据块为单位进行的,为了使高速的 CPU 同低速块设备能够协调工作,提高读写效率,操作系统设置了缓冲机制。
块设备读和写当进行读写的时候,首先对缓冲区读写,只有缓冲区中没有需要读的数据或是需要写的数据没有地方写时,才真正地启动设备控制器去控制设备本身进行数据交换,而对于设备本身的数据交换同样也是同缓冲区打交道。

在PC 体系结构中,允许块的大小为512、1024、2048 和4096 字节。同一个块设备驱动程序可以作用于多个块大小,因为它必须处理共享**同一主设备号的一组设备文件,而每个块设备文件都有自己预定义的块大小。

例如,一个块设备驱动程序可能会处理有两个分区的硬盘,一个分区包含Ext2 文件系统,另一个分区包含交换分区。
内核在一个名为blksize_size的表中存放块的大小;
表中每个元素的索引就是相应块设备文件的主设备号和从设备号。如果blksize_size[M]为NULL,那么共享主设备号M的所有块设备都使用标准的块大小,即1024 字节。

每个块都需要自己的缓冲区,它是内核用来存放块内容的RAM内存区。当设备驱动程序从磁盘读出一个块时,就用从硬件设备中所获得的值来填充相应的缓冲区;同样,当设备驱动程序向磁盘中写入一个块时,就用相关缓冲区的实际值来更新硬件设备上相应的一组相邻字节。
缓冲区的大小一定要与块的大小相匹配。

块设备请求

虽然块设备驱动程序可以一次传送一个单独的数据块,但是内核并不会为磁盘上每个被访问的数据块都单独执行一次I/O 操作,取而代之的是,只要可能,内核就试图把几个块合并在一起,并作为一个整体来处理,这样就减少了磁头的平均移动时间。

当进程、VFS 层或者任何其他的内核部分要读写一个磁盘块时,就真正引起一个块设备请求。

  • 从本质上说,这个请求描述的是所请求的块以及要对它执行的操作类型(读还是写)。
  • 然而,并不是请求一发出,内核就满足它,实际上,块请求发出时I/O 操作仅仅被调度,稍后才会被执行(先调度,后执行)。这种人为的延迟有悖于提高块设备性能的关键机制。
  • 当请求传送一个新的数据块时,内核检查能否通过稍微扩大前一个一直处于等待状态的请求而满足这个新请求,也就是说,能否不用进一步的搜索操作就能满足新请求。由于磁盘的访问大都是顺序的,因此这种简单机制就非常高效。

延迟请求复杂化了块设备的处理。

  • 例如,假设某个进程打开了一个普通文件,然后,文件系统的驱动程序就要从磁盘读取相应的索引节点。高级块设备驱动程序把这个请求加入一个等待队列,并把这个进程挂起,直到存放索引节点的块被传送为止。因为块设备驱动程序是中断驱动的,因此,只要高级驱动程序一发出块请求,它就可以终止执行。
  • 在稍后的时间低级驱动程序才被激活,它会调用一个所谓的策略程序从一个队列中取得这个请求,并向磁盘控制器发出适当的命令来满足这个请求。当I/O 操作完成时,磁盘控制器就产生一个中断,如果需要,相应的处理程序会再次调用这个策略程序来处理队列中进程的下一个请求。

每个块设备驱动程序都维护自己的请求队列;每个物理块设备都应该有一个请求队列,以提高磁盘性能的方式对请求进行排序。
因此策略程序就可以顺序扫描这种队列,并以最少地移动磁头而为所有的请求提供服务。

块设备驱动程序的几个函数

Linux中硬盘驱动程序的实现

重要文件

由于块设备驱动程序的绝大部分都与设备无关的,故内核开发者通过把大部分相同的代码放在一个头文件block/blk.h中来简化驱动程序的代码。从而每个块设备驱动程序都必须 包含这个头文件。

参考资料

《深入分析Linux内核源代码》 //书籍内核版本有些旧
《Linux内核设计与实现》 //目前感觉这个版本是最准的
Linux 通用块层 bio 详解
块设备注册 register_blkdev | 学步园

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×