Contents

内存管理:内存分配

本文本文参考OSDev Wiki上关于内存管理单元(MMU)的介绍,原文地址:https://wiki.osdev.org/Memory_Management_Unit

MMU(内存管理单元)是许多计算机系统中的一个关键组件,用于执行内存地址转换内存保护以及各架构所特有的其它内存管理功能。


地址转换(Translation)

MMU 为计算机提供的主要服务是内存地址转换。内存地址转换是指将**虚拟地址(virtual address)转换为物理地址(physical address)**的过程。我们可以说虚拟地址被映射(mapped)到物理地址。通过这种机制,我们可以按照自己的方式构建内存模型,也就是说,可以重新组织内存呈现给程序的方式。

例如,在构建 Higher Half Kernel(高半区内核) 时就使用了这种技术。内核最初被加载在物理地址 x,但在启用分页后,MMU 被指示将物理位置 x 映射到虚拟地址 0xC0000000。这样便产生了一种效果:内核看起来就像位于 0xC0000000


内存保护(Protection)

由于我们可以让内存呈现出任意我们希望的样子,因此可以让每个进程都“以为”自己是系统中唯一的进程。此外,进程只能访问属于它自己的那部分虚拟内存,因此无法修改或读取其他应用的内存。这意味着,即便某个应用程序崩溃,它也只会影响自身,而不会破坏系统中其他程序的运行。


当代体系结构中的内存管理单元与虚拟内存系统论述

这是一份非常简要的概述,介绍了使用虚拟地址空间所涉及的理论。本文并不专注于某一种架构,而是试图以一种通用方式对带有 MMU 的 CPU 进行建模。


虚拟内存系统的一般论述

通常,一个芯片组(主板)会具有 N 字节的物理内存。物理内存是“真实”的内存,应该对所有处理器全局可见。在正常运行时,或者更准确地说,当 CPU 在 未启用分页内存管理单元(PMMU) 的情况下运行时,CPU 遇到的任何地址都会绕过(P)MMU,直接通过地址总线访问内存。

接下来,我们将直接进入 TLB(翻译后备缓冲) 的概念,以及 分页(paging) 的工作方式等。当今许多处理器架构都规定了在操作系统软件启用处理器 PMMU 时,处理器会表现出的一系列行为。但是,什么是内存管理单元(MMU)? MMU 本质上是一种用于缓存地址转换信息的结构。当处理器允许系统中使用多个“虚拟”的、相互独立的地址空间,使得 CPU 所看到的是连续的“虚拟”内存,而这些虚拟内存页可以映射到任意物理页时,就必须有某种形式的 表结构或记录机制 来保持:每个虚拟页最终应映射到哪个物理帧,从而让处理器能够在地址总线上访问正确的物理位置。

进一步说明:提供虚拟内存的 MMU 在芯片上拥有一个 翻译缓存(translation cache)。在这个缓存中,每个“翻译条目(translation entry)”都告诉 CPU:某个虚拟地址应映射到哪个物理地址。我们可以将这个芯片上的缓存想象为一个由以下形式条目构成的大型查找数组:

// TLB 的抽象模型。

typedef uintptr_t vaddr_t;
typedef uintptr_t paddr_t;

// 标记该条目在模拟的硬件 TLB 中已被设置为有效。
#define TLB_ENTRY_FLAGS_INUSE

struct tlb_cache_record_t
{
    vaddr_t entry_virtual_address;
    paddr_t relevant_physical_address;
    uint16_t permissions;
};

// 硬件翻译后备缓冲(Translation Lookaside Buffer)实例。
struct tlb_cache_record_t hw_tlb[CPU_MODEL_MAX_TLB_ENTRIES];

你的处理器中的 TLB(Translation Lookaside Buffer,翻译后备缓冲) 本质上是一个用于查找条目的哈希表,这些条目记录了每个虚拟页所对应的物理地址。当你启用分页后,CPU 遇到的每一次地址访问都会提交给 TLB 进行查找。处理器内部会执行类似如下的逻辑:

// TLB 查找的模型例程。

int tlb_lookup(vaddr_t v, paddr_t *p)
{
   for (int i = 0; i < CPU_MODEL_MAX_TLB_ENTRIES; i++)
   {
      if (hw_tlb[i].flags & TLB_ENTRY_FLAGS_INUSE &&
          hw_tlb[i].entry_virtual_address == v)
      {
         *p = hw_tlb[i].relevant_physical_address;
         return 1;
      }
   }
   return 0;
}

如果 TLB 中存在该虚拟地址的条目,则返回记录的物理地址。需要注意的是: CPU 并不关心内存中真正的页表(真实翻译结果)是什么! 确保处理器 TLB 中的信息有效且正确,是操作系统的责任。

举例来说:假设处理器的 TLB 中有一条记录,表示虚拟地址 0xC0103000 映射到物理地址 0x11807000。如果你的内核修改了 RAM 中的页表,即改变了该虚拟地址应当映射的物理页,这种改变并不会自动影响芯片上的 TLB 条目。

除非你显式告诉 CPU 刷新该 TLB 条目(针对 0xC0103000),否则下次访问该地址时,CPU 会继续查到旧的条目并使用 TLB 中记录的物理地址 0x11807000

因此,处理器架构通常会提供一种指令,可以用来使 TLB 条目失效(可以一次性全部失效,也可以逐条刷新,这取决于 CPU 的设计)。

在我们的模型 CPU 架构中,存在一条指令可用于使某个虚拟地址对应的 TLB 条目失效,称为 TLBFLSH。操作系统可以这样调用它:

asm volatile ("TLBFLSH   %0\n\t" :: "r" (virtual_address));

对应的模型化 TLB 刷新函数如下:

// 用于刷新(失效)之前模型化的 TLB 的函数。

void tlb_flush_single(vaddr_t v)
{
   for (int i = 0; i < CPU_MODEL_MAX_TLB_ENTRIES; i++)
   {
      if (hw_tlb[i].flags & TLB_ENTRY_FLAGS_INUSE &&
          hw_tlb[i].entry_virtual_address == v)
      {
         hw_tlb[i].flags &= ~TLB_ENTRY_FLAGS_INUSE;
         return;
      }
   }
}

请务必理解:只要你启用了 CPU 的 MMU,处理器在将地址发送到地址总线之前,始终会优先查找 TLB。也就是说,一旦启用 MMU,在关闭它之前,你实际上就被“困”在一个虚拟地址空间中。除非你能够通过修改页表并使对应虚拟地址的 TLB 条目失效,从而编辑这个虚拟地址空间,否则你无法改变内核从 RAM 中读取或写入的位置。启用分页意味着:所有的数据访问和指令取址都会首先经过 TLB。

**TLB 就是 MMU。MMU 就是 TLB。**请理解这一点。你构建的页表并不是 CPU MMU 的一部分。事实上,许多体系结构根本不会查看你所构建的软件页表;它们只查 TLB。 那么,如果处理器不会遍历软件构建的页表,TLB 条目是怎么被填充进去的?答案是:由软件写入。就是这样。

MMU 大致可以分为两类:

  1. 软件管理型 MMU:软件必须手动修改处理器芯片上的 TLB 条目,并确保其完全一致性(coherency)。
  2. 硬件辅助型 MMU:软件只需使过期条目无效,而处理器会自动查找新条目(通常通过页表遍历)。

那些会扫描某些操作系统构建的表格以获取地址转换信息的 MMU 实现被称为 “硬件辅助 TLB 加载(Hardware Assisted TLB-Loading)” 的 MMU。 一般来说,CPU 不必负责决定应该为你填入哪些 TLB 条目——那是软件的职责。但有些 CPU 比较“好”,会自动扫描软件页表并获得新的翻译项。

