关闭页面特效

Windows 保护模式学习笔记

x86,照着 lzyddf 师傅的 blogOneTrainee师傅的blog 学的,然后在此基础上扩展学习了一些东西。

X86 CPU的三个模式:实模式、保护模式和虚拟8086模式。

1|0段寄存器


比如说,当我们用汇编读写某一个地址时,例如mov dword ptr ds:[0x123456], eax,这里实际上是读取了 DS 中偏移为 0x123456 处的内存地址。也就是说相当于mov (DS.base + 0x123456), eax,把 eax 的值写入地址 DS.base + 0x123456。

这里的 DS 就是一个段寄存器。一般来说有 8 个段寄存器,分别是:ES CS SS DS FS GS LDTR TR

段寄存器结构如下

Windows 保护模式学习笔记 1

struct SegMent { WORD Selector; // 段选择子 16位 可见 WORD Atrributes; // 段属性 16位 不可见 DWORD Base // 段起始地址 32位 不可见 DWORD Limit // 段大小 32位 不可见 }

Selector 用于索引全局描述符表(GDT)或局部描述符表(LDT)中的段描述符,Attribute 用于描述段的类型、权限和其他特性,Base 就是段的起始地址,Limit 表示段的大小,限制段的访问范围。具体怎么表示的这里不多写。

Windows 保护模式学习笔记 2

红色字体部分视环境而变。

一般我们读写段寄存器的时候这样搞:

读:MOV AX,ES 写:MOV DS,AX

从段寄存器的结构可以看出,段寄存器实际上有 96 位(16 位可见部分 + 80 位不可见部分),而 AX 寄存器只有 16 位。因此,读操作时可以直接将段寄存器的低 16 位(即 Selector)读入 AX,但写操作时情况则不同:写入的虽然是 16 位的值,但段寄存器的完整 96 位数据会被更新。那么,剩下的 80 位数据从何而来呢?

2|0GDT 表与 LDT 表


为了解决这个问题,就得引入 GDT 表与 LDT 表。也就是我们之前提到的

Selector 用于索引全局描述符表(Global Descriptor Table, GDT)或局部描述符表(Local Descriptor Table, LDT)中存储的段描述符

从程序员的视角来看,段寄存器是 16 位的(也就是那 16 位可见部分),但是为了完整地描述一个段,还需要段基址、界限和属性,也就是 Base,Limit 和 Attributes。这些信息被封装在一个 64 位的数据结构中,称为段描述符。由于段寄存器只有 16 位,无法直接存储 64 位的段描述符,因此 Intel 设计了一种间接引用的机制:将所有的段描述符存储在一个全局数组(即 GDT)中,而段寄存器中的值则作为索引,指向 GDT 中的某个段描述符。

Windows 保护模式学习笔记 4

另外,除了GDT之外,IA-32 还允许程序员构建与 GDT 类似的数据结构,也就是 LDT。但与 GDT 不同的是,LDT 在系统中可以存在多个,并且 LDT 不是全局可见的,它们只对引用它们的任务可见,每个任务最多可以拥有一个 LDT。另外,每一个 LDT 自身作为一个段存在,它们的段描述符被放在 GDT 中。LDT 是一个可选的数据结构,为了 OS 内核的简洁性和可移植性,一般就不要用它了。

回到上一段的问题中来。当我们执行类似 MOV DS, AX 指令时,CPU 会查表,根据 AX 的值来决定查找 GDT 还是 LDT,查找表的什么位置,以及查出多少数据。

具体来说,当执行 MOV DS, AX 这样的写操作时,CPU 会执行以下步骤:

  1. 读取 AX 的值,获取 16 位的 Selector。

  2. 从段选择子(Selector)中提取高 13 位,作为 Index,检查 Selector 的 TI 位(Table Indicator),确定是访问 GDT 还是 LDT。

    Windows 保护模式学习笔记 3

  3. 根据 Index 和 TI 位,从 GDT 或 LDT 中找到对应的 64 位段描述符。处理器将 Index 乘 8 再加上 GDT 或者 LDT 的基地址,就是要加载的段描述符

