欢迎光临散文网 会员登陆 & 注册

深度剖析Linux块设备IO子系统(一)_驱动模型(秒懂)

2022-04-01 17:41 作者:补给站Linux内核  | 我要投稿
  • 块设备是Linux三大设备之一,其驱动模型主要针对磁盘,Flash等存储类设备,块设备(blockdevice)是一种具有一定结构的随机存取设备,对这种设备的读写是按块(所以叫块设备)进行的,他使用缓冲区来存放暂时的数据,待条件成熟后,从缓存一次性写入设备或者从设备一次性读到缓冲区。作为存储设备,块设备驱动的核心问题就是哪些page->segment->block->sector与哪些sector有数据交互,本文以3.14为蓝本,探讨内核中的块设备驱动模型。

框架

  • 下图是Linux中的块设备模型示意图,应用层程序有两种方式访问一个块设备:/dev和文件系统挂载点,前者和字符设备一样,通常用于配置,后者就是我们mount之后通过文件系统直接访问一个块设备了。

  1. read()系统调用最终会调用一个适当的VFS函数(read()-->sys_read()-->vfs_read()),将文件描述符fd和文件内的偏移量offset传递给它。

  2. VFS会判断这个SCI的处理方式,如果访问的内容已经被缓存在RAM中(磁盘高速缓存机制),就直接访问,否则从磁盘中读取

  3. 为了从物理磁盘中读取,内核依赖映射层mapping layer,即上图中的磁盘文件系统

  4. 确定该文件所在文件系统的块的大小,并根据文件块的大小计算所请求数据的长度。本质上,文件被拆成很多块,因此内核需要确定请求数据所在的块

  5. 映射层调用一个具体的文件系统的函数,这个层的函数会访问文件的磁盘节点,然后根据逻辑块号确定所请求数据在磁盘上的位置。


  1. 内核利用通用块层(generic block layer)启动IO操作来传达所请求的数据,通常,一个IO操作只针对磁盘上一组连续的块。

  2. IO调度程序根据预先定义的内核策略将待处理的IO进行重排和合并

  3. 块设备驱动程序向磁盘控制器硬件接口发送适当的指令,进行实际的数据操作



块设备 VS 字符设备

  • 作为一种存储设备,和字符设备相比,块设备有以下几种不同:



  • 此外,大多数情况下,磁盘控制器都是直接使用DMA方式进行数据传送。


【文章福利】小编推荐自己的Linux内核技术交流群:【891587639】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!前100名进群领取,额外赠送一份价值699的内核资料包(含视频教程、电子书、实战项目及代码) 



 


 

