KVM 虚拟化原理探究--启动过程及各部分虚拟化原理

KVM 虚拟化原理探究— overview

标签(空格分隔): KVM


写在前面的话

本文不介绍kvm和qemu的基本安装操作,希望读者具有一定的KVM实践经验。同时希望借此系列博客,能够对KVM底层有一些清晰直观的认识,当然我没有通读KVM的源码,文中的内容一部分来自于书籍和资料,一部分来自于实践,还有一些来自于自己的理解,肯定会有一些理解的偏差,欢迎讨论并指正。本系列文章敬代表我个人观点和实践,不代表公司层面。

KVM虚拟化简介

KVM 全称 kernel-based virtual machine,由Qumranet公司发起,2008年被RedHat收购。
KVM实现主要基于Intel-V或者AMD-V提供的虚拟化平台,利用普通的Linux进程运行于虚拟态的指令集,模拟虚拟机监视器和CPU。KVM不提供硬件虚拟化操作,其IO操作等都借助QEMU来完成。

image_1apkng413147jg181jlq1j3kjhsm.png-16.4kB

KVM有如下特点:

  • guest作为一个普通进程运行于宿主机
  • guest的CPU(vCPU)作为进程的线程存在,并受到宿主机内核的调度
  • guest继承了宿主机内核的一些属性,比如huge pages(大页表)
  • guest的磁盘IO和网络IO会受到宿主机的设置的影响
  • guest通过宿主机上的虚拟网桥与外部相连

KVM整体架构

image_1apknjokv7q91f5cif312h6jpu13.png-122.4kB

每一个虚拟机(guest)在Host上都被模拟为一个QEMU进程,即emulation进程。
我们创建一个虚拟机后,用普通的ps 命令就可以查看到。

➜  ~ virsh list --all
 Id    Name                           State
----------------------------------------------------
 1     kvm-01                         running

➜  ~ ps aux | grep qemu
libvirt+ 20308 15.1  7.5 5023928 595884 ?      Sl   17:29   0:10 /usr/bin/qemu-system-x86_64 -name kvm-01 -S -machine pc-i440fx-wily,accel=kvm,usb=off -m 2048 -realtime mlock=off -smp 2 qemu ....

可以看到,此虚拟机就是一个普通的Linux进程,他有自己的pid。并且有四个线程,线程数量不是固定的,但是至少会有三个(vCPU,IO,Signal)。其中有两个是vCPU线程,有一个IO线程还有一个信号处理线程。

➜  ~ pstree -p 20308
qemu-system-x86(20308)-+-{qemu-system-x86}(20353)
                       |-{qemu-system-x86}(20408)
                       |-{qemu-system-x86}(20409)
                       |-{qemu-system-x86}(20412)

虚拟CPU

guest的所有用户级别(user)的指令集,都会直接由宿主机线程执行,此线程会调用KVM的ioctl方式提供的接口加载guest的指令并在特殊的CPU模式下运行,不需要经过CPU指令集的软件模拟转换,大大的减少了虚拟化成本,这也是KVM优于其他虚拟化方式的点之一。

KVM向外提供了一个虚拟设备/dev/kvm,通过ioctl(IO设备带外管理接口)来对KVM进行操作,包括虚拟机的初始化,分配内存,指令加载等等。

虚拟IO设备

guest作为一个进程存在,当然他的内核的所有驱动等都存在,只是硬件被QEMU所模拟(后面介绍virtio的时候特殊)。guest的所有硬件操作都会有QEMU来接管,QEMU负责与真实的宿主机硬件打交道。

虚拟内存

guest的内存在host上由emulator提供,对emulator来说,guest访问的内存就是他的虚拟地址空间,guest上需要经过一次虚拟地址到物理地址的转换,转换到guest的物理地址其实也就是emulator的虚拟地址,emulator再次经过一次转换,转换为host的物理地址。后面会有介绍各种虚拟化的优化手段,这里只是做一个overview。


虚拟机启动过程

第一步,获取到kvm句柄
kvmfd = open("/dev/kvm", O_RDWR);
第二步,创建虚拟机,获取到虚拟机句柄。
vmfd = ioctl(kvmfd, KVM_CREATE_VM, 0);
第三步,为虚拟机映射内存,还有其他的PCI,信号处理的初始化。
ioctl(kvmfd, KVM_SET_USER_MEMORY_REGION, &mem);
第四步,将虚拟机镜像映射到内存,相当于物理机的boot过程,把镜像映射到内存。
第五步,创建vCPU,并为vCPU分配内存空间。
ioctl(kvmfd, KVM_CREATE_VCPU, vcpuid);
vcpu->kvm_run_mmap_size = ioctl(kvm->dev_fd, KVM_GET_VCPU_MMAP_SIZE, 0);
第五步,创建vCPU个数的线程并运行虚拟机。
ioctl(kvm->vcpus->vcpu_fd, KVM_RUN, 0);
第六步,线程进入循环,并捕获虚拟机退出原因,做相应的处理。
这里的退出并不一定是虚拟机关机,虚拟机如果遇到IO操作,访问硬件设备,缺页中断等都会退出执行,退出执行可以理解为将CPU执行上下文返回到QEMU
open("/dev/kvm")
ioctl(KVM_CREATE_VM)
ioctl(KVM_CREATE_VCPU)
for (;;) {
     ioctl(KVM_RUN)
     switch (exit_reason) {
     case KVM_EXIT_IO:  /* ... */
     case KVM_EXIT_HLT: /* ... */
     }
}

关于KVM_CREATE_VM参数的描述,创建的VM是没有cpu和内存的,需要QEMU进程利用mmap系统调用映射一块内存给VM的描述符,其实也就是给VM创建内存的过程。

KVM ioctl接口文档

先来一个KVM API开胃菜

下面是一个KVM的简单demo,其目的在于加载 code 并使用KVM运行起来.
这是一个at&t的8086汇编,.code16表示他是一个16位的,当然直接运行是运行不起来的,为了让他运行起来,我们可以用KVM提供的API,将这个程序看做一个最简单的操作系统,让其运行起来。
这个汇编的作用是输出al寄存器的值到0x3f8端口。对于x86架构来说,通过IN/OUT指令访问。PC架构一共有65536个8bit的I/O端口,组成64KI/O地址空间,编号从0~0xFFFF。连续两个8bit的端口可以组成一个16bit的端口,连续4个组成一个32bit的端口。I/O地址空间和CPU的物理地址空间是两个不同的概念,例如I/O地址空间为64K,一个32bit的CPU物理地址空间是4G。
最终程序理想的输出应该是,al,bl的值后面KVM初始化的时候有赋值。
4\n (并不直接输出\n,而是换了一行),hlt 指令表示虚拟机退出

.globl _start
    .code16
_start:
    mov $0x3f8, %dx
    add %bl, %al
    add $'0', %al
    out %al, (%dx)
    mov $'\n', %al
    out %al, (%dx)
    hlt

我们编译一下这个汇编,得到一个 Bin.bin 的二进制文件

as -32 bin.S -o bin.o
ld -m elf_i386 --oformat binary -N -e _start -Ttext 0x10000 -o Bin.bin bin.o

查看一下二进制格式

➜  demo1 hexdump -C bin.bin
00000000  ba f8 03 00 d8 04 30 ee  b0 0a ee f4              |......0.....|
0000000c
对应了下面的code数组,这样直接加载字节码就不需要再从文件加载了
    const uint8_t code[] = {
        0xba, 0xf8, 0x03, /* mov $0x3f8, %dx */
        0x00, 0xd8,       /* add %bl, %al */
        0x04, '0',        /* add $'0', %al */
        0xee,             /* out %al, (%dx) */
        0xb0, '\n',       /* mov $'\n', %al */
        0xee,             /* out %al, (%dx) */
        0xf4,             /* hlt */
    };
#include <err.h>
#include <fcntl.h>
#include <linux/kvm.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>

