VirtIO简介

简介

2022 年 5 月 24 日 | 阅读时间 33 分钟 (https://blogs.oracle.com/authors/jonah-palmer)

概述:

在本文档中,我们将从技术角度了解 VirtIO 的基础知识,并深入探讨其一些关键领域。这篇 VirtIO 简介是在假设读者几乎没有 VirtIO 工作知识的情况下编写的,但对于那些已经熟悉 VirtIO 的人来说,这也应该是一个有用的复习。

我们将首先了解 VirtIO 的实际含义以及我们使用它的原因。然后我们将获得更多技术知识并提供有关 VirtIO 关键领域(即 VirtIO 设备和虚拟机)的更多详细信息。驱动程序、VirtQueue 和 VRing(或“区域”)。最后,为了让一切完整,我们将看一下 Qemu 中 VirtIO 设备的 VirtQueue 的工作示例(带有一些代码),并了解 VirtIO 世界中的所有内容如何组合在一起。

什么是 VirtIO?

正式来说,VirtIO,或虚拟输入&输出是虚拟机主机设备上的抽象层。但这到底意味着什么呢?本质上,它是一个接口,允许虚拟机通过称为 VirtIO 设备的最小化虚拟设备来使用其主机的设备。这些 VirtIO 设备规模很小,因为它们仅满足发送和接收数据的基本需求。这是因为,通过 VirtIO,我们让主机处理其实际物理硬件设备上的大部分设置、维护和处理。 VirtIO 设备的作用或多或少是从主机的实际物理硬件中获取数据。

例如,假设我们有一个在主机上运行的 VM(虚拟机),并且该 VM 想要访问互联网。虚拟机没有自己的网卡来访问互联网,但主机有。为了让虚拟机访问主机的网卡,从而访问互联网,可以创建一个名为 virtio-net 的 VirtIO 设备。简而言之,它的主要目的是向主机发送和接收网络数据。换句话说,让 virtio-net 成为主机和来宾之间网络数据的联络人。

virtio-net-simple-ex1

图1

上图 1 概括地展示了虚拟机向主机请求和接收网络数据的过程。高级交互将类似于以下内容:

  1. **虚拟机:**我想要访问 google.com。你好 virtio-net,你能告诉主机帮我检索这个网页吗?
  2. **Virtio-net:**好的。您好,楼主,您能帮我们拉一下这个网页吗?
  3. **主持人:**好的。我现在正在抓取网页数据。
  4. **主持人:**这是请求的网页数据。
  5. Virtio-net: 谢谢。嘿 VM,这是您请求的网页。

虽然这是一个过于简化的示例,但核心思想仍然完好无损。也就是说,让主机硬件承担尽可能多的工作,并让 VirtIO 处理发送和接收数据。例如,与模拟设备相比,将大量工作转移到主机可以使虚拟机上的执行速度更快、效率更高。< /span>

VirtIO 的另一个重要方面是其核心框架已标准化为官方 VirtIO 规范。 VirtIO 规范定义了 VirtIO 设备和驱动程序必须满足的标准要求(例如功能位、状态、配置、常规操作等)。这一点很重要,因为这意味着,无论使用 VirtIO 的环境或操作系统如何,其实现的核心框架都必须相同。

虽然 VirtIO 实现具有一致性,但在组织和设置方面也存在一些余地。例如,Linux 内核中的 virtqueue 结构与 Qemu 的 VirtQueue 结构的组织方式不同。然而,一旦您了解 VirtIO 的一种实现(例如在 Qemu 中),理解其他实现就会容易得多。

为什么(或者为什么不)VirtIO?

在上面的示例中,我们使用主机的网络设备让虚拟机访问互联网,但是模拟网络设备又如何呢?我们的虚拟机呢?通过仿真,我们可以模仿任何设备,甚至是我们的主机硬件物理上不支持的设备。那么,如果我们可以为虚拟机模拟任何设备,为什么还要将自己限制在主机的设备和功能上呢?要回答这个问题,我们首先要了解虚拟化和仿真之间的区别。

虚拟化与仿真:

在仿真中,软件会替代硬件并发挥作用,就好像它是真实的硬件一样。回想一下,在前面的示例中,我们的虚拟机使用 virtio-net 设备与主机的 NIC 进行通信。如果我们希望虚拟机使用主机没有且不支持的 NIC(即某些旧设备),该怎么办?在这种情况下,我们可以使用仿真并获取软件来填补缺少的硬件支持。我们还可以使用仿真让虚拟机在适用于其他硬件的完全不同的操作系统上运行(例如 Windows PC 上的 MacOS)。

当您需要使用主机硬件不具备或不支持的设备或软件时,首选仿真。然而,仿真并不是没有成本的,因为填补缺失硬件的软件是主机 CPU 必须处理的额外代码。拥有专用硬件总是会更快!

在虚拟化中,软件会分割主机的物理硬件以供来宾虚拟机使用。将主机的硬件分配给每个来宾虚拟机本质上是将这部分硬件“专用”给该虚拟机,使该虚拟机认为它拥有自己的硬件(实际上它只是从主机“借用”它)。这里虚拟化的关键思想是每个来宾都可以直接访问主机硬件的该部分。请注意,此处的“专用”并不意味着主机将被剥夺所述设备。这更像是共享,而不是授予特定硬件的全部所有权。

当然,由于虚拟化分割了主机的资源,因此我们的来宾自然会受到主机硬件支持的限制。对于 VirtIO 设备,这就是它的输入/输出(NIC、块、内存等)虚拟化。换句话说,它是主机和客户机之间 I/O 设备的通信框架。

为什么(或者为什么不)VirtIO? (续)

很明显,虚拟化和仿真都是通过软件模仿硬件的技术。然而,这些技术用于满足不同的期望。简而言之,如果您需要满足以下条件,您可以选择仿真而不是虚拟化:

  • 运行适用于不同硬件的操作系统(例如 PC 上的 MacOS、PC 上基于游戏机的游戏等)
  • 运行适用于其他操作系统的软件(例如 MacOS 上的 Microsoft Word)
  • 在不受支持的硬件上运行旧设备

相比之下,如果您符合以下条件,您会选择虚拟化而不是模拟:

  • 关心主机和访客性能(专用硬件)
  • 不需要对遗留软件或硬件的支持
  • 需要运行多个来宾实例并有效利用主机资源

VirtIO架构

VirtIO 的架构由三个关键部分组成:前端驱动程序、后端设备及其 VirtQueues 和 VirtQueues。 VR 环。在下图中,我们可以看到每个部分在使用 VirtIO 的典型主机和来宾设置中的位置(例如,无 VHost、SR-IOV 等)。

Virtio架构

图2

在图 2 中,我们可以看到前端 VirtIO 驱动程序存在于 guest 虚拟机内核中,后端 VirtIO 设备存在于虚拟机管理程序 (Qemu) 中,它们之间的通信通过 VirtQueues 和 VirtQueues 在数据平面中处理。 VR 环。我们还可以看到来自 VirtIO 驱动程序和设备的通知(例如 VMExits、vCPU IRQ),这些通知被路由到 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 设备也存在于虚拟机管理程序中。在上面的图 2 和本文档中,我们将使用 Qemu 作为我们的(类型 2)虚拟机管理程序。这意味着我们的 VirtIO 设备将存在于 Qemu 进程中。 VirtIO 设备的核心职责是:

  • 接受来自相应前端 VirtIO 驱动的 I/O 请求
  • 通过将 I/O 操作卸载到主机的物理硬件来处理请求
  • 使处理后的请求数据可供 VirtIO 驱动程序使用

返回 virtio-scsi 示例; virtio-scsi 驱动程序通知其对应的设备,让设备知道它需要去检索实际物理硬件上存储的所请求的文档。 virtio-scsi 设备接受此请求并执行必要的调用以从物理硬件检索数据。最后,设备将检索到的数据放入其共享 VirtQueue 中,从而使数据可供驱动程序使用。

虚拟队列:

VirtIO 架构的最后一个关键部分是 VirtQueue,它是本质上协助设备和驱动程序执行各种 VRing 操作的数据结构。 VirtQueue 在来宾物理内存中共享,这意味着每个 VirtIO 驱动程序和虚拟机都可以使用 VirtQueue。设备对访问 RAM 中的同一页面。换句话说,驱动程序和设备的 VirtQueue 不是同步的两个不同区域。

关于 VirtQueue 的描述,网上有很多不一致的地方。有些人将其与 VRing(或 virtio 环)同义使用,而另一些人则单独描述它们。这是因为 VRing 是 VirtQueue 的主要功能,因为 VRing 是促进 VirtIO 设备和驱动程序之间数据传输的实际数据结构。我们将在这里单独描述它们,因为 VirtQueue 不仅仅是它的 VRing。

下面的图 3 显示了 Qemu 版本的 VirtQueue 和 VRing 数据结构。

VirtQueue+VRing结构图

图3

在Qemu的VirtIO框架中,我们可以清楚地看到VirtQueue数据结构与其VRing的数据结构之间的区别和关系(例如VRing、) a>)。、VRingDescVRingAvail``VRingUsed

