跳转至

地址空间与分页

程序使用地址访问代码、栈和堆,但物理内存由许多进程共享,位置还会随运行变化。虚拟地址空间把“程序看到的地址”与“当前存放数据的物理页框”分开。

为什么直接使用物理地址不够

若程序中的地址就是物理地址,会遇到:

  • 两个程序可能使用同一地址范围而冲突。
  • 程序装入位置改变后,代码中的地址要重定位。
  • 一个程序可以读写另一个程序或内核的内存。
  • 物理内存不足时,很难只保留当前需要的部分。

虚拟内存通过每个进程独立的映射解决这些问题。相同虚拟地址在不同进程中可以指向不同物理页。

地址空间的直观布局

典型进程地址空间包含:

高地址
  内核保留区域或不可访问区域
  用户栈(通常向低地址增长)
  共享库与内存映射区
  堆(通常向高地址增长)
  静态数据
  程序代码
低地址

这只是概念布局。地址随机化、体系结构、可执行格式和运行时会改变实际位置。空洞并不等于占用同等大小的物理内存;虚拟空间可以稀疏。

历史路径:重定位、分段与分页

基址与界限

进程产生逻辑地址 \(x\),硬件检查 \(0\le x<limit\),再加基址:

\[ physical=base+x. \]

这提供简单重定位和连续区域保护,但每个进程需要一大块连续物理内存,容易产生外部碎片,也不方便让代码、堆和栈独立增长。

分段

分段把地址空间按代码、数据、栈等逻辑区域拆分,每段有独立基址、界限和权限。它符合程序结构,也方便共享只读代码。

问题是段大小可变,物理内存分配仍会产生外部碎片;段增长还可能需要搬迁。现代通用系统多以分页作为主机制,但“区域具有不同语义和权限”的思想仍存在于虚拟内存区域中。

分页

分页把虚拟空间切成固定大小的虚拟页,把物理内存切成同样大小的页框。虚拟页可以映射到任意页框,无需物理连续。

若页大小为 \(P=2^p\) 字节,虚拟地址可分为:

虚拟页号 | 页内偏移 p 位

地址翻译只替换页号,页内偏移保持不变。

最小地址翻译例子

假设页大小为 4096 字节,虚拟地址为 13,000:

\[ VPN=\left\lfloor\frac{13000}{4096}\right\rfloor=3, \]
\[ offset=13000\bmod4096=712. \]

若页表把虚拟页 3 映射到物理页框 20,则:

\[ physical=20\times4096+712=82632. \]

程序只使用 13,000,硬件和操作系统共同维护映射到 82,632 的关系。

页表项包含什么

页表项不仅保存物理页框号,还常包含:

  • present/valid:映射当前是否有效。
  • readable、writable、executable:访问权限。
  • user/supervisor:用户态能否访问。
  • accessed/reference:近期是否访问过。
  • dirty:映射后是否写过。
  • cache policy、global、copy-on-write 等架构或系统相关状态。

权限按页实施,使代码页可读可执行但不可写,数据页可读写但不可执行。W^X 思想避免同一页同时可写又可执行,减少代码注入风险,但 JIT 等场景需要受控切换或双映射。

内部碎片

固定页消除了进程分配的外部碎片,却可能在最后一页浪费空间。若对象大小随机分布且不考虑其他因素,平均内部浪费常粗略估为半页:

\[ E[waste]\approx\frac{P}{2}. \]

这只是直觉模型。实际映射共享、文件尾页、分配器和大页都会改变浪费情况。

按需分页

建立虚拟区域时不必立即分配所有物理页。首次访问尚未驻留的合法页面会触发缺页异常,内核再分配零页、读取文件或从交换区换入。

优点:

  • 程序启动更快,只装入真正访问的页面。
  • 稀疏大地址空间无需占用同等物理内存。
  • 内存可在活跃工作集之间动态分配。

代价是首次访问延迟不可预测;内存紧张时还可能发生回收和磁盘 I/O。

共享页面

不同进程的虚拟页可映射到同一物理页:

  • 多个进程共享只读库代码。
  • 父子进程在 fork 后写时复制。
  • 进程通过共享内存通信。
  • 文件页同时被页缓存和进程映射引用。

共享提高内存效率,但写权限和一致性必须明确。共享可写页上的普通数据访问需要同步,和同一进程多线程共享内存一样。

写时复制

fork 后父子进程最初共享物理页,页表将其标记为只读和写时复制。任一方写入时:

  1. 触发写保护缺页。
  2. 内核确认这是合法 COW 页面。
  3. 分配新页框并复制原内容。
  4. 更新写入方页表为可写新页。
  5. 重试写指令。

若双方都不写,复制永不发生。代价是首次写入出现缺页和复制延迟;大进程 fork 还需复制或共享页表结构,并非完全免费。

mmap 与文件映射

文件映射把文件的一段映射进虚拟地址空间,应用通过普通加载/存储访问。首次访问可能缺页并从文件读取,写入可能进入页缓存,最终按映射类型和刷写策略持久化。

void *p = mmap(NULL, length, PROT_READ, MAP_PRIVATE, fd, 0);

MAP_PRIVATE 的写入通常采用写时复制,不回写原文件;MAP_SHARED 则建立共享文件映射,但“内存已经修改”不等于“数据已经持久化到设备”,还需理解同步与刷盘语义。

大页

增大页大小会减少页表项数量,并提高 TLB 单项覆盖范围:

\[ TLB\ coverage=N_{entries}\times P. \]

代价包括更大内部碎片、更高缺页搬运成本、内存整理需求和更粗的保护粒度。大页适合大而连续的工作集,不一定适合大量小映射。

地址随机化与保护

地址空间布局随机化改变栈、堆、库和可执行映像位置,使攻击者难以预测目标地址。它增加利用难度,却不是内存安全:信息泄漏可能暴露布局,越界写的根因仍存在。

页级不可执行、只读和用户/内核权限提供基础保护。更细对象边界通常需要语言、编译器、运行时或额外硬件机制配合。

与分配器的区别

应用调用 malloc 获得的是进程虚拟空间中的一段可用区域。用户态分配器可能先从已有 arena 切块,未必每次都发系统调用;只有需要扩展或归还大块虚拟区域时,才通过 brkmmap 等接口联系内核。

内核物理页分配器管理页框,slab 类分配器再从页中提供小型内核对象。不同层都叫“内存分配”,但对象、碎片和同步问题不同。

自测

  1. 两个进程能否使用相同虚拟地址保存不同数据?
  2. 分页消除了哪类碎片,又引入哪类碎片?
  3. fork 的写时复制为什么要先把共享页设为只读?
  4. 大页怎样改善 TLB 覆盖,又有什么代价?
  5. mmap 写入成功是否表示数据已经落到非易失设备?
参考思路

每个地址空间的页表不同,所以可以。分页避免可变连续分配的外部碎片,但有页内碎片。只读写保护让首次写入可被硬件捕获。大页减少翻译项,代价是碎片和搬运粒度。内存修改与持久化是不同阶段。