Menu Close

虚拟内存到底是如何工作的?(2):缺页异常、按需分页、内存保护、共享内存与写时复制

在第一部分中,我们沿着一条虚拟地址一路追踪到了物理地址。

虚拟内存到底是如何工作的?(2):缺页异常、按需分页、内存保护、共享内存与写时复制

我们看到,一个进程如何产生虚拟地址,CPU 中的内存管理单元,也就是 MMU,如何对这个地址进行转换;页表如何描述虚拟地址与物理地址之间的映射关系;以及转换后备缓冲器,也就是 TLB,如何加快这一过程。

最基本的路径大致如下:

虚拟地址 → TLB → MMU → 页表 → 物理地址 → Cache / RAM

但是,这马上会引出一个非常重要的问题:

如果某个虚拟页面当前并没有有效地映射到物理内存,会发生什么?

到了这里,虚拟内存才真正开始变得有意思。

现代操作系统做的事情,并不仅仅是把虚拟地址翻译成物理地址。

它还必须动态决定:

  • 哪些页面应该留在 RAM
  • 哪些页面可以以后再加载
  • 哪些内存属于哪个进程
  • 哪些页面可以共享
  • 哪些页面允许被修改
  • 进程访问当前不可用的内存时,系统应该怎么办

要理解这些机制,我们需要继续了解缺页异常、按需分页、内存保护、共享内存以及写时复制。


1. 虚拟地址并不意味着物理内存一定存在

一个进程可以拥有很大的虚拟地址空间。

但这并不意味着,它的每一个虚拟页面都必须对应 RAM 中的一个物理页框。

假设一个进程认为自己拥有下面这个虚拟页面:

Virtual Page 0x1234

页表中可能存在如下映射:

Virtual Page 0x1234

Physical Frame 0x08A7

在这种情况下,MMU 可以正常完成地址转换。

但是,页表也可能告诉 CPU:这个页面当前并不在物理内存中。

从概念上看:

Virtual Page 0x1234

Page Table Entry

Present = 0

这时候,CPU 就不能继续执行这次内存访问了。

它必须产生一个异常。

这个事件就叫做:

缺页异常,Page Fault。


2. 什么是缺页异常?

当一个进程访问某个虚拟页面,而这个页面当前无法通过正常方式完成地址转换时,就会发生缺页异常。

CPU 会在地址转换过程中检测到这个问题。

它不会继续完成这次内存访问,而是把控制权转交给操作系统

整个过程大致如下:

程序访问虚拟地址

MMU 检查地址转换

所需页面当前不可用

CPU 触发 Page Fault

操作系统接管控制

这里有一个非常重要的概念:

Page Fault 并不自动等于程序出错。

这是理解虚拟内存时非常关键的一点。

实际上,很多缺页异常完全属于正常现象。

操作系统甚至会故意利用 Page Fault 来实现自己的内存管理策略。


3. 并不是每一次 Page Fault 都意味着程序崩溃

操作系统收到一个 Page Fault 时,它首先必须判断:

为什么会发生这个异常?

可能存在很多不同的原因。

这个页面可能本身完全合法,只是目前还没有加载到 RAM

也可能是进程正在访问自己没有权限访问的内存。

还可能是程序正在试图修改一个只读页面。

或者,操作系统正在使用写时复制,也就是 Copy-on-Write。

因此,操作系统必须检查发生异常的地址,以及相关页表项中的信息。

从概念上看:

Page Fault

这个地址是否合法?

Yes —————- No
|         |
处理异常    终止进程或发送信号

这个区别非常重要。

Page Fault 是一个硬件事件。

但是:

接下来该怎么办,是由操作系统的策略决定的。


4. 按需分页:只有真正需要时才加载内存

Page Fault 最重要的用途之一,就是实现:

按需分页,Demand Paging。

按需分页的意思是:

操作系统没有必要在程序启动时,就把整个程序的所有页面全部加载进 RAM

相反,某些页面完全可以等到真正被访问时再加载。

假设我们有一个很大的应用程序。

它的可执行文件中可能包含:

  • 启动代码
  • 用户界面代码
  • 网络代码
  • 打印代码
  • 错误处理代码
  • 很少使用的函数
  • 各种库
  • 数据

