Archipelago

Lec 18虚拟内存

讲义完成更新于 2026-09-29

Lec 18 虚拟内存

TL;DR

  • 虚拟化与隔离保护:MMU 将进程虚拟地址映射为离散物理页,提供私有连续大地址空间,借助特权级与访问权限位实现硬隔离。
  • 分页与缺页异常模型:按需分页(Demand Paging)将 DRAM 视作二级存储的缓存;未命中时触发缺页中断由操作系统执行换入换出与页表更新。
  • TLB 与 VIPT 并行加速:通过快表(TLB)缓存热点转换,结合虚拟索引物理标记(VIPT)技术实现 MMU 地址翻译与 L1 缓存查找的高并发重叠。

虚拟内存的系统级动机

探讨一下另一大块关于硬件支持下,能让操作系统运行更快的方法——虚拟内存。

本讲导览

  • 回顾
  • 虚拟内存概述
  • 页面置换策略
  • 页面置换过程
  • MMU 工作原理
  • 上下文切换
  • 虚拟地址空间组织
  • MMU 实现细节

回顾

操作系统的三大目标

  • 保护和私有。进程之间相互访问到对方的数据,或者做一些硬件资源有害的事。

    • 保护和私有。 每个进程都有独立的私有空间,其他正在运行的进程无法访问。
  • 抽象。 隐藏硬件细节。比如进程可以通过 open 和访问文件不需要发送原始命令给到硬盘

  • 资源管理。控制进程如何共享资源(CPU,MEM,DISK)

    • 共享“CPU":

      • 用户态+管理员态

      • 通过陷入安全地进入 supervisor 态

    • 共享”内存“ ——今天的主题

    [!IMPORTANT]

    虚拟内存主要围绕让每个进程都有一个很大的,私有的,独享的存储空间。

内存层次结构

计算机分层存储层次结构与访问延迟容量权衡

当前内存技术中的一个基本权衡:随着内存容量的增加,访问时间也会增加。要构建一个容量大且平均访问时间小的内存系统,需要一些架构上的巧妙设计。这种巧妙设计体现在缓存中,它是一个位于 CPU 和主内存之间的硬件子系统。

硬件缓存

硬件缓存映射架构对比:直接映射、全相联与组相联

  • 直接映射(Direct-Mapped)缓存:每个内存块只能映射到缓存中的一个特定位置,这样会导致不同内存块争夺同一个缓存位置的问题。

  • 全相联(Fully Associative)缓存:任何内存块可以放置在缓存中的任何位置,这样提高了缓存的灵活性,但增加了查找的复杂性。

  • 组相联(Set-Associative)缓存:结合了上述两种方式。缓存被划分为若干组,每个内存块可以放在某一组中的任意位置。通常,组相联缓存比直接映射缓存更能有效减少缓存位置争夺的问题。

缓存的内容由硬件自动管理。缓存之所以有效,是因为局部性原理:如果 CPU 在时间 T 访问了位置 X,那么它很可能在不久的将来访问附近的位置。缓存的组织方式使得附近的位置能够同时存在于缓存中。

为了增加请求地址存在于缓存中的概率,我们引入了”associativity(关联性)"的概念,增加了每次访问时检查缓存位置的数量,解决了指令和数据争夺相同缓存位置的问题。

我们还讨论了块大小(缓存行中的字数)、替换策略(如何选择在缓存未命中时重新使用哪个缓存行)和写策略(决定何时将更改的数据写回主内存)的适当选择。

我们从未讨论过主内存中的数据来自何处以及如何管理填充主内存的过程。这是今天讲座的主题。

虚拟内存概述

我们将内存用作是磁盘的缓存,与硬件缓存有两个区别

  • 好消息是,二级存储的容量巨大
  • 坏消息是,磁盘访问时间比主存慢 100,000 倍。

在讨论 DRAM 时,我们发现检索一个连续块的额外访问时间比第一个字的访问时间要小,因此假设我们最终会访问附加的字,获取一个块是一个正确的选择。对于磁盘而言,更是如此。并且, 也可知道,未命中的惩罚代价是很高的

鉴于我们需要

  1. 高关联性 High Associativity,这意味着在主内存中定位和管理来自磁盘的数据时,要尽可能灵活,以便减少数据块间的冲突。
  2. 程序的工作集(程序在运行时需要访问的所有数据)能够完全适应在主存中。即,虚拟内存系统应能够合理地管理内存,以避免将不必要的数据从磁盘加载到内存中,从而避免频繁的未命中和磁盘访问。
  3. 使用较大的块大小,,一旦从磁盘读取了一个数据块,读取这个块中的其他字(即数据单元)通常成本很低, 又因为局部性原理,我们预计会访问块中的其他字,从而将missmiss的成本分摊到许多未来的hitshits
  4. Write-back 策略,在主内存中更改的数据需要被其他二级存储块的数据替换时,我们才会更新磁盘上的内容。

