塊設備與字符設備的區別
1、 從字面上理解,塊設備和字符設備最大的區別在於讀寫數據的基本單元不同。塊設備讀寫數據的基本單元爲塊,例如磁盤通常爲一個 sector ,而字符設備的基本單元爲字節。所以 Linux 中塊設備驅動往往爲磁盤設備的驅動,但是由於磁盤設備的 IO 性能與 CPU 相比很差,因此,塊設備的數據流往往會引入文件系統的 Cache 機制。
2、 從實現角度來看, Linux 爲塊設備和字符設備提供了兩套機制。字符設備實現的比較簡單,內核例程和用戶態 API 一一對應,用戶層的 Read 函數直接對應了內核中的 Read 例程,這種映射關係由字符設備的 file_operations 維護。塊設備接口相對於字符設備複雜, read 、 write API 沒有直接到塊設備層,而是直接到文件系統層,然後再由文件系統層發起讀寫請求。
塊設備讀寫流程
在學習塊設備原理的時候,我最關係塊設備的數據流程,從應用程序調用 Read 或者 Write 開始,數據在內核中到底是如何流通、處理的呢?然後又如何抵達具體的物理設備的呢?下面對一個帶 Cache 功能的塊設備數據流程進行分析。
1、 用戶態程序通過 open() 打開指定的塊設備,通過 systemcall 機制陷入內核,執行 blkdev_open() 函數,該函數註冊到文件系統方法( file_operations )中的 open 上。在 blkdev_open 函數中調用 bd_acquire() 函數, bd_acquire 函數完成文件系統 inode 到塊設備 bdev 的轉換,具體的轉換方法通過 hash 查找實現。得到具體塊設備的 bdev 之後,調用 do_open() 函數完成設備打開的操作。在 do_open 函數中會調用到塊設備驅動註冊的 open 方法,具體調用如下: gendisk->fops->open(bdev->bd_inode, file) 。
2、 用戶程序通過 read 、 write 函數對設備進行讀寫,文件系統會調用相應的方法,通常會調用如下兩個函數: generic_file_read 和 blkdev_file_write 。在讀寫過程中採用了多種策略,首先分析讀過程。
3、 用戶態調用了 read 函數,內核執行 generic_file_read ,如果不是 direct io 方式,那麼直接調用 do_generic_file_read->do_generic_mapping_read() 函數,在 do_generic_mapping_read (函數位於 filemap.c )函數中,首先查找數據是否命中 Cache ,如果命中,那麼直接將數據返回給用戶態;否則通過 address_space->a_ops->readpage 函數發起一個真實的讀請求。在 readpage 函數中,構造一個 buffer_head ,設置 bh 回調函數 end_buffer_async_read ,然後調用 submit_bh 發起請求。在 submit_bh 函數中,根據 buffer_head 構造 bio ,設置 bio 的回調函數 end_bio_bh_io_sync ,最後通過 submit_bio 將 bio 請求發送給指定的快設備。
4、 如果用戶態調用了一個 write 函數,內核執行 blkdev_file_write 函數,如果不是 direct io 操作方式,那麼執行 buffered write 操作過程,直接調用 generic_file_buffered_write 函數。 Buffered write 操作方法會將數據直接寫入 Cache ,並進行 Cache 的替換操作,在替換操作過程中需要對實際的快設備進行操作, address_space->a_ops 提供了塊設備操作的方法。當數據被寫入到 Cache 之後, write 函數就可以返回了,後繼異步寫入的任務絕大部分交給了 pdflush daemon (有一部分在替換的時候做了)
5、 數據流操作到這一步,我們已經很清楚用戶的數據是如何到內核了。與用戶最接近的方法是 file_operations ,每種設備類型都定義了這一方法(由於 Linux 將所有設備都看成是文件,所以爲每類設備都定義了文件操作方法,例如,字符設備的操作方法爲 def_chr_fops ,塊設備爲 def_blk_fops ,網絡設備爲 bad_sock_fops )。每種設備類型底層操作方法是不一樣的,但是通過 file_operations 方法將設備類型的差異化屏蔽了,這就是 Linux 能夠將所有設備都理解爲文件的緣由。到這裏,又提出一個問題:既然這樣,那設備的差異化又該如何體現呢?在文件系統層定義了文件系統訪問設備的方法,該方法就是 address_space_operations ,文件系統通過該方法可以訪問具體的設備。對於字符設備而言,沒有實現 address_space_operations 方法,也沒有必要,因爲字符設備的接口與文件系統的接口是一樣的,在字符設備 open 操作的過程中,將 inode 所指向的 file_operations 替換成 cdev 所指向的 file_operations 就可以了。這樣用戶層讀寫字符設備可以直接調用 cdev 中 file_operations 方法了。
6、 截至到步驟( 4 ),讀操作在沒有命中 Cache 的情況下通過 address_space_operations 方法中的 readpage 函數發起塊設備讀請求;寫操作在替換 Cache 或者 Pdflush 喚醒時發起塊設備請求。發起塊設備請求的過程都一樣,首先根據需求構建 bio 結構, bio 結構中包含了讀寫地址、長度、目的設備、回調函數等信息。構造完 bio 之後,通過簡單的 submit_bio 函數將請求轉發給具體的塊設備。從這裏可以看出,塊設備接口很簡單,接口方法爲 submit_bio (更底層函數爲 generic_make_request ),數據結構爲 struct bio 。
7、 submit_bio 函數通過 generic_make_request 轉發 bio , generic_make_request 是一個循環,其通過每個塊設備下注冊的 q->make_request_fn 函數與塊設備進行交互。如果訪問的塊設備是一個有 queue 的設備,那麼會將系統的 __make_request 函數註冊到 q->make_request_fn 中;否則塊設備會註冊一個私有的方法。在私有的方法中,由於不存在 queue 隊列,所以不會處理具體的請求,而是通過修改 bio 中的方法實現 bio 的轉發,在私有 make_request 方法中,往往會返回 1 ,告訴 generic_make_request 繼續轉發比 bio 。 Generic_make_request 的執行上下文可能有兩種,一種是用戶上下文,另一種爲 pdflush 所在的內核線程上下文。
8、 通過 generic_make_request 的不斷轉發,最後請求一定會到一個存在 queue 隊列的塊設備上,假設最終的那個塊設備是某個 scsi disk ( /dev/sda )。 generic_make_request 將請求轉發給 sda 時,調用 __make_request ,該函數是 Linux 提供的塊設備請求處理函數。在該函數中實現了極其重要的操作,通常所說的 IO Schedule 就在該函數中實現。在該函數中試圖將轉發過來的 bio merge 到一個已經存在的 request 中,如果可以合併,那麼將新的 bio 請求掛載到一個已經存在 request 中。如果不能合併,那麼分配一個新的 request ,然後將 bio 添加到其中。這一切搞定之後,說明通過 generic_make_request 轉發的 bio 已經抵達了內核的一個站點—— request ,找到了一個臨時歸宿。此時,還沒有真正啓動物理設備的操作。在 __make_request 退出之前,會判斷一個 bio 中的 sync 標記,如果該標記有效,說明請求的 bio 是一個是實時性很強的操作,不能在內核中停留,因此調用了 __generic_unplug_device 函數,該函數將觸發下一階段的操作;如果該標記無效的話,那麼該請求就需要在 queue 隊列中停留一段時間,等到 queue 隊列觸發鬧鐘響了之後,再觸發下一階段的操作。 __make_request 函數返回 0 ,告訴 generic_make_request 無需再轉發 bio 了, bio 轉發結束。
9、 到目前爲止,文件系統( pdflush 或者 address_space_operations )發下來的 bio 已經 merge 到 request queue 中,如果爲 sync bio ,那麼直接調用 __generic_unplug_device ,否則需要在 unplug timer 的軟中斷上下文中執行 q->unplug_fn 。後繼 request 的處理方法應該和具體的物理設備相關,但是在標準的塊設備上如何體現不同物理設備的差異性呢?這種差異性就體現在 queue 隊列的方法上,不同的物理設備, queue 隊列的方法是不一樣的。舉例中的 sda 是一個 scsi 設備,在 scsi middle level 將 scsi_request_fn 函數註冊到了 queue 隊列的 request_fn 方法上。在 q->unplug_fn (具體方法爲: generic_unplug_device )函數中會調用 request 隊列的具體處理函數 q->request_fn 。 Ok ,到這一步實際上已經將塊設備層與 scsi 總線驅動層聯繫在了一起,他們的接口方法爲 request_fn (具體函數爲 scsi_request_fn )。
10、 明白了第( 9 )點之後,接下來的過程實際上和具體的 scsi 總線操作相關了。在 scsi_request_fn 函數中會掃描 request 隊列,通過 elv_next_request 函數從隊列中獲取一個 request 。在 elv_next_request 函數中通過 scsi 總線層註冊的 q->prep_rq_fn ( scsi 層註冊爲 scsi_prep_fn )函數將具體的 request 轉換成 scsi 驅動所能認識的 scsi command 。獲取一個 request 之後, scsi_request_fn 函數直接調用 scsi_dispatch_cmd 函數將 scsi command 發送給一個具體的 scsi host 。到這一步,有一個問題: scsi command 具體轉發給那個 scsi host 呢?祕密就在於 q->queuedata 中,在爲 sda 設備分配 queue 隊列時,已經指定了 sda 塊設備與底層的 scsi 設備( scsi device )之間的關係,他們的關係是通過 request queue 維護的。
11、 在 scsi_dispatch_cmd 函數中,通過 scsi host 的接口方法 queuecommand 將 scsi command 發送給 scsi host 。通常 scsi host 的 queuecommand 方法會將接收到的 scsi command 掛到自己維護的隊列中,然後再啓動 DMA 過程將 scsi command 中的數據發送給具體的磁盤。 DMA 完畢之後, DMA 控制器中斷 CPU ,告訴 CPU DMA 過程結束,並且在中斷上下文中設置 DMA 結束的中斷下半部。 DMA 中斷服務程序返回之後觸發軟中斷,執行 SCSI 中斷下半部。
12、 在 SCSi 中斷下半部中,調用 scsi command 結束的回調函數,這個函數往往爲 scsi_done ,在 scsi_done 函數調用 blk_complete_request 函數結束請求 request ,每個請求維護了一個 bio 鏈,所以在結束請求過程中回調每個請求中的 bio 回調函數,結束具體的 bio 。 Bio 又有文件系統的 buffer head 生成,所以在結束 bio 時,回調 buffer_head 的回調處理函數 bio->bi_end_io (註冊爲 end_bio_bh_io_sync )。自此,由中斷引發的一系列回調過程結束,總結一下回調過程如下: scsi_done->end_request->end_bio->end_bufferhead 。
13、 回調結束之後,文件系統引發的讀寫操作過程結束。
初始化一個塊設備
每個塊設備都擁有一個操作接口: struct block_device_operations ,該接口定義了 open 、 close 、 ioctl 等函數接口,但沒有,也沒有必要定義 read 、 write 函數接口。
初始化一個塊設備的過程如下:
int setup_device(block_dev_t *dev, int minor)
{
int hardsect_size = HARDSECT_SIZE;
int chunk_size;
sector_t dev_size;
/* 分配一個請求隊列 */
dev->queue = blk_alloc_queue(GFP_KERNEL);
if (dev->queue == NULL) {
printk(ERROR, "blk_alloc_queue failure!/n");
return -ENOMEM;
}
chunk_size = dev->chunk_size >> 9; //sectors
/* 將 block_make_request 註冊到 q->make_request 上 */
blk_queue_make_request(dev->queue, block_make_request);
blk_queue_max_sectors(dev->queue, chunk_size);
blk_queue_hardsect_size(dev->queue, hardsect_size);
blk_queue_merge_bvec(dev->queue, block_mergeable_bvec);
dev->queue->queuedata = dev;
/* 將 block_unplug 註冊到 q->unplug_fn 上 */
dev->queue->unplug_fn = block_unplug;
/* 分配一個 gendisk */
dev->gd = alloc_disk(1);
if (!dev->gd) {
prink(ERROR, "alloc_disk failure!/n");
blk_cleanup_queue(dev->queue);
return -ENOMEM;
}
dev->gd->major = block_major; /* 設備的 major 號 */
dev->gd->first_minor = minor; /* 設備的 minor 號 */
dev->gd->fops = &block_ops; /* 塊設備的操作接口, open 、 close 、 ioctl */
dev->gd->queue = dev->queue; /* 塊設備的請求隊列 */
dev->gd->private_data = dev;
snprintf(dev->gd->disk_name, 32, dev->block_name);
dev_size = (sector_t) dev->dev_size >> 9;
set_capacity(dev->gd, dev_size); /* 設置塊設備的容量 */
add_disk(dev->gd); /* 添加塊設備 */
return 0;
}
註冊 / 釋放一個塊設備
通過 register_blkdev 函數將塊設備註冊到 Linux 系統。示例代碼如下:
static int blockdev_init(void)
{
…
block_major = register_blkdev(block_major, "blockd");
if (block_major <= 0) {
printk(ERROR, "blockd: cannot get major %d/n", block_major);
return -EFAULT;
}
…
}
通過 unregister_blkdev 函數清除一個塊設備。示例代碼如下:
static int blockdev_cleanup(void)
{
…
unregister_blkdev(block_major, "blockd");
…
}
make_request 函數
make_request 函數是塊設備中最重要的接口函數,每個塊設備都需要提供 make_request 函數。如果塊設備爲有請求隊列的實際設備,那麼 make_request 函數被註冊爲 __make_request ,該函數由 Linux 系統提供;反之,需要用戶提供私有函數。 __make_request 函數功能在前文已述。
在用戶提供的私有 make_request 函數中往往對 bio 進行過濾處理,這樣的驅動在 Linux 中有 md ( raid0 、 raid1 、 raid5 ),過濾處理完畢之後,私有 make_request 函數返回 1 ,告訴 generic_make_request 函數進行 bio 轉發。