Skip to content

Lab 018 · 给每个世界一套页表:地址空间

配套章节:018 · 给每个世界一套页表:地址空间。这一关给你目标和约束,不贴 PML4 拷贝、不贴子树回收、不贴隔离的判断——那些得你自己写。

实验目标

把「虚拟地址空间」做成一个能创建、切换、销毁的对象,为进程隔离打地基。拆成几个能独立验证的子目标:

  1. 能 init:启动时把当前 CR3(内核页表根)存下来,作为「内核半区」的源头。
  2. 能构造:每个实例要一页做自己的 PML4,清零,然后把内核那半(PML4[256..511])从内核页表拷过来——内核映射由此共享进新空间。
  3. 能 map / unmap / translate:在本空间的页表里做映射,以本空间的 PML4 为根。
  4. 能隔离:两个空间各自映射,互不可见(同一虚拟地址在 A 空间有、在 B 空间查不到)。
  5. 能切换:activate() 把自己的 PML4 写进 CR3。
  6. 能销毁:析构时只回收用户半区(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 门控:

bash
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 仍对、同空间映射两页、析构不损坏内核映射:

bash
cmake --build build --target run-big-kernel-test

init_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。AddressSpacedelete 拷贝构造与拷贝赋值,只留移动。
  • 析构把用户半区数据页也回收了,后续别处引用的页被抽走:这套析构设计把用户半区每页当独占,PT 层数据页也 free。别往用户半区映射「还要被别处引用的共享页」。018 没人这么用不发作,但做共享内存 / COW 时这里是雷区。

通过标准

  1. host 单测全绿:PML4 分配、用户半区清零、内核半区拷贝、不同实例不同根、析构回收。
  2. QEMU 机内测通过:init_kernel 打出 [AS] Kernel PML4 saved、跨空间隔离(Test 7)成立、activate 改变 CR3 且测后恢复(Test 8/9)、析构不损坏内核映射(Test 11)。
  3. 构造前必先 init_kernel;构造拷内核半区是浅拷贝(共享下级内核表)。
  4. 析构只扫 PML4[0..255],内核半区(256..511)绝对不动;activate 后由调用者负责切回内核 CR3。
  5. 禁拷贝、允移动(移动后源置 0、析构跳过回收)。

做到这五条,内核就有了「地址空间」抽象,PMM + VMM + 堆 + 地址空间四块就位。但生产路径里它只做了 init_kernel、还没人造实例来住——谁来住?进程。那是下一关 019 的事。

035_multi_terminal-40-g5d72b8b · 5d72b8b · 2026-06-26