在一次具体运行过程中,这个程序可能根本不会使用其中很多部分。

如果程序刚启动时就把所有内容全部装入 RAM,不仅浪费时间,也会浪费物理内存。

因此,操作系统可以让很多页面一开始处于未加载状态。

当程序真正访问某个页面时:

CPU 访问页面

页面当前不在 RAM

Page Fault

操作系统加载所需页面

更新页表

重新执行原来的指令

而整个过程中,程序本身通常并不知道发生过这些事情。


5. 按需分页发生 Page Fault 时,系统到底做了什么?

假设一个程序访问了某个属于自身可执行文件的页面。

这个页面是合法的,但当前还没有被加载到 RAM 中。

一个简化后的处理过程大致如下。

首先,CPU 尝试访问这个虚拟地址。

MMU 检查页表后发现,对应的页表项目前并不处于 Present 状态。

于是 CPU 产生 Page Fault,并进入操作系统内核。

接下来,操作系统会判断这个地址是否合法。

如果地址合法,操作系统会寻找一个空闲的物理页框。

随后,它从存储设备中读取所需的数据,并把数据加载到这个物理页框中。

然后,操作系统更新页表。

例如:

Virtual Page 42

Physical Frame 891

随后,这个页表项会被标记为 Present。

操作系统返回之前被中断的程序。

CPU 再次执行刚才失败的那条指令。

这一次,地址转换成功。

程序继续运行。


6. 为什么按需分页非常有用?

Demand Paging 带来了几个非常重要的好处。

更快的程序启动速度

操作系统不需要在程序真正开始执行之前,就把整个可执行文件全部加载到 RAM

只需要先加载当前马上要使用的页面。

因此,程序可以更快启动。

更低的 RAM 占用

程序中暂时没有使用的部分,不需要占用物理内存。

可以同时运行更多进程

因为每个进程在某个时刻可能只使用自己虚拟地址空间的一部分,所以有限的物理 RAM 可以同时支持更多正在运行的程序。

大型虚拟地址空间成为可能

一个进程所看到的虚拟地址空间,可以远远大于当前真正分配给它的物理 RAM

这种虚拟内存与物理内存之间的分离,是现代操作系统最重要的基础机制之一。


7. 如果 RAM 已经满了怎么办?

当系统中还有空闲物理页框时,按需分页比较简单。

但是如果 RAM 已经被大量占用呢?

这时候,操作系统可能需要主动释放一个物理页框。

为此,它可以选择某个现有页面作为“牺牲页面”,也就是 victim page。

概念上:

RAM 已满

选择一个页面

这个页面能否直接丢弃?

重新使用它的物理页框

如果这个页面中的数据可以重新从文件中读取,操作系统可能直接把它丢掉。

例如,一个没有被修改过的程序代码页面,通常可以在以后需要时重新从可执行文件读取。

但是,如果这个页面包含的是已经修改过的匿名内存,那么情况就不同了。

操作系统可能需要找另外一个地方暂时保存它的内容。

历史上以及今天的很多系统中,这通常会涉及:

Swap Space,交换空间。


8. Swap Space 与页面换出

Swap 是一种存储空间,可以用来保存暂时从 RAM 中移出的内存页面。

一个简化的过程是:

RAM 中的页面

被换出

Swap 存储空间

之后,如果进程再次访问这个页面:

进程访问页面

Page Fault

操作系统从 Swap 中读取页面

页面重新进入 RAM

但是这里有一个非常现实的问题。

存储设备的速度远远低于 RAM

因此,如果系统频繁进行页面换入换出,性能就可能严重下降。

当物理内存不足以容纳当前的活动工作集时,系统可能不断地在 RAM 和存储之间搬运页面。

这种状态被称为:

Thrashing,内存颠簸。

在这种情况下,计算机会把大量时间花在搬运页面上,而不是执行真正有用的计算任务。


9. 页表同时也负责内存保护

页表并不仅仅用于地址转换。

一个页表项中通常还包含一些控制信息,用来描述这个页面允许如何被访问。

