基本原理
模拟原理
- MMIO Memory,memory-mapped I/O Memory,它是一种QEMU/KVM定义的内存类型,和普通的RAM Memory一样,Guest中MMIO Memory也表示一段内存区域。也是易失性存储。
- MMIO Memory和RAM Memory不同之处在于,Guest对MMIO Memory表示的内存读写时,后端除了要模拟RAM Memory一样的行为,在写内存(OUT)时会触发写回调(callback),读内存(IN)时会触发读回调(callback),就像IO一样。所以称之为内存映射IO。
- 通过对MMIO功能的描述,我们可以把QEMU/KVM实现MMIO模拟的要素归纳为两个,一是模拟Guest读写内存的行为,二是监控虚机对MMIO内存的读写,触发对应回调函数的执行。对于前者,QEMU/KVM通过执行和Guest相同的指令,来模拟Guest读写MMIO内存的行为,对于后者,QEMU/KVM首先要监听Guest访问内存的行为,一旦有对MMIO内存的读写,则需要让Guest陷入KVM,让QEMU/KVM来触发用户注册的回调。
- 在具体实现MMIO模拟时,QEMU/KVM通过以下步骤来实现:
- 定义MMIO内存,在Guest内存缺页而退出时,识别出来,在QEMU/KVM缺页处理流程中走模拟MMIO逻辑。
- QEMU/KVM在Host上获取Guest退出前的读写指令,直接执行,从而完成Guest的内存读写模拟。
- 返回QEMU用户态,触发用户态注册的MMIO读写回调。
硬件基础
MMIO内存与普通内存不同,Guest写MMIO内存时每次都会缺页退出,交给后端来处理,类似于敏感指令。同样都是由于页表异常导致的退出,KVM怎么识别MMIO的PF与普通内存的PF呢?答案是通过页表项的特殊标志位区分。x86 intel架构下的spte格式如下:
- intel手册28.2.3.1中有如下一段话:
An EPT misconfiguration occurs if the entry is present and a reserved bit is set.
EPT misconfigurations result when an EPT paging-structure entry is configured with settings reserved for future
functionality.
- 翻译过来就是当ept的页表执行的页被标记为存在,但保留位被置位时,硬件会产生ept misconfiguration异常,软件可以基于这种异常实现特殊的功能。
- 这里我们的KVM就用来实现了intel架构MMIO PF的特殊功能。具体实现时,将页表项的bit2:0设置为0b110,置位bit51和bit62,如下:
- kvm维护spte所有页表项,当Guest产生页表异常(EPT misconfiguration)退出时,KVM首先检查拿到异常退出的GPA地址,遍历EPT页表结构,找到其对应的spte,如果发现以上描述的对应位被置1,则可以识别到这是一个MMIO的PF,进而做对应的处理,如下:
数据结构
- MMIO PF的模拟分为两个部分,一个是callback的实现,一个是内存读写指令的模拟。两部分分别在QEMU和内核中实现,我们以virtio-pci配置空间的模拟举例,来分析MMIO内存的相关数据结构。
QEMU
- Qemu中的MemoryRegionOps描述了MMIO内存的回调钩子,如下:
/*
* Memory region callbacks
*/
struct MemoryRegionOps {
/* Read from the memory region. @addr is relative to @mr; @size is
* in bytes. */
uint64_t (*read)(void *opaque,
hwaddr addr,
unsigned size);
/* Write to the memory region. @addr is relative to @mr; @size is
* in bytes. */
void (*write)(void *opaque,
hwaddr addr,
uint64_t data,
unsigned size);
enum device_endian endianness;
......
};
- 对于legacy(virtio spec 0.95)的virtio-pci实现,Qemu定义了如下结构体对象用来描述MMIO读写对应操作:
static const MemoryRegionOps virtio_pci_config_ops = {
.read = virtio_pci_config_read, /* 写virtio-pci的配置空间时,触发该回调 */
.write = virtio_pci_config_write, /* 读virtio-pci的配置空间时,触发该回调 */
.impl = {
.min_access_size = 1,
.max_access_size = 4,
},
.endianness = DEVICE_LITTLE_ENDIAN,
};
- 对于modern(virtio spec 1.0/1.1)的virtio-pci实现,Qemu将pci common的配置空间和设备specific配置空间的MMIO读写操作分别定义:
/* common 配置空间的读写回调 */
static const MemoryRegionOps common_ops = {
.read = virtio_pci_common_read,
.write = virtio_pci_common_write,
.impl = {
.min_access_size = 1,
.max_access_size = 4,
},
.endianness = DEVICE_LITTLE_ENDIAN,
};
/* 中断配置空间的读写回调 */
static const MemoryRegionOps isr_ops = {
.read = virtio_pci_isr_read,
.write = virtio_pci_isr_write,
.impl = {
.min_access_size = 1,
.max_access_size = 4,
},
.endianness = DEVICE_LITTLE_ENDIAN,
};
/* 具体设备(virtio-net/virtio-blk)配置空间的读写回调 */
static const MemoryRegionOps device_ops = {
.read = virtio_pci_device_read,
.write = virtio_pci_device_write,
.impl = {
.min_access_size = 1,
.max_access_size = 4,
},
.endianness = DEVICE_LITTLE_ENDIAN,
};
......
借助下图的结构我们可以很简单的读懂上面的代码,上面的代码就是针对每段virtio-pci配置空间,定义了对应的读写回调。一旦Guest的内存读写落到该区域,KVM模拟完读写指令后就会返回用户态,注册的回调函数会得到执行。
KVM
- KVM完成识别MMIO PF并执行内存读写指令模拟的任务,这里我们主要介绍相关数据结构。
/* KVM定义的MMIO读写退出的宏KVM_EXIT_MMIO
* Qemu vCPU线程在处理从内核退出的线程时
* 依据该字段进入对应的MMIO读写逻辑
*/
#define KVM_EXIT_MMIO 6
/* mmio结构体用来描述MMIO读写的详细信息 */
struct kvm_run {
......
/* KVM_EXIT_MMIO */
struct {
__u64 phys_addr;/* 要读写的MMIO内存物理地址 */
__u8 data[8];/* 如果是写MMIO的操作,该字段存放写入的数据 */
__u32 len;/* 要写MMIO内存的数据长度 */
__u8 is_write;/* 是否为写操作 */
} mmio;
......
}
PF流程
- 我们以Guest读写virtio-pci common空间的device_status字段为例,分析MMIO的PF流程。device_status的作用是在Guest初始化virtio-pci设备时前后端用来同步状态。主要有以下几种状态,当Guest想复位virtio-pci设备时,往device_status字段写0通知后端。
/* Status byte for guest to report progress. */
#define VIRTIO_CONFIG_STATUS_RESET 0x00 /* 设备复位 */
#define VIRTIO_CONFIG_STATUS_ACK 0x01
#define VIRTIO_CONFIG_STATUS_DRIVER 0x02
#define VIRTIO_CONFIG_STATUS_DRIVER_OK 0x04
#define VIRTIO_CONFIG_STATUS_FEATURES_OK 0x08
#define VIRTIO_CONFIG_STATUS_FAILED 0x80
Guest
- 本文基于dpdk-18.05源码分析,dpdk中对device_status字段的读写函数分别是:
static uint8_t
modern_get_status(struct virtio_hw *hw)
{
return rte_read8(&hw->common_cfg->device_status);
}
static void
modern_set_status(struct virtio_hw *hw, uint8_t status)
{
rte_write8(status, &hw->common_cfg->device_status);
}
- testpmd初始化virtio-net网卡时会复位设备,流程如下:
main
rte_eal_init
rte_bus_probe
rte_pci_probe
pci_probe_all_drivers
rte_pci_probe_one_driver
eth_virtio_pci_probe
rte_eth_dev_pci_generic_probe
eth_virtio_dev_init
virtio_init_device
vtpci_reset
modern_set_status
Host
1. KVM
- KVM在的vCPU在进入Guest后,检查到Guest异常退出,则通过handle_exit处理:
kvm_arch_vcpu_ioctl_run
vcpu_run
for (;;) {
if (kvm_vcpu_running(vcpu)) {
r = vcpu_enter_guest(vcpu)
kvm_x86_ops->handle_exit(vcpu)
}
if (r <= 0)
break;
......
}
vmx_handle_exit
/* 根据退出原因执行对应的handler,这里是ept misconfiguration */
kvm_vmx_exit_handlers[exit_reason](vcpu)
static int (*const kvm_vmx_exit_handlers[])(struct kvm_vcpu *vcpu) = {
......
[EXIT_REASON_EPT_MISCONFIG] = handle_ept_misconfig,
......
}
handle_ept_misconfig
/* 从VMCS中取出引起PF的Guest物理地址 GPA */
gpa = vmcs_read64(GUEST_PHYSICAL_ADDRESS)
kvm_mmu_page_fault(vcpu, gpa, PFERR_RSVD_MASK, NULL, 0)
/* 首先判断是否要处理保留位引起的PF,这里符合条件,进入handle_mmio_page_fault逻辑 */
if (unlikely(error_code & PFERR_RSVD_MASK)) {
r = handle_mmio_page_fault(vcpu, cr2, direct);
if (r == RET_PF_EMULATE)
goto emulate;
}
- 仔细分析KVM这对MMIO PF的处理过程:
static int handle_mmio_page_fault(struct kvm_vcpu *vcpu, u64 addr, bool direct)
{
u64 spte;
bool reserved;
/* 如果引起MMIO的GPA之前保存在缓存中,则跳过页表遍历,直接执行指令模拟,减少执行开销 */
if (mmio_info_in_cache(vcpu, addr, direct))
return RET_PF_EMULATE;
/* get_mmio_spte会遍历EPT页表,找到GPA所在的页表项并取出其内容
* 同时其返回值还用于判断遍历页表的过程中是否有页表项的reserved bit被置位
* 为true表示reserved置位,但是并非MMIO类型的内存。这种情况下KVM中不会出现,因此报错
* 为false表示正常
*/
reserved = get_mmio_spte(vcpu, addr, &spte);
if (WARN_ON(reserved))
return -EINVAL;
/* 判断取出的页表项是否为MMIO类型的,如果是,在执行指令模拟前,在intel的架构实现中
* 还可以缓存页表项对应的GPA地址,下一次相同GPA地址的MMIO缺页就可以直接从缓存中去spte
*/
if (is_mmio_spte(spte)) {
gfn_t gfn = get_mmio_spte_gfn(spte);
unsigned int access = get_mmio_spte_access(spte);
/* 缓存GPA地址对应的页框号 */
vcpu_cache_mmio_info(vcpu, addr, gfn, access);
/* 正常情况下,返回RET_PF_EMULATE表示需要KVM模拟MMIO内存的读写指令 */
return RET_PF_EMULATE;
}
/*
* If the page table is zapped by other cpus, let CPU fault again on
* the address.
*/
return RET_PF_RETRY;
}
- 回到kvm_mmu_page_fault,继续跟踪MMIO PF的指令模拟:
kvm_mmu_page_fault
int r, emulation_type = EMULTYPE_PF;
if (unlikely(error_code & PFERR_RSVD_MASK)) {
r = handle_mmio_page_fault(vcpu, cr2_or_gpa, direct);
if (r == RET_PF_EMULATE)
goto emulate;
}
......
emulate:
return x86_emulate_instruction(vcpu, cr2_or_gpa, emulation_type, insn,
insn_len);
- 分析x86_emulate_instruction函数
x86_emulate_instruction
if (!(emulation_type & EMULTYPE_NO_DECODE)) {
kvm_clear_exception_queue(vcpu);
/* 读取汇编指令内容,并执行该指令 */
r = x86_decode_emulated_instruction(vcpu, emulation_type,
insn, insn_len);
......
}
if (ctxt->have_exception) {
......
/* 这里mmio_needed为true,进入 */
} else if (vcpu->mmio_needed) {
++vcpu->stat.mmio_exits;
if (!vcpu->mmio_is_write)
writeback = false;
/* 设置返回值为0,返回到kvm_arch_vcpu_ioctl_run循环中
* 即vcpu_enter_guest返回值为0,KVM会跳出循环,返回用户态
*/
r = 0;
/* 返回用户态之前要准备MMIO相关的一些信息,传递到用户态
* 这里会通过complete_userspace_io来执行。这里针对
* MMIO exit,KVM有具体的实现
*/
vcpu->arch.complete_userspace_io = complete_emulated_mmio;
......
}
- 我们看一下在什么地方执行complete_userspace_io,从kvm_vcpu_ioctl开始分析:
kvm_vcpu_ioctl
switch (ioctl) {
case KVM_RUN: {
......
/* run vCPU,进入Guest态 */
r = kvm_arch_vcpu_ioctl_run(vcpu);
......
}
int kvm_arch_vcpu_ioctl_run(struct kvm_vcpu *vcpu)
{
......
/* 在进入Guest态前,如果检查到有需要用户空间完成的IO
* 这里MMIO的PF属于这种情况,就执行注册到complete_userspace_io的函数
* 也就是x86_emulate_instruction函数中注册的complete_emulated_mmio
* 执行完函数后,不再进入Guest态,而是返回用户态Qemu
*/
if (unlikely(vcpu->arch.complete_userspace_io)) {
int (*cui)(struct kvm_vcpu *) = vcpu->arch.complete_userspace_io;
vcpu->arch.complete_userspace_io = NULL;
r = cui(vcpu);
if (r <= 0)
goto out;
}
r = vcpu_run(vcpu);
......
return r;
}
- 分析complete_emulated_mmio的具体实现:
static int complete_emulated_mmio(struct kvm_vcpu *vcpu)
{
......
/* 设置KVM退回到Qemu的原因 */
run->exit_reason = KVM_EXIT_MMIO;
/* 设置MMIO PF时访问的物理地址 */
run->mmio.phys_addr = frag->gpa;
/* 如果是对MMIO内存的写操作,拷贝要写入的数据,传递给Qemu */
if (vcpu->mmio_is_write)
memcpy(run->mmio.data, frag->data, min(8u, frag->len));
run->mmio.len = min(8u, frag->len);
run->mmio.is_write = vcpu->mmio_is_write;
vcpu->arch.complete_userspace_io = complete_emulated_mmio;
return 0;
}
- 自此之后,KVM完成了对MMIO PF的所有处理,接下来的流程就是返回Qemu,让Qemu执行MMIO对应的回调函数。
2. QEMU
- KVM完成指令模拟后,返回到用户态Qemu,我们从Qemu的vCPU线程开始分析:
kvm_cpu_exec
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;
/* 判断返回的原因是MMIO读写引起的 */
case KVM_EXIT_MMIO:
DPRINTF("handle_mmio\n");
/* Called outside BQL */
/* 遍历地址空间,找到MMIO读写的内存区间所在的MR,执行对应的回调函数 */
address_space_rw(&address_space_memory,
run->mmio.phys_addr, attrs,
run->mmio.data,
run->mmio.len,
run->mmio.is_write);
ret = 0;
break;
......
}
}
实验
Guest
- dpdk的测试程序testpmd用来测试以poll mode driver的方式使用网卡,因此在接管网卡时会对网卡进行一系列初始化,在Guest跑这个程序时如果网卡是virtio-net,就会对virtio-pci的配置空间进行读写,通过跑testpmd工具我们可以验证MMIO PF的完整流程。testpmd工具来自vpp-dpdk-devel,整个测试程序如下:
yum install -y vpp-dpdk-devel
ip link set ens5 down
modprobe uio /* 加载用户空间IO模块 */
insmod igb_uio.ko /* 加载dpdk生成的支持虚拟化的的用户态io模块igb_uio */
dpdk-devbind -b igb_uio 00:05.0 /* 将ens5网卡从kernel detach,attach到IGB_UIO driver,00:05.0就是网卡的pci号 */
gdb testpmd /* gdb调试testpmd */
set args -l 1-7 -n 2 -- -i --nb-cores=6 --eth-peer=0,82:54:00:d8:42:b0 --forward-mode=rxonly --txq=12 --rxq=12 --txd=1024 --rxd=1024
set print pretty
b modern_set_status
b modern_get_status
r
- 结果如下:
- 函数断在了modern_set_status,vtpci_reset函数调用这个函数复位virtio-net设备,流程如下:
void vtpci_reset(struct virtio_hw *hw)
{
VTPCI_OPS(hw)->set_status(hw, VIRTIO_CONFIG_STATUS_RESET);
/* flush status write */
VTPCI_OPS(hw)->get_status(hw);
}
/* set_status回调函数的定义 */
const struct virtio_pci_ops modern_ops = {
.read_dev_cfg= modern_read_dev_config,
.write_dev_cfg= modern_write_dev_config,
.get_status= modern_get_status,
.set_status= modern_set_status,
......
};
/* 复位virtio-net设备的实际操作就是往common配置空间的device_status字段写0 */
static void modern_set_status(struct virtio_hw *hw, uint8_t status)
{
rte_write8(status, &hw->common_cfg->device_status);
}
- 反汇编modern_set_status函数得到两条mov指令,分别如下:
=> 0x000055555596c360 <+0>: mov 0x48(%rdi),%rax
0x000055555596c364 <+4>: mov %sil,0x14(%rax)
- 第二条指令就是将rsi寄存器低8位的值写入rax指针偏移0x14的内存地址,rax指针偏移0x14的内存就是hw->common_cfg->device_status的内存地址,gdb执行第一条mov指令后,状态如下:
- 可以确定,gdb执行下一条mov指令的时候,就是真正写MMIO内存了,推断这条指令执行时会触发EPT misconfiguration异常并陷入到内核态,内核态模拟完该指令后返回用户态,最终调用virtio_pci_common_write。对于内核。我们需要打开trace验证。对于Qemu我们只需要gdb attach Qemu进程,在virtio_pci_common_write断住即可。
Host
- KVM分别打开kvm_exit、handle_mmio_page_fault和kvm_emulate_insn trace:
echo 1 > /sys/kernel/debug/tracing/events/kvm/kvm_exit/enable
echo 1 > /sys/kernel/debug/tracing/events/kvmmmu/handle_mmio_page_fault/enable
echo 1 > /sys/kernel/debug/tracing/events/kvm/kvm_emulate_insn/enable
- gdb attach Qemu进程并设置virtio_pci_common_write位断点
- 我们在Guest中把0x000055555596c364处的指令打印出来,随后执行mov指令,分别观察Guest、KVM和Qemu,如下:
- 从上我们可以看到,当Guest执行0x000055555596c364处的汇编指令(0x40 0x88 0x70 0x14)时,陷入了KVM,KVM在EPT misconfiguration缺页处理流程中,处理了MMIO内存的缺页,具体操作就是代替Guest执行了汇编指令,以此实现对Guest的内存读写动作的模拟,然后返回用户态,触发virtio_pci_common_write回调。