使用 VFIO 对 NVMe 设备进行「传输层」测试

标题:Transport-level Testing of NVMe Devices using VFIO

日期:2021/11/12

作者:Klaus Jensen

链接:https://www.youtube.com/watch?v=e1daFS9cKu0

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。

备注:「传输层」太玄学了,其实就是中断和控制器命令支持。这篇文章对我很有价值。


大家好,欢迎收听我关于使用 VFIO 对 NVMe 设备进行传输层测试的演讲。我叫 Klaus,是三星电子的一名软件工程师。我的日常工作主要围绕开源软件展开。我是 QEMU 模拟 NVMe 设备的联合维护者之一,同时也活跃在开源 NVMe 生态系统的其他各个领域。

今天我们将讨论 VFIO。为了理解我们将要使用的技术,我们需要先了解一些 NVMe 和 PCI Express 的底层知识。然后,我们会稍微谈谈 IOMMU 及其工作原理,以及 VFIO 内核框架。我们将利用这些知识,尝试在不到 30 分钟内编写一个驱动程序,这个驱动程序将能够向 NVMe 设备提交底层的命令。我们还将看到如何与控制器内存缓冲区(Controller Memory Buffer)进行交互,它也是一个基于 PCI Express 的设备。

NVMe 概览

首先,用一张幻灯片来介绍 NVMe。NVMe 是 Non-Volatile Memory Express(非易失性内存主机控制器接口规范)的缩写,它是一种存储接口,旨在充分利用 NAND 闪存的低延迟和内在并行性。

这里有几个核心术语:

  • 控制器 (Controller):基本上就是一个 PCI Express 功能(function),作为主机与设备之间的接口。

  • 命令队列 (Command Queues):这是让 NVMe 高效工作的核心特性。主机会将描述命令的“提交队列条目”写入一个专用的“提交队列”中。而控制器则会将“完成队列条目”写入另一个专用的“完成队列”中。

虽然这听起来可能很简单,但我们无法用一张幻灯片就讲完所有内容。我们需要更多的信息,因为在本次演讲结束时,我们实际上会拥有一个初级的 NVMe 驱动程序。因此,我们将专注于核心概念,包括:队列、提交和完成队列条目、PCI 门铃(doorbells)以及核心控制器配置。

我们只会讨论一个 NVMe 命令——identify 命令,它可以告诉主机关于控制器的各种信息。这意味着我们将跳过所有其他命令,比如 I/O 命令。如果您感兴趣,可以在演讲结束后查阅 NVMe 规范。我们也不会讨论特定的命名空间类型,如标准的 NVM 命名空间类型和分区命名空间(Zoned Namespace)类型。如果您对后者感兴趣,我建议您观看我去年在开源峰会上的演讲,那次演讲专门讨论了在 QEMU 中实现分区命名空间。

NVMe 底层机制

让我们首先看一下实际的提交队列条目 (Submission Queue Entry)。它是一个 64 字节的数据结构,描述了控制器执行一个命令所需的所有信息。这里面有很多字段,但在本次演讲中,我们只关心其中的三个:

  1. 命令标识符 (Command Identifier):一个由主机选择的、用于唯一标识某个命令的 ID。

  2. 操作码 (Opcode):它与命令提交到的具体提交队列一起,唯一地确定了控制器应该执行的命令。

  3. 数据指针 (Data Pointer):描述了与该命令关联的数据负载(payload)的位置。

数据指针本身是 16 字节,但它实际上由两个 64 位的内存地址组成,我们称之为“物理区域页”(Physical Region Pages, PRP)。这些地址总是指向大小与主机操作系统页面大小相同的内存块。

作为 NVMe 核心的队列,基本上是由两个指针定义的循环缓冲区:

  • 尾指针 (Tail Pointer):当我们向队列中添加内容时,该指针会递增。

  • 头指针 (Head Pointer):当我们从队列中移除或读取条目时,该指针会递增。

