Sampleblk 是一個用於學習目的的 Linux 塊設備驅動項目。其中 day1 的源代碼實現了一個最簡的塊設備驅動,源代碼只有 200 多行。本文主要圍繞這些源代碼,討論 Linux 塊設備驅動開發的基本知識。
開發 Linux 驅動需要做一系列的開發環境准備工作。Sampleblk 驅動是在 Linux 4.6.0 下開發和調試的。由於在不同 Linux 內核版本的通用 block 層的 API 有很大變化,這個驅動在其它內核版本編譯可能會有問題。開發,編譯,調試內核模塊需要先准備內核開發環境,編譯內核源代碼。這些基礎的內容互聯網上隨處可得,本文不再贅述。
此外,開發 Linux 設備驅動的經典書籍當屬 Device Drivers, Third Edition 簡稱 LDD3。該書籍是免費的,可以自由下載並按照其規定的 License 重新分發。
Linux 驅動模塊的開發遵守 Linux 為模塊開發者提供的基本框架和 API。LDD3 的 hello world 模塊提供了寫一個最簡內核模塊的例子。而 Sampleblk 塊驅動的模塊與之類似,實現了 Linux 內核模塊所必需的模塊初始化和退出函數,
module_init(sampleblk_init); module_exit(sampleblk_exit);
與 hello world 模塊不同的是,Sampleblk 驅動的初始化和退出函數要實現一個塊設備驅動程序所必需的基本功能。本節主要針對這部分內容做詳細說明。
歸納起來,sampleblk_init 函數為完成塊設備驅動的初始化,主要做了以下幾件事情,
調用 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;
通常,所有 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 結構。對調用者來說,其中以下部分的初始化格外重要,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 棧的關鍵數據結構有所了解。
塊設備驅動待處理的 IO 請求隊列結構。如果該隊列是利用blk_init_queue 分配和初始化的,則該隊裡內的 IO 請求( struct request )需要經過 IO 調度器的處理(排序或合並),由 blk_queue_bio 觸發。
當塊設備策略驅動函數被調用時,request 是通過其 queuelist 成員鏈接在 struct request_queue 的 queue_head 鏈表裡的。一個 IO 申請隊列上會有很多個 request 結構。
一個 bio 邏輯上代表了上層某個任務對通用塊設備層發起的 IO 請求。來自不同應用,不同上下文的,不同線程的 IO 請求在塊設備驅動層被封裝成不同的 bio 數據結構。
同一個 bio 結構的數據是由塊設備上從起始扇區開始的物理連續扇區組成的。由於在塊設備上連續的物理扇區在內存中無法保證是物理內存連續的,因此才有了段 (Segment)的概念。在 Segment 內部的塊設備的扇區是物理內存連續的,但 Segment 之間卻不能保證物理內存的連續性。Segment 長度不會超過內存頁大小,而且總是扇區大小的整數倍。因此,一個 Segment 可以用 [page, offset, len] 來唯一確定。而一個 bio 結構可以包含多個 Segment,可以由指向 Segment 的指針數組來表示。
在 struct bio 中,成員 bi_io_vec 就是前文所述的“指向 Segment 的指針數組” 的基地址,而每個數組的元素就是指向 struct bio_vec 的指針。而 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 所屬的一個鏈表(下節內容)。
一個 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 操作。
如前所述,當塊設備驅動使用 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。驅動函數 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 的最基本功能已經實驗完畢。