第三章实验:内存虚拟化
实验一: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
)写入到 VMCS 的 EPT_POINTER 字段。
construct_eptp
函数中仅仅是根据Intel手册中对于VMCS的EPT_POINTER字段的规定进行数据的“组装”,关键点在其参数root_hpa
。root_hpa
是kvm_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_pgd
,kvm_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副本的根目录。
实验
- 在
struct kvm_vcpu
中添加一个成员变量bool rebuild_ept
; - 在
kvm_set_cr0
中添加判断开启分页的语句,并将rebuild_ept
设置为true
; - 在
kvm_tdp_mmu_get_vcpu_root_hpa
中添加创建EPTP副本的代码,通过一个if语句控制,创建完成后将rebuild_ept
设置为false
。
实验结果
dmesg

实验二:VMFUNC的创建与使用
创建多个EPT页表,并使用EPTP机制对虚拟机线程进行页表隔离与切换
背景知识
开启VMFUNC功能需要:
- VM-Function的第0位置1,其余位为0;
- “activate secondary controls”VM-execution控制位(bit 31)置位1;
- “enable VM functions”VM-execution控制位(bit 13)置位1;
- 指定EPTP list的地址;
- 向EPTP List中填入适当的EPTP。
解决思路
为了完成实验目标,主要需要以下步骤:
KVM端:
- 开启VMFUNC支持;
- 为EPTP List分配内存,在VMCS中写入EPTP List的地址
- 创建EPTP页表副本,在EPTP List中写入地址
- 在EPT violation中进行设置,检测到Non-root模式调用VMFUNC后,置换root_hpa指针
- 在虚拟机退出时释放EPTP List所在页
- 在卸载MMU时释放创建的页表副本。
虚拟机内:
- 启动新的线程,在进程创建和退出时都调用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_hpa
,new_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

此时ept_violation处理函数中输出此时的eptp指针为0x1c0f8c05e,正是新创建的eptp。
实验三:气球模型的充气与放气实现
QEMU中增加捕获和修改气球模型的映射页面的功能,并在“放气”时将这些页面重新映射到虚拟机操作系统内核中。
背景知识
使用QEMU的balloon功能
- 正常通过KVM/QEMU启动虚拟机,可以参考实验一第一章实验
- 通过VNC连接到QEMU
gvncviewer 127.0.0.1::5900
- 通过快捷键
Ctrl+Alt+2
切换到QEMU Monitor - 查看balloon设备信息
(qemu) info balloon
- 修改虚拟机可用的内存
(qemu) balloon 2048
- 注意数字单位是MB
- 通过快捷键
Ctrl+Alt+2
返回到虚拟机图形界面 - 在虚拟机中查看内存使用情况
free -m
- 重复3-7步骤,将虚拟机可用内存修改为4096MB,再次查看内存使用情况
调试QEMU
在进行QEMU代码修改的时候,可以通过调试的方法检查代码中出现的错误。
- 确保在编译qemu时开启了debug选项
./configure --enable-debug
- 利用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
- 查看qemu进程的pid
ps -aux | grep qemu
- GDB调试QEMU
sudo gdb qemu-system-x86_64 [pid]
- GDB中下断点
b virtio_balloon_handle_output
气球模型吸气、放气流程
充气阶段
- 【宿主机:QEMU】用户通过QEMU monitor发起气球充气请求
- 【宿主机:QEMU】对请求进行处理,通过系统调用进入到KVM,要求KVM向虚拟机注入中断
- 【宿主机:KVM】向虚拟机注入中断
- 【虚拟机】收到中断,执行中断处理函数,调用气球驱动
- 【虚拟机】在气球驱动中申请内存,将申请到的内存的虚拟机物理地址和长度用链表进行维护,并且利用virtio协议填入到内存缓冲区中,注册回调函数
- 【虚拟机】写MMIO内存陷入到KVM中
- 【宿主机:KVM】KVM获取陷入原因,将控制权返回到QEMU中
- 【宿主机:QEMU】QEMU根据virtio协议获取虚拟机分配的内存的虚拟机物理地址,并将其转化为宿主机虚拟地址(QEMU进程地址空间)。然后以NONEED为参数进行madvise系统调用释放内存。
- 【宿主机:QEMU】通过系统调用进入到KVM,要求KVM向虚拟机注入中断
- 【宿主机:KVM】向虚拟机注入中断
- 【虚拟机】虚拟机回调函数执行
- 【虚拟机】虚拟机继续执行
放气阶段
- 【宿主机:QEMU】用户通过QEMU monitor发起气球充气请求
- 【宿主机:QEMU】对请求进行处理,通过系统调用进入到KVM,要求KVM向虚拟机注入中断
- 【宿主机:KVM】向虚拟机注入中断
- 【虚拟机】收到中断,执行中断处理函数,调用气球驱动
- 【虚拟机】在气球驱动中将维护的内存取出,并且利用virtio协议填入到内存缓冲区中,注册回调函数
- 【虚拟机】写MMIO内存陷入到KVM中
- 【宿主机:KVM】KVM获取陷入原因,将控制权返回到QEMU中
- 【宿主机:QEMU】QEMU根据virtio协议获取虚拟机分配的内存的虚拟机物理地址,并将其转化为宿主机虚拟地址(QEMU进程地址空间)。然后以WILLNEED为参数进行madvise系统调用。
- 【宿主机:QEMU】通过系统调用进入到KVM,要求KVM向虚拟机注入中断
- 【宿主机:KVM】向虚拟机注入中断
- 【虚拟机】虚拟机回调函数执行
- 【虚拟机】虚拟机继续执行
搭建环境
-
下载Linux4.14.173源码
-
编译虚拟机内核
为什么需要重新编译虚拟机内核?
下载的虚拟机镜像的balloon和内核完整编译到一起,对于balloon的修改不能以模块的方式的进行;重新编译内核后,不需要使用完整的虚拟机镜像即可实现大部分功能。具体编译内核可以参考wiki:编译内核与调试 - Wiki - Gitee.com
- 重新编译QEMU
修改qemu的configure
脚本,将madvise配置修改为禁用:
if compile_prog "" "" ; then
madvise=yes
fi
madvise=no
- 使用新内核以及新编译好的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
解决思路
- 在虚拟机申请内存后(上文充气阶段步骤5),向内存写入数据“before inflate”;
- 在QEMU中找到处理气球充气时的处理函数(上文充气阶段步骤8),输出虚拟机写入的数据,并向内存写入新的数据;
- 在QEMU中取消气球充气时,内存的释放(注:在上文环境搭建步骤3中,已经将QEMU的madvise配置选项修改为了关闭,所以不需要再对应的修改代码);
- 在虚拟机内核中找到处理气球放气时的处理函数(上文放气阶段步骤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

修改虚拟机内核,输出页面重映射数据
在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);
...
}