int main(void) {
    int kvm, vmfd, vcpufd, ret;
    const uint8_t code[] = {
        0xba, 0xf8, 0x03, /* mov $0x3f8, %dx */
        0x00, 0xd8,       /* add %bl, %al */
        0x04, '0',        /* add $'0', %al */
        0xee,             /* out %al, (%dx) */
        0xb0, '\n',       /* mov $'\n', %al */
        0xee,             /* out %al, (%dx) */
        0xf4,             /* hlt */
    };
    uint8_t *mem;
    struct kvm_sregs sregs;
    size_t mmap_size;
    struct kvm_run *run;
    
    // 获取 kvm 句柄
    kvm = open("/dev/kvm", O_RDWR | O_CLOEXEC);
    if (kvm == -1)
        err(1, "/dev/kvm");

    // 确保是正确的 API 版本
    ret = ioctl(kvm, KVM_GET_API_VERSION, NULL);
    if (ret == -1)
        err(1, "KVM_GET_API_VERSION");
    if (ret != 12)
        errx(1, "KVM_GET_API_VERSION %d, expected 12", ret);
    
    // 创建一虚拟机
    vmfd = ioctl(kvm, KVM_CREATE_VM, (unsigned long)0);
    if (vmfd == -1)
        err(1, "KVM_CREATE_VM");
    
    // 为这个虚拟机申请内存,并将代码(镜像)加载到虚拟机内存中
    mem = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    if (!mem)
        err(1, "allocating guest memory");
    memcpy(mem, code, sizeof(code));

    // 为什么从 0x1000 开始呢,因为页表空间的前4K是留给页表目录
    struct kvm_userspace_memory_region region = {
        .slot = 0,
        .guest_phys_addr = 0x1000,
        .memory_size = 0x1000,
        .userspace_addr = (uint64_t)mem,
    };
    // 设置 KVM 的内存区域
    ret = ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, &region);
    if (ret == -1)
        err(1, "KVM_SET_USER_MEMORY_REGION");
    
    // 创建虚拟CPU
    vcpufd = ioctl(vmfd, KVM_CREATE_VCPU, (unsigned long)0);
    if (vcpufd == -1)
        err(1, "KVM_CREATE_VCPU");

    // 获取 KVM 运行时结构的大小
    ret = ioctl(kvm, KVM_GET_VCPU_MMAP_SIZE, NULL);
    if (ret == -1)
        err(1, "KVM_GET_VCPU_MMAP_SIZE");
    mmap_size = ret;
    if (mmap_size < sizeof(*run))
        errx(1, "KVM_GET_VCPU_MMAP_SIZE unexpectedly small");
    // 将 kvm run 与 vcpu 做关联,这样能够获取到kvm的运行时信息
    run = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, vcpufd, 0);
    if (!run)
        err(1, "mmap vcpu");

    // 获取特殊寄存器
    ret = ioctl(vcpufd, KVM_GET_SREGS, &sregs);
    if (ret == -1)
        err(1, "KVM_GET_SREGS");
    // 设置代码段为从地址0处开始,我们的代码被加载到了0x0000的起始位置
    sregs.cs.base = 0;
    sregs.cs.selector = 0;
    // KVM_SET_SREGS 设置特殊寄存器
    ret = ioctl(vcpufd, KVM_SET_SREGS, &sregs);
    if (ret == -1)
        err(1, "KVM_SET_SREGS");

    
    // 设置代码的入口地址,相当于32位main函数的地址,这里16位汇编都是由0x1000处开始。
    // 如果是正式的镜像,那么rip的值应该是类似引导扇区加载进来的指令
    struct kvm_regs regs = {
        .rip = 0x1000,
        .rax = 2,    // 设置 ax 寄存器初始值为 2
        .rbx = 2,    // 同理
        .rflags = 0x2,   // 初始化flags寄存器,x86架构下需要设置,否则会粗错
    };
    ret = ioctl(vcpufd, KVM_SET_REGS, &regs);
    if (ret == -1)
        err(1, "KVM_SET_REGS");

    // 开始运行虚拟机,如果是qemu-kvm,会用一个线程来执行这个vCPU,并加载指令
    while (1) {
        // 开始运行虚拟机
        ret = ioctl(vcpufd, KVM_RUN, NULL);
        if (ret == -1)
            err(1, "KVM_RUN");
        // 获取虚拟机退出原因
        switch (run->exit_reason) {
        case KVM_EXIT_HLT:
            puts("KVM_EXIT_HLT");
            return 0;
        // 汇编调用了 out 指令,vmx 模式下不允许执行这个操作,所以
        // 将操作权切换到了宿主机,切换的时候会将上下文保存到VMCS寄存器
        // 后面CPU虚拟化会讲到这部分
        // 因为虚拟机的内存宿主机能够直接读取到,所以直接在宿主机上获取到
        // 虚拟机的输出(out指令),这也是后面PCI设备虚拟化的一个基础,DMA模式的PCI设备
        case KVM_EXIT_IO:
            if (run->io.direction == KVM_EXIT_IO_OUT && run->io.size == 1 && run->io.port == 0x3f8 && run->io.count == 1)
                putchar(*(((char *)run) + run->io.data_offset));
            else
                errx(1, "unhandled KVM_EXIT_IO");
            break;
        case KVM_EXIT_FAIL_ENTRY:
            errx(1, "KVM_EXIT_FAIL_ENTRY: hardware_entry_failure_reason = 0x%llx",
                 (unsigned long long)run->fail_entry.hardware_entry_failure_reason);
        case KVM_EXIT_INTERNAL_ERROR:
            errx(1, "KVM_EXIT_INTERNAL_ERROR: suberror = 0x%x", run->internal.suberror);
        default:
            errx(1, "exit_reason = 0x%x", run->exit_reason);
        }
    }
}

编译并运行这个demo

gcc -g demo.c -o demo
➜  demo1 ./demo
4
KVM_EXIT_HLT

另外一个简单的QEMU emulator demo

IBM的徐同学有做过介绍,在此基础上我再详细介绍一下qemu-kvm的启动过程。

.globl _start
    .code16
_start:
    xorw %ax, %ax   # 将 ax 寄存器清零

loop1:
    out %ax, $0x10  # 像 0x10 的端口输出 ax 的内容,at&t汇编的操作数和Intel的相反。
    inc %ax         # ax 值加一
    jmp loop1       # 继续循环

这个汇编的作用就是一直不停的向0x10端口输出一字节的值。

从main函数开始说起

int main(int argc, char **argv) {
    int ret = 0;
    // 初始化kvm结构体
    struct kvm *kvm = kvm_init();

    if (kvm == NULL) {
        fprintf(stderr, "kvm init fauilt\n");
        return -1;
    }
    
    // 创建VM,并分配内存空间
    if (kvm_create_vm(kvm, RAM_SIZE) < 0) {
        fprintf(stderr, "create vm fault\n");
        return -1;
    }
    
    // 加载镜像
    load_binary(kvm);

    // only support one vcpu now
    kvm->vcpu_number = 1;
    // 创建执行现场
    kvm->vcpus = kvm_init_vcpu(kvm, 0, kvm_cpu_thread);
    
    // 启动虚拟机
    kvm_run_vm(kvm);

    kvm_clean_vm(kvm);
    kvm_clean_vcpu(kvm->vcpus);
    kvm_clean(kvm);
}

第一步,调用kvm_init() 初始化了 kvm 结构体。先来看看怎么定义一个简单的kvm。

struct kvm {
   int dev_fd;              // /dev/kvm 的句柄
   int vm_fd;               // GUEST 的句柄
   __u64 ram_size;          // GUEST 的内存大小
   __u64 ram_start;         // GUEST 的内存起始地址,
                            // 这个地址是qemu emulator通过mmap映射的地址
   
   int kvm_version;         
   struct kvm_userspace_memory_region mem; // slot 内存结构,由用户空间填充、
                                           // 允许对guest的地址做分段。将多个slot组成线性地址

   struct vcpu *vcpus;      // vcpu 数组
   int vcpu_number;         // vcpu 个数
};

初始化 kvm 结构体。

struct kvm *kvm_init(void) {
    struct kvm *kvm = malloc(sizeof(struct kvm));
    kvm->dev_fd = open(KVM_DEVICE, O_RDWR);  // 打开 /dev/kvm 获取 kvm 句柄

    if (kvm->dev_fd < 0) {
        perror("open kvm device fault: ");
        return NULL;
    }

    kvm->kvm_version = ioctl(kvm->dev_fd, KVM_GET_API_VERSION, 0);  // 获取 kvm API 版本

    return kvm;
}

第二步+第三步,创建虚拟机,获取到虚拟机句柄,并为其分配内存。

int kvm_create_vm(struct kvm *kvm, int ram_size) {
    int ret = 0;
    // 调用 KVM_CREATE_KVM 接口获取 vm 句柄
    kvm->vm_fd = ioctl(kvm->dev_fd, KVM_CREATE_VM, 0);

    if (kvm->vm_fd < 0) {
        perror("can not create vm");
        return -1;
    }

    // 为 kvm 分配内存。通过系统调用.
    kvm->ram_size = ram_size;
    kvm->ram_start =  (__u64)mmap(NULL, kvm->ram_size, 
                PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, 
                -1, 0);

    if ((void *)kvm->ram_start == MAP_FAILED) {
        perror("can not mmap ram");
        return -1;
    }
    
    // kvm->mem 结构需要初始化后传递给 KVM_SET_USER_MEMORY_REGION 接口
    // 只有一个内存槽
    kvm->mem.slot = 0;
    // guest 物理内存起始地址
    kvm->mem.guest_phys_addr = 0;
    // 虚拟机内存大小
    kvm->mem.memory_size = kvm->ram_size;
    // 虚拟机内存在host上的用户空间地址,这里就是绑定内存给guest
    kvm->mem.userspace_addr = kvm->ram_start;
    
    // 调用 KVM_SET_USER_MEMORY_REGION 为虚拟机分配内存。
    ret = ioctl(kvm->vm_fd, KVM_SET_USER_MEMORY_REGION, &(kvm->mem));

    if (ret < 0) {
        perror("can not set user memory region");
        return ret;
    }
    return ret;
}

接下来就是load_binary把二进制文件load到虚拟机的内存中来,在第一个demo中我们是直接把字节码放到了内存中,这里模拟镜像加载步骤,把二进制文件加载到内存中。

