页表 = 间接层
页表是寻址的间接层,内核通过它控制每个进程能看到什么内存。
MIT 6.1810 · 第 5 课
页表是实现隔离的关键机制。本课深入 RISC-V 的三级页表结构,探讨内核页表与用户页表的布局,并通过 xv6 代码走读理解虚拟内存的实际实现。
假设 shell 有一个 bug:偶尔会写到随机内存地址。没有虚拟内存,shell 可能覆盖内核数据或其他进程的内存。
CPU → MMU → RAM,MMU 通过页表把虚拟地址(VA)翻译为物理地址(PA):
CPU 发出 VA ──→ MMU 查页表 ──→ 得到 PA ──→ 访问 RAM
| 概念 | 说明 |
|---|---|
| satp 寄存器 | 存放当前页表的物理地址,内核写 satp 切换页表 |
| 页(Page) | RISC-V 的页大小 = 4 KB = \(2^{12}\) 字节 |
| PTE | 页表条目(Page Table Entry),64 位 |
64 位地址中只使用低 39 位(Sv39),地址空间上限 512 GB:
[10 保留位 | 44 位 PPN(物理页号)| 10 位标志]
标志位:V(Valid), R(Read), W(Write), X(Execute), U(User)
地址翻译:高 27 位是页号(索引页表),低 12 位是页内偏移(直接复制到物理地址)。
如果使用单级数组,需要 \(2^{27}\) 个 PTE × 8 字节 ≈ 1 GB 每个地址空间——即使进程只使用几 KB 内存,也要占 1 GB 页表空间,完全不可接受。
VA: [L2 索引 9位 | L1 索引 9位 | L0 索引 9位 | 页内偏移 12位]
Level 2 目录 ──→ Level 1 目录 ──→ Level 0 目录 ──→ 物理页
↑ ↑ ↑
9 bits 9 bits 9 bits
只为实际使用的地址分配中间目录页——未使用的部分不占内存。
9 bits → 512 个 PTE × 8 字节 = 4096 字节 = 正是一个页的大小。每个目录刚好占一页,方便分配与管理。
三级页表每次内存访问需要 4 次查表。CPU 用 Translation Look-aside Buffer (TLB) 缓存最近使用的翻译结果,避免频繁查表。
sfence.vma:刷新 TLB(切换页表时必须使用)| 标志 | 含义 |
|---|---|
| V | Valid:该 PTE 是否有效 |
| R/W/X | 可读/可写/可执行 |
| U | User:用户态可访问 |
| G | Global:全局 TLB 条目(跨地址空间共享) |
当 V 位为 0,或写操作但 W 位为 0 时,硬件触发 page fault,强制进入内核。内核可以:
内核页表建立直接映射(direct mapping):虚拟地址 = 物理地址(大部分区域)。这使得内核可以直接把物理地址 cast 成 C 指针写入。
| 虚拟地址 | 物理地址 | 用途 |
|---|---|---|
| 0x10000000 | 0x10000000 | UART 控制台 |
| 0x10001000 | 0x10001000 | VIRTIO 磁盘 |
| 0x80000000 → 0x88000000 | 0x80000000 → 0x88000000 | 128 MB RAM |
| 0x3ffffff000 | - | Trampoline(用户/内核过渡页) |
| 0x3fffffe000 | - | 内核栈(每个进程一个) |
注意:
| 地址范围 | 内容 |
|---|---|
| 低地址 | text → data → heap(向上增长) |
| stack 下方 | stack guard(无 PTE_U,不可访问) |
| stack | 用户栈(向下增长) |
| 0x3ffffff000 | Trampoline(无 PTE_U) |
| 0x3fffffe000 | Trapframe(无 PTE_U) |
// kernel/vm.c
pagetable_t kvmmake(void) {
pagetable_t kpgtbl = (pagetable_t) kalloc();
memset(kpgtbl, 0, PGSIZE);
// UART0 直接映射
kvmmap(kpgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);
// VIRTIO0
kvmmap(kpgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
// 物理 RAM 直接映射
kvmmap(kpgtbl, KERNBASE, KERNBASE, PHYSTOP - KERNBASE,
PTE_R | PTE_W);
// 内核栈
proc_mapstacks(kpgtbl);
return kpgtbl;
}
// kernel/vm.c
void kvmmap(pagetable_t kpgtbl, uint64 va, uint64 pa,
uint64 size, int perm) {
mappages(kpgtbl, va, size, pa, perm);
}
// 为 [va, va+size) 的每个页建立映射
int mappages(pagetable_t pagetable, uint64 va, uint64 size,
uint64 pa, int perm) {
uint64 a, last;
pte_t *pte;
a = PGROUNDDOWN(va);
last = PGROUNDDOWN(va + size - 1);
for(;;) {
// walk 找到 va 对应的 PTE 地址(必要时创建目录页)
if((pte = walk(pagetable, a, 1)) == 0)
return -1;
if(*pte & PTE_V)
panic("remap");
// 设置 PTE:物理页号 + 权限 + Valid
*pte = PA2PTE(pa) | perm | PTE_V;
if(a == last)
break;
a += PGSIZE;
pa += PGSIZE;
}
return 0;
}
walk() 模拟硬件的页表遍历过程:
// kernel/vm.c
pte_t *walk(pagetable_t pagetable, uint64 va, int alloc) {
if(va >= MAXVA)
panic("walk");
for(int level = 2; level > 0; level--) {
pte_t *pte = &pagetable[PX(level, va)]; // 提取 9 位索引
if(*pte & PTE_V) {
// 下一级目录已存在
pagetable = (pagetable_t)PTE2PA(*pte);
} else {
// 需要分配新的目录页
if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
return 0;
memset(pagetable, 0, PGSIZE);
*pte = PA2PTE(pagetable) | PTE_V;
}
}
// 最后一级:返回叶 PTE 的地址
return &pagetable[PX(0, va)];
}
// kernel/vm.c
void kvminithart() {
// satp 需要页表的物理地址!
w_satp(MAKE_SATP(kernel_pagetable));
sfence_vma(); // 刷新 TLB
}
页号 = 0x10000000 >> 12 = 0x10000
L2 索引 = (0x10000 >> 18) & 0x1FF = 0x0
L1 索引 = (0x10000 >> 9) & 0x1FF = 0x80
L0 索引 = 0x10000 & 0x1FF = 0x0
页内偏移 = 0x10000000 & 0xFFF = 0x0
PTE 低 10 位 = 0x7 = 0b000111
→ PTE_R (0b00001) | PTE_W (0b00010) | PTE_V (0b00001)
vmprint(pagetable):打印页表结构(你将实现这个函数)info mem:查看当前 satp 的页表映射walk() 的每一级遍历Q1:RISC-V Sv39 的虚拟地址空间大小是多少?
Q2:为什么 xv6 使用三级页表而不是单级页表?
Q3:为什么 Trampoline 页必须在用户页表和内核页表中映射到相同的虚拟地址?
Q4:当用户程序访问未映射的虚拟地址时,会发生什么?
Q5:内核页表使用"直接映射"(VA = PA)的主要好处是什么?
页表是寻址的间接层,内核通过它控制每个进程能看到什么内存。
Sv39 的三级结构只为实际使用的地址分配目录页,避免单级页表的 1 GB 浪费。
Trampoline 页映射在用户和内核页表的相同 VA,使用户/内核切换时能继续执行。
xv6 的 walk() 函数模拟硬件页表遍历,用于更新页表或访问用户指针。