如下图所示,我们有一个队列,其中包含三个条目,而读取者还没有读取任何一个。所以头指针指向第一个位置。为了让生产者或写入者知道下一个条目应该写在哪里,我们有一个尾指针,它指向队列中下一个空闲的槽位。

(注:此处为演讲中可能出现的图示的文字描述)

通过这种方式,队列是无锁的,因为读取者永远不会读取超过尾指针的位置,而写入者也永远不会写入超过头指针的位置。主机和控制器都可以充当队列的读取者和写入者,具体取决于队列的类型。例如,对于提交队列,主机是写入者,控制器是读取者;而对于完成队列,情况则正好相反。

这些队列通常分配在主系统内存中。这意味着从主机的角度来看,写入一个命令或读取一个完成条目就像一次内存拷贝一样简单。

这就引出了一些问题:

  1. 控制器或设备究竟是如何知道在哪里找到这些队列的?

  2. 当我们向队列写入命令后,如何让设备去获取并执行这些命令?

  3. 我们如何知道命令是否被执行,以及它究竟是在何时被执行的?

为了配置设备并告知其不同信息,PCI 设备可以暴露一组配置寄存器。我们称之为 PCI 配置空间 (PCI Configuration Space),它被映射到主机的内存地址空间中。这意味着主机可以访问这个地址空间来配置 PCI 设备,并读取关于该设备的各种信息。

设备还可以暴露一些内存区域,以供映射到主机地址空间。我们称之为基地址寄存器 (Base Address Registers, BARs)。它们包含一个由主机配置的地址,这个地址告诉设备在哪个位置可以读取设备暴露的那些配置寄存器。请注意,这些寄存器与 PCI 配置寄存器不同,它们是特定于设备类型(比如 NVMe)的自定义寄存器。

在 NVMe 的情况下,设备会暴露一个称为内存基地址寄存器 (Memory Base Address Register, M bar) 的内存区域。这个内存位置包含了基本的控制器配置信息,并用于主机与设备之间的基本通信。

以表格形式来看,它大概是这个样子:

  • Controller Capabilities Register: 一个只读寄存器,设备通过它告诉主机它所支持的各种能力和特性。

  • Version Register: 告诉主机设备实现了哪个版本的 NVMe 规范。

  • Controller Configuration Registers: 主机可以通过这些寄存器以特定的方式和参数来设置设备,以使其正常工作。

  • Admin Submission Queue Base AddressAdmin Completion Queue Base Address: 这两个是特殊的寄存器,它们告诉设备从哪里获取管理命令(admin commands),以及将这些命令的完成条目发布到哪里。

现在我们知道了如何通过配置这些控制器配置寄存器来基本地引导设备。那么,我们如何让设备真正去获取并执行命令呢?

为此,我们使用一种叫做 PCI 门铃 (PCI Doorbell) 的机制。PCI 门铃只是对一种只写的内存映射寄存器的通用称呼。我们虽然可以从它们那里读取,但读取行为是未定义的。所以从主机的角度看,我们只会写入这些寄存器。

正如你所见,它们也位于设备的 M bar(或第一个 BAR)中,起始地址是内存 BAR 地址加上 4K(十六进制的 1000)。尾部门铃(tail doorbells)是 4 字节宽的,它们有两种形式:尾部门铃和头部门铃(head doorbell),我们稍后会看到它们各自的用途。

当我们写入这个寄存器时,我们称之为“敲响门铃”(ringing the doorbell)。具体做法是:当主机向提交队列写入一个或多个条目后,我们可以“踢”一下设备,告诉它“现在你需要开始执行任务了”。我们通过将新的尾指针值写入这个寄存器来实现这一点。

// 伪代码
write_to_memory(doorbell_address, new_tail_pointer_value);

这个值告诉设备:“我已经将条目插入到队列中这个位置了,你现在可以从你上次停留的位置开始获取它们。” 在这个例子中,它告诉设备可以获取并执行条目 0、1 和 2。