IO调度

  • 就是电梯算法。我们知道,磁盘是的读写是通过机械性的移动磁头来实现读写的,理论上磁盘设备满足块设备的随机读写的要求,但是出于节约磁盘,提高效率的考虑,我们希望当磁头处于某一个位置的时候,一起将最近需要写在附近的数据写入,而不是这写一下,那写一下然后再回来,IO调度就是将上层发下来的IO请求的顺序进行重新排序以及对多个请求进行合并,这样就可以实现上述的提高效率、节约磁盘的目的。这种解决问题的思路使用电梯算法,一个运行中的电梯,一个人20楼->1楼,另外一个人15->5楼,电梯不会先将第一个人送到1楼再去15楼接第二个人将其送到5楼,而是从20楼下来,到15楼的时候停下接人,到5楼将第二个放下,最后到达1楼,一句话,电梯算法最终服务的优先顺序并不按照按按钮的先后顺序。Linux内核中提供了下面的几种电梯算法来实现IO调度:

  1. No-op I/O scheduler只实现了简单的FIFO的,只进行最简单的合并,比较适合基于Flash的存储

  2. Anticipatory I/O scheduler推迟IO请求(大约几个微秒),以期能对他们进行排序,获得更高效率

  3. Deadline I/O scheduler试图把每次请求的延迟降到最低,同时也会对BIO重新排序,特别适用于读取较多的场合,比如数据库

  4. CFQ I/O scheduler为系统内所有的任务分配均匀的IO带宽,提供一个公平的工作环境,在多媒体环境中,能保证音视频及时从磁盘中读取数据,是当前内核默认的调度器

  • 我们可以通过内核传参的方式指定使用的调度算法

  • 或者,使用如下命令改变内核调度算法

  • Page->Segment->Block->Sector VS Sector

  • VS左面的是数据交互中的内存部分,Page就是内存映射的最小单位; Segment就是一个Page中我们要操作的一部分,由若干个相邻的块组成; Block是逻辑上的进行数据存取的最小单位,是文件系统的抽象,逻辑块的大小是在格式化的时候确定的, 一个 Block 最多仅能容纳一个文件(即不存在多个文件同一个block的情况)。如果一个文件比block小,他也会占用一个block,因而block中空余的空间会浪费掉。而一个大文件,可以占多个甚至数十个成百上千万的block。Linux内核要求 Block_Size = Sector_Size * (2的n次方),并且Block_Size <= 内存的Page_Size(页大小), 如ext2 fs的block缺省是4k。若block太大,则存取小文件时,有空间浪费的问题;若block太小,则硬盘的 Block 数目会大增,而造成 inode 在指向 block 的时候的一些搜寻时间的增加,又会造成大文件读写方面的效率较差,block是VFS和文件系统传送数据的基本单位。block对应磁盘上的一个或多个相邻的扇区,而VFS将其看成是一个单一的数据单元,块设备的block的大小不是唯一的,创建一个磁盘文件系统时,管理员可以选择合适的扇区的大小,同一个磁盘的几个分区可以使用不同的块大小。此外,对块设备文件的每次读或写操作是一种"原始"访问,因为它绕过了磁盘文件系统,内核通过使用最大的块(4096)执行该操作。Linux对内存中的block会被进一步划分为Sector,Sector是硬件设备传送数据的基本单位,这个Sector就是512byte,和物理设备上的概念不一样,如果实际的设备的sector不是512byte,而是4096byte(eg SSD),那么只需要将多个内核sector对应一个设备sector即可


  • VS右边是物理上的概念,磁盘中一个Sector是512Byte,SSD中一个Sector是4K

  • 核心结构与方法简述

  • 核心结构

  • gendisk是一个物理磁盘或分区在内核中的描述

  • block_device_operations描述磁盘的操作方法集,block_device_operations之于gendisk,类似于file_operations之于cdev

  • request_queue对象表示针对一个gendisk对象的所有请求的队列,是相应gendisk对象的一个域

  • request表示经过IO调度之后的针对一个gendisk(磁盘)的一个"请求",是request_queue的一个节点。多个request构成了一个request_queue

  • bio表示应用程序对一个gendisk(磁盘)原始的访问请求,一个bio由多个bio_vec,多个bio经过IO调度和合并之后可以形成一个request。

  • bio_vec描述的应用层准备读写一个gendisk(磁盘)时需要使用的内存页page的一部分,即上文中的"段",多个bio_vec和bio_iter形成一个bio

  • bvec_iter描述一个bio_vec中的一个sector信息


  • 核心方法

  • set_capacity()设置gendisk对应的磁盘的物理参数

  • blk_init_queue()分配+初始化+绑定一个有IO调度的gendisk的requst_queue,处理函数是void (request_fn_proc) (struct request_queue *q);类型

  • blk_alloc_queue() 分配+初始化一个没有IO调度的gendisk的request_queue,

  • blk_queue_make_request()绑定处理函数到一个没有IO调度的request_queue,处理函数函数是void (make_request_fn) (struct request_queue *q, struct bio *bio);类型

  • __rq_for_each_bio()遍历一个request中的所有的bio

  • bio_for_each_segment()遍历一个bio中所有的segment

  • rq_for_each_segment()遍历一个request中的所有的bio中的所有的segment 最后三个遍历算法都是用在request_queue绑定的处理函数中,这个函数负责对上层请求的处理。

  • 核心结构与方法详述

  • gendisk

  • 同样是面向对象的设计方法,Linux内核使用gendisk对象描述一个系统的中的块设备,类似于Windows系统中的磁盘分区和物理磁盘的关系,OS眼中的磁盘都是逻辑磁盘,也就是一个磁盘分区,一个物理磁盘可以对应多个磁盘分区,在Linux中,这个gendisk就是用来描述一个逻辑磁盘,也就是一个磁盘分区。

  • struct gendisk

  • --169-->驱动的主设备号

  • --170-->第一个次设备号

  • --171-->次设备号的数量,即允许的最大分区的数量,1表示不允许分区

  • --174-->设备名称

  • --185-->分区表数组首地址

  • --186-->第一个分区,相当于part_tbl->part[0]

  • --188-->操作方法集指针

  • --189-->请求对象指针

  • --190-->私有数据指针

  • --193-->表示这是一个设备


