第三章实验:内存虚拟化

实验一:EPT页表副本的创建与替换

创建EPT页表副本,在虚拟机从实模式跳转到保护模式时替换原有的EPT页表

解决思路

如3.3.3中提到的,vmx_load_mmu_pgd函数中完成了EPT页表的加载:

if(enable_ept){
    eptp = construct_eptp(vcpu, root_hpa, root_level);
    vmcs_write64(EPT_POINTER, eptp);
    ......
}

vmx_load_mmu_pgd调用了construct_eptp,将其返回值(eptp)写入到 VMCSEPT_POINTER 字段。

construct_eptp函数中仅仅是根据Intel手册中对于VMCSEPT_POINTER字段的规定进行数据的“组装”,关键点在其参数root_hparoot_hpakvm_mmu_load_pgd调用vmx_load_mmu_pgd时提供的参数:

static inline void kvm_mmu_load_pgd(struct kvm_vcpu *vcpu)
{
    u64 root_hpa = vcpu->arch.mmu->root_hpa;
    ...
    static_call(kvm_x86_load_mmu_pgd)(vcpu, root_hpa,
                                     vcpu->arch.mmu->shadow_root_level);
}

继续跟踪vcpu->arch.mmu->root_hpa的来源,kvm_mmu_load函数调用了kvm_mmu_load_pgdkvm_mmu_load函数如下:

int kvm_mmu_load(struct kvm_vcpu *vcpu){
    int r;
    ...
    if(vcpu->arch.mmu->direct_map)
        r = mmu_alloc_direct_roots(vcpu);

    ...
    kvm_mmu_load_pgd(vcpu);
}

采用了EPT的地址转换方案时,vcpu->arch.mmu.direct_map的值为True(可以参考本书的3.3.2节的init_kvm_tdp_mmu函数讲解),所以会进入mmu_alloc_direct_roots(vcpu)分支:

static int mmu_alloc_direct_roots(struct kvm_vcpu *vcpu)
{
	hpa_t root;
    if (is_tdp_mmu_get_vcpu_root_hpa(vcpu->kvm)){
        root = kvm_tdp_mmu_get_vcpu_root_hpa(vcpu);
        mmu->root_hpa = root;
    }
    ...
}

其调用了kvm_tdp_mmu_get_vcpu_root_hpa,该函数实现如下:

hpa_t kvm_tdp_mmu_get_vcpu_root_hpa(struct kvm_vcpu *vcpu)
{
	union kvm_mmu_page_role role;
	struct kvm *kvm = vcpu->kvm;
	struct kvm_mmu_page *root, *root_copy;

	lockdep_assert_held_write(&kvm->mmu_lock);

	role = page_role_for_level(vcpu, vcpu->arch.mmu->shadow_root_level);

	/* Check for an existing root before allocating a new one. */
	for_each_tdp_mmu_root(kvm, root, kvm_mmu_role_as_id(role)) {
		if (root->role.word == role.word &&
		    kvm_tdp_mmu_get_root(kvm, root))
			goto out;
	}

	root = alloc_tdp_mmu_page(vcpu, 0, vcpu->arch.mmu->shadow_root_level);
	refcount_set(&root->tdp_mmu_root_count, 1);

	spin_lock(&kvm->arch.tdp_mmu_pages_lock);
	list_add_rcu(&root->link, &kvm->arch.tdp_mmu_roots);
	spin_unlock(&kvm->arch.tdp_mmu_pages_lock);

out:
	return __pa(root->spt);
}

在没有找到已经存在的root的情况下会创建新的root,调用alloc_tdp_mmu_page,其具体实现在本书的3.3.4节进行过详细介绍。

所以我们可以在kvm_tdp_mmu_get_vcpu_root_hpa中构造EPT副本的根目录。

实验

  1. struct kvm_vcpu中添加一个成员变量bool rebuild_ept
  2. kvm_set_cr0中添加判断开启分页的语句,并将rebuild_ept设置为true
  3. kvm_tdp_mmu_get_vcpu_root_hpa中添加创建EPTP副本的代码,通过一个if语句控制,创建完成后将rebuild_ept设置为false

