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

虛擬化 CPU、記憶體與輸入輸出

一台虛擬機唯有把三項核心資源都忠實地偽造出來,才會令人信服:處理器、記憶體,還有各種裝置。這篇導覽會逐一走過——CPU 如何靠攔截它那些危險指令來共享、第二層分頁表如何把實體記憶體對客體藏起來,以及一個叫 virtio 的聰明取巧手法,如何讓虛擬磁碟與網路跑得快。

三場幻覺,而不只一場

到了現在,你已經懂得那個大主意:超管理器會給每個客體呈現一整台機器的軟體幻覺,也就是一台虛擬機,而上一篇導覽展示了處理器如何被共享的核心——攔截並模擬。但真實的電腦不只是一顆 CPU。程式還預期有可以定址的記憶體,以及可以讀寫的裝置。所以一台令人信服的虛擬機,得同時偽造三樣不同的東西,而每一樣都由相當不同的把戲來偽造。這篇導覽會依序帶過它們:CPU,接著記憶體,再來是輸入輸出。把這三樣都做對,那個客體作業系統——它自己就是個以為整棟樓都歸它管的大樓管理員——便永遠不會察覺自己其實只是個房客。

往下走的時候,口袋裡先揣著一條原則。虛擬化靠的是居中介入(interposition):超管理器把自己插在客體和真實硬體之間,讓無害的事直接在硬體上全速跑,只在危險或共享的部分才出手。整門手藝的精髓,就是讓那種介入既稀有又便宜。要是超管理器得逐條解譯每一道指令,一台虛擬機會慢得像爬。目標恰恰相反——絕大多數時候,客體都跑在裸 CPU 上,而超管理器是那位沉默的房東,你只在試圖去碰某樣其實不屬於你的東西時,才會見到他。

虛擬化 CPU:直接執行,其餘的就攔下來

回想那條讓整件事變得可解的規矩:客體那些尋常的算術、它的迴圈、它的記憶體讀取——所有無害的指令——都直接在真實 CPU 上以原生速度跑。只有特權指令,也就是那些會去重新設定真正機器的指令(設定計時器、跟裝置對話、改動分頁表暫存器),才必須被攔下來。超管理器把客體作業系統放在使用者模式裡執行,即使客體自以為身處核心模式。當客體在使用者模式下試圖執行一條特權指令時,硬體會拒絕並朝超管理器拋出一個陷阱,超管理器隨即默默地代客體做出正確的事,再讓它繼續。這就是一句話版的攔截並模擬:特權動作會陷入;超管理器則進行模擬。

上一篇導覽老實交代了為什麼這在 x86 上歷來如此痛苦:x86 有少數幾條指令是不可虛擬化的——它們在核心模式和使用者模式下行為不同,但在以非特權身分執行時卻不會陷入,只是默默地表現失常。於是一個客體有可能偷看到真實的機器狀態,進而發現它一直在對自己說謊。早期的 VMware 用二進位轉譯解決了這點,掃描客體的核心程式碼,當場把那些麻煩的指令改寫掉。後來晶片廠商直接修好了架構本身:硬體輔助虛擬化(Intel VT-x、AMD-V)在核心底下加了一層真正的新特權層。客體作業系統得以在它自己的世界裡坐進真正的核心模式,而超管理器則坐在更深一層的「root 模式」;那些危險指令如今會乾淨俐落地陷入到它那裡。不需要任何改寫。

虛擬化記憶體:一張分頁表疊在另一張分頁表上

現在來到最深的謎題,也是解法最漂亮的一個。客體作業系統執行它自己的分頁表,把它那些行程的虛擬位址,翻譯成它自以為是的實體位址。但那些並不是真正的實體位址——它們是「客體實體」位址,本身也只是超管理器發出來的一場幻覺。真實的 RAM 被分給好幾個客體,所以同一個客體實體位址「頁框 100」,對 VM A 和 VM B 來說,必定指向截然不同的真實頁框。於是我們有的不是一層、而是兩層翻譯:客體虛擬到客體實體(客體自己的分頁表),再來是客體實體到主機實體(超管理器的對映)。硬體 MMU 卻只走一張表——所以總得有什麼東西,把兩層摺成一層。

