第二章实验:CPU虚拟化

实验一:简易KVM虚拟机实例

背景知识

/dev/kvm 设备

/dev/kvm 是一个字符设备,KVM 通过该设备导出了一系列 ioctl 系统调用接口,QEMU 等用户层程序通过这些接口来控制虚拟机。KVM 的 ioctl 接口可以分为三类:

  1. 系统全局的 ioctl,这类 ioctl 的作用对象是 KVM 模块,对应内核处理函数入口为 kvm_dev_ioctl,功能包括获取 KVM 版本,创建 VM 等。
  2. 虚拟机相关的 ioctl,这类 ioctl 的作用对象是 VM,对应内核处理函数入口为 kvm_vm_ioctl ,功能包括设置 VM 内存,创建 VCPU 等。
  3. 虚拟机 VCPU 相关的 ioctl,这类 ioctl 的作用对象是 VCPU,对应内核处理函数入口为 kvm_vcpu_ioctl ,功能包括设置寄存器,VCPU运行等。

解决思路

创建一个简易 KVM 虚拟机实例分为两大部分:

  1. 实现一个简易的用户层 QEMU。
  2. 实现一个简易的 guest 内核。

实现简易的用户层 QEMU

本书的1.4.1章节中介绍了 KVM 虚拟机与 QEMU 的关系: KVM 的生命周期由 QEMU 管理和维护,一个 KVM 虚拟机可以理解为一个 QEMU 进程,虚拟机的一个 VCPU 对应 QEMU 进程中的一个线程。

KVM 导出了一系列 IOCTL 接口供用户层创建、配置、启动虚拟机,典型的用户层软件是 QEMU ,但是从本质上来说,QEMU 和 KVM 可以不必相互依赖,本实验展示简易的用户层程序(后称为简易 QEMU)。

该简易 QEMU 主要实现如下几个功能:

  • 创建虚拟机对象
  • 为虚拟机分配物理内存、加载 guest 二进制
  • 创建虚拟机 VCPU 对象
  • 设置 VCPU 的相关寄存器、将 VCPU 调度到物理 CPU 上运行
  • 处理 VM Exit 事件

创建虚拟机对象

首先打开 /dev/kvm 设备获取 KVM 模块的文件描述符 dev_fd ,通过 ioctl(KVM_GET_API_VERSION) 获取 KVM 的版本号,从而使用户层知道相关接口在内核是否有支持。

再通过 ioctl(KVM_CREATE_VM) 创建一个虚拟机对象,该 ioctl 返回一个代表虚拟机的文件描述符 vm_fd ,之后可以通过 vm_fd 控制虚拟机的内存、VCPU 等。

为虚拟机分配物理内存、加载 guest 二进制

虚拟机的物理内存对应 QEMU 的进程地址空间,使用 mmap 分配一定大小的内存作为虚拟机物理内存,然后调用 ioctl(KVM_SET_USER_MEMORY_REGION) 为虚拟机指定一个内存条。接着将简易的 guest 二进制加载到该内存区域。

创建虚拟机 VCPU 对象

调用 ioctl(KVM_CREATE_VCPU) 创建虚拟机 VCPU 对象,该 ioctl 返回一个代表 VCPU 的文件描述符 vcpu_fd ,之后通过 vcpu_fd 控制虚拟机的运行等。

虚拟机运行过程中用户层(简易QEMU)和内核层(KVM模块)之间的数据共享是通过 VCPU 维护的 kvm_run 数据结构来实现的。首先通过调用 ioctl(KVM_GET_VCPU_MMAP_SIZE) 得到该结构的大小,接着调用 mmapkvm_run 分配空间。

设置 VCPU 的相关寄存器、将 VCPU 调度到物理 CPU 上运行

为了让虚拟机 VCPU 运行起来,需要设置 VCPU 的相关寄存器,其中段寄存器和控制寄存器等特殊寄存器存放在 kvm_sregs 中,通过 ioctl(KVM_GET_SREGS)、ioctl(KVM_SET_SREGS) 读取和修改,通用寄存器存放在 kvm_regs 中,通过 ioctl(KVM_SET_REGS) 修改。