实验结果

dmesg

实验二:VMFUNC的创建与使用

创建多个EPT页表,并使用EPTP机制对虚拟机线程进行页表隔离与切换

背景知识

开启VMFUNC功能需要:

  1. VM-Function的第0位置1,其余位为0;
  2. “activate secondary controls”VM-execution控制位(bit 31)置位1;
  3. “enable VM functions”VM-execution控制位(bit 13)置位1;
  4. 指定EPTP list的地址;
  5. 向EPTP List中填入适当的EPTP。

解决思路

为了完成实验目标,主要需要以下步骤:

KVM端

  1. 开启VMFUNC支持;
  2. 为EPTP List分配内存,在VMCS中写入EPTP List的地址
  3. 创建EPTP页表副本,在EPTP List中写入地址
  4. 在EPT violation中进行设置,检测到Non-root模式调用VMFUNC后,置换root_hpa指针
  5. 在虚拟机退出时释放EPTP List所在页
  6. 在卸载MMU时释放创建的页表副本。

虚拟机内

  1. 启动新的线程,在进程创建和退出时都调用VMFUNC

实验

开启VMFUNC支持

  • KVM中activate secondary control已经默认开启
  • enable VM functions默认关闭
  • VM-Function控制位未开启

./arch/x86/kvm/vmx/vmx.c中,修改vmx_secondary_exec_control函数:

+++ exec_control |= SECONDARY_EXEC_ENABLE_VMFUNC

./arch/x86/kvm/vmx/vmx.c中,修改init_vmcs函数:

if (cpu_has_vmx_vmfunc()){
	printk("[init_vmcs]: secondary turn on\n");
	vmcs_write64(VM_FUNCTION_CONTROL, 1);//最低位置1
}

为EPTP List分配内存,在VMCS中写入EPTP List的地址

static int vmx_enable_ept_switching(struct vcpu_vmx *vmx)
{
	struct page *eptp_list_pg;

	eptp_list_pg = alloc_page(GFP_KERNEL | __GFP_ZERO);
	if (!eptp_list_pg){
		return -ENOMEM;
	}

	vmx->eptp_list_pg = eptp_list_pg;

	vmcs_write64(EPTP_LIST_ADDRESS, page_to_phys(vmx->eptp_list_pg));
	return 0;
}

在EPTP List中写入地址

