JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

輪詢對中斷、裝置樹,與使用者資料傳輸

第四篇教會你的驅動程式怎麼和硬體說話;第三篇教它在硬體中斷時怎麼應答。現在是這一級收尾的幾個問題:什麼時候「回應每一個中斷」反而是錯的一步、核心究竟怎麼在任何驅動程式執行之前就知道存在哪些硬體、以及一個驅動程式要怎麼把位元組遞過使用者與核心之間那道牆,而不至於開出一個讓攻擊者一路走進來的洞?

當中斷不再幫上忙

第三篇把中斷推銷給你,理由很好:與其讓 CPU 浪費地一遍又一遍問「你好了沒?」,不如讓裝置只在真的有事發生時拍一下 CPU 的肩膀。對一個一秒觸發幾十次的鍵盤來說,這太棒了——CPU 在這之間做真正的工作,只有在一個鍵真的被按下時才付出一次中斷的代價。整個吸引力在於:中斷之所以便宜,是在事件稀少的時候。緊抓著這個限定條件,因為這整篇的張力就住在它裡面。

現在把同樣的機制對準一張 10 Gb 的網路卡,讓它承受一場小封包的洪水。每一個抵達的封包都拉起一個中斷。一個中斷不是免費的:CPU 必須停下手邊的事、存下它的暫存器、切進中斷脈絡、跳過中斷表、執行處理常式,然後在離開的路上把一切還原。在每秒幾百萬個封包之下,這台機器把全部時間花在進出中斷處理常式上,永遠到不了那個本該處理封包的使用者程式碼。吞吐量崩潰;在足夠的負載下它可以掉到零。這個病態有個名字——中斷風暴(interrupt storm),或叫接收活鎖(receive livelock)——而這正是那個讓裝置反應靈敏的機制本身,變成把它勒死的東西的那一刻。

逃生口正是中斷當初被發明來取代的那個東西:輪詢(polling)。在一個純輪詢設計裡,驅動程式停用裝置的中斷,改成迴圈,反覆讀一個狀態暫存器來問「有封包嗎?還有一個嗎?再一個嗎?」,並在一趟裡盡量把找到的全部排掉。現在每個封包的代價,是一次便宜的暫存器讀取,而不是一整套中斷的進入與離開,所以在重負載下,輪詢效率顯著地更高——CPU 把一趟進入驅動程式的成本,攤平到一整批封包上。陷阱則是中斷那個陷阱的鏡像:當裝置閒著時,一個輪詢迴圈是純粹的浪費,一顆全速空轉的 CPU 一秒幾百萬次問一張安靜的卡同一個問題,什麼也沒換到。

NAPI:兩邊的好處都拿

所以中斷在流量輕時勝出、輪詢在流量重時勝出——而一張真正的網路卡兩者都會遇到,常常還在同一秒之內。核心的答案不是二選一,而是在兩者之間動態切換。那個混合體就是 NAPI(「New API」),每一個現代 Linux 網路驅動程式都採用的接收模型。這個想法一句話就講得完:用一個中斷來發現有工作來了,然後切換到輪詢把它排掉,工作排完了再切回中斷。

走一遍這個循環。卡片啟用著中斷待命,閒置時不花成本。一個封包抵達、拉起一個中斷;處理常式在它的上半部幾乎什麼都不做——它停用這張卡的接收中斷,並排定一次輪詢。從那一刻起,核心輪詢這張卡,成批地排掉封包,中斷關著,於是即便來了一股洪流,也拉起個額外的中斷。輪詢函式拿到一份預算——比方說 64 個封包——好讓一個裝置無法獨佔 CPU;如果它用完預算時還有更多在等,它就讓出去、稍後再被輪詢一次。關鍵在於:當一次輪詢回來時發現的封包少於預算,佇列就排空了:驅動程式重新啟用中斷、停止輪詢,滑回那個便宜的閒置狀態。輕負載表現得像中斷、重負載表現得像輪詢,而轉換是自動的。

核心怎麼得知存在哪些硬體

退回到一個我們悄悄跳過的問題。第一篇說過,驅動程式模型透過在匯流排列舉時讀取裝置的 id,把一個驅動程式配對到一個裝置。那之所以行得通,是因為像 PCI 或 USB 這樣會自我描述的匯流排,讓核心可以走過它、問每一個插槽「你是誰?」——裝置用一個廠商與裝置 id 回答、核心找出相符的驅動程式,搞定。在一台 PC 上,這就是故事的全部。但它悄悄假設了硬體能自我介紹。

在那些運轉著世界上大多數裝置的嵌入式板子上——手機、路由器、你車子的儀表板——那個假設就破了。一顆焊在板子上的晶片,常常坐在一條根本沒有列舉的簡單匯流排上:沒有任何協定能去問一個記憶體映射的感測器「你是誰、你住在哪個位址?」硬體就只是在那裡,在板子設計者選定的一個固定實體位址上,而晶片上沒有任何東西會把它告訴軟體。如果核心發現不了這個硬體,它又怎麼可能在不把每一塊板子的接線寫死進核心本身的前提下,去驅動它?