void load_binary(struct kvm *kvm) {
    int fd = open(BINARY_FILE, O_RDONLY);  // 打开这个二进制文件(镜像)

    if (fd < 0) {
        fprintf(stderr, "can not open binary file\n");
        exit(1);
    }

    int ret = 0;
    char *p = (char *)kvm->ram_start;

    while(1) {
        ret = read(fd, p, 4096);           // 将镜像内容加载到虚拟机的内存中
        if (ret <= 0) {
            break;
        }
        printf("read size: %d", ret);
        p += ret;
    }
}

加载完镜像后,需要初始化vCPU,以便能够运行镜像内容

struct vcpu {
    int vcpu_id;                 // vCPU id,vCPU
    int vcpu_fd;                 // vCPU 句柄
    pthread_t vcpu_thread;       // vCPU 线程句柄
    struct kvm_run *kvm_run;     // KVM 运行时结构,也可以看做是上下文
    int kvm_run_mmap_size;       // 运行时结构大小
    struct kvm_regs regs;        // vCPU的寄存器
    struct kvm_sregs sregs;      // vCPU的特殊寄存器
    void *(*vcpu_thread_func)(void *);  // 线程执行函数
};

struct vcpu *kvm_init_vcpu(struct kvm *kvm, int vcpu_id, void *(*fn)(void *)) {
    // 申请vcpu结构
    struct vcpu *vcpu = malloc(sizeof(struct vcpu));
    // 只有一个 vCPU,所以这里只初始化一个
    vcpu->vcpu_id = 0;
    // 调用 KVM_CREATE_VCPU 获取 vCPU 句柄,并关联到kvm->vm_fd(由KVM_CREATE_VM返回)
    vcpu->vcpu_fd = ioctl(kvm->vm_fd, KVM_CREATE_VCPU, vcpu->vcpu_id);

    if (vcpu->vcpu_fd < 0) {
        perror("can not create vcpu");
        return NULL;
    }
    
    // 获取KVM运行时结构大小
    vcpu->kvm_run_mmap_size = ioctl(kvm->dev_fd, KVM_GET_VCPU_MMAP_SIZE, 0);

    if (vcpu->kvm_run_mmap_size < 0) {
        perror("can not get vcpu mmsize");
        return NULL;
    }

    printf("%d\n", vcpu->kvm_run_mmap_size);
    // 将 vcpu_fd 的内存映射给 vcpu->kvm_run结构。相当于一个关联操作
    // 以便能够在虚拟机退出的时候获取到vCPU的返回值等信息
    vcpu->kvm_run = mmap(NULL, vcpu->kvm_run_mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, vcpu->vcpu_fd, 0);

    if (vcpu->kvm_run == MAP_FAILED) {
        perror("can not mmap kvm_run");
        return NULL;
    }
    
    // 设置线程执行函数
    vcpu->vcpu_thread_func = fn;
    return vcpu;
}

最后一步,以上工作就绪后,启动虚拟机。

void kvm_run_vm(struct kvm *kvm) {
    int i = 0;

    for (i = 0; i < kvm->vcpu_number; i++) {
        // 启动线程执行 vcpu_thread_func 并将 kvm 结构作为参数传递给线程
        if (pthread_create(&(kvm->vcpus->vcpu_thread), (const pthread_attr_t *)NULL, kvm->vcpus[i].vcpu_thread_func, kvm) != 0) {
            perror("can not create kvm thread");
            exit(1);
        }
    }

    pthread_join(kvm->vcpus->vcpu_thread, NULL);
}

启动虚拟机其实就是创建线程,并执行相应的线程回调函数。
线程回调函数在kvm_init_vcpu的时候传入

void *kvm_cpu_thread(void *data) {
    // 获取参数
    struct kvm *kvm = (struct kvm *)data;
    int ret = 0;
    // 设置KVM的参数
    kvm_reset_vcpu(kvm->vcpus);

    while (1) {
        printf("KVM start run\n");
        // 启动虚拟机,此时的虚拟机已经有内存和CPU了,可以运行起来了。
        ret = ioctl(kvm->vcpus->vcpu_fd, KVM_RUN, 0);
    
        if (ret < 0) {
            fprintf(stderr, "KVM_RUN failed\n");
            exit(1);
        }
        
        // 前文 kvm_init_vcpu 函数中,将 kvm_run 关联了 vCPU 结构的内存
        // 所以这里虚拟机退出的时候,可以获取到 exit_reason,虚拟机退出原因
        switch (kvm->vcpus->kvm_run->exit_reason) {
        case KVM_EXIT_UNKNOWN:
            printf("KVM_EXIT_UNKNOWN\n");
            break;
        case KVM_EXIT_DEBUG:
            printf("KVM_EXIT_DEBUG\n");
            break;
        // 虚拟机执行了IO操作,虚拟机模式下的CPU会暂停虚拟机并
        // 把执行权交给emulator
        case KVM_EXIT_IO:
            printf("KVM_EXIT_IO\n");
            printf("out port: %d, data: %d\n", 
                kvm->vcpus->kvm_run->io.port,  
                *(int *)((char *)(kvm->vcpus->kvm_run) + kvm->vcpus->kvm_run->io.data_offset)
                );
            sleep(1);
            break;
        // 虚拟机执行了memory map IO操作
        case KVM_EXIT_MMIO:
            printf("KVM_EXIT_MMIO\n");
            break;
        case KVM_EXIT_INTR:
            printf("KVM_EXIT_INTR\n");
            break;
        case KVM_EXIT_SHUTDOWN:
            printf("KVM_EXIT_SHUTDOWN\n");
            goto exit_kvm;
            break;
        default:
            printf("KVM PANIC\n");
            goto exit_kvm;
        }
    }

exit_kvm:
    return 0;
}

void kvm_reset_vcpu (struct vcpu *vcpu) {
    if (ioctl(vcpu->vcpu_fd, KVM_GET_SREGS, &(vcpu->sregs)) < 0) {
        perror("can not get sregs\n");
        exit(1);
    }
    // #define CODE_START 0x1000
    /* sregs 结构体 x86 struct kvm_sregs { struct kvm_segment cs, ds, es, fs, gs, ss; struct kvm_segment tr, ldt; struct kvm_dtable gdt, idt; __u64 cr0, cr2, cr3, cr4, cr8; __u64 efer; __u64 apic_base; __u64 interrupt_bitmap[(KVM_NR_INTERRUPTS + 63) / 64]; }; */
    // cs 为code start寄存器,存放了程序的起始地址
    vcpu->sregs.cs.selector = CODE_START;
    vcpu->sregs.cs.base = CODE_START * 16;
    // ss 为堆栈寄存器,存放了堆栈的起始位置
    vcpu->sregs.ss.selector = CODE_START;
    vcpu->sregs.ss.base = CODE_START * 16;
    // ds 为数据段寄存器,存放了数据开始地址
    vcpu->sregs.ds.selector = CODE_START;
    vcpu->sregs.ds.base = CODE_START *16;
    // es 为附加段寄存器
    vcpu->sregs.es.selector = CODE_START;
    vcpu->sregs.es.base = CODE_START * 16;
    // fs, gs 同样为段寄存器
    vcpu->sregs.fs.selector = CODE_START;
    vcpu->sregs.fs.base = CODE_START * 16;
    vcpu->sregs.gs.selector = CODE_START;
    
    // 为vCPU设置以上寄存器的值
    if (ioctl(vcpu->vcpu_fd, KVM_SET_SREGS, &vcpu->sregs) < 0) {
        perror("can not set sregs");
        exit(1);
    }
    
    // 设置寄存器标志位
    vcpu->regs.rflags = 0x0000000000000002ULL;
    // rip 表示了程序的起始指针,地址为 0x0000000
    // 在加载镜像的时候,我们直接将binary读取到了虚拟机的内存起始位
    // 所以虚拟机开始的时候会直接运行binary
    vcpu->regs.rip = 0;
    // rsp 为堆栈顶
    vcpu->regs.rsp = 0xffffffff;
    // rbp 为堆栈底部
    vcpu->regs.rbp= 0;

    if (ioctl(vcpu->vcpu_fd, KVM_SET_REGS, &(vcpu->regs)) < 0) {
        perror("KVM SET REGS\n");
        exit(1);
    }
}

运行一下结果,可以看到当虚拟机执行了指令 out %ax, $0x10 的时候,会引起虚拟机的退出,这是CPU虚拟化里面将要介绍的特殊机制。
宿主机获取到虚拟机退出的原因后,获取相应的输出。这里的步骤就类似于IO虚拟化,直接读取IO模块的内存,并输出结果。

➜  kvmsample git:(master) ✗ ./kvmsample
read size: 712288
KVM start run
KVM_EXIT_IO
out port: 16, data: 0
KVM start run
KVM_EXIT_IO
out port: 16, data: 1
KVM start run
KVM_EXIT_IO
out port: 16, data: 2
KVM start run
KVM_EXIT_IO
out port: 16, data: 3
KVM start run
KVM_EXIT_IO
out port: 16, data: 4
...

总结