那么当队列满了会发生什么呢?当主机产生的提交队列条目足够多,以至于尾指针(在这个例子中)到达了 7,队列中没有更多空间时。这时,控制器必须以某种方式通知主机它已经执行了这些命令,以便主机可以重用队列中的这些槽位。需要注意的是,主机有责任不去覆盖那些尚未被控制器取走的条目。

要理解这一点,我们必须看看 NVMe 是如何将这些信息“捎带”在完成队列条目中的。

具体来说,我们如何知道一个命令是否被执行了?我们使用完成队列条目 (Completion Queue Entries)。每当控制器完成一个命令的执行时,它就会发布(写入)一个完成队列条目。完成队列条目是 16 字节,比提交条目小,但它包含了足够的信息让主机了解命令的状态:是成功了?还是控制器遇到了某种错误?也许它不理解我们请求的操作码?或者遇到了内存错误之类的。

它总是会包含完整的命令标识符,主机用它来关联对应的命令。它还包含了我们最初提交该命令的提交队列的标识。并且,它还包含了一个新的头指针值。这个值是控制器内部维护的头指针的新值,它告诉主机:“我现在已经执行完这些命令了,所以你可以重用这些槽位了。”

举个例子,当控制器捎带信息并给出一个新的头指针位置为 4 时,主机就会知道它现在可以重用条目 0、1、2 和 3 来存放新的命令了。

那么,我们如何知道这些命令是何时被执行的呢?由于没有门铃寄存器可以被敲响来通知主机完成队列中有新条目,我们有两种选择:

  1. 轮询 (Polling):主机可以简单地持续读取完成队列的内存位置,等待一个叫做“阶段变化”(phase change)的事件。

  2. 中断 (Interrupts):我们也可以依赖控制器生成的中断,或者将两者结合起来。

首先让我们看看轮询和“阶段变化”是什么意思。在完成队列条目中有一个特殊的位,叫做阶段位 (Phase Bit)。每当控制器写入或覆盖一个已有的完成队列条目时,它就会反转这个阶段位。

当我们开始操作一个完全空的队列时,所有条目的阶段位都是 0。主机可以持续读取内存位置(比如队列的尾部),直到阶段位发生变化。当控制器写入新条目时,它会反转这个阶段位,使其变为 1。主机会立即注意到这个变化,并知道这是一个新的条目。然后它会推进自己的尾部位置,并检查下一个条目。如果下一个条目的阶段位没有改变,我们就知道那不是一个新条目,于是停止轮询或等待下一个新条目的到来。

虽然轮询可以实现非常低的延迟,但它通常需要牺牲一整个 CPU核心来持续地做这件事。

另一种方式是依赖中断,这也是使用 NVMe 设备的标准方式。在一个基于中断的系统中,我们依赖控制器生成一个中断,这个中断基本上是在说:“有事情发生了。”因为中断本身除了“有事”之外不携带任何信息,所以你需要采取行动。根据配置和设备能力的不同,中断可能指示某个特定的完成队列中有新内容,或者仅仅指示某个完成队列中有新内容。

因此,我们仍然需要使用阶段位。因为当我们收到中断时,我们并不知道队列中到底有多少个新的完成队列条目。所以我们仍然需要持续读取完成队列,直到我们再次看到阶段位的变化为止。

最后,我们需要一种方式让主机能够告知控制器它已经读取了某个完成队列条目,以便控制器可以重用完成队列中的这个槽位。因为控制器也受到同样的限制,它不能覆盖那些尚未被主机确认的条目。

我们通过另一个门铃来实现这一点,我们称之为头部门铃 (Head Doorbell),它也是一个在 BAR 中的寄存器。基本上,发生的事情是我们告诉设备我们所维护的头指针的新位置。但请注意,内存中的条目并不会被清零或清除,它仍然保留在原位,这意味着阶段位的值也保留在原位。当下一次我们绕着循环队列回到这个位置时,控制器会再次反转阶段位,我们就能注意到这是一个新条目了。

好了,这就是我们所需要的 NVMe 底层知识。现在进入第二部分,我们将看看 VFIO、IOMMU,以及如何实际使用这些知识来手动操作一个设备。


