Linux 块设备驱动开发:从请求队列到 I/O 调度的内核路径解析 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/调度器驱动回调] endbio 与 request 的关系bioBlock 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-mqBlock 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服务器场景。