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,内核会用默认行为。但 open 和 release 我建议一定要实现——哪怕只是做个引用计数。为什么?
我曾经遇到一个 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 是顶层结构,它持有
queue和fops的指针 - request_queue 负责管理 I/O 请求,每个请求由多个 bio 组成
- block_device_operations 定义了设备的操作接口
- bio 是底层的数据载体,承载实际的读写数据
我的建议:写驱动时,先从 gendisk 入手,把它需要的资源(队列、操作函数)准备好,再注册。这个顺序搞反了,后面调试会很痛苦。我早期一个项目就是先注册了 gendisk,再初始化队列,结果内核在注册时就尝试发送请求,队列还没准备好——直接 panic。
好了,这四个结构体就讲到这里。记住它们的关系:gendisk 是身份证,request_queue 是调度中心,block_device_operations 是操作手册,bio 是数据快递员。搞懂了它们,块设备驱动的大框架你就拿下了。
公众号:蓝海资料掘金营,微信deep3321