虚拟机的启动过程基本上可以这么总结:
创建kvm句柄->创建vm->分配内存->加载镜像到内存->启动线程执行KVM_RUN。从这个虚拟机的demo可以看出,虚拟机的内存是由宿主机通过mmap调用映射给虚拟机的,而vCPU是宿主机的一个线程,这个线程通过设置相应的vCPU的寄存器指定了虚拟机的程序加载地址后,开始运行虚拟机的指令,当虚拟机执行了IO操作后,CPU捕获到中断并把执行权又交回给宿主机。

当然真实的qemu-kvm比这个复杂的多,包括设置很多IO设备的MMIO,设置信号处理等。

下一篇将介绍CPU虚拟化相关知识。

上一篇文章笼统的介绍了一个虚拟机的诞生过程,从demo中也可以看到,运行一个虚拟机再也不需要像以前想象的那样,需要用软件来模拟硬件指令集了。虚拟机的指令集直接运行在宿主机物理CPU上,当虚拟机中的指令设计到IO操作或者一些特殊指令的时候,控制权转让给了宿主机(这里其实是转让给了vm monitor,下面检查VMM),也就是一个demo进程,他在宿主机上的表现形式也就是一个用户级进程。

用一张图来解释更为贴切。

vcpu-follow.png-102.9kB

VMM完成vCPU,内存的初始化后,通过ioctl调用KVM的接口,完成虚拟机的创建,并创建一个线程来运行VM,由于VM在前期初始化的时候会设置各种寄存器来帮助KVM查找到需要加载的指令的入口(main函数)。所以线程在调用了KVM接口后,物理CPU的控制权就交给了VM。VM运行在VMX non-root模式,这是Intel-V或者AMD-V提供的一种特殊的CPU执行模式。然后当VM执行了特殊指令的时候,CPU将当前VM的上下文保存到VMCS寄存器(这个寄存器是一个指针,保存了实际的上下文地址),然后执行权切换到VMM。VMM 获取 VM 返回原因,并做处理。如果是IO请求,VMM 可以直接读取VM的内存并将IO操作模拟出来,然后再调用VMRESUME指令,VM继续执行,此时在VM看来,IO操作的指令被CPU执行了。

Intel-V 技术

Intel-V 技术是Intel为了支持虚拟化而提供的一套CPU特殊运行模式。

Intel-V虚拟化技术结构

Intel-V 在IA-32处理器上扩展了处理器等级,原来的CPU支持ring0~ring3 4个等级,但是Linux只使用了其中的两个ring0,ring3。当CPU寄存器标示了当前CPU处于ring0级别的时候,表示此时CPU正在运行的是内核的代码。而当CPU处于ring3级别的时候,表示此时CPU正在运行的是用户级别的代码。当发生系统调用或者进程切换的时候,CPU会从ring3级别转到ring0级别。ring3级别是不允许执行硬件操作的,所有硬件操作都需要系统提供的API来完成。
比如说一个IO操作:

int nread = read(fd, buffer, 1024);

当执行到此段代码的时候,然后查找到系统调用号,保存到寄存器eax,然后会将对应的参数压栈后产生一个系统调用中断,对应的是 int $0x80。产生了系统调用中断后,此时CPU将切换到ring0模式,内核通过寄存器读取到参数,并完成最后的IO后续操作,操作完成后返回ring3模式。

movel  $3,%eax
movel  fd,%ebx
movel  buffer,%ecx
movel  1024,%edx      
int    $0x80

Intel-V 在 ring0~ring3 的基础上,增加了VMX模式,VMX分为root和non-root。这里的VMX root模式是给VMM(前面有提到VM monitor),在KVM体系中,就是qemu-kvm进程所运行的模式。VMX non-root模式就是运行的Guest,Guest也分ring0~ring3,不过他并不感知自己处于VMX non-root模式下。

image_1appl048b1jqh1joj5hkthj1kdr29.png-169.9kB

Intel的虚拟架构基本上分两个部分:

  • 虚拟机监视器
  • 客户机(Guest VM)

虚拟机监视器(Virtual-machine monitors - VMM)

虚拟机监视器在宿主机上表现为一个提供虚拟机CPU,内存以及一系列硬件虚拟的实体,这个实体在KVM体系中就是一个进程,如qemu-kvm。VMM负责管理虚拟机的资源,并拥有所有虚拟机资源的控制权,包括切换虚拟机的CPU上下文等。

Guest

这个Guest在前面的Demo里面也提到,可能是一个操作系统(OS),也可能就是一个二进制程序,whatever,对于VMM来说,他就是一堆指令集,只需要知道入口(rip寄存器值)就可以加载。
Guest运行需要虚拟CPU,当Guest代码运行的时候,处于VMX non-root模式,此模式下,该用什么指令还是用什么指令,该用寄存器该用cache还是用cache,但是在执行到特殊指令的时候(比如Demo中的out指令),把CPU控制权交给VMM,由VMM来处理特殊指令,完成硬件操作。

VMM 与 Guest 的切换

image_1applkej22o6chnj5q1u6ffpq2m.png-18.5kB

Guest与VMM之间的切换分两个部分:VM entry 和 VM exit。有几种情况会导致VM exit,比如说Guest执行了硬件访问操作,或者Guest调用了VMCALL指令或者调用了退出指令或者产生了一个page fault,或者访问了特殊设备的寄存器等。当Guest处于VMX模式的时候,没有提供获取是否处于此模式下的指令或者寄存器,也就是说,Guest不能判断当前CPU是否处于VMX模式。当产生VM exit的时候,CPU会将exit reason保存到MSRs(VMX模式的特殊寄存器组),对应到KVM就是vCPU->kvm_run->exit_reason。VMM根据exit_reason做相应的处理。

VMM 的生命周期

如上图所示,VMM 开始于VMXON 指令,结束与VMXOFF指令。
第一次启动Guest,通过VMLAUNCH指令加载Guest,这时候一切都是新的,比如说起始的rip寄存器等。后续Guest exit后再entry,是通过VMRESUME指令,此指令会将VMCS(后面会介绍到)所指向的内容加载到当前Guest的上下文,以便Guest继续执行。

VMCS (Virtual-Machine control structure)

顾名思义,VMCS就是虚拟机控制结构,前面提到过很多次,Guest Exit的时候,会将当前Guest的上下文保存到VMCS中,Guest entry的时候把VMCS上下文恢复到VMM。VMCS是一个64位的指针,指向一个真实的内存地址,VMCS是以vCPU为单位的,就是说当前有多少个vCPU,就有多少个VMCS指针。VMCS的操作包括VMREAD,VMWRITE,VMCLEAR。

Guest exit Reason

下面是qemu-kvm定义的exit reason。可以看到有很多可能会导致Guest转让控制权。选取几个解释一下。

static int (*const kvm_vmx_exit_handlers[])(struct kvm_vcpu *vcpu) = {
    [EXIT_REASON_EXCEPTION_NMI]           = handle_exception, 
    [EXIT_REASON_EXTERNAL_INTERRUPT]      = handle_external_interrupt, 
    [EXIT_REASON_TRIPLE_FAULT]            = handle_triple_fault,
    [EXIT_REASON_NMI_WINDOW]              = handle_nmi_window,
     // 访问了IO设备
    [EXIT_REASON_IO_INSTRUCTION]          = handle_io,
     // 访问了CR寄存器,地址寄存器,和DR寄存器(debug register)一样,用于调试
    [EXIT_REASON_CR_ACCESS]               = handle_cr,
    [EXIT_REASON_DR_ACCESS]               = handle_dr, 
    [EXIT_REASON_CPUID]                   = handle_cpuid,
    // 访问了MSR寄存器
    [EXIT_REASON_MSR_READ]                = handle_rdmsr,
    [EXIT_REASON_MSR_WRITE]               = handle_wrmsr,
    [EXIT_REASON_PENDING_INTERRUPT]       = handle_interrupt_window,
    // Guest执行了HLT指令,Demo开胃菜就是这个指令
    [EXIT_REASON_HLT]                     = handle_halt,
    [EXIT_REASON_INVD]                    = handle_invd,
    [EXIT_REASON_INVLPG]                  = handle_invlpg,
    [EXIT_REASON_RDPMC]                   = handle_rdpmc,
    // 不太清楚以下VM系列的指令有什么用,猜测是递归VM(虚拟机里面运行虚拟机)
    [EXIT_REASON_VMCALL]                  = handle_vmcall, 
    [EXIT_REASON_VMCLEAR]                 = handle_vmclear,
    [EXIT_REASON_VMLAUNCH]                = handle_vmlaunch,
    [EXIT_REASON_VMPTRLD]                 = handle_vmptrld,
    [EXIT_REASON_VMPTRST]                 = handle_vmptrst,
    [EXIT_REASON_VMREAD]                  = handle_vmread,
    [EXIT_REASON_VMRESUME]                = handle_vmresume,
    [EXIT_REASON_VMWRITE]                 = handle_vmwrite,
    [EXIT_REASON_VMOFF]                   = handle_vmoff,
    [EXIT_REASON_VMON]                    = handle_vmon,
    
    [EXIT_REASON_TPR_BELOW_THRESHOLD]     = handle_tpr_below_threshold,
    // 访问了高级PCI设备
    [EXIT_REASON_APIC_ACCESS]             = handle_apic_access,
    [EXIT_REASON_APIC_WRITE]              = handle_apic_write,
    [EXIT_REASON_EOI_INDUCED]             = handle_apic_eoi_induced,
    [EXIT_REASON_WBINVD]                  = handle_wbinvd,
    [EXIT_REASON_XSETBV]                  = handle_xsetbv,
    // 进程切换
    [EXIT_REASON_TASK_SWITCH]             = handle_task_switch,
    [EXIT_REASON_MCE_DURING_VMENTRY]      = handle_machine_check,
    // ept 是Intel的一个硬件内存虚拟化技术
    [EXIT_REASON_EPT_VIOLATION]           = handle_ept_violation,
    [EXIT_REASON_EPT_MISCONFIG]           = handle_ept_misconfig,
    // 执行了暂停指令
    [EXIT_REASON_PAUSE_INSTRUCTION]       = handle_pause,
    [EXIT_REASON_MWAIT_INSTRUCTION]       = handle_invalid_op,
    [EXIT_REASON_MONITOR_INSTRUCTION]     = handle_invalid_op,
    [EXIT_REASON_INVEPT]                  = handle_invept,
};