第二部分:VFIO, IOMMU 与实践

我们刚才所讲的,基本上就是内核驱动程序或用户空间框架为你所做的事情。所以,你不需要为了用 NVMe 设备做一些很棒的事情而去操心这些细节。你可以使用操作系统的块层抽象,结合像 io_uring 这样的高性能异步编程模型,或者像 XNVMe 这样更高层的框架。或者,你也可以使用像 SPDK 这样的用户空间框架来获得更多的控制权。

但是,无论是 XNVMe 的透传模式,还是 SPDK,都不允许在真正的传输层 (transport level) 进行操作。这些框架的所有抽象都停留在命令层 (command level)。这意味着,如果你看它们的 API,SPDK 最底层的命令是关于写入一个管理命令或 I/O 命令,然后就是简单地等待和处理完成。这在 XNVMe 中也是类似的,你可以向设备透传一个命令,然后等待并查看完成队列。

这意味着你无法直接操纵队列。其中一个限制是,XNVMe 和 SPDK 都依赖于一个概念,即提交队列和完成队列总是一一对应的。但在 NVMe 规范中,你可以有一个多对一的关系,即多个提交队列对应一个完成队列。这在这些框架中是不支持的。

你也没有对中断向量配置的具体控制权。我相信 SPDK 总是会尝试附加… 实际上 SPDK 不使用中断向量,它对所有队列都使用轮询。但是你无法设置一组完成队列使用一个中断向量,而另一组每个队列使用一个向量,诸如此类。而且,在这些框架中,你基本上也完全没有底层控制器配置的能力。

这使得它们在对设备进行真正底层的检查和测试,以及测试设备中的代码路径方面,不那么适用。

那么,如果我们真的想做一些我们在第一部分学到的事情,我们该如何着手呢?

  1. 我们可以修改 Linux 中的标准 NVMe 内核驱动程序,来做一些自定义的事情。比如添加一个自定义的 ioctl 来执行特定的测试。

  2. 或者,我们可以干脆禁用内核的所有安全特性,以 root 身份为所欲为,通过 pci-generic 驱动程序来做这件事。

  3. 或者,我们可以利用虚拟功能 I/O (Virtual Function I/O, VFIO) 框架来做这类自定义的事情,而且是以一种非常安全的方式。

VFIO 框架

我们到底能做到多底层呢?问题在于,在寄存器层面与设备交互传统上是内核的工作。这是一种非常特权化的活动,专为内核和内核黑客保留。但是 VFIO 框架在某种程度上改变了这一点。

VFIO 本身是一个驱动框架,而 vfio-pci 是一个驱动程序,PCI 设备可以附加或绑定到这个驱动上,而不是像 NVMe 这样的常规驱动。这里的关键是,内核保留了对设备的控制权。它仍然负责管理设备,保护设备免受用户空间的影响。但是,vfio-pci 驱动程序提供了从用户空间对该设备的完全访问权限。这意味着我们实际上可以读写 PCI 配置空间,以及读写 BARs。它还给了我们非常精细的中断控制,可以配置传统的基于引脚(pin-based)的中断,或者 MSI 和 MSI-X 配置。

这一切都依赖于基于 IOMMU (I/O Memory Management Unit) 的 DMA 转换。这使得驱动程序能够限制用户实际能编程设备去做的行为,从而确保安全。它还将设备的 DMA 操作限制在用户空间进程的活动空间内。这一点至关重要,因为传统上 I/O 设备是使用物理地址工作的,所以你基本上可以指示设备向另一个可能属于其他进程的物理地址执行 DMA。

但是,IOMMU 在 I/O 设备和 CPU 之间提供了一层转换。它基本上做了一件我们称之为添加“I/O 虚拟地址”(IO Virtual Addresses, IOVA)的事情,这有点像虚拟地址,但它是为 I/O 设备准备的虚拟地址空间,这些地址会映射到可以访问系统内存的物理地址。所以它基本上就像内存管理单元(MMU)为 CPU 操作普通虚拟内存所做的事情一样。

