3、块设备驱动核心数据结构:gendisk、request_queue、block_device_operations、bio结构体详解

做块设备驱动,说白了就是跟几个核心数据结构打交道。我刚开始接触这块时,看着这些结构体一头雾水——怎么这么多?后来在项目里一个个踩坑,才慢慢摸清楚它们的脾气。今天我就把这四个核心结构体掰开揉碎了讲给你听。

3.1 gendisk:块设备的“身份证”

gendisk 是内核用来描述一个块设备的核心结构。你可以把它想象成块设备的“身份证”——内核通过它来识别和管理每个块设备。

关键点:每个块设备在内核中对应一个 gendisk 实例。没有它,你的设备就不算“块设备”。

先看它的核心成员:

struct gendisk {
    int major;              // 主设备号
    int first_minor;        // 起始次设备号
    int minors;             // 次设备号数量(分区数+1)
    char disk_name[DISK_NAME_LEN]; // 设备名称,比如"sda"
    struct disk_part_tbl *part_tbl; // 分区表
    struct block_device_operations *fops; // 操作函数集
    struct request_queue *queue;       // 请求队列
    void *private_data;                // 私有数据
    ...
};

我在项目中遇到过一个问题:minors 设得太小,导致分区数不够用。当时客户要求支持16个分区,我设成了16,结果忘了主分区本身也要占一个号。嗯,这里要注意——minors 的值是分区数加1。

分配和注册 gendisk 的流程是这样的:

// 1. 分配 gendisk
struct gendisk *disk = alloc_disk(16); // 16个次设备号

// 2. 设置基本属性
disk->major = major;
disk->first_minor = 0;
strcpy(disk->disk_name, "mydisk");
disk->fops = &my_block_ops;
disk->queue = my_queue;
disk->private_data = my_dev;

// 3. 注册到内核
add_disk(disk);

注意:add_disk() 调用之后,内核就会开始向你的设备发送请求。所以在这之前,一定要确保所有初始化工作都完成了。我曾经因为顺序搞反,导致设备刚注册就收到请求,结果处理函数还没准备好——直接崩溃。

3.2 request_queue:请求的“调度中心”

你想想看,上层应用发起的读写请求,最终都要排着队进入 request_queue。这个队列就是块设备驱动的“调度中心”。

创建请求队列有两种方式:

// 方式一:使用块层提供的通用队列
struct request_queue *queue;
queue = blk_init_queue(my_request_fn, &my_lock);

// 方式二:使用 blk-mq(多队列)
queue = blk_mq_init_queue(&my_tag_set);

我个人习惯用 blk-mq,尤其是做 SSD 这类高性能设备时。为什么呢?因为 blk-mq 支持多队列并行处理,能充分利用多核 CPU 的能力。

请求队列的核心参数:

参数 含义 我踩过的坑
queue_depth 队列深度 设太大导致内存占用过高
max_sectors 最大扇区数 设太小影响大块读写性能
max_segments 最大分散/聚集段数 设太小导致 bio 被拆分过多
max_hw_sectors 硬件支持的最大扇区数 超过硬件限制会报错

小技巧:调试时可以用 blk_queue_max_hw_sectors() 动态调整参数,不用每次都重新编译驱动。我在调试一个 NVMe 驱动时就用这招,省了不少时间。

3.3 block_device_operations:驱动的“操作手册”

这个结构体定义了块设备支持的所有操作。说白了,就是告诉内核:“我的设备能干啥”。

struct block_device_operations {
    int (*open)(struct block_device *, fmode_t);
    void (*release)(struct gendisk *, fmode_t);
    int (*ioctl)(struct block_device *, fmode_t, unsigned, unsigned long);
    int (*compat_ioctl)(struct block_device *, fmode_t, unsigned, unsigned long);
    int (*direct_access)(struct block_device *, sector_t, void **, unsigned long *);
    int (*media_changed)(struct gendisk *);
    int (*revalidate_disk)(struct gendisk *);
    int (*getgeo)(struct block_device *, struct hd_geometry *);
    ...
};