vmx_load_mmu_pgd中:

	if (enable_ept) {
		eptp = construct_eptp(vcpu, root_hpa, root_level);

		if (to_vmx(vcpu)->eptp_list_pg){
			eptp_list = (u64 *)phys_to_virt(page_to_phys(to_vmx(vcpu)->eptp_list_pg));
			for (i = 0; i < EPTP_LIST_NUM; i++){
				eptp_list[i] = eptp;
			}
		}

创建页表副本

struct kvm_mmu *mmu中创建一个成员变量:

#define KVM_EPTP_NUMBER 1//EPTP的数量按需设置
struct kvm_mmu{
    ...
    hpa_t new_root_hpa[KVM_EPTP_NUMBERS];
    ...
}

然后为新的根分配内存,参考kvm_tdp_mmu_get_vcpu_root_hpa实现了new_kvm_tdp_mmu_get_vcpu_root_hpa

static int mmu_alloc_direct_roots(struct kvm_vcpu *vcpu){
    ...
    if (is_tdp_mmu_enabled(vcpu->kvm)) {
		root = kvm_tdp_mmu_get_vcpu_root_hpa(vcpu);
		mmu->root_hpa = root;
        for(i = 0; i < KVM_EPTP_NUMBERS; i++){
			mmu->new_root_hpa[i] = new_kvm_tdp_mmu_get_vcpu_root_hpa(vcpu);
		}
}
hpa_t new_kvm_tdp_mmu_get_vcpu_root_hpa(struct kvm_vcpu *vcpu){
    union kvm_mmu_page_role role;
    struct kvm *kvm = vcpu->kvm;
    struct kvm_mmu_page *root;

    lockdep_assert_held_write(&kvm->mmu_lock);

    role = alloc_tdp_mmu_kpage(vcpu, 0, vcpu->arch.mmu->shadow_root_level);
    refcount_set(&root->tdp_mmu_root_count, 1);

    spin_lock(&kvm->arch.tdp_mmu_pages_lock);
    list_add_rcu(&root->link, &kvm->arch.tdp_mmu_roots);
    spin_unlock(&kvm->arch.tdp_mmu_pages_lock);

    return __pa(root->spt);
}

相比kvm_tdp_mmu_get_vcpu_root_hpanew_kvm_tdp_mmu_get_vcpu_root_hpa删除了从备用链表中取出页表的链表,强制KVM分配新的物理页。

vmx_load_mmu_pgd中添加一个参数

static void vmx_load_mmu_pgd(struct kvm_vcpu *vcpu, hpa_t root_hpa, hpa_t *new_root_hpa, int root_level){
...
    if (enable_ept){
        eptp = construct_eptp(vcpu, root_hpa, root_level);
        vmcs_write64(EPT_POINTER, eptp);

        if (to_vmx(vcpu)->eptp_list_pg){
			eptp_list = (u64 *)phys_to_virt(page_to_phys(to_vmx(vcpu)->eptp_list_pg));
			eptp_list[0] = eptp;

			for(i = 0; i < KVM_EPTP_NUMBERS; i++){
				new_eptp = construct_eptp(vcpu, new_root_hpa[i], root_level);
				eptp_list[i + 1] = new_eptp;
				printk("new_eptp: 0x%016llx\n", new_eptp);
			}
		}
    }
...
}

进而需要修改kvm_mmu_load_pgd

static inline void kvm_mmu_load_pgd(struct kvm_vcpu *vcpu){
...
    new_root_hpa = vcpu->arch.mmu->new_root_hpa;

	for (i = 0; i < KVM_EPTP_NUMBERS; i++){
		if (!VALID_PAGE(new_root_hpa[i]))
			return;
	}

	static_call(kvm_x86_load_mmu_pgd)(vcpu, root_hpa, new_root_hpa,
 					  vcpu->arch.mmu->shadow_root_level);
}

注意,由于KVM支持多种体系结构,所以需要同时修改AMD SVM相关函数的参数。

虚拟机内调用VMFUNC

编写应用程序代码,在虚拟机中运行

#include <stdio.h>

#define VMX_FUNC ".byte 0x0f,0x01,0xd4" //硬编码VMFUNC的指令

static void vmfunc(unsigned int nr, unsigned int ept){
    asm volatile(
        VMX_FUNC
        :
        : "a"(nr), "c"(ept)
        : "memory");
    return;
}

启动一个线程:

void *thread_func(void * arg){
    vmfunc(0, 1);
    int i = 0;
    i++;
    printf("i = %d\n", i);
    vmfunc(0, 0);
}

处理EPT violation

切换VMFUNC后,执行指令会导致EPT Violation从而陷入到Hypervisor中。需要在Hypervisor中检查当前的EPTP指针,然后遍历new_root_hpa数组找到指针是否切换,如果指针进行了切换则需要修改页面的root_hpa,使得KVM能够利用EPT violation维护EPT页表。

	eptp = vmcs_read64(EPT_POINTER);
	for(i = 0; i < KVM_EPTP_NUMBERS; i++){
		if((eptp >> PAGE_SHIFT) == ((mmu->new_root_hpa[i]) >> PAGE_SHIFT)){
			printk("[ept_violation]: new_root_hpa[%d] = 0x%llx\n", i, mmu->new_root_hpa[i]);
			printk("[ept_violation]: root_hpa = 0x%llx\n", mmu->root_hpa);
			printk("[ept_violation]: Currenty eptp = 0x%llx\n", eptp);
			// exchange root_hpa and new_root_hpa[i]
			temp = mmu->root_hpa;
			mmu->root_hpa = mmu->new_root_hpa[i];
			mmu->new_root_hpa[i] = temp;
			break;
		}
	}

结果

能够成功输出:i = 1

查看内核debug信息:

dmesg
vmfunc_thread

此时ept_violation处理函数中输出此时的eptp指针为0x1c0f8c05e,正是新创建的eptp。

实验三:气球模型的充气与放气实现

QEMU中增加捕获和修改气球模型的映射页面的功能,并在“放气”时将这些页面重新映射到虚拟机操作系统内核中。

背景知识

使用QEMU的balloon功能

  1. 正常通过KVM/QEMU启动虚拟机,可以参考实验一第一章实验
  2. 通过VNC连接到QEMU
gvncviewer 127.0.0.1::5900
  1. 通过快捷键Ctrl+Alt+2切换到QEMU Monitor
  2. 查看balloon设备信息
(qemu) info balloon
  1. 修改虚拟机可用的内存
(qemu) balloon 2048
  • 注意数字单位是MB
  1. 通过快捷键Ctrl+Alt+2返回到虚拟机图形界面
  2. 在虚拟机中查看内存使用情况
free -m
  1. 重复3-7步骤,将虚拟机可用内存修改为4096MB,再次查看内存使用情况

调试QEMU

在进行QEMU代码修改的时候,可以通过调试的方法检查代码中出现的错误。

  1. 确保在编译qemu时开启了debug选项
./configure --enable-debug
  1. 利用qemu启动虚拟机
qemu-system-x86_64 -m 4096 -smp 4 --enable-kvm ubuntu.img -net user,host=10.0.2.10,hostfwd=tcp::10021-:22,hostfwd=tcp::10025-:80 -net nic -device virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x4
  1. 查看qemu进程的pid
ps -aux | grep qemu
  1. GDB调试QEMU
sudo gdb qemu-system-x86_64 [pid]
  1. GDB中下断点
b virtio_balloon_handle_output

气球模型吸气、放气流程

充气阶段

  1. 【宿主机:QEMU】用户通过QEMU monitor发起气球充气请求
  2. 【宿主机:QEMU】对请求进行处理,通过系统调用进入到KVM,要求KVM向虚拟机注入中断
  3. 【宿主机:KVM】向虚拟机注入中断
  4. 【虚拟机】收到中断,执行中断处理函数,调用气球驱动
  5. 【虚拟机】在气球驱动中申请内存,将申请到的内存的虚拟机物理地址和长度用链表进行维护,并且利用virtio协议填入到内存缓冲区中,注册回调函数
  6. 【虚拟机】写MMIO内存陷入到KVM中
  7. 【宿主机:KVM】KVM获取陷入原因,将控制权返回到QEMU中
  8. 【宿主机:QEMU】QEMU根据virtio协议获取虚拟机分配的内存的虚拟机物理地址,并将其转化为宿主机虚拟地址(QEMU进程地址空间)。然后以NONEED为参数进行madvise系统调用释放内存。
  9. 【宿主机:QEMU】通过系统调用进入到KVM,要求KVM向虚拟机注入中断
  10. 【宿主机:KVM】向虚拟机注入中断
  11. 【虚拟机】虚拟机回调函数执行
  12. 【虚拟机】虚拟机继续执行

放气阶段

  1. 【宿主机:QEMU】用户通过QEMU monitor发起气球充气请求
  2. 【宿主机:QEMU】对请求进行处理,通过系统调用进入到KVM,要求KVM向虚拟机注入中断
  3. 【宿主机:KVM】向虚拟机注入中断
  4. 【虚拟机】收到中断,执行中断处理函数,调用气球驱动
  5. 【虚拟机】在气球驱动中将维护的内存取出,并且利用virtio协议填入到内存缓冲区中,注册回调函数
  6. 【虚拟机】写MMIO内存陷入到KVM中
  7. 【宿主机:KVM】KVM获取陷入原因,将控制权返回到QEMU中
  8. 【宿主机:QEMU】QEMU根据virtio协议获取虚拟机分配的内存的虚拟机物理地址,并将其转化为宿主机虚拟地址(QEMU进程地址空间)。然后以WILLNEED为参数进行madvise系统调用。
  9. 【宿主机:QEMU】通过系统调用进入到KVM,要求KVM向虚拟机注入中断
  10. 【宿主机:KVM】向虚拟机注入中断
  11. 【虚拟机】虚拟机回调函数执行
  12. 【虚拟机】虚拟机继续执行

搭建环境

  1. 下载Linux4.14.173源码

  2. 编译虚拟机内核

为什么需要重新编译虚拟机内核?

下载的虚拟机镜像的balloon和内核完整编译到一起,对于balloon的修改不能以模块的方式的进行;重新编译内核后,不需要使用完整的虚拟机镜像即可实现大部分功能。具体编译内核可以参考wiki:编译内核与调试 - Wiki - Gitee.com

  1. 重新编译QEMU

修改qemu的configure脚本,将madvise配置修改为禁用:

if compile_prog "" "" ; then
	madvise=yes
fi
madvise=no
  1. 使用新内核以及新编译好的QEMU启动虚拟机
sudo qemu-system-x86_64 \
-s
-m 4096
-smp 4
-enable-kvm
-kernel $KERNEL/bzImage #指定内核镜像
-append "root=/dev/sda kgdboc=ttyS0,115200 net.ifnames=0 kvm_intel" #指定启动参数,开启了
-drive file=stretch.img,format=raw #指定文件系统
-serial tcp::4321,server,nowait #指定串口
-net user,host=10.0.2.10,hostfwd=tcp::10021-:22,hostfwd=tcp::10025-:80 #指定端口映射,将22(ssh)映射到宿主机的10021,将80(http)映射到宿主机的10025
-net nic,model=e1000
-device virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x4

解决思路

  1. 在虚拟机申请内存后(上文充气阶段步骤5),向内存写入数据“before inflate”;
  2. 在QEMU中找到处理气球充气时的处理函数(上文充气阶段步骤8),输出虚拟机写入的数据,并向内存写入新的数据;
  3. 在QEMU中取消气球充气时,内存的释放(注:在上文环境搭建步骤3中,已经将QEMU的madvise配置选项修改为了关闭,所以不需要再对应的修改代码);
  4. 在虚拟机内核中找到处理气球放气时的处理函数(上文放气阶段步骤11),输出内存中的信息。

实验

虚拟机内写入数据

balloon_page_alloc函数中:

struct page *balloon_page_alloc(void){
    struct page *page = alloc_page(balloon_mapping_gfp_mask() | __GFP_NOMEMALLOC | __GFP_NORETRY);
    memcpy(page_address(page), "before inflate\0", 15);
    return page
}

QEMU中拦截页面信息

ram_block_discard_range中,输出虚拟机写入的数据,并且修改内存上的数据。

if(need_madvise){
    printf("data = %s\n", host_startaddr);
	memset(host_startaddr, 0, length);
	memcpy(host_startaddr, "after deflate", 13);
if defined(CONFIG_MADVISE)
    ret = madvise(host_startaddr, length, MADV_DONTNEED);//这段代码将不会被执行到
...
}

有关于madvise系统调用,可以参考wiki:https://gitee.com/silver-zhou/virtualization_book/wikis/madvise%E7%B3%BB%E7%BB%9F%E8%B0%83%E7%94%A8?sort_id=7986141

balloon inflate page

修改虚拟机内核,输出页面重映射数据

release_pages_balloon中,将页面上的数据输出:

char * page_addr;
int i;
char data[30];

list_for_each_entry_safe(page, next, pages, lru) {
    page_addr = (char *)page_address(page); //通过内核函数page_address将page指针转换为虚拟地址

    for(i = 0; i < 4096; i++){
        data[i] = page_addr[i];
        if(!data[i]) printk("data = %c\n", data);
    }
    data[i] = '\0';
    printk("data = %s\n", data);
    ...
}
balloon deflate guest