虚拟内存是现代计算机系统中最重要的概念之一。
当一个程序运行时,它看起来拥有一块属于自己的、巨大而连续的内存空间。
例如,程序可能访问这样一个地址:
0x00401000
但这个地址通常并不直接对应物理内存 RAM 中的某个实际位置。
现代 CPU 和操作系统会在程序使用的地址与物理内存中的真实位置之间进行转换。
这套机制就是 虚拟内存(Virtual Memory)。
从整体上看,过程可以简化为:
程序
↓
虚拟地址
↓
MMU
↓
页表
↓
物理地址
↓
缓存 / RAM
虚拟内存让程序可以使用内存,而不需要知道自己的数据实际上存放在 RAM 的什么位置。
它同时也是进程隔离、内存保护、共享内存以及许多现代操作系统功能的基础。
在第一部分中,我们重点回答一个最基本的问题:
一个虚拟地址究竟是怎样变成物理地址的?
1. 为什么计算机需要虚拟内存?
假设一台计算机完全没有虚拟内存,所有程序都直接使用物理 RAM 地址。
例如,程序 A 可能占用:
0x00000000 – 0x0FFFFFFF
而程序 B 占用:
0x10000000 – 0x1FFFFFFF
这样一来,每个程序都必须准确知道自己被加载到了物理内存中的什么位置。
程序之间还必须小心避免覆盖彼此的内存。
一个简单的编程错误,就有可能把数据写进另一个程序正在使用的内存区域。
这样管理内存会变得非常困难。
虚拟内存在程序和物理内存之间增加了一层抽象。
操作系统不让程序直接控制物理地址,而是给每个进程提供属于自己的 虚拟地址空间(Virtual Address Space)。
程序只需要使用虚拟地址。
至于这些虚拟地址实际对应物理 RAM 中的什么位置,则由 CPU 和操作系统在后台完成转换。
2. 虚拟地址与物理地址
计算机内存系统中有两类非常重要的地址。
虚拟地址(Virtual Address),是运行中的程序所看到和使用的地址。
物理地址(Physical Address),则对应物理内存中的真实位置。
例如:
虚拟地址
0x40001000
↓
物理地址
0x18A32000
程序看到的是:
0x40001000
但数据实际上可能存储在 RAM 中完全不同的位置:
0x18A32000
另一个进程甚至也可以使用完全相同的虚拟地址:
0x40001000
但这个地址可以被映射到另一个物理位置。
例如:
进程 A
0x40001000
↓
0x18A32000
进程 B
0x40001000
↓
0x72B11000
因此,同一个虚拟地址,在不同的进程中可以对应完全不同的物理地址。
这正是虚拟内存非常重要的能力之一。
3. 每个进程都有自己的地址空间
当操作系统创建一个进程时,会为这个进程建立属于它自己的虚拟地址空间。
一个简化的进程地址空间可能类似这样:
高地址
+———————-+
| 栈 Stack |
+———————-+
| |
| 空闲空间 |
| |
+———————-+
| 堆 Heap |
+———————-+
| 全局数据 |
+———————-+
| 程序代码 |
+———————-+
低地址
实际的地址空间布局会受到操作系统、CPU 架构以及其他因素影响。
但最重要的概念是:
程序看到的是一个干净、连续的虚拟地址空间。
物理内存实际的布局完全不必长成这样。
操作系统可以把一个进程的不同部分放到 RAM 中完全不同的位置。
4. 虚拟内存被划分成页面
虚拟内存通常不是一个字节一个字节进行管理的。
它会被划分成固定大小的内存块,这些内存块称为 页面(Page)。
一种非常常见的页面大小是:
4 KB
物理内存也会被划分成相同基本大小的内存块,通常称为 页框(Page Frame),也可以理解为物理页面。
概念上:
虚拟内存
Page 0
Page 1
Page 2
Page 3
……
物理 RAM:
Frame 0
Frame 1
Frame 2
Frame 3
……
操作系统负责把虚拟页面映射到物理页框。
例如:
Virtual Page 0 → Physical Frame 81
Virtual Page 1 → Physical Frame 12
Virtual Page 2 → Physical Frame 205
这里有一个非常重要的地方。
这些物理页框并不需要彼此相邻。
也就是说,一个程序看到的虚拟内存可能是连续的,但这些虚拟页面对应的数据,在物理 RAM 中却可能分散在完全不同的位置。
5. 一个虚拟地址包含页号和偏移量
使用分页机制之后,一个虚拟地址在概念上可以分成两个部分:
Virtual Page Number | Page Offset
也就是:
虚拟页号(Virtual Page Number)
和
页内偏移量(Page Offset)
虚拟页号用来确定这个地址属于哪个虚拟页面。
偏移量则确定具体访问这个页面中的哪一个字节。
假设页面大小为:
4 KB
那么:
4 KB = 4096 字节 = 2^12 字节
因此,一个 4 KB 页面需要 12 位二进制数字来表示页面内部的位置。
所以虚拟地址最低的 12 位可以作为页内偏移量。
概念上:
虚拟地址
+———————-+————-+
| 虚拟页号 | 页内偏移量 |
+———————-+————-+
|
1 2 |
12 bits |
在地址转换过程中,虚拟页号会被转换。
但是页内偏移量通常保持不变。
转换之后:
物理地址
+———————-+————-+
| 物理页框号 | 页内偏移量 |
+———————-+————-+
这就是分页式虚拟内存最基本的原理之一。
6. 页表保存映射关系
CPU 必须知道:
一个虚拟页面究竟对应哪个物理页框?
操作系统把这种对应关系保存在一种称为 页表(Page Table) 的数据结构中。
一个简化的页表可能包含:
Virtual Page 0 → Physical Frame 81
Virtual Page 1 → Physical Frame 12
Virtual Page 2 → Physical Frame 205
每一个映射关系都会保存在一个 页表项(Page Table Entry) 中。
页表项通常简称:
PTE
一个 PTE 保存虚拟页面与物理页面之间的映射信息。
除此之外,它通常还会包含一些控制信息,例如:
物理页框号
读权限
写权限
执行权限
用户态或内核态访问权限
目前我们首先关注其中最重要的信息:
物理页框号。
因为有了它,CPU 才能够把虚拟地址转换成物理地址。
7. MMU 负责完成地址转换
CPU 内部有专门处理虚拟内存地址转换的硬件。
它叫做:
Memory Management Unit
也就是:
MMU,内存管理单元。
假设程序执行:
value = array[i];
CPU 最终需要计算出一个内存地址。
这个地址通常是虚拟地址。
整个过程大致可以表示为:
↓
虚拟地址
↓
MMU
↓
页表
↓
物理地址
↓
内存
MMU 会取得虚拟地址中的虚拟页号,然后找到它对应的物理页框。
接着,再把物理页框号与原来的页内偏移量组合起来。
概念上:
虚拟页号
↓
地址转换
↓
物理页框号
与此同时:
页内偏移量
↓
保持不变
最后得到:
物理地址。
8. 为什么页表会变得非常大?
现代计算机支持非常巨大的虚拟地址空间。
最简单的设计方法,是建立一个巨大的页表,为每一个可能存在的虚拟页面都准备一个页表项。
但这样做会浪费大量内存。
因为绝大多数程序实际上只使用整个虚拟地址空间中的一小部分。
因此,现代处理器通常采用:
多级页表(Multi-Level Page Tables)
概念上可以表示为:
虚拟地址
↓
第一级页表
↓
第二级页表
↓
第三级页表
↓
第四级页表
↓
页表项
↓
物理页框
这样就不需要一次性建立一个覆盖整个虚拟地址空间的巨大页表。
系统只需要为真正使用到的部分建立相应的页表结构。
不同 CPU 架构使用的页表级数和具体格式可能不同。
但是基本思想非常类似:
只建立真正需要的页表部分,从而减少内存浪费。
9. 什么是 Page Table Walk?
当 CPU 需要把一个虚拟地址转换成物理地址时,有时必须沿着多级页表逐级查找。
这个过程叫做:
Page Table Walk
也就是通常所说的:
页表遍历。
概念上:
虚拟地址
↓
第一级页表
↓
第二级页表
↓
第三级页表
↓
最终页表项
↓
物理地址
这里有一个明显的问题。
页表本身也是存储在内存中的。
如果 CPU 每访问一次普通数据,都必须先额外访问内存好几次,只为了查询页表,那么整个内存访问过程就会变得非常慢。
因此,现代 CPU 又设计了一个非常小、但极其重要的硬件结构来解决这个问题。
10. TLB 让地址转换变得更快
Translation Lookaside Buffer
简称:
TLB
它是处理器内部一个很小但速度非常快的缓存。
TLB 保存最近使用过的:
虚拟地址 → 物理地址
转换结果。
在执行完整的 Page Table Walk 之前,CPU 会首先检查 TLB。
概念上:
虚拟地址
↓
TLB
↙ ↘
Hit Miss
↓ ↓
直接使用 遍历页表
映射结果
↓ ↓
物理地址
如果 TLB 中已经存在需要的地址转换结果,就叫:
TLB Hit
也就是 TLB 命中。
CPU 可以迅速获得对应的物理地址。
如果 TLB 中没有这个转换结果,就叫:
TLB Miss
也就是 TLB 未命中。
这时处理器就必须查询页表层级,完成 Page Table Walk。
找到新的地址转换结果以后,通常还会把它存入 TLB。
这样下一次再访问同一个页面时,就可以更快地完成地址转换。
11. TLB 和 CPU Cache 不是一回事
TLB 很容易和 CPU Cache 混淆。
但它们负责的是完全不同的工作。
TLB 保存的是:
虚拟地址
→
物理地址
也就是说:
TLB 缓存的是地址转换结果。
而 CPU Cache,例如:
L1 Cache
L2 Cache
L3 Cache
保存的是:
程序指令和数据。
因此,一个简化的内存访问过程可以理解为:
↓
虚拟地址
↓
TLB / MMU
↓
物理地址
↓
L1 Cache
↓
L2 Cache
↓
L3 Cache
↓
真实的现代 CPU 会进行大量优化,有些操作甚至会并行或重叠执行。
但是从概念上理解:
TLB 缓存的是地址转换。
而:
L1、L2、L3 Cache 缓存的是程序指令和数据。
它们是两种完全不同的硬件结构,解决的是两类不同的问题。
12. 一个完整的地址转换实例
现在我们把前面的内容全部连接起来。
假设 CPU 正在执行一条需要读取内存的指令。
Step 1:CPU 计算虚拟地址
例如:
Virtual Address
0x40001234
Step 2:地址被分成两个部分
概念上:
虚拟页号
页内偏移量
Step 3:CPU 检查 TLB
如果对应的地址转换已经保存在 TLB 中:
TLB Hit
CPU 就可以快速获得物理页框。
如果没有:
TLB Miss
那么可能需要进行一次 Page Table Walk。
Step 4:页表提供映射关系
例如:
Virtual Page 100
↓
Physical Frame 500
也就是说:
虚拟页 100
被映射到:
物理页框 500
Step 5:组合物理页框号和偏移量
概念上:
物理页框号
页内偏移量
=
物理地址
注意:
在整个转换过程中,页内偏移量通常保持不变。
真正发生变化的是:
虚拟页号
↓
物理页框号
Step 6:CPU 访问内存层次结构
得到物理地址以后,CPU 就可以通过缓存和内存系统寻找真正的数据。
整个过程大致可以表示为:
程序
↓
虚拟地址
↓
TLB
↓
MMU
↓
页表
↓
物理地址
↓
CPU Cache
↓
对于程序本身来说,它通常完全看不到这些复杂过程。
程序只是简单地:
访问一个内存地址。
13. 为什么虚拟内存如此强大?
虚拟内存把原本紧密联系在一起的两件事情分开了:
程序看到的内存是什么样子
和:
数据实际上存储在 RAM 的什么位置
这种分离给了操作系统非常大的控制能力。
一个进程可以看到简单、连续、可预测的地址空间。
但它所使用的物理内存却可以分散在 RAM 中的很多不同位置。
不同进程甚至可以使用相同的虚拟地址,而彼此之间不会发生冲突。
操作系统还可以控制:
哪些内存区域允许某个进程访问,
哪些内存只能读取,
哪些内存可以写入,
哪些内存可以执行。
因此,虚拟内存并不仅仅是一种“让内存看起来更大”的技术。
它是现代多任务操作系统能够正常工作的核心基础机制之一。
14. 总结
程序通常不会直接访问物理内存地址。
它使用的是:
虚拟地址。
虚拟内存把地址空间划分成:
页面(Pages)。
物理内存则被划分成:
物理页框(Page Frames)。
操作系统建立:
页表(Page Tables)
用来描述虚拟页面如何映射到物理页框。
CPU 内部的:
MMU
负责执行虚拟地址到物理地址的转换。
而:
TLB
会缓存最近使用过的地址转换结果,使 CPU 不需要每一次访问内存都重新遍历页表。
整个基本流程可以总结成:
程序
↓
虚拟地址
↓
TLB / MMU
↓
页表
↓
物理地址
↓
Cache / RAM
到这里,我们已经理解了:
程序所使用的一个普通虚拟地址,最终是怎样一步一步找到物理内存中真正的数据位置的。
但是还有一个非常重要的问题没有解决。
如果某个虚拟页面当前根本没有对应 CPU 所需要的物理内存,会发生什么?
还有:
如果一个程序试图读取或写入它没有权限访问的内存,又会发生什么?
这时,操作系统内核就会更加深入地参与进来。
在 第二部分 中,我们将继续介绍:
Page Fault、Demand Paging、Memory Protection、Shared Memory、Copy-on-Write,以及当单纯的地址转换已经无法解决问题时,操作系统究竟如何管理虚拟内存。
