通用块层

概述

在块设备上的操作,涉及内核中的多个组成部分,如图1所示。假设一个进程使用系统
调用read()读取磁盘上的文件。下面步骤是内核响应进程读请求的步骤;

linux-kernel-bio.png
  • 系统调用read()会触发相应的VFS(Virtual Filesystem Switch)函数,传递的参数
    有文件描述符和文件偏移量。
  • VFS确定请求的数据是否已经在内存缓冲区中;若数据不在内存中,确定如何执行读操作。
  • 假设内核必须从块设备上读取数据,这样内核就必须确定数据在物理设备上的位置。这由映射层(Mapping Layer)来完成,主要执行两步
    1. 内核确定该文件所在的文件系统的块大小,并根据文件块的大小计算所请求数据的长度。本质上,文件被看作拆分成许多块,因此内核确定请求数据所在的块号(文件开始位置的相对索引)。
    2. 接下来,映射层调用一个具体的文件系统的函数,它访问文件的磁盘节点,然后根据逻辑块号确定所请求数据在磁盘上的位置。事实上,磁盘也被看作分成许多块,因此内核必须确定存放所请求数据的块对应的号(磁盘或分区开始位置的相对索引)。由于一个文件可能存储在磁盘上的不连续块中,因此存放在磁盘索引节点中的数据结构将每个文件块号映射为一个逻辑块号。
  • 内核可以对块设备发出读请求。内核利用通用块层(generic block layer)启动I/O操作来传送所请求的数据。一般而言, 每个I/O操作只针对磁盘上一组连续的块。由于请求的数据不必位于相邻的块中,所以通用块层可能启动几次I/O操作。每次I/O操作是由一个“块I/O”(简单block io 即bio)的结构来描述,它收集底层组件需要的所有信息以满足所发出的请求
    通用块层为所有的块设备提供了一个抽象的视图,因而隐藏了硬件块设备间的差异性。几乎所有的块设备都是磁盘。所以通用块层也提供了一些数据结构来描述“磁盘”或"磁盘分区"
  • 通用块层的下面“I/O调度程序”根据预定义的内核策略将待处理的I/O数据传送请求进行归类。调度程序的作用是把物理介质上相邻的数据请求聚集在一起。
    -最后,块设备驱动程序向磁盘控制器的三件接口发送适当的命令。从而进行实际的数据传送。

对于(1)、(2)两个步骤,在Linux虚拟文件系统中,我们讨论了VFS(Virtual Filesystem Switch)主要数据结构和操作,结合相关系统调用(如sys_read()、sys_write()等) 的源码,我们不难理解VFS层相关的操作和实现。

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

  • 硬件块设备控制器采用称为扇区的固定长度的块来传递数据。因此,I/O调度程序和块驱动程序必须管理数据扇区
  • 虚拟文件系统、映射层和文件系统将磁盘数据存放在称为的逻辑单元中。一个块对应文件系统中一个最小磁盘存储单元
  • 块设备驱动程序应该能够处理数据的:一个段就是一个内存页或内存内的一部分,它们包括磁盘上物理相邻的数据块。
  • 磁盘高速缓存作用于磁盘数据的上,每页正好装在一个页框中。
  • 通用块层将所有的上层和下层的组件组合在一起,因此它了解数据的扇区、块、段以及页。
    注:但是,如果从原始块设备文件进行读访问,映射层就不调用具体文件系统的方法,而是把块设备文件中的偏移量转换成磁盘或在对应该设备文件的磁盘分区中的位置。
    即使有许多不同的数据块,它们通常也是共享相同的物理RAM单元。例如:图2显示了一个具有4K的页的构造。上层内核组件将页看成是由4个1K字节组成的块缓冲区。块设备驱动程序正在传送页中的后3个块,因此这3块被插入到涵盖了后3K字节的段中。硬盘控制器将段看成由6个512字节的扇区组成。
    page-disk-layout.png

相关概念

