【转载】64 位 Windows 内核虚拟地址空间布局(基于 X64 CPU)

原文链接:http://shayi1983.blog.51cto.com/4681835/1734822

本文为原创翻译,原文出处为 http://www.codemachine.com/article_x64kvas.html

对于原文中,较难理解或者论述过于简单的部分,则添加了译注;译注来自于内核调试器验证的结果,以及 WRK 源码中的逻辑,还有《深入解析 Windows 操作系统》一书中的译文。

本文档解释 X64 版本的 Windows 7 与 Server 2008 R2 上,内核虚拟地址空间的细节。调试器扩展命令 !CMKD.kvas 应用这一理论来显示 X64 虚拟地址空间,并且将一个给定的地址映射到其中一个地址范围。

内核虚拟地址布局

X64 CPU 仅支持 64 位虚拟地址中的 48 位,这 48 位虚拟地址被运行在该 CPU 上的软件使用。 对于用户模式地址,64 位虚拟地址中的高 16 位总是被设置为 0x0;对于内核模式地址,总是设置为 0xF。

这有效地将 X64 地址空间分开成2部分——用户模式地址的范围:0x00000000`00000000~0x0000FFFF`FFFFFFFF;内核模式地址的范围:0xFFFF0000`00000000~0xFFFFFFFF`FFFFFFFF。

此内核虚拟地址范围总计为 256 TB,用于 Windows 上可访问的全部内核虚拟地址空间。然后,Windows 静态划分此空间成多个固定大小的虚拟地址范围(VA),每个范围被赋予特定用途。每个范围的起始和结束地址如下表所示:

【转载】64 位 Windows 内核虚拟地址空间布局(基于 X64 CPU)

因此,为了简化处理器芯片架构以及避免非必要的开销——尤其是地址翻译方面(后面会讨论)—— 当前 AMD 和 Intel 的 x64 处理器仅实现了 16 EB 虚拟地址空间中的 256 TB。换言之,一个 64 位的虚拟地址中,仅有低 48 位被实现(使用)。然而,虚拟地址仍旧是 64 位宽,在寄存器中,或存储在内存中,它们都占用 8 字节。虚拟地址中的高 16 位(比特位 48~63)需要被设置成与最高的“实现位”(也就是比特位 47)相同的值,这是通过一种类似于二进制补码运算的符号扩展来完成的。符合这一运算规则的地址被称为“规范”(canonical)地址。

根据这些规则,正如预期的那样,地址空间的下半部分从 0x0000000000000000 开始,但是结束于 0x00007FFFFFFFFFFF。地址空间的上半部分从 0xFFFF800000000000 开始,结束于 0xFFFFFFFFFFFFFFFF。每个“规范的”部分为 128 TB。随着更新的处理器实现/使用更多的地址位,内存中的下半部分将会向上扩展,直到 0x7FFFFFFFFFFFFFFF;而内存中的上半部分则会向下扩展,直到 0x8000000000000000 (与当前的 32 位用户—内核内存空间分割法类似,只是又多出了 32 位)

Windows 使用的某些数据结构例如推锁,Ex 快速引用指针,以及互锁单向链表。。。这些都要求能够对一个虚拟地址中的一些比特位执行其自身位数2倍原子操纵的 CPU 指令。(例如,对于 32 位地址,要求最多能够一次性的原子修改 64 位;对于 64 位地址,要求最多能够一次性的原子修改 128 位;以创建无锁(lock-free)的压栈与弹栈操作)

因此,在虚拟地址为 64 位的 X64 CPU 上,需要一个 128 位的原子指令 CMPXCHG。早期版本的 X64 CPU 并没有这样一条指令,这就对上述数据结构的实现构成障碍。

X64 CPU 已经限制了虚拟地址中可用的比特数为 48 位,Windows 做出了进一步的限制,将其削减为 44 位。因而,能够存储这类数据结构的虚拟地址跨度被限制为 2^44 ,换言之,当前 64 位 Windows 的系统(内核)虚拟地址空间被限制为 8TB,即 0xFFFFF80000000000~0xFFFFFFFFFFFFFFFF。结果就是,像“未使用的系统空间”,“PTE 空间(用于系统页表条目)”,“超空间”,以及“系统缓存工作集”这类超出了 44 位范围限制的虚拟地址区域,就无法存储这些数据结构。此限制也被扩展到用户模式,有效的束了被 Windows 使用的用户模式虚拟地址数量为 8TB,即

0x00000000`00000000~0x000007FF`FFFFFFFF,以及内核模式的 8TB,即 0xFFFFF80000000000~0xFFFFFFFFFFFFFFFF。注意,有些内核虚拟地址区域超出了此一范围,例如 FFFF0800`00000000~FFFFF7FF`FFFFFFFF,虽然 Windows 使用这类区域,但并非作为通用的分配和数据结构存储,如前所述。

Windows x64 16-TB 限制

x64 上的 Windows(即 64 位版本)对 x64 处理器可用(实现)的 256 TB 虚拟地址空间作出了进一步的限制——当前(写作本书时)的 64 位 Windows 仅支持略多于 16 TB 虚拟地址空间的使用。

它被分为两个 8 TB 的区域:用户模式,每进程区域从 0 开始并且朝向更高的地址段增长(结束于 0x000007FFFFFFFFFF);内核模式,系统范围的区域则从“all Fs”(译注:推测作者的意思是指“0xFFFFFFFFFFFFFFFF”)开始并且朝向更低的地址段增长,多数情况下它结束于 0xFFFFF80000000000。本节讨论这个原始的 16 TB 限制。

