Lab 018 · 给每个世界一套页表:地址空间
配套章节:018 · 给每个世界一套页表:地址空间。这一关给你目标和约束,不贴 PML4 拷贝、不贴子树回收、不贴隔离的判断——那些得你自己写。
实验目标
把「虚拟地址空间」做成一个能创建、切换、销毁的对象,为进程隔离打地基。拆成几个能独立验证的子目标:
- 能 init:启动时把当前 CR3(内核页表根)存下来,作为「内核半区」的源头。
- 能构造:每个实例要一页做自己的 PML4,清零,然后把内核那半(PML4[256..511])从内核页表拷过来——内核映射由此共享进新空间。
- 能 map / unmap / translate:在本空间的页表里做映射,以本空间的 PML4 为根。
- 能隔离:两个空间各自映射,互不可见(同一虚拟地址在 A 空间有、在 B 空间查不到)。
- 能切换:
activate()把自己的 PML4 写进 CR3。 - 能销毁:析构时只回收用户半区(PML4[0..255])的页表子树 + 数据页 + 自己的 PML4,内核半区(256..511)一个都不碰。
做完这几条,内核就有了「造一个隔离的虚拟世界、钻进去、再拆掉」的能力——给进程用的地基就铺好了。
前置条件
你得先过 Lab 016 + Lab 017。关键依赖:
- 016 的
VMM::map(virt, phys, flags, uint64_t* pml4 = nullptr):这一关的 map/unmap/translate 全部透传给它,把本空间的 PML4 通过那个pml4参数传进去——016 说「这个参数是为以后留的」,这一关就是那个「以后」。 - 016 的
phys_to_virt(物理地址 + 高半区偏移):读那些存在物理内存里的页表,全靠它。 - 015 的
g_pmm.alloc_page/free_page:PML4、各级中间页表,都要从 PMM 要页、回收时还页。
还要理解 x86-64 的规范地址:bit 47 是低/高半区分界,PML4 索引 256 是内核半区的第一个表项(对应虚拟地址 0xFFFF800000000000),0..255 是用户半区,256..511 是内核半区。
任务分解
第一步:存内核 PML4。 一个静态方法,启动时调一次:read_cr3() 读出当前 PML4 物理基址,存进一个静态成员。时机要卡在 VMM 把内核页表建好之后、任何地址空间实例构造之前。想清楚这个值为什么不能是 0(后面构造要靠它)。
第二步:构造。 从 PMM 要一页做自己的 PML4(要不到要安全处理)。把这页当 512 个页表项,全清零(为什么必须清零?不清零残留位会被当成 present)。然后把内核 PML4 的 PML4[256..511] 逐项拷贝到新 PML4 的同位置。想清楚这是「浅拷贝」——拷的是表项本身,新空间的这些项和内核的指向同一套下级内核页表,所以内核映射是共享引用、不是复制。
第三步:map / unmap / translate。 不重写 walk,直接透传给 VMM,把本空间的 PML4 物理地址通过那个根参数传进去。于是映射发生在本空间的页表里,和内核、和别的空间互不干扰。
第四步:隔离(本关核心)。 造两个空间 A 和 B。在 A 里 map(virt, phys, flags)。用 translate 验证:A 看得到这个映射(返回 phys),B 看不到(返回 0)。这条「同一虚拟地址在不同空间解析到不同结果」,就是进程隔离的物理基础。注意:这一步用 translate(软件走 B 的页表)就能验隔离,不一定要真切 CR3。
第五步:activate。 一个方法,把自己的 PML4 物理地址写进 CR3。写完后 CPU 就按本空间的页表走地址。想清楚:切过去之后,内核映射为什么还在(因为构造时拷了内核半区);以及切过去之后「什么时候切回来」是谁的责任(调用者,这一关里测完要手动恢复)。
第六步:析构 + 子树回收。 析构时,只遍历 PML4[0..255](用户半区!),对每个 present 项递归回收它下面的整棵子树(PDPT→PD→PT),最后回收 PML4 本身。递归回收要想清楚两层事:还没到 PT 层时,先递归进下级表再回收本级页;到 PT 层时不递归了(下面没表),但 PT 项指向的数据页要不要回收?(以实现为准:这套设计会回收,因为它把用户半区每页都当独占。)无论如何,PML4[256..511] 绝对不碰——那是共享的内核表,碰了就全机重启。
接口约束
你要实现出来的东西,对外长这样(职责和签名,不给算法实现):
static void AddressSpace::init_kernel():启动时存内核 PML4。AddressSpace():要一页 PML4、清零、拷内核半区 PML4[256..511]。~AddressSpace():回收用户半区子树 + PML4 本身,内核半区不动。bool AddressSpace::map(uint64_t virt, uint64_t phys, uint64_t flags):透传 VMM,以本空间为根。void AddressSpace::unmap(uint64_t virt)/uint64_t AddressSpace::translate(uint64_t virt):同上。void AddressSpace::activate():把本空间 PML4 写进 CR3。uint64_t AddressSpace::pml4_phys() const/static uint64_t AddressSpace::kernel_pml4():访问器。- 禁拷贝(
= delete)、允移动(转移pml4_phys_所有权、源置 0)。
关键约束(违反就出大事):
- 构造前必须
init_kernel,否则kernel_pml4_为 0,构造拷进来的内核半区全是垃圾,activate 即崩。 - 析构只扫 PML4[0..255],扫进 256..511 会 free 掉共享内核页表,全机重启。
activate之后、析构之前,调用者要负责把 CR3 切回内核(这一关里手动write_cr3(kernel_pml4)),否则 CPU 走在正在被回收的页表上。
PML4 半区分界(256)、各级页表项数(512)、KERNEL_VMA 这些常量你自己定或沿用 016 的,这关不提供。
验证步骤
纯结构(PML4 分配、清零、内核半区拷贝、用户半区私有、析构回收)在 host 上镜像着测。用一个 mock PMM(分配伪造物理页)+ 简化的 mock VMM 把 AddressSpace 行为抄一份,-O2 编、CINUX_HOST_TEST 门控:
ctest --test-dir build -R address_space --output-on-failure建议覆盖:构造分配了非 0 的 PML4、构造后用户半区(PML4[0..255])全 0、内核半区(256..511)从 kernel PML4 拷过来了、两个实例拿到不同 PML4、析构把 PML4 页还回去(无用户映射时只回收 PML4 本身)。
真 PMM、真 VMM、真 CR3,在 QEMU 里验。机内测跑真正的 AddressSpace,11 个场景:init_kernel 存了非 0 内核 PML4、构造出与内核不同的 PML4、两实例根不同、单空间 map/translate/unmap、translate 未映射返回 0、跨空间隔离(在 AS#1 映射、AS#2 translate 同地址返回 0)、activate 改变 CR3(切完 read_cr3() == as.pml4_phys()、随后恢复内核)、activate 后 translate 仍对、同空间映射两页、析构不损坏内核映射:
cmake --build build --target run-big-kernel-testinit_kernel 会打印 [AS] Kernel PML4 saved at phys ...,test section AddressSpace Tests (018) 全过、末尾 ALL TESTS PASSED,就说明地址空间走通了。尤其跨空间隔离那条,是这一关的里程碑。
常见故障
- 一构造 / 一切换地址空间就重启,串口可能来得及打半行乱码:
init_kernel没在构造前调,或调得早于 VMM 把 CR3 换成真内核页表,kernel_pml4_是 0 或指向临时表,构造拷进来的内核半区全是垃圾,activate 即崩。确保init_kernel()在 VMM init 之后、首个 AS 之前调用,且kernel_pml4()非 0。 - activate 后、空间析构后崩,且崩得晚:切了 CR3 之后没切回内核就析构,CPU 走在正在被 free 的页表上。
activate测完、析构前,手动write_cr3(kernel_pml4)恢复。 - 析构后一会儿才崩,毫无预兆:析构循环边界写错,扫进了 PML4[256..511],把共享内核页表 free 了,等 CPU 下次走内核映射才暴露。循环只扫
0..256。 - 两个空间的隔离没生效,A 映射后 B 也看得到:B 用了内核 PML4(没传本空间的 pml4 根),或构造时没正确清零用户半区、残留了别人的映射。map/translate 一定要透传本空间的 PML4;构造必须把用户半区清零。
- 拷贝被误用,导致同一批物理页被 free 两次:类型没禁拷贝,两个对象各以为自己拥有 PML4。
AddressSpace要delete拷贝构造与拷贝赋值,只留移动。 - 析构把用户半区数据页也回收了,后续别处引用的页被抽走:这套析构设计把用户半区每页当独占,PT 层数据页也 free。别往用户半区映射「还要被别处引用的共享页」。018 没人这么用不发作,但做共享内存 / COW 时这里是雷区。
通过标准
- host 单测全绿:PML4 分配、用户半区清零、内核半区拷贝、不同实例不同根、析构回收。
- QEMU 机内测通过:
init_kernel打出[AS] Kernel PML4 saved、跨空间隔离(Test 7)成立、activate 改变 CR3 且测后恢复(Test 8/9)、析构不损坏内核映射(Test 11)。 - 构造前必先
init_kernel;构造拷内核半区是浅拷贝(共享下级内核表)。 - 析构只扫 PML4[0..255],内核半区(256..511)绝对不动;
activate后由调用者负责切回内核 CR3。 - 禁拷贝、允移动(移动后源置 0、析构跳过回收)。
做到这五条,内核就有了「地址空间」抽象,PMM + VMM + 堆 + 地址空间四块就位。但生产路径里它只做了 init_kernel、还没人造实例来住——谁来住?进程。那是下一关 019 的事。