未命中的长延迟还有一个好处。我们可以在软件中管理主内存的组织和对二级存储的访问。即使处理未命中的后果需要成千上万条指令,相比于磁盘的访问时间,这些指令的执行速度非常快。因此,我们的策略是让硬件处理命中,让软件处理未命中。这将导致简单的内存管理硬件和在软件中实现非常巧妙策略的可能性,以应对未命中的处理

工作原理

下面虚拟内存系统的工作原理。CPU 生成的内存地址是虚拟地址,主内存使用的是物理地址,CPU 和主存之间,有个硬件叫内存管理单元(MMU),它的作用是将虚拟地址转换为物理地址。

等等! 在 CPU 和主内存之间不是缓存吗?

但现在,我们假设只有 MMU 而没有缓存。

MMU 硬件通过简单的表查找将虚拟地址转换为物理地址。这个表称为页表(page table)。从概念上讲,MMU 使用虚拟地址作为索引来选择表中的一个条目,该条目告诉我们相应的物理地址。这个表允许将一个虚拟地址可以被映射到主内存的任何位置。在正常操作中,我们希望确保两个虚拟地址不会映射到同一个物理地址。但如果某些虚拟地址没有对应的物理地址的翻译,这也是可以的。这将表示请求的虚拟地址的内容尚未加载到主内存中,因此 MMU 将向 CPU 发出内存管理异常信号,然后 OS 可以分配到主内存中的一个位置,并执行所需的 I/O 操作,将数据从二级存储加载到主内存中的指定位置。

页表是操作系统决定的还是 CPU 上的 MMU 决定的?

页表是操作系统管理的,硬件 MMU 只是执行这个转换。因此最终是由 OS 决定的

页表使得系统能控制程序运行时如何访问物理内存。例如,当我们在切换程序时更改页映射,能够快速连续运行多个程序(分时调度)。通过正确的管理页表,使得一个程序可以访问主存的某位置但是对于另外一个程序却不可访问。我们还可以使用内存管理异常在需要时将程序内容加载到主内存中,而不是在执行开始之前加载整个程序。

虚拟内存的分页实现

虚拟地址空间与物理内存的分页映射机制

我们将虚拟和物理地址空间划分为固定大小的块,称为页(page)。页大小总是 2 的幂字节,例如2p2^p字节,因此要确定某个位置,我们需要有 p 位进行定位。我们将使用虚拟或物理地址的低位 p 位作为页偏移量。其余的地址位告诉我们正在访问哪个页,称为页号。

我们将一个进程所需要的页,映射到物理内存的不同区域,但是不要求相邻。为了能找到一个进程的页所在的物理地址,我们需要引入页表(page table),它使用低位(lower-order bits)作为页内偏移量(offset),高位(higher-order bits)来确定具体是哪一页。每个虚拟地址现在被解释为一个有序对{页号,偏移量},页表告诉我们每个虚拟页号对应的物理页号以及相应的起始物理地址

一个典型的页大小为 4KB 到 16KB,对应 p=12 和 p=14。例如,假设 p=12。如果 CPU 生成一个 32 位的虚拟地址,虚拟地址的低 12 位是页偏移量,高 20 位是虚拟页号,类似地,物理地址的低 p 位是页偏移量,其余的物理地址位是物理页号。

[!IMPORTANT]

MMU(内存管理单元)将管理页面,而不是单个位置。

我们将整个页面从二级存储中移动到主内存中。根据局部性原则,如果一个程序访问了页面上的一个位置,我们预计它很快会访问页面上的其他相邻位置。

MMU 将虚拟页码映射到物理页码。它通过使用虚拟页码(VPN)作为索引来访问页表。页表中的每个条目指示页面是否驻留在主内存中,如果是,它会提供适当的物理页码(PPN)。PPN 与页面偏移量结合形成主内存的物理地址。

如果请求的虚拟页面不在主内存中,MMU 会向 CPU 发送一个内存管理异常,称为页面错误(page fault),以便 CPU 能够从二级存储中加载适当的页面,并在 MMU 中设置适当的映射。

我们将主内存作为页面缓存(page cache),这种方法称为“分页”或有时称为“按需分页”,因为页面的移动是根据程序的需求来决定的。

分页机制梳理

  1. 最初,程序的所有虚拟页面都驻留在二级存储中,MMU 是空的,即没有页面驻留在物理内存中。

  2. CPU 开始运行程序,程序生成的每个虚拟地址,无论是用于指令提取还是数据访问,都传递给 MMU 以映射到主内存中的物理地址。

    • 如果虚拟地址已驻留在物理内存中,主内存硬件可以完成访问。
  3. 如果虚拟地址未驻留在物理内存中,MMU 会发出页面错误异常。

  4. 切换到页面错误处理程序

    • 处理程序分配一个物理页面来存放请求的虚拟页面,并将虚拟页面从二级存储中加载到主内存中。然后,
    • 它调整请求的虚拟页面的页映射条目,标记它现在已驻留
    • 并指示为新分配和初始化的物理页面提供物理页面编号。

