在 x86 架构的虚拟化环境中,位于 arch/x86/kvm 目录下的 mmu.c 和 mmu.h 文件以及 paging_tmpl.h 文件负责向客户机提供标准的 x86 内存管理单元,并在将客户机的物理地址转换为主机物理地址时执行操作.
描述了 mmu 代码需要满足以下要求:
-
正确性: - 客户机不应能够确定它是在模拟的 mmu 上运行,除了在时间上(我们试图遵守规范,而不是模拟特定实现的特征,如 TLB 大小)。
-
安全性: - 客户机不得能够访问未分配给它的主机内存。
-
性能: - 最小化 mmu 引入的性能惩罚。
-
可扩展性: - 需要适应大内存和大 VCPU 客户机。
-
硬件: - 支持完整范围的 x86 虚拟化硬件。
-
集成: - Linux 内存管理代码必须控制客户机内存,以便交换、页面迁移、页面合并、透明超大页和类似功能可以无需更改而正常工作。
-
脏页跟踪: -报告对客户机内存的写操作,以启用实时迁移和基于帧缓冲的显示。
-
占用空间: - 保持内核内存固定的数量较低(大多数内存应该是可收缩的)。
-
可靠性: - 避免多页或 GFP_ATOMIC 分配。
首字母缩写
==== ==================================================================== pfn 主机页帧编号
hpa 主机物理地址
hva 主机虚拟地址
gfn 客户机页帧编号
gpa 客户机物理地址
gva 客户机虚拟地址
ngpa 嵌套客户机物理地址
ngva 嵌套客户机虚拟地址
pte 页表项(也用于泛指分页结构的条目)
gpte 客户机页表项(指代 gfns)
spte 影子页表项(指代 pfns)
tdp 二维分页(NPT 和 EPT 的供应商中立术语) =======================================================================
支持虚拟的实际硬件
mmu 支持第一代 mmu 硬件,允许在客户机进入期间进行当前分页模式和 cr3 的原子切换,以及二维分页(AMD 的 NPT 和 Intel 的 EPT)。它所模拟的硬件是传统的 2/3/4 级 x86 mmu,支持全局页面、pae、pse、pse36、cr0.wp 和 1GB 页面。模拟硬件还能够在支持 NPT 的主机上公开 NPT 能力。
翻译
mmu 的主要任务是编程处理器的 mmu,以翻译客户机的地址。在不同的时候需要不同的翻译:
-
当客户机分页被禁用时,我们将客户机物理地址翻译为主机物理地址(gpa->hpa)。
-
当客户机分页被启用时,我们将客户机虚拟地址翻译为客户机物理地址,再到主机物理地址(gva->gpa->hpa)。
-
当客户机启动其自己的客户机时,我们将嵌套客户机虚拟地址翻译为嵌套客户机物理地址,再到客户机物理地址,最终到主机物理地址(ngva->ngpa->gpa->hpa)。
主要的挑战是在只支持 1(传统)和 2(tdp)个翻译的硬件中编码 1 到 3 个翻译。当所需的翻译数量与硬件匹配时,mmu 以直接模式运行;否则,它以影子模式运行(见下文)。
内存
客户机内存(gpa)是使用 kvm 的进程的用户地址空间的一部分。用户空间定义了客户机地址和用户地址之间的转换关系(gpa->hva);请注意,两个 gpas 可能映射到相同的 hva,但反之则不成立。
这些 hva 可以使用主机可用的任何方法支持:匿名内存、文件支持的内存和设备内存。内存可能随时由主机进行分页。
事件
mmu 受事件驱动,其中一些来自客户机,一些来自主机。
由客户机生成的事件:
-
对控制寄存器的写入(特别是 cr3)
-
invlpg/invlpga 指令的执行
-
对缺失或受保护的翻译的访问
由主机生成的事件:
-
gpa->hpa 转换(通过 gpa->hva 再通过 hva->hpa 的转换)
-
内存压力(收缩器)
影子页表
主要数据结构是影子页,即 struct kvm_mmu_page。 影子页包含 512 个 spte,可以是叶子 spte 或非叶子 spte。一个影子页可能包含叶子 spte 和非叶子 spte 的混合。
非叶子 spte 允许硬件 mmu 访问叶子页,并且与直接的翻译无关。 它指向其他影子页。
叶子 spte 对应于编码到一个分页结构条目中的一个或两个翻译。 这些总是翻译堆栈的最低级,可选的更高级别翻译留给 NPT/EPT。叶子 pte 指向客户机页。
以下表格显示由叶子页表项编码的翻译,其中括号中是更高级别的翻译:
非嵌套客户机:
+-------------+---------------------+ | Translation | Expression | +-------------+---------------------+ | nonpaging | gpa->hpa | | paging | gva->gpa->hpa | | paging, tdp | (gva->)gpa->hpa | +-------------+---------------------+
嵌套客户机:
+-----------+------------------------+ | Translation | Expression | +-----------+------------------------+ | non-tdp | ngva->gpa->hpa (*) | | tdp | (ngva->)ngpa->gpa->hpa | +-----------+------------------------+ (*) 如果不存在 NPT(Nested Page Tables),客户机的虚拟机监控程序将会将 ngva(嵌套客户机虚拟地址)到 gpa(客户机物理地址)的翻译编码进其页表中。
影子页表包含一下信息:
-
role.level:
-
影子页在影子分页层次结构中的级别。1=4k sptes,2=2M sptes,3=1G sptes,等等。
-
-
role.direct:
-
如果设置,从此页可达的叶子 sptes 是线性范围的。
-
例如,实模式翻译、由小主机页支持的大客户机页以及在 NPT 或 EPT 激活时的 gpa->hpa 翻译。
-
线性范围从 (gfn << PAGE_SHIFT) 开始,其大小由 role.level 决定(对于第一级,为2MB;对于第二级,为1GB;对于第三级,为0.5TB;对于第四级,为256TB)。
-
如果未设置,此页对应于由 gfn 字段表示的客户机页表。
-
-
role.quadrant:
-
当 role.has_4_byte_gpte=1 时,客户机使用32位 gpte,而主机使用64位 spte。
-
对于第一级影子页,role.quadrant 可以是 0 或 1,表示客户机页表中的第一个或第二个 512-gpte 块。
-
对于第二级页表,每个32位 gpte 被转换为两个64位 spte(因为每个第一级客户机页由两个第一级影子页阴影),所以 role.quadrant 取值范围为0到3。
-
每个象限映射1GB虚拟地址空间。
-
-
role.access:
-
从父 ptes 继承的客户机访问权限,以 uwx 形式表示。注意执行权限是积极的,而不是否定的。
-
-
role.invalid:
-
该页无效,不应使用。它是当前被固定(由指向它的 CPU 硬件寄存器指向)的根页;一旦取消固定,它将被销毁。
-
-
role.has_4_byte_gpte:
-
反映了页面有效的客户机 PTE 的大小,即在使用直接映射或 64 位 gpte 时为 '0',在使用 32 位 gpte 时为 '1'。
-
-
role.efer_nx:
-
包含页面有效的 efer.nx 的值。
-
-
role.cr0_wp:
-
包含页面有效的 cr0.wp 的值。
-
-
role.smep_andnot_wp:
-
包含页面有效的 cr4.smep && !cr0.wp 的值。
-
具有这个条件的页面与其他页面不同;请参阅下面对 cr0.wp=0 处理的说明。
-
-
role.smap_andnot_wp:
-
包含页面有效的 cr4.smap && !cr0.wp 的值。
-
具有这个条件的页面与其他页面不同;请参阅下面对 cr0.wp=0 处理的说明。
-
-
role.smm:
-
如果页面在系统管理模式中有效,则为1。
-
此字段确定使用哪个 kvm_memslots 数组构建此影子页;它还用于通过 kvm_memslots_for_spte_role 宏和 gfn_to_memslot 从 struct kvm_mmu_page 返回到 memslot。
-
-
role.ad_disabled:
-
如果 MMU 实例无法使用 A/D 位,则为1。
-
在 Haswell 之前,EPT 没有 A/D 位;如果 L1 hypervisor 不启用 A/D 位,shadow EPT 页表也无法使用 A/D 位。
-
-
role.passthrough:
-
该页面没有由客户机页表支持,但其第一个条目指向一个。
-
如果 NPT 使用 5 级页表(host CR4.LA57=1)并阴影 L1 的 4 级 NPT(L1 CR4.LA57=1),则设置此项。
-
-
gfn:
-
表示由此页阴影的翻译所在的客户机页表,或用于线性翻译的基础页帧。参见 role.direct。
-
-
spt:
-
一个包含此页翻译的 64 位 spte 的页。
-
由 kvm 和硬件访问。
-
spt 指向的页将其 page->private 指向影子页结构。
-
spt 中的 spte 可以指向客户机页,也可以指向较低级别的影子页。
-
具体来说,如果 sp1 和 sp2 是影子页,则 sp1->spt[n] 可能指向 __pa(sp2->spt)。sp2 将通过 parent_pte 回指 sp1。
-
spt 数组形成一个有向无环图结构,其中影子页作为一个节点,客户机页作为叶子。
-
-
gfns:
-
一个包含 512 个客户机帧号的数组,每个帧号对应一个存在的 pte。
-
用于从 pte 到 gfn 的反向映射。当 role.direct 设置时,可以在使用时通过 gfn 字段计算这个数组,此时不需要分配 gfns 数组。参见 role.direct 和 gfn。
-
-
root_count:
-
计数器,用于跟踪有多少硬件寄存器(guest cr3 或 pdptrs)当前指向该页。
-
当此计数器非零时,该页不能被销毁。参见 role.invalid。
-
-
parent_ptes:
-
pte/ptes 指向此页的 spt 的逆映射。
-
如果 parent_ptes 的最低位为零,表示只有一个 spte 指向此页,parent_ptes 指向这个单一的 spte;否则,存在多个 spte 指向此页,parent_ptes & ~0x1 指向一个数据结构,其中包含父 spte 的列表。
-
-
unsync:
-
如果为 true,则此页中的翻译可能与客户机的翻译不匹配。
-
这等效于当 pte 更改但在刷新 tlb 条目之前的 tlb 状态。因此,unsync ptes 在客户机执行 invlpg 或通过其他方式刷新其 tlb 时同步。
-
仅对叶子页有效。
-
-
unsync_children:
-
该页中有多少个 spte 指向不同步的页面(或具有不同步的子页面)。
-
-
unsync_child_bitmap:
-
一个位图,指示该页的 spte 是否(直接或间接)指向可能不同步的页面。用于快速定位从给定页面可达的所有不同步页面。
-
-
clear_spte_count:
-
仅在32位主机上存在,其中64位 spte 无法原子写入。
-
读者在 MMU 锁不足的情况下使用此计数器来检测正在进行的更新并重试,直到写者完成写入。
-
-
write_flooding_count:
-
客户机可能多次写入一个页表,如果需要将该页写保护,可能导致大量模拟(参见下面的 "同步和不同步的页面")。叶子页可以不同步,以避免触发频繁的模拟,但对于非叶子页则不可能。
-
此字段计算自上次实际使用页表以来的模拟次数;如果在该页面上模拟太频繁,KVM 将取消映射该页面以避免将来的模拟。
-
反向映射
MMU 维护了一个反向映射,通过该映射可以根据其 gfn(guest frame number,客户机帧号)找到映射某个页面的所有页表项(pte)。例如,在交换(swap)页面时使用这个反向映射。
同步和未同步页面
客户机使用两个事件来同步其TLB(Translation Lookaside Buffer)和页表:TLB 刷新和页面失效(invlpg)。
TLB 刷新意味着我们需要同步从客户机的 cr3(控制寄存器 3)可达的所有 spte(页表项)。这是一项昂贵的操作,因此我们保持所有客户机页表的写保护,并在写入 gpte(全局页表项)时同步 spte 到 gpte。
一个特殊情况是当一个客户机页表可以从当前客户机的 cr3 可达时。在这种情况下,客户机有责任在使用翻译之前发出 invlpg 指令。我们利用这一点通过从客户机页中去除写保护,允许客户机自由修改它。在客户机调用 invlpg 时,我们同步已修改的 gpte。这减少了在客户机修改多个 gpte 或者当一个客户机页不再用作页表而被用于随机客户机数据时,我们需要进行模拟的量。
作为副作用,我们必须在 TLB 刷新时重新同步所有可达的未同步影子页。
事件处理方式
-
客户机页故障(或 NPT 页故障,或 EPT 违规)
这是最复杂的事件。页故障的原因可能是:
-
真实的客户机故障(客户机地址不允许访问)(*)
-
访问丢失的地址
-
访问受保护的地址
-
在记录脏页时,内存被写保护
-
同步的影子页被写保护 (*)
-
-
访问不可翻译的内存(MMIO) (*) 在直接模式下不适用
处理页故障的步骤如下:
-
如果错误代码的 RSV 位被设置,那么页故障是由客户机访问 MMIO 引起的,同时缓存的 MMIO 信息是可用的。
-
遍历影子页表
-
检查 spte 中的有效生成号(参见下面的 "快速失效 MMIO spte")
-
将信息缓存到 vcpu->arch.mmio_gva、vcpu->arch.mmio_access 和 vcpu->arch.mmio_gfn,并调用模拟器
-
-
如果错误代码的 P 位和 R/W 位都设置了,这可能可以作为 "快速页故障" 处理(无需获取 MMU 锁)。详见 Documentation/virt/kvm/locking.rst 中的描述。
-
如果需要,遍历客户机页表以确定客户机翻译(gva->gpa 或 ngpa->gpa)
-
如果权限不足,则将故障反映回客户机
-
-
确定主机页
-
如果这是一个 MMIO 请求,则没有主机页;将信息缓存到 vcpu->arch.mmio_gva、vcpu->arch.mmio_access 和 vcpu->arch.mmio_gfn
-
-
遍历影子页表以找到翻译的 spte,根据需要实例化缺失的中间页表
-
如果这是一个 MMIO 请求,将 MMIO 信息缓存到 spte,并在 spte 上设置一些保留位(参见 kvm_mmu_set_mmio_spte_mask 的调用者)
-
-
尝试取消同步页面
-
如果成功,则可以让客户机继续并修改 gpte
-
-
模拟指令
-
如果失败,取消遮蔽页面并让客户机继续
-
-
更新由指令修改的任何翻译
invlpg 处理:
-
遍历影子页层次结构并删除受影响的翻译
-
尝试重新实例化指示的翻译,希望客户机在不久的将来会使用它
客户机控制寄存器更新:
-
移动到 cr3
-
查找新的影子根
-
同步新可达的影子页
-
-
移动到 cr0/cr4/efer
-
为新的分页模式设置 mmu 上下文
-
查找新的影子根
-
同步新可达的影子页
-
主机翻译更新:
-
mmu 通知器使用更新的 hva 调用
-
通过反向映射查找受影响的 spte
-
删除(或更新)翻译
模拟 cr0.wp(CR0.WP
是x86架构中控制页表项写保护的位)
如果未启用 TDP(Two-Dimensional Paging),主机必须保持 CR0.WP=1
,以便页写保护适用于虚拟机内核而不是虚拟机用户空间。当虚拟机的 CR0.WP=1
时,这不会产生问题。然而,当虚拟机的 CR0.WP=0
时,我们无法将 gpte.u=1
且 gpte.w=0
的权限映射到任何 spte
上(语义要求允许任何虚拟机内核访问以及用户读取访问)。
我们通过将权限映射到两种可能的 spte
上来处理这个问题,具体取决于故障类型:
-
内核写入故障:
spte.u=0
,spte.w=1
(允许完全内核访问,不允许用户访问) -
读取故障:
spte.u=1
,spte.w=0
(允许完全读取访问,不允许内核写入访问)
(用户写入故障会生成 #PF)
在第一种情况下,存在两个额外的复杂性:
-
如果启用了
CR4.SMEP
:由于我们已经将页面转换为内核页面,内核现在可以执行它。我们通过还设置spte.nx
来处理这个。如果我们遇到用户取指令或读取故障,我们将更改spte.u=1
和spte.nx=gpte.nx
。为了使此工作,当使用影子分页时,KVM 强制将EFER.NX
设置为 1。 -
如果禁用了
CR4.SMAP
:由于页面已更改为内核页面,在启用CR4.SMAP
时无法重新使用它。我们将CR4.SMAP && !CR0.WP
设置为影子页面的角色,以避免这种情况。请注意,在这里,我们不关心启用CR4.SMAP
的情况,因为 KVM 将直接向虚拟机注入 #PF,因为权限检查失败。
为了防止具有 CR0.WP=0
的 spte 在 CR0.WP
更改为 1 后被内核写入,我们将 CR0.WP
的值作为页面角色的一部分。这意味着使用一个值为 CR0.WP
的 spte 无法在 CR0.WP
具有不同值时使用 - 它将被影子页面查找代码忽略。当使用 CR0.WP=0
且 CR4.SMEP=0
创建的 spte 在将 CR4.SMEP
更改为 1 后使用时存在类似的问题。为了避免这种情况,!CR0.WP && CR4.SMEP
的值也作为页面角色的一部分。
大页
MMU支持大大小小的虚拟机和主机页面的所有组合。支持的页面大小包括4k、2M、4M和1G。由于MMU始终使用PAE分页,因此4M页面在虚拟机和主机上都被视为两个独立的2M页面。
要实例化一个大的SPT(页表项),必须满足四个约束条件:
-
SPT必须指向一个大的主机页面。
-
客户PTE必须是至少相同大小的大PTE(如果启用了TDP,则没有客户PTE,此条件得到满足)。
-
如果SPT将是可写的,则大页面帧不能与任何写保护页面重叠。
-
客户页面必须完全包含在单个内存插槽中。
为了检查最后两个条件,MMU为每个内存插槽和大页面大小维护了一组->disallow_lpage
数组。每个写保护页面都会导致其disallow_lpage
递增,从而阻止实例化大的SPT。位于不对齐内存插槽末尾的帧具有人为膨胀的->disallow_lpages
,因此它们永远无法实例化。
快速使无效的 MMIO SPT(页表项)
如上文中提到的"Reaction to events",KVM 将在叶子 SPT(页表项)中缓存 MMIO 信息。当添加新的内存插槽或更改现有内存插槽时,这些信息可能会变得过时,需要进行失效操作。这也需要在遍历所有影子页时保持 MMU 锁,并且使用类似的技术可以使其更具可扩展性。
MMIO SPT 具有一些备用位,用于存储生成编号。全局生成编号存储在 kvm_memslots(kvm)->generation
中,每当更改客户内存信息时都会增加。
当 KVM 发现一个 MMIO SPT 时,它会检查 SPT 的生成编号。如果 SPT 的生成编号与全局生成编号不相等,它将忽略缓存的 MMIO 信息,并通过慢路径处理页面错误。
由于仅使用 18 位来存储 MMIO SPT 上的生成编号,当发生溢出时会清除所有页面。
不幸的是,单个内存访问可能会多次访问 kvm_memslots(kvm)
,最后一次发生在获取生成编号并存储到 MMIO SPT 中时。因此,MMIO SPT 可能是基于过时信息创建的,但具有最新的生成编号。
为了避免这种情况,生成编号在 synchronize_srcu
返回后再次递增;因此,在进行内存插槽更新时,kvm_memslots(kvm)->generation
的第 63 位被设置为 1,而一些 SRCU 读者可能正在使用旧副本。我们不想使用基于奇数生成编号创建的 MMIO SPT,而且我们可以在不失去 MMIO SPT 中的一个位的情况下实现这一点。生成编号的 "更新正在进行中" 位未存储在 MMIO SPT 中,因此在从 SPT 中提取生成编号时,该位会被隐式设置为零。如果 KVM 不幸地在更新进行中创建了 MMIO SPT,那么对 SPT 的下一次访问将始终是缓存未命中。例如,在更新窗口期间的后续访问将由于进行中的标志发散而未命中,而在更新窗口关闭后的访问将具有较高的生成编号(与 SPT 相比)。
Further reading
-
NPT presentation from KVM Forum 2008 https://www.linux-kvm.org/images/c/c8/KvmForum2008%24kdf2008_21.pdf