总结

KVM的CPU虚拟化依托于Intel-V提供的虚拟化技术,将Guest运行于VMX模式,当执行了特殊操作的时候,将控制权返回给VMM。VMM处理完特殊操作后再把结果返回给Guest。
CPU虚拟化可以说是KVM的最关键的核心,弄清楚了VM Exit和VM Entry。后续的IO虚拟化,内存虚拟化都是建立在此基础上。下一章介绍内存虚拟化。


内存虚拟化简介

前一章介绍了CPU虚拟化的内容,这一章介绍一下KVM的内存虚拟化原理。可以说内存是除了CPU外最重要的组件,Guest最终使用的还是宿主机的内存,所以内存虚拟化其实就是关于如何做Guest到宿主机物理内存之间的各种地址转换,如何转换会让转换效率更高呢,KVM经历了三代的内存虚拟化技术,大大加快了内存的访问速率。

传统的地址转换

在保护模式下,普通的应用进程使用的都是自己的虚拟地址空间,一个64位的机器上的每一个进程都可以访问0到2^64的地址范围,实际上内存并没有这么多,也不会给你这么多。对于进程而言,他拥有所有的内存,对内核而言,只分配了一小段内存给进程,待进程需要更多的进程的时候再分配给进程。
通常应用进程所使用的内存叫做虚拟地址,而内核所使用的是物理内存。内核负责为每个进程维护虚拟地址到物理内存的转换关系映射。
首先,逻辑地址需要转换为线性地址,然后由线性地址转换为物理地址。

逻辑地址 ==> 线性地址 ==> 物理地址

逻辑地址和线性地址之间通过简单的偏移来完成。
image_1aq931k0p42710me1il511r1kin9.png-59.9kB

一个完整的逻辑地址 = [段选择符:段内偏移地址],查找GDT或者LDT(通过寄存器gdtr,ldtr)找到描述符,通过段选择符(selector)前13位在段描述符做index,找到Base地址,Base+offset就是线性地址。

为什么要这么做?据说是Intel为了保证兼容性。

逻辑地址到线性地址的转换在虚拟化中没有太多的需要介绍的,这一层不存在实际的虚拟化操作,和传统方式一样,最重要的是线性地址到物理地址这一层的转换。

传统的线性地址到物理地址的转换由CPU的页式内存管理,页式内存管理。
页式内存管理负责将线性地址转换到物理地址,一个线性地址被分五段描述,第一段为基地址,通过与当前CR3寄存器(CR3寄存器每个进程有一个,线程共享,当发生进程切换的时候,CR3被载入到对应的寄存器中,这也是各个进程的内存隔离的基础)做运算,得到页表的地址index,通过四次运算,最终得到一个大小为4K的页(有可能更大,比如设置了hugepages以后)。整个过程都是CPU完成,进程不需要参与其中,如果在查询中发现页已经存在,直接返回物理地址,如果页不存在,那么将产生一个缺页中断,内核负责处理缺页中断,并把页加载到页表中,中断返回后,CPU获取到页地址后继续进行运算。

image_1aq93m25p10v913qns5kpsfbjm.png-152.1kB

KVM中的内存结构

由于qemu-kvm进程在宿主机上作为一个普通进程,那对于Guest而言,需要的转换过程就是这样。

Guest虚拟内存地址(GVA)
          |
    Guest线性地址 
          |
   Guest物理地址(GPA)
          |             Guest
   ------------------
          |             HV
    HV虚拟地址(HVA)
          |
      HV线性地址
          |
    HV物理地址(HPA)

What's the fu*k ?这么多...
别着急,Guest虚拟地址到HV线性地址之间的转换和HV虚拟地址到线性地址的转换过程可以省略,这样看起来就更清晰一点。

Guest虚拟内存地址(GVA)
          |
   Guest物理地址(GPA)
          |             Guest
  ------------------
          |             HV
    HV虚拟地址(HVA)
          |
    HV物理地址(HPA)

前面也说到KVM通过不断的改进转换过程,让KVM的内存虚拟化更加的高效,我们从最初的软件虚拟化的方式介绍。

软件虚拟化方式实现

第一层转换,由GVA->GPA的转换和传统的转换关系一样,通过查找CR3然后进行页表查询,找到对应的GPA,GPA到HVA的关系由qemu-kvm负责维护,我们在第二章KVM启动过程的demo里面就有介绍到怎样给KVM映射内存,通过mmap的方式把HV的内存映射给Guest。

image_1aq96r87sq6v12lp1r8119n0cd21t.png-37.4kB

struct kvm_userspace_memory_region region = {
    .slot = 0,
    .guest_phys_addr = 0x1000,
    .memory_size = 0x1000,
    .userspace_addr = (uint64_t)mem,
};

可以看到,qemu-kvm的kvm_userspace_memory_region结构体描述了guest的物理地址起始位置和内存大小,然后描述了Guest的物理内存在HV的映射userspace_addr,通过多个slot,可以把不连续的HV的虚拟地址空间映射给Guest的连续的物理地址空间。

image_1aq965a4v18e1mnj15pb3du13pu1g.png-41.7kB

软件模拟的虚拟化方式由qemu-kvm来负责维护GPA->HVA的转换,然后再经过一次HVA->HPA的方式,从过程上来看,这样的访问是很低效的,特别是在当GVA到GPA转换时候产生缺页中断,这时候产生一个异常Guest退出,HV捕获异常后计算出物理地址(分配新的内存给Guest),然后重新Entry。这个过程会可能导致频繁的Guest退出,且转换过程过长。于是KVM使用了一种叫做影子页表的技术。

影子页表的虚拟化方式

影子页表的出现,就是为了减少地址转换带来的开销,直接把GVA转换到HVP的技术。在软件虚拟化的内存转换中,GVA到GPA的转换通过查询CR3寄存器来完成,CR3保存了Guest中的页表基地址,然后载入MMU来做地址转换。
在加入了影子页表的技术后,当访问到CR3寄存器的时候(可能是由于Guest进程后导致的),KVM捕获到这个操作,CPU虚拟化章节EXIT_REASON_CR_ACCESS,qemu-kvm通过载入特俗的CR3和影子页表来欺骗Guest这个就是真实的CR3,后面的操作就和传统的访问内存的方式一致,当需要访问物理内存的时候,只会经过一层的影子页表的转换。

image_1aq972tgu2tr15g216kh1u3s1s1q2a.png-47.2kB

影子页表由qemu-kvm进程维护,实际上就是一个Guest的页表到宿主机页表的映射,每一级的页表的hash值对应到qemu-kvm中影子页表的一个目录。在初次GVA->HPA的转换时候,影子页表没有建立,此时Guest产生缺页中断,和传统的转换过程一样,经过两次转换(VA->PA),然后影子页表记录GVA->GPA->HVA->HPA。这样产生GVA->GPA的直接关系,保存到影子页表中。

image_1aq97hvkm14lg14al112i1j1m1ren2n.png-19.4kB

影子页表的引入,减少了GVA->HPA的转换过程,但是坏处在于qemu-kvm需要为Guest的每个进程维护一个影子页表,这将带来很大的内存开销,同时影子页表的建立是很耗时的,如果Guest进程过多,将导致频繁的影子页表的导入与导出,虽然用了cache技术,但是还是软件层面的,效率并不是最好,所以Intel和AMD在此基础上提供了硬件虚拟化技术。

EPT硬件加速的虚拟化方式

image_1aq997mlo1rt01aka186abnob3134.png-65kB
EPT(extended page table)可以看做一个硬件的影子页表,在Guest中通过增加EPT寄存器,当Guest产生了CR3和页表的访问的时候,由于对CR3中的页表地址的访问是GPA,当地址为空时候,也就是Page fault后,产生缺页异常,如果在软件模拟或者影子页表的虚拟化方式中,此时会有VM退出,qemu-kvm进程接管并获取到此异常。但是在EPT的虚拟化方式中,qemu-kvm忽略此异常,Guest并不退出,而是按照传统的缺页中断处理,在缺页中断处理的过程中会产生EXIT_REASON_EPT_VIOLATION,Guest退出,qemu-kvm捕获到异常后,分配物理地址并建立GVA->HPA的映射,并保存到EPT中,将EPT载入到MMU,下次转换时候直接查询根据CR3查询EPT表来完成GVA->HPA的转换。以后的转换都由硬件直接完成,大大提高了效率,且不需要为每个进程维护一套页表,减少了内存开销。
在笔者的测试中,Guest和HV的内存访问速率对比为3756MB/s对比4340MB/s。可以看到内存访问已经很接近宿主机的水平了。

总结

KVM内存的虚拟化就是一个将虚拟机的虚拟内存转换为宿主机物理内存的过程,Guest使用的依然是宿主机的物理内存,只是在这个过程中怎样减少转换带来的开销成为优化的主要点。
KVM经过软件模拟->影子页表->EPT的技术的进化,效率也越来越高。

IO 虚拟化简介

前面的文章介绍了KVM的启动过程,CPU虚拟化,内存虚拟化原理。作为一个完整的风诺依曼计算机系统,必然有输入计算输出这个步骤。传统的IO包括了网络设备IO,块设备IO,字符设备IO等等,在KVM虚拟化原理探究里面,我们最主要介绍网络设备IO和块设备IO,其实他们的原理都很像,但是在虚拟化层又分化开了,这也是为什么网络设备IO虚拟化和块设备IO虚拟化要分开讲的原因。这一章介绍一下网络设备IO虚拟化,下一章介绍块设备IO虚拟化。

传统的网络IO流程

这里的传统并不是真的传统,而是介绍一下在非虚拟化环境下的网络设备IO流程。我们平常所使用的Linux版本,比如Debian或者CentOS等都是标准的Linux TCP/IP协议栈,协议栈底层提供了driver抽象层来适配不同的网卡,在虚拟化中最重要的是设备的虚拟化,但是了解整个网络IO流程后去看待虚拟化就会更加容易理解了。

标准的TCP/IP结构

在用户层,我们通过socket与Kernel做交互,包括创建端口,数据的接收发送等操作。
在Kernel层,TCP/IP协议栈负责将我们的socket数据封装到TCP或者UDP包中,然后进入IP层,加入IP地址端口信息等,进入数据链路层,加入Mac地址等信息后,通过驱动写入到网卡,网卡再把数据发送出去。如下图所示,比较主观的图。

image_1aqgfh90g3ip1gtmclm180313o4m.png-95.8kB

在Linux的TCP/IP协议栈中,每个数据包是有内核的skb_buff结构描述的,如下图所示,socket发送数据包的时候后,进入内核,内核从skb_buff的池中分配一个skb_buff用来承载数据流量。

image_1aqgg26d7gof1jaanlbtblvq13.png-114.6kB
当数据到了链路层,链路层做好相应的链路层头部封装后,调用驱动层适配层的发送接口 dev_queue_xmit,最终调用到 net_start_xmit 接口。
image_1aqgn9e3ak2g183g1s2j150713581g.png-268.7kB

发送数据和接收数据驱动层都采用DMA模式,驱动加载时候会为网卡映射内存并设置描述状态(寄存器中),也就是内存的起始位,长度,剩余大小等等。发送时候将数据放到映射的内存中,然后设置网卡寄存器产生一个中断,告诉网卡有数据,网卡收到中断后处理对应的内存中的数据,处理完后向CPU产生一个中断告诉CPU数据发送完成,CPU中断处理过程中向上层driver通知数据发送完成,driver再依次向上层返回。在这个过程中对于driver来说,发送是同步的。接收数据的流程和发送数据几乎一致,这里就不细说了。DMA的模式对后面的IO虚拟化来说很重要。

image_1aqger4b915nf19k11gjv1lc21atm9.png-46.6kB

KVM 网络IO虚拟化

准确来说,KVM只提供了一些基本的CPU和内存的虚拟化方案,真正的IO实现都由qemu-kvm来完成,只不过我们在介绍KVM的文章里都默认qemu-kvm和KVM为一个体系,就没有分的那么仔细了。实际上网络IO虚拟化都是由qemu-kvm来完成的。

KVM 全虚拟化IO

还记得我们第一章节的demo里面,我们的“镜像”调用了 out 指令产生了一个IO操作,然后因为此操作为敏感的设备访问类型的操作,不能在VMX non-root 模式下执行,于是VM exits,模拟器接管了这个IO操作。