第四步中,尝试分配物理页面时,处理程序可能会发现所有物理页面当前都在使用中。在这种情况下,它选择一个现有的页面进行替换,例如一个最近没有被访问的驻留虚拟页面。它将选择的虚拟页面的内容交换到二级存储中,并更新被替换虚拟页面的页映射条目,以指示它不再驻留。现在有一个空闲的物理页面可以重新使用,以存放缺失的虚拟页面的内容。

程序的工作集,即程序当前正在访问的页面集,通过一系列页面错误加载到主内存中。程序开始运行时,页面错误会频繁发生,之后工作集变化缓慢,因此页面错误的频率会显著降低,如果程序较小且表现良好,页面错误的频率可能接近零。也有可能编写不断生成页面错误的程序,这种现象称为“抖动”(thrashing)。由于二级存储的访问时间较长,抖动的程序运行得非常缓慢,通常慢到用户放弃并重写程序以更合理地运行。(也就是说,代码需要性能优化了)

一种简单页表的设计

页表的设计非常简单。每个虚拟页面在页表中都有一个条目。例如,如果 CPU 生成的是一个 32 位的虚拟地址,且页面大小为 2¹² 字节,那么虚拟页码就有 32−12=20 位,页表将包含 2202^{20} 个条目。

页表中的每个条目包含一个“驻留位”(R),当虚拟页面驻留在物理内存中时,该位被设置为 1。如果 R 为 0,则访问该虚拟页面会导致页面错误。如果 R 为 1,又因为条目还包含物理页码(PPN),它指示虚拟页面在主内存中的具体位置。

还有一个附加的状态位,称为“脏位”(D)。当页面刚从二级存储中加载时,它是“干净”的,即物理内存的内容与二级存储中的页面内容相同。因此,D 位设置为 0。如果随后 CPU 对该页面中的某个位置进行了存储操作,页面的 D 位将被设置为 1,表示页面是“脏的”,即内存的内容现在与二级存储的内容不同。如果一个脏页面被选中进行替换,则在页面被重用之前,必须将其内容写入二级存储以保存更改。

一些 MMU 在每个页表条目中还有其他状态位。例如,可能有一个“只读”位,当该位被设置时,如果程序试图向该页面存储数据,将产生异常。这对于保护代码页面不被错误的数据访问破坏非常有用,也是一种非常实用的调试功能。

[!NOTE] 扩展知识: 磁盘上的一块区域叫交换空间(Swap Space),用于备份 DRAM 中的数据。当内存不够用时,操作系统可以将不常用的数据从内存移到此处,主要是为了扩展内存。

示例: 从虚拟地址到物理地址转换

下面出一道题:

基本设置:

  • 页大小282^8字节
  • 16 个虚拟页,8 个物理页
  • 意味着,虚拟地址 4+8=12 位;物理地址 11 位

执行指令 LD(R31, 0x2C8, R0)时,VA = 0x2C8, 那么 PA = ?

单级页表条目结构与虚拟地址到物理地址转换计算示例

Solution:我们给定了虚拟地址 0x2C8,可知我们的 VPN=2,接着我们就去页表中找 VPN=2,通过查找到(D,R,PPN) = (0, 1, 4),查看索引为 2 的页表条目,我们看到 R 位为 1,表示虚拟页面 2 驻留在物理内存中。条目的 PPN 字段告诉我们虚拟页面 2 位于物理页面 4,将 PPN 和 8 位偏移量结合起来得到0x4C8

页错误

当 CPU 访问一个不驻留的虚拟页面时会发生什么,也就是说,该页面的驻留位被设置为 0。

在这种情况下,MMU 会发出页面错误异常,导致 CPU 暂停程序执行并切换到页面错误处理程序。处理程序首先找到一个未使用的物理页面,或者如果有必要的话,通过选择一个正在使用的页面并将其腾空。

我们可以选择哪个页面?当然,有一些限制。显然,我们不能选择存放页面错误处理程序代码的页面。免于被选择的页面称为“锁定(wired)”页面。最优策略是选择下次使用时间最远的页面。但是,这显然需要了解未来的执行路径,因此这并不是一个可行的策略。不管用什么策略进行替换。

最后,处理程序完成,原始程序的执行恢复,重新执行导致页面错误的指令。由于页映射表已更新,这次访问成功,执行得以继续。

示例: 页错误

下面出一道题:

基本设置:

  • 页大小282^8字节
  • 16 个虚拟页,8 个物理页
  • LRU 页: VPN = 0xE

1,执行指令,ST(BP, -4, SP) -- 其中 SP = 0x604, VA = 0x600, 那么 PA = ?