具体的权限位根据不同处理器架构可能有所区别,但通常包含类似下面的概念:

  • Present
  • Readable
  • Writable
  • Executable
  • User Accessible
  • Kernel Only

不同 CPU 架构中的具体位定义可能不同。

但是基本思想是一样的。

MMU 在执行内存访问时,不只是进行地址转换。

它还会检查这些权限。

因此:

虚拟内存同时也是一种由硬件支持的安全机制。


10. 读、写和执行权限

假设某个页面中存放的是程序指令。

操作系统可能把它设置为:

Read = Yes
Write = No
Execute = Yes

也就是说:

这个页面可以读取,可以执行,但不能修改。

而一个普通数据页面可能设置为:

Read = Yes
Write = Yes
Execute = No

也就是说:

它可以被读取和修改,但不能被当作程序代码执行。

如果程序试图执行一个不被允许的操作,CPU 就会产生异常。

例如:

程序尝试写入

页面是只读的

Protection Fault

操作系统接管控制

随后,操作系统再决定如何处理。

Unix 类系统中,非法内存访问最终可能导致系统向进程发送类似:

SIGSEGV

这样的信号。

它通常就是我们熟悉的:

Segmentation Fault,段错误。


11. 虚拟内存把不同进程隔离开来

假设现在有两个进程

Process A

和:

Process B

两个进程完全可能使用同一个虚拟地址:

0x0000000040001000

但是,两个进程各自的页表,可以把这个相同的虚拟地址映射到完全不同的物理页框。

例如:

Process A:
Virtual Page 100

Physical Frame 500

而:

Process B:
Virtual Page 100

Physical Frame 900

虚拟地址完全一样。

但是实际使用的物理内存却不同。

这正是进程之间能够彼此隔离的重要原因之一。

Process A 通常不能简单地构造一个任意指针,然后读取 Process B 的私有内存。

原因很简单:

Process A 的页表中根本没有指向那块物理内存的映射。


12. 内核拥有自己受到保护的内存

操作系统内核本身也需要使用内存。

但是普通应用程序显然不能被允许随意修改内核中的数据结构。

页表中的权限机制帮助硬件建立这条安全边界。

某个页面可以被设置成:

只有当 CPU 运行在足够高的特权级时才能访问。

从概念上看:

User Process

X

Kernel Memory

这条边界保护着大量非常重要的操作系统结构,例如:

  • 进程信息
  • 页表
  • 设备状态
  • 文件系统结构
  • 内核代码
  • 安全信息

如果没有硬件支持的内存保护,一个有 Bug 的普通应用程序就可能轻而易举地破坏整个操作系统


13. 共享内存:不同进程可以映射同一个物理页面

进程隔离非常重要。

但是有些时候,不同进程又需要有意地共享内存。

虚拟内存同样可以做到这一点。

假设两个进程分别拥有不同的虚拟页面:

Process A Virtual Page

以及:

Process B Virtual Page

操作系统可以配置两个进程各自的页表,让它们同时指向同一个物理页框。

例如:

Process A Virtual Page ──┐
├── Physical Frame 700
Process B Virtual Page ──┘

现在,两个进程访问的实际上就是同一块物理内存。

这就是:

Shared Memory,共享内存。

共享内存可以实现非常快速的进程间通信。

因为很多情况下,数据不需要再经过另一个通信通道复制一遍。


14. 共享库也可以共享物理内存

共享内存并不只是用于显式的进程间通信。

我们再考虑一个非常常见的例子:

共享库。

假设十个程序同时使用同一个只读共享库。

操作系统并不一定需要在物理 RAM 中保存十份完全相同的库代码。

相反,多个进程的虚拟地址空间,可以让各自的库页面共同映射到同一组物理页框。

例如:

Process A ──┐
Process B ──┤
Process C ──┼── RAM 中的共享库代码
Process D ──┤
Process E ──┘

只要这些页面没有被修改,它们就可以继续被多个进程共享。

这样就能够节省大量物理内存。

再次强调:

不同进程中的虚拟地址可以不同,但是底层对应的物理页面却完全可以是同一个。


15. 写时复制:先共享,真正需要时再复制

虚拟内存中还有另外一种非常强大的技术:

Copy-on-Write,写时复制。

