當你在虛擬機中流暢傳輸文件時,是否想過背后是誰在高效調度 IO 資源?當云計算平臺承載千萬級并發請求時,又是誰在底層保障數據通路的穩定?答案藏在一個低調卻關鍵的技術里 ——virtio。作為 Linux IO 虛擬化的 “隱形引擎”,virtio 用獨特的半虛擬化設計,架起了虛擬機與物理設備間的高效橋梁。它跳過傳統虛擬化的性能損耗陷阱,用極簡接口實現接近原生的 IO 速度,如今已成為 KVM、QEMU 等主流虛擬化方案的 “標配心臟”。
但 virtio 的魔力遠不止于此:環形緩沖區如何實現零拷貝傳輸?前端驅動與后端設備如何默契配合?中斷機制又藏著怎樣的優化智慧?今天,我們就撕開技術面紗,從架構邏輯到運行細節,解密 virtio 成為 Linux IO 虛擬化核心的真正密碼。
一、Linux IO 虛擬化概述
簡單來說,虛擬化是通過 “軟件定義” 將物理硬件抽象邏輯化,實現邏輯資源與底層硬件的隔離,以達到物理硬件資源利用的最大化。其中,虛擬機技術便是虛擬化技術的典型代表,它可以在一臺物理主機上同時運行多個相互隔離的虛擬機,每個虛擬機仿佛都擁有獨立的硬件資源,能夠運行不同的操作系統和應用程序。
在虛擬化的龐大體系中,Linux IO 虛擬化占據著舉足輕重的地位,主要負責處理虛擬機與物理硬件之間的輸入 / 輸出(I/O)通信,致力于突破 I/O 性能瓶頸。打個比方,若將虛擬機看作是一個個忙碌的工廠,不斷需要原材料(輸入數據)并輸出產品(輸出數據),那么 Linux IO 虛擬化就是優化工廠運輸線路和裝卸流程的關鍵技術,確保原材料和產品能夠快速、高效地進出工廠,保障虛擬機在數據傳輸方面的順暢。
傳統的 Linux IO 虛擬化通常采用 Qemu 模擬的方式。當客戶機中的設備驅動程序發起 I/O 操作請求時,KVM 模塊中的 I/O 操作捕獲代碼會首先攔截該請求,隨后將 I/O 請求信息存放到 I/O 共享頁,并通知用戶空間的 Qemu 程序。Qemu 模擬程序獲取 I/O 操作的具體信息后,交由硬件模擬代碼模擬此次 I/O 操作,完成后將結果放回 I/O 共享頁,再通知 KVM 模塊中的 I/O 操作捕獲代碼,最后由捕獲代碼讀取操作結果并返回給客戶機。
這種模擬方式雖然靈活性高,能夠通過軟件模擬出各種硬件設備,且無需修改客戶機操作系統就能使模擬設備正常工作,為軟件開發及調試提供了便利,但是缺點也很明顯。每次 I/O 操作路徑長,會頻繁發生 VMEntry、VMExit ,需要多次上下文切換,就像接力賽中選手不斷交接接力棒,耗時費力。同時,多次的數據復制操作進一步降低了效率,導致整體性能不佳。在一些對 I/O 性能要求嚴苛的場景,如大規模數據處理、實時通信等,傳統的 Qemu 模擬 I/O 設備的方式往往難以滿足需求。
二、Linux IO 虛擬化中virtio
2.1 virtio 是什么
virtio 是一種用于虛擬化平臺的 I/O 虛擬化標準,由澳大利亞天才級程序員 Rusty Russell 開發 ,最初是為了支持他自己的虛擬化解決方案 lguest。在半虛擬化架構中,它就像是一座連接來賓操作系統(運行在虛擬機中的操作系統)和 Hypervisor(虛擬機監視器)的橋梁,起著至關重要的作用。
從本質上來說,virtio 是對半虛擬化 hypervisor 中的一組通用模擬設備的抽象。它定義了一套通用的設備模型和接口,將各種物理設備的功能抽象出來,無論是網絡適配器、磁盤驅動器還是其他設備,virtio 都為它們提供了統一的抽象表示,就像一個萬能的模具,可以根據不同的需求塑造出各種虛擬設備,使得不同的虛擬化平臺可以基于它實現統一的 I/O 虛擬化。例如,在 KVM 虛擬化環境中,通過 virtio 可以高效地實現虛擬機的網絡和磁盤 I/O 虛擬化。
在完全虛擬化的解決方案中,guest VM 要使用底層 host 資源,需要 Hypervisor 來截獲所有的請求指令,然后模擬出這些指令的行為,這樣勢必會帶來很多性能上的開銷。半虛擬化通過底層硬件輔助的方式,將部分沒必要虛擬化的指令通過硬件來完成,Hypervisor 只負責完成部分指令的虛擬化,要做到這點,需要 guest 來配合,guest 完成不同設備的前端驅動程序,Hypervisor 配合 guest 完成相應的后端驅動程序,這樣兩者之間通過某種交互機制就可以實現高效的虛擬化過程。
由于不同 guest 前端設備其工作邏輯大同小異(如塊設備、網絡設備、PCI設備、balloon驅動等),單獨為每個設備定義一套接口實屬沒有必要,而且還要考慮擴平臺的兼容性問題,另外,不同后端 Hypervisor 的實現方式也大同小異(如KVM、Xen等),這個時候,就需要一套通用框架和標準接口(協議)來完成兩者之間的交互過程,virtio 就是這樣一套標準,它極大地解決了這些不通用的問題。
與傳統的 Linux IO 虛擬化實現方式相比,virtio 具有多方面的顯著優勢。首先,它提供了通用接口,大大提高了代碼的可重用性和跨平臺性。以往針對不同的虛擬化平臺和設備,需要開發不同的驅動程序,而有了 virtio,基于其通用接口,開發者可以更輕松地編寫適用于多種虛擬化環境的驅動,減少了開發成本和工作量。
其次,在性能提升方面,virtio 表現出色。傳統方式中頻繁的 VMEntry、VMExit 以及多次上下文切換和數據復制導致性能低下,而 virtio 采用半虛擬化技術,通過底層硬件輔助,將部分沒必要虛擬化的指令通過硬件完成,Hypervisor 只負責完成部分指令的虛擬化。同時,它通過虛擬隊列(virtqueue)和環形緩沖區(virtio-ring)來實現前端驅動和后端處理程序之間高效的數據傳輸,減少了 VMEXIT 次數,使得數據傳輸更加高效,極大地提升了 I/O 性能,其性能幾乎可以達到和非虛擬化環境中的原生系統差不多的 I/O 性能。
2.2?virtio數據流交互機制
vring 主要通過兩個環形緩沖區來完成數據流的轉發,如下圖所示:
vring 包含三個部分,描述符數組 desc,可用的 available ring 和使用過的 used ring。
desc 用于存儲一些關聯的描述符,每個描述符記錄一個對 buffer 的描述,available ring 則用于 guest 端表示當前有哪些描述符是可用的,而 used ring 則表示 host 端哪些描述符已經被使用。
Virtio 使用 virtqueue來實現 I/O 機制,每個 virtqueue 就是一個承載大量數據的隊列,具體使用多少個隊列取決于需求,例如,virtio 網絡驅動程序(virtio-net)使用兩個隊列(一個用于接受,另一個用于發送),而 virtio 塊驅動程序(virtio-blk)僅使用一個隊列。
具體的,假設 guest 要向 host 發送數據,首先,guest 通過函數 virtqueue_add_buf 將存有數據的 buffer 添加到 virtqueue 中,然后調用 virtqueue_kick 函數,virtqueue_kick 調用 virtqueue_notify 函數,通過寫入寄存器的方式來通知到 host。host 調用 virtqueue_get_buf 來獲取 virtqueue 中收到的數據。
存放數據的 buffer 是一種分散-聚集的數組,由 desc 結構來承載,如下是一種常用的 desc 的結構:
當 guest 向 virtqueue 中寫數據時,實際上是向 desc 結構指向的 buffer 中填充數據,完了會更新 available ring,然后再通知 host。
當 host 收到接收數據的通知時,首先從 desc 指向的 buffer 中找到 available ring 中添加的 buffer,映射內存,同時更新 used ring,并通知 guest 接收數據完畢。
2.2 Virtio緩沖池
來賓操作系統(前端)驅動程序通過緩沖池與 hypervisor 交互。對于 I/O,來賓操作系統提供一個或多個表示請求的緩沖池。例如,您可以提供 3 個緩沖池,第一個表示 Read 請求,后面兩個表示響應數據。該配置在內部被表示為一個散集列表(scatter-gather),列表中的每個條目表示一個地址和一個長度。
2.4核心API
通過 virtio_device 和 virtqueue(更常見)將來賓操作系統驅動程序與 hypervisor 的驅動程序鏈接起來。virtqueue 支持它自己的由 5 個函數組成的 API。您可以使用第一個函數 add_buf 來向 hypervisor 提供請求。如前面所述,該請求以散集列表的形式存在。對于 add_buf,來賓操作系統提供用于將請求添加到隊列的 virtqueue、散集列表(地址和長度數組)、用作輸出條目(目標是底層 hypervisor)的緩沖池數量,以及用作輸入條目(hypervisor 將為它們儲存數據并返回到來賓操作系統)的緩沖池數量。當通過 add_buf 向 hypervisor 發出請求時,來賓操作系統能夠通過 kick 函數通知 hypervisor 新的請求。為了獲得最佳的性能,來賓操作系統應該在通過 kick 發出通知之前將盡可能多的緩沖池裝載到 virtqueue。
通過 get_buf 函數觸發來自 hypervisor 的響應。來賓操作系統僅需調用該函數或通過提供的 virtqueue callback 函數等待通知就可以實現輪詢。當來賓操作系統知道緩沖區可用時,調用 get_buf 返回完成的緩沖區。
virtqueue API 的最后兩個函數是 enable_cb 和 disable_cb。您可以使用這兩個函數來啟用或禁用回調進程(通過在 virtqueue 中由 virtqueue 初始化的 callback 函數)。注意,該回調函數和 hypervisor 位于獨立的地址空間中,因此調用通過一個間接的 hypervisor 來觸發(比如 kvm_hypercall)。
緩沖區的格式、順序和內容僅對前端和后端驅動程序有意義。內部傳輸(當前實現中的連接點)僅移動緩沖區,并且不知道它們的內部表示。
三、virtio架構剖析
3.1整體架構概覽
virtio 的架構精妙而復雜,猶如一座精心設計的大廈,主要由四層構成,每一層都肩負著獨特而重要的使命,它們相互協作,共同構建起高效的 I/O 虛擬化橋梁。
最上層是前端驅動,它就像是虛擬機內部的 “大管家”,運行在虛擬機之中,針對不同類型的設備,如塊設備(如磁盤)、網絡設備、PCI 模擬設備、balloon 驅動(用于動態管理客戶機內存使用)和控制臺驅動等,有著不同的驅動程序,但與后端驅動交互的接口卻是統一的。這些前端驅動主要負責接收用戶態的請求,就像管家接收家中成員的各種需求,然后按照傳輸協議將這些請求進行封裝,使其能夠在虛擬化環境中順利傳輸,最后寫 I/O 端口,發送一個通知到 Qemu 的后端設備,告知后端有任務需要處理。
最下層是后端處理程序,它位于宿主機的 Qemu 中,是操作硬件設備的 “執行者”。當它接收到前端驅動發過來的 I/O 請求后,會從接收的數據中按照傳輸協議的格式進行解析,理解請求的具體內容。對于網卡等需要與實際物理設備交互的請求,后端驅動會對物理設備進行操作,比如向內核協議棧發送一個網絡包完成虛擬機對于網絡的操作,從而完成請求,并且會通過中斷機制通知前端驅動,告知前端任務已完成。
中間兩層是 virtio 層和 virtio-ring 層,它們是前后端通信的關鍵紐帶。virtio 層實現的是虛擬隊列接口,是前后端通信的 “橋梁設計師”,它在概念上將前端驅動程序附加到后端驅動,不同類型的設備使用的虛擬隊列數量不同,例如,virtio 網絡驅動使用兩個虛擬隊列,一個用于接收,一個用于發送;而 virtio 塊驅動僅使用一個隊列 。虛擬隊列實際上被實現為跨越客戶機操作系統和 hypervisor 的銜接點,只要客戶機操作系統和 virtio 后端程序都遵循一定的標準,以相互匹配的方式實現它,就可以實現高效通信。
virtio-ring 層則是這座橋梁的 “建筑工人”,它實現了環形緩沖區(ring buffer),用于保存前端驅動和后端處理程序執行的信息。它可以一次性保存前端驅動的多次 I/O 請求,并且交由后端去批量處理,最后實際調用宿主機中設備驅動實現物理上的 I/O 操作,這樣就可以根據約定實現批量處理,而不是客戶機中每次 I/O 請求都需要處理一次,從而大大提高了客戶機與 hypervisor 信息交換的效率。
3.2關鍵組件解析
在 virtio 的架構中,虛擬隊列接口和環形緩沖區是至關重要的組件,它們就像是人體的神經系統和血液循環系統,確保了數據的高效傳輸和系統的正常運行。
虛擬隊列接口是 virtio 實現前后端通信的核心機制之一,它定義了一組標準的接口,使得前端驅動和后端處理程序能夠進行有效的交互。每個前端驅動可以根據需求使用零個或多個虛擬隊列,這些隊列就像是一條條數據傳輸的 “高速公路”,不同類型的設備根據自身的特點選擇合適數量的隊列。virtio 網絡驅動需要同時處理數據的接收和發送,因此使用兩個虛擬隊列,一個專門用于接收數據,另一個用于發送數據,這樣可以提高數據處理的效率,避免接收和發送數據時的沖突。
而環形緩沖區則是虛擬隊列的具體實現方式,它是一段共享內存,被劃分為三個主要部分:描述符表(Descriptor Table)、可用描述符表(Available Ring)和已用描述符表(Used Ring) 。描述符表用于存儲一些關聯的描述符,每個描述符記錄一個對 buffer 的描述,就像一個個貨物清單,詳細記錄了數據的位置、大小等信息;可用描述符表用于保存前端驅動提供給后端設備且后端設備可以使用的描述符,它就像是一個 “待處理任務清單”,后端設備可以從中獲取需要處理的數據;已用描述符表用于保存后端處理程序已經處理過并且尚未反饋給前端驅動的描述,它就像是一個 “已完成任務清單”,前端驅動可以從中了解哪些數據已經被處理完畢。
當虛擬機需要發送請求到后端設備時,前端驅動會將存有數據的 buffer 添加到 virtqueue 中,然后更新可用描述符表,將對應的描述符標記為可用,并通過寫入寄存器的方式通知后端設備,就像在 “待處理任務清單” 上添加了一項任務,并通知后端工作人員。后端設備接收到通知后,從可用描述符表中讀取請求信息,根據描述符表中的信息從共享內存中讀出數據進行處理。
處理完成后,后端設備將響應狀態存放在已用描述符表中,并通知前端驅動,就像在 “已完成任務清單” 上記錄下完成的任務,并通知前端工作人員。前端驅動從已用描述符表中得到請求完成信息,并獲取請求的數據,完成一次數據傳輸的過程。
3.3 Virtio初始化
⑴前端初始化
Virtio設備遵循linux內核通用的設備模型,bus類型為virtio_bus,對它的理解可以類似PCI設備。設備模型的實現主要在driver/virtio/virtio.c文件中。
設備注冊
int register_virtio_device(struct virtio_device *dev)
-> dev->dev.bus = &virtio_bus; //填寫bus類型
-> err = ida_simple_get(&virtio_index_ida, 0, 0, GFP_KERNEL);//分配一個唯一的設備index標示
-> dev->config->reset(dev); //重置config
-> err = device_register(&dev->dev); //在系統中注冊設備
驅動注冊
int register_virtio_driver(struct virtio_driver *driver)
-> driver->driver.bus = &virtio_bus; //填寫bus類型
->driver_register(&driver->driver); //向系統中注冊driver
設備匹配
virtio_bus. match = virtio_dev_match
//用于甄別總線上設備是否與virtio對應的設備匹配,
//方法是查看設備id是否與driver中保存的id_table中的某個id匹配。
設備發現
virtio_bus. probe = virtio_dev_probe
// virtio_dev_probe函數首先是
-> device_features = dev->config->get_features(dev); //獲得設備的配置信息
-> // 查找device和driver共同支持的feature,設置dev->features
-> dev->config->finalize_features(dev); //確認需要使用的features
-> drv->probe(dev); //調用driver的probe函數,通常這個函數進行具體設備的初始化,
例如virtio_blk驅動中用于初始化queue,創建磁盤設備并初始化一些必要的數據結構
當virtio后端模擬出virtio_blk設備后,guest os掃描到此virtio設備,然后調用virtio_pci_driver中virtio_pci_probe函數完成pci設備的啟動。
注冊一條virtio_bus,同時在virtio總線進行注冊設備。當virtio總線進行注冊設備register_virtio_device,將調用virtio總線的probe函數:virtio_dev_probe()。該函數遍歷驅動,找到支持驅動關聯到該設備并且調用virtio_driver probe。
virtblk_probe函數調用流程如下:
virtio_config_val:得到硬件上支持多少個segments(因為都是聚散IO,segment應該是指聚散列表的最大項數),這里需要注意的是頭部和尾部各需要一個額外的segment
init_vq:調用init_vq函數進行virtqueue、vring等相關的初始化設置工作。
alloc_disk:調用alloc_disk為此虛擬磁盤分配一個gendisk類型的對象
blk_init_queue:注冊queue的處理函數為do_virtblk_request
static int __devinit virtblk_probe(struct virtio_device *vdev)
{.../* 得到硬件上支持多少個segments(因為都是聚散IO,這個segment應該是指聚散列表的最大項數), 這里需要注意的是頭部和尾部各需要一個額外的segment */err = virtio_config_val(vdev, VIRTIO_BLK_F_SEG_MAX,offsetof(struct virtio_blk_config, seg_max),&sg_elems);.../* 分配vq,調用virtio_find_single_vq(vdev, blk_done, "requests");分配單個vq,名字為”request”,注冊 的通知函數是blk_done */err = init_vq(vblk);/* 調用alloc_disk為此虛擬磁盤分配一個gendisk類型的對象,對象指針保存在virtio_blk結構的disk 中*/vblk->disk = alloc_disk(1 << PART_BITS);/* 分配request_queue結構,從屬于virtio-blk的gendisk結構下初始化gendisk及disk queue,注冊queue 的處理函數為do_virtblk_request,其中queuedata也設置為virtio_blk結構。*/q = vblk->disk->queue = blk_init_queue(do_virtblk_request, NULL);...add_disk(vblk->disk); //使設備對外生效
}
init_vq
完成virtqueue和vring的分配,設置隊列的回調函數,中斷處理函數,流程如下:
-->init_vq-->virtio_find_single_vq-->vp_find_vqs-->vp_try_to_find_vqs-->setup_vq-->vring_new_virtqueue-->request_irq
分配vq的函數init_vq:
static int init_vq(struct virtio_blk *vblk)
{...vblk->vq = virtio_find_single_vq(vblk->vdev, blk_done, "requests");...
}
struct virtqueue *virtio_find_single_vq(struct virtio_device *vdev,vq_callback_t *c, const char *n)
{vq_callback_t *callbacks[] = { c };const char *names[] = { n };struct virtqueue *vq;/* 調用find_vqs回調函數(對應vp_find_vqs函數,在virtio_pci_probe中設置)進行具體的設置。會將相應的virtqueue對象指針存放在vqs這個臨時指針數組中 */int err = vdev->config->find_vqs(vdev, 1, &vq, callbacks, names);if (err < 0)return ERR_PTR(err);return vq;
}
static int vp_find_vqs(struct virtio_device *vdev, unsigned nvqs,struct virtqueue *vqs[],vq_callback_t *callbacks[],const char *names[])
{int err;/* 這個函數中只是三次調用了vp_try_to_find_vqs函數來完成操作,只是每次想起傳送的參數有些不一樣,該函數的最后兩個參數:use_msix表示是否使用MSI-X機制的中斷、per_vq_vectors表示是否對每一 個virtqueue使用使用一個中斷vector *//* Try MSI-X with one vector per queue. */err = vp_try_to_find_vqs(vdev, nvqs, vqs, callbacks, names, true, true);if (!err)return 0;err = vp_try_to_find_vqs(vdev, nvqs, vqs, callbacks, names,true, false);if (!err)return 0;return vp_try_to_find_vqs(vdev, nvqs, vqs, callbacks, names,false, false);
}
Virtio設備中斷,有兩種產生中斷情況:
當設備的配置信息發生改變(config changed),會產生一個中斷(稱為change中斷),中斷處理程序需要調用相應的處理函數(需要驅動定義)
當設備向隊列中寫入信息時,會產生一個中斷(稱為vq中斷),中斷處理函數需要調用相應的隊列的回調函數(需要驅動定義)
三種中斷處理方式:
1). 不用msix中斷,則change中斷和所有vq中斷共用一個中斷irq。
中斷處理函數:vp_interrupt。
vp_interrupt函數中包含了對change中斷和vq中斷的處理。
2). 使用msix中斷,但只有2個vector;一個用來對應change中斷,一個對應所有隊列的vq中斷。
change中斷處理函數:vp_config_changed
vq中斷處理函數:vp_vring_interrupt
3). 使用msix中斷,有n+1個vector;一個用來對應change中斷,n個分別對應n個隊列的vq中斷。每個vq一個vector。
static int vp_try_to_find_vqs(struct virtio_device *vdev, unsigned nvqs,struct virtqueue *vqs[],vq_callback_t *callbacks[],const char *names[],bool use_msix,bool per_vq_vectors)
{struct virtio_pci_device *vp_dev = to_vp_device(vdev);u16 msix_vec;int i, err, nvectors, allocated_vectors;if (!use_msix) {/* 不用msix,所有vq共用一個irq ,設置中斷處理函數vp_interrupt*/err = vp_request_intx(vdev);} else {if (per_vq_vectors) {nvectors = 1;for (i = 0; i < nvqs; ++i)if (callbacks[i])++nvectors;} else {/* Second best: one for change, shared for all vqs. */nvectors = 2;}/*per_vq_vectors為0,設置處理函數vp_vring_interrupt*/err = vp_request_msix_vectors(vdev, nvectors, per_vq_vectors);}for (i = 0; i < nvqs; ++i) {if (!callbacks[i] || !vp_dev->msix_enabled)msix_vec = VIRTIO_MSI_NO_VECTOR;else if (vp_dev->per_vq_vectors)msix_vec = allocated_vectors++;elsemsix_vec = VP_MSIX_VQ_VECTOR;vqs[i] = setup_vq(vdev, i, callbacks[i], names[i], msix_vec);.../* 如果per_vq_vectors為1,則為每個隊列指定一個vector,vq中斷處理函數為vring_interrupt*/err = request_irq(vp_dev->msix_entries[msix_vec].vector,vring_interrupt, 0,vp_dev->msix_names[msix_vec],vqs[i]);}return 0;
}
setup_vq完成virtqueue(主要用于數據的操作)、vring(用于數據的存放)的分配和初始化任務:
static struct virtqueue *setup_vq(struct virtio_device *vdev, unsigned index,?void (*callback)(struct virtqueue *vq),const char *name,u16 msix_vec)
{struct virtqueue *vq;/* 寫寄存器退出guest,設置設備的隊列序號,對于塊設備就是0(最大只能為VIRTIO_PCI_QUEUE_MAX 64) */iowrite16(index, vp_dev->ioaddr + VIRTIO_PCI_QUEUE_SEL);/*得到硬件隊列的深度num*/num = ioread16(vp_dev->ioaddr + VIRTIO_PCI_QUEUE_NUM);.../* IO同步信息,如虛擬隊列地址,會調用virtio_queue_set_addr進行處理*/iowrite32(virt_to_phys(info->queue) >> VIRTIO_PCI_QUEUE_ADDR_SHIFT,vp_dev->ioaddr + VIRTIO_PCI_QUEUE_PFN);.../* 調用該函數分配vring_virtqueue對象,該結構中既包含了vring、又包含了virtqueue,并且返回 virtqueue對象指針*/vq = vring_new_virtqueue(info->num, VIRTIO_PCI_VRING_ALIGN,vdev, info->queue, vp_notify, callback, name);...return vq;
}
IO同步信息,如虛擬隊列地址,會調用virtio_queue_set_addr進行處理:
virtio_queue_set_addr(vdev, vdev->queue_sel, addr);
--> vdev->vq[n].pa = addr; //n=vdev->queue_sel,即同步隊列地址
--> virtqueue_init(&vdev->vq[n]); //初始化后端的虛擬隊列
--> target_phys_addr_t pa = vq->pa; ? ?//主機vring虛擬首地址
--> vq->vring.desc = pa; //同步desc地址
--> vq->vring.avail = pa + vq->vring.num * sizeof(VRingDesc); //同步avail地址
--> vq->vring.used = vring_align(vq->vring.avail +?offsetof(VRingAvail, ring[vq->vring.num]),VIRTIO_PCI_VRING_ALIGN); ?//同步used地址
其中,pa是由客戶機傳送過來的物理頁地址,在主機中就是主機的虛擬頁地址,賦值給主機中對應vq中的vring,則同步了主客機中虛擬隊列地址,之后vring中的當前可用緩沖描述符avail、已使用緩沖used均得到同步。
分配vring_virtqueue對象由vring_new_virtqueue函數完成:
struct virtqueue *vring_new_virtqueue(unsigned int num, ? ? ?unsigned int vring_align,struct virtio_device *vdev, ? ? ?void *pages, ? ? ?void (*notify)(struct virtqueue *), ? ? ?void (*callback)(struct virtqueue *), ? ? ?const char *name)
{struct vring_virtqueue *vq;unsigned int i;/* We assume num is a power of 2. */if (num & (num - 1)) {dev_warn(&vdev->dev, "Bad virtqueue length %u\n", num);return NULL;}/* 調用vring_init函數初始化vring對象,其desc、avail、used三個域瓜分了上面的setup_vp函數第一步中分配的內存頁面 */vring_init(&vq->vring, num, pages, vring_align);/*初始化virtqueue對象(注意其callback會被設置成virtblk_done函數*/vq->vq.callback = callback;vq->vq.vdev = vdev;vq->vq.name = name;vq->notify = notify;vq->broken = false;vq->last_used_idx = 0;vq->num_added = 0;list_add_tail(&vq->vq.list, &vdev->vqs);/* No callback? Tell other side not to bother us. */if (!callback)vq->vring.avail->flags |= VRING_AVAIL_F_NO_INTERRUPT;/* Put everything in free lists. */vq->num_free = num;vq->free_head = 0;for (i = 0; i < num-1; i++) {vq->vring.desc[i].next = i+1;vq->data[i] = NULL;}vq->data[i] = NULL;/*返回virtqueue對象指針*/return &vq->vq;
}
調用vring_init
函數初始化vring對象:
static inline void vring_init(struct vring *vr, unsigned int num, void *p,unsigned long align)
{vr->num = num;vr->desc = p;vr->avail = p + num*sizeof(struct vring_desc);vr->used = (void *)(((unsigned long)&vr->avail->ring[num] + align-1)& ~(align - 1));
}
⑵后端初始化
后端驅動的初始化流程實際是后端驅動的數據結構進行初始化,設置PCI設備的信息,并結合到virtio設備中,設置主機狀態,配置并初始化虛擬隊列,為每個塊設備綁定一個虛擬隊列及隊列處理函數,并綁定設備處理函數,以處理IO請求。virtio-block后端初始化流程:
type_init(virtio_pci_register_types)--> type_register_static(&virtio_blk_info) // 注冊一個設備結構,為PCI子設備--> class_init = virtio_blk_class_init,--> k->init = virtio_blk_init_pci;
static int virtio_blk_init_pci(PCIDevice *pci_dev)
{VirtIOPCIProxy *proxy = DO_UPCAST(VirtIOPCIProxy, pci_dev, pci_dev);VirtIODevice *vdev;...vdev = virtio_blk_init(&pci_dev->qdev, &proxy->blk);...virtio_init_pci(proxy, vdev);/* make the actual value visible */proxy->nvectors = vdev->nvectors;return 0;
}
調用virtio_blk_init來初始化virtio-blk設備,virtio_blk_init代碼如下:
VirtIODevice *virtio_blk_init(DeviceState *dev, VirtIOBlkConf *blk)
{VirtIOBlock *s;static int virtio_blk_id;.../* virtio_common_init初始化一個VirtIOBlock結構,這里主要是分配一個VirtIODevice 結構并為它賦值,VirtIODevice結構主要描述IO設備的一些配置接口和屬性。VirtIOBlock結構第一個域是VirtIODevice結構,VirtIOBlock結構還包括一些其他的塊設備屬性和狀態參數。*/s = (VirtIOBlock *)virtio_common_init("virtio-blk", VIRTIO_ID_BLOCK,sizeof(struct virtio_blk_config),sizeof(VirtIOBlock));/* 對VirtIOBlock結構中的域賦值,其中比較重要的是對一些virtio通用配置接口的賦值(get_config,set_config,get_features,set_status,reset),如此,virtio_blk便 有了自定義的配置。*/s->vdev.get_config = virtio_blk_update_config;s->vdev.set_config = virtio_blk_set_config;s->vdev.get_features = virtio_blk_get_features;s->vdev.set_status = virtio_blk_set_status;s->vdev.reset = virtio_blk_reset;s->bs = blk->conf.bs;s->conf = &blk->conf;s->blk = blk;s->rq = NULL;s->sector_mask = (s->conf->logical_block_size / BDRV_SECTOR_SIZE) - 1;/* 初始化vq,virtio_add_queue為設置vq的中vring處理的最大個數是128,注冊 handle_output函數為virtio_blk_handle_output(host端處理函數)*/s->vq = virtio_add_queue(&s->vdev, 128, virtio_blk_handle_output);/* qemu_add_vm_change_state_handler(virtio_blk_dma_restart_cb, s);設置vm狀態改 變的處理函數為virtio_blk_dma_restart_cb*/qemu_add_vm_change_state_handler(virtio_blk_dma_restart_cb, s);s->qdev = dev;/* register_savevm注冊虛擬機save和load函數(熱遷移)*/register_savevm(dev, "virtio-blk", virtio_blk_id++, 2,virtio_blk_save, virtio_blk_load, s);...return &s->vdev;
}//初始化vq,調用virtio_add_queue:
VirtQueue *virtio_add_queue(VirtIODevice *vdev, int queue_size,void (*handle_output)(VirtIODevice *, VirtQueue *))
{...vdev->vq[i].vring.num = queue_size; ?//設置隊列的深度vdev->vq[i].handle_output = handle_output; ?//注冊隊列的處理函數return &vdev->vq[i];
}
初始化virtio-PCI信息,分配bar,注冊接口以及接口處理函數;設備綁定virtio-pci的ops,設置主機特征,調用函數virtio_init_pci來初始化virtio-blk pci相關信息:
void virtio_init_pci(VirtIOPCIProxy *proxy, VirtIODevice *vdev)
{uint8_t *config;uint32_t size;?.../* memory_region_init_io():初始化IO內存,并設置IO內存操作和內存讀寫函數 virtio_pci_config_ops*/memory_region_init_io(&proxy->bar, &virtio_pci_config_ops, proxy,"virtio-pci", size);/*將IO內存綁定到PCI設備,即初始化bar,給bar注冊pci地址*/pci_register_bar(&proxy->pci_dev, 0, PCI_BASE_ADDRESS_SPACE_IO,&proxy->bar);if (!kvm_has_many_ioeventfds()) {proxy->flags &= ~VIRTIO_PCI_FLAG_USE_IOEVENTFD;}/*綁定virtio-pci總線的ops并指向設備代理proxy*/virtio_bind_device(vdev, &virtio pci_bindings, proxy);proxy->host_features |= 0x1 << VIRTIO_F_NOTIFY_ON_EMPTY;proxy->host_features |= 0x1 << VIRTIO_F_BAD_FEATURE;proxy->host_features = vdev->get_features(vdev, proxy->host_features);
}
其中,virtio-pic讀寫操作為virtio_pci_config_ops:
static const MemoryRegionPortio virtio_portio[] = {{ 0, 0x10000, 2, .write = virtio_pci_config_writew, },...{ 0, 0x10000, 2, .read = virtio_pci_config_readw, },
};
在設備注冊完成后,qemu調用io_region_add進行io端口注冊:
static void io_region_add(MemoryListener *listener,MemoryRegionSection *section)
{ ?.../*io端口信息初始化*/iorange_init(&mrio->iorange, &memory_region_iorange_ops,section->offset_within_address_space, section->size);/*io端口注冊*/ioport_register(&mrio->iorange);
}
ioport_register調用register_ioport_read及register_ioport_write將io端口對應的回調函數保存到ioport_write_table數組中:
int register_ioport_write(pio_addr_t start, int length, int size,IOPortWriteFunc *func, void *opaque)
{...for(i = start; i < start + length; ++i) {/*設置對應端口的回調函數*/ioport_write_table[bsize][i] = func; ?...}return 0;
}
四、virtio 的工作原理
虛擬隊列(virtqueue)是 virtio 實現高效數據傳輸的核心機制,而描述符表、可用環和已用環則是虛擬隊列的關鍵組成部分,它們各自承擔著重要的職責,相互配合完成數據的傳輸任務。
描述符表可以看作是一個詳細的 “數據清單”,它存放著真正的數據報文信息,每個描述符都詳細記錄了數據的起始地址、長度以及一些標志位等關鍵信息。這些信息就像是貨物的標簽,準確地告訴接收方如何正確地處理這些數據。當客戶機需要發送一個網絡數據包時,前端驅動會創建一個描述符,在描述符中記錄下數據包在內存中的起始地址、數據包的長度以及一些與傳輸相關的標志位信息,然后將這個描述符添加到描述符表中。
可用環是前端驅動用來告知后端驅動有哪些數據是可供處理的 “待處理任務列表”。前端驅動將數據描述符的索引放入可用環中,后端驅動從這里獲取任務并進行處理。繼續以上述網絡數據包發送為例,前端驅動在將描述符添加到描述符表后,會將該描述符的索引放入可用環中,并通知后端驅動有新的數據可供處理。
已用環則是后端驅動用來通知前端驅動哪些數據已經處理完成的 “完成任務反饋清單”。后端驅動在處理完數據后,會將描述符的索引放入已用環中,前端驅動看到已用環的反饋后,就知道哪些數據包已經成功處理,可以進行后續的操作,比如回收相關的資源。當后端驅動將網絡數據包成功發送到物理網絡接口后,它會將對應的描述符索引放入已用環中,通知前端驅動該數據包已發送完成。
在數據傳輸過程中,當客戶機的前端驅動有數據要發送時,它首先會將數據存儲在內存中的特定位置,并創建相應的描述符記錄數據的相關信息,然后將描述符添加到描述符表中,并把描述符的索引放入可用環中,接著通過通知機制(如中斷)告知后端驅動有新的數據到來。后端驅動接收到通知后,從可用環中獲取描述符索引,根據索引從描述符表中讀取描述符,進而獲取數據的位置和相關信息,完成對數據的處理,比如將數據發送到物理設備。
處理完成后,后端驅動將描述符索引放入已用環中,并通知前端驅動數據已處理完畢。前端驅動從已用環中得知數據處理結果后,進行相應的后續操作,如釋放已處理數據占用的內存空間等。通過這樣的方式,數據在前后端之間通過虛擬隊列實現了高效、有序的傳輸 。
五、virtio 代碼分析
5.1關鍵數據結構
在 virtio 的代碼實現中,有幾個關鍵的數據結構起著核心作用,它們相互協作,共同構建了 virtio 高效的 I/O 虛擬化功能。
virtio_bus是基于總線驅動模型的公共數據結構,定義新的 bus 時需要填充該結構,在drivers/virtio/virtio.c中進行定義。它以core_initcall的方式被注冊,啟動順序優先級很高,就像是系統啟動時的 “先鋒隊”,早早地為后續設備和驅動的注冊搭建好舞臺。在virtio_dev_match函數中,涉及到virtio_device_id結構的匹配,通過先匹配device字段,后匹配vendor字段的方式,確保驅動與設備的正確匹配,就像在茫茫人海中精準找到對應的合作伙伴。
virtio_device定義在include/linux/virtio.h中,其中的id成員標識了當前virtio_device的用途,以virtio-net為例,它就是其中一種具體的用途。config成員指向virtio_config_ops操作集,其中的函數主要與virtio_device的配置相關,包括實例化 / 反實例化virtqueue,以及獲取 / 設置設備的屬性與狀態等重要操作。vqs是一個鏈表,用于持有virtio_device所持有的virtqueue,在virtio-net中通常會建立兩條virtqueue,分別用于數據的接收和發送,就像兩條繁忙的運輸通道,保障數據的高效傳輸。features則記錄了virtio_driver和virtio_device同時支持的通信特性,是前后端最終協商的通信特性集合,這些特性決定了數據傳輸的方式和效率 。
virtio_driver同樣定義在include/linux/virtio.h中,id_table對應virtio_device結構中的id成員,它是當前driver支持的所有id列表,通過這個列表,驅動可以快速識別和匹配支持的設備。feature_table和feature_table_size分別表示當前driver支持的所有virtio傳輸屬性列表以及屬性數組的元素個數,這些屬性為驅動的功能實現提供了豐富的選項。probe函數是virtio_driver層面注冊的重要函數,當virtio_device和virtio_driver匹配成功后,會先調用bus層面的probe函數,然后在virtio_bus層面的probe函數中,進一步調用virtio_driver層面的probe函數,這個過程就像是接力賽中的交接棒,確保設備驅動的順利初始化 。
virtqueue是實現數據傳輸的關鍵數據結構,它是virtio前端與后端通信的主要方式。每個virtqueue包含描述符表(Descriptor Table)、可用環(Available Ring)和已用環(Used Ring)。描述符表由一組描述符組成,每個描述符代表一個緩沖區的地址和長度,用于指定設備操作時數據傳輸的來源或目的地,就像一份詳細的貨物清單,記錄著數據的存放位置和數量。
可用環用于前端通知后端有新的 I/O 操作請求,前端驅動會將描述符的索引填充到可用環中,后端可通過遍歷可用環來處理這些請求,就像在任務列表中領取待辦任務。已用環用于后端通知前端一個操作已經完成,后端會將描述符索引寫入已用環,前端可以從中獲取完成信息,就像收到任務完成的反饋通知 。
5.2代碼實現細節
以virtio-net模塊為例,深入剖析其前端驅動和后端驅動的代碼實現,能讓我們更清晰地了解 virtio 在網絡 I/O 虛擬化中的工作機制。
在前端驅動中,設備初始化是一個關鍵步驟。在virtnet_probe函數中,首先會進行一系列的初始化操作,包括識別和初始化接收和發送的virtqueues。如果協商了VIRTIO_NET_F_MQ特性位,會根據max_virtqueue_pairs來確定virtqueues的數量;否則,通常識別N=1。如果協商了VIRTIO_NET_F_CTRL_VQ特性位,還會識別控制virtqueue。接著,會填充接收隊列的緩沖區,為數據接收做好準備。同時,根據協商的特性位,還會進行一些其他的配置,如設置 MAC 地址、判斷鏈接狀態、協商校驗和及分段卸載等特性 。
在數據發送流程中,當內核協議棧調用dev_hard_start_xmit函數時,會逐步調用到virtio_net.c中的start_xmit函數。在start_xmit函數中,會調用xmit_skb函數,將skb(Socket Buffer,套接字緩沖區,用于存儲網絡數據包)放到vqueue中。具體操作是先通過sg_init_table初始化sg列表,sg_set_buf將sg指向特定的buffer,skb_to_sgvec將socket buffer中的數據填充到sg中,然后通過virtqueue_add_outbuf將sg添加到Virtqueue中,并更新Avail隊列中描述符的索引值。最后,通過virtqueue_notify通知后端驅動可以來取數據,整個過程就像將貨物裝上運輸車輛,并通知物流公司來取貨 。
數據接收流程則相對復雜一些。當有數據到達時,會觸發中斷,進入中斷處理流程。在中斷處理的上半部,通常是一些簡單的操作,比如將napi掛到本地cpu的softnet_data->poll_list鏈表,并通過raise_softirq觸發網絡收包軟中斷。在中斷處理的下半部,會執行軟中斷回調函數net_rx_action,進而調用virtio_net.c中的virtnet_poll函數。
在virtnet_poll函數中,會從virtqueue中獲取數據,將接收到的數據轉換成skb,并根據接收類型進行不同的處理。最后,通過napi_gro_receive將skb上傳到上層協議棧,如果檢測到本次中斷接收數據完成,會重新開啟中斷,等待下一次數據接收,整個過程就像一個高效的物流分揀中心,不斷接收、處理和分發貨物 。
在后端驅動中,以vhost-net模塊為例,其注冊主要使用 Linux 內核提供的內存注冊機制。在vhost_net_init函數中,通過misc_register將vhost-net模塊注冊為一個雜項設備,對應的字符設備為/dev/vhost-net。當用戶態使用open系統調用時,會執行vhost_net_open函數,對字符設備進行初始化,包括分配內存、初始化vhost_dev和vhost_virtqueue等操作。為了獲取tap設備的數據包,vhost-net模塊注冊了tun socket,并實現了相應的收發包函數。當tap獲取到數據包時,會調用virtnet_poll函數,從virtqueue中獲取數據并進行處理 。
5.3代碼中的優化技巧
在 virtio 的代碼實現中,采用了多種優化技巧來提高性能,使其在 I/O 虛擬化領域表現出色。
批量處理是一個重要的優化手段。在數據傳輸過程中,不是每次只處理一個數據單元,而是將多個數據單元組合成一批進行處理。以網絡數據包的發送為例,前端驅動可以將多個小的網絡數據包合并成一個大的數據包,然后通過virtqueue發送給后端驅動。這樣做可以減少數據傳輸的次數,降低VMEXIT的頻率,從而提高整體性能。就像在物流運輸中,將多個小包裹合并成一個大包裹進行運輸,減少了運輸次數和成本 。
緩存機制的運用也極大地提升了性能。在virtio-net模塊中,會使用緩存來存儲一些頻繁訪問的數據或狀態信息。例如,前端驅動可能會緩存一些常用的網絡配置參數,避免每次進行網絡操作時都去重新讀取配置文件,從而節省了讀取時間,提高了操作效率。后端驅動也可能會緩存一些設備的狀態信息,以便快速響應前端驅動的請求,就像在圖書館中,將常用的書籍放在方便拿取的位置,讀者借閱時可以更快地獲取 。
此外,virtio還通過合理的中斷處理機制來優化性能。在傳統的 I/O 虛擬化中,頻繁的中斷會導致大量的上下文切換,消耗系統資源。而virtio采用了一些策略來減少中斷的頻率,例如使用中斷合并技術,將多個中斷請求合并成一個中斷進行處理,這樣可以減少中斷處理的開銷,提高系統的整體性能,就像將多個小任務合并成一個大任務進行處理,減少了任務切換的時間 。
在數據傳輸過程中,virtio還利用了內存映射和直接內存訪問(DMA)技術。通過內存映射,前端驅動和后端驅動可以直接訪問共享內存中的數據,避免了數據在不同內存區域之間的多次復制,提高了數據傳輸的效率。DMA 技術則允許設備直接訪問內存,而不需要 CPU 的干預,進一步減輕了 CPU 的負擔,提高了系統的整體性能,就像在工廠生產中,引入自動化設備,讓設備直接進行生產操作,減少了人工干預,提高了生產效率 。