實戰篇:在QEMU中編寫和調試VHost/Virtio驅動

2025.05.10
在雲端運算環境中,當多個虛擬機器同時進行大規模資料傳輸時,這種通訊瓶頸就會變得特別明顯,嚴重影響了雲端服務的效能和使用者體驗。同樣,在大數據分析場景下,虛擬機器需要頻繁地讀寫儲存設備,如果虛擬設備通訊效率低下,就會導致資料分析的速度大幅下降,無法滿足即時性的要求。

那麼,有沒有一種方法可以打破這種通訊困境,讓虛擬設備之間的通訊更有效率、更流暢呢?答案就是 vhost/virtio 技術。它就像是虛擬世界中的通訊加速器,為解決虛擬設備通訊難題帶來了新的曙光。接下來,就讓我們深入了解 vhost/virtio 技術的奧秘。


一、手寫 Vhost/Virtio
1.1 前期準備:搭建 “舞台”
在開始這場奇妙的手寫 vhost/virtio 之旅前,我們首先需要搭建一個合適的開發環境,就如同搭建一個穩固的舞台,為後續的精彩表演做好充分準備。
我們要安裝 Qemu,它是虛擬化的基石。安裝 Qemu 的方式有多種,對於追求便利的開發者來說,可以使用系統自帶的套件管理器,例如在 Ubuntu 系統中,只需在終端機輸入 “sudo apt - get install qemu - system - x86” 即可輕鬆完成安裝。但如果你想要更前沿的功能和效能最佳化,從原始碼編譯安裝則是個不錯的選擇。你可以從 Qemu 的官方網站下載最新的源代碼,然後按照官方文檔的指引進行編譯和安裝 ,雖然這個過程可能稍微複雜一些,但能讓你獲得最適合自己需求的 Qemu 版本。
除了 Qemu,我們還需要一系列相關的開發工具和函式庫檔案。例如,GCC(GNU Compiler Collection)是必不可少的,它能將我們編寫的 C 程式碼編譯成可執行的程式。安裝 GCC 也很簡單,在大多數 Linux 系統中,透過套件管理器就能快速完成安裝。另外,還需要安裝一些開發庫,如 libvirt 開發庫,它提供了與虛擬化管理相關的接口,方便我們在代碼中對虛擬機進行各種操作;以及 libpciaccess 庫,它有助於我們訪問 PCI 設備,在處理虛擬設備相關的功能時發揮著重要作用。在安裝這些庫檔案時,請務必注意它們的版本相容性,不同版本之間可能存在介面差異,
不相容的版本可能會導致後續開發過程中出現各種難以偵錯的問題。

1.2 初窺門徑:理解關鍵資料結構

當我們搭建好開發環境後,就如同踏入了一座神秘的城堡,首先要熟悉城堡中的各種機關和暗道,也就是 vhost/virtio 中的關鍵資料結構。
在 vhost/virtio 的世界裡,virtio_ring 資料佇列是最核心的資料結構之一,它就像是一座橋樑,連接虛擬機器(Guest)和宿主機器(Host)之間的資料傳輸通道。 virtio_ring 主要由描述符表(descriptor table)、可用環表(available ring)和已使用環表(used ring)三部分組成。描述符表就像是一個貨物清單,裡面存放著真正的數據報文信息,每個描述符都記錄了數據的起始地址、長度以及一些標誌位等關鍵信息 ,這些信息就像是貨物的標籤,告訴接收方如何正確地處理這些數據。
可用環表則是 Guest 用來告知 Host 有哪些數據是可供處理的,它就像是一個待處理任務列表,Guest 將數據描述符的索引放入可用環表中,Host 從這裡獲取任務並進行處理。已用環表則是 Host 用來通知 Guest 哪些資料已經處理完成,Guest 可以回收對應的資源,就像是完成任務後的回饋清單。
在網路通訊場景中,當 Guest 要傳送網路封包時,它會先將封包的相關資訊填入描述符表中,然後將描述符的索引新增至可用環表中,Host 偵測到可用環表有新的任務後,就會從描述子表中取得封包並進行傳送處理,處理完成後,將描述子的索引已用環表中,Guest看到已使用環表的回饋後,就知道哪些資料包已經成功發送,可以進行後續的操作了。理解這些資料結構的工作原理和相互之間的關係,是我們手寫 vhost/virtio 的關鍵,只有掌握了它們,我們才能在後續的程式碼實作
1.3 核心程式碼實作:建構 “通訊橋樑”
①建立共享記憶體
共享記憶體是 vhost/virtio 實現高效通訊的基礎,它就像是一個公共的倉庫,Guest 和 Host 都可以直接訪問,從而避免了資料的多次拷貝,大大提高了通訊效率。