系统中能够随机访问固定大小数据片(chunk)的设备称为块设备,这些数据片就称作 块。最常见的块设备是硬盘,除此之外,还有CD-ROM驱动器和SSD等。它们通常安装文 件系统的方式使用。
内核管理块设备要比管理字符设备复杂。因为字符设备仅仅需要控制一个位置-当前位 置,而块设备访问的位置必须能够在介质的不同区间前后移动。所以内核不必提供一个专门 的子系统来管理字符设备,但是对于块设备的管理却必须要有一个专门提供服务的子系统。 不仅仅是因为块设备的复杂性远远高于字符设备,更重要的原因是块设备对执行性能要求很 高;对硬盘每多一分利用都会对系统的整体性能带来提升,其效果要远远比键盘吞吐速度成 倍的提高大得多。另外,块设备的复杂性会为这种优化留下很大的空间。

  • 扇区
    为了达到可接受的性能,硬盘和类似的设备快速传送几个相邻字节的数据。块设备的每次传送操作都作用于一组称为扇区的相邻字节。我们假定字节按相邻的方式记录在磁盘表面,这样一次搜索操作就可以访问到它们。尽管磁盘的物理构造很复杂,但是硬盘控制器收到的命令将硬盘看成一个大组扇区
    在大部分磁盘设备中,扇区的大小是512字节但是一些设备使用更大的扇区(1K 2K)。注意,应该把扇区作为数据传送的基本单元;不允许传送少于一个扇区的数据,尽管大多数磁盘设备都可以同时传送几个相邻的扇区。
    Linux中扇区的大小按惯例设为512字节;如果一个块设备使用更大的扇区,那么相应的底层块设备驱动程序将做些必要的变换。因此,对存放在块设备中的一组数据是通过它们在磁盘上的位置来标识,即首个512字节扇区的下标以及扇区的数目。扇区的下标放在类型为sector_t的32或64位亦是中。

  • 扇区是硬件设备传送数据的基本单位,而是VFS和文件系统传送数据的基本单位。例如:内核访问一个文件的内容时,它必须首先从磁盘上读文件的磁盘索引节点所在块。该块对应磁盘上的一个或多个相邻的扇区,而VFS将其看成是一个单一的数据单元
    在Linux中,块大小必须是2的幂,而且不能超过一个页框。此外,它必须是扇区大小的整数倍,因为每个块必须包含整个扇区。因此在80x86体系结构中,它允许块的大小为512 1024 2048 4096字节(linux 固定扇区大小为512)。

块设备的块大小不是唯一的。创建一个磁盘文件系统时,管理员可以选择合适的块大小。因此,同一个磁盘上的几个分区可能使用不同的块大小。此外,对块设备文件的每次读或写操作是一种"原始"访问,因此它绕过了磁盘的文件系统;内核通过使用最大的块(4096)执行该操作。

每个块都需要自己的块缓冲区,它是内核用来存放块内容的RAM内存区。当内核从磁盘读出一个块时,就用从硬件设备中获得的值来填充相应的块缓冲区;同样,当内核向磁盘写入一个块时,就用相关块缓冲的实际值来更新硬件设备上相应的一组相邻字节。块缓冲区的大小 通常要与相应的块大小相匹配。
缓冲区首部是一个与每个缓冲区相关的buffer_head类型的描述符。它包含内核处理缓冲区需要了解的所有信息;因此,在对每个缓冲区进行操作之前,内核都要首先检查其缓冲区首部。
其中b_page字段存放的是块缓冲区所在页框的页描述符地址。如果页框位于高端内存中,那么 b_data字段存放页中块缓冲区的偏移量;否则,b_data存放块缓冲区本身的起始线性地址。b_blocknr字段存放的是逻辑块号。最后,b_bdev字段标识使用缓冲区首部的块设备。


  • 磁盘的每个IO操作就是在磁盘与一些RAM单元之间相互传送一些相邻扇区的内容。大多数情况下,磁盘控制器直接采用DMA方式进行数据传送。块设备驱动程序只要向磁盘控制器发送一些适当的命令就可以触发一次数据传送,一旦完成数据的传送,控制器就会发出一个中断通知块设备驱动程序。
    DMA方式传送的是磁盘上相邻扇区的数据。这是一个物理约束:磁盘控制器允许DMA传送不相邻的扇区数据,但是这种方式的传送速率很低,因为在磁盘表面上移动读、写磁头是相当慢的。

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

  1. 要传送的起始磁盘扇区号和总的扇区数。
  2. 内存区的描述符链表,其中链表的每项包含一个地址和一个长度。
    磁盘控制器负责整个数据的传送;例如,在读操作中控制器从相邻磁盘扇区中获得数据,然后将它们存放到不同的内存区中。
    为了使用分散-聚集DMA方式传送方式,块设备驱动程序必须能够处理称为段的数据存储单元。一个段就是一个内存页或内存页中的一部分,它们包含一些相邻的磁盘扇区中的数据。因此一次分散-聚集DMA操作可以同时传送几个段。

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

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

通用块层

通用块层是一个内核组件,它处理来自系统中的所有块设备发出的请求。

  • BIO结构

通用块层的核心数据结构是一个称为BIO的描述符,它描述了块设备的IO操作。每个bio结构都包含一个磁盘存储区标识符(存储区中的起始扇区和扇区数目)和一个或多个描述符 与IO操作相关的内存区的段。bio由struct bio 数据结构描述,源代码如下:
struct bio
https://github.com/sparrowzoo/linux/blob/master/include/linux/blk_types.h

