Linux 块设备驱动开发:从请求队列到 I/O 调度的内核路径解析
一、存储 I/O 的内核瓶颈——块设备驱动为何是系统性能的关键枢纽
块设备(Block Device)是 Linux 系统中最核心的设备类别之一,硬盘、SSD、NVMe、虚拟磁盘均属于此类。与字符设备的流式读写不同,块设备的 I/O 操作以固定大小的块(通常 512 字节或 4KB)为单位,且支持随机访问和缓存。
块设备驱动是用户态文件系统与物理存储介质之间的桥梁。当应用程序执行 write() 系统调用时,数据经过 VFS(虚拟文件系统)、页缓存、I/O 调度器,最终到达块设备驱动的请求处理函数。这条路径上的每一个环节都可能成为性能瓶颈:
- I/O 调度器的合并与排序开销:机械硬盘时代,电梯算法通过排序请求减少磁头寻道;SSD 时代,排序的意义减弱,但合并相邻请求仍能减少 DMA 传输次数
- 请求队列的锁竞争:多线程并发提交 I/O 时,请求队列的自旋锁成为热点,高并发场景下锁等待时间可能超过实际 I/O 时间
- 中断处理的延迟:传统块设备驱动依赖硬中断通知 I/O 完成,中断处理函数在中断上下文中执行,无法睡眠、无法执行耗时操作
理解块设备驱动的内核架构,是从根本上解决存储 I/O 性能问题的前提。
二、块设备 I/O 的内核路径——从用户态 write 到驱动回调的完整链路
一个块设备 I/O 请求从用户态到达驱动层,需要经过多层内核子系统的处理:
flowchart TB
A[用户态: write 系统调用] --> B[VFS 层: vfs_write]
B --> C[页缓存: 查找/创建页]
C --> D[脏页回写: mark_buffer_dirty]
D --> E[块 I/O 层: submit_bio]
E --> F[I/O 调度器: 合并/排序]
F --> G[请求队列: request_queue]
G --> H{驱动策略}
H -->|make_request_fn| I[直接处理: NVMe多队列]
H -->|request_fn| J[队列消费: 传统驱动]
I --> K[硬件提交: DMA/MMIO]
J --> K
K --> L[硬件执行]
L --> M[完成中断]
M --> N[结束回调: bio_endio]
N --> O[唤醒等待进程]
subgraph 块I/O核心数据结构
P[bio: 生物块I/O描述符<br/>描述一次I/O操作的内存段]
Q[request: 调度后的请求<br/>合并后的bio链表]
R[request_queue: 请求队列<br/>调度器+驱动回调]
end
bio 与 request 的关系:bio(Block I/O)是内核描述一次 I/O 操作的核心数据结构,包含目标扇区、内存段列表(bvec 数组)、读写方向和完成回调。request 是 I/O 调度器将多个 bio 合并后的产物,一个 request 可能包含多个连续的 bio。驱动从请求队列中取出 request 而非直接处理 bio,这是 I/O 调度器发挥作用的基础。
I/O 调度器的作用:Linux 内核支持多种 I/O 调度器——Deadline、CFQ、BFQ、mq-deadline、none。调度器的核心职责是合并相邻请求(减少 I/O 次数)、排序请求顺序(优化寻道)、限制请求延迟(防止饥饿)。NVMe 设备通常使用 none 调度器(不做排序),因为 NVMe 的随机访问延迟极低,排序的开销反而高于收益。
驱动的两种模式:传统块设备驱动使用 request_fn 模式——驱动注册一个回调函数,内核将请求放入队列后调用该回调,驱动从队列中取出请求并提交给硬件。现代高性能驱动(如 NVMe)使用 make_request_fn 模式——驱动绕过请求队列和 I/O 调度器,直接处理 bio,利用硬件的多队列能力实现并行提交。
三、块设备驱动核心实现——基于 request_fn 模式的虚拟块设备
以下代码实现了一个基于 Linux 内核模块的虚拟块设备驱动,使用 request_fn 模式处理 I/O 请求:
/*
* Linux 块设备驱动示例 - 虚拟块设备 (vblk)
* 基于 request_fn 模式,使用内核内存模拟存储介质
* 适用于学习块设备驱动的核心概念和开发模式
*
* 编译: make -C /lib/modules/$(uname -r)/build M=$(pwd) modules
* 加载: sudo insmod vblk.ko
* 卸载: sudo rmmod vblk
*/
#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/errno.h>
#include <linux/types.h>
#include <linux/blkdev.h>
#include <linux/hdreg.h>
#include <linux/genhd.h>
#include <linux/blk-mq.h>
/* 模块参数 */
static int vblk_capacity_mb = 64; /* 设备容量,默认 64MB */
module_param(vblk_capacity_mb, int, 0444);
MODULE_PARM_DESC(vblk_capacity_mb, "虚拟块设备容量(MB)");
/* 设备主设备号,0 表示动态分配 */
static int vblk_major = 0;
/* 设备名称和次设备号 */
#define VBLK_NAME "vblk"
#define VBLK_MINORS 16 /* 支持的分区数 */
/* 扇区大小(字节) */
#define VBLK_SECTOR_SIZE 512
/* 驱动私有数据结构 */
struct vblk_device {
struct request_queue *queue; /* 请求队列 */
struct gendisk *disk; /* 通用磁盘描述符 */
spinlock_t lock; /* 请求队列自旋锁 */
void *data; /* 模拟存储介质的内存区域 */
sector_t capacity; /* 设备容量(扇区数) */
};
static struct vblk_device *vblk_dev;
/*
* 处理单个 I/O 请求
* 根据请求方向执行内存拷贝(读)或内存写入(写)
*/
static int vblk_transfer(
struct vblk_device *dev,
sector_t sector,
unsigned int nsect,
char *buffer,
int write
)
{
unsigned long offset = sector * VBLK_SECTOR_SIZE;
unsigned long nbytes = nsect * VBLK_SECTOR_SIZE;
/* 边界检查:防止越界访问 */
if (offset + nbytes > dev->capacity * VBLK_SECTOR_SIZE) {
pr_warn("vblk: 越界访问 sector=%llu nsect=%u offset=%lu nbytes=%lu\n",
(unsigned long long)sector, nsect, offset, nbytes);
return -EIO;
}
if (write) {
/* 写操作:从请求缓冲区拷贝到设备内存 */
memcpy(dev->data + offset, buffer, nbytes);
} else {
/* 读操作:从设备内存拷贝到请求缓冲区 */
memcpy(buffer, dev->data + offset, nbytes);
}
return 0;
}
/*
* 请求处理函数(request_fn 模式的核心回调)
* 从请求队列中取出请求,逐个处理
*
* 注意:此函数在持有队列锁的中断上下文中被调用
* 不能执行睡眠操作(如 kmalloc(GFP_KERNEL)、mutex_lock 等)
*/
static void vblk_request(struct request_queue *q)
{
struct request *req;
struct vblk_device *dev = q->queuedata;
int ret = 0;
/* 使用 blk_fetch_request 逐个取出请求
* 该函数会自动处理请求的合并和排序 */
while ((req = blk_fetch_request(q)) != NULL) {
sector_t sector = blk_rq_pos(req);
unsigned int nsect = blk_rq_sectors(req);
struct req_iterator iter;
struct bio_vec bvec;
/* 遍历请求中的所有 bio 段
* 一个请求可能包含多个不连续的内存段 */
rq_for_each_segment(bvec, req, iter) {
/* 将 bvec 映射到内核虚拟地址
* kmap_atomic 适用于高内存场景,不会睡眠 */
void *buffer = kmap_atomic(bvec.bv_page);
unsigned int len = bvec.bv_len;
ret = vblk_transfer(
dev,
sector,
len / VBLK_SECTOR_SIZE,
buffer + bvec.bv_offset,
rq_data_dir(req)
);
kunmap_atomic(buffer);
if (ret < 0) {
break;
}
sector += len / VBLK_SECTOR_SIZE;
}
/* 通知块层请求处理完成
* 最后一个参数表示是否还有后续请求 */
if (!__blk_end_request_cur(req, ret)) {
/* 请求未完全处理完(多段请求的部分段失败)
* 继续处理下一个请求 */
continue;
}
}
}
/*
* 块设备操作函数集
* 虚拟设备只需实现 open/release/ioctl
*/
static int vblk_open(struct gendisk *disk, blk_mode_t mode)
{
/* 虚拟设备无需特殊打开逻辑 */
return 0;
}
static void vblk_release(struct gendisk *disk)
{
/* 虚拟设备无需特殊关闭逻辑 */
}
/*
* 处理 HDIO_GETGEO ioctl
* 某些分区工具(如 fdisk)需要获取磁盘几何信息
* 虚拟设备返回一个合理的默认值即可
*/
static int vblk_ioctl(
struct block_device *bdev,
blk_mode_t mode,
unsigned int cmd,
unsigned long arg
)
{
struct hd_geometry geo;
switch (cmd) {
case HDIO_GETGEO:
/* 构造虚拟的磁盘几何信息 */
geo.cylinders = (vblk_capacity_mb * 1024 * 1024) / (64 * 32 * 512);
geo.heads = 64;
geo.sectors = 32;
geo.start = 0;
if (copy_to_user((void __user *)arg, &geo, sizeof(geo))) {
return -EFAULT;
}
return 0;
default:
return -ENOTTY; /* 不支持的 ioctl 命令 */
}
}
/* 块设备操作函数集定义 */
static const struct block_device_operations vblk_fops = {
.owner = THIS_MODULE,
.open = vblk_open,
.release = vblk_release,
.ioctl = vblk_ioctl,
};
/*
* 模块初始化
* 分配设备结构、注册块设备、初始化请求队列、添加磁盘
*/
static int __init vblk_init(void)
{
int ret;
/* 分配驱动私有数据结构 */
vblk_dev = kzalloc(sizeof(*vblk_dev), GFP_KERNEL);
if (!vblk_dev) {
pr_err("vblk: 无法分配设备结构\n");
return -ENOMEM;
}
/* 计算设备容量(扇区数) */
vblk_dev->capacity = (sector_t)vblk_capacity_mb * 1024 * 1024 / VBLK_SECTOR_SIZE;
/* 分配模拟存储介质的内存区域
* 使用 vzalloc 而非 kmalloc,因为大容量时 kmalloc 可能失败
* vzalloc 使用虚拟连续内存,对大块分配更友好 */
vblk_dev->data = vzalloc(vblk_capacity_mb * 1024 * 1024);
if (!vblk_dev->data) {
pr_err("vblk: 无法分配存储内存 (%dMB)\n", vblk_capacity_mb);
ret = -ENOMEM;
goto out_free_dev;
}
/* 注册块设备,获取主设备号 */
vblk_major = register_blkdev(vblk_major, VBLK_NAME);
if (vblk_major <= 0) {
pr_err("vblk: 无法注册块设备\n");
ret = -EIO;
goto out_free_data;
}
/* 初始化自旋锁 */
spin_lock_init(&vblk_dev->lock);
/* 初始化请求队列
* 使用 blk_init_queue 注册请求处理函数
* 第二个参数是保护队列的自旋锁 */
vblk_dev->queue = blk_init_queue(vblk_request, &vblk_dev->lock);
if (!vblk_dev->queue) {
pr_err("vblk: 无法初始化请求队列\n");
ret = -ENOMEM;
goto out_unregister;
}
/* 将设备私有数据关联到队列,供回调函数使用 */
vblk_dev->queue->queuedata = vblk_dev;
/* 分配 gendisk 结构 */
vblk_dev->disk = alloc_disk(VBLK_MINORS);
if (!vblk_dev->disk) {
pr_err("vblk: 无法分配 gendisk\n");
ret = -ENOMEM;
goto out_cleanup_queue;
}
/* 配置 gendisk */
vblk_dev->disk->major = vblk_major;
vblk_dev->disk->first_minor = 0;
vblk_dev->disk->fops = &vblk_fops;
vblk_dev->disk->private_data = vblk_dev;
vblk_dev->disk->queue = vblk_dev->queue;
snprintf(vblk_dev->disk->disk_name, 32, VBLK_NAME);
set_capacity(vblk_dev->disk, vblk_dev->capacity);
/* 添加磁盘到系统——此步之后设备对用户态可见 */
add_disk(vblk_dev->disk);
pr_info("vblk: 虚拟块设备已创建 /dev/%s, 容量 %dMB\n",
VBLK_NAME, vblk_capacity_mb);
return 0;
/* 错误处理:按相反顺序释放资源 */
out_cleanup_queue:
blk_cleanup_queue(vblk_dev->queue);
out_unregister:
unregister_blkdev(vblk_major, VBLK_NAME);
out_free_data:
vfree(vblk_dev->data);
out_free_dev:
kfree(vblk_dev);
return ret;
}
/*
* 模块卸载
* 按相反顺序释放所有资源
*/
static void __exit vblk_exit(void)
{
/* 从系统中移除磁盘
* del_gendisk 会等待所有 I/O 完成 */
del_gendisk(vblk_dev->disk);
/* 释放 gendisk 引用 */
put_disk(vblk_dev->disk);
/* 清理请求队列 */
blk_cleanup_queue(vblk_dev->queue);
/* 注销块设备 */
unregister_blkdev(vblk_major, VBLK_NAME);
/* 释放存储内存 */
vfree(vblk_dev->data);
/* 释放设备结构 */
kfree(vblk_dev);
pr_info("vblk: 虚拟块设备已移除\n");
}
module_init(vblk_init);
module_exit(vblk_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("vblk-driver");
MODULE_DESCRIPTION("虚拟块设备驱动 - request_fn 模式示例");
四、request_fn 模式的性能天花板——何时必须切换到 blk-mq
上述基于 request_fn 的驱动实现虽然结构清晰,但在高性能场景下存在根本性瓶颈。
单队列锁竞争:request_fn 模式使用单一请求队列,由一把自旋锁保护。在 NVMe SSD 的场景下,I/O 提交速率可达每秒百万级,单队列锁的竞争会导致 CPU 大量时间消耗在自旋等待上。实测数据表明,在 32 核服务器上,单队列锁的竞争可使 I/O 吞吐量下降 40-60%。
中断上下文限制:request_fn 在持有队列锁的状态下被调用,不能睡眠、不能执行耗时操作。这意味着驱动无法在请求处理函数中执行复杂的 DMA 映射、错误恢复或状态机推进。所有这些操作必须延迟到工作队列或线程中执行,增加了上下文切换开销。
I/O 调度器的开销:request_fn 模式强制经过 I/O 调度器,即使设备不需要排序(如 NVMe)。调度器的合并和排序逻辑消耗 CPU 时间,且引入了请求延迟——请求在队列中等待被调度器处理的时间可能超过实际 I/O 执行时间。
blk-mq 的解决方案:Linux 3.19 引入的 blk-mq(Block Multi-Queue)架构通过多队列和软件/硬件队列分离解决了上述问题。blk-mq 为每个 CPU 核心分配独立的软件队列(无锁提交),为每个硬件队列分配独立的硬件上下文(并行处理),彻底消除了单队列锁瓶颈。现代 NVMe 驱动全部基于 blk-mq 实现。
迁移成本:从 request_fn 迁移到 blk-mq 不是简单的 API 替换,而是需要重新设计驱动的并发模型。blk-mq 要求驱动实现 queue_rq 回调(替代 request_fn)、complete 回调(替代中断处理中的 blk_end_request)和 map_queue 映射(替代隐式的单队列映射)。对于已有驱动,迁移工作量约 2-4 周。
五、总结
Linux 块设备驱动是存储 I/O 路径的关键环节,其架构从 request_fn 单队列模式演进到 blk-mq 多队列模式,反映了存储硬件从机械硬盘到 NVMe SSD 的性能跃迁。request_fn 模式结构简单、适合入门和低速设备,但在高并发场景下受限于单队列锁竞争;blk-mq 模式通过多队列并行消除了锁瓶颈,是高性能块设备驱动的必选架构。
落地路线建议:
从 request_fn 入门:先实现一个基于
request_fn的虚拟块设备驱动,理解 bio、request、request_queue 三个核心数据结构的关系和 I/O 路径。使用 fio 基准测试:对驱动进行 I/O 吞吐量、延迟分布和 CPU 利用率的基准测试,量化单队列锁竞争的实际影响。
迁移到 blk-mq:当 I/O 吞吐量需求超过 100K IOPS 或 CPU 核心数超过 8 时,开始向 blk-mq 架构迁移。优先实现
queue_rq和complete回调。硬件队列数调优:blk-mq 的硬件队列数应与设备的 DMA 通道数匹配。NVMe 设备通常配置为与 CPU 核心数相等的硬件队列数。
I/O 调度器选择:NVMe 设备使用
none调度器;SATA SSD 使用mq-deadline;机械硬盘使用bfq(交互式场景)或mq-deadline(服务器场景)。

149

被折叠的 条评论
为什么被折叠?



