一、保护模式Guest的寻址

原理

​ 为每个guest进程分别制作一张表,记录着GVA到HPA的映射关系。guest模式下的cr3寄存器不再指向内部GVA到GPA映射的表,而是指向这张新的表。当MMU收到GVA时,通过遍历这张新的表,将GVA翻译成HPA。

​ 因为guest自身页表不能完成GVA到HPA的多层地址映射,因此每当guest设置cr3寄存器时,KVM都需要截获这个操作,将cr3替换为影子页表,因此每次设置cr3时都需要触发虚拟机退出,陷入KVM模块。(无疑会造成很大的资源消耗,有EPT之后就不用了)

​ 两个关键点:

  1. KVM需要构建GVA映射到HPA的页表,这个页表需要根据guest内部页表的信息更新,实际地址映射时生效的是这张页表,会将guest内部的页表给隐藏起来,所以它叫影子页表
  2. 保护模式的guest有自己的页表,而且不只有一个页表,每个任务都会有自己的页表,随着任务的切换而更换页表,所以,KVM也需要准备多个影子页表,每个guest任务对应一个。 并且在guest内部任务切换时,kvm需要捕获这个切换,切换对应的影子页表。

​ 建立映射时,需要经过三次转换:

  • 第一次是guest使用自身的页表完成GVA到GPA的转换
  • 第二次是KVM根据内存条信息完成GPA到HVA的转换
  • 第三次是host利用内核的内存管理机制完成HVA到HPA的转换

​ 影子页表构建好后,在映射建立完成后,GVA到HPA经过一次映射即可。

image-20230917150408144

影子页表的建立 / 缺页异常处理

​ 保护模式guest发生缺页异常时,控制cr2寄存器中存储的是GVA,而只有guest知道GVA到GPA的映射,所以,缺页异常处理函数首先需要遍历guest的页表,取出对应的GPA。

​ 如果没有建立GVA到GPA的映射,则KVM向guest注入缺页异常,guest进行正常的缺页异常处理,完成GVA到GPA的映射。建立好GVA到GPA的映射后,然后再继续建立GVA到HPA的映射。

​ 刚开始时,影子页表是空的,所以开始时任何内存访问操作都会引起缺页异常,导致vm exit 进入handle_exception_nmi (不可屏蔽中断异常处理)

