页表:虚拟内存是怎么实现的
前置:进程(地址空间、exec 怎么把程序装进内存)、状态切换、权限与安全(
satp、trampoline、PTE_U)。实践:pgtbl lab(解题)和 03.1 深入(读懂输出、程序一生里页表怎么变)。后面的 traps、cow、mmap lab 都建立在这一篇上。
宿主机实验(Ubuntu 26.04,内核 7.0,AMD Ryzen 7 5850U):
cd local/system/code gcc -O2 pagetable.c -o pagetable && ./pagetable # 懒分配、缺页次数、大页和 TLB cat /proc/self/maps # 一个真实进程的地址空间
01-process 里说过一句"每个进程有自己的地址空间"。这一篇回答它背后的问题:
- 程序里的地址是怎么变成物理内存地址的?
- 这张"翻译表"长什么样、存在哪、谁来维护?
- 有了这一层翻译,操作系统能玩出哪些花样?代价又是什么?
0. 心智模型:先记住这一张图
页表不是硬件魔法,而是内核用普通内存读写维护的一棵树。硬件只负责按这棵树查地址;查什么、怎么查、查不到怎么办,全由内核决定。
写结构、写权限 按树翻译每一次访存
内核 ───────────────────► 页表(内存里的树)◄─────────────────── MMU
▲ 读、清 A/D 位 │ ▲ 置 A/D 位 │
│ │ └──────────────────────────────┤
│ ▼ ▼
│ satp 指向树根 TLB:翻译结果的缓存
│ (改了树要 sfence.vma 清掉)
└──── 翻译失败 → 缺页异常 → 内核插手:杀进程,或者补好映射再让程序重试
三个角色,各管一件事:
| 角色 | 做什么 | 不做什么 |
|---|---|---|
| 内核 | 决定树的形状:哪些虚拟页映射到哪些物理页、带什么权限;读和清 A/D 位;处理缺页 | 不参与每一次访存,翻译是硬件做的 |
| 硬件(MMU) | 每次访存按树查;权限不对或查不到就报缺页;访问时置 A、写时置 D | 不分配内存,不决定映射,不同步 TLB |
| TLB | 缓存最近的翻译结果,让大部分访存不用走树 | 不知道内存里的树被改了,除非内核用 sfence.vma 告诉它 |
由这张图能推出几条贯穿后面所有内容的结论:
- 地址空间就是一张清单。 进程有多少内存、每段能做什么,全写在 PTE 里。表里没有的地址就不存在(§2、§9)。
- 页表是存在内存里的树。 页表页是普通的物理页,内核靠直接映射去读写它;三级结构只给用到的地址段分配节点,所以省内存(§4、§6、§7)。
- 页表是内核和硬件共享的数据结构。 内核写结构和权限,硬件写 A/D 位。有共享就有一致性问题:改了 PTE,TLB 里可能还是旧的(§8、§11)。
- 权限附在映射上,而不是物理页上。 同一个物理页,在内核的直接映射里可读写,在用户的映射里可以只读或者根本不出现。共享就是两个 PTE 指向同一个物理页(§10)。
- 映射粒度是一种取舍。 4KB 灵活,2MB 让 TLB 覆盖更大、缺页更少,但要求物理连续,记账也变粗(§12)。
- 翻译失败是内核插手的时机。 懒分配、写时复制、内存映射文件、换出,都是"故意让翻译失败,在缺页时补上"(§10)。后面的 traps、cow、mmap lab 都建立在这一条上。
后面每一节都是在展开其中的某一条。
1. 名词速查
| 名词 | 全称 / 意思 | 在 xv6 / RISC-V 里 |
|---|---|---|
| 虚拟地址(VA) | virtual address,程序里用的地址 | 39 位有效(Sv39),xv6 只用到 MAXVA = 1 << 38 |
| 物理地址(PA) | physical address,内存条上的真实位置 | RAM 从 0x80000000(KERNBASE)到 PHYSTOP,共 128MB |
| 页(page) | 翻译的最小单位 | 4KB(PGSIZE),地址低 12 位是页内偏移 |
| 物理页 / 页框(frame) | 一块 4KB 的物理内存 | kalloc() 一次给一页 |
| PTE | page table entry,页表项 | 64 位:物理页号 + 权限位 |
| PPN / VPN | physical / virtual page number | 地址去掉低 12 位偏移之后的部分 |
| 页表页 | 存 PTE 的那一页 | 一页放 512 个 PTE |
| MMU | memory management unit | CPU 里每次访存都按页表做翻译的硬件 |
satp |
supervisor address translation and protection | 存当前顶层页表的物理地址。换它就是换地址空间 |
| TLB | translation lookaside buffer | MMU 里缓存翻译结果的小表 |
sfence.vma |
内存管理用的屏障指令 | 清掉 TLB 里的旧翻译 |
| 缺页(page fault) | 翻译失败或权限不够时的异常 | scause 12(取指)、13(读)、15(写),stval 是出错地址 |
| 大页(superpage / huge page) | 一个 PTE 映射 2MB 或 1GB | 在中间层放叶子 PTE |
| 直接映射(direct map) | 虚拟地址 = 物理地址 | xv6 内核把整块 RAM 这样映射 |
2. 为什么要多一层翻译
如果程序直接用物理地址,会同时出现三个问题:
| 问题 | 直接用物理地址时 | 有了页表 |
|---|---|---|
| 隔离 | 任何程序都能读写任何内存,包括内核和别的进程 | 进程只能访问自己页表里有的东西 |
| 地址冲突 | 两个程序都想从地址 0 开始,链接时就得错开 | 每个进程都从 0 开始,各自翻译到不同的物理页 |
| 连续性 | 程序要 1MB 连续内存,物理上就得找到连续的 1MB | 虚拟上连续即可,物理页可以东一块西一块 |
页表就是一张"虚拟页 → 物理页 + 权限"的映射表。CPU 每一次取指令、每一次读写内存,MMU 都要查一遍。
用 pgtbltest 的真实输出看第 3 条:虚拟地址 0x0、0x1000、0x2000、0x3000、0x4000 连在一起,物理页却是 0x85F7E000、0x85F7B000、0x85F7A000、0x85F79000、0x85F78000,倒着排,中间还有空洞(被页表页占了)。程序完全感觉不到。
一句话:页表是一层间接。计算机里"加一层间接"能解决的问题,内存管理里几乎都用它解决了。第 10 节列出它带来的所有能力。
3. 一张大表行不行:算一笔账
最朴素的做法:按虚拟页号直接下标,一个数组装下所有 PTE。
- Sv39 有 2^39 / 2^12 = 2^27 个虚拟页;每个 PTE 8 字节;
- 一张表 = 2^27 × 8B = 1GB,而且每个进程一张;
- 而一个典型的 xv6 进程只用了 8 页。
绝大部分表项都是空的,却要占内存。解决办法是把表做成树:只给"真的有映射"的那几段分配页表页。
4. Sv39:三级页表
虚拟地址的拆法
38 30 29 21 20 12 11 0
┌────────────┬────────────┬────────────┬──────────────┐
│ L2 下标 9位 │ L1 下标 9位 │ L0 下标 9位 │ 页内偏移 12位 │
└────────────┴────────────┴────────────┴──────────────┘
- 每级页表正好是一页:2^9 = 512 个 PTE × 8 字节 = 4KB。
- 顶层(L2)一项管 1GB,中间层(L1)一项管 2MB,最底层(L0)一项管 4KB。
- xv6 里的宏:
PX(level, va)取第level级的下标,PXSHIFT(level)= 12 + 9 × level。
走一遍:USYSCALL 页的地址怎么翻译
用 pgtbl lab 里 vmprint 实际打印的数字,翻译 va = 0x3FFFFFD000:
va = 0x3FFFFFD000
L2 下标 = va >> 30 = 255
L1 下标 = (va >> 21) & 0x1FF = 511
L0 下标 = (va >> 12) & 0x1FF = 509
偏移 = va & 0xFFF = 0
satp → 顶层页表 0x85F81000
[255] = pte 0x217E0001 → 只有 V,是指针 → 下一级在 0x85F80000
[511] = pte 0x217DFC01 → 只有 V,是指针 → 下一级在 0x85F7F000
[509] = pte 0x217EC413 → 有 R、U,是叶子 → 物理页 0x85FB1000
物理地址 = 0x85FB1000 + 0 = 0x85FB1000
PTE 怎么变成物理地址:PTE2PA(pte) = (pte >> 10) << 12。比如 0x217EC413 >> 10 = 0x85FB1,再左移 12 位就是 0x85FB1000。低 10 位 0x013 是标志位。
硬件每次 TLB 不命中都在做这件事。 xv6 的 walk()(kernel/vm.c)用软件把同样的过程走一遍,内核需要自己查页表时(建映射、copyin/copyout 翻译用户指针)就调用它。
为什么是 39 位、为什么 xv6 只用 38 位
64 位地址目前用不完,Sv39 只翻译低 39 位,并要求第 39 到 63 位都等于第 38 位("符号扩展"),否则就是非法地址。xv6 干脆只用到 1 << 38,避开了高半部分要符号扩展的麻烦。所以 vmprint 打印的顶端地址是 0x3fc0000000,而评分脚本也接受它的符号扩展形式 0xffffffffc0000000。
5. PTE:64 位里有什么
63 54 53 10 9 8 7 6 5 4 3 2 1 0
┌─────────┬──────────────────────┬─────┬─┬─┬─┬─┬─┬─┬─┬─┐
│ 保留 │ PPN(物理页号,44 位) │ RSW │D│A│G│U│X│W│R│V│
└─────────┴──────────────────────┴─────┴─┴─┴─┴─┴─┴─┴─┴─┘
| 位 | 名字 | 意思 | 谁设置 |
|---|---|---|---|
| 0 | V | 这一项有效 | 内核 |
| 1–3 | R / W / X | 可读 / 可写 / 可执行 | 内核 |
| 4 | U | U 态可以访问。没有 U,只有内核能访问 | 内核 |
| 5 | G | global,所有地址空间都有这条映射(TLB 切换时可以保留) | 内核,xv6 不用 |
| 6 | A | accessed,这一页被读、写或执行过 | 硬件 |
| 7 | D | dirty,这一页被写过 | 硬件 |
| 8–9 | RSW | 留给软件随便用 | 内核(cow lab 会用它标"写时复制") |
| 10–53 | PPN | 物理页号 | 内核 |
几条规则:
- R、W、X 全是 0 → 这是指向下一级的指针;只要有一位是 1,它就是叶子。所以中间层只有 V(vmprint 里 pte 低位是
0x001的那些行)。 - 只给 W 不给 R 是保留组合,非法。
- 叶子可以出现在任何一级:L0 的叶子映射 4KB;L1 的叶子映射 2MB(大页);L2 的叶子映射 1GB。
拿 pgtbltest 的几个 perm 练习拆位:
| perm | 二进制(D A G U X W R V) | 拆出来 | 是什么 |
|---|---|---|---|
| 0x5B | 0101 1011 | A U X R V | 代码页,执行过 |
| 0xD7 | 1101 0111 | D A U W R V | 用户栈,读过也写过 |
| 0x07 | 0000 0111 | W R V | 栈保护页:没有 U,用户一碰就缺页 |
| 0x13 | 0001 0011 | U R V | 用户只读的共享页 |
| 0x4B | 0100 1011 | A X R V | trampoline:没有 U,只有陷入后的内核能执行 |
6. 页表存在哪、谁来改
页表页就是普通的物理页,用 kalloc() 分配,里面是 512 个 uint64。内核改页表,就是往这些页里写整数。xv6 的 freewalk() 能用一个递归函数释放整张页表,walk() 能用一个循环模拟硬件的查找,都是因为页表就是内存里的一棵普通的树。
写它的不只是内核。 内核写 PPN 和 R/W/X/U/V;硬件在访问时把 A 位、写入时把 D 位直接写回内存里的 PTE(§11)。所以页表是两方共享的数据结构,后面所有"改了 PTE 要不要刷 TLB"的问题都源于这一点。
这引出一个"先有鸡还是先有蛋"的问题:内核也运行在页表之下,它怎么访问页表页的物理地址?
- xv6 的内核页表把整块 RAM 直接映射:虚拟地址 0x80000000 就是物理地址 0x80000000,一直到
PHYSTOP。 - 所以内核拿到一个物理地址,可以直接当指针用:
(pagetable_t)PTE2PA(pte)就能下标访问下一级页表。 - 开机时分页还没打开,
kvmmake()用物理地址建好内核页表;然后kvminithart()写satp打开分页。因为是直接映射,打开分页的前后,同一个地址指向同一个地方,正在执行的代码不会"跳走"。
Linux 也有同样的直接映射区(x86-64 上叫 physmap / direct map),只是它不在虚拟地址等于物理地址的位置,而是整体平移到一个高地址。
7. 多级省了什么、多了什么
省内存:pgtbltest 整个进程只用了 5 个页表页(20KB):
顶层 ─┬─ [0] → L1 → L0:0x0 起的 5 页(代码、数据、保护页、栈)
└─ [255] → L1 → L0:顶端 3 页(USYSCALL、TRAPFRAME、TRAMPOLINE)
对比一张大表的 1GB。中间那几百 GB 的空洞,在顶层只是 510 个 V=0 的项。
多了查找成本:一次完整翻译要依次读 3 个 PTE,也就是 3 次内存访问,之后才能做真正的那一次访存。如果每次访存都这样,内存速度要慢 4 倍。解决办法是 TLB。
8. TLB:翻译结果的缓存
TLB 存"虚拟页 → 物理页 + 权限"的最近翻译结果。命中时不用走页表;不命中时硬件走一遍页表,再把结果填进 TLB。
| 层级 | 典型大小 | 4KB 页时覆盖 | 2MB 页时覆盖 |
|---|---|---|---|
| L1 DTLB | 几十项(比如 64) | 256KB | 128MB |
| L2 TLB | 一两千项(比如 2048) | 8MB | 4GB |
程序访问的内存超过 TLB 能覆盖的范围,就会频繁 TLB 不命中,每次都要走页表。这就是大页的主要价值(第 11 节)。
TLB 是缓存,就有一致性问题。 内核改了内存里的 PTE,TLB 里可能还留着旧的。RISC-V 不会自动同步,要靠 sfence.vma 清掉:
| 什么时候 | 为什么 | xv6 在哪做 |
|---|---|---|
换 satp(换地址空间) |
TLB 里全是上一个地址空间的翻译 | uservec、userret 换页表前后各一次;kvminithart |
| 删掉或改小一个映射 | 不清的话,进程还能通过旧的 TLB 项访问那一页 | xv6 靠下一次换 satp 时清,每次陷入都会换 |
| 清掉 A 或 D 位 | 硬件只在 TLB 不命中时才会去置 A、D | 同上 |
xv6 每次进出内核都清空整个 TLB,简单但浪费。真实系统用 ASID(地址空间编号)给 TLB 项打标签:切换进程时不用清空,各进程的翻译可以同时留在 TLB 里。Meltdown 之后 Linux 的 KPTI 让用户态和内核态用两套页表,PCID/ASID 就成了减少这笔开销的关键。
9. xv6 的两种地址空间
用户地址空间(pgtbltest 的真实布局)
0x3FFFFFF000 TRAMPOLINE R X (没有 U) 内核代码里的那一页,和内核页表映射在同一个虚拟地址
0x3FFFFFE000 TRAPFRAME R W (没有 U) 陷入时保存用户寄存器
0x3FFFFFD000 USYSCALL R U 只读共享页(pgtbl lab 加的)
……几百 GB 的空洞……
0x5000 sz:堆从这里往上长(sbrk)
0x4000 用户栈 R W U
0x3000 保护页 R W (没有 U) 栈溢出时缺页,而不是悄悄写坏数据
0x2000 数据 + bss R W U
0x0 代码 R X U
内核地址空间
0x3FFFFFF000 TRAMPOLINE 和每个用户页表里的同一页
内核栈,每个栈下面隔一个不映射的保护页
……
PHYSTOP ────────────
空闲 RAM、内核数据 R W 直接映射
0x80000000 内核代码 R X 直接映射(KERNBASE)
0x0C000000 PLIC(中断控制器) R W 设备寄存器
0x10001000 VIRTIO 磁盘 R W
0x10000000 UART 串口 R W
几个设计点:
- 两张表都映射了 trampoline,而且在同一个虚拟地址。 换
satp的那条指令执行完,PC 指向的地址在新页表里仍然有效(见 04 §3)。 - 保护页(guard page)就是一个故意不给权限的页。 用户栈下面那一页没有 U,内核栈下面那一页干脆不映射。栈溢出时立刻缺页,而不是写坏相邻的数据。代价只是一点虚拟地址,不占物理内存(内核栈的保护页)或只占一页。
- 设备寄存器也是映射进来的。 内核往 UART 的地址写一个字节,物理总线把它送给串口而不是内存。页表不关心物理地址背后是 RAM 还是设备。
- 内核代码 R X 不可写,数据 R W 不可执行(W^X)。有漏洞让攻击者写了内存,也不能直接在里面执行。
10. 一层间接带来的能力
页表的威力在于:翻译可以失败,失败时陷入内核,内核可以修好再让程序重试。程序完全感觉不到。
| 能力 | 怎么用页表 | 在哪见到 |
|---|---|---|
| 隔离 | 每个进程一张表,表里没有的就访问不到 | 所有 lab |
| 保护 | R/W/X/U 位;保护页 | 栈保护页、W^X |
| 共享 | 两张表的 PTE 指向同一个物理页 | trampoline;只读共享页(vDSO);共享库的代码 |
| 懒分配 | 先只增大 sz,不映射;第一次访问缺页时再分配 |
xv6 的 sbrklazy + vmfault;Linux 的 malloc 大块内存 |
| 写时复制(COW) | fork 时父子共享物理页、都标成只读;谁先写谁缺页,内核这时才复制 | cow lab |
| 内存映射文件 | 页表指向文件内容所在的页,缺页时从磁盘读 | mmap lab |
| 空指针检测 | 第 0 页永远不映射 | Linux 默认;xv6 默认把代码放在 0 |
| 换出与回收 | 把不常用的页写到磁盘、清掉 PTE;再访问时缺页读回来。靠 A 位判断"常不常用" | Linux 的页面回收 |
权限附在映射上,不在物理页上
同一个物理页可以同时出现在好几张页表里,每次出现的权限都可以不同:
| 物理页 | 在内核页表里 | 在用户页表里 |
|---|---|---|
| trampoline 那一页 | R X(内核代码的一部分) | R X,没有 U,只有陷入后才能执行 |
| 进程的 trapframe 页 | R W(直接映射) | R W,没有 U |
| 只读共享页(vDSO 那种) | R W,内核随时更新 | R U,用户只能读,伪造不了 |
| 用户的数据页 | R W(直接映射,内核 copyout 就是写这里) |
R W U |
所以问"这一页能不能写"没有意义,要问"通过哪个映射能不能写"。这也是 cow lab 的出发点:父子进程共享同一个物理页,各自的 PTE 都标成只读,物理页本身什么都没变。
一个映射的一生:四件事要配对
给进程加一个页(xv6 里的 trapframe 页就是现成的例子),要做四件事,分在不同的时刻:
| 时刻 | 做什么 | trapframe 页在 xv6 里 |
|---|---|---|
| 进程创建 | 分配物理页 | allocproc 里 kalloc |
| 建页表 | 把它映射进用户页表 | proc_pagetable 里 mappages |
| 拆页表 | 解除映射,不释放物理页 | proc_freepagetable 里 uvmunmap(…, 0) |
| 进程销毁 | 释放物理页 | freeproc 里 kfree |
为什么映射和释放要分开:页表跟着程序走,物理页跟着进程走。 exec 会建一张新页表、拆掉旧的,但进程还是那个进程,trapframe 要留下来映射进新页表。四件事少一件:少了解除映射,freewalk 碰到叶子会 panic;少了释放,就是内存泄漏;解除映射时顺手释放了,exec 之后就是悬空指针。
懒分配在宿主机上就能看到:mmap 1GB 之后 RSS 只有 1MB;每页写一遍,触发 262146 次缺页,RSS 才涨到 1025MB。
11. A 位和 D 位:硬件帮内核记账
A(accessed)和 D(dirty)是硬件主动写回内存里的 PTE 的两个位,内核只负责读和清。
| 位 | 内核拿它做什么 |
|---|---|
| A | 页面回收:定期清掉 A,过一阵再看。还是 0 的页最近没人用,适合换出去(时钟算法,LRU 的近似) |
| A | 工作集估计:一段时间里有多少页被访问过 |
| D | 写回:内存映射文件的页,只有 D=1 才需要写回磁盘 |
| D | 增量复制:虚拟机热迁移、增量快照,只复制上次以来写过的页 |
两点要注意:
- RISC-V 允许两种实现:硬件自动置 A/D,或者在 A/D 为 0 时直接报缺页,由内核软件去置位。QEMU 是前一种。写可移植的内核时两种都要考虑。
- 大页让记账变粗:一个 2MB 大页只有一个 PTE,也就只有一个 A、一个 D。写了其中 1 个字节,整个 2MB 都算脏。所以需要精细脏页追踪的场景(比如热迁移)会先把大页拆成 4KB 页。
12. 大页:用一个 PTE 映射 2MB
在 L1 放一个叶子 PTE,它就映射一整块 2MB,物理上必须连续,并且虚拟地址和物理地址都要 2MB 对齐。
从树的角度看,大页只是让树的中间层也可以有叶子,把底下一整棵子树换成一个 PTE。硬件的查找规则不变(碰到 R/W/X 不全为 0 就停),所以大部分软件也不用知道某个地址落在大页里:查到叶子就行。会出问题的只有默认"一个 PTE 管 4KB"的代码,比如按 4KB 步进地遍历、复制、释放,或者用 PTE 里的物理地址直接加页内偏移。
好处
| 好处 | 原因 | 宿主机实测(1GB 匿名内存) |
|---|---|---|
| TLB 覆盖范围大 512 倍 | 一个 TLB 项管 2MB | 随机指针追逐:4KB 页每步 83 到 94 ns,2MB 大页 55 到 67 ns |
| 走页表少一级 | TLB 不命中时只读 2 个 PTE | (同上) |
| 缺页少 512 倍 | 一次缺页就映射 2MB | 每页写一遍:262146 次缺页、约 480 ms,大页 512 次、约 125 ms |
| 页表本身更小 | 少了整层 L0 页表页 | xv6 内核:95 个大页 PTE 省掉 95 个 L0 页表页(380KB) |
代价
| 代价 | 说明 |
|---|---|
| 需要物理连续、对齐的 2MB | 系统跑久了物理内存碎片化,凑不出来。所以要么开机时预留,要么运行时整理(Linux 的内存规整 compaction) |
| 内部浪费 | 程序只用 100KB,也得给 2MB |
| 清零延迟 | 第一次缺页要清零 2MB,这一次缺页明显更慢(总量反而更省) |
| 记账粒度变粗 | 一个 A/D 位管 2MB(见上一节) |
| 部分释放要拆分 | 只释放大页的一半时,要先拆(demote)成 512 个 4KB 映射;反过来把 512 个 4KB 页合成大页叫升级(promote) |
这些权衡正是 Navarro 等人 OSDI 2002 论文(LEC 9)讨论的:什么时候预留、什么时候升级、怎么避免碎片。
Linux 的两种大页
- 透明大页(THP):内核自动用大页,程序不用改。模式在
/sys/kernel/mm/transparent_hugepage/enabled:always、madvise(只对调用过madvise(MADV_HUGEPAGE)的区域用,本机就是这个)、never。后台线程khugepaged会把凑齐的 512 个小页合并成大页。/proc/self/smaps的AnonHugePages显示用了多少。 - hugetlbfs:开机或运行时预留一批 2MB / 1GB 页,程序通过
mmap(MAP_HUGETLB)显式申请。不会被换出,不需要规整。延迟敏感的系统(数据库、交易系统、DPDK)常用它。
13. Linux 对照
| xv6 | Linux(RISC-V / x86-64) |
|---|---|
| Sv39,3 级 | RISC-V 支持 Sv39 / Sv48 / Sv57;x86-64 是 4 级(PML4),新 CPU 可以开 5 级(PML5),可用虚拟地址分别是 256TB 和 128PB |
walk() |
pgd_offset → p4d → pud → pmd → pte_offset(include/linux/pgtable.h),用同一套代码适配不同级数 |
vmfault() |
handle_mm_fault()(mm/memory.c);入口在 arch/riscv/mm/fault.c、arch/x86/mm/fault.c |
p->sz 一个数 |
struct vm_area_struct 链(现在是 maple tree),每段有自己的权限和来源:cat /proc/self/maps 每行就是一个 VMA |
| 只读共享页(USYSCALL) | [vvar] + [vdso]:clock_gettime 不陷入内核 |
| 内核直接映射 | direct map(x86-64 不开 KASLR 时从 0xffff888000000000 起) |
| 每次陷入都换页表 | 默认用户页表里也映射了内核(权限隔开);开了 KPTI 才换,靠 PCID 减少 TLB 清空 |
看真实进程的页表:/proc/<pid>/pagemap 给出每个虚拟页的物理页号。但从 2015 年(Linux 4.0 前后)开始,非 root 用户读到的物理页号都是 0:知道物理地址能帮助 Rowhammer 这类攻击定位相邻的内存行。这也是一种"不让用户知道物理地址"的隔离。
读码任务:在 mm/memory.c 里从 handle_mm_fault() 跟到 do_anonymous_page(),看匿名页第一次缺页时怎么分配、清零、填 PTE。对照 xv6 的 vmfault(),它们做的是同一件事。
14. 一张表
| 问题 | 答案 |
|---|---|
| 页表是什么 | 存在物理内存里的一棵三级树,每个节点一页、512 个 PTE |
| 谁查它 | 硬件(MMU)每次访存都查;内核用 walk() 软件查 |
| 谁改它 | 内核用普通内存写改 PTE;硬件只改 A、D 位 |
| 当前用哪张 | satp 指向的那张;换进程就换 satp |
| 怎么区分指针和叶子 | R、W、X 全 0 是指针 |
| 怎么做隔离 | 每个进程一张表;PTE_U 区分用户和内核 |
| 为什么分多级 | 只给用到的地址段分配页表页,省内存 |
| 多级的代价 | 一次翻译要读多次内存,靠 TLB 缓存 |
| 改了 PTE 之后 | sfence.vma,否则 TLB 里可能还是旧的 |
| 大页 | 在中间层放叶子;TLB 覆盖大、缺页少;要物理连续和对齐,记账变粗 |
| 缺页 | 不一定是错误:懒分配、COW、换出、mmap 都靠它 |
自测:不看笔记能不能做到
- 画出 §0 那张图:内核、MMU、TLB 各自读写页表的哪些部分。
- 手动翻译
0x3FFFFFD000:三级下标各是多少,每一级读出的 PTE 怎么变成下一级页表的地址(§4)。 - 说出 trapframe 页从分配到释放的四个时刻,以及为什么映射和释放要分开。
- 解释为什么清掉 A 位之后,不刷 TLB 可能会漏报。
- 举一个"同一个物理页、两种权限"的例子。
思考题(先自己答)
- 一个 xv6 进程只用了 0x0 到 0x5000 这 5 页,再加顶端 3 页。它的页表一共占几页?如果它把堆长到 1GB(全用 4KB 页),页表大约要多少页?
- 为什么中间层 PTE 不需要 U 位,权限只看叶子?(提示:如果中间层也检查权限,大页和普通页的规则会怎样?)
- 用户栈的保护页为什么用"去掉 U"而不是"不映射"?两种做法对内核有什么区别?
- 内核把 PTE 的 W 位清掉,但没有执行
sfence.vma。接下来同一个进程往那一页写,会发生什么? - 宿主机实验里,为什么大页版"每页写一遍"省下的时间,主要来自缺页次数,而不是 TLB?
- fork 之后父子进程都有一份页表。如果父进程用了大页,而子进程只能用 4KB 页(大页用完了),会有什么问题吗?