第六十八章 Linux 块设备驱动实验
68.1 什么是块设备?
块设备是针对存储设备的,比如SD卡、EMMC、NAND Flash、Nor Flash、SPI Flash、机械硬盘、固态硬盘等。因此块设备驱动其实就是这些存储设备驱动,块设备驱动相比字符设备驱动的主要区别如下:
①、块设备只能以块为单位进行读写访问,块是linux虚拟文件系统(VFS)基本的数据传输单位。字符设备是以字节为单位进行数据传输的,不需要缓冲。
②、块设备在结构上是可以进行随机访问的,对于这些设备的读写都是按块进行的,块设备使用缓冲区来暂时存放数据,等到条件成熟以后在一次性将缓冲区中的数据写入块设备中。这么做的目的为了提高块设备寿命,大家如果仔细观察的话就会发现有些硬盘或者NAND Flash就会标明擦除次数(flash的特性,写之前要先擦除),比如擦除100000次等。因此,为了提高块设备寿命而引入了缓冲区,数据先写入到缓冲区中,等满足一定条件后再一次性写入到真正的物理存储设备中,这样就减少了对块设备的擦除次数,提高了块设备寿命。
字符设备是顺序的数据流设备,字符设备是按照字节进行读写访问的。字符设备不需要缓冲区,对于字符设备的访问都是实时的,而且也不需要按照固定的块大小进行访问。
块设备结构的不同其I/O算法也会不同,比如对于EMMC、SD卡、NAND Flash这类没有任何机械设备的存储设备就可以任意读写任何的扇区(块设备物理存储单元)。但是对于机械硬盘这样带有磁头的设备,读取不同的盘面或者磁道里面的数据,磁头都需要进行移动,因此对于机械硬盘而言,将那些杂乱的访问按照一定的顺序进行排列可以有效提高磁盘性能,linux里面针对不同的存储设备实现了不同的I/O调度算法。
68.2 块设备驱动框架
68.2.1 block_device结构体
linux内核使用block_device表示块设备,block_device为一个结构体,定义在include/linux/fs.h文件中,结构体内容如下:
int register_blkdev(unsigned int major, const char *name)
函数参数和返回值含义如下:
major:主设备号。
name:块设备名字。
返回值:如果参数major在1255之间的话表示自定义主设备号,那么返回0表示注册成功,如果返回负值的话表示注册失败。如果major为0的话表示由系统自动分配主设备号,那么返回值就是系统分配的主设备号(1255),如果返回负值那就表示注册失败。
2、注销块设备
和字符设备驱动一样,如果不使用某个块设备了,那么就需要注销掉,函数为unregister_blkdev,函数原型如下:
void unregister_blkdev(unsigned int major, const char *name)
函数参数和返回值含义如下:
major:要注销的块设备主设备号。
name:要注销的块设备名字。
返回值:无。
68.2.2 gendisk结构体
linux内核使用gendisk来描述一个磁盘设备,这是一个结构体,定义在include/linux/genhd.h中,内容如下:
第5行,major为磁盘设备的主设备号。
第6行,first_minor为磁盘的第一个次设备号。
第7行,minors为磁盘的次设备号数量,也就是磁盘的分区数量,这些分区的主设备号一样,次设备号不同。
第21行,part_tbl为磁盘对应的分区表,为结构体disk_part_tbl类型,disk_part_tbl的核心是一个hd_struct结构体指针数组,此数组每一项都对应一个分区信息。
第24行,fops为块设备操作集,为block_device_operations结构体类型。和字符设备操作集file_operations一样,是块设备驱动中的重点!
第25行,queue为磁盘对应的请求队列,所以针对该磁盘设备的请求都放到此队列中,驱动程序需要处理此队列中的所有请求。
编写块的设备驱动的时候需要分配并初始化一个gendisk,linux内核提供了一组gendisk操作函数,我们来看一下一些常用的API函数。
1、申请gendisk
使用gendisk之前要先申请,allo_disk函数用于申请一个gendisk,函数原型如下:
struct gendisk *alloc_disk(int minors)
函数参数和返回值含义如下:
minors:次设备号数量,也就是gendisk对应的分区数量。
返回值:成功:返回申请到的gendisk,失败:NULL。
2、删除gendisk
如果要删除gendisk的话可以使用函数del_gendisk,函数原型如下:
void del_gendisk(struct gendisk *gp)
函数参数和返回值含义如下:
gp:要删除的gendisk。
返回值:无。
3、将gendisk添加到内核
使用alloc_disk申请到gendisk以后系统还不能使用,必须使用add_disk函数将申请到的gendisk添加到内核中,add_disk函数原型如下:
void add_disk(struct gendisk disk)
函数参数和返回值含义如下:
disk:要添加到内核的gendisk。
返回值:无。
4、设置gendisk容量
每一个磁盘都有容量,所以在初始化gendisk的时候也需要设置其容量,使用函数set_capacity,函数原型如下:
void set_capacity(struct gendisk disk, sector_t size)
函数参数和返回值含义如下:
disk:要设置容量的gendisk。
size:磁盘容量大小,注意这里是扇区数量。块设备中最小的可寻址单元是扇区,一个扇区一般是512字节,有些设备的物理扇区可能不是512字节。不管物理扇区是多少,内核和块设备驱动之间的扇区都是512字节。所以set_capacity函数设置的大小就是块设备实际容量除以512字节得到的扇区数量。比如一个2MB的磁盘,其扇区数量就是(210241024)/512=4096。
返回值:无。
5、调整gendisk引用计数
内核会通过get_disk和put_disk这两个函数来调整gendisk的引用计数,根据名字就可以知道,get_disk是增加gendisk的引用计数,put_disk是减少gendisk的引用计数,这两个函数原型如下所示:
truct kobject *get_disk(struct gendisk *disk)
void put_disk(struct gendisk *disk)
68.2.3 block_device_operations结构体
和字符设备的file _operations一样,块设备也有操作集,为结构体block_device_operations,此结构体定义在include/linux/blkdev.h中,结构体内容如下:
68.2.4 块设备I/O请求过程
大家如果仔细观察的话会在block_device_operations结构体中并没有找到read和write这样的读写函数,那么块设备是怎么从物理块设备中读写数据?这里就引出了块设备驱动中非常重要的request_queue、request和bio。
1、请求队列request_queue
内核将对块设备的读写都发送到请求队列request_queue中,request_queue中是大量的request(请求结构体),而request又包含了bio,bio保存了读写相关数据,比如从块设备的哪个地址开始读取、读取的数据长度,读取到哪里,如果是写的话还包括要写入的数据等。我们先来看一下request_queue,这是一个结构体,定义在文件include/linux/blkdev.h中,由于request_queue结构体比较长,这里就不列出来了。大家回过头看一下示例代码68.2.2.1的gendisk结构体就会发现里面有一个request_queue结构体指针类型成员变量queue,也就说在编写块设备驱动的时候,每个磁盘(gendisk)都要分配一个request_queue。
①、初始化请求队列
我们首先需要申请并初始化一个request_queue,然后在初始化gendisk的时候将这个request_queue地址赋值给gendisk的queue成员变量。使用blk_init_queue函数来完成request_queue的申请与初始化,函数原型如下:
request_queue *blk_init_queue(request_fn_proc *rfn, spinlock_t *lock)
函数参数和返回值含义如下:
rfn:请求处理函数指针,每个request_queue都要有一个请求处理函数,请求处理函数request_fn_proc原型如下:
void (request_fn_proc) (struct request_queue *q)
请求处理函数需要驱动编写人员自行实现。
lock:自旋锁指针,需要驱动编写人员定义一个自旋锁,然后传递进来。,请求队列会使用这个自旋锁。
返回值:如果为NULL的话表示失败,成功的话就返回申请到的request_queue地址。
②、删除请求队列
当卸载块设备驱动的时候我们还需要删除掉前面申请到的request_queue,删除请求队列使用函数blk_cleanup_queue,函数原型如下:
void blk_cleanup_queue(struct request_queue *q)
函数参数和返回值含义如下:
q:需要删除的请求队列。
返回值:无。
③、分配请求队列并绑定制造请求函数
blk_init_queue函数完成了请求队列的申请已经请求处理函数的绑定,这个一般用于像机械硬盘这样的存储设备,需要I/O调度器来优化数据读写过程。但是对于EMMC、SD卡这样的非机械设备,可以进行完全随机访问,所以就不需要复杂的I/O调度器了。对于非机械设备我们可以先申请request_queue,然后将申请到的request_queue与“制造请求”函数绑定在一起。先来看一下request_queue申请函数blk_alloc_queue,函数原型如下:
struct request_queue *blk_alloc_queue(gfp_t gfp_mask)
函数参数和返回值含义如下:
gfp_mask:内存分配掩码,具体可选择的掩码值请参考include/linux/gfp.h中的相关宏定义,一般为GFP_KERNEL。
返回值:申请到的无I/O调度的request_queue。
我们需要为blk_alloc_queue函数申请到的请求队列绑定一个“制造请求”函数(其他参考资料将其直接翻译为“制造请求”函数)。这里我们需要用到函数blk_queue_make_request,函数原型如下:
void blk_queue_make_request(struct request_queue *q, make_request_fn *mfn)
函数参数和返回值含义如下:
q:需要绑定的请求队列,也就是blk_alloc_queue申请到的请求队列。
mfn:需要绑定的“制造”请求函数,函数原型如下:
void (make_request_fn) (struct request_queue *q, struct bio *bio)
“制造请求”函数需要驱动编写人员实现。
返回值:无。
一般blk_alloc_queue和blk_queue_make_request是搭配在一起使用的,用于那么非机械的存储设备、无需I/O调度器,比如EMMC、SD卡等。blk_init_queue函数会给请求队列分配一个I/O调度器,用于机械存储设备,比如机械硬盘等。
2、请求request
请求队列(request_queue)里面包含的就是一系列的请求(request),request是一个结构体,定义在include/linux/blkdev.h里面,这里就不展开request结构体了,太长了。request里面有一个名为“bio”的成员变量,类型为bio结构体指针。前面说了,真正的数据就保存在bio里面,所以我们需要从request_queue中取出一个一个的request,然后再从每个request里面取出bio,最后根据bio的描述讲数据写入到块设备,或者从块设备中读取数据。
①、获取请求
我们需要从request_queue中依次获取每个request,使用blk_peek_request函数完成此操作,函数原型如下:
request *blk_peek_request(struct request_queue *q)
函数参数和返回值含义如下:
q:指定request_queue。
返回值:request_queue中下一个要处理的请求(request),如果没有要处理的请求就返回NULL。
②、开启请求
使用blk_peek_request函数获取到下一个要处理的请求以后就要开始处理这个请求,这里要用到blk_start_request函数,函数原型如下:
void blk_start_request(struct request *req)
函数参数和返回值含义如下:
req:要开始处理的请求。
返回值:无。
③、一步到位处理请求
我们也可以使用blk_fetch_request函数来一次性完成请求的获取和开启,blk_fetch_request函数很简单,内容如下:
可以看出,blk_fetch_request就是直接调用了blk_peek_request和blk_start_request这两个函数。
④、其他和请求有关的函数
关于请求的API还有很多,常见的见表68.2.4.1:
表68.2.4.1 请求相关API函数
3、bio结构
每个request里面里面会有多个bio,bio保存着最终要读写的数据、地址等信息。上层应用程序对于块设备的读写会被构造成一个或多个bio结构,bio结构描述了要读写的起始扇区、要读写的扇区数量、是读取还是写入、页偏移、数据长度等等信息。上层会将bio提交给I/O调度器,I/O调度器会将这些bio构造成request结构,而一个物理存储设备对应一个request_queue,request_queue里面顺序存放着一系列的request。新产生的bio可能被合并到request_queue里现有的request中,也可能产生新的request,然后插入到request_queue中合适的位置,这一切都是由I/O调度器来完成的。request_queue、request和bio之间的关系如图68.2.4.1所示:
图68.2.4.1 request_queue、request和bio之间的关系
bio是个结构体,定义在include/linux/blk_types.h中,结构体内容如下:
图68.4.2.2 bio、bio_iter与bio_vec之间的关系
①、遍历请求中的bio
前面说了,请求中包含有大量的bio,因此就涉及到遍历请求中所有bio并进行处理。遍历请求中的bio使用函数__rq_for_each_bio,这是一个宏,内容如下:
bio包含了最终要操作的数据,因此还需要遍历bio中的所有段,这里要用到bio_for_each_segment函数,此函数也是一个宏,内容如下:
bvoid bio_endio(struct bio *bio, int error)
函数参数和返回值含义如下:
bio:要结束的bio。
error:如果bio处理成功的话就直接填0,如果失败的话就填个负值,比如-EIO。
返回值:无
68.3 使用请求队列实验
关于块设备架构就讲解这些,接下来我们使用开发板上的RAM模拟一段块设备,也就是ramdisk,然后编写块设备驱动。
68.3.1 实验程序编写
本实验对应的例程路径为:开发板光盘-> 2、Linux驱动例程-> 24_ramdisk_withrequest。
首先是传统的使用请求队列的时候,也就是针对机械硬盘的时候如何编写驱动。由于实验程序稍微有点长,因此我们就分步骤来讲解一下,本实验参考自linux内核drivers/block/z2ram.c。打开实验源码,我们先来一下一相关的宏定义和结构体,代码如下:
扇区地址,左移9位转换为字节地址 */
68.3.2 运行测试
编译上一小节的驱动,得到ramdisk.ko驱动模块,然后拷贝到rootfs/lib/modules/4.1.15目录中,重启开发板,进入到目录lib/modules/4.1.15中。输入如下命令加载ramdisk.ko这个驱动模块。
depmod //第一次加载驱动的时候需要运行此命令
modprobe ramdisk.ko //加载驱动模块
1、查看ramdisk磁盘
驱动加载成功以后就会在/dev/目录下生成一个名为“ramdisk”的设备, 输入如下命令查看ramdisk磁盘信息:
fdisk -l //查看磁盘信息
上述命令会将当前系统中所有的磁盘信息都打印出来,其中就包括了ramdisk设备,如图68.3.2.1所示:
图68.3.2.1 ramdisk磁盘信息
从图68.3.2.1可以看出,ramdisk已经识别出来了,大小为2MB,但是同时也提示/dev/ramdisk没有分区表,因为我们还没有格式化/dev/ramdisk。
2、格式化/dev/ramdisk
使用mkfs.vfat命令格式化/dev/ramdisk,将其格式化成vfat格式,输入如下命令:
mkfs.vfat /dev/ramdisk
格式化完成以后就可以挂载/dev/ramdisk来访问了,挂载点可以自定义,这里笔者就将其挂载到/tmp目录下,输入如下命令:
mount /dev/ramdisk /tmp
挂载成功以后就可以通过/tmp来访问ramdisk这个磁盘了,进入到/tmp目录中,可以通过vi命令新建一个txt文件来测试磁盘访问是否正常。
68.4 不使用请求队列实验
68.4.1 实验程序编写
本实验对应的例程路径为:开发板光盘-> 2、Linux驱动例程-> 25_ramdisk_norequest。
前面我们学习了如何使用请求队列,请求队列会用到I/O调度器,适合机械硬盘这种存储设备。对于EMMC、SD、ramdisk这样没有机械结构的存储设备,我们可以直接访问任意一个扇区,因此可以不需要I/O调度器,也就不需要请求队列了,这个我们前面已经说过了。本实验就来学习一下如何使用“制造请求”方法,本实验在上一个实验的基础上修改而来,参考了linux内核drivers/block/zram/zram_drv.c。重点来看一下与上一个实验不同的地方,首先是驱动入口函数ramdisk_init,ramdisk_init函数大部分和上一个实验相同,只是本实验中改为使用blk_queue_make_request函数设置“制造请求”函数,修改后的ramdisk_init函数内容如下(有省略):
第17行,根据bio_vec中页地址以及偏移地址转换为真正的数据起始地址。
第18行,获取要出来的数据长度,也就是bio_vec的bv_len成员变量。
第20~23行,和上一个实验一样,要操作的块设备起始地址知道了,数据的存放地址以及长度也知道,接下来就是根据读写操作将数据从块设备中读出来,或者将数据写入到块设备中。
第27行,调用bio_endio函数,结束bio。
68.4.2 运行测试
测试方法和上一个实验一样,参考68.3.2小节即可。