第一個解法是影子分頁表,純粹用軟體做。超管理器偷偷建起第三張表,把客體虛擬位址直接對映到真實的主機實體頁框——靠人工把兩層組合起來。這張影子表,才是真實硬體 MMU 實際用的那張。客體永遠看不到它;客體編輯自己的分頁表,深信那才驅動著翻譯,但超管理器把客體那張表標成唯讀,攔下客體做的每一次更動,再據此修補影子表。它行得通,可是代價殘酷:客體的每一次分頁表更新,如今都是一次陷入超管理器,而在一個忙碌的客體上,那種事接連不斷。雖然忠實,卻很昂貴。

後來硬體把這也修好了,而且很優雅:巢狀分頁表(Intel 叫它 EPT,AMD 叫它 RVI/NPT)。如今 MMU 自己就懂兩層。客體保有並自由編輯它自己的分頁表——客體虛擬到客體實體——完全不需要任何陷入。超管理器則保有第二張表——客體實體到主機實體——而硬體會在每一次未命中時,自動把兩張表一張接一張地走過去。代價從軟體(接連不斷的陷入)轉移到了硬體(TLB 未命中時,一趟更長的分頁表走訪)。哪一種比較划算?要看工作負載:當客體頻繁改動它的對映時,巢狀分頁勝出;當客體的分頁表很穩定、但 TLB 未命中很頻繁時,影子分頁可能勝出。誠實提醒——天下沒有白吃的午餐,只有一張不同的帳單。

  Without VM (one translation):
    process-virtual --[page table]--> physical frame

  With VM (two translations the MMU must do):
    guest-virtual --[guest page table]--> guest-physical
    guest-physical --[nested page table]--> host-physical frame

  Shadow page table = precompute the whole chain in software:
    guest-virtual ----------[shadow table]----------> host-physical
一台虛擬機需要兩層位址翻譯;影子分頁表在軟體裡把它們摺起來,巢狀分頁表則讓硬體自己走完兩層。

虛擬化輸入輸出:模擬,或者乾脆坦白

客體也想要磁碟、網路卡,和一個時鐘。第一種辦法是完整裝置模擬:超管理器假扮成某個特定、真實、廣為人知的硬體——比方說一張老款的 Intel 網路卡——一個位元一個位元地像。每當客體去戳那張卡的某個記憶體對映輸入輸出暫存器時,就會陷入超管理器,由它把這次存取解碼並演出來。巨大的好處是,客體完全不需要任何特殊驅動程式;它原本給那張著名老卡用的驅動程式照樣能用。壞處是速度:單單一個網路封包,就可能引發許多次暫存器的戳動,而每一次戳動都是一次陷入。忠實地模擬一個裝置雖然正確,卻很慢,原因跟逐條解譯每一道 CPU 指令會很慢是一樣的。

聰明的修法是別再假裝了。與其去模擬一個客體早已備有驅動程式的真實硬體,不如定義一個全新的、刻意做得很簡單的、知道自己身處虛擬化環境的裝置——並在客體裡面附上一個給它用的驅動程式。這正是把半虛擬化的主意套用到裝置上,而它通行的標準叫做 virtio。一個 virtio 裝置和它的客體驅動程式,會共用記憶體裡的一個環形緩衝區:客體一口氣把許多請求排進佇列,然後只通知超管理器一次(「佇列裡有工作了」),而不是每戳一次暫存器就陷入一次。穿越邊界的來回次數少得多,吞吐量也高得多。權衡之處在於對「自己是虛擬的」這件事坦白——客體必須跑一個 virtio 驅動程式,所以它知道自己身在一台 VM 裡。速度,是用些微失去那場完美幻覺換來的。

即時遷移:在不停機的情況下搬動一台執行中的機器