2, 请在图中标注执行完后的映射关系,以及更正页表项

缺页异常处理流程与 LRU 页面置换页表项更新示例

Solution: 一个存储指令,它正在访问虚拟地址 0x600,该地址位于虚拟页面 6 上,检查虚拟页面号(VPN)6 的页表条目,我们看到它的 R 位为 0,表明它没有驻留在主存中,这导致了页面错误异常。页面错误处理程序选择 VPN 0xE 进行替换,因为我们在设置中已经得知它是最近最少使用(LRU)的页面。接下来,所需的虚拟页面会从二级存储读取到选定的物理页面中,并且更新页表条目, 标记虚拟页号 0x6 已经驻留在物理页号 0x5 中。最后,处理程序完成,原始程序的执行恢复,重新执行导致页面错误的指令。由于页映射表已更新,这次访问成功,执行得以继续。

缺页置换后页表条目驻留位与物理页号更新示意图

程序员角度看虚拟内存

我们可以将内存管理单元 (MMU) 的工作分为两项任务,作为计算机科学家,可以将其视为两个过程。在这个框架中,页映射信息保存在几个数组中:R 数组保存驻留位,D 数组保存脏位,PPN 数组保存物理页号,DiskAdr 数组保存每个虚拟页在二级存储中的位置。

// 硬件执行
int VtoP(int Vaddr) {
  int VpageNo = Vaddr >> p;
  int P0 = Vaddr & ((1 << p) - 1); // 获取页内偏移
  if(R[VPageNo] == 0)
    PageFault(VpageNo);
  return (PPN(VPageNo) << p) | P0; // 将物理页号移到高位 然后与页内偏移 与,就得到了最终的物理地址
}

// 下面软件执行
void PageFault(int VpageNo) {
  int i;
  i = SelectLRUpage();
  if (D[i] == 1) {
    WritePage(DiskAdr[i], PPN[i]);
  }
  R[i] = 0;
  PPN[VPageNo] = PPN[i];
  ReadPage(DiskAddr[VPageNo], PPN[i]);
  R[VPageNo] = 1;
  D[VPageNo] = 0;
}

VtoP 过程在每次内存访问时被调用,用于将虚拟地址转换为物理地址。如果请求的虚拟页不在内存中,则会调用 PageFault 过程,使该页驻留。一旦请求的页驻留,VPN(虚拟页号)就会作为索引用于查找相应的 PPN(物理页号),然后将其与页偏移量连接起来,形成物理地址。

PageFault 过程首先选择一个虚拟页进行替换,如果该页是脏的,则会将其内容写回。然后将选定的页标记为不驻留。

最后,从二级存储读取所需的虚拟页,并更新页表信息,反映该页现在已经驻留在新分配的物理页中。

软硬件平衡

我们将使用硬件来实现 VtoP(虚拟地址到物理地址)的功能,因为每次内存访问时都需要进行这个转换。调用 PageFault(页面错误)过程是通过页面错误异常来完成的,这会指示 CPU 执行相应的处理程序软件,其中包含 PageFault 过程。

这种策略值得在所有实现选择中推广:对于需要快速执行的操作,使用硬件来处理,而通过异常处理(希望是很少发生的)异常情况则由软件处理。由于软件是由 CPU 执行的,而 CPU 本身也是一块硬件,本质上我们是在做出使用专用硬件(如 MMU)和使用通用硬件(如 CPU)之间的权衡

简而言之,我们不要轻易使用专用硬件,除非操作的频率非常高且直接影响系统性能。

[!IMPORTANT]

虚拟内存系统的架构由三个参数决定,因此也决定了 MMU 的架构。

  • P 表示虚拟和物理地址中用于页偏移的地址位数。
  • V 表示虚拟页号所占的地址位数。
  • M 表示物理页号所占的地址位数。

虚拟地址的大小由指令集架构(ISA)决定。虚拟地址空间不足会限制系统能够处理的数据量和内存地址范围,这也是很多 ISA 消亡的主要原因

为什么典型的页大小是 4KB 到 16KB?

Sol:这是内存存储不需要的位置的缺点和从二级存储中读取尽可能多数据优点之间的”权衡“, 将初始访问的 word 的高成本分摊到尽可能多的 word 上。

小虚拟地址的限制是许多 ISA 消亡的主要原因。当然,每代工程师都认为他们的过渡将是最终的!我记得我们曾经认为 32 位是一个无法想象的巨大地址空间。那时我们还在按兆字节购买内存,在我们的幻想中我们才会认为一个系统可以拥有数千兆字节的内存。今天的 CPU 架构师们对 64 位感到相当自豪——让我们看看他们在几十年后会有何感想!

