超管理器坐在哪裡?第一型與第二型
在第一篇導覽裡你認識了超管理器——那一薄層軟體,給每台虛擬機器發一份假的硬體,並讓各個客體(guest)察覺不到彼此。對任何一個真實的超管理器,最先該問的問題,是一個「座位」的問題:它在軟體堆疊裡到底住在哪?答案有兩種,而整個產業都圍著它們組織起來。第一型超管理器直接跑在裸機之上——它就是機器上最底層的軟體,坐在矽晶片上、一般作業系統本來會坐的那個位置,而客體作業系統則層層疊在它上頭。第二型超管理器則像一個一般的程式那樣,跑在一套正常的宿主作業系統裡——你日常的 Windows、macOS 或 Linux 先開機,超管理器只是又一個在它上面跑的應用程式,客體再往裡又巢狀深一層。
把這個差別想成經營民宿的兩種方式。第一型超管理器是一棟為此而建的公寓大樓:一樓除了大樓自己的管理處之外什麼都沒有——沒有住戶住在那裡,它存在的唯一目的就是分配房間——而每一位居民都是它上方的客人。第二型超管理器比較像是把你自己已經住著的房子裡的空房間出租:宿主作業系統是日復一日佔著這地方的那家人,客人則被塞進主人借給他們的房間裡。那棟大樓更精瘦,給每位客人的服務也更均勻、更可預期;而那間合住的房子更容易張羅,因為已經有人住在那兒了,你只要再裝一個應用程式。
核心把戲:陷阱與模擬
現在來談那個深的問題。客體作業系統以為自己擁有這台機器。它會試著去做核心會做的那些事——關掉中斷、重新載入分頁表暫存器、跟磁碟控制器對話、進出特權模式。但它絕對不能真的被允許這麼做,因為那會讓它踩爛超管理器、也踩爛其他客體。那麼,你要怎樣讓一個客體相信自己當家作主,卻又悄悄剝掉它的真正權力?答案是整個系統軟體裡最優雅的點子之一,而它再利用了一堵你早就熟得不能再熟的牆:雙模式運作,硬體在使用者模式與核心模式之間的那道劃分。
這就是那一手,而它有個名字:陷阱與模擬(trap-and-emulate)。把整個客體——連客體自己的核心一起——都放到使用者模式、那個被降權的層級去跑。讓真正的超管理器成為唯一跑在核心模式的東西。如今一般的客體指令(把兩個數字相加、複製客體本來就擁有的記憶體)就直接以原生全速在真實的 CPU 上跑;什麼特別的事都沒發生,所以幾乎沒有額外負擔。但就在客體核心嘗試一道特權指令的那一瞬間——某件只有核心模式才被允許做的事——硬體拒絕了,因為客體其實在使用者模式。這次拒絕不是當機;它是一個陷阱(trap):CPU 跳進超管理器,跟一道陷阱指令進入一套正常核心所用的,是完全一樣的機制。超管理器接住這個陷阱,看看客體想做什麼,在客體的那份假硬體上把那個動作做掉(它把它模擬出來),再把控制權交回去。客體從頭到尾不知道自己被攔截過。
guest kernel (running in USER mode)
| executes a privileged instruction
v
[ CPU sees user mode -> refuses -> TRAP ]
|
v
hypervisor (KERNEL mode)
1. catch the trap
2. decode what guest meant to do
3. emulate it on the guest's virtual hardware
4. return -> resume guest right after the instruction為什麼這是個這麼好的設計?因為常見的情況——壓倒性多數的指令——完全不花成本;只有那些罕見的特權指令,才付出一次陷阱的代價。這跟「把系統呼叫弄得昂貴、卻讓一般算術免費」是同一種直覺。Gerald Popek 與 Robert Goldberg 早在 1974 年就把它講精確了:一台機器能被陷阱與模擬乾淨地虛擬化,當且僅當每一道敏感指令(會碰觸或洩漏特權機器狀態的)同時也是一道特權指令(在使用者模式跑時會陷阱的)。只要這條件成立,那張陷阱之網就接住了一切危險的東西。記牢這句話——它正是 x86 打破的那個條件。
為什麼 x86 在歷史上這麼難
陷阱與模擬很美,但它只在硬體配合時才管用——而幾十年來,地球上最熱門的硬體偏偏不配合。經典的 x86 指令集,是在沒人在意虛擬化的年代設計的,裡頭含著一組惡名昭彰、大約十七道敏感卻不特權的指令:它們讀取、或悄悄改動特權機器狀態,然而在使用者模式跑時不會陷阱。它們就只是默默地做錯事。教科書裡的反派是 POPF 這道指令。在真正的核心模式裡,它能改動中斷致能旗標;把同一道 POPF 拿到使用者模式跑,CPU 不但不陷阱、讓超管理器有機會介入,反而只是忽略那一部分、繼續往下跑。那張陷阱之網破了個洞,而危險的指令就這麼直接溜了過去。
按 Popek 與 Goldberg 的法則,光是這一個瑕疵,就意味著 x86 根本無法被陷阱與模擬乾淨地虛擬化。你沒辦法只是把客體降權、然後信任陷阱會接住一切,因為最糟的那些指令不會陷阱。多年來,這被當成一件斬釘截鐵的不可能——然後在 1998 年,一家叫 VMware 的公司用純軟體找到了繞過去的辦法。他們的把戲是二進位轉譯(binary translation):與其讓客體的核心程式碼直接跑、再指望它陷阱,超管理器在指令真正執行前一步掃描客體的指令流,並即時把它改寫。每一道無害的指令都被原封不動留下、以原生速度跑;而每一道那種陰險的「敏感卻不特權」指令,都被換成一段會改去呼叫超管理器的安全序列。實際上,就是軟體製造出了硬體忘了提供的那些陷阱。
一個微妙卻重要的誠實:二進位轉譯之所以慢,並不是因為轉譯一直在發生——大多數程式碼只被轉譯一次、然後快取起來重複使用。成本出現在別的地方。轉譯後的程式碼更大,轉譯器本身燒掉 CPU 與記憶體,而某些工作負載(大量系統呼叫、大量分頁表改動)會比原生觸發多得多的超管理器來回。所以早期純軟體的 x86 虛擬機,令人佩服地正確,卻背著一筆真實、量得出來的稅。正是這筆揮之不去的稅,催生了下一節那個硬體解法,要把它抹掉。
虛擬化的三條路:全虛擬、半虛擬、硬體輔助
退一步,你就能把整片地景看成同一個問題的三個答案。全虛擬化(full virtualization)就是二進位轉譯給你的東西:客體作業系統被完全不加修改地保留——它真心以為自己在真實硬體上——而超管理器則做盡所需的任何軟體體操,去把這場幻覺維持得密不透風。最大的好處是:你能跑一套現成、未經改動的作業系統,甚至是一套你從沒看過原始碼的。代價就是我們剛剛描述的那筆額外負擔。
半虛擬化(paravirtualization)採取相反的交易。與其欺騙客體,你乾脆讓它知道內情。在半虛擬化裡,客體作業系統被修改過,使得每當它本來會執行一道礙手礙腳的特權指令時,它改成客客氣氣地發出一個明確的呼叫——一個超呼叫(hypercall)——直通超管理器,就像一個一般程式向核心發系統呼叫那樣。沒有陷阱要接、沒有指令流要改寫、沒有惹人厭的指令要操心,因為客體根本就不去跑它們。結果是又快又乾淨。代價是對一個硬限制的誠實:你只能半虛擬化一套你改得動原始碼的作業系統。Xen 讓這個做法出了名;這也是為什麼半虛擬化的驅動程式(給磁碟與網路用的一條協作快速通道),即使在其他方面是全虛擬化的系統上,至今依然無處不在。
硬體輔助虛擬化(hardware-assisted virtualization)是晶片廠商最終給出的答案,而它已悄悄讓另外兩場辯論幾乎變得無關緊要。約莫在 2005–2006 年,Intel(VT-x)與 AMD(AMD-V)替 CPU 加上了一個全新、更加特權的運作層——常被叫做根模式(root mode),對應客體的非根模式(non-root mode)。有了硬體輔助虛擬化,客體核心終於能跑在它自己貨真價實的核心模式(所以未經修改的作業系統照樣能動,就像全虛擬化),然而 CPU 會在那些真正要緊的事件上,自動乾淨地陷阱出去給超管理器——這叫一次 VM exit——直接在矽晶片裡把那個十七道指令的洞補上。不用二進位轉譯、不用改客體。從某種意義上說,這是陷阱與模擬終於被做對了,由硬體替你執行 Popek 與 Goldberg 的法則。不過要對那個但書誠實:一次 VM exit 與配對的 VM entry 並不免費,所以新的賽局變成了:盡量少觸發它。
把它串起來,以及那些誠實的但書
- 客體裡的一個程式發出一個需要客體核心的請求——比方說它呼叫 read(fd, buf, n) 去讀一個檔案。
- 客體核心以原生速度跑一般的程式碼,接著抵達一道會碰觸特權狀態的指令(去設定磁碟控制器、改動一個控制暫存器)。
- 控制權離開客體:在老 x86 上,一段二進位轉譯出來的小程式呼叫超管理器;在現代硬體上,CPU 乾淨地做一次 VM exit、進入根模式。
- 超管理器對著這個客體的虛擬硬體模擬出本來想要的效果,與其他每個客體、以及它自己,都隔離開來。
- 控制權返回,客體就從它離開的那一點繼續,渾然不覺自己從來沒擁有過這台機器。
留意這篇導覽涵蓋了什麼、又沒涵蓋什麼。我們讓 CPU 可虛擬化了:客體能跑它自己的核心,而超管理器穩住掌控。但一台真實的機器不只是一顆 CPU。客體還預期自己能管理記憶體——而它建起來的那些分頁表,本身就是危險的特權狀態,所以它們需要自己的一套陷阱與模擬故事(先是影子分頁表,再來是硬體的巢狀分頁表)。它預期有實際上並不存在的磁碟與網路卡。這些——把記憶體與輸入輸出虛擬化,以及一台虛擬機器怎麼竟然能在執行中被整個拎起、搬到另一台實體主機上——正是下一篇導覽的全部主題。這一篇給了你引擎;下一篇把輪子裝上去。