深度探索Linux系统虚拟化:原理与实现
上QQ阅读APP看书,第一时间看更新

1.3.1 访问外设

前文中提到,虚拟化的3个条件之一是资源控制,即由VMM控制和协调宿主机资源给各个虚拟机,而不能由虚拟机控制宿主机的资源。以虚拟机的不同处理器之间发送核间中断为例,核间中断是由一个CPU通过其对应的LAPIC发送中断信号到目标CPU对应的LAPIC,如果不加限制地任由Guest访问CPU的物理LAPIC芯片,那么这个中断信号就可能被发送到其他物理CPU了。而对于虚拟化而言,不同的CPU只是不同的线程,核间中断本质上是在同一个进程的不同线程之间发送中断信号。当Guest的一个CPU(线程)发送核间中断时,应该陷入VMM中,由虚拟的LAPIC找到目标CPU(线程),向目标CPU(线程)注入中断。

常用的访问外设方式包括PIO(programmed I/O)和MMIO(memory-mapped I/O)。在这一节,我们重点探讨MMIO,然后简单地介绍一下PIO,更多内容将在“设备虚拟化”一章中讨论。

1.MMIO

MMIO是PCI规范的一部分,I/O设备被映射到内存地址空间而不是I/O空间。从处理器的角度来看,I/O映射到内存地址空间后,访问外设与访问内存一样,简化了程序设计。以MMIO方式访问外设时不使用专用的访问外设的指令(out、outs、in、ins),是一种隐式的I/O访问,但是因为这些映射的地址空间是留给外设的,因此CPU将产生页面异常,从而触发虚拟机退出,陷入VMM中。以LAPIC为例,其使用一个4KB大小的设备内存保存各个寄存器的值,内核将这个4KB大小的页面映射到地址空间中:


linux-1.3.31/arch/i386/kernel/smp.c
void smp_boot_cpus(void)
{
    …
    apic_reg = vremap(0xFEE00000,4096);
    …
}

linux-1.3.31/include/asm-i386/i82489.h
#define     APIC_ICR    0x300
linux-1.3.31/include/asm-i386/smp.h
extern __inline void apic_write(unsigned long reg, 
unsigned long v)
{
    *((unsigned long *)(apic_reg+reg))=v;
}

代码中地址0xFEE00000是32位x86架构为LAPIC的4KB的设备内存分配的总线地址,映射到地址空间中的逻辑地址为apic_reg。LAPIC各个寄存器都存储在这个4KB设备内存中,各个寄存器可以使用相对于4KB内存的偏移寻址。比如,icr寄存器的低32位的偏移为0x300,因此icr寄存器的逻辑地址为apic_reg+0x300,此时访问icr寄存器就像访问普通内存一样了,写icr寄存器的代码如下所示:


linux-1.3.31/arch/i386/kernel/smp.c
void smp_boot_cpus(void)
{
    …
            apic_write(APIC_ICR, cfg);   /* Kick the second  */
    …
}

当Guest执行这条指令时,由于这是为LAPIC保留的地址空间,因此将触发Guest发生页面异常,进入KVM模块:


commit 97222cc8316328965851ed28d23f6b64b4c912d2
KVM: Emulate local APIC in kernel
linux.git/drivers/kvm/vmx.c
static int handle_exception(struct kvm_vcpu *vcpu, …)
{
    …
    if (is_page_fault(intr_info)) {
        …
        r = kvm_mmu_page_fault(vcpu, cr2, error_code);
        …
        if (!r) {
            …
            return 1;
        }

        er = emulate_instruction(vcpu, kvm_run, cr2, error_code);
        …
    }
    …
}

显然对于这种页面异常,缺页异常处理函数是没法处理的,因为这个地址范围根本就不是留给内存的,所以,最后逻辑就到了函数emulate_instruction。后面我们会看到,为了提高效率和简化实现,Intel VMX增加了一种原因为apic access的虚拟机退出,我们会在“中断虚拟化”一章中讨论。可以毫不夸张地说,MMIO的模拟是KVM指令模拟中较为复杂的,代码非常晦涩难懂。要理解MMIO的模拟,需要对x86指令有所了解。我们首先来看一下x86指令的格式,如图1-4所示。

图1-4 x86指令格式

首先是指令前缀(instruction prefixes),典型的比如lock前缀,其对应常用的原子操作。当指令前面添加了lock前缀,后面的操作将锁内存总线,排他地进行该次内存读写,高性能编程领域经常使用原子操作。此外,还有常用于mov系列指令之前的rep前缀等。

每一个指令都包含操作码(opcode),opcode就是这个指令的索引,占用1~3字节。opcode是指令编码中最重要的部分,所有的指令都必须有opcode,而其他的5个域都是可选的。

