Windows

保护模式下的内存管理:

注:主要参考来自 内核第二讲,内存保护的实现,以及知识简介,局部描述符,全局描述符. - iBinary - 博客园 (cnblogs.com)

Win32汇编–Windows 的内存管理机制 - 木屐 - 博客园 (cnblogs.com) (来源小甲鱼)

80386指的是32位系统

三种模式

实模式: 指的是操作系统在启动的时候,这时候访问的内存都是实际的物理内存.也就是在这个时机,系统会在实模式下填写全局描述符表 GDT

保护模式: 当各种表填写好了,那么我们的内存也被保护了.这个是否我们的进程就不会直接访问物理地址了.进而产生了保护行为,我们的内存就有了 可读 可写,可执行一说了.

虚拟86模式: 操作系统启动的是否,运行的都是实际的16位汇编.那么现在我们假设有一个16位程序要启动.那么修改了我们物理地址的内存,那么保护模式不就没用了.所以为了防止这一情况的产生,操作系统做了一个虚拟86模式,也就是说可以运行16位汇编程序.

保护模式如何保护内存

操作系统启动的会从实模式启动,然后会切换到保护模式,此时操作系统会通过做表,然后通过查表,进而判断内存是否可以访问或者读写.

在16位汇编中,我们可以通过段+偏移的方式来寻找内存.管理内存.那么我们现在要对内存做管理.那么就要分段了.

什么是分段管理?

基本概念:

逻辑地址: 逻辑地址指的是 段 + 偏移的方式,我们程序中每一行代码都是逻辑地址.

线性地址: 线性地址我们可以理解为逻辑上连续的物理地址.

物理地址: 物理地址就是内存条的地址,也就是我们说的实际地址.

1721575906313

段+偏移 查表,会查到线性地址,每个段+偏移都会查到一块物理地址.

00401000~00402000,在逻辑上我们看的是连续的,但是通过查表转换为物理地址的是否则不是连续的. (有可能通过查表,得出的物理地址不是连续的.但是逻辑地址是连续的. )

1721389509679

为什么要查表得到物理地址?
进程间的数据肯定不是共享的,虽然逻辑地址可能都是0x400000但是物理地址不是一样的,这样就可以实现进程隔离

表格是如何做

最基本的就是希望通过这个表,能够查出:

1.段起始地址

2.大小

3.结束地址.

4.当前内存的保护属性.

1721379218651

段描述符(Segment Descriptor)

段描述符分为全局描述符表(GDT)和局部描述符表(LDT),并且它们的大小都是64位。

段描述符是一个8字节(64位)的结构体(被拆开成两个部分),包含段的各种信息。其结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Segment_Descriptor
{
unsigned int Segment_Limit_Low16 : 16;
unsigned int Base_Address_Low16 : 16;
unsigned int Base_Address_Mid8 : 8;
unsigned int Type : 4;
unsigned int s : 1;
unsigned int DPL : 1;
unsigned int P : 1;
unsigned int Segment_Limit_High4 : 4;
unsigned int AVL : 1;
unsigned int Remain : 1;
unsigned int D_B : 1;
unsigned int G : 1;
unsigned int Base_Address_High8 : 8;
};

低 4 字节(32 位)

  1. Base 15:00 (0-15位):
    • 段的基地址的低 16 位。
  2. Limit 15:00 (16-31位):
    • 段限长的低 16 位。