实际开发中,大部分函数都可以设为 NULL,内核会用默认行为。但 openrelease 我建议一定要实现——哪怕只是做个引用计数。为什么?

我曾经遇到一个 bug:设备被卸载时,还有进程在访问它。因为没有实现 open/release,内核不知道还有人在用,直接就把设备干掉了。结果就是...嗯,你懂的,系统挂了。

static int my_open(struct block_device *bdev, fmode_t mode)
{
    struct my_dev *dev = bdev->bd_disk->private_data;
    
    // 增加引用计数
    atomic_inc(&dev->refcnt);
    return 0;
}

static void my_release(struct gendisk *disk, fmode_t mode)
{
    struct my_dev *dev = disk->private_data;
    
    // 减少引用计数
    atomic_dec(&dev->refcnt);
}

3.4 bio:I/O请求的“最小单元”

bio 是块层 I/O 请求的基本单位。你想想看,上层应用发一个 read() 调用,经过 VFS、文件系统,最后到块层,就变成了一个个 bio。

bio 的结构:

struct bio {
    sector_t        bi_sector;      // 起始扇区号
    struct bio      *bi_next;       // 链表中的下一个 bio
    struct block_device *bi_bdev;   // 目标块设备
    unsigned long   bi_flags;       // 标志位
    unsigned short  bi_vcnt;        // bio_vec 的数量
    unsigned short  bi_idx;         // 当前处理的 bio_vec 索引
    struct bio_vec  *bi_io_vec;     // 数据段数组
    ...
};

struct bio_vec {
    struct page     *bv_page;       // 数据所在页
    unsigned int    bv_len;         // 数据长度
    unsigned int    bv_offset;      // 页内偏移
};

bio 的处理流程:

static void my_request_fn(struct request_queue *q)
{
    struct request *req;
    
    while ((req = blk_fetch_request(q)) != NULL) {
        struct bio *bio;
        
        // 遍历请求中的所有 bio
        __rq_for_each_bio(bio, req) {
            struct bio_vec bvec;
            struct bvec_iter iter;
            
            // 遍历 bio 中的所有数据段
            bio_for_each_segment(bvec, bio, iter) {
                // 处理每个数据段
                void *addr = kmap(bvec.bv_page) + bvec.bv_offset;
                // ... 读写操作 ...
                kunmap(bvec.bv_page);
            }
        }
        
        // 完成请求
        __blk_end_request_all(req, 0);
    }
}

核心理解:bio 的设计体现了 Linux 块层的精髓——通过 bio_vec 实现零拷贝。数据不用在用户态和内核态之间来回拷贝,而是直接操作物理页面。这也是 Linux 块设备性能好的原因之一。

3.5 四个结构体的关系

这四个结构体不是孤立的,它们组成了一个完整的块设备驱动框架。我画了张图帮你理清关系:

块设备驱动核心数据结构关系图 gendisk 块设备描述符 主/次设备号 设备名称 分区表 request_queue 请求调度中心 队列深度 最大扇区数 调度策略 block_device_ops 操作函数集 open/release ioctl getgeo bio I/O请求单元 起始扇区 bio_vec数组 数据段 queue fops 包含多个bio 一个 gendisk 包含一个 request_queue 和一组 block_device_operations request_queue 中的每个 request 包含一个或多个 bio

从图中你能看到:

  • gendisk 是顶层结构,它持有 queuefops 的指针
  • request_queue 负责管理 I/O 请求,每个请求由多个 bio 组成
  • block_device_operations 定义了设备的操作接口
  • bio 是底层的数据载体,承载实际的读写数据

我的建议:写驱动时,先从 gendisk 入手,把它需要的资源(队列、操作函数)准备好,再注册。这个顺序搞反了,后面调试会很痛苦。我早期一个项目就是先注册了 gendisk,再初始化队列,结果内核在注册时就尝试发送请求,队列还没准备好——直接 panic。

好了,这四个结构体就讲到这里。记住它们的关系:gendisk 是身份证,request_queue 是调度中心,block_device_operations 是操作手册,bio 是数据快递员。搞懂了它们,块设备驱动的大框架你就拿下了。


公众号:蓝海资料掘金营,微信deep3321