裝置不過是更多的位址罷了
到現在你已經能註冊一個驅動程式、把它歸成字元、區塊或網路口味、並在硬體大喊時接住一個中斷。但我們一直繞過那個最物理的問題:當驅動程式想叫網路卡開始傳送、或問溫度感測器要一個讀數時,它究竟做了什麼?並沒有一條特別的「和硬體對話」指令。答案漂亮地直白——裝置的控制旋鈕被攤開成一些記憶體位址,而驅動程式像對待任何其他記憶體位置那樣讀寫它們。
這就是記憶體映射 I/O(memory-mapped I/O,MMIO)。晶片攤開一塊裝置暫存器——每個暫存器是一個小格子,通常 32 位元寬,各自代表某件特定的事:往這一格寫 1 來啟動一次傳輸、讀那一格來得知裝置是否忙碌、在第三格設一個位元來啟用中斷。匯流排把那些格子接到實體位址上,比方說一個從 0xfebc0000 起頭的視窗。當 CPU 對 0xfebc0000 做一次普通的儲存時,那些位元組根本不會抵達 RAM——它們行進到晶片,而晶片起反應。MMIO 正是為什麼一個統一的東西,即位址空間,能同時命名你的資料和硬體的操縱桿。
有一個皺褶會咬到每一個初學者。核心操作的位址是虛擬的,但裝置視窗住在一個固定的實體位址上,預設沒有任何分頁表映射它。所以在碰一個暫存器之前,驅動程式呼叫 ioremap(),把實體基底 0xfebc0000 與一個長度交給它;核心裝上一筆分頁映射,回給你一個你可以解參考的虛擬指標。關鍵在於,那筆映射被標記為不快取——我們稍後就會看到,為什麼一個暫存器絕對不能像普通記憶體那樣被快取。
為什麼要 volatile,又為什麼要不快取
裝置暫存器打破了一個編譯器對普通記憶體奉為神聖的假設:如果你的程式碼裡沒人寫過某個位置,它的值就不可能改變,而連著讀同一個位置兩次是浪費、可以合併成一次讀取。對一個狀態暫存器來說這兩個假設都是錯的——是硬體在你腳下改它,而你可能需要反覆讀它、直到某個位元翻轉。用 volatile 修飾子告訴編譯器這件事:它強迫原始碼裡的每一次存取都變成一次真正的載入或儲存,一個都不省略、彼此之間不重排、一個都不憑空捏造。ioremap() 正是為了這個理由才回傳一個帶 volatile 修飾的指標。
快取是更深的陷阱,而正是在這裡,你在效能那級遇到的快取從朋友變成敵人。如果暫存器視窗被快取了,一次對控制暫存器的寫入可能會在 CPU 的快取行裡停留一陣子才抵達晶片——於是裝置要等到天知道何時才啟動——而一次讀取可能從快取拿到過時的值,而不是去問活著的硬體。兩者都是災難。這就是為什麼 ioremap() 把這塊區域映射為不快取:每一次存取都直達裝置、依程式順序、中間沒有快取。這也是為什麼你絕不能對 MMIO 用普通的 memcpy();驅動程式改用 readl() 與 writel() 這類存取輔助函式,核心保證它們會對裝置發出單一一次、大小正確的存取。
DMA:讓裝置自己來搬
MMIO 拿來戳幾個控制暫存器很好,但想像一次接收一個 64 KiB 的網路封包、卻一次只讀一個 32 位元暫存器:CPU 會把幾百萬個週期燒在純粹搬運位元組上,什麼別的都不做。所以對大量資料,我們把這份工作交給裝置本身。直接記憶體存取(direct memory access,DMA)是硬體自己讀寫主記憶體、不需要 CPU 逐位元組複製的能力。驅動程式告訴裝置「這裡有一個位於這個位址的緩衝區、這麼多位元組——去把它填滿」,然後讓開;裝置的 DMA 引擎把資料直接灌進 RAM,只在整筆傳輸完成時才升起一個中斷。
這出色地有效率,又靜靜地危險,因為現在有兩方碰同一個緩衝區。第一,DMA 引擎說的是實體位址——它沒有 MMU 替它做虛擬到實體的轉換——所以驅動程式必須為這個緩衝區取得一個實體(「匯流排」)位址、把那個編入裝置,而絕不是 CPU 用的虛擬指標。第二,快取又來搗亂:如果 CPU 的快取裡有這個緩衝區一份髒的副本,裝置可能 DMA 出去過時的資料,或把新資料寫進 RAM、而一個讀快取的 CPU 卻忽略它。所以一筆 DMA 傳輸被快取同步包夾起來:裝置讀之前先清出(flush),CPU 讀裝置寫的東西之前先使無效(invalidate)。核心的 DMA API 替你做這份簿記,但你必須在正確的時刻呼叫它——略過它,你就得到我們一再警告的那個幾分鐘後才毀損的臭蟲。
IOMMU:給裝置用的分頁表
讓一個裝置寫進實體記憶體的任何地方,既強大又同時嚇人。一個有臭蟲的 DMA 描述符——或者在一個你不完全信任的裝置上,一個惡意的描述符——可能把位元組濺得核心資料結構到處都是,因為裝置完全繞過了 CPU 的 MMU。解法是給裝置它們自己的位址轉換。IOMMU 坐在裝置與實體記憶體之間,替 DMA 做的事正是 MMU 替 CPU 做的事:它透過一張每裝置一份的分頁表,轉換裝置發出的位址,於是裝置看到的是一個整潔的 I/O 虛擬位址空間、只能搆到核心明確替它映射的那些分頁。
這一下子買到兩樣東西。它是保護——一個被 IOMMU 圈住的裝置無法破壞它從未被授予的記憶體,而這正是為什麼把一個真實裝置直接穿透給虛擬機是安全的。它也是方便——因為 IOMMU 能把一段連續的 I/O 虛擬範圍散布到許多不相鄰的實體分頁上,於是即使是一個 vmalloc 那樣散落的緩衝區,也能對一個簡單的裝置呈現得彷彿是一整塊。同一個保護你的轉換把戲,也把上一節那個實體連續性的問題遮蓋掉了。代價一如往常是誠實:IOMMU 有它自己的轉換快取,映射改變時必須清掉它,而那次清掉並不免費。
把一次傳輸組裝起來
讓我們走一遍一次接收的生命,好讓這些零件鎖進同一幅圖像。下面那句「敲門鈴」是驅動程式日常用來指那最後一次 MMIO 寫入的比喻——那次寫入告訴裝置有一個請求就緒了——它不過是對一個暫存器的一次 writel(),但把它叫做門鈴讓順序規則變得鮮明:門鈴必須最後才敲,在緩衝區與描述符全都就位之後,否則裝置會衝出去讀到一半搭好的資料。
- 配置一個可安全做 DMA 的緩衝區(實體連續,透過核心的 DMA 輔助函式),並對同一塊記憶體拿回一個 CPU 指標和一個裝置側的匯流排位址。
- 在記憶體裡建一個描述符:把緩衝區的匯流排位址與長度寫進裝置手冊所規定的那個小結構裡,好讓硬體知道該把資料放哪。
- 發出一個寫入屏障,讓描述符的位元組保證在下一步之前對裝置可見——這就是 volatile 對屏障那個區分的具體化。
- 敲門鈴:對裝置的控制暫存器做單一一次 writel(),告訴它開始。CPU 此刻讓開,可以去做別的工作。
- 裝置把進來的資料 DMA 直接灌進你的緩衝區,然後升起一個中斷來宣告完成。
- 在處理常式裡,為 CPU 存取同步這個緩衝區(使任何過時的快取失效),然後此刻才讀那些剛抵達的位元組——它們終於屬於你來使用了。
注意這一級的每個主題都匯聚在那六個步驟裡。MMIO 敲門鈴並讀狀態;DMA 搬運大量資料;快取與屏障規則讓兩方對記憶體的看法保持一致;那個中斷標示完成,於是我們從不忙等;而一個 IOMMU,若在場,靜靜地把整套舞步圈在被授權的分頁裡。這一級下一篇、也是最後一篇,退一步去權衡何時連中斷都太昂貴——也就是輪詢對中斷的取捨與 NAPI——以及裝置樹怎麼一開始就告訴核心存在哪些這樣的裝置,還有我們剛剛對一個 DMA 緩衝區所下的同一份小心,在把位元組渡過那道牆、送給一個使用者程式時如何同樣適用。