分段机制

大致描述

分段机制的逻辑是把内存按照使用途径划分成不同的段:

  1. 代码段
  2. 数据段
  3. 栈段

对应经典的段寄存器:

  1. CS CodeSegment
  2. DS DataSegment
  3. SS StackSegment
  4. ES FS GS …

CPU 不直接把程序给出的地址视为物理地址,而是将其表示为:逻辑地址 = 段选择子:段内偏移

假设一条指令需要读取数据:mov eax, [0x1234] 并假设当前 DS = 0x23,那么 CPU 使用的逻辑地址可以表示为 DS:0x1234

而逻辑地址会根据下面这条链路解析为真正的物理地址:通过分段机制解析为线性地址 -> 通过分页机制解析为物理地址

段寄存器

段寄存器中保存的通常不是段基址,而是一个十六位段选择子:

image.png

CPU 根据 Index 从描述符表中找到对应的段描述符

GDT 与 LDT

Wiki: 全局描述符表 (GDT) 是一个从 Intel x86-系列处理器 80286 开始用于界定不同内存区域的特征的数据结构。 全局描述表位于内存中。全局描述表的条目描述及规定了不同内存分区的各种特征,包括基地址、大小和访问等特权如可执行和可写等。 在 Intel 的术语中,这些内存区域被称为段 。

CPU 通过 GDTR 寄存器记录 GDT 的地址和大小。而 LDT 现在基本很少使用。

GDT 的是提供内存保护。在 80286 之前的处理器中只有实模式,所有程序都可访问任意内存。GDT 是保护模式下限制非法内存访问的一种方式。

GDT 结构示意:

image.png

  • Base:段基址
  • Limit:段界限
  • Type:代码段、数据段等
  • DPL:描述符特权级
  • P:段是否存在
  • G:界限粒度
  • D/B:默认操作数或栈宽度
  • L:是否为 64 位代码段

特权级机制

  1. CPL(Current)
  2. DPL(Descriptor)
  3. RPL(Request)

有效特权级必须满足段描述符规定的访问条件。数值越小,权限越高,因此不能把 Ring 3 的代码简单伪装成 Ring 0。

不过,现代 Windows 的内核隔离主要还会依赖页表中的 User/Supervisor、读写和 NX 等权限。

x64 下的分段

在 64 位模式下,传统分段被大幅弱化。对 CS、DS、ES、SS 而言:

  • 大部分段基址被当作 0
  • 大部分段界限检查被忽略
  • 线性地址主要由 64 位偏移决定
  • CS 仍影响执行模式和特权级

逻辑上可以近似理解为:

线性地址 ≈ 64 位虚拟地址,但是 FS 和 GS 是重要例外。它们仍然可以使用非零基址:线性地址 = FS/GS 基址 + 偏移,总体来说传统分段机制似乎没有那么有用了,大致了解一下应该就差不多了。

分页机制

概述

分页机制把内存划分为固定大小的块:

  • 虚拟页(Page):虚拟地址空间中的块
  • 物理页框(Page Frame):物理内存中的块

Windows 中常见的页面大小是 4 Kib = 0x1000 字节

分页可以帮助我们实现这些功能:

  1. 进程隔离
  2. 内存保护
  3. 非连续映射
  4. 按需分配
  5. 内存共享

在 x64 系统中,页表通常采用多级结构。

一个使用 4 KiB 页面的虚拟地址可以拆成:

63 48 47 39 38 30
+-----------------------+-----------+-----------+
| 符号扩展区域 | PML4 索引 | PDPT 索引 |
+-----------------------+-----------+-----------+

29 21 20 12 11 0
+-----------+-----------+----------------------+
| PD 索引 | PT 索引 | 页内偏移 |
+-----------+-----------+----------------------+

每一级有 2^9 = 512 个表项

Windows 页面类型

  • Private Page 进程私有的页面,常见于:
    • 堆
    • 栈
    • VirtualAlloc
  • Mapped Page 来自内存映射文件或者共享 Section:
  • Image Page 由 EXE 或 DLL 映射形成,通常根据 PE 节设置权限:
    • .text 可执行,通常只读
    • .data 可读写
    • .rdata 只读

Windows 页面状态

虚拟地址通常存在三种状态 Free / Reserved / Committed。
同时还可以通过 Resident / non-resident 描述当前虚拟地址是否驻留在物理内存中。

Free: MEM_FREE 表示这段虚拟地址范围尚未被进程使用。用来描述进程地址空间中未被使用的虚拟内存编号。

  • 虚拟地址范围:存在
  • 地址归属:未分配
  • 提交存储:没有
  • 物理页面:没有
  • 程序能否访问:不能
void *p = VirtualAlloc(
	NULL,
    64 * 1024,
    MEM_RESERVE,
    PAGE_READWRITE
);

成功后,这段地址就能够从 Free → Reserved 状态。

Reserved: MEM_RESERVE 表示 Windows 已经把这段虚拟地址范围留给当前分配,其他普通分配不能再占用它,但尚未对其中的页面作出提交承诺。

  • 虚拟地址范围:已占用
  • 提交存储:没有
  • 物理页面:通常没有
  • 程序能否正常读写:不能

Reserved 可以帮助操作系统在需要大块内存的时候先记录内存所有的状态,暂时只使用其中的少量页面。

Committed: MEM_COMMIT 表示 Windows 已经承诺,在程序需要页面内容时,可以为它提供相应的存储支持。

char *p = VirtualAlloc(
	NULL,
	100 * 1024 * 1024,
    MEM_RESERVE | MEM_COMMIT,
    PAGE_READWRITE
);

调用成功后获得 100 MiB 已提交虚拟空间,但如果程序尚未访问这些页面,实际驻留的物理内存通常远小于 100 MiB。

借助 VMmap 查看单个进程的进程状态和页面统计

首先,我们可以借助 Windows api 来帮助我们分配内存,其中比较重要的函数就是 VirtualAlloc。

VirtualAlloc(
    nullptr,
    64 * 1024 * 1024,
    MEM_RESERVE | MEM_COMMIT,
    PAGE_READWRITE
);

对应关系:

  • 64 * 1024 * 1024 65,536 K
  • MEM_RESERVE 占用连续虚拟地址范围,即我们分配的内存是连续的。
  • MEM_COMMIT Committed = 65,536 K
  • PAGE_READWRITE Protection = Read/Write
  • nullptr Windows 自动选择地址

这个内存地址是虚拟内存地址,而不是物理内存地址。同时我们还能看到通过 ASLR 机制载入的 DLL 动态链接库、堆地址、栈地址、未使用地址等等。

|425