​ handle_exception_nmi从这里进入异常处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static int handle_exception_nmi(struct kvm_vcpu *vcpu)
{
struct vcpu_vmx *vmx = to_vmx(vcpu);
struct kvm_run *kvm_run = vcpu->run;
......

if (is_page_fault(intr_info)) {
cr2 = vmx_get_exit_qual(vcpu);
if (enable_ept && !vcpu->arch.apf.host_apf_flags) {
/*
* EPT will cause page fault only if we need to
* detect illegal GPAs.
*/
WARN_ON_ONCE(!allow_smaller_maxphyaddr);
kvm_fixup_and_inject_pf_error(vcpu, cr2, error_code);
return 1;
} else
return kvm_handle_page_fault(vcpu, error_code, cr2, NULL, 0);
}

​ 调用链如下

1
2
3
4
- kvm_handle_page_fault
- kvm_mmu_page_fault
- kvm_mmu_do_page_fault
-

​ 到了这里之后, 如果没开启ept,会进入else,猜测这里的page_fault是FNAME(page_fault),这样就连起来了

1
2
3
4
5
6
7
8
static inline int kvm_mmu_do_page_fault(struct kvm_vcpu *vcpu, gpa_t cr2_or_gpa,
u32 err, bool prefetch, int *emulation_type)
{
.....
if (IS_ENABLED(CONFIG_RETPOLINE) && fault.is_tdp)
r = kvm_tdp_page_fault(vcpu, &fault);
else
r = vcpu->arch.mmu->page_fault(vcpu, &fault);

GVA-GPA 映射

kvm\mmu\paging_tmpl.h

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
static int FNAME(page_fault)(struct kvm_vcpu *vcpu, struct kvm_page_fault *fault)
{
struct guest_walker walker;
int r;

pgprintk("%s: addr %lx err %x\n", __func__, fault->addr, fault->error_code);
WARN_ON_ONCE(fault->is_tdp);

/*
* Look up the guest pte for the faulting address.
* If PFEC.RSVD is set, this is a shadow page fault.
* The bit needs to be cleared before walking guest page tables.
*/
r = FNAME(walk_addr)(&walker, vcpu, fault->addr,
fault->error_code & ~PFERR_RSVD_MASK);

/*
* The page is not mapped by the guest. Let the guest handle it.
*/
if (!r) {
pgprintk("%s: guest page fault\n", __func__);
if (!fault->prefetch)
kvm_inject_emulated_page_fault(vcpu, &walker.fault);

return RET_PF_RETRY;
}

fault->gfn = walker.gfn;
fault->max_level = walker.level;
fault->slot = kvm_vcpu_gfn_to_memslot(vcpu, fault->gfn);//获取内存条信息

if (page_fault_handle_page_track(vcpu, fault)) {
shadow_page_table_clear_flood(vcpu, fault->addr);
return RET_PF_EMULATE;
}

r = mmu_topup_memory_caches(vcpu, true);
if (r)
return r;

r = kvm_faultin_pfn(vcpu, fault, walker.pte_access);//关键函数
if (r != RET_PF_CONTINUE)
return r;
....
}

​ 在硬件MMU中,Table walk单元负责遍历页表,这里函数walk_addr就相当于硬件MMu中Table walk,负责遍历guest页表。 异常处理函数首先调用这个函数遍历guess页表,尝试取出GPA,见14行代码,遍历完后,把具体信息保存在walker中。

​ 感觉写的注释非常精髓

1
2
3
4
5
6
/*
* Fetch a guest pte for a guest virtual address, or for an L2's GPA.
*/
static int FNAME(walk_addr_generic)(struct guest_walker *walker,
struct kvm_vcpu *vcpu, struct kvm_mmu *mmu,
gpa_t addr, u64 access)

​ 如果返回0,说明没有建立映射,进行guest的缺页处理,注入异常,guest去建立GVA到GPA的映射。见20行。

​ 进入到kvm_inject_emulated_page_fault

x86.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void kvm_inject_emulated_page_fault(struct kvm_vcpu *vcpu,
struct x86_exception *fault)
{
struct kvm_mmu *fault_mmu;
WARN_ON_ONCE(fault->vector != PF_VECTOR);

fault_mmu = fault->nested_page_fault ? vcpu->arch.mmu :
vcpu->arch.walk_mmu;

/*
* Invalidate the TLB entry for the faulting address, if it exists,
* else the access will fault indefinitely (and to emulate hardware).
*/
if ((fault->error_code & PFERR_PRESENT_MASK) &&
!(fault->error_code & PFERR_RSVD_MASK))
kvm_mmu_invalidate_addr(vcpu, fault_mmu, fault->address,
KVM_MMU_ROOT_CURRENT);

fault_mmu->inject_page_fault(vcpu, fault);
}
EXPORT_SYMBOL_GPL(kvm_inject_emulated_page_fault);

​ 会调用 inject_page_fault, 对应的函数如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void kvm_inject_page_fault(struct kvm_vcpu *vcpu, struct x86_exception *fault)
{
++vcpu->stat.pf_guest;

/*
* Async #PF in L2 is always forwarded to L1 as a VM-Exit regardless of
* whether or not L1 wants to intercept "regular" #PF.
*/
if (is_guest_mode(vcpu) && fault->async_page_fault)
kvm_queue_exception_vmexit(vcpu, PF_VECTOR,
true, fault->error_code,
true, fault->address);
else
kvm_queue_exception_e_p(vcpu, PF_VECTOR, fault->error_code,
fault->address);
}

GPA-HPA 映射

​ 建立映射后往后走,来到下一个重要函数

kvm_faultin_pfn这个函数用来处理虚拟地址映射到宿主机的物理地址

mmu.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static int kvm_faultin_pfn(struct kvm_vcpu *vcpu, struct kvm_page_fault *fault,
unsigned int access)
{
int ret;

fault->mmu_seq = vcpu->kvm->mmu_invalidate_seq;
smp_rmb();

ret = __kvm_faultin_pfn(vcpu, fault);
if (ret != RET_PF_CONTINUE)
return ret;

if (unlikely(is_error_pfn(fault->pfn)))
return kvm_handle_error_pfn(vcpu, fault);

if (unlikely(!fault->slot))
return kvm_handle_noslot_fault(vcpu, fault, access);

return RET_PF_CONTINUE;
}

​ 这其中主要调用__kvm_faultin_pfn完成实际映射工作

1
2
3
4
5
6
7
8
9
10
11
12
static int __kvm_faultin_pfn(struct kvm_vcpu *vcpu, struct kvm_page_fault *fault)
{
struct kvm_memory_slot *slot = fault->slot;
bool async;

.....
async = false;
fault->pfn = __gfn_to_pfn_memslot(slot, fault->gfn, false, false, &async,
fault->write, &fault->map_writable,
&fault->hva);
......
}

​ 而这里,最关键的是__gfn_to_pfn_memslot,通过这个函数拿到pfn

影子页表填充

​ 映射和填充是两回事?

for_each_shadow_entry用来迭代不同页表级别

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
27
28
29
30
31
32
33
34
35
36
static int FNAME(fetch)(struct kvm_vcpu *vcpu, struct kvm_page_fault *fault,
struct guest_walker *gw)
{
.....
for_each_shadow_entry(vcpu, fault->addr, it) { //应该是用来查找对应级别的页表目录
gfn_t table_gfn;

clear_sp_write_flooding_count(it.sptep);
if (it.level == gw->level)
break;

table_gfn = gw->table_gfn[it.level - 2]; //获取当前页表级别的表项对应的页表页的GFN
access = gw->pt_access[it.level - 2];
sp = kvm_mmu_get_child_sp(vcpu, it.sptep, table_gfn,
false, access); //尝试获取或创建当前页表级别的影子页表页,false表示不创建新的,仅尝试获取已存在的
//it.sptep 表示当前的SPTE
if (sp != ERR_PTR(-EEXIST)) { // 成功获取或创建
.............
/*
* Verify that the gpte in the page we've just write
* protected is still there.
*/
if (FNAME(gpte_changed)(vcpu, gw, it.level - 1))
goto out_gpte_changed;

if (sp != ERR_PTR(-EEXIST))
link_shadow_page(vcpu, it.sptep, sp);//将影子页表与SPTE进行链接

if (fault->write && table_gfn == fault->gfn)
fault->write_fault_to_shadow_pgtable = true;

.....
ret = mmu_set_spte(vcpu, fault->slot, it.sptep, gw->pte_access,
base_gfn, fault->pfn, fault); //设置/更新PTE
.....
}

​ kvm_mmu_get_child_sp

1
2
3
4
5
6
7
8
9
10
11
12
13
static struct kvm_mmu_page *kvm_mmu_get_child_sp(struct kvm_vcpu *vcpu,
u64 *sptep, gfn_t gfn,
bool direct, unsigned int access)
{
union kvm_mmu_page_role role;

if (is_shadow_present_pte(*sptep) && !is_large_pte(*sptep))
return ERR_PTR(-EEXIST); // 检查是否存在

role = kvm_mmu_child_role(sptep, direct, access); //新的页表的属性
return kvm_mmu_get_shadow_page(vcpu, gfn, role);//查找/创建新的影子页表页
}

​ kvm_mmu_get_shadow_page来创建/查找

1
2
3
4
5
6
7
8
9
10
11
12
static struct kvm_mmu_page *kvm_mmu_get_shadow_page(struct kvm_vcpu *vcpu,
gfn_t gfn,
union kvm_mmu_page_role role)
{
struct shadow_page_caches caches = {
.page_header_cache = &vcpu->arch.mmu_page_header_cache,
.shadow_page_cache = &vcpu->arch.mmu_shadow_page_cache,
.shadowed_info_cache = &vcpu->arch.mmu_shadowed_info_cache,
};

return __kvm_mmu_get_shadow_page(vcpu->kvm, vcpu, &caches, gfn, role);//gfn表示需要映射的guest页的地址
}

​ __kvm_mmu_get_shadow_page 最终的查找/创建影子页表页

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* Note, @vcpu may be NULL if @role.direct is true; see kvm_mmu_find_shadow_page. */
static struct kvm_mmu_page *__kvm_mmu_get_shadow_page(struct kvm *kvm,
struct kvm_vcpu *vcpu,
struct shadow_page_caches *caches,
gfn_t gfn,
union kvm_mmu_page_role role)
{
struct hlist_head *sp_list;
struct kvm_mmu_page *sp;
bool created = false;

sp_list = &kvm->arch.mmu_page_hash[kvm_page_table_hashfn(gfn)];

sp = kvm_mmu_find_shadow_page(kvm, vcpu, gfn, sp_list, role);//从哈希表中查找指定gfn和role的影子页表页
if (!sp) { // 没有找到,进行创建
created = true;
sp = kvm_mmu_alloc_shadow_page(kvm, caches, gfn, sp_list, role);
}

trace_kvm_mmu_get_page(sp, created);
return sp;
}

​ 遍历到最后一级页表,相应的表项不存在,就需要位GPA申请物理页面、填充页表项了

set_pte寻找空闲的物理页,填充页表项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int mmu_set_spte(struct kvm_vcpu *vcpu, struct kvm_memory_slot *slot,
u64 *sptep, unsigned int pte_access, gfn_t gfn,
kvm_pfn_t pfn, struct kvm_page_fault *fault)
{
struct kvm_mmu_page *sp = sptep_to_sp(sptep);
int level = sp->role.level;
........


if (is_shadow_present_pte(*sptep))

......

wrprot = make_spte(vcpu, sp, slot, pte_access, gfn, pfn, *sptep, prefetch,
true, host_writable, &spte);

...
}

​ make_spte 生成新的页表项

建立好之后的寻址

​ 每次访问cr3都会触发异常

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
static int handle_cr(struct kvm_vcpu *vcpu)
{
unsigned long exit_qualification, val;
int cr;
int reg;
int err;
int ret;

exit_qualification = vmx_get_exit_qual(vcpu); //读取字段
cr = exit_qualification & 15; //提取字段的 0-3位,判断访问的那个控制寄存器
reg = (exit_qualification >> 8) & 15;//提取8-11位,判断guest试图加载到cr3的页表地址存储在哪个寄存器
switch ((exit_qualification >> 4) & 3) {//提取4、5位 判断时写还是读控制寄存器 0表示写
case 0: /* mov to cr */
val = kvm_register_read(vcpu, reg);
trace_kvm_cr_write(cr, val);
switch (cr) {
case 0:
err = handle_set_cr0(vcpu, val);
return kvm_complete_insn_gp(vcpu, err);
case 3:
WARN_ON_ONCE(enable_unrestricted_guest);

err = kvm_set_cr3(vcpu, val);
return kvm_complete_insn_gp(vcpu, err);
..........

​ 不知道kvm_register_read读取的cr3是否存储着影子页表的地址,是的话逻辑就比较简单了,设置cr3为影子页表的地址,然后就拿到了地址映射,进行后续内存操作??

1
2
3
4
int kvm_set_cr3(struct kvm_vcpu *vcpu, unsigned long cr3)
{
.....
vcpu->arch.cr3 = cr3;

二、EPT

​ 简而言之:MMU完成GVA到GPA的映射(kvm不捕获异常了,guest自己处理),EPT完成GPA到HPA的映射. 引入了EPT violation异常处理EPT的缺页

​ 具体而言:当Guest内部发生缺页异常时,CPU不再切换到Host模式了,而是由Guest自身的缺页异常处理函数处理。当地址从GVA翻译到GPA后,GPA在硬件内部从MMU流转到了EPT。如果EPT页表中存在GPA到HPA的映射,则EPA最终获取了GPA对应的HPA,将HPA送上地址总线。如果EPT中尚未建立GPA到HPA的映射,则CPU抛出EPT异常,CPU从Guest模式切换到Host模式,KVM中的EPT异常处理函数负责寻找空闲物理页面,建立EPT表中GPA到HPA的映射。

​ VMX在VMCS中定义了一个字段 Extended-Page-Table Pointer,KVM可以将EPT页表的位置写入这个字段,这样当CPU进入Guest模式时,就可以从这个字段读取EPT页表的位置。

EPT页表的设置

vmx.c

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
27
28
29
static void vmx_load_mmu_pgd(struct kvm_vcpu *vcpu, hpa_t root_hpa,
int root_level)
{
struct kvm *kvm = vcpu->kvm;
bool update_guest_cr3 = true;
unsigned long guest_cr3;
u64 eptp;

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

hv_track_root_tdp(vcpu, root_hpa);

if (!enable_unrestricted_guest && !is_paging(vcpu))
guest_cr3 = to_kvm_vmx(kvm)->ept_identity_map_addr;
else if (kvm_register_is_dirty(vcpu, VCPU_EXREG_CR3))
guest_cr3 = vcpu->arch.cr3;
else /* vmcs.GUEST_CR3 is already up-to-date. */
update_guest_cr3 = false;
vmx_ept_load_pdptrs(vcpu);
} else {
guest_cr3 = root_hpa | kvm_get_active_pcid(vcpu);
}

if (update_guest_cr3)
vmcs_writel(GUEST_CR3, guest_cr3);
}

​ 通过construct_eptp构建ept,然后vmcs_write64写入vmcs,这里是将root_hpa作为了EPT的根页面,第18行,设置变量guest_cr3指向guest自己的页表,最后27行,把cr3的值写入VMCS字段,这样切入guest后,guest模式下的CPU的cr3寄存器就指向了自己的页表。

EPT页表的构建 / 缺页异常处理 (GPA-HPA)

​ CPU需要查询EPT表来进行GPA-HPA的转换,初始情况下,guest cr3指向的地址的页表项都是空的,CPU触发EPT violation ,虚拟机产生退出,并且退出原因为EXIT_REASON_EPT_VIOLATION,会调用handle_ept_violation处理函数

image-20230917114957653

​ vmx.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static int handle_ept_violation(struct kvm_vcpu *vcpu)
{
unsigned long exit_qualification;
gpa_t gpa;
u64 error_code;

exit_qualification = vmx_get_exit_qual(vcpu);

if (!(to_vmx(vcpu)->idt_vectoring_info & VECTORING_INFO_VALID_MASK) &&
enable_vnmi &&
(exit_qualification & INTR_INFO_UNBLOCK_NMI))
vmcs_set_bits(GUEST_INTERRUPTIBILITY_INFO, GUEST_INTR_STATE_NMI);

gpa = vmcs_read64(GUEST_PHYSICAL_ADDRESS);// vm exit前会将引发异常的GPA保存到VMCS的该字段,这里进行读取
.......
return kvm_mmu_page_fault(vcpu, gpa, error_code, NULL, 0);
}

​ 经过一系列检查和设置后进入kvm_mmu_page_fault处理函数,这里就检查了mmio这种错误,如果不是的话,会进入下一个处理函数kvm_mmu_do_page_fault

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int noinline kvm_mmu_page_fault(struct kvm_vcpu *vcpu, gpa_t cr2_or_gpa, u64 error_code,
void *insn, int insn_len)
{
....

r = RET_PF_INVALID;
if (unlikely(error_code & PFERR_RSVD_MASK)) {
r = handle_mmio_page_fault(vcpu, cr2_or_gpa, direct);
if (r == RET_PF_EMULATE)
goto emulate;
}

if (r == RET_PF_INVALID) {
r = kvm_mmu_do_page_fault(vcpu, cr2_or_gpa,
lower_32_bits(error_code), false,
&emulation_type);

.....
}
EXPORT_SYMBOL_GPL(kvm_mmu_page_fault);

​ kvm_mmu_do_page_fault

mmu_internal.h 这里会进入kvm_tdp_page_fault(上面的影子页表就进入else了)

1
2
3
4
5
6
7
8
9
10
11
12
13
static inline int kvm_mmu_do_page_fault(struct kvm_vcpu *vcpu, gpa_t cr2_or_gpa,
u32 err, bool prefetch, int *emulation_type)
{

......

if (IS_ENABLED(CONFIG_RETPOLINE) && fault.is_tdp)
r = kvm_tdp_page_fault(vcpu, &fault);
else
r = vcpu->arch.mmu->page_fault(vcpu, &fault);

.....
}

​ kvm_tdp_page_fault, 目前还不是很了解tdp,但走这两条路都能到最后的地址映射

1
2
3
4
5
6
7
8
9
10
int kvm_tdp_page_fault(struct kvm_vcpu *vcpu, struct kvm_page_fault *fault)
{
.....
#ifdef CONFIG_X86_64
if (tdp_mmu_enabled)
return kvm_tdp_mmu_page_fault(vcpu, fault);
#endif

return direct_page_fault(vcpu, fault);
}

​ kvm_tdp_mmu_page_fault中的kvm_faultin_pfn是GPA到HPA的重要函数

1
2
3
4
5
6
7
static int kvm_tdp_mmu_page_fault(struct kvm_vcpu *vcpu,
struct kvm_page_fault *fault)
{
..
r = kvm_faultin_pfn(vcpu, fault, ACC_ALL);
...
}

​ direct_page_fault的最后会走到direct_map函数,direct_map是EPT页表构建过程中的关键函数, 建立映射,感觉一些主体逻辑和 FNAME(fetch)有点像

三、TLB缓存

​ EPT:CPU使用TLB(Translation Lookaside Buffer)缓存线性虚拟地址到物理地址的映射,地址转换时CPU先根据GPA先查找TLB,如果未找到映射的HPA,将根据页表中的映射填充TLB,再进行地址转换。

​ 影子页表方案:不同Guest的vCPU切换执行时需要刷新TLB,严重影响了内存访问效率。因此,Intel引入了VPID(Virtual-Processor Identifier)技术在硬件上为TLB增加一个标志,每个TLB表项与一个VPID关联,唯一对应一个vCPU,当vCPU切换时可根据VPID找到并保留已有的TLB表项,减少TLB刷新。

VPID

​ VPID是一种硬件级的对TLB资源管理的优化。通过在硬件上为每个TLB项增加一个标志,来标识不同的虚拟处理器地址空间,从而区分开VMM以及不同虚拟机的不同处理器的TLB。避免了每次切换都使得TLB全部失效。

​ VT-x 通过在VMCS中增加两个域来支持VPID,一个是VMCS中的enable VPID域,该域决定是否开启VPID功能。第二个是VPID域,用于标识VMCS对应的TLB。VMM本身也需要一个VPID,VT-x规定虚拟处理器标志0被指定用于VMM自身。

capabilities.h中有一个关于内存两大特性的结构体

1
2
3
4
struct vmx_capability {
u32 ept;
u32 vpid;
};

​ 会在hardware_setup函数中设置相关特性是否开启

1
2
3
4
5
static __init int hardware_setup(void)
{
if (!cpu_has_vmx_vpid() || !cpu_has_vmx_invvpid() ||
!(cpu_has_vmx_invvpid_single() || cpu_has_vmx_invvpid_global()))
enable_vpid = 0;

​ 剩下的事好像就没软件什么事了,归硬件去操作

四、参考

https://www.cnblogs.com/LoyenWang/p/13943005.html 不过这个是arm的

https://mp.weixin.qq.com/s/fLSSbtPjx29Gg-IJfgnbZw

https://zhuanlan.zhihu.com/p/108425561 TLB原理

http://www.xiongfuli.com/%E8%99%9A%E6%8B%9F%E5%8C%96/2013-06/KVM-Implementation.html

https://luohao-brian.gitbooks.io/interrupt-virtualization/content/kvmzhi-nei-cun-xu-ni531628-kvm-mmu-virtualization.html

https://cloud.tencent.com/developer/article/1975756

https://royhunter.github.io/2014/06/18/KVM-EPT/