一、文章阅读
本文基于内核版本2.6简单介绍:编写简单块设备驱动程序 http://kerneltravel.net/blog/2020/io_sys_szp_no5/
块设备也和字符设备一样可以通过/dev目录下的设备文件来访问,而网络设备不可以。此外块设备(例如磁盘)上能够容纳文件系统。
块设备驱动程序注册
磁盘注册
通过注册驱动程序我们获得了主设备号,但是现在还不能对磁盘进行操作。内核对于磁盘的表示是使用的gendisk结构体, gendisk结构中的许多成员必须由驱动程序进行初始化。
块设备操作
字符设备使用file_operations结构告诉系统对它们的操作接口。块设备使用类似的数据结构,在<linux/fs.h>中声明了结构block_device_ operations。同时块设备在VFS层也提供了统一的标准操作结构file_ operations。
file_ operations 与block device operations的结构类似,但不能混淆二者。file_ operations由VFS层用来与用户空间通信,其中的例程会调用block_ device_operations中的函数,以实现与块设备的通信。block_ device_ operations必须针对各种块设备分别实现,对设备的属性加以抽象,而在此之上建立的file_ operations, 使用同样的操作即可处理所有的块设备。
请求队列
块设备的读写请求放置在一个队列上,称之为请求队列。gendisk结构包括了一个指针,指向这个特定于设备的队列
请求处理
…
挂载设备
最后使用mount命令将设备挂载到/mnt目录下,就可以像其他设备一样进行数据的读写操作。 为什么一个设备已经被系统识别在/dev下,为什么不能直接访问,而需要继续mount。 原因在于,设备文件只能读取设备自身的一些基本信息。 如果读取内部数据的话,由于块设备支持文件系统,很多设备的文件系统并不一样没法直接读取。 必须得按照一定的格式去解析设备里的文件。而mount就按照你指定的格式去读取设备里的数据。
二、视频学习
(一)9.7工程实践-编写块设备驱动的基础(上)
扇区是磁盘的最小组成单元,通常是512个字节
磁盘的最小读写单元是一个扇区,扇区也称为块
文件系统层,包括常见的文件系统,以及虚拟文件系统层VFS。字符设备可以直接用应用程序打开。块设备不会在应用程序直接打开,而是要通过文件系统访问块设备。
通用块层:为了通用的块设备,建立了一个统一的模型,这一层的主要工作是接收文件系统层发出的磁盘请求,并最终向磁盘设备发出io请求,所以它隐藏了很多底层硬件块设备的细节,为块设备提供了一个抽象的模型。
IO调度层:接收通用块层发出的IO请求,缓存这些请求,然后根据IO调度算法来合并相邻的请求,然后调用驱动层提供的处理函数,发送这些io请求到硬件设备。这里io调度层有多种调度算法,比如电梯调度算法,CFQ调度算法,因为传统机械硬盘,他的物理结构特性,内核是不会简单的按照请求的顺序来提交给块设备的,io调度器会根据场景进行优化,来做一些合并或排序这样一些预操作。
块设备驱动:这一层就是实际的块设备驱动,比如磁盘驱动,光驱驱动,ssd驱动。一般来说,块设备驱动包含块设备的注册,打开,关闭以及具体的IO处理
对于来自通用块层的请求,在提交到块设备驱动的时候,我们需要构造一个请求,就是request,也要把这些request插入到块设备请求队列中,因此,文件系统有新的请求的时候就会把新的请求加入到请求队列中,这里通常会通过submit_io这个请求队列把来自文件系统的请求添加到请求队列中。只要请求队列不为空,队列中对应的这些块设备驱动程序就会从请求队列里面获取请求,然后把这些对应的请求发送到具体的块设备驱动里面进行处理
块设备子系统最重要的一个功能就是把文件系统层发来的数据请求进行处理,而这个处理过程中最核心的一个数据对象就是bio,bio有点类似网络的协议包,它在文件系统和块设备子系统中来回的游动,把要读的数据和要写的数据来回的搬移。通常来讲,bio代表来自文件系统的一个请求,每个bio都代表不同的访问上下文,可能来自不同的文件系统或应用程序,所以bio对象包含了块设备处理一个请求它所需要的所有的信息
块设备注册函数register_blkdev函数,这个函数有两个参数,第一个参数major参数是块设备使用的主设备号,第二个参数那么是设备名,如果第一个参数major=0,内核会自动分配一个新的主设备号,这个函数成功的话就会返回一个主设备号,如果返回负数表示失败
注销函数unregister_blkdev函数,它也有两个参数,传递的参数必须与register_blkdev一致
初始化请求队列blk_init_queue,这个函数为当前设备分配一个request请求队列,第一个参数rfn表示请求处理函数,它是一个函数指针,第二个参数lock是请求队列要使用的一个锁,通常我们的设备驱动里面需要初始化这个锁
注销请求队列blk_cleanup_queue
函数alloc_disk,动态分配一个gendisk数据结构,第一个参数minors表示这个磁盘使用的次设备号的数量,即磁盘分区的数量。
函数add_disk,该函数会像linux内核注册一个磁盘设备,这里和字符设备的cdev_add函数非常类似,都是向系统注册一个设备,这个函数的参数是alloc_disk函数分配的gendisk对象
函数del_gendisk,当磁盘设备不再使用时,我们用该函数释放gendisk对象。
当需要和块设备进行数据传输,或者对块设备发送iO请求的时候,内核需要发送请求对象bio到请求队列里面,此时就可以调用submit_bio这个接口函数来完成。该函数有两个参数,第一个参数rw表示发送请求是读还是写。第二个参数bio对象。
如果驱动程序需要单独调用submit_bio这个api的时候,驱动程序需要去负责填充bio。
(二)9.8 工程实践-块设备驱动程序分析(中)
eMMC (Embedded Multi Media Card)是MMC协会订立、主要针对手机或平板电脑等产品的内嵌式存储器标准规格。
(调用write函数)buffer→page cache(write函数结束,flush内核线程开始运行)→eMMC
三、实验
(一)代码实现
ramdisk-driver.c 代码如下:
#include <linux/fs.h>
#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/init.h>
#include <linux/vmalloc.h>
#include <linux/blkdev.h>
#include <linux/genhd.h>
#include <linux/errno.h>
#include <linux/hdreg.h>
#include <linux/version.h>
#define MY_DEVICE_NAME "myramdisk"
static int mybdrv_ma_no, diskmb = 256, disk_size; //定义ramdisk的大小是256MB
static char *ramdisk;
static struct gendisk *my_gd;
static spinlock_t lock;
static unsigned short sector_size = 512;
static struct request_queue *my_request_queue;
module_param_named(size, diskmb, int, 0);
static void my_request(struct request_queue *q)
{
struct request *rq;
int size, res = 0;
char *ptr;
unsigned nr_sectors, sector;
pr_info("start handle request\n");
rq = blk_fetch_request(q); //从请求队列中拉一个request出来
while (rq) {
nr_sectors = blk_rq_cur_sectors(rq); //这个请求需要多少个sector
sector = blk_rq_pos(rq);
//因为我们的块设备是一个简单的以连续内存作为存储的一个虚拟的块设备,所以可以通过一个简单的计算算出当前的request它的存储地址和大小
ptr = ramdisk + sector * sector_size;
size = nr_sectors * sector_size;
if ((ptr + size) > (ramdisk + disk_size)) {
pr_err("end of device\n");
goto done;
}
if (rq_data_dir(rq)) {
pr_info("writing at sector %d, %u sectors\n",
sector, nr_sectors);
memcpy(ptr, bio_data(rq->bio), size); //数据做一个拷贝,将bio_data请求的数据拷贝到ptr指针里面
} else {
pr_info("reading at sector %d, %u sectors\n",
sector, nr_sectors);
memcpy(bio_data(rq->bio), ptr, size);
}
done:
if (!__blk_end_request_cur(rq, res))
rq = blk_fetch_request(q);
}
pr_info("handle request done\n");
}
static int my_ioctl(struct block_device *bdev, fmode_t mode,
unsigned int cmd, unsigned long arg) //实现HDIO_GETGEO的ioctl请求,可以读出ramdisk的hd_geometry参数
{
long size;
struct hd_geometry geo;
pr_info("cmd=%d\n", cmd);
switch (cmd) {
case HDIO_GETGEO:
pr_info("HIT HDIO_GETGEO\n");
/*
* get geometry: we have to fake one...
*/
size = disk_size;
size &= ~0x3f;
geo.cylinders = size>>6;
geo.heads = 2;
geo.sectors = 16;
geo.start = 4;
if (copy_to_user((void __user *)arg, &geo, sizeof(geo)))
return -EFAULT; //向用户空间进行数据传递
return 0;
}
pr_warn("return -ENOTTY\n");
return -ENOTTY;
}
static const struct block_device_operations mybdrv_fops = {
.owner = THIS_MODULE,
.ioctl = my_ioctl,
};
static int __init my_init(void)
{
disk_size = diskmb * 1024 * 1024; //简单给ramdisk设定一个大小256MB。
spin_lock_init(&lock); //初始化一个spin_lock的锁
ramdisk = vmalloc(disk_size); //给ramdisk块设备来分配存储空间
if (!ramdisk)
return -ENOMEM;
my_request_queue = blk_init_queue(my_request, &lock); //初始化请求队列,第一个参数是函数指针,第二个参数lock是一个锁
if (!my_request_queue) {
vfree(ramdisk);
return -ENOMEM;
}
blk_queue_logical_block_size(my_request_queue, sector_size); //设置my_request_queue的大小为sector_size,即512
mybdrv_ma_no = register_blkdev(0, MY_DEVICE_NAME); //注册一个块设备,第一个参数为0的话就是让系统自动分配一个主设备号,第二个参数是设备名称,注册成功会返回一个主设备号
if (mybdrv_ma_no < 0) {
pr_err("Failed registering mybdrv, returned %d\n",
mybdrv_ma_no);
vfree(ramdisk);
return mybdrv_ma_no;
}
my_gd = alloc_disk(16); //分配一个gendisk
if (!my_gd) {
unregister_blkdev(mybdrv_ma_no, MY_DEVICE_NAME);
vfree(ramdisk);
return -ENOMEM;
}
//初始化
my_gd->major = mybdrv_ma_no; //主设备号
my_gd->first_minor = 0; //次设备号
my_gd->fops = &mybdrv_fops; //方法集
strcpy(my_gd->disk_name, MY_DEVICE_NAME); //给my_gd起名字
my_gd->queue = my_request_queue; //请求队列
set_capacity(my_gd, disk_size / sector_size); //设置容量
add_disk(my_gd); //把gendisk添加到Linux系统的块设备子系统里
pr_info("device successfully registered, Major No. = %d\n",
mybdrv_ma_no);
pr_info("Capacity of ram disk is: %d MB\n", diskmb);
return 0;
}
static void __exit my_exit(void)
{
del_gendisk(my_gd);
put_disk(my_gd);
unregister_blkdev(mybdrv_ma_no, MY_DEVICE_NAME);
pr_info("module successfully unloaded, Major No. = %d\n", mybdrv_ma_no);
blk_cleanup_queue(my_request_queue);
vfree(ramdisk);
}
module_init(my_init); //指定了驱动的入口,入口函数是my_init
module_exit(my_exit);
MODULE_AUTHOR("Benshushu");
MODULE_LICENSE("GPL v2");
运行结果:
ls /dev/ 查看创建的设备:
执行指令 sudo mkfs.ext4 /dev/myramdisk 格式化ramdisk:
查看dmesg:
每一次request处理的时候,入口会打印一个start handle request
reading at sector 229376, 8 sectors 这里229376是一个sector在整个ramdisk的一个起始的指针,相当于文件的一个指针,8是这次操作操作了8个sector
执行指令sudo mount /dev/myramdisk /mnt/,查看mnt目录:
执行指令:df ,查看系统挂载的分区:
依次执行如下指令:
sudo dmesg -c //清掉dmesg内容
sudo mkdir mytest //在mnt目录下创建一个mytest文件夹
dmesg
运行结果:
这些dmesg信息就是创建mytest目录的时候它发起的一些块设备的IO操作,是块设备驱动里面处理这些来自文件系统的请求所做的一些最底层的操作了
(二)源码学习
/*block_device数据结构用来抽象和描述一个块设备,Linux内核通常把block_device结构和虚拟文件系统VFS关联起来,block_device是虚拟文件系统和块设备子系统的一个粘合剂,用户程序一般是不会直接操作块设备的,通常是通过文件系统的接口来访问块设备*/
struct block_device {
......
dev_t bd_dev; //它是一个搜索键,dev_t为u32类型
struct inode* bd_inode;
struct super_block * bd_super;
struct gendisk * bd_disk;
struct request_queue * bd_queue;
......
} __randomize_layout;
/*linux内核把磁盘类设备,关于磁盘通用的那部分提取出来,使用一个gendisk这样一个数据结构来描述,所以它实际上也是一种抽象,这个gendisk数据结构,可以表示一个已经分区的磁盘,也可以表示一个未分区的磁盘*/
struct gendisk {
int major; //major表示这个块设备的主设备号,通常用来指定当前设备对应的哪个设备驱动。
int first_minor;
int minors; //first_minor和minors这两个是用来表示次设备号的一个范围,minors 表示当前gendisk对象所能包含的最大次设备个数
char disk_name[DISK_NAME_LEN]; //disk_name表示当前块设备对象名称
......
struct disk_part_tbl __rcu *part_tbl; //disk_part_tbl表示gendisk对象的分区表信息
......
const struct block_device_operations *fops; //fops表示块设备的一组文件操作集合
struct request_queue *queue; //queue表示当前gendisk对象所代表的块设备的io请求队列
......
};
/*block_device_operations是块设备驱动的操作方法集*/
struct block_device_operations {
int (*open) (struct block_device *, fmode_t); //open打开块设备
void (*release) (struct gendisk *, fmode_t); //release关闭块设备
......
int (*ioctl) (struct block_device *, fmode_t, unsigned, unsigned long); //ioctl来实现块设备的标准命令
......
int (*media_changed) (struct gendisk *); //media_changed检查驱动器的介质是否改变
......
int (*getgeo)(struct block_device *, struct hd_geometry *); //getgeo获取磁盘器的一些信息
......
};
struct request_queue {
/*
* Together with queue_head for cacheline sharing
*/
struct list_head queue_head; //待处理请求的链表,请求队列中的请求用链表组织在一起
struct request *last_merge; //指向队列中首先可能合并的请求描述符
struct elevator_queue *elevator; //指向elevator对象的指针(电梯算法)
int nr_rqs[2]; /* # allocated [a]sync rqs */
int nr_rqs_elvpriv; /* # allocated rqs w/ elvpriv */
......
/*
* If blkcg is not used, @q->root_rl serves all requests. If blkcg
* is used, root blkg allocates from @q->root_rl and all other
* blkgs from their own blkg->rl. Which one to use should be
* determined using bio_request_list().
*/
struct request_list root_rl; //为分配请求描述符所使用的数据结构
request_fn_proc *request_fn; //处理队列中请求的函数接口
make_request_fn *make_request_fn; //将一个新请求插入请求队列时调用的方法
poll_q_fn *poll_fn;
prep_rq_fn *prep_rq_fn; //该方法把这个处理请求的
命令发送给硬件设备
......
spinlock_t __queue_lock; //请求队列锁
spinlock_t *queue_lock; //指向请求队列锁的指针
......
unsigned long nr_requests; //请求队列中允许的最大请求数
......
struct queue_limits limits; //队列的其他限制
};
/*块层和较低层的I/O的主要单元(即驱动程序和堆叠驱动程序)*/
struct bio {
struct bio *bi_next; /* 请求队列link */
struct gendisk *bi_disk;
unsigned int bi_opf; /* 底部位要求q标志,顶部位要求REQ_OP。使用访问器。*/
unsigned short bi_flags; /* 状态等和bvec池号 */
unsigned short bi_ioprio;
unsigned short bi_write_hint;
blk_status_t bi_status;
u8 bi_partno;
/* 执行物理地址合并后,此BIO中的段数。 */
unsigned int bi_phys_segments;
/* 为了跟踪最大段大小,我们考虑了这个bio中第一个和最后一个可合并段的大小。 */
unsigned int bi_seg_front_size;
unsigned int bi_seg_back_size;
......
unsigned short bi_vcnt; /* 多少个 bio_vec's */
/* 所有以bi_max_vecs开头的内容将由bio_reset()保存。 */
unsigned short bi_max_vecs; /* 我们能坚持的最大限度 */
atomic_t __bi_cnt; /* pin count */
struct bio_vec *bi_io_vec; /* 实际的vec列表 */
struct bio_set *bi_pool;
/* 我们可以在bio的末尾内联一些vecs,以避免对少量的bio_vecs进行双重分配。显然,这个成员必须被保留在bio的最后。 */
struct bio_vec bi_inline_vecs[0];
};
四、问答
1.块设备和字符设备的区别
字符设备:提供连续的数据流,应用程序可以顺序读取,通常不支持随机存取。相反,此类设备支持按字节自/字符来读写数据。举例来说,百键盘、串口、调制解调器都是典型的字符设备。
块设备:应用程序可以随机访问设备数据,程序可自行确定读取数据的位置。硬盘、软盘、CD-ROM驱动器和闪存都是典型度的块设备,应用程序可以寻址磁盘上的任何位置,并由此读取数据。此外,数据的读写知只能以块(通常是512B)的倍数进行。与字符设备不同,块设备并不支持基于字符的寻址。
总结:这两种类型的设备的根本区别在于它们是否可以被随机访问。字符设备只能顺序读取道,块设备可以随机读取。
2.什么是驱动?
(1)驱动程序是软件和硬件连接的桥梁。
(2)驱动程序是添加到操作系统中的特殊程序。
(3)驱动程序是一种可以使计算机和设备进行通信的特殊程序。
3.为什么需要驱动?
(1)软件系统无法直接识别要接入的设备是什么,有什么功能。
(2)硬件设备只能理解电子信号,无法直接理解软件系统下发的命令。
4.驱动是谁做的?
驱动程序是硬件厂商根据操作系统编写的配置文件。
5.驱动的作用
驱动是软件和硬件的桥梁
(1)将硬件本身的功能告诉操作系统,完成硬件设备电子信号与操作系统及软件的高级编程语言(java等)之间的相互翻译。
(2)将操作系统的标准指令传达给硬件设备
(3)当操作系统需要使用某个硬件时,比如让声卡播放音乐,它会先发送相应指令到声卡驱动程序,声卡驱动程序接收到后,马上将其翻译成声卡才能听懂的电子信号命令,从而让声卡播放音乐。
6.一个进程在某个磁盘文件上发出一个read()系统调用,内核对进程请求回应的一般步骤是?
(1)read() 调用一个适当的 VFS 函数,将文件描述符和文件内的偏移量传递给它。
虚拟文件系统位于块设备处理体系结构的上层,提供一个通用的文件系统模型,Linux 支持的所有系统均采用该模型。
(2)VFS 函数确定所请求的数据是否已经存在,如有必要,它决定如何执行 read 操作。
有时候没有必要访问磁盘上的数据,因为内核将大多数最近从快速设备读出或写入其中的数据保存在 RAM 中。
(3)假设内核从块设备读数据,那么它就必须确定数据的物理位置。因此,内核依赖映射层执行下面步骤:
a. 内核确定该文件所在文件系统的块大小,并根据文件块的大小计算所请求数据的长度。
本质上,文件被看作拆分成许多块,因此内核确定请求数据所在的块号(文件开始位置的相对索引)。
b. 映射层调用一个具体文件系统的函数,它访问文件的磁盘节点,然后根据逻辑块号确定所请求数据在磁盘上的位置。
因为磁盘也被看作拆分成许多块,所以内核必须确定所请求数据的块对应的号。
由于一个文件可能存储子磁盘上的不连续块中,因此存放在磁盘索引节点中的数据结构将每个文件块号映射为一个逻辑块号。
(4)现在内核可以对块设备发出读请求。内核利用通用块层启动 I/O 操作来传送所请求的数据。
一般,每个 I/O 操作只针对磁盘上一组连续操作的块。
由于请求的数据不必位于相邻的块中,所以通用层可能启动几次 I/O 操作。
每次 I/O 操作是由一个“块 I/O”结构描述符,它收集底层组件所需要的所有信息以满足所发出的请求。
(5)通用块层下面的“I/O 调度程序”根据预先定义的内核策略将待处理的 I/O 数据传送请求进行归类。
调度程序的作用是把物理介质上相邻的数据请求聚集在一起。
(6)最后,块设备驱动程序向磁盘控制器的硬件接口发出适当的命令,从而进行实际的数据传送。