從 trap-and-emulate 到硬體支援虛擬化

我在大三下進入實驗室做專題並接觸了虛擬機,研究的過程中無時無刻都與虛擬機作伴,對虛擬機的了解日益增加,但仍然無法理解虛擬機運作的本質。直到這學期修了「虛擬機器」這門課,這才終於解開了我長期以來對虛擬機的不確定感。這篇文章中我將以 arm64 架構的單核 CPU 虛擬機作為範例,解釋作業系統如何模擬虛擬機。

QEMU/KVM

虛擬機在定義上就是一個模擬整台電腦運作過程的程式,以 Linux 上最常使用的 QEMU 為例,它和 VirtualBox 及 VMware 都是用來管理虛擬機的 VMM(Virtual Machine Monitor),它負責管理虛擬機的 vCPU(Virtual CPU)、記憶體與 I/O 裝置等資源,並執行虛擬機上的指令。

當一個虛擬機被啟動時,QEMU 會根據分配給虛擬機的 vCPU 數量產生執行緒,每一個 vCPU 都對應到一個執行緒。以本文使用的單核 vCPU 為例,QEMU 會呼叫 pthread_create 建立一個 vCPU 執行緒,當這個 vCPU 執行緒被 host 的排程器排程至一個實體 CPU 上執行時,就相當於這個虛擬機的 vCPU 正在執行。

我使用 QEMU 啟動一個 arm64 的單核 Linux 虛擬機並在其中執行 stress-ng,在 host 執行 htop 可以發現 QEMU 的其中一個執行緒佔用了 100% CPU 使用率,這個執行緒就是虛擬機的 vCPU:

htop 使用 htop 觀察 QEMU 的 vCPU 執行緒

當然,QEMU 的工作不只是執行虛擬機而已,它還有其他工作要完成,所以這個 QEMU 行程下有超過一個執行緒是正常的。

要留意的是,執行這個 vCPU 執行緒的硬體依然是一個「實體 CPU」,對於這個實體 CPU 而言,它所執行的指令與其他行程無異,vCPU 對它而言不過就是一連串指令而已。

虛擬機如何開機

QEMU 開始執行後,它會準備好所有虛擬機作業系統開機所需的所有資訊,並將指向此資訊的指標放在特定的暫存器中,在 arm64 上是 x0 暫存器。在實體機器上這些資訊會由 BIOS 或 UEFI 提供,而 QEMU 則會模仿這些韌體將資訊提供給虛擬機。

這過程中 QEMU 也會將虛擬機的作業系統核心放到特定記憶體位置上,如同 BIOS 或 Grub 做的事,接著發送 ioctl 系統呼叫給 KVM,請它將 PC 指向虛擬機核心的第一條指令並開始執行。

KVM 是一種在核心中管理虛擬機的 hypervisor,可以修改虛擬機的分頁表、PC 等暫存器;它是 Linux 上最常見的實作,但並非唯一。之所以一定要藉由 KVM 來修改這些暫存器,是因為在使用者空間的 QEMU 並沒有權限直接修改它們。整個過程其實和作業系統將程式載入記憶體後執行第一行指令並無二致:先把所需的資源放到特定的記憶體位置上,便可開始執行。

這整套架構一般稱為 QEMU/KVM,由 QEMU 向 KVM 發送系統呼叫來執行虛擬機。

我覺得很值得玩味的是:本質上執行虛擬機就是由實體 CPU 執行 host 上的 QEMU 執行緒,這點可以從上圖的 htop 看出來。但是當 PC 被指向虛擬機核心的第一行指令並執行的這一瞬間,彷彿 CPU 已不再執行 QEMU,而是執行一台完全獨立的系統了。

Trap-and-Emulate:執行於 EL0 的虛擬機

以上用行程來描述虛擬機的說法是最容易理解的,但這延伸出一個問題,虛擬機的作業系統核心是執行在使用者空間,那麼虛擬機能夠執行核心空間的指令嗎?

為了理解這件事,我們得先理解 arm64 的權限等級。在 arm64 系統上,使用者空間的權限等級為 EL0,作業系統核心則執行在 EL1,數字愈高權限愈大。