switch (kvm->vcpus->kvm_run->exit_reason) {
        case KVM_EXIT_UNKNOWN:
            printf("KVM_EXIT_UNKNOWN\n");
            break;
        // 虚拟机执行了IO操作,虚拟机模式下的CPU会暂停虚拟机并
        // 把执行权交给emulator
        case KVM_EXIT_IO:
            printf("KVM_EXIT_IO\n");
            printf("out port: %d, data: %d\n", 
                kvm->vcpus->kvm_run->io.port,  
                *(int *)((char *)(kvm->vcpus->kvm_run) + kvm->vcpus->kvm_run->io.data_offset)
                );
            break;
        ...

虚拟机退出并得知原因为 KVM_EXIT_IO,模拟器得知由于设备产生了IO操作并退出,于是获取这个IO操作并打印出数据。这里其实我们就最小化的模拟了一个虚拟IO的过程,由模拟器接管这个IO。

在qemu-kvm全虚拟化的IO过程中,其实原理也是一样,KVM捕获IO中断,由qemu-kvm接管这个IO,由于采用了DMA映射,qemu-kvm在启动时候会注册设备的mmio信息,以便能获取到DMA设备的映射内存和控制信息。

static int pci_e1000_init(PCIDevice *pci_dev)
{
    e1000_mmio_setup(d); 
    // 为PCI设备设置 mmio 空间
    pci_register_bar(&d->dev, 0, PCI_BASE_ADDRESS_SPACE_MEMORY, &d->mmio); 
    pci_register_bar(&d->dev, 1, PCI_BASE_ADDRESS_SPACE_IO, &d->io);
    d->nic = qemu_new_nic(&net_e1000_info, &d->conf, object_get_typename(OBJECT(d)), d->dev.qdev.id, d);   
    add_boot_device_path(d->conf.bootindex, &pci_dev->qdev, "/ethernet-phy@0"); 
}

对于PCI设备来说,当设备与CPU之间通过映射了一段连续的物理内存后,CPU对PCI设备的访问只需要像访问内存一样访问既可以。IO设备通常有两种模式,一种是port模式,一种是MMIO模式,前者就是我们demo里面的in/out指令,后者就是PCI设备的DMA访问方式,两种方式的操作都能被KVM捕获。

于是qemu-kvm将此操作代替Guest完成后并执行相应的“回调”,也就是向vCPU产生中断告诉IO完成并返回Guest继续执行。vCPU中断和CPU中断一样,设置相应的寄存器后中断便会触发。

在全虚拟化环境下,Guest中的IO都由qemu-kvm接管,在Guest中看到的一个网卡设备并不是真正的一块网卡,而是由物理机产生的一个tap设备。知识在驱动注册的时候将一些tap设备所支持的特性加入到了Guest的驱动注册信息里面,所以在Guest中看到有网络设备。

image_1aqgs30fm15lj1pse1t6lm051751t.png-37.9kB

如上图所示,qemu接管了来自Guest的IO操作,真实的场景肯定是需要将数据再次发送出去的,而不是像demo一样打印出来,在Guest中的数据包二层封装的Mac地址后,qemu层不需要对数据进行拆开再解析,而只需要将数据写入到tap设备,tap设备和bridge之间交互完成后,由bridge直接发送到网卡,bridge(其实NIC绑定到了Bridge)开启了混杂模式,可以将所有请求都接收或者发送出去。

以下来自这篇文章的引用

当一个 TAP 设备被创建时,在 Linux 设备文件目录下将会生成一个对应 char 设备,用户程序可以像打开普通文件一样打开这个文件进行读写。当执行 write()操作时,数据进入 TAP 设备,此时对于 Linux 网络层来说,相当于 TAP 设备收到了一包数据,请求内核接受它,如同普通的物理网卡从外界收到一包数据一样,不同的是其实数据来自 Linux 上的一个用户程序。Linux 收到此数据后将根据网络配置进行后续处理,从而完成了用户程序向 Linux 内核网络层注入数据的功能。当用户程序执行 read()请求时,相当于向内核查询 TAP 设备上是否有需要被发送出去的数据,有的话取出到用户程序里,完成 TAP 设备的发送数据功能。针对 TAP 设备的一个形象的比喻是:使用 TAP 设备的应用程序相当于另外一台计算机,TAP 设备是本机的一个网卡,他们之间相互连接。应用程序通过 read()/write()操作,和本机网络核心进行通讯。

类似这样的操作

fd = open("/dev/tap", XXX)
write(fd, buf, 1024);
read(fd, buf, 1024);

bridge可能是一个Linux bridge,也可能是一个OVS(Open virtual switch),在涉及到网络虚拟化的时候,通常需要利用到bridge提供的VLAN tag功能。

以上就是KVM的网络全虚拟化IO流程了,我们也可以看到这个流程的不足,比如说当网络流量很大的时候,会产生过多的VM的切换,同时产生过多的数据copy操作,我们知道copy是很浪费CPU时钟周期的。于是qemu-kvm在发展的过程中,实现了virtio驱动。

KVM Virtio 驱动

基于 Virtio 的虚拟化也叫作半虚拟化,因为要求在Guest中加入virtio驱动,也就意味着Guest知道了自己运行于虚拟环境了。
image_1aqgvbg7090kr1a1meq1tvilc62a.png-48.6kB

不同于全虚拟化的方式,Virtio通过在Guest的Driver层引入了两个队列和相应的队列就绪描述符与qemu-kvm层Virtio Backend进行通信,并用文件描述符来替代之前的中断。
Virtio front-end与Backend之间通过Vring buffer交互,在qemu中,使用事件循环机制来描述buffer的状态,这样当buffer中有数据的时候,qemu-kvm会监听到eventfd的事件就绪,于是就可以读取数据后发送到tap设备,当有数据从tap设备过来的时候,qemu将数据写入到buffer,并设置eventfd,这样front-end监听到事件就绪后从buffer中读取数据。

可以看到virtio在全虚拟化的基础上做了改动,降低了Guest exit和entry的开销,同时利用eventfd来建立控制替代硬件中断面膜是,一定程度上改善了网络IO的性能。
不过从整体流程上来看,virtio还是存在过多的内存拷贝,比如qemu-kvm从Vring buffer中拷贝数据后发送到tap设备,这个过程需要经过用户态到内核态的拷贝,加上一系列的系统调用,所以在流程上还可以继续完善,于是出现了内核态的virtio,被称作vhost-net。

KVM Vhost-net

我们用一张图来对比一下virtio与vhost-net,图片来自redhat官网。

image_1aqh0s600fq41tj21b1r16331elm2n.png-143.8kB

vhost-net 绕过了 QEMU 直接在Guest的front-end和backend之间通信,减少了数据的拷贝,特别是减少了用户态到内核态的拷贝。性能得到大大加强,就吞吐量来说,vhost-net基本能够跑满一台物理机的带宽。
vhost-net需要内核支持,Redhat 6.1 后开始支持,默认状态下是开启的。

总结

KVM的网络设备IO虚拟化经过了全虚拟化->virtio->vhost-net的进化,性能越来越接近真实物理网卡,但是在小包处理方面任然存在差距,不过已经不是一个系统的瓶颈了,可以看到KVM在经过了这多年的发展后,性能也是越发的强劲,这也是他领先于其他虚拟化的重要原因之一。
在本章介绍了IO虚拟化后,下一章介绍块设备的虚拟化,块设备虚拟化同样利用了DMA的特性。


块设备IO虚拟化简介

上一篇文章讲到了网络IO虚拟化,作为另外一个重要的虚拟化资源,块设备IO的虚拟化也是同样非常重要的。同网络IO虚拟化类似,块设备IO也有全虚拟化和virtio的虚拟化方式(virtio-blk)。现代块设备的工作模式都是基于DMA的方式,所以全虚拟化的方式和网络设备的方式接近,同样的virtio-blk的虚拟化方式和virtio-net的设计方式也是一样,只是在virtio backend端有差别。

传统块设备架构

块设备IO协议栈

image_1aqo0ufta16pqlsm7nvuvc1ur71g.png-133.1kB

如上图所示,我们把块设备IO的流程也看做一个TCP/IP协议栈的话,从最上层说起。

Page cache层,这里如果是非直接IO,写操作如果在内存够用的情况下,都是写到这一级后就返回。在IO流程里面,属于writeback模式。 需要持久化的时候有两种选择,一种是显示的调用flush操作,这样此文件(以文件为单位)的cache就会同步刷到磁盘,另一种是等待系统自动flush。

VFS,也就是我们通常所说的虚拟文件系统层,这一层给我们上层提供了统一的系统调用,我们常用的create,open,read,write,close转化为系统调用后,都与VFS层交互。VFS不仅为上层系统调用提供了统一的接口,还组织了文件系统结构,定义了文件的数据结构,比如根据inode查找dentry并找到对应文件信息,并找到描述一个文件的数据结构struct file。文件其实是一种对磁盘中存储的一堆零散的数据的一种描述,在Linux上,一个文件由一个inode 表示。inode在系统管理员看来是每一个文件的唯一标识,在系统里面,inode是一个结构,存储了关于这个文件的大部分信息。这个数据结构有几个回调操作就是提供给不同的文件系统做适配的。下层的文件系统需要实现file_operation的几个接口,做具体的数据的读写操作等。

struct file_operations {
    //文件读操作
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    //文件写操作
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    int (*readdir) (struct file *, void *, filldir_t);
    //文件打开操作
    int (*open) (struct inode *, struct file *);
};

再往下就是针对不同的文件系统层,比如我们的ext3,ext4等等。 我们在VFS层所说的几个文件系统需要实现的接口,都在这一层做真正的实现。这一层的文件系统并不直接操作文件,而是通过与下层的通用块设备层做交互,为什么要抽象一层通用块设备呢?我们的文件系统适用于不同的设备类型,比如可能是一个SSD盘又或者是一个USB设备,不同的设备的驱动不一样,文件系统没有必要为每一种不同的设备做适配,只需要兼容通用快设备层的接口就可以。

位于文件系统层下面的是通用快设备层,这一层在程序设计里面是属于接口层,用于屏蔽底层不同的快设备做的抽象,为上层的文件系统提供统一的接口。

通用快设备下层就是IO调度层。用如下命令可以看到系统的IO调度算法.

➜  ~ cat /sys/block/sda/queue/scheduler
noop deadline [cfq]
  1. noop,可以看成是FIFO(先进先出队列),对IO做一些简单的合并,比如对同一个文件的操作做合并,这种算法适合比如SSD磁盘不需要寻道的块设备。

  2. cfq,完全公平队列。此算法的设计是从进程级别来保证的,就是说公平的对象是每个进程。系统为此算法分配了N个队列用来保存来自不同进程的请求,当进程有IO请求的时候,会散列到不同的队列,散列算法是一致的,同一个进程的请求总是被散列到同一个队列。然后系统根据时间片轮训这N个队列的IO请求来完成实际磁盘读写。

  3. deadline,在Linux的电梯调度算法的基础上,增加了两个队列,用来处理即将超时或者超时的IO请求,这两个队列的优先级比其他队列的优先级较高,所以避免了IO饥饿情况的产生。

块设备驱动层就是针对不同的块设备的真实驱动层了,块设备驱动层完成块设备的内存映射并处理块设备的中断,完成块设备的读写。

块设备就是真实的存储设备,包括SAS,SATA,SSD等等。块设备也可能有cache,一般称为Disk cache,对于驱动层来说,cache的存在是很重要的,比如writeback模式下,驱动层只需要写入到Disk cache层就可以返回,块设备层保证数据的持久化以及一致性。
通常带有Disk cache的块设备都有电池管理,当掉电的时候保证cache的内容能够保持一段时间,下次启动的时候将cache的内容写入到磁盘中。

块设备IO流程

应用层的读写操作,都是通过系统调用read,write完成,由Linux VFS提供的系统调用接口完成,屏蔽了下层块设备的复杂操作。write操作有直接IO和非直接IO之分(缓冲IO),非直接IO的写操作直接写入到page cache后就返回,后续的数据依赖系统的flush操作,如果在flush操作未完成的时候发生了系统掉电,那可能会丢失一部分数据。直接IO(Direct IO),绕过了page cache,数据必须达到磁盘后才返回IO操作完成。

对于I/O的读写流程,逻辑比较复杂,这里以写流程简单描述如下:
image_1aqo0lnfn6m8ei6lef1qg019p713.png-46.9kB

  1. 用户调用系统调用write写一个文件,会调到sys_write函数;
  2. 经过VFS虚拟文件系统层,调用vfs_write,如果是缓存写方式,则写入page cache,然后就返回,后续就是刷脏页的流程;如果是Direct I/O的方式,就会走到块设备直接IO(do_blockdev_direct_IO)的流程;
  3. 构造bio请求,调用submit_bio往具体的块设备下发请求,submit_bio函数通过generic_make_request转发bio,generic_make_request是一个循环,其通过每个块设备下注册的q->make_request_fn函数与块设备进行交互;
  4. 请求下发到底层的块设备上,调用块设备请求处理函数__make_request进行处理,在这个函数中就会调用blk_queue_bio,这个函数就是合并bio到request中,也就是I/O调度器的具体实现:如果几个bio要读写的区域是连续的,就合并到一个request;否则就创建一个新的request,把自己挂到这个request下。合并bio请求也是有限度的,如果合并后的请求超过阈值(在/sys/block/xxx/queue/max_sectors_kb里设置),就不能再合并成一个request了,而会新分配一个request;
  5. 接下来的I/O操作就与具体的物理设备有关了,块设备驱动的读写也是通过DMA方式进行。
    image_1aqnul1bnu1b1o3l14m81es11r72m.png-19.8kB

如上图所示,在初始化IO设备的时候,会为IO设备分配一部分物理内存,这个物理内存可以由CPU的MMU和连接IO总线的IOMMU管理,作为共享内存存在。以一个读取操作为例子,当CPU需要读取块设备的某个内容的时候,CPU会通过中断告知设备内存地址以及大小和需要读取的块设备地址,然后CPU返回,块设备完成实际的读取数据后,将数据写入到共享的内存,并以中断方式通知CPU IO流程完成,并设置内存地址,接着CPU直接从内存中读取数据。
写请求类似,都是通过共享内存的方式,这样可以解放CPU,不需要CPU同步等待IO的完成并且不需要CPU做过多的运算操作。
因为块设备IO的虚拟化需要经过两次IO协议栈,一次Guest,一次HV。所以需要把块设备IO协议栈说的很具体一点。

至此,Linux块设备的IO层就基本介绍完整了,以上内容也只是做一个简单的介绍,这部分的内容可以很深入的去了解,在此限于篇幅限制,就不做过多介绍了。

块设备IO虚拟化

块设备的全虚拟化方式和网络IO的DMA设备虚拟化方式类似,这里就不过多介绍了,主要介绍一下virtio-blk。

image_1aqo1nvbo1o615hn2kuptlbso1t.png-52.7kB

如上图所示,块设备IO的虚拟化流程和网络IO的流程基本一致,差别在于virtio-backend一段,virtio-net是写入到tap设备,virtio-blk是写入到镜像文件中。
块设备IO的流程需要经过两次IO协议栈,一次位于Guest,一次位于HV。当我们指定virtio的cache模式的时候,实际上指定的是virtio-backend(下面简称v-backend)写入HV块设备的方式。
在虚拟化层次来看,Guest对于这几种Cache模式是没有感知的,也就是无论Cache模式是怎样,Guest都不会有所谓的绕过Guest的Page cache等操作,Virtio-front模拟的是驱动层的操作,不会涉及到更上层的IO协议栈。

image_1aqo7picj8ks1qng1lt51errfvim.png-62.7kB

如上图所示,蓝色表示 writethrough,黄色表示 none,红色表示 writeback。其中虚线表示写到哪一个层次后write调用返回。

  • cache=writethrough (蓝色线)
    表示v-backend打开镜像文件并写入时候采用非直接IO+flush操作,也就是说每次写入到Page cache并flush一次,直到数据被真实写入到磁盘后write调用返回,这样必然会导致数据写入变慢,但是好处就是安全性较高。

  • cache=none (黄色线)
    cache为none模式表示了v-backend写入文件时候使用到了DIRECT_IO,将会绕过HV的Page cache,直接写入磁盘,如果磁盘有Disk cache的话,写入Disk cache就返回,此模式的好处在于保证性能的前提下,也能保证数据的安全性,在使用了Disk cache电池的情况下。但是对于读操作,因为没有写入HV Page cache,所以会有一定性能影响。

  • cache=writeback (红色线)
    此模式表示v-backend使用了非直接IO,写入到HV的Page后就返回,有可能会导致数据丢失。

总结

块设备IO的虚拟化方式也是统一的virtio-x模式,但是virtio-blk需要经过两次IO协议栈,带来了不必要的开销。前面的铺垫都是为了介绍三种重要的cache模式。




使用KVM API实现Emulator Demo

这边文章来描述如何用KVM API来写一个Virtualizer的demo code, 也就是相当与Qemu,用来做设备模拟。 此文是帮助想了解KVM原理已经Qemu原理的人 or Just for fun.

完整的Code在这里: https://github.com/soulxu/kvmsample

这个code其实是很久以前写的,以前在team内部分享过,用来帮助大家理解kvm工作原理。现在既然要开始写code了,就用这个先来个开端。

当然我不可能写一个完整的Qemu,只是写出Qemu中最基本的那些code。这个虚拟机只有一个VCPU和512000000字节内存(其实富裕了) 可以进行一些I/O,当然这些I/O的结果只能导致一些print,没有实际模拟任何设备。所以所能执行的Guest也很简单。

首先来看看Guest有多简单。

1
2
3
4
5
6
7
8
9
.globl _start  .code16 _start:  xorw %ax, %ax  loop1:  out %ax, $0x10  inc %ax  jmp loop1 

不熟悉汇编也没关系,这code很简单,基本也能猜到干啥了。对,Guest只是基于at&t汇编写的一个在8086模式下的死循环,不停的向端口0x10写东西。目标就是让这个Guest跑起来了。

我们的目标就是让这个Guest能执行起来。下面开始看我们虚拟机的code了。

我们先来看看main函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int main(int argc, char **argv) {  int ret = 0;  struct kvm *kvm = kvm_init();   if (kvm == NULL) {  fprintf(stderr, "kvm init fauilt\n");  return -1;  }   if (kvm_create_vm(kvm, RAM_SIZE) < 0) {  fprintf(stderr, "create vm fault\n");  return -1;  }   load_binary(kvm);   // only support one vcpu now  kvm->vcpu_number = 1;  kvm->vcpus = kvm_init_vcpu(kvm, 0, kvm_cpu_thread);   kvm_run_vm(kvm);   kvm_clean_vm(kvm);  kvm_clean_vcpu(kvm->vcpus);  kvm_clean(kvm); } 

这里正是第一个kvm基本原理: 一个虚拟机就是一个进程,我们的虚拟机从这个main函数开始

让我先来看看kvm_init。这里很简单,就是打开了/dev/kvm设备,这是kvm的入口,对kvm的所有操作都是通过对文件描述符上执行ioctl来完成。 这里很简单,就是打开kvm设备,然后将文件描述符返回到我自己创建的一个结构体当中。

然后我们就开始创建一个vm,然后为其分配内存。

1
kvm->vm_fd = ioctl(kvm->dev_fd, KVM_CREATE_VM, 0); 

创建一个虚拟机很简单,在kvm设备上执行这么一个ioctl即可,然后会得到新建的vm的文件描述,用来操作这个vm。

然后我们来分配内存,这里最重要的是struct kvm_userspace_memory_region这个数据结构。

1
2
3
4
5
6
7
8
/* for KVM_SET_USER_MEMORY_REGION */ struct kvm_userspace_memory_region {  __u32 slot;  __u32 flags;  __u64 guest_phys_addr;  __u64 memory_size; /* bytes */  __u64 userspace_addr; /* start of the userspace allocated memory */ }; 

memory_size是guest的内存的大小。userspace_addr是你为其份分配的内存的起始地址,而guest_phys_addr则是这段内存映射到guest的什么物理内存地址。

这里用mmap创建了一段匿名映射,并将地址置入userspace_addr。随后来告诉我们的vm这些信息:

1
ioctl(kvm->vm_fd, KVM_SET_USER_MEMORY_REGION, &(kvm->mem)); 

这里是来操作我们的vm了,不是kvm设备文件了。

我们有了内存了,现在可以把我们的guest code加载的进来了,这个实现很简单就是打开编译后的二进制文件将其写入我们分配的内存空间当中。 这里所要注意的就是如何编译guest code,这里我们编译出来的是flat binary,不需要什么elf的封装。

有了内存,下一步就是vcpu了,创建vcpu是在kvm_init_vcpu函数里。 这里最重要的操作只有这个:

1
2
3
vcpu->kvm_run_mmap_size = ioctl(kvm->dev_fd, KVM_GET_VCPU_MMAP_SIZE, 0); ... vcpu->kvm_run = mmap(NULL, vcpu->kvm_run_mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, vcpu->vcpu_fd, 0); 

struct kvm_run是保存vcpu状态的一个数据结构,稍后我们可以看到我们可以从这里得到当陷入后具体陷入原因。

有了内存和vcpu就可以运行了:

1
pthread_create(&(kvm->vcpus->vcpu_thread), (const pthread_attr_t *)NULL, kvm->vcpus[i].vcpu_thread_func, kvm) 

这里是另一个kvm基本概念了,一个vcpu就是一个线程。这里让我们为vcpu创建一个线程。

最终我们到了最关键的部分了,就是这个vcpu线程。其实他就是一个循环。 当循环开始的时候,我们让他执行guest code:

1
ret = ioctl(kvm->vcpus->vcpu_fd, KVM_RUN, 0) 

当执行这条语句后,guest code就开始执行了,这个函数就阻塞在这里了。直到something happened而且需要由hypervisor进行处理的时候这个函数才会返回。 比如说I/O发生了,这个函数就会返回了,这里我们就需要通过struct kvm_run中得到具体的陷入原因。我们的guest只是做一些I/O port的操作,所以可以看到 当退出原因是KVM_EXIT_IO时,我将guest的所写入的数据print出来。

到这里这就是这个virtualizer的全部了. 如果你想体验一下,只需要执行make。

1
2
3
4
5
:~/code/kvmsample$ make cc -c -o main.o main.c gcc main.c -o kvmsample -lpthread as -32 test.S -o test.o ld -m elf_i386 --oformat binary -N -e _start -Ttext 0x10000 -o test.bin test.o 

然后执行kvmsample

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ ./kvmsample read size: 712288 KVM start run KVM_EXIT_IO out port: 16, data: 0 KVM start run KVM_EXIT_IO out port: 16, data: 1 KVM start run KVM_EXIT_IO out port: 16, data: 2 KVM start run KVM_EXIT_IO out port: 16, data: 3 .... 

其实qemu里面的code也就是这样,你也可以在其中找到这个loop,只不过它被qemu内部的各种设备框架所隐藏起来了。

本站公众号
   欢迎关注本站公众号,获取更多程序园信息
开发小院