目前物理地址的大小介于 30 位(嵌入式处理器,内存需求较小)到 40 多位(处理大数据集的服务器)之间。由于 CPU 实现每隔几年就会更新,物理内存大小的选择可以根据当前技术进行调整。由于程序员使用的是虚拟地址,他们与这种实现选择隔离开来。MMU 确保现有软件能够在不同大小的物理内存下继续正常工作。程序员可能会注意到性能上的差异,但不会在基本功能上发现变化。

RAM 驻留页表的缺点

但是为什么要使用专用的内存来存储页表,而不是使用已经存在且已经购买的主内存的一部分呢?

我们可以使用一个寄存器,称为页表指针,来保存主内存中页表数组的地址。换句话说,页表将占用一些专用的物理页面。通过将虚拟页号用作索引,硬件可以执行通常的数组访问计算,从主内存中提取所需的页表项。

这种提议的缺点是,执行一次虚拟访问现在需要两个物理内存访问:第一个访问是为了检索虚拟到物理地址转换所需的页表项,第二个访问是为了实际访问请求的内存位置。

快表 TLB

大多数系统都包含一种专用的缓存,称为转换后备缓冲器(translation look-aside buffer, TLB),用于将虚拟页号映射到物理页号。TLB 通常很小且速度很快。为了确保最佳的命中率,TLB 通常是全相联的,以避免冲突。如果在 TLB 中找到了物理页号,则可以避免对主内存的页表项的访问,这样每次虚拟访问就可以恢复为单次物理访问。

TLB 的命中率相当高,通常超过 99%。这并不令人惊讶,因为局部性和工作集的概念表明,在短时间内只有少量页面处于活动使用状态。

带有快表 TLB 的 MMU 虚拟地址快速翻译硬件数据通路

综合来看:CPU 生成的虚拟地址首先由 TLB 处理,以查看是否缓存了从虚拟页号(VPN)到物理页号(PPN)的适当翻译。如果缓存中有,则可以直接进行主内存访问。

如果所需的映射不在 TLB 中,则会访问主内存中的相应页表项。如果页面在内存中,页表项的 PPN 字段将用于完成地址翻译。翻译结果当然会被缓存到 TLB 中,以便后续访问此页面时可以避免对页表的访问。

如果所需的页面不在内存中,MMU 会触发页面错误异常,然后页面错误处理程序代码将处理这个问题。

TLB 命中、页表查找与缺页异常的多级地址转换流程图

示例:快表

假设:

TLB 与页表联合寻址的参数配置与示例表格

提问:

  1. 物理内存中可以同时存在多少个页面?
  2. 页表中有多少条目?
  3. 每个页表条目多少位(假如每个都有 PPN,驻留位,脏位)?
  4. 页表占用多少页面?
  5. 给定任何时间,虚拟内存有多少比例可以在内存驻留?
  6. 虚拟地址 0x1804 的物理地址是什么? 涉及到哪些 MMU 组件进行翻译?
  7. 虚拟地址 0x1080 呢?
  8. 虚拟地址 0x0FC 呢?

1、2^24 / 2^10 个 2、 2^32 / 2^10 个 3、24+2=26bit 4、 (2^32 / 2^10 * 26) / (8 *2^10) 页 5、页表条目/虚拟页面数量 = 2^14/2^22 6、将虚拟地址分解为 VPN 和偏移量,分别是 0x6 和 0x004,首先在 TLB 找,发现 VPN-to-PPN 的映射被缓存了,将 PPN(0x2)和 10 位偏移连接得到 0x804

7,同上题,虚拟内存 0x1080 呢?对于这个地址,VPN 是 0x4,偏移量是 0x80。VPN 0x4 的翻译没有缓存到 TLB 中,因此我们需要检查页表,这告诉我们该页面驻留在物理页面 5。通过将 PPN 和偏移量连接,得到物理地址 0x1480 8、同上题,虚拟内存 0x0FC 呢?在这个地址中,VPN 是 0,偏移量是 0xFC。VPN 0 的映射没有在 TLB 中找到,检查页表显示 VPN 0 不在主内存中驻留,因此触发了页面错误异常。

上下文切换

页表提供了解释虚拟地址的上下文 context:即它虚拟地址在主内存或辅助存储中位置所需的信息。

多个程序可以同时加载到主内存中,每个程序都有自己的上下文。需要注意的是,分开的上下文确保程序之间不会相互干扰。例如,一个程序中虚拟地址 0 的物理位置与另一个程序中虚拟地址 0 的物理位置会不同。每个程序在自己的虚拟地址空间中独立运行。正是页表提供的上下文使得它们可以共存并共享同一块物理内存。因此,在切换程序时我们需要切换上下文。这是通过重新加载 Reload页表来实现的。

多进程独立虚拟地址空间与操作系统上下文切换示意图

在时间共享系统中,CPU 会定期从运行一个程序切换到另一个程序,给人一种多个程序各自运行在自己的虚拟机上的错觉。这通过在切换到下一个程序时切换上下文来实现。

