前言
本篇介绍Windows中的内存子系统,尤其是从虚拟地址到物理地址的映射流程。
内存分页
虚拟内存
虚拟内存是一个48位的地址,由一个QWord类型存放,额外的高位为48位的符号扩展。 其余48位,每一位都有作用。
下方是对应位的作用。

其中每一位的作用都表示了一个index或者偏移。这些index用于寻址真实的物理地址。
分页结构
当前Windows系统使用多级页表来寻址对应的物理地址。具体的分页模式分为32位分页,PAE(Physical Address Extention)分页,4级分页和5级分页。由CR4和IA32_EFER寄存器给出。
- 如果 CR4.PAE = 0, 使用的是 32位分页模式。
- 如果 CR4.PAE = 1 且 IA32_EFER.LME = 0,使用的是 PAE 分页模式
- 如果 CR4.PAE = 1, IA32_EFER.LME = 1 且 CR4.LA57 = 0,使用的是 4 级分页模式。
- 如果 CR4.PAE = 1, IA32_EFER.LME = 1 且 CR4.LA57 = 1,使用的是 5 级分页模式。
目前,x64一般使用的分级页表被称作:9-9-9-9-12分页。意思是有4级页表,每级分别使用虚拟地址中的9,9,9,9位来表示偏移,最后12位用来表示页内偏移。
为什么不直接使用单页表呢,即:为什么不直接用虚拟地址映射到一个物理地址呢?可以想象到,如果一个32位进程的空间有4GB,不看高2G的内核内存。如果每个用户内存映射到一个物理地址,每一个表项就需要128位,16字节,一个进程就需要2G*16字节。显然,对于内存的损耗过大。
那该怎么办呢,我们想出了方法:将内存按4KB分页,这样用一个虚拟内存(8字节)直接对应一个4KB,再将低位来表示页内偏移,这样页表就减少了4096倍的大小。此时,就形成了单级页表。它通过页表基址+页内偏移来索引。但是这样也有一个问题,如果想用单级页表,则页表必须连续,否则CPU无法从当前页基址寻找下一个页的位置。如果程序需要一个低地址内存,同时又需要一个高地址内存(比如代码区和栈区),那么页表会将中间的内容置空,在低端和高端各放置一个表项。从而产生了内存浪费。
于是,为了减少连续内存浪费,分页内存自然出现了。通过在原始的页表上再套一层4KB的页表,索引时,前9位索引一个指向页表的表的基址,中9位索引具体页表,最后12位是页内偏移。这样,对于上面的情况,只需要在前9位做出区分,如果是连续的,就在第二级连续存放页表即可。同时,它可以任意释放
那么,知道了为什么要分页,那么为什么一定是9-9-9-9-12呢,首先,为什么最后一位一定是12,这是因为一个页就是2^12=4KB,其次,为什么前面是9,这是因为最好一个页表就在一个页中,那么一个页是4KB,每一个表项是8字节,因此需要512个表项,512就是2^9,然后,总数是48,是因为2^48=256TB,但9-9-9-12却只有256GB,目前服务器内存大部分都大于1TB,那么为什么不使用5级呢,因为每次进行索引都需要一次读写内存,使用虚拟地址的必然后果就是减慢了读取内存的速度,如果缓存未命中,就需要额外读取4次,相当于5倍的时间损耗,对于5级地址更为夸张。因此使用4级分页内存是最为合理的。(虽然目前Windows和Linux都支持了5级分页)
每个进程,都有一个对应的PML4表(进程唯一)。该表存放在内存中,由CR3寄存器指明位置。同时,CR4寄存器
页表结构
页表分为:
-
Page Map Level 4
-
Page Directory Pointer Table
-
Page Directory
-
Page Table
-
Offset
每一级的页表结构如下:(图片出自参考2)

