吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 9748|回复: 53
收起左侧

[系统底层] 《Windows 7 x64内存管理》之用户范围内存管理 Part1

  [复制链接]
BeneficialWeb 发表于 2022-3-27 17:44
本帖最后由 BeneficialWeb 于 2022-3-27 17:49 编辑

用户范围内存管理

1. 用户范围内容

一个运行在用户范围的进程通常包含以下内容:

  • 可执行文件
  • Dlls
  • 进程堆
  • 线程栈

这些内容被存储在VMM选择的VA范围内的区域,这些区域外的地址是无效的,这意味着如果代码试图以任何方式访问它们,就会产生访问违规。如果一个程序想使用一个给定的区域,它必须首先调用VMMAPI,使其有效。在接下来的章节中,我们将研究这些API的作用,这将有助于我们了解VMM的内部工作。现在,我们先研究这些API分配的用户范围内存。稍后,我们将研究VMM如何管理可执行代码的加载、堆栈和堆。

2. 用户范围内存申请

在用户范围内,内存区域的分配分两步进行:首先,预留一个虚拟地址范围,即从进程的未分配的VA中选择并留出。这样做后并不意味着这个范围是可用的:如果进程试图接触这个范围内的地址,它仍然会得到一个访问违规,也就是说,它将得到一个同样的异常,原因是他依然是完全无效的地址。第二步是提交范围,这使得它可以使用。当一个范围被提交时,我们可以说(将事情简化一点),它要么在物理内存中,要么在分页文件中,Windows准备在需要时将其映射到地址空间中,所以代码可以自由地访问这个范围。VirtualAllocEx是可用于保留和提交内存的API之一。

但是,为什么将内存分配分成两步?主要原因是这样Windows可以跟踪分配了多少虚拟内存。系统计算出一个叫做提交限制的数值,它可以作为性能计数器(Memory/Commit Limit)被检索。它的值在某种程度上小于物理RAM的数量加上分页文件的大小,它是可以保留在内存中或换出的字节数。由于一个页面的数据必须保存在某个地方,要么在磁盘上,要么在内存中,这个值代表了系统能够处理的最大页面数。

当进程分配内存时,Windows会在名为Memory/Committed Bytes的计数器中跟踪它们使用了多少数据页。这个值,也被称为提交开销,不能大于提交限制,因为这些数据页必须被储存在某个地方。

因此,当一个进程试图增加提交开销时,如果会超过提交限制,操作会失败。实际上,提交限制可以增长,因为分页文件本身可以增长,达到指定的最大尺寸。只要分页文件低于其最大值,Windows就会很宽容地允许操作成功。然而,如果我们继续分配内存,当它不能再增长时,我们一定会被提交限制所阻止。

现在,假设我们需要写一段代码,它必须为一个数据结构分配一个非常大的连续内存范围,尽管我们不确定这个范围内有多少是我们实际要使用的。再提交页面后也许我们只使用其中的一小部分,就会消耗很大一部分的提交限制。

相反,保留一个内存范围允许我们预留一个连续的地址范围,而不消耗提交限制。预留的范围以后可以在单独的小块中提交,因此只有在需要时才增加提交费用。

除了系统范围内的提交限制,Windows还允许对一个进程所能提交的内存数量进行单进程限制。虽然这个功能在默认情况下是禁用的,但它可以被打开,因此它是另一个只在需要时提交内存的好理由。

然而,提交虚拟内存并不一定意味着要创建分页结构来映射物理页:在可能的情况下,Windows会等到内存被实际引用时再进行创建,因此这就成了页面错误处理程序的工作。我们将很快讨论一种情况,即Windows无法避免在提交时创建分页结构。

基于这种分配方案,用户范围内的虚拟地址可以处于三种分配状态之一:无效、保留或提交。储存已提交地址的页面不一定存在:在实际访问该页面之前,VMM并没有真正将VPN映射到物理页面;即使已经完成了映射,该页面也可以在之后被移到分页文件中。关键是,一旦一个虚拟页被提交,VMM就准备使其有效,最终从分页文件中检索其内容。

有一些内存分配API在其输入参数中包含了一个NUMA节点号,这样我们就可以为分配指定一个首选节点。这打破了VMM从理想节点分配页面的默认行为。作为一个例子,VirtualAllocEx根据默认的VMM行为保留和提交页面,而VirtualAllocExNuma允许指定首选节点。该节点只被认为是首选的,因为物理页并不是在保留或提交内存的时候实际分配的,而是在访问内存的时候。如果发生这种情况,在指定的节点上没有可用的页面,VMM将使用最近的一个节点的物理页面。

3.VMM数据结构

3.1 虚拟地址描述符 (VAD)

VADs用于跟踪用户模式范围内的保留和提交的地址。对于每一个保留或提交的范围,VAD存储了限定它的地址,它是只保留还是提交,以及它的保护,即允许对该范围的访问类型。

VADs被组织成一个树状结构,每个节点是一个VAD实例,最多可以有两个子节点。左边的子节点,如果存在的话,是父节点所覆盖的地址范围下面的一个VAD;右边的子节点是它上面的一个范围:

1.png

Windows
符号中,有三种数据类型定义为VAD_MMVAD_SHORT, __MMVAD*MMVAD_LONG,其中每个结构都是前一个结构的超集。这里研究的VMM*逻辑只涉及_MMVAD_SHORT的成员,WinDbg**向我们展示了以下定义。

0: kd> dt nt!_MMVAD_SHORT -bv
struct _MMVAD_SHORT, 8 elements, 0x40 bytes
   +0x000 u1               : union <unnamed-tag>, 2 elements, 0x8 bytes
      +0x000 Balance          : Bitfield Pos 0, 2 Bits
      +0x000 Parent           : Ptr64 to
   +0x008 LeftChild        : Ptr64 to
   +0x010 RightChild       : Ptr64 to
   +0x018 StartingVpn      : Uint8B
   +0x020 EndingVpn        : Uint8B
   +0x028 u                : union <unnamed-tag>, 2 elements, 0x8 bytes
      +0x000 LongFlags        : Uint8B
      +0x000 VadFlags         : struct _MMVAD_FLAGS, 7 elements, 0x8 bytes
         +0x000 CommitCharge     : Bitfield Pos 0, 51 Bits
         +0x000 NoChange         : Bitfield Pos 51, 1 Bit
         +0x000 VadType          : Bitfield Pos 52, 3 Bits
         +0x000 MemCommit        : Bitfield Pos 55, 1 Bit
         +0x000 Protection       : Bitfield Pos 56, 5 Bits
         +0x000 Spare            : Bitfield Pos 61, 2 Bits
         +0x000 PrivateMemory    : Bitfield Pos 63, 1 Bit
   +0x030 PushLock         : struct _EX_PUSH_LOCK, 7 elements, 0x8 bytes
      +0x000 Locked           : Bitfield Pos 0, 1 Bit
      +0x000 Waiting          : Bitfield Pos 1, 1 Bit
      +0x000 Waking           : Bitfield Pos 2, 1 Bit
      +0x000 MultipleShared   : Bitfield Pos 3, 1 Bit
      +0x000 Shared           : Bitfield Pos 4, 60 Bits
      +0x000 Value            : Uint8B
      +0x000 Ptr              : Ptr64 to
   +0x038 u5               : union <unnamed-tag>, 2 elements, 0x8 bytes
      +0x000 LongFlags3       : Uint8B
      +0x000 VadFlags3        : struct _MMVAD_FLAGS3, 8 elements, 0x8 bytes
         +0x000 PreferredNode    : Bitfield Pos 0, 6 Bits
         +0x000 Teb              : Bitfield Pos 6, 1 Bit
         +0x000 Spare            : Bitfield Pos 7, 1 Bit
         +0x000 SequentialAccess : Bitfield Pos 8, 1 Bit
         +0x000 LastSequentialTrim : Bitfield Pos 9, 15 Bits
         +0x000 Spare2           : Bitfield Pos 24, 8 Bits
         +0x000 LargePageCreating : Bitfield Pos 32, 1 Bit
         +0x000 Spare3           : Bitfield Pos 33, 31 Bits

同样有趣的是,_MMVAD_SHORT本身是基于_MMADDRESS_NODE,另一个较小的结构,它的布局与+0x20处的指针相同,并存储了建立VAD树的基本信息。该树本身以其发明者的名字命名为AVL
自平衡树。Adelson-VelskiiLandis。下图是_MMADDRESS_NODE树的布局:

0: kd> dt _MMADDRESS_NODE -bv
nt!_MMADDRESS_NODE
struct _MMADDRESS_NODE, 5 elements, 0x28 bytes
   +0x000 u1               : union <unnamed-tag>, 2 elements, 0x8 bytes
      +0x000 Balance          : Bitfield Pos 0, 2 Bits
      +0x000 Parent           : Ptr64 to
   +0x008 LeftChild        : Ptr64 to
   +0x010 RightChild       : Ptr64 to
   +0x018 StartingVpn      : Uint8B
   +0x020 EndingVpn        : Uint8B

下面使用WinDbg查看进程的VADs。下面的程序名叫MemTest.exe,是用于测试申请内存的。首先通过!process扩展命令找到VAD root;接着!vad命令打印出进程VAD信息。

0: kd> !process 0 1 memtests.exe
PROCESS fffffa8062a4c9d0
    SessionId: 1  Cid: 08d8    Peb: 7fffffdc000  ParentCid: 0a80
    DirBase: 111132000  ObjectTable: fffff8a0026b47f0  HandleCount:  11.
    Image: MemTests.exe
    VadRoot fffffa8062a76440 Vads 41 Clone 0 Private 165. Modified 0. Locked 0.
    DeviceMap fffff8a00133ac20
    Token                             fffff8a00f393a90
    ElapsedTime                       02:46:52.284
    UserTime                          00:00:00.000
    KernelTime                        00:00:00.000
    QuotaPoolUsage[PagedPool]         22672
    QuotaPoolUsage[NonPagedPool]      4800
    Working Set Sizes (now,min,max)  (610, 50, 345) (2440KB, 200KB, 1380KB)
    PeakWorkingSetSize                610
    VirtualSize                       11 Mb
    PeakVirtualSize                   11 Mb
    PageFaultCount                    607
    MemoryPriority                    FOREGROUND
    BasePriority                      8
    CommitCharge                      185
0: kd> !vad fffffa8062a76440
VAD             Level         Start             End              Commit
fffffa80625d7210  4              10              1f               0 Mapped       READWRITE          Pagefile section, shared commit 0x10
fffffa8062e22420  3              20              2f               0 Mapped       READWRITE          Pagefile section, shared commit 0x10
fffffa8062cff9b0  4              30              33               0 Mapped       READONLY           Pagefile section, shared commit 0x4
fffffa8061b2b140  2              40              40               0 Mapped       READONLY           Pagefile section, shared commit 0x1
fffffa8062a9d5b0  3              50              50               1 Private      READWRITE
fffffa8062c19a50  1              60             15f               6 Private      READWRITE
fffffa80630cb600  4             160             1c6               0 Mapped       READONLY           \Windows\System32\locale.nls
fffffa8062e8e170  3             210             30f              55 Private      READWRITE
fffffa8062661110  5             310             40f              32 Private      READWRITE
fffffa8062e1ce30  4             490             49f               6 Private      READWRITE
fffffa8062a92b10  5           76f60           7707e               4 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\kernel32.dll
fffffa8061b2bf80  2           77180           77328              12 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\ntdll.dll
fffffa8062afb3f0  4           7efe0           7f0df               0 Mapped       READONLY           Pagefile section, shared commit 0x5
fffffa8062695e30  3           7f0e0           7ffdf               0 Private      READONLY
fffffa8062a76440  0           7ffe0           7ffef 2251799813685247 Private      READONLY
fffffa8062f2ba50  5          13f680          13f692               2 Mapped  Exe  EXECUTE_WRITECOPY  \Users\BypassAntiVirus\Desktop\MemTests.exe
fffffa80627c8970  4        7fef8e80        7fef8e82               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\api-ms-win-crt-conio-l1-1-0.dll
fffffa8062dd4170  5        7fef92a0        7fef92a2               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\api-ms-win-crt-locale-l1-1-0.dll
fffffa80627817f0  3        7fef92b0        7fef92b4               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\api-ms-win-crt-math-l1-1-0.dll
fffffa806181c0d0  4        7fef92c0        7fef92c3               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\api-ms-win-crt-convert-l1-1-0.dll
fffffa8062b17220  5        7fef92d0        7fef92d3               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\api-ms-win-crt-stdio-l1-1-0.dll
fffffa80617fe0d0  2        7fef92f0        7fef92f3               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\api-ms-win-crt-string-l1-1-0.dll
fffffa8062e0c9c0  5        7fef9300        7fef9302               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\api-ms-win-crt-heap-l1-1-0.dll
fffffa8062eb6a40  4        7fef9310        7fef9312               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\api-ms-win-core-file-l1-2-0.dll
fffffa806287fa90  5        7fef9320        7fef9322               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\api-ms-win-core-processthreads-l1-1-1.dll
fffffa8062e1a5d0  3        7fef9330        7fef9332               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\api-ms-win-core-localization-l1-2-0.dll
fffffa8062dc8ad0  5        7fef9340        7fef9342               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\api-ms-win-core-file-l2-1-0.dll
fffffa8061aa91f0  4        7fef9350        7fef9352               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\api-ms-win-core-timezone-l1-1-0.dll
fffffa8062e28ec0  5        7fef9360        7fef9451               4 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\ucrtbase.dll
fffffa806271f220  1        7fef9460        7fef9476               2 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\vcruntime140.dll
fffffa8062e03c10  4        7fef98e0        7fef98e3               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\api-ms-win-crt-runtime-l1-1-0.dll
fffffa80617dc110  5        7fefa030        7fefa032               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\api-ms-win-core-synch-l1-2-0.dll
fffffa8062e1c450  3        7fefd2f0        7fefd35a               3 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\KernelBase.dll
fffffa80627a5bb0  4        7fefe3a0        7fefe43e               7 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\msvcrt.dll
fffffa8062e4c160  5        7fefe440        7fefe56c               3 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\rpcrt4.dll
fffffa806252c4a0  2        7fefe570        7fefe64a               7 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\advapi32.dll
fffffa8061811110  5        7feff410        7feff42e               4 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\sechost.dll
fffffa8062687e00  4        7feff4a0        7feff4a0               0 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\System32\apisetschema.dll
fffffa8062f9a410  3        7fffffa0        7fffffd2               0 Mapped       READONLY           Pagefile section, shared commit 0x33
fffffa80630e67a0  4        7fffffdc        7fffffdc               1 Private      READWRITE
fffffa8062d86430  5        7fffffde        7fffffdf               2 Private      READWRITE

Total VADs: 41, average level: 4, maximum depth: 5
Total private commit: 0x8000000000096 pages (9007199254741592 KB)
Total shared commit:  0x5d pages (372 KB)

实际上,VAD的地址也可以通过_EPROCESS来查看,也就是_PROCESS.VadRoot.BalancedRoot.RightChild

0: kd> dt nt!_EPROCESS VadRoot.. fffffa8062a4c9d0
   +0x448 VadRoot   :
      +0x000 BalancedRoot :
         +0x000 u1        : <unnamed-tag>
         +0x008 LeftChild : (null)
         +0x010 RightChild : 0xfffffa80`62a76440 _MMADDRESS_NODE
         +0x018 StartingVpn : 0
         +0x020 EndingVpn : 0
      +0x028 DepthOfTree : 0x2906
      +0x028 Unused    : 0x2906
      +0x028 NumberGenericTableElements : 0x2906
      +0x030 NodeHint  :
      +0x038 NodeFreeHint :

Windbg输出的VAD信息的每一列的含义如下:

  • VAD 地址  也就是VAD数据结构的地址
  • VAD树的级别。上面的例子中,VAD级别为0的就和我们使用的VAD地址一样。这个节点就是树的根。
  • 起始地址范围和结束地址范围,这里使用的是虚拟页面号(VPN)来表示的。也就是说这个地址值是除以页面大小4kb后的结果,当然也可以说是地址值右移3个16进制位。因此第一行定义的起始地址就是0x1000,结束地址是0x1ffff。注意这里的结束地址只是最后一个页面的VPN,因此最后一个字节的地址应该是0x1F000+0xFFF
  • 第5列表示提交的页面数。这是因为预留一块区域后,可以只提交一部分,所以这一列的数字告诉我们实际提交的内存范围是多少。那VMM怎么知道哪些页面被提交了呢?答案后面揭晓。
  • 这一列表示分配的类型。现在我们只分析私有内存,也就是标记为Private的。
  • 接下来是页面的访问类型,后面也会说到。
  • 这一列书上没说,应该是新版Windbg支持的更多内存信息。

3.1.1 部分提交的区域

在预定一个区域后,我们可以提交许多子区域。不过子区域起始地址必须在页边界上,而且必须由整页面组成。VMM必须要记录一个范围内的页面哪些被提交了,很显然VAD不能存储这样的信息,因此VMM使用PTEs来完成这项工作。VMM不是说提交范围就初始化PTEs,而是当一个范围被部分提交时,才需要用到PTEs,它才会进行初始化操作。

举个例子,我们可能一步就完成了地址范围的预留和提交,因此这样就时提交了整个范围。当这种情况发生时,VMM不会为这个范围初始化PTEs。这可以通过检查发现,他们会被设置为0.PTEs页可以不存在,我们将在后面看到。对于一个预留但未提交的范围,一级一个无效的范围,他的PTEs也会被设置为0。因此不能通过PTEs来判断这个范围已经提交,这时候就需要利用存储在VAD中的信息。通过一些内存分配实验,我们可以观察到当区域被同时预留和全部提交时,_MMVAD_SHORT.u.VadFlags.MemCommit被设置为1。对于这样的范围,VMM只会在访问这个区域的地址时才进行更新PTE的操作。这个情景下的PTE在提交时并未建立,而是在内存访问时才完成PTE到物理页的指向,并且根据分配时的保护选项赋值相应的控制位。

现在考虑一个不同的情况,我们预定了一块区域但是并没有提交。如果我们观察这个区域的PTEs,那么会发现他们也是设置为0,但是_MMVAD_SHORT.u.VadFlags.MemCommit也被设置0。当我们提交这个范围的一部分时,VMM别无选择,只能在映射这个子范围的PTE中记录这一事实,这些PTE将会被设置为非0值。然后我们还没有实际访问这个范围内的地址,PTEs还没有完全建立虚拟地址的转换,也就是说,他还没有对应的物理地址,因为物理页还没有被分配来映射这个虚拟访问。在这个阶段,这些PTEs值包含了他们所映射的页面的保护信息,他们的存在位也是被清零的,后面我们会进行一些实验操作进行验证。只有当最终范围这个内存时,这些PTEs才会发生改变。

到目前为止,我们一直假设PTEs是存在的,然而我们必须记住分页结构的层次性:

  • PML4 entry的存在位清0时,那么对应的VA范围将没有PDPTsPDs,或者PTs
  • 类似的,PML4 entry可以指向一个存在的PDPT,但是PDPT entry的存在位也可以被清零,那么对于这个特定的区域也就没有PDsPTs

当涉及到记录已预留和已提交的内存时,这意味着有可能出现已提交的内存范围的PTEs不存在。这种情况前面说过了,发生在VMM不需要使用PTEs来记录部分提交的时候。因此,在前面的例子中,PTE可以为0或者说PTE也可以是缺失。

另一方面,如果VMM被迫创建一个页表,为部分提交设置一些PTE,它还得将把页中所有其他PTE设置为0,以记录这些其他范围没有提交。

运行一个调用VirtualAllocEx的测试程序,观察VADPTE在不同阶段的状态,可以观察到迄今为止解释的行为。让我们用一个实际的例子来结束这一切:我们使用一个名为MemTests的测试程序,它预定了128MB的VA,但没有提交它。我们使用一个大的范围,以便有更好的机会观察到不存在的PTEs:虽然范围开始的PTs可能存在,因为它们已经被VMM用于其他分配,但范围结束的PTs有很大的机会在我们调用函数时没有被分配。

当我们预定一个范围时,我们有以下情况:

Untitled.png

我们没有要求特定地址,这个起始地址是VirtualAlocEx自己选择的,然后我们指定了范围大小是128MB,也就是0x80000000字节。

!vad命令输出后的结构是这样的,我们没有提交任何的申请页。

VAD             Level         Start             End              Commit
fffffa8062b3a060  5           7fff0           fffef               0 Private      READWRITE

由于VA和其PTEVA之间的已知关系,我们可以计算出范围内第一页和最后一页的PTE的地址,这些地址如下所示

Untitled 1.png

我们可以查看这两个PTEs:

0: kd> dq FFFFF680003FFF80
fffff680`003fff80  00000000`00000000 00000000`00000000
fffff680`003fff90  00000000`00000000 00000000`00000000
fffff680`003fffa0  00000000`00000000 00000000`00000000
fffff680`003fffb0  00000000`00000000 00000000`00000000
fffff680`003fffc0  00000000`00000000 00000000`00000000
fffff680`003fffd0  00000000`00000000 00000000`00000000
fffff680`003fffe0  00000000`00000000 00000000`00000000
fffff680`003ffff0  00000000`00000000 00000000`00000000
0: kd> dq FFFFF680007FFF78
fffff680`007fff78  ????????`???????? ????????`????????

这告诉我们第一个页面范围已经存在了,VMM由于某种原因已经完成了对页表的创建。然后,PTEs都被设置为0,与我们还没有提交任何东西的事实一致。

最后一个PTE比较有趣,因为它并不存在。

顺便提一下,其实当我们查看PTEs时,他们可见的VA与当前进程的PTEs是对应的。我们不能通过简单的ctrl+c中断到调试器查看MemTestPTEs,因为当调试器进行控制时,当前的进程是System。解决这个问题的一个方法是使用带/i选项的.process元命令,它允许恢复执行并再次中断,此时所需的进程就是目标进程了。.process需要进程的_EPROCESS实例地址,这可以通过以下方式获得。

0: kd> !process 0 0 MemTests.exe
PROCESS fffffa80630363f0
    SessionId: 1  Cid: 01c8    Peb: 7fffffdf000  ParentCid: 0a80
    DirBase: 89e9f000  ObjectTable: fffff8a0010da7f0  HandleCount:  11.
    Image: MemTests.exe
