程序里的地址是真实内存地址吗?从虚拟内存到地址转换

写一行 printf("%p", &x),打出来一个像 0x7ffd2c4a3b4c 的值。它看着像个内存地址,但它不是内存条上那个物理地址 。程序里所有的指针、所有 mov 指令里带的地址,都是虚拟地址(也叫逻辑地址)。

CPU 把虚拟地址发出来之后,并不是直接拿去访问内存,中间还要过一道翻译,翻成物理地址才真的去读写。这道翻译就是虚拟内存,这篇文章讲它为什么存在、怎么翻。


为什么要虚拟内存

先想一下如果不用虚拟内存会怎样,也就是程序里直接写物理地址:

  • 两个程序想同时跑,得提前商量好各自占哪块物理内存,谁也别碰谁的。程序一多,这种"排座位"根本排不过来。
  • 程序编译的时候就得知道自己会被加载到哪个物理地址。可物理内存上哪里有空洞是运行的时候才知道的。
  • 用一个共享库,每个进程都得在自己的物理内存里放一份拷贝,浪费。

虚拟内存把"程序看到的地址"和"真实的物理地址"解绑,换来四样东西:

好处 怎么来的
隔离 每个进程一张自己的页表,看到的是自己独立的地址空间
不用关心物理布局 连续的一段虚拟地址可以映射到任意、离散的物理页
地址空间能超过物理内存 用不到的页放磁盘(swap),要用了再换进来
共享 同一块物理页可以同时映射进多个进程(共享库、fork 的 COW)

地址怎么翻译

翻译的粒度是页,不是每个字节。常见的页大小是 4KB,也就是 2^12 字节。于是一个虚拟地址被切成两段:

text 复制代码
虚拟地址 = [ 虚拟页号 VPN ] [ 页内偏移 offset ]
              高位              低 12 位

翻译只改前面那段高位:把虚拟页号换成物理页号(也叫物理页帧号 PFN),低 12 位的偏移原样搬过去。这样拼出来的就是物理地址。

text 复制代码
物理地址 = [ 物理页帧号 PFN ] [ 页内偏移 offset ]   ← 偏移和上面完全一样

干这件事的硬件叫 MMU (Memory Management Unit),它在 CPU 里,每次访存都会走一遍。维护"虚拟页号 → 物理页号"这张对照表的是页表(Page Table),由内核负责填。


页表长什么样

页表本质就是一个数组,用虚拟页号当下标,取出来的表项叫 PTE(Page Table Entry),里面存物理页帧号,再加上几个标志位:

位 名字 管什么
Present / Valid 有效位 这个页现在在不在物理内存里。在=1,不在(被换出去了)=0
Read/Write 读写位 这个页允许写吗,不允许写就是只读页(比如代码段)
User/Supervisor 权限位 用户态能不能访问,内核页这里是 0
Accessed 访问位 有没有被读过,给页面置换算法用
Dirty 脏位 有没有被写过,决定换出时要不要写回磁盘
NX 执行位 这个页能不能当代码执行,防缓冲区溢出

有效位和脏位这两个,是缺页和页面置换的基础。


单级页表为什么不够

先算一笔账。48 位虚拟地址、4KB 页大小,虚拟页的数量是

text 复制代码
2^48 / 2^12 = 2^36 个页

每个 PTE 8 字节,如果真用一个平铺的数组来存,一个进程就是

text 复制代码
2^36 × 8 字节 = 512 GB

光页表就要 512GB,显然不现实。而且绝大部分虚拟地址根本不会被用到,平铺等于给一片空地全铺上瓦。

解法是多级页表,本质是"只给用到的部分分配"。x86-64 用四级:

text 复制代码
虚拟地址(x86-64,实际只用了低 48 位):

 47      39 38      30 29      21 20      12 11        0
+----------+----------+----------+----------+-----------+
|  PML4    |  PDPT    |    PD    |    PT    | 页内偏移  |
|   9 位   |   9 位   |   9 位   |   9 位   |   12 位   |
+----------+----------+----------+----------+-----------+