除了 VRing 本身之外,VirtQueue 还便于使用各种标志、索引和处理程序(或回调函数),所有这些都以一种或另一种方式用于 VRing 操作。不过,需要注意的是,VirtQueue 的组织特定于来宾操作系统,以及我们讨论的是用户空间(例如 Qemu)还是内核 VirtIO 框架。此外,VirtQueue 的操作特定于 VirtIO 配置(例如拆分 VirtQueue 与打包 VirtQueue)。

例如,下图 4 显示了 Linux 内核版本的 VirtQueue 和 VRing 数据结构。

VirtQueue+VRing-结构图2

图4

将 Linux 内核的 VirtIO 框架与图 3 中的 Qemu 进行比较,我们可以清楚地看到它们组织上的差异。然而,由于 VirtIO 规范,我们也可以看到它们的 VRing 结构(描述、可用、使用)有相似之处。

目前,了解每个结构体的字段如何对 VirtQueue 和 VRing 操作做出贡献并不重要。这里的要点是要知道 VirtQueues 和 VRings 是两种不同的数据结构,VirtQueue 的组织会根据操作系统以及我们谈论的是用户态还是内核 VirtIO 框架而有所不同。

VR环:

正如我们刚才提到的,VRing 是 VirtQueue 的主要功能,也是保存实际传输数据的核心数据结构。它们被称为“环”的原因是因为它本质上是一个数组,一旦写入最后一个条目,它就会回绕到其自身的开头。这些 VRing 现在开始被称为“区域”,但由于 Qemu 仍然在其源代码中使用 VRing 术语,我们在这里将继续使用该名称。

