從不出錯,到不背叛
上一篇導覽擔心的是單一程式正確:一道宇宙射線翻轉了一個位元,ECC 抓住它、修好它,機器繼續算出正確的答案。那是可信賴的其中一張臉——可靠性,也就是機器不會意外地失效。這篇導覽轉向更尖銳的另一張臉:機器不會存心背叛你。在任何一台真實的電腦上,數十支你並不完全信任的程式——網頁、下載來的應用、那一款來路不明的遊戲——肩並肩地跑在同一顆處理器、同一塊實體 RAM 上。硬體的任務,是確保它們誰也碰不到另一支程式的祕密,誰也奪不走整台機器的控制權。
為什麼非得由硬體來做?因為光靠軟體做不到。假如保護只是作業系統客客氣氣地請大家遵守的一條規矩,那麼第一支有臭蟲或懷惡意的程式,乾脆就不理那條規矩。任誰都能跨過去的牆,不是牆。所以處理器親自在矽片裡執行兩塊基石:特權,決定誰被允許做危險的事;以及隔離,決定每支程式甚至能看見哪些記憶體。把這兩件事做對,你就能安全地跑不受信任的程式碼;做錯——正如我們將在 Spectre 與 Meltdown 兩篇導覽裡看到的——整座大廈就會漏水。
特權等級:一位國王與他的臣民
第一道牆把世界分成兩種公民。處理器在數個特權等級之一裡執行:最受信任的是核心模式(又叫監督者模式),作業系統住在那裡;它之下是使用者模式,你的應用程式在那裡跑。把它想成一個有國王和臣民的王國。國王可以碰任何東西——分頁表、裝置控制器、決定下一個輪到誰跑的計時器。臣民只能做平凡而安全的工作:算術、分支、讀寫它自己的記憶體。某些指令是特權指令:在使用者模式裡試著執行其中一條,硬體就會拒絕,改而舉起一個錯誤。
但臣民有時是真的需要國王幫忙——開一個檔案、送一個封包,或要求更多記憶體。他們不能就這麼跳進核心程式碼裡;那會把整件事的意義都毀掉。他們改用一道受控的門:系統呼叫。一條特殊指令(在 RISC-V 上是 `ecall`)陷入核心,落在一個固定、受祝福的進入點上,把處理器切到核心模式,再把控制權交給作業系統事先選好的程式碼。核心檢查這個請求,代臣民做那件特權工作,然後返回使用者模式。關鍵的細節在於:使用者程式碼從來不能挑它落在核心的哪裡——它只能去敲那唯一一扇官方的門。
隔離:作為守門人的 MMU
特權說的是誰可以動手;隔離說的是每支程式能看見什麼。它的機制重用了你兩級之前已經見過的東西。回想虛擬記憶體:每支程式都相信自己擁有整棟房子,從位址 0x0 一路到頂,而一位安靜的翻譯員把它的房間映射到它們在實體 RAM 裡真正坐落的地方。那位翻譯員就是記憶體管理單元——MMU,而它做的遠不只是翻譯——在每一次記憶體存取上,它同時當著守門人,查驗這次存取究竟被不被允許。翻譯與保護是同一套機器;這正是為什麼虛擬記憶體不是「多出來的 RAM」,而是位址轉換加上保護。
行程隔離的魔法就住在這裡。每支程式拿到它自己的一組分頁表,而以國王身分執行的核心,把它們設定成:一支程式的虛擬位址只映射到核心交給它的那些實體頁框。程式 A 的分頁表裡,根本沒有一個項目指向程式 B 的頁框。於是即使兩者跑在同一塊 RAM 上,A 也字面上沒有任何辦法去叫出一個屬於 B 的位元組;它需要的那個位址,在 A 的地圖裡並不存在。兩支程式都在改「同一個」位址 0x4000,會被悄悄地導向兩個不同的實體頁,永不相撞。那些牆不是漆在 RAM 上的——它們烤進了「誰的轉換指向哪裡」之中。
每個分頁表項目還帶著幾個權限位元,MMU 在每次存取時都會查。典型的是:這一頁可以讀、可以寫、可以執行嗎?它能從使用者模式存取,還是僅限核心?這些位元給了記憶體保護牙齒。一頁裝著程式碼的頁被標成可讀可執行、但不可寫,於是臭蟲沒辦法在程式執行途中重寫它。一頁裝著資料的頁是可讀可寫、但不可執行,於是把資料偷塞進緩衝區的攻擊者,沒辦法接著跳進去把它當程式跑。而核心的頁被標成僅限核心,於是一支試圖去讀它們的使用者程式會立刻出錯——至少,那曾是鐵則,直到 Meltdown 在恰恰是這道查驗上揭示了一道裂縫。
一次受保護的存取,一步一步來
讓我們追蹤一次平凡的載入穿過守門人,親眼看保護與轉換一起發生。假設一支使用者模式的程式,從虛擬位址 0x4008 執行一次載入——比方說,某一頁的第八個位元組。一步一步走:
- 處理器把虛擬位址拆成一個頁號,以及頁內的一個偏移量,再把頁號交給 MMU。
- MMU 查 TLB——那塊裝著近期轉換的小而快的快取。命中時,它一個週期內就拿到實體頁框與權限位元;TLB 未命中時,它就去走記憶體裡的分頁表,把它們找出來。
- 它拿權限位元來對這次存取查驗:這一頁存在嗎?可讀嗎?允許使用者模式存取嗎?只要任何一項查驗沒過,MMU 就舉起一個錯誤,這次載入根本碰不到記憶體。
- 出錯時,控制權陷入核心——恰如系統呼叫那道受控的門——由國王決定怎麼辦:殺掉這支犯規的程式,或悄悄把事情補好再重試。
- 唯有每一項查驗都通過,MMU 才把實體頁框與偏移量組合成一個真正的實體位址,這次載入才終於去讀 RAM。
注意那道權限查驗就坐在轉換路徑本身之內:沒有什麼額外步驟是核心必須記得插進去的,也沒有什麼指令是程式能跑來跳過它的。你的程式發出的每一次記憶體參照——每秒數十億次——都被硬體默默地審查過。這正是它便宜到能永遠開著的唯一原因:它搭著你本來就在付錢的那趟轉換的便車,而 TLB 命中讓常見情況實際上是免費的。
虛擬化:國王之上的國王
現在把這個點子往上再撐高一層。一台雲端伺服器可能同時跑著好幾套完整的作業系統——你的虛擬機與一個陌生人的,在同一顆實體晶片上。每一套客體作業系統仍以為自己是國王,自由地設定分頁表、處理自己的系統呼叫。但在它們全體之上,必須有一位新的超級國王:超管理器,執行在核心模式之上的一個特權等級,把各自的 RAM 切片與核心份額分給每位客體,並且像核心隔離一般程式那樣去隔離各客體——只是高了一層。
難的部分是記憶體。一支客體程式的位址必須被翻譯兩次:從程式的虛擬位址,譯到客體作業系統所認定的實體記憶體,再從那裡譯到超管理器實際撥給的真正實體 RAM。用軟體做這件事慢得令人難受,於是晶片長出了明確的虛擬化支援——巢狀分頁表(Intel 的 EPT、AMD 的 NPT),讓 MMU 在一趟走查裡用硬體完成兩次轉換。這是這整個主題反覆出現的旋律:一道在軟體裡昂貴的安全或抽象邊界,被專門化進矽片以使它便宜,因為你跑不起的保護,就是沒人會開啟的保護。