从图表中我们可以看到,在一台 IOMMU 后面有一堆 I/O 设备,它们都使用 IOVA 工作。我们实际上可以在 IOMMU 后面有多个 I/O 设备。在这种情况下,这些 I/O 设备理论上(取决于主板的布线方式)可以相互交互。所以我们仍然可能有一个设备通过 DMA 读写另一个设备。

因此,VFIO 框架和内核中的 IOMMU 框架有一个IOMMU 组 (IOMMU Group) 的概念,这基本上是我们能确保安全的最小粒度。对于一个完全隔离的 I/O 设备,它需要位于一个单独的组中,没有其他 I/O 设备。否则,我们就必须接受安全或隔离的粒度是在这个组的层面上。

使用 VFIO

要使用 VFIO,涉及到大量的样板代码。VFIO 使用一个叫做容器 (Container) 的概念。当你开始使用 VFIO 时,第一件事就是创建这个容器。然后你可以验证 VFIO 的能力,比如内核支持的 API 版本是多少?是否真的有我们可以使用的 IOMMU?

接着,我们必须确定设备的 IOMMU 组,并验证这个组是“可行的”(viable)。“可行”意味着组中的每个设备要么没有绑定任何驱动,要么全部都绑定到了 vfio-pci 驱动。当组被确定为可行后,我们就可以将它添加到容器中。很酷的一点是,因为我们可以确保不同组之间的交互总是通过 IOMMU,所以我们可以向同一个容器中添加更多的组,并独立地与它们一起工作。

当所有这些都完成后,我们就可以启用 IOMMU,并获取 IOVA 可以使用的地址范围列表。最后,我们就可以真正地打开设备,获得一个设备句柄(它是一个文件描述符),然后开始配置它。这意味着我们可以设置内存区域、PCI 配置空间、我们感兴趣的任何 BARs,以及配置设备的中断。

来自内核的用户空间 API 非常精简,但也为未来的特性和能力提供了极大的可扩展性。当你直接使用它时,它看起来是这样的,意味着它是一堆 ioctl 调用。例如,这里有一个查询组状态的例子,我们可以检查它是否可行。

// 伪代码: 使用 ioctl 与 VFIO 交互
struct vfio_group_status group_status = { .argsz = sizeof(group_status) };
ioctl(group_fd, VFIO_GROUP_GET_STATUS, &group_status);
if (!(group_status.flags & VFIO_GROUP_FLAGS_VIABLE)) {
    // 组不可用
}

这里还有一个设置中断的例子。如你所见,你设置一个特定大小的数据结构,设置一堆字段,然后你发出另一个 ioctl 来为设备实际配置它。

所有这些都与 PCI 无关,它是对使用这个通用框架的设备的抽象。内核网站上有关于 VFIO 框架的相当不错的文档,但在 QEMU 的源代码中也有很多很好的代码。QEMU 源代码对设备透传有广泛的支持,这意味着在主机上解绑一个设备,然后基本上将它绑定或透传给虚拟机。在这里发生的是,虚拟机或 hypervisor 变成了一个管理该设备并将其传递给客户操作系统的用户空间驱动。这在 QEMU 中是由 VFIO 子系统完成的。

QEMU 还有一个用于将原始 NVMe 设备用作块存储的 NVMe 驱动,它也是基于 VFIO 的,所以里面有很多好东西。VFIO 辅助库(我们称之为库,但它在 QEMU 中是一个对象)包含了一堆非常有用的辅助函数来处理这些。总的来说,QEMU 中有大量优秀的代码可供学习和研究,以更深入地理解其工作原理。

但我们缺少一样东西:没有 libvfio。没有一个用户空间库能够真正地将 QEMU 代码中所有这些最佳实践和实用函数统一起来。

一个新的 VFIO 库

