原文目录[https://www.cnblogs.com/Bozh/p/5788431.html] 感谢作者同意转载
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来完成。
KVM有如下特点:
- guest作为一个普通进程运行于宿主机
- guest的CPU(vCPU)作为进程的线程存在,并受到宿主机内核的调度
- guest继承了宿主机内核的一些属性,比如huge pages(大页表)
- guest的磁盘IO和网络IO会受到宿主机的设置的影响
- guest通过宿主机上的虚拟网桥与外部相连
KVM整体架构
每一个虚拟机(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 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 0
x10000
-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, ®ion);
-
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, ®s);
-
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, $0x1
0
# 像 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进程,他在宿主机上的表现形式也就是一个用户级进程。
用一张图来解释更为贴切。
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 $0x8
0
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模式下。
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 的切换
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虚拟化,内存虚拟化都是建立在此基础上。下一章介绍内存虚拟化。
前面文章中我们讲过Qemu、KVM、Guest OS这三种层次以及对应的三种模式,也知道这三种模式之间的配合,下面上一张图回顾一下。
那现在我们就从代码的角度来讲一下这三层之间具体是如何配合的。
前面我们也讲过,首先Qemu层用户发起启动虚拟机命令后会通过ioctl调用进入到kvm内核层,完成相关初始化工作之后就运行虚拟机。
在kvm内核层中,当接收到ioctl的KVM_RUN命令后,实际调用的是kvm_arch_vcpu_ioctl_run()函数。
- case KVM_RUN:
- r = -EINVAL;
- if (arg)
- goto out;
- r =kvm_arch_vcpu_ioctl_run(vcpu, vcpu->run);//
- trace_kvm_userspace_exit(vcpu->run->exit_reason,r);
- break;
随后依次调用__vcpu_run(),vcpu_enter_guest(),kvm_x86_ops->run(),vmx_vcpu_run(),在vmx_vcpu_run()函数中有一段汇编语言被调用,这段汇编中执行了ASM_VMX_VMLAUNCH或者ASM_VMX_VMRESUME指令进入到客户模式。
- asm(
- .........//省略部分代码
- /* Enter guest mode */
- "jne 1f \n\t"
- __ex(ASM_VMX_VMLAUNCH)"\n\t"
- "jmp 2f \n\t"
- "1: "__ex(ASM_VMX_VMRESUME) "\n\t"
- ........//省略部分代码
- );
执行汇编指令进入到客户模式能够实现是因为KVM采用了硬件虚拟化的技术,比如Intel的芯片上提供了硬件支持并提供了相关一系列指令。再具体我也不知道了,查看Intel手册吧。那么进入到客户模式后,客户模式因为一些异常需要退出到KVM内核进行处理,这个是怎么实现的呢?
首先我们要说一下一个与异常处理相关的重要的数据结构VMCS。VMCS是虚拟机控制结构,他分为三部分:版本信息;终止标识符;VMCS数据域。其中VMCS数据域包含六类信息:客户状态域,宿主机状态域,VM-Entry控制域,VM-Execution控制域,VM-Exit控制域以及VM-Exit信息域。宿主机状态域保存了基本的寄存器信息,其中CS:RIP指向KVM中异常处理程序的入口地址,VM-Exit信息域中存放异常退出原因等信息。实际上,在KVM内核初始化vcpu时就将异常处理程序入口地址装载进VMCS中CS:RIP寄存器结构,当客户机发生异常时,就根据这个入口地址退出到内核模式执行异常处理程序。
KVM内核中异常处理总入口函数是vmx_handle_exit()函数。
- static intvmx_handle_exit(struct kvm_vcpu *vcpu)
- {
- struct vcpu_vmx *vmx = to_vmx(vcpu);
- u32 exit_reason = vmx->exit_reason;
- ........//一些处理,省略这部分代码
- if (exit_reason <kvm_vmx_max_exit_handlers
- && kvm_vmx_exit_handlers[exit_reason])
- returnkvm_vmx_exit_handlers[exit_reason](vcpu);
- else {
- vcpu->run->exit_reason= KVM_EXIT_UNKNOWN;
- vcpu->run->hw.hardware_exit_reason= exit_reason;
- }
- return 0;
- }
该函数中,首先读取exit_reason,然后进行一些必要的处理,最后调用kvm_vmx_exit_handlers[exit_reason](vcpu),我们来看一下这个结构,实际上是一个函数指针数组,里面对应着所有的异常相应的异常处理函数。
- 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,
- [EXIT_REASON_IO_INSTRUCTION] = handle_io,
- [EXIT_REASON_CR_ACCESS] = handle_cr,
- [EXIT_REASON_DR_ACCESS] = handle_dr,
- [EXIT_REASON_CPUID] = handle_cpuid,
- [EXIT_REASON_MSR_READ] = handle_rdmsr,
- [EXIT_REASON_MSR_WRITE] = handle_wrmsr,
- [EXIT_REASON_PENDING_INTERRUPT] = handle_interrupt_window,
- [EXIT_REASON_HLT] = handle_halt,
- [EXIT_REASON_INVD] = handle_invd,
- [EXIT_REASON_INVLPG] = handle_invlpg,
- [EXIT_REASON_RDPMC] = handle_rdpmc,
- [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,
- [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,
- [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,
- };
这里面比如handle_ept_violation就是影子页(EPT页)缺页异常的处理函数。
我们以handle_ept_violation()为例向下说明,依次调用kvm_mmu_page_fault(),vcpu->arch.mmu.page_fault(),tdp_page_fault()等后续函数完成缺页处理。
在这里,我们要注意kvm_vmx_exit_handlers[exit_reason](vcpu)的返回值,比如当实际调用handle_ept_violation()时返回值大于0,就直接切回客户模式。但是有时候可能需要Qemu的协助。在实际调用(r = kvm_x86_ops->handle_exit(vcpu);)时,返回值大于0,那么就说明KVM已经处理完成,可以再次切换进客户模式,但如果返回值小于等于0,那就说明需要Qemu的协助,KVM会在run结构体中的exit_reason中记录退出原因,并进入到Qemu中进行处理。这个判断过程是在__vcpu_run()函数中进行的,实际是一个while循环。
- static int__vcpu_run(struct kvm_vcpu *vcpu)
- {
- ......//省略部分代码
- r = 1;
- while (r > 0) {
- if (vcpu->arch.mp_state ==KVM_MP_STATE_RUNNABLE &&
- !vcpu->arch.apf.halted)
- r =vcpu_enter_guest(vcpu);
- else {
- ......//省略部分代码
- }
- if (r <= 0)
- break;
- ......//省略部分代码
- }
- srcu_read_unlock(&kvm->srcu,vcpu->srcu_idx);
- vapic_exit(vcpu);
- return r;
- }
上面函数中vcpu_enter_guest()我们前面讲过,是在kvm内核中转入客户模式的函数,他处于while循环中,也就是如果不需要Qemu的协助,即r>0,那就继续循环,然后重新切换进客户系统运行,如果需要Qemu的协助,那返回值r<=0,退出循环,向上层返回r。
上面说的r一直往上层返回,直到kvm_vcpu_ioctl()函数中的
case KVM_RUN:
trace_kvm_userspace_exit(vcpu->run->exit_reason, r);
这一条语句就是将退出原因注入到Qemu层。
Qemu层这时候读取到ioctl的返回值,然后继续执行,就会判断有没有KVM的异常注入,这里其实我在前一篇文章中简单提及了一下。
- int kvm_cpu_exec(CPUArchState *env)
- {
- .......
- do {
- ......
- run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN,0);
- ......
- trace_kvm_run_exit(cpu->cpu_index,run->exit_reason);
- switch (run->exit_reason) {
- case KVM_EXIT_IO:
- ......
- break;
- case KVM_EXIT_MMIO:
- ......
- break;
- case KVM_EXIT_IRQ_WINDOW_OPEN:
- .......
- break;
- case KVM_EXIT_SHUTDOWN:
- ......
- break;
- case KVM_EXIT_UNKNOWN:
- ......
- break;
- case KVM_EXIT_INTERNAL_ERROR:
- ......
- break;
- default:
- ......
- break;
- }
- } while (ret == 0);
- }
trace_kvm_run_exit(cpu->cpu_index,run->exit_reason);这条语句就是接收内核注入的退出原因,后面switch语句进行处理,每一个case对应一种退出原因,这里你也可以自己添加的。因为也是在while循环中,处理完一次后又进行ioctl调用运行虚拟机并切换到客户模式,这就形成了一个完整的闭环。
内存虚拟化简介
前一章介绍了CPU虚拟化的内容,这一章介绍一下KVM的内存虚拟化原理。可以说内存是除了CPU外最重要的组件,Guest最终使用的还是宿主机的内存,所以内存虚拟化其实就是关于如何做Guest到宿主机物理内存之间的各种地址转换,如何转换会让转换效率更高呢,KVM经历了三代的内存虚拟化技术,大大加快了内存的访问速率。
传统的地址转换
在保护模式下,普通的应用进程使用的都是自己的虚拟地址空间,一个64位的机器上的每一个进程都可以访问0到2^64的地址范围,实际上内存并没有这么多,也不会给你这么多。对于进程而言,他拥有所有的内存,对内核而言,只分配了一小段内存给进程,待进程需要更多的进程的时候再分配给进程。
通常应用进程所使用的内存叫做虚拟地址,而内核所使用的是物理内存。内核负责为每个进程维护虚拟地址到物理内存的转换关系映射。
首先,逻辑地址需要转换为线性地址,然后由线性地址转换为物理地址。
逻辑地址 ==> 线性地址 ==> 物理地址
逻辑地址和线性地址之间通过简单的偏移来完成。
一个完整的逻辑地址 = [段选择符:段内偏移地址],查找GDT或者LDT(通过寄存器gdtr,ldtr)找到描述符,通过段选择符(selector)前13位在段描述符做index,找到Base地址,Base+offset就是线性地址。
为什么要这么做?据说是Intel为了保证兼容性。
逻辑地址到线性地址的转换在虚拟化中没有太多的需要介绍的,这一层不存在实际的虚拟化操作,和传统方式一样,最重要的是线性地址到物理地址这一层的转换。
传统的线性地址到物理地址的转换由CPU的页式内存管理,页式内存管理。
页式内存管理负责将线性地址转换到物理地址,一个线性地址被分五段描述,第一段为基地址,通过与当前CR3寄存器(CR3寄存器每个进程有一个,线程共享,当发生进程切换的时候,CR3被载入到对应的寄存器中,这也是各个进程的内存隔离的基础)做运算,得到页表的地址index,通过四次运算,最终得到一个大小为4K的页(有可能更大,比如设置了hugepages以后)。整个过程都是CPU完成,进程不需要参与其中,如果在查询中发现页已经存在,直接返回物理地址,如果页不存在,那么将产生一个缺页中断,内核负责处理缺页中断,并把页加载到页表中,中断返回后,CPU获取到页地址后继续进行运算。
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。
-
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的连续的物理地址空间。
软件模拟的虚拟化方式由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,后面的操作就和传统的访问内存的方式一致,当需要访问物理内存的时候,只会经过一层的影子页表的转换。
影子页表由qemu-kvm进程维护,实际上就是一个Guest的页表到宿主机页表的映射,每一级的页表的hash值对应到qemu-kvm中影子页表的一个目录。在初次GVA->HPA的转换时候,影子页表没有建立,此时Guest产生缺页中断,和传统的转换过程一样,经过两次转换(VA->PA),然后影子页表记录GVA->GPA->HVA->HPA。这样产生GVA->GPA的直接关系,保存到影子页表中。
影子页表的引入,减少了GVA->HPA的转换过程,但是坏处在于qemu-kvm需要为Guest的每个进程维护一个影子页表,这将带来很大的内存开销,同时影子页表的建立是很耗时的,如果Guest进程过多,将导致频繁的影子页表的导入与导出,虽然用了cache技术,但是还是软件层面的,效率并不是最好,所以Intel和AMD在此基础上提供了硬件虚拟化技术。
EPT硬件加速的虚拟化方式
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的技术的进化,效率也越来越高。