这篇文章给大家介绍如何解析 KVM 虚拟化原理中的内存虚拟化,内容非常详细,感兴趣的小伙伴们可以参考借鉴,希望对大家能有所帮助。
内存虚拟化简介
下面介绍一下 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 的直接关系,保存到影子页表中。
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 的技术的进化,效率也越来越高。
关于如何解析 KVM 虚拟化原理中的内存虚拟化就分享到这里了,希望以上内容可以对大家有一定的帮助,可以学到更多知识。如果觉得文章不错,可以把它分享出去让更多的人看到。