  4. 将 Selector(16 位)写入段寄存器的可见部分,将段描述符中的 Base、Limit 和 Attributes 加载到段寄存器的不可见部分。

这样就完成了全部 96 位的更新。

除了 MOV 指令,还可以使用 LES、LSS、LDS、LFS、LGS 指令修改段寄存器。注意:不存在 LCS 指令,因为 CS 不可写。例如

char buffer[6]; __asm { les ecx,fword ptr ds:[buffer] // 高 2 个字节给 es,低 4 个字节给 ecx }

这里还要提一下在不同模式下利用段寄存器去寻址的区别。

在实模式下,段寄存器直接存储段基址的高 16 位,物理地址的计算方式为物理地址 = (段寄存器值 << 4) + 偏移地址,例如:CS = 0x1000IP = 0x0020,物理地址 = 0x1000 << 4 + 0x0020 = 0x10020。这种也是平常大学本科的课程内讲的寻址方法。

而在保护模式下就需要用到 GDT 和 LDT 表了。此时段寄存器分为可见部分(段选择子)和不可见部分(段描述符缓存),段描述符缓存包括:段基址,段界限和段属性。段选择子用于索引 GDT 或 LDT 中的段描述符。此时物理地址的计算方式为物理地址 = 段基址 + 偏移地址

还有一种是长模式,这种情况下段寄存器的作用被弱化,段基址固定为 0,段界限被忽略。线性地址直接等于偏移地址。例如,mov eax, ds:[0x123456] 的线性地址就是 0x123456

3|0段权限


上一节里面 Selector 的结构里面有一个 RPL 我们没有提到。Requested Privilege Level,请求特权级,图中可以看见它是个 2 位的东西,表示当前请求的特权级,取值是 0~3。

这里就引出 CPU 分级概念,也就是我们熟知的 0~3 环。

Windows 保护模式学习笔记 5

简单来说,这个分级就是防止你胡乱更改一些重要的内核参数,导致蓝屏什么的。一般我们写的程序都在 Ring 3 上运行。只有调用某些系统 API 的时候会跑到 Ring 0 上,这也是为什么平时我们称应用程序为 3 环,系统程序为 0 环。Windows 下一般就用 0 和 3 两环。

那么,如何判断某个程序处于哪一环?这里我们又引入进程特权级别。

3|1当前特权级(CPL)


段寄存器 CS 的后两位比特位称为当前特权级。也就是,CS 的 Selector 的后两位不是 RPL 是 CPL。注意:段选择子 SS 和 CS 的后两位比特位相同。因为 CS 是代码段,当前运行的程序就在这个上面跑,所以是 CPL,比较好理解。

举个例子

CS = 0x001B0x001B = 二进制:0000 0000 0001 1011 → 二进制:11 = 十进制:3 → 因此:当前进程处于3

3|2请求特权级(RPL)


RPL 是段选择子结构中的一部分,是针对段选择子而言的,每个段的选择子都有自己的 RPL,其表示用什么权限去访问一个段。比如说,当前 CPL 是 0,但是为了避免出错,我在访问其它段的时候想降低访问的权限(例如我有读写权限,但是为了安全,我只想用只读权限),就可以用 RPL 把权限降低到 3 去。

举个例子

MOV AX,0008 MOV DS,AXMOV AX,000B MOV DS,AX 指向的是同一个段描述符,但RPL不同。 0008: 0000 0000 0000 1000, 0000B: 0000 0000 0000 1011, 3

3|3段特权级(DPL)


我们之前在段描述符的那个图里面可以注意到有个 Descriptor Privilege Level,就是段特权级。它规定了访问所在段描述符所需要的特权级别是多少。

3|4数据段的权限检查


也就是检查:CPL<= DPL 并且 RPL<= DPL(数值上的比较)。就是符不符合段特权级的限制。

举个例子,当 CPL = 0 时执行以下指令:

MOV AX, 000B // RPL = 3,请求权限为 3 MOV DS, AX // 假设 ax 指向的段描述符的 DPL = 0

上述指令虽然满足了 CPL <= DPL,但 RPL > DPL,因此执行失败。

4|0代码跨段跳转


除 CS 外,其他的段寄存器都可以通过MOV,LES,LSS,LDS,LFS,LGS指令进行修改。CS 为什么不可以用这些命令直接修改呢?因为 CS 的改变意味着 EIP 的改变,改变 CS 的同时必须修改 EIP,所以我们无法使用上面的指令来进行修改。

只改变EIP的指令: JMP / CALL JCC / RET 同时修改CSEIP的指令: JMP FAR / CALL FAR / RETF / INT /IRETED

跨段跳转分为两种情况:

  1. 要跳转的段是一致代码段
  2. 要跳转的段是非一致代码段

这里得先知道什么是一致代码段和非一致代码段。我们回到段描述符来

Windows 保护模式学习笔记 4

还是这个图,关注 15-8 这 8 位。

  • P为 Persent 存在位。P=1 表示段在内存中存在,可以正常访问。P=0 表示段在内存中不存在,访问时会触发异常(例如如段不存在异常 #NP)。

  • DPL:段特权级,0~3 。

  • S 表示描述符的类型。S=1 表示当前描述符是代码段或数据段描述符。S=0 表示当前描述符是系统段描述符或门描述符。

  • 当 S=1 时,TYPE 的四位分别是

    • 11,执行位:置 1 时表示可执行(代码段),置 0 时表示不可执行(数据段);
    • 10,一致位:置 1 时表示一致码段,置 0 时表示非一致码段;
    • 9,读写位:置 1 时表示可读可写,置 0 时表示只读(代码段)或只写(数据段);
    • 8,访问位:置 1 时表示已被访问,置 0 时表示未被访问。

从系统的角度来看,一致代码段和非一致代码段的意思就是指这个一致位是否置 1,置 1 就是一致代码段,置0就为非一致代码段。

具体来说:

一致代码段是操作系统提供的一种共享代码段,允许低特权级的程序直接调用或跳转到该段代码。另外,特权级高的程序不允许访问特权级低的数据(核心态代码不能直接访问用户态的数据);特权级低的程序可以访问到特权级高的数据,但特权级不会改变。

非一致代码段是操作系统保护的系统代码段,不允许低特权级程序直接访问。其只允许同级访问,禁止跨特权级别的访问。如果低特权级程序需要访问非一致代码段,一般得通过特定的“门”机制进行特权级切换。

现在模拟一下跳转流程,例如JMP 0x20:0x004183D

首先段选择子是 0x20:RPL = 00,TI = 0,Index = 4。查 GDT 表,通过 Index 找段描述符。然后看是一致代码段还是非一致代码段,进行权限检查。通过权限检查后,CPU会将段描述符加载到 CS 段寄存器中,然后将 CS.Base + Offset的值写入 EIP,然后执行 CS:EIP 处的代码。这样就完成一次段间跳转。

5|0长调用与短调用


JMP 并不影响堆栈,但 CALL 指令会影响。

5|1短调用


指令格式为:CALL 立即数 / 寄存器 / 内存

Windows 保护模式学习笔记 6

发生改变的寄存器:ESP EIP

一般调用同一个程序内的函数就是用的这种

5|2长调用(跨段不提权)


指令格式为:CALL CS:EIP(EIP是废弃的)

Windows 保护模式学习笔记 7

发生改变的寄存器:ESP EIP CS,这里用的是 retf 恢复

5|3长调用(跨段提权)


指令格式为:CALL CS:EIP(EIP是废弃的)

Windows 保护模式学习笔记 8

发生改变的寄存器:ESP EIP CS SS,长调用执行后,堆栈已经不是原来的堆栈,而是 0 环的堆栈(ESP0)

retf 执行时:

Windows 保护模式学习笔记 9

发生改变的寄存器:ESP EIP CS SS

  1. 跨段调用时,一旦有权限切换,就会切换堆栈
  2. CS 的权限一旦改变,SS 的权限也要随着改变,CS 与 SS 的等级必须一样
  3. JMP FAR 只能跳转到同级非一致代码段,但 CALL FAR 可以通过调用门提权,提升 CPL 的权限

6|0调用门


之前我们提到过,如果低特权级程序需要访问非一致代码段,一般得通过特定的“门”机制进行特权级切换。调用门(Call Gate)是 x86 架构中的一种机制,用于实现跨特权级的受控跳转。它允许低特权级程序通过一种受控的方式调用高特权级程序,同时确保系统的安全性和隔离性。本质上,调用门是一种段描述符,存储在 GDT 或 LDT 中,但是其结构与普通的段描述符有所不同。

指令格式为:CALL CS:EIP(EIP是废弃的)

执行步骤:

  1. 根据 CS 的值查 GDT 表,找到对应的段描述符,这个描述符是一个调用门
  2. 在调用门描述符中存储另一个代码段的段选择子
  3. 段选择子指向的段段.Base + 偏移地址就是真正要执行的地址

门描述符结构如下

Windows 保护模式学习笔记 10

S(第 12 位)表示描述符的类型。S=1 表示当前描述符是代码段或数据段描述符。S=0 表示当前描述符是系统段描述符或门描述符。所以这个地方一定是 0。然后在这种情况下,Type 的值为 1100(可执行,一致代码段,只读,未被访问),该描述符才是门描述符。

低四字节的 16~31 位是决定调用的代码存在于哪个段的段选择子,0~15 位是决定跳转位置的段内偏移。当长调用执行时:真正要执行的代码地址 = 门描述符中段选择子所指向的代码段的 Base + 门描述符高四字节的 16~31 位 + 门描述符低四字节的 0~15 位

当程序通过调用门进行跳转时,CPU 会执行以下步骤:

  1. 检查调用者的特权级(CPL)是否满足调用门的 DPL。检查目标代码段的 DPL 是否允许调用者的特权级访问。

  2. 如果特权级发生变化,CPU 会自动切换到目标特权级的栈。将调用者的栈指针(SS:ESP)保存到新栈中。

  3. 根据调用门中指定的参数个数,将参数从调用者栈复制到目标栈。

  4. 加载目标代码段的段选择子到 CS,加载偏移地址到 EIP,开始执行目标代码。

总结一下这两节

  1. 当通过门,权限不变的时候,只会 PUSH 两个值:CS 和返回地址,新的 CS 的值由调用门决定
  2. 当通过门,权限改变的时候,会 PUSH 四个值:SS、ESP、CS、返回地址,新的CS的值由调用门决定,新的 SS 和 ESP 由 TSS(Task State Segment) 提供
  3. 通过门调用时,要执行哪行代码由调用门决定;但使用 RETF 返回时,由堆栈中压入的值决定;这就是说,进门时只能按指定路线走,出门时可以FQ(只要改变堆栈里面的值就可以想去哪去哪)

7|0中断门&陷阱门


Windows 实际上并没有使用调用门,但是使用了中断门。学习调用门是为了更好地理解中断门。老的 CPU 会使用中断门进 Ring 0,现在新的 CPU 用的是快速调用。

7|1中断描述符


IDT(Interrupt Descriptor Table) 即中断描述符表,同 GDT 一样,IDT 也是由一系列描述符组成的,每个描述符占 8 个字节。但要注意的是,IDT 表中的第一个元素不是 NULL。同 GDTR 寄存器存储了 GDT 的入口地址一样,IDT 的入口地址也有 IDTR 来存储。相应的,IDTL 也存储了 IDT 表的偏移值,其大小一般是 0x7FF=2048 字节。

IDT 表可以包含 3 种门描述符:任务门描述符、中断门描述符、陷阱门描述符。

  • 任务门描述符包含目标任务的 TSS(Task State Segment)段选择子,用于在任务切换时加载新任务的上下文,其通过中断或调用触发任务切换。
  • 中断门描述符用于描述中断例程的入口。
  • 陷阱门描述符用于描述异常处理例程的入口。

7|2中断门


Windows 保护模式学习笔记 11

结构如图所示。其执行前后堆栈变化如图所示

Windows 保护模式学习笔记 12

7|3陷阱门


Windows 保护模式学习笔记 13

结构如图所示。

可以看出,中断门和陷阱门十分相似。它们唯一的区别在于,中断门执行时,会将 IF 标志位清零,但陷阱门不会。

当通过中断门进入中断处理程序时,CPU 会自动清除 IF 标志位,从而禁用可屏蔽中断。这意味着在中断处理程序执行期间,CPU 不会响应其他可屏蔽中断,直到中断处理程序结束。中断处理程序结束时,CPU 会通过 IRET 指令恢复之前的 IF 标志位状态。当通过陷阱门进入异常处理程序时,CPU 不会修改 IF 标志位,因此可屏蔽中断仍然允许。这意味着在异常处理程序执行期间,CPU 可以响应其他可屏蔽中断。

这样,通过陷阱门进入的服务程序就允许运行嵌套中断,而中断门进入的服务程序不允许嵌套中断的发生。

可屏蔽中断:比如程序正在运行时,我们通过键盘敲击了锁屏的快捷键,若IF位为1,CPU就能够接收到我们敲击键盘的指令并锁屏
不可屏蔽中断:断电时,电源会向CPU发出一个请求,这个请求叫作不可屏蔽中断,此时不管IF位是否为0,CPU都要去处理这个请求

8|0任务段


之前提到,在调用门、中断门与陷阱门中,一旦出现权限切换,那么就会有堆栈的切换;而且,由于 CS 的 CPL 发生改变,也导致了 SS 也必须要切换。切换时,会有新的 ESP 和 SS(CS 是由中断门或者调用门指定),这 2 个值从哪里来的呢?答案是 TSS (Task-state segment ):任务状态段

TSS 是一块 104 字节内存,存储了一堆寄存器的值。不要把它和任务切换关联起来,它就是一段存储寄存器数据的值而已。不记住这句话等会任务门就看昏了(

Windows 保护模式学习笔记 14

抽象成结构体就是这个样子

typedef struct TSS { DWORD link; // 保存前一个 TSS 段选择子,使用 call 指令切换寄存器的时候由CPU填写。 // 这 6 个值是固定不变的,用于提权,CPU 切换栈的时候用 DWORD esp0; // 保存 0 环栈指针 DWORD ss0; // 保存 0 环栈段选择子 DWORD esp1; // 保存 1 环栈指针 DWORD ss1; // 保存 1 环栈段选择子 DWORD esp2; // 保存 2 环栈指针 DWORD ss2; // 保存 2 环栈段选择子 // 下面这些都是用来做切换寄存器值用的,切换寄存器的时候由CPU自动填写。 DWORD cr3; DWORD eip; DWORD eflags; DWORD eax; DWORD ecx; DWORD edx; DWORD ebx; DWORD esp; DWORD ebp; DWORD esi; DWORD edi; DWORD es; DWORD cs; DWORD ss; DWORD ds; DWORD fs; DWORD gs; DWORD ldt; // 这个暂时忽略 DWORD io_map; } TSS;

如果代码从 3 环跨到 0 环,现在观察上面的图或者结构体,可以看到确实存在这么一个 SS0 和 ESP0。提权的时候,CPU 就从这个 TSS 里把 SS0 和 ESP0 取出来,放到 ss 和 esp 寄存器中。

CPU 通过 TR 段寄存器寻找 TSS。TR 寄存器的值是当操作系统启动时,从 TSS 段描述符中加载出来的,TSS 段描述符在 GDT 表中,TR.Base = TSS 起始地址,TR.Limit = TSS 大小。

我们使用指令LTR来将 TSS 段描述符加载到 TR 寄存器。用 LTR 指令去装载的话 仅仅是改变 TR 寄存器的值(96位),并没有真正改变 TSS;LTR 指令只能在系统层 Ring 0 使用,加载后 TSS 段描述符的状态位 Type 会发生改变。

我们使用指令STR来读 TR 寄存器。如果用 STR 去读的话,只读了 TR 的 16 位,也就是段选择子。

现在我们给出 TSS 段描述符的结构,TSS 段描述符是系统段描述符中的一种,所以可以看见这里第 12 位 S=0

Windows 保护模式学习笔记 15

Type = 二进制 1001,说明该 TSS 段描述符未被加载到TR段寄存器中;Type = 二进制1011,说明该 TSS 段描述符已被加载到 TR 段寄存器中。

TSS 段描述符是系统段描述符的一种,我们之前在代码跨段跳转那里说的 TYPE 的意义是 S=1 的时候,也就是数据段和代码段的情况,和这里不冲突。

TSS、TSS 段描述符、TR 段寄存器关系示意图:

Windows 保护模式学习笔记 16

9|0任务门


任务门的存在就是为了实现任务的切换,而任务切换的本质就是暂停/终止当前任务的执行切换,切换到另一任务执行。

任务门存在于 IDT 表,其中包含 TSS 段选择子,可以通过访问任务门达到切换 TSS 的目的。任务门的描述符结构图如下

Windows 保护模式学习笔记 17

Reserved 就是保留位,一般就是置 0。P 位表示任务门是否有效,1 表示有效,0 表示无效。DPL 描述符特权级,决定哪些特权级的程序可以访问该任务门,但是这个对因中断而发起的任务切换不起作用,CPU 不按特权级施加任何保护。TSS 段选择子是让 CPU 用来根据其加载新任务的上下文的。

任务门执行过程如下:

  1. INT N(N 为 IDT 表索引号)
  2. 系统通过用户指定的索引查找 IDT 表,找到对应的门描述符
  3. 门描述符若为任务门描述符,则根据任务门描述符中 TSS 段选择子查找 GDT 表,加载目标任务的 TSS 段描述符
  4. 将目标任务的 TSS 段描述符中的内容加载到 TR 段寄存器
  5. 将当前任务的寄存器状态保存到当前任务的 TSS 中。
  6. TR 段寄存器通过 Base 和 Limit 找到 TSS,使用 TSS 中的值加载新任务的寄存器状态
  7. 根据目标 TSS 中的信息,切换到新任务的特权级。如果新任务的 TSS 中包含 CR3 的值(页目录基址寄存器),CPU 会更新 CR3,从而切换到新任务的地址空间。
  8. 新任务开始执行后,该任务的 TSS 描述符的 B(Busy Flag)位置 1,表示任务正忙
  9. IRETD 返回

10|010-10-12 分页


首先是一个操作系统课学过的玩意:每个程序在运行时,操作系统都会为其分配一段 4GB 的内存空间。但是,我们内存根本不够为每个进程都分配一次。实际上,这个 4GB 是一个虚拟的内存空间,虚拟内存到物理内存有一层映射关系。

看这个代码:MOV eax,dword ptr ds:[0x12345678],其中,0x12345678 是有效地址,ds.Base + 0x12345678 是线性地址。当段寄存器的 Base=0 时,有效地址 = 线性地址。

另外还有物理地址,就是在物理设备上的真实地址。我们平时所用到的 DLL 存在于物理地址中,当程序想要调用某个 DLL 时,DLL 便会把这个物理地址映射一份线性地址给程序,这样程序就能够通过线性地址找到 DLL 的物理地址。

然后还要引入一个控制寄存器 CR3,每个进程都有一个 CR3 的值,它存储了当前任务的页目录基地址,一共 4096 字节。在分页机制中,CPU 通过 CR3 寄存器找到页目录,从而开始地址转换过程。

现在我们讲 10-10-12 分页机制。10-10-12 分页是 x86 架构中用于将 32 位线性地址映射到物理地址的一种分页机制。它是 x86 保护模式下的标准分页方式,也称为两级页表分页。在 10-10-12 分页方式中,32 位线性地址被划分为三个部分:

  1. 高 10 位:页目录索引,定位页目录项

  2. 中间 10 位:页表索引,定位页表项

  3. 低 12 位:页内偏移,定位页内地址

顺便,复习一下两级页表结构:页目录是一个 4KB 的表,包含 1024 个页目录项,每个页目录项大小为 4 字节,指向一个页表;页表是一个 4KB 的表,包含 1024 个页表项,每个页表项大小为 4 字节,指向一个 4KB 的物理页;物理页是实际的内存块,大小为 4KB。

然后我们也就知道为什么要按 10-10-12 分页了:

  1. 一个物理页的大小为 4096 字节,即212字节,若要遍历整个物理页,则需要 12 个比特位
  2. 一个页表有 1024 个页表项,1024 就是 210,即需要 10 个比特位
  3. 页目录表项同理,也需要 10 个比特位

举个例子。假设线性地址为 0x00402010,其二进制表示为:0000 0000 01 | 00 0000 0010 | 0000 0001 0000,那么

  1. 页目录索引:0000 0000 01(0x001)

  2. 页表索引:00 0000 0010(0x002)。

  3. 页内偏移:0000 0001 0000(0x010)

转换:

  1. 使用页目录索引 0x001 找到页目录项,获取页表基地址(之前提到了,页目录基地址在 CR3 里面)
  2. 使用页表索引 0x002 找到页表项,获取物理页基地址
  3. 将物理页基地址与页内偏移 0x010 相加,得到物理地址

11|0PDE&PTE


其实感觉该先讲了这个再讲 10-10-12 分页的(

给一个 CR3 + 两级页表结构的示意图。PDE:页目录表项,PTE:页表项

Windows 保护模式学习笔记 18

再在这里抄一遍两级页表结构:页目录是一个 4KB 的表,包含 1024 个页目录项,每个页目录项大小为 4 字节,指向一个页表;页表是一个 4KB 的表,包含 1024 个页表项,每个页表项大小为 4 字节,指向一个 4KB 的物理页;物理页是实际的内存块,大小为 4KB。

特征如下

  1. PTE 可以指向一个物理页,也可以不指向物理页
  2. 多个 PTE 可以指向同一个物理页
  3. 一个 PTE 只能指向一个物理页

物理页的属性 = PDE属性 & PTE属性,这俩的结构如下

Windows 保护模式学习笔记 19

Windows 保护模式学习笔记 20

P位:是否有效位
注意:当PDE或PTE中有一个的属性P=0时,物理页就是无效的

R/W位:读写位
R/W=0:只读
R/W=1:可读可写

U/S位:权限位
U/S=0:特权用户
U/S=1:普通用户

PS位:PDE特有
PS == PageSize
PS=1:PDE直接指向物理页,低22位=页内偏移,偏移最大值为4MB,俗称"大页"
PS=0:PDE指向PTE

A位:访问位
A=1:该PDE/PTE被访问过
A=0:该PDE/PTE未被访问过

D位:脏位
D=1:该PDE/PTE被写过
D=0:该PDE/PTE未被写过

PWT 和 PCD 在后面说

12|0页目录表基址&页表基址


好像没啥好写的,抄一下总结完事

  1. 线性地址 C0300000 对应的物理页就是页目录表

  2. 这个物理页即页目录表本身也是页表

  3. 这个物理页是一张特殊的页表,每一项PTE指向的不是普通的物理页,而是指向其它的页表

  4. 访问页目录表的公式:C0300000 + PDI*4(I=index)

  5. 页表被映射到了从0xC0000000~0xC03FFFFF的 4M 地址空间

  6. 在这 1024 个表中有一张特殊的表:页目录表

  7. 页目录被映射到了 0xC0300000 开始处的 4K 地址空间

  8. 访问页表的公式:0xC0000000 + PDI*4096 + PTI*4(I=index)

这里 0xC0300000 和 0xC0000000 随操作系统的改变也会跟着变化

13|02-9-9-12 分页


随着硬件的发展,4GB 的物理地址范围已经无法满足要求,然后就有了 2-9-9-12 分页方式,又称为 PAE(Physical Address Extension,物理地址扩展)分页。

页的大小是确定的,4KB 不能随便改,所以 32 位的最后一部分就确定为了 12 位。如果想增大物理内存的访问范围,就需要增大 PTE,增大了多少呢?考虑对齐的因素,增加到 8 个字节。

由于 PTE 增大了,而 PTT 表的大小没变,依然是 4KB,所以每张 PTT 表能放的 PTE 个数由原来的 1024 个减少到512 个,512 等于 2 的 9 次方,因此 PTI = 9。

Windows 保护模式学习笔记 21

由于 2 的 9 次方个 PDE 就能找到所有的 PTT 表,因此 PDI=9。
分配到这里时,还剩下前 2 位未分配。与 10-10-12 不同,CR3 不直接指向 PDT 表,而是指向一张新的表,叫做PDPT 表(页目录指针表),PDPT 表中的每一个成员叫做 PDPTE(Page-Directory-Point-Table Entry,页目录指针表项),每项占 8 个字节。PDPT 表只有 4 个成员,因为 2 位比特位只能满足四种情况:00 01 10 11

Windows 保护模式学习笔记 22

这里给出 PDPTE 的结构图

Windows 保护模式学习笔记 23

P位:第0位,有效位
Avail:这部分供操作系统软件随意使用,CPU不使用
Base Addr:指向PDT表地址,由两部分组成
第一部分:高四字节32~35位
第二部分:低4字节12~31位
这两部分加起来共24位,后12位补0
灰色部分:保留位

然后是 PDE 的

Windows 保护模式学习笔记 24

PAT位:页属性表,只有当 PS=1 时,PAT 位才是有意义的(页属性表只针对页)

然后是 PTE 的,PTE 和 PDE 都是 8 字节的

Windows 保护模式学习笔记 25

  1. PTE 中 12~35 位是物理页基址,低 12 位补0
  2. 物理页基址 + 12 位页内偏移指向具体数据

这里还要介绍 XD/NX 保护位。Intel 中称为 XD,AMD 中称为 NX,即 No Excetion。

段的属性有可读、可写和可执行,但页的属性只有可读、可写。当 RET 执行返回的时候,如果把堆栈里面的数据指向一段提前准备好的数据(shellcode),也就是把数据当作代码来执行,那么就会产生任意代码执行的后果。所以,Intel 就在这方面做了硬件保护,设置了一个不可执行位,也就是 XD/NX 位。当 XD=1 时,软件产生了溢出也没有关系,即使 EIP 蹦到了危险的“数据区”,也是不可以执行的。

在 PAE 分页模式下,PDE 与 PTE 的最高位为 XD/NX 位。

14|0TLB


当我们通过一个线性地址访问物理页的时候,假设就是MOV EAX,[0x12345678],实际上 CPU 不止读了 4 个字节。10-10-12 分页的话,先通过线性地址找到对应的 PDE,再通过 PDE 和线性地址找到 PTE,最后再通过 PTE 找到对应物理页,就是 4 + 4 + 4 = 12 字节。2-9-9-12 分页的话,先找到 PDPTE,再找到 PDE,再找到 PTE,最后找到物理页,就是 8 + 8 + 8 + 4 = 28 个字节。要是跨页访问,读得更多。

那么为了提高访问效率,只能对线性地址与其对应的物理地址做记录。CPU 内部做了一张表,用来记录这些东西。它的效率和寄存器一样快,名字叫做 TLB(Translation Lookaside Buffer,转译后备缓冲器)。由于TLB的效率很快,因此它的大小不能太大,少则几十条,多则也只有上百条。

TLB 结构如图所示

Windows 保护模式学习笔记 26

  • ATTR:属性
    在 10-10-12 分页模式下:ATTR = PDE 属性 & PTE 属性
    在 2-9-9-12 分页模式下:ATTR = PDPTE 属性 & PDE 属性 & PTE 属性

  • LRU:统计信息
    由于 TLB 的大小有限,因此当 TLB 被写满、又有新的地址即将写入时,TLB 就会根据统计信息来判断哪些地址是不常用的,从而将不常用的记录从 TLB 中移除。

PDE 和 PTE 中有个 G 标志位(当 PDE 为大页时,G 标志位才起作用),如果 G 位为 1,刷新 TLB 时将不会刷新PDE/PTE。G位为 1 的页,当 TLB 写满时,CPU 根据统计信息将不常用的地址废弃,保留最常用的地址。

TLB 在 X86 体系的 CPU 中的实际应用最早是从 Intel 的 486CPU 开始的,在 X86 体系的 CPU 中,一般都设有如下 4 组 TLB:

第一组:缓存一般页表(4K字节页面)的指令页表缓存(Instruction-TLB);
第二组:缓存一般页表(4K字节页面)的数据页表缓存(Data-TLB);
第三组:缓存大尺寸页表(2M/4M字节页面)的指令页表缓存(Instruction-TLB);
第四组:缓存大尺寸页表(2M/4M字节页面)的数据页表缓存(Data-TLB)

15|0中断与异常


15|1中断


中断通常是由 CPU 外部的输入输出设备(硬件)所触发的,供外部设备通知 CPU “有事情需要处理”,因此又叫中断请求(Interrupt Request)。中断请求的目的是希望 CPU 暂时停止执行当前正在执行的程序,转去执行中断请求所对应的中断处理例程(中断处理程序在哪由 IDT 表决定)。

假设没有中断这种机制,当一个的程序的代码为死循环时,其他的程序就没有机会执行了。所以,中断的本质是:改变 CPU 的执行路线

8086 有两条中断请求线:可屏蔽中断线(INTR,Interrupt Require)以及非屏蔽中断线(NMI,Non-Maskable Interrupt)。

首先是可屏蔽中断。在硬件级,可屏蔽中断是由一块专门的芯片来管理的,通常称为中断控制器。它负责分配中断资源和管理各个中断源发出的中断请求。为了便于标识各个中断请求,中断管理器通常用 IRQ(Interrupt Request)后面加上数字来表示不同的中断。比如:在 Windows 中,时钟中断的 IRQ 编号为 0,也就是 IRQ0

大多数操作系统时钟中断在 10-100MS 之间,Windows 系列为 10-20MS。Windows 时钟中断每隔 10~20MS 会向 CPU 发送一个请求,当 CPU 收到请求时,操作系统就会接管 CPU,指定 CPU 去执行一段代码,操作系统在这段代码里便有机会进行线程的切换。这样,即便一个程序进入死循环,操作系统依然有机会进行线程切换

当然,操作系统主要并不是通过时钟中断来进行线程切换,而只是有机会进行线程切换,这里只是举个例子。

时钟中断的 IRQ 编号为 0,所在位置为 IDT[0x30],其它中断 IRQ1~IRQ15 分别对应 IDT[0x31]~IDT[0x35]。

如果自己的程序执行时不希望 CPU 去处理这些中断,可以:

  1. 用 CLI 指令清空 EFLAG 寄存器中的 IF 位
  2. 用 STI 指令设置 EFLAG 寄存器中的 IF 位

另外,硬件中断与 IDT 表中的对应关系并非固定不变的

非可屏蔽中断在 IDT 表的中断号为 0x2,也就是 IDT[0x2],这个编号在 8086 中是固定的。当非可屏蔽中断产生时,CPU 在执行完当前指令后会里面进入中断处理程序。非可屏蔽中断不受 EFLAG 寄存器中 IF 位的影响,一旦发生,CPU 必须处理。

15|2异常


异常通常是 CPU 在执行指令时检测到的某些错误,比如除 0、访问无效页面等。

中断与异常的区别如下:

  • 中断来自于外部设备,是中断源(比如键盘)发起的,CPU是被动的
  • 异常来自于 CPU 本身,是 CPU 主动产生的
  • INT N 虽然被称为“软件中断”,但其本质是异常。所以 EFLAG 的 IF 位对 INT N 无效。

常见的异常处理程序如下

Windows 保护模式学习笔记 27

页错误:当我们访问一个线性地址,而这个线性地址指向的物理页是无效的,便会触发 CPU 异常,该异常位于 E 号门(IDT[0xE])
段错误:一旦段的运算发生异常时(如权限检查),便会走 D 号门(IDT[0xD])
除 0 错误:当除数为 0 时,会触发异常,这时走 0 号门(IDT[0x0])
双重错误:假设执行一个异常(如页错误)时又产生了一个错误,那么便会触发双重错误,这时走 8 号门(IDT[0x8])

还有一种常见的叫缺页异常的。一般有两种情况:

  1. 当 PDE/PTE 的 P=0 时会发生缺页异常
  2. 当 PDE/PTE 的属性为只读但程序试图写入时会发生缺页异常

一旦发生缺页异常,CPU 会执行 IDT 表中的 0xE 号中断处理程序,由操作系统来接管

Windows 保护模式学习笔记 28

分别来看两种情况的一个例子。首先是 P=0 时的情况:

当一个物理页是有效的时,其对应的 PDE 或 PTE 的 P 位必须为 1,表示该物理页存在于内存中。如果其他进程的物理页资源紧缺(内存不足),但当前进程的某个线性地址是有效的(指向一个有效的物理页),操作系统会将该物理页的内容保存到磁盘的页面文件中。然后将该物理页从当前进程的页表中解除映射,并将其挂到其他进程的页表中供其使用,随后将当前进程指向该物理页的 PDE 或 PTE 的 P 位设置为 0,表示该物理页已不在内存中。

当进程再次访问这个线性地址时,CPU 会发现 P 位为 0,触发 0xE 号中断(页错误),并进入中断处理程序(IDT[0xE])。在中断处理程序中,如果发现 P=0、转移位=0、原型位=0,而其他位有值,则说明该物理页的内容被保存到了页面文件中。操作系统会根据 PFN(Page Frame Number) 找到页面文件的编号,并将内容从页面文件读回内存中的一个物理页。最后,操作系统会更新 PDE 或 PTE,将 P 位重新设置为 1,并恢复线性地址到物理页的映射。

整个过程对于用户来说是完全透明的,用户并不知道发生了一个异常,只知道程序能够对地址进行正确的读写,但其实这个过程中可能有大量异常在发生。操作系统通过这种异常的方式节省大量物理页,当我们的程序在执行时,这种缺页异常时时刻刻在发生

然后就是属性为只读但是程序试图写入时的情况:

当一个线性地址的 PDE 或 PTE 属性为只读,但试图往里写时,CPU 检测到了这个异常,但 CPU 没有权利处理,便进入 0xE 号中断处理程序(IDT[0xE]),由操作系统来接管。操作系统检测出用户的操作确实是不合理的,便会返回一个错误(0xC0000005,内存访问失败)

16|0控制寄存器


控制寄存器有五个,分别是:Cr0 Cr1 Cr2 Cr3 Cr4。Cr1 由系统保留,Cr3 是页目录表基址,之前一直在用。所以现在看剩下几个

16|1Cr0


结构图如下

Windows 保护模式学习笔记 29

PE位:启用保护(Protecction Enable)标志
PE=1:保护模式
PE=0:实地址模式
这个标志仅开启段级保护,而没有启用分页机制
若要启用分页机制,那么PE和PG标志都要置位

PG位:分页机制标志
PG=1:开启了分页机制
PG=0:未开启分页机制
在开启这个标志位之前必须已经或者同时开启PE标志

PG=0且PE=0:处理器工作状态为实地址模式
PG=0且PE=1:处理器工作状态为没有开启分页机制的保护模式
PG=1且PE=0:不存在。在PE没有开启的情况下无法开启PG
PG=1且PE=1:处理器工作状态为开启了分页机制的保护模式

WP位:写保护(Write Proctect)标志
对于Intel 80486或以上的CPU,CR0的16位是写保护标志
当设置该标志时,处理器会禁止超级用户程序(例如特权级0的程序)向用户级只读页面执行写操作

当 CPL<3 的时候:

  1. 如果 WP=0 可以读写任意用户级物理页,只要线性地址有效
  2. 如果 WP=1 可以读取任意用户级物理页,但对于只读的物理页,则不能写

16|2Cr2


当 CPU 访问某个物理页时,如果对应的项的 P 位为 0,表示该物理页不在内存中,此时会触发缺页异常。缺页异常发生后,CPU 会将引起异常的线性地址存储到 CR2 寄存器 中,然后跳转到操作系统的异常处理程序进行处理。

如果该线性地址对应的页面内容已被保存到页面文件(P 位为 0,但页面有效),处理程序会从页面文件中读取数据,并将其加载到一个有效的物理页中。然后,更新 PDE 或 PTE,将 P 位设置为 1,并恢复线性地址到物理页的映射。在处理异常时,操作系统会记录程序原先执行到的位置(即引起异常的线性地址),以便异常处理结束后能够继续执行。CR2 寄存器的作用就是保存这个线性地址,确保异常处理程序能够准确找到异常发生的位置。

如果异常处理程序检测到用户访问的页面是一个未分配的页面(即无效的线性地址),则会报告一个异常,并终止程序的执行。

16|3Cr4


比较新的 CPU 会用到的,结构图如下

Windows 保护模式学习笔记 30

PAE:
PAE=1:2-9-9-12分页
PAE=0:10-10-12分页

PSE:

Windows 保护模式学习笔记 31

17|0PWT&PCD


PDE&PTE 的时候留了俩位没写干啥的,这里来说

首先了解 CPU 缓存。CPU 缓存是位于 CPU 与物理内存之间的临时存储器,它的容量比内存小的多,但是交换速度(读写速度)比内存要快得多。CPU 缓存可以做的很大,有几 K、几十 K、几百 K、甚至上 M,这决定于 CPU 的版本。

之前我们提到过 TLB,也是一种缓存。TLB 存储了线性地址与物理地址之间的对应关系,而 CPU 缓存存储了物理地址与内容之间的对应关系。有了 CPU 缓存,当 CPU 再去查找/读取某一个线性地址对应的物理页时,就可以先查 TLB,找到它的物理地址,再找 CPU 缓存,找到它的内容。这样就非常快。可以说,CPU 缓存的大小决定了CPU的执行速度

PWT(Page Write Through,页写直达)
PWT=1:写 Cache 的时候也要将数据写入内存中
PWT=0:写 Cache 的时候就只是写 Cache,是否要映射到内存由 CPU 缓存控制器自己决定

PCD(Page Cache Disable,页缓存禁用)
PCD=1:禁止某个页写入缓存(直接写入内存)
比如:做页表用的页,已经存储在 TLB 中,可能就不需要再做缓存,而它的 PCD 一定为1

18|0总结


在学完本科课程后再来看这些东西,有一种朦胧的熟悉感,大部分东西都是课程中有学到过的,只是上课的时候纯纯的是为了应付课程分学的,完全没认真,现在再来过一次也有助于构建一下整体的知识框架,也挺好的。

lzyddf 师傅的 blog 感觉都挺有用的,打算一路学完驱动开发那再停下,然后自己再扩展着学学驱动开发。速通完师傅的 blog 后还要去混一下毕设的中期检查,少说在 3 月中之前得做的差不多,要加快进度了(


__EOF__

作  者iPlayForSG
出  处https://www.cnblogs.com/Here-is-SG/p/18734496
关于博主:编程路上的小学生,热爱技术,喜欢专研。评论和私信会在第一时间回复。或者直接私信我。
版权声明:署名 - 非商业性使用 - 禁止演绎,协议普通文本 | 协议法律文本
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!

posted @   iPlayForSG  阅读(14)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
历史上的今天:
2023-02-24 SMC的实现方法
点击右上角即可分享
微信分享提示