-
virtio半虚拟化基本原理简介
在本文中,将首先了解VirtIO的基本概念及其应用原因,然后从技术角度深入探讨VirtIO的关键领域,包括VirtIO设备与驱动程序、VirtQueues和VRings。在介绍完这些基础知识后,将通过一个在Qemu中的VirtIO设备VirtQueue的实际工作示例(包含一些代码),来帮助读者更好地理解VirtIO的运作方式。
简介
VirtIO(虚拟输入输出)是一个位于宿主机设备上的抽象层,用于虚拟机。但这是什么意思呢?本质上,它是一个接口,允许虚拟机通过一种称为VirtIO设备的最小化虚拟设备来使用其宿主机的设备。这些VirtIO设备之所以最小化,是因为它们仅实现了发送和接收数据所需的最基本功能。这是因为,在VirtIO中,让宿主机处理其实际物理硬件设备上的大部分设置、维护和处理工作。VirtIO设备的作用主要是在宿主机的实际物理硬件之间传输数据。
例如,假设在一台宿主机上运行了一个虚拟机(VM),虚拟机想要访问互联网。虚拟机没有自己的网卡来访问互联网,但宿主机有。为了让虚拟机访问宿主机的网卡,从而访问互联网,可以创建一个名为virtio-net的VirtIO设备。简而言之,它的主要目的是在宿主机和虚拟机之间发送和接收网络数据。换句话说,让virtio-net成为宿主机和客户机之间网络数据的联络员。
上图展示了虚拟机请求并接收来自宿主机的网络数据的过程。交互过程大致如下:
- guest:我想访问google.com。嘿,virtio-net,你能告诉host关于这个网页的信息吗?
- Virtio-net:好的。host,你能为获取这个网页吗?
- host:好的。我现在正在抓取网页数据。
- host:这是请求的网页数据。
- Virtio-net:谢谢。guest,这是你请求的网页。
可以通过这样一个简化的例子了解其核心思想。让宿主机的硬件尽可能多地完成工作,并让VirtIO处理发送和接收数据。将大部分工作下放到宿主机上,使得在虚拟机上的执行更快,效率更高。
与设备模拟相比,VirtIO的另一个重要方面是其核心框架已经标准化为一个官方VirtIO规范。VirtIO规范定义了VirtIO设备和驱动程序必须满足的标准要求(例如特性位、状态、配置、一般操作等)这意味着,无论使用VirtIO的环境或操作系统如何,其实现的核心框架必须是相同的。
虽然VirtIO实现有一定的规范限制,但在组织和配置方面也有一些自由度。例如,Linux内核中的virtqueue结构与Qemu的VirtQueue结构的组织方式不同。一旦理解了VirtIO的一种实现(例如在Qemu中),理解其他实现就会变得容易得多。
为什么选择virtio
在上述例子中,使用了宿主机的网络设备来让虚拟机访问互联网,但为什么不选择为虚拟机模拟一个网络设备呢?通过模拟,可以模仿任何设备,甚至是那些在宿主机硬件上无法物理支持的设 备。那么,如果能够为虚拟机模拟任何设备,为什么还要费心限制自己使用宿主机的设备和能力呢?为了回答这个问题,首先需要了解虚拟化和模拟之间的区别。
模拟与虚拟化对比
在模拟中,软件代替硬件并表现得就像真实的硬件一样。回想一下,在之前的例子中,虚拟机使用了virtio-net设备与宿主机的网卡进行通信。如果希望虚拟机使用一个宿主机没有且不支持的网卡,即某种遗留设备,那么可以使用模拟,让软件来填补缺失的硬件支持。还可以使用模拟让虚拟机运行在一个完全不同的操作系统上,这个操作系统是为其他硬件设计的(例如,在Windows PC上运行MacOS)。
当你需要使用宿主机硬件没有或不支持的设备或软件时,模拟是首选。然而,模拟并非没有代价,因为代替缺失硬件的软件是额外的代码,宿主机的CPU必须处理这些代码。所以拥有专用硬件总是会更快!
在虚拟化中,软件将宿主机的物理硬件分割给客户虚拟机使用。这种将宿主机硬件分割给每个客户虚拟机的做法本质上是将那部分硬件“专用”给该虚拟机,使该虚拟机认为它拥有自己的硬件(实际上它只是从宿主机那里“借用”)。这里虚拟化的关键思想是每个客户都有专用的直接访问宿主机那部分硬件的权限。注意这里的“专用”并不意味着宿主机会被剥夺所说的设备。这更像是共享而不是给予特定硬件的全部所有权。
当然,由于虚拟化分割了宿主机的资源,所以客户虚拟机受限于宿主机硬件所支持的。对于VirtIO设备,就是它的输入/输出(网卡、块设备、内存等)虚拟化。换句话说,它是宿主机和客户机之间用于I/O设备的通信框架。
显然,虚拟化和模拟都是通过软件来模仿硬件的技术。然而,这些技术用于满足不同的需求。简而言之,如果需要:
- 运行适用于不同硬件的操作系统(例如,在PC上运行MacOS,在PC上运行基于控制台的游戏等)
- 在另一种操作系统上运行软件(例如,在MacOS上运行Microsoft Word)
- 在不受支持的硬件上运行传统设备
那么会选择模拟而不是虚拟化。
相比之下,如果:
- 关注宿主机和客户机的性能(专用硬件)
- 不需要支持传统软件或硬件
- 需要运行多个客户机实例并有效利用宿主机的资源
那么会选择虚拟化而不是模拟。
VirtIO架构
不同的文章对 host 端和 guest 端有不同的描述,设备(device)和后端(backend)都表示 host 端,驱动(driver)和前端(frontend)都表示 guest 端。
VirtIO的架构有三个关键部分:前端驱动程序、后端设备和VirtQueues及VRings。
上图中,可以看到前端VirtIO驱动程序存在于客户机的内核中,后端VirtIO设备存在于管理程序(Qemu)中,它们之间的通信通过数据平面上的VirtQueues和VRings来处理。还可以看到来自VirtIO驱动程序和设备的通知(例如,VMExits,vCPU IRQs),这些通知被路由到KVM中断。本文中不会详细讨论这些,目前知道它们的存在就可以了。
VirtIO驱动(前端)
在带有VirtIO的主机和客户机中,VirtIO驱动程序存在于客户机的内核中。在客户机操作系统中,每个VirtIO驱动程序都被视为一个内核模块。VirtIO驱动程序的核心职责包括:
- 接受来自用户进程的I/O请求
- 将这些I/O请求传输到相应的后端VirtIO设备
- 从其VirtIO设备对应部分检索已完成的请求
例如,来自virtio-scsi的I/O请求可能是用户想要从存储中检索文档。virtio-scsi驱动程序接受检索所述文档的请求,并将请求发送到virtio-scsi设备(后端)。一旦VirtIO设备完成了请求,文档随后就可供VirtIO驱动程序使用。VirtIO驱动程序检索文档并将其提供给用户。
VirtIO设备(后端)
在带有VirtIO的主机和客户机中,VirtIO设备存在于管理程序(Qemu)中。在上图中,以及本文,将使用Qemu作为(Type 2)管理程序。这意味着VirtIO设备将存在于Qemu进程中。VirtIO设备的核心职责包括:
- 接受来自相应前端VirtIO驱动程序的I/O请求
- 通过将I/O操作卸载到宿主机的物理硬件上来处理请求
- 将处理过的请求数据提供给VirtIO驱动程序
回到virtio-scsi示例;virtio-scsi驱动程序通知其设备对应部分,让设备知道它需要去实际物理硬件上检索所请求的文档。virtio-scsi设备接受这个请求,并执行必要的调用以从物理硬件中检索数据。最后,设备通过将其检索到的数据放入其共享的VirtQueue中来使数据可供驱动程序使用。
VirtQueues
VirtIO框架种最后一个关键部分是VirtQueues数据结构,本质上协助设备和驱动程序执行各种VRing操作。VirtQueues本质上是共享客户机物理内存,这意味着每个VirtIO驱动程序和设备访问的是RAM中的同一页。换句话说,驱动程序和设备的VirtQueues不是两个不同的区域,它们是同步的。
关于VirtQueue的描述,网上存在很多不一致的描述。有些人将VirtQueue与VRings(或virtio-rings)同义使用,而另一些人则分别描述它们。这是因为VRings是VirtQueues的主要特征,因为VRings是实际的数据结构,在VirtIO设备和驱动程序之间传输数据。而VirtQueue不仅仅包括VRings,还有其他内容,所以这里将分别描述它们。
下图为Qemu中 VirtQueue和VRing数据结构之间的关系:
在Qemu的VirtIO框架中,可以清楚地看到VirtQueue数据结构与其VRing数据结构(例如VRing、VRingDesc、VRingAvail、VRingUsed)之间的区别和关系。
除了VRings本身之外,VirtQueue还包含了各种标志、索引和处理程序(或回调函数),这些都用于VRing操作。不过,这里有一个需要注意的地方,即VirtQueue的组织对于特定的客户机操作系统,以及用户空间(例如Qemu)还是内核VirtIO框架是不太相同的。此外,VirtQueues的操作特定于VirtIO配置(例如split与packed VirtQueues)。
下图展示了Linux内核中的VirtQueue和VRing数据结构:
将Linux内核的VirtIO框架与Qemu的进行比较,可以清楚地看到它们在组织上的差异。然而,由于VirtIO规范,也可以看到它们的VRing结构(desc,avail,used)之间的相似性。
了解每个结构体的字段如何影响VirtQueue和VRing操作并不重要。这里的关键是要知道VirtQueues和VRings是两种不同的数据结构,VirtQueue的组织会根据操作系统以及Qemu还是内核的VirtIO框架而有所不同。
VRings
VirtQueue有三种类型的VRings
- Descriptor ring (描述符区域)
- Available ring (driver 区域)
- Used ring (device 区域)
Descriptor Ring
描述符环本质上是一个描述符的循环数组,其中描述符是一个数据结构,用于描述数据缓冲区。描述符包含以下字段:
- addr:客户机物理地址
- len:数据缓冲区的长度
- flags:标志(NEXT,WRITE,INDIRECT)
- next:下一个链接描述符在描述符环中的索引
flags:(a) 下一个描述符中是否有更多相关数据(NEXT),(b) 此缓冲区是否仅可写(设备可写)(WRITE),© 缓冲区是否包含间接描述符表(INDIRECT)?为了简单起见,这里不讨论间接描述符表。
关于NEXT标志,当当前描述符的缓冲区中的数据延续到“下一个”描述符的缓冲区时,就会设置此标志。当一个或多个描述符以这种方式链接在一起时,这称为“描述符链接”。next字段指的是下一个链接描述符在描述符环中的索引。关于描述符链有一点需要注意,它们可以由仅可写和仅可读的描述符组成。
最后,只有驱动程序可以向描述符环添加(写入)描述符,设备只能写入设备可写的缓冲区,如果描述符的标志表示缓冲区是可写的。缓冲区可以是仅可写的或仅可读的,但绝不会两者都是。
上图是一个描述符环,其中有四个描述符项,其中两个是链接在一起的。第一个描述符项位于索引[0],它告诉数据缓冲区位于 GPA(客户物理地址)0x600,缓冲区长度为 0x100 字节,并且被标记为设备可写(W)。由于没有 “下一项” 标志(N)且下一项字段被设置为 0,所以知道这个描述符不是一个描述符链的头部。
第二个描述符项([1])表示它的数据缓冲区位于 GPA 0x810,缓冲区长度为 0x200 字节,并且被标记为设备可写且有 “下一项” 标志(next)。知道这是一个描述符链的头部,因为 “Next” 标志被置位。其“Next”字段告诉链中的下一个描述符位于描述符环的索引 [2] 处。
第三个描述符项([2])表示数据缓冲区从 GPA 0xA10 继续,长度为 0x200 字节,缓冲区同样是设备可写的。描述符链在这里结束,因为没有置位 “下一项” 标志。
最后,第四个描述符项([3])显示其数据缓冲区位于 GPA 0x525,缓冲区长度为 0x50 字节,且没有任何标志(即设备只读,无描述符链)。
需要注意的是,在描述符环中,各缓冲区的 GPA 和长度不能与其他项的内存范围重叠,并且下一缓冲区的 GPA 不一定要比前一个更大(例如描述符项 [3])。
Available Ring
可用环(也称为 avail ring,驱动程序区域)是一个循环数组,用于引用描述符环中可用的描述符。换句话说,可用环中的每个条目都指向描述符环中的一个描述符(或描述符链的头部)。
除了可用环数组外,可用环还包括以下字段:
- flags:标志位
- idx:指向下一个可用条目的索引
- ring[]:实际的可用环数组
flags 字段表示可用环的配置以及标志。idx 字段表示可用环中下一个可用条目的索引,驱动程序会在这个位置放置对描述符(或描述符链头部)的引用。最后,ring 字段代表实际的可用环数组,驱动程序在其中存储描述符环的引用。
只有驱动程序可以配置和向可用环添加条目,而对应的设备只能从中读取。最初,在驱动程序向可用环添加第一个条目之前,没有标志的可用环可能类似于下图所示:
假设驱动程序将描述符环中的第一个描述符条目添加到可用环。那么可用环将会像下图所示:
驱动程序通过将描述符表的索引添加到可用环的第一个可用条目(ring[0]),使设备可以访问第一个描述符条目。还可以看到,idx 现在为 1,因为 ring[1] 是可用环中的下一个可用条目。在这种状态下,设备只能读取描述符环中的第一个条目,无法访问其他描述符。
现在假设驱动程序将下一个描述符条目添加到可用环。注意,下一个描述符条目是一个描述符链的头部。此时,可用环将会如下图:
驱动程序将第二个和第三个描述符条目标记为可用。现在,ring[1] 指向一个描述符链的头部,使设备可以访问其所有链式描述符。idx 设置为 2,因为 ring[2] 是可用环中的下一个可用条目。
最后,假设驱动程序将下一个描述符条目添加到可用环中。现在可用环看起来如图:
驱动程序通过将描述符环的索引添加到可用环的下一个可用条目(ring[2]),使设备可以使用第四个描述符条目。需要注意的是,ring[2] 中的描述符环索引是 3。这是因为 ring[1] 包含了描述符环中的索引 1 和 2。最后,可用环的 idx 现在是 3,因为可用环中下一个可用条目是 ring[3]。
总结来说,只有驱动程序可以分别向描述符环和可用环添加描述符条目和可用环条目。然而,设备无法访问这些数据,直到驱动程序将相应的描述符环索引添加到可用环中。
Used Ring
已用环(或设备区域)与可用环类似,不同的是它是一个循环数组,引用描述符环中已使用的描述符条目(即设备对描述符的数据缓冲区进行了读取或写入)。
已用环由以下字段组成:
- flags:配置标志
- idx:下一个可用的已用环条目的索引
- ring[]:实际的已用环数组(由数据结构组成)
- id:该条目引用的描述符环索引
- len:写入描述符缓冲区的数据长度
与可用环数组不同,已用环中的每个条目是数据对(表示为“已用元素”结构),用于描述(1)描述符环中引用已使用(读取或写入)的缓冲区的描述符(或链式描述符)的索引(id),以及(2)写入描述符缓冲区的总长度(len)(或链式描述符中所有缓冲区的总写入长度)。
类似于可用环,已用环也使用 flags 和 idx 字段。索引字段的作用与可用环中的索引字段相同,只不过在已用环中,它表示已用环数组中下一个可用的条目。
与可用环相反,只有设备可以配置并向已用环添加条目,而对应的驱动程序只能读取它。
最初,在设备开始处理来自可用环的数据之前,没有标志的已用环可能看起来如下图:
这里看到一个空的已用环,下一可用的已用环索引(idx)设置为 0,指向 ring[0]。
当设备处理完第一个可用环条目并将一个条目添加到已用环时会发生什么。比如,第一个描述符的数据缓冲区被标记为设备可写,因此假设设备向描述符的可写缓冲区写入了 0x50 字节。最终的已用环将会像下面这样:
上图中,可以看到已用环条目的数据对:0 | 0x50。0(id)表示设备使用了描述符环上的索引(在本例中,设备向该描述符的数据缓冲区写入了数据),而 0x50(len)则是写入描述符数据缓冲区的总字节数。最后,已用环的 idx 被设置为 1,因为现在已用环中下一个可用条目是索引 1。
让看看当设备处理完第二个可用环条目后,已用环中的下一个条目会是什么样子。回想一下,第二个可用环条目指向一个描述符链,其中两个描述符都是设备可写的。假设设备向第一个描述符的数据缓冲区写入了 0x200 字节,向第二个描述符的数据缓冲区写入了 0x150 字节。最终的已用环如图所示:
这里可以看到,已用环条目在有多个数据缓冲区的描述符链中是如何表现的。已用环条目的索引总是指向单个描述符或描述符链的头部。在本例中,已用环的 ring[1] 指向描述符环中索引为 1 的描述符链的头部。
描述符链的已用环条目中的长度代表所有链式描述符的数据缓冲区中写入的总字节数。由于设备向第一个链式描述符的数据缓冲区写入了 0x200 字节,向第二个链式描述符的数据缓冲区写入了 0x150 字节,因此链式描述符中写入的总长度为 0x350 字节。
最后,假设设备处理了可用环的第三个条目并添加了相应的已用环条目。请注意,可用环的第三个条目指向一个没有标志的描述符,这意味着它是一个单一描述符,并且其数据缓冲区对设备来说是只读的。最终的已用环如图所示:
在这里可以看到,针对单个只读描述符,已用环条目会是什么样子的。值得注意的是,已用环条目中的长度为 0x0。它的值为零是因为该缓冲区没有写入任何数据(对设备来说是只读的)。最后,如预期的那样,已用环条目中的索引为 3,因为描述符环的索引 2 是描述符链的一部分。
VHost概述
本文不会深入讨论 VHost,但学习过 VirtIO 的朋友可能已经看到过“VHost”这个术语。因此,值得在这里简要描述一下。
与 VirtIO 驱动程序和设备不同,后者的数据平面存在于 Qemu 进程中,VHost 可以将数据平面卸载到另一个主机用户进程(VHost-User)或主机的内核(VHost,作为内核模块)。这样做的动机是为了提高性能。也就是说,在纯 VirtIO 解决方案中,每当驱动程序请求主机对其物理硬件进行处理时,都会发生上下文切换。这些上下文切换是代价昂贵的操作,增加了请求之间的延迟。通过将数据平面卸载到另一个主机用户进程或其内核,基本上绕过了 Qemu 进程,从而通过减少延迟来提高性能。
然而,尽管这样可以提高性能,但由于数据路径现在直接进入主机的内核,这也增加了安全隐患。
下图是一个示例 VHost(VHost-SCSI)的一般框架:
如果将图中的VHost总体框架与VirtIO框架进行比较,可以看到一些关键区别:
- 传输层(或数据平面)现在是从客户机内核到主机内核。
- VirtIO设备功能缩减为仅处理控制平面任务。
- 主机内核中存在VHost-SCSI内核模块。
qemu中的virtio
virtio-SCSI设备用于将虚拟逻辑单元(例如硬盘驱动器)组合在一起,并通过SCSI协议启用与它们的通信。qemu中命令参数如下:
-device virtio-scsi-pci -device scsi-hd,drive=hd0,bootindex=0 -drive file=/home/qemu-imgs/test.img,if=none,id=hd0
在Qemu源代码中,查看hw/scsi/virtio-scsi.c,可以看到与设备操作相关的各种函数。 在Qemu中,“realize”一词用来表示VirtIO设备的初始设置和配置(“unrealize”用来删除设备)。在函数virtio_scsi_common_realize()中,可以看到为virtio-SCSI设备设置了三种不同类型的VirtQueues:
// In hw/scsi/virtio-scsi.c void virtio_scsi_common_realize(DeviceState *dev, VirtIOHandleOutput ctrl, VirtIOHandleOutput evt, VirtIOHandleOutput cmd, Error **errp) { ... s->ctrl_vq = virtio_add_queue(vdev, s->conf.virtqueue_size, ctrl); s->event_vq = virtio_add_queue(vdev, s->conf.virtqueue_size, evt); for (i = 0; i < s->conf.num_queues; i++) { s->cmd_vqs[i] = virtio_add_queue(vdev, s->conf.virtqueue_size, cmd); } }
大多数VirtIO设备都会有多个VirtQueues,每个VirtQueue都有其独特的功能,比如virtio-net,有两个VirtQueue,分别用于收包核发包。在virtio-SCSI的情况下,有一个控制VirtQueue(ctrl_vq)、一个事件VirtQueue(event_vq)以及一个或多个命令(或请求)VirtQueues(cmd_vqs)。
控制VirtQueue用于任务管理功能(TMFs),例如启动、关闭、重置等virtio-SCSI设备。它还用于订阅和查询异步通知。
事件VirtQueue用于报告来自主机关于连接到virtio-SCSI的逻辑单元的信息(事件)。这些事件包括传输事件(例如设备重置、重新扫描、热插拔、热卸载等)、异步通知和逻辑单元号(LUN)参数更改。
最后,命令或请求VirtQueues用于典型的SCSI传输命令(例如读取和写入文件)。在本节中,将重点关注命令VirtQueue的操作,因为与其他两个相比使用得更多。
命令VirtQueue
命令(或请求)VirtQueue是本节中将重点关注的VirtQueue。它是用于传输有关典型SCSI传输命令(如读写文件)的信息的VirtQueue。Virtio-SCSI可以有一个或多个这样的命令VirtQueues。
如前所述,Qemu中的VirtQueues结构具有一个用于处理输出的回调函数,为VirtIOHandleOutput handle_output。在virtio-SCSI的命令VirtQueue的情况下,此回调函数字段将指向virtio-SCSI的命令VirtQueue处理函数virtio_scsi_handle_cmd():
// In hw/scsi/virtio-scsi.c static void virtio_scsi_device_realize(DeviceState *dev, Error **errp) { VirtIODevice *vdev = VIRTIO_DEVICE(dev); VirtIOSCSI *s = VIRTIO_SCSI(dev); Error *err = NULL; virtio_scsi_common_realize(dev, virtio_scsi_handle_ctrl, virtio_scsi_handle_event, virtio_scsi_handle_cmd, <----* &err); ... } // In hw/virtio/virtio.c VirtQueue *virtio_add_queue(VirtIODevice *vdev, int queue_size, VirtIOHandleOutput handle_output) { ... vdev->vq[i].vring.num = queue_size; vdev->vq[i].vring.num_default = queue_size; vdev->vq[i].vring.align = VIRTIO_PCI_VRING_ALIGN; vdev->vq[i].handle_output = handle_output; <----* vdev->vq[i].used_elems = g_malloc0(sizeof(VirtQueueElement) * queue_size); return &vdev->vq[i]; }
VirtQueue的输出处理函数的调用方式取决于VirtIO设备和该VirtQueue对设备的角色。在virtio-SCSI的命令VirtQueue的情况下,当virtio-SCSI驱动程序向Qemu发送通知,告诉Qemu通知其设备对应方有SCSI命令数据准备好在其可用VRing上处理时,就会调用其输出处理函数。
VirtIO设备等到其对应的VirtIO驱动程序已经向描述符环添加了新的描述符,通过向可用环添加描述符引用条目使这些描述符可用于设备,以及通知其设备可用环已准备好处理,才会开始参与VRing操作。
换句话说,当执行到达virtio_scsi_handle_cmd()函数时,意味着virtio-SCSI设备已经从其驱动程序接收到通知,并开始处理来自其命令VirtQueue的可用环的数据。
virtio_scsi_handle_cmd()–>virtio_scsi_handle_cmd_vq():
// In hw/scsi/virtio-scsi.c bool virtio_scsi_handle_cmd_vq(VirtIOSCSI *s, VirtQueue *vq) { VirtIOSCSIReq *req, *next; int ret = 0; bool suppress_notifications = virtio_queue_get_notification(vq); bool progress = false; QTAILQ_HEAD(, VirtIOSCSIReq) reqs = QTAILQ_HEAD_INITIALIZER(reqs); do { if (suppress_notifications) { virtio_queue_set_notification(vq, 0); } while ((req = virtio_scsi_pop_req(s, vq))) { progress = true; ret = virtio_scsi_handle_cmd_req_prepare(s, req); if (!ret) { QTAILQ_INSERT_TAIL(&reqs, req, next); } else if (ret == -EINVAL) { /* The device is broken and shouldn't process any request */ while (!QTAILQ_EMPTY(&reqs)) { ... } } } if (suppress_notifications) { virtio_queue_set_notification(vq, 1); } } while (ret != -EINVAL && !virtio_queue_empty(vq)); QTAILQ_FOREACH_SAFE(req, &reqs, next, next) { virtio_scsi_handle_cmd_req_submit(s, req); } return progress; }
这个函数主要是virtio-SCSI命令VirtQueue处理其可用环上的数据。
如果驱动程序在可用环上提供了20个条目,从索引0到19,那么在将最后一个描述符引用添加到available ring[19]之后,可用环的idx将是20。在设备处理完最后一个可用环条目并将其相应的used ring条目放在used ring[19]上之后,used ring的idx也将是20。当这种情况发生时,几乎在添加最后一个used ring条目之后立即,设备必须然后通知其驱动程序。对于上述场景需要设置VIRTIO_VRING_F_EVENT_IDX,这意味着它只会在used ring中的idx等于available ring中的idx时通知其驱动程序。
在virtio_scsi_handle_cmd_vq()执行开始之前,假设其命令VirtQueue的描述符和可用环看起来像下图:
下面是virtio_scsi_handle_cmd_vq函数解释:
QTAILQ_HEAD(, VirtIOSCSIReq) reqs = QTAILQ_HEAD_INITIALIZER(reqs);//对于virtio-SCSI的命令VirtQueue,其可用环上的每个条目都被制成一个VirtIOSCSIReq对象,并追加到reqs队列的末尾。 // virtio_scsi_handle_cmd_vq do { /* Turn off notifications if we're suppressing them */ if (suppress_notifications) { virtio_queue_set_notification(vq, 0); } while ((req = virtio_scsi_pop_req(s, vq))) { progress = true; ret = virtio_scsi_handle_cmd_req_prepare(s, req); if (!ret) { QTAILQ_INSERT_TAIL(&reqs, req, next); } else if (ret == -EINVAL) { /* The device is broken and shouldn't process any request */ ... } } /* Turn on notifications if we've been suppressing them */ if (suppress_notifications) { virtio_queue_set_notification(vq, 1); } } while (ret != -EINVAL && !virtio_queue_empty(vq));
在开始读取可用环之前,首先限制备发送给驱动程序的任何通知(VIRTIO_VRING_F_EVENT_IDX)。然后进入第二个while循环while ((req = virtio_scsi_pop_req(s, vq)))。在这个while循环中遍历可用环,对于每个条目,将其数据放入一个VirtIOSCSIRes对象中。然后将每个VirtIOSCSIReq对象追加到reqs队列的末尾。
在while循环结束时,将拥有类似下图,其中Req1是指从读取可用环条目ring[0]创建的VirtIOSCSIReq对象,Req2是从读取可用环条目ring[1]的VirtIOSCSIReq对象,类似地,Req3来自可用环条目ring[2]:
在从可用环中读取了所有能读取的数据之后,然后重新启用通知,以便设备可以在处理请求并将它们放在已用环上之后通知驱动程序已用数据。请注意,这只会启用通知,而不会发送。在场景中(带有VIRTIO_VRING_F_EVENT_IDX),这只是准备好一旦将所有已处理的请求的数据放到已用环上就通知的设备。
在实际去提交请求之前,仍然在do-while循环中,如果设备坏了或者还没有从可用环中读取所有的数据,这个循环就会终止。这是为了防止在从可用环中读取最后一个条目之后,更多的数据被添加到可用环中。
现在设备已经从可用环中读取了所有它能读取的数据,并将每个条目转换成了它自己的VirtIOSCSIReq对象,然后遍历reqs队列,并逐个提交每个请求以供实际的物理SCSI设备处理:
QTAILQ_FOREACH_SAFE(req, &reqs, next, next) { virtio_scsi_handle_cmd_req_submit(s, req); }
一旦主机的SCSI设备完成了请求,执行就会转到virtio_scsi_command_complete(),然后是virtio_scsi_complete_cmd_req(),最后是virtio_scsi_complete_req()。virtio_scsi_complete_req(),是设备将已用数据放到已用环上的函数。
// In hw/scsi/virtio-scsi.c static void virtio_scsi_complete_req(VirtIOSCSIReq *req) { VirtIOSCSI *s = req->dev; VirtQueue *vq = req->vq; VirtIODevice *vdev = VIRTIO_DEVICE(s); qemu_iovec_from_buf(&req->resp_iov, 0, &req->resp, req->resp_size); /* Push used request data onto used ring */ virtqueue_push(vq, &req->elem, req->qsgl.size + req->resp_iov.size); /* Determine if we need to notify the driver */ if (s->dataplane_started && !s->dataplane_fenced) { virtio_notify_irqfd(vdev, vq); } else { virtio_notify(vdev, vq); } if (req->sreq) { req->sreq->hba_private = NULL; scsi_req_unref(req->sreq); } virtio_scsi_free_req(req); }
要完成一个请求,virtio-SCSI设备必须通过将处理过的数据放在已用环上(virtqueue_push())来使驱动程序能够访问这些数据。请记住,此时实际数据已经被写入到一个描述符的缓冲区中(或者写入到来自链式可写描述符的多个缓冲区中)。现在所做的就是告诉驱动程序在描述符环中的哪个位置查找,以及向其数据缓冲区写了多少数据。
在为Req1向已用环添加了一个条目之后,命令VirtQueue的VRings将如下图:
第一个请求指的是设备只读缓冲区,所以描述符索引是0,写入长度是0。idx也递增到1。在virtqueue_push()之后,然后检查是否应该通知驱动程序。
如果设备正在使用VIRTIO_VRING_F_EVENT_IDX功能。一旦已用环idx是3,设备就会通知驱动程序。因此,对于这个请求,没有通知发送给驱动程序。
接下来的两个请求指的是设备可写缓冲区。为了保持一致性,假设Req2的写入长度是0x200,Req3写入长度是0x500。在从virtqueue_push()返回Req3之后,已用环将如下图:
现在,由于满足了设备特性VIRTIO_VRING_F_EVENT_IDX的条件,即已用环的idx等于可用环的idx,设备将会通知驱动程序已用环的内容。一旦驱动程序被通知,它将进入已用环,在描述符环上找到处理过的数据,并按顺序处理这些数据。
在通知驱动程序并进行一些清理工作之后,virtio-SCSI设备的工作就完成了,并回到等待驱动程序通知新数据的状态。
参考
- https://www.linux-kvm.org/images/archive/f/f5/20110823142849!2011-forum-virtio-scsi.pdf
- https://projectacrn.github.io/latest/developer-guides/hld/hld-virtio-devices.html#
- https://insujang.github.io/2021-03-10/virtio-and-vhost-architecture-part-1/
- https://www.cs.cmu.edu/~412/lectures/Virtio_2015-10-14.pdf
- https://www.redhat.com/en/blog/virtio-devices-and-drivers-overview-headjack-and-phone
- https://www.redhat.com/en/blog/virtqueues-and-virtio-ring-how-data-travels
- https://www.redhat.com/en/blog/introduction-virtio-networking-and-vhost-net
- https://www.redhat.com/en/blog/journey-vhost-users-realm
- https://www.dell.com/en-us/blog/emulation-or-virtualization-what-s-the-difference/
- https://www.hitechnectar.com/blogs/virtualization-emulation/
- https://developer.ibm.com/articles/l-virtio/
- https://www.linaro.org/blog/virtio-work/
- https://docs.oasis-open.org/virtio/virtio/v1.1/csprd01/virtio-v1.1-csprd01.html
- https://developpaper.com/original-kvm-qemu-analysis-of-linux-virtualization-11-virtqueue/