当前位置: 首页 > news >正文

深度解读virtio:Linux IO虚拟化核心机制

当你在虚拟机中流畅传输文件时,是否想过背后是谁在高效调度 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 的负担,提高了系统的整体性能,就像在工厂生产中,引入自动化设备,让设备直接进行生产操作,减少了人工干预,提高了生产效率 。

http://www.xdnf.cn/news/1124119.html

相关文章:

  • 一种用于医学图像分割的使用了多尺寸注意力Transformer的混合模型: HyTransMA
  • 记录自己在将python文件变成可访问库文件是碰到的问题
  • Linux的相关学习
  • JavaScript进阶篇——第一章 作用域与垃圾回收机制
  • 2025 R3CTF
  • 日语学习-日语知识点小记-构建基础-JLPT-N3阶段(4):语法+单词+復習+发音
  • JS基础知识(上)
  • 设计模式(行为型)-迭代器模式
  • H2 与高斯数据库兼容性解决方案:虚拟表与类型处理
  • 前端开发中的常见问题及解决方案
  • 群晖Nas - Docker(ContainerManager)上安装SVN Server和库权限设置问题
  • HarmonyOS从入门到精通:动画设计与实现之九 - 实用动画案例详解(下)
  • Redis作缓存时存在的问题及其解决方案
  • mysql 与redis缓存一致性,延时双删 和先更新数据库,再删除缓存,哪个方案好
  • 《Librosa :一个专为音频信号处理和音乐分析设计的Python库》
  • Pythonic:Python 语言习惯和哲学的代码风格
  • Kubernetes 高级调度01
  • STM32F1_Hal库学习UART
  • 破局与重构:文心大模型开源的产业变革密码
  • Java-ThreadLocal
  • java基础(day07)
  • 打开xmind文件出现黑色
  • 【LeetCode 热题 100】94. 二叉树的中序遍历——DFS
  • 13.计算 Python 字符串的字节大小
  • SpringMVC2
  • 鸿蒙开发NDK之---- 如何将ArkTs的类型转化成C++对应的类型(基础类型,包含部分代码解释)
  • 修改主机名颜色脚本
  • 虚拟货币交易:游走在合法与犯罪的生死线
  • 在Adobe Substance 3D Painter中,已经有基础图层,如何新建一个图层A,clone基础图层的纹理和内容到A图层
  • Java:继承和多态(必会知识点整理)