1 概述
内存是计算机系统中的重要部件。在任何广泛使用的计算机体系结构中,CPU若没有内存提供指令、保存执行结果,则无法运行,CPU从设备读取的数据也将无法保存。由DRAM(Dynamic Random Access Memory,动态随机访问存储器)芯片组成的内存也是存储器层次结构中重要的一环,其速度虽然慢于SRAM(Static Random Access Memory,静态随机访问存储器),但因为价格更低廉,可以获得更大的容量。
在当前的生产环境中,内存容量已经达到了TB级别。随着大数据时代的到来,大量数据需要保存在内存中进行处理,才能够获得更低的延迟,从而缩短用户的等待时间,提升用户体验。云时代的到来为计算机内存管理提出了新的挑战:**需要为客户机提供从0开始的、连续的、相互隔离的虚拟“物理内存”,并且使内存访问延迟低,接近宿主机环境下的内存访问延迟。**这就是本文主要介绍的内容,即如何高效地实现内存虚拟化。
广义的内存虚拟化不仅包括硬件层面的内存虚拟化,还包含更多意义上的内存虚拟化。内存虚拟化即给访存指令提供一个内存空间,或称为地址空间。地址空间必须是从0开始且连续的,可以形象地看作一个大数组,通过从0开始的编号访问其中的元素,每个元素储存了固定大小的数据。由低层向高层、由简单到复杂,各类地址空间可以概括为:单机上的物理地址空间、单/多机上的虚拟地址空间、单机上的“虚拟”物理地址空间、多机上的“虚拟”物理地址空间,下面将依次介绍。
单机上的物理地址空间
对于一条访存指令,若系统中没有开启分页模式,在不考虑开启分段模式的情况下,这条指令可以访问全部的物理内存。指令访问的PA(Physical Address,物理地址)是从0开始且连续的。在计算机启动之初,BIOS探测到主板内存插槽上的所有内存条,并给每个内存条赋予一个物理地址范围,最后给CPU提供一个从0开始的物理地址空间。从而每个内存条都被映射到一个物理地址范围内,软件无须知道自己访问的是哪个内存条上的数据,使用物理地址即可访问内存条上存储的数据。物理地址隐藏了内存条的相关信息,给系统提供了从0开始且连续的物理地址空间抽象,是一种虚拟化,如下图(a)所示:

除了内存条被映射到物理地址空间内,也有一些外围设备 (Peripheral Devices) 的内存和寄存器被映射到物理地址空间内。当CPU发出的访存指令的物理地址落在外围设备对应的物理地址范围内,则会将数据返回给CPU,这称作内存映射I/O MMIO。如果只有一层物理地址空间,则系统中只能运行一个进程,无法对CPU分时复用。
单/多机上的虚拟地址空间
为了实现CPU的分时复用,操作系统提供了进程的概念。一个进程即是一串相互关联、完成同一个任务的指令序列。为了使不同进程的访存指令在访问物理内存时不会相互冲突,VA(Virtual Address,虚拟地址)的概念被引入。每个进程都有一个独立的虚拟地址空间,从0开始且连续,VA到PA的映射由操作系统决定。这样,假设操作系统为进程A分配了第0块和第2块的物理内存,为进程B分配了第1块和第3块的物理内存,而两个进程的虚拟地址空间大小均为4。
本文使用抽象的 “块” 作为虚拟内存和物理内存之间映射的基本单位,即抽象层间映射的基本单位。两个进程都可以使用虚拟地址0访问它们拥有的第0块虚拟内存,而不会引起访问冲突,如上图(b)所示。由于VA到PA的映射由操作系统决定,因此操作系统可以巧妙地设置该映射,使得系统中的多个进程以一种低内存占用的方式运行。假设进程A使用的第2块物理内存和进程B使用的第3块物理内存保存的数据相同,那么操作系统可以选择将进程A、B的第1块虚拟内存同时映射到系统中的第2块物理内存,并释放第3块物理内存,减少物理内存占用。减少物理内存占用的方法还有将虚拟内存块对应的物理内存换出到磁盘上,而不改变虚拟内存的抽象层,这种方式称为页换出。
这就是抽象层提供的一个好处:给上层应用提供一个不变的 “虚拟” 内存,而灵活地改变其 “后台” 实现。虚拟内存甚至可以建立在多个物理内存硬件上,从而实现内存资源的聚合。如上图(c)所示,这种架构称为DSM(Distributed Shared Memory,分布式共享内存),它可以使单机进程无修改地运行在M0和M1上(M代表Machine),后文将介绍其原理,以及如何被用于实现分布式虚拟机监控器GiantVM。除了横向扩展内存虚拟化的概念,即增加物理内存的量,还可以增加抽象层的层级数。
单机上的 “虚拟 ”物理地址空间
如果保持物理地址空间的概念不变,继续更改物理地址空间的后台实现将会产生什么概念? 一个很容易想到的想法是用虚拟内存代替物理内存条作为物理地址实现的后台。而物理内存可以提供给一整台机器使用,于是产生了内存虚拟化的概念。
假设进程 A 运行在物理硬件上,它提供 4 块虚拟的物理内存给客户机A使用,分别对应其 0、1、2、3 块虚拟内存。客户机A在此 “虚拟” 物理内存的基础上,继续提供虚拟内存的抽象,将第0、1块物理内存分别给客户机中运行的进程A1、B1使用,分别映射到进程A1的第0块虚拟内存、进程B1的第1块虚拟内存。如下图所示,客户机进程A和普通进程B并无差别。
1 | // 图3-2 |
这里产生了GVA、GPA、HVA和HPA四层地址空间,其中**HVA到HPA的映射仍然由宿主机操作系统决定,GPA到HVA的映射由Hypervisor决定,而GVA到GPA的映射由客户机操作系统决定。**这样的 “虚拟” 物理地址抽象有什么可利用之处呢?
首先,这改变了对于访存指令的固有认知,即访存指令不一定访问真实的物理内存。只要保证虚拟硬件的抽象和原有的物理硬件相同,系统软件就可以按需灵活地更改抽象层的后台实现。如果真实的物理硬件需要更换维修,那么虚拟机热迁移可以将客户机迁移到另一个机器上,而不会由于更换硬件停止虚拟机的运行。这是由于 “虚拟” 物理内存的抽象没有改变,客户机将不会感知到它所依赖运行的硬件发生了变化。其次,根据前文对单机虚拟内存的描述,多个进程的虚拟内存总量可以超过系统上装备的物理内存总量。类似的,在内存虚拟化中,“虚拟” 物理内存的总量可以超过真实的物理内存总量,即内存超售。

多机上的 “虚拟 ”物理地址空间
大数据环境下的应用都会占用大量的物理内存。单个机器渐渐无法满足大数据处理任务运行过程中所需要的内存空间。于是,人们将关注点从高配置的单机纵向扩展 (Scale-up) 转向了数量较大的单机横向扩展 (Scale-out) 。为了使大数据应用运行在多个机器上,而掩盖掉网络通信、容错等分布式环境下需要额外注意的复杂度,大数据框架被开发出来,如Spark、Hadoop等。但这些框架仍然有陡峭的学习曲线,程序员需要学习其复杂的编程模型才能在分布式框架上编写代码。
如果存在一个SSI(Single System Image,单一系统镜像),即在多个节点组成的分布式集群上给程序员提供一种单机的编程模型,则会极大地提高分布式应用的开发效率,彻底掩盖分布式系统的复杂性,无须学习分布式框架的编程模型。若把前文DSM的思想用于实现 “虚拟” 物理内存,将获得一个容量巨大的 “虚拟” 物理内存。DSM在多个物理节点之上建立了一个 “虚拟” 物理内存的抽象层,如下图所示,M0、M1分别配备4块物理内存。

仿照单机上的 “虚拟” 物理内存,此处仍由宿主机的VM进程A、B为跨界点客户机VM提供 “虚拟” 物理内存。于是,VM拥有了8块物理内存,其后台实现是两个物理机的虚拟内存。这被称为“多虚一”,即多个节点共同虚拟化出一个虚拟机。DSM保持了 “虚拟” 物理内存的抽象层不变,任何一个操作系统均可运行在这样的抽象之上,和运行在真实物理硬件上没有差别。DSM抽象层的后台实现将在后文中介绍。
2 内存虚拟化的实现
下面首先介绍虚拟地址的实现,再介绍内存虚拟化的实现。最后再聊聊建立多机上内存抽象的方法。
2.1 谈谈页表
系统使能MMU后,所有访存指令提供的地址均为VA,还需要将VA转换为PA,才能从真实的内存硬件中获取数据。如何实现VA到PA的转换? 一个简单的想法是:将每个VA和PA的对应关系记录在一个表中,使用VA查询该表即可找到对应的PA。这样的映射表会占用大量内存,故在现代操作系统中,虚拟内存和物理内存被分为4KB页,映射表中只记录 VFN(Virtual Frame Number,虚拟页号)对应的 PFN(Physical Frame Number,物理页号),映射表表项数量减少为原来的1/4096。映射表记录了虚拟页与物理页之间的映射,于是得名PT(Page Table,页表),其表项称为 PTE(Page Table Entry,页表项)。为了进一步减少 PTE 数量,也可以使用2MB/1GB大小的页,即大页。
所有系统软件的设计都追求时间尽可能短、空间占用尽可能少,地址翻译系统的设计也一样。事实上,虚拟地址空间十分庞大,应用程序不可能在短时间内访问大量的虚拟内存,而是多次访问某些范围内的虚拟内存,即存在空间局部性。基于这一观察,**操作系统设计者将页表组织成基数树,或称为多级页表,**可以使未使用的查询表项不出现在内存中,大大减少内存占用。在32位架构(如x86)中,操作系统使用 10+10 形式的二级页表,用前10位索引一级页表,后10位索引二级页表。而在64位架构(如x86-64、ARMv8-A)中,操作系统使用 9+9+9+9 形式的四级页表,如下图所示:

其中页表的每一级分别用PML4(Page Map Level 4,第4级页映射)、PDPT(Page Directory Pointer Table,页目录指针表)、PD(Page Directory,页目录)、PT表示,查询一次页表需要4次内存访问。在32位系统中,当一个应用程序连续访问了连续的4MB虚拟内存,使用 10+10 的二级页表则仅需要1个一级页表页和1个二级页表页,页表占用内存大小为8KB。而使用一级页表则需要4MB内存,仅仅是查询页表时多了一次内存访问,内存占用就减小为原来的 8KB/4MB=1/512,这是十分划算的。
查询页表不应该由应用程序负责,因为这对于应用程序完成自己的工作是无意义的,并且由应用程序负责修改页表会产生虚拟内存泄漏的问题,虚拟内存的隔离性将不复存在,造成安全问题。于是,CPU芯片设计者引入了MMU(Memory Management Unit,内存管理单元)负责查询页表。每个CPU核心上都配备了一个独立的MMU,只要CPU将页表的基地址放入一个指定的寄存器中,MMU中的PTW(Page Table Walker,页表爬取器)即可查找页表将CPU产生的VA自动翻译成PFN,左移12位后与VA的低12位相加即得到PA,不需要CPU执行额外的指令。这一指定的寄存器在Intel体系中是CR3,在ARM体系中是TTBRx(Translation Table Base Register x,翻译表基地址寄存器x)。这些架构下的地址翻译原理大致相同,故本文统一使用ptr(pointer,指针)代表页表基地址。
同时,MMU硬件也通过PTE上的标志位实现了访存合法性的检查,以及内存访问情况的记录。Intel体系下的PTE标志位详见Intel SDM卷3第4章,其他体系有相应的手册。此处列举如下x86架构中常用的标志位:
- P (Present) 位。PTE[0],置为1时该PTE有效,为0时该PTE无效。任何访问该PTE对应的虚拟地址的指令均引起缺页异常。当物理页未分配、页表项未建立(此时PTE的每一位均为0)或物理页被换出时(此时PTE的每一位不全为0),P位为0,物理页不存在;
- R/W位和U/S位。PTE[1∶2],置为1时表示该页是可读可写的,为0时表示该页只读。U/S位置为0时只有管理者(supervisor,即运行在Ring0、Ring1、Ring2的代码)可以访问,为1时用户(user,即运行在Ring3的代码)、管理者均可访问。这两个位用于权限管理;
- A(Accessed)位和D(Dirty)位。PTE[5∶6],当CPU写入PTE对应的页时D位被置为1,当CPU读取或写入PTE对应的页时A位被置为1,这两位均需要操作系统置0。
- D位仅存在于PTE中,而不存在于PDE(Page Directory Entry,页目录项)中。D位用于标识一个文件映射的页在内存中是否被修改,在将页换出时需要更新对应的磁盘文件;
- A位用于标识内存页是否最近被访问。当操作系统将A位置为0后一段时间内,若A位不再变为1,则对应的内存页不经常被访问,可以被换出到磁盘。操作系统有一套内存页换出机制,将不经常访问的内存页换出到磁盘,保证内存被充分使用。
**当MMU硬件无法完成地址翻译时,则需要操作系统软件的配合。**在地址翻译的过程中,MMU硬件仅负责页表的查询,而操作系统负责页表的维护。当MMU无法完成地址翻译时,就会向CPU发送一个信号,产生了缺页异常(在x86架构中是14号异常),从而调用操作系统内核的缺页异常处理函数。内核按照其需要为产生缺页异常的VA分配物理内存,建立页表,并重新执行引起缺页异常的访存指令;如果该访存指令访问的虚拟内存被换出到磁盘上,则需要首先分配物理内存页,再对磁盘发起I/O请求,将被换出的页读取到新分配的物理内存页上,最后建立对应的页表项。操作系统可以灵活地利用缺页异常,实现内存换出、COW(Copy-On-Write,写时复制)等功能。
缩短时间的一种重要方式是缓存。在MMU中,TLB(Translation Lookaside Buffer,翻译后备缓冲器)用于缓存VA到PA的映射,避免查询页表造成的内存访问。然而,内存容量随着技术的进步快速增大,TLB容量的增速却很缓慢,这导致TLB能够覆盖的虚拟内存空间越来越小。研究表明,应用运行时间的 50% 都耗费在查询页表上。**前文提到的大页可以在一定程度上解决这一问题,但不够灵活。**Vasileios等提出了区间式TLB机制,每个区间TLB项可以将任意长度的虚拟内存区间映射到物理内存,该长度由操作系统决定,进一步减少了PTE的数量。这两种方式使得TLB能够覆盖更多的虚拟地址空间,减少了查询页表的次数。
在进程切换时,由于进程运行在不同的虚拟地址空间中,需要将TLB中的所有项清空,否则地址翻译将出错。x86/ARM硬件提供了PCID(Process Context IDentifier,进程上下文标识符)/ASID(Address Space IDentifier,地址空间标识符),将TLB中的项标上进程ID,于是在进程切换时无须清空TLB。详见Intel SDM卷3第4.10节。
2.2 内存虚拟化的纯软件实现: 影子页表
在新系统设计的过程中,复用已有设计可以加快设计的流程,降低设计的难度。是否可以重用CPU芯片上的MMU从而实现GVA到HPA的翻译呢? 客户机操作系统运行在非根模式下,将客户机页表起始地址GPA写入ptr,这样MMU只能完成GVA到GPA的翻译。将页表修改为GVA到HPA的映射,MMU就能将GVA翻译为HPA,并且对客户机完全透明,从而完成问题的转化。使用影子页表翻译客户机虚拟地址的流程如下图所示:

具体操作过程是:
- 当vCPU创建时,Hypervisor为vCPU准备一个新的空页表。当vCPU将GPT的GPA写入ptr时,引起一次VM-Exit,vCPU线程退出到Hypervisor中,调用处理ptr写入的处理函数;
- Hypervisor中保存了GPA到HVA的映射,处理函数读取客户机试图写入的ptr值,翻译为HVA,即可读取GPT的内容。进而,Hypervisor遍历GPT,并将每个GPT表项中保存的GPA翻译为HVA,再使用宿主机操作系统内核翻译为HPA,最后将HPA填入新页表SPT的GVA处;
- 完成GPT的遍历后,就建立了对应的新页表SPT,Hypervisor的处理函数最终将该新页表的基地址写入ptr中,并返回到vCPU重新执行引起VM-Exit的指令。见上图中标号①;
- GPT的所有页表页被标记为只读,客户机写入GPT引发VM-Exit,并调用Hypervisor的相关处理函数,把对GPT的修改翻译为对SPT的修改,完成新页表与GPT之间的同步,见图3-5中标号②。
上述过程中出现的新页表称为影子页表,因为对于每个GPT,都需要对应的SPT作为代替,且SPT与GPT的结构完全相同,其不同仅仅是每个表项中的GPA被修改为了对应的HPA,如同影子一样。影子页表查询的结果是HFN(Host Frame Number,宿主机页号),左移12位后与GVA的低12位相加即得到对应的HPA。
和非虚拟化场景下的页表相同,MMU硬件可以自动查询SPT,将CPU产生的GVA直接翻译为HPA,也有TLB保存GVA到HPA的映射以加快地址翻译。虚拟内存的优势也可应用在VM进程的内存管理上,如宿主机操作系统可以将VM进程不常用的内存页换出到磁盘,两个客户机之间也可以通过页表的权限实现简单的隔离,还可以通过将两个内存插槽对应的HVA映射到相同的HPA,应用COW技术实现客户机之间的内存共用,从而节约系统的物理内存,实现内存超售。
为了将GPT翻译为SPT,需要利用宿主机中保存的GPA到HPA的映射。由于HVA到HPA的映射由宿主机页表保存,Hypervisor仅需关注GPA到HVA的映射。事实上,在Hypervisor (如KVM) 中,内存插槽 (kvm_memory_slot) 数据结构将一段GPA一对一映射到HVA,用宿主机的虚拟内存作为虚拟的 “内存插槽” 给客户机使用。内存插槽在HVA中的位置可以不从0开始且不连续,但Hypervisor使用多个内存插槽可以给客户机提供一个从0开始且连续的物理地址空间。客户机内存插槽的设计在后文中有详细介绍。
影子页表的缺陷:
- 由于Hypervisor需要为每个客户机中的每个进程维护一个独立的SPT,系统中GPT的数目等于SPT的数目,这将占用大量内存;
- 当GPT被修改时,由于需要保持SPT与GPT的同步,vCPU线程需要VM-Exit将控制权转让给Hypervisor,由Hypervisor根据GPT的修改来修改SPT,同时将TLB中缓存的相关项清除。于是,每次客户机对GPT的修改都将引起巨大的性能开销,只有当客户机很少修改GPT时,SPT才会表现出较好的性能。
2.3 内存虚拟化的硬件支持: 扩展页表
仔细回顾虚拟化环境下的地址翻译,可以发现GPA到HPA的转换一步可以从影子页表中剥离出来:
- 当客户机创建时,宿主机给客户机使用的内存已经分配完毕,在客户机的运行过程中,很少改变GPA到HPA的映射;
- 其次,SPT和GPT也包含重复的信息,即等价于包含了两次GVA到GPA的映射;
- 两个进程的SPT之间也包含重复的信息,即重复多次包含了GPA到HPA的映射,故增加了页表维护的复杂度并增大了内存开销。
于是,系统设计者将GPA到HPA的映射从影子页表中剥离出来,形成一个新的页表,配合GVA到GPA映射的页表(GPT)共同完成地址翻译。
虚拟环境下的地址翻译依赖于双层页表 (Two Level Paging) 。GPA到HPA映射的页表在 x86 中称为EPT(扩展页表),在ARM中称为第二阶段页表 (Stage-2 Page Table),而GPT称为第一阶段页表 (Stage-1 Page Table),一、二阶段页表基地址分别保存在 TTBR0_EL1 和 VTTBR_EL2 中。
本文使用gptr(guest pointer,客户机指针)表示GPT基地址寄存器,HPT(Host Page Table,宿主机页表)表示被剥离出的页表;hptr(host pointer,宿主机指针)表示HPT基地址寄存器。
每个客户机有一个私有的HPT,包含与GPT完全不重复的信息。由于HPT与GPT之间没有依赖关系,修改GPT时无须修改HPT,即无须Hypervisor干预,从而减少了客户机退出到Hypervisor的次数。
ARM与Intel VT都提供了类似的双层页表支持,这里只介绍Intel VT中提供的支持,后文统一使用EPT表示保存了GPA到HPA映射的页表。Intel VT提供了具有交叉查询GPT和EPT功能的扩展MMU。当VM-Entry时,EPTP(EPT Pointer,扩展页表指针,即EPT的基地址)将由Hypervisor进行设置,保存在VM-Execution控制域中的EPTP字段中。需要注意的是,每个客户机中的所有vCPU在运行前,其对应的VMCS中的EPTP都会被写入相同的值。这是因为所有的vCPU应该看到相同的客户机物理地址空间,即所有vCPU共享EPT。一个客户机仅需一个EPT,故减小了内存开销。
EPT表项的构成较为简单,其第0、1、2位分别表示了客户机物理页的可读、可写、可执行权限,并包含指向下一级页表页的指针(HPA)。当EPTP(存在于VMCS中)的第6位为1时,会使能EPT的A/D(Accessed/Dirty,访问/脏)位。EPT中的A/D位和前文所述的进程页表的A/D位类似,D位仅存在于第四级页表项,即PTE中。A/D位由处理器硬件置1,由Hypervisor软件置0。
- 每当EPT被使用时,对应的EPT表项的A位被处理器置1;
- 当客户机物理内存被写入时,对应的EPT表项的D位被置1。
需要注意的是,**对客户机页表GPT的任何访问均被视为写,GPT的页表页对应EPT表项的D位均被处理器硬件置1。**该硬件特性将在本文中多处提及,可用于实现一些软件功能,也可选择关闭该特性,例如可以用此硬件特性实现虚拟机热迁移。详见Intel SDM卷3第28.2.4节。
和客户机操作系统中的GPT类似,EPT也是在缺页异常中由Hypervisor软件建立的,具体过程如下:
- 当刚启动的客户机中的某进程访问了一个虚拟地址,由于此时该进程的一级页表(GPT中的PML4)为空,故触发客户机操作系统中的缺页异常;
- 客户机操作系统为了分配GPT对应的客户机物理页,需要查询EPT。此时,由于EPT尚未建立,客户机操作系统就退出到了Hypervisor。当客户机操作系统访问了一个缺失的EPT页表项,处理器产生EPT违例(EPT Violation)的VM-Exit,从而Hypervisor分配宿主机内存、建立EPT表项;
- 触发EPT违例的详细原因会被硬件记录在VMCS的VM-Exit条件 (VM-Exit Qualification) 字段,供Hypervisor使用。宿主机操作系统完成宿主机物理页分配,建立对应的EPT表项,将返回到客户机操作系统;
- 客户机操作系统继续访问GPT的下一级页表(PDPT),重复步骤1、2,GPT和EPT的建立方可完成。
这里又出现了软硬件的明确分工: **软件维护页表,硬件查询页表。**如果访问了EPT中的一个配置错误,不符合Intel规范的表项,处理器会触发EPT配置错误 (EPT Misconfiguration) 的VM-Exit。例如访问了一个不可读但可写的表项,此时硬件将不会记录发生EPT配置错误的原因,这类VM-Exit被Hypervisor用于模拟MMIO。有关EPT硬件的详细内容请参阅Intel SDM卷3第28章,Hypervisor软件部分的介绍见本文的第三章。
当处在非根模式下的CPU访问了一个GVA,MMU将首先查询GPT。gptr包含客户机页表的起始地址GPA,这会触发MMU交叉地查询EPT,将gptr中包含的GPA翻译成HPA,从而获取GPT的第一级表项。同理,为了获取GPT中每个层级的页表项,MMU都会查询EPT。
在64位的系统中,Hypervisor使用GPA的低48位查询EPT页表,而EPT页表也使用了 9+9+9+9 的四级页表的形式。假设GPT也是四级页表,那么非根模式下的CPU为了读取一个GVA处的数据,需要额外读取24个页表项(图中加粗黑框的灰色长方形),因此需要额外的24次内存访问,如下图所示:

即使MMU中有TLB缓存GVA到HPA的映射,TLB也无法覆盖越来越大的客户机虚拟地址空间,双层页表的查询将会造成巨大的开销。而在TLB未命中时,一个四级影子页表仅需访问 4 次内存即可得到HPA,相比于双层页表有巨大的优势。是否可以将影子页表与扩展页表相结合呢?
2.4 //TODO 扩展页表与影子页表的结合: 敏捷页表
在系统设计中,经常存在着各种各样的折中与权衡。虽然影子页表和双层页表(即x86中的扩展页表)在TLB命中时,均可以以最快的时间获得GVA到HPA的映射,但遭遇1次TLB未命中时,查询双层页表需要访问内存的次数增长到了24/4=6倍。然而影子页表会引起大量的VM-Exit,尤其是在频繁分配、释放虚拟内存的内存密集型场景下,这使得影子页表在现在的虚拟化环境下很少使用。由于内存容量快速增大、TLB容量增长缓慢,TLB不命中的次数越来越多,查询双层页表造成的6倍内存访问也造成了不可忽视的开销,影子页表有一些优势。表3-1将影子页表与扩展页表进行了对比。

Jayneel等人观察到[插图],在2秒的采样时间内,仅有1~5%的地址空间被频繁修改,且被修改的地址空间会比未修改的地址空间修改得更加频繁。例如,保存代码的地址空间极少被修改、写入,称为静态区;而地址空间的堆栈以及映射文件的部分则被频繁修改,称为动态区。若用SPT完成静态区的地址翻译,则能减小TLB不命中时的页表查询开销;而用双层页表完成动态区的地址翻译,则能避免GPT频繁修改造成的VM-Exit。于是,研究人员于2016年提出了敏捷页表[插图]。他们设计了一种影子页表和双层页表混用的机制,还有一种策略决定何时由影子页表转换到双层页表或由双层页表转换到影子页表。这是一种机制与策略的分离:机制是固定的,而程序员可以灵活修改策略,具有更好的灵活性。图3-7展示了影子页表与双层页表的混用机制。**为了实现敏捷页表,需要硬件支持以及软件支持,**下面分开介绍。
硬件支持
先介绍影子页表与双层页表的混用机制,这部分功能主要使用硬件实现。首先,影子页表需要提供转换位(Switching Bit)的支持。在影子页表的页表项中,仍有一些标志位被硬件忽略,可以放置转换位。硬件增加了sptr(Shadow Pointer,影子指针)寄存器,用于放置影子页表基地址;同时保留原有的ptr寄存器(更名为gptr寄存器),用于放置客户机页表基地址;hptr放置EPT的基地址,用于查询EPT。当查询SPT时读到一个转换位被置为1的影子页表项,则MMU切换到双层页表的地址翻译模式继续交叉地查询GPT和EPT。在切换前后,MMU中的TLB仍然保存了GVA到HPA的映射,无须刷新TLB。在转换位被置为1的影子页表项中,记录了下一级GPT页表页的起始地址。该地址为HPA,转换位被置为1时由硬件写入。通过该HPA即可查询到下一级GPT的页表项,使得页表查询过程继续进行下去。
在影子页表中,可以将任何一级页表项的转换位置为1,如图3-7(a)中SPT的第三级页表项的转换位置为1,图3-7(b)中SPT的第三级页表项的转换位置为1。在下图(a)中查询3级影子页表,仅额外读取3+1+4=8次页表项(图中的加粗黑框灰色长方形);图(b)中仅遍历2级影子页表,但要额外读取2+2+8=12次页表项。


软件支持
转换位是否置1由Hypervisor决定,这属于策略设计需要关注的。当MMU进入双层页表查询模式后,其读取的GPT表项不再被标记为只读,可以被客户机操作系统写入而不引起VM-Exit。为了利用这一优势,Hypervisor实现了一套策略维护转换位。当客户机进程被创建且调度运行时,MMU处在影子页表地址翻译的状态,GPT被标记为只读。若一个SPT页表项被修改,将发生VM-Exit,Hypervisor记录1次对该SPT页表项的修改并更新SPT,返回客户机。当Hypervisor记录到2次对该SPT页表项的修改时,则将该SPT页表项的转换位置为1,表明该SPT页表项经常被修改,需要切换到双层页表模式。如图3-7(a)所示,当客户机修改了两次sPDE(shadow Page Directory Entry,影子页目录项)时,将sPDE的转换位置为1,那么对于gPTE(guest Page Table Entry,客户机页表项)的修改将不会引起VM-Exit,代价只是由读取4次内存增加到读取8次。
还需要一套策略决定转换位置0。当客户机页表很少被修改时,如果将MMU地址翻译模式部分切换回SPT的翻译模式,在TLB不命中时可以减少读取页表项的次数。然而,假设此时有对GPT和EPT频繁的只读访问,客户机将不退出Hypervisor,Hypervisor也无法得知何时应该切换回影子页表模式。为此,前文所述的EPT A/D位特性(见Intel SDM卷3第28.2.4节)应该被关闭,因为当EPT A/D位使能时,所有对GPT的访问无论读/写均视为写(注意,除GPT页表页的只读访问并不被视为写),于是GPT所在的客户机物理页对应的EPT表项中脏位(即D位)将被置1。此时Hypervisor可以通过EPT中的脏位观察是否应该回到SPT模式。在一个检查周期开始时,将所有GPT对应的EPT表项脏位置0;在周期结束时,若脏位仍然没有置1,则视为GPT修改不频繁,对应的转换位置为0,切换回双层页表的翻译模式。
敏捷页表仅是一个设想,所需的硬件支持尚未实现。研究者用仿真工具模拟了敏捷页表的运行,并测得相比于双层页表更低的TLB不命中开销,以及相比于影子页表更少的VM-Exit次数。然而,敏捷页表对进程切换不友好,由于敏捷页表的一级页表使用了影子页表的页表页,在切换sptr时会引起VM-Exit,但有相应的硬件优化,详见参考文献[插图]。
2.5 //TODO 内存的半虚拟化: 直接页表映射与内存气球
上文所述的内存虚拟化实现均基于一个前提:客户机无从得知自己使用的是“虚拟”的物理内存。如果客户机知道自己运行在“虚拟”的内存硬件上,内存虚拟化的实现是否会更简单?内存虚拟化实现的性能是否更高?本节介绍两个与内存半虚拟化的相关技术。
直接页表映射
半虚拟化可以通过告知客户机运行在虚拟环境下,让客户机协同Hypervisor完成虚拟化任务,从而可以使Hypervisor需要完成的工作更少,Hypervisor的实现更为简单。将半虚拟化的思想应用在内存虚拟化上,则Hypervisor有能力告知客户机操作系统:将页表维护成能够直接安装到真实硬件MMU的版本,Hypervisor将不对客户机页表进行任何修改。GPT中将保存GVA到HPA的映射,而Hypervisor需要做的仅仅是告知客户机操作系统可以使用的真实物理内存范围。这样,只需要增加客户机操作系统的一些复杂性,就不需要降低客户机运行性能的影子页表,也不需要复杂的EPT硬件扩展,内存虚拟化的困难减小,性能也得到提高。这种内存虚拟化的实现方式称作直接页表映射。
然而,由于页表中的映射对于客户机之间的隔离性、系统安全等至关重要,客户机对页表不可随便更改。在客户机更改页表时,只能调用Hypervisor提供的超级调用,由Hypervisor检查客户机映射的内存范围是否合法,才能返回客户机继续执行。相比于影子页表,Hypervisor需要完成复杂的GPT到SPT的翻译,直接映射大大减轻了Hypervisor的负担。为了减小多次非根模式与根模式切换带来的开销,客户机可以选择将多次对GPT的更改组合起来,合并成一次超级调用进入Hypervisor,从而将多次CPU模式切换替换为1次模式切换。虽然Hypervisor进行了GPT修改的合法性检查,但由于客户机明确地知道真实物理硬件的物理地址(HPA),仍然可以利用HPA发起行锤(Rowhammer)攻击。该攻击具体原理如下:由于DRAM不断发展,厂商将DRAM的单元做得越来越近,而相邻单元的相互影响也越来越大,不断访问某个地址的物理内存,即可造成相邻位置内存位的翻转。若客户机得知了真实的物理内存地址,则可以对不属于自己的相邻物理内存发起行锤攻击。
内存气球
根据对SPT原理的介绍,客户机的“虚拟”物理内存的后台实现其实是宿主机进程的虚拟内存,可以使用宿主机虚拟内存的功能管理客户机物理内存。例如,为了实现内存超售,给所有客户机分配的物理内存总量可以大于物理硬件的内存容量。由于抽象层这一概念的存在,系统软件的设计者可以灵活更改“虚拟”物理内存的后台实现,将“虚拟”物理内存对应的宿主机虚拟内存换出到磁盘,或映射到同一块宿主机物理内存,从而减小宿主机物理内存的压力。然而,Hypervisor在决定换出哪块“虚拟”物理内存时,无法精确地得知哪些部分在未来一段时间内不会被客户机使用。即使开启了EPT的A/D位,Hypervisor也仅仅能够得知在过去一段时间内,客户机访问了哪些页、长时间未访问哪些页,而无法得知这些页之间的关联与意义,即所谓的语义鸿沟。这会导致“虚拟”物理内存换出的太多或太少:“太多”会使客户机不断等待“虚拟”物理内存从磁盘换入内存,降低客户机性能;而“太少”则使Hypervisor没有释放那些完全可以被立即释放的“虚拟”物理内存,造成系统内存资源的浪费。
Hypervisor无法实现高效的客户机内存换出策略的原因是:Hypervisor无法得知客户机内部发生了什么。而半虚拟化可以很好地解决该问题,可以使客户机和Hypervisor更好地沟通。内存气球利用了客户机内核提供的物理内存分配函数,来实现客户机内存的高效释放。其主要工作流程是,Hypervisor调用客户机提供的内存释放接口,请求客户机释放其占用的“虚拟”物理内存。客户机收到该请求后,调用其内核提供的物理内存分配函数(如Linux内核中的alloc_pages函数),并把分配好的“虚拟”物理内存范围返回给Hypervisor。Hypervisor可以将该“虚拟”物理内存对应的虚拟内存释放,减轻系统内存压力。由于客户机内核的物理内存分配函数会“自动”找出未被使用的物理内存,因此这种方式很简易地找出了客户机中不被使用的物理内存,大大简化了内存气球的实现。
3 QEMU/KVM分析
本节将深入分析QEMU/KVM内存虚拟化相关代码,其中:
KVM代码来自Linux内核v4.19,QEMU代码版本为4.1.1。
内存虚拟化的核心是使用虚拟内存代替物理内存条,作为 “虚拟” 物理内存的实现 “后台”,从而给客户机提供从0开始且连续的 “虚拟” 物理内存。客户机访存指令提供的地址是GVA,被宿主机MMU翻译成HPA,再发送到物理内存上读取/写入数据。Hypervisor和操作系统维护页表,将页表装载到MMU中,与MMU硬件协同完成内存虚拟化。
对应到广泛使用的Type II Hypervisor QEMU/KVM中,两者的职能分别是:
- QEMU负责在宿主机用户态分配虚拟内存,作为客户机 “虚拟” 物理内存的后台实现,即完成所有物理内存硬件的功能;
- 而KVM负责在内核态维护GVA到HPA的映射,即维护页表,并将页表装载到MMU中完成软硬件的配合。
这属于一种策略和机制的分离,其中KVM提供了地址翻译机制,而QEMU决定如何利用KVM的地址翻译机制完成内存虚拟化,实现一套功能完整的内存虚拟化策略。这种分离的好处在于,Hypervisor的编写者可以灵活地变更策略的实现,而机制无须修改。下面分别对QEMU的物理内存模拟和KVM的页表维护进行分析。
3.1 QEMU内存数据结构
为了正确地运行客户机,**QEMU需要模拟物理地址空间的所有功能,**包括:
- QEMU作为宿主机上的用户态进程,在宿主机上分配一段虚拟内存提供给客户机作为客户机物理内存使用;
- QEMU需要**模拟物理地址空间中外围设备对应的MMIO部分,**通过截获对该内存区域的访问,完成对设备功能的模拟,使得客户机像在真实环境中一样完成MMIO。
“虚拟”物理内存分配
本节从解决第一个问题开始,即:QEMU进程如何分配虚拟内存作为客户机的物理内存?
熟悉C语言标准库的读者知道,要分配一段大小不固定的虚拟内存,应该调用 malloc 函数。系统首先分配足够的堆内存给 malloc 函数使用,当分配的堆内存用完时,malloc 函数调用 brk 函数修改内核中的brk指针,增大分配的堆内存。如果 malloc 函数请求的内存大小超过128KB,则会调用 mmap 系统调用在虚拟内存的内存映射区而非堆上分配内存。
由于QEMU需要给客户机分配较大块的虚拟内存作为 “虚拟” 物理内存,故QEMU选择使用 mmap 函数。mmap 函数建立的虚拟内存映射根据分配的虚拟内存是否关联到磁盘文件分为文件映射和匿名映射,此处只关注匿名映射。**RAMBlock方便了宿主机虚拟内存的管理,**简称RB,其定义如下:
qemu-4.1.1/include/exec/ram_addr.h