准备工作完毕,可以让虚拟机运行起来了,通常在一个死循环中对 vcpu_fd 调用 ioctl(KVM_RUN)。KVM 内核模块在处理这个 ioctl 时会把 VCPU 调度到物理 CPU 上运行,遇到 KVM 无法处理的退出事件则会返回到此处,处理完之后再次进入循环调用 ioctl(KVM_RUN) 进入虚拟机。

处理 VM Exit 事件

内核 KVM 无法处理时,会将信息保存到 kvm_run 并 return 0,用户层则可以通过读取该共享内存得知虚拟机退出原因,进行相应的处理。本例包括 guest 向 I/O 端口写数据产生的 KVM_EXIT_IO 退出的处理、执行 HALT 指令等。

实现简易的 guest 内核

简易内核主要实现一些能触发 VM Exit 的操作,包括向 I/O 端口写数据、执行 HALT 指令。汇编文件如下

// guest_test.S

.globl _start
    .code16
_start:
    xorw %ax, %ax

    out %ax, $0x10
    inc %ax
    out %ax, $0x10
    inc %ax
    out %ax, $0x10

    hlt

编译

$ as -32 guest_test.S -o guest_test.o
$ ld -m elf_i386 --oformat binary -e _start -Ttext 0x0 -o guest_test.bin guest_test.o

相关链接参数说明:

-m EMULATION                Set emulation
-e ADDRESS, --entry ADDRESS Set start address
--oformat TARGET            Specify target of output file
-Ttext ADDRESS              Set address of .text section
-o FILE, --output FILE      Set output file name

编译执行

$ make
as -32 guest_test.S -o guest_test.o
ld -m elf_i386 --oformat binary -e _start -Ttext 0x0 -o guest_test.bin guest_test.o

$ ls
guest_test.bin  guest_test.S  qemu_sample    qemu_sample.o
guest_test.o    Makefile      qemu_sample.c

$ ./qemu_sample
open /dev/kvm...
get vm fd...
load guest_test.bin...
get vcpu fd...
set vcpu regs...
vcpu run...
KVM_EXIT_IO
guest put io data: 0
vcpu run...
KVM_EXIT_IO
guest put io data: 1
vcpu run...
KVM_EXIT_IO
guest put io data: 2
vcpu run...
KVM_EXIT_HLT

实验二:捕获虚拟机下一条指令地址信息

背景知识

下一条指令信息,存放在虚拟机的 CS:RIP 中,需要使用VMX系列指令读取VMCS相关字段

解决思路

内核中封装了一些 vmcs 读取相关的函数:

# linux-5.15/arch/x86/kvm/vmx/vmx_ops.h

static __always_inline unsigned long __vmcs_readl(unsigned long field)
{
	unsigned long value;

	asm volatile("1: vmread %2, %1\n\t"
		     ".byte 0x3e\n\t" /* branch taken hint */
		     "ja 3f\n\t"

		     /*
		      * VMREAD failed.  Push '0' for @fault, push the failing
		      * @field, and bounce through the trampoline to preserve
		      * volatile registers.
		      */
		     "push $0\n\t"
		     "push %2\n\t"
		     "2:call vmread_error_trampoline\n\t"

		     /*
		      * Unwind the stack.  Note, the trampoline zeros out the
		      * memory for @fault so that the result is '0' on error.
		      */
		     "pop %2\n\t"
		     "pop %1\n\t"
		     "3:\n\t"

		     /* VMREAD faulted.  As above, except push '1' for @fault. */
		     ".pushsection .fixup, \"ax\"\n\t"
		     "4: push $1\n\t"
		     "push %2\n\t"
		     "jmp 2b\n\t"
		     ".popsection\n\t"
		     _ASM_EXTABLE(1b, 4b)
		     : ASM_CALL_CONSTRAINT, "=r"(value) : "r"(field) : "cc");
	return value;
}

static __always_inline u16 vmcs_read16(unsigned long field)
{
	vmcs_check16(field);
	if (static_branch_unlikely(&enable_evmcs))
		return evmcs_read16(field);
	return __vmcs_readl(field);
}
static __always_inline unsigned long vmcs_readl(unsigned long field)
{
	vmcs_checkl(field);
	if (static_branch_unlikely(&enable_evmcs))
		return evmcs_read64(field);
	return __vmcs_readl(field);
}