高 4 字节(32 位)

  1. Base 23:16 (32-39位):
    • 段的基地址的中间 8 位。
  2. Type (40-43位):
    • 描述符类型字段,包含了段类型和访问权限等信息。
    • 常见的值:
      • 代码段(执行段):可执行、不可写、可读。
      • 数据段:不可执行、可写、可读。
    • 各位的作用:
      • 第 40 位:是否访问过。
      • 第 41 位:可读/写标志。
      • 第 42 位:是否向下扩展(数据段)/是否一致性段(代码段)。
      • 第 43 位:可执行标志(1 为代码段,0 为数据段)。
  3. S (44位):
    • 描述符类型标志,0 为系统描述符(如任务状态段 TSS),1 为代码或数据段。
  4. DPL (45-46位):
    • 特权级别(Descriptor Privilege Level),用来确定访问该段所需的最低特权级别。取值范围为 0(最高特权级)到 3(最低特权级)。
  5. P (47位):
    • 存在标志(Present),1 表示段存在于内存中。
  6. Limit 19:16 (48-51位):
    • 段限长的高 4 位。
  7. AVL (52位):
    • 系统保留位,可供操作系统使用。
  8. L (53位):
    • 64 位代码段标志(Long Mode),32 位模式下应为 0。
  9. D/B (54位):
    • 默认操作大小标志(D 位),对于代码段,1 表示 32 位操作(即默认操作是 32 位的),0 表示 16 位操作。对于堆栈段,1 表示默认堆栈指针为 ESP,0 表示默认堆栈指针为 SP。
    • 有时也称为 Big 位(B 位)。
  10. G (55位):
    • 粒度标志(Granularity),0 表示段限长的单位为字节,1 表示单位为 4KB。
  11. Base 31:24 (56-63位):
    • 段的基地址的高 8 位。

TYPE位展开

1721548239971

处理器在访问段时,会根据Type字段和DPL字段检查访问权限。例如:

  • 若尝试写操作但RW位为0(只读),则访问会被拒绝。
  • 当前代码段的特权级别(CPL)和目标段的DPL也会进行比较,确定是否允许访问。

如何让一个段不可读,不可写,不可执行?很简单,把p标志位设置为0即可

如何让一个段可读可写可执行?
如果想要一块内存可读可执行,可写.那么需要建立两个表.分别让TYPE为 = 2,然后 = 8即可.
查询Data段的权限,利用的寄存器是DS去做索引(在下面段选择子会说为什么是寄存器做索引)
查询Code段的权限,利用的寄存器是CS去做索引
所以可以做两张表,一个描述Data,一个表述Code

查询Code段的权限,利用的寄存器是CS去做索引

内存映射与共享

内存映射(Memory Mapping)是将虚拟地址空间的一部分映射到物理内存的一部分。共享内存(Shared Memory)是指不同进程之间共享同一块物理内存。内存映射和共享通常通过段描述符和页表来实现。

内存映射的基本过程

  1. 查找空闲段描述符
    • 系统会在段描述符表(如 GDT 或 LDT)中查找一个空闲的段描述符。
  2. 填充段描述符
    • 段描述符按照一定的格式填好,包括基地址(Base Address)、段限长(Limit)、段类型(Type)、特权级别(DPL)等。
  3. 设置段描述符的 P 标志
    • 段描述符中的 P 标志(Present 位)表示段是否存在。P 标志设置为 1 表示段存在,设置为 0 表示段不存在。如果 P 标志为 0,该段描述符不可用。

如果是恶意程序,直接找到这个软件的段描述符的p,全改为0,这样段全部无效,软件直接崩溃

线性地址的范围怎么计算

base Address + limit 则可以形成一块内存区域.

1721396908919

假设段A的基地址是 00012345h,段界限为5678h,并且是以字节为单位(G = 0),那么

段基地址+ 段界限 = 000179BDh, 那么在00012345h ~ 000179BDH则是段A的线性地址

计算就是 段基地址 + 段界限 (也可以理解为,起始地址+一块区域.则这块区域就是线性地址)

如果属性中 G位 = 1,那么就是按照4K去计算.那么就是这样算0012345h + (5678 *4K) + 0FFFh = 0568b344h
那么段A的线性地址就是从 0012345h ~ 0568b344h

段选择子,进行查表

段寄存器的作用:

在保护模式下,段寄存器就不再有用了呢?
答案是否定的!实际上段寄存器更有用了,虽然在寻址上不再有分段的限制问题,但在保护模式下,一个地址空间是否可以被写入,可以被多少优先级的代码写入,是不是允许执行等涉及保护的问题就出来了?

要解决这些问题,必须对一个地址空间定义一些安全上的属性。段寄存器这时就派上了用途,不妨将这些属性存放在段寄存器中!

80386的段寄存器仍然是16位的,无法放下保护模式下64位的段描述符。如何解决这个新的问题呢?解决办法是把所有段的段描述符顺序放在内存中的指定位置,组成一个段描述符表(Descriptor Table)

