文章目录
通用块设备层
I/O体系结构
与外设的通信通常称为输入输出,缩写为I/O。在实现外设的I/O时,内核需要处理好3个问题:
- 根据具体的设备型号和模型,使用各种方法对硬件寻址
- 内核必须向用户应用程序和系统工具提供访问各种设备的方法,应该采用统一的方案
- 用户空间需要知道内核中有哪些设备可用
与外设的通信是层次化的,如下图
在层次系统的底部是设备本身,它通过总线系统连接到其它设备和系统CPU,设备与内核的通信经由该路径进行
硬件设备可以以多种方式连接到系统,主板上的扩展槽和外部连接器是最常用的方法
-
总线系统
外设不直接连接到CPU,它们通过总线连接起来,总线负责设备与CPU之间以及各个设备之间的通信。下列是一些代表性的总线:
- PCI:许多体系结构使用的主要系统总线,PCI设备插入到系统主板的扩展槽中
- ISA:一种比较古老的总线
- IEEE1394,:也成为了FireWire,是高端电脑中非常流行的一种外部总线
- USB:广泛应用的外部总线
- SCSI:过去称为专业人员的总线,主要用在服务器系统上寻址硬盘
- 并口和串口:非常简单且速率很低,用于外部连接
系统一般是将一些总线组合在一起,当前的PC设计通常包括两个通过桥接器互连的PCI总线,一些总线(如USB和FireWire)无法作为主总线,需要经由另一个系统总线将数据传递到处理器。如下图是系统中不同总线的连接方式
-
与外设的交互
与外设通信的方法如下
- I/O端口:内核发送数据到I/O控制器,数据的目标设备通过唯一的端口号标识,数据被传输到设备进行处理。处理器管理了一个独立的虚拟地址空间,可用于管理所有I/O地址
- I/O内存映射:现代处理器提供了对I/O端口进行内存映射的选项,将特定外设的端口地址映射到普通内存中,可以像操作普通内存那样操作外设,图形卡通常会使用这类操作
- 轮询和中断:通过轮询或者中断来感知到某个设备的数据是否就绪
-
通过总线控制设备
总线类型分为系统和扩展总线
就系统总线而言(例如PCI总线),可以使用I/O语句和内存映射与总线本身和附接的设备通信,而扩展总线(如USE、SCSI等),通过明确定义的总线协议和附接的设备交换数据和命令
访问设备
设备文件用于访问扩展设备,它们不关联到硬盘或任何其他存储介质上的数据段,而是建立了与某个设备驱动程序的连接,以支持与扩展设备的通信
设备文件
设备不是通过文件名标识,而是通过文件的主、从设备号来标识
字符设备、块设备与其它设备
有些设备因为数据传输量低,适合于面向字符的数据交换,其它设备则更适合于处理包含固定数目字节的数据块,内核会区分字符设备和块设备,前一类包括串行接口和文本终端,后一类则包括硬盘、光驱等设备
- 标识设备文件
- 访问权限之前的字母是b或c,分别代表块设备和字符设备
- 设备文件没有文件长度,而是增加了另外两个值,分别是主设备号和从设备号,二者共同形成唯一的号码,内核可用于查找对应的设备驱动程序
主设备号用于寻址设备驱动程序本身,驱动程序管理的各个设备(如第1个和第2个硬盘)则通过不同的从设备号指定,如下所示连续的从设备号用于标识各个分区,硬盘的各个分区可以通过设备文件进行寻址(如/dev/vda、/dev/vda1),而/dev/vda*则代表整个硬盘。
系统可能包含几个同样类型的设备,由同一个设备驱动程序管理(避免多次将同样的代码加载到内核),其次可以将同类设备合并起来,便于插入到内核的数据结构中进行管理,一个驱动程序可以分配多个主设备号
注意:块设备和字符设备的主设备号可能是相同的,除非同时指定设备号和设备类型,否则找到的驱动程序可能不是唯一的
-
动态创建设备文件
/dev的设备结点一般是基于磁盘的文件系统静态创建的,随着支持的设备越来越多,几乎所有的Linux设备版都将/dev内容的管理工作切换到udevd,这是一个守护进程,允许从用户层动态创建设备文件
每当内核检测到一个设备时,都会创建一个内核对象kobject,该对象借助sysfs导出到用户层,同时内核向用户空间发送一个热插拔消息,热插拔消息包含了驱动程序为设备分配的主从设备号,udevd守护进程的工作就是监听这些消息。在注册新设备时,会在/dev中创建对应的项
由于引入了udev机制,/dev不再放置到基于磁盘的文件系统中,而是使用tmpfs(内存文件系统),非持久性
使用ioctl进行设备寻址
ioctl可以用于对设备I/O通道进行管理,设置设备的配置选项,它基于ioctl系统调用实现,由内核的sys_ioctl处理
网卡:网卡比较特殊,它没有设备文件,用户必须使用套接字和网卡通信,套接字是一个抽象层,对所有网卡提供一个抽象视图
设备数据库
内核中用于跟踪所有可用设备的数据库是相同的,由于字符设备和块设备都通过唯一的设备号标识
- 每个字符设备表示为struct cdev的实例
- struct genhd用于管理块设备的分区(作用类似于字符设备的cdev)
如下图是内核跟踪所有cdev和genhd实例的方式,有两个全局数组(bdev_map和cdev_map,都是kobj_map结构的实例)用来实现散列表,使用主设备号作为散列键,散列方式是:major % 255(由于很少有设备的主设备号大于255,因此哈希碰撞很少)
互斥量lock实现了对散列表访问的串行化,struct probe代表散列链表元素,其成员如下:
- next:将所有散列元素连接到一个单链表
- dev:表示设备号(dev_t类型,包含了主设备号和从设备号)
- range:存放了从设备号的连续范围
- owner:指向提供设备驱动程序的模块
- get:指向一个函数,可以返回与设备关联的kobject实例
- data:用于区分块设备和字符设备,对于字符设备,它指向struct cdev的一个实例,而对于块设备,它指向struct genhd的一个实例
与文件系统关联
除极少数例外,设备文件都是由标准函数处理,类似于普通文件,都是通过虚拟文件系统来管理
inode中的设备文件成员
虚拟文件系统的每个文件都关联到一个inode,用于管理文件的属性,inode结构中与设备驱动程序有关的成员如下
- i_rdev:存放了主从设备号
- i_mode:存储了文件类型(面向块或者字符)
- i_fop:一组函数指针的集合,其中包含很多文件操作,由虚拟文件系统使用来处理设备
- i_bdev和i_cdev:内核根据inode表示块设备还是字符设备,来指向更多具体的信息
标准文件操作
在打开一个设备文件时,各种文件系统的实现会调用init_special_inode函数,为块设备或字符设备创建一个inode
用于字符设备的标准操作
字符设备只有一个文件操作可用
由于字符设备彼此不同,每个设备文件都需要一组独立、自定义的操作,chrdev_open的主要任务是向该结构填入适用于已打开设备的函数指针
用于块设备的标准操作
相比字符设备,块设备的方案更加一致,这些操作的指针集中在blk_fops的结构中
读写操作由通用的内核例程进行,内核中的缓存自动用于块设备
注意:file_operations和block_device_operations的结构类似,但前者是由VFS和用户空间通信,其中的例程会调用block_device_operations中的函数,以实现与块设备的通信,block_device_operations必须针对各种块设备分别实现,它对设备的属性进行抽象,而在此之上建立的file_operations实现了所有文件的抽象,使用同样的接口就可以操作绝大多数设备文件(除网卡等少数设备外)和普通文件
上述数据结构不能完全描述块设备,因为对块设备的访问不是分别处理单个的请求,而是通过缓存和请求队列构成的精细、复杂的系统来高效地管理。其中,缓存主要由通用的内核代码操作,而请求队列由块设备层管理
块设备操作
块设备和字符设备主要有以下3点不同:
- 可以在数据的任何位置进行访问
- 数据总是以固定长度的块进行传输
- 对块设备的访问有大规模的缓存,已经读取的数据保存在内存中,写入操作也使用了缓存,以便延迟处理
块和扇区
块:是一个特定长度的字节序列,用于保存在内核和设备之间传输的数据
扇区:固定的硬件单位,指定某个设备最少能够传输的数据量
块是连续扇区的序列,块长度总是扇区长度的整数倍
块设备层不仅负责寻址块设备,也负责预读算法等其他任务(如果预读的数据不是立即需要,那么块设备层必须提供缓冲区/缓存来保存这些数据)
块设备的表示
内核使用请求队列管理来处理块设备的请求,它能够缓存并重排读写数据块的请求
块设备层的各个成员如下图所示
裸块设备由struct block_device表示,内核将与块设备关联的block_device实例紧邻块设备的inode之前存储,由以下数据结构实现
所有表示块设备的inode都保存在伪文件系统bdev中,这使得可以使用标准的VFS函数,来处理块设备inode的集合
辅助函数bget来完成,给定dev_t表示的设备号,该函数查找文件系统,看对应的inode是否存在,如果存在,返回inode的指针,在通过bdev_inode结构找到inode对应的block_device实例;如果此前设备没有打开过,致使inode尚未存在,bdget和伪文件系统会确保自动分配一个新的bdev_inode并进行适当地设置
块设备层提供了丰富的队列功能,每个设备都关联了请求队列,如上图,每个数组项中都包含了指向各种结构和函数的指针,其中最重要的成员如下:
- 一个等待队列:保存对设备的读写请求
- 函数指针:指向I/O调度器实现,用来重排请求的函数
- 特征数据:如扇区、块长度和设备容量
- 通用硬盘抽象genhd,对每个设备可用,其中存储了分区数据及指向底层操作的指针
对块设备的读写请求不会立即执行对应的操作,而是汇总起来,经过协同之后传输到设备。因此,对应块设备文件的file_operations结构中没有保存用于执行读写操作的具体函数,而是包含了通用函数,如generic_read_file和generic_write_file,这些函数都是特定于驱动程序
数据结构
-
块设备
块设备的核心由struct block_device表示
- 块设备的设备号保存在bd_dev
- bd_inode指向bdev伪文件系统中表示该块设备的inode
- bd_inodes是一个链表的表头,该链表包含了表示该块设备的设备特殊文件的所有inode
- bd_part指向数据结构struct hd_strcut,表示包含在块设备上的分区
- bd_disk提供另一个抽象层,用来划分硬盘
- bd_list是一个链表元素,用于跟踪记录系统中所有可用的block_device实例
- bd_private可用于在block_device实例中存储特定于持有者的数据
关于内核的哪个部分允许持有块设备,没有固定的规则。在ext3文件系统中,会持有已装载文件系统的外部日志的块设备,并将超级块注册为持有者;如果某个分区用作交换区,在使用swapon系统**该分区之后,页交换代码将持有该分区;在使用blkdev_open打开块设备并请求独占使用时,与该设备文件关联的file实例会持有该设备
注意:block_device结构对于整个块设备和分区都会有对应的实例,他们之间通过指针会建立一些关联关系
-
通用硬盘和分区
内核使用如下数据结构,对已经分区的硬盘提供表示
- major:指定设备驱动程序的主设备号,first_minor和minors表明从设备号的可能范围
- disk_name:磁盘名称,用于在sysfs和/proc/partitions中表示该磁盘
- part是一个数组,由指向hd_struct的指针组成,每个磁盘分区对应一个数组项
- fops是一个指针,指向特定于设备、执行各种底层任务的各个函数
- queue用于管理请求队列
- kobj是一个kobject实例
对每个分区来说,都有一个hd_struct实例,用于描述分区在设备中的键
其中,start_sect和nr_sects定义了该分区在块设备上的起始扇区和长度
注意:struct gendisk实例不能由驱动程序分别分配,而是必须使用辅助函数alloc_disk函数
给出设备的从设备号,调用该函数可自动分配gendisk实例
alloc_disk将新的磁盘集成到设备模型的数据结构中,del_gendisk用于在销毁时释放gendisk实例
-
各个部分之间的联系
各个数据结构(block_device、gendisk和hd_struct)之间的关系如下图
对块设备上每个打开的分区,都对应一个struct block_device实例,对应于分区的block_device通过bd_contains关联到对应于整个块设备的block_device,所有的block_device都通过bd_disk指向对应的通用磁盘数据结构的gendisk(尽管一个分区的磁盘对应多个block_device实例,但只对应一个gendisk实例)
gendisk实例中的part成员指向hd_struct指针的数组,每个数组项表示一个分区,表示分区的block_device实例包含一个指针指向hd_struct实例,它在struct gendisk和block_device之间是共享的
通用硬盘结构gendisk集成到了kobject框架中,如图所示
块设备子系统由kset实例block_subsystem表示,kset中有一个链表,每个gendisk实例所包含的kobject实例都放置到该链表下
由struct hd_struct表示的分区对象也包含一个嵌入的kobject,由于分区是硬盘的子元素,hd_struct中kobject的parent指针指向gendisk结构的kobject
-
块设备操作
特定于块设备的操作汇总如下图
-
请求队列
块设备的读写请求都放置在一个队列上,称为请求队列。gendisk结构包含一个指针,指向特定于设备的队列,部分成员如下图
-
queue_head是其中的主要成员,是一个表头,用于构建I/O请求的双链表,链表每个元素是request类型,代表向块设备读取数据的一个请求。内核会重排该链表以获得更好的I/O性能(由于有几种方法可以重排请求,elevator成员以函数指针的形式将所需的函数群集起来)
-
rq用作request实例的缓存,数据类型为struct request_list,同时这个结构提供了两个计数器,用于记录可用的空闲输入和输出请求的数目
-
结构的下一部分主要包含一系列函数指针,表示请求处理所涉及的主要领域
内核提供了这些函数的标准实现,可用于大多数驱动程序,同时每个驱动程序可以实现自身的request_fn函数,该函数是请求队列管理与各个设备的底层功能之间的主要关联,在内核处理当前队列执行读写操作时,会调用该函数
前4个函数负责管理请求队列
- request_fn:用于向队列添加新请求的标准接口,称为策略例程
- make_request_fn:创建新请求
- prep_rq_fn:是一个请求预备函数
- prepare_flush_fn:在预备刷出队列时调用,即一次性执行所有待处理请求的时候,会对设备进行必要的清理
- 对于大的请求,完成所有I/O可能很耗时,后续的内核版本添加了软中断,可以异步完成请求,通过调用blk_complete_request要求异步完成请求,softirq_done_fn用作回调函数,通知驱动程序已经完成
向系统添加磁盘和分区
函数add_disk用于向系统添加通用硬盘,add_partition用于添加分区
-
添加分区
add_partition负责向通用硬盘数据结构添加一个新的分区,如下图是一个简化版,首先会分配一个新的struct hd_struct实例,并填充该分区的一些信息
在指定一个名字之后,将分区的内核对象设置为通用硬盘对象,ktype设置为ktype_part
之后,用kobject_add添加新对象,使之成为块设备子系统的一个成员。最后,修改通用硬盘对象,使对应的part数组项指向新的分区
-
添加磁盘
add_disk代码流程图如下,分为三个阶段
首先,调用blk_register_region,确认所要求的设备号范围尚未分配,接下来在redister_disk中调用bget_disk获取该设备的一个新的device_block实例
打开块设备文件
在应用程序打开一个块设备文件时,虚拟文件系统将调用file_operations结构的open函数,最终会调用blkdev_open,如下图为相关的代码流程图
bd_acquire首先找到与该设备匹配的block_device实例
- 如果该设备已经使用过,指向该实例的指针从inode->i_bdev得到
- 否则,使用dev_t信息创建实例,然后do_open将执行主要工作
- 最后,检查是否设置了O_EXCL标志来请求独占访问,如果是,则调用bd_claim要求持有该块设备,此时会将设备文件关联的file实例设置为块设备的当前持有者
do_open的代码流程如下图
-
第一步会调用get_gendisk函数,返回属于块设备的gendisk实例
-
如果块设备之前打开过
- disk->fops->open调用文件适当的open函数,执行特定于硬件的初始化任务
- 如果block_device->bd_invalidated表名分区信息无效,则调用rescan_partitions重新读取分区信息,
-
如果设备此前没有打开过,再判断是否为分区
-
如果打开的是主块设备,不是分区,需要调用disk->fops->open处理打开设备的底层工作,如果现存的分区信息无效则调用rescan_partitions读取分区表
-
如果打开的是分区,内核需要将分区的block_device实例与对应于整个块设备的blcok_device实例关联起来,关联的代码如下,whole是包含分区的整个磁盘的block_device实例,然后使用blcok_device->bd_contains建立分区及其容器之间的关联
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ekoYjTwq-1599274324822)(D:\Tech\学习笔记\Linux学习笔记\image-20200813090909431.png)]
-
请求结构
内核提供了数据结构来描述发送给块设备的请求
请求需要保存在请求队列上,队列使用双向链表实现,queuelist提供了所需的链表元素,q指向该请求所属的请求队列。在一个请求完成后,则将其放入完成链表donelist上
结构中的3个成员,指定了所需传输数据的准确位置
- sector:指定了数据传输的起始扇区
- current_nr_sector:代表当前请求在当前段中还需要传输的扇区数目
- nr_sectors:指定了当前请求还需要传输的扇区数目
两个私有成员elevator_private和elevator_private2,通过当前处理请求的I/O调度器设置
BIO用于在系统和设备之间传输数据
- bio:标识传输尚未完成的当前BIO实例
- biotail:指向最后一个BIO实例(一个请求中可使用多个BIO)
与请求关联的标志分为两个部分,cmd_flags包含了用于请求的一组通用标志,cmd_type表示请求的类型
最常见的请求类型是REQ_TYPE_FS:文件系统请求
BIO
如下图是BIO的结构
BIO的主要管理结构bio关联到一个数组,每个数组项指向一个内存页(对应于该页帧的page实例),这些页用于从设备接收数据、向设备发送数据
BIO的数据结构如下
-
bi_sector:指定传输开始的扇区号
-
bi_next:将与请求关联的几个BIO组织到单链表
-
bi_bdev:一个指针,指向请求所属设备的block_device数据结构
-
bi_phys_segments和bi_hw_segments指定了传输中段的数目,二者分别是由I/O MMU重新映射之前/之后的数值
-
bi_size:表示请求所涉及数据的长度(单位字节)
-
bi_io_vec:一个指向I/O数组的指针,bi_cnt指定了数组中数组项的数目,bi_idx代表当前处理的数组项索引。其数据结构如下图
bv_page指向用于数据传输的page实例,bv_offset表示该页内的偏移量(一般该值为0,因为页边界通常用作I/O操作的边界)
bv_len指定了用于数据的字节数目
-
bi_private:包含用于用于驱动程序相关的信息
-
bi_destructor:指向一个析构函数,在从内存删除一个bio实例之前调用
-
在硬件传输完成时,设备驱动程序必须调用bo_end_io,其中会在块设备层进行清理工作,或者唤醒该请求结束的睡眠进程
提交请求
本节主要讨论如何向设备提交物理请求来读取和写入数据
内核分两个步骤提交请求:
- 首先创建一个bio实例以描述请求,然后将实例嵌入到请求中,置于请求队列上
- 接下来内核处理请求队列并执行bio中的操作
在BIO创建之后,调用make_request_fn产生一个新请求以插入到请求队列中,请求通过request_fn提交
-
创建请求
submit_bio是一个关键函数,负责根据传递的bio创建一个新请求,并调用make_request_fn将请求置于驱动程序的请求队列上,如下图是相关的代码流程图
内核中各个地方都会调用该函数发起物理数据传输,submit_bio只是更新内核的统计量,实际的工作在generic_make_request中的__generic_make_request完成,其中的工作主要分3步进行:
- 调用bdev_get_queue,找到该请求所涉及块设备的请求队列
- 如果设备是分区的,调用blk_partition_remap重新映射该请求,确保读写正确的区域(分区的正确偏移量保存在队列关联的gendisk实例的part数组中)
- queue->make_request_fn根据bio产生请求并发送到设备驱动程序(对大多数设备,发送操作调用内核的标准函数__make_request完成)
接下来讨论make_request函数的默认实现__make_request,其代码流程图如下
在创建请求所需信息已经从传递的bio实例读取之后,内核调用elv_queue_empty检查I/O调度器队列是否为空,如果为空,则调用blk_pluge_device;否则,调用elv_merge合并请求,该函数进一步调用请求队列elevator成员的elevator_merge_fn函数(I/O调度器的实现部分),它会返回一个指针,指向请求链表中需要新插入的位置。I/O调度器还会指定请求是否以及如何与现存请求合并
- ELEVATOR_BACK_MERGE和ELEVATOR_FRONT_MERGE使新请求与请求链表中找到的请求合并,对于elv_merge返回的位置上的现存请求,ELEVATOR_BACK_MERGE将请求合并到现存数据之后,ELEVATOR_FRONT_MERGE合并到现存数据之前
- ELEVATOR_NO_MERGE说明该请求无法与队列上现存的请求合并,请求必须添加到请求队列上
在I/O调度之后,内核会产生一个新请求
- get_request_wait分配一个新请求实例,然后使用init_request_from_bio将bio的数据填入到请求实例中
- 然后add_request会调用__elv_add_request_pos更新一些内核统计量,然后将请求加入请求链表(会调用特定于I/O调度器的函数elevator_add_req_fn),插入位置由elv_merge返回的指针确定
- 如果请求将要同步处理或者队列长度达到了阈值,内核调用__generic_unplug_device拔出队列请求,确保请求可以处理
-
队列plugging
内核使用队列插入(queue plugging)机制,来阻止请求的处理,请求队列可能处于空闲状态或插入状态。如果处于空闲状态,队列中等待的请求将会被处理,否则,新的请求只是添加到队列,并不处理,此时request_queue结构的queue_flags成员中QUEUE_FLAG_PLUGGED标志置位,内核使用blk_queue_plugged辅助函数检查该标志
在插入模式下,内核使用blk_plug_device确保队列在未来某个时间被处理,如下所示,使用定时器mod_timer调用blk_unplug_timeout拔出队列
另外,如果当前队列当前的读写请求数达到unplug_thresh的阈值,则elv_insert调用__generic_unplug_device触发拔出操作,处理等待的请求
-
执行请求
在请求队列的请求即将被处理时,会调用特定于设备的request_fn函数,该函数与硬件的关联很紧密,内核不会提供默认的实现,内核会使用blk_dev_init注册队列时传递的方法
sample_request是一个硬件无关的例程,说明了所有驱动程序在request_fn中执行的基本步骤
在一个while循环中调用elv_next_request,用于从队列顺序读取请求,传输通过perform_sample_transfer执行,end_request用于从请求队列删除请求,并更新内核统计量
在真正的驱动程序中,特定于硬件的操作会分离到独立的函数中,如下所示,需要特定于硬件的函数来代替perform_sample_transfer中的注释部分