在 VM Exit 后,guest 相关信息会保存到 VMCS 字段,KVM 在 vmx_handle_exit 函数根据退出原因进行处理,处理完成后,再次返回虚拟机之前(VMENTRY/VMRESUME),下一条指令的地址信息保存VMCS中Guest相关字段中,在此可以查看虚拟机 CS:RIP 信息。

# linux-5.15/arch/x86/kvm/vmx/vmx.c

static int vmx_handle_exit(struct kvm_vcpu *vcpu, fastpath_t exit_fastpath)
{
	int ret = __vmx_handle_exit(vcpu, exit_fastpath);

    // 获取当前VMCS的guest_rip和guest_cs字段
	printk(KERN_NOTICE "GUEST CS:RIP=%04x:0x%016lx\n",
	       vmcs_read16(GUEST_CS_SELECTOR), vmcs_readl(GUEST_RIP));
	...
}

输出结果

在linux目录下应用patch,运行编译脚本文件,dmesg查看输出。

通过 dmesg 查看内核输出信息如下

$ dmesg
[1476092.242576] GUEST CS:RIP=0010:0xffffffffb768dc17
[1476092.242579] GUEST CS:RIP=0010:0xffffffffb768dc17
[1476092.242581] GUEST CS:RIP=0033:0x00005568d139af28
[1476092.242582] GUEST CS:RIP=0033:0x00005568d139af28
[1476092.242584] GUEST CS:RIP=0010:0xffffffffb768dc17
[1476092.242587] GUEST CS:RIP=0033:0x00005568d139af28
[1476092.242597] GUEST CS:RIP=0010:0xffffffffb768dc17
[1476092.242602] GUEST CS:RIP=0010:0xffffffffb768dc17
[1476092.242606] GUEST CS:RIP=0010:0xffffffffb768dc17
[1476092.242611] GUEST CS:RIP=0010:0xffffffffb768dc17
[1476092.242616] GUEST CS:RIP=0033:0x00005568d13ee0b5
[1476092.242641] GUEST CS:RIP=0010:0xffffffffb768dc17
[1476092.242644] GUEST CS:RIP=0033:0x00005568d13fa710
[1476092.242647] GUEST CS:RIP=0010:0xffffffffb768dc17
[1476092.242651] GUEST CS:RIP=0010:0xffffffffb768dc17
[1476092.242680] GUEST CS:RIP=0010:0xffffffffb768dc17
[1476092.242684] GUEST CS:RIP=0010:0xffffffffb768dc17

实验三:伪造虚拟机 CPUID 信息

背景知识

CPUID 指令

  • CPUID 指令主要用来获取处理器的 identification 和 feature 信息,输入参数通过 EAX、ECX 寄存器传入,输出的值返回到 EAX、EBX、ECX、EDX 寄存器。
CPUID-desc
ia-32

解决思路

在 guest 中查看 CPU 相关信息:

CPUinfo-guest

CPUID 指令的模拟在 kvm_emulate_cpuid 函数,首先从eax、ecx寄存器读取指令操作码等,然后调用 kvm_cpuid 遍历 vcpu->arch.cpuid_entries 表找出与本操作码对应的 entry,然后将 entry 中四个寄存器eax、ebx、ecx、edx的值读出。

# linux-5.15/arch/x86/kvm/cpuid.c

