目录
序言
块设备(如硬盘、虚拟盘)以固定大小的块(扇区)进行读写。块设备驱动的主要任务就是响应来自文件系统的 I/O 请求,并将数据正确地读写到设备对应的存储区域。
为了在多核系统中提高并发性能,最新内核采用了blk-mq(Block Multi-Queue)接口,它使用多个硬件队列来分发和处理 I/O 请求。每个请求包含数据传输的信息(如起始扇区、数据长度等),本篇文章将使用blk-mq而不再讲述单队列模式,另外如果有兴趣深入学习块设备和多队列模式可以阅读此链接。
1.块设备结构
分区(gendisk)
gendisk
是内核中描述一个块设备的结构体,可以理解为分区,它保存了设备的主设备号、次设备号、设备名称、容量、操作函数等信息。当驱动调用 add_disk()
后,系统会将设备显示在 /dev
下。
块设备中最小的可寻址单元是扇区,扇区大小一般是2的整数倍,最常见的大小是512字节。扇区的大小是设备的物理属性,扇区是所有块设备的基本单元,块设备 无法对比它还小的单元进行寻址和操作,不过许多块设备能够一次就传输多个扇区。虽然大多数块设备的扇区大小都是512字节,不过其它大小的扇区也很常见, 比如,很多CD-ROM盘的扇区都是2K大小。不管物理设备的真实扇区大小是多少,内核与块设备驱动交互的扇区都以512字节为单位。因此,set_capacity()函数也以512字节为单位。
请求(request)
request是描述I/O请求,包含数据传输的信息(如起始扇区、数据长度等)还有bio结构体。
下面只列出部分request结构体中的参数,具体的请查阅源文件或资料。
- 扇区参数
sector_t hard_sector;
unsigned long hard_nr_sectors;
unsigned int hard_cur_sectors;
上述 3 个成员标识还未完成的扇区,hard_sector 是第一个尚未传输的扇区,hard_nr_sectors 是尚待完成的扇区数,hard_cur_sectors 是当前 I/O 操作中待完成的扇区数。这些成员只用于内核块设备层,驱动不应当使用它们。
在驱动程序中一般使用的是:
sector_t sector;
unsigned long nr_sectors;
unsigned int current_nr_sectors;
这 3 个成员在内核和驱动交互中发挥着重大作用。它们以 512 字节大小为一个扇区,如果硬件的扇区大小不是 512 字节,则需要进行相应的调整。例如,如果硬件的扇区大小是 2048 字节,则在进行硬件操作之前,需要用 4 来除起始扇区号。
注意:hard_sector 、 hard_nr_sectors 、 hard_cur_sectors 与 sector 、 nr_sectors 、
current_nr_sectors 之间可认为是“副本”关系。
- struct bio *bio bio是这个请求中包含的 bio 结构体的链表,驱动中不宜直接存取这个成员,而应该使用后文将介绍的rq_for_each_bio()。
- char *buffer 指向缓冲区的指针,数据应当被传送到或者来自这个缓冲区,这个指针是一个内核虚拟地址,可被驱动直接引用。
使用如下宏可以从 request 获得数据传送的方向
rq_data_dir(struct request *req);
0 返回值表示从设备中读,非 0 返回值表示向设备写。
请求队列
当外部设备或用户程序访问块设备时,会发起I/O请求,而我们的块设备有一个请求队列,我们这里是最新的blk-mq队列,其会为每一个CPU都分配一组软件队列和硬件队列,每个队列可以支持0-1023个I/O请求
1. 多队列架构
软件队列(Software Queues):每个 CPU 核心分配一个队列,减少锁竞争。
硬件派发队列(Hardware Dispatch Queues):根据设备硬件队列数量分配,映射到实际硬件通道。
标签集(Tag Set):管理请求标签,实现请求与硬件的解耦。
2. 默认限制与扩展
队列深度(Queue Depth):默认 1024,但需根据硬件能力调整。
硬件队列数量:建议与 CPU 核心数或硬件通道数对齐。
struct bio
{
sector_t bi_sector; /* 标识这个 bio 要传送的第一个(512 字节)扇区。 */
struct bio *bi_next; /* 下一个 bio */
struct block_device *bi_bdev;
unsigned long bi_flags; /* 一组描述 bio 的标志,如果这是一个写请求,最低有效位被置位,
可以使用bio_data_dir(bio)宏来获得读写方向。 */
unsigned long bi_rw; /* 低位表示 READ/WRITE,高位表示优先级*/
unsigned short bi_vcnt; /* bio_vec 数量 */
unsigned short bi_idx; /* 当前 bvl_vec 索引 */
/*不相邻的物理段的数目*/
unsigned short bi_phys_segments;
/*物理合并和 DMA remap 合并后不相邻的物理段的数目*/
unsigned short bi_hw_segments;
unsigned int bi_size; /* 以字节为单位所需传输的数据大小,驱动中可以使用bio_sectors(bio)宏获得以扇区为单位的大小。 */
/* 为了明了最大的 hw 尺寸,我们考虑这个 bio 中第一个和最后一个 虚拟的可合并的段的尺寸 */
unsigned int bi_hw_front_size;
unsigned int bi_hw_back_size;
unsigned int bi_max_vecs; /* 我们能持有的最大 bvl_vecs 数 */
struct bio_vec *bi_io_vec; /* bio_vec 结构体,bio 的核心*/
bio_end_io_t *bi_end_io;
atomic_t bi_cnt;
void *bi_private;
bio_destructor_t *bi_destructor;
};
具体如何配置后面会讲到。
bio
I/O 请求的数据通常以 bio
(block I/O)表示,一个请求中可能包含多个bio,而 bio中的每个数据段用 bio_vec
表示。一个 bio_vec
指向一段连续的内存页数据,在数据传输过程中我们需要将这些页映射到内核地址空间进行访问(通过 kmap
与 kunmap
)。
bio结构体:
struct bio
{
sector_t bi_sector; /* 标识这个 bio 要传送的第一个(512 字节)扇区。 */
struct bio *bi_next; /* 下一个 bio */
struct block_device *bi_bdev;
unsigned long bi_flags; /* 一组描述 bio 的标志,如果这是一个写请求,最低有效位被置位,
可以使用bio_data_dir(bio)宏来获得读写方向。 */
unsigned long bi_rw; /* 低位表示 READ/WRITE,高位表示优先级*/
unsigned short bi_vcnt; /* bio_vec 数量 */
unsigned short bi_idx; /* 当前 bvl_vec 索引 */
/*不相邻的物理段的数目*/
unsigned short bi_phys_segments;
/*物理合并和 DMA remap 合并后不相邻的物理段的数目*/
unsigned short bi_hw_segments;
unsigned int bi_size; /* 以字节为单位所需传输的数据大小,驱动中可以使用bio_sectors(bio)宏获得以扇区为单位的大小。 */
/* 为了明了最大的 hw 尺寸,我们考虑这个 bio 中第一个和最后一个 虚拟的可合并的段的尺寸 */
unsigned int bi_hw_front_size;
unsigned int bi_hw_back_size;
unsigned int bi_max_vecs; /* 我们能持有的最大 bvl_vecs 数 */
struct bio_vec *bi_io_vec; /* bio_vec 结构体,bio 的核心*/
bio_end_io_t *bi_end_io;
atomic_t bi_cnt;
void *bi_private;
bio_destructor_t *bi_destructor;
};
bio_vec结构体:
struct bio_vec
{
struct page *bv_page; /* 页指针 */
unsigned int bv_len; /* 传输的字节数 */
unsigned int bv_offset; /* 偏移位置 */
};
2.块设备的使用
头文件与宏定义
包含内核模块、块设备、内存管理等所需的头文件,并定义设备名称、扇区大小(通常为 512 字节)和设备总大小(例如 16MB)。并且定义了一个自定义数据结构(例如 struct mydisk_device
),用来保存设备的存储数据指针和设备大小。全局变量 device
保存了设备的实例。
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/blkdev.h>
#include <linux/genhd.h>
#include <linux/vmalloc.h>
#include <linux/spinlock.h>
#include <linux/blk-mq.h>
#include <linux/bio.h>
#include <linux/highmem.h> // 用于 kmap/kunmap
#define DEVICE_NAME "myblk" // 设备名称
#define KERNEL_SECTOR_SIZE 512 // 内核扇区大小,固定为 512 字节
#define MYDISK_SIZE (16 * 1024 * 1024) // 设备大小:16MB 分配内存基本单位为byte
struct mydisk_device {
unsigned char *data; // 存放设备数据的内存区
size_t size; // 设备的总大小(字节)
};
static struct mydisk_device *device = NULL;
自定义结构用于保存设备的内存数据区域和大小。在初始化阶段,我们会分配一块内存作为设备的“存储区”。
blk-mq 相关结构和操作
static struct blk_mq_tag_set tag_set;
static struct request_queue *queue = NULL;
tag_set:配置多队列(blk-mq)的参数,如硬件队列数、队列深度、NUMA 亲和性等。
queue:所有 I/O 请求都会被加入到这个请求队列中,blk-mq 会调用我们定义的处理函数来处理这些请求。
blk-mq 请求处理函数
这是驱动核心部分,用于处理每个 I/O 请求。函数中会根据请求的起始扇区和请求长度计算出设备内存的偏移,然后遍历请求中的所有 bio_vec
数据段,根据请求方向(读或写)完成数据拷贝,最后调用 blk_mq_end_request()
通知系统该请求处理完毕
static blk_status_t myblk_mq_fn(struct blk_mq_hw_ctx *hctx,
const struct blk_mq_queue_data *bd)
{
struct request *req = bd->rq;
blk_status_t status = BLK_STS_OK;
unsigned long offset;
unsigned int total_len;
struct req_iterator iter;
struct bio_vec bvec;
unsigned int copied = 0;
/* 根据请求起始扇区计算设备内存偏移 */
offset = blk_rq_pos(req) * KERNEL_SECTOR_SIZE;
total_len = blk_rq_bytes(req);
/* 检查请求是否超出设备范围 */
if (offset + total_len > device->size) {
printk(KERN_NOTICE "myblk: 请求超出设备范围: offset %lu, len %u\n", offset, total_len);
status = BLK_STS_IOERR;
goto done;
}
/* 遍历请求中的每个 bio_vec 数据段 */
rq_for_each_segment(bvec, req, iter) {
/* 将 bio_vec 中的页映射到内核地址空间 */
char *buffer = kmap(bvec.bv_page) + bvec.bv_offset;
unsigned int len = bvec.bv_len;
if (copied + len > total_len)
len = total_len - copied;
/* 根据请求类型进行数据拷贝:
* - 读请求:将设备数据复制到用户请求缓冲区
* - 写请求:将用户数据写入设备内存
*/
if (rq_data_dir(req) == READ)
memcpy(buffer, device->data + offset + copied, len);
else
memcpy(device->data + offset + copied, buffer, len);
copied += len;
kunmap(bvec.bv_page);
}
done:
/* 通知 blk-mq 请求处理完毕 */
blk_mq_end_request(req, status);
return status;
}
请求参数解析:
blk_rq_pos(req)
返回请求的起始扇区,乘以 512 得到字节偏移。blk_rq_bytes(req)
返回请求需要传输的总字节数。
边界检查:
检查请求的数据范围是否超过了设备分配的内存。如果超出,则返回错误状态。遍历 bio_vec 数据段:
使用rq_for_each_segment()
遍历请求中每个数据段,每个数据段都对应一块内存页。通过
kmap
将页映射到内核虚拟地址空间,进行数据拷贝。根据请求方向(读或写)选择合适的
memcpy
操作。使用
kunmap
解除映射。
结束请求:
调用blk_mq_end_request()
告诉内核该请求已经完成,状态(成功或错误)作为参数传递。
块设备操作函数
static int myblk_open(struct block_device *bdev, fmode_t mode)
{
printk(KERN_INFO "myblk: 设备打开\n");
return 0;
}
static void myblk_release(struct gendisk *disk, fmode_t mode)
{
printk(KERN_INFO "myblk: 设备关闭\n");
}
static int myblk_getgeo(struct block_device *bdev, struct hd_geometry *geo)
{
geo->heads = 4;
geo->sectors = 32;
geo->cylinders = (MYDISK_SIZE) / (4 * 32 * KERNEL_SECTOR_SIZE);
geo->start = 0;
return 0;
}
open 与 release
当用户程序通过/dev/myblk
打开或关闭设备时,这两个函数会被调用。这里仅打印日志,实际应用中可能需要对设备进行状态维护或加锁操作。getgeo
该函数用于返回设备的几何信息(柱面、磁头、扇区数),部分老旧应用可能依赖这些信息,但对于虚拟设备来说,这个值通常只作兼容性返回。
定义块设备操作结构体:
static struct block_device_operations myblk_fops = {
.owner = THIS_MODULE,
.open = myblk_open,
.release= myblk_release,
.getgeo = myblk_getgeo,
};
将前面定义的设备操作函数绑定到块设备操作结构体中,供系统调用。
模块初始化函数
static int __init myblk_init(void)
{
int ret;
printk(KERN_INFO "myblk: 模块初始化\n");
/* 分配设备结构 */
device = kmalloc(sizeof(*device), GFP_KERNEL);
if (!device) {
ret = -ENOMEM;
goto out;
}
device->size = MYDISK_SIZE;
/* 分配设备存储内存 */
device->data = vmalloc(device->size);
if (!device->data) {
ret = -ENOMEM;
goto free_device;
}
/* 初始化 blk-mq tag_set */
memset(&tag_set, 0, sizeof(tag_set));
tag_set.ops = &mq_ops; // 指定请求处理函数所在的操作结构
tag_set.nr_hw_queues = 1; // 设置硬件队列数量(这里只使用一个队列)
tag_set.queue_depth = 128; // 队列深度,根据实际需求调整
tag_set.numa_node = NUMA_NO_NODE;
tag_set.cmd_size = 0;
ret = blk_mq_alloc_tag_set(&tag_set);
if (ret)
goto free_data;
/* 初始化请求队列,采用 blk-mq 接口 */
queue = blk_mq_init_queue(&tag_set);
if (IS_ERR(queue)) {
ret = PTR_ERR(queue);
goto free_tag_set;
}
queue->queuedata = device;
/* 注册块设备,动态分配主设备号 */
major_num = register_blkdev(0, DEVICE_NAME);
if (major_num <= 0) {
ret = -EBUSY;
goto cleanup_queue;
}
/* 分配并初始化 gendisk 结构 */
mydisk = alloc_disk(1); // 分区数量设为 1
if (!mydisk) {
ret = -ENOMEM;
goto unregister_blk;
}
mydisk->major = major_num;
mydisk->first_minor = 0;
mydisk->fops = &myblk_fops;
mydisk->private_data = device;
snprintf(mydisk->disk_name, 32, DEVICE_NAME);
set_capacity(mydisk, device->size / KERNEL_SECTOR_SIZE);
mydisk->queue = queue;
/* 将设备添加到系统中,使其在 /dev 下可见 */
add_disk(mydisk);
printk(KERN_INFO "myblk: 模块加载成功\n");
return 0;
unregister_blk:
unregister_blkdev(major_num, DEVICE_NAME);
cleanup_queue:
blk_cleanup_queue(queue);
free_tag_set:
blk_mq_free_tag_set(&tag_set);
free_data:
vfree(device->data);
free_device:
kfree(device);
out:
return ret;
}
设备结构与内存分配
使用kmalloc
分配保存设备信息的结构,再用vmalloc
分配一块连续的虚拟内存作为存储空间。初始化 blk-mq
通过设置tag_set
的各项参数(例如硬件队列数和队列深度),并调用blk_mq_alloc_tag_set
和blk_mq_init_queue
来建立基于 blk-mq 的请求队列。设备注册与 gendisk 初始化
使用
register_blkdev()
动态获取一个主设备号;分配并设置
gendisk
结构,包括设备号、操作函数、设备容量(通过set_capacity
将字节数转成扇区数)和请求队列;最后调用
add_disk()
注册设备,使其在系统中可见(如/dev/myblk
),这里填写的数字为分区数字,用来划分不同的区域。
模块退出函数
static void __exit myblk_exit(void)
{
del_gendisk(mydisk);
put_disk(mydisk);
unregister_blkdev(major_num, DEVICE_NAME);
blk_cleanup_queue(queue);
blk_mq_free_tag_set(&tag_set);
vfree(device->data);
kfree(device);
printk(KERN_INFO "myblk: 模块卸载\n");
}
清理顺序
模块卸载时要反向释放在初始化时分配的所有资源:
先通过
del_gendisk
和put_disk
移除并释放 gendisk 结构;注销块设备主设备号;
清理请求队列和释放 blk-mq 的 tag_set;
最后释放分配的内存区域。
3.总结
通过对块设备的深入研究,我们对其工作原理、数据传输方式以及在多核系统中的并发性能有了更清晰的认识。块设备以固定大小的扇区为单位进行数据读写,块设备驱动程序负责响应文件系统的I/O请求,将数据准确地读写到设备的存储区域。在多核系统中,采用blk-mq(Block Multi-Queue)接口可以提高并发性能,利用多个硬件队列来分发和处理I/O请求。
在块设备的实现过程中,gendisk
结构体用于描述设备的基本信息,如主次设备号、设备名称和容量等。request
结构体则描述具体的I/O请求,包含数据传输的起始扇区、数据长度等信息。bio
(block I/O)结构体用于表示I/O请求的数据,可能包含多个bio_vec
,每个bio_vec
指向一段连续的内存数据。
最后再聊聊块设备,其实我自己也迷糊了一会,块设备,到底是干嘛的?
硬件的块设备就是SSD,HHD这类数据存储设备
软件的块设备其实就是我们的虚拟磁盘,当操作系统操作数据需要与块设备进行I/O操作,而块设备负责管理这些数据,它将数据划分成大小相等的扇区(例如每个扇区为 512B),每个都有对应的标识符,当操作系统或者用户程序需要访问时,就需要通过块设备去进行存取(就是这么简单,可惜之前傻傻分不清还以为是I/O通道,又一阵子以为是u盘这类存储设备)总的来说就是可以读写磁盘的一个设备。