在建立共享記憶體時,我們可以使用作業系統提供的相關函數,例如在 Linux 系統中,可以使用 shmget 函數來建立共享記憶體段。首先,我們需要定義共享記憶體的大小和一些權限標誌 ,然後呼叫 shmget 函數,它會傳回一個共享記憶體標識符,這個標識符就像是倉庫的鑰匙,後續我們對共享記憶體的操作都需要使用這個標識符。例如:
創建好共享記憶體後,還需要將其映射到進程的位址空間中,這樣我們的程式碼才能直接存取共享記憶體中的資料。在 Linux 中,可以使用 shmat 函數來完成映射操作。映射完成後,就可以像存取普通記憶體一樣對共享記憶體進行讀寫操作了。同時,我們也需要注意在適當的時候通知核心或其他行程共享記憶體的相關訊息,例如透過信號量或其他同步機制,確保各個行程對共享記憶體的存取是安全且有序的。
②初始化 Virtio Ring
Virtio Ring 的初始化是確保資料能夠正確傳輸的關鍵步驟,它就像是為橋樑設定正確的交通規則,讓資料在 Guest 和 Host 之間順暢地流動。

初始化 Virtio Ring 時,我們需要設定描述符、可用和已使用索引等關鍵參數。首先,我們要為描述符表分配記憶體空間,並初始化每個描述符的內容,包括資料的起始位址、長度和標誌位元等。例如:
在上述程式碼中,我們首先定義了 Virtio Ring 相關的結構,然後實作了一個初始化函數 init_virtio_ring。在函數中,我們為描述符表、可用環和已用環分配內存,並對它們進行初始化。描述符的 next 欄位形成了一個環形鍊錶,方便資料的管理和存取。可用環和已用環的 idx 欄位初始化為 0,表示目前沒有資料待處理或已處理。透過這樣的初始化操作,Virtio Ring 就可以準備好進行資料的收發工作了。
③資料收發處理
資料的傳送和接收是 vhost/virtio的核心功能,它就像是橋樑上車輛的行駛,實現了Guest 和Host之間的資訊互動。

當 Guest 要傳送資料時,它會先填入資料到共享記憶體中,並將資料的相關資訊(如資料長度、記憶體位址等)填入 Virtio Ring 的描述符中。然後,Guest 將描述符的索引新增至可用環表中,並更新可用環表的 idx 索引,通知 Host 有新的資料需要處理。例如:
在傳送資料的過程中,我們需要注意可用環表的索引管理,確保不會發生溢位。同時,要及時通知 Host 有新的資料到來,以便 Host 能夠及時處理。

當 Host 接收到 Guest 的通知後,它會從可用環表中獲取描述符的索引,然後根據索引從描述符表中獲取數據的相關信息,並從共享內存中讀取數據進行處理。處理完成後,Host 將描述符的索引新增至已使用環表中,並更新已使用環表的 idx 索引,通知 Guest 資料已處理完成。例如:
在接收資料的過程中,Host 需要不斷檢查可用環表和已使用環表的索引,確保能夠及時處理新的數據,並將處理結果回饋給 Guest。同時,也要注意已使用環表的索引管理,避免出現錯誤。

④中斷處理機制
中斷處理在 vhost/virtio 中起著至關重要的作用,它就像是橋樑上的交通號誌,能夠及時通知對方有重要事件發生,從而實現高效的通訊。
在 vhost/virtio 中,中斷主要用於 Guest 通知 Host 有資料待處理,或 Host 通知 Guest 資料已經處理完成。當 Guest 填充資料到共享記憶體並更新 Virtio Ring 後,它可以透過觸發中斷來通知 Host。在 Linux 系統中,可以使用 eventfd 來實作中斷通知機制。

