目录
- 一、Motivation
- 二、Solution
- S1 - 块设备驱动框架
- (1)注册块设备
- (2)注销块设备
- (3)申请 gendisk
- (4)删除 gendisk
- (5)将 gendisk 加入 kernel
- (6)设置 gendisk 容量
- (7)gendisk 引用计数
- S2 - 定义块设备
- S3 - 无 I/O 调度的请求队列
一、Motivation
在类 Unix OS 的世界里,I/O 设备都是被当作设备文件( device file )这种特殊文件来处理的。例如,用同一 write() 系统调用既可以向普通文件中写入数据,也可以向打印机等外围设备中写数据。根据设备文件的特点可分为字符设备和块设备,
- 字符设备一般是不支持随机访问的,例如,鼠标和键盘等;
- 块设备是支持随机访问的,例如,硬盘
我们为什么要编写关于设备的驱动程序呢?主要是为了操控插入的外接设备,本文主要是对块设备展开具体讲解,如有字符设备的需求请移步「Tech初见」Linux驱动之blkdev
二、Solution
S1 - 块设备驱动框架
kernel 用 block_device 结构体表示块设备,定义在 include/linux/fs.h 中,结构体内部的成员变量太多了,我决定不展开讲解,等到用到的时候再说。关于 block_device 我们在这里仅仅需要搞明白两点就可以了,
(1)注册块设备
该方法是向 kernel 中注册新的块设备并申请设备号,原型为,
int register_blkdev(unsigned int major, const char* name)
其中,major 为主设备号,name 为块设备名称。若 major 为 0,表示由系统自动分配主设备号;若 major 在 1 ~ 255 之间,表示自定义的主设备号。返回值为 0 则表示注册成功,反之失败。关于主次设备号的内容同样在「Tech初见」Linux驱动之blkdev 中有详细的说明
(2)注销块设备
该方法是在 kernel 中注销指定的块设备,原型为,
void unregister_blkdev(unsigned int major, const char* name)
gendisk 是块设备最重要的结构体,意为通用磁盘,定义在 include/linux/genhd.h,可以理解为 gendisk 是我们创建的块设备节点与 kernel 交互的中间人,同样我们一开始不需要太深入地了解内部的成员变量,只需要记得这几个用于申请和释放 gendisk 的方法即可,
(3)申请 gendisk
在使用 gendisk 之前要先申请,原型为,
struct gendisk* alloc_disk(int minors)
其中,minors 为次设备号的数量,即 gendisk 对应的分区数量
(4)删除 gendisk
原型为,
void del_gendisk(struct gendisk* gdisk)
(5)将 gendisk 加入 kernel
申请到 gendisk 之后,对其进行初始化,然后就可以加入到 kernel 中了,原型为,
void add_disk(struct gendisk* gdisk)
(6)设置 gendisk 容量
初始化 gendisk 时需要设置其容量,原型为,
void set_capacity(struct gendisk* gdisk, sector_t size)
其中的 size 是磁盘容量大小,注意这里指的是 sector 个数,一个 sector 通常为 512 字节
(7)gendisk 引用计数
增加 gendisk 的引用计数,
struct kobject* get_disk(struct gendisk* gdisk)
减少 gendisk 的引用计数,
void put_disk(struct gendisk* disk)
当 kernel 中没人再引用该 gendisk 时,kernel 就可以放心大胆地释放这块空间了
block_device_operations 用来表示块设备的操作集,定义在 include/linux/blkdev.h 中,同样也无需了解太多,记住 open() 和 release() 即可
关于块设备 I/O 请求过程,我先要引入的就是请求队列 request_queue 的概念,它就是一个队列,里面存放着不同的 I/O 请求,我们都知道 I/O 操作比 CPU 操作要慢很多,为了提高系统的利用率和吞吐量,我们一般都是等一等 I/O 操作,待它成势了再一次性地写入磁盘,这样可以减少磁盘的寻道时间,队列和 request 以及包含所需操作的磁块 bio 的关系如下图,
每个 gendisk 都应该有一个请求队列,可以通过,
struct request_queue* blk_alloc_queue(gfp_t gfp_mask)
进行申请,gfp_mask 一般为 GFP_KERNEL。然后通过,
void blk_queue_make_request(struct request_queue* que, make_request_fn* fn)
来为队列绑定请求函数,意思就是说只要是这个队列中的请求,统统按照 fn 的业务逻辑来处理。当然,还需要实现具体的 fn,
void (make_request_fn) (struct request_queue* que, struct bio* bio)
最后,我还想讲解一下 bio 结构体,它保存着最终要读写的数据地址等信息,定义在 include/linux/blk_types.h 中
S2 - 定义块设备
定义一些自己的块设备及对应的操作,struct myblkdev 包含的 struct gendisk 相当重要,透过它才能体现出我们定义的是块设备,buf
指向模拟的磁盘空间,宏 DISKSIZE
是磁盘的大小,默认为 2 MB,宏 NDISKPART
表示磁盘有 3 个 sector,每个 sector 的大小为宏 SECTORSIZE
512,
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/list.h>
#include <linux/fs.h>
#include <linux/blkdev.h>
#include <linux/genhd.h>
#include <linux/slab.h>
#include <linux/string.h>#define DISKSIZE (2*1024*1024) /* 模拟磁盘的大小为 2 MB */
#define DEVNAME "ramdisk"
#define NDISKPART 3 /* 模拟磁盘有 3 个分区 */
#define SECTORSIZE 512 /* 扇区大小 *//* 自定义的块设备结构 */
struct myblkdev {int major;unsigned char* buf; /* 指向模拟磁盘的空间 */struct gendisk* gdisk;struct request_queue* que;
};struct myblkdev mydev;int
my_blkdev_open(struct block_device* dev, fmod_t mode)
{printk(KERN_INFO "my_blkdev_open\n");return 0;
}void
my_blkdev_release(struct gendisk* gdisk, fmod_t mode)
{printk(KERN_INFO "my_blkdev_release\n");
}struct block_device_operations blkops = {.owner = THIS_MODULE,.open = my_blkdev_open,.release = my_blkdev_release,
};
然后,我们的目光就可以转到驱动程序注册的流程当中了,相比于「Tech初见」Linux驱动之blkdev 的字符设备,块设备的初始化稍微复杂了一些,但是本质都是一样的,即是申请 - 初始化 - 加入 kernel 模型。拢共分为五步走,
/* 初始化函数 */
static int __init
my_blkdev_init(void)
{int ok = 0;printk(KERN_INFO "my_blkdev_init\n");/* 注册块设备 */mydev.major = register_blkdev(0, DEVNAME);if(mydev.major < 0) {ok = -EINVAL;goto over;}/* 申请模拟磁盘的内存空间 */mydev.buf = kzalloc(DISKSIZE, GFP_KERNEL);if(mydev.buf == NULL) {ok = -EINVAL;goto alloc_buf_fail;}/* 分配 gendisk */mydev.gdisk = alloc_disk(NDISKPART);if(mydev.gdisk == NULL) {ok = -EINVAL;goto alloc_disk_fail;}/* 分配 request 队列 */mydev.que = blk_alloc_queue(GFP_KERNEL);if(mydev.que == NULL) {ok = -EINVAL;goto alloc_que_fail;}blk_queue_make_request(mydev.que, my_blkdev_make_req_fn);/* 注册 gendisk */mydev.gdisk->major = mydev.major;mydev.gdisk->first_minor = 0;mydev.gdisk->fops = &blkops;mydev.gdisk->queue = mydev.que;mydev.gdisk->private_data = &mydev;strcpy(mydev.gdisk->disk_name, DEVNAME); /* 给 myblkdev 最核心的组件 kobject 的 gdisk 取名字 */set_capacity(mydev.gdisk, DISKSIZE/SECTORSIZE); /* 设备容量以 sector 为单位 */add_disk(mydev.gdisk);goto over;alloc_que_fail:put_disk(mydev.gdisk);
alloc_disk_fail:kfree(mydev.buf);
alloc_buf_fail:unregister_blkdev(mydev.major, DEVNAME);
over:return ok;
}
(1)注册块设备,为设备申请主设备号并告知 kernel:块设备的名称;(2)申请模拟磁盘的内存空间,通过 kzalloc() 申请空间交给块设备管理;(3)申请 gendisk;(4)为块设备分配 request 队列,用以存放 I/O 请求;(5)初始化 gendisk 并将它注册到 kernel 模型中
大抵就是这些流程,需要注意的是,这里我采用的是不使用 I/O 调度的请求队列。我想大概讲一讲有 I/O 调度和没有的请求队列的区别,对于一些老旧的块设备,比如机械硬盘,有调度的请求队列能够利用请求的特性对其进行重新排序进而减少机械臂移动的次数,从而提高磁盘的工作性能;但是对于现在的 NAND 闪存 OR 固态硬盘,对请求进行排序可能并不会带来实质性的性能提升,反而会增加额外的开销。所以,具体选择哪种请求队列,需要根据实际情况进行判断
关于请求队列的详细用法我在下一小节再展开讲解,我们继续顺着注册模块的流程将驱动程序进行到底,讲解一下模块注销的相关步骤,
/* 卸载函数 */
static void __exit
my_blkdev_exit(void)
{printk(KERN_INFO "my_blkdev_exit\n");/* 删除 gendisk */del_gendisk(mydev.gdisk);/* 减少 gendisk 的引用计数 */put_disk(mydev.gdisk);/* 清空 request 队列 */blk_cleanup_queue(mydev.que);/* 注销块设备 */unregister_blkdev(mydev.major, DEVNAME);/* 释放空间 */kfree(mydev.buf);
}
和注册流程一样,申请了哪些东西,在注销时都要归还,包括 gendisk、请求队列等
S3 - 无 I/O 调度的请求队列
无 I/O 调度的请求队列绑定一个请求处理函数,我取名叫 my_blkdev_make_req_fn
,它接受 req_que 请求队列和 bio I/O 操作(页、长度和偏移)作为参数,
/* 制造请求函数 */
void
my_blkdev_make_req_fn(struct request_queue* req_que, struct bio* bio)
{int offset;struct bio_vec bvec;struct bvec_iter iter;unsigned long len = 0;/* 获取要操作的磁盘的起始地址(以字节为单位)*/offset = (bio->bi_iter.bi_sector) << 9;bio_for_each_segment(bvec, bio, iter) { /* 处理 bio 中的每个段 */char* ptr = page_address(bvec.bv_page) + bvec.bv_offset;len = bvec.bv_len;/* 是读操作 OR 写操作 */if(bio_data_dir(bio) == READ)memcpy(ptr, mydev.buf+offset, len);if(bio_data_dir(bio) == WRITE)memcpy(mydev.buf+offset, ptr, len);offset += len;}set_bit(BIO_UPTODATE, &bio->bi_flags);bio_endio(bio, 0);
}
宏 bio_for_each_segment
即是遍历 bio 中从当前偏移 offset 开始的未完成的数据段,而宏 bio_for_each_segment_all
是遍历 bio 中所有数据段,不论它们是否已经被完成。在宏的作用域内针对每个数据段做相应的读/写操作。最后,处理完 bio 的所有数据段后透过 bio_endio() 告诉 request_que 目前的 I/O 任务已经完成