searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享
原创

MMIO内存模拟原理

2022-12-29 10:45:12
136
0

基本原理

模拟原理

  • 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通过以下步骤来实现:
  1. 定义MMIO内存,在Guest内存缺页而退出时,识别出来,在QEMU/KVM缺页处理流程中走模拟MMIO逻辑。
  2. QEMU/KVM在Host上获取Guest退出前的读写指令,直接执行,从而完成Guest的内存读写模拟。
  3. 返回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回调。
0条评论
0 / 1000
享乐主
2文章数
0粉丝数
享乐主
2 文章 | 0 粉丝
享乐主
2文章数
0粉丝数
享乐主
2 文章 | 0 粉丝
原创

MMIO内存模拟原理

2022-12-29 10:45:12
136
0

基本原理

模拟原理

  • 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通过以下步骤来实现:
  1. 定义MMIO内存,在Guest内存缺页而退出时,识别出来,在QEMU/KVM缺页处理流程中走模拟MMIO逻辑。
  2. QEMU/KVM在Host上获取Guest退出前的读写指令,直接执行,从而完成Guest的内存读写模拟。
  3. 返回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回调。
文章来自个人专栏
文章 | 订阅
0条评论
0 / 1000
请输入你的评论
0
0