gendisk是一个动态分配的结构体,所以不要自己手动来分配,而是使用内核相应的API来分配,其中会做一些初始化的工作

上面几个API是一个块设备驱动中必不可少的部分,下面的两个主要是用来内核对于设备管理用的,通常不要驱动来实现

  • 这两个API最终回调用kobject *get_disk() 和kobject_put()来实现对设备的引用计数

block_device_operations

  • 和字符设备一样,如果使用/dev接口访问块设备,最终就会回调这个操作方法集的注册函数

struct block_device_operations

--1559-->当应用层打开一个块设备的时候被回调 

--1560-->当应用层关闭一个块设备的时候被回调 

--1562-->相当于file_operations里的compat_ioctl,不过块设备的ioctl包含大量的标准操作,所以在这个接口实现的操作很少 

--1567-->在移动块设备中测试介质是否改变的方法,已经过时,同样的功能被check_event()实现 

--1571-->即get geometry,获取驱动器的几何信息,获取到的信息会被填充在一个hd_geometry结构中 

--1574-->模块所属,通常填THIS_MODULE

request_queue

  • 每一个gendisk对象都有一个request_queue对象,前文说过,块设备有两种访问接口,一种是/dev下,一种是通过文件系统,后者经过IO调度在这个gendisk->request_queue上增加请求,最终回调与request_queue绑定的处理函数,将这些请求向下变成具体的硬件操作

struct request_queue

--298-->请求队列的链表头 

--300-->请求队列使用的IO调度算法, 通过内核启动参数来选择: kernel elevator=deadline request_queue_t和gendisk一样需要使用内核API来分配并初始化,里面大量的成员不要直接操作, 此外, 请求队列如果要正常工作还需要绑定到一个处理函数中, 当请求队列不为空时, 处理函数会被回调, 这就是块设备驱动中处理请求的核心部分!

  • 从驱动模型的角度来说, 块设备主要分为两类需要IO调度的和不需要IO调度的, 前者包括磁盘, 光盘等, 后者包括Flash, SD卡等, 为了保证模型的统一性 , Linux中对这两种使用同样的模型但是通过不同的API来完成上述的初始化和绑定

有IO调度类设备API

无IO调度类设备API

共用API

  • 针对请求队列的操作是块设备的一个核心任务, 其实质就是对请求队列操作函数的编写, 这个函数的主要功能就是从请求队列中获取请求并根据请求进行相应的操作 内核中已经提供了大量的API供该函数使用

request

struct request

--98-->将这个request挂接到链表的节点 

--104-->这个request从属的request_queue 

--117-->组成这个request的bio链表的头指针 

--118-->组成这个request的bio链表的尾指针 

--120-->内核hash表头指针

bio

  • bio用来描述单一的I/O请求,它记录了一次I/O操作所必需的相关信息,如用于I/O操作的数据缓存位置,,I/O操作的块设备起始扇区,是读操作还是写操作等等

  • struct bio

  • --47-->指向链表中下一个bio的指针bi_next --50-->bi_rw低位表示读写READ/WRITE, 高位表示优先级

  • --90-->bio对象包含bio_vec对象的数目

  • --91-->这个bio能承载的最大的io_vec的数目

  • --95-->该bio描述的第一个io_vec --104-->表示这个bio包含的bio_vec变量的数组,即这个bio对应的某一个page中的一"段"内存

  • bio_vec

  • 描述指定page中的一块连续的区域,在bio中描述的就是一个page中的一个"段"(segment)

  • struct bio_vec

  • --26-->描述的page

  • --27-->描述的长度

  • --28-->描述的起始地址偏移量

  • bio_iter

  • 用于记录当前bvec被处理的情况,用于遍历bio

  • __rq_for_each_bio()

  • 遍历一个request中的每一个bio

bio_for_each_segment()

  • 遍历一个bio中的每一个segment

rq_for_each_segment()

  • 遍历一个request中的每一个segment

小结

  • 遍历request_queue,绑定函数的一个必要的工作就是将request_queue中的数据取出, 所以遍历是必不可少的, 针对有IO调度的设备, 我们需要从中提取请求再继续操作, 对于没有IO调度的设备, 我们可以直接从request_queue中提取bio进行操作, 这两种处理函数的接口就不一样,下面的例子是对LDD3中的代码进行了修剪而来的,相应的API使用的是3.14版本,可以看出这两种模式的使用方法的不同。


深度剖析Linux块设备IO子系统(一)_驱动模型(秒懂)的评论 (共 条)

分享到微博请遵守国家法律