avatar

Catalog
10-10-12分页

保护模式下内存管理方式分为两种,段与页。前面的篇章中,简要介绍了段的知识,今天就来和大家聊聊页的知识,页是保护模式中更为重要的一环,随着系统进入32-Bit,段的作用明显降低了,取而代之的则是在段的基础上,更为细分的页。

段与页

这是Intel白皮书中介绍关于段与页的概要图,经过段的学习,可以很容易的理解左半部分,这是一个根据所提供的有效地址(图中Offset)以及段寄存器中确定的基址,锁定线性地址空间中的某个线性地址(图中Lin.Addr.)的过程。而右半部分,则是利用了页的功能,通过拆分线性地址,一步步转化成了物理地址

上述提到了3个概念,有效地址,线性地址以及物理地址,文字叙述会让人混淆,我们来看一条语句:

Code
1
mov dword ptr ds:[0x12345678], 0x123

其中,0x12345678是有效地址

ds.Base + 0x12345678是线性地址

这都非常好理解,那什么是物理地址呢?考虑这样一个问题,掌握3环知识的小伙伴们知道每个进程都有4GB的内存空间,这时如果有一个进程A,给会进行一个操作,给ds.Base + 0x12345678赋值0x123,还有一个进程B,同样会给ds.Base + 0x12345678赋值0x123,那么ds.Base + 0x12345678处的位置到底是哪个值呢?还是两者都不是呢?这就涉及到了物理地址的概念。

PDT与PTT

每个进程都有一个CR3的值,这很突兀,CR3是什么?实际之前在TSS切换时也用到了,具体等到了控制寄存器那会详细分析。简单来说,CR3被用来切换和定位当前正在使用的页表,它是一个32位的寄存器,其中高20位指向一个物理页(Windows系统上,一个页的大小是4KB,也就是4096个字节),如下图所示:

这图该怎么看呢?首先CR3会指向一个物理页,这个物理页又叫做页目录表(PDT),页目录表每个元素叫做页目录表项(PDE),页目录表项,每个4字节,所以一共有1024个页目录表项,CR3就好比一本书,PDT就是这本书的目录,PDE就是章节,这是一本有1024个章节的书(哇塞,真厚啊U•ェ•*U),这样就好理解多了。这是第一级。

页目录表项又指向一个第二级的表,叫做页表(PTT),页表的大小也是4KB,页表中的每个元素叫做页表项(PTE)。页表项可以理解为书中章节的每个小节,就好比第一章里面有1024个小节,这个小节就是PTE,这1024个小节加起来,构成一个小节表,就是PTT。

第二级介绍完了,第三级也就好理解了,既然书中每个章节的每个小节理解了,接下来就是页码了,每个小节都会对应书中的某个页码。而这个页码,就是相当于的物理页了。这样就可以理解这张图了,就是一部部找到物理页。

10-10-12分页

Windows采用三种分页方式,在32位系统上主要有10-10-12分页和2-9-9-12分页这两种方式,在64位系统上增加了9-9-9-9-12这种分页方式,后面的文章会依次介绍32位下的两种分页方式。首先从10-10-12开始。

首先修改C盘的boot.ini文件,将noexecute改成execute,重启虚拟机,即可使用10-10-12分页方式

10-10-12分页是如何工作的呢?来看一个简单的程序:

c
1
2
3
4
5
6
7
8
9
#include "stdafx.h"

int main(int argc, char* argv[])
{
int a = 0x123;
printf("%x", &a);
getchar();
return 0;
}

这个程序很简单,给a赋值0x123,并查看a的地址

而这个地址0x12ff7c,实际上就是前文提到的线性地址,接下来我们将这个32位的地址按照10-10-12的方式进行拆分:

1.将0x12ff7c拆分成二进制:0000 0000 0001 0010 1111 1111 0111 1100

2.将这32位二进制数,按照10-10-12的方式组合:

每部分位数 二进制 十六进制
10 0000 0000 00 0
10 01 0010 1111 12f
12 1111 0111 1100 f7c

3.根据Cr3找到页目录表(PDT)中的页目录表项(PDE):

首先在Windbg中执行!process 0 0指令,找到当前程序的Cr3,Cr3的值指向的就是页目录表的首地址。由于第一部分值为0,所以要查找的PDE,需要用Cr3+0*4(乘4是因为每个PDE大小是4字节),这里注意一下,由于查找的是物理地址,所以使用的是!dd指令。

4.根据PDE找到页表(PTT)中的页表项(PTE):

上一步已经找到了PDE,PDE的值指向的是某个PTT的首地址,方法和上一步一样,用PDE中的值+12f(拆分完的第二部分)x4,就可以得到PTE,这里要注意一点,将PDE中的值代入时,后12位置0,由于后12位为属性位,在查找的过程中不起作用

5.根据PTE确定物理地址:

确定PTE后,就剩最后一步了。由于一个物理页的大小本身就是4KB,也就是2的12次方,所以当确定了前20位后也就确定了物理页,因此我们要找的内容就在219da000这个物理页上的某个物理地址。这个物理页的范围是219da000~219dafff。现在可以理解,PTE指向的是一个物理页的首地址,根据最后12位的来确定,我们要寻找的值在物理页上的偏移,也就真正的找到了这个物理地址。

根据实验截图,发现我们一开始存在变量a里面的0x123,真正存的地方在0x219daf7c这个物理地址的位置,这就是通过线性地址一步步的找过来的,这些工作都是CPU做的,比如当我们读取a这个地址上的值是,CPU会通过分页机制读取该物理地址的值,然后再显示出来。

有趣的实验

读错值了?

有了10-10-12分页的知识,来做一个有趣的小实验

c
1
2
3
4
5
6
7
8
9
10
11
#include "stdafx.h"

int main(int argc, char* argv[])
{
int a = 0x123;
printf("%x", &a);
getchar();
printf("%x", *(&a));
getchar();
return 0;
}

这个代码非常简单,一般人认为,会先输出a的地址,然后再输出0x123。但有了物理页的知识,我们就可以做一些手脚了。

很奇怪吧?为什么输出不是0x123,而却输出0x456呢?原因就在于,我们偷偷修改了变量的物理地址上的值,将原本的0x123改成了0x456,因此,CPU再次去物理页读取时,值已经发生了变化,读到了修改后的值。

0地址也能存值?

c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "stdafx.h"

int main(int argc, char* argv[])
{
int a = 1;
printf("%x", &a);
getchar();

*(int*)0 = 0x123;

printf("address0: %x", *(int*)0);
getchar();
return 0;
}

这是个显而易见运行会失败的程序,为什么?因为你给0地址赋值了,有点C/C++开发经验的人都知道,0地址是不能存值的,为什么?因为运行不过去啊!这不扯淡嘛!你看我就运行过去了!

这又是为什么呢?其实0地址不能存值的原因是,没有给它挂物理页,既然没有物理页,那CPU按照分页去查的时候,就查不到了;那么只要给他挂个物理页,就可以给这个线性地址存值了,具体操作如下。

总结

  1. 一张页表能包含的物理页:1024*KB = 4MB
  2. 10-10-12分页共有1024张页表:1024*4MB = 4GB
  3. 前20位的值如果相同,那么一定在同一个物理页
  4. 一个PTE最多可以指向一个物理页;PTE可以没有物理页;多个PTE可以指向同一个物理页

参考资料:《Intel白皮书第三卷第四章》

参考教程:https://www.bilibili.com/video/av68700135?p=24

Author: cataLoc
Link: http://cata1oc.github.io/2020/03/18/10-10-12%E5%88%86%E9%A1%B5/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
Donate
  • 微信
    微信
  • 支付寶
    支付寶