一旦一台機器的全部狀態——它的 CPU 暫存器、它的記憶體、它的虛擬裝置——都活在超管理器的資料結構裡,而不是焊死在某一塊實體主機板上,一件驚人的事就成為可能:你可以把一台執行中的 VM 整個抬起來,搬到另一台不同的實體主機上,幾乎不打一個嗝。這就是即時遷移,是雲端背後那股安靜的超能力。供應商要把一台機器清空以便維護、或者重新平衡負載,靠的就是它,而你的伺服器看起來卻從未停機過。難的地方在於:當你忙著把記憶體複製走的時候,那台 VM 還一直在改動它自己的記憶體。訣竅是去追著那些改動跑,直到幾乎沒剩下什麼為止。

  1. 預先複製,第一輪:在 VM 仍於來源主機上執行的同時,把它所有的記憶體分頁透過網路複製到目的地。這要花一段時間,而客體在過程中持續寫入。
  2. 追蹤被改髒的分頁:任何分頁在被複製之後又被客體寫過,就標記為「髒的」。現在只把那些髒分頁再複製一遍——這比整片記憶體小上許多。
  3. 重複,逐步收斂:每一輪只把上一輪以來改動過的部分再複製一次。因為一台忙碌的 VM 弄髒的分頁集合會愈縮愈小,每一趟搬的也愈來愈少,直到剩下的髒分頁集合小到不能再小。
  4. 短暫的停機與交棒:把 VM 暫停數毫秒,把最後那一小撮髒分頁連同 CPU 暫存器複製過去,然後在目的主機上讓 VM 繼續執行。這段暫停短到網路連線都能存活,使用者也鮮少察覺。

對它的極限要誠實以對。即時遷移不是什麼魔法瞬間移動——它仰賴兩台主機之間有一個快速的網路、仰賴有共享儲存好讓磁碟不必也跟著被複製,還仰賴那個髒分頁集合真的會縮小。一台瘋狂重寫自己整片記憶體、速度比網路能運走它還快的 VM,永遠收斂不了;這種情況下,超管理器最終就只能強制進入那段短暫的暫停,並接受一段稍微長一點的停機。而且這裡沒有任何東西能跨越 CPU 架構:你沒辦法把一台 x86 主機上的 VM 即時遷移到一台 Arm 主機,因為客體存下來的暫存器和指令,只有在它們原本所屬的那顆晶片上才講得通。

接下來的去向:一場更輕的幻覺

退一步,看看我們剛才為了騙過一個客體作業系統、讓它以為自己獨佔一整台電腦,蓋了多少機器。我們用陷阱和一層隱藏的特權層偽造了一顆 CPU,用第二層分頁表偽造了記憶體,又用模擬或 virtio 偽造了裝置。這一切之所以存在,是因為一台 VM 會在超管理器之上,執行一個完整、獨立的客體作業系統——它自己的核心、它自己的一切。那帶來了強隔離:客體核心被圍在它自己那台機器裡。但它很重。開一台 VM 意味著啟動一整套作業系統;同時跑十台,就是十個核心份量的記憶體。

這替本階接下來的內容架好了那個轉折。如果許多互相隔離的世界,能共用「一個」核心,而不必各自扛著自己的核心呢?那就是容器的主意,也是下一篇導覽的主題。一個容器不執行客體作業系統,也根本不需要一個在偽造硬體的超管理器。取而代之,主機核心會發給每個容器它自己一份私有的系統視角——它自己的行程編號、它自己的檔案系統掛載、它自己的網路——靠的是兩個 Linux 機制,叫做 namespaces(命名空間,決定一個容器看得到什麼)和 cgroups(控制群組,決定它能用多少),再疊在一個可堆疊的疊加檔案系統映像之上。結果遠比一台 VM 輕,啟動也只在一眨眼間。誠實的代價——本階最後一篇導覽會仔細掂量它——是隔離較弱:因為容器共用主機核心,那唯一一個共享核心裡的一個缺陷,就可能危及它們全部,而一台 VM 那道完整的客體核心,是一堵更厚的牆。