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

核心模組與驅動程式模型

你已經知道核心執行在自己那個有特權的世界裡,和你的程式分開。現在我們走進那道門:一個驅動程式怎麼被載入一個正在執行的核心、它如何活著又如何死去,以及核心怎麼記住「哪一段程式碼為哪一塊硬體說話」。

在核心執行中途加入它的程式碼

從前面幾級你已經有了一幅關於邊界的清晰圖像:你的程式住在使用者空間核心住在一道硬牆的另一側,而你只能透過一個陷入核心模式系統呼叫越過那道牆。一個裝置驅動程式住在那道牆的另一邊——它是核心程式碼。在像 Linux 這樣的單體核心裡,驅動程式以完整的核心特權執行、在核心自己的位址空間裡、各個驅動程式之間沒有任何保護。驅動程式裡的一個臭蟲不是某個行程裡的一次區段錯誤;它可以把整台機器拖垮。這就是這一級的賭注,也是我們小心翼翼的理由。

現在是令人驚訝的部分。你不必每次想要一個新驅動程式時都重新編譯整個核心並重新開機。核心可以在執行中途接受新的程式碼。一個可載入核心模組(loadable kernel module,LKM)是一塊目的碼——一個 .ko 檔,「kernel object」——你可以把它接合進這個活著的核心、之後再移除,全程不必重新開機。當你執行像「sudo insmod mydriver.ko」這樣的命令時,核心載入那個檔案、把它的符號對著核心自己的符號表解析、執行它的初始化函式,而從那一瞬間起,這個模組的程式碼就成為核心的一部分、以完整特權執行。這和你在工具鏈那級遇到的共享函式庫是同一個想法——把分開編譯的程式碼晚一步連結進來——只不過被連結進去的宿主是活著的核心,而不是一個使用者行程。

生命週期:init、活著、exit

每個模組都剛好有兩個替它的生命收尾的儀式性函式,整個模組生命週期都掛在它們上面。init 函式,用 module_init() 這個巨集標記,在載入時執行一次:它是模組取得所需資源的地方——向某個子系統註冊自己、用 kmalloc() 配置核心記憶體、要求一條中斷線、宣告一個裝置編號。exit 函式,用 module_exit() 標記,在卸載時執行一次,而且必須把 init 做過的每一件事反向地復原。核心裡沒有垃圾回收器;如果 exit 忘了釋放 init 配置的東西,那塊記憶體就消失到重新開機為止——一個你在執行中的核心裡無法回復的記憶體流失

init 函式回傳一個 int:成功是 0,失敗則是一個負的錯誤碼。這不是裝飾——它是一份硬合約。如果 init 回傳非零值,核心就拒絕載入這個模組,並拆掉它已經開始的任何東西。所以你在錯誤處理那級學到的紀律在這裡變得字面化:檢查每一個呼叫 init 所做的,而在第一個失敗時,把你到目前為止做過的復原,並回傳那個錯誤。經典的形狀是一串 goto 標籤的階梯,反向地拆解部分完成的設定——正是那個 goto 清理模式,在核心 C 裡這不是程式碼壞味道,而是在失敗路徑上釋放資源的慣用、正確做法。

insmod mydriver.ko
      |
      v
  module_init(my_init)  ---> returns 0  ---> module LIVE
      |                       |             (functions callable,
      |                       |              waiting for events)
      |                       returns <0 --> load FAILS, kernel
      |                                      unwinds, .ko discarded
      v
  ... time passes; driver code runs only when CALLED ...
      |
      v
  rmmod mydriver  (only if refcount == 0)
      |
      v
  module_exit(my_exit) ---> frees/unregisters everything init grabbed
模組生命週期。注意在 init 與 exit 之間,模組只是作為可被呼叫的程式碼待在那裡;在某個事件呼叫進它之前,它不做任何自己的工作。而當參考計數大於零時,rmmod 會被拒絕。

安全地卸載,以及傳入參數

