問完四篇機制之後的一個問題
到現在,你已經跟著一個核心走完了它的一生。你看著它從韌體與啟動載入器中甦醒,你追蹤過一個系統呼叫如何透過陷阱指令與系統呼叫表,跨越使用者與核心的邊界,你看見中斷被導引、記憶體被夥伴與板塊配置器分發出去,也看著它在搶占之下靠著鎖維持正確。那些零件中的每一個——排程器、記憶體管理員、檔案系統、裝置驅動程式、網路堆疊——都得住在某個地方。這最後一篇,談的就是那一個架構上的抉擇:我們要把那一大堆程式碼放在哪裡,又要在最高權限下信任它們之中的多少?
回想整階都倚靠的那一個事實:在核心模式裡,CPU 願意執行任何特權指令、碰觸任何實體位址、指揮任何裝置,而在使用者模式裡則一概不能。跑在核心模式裡的程式碼,沒有任何護欄。所以這個架構問題,其實是一個關於信任的問題:我們要讓那個受信任、無所不能的部分,長到多大?單核心(monolithic)與微核心(microkernel)就是那兩個著名的答案,而它們往相反的方向拉。
單核心:所有人擠在同一個房間
一個單核心把幾乎所有作業系統服務——排程器、虛擬記憶體、檔案系統、驅動程式、網路堆疊——統統塞進一支龐大的程式裡,這支程式整個跑在核心模式中,共處於單一的位址空間。想像那位大樓管理員不只坐在前台,還親自兼任水電工、水管工、保全和分信員,全擠在同一個房間裡,共用同一串鑰匙。當檔案系統想要磁碟驅動程式時,它不會送一則訊息出去再乾等;它就只是呼叫一個函式,就像任何普通程式裡的一部分呼叫另一部分那樣。這就是單核心全部的魅力所在:服務之間靠直接的函式呼叫溝通,而那大概是通訊所能達到的最便宜了。
Linux 就是那個高聳的範例,而這裡值得老實澄清一個誤解:「單核心」並不等於「一坨凍住、改不了的大團塊」。透過你上一篇遇過的可載入核心模組,一個單核心可以按需把一個新的驅動程式或檔案系統,載入正在執行的核心裡。但是——而這正是關鍵所在——載入單核心裡的一個模組,是在那同一個受信任的位址空間內部、以完整權限執行的。它依然是所有人擠在同一個房間;你只不過是讓一個新人從側門進來,還配了一整串鑰匙。
微核心:微小的內核,伺服器都搬到外面
一個微核心下的是相反的賭注。它把跑在核心模式裡的部分,縮到絕對需要完整權限的最低限度——通常就只有三件事:管理位址空間、排程執行緒、以及在它們之間傳遞訊息。其餘的一切——檔案系統、驅動程式、網路堆疊,甚至大半的虛擬記憶體策略——都被逐出核心,改寫成普通的使用者模式程式,叫做伺服器(server),每一個都待在自己受保護的位址空間裡,對那個微小的內核來說,每一個都不過是另一個行程。如今那位大樓管理員幾乎什麼都不做,只負責轉接訊息;水電工和水管工,成了走廊另一頭各自鎖著辦公室的獨立承包商。
回報,是透過隔離換來的強韌。如果檔案系統伺服器有個臭蟲而崩潰,那不過是一個使用者行程死掉而已——核心安然無恙,而一個監督者通常還能在系統其餘部分照常運轉的同時,把那個失敗的伺服器重新啟動。一個出錯的驅動程式,再也沒辦法塗改排程器的資料,因為它字面上就是碰不到排程器的記憶體;它住在那道保護一支應用程式不受另一支侵擾的同一面硬體牆後頭。這就是微核心在「失敗無可接受」之處備受青睞的深層原因:航空電子、醫療裝置、安全飛地。
但天下沒有白吃的午餐,而帳單在通訊這一關到來。在單核心裡,檔案系統要碰到磁碟驅動程式,是一次函式呼叫。在微核心裡,檔案系統伺服器要碰到磁碟驅動伺服器,靠的是行程間通訊——一種必須穿過核心的訊息傳遞。而穿過核心一趟,意味著一次內容切換:陷入核心、複製或映射訊息、切換位址空間、排程並執行另一個伺服器,然後再把整趟旅程走回來。單核心只付一次便宜的呼叫,而微核心要付下好幾次跨越保護邊界的代價。微核心拿 IPC 成本去換強韌——這短短一句話,就是整場辯論的縮影。
實作追蹤:同一個 read(),兩種架構
沒有什麼比看著同一個操作以兩種方式發生,更能把這筆取捨變具體了。拿一支程式來說,它用你早已熟悉的那個系統呼叫 read(fd, buf, n),向一個檔案要一段資料,而這段資料尚未被快取,必須從磁碟取得。使用者那一側在兩個世界裡一模一樣——包裝函式陷入核心的方式,就如這一階第二篇所描述的那樣。不同的,是控制權落入核心之後所發生的一切。
MONOLITHIC (one address space, function calls):
read() trap --> VFS layer --> ext4 code --> block layer --> disk driver
(all just function calls inside the kernel)
--> driver issues I/O, data lands in kernel buffer, copied to user buf
Boundary crossings: 1 (the syscall trap in and the return out)
MICROKERNEL (separate servers, messages):
read() trap --> microkernel
-> IPC msg to File-System server (switch + run FS server)
-> IPC msg to Disk-Driver server (switch + run driver server)
-> driver does I/O, replies with data (switch back)
-> FS server replies with data (switch back)
-> microkernel returns data to the app (switch back)
Boundary crossings: many (one pair per server hop)盯著這兩幅圖,整個論點就看得見了。單核心那條路,是一個房間裡一連串便宜呼叫排成的直線——很快,但如果 ext4 程式碼或驅動程式弄壞了記憶體,那條線會一路直直貫穿排程器和其餘的一切。微核心那條路,則在一道道受保護的牆之間來回曲折,每跳一步都要付一筆切換與複製的小過路費——比較慢,但驅動伺服器裡的一個臭蟲,會止步於它自己位址空間的那道牆,碰不到檔案系統伺服器,更別說那個內核了。速度住在左邊;韌性住在右邊;而右邊那些過路費,正正就是我們點名過的 IPC 成本。
這場辯論實際上是怎麼收場的
這可不是一場客客氣氣的理論分歧。在 1990 年代初,它成了一場著名的公開論戰,而有許多年,實務上的裁決似乎偏向單核心:像 Mach 那樣的早期微核心很慢,因為在當年的硬體上,IPC 是真的昂貴,而 Mach 最後為了把速度撈回來,把太多東西又塞回核心裡,模糊了它本該劃出的那條線。與此同時,一個樸實無華的單核心——Linux——靠著快速、務實、又容易用模組擴充,悄悄地吃下了整個世界。在不經意的旁觀者眼裡,看起來就像是單核心直接贏了。
但故事並未在那裡畫上句點,而誠實的結論,比「某一方贏了」要有趣得多。L4 這一系微核心證明了,那份緩慢大半是一場工程上的失敗,而非什麼根本的定律:靠著對 IPC 進行毫不留情的最佳化,L4 把一則訊息的成本砍掉了超過一個數量級,讓微核心這個主意變得真正可行。它的後裔 seL4 走得更遠,成了第一個經過機器檢核的形式化證明、被證明正確的通用核心——在它的假設之下,這是一份數學上的保證,保證該核心沒有某幾整類的臭蟲。你沒辦法對數百萬行的單核心做出一份相當的證明;微核心那個小小的受信任內核,正是讓這樣一份證明根本成為可能的關鍵。所以微核心的強韌終究是真的——只不過花了數十年工夫去攤平那筆 IPC 帳單,才負擔得起。
大多數真實系統實際的作法,是拒絕選一個純粹的陣營。macOS 與 iOS 底下的那個核心 XNU,是從一個 Mach 微核心長出來的,卻為了速度把它的服務跑在核心裡;Windows NT 有著分層的設計、帶有微核心的血統,卻又為了效能把圖形等東西拉進核心。我們把這些叫做混合核心:它們保留了一些微核心的結構與理想,同時把最熱的路徑搬回核心內,以閃避那筆 IPC 過路費。「混合」這個標籤有點滑溜——純粹主義者會主張大多數混合核心其實是最佳化過的單核心——但它確實點出了業界大多走的那條真實的中間道路。
整階該帶走的東西
一路退到最後面來看。這一階裡的每一篇——開機序列、系統呼叫路徑、中斷與核心配置、同步與模組——其實都在探究同一股張力:一個核心,必須強大到足以驅動硬體,又可信賴到不會把機器砸爛,而這兩股拉力彼此拉扯。單核心與微核心,是化解那場拉扯的兩種有原則的辦法。單核心信任一個龐大的內核,得到的回報是純粹的速度與簡潔;微核心只信任一個微小的內核,得到的回報是隔離、可重啟、乃至可被證明的正確性——而代價,付在 IPC 上。
再留意一下,這場辯論如何悄悄地呼應了你在這之前那一階的最後一篇。容器對上虛擬機器的抉擇,是同一個形狀:為了輕量而多共用,或為了安全而多複製、多隔離。核心這個問題,就是同一桿秤,畫在機器最深的那一層——我們要在那個受信任的內核裡共用多少,又要把多少築牆隔開。一旦你能看見那同一桿秤,在每一層底下運轉著,從單單一個核心一路上到整支整支的機隊,你就不再是在背誦作業系統了。你是在閱讀它們。