页表与内存管理调试——walk page table 看 CR3 翻译每一级
x86 分页的几种模式
分页模式由 CR0.PG + CR4.PAE + EFER.LMA 组合决定:
| PG | PAE | LMA | 模式 | 页表层级 | 页大小 |
|---|---|---|---|---|---|
| 0 | - | - | 不分页 | 无 | - |
| 1 | 0 | 0 | 普通 32 位 | PD → PT(2 级) | 4KB / 4MB |
| 1 | 1 | 0 | PAE 32 位 | PDPT → PD → PT(3 级) | 4KB / 2MB |
| 1 | 1 | 1 | x86-64 Long Mode | PML4 → PDPT → PD → PT(4 级) | 4KB / 2MB / 1GB |
| 1 | 1 | 1 | x86-64 5-level(可选) | PML5 → PML4 → PDPT → PD → PT | 4KB |
OS 开发最常遇到的是 4 级 x86-64 和 3 级 PAE。
地址拆分:每级表用哪些位
地址翻译的核心思想:虚拟地址被拆成几段,每段用来查不同层级的页表。
4 级 x86-64(Long Mode)
48 位虚拟地址拆成:
[47:39] PML4 index (9 位)
[38:30] PDPT index (9 位)
[29:21] PD index (9 位)
[20:12] PT index (9 位)
[11:0] Page offset (12 位)
每级页表 4KB 大小、512 个 8 字节条目。CR3 指向 PML4 表的物理基址。
3 级 PAE(32 位 + PAE)
32 位虚拟地址拆成:
[31:30] PDPT index (2 位)
[29:21] PD index (9 位)
[20:12] PT index (9 位)
[11:0] Page offset (12 位)
PAE 的页表条目是 8 字节(比普通 32 位的 4 字节大)。
Bochs 里手动 walk page table
示例场景:验证 0xC0100000 的翻译(PAE 32 位模式)
bochs:1> info cr
cr3=0x12345000 # PDPT 物理基址
# 第 1 级:PDPT(2 个条目,用虚拟地址 [31:30] 位)
# 虚拟地址 0xC0100000 → 二进制 1100 0000 0001 0000 0000 0000 0000 0000
# [31:30] = 11 = 3(二进制)→ PDPT 索引 3
bochs:2> xp /8gx 0x12345000 + 3*8 # 读 PDPT 第 3 项
0x12345018: 0x0000000000567001 # 条目值,bit 0=1(Present),bit 1=1(RW)
# 提取页表基址:(0x0000000000567001 >> 12) << 12 = 0x0000000000567000
# 第 2 级:PD(512 个条目,用虚拟地址 [29:21] 位)
# 虚拟地址 0xC0100000 → [29:21] = 010000000 = 256(二进制)
bochs:3> xp /8gx 0x567000 + 256*8 # 读 PD 第 256 项
0x5678000: 0x0000000000890001 # Present+RW
# 第 3 级:PT(512 个条目,用虚拟地址 [20:12] 位)
# 虚拟地址 0xC0100000 → [20:12] = 000000000 = 0
bochs:4> xp /8gx 0x890000 + 0*8 # 读 PT 第 0 项
0x890000: 0x0000000000012001 # Present+RW,页框号 0x12
# 最终物理地址 = 页框号 << 12 + offset
# 物理地址 = 0x12000 + (0xC0100000 & 0xFFF) = 0x12000
bochs:5> xp /16lx 0x12000 # 用 xp 直接看物理
bochs:6> x /16lx 0xC0100000 # 用 x 看虚拟(自动翻译)
# 两者应该显示相同内容!
示例场景:4 级 x86-64 walk
bochs:1> info cr
cr3=0x200000 # PML4 基址
# 虚拟地址 0xffffffff80100000
# [47:39] = 111111111 = 511(符号扩展的上半部分)
# [38:30] = 100000000 = 256
# [29:21] = 000000000 = 0
# [20:12] = 000000000 = 0
bochs:2> xp /8gx 0x200000 + 511*8 # PML4 index 511
bochs:3> xp /8gx <PML4 条目 base> + 256*8 # PDPT index 256
bochs:4> xp /8gx <PDPT 条目 base> + 0*8 # PD index 0
bochs:5> xp /8gx <PD 条目 base> + 0*8 # PT index 0
# PT 条目里拿到最终页框号
页表条目格式
每级页表条目都是 8 字节(PAE/x86-64)或 4 字节(普通 32 位)。格式:
8 字节条目(x86-64)
| 位 | 含义 |
|---|---|
| 0 | Present(P)= 1 表示该条目有效 |
| 1 | Read/Write(RW)= 1 可写 |
| 2 | User/Supervisor(US)= 1 用户态可访问 |
| 3 | Page-level Write Through |
| 4 | Page-level Cache Disable |
| 5 | Accessed(A)= CPU 自动置位 |
| 6 | Dirty(D)= CPU 自动置位(叶子表项) |
| 7 | Page Size(PS)= 1 表示大页(2MB/1GB) |
| 8 | Global(G) |
| 51 | PAT(Page Attribute Table) |
| 39:12 | 页框号(Physical Address) |
| 63:52 | 保留 |
4 字节条目(普通 32 位 PT)
| 位 | 含义 |
|---|---|
| 0-4 | 同上 |
| 5 | Accessed |
| 6 | Dirty(仅 PT 叶子) |
| 7 | Page Size(PD 上 = 4MB 大页) |
| 31:12 | 页框号 |
walk page table 时的错误检查
某级表项 Present = 0
0x12345000: 0x0000000000000000 # P=0,条目无效
→ 页表里根本没写这个映射。内核初始化漏了、或者 CR3 指错了表、或者地址越界。
Present = 1 但 RW / US 权限不对
0x12345000: 0x0000000000567001 # RW=1, US=1 → 用户态可读写
0x12345000: 0x0000000000567005 # RW=0, US=1 → 用户态只读
→ page fault handler 里 error code 的 bit 1/2 应该能反映(写只读页 / 用户态访问内核页)。
页框号明显不合理
页框号应该是对齐到 4KB / 2MB / 1GB 的。如果页框号是个奇怪的值(比如 0x1),检查是不是用了 PAE 模式但按普通 32 位 walk(条目大小从 4 字节变 8 字节,索引错位)。
TLB 和页表修改
x86 有 TLB(Translation Lookaside Buffer)缓存页表翻译结果。如果你在 Bochs 里手动修改了物理内存里的页表条目,TLB 缓存不会自动失效——翻译可能还是用旧值。
在 Bochs 调试器里改完页表后需要让 CPU 刷新 TLB:
bochs:1> set $cr0 = ($cr0 | 0x80000000) - 0x80000000 # 关 PG 再开
# 或者更简单:
bochs:2> set $cr3 = 0x12345000 # 重新写入 CR3(强制 TLB 刷新)
这会强制 CPU 重新加载页表、刷新 TLB。OS 内核代码里 mov cr3, eax 天然会刷新 TLB,但你在调试器里手动改内存不走这条路径。
内核内存管理验证
你的内核写好了 kmalloc / kfree(内核堆分配)后,想验证它是不是正确映射了物理页?
- 触发 kmalloc 分配一小块内存
- 在 Bochs 里对 kmalloc 返回的虚拟地址设断点
- 分配完成后,用
x看虚拟地址、用xp看对应的物理地址(手动 walk) - 验证物理页真的是从你的 free list 里拿出来的(页框号符合你的分配策略)
- kfree 后检查这个页框是不是回到了 free list
这比只跑程序看结果更可靠——你能直接看到页表里写了什么。