Sampleblk 是一个用于学习目的的 Linux 块设备驱动项目。其中 day1 的源代码实现了一个最简的块设备驱动,源代码只有 200 多行。本文主要围绕这些源代码,讨论 Linux 块设备驱动开发的基本知识。html
开发 Linux 驱动须要作一系列的开发环境准备工做。Sampleblk 驱动是在 Linux 4.6.0 下开发和调试的。因为在不一样 Linux 内核版本的通用 block 层的 API 有很大变化,这个驱动在其它内核版本编译可能会有问题。开发,编译,调试内核模块须要先准备内核开发环境,编译内核源代码。这些基础的内容互联网上随处可得,本文再也不赘述。linux
此外,开发 Linux 设备驱动的经典书籍当属 Device Drivers, Third Edition 简称 LDD3。该书籍是免费的,能够自由下载并按照其规定的 License 从新分发。git
Linux 驱动模块的开发遵照 Linux 为模块开发者提供的基本框架和 API。LDD3 的 hello world 模块提供了写一个最简内核模块的例子。而 Sampleblk 块驱动的模块与之相似,实现了 Linux 内核模块所必需的模块初始化和退出函数,github
module_init(sampleblk_init); module_exit(sampleblk_exit);
与 hello world 模块不一样的是,Sampleblk 驱动的初始化和退出函数要实现一个块设备驱动程序所必需的基本功能。本节主要针对这部份内容作详细说明。数组
概括起来,sampleblk_init
函数为完成块设备驱动的初始化,主要作了如下几件事情,bash
调用 register_blkdev
完成 major number 的分配和注册,函数原型以下,markdown
int register_blkdev(unsigned int major, const char *name);
Linux 内核为块设备驱动维护了一个全局哈希表 major_names
这个哈希表的 bucket 是 [0..255] 的整数索引的指向 blk_major_name
的结构指针数组。数据结构
static struct blk_major_name { struct blk_major_name *next; int major; char name[16]; } *major_names[BLKDEV_MAJOR_HASH_SIZE];
而 register_blkdev
的 major
参数不为 0 时,其实现就尝试在这个哈希表中寻找指定的 major
对应的 bucket 里的空闲指针,分配一个新的blk_major_name
,按照指定参数初始化 major
和 name
。假如指定的 major
已经被别人占用(指针非空),则表示 major
号冲突,反回错误。框架
当 major
参数为 0 时,则由内核从 [1..255] 的整数范围内分配一个未使用的反回给调用者。所以,虽然 Linux 内核的主设备号 (Major Number) 是 12 位的,不指定 major
时,仍旧从 [1..255] 范围内分配。异步
Sampleblk 驱动经过指定 major
为 0,让内核为其分配和注册一个未使用的主设备号,其代码以下,
sampleblk_major = register_blkdev(0, "sampleblk"); if (sampleblk_major < 0) return sampleblk_major;
一般,全部 Linux 内核驱动都会声明一个数据结构来存储驱动须要频繁访问的状态信息。这里,咱们为 Sampleblk 驱动也声明了一个,
struct sampleblk_dev { int minor; spinlock_t lock; struct request_queue *queue; struct gendisk *disk; ssize_t size; void *data; };
为了简化实现和方便调试,Sampleblk 驱动暂时只支持一个 minor 设备号,而且能够用如下全局变量访问,
struct sampleblk_dev *sampleblk_dev = NULL;
下面的代码分配了 sampleblk_dev
结构,而且给结构的成员作了初始化,
sampleblk_dev = kzalloc(sizeof(struct sampleblk_dev), GFP_KERNEL); if (!sampleblk_dev) { rv = -ENOMEM; goto fail; } sampleblk_dev->size = sampleblk_sect_size * sampleblk_nsects; sampleblk_dev->data = vmalloc(sampleblk_dev->size); if (!sampleblk_dev->data) { rv = -ENOMEM; goto fail_dev; } sampleblk_dev->minor = minor;
使用 blk_init_queue
初始化 Request Queue 须要先声明一个所谓的策略 (Strategy) 回调和保护该 Request Queue 的自旋锁。而后将该策略回调的函数指针和自旋锁指针作为参数传递给该函数。
在 Sampleblk 驱动里,就是 sampleblk_request
函数和 sampleblk_dev->lock
,
spin_lock_init(&sampleblk_dev->lock); sampleblk_dev->queue = blk_init_queue(sampleblk_request, &sampleblk_dev->lock); if (!sampleblk_dev->queue) { rv = -ENOMEM; goto fail_data; }
策略函数 sampleblk_request
用于执行块设备的 read 和 write IO 操做,其主要的入口参数就是 Request Queue 结构:struct request_queue
。关于策略函数的具体实现咱们稍后介绍。
当执行 blk_init_queue
时,其内部实现会作以下的处理,
struct request_queue
结构。struct request_queue
结构。对调用者来讲,其中如下部分的初始化格外重要, blk_init_queue
指定的策略函数指针会赋值给 struct request_queue
的 request_fn
成员。blk_init_queue
指定的自旋锁指针会赋值给 struct request_queue
的 queue_lock
成员。request_queue
关联的 IO 调度器的初始化。Linux 内核提供了多种分配和初始化 Request Queue 的方法,
blk_mq_init_queue
主要用于使用多队列技术的块设备驱动blk_alloc_queue
和 blk_queue_make_request
主要用于绕开内核支持的 IO 调度器的合并和排序,使用自定义的实现。blk_init_queue
则使用内核支持的 IO 调度器,驱动只专一于策略函数的实现。Sampleblk 驱动属于第三种状况。这里再次强调一下:若是块设备驱动须要使用标准的 IO 调度器对 IO 请求进行合并或者排序时,必需使用 blk_init_queue
来分配和初始化 Request Queue.
Linux 的块设备操做函数表 block_device_operations
定义在 include/linux/blkdev.h
文件中。块设备驱动能够经过定义这个操做函数表来实现对标准块设备驱动操做函数的定制。
若是驱动没有实现这个操做表定义的方法,Linux 块设备层的代码也会按照块设备公共层的代码缺省的行为工做。
Sampleblk 驱动虽然声明了本身的 open
, release
, ioctl
方法,但这些方法对应的驱动函数内都没有作实质工做。所以实际的块设备操做时的行为是由块设备公共层来实现的,
static const struct block_device_operations sampleblk_fops = { .owner = THIS_MODULE, .open = sampleblk_open, .release = sampleblk_release, .ioctl = sampleblk_ioctl, };
Linux 内核使用 struct gendisk
来抽象和表示一个磁盘。也就是说,块设备驱动要支持正常的块设备操做,必需分配和初始化一个 struct gendisk
。
首先,使用 alloc_disk
分配一个 struct gendisk
,
disk = alloc_disk(minor); if (!disk) { rv = -ENOMEM; goto fail_queue; } sampleblk_dev->disk = disk;
而后,初始化 struct gendisk
的重要成员,尤为是块设备操做函数表,Rquest Queue,和容量设置。最终调用 add_disk
来让磁盘在系统内可见,触发磁盘热插拔的 uevent。
disk->major = sampleblk_major; disk->first_minor = minor; disk->fops = &sampleblk_fops; disk->private_data = sampleblk_dev; disk->queue = sampleblk_dev->queue; sprintf(disk->disk_name, "sampleblk%d", minor); set_capacity(disk, sampleblk_nsects); add_disk(disk);
这是个 sampleblk_init
的逆过程,
删除磁盘
del_gendisk
是 add_disk
的逆过程,让磁盘在系统中再也不可见,触发热插拔 uevent。
del_gendisk(sampleblk_dev->disk);
中止并释放块设备 IO 请求队列
blk_cleanup_queue
是 blk_init_queue
的逆过程,但其在释放 struct request_queue
以前,要把待处理的 IO 请求都处理掉。
blk_cleanup_queue(sampleblk_dev->queue);
当 blk_cleanup_queue
把全部 IO 请求所有处理完时,会标记这个队列立刻要被释放,这样能够阻止 blk_run_queue
继续调用块驱动的策略函数,继续执行 IO 请求。Linux 3.8 以前,内核在 blk_run_queue
和 blk_cleanup_queue
同时执行时有严重 bug。最近在一个有磁盘 IO 时的 Surprise Remove 的压力测试中发现了这个 bug (老实说,有些惊讶,这个 bug 存在这么久一直没人发现)。
释放磁盘
put_disk
是 alloc_disk
的逆过程。这里 gendisk
对应的 kobject
引用计数变为零,完全释放掉 gendisk
。
put_disk(sampleblk_dev->disk);
释放数据区
vfree
是 vmalloc
的逆过程。
vfree(sampleblk_dev->data);
释放驱动全局数据结构。
free
是 kzalloc
的逆过程。
kfree(sampleblk_dev);
注销块设备。
unregister_blkdev
是 register_blkdev
的逆过程。
unregister_blkdev(sampleblk_major, “sampleblk”);
理解块设备驱动的策略函数实现,必需先对 Linux IO 栈的关键数据结构有所了解。
struct request_queue
块设备驱动待处理的 IO 请求队列结构。若是该队列是利用blk_init_queue
分配和初始化的,则该队里内的 IO 请求( struct request
)须要通过 IO 调度器的处理(排序或合并),由 blk_queue_bio
触发。
当块设备策略驱动函数被调用时,request
是经过其 queuelist
成员连接在 struct request_queue
的 queue_head
链表里的。一个 IO 申请队列上会有不少个 request
结构。
struct bio
一个 bio
逻辑上表明了上层某个任务对通用块设备层发起的 IO 请求。来自不一样应用,不一样上下文的,不一样线程的 IO 请求在块设备驱动层被封装成不一样的 bio
数据结构。
同一个 bio
结构的数据是由块设备上从起始扇区开始的物理连续扇区组成的。因为在块设备上连续的物理扇区在内存中没法保证是物理内存连续的,所以才有了段 (Segment)的概念。在 Segment 内部的块设备的扇区是物理内存连续的,但 Segment 之间却不能保证物理内存的连续性。Segment 长度不会超过内存页大小,并且老是扇区大小的整数倍。
下图清晰的展示了扇区 (Sector),块 (Block) 和段 (Segment) 在内存页 (Page) 内部的布局,以及它们之间的关系(注:图截取自 Understand Linux Kernel 第三版,版权归原做者全部),
所以,一个 Segment 能够用 [page, offset, len] 来惟一肯定。一个 bio
结构能够包含多个 Segment。而 bio
结构经过指向 Segment 的指针数组来表示了这种一对多关系。
在 struct bio
中,成员 bi_io_vec
就是前文所述的“指向 Segment 的指针数组” 的基地址,而每一个数组的元素就是指向 struct bio_vec
的指针。
struct bio { [...snipped..] struct bio_vec *bi_io_vec; /* the actual vec list */ [...snipped..] }
而 struct bio_vec
就是描述一个 Segment 的数据结构,
struct bio_vec { struct page *bv_page; /* Segment 所在的物理页的 struct page 结构指针 */ unsigned int bv_len; /* Segment 长度,扇区整数倍 */ unsigned int bv_offset; /* Segment 在物理页内起始的偏移地址 */ };
在 struct bio
中的另外一个成员 bi_vcnt
用来描述这个 bio
里有多少个 Segment,即指针数组的元素个数。一个 bio
最多包含的 Segment/Page 数是由以下内核宏定义决定的,
#define BIO_MAX_PAGES 256
多个 bio
结构能够经过成员 bi_next
连接成一个链表。bio
链表能够是某个作 IO 的任务 task_struct
成员 bio_list
所维护的一个链表。也能够是某个 struct request
所属的一个链表(下节内容)。
下图展示了 bio
结构经过 bi_next
连接组成的链表。其中的每一个 bio
结构和 Segment/Page 存在一对多关系 (注:图截取自 Professional Linux Kernel Architecture,版权归原做者全部),
struct request
一个 request
逻辑上表明了块设备驱动层收到的 IO 请求。该 IO 请求的数据在块设备上是从起始扇区开始的物理连续扇区组成的。
在 struct request
里能够包含不少个 struct bio
,主要是经过 bio
结构的 bi_next
连接成一个链表。这个链表的第一个 bio
结构,则由 struct request
的 bio
成员指向。
而链表的尾部则由 biotail
成员指向。
通用块设备层接收到的来自不一样线程的 bio
后,一般根据状况选择以下两种方案之一,
将 bio
合并入已有的 request
blk_queue_bio
会调用 IO 调度器作 IO 的合并 (merge)。多个 bio
可能所以被合并到同一个 request
结构里,组成一个 request
结构内部的 bio
结构链表。因为每一个 bio
结构都来自不一样的任务,所以 IO 请求合并只能在 request
结构层面经过链表插入排序完成,原有的 bio
结构内部不会被修改。
分配新的 request
若是 bio
不能被合并到已有的 request
里,通用块设备层就会为这个 bio
构造一个新 request
而后插入到 IO 调度器内部的队列里。待上层任务经过 blk_finish_plug
来触发 blk_run_queue
动做,块设备驱动的策略函数 request_fn
会触发 IO 调度器的排序操做,将 request
排序插入块设备驱动的 IO 请求队列。
不论以上哪一种状况,通用块设备的代码将会调用块驱动程序注册在 request_queue
的 request_fn
回调,这个回调里最终会将合并或者排序后的 request
交由驱动的底层函数来作 IO 操做。
request_fn
如前所述,当块设备驱动使用 blk_run_queue
来分配和初始化 request_queue
时,这个函数也须要驱动指定自定义的策略函数 request_fn
和所需的自旋锁 queue_lock
。驱动实现本身的 request_fn
时,须要了解以下特色,
当通用块层代码调用 request_fn
时,内核已经拿了这个 request_queue
的 queue_lock
。所以,此时的上下文是 atomic 上下文。在驱动的策略函数退出 queue_lock
以前,须要遵照内核在 atomic 上下文的约束条件。
进入驱动策略函数时,通用块设备层代码可能会同时访问 request_queue
。为了减小在 request_queue
的 queue_lock
上的锁竞争, 块驱动策略函数应该尽早退出 queue_lock
,而后在策略函数返回前从新拿到锁。
策略函数是异步执行的,不处在用户态进程所对应的内核上下文。所以实现时不能假设策略函数运行在用户进程的内核上下文中。
Sampleblk 的策略函数是 sampleblk_request,经过 blk_init_queue
注册到了 request_queue
的 request_fn
成员上。
static void sampleblk_request(struct request_queue *q) { struct request *rq = NULL; int rv = 0; uint64_t pos = 0; ssize_t size = 0; struct bio_vec bvec; struct req_iterator iter; void *kaddr = NULL; while ((rq = blk_fetch_request(q)) != NULL) { spin_unlock_irq(q->queue_lock); if (rq->cmd_type != REQ_TYPE_FS) { rv = -EIO; goto skip; } BUG_ON(sampleblk_dev != rq->rq_disk->private_data); pos = blk_rq_pos(rq) * sampleblk_sect_size; size = blk_rq_bytes(rq); if ((pos + size > sampleblk_dev->size)) { pr_crit("sampleblk: Beyond-end write (%llu %zx)\n", pos, size); rv = -EIO; goto skip; } rq_for_each_segment(bvec, rq, iter) { kaddr = kmap(bvec.bv_page); rv = sampleblk_handle_io(sampleblk_dev, pos, bvec.bv_len, kaddr + bvec.bv_offset, rq_data_dir(rq)); if (rv < 0) goto skip; pos += bvec.bv_len; kunmap(bvec.bv_page); } skip: blk_end_request_all(rq, rv); spin_lock_irq(q->queue_lock); } }
策略函数 sampleblk_request
的实现逻辑以下,
blk_fetch_request
循环获取队列中每个待处理 request
。 blk_fetch_request
能够返回 struct request_queue
的 queue_head
队列的第一个 request
的指针。而后再调用 blk_dequeue_request
从队列里摘除这个 request
。request
,当即退出锁 queue_lock
,但处理完每一个 request
,须要再次得到 queue_lock
。REQ_TYPE_FS
用来检查是不是一个来自文件系统的 request
。本驱动不支持非文件系统 request
。blk_rq_pos
能够返回 request
的起始扇区号,而 blk_rq_bytes
返回整个 request
的字节数,应该是扇区的整数倍。rq_for_each_segment
这个宏定义用来循环迭代遍历一个 request
里的每个 Segment: 即 struct bio_vec
。注意,每一个 Segment 即 bio_vec
都是以 blk_rq_pos
为起始扇区,物理扇区连续的的。Segment 之间只是物理内存不保证连续而已。struct bio_vec
均可以利用 kmap 来得到这个 Segment 所在页的虚拟地址。利用 bv_offset
和 bv_len
能够进一步知道这个 segment 的确切页内偏移和具体长度。rq_data_dir
能够获知这个 request
的请求是 read 仍是 write。request
以后,必需调用 blk_end_request_all
让块通用层代码作后续处理。驱动函数 sampleblk_handle_io
把一个 request
的每一个 segment 都作一次驱动层面的 IO 操做。调用该驱动函数前,起始扇区地址 pos
,长度 bv_len
, 起始扇区虚拟内存地址 kaddr + bvec.bv_offset
,和 read/write 都作为参数准备好。因为 Sampleblk 驱动只是一个 ramdisk 驱动,所以,每一个 segment 的 IO 操做都是 memcpy
来实现的,
/* * Do an I/O operation for each segment */ static int sampleblk_handle_io(struct sampleblk_dev *sampleblk_dev, uint64_t pos, ssize_t size, void *buffer, int write) { if (write) memcpy(sampleblk_dev->data + pos, buffer, size); else memcpy(buffer, sampleblk_dev->data + pos, size); return 0; }
首先,须要下载内核源代码,编译和安装内核,用新内核启动。
因为本驱动是在 Linux 4.6.0 上开发和调试的,并且块设备驱动内核函数不一样内核版本变更很大,最好去下载 Linux mainline 源代码,而后 git checkout 到版本 4.6.0 上编译内核。编译和安装内核的具体步骤网上有不少介绍,这里请读者自行解决。
编译好内核后,在内核目录,编译驱动模块。
$ make M=/ws/lktm/drivers/block/sampleblk/day1
驱动编译成功,加载内核模块
$ sudo insmod /ws/lktm/drivers/block/sampleblk/day1/sampleblk.ko
驱动加载成功后,使用 crash 工具,能够查看 struct smapleblk_dev
的内容,
crash7> mod -s sampleblk /home/yango/ws/lktm/drivers/block/sampleblk/day1/sampleblk.ko
MODULE NAME SIZE OBJECT FILE
ffffffffa03bb580 sampleblk 2681 /home/yango/ws/lktm/drivers/block/sampleblk/day1/sampleblk.ko
crash7> p *sampleblk_dev
$4 = {
minor = 1,
lock = {
{
rlock = {
raw_lock = {
val = {
counter = 0
}
}
}
}
},
queue = 0xffff880034ef9200,
disk = 0xffff880000887000,
size = 524288,
data = 0xffffc90001a5c000
}
注:关于 Linux Crash 的使用,请参考延伸阅读。
问题:把驱动的 sampleblk_request
函数实现所有删除,从新编译和加载内核模块。而后用 rmmod 卸载模块,卸载会失败, 内核报告模块正在被使用。
使用 strace
能够观察到 /sys/module/sampleblk/refcnt
非零,即模块正在被使用。
$ strace rmmod sampleblk execve("/usr/sbin/rmmod", ["rmmod", "sampleblk"], [/* 26 vars */]) = 0 ................[snipped].......................... openat(AT_FDCWD, "/sys/module/sampleblk/holders", O_RDONLY|O_NONBLOCK|O_DIRECTORY|O_CLOEXEC) = 3 getdents(3, /* 2 entries */, 32768) = 48 getdents(3, /* 0 entries */, 32768) = 0 close(3) = 0 open("/sys/module/sampleblk/refcnt", O_RDONLY|O_CLOEXEC) = 3 /* 显示引用数为 3 */ read(3, "1\n", 31) = 2 read(3, "", 29) = 0 close(3) = 0 write(2, "rmmod: ERROR: Module sampleblk i"..., 41rmmod: ERROR: Module sampleblk is in use ) = 41 exit_group(1) = ? +++ exited with 1 +++
若是用 lsmod
命令查看,能够看到模块的引用计数确实是 3,但没有显示引用者的名字。通常状况下,只有内核模块间的相互引用才有引用模块的名字,因此没有引用者的名字,那么引用者来自用户空间的进程。
那么,到底是谁在使用 sampleblk 这个刚刚加载的驱动呢?利用 module:module_get
tracepoint,就能够获得答案了。从新启动内核,在加载模块前,运行 tpoint
命令。而后,再运行 insmod
来加载模块。
$ sudo ./tpoint module:module_get Tracing module:module_get. Ctrl-C to end. systemd-udevd-2986 [000] .... 196.382796: module_get: sampleblk call_site=get_disk refcnt=2 systemd-udevd-2986 [000] .... 196.383071: module_get: sampleblk call_site=get_disk refcnt=3
能够看到,原来是 systemd 的 udevd 进程在使用 sampleblk 设备。若是熟悉 udevd 的人可能就会当即恍然大悟,由于 udevd 负责侦听系统中全部设备的热插拔事件,并负责根据预约义规则来对新设备执行一系列操做。而 sampleblk 驱动在调用 add_disk
时,kobject
层的代码会向用户态的 udevd 发送热插拔的 uevent
,所以 udevd 会打开块设备,作相关的操做。
利用 crash 命令,能够很容易找到是哪一个进程在打开 sampleblk 设备,
crash> foreach files -R /dev/sampleblk PID: 4084 TASK: ffff88000684d700 CPU: 0 COMMAND: "systemd-udevd" ROOT: / CWD: / FD FILE DENTRY INODE TYPE PATH 8 ffff88000691ad00 ffff88001ffc0600 ffff8800391ada08 BLK /dev/sampleblk1 9 ffff880006918e00 ffff88001ffc0600 ffff8800391ada08 BLK /dev/sampleblk1
因为 sampleblk_request
函数实现被删除,则 udevd
发送的 IO 操做没法被 sampleblk 设备驱动完成,所以 udevd 陷入到长期的阻塞等待中,直到超时返回错误,释放设备。上述分析能够从系统的消息日志中被证明,
messages:Apr 23 03:11:51 localhost systemd-udevd: worker [2466] /devices/virtual/block/sampleblk1 is taking a long time messages:Apr 23 03:12:02 localhost systemd-udevd: worker [2466] /devices/virtual/block/sampleblk1 timeout; kill it messages:Apr 23 03:12:02 localhost systemd-udevd: seq 4313 '/devices/virtual/block/sampleblk1' killed
注:tpoint
是一个基于 ftrace 的开源的 bash 脚本工具,能够直接下载运行使用。它是 Brendan Gregg 在 github 上的开源项目,前文已经给出了项目的连接。
从新把删除的 sampleblk_request
函数源码加回去,则这个问题就不会存在。由于 udevd 能够很快结束对 sampleblk 设备的访问。
虽然 Sampleblk 块驱动只有 200 行源码,但已经能够看成 ramdisk 来使用,在其上能够建立文件系统,
$ sudo mkfs.ext4 /dev/sampleblk1
文件系统建立成功后,mount
文件系统,并建立一个空文件 a。能够看到,均可以正常运行。
$sudo mount /dev/sampleblk1 /mnt $touch a
至此,sampleblk 作为 ramdisk 的最基本功能已经实验完毕。