首先,Guest 建立一個 eventfd 對象,並將其與 Virtio Ring 的中斷關聯起來。當 Guest 需要通知 Host 時,它會向 eventfd 物件寫入一個值,這個值會觸發 Host 的中斷處理程序。例如:
Host 在啟動時,會監聽這個 eventfd 對象,當接收到中斷訊號時,它會呼叫對應的中斷處理函數來處理資料。例如:
在上述程式碼中,Guest 透過 write 函數向 eventfd 物件寫入值來觸發中斷,Host 使用 poll 函數監聽 eventfd 對象,當接收到 POLLIN 事件時,說明有中斷發生,然後呼叫 host_receive_data 函式處理資料。透過這樣的中斷處理機制,Guest 和 Host 可以實現高效的資料交互,避免了不必要的輪詢操作,提高了系統的效能和回應速度。

二、QEMU後端驅動
VIRTIO設備的前端是GUEST的核心驅動,後端由QEMU或DPU實現。不論是原來的QEMU-VIRTIO框架還是現在的DPU,VIRTIO的控制面與資料面都是相對獨立的設計。本文主要針對QEMU的VIRTIO後端進行分析。

控制面負責GUEST前端和VIRTIO設備的協商流程,主要用於前後端的feature協商匹配、向GUEST提供後端設備資訊、建立前後端之間的資料通道。等控制面協商完成後,資料面啟動前後端的資料互動流程;後面的流程中控制面負責一些設定資訊的下發與通知,例如block設備capacity設定、net設備mac位址動態修改等。
QEMU負責設備控制面的實現,而資料面則由VHOST框架接手。 VHOST又分為用戶態的vhost-user和內核態的vhost-kernel路徑,前者是由用戶態的dpdk接管資料路徑,將資料從用戶態OVS協定棧轉發,後者是由內核態的vhost驅動接管資料路徑,將資料從核心協定棧發送出去。本文主要針對vhost-user路徑,以net設備為例進行描述。

如果要順利的看懂QEMU後端VIRTIO驅動框架,需要具備QEMU的QOM基礎知識,在這個基礎上將資料結構、初始化流程理清楚,就可以更快的上手。如果只是對VIRTIO相關的設計有興趣,可直接看下一章原理性的內容。
QEMU設備管理是非常重要的部分,後來引進了專門的設備樹管理機制。而其參考了C++的類別、繼承的一些概念,但又不是完全一致,對於非科班出身的作者閱讀起來有些吃力。因為框架相關的程式碼中時常使用內部資料指標cast​​的一些巨集定義,非常影響可讀性。

2.1 VIRTIO設備建立流程
從實際的命令列範例入手,查看設備是如何建立的。

(1)virtio-net-pci設備命令列
首先從QEMU的命令列入手,建立一個使用virtio設備的虛擬機,可使用以下命令列:
其中,創建一個虛擬硬體設備,都是透過-device來實現的,上面的命令列中創建了一個virtio-net-pci設備

這個硬體設備的建構依賴qemu框架裡的netdev設備(並不會獨立的對guest呈現)

上面的netdev裝置又依賴qemu框架裡的字元裝置(同樣不會獨立的對guest呈現)
(2)命令列解析處理
QEMU的命令列解析在main函數進行,解析後依照qemu標準格式儲存到本機。然後透過qemu_find_opts(“”)介面可以取得本機結構體中具有對應關鍵字的所有指令列表,對解析後的指令列表使用qemu_opts_foreach依序執行處理函數。

常用的用法,例如netdev的處理,qemu_find_opts找到所有的netdev的命令列表,qemu_opts_foreach則對列表裡的所有元素依次執行net_init_netdev,初始化對應的netdev結構。
net_init_netdev初始化函數中,根據type=vhost-user,執行對應的net_init_vhost_user函數進行初始化,並為每個佇列建立一個NetClientState結構,用於後續socket通訊。對於"-device"參數的處理也是採用相同的方式,依序執行device_init_func,初始化對應的DeviceState結構。
device後面接著的第一個參數qemu稱為driver,其實就是根據不同的裝置類型(我們的場景為「virtio-net-pci")來匹配不同的處理。而device採用的是通用的設備類,根據驅動的名字在device_init_func函數裡調用qdev_device_add()接口,然後匹配到對應的DeviceClass(就是virtio-net-pci對應的DeviceClass)。

匹配到DeviceClass後,呼叫class裡的instance_init接口,建立對應的實例,即DeviceState。

備註:看到了DeviceClass和DeviceState,這個是QEMU裝置管理框架裡的重要元素。
1)Class後綴表示一類方法實現,是相應設備類型的一類實現,對於同一設備類型的多個設備是通用的,不管創建幾個virtio-pci-net設備,只需要一份VirtioPciClass。