每个 VirtQueue 最多可以有(通常有)三种类型的 VRing(或区域):

  • 描述符环(描述符区域)
  • 可用环(驱动区域)
  • 使用环(设备区域)

描述符环(描述符区域):

描述符环(或描述符表、描述符区域)本质上是一个描述符的循环数组描述符,其中描述符是一种数据结构,描述数据缓冲区。描述符保存有关其数据缓冲区的以下信息:

  • addr:客人实际地址
  • len:数据缓冲区的长度
  • flags:标志(NEXTWRITEINDIRECT
  • next:下一个链接描述符的索引(在描述环中)

标志通知设备或驱动程序 (a) 下一个描述符中是否有更多相关数据 (NEXT),(b) 该缓冲区是否为只写(设备可写)( WRITE),以及 (c) 缓冲区是否包含间接描述符表 (INDIRECT)?为了简单起见,我们不会在这里讨论间接描述符表。

对于 NEXT 标志,当当前描述符缓冲区中的数据继续进入“下一个”描述符缓冲区时,会设置此标志。当一个或多个描述符以这种方式链接在一起时,这称为“描述符链接”。 next 字段指的是下一个链式描述符的索引(在描述符环中)。关于描述符链值得注意的一件事是,它们可以由两者只写和只读描述符组成。

最后,只有驱动程序可以将描述符添加(写入)到描述符环,并且如果描述符的标志表明缓冲区可写,则设备只能写入设备可写缓冲区。缓冲区可以是只写的或只读的,但不能同时是两者。

描述符表

图5

在上面的图 5 中,我们可以看到一个具有四个描述符条目的描述符环,其中两个条目链接在一起。索引 [0] 处的第一个描述符条目告诉我们数据缓冲区位于 0x600 的 GPA(客户物理地址),它的数据缓冲区是 长度为 0x100 个字节,并且被标记为设备可写 (W)。我们知道该条目不是描述符链的头部,因为没有下一个 (N) 标志,并且下一个字段设置为 0

第二个描述符条目 ([1]) 告诉我们其数据缓冲区位于 GPA 0x810,数据缓冲区长度为 < a i=3> 字节,并标记为设备可写和下一个。由于下一个标志被提出,我们知道这个描述符是描述符链的头部。 字段告诉我们该链中的下一个描述符位于描述符环索引。0x200``next``[2]

第三个描述符条目 ([2]) 的数据缓冲区继续以 GPA 0xA10 继续存储 0x200 个字节,并且缓冲区也是设备可写的。由于没有引发下一个标志,描述符链在此结束。

最后,第四个描述符条目 ([3]) 告诉我们其数据缓冲区位于 GPA 0x525,数据缓冲区长度为 0x50 字节,并且没有标志(设备只读,无描述符链)。

请注意,在描述符环中,缓冲区的 GPA 和长度不得与另一个条目的内存范围重叠,并且缓冲区开头的 GPA下一个缓冲区不一定要高于前一个缓冲区(例如描述符条目[3])。

可用环(驾驶员区域):

可用环(或可用环、驱动区域)是描述符环中可用描述符的引用的循环数组。换句话说,可用环中的每个条目都指向描述符环中的一个描述符(或描述符链的头部)。

包括可用环数组在内,可用环还有以下字段:

  • flags:配置标志
  • idx:下一个可用的可用环条目的索引
  • ring[]:实际可用的环形阵列

标志字段表示可用环的配置及其一些操作。索引字段表示可用环中的下一个可用条目,驱动程序将在其中放置对描述符(或描述符链的头)的下一个引用。最后,ring 字段表示实际可用的环数组,其中驱动程序存储描述符环引用。

只有驱动程序可以配置并向可用环添加条目,而相应的设备只能从中读取。最初,在驱动程序将其第一个条目添加到可用环之前,没有标志的可用环将类似于下面的图 6:

可用环初始化

图6

在图 6 中,我们可以看到一个没有条目且没有设置标志的可用环。索引 (idx) 为 0,因为可用环数组的下一个可用条目为 ring[0]

现在,使用图 5 作为我们的描述符环,假设驱动程序在描述符环上添加(或使其可用)第一个描述符条目。可用的环将如下图 7 所示:

描述-可用-ring1

图7

在这里我们可以看到,驱动程序通过将描述符表的索引添加到第一个描述符条目,使第一个描述符条目可供设备使用。可用环中的可用条目 (ring[0])。我们还可以看到 idx 现在是 1,因为 ring[1] 现在是环上的下一个可用条目。在这种状态下,设备只能读取描述符环的第一个条目,而无法访问其他描述符。

现在假设驱动程序将下一个描述符条目添加到其可用环中。请注意,下一个描述符条目是描述符链的头部。可用的环将如图 8 所示:

描述符表+可用环2 图8

在这里,我们看到驱动程序使第二个第三个描述符条目可用(链接描述符)。现在 ring[1] 指向描述符链的头部,使设备可以访问其所有链式描述符。 idx 设置为 2,因为 ring[2] 现在是可用环上的下一个可用条目。

最后,假设驱动程序将下一个描述符条目添加到可用环中。现在可用的环如下图 9 所示:

描述-可用-ring3

图9

在这里,我们看到驱动程序通过将其描述符环的索引添加到可用环上的下一个可用条目 (ring[2]),使第四个描述符条目可供设备使用。请注意,在 ring[2] 中,描述符环的索引为 3。这是因为 ring[1] 包括描述符环索引 1 和 2。 2(链式)。最后,可用环的 idx 现在为 3,因为可用环上的下一个可用条目为 ring[3]

总之,驱动程序是唯一能够分别向描述符和可用环添加描述符条目和可用环条目的驱动程序。然而,设备无法访问此数据,直到驱动程序将相应的描述符环索引添加到可用环中。

使用过的环(设备区域):

已使用环(或设备区域)与可用环类似,不同之处在于它是对 已使用 描述符条目的引用的循环数组描述符环(即设备写入或读取描述符的数据缓冲区)。

以下字段组成了已使用的环:

  • flags:配置标志

  • idx:下一个可用的已使用环条目的索引

  • ring[]
    

    :实际使用的环形数组(数据对结构)

    • id:该元素引用的描述符环的索引
    • len:写入描述符缓冲区的数据长度

与可用环数组不同,使用环上的每个条目都是数据对(表示为“使用元素”结构),描述 (1) 的索引 (id)描述符环中的描述符(或链式描述符的头部)引用已使用(读取或写入)的缓冲区和 (2) 写入描述符缓冲区的总写入长度 (len) (或描述符链中所有缓冲区的总写入长度)。

与可用环类似,已用环也使用flagsidx字段。索引字段与可用环的索引字段相同,但对于已使用的环,它表示已使用的环数组中的下一个可用条目。

与可用环相反,只有设备可以配置并向已用环添加条目,而相应的驱动程序只能从中读取。

最初,在设备开始处理来自可用环的数据之前,使用的环(没有标志)将如下图 10 所示:

使用环初始化

图10

在这里,我们看到一个空的已用环,其中下一个可用的已用环索引 (idx) 设置为 0,对于 ring[0],这里没什么特别的。

现在让我们看看当设备处理完第一个可用环条目并向其使用的环添加一个条目时会发生什么(使用上面的图 10)。回想一下,第一个描述符的数据缓冲区被标记为设备可写,因此假设设备将 0x50 字节写入描述符的设备可写缓冲区。最终使用的戒指如下所示:

描述符表+可用环+已用环1

图11

在上面的图 11 中,我们可以看到所使用的环条目的数据对:0 | 0x500 (id) 表示设备在描述符环上使用(在本例中为写入)描述符的数据缓冲区和 0x50 (len) 是写入描述符数据缓冲区的总字节数。最后,已用环的 idx 设置为 1,因为它现在是已用环上的下一个可用条目。

让我们看看在设备处理第二个可用环条目后,已用环上的下一个条目看起来如何。回想一下,可用环的第二个条目指向描述符链,其中两个描述符都是设备可写的。我们还假设设备在第一个描述符的数据缓冲区中写入 0x200 字节,并在第二个描述符的数据缓冲区中写入 0x150 字节。最终使用的环如下图12所示:

描述符表+可用环+已用环2

图12

在这里,我们可以看到使用的环条目在给定写入了多个数据缓冲区的描述符链的情况下是什么样子。使用的环条目的索引总是指向单个描述符或描述符链的头部。在这种情况下,使用的ring[1]指向描述符环索引处的描述符链的头部1

描述符链所用环条目的长度表示写入每个链式描述符数据缓冲区的字节总数。由于设备将 0x200 字节写入第一个链接描述符的数据缓冲区,并将 0x150 字节写入第二个链接描述符的数据缓冲区,这意味着所有描述符的总写入长度链接描述符的数量将为 0x350

最后,让设备处理可用环的第三个条目并添加其对应的已用环条目。请注意,可用环的第三个条目指向没有标志的描述符,这意味着它是单个描述符,并且其数据缓冲区对于设备来说是只读的。使用的环如下图 13 所示:

描述符表+可用环+已用环3

图13

在这里,我们可以看到给定单个只读描述符时使用的环条目的外观。这里值得注意的是所使用的环条目数据对中的长度0x0。这里它的值为零只是因为没有向该缓冲区写入任何内容(对于设备来说是只读的)。最后,正如预期的那样,所用环条目中的索引为 3,因为描述符环的索引 2 是描述符链的一部分。

VR环总结:

在本节中,我们通过示例描述符环数据集介绍了描述符、可用和已使用的环。这里需要强调的是,上面的示例非常简单,没有显示通知的作用,也没有显示其他 VRing 配置(例如间接描述符、使用的缓冲区通知抑制、打包 VirtQueue 等)。但是,它确实显示了三个环背后的一般用途以及它们如何与设备和驱动程序一起工作。

总之,VRing 是访客与其主机之间交换数据的一种方式。在上面的示例中,我们看到了 VirtIO 驱动程序如何以可用环上的描述符引用的形式向设备发出请求,设备通过可用环访问请求,以及设备如何通过已使用的环向驱动程序提供已处理的请求。戒指。

在下一节中,我们将查看 Qemu 中的 VirtIO 设备示例,了解它如何使用 VirtQueue 以及 VirtQueue 如何使用 VRing。

荣誉奖:VHost

在本文中,我们不会深入了解 VHost,但任何开始了解 VirtIO 的人可能已经看到了提到的“VHost”术语。因此,对于那些还不熟悉它的人来说,值得在这里简单描述一下。

与 VirtIO 驱动程序不同对于数据平面存在于 Qemu 进程中的设备,VHost 可以将数据平面卸载到另一个主机用户进程(VHost-User)或主机的内核(VHost,作为内核模块)。这样做的动机是性能。也就是说,在如图 2 所示的纯 VirtIO 解决方案中,每次驱动程序请求主机在其物理硬件上执行某些处理时,都会发生上下文切换。这些上下文切换是昂贵的操作,会在请求之间增加大量延迟。通过将数据平面卸载到另一个主机用户进程或其内核,我们基本上绕过了 Qemu 进程,从而通过减少延迟来提高性能。

然而,虽然它确实提高了性能,但考虑到数据路径现在直接进入主机内核,因此也增加了一定程度的安全问题。

下面的图 14 显示了示例 VHost (VHost-SCSI) 的总体布局:

虚拟主机架构

图14

如果我们将图 14 中的 VHost 总体框架与图 2 中的纯 VirtIO 框架进行比较,我们会发现一些关键差异:

  • 传输层(或数据平面)现在是从来宾内核到主机内核
  • VirtIO 设备模型 存在,但其功能仅限于处理控制平面任务
  • 主机内核中存在VHost-SCSI内核模块

这只是 VHost 配置和纯 VirtIO 配置之间组织的概括。关于 VHost 和 VHost 还有更多可说的。 VHost-User 及其带来的功能。然而,在本文中,我们将仅关注类似于图 2 的纯 VirtIO 实现。

Qemu 中的 VirtIO

在本节中,我们将看一下 Qemu 中的 VirtIO 设备示例,并了解其中一个 VirtQueue 的工作原理。这里的目标不仅是了解 VirtIO 设备的大致工作原理,而且还了解 VirtQueue 和 VRing 在标准 VirtIO 设备中的作用。对于我们的示例设备,让我们看一下使用拆分 VirtQueue 配置和协商的 VIRTIO_VRING_F_EVENT_IDX 功能位的 virtio-SCSI。

Virtio-SCSI:

virtio-SCSI 设备用于将虚拟逻辑单元(例如硬盘驱动器)分组在一起,并允许通过 SCSI 协议与它们进行通信。对于我们的示例,假设我们仅使用该设备连接到硬盘驱动器。该设备(使用 HDD)的 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,我们可以看到与设备操作相关的各种功能。让我们看一下这个设备是如何设置的,特别是它的 VirtQueues。

在 Qemu 中,术语“realize”用于表示 VirtIO 设备的初始设置和配置(“unrealize”用于表示拆除设备)。在函数virtio_scsi_common_realize()中,我们可以看到为virtio-SCSI设备设置了三种不同类型的VirtQueue:

复制代码片段

已复制到剪贴板

错误:无法复制

已复制到剪贴板

错误:无法复制

// 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 设备都会有多个 VirtQueue,每个 VirtQueue 都有自己独特的功能。在 virtio-SCSI 情况下,我们有一个控制 VirtQueue (ctrl_vq)、一个事件 VirtQueue (event_vq) 和一个或多个命令(或请求)VirtQueues ( cmd_vqs)。

控件 VirtQueue 用于任务管理功能 (TMF),例如启动、关闭、重置 virtio-SCSI 设备等。它还用于订阅和查询异步通知。

事件 VirtQueue 用于报告来自连接到 virtio-SCSI 的逻辑单元上的主机的信息(事件)。这些事件包括传输事件(例如设备重置、重新扫描、热插拔等)、异步通知和逻辑单元号 (LUN) 参数更改。

最后,命令或请求 VirtQueues 用于典型的 SCSI 传输命令(例如,读写文件)。在本节中,我们将重点关注 VirtQueue 命令的操作,因为与其他两个命令相比,它更有趣并且使用得更多。

命令 VirtQueue:

命令(或请求)VirtQueue 是我们本节将重点关注的 VirtQueue。 VirtQueue 用于传输有关典型 SCSI 传输命令(例如读取和写入文件)的信息。 Virtio-SCSI 可以有一个或多个这样的命令 VirtQueue。

如前所述,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

回想一下图 2,我们正在查看 Qemu 的源代码,更具体地说是 VirtIO 设备代码。还记得前面的 VRing 部分,VirtIO 设备不会开始参与 VRing 操作,直到其相应的 VirtIO 驱动程序 (1) 将新描述符添加到描述符环,(2) 通过添加描述符引用使这些描述符可供设备使用(3) 通知其设备可用环已准备好进行处理。

换句话说,当执行到virtio_scsi_handle_cmd()函数时,意味着virtio-SCSI设备已经收到了来自其驱动程序的通知,并开始开始处理来自其命令VirtQueue的数据可用环。您可以认为可用环和描述符环的当前状态类似于图 9。

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将如何处理和处理其可用环上的数据。

对于以下场景,请回想一下VIRTIO_VRING_F_EVENT_IDX 功能位已协商。对于设备来说,这意味着只有当 使用的环中的idx等于位于可用环中。idx

换句话说,如果驱动程序在可用环上提供从索引 0 到 19 的 20 个条目,则可用环的 idx 将在 20 之后将最后一个描述符引用添加到可用的ring[19]。设备处理完最后一个可用环条目并将其对应的已使用环条目放在已使用的ring[19]上后,已使用环的idx也将是20.当发生这种情况时,几乎在添加最后使用的环条目之后,设备必须立即通知其驱动程序。

virtio_scsi_handle_cmd_vq()开始执行之前,假设其命令 VirtQueue 的描述符和可用环如下图 15 所示:

Virtio-scsi-ex1

图15

回顾一下virtio_scsi_handle_cmd_vq(),让我们浏览一下这个函数,看看 virtio-SCSI 设备如何处理刚刚收到通知的数据。

首先,在函数的开头,初始化 virtio-SCSI 请求 (VirtIOSCSIReq) 的队列数据结构,称为 reqs

复制代码片段

已复制到剪贴板

错误:无法复制

已复制到剪贴板

错误:无法复制

QTAILQ_HEAD(, VirtIOSCSIReq) reqs = QTAILQ_HEAD_INITIALIZER(reqs);

对于 virtio-SCSI 的命令 VirtQueue,其可用环上的每个条目都被制作成一个 VirtIOSCSIReq 对象,该对象附加在 reqs 的末尾队列。我们可以看到紧随其后的 do-while 循环就是这种情况:

复制代码片段

已复制到剪贴板

错误:无法复制

已复制到剪贴板

错误:无法复制

// 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 循环中,我们将遍历可用环,对于每个条目,我们将其数据放入 VirtIOSCSIReq 对象中。然后,每个 VirtIOSCSIReq 对象都会附加到 reqs 队列的末尾。

在 while 循环结束时,我们将得到如下图 16 所示的结果,其中 Req1 指的是通过读取可用环条目创建的 VirtIOSCSIReq 对象< /span>,类似:来自可用环条目对象是读取可用环条目ring[0]Req2``VirtIOSCSIReq``ring[1]``Req3``ring[2]

Virtio-scsi-ex2

图16

从可用环中读取所有数据后,我们会重新启用通知,以便设备在处理完请求并将其放入已用环上后可以通知驱动程序已使用的数据。请注意,这仅启用通知,而不发送通知。在我们的场景中(使用 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(),最后是 < a i=3>.这三个函数中更有趣的是,因为这是设备将使用过的数据放入使用过的环上的函数。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 的 VRing 将如下图 17 所示:

Virtio-scsi-ex3

图17

第一个请求引用设备只读缓冲区,因此描述符索引为 0,写入长度为 0。idx 也递增到 1virtqueue_push()之后,我们会检查是否应该通知司机。

回想一下,我们的设备正在使用VIRTIO_VRING_F_EVENT_IDX 功能。在我们的示例中,一旦我们使用的环idx3,设备就会通知驱动程序。因此,对于此请求,不会向驱动程序发送任何通知。

接下来的两个请求涉及设备可写缓冲区。为了保持一致性,假设 Req2 的书写长度为 0x200Req3 的书写长度为 0x500。从 virtqueue_push() 返回 Req3 后,所使用的环将如下图 18 所示:

Virtio-scsi-ex4

图18

现在设备通知驱动程序所使用的环的内容,因为设备功能的条件VIRTIO_VRING_F_EVENT_IDX 已满足。也就是说,已用环的idx等于可用环的idx。一旦驱动程序收到通知,驱动程序就会进入已使用的环,在描述符环上查找已处理的数据,并对这些数据执行任何它想要的操作。

在通知驱动程序并进行一些清理工作之后,virtio-SCSI 设备的工作就完成了,并返回等待驱动程序通知它要在其可用环上处理的新数据。

VirtIO 总结

在本文中,我们讨论了 VirtIO 是什么、为什么我们应该关心它、它的替代方案(例如仿真)以及设备和虚拟机等关键概念。驱动程序、VirtQueue 和 VRing。然后,我们查看了一个标准 VirtIO 设备(在 Qemu 中),并跟踪其中一个 VirtQueue 的执行情况,以推断 VirtQueue 和 VRing 在 VirtIO 设备中的作用。

应该指出的是,本文仅仅涉及 VirtIO 的表面,因为我们只介绍了最基本的知识并使用了简单的示例。在我们的示例中,我们仅假设了拆分 VirtQueue 配置,并且仅使用了 VIRTIO_RING_F_EVENT_IDX 功能位。例如,我们没有详细介绍通知、VirtIO 驱动程序端代码(在内核中)或修改 VirtQueue 及其 VRing 操作的其他功能(例如“打包”VirtQueue、间接描述符、有序描述符使用) 、SR-IOV 等)。

无论如何,本文应该创建一个扎实的 VirtIO 实用知识,以便更容易学习其他 VirtIO 设备或概念。

参考

https://blogs.oracle.com/linux/post/introduction-to-virtio

乔纳·帕尔默

晓兵

博客: https://logread.cn | https://blog.csdn.net/ssbandjl | https://cloud.tencent.com/developer/user/5060293/articles

weixin: ssbandjl

公众号: 云原生云

云原生云