bool kvm_cpuid(struct kvm_vcpu *vcpu, u32 *eax, u32 *ebx,
	       u32 *ecx, u32 *edx, bool exact_only)
{
	u32 orig_function = *eax, function = *eax, index = *ecx;
	struct kvm_cpuid_entry2 *entry;
	bool exact, used_max_basic = false;

	entry = kvm_find_cpuid_entry(vcpu, function, index);
	exact = !!entry;

	if (!entry && !exact_only) {
		entry = get_out_of_range_cpuid_entry(vcpu, &function, index);
		used_max_basic = !!entry;
	}

	if (entry) {
		*eax = entry->eax;
		*ebx = entry->ebx;
		*ecx = entry->ecx;
		*edx = entry->edx;
		if (function == 7 && index == 0) {
			u64 data;
		        if (!__kvm_get_msr(vcpu, MSR_IA32_TSX_CTRL, &data, true) &&
			    (data & TSX_CTRL_CPUID_CLEAR))
				*ebx &= ~(F(RTM) | F(HLE));
		}
	} else {
		*eax = *ebx = *ecx = *edx = 0;
		/*
		 * When leaf 0BH or 1FH is defined, CL is pass-through
		 * and EDX is always the x2APIC ID, even for undefined
		 * subleaves. Index 1 will exist iff the leaf is
		 * implemented, so we pass through CL iff leaf 1
		 * exists. EDX can be copied from any existing index.
		 */
		if (function == 0xb || function == 0x1f) {
			entry = kvm_find_cpuid_entry(vcpu, function, 1);
			if (entry) {
				*ecx = index & 0xff;
				*edx = entry->edx;
			}
		}
	}
	trace_kvm_cpuid(orig_function, index, *eax, *ebx, *ecx, *edx, exact,
			used_max_basic);
	return exact;
}

int kvm_emulate_cpuid(struct kvm_vcpu *vcpu)
{
	u32 eax, ebx, ecx, edx;

	if (cpuid_fault_enabled(vcpu) && !kvm_require_cpl(vcpu, 0))
		return 1;

	eax = kvm_rax_read(vcpu);
	ecx = kvm_rcx_read(vcpu);
	kvm_cpuid(vcpu, &eax, &ebx, &ecx, &edx, false);
	kvm_rax_write(vcpu, eax);
	kvm_rbx_write(vcpu, ebx);
	kvm_rcx_write(vcpu, ecx);
	kvm_rdx_write(vcpu, edx);
	return kvm_skip_emulated_instruction(vcpu);
}

其中 vcpu->arch.cpuid_entries 表的设置:该entries是一个结构数组,由qemu和kvm共同维护,qemu在 kvm_arch_init_vcpu 函数中设置完 entries 的值之后,调用 kvm_vcpu_ioctl(cs, KVM_SET_CPUID2, &cpuid_data) 进入kvm,kvm中 kvm_vcpu_ioctl_set_cpuid2 函数再进一步设置。

本实验以“model name”项为例进行测试,根据 Intel 手册该项对应的 CPUID 指令的操作码为 0x80000002~0x80000004,在 kvm_cpuid 中进行操作码匹配,对相应的寄存器进行修改。


bool kvm_cpuid(struct kvm_vcpu *vcpu, u32 *eax, u32 *ebx,
	       u32 *ecx, u32 *edx, bool exact_only)
{
	u32 orig_function = *eax, function = *eax, index = *ecx;
	struct kvm_cpuid_entry2 *entry;
	bool exact, used_max_basic = false;

	/*set model_id fake value*/
	const char *model_id = "modify cpuid-model for test";
	u32 cpuid_model[12];
	int c, len, i;
	len = strlen(model_id);
    memset(cpuid_model, 0, 48);
    for (i = 0; i < 48; i++) {
        if (i >= len) {
            c = '\0';
        } else {
            c = (unsigned char)model_id[i];
        }
        cpuid_model[i >> 2] |= c << (8 * (i & 3));
    }
	/*set cpuid_model fake value*/

	entry = kvm_find_cpuid_entry(vcpu, function, index);
	exact = !!entry;

	if (!entry && !exact_only) {
		entry = get_out_of_range_cpuid_entry(vcpu, &function, index);
		used_max_basic = !!entry;
	}

	if (entry) {
		*eax = entry->eax;
		*ebx = entry->ebx;
		*ecx = entry->ecx;
		*edx = entry->edx;

		/*****case model-id modify test*/
		if(function == 0x80000002 || function == 0x80000003 || function == 0x80000004){
			printk("index:0x%x, cpuid_model=0x%x", index, cpuid_model[(function - 0x80000002) * 4 + 0]);
			*eax = cpuid_model[(function - 0x80000002) * 4 + 0];
			*ebx = cpuid_model[(function - 0x80000002) * 4 + 1];
			*ecx = cpuid_model[(function - 0x80000002) * 4 + 2];
			*edx = cpuid_model[(function - 0x80000002) * 4 + 3];
		}
		/*****case model-id modify test*/
		...
}

编译运行结果如下:

modify-cpuid-model-test