其中重要的标志位如下:
| 位 (Bit) | 缩写 | 全称及功能 |
|---|---|---|
| 0 | P | Present(存在位):1 表示在内存中,0 表示已换出到磁盘或未分配。若为 0,其余位失效。 |
| 1 | R/W | Read/Write:0 为只读,1 为可读写。 |
| 2 | U/S | User/Supervisor:0 仅内核可访问,1 用户态也可访问。 |
| 3 | PWT | Page Write-Through:控制高速缓存策略(直写)。 |
| 4 | PCD | Page Cache Disable:禁用该页的高速缓存(常用于 I/O 映射)。 |
| 5 | A | Accessed:CPU 设置,表示该页最近被读写过,用于内存置换算法。 |
| 6 | D | Dirty:(仅指向真实页面的表项有效)表示该页内容被写入。 |
| 7 | PS | Page Size:在 PDPT/PD 中,若为 1 则开启“大页”(1GB/2MB),不再向下寻址。 |
| 63 | NX | No-Execute:禁止在该内存页执行指令(防溢出攻击的核心)。 |
对于大页面的索引:
PDPTE(1GB内存,如果PS为1):(51:30)<<30为基址,将VA的29:0作为页内偏移
PDE(2MB内存,如果PS为1):(51:21)<<21为基址,将VA的21:0作为页内偏移
优化
为了让虚拟内存访问的速度变快,CPU底层进行了很多优化,其中最重要的是TLB转址旁路缓存。
TLB
TLB是页表的Cache,保存了对应页表项。其存在于MMU中。一般来说,CPU直接发送指令给MMU取地址,MMU中的顶层TLB没有找到,则再去下级TLB缓存寻找,最后找不到了才会进行完整的4次访问。(CPU,MMU和Cache都在芯片中)。同时,每次切换进程,都需要清空TLB。
TWU
TWU是硬件页表遍历单元,如果TLB没有找到,则该单元负责读取,其速度更快,且可以与CPU并行,不会被中断打断,找到后,将新的映射写入TLB。在Intel CPU中,TWU被叫做PMH(Page Miss Handler)
上层机制
Windows中,虚拟内存作为底层机制,内存管理器组件实现了更多内容,以下仅作介绍,读者可自行查阅相关内容。
缺页异常
当访问一个P位为0的表项时会产生缺页异常,操作系统内核会捕获它并判断对应页的实际存在位置,并调入内存,更新页表。
工作集管理
工作集是当前一个进程当前驻留在内存的页面集合。
在最小工作集和最大工作集间流动,使得性能平衡。
PFN和页面状态
PFN数据库维护了当前所有物理页面的状态,分为:
-
活动:正在被进程使用
-
过渡:正在从磁盘读取或准备写入磁盘
-
备用:不在对应工作集,但内容有效
-
已修改:页面内容被修改,且已移出
-
已修改且不可写:标记为已修改,但是禁止写入
-
空闲:无有效数据
-
零化:已清零
-
坏页:物理内存页损坏
内存压缩
为了优化速度,优先将换出页面操作改为CPU使用算法在内存中压缩页面内容。
提交限制
物理内存总量+分页文件总量=系统最大虚拟内存分配量
pagefile.sys是虚拟内存的物理载体。
已提交表示系统可以随时拿出这么多内存来使用。
写时复制
对于共享文件,默认映射同一个物理内存,当写入时,单独映射一个物理内存。
WinAPI
// 分配内存:使用POOL_FLAG_NON_PAGED表示非分页内存池PVOID buffer = ExAllocatePool2(POOL_FLAG_NON_PAGED, 4096, 'MyTg');if (buffer) { ExFreePoolWithTag(buffer, 'MyTg');}
// 内存映射PHYSICAL_ADDRESS physAddr;physAddr.QuadPart = 0x12345000; // 目标物理地址// 将物理地址映射到内核虚拟空间PVOID virtualAddr = MmMapIoSpace(physAddr, 4096, MmNonCached);// 也可以直接操作页表AMD-V嵌套虚拟化页表
只需要配置VMCB区域的n_cr3,并构建一个结构和x86-64相同的页表即可。它负责GPA到HPA的映射,如果不够,会触发VMEXIT(Nested Page Fault)并交给host来处理该异常。所以可以尝试直接分配大内存。
参考
https://l4kks41.com/virtual-to-physical-address-translation-in-windows-x64-paging/
部分信息可能已经过时