有一段特权代码称为操作系统(OS),它负责管理一个物理处理器和主内存在多个程序之间的共享,每个程序都有自己的 CPU 状态和虚拟地址空间。操作系统实际上是在使用一组共享的物理资源创建多个虚拟机并协调它们的执行。

操作系统运行在一种特殊的上下文中,我们称之为内核。操作系统包含异常处理和支持分时调度的必要代码。由于它需要管理物理内存,因此它被允许访问任何物理位置以处理页面错误等问题。运行中的程序出现异常时,硬件会切换到内核上下文,这被称为进入“内核模式”。处理完异常后,程序的执行会恢复到所谓的“用户模式”。

由于操作系统在内核模式下运行,它可以访问许多在用户模式下无法访问的硬件寄存器。这些包括 MMU 状态、I/O 设备等。需要访问磁盘等硬件的用户模式程序需要向操作系统内核发出请求,以执行操作,从而使操作系统有机会检查请求的适当权限等。

内存管理& 保护

典型用户进程虚拟地址空间内存布局与权限保护分区

用户模式程序(也称为应用程序)在编写时,假设它们可以访问整个虚拟地址空间。它们通常遵循相同的约定,如程序中第一条指令的地址、栈指针的初始值等。由于所有这些虚拟地址都是使用当前上下文进行解释的,通过控制上下文,操作系统可以确保程序能够无冲突地共存。

上图的图示展示了一种标准的组织应用程序虚拟地址空间的计划。通常,第一个虚拟页会被设置为不可访问,这有助于捕捉涉及引用初始化(即零值)指针的错误。接下来是一些只读页,这些页保存了应用程序的代码以及它所使用的任何共享库的代码。将代码页标记为只读可以避免难以发现的错误,这些错误可能是由于错误的数据访问无意中更改了程序!然后是读写页,这些页保存了应用程序的静态分配数据结构。

虚拟地址空间的其余部分被划分为两个可以随着时间增长的数据区域。第一个是应用程序的栈,用于保存过程调用记录。这里我们将栈放在虚拟地址空间的较低端,因为我们的约定是栈向更高的地址增长。

另一个可增长的区域是堆,用于动态分配长寿命数据结构的存储。“动态”意味着对象的分配和释放是通过显式的过程调用来完成的,而不是在应用程序运行时预先知道哪些对象会被创建。正如这里所示,随着堆的扩展,它向较低地址增长。

页面错误 handler 知道在这些区域增长时分配新页面。当然,如果它们在中间某个地方相遇并且需要更多空间,应用程序将没有运气——它已经用尽了虚拟内存!

多级页表

我们可以对 MMU 实现进行一些调整,以提高效率或功能。

在我们简单的页表实现中,完整的页表占用了一些物理页面。使用这里显示的数字,如果每个页表项占用一个主存字,我们需要 2202^{20} 个字(或 2102^{10} 页)来保存页表。如果我们有多个上下文,我们将需要多个页表,这将对我们的物理内存资源提出很大的需求。

这里展示的 MMU 实现使用了分层页表。虚拟地址的前 10 位用于访问一个“页目录”,该目录指示了保存该虚拟地址空间段页表的物理页。关键思想是页表段存储在虚拟内存中,即它们不需要在任何给定时间都驻留在内存中。如果运行的应用程序只活跃地使用了其虚拟地址空间的一小部分,我们可能只需要少量页面来保存页目录和必要的页表段。当有多个应用程序,每个都有自己的上下文时,这种节省真的会累积起来。

在这个例子中,注意到页目录中的中间条目,即堆栈和堆之间尚未分配的虚拟内存对应的条目,都标记为“未驻留”。因此,不需要为持有标记为“未驻留”的大量页表项而分配资源。

现在访问页表需要两次访问主存(首先访问页目录,然后访问页表的适当段),但 TLB 使得这额外的访问几乎没有影响。

在更改上下文时,操作系统通常会重新加载页表指针,以指向适当的页表(或如果我们采用之前幻灯片中的方案,则指向页目录)。由于这种上下文切换实际上更改了页表中的所有条目,操作系统还必须使 TLB 缓存中的所有条目失效。这自然会对 TLB 命中率产生巨大影响,平均内存访问时间会大幅增加,因为所有页表访问现在都是必需的,直到 TLB 被重新填充。

为了减少上下文切换的影响,一些 MMU 包含一个上下文编号寄存器,其内容与虚拟页号连接在一起,形成对 TLB 的查询。这本质上意味着 TLB 缓存条目的标签字段将扩展以包括在填充 TLB 条目时提供的上下文编号。

切换上下文时,操作系统现在需要重新加载上下文编号寄存器和页表指针。由于有了新的上下文编号,其他上下文中的 TLB 条目将不再匹配,因此在上下文切换时无需刷新 TLB。如果 TLB 具有足够的容量来缓存多个上下文的 VPN 到 PPN 的映射,则上下文切换将不会对平均内存访问时间产生实质性的影响。