翻译时一层层往下走:内核寄存器 CR3 存着第一级表(PML4)的物理地址,用最高的 9 位做下标取一项,那一项指向第二级表;再用接下来 9 位下标,如此往下走四级,最后一级拿出来的是物理页帧号,拼上 12 位偏移。

好处在于:如果某个 9 位区段整个没用到,上一层对应的表项直接置空就行,下级的表根本不用分配。一个进程真正用到的虚拟地址是一小片,多级页表就只为这片地址铺路。


TLB

多级页表解决了空间,却把时间搞坏了:四级就是四次访存,只为了算出物理地址,然后再访存一次拿数据------一次读内存变成五次。

所以 MMU 里加了个缓存,专存最近用过的"虚拟页号 → 物理页帧号"映射,叫 TLB(Translation Lookaside Buffer)。TLB 命中就一两个周期,直接出结果;miss 了才老老实实走那四级。程序的访问有很强的局部性,同一页会被反复访问,所以 TLB 命中率通常很高。

这也能回头解释上上一篇讲进程切换时说的 TLB 抖动:进程一换,页表换了,TLB 里按旧页表存的映射批量作废,接下来一段时间全是 TLB miss,每次访存都要多走几次页表。


缺页

访问一个虚拟地址时,MMU 发现它的 PTE 有效位是 0 ,说明这个页不在物理内存里,硬件就抛一个缺页异常(Page Fault),把控制权交给内核。内核对两种情况区别对待:

  • 地址是合法的 :它在进程的地址空间里(比如 mmap 了一段文件、或者这个页被换到 swap 去了),目前只是没加载进来。内核去磁盘把它读进一个物理页,填好 PTE,然后回到出问题的那条指令重新执行一遍。这回 MMU 就能翻译了,程序完全不知道中间发生过这个插曲。
  • 地址是非法的 :野指针,指向进程根本没有的地址。内核给进程发 SIGSEGV,就是我们熟悉的那句 Segmentation fault。

所以"每次访问内存都可能触发一次磁盘 IO"这件事是真的,只是靠局部性和预读把它摊薄了。malloc 一块很大的内存后不马上崩、第一次写才开始变慢,也是因为物理页是访问到才分配的(这叫按需分页,demand paging)。

这里能顺带解释一个现象:注释掉的代码不改指针、只改数据,只要不真的去访问这个地址,就可能一直不报错------因为缺页和权限检查都发生在访问的那一刻,页表项没被碰到就没事。

相关推荐
INGNIGHT1 小时前
486. 预测赢家(博弈dp/区间dp)
linux·c++
秋饼1 小时前
Claude Haiku 5.5 + Spring AI 2.0 企业级低成本高并发 AI Agent 实战:子智能体分工、成本熔断与百万上下文落地
java·ai·技术分享·后端开发
xuxigifxfh1 小时前
牛客:BISHI8 大整数哈希
java·算法·哈希算法
涉密IT资质笔记1 小时前
涉密资质投入产出分析模型:时间成本、四块成本构成与 ROI 决策框架
java·开发语言·算法
木井巳2 小时前
【BFS 解决拓扑排序】火星词典
java·算法·leetcode·广度优先·宽度优先·图搜索算法
霸道流氓气质2 小时前
Java调用StableDiffusion/ComfyUI图像生成:DAG工作流编排、WebSocket实时进度与Flux模型集成的生产级实战
java·websocket·stable diffusion
Allstar_432 小时前
拆解全星APQP系统的六大核心质量工具如何联动——汽车供应链企业专业级研发项目管理系统
运维·服务器
随便写个软件2 小时前
【新版本】EasyExplorer资源管理器 3.12.0——地址栏重要更新
运维·服务器·前端·everything·资源管理器
北风toto2 小时前
KMP 算法详解:从暴力匹配到 O(n+m) 的字符串搜索
java·开发语言·算法