答案是 裝置樹(device tree):一個和核心分開的資料檔,它描述那些核心發現不了的硬體。它是一棵由節點組成的樹——每個裝置一個節點——每個節點寫明一個相容的驅動程式字串(像「vendor,uart-v2」)、裝置暫存器的實體位址、它拉哪一條中斷線、它的時脈,等等。啟動載入器在開機時把這份描述載入記憶體、並把它的位址交給核心。核心讀這棵樹,對每個節點找出一個「compatible」字串相符的驅動程式,然後用直接取自該節點的位址與中斷去呼叫那個驅動程式的 probe。這就是第一篇那支「相符就 probe」的舞——只不過用來配對的資料,來自某人寫的一個檔案,而不是來自一個自我介紹的硬體。

把位元組搬過那道牆

現在是最後一塊,也是牙齒最利的一塊。一個字元驅動程式的 read() 與 write() 必須在一個使用者程式的緩衝區和核心之間搬資料。從最早幾級你就知道,一個指標不過是一個位址。所以當一個使用者呼叫 read(fd, buf, n) 時,核心處理常式收到位址 buf 和長度 n——而那個顯而易見、卻災難性地錯誤的動作,是把 buf 當成任何核心指標、就直接用 memcpy() 或一個樸素的 *p = value 寫進去。這麼做,你就建出了一個跟整台機器一樣大的安全漏洞。

為什麼樸素地解參考一個使用者指標是一場災難?因為那個指標越過了一道信任邊界——它來自一個不受信任的使用者程式,而核心必須假設它充滿敵意。那個位址可能是個謊:它也許指向行程從來不被允許碰的核心記憶體。如果核心天真地解參考它,那麼核心——以完整特權執行——會樂呵呵地從自己的空間讀出一個祕密、複製回給攻擊者,或者用攻擊者選定的位元組覆寫核心資料。一個使用者指標是一個聲稱,不是一個保證,而這道邊界的全部安全,繫於核心永遠不盲目相信那個聲稱。

這就是為什麼核心從不直接解參考一個使用者指標。它用兩個專門的輔助函式——copy_to_user() 與 copy_from_user()——來安全地做這個傳輸。copy_from_user(dst_in_kernel, src_user_ptr, n) 把 n 個位元組從一個使用者位址拉進核心;copy_to_user(dst_user_ptr, src_in_kernel, n) 則往另一個方向推。在碰任何一個位元組之前,它們各自先檢查那段使用者位址範圍是否真的落在這個行程被允許的使用者空間區域裡,拒絕任何伸進核心的指標。而且因為一個使用者分頁可能被換出、或根本沒被對映,這些函式被寫成在存取時接住一個錯誤(fault)、回傳一個錯誤計數,而不是讓核心當機——它們把一次亂跑的存取,變成一次乾淨的失敗。

ssize_t my_read(struct file *f, char *user_buf, size_t n, loff_t *off)
{
    char kbuf[256];
    size_t len = fill_from_device(kbuf, min(n, sizeof kbuf));

    /* WRONG: *user_buf = ... ; or memcpy(user_buf, kbuf, len);
       trusts an untrusted address, at full kernel privilege. */

    /* RIGHT: validates the range, faults safely, never trusts buf. */
    if (copy_to_user(user_buf, kbuf, len) != 0)
        return -EFAULT;          /* some bytes could not be copied */
    return len;                  /* report how many bytes we gave */
}
一個字元裝置的 read()。使用者給了我們 user_buf;我們從不直接解參考它。copy_to_user() 驗證那段範圍、在不當機的情況下處理分頁錯誤,並回傳它無法複製的計數——非零就表示我們交還 -EFAULT。

為這一級收尾

退後一步,把整級看成一個形狀。一個驅動程式是被封裝成可載入模組的核心程式碼,由驅動程式模型綁定到硬體(第一篇)。它透過一張函式指標表,呈現三張面孔之一——字元、區塊、或網路(第二篇)。它用一個快速的上半部和一個延後的下半部,及時應答硬體(第三篇)。它透過記憶體映射 I/O 與 DMA,把位元組搬進、搬出晶片(第四篇)。而在這一篇裡,它學會在輪詢與中斷之間做選擇——當事件率劇烈擺盪時,落腳在 NAPI——在匯流排無法自我介紹時,從一棵裝置樹裡得知存在哪些硬體,並用 copy_to_user() 與 copy_from_user() 把資料搬過使用者那道牆,而不是信任一個它並非親手造出的指標。

如果有一個主題貫穿這全部五篇,那就是:核心換取它的力量,靠的是放棄它的安全網。沒有行程可以崩潰、上面沒有作業系統替你善後、沒有垃圾回收器、也沒有殼層接住你——這門課建立起來的每一個反射(檢查每一個回傳、擁有每一項資源、驗證每一道邊界、把粗活延後、不信任每一個使用者給的指標)在這裡都變得承重,那是它在使用者空間裡從來不曾有過的。這不是害怕核心程式設計的理由;它正是它令人滿足的理由。你正在寫那個其他一切都站在上面的層,而現在你從頭到尾明白了:幾百行小心的 C,是怎麼讓一塊安靜的矽,變成整個系統都能使用的一個裝置。