因此,我一直在开发一个库,目前在我的本地仓库中就叫做 vfio。它是一组用 C 语言编写的实用库,用于处理 VFIO 用户 API。该库包含用于执行基本 VFIO 设备初始化的函数,也就是那些样板代码。它还包括一个极其简单、朴素的所谓“固定 IOVA 分配器”,它只帮助你分配 IOVA,但你不能释放它们。你只能分配新的,分配器只是确保你不会重用这些虚拟地址。所以,在某种意义上,虽然这可以给你未使用的 IOVA,你仍然需要自己管理它们。但它是基于插件的,所以如果你想要一个更高级的分配器,你可以自己写一个。

此外还有一堆实用函数,用于进行可移植的、字节序正确的 I/O。有用于设置中断配置的实用函数,还有用于进行内存 DMA 映射的实用函数。

所以,我之前谈到的所有样板代码,基本上可以通过一个 API 调用完成:

// 伪代码
vfio_pci_init(pci_id, &state);

它会从一个 PCI ID 初始化一个 vfio_pci_state。然后还有另一个调用来配置 IRQ,基本上是设置一个 Linux 内核的 eventfd 并将其与设备上的特定中断向量匹配。

这一切都计划开源。我还在开发中,但我的目标是在今年第三季度之前将其开源。

除了基本的 VFIO 功能,它还包含了一堆 NVMe 特定的功能,因为这是我的工作内容。它为此提供了一堆实用函数,比如初始化控制器、重置控制器、启用控制器,以及创建用于实际 I/O 的 I/O 队列对。

它还具有非常底层的命令提交功能。其核心 API 处于以下级别:

  • post:将命令发布到提交队列。

  • kick:踢一下设备,即写入门铃或“敲响门铃”。

  • peak:窥探完成队列中的顶部条目。

  • acknowledge:确认完成队列中的所有条目。

它还有一些中等级别的便利函数,比如 post_kick_wait_acknowledge 组合命令,它基本上允许你同步地提交一个命令并等待其完成,同时给你一个指向完成队列条目的引用。

实验环境:使用 QEMU

当我们想尝试模拟所有这些东西时,我们可以使用 QEMU,这是一个非常好的实验平台,因为我们需要一个 IOMMU。大多数工作站已经有了这个,但在虚拟环境中工作会很方便。

设置这个需要你用 Q35 机器类型配置 QEMU,并且你需要启用内核 IOMMU 芯片的分离模式。然后你需要添加 Intel IOMMU 设备,即虚拟 IOMMU。之后,像往常一样,你添加你的 NVMe 控制器和命名空间,然后设置你的常规启动驱动器,配置 CPU 类型、内存大小、网络等等。

当你把这一切都准备好并运行时,使用这个库的第一件事就是在 PCI 级别上初始化设备。你通过 vfio_pci_init 调用来完成。在那之后,我们现在可以映射控制器寄存器了。

在这种情况下,我们感兴趣的是映射我们之前谈到的 M bar 或第一个 BAR (bar0)。我们分别映射控制器寄存器和门铃。如下所示,我们映射 bar0 的前 4K 字节,这是控制器寄存器。然后我们映射门铃,它们是 BAR 上的接下来 4K 字节。

映射完成后,我们基本上可以重置设备,这意味着我们向控制器配置寄存器写入一个特定的值,设备就会重置并准备好使用。

然后我们分配一些内存。我们使用 mmap 分配常规虚拟内存,这保证了我们得到一块页面对齐的内存。然后我们分配一些 IOVA。这是我们自己选择的,因为我们控制着映射。在这种情况下,我们只选择空地址(null address)。它是一个有效的地址。这实际上在 QEMU NVMe 设备中发现了一个 bug,因为它不接受地址零作为管理队列等的地址。但零地址是一个有效的主机地址,所以没问题。

然后我们调用 vfio_dma_map 函数,它将虚拟地址映射到 IOVA。从那时起,我们就可以在给设备的命令中使用这个 IOVA 了。

正如我们所学,我们需要用管理队列来引导设备。所以,我们分配两个 IOVA,这里是地址 01000h。我们为管理队列 mmap 空间,然后映射它们。接下来,我们告诉设备这些队列的大小,通过写入管理队列属性寄存器来实现。然后,我们通过管理提交队列地址寄存器和管理完成队列地址寄存器,告知控制器队列的实际地址。