最后,让我们回到如何将缓存和 MMU 结合到我们的内存系统中去的问题。

第一种选择是将缓存放在 CPU 和 MMU 之间,即缓存将处理虚拟地址。这看起来不错:VPN 到 PPN 的转换只在缓存未命中时发生。困难在于上下文切换时,会改变虚拟内存的有效内容。毕竟,上下文切换的目的就是切换执行到另一个程序。这意味着操作系统在执行上下文切换时必须使缓存中的所有条目失效,这会导致缓存未命中率相当高,直到缓存被重新填充。因此,上下文切换的性能影响会非常高。

我们可以通过缓存物理地址来解决这个问题,即将缓存放在 MMU 和主内存之间。这样缓存的内容不会受到上下文切换的影响——请求的物理地址会有所不同,但缓存会随之处理。这个方法的缺点是,在开始缓存访问之前,我们必须承担 MMU 翻译的成本,这会稍微增加平均内存访问时间。

优化点: MMU 和缓存并行工作

虚相联物理标记(VIPT)缓存与 MMU 并行访问时序架构

我们巧妙地设计,我们不必等待 MMU 完成后再开始访问缓存。一开始,缓存需要虚拟地址中的行号来提取适当的缓存行。如果用于行号的地址位完全包含在虚拟地址的页偏移中,这些位不会受到 MMU 翻译的影响,因此缓存查找可以与 MMU 操作并行进行。

一旦缓存查找完成,缓存行的标签字段可以与 MMU 生成的物理地址的适当位进行比较。如果 MMU 中发生了 TLB 命中,物理地址应该会在与缓存查找产生的标签字段大约相同的时间内可用。

通过并行执行 MMU 翻译和缓存查找,通常不会对平均内存访问时间产生影响!这样就可以实现两全其美:一个物理地址缓存,不需要为 MMU 翻译付出时间代价。

最后一个细节:增加缓存容量的一种方法是增加缓存行的数量,从而增加用作行号的地址位数。由于我们希望行号适配到虚拟地址的页偏移字段中,因此缓存行的数量是有限的。相同的论点适用于增加块大小。因此,要增加缓存容量,我们唯一的选择是增加缓存的关联度,这样可以在不影响用于行号的地址位的情况下增加容量。

这就是我们对虚拟内存讨论的全部内容。我们使用 MMU 提供虚拟地址到物理地址的映射上下文。通过切换上下文,我们可以创建多个虚拟地址空间的错觉,使许多程序可以共享单个 CPU 和物理内存而不互相干扰。

我们讨论了使用页表将虚拟页号翻译为物理页号。为了节省成本,我们将页表放在物理内存中,并使用 TLB 消除大多数虚拟内存访问的页表访问成本。访问非驻留页会导致页错误异常,允许操作系统管理许多应用程序之间公平共享物理内存的复杂性。

我们看到,提供上下文是创建虚拟机的第一步,这也是我们下一节讲座的主题。

虚拟内存的分段实现

  • 在分段内存管理中,我们为每个进程分配一段特定的连续物理内存段。理论上,我们可以直接将物理地址暴露给程序使用,但这样会使编译过程变得非常繁琐(因为程序需要明确添加起始地址)。相比之下,让进程将起始地址视为地址 0,并让操作系统灵活地移动我们的连续内存块会更为方便。

    因此,我们采用了另一种方法:为每个进程分配一个基址寄存器(这是特权寄存器,只有操作系统可以控制)。实际使用的物理地址始终是虚拟地址加上段基址寄存器的值。这种方式使得进程可以以相对简单的方式处理地址,而操作系统负责将虚拟地址转换为物理地址,从而实现了地址空间的管理和保护。基址与界限寄存器分段内存转换模型

    这种技术被称为基址和界限(base-and-bound)地址转换,其中的“界限”部分涉及到限制内存分配的范围。因此,除了基址寄存器外,每个进程还需要一个界限寄存器(同样是特权寄存器)。操作系统通过检查虚拟地址是否在允许的范围内来确保内存访问的合法性。如果虚拟地址超出了允许的范围,就会触发一个界限违规异常(bound violation exception),因为程序试图访问其不应该访问的内存区域。

    独立代码段与数据段的基址界限保护机制

    对于每个程序而言,我们会为数据和代码的分配分离,因此对于每个程序, 在内存中都有这么个数据段(包含了数据界限寄存器,数据基址寄存器),以及代码段(包含了代码界限寄存器,代码基址寄存器)。这种分离是有好处的,因为这样就不可能在执行期间意外更改我们的代码。并且多个程序可以共享一个代码段。

    这种实现方式简单,但是很少用,因为他会有几个问题——最主要的就是内存碎片的问题。