2)State後綴表示具體的instance實體,每建立一個裝置都要實例化一個instance結構。建立和初始化這個結構是由object_new()介面完成的,初始化還會呼叫對應的類別定義的instance_init()介面。
(3)設備實例初始化
在qdev_device_add函數中,首先會呼叫object_new,建立object(object是所有instance實例的根結構),最終是透過呼叫每個virtio-pci-net對應DeviceClass裡的instance_init來建立實例。
VirtioNetPci結構體中包含其父類別的實例VirtIOPCIProxy,其擁有的設備框架自訂的結構是VirtIONet的實例。對於netdev來說,它也利用了qemu的class和device框架,但netdev不像-device一樣透過框架的qdev_device_add介面呼叫object_new完成。他的資料空間跟隨在virtio_net_pci的自訂結構裡,然後透過virtio_instance_init_com介面明確的呼叫object_initialize()函數實作「virtio-net-device」的instance初始化。
(4)virtio-net-pci設備realize流程
qdev_device_add介面中,也會呼叫realize接口,前面的instance_init只是實例的簡單初始化,真實的裝置相關的具體初始化動作都是從裝置realize之後進行的。也就是對應class的realize介面。

首先在qdev_device_add()介面中,置位裝置的realized屬性,進而呼叫每一層class的realize函數。大家想一下,類似核心驅動,設備肯定按照協定的分層從下向上辨識的,先辨識pci設備,然後是virtio,進而辨識到virtio-net設備。所以qemu的辨識過程也是這樣,從最底層的realize層層呼叫到上層的realize介面。

參考VirtIO的Class結構,整個realize的流程整理如下:
在初始化的過程中,對資料結構進行一一初始化。在pci設備的realize之前插入virtio_pci_dc_realize函數的原因是,如果是modern模式的pci設備必須是pci-express協議,所以需要置位PCIDevice裡的pcie-capability標識,強行令pci設備為pcie類型。然後再進行pci層的設備初始化,初始化一個pcie設備。

virtio_pci_realize介面對VirtioPCIProxy資料結構進行了初始化,是virtio+pci所需的初始化。所以初始化了virtio設備的bar空間以及所需的pcie-capability。

virtio_net_pci_realize介面主要是觸發VirtIONet裡的VirtIODevice的realize流程,這就是virtio裝置的realize流程(virtio_device_realize介面)。
virtio_device_realize介面實作呼叫了virtio_net_device_realize,而對於特定virtio裝置(net類型)的初始化都是在這裡進行的。所以這部分是對VirtIONet及其包裹的VirtIODevice資料結構進行初始化,包括VirtIODevice結構裡的vq指標就是在這裡根據佇列個數動態申請空間的。

virtio_device_realize接口還執行了virtio_bus_device_plugged接口,這是virtio總線上的virtio設備的plugged接口,這部分內容脫離了virtio-pci框架,進入到更上層的virtio框架。但virtio_bus派生了virtio_pci_bus,virtio_pci_bus將繼承的virtio_bus的介面都設定成了自己的介面。所以最後還是呼叫了virtio-pci下的virtio_pci_device_plugged函式。
virtio_pci_device_plugged接口是最核心的初始化接口,modern模式初始化pci設備的bar空間讀寫操作接口,因為分多塊讀寫,所以還引入了memory_region,然後添加相應的capability;legacy模式初始化pci的bar空間讀寫操作接口,至此virtio設備的初始化相應的capability;legacy模式初始化pci的bar空間讀寫操作接口,至此程序的初始化程序完成,等待與hostacy模式初始化pci的bar空間讀寫操作接口,至此