三個插座,而不是一個
第一篇給了你驅動程式模型:一條匯流排提供一個裝置、核心把它配對到一個驅動程式、然後驅動程式的 probe 函式執行。但「驅動程式執行」留下了一個懸而未決的問題——它以什麼身分執行?當一個程式稍後對你的硬體呼叫 read() 時,那個呼叫究竟落在哪裡?核心的答案不是一道通用的門。它把每一個裝置歸入三個家族之一,而每個家族都以不同的形狀插進核心裡不同的插座。鍵盤、硬碟與網路卡在使用者空間眼中根本長得不一樣,而本篇談的就是為什麼。
這三個家族是字元裝置、區塊裝置與網路裝置。分界線在於硬體天生想要的存取模式。字元裝置是一道串流:位元組一個接一個依序流過,而一個位元組一旦被讀走就消失了——想想鍵盤、序列埠、音訊線路。區塊裝置是隨機存取的儲存:它是一個由固定大小區塊組成、編了號的陣列(每塊常見為 512 位元組或 4 KiB),你可以隨意尋址進去、反覆重讀——磁碟、SSD、USB 隨身碟。網路裝置兩者皆非:它不持有資料,它搬運的是封包,而且它甚至不像另外兩者那樣套得進檔案這個比喻。
使用者空間如何指名一個裝置:裝置節點
在一個程式能與你的驅動程式對話之前,它需要一個可供開啟的名字。那個名字就是一個裝置節點——位於 /dev 之下的一個特殊檔案,例如 /dev/sda 或 /dev/ttyS0。它在目錄列表裡看起來像個檔案,卻不持有它自己的任何資料;它是一道貼了標籤的門。那個標籤是儲存在節點裡的兩個小數字,亦即主編號與次編號。主編號說明哪一個驅動程式擁有這個節點,次編號則說明那個驅動程式所處理的諸多裝置中是哪一個。所以 /dev/sda 與 /dev/sdb 可以共用一個主編號(兩者都是同一個磁碟驅動程式),而僅在次編號上不同(第一顆磁碟、第二顆磁碟)。
現在這條路徑亮了起來。當一個程式執行 open("/dev/ttyS0", O_RDWR) 時,核心會解析這條路徑、找到那個節點、讀取它的主編號,並到一張表裡查那個主編號以找出你的驅動程式。從那一刻起,這個開啟的檔案描述符就被綁定到你的程式碼:對它接下來的 read() 或 write() 不會走向某個檔案系統,而會直接走向你所寫的函式。裝置節點就是那座橋,連起每個程式都早已會說的那套普通檔案 API,與坐在硬體背後的那個特定驅動程式。
接好線的字元驅動程式:file_operations
三者之中,字元裝置是該最先學的那一個,因為它的接線最直接,也是幾乎每個人寫下第一支驅動程式的地方。「一個程式呼叫了 read()」與「我的函式執行」之間的連結,是一個塞滿了函式指標的結構:那個file_operations 結構。你宣告一個 struct file_operations、把你在意的那些欄位填入你自己函式的位址,並用你的主編號去註冊它。每一個欄位對應到一個系統呼叫:.read 欄位會在 read() 時被呼叫、.write 在 write() 時、.open 在 open() 時、.release 在 close() 時,依此類推。
// a character driver's wiring, stripped to the essentials
static ssize_t my_read(struct file *f, char __user *ubuf,
size_t n, loff_t *off)
{
char msg[] = "hi";
size_t len = sizeof(msg) - 1;
if (*off >= len)
return 0; // 0 == end of file, no more bytes
if (n > len - *off)
n = len - *off;
if (copy_to_user(ubuf, msg + *off, n)) // checked crossing!
return -EFAULT; // bad user pointer -> clean error
*off += n;
return n; // number of bytes delivered
}
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.read = my_read, // read() lands here
.open = my_open,
.release = my_release,
};慢慢讀 my_read(),因為每一行都對得起它的位置,而且好幾行都呼應了先前的篇章。char __user ubuf 這個參數是使用者的緩衝區,而那個 __user 標記是一個刻意的提醒:這個指標在核心裡不能*安全地解參考——正是系統呼叫那一篇反覆灌輸給你的那道邊界。所以驅動程式不寫 *ubuf = ...,而是呼叫 copy_to_user(),它會檢查位址、若位址是壞的就乾淨地觸發頁錯誤而回傳 -EFAULT。那個 off 指標是檔案位移,讓一道串流能在多次呼叫之間記住自己的位置,而回傳 0 正是字元裝置示意檔案結束的方式。這些沒有一樣是裝飾:略過那個 copy_to_user() 檢查,你寫出的就是一個核心安全漏洞,而不是一條捷徑。
區塊裝置:為什麼磁碟不只是一個慢吞吞的字元裝置
初學者很自然會猜:磁碟就是一個位元組更多的字元裝置——依序讀下去就好。那個猜測錯得很有教益,而看清楚為什麼,正是區塊家族的整個重點。儲存硬體有兩個頑固的事實:它是以固定大小的區塊為單位來定址的,而不是單個位元組;而且一個你讀過一次的區塊,你很可能很快又會再讀它。所以核心不會像接字元驅動程式那樣,把你的磁碟驅動程式直接接到 read() 上。它在中間滑進了兩層笨重的機制,而這正是讓區塊裝置成為另一種生物的原因。
第一層是分頁快取。當你讀一個檔案時,核心會把它取來的區塊保留在 RAM 裡,於是對同一份資料的下一次讀取根本不必碰磁碟——它是從記憶體被服務的。當你寫入時,你的位元組會先落在快取裡,稍後才在背景被刷到碟片上。這就是為什麼你的磁碟燈不會在每次 write() 時都閃、也是為什麼重讀一個檔案突然變得即時。字元裝置完全得不到這套快取,因為去快取一道鍵盤串流會是荒謬的——你無法重讀一個早已流過去的按鍵。
第二層是區塊輸入輸出層及其排程器。那些確實抵達磁碟的真實讀寫,會被打包成區塊請求,再交給一個輸入輸出排程器,由它在硬體看見之前重新排序並合併這些請求。原因在於對硬體機械特性的體貼:在一顆會旋轉的磁碟上,依位址順序而非到達順序去服務請求,能避免讓磁頭來回亂甩;即使在 SSD 上,把許多小請求合併成幾個大請求也是一大勝利。所以區塊驅動程式的工作不是「現在把這些位元組抄過去」,而是「有效率地完成這個請求佇列」。你的驅動程式實作那個堆疊的底部,而快取與排程器坐在它上方,做著字元驅動程式從來不必思考的工作。
網路裝置:打破檔案模子的那個家族
網路裝置是那個誠實的例外,與其假裝它套得進去,不如把它差異有多大講清楚。沒有 /dev/eth0 可供開啟、沒有為你的網卡準備的、帶有 .read 的 file_operations。取而代之,核心交給網路驅動程式一個完全不同的結構(struct net_device),它的關鍵欄位不是「我該如何讀一個位元組」,而是「這是一個封包——把它送出去」。硬體並不會遞給你一道你所請求的、整整齊齊的位元組串流;它遞來的是封包,非同步地、在它們碰巧從線路上抵達的任何時刻,定址給正在聆聽的那個程式。
那份非同步性逼出一個你該牢牢握住的反轉。字元或區塊驅動程式大多是被動回應的:它靜止不動,直到某個系統呼叫呼叫了它的某個函式指標,才執行起來。網路驅動程式還必須處理沒有人請求、卻在它抵達的當下就到來的資料——線路不會等你的 read()。所以進來的封包是被向上推往網路堆疊、朝向最終想要它們的那個通訊端,而不是被一次讀取向下拉出來。這正是為什麼網路裝置是透過你在網路那一段遇過的通訊端 API 來接觸使用者空間,而非透過對某條路徑的 open():檔案模型假設你在自己選擇時才拉取位元組,而封包根本不是那樣運作的。
退一步,這張地圖就清楚了。核心所認得的每一個裝置都被歸入字元、區塊或網路,而家族決定了下游的一切:字元驅動程式把它的 file_operations 結構直接接到 read() 與 write(),服務一道位元組串流;區塊驅動程式活在一層快取與一個輸入輸出排程器之下,後者把隨機儲存變成快速的檔案;網路驅動程式在一個堆疊裡上下搬運封包,並且是透過通訊端、而非一條路徑被接觸到。它們共用驅動程式模型與主/次編號這套命名方案,但工作的形狀再難更不一樣了。接下來,第三篇走進一個裝置真正要求關注的那一刻——中斷——以及在「快速地處理它」與「完整地處理它」之間那道審慎的劃分。