需要预先了解:


1. 虚拟地址

内存基础 里说过:内存中每个字节都有唯一的编号,CPU 通过编号定位并读写数据。不过有一个重要的事实没有展开——你在 C++ 代码里拿到的所有地址,都不是物理内存的真实编号,而是虚拟地址(virtual address)。

物理内存长什么样、数据实际放在哪,进程既不知道也不需要知道。进程眼里只有一套自己的虚拟地址空间,而把虚拟地址翻译成物理地址、决定数据实际存放位置的工作,由硬件和操作系统在幕后完成。

这篇文章先确认"你看到的全是虚拟地址"这件事,再看虚拟地址和物理地址的映射是怎么实现的,最后聊聊这套机制换来了什么。


1.1 你看到的地址都是虚拟地址

先看一段最普通的代码:

#include <cstdio>

int g_value = 42;        // 全局变量,位于数据区

int main()
{
    int local = 10;      // 局部变量,位于栈
    int* p = &g_value;   // 指针,存储的是 g_value 的虚拟地址

    printf("g_value 的地址: %p\n", (void*)&g_value);
    printf("local   的地址: %p\n", (void*)&local);
    printf("p 中存储的值:   %p\n", (void*)p);
    return 0;
}

编译并运行代码后,看到这样的输出:

g_value 的地址: 00007FF6D3EA3000
local   的地址: 0000008AF17FF8E4
p 中存储的值:   00007FF6D3EA3000

这三个地址都是虚拟地址:全局变量在虚拟地址空间的数据区,局部变量在栈区。把程序再运行一次,会发现地址变了——这不是内存"漂移"了,而是操作系统每次启动进程时都会重新安排虚拟地址的分配(配合 ASLR,Address Space Layout Randomization,地址空间布局随机化——故意把各区域装载到随机的基地址上,增加攻击者预测地址的难度)。

再看汇编层面的证据。把上面的代码用 g++ -O0 -S 生成汇编,关键指令是:

lea     rax, [rip+g_value]        # 取 g_value 的虚拟地址
mov     QWORD PTR [rbp-8], rax    # 存入局部变量 p

lea 指令里的 [rip+g_value] 在链接与加载时会被填成 g_value 的虚拟地址,也就是说地址作为立即数直接写在指令里。CPU 执行这条指令拿到的是虚拟地址,真正用它访问内存时,再由硬件翻译成物理地址。

由此可以下一个结论:

  • C++ 代码里的 &a、指针的值、函数指针——虚拟地址
  • 调试器、反汇编器、打印出来的地址——虚拟地址
  • int* 在 64 位系统下是 8 字节,因为虚拟地址是 64 位

物理地址对上层完全不可见,只有内存控制器、MMU 和操作系统内核的一部分在关心它。你没有任何合法的手段在用户态代码里拿到物理地址。


1.2 虚拟地址与物理地址

物理地址(physical address)是 DRAM 芯片上每个存储单元的真实编号,能编号的范围由地址总线宽度决定。例如 16GB 的物理内存就有约 2342^{34} 个可寻址单元。

虚拟地址(virtual address)是进程视角下的地址。每个进程都有自己独立的一套虚拟地址空间,从 0 开始编号。64 位系统上每个进程名义上拥有 2642^{64} 的地址范围。

两者的对比:

虚拟地址 物理地址
谁在使用 进程、C++ 代码、调试器 硬件、内核驱动
数量 每个进程独立一套 全机器共用一套
是否真实存在 只是编号,未必对应实际内存 真实存在的存储单元
连续性 连续的虚拟地址很常见 对应的物理页可以散落各处

两个关键点:

  1. 不同进程的地址互不冲突。两个进程可以同时拥有 0x00007FF6D3EA3000 这个虚拟地址——各自的地址空间独立,翻译之后落在完全不同的物理位置上。
  2. 虚拟地址空间可以比物理内存大。64 位系统上有效虚拟地址通常是 48 位,即 256TB,而物理内存可能只有 16GB。虚拟地址只是"编号",没用到的编号不占任何资源。

1.3 映射是怎么实现的