与操作码不同,操作数并不都是嵌在指令中的。操作码指定了寄存器以及嵌入在指令中的立即数,至于是在哪个寄存器、在内存的哪个位置、使用哪个寄存器索引内存位置,则由ModR/M和SIB通过编码查表的方式确定。

displacement表示偏移,immediate表示立即数。

我们以下面的代码片段为例,看一下编译器将MMIO访问翻译的汇编指令:


// test.c
char *icr_reg;
void write() {
    *((unsigned long *)icr_reg) = 123;
}

我们将上述代码片段编译为汇编指令:


gcc -S test.c

核心汇编指令如下:


// test.s
    movq    icr_reg(%rip), %rax
    movq    $123, (%rax)

可见,这段MMIO访问被编译器翻译为mov指令,源操作数是立即数,目的操作数icr_reg(%rip)相当于icr寄存器映射到内存地址空间中的内存地址。因为这个地址是一段特殊的地址,所以当Guest访问这个地址,即上述第2行代码时,将产生页面异常,触发虚拟机退出,进入KVM模块。

KVM中模拟指令的入口函数是emulate_instruction,其核心部分在函数x86_emulate_memop中,结合这个函数我们来讨论一下MMIO指令的模拟:


commit 97222cc8316328965851ed28d23f6b64b4c912d2
KVM: Emulate local APIC in kernel
linux.git/drivers/kvm/x86_emulate.c
01 int x86_emulate_memop(struct x86_emulate_ctxt *ctxt, …)
02 {
03     unsigned d;
04     u8 b, sib, twobyte = 0, rex_prefix = 0;
05     …
06     for (i = 0; i < 8; i++) {
07         switch (b = insn_fetch(u8, 1, _eip)) {
08     …
09     d = opcode_table[b];
10     …
11     if (d & ModRM) {
12         modrm = insn_fetch(u8, 1, _eip);
13         modrm_mod |= (modrm & 0xc0) >> 6;
14         …
15     }
16     …
17     switch (d & SrcMask) {
18     …
19     case SrcImm:
20         src.type = OP_IMM;
21         src.ptr = (unsigned long *)_eip;
22         src.bytes = (d & ByteOp) ? 1 : op_bytes;
23         …
24         switch (src.bytes) {
25         case 1:
26             src.val = insn_fetch(s8, 1, _eip);
27             break;
28         …
29     }
30     …
31     switch (d & DstMask) {
32     …
33     case DstMem:
34         dst.type = OP_MEM;
35         dst.ptr = (unsigned long *)cr2;
36         dst.bytes = (d & ByteOp) ? 1 : op_bytes;
37     …
38     }
39     …
40     switch (b) {
41     …
42     case 0x88 ... 0x8b: /* mov */
43     case 0xc6 ... 0xc7: /* mov (sole member of Grp11) */
44         dst.val = src.val;
45         break;
46     …
47     }
48
49 writeback:
50     if (!no_wb) {
51         switch (dst.type) {
52         …
53         case OP_MEM:
54             …
56                 rc = ops->write_emulated((unsigned long)dst.ptr,
57                              &dst.val, dst.bytes,
58                              ctxt->vcpu);
59     …
60     ctxt->vcpu->rip = _eip;
61     …
62 }

函数x86_emulate_memop首先解析代码的前缀,即代码第6~8行。在处理完指令前缀后,变量b通过函数insn_fetch读入的是操作码(opcode),然后需要根据操作码判断指令操作数的寻址方式,该方式记录在一个数组opcode_table中,以操作码为索引就可以读出寻址方式,见第9行代码。如果使用了ModR/M和SIB寻址操作数,则解码ModR/M和SIB部分见第11~15行代码。

第17~29行代码解析源操作数,对于以MMIO方式写APIC的寄存器来说,源操作数是立即数,所以进入第19行代码所在的分支。因为立即数直接嵌在指令编码里,所以根据立即数占据的字节数,调用insn_fetch从指令编码中读取立即数,见第25~27行代码。为了减少代码的篇幅,这里只列出了立即数为1字节的情况。

第31~38行代码解析目的操作数,对于以MMIO方式写APIC的寄存器来说,其目的操作数是内存,所以进入第33行代码所在的分支。本质上,这条指令是因为向目的操作数指定的地址写入时引发页面异常,而引起异常的地址记录在cr2寄存器中,所以目的操作数的地址就是cr2寄存器中的地址,见第35行代码。

确定好了源操作数和目的操作数后,接下来就要模拟操作码所对应的操作了,即第40~47行代码。对于以MMIO方式写APIC的寄存器来说,其操作是mov,所以进入第42、43行代码所在分支。这里模拟了mov指令的逻辑,将源操作数的值写入目的操作数指定的地址,见第44行代码。

指令模拟完成后,需要更新指令指针,跳过已经模拟完的指令,否则会形成死循环,见第60行代码。

对于一个设备而言,仅仅简单地把源操作数赋值给目的操作数指向的地址还不够,因为写寄存器的操作可能会伴随一些副作用,需要设备做些额外的操作。比如,对于APIC而言,写icr寄存器可能需要LAPIC向另外一个处理器发出IPI中断,因此还需要调用设备的相应处理函数,这就是第56~58行代码的目的,函数指针write_emulated指向的函数为emulator_write_emulated:


commit c5ec153402b6d276fe20029da1059ba42a4b55e5
KVM: enable in-kernel APIC INIT/SIPI handling
linux.git/drivers/kvm/kvm_main.c
int emulator_write_emulated(unsigned long addr, const void *val,…)
{
    …
    return emulator_write_emulated_onepage(addr, val, …);
}

static int emulator_write_emulated_onepage(unsigned long addr,…)
{
    …
    mmio_dev = vcpu_find_mmio_dev(vcpu, gpa);
    if (mmio_dev) {
        kvm_iodevice_write(mmio_dev, gpa, bytes, val);
        return X86EMUL_CONTINUE;
    }
    …
}

函数emulator_write_emulated_onepage根据目的操作数的地址找到MMIO设备,然后kvm_iodevice_write调用具体MMIO设备的处理函数。对于LAPIC模拟设备,这个函数是apic_mmio_write。如果Guest内核写的是icr寄存器,可以清楚地看到伴随着这个“写icr寄存器”的动作,LAPIC还有另一个副作用,即向其他CPU发送IPI:


commit c5ec153402b6d276fe20029da1059ba42a4b55e5
KVM: enable in-kernel APIC INIT/SIPI handling
linux.git/drivers/kvm/lapic.c
static void apic_mmio_write(struct kvm_io_device *this, …)
{
    …
    case APIC_ICR:
        …
        apic_send_ipi(apic);
    …
}

鉴于LAPIC的寄存器的访问非常频繁,所以Intel从硬件层面做了很多支持,比如为访问LAPIC的寄存器增加了专门退出的原因,这样就不必首先进入缺页异常函数来尝试处理,当缺页异常函数无法处理后再进入指令模拟函数,而是直接进入LAPIC的处理函数:


commit f78e0e2ee498e8f847500b565792c7d7634dcf54
KVM: VMX: Enable memory mapped TPR shadow (FlexPriority)
linux.git/drivers/kvm/vmx.c
static int (*kvm_vmx_exit_handlers[])(…) = {
    …
    [EXIT_REASON_APIC_ACCESS]             = handle_apic_access,
};

static int handle_apic_access(struct kvm_vcpu *vcpu, …)
{
    …
    er = emulate_instruction(vcpu, kvm_run, 0, 0, 0);
    …
}

2.PIO

PIO使用专用的I/O指令(out、outs、in、ins)访问外设,当Guest通过这些专门的I/O指令访问外设时,处于Guest模式的CPU将主动发生陷入,进入VMM。Intel PIO指令支持两种模式,一种是普通的I/O,另一种是string I/O。普通的I/O指令一次传递1个值,对应于x86架构的指令out、in;string I/O指令一次传递多个值,对应于x86架构的指令outs、ins。因此,对于普通的I/O,只需要记录下val,而对于string I/O,则需要记录下I/O值所在的地址。

我们以向块设备写数据为例,对于普通的I/O,其使用的是out指令,格式如表1-1所示。

表1-1 out指令格式

我们可以看到,无论哪种格式,out指令的源操作数都是寄存器al、ax、eax系列。因此,当陷入KVM模块时,KVM模块可以从Guest的rax寄存器的值中取出Guest准备写给外设的值,KVM将这个值存储到结构体kvm_run中。对于string类型的I/O,需要记录的是数据所在的内存地址,这个地址在陷入KVM前,CPU会将其记录在VMCS的字段GUEST_LINEAR_ADDRESS中,KVM将这个值从VMCS中读出来,存储到结构体kvm_run中:


commit 6aa8b732ca01c3d7a54e93f4d701b8aabbe60fb7
[PATCH] kvm: userspace interface
linux.git/drivers/kvm/vmx.c
static int handle_io(struct kvm_vcpu *vcpu, …)
{
    …
    if (kvm_run->io.string) {
    …
        kvm_run->io.address = vmcs_readl(GUEST_LINEAR_ADDRESS);
    } else
        kvm_run->io.value = vcpu->regs[VCPU_REGS_RAX]; /* rax */
    return 0;
}


然后,程序的执行流程流转到I/O模拟设备,模拟设备将从结构体kvm_run中取出I/O相关的值,存储到本地文件镜像或通过网络发给存储集群。I/O模拟的更多细节我们将在“设备虚拟化”一章讨论。