在当今数字化时代,虚拟化技术早已成为推动计算机领域发展的重要力量。想象一下,一台物理主机上能同时运行多个相互隔离的虚拟机,每个虚拟机都仿佛拥有自己独立的硬件资源,这一切是如何实现的呢?今天,就让我们一起踏上这场充满奥秘的 Linux IO 虚拟化探索之旅,而我们的主角 ——virtio,将为我们揭开这层神秘的面纱。它是如何在虚拟化的世界里巧妙运作,解决了 I/O 虚拟化中的诸多难题?又有着怎样独特的设计和实现,让众多开发者为之着迷?接下来,就跟我一同深入 virtio 的奇妙世界,探寻其中的秘密。
一、Linux IO虚拟化简介
1.1虚拟化概述
在虚拟化的大家族中,Linux IO 虚拟化占据着重要的地位。它专注于解决虚拟机与物理硬件之间输入 / 输出(I/O)通信的问题,力求打破 I/O 性能瓶颈,让虚拟机在数据传输的高速公路上畅行无阻。想象一下,虚拟机就像一个个繁忙的工厂,不断地需要原材料(输入数据)和输出产品(输出数据),而 Linux IO 虚拟化就是优化工厂运输线路和装卸流程的关键技术,确保原材料和产品能够快速、高效地进出工厂。
而 virtio,作为 Linux IO 虚拟化领域的璀璨明星,发挥着举足轻重的作用。它就像是一座坚固的桥梁,连接着虚拟机和物理设备,为两者之间的通信搭建了一条高效、稳定的通道。virtio 提供了一套通用的 I/O 设备虚拟化框架,使得不同的虚拟机监控器(Hypervisor)和设备驱动能够基于统一的标准进行交互,大大提高了代码的可重用性和跨平台性。无论你使用的是 KVM、Xen 还是其他虚拟化解决方案,virtio 都能像一位可靠的伙伴,为你提供出色的 I/O 虚拟化支持。
Virtio的好处:
- virtio作为一种Linux内部的API,提供了多种前端驱动模块
- 框架通用,方便模拟各种设备
- 使用半虚拟化可以大大减少VMEXIT次数,提高性能
1.2Linux IO 虚拟化
在深入了解 virtio 之前,让我们先来回顾一下 Linux IO 虚拟化的传统实现方式,以及它所面临的挑战。传统的 Linux IO 虚拟化中,Qemu 扮演着重要的角色,它采用纯软件的方式来模拟 I/O 设备,就像一位技艺高超的模仿者,努力模仿各种真实设备的行为 。
当客户机中的设备驱动程序发起 I/O 操作请求时,整个流程就像一场精心编排的接力赛。KVM 模块中的 I/O 操作捕获代码首先拦截这次 I/O 请求,就像接力赛中的第一棒选手,迅速接过请求的 “接力棒”。然后,它将本次 I/O 请求的信息存放到 I/O 共享页,并通知用户空间的 Qemu 程序。Qemu 模拟程序获得 I/O 操作的具体信息之后,交由硬件模拟代码来模拟出本次的 I/O 操作,完成之后,将结果放回到 I/O 共享页,并通知 KVM 模块中的 I/O 操作捕获代码。最后,由 KVM 模块中的捕获代码读取 I/O 共享页中的操作结果,并把结果返回客户机中。在这个过程中,客户机作为一个 Qemu 进程在等待 I/O 时也可能被阻塞,就像接力赛中的选手在传递接力棒时可能会遇到一些阻碍。
这种模拟方式虽然具有很强的灵活性,能够通过软件模拟出各种各样的硬件设备,包括一些不常用的或很老很经典的设备,而且不用修改客户机操作系统,就可以使模拟设备在客户机中正常工作,为解决手上没有足够设备的软件开发及调试提供了很大的帮助。但它的缺点也很明显,每次 I/O 操作的路径比较长,有较多的 VMEntry、VMExit 发生,需要多次上下文切换,就像接力赛中选手频繁交接接力棒,耗费大量时间和精力。同时,也需要多次数据复制,这无疑进一步降低了效率,导致其性能较差。在一些对 I/O 性能要求较高的场景中,如大规模数据处理、实时通信等,传统的 Qemu 模拟 I/O 设备的方式往往难以满足需求,就像一辆老旧的汽车,在高速公路上无法达到预期的速度。
随着虚拟化技术的广泛应用,对 I/O 性能的要求越来越高,传统的 IO 虚拟化方式逐渐暴露出其局限性,这也促使了新的技术 ——virtio 的出现,它将为我们带来怎样的惊喜呢?让我们继续深入探索。
二、揭开virtio神秘面纱
virtio,作为 Linux IO 虚拟化领域的关键技术,究竟是什么呢?简单来说,virtio 是一种用于虚拟化平台的 I/O 虚拟化标准 ,它就像是一个智能的翻译官,让虚拟机和宿主系统能够顺畅地交流。它由 Rusty Russell 开发,最初是为了支持自己的虚拟化解决方案 lguest。在半虚拟化的世界里,virtio 扮演着至关重要的角色,它是对一组通用模拟设备的抽象,就像一个万能的模具,可以根据不同的需求塑造出各种虚拟设备。
在半虚拟化的架构中,来宾操作系统(也就是虚拟机中的操作系统)需要与 Hypervisor(虚拟机监视器)进行紧密的合作 。而 virtio 就像是一座桥梁,连接着来宾操作系统和 Hypervisor。它提供了一组通用的接口,让来宾操作系统能够以一种标准化的方式与 Hypervisor 进行交互。这样一来,不同的虚拟化平台就可以基于 virtio 实现统一的 I/O 虚拟化,大大提高了开发效率和兼容性。想象一下,有了 virtio 这座桥梁,不同的虚拟化平台就像不同语言的人,通过 virtio 这个翻译官,能够轻松地沟通和协作,实现高效的 I/O 虚拟化。
那么,virtio 是如何抽象模拟设备的呢?它通过定义一套通用的设备模型和接口,将各种物理设备的功能抽象出来 。无论是网络适配器、磁盘驱动器还是其他设备,virtio 都为它们提供了统一的抽象表示。在虚拟化环境中,虚拟机中的网络设备可以通过 virtio 接口与 Hypervisor 中的网络后端进行通信,而不需要关心具体的物理网络设备是什么。这种抽象模拟的方式,使得 virtio 具有很强的通用性和灵活性,能够适应各种不同的虚拟化场景。就像一个万能的遥控器,无论你是控制电视、空调还是其他电器,都可以通过这个遥控器进行操作,而不需要为每种电器都配备一个专门的遥控器。
2.1virtio 数据流交互机制
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.2Virtio 缓冲池
来宾操作系统(前端)驱动程序通过缓冲池与 hypervisor 交互。对于 I/O,来宾操作系统提供一个或多个表示请求的缓冲池。例如,您可以提供 3 个缓冲池,第一个表示 Read 请求,后面两个表示响应数据。该配置在内部被表示为一个散集列表(scatter-gather),列表中的每个条目表示一个地址和一个长度。
2.3核心 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设备遵循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++;
else
msix_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 代码深度探索
4.1数据结构探秘
在 virtio 的代码世界里,vring 和 virtqueue 是最为关键的数据结构,它们就像是代码大厦的基石,支撑着整个 virtio 的功能实现。
vring 是 virtio 前端驱动和后端 Hypervisor 虚拟设备之间传输数据的核心载体 ,它主要由描述符表(Descriptor Table)、可用描述符表(Available Ring)和已用描述符表(Used Ring)这三个部分组成。在早期的 virtio 1.0 版本及之前,这三个部分是相互分离的,形成了所谓的 Split Virtqueue。在这种模式下,每个部分都有其特定的读写权限,并且通过 next 字段将多个描述符串接成描述符链表的形式来描述一个 IO 请求,这种方式虽然能够实现基本的数据传输功能,但在数据管理和处理效率上存在一定的局限性。
随着技术的发展,virtio 1.1 版本引入了 Packed Virtqueue,它将描述符表、可用描述符表和已用描述符表合并在一起,形成了一个更加紧凑的结构。在这种结构中,增加了 Flag 的相关标记值,去除了 next 字段,同时增加了 Buffer ID,对 entries 支持进行了增强。这样的设计使得数据管理更加高效,也更容易增加与硬件的亲和性并更好地利用 Cache。就像重新规划了仓库的布局,使得货物的存放和取用更加方便快捷。
而 virtqueue 则是对 vring 的进一步封装和管理,它包含了 vring 以及其他一些与队列相关的信息和操作函数 。在实际运行中,Client 会把 Buffers 插入到 virtqueue 中,队列会根据不同设备安排不同的数量。网络设备通常有两个队列,一个用于接收数据,一个用于发送数据,这样可以实现数据的高效处理,避免接收和发送数据时的冲突。virtqueue 还提供了一些对 vring 进行操作的函数,如 add_buf 用于将数据缓冲区添加到队列中,get_buf 用于从队列中获取数据缓冲区,kick 用于通知对端有新的数据到来等。这些函数就像是仓库管理员的工具,帮助管理员高效地管理仓库中的货物。
4.2核心流程解读
以网络设备为例,virtio 的数据收发流程是其核心功能的具体体现,这个流程就像是一场紧张有序的接力赛,各个环节紧密配合,确保数据的高效传输。
当网络设备发送数据时,前端驱动首先会通过 start_xmit 函数开始数据传输的旅程。在这个函数中,会调用 xmit_skb 函数来具体处理数据的发送。xmit_skb 函数会先使用 sg_init_table 初始化 sg 列表,这个 sg 列表就像是一个货物清单,记录了要发送的数据的相关信息。然后,sg_set_buf 将 sg 指向特定的 buffer,skb_to_sgvec 将 socket buffer 中的数据填充到 sg 中,就像是将货物装载到运输工具上。
接着,通过 virtqueue_add_outbuf 将 sg 添加到 Virtqueue 中,并更新 Avail 队列中描述符的索引值,这一步就像是将装满货物的运输工具放入仓库的待发货区域,并记录下货物的位置信息。最后,virtqueue_notify 通知 Device,可以过来取数据了,就像是通知快递员来取货。
在数据接收方面,当 Qemu 收到 tap 发送过来的数据包后,会在 virtio_net_receive 函数中把数据拷贝到虚拟机的 virtio 网卡接收队列 。这个过程就像是快递员将包裹送到仓库的接收区域。然后,会向虚拟机注入一个中断,这样虚拟机便感知到有网络数据报文的到来。在虚拟机内部,数据接收流程从 napi_gro_receive 函数开始,它会将接收到的数据传输给网络层。接着,netif_receive_skb 函数会将 skb(套接字缓冲区)传递给网络层进行处理。在驱动的 poll 方法中,会调用 napi_poll 函数,具体到 virtio_net.c 中就是 virtnet_poll 函数。
在这个函数中,会调用 receive_buf 函数将接收到的数据转换成 skb,然后根据接收类型(如 XDP_PASS、XDP_TX 等)对 virtqueue 中的数据进行不同的处理。如果检测到本次中断接收数据完成,则会重新开启中断,等待下一次中断接收数据。在整个过程中,还会涉及到一些其他的函数和操作,如 skb_recv_done 函数用于数据接收完成后的回调,virtqueue_napi_schedule 函数用于调度 NAPI(网络接口轮询)等。这些函数和操作相互配合,确保了数据接收的高效和稳定。