Windows 已经采用并将持续引入一系列机制,以假设地址中的可用二进制位。推锁,快速引用(fast references),Patchguard DPC 上下文,以及单向链表。。。这些数据结构都是使用一个非寻址用指针中二进制位的常见例子。单向链表的使用,再加上原始 x64 处理器中一条 CPU 指令的缺失(译注:在后文中可以看到这条缺失的指令就是

“CMPXCHG16B”),导致在64 位 Windows上,需要“port”该数据结构(即单向链表),才能让 64 位 Windows 负责此 16 TB 内存寻址限制的实施。

下面是 Windows 用来表示链表内一个条目(一项)的数据结构,即 SLIST_HEADER:

1
2
3
4
5
6
7
8
typedef union _SLIST_HEADER {
  ULONGLONG Alignment;
  struct {
    SLIST_ENTRY Next;        //32 位
    USHORT Depth;        //16 位
    USHORT Sequence;        //16 位
  } DUMMYSTRUCTNAME;
} SLIST_HEADER, *PSLIST_HEADER;

注意,在这个联合(union)中有一个 8 字节大小的结构,以及一个用来保证对齐长度的 8 字节(ULONGLONG 型)变量 Alignment;其中的结构体由三个元素组成:指向下一个条目(下一项)的指针(32 位,或者 4字节),一个 USHORT 型变量 Depth 用来表示深度(16 位,或者 2 字节),以及一个 USHORT 型变量 Sequence 用来表示序列号。为了创建无锁(lock-free)的压栈与弹栈操作,实现利用了在 Pentium 以及更高型号处理器中支持的一条指令——CMPXCHG8B(比较和交换 8 字节),该指令允许原子的修改 8 字节的数据。通过使用这一条原生的 CPU 指令,及其支持的 LOCK (指令)前缀(用来保证在一个多处理器系统上的原子性),一个自旋锁需要结合两个 32 位来访问的规则被取消了,并且链表中的所有操作都变成锁无关的(提高了速度与可扩展性)。

64 位计算机上的地址是 64 位的,因此指向下一个条目的指针(即 DUMMYSTRUCTNAME 结构的 Next 成员)逻辑上应该是 64 位的。如果深度和序列号成员的大小保持不变,那么系统必须提供一种方式来修改至少 64+32 位的数据——如果能够修改 128 位则更好,这是由于,为了增加深度与序列号成员的“熵”(entropy,平均信息量),而分别将这2个成员的大小增大一倍:32 位,所以整个结构体的大小就变为 128 位。然而,首个 x64 处理器并没有实现支持这一操作(原子的修改这 128 位数据)所必要的 CMPXCHG16B 指令。因此, Windows 自身的实现代码就被编写成:把尽可能多的信息封装到仅有的 64 位结构体中(前文中“port”一词的语义),这是让数据最可能被一次性原子地修改的方法(依旧使用 CMPXCHG8B)。封装尽可能多信息的结果导致了 64 位的 SLIST_HEADER 看起来像

下面这个样子:

(译注:注意其中的 NextEntry 指针,在 64 位系统上它应该是 64 位宽,此处却缩短成了 39 位;而有趣的是,每个结构成员依旧被定义成按常理讲应该是 8 字节大小的 ULONGLONG 型)

1
2
3
4
5
struct // 8-byte header
  ULONGLONG Depth:16;
  ULONGLONG Sequence:9;
  ULONGLONG NextEntry:39;
} Header8;

第一个变化是,序列号成员的占用空间从原来的 16 位减少成只有 9 位,从而减小了链表能够实现的最大序列号。仅为 NextEntry 指针留下了 39 位,仍旧与 64 位相去甚远。然而,通过在为此数据结构分配内存空间时,强制其按照 16 字节大小对齐,就可以使用多出来的 4 位,因为最低的 4 位现在总是可以假设为 0 。这就为地址提供了 43 位(即 NextEntry 指针,39+4),但是系统还可以再作出一个假设。

因为链表的实现被用于内核模式或用户模式,但不能同时跨越二者的地址空间,因此可以忽略最高位,就像在 32 位机器上一样。如果函数调用在内核模式下,代码将假设使用的地址是内核模式的,反之亦然。这就允许我们使用 NextEntry 指针寻址最多 44 位的内存,它是 64 位 Windows 寻址限制的决定性约束。

44 位是一个比 32 更好的数字,它允许能够描述 16 TB 的虚拟内存,并由此将 Windows 分割成 2 个 8 TB 的块,分别用于用户与内核模式内存。尽管如此,16 TB 的虚拟内存仍旧只有处理器自身限制(48 位 = 256 TB)的 1/16,并且与 64 位能够描述的虚拟内存上限相比仍旧是九牛一毛。因此,在考虑到可扩展性的情况下,SLIST_HEADER 中确实有一些其它的位,用以定义所处理的头部类型。(译注:我们即将在下面给出的源码中看到,当结构中一个叫做 HeaderType 的二进制位,其值为 0 时,表示 SLIST_HEADER 的大小为 8 字节;其值为 1 时,表示大小为 16 字节,从而可以支持 NextEntry 指针使用 60 位来寻址,在这种情况下,能够支持 1 EB 的虚拟地址空间,计算方法是,每多出 4 位,实际可寻址的内存就为原来的 16 倍,即 2 的 4 次方 )

这意味着,未来某一天当所有的 x64 处理器都支持 128 位的“比较和交换”(即前文提到的 CMPXCHG16B 指令),那时 Windows 就可以很容易地利用这个硬件特性,寻址更大范围的地址空间(同时也意味着,需要预先发布两个不同的内核映像)下面来看看“完整的” 8 字节 SLIST_HEADER 头部:

1
2
3
4
5
6
7
8
9
struct // 8-byte header
  ULONGLONG Depth:16;
  ULONGLONG Sequence:9;
  ULONGLONG NextEntry:39;
  ULONGLONG HeaderType:1;     // 0: 8-byte; 1: 16-byte
  ULONGLONG Init:1;              // 0: uninitialized; 1: initialized
  ULONGLONG Reserved:59;
  ULONGLONG Region:3;
} Header8;

