1 I/O虚拟化概述
CPU访问外部设备的主要接口是设备提供的I/O资源,然而一个计算机系统的物理外部设备资源是有限的。在虚拟化环境下,全部虚拟机所需要的I/O设备通常会多于硬件所能提供的I/O设备资源。
虚拟机作为一台“虚拟”的机器,运行在虚拟机中的应用程序同样有访问外设的需求,所以如何处理虚拟机发起的I/O请求是I/O虚拟化所要解决的问题。Hypervisor需要将虚拟机中的I/O请求转换为对实际物理设备的访问。例如,当虚拟机中的应用发起对虚拟磁盘的读请求时,Hypervisor需要定位到物理磁盘的对应扇区,并将该扇区对应的数据返回给虚拟机中的应用程序。为了实现这一转换,Hypervisor会通过一系列软硬件机制截获客户机操作系统发起的PIO、MMIO和DMA等I/O访问请求,并处理虚拟机中需要执行的I/O操作,最后向客户机内的驱动程序返回正确的I/O结果。上述转换过程称为I/O虚拟化。
I/O虚拟化在实现过程中存在一些问题,这些问题也是虚拟化技术一直以来的难点。首先I/O设备种类繁多并且异构性强,不同设备可能适用于不同的虚拟化方式,这增大了系统实现的复杂度。其次在纯软件实现的情况下,I/O虚拟化需要经过虚拟机与物理机中的两层I/O栈,导致I/O性能大幅下降,这使得I/O虚拟化成为虚拟化的性能瓶颈。
1.1 I/O过程
I/O过程是CPU与外部设备相互访问和数据交换的渠道。外部设备会为CPU提供包括设备寄存器和设备RAM(Random Access Memory,随机存储器)在内的设备接口,CPU能够通过读写设备接口完成对设备的访问和操作。
设备寄存器也称为“I/O端口”,通常包括控制寄存器、状态寄存器和数据寄存器三大类。
- 控制寄存器用来存放CPU向设备发出的控制命令;
- 状态寄存器用来指示外设当前状态;
- 数据寄存器用于存放CPU与外设之间需要交换的数据。
在操作系统中,所有控制外部设备的操作必须由设备对应的驱动程序来完成。操作系统需要为不同设备提供相应的驱动程序,因此在操作系统的源码中,与设备驱动相关的代码占有很大的比例。**不同厂商生产的同一种设备,即使其内部逻辑电路和固件可能会有所不同,但都遵循同一种接口标准,为驱动程序提供相同的设备接口,即I/O端口。**当驱动程序访问I/O端口时,外设会根据接口标准中的要求通过设备固件实现相关功能。这种设计使得操作系统无须为每个外设厂商提供不同的驱动程序,例如:当用户更换不同厂商的鼠标时,系统中的鼠标驱动程序会自动适配该鼠标设备。
根据数据传送过程是否需要CPU的参与,可以将I/O方式分为两类,即可编程I/O(Programmed I/O)与DMA。
PIO
在可编程I/O方式下,外设接口需要通过一定的方式被映射到系统的地址空间才能被CPU访问。根据映射地址空间的不同,又可以将可编程I/O分为PMIO与MMIO。相较于PMIO,MMIO的应用更加广泛,几乎所有的CPU架构都支持MMIO,MMIO使用的地址空间是内存所在的物理地址空间。
RISC(Reduced Instruction Set Computer,精简指令集计算机)的CPU(如ARM、PowerPC等)只支持MMIO,采用统一编址的方式将I/O端口编址到物理地址空间的特定区域,通过内存访问指令实现对I/O端口的访问。然而某些架构也支持将I/O端口映射到专门的I/O地址空间,例如x86架构一共有65536个8位的I/O端口,编号从0到0xffff,这样就形成了一个独立于物理地址空间的64KB的I/O地址空间。
为了区分物理地址访问和I/O空间访问,x86架构提供了专用于端口访问的指令——IN/OUT。CPU可以通过IN/OUT指令向接口电路中的寄存器发送命令、读取状态和传送数据。当IN/OUT指令运行在内核态时,可以访问整个I/O地址空间,如果运行在CPU的低特权级,则只能根据I/O位图(I/O bitmap)的内容访问允许访问的端口。
下图展示了非虚拟化环境下的一次端口映射I/O流程:

DMA
DMA是一种在外设与内存之间交换数据的接口技术,数据传输过程无须CPU控制。DMA实现了外设与内存之间的直接数据传输,传输速度基本取决于存储器和外设本身的速度,适用于一些高速I/O设备进行大批量数据传送的情景。
DMA的过程由DMAC(DMA Controller,DMA控制器)控制,它是内存储器与外设之间进行高速数据传送的硬件控制电路,是一种实现直接数据传输的专用处理器。DMAC可以从CPU暂时接管地址总线的控制权,并对内存进行寻址,同时能够动态修改地址指针,完成数据在外设与内存之间的传送。
在发起DMA访问前,设备驱动会向外设提供一组内存描述符,每个内存描述符会指定设备DMA内存区域的基地址和长度。每次DMA操作访问的是内存描述符对应的连续内存空间,但多个内存描述符代表的内存块之间并不一定连续。
在DMA过程中,DMA控制器直接使用物理地址访问内存,并不需要线性地址到物理地址的转换过程。在虚拟化环境下,虚拟机的驱动程序提供的物理内存地址并不是实际的物理地址,这使得设备DMA操作可能会访问到不属于该客户机的物理内存空间,对虚拟化环境的安全性和隔离性造成破坏。由于DMA控制器是一个硬件控制电路,Hypervisor无法通过软件的方式截获设备的DMA操作,为了解决这一问题,Intel推出了VT-d(Virtualization Technology for Direct I/O,直接I/O虚拟化技术)技术,是IOMMU的标准实现。
1.2 I/O虚拟化的基本任务
访问捕获
I/O虚拟化的一个基本要求是能够隔离和限制虚拟机对真实物理设备的直接访问。在虚拟机未被分配明确的物理设备时,Hypervisor不允许客户机操作系统直接与I/O设备进行交互。
以磁盘设备为例,Hypervisor、宿主机和虚拟机会共享磁盘上的存储空间。如果客户机中的驱动程序拥有对磁盘的直接操作权限,则可能会访问到不属于该虚拟机的磁盘扇区,这样轻则导致数据泄露和丢失,严重时会威胁其他虚拟机甚至是宿主机的运行安全,无法满足虚拟化模型中的隔离性要求。为了避免这个问题,Hypervisor必须能够以某种形式截获客户机的I/O请求,防止客户机访问到不属于它的外部设备,同时使客户机保持该类设备可以被访问的错觉。
提供I/O访问接口
上文提到,计算机使用一个外设需要操作系统的驱动程序和外设内部固件的配合。但是操作系统通常并不关心外设内部的逻辑,只需要访问外设提供给操作系统的设备接口。因此Hypervisor可以通过模拟目标设备的外部访问接口,并将模拟的软件接口提供给虚拟机,从而使客户机操作系统能够通过自身驱动程序发起对外设的I/O操作。通常虚拟机发起的I/O操作被称为“虚拟I/O”,与之对应的是宿主机操作系统发起的能够直接控制物理外设的“物理I/O”。与“物理I/O”不同的是,“虚拟I/O”操作的是Hypervisor提供的虚拟设备接口。
实现设备的功能
在处理虚拟机发起的I/O操作过程中,Hypervisor不仅需要截获虚拟机对设备的访问,还需要实现虚拟机“期望”的设备功能,向虚拟机返回正确的结果。不同软件架构的Hypervisor实现虚拟设备功能的方式会有所不同,但基本思路都是解析虚拟机的I/O请求,并交由实际的物理设备执行,最终向客户机操作系统返回I/O操作结果。
1.3 软件实现的I/O虚拟化
虽然在不同的虚拟化模式下,I/O虚拟化的基本任务相同,但如何实现上述基本任务存在着多种选择。例如Hypervisor可以使用软件模拟虚拟设备的接口,也可以在硬件层面将物理设备端口暴露给虚拟机。因此,与CPU虚拟化和内存虚拟化类似,I/O虚拟化实现方式也可以根据是否需要硬件支持分为纯软件I/O虚拟化和硬件辅助I/O虚拟化两种。同时纯软件实现的I/O虚拟化也可以根据客户机操作系统能否直接使用原生设备驱动进一步分为I/O全虚拟化和I/O半虚拟化。
1 | /* |
设备模拟 (全虚拟化)
设备模拟属于纯软件实现的I/O全虚拟化方式。设备模拟的应用十分广泛,目前绝大部分Hypervisor,例如VMware Workstation、KVM、Xen、Xvisor都支持设备模拟。
在全虚拟化环境下,为了满足虚拟机之间的隔离性和安全性等要求,虚拟机并不能直接访问真实的物理外设,虚拟机内部驱动程序操作的设备是由Hypervisor提供的一个虚拟的设备抽象,整体流程是这样:
- 虚拟机启动过程中,这些由Hypervisor提供的虚拟设备会被虚拟BIOS和客户机操作系统检测到,然后挂载到虚拟的设备总线;
- 当客户机操作系统中对应的驱动程序向虚拟设备发起I/O请求时,由于I/O指令是敏感指令,会引发VM-Exit陷入Hypervisor。之后与这些I/O请求相关的信息会被Hypervisor截获,发送到相关的软件模块进行处理;
- 最后软件模块会模拟这些I/O请求并将I/O结果返回给虚拟机操作系统中的设备驱动程序。
在以上过程中,I/O请求的截获和处理对于客户机操作系统来说是透明的,客户机操作系统始终认为自己运行在一个真实的物理硬件平台,而实现设备模拟以及处理I/O请求和返回I/O操作结果的软件模块则称为设备模型 (device model)。
设备模型主要由两部分组成:
- 一部分是**以软件实现的形式向客户机操作系统提供的目标设备接口,**该接口主要用客户机操作虚拟设备;
- 另一部分是**设备具体功能的软件实现,**该部分会对截获的I/O请求进行解析,之后根据设备模型所处的运行环境执行相应的I/O处理过程,最终给客户机操作系统返回I/O操作结果。
由于设备模型是通过纯软件实现的,模拟的虚拟设备与宿主机的硬件并不存在直接关联,因此设备模型甚至可以模拟实际不存在的设备。设备模型可以运行在宿主机操作系统的用户态,也可以运行在Hypervisor,甚至可以运行在其他特权虚拟机。下图展示了设备模型运行在宿主机用户态和Hypervisor这两种情况。

QEMU/KVM是把设备模型运行在用户态上这一类Hypervisor的典型代表。在QEMU/KVM中,设备模型作为用户进程运行在宿主机操作系统的用户空间。在宿主机中,设备模型可以通过使用Linux提供的系统调用以及各种库函数完成对虚拟机内I/O访问的模拟。图(a)展示了用户态设备模型的I/O模拟流程,在这种模式下,一次I/O模拟过程会发生多次上下文切换,包括:
首先,客户机I/O操作会触发VM-Exit,发生由客户机操作系统到Hypervisor的上下文切换;然后Hypervisor将I/O操作通过I/O共享页传递给设备模型,此时会切换到用户空间的设备模拟进程;最后设备模型发起系统调用,切换到宿主机操作系统的内核态。
与 Xvisor 类似的虚拟化方案则将设备模型整合在Hypervisor中。设备模型作为Hypervisor的一部分,与真实的设备驱动共同运行在内核态。图(b)展示了该模式下的I/O模拟流程,设备模型会解析Hypervisor拦截的客户机设备驱动发出的I/O请求,并将解析后产生的物理I/O请求发送给相应的真实物理驱动执行,最后设备模型会将物理驱动获得的I/O结果发送给客户机操作系统。相比于运行在用户态,这种实现方式避免了系统调用所产生的大量上下文切换,缩短了虚拟机I/O的模拟路径,提高了I/O的性能。但由于大量物理驱动实现在Hypervisor中,增加了Hypervisor设计的复杂性,并限制了方案的可移植性和通用性。
半虚拟化
为了解决设备模拟中由于频繁的上下文切换导致的I/O性能大幅下降问题,I/O半虚拟化技术应运而生,其中的典型代表是Xen和virtio。下图展示了Xen前后端设备驱动模型。

在I/O半虚拟化方式下,客户机操作系统中的原生驱动会被移除,取而代之的是简化后的前端设备驱动。Hypervisor会将原生物理驱动保留在指定的特权虚拟机(Domain 0)中,该特权虚拟机通常负责管理其他非特权虚拟机(Domain U)的生命周期,例如启动、销毁虚拟机。特权虚拟机中安装有后端设备驱动,后端设备驱动的功能是接收并解析来自非特权虚拟机中前端设备驱动的I/O请求,并将I/O请求转发到对应设备的原生物理设备驱动执行,最后将I/O操作结果返回给非特权虚拟机中的前端设备驱动。
在半虚拟化模式中,客户机操作系统对自身所处的虚拟化环境具有清晰的认识,这样做的好处是:
- 客户机能够以调用服务的形式主动向Hypervisor发起批量的异步I/O请求。相比于设备模拟中每次I/O请求都需要被Hypervisor截获,I/O半虚拟化方式极大地减少了上下文切换的开销。
- 而且前端驱动只需在遵守标准的前后端接口协议的基础上将I/O请求转发,无须实现原生设备驱动中复杂的逻辑,大幅简化了前端驱动的实现复杂度。
当然,I/O半虚拟化方式并不是完美的。由于需要修改操作系统源码,导致I/O半虚拟化方法难以在闭源的操作系统上应用。
1.4 硬件辅助的I/O虚拟化
上节介绍了I/O虚拟化主要的两种软件实现方式——设备模拟和半虚拟化。设备模型为客户机操作系统提供虚拟的设备访问接口,但具体的软件实现对操作系统透明,所以使用设备模拟的方式可以运行未经修改的原生操作系统,具有较强的通用性。但是存在如下问题:
- 由于I/O操作涉及大量的设备寄存器访问,在全虚拟化环境下会导致非常多的 VM-Exit,从而需要进行频繁的上下文切换;
- 全虚拟化需要经过客户机和宿主机的两层I/O栈,导致I/O的路径变长而且数据需要在内存中复制多次;
- 半虚拟化I/O方案使用前后端驱动的方式,能够批量处理虚拟机中的I/O请求,使得虚拟机能够获得与原生系统相近的I/O性能。但是使用半虚拟化I/O方式需要客户机操作系统的配合,客户机操作系统必须清楚自己运行在虚拟化环境中,并且需要安装特定的驱动程序同时修改操作系统的源码,这样就限制了I/O半虚拟化方案的通用性。
为了解决I/O虚拟化软件实现方案存在的弊端,Intel、AMD、ARM等处理器厂商推出了各自的硬件辅助I/O虚拟化技术。下图展示了设备直通访问模型与SR-IOV这两种最具代表性的硬件解决方案。

设备直通访问
设备直通访问的实现主要依赖于设备和内存之间的IOMMU(Input-Output Memory Management Unit,输入输出内存管理单元)。IOMMU与MMU类似,位于主存和设备之间,为每个设备创建一个IOVA(I/O Virtual Address,虚拟I/O地址空间),并提供一种将IOVA动态映射到物理地址的机制。当虚拟机发起DMA时,设备驱动会使用IOVA作为DMA地址,之后设备会试图访问IOVA,这时IOMMU会将IOVA重新映射到合法的物理地址。
在虚拟化环境中,IOMMU的引入能够在硬件层面限制设备对内存访问的范围,实现不同虚拟机之间I/O资源的隔离。IOMMU作为一种通用的解决方案,几乎所有知名处理器厂商都推出了支持IOMMU的CPU。本节选择介绍Intel提出的VT-d技术。
VT-d技术是Intel为了提升I/O虚拟化性能提出的硬件方案,与VT-x技术同属于Intel硬件虚拟化技术。如上图所示,VT-d方案直接将物理设备分配给特定的虚拟机,使得客户机操作系统中的原生驱动程序可以通过外设接口直通访问并操作物理外设,该过程无须下陷到Hypervisor中处理。同时由于I/O路径长度几乎等同于非虚拟化环境下的I/O路径长度,因此虚拟机能够获得与裸机一致的I/O性能。
在引入VT-d技术的CPU上,Hypervisor无须模拟客户机操作系统发起的I/O操作,这极大地降低了Hypervisor设计的复杂度。例如完全采用设备直通访问的方式为虚拟机分配物理外设资源的Jailhouse(西门子开发的Hypervisor),其代码行数只有不到4万行,而同为Type I型Hypervisor的Xen,由于同时还支持设备模型,其代码行数远超Jailhouse,约为30万行。
SR-IOV
设备直通访问虽然能够将一个物理设备分配给虚拟机独占使用,但这种设备独占的方式降低了设备的利用率,会大幅增加设备的硬件成本,在某种程度上 “违背” 了虚拟化的初衷,并且不具备可扩展性。例如,目前市面上的网卡设备一般都拥有较高的带宽,同时正常情况下系统并不会一直使用网络功能。如果使用设备直通方式,很容易造成网卡带宽资源的浪费。
为了解决这一问题,设备厂商**在硬件层面将一个物理设备虚拟成多个设备,然后将虚拟出来的设备分配给虚拟机,使得物理设备可以同时被多个虚拟机共享。出于兼容性考虑,PCI-SIG制定了SR-IOV规范。SR-IOV规范允许虚拟机能够在没有软件参与的情况下共享设备I/O端口的物理功能,**同时可以获得媲美非虚拟化环境I/O性能。
2 I/O虚拟化的实现方式
与CPU和内存相比,外设的种类繁多,不同设备遵循的总线协议也有所不同。常见的设备有PCI设备、ISA设备、USB设备等。目前PCI设备是应用最广泛的设备,几乎所有主板都支持PCI设备,所以本章将基于x86架构,以PCI设备为例介绍设备模拟、半虚拟化、设备直通访问、SR-IOV这四种I/O虚拟化方式的具体实现。
2.1 PCI设备简介
PCI总线可以被看作系统总线的延展,同时作为CPU的局部总线连接外部设备。PCI总线是一个典型的树结构:

从图中看,与PCI总线相关的模块包括:
Host Bridge比如PC中常见的
North Bridge(北桥)。图中处理器、Cache、内存子系统通过Host Bridge连接到PCI上,Host Bridge管理PCI总线域,是联系处理器和PCI设备的桥梁,完成处理器与PCI设备间的数据交换。其中数据交换,包含处理器访问PCI设备的地址空间和PCI设备使用DMA机制访问主存储器,在PCI设备用DMA访问存储器时,会存在Cache一致性问题,这个也是Host Bridge设计时需要考虑的。此外,Host Bridge还可选的支持仲裁机制,热插拔等;PCI Local BusPCI总线,由Host Bridge或者PCI-to-PCI Bridge管理,用来连接各类设备,比如声卡、网卡、IDE接口等。可以通过PCI-to-PCI Bridge来扩展PCI总线,并构成多级总线的总线树,比如图中的
PCI Local Bus #0和PCI Local Bus #1两条PCI总线就构成一颗总线树,同属一个总线域;PCI-To-PCI BridgePCI桥,用于扩展PCI总线,使采用PCI总线进行大规模系统互联成为可能,管理下游总线,并转发上下游总线之间的事务;
PCI Device- **PCI从设备:**被动接收来自Host Bridge或者其他PCI设备的读写请求;
- PCI主设备:可以通过总线仲裁获得PCI总线的使用权,主动向其他PCI设备或主存储器发起读写请求;
- **桥设备:**管理下游的PCI总线,并转发上下游总线之间的总线事务,包括PCI桥、PCI-to-ISA桥、PCI-to-Cardbus桥等;
一个插入在PCI插槽中的物理PCI设备可能具有多个功能单元,其中每个功能单元用功能号 (Function Number) 表示。操作系统会将某一物理PCI设备的不同逻辑设备视作多个独立的逻辑设备。本质上,CPU通过I/O访问的是PCI设备的某个逻辑设备。通过PCI总线的分层结构可以根据总线号和设备号确定某个物理PCI设备,进而一个PCI逻辑设备可以由总线号、设备号、功能号三个参数唯一确定,这三个参数就构成了PCI设备标识符。
操作系统通过每个PCI设备的配置空间识别和访问PCI设备。PCI配置空间的大小为256字节,实际上是一组连续的设备寄存器。如图下所示,其中前64字节称为配置头,由PCI标准统一规定格式和用途。配置头的主要功能是识别设备、定义主机访问PCI卡的方式,例如设备号(Device ID)和厂商号(Vendor ID)用于表示设备的类型和生产该设备的厂商。PCI配置空间的剩余192字节由PCI设备自己定义。

PCI配置空间有6个BAR(Base Address Registers,基地址寄存器),即PCI BAR,代表每个PCI设备最多能映射6段地址空间,编号分别为i=0,1,2,…,5。BAR记录设备所需要的地址空间的类型(内存空间或者I/O空间,用BAR的最后一位区分)、基地址以及其他属性。
PCI设备标识符可以被视为设备PCI配置空间地址的一部分,主桥能够根据PCI设备标识符选择对应PCI设备并根据寄存器偏移地址获取设备配置空间的信息。
PCI配置空间地址的结构如下图所示:

其中 [23∶8] 为设备标识符,高8位总线编号 (Bus number) 字段代表设备所在的总线,中间 5 位设备编号 (Device number) 字段指示总线上的具体某个物理设备,最后 3 位功能编号 (Function number) 字段标识物理设备上的某个逻辑设备。根据每个字段的位数,系统最多可以有 256 条PCI总线,每条总线上最多有 32 个物理设备,同时每个设备最多拥有 8 个功能单元,即逻辑设备。为了方便表达,本文以 3 个字段的首字母缩写BDF 代表PCI设备标识符。
2.2 设备模拟
由于外设的种类繁多,设备模型需要模拟每个设备拥有的不同接口和功能,并且提供多种设备访问方式,这导致设备模型的实现十分复杂。但是总体来看,虽然每个设备的接口数量以及内部逻辑有所差异,但CPU本质上都是通过读写一组设备寄存器或设备RAM实现对设备的访问,这就使不同虚拟设备的底层实现之间具有相似性。本节将从PIO、MMIO、DMA和PCI配置空间访问四个方面介绍设备模拟的实现。
PMIO
x86架构提供了 IN/OUT、INS/OUTS 等指令访问与设备寄存器相关的I/O端口。Hypervisor将这四条指令设定为会触发VM-Exit的敏感指令,当客户机通过发起这四条指令进行端口I/O操作时会触发VM-Exit,陷入Hypervisor。同时Hypervisor会保存访问的端口号、访问数据宽度、数据传输方向等相关信息,以达到截获客户机端口I/O操作的目的。下图展示了Hypervisor模拟虚拟机发起一次PMIO访问的过程。

- out 指令触发 VM-Exit;
- 模拟 out 指令并返回客户机;
设备模型在初始化阶段会将虚拟设备涉及的PMIO处理函数在Hypervisor中进行注册,并以数组的形式存储I/O处理函数的指针。Hypervisor会根据截获的端口号和访问的数据宽度与相应的PMIO处理函数进行匹配,找到相关函数的指针后,交由PIO处理函数进行进一步的模拟过程。
MMIO
内存映射I/O相较于端口I/O是一种应用更加广泛的I/O形式。MMIO地址空间属于物理地址空间中的高地址部分,程序可以使用内存访问指令进行MMIO。与端口I/O涉及的IN/OUT指令不同的是,内存访问指令并不属于MMIO的专属指令,Hypervisor不能将其设为敏感指令。那针对MMIO访问该如何触发VM Exit呢?
- 为了截获客户机的MMIO操作,Hypervisor不会将虚拟机中MMIO所在的物理地址范围映射到主机的物理地址空间,即影子页表中不存在相应的页表项。每次当客户机操作系统发起MMIO时,都会产生一个缺页异常,产生VM-Exit,这样就可以截获客户机的MMIO访问并交由相关处理函数模拟。
MMIO相关的处理函数的组织形式通常不采用PMIO中的数组形式。这是因为MMIO占用的内存区域较大,一般有上百兆字节,如果使用数组保存每个端口的处理函数会导致巨大的内存占用。通常每个MMIO相关的处理函数能够处理对某一片内存区域发起的MMIO访问,这样就大大减少了处理函数的数量,从而降低了对内存的占用。
设备模型首先会向Hypervisor申请MMIO区域并注册该MMIO的处理函数,之后当虚拟机发起MMIO访问时,Hypervisor会根据产生异常的内存地址找到对应的MMIO区域,最后该MMIO区域注册的处理函数会定位到要访问的I/O端口并执行相关软件模拟过程。
DMA
与ISA设备不同的是,PCI设备架构中并不存在DMA控制器,任何一个PCI设备都可以向PCI总线控制器发起总线请求,获得对总线的控制权。PCI设备发起DMA的过程是由操作系统中的设备驱动程序访问与DMA操作相关的特定硬件寄存器组实现的。设备驱动不仅可以通过写入相关硬件寄存器设置DMA操作的目的地址,而且可以通过向DMA命令寄存器写入DMA命令发起DMA请求。
设备驱动程序只能以PMIO或MMIO的方式访问外设寄存器,所以Hypervisor通过截获客户机操作系统发起的PMIO和MMIO即可实现对客户机DMA请求的拦截。之后Hypervisor会执行DMA相关处理流程。首先,设备模型会借助Hypervisor的内存管理子系统,将客户机操作系统指定的DMA内存映射到自身的地址空间中。之后设备模型会执行相应的系统调用,将数据以DMA的方式读写到Hypervisor映射的DMA内存。最后当数据传输完毕,设备模型会通过虚拟中断控制器将对应的虚拟设备中断注入到虚拟机中,虚拟机结束此次DMA操作。
PCI配置空间
x86架构在I/O地址空间提供了两个32位寄存器,用于访问PCI设备的配置空间:
config_address寄存器(端口地址为0xcf8)用于存放目标PCI设备的设备标识符,以及要访问的设备寄存器在配置空间的字节偏移;config_data寄存器(端口地址为0xcfc)用于存放设备寄存器的数据;
CPU读取配置空间时,CPU首先将PCI配置空间地址写入 config_address,之后PCI主桥把设备号 config_address[11:15] 转译到PCI总线并通知对应PCI设备,PCI设备会把对应寄存器数据放入地址总线,之后PCI主桥读回数据,放到 config_data 中。最后CPU便可以通过 config_data访问配置空间的内容。由于访问过程涉及IN/OUT这两个端口访问指令,所以可以通过与PMIO一样的形式模拟客户机对PCI配置空间的访问。
2.3 I/O半虚拟化
设备前后端分离是 I/O 半虚拟化的核心思想。在该方法下,虚拟机使用专门的前端驱动程序,通过特定数据传输接口与特权虚拟机或Hypervisor中的后端设备交互,后端设备则通过物理驱动程序完成对外设的访问。在I/O半虚拟化领域,一些知名的Hypervisor都推出了自己的解决方案,例如Xen提供的半虚拟化驱动、VMware提供的guest tools,本节将介绍一种被广泛使用的半虚拟化方案—— virtio。
Linux作为使用最广泛的开源操作系统,能够被众多虚拟化方案支持,例如Xen、KVM、VMware等。虽然这些Hypervisor都支持Linux,但是在virtio出现之前,众多Hypervisor内都拥有属于自己的块设备、网络设备驱动,而且还需要维护功能重叠但实现又有所不同的设备模型,这就给日常维护以及性能优化带来了很大的麻烦。
为了解决上述问题,IBM推出了virtio,**virtio的主要目标是为半虚拟化提供一个统一的前后端设备接口标准。**virtio将一组高效且通用的Linux前端virtio驱动加入Linux源码树中,并提供了一系列能够应用于不同Hypervisor的后端virtio设备,这样显著降低了日常维护的成本。其中:
- virtio驱动程序是虚拟机中的软件部分,它依据virtio规范与virtio设备进行通信,主要作用是发现virtio设备并在虚拟机中分配用于前后端通信的共享内存;
- virtio设备会公开virtio接口,用于管理和交换信息。virtio接口一般包括设备状态(Device Status)、设备支持的特性(Virtio Feature)以及前后端数据传输的通道(Virtqueue队列)。前后端消息通知机制 (Notification) 同样也属于virtio接口的一部分,用于提醒前后端处理到来的信息。
virtio作为一个通用半虚拟化框架,可以在不同类型设备总线之上实现,本文将基于最普遍的PCI总线介绍virtio的具体实现。
virtio-pci
为了支持PCI总线,每种virtio设备需要对应一个 virtio-pci 代理设备。virtio-pci 代理设备能够通过与PCI设备相似的方式被虚拟机中的BIOS或客户机操作系统识别,并挂载到PCI总线。virtio-pci 代理设备的一个重要作用是提供virtio设备的访问接口,它会创建一条virtio总线,并将virtio设备挂载到virtio总线,这样virtio驱动便能够访问virtio设备。
virtio-pci 设备拥有专属的厂商号 0x1a4 以及特定的设备号的区间 0x1000-0x10ff。系统可以通过厂商号识别出该PCI设备为 virtio-pci 设备。子系统厂商号 (Subsystem Vendor ID) 和设备号可以用于指示该 virtio-pci 设备支持的virtio设备类型。
系统通常会使用 virtio-pci 设备的第一个BAR指示的I/O空间对 virtio-pci 设备进行配置。该I/O区域包括一个 virtio-header 结构,用于存放virtio设备的通用配置项以及设备的专属配置。下图展示了 virtio-header 中的通用配置项:

由于virtio设备需要挂载到 virtio-pci 设备提供的virtio总线,所以virtio设备探测与驱动加载需要在 virtio-pci 初始化完成的前提下进行。因此整个virtio前端初始化过程可以分为:virtio-pci设备探测和驱动加载和virtio设备初始化和驱动加载两个阶段。
virtio-pci 设备作为PCI设备的一种,可以通过与其他PCI设备一样的方式被探测。虚拟机启动时,虚拟BIOS和客户机操作系统会扫描PCI总线,进行PCI设备枚举过程。该过程会探测到 virtio-pci 设备,并将 virtio-pci 设备注册到PCI总线。之后,与设备和驱动匹配相关的match函数将根据PCI配置空间中的厂商号、设备号、子系统厂商号和子设备号为设备绑定对应驱动。
PCI设备和驱动匹配的依据通常是驱动中的一个由
pci_device_id组成的id_table数组。在virtio-pci设备驱动中,将厂商号设置为0x1a4,而设备号、子系统厂商号、子设备号都被置为PCI_ANY_ID,表示可以匹配任何ID。这样无论是哪一种virtio-pci设备,都能与共用的virtio-pci设备驱动绑定。virtio-pci设备与virtio-pci设备驱动绑定后,会进入virtio-pci设备探测阶段。virtio-pci设备探测阶段通常使用virtio_pci_device结构体表示virtio-pci设备。该阶段会使能virtio-pci设备并初始化相应的virtio设备和virtio总线。最后virtio设备会被注册到virtio总线上,该过程会触发virtio总线上virtio设备与virtio驱动之间的匹配操作。匹配操作成功之后,virtio驱动会进行virtio设备探测过程(例如
virtioblk_probe)。virtio设备探测的主要任务是读取virtio-pci配置空间中关于virtio设备配置的内容(virtio-header),之后按照virtio配置初始化virtqueue,并根据virtio设备的特性初始化对应的物理设备。
下图展示了 virtio 前后端架构:

主要包含三部分,前端驱动、后端设备、virtqueue:
- 前端驱动位于客户机操作系统内部,包括
virtio/virtio-pci设备驱动,其作用是接收用户态请求,然后根据传输协议将封装后的I/O请求放入virtqueue,并向后端发送一个通知; - 后端设备位于QEMU或宿主机内核中,包括
virtio/virtio-pci设备。后端设备从virtqueue中接收前端驱动发出的I/O请求的基本信息,然后调用相关函数完成I/O操作,最后向客户机中的前端驱动发起中断; - 不同virtio设备可能拥有不同数量的**virtqueue,**例如virtio块设备只有一个virtqueue,而virtio网卡设备则有两个virtqueue,一个用于发送数据另一个用于接收数据。
virtqueue
**vring 是 virtqueue 机制的具体实现。**vring在虚拟机和QEMU之间引入一段共享的环形缓冲区作为前后端数据通信的载体。相较于传统的I/O方式,环形缓冲区的引入使得可以一次处理多个I/O请求,提高了每次I/O传递的数据量,并且显著减少了上下文切换的次数。
virtqueue 传输机制是 virtio 框架能够提升 I/O 虚拟化性能的关键。
vring主要由三个部分组成:描述符表 (descriptor table) 、可用的描述符环 (available ring) 和已用描述符环 (used ring)。
descriptor table
描述符表用于保存一系列描述符,每一个描述符都被用来描述客户机内的一块内存区域。客户机中的前端驱动负责管理这些内存区域的分配和回收。描述符通过如下字段指定内存区域的各项属性:
addr:该字段表示内存区域在客户机物理内存空间中的起始地址;flag:该字段用来标识描述符自身的特性,一共有三种可选值。VRING_DESC_F_WRITE:表示当前内存区域是只写的,即该内存区域只能被后端设备用来向前端驱动传递数据;VRING_DESC_F_NEXT:表明该描述符的next字段是否有效;VRING_DESC_F_INDIRECT:表明该描述符是否指向一个中间描述符表;
len:该字段的意义取决于该内存区域的读写属性。如果该区域是只写的,数据传递方向只能从后端设备到前端驱动,此时 len 表示设备最多可以向该内存块写入的数据长度;反之,如果该区域是只读的,此时 len 表示后端设备必须读取的来自前端驱动的数据量;next:在virtio中,一次前、后端的数据交互请求往往会包含多个I/O请求合并,而且一个I/O合并也可能涉及多个不连续的内存区域。通常的做法是将描述符组织成描述符链表的形式来表示所有的内存区域。next字段便是用来指向下一个描述符。通过flag字段中的值VRING_DESC_F_NEXT,就可以间接地确定该描述符是否为描述符链表的最后一个。
available ring
可用描述符环是 virtqueue 中的一块区域,用于**保存前端驱动提供给后端设备且后端设备可以使用的描述符。**可用描述符环由一个flags字段、idx索引字段以及一个以数组形式实现的环ring[]组成。其中:
- flags字段有0和1两种取值,1 表明后端设备使用完前端驱动分配的描述符时无须向虚拟机发送中断;
- idx 用来索引数组 ring 中下一个可用的位置。数组ring中存放的是描述符链表中作为链表头的描述符在描述符表中的索引,所以可以通过数组ring中元素的值找到对应描述符链表。当前端驱动为后端设备组织好一个可用描述符链表时,前端驱动会根据idx的值将该可用描述符链表头在描述符表中的索引加入数组ring的相应位置。
每次后端设备取可用描述符时,需要知道剩余可用描述符在数组ring中的起始位置。后端设备会维护一个变量 last_avail_idx,用来标记这个位置。当切换到主机中时,后端设备将检查 last_avail_idx/idx 的值,数组ring中位于 last_avail_idx ~ idx-1之间的部分就是可供后端设备使用的区域。
used ring
已用描述符环的组成结构与可用描述符环类似,用于**保存后端驱动已经处理过并且尚未反馈给驱动的描述符。**与可用描述符环不同的是,已用描述符环中数组ring的每个元素不仅包含后端设备已经处理的描述符链表的头部描述符在描述符表中的索引,而且由于后端设备可能会向前端驱动写回数据或需要告知驱动写操作的状态,还需要包括一个len字段来记录设备写回数据的长度。
- 当后端设备处理完一个来自可用描述符数组的描述符链表后,需要将链表头的描述符在描述符表中的索引以及写回数据的长度一起加入数组ring中。idx在该过程中的作用与在可用描述符环中相同。
同样的,当设备驱动回收已用的设备描述符时,需要知道剩余已用标识符在数组ring中的起始位置,前端驱动会维护一个变量 last_used_idx,用来标记这个位置。当切换到虚拟机中时,前端驱动将检查 last_used_idx/idx 的值,数组ring中位于 last_used_idx ~ idx-1 之间的部分便是可供前端驱动回收的区域。
virtqueue初始化
在前端驱动发起I/O操作前,驱动作为virtqueue的所有者需要初始化将要用到的virtqueue。下面是virtqueue初始化的具体过程:
- 在virtio框架下,virtqueue的相关参数(例如地址和大小)都保存在上文中提到的virtio-header中。virtio-header存放在virtio-pci设备配置空间第一个BAR指向的I/O区域,所以将该区域映射进内核可以获得virtqueue相关属性;
- 根据后端设备种类不同,一个前端驱动可能拥有多个队列。可以将virtqueue的索引写入virtio-header中的Queue Select寄存器来通知设备所要初始化的具体队列;
- 为了给virtqueue分配空间,驱动还需要知道virtqueue的大小。前端驱动通过读virtio-header中的Queue Size寄存器,获得virtqueue内描述符的数量;
- 根据描述符数量计算并为virtqueue分配内存空间,并将内存空间的起始地址除以4096,转换成以页为单位的地址后写入virtio-header中的Queue Address寄存器;
- 后端设备收到该地址后,将该地址左移12位获得virtqueue的GPA,最后将该GPA转换为HVA。
virtio-net
virtio能够支持各种不同的设备,其中虚拟网卡设备是virtio中比较复杂的设备。基于virtio实现的网络架构通常被称为 virtio-net。下图描述了virtio-net 的一次网络包发送过程:

- 运行在客户机内核空间的 virtio-net 驱动会将要发送的数据包放置在虚拟机的内存缓冲区中;
- virtio-net 驱动为数据包所在的内存缓冲区创建一系列描述符,并组织成描述符链加入可用描述符环中;
- virtio-net 驱动以 MMIO 的方式写特定地址,造成 VM-Exit,陷入 KVM 中;
- KVM 分析 VM-Exit 的原因,将控制流转发给 QEMU 中的 virtio-net 后端设备;
- virtio-net 后端设备从可用描述符环中获得数据包所在的客户机物理地址,由于虚拟机的内存空间由QEMU分配,所以QEMU能够将客户机物理地址转换为QEMU在主机操作系统中的虚拟地址;
- virtio-net 后端设备根据上一步转化得到的主机虚拟地址得到网络包数据;
- virtio-net 后端设备以系统调用的方式将网络包通过内核网络栈发出,之后将描述符加入已用描述符环中;
- QEMU以中断注入的形式向虚拟机发送I/O完成通知,并最终将控制流交还给虚拟机。
vhost
上文主要介绍了后端设备位于用户态QEMU进程中的情况。在该场景下,一次virtio访问会涉及虚拟机内核与主机KVM模块之间、KVM模块与主机用户态QEMU进程之间、QEMU进程与主机内核中的驱动程序之间的多次特权级切换。每次特权级切换都需要保存上下文,造成了极大的性能损失。
还有一种将设备模型置于Hypervisor的设备模拟方式,该方式避免了系统调用所产生的大量上下文切换,缩短了虚拟机I/O的模拟路径,提高了I/O的性能。vhost就是该方式的一种具体实现,如下图所示:

vhost采用了与之类似的方式优化传统的virtio。vhost API是一个基于消息的协议,它允许Hypervisor将数据交互的工作卸载到运行在宿主机内核态的另一个组件—— handler。handler能够将数据转发的任务从QEMU的设备模型中解耦。使用此协议,Hypervisor (QEMU) 需要向handler发送Hypervisor的内存布局 (MemoryLayout) 和一对文件描述符 ioeventfd/irqfd。内存布局用于定位 virtqueue 队列和数据缓存在QEMU内存中的位置。文件描述符用于handler发送和接收通知。这些文件描述符在handler和KVM之间共享,因此handler可以和KVM直接通信,不需要QEMU的干预。
vhost-net是一个内核驱动程序,是一种vhost协议处理程序,用于实现数据包的快速转发:
- 加载vhost-net内核驱动程序时,会在
/dev/vhost-net上公开一个字符设备。当QEMU在vhost-net的支持下启动时,QEMU会使用几个ioctl调用来初始化vhost-net; - 在初始化过程中,QEMU进程会与vhost-net相关联,并将virtio feature、用于发送和接收通知的文件描述符、物理内存映射传递给vhost-net。vhost-net内核驱动程序会为客户机创建对应内核线程。这个线程称为 “vhost工作线程” 。
QEMU分配文件描述符 ioeventfd 并将其注册到vhost和KVM,该文件描述符负责传递I/O事件通知。当虚拟机访问特定端口时,KVM向ioeventfd 写入与该访问相关的内容,同时vhost内核线程会不断轮询 ioeventfd 并立即获得通知。因此对特定客户机内存地址(例如MMIO内存区域)的读/写操作不需要切换到QEMU中,可以直接路由到 vhost 工作线程,这样就实现了异步处理,不需要终止vCPU的执行,避免了上下文切换的开销。
另外,QEMU分配另一个文件描述符 irqfd 并再次将其注册到KVM和vhost上,该文件描述符负责传递vCPU中断消息。vhost工作线程完成数据包读写之后,会对 irqfd 进行写操作,之后KVM从 irqfd 中读取与vCPU相关的信息,最后将vCPU中断注入客户机。该过程也是异步的,这种异步通知机制使得在数据处理过程中,vCPU可以继续运行,显著减少了性能损失。vhost的引入改变了发生VM-Exit之后KVM和QEMU的工作流程,但并不影响前端驱动的固有设计,vhost对前端驱动来说是透明的。
vhost-user //TODO
上文的vhost-net方案比较适合客户机与主机网络控制器之间的通信或者客户机与外设之间的通信。在这种情况下,客户机与后端设备的通信只发生一次数据复制与用户态切换。但是vhost-net并不适用于客户机与主机上的某些用户态程序交互。因为如果使用vhost-net方案,客户机和KVM模块之间、用户态程序与后端设备之间都会产生上下文切换以及数据复制。由于客户机和用户态程序都位于用户态,数据可以直接在用户态进行传递,无须经过“用户态——内核态——用户态”这两次复制过程。
一种将vhost从内核态迁移到用户态的vhost-user方案应运而生。vhost-user使用了和vhost-net一样的vhost协议,即都使用了vring完成前后端数据交互,而且还使用了相同的事件通知机制。不同的是,vhost将后端实现在内核态而vhost-user实现在用户空间中,用于用户空间中两个进程之间的通信。而且事件通知和数据交互的具体实现方式两者也有所不同。vhost-user基于客户/服务的模式,采用UNIX域套接字(UNIX domain socket)来建立QEMU进程与vhost-user之间的联系,进而将virtio feature、用于发送和接收通知的文件描述符、物理内存映射传递给vhost-user,而vhost-net中使用的是ioctl的方式。vhost-user采用套接字的方式大大简化了操作。vhost-user基于vring这套通用的共享内存通信方案,只要客户端和服务端按照vring提供的接口实现所需功能即可。常见的实现方案是将客户端集成在客户机的virtio驱动上,服务端实现在DPDK(Data Plane Development Kit,数据平面开发套件)[插图]等用于网络加速的用户态应用中
图4-12展示了vhost-user/virtio-net-pmd架构下一次网络包的发送过程,具体过程如下。

(1)虚拟机中与DPDK库链接的应用程序通过用户态virtio-pmd驱动发送数据包。首先将数据包写入缓冲区,并在可用的描述符环中添加它们对应的描述符。(2)主机中的vhost-user PMD(Poll Mode Driver,轮询模式驱动)会不断轮询virtqueue,因此它会立即检测到新的描述符,并进行处理。(3)对于每个描述符,vhost-user PMD通过查询设备TLB获得缓冲区的HVA。如果发生TLB未命中,将发送一个对QEMU的请求更新设备TLB。这种情况并不多见,因为DPDK使用了大页机制,TLB未命中的概率很低。(4)vhost-user PMD将缓冲区的数据复制到mbuf(DPDK应用程序使用的消息缓冲区)。缓冲区描述符被vhost-user添加到used描述符环中。这时虚拟机中同样一直在轮询virtqueue的virtio-net-pmd驱动会立即回收used描述符。(5)最后主机中的DPDK APP(应用程序)处理mbuf。DPDK绕过Linux内核网络栈,直接将数据包发送到NIC(Network Interface Controller,网络接口控制器)中,实现数据包的快速转发。
容器作为一种进程级别的虚拟化技术在NFV(Network Functions Virtualization,网络功能虚拟化)领域具有很大的应用潜力。容器通常使用宿主机操作系统内核的网络栈来实现网络数据包转发。然而出于中断处理、网络包复制等原因,内核网络栈每秒最多只能环回1百万~2百万个数据包,并不能满足NFV在吞吐量、延迟和性能抖动等方面的要求。为了解决这一问题,绕过Linux网络栈,提升容器中的网络性能,Intel在2017年将vhost协议移植到了容器[插图],提出了如图4-13所示的virtio-user解决方案。

virtio-user方案在DPDK框架中添加了一个新型的虚拟设备virtio-user,该设备有两种不同类型的网络接口。vhost-kernel接口用于连接主机内核中的vhost-net模块。vhost-user接口遵循上文提到的vhost-user规范,与运行在用户空间的虚拟机交换机连接,并通过虚拟机交换机直接与网卡交互。与QEMU/KVM不同的是,QEMU将整个内存空间布局与vhost后端共享,vhost-user只与vhost后端共享容器内存空间中由DPDK应用程序管理的区域。
2.4 设备直通访问
前文曾提到,I/O虚拟化的一个最基本要求是能够**限制和隔离虚拟机访问不属于自身的I/O设备。**在设备直通访问中为了满足这一要求,Hypervisor需要做到:
- 将外设分配给不同的虚拟机,这些设备称为该虚拟机的指定设备,虚拟机中的vCPU无法访问到其他虚拟机的设备;
- 指定设备的驱动程序只在拥有该设备的虚拟机中运行,并且能在不发生或发生少量VM-Exit情况下直接与设备硬件交互;
- 同时,一个虚拟机的指定设备只能访问属于该虚拟机的物理内存,而且必须将中断发送给虚拟机的中断控制器;
前文提到过,CPU通过PCI配置空间中的BAR获取I/O空间地址或物理地址来访问外设提供的I/O资源。一般情况下,客户机中的驱动程序使用的只是虚拟的外设I/O地址或物理地址空间(GPA),如果直接使用该地址访问设备会产生一系列问题。
例如,当一个虚拟机中的物理驱动程序为I/O设备指定一块用于DMA操作的内存区域时,驱动使用的DMA地址并不是实际的物理内存地址,如果设备直接使用该地址可能会访问到属于其他虚拟机的物理内存,甚至是Hypervisor所在内存区域,给整个系统带来风险。设备模拟和半虚拟化等软件方案可以通过执行对应的I/O处理函数,将虚拟机中驱动程序使用的地址转换为实际的物理地址,并能够对地址的合法性进行检查。而在设备直通访问方案中,Hypervisor并不会参与I/O操作的模拟,也就无法帮助虚拟机完成地址转换过程,这就给虚拟机直接访问物理I/O设备带来了三个问题:
- 第一个问题是如何保证虚拟机的原生驱动能够直接通过真实的I/O地址空间来操作I/O设备?
- 第二个问题是在DMA过程中,如何控制外设访问到虚拟机所在的物理内存地址?
- 第三个问题是如何将设备产生的中断发送到虚拟机的中断控制器中?
其中第三个问题留在中断虚拟化文档中具体分析,本文将从PIO、MMIO、DMA三个方面回答前两个问题。
PIO
在内存虚拟化中,客户机操作系统的页表中存放的是虚拟机物理内存地址 (GPA),需要通过一定方式(例如影子页表、EPT等)将虚拟机物理内存地址转换为主机物理内存地址 (HPA),从而实现各虚拟机之间内存的隔离。设备直通访问同样也需要建立虚拟机I/O地址空间与实际外设I/O地址空间的映射。
在CPU硬件辅助虚拟化中VMCS中存在I/O位图这一概念,I/O位图的一个作用是决定虚拟机是否可以直接访问某个端口,使得Hypervisor能够细粒度地管控I/O。在VM-Excution控制域内有一个I/O位图地址字段 (I/O bitmap Addresses)。该字段包括两个64位的物理地址,这两个地址分别指向两个大小为4KB的I/O位图。
I/O位图中的每个位对应一个I/O端口。当Hypervisor将VM-Excution控制域中的启用I/O位图 (Use I/O bitmaps) 字段置1时,意味着Hypervisor会使用I/O位图控制虚拟机中的I/O指令。当客户机发起对某个物理端口的I/O操作时,如果该端口在I/O位图中对应的位值为0,此时不会发生VM-Exit,客户机会直接访问该物理端口。
目前很多Hypervisor(例如Xen、Xvisor等)都支持多种I/O虚拟化方式。根据应用场景的需要,虚拟机拥有的I/O设备,其中部分是设备模拟技术提供的虚拟设备,另一部分则是Hypervisor直接分配的物理设备。在这种情况下,虚拟设备的PCI BAR由客户机中的虚拟BIOS配置,可能会与直接分配的物理设备的PCI BAR产生冲突,所以不能直接使用上文介绍的直接访问物理端口的方式。Hypervisor会维护一个直通设备的虚拟PCI BAR与真实PCI BAR之间的映射表,并将虚拟PCI BAR中的端口在I/O位图中对应的位置为1。这样虚拟机通过直通设备的虚拟PCI BAR发起I/O访问时会产生VM-Exit陷入Hypervisor中,然后Hypervisor会根据映射表将访问请求发送给直通设备的真实I/O端口。
MMIO
由于内存与MMIO同属于物理地址空间,且MMIO使用与内存访问相同的访存指令通过物理地址完成对I/O资源的访问,所以理论上可以使用内存虚拟化的相关机制解决MMIO问题。
在内存虚拟化章节中,介绍了Intel VT提供的内存虚拟化支持,扩展后的MMU能够查询EPT,以硬件实现的方式完成GPA到HPA的映射过程。在开启EPT功能的物理机上,Hypervisor可以在客户机私有的EPT中建立反映虚拟MMIO物理地址与实际MMIO物理地址之间映射关系的页表项。之后当客户机以MMIO的方式访问那些分配给它的物理外设时,EPT机制会在不产生VM-Exit的情况下(假设不发生EPT Misconfiguration和EPT Violation)完成对物理外设的访问。
DMA
为了解决第二个问题,将DMA过程中对内存的访问限制在发起DMA的设备所在虚拟机的物理内存区域,Intel的VT-d技术提供了DMA重映射机制,在位于PCI总线树根部的北桥芯片中引入了DMA重映射硬件。
在启动DMA重映射硬件的系统上,当根节点下的PCI设备尝试通过DMA的方式访问内存时,DMA重映射硬件会拦截该访问,并通过查询I/O页表的方式来确定本次访问是否被允许,并重映射到内存访问的实际位置。**I/O页表是与分页机制类似的页表结构,I/O页表的创建和维护由Hypervisor负责,**后面将会详细介绍I/O页表。
在I/O虚拟化中,每个虚拟机都拥有与主机物理地址空间不同的物理地址空间视图。DMA重映射硬件将从I/O设备发过来的访问请求中包含的地址看作是DMA地址。根据不同的使用配置,**设备的DMA地址可以是分配给它的虚拟机的GPA、发起DMA请求的宿主机进程的HVA、客户机进程的GVA以及IOVA。**根据DMA地址空间的不同,DMA重映射硬件将来自I/O子系统的DMA访问请求分为如下两类:
- **不带PASID(Process Address Space Identifier,进程地址空间标识符)的请求:**这类请求使用的DMA地址一般是GPA和IOVA,并且通常会表明该请求的类型(读、写或原子操作)、DMA目标的地址、大小和发起请求的源设备的ID等信息;
- **带有PASID的请求:**这类请求使用的DMA地址一般是GVA和HVA,只有具备虚拟地址功能 (Virtual Address Capability) 的PCI设备才能发出这类请求。除了
1提到的信息之外,这类请求还带有用于定位进程地址空间的PASID和一些其他信息。
虽然I/O子系统的内存访问请求有不同种类,DMA地址的类型也有所不同,但**重映射硬件的最终任务都是要将DMA地址转换成HPA,并实现对物理地址的访问。**DMA地址重映射发生在地址解码、查询处理器缓存或转发到内存控制器等进一步的硬件操作之前。下图是DMA地址重映射的一个例子:

首先,I/O设备1和I/O设备2分别被分配给虚拟机1和虚拟机2。之后,Hypervisor为两个虚拟机分配系统物理内存,并开启DMA地址重映射功能。设备1和设备2分别发起与目的物理地址相同的DMA请求,但由于设备分属于不同的虚拟机,DMA重映射硬件会将其分别转换为设备所在虚拟机的实际物理地址。
DMA重映射硬件每次捕获设备DMA请求时,需要先识别发起该请求的外设。一般使用源标识符 (Source Identifier) 标识发起DMA操作的设备。重映射硬件可以根据设备的类别以及I/O事务的具体实现方式确定外设的源标识符。例如,一些I/O总线协议中,源标识符是I/O事务的一个组成部分。对于PCI设备,PCI设备标识符能够唯一确定某个PCI设备,因此PCI总线使用PCI设备标识符作为源标识符。
在x86架构下,内存的页表基地址会存储在CR3寄存器中,CPU通过读取CR3的值即可获得当前进程的页表。之后,通过查询页表过程即可以完成虚拟地址与物理地址的转换。设备的I/O地址转换过程则较为复杂,DMA重映射硬件支持两层地址转换,第一级转换 (First-level Translation) 负责将GVA重新映射到GPA,第二级转换则将GPA重新映射到HPA。I/O设备的DMA请求有两种——不带PASID和带有PASID,由于前者使用的是GPA,所以只需要第二级转换,而后者使用的是GVA,则需要两层地址转换过程。
VT-d提供两种地址翻译模式,传统 (Legacy) 地址转换模式与可扩展 (Scalable) 地址转换模式。传统模式仅支持第二级地址转换,所以只能应用于不带PASID的请求。可扩展模式支持两层地址转换,能够同时支持不带PASID和带有PASID的DMA请求。下文分别介绍这两种模式。
在传统模式中,如下图所示,根条目表作为最顶层的结构将设备映射到各自的虚拟机,所以根条目表也是DMA地址转换的起点。根条目表在系统内存中的位置是由根条目表地址寄存器 RTADDR_REG 决定的。

下图描述了 RTADDR_REG 的字段分布情况。根条目表地址寄存器的转换表模式字段 (RTADDR_REG.TTM) 用于表示目前所使用的地址翻译模式。

当RTADDR_REG 的TTM字段为00时,代表处于传统模式,RTADDR_REG 的根条目表地址字段 (RTADDR_REG.RTA) 保存的是指向根条目表的指针。
根条目表 (Root Table) 的大小为4KB,包含256个根条目,每个根条目对应一条PCI总线。在检索根条目表时,使用源标识符字段中的总线号(高8位)作为索引定位到发起DMA的设备所在总线对应的根条目。根条目中包含一个上下文条目表的指针,该指针用于获取总线上所有设备共同的上下文条目表。每个上下文条目表包含256个条目,每个条目对应于总线上的某个PCI设备。对于PCI设备,将使用源标识符的设备号和功能号(较低的8位)索引上下文条目表,以获得目标设备对应的上下文条目。上下文条目中保存有一个第二级转换页表的基地址。使用DMA地址查询该转换页表时,会依次映射到I/O页表的多级地址转换结构,并最终将DMA请求中的GPA转换为HPA。
当 RTADDR_REG.TTM 为01b时,此时DMA重映射硬件处于下图所示的可扩展地址转换模式,RTADDR_REG 的RTA字段保存指向可扩展模式下根条目表的指针。

可扩展模式根条目表与根条目表类似,大小都是4KB,并且同样包含256个根条目,每个根条目对应一条PCI总线。与根条目中将 [64∶127] 设为保留字段不同的是,每个可扩展模式根条目分为[0∶63]与[64∶127]两部分。如下图所示,低64位中的LCTP(Lower Context Table Pointer,低上下文表指针)字段指向下半部(lower)上下文条目表,高64位中的UCTP(Upper Context Table Pointer,高上下文表指针)字段指向上半部(upper)上下文条目表。

下半部上下文条目表的大小为4KB,包含128个可扩展模式上下文条目,对应总线上设备号在 0~15 的PCI设备。上半部上下文条目表的大小也是4KB,包含128个可伸缩模式上下文条目,对应总线上设备号在 16~31 的PCI设备。在可扩展模式下,处理不带PASID的请求与带有PASID的请求类似,一样可以从可扩展模式上下文条目的RID_PASID字段中获得PASID值。可扩展模式上下文条目的PASIDDIRPTR字段包含指向可扩展模式PASID目录的指针。
请求的PASID值的高14位 (19∶6) 会用于索引可伸缩模式的PASID目录。每个现有的可伸缩模式PASID目录条目的SMPTBLPTR字段都包含一个指向可伸缩模式PASID表的指针。请求的PASID值的较低6位(5∶0)被用于索引可伸缩模式的PASID表。PASID表条目的FLPTPTR字段和SLPTR字段分别包含第一级地址转换和第二级转换页表的指针。同时,PASID表条目的PGTT字段表明需要进行哪些地址转换过程,PGTT为001b时代表只进行第一阶段地址转换,010b代表只进行第二阶段地址转换,011b则意味着需要执行嵌套转换以达到双层地址转换的目的。
DMA重映射在第一级地址转换和第二级地址转换中都使用了多级页表结构。第一级地址转换支持4级结构或5级结构。第二级地址转换支持N级结构,其中N的值取决于由 Capability 寄存器支持的GAW(Guest Address Width,客户机地址宽度)。每级页表的大小都为4KB,拥有512个页表项。页表的翻译过程与内存分页机制类似,在此不再过多介绍。I/O页表同样支持4KB、2MB、1GB等粒度的页面大小。通过 Capability 寄存器可以查询系统支持页面大小的粒度信息。
在内存分页机制中,CPU能够借助Cache、TLB等缓存机制来加速CPU对内存的访问过程。重映射硬件也可以通过缓存与重映射地址转换相关的数据结构来加速地址转换过程。
下面是重映射硬件地址翻译缓存 (Translation Cache) 的几种类型:
- **上下文条目缓存:**该缓存的作用是缓存上下文条目,用于地址翻译请求。翻译请求中的源标识符用于索引对应的缓存条目;
- **PASID缓存:**该缓存的作用是缓存可伸缩模式的PASID表条目,这些条目用于地址转换。该缓存只在启用可缩放模式转换时使用;
- **IOTLB:**IOTLB(I/O Translation Lookaside Buffer,I/O翻译后备缓存器)中的每个条目负责保存请求地址的页号与对应物理页之间的映射;
- **分页结构缓存:**该缓存的作用是缓存经常使用的分页结构条目,这些条目用于引用其他分页结构条目。缓存的分页结构条目可以是PML5缓存、PML4缓存、PDPE缓存或PDE缓存。
在重映射硬件上,翻译缓存可以支持多个外部设备的请求。**缓存效率取决于平台中同时活动的DMA流的数量和DMA访问的地址局部性。**重映射硬件持有的翻译缓存资源十分有限,某些情况下可能无法满足设备使用需求。一种扩展缓存资源的方式是让外设参与重映射过程,将翻译缓存实现在外设中。设备上的这些翻译缓存称为设备TLB。设备TLB减轻了北桥芯片中翻译缓存的压力,同时使设备在发起DMA请求之前,通过翻译缓存使实现地址转换成为可能。设备TLB可以用于对访问延迟要求较高的设备(如实时设备),以及具有高DMA工作负载或多个DMA流的设备。
2.5 VFIO
由于Linux遵循GPL(General Public License,通用公共许可证)开源协议,根据GPL协议内容,所有基于内核的驱动程序同样应该遵守GPL开源协议。一些商业公司推出的设备驱动会采用闭源策略,例如英伟达公司的GPU驱动基本是闭源的。AMD公司虽然积极参与Linux GPU驱动开源工作,但是AMD闭源GPU驱动的性能通常优于开源驱动。为了绕过Linux的GPL协议,厂商通常使用用户态驱动框架来达到在Linux上闭源的目的,例如英伟达公司将CUDA驱动和OpenGL驱动实现在用户态。除了能够绕开开源协议,这种UIO(Userspace I/O,用户空间I/O)拥有开发工作量少、易于调试以及能够集成到特定应用程序(例如前文提到的DPDK)中等优势。
UIO虽然拥有上述诸多优势,但是作为一个运行在用户态的驱动它有一个致命的缺点,就是无法动态申请DMA区域。因为如果UIO可以随意发起DMA请求,那么意味着普通用户可以读写任何物理地址的内容,这样就会给系统的安全性造成极大的威胁。幸运的是该问题能够被上文介绍的VT-d(GVA/HVA -> HPA)技术解决,因此基于IOMMU的另外一套用户态驱动框架——VFIO(Virtual Function I/O,虚拟功能I/O)应运而生。
VFIO会为用户态进程提供一系列交互接口。同UIO一样,VFIO使用一个非常简单的内核模块为用户提供名为 /dev/vfio/$Group 的用于设备访问的文件接口。用户进程可以通过一组ioctl与VFIO进行交互,例如ioctl函数可以配置IOMMU,并将DMA地址映射到进程地址空间,从而允许用户态驱动发起DMA操作,除此之外,还可以通过ioctl配置对应的PCI配置空间、获取相关中断信息并注册中断处理函数。VFIO相较于UIO更加安全,能够限制用户态驱动对内存地址的访问,这一特性使得VFIO能够应用于虚拟化环境。同时VFIO也可以被集成在DPDK等特定应用程序中,相较于UIO,VFIO的使用优先级更高。
2.6 SR-IOV
SR-IOV架构如下图所示:

首先介绍 PF/VF 的概念:
- **PF是一种支持SR-IOV功能的PCI功能,**它可以对设备所有的物理资源进行配置,将其划分为多个资源子集,并在资源子集之上虚拟出VF;
- **VF是一种轻量级PCIe功能,**可以看作是从PF中分离出来的部分I/O功能,它与PF相关联,拥有属于自己的一组用于数据交互的资源。这些资源包括用于数据传输的内存地址空间、中断以及I/O端口等,这些资源会记录在VF专属的配置空间中。
在SR-IOV标准下,Hypervisor或者某一特权虚拟机通过调用PF驱动,开启设备的SR-IOV功能,并创建和管理多个VF设备。之后使用类似于直通访问的模式,将VF设备分配给其他虚拟机。虚拟机无须通过Hypervisor提供虚拟设备来访问I/O设备,可以通过PIO、MMIO、DMA等方式直接与VF交互。
在系统启动时,物理BIOS会自行为VF分配配置空间并初始化,并将分配给设备的内存地址、I/O端口地址记录在BAR中。出于安全性和隔离性等方面的考虑,客户机并不能直接使用BAR中保存的HPA,也不能直接修改VF配置空间。Hypervisor需要为客户机提供虚拟的VF配置空间,将配置空间的地址信息替换为GPA,同时建立并维护GPA到HPA的映射表。Hypervisor会截获客户机对VF的访问请求,然后根据GPA与HPA的映射关系定位到实际的地址空间执行。
3 QEMU/KVM虚拟设备的实现
本节将以QEMU/KVM为基础,深入代码层面介绍I/O虚拟化在Hypervisor中的实现。
本节选择Masaryk大学编写的edu设备作为示例,edu设备属于PCI设备,设备源代码位于QEMU的 /hw/misc 路径下。**edu设备结构比较简单,并且不与实际的物理设备交互,是一个纯粹的 “虚拟” 设备,**但它的功能较为全面,以该设备为例能够清晰地展示在QEMU中实现一个虚拟设备的整个过程。
本节的讲解流程如下:
- 首先会通过描述QEMU中的QOM(QEMU Object Model,QEMU对象模型)机制来展示edu设备对象的注册与创建过程;
- 之后会介绍主板芯片的模拟和PCI总线结构的创建与初始化过程;
- 然后介绍PIO和MMIO在QEMU中的处理过程;
- 最后,我们将以实验的形式**为edu设备编写配套的设备驱动,**来展示在QEMU中如何模拟一个虚拟PCI设备,包括模拟虚拟设备的访问接口、实现设备DMA以及自定义的设备功能、完成设备中断发送等过程。
3.1 QEMU对象模型
在QEMU/KVM架构中,QEMU在整个架构中作为一个用户态进程运行在VMX根模式的Ring3特权级,**与vCPU创建和设备模拟相关的内容由QEMU负责。**经过多年的发展,QEMU能够模拟多种架构的CPU和大量的设备。不同架构的CPU之间以及同种架构不同型号CPU之间拥有通用属性同时也有自身的特性。对于设备来说也存在这种情况。例如网卡作为一种PCI设备,拥有自己的功能,也遵循PCI通用标准,同样PCI设备也属于设备的一种类别。
熟悉面向对象编程语言的朋友应该会想到这种情况适合面向对象的思想,可以将不同类型设备之间的共性抽象成一个设备父类,某一类设备同时也是特定设备的父类。
C语言本身并不支持面向对象,早期QEMU的每种设备都有不同的表示方式,无法利用不同设备之间的共性,导致代码混乱且冗余。为了改变这一情况,QEMU推出了QOM。从某种程度上来说,**QOM也可以看作QEMU在C语言的基础上实现的一套面向对象机制,**负责将CPU、内存、总线、设备等都抽象为对象,其中总线和设备模拟占了很大的比重。所以在讲总线和设备初始化之前,首先以edu设备对象的初始化为例介绍QOM。
在QOM中,一类设备被抽象为一个对象类,一个设备实例被抽象为一个对象实例,对象和对象实例均存在继承关系,其中ObjectClass是所有对象类的基类,Object是所有对象实例的基对象,有点类似于C++中的类和对象。
除了上述对象类和对象实例外,QOM对象初始化还涉及TypeInfo和TypeImpl两个数据结构。TypeInfo是对对象类的描述,往往包含类名、父类名、类初始化函数、类实例大小等描述性信息。TypeImpl由TypeInfo注册得到,存储在全局type_table中。TypeImpl与TypeInfo最大的不同在于,TypeImpl持有对其对象类的引用,因此要从TypeInfo得到ObjectClass,必须先将TypeInfo转化为TypeImpl。
QOM中对象的初始化可分为四步:①将TypeInfo注册为TypeImpl;②创建对象类;③创建对象实例;④具现对象实例。
设备对象类注册
TypeInfo注册为TypeImpl包含两个步骤:
- 首先将TypeInfo转换为ModuleEntry;
- 然后通过ModuleEntry存储的初始化函数将TypeInfo转换为TypeImpl,并添加到全局type_table中。
以edu设备为例,TypeInfo转换为ModuleEntry的具体代码如下:
qemu-4.1.1/hw/misc/edu.c

edu设备代码中会静态定义TypeInfo(即 edu_info),type_init 函数则是由CRT (Crun-time) 负责执行。
type_init 函数接受一个初始化函数指针作为参数,创建一个ModuleEntry存储初始化函数指针及ModuleEntry的类型。QEMU中定义了几种不同类型的ModuleEntry结构体,同一种类型的ModuleEntry链接为ModuleTypeList,全部ModuleTypeList则存储于全局数组 init_type_list 中。组织结构如下图所示:

edu_info 注册的ModuleEntry对应的类型为MODULE_INIT_QOM,其余类型还有MODULE_INIT_BLOCK、MODULE_INIT_OPTS等。为edu_info注册对应的Mod-uleEntry后,通过 module_call_init 函数便可以将edu_info转换为TypeImpl,整个函数的调用流程如下图所示:

module_call_init 函数遍历ModuleTypeList中的ModuleEntry并执行其存储的初始化函数。对于edu_info而言,其对应的初始化函数就是type_init 函数传入的函数指针,即 type_register_static(&edu_info) 函数。
type_register_static 函数通过 type_register 调用 type_register_internal 函数注册edu设备的TypeInfo。type_register_internal函数调用 type_new 函数将TypeInfo转换为TypeImpl,并调用 type_table_add 函数将得到的TypeImpl添加到全局的 type_table 中。
设备对象类创建
完成edu设备对象类注册之后还需要创建该对象类。创建对象类有两种方式:
- 一种是主动调用
object_class_get_list接口函数,比如object_class_get_list(TYPE_DEVICE,false)函数,创建TYPE_DEVICE类型的ObjectClass; - 另一种是被动调用,如
object_class_by_name函数、object_class_get_parent函数、object_new_with_type函数,object_initialize_with_type函数。
无论是主动调用还是被动调用,这些接口最终都会调用 type_initialize 函数,type_initialize 的调用过程如下图所示:

type_initialize 函数接受一个TypeImpl结构体作为参数,代码流程如下:
- 首先为该TypeImpl对应的对象类分配内存空间,并将TypeImpl的class成员指向该内存区域;
- 然后调用
type_get_parent函数获取其父对象类的TypeImpl;type_get_parent函数最后会调用type_get_by_name函数,而前面提到type_get_by_name函数最终也会调用type_initialize函数,从而实现对父类的初始化。这样就形成了递归调用,逐级向上初始化父对象类,直至到达根对象类ObjectClass; type_initialize函数随后调用memcpy函数将父对象类复制到其内存空间的前面,这样只要知道父对象类和子对象类的大小,就可以轻松实现父类和子类之间的转换;- 最后
type_initialize函数将调用父类的class_base_init函数和该TypeImpl的class_init函数进行初始化。edu_info定义edu对象类的class_init函数为edu_class_init函数。
edu_class_init 函数设置了edu设备的realize函数,该函数用于下文提到的edu对象实例具现化,同时还设置了edu设备的厂商号与设备号等设备属性。edu_class_init 函数的具体代码如下:
qemu-4.1.1/hw/misc/edu.c

设备对象实例创建
QOM将一个具体的设备抽象为一个对象实例,每个对象实例都对应一个 XXXState 结构体,记录设备自身信息。在edu设备源码中定义了edu设备对象的结构体EduState,EduState中包含MMIO内存区域信息、设备状态、中断返回状态、DMA相关信息等属性。定义EduState的具体代码如下:
qemu-4.1.1/hw/misc/edu.c

**对象实例与对象类类似,也存在继承关系,**Object数据体是所有对象实例的根对象实例。定义Object的具体代码如下:
qemu-4.1.1/include/qom/object.h

根据上述定义,对象实例持有对其所述对象类的引用。因此在创建对象实例时,需要创建相应的对象类,也就是上文提到的被动创建对象类。在完成edu设备对象类的初始化后,QEMU已经能够创建edu设备对象实例。一般的做法是在QEMU启动命令行中添加 -device edu 参数。QEMU在检查到该参数后,会调用 qdev_device_add 函数添加edu设备。
用于创建对象实例的接口包括 object_new 函数和 object_new_with_props 函数,它们最终都会调用 object_new_with_type 函数,qdev_device_add 函数使用的是 object_new 接口函数。object_new_with_type 函数的调用路径如下图所示:

object_new_with_type 函数接收edu设备的TypeImpl结构体作为参数,代码流程如下:
- 首先调用
type_initialize函数确保edu设备对象类被初始化; - 然后为edu对象实例分配大小为 sizeof(EduState) 的内存空间;
- 最后调用
object_initialize_with_type函数初始化对象实例。object_initialize_with_type函数首先为EduState中的属性分配Hash表;- 然后调用
object_init_with_type函数。object_init_with_type函数首先判断该实例对象是否有父实例对象,若有,则递归调用object_init_with_type函数对其父实例对象进行初始化;- 最后调用TypeImpl的
instance_init函数。TypeImpl中的instance_init函数在TypeInfo注册为TypeImpl时设置,edu设备在edu_info中将该函数设置为edu_instance_init函数,该函数将初始化EduState并设置edu设备的DMA掩码。edu设备的DMA掩码默认为28位,即只支持256MB地址范围。
edu_instance_init 函数的具体代码如下:
qemu-4.1.1/hw/misc/edu.c

前面提到,所有对象实例的根对象实例都是Object,各对象实例之间的继承关系如下图所示,此处仅列出它们的类型:

设备对象实例具现化
此时已经创建了edu设备对象实例并调用了实例初始化函数 edu_instance_init,但由于此时EduState里的属性并未分配,所以并不能立即使用,必须具现化该对象实例。
所谓具现对象实例是指调用设备对象实例的realize函数(如创建一个磁盘设备对象实例)后,它仍不能使用,只有在realize函数中将其挂载到总线上后,相应的I/O指令才能真正访问到该设备。
此处仍以edu设备为例进行说明,TYPE_DEVICE类型的对象实例对应的TypeInfo结构体为 device_type_info,其定义如下:
qemu-4.1.1/hw/core/qdev.c

根据上述定义,设备对象实例对应的结构体为DeviceState,它的 instance_init 函数为 device_initfn 函数。前面提到创建对象实例时会逐级向上递归调用其父类型的 instance_init 函数,所以在创建edu设备对象实例时会调用 device_initfn 函数。
device_initfn 函数则会调用 object_property_add_bool 函数为设备对象实例添加一个名为realized的属性。与属性名一同传入的还有两个回调函数,device_get_realized 函数和 device_set_realized 函数,它们分别为realized属性的 getter/setter 函数。
若后续调用 object_property_get_bool/object_property_set_bool 函数读取/设置realized属性时最终会调用到device_get_realized/device_set_realized 函数,device_set_realized 函数则会调用DeviceState中存储的realize成员。**因此每次调用object_set_property_bool 设置realized属性时会触发设备的realize回调。**具体代码如下:
qemu-4.1.1/hw/core/qdev.c

不同设备对象实例对应的realize函数不同,上文提到,edu设备对象实例在其类实例初始化函数 edu_class_init 中将realize函数设置为pci_edu_realize 函数。在此简要介绍该函数的功能。pci_edu_realize 函数具体代码如下:
qemu-4.1.1/hw/misc/edu.c

pci_edu_realize 函数会初始化edu设备配置空间中的部分数据并设置设备的功能,代码流程如下:
- 该函数首先调用
pci_config_set_interrupt_pin函数,将PCI配置空间中Interrupt Pin寄存器(0X3D)的值设置为1,这意味着edu设备使用INTA#脚申请中断; - 之后调用
msi_init函数设置PCI配置空间以开启MSI功能; timer_init_ms函数用于注册定时器,不间断地查看是否有DMA传送需求;qemu_thread_create函数用于创建一个线程,该线程用于阶乘计算,属于edu设备的一个特定功能;memory_region_init_io函数初始化一个MMIO内存区域,该内存区域大小为1MB,并指定该MMIO内存区域的读写函数edu_mmio_ops。在edu_mmio_ops函数中指定edu_mmio_read/edu_mmio_write函数作为MMIO读写回调函数,该回调函数就是内存虚拟化文档中提到的MMIO处理函数,负责模拟虚拟设备的MMIO访问;pci_register_bar函数把上一步设置好的MMIO参数注册到设备配置空间的第0号BAR。
至此edu设备的具现化便完成了,此时用户或客户机可以通过设备驱动使用该设备。
本节通过edu设备这一例子,介绍了QEMU中使用的QOM工作机制,阐述了一个PCI设备在QEMU中注册并初始化对象类、创建和初始化对象实例以及最终具现化对象实例的过程。但本节并未涉及与PCI设备在总线上注册相关的内容,这部分内容会在3.2节介绍。
3.2 主板芯片组与总线模拟
在虚拟机启动之前,QEMU会模拟并初始化主板上的芯片组,例如南北桥芯片。在命令行输入 qemu-system-x86_64-machine help,终端会显示QEMU支持 i440FX+PIIX 和 Q35+ICH9 这两种芯片组。QEMU最初只提供 I440FX+PIIX 架构,该架构诞生年代久远,不支持PCIe、AHCI等特性,为了顺应芯片组的发展,Jason Baron在2012年的KVM forum上为QEMU加入Q35芯片组支持。
本文仅介绍QEMU默认的I440FX架构,对QEMU中与Q35架构相关内容可以阅读QEMU提供的文档与源码。
I440FX是Intel公司在1996年推出的第一款能够支持Pentium Ⅱ的主板芯片,它集成了多种系统功能,在主板上作为北桥,负责与主板上高速设备以及CPU的连接。PIIX(PCI ISA IDE Xcelerator,南桥芯片)本质上是一个多功能PCI设备,被称作南桥,PIIX由I440FX引出,负责与主板上低速设备的连接。下图为**QEMU中模拟的I440FX主板架构,**该图所示的架构与芯片组实际架构基本对应:

I440FX中的PMC(PCI Bridge and Memory Controller,PCI桥内存控制器)提供了控制内存访问的接口,PCI主桥则作为控制和连接PCI总线系统的PCI根总线接口,因此I440FX可以向下连接内存并且可以通过PCI根总线接口扩展出整个PCI设备树,其中PIIX南桥芯片位于PCI 0号总线。I440FX同时还可以通过连接HOST总线向上与多个CPU相连。如上图所示,PIIX作为南桥可以与IDE控制器、MA控制器、USB控制器、SMBus总线控制器、X-Bus控制器、USB控制、PIT、RTC(Real Time Clock,实时时钟)、PIC设备相连,同时PIIX还提供了PCI-ISA桥,用于连接ISA总线进而实现与传统ISA设备的连接。
I440FX芯片初始化
在QOM工作机制中,QEMU的所有设备都被抽象为对象,对于整个机器来说也不例外,虚拟机同样拥有属于自己的对象类型。在QEMU中定义了机器的对象类型,使用 MachineClass 数据结构表示。MachineClass 的类别与主板芯片类型相关联,通常由特定的宏来定义。例如DEFINE_Q35_MACHINE/DEFINE_I440FX_MACHINE 分别定义了Q35主板架构与I440FX主板架构的机器。
下面将介绍**I440FX架构初始化过程,**部分代码如下:
qemu-4.1.1/hw/i386/pc_piix.c

DEFINE_I440FX_MACHINE 宏由两部分组成:
- 首先该宏定义了I440FX虚拟机的初始化函数
pc_init_# #suffix,其中suffix代表I440FX的版本,该函数通过调用pc_init1函数来完成对整个虚拟机的初始化。pc_init1函数是整个虚拟机初始化的核心,涵盖虚拟机的方方面面,I440FX主板芯片组的初始化也由该函数负责。 - 第二部分
DEFINE_PC_MACHINE也是一个宏,在不同主板架构的机器间通用,**负责虚拟机对象类型的注册与初始化。**具体代码如下:
qemu-4.1.1/include/hw/i386/pc.h

该宏的具体工作如下:
- 定义了虚拟机对应的TypeInfo,即
pc_machine_type_##suffix,并将父类型设置为TYPE_PC_MACHINE,同时把class_init函数设置为pc_machine_##suffix##_class_init函数。该函数负责创建虚拟机对象类型,并把类的初始化函数设置为上文提到的pc_init_##suffix函数。 - 之后
type_init(pc_machine_init_##suffix)函数负责注册虚拟机对象类型,注册的具体过程上文已经介绍,不再赘述。
在虚拟机初始化过程中,之前提到的 pc_init1 函数会对I440FX主板进行初始化。其中 i440fx_init 函数和 piix3_create 函数分别是I440FX北桥芯片和PIIX3南桥芯片的初始化函数。pc_init1 函数的部分代码如下:
qemu-4.1.1/hw/i386/pc_piix.c

i440fx_init 函数需要传入多个参数,这里主要关注前三个参数。其中host_type与pci_type参数对应于 pc_init1 函数的后两个宏定义参数:
host_type代表I440FX芯片的PCI主桥部分;pci_type代表I440FX芯片在PCI总线上的部分。
该PCI设备的设备实例用PCII440FXState表示。&i440fx_state 参数传入的是 pc_init1 函数中定义的PCII440FXState类型指针。宏定义的具体代码如下:
qemu-4.1.1/include/hw/i386/pc.h

与I440FX芯片的结构相对应,I440FX芯片初始化分为PCI主桥(即北桥)和PCII440FX初始化两部分。i440fx_init 函数的核心代码如下:
qemu-4.1.1/hw/pci-host/piix.c

qdev_new(host_type)函数的作用是创建PCI主桥,该函数与添加edu设备时调用的qdev_device_add函数类似,都是通过调用object_new接口函数,根据传入的设备类型创建设备对象实例;x86架构在PCI主桥提供
config_address寄存器(端口地址为0xCF8)与config_data寄存器(端口地址为0xCFC)这两个32位寄存器,用于访问PCI设备的配置空间。在PCI主桥设备实例创建和具现化过程中完成对这两个寄存器的初始化,并将其加入I/O地址空间中。在pci_host.c文件中定义了函数pci_host_config_write、pci_host_config_read、pci_host_data_write和pci_host_data_read。这四个函数负责config_address寄存器和config_data寄存器的读写。PCI主桥设备实例创建和具现化的具体代码如下:
qemu-4.1.1/hw/pci-host/piix.c创建实例
qemu-4.1.1/hw/pci-host/piix.c具现化
然后
pci_root_bus_new函数会调用qbus_create函数创建一条PCI总线,该总线也称为0号总线,之后调用pci_root_bus_init函数对总线进行初始化并将其挂载到PCI主桥;然后从
pci_root_bus_new函数退出,执行i440fx_init函数中的qdev_init_nofail函数,该函数最终会调用object_property_set_bool(OBJECT(dev),"realized",true,errp)函数。object_property_set_bool函数会将北桥设备的realized属性设置为true,触发北桥设备具现化函数的回调。pci_root_bus_new函数的具体代码如下:qemu-4.1.1/hw/pci/pci.c
至此,I440FX芯片第一阶段的PCI主桥初始化结束。
在I440FX初始化第二阶段,pci_create_simple 函数直接调用 pci_create_simple_multifunction 接口函数,并最终调用 object_new 函数与 object_property_set_bool 函数完成PCI I440FX设备的创建和具象化过程。最终PCI I440FX设备会被挂载到PCI 0号总线(根总线)的0号插槽。
在较新版本的QEMU源码中,I440FX和PIIX3的初始化由 pc_init1 函数中的两个 i440fx_init 函数和 piix3_create 函数分别执行。i440fx_init 执行结束后会把PCI根总线返回给 pc_init1 函数,随后 pc_init1 函数会将PCI根总线作为参数传入 piix3_create 函数。然而在QEMU 4.1.1版本,PIIX3设备的创建过程也由 i440fx_init 函数执行,i440fx_init 函数使用与PCI I440FX设备相同的pci_create_simple_multifunction 接口创建和具现化PIIX3设备。
在PIIX3设备对象的具现化函数 piix3_realize 中,会通过 isa_bus_new 函数创建一条ISA总线,该ISA总线会挂载到PIIX3下。最后 pci_bus_irqs 函数和 pci_bus_set_route_irq_fn 函数会设置PCI根总线的中断路由信息。QEMU 4.1.1版本PIIX3设备创建的部分代码如下:
qemu-4.1.1/hw/pci-host/piix.c

PCI总线是一个多级结构,PCI设备、PCI-PCI桥、PCI-ISA桥设备会注册到总线上。桥设备会扩展PCI总线结构,例如PCI-PCI桥设备会创建下一级PCI总线。这样就形成了 “总线—设备—总线—设备” 的树形结构。目前 pci_root_bus_new 函数已经在主桥下创建了PCI根总线,pc_init1 函数之后会将系统默认的一些PCI设备(例如e1000网卡、VGA控制器)注册到PCI根总线上。
PCI设备注册到总线上
**PCI设备的注册是在PCI设备具现化过程中完成的。**下文仍以edu设备为例介绍PCI设备的具现化过程。
3.1节提到,edu设备初始化过程会调用父类型的实例初始化函数。edu设备属于PCI设备,其父类型为PCIDeviceClass,该类型的初始化函数为pci_device_class_init 函数。pci_device_class_init 函数会将PCIDeviceClass的realize函数设为默认的 pci_qdev_realize 函数。pci_device_class_init 函数的具体代码如下:
qemu-4.1.1/hw/pci/pci.c

pci_qdev_realize 函数首先会调用 do_pci_register_device 函数执行通用的PCI设备初始化流程,包括设置edu设备在对应总线上的插槽号、初始化edu设备的地址空间、分配edu设备的配置空间并初始化配置空间里的部分寄存器、设置配置空间的读写函数、将edu设备加入所在总线的devices数组中。具体代码如下:
qemu-4.1.1/hw/pci/pci.c

之后 pci_qdev_realize 函数会执行edu设备的realize函数,该函数的作用3.1节已经介绍,此处不再赘述。部分PCI设备可能会有专属的设备ROM,在 pci_qdev_realize 函数中,最后会执行 pci_add_option_rom 函数将ROM文件注册到PCI设备的BAR中。如果edu设备不存在ROM,进入 pci_add_option_rom 函数后会直接返回。
至此,edu设备便被完全初始化并挂载到对应的PCI总线之上。
3.3 QEMU/KVM设备访问的模拟
本节将完整介绍QEMU/KVM架构处理虚拟机发起的PIO与MMIO请求过程。
在QEMU/KVM架构下,KVM会将x86架构提供的用于端口访问的 IN/OUT、INS/OUTS 指令设置为敏感指令,使得虚拟机在发起PIO时会产生VM-Exit,进而陷入KVM和QEMU中进行处理。在介绍QEMU/KVM如何将上述指令设置为敏感指令之前,首先回顾一下I/O位图的相关概念。
VM-Excution 控制域的I/O位图地址字段会包含两个64位的物理地址,这两个物理地址分别指向两个大小为4KB的I/O位图A和I/O位图B。两个I/O位图中的每位都对应一个I/O端口,其中I/O位图A包含的I/O端口地址范围为 0x0000~0x7fff,I/O位图B包含的I/O端口地址范围为0x8000~0xffff。当某个I/O端口在I/O位图中的对应位为1时,代表当虚拟机访问该端口时会发生VM-Exit。因此,KVM通过将VMCS中的I/O位图全部置为1,便可以实现对虚拟机中端口访问指令的截获。
虚拟机发起PIO访问
从VM-Exit到处理IO
当vCPU因虚拟机执行PIO指令发生VM-Exit时,vmx_vcpu_run 函数将 vmx->exit_reason 设置为EXIT_REASON_IO_INSTRUCTION后会返回至vcpu_enter_guest 函数。之后 vcpu_enter_guest 函数通过kvm_x86_ops的handle_exit成员调用 vmx_handle_exit 函数。
vmx_handle_exit 函数根据前面的 vmx->exit_reason 为EXIT_REASON_IO_INSTRUCTION,进而调用全局数组 kvm_vmx_exit_handlers 中用于处理PIO产生的VM-Exit的处理函数 handle_io 。该过程的具体代码如下:
linux-4.19.0/arch/x86/kvm/vmx.c

linux-4.19.0/arch/x86/kvm/vmx.c

linux-4.19.0/arch/x86/kvm/vmx.c

KVM的IO处理函数 (内核态)
handle_io 函数首先会调用 vmcs_readl 函数从VMCS中读取VM-Exit相关信息,包括端口访问方式in(读或写)、访问数据大小size以及端口号port。然后 handle_io 函数会将上述信息作为参数传递给 kvm_fast_pio 函数做进一步处理,具体代码如下:
linux-4.19.0/arch/x86/kvm/vmx.c

kvm_fast_pio 函数根据in的值调用 kvm_fast_pio_in 函数或 kvm_fast_pio_out 函数。在此以 kvm_fast_pio_out 函数为例:
kvm_fast_pio_out函数首先会调用kvm_register_read函数获取要写入的数据;- 之后进入
emulator_pio_out_emulated函数将写入的数据复制到vcpu->arch.pio_data中; - 最终控制流会进入
emulator_pio_in_out函数,emulator_pio_in_out函数首先调用kernel_pio函数,尝试在KVM中处理该PIO请求,若KVM无法处理,它将vcpu->run->exit_reason设置为KVM_EXIT_IO并最终返回0,这导致vcpu_run函数退出循环并返回至QEMU中进行处理。
具体代码如下:
linux-4.19.0/arch/x86/kvm/x86.c

linux-4.19.0/arch/x86/kvm/x86.c

linux-4.19.0/arch/x86/kvm/x86.c

linux-4.19.0/arch/x86/kvm/x86.c

注:QEMU对PIO和MMIO的处理放在一起分析,在下一节。
虚拟机发起MMIO访问
由于MMIO需要将I/O端口和设备RAM映射到物理地址空间,并且CPU需要使用内存访问指令进行对设备进行MMIO访问,所以QEMU/KVM架构下对MMIO的模拟也需要利用EPT机制。
与PIO的处理过程类似,当虚拟机发起MMIO访问时,同样会发生VM-Exit陷入KVM和QEMU中进行处理。**但与PIO不同的是,MMIO是通过缺页异常产生VM-Exit,**以下是QEMU/KVM中的具体实现过程。
QEMU设备初始化
首先QEMU在设备初始化的过程中,会通过前文edu设备具现化中介绍的 memory_region_init_io 函数初始化一个MMIO内存区域,代码如下:
qemu-4.1.1/memory.c

在如上的 memory_region_init_io 函数的原型中,该函数并未调用 memory_region_init_ram 设置mr->ram,因此该MemoryRegion并未实际分配内存。此时,该MemoryRegion被加入MemoryRegion树,会触发KVM的listener,从而调用listener的 kvm_region_add 函数。然后kvm_region_add 函数会调用 kvm_set_phys_mem 函数,该函数会检查MemoryRegion的类型,如果不是RAM类型,则直接返回。代码如下:
qemu-4.1.1/accel/kvm/kvm-all.c

qemu-4.1.1/accel/kvm/kvm-all.c

KVM初始化设置
在KVM初始化过程中,会执行 kvm_arch_hardware_setup 函数进行硬件设置。该函数会通过kvm_x86_ops的 hardware_setup 函数最终调用vmx.c中的 ept_set_mmio_spte_mask 函数,将全局变量 shadow_mmio_mask 的最低三位设置为110b。具体代码如下:
linux-4.19.0/arch/x86/kvm/vmx.c

linux-4.19.0/arch/x86/kvm/mmu.c

KVM中MMIO处理
当虚拟机第一次访问虚拟设备的MMIO MemoryRegion时,由于先前没有给该MR分配RAM,因此会产生一个EPT Violation缺页异常。在EPT页表缺页的情况下,KVM会调用缺页异常处理函数 tdp_page_fault,完善EPT。流程如下:
tdp_page_fault函数会调用try_async_pf函数,该函数会检查参数gfn是否在KVM的memslots的范围中。如果不在,则向pfn中写入KVM_PFN_NOSLOT;- 最后
tdp_page_fault函数会进入_direct_map函数,并经过mmu_set_spte -> set_spte -> set_mmio_spte函数调用链最终进入set_mmio_spte函数。
具体代码如下:
linux-4.19.0/arch/x86/kvm/mmu.c

set_mmio_spte 函数会检查当前pfn中是否被写入KVM_PFN_NOSLOT,如果被写入,则将EPT中这段客户机物理内存(即gfn)对应的EPT页表属性标记上代表MMIO的特殊值—— shadow_mmio_mask,以后的读写都会引起 EXIT_REASON_EPT_MISCONFIG 类型的VM-Exit。
此处不讨论建立EPT映射的过程,关于处理EPT缺页异常涉及的函数,请阅读内存虚拟化文档。
在全局数组 kvm_vmx_exit_handlers 中,EXIT_REASON_EPT_MISCONFIG 对应的VM-Exit处理函数为 handle_ept_misconfig。该函数会先尝试调用 kvm_io_bus_write 函数,在KVM中寻找能够处理本次MMIO请求的设备,如果KVM无法处理,则会调用 kvm_mmu_page_fault 函数,从而进入 handle_mmio_page_fault 函数中处理。具体代码如下:
linux-4.19.0/arch/x86/kvm/vmx.c

linux-4.19.0/arch/x86/kvm/mmu.c

linux-4.19.0/arch/x86/kvm/mmu.c

handle_mmio_page_fault 函数的处理主要有三个阶段:
- 第一个阶段是通过
mmio_info_in_cache函数检查MMIO对应的地址是否在缓存中存在,如果存在,则立即返回到kvm_mmu_page_fault函数,返回值为RET_PF_EMULATE; - 如果第一阶段未返回,则进入第二阶段,该阶段会通过
walk_shadow_page_get_mmio_spte函数获取MMIO地址对应的spte,并通过check_mmio_spte函数判断该地址是否代表MMIO区域,如果是,则返回RET_PF_EMULATE; - 第三阶段,返回到在
kvm_mmu_page_fault函数之后,该函数会检查handle_mmio_page_fault函数的返回值。如果返回值为RET_PF_EMULATE,则跳转到x86_emulate_instruction函数并最终进入QEMU中处理。
QEMU中PIO/MMIO处理
当KVM将控制流交给QEMU后,重新进入 qemu_kvm_cpu_thread_fn 函数执行 kvm_cpu_exec 函数。vCPU中用于保存VM-Exit相关信息的run成员变量会通过mmap映射到QEMU所在的内存空间,所以QEMU中的 kvm_cpu_exec 函数可以通过检查 kvm_run 结构中的 exit_reason,根据其退出原因进行进一步处理。
下面是 kvm_cpu_exec 函数中的相关代码:
qemu-4.1.1/accel/kvm/kvm-all.c

其中:
KVM_EXIT_IO与KVM_EXIT_MMIO分别代表PIO与MMIO的exit_reason,kvm_handle_io函数与address_space_rw函数分别用于模拟PIO与MMIO请求。- 在vCPU中的run成员中包含io与mmio这两个数据结构,用于描述PIO与MMIO相关信息。
- io中定义了数据传输的方向,0代表读端口,1代表写端口,方向信息保存在
direction成员中。size/port/count成员分别定义了每次读写的数据长度、端口号、数据读写次数等信息。data_offset中保存了数据在kvm_run中的偏移地址。这些信息都会作为参数传递给kvm_handle_io函数,用于进一步的PIO模拟; - mmio结构相对简单,
phys_addr用于保存64位目的物理地址,data用于保存读写的数据,len代表数据长度,is_write函数确定是读还是写。这些信息同样会作为参数传入mmio处理函数address_space_rw中。
- io中定义了数据传输的方向,0代表读端口,1代表写端口,方向信息保存在
io/mmio 结构如下:
qemu-4.1.1/linux-headers/linux/kvm.h

在QEMU 4.1.1版本中,PIO的处理函数 kvm_handle_io 本质上调用了与MMIO一样的 address_space_rw 接口,区别在于PIO传入的是I/O地址空间 address_space_io 函数,而MMIO传入的是内存地址空间 address_space_memory 函数。之后 address_space_rw 会完成对相应地址的读写操作。具体过程在内存虚拟化文档中已经介绍,这里不再赘述。具体代码如下:
qemu-4.1.1/accel/kvm/kvm-all.c

QEMU模拟设备功能
驱动程序通过访问设备提供的寄存器接口来使用设备的特定功能,所以**QEMU不仅要实现对虚拟设备的端口和设备内存的读写,同时需要模拟虚拟设备的功能。**下面仍以 edu 设备为例介绍虚拟设备的功能实现。
与 edu 设备具象化相关的章节提到了 memory_region_init_io 函数,该函数会初始化一个大小为1MB的MMIO内存区域,并为该MMIO内存区域注册读写函数 edu_mmio_ops 。edu_mmio_ops 是一个MemoryRegionOps类型的结构体,作为成员变量保存在 edu 设备对应的MMIO MemoryRegion中。edu_mmio_ops 中注册的 edu_mmio_read 函数与 edu_mmio_write 函数会根据每次MMIO访问的位置和数据长度执行对应的功能函数,具体功能会在后文介绍。
下图展示了虚拟机向 edu 设备MMIO内存区域发起读访问时的函数调用流程:

注:①QEMU执行ioctl函数进入KVM;②KVM退出到QEMU,执行QEMU的MMIO模拟函数。
address_space_rw 函数经过层层调用最终会进入 memory_region_read_accessor 函数,memory_region_read_accessor 函数通过 mr->ops->read(mr->opaque,addr,size) 会引起edu设备中 edu_mmio_read 函数的回调。具体代码如下:
qemu-4.1.1/memory.c

edu_mmio_read/edu_mmio_write就是QEMU对edu设备功能的模拟,这两个函数会根据每次MMIO访问的位置和数据长度执行对应的功能函数,具体在3.4节实验中介绍。
3.4 实验: 为edu设备添加设备驱动
Masaryk大学编写edu设备的初衷是用于内核设备驱动的教学,Linux内核中并不存在edu设备的驱动程序。
本节将以实验的形式**为edu设备编写相应的驱动程序,**目的是为了更加清晰直观地展示虚拟设备背后的运行原理。本节分为三部分:
- 第一部分:分析与edu设备功能相关的寄存器;
- 第二部分:介绍在驱动中如何访问edu设备的配置空间和MMIO空间、发起DMA请求以及处理设备中断;
- 第三部分:演示实验的整体流程。
上文提到的 edu_mmio_read 函数和 edu_mmio_write 函数是edu设备的核心,当访问的地址在MMIO内存区域的偏移小于 0x80 时,只允许4字节大小的访问;当地址偏移大于或等于 0x80 时,允许4字节或8字节的数据访问。
edu设备功能模拟
edu设备在MMIO内存区域内会设置一些特殊的地址并赋予这些地址不同的读写权限,驱动读写这些地址时会触发相应的功能。下文会介绍这些特殊的地址。具体代码如下:
qemu-4.1.1/hw/misc/edu.c

qemu-4.1.1/hw/misc/edu.c

0x00(RO):0x00权限为只读,读0x00时会返回edu设备的标识符0x010000edu;0x04(RW):0x04权限为可读可写,读0x04时会返回edu设备中addr4成员变量的值,写0x04时会将写入的数据取反之后赋值给addr4;0x08(RW):0x08用于阶乘计算,权限为可读可写。读0x08时,会返回edu设备中
fact成员变量的值,fact表示阶乘结果;写0x08时,会将写入的数据赋值给
fact,然后edu设备的status会与宏变量EDU_STATUS_COMPUTING做或运算,并将运算结果赋值给status。这个宏变量的值为0x1,代表此时edu设备处于阶乘计算状态。之后会通过
qemu_cond_signal函数唤醒在edu设备具象化时创建的edu_fact_thread线程,该线程用于阶乘计算。edu_fact_thread函数在阶乘计算结束后会执行atomic_and(&edu->status,~EDU_STATUS_COMPUTING),改变edu设备的status。之后
edu_fact_thread函数会检查status和EDU_STATUS_IRQFACT(0x80)与运算的结果,等价于检查status的第7位是否为1。若为1,代表edu设备被设置为执行完一次阶乘后需要发送中断,此时edu_fact_thread函数会调用edu_raise_irq函数向虚拟机发送中断。edu_fact_thread函数代码如下:qemu-4.1.1/hw/misc/edu.c
0x20(RW):0x20权限为可读可写。读0x20时会返回edu设备的status。对0x20写时,会将写入数据第7位的值赋给status的第7位,用于决定每次阶乘后是否向虚拟机发送中断。0x24(RO):0x24权限为只读。对0x24读时会返回edu设备的irq_status,代表中断产生的原因。驱动中的中断处理程序可以通过读0x24来获取irq_status。0x60(WO):0x60权限为只写。向0x60写数据时,会调用edu_raise_irq函数,edu_raise_irq函数通过pci_set_irq接口向虚拟机发送中断,同时会把irq_status和写入数据的或运算结果赋值给irq_status。具体代码如下:qemu-4.1.1/hw/misc/edu.c
0x64(WO):0x64权限为只写。0x64用于中断应答,将中断在irq_status中清除,停止生成该中断。向0x64写数据时,会调用edu_lower_irq函数。edu_lower_irq函数会把写入的数据取反后和irq_status进行与运算,并将最终结果赋值给irq_status。通常驱动中的中断处理程序向0x64端口写入的值为irq_status,这样便可以将irq_status的值置零。具体代码如下:qemu-4.1.1/hw/misc/edu.c
0x80(RW):0x80的权限为可读可写。读0x80时,会返回edu设备的dma.src。对0x80写时,会将写入数据赋值给edu设备的dma.src。dma.src代表DMA的源地址。0x88(RW):0x88的权限为可读可写。读0x88时,会返回edu设备的dma.dst。对0x88写时,会将写入数据赋值给edu设备的dma.dst。dma.dst代表DMA的目的地址。0x90(RW):0x90的权限为可读写。读0x90时,会返回edu设备的dma.cnt。对0x90写时,会将写入数据赋值给edu设备的dma.cnt。dma.cnt代表DMA传输的字节数。0x98(RW):0x98的权限为可读可写,被用作DMA命令寄存器。- 第0位为1代表开始DMA传输;
- 第1位决定DMA数据传输的方向:
- 0代表从RAM到edu设备;
- 1代表edu设备到RAM;
- 第2位决定是否在DMA结束之后向虚拟机发起中断,并将
irq_status设置为0x100。
为edu设备添加设备驱动
本节将介绍如何在虚拟机中为edu设备添加相应的设备驱动,并设计测试程序使用edu设备。
当加载edu设备驱动模块时,PCI总线会遍历总线上已经注册的设备,调用总线的match函数判断是否有匹配的设备,匹配的依据是驱动提供的pci_device_id。edu设备源码中定义的 vendor_id 和edu设备id会被加入驱动代码中的 pci_device_id 数组 pci_ids[] 中,以实现**驱动和edu设备的匹配。**具体代码如下:
edu_driver.c

为edu设备驱动编写 file_operations 中的 write 函数和 read 函数可以按照PCI设备驱动编写的一般方法,代码如下:
1 | static ssize_t edu_read(struct file *filp, char __user *buf, size_t len, loff_t *off) |
由于edu设备的特殊性,edu设备驱动需要为edu设备设计专门的中断处理函数与 probe 函数以及用于控制edu设备的多种功能的 ioctl 函数。
pci_probe
当驱动和设备完成匹配之后会调用 probe 函数执行设备的相关初始化工作。
pci_probe函数中首先使用register_chrdev函数来注册edu设备,第一个参数为0代表使用系统动态分配的主设备号。pci_iomap函数会返回用于表示edu设备的PCI BAR的I/O地址空间的__iomem类型指针。后续iowrite*和ioread*函数会通过获得的__iomem地址对edu设备的MMIO区域进行读写。pci_probe函数最后会向内核注册edu设备的中断服务函数irq_handler,该函数是一个回调函数。当中断注入虚拟机时会调用irq_handler函数,并将设备号传递给它。具体代码如下:
edu_driver.c

irq_handler
pci_probe 函数注册了 irq_handler 函数用于中断处理。该函数首先根据主设备号判断中断是否属于edu设备,之后 irq_handler 函数会读取edu设备的 irq_status 并判断产生该中断的原因。**为了区分DMA读中断、DMA写中断以及阶乘运算产生的中断,edu设备源码需要被修改。**具体改动如下:
- 当产生DMA读中断时会将edu设备的
irq_status设置为0x100; - 当产生DMA写中断时会将
irq_status设置为0x101; - 当
irq_status等于0x1时代表阶乘运算中断。
打印出edu设备中断的原因后,irq_handler 函数会调用 iowrite32 函数,将 irq_status 写入上文提到的x64寄存器,以此向edu设备发送一个中断应答。edu设备会按照上文介绍的方式将 irq_status 置零,并拉低中断线电平。具体代码如下:
edu_driver.c

edu_ioctl
edu设备的特殊功能较多,为了使代码结构更清晰,需要设计
edu_ioctl函数对edu设备的特性进行控制。用户程序的ioctl函数与驱动层的edu_ioctl函数配合,实现向设备传递控制命令。
ioctl 函数的cmd参数具有以下五种控制命令,每条命令分别控制edu设备的一项功能:
DMA_WRITE_CMD代表发起一次DMA写操作,主要设置DMA的源地址、DMA的目的地址、DMA传输的数据长度以及edu设备定义的DMA组合指令。DMA_CMD|DMA_TO_MEM|DMA_IRQ这一组合指令代表进行DMA写,并且在DMA结束后向虚拟机发送中断。DMA_READ_CMD代表发起一次读操作,过程与DMA_WRITE_CMD类似。PRINT_EDUINFO_CMD代表打印edu设备的基本信息,包括edu设备MMIO区域的大小、配置空间前64字节的信息、edu设备申请的硬件中断号、MMIO区域部分初始化的值。SEND_INTERRUPT_CMD命令会写0x60寄存器,此时edu设备会发送中断,并将irq_status设置为0x12345678。FACTORIAL_CMD命令代表发起一次阶乘运算,首先edu驱动会向0x20寄存器写入值0x80,这步的作用是设置edu设备在阶乘结束后发送中断。之后用于阶乘运算的值会被写入0x8寄存器,实验中用于阶乘计算的值是10。
edu_ioctl 代码如下:
edu_driver.c

整体实验流程
为了更好地展示实验结果,本节设计了一个简单的用户态测试程序,并在edu设备源码的关键位置添加了相应的输出信息。
实验第一步是在QEMU中启动带有edu设备的虚拟机,本次实验的启动参数如下:
boot.sh

进入虚拟机后,在终端输入 lspci 命令,根据edu的设备号以及 vendor ID 在PCI设备列表中可以查询到edu设备被挂载到了0号总线的04号槽:
lspci

接着输入 lspci-s 00:04.0-vvv-xxxx 命令,会显示edu设备的基本信息,包括edu设备的中断信息、MMIO地址空间信息以及设备配置空间信息等。
lspci-s 00:04.0-vvv-xxxx

上文介绍了 edu_mmio_read 函数的回调过程,所以实验的第一步首先对 edu_mmio_read 函数的调用过程进行验证。具体过程如下:
- 在QEMU中启动虚拟机后,打开另外一个超级终端,通过
ps-aux|grep qemu命令来查询QEMU的进程号; - 之后在终端中启动GDB调试,在GDB命令行中输入
attach+ QEMU进程号,调试正在运行的QEMU程序; - 接着在GDB中为
edu_mmio_read函数设置断点,输入c继续执行QEMU程序; - 然后在虚拟机中加载edu设备的驱动模块,此时GDB会显示QEMU的执行停在了
edu_mmio_read函数处。
GDB中提供了 backtrace 命令用于查看函数的调用栈。最后输入 backtrace 命令,GDB会显示以下结果:
GDB backtrace

backtrace 的输出结果展示了地址转换的过程,我们可以将该过程与内存虚拟化文档中介绍的QEMU中内存地址转换过程对比。经过对比可以发现,GDB中显示的 edu_mmio_read 函数的函数调用栈与上节描述的一致。
然后将在GDB中设置的断点取消,继续运行QEMU进程。虚拟机随后运行先前编写的用户态测试程序,虚拟机的 dmesg 输出显示edu设备的配置空间信息与 lspci-s 00:04.0-vvv-xxxx 打印的结果一致。以下是 dmesg 中的部分信息:
dmesg

dmesg 的最后几条输出信息展示了edu功能的执行结果。驱动首先接收到了 irq_status 为0x12345679的设备中断,irq_status 与 pci_ioctl 函数中设置的一致。QEMU输出了以下对应信息,包括 irq_status 的设置以及中断状态清除等。
Log in QEMU

然后驱动再次接收到了 irq_status 为0x1的设备中断,并判断该中断为阶乘计算产生的,最后输出了阶乘计算的结果 0x375f00。在QEMU中的信息展示了设置设备状态、分配阶乘对象 fact 以及发出阶乘运算中断的过程。
Log in QEMU

紧接着测试程序会发起DMA命令,会在edu设备中设置与DMA相关的信息。edu设备的 edu_timer 定时器检测到 dma.cmd 被设置为可运行状态后,会先根据 dma.cmd 第1位的值判断出DMA数据的传输方向,之后会检查DMA操作是否越界,如果未越界,则将DMA信息传入pci_dma_read/pci_dma_write 函数发起DMA操作。
pci_dma_read/pci_dma_write 函数的返回值用于判断DMA操作是否成功完成。当DMA操作完成后,edu设备会返回相应的DMA中断。如下QEMU中的输出信息展示了这一系列过程。
Log in QEMU

Log in QEMU
最后整理于 2025-11-11