- host指针保存了
mmap函数返回的宿主机虚拟地址 - max_length保存了
mmap函数申请的虚拟内存大小 - idstr保存了该RB的名称
- mr保存了其所属的MemoryRegion
- next指向该RB在全局变量ram_list中的下一个RB
- ram_addr_t类型代表了所有内存条组成的GPA空间
ram_list.blocks 将所有 “虚拟” 物理内存块RB组织在一起,根据max_length从大到小排列,RAMBlock组织结构如下图所示:

其中,offset是在ram_addr_t地址空间中的偏移,used_length是当前使用的长度,即包含有效数据的长度,max_length是 mmap 函数分配的长度,即最大可以使用的长度。和 mmap 函数的映射类型相同,RB也分为匿名文件对应的类型(其fd为-1)以及磁盘文件对应的类型(如果使用QEMU的-mem-path选项)。qemu_ram_alloc_* 函数族负责分配新的RB,它们最终都调用 ram_block_add 函数填充RB数据结构,代码如下。
qemu-4.1.1/exec.c

参数 new_block 表示待填充的RB,流程如下:
- 首先调用
find_ram_offset在全局ram_list.blocks中查找能够容纳下max_length大小RB的位置,并将该位置填入RB的offset中; - 然后调用
phys_mem_alloc函数,它最终调用mmap函数从而系统调用完成虚拟内存的分配,并将分配的虚拟内存起始地址填入host成员中。 - 当new_block完成分配后,还需要将new_block加入ram_list.blocks中,通过
QLIST_INSERT_*函数完成 ram_list.blocks将整个虚拟机的所有 “内存条” RB管理起来,形成了ram_addr_t类型的地址空间,表示所有“虚拟”物理内存条在客户机物理地址空间中所占的空间。管理RB的接口一般命名为qemu_ram_*,见exec.c文件。最终,QEMU调用qemu_madvise函数建议对该RB对应的虚拟内存使用大页,根据前文分析,使用大页有助于提高TLB命中率。
ramlist 的类型struct RAMList如下:
qemu-4.1.1/include/exec/ramlist.h

ram_list将ram_list.blocks封装成struct RAMList数据结构从而方便管理,是exec.c文件中的全局变量,保存客户机所有物理内存条的信息。其成员如下:
- mru_block保存了最近使用的RB,作为查找ram_list.blocks的缓存,无须遍历链表;
- dirty_memory是整个ram_list.blocks中所有RB的脏页位图,每一位代表了一个脏的物理内存页,而RB的bmap位图是其一部分;
为了模拟VGA(Video Graphics Array,显示绘图阵列,可看作一种设备),QEMU需要重绘脏页对应的界面;为了模拟TCG(Tiny Code Generator,微码生成器,支持QEMU的二进制代码翻译,一种基于纯软件的虚拟化方法),QEMU需要重新编译自调整的代码;对于热迁移,QEMU需要重传脏页。QEMU调用ioctl(KVM_GET_DIRTY_LOG) 函数从KVM中读取脏页位图。
支持”虚拟”物理内存访问回调函数
实体MR
物理地址空间不仅被内存条所占据,也被外围设备的MMIO区域所占据,QEMU需要对客户机访问MMIO进行模拟。CPU使用PIO访问端口地址空间,QEMU也需要对这类访问进行模拟。**对于这些地址空间段,QEMU无须为其分配宿主机虚拟内存,只需设置对应的回调函数。**为此,QEMU在RAMBlock的基础上加了一层包装,形成了MemoryRegion,简称MR,包含MR和回调函数。**MR代表客户机的一块具有特定功能的物理内存区域,**定义如下:
qemu-4.1.1/include/exec/memory.h

QEMU实现了MMIO的模拟。为此,将一个RB和包含了MMIO模拟函数的MemoryRegionOps绑定起来,就形成了MR这种表示多个种类物理内存块的数据结构。当KVM中表示内存条的MR其ops域为NULL时,ram_block不为NULL;而对于表示MMIO内存区的MR,其ops注册为一组MMIO模拟函数时,ram_block为NULL。
当客户机访问了一个MMIO对应的区域,KVM将退出到QEMU,调用ops对应的函数。ops中包含了read、write等函数,其参数包括相对于MR的hwaddr地址addr、写入的数据以及数据的大小,模拟硬件MMIO读写(例如PCI Device ID(Peripheral Component Interconnect Device IDentifier,外设部件互联设备标识符))的read函数应该返回设备ID。
至此,QEMU将整个物理地址空间用MR占满,这种既包含内存条区域又包含MMIO区域的物理地址空间的类型是hwaddr。MR的addr域即为hwaddr类型,表示该MR的GPA。
QEMU对象模型为MR提供了构造函数和析构函数,分别在一个MR实例创建和销毁时调用。在MR的parent_object销毁时,就会调用MR的析构函数。具体为:
- MR创建时调用
memory_region_initfn函数初始化MR,包括将enable置为true和初始化ops、subregions链表等; - 调用
memory_region_ref函数使MR的parent_obj的引用数加1; memory_region_unref函数则使MR的parent_obj的引用数减1,若引用计数为0,则会调用memory_region_finalize函数完成MR的析构,如果是RAM类型的MR,还会释放对应的RB。
QEMU给所有种类的MR都提供了进一步封装的构造函数,根据MR的类型填充数据结构。这些函数是memory_region_init_*,其中 * 代表类型,下面举例介绍。
- RAM类型MR需要调用前文的qemu_ram_alloc函数分配一个RB填入ram_block域,ram域为true;ROM类型MR则需要额外将read_only域置为true,表示只读的内存区;
- MMIO类型MR负责实现MMIO模拟,需要传入ops进行初始化,其中ops是一组回调函数,当QEMU需要模拟MMIO时,会调用ops中的函数进行MMIO模拟;
- 对于ROM Device(Read Only Memory Device,只读内存设备)类型MR,对它进行读取则等同于RAM类型的MR,而写入则等同于MMIO类型的MR,调用回调函数ops;
- IOMMU(Input/Output Memory Management Unit,输入输出内存管理单元)类型MR将对该MR的读写转发到另一MR上模拟IOMMU。
所有的MR构造函数 memory_region_init_* 都要调用 memory_region_init 函数填充size、name等成员。这些MR均称为实体MR,terminates为true。前三类实体MR的创建过程如下图所示:

容器MR/别名MR
所有的MR组成了一棵树,其叶子节点是RAM和MMIO,而中间节点代表总线(容器MR)、内存控制器(别名MR)或被重定向到其他内存区域的内存区,树根是一个容器MR,如下图所示:

这棵树表示一个地址空间,被AddressSpace数据结构指向。下面介绍容器MR和别名MR:
容器 (Container) MR
容器 (Container) MR将其他的MR用subregions链表管理起来,用 memory_region_init 函数初始化。例如PCI BAR(PCI Base Address Register,PCI基地址寄存器)的模拟由RAM和MMIO部分组成,需要容器类型的MR表示PCI BAR,通过memory_region_add_subregion 函数加入容器MR,子MR的container成员指向其容器MR。
通常情况下,一个容器MR的子MR不会重叠,当在该容器MR内解析一个hwaddr时,只会落入一个子MR。但一些情况下子MR会重合,这时使用 memory_region_add_subregion_overlap 函数将子MR加入容器MR,而解析地址时由优先级决定哪个子MR可见。
没有自己的ops/ram_block的容器MR称为纯容器MR,容器MR也可以拥有自己的MemoryRegionOps以及RAMBlock。在一个容器MR中,subregions链表上的所有子MR按照各自的优先级从小到大排列。
memory_region_add_subregion_overlap函数可以指定子MR的优先级,如果优先级为负,则隐藏在默认子MR之下,反之在上。
别名 (Alias) MR
别名 (Alias) MR指向另一个非别名类型的MR,QEMU通过将多个别名MR指向同一个MR的不同偏移处,从而将此MR分为几部分。alias域指向原MR(在代码中多用orig表示),alias_offset是别名MR在实际MR中的位置。
别名MR用 memory_region_init_alias 函数初始化。别名MR没有自己的内存,没有子MR。别名MR本质上是一个实体MR(或另一个别名MR)的一部分,而容器MR的子MR并非容器MR的一部分,仅被容器MR管理。别名MR并非容器MR的子MR。
在QEMU中,全局变量system_memory是整个MR树的根。给定MR树的根MR以及要查询的客户机物理地址,QEMU就可以查找该地址落在哪个实体MR中。首先判断该地址是否在根管理的范围内,如果不在则返回,否则进行如下步骤的搜索:
- 按照优先级从高到低的顺序遍历该容器MR的subregions链表;
- 如果子MR是实体MR,且该地址落在子MR的[mr->addr,mr->addr+mr->size)中,则结束查找,返回该实体MR;
- 如果子MR是容器MR,则递归调用步骤1;
- 如果子MR是别名MR,则继续查找对应的实际MR;
- 如果在所有的子MR中都没有查找到,则查询该地址是否在容器MR自己的内存范围内。
想了解更完整的说明,可以查看QEMU源码树中的 docs/devel/memory.rst 文档,或查阅注释,其中包含MemoryRegion的所有概念,以及MR相关接口的详细解释。
顶层数据结构AddressSpace
在前几节中,QEMU使用 mmap 函数分配了宿主机虚拟内存,作为客户机“虚拟”物理内存的实现后台,又构建了MemoryRegion树,对一个物理地址空间内各个不同区域的功能进行了模拟,包括内存条RAM以及MMIO,使用容器MR对子MR进行管理,完成了QEMU对各段内存功能的模拟。
前文由底层向上层,介绍了QEMU中抽象程度较高的数据结构MR。进一步地,除了对客户机物理地址空间的模拟,MR树可以用在对任何地址空间的模拟上。计算机硬件中还存在以下地址空间,与内存地址空间类似。
- CPU地址总线传来的地址可以用于访问RAM或MMIO,即形成了物理地址空间,system_memory即表示此空间;
- 除了MMIO,CPU与外围设备打交道的另一种方式是PIO,CPU使用
in/out指令访问设备端口,端口号组成了另一种地址空间,在x86上有65536个端口,端口地址空间范围是[0,0xffff); - 除了CPU可以访问内存,外围设备也可以自发地访问内存,这种访问方式称作DMA(Direct Memory Access,直接内存访问),可以绕过CPU,不使用CPU访存指令访问内存。外围设备可以看到的内存地址也组成了一个地址空间。
以上三个场景均需要对一个MR树进行管理。QEMU引入了AddressSpace数据结构(简称为AS),表示CPU、外设或PIO可以访问的地址空间,定义如下:
qemu-4.1.1/include/exec/memory.h

