1. 背景
Sampleblk 是一個用於學習目的的 Linux 塊裝置驅動項目。其中 day1 的原始碼實現了一個最簡的塊裝置驅動,原始碼只有 200 多行。本文主要圍繞這些原始碼,討論 Linux 塊裝置驅動開發的基本知識。
開發 Linux 驅動需要做一系列的開發環境準備工作。Sampleblk 驅動是在 Linux 4.6.0 下開發和調試的。由於在不同 Linux 核心版本的通用 block 層的 API 有很大變化,這個驅動在其它核心版本編譯可能會有問題。開發,編譯,調試核心模組需要先準備核心開發環境,編譯核心原始碼。這些基礎的內容互連網上隨處可得,本文不再贅述。
此外,開發 Linux 裝置驅動的經典書籍當屬 Device Drivers, Third Edition 簡稱 LDD3。該書籍是免費的,可以自由下載並按照其規定的 License 重新分發。 2. 模組初始化和退出
Linux 驅動模組的開發遵守 Linux 為模組開發人員提供的基本架構和 API。LDD3 的 hello world 模組提供了寫一個最簡核心模組的例子。而 Sampleblk 塊驅動的模組與之類似,實現了 Linux 核心模組所必需的模組初始化和退出函數,
module_init(sampleblk_init);module_exit(sampleblk_exit);
與 hello world 模組不同的是,Sampleblk 驅動的初始化和退出函數要實現一個塊裝置驅動程式所必需的準系統。本節主要針對這部分內容做詳細說明。 2.1 sampleblk_init
歸納起來,sampleblk_init 函數為完成塊裝置驅動的初始化,主要做了以下幾件事情, 2.1.1 塊裝置註冊
調用 register_blkdev 完成 major number 的分配和註冊,函數原型如下,
int register_blkdev(unsigned int major, const char *name);
Linux 核心為塊裝置驅動維護了一個全域雜湊表 major_names這個雜湊表的 bucket 是 [0..255] 的整數索引的指向 blk_major_name 的結構指標數組。
static struct blk_major_name { struct blk_major_name *next; int major; char name[16];} *major_names[BLKDEV_MAJOR_HASH_SIZE];
而 register_blkdev 的 major 參數不為 0 時,其實現就嘗試在這個雜湊表中尋找指定的 major 對應的 bucket 裡的空閑指標,分配一個新的blk_major_name,按照指定參數初始化 major 和 name。假如指定的 major 已經被別人佔用(指標非空),則表示 major 號衝突,反回錯誤。
當 major 參數為 0 時,則由核心從 [1..255] 的整數範圍內分配一個未使用的反回給調用者。因此,雖然 Linux 核心的主裝置號 (Major Number) 是 12 位的,不指定 major 時,仍舊從 [1..255] 範圍內分配。
Sampleblk 驅動通過指定 major 為 0,讓核心為其分配和註冊一個未使用的主裝置號,其代碼如下,
sampleblk_major = register_blkdev(0, "sampleblk");if (sampleblk_major < 0) return sampleblk_major;
2.1.2 驅動狀態資料結構的分配和初始化
通常,所有 Linux 核心驅動都會聲明一個資料結構來儲存驅動需要頻繁訪問的狀態資訊。這裡,我們為 Sampleblk 驅動也聲明了一個,
struct sampleblk_dev { int minor; spinlock_t lock; struct request_queue *queue; struct gendisk *disk; ssize_t size; void *data;};
為了簡化實現和方便調試,Sampleblk 驅動暫時只支援一個 minor 裝置號,並且可以用以下全域變數訪問,
struct sampleblk_dev *sampleblk_dev = NULL;
下面的代碼分配了 sampleblk_dev 結構,並且給結構的成員做了初始化,
sampleblk_dev = kzalloc(sizeof(struct sampleblk_dev), GFP_KERNEL);if (!sampleblk_dev) { rv = -ENOMEM; goto fail;}sampleblk_dev->size = sampleblk_sect_size * sampleblk_nsects;sampleblk_dev->data = vmalloc(sampleblk_dev->size);if (!sampleblk_dev->data) { rv = -ENOMEM; goto fail_dev;}sampleblk_dev->minor = minor;
2.1.3 Request Queue 初始化
使用 blk_init_queue 初始化 Request Queue 需要先聲明一個所謂的策略 (Strategy) 回調和保護該 Request Queue 的自旋鎖。然後將該策略回調的函數指標和自旋鎖指標做為參數傳遞給該函數。
在 Sampleblk 驅動裡,就是 sampleblk_request 函數和 sampleblk_dev->lock,
spin_lock_init(&sampleblk_dev->lock);sampleblk_dev->queue = blk_init_queue(sampleblk_request, &sampleblk_dev->lock);if (!sampleblk_dev->queue) { rv = -ENOMEM; goto fail_data;}
策略函數 sampleblk_request 用於執行塊裝置的 read 和 write IO 操作,其主要的入口參數就是 Request Queue 結構:struct request_queue。關於策略函數的具體實現我們稍後介紹。
當執行 blk_init_queue 時,其內部實現會做如下的處理, 從記憶體中分配一個 struct request_queue 結構。 初始化 struct request_queue 結構。對調用者來說,其中以下部分的初始化格外重要,
blk_init_queue 指定的策略函數指標會賦值給 struct request_queue 的 request_fn 成員。 blk_init_queue 指定的自旋鎖指標會賦值給 struct request_queue 的 queue_lock 成員。 與這個request_queue 關聯的 IO 調度器的初始化。
Linux 核心提供了多種分配和初始化 Request Queue 的方法, blk_mq_init_queue 主要用於使用多隊列技術的塊裝置驅動 blk_alloc_queue 和 blk_queue_make_request 主要用於繞開核心支援的 IO 調度器的合并和排序,使用自訂的實現。 blk_init_queue 則使用核心支援的 IO 調度器,驅動只專註於策略函數的實現。
Sampleblk 驅動屬於第三種情況。這裡再次強調一下:如果塊裝置驅動需要使用標準的 IO 調度器對 IO 請求進行合并或者排序時,必需使用 blk_init_queue 來分配和初始化 Request Queue. 2.1.4 塊裝置操作函數表初始化
Linux 的塊裝置操作函數表 block_device_operations 定義在 include/linux/blkdev.h 檔案中。塊裝置驅動可以通過定義這個操作函數表來實現對標準塊裝置驅動操作函數的定製。
如果驅動沒有實現這個動作表定義的方法,Linux 塊裝置層的代碼也會按照塊裝置公用層的代碼預設的行為工作。
Sampleblk 驅動雖然聲明了自己的 open, release, ioctl 方法,但這些方法對應的驅動函數內都沒有做實質工作。因此實際的塊裝置操作時的行為是由塊裝置公用層來實現的,
static const struct block_device_operations sampleblk_fops = { .owner = THIS_MODULE, .open = sampleblk_open, .release = sampleblk_release, .ioctl = sampleblk_ioctl,};
2.1.5 磁碟建立和初始化
Linux 核心使用 struct gendisk 來抽象和表示一個磁碟。也就是說,塊裝置驅動要支援正常的塊裝置操作,必需分配和初始化一個 struct gendisk。
首先,使用 alloc_disk 分配一個 struct gendisk,
disk = alloc_disk(minor);if (!disk) { rv = -ENOMEM; goto fail_queue;}sampleblk_dev->disk = disk;
然後,初始化 struct gendisk 的重要成員,尤其是塊裝置操作函數表,Rquest Queue,和容量設定。最終調用 add_disk 來讓磁碟在系統內可見,觸發磁碟熱插拔的 uevent。
disk->major = sampleblk_major;disk->first_minor = minor;disk->fops = &sampleblk_fops;disk->private_data = sampleblk_dev;disk->queue = sampleblk_dev->queue;sprintf(disk->disk_name, "sampleblk%d", minor);set_capacity(disk, sampleblk_nsects);add_disk(disk);
2.2 sampleblk_exit
這是個 sampleblk_init 的逆過程,
刪除磁碟
del_gendisk 是 add_disk 的逆過程,讓磁碟在系統中不再可見,觸發熱插拔 uevent。
del_gendisk(sampleblk_dev->disk);
停止並釋放塊裝置 IO 請求隊列
blk_cleanup_queue 是 blk_init_queue 的逆過程,但其在釋放 struct request_queue 之前,要把待處理的 IO 請求都處理掉。
blk_cleanup_queue(sampleblk_dev->queue);
當 blk_cleanup_queue 把所有 IO 請求全部處理完時,會標記這個隊列馬上要被釋放,這樣可以阻止 blk_run_queue 繼續調用塊驅動的策略函數,繼續執行 IO 請求。Linux 3.8 之前,核心在 blk_run_queue 和 blk_cleanup_queue 同時執行時有嚴重 bug。最近在一個有磁碟 IO 時的 Surprise Remove 的壓力測試中發現了這個 bug (老實說,有些驚訝,這個 bug 存在這麼久一直沒人發現)。
釋放磁碟
put_disk 是 alloc_disk 的逆過程。這裡 gendisk 對應的 kobject 引用計數變為零,徹底釋放掉 gendisk。
put_disk(sampleblk_dev->disk);
釋放資料區
vfree 是 vmalloc 的逆過程。
vfree(sampleblk_dev->data);
釋放驅動全域資料結構。
free 是 kzalloc 的逆過程。
kfree(sampleblk_dev);
登出塊裝置。
unregister_blkdev 是 register_blkdev 的逆過程。
unregister_blkdev(sampleblk_major, “sampleblk”); 3. 策略函數實現
理解塊裝置驅動的策略函數實現,必需先對 Linux IO 棧的關鍵資料結構有所瞭解。 3.1 struct request_queue
塊裝置驅動待處理的 IO 請求隊列結構。如果該隊列是利用blk_init_queue 分配和初始化的,則該隊裡內的 IO 請求( struct request )需要經過 IO 調度器的處理(排序或合并),由 blk_queue_bio 觸發。
當塊裝置策略驅動函數被調用時,request 是通過其 queuelist 成員連結在 struct request_queue 的 queue_head 鏈表裡的。一個 IO 申請隊列上會有很多個 request 結構。 3.2 struct bio
一個 bio 邏輯上代表了上層某個任務對通用塊裝置層發起的 IO 請求。來自不同應用,不同內容相關的,不同線程的 IO 請求在塊裝置驅動層被封裝成不同的 bio 資料結構。
同一個 bio 結構的資料是由塊裝置上從起始扇區開始的物理連續扇區組成的。由於在塊裝置上連續的物理扇區在記憶體中無法保證是實體記憶體連續的,因此才有了段 (Segment)的概念。在 Segment 內部的塊裝置的扇區是實體記憶體連續的,但 Segment 之間卻不能保證實體記憶體的連續性。Segment 長度不會超過記憶體頁大小,而且總是扇區大小的整數倍。
下圖清晰的展現了扇區 (Sector),塊 (Block) 和段 (Segment) 在記憶體頁 (Page) 內部的布局,以及它們之間的關係(註:圖截取自 Understand Linux Kernel 第三版,著作權歸原作者所有),
因此,一個 Segment 可以用 [page, offset, len] 來唯一確定。一個 bio 結構可以包含多個 Segment。而 bio 結構通過指向 Segment 的指標數組來表示了這種一對多關聯性。
在 struct bio 中,成員 bi_io_vec 就是前文所述的“指向 Segment 的指標數組” 的基地址,而每個數組的元素就是指向 struct bio_vec 的指標。
struct bio { [...snipped..] struct bio_vec *bi_io_vec; /* the actual vec list */ [...snipped..]}
而 struct bio_vec 就是描述一個 Segment 的資料結構,
struct bio_vec { struct page *bv_page; /* Segment 所在的物理頁的 struct page 結構指標 */ unsigned int bv_len; /* Segment 長度,扇區整數倍 */ unsigned int bv_offset; /* Segment 在物理頁內起始的位移地址 */};
在 struct bio 中的另一個成員 bi_vcnt 用來描述這個 bio 裡有多少個 Segment,即指標數組的元素個數。一個 bio 最多包含的 Segment/Page 數是由如下核心宏定義決定的,
#define BIO_MAX_PAGES 256
多個 bio 結構可以通過成員 bi_next 連結成一個鏈表。bio 鏈表可以是某個做 IO 的任務 task_struct 成員 bio_list 所維護的一個鏈表。也可以是某個 struct request 所屬的一個鏈表(下節內容)。
下圖展現了 bio 結構通過 bi_next 連結組成的鏈表。其中的每個 bio 結構和 Segment/Page 存在一對多關聯性 (註:圖截取自 Professional Linux Kernel Architecture,著作權歸原作者所有),
3.3 struct request
一個 request 邏輯上代表了塊裝置驅動層收到的 IO 請求。該 IO 請求的資料在塊裝置上是從起始扇區開始的物理連續扇區組成的。
在 struct request 裡可以包含很多個 struct bio,主要是通過 bio 結構的 bi_next 連結成一個鏈表。這個鏈表的第一個 bio 結構,則由 struct request 的 bio 成員指向。
而鏈表的尾部則由 biotail 成員指向。
通用塊裝置層接收到的來自不同線程的 bio 後,通常根據情況選擇如下兩種方案之一,
將 bio 合并入已有的 request
blk_queue_bio 會調用 IO 調度器做 IO 的合并 (merge)。多個 bio 可能因此被合并到同一個 request 結構裡,組成一個 request 結構內部的 bio 結構鏈表。由於每個 bio 結構都來自不同的任務,因此 IO 請求合并只能在 request 結構層面通過鏈表插入排序完成,原有的 bio 結構內部不會被修改。
分配新的 request
如果 bio 不能被合并到已有的 request 裡,通用塊裝置層就會為這個 bio 構造一個新 request 然後插入到 IO 調度器內部的隊列裡。待上層任務通過 blk_finish_plug 來觸發 blk_run_queue 動作,塊裝置驅動的策略函數 request_fn 會觸發 IO 調度器的排序操作,將 request 排序插入塊裝置驅動的 IO 請求隊列。
不論以上哪種情況,通用塊裝置的代碼將會調用塊驅動程式註冊在 request_queue 的 request_fn 回調,這個回調裡最終會將合并或者排序後的 request 交由驅動的底層函數來做 IO 操作。 3.4 策略函數 request_fn
如前所述,當塊裝置驅動使用 blk_run_queue 來分配和初始化 request_queue 時,這個函數也需要驅動指定自訂的策略函數 request_fn 和所需的自旋鎖 queue_lock。驅動實現自己的 request_fn 時,需要瞭解如下特點,
當通用塊層代碼調用 request_fn 時,核心已經拿了這個 request_queue 的 queue_lock。因此,此時的上下文是 atomic 上下文。在驅動的策略函數退出 queue_lock 之前,需要遵守核心在 atomic 內容相關的約束條件。
進入驅動策略函數時,通用塊裝置層代碼可能會同時訪問 request_queue。為了減少在 request_queue 的 queue_lock 上的鎖競爭, 塊驅動策略函數應該儘早退出 queue_lock,然後在策略函數返回前重新拿到鎖。
策略函數是非同步執行的,不處在使用者態進程所對應的核心上下文。因此實現時不能假設策略函數運行在使用者進程的核心上下文中。
Sampleblk 的策略函數是 sampleblk_request,通過 blk_init_queue 註冊到了 request_queue 的 request_fn 成員上。
static void sampleblk_request(struct request_queue *q){ struct request *rq = NULL; int rv = 0; uint64_t pos = 0; ssize_t size = 0; struct bio_vec bvec; struct req_iterator iter; void *kaddr = NULL; while ((rq = blk_fetch_request(q)) != NULL) { spin_unlock_irq(q->queue_lock); if (rq->cmd_type != REQ_TYPE_FS) { rv = -EIO; goto skip; } BUG_ON(sampleblk_dev != rq->rq_disk->private_data); pos = blk_rq_pos(rq) * sampleblk_sect_size; size = blk_rq_bytes(rq); if ((pos + size > sampleblk_dev->size)) { pr_crit("sampleblk: Beyond-end write (%llu %zx)\n", pos, size); rv = -EIO; goto skip; } rq_for_each_segment(bvec, rq, iter) { kaddr = kmap(bvec.bv_page); rv = sampleblk_handle_io(sampleblk_dev, pos, bvec.bv_len, kaddr + bvec.bv_offset, rq_data_dir(rq)); if (rv < 0) goto skip; pos += bvec.bv_len; kunmap(bvec.bv_page); }skip: blk_end_request_all(rq, rv); spin_lock_irq(q->queue_lock); }}
策略函數 sampleblk_request 的實現邏輯如下, 使用 blk_fetch_request 迴圈擷取隊列中每一個待處理 request。
核心功能 blk_fetch_request 可以返回 struct request_queue 的 queue_head 隊列的第一個 request 的指標。然後再調用 blk_dequeue_request 從隊列裡摘除這個 request。 每拿到一個 request,立即退出鎖 queue_lock,但處理完每個 request,需要再次獲得 queue_lock。 REQ_TYPE_FS 用來檢查是否是一個來自檔案系統的 request。本驅動不支援非檔案系統 request。 blk_rq_pos 可以返回 request 的起始扇區號,而 blk_rq_bytes 返回整個 request 的位元組數,應該是扇區的整數倍。 rq_for_each_segment 這個宏定義用來迴圈迭代遍曆一個 request 裡的每一個 Segment: 即 struct bio_vec。注意,每個 Segment 即 bio_vec 都是以 blk_rq_pos 為起始扇區,物理扇區連續的的。Segment 之間只是實體記憶體不保證連續而已。 每一個 struct bio_vec 都可以利用 kmap 來獲得這個 Segment 所在頁的虛擬位址。利用 bv_offset 和 bv_len 可以進一步知道這個 segment 的確切頁內位移和具體長度。 rq_data_dir 可以獲知這個 request 的請求是 read 還是 write。 處理完畢該 request 之後,必需調用 blk_end_request_all 讓塊通用層代碼做後續處理。
驅動函數 sampleblk_handle_io 把一個 request的每個 segment 都做一次驅動層面的 IO 操作。調用該驅動函數前,起始扇區地址 pos,長度 bv_len, 起始扇區虛擬記憶體地址 kaddr + bvec.bv_offset,和 read/write 都做為參數準備好。由於 Sampleblk 驅動只是一個 ramdisk 驅動,因此,每個 segment 的 IO 操作都是 memcpy 來實現的,
/* * Do an I/O operation for each segment */static int sampleblk_handle_io(struct sampleblk_dev *sampleblk_dev, uint64_t pos, ssize_t size, void *buffer, int write){ if (write) memcpy(sampleblk_dev->data + pos, buffer, size); else memcpy(buffer, sampleblk_dev->data + pos, size); return 0;}
4. 實驗
4.1 編譯和載入
首先,需要下載核心原始碼,編譯和安裝核心,用新核心啟動。
由於本驅動是在 Linux 4.6.0 上開發和調試的,而且塊裝置驅動核心功能不同核心版本變動很大,最好去下載 Linux mainline 原始碼,然後 git checkout 到版本 4.6.0 上編譯核心。編譯和安裝核心的具體步驟網上有很多介紹,這裡請讀者自行解決。
編譯好核心後,在核心目錄,編譯驅動模組。
$ make M=/ws/lktm/drivers/block/sampleblk/day1
驅動編譯成功,載入核心模組
$ sudo insmod /ws/lktm/drivers/block/sampleblk/day1/sampleblk.ko
驅動載入成功後,使用 crash 工具,可以查看 struct smapleblk_dev 的內容,
crash7> mod -s sampleblk /home/yango/ws/lktm/drivers/block/sampleblk/day1/sampleblk.ko
MODULE NAME SIZE OBJECT FILE
ffffffffa03bb580 sampleblk 2681 /home/yango/ws/lktm/drivers/block/sampleblk/day1/sampleblk.ko
crash7> p *sampleblk_dev
$4 = {
minor = 1,
lock = {
{
rlock = {
raw_lock = {
val = {
counter = 0
}
}
}
}
},
queue = 0xffff880034ef9200,
disk = 0xffff880000887000,
size = 524288,
data = 0xffffc90001a5c000
}
註:關於 Linux Crash 的使用,請參考延伸閱讀。 4.2 模組引用問題解決
問題:把驅動的 sampleblk_request 函數實現全部刪除,重新編譯和載入核心模組。然後用 rmmod 卸載模組,卸載會失敗, 核心報告模組正在被使用。
使用 strace 可以觀察到 /sys/module/sampleblk/refcnt 非零,即模組正在被使用。
$ strace rmmod sampleblkexecve("/usr/sbin/rmmod", ["rmmod", "sampleblk"], [/* 26 vars */]) = 0................[snipped]..........................openat(AT_FDCWD, "/sys/module/sampleblk/holders", O_RDONLY|O_NONBLOCK|O_DIRECTORY|O_CLOEXEC) = 3getdents(3, /* 2 entries */, 32768) = 48getdents(3, /* 0 entries */, 32768) = 0close(3) = 0open("/sys/module/sampleblk/refcnt", O_RDONLY|O_CLOEXEC) = 3 /* 顯示引用數為 3 */read(3, "1\n", 31) = 2read(3, "", 29) = 0close(3) = 0write(2, "rmmod: ERROR: Module sampleblk i"..., 41rmmod: ERROR: Module sampleblk is in use) = 41exit_group(1) = ?+++ exited with 1 +++
如果用 lsmod 命令查看,可以看到模組的引用計數確實是 3,但沒有顯示引用者的名字。一般情況下,只有核心模組間的相互引用才有引用模組的名字,所以沒有引用者的名字,那麼引用者來自使用者空間的進程。
那麼,究竟是誰在使用 sampleblk 這個剛剛載入的驅動呢。利用 module:module_get tracepoint,就可以得到答案了。重新啟動核心,在載入模組前,運行 tpoint 命令。然後,再運行 insmod 來載入模組。
$ sudo ./tpoint module:module_getTracing module:module_get. Ctrl-C to end. systemd-udevd-2986 [000] .... 196.382796: module_get: sampleblk call_site=get_disk refcnt=2 systemd-udevd-2986 [000] .... 196.383071: module_get: sampleblk call_site=get_disk refcnt=3
可以看到,原來是 systemd 的 udevd 進程在使用 sampleblk 裝置。如果熟悉 udevd 的人可能就會立即恍然大悟,因為 udevd 負責偵聽系統中所有裝置的熱插拔事件,並負責根據預定義規則來對新裝置執行一系列操作。而 sampleblk 驅動在調用 add_disk 時,kobject 層的代碼會向使用者態的 udevd 發送熱插拔的 uevent,因此 udevd 會開啟塊裝置,做相關的操作。
利用 crash 命令,可以很容易找到是哪個進程在開啟 sampleblk 裝置,
crash> foreach files -R /dev/sampleblkPID: 4084 TASK: ffff88000684d700 CPU: 0 COMMAND: "systemd-udevd"ROOT: / CWD: / FD FILE DENTRY INODE TYPE PATH 8 ffff88000691ad00 ffff88001ffc0600 ffff8800391ada08 BLK /dev/sampleblk1 9 ffff880006918e00 ffff88001ffc0600 ffff8800391ada08 BLK /dev/sampleblk1
由於 sampleblk_request 函數實現被刪除,則 udevd 發送的 IO 操作無法被 sampleblk 裝置驅動完成,因此 udevd 陷入到長期的阻塞等待中,直到逾時返回錯誤,釋放裝置。上述分析可以從系統的訊息日誌中被證實,
messages:Apr 23 03:11:51 localhost systemd-udevd: worker [2466] /devices/virtual/block/sampleblk1 is taking a long timemessages:Apr 23 03:12:02 localhost systemd-udevd: worker [2466] /devices/virtual/block/sampleblk1 timeout; kill itmessages:Apr 23 03:12:02 localhost systemd-udevd: seq 4313 '/devices/virtual/block/sampleblk1' killed
註:tpoint 是一個基於 ftrace 的開源的 bash 指令碼工具,可以直接下載運行使用。它是 Brendan Gregg 在 github 上的開源項目,前文已經給出了項目的連結。
重新把刪除的 sampleblk_request 函數源碼加回去,則這個問題就不會存在。因為 udevd 可以很快結束對 sampleblk 裝置的訪問。 4.3 建立檔案系統
雖然 Sampleblk 塊驅動只有 200 行源碼,但已經可以當作 ramdisk 來使用,在其上可以建立檔案系統,
$ sudo mkfs.ext4 /dev/sampleblk1
檔案系統建立成功後,mount 檔案系統,並建立一個空檔案 a。可以看到,都可以正常運行。
$sudo mount /dev/sampleblk1 /mnt$touch a
至此,sampleblk 做為 ramdisk 的最準系統已經實驗完畢。 5. 延伸閱讀 Linux Crash - background Linux Crash - page cache debug Ftrace: The hidden light switch Device Drivers, Third Edition