第3章 内存虚拟化 随着大数据时代的到来,数据处理成为云计算的核心能力。例如在大数据处理系统中,应用程序需要访问大量内存中的数据,于是低延迟内存访问在云环境中至关重要,然而云计算采用虚拟化技术,却给内存访问带来了更大开销。因此要解决云环境下的内存管理问题,就要对内存虚拟化技术有深入了解。 本章3.1节介绍内存虚拟化中的基本概念,使读者了解内存虚拟要解决的基本问题,以及内存虚拟化的基本原理。3.2节介绍内存虚拟化的三种实现方式,包括软件方式、硬件辅助方式以及半虚拟化的方式,并分析每种方式的优缺点,包括可能的优化以及最新进展。3.3节介绍x86架构下的内存虚拟化在QEMU/KVM中的实现,通过描述其中主要数据结构以及主要流程,使读者了解内存虚拟化的实现方式。3.4节作为内存虚拟化的拓展,介绍了“多虚一”环境下QEMU/KVM中的实现。除了介绍基本原理和实现原理,本章还增加了相关实验和相关研究的最新进展,让读者更加深入地理解内存虚拟化知识。 3.1内存虚拟化概述 内存是计算机系统中的重要部件。在任何广泛使用的计算机体系结构中,CPU若没有内存提供指令、保存执行结果,则无法运行,CPU从设备读取的数据也将无法保存。由DRAM(Dynamic Random Access Memory,动态随机访问存储器)芯片组成的内存也是存储器层次结构中重要的一环,其速度虽然慢于SRAM(Static Random Access Memory,静态随机访问存储器),但因为价格更低廉,可以获得更大的容量。 在当前的生产环境中,内存容量已经达到了TB级别。随着大数据时代的到来,大量数据需要保存在内存中进行处理,才能够获得更低的延迟,从而缩短用户的等待时间,提升用户体验。云时代的到来为计算机内存管理提出了新的挑战: 需要为客户机提供从0开始的、连续的、相互隔离的虚拟“物理内存”,并且使内存访问延迟低,接近宿主机环境下的内存访问延迟。这就是本章主要介绍的内容,即如何高效地实现内存虚拟化。 广义的内存虚拟化不仅包括硬件层面的内存虚拟化,还包含更多意义上的内存虚拟化。内存虚拟化即给访存指令提供一个内存空间,或称为地址空间。地址空间必须是从0开始且连续的,可以形象地看作一个大数组,通过从0开始的编号访问其中的元素,每个元素储存了固定大小的数据。由低层向高层、由简单到复杂,各类地址空间可以概括如下: (1) 单机上的物理地址空间。对于一条访存指令,若系统中没有开启分页模式,在不考虑开启分段模式的情况下,这条指令可以访问全部的物理内存。指令访问的PA(Physical Address,物理地址)是从0开始且连续的。在计算机启动之初,BIOS(Basic Input Output System,基本输入输出系统)探测到主板内存插槽上的所有内存条,并给每个内存条赋予一个物理地址范围,最后给CPU提供一个从0开始的物理地址空间。从而每个内存条都被映射到一个物理地址范围内,软件无须知道自己访问的是哪个内存条上的数据,使用物理地址即可访问内存条上存储的数据。物理地址隐藏了内存条的相关信息,给系统提供了从0开始且连续的物理地址空间抽象,是一种虚拟化,如图31(a)所示。除了内存条被映射到物理地址空间内,也有一些外围设备(Peripheral Devices)的内存和寄存器被映射到物理地址空间内。当CPU发出的访存指令的物理地址落在外围设备对应的物理地址范围内,则会将数据返回给CPU,这称作内存映射I/O。如果只有一层物理地址空间,则系统中只能运行一个进程,无法对CPU分时复用。 图31物理内存、虚拟内存与分布式共享内存 深入浅出系统虚拟化: 原理与实践 第3章内存虚拟化 (2) 单/多机上的虚拟地址空间。为了实现CPU的分时复用,操作系统提供了进程的概念。一个进程即是一串相互关联、完成同一个任务的指令序列。为了使不同进程的访存指令在访问物理内存时不会相互冲突,VA(Virtual Address,虚拟地址)的概念被引入。每个进程都有一个独立的虚拟地址空间,从0开始且连续,VA到PA的映射由操作系统决定。这样,假设操作系统为进程A分配了第0块和第2块的物理内存,为进程B分配了第1块和第3块的物理内存,而两个进程的虚拟地址空间大小均为4。本书使用抽象的“块”作为虚拟内存和物理内存之间映射的基本单位,即抽象层间映射的基本单位。两个进程都可以使用虚拟地址0访问它们拥有的第0块虚拟内存,而不会引起访问冲突,如图31(b)所示。由于VA到PA的映射由操作系统决定,因此操作系统可以巧妙地设置该映射,使得系统中的多个进程以一种低内存占用的方式运行。假设进程A使用的第2块物理内存和进程B使用的第3块物理内存保存的数据相同,那么操作系统可以选择将进程A、B的第1块虚拟内存同时映射到系统中的第2块物理内存,并释放第3块物理内存,减少物理内存占用。减少物理内存占用的方法还有将虚拟内存块对应的物理内存换出到磁盘上,而不改变虚拟内存的抽象层,这种方式称为页换出。这就是抽象层提供的一个好处: 给上层应用提供一个不变的“虚拟”内存,而灵活地改变其“后台”实现。虚拟内存甚至可以建立在多个物理内存硬件上,从而实现内存资源的聚合。如图31(c)所示,这种架构称为DSM(Distributed Shared Memory,分布式共享内存),它可以使单机进程无修改地运行在M0和M1上(M代表Machine),3.4节将介绍其原理,以及如何被用于实现分布式虚拟机监控器GiantVM。除了横向扩展内存虚拟化的概念,即增加物理内存的量,还可以增加抽象层的层级数。 (3) 单机上的“虚拟”物理地址空间。如果保持物理地址空间的概念不变,继续更改物理地址空间的后台实现将会产生什么概念?一个很容易想到的想法是用虚拟内存代替物理内存条作为物理地址实现的后台。而物理内存可以提供给一整台机器使用,于是产生了内存虚拟化的概念。假设进程A运行在物理硬件上,它提供4块虚拟的物理内存给客户机A使用,分别对应其0、1、2、3块虚拟内存。客户机A在此“虚拟”物理内存的基础上,继续提供虚拟内存的抽象,将第0、1块物理内存分别给客户机中运行的进程A1、B1使用,分别映射到进程A1的第0块虚拟内存、进程B1的第1块虚拟内存。如图32所示,客户机进程A和普通进程B并无差别。如第1章所述,这里产生了GVA、GPA、HVA和HPA四层地址空间,其中HVA到HPA的映射仍然由宿主机操作系统决定,GPA到HVA的映射由Hypervisor决定,而GVA到GPA的映射由客户机操作系统决定。这样的“虚拟”物理地址抽象有什么可利用之处呢?首先,这改变了对于访存指令的固有认知,即访存指令不一定访问真实的物理内存。只要保证虚拟硬件的抽象和原有的物理硬件相同,系统软件就可以按需灵活地更改抽象层的后台实现。如果真实的物理硬件需要更换维修,那么虚拟机热迁移可以将客户机迁移到另一个机器上,而不会由于更换硬件停止虚拟机的运行。这是由于“虚拟”物理内存的抽象没有改变,客户机将不会感知到它所依赖运行的硬件发生了变化。其次,根据前文对单机虚拟内存的描述,多个进程的虚拟内存总量可以超过系统上装备的物理内存总量。类似的,在内存虚拟化中, 图32单机上的“虚拟”物理内存 “虚拟”物理内存的总量可以超过真实的物理内存总量,即内存超售。 (4) 多机上的“虚拟”物理地址空间。大数据环境下的应用都会占用大量的物理内存。单个机器渐渐无法满足大数据处理任务运行过程中所需要的内存空间。于是,人们将关注点从高配置的单机纵向扩展(Scaleup)转向了数量较大的单机横向扩展(Scaleout)。为了使大数据应用运行在多个机器上,而掩盖掉网络通信、容错等分布式环境下需要额外注意的复杂度,大数据框架被开发出来,如Spark、Hadoop等。但这些框架仍然有陡峭的学习曲线,程序员需要学习其复杂的编程模型才能在分布式框架上编写代码。如果存在一个SSI(Single System Image,单一系统镜像),即在多个节点组成的分布式集群上给程序员提供一种单机的编程模型,则会极大地提高分布式应用的开发效率,彻底掩盖分布式系统的复杂性,无须学习分布式框架的编程模型。若把前文DSM的思想用于实现“虚拟”物理内存,将获得一个容量巨大的“虚拟”物理内存。DSM在多个物理节点之上建立了一个“虚拟”物理内存的抽象层,如图33所示,M0、M1分别配备4块物理内存。仿照单机上的“虚拟”物理内存,此处仍由宿主机的VM进程A、B为跨界点客户机VM提供“虚拟”物理内存。于是,VM拥有了8块物理内存,其后台实现是两个物理机的虚拟内存。这被称为“多虚一”,即多个节点共同虚拟化出一个虚拟机。DSM保持了“虚拟”物理内存的抽象层不变,任何一个操作系统均可运行在这样的抽象之上,和运行在真实物理硬件上没有差别。DSM抽象层的后台实现将在3.4节介绍。 图33多机上的“虚拟”物理内存 3.2内存虚拟化的实现 3.1节叙述了各种各样的内存虚拟化模型,本节讨论在真实的操作系统中如何对这些内存虚拟化进行实现。将物理内存条抽象为物理地址空间由BIOS等实现,与本章主题不相关。下面首先介绍虚拟地址的实现,再介绍内存虚拟化的实现。作为拓展部分,建立多机上内存抽象的方法详见3.4节。 3.2.1虚拟内存的实现: 页表 如3.1节介绍,所有访存指令提供的地址均为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形式的四级页表,如图34所示,其中页表的每一级分别用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,指针)代表页表基地址,如图34所示。同时,MMU硬件也通过PTE上的标志位实现了访存合法性的检查,以及内存访问情况的记录。Intel体系下的PTE标志位详见Intel SDM卷3下载网址: https://www.intel.com/content/www/us/en/architectureandtechnology/64ia32architecturessoftwaredevelopersystemprogrammingmanual325384.htm,请参阅最新版。第4章,其他体系有相应的手册。本章第3、4节都基于Intel x86架构,此处列举如下x86架构中常用的标志位。 注: ①指向PD页表页或1GB大页; ②指向PT页表页或2MB大页。 图3464位系统中虚拟地址的翻译 (1) P(Present)位。PTE[0],置为1时该PTE有效,为0时该PTE无效。任何访问该PTE对应的虚拟地址的指令均引起缺页异常。当物理页未分配、页表项未建立(此时PTE的每一位均为0)或物理页被换出时(此时PTE的每一位不全为0),P位为0,物理页不存在。 (2) R/W位和U/S位。PTE[1∶2],置为1时表示该页是可读可写的,为0时表示该页只读。U/S位置为0时只有管理者(supervisor,即运行在Ring0、Ring1、Ring2的代码)可以访问,为1时用户(user,即运行在Ring3的代码)、管理者均可访问。这两个位用于权限管理。 (3) 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的映射,避免查询页表造成的内存访问。MMU中也有页表缓存,用于缓存PTE,进一步优化TLB不命中时的性能,然而,内存容量随着技术的进步快速增大,TLB容量的增速却很缓慢,这导致TLB能够覆盖的虚拟内存空间越来越小。研究表明,应用运行时间的50%都耗费在查询页表上[1]。前文提到的大页可以在一定程度上解决这一问题,但不够灵活。Vasileios等提出了区间式TLB机制[16],每个区间TLB项可以将任意长度的虚拟内存区间映射到物理内存,该长度由操作系统决定,进一步减少了PTE的数量。这两种方式使得TLB能够覆盖更多的虚拟地址空间,减少了查询页表的次数。在进程切换时,由于进程运行在不同的虚拟地址空间中,需要将TLB中的所有项清空,否则地址翻译将出错。x86/ARM硬件提供了PCID(Process Context IDentifier,进程上下文标识符)/ASID(Address Space IDentifier,地址空间标识符),将TLB中的项标上进程ID,于是在进程切换时无须清空TLB。详见Intel SDM卷3第4.10节。 3.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写入的处理函数(步骤1)。Hypervisor中保存了GPA到HVA的映射,处理函数读取客户机试图写入ptr值,翻译为HVA,即可读取GPT的内容。进而,Hypervisor遍历GPT,并将每个GPT表项中保存的GPA翻译为HVA,再使用宿主机操作系统内核翻译为HPA,最后将HPA填入新页表的GVA处(步骤2)。完成GPT的遍历后,就建立了对应的新页表,Hypervisor的处理函数最终将该新页表的基地址写入ptr中,并返回到vCPU重新执行引起VMExit的指令(步骤3),见图35中标号①。GPT的所有页表页被标记为只读,客户机写入GPT引发VMExit,并调用Hypervisor的相关处理函数,把对GPT的修改翻译为对SPT的修改,完成新页表与GPT之间的同步,见图35中标号②。新页表称为影子页表,因为对于每个GPT,都需要对应的SPT作为代替,且SPT与GPT的结构完全相同,其不同仅仅是每个表项中的GPA被修改为了对应的HPA,如同影子一样。影子页表查询的结果是HFN(Host Frame Number,宿主机页号),左移12位后与GVA的低12位相加即得到对应的HPA。 注: ①写入ptr引起的退出; ②写入GPT引起退出; ③写入被Hypervisor截获。 图35使用影子页表翻译客户机虚拟地址 和非虚拟化场景下的页表相同,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才会表现出较好的性能。 3.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中(见第5章)。本章使用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)为空,故触发客户机操作系统中的缺页异常(步骤1)。客户机操作系统为了分配GPT对应的客户机物理页,需要查询EPT。此时,由于EPT尚未建立,客户机操作系统就退出到了Hypervisor(步骤2)。当客户机操作系统访问了一个缺失的EPT页表项,处理器产生EPT违例(EPT Violation)的VMExit,从而Hypervisor分配宿主机内存、建立EPT表项(步骤3)。触发EPT违例的详细原因会被硬件记录在VMCS的VMExit条件(VMExit Qualification)字段,供Hypervisor使用。宿主机操作系统完成宿主机物理页分配,建立对应的EPT表项,将返回到客户机操作系统(步骤3)。客户机操作系统继续访问GPT的下一级页表(PDPT),重复步骤1、2,GPT和EPT的建立方可完成。这里又出现了软硬件的明确分工: 软件维护页表,硬件查询页表。如果访问了EPT中的一个配置错误,不符合Intel规范的表项,处理器会触发EPT配置错误(EPT Misconfiguration)的VMExit。例如访问了一个不可读但可写的表项,此时硬件将不会记录发生EPT配置错误的原因,这类VMExit被Hypervisor用于模拟MMIO。有关EPT硬件的详细内容请参阅Intel SDM卷3第28章,Hypervisor软件部分的介绍见本书3.3节。 当处在非根模式下的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处的数据,如图36所示,需要额外读取24个页表项(图中加粗黑框的灰色长方形),因此需要额外的24次内存访问。 图36使用双层页表翻译客户机虚拟地址 即使MMU中有TLB缓存GVA到HPA的映射,TLB也无法覆盖越来越大的客户机虚拟地址空间,双层页表的查询将会造成巨大的开销。而在TLB 未命中时,一个四级影子页表仅需访问4次内存即可得到HPA,相比于双层页表有巨大的优势。是否可以将影子页表与扩展页表相结合呢? 3.2.4扩展页表与影子页表的结合: 敏捷页表 在系统设计中,经常存在着各种各样的折中与权衡。虽然影子页表和双层页表(即x86中的扩展页表)在TLB命中时,均可以以最快的时间获得GVA到HPA的映射,但遭遇1次TLB未命中时,查询双层页表需要访问内存的次数增长到了24/4=6倍。然而影子页表会引起大量的VMExit,尤其是在频繁分配、释放虚拟内存的内存密集型场景下,这使得影子页表在现在的虚拟化环境下很少使用。由于内存容量快速增大、TLB容量增长缓慢,TLB不命中的次数越来越多,查询双层页表造成的6倍内存访问也造成了不可忽视的开销,影子页表有一些优势。表31将影子页表与扩展页表进行了对比。 表31影子页表与扩展页表对比 对比项硬件扩展TLB功能查询访存次数更改GPT内存占用 PT否VA→PA4无须退出低 SPT否GVA→HPA4需要退出高 EPT是GVA→HPA24无须退出低 Jayneel等人观察到[1],在2秒的采样时间内,仅有1~5%的地址空间被频繁修改,且被修改的地址空间会比未修改的地址空间修改得更加频繁。例如,保存代码的地址空间极少被修改、写入,称为静态区; 而地址空间的堆栈以及映射文件的部分则被频繁修改,称为动态区。若用SPT完成静态区的地址翻译,则能减小TLB 不命中时的页表查询开销; 而用双层页表完成动态区的地址翻译,则能避免GPT频繁修改造成的VMExit。于是,研究人员于2016年提出了敏捷页表 [1]。他们设计了一种影子页表和双层页表混用的机制,还有一种策略决定何时由影子页表转换到双层页表或由双层页表转换到影子页表。这是一种机制与策略的分离: 机制是固定的,而程序员可以灵活修改策略,具有更好的灵活性。图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。图37(a)中查询3级影子页表,仅额外读取3+1+4=8次页表项(图中的加粗黑框灰色长方形); 图37(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)所示,当客户机修改了 图37使用敏捷页表翻译客户机虚拟地址 图37(续) 两次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,但有相应的硬件优化,详见参考文献[15]。 3.2.5内存的半虚拟化: 直接页表映射与内存气球 上文所述的内存虚拟化实现均基于一个前提: 客户机无从得知自己使用的是“虚拟”的物理内存。如果客户机知道自己运行在“虚拟”的内存硬件上,内存虚拟化的实现是否会更简单?内存虚拟化实现的性能是否更高?本节介绍两个与内存半虚拟化的相关技术。 (1) 直接页表映射。半虚拟化可以通过告知客户机运行在虚拟环境下,让客户机协同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的单元做得越来越近,而相邻单元的相互影响也越来越大,不断访问某个地址的物理内存,即可造成相邻位置内存位的翻转。若客户机得知了真实的物理内存地址,则可以对不属于自己的相邻物理内存发起行锤攻击。 (2) 内存气球。根据对SPT原理的介绍,客户机的“虚拟”物理内存的后台实现其实是宿主机进程的虚拟内存,可以使用宿主机虚拟内存的功能管理客户机物理内存。例如,为了实现内存超售,给所有客户机分配的物理内存总量可以大于物理硬件的内存容量。由于抽象层这一概念的存在,系统软件的设计者可以灵活更改“虚拟”物理内存的后台实现,将“虚拟”物理内存对应的宿主机虚拟内存换出到磁盘,或映射到同一块宿主机物理内存,从而减小宿主机物理内存的压力。然而,Hypervisor在决定换出哪块“虚拟”物理内存时,无法精确地得知哪些部分在未来一段时间内不会被客户机使用。即使开启了EPT的A/D位,Hypervisor也仅仅能够得知在过去一段时间内,客户机访问了哪些页、长时间未访问哪些页,而无法得知这些页之间的关联与意义,即所谓的语义鸿沟。这会导致“虚拟”物理内存换出的太多或太少: “太多”会使客户机不断等待“虚拟”物理内存从磁盘换入内存,降低客户机性能; 而“太少”则使Hypervisor没有释放那些完全可以被立即释放的“虚拟”物理内存,造成系统内存资源的浪费。 Hypervisor无法实现高效的客户机内存换出策略的原因是: Hypervisor无法得知客户机内部发生了什么。而半虚拟化可以很好地解决该问题,可以使客户机和Hypervisor更好地沟通。内存气球利用了客户机内核提供的物理内存分配函数,来实现客户机内存的高效释放。其主要工作流程是,Hypervisor调用客户机提供的内存释放接口,请求客户机释放其占用的“虚拟”物理内存。客户机收到该请求后,调用其内核提供的物理内存分配函数(如Linux内核中的alloc_pages函数),并把分配好的“虚拟”物理内存范围返回给Hypervisor。Hypervisor可以将该“虚拟”物理内存对应的虚拟内存释放,减轻系统内存压力。由于客户机内核的物理内存分配函数会“自动”找出未被使用的物理内存,因此这种方式很简易地找出了客户机中不被使用的物理内存,大大简化了内存气球的实现。 3.3QEMU/KVM内存虚拟化源码 本节将深入分析QEMU/KVM内存虚拟化相关代码,其中KVM代码来自Linux内核v4.19,QEMU代码版本为4.1.1。下文将围绕实现所需的数据结构以及相关函数进行介绍,忽略错误处理等代码,给出充足的注释,使读者易于理解。 如3.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.3.1QEMU内存数据结构 为了正确地运行客户机,QEMU需要模拟3.1节所述物理地址空间的所有功能。①QEMU作为宿主机上的用户态进程,在宿主机上分配一段虚拟地址提供给客户机作为客户机物理内存使用。②QEMU需要模拟物理地址空间中外围设备对应的MMIO部分,通过截获对该内存区域的访问,完成对设备功能的模拟,使得客户机像在真实环境中一样完成MMIO。 1. “虚拟”物理内存的分配 本节从解决第一个问题开始,即: 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 struct RAMBlock { struct MemoryRegion *mr; // 对应的MemoryRegion uint8_t *host; // 宿主机虚拟地址 HVA // 客户机物理地址相关数据 GPA ram_addr_t offset; ram_addr_t used_length; ram_addr_t max_length; char idstr[256]; // RAMBlock名称,在vmstate_register_ram函数中填充 QLIST_ENTRY(RAMBlock) next; // 指向ram_list.blocks中的下一个元素 int fd; // 对应的文件描述符,当使用磁盘文件映射时使用,最终传入mmap函数 unsigned long *bmap; // 脏页位图 uint32_t flags; // 标志位,如RAM_MIGRATABLE标记该RAMBlock可以被迁移 }; 其中host指针保存了mmap函数返回的宿主机虚拟地址,max_length保存了mmap函数申请的虚拟内存大小,idstr保存了该RB的名称,mr保存了其所属的MemoryRegion。next指向该RB在全局变量ram_list中的下一个RB。ram_addr_t类型代表了所有内存条组成的GPA空间,ram_list.blocks将所有“虚拟”物理内存块RB组织在一起,根据max_length从大到小排列,如图38所示。 图38RAMBlock组织结构 其中,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 static void ram_block_add(RAMBlock *new_block, Error **errp, bool shared) { new_block->offset = find_ram_offset(new_block->max_length);//查找空位 if (!new_block->host) new_block->host = phys_mem_alloc(new_block->max_length,//调用mmap函数 &new_block->mr->align, shared); .. QLIST_INSERT_BEFORE_RCU(...); //加入ram_list.blocks smp_wmb(); ram_list.version++; if (new_block->host) qemu_madvise(new_block->host, new_block->max_length, QEMU_MADV_HUGEPAGE); } 参数new_block表示待填充的RB。首先调用find_ram_offset在全局ram_list.blocks中查找能够容纳下max_size大小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 typedef struct RAMList { RAMBlock *mru_block; // 最近使用的RAMBlock QLIST_HEAD(, RAMBlock) blocks; // ram_addr_t空间的链表 // 脏页位图,用于实现VGA、TCG、热迁移,管理粒度为一个页,即4KB DirtyMemoryBlocks *dirty_memory[DIRTY_MEMORY_NUM]; uint32_t version; // 在RAMList被修改并调用smp_wmb()后加1 } RAMList; 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中读取脏页位图。 2. 支持“虚拟”物理内存访问回调函数 物理地址空间不仅被内存条所占据,也被外围设备的MMIO区域所占据,QEMU需要对客户机访问MMIO进行模拟。CPU使用PIO访问端口地址空间,QEMU也需要对这类访问进行模拟。对于这些地址空间段,QEMU无须为其分配宿主机虚拟内存,只需设置对应的回调函数。为此,QEMU在RAMBlock的基础上加了一层包装,形成了MemoryRegion,简称MR,包含MR和回调函数。MR代表客户机的一块具有特定功能的物理内存区域,定义如下。 qemu-4.1.1/include/exec/memory.h struct MemoryRegion { Object parent_obj; // 父对象 bool ram; // 是否是RAM bool read_only; // 是否只读 bool rom_device, ram_device; // ROM和RAM设备 bool terminates; // 是否为叶子节点MR bool enabled; // 是否使能,被注册到KVM中,若不使能,则在处理中忽略 bool nonvolatile; // 是否是非易失性内存(non-volatile memory) RAMBlock *ram_block; // 对应的RB const MemoryRegionOps *ops; // 回调函数 MemoryRegion *container, *alias; // 指向容器MR、别名MR Int128 size; // MR大小 hwaddr addr; // MR起始地址 hwaddr alias_offset; // 在别名MR中的偏移 int32_t priority; // 优先级 QTAILQ_HEAD(, MemoryRegion) subregions; // 容器MR的子MR链表头 QTAILQ_ENTRY(MemoryRegion) subregions_link; // 此MR在MR链表中对应的节点 const char *name; // MR名称,方便调试 }; struct MemoryRegionOps { uint64_t (*read)(void *opaque, hwaddr addr, unsigned size); uint64_t (*write)(void *opaque, hwaddr addr, uint64_t data, unsigned size); }; 根据本书第4章,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_*,*代表类型,下面举例介绍。 (1) RAM类型MR需要调用前文的qemu_ram_alloc函数分配一个RB填入ram_block域,ram域为true; ROM类型MR则需要额外将read_only域置为true,表示只读的内存区。 (2) MMIO类型MR负责实现MMIO模拟,需要传入ops进行初始化,其中ops是一组回调函数,当QEMU需要模拟MMIO时,会调用ops中的函数进行MMIO模拟。 (3) 对于ROM Device(Read Only Memory Device,只读内存设备)类型MR,对它进行读取则等同于RAM类型的MR,而写入则等同于MMIO类型的MR,调用回调函数ops。 (4) IOMMU(Input/Output Memory Management Unit,输入输出内存管理单元)类型MR将对该MR的读写转发到另一MR上模拟IOMMU。所有的MR构造函数memory_region_init_*都要调用memory_region_init函数填充size、name等成员。这些MR均称为实体MR,terminates为true。前三类实体MR的创建过程如图39所示。 图39实体MR的构造函数及其调用链 所有的MR组成了一棵树,其叶子节点是RAM和MMIO,而中间节点代表总线(容器MR)、内存控制器(别名MR)或被重定向到其他内存区域的内存区,树根是一个容器MR,如图310所示。这棵树表示一个地址空间,被AddressSpace数据结构指向。下面介绍容器MR和别名MR。 (1) 容器(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之下,反之在上。 (2) 别名(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。3.3.2节的实验将介绍别名MR以及容器MR的实例,介绍QEMU如何使用它们。 图310MemoryRegion树 在QEMU中,全局变量system_memory是整个MR树的根。给定MR树的根MR以及要查询的客户机物理地址,QEMU就可以查找该地址落在哪个实体MR中。首先判断该地址是否在根管理的范围内,如果不在则返回,否则进行如下步骤的搜索: ①按照优先级从高到低的顺序遍历该容器MR的subregions链表; ②如果子MR是实体MR,且该地址落在子MR的[mr>addr,mr>addr+mr>size)中,则结束查找,返回该实体MR; ③如果子MR是容器MR,则递归调用步骤①; ④如果子MR是别名MR,则继续查找对应的实际MR; ⑤如果在所有的子MR中都没有查找到,则查询该地址是否在容器MR自己的内存范围内。 想了解更完整的说明,可以查看QEMU源码树中的docs/devel/memory.rst文档,或查阅注释参考地址: https://qemu.readthedocs.io/en/latest/devel/memory.html,包含全部QEMU内存接口的说明。,其中包含MemoryRegion的所有概念,以及MR相关接口的详细解释。 3. 顶层数据结构AddressSpace 在前几节中,QEMU使用mmap函数分配了宿主机虚拟内存,作为客户机“虚拟”物理内存的实现后台,又构建了MemoryRegion树,对一个物理地址空间内各个不同区域的功能进行了模拟,包括内存条RAM以及MMIO,使用容器MR对子MR进行管理,完成了QEMU对各段内存功能的模拟。前文由底层向上层,介绍了QEMU中抽象程度较高的数据结构MR。进一步地,除了对客户机物理地址空间的模拟,MR树可以用在对任何地址空间的模拟上。计算机硬件中还存在以下地址空间,与内存地址空间类似。 (1) CPU地址总线传来的地址可以用于访问RAM或MMIO,即形成了物理地址空间,3.2.5节的system_memory即表示此空间。 (2) 除了MMIO,CPU与外围设备打交道的另一种方式是PIO,CPU使用in*/out*指令访问设备端口,端口号组成了另一种地址空间,在x86上有65536个端口,端口地址空间范围是[0, 0xffff)。 (3) 除了CPU可以访问内存,外围设备也可以自发地访问内存,这种访问方式称作DMA(Direct Memory Access,直接内存访问),可以绕过CPU,不使用CPU访存指令访问内存。外围设备可以看到的内存地址也组成了一个地址空间。 以上三个场景均需要对一个MR树进行管理。QEMU引入了AddressSpace数据结构(简称为AS),表示CPU、外设或PIO可以访问的地址空间,定义如下。 qemu-4.1.1/include/exec/memory.h // AddressSpace: 描述hwaddr到MemoryRegion的映射 struct AddressSpace { char *name; // AS名称,方便调试 MemoryRegion *root; // 根MR // 当前的扁平视图,在address_space_update_topology时作为old比较 struct FlatView *current_map; // listener链表,在MR树根root的拓扑结构被更改时调用,按照优先级排列 QTAILQ_HEAD(, MemoryListener) listeners; QTAILQ_ENTRY(AddressSpace) address_spaces_link;// 全局AS链表 }; 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,包括: (1) 全局变量address_space_memory,表示物理地址空间,其MR树根是大小为UINT64_MAX的system_memory。 (2) address_space_io,表示PIO空间,其MR树根是大小为65536(即0xffff)的system_io。 (3) 每个CPU都有一个名为cpumemoryn的AS,其中n为从0开始的CPU编号,和address_space_memory一样,使用了system_memory作为MR树根。 (4) 外围设备角度的AS,例如VGA、e1000等模拟设备,它们的AS使用一个大小为UINT64_MAX的总线MR作为MR树根,并使用system_memory的别名MR作为MR树根的子MR,即可以将内存读写转发到system_memory对应的区域上。 AS数据结构提供了以下接口,供AS的使用者对AS进行创建、销毁与读写,其含义见注释。此处省略了与缓存功能相关的接口,感兴趣的读者可自行查阅源码。其中有关读写的接口说明详见QEMU源码树的docs/devel/loadsstores.rst文档。后续将围绕这些AS管理函数,对AS的树结构与线性结构的同步,以及AS的读写、回调进行讲解。由于一些函数复杂度较高,这里只讲解重要的函数。AS相关操作均围绕着hwaddr (GPA)、void*或uint8_t* (HVA)之间的转换进行。 qemu-4.1.1/include/exec/memory.h // address_space_init: 用MR树根root初始化AS,名称为name void address_space_init(AddressSpace *as, MemoryRegion *root, const char *name); // address_space_rw: 读写AS,位置为addr,数据位置为buf,长度len,其他参数attr MemTxResult address_space_rw(AddressSpace *as, hwaddr addr, MemTxAttrs attrs, uint8_t *buf, hwaddr len, bool is_write); // address_space_write: 写入AS,位置为addr,数据位置为buf,长度len,其他参数attr MemTxResult address_space_write(AddressSpace *as, hwaddr addr, MemTxAttrs attrs, const uint8_t *buf, hwaddr len); // address_space_map: 将AS的 [addr,addr+*plen) 段映射到一个HVA处,并返回该HVA void *address_space_map(AddressSpace *as, hwaddr addr, hwaddr *plen, bool is_write, MemTxAttrs attrs); // address_space_unmap: address_space_map的逆操作 void address_space_unmap(AddressSpace *as, void *buffer, hwaddr len, int is_write, hwaddr access_len); 在memory.c文件中,还有几个全局变量在下文出现,总结如下。 qemu-4.1.1/memory.c static unsigned memory_region_transaction_depth; // MR事务深度,负责管理MR事务 static bool memory_region_update_pending; // 是否有MR树的修改未同步到FlatView static bool ioeventfd_update_pending; // 是否有ioeventfd的更改未处理 static QTAILQ_HEAD(, MemoryListener) memory_listeners = QTAILQ_HEAD_INITIALIZER(memory_listeners);// 全局的监听者链表,监听所有AS static QTAILQ_HEAD(, AddressSpace) address_spaces = QTAILQ_HEAD_INITIALIZER(address_spaces); // 全局的AS链表,保存所有AS static GHashTable *flat_views; // physmr到FlatView的映射,全局哈希表 综上,AddressSpace相关数据结构之间的关系如图311所示,下面将围绕此图的各个部分展开介绍。 4. 从树状结构到线性结构 这里介绍树状结构到线性结构的同步过程。为了将GPA翻译到HPA,QEMU通过ioctl系统调用,将AS对应的线性结构current_map注册到KVM中; 当树状结构被修改时,QEMU需要重新生成新的current_map,并与旧的current_map进行比较,将更改的部分重新注册到KVM中。FlatView用于管理线性结构,定义如下。 qemu-4.1.1/include/exec/memory.h struct FlatView { unsigned ref; // 引用计数 FlatRange *ranges; // FlatRanges数组 unsigned nr; // ranges数组长度 unsigned nr_allocated; // ranges数组中有效元素的个数 struct AddressSpaceDispatch *dispatch; // hwaddr地址分派器 MemoryRegion *root; // 所在AS的MR树根 }; 注: ①修改MR树; ②生成扁平视图; ③通知所有listeners; ④ioctl系统调用进入KVM; ⑤AddressSpace读写; ⑥定位到MR并完成读写。 图311AddressSpace相关数据结构 每个AS都有一个对应的FlatView,它保存了AS内存拓扑的线性结构,并且承担了hwaddr的分派功能,即通过dispatch成员将hwaddr映射到对应的MR,后文介绍。FlatView中保存了FlatRange数组,是AS对应的线性结构,FlatRange定义如下。 qemu-4.1.1/memory.c struct FlatRange { MemoryRegion *mr; // 指向对应的物理mr hwaddr offset_in_region; // 在mr中的偏移 AddrRange addr; // 在AS中所占据的地址范围 bool romd_mode; // Rom Device模式 bool readonly; // 只读的FlatView bool nonvolatile; // 是否是非易失性内存 int has_coalesced_range; // 是否存在已合并的范围 }; struct AddrRange { Int128 start; // 起始地址 Int128 size; // 范围大小 }; 如果将FlatView的FlatRange数组按顺序铺开,就得到了一个分布在hwaddr地址空间上的线性结构,由一段段可能互不相邻的FlatRange组成。何时由MemoryRegion树状视图生成该线性视图?经过分析,有两个时间节点: ①AS被初始化时,根据传入的MR树根生成线性视图current_map; ②AS对应的MR树被更改时,需要重新生成线性视图current_map。 首先分析AS初始化时如何生成FlatView,AS初始化函数定义如下。 qemu-4.1.1/memory.c void address_space_init(AddressSpace *as, MemoryRegion *root, const char *name) { as->root = root; as->current_map = NULL; QTAILQ_INIT(&as->listeners); QTAILQ_INSERT_TAIL(&address_spaces, as, address_spaces_link); address_space_update_topology(as); } 该函数首先初始化各个成员,包括current_map、root指针,并初始化listeners监听者链表。全局链表address_spaces负责将模拟客户机硬件所使用的所有AS链接起来,方便遍历所有的AS。address_update_topology函数较为重要,该函数负责为新的AS生成FlatView,定义如下。 qemu-4.1.1/memory.c static void address_space_update_topology(AddressSpace *as) { MemoryRegion *physmr = memory_region_get_flatview_root(as->root); flatviews_init() -> { if (flat_views) return; flat_views = g_hash_table_new_full(...); } if (!g_hash_table_lookup(flat_views, physmr)) { generate_memory_topology(physmr) -> { flatview_init(view); if (mr) render_memory_region(view, mr, ...) -> flatview_insert(view, &fr); flatview_simplify(view); // 省略dispatch相关代码 g_hash_table_replace(flat_views, mr, view); return view; } } address_space_set_flatview(as); } (1) 调用memory_region_get_flatview_root函数找到AS的物理MR根(简称physmr,物理MR,后文将多次用到),其目的是找到实体MR(而非别名MR)的树根。这样可以减少FlatView的个数,使得拥有相同的实体MR的AS共用一个FlatView,减少内存开销,详见注释地址: https://patchwork.kernel.org/project/qemudevel/patch/20170921085110.2559810aik@ozlabs.ru/,说明了引入physmr的原因。。由此可知,AS的FlatView与physmr绑定,而非与根MR绑定。 (2) 调用flatviews_init函数初始化全局变量flat_views,它是一个全局的哈希表,负责将MR映射到FlatView。在首次调用flatviews_init函数时被设置为新的空哈希表。 (3) 调用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 typedef struct KVMSlot { hwaddr start_addr; // 起始地址 ram_addr_t memory_size; // Slot大小 void *ram; // 宿主机虚拟地址,即HVA } KVMSlot; typedef struct KVMMemoryListener { MemoryListener listener; // 指向通用MemoryListener KVMSlot *slots; // KVMSlot数组 } KVMMemoryListener; KVMMemoryListener中的KVMSlot是QEMU中KVM的kvm_memory_slot对应的数据结构,负责向KVM注册内存; listener是通用监听器,定义如下。 qemu-4.1.1/include/exec/memory.h struct MemoryListener { void (*region_add)(MemoryListener *listener, MemoryRegionSection *section); // 添加函数 void (*region_del)(MemoryListener *listener, MemoryRegionSection *section); unsigned priority; // 优先级 AddressSpace *address_space; QTAILQ_ENTRY(MemoryListener) link; // listener链表节点 }; 通用监听器MemoryListener是一个函数指针的集合,并有一个priority成员表示其优先级,所有的listener在注册时按照优先级顺序连接到AS的监听者链表上。其中KVM监听器注册的代码如下。 qemu-4.1.1/accel/kvm/kvm-all.c void kvm_memory_listener_register(...) { kml->listener.region_add = kvm_region_add; kml->listener.region_del = kvm_region_del; kml->listener.priority = 10; memory_listener_register(&kml->listener, as); } kvm_init -> kvm_memory_listener_register(&s->memory_listener, &address_space_memory, 0); kvm_region_add -> kvm_set_phys_mem(kml, section, true) -> kvm_set_user_memory_region(kml, mem, true) -> kvm_vm_ioctl(s, KVM_SET_USER_MEMORY_REGION, &mem) -> ioctl(s->vmfd, KVM_SET_USER_MEMORY_REGION, &mem) // 进入内核 在KVM监听者中,region_add函数指针指向kvm_region_add函数,最终调用ioctl函数完成内存的注册。下面继续介绍AS的初始化流程。 (4) 最后回到AS的初始化函数address_space_init,接步骤(3)。它继续调用address_space_set_flatview函数,将新生成的physmr对应的view告知所有监听者,并且将AS的current_map更新到新生成的view。此处调用的是KVM监听器的回调函数,从address_space_set_flatview函数讲起,代码如下。 qemu-4.1.1/memory.c static void address_space_set_flatview(AddressSpace *as) { FlatView *old_view = address_space_to_flatview(as); MemoryRegion *physmr = memory_region_get_flatview_root(as->root); FlatView *new_view = g_hash_table_lookup(flat_views, physmr); if (old_view == new_view) return; if (!QTAILQ_EMPTY(&as->listeners)) { ... address_space_update_topology_pass(as, old_view2, new_view, false); address_space_update_topology_pass(as, old_view2, new_view, true); } atomic_rcu_set(&as->current_map, new_view); } 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 struct MemoryRegionSection { Int128 size; // section大小 MemoryRegion *mr; // 对应的MR FlatView *fv; // 对应的FlatView hwaddr offset_within_region; // 在MR中的偏移 hwaddr offset_within_address_space; // 在AS中的偏移 bool readonly; // 只读的FlatView bool nonvolatile; // 是否是非易失性内存 }; 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 address_space_update_topology_pass -> { while (iold < old_view->nr || inew < new_view->nr) { // 省略比较新旧FlatView的逻辑 MEMORY_LISTENER_UPDATE_REGION(frold, as, Reverse, region_add); } // 省略其他存在更改的情况 } } #define MEMORY_LISTENER_UPDATE_REGION(fr, as, dir, callback, _args...)\ do {\ MemoryRegionSection mrs = section_from_flat_range(fr,\ address_space_to_flatview(as));\ MEMORY_LISTENER_CALL(as, callback, dir, &mrs, ##_args);\ } while(0) #define MEMORY_LISTENER_CALL(_as, _callback, _direction, _section, _args...)\ do {\ MemoryListener *_listener;\ // 省略switch\ QTAILQ_FOREACH(listener, &(_as)->listeners, link_as) {\ if (_listener->_callback) {\ _listener->_callback(listener, _section, ##_args); \ }\ }\ } while (0) 至此,QEMU已经完成了AS初始化工作。AS初始化时涉及的调用链如图312所示。可以看到,初始化一个AS时,首先调用generate_memory_topology函数生成其physmr根对应的FlatView,再调用函数address_space_set_flatview→address_space_update_topology_pass→kvm_region_add通知KVM模块: 线性视图已经更改,需要重新向KVM使用ioctl函数注册内存,最终调用ioctl函数进入内核态的KVM模块中。 图312AS创建时树状结构到线性结构的调用链 简而言之,重要的是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 memory_region_*() -> { // 更新MR的一类函数 memory_region_transaction_begin -> // 事务开始 { ++memory_region_transaction_depth; } ... // 更新MR树中的MR memory_region_transaction_commit -> {// 事务提交 --memory_region_transaction_depth; if (!memory_region_transaction_depth) { if (memory_region_update_pending) { flatviews_reset() -> { QTAILQ_FOREACH(as, &address_spaces,address_spaces_link) { generate_memory_topology(physmr); } } memory_region_update_pending = false; QTAILQ_FOREACH(as, &address_spaces, address_spaces_link) { address_space_set_flatview(as); } } else if (ioeventfd_update_pending) { // 省略ioeventfd相关处理 } } } } 可以看到,每次修改MR树都在flatviews_reset函数中重新生成了对应的FlatView,并且调用address_space_set_flatview函数将新的FlatView注册到KVM中。简化的调用链如图313所示,类似于AS创建之后的调用链。 至此,QEMU完成了树状结构到线性结构的同步,并将线性结构注册到KVM中。QEMU进行了树状结构到线性结构的转化,将较为复杂的“策略”转换成一种简单的形式,可以调用KVM提供的“机制”完成QEMU/KVM的协同工作。此时,当客户机访问一个“虚拟”物理地址GPA时,如果该GPA不是用于MMIO,那么MMU都会查询KVM所维护的EPT得到其对应的“真实”物理地址,客户机访问这部分“虚拟”物理内存将不会引起VMExit,从而完成高效的内存虚拟化。对于MMIO区域的GPA,QEMU是如何与KVM协作的呢? 图313更改MemoryRegion树时树状结构到线性结构的调用链 5. 客户机物理内存地址的分派 对于实现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_kvm_start_vcpu -> qemu_kvm_cpu_thread_fn -> kvm_cpu_exec -> { do { run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0); // 进入内核 switch (run->exit_reason) { // 内核返回的退出原因 case KVM_EXIT_IO: kvm_handle_io() -> address_space_rw(&address_space_io, run-> ...) case KVM_EXIT_MMIO: address_space_rw(&address_space_memory, run-> ...) } } while (ret == 0); } 可以看到,在退出到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 struct AddressSpace { struct FlatView *current_map; } struct FlatView { struct AddressSpaceDispatch *dispatch; } AS的线性视图(扁平视图)中保存了struct AddressSpaceDispatch的指针,定义如下。 qemu-4.1.1/exec.c typedef struct PhysPageEntry PhysPageEntry; struct PhysPageEntry { uint32_t skip : 6; uint32_t ptr : 26; }; typedef PhysPageEntry Node[P_L2_SIZE]; typedef struct PhysPageMap { unsigned sections_nb, sections_nb_alloc; unsigned nodes_nb, nodes_nb_alloc; Node *nodes; MemoryRegionSection *sections; } PhysPageMap; struct AddressSpaceDispatch { MemoryRegionSection *mru_section; // 最近访问的section PhysPageEntry phys_map; // 页表指针 PhysPageMap map; // 页表 }; 每个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 // FlatRange转换为 MemoryRegionSection static inline MemoryRegionSection section_from_flat_range(FlatRange *fr, FlatView *fv) { return (MemoryRegionSection) { .mr = fr->mr, .fv = fv, .offset_within_region = fr->offset_in_region, .size = fr->addr.size, .offset_within_address_space = int128_get64(fr->addr.start), .readonly = fr->readonly, .nonvolatile = fr->nonvolatile, }; } static FlatView *generate_memory_topology(MemoryRegion *mr) { // 省略生成FlatRange数组的过程 view->dispatch = address_space_dispatch_new(view); for (i = 0; i < view->nr; i++) { MemoryRegionSection mrs = section_from_flat_range(&view->ranges[i], view); flatview_add_to_dispatch(view, &mrs); } address_space_dispatch_compact(view->dispatch); } 在生成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 address_space_rw -> { address_space_write(as, addr, attrs, buf, len) / address_space_read_full(as, addr, attrs, buf, len) -> { if (len > 0) { fv = address_space_to_flatview(as) -> { return atomic_rcu_read(&as->current_map); } result = flatview_write(fv, addr, attrs, buf, len); } return result; } } 这里出现了一个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 fv, hwaddr addr) -> { mr = flatview_translate(fv, addr); return flatview_read_continue(mr, addr) -> { for (;;) { if (!memory_access_is_direct(mr, false)) { // I/O区域的读写 memory_region_dispatch_read -> { tmp = mr->ops->read(mr->opaque, addr, size); } } else { // RAM区域的读写 ptr = qemu_ram_ptr_length(mr->ram_block, addr1, &l, false); memcpy(buf, ptr, l); } ... mr = flatview_translate(fv, addr, &addr1, &l, false, attrs); } }; } 可以看到,flatview_read函数不断调用flatview_translate函数,通过FlatView内部的页表AddressSpaceDispatch得到一个hwaddr地址对应的MemoryRegion,再进行MemoryRegion对应的模拟。对于I/O类型的MR,最终调用ops>read函数完成读取的模拟; 对于RAM类型的MR,则找到hwaddr addr对应的HVA,记录在ptr指针中,调用memcpy完成读取。客户机物理内存地址分派的调用链如图314所示。 图314客户机物理内存地址分派 至此,已经介绍了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。下一节将展示运行过程中这些数据结构的组织形式,使读者有更直观的认识。 3.3.2实验: 打印MemoryRegion树 QEMU为了模拟MMIO以及物理设备的行为,形成了一套复杂的数据结构,但这些只是静态的代码。本节将QEMU代码运行起来,在动态过程中打印出MemoryRegion树,更形象地展示数据结构之间的关系。 实验使用从源代码编译的QEMU v4.1.1,以及事先准备好的客户机磁盘镜像作为QEMU的hda参数传递给QEMU。首先,使用如下命令进入QEMU监视器。 Physical Machine Terminal 1 sudo ./qemu-4.1.1/x86_64-softmmu/qemu-system-x86_64 -m 4096 -smp 2 -cpu host --enable-kvm -monitor stdio -numa node,cpus=0 -numa node,cpus=1 QEMU 4.1.1 monitor - type 'help' for more information (qemu) VNC server running on 127.0.0.1:5900 (qemu) 启动命令的含义为: 将QEMU 管理器的输入输出重定向到字符设备stdio(monitor stdio),即此处的命令行; 此命令启动了2个vCPU(smp 2),使用NUMA(NonUniform Memory Access,非统一内存访问)架构,分为两个NUMA 节点(numa node),分配4GB的“虚拟”物理内存(m 4096); 开启KVM支持,并使用与宿主机一样的CPU型号(cpu host enablekvm)。接下来,使用命令info mtree打印此客户机的MemoryRegion树,在输出中,QEMU用不同宽度的缩进表示不同树的深度,打印如下。 Physical Machine Terminal 1 (qemu) info mtree address-space: memory 0000000000000000-ffffffffffffffff (prio 0, i/o): system 0000000000000000-00000000bfffffff (prio 0, i/o): alias ram-below-4g @pc.ram 0000000000000000-00000000bfffffff 0000000000000000-ffffffffffffffff (prio -1, i/o): pci 00000000000a0000-00000000000bffff (prio 1, i/o): vga-lowmem 00000000000c0000-00000000000dffff (prio 1, rom): pc.rom 00000000000e0000-00000000000fffff (prio 1, i/o): alias isa-bios @pc.bios 0000000000020000-000000000003ffff 00000000fd000000-00000000fdffffff (prio 1, ram): vga.vram 00000000febc0000-00000000febdffff (prio 1, i/o): e1000-mmio 00000000febf0000-00000000febf0fff (prio 1, i/o): vga.mmio 00000000febf0000-00000000febf00ff (prio 0, i/o): edid 00000000fffc0000-00000000ffffffff (prio 0, rom): pc.bios 00000000fec00000-00000000fec00fff (prio 0, i/o): kvm-ioapic 00000000fed00000-00000000fed003ff (prio 0, i/o): hpet 00000000fee00000-00000000feefffff (prio 4096, i/o): kvm-apic-msi 0000000100000000-000000013fffffff (prio 0, i/o): alias ram-above-4g @pc.ram 00000000c0000000-00000000ffffffff address-space: I/O 0000000000000000-000000000000ffff (prio 0, i/o): io 0000000000000000-0000000000000007 (prio 0, i/o): dma-chan 0000000000000008-000000000000000f (prio 0, i/o): dma-cont 0000000000000064-0000000000000064 (prio 0, i/o): i8042-cmd 0000000000000070-0000000000000071 (prio 0, i/o): rtc 0000000000000070-0000000000000070 (prio 0, i/o): rtc-index 000000000000007e-000000000000007f (prio 0, i/o): kvmvapic 0000000000000080-0000000000000080 (prio 0, i/o): ioport80 0000000000000081-0000000000000083 (prio 0, i/o): dma-page 0000000000000087-0000000000000087 (prio 0, i/o): dma-page 0000000000000089-000000000000008b (prio 0, i/o): dma-page 000000000000008f-000000000000008f (prio 0, i/o): dma-page 0000000000000092-0000000000000092 (prio 0, i/o): port92 00000000000000a0-00000000000000a1 (prio 0, i/o): kvm-pic address-space: cpu-memory-0 // 与address-space: memory相同 address-space: cpu-memory-1 // 与address-space: memory相同 address-space: i440FX 0000000000000000-ffffffffffffffff (prio 0, i/o): bus master container address-space: PIIX3 0000000000000000-ffffffffffffffff (prio 0, i/o): bus master container address-space: VGA 0000000000000000-ffffffffffffffff (prio 0, i/o): bus master container address-space: e1000 0000000000000000-ffffffffffffffff (prio 0, i/o): bus master container 0000000000000000-ffffffffffffffff (prio 0, i/o): alias bus master @system 0000000000000000-ffffffffffffffff address-space: piix3-ide 0000000000000000-ffffffffffffffff (prio 0, i/o): bus master container memory-region: pc.ram 0000000000000000-00000000ffffffff (prio 0, ram): pc.ram memory-region: pc.bios 00000000fffc0000-00000000ffffffff (prio 0, rom): pc.bios memory-region: pci // 与address-space: memory中对应的部分相同 memory-region: system // 与address-space: memory中对应的部分相同 此处省略了被标为 [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,其地址范围是0x0000000000000000~0xffffffffffffffff,即0~UINT64_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 // 全局变量、静态变量 RAMList ram_list = { .blocks = QLIST_HEAD_INITIALIZER(ram_list.blocks) }; static MemoryRegion *system_memory, *system_io; AddressSpace address_space_io, address_space_memory; MemoryRegion io_mem_rom, io_mem_notdirty; main() -> cpu_exec_init_all()-> { io_mem_init() -> { memory_region_init_io(&io_mem_rom); ... } memory_map_init() -> { memory_region_init(system_memory, NULL, "system", UINT64_MAX); address_space_init(&address_space_memory, system_memory, "memory"); memory_region_init_io(system_io, NULL, &unassigned_io_ops, NULL, "io", 65536); address_space_init(&address_space_io, system_io, "I/O"); } } 在这里,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硬件初始化函数 pc_init1() // pc_piix.c { if (pcmc->pci_enabled) { // 初始化PCI MR memory_region_init(pci_memory, NULL, "pci", UINT64_MAX); rom_memory = pci_memory; } pc_memory_init(pcms, system_memory, rom_memory, &ram_memory)//pc.c { memory_region_allocate_system_memory(ram, NULL, "pc.ram", machine->ram_size) // numa.c { if (nb_numa_nodes == 0 || !have_memdevs) { //模拟非NUMA allocate_system_memory_nonnuma(ram) -> memory_region_init_ram_nomigrate(ram) -> new_block->host = qemu_ram_mmap() -> mmap() } else { // 省略模拟NUMA架构的代码 } } // memory_region_allocate_system_memory memory_region_init_alias(ram_below_4g, NULL, "ram-below-4g", ram, ...); memory_region_add_subregion(system_memory, 0, ram_below_4g); if (pcms->above_4g_mem_size > 0) { memory_region_init_alias(ram_above_4g, NULL, "ram-above-4g", ram, ...); memory_region_add_subregion(system_memory, 0x100000000ULL, ram_above_4g); } memory_region_init_ram(option_rom_mr, NULL, "pc.rom", PC_ROM_SIZE, &error_fatal); } // pc_memory_init } // pc_init1 可以看到,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.3KVM内存数据结构 相比于“策略”的实现,“机制”的实现往往更加简单。QEMU内存虚拟化需要完成的功能较多,包括宿主机虚拟内存的分配、MMIO的模拟、HVA到GPA的翻译等,而KVM内存虚拟化只需要维护好EPT页表,并与硬件配合即可。同时,由于KVM是Linux内核中的一个内核模块,它可以重用Linux内核的内存管理接口,降低了实现的难度。 本节不讨论由于性能不佳而不常采用的影子页表的实现,只讨论Intel x86架构下的扩展页表EPT的维护。在Linux源码树的Documentation目录下,描述内核代码的Documentation/virtual/kvm/mmu.txt文档有对KVM内存管理模块较为全面规范的描述,但较难理解。下文将抽取主线,使叙述更易懂。KVM中相关的数据结构相比于QEMU更简洁,如图315所示。 图315KVM内存虚拟化数据结构 1. 接收QEMU的内存注册 首先,QEMU应该给KVM注册需要其做地址翻译的“虚拟”物理内存,否则KVM所维护的EPT页表将无用武之地,因此从KVM接收QEMU的内存注册开始讲起。联系3.3.1节,当MemoryRegion树被更改后,都会通知所有的listener,其中包括KVM的监听器KVMMemoryListener,调用ioctl完成KVM内存注册。注册的基本单位是如下数据结构,包含GPA、HVA、该段内存的大小等字段,与QEMU中的线性视图相类似。 linux-4.19.0/include/uapi/linux/kvm.h #define KVM_MEM_LOG_DIRTY_PAGES (1UL << 0) // 需要记录脏页 #define KVM_MEM_READONLY (1UL << 1) // 只读 /* for KVM_SET_USER_MEMORY_REGION */ struct kvm_userspace_memory_region { u32 slot; // 保存ID号和AS的ID号 u32 flags; // 标志,有效的标志只有0和1,见上面宏定义 u64 guest_phys_addr; // GPA u64 memory_size; // 该段内存大小 u64 userspace_addr; // HVA }; QEMU进行ioctl系统调用后进入内核的kvm_vm_ioctl函数,并根据ioctl的参数调用相应的处理函数。此时ioctl参数是如下KVM_SET_USER_MEMORY_REGION。 linux-4.19.0/virt/kvm/kvm_main.c static long kvm_vm_ioctl(...unsigned int ioctl, unsigned long arg) { switch (ioctl) { case KVM_SET_USER_MEMORY_REGION:{ struct kvm_userspace_memory_region kvm_userspace_mem; copy_from_user(&kvm_userspace_mem, argp, sizeof(kvm_userspace_mem)); kvm_vm_ioctl_set_memory_region(kvm, &kvm_userspace_mem) -> kvm_set_memory_region(kvm, mem) -> kvm_set_memory_region(kvm, mem) break; } } 由于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 struct kvm_memory_slot { gfn_t base_gfn; // 起始GFN unsigned long npages; // slot的大小,单位是4KB的页 unsigned long *dirty_bitmap; // 脏页位图 struct kvm_arch_memory_slot arch; // 架构相关信息 unsigned long userspace_addr; // 起始HVA u32 flags; // 和user态memslot的标志位相同 short id; }; struct kvm_memslots { u64 generation; // kvm_memory_slot数组,按照base_gfn从大到小排序,形成gfn_t的地址空间 struct kvm_memory_slot memslots[KVM_MEM_SLOTS_NUM]; // 用kvm_memory_slot.id查询在memslots数组中的index short id_to_index[KVM_MEM_SLOTS_NUM]; atomic_t lru_slot; // 最近使用的slot在memslots中的索引 int used_slots; // memslots中有效元素的个数 }; struct kvm { struct kvm_memslots rcu *memslots[KVM_ADDRESS_SPACE_NUM]; // 两个AS } enum kvm_mr_change { KVM_MR_CREATE, KVM_MR_DELETE, KVM_MR_MOVE, KVM_MR_FLAGS_ONLY, }; 可以看到,每个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 int kvm_set_memory_region(struct kvm *kvm, const struct kvm_userspace_memory_region *mem) { struct kvm_memory_region old, new; struct kvm_memslots *slots = NULL; // 新的memslots enum kvm_mr_change change; new = old = *slot; // 获取原位上的旧slot // 省略new的填充,根据mem进行填充即可 // 省略对比old/new得出kvm_mr_change类型 if (change == KVM_MR_CREATE) { new.userspace_addr = mem->userspace_addr; if (kvm_arch_create_memslot(kvm, &new, npages)) goto out_free; } update_memslots(slots, &new); // 将new插入slots install_new_memslots(kvm, as_id, slots); //使用RCU将kvm->memslots设为slots } 该函数首先得到要插入位置的旧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,代码如下。 linux-4.19.0/arch/x86/include/asm/kvm_host.h struct kvm_arch_memory_slot { struct kvm_rmap_head *rmap[KVM_NR_PAGE_SIZES]; struct kvm_lpage_info *lpage_info[KVM_NR_PAGE_SIZES - 1]; } struct kvm_rmap_head { // 存储spte的地址,或存储spte链表struct pte_list_desc的地址 unsigned long val; }; struct kvm_lpage_info { int disallow_lpage; // 为1则不允许对应的gfn使用大页 }; struct pte_list_desc { u64 *sptes[PTE_LIST_EXT]; // PTE_LIST_EXT为3 struct pte_list_desc *more; }; 针对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 int kvm_arch_create_memslot(memslot, npages) -> { // 遍历所有可能的页面大小,共KVM_NR_PAGE_SIZES种页面大小 for (i = 0; i < KVM_NR_PAGE_SIZES; ++i) {// 第i + 1级页表是最后一级页表 lpages = gfn_to_index(slot->base_gfn + npages - 1, slot->base_gfn, i + 1) + 1; // 计算当前页面大小下的页面数 slot->arch.rmap[i] = kvcalloc(lpages, sizeof(*slot->arch.rmap[i]), GFP_KERNEL); // 4KB页不是大页,lpage_info无须记录其信息 // 故lpage_info数组长度为rmap长度减1 if (i == 0) continue; linfo = kvcalloc(lpages, sizeof(*linfo), GFP_KERNEL); slot->arch.lpage_info[i - 1] = linfo; // 省略是否可以使用大页的判断 ... arch.lpage_info[i - 1][...].disallow_lpage = 1 } } 这里,每个“虚拟”物理页都有两个信息: ①映射该gfn的所有spte(EPT页表项),保存在arch.rmap数组中,可以以spte链表的形式存在,每个页面都有对应的链表。该链表负责在某HVA处的页面被换出时,将所有与该HVA对应的spte置为无效。这种反向映射机制在Linux内核中也存在。②保存该gfn处是否可以使用大页,不做深入分析。 可以看到,KVM已经保存了所有客户机“虚拟”物理内存页面的信息,存在于memslots成员中,KVM维护EPT页表时将完全基于memslots数据结构。下面分析KVM如何维护EPT页表,与MMU硬件协同完成地址翻译。 2. 创建vCPU的虚拟MMU 继续介绍EPT页表页的创建与管理,相关数据结构如图316所示。其中需要着重关注的是struct kvm_mmu_page,它管理了一个EPT页表页的所有信息,vCPU虚拟MMU的主要工作是维护EPT页表中每个页表页的struct kvm_mmu_page。 图316虚拟MMU相关数据结构 为了管理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 struct kvm_vcpu_arch { unsigned long cr3; // 客户机vCPU的cr3 struct kvm_mmu mmu; // 进行GPA -> HPA翻译的虚拟MMU struct kvm_mmu *walk_mmu; // 当前正在工作的MMU的指针 struct kvm_mmu_memory_cache mmu_pte_list_desc_cache; // pte链表缓存池 struct kvm_mmu_memory_cache mmu_page_cache; // 页表页缓存池 struct kvm_mmu_memory_cache mmu_page_header_cache; // kvm_mmu_page缓存池 // EPT页表页相关数据 unsigned int n_used_mmu_pages, n_requested_mmu_pages, n_max_mmu_pages; // 维护此struct kvm的所有EPT页表页 struct hlist_head mmu_page_hash[KVM_NUM_MMU_PAGES]; // EPT页表页哈希表 struct list_head active_mmu_pages; // 全部活跃的EPT页表页 unsigned long mmu_valid_gen; // 当前版本号 } struct kvm_mmu_memory_cache { // 通过提前分配页面形成缓存池,加快数据结构的分配 int nobjs; // 缓存池中的缓存对象个数 void *objects[KVM_NR_MEM_OBJS]; // 缓存对象列表 }; struct kvm_mmu { void (*set_cr3)(struct kvm_vcpu *vcpu, unsigned long root); //设置客户机cr3 unsigned long (*get_cr3)(struct kvm_vcpu *vcpu); // 读取客户机cr3 int (*page_fault)(struct kvm_vcpu *vcpu, gva_t gva, u32 err, bool prefault); // 缺页异常处理函数 hpa_t root_hpa; // EPT基地址 u8 root_level; // GPT级数 u8 shadow_root_level; // EPT级数 bool direct_map; // 是否开启EPT } 在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 static bool read_mostly enable_ept = 1; static init int hardware_setup(void) -> if (enable_ept) vmx_enable_tdp() -> kvm_enable_tdp() -> { tdp_enabled = true; } 在此之后创建vCPU时,将完成基于EPT(即tdp)的虚拟MMU初始化。虚拟MMU的初始化分为kvm_mmu_create/kvm_mmu_setup两步: 创建和填充。调用链如下。 MMU创建流程 kvm_vm_ioctl -> kvm_vm_ioctl_create_vcpu(kvm, id) -> { //创建vCPU的ioctl调用 vcpu = kvm_arch_vcpu_create(kvm, id) -> { vcpu = kvm_x86_ops->vcpu_create(kvm, id) -> vmx_create_vcpu -> { kvm_vcpu_init -> kvm_arch_vcpu_init -> kvm_mmu_create(vcpu) { vcpu->arch.walk_mmu = &vcpu->arch.mmu; vcpu->arch.mmu.root_hpa = INVALID_PAGE; alloc_mmu_pages() { if (tdp_enabled) return; } } } return vcpu; } // kvm_arch_vcpu_create kvm_arch_vcpu_setup(vcpu) -> kvm_mmu_setup(vcpu) -> { MMU_WARN_ON(VALID_PAGE(vcpu->arch.mmu.root_hpa)); // 当vCPU创建时调用 kvm_init_mmu(vcpu, false) -> { ... // 省略除tdp类型MMU的初始化 else if (tdp_enabled) {   // 基于tdp的虚拟MMU的初始化 init_kvm_tdp_mmu(vcpu) -> { struct kvm_mmu *context = &vcpu->arch.mmu; context->page_fault = tdp_page_fault; context->set_cr3 = kvm_x86_ops->set_tdp_cr3; context->direct_map = true; // 判断vCPU的执行模式 if (!is_paging(vcpu)) { context->... } else if (is_long_mode(vcpu)) { context->... } } // init_kvm_tdp_mmu } // if ... } // kvm_init_mmu } // kvm_mmu_setup kvm->vcpus[atomic_read(&kvm->online_vcpus)] = vcpu; // vCPU 创建完成 } // kvm_vm_ioctl_create_vcpu 虚拟MMU的初始化与vCPU的初始化绑定,即一个vCPU有一个虚拟MMU。可以看到,虚拟MMU事实上包含了一组与tdp相关的页表管理函数,包括缺页异常处理函数tdp_page_fault、页表基地址设置函数set_tdp_cr3(即vmx_set_cr3),以及虚拟MMU的相关属性的设置。完成虚拟MMU的创建后,下文介绍KVM如何维护EPTP和客户机CR3。 EPT页表页kvm_mmu_page与普通进程的页表页一样,在缺页异常中创建。这不是一般的缺页异常,而是客户机引发的VMExit,是一个由Intel硬件提供的特性。该VMExit将使客户机暂停执行并进入KVM,使得KVM有机会完善EPT。为了配合硬件,KVM需要将EPT的基地址写入VMCS,让硬件MMU自动访问该EPT页表,当硬件MMU发现该页表存在不完整的情况时,将产生VMExit,并调用虚拟MMU的相关函数。 接上文对VM文件描述符的KVM_CREATE_VCPU调用,QEMU将对vCPU对应的文件描述符进行KVM_RUN调用,运行刚刚初始化并填充好的vCPU,流程如下。 EPT基地址设置流程 kvm_vcpu_ioctl -> kvm_arch_vcpu_ioctl_run -> vcpu_run -> for (;;) { if (kvm_vcpu_running(vcpu)) r = vcpu_enter_guest(vcpu); else r = vcpu_block(kvm, vcpu); if (r <= 0) break; } vcpu_enter_guest(vcpu) -> { r = kvm_mmu_reload(vcpu) -> { if (likely(vcpu->arch.mmu.root_hpa != INVALID_PAGE)) // EPT已建立好 return 0; return kvm_mmu_load(vcpu); } kvm_x86_ops->run(vcpu); // 进入客户机 r = kvm_x86_ops->handle_exit(vcpu); // 处理VM-Exit } kvm_mmu_load(vcpu) -> { mmu_topup_memory_caches(vcpu); // 预分配kvm_vcpu_arch中的三个缓存 mmu_alloc_roots -> mmu_alloc_direct_roots -> { // 分配EPT第4级页表页 struct kvm_mmu_page *sp; if (vcpu->arch.mmu.shadow_root_level >= PT64_ROOT_4LEVEL) { //四级页表 sp = kvm_mmu_get_page(vcpu, 0, 0, vcpu->arch.mmu.shadow_root_level, 1, ACC_ALL); vcpu->arch.mmu.root_hpa = pa(sp->spt); // 设置root_hpa } } kvm_mmu_load_cr3(vcpu) { if (VALID_PAGE(vcpu->arch.mmu.root_hpa)) {// 如果root_hpa指向有效的EPT vcpu->arch.mmu.set_cr3(vcpu->arch.mmu.root_hpa) { -> vmx_set_cr3(vcpu, cr3) -> { eptp = construct_eptp(vcpu, cr3); vmcs_write64(EPT_POINTER, eptp); // 设置EPT guest_cr3 = kvm_read_cr3(vcpu); vmcs_writel(GUEST_CR3, guest_cr3); // 设置客户机CR3 } // vmx_set_cr3 } // set_cr3 } // if // 刷新EPT的TLB kvm_x86_ops->tlb_flush(vcpu, true) -> vmx_flush_tlb -> { ASM_VMX_INVEPT VMX_VPID_EXTENT_SINGLE_CONTEXT VMX_VPID_EXTENT_ALL_CONTEXT } } // kvm_mmu_load_cr3 } // kvm_mmu_load 可以看到,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函数中进行。 3. 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 struct kvm_mmu_page { // 页表页描述结构 struct list_head link; // active_mmu_pages链表中的节点 struct hlist_node hash_link; // 哈希表中的节点 union kvm_mmu_page_role role; // 该EPT页表页的属性 u64 *spt; // 页表页的宿主机虚拟地址 struct kvm_rmap_head parent_ptes; // 指向此页表页的pte的链表 unsigned long mmu_valid_gen; // 此页表页版本号 } 该结构维护了一个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 vcpu_enter_guest -> kvm_x86_ops->handle_exit(vcpu) -> vmx_handle_exit -> kvm_vmx_exit_handlers[exit_reason](vcpu); static int (*const kvm_vmx_exit_handlers[])(struct kvm_vcpu *vcpu) = { [EXIT_REASON_EPT_VIOLATION]= handle_ept_violation, } enum { // 各类返回值 RET_PF_RETRY = 0, // 返回客户机重新执行引起EPT Violation的指令 RET_PF_EMULATE = 1, // 执行模拟 RET_PF_INVALID = 2, // 无效的模拟,继续执行真实的缺页异常处理过程 }; static int handle_ept_violation(struct kvm_vcpu *vcpu) { vcpu->arch.exit_qualification = vmcs_readl(EXIT_QUALIFICATION); error_code = PFERR_*_MASK; // 从exit_qualification获得 gpa = vmcs_read64(GUEST_PHYSICAL_ADDRESS); return kvm_mmu_page_fault(vcpu, gpa, error_code, NULL, 0) { r = RET_PF_INVALID; if (unlikely(error_code & PFERR_RSVD_MASK)) { r = handle_mmio_page_fault(vcpu, cr2, direct); // MMIO模拟 if (r == RET_PF_EMULATE) goto emulate; // 执行模拟 } if (r == RET_PF_INVALID) vcpu->arch.mmu.page_fault(vcpu, gpa, error_code, false) -> tdp_page_fault(vcpu, gpa, error_code, prefault) if (r == RET_PF_RETRY) return 1; // 重新执行引起EPT Violation的指令 } } 退出原因是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 static int tdp_page_fault(struct kvm_vcpu *vcpu, gva_t gpa, u32 error_code, bool prefault) { kvm_pfn_t pfn; // GPA对应的HPA int level; // 最后一级页表的level,1GB的大页是3,2MB的大页是2,4KB页是1 gfn_t gfn = gpa >> PAGE_SHIFT; int write = error_code & PFERR_WRITE_MASK; mmu_topup_memory_caches(vcpu) -> { // 预分配缓存池 mmu_topup_memory_cache(&vcpu->arch.mmu_pte_list_desc_cache, pte_list_desc_cache, 8 + PTE_PREFETCH_NUM); mmu_topup_memory_cache_page(&vcpu->arch.mmu_page_cache, 8); mmu_topup_memory_cache(&vcpu->arch.mmu_page_header_cache, mmu_page_header_cache, 4); } level = mapping_level(vcpu, gfn, &force_pt_level); // level = 1 if (fast_page_fault(vcpu, gpa, level, error_code)) // 用于脏页跟踪 return RET_PF_RETRY; if (try_async_pf(vcpu, prefault, gfn, gpa, &pfn, write, &map_writable)) return RET_PF_RETRY; spin_lock(&vcpu->kvm->mmu_lock); // 获取EPT锁 if (mmu_notifier_retry(vcpu->kvm, mmu_seq)) goto out_unlock; if (make_mmu_pages_available(vcpu) < 0) goto out_unlock; r = direct_map(vcpu, write, map_writable, level, gfn, pfn, prefault); spin_unlock(&vcpu->kvm->mmu_lock); // 释放EPT锁 return r; out_unlock: spin_unlock(&vcpu->kvm->mmu_lock); kvm_release_pfn_clean(pfn); return RET_PF_RETRY; } tdp_page_fault函数较为复杂,涉及多个Linux内核子系统,下面分步介绍。 (1) 该函数首先填充三个缓存池,每次预分配都分别增加16、8、4个预备的对象。这三个缓存池分别用于pte链表(反向映射中一个gfn对应的所有pte的链表,以及一个pte的parent_ptes链表)、EPT页表页、EPT页表页头(即描述EPT页表页的struct kvm_mmu_page)的分配,这三类对象的分配在KVM中很频繁,因此缓存池能够通过合并分配提高分配效率。 (2) 这里忽略大页管理系统,假定EPT中不映射大页,mapping_level函数将返回1。接下来,尝试缺页异常处理的快速路径,调用fast_page_fault函数。此函数和KVM脏页管理系统有关,回忆当QEMU注册memslot时,可以使用KVM_MEM_LOG_DIRTY_PAGES标志,它表示需要对这段内存进行脏页跟踪,多用于QEMU虚拟机热迁移的实现。当QEMU使用该标志进行内存注册时,KVM将会把这段内存对应的所有EPT表项置为只读,这涉及内存页的反向映射系统。于是,该页表项指向的物理页已经分配,只需要将该页表项修改为可写就可继续正常执行客户机代码,而不再产生EPT Violation,不需要执行后面的真实缺页异常处理。 (3) 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使用。 (4) 进入修改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四级页表,其定义如下。 linux-4.19.0/arch/x86/kvm/mmu.c static int direct_map(struct kvm_vcpu *vcpu, int write, int map_writable, int level, gfn_t gfn, kvm_pfn_t pfn, bool prefault) { struct kvm_shadow_walk_iterator iterator; struct kvm_mmu_page *sp; int emulate = 0; gfn_t pseudo_gfn; for_each_shadow_entry(vcpu, (u64)gfn << PAGE_SHIFT, iterator) { if (iterator.level == level) { emulate = mmu_set_spte(vcpu, iterator.sptep, ACC_ALL, write, level, gfn, pfn, prefault, map_writable); direct_pte_prefetch(vcpu, iterator.sptep); break; } if (!is_shadow_present_pte(*iterator.sptep)) { u64 base_addr = iterator.addr; base_addr &= PT64_LVL_ADDR_MASK(iterator.level); pseudo_gfn = base_addr >> PAGE_SHIFT; sp = kvm_mmu_get_page(vcpu, pseudo_gfn, iterator.addr, iterator.level - 1, 1, ACC_ALL); link_shadow_page(vcpu, iterator.sptep, sp); } } return emulate; // 可能返回RET_PF_EMULATE } 该函数根据前面步骤得到的gfn(客户机物理页号)以及pfn(宿主机物理页号)完成EPT中gfn对应部分的建立。for_each_shadow_entry宏负责遍历四级页表,其参数是客户机物理地址(gfn<> 9), TBYTE_TO_BINARY((ulong) >> 6), \ TBYTE_TO_BINARY((ulong) >> 3), TBYTE_TO_BINARY((ulong)) #define UL_TO_PTE_INDEX(ulong) \ TBYTE_TO_BINARY((ulong) >> 6), TBYTE_TO_BINARY((ulong) >> 3), TBYTE_TO_BINARY((ulong)) #define UL_TO_VADDR(ulong) \ UL_TO_PTE_INDEX((ulong) >> 39), UL_TO_PTE_INDEX((ulong) >> 30), \ UL_TO_PTE_INDEX((ulong) >> 21), UL_TO_PTE_INDEX((ulong) >> 12), \ UL_TO_PTE_OFFSET((ulong)) #define TBYTE_TO_BINARY_PATTERN "%c%c%c" #define PTE_INDEX_PATTERN \ TBYTE_TO_BINARY_PATTERN TBYTE_TO_BINARY_PATTERN TBYTE_TO_BINARY_PATTERN " " #define VADDR_OFFSET_PATTERN \ TBYTE_TO_BINARY_PATTERN TBYTE_TO_BINARY_PATTERN \ TBYTE_TO_BINARY_PATTERN TBYTE_TO_BINARY_PATTERN #define VADDR_PATTERN \ PTE_INDEX_PATTERN PTE_INDEX_PATTERN \ PTE_INDEX_PATTERN PTE_INDEX_PATTERN \ VADDR_OFFSET_PATTERN int init_module(void) -> print_ptr_vaddr(ptr); 由于内核尚没有将变量转化为二进制表示的函数,此处编写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 Value at GVA: 0xdeadbeef GPT PGD index: 304 GPT PUD index: 502 GPT PMD index: 469 GPT PTE index: 163 304 502 469 163 GVA [PGD IDX] [PUD IDX] [PMD IDX] [PTE IDX] [Offset] GVA 100110000 111110110 111010101 010100011 011001011000 可以看到四级页表的四个索引值,以及页内偏移的二进制表示。为了获得一个GVA,在模块初始化函数中,首先调用kmalloc函数生成一个int类型变量的指针,由于内核模块运行在客户机内核中,所以该指针包含一个GVA。在上面的代码片段中,客户机内核模块在该指针处写入数字: 0xdeadbeef,期望在GVA对应的HVA处读到该数字。在输出中可以看到四级页表的索引,得知在页表页中应该读取第几个页表项。接下来,内核模块找到客户机内核线程的CR3,并将其传入页表打印函数。下面是客户机内核模块的相关代码。 gpt-dump/gpt-dump.c /* static vals */ unsigned long vaddr, paddr, pgd_idx, pud_idx, pmd_idx, pte_idx; int init_module(void) -> dump_pgd(current->mm->pgd, 1); void dump_pgd(pgd_t *pgtable, int level) { unsigned long i; pgd_t pgd; for (i = 0; i < PTRS_PER_PGD; i++) { pgd = pgtable[i]; if (pgd_val(pgd) && (i == pgd_idx)) { if (pgd_large(pgd)) { pr_info("Large pgd detected! return"); break; } if (pgd_present(pgd)) { // 调用pa检查页表页起始地址 pr_pte(pa(pgtable), pgd_val(pgd), i, level); dump_pud((pud_t *) pgd_page_vaddr(pgd), level + 1); } } } } void dump_pud(pud_t *pgtable, int level); void dump_pmd(pmd_t *pgtable, int level); void dump_pte(pte_t *pgtable, int level); 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 const char *PREFIXES[] = {"PGD", "PUD", "PMD", "PTE"}; static inline void pr_pte(unsigned long address, unsigned long pte, unsigned long i, int level) { if (level == 1) pr_cont(" NEXT_LVL_GPA(CR3)="); else pr_cont(" NEXT_LVL_GPA(%s)=", PREFIXES[level - 2]); pr_cont(PTE_PHYADDR_PATTREN, UL_TO_PTE_PHYADDR(address >> PAGE_SHIFT)); pr_cont(" +64 * %-3lu\n", i); pr_sep(); // 打印换行符 pr_cont(" %-3lu: %s " PTE_PATTERN"\n", i, PREFIXES[level - 1], UL_TO_PTE(pte)); } 参数address表示本级页表页的起始地址,从上一级页表项获得,pte表示本级页表项。宏PTE_PATTREN用于打印一个页表项。继续查询下一级页表的函数调用链为dump_pgd→dump_pud→dump_pmd→dump_pte,其中每一步的逻辑大致相同。address和pte的打印结果如下。 gpt-dump/gpt-dump.txt NEXT_LVL_GPA(CR3)=0000000000000000000000110000110000001100+64 * 304 304: PGD 000000000000 0000000000000000000000011111110110010011 000001100111 NEXT_LVL_GPA(PGD)=0000000000000000000000011111110110010011+64 * 502 502: PUD 000000000000 0000000000000000000000011111110110010100 00000110011 NEXT_LVL_GPA(PUD)=0000000000000000000000011111110110010100+64 * 469 469: PMD 000000000000 0000000000000000000000111010101011100100 000001100011 NEXT_LVL_GPA(PMD)=0000000000000000000000111010101011100100+64 * 163 163: PTE 100000000000 0000000000000000000000111010101010100011 000001100011 GPA=0000000000000000000000111010101010100011 011001011000 从加粗的部分可以看到,客户机内核模块对页表页的地址调用pa函数获得其物理地址,和上一级页表项中保存的下一级页表页的物理地址完全相同,这符合预期。最终,就可以从PTE中获得物理地址,为了验证正确性,客户机内核模块对vaddr调用pa函数,打印出GPA开头的行,具体在print_pa_check函数中实现,代码如下。 gpt-dump/gpt-dump.c static inline void print_pa_check(unsigned long vaddr) { paddr = pa(vaddr); pr_info(" GPA=" PADDR_PATTERN "\n", UL_TO_PADDR(paddr)); } 可以看到,和之前获得的PTE中的物理地址相同,说明读取页表的结果正确。其中低12位是页内偏移,物理地址和虚拟地址中的页内偏移完全相同。最后,客户机内核模块调用kvm_hypercall1(22, paddr)函数,将paddr传给KVM。 3. KVM中的地址转换: GPA到HPA KVM负责维护EPT,具有读取EPT的权限。本实验修改宿主机内核的KVM模块,增加一个超级调用处理函数,接收从客户机传来的GPA,模拟GPA到HPA的翻译。在CPU虚拟化章节中提到,当客户机执行了一个敏感非特权指令时,会引起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 static int (*const kvm_vmx_exit_handlers[])(struct kvm_vcpu *vcpu) = { [EXIT_REASON_VMCALL] = handle_vmcall, } static int handle_vmcall(struct kvm_vcpu *vcpu) { return kvm_emulate_hypercall(vcpu); } 函数handle_vmcall调用超级调用模拟函数kvm_emulate_hypercall,代码如下。 linux-4.19.0/arch/x86/kvm/x86.c int kvm_emulate_hypercall(struct kvm_vcpu *vcpu) { unsigned long nr, a0, a1, a2, a3, ret; nr = kvm_register_read(vcpu, VCPU_REGS_RAX); a0 = kvm_register_read(vcpu, VCPU_REGS_RBX); .. switch (nr) { case KVM_HC_DUMP_SPT: print_gpa_from_guest(a0); mmu_spte_walk(vcpu, pr_spte); ret = 0; break; .. } } 其中,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 EPT PGD index: 0 EPT PUD index: 0 EPT PMD index: 469 EPT PTE index: 163 0 0 469 163 GPA [PGD IDX] [PUD IDX] [PMD IDX] [PTE IDX] [Offset] GPA 000000000 000000000 111010101 010100011 011001011000 可以看到,此处的GPA与在客户机中读取GPT得到的GPA相同,说明客户机内核模块的超级调用成功。接下来调用mmu_spte_walk函数遍历此vCPU的EPT,在代码中称作spt,这是为了和影子页表共用一套代码。传入mmu_spte_walk函数的参数有vcpu,以及遍历到一个页表项时所调用函数的指针pr_spte,负责打印页表项。遍历代码如下。 linux-4.19.0/arch/x86/kvm/mmu.c unsigned long gpa_from_guest, pxx_idx[PT64_ROOT_4LEVEL]; void mmu_spte_walk(struct kvm_vcpu *vcpu, inspect_spte_fn fn) { int i; struct kvm_mmu_page *sp; if (!VALID_PAGE(vcpu->arch.mmu.root_hpa)) return; if (vcpu->arch.mmu.root_level >= PT64_ROOT_4LEVEL) { hpa_t root = vcpu->arch.mmu.root_hpa; sp = page_header(root); mmu_spte_walk(vcpu, sp, fn, 1); } } static void mmu_spte_walk(struct kvm_vcpu *vcpu, struct kvm_mmu_page *sp, inspect_spte_fn fn, int level) { int i; for (i = 0; i < PT64_ENT_PER_PAGE; ++i) { u64 *ent = sp->spt; if (i == pxx_idx[PT64_ROOT_4LEVEL - (5 - level)] && is_shadow_present_pte(ent[i])) { struct kvm_mmu_page *child; fn(pa(ent), ent[i], i, level); // 调用pa检查页表页起始地址 if (!is_last_spte(ent[i], 5 - level)) {// 继续下一级遍历 child = page_header(ent[i] & PT64_BASE_ADDR_MASK); mmu_spte_walk(vcpu, child, fn, level + 1); } else { if (is_large_pte(ent[i])) // 遇到大页 print_huge_pte(ent[i]); else// 未遇到大页 print_pte(ent[i]); } } } } 如3.3.3节所述,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 NEXT_LVL_HPA(EPTP) =0000000000000000000111010111011000100101+64 * 0 0: PGD 000000000000 0000000000000000001000100001100010010111 000100000111 NEXT_LVL_HPA(PGD)=0000000000000000001000100001100010010111+64 * 0 0: PUD 000000000000 0000000000000000001000000010100010100100 000100000111 NEXT_LVL_HPA(PUD)=0000000000000000001000000010100010100100+64 * 469 469: PMD 000000000000 0000000000000000001000100000111000011111 000100000111 NEXT_LVL_HPA(PMD)=0000000000000000001000100000111000011111+64 * 163 163: PTE 000000000000 0000000000000000000110101101111010011011 111101110111 由于本实验没有关闭宿主机上的大页,KVM在查询到最后一级页表时做了两种处理: 如果遍历到大页,则调用print_huge_pte函数打印最后获取HPA的过程; 否则调用print_pte函数。具体的打印代码不再赘述,下面只展示最后如何使用代码得到HPA。 linux-4.19.0/arch/x86/kvm/mmu.c unsigned long gpa_from_guest, pxx_idx[PT64_ROOT_4LEVEL]; // 大页的情况,仅考虑2MB的大页 static inline u64 print_huge_pte(u64 ent) { // 最终宿主机物理页的HPA u64 page_hpa = ent & PT64_DIR_BASE_ADDR_MASK, // 获取页偏移的mask offset_mask = PT64_LVL_OFFSET_MASK(2) | (PAGE_SIZE - 1), // 物理页HPA加上页偏移,最终调用va()得到HVA *ptr = (u64*)(va(page_hpa + (gpa_from_guest & offset_mask))); // 最终读取ptr处的数据,为0xdeadbeef pr_info("Value at HPA: %p\n" , (void *) *ptr); } // 普通4KB页的情况 static inline u64 print_pte(u64 ent) { // 最终宿主机物理页的HPA u64 page_hpa = ent & PT64_BASE_ADDR_MASK, // 获取页偏移的mask offset_mask = PAGE_SIZE - 1, // 物理页HPA加上页偏移,最终调用va()得到HVA *ptr = (u64*)(va(page_hpa + (gpa_from_guest & offset_mask))); // 最终读取ptr处的数据,为0xdeadbeef pr_info("Value at HPA: %p\n" , (void *) *ptr); } 对于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文件存储了完整的输出信息,供读者查看。 3.4GiantVM内存虚拟化 第2章结尾介绍了“多虚一”的虚拟机监控器GiantVM,基于QEMU/KVM的Type Ⅱ开源虚拟机监控器运行在多个物理节点上,为操作系统提供了一个透明的“虚拟”物理硬件,而这些“虚拟”物理硬件的后台实现是多个物理节点上的物理资源。这种分布式的虚拟机监控器既可以聚合海量的计算资源,为资源要求高的工作负载(如机器学习、大数据分析)提供便利,也可以覆盖分布式系统的复杂性,程序员可以将在单机上运行的程序无修改地运行在一个分布式系统上,即提供一个SSI,如本章开头多机上的“虚拟”物理地址空间所述。 分布式框架(如Spark、MapReduce)往往有复杂的编程模型,程序员想要让自己的程序运行在分布式系统上,则必须要根据这些分布式框架提供的接口改写旧的代码; 而巨型虚拟机GiantVM可以运行任何一个普通的操作系统,给程序员提供与单机环境上完全相同的接口,无须修改原有的代码即可以将程序运行在海量资源之上,甚至原来难以运行在分布式集群上的应用也可以借助巨型虚拟机运行在分布式集群上。 在一个运行在GiantVM的客户机看来,它拥有大量的CPU和物理内存。经过测试,GiantVM可以运行具有512个vCPU和2TB内存的sv6操作系统代码仓库地址: https://github.com/aclements/sv6,sv6是一个实验性质的操作系统。。第2章已经介绍了CPU的虚拟化以及跨节点中断转发的实现。作为QEMU/KVM内存虚拟化原理的拓展,本章介绍分布式共享内存的原理以及内存虚拟化在GiantVM中的实现。 3.4.1分布式共享内存 如3.1节所述,多机上的“虚拟”物理内存的后台实现是多个物理节点,每个节点都拥有一定数量的物理内存,将这些物理内存通过虚拟化的方式聚合起来,建立一个虚拟“物理内存”的抽象层,就可以供操作系统无修改地运行。接下来的问题是: 以什么样的方式建立该抽象层呢?容易想到,应该用地址翻译建立抽象层,下文讨论如何完成地址的管理。 从熟悉的CPU缓存架构出发: 每个CPU核都有一个私有的一级/二级缓存(L1/L2 cache,L1 cache大小一般为32KB,L2 cache大小一般为256KB),它们保存着主存(内存)中数据的副本。每次访问主存之后,需要将访问地址周围缓存行大小(一般为64字节)的数据复制到缓存中,缓存行是主存和缓存之间数据交换的单位。为了保证每个CPU核的缓存中副本的正确性,有一套MESI缓存一致性协议控制每个缓存行的状态,包括已修改的(Modified,M)、独占的(Exclusive,E)、共享的(Shared,S)、无效的(Invalid,I)四种状态。CPU对缓存行的读写操作都将触发缓存行状态的改变,例如CPU读取一个无效的缓存行,则首先将该缓存行对应数据的最新版本写入主存,再将该数据拷贝到该缓存行中,最后将该缓存行的状态置为共享。这样每个CPU核心都能够读到该数据的最新副本,从而避免读取到旧的数据产生错误。缓存架构在Intel SDM中有详细介绍,有兴趣的读者可以查阅。 回想多台机器上的“虚拟”物理地址空间,对它的管理类似于CPU缓存架构: 将每个物理节点类比为一个CPU核心,而“虚拟”物理地址空间视作主存,那么每个物理节点的内存中均保存着“虚拟”物理地址空间中数据的一个副本。为了减少网络访问,每个机器的内存和全局的“虚拟”物理内存之间交换数据的单位是页,大小为4KB,而非64字节的缓存行,每个页都有一个类似于MESI的状态。于是,如果一个访存指令访问一个“虚拟”物理地址空间中的地址,那么首先在执行这条指令的机器的内存中查看是否保存了一份该数据的副本,如果没有此副本,则向一个全局的控制者询问: 这份数据保存在哪里?于是可以取得该数据的最新副本,并复制到本地内存。这样访问“虚拟”物理地址空间中地址的指令就可以访问整个分布式集群中所有节点的物理内存,虽然需要经过网络,但还是实现了内存的聚合。 可能有读者会产生疑问: 如果给每台机器划分出一块“虚拟”物理地址空间的范围供其专门管理,只要访问的“虚拟”物理地址落在某个机器所管辖的范围内,那么是否该地址对应数据的副本就在这台机器上?例如,如果要实现一个16G的物理地址空间,一共有4个节点,那么节点1负责前1/4的地址空间,节点2负责第二个1/4的地址空间,以此类推。这种实现方式本质上还是需要将远程节点的数据缓存到本地,并维护每个缓存页的状态,否则会产生大量的网络访问,例如,如果节点1频繁访问节点2所管辖的内存范围,那么节点1就需要通过网络频繁访问节点2的内存,造成性能问题。 图318MSI协议状态变迁图 GiantVM的构想与多年前IVY这一DSM(Distributed Shared Memory,分布式共享内存)系统设计者Kai Lihttp://css.csail.mit.edu/6.824/2014/papers/lidsm.pdf中介绍了Kai Li设计的分布式共享内存实现。的理念不谋而合: 如图318所示,为了维护数据一致性,IVY定义了已修改、 共享、无效三种状态,每个页都处于三种状态之一。无效状态表示当前页的内容无效,此时该页不可读写,若进行读写则产生缺页异常,进入 IVY 进行状态迁移; 共享状态表示当前页可能被多台机器共享,此时该页只读,若进行写入则触发状态迁移; 已修改状态表示当前页被一台机器独占,此时该页可由这台机器读写。在状态迁移的处理上,IVY 采取了与基于目录的一致性协议类似的做法,引入了管理者(Manager)的概念,相当于缓存一致性算法中的目录(Directory),由管理者跟踪每个页的持有者(Owner)。对无效页进行读取时,IVY系统通过查询管理者找到页的持有者,若是已修改页,则该已修改页转到共享状态,否则不变,然后将请求者(该无效页所在的机器)加入持有者的复制集(Copyset),最后将该页的拷贝返回给请求者,该页即可以从无效状态转移到共享状态。类似地,对无效或共享页进行写入时,IVY 系统也是通过查询管理者找到页的持有者,获取拷贝,不过此时还需要获取其复制集,取得拷贝后完成写入,最后向复制集中的所有机器发送无效消息,令它们上面的所有对应页进入无效状态,即可以完成写入,使写入者转移到已修改状态。 当客户机用“虚拟”物理地址进行内存访问时,就可以复用IVY系统的MSI(Modified、Shared、Invalid,已修改的、共享的、无效的)页状态管理机制,使用“虚拟”内存地址查找真实数据在分布式集群中的位置。IVY向上层操作系统暴露与实际内存完全相同的语义,即随机访问和按字节寻址。有了DSM模型,就可以讨论如何在QEMU/KVM中实现该模型,即实现GiantVM的内存虚拟化。 3.4.2GiantVM中的DSM架构 下文将基于QEMU/KVM二次开发的分布式QEMU称为dQEMU(distributed QEMU,分布式QEMU),当它单独部署到一台物理机上时,就退化成为普通的QEMU; 而当部署到多台物理机上时,则每台物理机上都会启动一个dQEMU实例(即dQEMU进程),dQEMU与其所在机器的KVM模块协同工作,由多个dQEMU实例通过网络共同构成一个分布式的虚拟机监控器。GiantVM的实现既有基于RDMA(Remote Direct Memory Access,远程内存访问)的版本,也有基于TCP(Transmission Control Protocol,传输控制协议)的版本。RDMA由于其绕过了远端操作系统,性能比TCP更高,但需要特殊的硬件。 首先,在dQEMU内存参数方面,假设“虚拟”物理内存的大小是2TB,在启动dQEMU进程时依然采用参数m 2T,于是在每个节点上运行的vCPU依然可以访问2TB的“虚拟”物理内存。此时dQEMU进程仅仅使用mmap在宿主机上分配的2TB虚拟内存,并未分配实际的物理内存,未建立GPT和EPT的相关表项。而具体访问的数据保存在哪个节点上由KVM进行管理,即在KVM中仿照IVY系统实现DSM,这是由于KVM可以管理EPT,EPT管理了“虚拟”物理内存。其次,为了让客户机操作系统对巨型虚拟机的实际物理拓扑有一定认识,GiantVM将物理机抽象成NUMA节点的形式呈现给客户机(通过dQEMU的numa参数),整个巨型虚拟机将被理解为一台巨大的 NUMA 机器,其中每个 NUMA节点对应于一台物理机器,这样客户机操作系统的进程调度和迁移、内存管理等都会对巨型虚拟机的物理拓扑有所参考。 在实现的内存模型方面,由于x86硬件给操作系统提供的内存模型是x86TSO(x86 Total Store Order,x86全存储序),因此GiantVM实现的内存模型的一致性不能弱于x86TSO,否则一般的操作系统无法正确运行。而IVY提供了顺序一致性的内存模型强于TSO,故可以将IVY系统整合进KVM,即可实现一个具有顺序一致性保证的“虚拟”物理内存平台。限于篇幅,此处暂不考虑更宽松的内存一致性模型。图319展示了在GiantVM中处理客户机读一个无效页的过程。整个过程的第1步是通过页表权限的控制,令客户机在执行内存读取操作时产生异常,退出到 KVM。随后,根据内存同步协议,需要先向管理者请求,然后管理者将请求转发给持有者,即第2、3步。这里,KVM专门开辟了一个内核线程作为服务端,用于接收其他节点发来的消息。持有者收到消息后,要访问客户机的内存,读取该页的内容,即第4、5步,随后才能将其返回给请求者。最后,请求者获得该页的副本后,还要先写入客户机的内存,即第8、9 步,并修改页表权限、将页转到共享状态,然后才能返回客户机模式。 图319IVY在GiantVM中的实现 下文将介绍在KVM中如何实现一个类似于IVY系统的内存管理系统,可以和前文KVM内存虚拟化的代码相互照应,作为一个拓展。GiantVM内存虚拟化的实现基于EPT的管理,由于EPT存储了“虚拟”物理内存页的一切信息,且与客户机页表完全解耦。 3.4.3GiantVM中DSM的实现 本节简要介绍如何在KVM中实现上文提出的10个处理步骤,可能涉及vCPU线程以及其他处理线程,均在内核态运行,下面分步介绍。 1. 页表权限的设置 可以看到,整个流程之所以被触发,就是因为页的权限设置,可以说它是内存同步协议成立的重要原因,在整个系统中的地位不言而喻。由于KVM同时支持影子页表和二级页表(即EPT)两种内存虚拟化模式,权限设置对应地也有两种,即设置影子页表或EPT中页表项的权限。实际运用中,影子页表模式基本上已被淘汰,因此仅考虑EPT模式的支持。 首先考察KVM本身对页表的管理。在KVM中,客户机触发缺页异常(影子页表模式)或EPT Violation(EPT模式)后,最终会来到 kvm_mmu_page_fault函数,然后根据模式的不同,通过函数指针调用不同的处理函数。EPT模式对应的函数为 tdp_page_fault函数,其中最重要的两个步骤分别是调用 try_async_pf函数和调用 direct_map函数。tdp_page_fault函数的参数是 GPA,前一个步骤所做的工作就是找到它对应的 HVA,然后根据 HVA 找到对应的PFN(Physical Frame Number,宿主机物理页框号)。后一个步骤所做的工作则是根据GFN(Guest Frame Number,客户机页框号)和PFN建立页表,这里就是建立EPT页表。最终的权限设置在set_spte函数中进行。 GiantVM在上述两个步骤之间增加了一个步骤,即调用 kvm_dsm_page_fault函数执行内存同步协议,这一步的返回值是IVY协议想为新建立的页表项设置的权限。EPT 表项基本上有三个权限位,即 R、W、X,分别是 EPT 表项的第 0~2位。对于已修改页,三个权限位都设置为 1,对应的返回值为 ACC_ALL; 对于共享页,将R、X 位设置为 1,对应的返回值为 ACC_EXEC_MASK | ACC_USER_MASK; 对于无效页,三个位都设置为 0。取得返回值后,传入 direct_map 函数,最后一路传到 set_spte函数,就实现了对页表项权限位的控制。下面代码展示了页表项权限位的控制流程。 giantvm-linux/arch/x86/kvm/mmu.c | dsm.c vCPU thread -> handle_ept_violation -> kvm_mmu_page_fault -> tdp_page_fault {// mmu.c if (try_async_pf(.., &pfn, ..)) return 0; dsm_access = kvm_dsm_vcpu_acquire_page(vcpu, &slot, gfn, write); r = direct_map(.., dsm_access); } kvm_dsm_vcpu_acquire_page -> // 是dsm模块向mmu模块提供的一个接口,见dsm.c kvm_dsm_acquire_page -> kvm_dsm_page_fault -> ivy_kvm_dsm_page_fault // 根据IVY协议得出页状态 direct_map(dsm_access) -> mmu_set_spte(pte_access) -> set_spte(pte_access) -> mmu_spte_update(sptep, new_spte) -> mmu_spte_set(sptep, new_spte) -> set_spte(sptep, new_spte) -> WRITE_ONCE(*sptep, spte); // 最终更新EPT表项 上面只介绍了对于权限的升级,相对应的降级操作则是在服务端内核线程进行的,例如收到无效化请求后,就要将自身的状态切换到无效。这里的操作更简单,可以直接利用 mmu_spte_update 函数修改页表项,不过此处使用的是 KVM 里进一步封装的函数。详细调用过程如下。 giantvm-linux/arch/x86/kvm/dsm.c | ivy.c kvm_vm_ioctl_dsm ->// dsm.c kvm_dsm_init -> thread = kthread_run(kvm_dsm_threadfn, (void*)kvm, // 启动主处理线程 "kvm-dsm/%d", kvm->arch.dsm_id); // kvm_dsm_threadfn在接收到一个请求后,启动NDSM_CONN_THREADS个req处理线程 kvm_dsm_threadfn { while (1) { ret = network_ops.accept(listen_sock, &accept_sock, 0); // 接收请求 for (i = 0; i < NDSM_CONN_THREADS; i++) { thread = kthread_run(kvm_dsm_handle_req, (void*)conn, "dsm-conn/%d:%d", kvm->arch.dsm_id, count++); } } } kvm_dsm_handle_req -> ivy_kvm_dsm_handle_req { // ivy.c while (1) { switch (req.req_type) { case DSM_REQ_INVALIDATE: ret = dsm_handle_invalidate_req(kvm, conn_sock, memslot, slot, &req, &retry, vfn, page, &tx_add); case DSM_REQ_WRITE: ret = dsm_handle_write_req(kvm, conn_sock, memslot, slot, &req, &retry, vfn, page, &tx_add); case DSM_REQ_READ: ret = dsm_handle_read_req(kvm, conn_sock, memslot, slot, &req, &retry, vfn, page, &tx_add); } } } 2. 客户机内存的访问 上文主要围绕图319中的步骤1展开,下面考察步骤4、5和8、9,即对客户机内存的读写访问的实现,相对而言这要简单得多。在KVM中,原本就有读写客户机内存的功能,一些半虚拟化特性(如时钟等)都会用到,它们最终分别是由kvm_read_guest_page和kvm_write_guest_page这两个函数实现的。因此,对于步骤8、9,KVM可以直接调用这两个函数对客户机内存进行读写。但是,对于步骤4、5,情况则有所不同。 步骤8、9实际上位于上文介绍的kvm_dsm_page_fault函数中(它又在tdp_page_fault函数中),而步骤4、5则是在专门开辟的服务端内核线程中执行的。这两者最大的区别在于,前者位于QEMU进程的vCPU线程中,而后者作为内核线程,没有对应的用户态程序。KVM在实现读写客户机内存的功能时,是通过GPA找到对应的HVA,然后使用Linux内核提供的copy_from_user和copy_to_user调用,从用户地址空间读取或向其写入。对于前者,用户地址空间就是QEMU进程的地址空间,因此操作能够成功。对于后者,不存在对应的用户地址空间,因此调用会失败。事实上,Linux内提供了为内核线程临时挂载用户地址空间的功能。服务端内核线程进行客户机内存读写操作时,首先通过use_mm函数挂载KVM所在的QEMU进程的地址空间,然后便可以进行读写操作,最后用unuse_mm函数取消挂载,恢复原状。这样就解决了服务端内核线程中无法对客户机进行内存读写的问题,调用链如下。 giantvm-linux/arch/x86/kvm/ivy.c dsm_handle_read_req/dsm_handle_write_req -> kvm_read_guest_page_nonlocal { use_mm(kvm->mm); ret = kvm_read_guest_page(slot, gfn, data, offset, len) { addr = gfn_to_hva_memslot_prot(slot, gfn, NULL); r = copy_from_user(data, (void user *)addr + offset, len); } unuse_mm(kvm->mm); } 3. 页面状态的维护 通过上文介绍,读者可以得知分布式共享内存如何进行必要的操作,如页表权限管理和客户机内存访问。构成分布式共享内存的另一个核心要素就是与每个页相关联的MSI状态,可以说内存同步协议就是围绕页的状态转移展开的。显而易见,节点之间发送的同步消息,例如读取某个页或无效化某个页的请求,必然要指明请求的是哪个页,否则无从确定此同步操作的对象。由于各个节点的dQEMU进程不可能有共同的虚拟地址,它们一定只能使用GFN指定操作对象。由此自然得出的一个结论就是,页的MSI状态以及复制集等信息也应该与GFN绑定。 在QEMU/KVM中,客户机的内存本质是在QEMU中申请的一段内存,QEMU可以通过HVA直接访问它。在QEMU中,通过使用由MemoryRegion对象构成的树来管理内存,QEMU将内存注册到KVM时还原成一维的内存区间进行注册,每个区间具有起始地址(包括HVA地址和GPA地址)和大小。在KVM中,上述内存区间由kvm_memory_slot表示,正如3.3.3节介绍。一种解决方案是,向该数据结构中添加一项struct kvm_dsm_info *gfn_dsm_state,用于维护分布式共享内存中的页面信息。在服务端内核线程收到消息后,根据 GFN 能很容易地找到对应的内存插槽并进一步检查或修改页面的 MSI 状态等。 然而,在上述原型系统中,会偶尔出现内存状态不一致的现象,例如本该被标为无效的页,客户机却没有经过同步就读取到了它的内容。经过进一步的排查和调研,这是因为对于同一个GFN也就是同一个页,系统实际上维护了两份kvm_dsm_info,其中一份被标记为无效时,另一份可能仍处于共享状态因此可以被读取。这是因为两个不同的GFN对应的是同一块宿主机的内存,QEMU在注册时为同一段宿主机虚拟地址注册了两次,分别使用了不同的GPA。这一般是为了模拟一些硬件在1MB以内的低地址有一个为兼容而存在的传统MMIO区域,在高地址又有一个MMIO区域,两个MMIO区域能访问到的内容是相同的。 简而言之,在QEMU/KVM中,一个HVA可能对应多个GPA,而每个HVA到GPA的映射都对应一个kvm_memory_slot,从而维护一份页面信息,而GPA到HVA的映射是一对一的。要解决这些问题,办法就是将页面信息与HVA(即QEMU中的地址)绑定,而不是与GFN绑定。为此,①GiantVM引入了kvm_dsm_memory_slot数据结构(以下简称hvaslot),用于维护QEMU申请的HVA内存区块的信息,页面状态等信息转为放入hvaslot中维护,这样就可以避免同一块内存拥有多份页面信息。②仍然使用GFN作为节点间传递的信息。当客户机发生缺页异常或服务端内核线程收到消息时,可以根据待处理的页的GFN,利用内存插槽中维护的GFN到HVA的映射,找到对应的hvaslot,进而读取或修改页面的MSI状态等。③另外,GiantVM还需要在hvaslot中维护从HVA到GFN的反向映射,这是KVM自身没有维护的。当GiantVM需要根据内存同步协议修改一个页的权限时,不能只修改同步消息中GFN对应的页表项,应该由该GFN找到对应的HVA,再根据反向映射找到所有关联的GFN,修改所有这些GFN对应的页表项的权限。经过上述重构后,此前随机出现的内存不一致现象得到了消除,客户机能长时间稳定运行。 在源码实现里,kvm_dsm_memory_slot由kvm_dsm_memslots管理,存储在struct kvm_arch中,不同于kvm_memslots直接存储在struct kvm中,数据结构之间的关系见下面的代码。 giantvm-linux/arch/x86/include/asm/kvm_host.h struct kvm -> struct kvm_arch arch; // include/linux/kvm_host.h struct kvm_arch { struct kvm_dsm_memslots *dsm_hvaslots; } struct kvm_dsm_memslots { struct kvm_dsm_memory_slot memslots[KVM_MEM_SLOTS_NUM]; // hvaslot数组 int used_slots; }; struct kvm_dsm_memory_slot { hfn_t base_vfn; // HVA起始地址 unsigned long npages; struct kvm_dsm_info *vfn_dsm_state; // kvm_dsm_info数组 }; struct kvm_dsm_info { // HVA页状态,包括DSM_INVALID、DSM_SHARED、DSM_MODIFIED、DSM_OWNER等 unsigned state; DECLARE_BITMAP(copyset, DSM_MAX_INSTANCES); // 复制集位图 #ifdef KVM_DSM_DIFF struct {...} diff; // 压缩优化相关状态,见下文 #endif }; kvm_dsm_memory_slot的添加流程与kvm_memory_slot相同,和3.3.4节所述类似,从QEMU调用ioctl开始,到添加kvm_memory_slot的函数kvm_arch_create_memslot→kvm_dsm_register_memslot_hva为止,有兴趣的读者可以查阅源码。kvm_dsm_memory_slot是DSM协议实现的关键数据结构,在代码中经常出现。 4. 网络通信 至此已经基本介绍完分布式共享内存在 KVM 中的实现以及遇到的问题,不过还有网络通信的实现,即图319中的2、3、6和7步,尚未展开说明。 为了减少在网络中需要传输的数据,GiantVM进行了压缩优化。提供编码和解码两个原语(dsm_encode_diff/dsm_decode_diff函数),需要在网络中传输页面时,编码函数得到新旧页数据的差,并进行编码; 而解码函数对编码函数得到的差进行解码,与旧页数据进行合并,得到新页数据。压缩优化的实质在于当节点1向节点2传输数据时,如果节点2已经保存了节点1所传输数据的旧版本,那么只需要传输两者数据之差。下面用一次读取请求作为例子,介绍压缩优化的使用,代码如下。 giantvm-linux/arch/x86/kvm/ivy.c struct kvm_network_ops network_ops; // 网络请求函数集,见arch/x86/kvm/dsm-util.c // vCPU线程(发起读取请求) int ivy_kvm_dsm_page_fault(...) // 调用链见3.1节 { char *page = NULL; page = kmalloc(PAGE_SIZE, GFP_KERNEL); if (write) { ... // 处理写引起的缺页异常 } else { // 省略DSM协议实现 ret = resp_len = kvm_dsm_fetch(kvm,owner,false,&req, page, &resp) -> { ret = network_ops.send(...); ... ret = network_ops.receive(*conn_sock, page,...); } // kvm_dsm_fetch } dsm_decode_diff(page, resp_len, memslot, gfn); kvm_write_guest_page(memslot, gfn, page, 0, PAGE_SIZE); } // 服务端内核线程(处理读取请求) static int dsm_handle_read_req(...char *page) { ... kvm_read_guest_page_nonlocal(kvm, memslot, req->gfn, page, 0, PAGE_SIZE); ...// 省略DSM协议的实现 if (is_owner) length = dsm_encode_diff(slot, vfn, req->msg_sender, page, memslot, req->gfn, req->version); ret = network_ops.send(conn_sock, page, length, 0, tx_add); } 在此实例中,一个节点上的KVM发生了EPT缺页异常,最终调用IVY协议的缺页处理函数ivy_kvm_dsm_page_fault。它首先分配一个PAGE_SIZE大小的页作为读取结果的存放页(这是由于DSM中网络传输的单位是4KB页,而非64字节大小的缓存行),page指向该页。接下来,向该页的持有者发起读取请求,调用kvm_dsm_fetch函数,最后通过网络接收到服务端内核线程返回的编码后的数据。于是,page被压缩后的页内容填充,使用dsm_decode_diff函数进行解码,最后kvm_write_guest_page函数写入客户机物理内存。 服务端内核线程接收到客户端的读请求后,首先从客户机物理内存中读取该页,并调用dsm_encode_diff函数进行编码,再使用send接口向网络发送该页,就能减小网络开销。 另一种减小网络开销的方式是使用绕过内核的网络传输。具体来说,目前一共有三种 RDMA 解决方案,即 Infiniband(无限带宽技术)、RoCE(RDMA over Converged Ethernet,基于统合式以太网的RDMA)和 iWARP(internet WideArea RDMA Protocol,互联网广域RDMA协议)。它们共同的特点是低延迟、高带宽,并且支持用户态协议栈,即应用程序可以不经过内核而直接操控网卡发送数据包,因此可以带来很大的性能提升。巨型虚拟机使用Infiniband解决方案。使用RDMA的network_ops如下。 giantvm-linux/arch/x86/kvm/dsm.c struct kvm_network_ops { // arch/x86/kvm/dsm-util.h int (*send)(kconnection_t *, const char *, size_t, unsigned long, const tx_add_t*); int (*receive)(kconnection_t *, char *, unsigned long, tx_add_t*); int (*connect)(const char *, const char *, kconnection_t **); int (*listen)(const char *, const char *, kconnection_t **); int (*accept)(kconnection_t *, kconnection_t **, unsigned long); int (*release)(kconnection_t *); }; //使用/ static int kvm_dsm_init() { #ifdef USE_KRDMA_NETWORK network_ops.send = krdma_send; network_ops.receive = krdma_receive; network_ops.connect = krdma_connect; network_ops.listen = krdma_listen; network_ops.accept = krdma_accept; network_ops.release = krdma_release; #endif } 5. 伪共享问题 显而易见,巨型虚拟机内存虚拟化系统的性能瓶颈在于网络开销。在DSM系统中,伪共享会造成巨大的网络开销,严重降低GiantVM的性能。伪共享是指两个没有联系的变量处在同一个页上,假设是变量X1和X2。节点1不断地写入变量X1,导致在节点2上,X1所在的页被置为无效; 而当节点1频繁写入X1变量时,节点2频繁地读取X2,而它发现X2所在的页被设为无效,就需要通过网络从节点1获取该页的最新副本,开销很大。这种情况在巨型虚拟机中十分常见,一种解决方法是将互不相关的变量分配在不同的页上,通过内核编译选项实现; 另一种解决方案是将经常访问共享页面的vCPU调度到同一个节点上,这样就不需要经过网络。 本章小结 本章首先介绍了内存虚拟化的抽象原理,说明了内存虚拟化的核心是维护地址的映射; 再介绍了现实世界中内存虚拟化的实现方式,包括影子页表与扩展页表,并介绍了敏捷页表作为扩展; 接下来对QEMU/KVM中的内存虚拟化实现进行深入介绍,最后一小节作为对内存虚拟化原理以及QEMU/KVM源码的拓展,介绍了巨型虚拟机“多虚一”系统的实现。源码章节中的实验部分有助于读者更加直观地理解源码实现,而源码实现是较难理解的部分。内存作为云环境的重要计算资源,对程序性能有着举足轻重的影响。内存虚拟化作为云环境中内存资源的基石,值得进一步做性能优化,等待读者去探索。