我们设置一个 eventfd,并将其分配给中断向量 0,它总是对应于管理队列。当所有这些都完成后,我们就可以启用控制器,即写入 CC (Controller Configuration) 寄存器。从现在开始,控制器就可以使用了。

实践:执行一个 identify 命令

让我们尝试执行一个 identify 命令来获取一些关于设备的信息。

  1. 分配数据缓冲区:再次,我们分配一定大小的内存。在这种情况下,设备返回的 identify 数据结构是 4KB 大小。我们再次选择一个任意的虚拟地址,然后映射它。

  2. 设置命令:我们需要一个可用的数据结构,它实际上映射到我们在演讲开始时看到的提交队列条目。我们设置它:设置操作码(opcode),选择一个命令 ID(cid),然后设置数据指针(dptr)。在这种情况下,我们只使用数据指针中的第一个地址,并将其设置为分配的 IOVA。对于 identify 命令,还有一个叫做“控制器/命名空间选择”的字段,用于选择子命令。在这里,我们请求设备提供关于控制器的一般信息。

  3. 写入命令:我们简单地使用 memcpy,将其写入我们作为用户空间进程看到的虚拟地址空间,在提交队列地址的当前尾部位置。

  4. 更新尾指针和敲门铃:我们更新尾指针(注意循环队列的回绕),然后最后一步是写入门铃。我们再次使用实用函数来向门铃地址进行 32 位的内存映射 I/O 写入,写入新的尾指针值。

当这发生时,控制器会接收到命令并执行它,并在完成后生成一个中断。

  1. 等待和处理完成:我们只需从 eventfd 读取,这是一个阻塞的文件 I/O。所以我们会在这里阻塞,直到我们真正收到中断。一旦有了中断,我们就可以读取完成了。这意味着我们从管理完成队列的当前头部位置开始读取,并等待阶段位的变化。当我们读取时,我们不断增加我们内部维护的头指针值。当阶段位再次改变时,我们就知道我们处理完了。

  2. 确认完成:最后一步是,我们通知控制器我们已经确认并读取了完成队列条目,我们通过将新的头指针值写回到完成队列头部门铃来实现。

现场演示

现在让我们看一个实际的演示,看看这是如何工作的。

演示环节描述

这里我已经像之前描述的那样启动了一个虚拟机。这边是我们 QEMU 的跟踪日志输出,它让我们能看到模拟设备上到底发生了什么。那边是我的 VFIO 终端。

首先,我们检查一下我们确实有设备。如这里所见,我们有一个 NVMe 控制器设备,一个 QEMU 模拟的 NVMe 设备,当前包含两个命名空间。我们现在不关心这个。

如果我们看一下 lspci,我们会看到控制器在这里。我们要做的第一件事是,将设备从 NVMe 驱动解绑,然后将它绑定到 vfio-pci 驱动。如你所见,VFIO 加载了。实际上发生的是控制器被关闭,并处于一种干净的状态。当我们解绑它时,内核会很好地关闭设备。

现在我们可以尝试运行这个 identify 示例了。看一下实际的代码,这个示例要做的是初始化 PCI 设备,从寄存器中打印一些东西,比如设备版本。我们会映射一些内存,使用分配器保留一个 IOVA,然后映射那个虚拟地址。然后我们会设置 identify 命令,并使用便利函数来提交、踢、等待和确认命令。最后,当命令执行完毕,我们会看看设备实际响应了什么,并从中打印一些信息,比如设备的厂商 ID。

运行这个,我们看到一堆调试信息。但这边跟踪日志里的内容才是最有趣的。我们看到库正在配置设备,特别是配置管理完成队列和提交队列的地址,最后启用了控制器。然后我们看到我们的应用程序正在写入提交队列门铃。控制器接收到这个,注意到它是一个 identify 命令,执行它,映射我们分配的地址(这里是 2000h),并将数据写回。然后它将一个完成条目入队,并在向量 0 上引发中断。当应用程序读取了那个完成队列条目后,它再次写入完成队列门铃,带有新的头指针值。