AS数据结构主要承担两个任务:一是将MR树(由root成员指向)转换成线性视图current_map,二是在给定一个hwaddr(GPA)时快速查找到一个MemoryRegion,从而快速调用MR对应的回调函数ops或MR对应的RB,从而找到HVA,完成GPA到HVA的翻译。简而言之,就是完成树和线性表之间的相互转换,而线性表和树各有用途:
- 线性表在向KVM注册内存时,需要给KVM提供一个线性的内存区域,才可以请求KVM完成这段客户机物理内存的地址翻译以及EPT建立;
- 而树状结构方便QEMU调用MR对应的回调函数ops,当KVM截获了一个MMIO/PIO的读写操作,且需要返回到QEMU时,应该在对应的AS中查询MR树,得到对应的ops进行模拟;也方便QEMU根据GPA找到RAM类型MR对应的HVA,即完成GPA到HVA的转换,方便内存读写。
除了线性结构和树结构之间的转换,QEMU还需要同步线性结构和树结构。在AS中,struct FlatView类型的current_map是线性结构,而MemoryRegion类型的root是树状结构。由于树状结构和线性结构应该保存相同的内存拓扑结构信息,其中一个更改时,应该同步到另一个数据结构。然而,不会存在对线性结构进行修改的情况,只会对树状结构通过函数族 memory_region_* 对MR树进行修改。
当MR树被修改时,QEMU需要重新生成线性结构,再遍历AS的listeners链表,调用每个listener中的函数。每当生成了一个新的线性结构FlatView后,都需要重新告知KVM需要翻译的内存区域。显而易见,每次重新告知KVM新的内存拓扑需要进行 ioctl 调用,这是一个系统调用,开销很大,故QEMU将旧的FlatView保存在current_map中,比较新旧FlatView得出更改部分,仅告知KVM要更改的部分即可。
QEMU代码中创建了**四类AS,**包括:
- 全局变量address_space_memory,表示物理地址空间,其MR树根是大小为UINT64_MAX的system_memory;
- address_space_io,表示PIO空间,其MR树根是大小为65536(即0xffff)的system_io;
- 每个CPU都有一个名为cpu-memory-n的AS,其中n为从0开始的CPU编号,和address_space_memory一样,使用了system_memory作为MR树根;
- 外围设备角度的AS,例如VGA、e1000等模拟设备,它们的AS使用一个大小为UINT64_MAX的总线MR作为MR树根,并使用system_memory的别名MR作为MR树根的子MR,即可以将内存读写转发到system_memory对应的区域上。
AS数据结构提供了以下接口,供AS的使用者对AS进行创建、销毁与读写,其含义见注释:
qemu-4.1.1/include/exec/memory.h

此处省略了与缓存功能相关的接口,感兴趣的读者可自行查阅源码。其中有关读写的接口说明详见QEMU源码树的docs/devel/loads-stores.rst文档。后续将围绕这些AS管理函数,对AS的树结构与线性结构的同步,以及AS的读写、回调进行讲解。由于一些函数复杂度较高,这里只讲解重要的函数。AS相关操作均围绕着hwaddr(GPA)、void或uint8_t(HVA)之间的转换进行。
在memory.c文件中,还有几个全局变量在下文出现,总结如下。
qemu-4.1.1/memory.c

综上,AddressSpace相关数据结构之间的关系如下图所示,下面将围绕此图的各个部分展开介绍。

图中流程简述:①修改MR树;②生成扁平视图;③通知所有listeners;④ioctl系统调用进入KVM;⑤AddressSpace读写;⑥定位到MR并完成读写。
从树状结构到线性结构
这里介绍树状结构到线性结构的同步过程。为了将GPA翻译到HPA,QEMU通过 ioctl 系统调用,将AS对应的线性结构current_map注册到KVM中;当树状结构被修改时,QEMU需要重新生成新的current_map,并与旧的current_map进行比较,将更改的部分重新注册到KVM中。FlatView用于管理线性结构,定义如下。
qemu-4.1.1/include/exec/memory.h

每个AS都有一个对应的FlatView,它保存了AS内存拓扑的线性结构,并且承担了hwaddr的分派功能,即通过dispatch成员将hwaddr映射到对应的MR,后文介绍。FlatView中保存了FlatRange数组,是AS对应的线性结构,FlatRange定义如下:
qemu-4.1.1/memory.c

如果将FlatView的FlatRange数组按顺序铺开,就得到了一个分布在hwaddr地址空间上的线性结构,由一段段可能互不相邻的FlatRange组成。何时由MemoryRegion树状视图生成该线性视图? 经过分析,有两个时间节点:
- AS被初始化时,根据传入的MR树根生成线性视图current_map;
- AS对应的MR树被更改时,需要重新生成线性视图current_map。
首先分析AS初始化时如何生成FlatView,AS初始化函数定义如下。
qemu-4.1.1/memory.c

该函数首先初始化各个成员,包括current_map、root指针,并初始化listeners监听者链表。全局链表address_spaces负责将模拟客户机硬件所使用的所有AS链接起来,方便遍历所有的AS
address_update_topology 函数较为重要,该函数负责为新的AS生成FlatView,定义如下。
qemu-4.1.1/memory.c

- 调用
memory_region_get_flatview_root函数找到AS的物理MR根(简称physmr,物理MR,后文将多次用到),其目的是找到实体MR(而非别名MR)的树根。这样可以减少FlatView的个数,使得拥有相同的实体MR的AS共用一个FlatView,减少内存开销。由此可知,AS的FlatView与physmr绑定,而非与根MR绑定; - 调用
flatviews_init函数初始化全局变量flat_views,它是一个全局的哈希表,负责将MR映射到FlatView。在首次调用flatviews_init函数时被设置为新的空哈希表; - 调用
generate_memory_topology函数,生成physmr对应的FlatView。这是将树状结构转换为线性结构的核心函数,但由于其复杂程度较高,此处不进行详细分析。它首先初始化view,然后调用render_memory_region函数生成physmr对应的view,再调用flatview_simplify函数简化view。接下来的代码将根据生成的view填充地址分派器dispatch。最终,physmr到view的映射被存储在全局哈希表flat_views中。
至此,QEMU已经将树状结构转换为线性结构,接下来是告知所有监听者新的线性结构。在这里,只有一个KVM监听器,代码如下:
qemu-4.1.1/include/sysemu/kvm_int.h

KVMMemoryListener中的KVMSlot是QEMU中KVM的kvm_memory_slot对应的数据结构,负责向KVM注册内存;listener是通用监听器,定义如下:
qemu-4.1.1/include/exec/memory.h

通用监听器MemoryListener是一个函数指针的集合,并有一个priority成员表示其优先级,所有的listener在注册时按照优先级顺序连接到AS的监听者链表上。其中KVM监听器注册的代码如下:
qemu-4.1.1/accel/kvm/kvm-all.c

在KVM监听者中,region_add 函数指针指向 kvm_region_add 函数,最终调用 ioctl 函数完成内存的注册。
下面让我们回到AS的初始化函数address_space_init 中,生成线性视图view后继续调用 address_space_set_flatview 函数,将新生成的physmr对应的view告知所有监听者,并且将AS的current_map更新到新生成的view。此处调用的是KVM监听器的回调函数,从 address_space_set_flatview 函数讲起,代码如下:
qemu-4.1.1/memory.c

address_space_to_flatview函数首先找到旧的current_map,作为old_view,该情况下为NULL;- 再通过physmr在全局哈希表flat_views中查找新生成的physmr对应的view,作为new_view,将新旧view比较。在AS初始化时,如果新旧view不相同,则会将新旧view传给
address_space_update_topology_pass函数,从而告知所有的监听者线性结构的变化,最后将current_map更新为new_view。
其中,线性结构变化的单位是MemoryRegionSection,定义如下:
qemu-4.1.1/include/exec/memory.h

address_space_update_topology_pass 函数首先对比新旧FlatView,得出新旧FlatView之间的差别,用FlatRange的形式保存。对此FlatRange调用MEMORY_LISTENER_UPDATE_REGION函数将FlatView转化为MemoryRegionSection,准备好调用所有listener的region_add函数。最终遍历AS的listeners链表,使用MemoryRegionSection调用所有listener的region_add函数。此处只关注KVM的kvm_region_add函数,这在前文介绍监听器数据结构时已经介绍过。具体调用链如下:
qemu-4.1.1/memory.c

至此,QEMU已经完成了AS初始化工作。AS初始化时涉及的调用链如下图所示:

可以看到,初始化一个AS时,首先调用 generate_memory_topology 函数生成其physmr根对应的FlatView,再调用函数 address_space_set_flatview->address_space_update_topology_pass->kvm_region_add 通知KVM模块: 线性视图已经更改,需要重新向KVM使用ioctl函数注册内存,最终调用ioctl函数进入内核态的KVM模块中。
简而言之,重要的是两个函数:
generate_memory_topology函数,负责生成MR树对应的FlatView;address_space_set_flatview函数,负责将FlatView的更改通过address_space_update_topology_pass函数告知所有listener,其中包括KVMMemoryListener。
AS的初始化只是一个需要同步树状结构与线性结构的情况。在AS初始化之后,MR树被更新时也需要同步到FlatView,并通知KVM。
QEMU提供的多个操作MR的接口会更新MR树,如 memory_region_add_subregion 函数,QEMU都会使用MR事务机制完成FlatView的同步以及KVM监听器的通知,其大致调用链代码如下:
qemu-4.1.1/memory.c

可以看到,每次修改MR树都在 flatviews_reset 函数中重新生成了对应的FlatView,并且调用address_space_set_flatview 函数将新的FlatView注册到KVM中。简化的调用链如下图所示,类似于AS创建之后的调用链。

至此,QEMU完成了树状结构到线性结构的同步,并将线性结构注册到KVM中。QEMU进行了树状结构到线性结构的转化,将较为复杂的“策略”转换成一种简单的形式,可以调用KVM提供的“机制”完成QEMU/KVM的协同工作。
此时,当客户机访问一个“虚拟”物理地址GPA时,如果该GPA不是用于MMIO,那么MMU都会查询KVM所维护的EPT得到其对应的“真实”物理地址,客户机访问这部分“虚拟”物理内存将不会引起VM-Exit,从而完成高效的内存虚拟化。对于MMIO区域的GPA,QEMU是如何与KVM协作的呢?
客户机物理内存地址的分派
对于实现MMIO的MR,在KVMMemoryListener对它进行KVM内存注册时,注册函数 kvm_region_add 将其识别为MMIO对应的“虚拟”物理内存区,没有对应的宿主机虚拟地址,就不会将其提交到KVM中。如果客户机访问了这段内存,KVM会识别这是MMIO对应的内存区,最终返回到QEMU的 ioctl(KVM_RUN) 系统调用之后。对于PIO的处理是类似的,也会退出到QEMU的 ioctl(KVM_RUN) 系统调用之后。调用代码如下:
qemu-4.1.1/accel/kvm/kvm-all.c

可以看到,在退出到QEMU之后,需要对QEMU模拟MMIO/PIO所使用的AS数据结构进行读写,使用的是前文介绍的AS读写函数address_space_rw。
所谓客户机物理内存地址的分派是指,将对AS读写的地址分派到对应的MemoryRegion上,调用MR所包含的处理函数进行MMIO/PIO模拟。事实上,如果给定一个hwaddr,可以在MR树中搜索其对应的MR,但这样做无疑是很低效的。为此,QEMU引入了一个类似于页表的结构完成地址转换,即 struct AddressSpaceDispatch,其复杂程度较高,不进行深入分析。数据结构关系如下:
qemu-4.1.1/include/exec/memory.h

AS的线性视图(扁平视图)中保存了struct AddressSpaceDispatch的指针,定义如下:
qemu-4.1.1/exec.c