虚拟地址到物理地址的翻译由 MMU(Memory Management Unit,内存管理单元)完成——它是 CPU 内的一块硬件,不是软件。支撑翻译的数据结构是页表(page table),由操作系统维护。

分页

现代系统普遍采用分页(paging)机制:把虚拟地址空间和物理内存都切成固定大小的页(page),x86-64 上常见页大小为 4KB。映射以页为单位建立——虚拟页对应物理页:

虚拟地址空间                          物理内存

0x002000 ┌────────┐             ┌────────┐ 0x8A000
         │  页 a  │ ──────────> │  页 a  │
0x003000 ├────────┤             ├────────┤ 0x21000
         │  页 b  │ ────────┐   │  页 y  │
0x004000 ├────────┤         │   ├────────┤ 0x9F000
         │        │         └─> │  页 b  │
         │ 未映射  │             │        │
         └────────┘             └────────┘

注意右图:连续的虚拟页对应的物理页可以散落在物理内存的任何位置,甚至暂时不在物理内存里(未分配或被换出到磁盘)。

分页之下,一个虚拟地址被拆成两部分:

64 位虚拟地址(x86-64 常用 48 位有效位):

 63            47                  11        0
 +--------------+------------------+----------+
 | 扩展/未使用位 |    虚拟页号 VPN    |  页内偏移  |
 |   (16 位)   |     (36 位)     |  (12 位) |
 +--------------+------------------+----------+
  • 低 12 位是页内偏移(offset)——页大小 4KB = 2122^{12},所以偏移占 12 位
  • 高位部分是虚拟页号(virtual page number,VPN),用来查映射

翻译的过程:用 VPN 查页表得到对应的物理页框号(page frame number,PFN),再拼上页内偏移:

物理地址 = 页框号 × 4096 + 页内偏移

页表

每个进程有一张自己的页表,本质是一张"虚拟页号 → 物理页框号"的映射表。表的基本单位是页表项(page table entry,PTE),内容大致是:

+-------------------+-------------------------------------+
| 页框号(PFN)      | 状态位:存在位、读写位、执行位、脏位…  |
+-------------------+-------------------------------------+

页框号指明数据实际在哪一页物理内存;状态位记录这一页的权限与状态:

  • 存在位:该虚拟页当前是否映射到了物理内存
  • 读写位 / 执行位:这一页是否允许写入 / 执行(只读数据、代码页就是这样保护的)
  • 脏位:这一页是否被写过(用于换出时决定是否写回磁盘)

多级页表

如果只用一级页表:48 位有效地址、4KB 页意味着 2362^{36} 个页表项,每项 8 字节,单张表就是 32GB——每个进程一张显然不可能。

实际的做法是多级页表:把 36 位 VPN 拆成 4 段、每段 9 位,形成一棵 4 层的查表树:

47       38 37       28 27       18 17       11        0
+-----------+-----------+-----------+-----------+--------+
| PGD 索引   |  PUD 索引  |  PMD 索引  |  PT 索引  | 页内偏移 |
+-----------+-----------+-----------+-----------+--------+
    9 位         9 位        9 位        9 位      12 位

翻译一次地址要依次查 4 张表:

CR3(页表基址,进程切换时由操作系统更换)
 |
 +--[PGD 索引]--> PGD 表 ───> PUD 表的地址
                   +--[PUD 索引]--> PUD 表 ───> PMD 表的地址
                                    +--[PMD 索引]--> PT 表 ───> 页表项 PTE
                                                                  |
物理地址 = PTE 中的页框号 × 4096 + 页内偏移 <─────────────────────────+

多级页表的巧妙之处在于按需分配:进程的地址空间通常非常稀疏(低地址放代码数据、高地址放栈,中间大量空洞),空洞对应的整棵子树根本不用创建。每个进程实际只需要为"真正用到的地址范围"分配页表。

TLB:给翻译加缓存

按上面的流程,访问一个字节要额外读 4 次内存(查 4 张表),代价难以接受。实际上这个开销几乎不存在,靠的是 TLB(Translation Lookaside Buffer,地址转换旁路缓存)——MMU 内部缓存"虚拟页号 → 页框号"映射的高速缓存:

访问 0x003012 的过程:
  1. MMU 拆分地址:页号 0x3,偏移 0x012
  2. 查 TLB:命中 → 直接得到页框号,约 1 个时钟周期
  3. TLB 未命中 → 走 4 级页表查表(4 次内存访问),结果填回 TLB
  4. PTE 标记"不存在"或权限不符 → 触发缺页异常,交给操作系统处理

程序具有局部性:短时间内访问的地址集中在少数几页里,所以 TLB 的命中率极高(通常超过 99%),绝大多数翻译在 1 个周期内完成。这也解释了一个实践规律——顺序访问内存比跳跃访问快,不仅是缓存友好的问题,跳跃访问还会让 TLB 频繁失效。

缺页

当 MMU 查到的 PTE 标记"不存在"或权限不符时,CPU 触发缺页异常(page fault),操作系统接管处理:

  • 访问的虚拟页还没建立映射,但分配是合法的(如 malloc 后首次写入、栈自动增长)→ 分配物理页、建立映射、返回重新执行指令
  • 访问的页被换出到磁盘(物理内存紧张时)→ 读回内存、更新映射
  • 访问完全非法的地址(野指针、越界)→ 无法补救,进程收到段错误并终止

同一个"缺页异常",前两种是正常且必需的机制,最后一种才是程序错误。


1.4 虚拟地址的意义

花这么大代价(页表、MMU、TLB)做一层中间映射,换来的是一系列关键能力。

进程隔离

每个进程一套独立地址空间,一个进程里的指针无论怎么构造,都只落在自己的地址空间里,物理上不可能直接读写另一个进程的内存。一个程序写崩了只影响自己,整个系统的稳定性由此而来。

内存保护

页表项的权限位让"非法访问"在硬件层面被拦截。C++ 里一个熟悉的崩溃场景:

const char* s = "hello";                 // 字符串字面量位于只读页
char* p = const_cast<char*>(s);
p[0] = 'H';                              // 典型情况下程序直接崩溃

崩溃的完整链路:写入时 MMU 发现该页的 PTE 标记为只读 → 触发保护异常 → 操作系统判定无法补救 → 进程终止。没有这套机制,这行代码可能"安静地"改掉只读数据,埋下更深的隐患。

按需分配与懒加载

物理内存只在真正被访问时才分配。malloc / new 只在虚拟地址空间里"记账",并不立即占用物理内存:

// 分配 1GB 虚拟地址空间,但没有写入任何数据
char* buffer = new char[1024 * 1024 * 1024];

此时打开任务管理器(详细信息页,添加"提交大小"与"工作集"两列)会发现:提交大小增加了约 1GB,工作集(实际占用的物理内存)几乎不变。直到你逐页写入,缺页异常才会一页一页地换来真实内存。

共享内存

多个虚拟页可以映射到同一个物理页。最常见的例子是动态库:系统里几十个进程都加载了同一个 libc,它的代码在物理内存里只需要一份,各进程把它映射进自己的地址空间即可。跨进程通信的"共享内存"机制用的也是同一原理。

物理内存不必连续

想要 1GB 连续内存?在虚拟地址空间里要 1GB 连续的编号很容易,而它对应的物理页可以散落在物理内存的各处。这让 malloc 永远不必担心"物理内存里凑不出连续大块"(外部碎片)的问题,也解释了 内存基础 里进程内存布局图中堆与栈之间的巨大空洞——那只是编号上的预留,不占任何实际资源。

统一的程序布局

每个进程的地址空间布局都是同样的形状:代码区在固定位置附近、堆向上增长、共享库与栈在高地址区。链接器只需要按这套固定布局给符号分配虚拟地址,加载器按映射表装载即可,完全不用关心这台机器有多少物理内存、当前还剩多少。你在 Windows 和 Linux 上编译同一个程序,全局变量的"地址模式"是相似的,因为两边都遵循同样的虚拟地址空间惯例。

注:内存基础 中讲过的"进程内存布局"——代码区、数据区、堆、栈——描述的正是每个进程自己的虚拟地址空间,而不是物理内存的真实排布。把这些区域画成一张图之所以可行,靠的正是虚拟地址给每个进程提供的统一假象。