1: kd> .process /i ffffa80630363f0
You need to continue execution (press 'g' <enter>) for the context
to be switched. When the debugger breaks in again, you will be in
the new process context.
1: kd> g
EX debug work: Unable to find process 0FFFFA80630363F0
Break instruction exception - code 80000003 (first chance)
nt!RtlpBreakWithStatusInstruction:
fffff800`0448c490 cc              int     3

现在让我们提交这两个页面的内存,然后!vad的输出就相应的改变了。

VAD             Level         Start             End              Commit
fffffa8061827110  5           7fff0           fffef               2 Private      READWRITE

PTEs的状态也不一样了:

0: kd> dq FFFFF680003FFF80
fffff680`003fff80  00000000`00000080 00000000`00000000
fffff680`003fff90  00000000`00000000 00000000`00000000
fffff680`003fffa0  00000000`00000000 00000000`00000000
fffff680`003fffb0  00000000`00000000 00000000`00000000
fffff680`003fffc0  00000000`00000000 00000000`00000000
fffff680`003fffd0  00000000`00000000 00000000`00000000
fffff680`003fffe0  00000000`00000000 00000000`00000000
fffff680`003ffff0  00000000`00000000 00000000`00000000
0: kd> dq FFFFF680007FFF78
fffff680`007fff78  00000000`00000080 00000000`00000000

第一个PTE现在被设置为0x80,它代表了页面保护的方式,我们将在后面讨论。这个非零的PTE告诉VMM这个页面已经提交了,因此对这个页面的访问不会导致访问违规。

最后一个PTE也是如此,只是现在这个PTE存在了,而在提交之前,甚至没有一个物理页来存储它。

这两个PTEs的存在位清零,PFN设置为0,所以还没有使用物理内存来映射我们范围内这两个页面的虚拟地址。

最后,我们看到第一个页面以外的PTEs仍然被设置为0,这告诉内存管理器它们还没有被提交。如果我们触碰这些页内的地址,我们就会因访问违规而崩溃。

我们的程序所做的最后一件事是访问这两个页面的第一个字节。由于MemTests做了很好的异常处理并提交了这些页面,所以它没有被打上访问违规的烙印,PTEs的变化是这样的

0: kd> dq FFFFF680003FFF80
fffff680`003fff80  a8200000`9117b867 00000000`00000000
fffff680`003fff90  00000000`00000000 00000000`00000000
fffff680`003fffa0  00000000`00000000 00000000`00000000
fffff680`003fffb0  00000000`00000000 00000000`00000000
fffff680`003fffc0  00000000`00000000 00000000`00000000
fffff680`003fffd0  00000000`00000000 00000000`00000000
fffff680`003fffe0  00000000`00000000 00000000`00000000
fffff680`003ffff0  00000000`00000000 00000000`00000000
0: kd> dq FFFFF680007FFF78
fffff680`007fff78  a8100000`8717a867 00000000`00000000
fffff680`007fff88  00000000`00000000 00000000`00000000
fffff680`007fff98  00000000`00000000 00000000`00000000
fffff680`007fffa8  00000000`00000000 00000000`00000000
fffff680`007fffb8  00000000`00000000 00000000`00000000
fffff680`007fffc8  00000000`00000000 00000000`00000000
fffff680`007fffd8  00000000`00000000 00000000`00000000
fffff680`007fffe8  00000000`00000000 00000000`00000000

这清楚地显示了PTEs现在对物理页的映射(一个小技巧:它们是奇数,仅这个事实就告诉我们存在位是1)。最右边三位的其他位是根据我们在调用VirtualAllocEx时指定的保护和缓存属性来设置的(后面会有更多的介绍)。第63位,执行禁止位,被设置,所以代码不能从这些页面执行,这也源于我们首先要求的保护。第52-62位,被处理器忽略,但是被VMM使用,我们后面会提到它们(它们存储了截断工作集的页面索引)。最后,第12-51位存储PFN。用于这些映射的两个物理页的PFN 如下

9117b 第一个页面

8717a 最后一个页面

最后注意:x64物理地址的理论极限是51位,然而VMM代码似乎总是将它们截断为48位,所以它目前可以处理 "只有 "256 TB的物理内存。

3.1.2 取消提交的子区域

我们可以使用VirtualFreeEx API取消提交已预定区域的部分内存,让他保持预定状态。当这种情况发生时,相应的PTEs被设置为0x00000000'00000200,WinDbg的解释如下:

1: kd> !process 0 0 MemColls.exe
PROCESS fffffa8063096b30
    SessionId: 1  Cid: 0328    Peb: 7fffffdf000  ParentCid: 0b3c
    DirBase: 823d8000  ObjectTable: fffff8a00f3e0610  HandleCount:   9.
    Image: MemColls.exe

1: kd> .process /i fffffa8063096b30
You need to continue execution (press 'g' <enter>) for the context
to be switched. When the debugger breaks in again, you will be in
the new process context.
1: kd> g
Break instruction exception - code 80000003 (first chance)
nt!RtlpBreakWithStatusInstruction:
fffff800`0448c490 cc              int     3
0: kd> !pte 2d0000
                                           VA 00000000002d0000
PXE at FFFFF6FB7DBED000    PPE at FFFFF6FB7DA00000    PDE at FFFFF6FB40000008    PTE at FFFFF68000001680
contains 02C0000080B9A867  contains 014000007E19E867  contains 1920000081801867  contains 0000000000000200
pfn 80b9a     ---DA--UWEV  pfn 7e19e     ---DA--UWEV  pfn 81801     ---DA--UWEV  not valid
                                                                                  PageFile:  0
                                                                                  Offset: 0
                                                                                  Protect: 10 - Decommitted

对于一次就预定和提交的范围,他的 __MMVAD_SHORT.u.VadFlags.MemCommit = 1, VMM就需要在PTE里记录他被取消提交的信息,因为VAD flags标记为整个区域都被提交了。这种情况下,VAD flag位仍保留置1,只是被取消提交的PTEs被设置为200。这可以通过windbg验证:

0: kd> !process 0 1MemColls.exe
PROCESS fffffa8063096b30
    SessionId: 1  Cid: 0328    Peb: 7fffffdf000  ParentCid: 0b3c
    DirBase: 823d8000  ObjectTable: fffff8a00f3e0610  HandleCount:   9.
    Image: MemColls.exe
    VadRoot fffffa8062e16570 Vads 35 Clone 0 Private 108. Modified 0. Locked 0.
    DeviceMap fffff8a00112a430
    Token                             fffff8a0013ea060
    ElapsedTime                       00:00:39.717
    UserTime                          00:00:00.000
    KernelTime                        00:00:00.000
    QuotaPoolUsage[PagedPool]         16904
    QuotaPoolUsage[NonPagedPool]      4080
    Working Set Sizes (now,min,max)  (497, 50, 345) (1988KB, 200KB, 1380KB)
    PeakWorkingSetSize                502
    VirtualSize                       7 Mb
    PeakVirtualSize                   8 Mb
    PageFaultCount                    499
    MemoryPriority                    BACKGROUND
    BasePriority                      8
    CommitCharge                      122

0: kd> !vad fffffa8062e16570
VAD             Level         Start             End              Commit        
fffffa8062634640  4             2d0             2d1               1 Private      READWRITE
0: kd> dt _MMVAD_SHORT u..  fffffa8062634640
nt!_MMVAD_SHORT
   +0x000 u1  :
      +0x000 Balance : 0n-6045658760896
      +0x000 Parent :
   +0x028 u   :
      +0x000 LongFlags : 0x84800000`00000001
      +0x000 VadFlags :
         +0x000 CommitCharge : 0y000000000000000000000000000000000000000000000000001 (0x1)
         +0x000 NoChange : 0y0
         +0x000 VadType : 0y000
         +0x000 MemCommit : 0y1
         +0x000 Protection : 0y00100 (0x4)
         +0x000 Spare : 0y00
         +0x000 PrivateMemory : 0y1
   +0x038 u5  :
      +0x000 LongFlags3 : 0
      +0x000 VadFlags3 :
         +0x000 PreferredNode : 0y000000 (0)
         +0x000 Teb : 0y0
         +0x000 Spare : 0y0
         +0x000 SequentialAccess : 0y0
         +0x000 LastSequentialTrim : 0y000000000000000 (0)
         +0x000 Spare2 : 0y00000000 (0)
         +0x000 LargePageCreating : 0y0
         +0x000 Spare3 : 0y0000000000000000000000000000000 (0)

对于一个具有_MMVAD_SHORT.u.VadFlags.MemCommit = 0VMM可以将PTE设置为0:该范围在VAD中没有被标记为已提交,所以PTE设置为0代表一个未提交的页面。然而,实际还存在情况是,PTE被设置为0x00000000'00000200,就像前面的情况一样。

3.1.3 VAD 和 内存保护类型

我们在调用VirtualAllocEx申请内存区域时,可以指定内存如何被访问,也就是保护类型,与此同时也就确定了缓存方式。

flProtect参数的可能值如下:

PAGE_READONLY: 这个区域不可写,而且代码不能在这里面执行。

PAGE_READWRITE: 允许读和写,代码不可执行。

PAGE_EXECUTE_READ:,PAGE_EXECUTE_READWRITE: 和上面的类似,不过指令提取也被允许,可以执行代码。

一个特别有趣的保护值是PAGE_EXECUTE,它应该使该范围只能用于执行代码,不允许读和写访问。这种保护在硬件上不被x64处理器所支持,在x64版本的Windows上似乎也没有实际作用。一个简单的测试程序可以分配一个PAGE_EXECUTE范围,然后读取它的内容(尽管对于一个新分配的范围,它只是零)。然而,这样的范围确实是不可写的,如果我们试图修改它的内容,就会出现访问违规。

传递到VirtualAllocEx里的flProtect决定了__MMVAD_SHORT.u.VadFlags.Protection成员的值。这可以通过调用API后使用调试器进行观测,Protection的值不是flProtect本身的值,但是是他派生出来的。这个VAD成员特别有趣,因为当它被初始化时,它的值最终会出现在PTE中,就像我们在上一节中看到的那样,用来跟踪内存提交。我们将在后面更详细地研究PTE,像我们前面看到的PTE,他的第5-9位存储了来自VAD的保护成员的值。我们必须记住,这些仍然仅仅只是PTE,P位是清零的,因此所有其他的位被处理器完全忽略,VMM可以自由使用它们。我们将看到在最后映射物理页时,VMM如何使用这个值来计算PTE的实际控制位。

下面的表显示了flProtect和Protect的对应关系:

Untitled 2.png

注意:

  1. 后面我们会分析各种缓存策略,当内存被访问时我们将看到最终的PTE是被如何计算出来的。
  2. write combinging术语来自VirtualAllocExMSDN文档

前面的例子表明,初始化的PTE跟踪一个已提交的页面被设置为0x80。测试是在调用VirtualAllocEx时,flProtect设置为PAGE_READWRITE,它设置了_MMVAD_SHORT.u.VadFlags.Protection=4。通过将4映射到PTE的第5-9位,我们可以得到

Untitled 3.png

也就是前面例子看到的0x80了。

最后,我们可以问自己:既然我们在谈论内存管理,那么VAD的内存从哪里来,或者说,它本身是如何管理的?它来自于VMM为内核组件维护的一个内存池,名为非分页池,我们将在后面分析它。现在,我们最好关注一下VMM如何为用户地址范围提供虚拟内存。

3.1.4 VADs对NUMA的支持

_MMVAD_SHORT 结构体中有一个6位的成员叫做u5.VadFlags3.PreferredNode,这个就是用来指定首选节点的。比如说通过调用VirtualAllocExNuma函数就能指定节点。

例如,调用VirtualAllocEx的结果是PreferredNode被设置为0;调用VirtualAllocExNuma时,nndPreferred(节点号)被设置为0,会创建一个PreferredNode=1的VAD。我们将在第150页的第26.4.2节中看到,在选择分配物理页的节点时,页面故障处理程序中如何检查这个字段。现在,我们可以预计,当首选节点被设置为零时,故障处理程序将从理想的节点上分配物理页,如第79页的第16.2节所述。这就解释了为什么PreferredNode被设置为nndPreferred + 1:零值具有 "未指定节点 "的特殊含义,因此传递给API的值(其范围从零开始)被递增以避免歧义。在第18页还指出,可以用VirtualAllocExNumaMapViewOfFileExNumaVAD级别指定首选节点。

3.2 虚拟地址位图

3.2.1 用户模式地址空间位图

存储在VAD中的部分信息被复制在该数据结构中,我们将其称为用户模式地址空间位图(UMAB)。

位图是由字节组成的块,其中每一个位都是用来跟踪一些资源的状态。位图的字节通常由_RTL_BITMAP的一个实例指出,其布局如下:

0: kd> dt _RTL_BITMAP
nt!_RTL_BITMAP
   +0x000 SizeOfBitMap     : Uint4B
   +0x008 Buffer           : Ptr64 Uint4B

对于UMABs,每个位与用户模式虚拟地址范围中的64 kB范围相关。因此,当位图的第0字节的第0位被设置时,意味着0-0xFFFF范围被分配(预定或预定并提交),第1位代表0x10000-0x1FFFF范围,等等。

VMM为每个进程维护一个UMAB,缓冲区(不是_RTL_BITMAP)的起始地址是0xFFFFF700'000000。如果我们看一下图17(第76页)中的内存地图,我们可以看到这是Hyperspace区域的起始地址,它是进程私有的,所以每个进程都有其私有的UMABUMAB必须足够长,以便为用户模式范围(即8TB)的每64k部分提供一个比特,因此需要的比特数是

0x800'00000000 / 0x10000 = 0x8000000

每个字节是8位,那么UMAB的长度按字节计数就是

0x8000000 / 8 = 0x1000000 = 16 MB

因此在每个地址空间中,区域0xFFFFF700'00000000-0xFFFFF700'00FFFFFF被用于UMAB

但为什么在VADUMAB中都要跟踪虚拟地址呢?一个可能的解释是,当UMAB找到一个空闲的虚拟范围时,它更快。

VADs对于检查一个给定的虚拟地址是否在一个已经被正确预留和提交的范围内是非常方便的。页面错误处理例程必须确定它是因为一个无效的地址才被调用的,还是因为需要映射一个合法的未被映射的地址才被调用的(例如,一个移到分页文件的内存页)。页面错误处理例程所要解决的问题是。"我有这个错误地址,是否有一个分配的区域包含着它?" 这可以通过快速搜索VAD树来回答这个问题。

然而,当涉及到分配一个新的虚拟区域时,问题就不同了:除非尝试分配的代码要求一个特定的起始地址,否则问题就变成了 "我在哪里可以找到一个n字节长的空闲区域?"。在位图中寻找答案可能更有效,因为它相当于搜索一个足够长的比特设置为0的序列。VirtualAllocEx确实也是这么做的。

UMAB的存在解释了为什么像VirtualAllocEx这样的函数预留的区域的起始地址必须在64kB边界上对齐。VAD和页表可以管理在页边界对齐的分配,但UMAB不能。VMM的设计者选择为每个比特映射64kB来减少UMAB的大小。

VirtualAllocEx分配的内存区域的长度不一定是64kB的倍数,因为这将是对已提交的以及最终物理内存的浪费。然而,给定一个起始地址为n 64k的单页区域,下一个虚拟区域必须从(n + 1) 64k开始,所以虚拟范围的尾部是未使用的。

尽管VMM为整个UMAB保留了一个16MB的虚拟范围,但在大多数进程中,它似乎只使用了一个物理页,所以只有0xFFFFF700'000000-0xFFFFF700'00000FFF的范围是实际有效的。这导致了0x8000个位图条目,映射了以下地址:

一页是4k,即4096字节,一个字节8位,那么有4096*8=0x8000个位图项

一个UMAB位代表了64kb范围,那么就是64*1024=0x1000大小。

0x8000*0x10000-1 = 0x80000000-1 相当于2GB-1

即使进程有高于2GB的有效VA范围也是如此(几个系统DLLs通常在7-8TB范围内映射),因此意味着VMM能够只使用VAD来管理2GB以上的范围。可能,这一切背后的原因是,在VA的低2GB范围内的分配更频繁,UMAB是为了加快它们的速度,但为每个进程消耗整个UMAB所需的16MB,这太昂贵了。

_MMWSL的成员中,我们发现LastVadBit通常被设置为0x7FFF,用来记录UMAB中最后一个有效的比特索引(一个页面存储0x8000比特)。我们将在第108页第20.2.3.1节中再次讨论这个问题)。

3.2.2 用户模式页表位图

紧随UMAB之后的是另一个位图,我们将其称为用户模式页表位图(UMPB)。每个位对应于一个映射用户模式范围的页表,当设置时,意味着页表存在,即相应的PDE是有效的(这意味着由该页表映射的地址的PML4EPDPTE也是有效的)。

一个PT映射512个页面或2MB,因此,为了映射8TB的用户模式地址,我们需要

0x800'00000000 / 0x200000= 0x400000

PTs,那么相应的位图buffer应该就是

0x400000/8 = 0x80000 = 512kB

UMPB的地址范围存在于0xFFFFF700'01000000 - 0xFFFFF700'0107FFFF。后面我们会看到UMPB的后面,也就是0xFFFF70001080000开始是进程的工作集链表。

3.2.3 验证两个位图的存在性

UMAB

我们还是使用MemTest程序来做实验。在程序调用VirtualAllocEx之前,我们先找到进程,然后确定MemTest的线程,接着对UMAB的起始地址下一个条件访问断点,也就是当MemTest程序的线程访问UMAB时才断下来:

0: kd> !process 0 4 MemTests.exe
PROCESS fffffa80625d05b0
    SessionId: 1  Cid: 0bf4    Peb: 7fffffd8000  ParentCid: 08e4
    DirBase: 15366f000  ObjectTable: fffff8a001b954a0  HandleCount:  11.
    Image: MemTests.exe

        THREAD fffffa80624bd060  Cid 0bf4.09cc  Teb: 000007fffffde000 Win32Thread: 0000000000000000 WAIT

