1. 项目概述为什么需要深入理解Linux块设备驱动在Linux内核开发领域文件系统、数据库、虚拟化存储这些上层应用的光鲜背后真正扛起数据存取重担的是默默无闻的块设备驱动。它不像字符驱动那样直接面向字节流而是以“块”为单位与复杂的I/O调度器、页缓存、请求队列打交道。很多驱动开发者初次接触块驱动时会被其相对复杂的框架吓退觉得它比字符驱动“高级”很多。实际上当你拆解清楚其核心骨架与交互逻辑后会发现它是一套设计精妙、职责分明的体系。“linux中block驱动的编写详解”这个标题指向的正是揭开这层神秘面纱的过程。它不仅仅是教你填充几个内核结构体更是理解Linux存储子系统如何高效、可靠地管理磁盘I/O的关键。无论是为一块新的SSD编写驱动还是实现一个基于内存的虚拟磁盘ramdisk甚至是构建一个复杂的分布式存储系统的本地接入层其基石都是块设备驱动。掌握它意味着你拿到了与内核最核心的I/O路径对话的钥匙能够优化存储性能诊断I/O瓶颈甚至创造新的存储抽象。接下来我将以一个虚拟的内存块设备为例带你从零开始完整走一遍块设备驱动的编写、测试与调试流程分享那些在官方文档里不会明说的实践细节和踩坑经验。2. 核心概念与框架深度解析在动手写代码之前我们必须先建立正确的心理模型。块设备驱动和字符设备驱动在设计哲学上有着根本的不同理解这些差异是避免后续编写时陷入困惑的基础。2.1 块设备 vs. 字符设备设计哲学的差异字符设备如键盘、鼠标、串口的核心是“流”Stream。驱动提供一个file_operations结构体上层应用通过read、write、ioctl等系统调用直接与驱动交互数据是顺序的、无结构的字节序列。I/O路径相对简短直接。块设备如硬盘、SSD、U盘的核心是“块”Block和“缓存”。数据以固定大小的块通常是512字节或4K字节为单位进行存取。Linux内核在块设备之上构建了复杂的缓存层Page Cache和I/O调度层Elevator。当应用程序写入文件时数据通常先进入页缓存由内核在后台选择合适的时机将脏页以“请求”request的形式批量、合并、排序后再下发给驱动处理。这个“请求”是块设备驱动的核心交互对象。因此块设备驱动主要不是直接处理read/write系统调用而是处理由内核I/O调度器构造好的struct request。驱动需要从request中提取要操作的扇区LBA、数据缓冲区struct bio等信息然后操作硬件完成数据传输。这种异步的、批量处理的模式旨在最大化磁盘的吞吐量减少磁头移动对于机械硬盘或提升并发度对于SSD。2.2 关键数据结构关系图理解以下几个核心结构体及其关系至关重要struct gendisk(通用磁盘)代表一个块设备实例。它包含设备的主要信息容量、名称、指向struct block_device_operations的指针、以及最重要的request_queue请求队列。struct request_queue(请求队列)这是驱动与I/O调度器之间的桥梁。所有针对该块设备的I/O请求都会被排入这个队列。驱动需要向内核分配并初始化一个请求队列并为其绑定一个“请求处理函数”request_fn。当内核认为需要处理I/O时就会调用这个函数。struct request(请求)描述一次I/O操作。一个request可能包含多个连续的或不连续的struct bio代表了上层希望读取或写入的一组数据块。struct bio(块I/O)是request的组成部分描述一个单独的、在逻辑上连续的数据段。它包含了目标设备、起始扇区、方向读/写、以及存放数据的内存页信息。驱动最终需要遍历request中的所有bio并处理每个bio。struct block_device_operations(块设备操作集)类似于字符设备的file_operations但提供的操作少得多主要处理设备打开、释放、IO控制、介质改变等管理性任务不负责实际的数据读写。它们的关系可以简单概括为一个gendisk拥有一个request_queue。request_queue中存放着多个request。每个request包含一个或多个bio。驱动通过request_queue的request_fn函数获取并处理request。2.3 驱动工作流程全景一个最简单的块设备驱动例如基于内存的ramdisk的工作流程如下模块初始化分配一个gendisk结构体分配并设置一个request_queue指定request_fn分配存储数据的内存空间设置gendisk的各个字段主设备号、容量、操作集、队列等最后将gendisk添加到系统。I/O请求处理当有读写操作时内核I/O调度器将请求放入request_queue并调用驱动注册的request_fn。在该函数中驱动通常使用blk_mq_start_request和blk_update_request等辅助函数来处理请求核心是遍历请求中的bio完成内存与“设备”对于ramdisk就是内存之间的数据拷贝。请求完成每个bio处理完毕后需要通知内核。最终整个request处理完成后驱动需要调用blk_mq_end_request来结束请求释放资源。模块退出将gendisk从系统删除清除request_queue释放gendisk结构和数据内存。注意现代内核尤其是4.x之后推荐使用更高效、可扩展的多队列Multi-Queue, blk-mq框架。传统的单队列请求request_fn模式正在被逐步淘汰。我们的示例将基于blk-mq框架这是当前及未来的标准做法。3. 实战从零编写一个内存块设备驱动理论说得再多不如一行代码。我们来实现一个名为simple_blkdev的简易内存块设备。它会在内存中划出一片区域模拟一个磁盘支持基本的读写操作。3.1 环境准备与模块骨架首先确保你有一个Linux内核开发环境安装了对应版本的内核头文件。我们的驱动将以内核模块的形式存在。// simple_blkdev.c #include linux/module.h #include linux/genhd.h // 包含 gendisk 相关定义 #include linux/blk-mq.h // 多队列块设备支持 #include linux/vmalloc.h // 用于分配大块内存 #define SIMPLE_BLKDEV_DISK_NAME simple_blkdev // 设备名 #define SIMPLE_BLKDEV_MAJOR 0 // 动态分配主设备号 #define SIMPLE_BLKDEV_MINOR 0 #define SIMPLE_BLKDEV_SECTORS 1024 * 1024 // 设备容量1024*1024个扇区假设512字节/扇区共512MB #define SIMPLE_BLKDEV_SECTOR_SIZE 512 // 扇区大小 #define SIMPLE_BLKDEV_QUEUE_DEPTH 128 // 队列深度 // 设备私有数据结构 struct simple_blkdev_device { struct gendisk *gd; struct blk_mq_tag_set tag_set; u8 *data; // 指向模拟设备存储空间的内存指针 sector_t capacity; // 设备容量扇区数 }; static struct simple_blkdev_device dev;我们定义了一个设备私有结构体用于管理这个虚拟设备的全部状态信息。data指针将指向我们用来模拟磁盘存储的那片内存。3.2 初始化构建设备与队列模块的初始化函数是module_init指定的入口。这里我们要完成几件关键事情static int __init simple_blkdev_init(void) { int ret 0; // 1. 分配存储数据的内存 dev.data vmalloc(SIMPLE_BLKDEV_SECTORS * SIMPLE_BLKDEV_SECTOR_SIZE); if (!dev.data) { pr_err(Failed to allocate device memory\n); return -ENOMEM; } dev.capacity SIMPLE_BLKDEV_SECTORS; // 2. 初始化 blk-mq 标签集 (Tag Set) memset(dev.tag_set, 0, sizeof(dev.tag_set)); dev.tag_set.ops simple_blkdev_mq_ops; // 操作集后面定义 dev.tag_set.nr_hw_queues 1; // 我们只使用一个硬件队列 dev.tag_set.queue_depth SIMPLE_BLKDEV_QUEUE_DEPTH; dev.tag_set.numa_node NUMA_NO_NODE; dev.tag_set.cmd_size 0; // 我们不需要额外的命令私有数据 dev.tag_set.flags BLK_MQ_F_SHOULD_MERGE; // 允许请求合并 dev.tag_set.driver_data dev; ret blk_mq_alloc_tag_set(dev.tag_set); if (ret) { pr_err(Failed to allocate tag set\n); goto out_free_data; } // 3. 分配并初始化 gendisk dev.gd blk_mq_alloc_disk(dev.tag_set, dev); if (IS_ERR(dev.gd)) { ret PTR_ERR(dev.gd); pr_err(Failed to allocate disk\n); goto out_free_tags; } strscpy(dev.gd-disk_name, SIMPLE_BLKDEV_DISK_NAME, DISK_NAME_LEN); dev.gd-major SIMPLE_BLKDEV_MAJOR; dev.gd-first_minor SIMPLE_BLKDEV_MINOR; dev.gd-minors 1; // 只有一个次设备 dev.gd-fops simple_blkdev_ops; // 块设备操作集后面定义 dev.gd-private_data dev; set_capacity(dev.gd, dev.capacity); // 设置设备容量 // 4. 将磁盘添加到系统 ret add_disk(dev.gd); if (ret) { pr_err(Failed to add disk\n); goto out_put_disk; } pr_info(Simple block device initialized with capacity %llu sectors\n, (unsigned long long)dev.capacity); return 0; out_put_disk: put_disk(dev.gd); out_free_tags: blk_mq_free_tag_set(dev.tag_set); out_free_data: vfree(dev.data); return ret; }关键点解析blk_mq_tag_set这是blk-mq框架的核心管理结构。它定义了队列的数量、深度、操作回调等。blk_mq_alloc_tag_set会为其分配必要的资源。blk_mq_alloc_disk这是一个现代API它一次性完成了gendisk的分配、与tag_set的关联以及request_queue的创建比旧的手动分配gendisk再分配request_queue的方式更简洁。set_capacity必须调用此函数来正确设置磁盘的容量否则fdisk -l等工具看到的容量将是0。错误处理内核编程必须严谨处理错误路径释放每一步申请的资源顺序通常是申请的逆序。3.3 定义块设备操作集这个操作集处理的是设备文件层面的管理操作而非数据I/O。static struct block_device_operations simple_blkdev_ops { .owner THIS_MODULE, // 这里可以添加 .open, .release, .ioctl 等对于简单设备留空即可。 };对于我们的内存设备open和release通常不需要特殊操作。如果需要实现类似“独占打开”或介质检测对于可移动设备的功能则需要在这里实现。3.4 核心实现blk-mq操作集与请求处理这是驱动最核心的部分我们需要定义struct blk_mq_ops并实现其中的队列回调函数。// blk-mq 操作集 static const struct blk_mq_ops simple_blkdev_mq_ops { .queue_rq simple_blkdev_queue_rq, // 处理请求的核心函数 }; // 请求处理函数 static blk_status_t simple_blkdev_queue_rq(struct blk_mq_hw_ctx *hctx, const struct blk_mq_queue_data *bd) { struct request *req bd-rq; struct simple_blkdev_device *dev req-q-queuedata; struct bio_vec bvec; struct req_iterator iter; sector_t sector; unsigned int bytes; char *buffer; blk_status_t status BLK_STS_OK; // 开始处理请求 blk_mq_start_request(req); // 获取请求的起始扇区和方向 sector blk_rq_pos(req); // 遍历请求中的所有bio rq_for_each_segment(bvec, req, iter) { // 计算本次处理的字节数 bytes bvec.bv_len; // 安全检查操作范围是否超出设备容量 if ((sector SECTOR_SHIFT) bytes dev-capacity SECTOR_SHIFT) { status BLK_STS_IOERR; break; } // 将内核缓冲区地址映射到内核虚拟地址空间 buffer page_address(bvec.bv_page) bvec.bv_offset; // 根据读/写操作在设备内存和请求缓冲区之间拷贝数据 if (rq_data_dir(req) READ) { // 读操作从“设备”内存拷贝到缓冲区 memcpy(buffer, dev-data (sector SECTOR_SHIFT), bytes); } else { // 写操作从缓冲区拷贝到“设备”内存 memcpy(dev-data (sector SECTOR_SHIFT), buffer, bytes); } // 移动到下一个数据段 sector bytes SECTOR_SHIFT; } // 结束请求通知上层I/O完成 blk_mq_end_request(req, status); return status; }代码逐行解读与避坑指南blk_mq_start_request(req)必须在处理请求开始时调用。它会启动请求的计时器用于统计I/O延迟并执行一些内部状态设置。忘记调用这个函数是新手常见错误可能导致内核警告或统计信息错误。blk_rq_pos(req)获取这个请求的起始扇区号LBA。这是扇区单位不是字节。后续计算偏移时需要用sector SECTOR_SHIFT通常SECTOR_SHIFT是9即乘以512来转换为字节偏移。rq_for_each_segment这是一个宏用于安全地遍历request中的每一个段segment。一个bio可能因为内存分散scatter-gather而被拆分成多个段。这个宏帮我们处理了这些细节。page_address(bvec.bv_page) bvec.bv_offset这是获取bio_vec对应数据缓冲区内核虚拟地址的标准方法。bv_page是内存页bv_offset是页内偏移。重要这段地址在内核上下文中是直接可访问的无需kmap/kunmap对于高端内存现代内核的page_address在多数情况下能处理好。rq_data_dir(req)判断请求方向READ或WRITE。这是定义在内核中的宏。memcpy对于我们的内存设备数据搬运就是简单的内存拷贝。对于真实硬件这里会替换为DMA操作或MMIO读写。blk_mq_end_request(req, status)必须在请求处理完成后调用并传入完成状态如BLK_STS_OK表示成功BLK_STS_IOERR表示错误。这个函数会唤醒等待该I/O完成的进程并释放请求结构体。一个请求只能调用一次blk_mq_end_request。实操心得在遍历和处理bio_vec时务必进行边界检查确保请求的扇区范围没有超出你设备声明的容量。内核的上层虽然会做基本检查但驱动自身的防御性编程能避免内存越界访问导致系统崩溃。此外对于真实硬件memcpy的位置需要替换为启动DMA传输或配置控制器寄存器的代码并在DMA完成中断中调用blk_mq_end_request。3.5 清理与模块退出退出函数需要按顺序清理所有资源。static void __exit simple_blkdev_exit(void) { if (dev.gd) { del_gendisk(dev.gd); // 从系统中删除磁盘 put_disk(dev.gd); // 减少gendisk引用计数可能释放它 } blk_mq_free_tag_set(dev.tag_set); // 释放标签集 if (dev.data) { vfree(dev.data); // 释放设备内存 dev.data NULL; } pr_info(Simple block device removed\n); } module_init(simple_blkdev_init); module_exit(simple_blkdev_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple in-memory block device driver);顺序很重要必须先del_gendisk确保没有进程再打开设备然后才能释放其依赖的资源如tag_set和data内存。put_disk通常在del_gendisk之后调用如果gendisk是用blk_mq_alloc_disk分配的put_disk的调用可能会在del_gendisk内部或之后由内核自动管理但显式调用是一个好习惯。4. 编译、加载与测试验证编写完驱动代码我们还需要一个Makefile来编译它。# Makefile obj-m : simple_blkdev.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean编译与加载# 编译模块 make # 加载模块这会在 /dev/ 下创建设备节点如 /dev/simple_blkdev sudo insmod simple_blkdev.ko # 查看内核日志确认初始化信息 dmesg | tail -20 # 使用 lsblk 或 fdisk 查看块设备 lsblk sudo fdisk -l /dev/simple_blkdev基础功能测试# 1. 创建文件系统并挂载 sudo mkfs.ext4 /dev/simple_blkdev sudo mkdir /mnt/simple_blk sudo mount /dev/simple_blkdev /mnt/simple_blk # 2. 进行文件读写测试 echo Hello, Block Driver! | sudo tee /mnt/simple_blk/test.txt sudo cat /mnt/simple_blk/test.txt dd if/dev/zero of/mnt/simple_blk/largefile bs1M count100 statusprogress # 3. 查看I/O统计信息 cat /sys/block/simple_blkdev/stat # 输出类似读扇区数 写扇区数 读请求数 写请求数 ... 这些信息来自驱动对请求的完成处理。 # 4. 卸载并移除模块 sudo umount /mnt/simple_blk sudo rmmod simple_blkdev dmesg | tail -10 # 查看退出日志5. 进阶话题与性能调优思考一个能工作的基础驱动只是起点。要让驱动健壮、高效还需要考虑更多。5.1 错误处理与鲁棒性增强我们的示例中错误处理非常基础。在生产级驱动中你需要考虑DMA映射失败dma_map_sg可能失败需要回滚。硬件超时为请求设置超时定时器blk_mq_rq_timeout如果硬件在规定时间未响应需要中止请求并返回错误。介质错误对于真实存储设备某些扇区可能损坏。驱动应能报告BLK_STS_MEDIUM错误。热插拔与电源管理实现block_device_operations中的revalidate_disk介质改变和pm回调。5.2 支持多队列与NUMA优化我们的示例只用了1个硬件队列nr_hw_queues 1。现代高性能NVMe SSD支持多个提交队列和完成队列以充分利用多核CPU。在tag_set中设置nr_hw_queues为硬件实际支持的队列数例如struct pci_dev的nr_vectors。在queue_rq函数中可以通过hctx-queue_num知道当前请求来自哪个硬件队列从而将请求分发到对应的硬件队列处理。对于NUMA系统可以将队列与CPU核心绑定减少跨NUMA节点的内存访问tag_set.numa_node可以用于提示内存分配的位置。5.3 I/O性能优化技巧合并与拆分内核调度器会尝试合并相邻的请求。驱动可以通过blk_queue_max_segments和blk_queue_max_segment_size告知队列自己处理分散/聚集scatter-gather列表的能力。如果硬件支持驱动也可以在queue_rq中进一步合并小的bio或拆分过大的请求以适应硬件限制。轮询模式对于超高延迟要求的场景如高性能数据库可以启用轮询模式让驱动主动检查硬件完成状态而不是等待中断。这需要硬件支持并在tag_set.flags中设置BLK_MQ_F_BLOCKING以外的相应标志同时实现poll回调。直接I/O与绕过缓存当上层使用O_DIRECT标志打开文件时I/O会尝试绕过页缓存。对于驱动来说这没有区别它处理的仍然是request。但理解这一点有助于你分析性能瓶颈是在驱动层还是内核缓存层。请求优先级request有优先级属性。虽然I/O调度器是主要决策者但驱动在可能的情况下可以优先处理高优先级请求例如在NVMe驱动中实现加权轮询。5.4 调试与追踪块设备驱动调试可能比较困难因为问题可能出现在I/O路径的任何一个环节。blktrace和blkparse这是最强大的工具。它可以追踪一个I/O请求从VFS下发到块层经过调度进入驱动最后完成的全过程。通过sudo blktrace -d /dev/simple_blkdev -o - | blkparse -i -你可以清晰地看到每个请求的生命周期定位延迟或错误发生在哪个阶段。动态调试在驱动代码中添加pr_debug并通过echo module simple_blkdev p /sys/kernel/debug/dynamic_debug/control来动态开启调试信息输出。SystemTap 或 BPF使用更高级的内核追踪工具可以编写脚本对驱动的特定函数进行采样、统计耗时绘制火焰图。编写Linux块设备驱动是一个系统工程它要求开发者不仅熟悉内核模块编程还要理解存储栈的运作原理。从最简单的内存设备开始逐步增加对中断、DMA、多队列、错误恢复等复杂功能的支持是掌握这项技能的有效路径。希望这篇详尽的解析能为你打开Linux块设备驱动开发的大门并成为你调试和优化更复杂驱动时的参考手册。记住多读内核源码如drivers/block/null_blk.c是一个极佳的学习示例多动手实验是提升的不二法门。
Linux块设备驱动开发实战:从内存设备到blk-mq框架详解
发布时间:2026/5/20 0:08:33
1. 项目概述为什么需要深入理解Linux块设备驱动在Linux内核开发领域文件系统、数据库、虚拟化存储这些上层应用的光鲜背后真正扛起数据存取重担的是默默无闻的块设备驱动。它不像字符驱动那样直接面向字节流而是以“块”为单位与复杂的I/O调度器、页缓存、请求队列打交道。很多驱动开发者初次接触块驱动时会被其相对复杂的框架吓退觉得它比字符驱动“高级”很多。实际上当你拆解清楚其核心骨架与交互逻辑后会发现它是一套设计精妙、职责分明的体系。“linux中block驱动的编写详解”这个标题指向的正是揭开这层神秘面纱的过程。它不仅仅是教你填充几个内核结构体更是理解Linux存储子系统如何高效、可靠地管理磁盘I/O的关键。无论是为一块新的SSD编写驱动还是实现一个基于内存的虚拟磁盘ramdisk甚至是构建一个复杂的分布式存储系统的本地接入层其基石都是块设备驱动。掌握它意味着你拿到了与内核最核心的I/O路径对话的钥匙能够优化存储性能诊断I/O瓶颈甚至创造新的存储抽象。接下来我将以一个虚拟的内存块设备为例带你从零开始完整走一遍块设备驱动的编写、测试与调试流程分享那些在官方文档里不会明说的实践细节和踩坑经验。2. 核心概念与框架深度解析在动手写代码之前我们必须先建立正确的心理模型。块设备驱动和字符设备驱动在设计哲学上有着根本的不同理解这些差异是避免后续编写时陷入困惑的基础。2.1 块设备 vs. 字符设备设计哲学的差异字符设备如键盘、鼠标、串口的核心是“流”Stream。驱动提供一个file_operations结构体上层应用通过read、write、ioctl等系统调用直接与驱动交互数据是顺序的、无结构的字节序列。I/O路径相对简短直接。块设备如硬盘、SSD、U盘的核心是“块”Block和“缓存”。数据以固定大小的块通常是512字节或4K字节为单位进行存取。Linux内核在块设备之上构建了复杂的缓存层Page Cache和I/O调度层Elevator。当应用程序写入文件时数据通常先进入页缓存由内核在后台选择合适的时机将脏页以“请求”request的形式批量、合并、排序后再下发给驱动处理。这个“请求”是块设备驱动的核心交互对象。因此块设备驱动主要不是直接处理read/write系统调用而是处理由内核I/O调度器构造好的struct request。驱动需要从request中提取要操作的扇区LBA、数据缓冲区struct bio等信息然后操作硬件完成数据传输。这种异步的、批量处理的模式旨在最大化磁盘的吞吐量减少磁头移动对于机械硬盘或提升并发度对于SSD。2.2 关键数据结构关系图理解以下几个核心结构体及其关系至关重要struct gendisk(通用磁盘)代表一个块设备实例。它包含设备的主要信息容量、名称、指向struct block_device_operations的指针、以及最重要的request_queue请求队列。struct request_queue(请求队列)这是驱动与I/O调度器之间的桥梁。所有针对该块设备的I/O请求都会被排入这个队列。驱动需要向内核分配并初始化一个请求队列并为其绑定一个“请求处理函数”request_fn。当内核认为需要处理I/O时就会调用这个函数。struct request(请求)描述一次I/O操作。一个request可能包含多个连续的或不连续的struct bio代表了上层希望读取或写入的一组数据块。struct bio(块I/O)是request的组成部分描述一个单独的、在逻辑上连续的数据段。它包含了目标设备、起始扇区、方向读/写、以及存放数据的内存页信息。驱动最终需要遍历request中的所有bio并处理每个bio。struct block_device_operations(块设备操作集)类似于字符设备的file_operations但提供的操作少得多主要处理设备打开、释放、IO控制、介质改变等管理性任务不负责实际的数据读写。它们的关系可以简单概括为一个gendisk拥有一个request_queue。request_queue中存放着多个request。每个request包含一个或多个bio。驱动通过request_queue的request_fn函数获取并处理request。2.3 驱动工作流程全景一个最简单的块设备驱动例如基于内存的ramdisk的工作流程如下模块初始化分配一个gendisk结构体分配并设置一个request_queue指定request_fn分配存储数据的内存空间设置gendisk的各个字段主设备号、容量、操作集、队列等最后将gendisk添加到系统。I/O请求处理当有读写操作时内核I/O调度器将请求放入request_queue并调用驱动注册的request_fn。在该函数中驱动通常使用blk_mq_start_request和blk_update_request等辅助函数来处理请求核心是遍历请求中的bio完成内存与“设备”对于ramdisk就是内存之间的数据拷贝。请求完成每个bio处理完毕后需要通知内核。最终整个request处理完成后驱动需要调用blk_mq_end_request来结束请求释放资源。模块退出将gendisk从系统删除清除request_queue释放gendisk结构和数据内存。注意现代内核尤其是4.x之后推荐使用更高效、可扩展的多队列Multi-Queue, blk-mq框架。传统的单队列请求request_fn模式正在被逐步淘汰。我们的示例将基于blk-mq框架这是当前及未来的标准做法。3. 实战从零编写一个内存块设备驱动理论说得再多不如一行代码。我们来实现一个名为simple_blkdev的简易内存块设备。它会在内存中划出一片区域模拟一个磁盘支持基本的读写操作。3.1 环境准备与模块骨架首先确保你有一个Linux内核开发环境安装了对应版本的内核头文件。我们的驱动将以内核模块的形式存在。// simple_blkdev.c #include linux/module.h #include linux/genhd.h // 包含 gendisk 相关定义 #include linux/blk-mq.h // 多队列块设备支持 #include linux/vmalloc.h // 用于分配大块内存 #define SIMPLE_BLKDEV_DISK_NAME simple_blkdev // 设备名 #define SIMPLE_BLKDEV_MAJOR 0 // 动态分配主设备号 #define SIMPLE_BLKDEV_MINOR 0 #define SIMPLE_BLKDEV_SECTORS 1024 * 1024 // 设备容量1024*1024个扇区假设512字节/扇区共512MB #define SIMPLE_BLKDEV_SECTOR_SIZE 512 // 扇区大小 #define SIMPLE_BLKDEV_QUEUE_DEPTH 128 // 队列深度 // 设备私有数据结构 struct simple_blkdev_device { struct gendisk *gd; struct blk_mq_tag_set tag_set; u8 *data; // 指向模拟设备存储空间的内存指针 sector_t capacity; // 设备容量扇区数 }; static struct simple_blkdev_device dev;我们定义了一个设备私有结构体用于管理这个虚拟设备的全部状态信息。data指针将指向我们用来模拟磁盘存储的那片内存。3.2 初始化构建设备与队列模块的初始化函数是module_init指定的入口。这里我们要完成几件关键事情static int __init simple_blkdev_init(void) { int ret 0; // 1. 分配存储数据的内存 dev.data vmalloc(SIMPLE_BLKDEV_SECTORS * SIMPLE_BLKDEV_SECTOR_SIZE); if (!dev.data) { pr_err(Failed to allocate device memory\n); return -ENOMEM; } dev.capacity SIMPLE_BLKDEV_SECTORS; // 2. 初始化 blk-mq 标签集 (Tag Set) memset(dev.tag_set, 0, sizeof(dev.tag_set)); dev.tag_set.ops simple_blkdev_mq_ops; // 操作集后面定义 dev.tag_set.nr_hw_queues 1; // 我们只使用一个硬件队列 dev.tag_set.queue_depth SIMPLE_BLKDEV_QUEUE_DEPTH; dev.tag_set.numa_node NUMA_NO_NODE; dev.tag_set.cmd_size 0; // 我们不需要额外的命令私有数据 dev.tag_set.flags BLK_MQ_F_SHOULD_MERGE; // 允许请求合并 dev.tag_set.driver_data dev; ret blk_mq_alloc_tag_set(dev.tag_set); if (ret) { pr_err(Failed to allocate tag set\n); goto out_free_data; } // 3. 分配并初始化 gendisk dev.gd blk_mq_alloc_disk(dev.tag_set, dev); if (IS_ERR(dev.gd)) { ret PTR_ERR(dev.gd); pr_err(Failed to allocate disk\n); goto out_free_tags; } strscpy(dev.gd-disk_name, SIMPLE_BLKDEV_DISK_NAME, DISK_NAME_LEN); dev.gd-major SIMPLE_BLKDEV_MAJOR; dev.gd-first_minor SIMPLE_BLKDEV_MINOR; dev.gd-minors 1; // 只有一个次设备 dev.gd-fops simple_blkdev_ops; // 块设备操作集后面定义 dev.gd-private_data dev; set_capacity(dev.gd, dev.capacity); // 设置设备容量 // 4. 将磁盘添加到系统 ret add_disk(dev.gd); if (ret) { pr_err(Failed to add disk\n); goto out_put_disk; } pr_info(Simple block device initialized with capacity %llu sectors\n, (unsigned long long)dev.capacity); return 0; out_put_disk: put_disk(dev.gd); out_free_tags: blk_mq_free_tag_set(dev.tag_set); out_free_data: vfree(dev.data); return ret; }关键点解析blk_mq_tag_set这是blk-mq框架的核心管理结构。它定义了队列的数量、深度、操作回调等。blk_mq_alloc_tag_set会为其分配必要的资源。blk_mq_alloc_disk这是一个现代API它一次性完成了gendisk的分配、与tag_set的关联以及request_queue的创建比旧的手动分配gendisk再分配request_queue的方式更简洁。set_capacity必须调用此函数来正确设置磁盘的容量否则fdisk -l等工具看到的容量将是0。错误处理内核编程必须严谨处理错误路径释放每一步申请的资源顺序通常是申请的逆序。3.3 定义块设备操作集这个操作集处理的是设备文件层面的管理操作而非数据I/O。static struct block_device_operations simple_blkdev_ops { .owner THIS_MODULE, // 这里可以添加 .open, .release, .ioctl 等对于简单设备留空即可。 };对于我们的内存设备open和release通常不需要特殊操作。如果需要实现类似“独占打开”或介质检测对于可移动设备的功能则需要在这里实现。3.4 核心实现blk-mq操作集与请求处理这是驱动最核心的部分我们需要定义struct blk_mq_ops并实现其中的队列回调函数。// blk-mq 操作集 static const struct blk_mq_ops simple_blkdev_mq_ops { .queue_rq simple_blkdev_queue_rq, // 处理请求的核心函数 }; // 请求处理函数 static blk_status_t simple_blkdev_queue_rq(struct blk_mq_hw_ctx *hctx, const struct blk_mq_queue_data *bd) { struct request *req bd-rq; struct simple_blkdev_device *dev req-q-queuedata; struct bio_vec bvec; struct req_iterator iter; sector_t sector; unsigned int bytes; char *buffer; blk_status_t status BLK_STS_OK; // 开始处理请求 blk_mq_start_request(req); // 获取请求的起始扇区和方向 sector blk_rq_pos(req); // 遍历请求中的所有bio rq_for_each_segment(bvec, req, iter) { // 计算本次处理的字节数 bytes bvec.bv_len; // 安全检查操作范围是否超出设备容量 if ((sector SECTOR_SHIFT) bytes dev-capacity SECTOR_SHIFT) { status BLK_STS_IOERR; break; } // 将内核缓冲区地址映射到内核虚拟地址空间 buffer page_address(bvec.bv_page) bvec.bv_offset; // 根据读/写操作在设备内存和请求缓冲区之间拷贝数据 if (rq_data_dir(req) READ) { // 读操作从“设备”内存拷贝到缓冲区 memcpy(buffer, dev-data (sector SECTOR_SHIFT), bytes); } else { // 写操作从缓冲区拷贝到“设备”内存 memcpy(dev-data (sector SECTOR_SHIFT), buffer, bytes); } // 移动到下一个数据段 sector bytes SECTOR_SHIFT; } // 结束请求通知上层I/O完成 blk_mq_end_request(req, status); return status; }代码逐行解读与避坑指南blk_mq_start_request(req)必须在处理请求开始时调用。它会启动请求的计时器用于统计I/O延迟并执行一些内部状态设置。忘记调用这个函数是新手常见错误可能导致内核警告或统计信息错误。blk_rq_pos(req)获取这个请求的起始扇区号LBA。这是扇区单位不是字节。后续计算偏移时需要用sector SECTOR_SHIFT通常SECTOR_SHIFT是9即乘以512来转换为字节偏移。rq_for_each_segment这是一个宏用于安全地遍历request中的每一个段segment。一个bio可能因为内存分散scatter-gather而被拆分成多个段。这个宏帮我们处理了这些细节。page_address(bvec.bv_page) bvec.bv_offset这是获取bio_vec对应数据缓冲区内核虚拟地址的标准方法。bv_page是内存页bv_offset是页内偏移。重要这段地址在内核上下文中是直接可访问的无需kmap/kunmap对于高端内存现代内核的page_address在多数情况下能处理好。rq_data_dir(req)判断请求方向READ或WRITE。这是定义在内核中的宏。memcpy对于我们的内存设备数据搬运就是简单的内存拷贝。对于真实硬件这里会替换为DMA操作或MMIO读写。blk_mq_end_request(req, status)必须在请求处理完成后调用并传入完成状态如BLK_STS_OK表示成功BLK_STS_IOERR表示错误。这个函数会唤醒等待该I/O完成的进程并释放请求结构体。一个请求只能调用一次blk_mq_end_request。实操心得在遍历和处理bio_vec时务必进行边界检查确保请求的扇区范围没有超出你设备声明的容量。内核的上层虽然会做基本检查但驱动自身的防御性编程能避免内存越界访问导致系统崩溃。此外对于真实硬件memcpy的位置需要替换为启动DMA传输或配置控制器寄存器的代码并在DMA完成中断中调用blk_mq_end_request。3.5 清理与模块退出退出函数需要按顺序清理所有资源。static void __exit simple_blkdev_exit(void) { if (dev.gd) { del_gendisk(dev.gd); // 从系统中删除磁盘 put_disk(dev.gd); // 减少gendisk引用计数可能释放它 } blk_mq_free_tag_set(dev.tag_set); // 释放标签集 if (dev.data) { vfree(dev.data); // 释放设备内存 dev.data NULL; } pr_info(Simple block device removed\n); } module_init(simple_blkdev_init); module_exit(simple_blkdev_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple in-memory block device driver);顺序很重要必须先del_gendisk确保没有进程再打开设备然后才能释放其依赖的资源如tag_set和data内存。put_disk通常在del_gendisk之后调用如果gendisk是用blk_mq_alloc_disk分配的put_disk的调用可能会在del_gendisk内部或之后由内核自动管理但显式调用是一个好习惯。4. 编译、加载与测试验证编写完驱动代码我们还需要一个Makefile来编译它。# Makefile obj-m : simple_blkdev.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean编译与加载# 编译模块 make # 加载模块这会在 /dev/ 下创建设备节点如 /dev/simple_blkdev sudo insmod simple_blkdev.ko # 查看内核日志确认初始化信息 dmesg | tail -20 # 使用 lsblk 或 fdisk 查看块设备 lsblk sudo fdisk -l /dev/simple_blkdev基础功能测试# 1. 创建文件系统并挂载 sudo mkfs.ext4 /dev/simple_blkdev sudo mkdir /mnt/simple_blk sudo mount /dev/simple_blkdev /mnt/simple_blk # 2. 进行文件读写测试 echo Hello, Block Driver! | sudo tee /mnt/simple_blk/test.txt sudo cat /mnt/simple_blk/test.txt dd if/dev/zero of/mnt/simple_blk/largefile bs1M count100 statusprogress # 3. 查看I/O统计信息 cat /sys/block/simple_blkdev/stat # 输出类似读扇区数 写扇区数 读请求数 写请求数 ... 这些信息来自驱动对请求的完成处理。 # 4. 卸载并移除模块 sudo umount /mnt/simple_blk sudo rmmod simple_blkdev dmesg | tail -10 # 查看退出日志5. 进阶话题与性能调优思考一个能工作的基础驱动只是起点。要让驱动健壮、高效还需要考虑更多。5.1 错误处理与鲁棒性增强我们的示例中错误处理非常基础。在生产级驱动中你需要考虑DMA映射失败dma_map_sg可能失败需要回滚。硬件超时为请求设置超时定时器blk_mq_rq_timeout如果硬件在规定时间未响应需要中止请求并返回错误。介质错误对于真实存储设备某些扇区可能损坏。驱动应能报告BLK_STS_MEDIUM错误。热插拔与电源管理实现block_device_operations中的revalidate_disk介质改变和pm回调。5.2 支持多队列与NUMA优化我们的示例只用了1个硬件队列nr_hw_queues 1。现代高性能NVMe SSD支持多个提交队列和完成队列以充分利用多核CPU。在tag_set中设置nr_hw_queues为硬件实际支持的队列数例如struct pci_dev的nr_vectors。在queue_rq函数中可以通过hctx-queue_num知道当前请求来自哪个硬件队列从而将请求分发到对应的硬件队列处理。对于NUMA系统可以将队列与CPU核心绑定减少跨NUMA节点的内存访问tag_set.numa_node可以用于提示内存分配的位置。5.3 I/O性能优化技巧合并与拆分内核调度器会尝试合并相邻的请求。驱动可以通过blk_queue_max_segments和blk_queue_max_segment_size告知队列自己处理分散/聚集scatter-gather列表的能力。如果硬件支持驱动也可以在queue_rq中进一步合并小的bio或拆分过大的请求以适应硬件限制。轮询模式对于超高延迟要求的场景如高性能数据库可以启用轮询模式让驱动主动检查硬件完成状态而不是等待中断。这需要硬件支持并在tag_set.flags中设置BLK_MQ_F_BLOCKING以外的相应标志同时实现poll回调。直接I/O与绕过缓存当上层使用O_DIRECT标志打开文件时I/O会尝试绕过页缓存。对于驱动来说这没有区别它处理的仍然是request。但理解这一点有助于你分析性能瓶颈是在驱动层还是内核缓存层。请求优先级request有优先级属性。虽然I/O调度器是主要决策者但驱动在可能的情况下可以优先处理高优先级请求例如在NVMe驱动中实现加权轮询。5.4 调试与追踪块设备驱动调试可能比较困难因为问题可能出现在I/O路径的任何一个环节。blktrace和blkparse这是最强大的工具。它可以追踪一个I/O请求从VFS下发到块层经过调度进入驱动最后完成的全过程。通过sudo blktrace -d /dev/simple_blkdev -o - | blkparse -i -你可以清晰地看到每个请求的生命周期定位延迟或错误发生在哪个阶段。动态调试在驱动代码中添加pr_debug并通过echo module simple_blkdev p /sys/kernel/debug/dynamic_debug/control来动态开启调试信息输出。SystemTap 或 BPF使用更高级的内核追踪工具可以编写脚本对驱动的特定函数进行采样、统计耗时绘制火焰图。编写Linux块设备驱动是一个系统工程它要求开发者不仅熟悉内核模块编程还要理解存储栈的运作原理。从最简单的内存设备开始逐步增加对中断、DMA、多队列、错误恢复等复杂功能的支持是掌握这项技能的有效路径。希望这篇详尽的解析能为你打开Linux块设备驱动开发的大门并成为你调试和优化更复杂驱动时的参考手册。记住多读内核源码如drivers/block/null_blk.c是一个极佳的学习示例多动手实验是提升的不二法门。