Sampleblk 是一個用於學習目的的 Linux 塊設備驅動項目。其中 day1 的源代碼實現了一個最簡的塊設備驅動,源代碼只有 200 多行。本文主要圍繞這些源代碼,討論 Linux 塊設備驅動開發的基本知識。html
開發 Linux 驅動須要作一系列的開發環境準備工做。Sampleblk 驅動是在 Linux 4.6.0 下開發和調試的。因爲在不一樣 Linux 內核版本的通用 block 層的 API 有很大變化,這個驅動在其它內核版本編譯可能會有問題。開發,編譯,調試內核模塊須要先準備內核開發環境,編譯內核源代碼。這些基礎的內容互聯網上隨處可得,本文再也不贅述。linux
此外,開發 Linux 設備驅動的經典書籍當屬 Device Drivers, Third Edition 簡稱 LDD3。該書籍是免費的,能夠自由下載並按照其規定的 License 從新分發。git
Linux 驅動模塊的開發遵照 Linux 爲模塊開發者提供的基本框架和 API。LDD3 的 hello world 模塊提供了寫一個最簡內核模塊的例子。而 Sampleblk 塊驅動的模塊與之相似,實現了 Linux 內核模塊所必需的模塊初始化和退出函數,github
module_init(sampleblk_init); module_exit(sampleblk_exit);
與 hello world 模塊不一樣的是,Sampleblk 驅動的初始化和退出函數要實現一個塊設備驅動程序所必需的基本功能。本節主要針對這部份內容作詳細說明。數組
概括起來,sampleblk_init
函數爲完成塊設備驅動的初始化,主要作了如下幾件事情,bash
調用 register_blkdev
完成 major number 的分配和註冊,函數原型以下,markdown
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;
一般,全部 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;
使用 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.
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, };
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);
這是個 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」);
理解塊設備驅動的策略函數實現,必需先對 Linux IO 棧的關鍵數據結構有所瞭解。
struct request_queue
塊設備驅動待處理的 IO 請求隊列結構。若是該隊列是利用blk_init_queue
分配和初始化的,則該隊裏內的 IO 請求( struct request
)須要通過 IO 調度器的處理(排序或合併),由 blk_queue_bio
觸發。
當塊設備策略驅動函數被調用時,request
是經過其 queuelist
成員連接在 struct request_queue
的 queue_head
鏈表裏的。一個 IO 申請隊列上會有不少個 request
結構。
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,版權歸原做者全部),
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 操做。
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; }
首先,須要下載內核源代碼,編譯和安裝內核,用新內核啓動。
因爲本驅動是在 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 的使用,請參考延伸閱讀。
問題:把驅動的 sampleblk_request
函數實現所有刪除,從新編譯和加載內核模塊。而後用 rmmod 卸載模塊,卸載會失敗, 內核報告模塊正在被使用。
使用 strace
能夠觀察到 /sys/module/sampleblk/refcnt
非零,即模塊正在被使用。
$ strace rmmod sampleblk execve("/usr/sbin/rmmod", ["rmmod", "sampleblk"], [/* 26 vars */]) = 0 ................[snipped].......................... openat(AT_FDCWD, "/sys/module/sampleblk/holders", O_RDONLY|O_NONBLOCK|O_DIRECTORY|O_CLOEXEC) = 3 getdents(3, /* 2 entries */, 32768) = 48 getdents(3, /* 0 entries */, 32768) = 0 close(3) = 0 open("/sys/module/sampleblk/refcnt", O_RDONLY|O_CLOEXEC) = 3 /* 顯示引用數爲 3 */ read(3, "1\n", 31) = 2 read(3, "", 29) = 0 close(3) = 0 write(2, "rmmod: ERROR: Module sampleblk i"..., 41rmmod: ERROR: Module sampleblk is in use ) = 41 exit_group(1) = ? +++ exited with 1 +++
若是用 lsmod
命令查看,能夠看到模塊的引用計數確實是 3,但沒有顯示引用者的名字。通常狀況下,只有內核模塊間的相互引用纔有引用模塊的名字,因此沒有引用者的名字,那麼引用者來自用戶空間的進程。
那麼,到底是誰在使用 sampleblk 這個剛剛加載的驅動呢?利用 module:module_get
tracepoint,就能夠獲得答案了。從新啓動內核,在加載模塊前,運行 tpoint
命令。而後,再運行 insmod
來加載模塊。
$ sudo ./tpoint module:module_get Tracing 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/sampleblk PID: 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 time messages:Apr 23 03:12:02 localhost systemd-udevd: worker [2466] /devices/virtual/block/sampleblk1 timeout; kill it messages: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 設備的訪問。
雖然 Sampleblk 塊驅動只有 200 行源碼,但已經能夠看成 ramdisk 來使用,在其上能夠建立文件系統,
$ sudo mkfs.ext4 /dev/sampleblk1
文件系統建立成功後,mount
文件系統,並建立一個空文件 a。能夠看到,均可以正常運行。
$sudo mount /dev/sampleblk1 /mnt $touch a
至此,sampleblk 作爲 ramdisk 的最基本功能已經實驗完畢。