而段寄存器中的16位用来做索引信息,指定这个段的属性用段描述符表中的第几个描述符来表示。

这时,段寄存器中的信息不再是段地址了,而是段选择器(Segment Selector)。可以通过它在段描述符表中“选择”一个项目以得到段的全部信息。

段选择子(Segment Selector)

段选择子是一个16位的值,用来选择一个段描述符。段选择子的结构如下:

  • 索引(Index): 13位,用于在GDT(全局描述符表)或LDT(局部描述符表)中索引段描述符。
  • TI位(Table Indicator): 1位,指示段选择子是指向GDT(0)还是LDT(1)。
  • RPL(Request Privilege Level): 2位,表示请求特权级。

描述符,全局描述符,局部描述符

80386中引入了两个新的寄存器来管理段描述符表。一个是48位的全局描述符表寄存器GDTR,一个是16位的局部描述符表寄存器LDTR。那么,为什么有两个描述符表寄存器呢?

GDTR指向的描述符表为全局描述符表GDT(Global Descriptor Table)。它包含系统中所有任务都可用的段描述符,通常包含描述操作系统所使用的代码段、数据段和堆栈段的描述符及各任务的LDT段等;全局描述符表只有一个。

LDTR则指向局部描述符表LDT(Local Descriptor Table)。80386处理器设计成每个任务都有一个独立的LDT。它包含有每个任务私有的代码段、数据段和堆栈段的描述符,也包含该任务所使用的一些门描述符,如任务门和调用门描述符等。

总结就是 全局描述符是全部进程共同享有的,局部描述符是每一个进程独有的。

全局描述符表(GDT)

  • 共享性质:GDT 是所有进程共同共享的表格,通常由操作系统在系统启动时初始化。
  • 描述符类型:GDT 包含系统段描述符(如任务状态段 TSS)、代码段描述符、数据段描述符等。
  • 用途:主要用于定义内核和操作系统核心部分的段描述符。进程间共享的段(如内核段)也定义在 GDT 中。

问:GDT最多多少项?

局部描述符表(LDT)

  • 独有性质:每个进程可以有一个独立的 LDT,这些 LDT 是进程私有的。
  • 描述符类型:LDT 通常包含用户模式下使用的段描述符,例如用户进程的代码段和数据段描述符。
  • 用途:主要用于定义特定进程的段,这些段在进程间不共享,提供了每个进程独立的内存段。

GDT 和 LDT 的比较

  1. 作用范围
    • GDT:系统范围内有效,定义了全局段,所有进程都可以访问。
    • LDT:进程范围内有效,定义了进程特有的段,仅该进程可访问。
  2. 创建和管理
    • GDT:由操作系统在启动时创建并初始化,通常不会频繁修改。
    • LDT:由操作系统为每个进程创建和管理,进程切换时切换 LDT。
  3. 访问控制
    • GDT:通常包含操作系统和内核段描述符,具有较高的特权级别。
    • LDT:包含用户进程的段描述符,具有较低的特权级别,主要用于用户模式下的内存访问。

其中LDT = A进程,那么就执行A进程的操作.切换进程,并且保存进程的各种信息.以及各种表. 如果我们换成了B进程,那么就会切换到B进程.

CPU从逻辑地址获取物理地址的过程梳理

例如运行一个EXE,逻辑地址0xAC2710里面存的值是0x55,那么它真正的物理地址是多少呢?
1721551741368

因为这是代码段,所以段选择子是CS,这里是0x1B
1721551972416

解析段选择子

0x001B:0000 0000 0001 1011

段选择子的结构如下:

  • 索引(Index): 13位,用于在GDT(全局描述符表)或LDT(局部描述符表)中索引段描述符。
  • TI位(Table Indicator): 1位,指示段选择子是指向GDT(0)还是LDT(1)。
  • RPL(Request Privilege Level): 2位,表示请求特权级。

所以
Index位:0000 0000 0001 1 换成数字就是3,下标为3
TI位:0
RPL位:11

段选择子中的RPL和段描述符的区别

