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

CPU 虛擬化:陷阱並模擬與 VT-x

第一篇讓你看見了什麼是 hypervisor。這一篇回答那個差點讓虛擬化破局的問題:當一個被寫來「擁有整台機器」的客體作業系統,在一個盒子裡執行一條特權指令時,hypervisor 要如何繼續掌權——而且還不讓客體察覺它其實並沒有真的擁有機器?

核心把戲:讓客體跑,但當它伸手要權力時就攔下它

從第一篇你已經握有關鍵概念:hypervisor 執行客體虛擬機,而其中的客體是一整個作業系統,它被寫成相信自己擁有裸機——它預期能設定分頁表、遮蔽中斷、並直接和 CPU 的控制暫存器對話。問題一說出口就很明顯:真實的 CPU 只有一顆,它無法讓兩個核心都擁有它。那麼,你要怎麼執行一個不斷想做特權操作的客體核心,而既不真的讓它奪走機器、也不逐行重寫它?讓機器虛擬化變得可行的答案,是一個單一而漂亮的把戲,叫做陷阱並模擬(trap-and-emulate)

它的樣貌是這樣。你把客體的普通指令——加法、載入、儲存,這些構成任何程式幾乎全部內容、無害的算術與記憶體操作——直接在真實 CPU 上以原生全速執行。對那些指令沒有直譯器、也沒有任何拖慢,因為客體並不特別;它只是程式碼。hypervisor 只需要在客體想做某件會影響真實機器狀態的事時介入——一條特權指令,例如重新載入分頁表暫存器或停住 CPU。而硬體早就有一個正好用來逮住這種事的機制,那是你在下兩級就遇過的:你把客體跑在沒有特權的使用者模式,於是它一執行特權指令,CPU 就拒絕並引發一個陷阱——一個一般保護錯誤(general-protection fault)——把控制權猛地拉給特權最高的程式碼:hypervisor。

現在是後半段:模擬(emulate)。hypervisor 逮住陷阱,看客體本來想執行哪條指令、帶著什麼運算元,然後代替客體做正確的事——但是對著一份私有的、虛擬的機器狀態副本來做,絕不對真實狀態做。如果客體想載入一個新的分頁表基底,hypervisor 就把這件事記進客體的虛擬 CPU 狀態(並更新它自己對真實分頁表的影子,而那正是下一篇的全部主題)。如果客體想關閉中斷,hypervisor 就只是在一個旗標裡記下「這個客體以為中斷關了」然後返回。接著它把客體儲存的指令指標推進到出錯指令之後,再恢復客體,而客體從不知道發生過任何不尋常的事。在危險的指令上設陷阱、安全地模擬它們、其餘一切原生執行——這就是全部的想法。

陷阱並模擬到底什麼時候才行得通?Popek 與 Goldberg

陷阱並模擬要能成立,唯一的前提是:每一條危險指令在以非特權方式執行時都真的會引發陷阱。只要有哪怕一條近乎特權的指令,在使用者模式下不是出錯、而是安靜地做了別的事,hypervisor 就永遠拿不到介入的機會,那個幻象也會無聲無息地破掉。1974 年,Gerald Popek 與 Robert Goldberg 把這件事講精確了,他們的結果至今仍是這個領域的基石:Popek 與 Goldberg 定理。要陳述它,你需要兩個仔細劃分的指令類別,而它們之間的差別,就是這整場遊戲的全部關鍵。

一條特權指令(privileged instruction)是指:在使用者模式執行就會引發陷阱、只有在核心模式才能執行的指令——這是 CPU 內建的圍籬。一條敏感指令(sensitive instruction)則更廣:任何會讀取或改變機器特權狀態(你正處於哪個 CPU 模式、中斷旗標、分頁表暫存器)的指令,或其行為取決於那個狀態的指令。經典的範例:想像一條把目前特權等級讀進暫存器的指令。它是敏感的——它的結果取決於真實的特權狀態——但如果硬體讓它在使用者模式下無害地執行(就只是回傳真實的環號)而不是引發陷阱,那它就不是特權指令。Popek 與 Goldberg 的定理說:陷阱並模擬之所以可行,正好是當每一條敏感指令也都是特權指令之時——也就是當危險指令的集合,是會引發陷阱的指令集合的子集合之時。若這成立,你就能把它們全部逮住。

  sensitive  = touches or depends on privileged state (dangerous)
  privileged = traps when run in user mode (catchable)

  Popek-Goldberg, virtualizable when:
      { sensitive } is a subset of { privileged }
      i.e. every dangerous instruction is one we can trap

  The x86 hole (pre-2005):
      some sensitive instructions did NOT trap in user mode
      -> classic trap-and-emulate could not catch them
把條件畫成一張圖:只要有任何敏感指令不是特權指令,它就會溜過去。

為什麼 x86 違反了這條規則——以及那些糟糕的補救辦法

歷史的刺點在這裡:世界上最重要的 CPU——32 位元的 x86——沒能通過 Popek-Goldberg 條件。它有一組惡名昭彰、大約十七條「敏感但不特權」的指令——最有名的例子是 popf,它把一個值彈進旗標暫存器。在核心模式下 popf 能改變「中斷啟用」旗標;在使用者模式下,同一條指令卻只是安靜地忽略那個位元、而不引發陷阱。於是一個跑 popf 來關中斷的客體核心會什麼都沒改、也得不到任何錯誤——hypervisor 永遠不會發現,客體對機器的心智模型於是與現實脫節,最終就出錯了。每個人都想虛擬化的那顆 CPU,按照嚴格的定理,根本不是傳統意義上可虛擬化的。

