目錄
序言
1.塊設備結構
分區(gendisk)
請求(request)
請求隊列
1.?多隊列架構
2.?默認限制與擴展
bio
2.塊設備的使用
頭文件與宏定義
blk-mq 相關結構和操作
塊設備操作函數
?模塊初始化函數
?模塊退出函數
3.總結
序言
塊設備(如硬盤、虛擬盤)以固定大小的塊(扇區)進行讀寫。塊設備驅動的主要任務就是響應來自文件系統的 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 分配內存基本單位為bytestruct 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);elsememcpy(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); // 分區數量設為 1if (!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盤這類存儲設備)總的來說就是可以讀寫磁盤的一個設備。