请注意,HeaderType 这个二进制位是如何覆盖 Depth 成员的二进制位(译注:虽然原文如是说,但是从给出的结构体布局还真的看不出来是怎么“覆盖”的),以及无论硬件是否支持,都允许实现代码处理 16 字节头部的。为了完整性起见,下面给出当 HeaderType 为 1 时,使用的 16 字节头部定义(布局):

1
2
3
4
5
6
7
8
struct // 16-byte header
  ULONGLONG Depth:16;
  ULONGLONG Sequence:48;
  ULONGLONG HeaderType:1;     // 0: 8-byte; 1: 16-byte
  ULONGLONG Init:1;               // 0: uninitialized; 1: initialized
  ULONGLONG Reserved:2;
  ULONGLONG NextEntry:60;     // last 4 bits are always 0's
} Header16;

注意到 NextEntry 指针现在变成了 60 位,而且,由于该结构依然是按照 16 字节对齐的,前文提到过这种对齐方式能够多出 4 位来使用,因此这将导致所有的 64 位都可用来寻址。

反之,不涉及 SLISTs (单向链表)的内核模式数据结构,也不会因此被限制在 8 TB 的地址空间范围内。系统页表条目,超空间,以及缓存工作集等,都占用  0xFFFFF80000000000 以下的虚拟地址空间,因为这些结构不使用单向链表。(译注:这里应该是作者笔误,因为根据图 10-13 以及 CodeMachine 站点上的 x64 地址空间布局概要,系统页表条目占用 0xFFFFF8A000000000 以下的虚拟地址空间,还有一个证据就是,去掉前面 5 个 F,即未用于寻址的 20 位,地址 0x8A000000000 约为 9 TB 多,符合作者原意,而地址 0x80000000000 基本就是 8 TB,与之不符 )

内核虚拟地址组成

下面的部分描述内核虚拟地址空间中的每一个虚拟地址区域。

未使用的系统空间/系统地址空间的起始(FFFF0800`00000000~FFFFF67F`FFFFFFFF)

这个区域的起始地址保存在内核全局变量 nt!MmSystemRangeStart 中。此区域在  Windows 7 X64 上是未使用的。

PTE 空间(FFFFF680`00000000~FFFFF6FF`FFFFFFFF)

X64  CPU 上的页面大小为 4K。页表条目(PTEs)被 CPU 用于将虚拟页面映射到物理页面,每个页表条目映射单个 4KB 的页面。X64 CPU 上的 PTE 为 64 位(8字节),这是为了能够容纳大型物理地址或页框号(PFNs)。因此,单个页表的页面(4 KB)仅能存储 512 个 PTE( 4KB / 8B = 512),这些 PTE 总共映射 2MB (512 * 4K)的虚拟地址空间。

其次,既然一个页目录项(PDEs) 指向一个用于页表的页面,那么单个 PDE 就能够映射 2MB 的虚拟地址空间。这个 2MB 的地址跨度就是在上表列出的多数系统虚拟地址区域中的分配粒度。大多数的这些区域分配有与其关联的位图,位图用于在这些区域中执行以 2 MB 的块为倍数的内存分配。(例如,一次分配 4 个 2 MB 的块)。这个任务由内存管理器的内部函数——MiObtainSystemVa()——来执行,它接收定义在系统枚举类型“nt!_MI_SYSTEM_VA_TYPE”中的值,作为要从中分配内存的区域标识符。

因此,MiObtainSystemVa() 的另一个参数——要分配的 PDE 数量——就等同于要分配的“2 MB 块”的个数。

在这个 512 GB 的范围中(译注:FFFFF70000000000 减去 FFFFF68000000000,转换为 10 进制即 549 GB,也就是作者的意思),包含了从虚拟地址翻译成物理地址所需的四级页表映射。此区域包含 X64 处理器使用的四级页表页面,用于用户模式与内核模式虚拟地址空间的映射。各种类型的 X64 页表页面被映射到的起始虚拟地址如下:

存储 PTE  的页面:FFFFF680`00000000

存储 PDE 的页面:FFFFF6FB`40000000

存储 PPE 的页面:FFFFF6FB`7DA00000

存储 PXE 的页面:FFFFF6FB`7DBED000

作为对比,下面是 X86  CPU 使用的二级页表映射,其中,每个 PTE 为 4 字节,因此,单个页表的页面(4 KB)能存储 1024 个 PTE( 4KB / 4B = 1024),这些 PTE(即一个 PDE) 总共映射 4MB (1024 * 4K)的虚拟地址空间。该图援引自《Windows 内核设计思想》一书 PDF 版。

【转载】64 位 Windows 内核虚拟地址空间布局(基于 X64 CPU)

超空间(FFFFF700`00000000~FFFFF77F`FFFFFFFF)

驻留在物理内存中的进程相关页面称为“工作集”,那么显然这些物理页面需要映射到系统虚拟地址空间中的一个特殊区域进行统一管理,此区域称为“超空间”。

进程工作集列表和每进程相关的内存管理数据结构(它们不需要在任意进程的上下文中被访问,例如 _MMWSL 与 _MMWSLE)都映射到这个 512GB 的区域中。

对于每个在进程工作集中的页面(这些页面驻留在物理内存中),在此区域都存在一个对应的 _MMWSL(内存管理器工作集列表)数据结构,它在超空间内的具体位置由EPROCESS.Vm.VmWorkingSetList 指向的地址确定;

还有一个对应的 _MMWSLE(内存管理器工作集列表条目)数据结构也在此区域中,后者的具体位置由 EPROCESS.Vm.VmWorkingSetList.wsle 指向的地址确定。下面以 32 位系统为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dt nt!_EPROCESS  -r 872e1d28 (某个进程的 EPROCESS 结构的虚拟地址)
 