0: kd> .process /i fffffa80625d05b0
You need to continue execution (press 'g' <enter>) for the context
to be switched. When the debugger breaks in again, you will be in
the new process context.
0: kd> g
Break instruction exception - code 80000003 (first chance)
nt!RtlpBreakWithStatusInstruction:
fffff800`04484490 cc              int     3
0: kd> ba r 1 /t fffffa80624bd060 0xFFFFF70000000000
0: kd> bl
     0 e Disable Clear  fffff700`00000000 r 1 0001 (0001) 
     Match thread data fffffa80`624bd060

当恢复执行后,程序只要执行内存分配操作,接着调试器会断下来:

0: kd> g
Single step exception - code 80000004 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
nt!MiFindEmptyAddressRange+0xe3:
fffff800`0479e503 e8d861d1ff      call    nt!RtlFindClearBits (fffff800`044b46e0)
0: kd> !thread @$thread
THREAD fffffa8062930b60  Cid 08e4.09dc  Teb: 000007fffffd3000 Win32Thread: fffff900c1fb9c30 RUNNING on processor 0
Not impersonating
DeviceMap                 fffff8a00133cfc0
Owning Process            fffffa8062879060       Image:         explorer.exe
Attached Process          N/A            Image:         N/A
Wait Start TickCount      604357         Ticks: 6 (0:00:00:00.093)
Context Switch Count      49091          IdealProcessor: 0                 LargeStack
UserTime                  00:00:00.171
KernelTime                00:00:01.279
Win32 Start Address shlwapi!WrapperThreadProc (0x000007fefe89c608)
Stack Init fffff880067995f0 Current fffff88006798080
Base fffff8800679b000 Limit fffff8800678e000 Call fffff880067999a0
Priority 11 BasePriority 9 PriorityDecrement 0 IoPriority 2 PagePriority 5
Child-SP          RetAddr               : Args to Child                                                           : Call Site
fffff880`06798ef0 fffff800`04784365     : ffffffff`ffffffff ffffffff`ffffffff fffff880`067991f0 00000000`00003000 : nt!MiFindEmptyAddressRange+0xe3
fffff880`06798f60 fffff800`0448b8d3     : ffffffff`ffffffff fffff880`067993a0 00000000`00000001 fffff880`06799390 : nt!NtAllocateVirtualMemory+0x6d5
fffff880`06799100 fffff800`04487e70     : fffff960`00154988 fffffa80`62930b60 fffff880`067994e0 00000000`0332e358 : nt!KiSystemServiceCopyEnd+0x13 (TrapFrame @ fffff880`06799170)
fffff880`06799308 fffff960`00154988     : fffffa80`62930b60 fffff880`067994e0 00000000`0332e358 fffff960`0013f564 : nt!KiServiceLinkage
fffff880`06799310 fffff800`0448b8d3     : 00000000`02010f27 00000000`00000000 00000000`00000000 00000000`00000000 : win32k!NtGdiCreateDIBSection+0x118
fffff880`067993f0 000007fe`fe58422a     : 000007fe`fe5841b1 00000000`00000000 00000000`0332e700 00000000`00000000 : nt!KiSystemServiceCopyEnd+0x13 (TrapFrame @ fffff880`06799460)
00000000`0332e338 000007fe`fe5841b1     : 00000000`00000000 00000000`0332e700 00000000`00000000 00000000`02accf40 : gdi32!NtGdiCreateDIBSection+0xa
00000000`0332e340 000007fe`fbc57f4a     : 00000000`02accf40 00000000`00000000 00000000`00000000 00003cdf`00000001 : gdi32!CreateDIBSection+0x211
00000000`0332e680 00000000`02accf40     : 00000000`00000000 00000000`00000000 00003cdf`00000001 00000000`00000000 : 0x000007fe`fbc57f4a
00000000`0332e688 00000000`00000000     : 00000000`00000000 00003cdf`00000001 00000000`00000000 00000000`00000000 : 0x2accf40

在控制台运行过程中可能会存在其他API分配内存的情况,我们可以先禁用断点,等到准备好调用VirtualAllocEx时启用断点,然后断下来查看调用栈:

1: kd> bl
     0 e Disable Clear  fffff700`00000000 r 1 0001 (0001)
     Match thread data fffffa80`624bd060

1: kd> bd 0

这里注意调试符号路径设置

Untitled 4.png

重新加载符号后再查看

1: kd> .reload
Connected to Windows 7 7601 x64 target at (Mon Mar 21 16:47:02.951 2022 (UTC + 8:00)), ptr64 TRUE
Loading Kernel Symbols
...............................................................
................................................................
...........................
Loading User Symbols
........................
Loading unloaded module list
........Unable to enumerate user-mode unloaded modules, Win32 error 0n30
1: kd> !thread @$thread
THREAD fffffa80624bd060  Cid 0bf4.09cc  Teb: 000007fffffde000 Win32Thread: 0000000000000000 RUNNING on processor 1
Not impersonating
DeviceMap                 fffff8a001328770
Owning Process            fffffa80625d05b0       Image:         MemTests.exe
Attached Process          N/A            Image:         N/A
Wait Start TickCount      608259         Ticks: 0
Context Switch Count      746            IdealProcessor: 1
UserTime                  00:00:00.000
KernelTime                00:00:00.031
Win32 Start Address MemTests!mainCRTStartup (0x000000013f5c7d1c)
Stack Init fffff880065f0c70 Current fffff880065f0530
Base fffff880065f1000 Limit fffff880065eb000 Call 0000000000000000
Priority 11 BasePriority 8 PriorityDecrement 2 IoPriority 2 PagePriority 5
Child-SP          RetAddr               : Args to Child                                                           : Call Site
fffff880`065f0860 fffff800`04784365     : ffffffff`ffffffff ffffffff`ffffffff fffff880`065f0b60 00000000`00002000 : nt!MiFindEmptyAddressRange+0xd2
fffff880`065f08d0 fffff800`0448b8d3     : ffffffff`ffffffff 00000000`0013f608 00000000`00000002 00000000`0013f610 : nt!NtAllocateVirtualMemory+0x6d5
fffff880`065f0a70 00000000`773f149a     : 000007fe`fd683096 00000000`0000ffff 000007fe`f960c57e 00000000`0013f630 : nt!KiSystemServiceCopyEnd+0x13 (TrapFrame @ fffff880`065f0ae0)
00000000`0013f5b8 000007fe`fd683096     : 00000000`0000ffff 000007fe`f960c57e 00000000`0013f630 00000000`0013f5e0 : ntdll!ZwAllocateVirtualMemory+0xa
00000000`0013f5c0 000007fe`fd6830d6     : 00000000`0013f638 00000000`00000000 00000000`00002000 00000000`0000000f : KERNELBASE!VirtualAllocExNuma+0x66
00000000`0013f600 00000000`771cbbe1     : 00000000`00002000 000007fe`f960c419 00000000`0000000d 00000000`00000004 : KERNELBASE!VirtualAllocEx+0x16
00000000`0013f640 00000001`3f5c6e62     : 00000001`3f5c98c0 000007fe`f9653400 00000000`0013d988 00000000`00243bb0 : kernel32!VirtualAllocExStub+0x11
00000000`0013f680 00000001`3f5c7593     : 00000000`0000006e 00000000`0013f719 00000000`00000000 00000000`00000000 : MemTests!VirtAllocTest+0x122 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 2600]
00000000`0013f6d0 00000001`3f5c5c80     : 00000000`00000000 00000000`00241a90 00000000`00000000 000007fe`f96520b0 : MemTests!VirtAllocTestInterface+0x603 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 2681]
00000000`0013f780 00000001`3f5c1392     : 00000000`00241a90 00000000`00241a90 00000000`0013dae8 00000000`00243b9f : MemTests!ProcessOption+0x120 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 2178]
00000000`0013f7e0 00000001`3f5c7cac     : 00000000`00000000 00000000`00000000 00000000`00000000 01d83cfb`0c7ae83e : MemTests!main+0x1e2 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 74]
(Inline Function) --------`--------     : --------`-------- --------`-------- --------`-------- --------`-------- : MemTests!invoke_main+0x22 (Inline Function @ 00000001`3f5c7cac) [d:\a01\_work\2\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 78]
00000000`0013f810 00000000`7719652d     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : MemTests!__scrt_common_main_seh+0x10c [d:\a01\_work\2\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 288]
00000000`0013f850 00000000`773cc521     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : kernel32!BaseThreadInitThunk+0xd
00000000`0013f880 00000000`00000000     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ntdll!RtlUserThreadStart+0x1d

调用栈里显示VirtualAllocEx传到内核模式后最终调用了MiFindEmptyAddressRange。断下来的指令可以通过执行ub指令进行查看,因为这个断点是页错误导致的,因此rip已经指向了错误指令的后面:

1: kd> ub
nt!MiFindEmptyAddressRange+0xaa:
fffff800`0479e4ca 8bc8            mov     ecx,eax
fffff800`0479e4cc 83ff01          cmp     edi,1
fffff800`0479e4cf 0f8585000000    jne     nt!MiFindEmptyAddressRange+0x13a (fffff800`0479e55a)
fffff800`0479e4d5 8bd7            mov     edx,edi
fffff800`0479e4d7 488d4c2430      lea     rcx,[rsp+30h]
fffff800`0479e4dc 4881fe00000100  cmp     rsi,10000h
fffff800`0479e4e3 0f85ffdc0100    jne     nt! ?? ::NNGAKEGL::`string'+0x9b38 (fffff800`047bc1e8)
fffff800`0479e4e9 a10000000000f7ffff mov   eax,dword ptr [FFFFF70000000000h]
1: kd> r rip
rip=fffff8000479e4f2

通过调试器进行单步跟踪几条指令,我们就会看到代码调用RtlFindClearBits,这个函数文档化的,他的第一个参数是一个指向RTL_BITMAP的指针。这个参数根据调用约定,那么就是rcx,寄存器的值高旭我们RTL_BITMAP是以下的值:

1: kd> r
rax=000000001fffffff rbx=000000001ffffffe rcx=fffff880065f0890
rdx=0000000000000001 rsi=0000000000010000 rdi=0000000000000001
rip=fffff8000479e503 rsp=fffff880065f0860 rbp=000007fffffdffff
 r8=000000000000001d  r9=000000000000001d r10=8000000000000000
r11=fffffa80625d05b0 r12=fffff880065f0920 r13=0000000000002000
r14=fffffa80625d05b0 r15=fffff70001080034
iopl=0         nv up ei pl nz na po nc
cs=0010  ss=0018  ds=002b  es=002b  fs=0053  gs=002b             efl=00000206
nt!MiFindEmptyAddressRange+0xe3:
fffff800`0479e503 e8d861d1ff      call    nt!RtlFindClearBits (fffff800`044b46e0)

可以看到rcx指向rsp的上面,因此我们可以dump栈上的RTL_BITMAP:

1: kd> ?? ((nt!_RTL_BITMAP*)@rcx)
struct _RTL_BITMAP * 0xfffff880`065f0890
   +0x000 SizeOfBitMap     : 0x8000
   +0x008 Buffer           : 0xfffff700`00000000  -> 0x1fffffff

因此我们传递给RtlFindClearBitsRTL_BITMAP指向的就是UMAB_RTL_BITMAP.SizeOfBitmap的值设置为0x8000,后面会进行解释。通过查阅RtlInitializeBitMap的文档,我们知道这个值也就是他的第三个参数指定的,代表的含义就是位图中的位数。因此我们的位图有0x8000位或者说0x1000字节长。

MiFindEmptyAddressRange 函数通过加载_MMWSL.LastVadBit的值然后+1来计算这个值,这个成员的意思就是UMAB的最后一个有效位的索引。通常是0x7FFF,因此那个大小就和我们前面看到的一样。

VAD产生的分配地址空间范围与位图内容进行比较也很有意思。下面是explorer.exevad

1: kd> !vad fffffa8062678a30
VAD             Level         Start             End              Commit
fffffa8062618cf0  8              10              1f               0 Mapped       READWRITE          Pagefile section, shared commit 0x10
[...]
fffffa8061794180  7            4800            487f              17 Private      READWRITE          
fffffa80626a5a70  6            4880            48ff              24 Private      READWRITE          
fffffa80624bf500  5            49f0            4a6f              17 Private      READWRITE          
fffffa80629c1d90  6            4c00            4dff             504 Private      READWRITE
[...]

可以看出:

  • 0x4800000-0x487FFFF 已经被申请了
  • 0x4A70000-0x4BFFFFF是空闲的
  • 从49F0000开始,有大量连续的范围被使用了

接下来让我们对比一下,UMAB的内容。0x48000000的偏移如下:

0x4800000/0x10000/8 = 0x90

我们将地址除以了64k,因为每一位涵盖的范围是这么大。因此字节里面的位的索引是除以8的余数:

0x48000000/0x10000%8 = 0

这个被分配的区域,用64k的块来计算的话:

0x4880000-0x4800000 = 0x80000

0x80000/0x10000 = 8

如果结束地址不是64k的倍数,那么 可以采用这个公式 (end-start+0xFFFF)/0x10000 将结果四舍五入为64k的最接近的倍数。

VirtualAllocEx的分配粒度是64k,起始地址不满足的话,地址范围将不会被使用。虽然0x4A70000满足分配粒度,但是他也没有被使用,在位图上应该有对应的位来表示没有被使用。

现在让我们来找到对应的位:

1: kd> db fffff700`00000000 + 90 l2
fffff700`00000090  ff ff

+90字节处的值是0xFF,说明全为1。和我们期待的一样,他的第0位确实是1。

0x4A70000/0x10000/8 = 0x94

0x4A70000/0x10000%8=7

0x4C00000-0x4A70000=0x190000

0x190000/0x10000=0x19=25

经过计算,+0x94处的内存应该会有19个0。

1: kd> db fffff700`00000000 + 94 l5
fffff700`00000094  7f 00 00 00 ff

可以看到第7位为0,往后有24个0,合计25个0。和我们计算的一致。

因此,这表明位图内容反映了存储在VAD中的虚拟地址空间分配。

UMPB

UMPB的证据可以在MmAccessFault的代码中找到,它被页面错误处理例程调用。这个函数检查了一个地址范围,从0xFFFFF700'01000000开始,一直延伸到_MWSL.MaximumUserPageTablePages / 8字节。MaximumUserPageTablePages是工作集链表的一个成员(在下一节解释),它通常被设置为0x400000。正如我们在第108页第20.2.2节中看到的,这个值是映射8TB所需的页表数量(正如成员名称所暗示的)。这告诉我们,在0xFFFFF700'01000000处开始一个数据结构,其大小等于用户模式页表的数量除以8(顺便说一下,这个除法是四舍五入到8的最近倍数),也就是说,这个数据结构的位数等于页表的数量。

我们还可以将这个区域的内容与VADs报告的地址空间分配进行比较。下面是explorer.exeVADs的另一个摘录:

1: kd> !vad fffffa8062678a30
VAD             Level         Start             End              Commit
[...]
fffffa8062678a30  0           7ffe0           7ffef 2251799813685247 Private      READONLY           
fffffa8062608bb0  8           ffa40           ffcff               7 Mapped  Exe  EXECUTE_WRITECOPY  \Windows\explorer.exe
[...]

这里可以分析出一块空闲的区域:0x7FFF0000-0xFFA3FFFF。现在我们要找到最后一个正在使用的页面的PT,它的地址是0x7FFEF000。这个PT将被使用,而下一个将是空闲的。由于每个PT映射2MB的地址空间,0x7FFEF000的PT基于0的索引是:

0x7FFEF000/0x200000 = 0x3FF

空闲区后第一个使用中的页面的PT的索引是:

0xFFA40000/0x200000 = 0x7FD

那么理论上,0x3FF将会被置1,而0x400到0x7FD都会被置0,然后0x7FE又被置1。对应的位图偏移就是

0x3FF/8 = 0x7F

字节里的索引就是

0x3FF%8 = 7

对于空闲范围的结束索引

0x7FD/8 = 0xFF

0x7FD%8= 5

然后我们看一看UMPB

1: kd> db 0xFFFFF70001000000+0x7F 0xFFFFF70001000000+0xFF
fffff700`0100007f  ff 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffff700`0100008f  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffff700`0100009f  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffff700`010000af  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffff700`010000bf  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffff700`010000cf  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffff700`010000df  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffff700`010000ef  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffff700`010000ff  60

可以看到+0x7F的第7位是1,往后直到0xFF的第5位全是0。+0xFF的第6位是1。

3.3 进程工作集数据结构

本节将介绍另一个基本的VMM数据结构,它用于跟踪一个进程的工作集。

一个进程的工作集(WS)是该进程使用的物理内存页的一个子集。我们把它定义为一个子集,因为它不包括:

  • 系统范围内的页,然而,它是进程地址空间的一部分,即映射到它。这包括用于:内核模式映像、内核内存池、内核堆栈等的页面。一些系统子范围是个例外,我们将在后面介绍。
  • 大页,这些页面本身是不可分页的。工作集是用来决定是否取消进程使用的物理页的映射,以增加空闲内存的数量,所以它只统计可分页页面。

另一方面,WS包括用户范围内的可分页页面,即:

  • 可分页的私有页面
  • 可分页的共享页面,后面会介绍共享内存是如何工作的

VMM跟踪WS的大小,并保持一个当前由物理页映射的VA的链表,我们将其称为工作集链表(WSL)。

可能会想到的一个问题是:我们为什么要用WSL?毕竟,这就是分页结构的作用是跟踪哪些VA有物理页面映射到它们。虽然如此,但拥有WSL有助于VMM完成其工作。

首先,在有些情况下,VMM需要对进程使用的所有物理页进行汇总,例如,当一个进程终止时,其内存必须被释放。从分页结构中收集这些信息将意味着扫描PML4以获得现有的PDPT,然后扫描每个PDPT以获得现有的PD,等等,这可能相当昂贵。因此,拥有一个映射VA的快速链表是很方便的。对于像大页面这样的不可分页的页面,这些信息可能是从其他数据结构中收集的,比如VADs

另外,WSL为每个可分页的VA存储了一些额外的信息,VMM会使用这些信息。例如,它记录了一个页面有多长时间没有被访问,这有助于VMM决定是否将页面内容移到分页文件中。

3.3.1 工作集大小

工作集的大小可以在_EPROCESS结构体的一个成员Vm中找到,他的类型是_MMSUPPORT:

0: kd> dt _EPROCESS -y Vm
ntdll!_EPROCESS
   +0x390 Vm : _MMSUPPORT
   +0x438 VmDeleted : Pos 5, 1 Bit
   +0x438 VmTopDown : Pos 21, 1 Bit

_MMSUPPORT存储了进程地址空间的各种信息,它包含下面的内容:

_MMSUPPORT.WorkingSetSize给出了当前WS的大小,单位是页。

MMSUPPORT.PeakWorkingSetSize给出了这个_MMSUPPORT生命周期内的最大值。

3.3.2 WSL的虚拟地址区域

WSL的地址也存储在Vm成员里,它在_MMSUPPORTVmWorkingSetList字段中。

0: kd> dt _MMSUPPORT
ntdll!_MMSUPPORT
   +0x000 WorkingSetMutex  : _EX_PUSH_LOCK
         ...
   +0x068 VmWorkingSetList : Ptr64 _MMWSL

VmWorkingSetList存放了WSL的地址,这个成员的类型是_MMWSL。下面是一个我们最喜欢的“实验室老鼠”——MemTests的输出:

0: kd> !process 0 0 MemTests.exe
PROCESS fffffa80625d05b0
    SessionId: 1  Cid: 0bf4    Peb: 7fffffd8000  ParentCid: 08e4
    DirBase: 15366f000  ObjectTable: fffff8a001b954a0  HandleCount:  11.
    Image: MemTests.exe

0: kd> dt nt!_eprocess fffffa80625d05b0 Vm.VmWorkingSetList
   +0x398 Vm                  : 
      +0x068 VmWorkingSetList    : 0xfffff700`01080000 _MMWSL

如果我们选择其他进程进行测试的话,会发现一些有趣的事情,下面是一个notepad.exe实例的输出:

0: kd> !process 0 0 notepad.exe
PROCESS fffffa8062c24b30
    SessionId: 1  Cid: 066c    Peb: 7fffffdf000  ParentCid: 08e4
    DirBase: 1538f7000  ObjectTable: fffff8a00133b4e0  HandleCount:  76.
    Image: notepad.exe

0: kd> dt nt!_eprocess fffffa8062c24b30 Vm.VmWorkingSetList
   +0x398 Vm                  : 
      +0x068 VmWorkingSetList    : 0xfffff700`01080000 _MMWSL

再看看一个Windows基本进程lsass的:

0: kd> !process 0 0 lsass.exe
PROCESS fffffa806246bb30
    SessionId: 0  Cid: 0210    Peb: 7fffffd9000  ParentCid: 0184
    DirBase: 1efb73000  ObjectTable: fffff8a00677b5a0  HandleCount: 570.
    Image: lsass.exe

0: kd> dt nt!_eprocess fffffa806246bb30 Vm.VmWorkingSetList
   +0x398 Vm                  : 
      +0x068 VmWorkingSetList    : 0xfffff700`01080000 _MMWSL

链表的地址总是一样的。这意味着只有一个WSL吗?不是这样的,如果你还记得内存空间布局图,那么你会发现这个地址在阴影区域一个叫Hyperspace的区域。回顾一下,每个进程都有这些阴影区域的私人副本,因为它有自己的PML4项。因此,在地址0xFFFFF700'01080000处通常可以找到当前在特定处理器上活动的地址空间的WSL

3.3.3 WSL项 (WSLEs)

_MMWSL有一个成员叫做Wsle,他指向了一个_MMWSLE结构体数组:

0: kd> dt nt!_MMWSL Wsle
   +0x010 Wsle : Ptr64 _MMWSLE

一个_MMWSLE是8字节长度的,可以存储一个虚拟地址和一些控制位,因为它们存储的VPN是4 kB对齐的,所以低12位是可用的。

数组中的工作集链表项(WSLE)可以是使用中的意味着它们存储了一个映射到物理页的进程的VA,或者它也可以是空闲的,其值与进程的VA没有关系。这反映在_MMWSLE的定义中:

0: kd> dt nt!_mmwsle -bv
struct _MMWSLE, 1 elements, 0x8 bytes
   +0x000 u1               : union <unnamed-tag>, 4 elements, 0x8 bytes
      +0x000 VirtualAddress   : Ptr64 to 
      +0x000 Long             : Uint8B
      +0x000 e1               : struct _MMWSLENTRY, 7 elements, 0x8 bytes
         +0x000 Valid            : Bitfield Pos 0, 1 Bit
         +0x000 Spare            : Bitfield Pos 1, 1 Bit
         +0x000 Hashed           : Bitfield Pos 2, 1 Bit
         +0x000 Direct           : Bitfield Pos 3, 1 Bit
         +0x000 Protection       : Bitfield Pos 4, 5 Bits
         +0x000 Age              : Bitfield Pos 9, 3 Bits
         +0x000 VirtualPageNumber : Bitfield Pos 12, 52 Bits
      +0x000 e2               : struct _MMWSLE_FREE_ENTRY, 3 elements, 0x8 bytes
         +0x000 MustBeZero       : Bitfield Pos 0, 1 Bit
         +0x000 PreviousFree     : Bitfield Pos 1, 31 Bits
         +0x000 NextFree         : Bitfield Pos 32, 32 Bits

_MMWSLENTRY定义了一个使用项的布局,而_MMWSLE_FREE_ENTRY是针对一个空闲项。

这个数组是使用中和空闲项的混合体,也就是说,这两种类型的项没有被分组到相邻的块中。这种情况的发生有很多原因。例如,VMM可以决定取消一个进程的虚拟页来释放一个物理页,所以存储该VPNWSLE变成了空闲,而它周围的其他块可能仍在使用。另外,一个进程可以在虚拟地址被物理映射并使用WSLE项的时候,取消分配一系列的虚拟地址,这些虚拟地址变成空闲的。
使用中的WSLE的值似乎并不是有序的,这表明VMM在需要时会使用第一个空闲的WSLE,不管它映射的是哪个VA

使用中的WSLEs存着一个_MMWSLENTRY实例,他的12-63位是VPNVadlid位被置为1,对于空闲项,这个位就会被清零。因此使用中的项,e1就是奇数,空闲的话,e1就是个偶数。HashedDirect位后面会和共享内存一起讨论。

下面是一个带有一些使用中的WSLE数组的例子:

0: kd> dt nt!_mmwsl 0xfffff700`01080000
   +0x000 FirstFree        : 0x565
   +0x004 FirstDynamic     : 5
   +0x008 LastEntry        : 0x65e
   +0x00c NextSlot         : 5
   +0x010 Wsle             : 0xfffff700`01080488 _MMWSLE
   +0x018 LowestPagableAddress : (null) 
   +0x020 LastInitializedWsle : 0x76e
   +0x024 NextAgingSlot    : 0
   +0x028 NumberOfCommittedPageTables : 0xa
   +0x02c VadBitMapHint    : 0xa
   +0x030 NonDirectCount   : 3
   +0x034 LastVadBit       : 0x7fff
[...]
0: kd> dq 0xfffff700`01080488
fffff700`01080488  fffff6fb`7dbed009 fffff6fb`7dbee009
fffff700`01080498  fffff6fb`7dc00049 fffff6fb`80008409
fffff700`010804a8  fffff700`01080009 fffff700`00000e09
fffff700`010804b8  fffff700`01000009 fffff6fb`7da00009
fffff700`010804c8  fffff6fb`40000009 fffff680`00000009
fffff700`010804d8  00000000`00038041 00000000`00011049
fffff700`010804e8  00000000`00012049 00000000`00013049
fffff700`010804f8  00000000`00014049 00000000`00015049

奇数的话,代表是使用中的。下面是第一项的内容:

0: kd> dt nt!_mmwslentry fffff700`01080488
   +0x000 Valid            : 0y1
   +0x000 Spare            : 0y0
   +0x000 Hashed           : 0y0
   +0x000 Direct           : 0y1
   +0x000 Protection       : 0y00000 (0)
   +0x000 Age              : 0y000
   +0x000 VirtualPageNumber : 0y1111111111111111111101101111101101111101101111101101 (0xfffff6fb7dbed)

在这里,我们看到有针对系统地址的WSLEs,这初看起来很奇怪:一个系统VA对于所有进程来说应该是被映射或无效的,那么为什么要跟踪它的状态到进程工作集呢?然而,这些地址位于第76页图17中内存布局图的两个阴影区域内,这些区域是进程私有的,所以除了用户模式范围外,WSL也跟踪这些区域。

_MMWSLENTRYAge成员特别重要,因为它被VMM用来决定是否应该将页面从工作集中删除,因为它已经 "老化 "了,即有一段时间没有被访问。这将在关于工作集修剪的章节中详细解释(第185页第28.2.4节)。

WSLEs的前5项似乎对于所有进程是一样的,让我们研究一下。

0: kd> dq 0xfffff700`01080488 L5
fffff700`01080488  fffff6fb`7dbed009 fffff6fb`7dbee009
fffff700`01080498  fffff6fb`7dc00049 fffff6fb`80008409
fffff700`010804a8  fffff700`01080009

前四项都在分页结构区域,我们必须搞清楚他们的含义。

一方面,WSLE存储了一个虚拟页的起始地址,它被映射到物理内存中。另一方面,分页结构区域中的一个地址是VA,例如,一个PTE,或一个PDE,等等。因此,在WSL中找到这样一个地址意味着存储该条目的分页结构(PT,PD,...)在物理内存中。例如,WSL中有一个PDPTE的地址那么就意味着整个PDPT在内存中,也就是说,它意味着指向它的PML4E是有效的。这些WSLE也存储了页对齐的地址:这样的地址对应于分页结构中的第一个项(如第一个PTE),该项的含义是整个分页结构(如整个PT)在物理内存中。注意,这并不意味着这些项是有效的:分页结构可能包含的是全零。WSLE只是告诉我们,特定的分页结构实例存在于物理内存中。

第0项,0xFFFFF6FB'7DBED009,是PML4VA(记住我们必须忽略最右边的三个数字,因为每个项存储的应该是页对齐的地址)。这意味着VMMWSL中跟踪这个VA被映射的事实,就像任何其他VA一样。

第1项,0xFFFFF6FB'7DBEE009是在PDPTE范围内。这个项意味着在0xFFFFF6FB'7DBEE000处可见的PDPT被映射到一个物理页,即选择它的PML4e是有效的(present bit set)。一个PDPT映射了一个512GB的范围,我们可以按照第69页15.2.3节的解释计算出映射范围的起始地址,结果是0xFFFFF700'00000000。因此,这个WSLE的含义是,映射范围为0xFFFFF700'000000-0xFFFFF780'000000的PDPT在物理内存中。这就是hyperspace的范围(见第76页的图17),WSL本身就在这里。换句话说,这个项记录了绘制整个hyperspace所需的PDPT在物理内存中。同样,这并不意味着它里面的PDPTE是有效的,只是说明PDPT存在于物理内存中。


----------------------------          ----9---   ---9----  ---9- --  ----9----    ------12-----
0xFFFFF70000000000 = 1111111111111111 111101110 000000000  000000000  000000000    000000000000
0xFFFFF6FB7DBEE000 = 1111111111111111 111101101 111101101  111101101  111101110    000000000000
0x1ed = 111101101
0xfffff6fb7dc00000 = 1111111111111111 111101101 111101101  111101110  000000000    000000000000
0xFFFFF70080000000 = 1111111111111111 111101110 000000010  000000000  000000000    000000000000

0xFFFFF6FB80008000 = 1111111111111111 111101101 111101110  000000000  000001000    000000000000
0xFFFFF70001000000 = 1111111111111111 111101110 000000000  000001000  000000000    000000000000

逆推过程就是移回原位,将auto-entry给覆盖掉了,然后再把地址规范化。就可以得到起始范围0xFFFFF700'000000。

第2项,0xFFFFF6FB'7DC00049,在PDE的范围内。这意味着PD在物理内存里。这个PD映射的2GB范围就是0xFFFFF700'00000000 - 0xFFFFF700'80000000。也就是hyperspace的前2GB。WSL也在这里面存着。

第3项,0xFFFFF6FB'80008409是在PTE范围内,告诉我们2MB范围的PT映射0xFFFFF700'01000000-0xFFFFF700'01200000是存在的。这个范围也在hyperspace内,它里面有WSL本身。

在这四个之后是第4项,0xFFFFF700'01080009,这不是一个分页结构的地址,而是存储WSL本身的页面地址。

因此,这5项的总体含义是,映射WSL本身所需的整个分页结构层次已经到位。显然,VMMWSL中记录了这些页面的使用情况,就像它对地址空间使用的其他每一个页面一样。另外,这让我们注意到,存储分页结构的物理页在进程工作集中被计算在内,就像存储数据的 "常规 "页一样(使用广义的 "数据 "一词,这意味着存储在内存中的任何东西,而不是处理器用来完成其工作的分页结构)。这与数据页和分页结构都可以从虚拟地址空间取消映射的事实是一致的。一个进程的工作集包括其驻留的虚拟页和将其映射到物理页所需的分页结构。分页结构和hyperspace范围内的地址是我们之前关于WSL不考虑系统范围地址的声明的例外情况。这与这些虚拟地址范围是进程私有的事实是一致的。MMWSL结构有一个名为FirstDynamic的成员,似乎总是被设置为5。我们可以猜测,这个成员存储了WSLE数组中第一个可用于 "正常 "使用的项的索引。0-4项是 "固定的",因此是 "非动态的",因为它们指的是使WSL本身存在所需的页面,5号以后的项是才是空闲的。对VMM代码的分析支持了这一假设,例如MiUpdateWsle函数,它似乎将小于FirstDynamic的索引视为无效。

空闲项存着一个_MMWSLE_FREE_ENTRY实例

0: kd> dt _MMWSLE_FREE_ENTRY
nt!_MMWSLE_FREE_ENTRY
   +0x000 MustBeZero       : Pos 0, 1 Bit
   +0x000 PreviousFree     : Pos 1, 31 Bits
   +0x000 NextFree         : Pos 32, 32 Bits

正如其名称所暗示的那样,一个空闲WSLE的第0位必须是0。
PreviousFreeNextFree将实例链接到一个空闲项链表中,以便快速访问。除了它们的名字,这可以通过分析名为MiRemoveWsleFromFreeListVMM例程来确认。该函数逻辑还显示,该链的第一项的PreviousFree = 0x7FFFFFFF,而最后一项的NextFree = OxFFFFFFFF。存储在这些字段中的值不是地址,而是由_MMWSL.Wsle指向的数组中的索引。同样有趣的是,这个函数在扫描空闲项链表时对这些值进行了一致性检查,如果发现它们无效,就会进行错误检查,也就是会蓝屏。

3.3.4 _MMWSL的其他成员

+0x000 FirstFree        : Uint4B

指向Wsle成员所指向的数组中空闲_MMWSLE链表的头部。可以在MiRemoveWsleFromFreeList函数中分析得到。

+0x008 LastEntry        : Uint4B

它似乎是在使用中的项索引中存的最大值。换句话说,所有索引大于LastEntry的现有项都是空闲的。可以在以下函数中分析得到:MiRemoveWsleFromFreeList, MiUpdateWsle.

+0x010 Wsle             : Ptr64 _MMWSLE

指向WSLE数组的开始。值得注意的是,它似乎总是有相同的值,数组就在_MMWSL结构之后开始。_MMWSL在0xFFFFF700'01080000,大小为0x488字节,因此Wsle通常被设置为0xFFFFF700'01080488。

+0x020 LastInitializedWsle : Uint4B

最后一个WSLE的地址。看起来VMMWSLE数组映射了数量不等的虚拟页,可能每当它变满时就会扩展它。数组从存储在Wsle成员中的地址开始,延伸到最后一个映射页的末端。这个成员存储了最后一个有效项的索引,通常在最后一个映射页的最后8个字节中。我们可以用notepad.exe的一个实例来验证这一点:

0: kd> !process 0 0 notepad.exe
PROCESS fffffa8062f39b30
    SessionId: 1  Cid: 079c    Peb: 7fffffdf000  ParentCid: 0a94
    DirBase: 1b8117000  ObjectTable: fffff8a0019ada20  HandleCount:  76.
    Image: notepad.exe

下面的命令是切换到notepad.exe的内存上下文。我们必须记得这样做,因为WSL在进程私有内存中。

0: kd> .process /i fffffa8062f39b30
You need to continue execution (press 'g' <enter>) for the context
to be switched. When the debugger breaks in again, you will be in
the new process context.
0: kd> g
Break instruction exception - code 80000003 (first chance)
nt!RtlpBreakWithStatusInstruction:
fffff800`044c2490 cc              int     3

现在让我们转储一下WSL的地址。虽然我们知道它通常是0xFFFFF700'01080000,但我们不冒任何风险,还是按照正常流程来。

0: kd> dt nt!_eprocess Vm.VmWorkingSetList @$proc
   +0x398 Vm                  :
      +0x068 VmWorkingSetList    : 0xfffff700`01080000 _MMWSL

_MMWSL的内容如下:

0: kd> dt nt!_mmwsl 0xfffff700`01080000
   +0x000 FirstFree        : 0x63b
   +0x004 FirstDynamic     : 5
   +0x008 LastEntry        : 0x720
   +0x00c NextSlot         : 5
   +0x010 Wsle             : 0xfffff700`01080488 _MMWSLE
   +0x018 LowestPagableAddress : (null)
   +0x020 LastInitializedWsle : 0x76e
         ...

最后一个初始化项的地址是由Wsle(数组起始地址)的值加上LastlnitializedWsle乘以8(一个项的大小)得到。下面是地址和它的内容:

0: kd> dq 0xfffff700`01080488 + 0x76e*8
fffff700`01083ff8  ffffffff`00000eda ????????`????????
fffff700`01084008  ????????`???????? ????????`????????
fffff700`01084018  ????????`???????? ????????`????????
fffff700`01084028  ????????`???????? ????????`????????
fffff700`01084038  ????????`???????? ????????`????????
fffff700`01084048  ????????`???????? ????????`????????
fffff700`01084058  ????????`???????? ????????`????????
fffff700`01084068  ????????`???????? ????????`????????

这个地址确实是在一个页面结束前的八个字节,而且下一个页面没有被映射。该地址内容是一个空闲项,因为它是偶数,并且他的_MMWSLE_FREE_ENTRY.NextFree(即32-63位)设置为0xFFFFFFFF,所以它是空闲项链表中的最后一个。

   +0x048 NonDirectHash    : Ptr64 _MMWSLE_NONDIRECT_HASH
   +0x050 HashTableStart   : Ptr64 _MMWSLE_HASH

我们在这里只简单地提到这两个成员,以后我们将和共享内存一起分析它们(见第331页第37.14节)。考虑一个由两个进程共享的内存页。每个进程都有自己的WSLE来存储映射到该页的VA,但是该项的索引在两个WSL中可能是不同的,也就是说,VA可以存储在不同WSL的不同项中。当这种情况发生时,这些哈希表被用来关联VA和索引。

3.3.5 !wsle 调试器扩展命令

掌握了到目前为止所获得的知识,我们可以更好地理解!wsle调试器扩展的输出。下面是一个在没有参数的情况下使用该命令的输出样例:

1: kd> !wsle

Working Set @ fffff70001080000
    FirstFree      53c  FirstDynamic        5
    LastEntry      631  NextSlot            5  LastInitialized      76e
    NonDirect        3  HashTable           0  HashTableSize          0

我们可以看到,使用了我们提到的WSL地址,并且显示了我们之前讨论的一些成员。

NonDirectHashTable以及HashTableSize后面会在共享内存进行解释。

3.3.6 从虚拟地址得到WSLE

在关于内存内容如何写入分页文件的讨论中,我们将看到,当VMM需要增加可用页的数量时,它会使用WSL来决定从进程工作集中删除哪些物理页。在这个逻辑中,VMM使用WSL作为其起点。一旦一个特定的WSLE被选为从WS中移除,VMM知道它所指的VA,因为它存储在WSLE本身。通过VA,VMM可以很容易地计算出用于映射的PTE的地址,并得到物理页的PFN。简而言之,VMM可以获得它需要更新的所有数据结构(我们将在后面的章节中详细分析这个过程)。

然而,在有些情况下,VMM只知道一个VA,并且需要为它去找WSLE。例如,考虑一下当一个进程通过调用VirtualFreeEx来释放一个虚拟内存的范围时会发生什么。这个函数接受一个虚拟地址作为输入,并且必须释放一个虚拟范围。当然,这意味着释放当前映射到该范围的任何物理页,并释放映射的VA的任何WSLEs

正如我们前面所看到的,正在使用的WSLEs并没有按照任何特定的顺序保存,因此为一个给定的VA找到一个WSLEs将意味着扫描整个WSL,这将是低效的。

相反,VMM似乎有一个更好的方法来解决这个问题。我们很快就会看到,VMM为系统中的每个物理页维护一个数据结构数组,称为PFN数据库。每个数组元素都存储着一个特定物理页的当前状态信息。当一个物理页在使用中,并且正在映射一个包含在工作集中的VA时,这个结构的一个成员存储了VAWSLE的索引。换句话说,VMM可以通过以下方式从VA得到WSLE:

  • 根据VA计算PTE地址
  • PTE中提取PFN
  • 根据PFN计算出在PFN数据库元素的地址
  • PFN数据库中提取WSLE索引
  • 访问WSLE

我们可以通过一个实验来证实这个假设。和本书中的其他许多实验一样,这个实验需要将系统附加到一个内核调试器上。

我们使用MemTests程序来分配和访问一个区域的内存。分配是通过选择主菜单上的VirtualAllocExTest()选项进行的。MemTests提示一组参数,这些参数设置如下:

IpAddress: 0, 让分配函数自己选择起始地址
dwSize: 0x1000
flAllocationType: 0x3000, 预定并提交内存
flProtect: 4, 可读写内存
specify NUMA node: n

当程序返回到主菜单时,它已经分配了区域。我们需要注意显示在菜单上方的区域起始地址(在下文中,它将是0x60000)。然后,我们必须写入该区域,以确保物理页被实际映射到它。我们可以通过选择 "Access region "选项,然后选择 "私有内存区域 "和 "写入内存 "来做到这一点,我们让开始和结束地址为默认值,这样整个区域都被写入。

之后,我们在主菜单上选择释放私有区域,以取消该区域的分配。在下一个提示中即将通过调用VirtualFreeEx释放内存区域,我们选择进入调试器。切到对应的线程上下文中。

0: kd> !process 0 0 MemTests.exe
PROCESS fffffa8062f38b30
    SessionId: 1  Cid: 059c    Peb: 7fffffd5000  ParentCid: 0a94
    DirBase: 41169000  ObjectTable: fffff8a006e48730  HandleCount:  11.
    Image: MemTests.exe

0: kd> .process /i fffffa8062f38b30
You need to continue execution (press 'g' <enter>) for the context
to be switched. When the debugger breaks in again, you will be in
the new process context.
0: kd> g
Break instruction exception - code 80000003 (first chance)
nt!RtlpBreakWithStatusInstruction:
fffff800`044c2490 cc              int     3
1: kd> !process 0 4 MemTests.exe
PROCESS fffffa8062f38b30
    SessionId: 1  Cid: 059c    Peb: 7fffffd5000  ParentCid: 0a94
    DirBase: 41169000  ObjectTable: fffff8a006e48730  HandleCount:  11.
    Image: MemTests.exe

        THREAD fffffa8062b9b060  Cid 059c.070c  Teb: 000007fffffde000 Win32Thread: 0000000000000000 WAIT
1: kd> .thread fffffa8062b9b060
Implicit thread is now fffffa80`62b9b060

现在我们必须预测关于PFN数据库的一些事情。我们将在访问存储工作集索引的PFN数据项时放置一个断点,以及在WSLE本身放置一个断点。这将使我们能够确认VirtualFreeEx使用这些信息来完成工作。

首先,我们使用!pte扩展来查找区域内第一页的物理地址。我们在这里需要我们之前提到的虚拟地址。

1: kd> !pte 0x60000
                                           VA 0000000000060000
PXE at FFFFF6FB7DBED000    PPE at FFFFF6FB7DA00000    PDE at FFFFF6FB40000000    PTE at FFFFF68000000300
contains 02C000003D861867  contains 0120000041968867  contains 0500000041069867  contains A7B000003B830867
pfn 3d861     ---DA--UWEV  pfn 41968     ---DA--UWEV  pfn 41069     ---DA--UWEV  pfn 3b830     ---DA--UW-V

这告诉我们,PFN是3b830(即物理地址是0x3b830000)。当我们把它送入!pfn扩展命令时,我们得到了该页的PFN数据库项的信息

1: kd> !pfn 3b830
    PFN 0003B830 at address FFFFFA8000B28900
    flink       0000027B  blink / share count 00000001  pteaddress FFFFF68000000300
    reference count 0001    used entry count  0000      Cached    color 0   Priority 5
    restore pte 00000080  containing page 041069  Active     M       
    Modified

我们现在不打算详细讲解PFN项。对于这个实验,我们只需要知道flink后面的数字0000027BWSLE的索引(十六进制),而且它的地址是在PFN项的最开始,也就是上面显示的地址0xFFFFFA8000A778D0处。我们可以用下面的调试器命令在这个地址的上设置一个读取断点。

0: kd> ba r 8 /t @$thread FFFFFA8000B28900

这将导致处理器在读取从0xFFFFFA8000A778D0开始的8字节范围内的字节时,进入调试器。/t @$thread选项告诉调试器,只有当断点在当前线程的上下文中被击中时才会真正停止执行。这个条件非常重要,因为像PFN数据库这样的内核数据结构会被许多系统线程访问,如果没有这个条件,断点会捕获各种系统函数。

下面的命令为当前线程设置了一个对WSLE第0x27B项进行写访问的断点。

0: kd> ba w 8 /t @$thread @@(&(((nt!_mmwsl*)0xfffff70001080000)->Wsle[0x27B]))

有了这些准备工作,我们就可以看到当VirtualFreeEx被调用时会发生什么。如果我们恢复执行,我们会看到在MiDeleteVirtualAddresses+0x4A0处碰到了断点0。请注意,数据断点会在访问断点所在内存地址的指令之后停止处理器,所以访问内存的地方应该是fffff800`044fc6a5。调用堆栈显示我们处于对VirtualFreeEx的调用中。

1: kd> k
 # Child-SP          RetAddr               Call Site
00 fffff880`028b8820 fffff800`044b7aef     nt!MiDeleteVirtualAddresses+0x4a0
01 fffff880`028b89e0 fffff800`044c98d3     nt!NtFreeVirtualMemory+0x61f
02 fffff880`028b8ae0 00000000`76e314fa     nt!KiSystemServiceCopyEnd+0x13
03 00000000`001bfc38 000007fe`fce12fc1     ntdll!ZwFreeVirtualMemory+0xa
04 00000000`001bfc40 00000001`3f1e5f60     KERNELBASE!VirtualFreeEx+0x41
05 (Inline Function) --------`--------     MemTests!ReleasePrivateRegion+0x83 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 2301]
06 00000000`001bfc70 00000001`3f1e1392     MemTests!ProcessOption+0x400 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 2196]
07 00000000`001bfcd0 00000001`3f1e7cac     MemTests!main+0x1e2 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 74]
08 (Inline Function) --------`--------     MemTests!invoke_main+0x22 [d:\a01\_work\2\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 78]
09 00000000`001bfd00 00000000`76bd652d     MemTests!__scrt_common_main_seh+0x10c [d:\a01\_work\2\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 288]
0a 00000000`001bfd40 00000000`76e0c521     kernel32!BaseThreadInitThunk+0xd
0b 00000000`001bfd70 00000000`00000000     ntdll!RtlUserThreadStart+0x1d
1: kd> ub
nt!MiDeleteVirtualAddresses+0x482:
fffff800`044fc68a 0f84a4020000    je      nt!MiDeleteVirtualAddresses+0x72c (fffff800`044fc934)
fffff800`044fc690 488bc6          mov     rax,rsi
fffff800`044fc693 48c1e80c        shr     rax,0Ch
fffff800`044fc697 4923c0          and     rax,r8
fffff800`044fc69a 488d1c40        lea     rbx,[rax+rax*2]
fffff800`044fc69e 48c1e304        shl     rbx,4
fffff800`044fc6a2 482bd9          sub     rbx,rcx
fffff800`044fc6a5 448b2b          mov     r13d,dword ptr [rbx]

我们也可以看看WSLE 0x27B,并确认它仍在使用和跟踪VA 0x60000。

1: kd> ?? ((nt!_mmwsl*)0xfffff70001080000)->Wsle[0x27B].u1
union <unnamed-tag>
   +0x000 VirtualAddress   : 0x00000000`00060009 Void
   +0x000 Long             : 0x60009
   +0x000 e1               : _MMWSLENTRY
   +0x000 e2               : _MMWSLE_FREE_ENTRY

当我们恢复执行时,我们被断点停止在
MiDeleteVirtualAddresses+0x55D。这意味着WSLE已经被更新了,所以最好再看一下它。

1: kd> ?? ((nt!_mmwsl*)0xfffff70001080000)->Wsle[0x27B].u1
union <unnamed-tag>
   +0x000 VirtualAddress   : 0x00000000`00060008 Void
   +0x000 Long             : 0x60008
   +0x000 e1               : _MMWSLENTRY
   +0x000 e2               : _MMWSLE_FREE_ENTRY

现在的值是一个偶数,所以这个项不再使用了。此时的调用栈如下:

1: kd> k
 # Child-SP          RetAddr               Call Site
00 fffff880`028b8820 fffff800`044b7aef     nt!MiDeleteVirtualAddresses+0x55d
01 fffff880`028b89e0 fffff800`044c98d3     nt!NtFreeVirtualMemory+0x61f
02 fffff880`028b8ae0 00000000`76e314fa     nt!KiSystemServiceCopyEnd+0x13
03 00000000`001bfc38 000007fe`fce12fc1     ntdll!ZwFreeVirtualMemory+0xa
04 00000000`001bfc40 00000001`3f1e5f60     KERNELBASE!VirtualFreeEx+0x41
05 (Inline Function) --------`--------     MemTests!ReleasePrivateRegion+0x83 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 2301]
06 00000000`001bfc70 00000001`3f1e1392     MemTests!ProcessOption+0x400 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 2196]
07 00000000`001bfcd0 00000001`3f1e7cac     MemTests!main+0x1e2 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 74]
08 (Inline Function) --------`--------     MemTests!invoke_main+0x22 [d:\a01\_work\2\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 78]
09 00000000`001bfd00 00000000`76bd652d     MemTests!__scrt_common_main_seh+0x10c [d:\a01\_work\2\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 288]
0a 00000000`001bfd40 00000000`76e0c521     kernel32!BaseThreadInitThunk+0xd
0b 00000000`001bfd70 00000000`00000000     ntdll!RtlUserThreadStart+0x1d

我们仍处于VirtualFreeEx的中间阶段:

如果我们接着运行下去,我们看到WSLEMiDeleteVirtualAddresses+0x5d9处被再次更新。

1: kd> ?? ((nt!_mmwsl*)0xfffff70001080000)->Wsle[0x27B].u1
union <unnamed-tag>
   +0x000 VirtualAddress   : 0x0000027d`fffffffe Void
   +0x000 Long             : 0x0000027d`fffffffe
   +0x000 e1               : _MMWSLENTRY
   +0x000 e2               : _MMWSLE_FREE_ENTRY
1: kd> k
 # Child-SP          RetAddr               Call Site
00 fffff880`028b8820 fffff800`044b7aef     nt!MiDeleteVirtualAddresses+0x5d9
01 fffff880`028b89e0 fffff800`044c98d3     nt!NtFreeVirtualMemory+0x61f
02 fffff880`028b8ae0 00000000`76e314fa     nt!KiSystemServiceCopyEnd+0x13
03 00000000`001bfc38 000007fe`fce12fc1     ntdll!ZwFreeVirtualMemory+0xa
04 00000000`001bfc40 00000001`3f1e5f60     KERNELBASE!VirtualFreeEx+0x41
05 (Inline Function) --------`--------     MemTests!ReleasePrivateRegion+0x83 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 2301] 
06 00000000`001bfc70 00000001`3f1e1392     MemTests!ProcessOption+0x400 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 2196] 
07 00000000`001bfcd0 00000001`3f1e7cac     MemTests!main+0x1e2 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 74] 
08 (Inline Function) --------`--------     MemTests!invoke_main+0x22 [d:\a01\_work\2\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 78] 
09 00000000`001bfd00 00000000`76bd652d     MemTests!__scrt_common_main_seh+0x10c [d:\a01\_work\2\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 288] 
0a 00000000`001bfd40 00000000`76e0c521     kernel32!BaseThreadInitThunk+0xd
0b 00000000`001bfd70 00000000`00000000     ntdll!RtlUserThreadStart+0x1d

有趣的是注意到WSLE现在是如何在空闲链表中被链起来的。在对断点进行放行后,我们再次命中了一个名为MilnsertPagelnFreeOrZeroedList的函数,函数里访问了PFN条目,此时还在VirtualFreeEx内。

Breakpoint 0 hit
nt!MiInsertPageInFreeOrZeroedList+0x2a8:
fffff800`044daa18 498bd8          mov     rbx,r8
1: kd> k
 # Child-SP          RetAddr               Call Site
