第二幕:虚拟内存管理——编织地址空间的幻象
绪
每个进程都觉得自己独占了整个地址空间。这就是虚拟内存制造的终极幻象。
物理内存是一大块扁平的、按页分割的实际硬件资源。虚拟内存则是每个进程视角下的"假内存"——它是连续的、私有的、不需要知道物理页具体在哪。MMU(内存管理单元)负责在物理地址和虚拟地址之间做实时翻译。
在 x86-64 下,这个翻译机制是 4 级页表。我们的任务,就是在已有 PMM 的基础上,实现一套可以建立虚拟地址到物理地址映射的 VMM。
4 级页表的奥秘
x86-64 的虚拟地址是 48 位的(尽管寄存器是 64 位)。这 48 位被分成 5 个部分:
| 63-48 | 47-39 | 38-30 | 29-21 | 20-12 | 11-0 |
| 符号扩展 | PML4 | PDPT | PD | PT | 偏移 |
| | 9 bits| 9 bits| 9 bits| 9 bits| 12 bits|每一级索引占用 9 位,恰好索引 512 个表项。最后 12 位是页内偏移(4096 字节一页)。
翻译过程就像爬一棵四层的树:
- CR3 寄存器 → 指向 PML4(Page Map Level 4)表的物理地址
- PML4[47:39] → 指向 PDPT(Page Directory Pointer Table)
- PDPT[38:30] → 指向 PD(Page Directory)
- PD[29:21] → 指向 PT(Page Table)
- PT[20:12] → 指向最终的物理页
- 物理地址 = 物理页基址 + 页内偏移[11:0]
页表项的结构
每一级页表的每一项都是一个 64 位的值:
| 63 | 62-52 | 51-12 | 11-0 |
| NX | 保留 | 物理地址 | 标志 |我们用到的标志位(定义在 vmm.h 中):
#define PTE_PRESENT (1ull << 0) // 该页/表项存在
#define PTE_WRITABLE (1ull << 1) // 可写
#define PTE_USER (1ull << 2) // Ring 3 用户态访问
#define PTE_NX (1ull << 63) // No-Execute: 禁止执行
#define PTE_ADDR_MASK 0x000FFFFFFFFFF000 // 提取物理地址的掩码- PTE_PRESENT (bit 0):最重要的标志。如果 CPU 访问的页表项 P 位为 0,就会触发 Page Fault(#14)。
- PTE_WRITABLE (bit 1):控制该页是否可写。设为只读的内存页被写入时也会触发 Page Fault——这是 Copy-on-Write 的基础。
- PTE_USER (bit 2):Ring 3 用户态能否访问。我们的内核目前全在 Ring 0,但提前设置此位为将来切换到用户态做准备。
- PTE_NX (bit 63):禁止执行。用于防止在数据页上执行代码——这是 W^X 策略的基础。
为什么物理地址掩码是 51 位?
0x000FFFFFFFFFF000 覆盖了 bit 12 到 bit 51,即 52 位物理地址空间(4 PiB)。当前 x86-64 CPU 实际上支持的最大物理地址通常小于这个值(取决于具体型号),但使用 52 位掩码可以确保兼容性。
映射一页:四级页表走到底
vmm_map_page 是整个 VMM 的核心。它的签名是:
void vmm_map_page(uint64_t *pml4, uint64_t vaddr, uint64_t paddr,
uint64_t flags);实现中最关键的步骤是"按需创建下级页表"——如果某一级表项不存在(P 位不为 1),就从 PMM 分配一页物理内存作为新的下级页表,清零后填入对应的表项:
void vmm_map_page(uint64_t *pml4, uint64_t vaddr, uint64_t paddr,
uint64_t flags) {
uint64_t pml4_idx = (vaddr >> 39) & 0x1FF;
uint64_t pdpt_idx = (vaddr >> 30) & 0x1FF;
uint64_t pd_idx = (vaddr >> 21) & 0x1FF;
uint64_t pt_idx = (vaddr >> 12) & 0x1FF;
uint64_t *pml4_virt = PHYS_TO_VIRT(pml4);
// PML4 -> PDPT
if (!(pml4_virt[pml4_idx] & PTE_PRESENT)) {
uint64_t new_pdpt_phys = pmm_alloc();
memset(PHYS_TO_VIRT(new_pdpt_phys), 0, 4096);
pml4_virt[pml4_idx] = new_pdpt_phys | PTE_PRESENT | PTE_WRITABLE | PTE_USER;
}
// PDPT -> PD
uint64_t *pdpt_virt = PHYS_TO_VIRT(pml4_virt[pml4_idx] & PTE_ADDR_MASK);
if (!(pdpt_virt[pdpt_idx] & PTE_PRESENT)) {
uint64_t new_pd_phys = pmm_alloc();
memset(PHYS_TO_VIRT(new_pd_phys), 0, 4096);
pdpt_virt[pdpt_idx] = new_pd_phys | PTE_PRESENT | PTE_WRITABLE | PTE_USER;
}
// PD -> PT
uint64_t *pd_virt = PHYS_TO_VIRT(pdpt_virt[pdpt_idx] & PTE_ADDR_MASK);
if (!(pd_virt[pd_idx] & PTE_PRESENT)) {
uint64_t new_pt_phys = pmm_alloc();
memset(PHYS_TO_VIRT(new_pt_phys), 0, 4096);
pd_virt[pd_idx] = new_pt_phys | PTE_PRESENT | PTE_WRITABLE | PTE_USER;
}
// PT -> Physical Page
uint64_t *pt_virt = PHYS_TO_VIRT(pd_virt[pd_idx] & PTE_ADDR_MASK);
pt_virt[pt_idx] = (paddr & PTE_ADDR_MASK) | flags;
// 刷新 TLB 中该虚拟地址的缓存
invlpg(vaddr);
}这个过程包含了几个有趣的点:
物理地址与虚拟地址的混用。 pml4 参数传入的是 PML4 表的物理地址(从 CR3 读取),但我们需要通过虚拟地址去访问它——因为内核运行在高半区。所以每一级我们都先用 PHYS_TO_VIRT 转换,再解引用。
小心:别把物理地址当虚拟地址用
这是裸机编程中最常见的错误之一。在启用分页之后,CPU 看到的所有地址指针都是虚拟地址。如果你试图解引用一个物理地址,CPU 会尝试通过页表翻译它——结果要么访问到错误的内存,要么直接触发 Page Fault。
HHDM 就是为这个而生的:它确保物理地址加上偏移后能正确地被页表翻译回原来的物理地址。
TLB 刷新。 修改页表后必须调用 invlpg 指令来刷新 TLB(Translation Lookaside Buffer)。TLB 是 MMU 内部的高速缓存,它缓存了最近使用的虚拟地址到物理地址的映射。如果不刷新,CPU 可能会继续使用旧的(或不存在的)映射,导致极难调试的随机性 bug。
static inline void invlpg(uint64_t vaddr) {
__asm__ volatile("invlpg (%0)" ::"r"(vaddr) : "memory");
}获取当前 PML4
要操作页表,首先得知道当前的 PML4 在哪里。CR3 寄存器指向它:
uint64_t *vmm_get_current_pml4() {
uint64_t cr3;
__asm__ volatile("mov %%cr3, %0" : "=r"(cr3));
return (uint64_t *)(cr3 & PTE_ADDR_MASK);
}CR3 的低 12 位包含了一些控制标志(如 PCID),我们只保留物理地址部分。
VMM 初始化与验证
vmm_init() 本身很简单,只是获取当前 PML4 并打印一条确认信息。真正有意思的在 kmain() 中的验证测试:
uint64_t phys_page = pmm_alloc(); // 1. 从 PMM 拿一页物理内存
uint64_t test_vaddr = 0x1000000000; // 2. 选一个虚拟地址
uint64_t *pml4 = vmm_get_current_pml4(); // 3. 获取当前页表
vmm_map_page(pml4, test_vaddr, phys_page, // 4. 建立映射
PTE_PRESENT | PTE_WRITABLE);
uint64_t *ptr = (uint64_t *)test_vaddr;
*ptr = 0xDEADBEEFCAFEBABE; // 5. 写入魔数
if (*ptr == 0xDEADBEEFCAFEBABE) { // 6. 读回验证
fb_puts("VMM Mapping Test Passed!");
}如果这个测试通过,就说明整个链条完美运转:PMM 分配了物理页 → VMM 建立了虚拟地址到物理地址的映射 → MMU 正确翻译了地址 → CPU 成功读写了数据。
从硬件角度看,当 CPU 执行 *ptr = 0xDEADBEEFCAFEBABE 时:
- MMU 取出
test_vaddr(0x1000000000) - 从 CR3 读取 PML4 物理地址
- 逐级查表,最终找到
phys_page的物理地址 - 将魔数写入该物理页
- 读回时再做一次相同的翻译
如果任何一级出了问题——表项没填对、地址转换错误、TLB 未刷新——这个测试都会失败。
小结
虚拟内存是现代操作系统的基石。它让每个进程拥有独立的地址空间、让内核可以实现 Copy-on-Write 和按需分页等高级特性。我们目前实现的只是最基础的部分——单地址空间的静态映射。
但这已经足够了。有了 VMM,我们内核的物理内存不再限制我们的想象力。我们可以分配任意位置的物理页,映射到任意虚拟地址,以任意权限组合。
展望未来:vmm_unmap_page() 还只是声明未实现;更重要的是,当进程数量增加时,我们需要为每个进程维护独立的 PML4、在进程切换时更换 CR3——那将打开进程隔离的大门,让我们的内核从一个简单的"裸机程序"真正演变成一个多任务操作系统。
但现在,让我们先享受这个里程碑时刻:我们的内核拥有了完整的内存管理能力。