(。。。省略无关输出。。。)
 +0x054 VmWorkingSetList : 0xc0802000 _MMWSL
    +0x000 FirstFree        : 0x5693
    +0x004 FirstDynamic     : 6
    +0x008 LastEntry        : 0x5970
    +0x00c NextSlot         : 6
    +0x010 Wsle             : 0xc0802d08 _MMWSLE
 
dt nt!_EPROCESS  -r
(。。。省略无关输出。。。)
  +0x054 VmWorkingSetList : Ptr32 _MMWSL
     +0x000 FirstFree        : Uint4B
     +0x004 FirstDynamic     : Uint4B
     +0x008 LastEntry        : Uint4B
     +0x00c NextSlot         : Uint4B
     +0x010 Wsle             : Ptr32 _MMWSLE

在上面这个例子中,地址 0xc0802000 在 32 位系统上的超空间区域内,_MMWSL 结构位于此处;而 _MMWSLE 结构则位于其后不远的 0xc0802d08 地址处。

参考下图,基于 x86 体系结构的 32 位 Windows 上,超空间的起始地址为 0xC0400000,而基于 x86  PAE 的 32 位 Windows 上(也就是上面例子的软硬件环境),超空间的起始地址为 0xC0800000;另外,每进程页表被映射到从 0xC0000000 开始的虚拟地址,每进程的页目录(PDE)则被映射到 0xC0300000 开始的虚拟地址(对于 PAE 机器, PDE 则从 0xC0600000 开始)

【转载】64 位 Windows 内核虚拟地址空间布局(基于 X64 CPU)

超空间也用于临时将物理页面(PFN)映射到系统空间。而超空间内的虚拟地址实际上是从系统 PTE 区域中分配的。

其中一个例子就是,除了进程页表中的无效页表条目外,引用的有效页面(例如,当一个页面从备用[standby]列表中被移除时)。

MiMapPageInHyperSpaceWorker() 把一个 PFN 映射到超空间,并返回分配给此映射的虚拟地址。MiMapPageInHyperSpaceWorker() 本应该把驻留在物理内存中的进程页面映射到超空间,但实际上它把这些页面映射到系统 PTE 区域。

MiZeroPhysicalPage(),MiWaitForInPageComplete(),MiCopyHeaderIfResident(),MiRestoreTransitionPte() 等函数,都调用 MiMapPageInHyperSpaceWorker() ,临时获取被映射到超空间内物理页面的虚拟地址。

共享的系统页面(FFFFF780`00000000~FFFFF780`00000FFF)

这个 4KB 的页面在 UVAS(用户虚拟地址空间)与 KVAS(内核虚拟地址空间)之间共享。它提供了一个在用户和内核模式间快速传递信息的方法。与此相关的共享数据结构为

nt!_KUSER_SHARED_DATA

系统缓存工作集(FFFFF780`00001000~FFFFF7FF`FFFFFFFF)

此区域用于映射系统缓存工作集和系统缓存工作集列表条目,系统缓存工作集信息驻留在这个 512GB 减掉 4KB 的范围内。

内核变量 nt!MmSystemCacheWs 指向用于系统缓存的工作集数据结构(例如 nt!_MMSUPPORT)。要显示用于系统缓存的工作集列表条目,

使用命令“!wsle 1 @@(((nt!_MMSUPPORT *) @@(nt!MmSystemCacheWs))->VmWorkingSetList)”。工作集修剪器使用这些条目来从系统缓存虚拟地址修剪对应的物理页面。

注意:以下的地址范围(FFFFF80000000000 开始)被符号扩展为大于 43 位,因而可用于 interlocked slists(直译为“互锁单向链表”);而小于 FFFFF80000000000 的系统地址空间则不能这样使用。

由最初的加载器映射(FFFFF800`00000000~FFFFF87F`FFFFFFFF)

这个 512GB 区域的映射,由加载器初始化。

在系统引导阶段,winload.exe 将 NTOSKRNL.EXE,HAL.DLL,以及内核调试器 DLL(KDCOM, KD1394, KDUSB)加载到此区域。该区域还包含了空闲线程的栈,DPC(延迟过程调用)的栈,以及 KPCR(内核处理器控制区,Kernel Processor Control Region)和 Idle 线程的数据结构。

系统页表条目(FFFFF880`00000000~FFFFF89F`FFFFFFFF)

这个 128GB 的区域仅内核模式下能够访问;

此区域包含映射视图,MDLs(内存描述符列表),适配器内存映射,驱动程序映像,以及内核栈。(当然,还有下文提到的,用于 I/O 映射的虚拟地址)

此区域由位图 nt!MiSystemPteBitmap 描述;内核变量 nt!MiSystemPteBitMapHint 则存储此区域的分配提示。当调用 MiObtainSystemVa() 时传入 MiVaSystemPtes 类型的系统虚拟地址范围时,它会在此区域中分配。

你可以通过在性能监视器中查看“Memory: Free System Page Table Entries”这个计数器的值,来得知当前可用的系统 PTE 数量。本文最后一节“系统页表条目的管理”讨论了更多细节。

分页池区(FFFFF8a0`00000000~FFFFF8bF`FFFFFFFF)

这个 128GB 的区域仅内核模式下能够访问。

内核变量 nt!MmPagedPoolEnd 存储可分页池的当前结束地址(由此可见它是动态增长或缩减的)。同样的,其当前大小存储在内核变量 nt!MmSizeOfPagedPoolInBytes 中。当调用 MiObtainSystemVa() 时传入 MiVaPagedPool 类型的系统虚拟地址范围时,它就会在此区域中分配。内核位图(Bitmap)——nt!MiPagedPoolVaBitMap——控制从可分页池中分配虚拟地址空间的操作,并且,内核变量 nt!MiPagedPoolVaBitMapHint 存储其分配提示(allocation hint)