為什麼核心不能在你一說「rmmod」時就把模組硬扯出來?因為在那個當下,可能有某個其他執行緒正模組的程式碼裡面——正從它的裝置讀資料、正執行到它某個函式的一半。把程式碼從一個正在執行的執行緒底下釋放掉,會是發生在最糟糕範圍上的釋放後使用。核心用一個參考計數來防範這件事:每當有東西開始依賴這個模組——一個開啟的裝置檔、一個仍在使用中的已註冊處理常式——計數就加一;當那個使用結束,它就減一。rmmod 只有在計數為零時才成功。這和你在使用者空間遇到的配置所有權是同一套邏輯,現在由核心執行,用來決定一個模組何時可以安全移除。

一個真正的驅動程式也需要被設定——用哪個 I/O 位址、緩衝區多大、要不要打開除錯記錄。你不能像對程式那樣把 argc/argv 傳給模組,因為模組沒有命令列。核心改提供模組參數:模組內部一個用 module_param() 宣告的變數,載入器可以當場設定它。你寫「insmod mydriver.ko buffer_size=4096 debug=1」,而那些值會在 init 執行之前落進模組的變數裡。它們甚至可以事後透過 /sys 底下的一個檔案被讀取和更改,那是核心對自己狀態開的一扇窗。這是核心對「環境變數為一般程式所服務的同一個需求」給出的整潔答案。

驅動程式模型:把程式碼配對到硬體

一個模組只是遞送的機制——那輛卡車。貨物是一個驅動程式,而驅動程式之所以重要,唯一在於它綁定到某一塊特定的硬體。那麼核心怎麼知道這段剛載入的程式碼是用來驅動某張特定網路卡的正確程式碼,而不是別人的?那個配對的工作就是驅動程式模型做的事:一個橫跨整個核心的框架,對每一個在場的裝置、每一個已註冊的驅動程式、以及連接它們的每一條匯流排,維持一幅統一的圖像。在這個框架存在之前,每個子系統都用自己那套雜亂的方式重新發明裝置追蹤;驅動程式模型給了核心一棵連貫的裝置與驅動程式樹,這也正是 /sys 背後的動力。

這個配對是透過匯流排運作的。硬體掛在匯流排上——PCI、USB、I2C——而匯流排上的每個裝置都宣告一個身分,像是 PCI 的廠商與裝置 id 對,例如 0x8086:0x100e。當核心走過一條匯流排、發現上面插了什麼時——這個步驟叫做匯流排列舉——它讀取那些 id。同時,每個驅動程式註冊一張表,說「我支援這些 id」。匯流排核心比較兩者:當一個被發現的裝置的 id 與某個驅動程式的表相符時,核心就呼叫那個驅動程式的 probe 函式,把那個裝置交給它。probe 是驅動程式終於甦醒、接管真實硬體的地方——而它正是驅動程式抽象那個承諾的鏡像:同樣的通用 open()/read()/write() 呼叫能觸及任何裝置,因為在底層,正確的 probe 把正確的驅動程式接到了正確的晶片上。

這打開了什麼,以及穿過這一級的路

退一步,看看我們建起了什麼。一個驅動程式是核心程式碼(完整特權、沒有安全網),被封裝成一個可載入模組(能接合進活著的核心、再接合出來),受一套嚴格的 init/exit 生命週期治理(取得然後釋放,每一項資源,不流失),由參數設定,並透過匯流排列舉與 probe、由驅動程式模型綁定到硬體。那一句話就是這一級一切的骨架;其餘各篇各自把它的一根骨頭填上血肉。它們都不會重新推導模組的機制——它們假設你現在握著的這幅圖像。

這是地圖。下一篇問這是哪一種裝置,因為核心把它們歸成三個有著不同介面的家族——字元區塊、與網路裝置——而驅動程式呈現出的那張 read()/write() 面孔,住在一個由函式指標組成的結構裡,即 file_operations 結構。之後我們走到硬體要求注意的那一刻:一個中斷觸發,驅動程式必須快速回應、然後把慢的部分延後。接著來的是最難的物理問題——驅動程式究竟怎麼把位元組真正進、搬出一塊晶片,透過記憶體映射 I/ODMA。這一級以權衡輪詢對中斷收尾,認識那棵告訴核心存在哪些硬體的裝置樹,並學會用 copy_to_user() 與 copy_from_user() 安全地把資料渡過那道牆。