RPL 表示当前请求者的权限级别,可能会根据上下文变化。例如,用户态代码(RPL = 3)请求访问一个段时,其 RPL 会反映用户态的特权级别。

DPL 固定表示段的访问要求。代码段、数据段、系统段等的 DPL 决定了什么特权级别的代码可以访问这些段。

解析GDT表

于是CPU现在就去找TI位指定的GDT表

我们可以用Windbg查询GDT表

1721559588264

可以看到GDT指向的地址是0x80b93800

查看这个地址,上面根据段选择子得到的Index为3,也就是第三项,因为段描述符每一项是64位,也就是8个字节,因此第三项是箭头指向的位置

1721559969296

接下来就是解析一下这个GDT:00cffb00 0000ffff

**1721379218651**

  • 基地址(Base Address): 分为基地址低位(Base Low)、基地址中位(Base Middle)、基地址高位(Base High),总共32位。
  • 段限长(Limit): 分为段限长低位(Limit Low)和段限长高位(Limit High),总共20位。
  • 类型(Type): 4位,用于指定段的类型和访问权限。
  • S位(Descriptor Type): 描述符类型(系统段或代码/数据段)。系统段负责切换线程,API调用,和内存没关系,但是数据段(也叫存储段),就是负责内存权限的
  • DPL(Descriptor Privilege Level): 描述符的特权级别。(用来判断是3环还是0环)决定哪一环可以用
  • P位(Present): 段是否存在。1表示有效,0表示无效
  • AVL(Available for system use): 系统可用位。
  • L位(Long mode): 用于64位模式的指示位。
  • DB位(Default operation size / Big): 指示默认操作大小(16位或32位)。0表示16位段,1表示32位段
  • G位(Granularity): 段限长的粒度(字节或4KB)
    Base有32bit,但是Limit只有20bit,颗粒度设置为4KB,表示的地址最大为(2^20-1) * 4k 这样表示0x00000000~0xfffff000,则可以节省表的大小
    limit计算公式 2^20 * 4k +0xfff

解析结果如下:
Segment Limit:0xffffff
Base Address:0x00000000
Type:0xb
s:0x1
DPL:0x3(3环可用,和RPL匹配)
p:0x1
AVL:0x0
L:0x0
D/B:1
G:1(说明粒度为1)

limit=(0xffffff-1) * 4k + 0xffff = 4G,也就是在 Base Address 开始之后的4G的空间里面,属性是Type

Type为0xB

1721548239971

对照着表得到的属性是Execute/Read,accessed

逻辑地址=>物理地址 = Base_Address +逻辑地址0xAC2710
所以当前举的例子的物理地址就是0xAC2710(在未开启分页机制,线性地址就是物理地址)

1721575906313

GDTR全局描述符表寄存器

GDTR(全局描述符表寄存器,Global Descriptor Table Register)是一个48位寄存器,用于存储全局描述符表(GDT)的基地址和限制(limit)。它的组成和作用如下:

组成

  1. 基地址(Base Address)
    • 32位(32-bit):存储GDT的基地址,即GDT在内存中的起始地址。
    • 用于指示全局描述符表在内存中的起始位置。
  2. 限制(Limit)
    • 16位(16-bit):存储GDT的大小,以字节为单位。
    • 限制值表示GDT的最大字节偏移量,从基地址开始的范围。
    • 项的数目为:(gdtr.limit+1)/8

用Windbg发现gdtr寄存器只有32位?

但是其实这两个是同一个寄存器,gdtr是指Base_Address,gdtl指的是Limit

1721572359087

特权指令:

LGDT(Load Global Descriptor Table Register):该指令用来加载GDTR的值,设置全局描述符表的位置和长度。

SGDT(Store Global Descriptor Table Register):该指令用来存储当前GDTR的值,通常用于调试和操作系统初始化过程。

在Ring3是可以用SGDT这个指令来拿当前GDTR的信息的,但是不能用LGDT,也就是在三环,只能读取但是不能设置(要是在三环能设置那还得了)

1721638423045

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
using namespace std;

struct GDTR
{
unsigned short Limit;
unsigned int Base_Address;
};