00 fffff880`028b8560 fffff800`044fd3d7     nt!MiInsertPageInFreeOrZeroedList+0x2a8
01 fffff880`028b8670 fffff800`044fc627     nt!MiDeletePteRun+0x4ed
02 fffff880`028b8820 fffff800`044b7aef     nt!MiDeleteVirtualAddresses+0x41f
03 fffff880`028b89e0 fffff800`044c98d3     nt!NtFreeVirtualMemory+0x61f
04 fffff880`028b8ae0 00000000`76e314fa     nt!KiSystemServiceCopyEnd+0x13
05 00000000`001bfc38 000007fe`fce12fc1     ntdll!ZwFreeVirtualMemory+0xa
06 00000000`001bfc40 00000001`3f1e5f60     KERNELBASE!VirtualFreeEx+0x41
07 (Inline Function) --------`--------     MemTests!ReleasePrivateRegion+0x83 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 2301]
08 00000000`001bfc70 00000001`3f1e1392     MemTests!ProcessOption+0x400 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 2196]
09 00000000`001bfcd0 00000001`3f1e7cac     MemTests!main+0x1e2 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 74]
0a (Inline Function) --------`--------     MemTests!invoke_main+0x22 [d:\a01\_work\2\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 78]
0b 00000000`001bfd00 00000000`76bd652d     MemTests!__scrt_common_main_seh+0x10c [d:\a01\_work\2\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 288]
0c 00000000`001bfd40 00000000`76e0c521     kernel32!BaseThreadInitThunk+0xd
0d 00000000`001bfd70 00000000`00000000     ntdll!RtlUserThreadStart+0x1d

