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

記憶體虛擬化:影子表與 EPT

一個客體作業系統以為自己擁有實體 RAM——但它並沒有。本篇追著一次記憶體存取,穿過虛擬化所添加的那兩層轉譯,並展示一個 Hypervisor 讓它變快的兩種辦法:軟體的影子分頁表,以及硬體的巢狀分頁(EPT)。

客體的謊言:到底誰真正擁有實體記憶體?

本段第二篇處理了 CPU 虛擬化:Hypervisor 直接在真實 CPU 上執行客體的指令,只在客體做了某件特權的事情時——透過一次 VM exit——才介入。那對指令而言行得通。但有一種資源,客體作業系統不停地伸手去拿、並假設自己完整地擁有它:那就是實體記憶體。客體的核心建了它自己的分頁表,它認為位址 0x0 是 RAM 的第一個位元組,而且它自由地對映任何它喜歡的實體頁框。一旦你把話講出口,問題就很明顯了:這裡有好幾個客體,外加主機,它們不可能全都擁有實體頁框 0。一定有人在對客體說謊,而且要做得夠快,快到客體從不察覺。

於是虛擬化加上了第二層轉譯。從虛擬記憶體那一段你已經知道其中一層:客體的 記憶體管理單元(MMU)把一個客體虛擬位址,轉成客體相信是實體的位址。我們稱之為客體實體位址。但客體實體位址本身就是一場虛構——它不過是客體編造出來的一個數字。Hypervisor 必須把客體實體位址轉譯成真正的機器位址,亦即真實 DRAM 晶片裡那個真實的位元組。所以現在一次記憶體存取需要兩層疊在一起的轉譯,而記憶體虛擬化的整門藝術,就是把那第二層轉譯做得幾乎免費。

Three address spaces, two translations:

  guest virtual addr  --[ guest page tables ]-->  guest physical addr
  guest physical addr --[ hypervisor mapping ]-->  host physical addr

Native (no VM):   GVA --------------------------> HPA   (one walk)
Virtualized:      GVA --> GPA --> HPA                  (two walks)
沒有虛擬機時,一次分頁表查訪就把一個虛擬位址對映到一個真實的實體位址。有了客體之後,那單一的對映變成了一條兩節的鏈——而第二節屬於 Hypervisor,不屬於客體。

為什麼你不能直接讓客體的分頁表上場

這裡有個誘人的捷徑,以及它失敗的原因。CPU 恰好有一個暫存器指向當前作用中的分頁表根部——在 x86 上那是 cr3——而硬體 MMU 會查訪那個暫存器所指的任何東西。所以你也許會希望:乾脆把客體自己的分頁表載入 cr3,讓真實的 MMU 去做事就好。但客體的分頁表是把客體虛擬位址對映到客體實體位址,而客體實體位址是那個編造出來的數字。如果客體把一個虛擬分頁對映到客體實體頁框 0x5000,而你讓 MMU 直接使用那張表,硬體就會去讀真實的機器頁框 0x5000——那大概屬於主機、或屬於另一個客體。這不是一個效能臭蟲;這是隔離性的徹底破口,而隔離性正是 Hypervisor 存在的唯一理由。

於是 Hypervisor 面對一個嚴苛的要求:硬體 MMU 最終所查訪的那張表,必須把客體虛擬位址直接對映到正確的主機實體頁框;然而客體仍必須能隨時自由地編輯它自己的分頁表,並相信自己掌控著記憶體。不能把真相告訴客體——那會打破幻象,也會打壞客體自己的核心程式碼。有兩種設計解決了這件事。較舊、純軟體的那一種是影子分頁表。較新、由硬體輔助的那一種是巢狀分頁,Intel 把它賣作 EPT、AMD 賣作 NPT。本篇接下來就是這兩個概念。

影子分頁表:Hypervisor 保留一份秘密副本

影子分頁表是一個建立在第二篇那套 VM exit 機制之上的巧妙把戲。其構想是:客體在它自己的記憶體裡建立它自己的分頁表,完全就像它在裸機上會做的那樣,把客體虛擬位址對映到客體實體位址。Hypervisor 從不讓硬體直接使用那些表。取而代之,對於每一張客體分頁表,Hypervisor 維護一份影子——一張第二的、隱藏的、客體看不見的表——它把兩層轉譯塌縮成一層。客體把虛擬分頁 V 對映到客體實體頁框 G 之處,影子就把 V 直接對映到主機實體頁框 H,亦即 Hypervisor 為支撐 G 而挑選的那個真實機器頁框。然後 Hypervisor 把那張影子載入 cr3。真實的 MMU 查訪影子、在單一次查訪內取得正確的主機實體位址,而客體始終蒙在鼓裡。