int main()
{
struct GDTR gdtr = { 0 };
__asm
{
SGDT [gdtr]
}
cout << hex << "0x" << gdtr.Base_Address << endl;
cout << hex << "0x" << gdtr.Limit << endl;
return 0;
}

但是64位不能内联汇编,用不了特权指令咋整

可以用联合编译,或者用_sgdt这些内部函数

多核CPU

但是有一个问题,就是我们发现,打印出来的Base_Address有时候会不一样,这是为什么?

这是由于我们的处理器是多核的,所以系统会为每一个Cpu都创建一个表,所以Base_Address会不一样

1721639021219

确定核数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//Ring3
#include <windows.h>
#include <iostream>
int main()
{
SYSTEM_INFO sysinfo;
GetSystemInfo(&sysinfo);
int numCPU = sysinfo.dwNumberOfProcessors;
std::cout << "Number of cores: " << numCPU << std::endl;
return 0;
}

//Ring0
#include <ntddk.h>

extern "C"
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
UNREFERENCED_PARAMETER(DriverObject);
UNREFERENCED_PARAMETER(RegistryPath);

ULONG numCPU = KeQueryActiveProcessorCount(NULL);
DbgPrint("Number of cores: %lu\n", numCPU);

return STATUS_SUCCESS;
}

在指定CPU上运行代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
//Ring3
#include <windows.h>
#include <iostream>

DWORD WINAPI ThreadFunc(LPVOID lpParam)
{
// 线程要执行的代码
std::cout << "Running on CPU: " << GetCurrentProcessorNumber() << std::endl;
return 0;
}

int main()
{
HANDLE hThread;
DWORD threadId;
DWORD_PTR affinityMask = (1 << 1) | (1 << 3) | (1 << 4); // 绑定到CPU 1, 3, 4

hThread = CreateThread(NULL, 0, ThreadFunc, NULL, 0, &threadId);
if (hThread == NULL)
{
std::cerr << "CreateThread failed" << std::endl;
return 1;
}

SetThreadAffinityMask(hThread, affinityMask);

WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);

return 0;
}



//Ring0
#include <ntddk.h>

VOID DriverUnload(PDRIVER_OBJECT DriverObject)
{
UNREFERENCED_PARAMETER(DriverObject);
DbgPrint("Driver Unloaded\n");
}

VOID WorkItemRoutine(PDEVICE_OBJECT DeviceObject, PVOID Context)
{
UNREFERENCED_PARAMETER(DeviceObject);
UNREFERENCED_PARAMETER(Context);

KAFFINITY affinityMask = (1 << 1) | (1 << 3) | (1 << 4); // 绑定到CPU 1, 3, 4
KeSetSystemAffinityThread(affinityMask);

DbgPrint("Running on CPU: %lu\n", KeGetCurrentProcessorNumber());

KeRevertToUserAffinityThread();
}

extern "C"
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
UNREFERENCED_PARAMETER(RegistryPath);

DriverObject->DriverUnload = DriverUnload;

PIO_WORKITEM workItem = IoAllocateWorkItem(DriverObject->DeviceObject);
IoQueueWorkItem(workItem, WorkItemRoutine, DelayedWorkQueue, NULL);

DbgPrint("Driver Loaded\n");

return STATUS_SUCCESS;
}

分段机制已经被放弃

分段的缺点:

  1. 外部碎片:由于段的大小不固定,可能会导致外部碎片问题。
  2. 复杂性:分段机制相对复杂,需要段描述符表和相应的硬件支持。

分页机制 (Paging)

分页机制是一种内存管理方案,它将内存划分为固定大小的页(如4KB),并将这些页映射到物理内存中的页框。页表存储了虚拟地址到物理地址的映射关系。

分页的优点:

  1. 消除外部碎片:由于页的大小固定,不会出现外部碎片问题。
  2. 简化内存管理:操作系统可以更容易地管理内存,因为所有页的大小相同。
  3. 虚拟内存支持:可以将不常用的页换出到硬盘,提高内存利用率。

用Windbg查看,发现4GB空间Base全为0,Limit为0xffffffff
说明逻辑地址直接等于了线性地址,变相放弃了分段机制!

1721575535882

下一章详细说分页机制