然后我们可以用bc*删除所有的断点,恢复执行.

综上所述,这个实验告诉我们,VirtualFreeEx执行了以下步骤:

  • 访问PFN数据库中WSLE的索引
  • 访问WSLE,并释放他

这些步骤与我们最初的假设是一致的。

这个方案仍有一个问题。正如我们将进一步看到的,VMM允许不同的进程共享一段物理内存。在所有共享进程中可能并不总是能够使用相同的WSLE:一个进程可以将共享的物理页映射到由WSLx项跟踪的VA,而另一个进程可以使用它自己的WSL中的y项来完成工作。这中情况是可能发生的,因为WSL的填充取决于每个进程自己访问虚拟地址的顺序。

然而,物理页的PFN数据库可以容纳一个WSL索引,这对所有的共享进程来说不可能是好事。我们在讨论共享内存时将会看到,VMM使用两个作为WSL一部分的哈希表来解决这个问题(见第331页第37.14节)。也就是_MMWSL. NonDirectHash_MMWSL.HashTableStart. 我们会讲这两个如何发挥作用。

3.4 页帧号数据库

页帧号数据库(PFNDB)是另一个基本的VMM数据结构。

PFNDB用于管理每一页的物理内存。它是一个_MMPFN结构的数组,大小等于物理页的数量。它位于地址0xFFFFFA80'00000000,它以PFN为索引,即物理地址为0x12345000的页面的数组元素是索引为0x12345。

一个物理页可以处于几种不同的状态之一:它可以映射某个进程的虚拟页;它可以处于被写出或处于从磁盘读取的过程中;它也可以是未使用的。一个_MMPFN实例的内容会根据页面的状态而改变。为了使_MMPFN尽可能的小,几个成员被重载了,根据页面的当前状态有不同的含义。

保持_MMPFN小是很重要的,因为PFNDB带来的固定开销与物理内存的数量成正比:如果一个系统有x个物理内存页,它也有x个MMPFN实例,这些实例本身存储在物理内存中。

此外,PFNDB是不可分页的,所以它使用固定数量的物理内存和虚拟地址空间。

下一节,我们讲讲解页面的不同状态。

4 物理页面状态

一个页面的状态存储在PFN数据库中,也就是_MMPFN.u3.e1.PageLocation成员里,他的值的情况由_MMLISTS枚举定义。下面的章节描述了每一种页面的状态,以及在这些状态下是否会存在一个引用该页面的PTE。这里只做一般性介绍,后面涉及VMM架构细节时,还会对一些页面状态进行回顾。

VMM允许将文件映射到内存,关于这点将在后面的章节进行介绍。当这种情况发生时,页面内容是从文件中读取和写入的,而不是分页文件。由于这个原因,下面的章节使用外部存储这个术语来指代可以找到页面内容的地方。我们将在后面看到分页在这两种情况下的实际作用。

4.1 Active

当一个页面正在映射一个虚拟地址,而且有一个或者多个PTE指向它时,那么页面的状态就是Active。这种状态也适用于存储分页结构的页面。常见的Active页面有:

  • 进程工作集中的页面,映射用户模式的VAs
  • 存储分页结构的页面

指向页面的PTEP位也会被设置,其余内容按照Intel x64架构进行定义。

我们后面在分析系统范围内存时会看到,对于某些虚拟范围,VMM不使用工作集。他将映射虚拟到物理地址的转换,但是映射的VA在任何工作集链表中都没有被记录。通常,这是为非分页的系统区域做的,比如非分页池。对于这样一种映射中使用的物理页。__MMPFN.u3.e1.PageLocation也会被设置为Active。所以说Active页时工作集的一部分,这种说法并不准确。这样的页面也会被用于一个地址转换中,如果是共享的,那么将不止一个页面。

4.2 Modified

该页面原来属于一个工作集,但是已经被从中移除了。这种页面的内容还没有被保存到外部存储中,所以在没保存之前,这个页面不会被重新使用。

指向这个页面的PTEP位是清零的,所以这个页面的内容会被处理器忽略,然后PTE的大部分剩余位还和P位被清零前的样子一样,比如说PFN,12-48位等等。如果代码视图访问这个页面,那么VMM会将其恢复到工作集。由于这不涉及磁盘访问,这种页面错误被称为软错误。实际上,当页面被置入这种状态时,一些PTE控制位会被改变,当软错误发生时,VMM能将他们恢复到原来的值。后面我们会详细分析软错误。

当内存管理器决定将其从WS中移除以减少WS的大小时,一个页面就会进入这个状态。我们将在以后的章节中看到,用于映射文件的页面也会因为不同的原因进入这种状态。

4.3 Standby

这个状态与Modified状态类似,除了页面内容已经被写入外部存储,所以如果VMM需要的话,页面可以被重新使用。然而,只要页面处于这种状态,它的内容仍然有效,PTE仍然指向它。和Modifed情况下一样(即软故障),如果一个线程访问了该页,就会被恢复到工作集中,并再次变成Active

4.4 Transition

页面上正在进行I/O操作,也就是说,它的内容正在从外部存储中读取或者写入。例如,一个处于Modified的页面正在被写入存储时进入Transition状态,之后再进入Standby状态。

一个从Modified的页面仍然有一个PTE指向它。我们将在第34章分析页面置入时讨论正在读取的页面的PTE

4.5 Free

这种页面中有未指定的脏数据。没有PTE指向它,它可以被重新使用。当VMM重新使用一个空闲页时,它不能简单地将其 "原来的 "映射到一个新的虚拟地址。这将允许旧的页面内容在新地址上可见。例如,一个进程可以读取另一个进程留在该页中的数据。为了避免这种情况,VMM在映射前用零来填充这样的页面。

4.6 Zeroed

这种页面可以自由使用,没有PTE指向它,他的内容已经被初始化为0。

4.7 Rom

物理地址对应的是只读存储器。

4.8 Bad

这种页面已经产生了硬错误,不能使用。这个状态也被内部用来处理从一个状态到另一个状态的转换。

4.9 Modified no-write

这个状态与Modified状态类似,但是,只要一个页面处于这个状态,它就不能被写入外部存储。这个状态不用于用户模式范围内虚拟地址的分页,所以我们暂时不对其进行研究,但我们将在研究高速缓存管理器时再对其进行研究。

5 页面链表

对于一些可能的状态,物理页被保存在一个链表中,以便VMM可以快速检索该类型的页。有一些链表用于: StandbyModifiedFreeZeroed no-writeROM页面。

为了使事情简单化,现在我们先简单知道有这些链表,以后我们会看到其中一些链表实际上被划分为子链表。

这些链表由_MMPFN实例组成,通过一些成员链接在一起,每个实例代表链表中的一个物理页面(PFN等于实例在_MMPFN数组中的索引)。

6 一个新物理页的申请

当一个新分配的地址第一次被引用时,会发生一个页面错误,因为VMM在第一次实际访问这个地址前都不会为其分配物理页面。此时的错误称为demand-zero错误,因为他必须完成VA到物理页的映射,并且这个页面要初始化为0。VMM会尝试从Zeroed链表中抽取一个页面来处理这个错误。如果Zeroed链表为空的话,他就从空闲页面里取一个,并初始化为0。如果Free链表也为空的话,他就在Standby链表里找,然后初始化页面内容为0。

最后一种可能性将我们引向一个有趣的问题:我们知道一个Standby页有一个指向它的PTE,它仍然持有该页的PFN并准备用于最终的软错误。如果该页被重复使用,这个PTE必须被更新,这样涉及它的最终故障就会被当作 "硬 "错误处理,需要从外部存储中读回内容。
但是我们也知道,PTE是在每个进程的区域中进行映射的:当VMM寻找要给某个进程的页面时,该进程的地址空间是当前的,这意味着该进程的PTE是在映射结构区域中映射的。

然而,如果我们想从Standby链表中抓取一个页面,它可能属于另一个进程,我们必须有一种方法来更新属于它的PTE。我们将看到这是如何做到的--留个悬念,后面会讲。

要想知道如果连Standby链表都是空的会发生什么,有点困难,因为大多数现有文献都没有考虑这种情况。

当一个页面的内容被保存到磁盘上并且该页面被移到Standby链表中之后,该页面将从Modified链表中取出。没有进一步详细说明在写入过程中错误线程是如何保持等待的,无论如何,这听起来更像是触发补充Standby链表,然后从里面取出页面。

我们将在第158页的26.4.3.4.3节中详细分析从Standby链表中分配的情况,尽管只涉及demand-zero错误。

如果Modified链表是空的,那么就会从工作集中删除一个页面。没有明确说明是哪个工作集,但是一个合理的假设是,该页是从发生错误的进程的WS中移除的。也许,VMM可以先看内容已经写入外部存储的页面,然后再看未保存的页面。

StandbyModified链表为空时,系统的物理内存严重不足,因为VMM试图通过修剪工作集来避免这种情况。

7 状态转换概述

VMM执行其工作时,一个页面会从一个状态进入另一个状态,因此本节概述了所涉及的状态转换。这并不是对所有可能的转换的详尽描述,以避免在这个阶段给读者带来太多的复杂性。例如,这里没有提到与文件映射有关的转换,因为我们将在后面讨论这个问题。我们将参考第135页的图22,以更好地理解状态转换。

我们可以从考虑一个属于WS的页面开始描述。当VMM试图保持物理内存可用时,这样的页面可以从WS中移除。这可能会发生,例如,因为内存越来越少,而且该页已经有一段时间没有被访问了。无论什么原因,如果分页文件中存在其内容的副本,则该页会进入Standby链表,如果它有未保存的内容,则会进入Modified链表。

如果进程访问该页,可以从Modified链表中把它恢复到原来的WS。由于进程产生了一个页面错误,这个操作也被称为错误返回页面,并且这个错误是一个软错误(不涉及磁盘访问)。

一个名为Modified Page WriterVMM线程专门用于将Modified页面内容写入分页文件中。当这种情况发生时,在写入过程中,Modified页面进入Transition状态,然后进入Standby状态。我们在这里把事情简化了一下,因为在写的过程中,进程可以尝试访问该页,但我们以后会分析这种事件。在本书的其余部分,我们将使用术语页面置(outpaging)出来表示一个页面已经被写入分页文件,即分页文件中存在它的一个副本。

我们现在准备介绍VMM架构的一个关键点。当一个页面被重新使用时,例如为另一个进程使用时,它不会被分页。换句话说,当VMM想将一个物理页重用在其他地方时,内存内容不会被写入分页文件中。相反,一个Modified页面被Modified Page Writer置出,之后,它可以在Standby链表中保持一段不确定的时间。页面的实际重用会在稍后阶段发生,如果有足够的物理内存,这可能会在页面被置出后很长一段时间发生。

Standby状态,一个页面可以错误返回原来的WS以映射相同的VA,或者当一个页面错误需要处理,但是FreeZero链表为空时,它可以被VMM重新使用。当后者发生时,指向Standby页的PTE被更新,这样,当相关进程再次访问相应的VA时,它将产生一个硬错误。如果该页被重新使用以处理一个demand-zero错误,它在被Active到新的WS之前就被清零了。如果它被重用来从分页文件中读回内容,那么在读的过程中它将进入Transition状态。

在本书的其余部分,我们将使用术语repurpose来表示页面重用。我们现在可以理解,outpagingrepurposeVMM在不同时刻采取的两种不同的行动:它换出内存以使Standby页可用,当它需要将物理内存给一个进程时,它就会repurpose页面。我们还将使用术语swapped out来指代被重新利用的物理页的内容,这个内容本身只能在分页文件中使用。因此,内存内容在存储它的物理页既被outpaged又被repurposed之后就会被swapped out

ActiveStandbyModified页面在它们被释放时,例如在进程退出时,会进入Free链表。

从作者研究的Windows文献来看,似乎Standby页在释放之前不会被移到Free链表中。但所有其他被审查的资料都没有提到任何关于它的内容。很可能VMM不动用Standby页,只有ZeroFree链表为空,而且它实际需要一个物理页来处理一个错误时才会用到。这样一来,Standby页就保持可用,并可以在实际需要物理内存时再进行错误处理。

Free链表中的页面内容被初始化为0,并由Zero页面线程移到Zero链表中,该线程是VMM的一部分,优先级为0,因此它在CPU空闲的时候运行。

另外,当VMM必须把一个页面从分页文件带回WS时,就会使用Free链表中的页面。VMM需要把内容读回物理页,所以它可以使用一个Free页,因为它的内容将被分页文件的数据覆盖。

如果Zero链表是空的,Free链表中的页可以用零填充,并用于解决demand-zero错误。

Zero链表中的页面可以被用来处理新分配的虚拟地址的页面错误,并将页面放入工作集。如果Free链表是空的,Zero页面也可以用来从分页文件中读回内容。

下图描述了到目前为止所涉及的状态转换:

Untitled 5.png

图注:

  1. 如果进程访问在Modified或Standby链表上的一个页时,VMM可以通过错误返回将其恢复为Active时拥有它的WS
  2. 一个demand-zero错误的解决,需要从Zero链表中获取一个页面,然而,如果链表是空的,则获取一个Free页面并初始化为零。如果两个链表都是空的,则从Standby链表中抽取一个页面。这意味着该页被重新使用,并被赋予一个WS,而这个WS并不是它先前被删除的那个。在被添加到新的WS之前,该页被填上零。以前拥有该页的WSPTE被更新,这样它就记录了该页内容在分页文件中的位置。当相应的进程试图引用这个页面时,它将产生一个硬页面错误。
  3. 为了解决硬错误,VMM将分配一个物理页并启动一个读操作,这样该页就进入了Transition状态。在可能的情况下,使用Free页;如果Free链表是空的,则使用Zero页;如果两个链表都是空的,则使用Standby页。

在接下来的章节中,我们将跟踪一个物理页可能的生命周期,从它第一次被分配到一个地址空间,到它被repurposed,后它的内容又最终被写回来。在这样做的同时,我们将解释VMM的其他几个细节。不过,在继续分析之前,我们将花点时间谈谈VMM中是如何处理同步的。

8 VMM中的同步

8.1 工作集PushLock

VMM需要更新一个地址空间的分页结构时,它获得WS推锁。如果一个以上的线程试图更新分页结构,它们可以以串行的方式进行更新,从而使这些结构处于一致的状态。

使用的推锁存在_MMSUPPORTWorkingSetMutex成员中,每个地址空间都有自己的MMSUPPORT实例。尽管这个成员的名字含有Mutex,但是他的变量类型是_EX_PUSH_LOCK

例如,我们将在后面看到WorkingSetMutex是如何在处理页面错误的开始阶段被获取,并在PTE被更新后被释放的(第26.8节,第172页)。需要更新分页结构的API(如VirtualAllocEx和VirtualFreeEx)在处理同一地址空间的页面错误时试图获取WorkingSetMutex,后调用它们的线程会进行等待。

根据以下规则,一个推锁可以以独占或共享的方式获得:

  • 如果推锁是空闲的或者已经被另一个线程以共享方式获得,那么以共享方式获得推锁的尝试就会成功。
  • 如果推锁已经被独占获取,共享获取的尝试就会被阻止。
  • 只有在推锁是空闲的情况下,独占推锁的获取才会成功,否则会被阻止(即之前的共享获取会阻止独占获取)。

上面提到的页面错误处理程序和VMM API都独占获取推锁,在它们之间串行执行。这也意味着,在同一地址空间引起页面错误的不同线程也将被推锁串行执行。

页面错误的大部分处理是在不提高IRQL的情况下进行的,因此VMM代码可以被打断,甚至被另一个线程抢占。VMM没有独占系统的控制权,而是使用同步原语,如工作集推锁来保护共享数据,并允许正常的线程调度继续进行。

当一个线程阻塞等待推锁时,调度器将其置于等待状态,因此它不会消耗CPU周期,直到它最终获得推锁并被恢复。

8.2 _MMPFN 锁

_MMPFN.PteAddress的第0位。被用作锁,以同步更新到_MMPFN实例。在更新之前,IRQL被提升到DISPATCH,并且测试该位;如果该位被设置,线程在一个循环中旋转,直到它被清除;如果该位被清除,它被设置,阻止来自其他线程的更新(测试和设置操作是一个单一的原子指令的一部分)。总之,这个位被用作在DPC级别获得类似像自旋锁的效果。

其他现有的Windows资料提到了 "PFN数据库自旋锁",然而,至少在Windows 7的代码中,似乎没有一个单一的锁用于整个数据库,而是,这个每个实例的锁。这有很大的意义,因为每个实例都可以独立于所有其他实例被锁定。PFN自旋锁的消亡似乎从!qlocks扩展的输出中得到证实:

1: kd> !qlocks
Key: O = Owner, 1-n = Wait order, blank = not owned/waiting, C = Corrupt

                       Processor Number
    Lock Name         0  1

KE   - Unused Spare
MM   - Unused Spare
MM   - Unused Spare
MM   - Unused Spare
CC   - Vacb
CC   - Master
EX   - NonPagedPool
IO   - Cancel
CC   - Unused Spare
IO   - Vpb
IO   - Database
IO   - Completion
NTFS - Struct
AFD  - WorkQueue
CC   - Bcb
MM   - NonPagedPool

该命令显示系统全局排队自旋锁的状态。

这个链表曾经包括(至少在Windows Vista之前)一个名为MM - PFN的自旋锁,现在已经没有了。

注意_MMPFN.pteAddress的第0位可以重复使用,因为PTE地址是以8个字节为界对齐的。

MMPFN锁只在很短的代码部分被获取,因为它让其他线程在忙等待循环内等待锁的旋转。这与WS推锁形成鲜明对比,WS推锁通常在许多VMM函数中独占获取。

9 首次访问私有地址

在本节中,我们将描述当第一次访问私有进程内存内的地址时会发生什么。将分析限制在这种特殊情况下,将有助于我们减少描述的复杂性,同时仍然也可以使我们了解VA如何被映射到物理页的一些细节。

首先,我们必须记住,这个地址必须在VA的一个区域内,这个区域已经被预留和提交,否则就不能被访问。

当第一次访问发生时,有几种可能出现的情况:

  • 地址的页表项(PTE)被设置为0。 这发生在VA范围被预定并整块提交的时候。
  • PTE可以被设置为一个非零值,它将页面的保护存储在第5-9位,当一个预留区只被部分提交时,就会发生这种情况。
  • 一个或多个映射该地址的分页结构可能还没有被分配。例如,页表(PT)可能缺失,这意味着页目录项(PDE)被设置为0.或者可能发生页目录(PD)和PT都缺失,所以页目录指针表项(PDPTE)被设置为0。最后,页目录指针表(PDPT)也可能不存在,这意味着只有VA的页映射4级(PML4E)存在并被设置为0。因为进程创建时就会有PML4的存在。
  • 同样,一个或多个分页结构可能已经被换出去了,所以,即使父表的PxE没有被设置为0,因为它存储了足够的信息来检索被换掉的内容,它的P(present)位仍然是清零的。我们现在不打算分析这种情况,因为我们将在第36章研究分页结构本身的分页问题。

上述所有情况都会导致一个页面错误(PF),所以处理器将执行转移到PF处理例程。通过查看IDT,我们可以看到这个处理例程是一个名为KiPageFault的函数,它将当前的执行状态保存在堆栈中,建立一个_KTRAP_FRAME结构的实例,然后调用MmAccessFault。后者是主要的VMM PF处理代码,其工作是通过映射虚拟地址来解决错误,或者返回一个适当的NTSTATUS值,代表无效的内存访问。

