上一章内核能分配物理页了,但用的全是物理地址。
物理地址有个问题:全局唯一,谁都能访问。进程 A 如果知道进程 B 的物理地址,直接就能读写它的数据。这不行。
解决方案是虚拟内存:每个程序看到的地址都是"假的",CPU 访问时由硬件自动翻译成真实的物理地址。程序互相隔离,谁也看不见谁。
地址翻译的硬件机制:四级页表
x86-64 的地址翻译靠 MMU(Memory Management Unit,内存管理单元,CPU 内部硬件,负责把虚拟地址翻译成物理地址)完成,翻译规则写在页表里,页表的根地址放在 CR3 寄存器里。
一个 64 位虚拟地址被这样拆开:
63 48 47 39 38 30 29 21 20 12 11 0
[ 符号扩展 | PML4_IDX | PDPT_IDX | PD_IDX | PT_IDX | 页内偏移 ]
9 bit 9 bit 9 bit 9 bit 12 bit
翻译过程是四级查表:
CR3 → PML4[PML4_IDX] → PDPT[PDPT_IDX] → PD[PD_IDX] → PT[PT_IDX] → 物理页帧
每级页表是一个 512 项的数组,每项 8 字节,刚好占满一个 4KB 页。每项的低 12 位是 flags,高位是下一级页表(或最终物理页)的地址。
用宏拆出各级 index:
#define PML4_IDX(v) (((v) >> 39) & 0x1FF)
#define PDPT_IDX(v) (((v) >> 30) & 0x1FF)
#define PD_IDX(v) (((v) >> 21) & 0x1FF)
#define PT_IDX(v) (((v) >> 12) & 0x1FF)
// 从页表条目取出地址(去掉低12位 flags)
#define ENTRY_ADDR(e) ((e) & 0x000FFFFFFFFFF000ULL)
页表条目的 Flags
每个条目的低 12 位控制这页的访问权限:
#define PAGE_PRESENT (1ULL << 0) // 这页有效(必须置1,否则访问触发 Page Fault)
#define PAGE_WRITABLE (1ULL << 1) // 可写(不置就只读)
#define PAGE_USER (1ULL << 2) // 用户态可访问(不置就只有内核能用)
#define PAGE_NX (1ULL << 63) // 禁止执行(数据页加上更安全)
#define PAGE_KERNEL (PAGE_PRESENT | PAGE_WRITABLE) // 内核页默认配置
这几个 bit 就是保护内存的关键。用户程序不能访问内核页(没有 PAGE_USER),代码段不能被当数据写(没有 PAGE_WRITABLE)。
map_page:建立一条虚拟→物理映射
有了四级页表结构,我们需要一个函数来"打通"某条虚拟→物理路径。逻辑是逐级往下走,如果某一级还不存在就临时分配一页充当它,最后在最底层的 PT 里写入物理地址。
核心函数,把虚拟地址 vaddr 映射到物理地址 paddr:
static uint64_t ensure_table(uint64_t *table, uint64_t idx, uint64_t flags) {
if (!(table[idx] & PAGE_PRESENT)) {
// 这一级页表不存在,分配一页,清零,写入条目
uint64_t *new_table = (uint64_t *)pmm_alloc();
for (int i = 0; i < 512; i++) new_table[i] = 0;
table[idx] = (uint64_t)new_table | flags;
}
return ENTRY_ADDR(table[idx]);
}
void map_page(uint64_t vaddr, uint64_t paddr, uint64_t flags) {
uint64_t *pml4 = (uint64_t *)read_cr3();
uint64_t *pdpt = (uint64_t *)ensure_table(pml4, PML4_IDX(vaddr), PAGE_KERNEL);
uint64_t *pd = (uint64_t *)ensure_table(pdpt, PDPT_IDX(vaddr), PAGE_KERNEL);
uint64_t *pt = (uint64_t *)ensure_table(pd, PD_IDX(vaddr), PAGE_KERNEL);
pt[PT_IDX(vaddr)] = paddr | flags;
flush_tlb(vaddr); // 告诉 CPU 这个地址的翻译缓存作废
}
ensure_table 的逻辑:如果这一级页表条目已经存在就直接用,不存在就从 PMM 分配一页来充当这级页表。这样页表是按需创建的,不用一开始就分配全部 4 级。
TLB:翻译缓存,改完必须刷
CPU 不会每次访存都走一遍四级查表——那太慢了。它有 TLB(Translation Lookaside Buffer,旁路转换缓冲,CPU 内部缓存,存放最近用过的虚拟→物理翻译结果)来缓存最近的翻译结果。
修改页表之后,如果不刷 TLB,CPU 还会用旧的缓存结果,导致访问到错误的物理地址。
刷新某个地址的 TLB 条目:
static void flush_tlb(uint64_t vaddr) {
__asm__ volatile ("invlpg (%0)" :: "r"(vaddr) : "memory");
}
invlpg 是 x86 专门用来失效单个 TLB 条目的指令,比整个刷新 CR3(会清空全部 TLB)代价小得多。
unmap_page 和 virt_to_phys
取消映射和正向映射是对称的——四级查下去,把最终的 PT 条目清零,然后刷 TLB,CPU 下次访问这个地址就会触发 Page Fault。
取消映射:四级查下去,把最终的 PT 条目清零,然后刷 TLB。
void unmap_page(uint64_t vaddr) {
uint64_t *pml4 = (uint64_t *)read_cr3();
if (!(pml4[PML4_IDX(vaddr)] & PAGE_PRESENT)) return;
uint64_t *pdpt = (uint64_t *)ENTRY_ADDR(pml4[PML4_IDX(vaddr)]);
if (!(pdpt[PDPT_IDX(vaddr)] & PAGE_PRESENT)) return;
uint64_t *pd = (uint64_t *)ENTRY_ADDR(pdpt[PDPT_IDX(vaddr)]);
if (!(pd[PD_IDX(vaddr)] & PAGE_PRESENT)) return;
uint64_t *pt = (uint64_t *)ENTRY_ADDR(pd[PD_IDX(vaddr)]);
pt[PT_IDX(vaddr)] = 0;
flush_tlb(vaddr);
}
查询虚拟地址对应的物理地址(调试用):同样四级查下去,任何一级不存在就返回 0。
验证
void *phys = pmm_alloc(); // 分配一个物理页
map_page(0x400000, (uint64_t)phys, PAGE_KERNEL); // 映射到虚拟地址 0x400000
uint64_t resolved = virt_to_phys(0x400000); // 反查验证
输出:
mapped vaddr=0x400000 -> paddr=0x0000000000102000
virt_to_phys(0x400000)=0x0000000000102000
虚拟地址 0x400000 被成功映射到物理页 0x102000,反查结果一致。
这时候向 0x400000 写数据,CPU 会自动翻译到 0x102000。如果访问一个没有映射的虚拟地址,CPU 触发 Page Fault(14 号异常),内核可以选择:补一页(懒分配)、返回错误,或者直接 kill 掉进程。
此刻的页表状态
Bootloader 建的页表只做了 0x0 ~ 0x200000 的恒等映射(虚拟地址 == 物理地址),内核代码能跑起来靠的就是这个。
vmm_init() 目前没做额外工作,只是占个位置。后续章节(进程、用户态)才会真正建立独立的地址空间,给每个进程一张自己的页表。
这一章之后
有了物理内存(PMM)和虚拟内存(VMM),下一步可以在内核上搭一个堆分配器——支持 malloc / free 风格的动态分配,按需给出任意大小的内存块,而不是每次都整页整页地要。