现在我们来解释何谓 “地址转换异常(Translation Fault)”

当处理器在其片上 TLB 中搜索某个虚拟地址的翻译记录,但没有找到条目时,就会发生地址转换异常。注意:这里并不是说 CPU 既查 TLB 又查软件页表仍未命中。原始的转换异常仅仅是 TLB 未命中的情况。

根据处理器架构的不同,转换异常会导致以下两种行为之一:

  1. CPU 陷入(trap)到操作系统,由 OS 手动搜索其地址空间转换记录,并显式地在 TLB 中加载该虚拟地址的条目。
  2. CPU 自动执行“页表遍历(page table walk)”,从由 OS 指定的某个内存地址开始(提示:x86 中是 CR3),自动查找相应条目。

也就是说,并不是所有处理器架构都会自动为你寻找转换信息。 对于那些会自动遍历软件页表的架构,页表格式通常被严格规定:“这个 bit 必须在这里,这里放物理地址,X 必须位于 Y 位置……” 典型例子就是著名的 x86 体系结构

而存在一些架构,只要 TLB 未命中,就 立即陷入 OS。在这种情况下,操作系统可以自行决定如何存储每个进程的地址空间翻译信息;并没有硬件规格要求 OS 使用某种特定格式。软件必须自行维护进程的虚拟地址空间并在发生转换异常时自行查找。

至此,我们应该理解 MMU 的工作方式,也应该理解虚拟地址空间的概念、TLB 为何需要失效刷新等内容。 需要注意的是,某些架构的页表/翻译表结构非常“奇特”,例如 PowerPC,它采用哈希表结构,与 x86 页表完全不同。 这篇维基百科文章对不同处理器的 MMU 实现做了介绍(内容见原文链接)。


理论落地:深入解析 x86 的“自引用页目录”技巧

由于关于这一主题的问题经常被提及,最好直接清晰地解释清楚,以免反复讨论。

本文针对 x86-32 架构,讲解所谓的 “自引用页目录”技巧(Self-referencing Page Directory trick)。在 x86-32 上,处理器遵循之前描述的“翻译故障模型”,即在 未找到 TLB 条目并且 在软件构建的页表中也未找到对应翻译条目 时才会发生异常。与其他拥有 MMU(内存管理单元)并支持硬件辅助 TLB 加载的架构类似,处理器会帮你遍历页表,但前提是你必须提供顶层页表的物理地址,也就是 CR3 寄存器中的页目录物理地址,处理器据此开始页表遍历。处理器在遍历时,会把它找到的内存数据“解释”为 页目录项(Page-Directory Entry)页表项(Page Table Entry)

请务必理解这一点:RAM 中的字节本身没有任何固有含义,页表只是 RAM 中的一组字节而已。你完全可以把一张页表交给网卡进行 DMA 传输,或者把网卡帧的物理地址放入 CR3,CPU 照样会遍历这些字节。如果 CR3 的值碰巧在内存中连续排列的字节正好满足 PRESENT、WRITE 等标志,并指向一个“正确”的帧地址,CPU 甚至可能完成页表遍历,找到对应的虚拟地址映射。这并不意味着这些字节原本就是页表——CPU 只是把它们解释为页表信息

假设我们有一个物理地址为 0x12345000 的页目录(Page Directory)。该页目录当然有 1024 个条目,编号 0~1023。为了说明重点,我们假设:

  • 页目录的 最后一条目(1023)指向自身

    pdir[1023] == 0x12345xxx   # xxx 为权限位
  • 页目录的第 0 条目指向一个页表,物理地址为 0x12344000

    pdir[0] == 0x12344xxx

该页表也有 1024 个页表项,用于映射虚拟地址 0x0~0x3FFFFF 到内存中的各种帧。此时的页目录与页表结构如下:

[页目录 @ 0x12345000]:
条目 0000 | 物理地址: (0x12344 << 12) | 权限位 0bxxxxxxxxxxxx
条目 ...
条目 ...
条目 1023 | 物理地址: (0x12345 << 12) | 权限位 0bxxxxxxxxxxxx

[页表 @ 0x12344000, pdir[0] 指向]:
条目 0000 | 物理地址: (0x34567 << 12) | 权限位 0bxxxxxxxxxxxx
条目 ...
条目 ...
条目 512 | 物理地址: (0x72445 << 12) | 权限位 0bxxxxxxxxxxxx

因此,我们有以下映射关系:

pdir[0]       == 0x12344xxx
pdir[0],ptab[0]   == 0x34567xxx
pdir[0],ptab[512] == 0x72445xxx
pdir[1023]    == 0x12345xxx

模拟页表遍历

我们来看虚拟地址 0x200000 的物理地址映射:

  1. CPU 遇到指令访问虚拟地址 0x200xxx,分页开启,需通过 MMU。
  2. 假设 TLB 中未命中,发生 第一次翻译故障。在 x86-32 上,CPU 会遍历页表而非直接产生异常。
  3. CPU 将虚拟地址 0x00200xxx 分为三段:10 位(页目录索引)、10 位(页表索引)、12 位(页内偏移)。
  4. CPU 知道需访问 CR3 指向的页目录的条目 0,然后访问页表的条目 512(0x200)。
  5. CPU 开始页表遍历:CR3 = 0x12345000。CPU 假设这是有效页目录地址,于是访问物理地址 0x12345000 + 0 * sizeof(pdir_entry_t) = 0x12345000
  6. CPU 读取 4 字节,解释为页目录项。
  7. 页目录项为 0x12344xxx,权限位合法(PRESENT=1)。
  8. CPU 提取页目录项中的物理地址,得到 0x12344000,作为页表起始地址。
  9. CPU 访问页表条目 512,对应物理地址 0x12344000 + 512 * sizeof(ptab_entry_t) = 0x12344800
  10. CPU 读取页表条目 4 字节,权限合法。
  11. CPU 提取物理帧地址:0x72445000
  12. CPU 在 TLB 中插入此映射:虚拟地址 0x200000 → 物理地址 0x72445000
  13. 程序继续执行,无 x86 页故障发生。

自引用页目录的遍历

接下来,我们来看自引用页目录条目映射虚拟地址 0xFFFFF000

  1. CPU 遇到指令访问虚拟地址 0xFFFFFxxx

  2. TLB 未命中,发生第一次翻译故障,CPU 开始页表遍历。

  3. CPU 将虚拟地址拆分:10 位页目录索引(1023)、10 位页表索引(1023)、12 位页内偏移。

  4. CPU 访问页目录条目 1023,物理地址 0x12345000 + 1023 * sizeof(pdir_entry_t) = 0x12345FFC

  5. CPU 读取条目,发现它为 0x12345xxx(指向自身)。

  6. CPU 检查权限位合法(PRESENT=1),提取物理地址 0x12345000

  7. CPU 将该地址当作页表起始地址,访问页表条目 1023,物理地址仍为 0x12345FFC

  8. CPU 读取条目,权限合法。

  9. CPU 提取物理帧地址:0x12345000。即虚拟地址 0xFFFFF000 被映射到页目录所在的物理地址。

  10. CPU 在 TLB 中插入此映射:虚拟地址 0xFFFFF000 → 物理地址 0x12345000

  11. 程序可以通过访问虚拟地址 0xFFFFF000 及其偏移,直接读写当前页目录内容。

    • 例如:访问 0xFFFFF000 + 0 读取页目录条目 0,访问 0xFFFFF000 + 4 读取条目 1,依此类推。

注意:虚拟地址 0xFFFFF000 对用户程序有效,除非将其映射为 SUPERVISOR(内核态)。否则用户程序可能修改自己的页表甚至尝试映射到内核物理地址,带来安全风险。


参考