在接下来的章节中,我们将描述MmAccessFault处理错误的步骤。值得再次说明的是,这种分析只限于一种特殊情况:对用户模式VA的第一次访问。

9.1 初始检查和同步

首先,MmAccessFault检查导致错误的地址是否是规范的形式,如果不是这样,则返回STATUS_ACCESS_VIOLATION

之后,它确保当前的IRQL小于DPC/dispatch。同样,如果这个检查失败,将返回错误的NTSTATUS;因为检查不通过的话,此时的IRQL意味着错误发生在内核模式下,MmAccessFault的调用者可能会进行bugcheck(蓝屏)。对于由用户模式代码产生的PF,当前的IRQL总是PASSIVE

接下来,该函数会检查在当前线程的_ETHREAD是否设置了以下任何标志:

  • OwnsProcessWorkingSetExclusive
  • OwnsProcessWorkingSetShared
  • OwnsSystemCacheWorkingSetExclusive
  • OwnsSystemCacheWorkingSetShared
  • OwnsSessionWorkingSetExclusive
  • OwnsSessionWorkingSetShared
  • OwnsPagedPoolWorkingSetExclusive
  • OwnsPagedPoolWorkingSetShared
  • OwnsSystemPtesWorkingSetExclusive
  • OwnsSystemPtesWorkingSetShared

如果这些标志中的一个或多个被设置,该函数会用代码0x1A(MEMORY_MANAGEMENT)进行bugchecks。从标志的名称来看,其逻辑似乎是:如果一个已经拥有锁的工作集线程发生了页面错误,则系统会崩溃。

如果前面的检查都通过了,该函数将禁用线程的APC传递,并获取进程的工作集推锁,该推锁存储在_MMSUPPORT.WorkingSetMutex(尽管是互斥锁的名字,但这个变量的类型是_EX_PUSH_LOCK)。_MMSUPPORT的指针从当前线程中获得。当推锁被成功获取后,该函数会设置
_ETHREAD.ProcessWorkingSetExclusive,这是之前检查的标志之一。

9.2 检查内存访问和设置PTE保护

VMM采取的下一步措施是检查尝试的内存访问是否有效,如果有效,则将PTE设置为一个中间值,该值将在剩下的处理中进行使用。

9.2.1 当PTE被发现为0时

如果VAPT存在,并且相应的PTE被设置为0,VMM必须检查VA是否被正确预定和提交。这是在MiCheckVirtualAddress函数中完成的,该函数根据VA寻找VAD。如果没有找到VADMmAccessFault返回STATUS_ACCESS_VIOLATION,因为该VA既没有被预定也没有被提交。

如果找到了VAD,则将_MMVAD_SHORT.u.VadFlags.Protection的值复制到PTE的第5-9位。

使用上述数据类型,来自VAD的保护被复制到PTE中,如下所示:

`MMPTE.u.Soft.Protection = _MMVAD_SHORT.u.VadFlags.Protection`

以这种方式设置PTE后,VMM根据VAD中的保护继续映射VA,而不检查导致错误的内存访问是否被页保护所允许。这意味着一个物理页将被映射到进程地址空间,并再次尝试执行错误指令。如果该指令执行的是页面保护不允许的访问,那么将发生一个新的PF,控制权将再次返回到MmAccessFaultMmAccessFault发现VA已经被映射,将采取不同的行动,因此将检测到违反保护的情况并返回STATUS_ACCESS_VIOLATION。

实验:观察两次页错误行为

上一节末尾概述的行为是很奇特的,所以值得用实验来观察它。愿意把前面所说的视为理所当然的读者可能想跳过本节。

我们将再次使用我们的MemTests程序,它将预定并提交一个页面,具有只读保护,然后尝试写入该页面。注意:本实验中可见的MemTests代码与下载包中的最终版本不同,因为随着本书的进展,MemTests变得更加复杂。然而,可下载的版本可以用来执行这个测试并重现其结果。

下面是MemTests在分配完页面后以及在试图写入页面之前的输出结果:

Untitled 6.png

Untitled 7.png

我们使用Windbg查看一下PTE:

1: kd> dq 0xFFFFF68000000B00 l1
fffff680`00000b00  00000000`00000000

可以看出PT存在,PTE被设置为0。然后我们在MmAccessFault + 0x244处设置一个断点:这是PTE = 0情况下调用MiCheckVirtualAddress的地方。我们使用bp命令的/p选项,只为MemTests.exe设置断点,否则当VMM为系统的其他部分做工作时,我们会不断地命中断点。在设置好断点后,我们让MemTests继续进行,这样就会命中断点。

bp /p 0xfffffa8063820630 MmAccessFault+0x244

现在让我们看看调用栈:

Breakpoint 0 hit
nt!MmAccessFault+0x244:
fffff800`044d7864 e8471b0100      call    nt!MiCheckVirtualAddress (fffff800`044e93b0)
1: kd> k
 # Child-SP          RetAddr               Call Site
00 fffff880`04082980 fffff800`044c876e     nt!MmAccessFault+0x244
01 fffff880`04082ae0 00000001`3f1e173f     nt!KiPageFault+0x16e
02 00000000`002afb30 00000001`3f1e1912     MemTests!AccessRegion+0x34f [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 124]
03 00000000`002afbd0 00000001`3f1e5bb0     MemTests!AccessRegionInterface+0x162 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 178]
04 00000000`002afc00 00000001`3f1e1392     MemTests!ProcessOption+0x50 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 2163]
05 00000000`002afc60 00000001`3f1e7cac     MemTests!main+0x1e2 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 74]
06 (Inline Function) --------`--------     MemTests!invoke_main+0x22 [d:\a01\_work\2\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 78]
07 00000000`002afc90 00000000`76bd652d     MemTests!__scrt_common_main_seh+0x10c [d:\a01\_work\2\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 288]
08 00000000`002afcd0 00000000`76e0c521     kernel32!BaseThreadInitThunk+0xd
09 00000000`002afd00 00000000`00000000     ntdll!RtlUserThreadStart+0x1d

Untitled 8.png

1: kd> .frame 2
02 00000000`002afb30 00000001`3f1e1912     MemTests!AccessRegion+0x34f [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 124]

Untitled 9.png

1: kd> ?? pStart
unsigned char * 0x00000000`00160000
 "--- memory read error at address 0x00000000`00160000 ---"

高亮显示的源代码行来自MemTests.exe,它是程序试图向页面进行写入操作。.frame 2命令将调试器的堆栈上下文设置为MemTests,这样我们就可以用??命令检查pStart的值,这表明我们确实在试图向0x160000的页面写入。内存读取错误的那一行告诉我们,调试器正试图解引用lpMem,但是失败了,这并不奇怪,因为这个VA还没有被映射。

我们正在查看的对MiCheckVirtualAddress的调用将两个局部变量的指针传递给了函数:一个位于rbp + 0x58,MiCheckVirtualAddress将返回_MMVAD_SHORT实例的指针。第二个变量位于rbp + 0x68,它是_MMVAD_SHORT.u.VadFlags.Protect。我们可以检查调用前后这两个变量的值。

Untitled 10.png

1: kd> dq @rbp+58 l1
fffff880`04082a58  00000000`00000000
1: kd> db @rbp+68 l1
fffff880`04082a68  00                                               .
1: kd> r
rax=0000000000000000 rbx=0000000000000000 rcx=0000000000160000
rdx=fffff88004082a68 rsi=0000000000000000 rdi=ffffffffffffffff
rip=fffff800044d7864 rsp=fffff88004082980 rbp=fffff88004082a00
 r8=fffff88004082a58  r9=fffff6fb40000000 r10=fffff68000000b00
r11=0000000000000001 r12=fffffa8063511060 r13=0000000000160000
r14=fffffa80638209c8 r15=0000058000000000
iopl=0         nv up ei pl zr na po nc
cs=0010  ss=0000  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
nt!MmAccessFault+0x244:
fffff800`044d7864 e8471b0100      call    nt!MiCheckVirtualAddress (fffff800`044e93b0)
1: kd> p
nt!MmAccessFault+0x249:
fffff800`044d7869 448b7d68        mov     r15d,dword ptr [rbp+68h]
1: kd> dq @rbp+58 l1
fffff880`04082a58  fffffa80`6117e7f0
1: kd> db @rbp+68 l1
fffff880`04082a68  01

p命令用于单步执行调试,在调用之前,两个局部变量都被设置为0,而在调用之后,位于rbp + 0x68的那个被设置为1。让我们看看rbp+0x58处的VAD所指向的内容:

1: kd> dt nt!_MMVAD_SHORT fffffa80`6117e7f0
   +0x000 u1               : <unnamed-tag>
   +0x008 LeftChild        : (null)
   +0x010 RightChild       : (null)
   +0x018 StartingVpn      : 0x160
   +0x020 EndingVpn        : 0x160
   +0x028 u                : <unnamed-tag>
   +0x030 PushLock         : _EX_PUSH_LOCK
   +0x038 u5               : <unnamed-tag>
1: kd> dt nt!_MMVAD_SHORT u.VadFlags. fffffa80`6117e7f0
   +0x000 u1          :
   +0x028 u           :
      +0x000 VadFlags    :
         +0x000 CommitCharge : 0y000000000000000000000000000000000000000000000000001 (0x1)
         +0x000 NoChange    : 0y0
         +0x000 VadType     : 0y000
         +0x000 MemCommit   : 0y1
         +0x000 Protection  : 0y00001 (0x1)
         +0x000 Spare       : 0y00
         +0x000 PrivateMemory : 0y1

我们可以从StartingVpnEndingVpn的值中看到,VAD在0x160000覆盖了我们的页面,并且u.VadFlags.Protection被设置为1,即复制到rbp + 0x68的局部变量中的值。1实际上指的就是一个具有PAGE_READONLY保护页面的值。

到目前为止,我们地址的PTE还没有被更新:

1: kd> dq 0xFFFFF68000000B00 l1
fffff680`00000b00  00000000`00000000

然后我们使用g @$ra执行到当前函数返回。

1: kd> g @$ra
nt!KiPageFault+0x16e:
fffff800`044c876e 85c0            test    eax,eax
1: kd> r
rax=0000000000000111 rbx=0000000000000003 rcx=0000000000000095
rdx=0000000000000111 rsi=0000000000000077 rdi=00000000000004c7
rip=fffff800044c876e rsp=fffff88004082ae0 rbp=fffff88004082b60
 r8=0000000000000111  r9=fffffa8000b44c50 r10=00000000001b5a78
r11=fffff70001080488 r12=0000000000161000 r13=0000000000000072
r14=0000000000000000 r15=0000000000160000
iopl=0         nv up ei ng nz na po nc
cs=0010  ss=0018  ds=002b  es=002b  fs=0053  gs=002b             efl=00000286
nt!KiPageFault+0x16e:
fffff800`044c876e 85c0            test    eax,eax

可以看到我们执行在KiPageFault内部,然后返回值存在rax里,他是0x111。

看一下ntstatus.h的Windows头文件就知道,这个值对应的是STATUS_PAGE_FAULT_DEMAND_ZERO。让我们看一下我们的PTE

1: kd> dq 0xFFFFF68000000B00 l1
fffff680`00000b00  a7c00000`3c197025

它的P位被设置和物理地址PFN是0x197000,所以我们的VA现在被映射了。因此,尽管我们试图写到一个只读的页面,我们得到了一个虚拟到物理的映射。

MemTests仍在等待完成对页面的写入,所以我们在MmAccessFault的开头为它设置了另一个断点,让事情继续下去。bp /p @$proc命令设置了一个只对当前进程有效的断点,即MemTests

1: kd> bp /p @$proc nt!MmAccessFault
1: kd> g
Breakpoint 1 hit
nt!MmAccessFault:
fffff800`044d7620 48895c2420      mov     qword ptr [rsp+20h],rbx

让我们看看MemTests在做什么:

1: kd> k
 # Child-SP          RetAddr               Call Site
00 fffff880`04082ad8 fffff800`044c876e     nt!MmAccessFault
01 fffff880`04082ae0 00000001`3f1e173f     nt!KiPageFault+0x16e
02 00000000`002afb30 00000001`3f1e1912     MemTests!AccessRegion+0x34f [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 124]
03 00000000`002afbd0 00000001`3f1e5bb0     MemTests!AccessRegionInterface+0x162 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 178]
04 00000000`002afc00 00000001`3f1e1392     MemTests!ProcessOption+0x50 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 2163]
05 00000000`002afc60 00000001`3f1e7cac     MemTests!main+0x1e2 [F:\cpp\WmipCodeSamples\MemTests\MemTests.cpp @ 74]
06 (Inline Function) --------`--------     MemTests!invoke_main+0x22 [d:\a01\_work\2\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 78]
07 00000000`002afc90 00000000`76bd652d     MemTests!__scrt_common_main_seh+0x10c [d:\a01\_work\2\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 288]
08 00000000`002afcd0 00000000`76e0c521     kernel32!BaseThreadInitThunk+0xd
09 00000000`002afd00 00000000`00000000     ntdll!RtlUserThreadStart+0x1d

Untitled 11.png

1: kd> ?? pTouch
unsigned char * 0x00000000`00160000
 ""

Untitled 12.png

MemTests仍然卡在同一个点上,试图写到0x160000:在它取得任何进展之前,它已经引起了第二个页面错误。注意与第一个PF的不同之处:调试器现在成功地完成了对lpMem的解引用,也就是说,没有内存读取的错误信息。

现在我们再一次让函数执行到返回:

1: kd> g @$ra
nt!KiPageFault+0x16e:
fffff800`044c876e 85c0            test    eax,eax
1: kd> r
rax=00000000c0000005 rbx=0000000000000003 rcx=0000000000000096
rdx=00000000c0000005 rsi=0000000000000077 rdi=00000000000004c7
rip=fffff800044c876e rsp=fffff88004082ae0 rbp=fffff88004082b60
 r8=00000000c0000005  r9=0000000000000001 r10=00000000001b5a78
r11=fffff88004082ae0 r12=0000000000161000 r13=0000000000000072
r14=0000000000000000 r15=0000000000160000
iopl=0         nv up ei ng nz na po nc
cs=0010  ss=0018  ds=002b  es=002b  fs=0053  gs=002b             efl=00000286
nt!KiPageFault+0x16e:
fffff800`044c876e 85c0            test    eax,eax

我们返回到了KiPageFault,但是现在的返回值是0xC0000005,这就是著名的STATUS_ACCESS_VIOLATION。如果我们继续执行,我们就会得到系统中常见的抱怨。

Untitled 13.png

9.2.2 当PTE不为0时

这种情况发生在保护已经被写入PTE的时候,以跟踪部分提交的范围。当这种情况发生时,VMM会在一个名为MiAccessCheck的函数内对尝试的内存访问执行一些额外的检查。例如,在VA被映射之前,试图写到只读内存的行为会在这里被发现并导致访问违规。

但并不是每个保护条件都被检查。例如,MiAccessCheck不检查是否有人试图在不可执行页面内执行代码(例如,有PAGE_READWRITE保护的页面)。如上一节所述,这种违规行为将被第二个页面错误所捕获。

如果访问被允许,PF处理将继续进行。

9.2.3 当一个或者多个分页结构需要被申请时

我们将在后面分析这种情况(第36.2节,第289页),那时我们将描述分页结构本身是如何分页的。现在,我们只想说,VMM调用MiCheckVirtualAddress来确保试图访问的是已提交的内存,如果检查失败则返回STATUS_ACCESS_VIOLATION。如果地址是有效的,它将建立分页结构层次,直到PT。举个例子,如果因为PML4E被设置为0而导致整个层次结构丢失,VMMPDPTPDPT分配页面,并建立缺失的三个层次的分页结构。之后,我们的地址有一个被设置为0的PTEPF的处理就像第140页第26.2.1节中所述的那样进行。

9.3 更新页表项使用计数

到目前为止,我们已经看到VMM如何管理一下内容:

  • 对PF和线程的状态进行基本的检查。
  • 确保分页结构的层次结构完整,即存在这个地址的PTE
  • 确保地址的PTE的_MMPTE.u.Soft.Protection掩码被设置

在完成了这些之后,VMM为存储页表的物理页找到了_MMPFN实例。通常认为_MMPFN实例指的是虚拟内存背后引用的页面,然而PT和所有其他分页结构本身都存储在物理页面中。因为每个物理页正好有一个_MMPFN实例,所以会有实例指代分页结构。为了获得PT mmpfn的地址,VMM计算出错误地址的PDEVA,并读取PDE里面的PFNPDE指向的是PT。然后,它使用PFN作为PFN数据库中的一个索引,以获得mmpfn实例。这样做是会增加_MMPFN.UsedPageTableEntries成员的值。我们将在后面讨论这个成员的意义。

9.4 分配物理页,并完成正确的着色和节点选择

VMM计算新页面的颜色,以优化缓存的利用率,在这样做的同时,也考虑了NUMA节点的拓扑结构。

9.4.1 对于默认 NUMA 节点的着色计算

首先,_MMSUPPORT.NextPageColor是一个计数器,每次有新的物理页被分配到与_MMSUPPORT实例相关的地址空间时都会递增。

然后,VMM访问线程理想处理器的_KPRCB实例。为了实现这一点,它使用了当前线程的_KTHREAD实例的IdealProcessor成员,该成员存储了一个数字,用于识别线程初始化时选择的理想处理器。VMM使用这个数字来索引到一个名为KiProcessorBlock的指针数组。每个数组项都指向KPRCB的一个实例,也就是这样:

KiProcessorBlock[_KTHREAD.IdealProcessor]

得到理想处理器的地址。

VMM将递增的NextPageColor_KPRCB.SecondaryColorMask相与,后者屏蔽了计数器的较高位,以提供实际的颜色值。

现在是时候看一些与NUMA相关的工作了。VMM将到目前为止获得的颜色值与理想处理器_KPRCBNodeShiftedColor成员进行或运算。这个值将设置到之前SecondaryColorMask屏蔽的位,然后作为识别NUMA节点的数字。例如,对于一个SecondaryColorMask = 0x3F的Core 2 Duo移动处理器,NodeShiftedColor将使用从6开始的位。这个最终值将被用于索引到一个物理页的链表数组中。每个索引对应于一个NUMA节点和颜色,并选择一个具有所需颜色并属于所需节点的页面链表。我们将这个索引称为颜色节点索引。

总而言之,颜色节点索引的计算方法如下:

  • 获取理想处理器的_KPRCB
  • 增加_MMSUPPORT.NextPageColor的值
  • 将结果与_KPRCB.SecondaryColorMask与运算
  • 再将上一步的结果与_KPRCB.NodeShiftedColor进行或运算

9.4.2 显示节点上的着色计算

可以通过一些API来分配内存时指定物理页必须来自一个特定的节点,例如VirtualAllocExNuma。这个函数采用与VirtualAllocEx相同的参数,加上一个节点号,这个节点号最终存储在VAD中,也就是_MMVAD_SHORT.u5.VadFlags3.PreferredNode;对于没有指定节点的分配,这个成员被设置为零。

如果VMMVAD中为错误地址找到一个非零的PreferredNode,则以不同的方式计算color-NUMA索引:

  • 计算的第一步仍然是递增_MMSUPPORT.NextPageColor
  • SeconaryColorMask 从当前处理器的_KPRCB中读取,而不是理想处理器。
  • 值增加后的NextPageColorSecondaryColorMask进行与运算
  • PreferredNode递减后左移MmSecondaryColorNodeShifted位。
  • 上一步骤的结果与NextPageColor进行或运算

与默认节点情况的主要区别是,color-NUMA索引的节点所在位不等于_KPRCB.NodeShiftedColor,而是等于指定的节点号减一,这意味着我们能明确设置索引的节点所在位。MmSecondaryColorNodeShift包含了将节点号定位到颜色位左边所需的移位计数,而在之前的情况下,KPRCB.NodeShiftedColor已经被定位在正确的位子上,这是为了提高效率。这里我们有一个来自API调用的节点号,所以我们必须进行移位计算。

SecondaryColorMask来自于当前的处理器,这可能是一种性能优化:系统中所有的处理器都有相同的颜色掩码,而且进入当前的处理器比进入理想的处理器更容易。在之前的案例中,我们必须访问理想处理器数据来获得NodeShiftedColor,所以从同一个KPRCB实例中获取SecondaryColorMask是有意义的。

显式节点和 非零 PTEs

当地址的PTE不等于0时,VAD不会被检索,因为页面保护已经在PTE中了。当页面从一个显式节点分配时,这并不完全正确。这种特殊情况在一个名为MiResolveDemandZeroFault的函数中处理,该函数从当前线程中提取_KTHREAD.Tcb.Apcstate.Process。这是一个指向当前地址空间被映射进程的_EPROCESS的指针。然后,该函数检查_EPROCESS.NumaAware标志,如果它被设置,就调用MiLocateAddress来定位出错地址的VAD,接着获取u5.VadFlags3.PreferredNode

因此,逻辑是:如果NumaAware是清零的,我们跳过对VAD树的搜索,使用默认的节点选择逻辑;否则,我们在VAD中检查这个特定地址是否有节点被指定。我们可以得出结论,如果进程至少调用过一次NUMA aware API,那么NumaAware可能就会被设置。

9.4.3 用于分配的物理页面链表

一旦VMM计算出color-NUMA索引,后者就被用来选择具有所需属性(即颜色和节点)的物理页面链表。每个链表将具有相同属性的页面的_MMPFN实例链接在一起,链表头被组织成按color-NUMA索引的数组。

有两种不同的_MMPFN链表:单向链表和PFN链表。

单向_MMPFN链表

第一种类型的链表被链接到_SLIST_HEADER的实例,它存储了链表头,用来定义其内容的备用内存布局的联合体。通过查看MmAccessFault的代码(例如见+0x9AE处的代码片段),我们可以看到静态的MiZeroPageSlist存储了一个_SLIST_HEADERs数组的地址。代码通过color-NUMA索引进入这个数组,并得到一个具有所需属性的页面链表的头部。如果我们查看_SLIST_HEADER出现的内容,我们发现它与_SLIST_HEADERHeaderX64HeaderX64成员一致。HeaderX64.Depth是指链表元素的数量。HeaderX64.NextEntry存储了PFN数据库地址范围内的一个_MMPFN实例的地址。实际上,NextEntry只有60位宽,所以它存储的是地址右移4位后的值,即除以16,这不是一个问题,因为_MMPFN的大小是0x30字节,PFN数据库中的实例的地址是这个大小的倍数,所以最右边的4位数字总是0。

