关注微信公众号:Linux内核拾遗
之前的文章中对I/O虚拟化技术的演化历程、基本原理和主流模型作了详细的介绍,如果对这些背景知识还不太了解,可以直接移步:
本文将深入探讨I/O虚拟化中的软件模拟技术,希望能为读者提供对该领域的深入理解和实践指导。
1 软件模拟概述
软件模拟是一种I/O全虚拟化技术,它通过软件方式实现硬件设备的模拟,使得虚拟机可以像访问真实硬件一样访问虚拟设备。
在I/O虚拟化中,软件模拟通常涉及模拟网络接口卡(如virtio-net)、磁盘控制器(如virtio-blk)等设备,以提供给虚拟机。
1.1 工作原理
I/O虚拟化中的软件模拟工作原理是通过虚拟化管理程序(如QEMU)在宿主机上模拟虚拟机所需的硬件设备。当虚拟机发出I/O请求时,虚拟化管理程序拦截这些请求并将其转换为宿主机上对应的硬件操作。比如,对于网络设备的模拟,虚拟机发送的数据包会被QEMU接收并转发到宿主机的物理网络接口,实现虚拟机与外部网络的通信。这种模拟方式无需虚拟机直接访问物理硬件,提高了系统的灵活性和安全性。
下图是 QEMU/KVM 以纯软件方式模拟 I/O 设备的示意图:

主要包括如下的步骤流程:
-
发起I/O操作请求:
- 当Guest中的设备驱动程序发起I/O操作请求时,这个请求会被KVM模块中的I/O Trap Code拦截。
-
I/O请求处理与共享页面:
- KVM模块处理该I/O请求,并将请求的信息存放到I/O Sharing Page(共享页面)中,然后通知用户空间的QEMU进程。
-
QEMU获取并模拟I/O操作:
-
QEMU进程从I/O Sharing Page中读取具体的I/O操作信息,并将该操作交由QEMU的I/O Emulation Code(硬件模拟代码)进行处理。
-
硬件模拟代码模拟I/O操作,与宿主机的实际设备驱动进行交互,完成I/O操作。
-
-
返回I/O操作结果:
-
QEMU进程模拟代码获取操作结果,并将结果放回I/O Sharing Page中,然后通知KVM模块。
-
KVM模块中的I/O Trap Code读取I/O Sharing Page中的操作结果,并将结果返回给Guest中的设备驱动程序。
-
需要注意的是:
- 客户机阻塞:在等待I/O操作结果时,客户机(Guest)进程可能会被阻塞,直到I/O操作完成。
- 大块I/O和DMA:当客户机通过DMA(直接内存访问)方式进行大块I/O操作时,QEMU不会将I/O操作结果放到I/O Sharing Page中,而是通过内存映射的方式将结果直接写入客户机的内存中。完成后,KVM模块会通知客户机DMA操作已完成。
1.2 优缺点分析
QEMU/KVM纯软件I/O设备模拟有以下的优点:
-
灵活性:可以通过软件模拟出各类硬件设备,而无需修改客户机操作系统,并且独立于hypervisor,可在不同虚拟化平台上使用。
-
安全性:隔离虚拟机与物理硬件,减少安全风险,同时I/O操作在受控环境中进行,便于监控。
-
便于调试和开发:软件层面的模拟便于调试和测试。设备可共享和复用,便于开发和维护。
但是(且最关键的是)它有以下的缺点:
- 性能开销:占用大量CPU资源,性能较硬件原生I/O性能低。每次 I/O 操作的路径较长,有较多的
VM-Entry、VM-Exit发生,需要多次上下文切换,也需要多次数据复制,这增加了I/O延迟。 - 复杂性:实现和维护复杂,需要详细模拟硬件行为。多层次协调增加代码复杂性,调试困难。
- 阻塞和资源竞争:虚拟机等待I/O操作时可能被阻塞。多虚拟机并发I/O操作可能导致资源竞争和性能下降。
2 QEMU/KVM中的软件模拟
QEMU在用户空间中独立进行设备模拟,虚拟设备通过hypervisor提供的接口供其他虚拟机(VM)调用。由于设备模拟独立于hypervisor,这意味着我们可以模拟任何设备,并且这些模拟设备可以在不同的hypervisor间共享。
本节我们讲述 QEMU 如何进行设备模拟。
2.1 QEMU设备I/O处理
在虚拟化环境中,虚拟机(VM)在访问某些硬件资源(如物理内存或I/O端口)时,可能会触发各种事件,这些事件会导致虚拟机退出到Hypervisor,并将控制权从VM转移到Hypervisor(如KVM),以便对这些事件进行适当处理。这些事件被称为VM-exit事件。
由于设备模拟通常是在QEMU中完成的,因此KVM不会处理I/O访问导致VM-exit事件,而是退出到QEMU中进一步处理。
在QEMU中,这些事件会根据其类型进行不同的处理。在设备I/O模拟中,KVM_EXIT_IO和KVM_EXIT_MMIO是两种常见的VM-exit原因,分别对应I/O端口访问和内存映射I/O(MMIO)访问。
KVM_EXIT_IO 事件表示虚拟机试图通过访问I/O端口与设备进行通信,可用于控制设备、读取状态或写入数据,包括虚拟串口、键盘和硬盘控制器等设备。当虚拟机执行I/O指令(如IN或OUT指令)后,KVM捕获事件并将控制权传递给QEMU处理,QEMU通过kvm_handle_io函数进行处理。
KVM_EXIT_MMIO 事件表示虚拟机试图通过内存映射I/O(MMIO)操作与设备进行交互,例如虚拟化的显卡、网卡以及其他通过MMIO进行通信的设备。在这种操作中,设备的寄存器被映射到虚拟机的物理内存地址空间中,虚拟机通过读写这些地址来与设备进行通信。当虚拟机尝试访问映射内存区域时,KVM捕获到这个事件并将控制权交给QEMU,QEMU利用address_space_rw函数处理该事件。
以下是QEMU中I/O事件处理部分的代码:
int kvm_cpu_exec(CPUState *cpu)
{
...
do {
...
run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0);
...
switch (run->exit_reason) {
case KVM_EXIT_IO:
DPRINTF("handle_io\n");
/* Called outside BQL */
kvm_handle_io(run->io.port, attrs,
(uint8_t *)run + run->io.data_offset,
run->io.direction,
run->io.size,
run->io.count);
ret = 0;
break;
case KVM_EXIT_MMIO:
DPRINTF("handle_mmio\n");
/* Called outside BQL */
address_space_rw(&address_space_memory,
run->mmio.phys_addr, attrs,
run->mmio.data,
run->mmio.len,
run->mmio.is_write);
ret = 0;
break;
...
}
} while (...);
}
代码首先通过kvm_vcpu_ioctl(cpu, KVM_RUN, 0)尝试运行VCPU,直到遇到导致VM-exit的事件。
当VCPU由于某个事件退出(VM-exit)时,run_ret会获取KVM_RUN的返回值,run->exit_reason会包含退出的原因:
-
对于I/O事件(KVM_EXIT_IO):调用
kvm_handle_io()处理I/O端口访问,参数包括I/O端口号、属性、数据偏移、方向(读/写)、大小和计数等。 -
对于MMIO事件(KVM_EXIT_MMIO):调用
address_space_rw()处理MMIO访问,参数包括内存空间地址、物理地址、属性、数据、数据长度和是否写操作等。
2.2 PIO模拟
实际上,PIO请求的处理函数kvm_handle_io()内部封装了address_space_rw()函数,但它使用了专门的端口地址空间address_space_io来进行操作,而不是通常用于内存映射I/O的address_space_memory。
static void kvm_handle_io(uint16_t port, MemTxAttrs attrs, void *data, int direction,
int size, uint32_t count)
{
int i;
uint8_t *ptr = data;
for (i = 0; i < count; i++) {
address_space_rw(&address_space_io, port, attrs,
ptr, size,
direction == KVM_EXIT_IO_OUT);
ptr += size;
}
}
其中port表示虚拟机试图访问的I/O端口号,data指向要读取或写入的数据缓冲区,direction指示读写操作类型,size和count分别每次读写的数据大小(字节)和读写次数。
在QEMU中,每个I/O设备通常都会有一个相关联的MemoryRegion结构和函数表,用于实现对应的读写操作。kvm_handle_io()通过循环每次处理一个数据单元,函数内部在address_space_io地址空间上调用address_space_rw(),实现对虚拟机I/O内存区域的读写。
2.3 MMIO模拟
对于 MMIO 而言,QEMU会直接调用 address_space_rw() ,该函数首先将全局地址空间 address_space_memory 展开成 FlatView 后再调用对应的函数进行读写操作。
address_space_rw()函数代码如下:
MemTxResult address_space_rw(AddressSpace *as, hwaddr addr, MemTxAttrs attrs,
void *buf, hwaddr len, bool is_write)
{
if (is_write) {
return address_space_write(as, addr, attrs, buf, len);
} else {
return address_space_read_full(as, addr, attrs, buf, len);
}
}
它根据读写操作类型is_write,选择调用address_space_write或address_space_read_full函数来处理读写操作。
MemTxResult address_space_read_full(AddressSpace *as, hwaddr addr,
MemTxAttrs attrs, void *buf, hwaddr len)
{
MemTxResult result = MEMTX_OK;
FlatView *fv;
if (len > 0) {
RCU_READ_LOCK_GUARD();
fv = address_space_to_flatview(as);
result = flatview_read(fv, addr, attrs, buf, len);
}
return result;
}
MemTxResult address_space_write(AddressSpace *as, hwaddr addr,
MemTxAttrs attrs,
const void *buf, hwaddr len)
{
MemTxResult result = MEMTX_OK;
FlatView *fv;
if (len > 0) {
RCU_READ_LOCK_GUARD();
fv = address_space_to_flatview(as);
result = flatview_write(fv, addr, attrs, buf, len);
}
return result;
}
可以看到,address_space_read_full()和address_space_write()均调用了address_space_to_flatview()函数,将AddressSpace转换为FlatView,然后再执行具体的处理函数(flatview_read()或者flatview_write())。
static MemTxResult flatview_read(FlatView *fv, hwaddr addr,
MemTxAttrs attrs, void *buf, hwaddr len)
{
hwaddr l;
hwaddr addr1;
MemoryRegion *mr;
l = len;
mr = flatview_translate(fv, addr, &addr1, &l, false, attrs);
if (!flatview_access_allowed(mr, attrs, addr, len)) {
return MEMTX_ACCESS_ERROR;
}
return flatview_read_continue(fv, addr, attrs, buf, len,
addr1, l, mr);
}
static MemTxResult flatview_write(FlatView *fv, hwaddr addr, MemTxAttrs attrs,
const void *buf, hwaddr len)
{
hwaddr l;
hwaddr addr1;
MemoryRegion *mr;
l = len;
mr = flatview_translate(fv, addr, &addr1, &l, true, attrs);
if (!flatview_access_allowed(mr, attrs, addr, len)) {
return MEMTX_ACCESS_ERROR;
}
return flatview_write_continue(fv, addr, attrs, buf, len,
addr1, l, mr);
}
函数flatview_read()或flatview_write()首先将逻辑地址转换为物理地址并确定对应的MemoryRegion,然后调用flatview_read_continue()或者flatview_write_continue()执行写入操作。
关注微信公众号:Linux内核拾遗