本文主要讨论Block IO子系统核心类。下图是Block子系统软件栈示意图,和很多子系统(驱动尤甚)一样,Block由一个承上启下的core层和分布于其周围的诸多模块组成,通常来讲,Block子系统可以被分为3层:Generic Block Layer、Block core、Block Device Driver。
Generic Block Layer主要是提供对设备(gendisk)的抽象,在提交一个请求到Core之前,显然要知道这个请求是提交给谁的,这个谁可以通过文件的inode获取,是构造请求(bio)的必需步骤。一个Block Device在注册到内核的时候需要注册相应的gendisk到Generic Block Layer
Block Core对上要接收上层提交的bio,通过plug机制以及IO调度算法,对请求进行合并和排序,再派发到底层驱动
Block Device Driver主要是一些块设备驱动。
此外,IO路径的所有逻辑都是为IO服务的,而bio和request作为承载IO请求的直接类,request_queue作为各个层操作IO请求的入口,它们的重要性不言而喻,对于这几个家伙,有很多种形象的比喻,如果每一层IO路径上的每一层逻辑是一个工位,那么它们就是流水线;如果每一层逻辑是一个山楂球,它们就是冰糖葫芦杆…
gendisk
通用块层提供了对设备请求的抽象,系统中的任何BlockDevice在注册的时候都要分配、初始化、注册一个gendisk对象及其必要数据结构到内核。该层主要由以下的对象组成gendisk、hd_struct,其中gendisk是整个通用块层的核心类,围绕着gendisk,通用块层的主要对象的关系如下图所示:
gendisk对象作为对块设备的抽象,用于描述整块磁盘、整个MMC芯片等,这种设计可以很好的体现OOP中的”多态”,gendisk本身封装了块设备的”共性”部分,而对于”个性”的部分,使用Kernel中的常见伎俩——private_data来解决。
对于共性的部分,块设备常用的”分区”的管理对象hd_struct和disk_part_tbl必然封装在gendisk中。具体地,在gendisk眼中,如果一个块设备没有分区,那么就只有一个0号分区,该分区由gendisk直接内嵌的hd_struct描述,如果一个块设备有了分区,那么就用disk_part_tbl描述其分区表,该分区表的第一项仍旧是gendisk的0号分区。gendisk通过0号分区hd_struct的device接入sysfs,同理,非0号分区也通过相应的hd_struct接入sysfs
对于个性的部分,对于scsi磁盘,private_data指向scsi_disk->scsi_driver(driver/scsi/sd.c +2904),对于RAID,指向struct mddev(drivers/md/md.c +4850),对于Device Mapper,private_data指向struct mapped_device(drivers/md/dm.c +2194)。此外,fops同样是”多态”,视情况被赋值为sd_fops(drivers/scsi/sd.c +2903)、lsi_fops(drivers/scsi/megaraid_mm.c +85)等
关于gendisk域的更详细解释,参见下表。
type | field | description |
---|---|---|
int | major | 块设备的主设备号 |
int | first_minor | 块设备的第一个次设备号,即第一个分区的次设备号 |
int | minors | 块设备的最大次设备号,即设备支持的分区数量 |
char[] | disk_name | 块设备名,即sd*,md* |
char*(*)(struct gendisk gd,umode_t mode) | devnode | 获取块设备名的回调接口 XXX |
unsigned int | events | 块设备支持的事件,块设备通常在/sys/sd*下注册events,events_async属性,读这两个属性时,会调用disk_events_show()将gendisk的这两个域返回 |
struct disk_part_tbl* | part_tbl | 分区表指针,封装了一组hd_struct对象 |
struct hd_struct | part0 | 0号分区,即不分区的块设备被看作一个大分区,由这个域表示。gendisk就是通过hd_struct->device来链入sysfs的 |
const struct block_device_operations* | fops | “多态”,视情况被赋值为sd_fops(drivers/scsi/sd.c +2903)、lsi_fops(drivers/scsi/megaraid_mm.c +85)等 |
struct request_queue * | queue | 块设备的请求队列 |
void * | private_data | “多态”,对于scsi磁盘,指向scsi_disk->scsi_driver(driver/scsi/sd.c +2904),对于RAID,指向struct mddev(drivers/md/md.c +4850),对于Device Mapper,指向struct mapped_device(drivers/md/dm.c +2194) |
int | flag | 块设备flag,取值为genhd.h中的GENHD_FL_REMOVABLE 等 |
atomic_t | sync_io | 写入磁盘的扇区计数器,仅用于RAID |
block_device
block_device表面上Block子系统有很强的关联,但其实,定义在fs.h中的它却是bdev伪文件系统的重要组成部分,向上与inode,super_block,filesystem_type等文件系统关联,向下与Block子系统的gendisk,hd_struct以及request_queue关联。一个块设备的每一个分区都能在/dev下看到一个设备文件,主要归功于每一个设备和分区都有一个block_device对象及其关联的inode。
此外,还有一个小特点在上图中没有体现,就是block_device的struct block_device* bd_contains
域,这个域对于代表非0号分区的block_disk来说,要指向代表0号分区的block_device;而对于代表0号分区的block_device来说,则指向本身,所以,bd_contains可以此作为判断读写的是一个分区还是一个块设备的依据。
bio
bio即Block IO。是进行IO的基本单位。我们知道,IO的目的即将数据从易失性存储设备(DRAM/SDRAM)复制到非易失性存储设备(HDD/SSD/MMC),人们还在IO路径中设计了层层cache并使用DMA等技术来提高IO效率,可以说,只要NVDIMM这类技术单位容量还很贵,这套复杂的设计还会存在很久,但无论怎么花哨,都摆脱不了”一切IO都是内存到磁盘的映射”这个本质。而,bio就是这个映射的描述。
作为Block子系统的一个重要组成,但其实bio并不是在Block层构造。而是诞生于FS层和Block层之间薄薄的一层:Page IO层,即就是内存管理中和IO有关的软件层,通常所说的dirty page的落盘即可看作这一层主要工作,而bio,作为对需要下发的数据的描述,正是诞生于此。
bi_size是还没传输的字节数
BIO既然要描述一段内存地址到一段设备地址的映射,那一定要有相关的成员描述这两部分空间。
每个bio_vec对象都管理一段连续内存,这一段内存称为”segment”,不能免俗,一个bio_vec通过三个域即可描述一块内存:bv_page指向内存页,bv_offset表示页内偏移量和bv_len表示内存区间长度。有了bio_vec的帮助,bio就可以通过管理这组bio_vec对象来管理一组segment。既然bio可以描述分散在多个内存页的内存区间,那是不是一个bio就可以表示n多的IO请求呢?当然不是,我们说:一个IO请求,是按照其在块设备上的连续性来划分的,所以,bio作为”潜在的“IO请求,可不能越雷池半步,所以,这些处于一个bio内、在内存端看似分散的IO,都有一个共同的特点:其位于块设备端的地址都是连续的”,即Scatter-Gather。
和对内存的表示类似,一个bio对象通过内嵌的bvec_iter对象的bi_sector和bi_size描述一段连续的块设备存储空间。
构成一个bio除了描述映射两端的地址区间,还要知道是哪个设备,系统中的所有内存都可以用一个地址空间描述,但是众多块设备可不行,所以构造一个bio的时候,还要指明目的块设备是哪个,这点其实不难办到,因为早在IO请求下发的时候,就已经通过inode找到了block_device对象,而block_device又维护了inode到gendisk的映射,所以,bio中只要包含相应的block_device对象,就不愁找不到目标gendisk并进一步找到这个gendisk的request_queue,这个角色由bio中struct block_device* bi_dev
来担任。bio需要找到目标块设备,还有另一个考虑:调度的需求。如果仅仅考虑目前系统中没有为所有的块设备进行统一编址,那完全可以添加抽象层将所有的设备进行统一编址,这样bio不就可以只维护两个地址空间的简单映射,具体到哪个块设备中去,由这层逻辑来解决,就像VFS一样,岂不更好?事实是,由于块设备的形态多样,需求不同,内核必须提供为每个块设备单独提供IO调度服务的能力,而IO调度若要发挥作用,必须早知道IO的去向,越早越好,所以bio中才封装了找到目标块设备的能力。
request, request_queue
前文说,bio是”潜在的“IO请求,即bio本身还不一定构成一个请求,如果一个bio描述了针对一段连续的块设备地址的IO,那么如果在这个bio下发到低层之前,又来了一个描述了一段紧邻这块设备地址的IO的bio呢?在块设备中,任何一个IO请求最终都会变成一个SCSI命令下发到存储设备,而这样的两个目标块设备地址段相邻的bio,是可以用一个SCSI命令描述的,即合并。通过合并,多个bio可以合并为更少的SCSI命令,可以提高IO性能,合并的结果,就是生成了IO请求——request。合并,简单到轮不到IO调度层的插手。
经过排序和合并构造的request,继续向下传递,首先是经过IO调度层的void *elevator_data
,这个成员在不同的IO调度器中实现不同(多态),但目的都是一样的,让request最终被派发到底层设备之前经过更多(往往也更复杂)的合并、排序等优化,进而提升性能,待IO Scheduler的任务完成了,request最终被放到了request_queue表示的派发队列中,每个块设备只有一个request_queue,所以这个派发队列中request的次序,就是最终派发到块设备的IO request次序。