反过来,_MMPFN.u1.Flink存储下一个链表元素的地址,并将最后一个节点的地址设为零。

有趣的是,_MMPFN.u3.e1.PageLocation被设置为5。PageLocation成员的内容与_MMLISTS枚举中定义的值一致:

0: kd> dt nt!_MMLISTS
   ZeroedPageList = 0n0
   FreePageList = 0n1
   StandbyPageList = 0n2
   ModifiedPageList = 0n3
   ModifiedNoWritePageList = 0n4
   BadPageList = 0n5
   ActiveAndValid = 0n6
   TransitionPage = 0n7

我们看到,5代表Bad页。

通过阅读MmAccessFault是如何访问这个列表的,我们可以得出结论,这实际上是一个按color-NUMA索引分组的zeroed页面链表,所以它们实际上不是bad页。

通过对符号名称的一点点猜测,我们找到了MiFreePageSlist符号,它似乎是Free页面链表的起点。它指向一个_SLIST_HEADERs的数组,每一个都指向一个_MMPFNs链表,其color-NUMA索引与数组索引相等。例如,这个命令显示,数组的元素[0]是一个有7个节点的链表:

0: kd> ?? ((nt!_SLIST_HEADER*) @@(poi nt!MiFreePageSlist))->HeaderX64.Depth
unsigned int64 7

下面的表达式给出了第一个PNF节点:

0: kd> ?? (((unsigned int64) ((((nt!_SLIST_HEADER*) @@(poi nt!MiFreePageSlist))->HeaderX64.NextEntry) << 4)) - 0xfffffa8000000000)/0x30
unsigned int64 0x132e00

为了计算PFN,我们首先提取_SLIST_HEADER.HeaderX64.NextEntry并向左移4位,这就给出了_MMPFN实例的实际地址。相应的PFN就可以这样计算:

(_MMPFN 地址 - PFN 数据库起始地址)/ _MMPFN 结构体大小

PNF db start = 0xfffffa8000000000

_MMPFN size = 0x30

0: kd> dt _KPRCB -y SecondaryColorMask @$prcb
ntdll!_KPRCB
   +0x4770 SecondaryColorMask : 0xff

结果PFN的颜色为0,因为这个特定的机器的颜色掩码等于0xFF,所以颜色在PFN的0-7位。

我们还可以查看链表中的下一个节点。首先,我们使用!pfn扩展来查看u1.Flink的内容。它存储了下一个节点的地址:

0: kd> !pfn 0x132e00
    PFN 00132E00 at address FFFFFA800398A000
    flink       FFFFFA800211E000  blink / share count 00000000  pteaddress FFFFF68000009CFA
    reference count 0000    used entry count  0000      Cached    color 0   Priority 0
    restore pte 00000080  containing page 1ECDB6  Bad

我们计算下一个节点的PFN:

0: kd> ?? (0xFFFFFA800211E000-0xfffffa8000000000)/0x30
unsigned int64 0xb0a00

他的页面颜色也是0。

索引1和2的链表得出以下结果:

0: kd> ?? ((nt!_SLIST_HEADER*) @@(poi nt!MiFreePageSlist))->HeaderX64.Depth
unsigned int64 7
0: kd> ?? (((unsigned int64) ((((nt!_SLIST_HEADER*) @@(poi nt!MiFreePageSlist) + 1)->HeaderX64.NextEntry) << 4)) - 0xfffffa8000000000)/0x30
unsigned int64 0x21f101
0: kd> ?? (((unsigned int64) ((((nt!_SLIST_HEADER*) @@(poi nt!MiFreePageSlist) + 2)->HeaderX64.NextEntry) << 4)) - 0xfffffa8000000000)/0x30
unsigned int64 0x21e202

结果显示,颜色与数组索引一致。

最后,使用!db扩展命令查看页面内容是很有意思的,这条命令它在给定的物理地址上转储内存。我们可以看到,这些页面有的是脏的,也就是说,没有被清零:

0: kd> !db 0x21f101000
#21f101000 00 00 00 00 00 00 00 00-1c 00 00 00 0a 00 00 80 ................
#21f101010 03 08 00 00 00 37 00 00-00 5e 00 00 00 00 0b 00 .....7...^......
#21f101020 00 00 ff ff 0e 00 00 00-c8 00 00 00 cb 00 00 00 ................
#21f101030 ce 00 00 00 d1 00 00 00-d4 00 00 00 d7 00 00 00 ................
#21f101040 da 00 00 00 dd 00 00 00-e0 00 00 00 e3 00 00 00 ................
#21f101050 e7 00 00 00 eb 00 00 00-ef 00 00 00 f3 00 00 00 ................
#21f101060 00 30 00 00 31 00 00 32-00 00 33 00 00 34 00 00 .0..1..2..3..4..
#21f101070 35 00 00 36 00 00 37 00-00 38 00 00 31 36 00 00 5..6..7..8..16..

我们看看Zeroed链表里面的页面:

0: kd> ?? ((nt!_SLIST_HEADER*) @@(poi nt!MiZeroPageSlist))->HeaderX64.Depth
unsigned int64 2
0: kd> ?? (((unsigned int64) ((((nt!_SLIST_HEADER*) @@(poi nt!MiZeroPageSlist))->HeaderX64.NextEntry) << 4)) - 0xfffffa8000000000)/0x30
unsigned int64 0xb0300
0: kd> !db 0xb0300000
#b0300000 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
#b0300010 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
#b0300020 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
#b0300030 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
#b0300040 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
#b0300050 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
#b0300060 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
#b0300070 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................

PFN链起来的_MMPFN链表

这些链表是通过color-NUMA索引的,就像前面那种链表一样,但是_MMPFN实例是通过它们在PFN数据库中的索引联系在一起的。记住这一点很有用,根据设计,这个索引等于与_MMPFN实例相关的物理页的PFN

静态变量 MmFreePagesByColor 是访问 PFN 链表的起点。它是一个由两个指针组成的数组:第一个指针指向zeroed页面的链表,第二个指针指向空闲页的链表。使用上一节的_MMLIST枚举,我们可以将其正式表达为:

MmFreePagesByColor[ZeroedPageList] = 指向Zeroed链表

MmFreePagesByColor[FreePageList] = 指向Free链表

每个指针都是以color-NUMA为索引的_MMPFNLIST数组的地址,_MMPFNLIST.Flink持有该链表中第一个_MMPFN的索引。根据我们使用的指针和指定的color-NUMA索引,这是一个Zeroed页或Free页的实例。反过来,_MMPFN.u1.Flink将包含链表中下一个实例的索引,对于最后一个节点,将被设置为0xFFFFFFFFFFFFFFFF。请注意,这是在单向链表中存储下一个节点的地址的同一个成员。_MMPFNLIST是双向链表,_MMPFNLIST.Blink存储最后一个链表元素的索引,每个节点都有_MMPFN.u2.Blink设置为前一个的索引;对于第一个元素,这个成员是0xFFFFFFFFFFFFFFFF。

_MMPFN.u3.e1.PageLocation被设置为该页所在列表的对应值:_MMLISTS.ZeroedPageList(即0)或_MMLISTS.FreePageList(即1)。这与上一节中的单向链表形成对比,在单链表中它被设置为_MMLISTS.BadPageList

我们将在以后详细说明,在系统地址范围内,这两种列表的存储位置。

两种链表类型的对比

由于单链表的元素是通过它们的地址链接的,VMM可以快速获取一个链表节点来分配页面。另一方面,对PFN链表的访问应该会慢一点,因为_MMPFN地址必须从索引中计算出来。

单链表看起来相当小(每个列表只有3个元素是很常见的),所以它们看起来像一个快速可用的_MMPFN实例的各种 "缓存"。当MmAccessFault需要分配一个Zeroed页面时,它首先查看单链表,只有这个链表为空时才去看PFN链表。

页面查询顺序

从Zeroed链表分配页面

如果发现了一个zeroed页面,VMM就会继续将其映射到地址空间中;所涉及的操作将在接下来的章节中进行分析。我们可以用前面介绍的图来表示页面状态的转换,并在下面用较粗的箭头来强调我们的特定转换

Untitled 14.png

从Free链表分配页面

Free链表中的一个页面虽然可以使用,但它是 "脏的",在被映射到地址空间之前必须被清零。这似乎不是什么大问题:将0写入一个页面的所有字节应该是很简单的。问题是,向内存写入的指令(以及从内存中读取的指令)使用的是虚拟地址。没有办法写一段代码来表示 "在物理地址0x1234处写0",所以页面必须被映射到内存的某个地方,以便被清零。另一方面,Free链表中的页面在设计上没有被映射到任何虚拟地址:这是使它准备被使用的原因。因此,VMM必须把它映射到一个临时的虚拟地址,然后用零来填充它。

许多现有的Windows文献指出,VMM将页面映射到hyperspace区域以完成这一任务,然而这在x64 Windows 7中实际上是不正确的。

看一下代码就会发现,MmAccessFault调用MiZeroPhysicalPage来完成工作,而这个函数又在系统PTE范围中映射页面(0xFFFFF880'00000000 - 0xFFFFF900'00000000),后面我们会分析细节。现在,我们只想说,如果我们在不同的进程中看这个范围的PML4E,我们看到它指向同一个PDPT,因此这个范围是所有地址空间共享的,而不是进程私有的,就像真正的Hyperspace一样,它的范围是0xFFFFF700'000000-0xFFFFF780'000000。

由于现有的文献中没有提到这种行为,它可能是由Windows 7引入的。

无论如何,页面被映射在像Hyperspace这样的进程私有区域或共享区域中并不重要:映射是暂时的,只是为了完成清零的目的。

一个值得问的问题是,既然我们要把页面映射到进程地址空间,为什么还要费尽心思把它映射到一个临时的VA。可以想象,VMM可以把它映射到错误地址上,它一定会在那里结束,并在从错误中返回之前用零填充它。

这种方法的问题是,一旦页面被映射到其最终的用户模式VA上,发生错误的进程中的其他线程就可以访问它。因此会有一个窗口期,这些线程可以看到页面的脏内容。

这是不可接受的:根据设计,Windows必须确保一个进程不能从另一个进程那里得到一个留有数据的页面,以满足C2的安全要求。这就是为什么我们的错误是通过映射一个充满零的页面而不仅仅是一个脏的页面来解决的,而且VMM在使其对进程可见之前确保该页面已经被清零了,因此需要一个临时的系统VA

这种Free页面的状态转换在下图中得到了强调:

Untitled 15.png

从Standby链表分配页面

在Standby链表中的一个页面已经从某个工作集中被移除,并且指向它的PTEP位是清零的。该页仍然存储着它被删除的地址空间的有效内容,所以它必须被填上零,就像前面的情况。

然而,在这种情况下,还有一层考虑:映射该页的PTE仍然指向它(它存储了该页的PFN),并且被设置为当该页的PF发生时,VMM知道它必须从Standby链表中恢复该页(见关于Standby状态的定义)。在重新使用该页之前,VMM必须明确地更新这个PTE,以便从现在开始,对它的引用将被视为一个硬错误,从分页文件中重新加载其内容(记住,根据设计,一个Standby页已经被写入分页文件)。

这里有一个问题:一般来说,这个PTE可能属于任何一个存在的地址空间,不一定是active的地址空间,VMM正在为其解决错误。VMM可以访问PTE的分页结构区域,但不能保证我们要更新的那个PTE就在其中。需要记住的是,分页结构区域的PML4 entry对每个地址空间都是不同的(它指向PML4本身)。

所以VMM面临着一个熟悉的问题:它需要将PTE映射到某个虚拟地址上,以便更新它,但这次它不能依靠分页结构区域。解决方案是将存储页表的物理页映射到一个工作虚拟地址。一些资料指出,该页被映射到Hyperspace,然而,对于x64 Windows来说,这并不完全正确,在那里的行为类似于空闲页清零时的行为。VMM调用MiMapPagelnHyperSpaceWorker,它将页面映射到0xFFFFF880'000000-0xFFFFF900'000000区域,而不是Hyperspace。无论如何,重要的是PT在一个工作虚拟地址上是可见的。下面是这种情况下的典型调用堆栈,程序中发生了一个页面错误,MiMapPagelnHyperSpaceWorker被调用:

0: kd> kn
 # Child-SP          RetAddr               Call Site
00 fffff880`049d92f8 fffff800`044bdf3b     nt!MiMapPageInHyperSpaceWorker
01 fffff880`049d9300 fffff800`04522e69     nt!MiRestoreTransitionPte+0x7b
02 fffff880`049d9390 fffff800`044e4004     nt!MiRemoveLowestPriorityStandbyPage+0x1d5
03 fffff880`049d9410 fffff800`044d376e     nt!MmAccessFault+0x19e4
04 fffff880`049d9570 fffff800`044f1d68     nt!KiPageFault+0x16e
05 fffff880`049d9700 fffff800`0493e1ef     nt!MmProbeAndLockPages+0x118
06 fffff880`049d9810 fffff880`05540ad4     nt!MmRotatePhysicalView+0x32f
07 fffff880`049d9a50 fffff880`05526b43     dxgmms1!VIDMM_PROCESS_HEAP::Rotate+0x2d0
08 fffff880`049d9ae0 fffff880`05526046     dxgmms1!VIDMM_GLOBAL::SetupPrimaryCpuAccess+0x173
09 fffff880`049d9b70 fffff880`0550caa7     dxgmms1!VIDMM_GLOBAL::OpenOneAllocation+0x376
0a fffff880`049d9c50 fffff880`05458469     dxgmms1!VidMmOpenAllocation+0xeb
0b fffff880`049d9ca0 fffff880`05451610     dxgkrnl!DXGDEVICE::CreateVidMmAllocations<_DXGK_ALLOCATIONINFO>+0x291
0c fffff880`049d9d30 fffff880`054532ef     dxgkrnl!DXGDEVICE::CreateAllocation+0xca8
0d fffff880`049da330 fffff880`054599e0     dxgkrnl!DXGDEVICE::CreateStandardAllocation+0x367
0e fffff880`049da490 fffff960`0077682d     dxgkrnl!DxgkCddEnable+0x904
0f fffff880`049da7e0 fffff960`00775ce1     cdd!CreateAndEnableDevice+0x1e1
10 fffff880`049da870 fffff800`04772cce     cdd!PresentWorkerThread+0x975
11 fffff880`049dac00 fffff800`044c6fe6     nt!PspSystemThreadStartup+0x5a
12 fffff880`049dac40 00000000`00000000     nt!KiStartSystemThread+0x16

VMM如何找到PT物理页呢?答案是:_MMPFN.u4.PteFrame存储的是PT页的PFN。值得注意的是,我们在这里涉及到两个物理页:我们想要重新使用的Standby页和存储PT的页。standby页的_MMPFN实例的u4.PteFrame设置为PTPFN。有了这些信息,VMM可以使PT在工作VA上暂时可见,但随后它必须去找具体的PTE,它在页面的4 kB范围内的某个地方。Standby页的另一个字段_MMPFN来救急了_MMPFN.PteAddress,它存储了映射该页的PTE的虚拟地址。诚然,这个VA只有在拥有PTE的地址空间是当前的时候才有效,也就是说,当PT在分页结构区域中被映射时,但VMM只需要页面内的VA偏移量,这在两种映射中是一样的。

这使VMM能够更新PTE并在其中存储足够的信息,以便在需要时能够从分页文件中检索页面内容。因此,在这个阶段,VMM必须知道页面内容在分页文件中的位置,而且它确实知道,因为它被记录在_MMPFN.OriginalPte中。实际上这个成员存储了整个PTE的内容,当页面被重用时将被写入内存中的PTE,所以这就是此时发生的事情。这实际上可以在MiRestoreTransitionPte+0x98观察到:_MMPFN.OriginalPte被加载到rax中,该地址又被保存在[rcx]中。

Untitled 16.png

通过上述步骤,VMM已经使该页可以重新使用,可以将其填充为零,并使用它来解决当前页面错误。

下图中的粗箭头代表了Standby页的这种转换。请注意与虚线箭头的区别,虚线箭头表示当Standby页被返回到它先前被移除的地址空间时发生的转换。这种转换是一种软错误,页面内容仍然有效,它只是被返回到工作集。我们刚才分析的转换是针对正在被重用的页面。总之,这两种转换是完全不同的,虽然就页面状态而言,它们遵循相同的路径。

Untitled 17.png

我们用repurposing这个术语来表示这种转换。

页面优先级

到目前为止,我们一直假设只有一个单一的Standby链表,但事实并非如此。实际上有八个,每个都是以不同的页面优先级分组的。一个页面的优先级是分配给它的一个从0到7的数字;有一个优先级为0的页面的Standby链表,另一个优先级为1的页面,等等。

优先级用于决定哪些页面要首先从Standby链表中删除:VMM首先看优先级为0的列表,然后,如果列表是空的,则看优先级为1的列表,以此类推,因此,优先级较高的页面最后被重新使用。毫不奇怪,优先级被存储在_MMPFN_MMPFN.u3.e1.Priority中。

但是优先级值是如何分配给页面的呢?每个线程都有一个页面优先级属性,因此,当它发生页面错误并为它分配一个物理页面时,页面优先级被设置为该线程的页面优先级。一个线程的页面优先级被存储在_ETHREAD.ThreadPagePriority中,其值来自于进程的页面优先级。因此,给进程分配一个较高的内存优先级的效果是增加其地址空间页在物理内存中被保留的机会:即使其工作集可能被削减,只要这些页仍然在Standby链表中,进程在访问它们时只会产生一个软错误。

进程在创建时通常将其页面优先级设置为5,从Windows 7开始,没有文档化的API来改变它,所以这只是系统使用的一个功能。然而,在撰写本文时,MSDN的在线版本列出了两个具有这种能力的新API。SetProcessInformationSetThreadlnformation;它们在目前被称为 "Windows Developer Preview "的Windows 8预览版中可用。

9.5 更新页面_MMPFN

在获得物理页以解决错误后,VMM会更新相应的_MMPFN的各种成员。大部分工作是在一个名为MilnitializePfn的函数中完成的。下面是最相关的成员的列表:

OriginalPte: 设置为PTE的当前内容。在这个阶段,PTE存储了来自VAD的保护,所以这就是被复制到OriginalPte中的内容。 例如,对于一个读/写页面,OriginalPte被设置为0x80。

u2.ShareCount:设置为1。这是共享该页的地址空间的计数,所以对于私有页来说,它被简单地设置为1。如果该页后来从工作集中删除(例如因为修剪),这个计数器会被递减。

u3.ReferenceCount:设置为1。这个计数器被VMM用来跟踪一个页面何时可以被移到ModifiedStandby链表中,我们将在后面继续讨论它。

u4.PteFrame: 设置为包含PTE的页表的PFN,它将映射该页。

PteAddress:设置为将要映射该页的PTE的虚拟地址。这个地址位于分页结构区域内,只有在当前地址空间处于Active状态时才有效。

u3.e1.PageLocation:设置为_MMLISTS.ActiveAndValid

u3.e1.Priority:设置为当前线程的_ETHREAD.ThreadPagePriority

u3.e1.Modified: 设置为1。后面会讲这个字段的作用。

在这个阶段,VMM还将_MMPFN.u3.e1.CacheAttribute设置为从存储在PTE_MMPTE.u.Soft.Protection)中的保护值计算出来的值,但是我们将在后面分析这个问题,也就是在讲PTE的缓存控制位之后。

在这个阶段,VMM也会在该页的页表相对应的实例中增加_MMPFN.u2.ShareCount的值。一个页表的共享计数是它所包含的有效PTE的数量加1,只要这个计数大于0,页表就不会被移到ModifiedStandby链表中。

9.6 设置PFN以及PTE中的控制位

随着_MMPFN的准备就绪,VMM转向PTE。在这个阶段,PTE的_MMPTE.u.Soft.Protection已经被相应的设置为预定和提交内存时指定的保护值,而其他PTE位仍被设置为0。

PTE被设置为一个有效的PTE,即P位被设置,所以我们现在将以_MMPTE.u.Hard布局来指代这块内容,这与处理器的布局是一致的

9.6.1 基本的PTE设置

_MMPTE.u.Hard.PageFrameNumber被设置为物理页的PFN。值得注意的是,这个字段是36位宽。目前,x64架构将PFN的最大宽度定义为40位。由于PFN是物理地址右移12位,这允许物理地址高达52位,跨越0-51位,并给出最大2PB的物理内存。然而,从Windows 7开始,VMM代码将PFN限制为36位,导致理论上最大的48位或256 Terabyte的内存。

PTE的第0-11位控制页面保护(只读,读/写,等等)和它的缓存策略。它们最初被设置为从一个名为MmProtectToPteMask的静态数组中读取的值,该数组的一项为8字节。MmAccessFault使用当前的_MMPTE.u.Soft.Protection作为数组的索引,然后取到的值被赋值到工作变量中,该变量会最终写到PTE里。这个数组将逻辑保护映射到实际处理器架构的相应位值上。

如果我们将工作变量命名为NewPagePte,类型为_MMPTE,我们可以将上述步骤正式表述为:。

NewPagePte = (_MMPTE)nt!MmProtectToPteMask[protection];

NewPagePte.u.Hard.PageFrameNumber = PFN;

接着,NewPagePte会进一步更新:

NewPagePte.u.Hard.Valid = 1; // P = 1 - maks this a valid PTE
NewPagePte.u.Hard.Owner = 1; // U/S = 1 - allows ring 3 access
NewPagePte.u.Hard.Access = 1;

9.6.2 可写页面的设置

VMM为可写页做了一点额外的工作。考虑一下保护值0x4,它对应于一个读/写页。相应的MmProtectToPteMask元素是

1: kd> dq nt!MmProtectToPteMask + 4*8 l1
fffff800`046fb5d0  80000000`00000800

这个掩码的第1位被设置为0,处理器看这个位来决定这个页面是否可写。值为0意味着该页是只读的。然而,这个掩码的第11位被设置为1,这是处理器忽略的有效PTE的少数位之一,那么操作系统就可以使用。似乎VMM使用这个位来记录一个页面是可写的。MmAccessFault检查这个位,当它被设置时,更新NewPagePte,如下所示:

  • 设置NewPagePte.u.Hard.Dirty1=1。Dirty1实际上只有1位。这是处理器定义的可写位,实际上就是使页面可写的一个位。
  • 设置NewPagePte.u.Hard.Dirty=1。这是第6位,处理器的脏位。

但是,为什么VMM要经历这些麻烦,而不是简单地使用处理器位?这与VMM如何向分页文件分配空间有关;当我们讲到内容已被写入分页文件的页面时,我们就能理解其中的原因,所以我们将在后面再讨论这个问题。