先前提到的虛擬機只執行在 EL0,包括虛擬機核心也是執行在 EL0,那麼要如何讓核心執行 EL1 的特權指令,例如修改 EL0 的分頁表呢?在此狀況中,若虛擬機核心執行 EL1 指令便會產生例外(exception)並 trap 至 EL1,類似一般程式越界存取記憶體而產生例外也會 trap 至 EL1 作業系統核心。接著 CPU 根據硬體邏輯執行 EL1 的例外處理函式,這個函式的位置被放在 EL1 的暫存器 VBAR_EL1 (Vector Base Address Register)中,所以 CPU 可以直接將 PC 指向該例外處理函式並開始執行。例外處理函式中會檢查 ESR_EL1 (Exception Syndrome Register)暫存器來得知發生了什麼事,發現是虛擬機執行了只能在 EL1 權限等級中執行的指令,於是執行在 EL1 的 VMM 會幫虛擬機模擬執行完這條指令,再返回到 EL0 的虛擬機。

以上的做法稱為 trap-and-emulate,虛擬機執行了 EL1 指令就會 trap 到 EL1 中的 VMM,然後 VMM 再 emulate 該指令。但是這種做法有其限制,最明顯的是效能問題,想像虛擬機中的一個系統呼叫過程中會執行多個 EL1 指令,而虛擬機中每個 EL1 指令都會 trap 至 VMM,帶來嚴重的效能損失;另一個問題是在某些指令集中,同一個指令可同時在 EL0 與 EL1 中執行但效果卻不同,此種指令稱為 critical instructions,虛擬機執行這種指令後既不會 trap 至 VMM,也沒辦法達到在 EL1 執行的效果,因此這些指令集無法藉由 trap-and-emulate 模擬。

硬體支援虛擬化

為了解決以上提及的兩個 trap-and-emulate 的問題,現代指令集推出了虛擬化硬體支援以更有效率地執行虛擬機,如 Intel VT-x、AMD-V 與 Arm Virtualization Extensions。

作業系統在 EL1 透過上下文切換管理 EL0 的「行程」,採用相同的概念,Arm VE 引進 EL2 權限等級以透過上下文切換管理 EL1 的「系統」。因為在 EL2 中上下文切換的單位從「行程」變成了「系統」,所以虛擬機就能夠有自己的整套 EL0 與 EL1 環境,完全足以執行一個作業系統。

值得注意的是,這也正好解決了前面 trap-and-emulate 的兩個問題:虛擬機核心如今直接執行在真正的 EL1 上,因此那些 EL1 指令可以原生執行而不必每次都 trap 至 hypervisor,效能問題迎刃而解;而 critical instructions 也能在 EL1 中得到正確的執行效果,不再有語意不符的困擾。

如同 EL1 執行作業系統核心,那麼有了 EL2 之後,EL2 要執行什麼程式呢?EL2 的任務大致上就只是切換 EL1 暫存器以在不同系統間切換而已。KVM 的做法是在 EL2 執行一個 lowvisor 用來接受 EL1 的請求並切換至虛擬機,而原本在 EL1 的 KVM 則稱為 highvisor,兩者合稱 KVM。當 highvisor 想要執行虛擬機時,highvisor 會發送「hypercall」給 lowvisor,而因為 lowvisor 也是 KVM 的一部分,自然了解這些 hypercall 所代表的請求,便會執行上下文切換並開始執行虛擬機。

el2 Christoffer Dall and Jason Nieh, KVM/ARM: the design and implementation of the linux ARM hypervisor

從上下文切換看虛擬機

我一直覺得上下文切換這個機制一直是達成分時多工的絕佳發明,作業系統可以讓行程在毫無知覺的情況下切換至其他行程並執行。被暫停的行程時間暫停,切換回來後又能繼續執行,彷彿什麼事都沒發生過。

有了 EL2 之後,這件事情對虛擬機而言也是相同的,KVM lowvisor 透過上下文切換讓虛擬機可以和 host 分時共享硬體資源,而虛擬機完全不知情,彷彿自己獨佔一台實體機器,如此優雅。