Hang Zhengyang

页表:虚拟内存是怎么实现的

前置:进程(地址空间、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 里说过一句"每个进程有自己的地址空间"。这一篇回答它背后的问题:

  1. 程序里的地址是怎么变成物理内存地址的?
  2. 这张"翻译表"长什么样、存在哪、谁来维护?
  3. 有了这一层翻译,操作系统能玩出哪些花样?代价又是什么?

0. 心智模型:先记住这一张图

页表不是硬件魔法,而是内核用普通内存读写维护的一棵树。硬件只负责按这棵树查地址;查什么、怎么查、查不到怎么办,全由内核决定。

            写结构、写权限                     按树翻译每一次访存
  内核 ───────────────────► 页表(内存里的树)◄─────────────────── MMU
   ▲  读、清 A/D 位            │      ▲          置 A/D 位            │
   │                          │      └──────────────────────────────┤
   │                          ▼                                     ▼
   │                  satp 指向树根                        TLB:翻译结果的缓存
   │                                                    (改了树要 sfence.vma 清掉)
   └──── 翻译失败 → 缺页异常 → 内核插手:杀进程,或者补好映射再让程序重试

三个角色,各管一件事:

角色 做什么 不做什么
内核 决定树的形状:哪些虚拟页映射到哪些物理页、带什么权限;读和清 A/D 位;处理缺页 不参与每一次访存,翻译是硬件做的
硬件(MMU) 每次访存按树查;权限不对或查不到就报缺页;访问时置 A、写时置 D 不分配内存,不决定映射,不同步 TLB
TLB 缓存最近的翻译结果,让大部分访存不用走树 不知道内存里的树被改了,除非内核用 sfence.vma 告诉它

由这张图能推出几条贯穿后面所有内容的结论:

  1. 地址空间就是一张清单。 进程有多少内存、每段能做什么,全写在 PTE 里。表里没有的地址就不存在(§2、§9)。
  2. 页表是存在内存里的树。 页表页是普通的物理页,内核靠直接映射去读写它;三级结构只给用到的地址段分配节点,所以省内存(§4、§6、§7)。
  3. 页表是内核和硬件共享的数据结构。 内核写结构和权限,硬件写 A/D 位。有共享就有一致性问题:改了 PTE,TLB 里可能还是旧的(§8、§11)。
  4. 权限附在映射上,而不是物理页上。 同一个物理页,在内核的直接映射里可读写,在用户的映射里可以只读或者根本不出现。共享就是两个 PTE 指向同一个物理页(§10)。
  5. 映射粒度是一种取舍。 4KB 灵活,2MB 让 TLB 覆盖更大、缺页更少,但要求物理连续,记账也变粗(§12)。
  6. 翻译失败是内核插手的时机。 懒分配、写时复制、内存映射文件、换出,都是"故意让翻译失败,在缺页时补上"(§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 都靠它

自测:不看笔记能不能做到

  1. 画出 §0 那张图:内核、MMU、TLB 各自读写页表的哪些部分。
  2. 手动翻译 0x3FFFFFD000:三级下标各是多少,每一级读出的 PTE 怎么变成下一级页表的地址(§4)。
  3. 说出 trapframe 页从分配到释放的四个时刻,以及为什么映射和释放要分开。
  4. 解释为什么清掉 A 位之后,不刷 TLB 可能会漏报。
  5. 举一个"同一个物理页、两种权限"的例子。

思考题(先自己答)

  1. 一个 xv6 进程只用了 0x0 到 0x5000 这 5 页,再加顶端 3 页。它的页表一共占几页?如果它把堆长到 1GB(全用 4KB 页),页表大约要多少页?
  2. 为什么中间层 PTE 不需要 U 位,权限只看叶子?(提示:如果中间层也检查权限,大页和普通页的规则会怎样?)
  3. 用户栈的保护页为什么用"去掉 U"而不是"不映射"?两种做法对内核有什么区别?
  4. 内核把 PTE 的 W 位清掉,但没有执行 sfence.vma。接下来同一个进程往那一页写,会发生什么?
  5. 宿主机实验里,为什么大页版"每页写一遍"省下的时间,主要来自缺页次数,而不是 TLB?
  6. fork 之后父子进程都有一份页表。如果父进程用了大页,而子进程只能用 4KB 页(大页用完了),会有什么问题吗?