虚拟地址
需要预先了解:
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 的物理内存就有约 个可寻址单元。
虚拟地址(virtual address)是进程视角下的地址。每个进程都有自己独立的一套虚拟地址空间,从 0 开始编号。64 位系统上每个进程名义上拥有 的地址范围。
两者的对比:
| 虚拟地址 | 物理地址 | |
|---|---|---|
| 谁在使用 | 进程、C++ 代码、调试器 | 硬件、内核驱动 |
| 数量 | 每个进程独立一套 | 全机器共用一套 |
| 是否真实存在 | 只是编号,未必对应实际内存 | 真实存在的存储单元 |
| 连续性 | 连续的虚拟地址很常见 | 对应的物理页可以散落各处 |
两个关键点:
- 不同进程的地址互不冲突。两个进程可以同时拥有
0x00007FF6D3EA3000这个虚拟地址——各自的地址空间独立,翻译之后落在完全不同的物理位置上。 - 虚拟地址空间可以比物理内存大。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 = ,所以偏移占 12 位
- 高位部分是虚拟页号(virtual page number,VPN),用来查映射
翻译的过程:用 VPN 查页表得到对应的物理页框号(page frame number,PFN),再拼上页内偏移:
物理地址 = 页框号 × 4096 + 页内偏移
页表
每个进程有一张自己的页表,本质是一张"虚拟页号 → 物理页框号"的映射表。表的基本单位是页表项(page table entry,PTE),内容大致是:
+-------------------+-------------------------------------+
| 页框号(PFN) | 状态位:存在位、读写位、执行位、脏位… |
+-------------------+-------------------------------------+
页框号指明数据实际在哪一页物理内存;状态位记录这一页的权限与状态:
- 存在位:该虚拟页当前是否映射到了物理内存
- 读写位 / 执行位:这一页是否允许写入 / 执行(只读数据、代码页就是这样保护的)
- 脏位:这一页是否被写过(用于换出时决定是否写回磁盘)
多级页表
如果只用一级页表:48 位有效地址、4KB 页意味着 个页表项,每项 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 上编译同一个程序,全局变量的"地址模式"是相似的,因为两边都遵循同样的虚拟地址空间惯例。
注:内存基础 中讲过的"进程内存布局"——代码区、数据区、堆、栈——描述的正是每个进程自己的虚拟地址空间,而不是物理内存的真实排布。把这些区域画成一张图之所以可行,靠的正是虚拟地址给每个进程提供的统一假象。


