一.控制寄存器
由于在页机制中,控制寄存器起到了比较大的作用,所以这里首先说一下控制寄存器。
控制寄存器(CR0、CR1、CR2、CR3、CR4如下图)确定了处理器的操作模式和当前正在执行的任务的特征。这些寄存器在所有32位模式和兼容性模式下都是32位的。

这几个控制寄存器的作用如下:
CR0:包含控制处理器的操作模式和状态的系统控制标志
CR1:保留
CR2:包含页面故障的线性地址(导致页面故障的线性地址)。当CPU访问了某个无效的就会产生缺页异常,此时CPU就会讲引起缺页异常的线性地址放到控制寄存器CR2中
CR3:包含分页结构层次结构基础的物理地址和两个标志(PCD和PWT)。只指定基本地址的最重要的位(减去下面的12位);地址的下12位被假定为0。因此,第一个分页结构必须与页面(4-KByte)边界对齐。PCD和PWT标志控制处理器内部数据缓存中的分页结构的缓存(它们不控制页面目录信息的TLB缓存)。
CR4:包含一组启用多个架构扩展的标志,并指示对特定处理器功能的操作系统或执行支持
对于CR0寄存器比较重要的几个标志是:
PE位:CR0的位0是启用保护标志。当PE=1时为保护模式,否则为实模式,这个标志开起段级保护,而并没有页级保护
PG位:用于启用分页机制,从386开始的所有IA-32处理器都支持该标志。通常,操作系统在启动早期,初始化内存实施,并通过这一位正式启用页机制。
WP位:对于Intel 8086或以上的CPU,该位为写保护标志,当开起WP位,ring0程序是不可以对只读页面进行写操作。也就是说,当CPL<3的时候,如果WP=0,则可以读写任意物理页,只要线性地址有效。否则,只能读取任意地址,但对于只读页面不能进行写操作。
PG=0且PE=0 处理器工作在实地址模式下
PG=0且PE=1 处理器工作在没有开启分页机制的保护模式下
PG=1且PE=0 在PE没有开启的情况下 无法开启PG
PG=1且PE=1 处理器工作在开启了分页机制的保护模式下
对于CR4寄存器比较重要的几个标志是:
二.页机制
IA处理器从386开始支持分页机制(paging)。分页机制的主要目的是高效利用内存,按页来组织和管理内存空间,把暂时不用的数据交换到空间较大的外部存储器(通常是硬盘)上(称为page out,换出),需要时在交换回来(称为page in,换进)。在启用分页机制以后,操作系统将线性地址空间划分为固定大小的页面(4KB,2MB,4MB等)。每个页面可以被映射到物理内存或外部存储器上的虚拟内存文件中。尽管原则上操作系统也可以利用段机制来实现虚拟内存,但是因为页机制具有功能更强大,灵活性更高等特点,今天的操作系统大多都是利用分页机制来实现虚拟内存和管理内存空间的。
首先,操作系统在创建进程时,就会为这个进程创建页表,从本质上讲,页表是进程空间的物理基础,所谓的进程空间隔离主要因为每个进程都有一套相对独立的页表,进程空间的切换实质上就是页表的切换。x86处理器中的CR3寄存器便是用来记录当前任务的页表位置的。当程序访问某一线性地址时,CPU会根据CR3寄存器找到当前任务使用的页表,然后根据预先定义的规则查找物理地址。在这个过程中,如果CPU找不到有效的页表项或者发现这次内存访问违反规则,便会产生页错误异常(#PF)。该异常的处理程序通常是操作系统的内存管理器。内存管理器得到异常报告后会根据CR2寄存器中记录的线性地址,讲所需的内存页从虚拟内存加载到物理内存中,并更新页表。做好以上工作以后,CPU从异常处理例程返回,重新执行导致页错误异常的那条指令,再次查找页表。这便是虚拟内存技术的基本工作原理。
三.32位经典分页
1.预备知识
在此模式下,控制寄存器CR3的结构如图4-4。该寄存器的12~31位(高20位)保存的是页目录的起始地址。因此,第一个分页结构必须与页面(4-KByte)边界对齐。低12位包含分页结构层次结构基础的物理地址和两个标志(PCD和PWT)。PCD和PWT标志控制处理器内部数据缓存中的分页结构的缓存(它们不控制页面目录信息的TLB缓存)。

关于PDE和PTE的内容在下面会有讲解,这里先给出结构中的几个位的含义方便后面讲解:
名称 |
比特位 |
含义 |
P |
0 |
物理页面是否存在,1表示存在 |
R/W |
1 |
读写权限,0代表只读,1代表可读写 |
U/S |
2 |
特权级别,0代表管理权限,1代表用户权限 |
PWT |
3 |
页面级别写入(Page Write Through)。PWT=1时 写Cache的时候也要将数据写入内存中 |
PCD |
4 |
禁用页面级缓存(Page Cache Disable);PCD=1时,禁止某个页写入缓存,直接写内存 |
A |
5 |
已访问;指示此条目是否已用于线性地址转换 |
D/ignore |
6 |
忽略 |
PS |
7 |
页大小,0表示4KB,1表示MB |
G |
8 |
全局页 |
PAT |
7或12 |
页属性表中的索引 |
2.分页模式介绍
当CR0的PG标志为1,CR4的PAE为0时,CPU使用32位经典分页模式。所谓经典模式,是相对于后来的PAE模式而言,它是80386所引入的。此模式为2级页表,在这个模式下,页表结构为两级,第一级称为页目录表,第二级称为页表。
页目录表是用来存放页目录表项(PDE)的线性表。每个页目录占1个4KB内存页,每个PDE的长度为32比特位(4字节),因此每个页目录表中最多包含1024个PDE。由图4-4可知,每个PDE中的内容可能有三种格式,当P位为0,则此时PDE无效,当P为1时,又分为两种一种用于指向4KB的下一集页表,另一种用于指向4MB的大内存页。
当PS等于0,也就是PDE的第七位为0的时候,代表的是4KB内存页,此时高20位代表该PDE所指向页表的起始物理地址的高20位,该起始物理地址的低12位固定为0,所以页表一定是按4KB边界对齐的。
当PS等于1,也就是PDE的第七位为1的时候,代表的是4MB内存页,此时高10位代表的是4MB内存页的起始物理地址高10位,该起始地址的低22位固定为0,因此4MB的内存页一定是按4MB进行边界对齐。
页表用来存放页表表项(PTE)的线性表。每个页表占一个4KB的内存页,每个PTE的长度为32比特位,因此每个页表最多包含1024个PTE。2MB和4MB的大内存页是直接映射到页目录表项,不需要使用页表。由图4-4可知,每个PTE分为两种格式,当P为0,此时PET无效,当P为1时,高20位代表的是4KB的内存页的起始物理地址的高20位,该起始物理地址的低12位假定为0,所以4KB内存页都是按4KB进行边界对齐。
有了前面的基础,下面看一下CPU是如何利用页目录表和页表等数据结构将一个32位的虚拟地址翻译为32位的物理地址的。其过程可以概括为以下的几个步骤:
通过CR3寄存器定位到页目录的起始地址,取线性地址的高10位作为索引选取页目录的一个表项,也就是PDE。
判断PDE的PS位,如果为1,代表这个PDE指向一个4MB的大内存页,PDE的高10位便是4MB内存页物理地址的高10位,线性地址的低22位是页内偏移。将二者合并起来便得到了物理地址。如果PS位是0,那么根据PDE中的页表基地址(取PDE的高20位,低12位设为0)定位到页表。
取线性地址的12位到21位(共10位)作为索引选取页表的一个表项,也就是PTE。
取出PTE中的内存页基地址(取PTE的高20位,低12位设为0)。
取线性地址的低12位作为页中偏移与上一步的内存页基地址相加便得到物理地址。
下图是上述过程的图示:

值得说明的是,页表本身也可能被交换到虚拟内存中,这时PDE的P位会是0,CPU会产生缺页异常,促使内存管理器将页表交换到物理内存。然后在重新执行访问内存的指令和地址翻译过程。与页表不同,每个进程的页目录是永久驻留在物理内存中的。
3.实验练习
接下来通过几个练习来体会上述内容,首先默认情况下Windows系统开起的不是经典32位分页模式,以XP系统为例,要开起经典32位模式只需要打开C盘中隐藏的boot.ini文件。

随后将noexecute改为execute,此时就打开了经典32位模式。
a.翻译过程练习
下面的两段代码会在不同进程的同一虚拟地址申请内存并对这块内存进行不同的赋值。
#include
#include
int main()
{
PDWORD pDwNum = (PDWORD)VirtualAlloc(NULL, 0x4, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (pDwNum)
{
*pDwNum = 0x1900;
printf("address: 0x%X num:0x%X\n", (DWORD)pDwNum, *pDwNum);
}
system("pause");
return 0;
}
#include
#include
int main()
{
PDWORD pDwNum = (PDWORD)VirtualAlloc(NULL, 0x4, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (pDwNum)
{
*pDwNum = 0x1874;
printf("address: 0x%X num:0x%X\n", (DWORD)pDwNum, *pDwNum);
}
system("pause");
return 0;
}
结果如下图所示

两个进程申请到的虚拟地址都是0x003A0000,将对应的二进制分成三部分作为索引,分别是00 0000 0000(0x0),11 1010 0000(0x3A0),0000 0000 0000(0x0)。
接下来就使用WinDbg来分别查询这两个进程申请的虚拟地址的物理页。
test.exe进程CR3为0x1A193000

将0x1A193000高20位作为PDE表的基地址,将虚拟地址高10位的0x0作为索引找到对应的PDE

将PDE的第12位清0得到PTE表的基地址,将虚拟地址中间10位最为索引获取对应的PTE

接着取出PDE并将低12位清0,0x1A35A000作为物理页基地址,并将虚拟地址最后的12位作为偏移在内存中找到保存的0x1900

demo.exe进程的CR3为0x18A7C000

同样经历上述过程可以获得保存在内存中的0x1874

上面的结果可以看到,两个进程都在虚拟地址0x003A0000写入了数据,但是经过分页模式以后,test进程最终保存数据的物理页是0x1A35A000,而demo进程最终保存数据的物理页是0x18C57000。
由此可以得出结论,不同进程之所以可以在同一虚拟地址读写内存,是因为虚拟地址会因为分页模式而映射到不同的物理页上。
b.0地址读写
正常情况下,下面这段代码运行是会出错的,因为程序不能对0地址进行读写
#include
#include
int main()
{
PDWORD pDwNum = (PDWORD)0;
system("pause");
*pDwNum = 0x1900;
printf("0x%x\n", *pDwNum);
system("pause");
return 0;
}
错误如下:

报错结果是0地址不能读写,根据上面的内容可以知道,想要对一个虚拟地址进行读写,就需要成功将虚拟地址转换为合法的物理地址,之所以出错很有可能是0地址并没有被正确挂载上PDE和PTE。
使用WinDbg来观察虚拟地址为0的时候,PDE和PTE的情况,首先获得test的CR3为0x048A2000

接着查看对应的PDE和PTE如下:

可以看到PDE是有效的但是PTE是无效的,这也是为什么0地址不能读写的,所以想要让0地址可以读写,就需要为其挂上有效的PDE和PTE。
更改代码如下,下面的代码申请了一块有效的虚拟地址
#include
#include
int main()
{
PDWORD pDwNum = (PDWORD)0;
PDWORD pDwNum2 = (PDWORD)VirtualAlloc(NULL, 0x4, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (pDwNum2)
{
*pDwNum2 = 0x1874;
printf("0x%X\n", (DWORD)pDwNum2);
system("pause");
*pDwNum = 0x1900;
printf("0x%x\n", *pDwNum);
system("pause");
}
return 0;
}
首先获取test进程的cr3为0x1CCA9000。

首先获取pDwNum2的PDE和PTE。

由输出可知,PDE和PTE分别是0x1CDFE867和0x1CEB6867,接着在将这两个值赋给0地址的PDE和PTE

[招生]科锐逆向工程师培训(2025年3月11日实地,远程教学同时开班, 第52期)!
最后于 2021-11-30 10:12
被1900编辑
,原因: