Linux块设备处理程序的组织是相当复杂的,在此不可能详细介绍内核块设备I/O子系统中包含的所有函数
我们主要说明下面几个问题:
- Linux块设备I/O子系统的体系结构是什么?
- 块设备I/O子系统的主要组件有哪些?有哪些作用?
- 打开一个块设备文件时内核执行的步骤有哪些?
- 内核如何对块设备和块设备的请求进行管理?->这部分在内核中称为块I/O层
块设备
:系统中能够随机(不需要按顺序)访问固定大小数据片(chunks)的设备被称作块设备
,这些数据片就称作块
。
字符设备
:另一种基本的设备类型是字符设备
。
两种类型的设备的根本区别在于它们是否可以被随机访问——也就是能否在访问设备时随意地从一个位置跳转到另一个位置。
扇区是什么?
为什么提出扇区?扇区的作用是什么?
扇区
的相邻字节。 块:
最大的块(4096字节)
执行该操作。块缓冲区
:每个块都需要自己的块缓冲区,它是内核用来存放块内容的RAM空间。
缓冲区首部
是一个与每个缓冲区相关的buffer_head
类型的描述符。它包含内核处理缓冲区需要了解的所有信息;因此,在对每个缓冲区进行操作之前,内核都要首先检查其缓冲区首部;因此,在对每个缓冲区进行操作之前,内核都要首先检查其缓冲区首部。图1是扇区与块之间的关系图:
扇区和块的别称:
扇区
:设备的最小寻址单元,或称为“硬扇区
”“设备块
”。块
:文件系统的最小寻址单元,或称为“文件块
”“I/O块
”。扇区和块的区别:
扇区这一个概念之所以对内核重要,是因为所有设备的I/O必须以扇区为单位进行操作,内核所使用的“块”这一个高级概念就是建立在扇区之上的。
深入理解linux
在本节我们来说明一下Linux块设备I/O子系统的体系结构。块设备驱动程序上的每个操作都涉及很多内核组件,其中最重要的一些如图2所示:
例如,我们假设一个进程在某个磁盘文件上发出一个read()系统调用(write()系统调用本质上采用同样的方式)。下面是内核对进程请求给予回应的一般步骤:
文件描述符
和文件内的偏移量
传递给它。虚拟文件系统位于块设备处理体系结构的上层,他提供一个通用的文件模型,Linux支持的所有文件系统均采用该模型。磁盘高速缓存
来获得数据。映射层(mapping layer)
,主要执行下面两步:通用块层(generic block layer)
启动I/O操作来传送所请求的数据。一般而言,每个I/O操作只针对磁盘上一组连续的块。由于请求的数据不必位于相邻的块中,所以通用块层可能启动几次I/O操作。每次I/O操作是由一个“块I/O”(简称“bio”)结构描述,它收集底层组件需要的所有信息以满足所发出的请求。通用块层为所有的块设备提供了一个抽象视图,因而隐藏了硬件块设备间的差异性。几乎所有的块设备都是磁盘。I/O调度程序
”根据预先定义的内核策略将待处理的I/O数据传送请求归类。调度程序的作用
是把物理介质上相邻的数据请求聚集在一起。块设备中的数据存储涉及到了许多内核组件,每个组件采用不同长度的块来管理磁盘数据:
扇区
”的固定长度的块来传送数据。因此,I/O调度程序和块设备驱动程序必须管理数据扇区。“块”
的逻辑单元中。一个块对应文件系统中一个最小的磁盘存储单元。“段”
: 块设备驱动程序应该能够处理数据的“段”
。一个段就是一个内存页或内存页的一部分,它们包含磁盘上物理相邻的数据块。磁盘高速缓存
作用于磁盘数据的“页”
上,每页正好装在一个页框
中。通用块层
将所有的上层和下层的组件组合在一起,因此它了解数据的扇区、块、段以及页
。即使有许多不同的数据块,它们通常也是共享相同的物理RAM单元。例如,图3显示了一个具有4KB字节的页
的构造。上层内核组件将页看成是4个1024字节组成的块缓冲区
。块设备驱动程序正在传送页中的后三个块
,因此这3块被插入到涵盖了后3072字节的段
中。硬盘控制器将该段看成是由6个512字节的扇区
组成。
磁盘的每个I/O操作的实质是在磁盘与一些RAM单元之间相互传送一些相邻扇区的内容。
大多数情况下,磁盘控制器直接采用DMA方式进行数据传送。
老式的磁盘控制器仅仅支持“简单的”DMA传送方式:在这种传送方式中,磁盘必须与RAM中的连续内存单元相互传送数据。但是,新的磁盘控制器,也就是我们即将讲到的SCSI磁盘控制器,支持所谓的分散-聚集(scatter-gather)DMA
传送方式。此种方式中,磁盘可以与一些非连续的内存区相互传送数据。启动一次分散-聚集DMA传送,块设备驱动程序需要向磁盘控制器发送:
磁盘控制器则负责整个数据传送,例如:
段
的数据存储元。段
就是一个内存页或内存页中的一部分,它们包含一些相邻磁盘扇区中的数据。注意,块设备驱动程序不需要知道块、块大小以及块缓冲区。因此,即使高层将段看成是由几个块缓冲区组成的页,块设备驱动程序也不用对此给予关注。
如果,不同的段在RAM中相应的页框正好是连续的并且在磁盘上相应的数据块也是相邻的,那么通用块层可以合并它们。通过这种合并方式产生的更大的内存区就称为物理段
。然而,在多种体系结构上还允许使用另一个合并方式:通过使用一个专门的总线电路来处理总线地址与物理地址间的映射。通过这种合并方式产生的内存区称为硬件段
。由于我们将注意力集中在80 x 86体系结构上,它在总线地址和物理地址之间不存在动态的映射,因此在本章剩余部分我们假定硬件段总是对应物理段。
当一个块被调入内存时(也就是,在读入后或者等待写出的时候),它要存储在一个缓冲区中。每个缓冲区和一个块对应,它相当于磁盘块在内存中的表示。一个块小于一个页,所以一页可以容纳一个或多个内存中的块。
由于内核在处理数据时需要一些相关的控制信息,每个缓冲区都有一个对应的描述符。该描述符buffer_head结构体
表示,被称作缓冲区头
,它包含了内核操作缓冲区所需的全部信息:
buffer_head结构体在include/linux/buffer_head.h中定义:
1 | /* |
说明:
b_count域
表示缓冲区的使用记数,可通过两个定义在文件include/linux/buffer_head.h中的内联函数对此域进行增减。
1 | static inline void get_bh(struct buffer_head *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中定义
1 | enum bh_state_bits { |
说明:
每个块缓冲区都对应一个块缓冲区头buffer_head,二者的关系类似于物理页框和物理页框描述符,前者用来存储数据,后者是对前者的属性以及控制信息的描述。
块缓冲区头、块缓冲区以及页框的关系如下:
总结一下:
通用块层是一个内核组件,它处理来自系统中的所有对块设备发出的请求。由于该层所提供的函数,内核可以容易的做到:
“零-复制”模式
,将磁盘数据直接存放在用户态地址空间而不是首先复制到内核内存区;深入理解linux
通用块层的核心数据结构是一个称为bio的描述符,它描述了块设备的I/O操作。目前内核中块I/O操作的基本容器由bio结构体表示。在更上层的具体的文件系统的读写操作方法中,构造bio结构,并通过通用块层来提交各块设备驱动程序来进行实际的数据传输。
它定义在文件include/linux/blk_types.h中:
段(segment)链表
形式组织的块I/O操作。一个段是一小块连续的内存缓冲区。这样,单个缓冲区就不一定要连续。所以使用段来描述缓冲区,即使一个缓冲区分散在内存的多个位置上,bio结构体也能对内核保证I/O操作的执行。这样的向量I/O称为分散-聚合I/O
。磁盘存储区表示符
(存储区中的起始扇区号和扇区数目)和一个或多个描述与I/O操作相关的内存区的段。bio结构定义如下:
1 | /* struct bio, bio_vec and BIO_* flags are defined in blk_types.h */ |
说明:
bio_vec结构体
中。bio中的每一个段是由bio_vec结构体描述的bi_io_vec
、bi_vcnt
和bi_idx
。下图显示了bio结构体及相关结构体之间的关系:说明:
当通用块层启动一次新的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,则从这些缓冲区中分配内存。
bi_io_vec
域指向一个bio_vec
结构体数组,该结构体链表包含一个特定I/O操作所需要使用的所有片段。
<page, offset, len>的向量
,它描述的是一个特定的片段。段所在的物理页、块在物理页中的偏移位置、从给定偏移量开始的块长度。整个bio_vec结构体数组表示一个完整的缓冲区。bio_vec结构定义在include/linux/blk_types.h
1 | /* |
总而言之,每一个块IO请求都通过一个bio结构体表示。
bi_cnt域记录bio结构体的使用计数,如果为0就销毁该结构体,并释放内存。通过下面的函数管理使用计数:
1 | /** |
1 | /* |
缓冲区头和新的bio结构体之间存在显著差别:
利用bio代替buffer_head好处有:
但是还是需要缓冲区头这个概念,毕竟它还负责描述磁盘块到页面的映射。
bio结构体不包含任何和缓冲区相关的状态信息——它仅仅是一个矢量数组,描述一个或多个单独块I/O操作的数据片段和相关信息。
在当前设置中,当bio结构体描述当前正在使用的I/O操作时,buffer_head结构体仍然需要包含缓冲区信息。
内核通过这两种结构分别保存各自的信息,可以保证每种结构所含的信息量尽可能少。
块设备将它们挂起的块I/O请求保存在请求队列中,该队列由request_queue结构体
表示,定义在文件linux/blkdev.h中,包含一个双向请求链表以及相关控制信息。
1 | struct request_queue { |
通过内核中像文件系统这样的高层的代码将请求加入到队列中。请求队列只要不为空,队列对应的块设备驱动程序就会从队列头获取请求,然后将其送入对应的块设备上去。
request结构体
表示。如果简单地以内核产生的请求的次序直接将请求发向块设备的话,性能会很差。磁盘寻址是整个计算机中最慢的操作之一,每次寻址(定位磁盘磁头到特定块上的某个位置)需要花费不少时间,所以尽量缩短寻址时间无疑提高系统性能。
为了优化寻址操作,内核既不会简单地按请求接受次序,也不会立即将其提交给磁盘。相反,它会在提交前,先执行名为合并排序
的预操作,这种预操作可以极大地提高系统的整体性能。
在内核中负责提交I/O请求的子系统
称为I/O调度程序
:
请求合并和排序
来完成的。进程调度程序与I/O调度程序的共同点与区别:
I/O调度程序的工作是管理块设备的请求队列。
I/O调度程序通过两种方法减少磁盘寻址时间:合并与排序。
字符设备的实现比较简单,内核例程和用户态API一一对应,这种映射关系由字符设备的file_operations维护。块设备接口则相对复杂,读写API没有直接到块设备层,而是直接到文件系统层,然后再由文件系统层发起读写请求。
对于块设备来说,读写操作是以数据块为单位进行的,为了使高速的 CPU 同低速块设备能够协调工作,提高读写效率,操作系统设置了缓冲机制。当进行读写的时候,首先对缓冲区读写,只有缓冲区中没有需要读的数据或是需要写的数据没有地方写时,才真正地启动设备控制器去控制设备本身进行数据交换,而对于设备本身的数据交换同样也是同缓冲区打交道。
块设备驱动中的第1个工作通常是注册它们自己到内核,申请设备号,完成这个任务的函数是register_blkdev()
,代码在/block/genhd.c
1 | /** |
参数说明:
/proc/devices
中。如果major为0,内核会自动分配一个新的主设备号,register_blkdev()函数的返回值就是这个主设备号。/proc/devices
是一个文件,这个文件列出字符和块设备的主设备号,以及分配到这些设备号的设备名称。示例如下:1 | [root@master ~] |
1 | static struct blk_major_name { |
说明:
unregister_blkdev()
代码在/block/genhd.c
1 | void unregister_blkdev(unsigned int major, const char *name) |
说明:传递给register_blkdev()的参数必须与传递给unregister_blkdev()的参数匹配,否则这个函数返回-EINVAL。
两个函数的声明代码在include/linux/fs.h
1 | extern int register_blkdev(unsigned int, const char *); |
每种具体的块设备都有一套具体的操作,因而各自有一个类似于file_operations 那样的数据结构,称为block_device_operations 结构。它是对块设备操作的集合,其定义为,代码在include/linux/blkdev.h:
1 | struct block_device_operations { |
如果说file_operation 结构是连接虚拟的VFS 文件的操作与具体文件系统的文件操作之间的枢纽,那么block_device_operations就是连接抽象的块设备操作与具体块设备操作之间的枢纽。
具体的块设备是由主设备号唯一确定的,因此,主设备号唯一地确定了一个具体的block_device_operations 数据结构
。
那么,块设备注册到系统以后,怎样与文件系统联系起来呢,也就是说,文件系统怎么调用已注册的块设备,这还得从file_operations 结构说起。
先来看一下块设备的file_operations 结构的定义,变量名为def_blk_fops
,其位于fs/block_dev.c中:
1 | const struct file_operations def_blk_fops = { |
以open()系统调用为例,说明用户进程中的一个系统调用如何最终与物理块设备的操作联系起来。在此,我们仅仅给出几个open()
函数的调用关系,如图所示。
这就简单地说明了块设备注册以后,从最上层的系统调用到具体地打开一个设备的过程。
块设备驱动程序通常分为两部分,即高级驱动程序和低级驱动程序,前者处理VFS 层,后者处理硬件设备
假设进程对一个设备文件发出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 设备文件的访问请求转换成对相应硬件设备的块请求。
getblk()函数
来检查缓冲区中是否已经预取了块,还是从上次访问以来缓冲区一直都没有改变。ll_rw_block()
继续从磁盘中读取这个块,后面这个函数激活操纵设备控制器的低级驱动程序,以执行对块设备所请求的操作。在VFS 直接访问某一块设备上的特定块时,也会触发缓冲区I/O 操作。
特定块的直接访问
是由bread()和breada()函数来执行的,这两个函数又会调用前面提到过的getblk()和ll_rw_block()函数。由于块设备速度很慢,因此缓冲区I/O 数据传送通常都是异步处理的:
对于块设备来说,读写操作是以数据块为单位进行的,为了使高速的 CPU 同低速块设备能够协调工作,提高读写效率,操作系统设置了缓冲机制。
块设备读和写当进行读写的时候,首先对缓冲区读写,只有缓冲区中没有需要读的数据或是需要写的数据没有地方写时,才真正地启动设备控制器去控制设备本身进行数据交换,而对于设备本身的数据交换同样也是同缓冲区打交道。
在PC 体系结构中,允许块的大小为512、1024、2048 和4096 字节。同一个块设备驱动程序可以作用于多个块大小,因为它必须处理共享**同一主设备号的一组设备文件,而每个块设备文件都有自己预定义的块大小。
例如,一个块设备驱动程序可能会处理有两个分区的硬盘,一个分区包含Ext2 文件系统,另一个分区包含交换分区。
内核在一个名为blksize_size
的表中存放块的大小;
表中每个元素的索引就是相应块设备文件的主设备号和从设备号。如果blksize_size[M]为NULL,那么共享主设备号M的所有块设备都使用标准的块大小,即1024 字节。
每个块都需要自己的缓冲区,它是内核用来存放块内容的RAM内存区
。当设备驱动程序从磁盘读出一个块时,就用从硬件设备中所获得的值来填充相应的缓冲区;同样,当设备驱动程序向磁盘中写入一个块时,就用相关缓冲区的实际值来更新硬件设备上相应的一组相邻字节。
缓冲区的大小一定要与块的大小相匹配。
虽然块设备驱动程序可以一次传送一个单独的数据块,但是内核并不会为磁盘上每个被访问的数据块都单独执行一次I/O 操作,取而代之的是,只要可能,内核就试图把几个块合并在一起,并作为一个整体来处理,这样就减少了磁头的平均移动时间。
当进程、VFS 层或者任何其他的内核部分要读写一个磁盘块时,就真正引起一个块设备请求。
延迟请求复杂化了块设备的处理。
每个块设备驱动程序都维护自己的请求队列;每个物理块设备都应该有一个请求队列,以提高磁盘性能的方式对请求进行排序。
因此策略程序就可以顺序扫描这种队列,并以最少地移动磁头而为所有的请求提供服务。
由于块设备驱动程序的绝大部分都与设备无关的,故内核开发者通过把大部分相同的代码放在一个头文件block/blk.h中来简化驱动程序的代码。从而每个块设备驱动程序都必须 包含这个头文件。
《深入分析Linux内核源代码》 //书籍内核版本有些旧
《Linux内核设计与实现》 //目前感觉这个版本是最准的
Linux 通用块层 bio 详解
块设备注册 register_blkdev | 学步园
Update your browser to view this website correctly. Update my browser now