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/>调度器+驱动回调]
    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 模式通过多队列并行消除了锁瓶颈,是高性能块设备驱动的必选架构。

落地路线建议:

  1. 从 request_fn 入门:先实现一个基于 request_fn 的虚拟块设备驱动,理解 bio、request、request_queue 三个核心数据结构的关系和 I/O 路径。

  2. 使用 fio 基准测试:对驱动进行 I/O 吞吐量、延迟分布和 CPU 利用率的基准测试,量化单队列锁竞争的实际影响。

  3. 迁移到 blk-mq:当 I/O 吞吐量需求超过 100K IOPS 或 CPU 核心数超过 8 时,开始向 blk-mq 架构迁移。优先实现 queue_rqcomplete 回调。

  4. 硬件队列数调优:blk-mq 的硬件队列数应与设备的 DMA 通道数匹配。NVMe 设备通常配置为与 CPU 核心数相等的硬件队列数。

  5. I/O 调度器选择:NVMe 设备使用 none 调度器;SATA SSD 使用 mq-deadline;机械硬盘使用 bfq(交互式场景)或 mq-deadline(服务器场景)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值