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