最后,我们看到我们成功读取了寄存器并得到了控制器实现的规范版本,并且我们也读出了设备的厂商 ID。

进阶主题:控制器内存缓冲区 (CMB)

更高端的 NVMe 设备支持一种叫做控制器内存缓冲区 (Controller Memory Buffer, CMB) 的东西。它基本上是位于设备上的一块通用读写内存区域,并通过一个 BAR 暴露出来。

这个区域有两个地址空间:PCI Express 地址范围(基本上像物理地址)和控制器虚拟地址范围。

因为这个区域在一个 BAR 上,所以它可以像任何其他 BAR 一样被映射。在这种情况下,我们知道它是一个 16KB 的 CMB,位于 bar2 的偏移量 0 处。我们像映射 M bar 一样映射这个 BAR,并得到一个指向这块虚拟内存的指针。

我们可以通过两种方式使用它:

  1. 直接写入:通过 memcpy 或类型转换直接向该指针写入,这是通过 PCI Express 地址范围进行的。

  2. 控制器写入:我们可以指示控制器执行一个命令,然后将该命令的结果写入 CMB,而不是主机内存。我们通过为控制器设置一个虚拟地址来做到这一点,我们称之为控制器基地址 (Controller Base Address)。这个地址只对控制器可见。控制器用这个地址来判断它应该执行 DMA 操作还是写入其自己的内部内存。

我们只需选择一个地址,比如我们可以选择最大的 IOVA 地址加一,并进行页面对齐,以确保我们使用的地址不会与 DMA 地址冲突。然后,我们向设备上一个叫做 CMB Memory Space Configuration 的寄存器写入这个地址,并启用它。

然后我们再次设置 identify 命令,但这次我们要求设备将结果写入的内存指针是这个控制器基地址。然后我们使用便利函数来执行命令。

当我们将数据写入 CMB 后,我们可以再次读出数据。我们可以要求控制器从内存中读取它,但我们也可以直接使用我们的虚拟地址引用来直接在设备上读取,让内核为我们执行内存映射 I/O。在这种情况下,我们只需获取 CMB 的地址,将其类型转换为 identify 控制器数据结构,然后我们就可以像访问任何其他数据结构一样访问其字段了。

CMB 演示环节描述

回到我们的演示环境,现在我们将使用这个叫做 CMB 的示例程序。正如我在前面的幻灯片中展示的,这里唯一特别不同的是我们将要映射这个地址。我们读取一些其他寄存器来获取 CMB 的大小信息。然后我们映射 CMB,分配一个控制器基地址,将它告知控制器,执行命令,最后打印出 identify 命令结果中的版本字段。

运行这个例子,我们看到控制器设置好了,我们设置了 CMB。它显示 CMB 位于一个物理地址上,这是操作系统分配给 BAR 的物理地址,但我们不用它。相反,我们分配了控制器基地址。然后我们执行命令,并可以打印出版本字段,告诉我们这是规范版本 1.4。


总结与展望

在我的演讲结束之际,有一些关键点我想强调:

  • 你实际上可以自己编写驱动程序。你不必是内核黑客或内核专家就能编写一个 PCI 驱动。通过对设备规范的相对简短的了解,你就可以从用户空间做这些驱动相关的事情。正如我们所见,即使算上理论讲解的时间,我们实际上也在不到 45 分钟内编写了一个能执行命令的、符合规范的简单驱动。

  • 我们需要一个 libvfio。我认为,如果我们能有一个社区共同维护的库,供大家使用,那将是一件好事。我会将我的工作开源作为一个起点,然后看看我们能走向何方。如果其他人有兴趣参与进来,让这样的项目启动并运行起来,我非常乐意合作。

我要说声谢谢。感谢大家参加我的演讲,希望你们喜欢。如果你们有任何问题,我可以通过电子邮件或在演讲结束后进行问答。再次感谢,再见。