通常缩写为:

COW。

它的核心思想其实非常简单:

在真正有人想修改这块内存之前,不要复制它。

假设两个进程一开始拥有完全相同的内存页面。

最直接的做法,是立刻建立两份完全独立的物理副本。

但这样做可能非常浪费。

操作系统可以换一种方式:

让两个进程一开始共同映射同一个物理页面。

例如:

Process A ──┐
├── Shared Physical Page
Process B ──┘

但是,这个页面暂时会被设置为禁止写入。

只要两个进程都只是读取这个页面,就完全没有必要进行复制。


16. Copy-on-Write 被触发时会发生什么?

假设现在 Process A 尝试修改这个共享页面。

因为这个页面为了实现 Copy-on-Write,被暂时标记为只读,所以这次写操作不能直接完成。

CPU 会产生一个异常。

操作系统收到异常以后,发现:

这并不是普通的非法写入。

这是一个:

Copy-on-Write 页面。

于是,操作系统创建一个新的物理页面,并把原始页面中的数据复制到新页面。

之后:

Process A

New Physical Page

而:

Process B

Original Physical Page

操作系统随后修改 Process A 的页表项,让它指向新的物理页面,并允许写入。

然后,原来失败的指令再次执行。

现在 Process A 就可以修改自己的私有副本了。

而 Process B 仍然继续使用原来的页面。

所以:

真正昂贵的内存复制,只会在确实发生写操作时才进行。


17. 为什么 fork() 能从 Copy-on-Write 中获得巨大好处?

Copy-on-Write 在 Unix操作系统中尤其重要。

一个典型例子就是:

fork() 系统调用。

从程序的角度来看,fork() 会创建一个新的进程

进程的内存内容一开始看起来几乎与父进程完全相同。

一种非常简单粗暴的实现方法是:

立即把父进程的整个内存复制一份。

但是这样可能非常昂贵。

现代操作系统可以采用更加高效的方法。

刚刚执行 fork() 时,父进程和子进程可以暂时共享大量相同的物理页面。

例如:

Parent ──┐
├── Shared Pages
Child ────┘

这些共享页面通过 Copy-on-Write 机制进行保护。

如果父进程和子进程都没有修改某个页面,它就可以一直保持共享。

只有当其中一个进程真正写入时,操作系统才为那个进程建立一份私有副本。

这样就极大地提高了进程创建效率。

尤其是在下面这种非常常见的情况中:

fork() → exec()

它的效果更加明显。

因为子进程在执行 exec() 后,很可能马上用另一个程序替换自己的大部分地址空间。

这样一来,大量原来共享的页面甚至根本没有机会被复制。


18. 一个完整的 Page Fault 示例

现在,我们可以把前面介绍的各种机制组合起来看一次。

假设程序执行一条指令,需要访问某个内存地址。

CPU 得到:

Virtual Address

首先检查 TLB。

如果 TLB 中已经缓存了有效的地址转换结果,那么内存访问可以很快继续。

但如果发生 TLB Miss,系统就必须进一步获得页表中的地址转换信息。

假设页表项显示:

这个页面当前并不在物理内存中。

CPU 就会产生:

Page Fault。

操作系统接管控制后,首先会问:

这个虚拟地址是否合法?

如果地址不合法,进程可能收到错误、信号,甚至直接被终止。

如果地址合法,操作系统就需要继续判断:

这次 Page Fault 到底是什么原因造成的?

例如:

这个页面可能属于程序的可执行文件,需要从文件中加载。

这个页面也可能以前被换出到了 Swap。

或者,这是一个刚刚分配的新页面,程序第一次访问它。

也可能是程序刚刚触发了 Copy-on-Write。

操作系统会根据具体情况执行相应的处理。

它可能:

  • 加载页面
  • 分配新的物理页框
  • 从 Swap 中读取页面
  • 复制页面
  • 修改映射关系

随后更新页表,并从异常处理程序返回。

原来的指令再次执行。

这一次,内存访问成功。


19. 硬件与操作系统共同完成虚拟内存

虚拟内存并不是完全由硬件实现的。

也不是完全由软件实现的。

它实际上是:

CPU 硬件与操作系统之间的合作。