下图说明了了不同的进程有有不同页表,黄色区域是 OS,后面会讲。每个页表中的页(表项)表示一个物理内存的起始地址。页表的条目不需要是连续的。例如,进程 1 的页表条目可以分散在不同的物理内存位置

多进程页表映射与离散物理内存分配示意图

操作系统(OS)控制的页表基址寄存器(PT base register)指向当前进程的开始的页表。令 VPN 为虚拟页号。操作系统控制的页表基址寄存器与双步内存寻址流程

如果我们想执行一次内存访问,让我们考虑一下该如何进行。我们有一个页表基址寄存器(PT base register),它告诉我们当前正在运行的进程的页表的起始位置。一旦我们完成地址转换并在页表中查找,我们将发现每个虚拟页号对应的物理页号,然后我们才能获取我们的数据。所以我们从一个虚拟页号(VPN)和偏移量(offset)拼接在一起的地址开始,我们实际上需要进行两步操作:

  1. 找到物理页号(PPN):物理页号位于内存地址 Mem[PT base + VPN],其中 PT base 的值是通过从内核页表中查找得来的。
  2. 计算物理地址:现在我们有了物理页号(PPN),我们可以通过计算 PPN + offset 来找到物理地址。

然后,我们需要进行第二次 DRAM 内存访问以实际获取数据字,这意味着我们已经将内存访问延迟翻倍了(这是不好的)。

进程切换时更新页表基址寄存器的硬件逻辑流程

为了完成这些工作,我们还需要高级页表,叫系统页表,本质上包含了指向每个进程的开头页表的指针。所以每次我们切换进程时,我们还需要调整 PT base寄存器 的值。

当我们第一次访问某个内存地址时,需要将虚拟地址(VA)转换为物理地址(PA)。这个过程通常涉及查找页表。后续操作直接使用物理地址:一旦完成了首次转换,后续对同一地址的访问将直接使用已转换得到的物理地址,不再进行额外的地址转换。

综上,我们用符号表示如下

进行地址转换时

  • PPN = Mem[PT Base Reg + VPN]
  • PA = {PPN, offset}

进程切换时

  • PT Base Reg := System PT Base + new process ID

[!IMPORTANT]

Page fault 会带来数百万时钟周期的开销

虚拟内存体系结构全景总结

整个地址转换过程

虚拟内存地址转换与存储层次协同全流程图解

目标#1: 大规模利用局部性

  • 程序员希望一个大的,扁平的地址空间,但是只使用其中的一小部分
  • 解决方法: 将工作集从磁盘缓存到 RAM 中
  • 基本实现: 使用单级页表的 MMU
    • 通过快速硬件路径访问已加载的页面
    • 按需加载虚拟内存: 页错误
  • 几个优化措施
    • 为了节省成本,将页表移动到 RAM 中
    • 使用快表来重新获得高性能
  • 缓存/虚拟内存交互: 可以缓存物理或者虚拟地址

目标 2 & 3: 简化内存管理,保护多个上下文之间互不干扰

我的理解

::: insight

VIPT 缓存设计与虚拟页大小绑定的微架构约束

虚拟内存与 CPU 缓存的结合是体系结构中最精妙的设计之一:

  1. PIPT vs VIVT 的困境:物理索引物理标记(PIPT)每次访存必须先等待 TLB 翻译出物理地址,再查询 L1 缓存,严重增加 L1 命中延迟;虚拟索引虚拟标记(VIVT)虽然快速,但在进程上下文切换时必须完全清空(Flush),且存在严重的别名问题(Aliasing / Synonym,即多个虚拟地址映射到同一物理地址导致缓存副本不一致)。
  2. VIPT 的无缝并行优雅解:虚索引物理标记(Virtually Indexed, Physically Tagged)让 CPU 在发射虚拟地址的瞬间,利用未经翻译的页内偏移(Page Offset)直接并发索引 L1 缓存,同时将虚拟页号送入 TLB 查找物理页号。当 L1 读出各路候选行的数据与物理 Tag 时,TLB 也恰好完成转换,二者在比较器处汇合并行判断命中。
  3. 页面大小对 L1 缓存容量的物理硬约束:VIPT 能够完全避免别名(Aliasing)的前提,是缓存索引位(Index)与块偏移(Offset)之和不能超过页内偏移位数 pp。例如对于标准 4KB(2122^{12} 字节)页面,如果每个 Cache Line 为 64 字节(6 位 Offset),则只剩 12−6=612 - 6 = 6 位用于 Index,这意味着缓存最多只能有 26=642^6 = 64 个 Set。要扩大 L1 缓存容量(如达到 32KB),唯一的办法就是增加组相联度(如做成 8 路组相联:64×8×64=32KB64 \times 8 \times 64 = 32\text{KB})。这一经典几何约束深刻体现了操作系统分页规则如何直接塑造硬件微架构的物理形态。 :::