现在,值得注意的是,一个可写页的PTE在处理器dirty 位已经设置的情况下是有效的。无论该页是否被写入,VMM都认为它是一个脏页。如果我们认为,从VMM的角度来看,当一个页面与它在分页文件中的副本不同,或者在分页文件中不存在它的副本时,这就有意义了。我们的新页面就属于后一种情况。

假设VMM在某个时候决定重新使用这个物理页。即使该页从未被写入,也就是填满了零,它也不得不把它的内容保存到分页文件中。这样,当虚拟页再次被访问时,它将被读回内存中,地址空间将重新获得初始化为零的页面。因此,就VMM而言,当一个页面可以重复使用而不必费心将其内容保存到分页文件中时,即在文件中已经存在一个相同的副本时,它就是 "干净的"。如果不是这种情况,VMM会设置Dirty位,以记录它还没有保存页面内容。

我们在前面讲过,_MMPFN.u3.e1.Modified也被设置了。这个位似乎是为了记录_MMPFN的页面是脏的,这在开始时可能是多余的。然而,我们必须记住,只有当前活动地址空间的PTEs在分页结构区域被映射。这个额外的位允许VMM通过查看它的_MMPFN来知道一个页面是否是脏的,_MMPFN总是可以访问的。

一个有趣的行为

这一节详细介绍了一个可以观察到的与上一节中概述的逻辑有关的异常。这实际上是一个实现细节,所以可能只有更好奇的读者才会感兴趣。

从观察到的行为来看,似乎在某些情况下,VMM实际上避免了整个页面内容为0页的分页。

这可以通过一个测试程序来观察,程序执行以下操作:

  • 申请和提交一块相当大的内存块,比方说300MB
  • 读取每个页面的第一个字节,从而导致VA被映射到物理页面上
  • 这样调用一个API:SetProcessWorkingSetSize(GetCurrentProcess(),-1,-1);

这个调用告诉内存管理器,进程愿意释放它的所有工作集。在调用之后,VA范围仍然被预定和提交,但是VA不再被映射到物理页上:物理页已经被转移到不同的状态,PTEsP位被清除。

根据前几节所述的逻辑,当分配范围的物理页被释放时,它们被标记为脏页。因此,它们应该进入Modifed链表,最终,它们的内容(除了4096个零之外什么都没有)将被写入分页文件。

然而,当执行这个实验时,我们观察到一个不同的行为:范围的初始部分实际上是这样处理的:页面进入Modifed状态,PTEs尽管无效却仍然 "指向 "它们。

这个实验的复现难点是虚拟机内存分配不要太高,我成功时的虚拟机内存是1GB,试过512MB,输出的发现是分页文件,试过8GB,输出的是起始地址被释放了。

下面是!pte命令对该范围的第一页的输出:(虚拟机内存给1GB)

0: kd> !process 0 0 MemTests.exe
PROCESS fffffa800ed2a060
    SessionId: 1  Cid: 09d0    Peb: 7fffffdf000  ParentCid: 0a88
    DirBase: 2925b000  ObjectTable: fffff8a00193dc30  HandleCount:  11.
    Image: MemTests.exe

0: kd> .process /i fffffa800ed2a060
You need to continue execution (press 'g' <enter>) for the context
to be switched. When the debugger breaks in again, you will be in
the new process context.
0: kd> g
Break instruction exception - code 80000003 (first chance)
nt!RtlpBreakWithStatusInstruction:
fffff800`044ce490 cc              int     3
0: kd> !pte 0x550000
                                           VA 0000000000550000
PXE at FFFFF6FB7DBED000    PPE at FFFFF6FB7DA00000    PDE at FFFFF6FB40000010    PTE at FFFFF68000002A80
contains 02C0000028DD1867  contains 0120000029BD8867  contains 0AA0000028042867  contains A7C00000236A3886
pfn 28dd1     ---DA--UWEV  pfn 29bd8     ---DA--UWEV  pfn 28042     ---DA--UWEV  not valid
                                                                                  Transition: 236a3
                                                                                  Protect: 4 - ReadWrite

Transition描述意味着PTE指向一个页面,该页面在Modified链表或Standby链表上(这些链表统称为Transition链表)。0x236a3是物理页的PFN,在原来的PTE内容中也可以看到。我们可以用!pfn扩展命令查看物理页的状态:

0: kd> !pfn 236a3
    PFN 000236A3 at address FFFFFA80006A3E90
    flink       00028CA0  blink / share count 000232A4  pteaddress FFFFF68000002A80
    reference count 0000    used entry count  0000      Cached    color 0   Priority 5
    restore pte 00000080  containing page 028042  Modified   M
    Modified

输出结果清楚地表明,该页处于Modified状态。我们还可以看到PTEVA被列为 "pteaddress",与!pte命令中的输出一致。另外,被列为 "containg page "的数字是包含PTE的页表的PFN。看一下来自!pte命令的PDE数据可以证实这一点:PDE里面的PFN等于cotaining page

到目前为止,情况还不错:页面处于Modified状态,一片秩序井然。然而起始地址+0x12B00000却是另外一个故事:

0: kd> !pte 0x550000 + 12B00000
                                           VA 0000000013050000
PXE at FFFFF6FB7DBED000    PPE at FFFFF6FB7DA00000    PDE at FFFFF6FB400004C0    PTE at FFFFF68000098280
contains 02C0000028DD1867  contains 0120000029BD8867  contains 005000002A97E867  contains 0000000000000080
pfn 28dd1     ---DA--UWEV  pfn 29bd8     ---DA--UWEV  pfn 2a97e     ---DA--UWEV  not valid
                                                                                  DemandZero
                                                                                  Protect: 4 - ReadWrite

PTE被简单地设置为0x80,即一个从未被访问过的读/写页的通常保护掩码。这意味着VMM实际上释放了物理页,而不是把它当作一个Modified页。这可能是一种性能优化:PTE没有在分页文件中保存全0的页,而是简单地设置为下一次对其VA范围的访问将触发一个错误,这将用一个全新的全0页来解决。看来,所有剩余的内存范围都是这样处理的。

如果我们用一个更小的范围(例如1MB)进行同样的测试,我们会发现该范围内的所有页面实际上都被释放了。

有趣的是,为了完成这个任务,VMM必须检查页面内容,以发现它是由零填充的。VMM没有办法 "知道 "该页面是否被修改过。这一点可以从以下事实中得到证实:

  • 当页面第一次被访问时,PTE被设置为读/写访问,并设置了dirty位。根本就没有其他的处理器功能来记录页面是否被写入--这本来是Dirty位的工作。
  • 如果我们改变测试程序,使其写入内存范围的所有页面,而不是从这些页面中读取,我们发现
    1. 当我们写入非零值时,物理页会被修改;这是必须的,否则我们会丢失写入的数据。
    2. 当我们写零时,其行为与我们从内存中只读时相同:大部分页面被释放。
  • 如果我们再次进行读取测试,并且在释放工作集之前,用内核调试器修改一个页面的字节,当工作集缩小时,该页面就变成Modified。

从这些事实中,我们可以得出结论,VMM是根据页面的实际内容来决定是否释放这些页面的。

9.6.3 缓存控制位

PTE的缓存控制位(PATPCDPWT)被设置为从MmProtectToPteMask读取的值。为了完全理解它们的设置,我们必须了解PAT是如何在Windows编程中使用的。

PATMSR 0x277,可以用WinDbgrdmsr命令在调试器中转储,它的值是0x0007010600070106。下表显示了每个PAT项的内存类型

0: kd> rdmsr 0x277
msr[277] = 00070106`00070106

Untitled 18.png

注意:,至少在VmWare player中,你不能在虚拟机中看到实际的PAT,它似乎被设置为0。 相反,可以使用本地内核调试会话,这需要在启动Windows时启用内核调试支持。这里作者说VmWare Player好像看不到PAT,但是VmWare 16 Pro测试是能看到的,可能这就是Pro的魅力吧。

如果我们把MmProtectToPteMask针对_MMPTE.u.Soft.Protection的所有32个可能值的转储出来。我们注意到PAT,PCD和PWT是由保护的第3,4位控制的,如下表所示:

Untitled 19.png

Untitled 20.png

请注意,保护值的第4:3位设置为00和10,具有相同的缓存设置。后面我们讨论保护页时进行说明原因。

当我们用VirtualAllocEx预定/提交内存时,我们可以用PAGE_NOCACHE(0x200)的值或上 IProtect参数来指定未缓存内存。如果我们看一下产生的Protection,我们可以看到这具有将位4:3设置为0y01的效果,从上表来看,这导致了UC-内存类型。PAGE_WRITECOMBINE的值在与 IProtect 或上时将它们设置为0y11,从而得到WC内存。如果我们不指定这两个值中的任何一个,第4:3位被设置为0y00,得到WB内存。

产生的保护也被MilnitializePfn用来计算_MMPFN.u3.e1.CacheAttribute

9.6.4 关于PTE的最后思考

到目前为止计算出的PTE值在这个阶段不会被写入处理器使用的实际PTE中。它被保存在一个工作变量中,而PTE仍然被设置为 "不存在"。当PTE有效时,物理页可以在任何时候被进程的其他线程访问,所以VMM对该页没有控制权。由于这个原因,更新PTE是作为最后一步进行的,现在,我们仍然有一些工作要做。

9.7 _MMPFN中的缓存控制位以及关于TLB刷新的探究

本节这是对前面提到的_MMPFN的补充。

VMM在_MMPFN.u3.e1.CahceAttribute里记录了物理页的缓存策略。

VMM更新即将被映射的页面的_MMPFN时,MilnitializePfn从保护的第3-4位计算出CacheAttribute的新值。下表显示了这些保护位、产生的PTE位、内存类型和_MMPFN.u3.e1.CacheAttribute之间的关系:

Untitled 21.png

CacheAttribute用于将一个物理页临时映射到一个函数的工作虚拟地址,如MiZeroPhysicalPageMiMapPagelnHyperSpaceWorker。当一个当前没有被映射的物理页必须在某个虚拟地址可见时,它们被调用,以访问其内容。例如,当一个页面必须被清零或者一个非当前地址空间的PTE必须被更新时,就会发生这种情况。在这些情况下,VMM看不到指定缓存策略的PTE,所以它根据_MMPFN.u3.e1.CacheAttribute的当前值为工作VAPTE设置PAT,PCDPWT。这样做可能是为了确保该页面的映射与其他可能存在的映射所使用的缓存策略相同。这实际上是英特尔的一个建议:应该避免对同一个物理页面使用不同的缓存策略进行多重映射(Intel第3A卷,第11.12.4节 Programming the PAT,第11-51页),并且在WDK文档中进一步证实了这一点。

Processor translation buffers cache virtual to physical address translations. These translation buffers allow many virtual addresses to map a single physical address. However, only one caching behavior is allowed for any given physical address translation. Therefore, if a driver maps two different virtual address ranges to the same physical address, it must ensure that it specifies the same caching behavior for both. Otherwise, the processor behavior is undefined with unpredictable system results.

这是在关于MEMORY_CACHING_TYPE的Remark里提到的。

但是,等等:我们谈论的是没有映射的页面,那么我们为什么要讲这么多呢?事实是,有证据表明,VMM可能会使用旧的TLB项来映射该页。

这个证据来自对MilnitializePfn的分析,它在计算了_MMPFN.u3.e1.CacheAttribute后,将其与存储在_MMPFN中的前一个值进行比较。如果这两个值不一样,它就会调用一个名为KeFlushTb的函数,其输入参数指定要刷新系统中所有处理器上的所有TLB项。

这证明了VMM使用了某种形式的懒惰TLB无效化的事实。

首先,TLB失效的事实本身就意味着,在这个阶段,可能有缓存的分页结构项指向这个页面。然而,这个页面目前并没有被映射到任何地址空间中,因为它来自于Zeroed、Free或Standby列表(MilnitializePfn被调用来初始化来自这些链表的页面)。这一点可以通过以下事实得到证实:此时没有PTEs被更新;只有TLBs被刷新,所以没有PTEs指向该页,但可能仍有缓存的TLB项存在。

可能是由于我们已经提到的Intel的建议,TLB必须被刷新。在这里,VMM检测到CacheAttribute的值在变化,所以它知道它将要映射的页面的缓存控制位(PAT,PCD,PWT)的值与上次映射页面时使用的不同。如果TLB项中的PAT、PCD、PWT的值是旧的,将违反英特尔的建议,所以TLB必须刷新。

延迟TLB的失效有一些影响,然而我们现在不打算讨论这些影响,我们还是继续关注页面错误是怎么解决的这一问题。

在继续进行页面错误分析之前,值得注意的是,_MMPFN.u3.e1.CacheAttribute的值和产生的缓存策略与MEMORY_CACHING_TYPE的声明完全一致。下表显示了由MEMORY_CACHING_TYPE定义的符号名称应用于CacheAttribute的值和产生的缓存策略:

Untitled 22.png

Untitled 23.png

9.8 更新工作集和PTE

到目前为止,我们已经看到VMM如何找到一个物理页来解决错误,以及如何更新_MMPFNPTE内容的工作副本。解决错误的最后步骤是:更新工作集数据结构和更新内存中的PTE。

为了更新工作集,VMM调用MiAllocateWsle,它获得一个空闲的WSL项,并将被映射的虚拟页号存储到_MMWSLE.u1.e1.VirtualPageNumber中。它还设置_MMWSLE.u1.e1.Valid = 1,以标记该项正在使用。对于私有内存,_MMWSLE.u1.e1.Direct 还会被设置为1。

在这个阶段,物理页的_MMPFN被进一步更新,将_MMPFN.u1.WsIndex设置为WSL项的索引,使得从_MMPFN中获得该项成为可能,但有一点我们不应该忘记:WSL区域在hyperspace中,这是一个进程私有空间区域。由_MMPFN.ul.WsIndex指出的特定项。只有在使用该页的地址空间是当前地址空间时才能被访问。PFN数据库在一个系统共享区域,可以从每个地址空间访问,因此Wslndex总是被映射到这里供我们查看,但必须映射正确的WSL。这与VMM需要更新一个PTE时的情况类似。

根据在_MMPFN中的WSL项索引,我们可以从一个映射的VA找到它的WSL项。

实际上是MiAllocateWsle更新了PTE并 "完成 "了虚拟到物理的映射。在前面的步骤中计算出的PTE的工作副本首先通过设置_MMPTE.Hard.SoftwareWslndex到WSL项索引的0-10位(这些位是被处理器忽略的),然后复制到PTE中。因此,PTE存储了他的工作集项的部分(即截断的)反向引用,但它不是完整的索引,它只有32位宽。

最后,_MMSUPPORT.WorkingSetMutex处的推锁被释放,MmAccessFault返回到KiPageFault,它从堆栈上的_KTRAP_FRAME恢复了线程执行上下文,从而恢复错误指令的执行。

9.9 Guard页面

9.9.1 概念

Guard页是用一个特殊的保护掩码分配的,作为虚拟内存区域的一个一次性警报。当保护页内的地址第一次被访问时,VMM会引发一个代码为STATUS_GUARD_PAGE_VIOLATION的异常。访问该页的代码可以用一个try/except块来处理这个异常,并继续执行。随后对同一个guard页的访问就不会再引起异常,并被视为对普通页面的访问,也就是说,如果它们与该页的常规保护一致,就能成功访问。

Guard页是为了给代码提供机会,使其在第一次被访问采取不同的行动方案。一个典型的应用是为一个动态增长的数据区域扩展一个已提交的内存区域。在这种情况下,代码保留了一个与数据区最大尺寸相对应的内存范围。记住,这与预定的虚拟内存影响不大,因为它没有被计入全局提交的内存计数中。之后,它在范围的开始部分提交尽可能少的页面(也许只有一个),并将下一个页面标记为guard页面。当常规的提交区域被耗尽,数据区域到达guard页时,代码会通过异常通知,这样代码就有机会扩展可访问的区域,并为下一次扩展设置新的guard页。

通过将VirtualAllocEx(或其他类似函数)的flProtect参数设置为所需的保护(例如PAGE_READWRITE)再或上PAGE_GUARD就能分配一个保护页。对该页的第一次访问将引发一次性异常,之后的任何访问,指定的保护将生效。例如变为可写可读了,但是没有保护属性了。

一个页面想成为一个Guard页面就必须要提交,:如果页面只是预定,当试图访问时,VMM会引发STATUS_ACCESS_VIOLATION异常,而页面仍然无效(也就是说,如果异常被捕获后重新尝试访问,同样的异常将被再次引发)。

9.9.3 实现

Guard页面的处理在MiAccessCheck中实现,它被MmAccessFault调用。VMM设置_MMPTE.u.Soft.Protection为=0y10。MiAccessCheck检测到这个值时,会将第4位置0,并返回
STATUS_GUARD_PAGE_VIOLATION,这会在用户模式代码中引发异常。由于MiAccessCheck清除了第4位,对该页的下一次访问将成功。

我们看到保护的第4:3位被用来编码页面的缓存策略。需要指出的时,保护(4:3)和内存类型之间的关系是:

Untitled 24.png

这意味着一个Guard页面只能有WB类型:当第4位被清除时。保护(4:3)变成0y00,该页是一个 "常规 "的WB页。

这可以通过在调用VirtualAllocEx时指定PAGE_GUARDPAGE_NOCACHEPAGE_WRITECOMBINE来确认:该函数会返回ERROR_INVALID_PARAMETER表示分配失败。

9.9.4 用户栈的特殊处理

这需要VMM进行一些特殊的处理,如果不进行特殊处理用户模式的代码会在rsp到达guard页时看到STATUS_GUARD_PAGE_VIOLATION异常出现,例如在调用函数时。可以看到,当MiAccessCheckMmAccessFault返回STATUS_GUARD_PAGE_VIOLATION时,后者会调用MiCheckForUserStackOverflow,当错误访问再线程的用户模式堆栈里时,它会建立一个新的guard页,并将返回代码改为STATUS_PAGE_FAULT_GUARD_PAGE。这成为从MmAccessFault返回到KiPageFault的值。

STATUS_PAGE_FAULT_GUARD_PAGE的数值为0x113,这意味着它不代表一个错误(NTSTATUS的错误位31被设置,所以>=0x80000000才是错误)。另一方面,,即非堆栈情况下返回的数值是0x80000001,即STATUS_GUARD_PAGE_VIOLATION,代表一个错误情况。

对于非堆栈的情况,错误值导致在用户模式代码中引发一个异常,异常代码被设置为错误代码。

对于堆栈的情况,来自MmAccessFault的成功代码会使KiPageFault走重新尝试错误指令,即让用户态再次执行访问堆栈的指令。在这个阶段,堆栈页的PTE已经被更新,所以Protection(4:3)= 0y00,即guard状态已经被关闭。这是在调用MiCheckForUserStackOverflow之前由VMM完成的。因此,恢复执行的指令会引起了一个新的页面错误,因为此时PTE仍然是无效的,但现在Protection被设置为读/写,所以现在要解决的是一个正常的demand-zero错误,为堆栈映射一个新的物理页面。该错误指令再一次被恢复时,他就能成功执行了。

9.10 总结和后续

我们已经看到了一个用户模式的demand-zero错误是如何通过将一个页面映射到地址空间来解决的。VMM首先确保内存访问是有效的,然后选择一个物理页,更新其数据结构(PFN db和工作集),最后映射该页。

从现在开始,该页对进程代码来说是可用的,它可以自由地对其进行读写。另外,该页一旦被映射就被认为是脏的,因为在分页文件中不存在它的副本。这意味着页面生命中的下一个逻辑状态是Modified状态:当VMM决定缩小工作集时,页面被移到Modified链表中,以便它的内容被保存在分页文件中。

除了刚才提到的那个,还有其他可能的转换:该页可能被进程释放,或者进程可能终止时释放它。我们将分析向Modified状态的转换,因为它更有趣:当VMM会在进程的 "背后 "将一部分地址空间分页出来。

然而,在这之前,下一章将分析与延迟TLB无效化有关的一些影响。

免费评分

参与人数 22吾爱币 +17 热心值 +21 收起 理由
junjia215 + 1 + 1 我很赞同!
tianjifuyun + 1 我很赞同!
jackies + 1 + 1 热心回复!
afhack + 1 + 1 谢谢@Thanks!
hqsudy + 1 + 1 谢谢@Thanks!
MatrixLau + 1 + 1 谢谢@Thanks!
tracyshenzl + 1 + 1 谢谢@Thanks!
xiaolong7645 + 1 + 1 我很赞同!
Chenda1 + 1 + 1 好长,感谢分享
poisonbcat + 1 + 1 谢谢@Thanks!
fengbolee + 2 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
t20220313 + 1 + 1 我很赞同!
ayaoko + 1 + 1 谢谢@Thanks!
gh0st_ + 1 + 1 用心讨论,共获提升!
yxpp + 1 + 1 谢谢@Thanks!
suiyueshentou + 1 我很赞同!
jackie102 + 1 用心讨论,共获提升!
sakura0 + 1 用心讨论,共获提升!
ContacNt + 1 我很赞同!
陈世界 + 1 + 1 我很赞同!
sam喵喵 + 1 谢谢@Thanks!
隔壁老王Orz + 1 谢谢@Thanks!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

 楼主| BeneficialWeb 发表于 2022-3-28 12:57
sam喵喵 发表于 2022-3-28 11:45
厉害,瞧这么多!这是哪本书上的啊

https://bbs.pediy.com/thread-271287.htm
sam喵喵 发表于 2022-4-2 09:33
BeneficialWeb 发表于 2022-3-28 12:57
https://bbs.pediy.com/thread-271287.htm

好书!!
大佬,这个电子书有吗,有的话麻烦发一份我!
sam喵喵 发表于 2022-3-28 11:45
Tony2009 发表于 2022-3-28 11:57
好厉害,都看晕了
gamecd 发表于 2022-3-28 13:18
mark一下~~~潜心学习~~大部分看不懂
DHLORE 发表于 2022-3-28 13:46
厉害,感谢大佬,内容挺多得沉下心慢慢看
wapj3076 发表于 2022-3-28 14:42
太复杂,犹如天书一般看不懂。楼主厉害!
snowsoft 发表于 2022-3-28 16:42
好文章,太长了,收藏待仔细拜读!
chaoge1 发表于 2022-3-28 16:52
太复杂,犹如天书一般看不懂。楼主厉害!
 楼主| BeneficialWeb 发表于 2022-3-28 18:47
chaoge1 发表于 2022-3-28 16:52
太复杂,犹如天书一般看不懂。楼主厉害!

可以先看在看雪的前几章
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - LCG - LSG ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2024-11-21 20:14

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表