工程師拒絕放棄,而他們做出的變通辦法值得認識,因為它們顯示了賭注有多高。VMware 大約在 1999 年的突破是二進位翻譯(binary translation):在執行客體核心的程式碼之前,先掃描它,並即時把那些惹麻煩的敏感指令改寫成安全的序列,讓它們真的會呼叫進 hypervisor——本質上就是在指令層級替客體打補丁,讓那些無法被陷阱攔下的指令終究還是被攔下。它行得通,是一項貨真價實的工程壯舉,但它複雜、且帶著實在的額外成本。第二條路——下一篇談 virtio 時會繞回來——是半虛擬化(paravirtualization):別跟硬體對幹——改客體。修改客體核心的原始碼,讓它原本會執行危險指令的地方,改成發出一個明確、有禮貌的呼叫(一個 hypercall)直達 hypervisor。更快也更乾淨,但它要求一個你被允許打補丁的客體,所以它沒辦法跑一個未經修改、現成的作業系統。

VT-x:當硬體長出一個給 hypervisor 的真正位置

乾淨的解法只能來自晶片本身,而在 2005 至 2006 年它真的來了:Intel 的 VT-x 與 AMD 的 AMD-V 帶來了硬體輔助虛擬化。核心想法很優雅。與其把 hypervisor 與客體塞進同樣那四個老舊的特權環、再指望陷阱能逮住一切,CPU 加上了一個全新的執行維度:兩種模式,叫做 root(根)non-root(非根)。hypervisor 跑在 VMX root 模式——這個新的、特權最高的位置,有時被畫成「環 -1」,位於客體核心之下。整個客體,包含核心在內,跑在 VMX non-root 模式,在那裡它可以使用環 0、感覺像個正常核心——但它的危險操作現在被設定成會自動彈出去給 hypervisor。

兩個新的轉換把這兩種模式縫在一起。一次 VM-entry 把硬體帶下去進入 non-root 模式以執行客體;一次 VM-exit 則是舊式陷阱的現代、硬體原生的後繼者——當客體做了某件被設定為要攔截的事,CPU 會自動儲存客體的完整狀態、切回 root 模式、把 hypervisor 落在一個已知的處理常式上,並附上一個原因碼,明確告訴它這次離開為何發生(一條特權指令、一個外部中斷、一次想碰控制暫存器的嘗試)。hypervisor 讀那個原因,對著客體的虛擬狀態模擬該操作,再發出一次 VM-entry 來恢復。這就是第一節那同一支陷阱並模擬之舞——但現在陷阱是 CPU 的頭等功能,依設計就能逮住每一條敏感指令,於是那個十七條指令的 x86 漏洞就此關上。不需要二進位翻譯、不需要打過補丁的客體:一個未經修改的 Windows 或 Linux 現在能直接作為客體執行。

這一切設定都住在一個由 hypervisor 掌控、位於記憶體中的結構裡:VMCS(虛擬機控制結構;AMD 把它的版本叫做 VMCB)。把它想成這個客體的「每虛擬機控制面板」。它保存著客體儲存下來的暫存器狀態、離開時要還原的主機狀態,以及最關鍵的一組控制位元圖(control bitmaps),讓 hypervisor 精確地調定哪些事件會引發 VM-exit——哪些例外、哪些控制暫存器寫入、某個特定 I/O 埠或某條特定指令是否該彈出去。hypervisor 對一個客體的整套政策,很大一部分就只是它在那個結構裡設下的那些位元。

它的代價,以及這條路通向何方

硬體輔助讓虛擬化變得正確又簡單,但要誠實面對代價:一次 VM-exit 並不免費。它是一次完整的硬體脈絡切換——儲存所有客體狀態、還原主機狀態、執行處理常式、回程時再反向操作一遍——成本大約是上千個週期,遠多於一次普通的函式呼叫。因此一個現代 hypervisor 的效能藝術,就是避免離開(exits):把 VMCS 設定成讓常見、無害的操作根本不離開,只有真正稀少、真正特權的那些才離開。一個做密集運算的客體可以連續跑很長一段、零次離開、以接近原生的速度執行;一個猛敲虛擬裝置的客體則不斷離開、付出昂貴代價。這正是第一篇裡那個 type-1 對 type-2 的效能故事,如今落實在一個你可以數出來的具體事件上。

退一步,把整條弧線握住。CPU 虛擬化的解法,是讓客體原生執行、只在它伸手要特權時才設陷阱;Popek-Goldberg 條件告訴你這件事到底什麼時候才可能;x86 違反了它,於是需要二進位翻譯或半虛擬化,直到 VT-x 加上 root/non-root 的劃分、讓 VM-exit 乾淨地逮住一切。但請留意我們刻意留下的那道缺口:每次客體碰它自己的分頁表,我們都說 hypervisor 必須讓一份對真實分頁表的影子保持同步。CPU 被虛擬化了——記憶體還沒,而天真地對每一次分頁表編輯都設陷阱,會是一場昂貴離開的風暴。補上那道缺口,正是下一篇要去的地方:影子分頁表,以及硬體的答案 EPT。