硬件提供了各种底层机制,例如:

  • MMU
  • 页表支持
  • TLB
  • 权限检查
  • 异常机制
  • CPU 特权级

操作系统负责各种策略,例如:

  • 哪些页面存在
  • 哪些页面应该被加载
  • 使用哪些物理页框
  • 哪些页面可以共享
  • 哪些页面允许写入
  • 如何处理 Page Fault
  • 哪些页面可以被换出

可以这样理解:

CPU 负责快速检测内存访问过程中发生了什么。

而:

操作系统负责决定这些情况意味着什么,以及接下来应该怎么办。


20. 虚拟内存远远不只是“把硬盘当 RAM”

虚拟内存有时候会被简单解释成:

RAM 不够时,就拿硬盘来顶替 RAM

这种说法并不完全错误。

但是它非常不完整。

Swap 的确可以是虚拟内存系统的一部分。

但虚拟内存包含的内容远远不只是 Swap。

它还提供:

  • 地址转换
  • 进程隔离
  • 内存保护
  • 按需分页
  • 共享内存
  • 共享库
  • Copy-on-Write
  • Memory-Mapped Files
  • 灵活的内存分配

所以,虚拟内存最重要的目的,并不是:

让存储设备假装自己是 RAM

它更深层的作用是:

为软件提供一个受控制、受保护、并且非常灵活的虚拟地址空间,然后由操作系统把这个虚拟地址空间映射到真实的硬件资源之上。


21. 从一个指针一直走到物理硬件

在 C 程序中,一个指针看起来非常简单:

int *ptr;

但是,当 CPU 真正访问这个指针中保存的地址时,背后可能会牵涉到一整套复杂机制。

整个路径可能包括:

Program Pointer

Virtual Address

TLB

MMU

Page Table

Permission Check

必要时触发 Page Fault

Operating System

Physical Page Frame

CPU Cache

RAM

而且,如果所需页面当前并不存在于物理内存中,操作系统还可能需要:

  • 从存储设备加载数据
  • 分配新的物理内存
  • 复制一个页面
  • 修改地址映射关系

完成这些操作以后,那条原本被中断的 CPU 指令才能继续执行。

所以,对程序员来说看起来只是一次简单的:

内存访问

实际上,却是软件和硬件之间一次受到严格控制的复杂协作过程。


Conclusion

虚拟内存首先创造了一层抽象。

程序使用的是:

虚拟地址。

真正的物理 RAM 中保存的是:

物理页框。

页表把两者连接起来。

MMU 负责执行地址转换。

TLB 则让频繁使用的地址转换变得更快。

但是,真正让虚拟内存变得强大的地方,是:

当地址转换无法正常完成的时候会发生什么。

Page Fault 允许操作系统介入。

通过 Page Fault,操作系统可以实现按需分页。

它可以只在真正需要某个页面时才把它加载进 RAM

当物理内存紧张时,它可以把暂时不需要的页面从 RAM 中移出去。

它可以利用页表权限实现:

  • 读权限
  • 写权限
  • 执行权限
  • 用户权限
  • 内核权限

它可以把一个进程与另一个进程隔离开。

它也可以故意让多个进程映射到同一块物理内存,实现共享。

通过 Copy-on-Write,它甚至可以让多个进程暂时共享同一个页面,直到其中某个进程真的需要修改数据时才进行复制。

因此,虚拟内存完整的概念,并不仅仅是:

Virtual Address → Physical Address

更准确地说,它应该是:

Virtual Address → Translation → Protection → Operating-System Policy → Physical Memory

也就是:

虚拟地址 → 地址转换 → 内存保护 → 操作系统策略 → 物理内存

硬件地址转换与操作系统控制策略结合起来,构成了现代多任务计算机最基础、也最重要的机制之一。

在下一部分中,我们还可以继续深入操作系统在实际环境中到底如何管理内存,包括:

mmap、Memory-Mapped Files、Heap、Stack、Anonymous Memory、Page Allocation、Swapping,以及 Linux 在一个进程真正申请内存时,到底做了什么。

Gate & Kernel — understanding computing from software all the way down to the hardware.

Posted in 计算机结构教程