那個不可能的承諾:一個呼叫應付所有裝置
在前三篇導覽裡,你一路爬到了硬體底層。你認識了輸入輸出硬體——控制器、連接埠、匯流排與暫存器——以及在其上搬移位元組的三種方式:附帶輪詢的程式化輸入輸出、中斷驅動輸入輸出,以及 直接記憶體存取。你也追蹤了一次中斷:從門鈴響起,經過向量與處理常式,進入它的上半部與下半部。那一切談的都是「一次一個」裝置。現在退一步,問一個貫穿整個這一階的問題:作業系統要如何在不發瘋的前提下,應付世上多到嚇人的各式裝置?
這數字很殘酷。一台筆電可能要跟鍵盤、觸控板、網路攝影機、麥克風、Wi-Fi 晶片、固態硬碟、USB 隨身碟、藍牙無線電與螢幕對話——而每一種又來自數十家製造商,各有各的暫存器、各有各的怪癖、各有各的命令碼。乘開來看,作業系統得想辦法跟成千上萬種不同的裝置溝通。然而這裡有個日常的奇蹟:你的程式打開它們之中任何一個,拿回一個檔案描述符,再用一模一樣的呼叫去讀——read(fd, buf, n)——無論 fd 是磁碟上的檔案、被按下的一個按鍵,還是網路上飛來的一個封包。一個介面,一萬種裝置。
作業系統守住這個承諾的方法,是一招經典的工程手段:用一條清晰的水平線,把問題切成兩半。線的上方住著「一大塊」共用的程式碼,做的是所有裝置共通的一切——而且你插上新玩意兒時它從不需要改。線的下方則住著一小片各裝置專屬的程式碼,每一種裝置一片,懂得那個裝置的私房語言。共用的那部分叫裝置無關輸入輸出層;裝置專屬的那部分叫裝置驅動程式。這篇導覽接下來的全部,談的就是這條線,以及它分開的那兩樣東西。
裝置驅動程式:替一個小玩意兒當翻譯
一支裝置驅動程式是一小段程式碼,由最懂這個裝置的人(通常是它的製造商)撰寫,它唯一的工作,就是把核心發出的通用請求,翻譯成那一個特定控制器聽得懂的、確切的暫存器戳動。可以把它想成大樓管理員替每位外籍訪客僱來的隨身口譯:管理員永遠說同樣那幾句客氣話——「請讀這個」「請寫那個」「你好了嗎?」——而口譯把它們轉譯成這位特定客人所講的那種古怪方言。換一位訪客,換一位口譯;管理員的台詞本從不更動。
具體來說,驅動程式對外暴露一組標準的函式供核心呼叫——這是同一類別的每支驅動程式都必須遵守的小小契約。對磁碟而言,這份契約包含像 open、close、read、write,以及一個用來下達裝置專屬命令的萬用 ioctl。核心呼叫 driver.read(...);驅動程式知道這(比方說)是某顆特定的 SATA 控制器,便透過輸入輸出連接埠或記憶體映射位址,把正確的數值寫進那顆控制器的暫存器,啟動一次 DMA 傳輸,並安排在裝置完工時呼叫它的中斷處理常式。一切裝置專屬的細節——哪個暫存器代表什麼、哪個命令碼啟動一次讀取——都封死在驅動程式裡。它上方的任何東西,都永遠不必知道。
裝置無關層:所有共通的事,只寫一次
如果說驅動程式是底層那位瘦瘦的口譯,那麼裝置無關輸入輸出層就是它上方那間肥厚又精明的辦公室,包辦所有不該由任何單一裝置各自重新發明的工作。當兩個程式都想要磁碟時,這一層替它們的請求排隊。當一個像 /dev/sda1 的名字需要被轉成「表中的第三支驅動程式」時,這一層去做查表。當資料應該在啟程前先短暫存放時,這一層管理那些緩衝區。當有錯誤回傳時,這一層決定要重試、回報,還是放棄。這些都不是某顆 SATA 磁碟或某支 USB 隨身碟所獨有的,所以它只被寫了一次,由每一個裝置共用。
它最重要的工作是命名與一致化。在 Unix 風格的系統上,這被推到了一個漂亮的極致:幾乎每個裝置都被呈現為檔案系統裡的一個特殊檔案,放在 /dev 底下。你的鍵盤、你的磁碟,甚至一個產生隨機位元組的來源,看起來全都是你可以開啟、讀取、關閉的檔案。讓這件事成立的共用層就是虛擬檔案系統(VFS):無論底下是什麼,它都接受同一套通用操作,再把每一個操作往下繞送到正確的驅動程式。這正是為什麼無論 fd 指向一份文件還是一支麥克風,read(fd, buf, n) 都說真話——呼叫一模一樣,只有底下的驅動程式不同。
your program: read(fd, buf, n) write(fd, buf, n) ----------------------------------------------------------------- system-call boundary device-INDEPENDENT naming (/dev/...), permission checks, I/O layer buffering, request queue, error policy (written ONCE) | | | ----------------------------------------------------------------- the uniform driver contract device DRIVERS [kbd drv] [SATA-disk drv] [wifi drv] ... (one per device) | | | ----------------------------------------------------------------- registers / ports / DMA HARDWARE controller controller controller
兩大族群:區塊裝置與字元裝置
這個統一的介面,對每一個裝置並非真的一模一樣——若那樣說就是說謊了,因為磁碟與鍵盤確實是不同的形狀。所以作業系統把裝置分成兩大族群,分別提供略有不同(但在各自族群內仍然統一)的介面。這個分法就是區塊裝置與字元裝置之分,它捕捉的是硬體行為上一個真實的差異,而不是把差異粉飾過去。
一個區塊裝置以固定大小的區塊搬移資料,而且關鍵在於:你可以按號碼、以任何順序去要任何一個區塊——它是可隨機定址的。磁碟與固態硬碟是經典例子:「把第 50,000 號區塊給我」完全說得通,作業系統可以自由地對區塊請求做緩衝、快取與重新排序。一個字元裝置則送出一連串接連而來的位元組,沒有「跳到第 50,000 個位置」這種概念——鍵盤、滑鼠、序列線、終端機都是。你可以讀下一個位元組,卻沒辦法說「讀下週二才會到的那個位元組」。這兩個族群需要不同的服務,所以裝置無關層對它們區別對待:區塊裝置拿到的是緩衝快取與一個請求排程器;字元裝置拿到的則是較單純的行與串流處理。
跟著一次 read 一路向下
讓我們把整個堆疊串起來,從你的程式呼叫它的那一刻起,追蹤一次對磁碟檔案的 read(fd, buf, n)。看看每一層如何只做自己分內的事、把其餘往下交——以及這個請求一路抵達硬體之後,又如何沿著同一道樓梯回到上面來。這就是本階引言所承諾的旅程:一次輸入輸出請求的一生。
- 你的程式呼叫 read(fd, buf, n)。這是一次系統呼叫:一個陷阱從使用者模式跨入核心,正如你在基礎那一階學過的。
- 裝置無關層接手。它在開啟檔案表裡查出 fd、檢查權限,並向緩衝快取問一個充滿希望的問題:那些區塊已經在記憶體裡了嗎?如果在,它就把它們複製進 buf 並立刻返回——完全沒碰到裝置。
- 若快取未命中,這一層便把請求轉譯成具體的區塊號碼,放上裝置的請求佇列,而(對一個區塊裝置而言)排程器可能會在其他待處理的請求之間重新排序它。接著它呼叫磁碟的驅動程式。
- 驅動程式講起控制器的私房語言:它把目標區塊、數量與一個「開始讀取」命令寫進控制器的暫存器、設定好一次 DMA 傳輸,接著呼叫端的執行緒便阻塞——它被送去睡覺,好讓 CPU 在慢吞吞的磁碟工作期間去跑別人。
- 磁碟完工,舉起一個中斷。它的處理常式執行起來——迅速的上半部向裝置回報收到,下半部則喚醒那個睡著的執行緒、並把緩衝區標記為就緒。
- 控制權沿著樓梯流回上方:驅動程式返回裝置無關層,後者把資料複製進你的 buf、更新檔案位置,並從系統呼叫返回。你的程式在 read() 的下一行恢復執行,手裡握著它的位元組——從來不知道那顆磁碟是 SATA、NVMe,還是月亮的某個盈虧階段。
留意第 4 步的形狀:執行緒被送去睡了。這就是阻塞式輸入輸出,也是預設——你的程式就只是等著、什麼也不做,直到資料就緒,這寫起來感覺很自然。但它並非唯一選項,而這個選擇很要緊。在非阻塞式輸入輸出下,read() 會立刻帶著「此刻手邊有的任何東西」返回(即使是零個位元組),讓程式能繼續做別的事、稍後再回來查看。在非同步輸入輸出下,你發出請求後,會在它完成時收到通知,根本完全不必等。誠實的取捨是:阻塞式單純,卻讓你的執行緒空閒呆等;非阻塞式與非同步讓你保持忙碌,卻把你的程式碼搞得糾纏許多。本階下一篇、也是最後一篇導覽,就會打開正是這幾種風格,連同我們在這裡一直倚靠的緩衝與快取把戲。
為什麼那條線才是重點所在
退一步,欣賞這個切分替你換來了什麼。因為所有共通的機構都住在線的上方,要替一個全新的小玩意兒加上支援,意味著寫一支新的驅動程式——幾百行或幾千行——而作業系統其餘的全部、加上有史以來寫過的每一個程式,都立刻、原封不動地能用它。這正是為什麼你能插上一台在你的作業系統出貨多年後才製造的印表機,就直接印得出來。這個統一層是對未來的一個承諾:說好我們這份小小的契約,我們就把你的裝置帶到任何地方。
不過有一個真實的代價值得誠實點明。資料每跨越一層,就是又一次複製或又一次函式呼叫,而正是這份額外開銷,使得高效能系統有時會繞過堆疊的某些部分——把封包直接複製進使用者記憶體,或讓資料庫去跟原始區塊對話、而不走檔案系統。這個統一介面並非免費;它用一小片尖峰速度,換來簡潔與觸及範圍上的巨大收穫。這幾乎總是正確的取捨,但「幾乎總是」不等於「總是」,而懂得何時該打破這層抽象,正是精通它的一部分。