但這裡有個決定一切的關卡:影子如何維持同步?客體自由地編輯它自己的表——它新增一個對映、移除一個、把一個分頁標記為唯讀——而 Hypervisor 必須把每一個這樣的變更都映照進影子裡,否則影子就會過時,客體就會看到錯誤的記憶體。訣竅是讓客體的編輯觸發陷阱。Hypervisor 在影子裡把客體真正的分頁表標記為唯讀。當客體寫入它自己的分頁表時,那次寫入打到一個唯讀分頁,觸發一次 VM exit;Hypervisor 接住它、解碼客體本來想寫什麼、把等價的變更套用到影子上,再讓客體繼續執行。客體相信自己自由地編輯了記憶體;事實上每一次分頁表寫入都被攔截並影子化了。

EPT/巢狀分頁:讓硬體查訪兩張表

Intel 與 AMD 推出的修正是硬體輔助的巢狀分頁——Intel 的延伸分頁表(EPT)、AMD 的巢狀分頁表(NPT)。其洞見很直接:與其逼 Hypervisor 用手把兩層轉譯壓平成一張影子表,不如給 MMU 第二組分頁表,並教會它查訪兩張表。客體保留並自由地編輯它自己的分頁表(客體虛擬位址到客體實體位址),就像在裸機上一樣載入 cr3。Hypervisor 另外建立 EPT,它把客體實體位址對映到主機實體位址,並用一個新的控制暫存器指向它。現在硬體自己做這個兩層的查訪——客體表得出客體實體位址,再經 EPT 得出主機實體位址——每次存取都不需要 Hypervisor 介入。

這抹去了影子設計最痛的地方。現在當客體編輯它自己的分頁表時,什麼陷阱都不會觸發——那些編輯只改動客體虛擬位址到客體實體位址的對映,而那是客體真正被允許掌控的,所以硬體會套用它們、完全沒有 VM exit。Hypervisor 只在 EPT 本身需要變動時才介入:客體碰到一個 Hypervisor 尚未以真實記憶體支撐的客體實體分頁、MMU 無法完成第二層查訪,而會造成一次 EPT 違規 exit,好讓 Hypervisor 對映進一個頁框。例行的客體記憶體活動——那些壓垮影子分頁的 fork 與 mmap 風暴——如今以近乎原生的速度執行。

那第二層為你換來了什麼

退一步,留意那多出來的一層轉譯遞給了 Hypervisor 什麼獎賞:對「客體實體記憶體究竟是什麼」這件事,完整而透明的掌控權。因為 Hypervisor 擁有客體實體到主機實體的對映,它可以對整個客體玩上作業系統早已對行程玩的每一種把戲。它可以讓一個客體實體分頁在客體首次碰它之前都不被支撐(懶惰地把一台虛擬機按需換頁地變出來),也可以在客體不知情的狀況下,把客體閒置的分頁換出到磁碟。它甚至可以把同一個主機實體頁框交給兩個客體相同的分頁、並標記它為寫入時複製,讓兩台執行同一作業系統的虛擬機共用一份唯讀程式碼的副本,直到其中一個寫入——這是唯有第二層轉譯才使其成為可能的記憶體節省。

有一點告誡讓這篇保持誠實,也把整段繫了起來。這兩種設計不是不同的目標——它們是用兩條路抵達的同一個目標,而哪一個會上場取決於硬體:一顆有 EPT/NPT 的現代 CPU 用巢狀分頁,而一顆沒有它的老晶片則退回到軟體影子分頁。客體分辨不出差異,而這正是重點所在;它只是編輯它的分頁表、讀取記憶體。而記憶體只是 Hypervisor 必須虛擬化的三種資源之一。CPU 你在第二篇看過了;記憶體在這裡;接下來,第四篇轉向輸入輸出,在那裡客體與虛擬裝置對話——而硬體輔助的把戲,讓位給準虛擬化與 virtio 那種協作式的做法。