会话空间(FFFFF900`00000000~FFFFF97F`FFFFFFFF)

会话数据结构,会话池以及会话映像都被加载到这个 512GB 的区域内

会话映像空间包含驱动程序映像,例如 Win32K.sys(实现了窗口管理器), CDD.DLL(规范的显示驱动程序),TSDDD.dll(帧缓存显示驱动程序),DXG.sys(DirectX 图形驱动程序)。。等等

对于任何属于某个会话的进程,其 EPROCESS 结构中的 Session 字段指向一个用于该会话的 MM_SESSION_SPACE 类型结构。会话的可分页池限制由 MM_SESSION_SPACE 的 PagesPoolStart 与 PagesPoolEnd 成员各自指向的地址来共同确定。(即,PagesPoolEnd 指向的结束地址,减去 PagesPoolStart 指向的起始地址)

在 32 位系统上,使用内核调试器命令 lm n ,能够列出上述图形驱动程序被加载到会话空间中,特定区域的起始与结束地址,如下所示:

【转载】64 位 Windows 内核虚拟地址空间布局(基于 X64 CPU)

动态内核虚拟地址空间(FFFFF980`00000000~FFFFFa70`FFFFFFFF)

此区域由系统缓存视图,可分页特殊池,以及非分页特殊池组成。内核变量 nt!MiSystemAvailableVa 存储此区域中,可用于分配的 “2MB 区域”的数量。

此区域的起始地址(FFFFF98000000000)由内核符号常量 MM_SYSTEM_SPACE_START 标识 ;它的大小由内核符号常量 MI_DYNAMIC_KERNEL_VA_BYTES 标识,以字节为单位。

当调用 MiObtainSystemVa() 时传入 MiVaSystemCache, MiVaSpecialPoolPaged, 或者 MiVaSpecialPoolNonPaged 类型的系统虚拟地址范围时,它都会在此区域中分配。

此区域由位图 nt!MiSystemVaBitmap 描述;内核变量 nt!MiSystemVaBitMapHint 则存储此区域的分配提示。