每个AS有一个独立的AddressSpaceDispatch,作为AS内存地址分派的页表,其叶子结点的页表项指向MemoryRegionSection,查询该页表的结果是MemoryRegionSection。在AddressSpaceDispatch中,mru_section保存了最近一次的查询结果,作用类似于TLB;map是一个多级页表,即能够快速查找,又不会占用过多内存。phys_map起到了CR3的作用,PhyPageMap中的nodes表示页表的中间节点,sections等同于物理页的作用。
由于AddressSpaceDispatch实现较为复杂,这里只关注该数据结构提供的接口。正如前文提到的,AS对应的dispatch在生成线性视图时初始化,并填入FlatView的dispatch字段,即在前文提到的核心函数 generate_memory_topology 中初始化,代码如下:
qemu-4.1.1/memory.c

在生成AS的FlatRange数组之后,QEMU将FlatRange数组中每个元素转换成其对应的MemoryRegionSection,并调用 flatview_add_to_dispatch 函数填入页表AddressSpaceDispatch中。这意味着,只要生成了FlatRange,QEMU就可以使用AddressSpaceDispatch查找一个AS中hwaddr(GPA)所在的MR,从而得出GPA对应的HVA。
address_space_rw 函数使用页表AddressSpaceDispatch完成地址转换,定义如下:
qemu-4.1.1/exec.c

这里出现了一个MemTxResult类型,QEMU将对AS的读写视为一次MR事务,其返回结果MemTxResult是一个uint32_t类型的变量,定义在include/exec/memattrs.h中,可以取MEMTX_OK、MEMTX_ERROR等值。对AS进行读写时,首先原子地读取AS的current_map,即当前的线性结构;再调用 flatview_read/write 函数对线性结构fv进行读写。此处用 flatview_read 函数作为例子进行说明,其定义如下:
qemu-4.1.1/exec.c

可以看到,flatview_read 函数不断调用 flatview_translate 函数,通过FlatView内部的页表AddressSpaceDispatch得到一个hwaddr地址对应的MemoryRegion,再进行MemoryRegion对应的模拟。对于I/O类型的MR,最终调用 ops->read 函数完成读取的模拟;对于RAM类型的MR,则找到hwaddr addr对应的HVA,记录在ptr指针中,调用memcpy完成读取。客户机物理内存地址分派的调用链如下图所示:

至此,已经介绍了QEMU中内存虚拟化相关的大部分数据结构及其操作接口之间的关系。总结如下:
- AddressSpace是顶层数据结构,将MemoryRegion树和FlatView线性结构组织起来,形成一个可供读写的地址空间;
- MemoryRegion中的容器类型和别名类型分别模拟了真实系统中的总线和内存控制器,将I/O类型的MR和RAM类型的MR通过树的形式组织起来。
- I/O类型的MR提供了一组ops回调函数,供QEMU实现物理硬件的模拟;
- 而RAM类型的MR对应宿主机上的一段虚拟地址HVA,作为客户机的“虚拟”物理地址,QEMU最终将这段地址注册到KVM中完成GPA到HPA的翻译;
- FlatView保存了MemoryRegion树对应的线性结构FlatRange,供QEMU将其转换为MemoryRegionSection注册到KVM中;还保存了地址分派器AddressSpaceDispatch,负责将GPA翻译为HVA。
下一节将展示运行过程中这些数据结构的组织形式,可以有更加直观的认识。
实验: 打印MemoryRegion树
QEMU为了模拟MMIO以及物理设备的行为,形成了一套复杂的数据结构,但这些只是静态的代码。本节将QEMU代码运行起来,在动态过程中打印出MemoryRegion树,更形象地展示数据结构之间的关系。
实验使用从源代码编译的QEMU v4.1.1,以及事先准备好的客户机磁盘镜像作为QEMU的-hda参数传递给QEMU。首先,使用如下命令进入QEMU监视器:

启动命令的含义为:将QEMU管理器的输入输出重定向到字符设备stdio(-monitor stdio),即此处的命令行。
此命令启动了2个vCPU -smp 2,使用NUMA(Non-Uniform Memory Access,非统一内存访问)架构,分为两个NUMA节点 -numa node,分配 4 GB的“虚拟”物理内存 -m 4096 ;开启KVM支持,并使用与宿主机一样的CPU型号 -cpu host--enable-kvm。
接下来,使用命令 info mtree 打印此客户机的MemoryRegion树,在输出中,QEMU用不同宽度的缩进表示不同树的深度,打印如下:



此处省略了被标为 [disabled] 的MR,以及一些陌生的MR。可以看到,整个虚拟机有address_space_memory作为物理内存空间的AS,有address_space_io作为PIO端口映射空间的AS。由于本实验启动了 2 个vCPU,所以这里打印出了两个CPU的AS,即 cpu-memory-0/1。其他的AS包括从设备角度可以观察到的AS,如e1000、VGA等设备的AS。
每个AS下显示了AS的MR树,其中非别名类型的MR只能打印出一条较短的记录,包含其地址范围。如address_space_memory的MR树根system_memory,其地址范围是0x00000000000000000xffffffffffffffff,即0UINT64_MAX;而别名MR会被明确标识为alias,并追加上其alias指针指向的原MR。有关 info mtree 命令的实现函数,请查阅QEMU源码树memory.c文件的 mtree_info -> mtree_print_mr 函数。
为了与源码相对应,继续在QEMU源码中寻找这些AS和MR被创建的位置,具体方法多种多样。一种直接的方法是在源码中搜索相关的创建函数,如 address_space_init、memory_region_init,更严谨的方法是通过GDB打断点的方式寻找。
首先,在QEMU的main函数中,cpu_exec_init_all 函数初始化了主要的AS以及MR树根,代码如下:
qemu-4.1.1/exec.c

在这里,QEMU初始化了 system_memory/system_io 等静态变量,作为两个全局AS变量 address_space_memory/address_space_io 的MR树根。这里初始化了与体系结构无关的AS,下面进入i386的模拟部分中与初始化架构相关的部分。在不同类型的PC_MACHINE的定义函数中,也会初始化AS/MR等数据结构,以 pc_init1 函数为例,代码如下:
qemu-4.1.1/hw/i386/pc_piix.c

可以看到,pc_init1 函数首先初始化了PCI MR,继续调用 pc_memory_init 函数,初始化了真实的全局物理内存pc.ram MR,并将其分为两个别名MR,即 ram_below_4g/ram_above_4g,并作为子MR加入了system_memory。
在解析QEMU参数时,QEMU读取到 -m 参数后的数字,并将其保存在machine->ram_size中,作为初始化pc.ram MR的大小,即物理内存的大小。在分配全局pc.ram MR时,QEMU将NUMA和非NUMA的情况分类。非NUMA的情况下,直接分配一个RAM类型的实体MR即可;而NUMA情况下,需要调用 host_memory_backend_get_memory 函数得到每个NUMA节点对应的MR,并作为子MR加入pc.ram MR中。这与之前info mtree打印出来的MR树相符合。
3.3 KVM内存数据结构
相比于 “策略” 的实现,“机制” 的实现往往更加简单。QEMU内存虚拟化需要完成的功能较多,包括宿主机虚拟内存的分配、MMIO的模拟、HVA到GPA的翻译等,而**KVM内存虚拟化只需要维护好EPT页表,并与硬件配合即可。**同时,由于KVM是Linux内核中的一个内核模块,它可以重用Linux内核的内存管理接口,降低了实现的难度。
本节不讨论由于性能不佳而不常采用的影子页表的实现,只讨论Intel x86架构下的扩展页表EPT的维护。在Linux源码树的Documentation目录下,描述内核代码的 Documentation/virtual/kvm/mmu.txt 文档有对KVM内存管理模块较为全面规范的描述,但较难理解。下文将抽取主线,使叙述更易懂。KVM中相关的数据结构相比于QEMU更简洁,如下图所示。

KVM在内存虚拟化方面的工作流程大致为:
- 接收QEMU的内存注册;
- 创建vCPU的虚拟MMU;
- EPT的建立;
下面对每个步骤展开分析。
接收QEMU的内存注册
首先,QEMU应该给KVM注册需要其做地址翻译的 “虚拟” 物理内存,否则KVM所维护的EPT页表将无用武之地,因此从KVM接收QEMU的内存注册开始讲起。
前文提到,当MemoryRegion树被更改后,都会通知所有的listener,其中包括KVM的监听器KVMMemoryListener,调用ioctl完成KVM内存注册。注册的基本单位是如下数据结构,包含GPA、HVA、该段内存的大小等字段,与QEMU中的线性视图相类似。
linux-4.19.0/include/uapi/linux/kvm.h

QEMU进行ioctl系统调用后进入内核的 kvm_vm_ioctl 函数,并根据ioctl的参数调用相应的处理函数。此时ioctl参数是KVM_SET_USER_MEMORY_REGION,代码流程如下:
linux-4.19.0/virt/kvm/kvm_main.c

由于QEMU中的kvm_userspace_memory_region处在用户态,因此内核态的KVM需要使用 copy_from_user 函数将其中的数据复制到内核态。最终进入 __kvm_set_memory_region 函数,将 kvm_userspace_memory_region 注册到KVM中,而KVM中保存客户机内存线性视图的结构是kvm_memory_region,用户态QEMU传来的数据需要转化为该数据结构进行保存,定义如下:
linux-4.19.0/include/linux/kvm_host.h

可以看到,每个KVM虚拟机对应的struct kvm结构体都保存了一个memslots,其中保存了kvm_memory_slot的数组。这是KVM中唯一保存客户机物理页信息的位置,与QEMU不同,KVM仅有一个客户机物理内存的线性视图,保存了所有客户机物理内存的相关信息。
在进入KVM的内存注册ioctl后,_kvm_set_memory_region 函数将使用用户态传来的结构体kvm_userspace_memory_region更新kvm的memslots,更新类型为enum kvm_mr_change。__kvm_set_memory_region 函数定义如下:
linux-4.19.0/virt/kvm/kvm_main.c

该函数首先得到要插入位置的旧memslot,与用户态传来的新memslot对比,得出change的类型。如果QEMU添加新的memslot,那么就会进入KVM_MR_CREATE的分支,执行架构相关函数 kvm_arch_create_memslot 填充新的memslot。准备好新的memslot后,就调用update_memslots 函数将新memslot填入slots数组,并保持数组的排序(base_gfn从大到小),最终原子地将slots填入kvm中。
KVM维护x86架构相关的客户机物理页信息,包含 kvm_rmap_head 以及 kvm_lpage_info,代码如下:

针对x86架构,每个客户机的 “虚拟” 物理页都有相关的信息,保存在memslot的arch成员中。EPT中最后一级页表可以是第3级页表,映射一个1GB的大页;可以是第2级页表,映射一个2MB的大页;也可以是通常情况的第1级页表,映射一个4KB的页。因此在arch结构体中,KVM将memslot管理的这段“虚拟”物理内存按照不同大小的页面分割,共上述3种情况(1GB、2MB、4KB),形成不同个数的页面(如1GB的内存区域按照1GB分割,则只有1页;按照2MB分割,则有512页)。下面代码填充每个页面的相关信息,分为KVM_NR_PAGE_SIZES种页面大小的情况。
linux-4.19.0/arch/x86/kvm/x86.c

这里,每个“虚拟”物理页都有两个信息:
- 映射该gfn的所有spte(EPT页表项),保存在arch.rmap数组中,可以以spte链表的形式存在,每个页面都有对应的链表。该链表负责在某HVA处的页面被换出时,将所有与该HVA对应的spte置为无效。这种反向映射机制在Linux内核中也存在;
- 保存该gfn处是否可以使用大页,不做深入分析。
可以看到,KVM已经保存了所有客户机 “虚拟” 物理内存页面的信息,存在于memslots成员中,KVM维护EPT页表时将完全基于memslots数据结构。下面分析KVM如何维护EPT页表,与MMU硬件协同完成地址翻译。
创建vCPU的虚拟MMU
继续介绍EPT页表页的创建与管理,虚拟MMU相关数据结构如下图所示:

其中需要着重关注的是 struct kvm_mmu_page,它管理了一个EPT页表页的所有信息,vCPU虚拟MMU的主要工作是维护EPT页表中每个页表页的 struct kvm_mmu_page。
**为了管理EPT页表页,KVM使用了 struct kvm_mmu_page 数据结构保存一个EPT页表页的相关信息,又使用 struct kvm_mmu 数据结构保存与内存管理相关的函数模拟硬件MMU。**每个vCPU都有一个虚拟的MMU,且MMU的模拟依赖架构,因此保存在 struct kvm_vcpu_arch 数据结构中。
注意区分,
struct kvm_arch保存了整个客户机的架构相关信息,而struct kvm_vcpu_arch保存了每个vCPU的架构相关信息。简而言之,虚拟MMU保存在vCPU的架构相关部分中,虚拟MMU的root_hpa指向kvm_mmu_page(后文介绍)组成的页表。
以下为这些数据结构的代码:
linux-4.19.0/arch/x86/include/asm/kvm_host.h

在KVM中有一个全局变量 enable_ept 决定是否开启EPT模式。如果 enable_ept 为1,则启用EPT模式,最终将全局变量 tdp_enabled(在arch/x86/kvm/mmu.c中)置为true。事实上还需要读取VMCS的相关配置域才能决定是否将 enable_ept 置为1,简洁起见本节省略这部分的介绍。下面是将 tdp_enable 置为true的代码。
linux-4.19.0/arch/x86/kvm/vmx.c

在此之后创建vCPU时,将完成基于EPT(即tdp)的虚拟MMU初始化。虚拟MMU的初始化分为 kvm_mmu_create/kvm_mmu_setup 两步:
- 虚拟MMU创建
- EPT基地址设置
MMU创建流程
整体代码如下:

虚拟MMU的初始化与vCPU的初始化绑定,即一个vCPU有一个虚拟MMU。可以看到,虚拟MMU事实上包含了一组与tdp相关的页表管理函数,包括缺页异常处理函数 tdp_page_fault、页表基地址设置函数 set_tdp_cr3(即 vmx_set_cr3),以及虚拟MMU的相关属性的设置。
完成虚拟MMU的创建后,下文介绍KVM如何维护 EPTP 和客户机 CR3。
EPT基地址设置流程
EPT页表页 kvm_mmu_page 与普通进程的页表页一样,在缺页异常中创建。但这不是一般的缺页异常,而是客户机引发的VM-Exit,是一个由Intel硬件提供的特性,即 EPT PAGE FAULT。该VM-Exit将使客户机暂停执行并进入KVM,使得KVM有机会完善EPT。为了配合硬件,KVM需要将EPT的基地址写入VMCS,让硬件MMU自动访问该EPT页表,当硬件MMU发现该页表存在不完整的情况时,将产生VM-Exit,并调用虚拟MMU的相关函数。
接上文对VM文件描述符的 KVM_CREATE_VCPU 调用,QEMU将对vCPU对应的文件描述符进行 KVM_RUN 调用,运行刚刚初始化并填充好的vCPU,流程如下:

可以看到,vcpu_run 函数中出现了无限for循环,保证在运行vCPU退出后,完成KVM的处理继续运行vCPU。在该循环中,运行vCPU前调用kvm_mmu_reload 函数,如果root_hpa尚未初始化(指向INVALID_PAGE),则调用 kvm_mmu_load 函数初始化root_hpa,其初始化工作主要包括:
- 调用
mmu_topup_memory_caches函数保证arch中的 3 个cache充足; - 为root_hpa分配一个kvm_mmu_page,作为EPT的第4级页表页,页表页的分配将在下文介绍;
- 根据上一步分配的EPT第4级页表页的物理地址,创建EPTP,写入VMCS的EPTP域中,EPTP域的详细文档见Intel SDM的卷3第24.6.11节;还要从arch结构中读取客户机CR3,写入VMCS的GUEST_CR3域中。这样在物理CPU进入非根模式后,就可以使用该EPTP进行GPA到HPA的翻译;
- 刷新TLB,有三种方式,不详细介绍。
综上,KVM已经准备好进入非根模式,执行 kvm_x86_ops->run 函数。下面介绍EPT页表页及其创建过程,EPT页表的创建和维护过程均在kvm_x86_ops->handle_exit 函数中进行。
EPT的建立
KVM从QEMU得到了需要GPA到HPA翻译的所有 kvm_memory_slots,设置好EPTP后,MMU硬件就可以截获GPA地址的访问,使客户机退出到KVM中,建立GPA相关的EPT页表。这里介绍EPT的建立与管理,首先介绍管理EPT页表页的数据结构 kvm_mmu_page,它定义如下:
linux-4.19.0/arch/x86/include/asm/kvm_host.h

该结构维护了一个 spt 指针关联的信息,spt 是指向页表页的指针,是一个HVA。spt 指向的4KB大小的页面即是EPT页表页,保存了512个页表项。KVM在 struct kvm_arch 中保存了一个active_mmu_pages链表,将所有的kvm_mmu_page链接起来,可以当页表页版本号mmu_valid_gen与arch中的mmu_valid_gen不同时,释放页表页(通过其操作接口 kvm_mmu_free_page 函数)。这样KVM就做到了内存占用尽可能小,这和mmu.txt文档中KVM内存虚拟化设计原则相符合。
创建页表页的时机在于发生EPT Violation时,CPU进入根模式运行KVM。在这里,KVM执行如下虚拟MMU的缺页异常处理函数:
linux-4.19.0/arch/x86/kvm/mmu.c

退出原因是EXIT_REASON_EPT_VIOLATION,于是调用 handle_ept_violation 函数。VMCS保存了该缺页异常的相关信息,包括造成缺页异常的gpa、缺页异常的类型error_code等。如果error_code表示需要处理MMIO类型的EPT Violation,则调用 handle_mmio_page_fault 函数。在EPT页表缺页的情况下,调用 tdp_page_fault 函数完善EPT。
这里忽略所有与大页相关的代码,感兴趣的读者可以在介绍的基础上研究大页相关代码,进行“增量式”学习。
tdp_page_fault 函数主要代码如下:
linux-4.19.0/arch/x86/kvm/mmu.c

tdp_page_fault 函数较为复杂,涉及多个Linux内核子系统,下面分步介绍。
该函数首先填充三个缓存池,每次预分配都分别增加16、8、4个预备的对象。这三个缓存池分别用于pte链表(反向映射中一个gfn对应的所有pte的链表,以及一个pte的parent_ptes链表)、EPT页表页、EPT页表页头(即描述EPT页表页的struct kvm_mmu_page)的分配,这三类对象的分配在KVM中很频繁,因此缓存池能够通过合并分配提高分配效率。
这里忽略大页管理系统,假定EPT中不映射大页,mapping_level函数将返回1。接下来,尝试缺页异常处理的快速路径,调用fast_page_fault函数。此函数和KVM脏页管理系统有关,回忆当QEMU注册memslot时,可以使用KVM_MEM_LOG_DIRTY_PAGES标志,它表示需要对这段内存进行脏页跟踪,多用于QEMU虚拟机热迁移的实现。当QEMU使用该标志进行内存注册时,KVM将会把这段内存对应的所有EPT表项置为只读,这涉及内存页的反向映射系统。于是,该页表项指向的物理页已经分配,只需要将该页表项修改为可写就可继续正常执行客户机代码,而不再产生EPT Violation,不需要执行后面的真实缺页异常处理。
try_async_pf函数负责检查客户机访问的GPA是否在KVM的memslots中,如果在,则分配宿主机物理页面,得到pfn。EPT将GPA翻译为HPA,那么为了填充EPT表项,一个客户机物理页(GPA)必将对应一个宿主机物理页(HPA)。但是,如果该宿主机内存页已经换出到磁盘上,则会使客户机vCPU停滞较长的一段时间。为此,KVM实现了异步缺页异常,当一个vCPU访问了被换出的宿主机物理页(这里借助了宿主机Linux内核的内存管理相关接口,不做深入介绍)时,则会将vCPU挂起,并执行另一个vCPU,等待被换出的页加载到内存中。加载完毕后,被挂起的vCPU则会从try_async_pf函数中返回,重新执行引起缺页异常的客户机指令。该函数的另一个任务是实现MMIO,检查参数gfn是否在KVM的memslots中,如果不在,则向pfn中写入KVM_PFN_NOSLOT,最后进入__direct_map函数的set_mmio_spte函数中,将EPT中这段客户机物理内存对应的部分标为特殊值,以后的读写都会引起EPT Misconfiguration的VM-Exit。至此,KVM获得了pfn(类型为kvm_pfn_t,表示HPA),交由下一步构建EPT表项、填充EPT使用。
进入修改EPT的代码区,由于多个vCPU线程以及MMU通知器(MMU Notifier,当Linux内核将虚拟内存页换出到磁盘上时将收到通知,KVM也注册了一个这样的通知器,其具体作用是在Linux内核换出客户机内存页时修改EPT)可能会同时修改EPT,需要加kvm的mmu_lock锁。
mmu_notifier_retry函数让MMU通知器线程优先执行,放弃vCPU的EPT填充。接下来,make_mmu_pages_available函数将无用的EPT页表页释放,与之前填充EPT页的分配缓存池相互照应。这里也用到了反向映射系统。__direct_map函数实际填充了EPT四级页表,其定义如下:inux-4.19.0/arch/x86/kvm/mmu.c

该函数根据前面步骤得到的gfn(客户机物理页号)以及pfn(宿主机物理页号)完成EPT中gfn对应部分的建立。for_each_shadow_entry宏负责遍历四级页表,其参数是客户机物理地址
gfn<<PAGE_SHIFT和遍历光标iterator。在C++11语法中,iterator承载了指针的作用,且方便了对一个数据结构的遍历。而此处的iterator(即读取EPT页表时指向EPT的指针)中存储了读取页表时的相关参数,方便了EPT的操作。详细内容见注释,此处仅关注其中的level、addr和sptep。linux-4.19.0/arch/x86/kvm/mmu.c

在此处的讨论中,for_each_shadow_entry宏会执行4次,由于忽略了大页,EPT共有四级页表。分两种情况:
- 遍历到的页表页是页表的中间节点,如果当前页表项
*iterator.sptep不存在,则调用kvm_mmu_get_page函数获取一个kvm_mmu_page,并调用link_shadow_page函数将当前sptep指向新的页表页; - 如果遍历到的是最后一级页表,则调用
mmu_set_spte函数将最后一级页表项指向新的pfn。这样就建立了映射gfn到pfn的EPT。
- 遍历到的页表页是页表的中间节点,如果当前页表项
最终释放mmu_lock,如果更改EPT页表过程中的某一步失败,还需要释放由
try_async_pf函数分配的pfn处的宿主机物理页。
至此,KVM完成了gpa对应的EPT四级页表的填充。
实验: 将GVA翻译为HPA
实验概述
作为内存虚拟化的总结以及KVM内存虚拟化源码分析的拓展,本节进行GVA到HPA的翻译实验。内存虚拟化的核心是地址翻译,即将某一个地址空间的地址转换为下层地址空间的地址。如前文所述,地址翻译由MMU硬件完成,首先使用客户机进程页表GPT将GVA翻译为GPA,再由扩展页表EPT将GPA翻译为HPA。由于无法观察硬件的地址翻译过程,于是本节借助内核提供的页表访问接口,通过编写软件模拟MMU的功能。
为了证明内存翻译代码运行的正确性,首先在GVA处写入一个int类型的变量,并在最后得到的HPA处对该int类型变量进行读取,如果写入的变量和读到的变量值相同,那么证明地址翻译正确。本实验实现的软件MMU分为两部分:
- 客户机中的地址翻译模块作为客户机中运行的内核模块,首先在GVA处写入一个int变量
0xdeadbeef,再通过读取GPT的方式将GVA翻译成GPA,最后通过超级调用将GPA传递给宿主机操作系统; - 宿主机中的KVM内核模块将截获到该超级调用,得到客户机传来的GPA,通过读取EPT的方式进一步翻译成HPA。为了读取HPA处的变量,还需要使用内核提供的接口做一次HPA到HVA的转化,这是因为分页模式开启,访存指令中的地址均是HVA,无法使用HPA直接访问物理内存。最终读取HVA处的变量,将读取到的值与客户机写入的值进行比较,如果也是
0xdeadbeef,则能够证明地址翻译的正确性。
下文分别介绍**客户机的地址翻译与宿主机的地址翻译,**形成一个整体,使读者了解地址翻译的流程,流程如下图所示。