bio中的每个段是一个由bio_vec数据结构描述的
源代码如下:
https://github.com/sparrowzoo/linux/blob/master/include/linux/bvec.h
在块IO操作期间,bio描述符的内容一直保持更新。例如,果块设备驱动程序在一次分散-聚集DMA操作中不能完成全部的数据传送,那么bio中的bi_idx字段会不断更新来指向待传送的第一个段。

struct bvec_iter {
    sector_t        bi_sector;  /* device address in 512 byte
                           sectors */
    unsigned int        bi_size;    /* residual I/O count */

    unsigned int        bi_idx;     /* current index into bvl_vec */

    unsigned int            bi_bvec_done;   /* number of bytes completed in
                           current bvec */
};

为了从索引bi_idx指向的当前段开始不断重复bio中的段,设备驱动程序可以执行宏bio_for_each_segment。
通用块层启动一次新的IO操作时,调用bio_alloc函数分配一个新的bio结构。通常,bio结构是由slab分配器分配的。但是,当内存不足时,内核也会使用一个备用的bio小内存池。内核也为bio_vec结构分配内存池。毕竟,分配一个bio结构而不能分配其中的段描述符也是没有什么意义的。相应地bio_put函数减少bio中中引用计数器bi_cnt的值,如果该值小于0,则释放bio结构以及相关的bio_vec结构。

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

    磁盘由gendisk对象描述源码注释
    https://github.com/sparrowzoo/linux/blob/master/include/linux/genhd.h

    块设备操作源码注释
    https://github.com/sparrowzoo/linux/blob/master/include/linux/blkdev.h

通常硬盘被划分成几个逻辑分区。每块块设备文件要么代表整个磁盘,要么代表磁盘中的某一个分区。例如,一个主设备号为3、次设备号为0的设备文件/dev/had代表的可能是一个主IDE磁盘;该磁盘中的前两个分区分别由设备文件/dev/hda1和/dev/hda2代表,它们的主设备号都是3,而次设备号分别为1和2。一般而言,磁盘中的分区是由连续的次设备号来区分的。
如果将一个磁盘分成了几个分区,那么其分区表保存在hd_struct结构数组中,该数的地址存放在gendisk对象的part (struct disk_part_tbl __rcu *part_tbl; 源码版本不一致)字段中。通过磁盘内分区的相对索引对该数组进行索引。hd_struct数据结构如下:

struct disk_part_tbl {
    struct rcu_head rcu_head;
    int len;
    struct hd_struct __rcu *last_lookup;
    struct hd_struct __rcu *part[];
};
struct hd_struct {
    sector_t start_sect;
    /*
     * nr_sects is protected by sequence counter. One might extend a
     * partition while IO is happening to it and update of nr_sects
     * can be non-atomic on 32bit machines with 64bit sector_t.
     */
    sector_t nr_sects;
    seqcount_t nr_sects_seq;
    sector_t alignment_offset;
    unsigned int discard_alignment;
    struct device __dev;
    struct kobject *holder_dir;
    int policy, partno;
    struct partition_meta_info *info;
#ifdef CONFIG_FAIL_MAKE_REQUEST
    int make_it_fail;
#endif
    unsigned long stamp;
    atomic_t in_flight[2];
#ifdef  CONFIG_SMP
    struct disk_stats __percpu *dkstats;
#else
    struct disk_stats dkstats;
#endif
    struct percpu_ref ref;
    struct rcu_head rcu_head;
};

当内核发现系统中一个新的磁盘时(在启动阶段,或将一个可移动介质插入到一个驱动器中时,或在运行期附加一个外置磁盘时),就调用alloc_disk()函数,该函数分配并初始化一个新的gendisk对象。如果新磁盘被分成了几个分区,那么alloc_disk还会分配并初始化一个适当的hd_struct类型的数组。然后,内核调用add_disk()函数将gendisk对象插入到通用块层的数据结构中。

  • 提交请求
    我们介绍一下当向通用块层提交一个IO操作请求时,内核所执行的步骤顺序。我们假定(因为上文提到一个IO,如果数据不相邻会被拆成多个请求)被请求的数据块在磁盘上是相邻的,并且内核已经知道了它们的物理位置。
  1. 第一步是执行bio_alloc函数分配一个新的bio描述符。然后通过设置一些字段值来初始化bio描述符(bi_sector\bi_size\bi_bdev\bi_io_vec\bi_rw\bi_end_io)
    一旦bio描述符被进行了适当的初始化,内核就调用generaic_make_request函数,该函数是通用块层的主要入口点。

    1. 获取与块设备相关的请求队列
    2. 调用blk_partition_remap()函数

    至此,能用块层 IO调度程序以及设备驱动程序将忘记磁盘分区的存在,直接作用于整个磁盘。

    1. 调用q_make_request_fn方法将bio请求插入到请求队列中。

你可能感兴趣的:(通用块层)