PFN 数据库(FFFFFa80`00000000~*nt!MmNonPagedPoolStart-1)

《背景知识:页面,页框,页框号》

“页”(或“页面”)是线性地址(虚拟地址)空间中的一个连续区域。在 x86(IA-32)体系结构上,一个页的大小可以是 4KB,2MB,或 4MB。通常为 4KB。页面可以驻留在物理内存中,或者磁盘上

“页框”是物理内存中一个具体的存储单元,当页面驻留在 RAM 中时,就可以存储在页框中。

物理内存中一个存储单元的地址可以由页框号(Page Frame Number,PFN)表示。

在页面大小为 4KB,且没有启用 PAE 的情况下,PFN 是一个 20 位的无符号整数值(例如,0x12345),它表示一个 32 位的物理地址,因此,需要假定低 12 位为 0,即页框号 0x12345 表示的物理地址为 0x12345000(即起始地址)。

4KB 大小的页面基于 4KB 边界对齐,这样,一个 PFN 能够识别(覆盖)的地址总是 4096 的倍数。

系统中每一个物理页面在 PFN 数据库中都有一个条目来描述其物理页框号(系统上的物理页面总数为,内核变量 nt!MmHighestPossiblePhysicalPage 的值加1),这是为了让 PFN 条目能适应热插拔内存。(插入或者移除的物理 RAM 条,都会反映在上述内核变量中)

在内核调试器中,以下表达式:

1
? poi(nt!MmNonPagedPoolStart) - poi(nt!MmPfnDatabase)

可以用来确定 PFN 数据库的大小。另外,要确定 PFN 数据库中条目的总数,可以使用以下表达式:

1
?(poi(nt!MmNonPagedPoolStart) - poi(nt!MmPfnDatabase))/ @@(sizeof(nt!_MMPFN))

内核变量  nt!MmPfnDatabase 负责定义此区域(用于 PFN 数据库)的起始地址。

(译注:使用 32 位 Windows 7 客户机版本,在任务管理器中显示的可用物理内存总数为 3.5GB。每个物理页面跨越 4KB 的物理地址范围,这里按 4096 字节计算。

命令 dd nt!MmHighestPossiblePhysicalPage 的输出结果为 000defff,加1为 000dffff,即10进制的 913408,913408 * 4096 B = 3741319168 B ,与 3.5GB 大致相符,验证了上面讨论的部分内容。

另外,从命令 ?(poi(nt!MmNonPagedPoolStart) - poi(nt!MmPfnDatabase))/ @@(sizeof(nt!_MMPFN)) 的输出得知, PFN 数据库中条目的总数为 913993,而物理页面总数为 913408,如果按照一个 PFN 条目描述一个 4KB 范围的物理页面,那么数据库中还剩余 585 项未使用 )

在 x64 版本的 Windows 上,一个 PFN 数据库中存储的每个页表条目大小为 8 字节,每个页表条目映射 4 KB 的页面,那么这样一个 PFN 数据库将跨越接近 6 TB 的系统虚拟地址空间,从而需要使用 49 位物理寻址。

非分页池区(*nt!MmNonPagedPoolStart ~ *nt!MmNonPagedPoolEnd)

非分页池区域紧随在 PFN 数据库之后。非分页池的起始地址存储在内核变量 nt!MmNonPagedPoolStart 中。当调用 MiObtainSystemVa() 时传入 MiVaNonPagedPool 类型的系统虚拟地址范围时,它就会在此区域中分配。内核位图——nt!MiNonPagePoolVaBitmap——控制从非分页池中分配虚拟地址空间的操作,并且,内核变量 nt!MiNonPagedPoolVaBitMapHint 存储其分配提示。

这个区域可按需扩大,最多到 128 GB,此区域仅内核模式下能够访问。

HAL 和 winload.exe 映射区域(FFFFFFFF`FFc00000~FFFFFFFF`FFFFFFFF)

内核全局变量 nt!MiLowHalVa 存储此区域的起始地址,即,0xFFFFFFFFFFC00000。此虚拟地址范围结束于 0xFFFFFFFFFFFFFFFF,它同时也是 X64 内核虚拟地址空间的结尾。

在这个区域中,最低限度也为 HAL 保留了 4MB 的空间(FFFFFFFF - FFc00000)

此区域仅用于系统启动时刻,在 MmInitSystem() 例程的内部逻辑中,会将 HAL 与 winload.exe 映射到此区域。这意味着,在初始化阶段后,系统无法使用属于此地址范围的内存。

(译注:结合前面对“由最初的加载器映射”区域的讨论,可以看出,在系统引导阶段,winload.exe 将 NTOSKRNL.EXE,HAL.DLL 映射到FFFFF800`00000000~FFFFF87F`FFFFFFFF 范围,然后,控制权转交内核时,也就是进入系统初始化阶段时,内核(NTOSKRNL.EXE 中的 MmInitSystem() 例程)反过来将 HAL 与 winload.exe 映射到 FFFFFFFF`FFc00000~FFFFFFFF`FFFFFFFF 范围,整个过程中,

HAD.DLL 分别被映射到2个不同的系统虚拟地址范围。 部分的调用流程是 :  MmInitSystem() -> MmInitNucleus() -> MiInitializeDynamicVa() -> MiInitializeSystemVaRange() ,因此,MiInitializeSystemVaRange() 负责硬编码这片用于映射 HAL 与 winload.exe 的内存范围,)

在系统初始化结束时刻,MmInitSystem() 调用函数 MiAddHalIoMappings(),后者扫描此虚拟地址范围并判断是否需要向由系统维护的 I/O 映射列表中添加任何 I/O 映射,如果需要,则调用 MiInsertIoSpaceMap() 例程。MiInsertIoSpaceMap() 为每个 I/O 映射创建一个追踪器条目,其带有名称为 MmIo "IO space mapping trackers " 的池标签,并且将该追踪器条目添加到一个双向链表(即 I/O 映射列表)中;内核变量 nt!MmIoHeader 指向此链表头部的地址。该双向链表中每个追踪器条目都代表一个被映射到系统页表条目(SysPTE)区域的物理内存块。

这些追踪器条目中的前几个字段(域)包含一些有趣的信息,描述了物理内存及其虚拟地址的映射。函数 MiInsertIoSpaceMap() 也会被 MmMapIoSpace() 例程调用,以跟踪系统上所有的适配器内存映射。

(换言之,MiAddHalIoMappings() 在 FFFFFFFF`FFc00000~FFFFFFFF`FFFFFFF 区域中扫描,并且调用 MiInsertIoSpaceMap() 在 nt!MmIoHeader 指向的 I/O 映射列表中添加追踪器条目,虽然每个条目都包含一个指针,指向的内核虚拟地址空间属于“系统页表条目”区域,但是,nt!MmIoHeader 变量自身,以及它指向的 I/O 映射列表,不一定会在“系统页表条目”区域中)

下面是 I/O 映射列表(即前述的双向链表)中的追踪器条目的内部结构:

1
2
3
4
5
6
7
struct _IO_SPACE_MAPPING_TRACKER {
    LIST_ENTRY Link;
    PHYSICAL_ADDRESS  Pfn;
    ULONGLONG  Pages;
    PVOID Va;
    . . . 
}

(译注:MmInitSystem() 与 MiAddHalIoMappings() 定义在 WRK 1.2 版的 mminit.c 源文件中,它们会在负责系统初始化阶段0和阶段1的 initos.c 源文件中被调用,具体而言,在系统初始化阶段0,由 initos.c 中,负责初始化执行体各组件的 ExpInitializeExecutive() 例程调用 MmInitSystem(),而 MmInitSystem() 在阶段0的主要任务就是初始化执行体组件——内存管理器;稍后,由 initos.c 中,负责系统初始化阶段1的 Phase1InitializationDiscard() 例程再次调用 MmInitSystem(),此时 MmInitSystem() 才会调用 MiAddHalIoMappings() ,执行扫描或添加 I/O 映射。

MiInsertIoSpaceMap() 则定义在 iosup.c 源文件中。上面的 _IO_SPACE_MAPPING_TRACKER 结构定义在 WRK 1.2 版中并不存在,取而代之使用了一个叫做 _MMIO_TRACKER 的结构来存储类似的信息,其定义在 mi.h 头文件中,如下所示,重要的字段添加了注释说明: )

1
2
3
4
5
6
7
8
typedef struct _MMIO_TRACKER {
    LIST_ENTRY ListEntry;         
    PVOID BaseVa;              //此成员存储的虚拟地址位于“系统PTE”区域内
    PFN_NUMBER PageFrameIndex;     // 虚拟地址映射的物理页框号
    PFN_NUMBER NumberOfPages;      // 追踪器条目使用的物理页面数量
    MI_PFN_CACHE_ATTRIBUTE CacheAttribute;
    PVOID StackTrace[MI_IO_BACKTRACE_LENGTH];
} MMIO_TRACKER, *PMMIO_TRACKER;

内核虚拟地址空间分配

Windows 的 32 位版本通过一个内部的内核虚拟分配器机制来管理系统地址空间,我们将在本节讨论这一机制。当前, Windows 的 64 位版本没有必要使用分配器机制来管理虚拟地址空间(因而绕过了成本开销),这是由于, 64 位虚拟地址空间中的每个区域都是静态定义的。

在系统初始化阶段,MiInitializeDynamicVa 函数建立起基本的动态范围,并将可用的虚拟地址设置成所有可用的内核空间。然后 MiInitializeDynamicVa() 通过调用 MiIntializeSystemVaRange 函数,初始化用于映射启动加载器映像(winload.exe),进程空间(超空间),以及 HAL 的地址空间范围。

(部分的调用流程是 :  MmInitSystem() -> MmInitNucleus() -> MiInitializeDynamicVa() -> MiInitializeSystemVaRange() )

MiIntializeSystemVaRange() 用于设置硬编码(hard-coded)的地址范围。(因此在 IDA PRO 的反汇编输出中,它不会调用 MiObtainSystemVa())

稍后,当非分页(非换页)池被初始化时,将再次使用该函数来为非分页池保留(reserve)虚拟地址范围。最后,每当一个驱动程序加载时,原本标记为启动加载器地址范围(MiVaBootLoaded)中的部分区域被重新标记为相应驱动程序映像的地址范围(MiVaDriverImages)。)

在这之后,系统虚拟地址空间中剩下的区域可以通过 MiObtainSystemVa() (及其类似的 MiObtainSessionVa())与 MiReturnSystemVa() ,分别动态地请求(分配)和释放。

内存管理器中的一个函数 MiObtainSystemVa(),用于从各个内核虚拟地址区域中,动态地分配多个 2 MB 的内存。当调用 MiObtainSystemVa() 时,调用者指定要分配的 PDE(页目录项)数量,以及要分配的系统虚拟地址类型(nt!_MI_SYSTEM_VA_TYPE 中的值之一)。

诸如像扩展系统缓存,系统页表条目,非分页池,分页池,以及/或特殊池;大页面内存映射,创建 PFN 数据库,以及创建一个新的会话。。。所有这些操作都将导致一个特定范围的动态虚拟地址分配。因此,调用 MiObtainSystemVa() 例程时,能够传入的有效虚拟地址类型有:MiVaPagedPool,MiVaNonPagedPool, MiVaSystemPtes,MiVaSystemCache,MiVaSpecialPoolPaged,MiVaSpecialPoolNonPaged。

MiObtainSystemVa() 能够从不同的内核虚拟地址区域中满足虚拟地址分配请求。例如,对 MiVaPagedPool 类型的请求被指向可分页池区域;对 MiVaNonPagedPool 类型的请求被指向不可分页池区域;对 MiVaSystemPte 类型的请求被指向系统页表条目区域;所有其它类型的分配请求被指向动态系统虚拟地址区(FFFFF980`00000000~FFFFFa70`FFFFFFFF)

内核虚拟地址空间分配器每次通过某种类型的虚拟地址,获得使用的虚拟内存范围时,它会更新 MiSystemVaType 数组,该数组包含新近分配范围的虚拟地址类型。MiSystemVaType 数组中可能出现的值都列在了表 10-9 中。

【转载】64 位 Windows 内核虚拟地址空间布局(基于 X64 CPU)

MiReturnSystemVa() 例程会释放由 MiObtainSystemVa() 分配的内存。函数 MiInitializeDynamicBitmap() 则初始化所有的位图;这些位图被  MiObtainSystemVa() 与 MiReturnSystemVa() 用来分配和释放内核虚拟地址。

动态内存分配的一个例子是 MiExpandSystemCache() 例程调用 MiObtainSystemVa() 以获得系统缓存视图。MiExpandSystemCache() 在调用 MiObtainSystemVa 时,传入 MiVaSystemCache 类型,其分配的虚拟地址空间用于缓存管理器的 VACB (虚拟地址控制块)数据结构。(译注:结合前文的讨论可知,如果传入了 MiVaSystemCache 类型,MiObtainSystemVa() 实际上会在动态内核虚拟地址空间中分配,而不是在系统缓存工作集中分配 )

尽管按需动态保留虚拟地址空间的能力允许了更佳的虚拟内存管理方式,但如果它没有办法释放已分配的内存,那么这种能力就毫无用处。因此,当分页池或系统缓存可能缩小时,或者当特殊池与大页面映射被释放时,与其关联的虚拟地址就被释放。另一种情况是,当启动注册表被释放时。这就允许根据每个内核组件的使用情况来实施动态内存管理。

此外,组件可以通过 MiReclaimSystemVa() 例程回收内存,如果可用的虚拟地址空间已经低于 128 MB,此例程会请求与系统缓存关联的虚拟地址应被冲洗掉(通过”内存段解引用线程“,即 MiDereferenceSegmentThread())(如果初始的非分页池已经被释放,那么也可以被回收)

除了专门为不同的内核内存消费者提供更恰当的比例分配,以及更好的虚拟地址管理外,当涉及降低内存占用率时,动态虚拟地址分配器也具有它的优势。与分页(换页)相关的数据结构是按需分配的,而不需手动预先分配静态页表条目和页表。在 32 位和 64 位系统上,这都能够减少引导阶段的内存使用率,这是由于,未使用的地址将不会有它们的页表分配。这也意味着,在 64 位系统上,被保留出来的大范围地址空间不需要让它们的页表映射到内存,因此这些区域能够有任意范围的限制,特别是在只有很少物理 RAM 的系统上,这样就能够节省导致分页的数据结构。

系统页表条目的管理

你可以在性能监视器中,添加并查看 Memory: Free System Page Table Entries 计数器的值,来得知可用的系统页表条目数量,或者通过使用 !sysptes 或 !vm 内核调试器命令。你也可以转储 _MI_SYSTEM_PTE_TYPE 结构,该结构是与 MiSystemPteInfo 这个全局变量相关联的。最后一种方法(转储)还可以向你显示出,系统上发生 PTE 分配失败的次数——如果这个值很大则表明一个问题,可能与系统 PTE 泄露有关。

如果你看到了大量的系统 PTE 分配失败,你可以通过创建一个新的,叫做 TrackPtes 的注册表键(DWORD 类型)来启用系统 PTE 跟踪,其路径为:HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management ,并将值设置为 1。然后你就可以使用 !sysptes 4 调式器命令,显示(系统 PTE)分配者的列表,如下所示(32 位系统上的输出):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
lkd>!sysptes 4
0x1ca2 System PTEs allocated to mapping locked pages
 
VA(在系统PTE区域内分配的起始虚拟地址)     MDL(内存描述符列表)   PageCount(分配的大小/页面数)   Caller/CallersCaller
ecbfdee8                    f0ed0958           2              netbt!DispatchIoctls+0x56a/netbt!NbtDispatchDevCtrl+0xcd
f0a8d050                   f0ed0510           1              netbt!DispatchIoctls+0x64e/netbt!NbtDispatchDevCtrl+0xcd
ecef5000                   1          20                 nt!MiFindContiguousMemory+0x63
ed447000                   0          2              Ntfs!NtfsInitializeVcb+0x30e/Ntfs!NtfsInitializeDevice+0x95
ee1ce000                   0          2              Ntfs!NtfsInitializeVcb+0x30e/Ntfs!NtfsInitializeDevice+0x95
ed9c4000                   1          ca                 nt!MiFindContiguousMemory+0x63
eda8e000                   1          ca                 nt!MiFindContiguousMemory+0x63
efb23d68                   f8067888           2              mrxsmb!BowserMapUsersBuffer+0x28
efac5af4                   f8b15b98           2              ndisuio!NdisuioRead+0x54/nt!NtReadFile+0x566
f0ac688c                   f848ff88           1              ndisuio!NdisuioRead+0x54/nt!NtReadFile+0x566
efac7b7c                   f82fc2a8           2              ndisuio!NdisuioRead+0x54/nt!NtReadFile+0x566
ee4d1000                   1          38                 nt!MiFindContiguousMemory+0x63
f0676000                   1          10                 hal!HalpGrowMapBuffers+0x134/hal!HalpAllocateAdapterEx

从上面的输出可以观察到,系统 PTE 的分配者除了各种执行体/内核组件外,多数是一些加载到内核空间的设备驱动程序,其中有系统自带的,也有第三方软硬件供应商开发的;

在“调用者/调用者的调用者”一列中,感叹号前面的就是驱动程序文件名,其省略了.sys文件名后缀,这些驱动程序的二进制文件,绝大多数可以在 C:\Windows\System32\drivers 路径下找到;感叹号右侧的则是该驱动中,请求MiObtainSystemVa() 分配系统 PTE 的例程(包括直接或间接递归调用),以及偏移位置。从这些例程的名称可以看出,它们请求在系统 PTE 区域中分配内存的目的都是与映射视图,MDLs(内存描述符列表),适配器内存映射,驱动程序映像,内核栈, I/O 映射等相关的。

例如,MiFindContiguousMemory() -> MmMapIoSpace() -> MiInsertIoSpaceMap -> ExAllocatePoolWithTag() -> MiAllocatePoolPages() -> MiAllocatePagedPoolPages() -> MiObtainSystemVa()

由此可知,无论是直接还是间接调用 MiObtainSystemVa() ,来在系统 PTE 区域分配内存,都会被跟踪记录下来(启用系统 PTE 分配者跟踪后)。

再如 hal!HalpAllocateAdapterEx ,从其名称就可以推测出是与适配器内存映射相关的例程。我们可以直接使用调试器命令 u 后接完整的例程偏移位置,来反汇编涉及分配操作地址处的机器代码。

MiObtainSystemVa() 从系统页表条目区域分配的内存会被 MiReservePtes() 例程再次划分,后者基于位图 nt!MiKernelStackPteInfo 以及 nt!MiSystemPteInfo,进行二次分配。(译注:回想一下,系统 PTE 区域的作用之一是存储内核栈)

将系统 PTE 区域分成2个不同类别背后的理由是——防止虚拟地址碎片。因为内核栈(特别是系统和服务进程内的线程)是要长期分配的,而其它分配,如内存描述符列表和映射视图,则保持一段相对较短的时间。

位图 nt!MiKernelStackPteInfo 与 nt!MiSystemPteInfo 都是指向 nt!_MI_SYSTEM_PTE_TYPE 型结构的指针。这些数据结构由函数 MiInitializeSystemPtes() 建立。

这些结构中的位图(结构中的 _RTL_BITMAP 型成员 Bitmap,通过命令

“dt nt!_MI_SYSTEM_PTE_TYPE”查看)覆盖了整个 128 GB 的系统 PTE 区域。

函数 MiReservePtes() 需要一个指向内嵌结构 Bitmap 的指针作为参数(这个内嵌结构属于 nt!MiKernelStackPteInfo 或 nt!MiSystemPteInfo 指向的结构成员之一),以确定是在内核栈子区域,还是系统 PTE 子区域中分配。不管是哪一种,稍后都可通过 MiReleasePtes() 例程来释放。

当 nt!MiKernelStackPteInfo 和 nt!MiSystemPteInfo 覆盖的虚拟地址范围被耗尽时,通过调用 MiExpandPtes() 可以将其扩大,后者转而调用 MiObtainSystemVa(传入 MiVaSystemPtes 类型)

MmAllocateMappingAddress() 以及 MmCreateKernelStack() 内部都会调用 MiReservePtes(),两者都会传入 nt!MiKernelStackPteInfo 指向的结构中,指向内嵌结构 Bitmap 的指针作为参数,以便能够在内核栈子区域中分配存储空间。

MiValidateIamgePfn(),MiCreateImageFileMap(),MiRelocateImagePfn(),MiRelocateImageAgain() 等函数,则请求 MiReservePtes() 在系统 PTE 子区域中分配存储空间。

上一篇:【jQuery基础学习】03 jQuery中的事件与动画


下一篇:uCOS-iii 中定义的一些常量