不同于字设备,高效的块驱动对于性能至关重要,它是核心内存和二级存储之间的管道,所以块层的设计必定围绕性能。
找源码的,请直接往下翻(在3.10.0版本可编译使用)。
大部分块驱动采取的第一步是注册它们自己到内核. 这个任务的函数是 register_blkdev(在
int register_blkdev(unsigned int major, const char *name)
参数是设备要使用的主编号和关联的名子(内核将显示它在 /proc/devices). 如果 major 传递为 0, 内核分配一个新的主编号并且返回它给调用者,用于临时性的设备注册,如果自己选择的话该值在1-255之间。参数name在系统中必须唯一。 执行成功返回0,否则返回负的返回值。
对应的注销函数是:
void unregister_blkdev(unsigned int major, const char *name)
注册的作用是:分配一个动态主编号,并且在/proc/devices创建一个入口。
register_blkdev 可用来获得一个主编号, 但没有磁盘驱动器对系统可用 。需要注册一个磁盘,先来看下块设备的操作集合。
块设备上是 struct block_device_operations(字符设备通过 file_ 操作结构), 定义在
struct block_device_operations {
int (*open) (struct block_device *, fmode_t);
void (*release) (struct gendisk *, fmode_t);
int (*rw_page)(struct block_device *, sector_t, struct page *, bool);
int (*ioctl) (struct block_device *, fmode_t, unsigned, unsigned long);
int (*compat_ioctl) (struct block_device *, fmode_t, unsigned, unsigned long);
unsigned int (*check_events) (struct gendisk *disk,
unsigned int clearing);
/* ->media_changed() is DEPRECATED, use ->check_events() instead */
int (*media_changed) (struct gendisk *);
void (*unlock_native_capacity) (struct gendisk *);
int (*revalidate_disk) (struct gendisk *);
int (*getgeo)(struct block_device *, struct hd_geometry *);
/* this callback is with swap_lock and sometimes page table lock held */
void (*swap_slot_free_notify) (struct block_device *, unsigned long);
struct module *owner;
const struct pr_ops *pr_ops;
};
其中有打开open/关闭release,ioctl系统调用,media_changed函数用来被内核调用检查介质,一般用于可移除设备例如USB,其参数是gendisk表示一个设备。revalidate_disk函数用来响应设备介质改变,让驱动进行需要的工作来准备新介质。倒数第二个参数owner是个指针,它指向拥有这个结构的模块。
注意该结构体中没有实际读写函数,这些操作由请求函数处理,后面会介绍。
再来看下磁盘的数据机构gendisk (定义于
struct gendisk {
/* major, first_minor and minors are input parameters only,
* don't use directly. Use disk_devt() and disk_max_parts().
*/
int major; /* major number of driver */
int first_minor;
int minors; /* maximum number of minors, =1 for
* disks that can't be partitioned. */
char disk_name[DISK_NAME_LEN]; /* name of major driver */
char *(*devnode)(struct gendisk *gd, umode_t *mode);
unsigned int events; /* supported events */
unsigned int async_events; /* async events, subset of all */
/* Array of pointers to partitions indexed by partno.
* Protected with matching bdev lock but stat and other
* non-critical accesses use RCU. Always access through
* helpers.
*/
struct disk_part_tbl __rcu *part_tbl;
struct hd_struct part0;
const struct block_device_operations *fops;
struct request_queue *queue;
void *private_data;
int flags;
struct rw_semaphore lookup_sem;
struct kobject *slave_dir;
struct timer_rand_state *random;
atomic_t sync_io; /* RAID */
struct disk_events *ev;
#ifdef CONFIG_BLK_DEV_INTEGRITY
struct kobject integrity_kobj;
#endif /* CONFIG_BLK_DEV_INTEGRITY */
int node_id;
struct badblocks *bb;
struct lockdep_map lockdep_map;
};
该结构体中有设备号、次编号(标记不同分区)、磁盘驱动器名字(出现在/proc/partitions和sysfs中)、 设备的操作集(block_device_operations)、设备IO请求结构、驱动器状态、驱动器容量、驱动内部数据指针private_data等。
和gendisk相关的函数有,alloc_disk函数用来分配一个磁盘,del_gendisk用来减掉一个对结构体的引用。
分配一个 gendisk 结构不能使系统可使用这个磁盘。还必须初始化这个结构并且调用 add_disk。一旦调用add_disk后, 这个磁盘是"活的"并且它的方法可被在任何时间被调用了,内核这个时候就可以来摸设备了。实际上第一个调用将可能发生, 也可能在 add_disk 函数返回之前; 内核将读前几个字节以试图找到一个分区表。在驱动被完全初始化并且准备好之前,不要调用add_disk来响应对磁盘的请求。下面来看初始话。
这里初始化分为两部分,第一部分是设备的结构体数据,另一个是磁盘的数据结构体gendisk。
本文用sbull_dev数据结构来表示设备,一个内部结构描述如下(摘自ldd3原著):
struct sbull_dev {
int size; /* Device size in sectors */
u8 *data; /* The data array */
short users; /* How many users */
short media_change; /* Flag a media change? */
spinlock_t lock; /* For mutual exclusion */
struct request_queue *queue; /* The device request queue */
struct gendisk *gd; /* The gendisk structure */
struct timer_list timer; /* For simulated media changes */
};
需要初始化这个结构,
memset (dev, 0, sizeof (struct sbull_dev));
dev->size = nsectors*hardsect_size;
dev->data = vmalloc(dev->size);
spin_lock_init(&dev->lock);
通过memset将设备结构初始化为0,设置设备大小(nsectors和hardsect_size是扇区数量和扇区大小),分配内存获得虚拟地址,这个分配的内存是模拟磁盘设备的空间用来被用户使用的。分配并初始化结构体的自旋锁。
初始化设备定时器
init_timer(&dev->timer);
dev->timer.data = (unsigned long) dev;
dev->timer.function = sbull_invalidate;
设置定时的回调函数为sbull_invalidate。
分配请求队列,包括队列的处理函数
dev->queue = blk_init_queue(sbull_request, &dev->lock);
其中sbull_request是请求函数负责读写,同时使用自旋锁来控制对队列的访问。
dev->queue是数据结构request_queue定义在include/linux/blkdev.h。
有了设备内存和请求队列后,可以继续初始化gendisk结构。
dev->gd = alloc_disk(SBULL_MINORS);//
dev->gd->major = sbull_major;
dev->gd->first_minor = which*SBULL_MINORS;
dev->gd->fops = &sbull_ops;
dev->gd->queue = dev->queue;
dev->gd->private_data = dev;
snprintf (dev->gd->disk_name, 32, "sbull%c", which + 'a');
set_capacity(dev->gd, nsectors*(hardsect_size/KERNEL_SECTOR_SIZE));
add_disk(dev->gd);
其中SBULL_MINORS是次编号的数据,磁盘名为sbulla, 第二个为sbullb, 以此类推。初始化完gendisk,设备和磁盘关联起来了。初始化接收,最后调用add_disk函数。
为实现模拟的介质移出, sbull 需要知道最后一个用户关闭设备。驱动中open 和 close 方法来保持一个用户计数被,保持这个计数最新.
open方法用相关的节点和文件结构指针作为参数. 当一个节点引用一个块设备, i_bdev->bd_disk 包含一个指向关联 gendisk 结构的指针; 这个指针可用来获得一个驱动的给设备的内部数据结构。 open函数会删除设备定时器,递增用户计数并且返回。 release函数会递减用户计数,如果没有用户在使用了,就会添加定时器。当然,在一个处理真实的硬件设备的驱动中, open 和 release 方法应当相应地设置驱动和硬件的状态。当最后一个用户关闭设备后, 一个 30 秒的定时器被设置; 如果设备在这个时间内不被打开, 设备的内容被清除, 并且内核被告知介质已被改变。
一些操作可导致一个块设备从用户空间直接打开; 例如分区一个磁盘, 在一个分区上建立一个文件系统, 或者运行一个文件系统检查器。当加载一个分 区时, 块驱动也可看到一个 open 调用,这个情况下, 没有用户空间进程持有一个这个设备的打开的文件描述符; 相反, 打开的文件被内核自身持有。块驱动无法知道一个加载操作(它从内核打开设备)和调用如mkfs 工具(从用户空间打开它)之间的差别.
检测磁盘变化函数check_disk_changed(位于文件fs/block_dev.c中)调用media_changed函数来查看设备是否变化,如果发生变化就返回一个非零值。而revalidate方法在介质改变后被调用。
块设备可提供一个 ioctl 方法来进行设备控制函数。很多用户层工具是用ioctl的方式来设置驱动的。 高层的块子系统代码在驱动能见到它们之 前解释许多的 ioctl 命令,现代的 块驱动不必实现许多的 ioctl 命令.
块驱动的核心是请求函数,也是系统整个性能的关键部分。无论何时内核认为驱动是时候处理对设备的读, 写, 或者其他操作. 请求函数在返回之前实际不需要完成所有的在队列中的请求; 但是实际上大部分真实设备,可能不完成任何一个请求。但是,驱动会确保这些请求最终被驱动全部处理.
每个设备都有一个请求队列。当这个队列被创建时, 请求函数和它关联到一起,一个自旋锁作为队列创建过程的一部分。无论何时请求函数被调用, 内核持有这个锁。结果, 请求函数在原子上下文中运行;
在请求函数持有锁时, 队列锁还阻止内核去排队任何对设备的其他请求. 在一些条件下,可能考虑在请求函数运行时丢弃这个锁。如果这样做,必须保证不存取请求队列, 或者任何其他被这个锁保护的数据结构。
请求函数的启动(常常地)与任何用户空间进程之间是完全异步的。不能假设内核运行在发起当前请求的进程上下文。我们不知道由这个请求提供的 I/O 缓冲是否在内核或者用户空间。驱动需要知道的关于请求的所有事情, 都包含在通过请求队列传递给的结构中。
request结构体就是请求操作块设备的请求结构体,该结构体被放到request_queue队列中。request_queue结构体定义在include/linux/blkdev.h文件中。队列结构非常复杂,不过幸运的是驱动不用太关注。请求队列存储参数, 来描述这个设备能够支持什么类型的请求: 它们的最大大小, 多少不同的段可进入一个请求, 硬件扇区 大小, 对齐要求, 等等
内核提供函数 elv_next_request 来获得队列中第一个未完成的请求。在3.10.0版本中是blk_fetch_request函数了。
一个块请求队列可包含不从磁盘发起或者磁盘移动块的请求。这些请求可包括供应商特定的, 低层的诊断操作或者和特殊设备模式相关的指令, 例如给可记录介质的报文写模式。大部分块驱动不知道 如何处理这样的请求, 就简单地失败它们。
请求队列的创建函数为:
struct request_queue *blk_init_queue(request_fn_proc *rfn, spinlock_t *lock)
关闭一个请求队列的函数如下:
void blk_cleanup_queue(struct request_queue *q)
都位于文件block/blk-core.c
当设备到达一个状态以致不能处理等待的命令,需要调用blk_stop_queue函数,这样请求函数将不会被调用,直到调用blk_start_queue函数。
每个请求结构代表一个块 I/O 请求,可能是由几个独立的请求在更高层次合并而成。
对任何特殊的请求而传送的扇区可能分布在整个主内存, 尽管它们常常对应块设备中的多个连续的扇区. 这个请求被表示为多个段, 每个对应一个内存中的缓冲。内核可能合并多个涉及磁盘上邻近扇区的请求, 但是它从不合并在单个请求结构中的读和写操作。但是,如果结果会破坏任何队列限制,内核确保不合并请求。具体可见Linux块IO总体概况
bio结构体是request结构体的实际数据,一个request结构体中包含一个或者多个bio结构体,被实现为一个 bio 结构的链表,在底层实际是按bio来对设备进行操作的,传递给驱动。
代码会把它合并到一个已经存在的request结构体中,或者需要的话会再创建一个新的request结构体;bio结构体包含了驱动程序执行请求的全部信息,使驱动可以跟踪它的位置。
结构接着被递给块 I/O 代码,合并它到一个存在的请求结构,如果需要, 创建一个新的. bio 结构包含一个块驱动需要进行请求的任何东西, 而不必涉及使这个请求启动的用户空间进程.
bio结构体是request结构体的实际数据,一个request结构体中包含一个或者多个bio结构体,在底层实际是按bio来对设备进行操作的,传递给驱动。
代码会把它合并到一个已经存在的request结构体中,或者需要的话会再创建一个新的request结构体;bio结构体包含了驱动程序执行请求的全部信息。
一个块 I/O 请求被转换为一个 bio 结构后, 已被分为单独的物理内存页.
直接使用 bi_io_vec 数组不被推荐, 为了内核开发者可以在以后改变 bio 结构而不会引起破坏. 为此, 一组宏被提供来简化使用 bio 结构. 开始的地方是 bio_for_each_segment, 它简单地循环 bi_io_vec 数组中 每个未被处理的项.
一个带有部分被处理请求的请求队列:
块层在驱动见到请求之前重新排序来提高 I/O 性能.如果需要,驱动也可以重新排序请求。在一个 I/O 请求的设备,已经完成传送一些或者全部扇区,必须通知块子系统。
代码中使用__rq_for_each_bio来获得请求中的每个BIO,如下:
__rq_for_each_bio(bio, req) {
sbull_xfer_bio(dev, bio);
nsect += bio->bi_size/KERNEL_SECTOR_SIZE;
}
然后使用bio_for_each_segment来获得BIO中的段,最后进行IO处理,此处是sbull_transfer函数。
bio_for_each_segment(bvec, bio, i) {
char *buffer = __bio_kmap_atomic(bio, i, KM_USER0);
sbull_transfer(dev, sector, bio_cur_bytes(bio) >> 9,
buffer, bio_data_dir(bio) == WRITE);
sector += bio_cur_bytes(bio) >> 9;
__bio_kunmap_atomic(buffer, KM_USER0);
}
有些设备, 例如软件 RAID 阵列或者被逻辑卷管理者创建的虚拟磁盘, 没有块层请求队列优化的条件。对于这类设备,最好直接从块层接收请求,不去用请求队列。
这个时候驱动必须提供一个"制作请求"函数, 而不是一个请求函数。需要注意的是其请求队列仍然存在, 虽然不会真正有任何请求。make_request 函数用一个 bio 结构作为它的主要参数, 这个 bio 结构表示一个或多个要传送的缓冲。make_request 函数可以直接进行传输, 或者重定向这个请求到另一个设备。
代码中的请求模式如下:
enum {
RM_SIMPLE = 0, /* The extra-simple request function */
RM_FULL = 1, /* The full-blown version */
RM_NOQUEUE = 2, /* Use make_request */
};
RM_SIMPLE使用简单的请求处理函数,请求处理函数为sbull_request,通过函数memcpy来实现简单进行读写复制。
RM_FULL使用了bio,一个request结构作为一个bio结构的链表实现的,request中的bio指针就负责指向bio链表,而bio结构则描述I/O请求。RM_FULL对应的请求处理函数就是直接对bio进行操作完成I/O请求的, 请求处理函数为sbull_request。
RM_NOQUEUE表示不适用队列,适用软件 RAID 阵列或者被逻辑卷管理者创建的虚拟磁盘。请求处理函数为sbull_make_request。
代码中请求队列的函数逻辑如下图:
改编自《Linux设备驱动-第三版》又名LDD3
代码太长,放到了github上,直接下载使用。
https://github.com/kernel-z/ldd3/tree/master/snull
本片中的块驱动虽可以运行使用,并模拟了硬盘的功能,但和实际的块驱动差异挺大。
源码中的sbull 同步执行请求, 一次处理一个,而现实中高性能的磁盘设备能够在同时有很多个请求停留。另外磁盘控制器可以优化IO的顺序,提高性能。如果只处理队列中的第一个请求, 那么在给定时间不能有多个请求被满足。
另外,驱动中一次只有一个缓冲被传送, 意味着最大的单次传送不会超过单个页的大小.
《Linux设备驱动-第三版》又名LDD3
https://www.kernel.org/
众多书籍源码:
https://github.com/EternalPeace/Linux-Network-Program-Samples
Linux块IO总体概况