Windows ARM64内部机制:异常与特权模型、虚拟内存管理及虚拟化主机扩展(VHE)
引言
大约五年前,当我刚开始学习Windows内部机制时,曾发布过一篇关于标准Intel x64 Windows机器上64位"内存分页"的博客文章。回顾那篇文章,我觉得还有很多不足之处,因此决定在不重复现有信息的情况下,对ARM64架构上的Windows内部机制进行深入探讨。
当前,任何Windows分析都默认假设你在x64机器(通常是基于Intel的)上操作,而关于ARM64上Windows内部机制的资料非常稀少。基于这一事实,我认为在配备高通Snapdragon X Elite处理器的新Surface Pro上,对ARM64架构的"Windows特性"进行类似探讨会很有趣。
本文将特别涵盖:
- 异常和特权级别(ARM64版本的x86处理器"环")
- 在ARM虚拟化主机扩展(VHE)下的Windows hypervisor行为
- 使用WinDbg中的rdmsr命令访问ARM系统寄存器
- TrustedZone与Windows VTL共存
- Windows特定的虚拟内存实现:分页层次结构、地址转换等
- ARM特定的PTE配置
- 自引用分页条目和虚拟内存中的PTE管理
- 转换后备缓冲器(TLB)和上下文切换
- 其他"Windows特性"
本文在运行ARM v9 “A-profile"架构的处理器和Windows 11 24H2系统上进行。
异常/特权模型
与Intel不同,ARM不利用传统的"特权"级别(如用户模式的PL 3和内核模式的PL 0,通常称为"环”)。ARM使用处理器"运行"在特定异常级别的概念来强制执行类似于"环级别"的特权。
这是因为ARM64使用基于异常的架构。几乎所有操作都是异常:从特殊指令(如svc,称为"supervisor call",是ARM64版本的系统调用)到中断(在ARM上中断被视为异常)。ARM将异常定义为"任何需要核心停止正常执行并执行专用软件例程的条件"。
ARM架构中,软件在VBAR_ELX系统寄存器中存储异常处理程序向量,其中X表示异常级别。例如,在异常级别1(相当于"内核模式")运行的处理器的所有异常处理程序都存储在VBAR_EL1系统寄存器中。
ARM目前定义了4个主要异常级别 - 异常级别(EL)3到EL0。ARM的术语与Intel相反:数字越低,特权越小。例如,EL0指"用户模式"。ARM的一个特别有趣之处在于,所有异常级别都有文档化的用途(尽管它们不必用于其文档化用途),这甚至包括hypervisor!
在基于Intel的系统中,hypervisor通常(错误地)被称为"环-1"或"环减1"。在Intel系统上没有对"环-1"的架构支持 - hypervisor只是在不同的模式(VMX root)下运行在环0。然而,在基于ARM的系统中,“异常级别"2被文档化为hypervisor保留。
Windows与虚拟化主机扩展(VHE)
较新的ARM处理器(从ARMv8.1-A开始)支持VHE(虚拟化主机扩展),这是一个扩展异常级别2(EL2,hypervisor运行的地方)功能特性的功能。
VHE似乎是为Linux和类型2 hypervisor开发的,特别允许将整个主机操作系统可选地运行在EL2中。这意味着hypervisor和客户操作系统都在同一个异常级别中。这样做有很多性能优势。
在没有VHE的情况下,类型2 hypervisor通常作为内核软件包在EL1中运行。由于EL2是"为hypervisor准备的”,这意味着为了在VM进入/退出时保存系统寄存器状态,需要在EL1和EL2之间不断切换,缓存不断刷新,导致更多的性能下降。将主机OS和hypervisor放在同一个异常级别中会大大减少客户<->hypervisor上下文切换。
“前VHE"的EL2只有1个页表基址寄存器,限制了EL2可以使用的地址空间量,使得几乎不可能将主机OS(VHE所做的)放在EL2中,因为主机OS通常需要运行用户模式应用程序和内核。VHE将EL2的页表根寄存器数量扩展到2个,有效地给予EL2与EL1几乎相同的分页命名法。
Windows打破了这种模式。尽管在Hyper-V中配置了VHE,但Windows仍然设计上使用EL1作为实际的操作系统/NT内核。这意味着客户内核(VM)和NT内核都在EL1中运行。这是因为我们在VBS下运行。启用hypervisor后,NT位于根分区中(实际VM位于子分区中)。
Windows虚拟内存内部机制 - ARM64版本
分页层次结构
基于ARM的处理器也有类似于Intel的分页层次结构。当今标准的64位Intel机器有4级分页,而ARM64在涉及4级分页时也使用四个页表进行虚拟到物理地址转换过程。
与Intel不同,ARM让操作系统对将使用哪种转换模式有更多的"发言权”。特定的转换粒度在系统寄存器中定义,这有效地定义了内存转换过程中最终页面的粒度级别。
在64位操作系统上,最常见的例子是4KB - 这意味着当粒度为4KB时,转换会映射最终的4KB大小的物理页面。
在现代ARM64机器上,通常使用4个表进行转换。这些表被命名为级别0/1/2/3,最后一步是从"最后"表索引(级别3表的索引)计算偏移量。
页表根和内存配置
ARM系统的一个显著区别是用户模式和内核模式内存之间的边界。ARM实际上为"较低"(用户模式)虚拟地址和"较高"(内核模式)地址分离了页表根,而不是"仅仅"使用某个位来表示"较低"和"较高"地址范围。
TTBR0_EL1是用户模式根,TTBR1_EL1是内核模式根。对于用户模式根,位1-47是页表根的物理地址。位0指的是Common not Private位。在Windows上,这始终设置为0。
Windows上的每个用户模式进程仍然在其KPROCESS.DirectoryTableBase中携带"它们的"每进程页表根。在上下文切换时,这个值被加载到TTBR0_EL1系统寄存器中,维护"当前"较低(用户模式)地址空间。这是Windows在ARM上(与x86相同)在特定进程执行时维护私有进程地址空间的方式。
转换过程
让我们以我们现有的知识转换一个内核模式虚拟地址!让我们尝试使用当前进程的页表根转换内核模式函数CI!CiInitialize的地址。
在检索页表根后(记住,在这种情况下我们使用TTBR1_EL1,因为位47设置为1,表示使用内核页表根),我们然后:
- 提取位46-39以检索级别0页表索引
- 索引数组(索引号+数据类型大小,即sizeof(PTE)或8字节)
这给了我们级别0 PTE,它允许我们找到级别1页表根。
原始值是0x0060000081715f23。这些是PTE的原始内容(在软件中表示为nt!_MMPTE_HARDWARE)。PFN(页框号)跨越位47:12(从位0开始)。我们可以简单地使用位操作从PTE中提取PFN,以表示物理帧。
从这里开始,我们需要做的就是将PFN乘以PAGE_SIZE - 即4KB(基于我们的粒度)。这给了我们级别1页表的物理地址(记住物理地址只是PFN * PAGE_SIZE)。
正如我们刚才看到的,目标VA中的位46:39用于第一个表索引(级别0),现在位38:30用于索引下一个表(级别1)。
这个PTE的原始值是0x0060000081714f23 - 这个PTE的PFN描述了下一个页表(级别2)的位置。
有了级别2表的基本地址,我们可以简单地重复这个过程。VA(CI!CiInitialize)中的位29:21是用于查找下一个表(最终的级别3表)的索引。
这次原始PTE值是0x0060000081d04f23。我们现在有一个描述最后一个页表级别3的PTE。我们可以简单地提取级别3页表的物理页面并最后一次索引它以找到我们最终的4KB物理页面。
有了物理地址,然后我们可以使用位20:12索引级别3页表。这将给我们描述最终物理页面的PTE(CI!CiInitialize的物理地址)。
最终PTE的原始值是0x9040000fdc755783。然而,提取PFN并计算物理地址似乎有点不对。我们得到了一些有效的物理内存,看起来是一个函数(因为它正确反汇编),但它不是CI!CiInitialize。
这是因为,尽管位20:12执行最后的页表索引,但位11:0仍然有意义。位11:0旨在用作最终转换中的偏移量。这意味着通过级别3索引产生的物理地址(最终块)仍然需要添加剩余的位。当我们这样做时,我们得到了CI!CiInitialize的正确物理地址!
这意味着CI!CiInitialize的最终物理地址是0xfdc7552c0!
ARM64页表条目
Windows在ARM64下,与x86相同,利用nt!_MMPTE_HARDWARE结构表示页表条目,并使用nt!_MMPFN描述页框号(PFN)。此外,由于我们稍后将讨论的原因,PTE在Windows系统的虚拟内存中可访问。
许多PTE字段看起来与它们的x86对应物相似,但仍有几个字段值得讨论:
- MMPTE_HARDWARE.NotLargePage:不是ARM64特定的。“大页面"指的是映射比指定粒度(4KB)允许的更多内存的页面。
- MMPTE_HARDWARE.NonSecure:我们之前简要讨论了"安全和非安全状态”。NonSecure位指的是范围内内存属于哪个安全状态。
- MMPTE_HARDWARE.NotDirty:这仅在ARM64上与Windows上的x64相反时才值得指出。
- MMPTE_HARDWARE.Sharability:这指的是ARM的SH位 - 称为"可共享属性"。
- MMPTE_HARDWARE.NonGlobal:这是一个实际的ARM定义位,称为nG。非全局表示目标内存仅在特定应用程序的上下文中有效。
- MMPTE_HARDWARE.PrivilegedNoExecute和MMPTE_HARDWARE.UserNoExecute:这些位非常自解释。
就像在基于x86的Windows安装中一样,PTE被映射到虚拟内存中,并在每次启动时随机化。
自引用页表和页表管理
本节并不完全特定于ARM64。然而,ARM仍然在Windows上将其用于虚拟内存中的PTE管理(并且有一些细微差别,所以可能仍然值得讨论)。
让我们思考一秒钟我们试图完成什么。正如我们所知,Windows将所有页表映射到单个平坦虚拟地址处的虚拟内存中,可以作为数组进行索引。
虚拟地址只是物理内存中各种页表(级别0、级别1等)的索引列表。Windows使用虚拟地址的位46:49、38:30、29:21、20:12和11:0。
页表映射到虚拟内存的方式正是如此。在这种情况下,我们实际上有一个虚拟地址映射到页表根的物理地址!这对每个进程都是如此。在每个页表根中(回想每个进程在其KPROCESS.DirectoryTableBase中有自己的页表根),总有一个特殊的级别0表索引(自引用索引)总是指"回自身"。该索引在所有进程中都是相同的。
这允许虚拟地址0xffff860000000000因此用于访问所有进程中所有页表(和内核模式内存)的所有页表。
地址空间标识符(ASID)、虚拟机标识符(VMID)和转换后备缓冲器(TLB)
我想谈的最后一件事是ARM64系统上TLB行为与典型x86机器的一些差异。TLB或转换后备缓冲器是内存转换的缓存。
Windows维护私有的每进程地址空间。这意味着,例如,地址0x41414141可能在进程A中包含字符串"Hello",但在进程B中0x41414141可能无效,可能保留但未提交到内存,或者可能指向一些完全不同的内容。这就是历史上TLB总是在上下文切换时刷新的原因。
与TLB相关的有几个项目,但在ARM上,一个非常有趣的事情是存在"地址空间"和/或"虚拟机"标识符(ASID/VMID)值。
从ASID开始,ASID是TLB中表示缓存转换所属进程的值。这不是进程ID,而是一个唯一值。这样做的原因非常有趣!正如我刚才提到的,更新页表根会使TLB无效,以免有任何"陈旧"或"错误"的缓存。这是我们在Windows上保证每进程地址空间的方式之一。
然而,在ARM上,交换页表根不会自动使TLB无效。这就是ASID发挥作用的地方!“当前进程"的ASID用于始终确保任何TLB条目访问都对应于该进程!这意味着,例如,进程A的ASID可以是4,进程B的可以是8。地址0x41414141的两个转换现在都可以缓存在TLB中,因为ASID保证只访问与目标进程对应的正确转换!不再需要在每次上下文切换时刷新TLB!
除了ASID,在Windows上通常发生的另一种执行模式是EL2中的hypervisor。除了ASID,ARM还提供VMID,它们是VM的"ASID”。VMID用于跟踪TLB中哪些转换与哪些VM相关联。
结论和未来工作
我非常享受我的新ARM64 Windows机器!目前我觉得它比基于x86的机器更有趣,我非常喜欢这个架构。我希望在未来提供更多基础内容,如ARM64 Windows系统上的异常处理和中断传递。