注:①调用kmalloc函数得到GVA;②查询GPT;③打印GPT相关表项;④查询GPT,得到GPA;⑤通过超级调用将GPA传入KVM,查询EPT;⑥打印EPT相关表项;⑦查询EPT,得到HPA。
首先说明实验的运行环境:宿主机CPU型号是IntelCore i7-6500U,频率2.50GHz,QEMU为客户机提供了与宿主机相同型号的CPU。宿主机物理内存大小为7.5GB,客户机物理内存为4GB。本节宿主机和客户机均使用Linux v4.19.0内核,并使用从源码编译的QEMU v4.1.1。再说明实验准备,读者应该事先制作好一个客户机磁盘镜像,作为QEMU的-hda参数进行读取,使用QEMU启动一个客户机。
实验相关过程与脚本见代码仓库:https://github.com/GiantVM/book 。
客户机中的地址转换: GVA到GPA
为了读取Linux操作系统的页表,需要在内核态编写代码。使用Linux内核模块是编写内核代码的一种简单的方式。整体操作流程如下:
- 内核模块的源码可以仅由一个.c文件组成,在实验里是gpt-dump.c,只需要编写一个Makefile(编译命令文件)对gpt-dump.c进行编译,得到gpt-dump.ko文件;
- 再使用命令
sudo insmod gpt-dump.ko即可将内核模块插入内核; - 内核模块插入内核后,就会调用gpt-dump.c中定义的
init_module函数;而使用命令sudo insmod gpt-dump.ko将内核模块移出内核时,则会调用cleanup_module函数。在init_module函数中即可编写内核代码在内核态运行,可以访问页表。
虚拟地址和物理地址在Linux内核中均使用64位的变量表示,而在Intel Core i7系统中,虚拟地址空间为48位,共256TB大小,物理地址空间为52位,共4PB(4096TB)大小。一个页表项的大小为64位,即8字节,一个页表页有512个页表项,页表页大小为4KB,故需要虚拟地址中的log2(512)=9 位索引每级的页表页。
Linux内核使用4级页表,用虚拟地址的第47:39位索引第4级页表,第38:30位索引第3级页表,第29:21位索引第2级页表,第20:12位索引第1级页表。这样就形成了前文所述的
9+9+9+9形式的四级页表。
虚拟地址使用第47:12位存储页表的索引,第11:0位存储虚拟地址在页中的偏移,因此查询页表只使用了虚拟地址的前48位,即访问虚拟地址空间仅使用了48位的虚拟地址。此处定义宏 UL_TO_VADDR以/VADDR_PATTERN 打印虚拟地址,包含页表的索引位(共36位),以及偏移(共12位)。部分相关宏定义如下。
gpt-dump/gpt-dump.c

由于内核中尚没有将变量转化为二进制表示的函数,此处编写 pr_info 函数,接收不同的 %c 格式化字符串和 0/1 字符的组合来打印虚拟地址、物理地址以及页表项。以虚拟地址的打印为例,首先定义输出3个位的字符组合,即 TBYTE_TO_BINARY,还有对应的打印三个char类型的格式化字符串 TBYTE_TO_BINARY_PATTERN。接下来,需要将虚拟地址的低12位(即偏移的12个位)全部打印出来。继续对代表虚拟地址的ulong(64位的变量)使用 TBYTE_TO_BINARY,得到其低3位;再将ulong右移3位,并使用宏 TBYTE_TO_BINARY,得到其5:3位,以此类推,可以得到所有形式的 0/1 字符组合,包括宏 UL_TO_PTE_OFFSET 负责输出12位,UL_TO_PTE_INDEX 负责输出9位。对于 pr_info 的格式化字符串,实现方式类似,只需在合适的位置加上空格,便于观察。在内核模块初始化函数中,首先调用 print_ptr_vaddr 打印虚拟地址,输出如下:
gpt-dump/gpt-dump.txt

可以看到四级页表的四个索引值,以及页内偏移的二进制表示。
为了获得一个GVA,在模块初始化函数中,首先调用 kmalloc 函数生成一个int类型变量的指针,由于内核模块运行在客户机内核中,所以该指针包含一个GVA。在上面的代码片段中,客户机内核模块在该指针处写入数字:0xdeadbeef,期望在GVA对应的HVA处读到该数字。在输出中可以看到四级页表的索引,得知在页表页中应该读取第几个页表项。接下来,内核模块找到客户机内核线程的 CR3,并将其传入页表打印函数。下面是客户机内核模块的相关代码:
gpt-dump/gpt-dump.c

current->mm->pgd 是当前进程current的CR3,指向PGD(Page Global Directory,页全局目录)页表页的起始地址。dump_pgd 函数负责遍历pgd页表页的PTRS_PER_PGD个页表项,这里是512个页表项。
pgd是一个pgd_t类型的变量,内核还提供了类似的pud_t、pmd_t、pte_t数据结构表示每级页表的页表项,以及操作这些数据结构的接口。
此处使用这些接口获取页表项的含义,如 pgd_val 函数返回该页表项的值,即该页表项对应的unsigned long变量;pgd_present 函数检查该页表项的第0位,返回该页表项是否有效;pgd_large 函数返回该页表项是否指向1GB的大页。
本节忽略大页的情况,我们可以使用内核参数关闭大页。这里定义了全局变量保存的虚拟地址和对应的物理地址,以及各级页表索引。pgd_idx表示从64位虚拟地址中获得的PGD页表索引。接下来,如果PGD页表页的第 pgd_idx 个页表项存在,那么调用 pr_pte 函数打印该页表项,此函数定义如下:
gpt-dump/gpt-dump.c

参数 address 表示本级页表页的起始地址,从上一级页表项获得,pte 表示本级页表项。宏PTE_PATTREN用于打印一个页表项。继续查询下一级页表的函数调用链为 dump_pgd -> dump_pud -> dump_pmd -> dump_pte,其中每一步的逻辑大致相同。address和pte的打印结果如下。
gpt-dump/gpt-dump.txt

从加粗的部分可以看到,客户机内核模块对页表页的地址调用 _pa 函数获得其物理地址,和上一级页表项中保存的下一级页表页的物理地址完全相同,这符合预期。最终,就可以从PTE中获得物理地址,为了验证正确性,客户机内核模块对vaddr调用 __pa 函数,打印出GPA开头的行,具体在 print_pa_check 函数中实现,代码如下。
gpt-dump/gpt-dump.c

可以看到,和之前获得的PTE中的物理地址相同,说明读取页表的结果正确。其中低12位是页内偏移,物理地址和虚拟地址中的页内偏移完全相同。最后,客户机内核模块将调用 kvm_hypercall1(22,paddr) 函数,将paddr传给KVM。
KVM中的地址转换: GPA到HPA
KVM负责维护EPT,具有读取EPT的权限。本实验修改宿主机内核的KVM模块,**增加一个超级调用处理函数,**接收从客户机传来的GPA,模拟GPA到HPA的翻译。
当客户机执行了一个敏感非特权指令时,会引起CPU的VM-Exit,CPU的执行模式从非根模式转换为根模式,并进入KVM的VM-Exit处理函数。kvm_hypercall1 函数最终执行vmcall指令,陷入KVM,KVM得知VM-Exit的原因是客户机执行了vmcall指令,编号为EXIT_REASON_VMCALL,于是调用如下 handle_vmcall 处理函数。
linux-4.19.0/arch/x86/kvm/vmx.c

函数 handle_vmcall 调用超级调用模拟函数 kvm_emulate_hypercall,代码如下。

其中,nr是 kvm_hypercall1 函数的第一个参数,a0是第二个参数。nr表示应该调用哪个超级调用模拟函数,定义KVM_HC_DUMP_SPT为22,表示打印客户机内核线程页表对应的EPT。首先调用 print_gpa_from_guest 函数打印客户机传来的GPA,并从GPA中获取每级EPT页表的索引,保存在全局变量 pxx_idx[4] 中,print_gpa_from_guest 函数的打印结果如下。
gpt-dump/ept-dump.txt

可以看到,此处的GPA与在客户机中读取GPT得到的GPA相同,说明客户机内核模块的超级调用成功。接下来调用 mmu_spte_walk 函数遍历此vCPU的EPT,在代码中称作spt,这是为了和影子页表共用一套代码。传入 mmu_spte_walk 函数的参数有vcpu,以及遍历到一个页表项时所调用函数的指针pr_spte,负责打印页表项。遍历代码如下。
linux-4.19.0/arch/x86/kvm/mmu.c

vcpu->arch.mmu.root_hpa 保存了EPT的基地址,初始化时被设为INVALID_PAGE。mmu_spte_walk 函数首先判断vcpu->arch.mmu.root_hpa是否是无效页INVALID_PAGE,如果是,则说明vCPU对应的EPT尚未建立,无法遍历。如果EPT的级数大于PT64_ROOT_4LEVEL,则调用递归函数__mmu_spte_walk 遍历页表。
page_header 函数返回一个hpa_t变量所指向页表页的 kvm_mmu_page 结构的指针。于是,KVM将EPT第4级页表页的kvm_mmu_page结构传入_mmu_spte_walk 函数,并且从level=1开始遍历EPT。
和查询GPT一样,KVM遍历页表页中的每一个页表项,如果页表项的索引等于之前 print_gpa_from_guest 函数中获得的pxx_idx中对应的索引,那么此页表项就是目标页表项,并将它传入fn函数进行打印。如果查询到的页表项不是最后一级,则继续递归调用 __mmu_spte_walk 函数查询下一级页表。在这里,将fn置为打印EPT页表项的函数,如下打印格式与GPT相同。
gpt-dump/ept-dump.txt

由于本实验没有关闭宿主机上的大页,KVM在查询到最后一级页表时做了两种处理:如果遍历到大页,则调用
print_huge_pte函数打印最后获取HPA的过程;否则调用print_pte函数。
具体的打印代码不再赘述,下面只展示最后如何使用代码得到HPA。
linux-4.19.0/arch/x86/kvm/mmu.c

**对于2MB大页的情况,**最后一级页表项是PDE,这类页表项的第51:21位表示大页的起始物理地址,操作如下:
- 这里使用PT64_DIR_BASE_ADDR_MASK宏从ent页表项中获取大页的起始物理地址;
- 接下来,使用
PT64_LVL_OFFSET_MASK(2)|(PAGE_SIZE-1)获取GPA中的大页偏移的部分,即20:0位; - 最终,结合大页起始地址和大页偏移得到HPA,最后调用
__va函数获取对应的HVA,并解引用该HVA,读取到数据0xdeadbeef,符合预期。
**对于普通4KB页的情况,**PTE中的第51:12位表示其指向的物理页的起始地址,操作如下:
- 使用PT64_BASE_ADDR_MASK宏从PTE中获取页的物理地址;
- 再使用PAGE_SIZE-1从GPA中获取页偏移,即11:0位;
- 最后结合页的物理地址和页偏移得到GPA,最后调用
__va函数得到HVA,并解引用该HVA,读取到数据0xdeadbeef,符合预期。
综上所述,客户机内核模块在一个GVA处写入了0xdeadbeef,读取客户机页表得到GVA对应的GPA,通过超级调用传递GPA到KVM模块,KVM读取EPT将GPA翻译成HPA,最后通过_va函数找到HPA对应的HVA,并读取到0xdeadbeef,表明HPA处确实存储了GVA处的数据,地址翻译成功。在翻译过程中,实验代码打印了地址翻译所涉及的页表项、GPA、HPA等,环环相扣。其中,ept-dump.txt文件存储了完整的输出信息。
最后整理于 2025-11-11