经过之前这些笔记的学习,都是字符设备驱动,本章来学习一下块设备驱动框架,块设备驱动是Linux三大驱动类型之一。块设备驱动要远比字符设备驱动复杂得多,不同类型的存储设备又对应不同的驱动子系统,本章重点学习一下块设备相关驱动概念,不涉及到具体的存储设备。最后,使用STM32MP1开发板板载RAM模拟一个块设备,学习块设备驱动框架的使用。
块设备是针对存储设备的,比如SD卡、EMMC、NAND Flash、Nor Flash、SPI Flash、机械硬盘、固态硬盘等。因此块设备驱动其实就是这些存储设备驱动,块设备驱动相比字符设备驱动的主要区别如下:
字符设备是顺序的数据流设备,字符设备是按照字节进行读写访问的。字符设备不需要缓
冲区,对于字符设备的访问都是实时的,而且也不需要按照固定的块大小进行访问。
块设备结构的不同其I/O算法也会不同,比如对于EMMC、SD卡、NAND Flash这类没有任何机械设备的存储设备就可以任意读写任何的扇区(块设备物理存储单元)。但是对于机械硬盘这样带有磁头的设备,读取不同的盘面或者磁道里面的数据,磁头都需要进行移动,因此对于机械硬盘而言,将那些杂乱的访问按照一定的顺序进行排列可以有效提高磁盘性能,linux里面针对不同的存储设备实现了不同的I/O调度算法。
Linux内核使用block_device表示块设备,block_device为一个结构体,定义在include/linux/fs.h文件中,结构体内容如下所示:
对于block_device结构体,重点关注一下第21行的bd_disk成员变量,此成员变量为gendisk结构体指针类型。内核使用block_device来表示一个具体的块设备对象,比如一个硬盘或者分区,如果是硬盘的话bd_disk就指向通用磁盘结构gendisk。
和字符设备驱动一样,需要向内核注册新的块设备、申请设备号,块设备注册函数为register_blkdev,函数原型如下
int register_blkdev(unsigned int major, const char *name)
函数参数和返回值含义如下:
和字符设备驱动一样,如果不使用某个块设备了,那么就需要注销掉,函数为
unregister_blkdev,函数原型如下
void unregister_blkdev(unsigned int major, const char *name)
函数参数和返回值含义如下:
Linux内核使用gendisk来描述一个磁盘设备,这是一个结构体,定义在include/linux/genhd.h中,内容如下所示:
简单看一下gendisk结构体中比较重要的几个成员变量:
第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函数。
使用gendisk之前要先申请,allo_disk函数用于申请一个gendisk,函数原型如下:
struct gendisk *alloc_disk(int minors)
函数参数和返回值含义如下:
如果要删除gendisk的话可以使用函数del_gendisk,函数原型如下:
void del_gendisk(struct gendisk *gp)
函数参数和返回值含义如下:
使用alloc_disk申请到gendisk以后系统还不能使用,必须使用add_disk函数将申请到的gendisk添加到内核中,add_disk函数原型如下:
void add_disk(struct gendisk *disk)
函数参数和返回值含义如下:
每一个磁盘都有容量,所以在初始化gendisk的时候也需要设置其容量,使用函数set_capacity,函数原型如下:
void set_capacity(struct gendisk *disk, sector_t size)
函数参数和返回值含义如下:
内核会通过get_disk_and_module和put_disk这两个函数来调整gendisk的引用计数,get_disk_and_module是增加gendisk的引用计数,put_disk是减少gendisk的引用计数,这两个函数原型如下所示:
struct kobject * get_disk_and_module (struct gendisk *disk)
void put_disk(struct gendisk *disk)
和字符设备的file_operations一样,块设备也有操作集,为结构体block_device_operations,此结构体定义在include/linux/blkdev.h 中,结构体内容如下:
可以看出,block_device_operations结构体里面的操作集函数和字符设备的file_operations操作集基本类似,但是块设备的操作集函数比较少,来看一下其中比较重要的几个成员函数:
第2行,open函数用于打开指定的块设备。
第3行,release函数用于关闭(释放)指定的块设备。
第4行,rw_page函数用于读写指定的页。
第5行,ioctl函数用于块设备的I/O控制。
第6行,compat_ioctl函数和ioctl函数一样,都是用于块设备的I/O控制。区别在于在64位系统上,32位应用程序的ioctl会调用compat_iotl函数。在32位系统上运行的32位应用程序调用的就是ioctl函数。
第13行,getgeo函数用于获取磁盘信息,包括磁头、柱面和扇区等信息。
第18行,owner表示此结构体属于哪个模块,一般直接设置为THIS_MODULE。
在block_device_operations结构体中并没有找到read和write这样的读写函数,引出处理块设备驱动中非常重要的request_queue、request和bio。
内核将对块设备的读写都发送到请求队列request_queue中,request_queue中是大量的request(请求结构体),而request又包含了bio,bio保存了读写相关数据,比如从块设备的哪个地址开始读取、读取的数据长度,读取到哪里,如果是写的话还包括要写入的数据等。先来看一下request_queue,这是一个结构体,定义在文件include/linux/blkdev.h中,由于request_queue 结构体比较长,这里就不列出来了。回过头看一下示例代码51.2.2.1的gendisk结构体就会发现里面有一个request_queue结构体指针类型成员变量queue,也就说在编写块设备驱动的时候,每个磁盘(gendisk)都要分配一个request_queue。
1、初始化请求队列
初始化请求队列可以分为两部分,第一部分是创建blk_mq_tag_set结构体,然后使用blk_mq_alloc_tag_set函数初始化blk_mq_tag_set对象。第二部分使用blk_mq_init_queue函数获取request_queue。
blk_mq_tag_set结构体定义在include/linux/blk-mq.h中,如下示例代码所示:
第8行,map[]数组为软硬件队列映射表。
第9行,nr_maps为映射表数量。
第10行,ops为驱动实现的操作集合,会被request_queue继承。
第11行,nr_hw_queues为硬件队列个数。
第12行,queue_depth为队列深度。
第15行,numa_node为所在numa节点。
第17行,flags为标志位,一般为BLK_MQ_F_SHOULD_MERGE,想了解跟多的标志位可以去看include/linux/blk-mq.h文件。
接着去看blk_mq_ops结构体,结构体原型如下所示(有省略):
这里只是列出queue_rq成员,因为本章例程只用到它。queue_rq是一个queue_rq_fn类型的指针,queue_rq_fn类型定义如下:
typedef blk_status_t (queue_rq_fn)(struct blk_mq_hw_ctx *,const struct blk_mq_queue_data *);
queue_rq请求处理函数指针,此函数需要驱动人员自行实现。注意:函数的两个形参就不要管了,只要知道可以通过第二形参获取request结构体。
编写块设备的请求队列驱动的时候需要分配并初始化一个blk_mq_tag_set,linux内核提供了一组 blk_mq_tag_set操作相关的函数,来看一下一些常用的API函数。
1)、为一个或多个请求队列分配tag集合
给blk_mq_tag_set对象赋值后,要使用blk_mq_alloc_tag_set函数为一个或多个请求队列分配tag和request集合,函数原型如下:
int blk_mq_alloc_tag_set(struct blk_mq_tag_set *set);
函数参数和返回值含义如下:
2)、释放请求队列中的tag集合
如果要释放请求队列中的tag集合,可以使用 blk_mq_free_tag_set,函数原型如下:
void blk_mq_free_tag_set(struct blk_mq_tag_set *set);
函数参数和返回值含义如下:
最后需要通过blk_mq_init_queue函数来初始化IO 请求队列request_queue,此函数会申请request_queue,然后返回,函数原型如下:
struct request_queue *blk_mq_init_queue(struct blk_mq_tag_set *);
函数参数和返回值含义如下:
Linux内核也提供了一步创建request_queue队列的函数:blk_mq_init_sq_queue,使用此函
数可以一步创建请求队列,函数原型如下:
struct request_queue *blk_mq_init_sq_queue(struct blk_mq_tag_set *set,
const struct blk_mq_ops *ops,
unsigned int queue_depth,
unsigned int set_flags)
函数参数和返回值含义如下:
2、删除请求队列
当卸载块设备驱动的时候还需要删除掉前面申请到的request_queue,删除请求队列使用函数blk_cleanup_queue,函数原型如下:
void blk_cleanup_queue(struct request_queue *q)
函数参数和返回值含义如下:
3、分配请求队列并绑定制造请求函数
blk_mq_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)
函数参数和返回值含义如下:
需要为申请到的请求队列绑定一个“制造请求”函数(其他参考资料将其直接翻译为“制造请求”函数)。这里需要用到函数blk_queue_make_request,函数原型如下:
void blk_queue_make_request(struct request_queue *q, make_request_fn *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调度器,用于机械存储设备,比如机械硬盘等。
请求队列(request_queue)里面包含的就是一系列的请求(request),request是一个结构体,定义在include/linux/blkdev.h 里面,这里就不展开request结构体了,太长了。request里面有一个
名为“bio”的成员变量,类型为bio结构体指针。前面说了,真正的数据就保存在bio里面,所以需要从request_queue中取出一个一个的request,然后再从每个request里面取出bio,最后根据bio的描述讲数据写入到块设备,或者从块设备中读取数据。
1、开启请求
当有请求处理的时候,要用blk_mq_start_request函数开启请求处理,函数原型如下:
void blk_mq_start_request(struct request *rq);
函数参数和返回值含义如下:
2、结束请求
不用处理请求的时候,要使用blk_mq_end_request函数结束请求处理,函数原型如下:
void blk_mq_end_request(struct request *rq, blk_status_t error);
函数参数和返回值含义如下:
这里先总结一下是如何使用这个两个函数去处理请求数据的,示例代码如下所示:
示例代码 51.2.4.3 处理请求模型
1 static int ramdisk_transfer(struct request *req)
2 {
3 /* 此函数要实现把数据拷贝到硬盘 */
4 reutrn 0;
5 }
6
7 static blk_status_t _queue_rq(struct blk_mq_hw_ctx *hctx,
const struct blk_mq_queue_data* bd)
8 {
9 struct request *req = bd->rq;
10 int ret;
11 /* 开启请求处理 */
12 blk_mq_start_request(req);
13
14 /* 处理请求 */
15 ret = ramdisk_transfer(req);
16
17 /* 结束请求处理 */
18 blk_mq_end_request(req, ret);
19
20 return 0;
21 }
每个request里面里面会有多个bio,bio保存着最终要读写的数据、地址等信息。上层应用程序对于块设备的读写会被构造成一个或多个bio结构,bio结构描述了要读写的起始扇区、要读写的扇区数量、是读取还是写入、页便宜、数据长度等等信息。上层会将bio提交给I/O调度器,I/O调度器会将这些bio构造成request结构,request_queue里面顺序存放着一系列的request。新产生的bio可能被合并到request_queue里现有的request中,也可能产生新的request,然后插入到request_queue中合适的位置,这一切都是由I/O调度器来完成的。request_queue、request和bio之间的关系如下图所示:
bio是一个结构体,定义在include/linux/blk_types.h中,结构体内容如下(有缩减):
重点来看一下第13行和第25行,第13行为bvec_iter结构体类型的成员变量,第25行为bio_vec结构体指针类型的成员变量。
bvec_iter结构体描述了要操作的设备扇区等信息,结构体内容如下:
bio_vec结构体描述内容如下:
可以看出bio_vec就是“page,offset,len”组合,page 指定了所在的物理页,offset表示所处页的偏移地址,len就是数据长度。
对于物理存储设备的操作不外乎就是将RAM中的数据写入到物理存储设备中,或者将物理设备中的数据读取到RAM中去处理。数据传输三个要求:数据源、数据长度以及数据目的地,也就是要从物理存储设备的哪个地址开始读取、读取到RAM中的哪个地址处、读取的数据长度是多少。既然bio是块设备最小的数据传输单元,那么bio就有必要描述清楚这些信息,其中bi_iter这个结构体成员变量就用于描述物理存储设备地址信息,比如要操作的扇区地址。bi_io_vec指向bio_vec数组首地址,bio_vec数组就是RAM信息,比如页地址、页偏移以及长度,“页地址”是linux内核里面内存管理相关的概念,这里不深究linux内存管理,只需要知道对于RAM的操作最终会转换为页相关操作。
bio、bvec_iter以及bio_vec这三个结构体之间的关系如下图所示:
1、遍历请求中的bio
前面说了,请求中包含有大量的bio,因此就涉及到遍历请求中所有bio并进行处理。遍历请求中的bio使用函数__rq_for_each_bio,这是一个宏,内容如下:
_bio就是遍历出来的每个bio,rq是要进行遍历操作的请求,_bio参数为bio结构体指针类型,rq参数为request结构体指针类型。
2、遍历bio中的所有段
bio包含了最终要操作的数据,因此还需要遍历bio中的所有段,这里要用到bio_for_each_segment函数,此函数也是一个宏,内容如下:
示例代码 51.2.4.8 bio_for_each_segment 函数
#define bio_for_each_segment(bvl, bio, iter) \
__bio_for_each_segment(bvl, bio, iter, (bio)->bi_iter)
第一个bvl参数就是遍历出来的每个bio_vec,第二个bio参数就是要遍历的bio,类型为bio结构体指针,第三个iter参数保存要遍历的bio中bio_iter成员变量。
3、通知bio处理结束
如果使用“制造请求”,也就是抛开I/O调度器直接处理bio的话,在bio处理完成以后要通过内核bio处理完成,使用bio_endio函数,函数原型如下:
void bio_endio(struct bio *bio, int error)
函数参数和返回值含义如下:
关于块设备架构就讲解这些,接下来使用开发板上的RAM模拟一段块设备,也就是ramdisk,然后编写块设备驱动。
首先这个实验为多队列请求实验,在内核5.0之后才有的。本实验是使用ROM模拟块设备的空间,没有使用硬件队列相关的函数。由于实验程序稍微有点长,因此就分步骤来讲解一下,实验是参考自linux内核drivers/block/z2ram.c。
先来看一下相关的宏定义和结构体。先宏定义dig一号ramdisk的大小、名字和minor(表示磁盘分区的数量);然后定义一个ramdisk设备结构体,其中要把刚才学习的结构体加进去:需要定义一个unsigned char *ramdiskbuf来表示ramdisk的内存空间,来模拟块设备,这是本次实验自行虚拟出来的;之后的这些市重点,需要定义一个gendisk结构体的指针gendisk,request_queue结构体指针queue,blk_mq_tag_set结构体的tag_set,然后最后加一个自旋锁spinlock_t的lock。顶一万了之后具象化一个ramdisk就可以了。
驱动的加载与卸载,就是init函数和exit函数。先来看init函数,先具象化一个dev指针来表示块设备,由于是用一块内存模拟真实的块设备,首先通过kzalloc申请dev的内存,然后用kmalloc来申请dev->ramdiskbuf内存,然后spin_lock_init初始化自旋锁,通过register_blkdev来注册块设备,然后create_req_queue创建多队列(用于操作块设备),最后create_req_gendisk创建块设备(提供接口给应用层调用)。
exit卸载函数里面,就跟之前分析的一样,显示通过del_gendisk再put_disk释放gendisk,然后blk_cleanup_queue清楚请求队列,之后blk_mq_free_tag_set释放blk_mq_tag_set,最后unregister_blkdev注销块设备,再kfree释放掉自己申请的虚拟块设备。
1、create_req_queue函数
这个函数就是初始化多队列。先设置多队列的重要参数,比如一些操作函数、队列深度、硬件队列个数和标志位等等;其中设置blk_mq_tag_set的ops成员变量,这就是块设备的队列操作集,这里设置为mq_ops,需要驱动开发人员自行编写实现,后面讲解。使用blk_mq_alloc_tag_set函数进行再次初始化blk_mq_tag_set对象,最后根据此对象分配请求队列。也可以使用blk_mq_init_sq_queue函数一步到位,第一个参数为blk_mq_tag_set对象、第二个参数为操作函数集合、第三个参数为硬件队列个数,第四个参数为标志位。
有了多队列后,就可以使用gendisk进行初始化块设备了。
2、create_req_gendisk函数
使用gendisk进行初始化块设备了,初始化块设备的函数如下所示:
首先使用alloc_disk分配一个gendisk;然后初始化申请到的gendisk对象,重点是设置geddisk的fops成员变量,fops负责设置块设备的操作集,然后设置多队列,之后使用set_capacity函数设置本块设备容量大小,注意这里的大小是扇区数,不是字节数,一个扇区是512字节;最后,gendisk初始化完成以后就可以使用add_disk函数将gendisk添加到内核中,也就是向内核添加一个磁盘设备。
3、 操作集
块设备的初始化和多队列的初始化都有自己的操作集,依次来看下这两个操作集的具体内容如下:
1)、gendisk的fops操作集
就是实现块设备的操作集block_device_operations,本例程实现的比较简单,仅仅实现了open、release和getgeo,其中open和release函数都是空函数,重点是getgeo函数,getgeo的具体实现就是获取磁盘信息,信息保存在参数geo中,本例程中设置ramdisk有2个磁头(head)、一共32个柱面(cylinderr)。知道磁盘总容量、磁头数、柱面数以后就可以计算出一共磁道上有多少个扇区了,也就是hd_geometry中的sectors成员变量。
2)、blk_mq_tag_set的ops操作集
blk_mq_tag_set的ops就是请求处理函数集合。
首先需要获取其实地址和大小,其实地址需要通过扇区地址转为字节地址,所以是blk_rq_pos(req)<<9是其实地址,而大小就是blk_rq_cur_bytes(req);之后需要判断读写,先通过bio_data读取到bio的数据存入buffer中然后通过memcpy实现读写。
多队列的操作集blk_mq_os,这里就实现了一个queue_rq,首先通过bd->rq获取到request队列,然后获取设备dev,通过blk_mq_start_request开启处理队列,然后自旋锁上锁,通过ramdisk_transfer来处理数据,之后blk_mq_end_request结束处理队列,然后自旋锁解锁完成。
简单总结一下块设备的编写步骤,首先有两个重要的结构体:blk_mq_tag_set和gendisk。可以把blk_mq_tag_set看作真正的IO读写操作(ops操作集就是IO操作),有了底层操作还不行,还需要gendisk结构体为上层提供接口调用(fops就是实现上层调用的操作)。
老样子,Makefile的obj-m改成ramdisk.o,然后“make”就可以了。
还需要要在buildroot目录下,打开busybox的图形化配置界面,使能mkfs.vfat。命令如下所示:
sudo make busybox-menuconfig |
按如下路径使能mkfs.vfat命令:
-> Linux System Utilities -> [*] mkfs.vfat (7.2 kb) |
如下图所示:
保存busybox配置,重新编译busybox,运行以下命令:
sudo make busybox |
之后重新编译buildroot,命令如下:
sudo make |
编译完成后进入output/images目录,运行以下命令替换根文件系统:
cd output/images/ //进入到 output/images 目录 sudo tar -axvf rootfs.tar -C /home/zuozhongkai/linux/nfs/rootfs //解压到 nfsroot 目录 |
上述命令将buildroot中output/images/rootfs.tar这个压缩包解压到/home/zuozhongkai/linux/nfs/rootfs这个目录中,这个目录就是教程中当前nfsroot目录,需要根据自己的实际情况解压到对应的目录文件中。
将前面编译出来的ramdisk.ko文件拷贝到rootfs/lib/modules/5.3.41目录中,重启开发板,进入到目录lib/modules/5.3.41中。输入如下命令加载ramdisk.ko这个驱动模块:
depmod //第一次加载驱动的实验需要运行此命令 modprobe ramdisk.ko //加载驱动模块 |
正常加载驱动就会有如下所示:
驱动加载成功以后就会在/dev/目录下生成一个名为“ramdisk”的设备,输入如下命令查看ramdisk磁盘信息:
fdisk -l //查看磁盘信息 |
上述命令会将当前系统中所有的磁盘信息都打印出来,其中就包括ramdisk设备,如下图所示:
从上图可以看出,ramdisk已经识别出来了,大小为2MB,但是同时也提示/dev/ramdisk没有分区表,因为还没有格式化/dev/ramdisk。
使用mkfs.vfat命令格式化/dev/ramdisk,将其格式化成vfat格式,输入如下命令:
mkfs.vfat /dev/ramdisk |
格式化完成以后就可以挂载/dev/ramdisk 来访问了,挂载点可以自定义,这里正点原子的教程中就将其挂载到/mnt目录下,输入如下命令:
mkdir /mnt/ram_disk -P //创建 ramdisk 挂载目录 mount /dev/ramdisk /mnt/ram_disk //挂载 ramdisk |
挂载成功以后就可以通过/mnt来访问ramdisk这个磁盘了,进入到/mnt目录中,可以通过vi命令新建一个txt文件来测试磁盘访问是否正常。
前面学习了如何使用请求队列,请求队列会用到I/O调度器,适合机械硬盘这种存储设备。对于EMMC、SD、ramdisk这样没有机械结构的存储设备,可以直接访问任意一个扇区,因此可以不需要I/O调度器,也就不需要请求队列了。本实验就来学习一下如何使用“制造请求”方法,本实验在上一个实验的基础上修改而来,参考了linux内核drivers/block/zram/zram_drv.c。首先是驱动入口函数ramdisk_init,ramdisk_init函数
大部分和上一个实验相同,只需要把blk_mq_tag_set相关的都删除掉,然后修改create_req_queue函数即可,在此函数里使用create_req_queue函数设置“制造请求”函数。
create_req_queue里面就是先通过blk_alloc_queue分配请求队列,然后blk_queue_make_request设置“制造请求函数”,最后把这个传入的randisk的设备dev存到request_queue结构体的queuedata成员变量中。
至于“制造请求”函数,就是ramdisk_make_request_fn函数,里面就是之前的操作读写的方法,只不过全是对bio的操作,通过bio的bi_iter成员变量的bi_sector然后<<9获取编译地址,然后由bio_for_each_segment循环获取bio的每个段,真正起始地址是通过page_address读取bvec的bv_page再加上bvec的bv_offset获得,长度就是bvec.bv_len;最后在完成了数据的读/写之后,调用bio_endio。
这个跟上一个实验是一样的,这里就不赘述了。
块设备就是针对存储设备的,比如SD卡、EMMC、NAND Flash、机械硬盘等。块设备的驱动就不同于之前学习的那些字符设备驱动,是只能以块为单位进行读写操作的。机械硬盘这种和SD卡、EMMC等没有机械设备的存储结构就不一样,驱动方法会有区别。
块设备是通过block_device来表示的。而磁盘设备是通过gendisk这个结构体来表示。而块设备的操作集是block_device_operations结构体。
块设备的IO是通过请求队列request_queue来保存的,队列中是大量的request结构体,request又包含了bio(保存了读写的相关数据,例如起始地址、数据长度,目标地址以及读写操作等)。
对于机械硬盘这种而言,就需要通过blk_mq_init_queue初始化IO请求队列;而类似EMMC这种非机械设备,就只需要借助“制造请求”函数就可以了。
具体的驱动编写最后看一看笔记,多看看最后记一下自己打打看代码就好了。