跳转至

实验3 虚拟内存:Lazy Allocation 与 Copy-on-Write

实验总览

本次实验要求大家在 NexOS 中实现两项虚拟内存机制:Lazy AllocationCopy-on-Write Fork

  1. Lazy Allocation

    在使用 sbrk() 扩大用户地址空间时,系统不立即分配物理页,只增加进程的逻辑地址空间大小。只有当进程真正读写这段地址、触发 Page Fault 后,内核才分配物理页并建立页表映射。这样可以避免为尚未使用的页面直接分配物理页面导致的内存提前消耗,造成空间浪费。

  2. Copy-on-Write Fork

    在执行 fork() 时不立即复制父进程的所有物理页,而是让父子进程先共享同一批物理页,并将原本可写的共享页临时改为只读,并打上 PTE_COW 标记。当父进程或子进程真正需要写入共享页时,再为其复制出私有页,从而降低 fork() 的时间和空间开销。

本次实验主要涉及以下代码文件:

文件 需要关注的内容
kernel/core/syscall.c 修改 sys_sbrk(),让正向增长支持 lazy allocation,并正确处理 shrink。
kernel/core/trap.c usertrap() 中识别用户态 Page Fault,并分流到 COW 或 lazy/mmap 处理路径。
kernel/core/vm.c 补全 lazy allocation、COW fault、uvmcopy()copyin()copyout() 等虚拟内存管理逻辑。
kernel/core/kalloc.c 为物理页分配器增加 COW 引用计数,并修改 kalloc() / kfree() 语义。
kernel/core/proc.c 理解 growproc()fork()proc_handle_page_fault()、用户栈和 guard page 的关系。
kernel/include/riscv.h 理解 PTE_V/R/W/X/UPTE_COWPGROUNDDOWN()PTE2PA() 等宏。
kernel/include/memlayout.h 理解 TRAMPOLINETRAPFRAMEPHYSTOP 等地址布局。
kernel/include/defs.h 查看实验相关函数声明,确认跨文件调用关系。

实验给分说明

本实验的基础部分总分为 20 分,按与之前实验相同的方式折算计入最终成绩:

  • 代码测试结果:按 70% 比例计入基础部分总分。
  • 线下提问检查:按 30% 比例计入基础部分总分。

具体分值和检查方式请以课程主页、希冀平台和课程群内最新公告为准。

实验代码提交说明

本实验采用希冀平台作为测试平台,网址为 https://cscourse.ustc.edu.cn

在在线作业 lab3 的代码提交界面,需要粘贴你自己的 GitLab 代码仓库链接。由于本次实验使用的是 lab3 分支,因此需要额外指定待测分支,可使用下面两种格式之一:

https://cscourse.ustc.edu.cn/vdir/Gitlab/username/yourproj.git --branch=lab3
https://cscourse.ustc.edu.cn/vdir/Gitlab/username/yourproj.git lab3

平台上的自动测试主要覆盖基础部分的主线功能。若你发现线上测试结果和本地结果存在明显出入,请保留本地运行截图与线上结果截图,并联系助教确认是代码问题还是测试脚本问题。

实验时间安排

说明

此处为实验发布时的安排计划,请以课程主页和课程群内最新公告为准。

  • 5.7 晚实验课,讲解实验三并检查实验。
  • 5.14 晚实验课,检查实验。
  • 5.21 晚实验课,检查实验。
  • 5.28 晚实验课,检查实验。
  • 6.4 晚及之后实验课,补检查实验。

补检查分数照常给分,但会记录此次检查未按时完成,此记录在最后综合分数时作为一种参考。本次实验难度较高,建议不要在最后几天集中完成。

检查时间、地点:周四晚 18:30-22:00,电三楼 406/408。基础实验周期为三周,之后为补检查。

获取实验代码(切换到 lab3 分支)

本实验假设你已经在本机 clone 过课程仓库。接下来请拉取远端 lab3 分支,并切换到该分支完成实验。

在仓库根目录执行:

git fetch --all
git checkout -b lab3 upstream/lab3
# 或者:
# git switch -c lab3 --track upstream/lab3

当你完成实验后,push 代码时,请注意指定远端仓库:

git push origin lab3

如果你本地已经有 lab3 分支,可以直接切换:

git checkout lab3
# 或者:
# git switch lab3

切换分支前

请先用 git status 检查当前工作区,避免未提交改动被分支切换打断。若提示找不到 upstream/lab3,请先确认远端是否已经发布该分支,以及你的远端名称是否为 upstream

本文阅读建议

本文将从以下三部分对本次实验内容进行展开讲解:

  1. 背景知识:建立虚拟内存、Trap、Page Fault、Lazy Allocation、Copy-On-Write 的概念框架。
  2. NexOS 内存管理的具体实现:详细介绍在 NexOS 中与虚拟内存相关的文件、数据结构、地址空间布局以及页表组织。
  3. 实验任务与功能要求:按照 Lazy Allocation 与 Copy-On-Write 的调用链说明实现目标、边界条件和代码提示。

另有一份补充材料:实验3补充材料:虚拟内存辅助函数与宏定义速查。这份材料集中解释 walk()mappages()kalloc()uvmunmap()copyout() 等底层工具函数,适合作为实现时随查随用的 API 手册。

如果只想快速了解实验要求和代码提示,可以直接跳到第 3 部分;如果想深入理解实验原理与思路,或者对Lazy Allocation、Copy-On-Write 等概念不够熟悉,建议从第 1 部分开始阅读。


1. 背景知识

在理论课上,我们已经简单接触过 Lazy AllocationCopy-on-Write (COW) 的概念,本次实验将围绕这两个现代操作系统中非常核心的虚拟内存技术展开,让大家通过动手实践,加深对理论课知识的理解与掌握。

1.1 虚拟内存与页表

理论课上,我们知道用户程序看到的是虚拟地址,而机器上的内存硬件使用的是物理地址,这二者是通过页表进行地址翻译与转换的。

在分页机制下,虚拟地址空间和物理内存都被划分为固定大小的页。页表记录了:虚拟页到物理页的映射关系,同时,页表项(PTE)中还保存了访问权限(读/写/执行/用户态访问)和一些软件位(如 COW 标记)。当用户程序访问一个虚拟地址时,CPU 会通过页表查找对应的物理地址,并检查权限。如果没有有效映射,或者权限不满足,就会触发 Page Fault。

页表是操作系统为每个进程提供私有地址空间的核心机制。页表决定了一个地址“意味着什么”,也决定了某个进程可以访问哪些物理内存。不同进程可以拥有相同的虚拟地址,但通过各自的页表映射到不同物理页,从而实现地址空间隔离。

总而言之,页表给内核提供了一层间接层,使得内核可以灵活地管理内存资源,支持虚拟内存、内存保护、共享内存等功能。

1.2 Page Fault

当 CPU 使用某个虚拟地址访问内存时,MMU 会根据当前页表进行地址翻译。如果翻译失败,或者页表项中的权限不允许当前操作,CPU 就会触发 Page Fault。

触发 Page Fault 的情况有以下几种:

  • 虚拟地址没有映射;
  • PTE 无效;
  • 权限位 PTE_R、PTE_W、PTE_X、PTE_U 不允许当前操作。

在本次实验中,我们需要认识到:Page Fault 不一定代表访问了非法地址导致程序出错,这可能表示三种完全不同的情况:

情况 是否一定是错误 典型处理
访问尚未分配物理页的 lazy 页面 内核分配物理页并建立映射
写入 COW 共享页 内核复制物理页或恢复写权限
写 text page、访问 guard page、越界访问 内核杀死当前进程

Page Fault 的另一个关键特征是:导致异常的指令还没有成功完成。

例如,用户态执行一条 store 指令时,如果目标页没有映射或没有写权限,硬件会触发 Page Fault。内核进入异常处理流程后,如果能够修复页表,那么返回用户态时,CPU 会从 sepc 指向的那条指令重新执行。此时页表已经被修好,原来的 store 指令就可以成功完成。

这正是 Lazy Allocation 和 COW 能对用户程序完全透明的原因。

1.3 Lazy Allocation

在介绍 Lazy Allocation 之前,我们需要先介绍一下 sbrk() 是做什么的。

用户程序可以通过 sbrk(n) 调整自己的 heap 大小。在内核中,每个进程都有一个 p->sz 字段,用来记录用户地址空间的逻辑边界。换句话说,sbrk() 最直接的作用就是移动这个边界。

具体来说:

  • 当 n > 0 时,sbrk() 扩大 heap,并返回扩大前的旧 break;
  • 当 n == 0 时,sbrk() 不修改 heap,只返回当前 break;
  • 当 n < 0 时,sbrk() 缩小 heap,并释放被裁掉的地址范围;
  • 返回值始终是修改前的旧 break,也就是新增区域的起始地址。

例如:

char *p = sbrk(4096);

如果调用成功,p 就是这次新增的 4096 字节 heap 区域的起始地址。此后,用户程序可以从 p 开始使用这段新申请到的虚拟地址空间。

不过,这里有一个关键问题:sbrk() 成功是否意味着这 4096 字节背后已经有真实的物理内存?

在传统的 eager allocation 实现中(也就是,答案是肯定的。当用户调用 sbrk(n) 扩大 heap 时,内核会立即为新增地址范围分配物理页、清零,并建立页表映射。这样做实现简单,但存在明显浪费:如果程序一次性申请了很大的 heap,却只实际访问其中一小部分,那么未访问的页面也会提前占用物理内存,造成空间和时间上的开销。

Lazy Allocation 正是为了解决这个问题。它的核心思想是:先将这段虚拟地址空间分配给进程,但暂时不为它映射具体的物理页。

也就是说,在 Lazy Allocation 下:

  • sbrk() 只扩大进程的逻辑地址空间,更新 p->sz;
  • 新增区域暂时没有对应的物理页映射;
  • 当用户第一次读写这段地址时,CPU 发现页表中没有有效映射,于是触发 Page Fault;
  • 内核在 Page Fault 处理过程中再分配物理页、清零并建立映射;
  • 修复完成后,返回用户态重新执行原来的访存指令。

因此,Lazy Allocation 并没有改变 sbrk() 对用户程序的接口。用户程序仍然认为自己已经申请到了 heap 空间。它改变的是内核“兑现”这段地址空间的时机:从 sbrk() 调用时,推迟到第一次实际访问时。

这种做法的价值在于:

  • 支持大规模虚拟地址空间预留,而不要求立即拥有等量物理内存,避免为永远不会被访问的页面分配物理页;
  • 将一次大规模分配的开销拆散到后续多次 Page Fault 中按需完成;
  • 对于只使用少量申请空间的程序来说,可以显著减少初始内存占用。

当然,Lazy Allocation 也不是没有代价。第一次访问每个新页面时都会触发 Page Fault,需要经历一次用户态到内核态的切换,因此会带来额外的运行时开销。另外,如果程序真正访问某个 lazy 页面时,系统已经没有空闲物理页,内核也无法继续满足这次访问,通常只能将该进程杀死。

1.4 Copy-on-Write

普通 fork 会为子进程复制父进程的全部用户内存。这种方式实现简单,但存在浪费:很多程序在 fork 后会马上调用 exec,导致之前复制的大部分页面会立刻被丢弃,造成无谓的开销。

Copy-On-Write,简称 COW,解决这个问题的思路是:

  • fork() 时先不复制物理页,让父子进程共享同一批物理页;
  • 只有当其中一方真的要写共享页时,再进行复制。

COW 的本质

COW 不是“不复制”,而是“推迟到必须写入时再复制”。如果页面永远不被写,复制就永远不会发生。

使用 COW 会引入一个新的变量 ref_cnt,也就是 物理页引用计数。因为在启用 COW 后,同一个物理页可能被父进程、子进程等多个进程同时引用。这要求我们在释放物理页时,必须确保所有引用这个页面的进程都已不再使用,才能把这页物理内存释放,因此需要对物理页的引用进行计数。

1.5 内核访问用户内存的特殊性

用户程序访问自己的虚拟地址时,硬件会自动查页表;如果访问失败,会进入 Page Fault 处理流程。

但系统调用中还有另一类访问:内核代表用户读写用户地址。

例如:

read(fd, buf, n);

这里的 buf 是用户地址,但真正把数据写入 buf 的代码运行在内核中。类似地,write(fd, buf, n)exec(path, argv) 等系统调用也需要内核读取用户地址中的数据。

这意味着 Lazy Allocation 和 COW 不能只考虑“用户态 load/store 触发的 Page Fault”。如果内核通过 copyout() 写入一个尚未分配物理页的 lazy 页面,或者写入一个 COW 页,也必须维护同样的语义:

  • 尚未访问过的 lazy 页面应该被按需分配;
  • COW 页应该先拆分成当前进程的私有页,再写入;
  • 非法用户地址仍然应该失败。

因此,本实验虽然从 sbrk()fork() 开始,但最终会牵涉到内核访问用户内存的路径。这也是后面 NexOS 实现部分需要重点讨论的内容。

2. NexOS 内存管理的具体实现

2.1 NexOS 的用户地址空间布局

NexOS 为每个用户进程维护一张独立的用户页表,记录在 struct procp->pagetable 字段中,而进程当前的虚拟内存地址空间大小记录在 p->sz 中。

从用户程序的角度看,它访问的是一段从低地址开始的虚拟地址空间;从内核的角度看,这段地址空间由页表描述,不同区域具有不同的权限和用途。下图展示了一个典型用户进程的地址空间布局。

用户地址空间布局
图:NexOS 用户地址空间布局。

我们按照地址从低到高来分析这张图。

低地址区域:text、data 和 heap

区域 作用 典型权限
text 程序代码段 R-XU
data 已初始化数据、全局变量等 RW-U
heap sbrk() 扩展的动态内存区域 RW-U

其中权限的含义是:

  • R:可读;
  • W:可写;
  • X:可执行;
  • U:用户态可访问。
  • -:没有该权限。

例如,text 段通常是 R-XU,表示用户态可以读取和执行代码段,但不能写代码段。这样如果用户程序错误地写入代码段,就会触发 Page Fault,并被内核判定为非法访问。

dataheap 通常是 RW-U,表示用户态可以读写,但不能执行。heap 是本次实验 Lazy Allocation 最关注的区域:当用户调用 sbrk() 扩大堆空间时,NexOS 可以先只扩大进程的逻辑地址空间,而不立即为新增页分配物理页。

也就是说,在 Lazy Allocation 开启后:

va < p->sz 只表示该虚拟地址在逻辑上属于当前进程,不一定表示该地址已经有物理页映射。真正的物理页可能要等到用户第一次访问该地址并触发 Page Fault 时才分配。

栈区域:stack 和 guard page

图中 stack 表示用户栈。用户栈保存函数调用过程中的局部变量、返回地址,以及程序启动时传给 main(argc, argv) 的参数信息。

右侧放大的部分展示了用户栈中的初始内容,主要包括:

  • 命令行参数字符串,例如 argument 0argument N
  • 一个空指针 0,对应 argv[argc]
  • 指向各个参数字符串的指针数组,即 argv[0]argv[argc - 1]
  • argv 本身,也就是传给 main 的第二个参数。

用户栈通常从高地址向低地址增长。为了检测栈越界,NexOS 会在用户栈下方放置一页 guard page。 guard page 的关键点是:它不是普通未分配页面。它的页表项可能仍然有效,但没有 PTE_U,因此用户态不能访问它。用户访问 guard page 应该被视为栈越界或非法访问。

Guard Page 不能被 Lazy Allocation 修复

guard page 的 PTE 仍然是 PTE_V,只是没有 PTE_U。用户访问它应该被视为非法访问并杀死进程,而不是把它当作尚未映射的 lazy 页面分配成普通可写页。否则,guard page 就失去了检测栈越界的作用。

高地址区域:TRAPFRAME 和 TRAMPOLINE

用户地址空间顶部有两页特殊映射:

  • TRAMPOLINE = MAXVA - PGSIZE
  • TRAPFRAME = TRAMPOLINE - PGSIZE

它们位于接近 MAXVA 的高地址处,如图中最上方所示。

区域 作用 用户态是否可访问
TRAMPOLINE 用户态和内核态切换时执行的 trampoline 代码
TRAPFRAME 保存用户寄存器和内核返回信息
  • TRAMPOLINE 保存 Trap 入口和返回用户态所需的代码。它需要在用户页表和内核页表中都能被稳定映射,因为 Trap 发生时,系统需要依赖这段代码完成页表切换和寄存器保存。

  • TRAPFRAME 是每个进程独有的区域,用于保存该进程从用户态进入内核时的寄存器状态,以及返回用户态时所需的信息。

这两页虽然位于用户虚拟地址空间顶部,但不应该被普通用户程序访问,也不应该被 sbrk() 或普通用户页映射覆盖。因此,sbrk() 增长必须小于等于 proc_mmap_limit(p),而 proc_mmap_limit(p) 默认不会超过 TRAPFRAME

2.2 NexOS 的页表组织——RISC-V Sv 39页表

NexOS 采用 RISC-V Sv39 分页模式。在 Sv39 页表下,64 位地址中只有低 39 位参与地址翻译,其中低 12 位是页内偏移,高 27 位分成三级页表索引,每级 9 位,包含了 \(2^{27}\) 个页表项(PTE)。每个 PTE 包含了 44 位的物理页号 (PPN) 和 10 位权限位(flags)。

一个虚拟地址可以拆成:

VPN[2] VPN[1] VPN[0] Offset
9 bits 9 bits 9 bits 12 bits

其中:

  • Offset 是页内偏移;
  • VPN[2]、VPN[1]、VPN[0] 分别用于索引三级页表;
  • 页大小为 4096 bytes = 2^12 bytes;
  • 每个页表页有 512 个 PTE,因此每级索引是 9 bit。

地址翻译过程可以大致抽象为以下步骤:

satp
  -> root page table
    -> level-1 page table
      -> level-0 page table
          -> leaf PTE
            -> physical page

最终物理地址由叶子 PTE 中的物理页号和虚拟地址中的页内偏移拼接得到:

\[ PA = (PPN << 12) \space | \space Offset \]

NexOS 中页表相关类型的定义是:

typedef uint64 pte_t;
typedef uint64 *pagetable_t;

也就是说,pagetable_t 本质上是一个指向根页表页的指针。每个用户进程的根页表记录在 struct procp->pagetable 中;内核也有自己的全局页表 kernel_pagetable。当 CPU 准备在用户态运行某个进程时,内核会通过 MAKE_SATP(p->pagetable) 构造 satp 的值,并在 trampoline 返回用户态时切换到该进程的用户页表。

NexOS 中使用的PTE 标志位包括:

  • PTE_V:页表项有效;
  • PTE_R:允许读;
  • PTE_W:允许写;
  • PTE_X:允许执行;
  • PTE_U:允许用户态访问;
  • PTE_COW:NexOS 自定义的软件位,用于标记 COW 页面。

Sv39 页表地址转换

图:Sv39 页表地址转换。

2.3 NexOS 中的 Page Fault

课堂上我们已经学习过 Page Fault 的定义以及处理流程。在 NexOS 中,Page Fault 处理的核心,就是根据 fault 地址和页表状态判断它属于哪一类页面并作出对应处理。

页面状态 页表表现 典型触发场景 应走路径
尚未映射的 lazy 页面 va < p->sz,但没有有效 leaf PTE 首次访问,或内核访问尚未访问过的 lazy 页面 分配零页并建立映射
普通可写页 PTE_V | PTE_R | PTE_W | PTE_U 正常读写 无需特殊处理
COW 页 PTE_V | PTE_R | PTE_U | PTE_COW,无 PTE_W 写入共享页 解除 COW,必要时复制物理页
只读 text 页 有效、可读/可执行、不可写、无 PTE_COW 用户尝试写代码段 非法访问,杀死进程
guard page PTE 有效,但无 PTE_U 用户栈越界访问 非法访问,杀死进程
越界地址 不属于合法用户地址空间 任意访问 非法访问,杀死进程

这个表是后续实现 should_lazy_alloc()cow_handle_fault()copyout()usertrap() 分派逻辑的基础。

下面这张图展示了 NexOS 中一次用户态 Page Fault 的控制流:

从用户态指令触发异常,到 trampoline 进入内核,再到 usertrap() 根据 fault 类型分派,最后在修复页表后返回用户态继续执行当前指令。

无法直接预览 PDF,请点击下载: lab3-page-fault-flow.pdf


3. 实验任务与功能要求

本次实验一共分为两个部分:

  1. Part 1:Lazy Allocation
  2. Part 2:Copy-on-Write Fork

由于 Copy-on-Write 是在 Lazy allocation 的基础上进行的修改,因此需要先完整实现并通过 Lazy Allocation 相关测试,才能继续实现 Copy-on-Write。这样可以避免 lazy fault、COW fault、copyout() 等路径混在一起,增加调试难度。

3.1:Lazy Allocation

Lazy Allocation 的目标是:sbrk() 扩展用户地址空间时,不立即分配物理页;只有当用户程序或内核真正访问该地址时,通过触发 Page Fault 将控制权给到内核态,由内核态根据 Page Fault 情况进行处理,分配物理页并建立映射。

这一部分主要围绕下面这条状态转换展开:

sbrk()
  -> 扩大 p->sz
  -> 预留一段尚未映射的用户虚拟地址
  -> 第一次访问时触发 Page Fault
  -> 分配物理页并建立 PTE
  -> 变成普通可写用户页

3.1.1 Lazy Allocation 总体流程

Lazy Allocation 的核心是把虚拟地址空间的预留和物理页的分配拆开,使得调用 sbrk() 的时候,只扩大进程的逻辑地址空间,不立即映射到实际的物理页面上。

当用户态第一次访问这类尚未映射的 lazy 页面时,硬件会触发 Page Fault,由内核进行处理。

整体路径如下:

user: sbrk(n)
  -> sys_sbrk()
      -> 只更新 p->sz,不分配物理页

user: 访问新增地址
  -> Page Fault
  -> usertrap()
  -> proc_handle_page_fault()
  -> user_lazy_alloc()
      -> should_lazy_alloc()
      -> kalloc()
      -> memset()
      -> mappages()
  -> 返回用户态重新执行 faulting instruction

需要特别注意:

  • lazy 页面第一次访问时才真正分配物理页;
  • Page Fault 修复成功后,sepc 不应执行 +4,因为原指令还没有完成;
  • 非法地址、guard page、写 text page 等情况不能被 lazy allocation 修复;
  • 内核通过 copyin()copyout()copyinstr() 访问尚未访问过的 lazy 页面时,也要主动补页。

3.1.2 实现 lazy 版本的 sys_sbrk()

sys_sbrk() 负责实现用户态 sbrk(n) 系统调用。启用 Lazy Allocation 后,它需要正确处理三种情况:

情况 处理方式
n > 0 只扩大进程逻辑地址空间,不立即分配物理页
n == 0 不修改地址空间,返回当前 break
n < 0 缩小进程逻辑地址空间,并释放被裁掉范围中已经映射的物理页

涉及文件

  • kernel/core/syscall.c

实现要点

lazy_alloc_enabled && n > 0 时,sys_sbrk() 不应再调用 growproc()。此时内核只需要更新 p->sz,表示这段虚拟地址已经逻辑上属于当前进程;真正的物理页分配会推迟到第一次访问该地址并触发 Page Fault 时完成。

n < 0 时,Lazy Allocation 不改变释放语义。已经不属于进程地址空间的页面应该立即回收。

实现时需要注意以下边界:

  • n == 0 时不修改 p->sz
  • n > 0 时,在进行分配之前要先检查地址的合法性,例如是否超过合法上界(proc_mmap_limit(p))、是否发生地址溢出等情况;

3.1.3 实现 should_lazy_alloc()

当用户访问某个尚未映射的地址并触发 Page Fault 时,内核不能立刻认为它一定是 lazy allocation。这个 fault 可能来自合法的 lazy heap 页面,也可能来自 guard page、text page 或越界地址。

因此我们需要对该地址进行分析决策,而should_lazy_alloc() 的作用就是判断:这个 fault 地址是否属于“逻辑上合法、但尚未分配物理页”的 lazy 页面,如果是,则返回 1,否则返回 0。

涉及文件

  • kernel/core/vm.c

实现要点

实现时应先使用 PGROUNDDOWN() 将 fault 地址对齐到页起始地址,后续查页表、判断 guard page、建立映射都应基于这个页起始地址。

一个可以通过 lazy allocation 修复的 fault 至少应满足:

  • 当前进程、页表和 trapframe 有效;
  • fault 地址位于当前进程的逻辑地址空间内,即小于 p->sz
  • fault 地址没有超过用户地址空间上界,例如 proc_mmap_limit(p)TRAPFRAMEMAXVA
  • fault 地址不是用户栈下方的 guard page;
  • 页表中还没有对应的有效 leaf PTE。

如果这些条件都满足,说明该地址很可能是 sbrk() 预留出来、但尚未实际分配物理页的虚拟空间,后续可以由 user_lazy_alloc() 为它分配一页清零后的物理页并建立映射。

不能通过 lazy allocation 修复的情况

情况 原因
写 text page text page 本来就不可写,这是保护错误
访问 guard page guard page 用于检测栈越界,不能变成普通用户页
访问越界地址 地址不属于当前进程的逻辑地址空间
访问 TRAPFRAME / TRAMPOLINE 这些是用户态不可访问的保留映射
已有有效 PTE 但权限不允许 这是保护错误,不是可以补页的虚拟空间

实现提醒

不要只用 va < p->sz 判断是否可以 lazy allocation。guard page、已有有效 PTE 的保护错误、以及保留高地址区域都不能被当成 可以通过 lazy 修复的。

3.1.4 实现 user_lazy_alloc()

经过 should_lazy_alloc() 判断后,内核已经知道某个 fault 地址属于尚未分配物理页的合法用户页。这类页面已经被 sbrk() 纳入进程的逻辑地址空间,但页表中还没有对应的有效映射。

user_lazy_alloc() 的任务,就是把这样的页面转换成一个真正可访问的用户页。

可以把它理解为下面这个状态转换:

逻辑上属于进程,但尚未映射物理页面的合法用户页
  -> 分配物理页
  -> 清零
  -> 建立用户页表映射
  -> 普通可读写用户页

涉及文件

kernel/core/vm.c

实现要点

user_lazy_alloc() 需要保证以下语义:

  • 按页处理 fault 地址: Page Fault 可能发生在一页中的任意偏移处,但物理页分配和页表映射都以页为单位。因此,处理前应先将 fault 地址对齐到页起始地址。

  • 只处理合法的延迟分配页面: 分配物理页前,应调用 should_lazy_alloc() 或等价逻辑确认该地址确实可以通过 Lazy Allocation 修复。不能为 guard page、text page、越界地址或已有有效映射的页面补页。

  • 新页面内容必须初始化为 0: Lazy Allocation 对用户程序应表现为 zero-fill-on-demand。也就是说,用户第一次读取尚未写过的页面时,应看到全 0,而不是物理页中的旧数据。

  • 建立用户态可读写映射: 成功补页后,该虚拟页应成为普通用户页,允许用户态读写。映射权限通常应包含 PTE_R、PTE_W 和 PTE_U。

  • 失败时正确清理资源: 如果物理页已经分配,但后续建立映射失败,必须释放刚分配的物理页,避免内存泄漏。

成功返回后(return 0),原本触发 Page Fault 的 load/store 指令会在返回用户态后重新执行。由于页表已经被修复,这次访问应当成功。

失败处理要求

  • 如果物理页分配失败,应返回错误(return -1),由上层决定是否杀死当前进程。
  • 如果页表映射失败,应释放已经分配的物理页,避免内存泄漏。
  • user_lazy_alloc() 不应该因为用户态非法访问而 panic 整个内核;无法修复的 fault 应交给上层处理,通常是杀死当前进程。

3.1.5 在 usertrap()proc_handle_page_fault() 中处理 lazy fault

当用户态程序第一次访问 sbrk() 新扩展出来、但尚未分配物理页的地址时,硬件会触发 Page Fault。此时内核需要判断这次 fault 是否可以修复:如果它来自合法的 lazy allocation 地址,就补页并返回用户态;如果它来自非法地址、guard page 或权限错误,就杀死当前进程。

这一节涉及两个位置:

  • usertrap():识别用户态 Page Fault,并把尚未处理的 load/store fault 交给进程层的 Page Fault 处理函数;
  • proc_handle_page_fault():在进程地址空间层面判断这个 fault 应该走 lazy allocation、mmap,还是判定为非法访问。

可以把这条路径抽象为:

用户态 load/store
  -> 触发 Page Fault
  -> 进入 usertrap()
      -> 读取 scause / stval
      -> 判断是否为 LOAD_PAGE_FAULT 或 STORE_PAGE_FAULT
      -> 如果不是 COW fault,交给 proc_handle_page_fault()
  -> proc_handle_page_fault()
      -> 拒绝已有有效 PTE 的保护错误
      -> 先尝试 user_lazy_alloc()
      -> 如果不是 lazy allocation,再尝试 mmap fault
  -> 返回用户态重新执行 faulting instruction

注意,usertrap() 只负责异常分派;真正的 lazy 页面判断和补页仍然由 proc_handle_page_fault()should_lazy_alloc()user_lazy_alloc() 完成。

涉及文件

  • kernel/core/trap.c
  • kernel/core/proc.c

实现要点

usertrap() 中,需要完成 Page Fault 的高层分派:

  • scause 表示 LOAD_PAGE_FAULTSTORE_PAGE_FAULT 时,通过 r_stval() 取得 fault 地址;
  • 如果启用了 COW,并且这是一个 STORE_PAGE_FAULT,应先让 COW 路径尝试处理;
  • 如果 COW 没有处理这个 fault,则调用 proc_handle_page_fault(fault_va, code == STORE_PAGE_FAULT)
  • 如果 proc_handle_page_fault() 返回 0,说明 fault 已经修复,后续返回用户态重新执行原指令;
  • 如果 proc_handle_page_fault() 返回 -1,说明无法修复,应打印必要调试信息并设置当前进程为 killed,而不是让内核 panic

proc_handle_page_fault() 中,当前代码已经给出了大部分框架,包括基本合法性检查、拒绝已有有效 PTE 的保护错误,以及后续 mmap fault 处理。Lazy Allocation 需要补上的关键逻辑是:

  • 如果 lazy_alloc_enabled 为真:
    • 调用 user_lazy_alloc() 进行分配
    • 如果返回 0,说明 lazy fault 已修复,proc_handle_page_fault() 返回 0
    • 如果失败,不要 panic,而是继续尝试后面的处理

也就是说,lazy allocation 分支应该位于 mmap fault 处理之前。这样 heap 中尚未映射的页面会优先通过 user_lazy_alloc() 补页;如果 fault 地址并不是合法的 lazy allocation 地址,再交给 mmap 逻辑继续判断。

3.1.6 支持内核访问尚未分配物理页的用户页面

Lazy Allocation 不只影响用户态的 load/store 指令。系统调用执行过程中,内核也可能需要读取或写入用户传入的地址,例如:

read(fd, buf, n)
  -> copyout() 将内核数据写入用户缓冲区 buf

write(fd, buf, n)
  -> copyin() 从用户缓冲区 buf 读取数据

exec(path, argv)
  -> copyinstr() / copyin() 读取用户传入的字符串和参数

如果这些用户地址来自 sbrk() 新扩展的区域,但用户程序还没有直接访问过它们,那么页表中可能还没有有效映射。此时,普通地址翻译会失败,但这不一定表示地址非法;它也可能只是一个尚未分配物理页的合法用户页面。

在 NexOS 中,普通地址翻译通常通过 walkaddr(pagetable, va) 完成。walkaddr() 只查询已有映射:它要求对应 PTE 已经存在、有效,并且允许用户态访问。如果页表中还没有映射,walkaddr() 会返回 0;它本身不会分配物理页,也不会处理 Lazy Allocation。walkaddr() 的完整说明见 虚拟内存辅助函数与宏定义速查

因此,本实验需要在内核访问用户地址时支持下面的语义:

先尝试普通地址翻译
  -> 如果翻译成功,直接使用物理地址
  -> 如果翻译失败,判断是否是尚未分配物理页的合法用户页面
  -> 如果可以 lazy allocation,则分配物理页并建立映射
  -> 重新翻译地址
  -> 继续完成 copyin/copyout/copyinstr

这一逻辑由 lazy_alloc_walkaddr() 封装。

涉及文件

  • kernel/core/vm.c

实现要点

lazy_alloc_walkaddr() 需要保证以下语义:

  • 优先使用已有映射: 如果 walkaddr() 能成功得到物理地址,直接返回。
  • 翻译失败后再判断原因: walkaddr() 失败可能表示地址非法,也可能表示该地址合法但尚未分配物理页。
  • 只为合法页面补页: 只有确认地址可以通过 Lazy Allocation 修复时,才分配物理页、清零并建立映射。非法地址、guard page、保留区域和权限错误仍然应该失败。
  • 补页后重新翻译: 页表被修改后,必须重新调用地址翻译函数获取物理地址。
  • 跨页复制逐页处理: copyin()copyout()copyinstr() 可能跨越多个页面,每一页都要独立完成地址翻译和必要的 lazy allocation。

copyin() / copyout() / copyinstr() 的影响

copyin()copyout()copyinstr() 中,应通过 lazy_alloc_walkaddr() 获取用户地址对应的物理地址,而不是直接依赖传统的 walkaddr()

这一部分的代码助教已经在框架中提供,大家需要理解其作用:它保证内核访问用户缓冲区时,也能正确处理尚未分配物理页的 lazy 页面。

3.1.7 Lazy Allocation 测试与调试

make qemu LAZY_ALLOC=1 COW_ALLOC=0

进入 NexOS shell 后运行:

lazytest

lazytest 会覆盖大块 sbrk()、基本读写、非顺序触页、shrink、fork 后继承 lazy 地址空间、sbrk(0)、guard page、以及接近 TRAPFRAME 的边界检查。

3.2:Copy-on-Write Fork

Copy-on-Write 的目标是:fork() 时不立即复制父进程的所有物理页,而是让父子进程先共享物理页;只有当其中一方写共享页时,才复制出私有页。

这一部分主要围绕下面这条状态转换展开:

普通可写页
  -> fork()
  -> 父子共享只读 COW 页
  -> 第一次写入触发 STORE_PAGE_FAULT
  -> cow_handle_fault()
  -> 普通可写私有页

3.2.1 COW 总体流程

COW 的关键是用页表权限制造一次“可恢复的写保护 fault”。fork() 时,父子进程先共享同一个物理页;对原本可写的页,父子 PTE 都清除 PTE_W 并设置 PTE_COW。之后任意一方写入该页都会触发 STORE_PAGE_FAULT,内核再根据引用计数决定是复制物理页,还是直接恢复写权限。

整体路径如下:

fork()
  -> uvmcopy()
      -> 父子共享物理页
      -> 对原本可写页清除 PTE_W
      -> 对原本可写页设置 PTE_COW
      -> 增加物理页引用计数

用户写共享页
  -> STORE_PAGE_FAULT
  -> usertrap()
  -> cow_handle_fault()
      -> ref_count == 1:恢复写权限
      -> ref_count > 1:复制物理页
  -> 返回用户态重新执行 store 指令

需要注意的是:

  • COW 只适用于原本可写的用户页;
  • NexOS 在 /include/riscv.h 中将权限位中的第 9 位定义成为 COW 的标志位 PTE_COW (1L << 8)
  • 原本只读的 text page 不能被标记为 PTE_COW
  • COW 必须配合物理页引用计数;
  • COW 和 Lazy Allocation 会同时存在,在通过 uvmcopy() 进行页面复制的时候,需要跳过尚未映射的 lazy 页面;
  • 内核通过 copyout() 写用户 COW 页时,也必须主动解除 COW。

3.2.2 为物理页增加引用计数

在没有 COW 时,一个用户物理页通常只被一个进程页表引用。进程退出、sbrk() shrink 或 uvmunmap() 释放页面时,内核可以直接调用 kfree() 把物理页放回 freelist。

启用 COW 后,父子进程会在 fork() 后共享同一批物理页。此时某个进程释放自己的页表映射,并不意味着这个物理页已经没有进程在使用;只有最后一个引用消失时,该物理页才能真正被释放。

引用计数维护的是下面这个生命周期:

kalloc()
  -> 新物理页 ref_count = 1

uvmcopy()
  -> 父子共享同一物理页
  -> kaddref(pa)
  -> ref_count + 1

kfree()
  -> ref_count - 1
  -> 如果 ref_count 仍大于 0,只减少引用,不释放物理页
  -> 如果 ref_count 变为 0,才放回 freelist

涉及文件

  • kernel/core/kalloc.c

实现要点

当前代码已经提供了一个可以使用的基本表示:

static struct spinlock ref_lock;
static int ref_cnt[PHYSTOP / PGSIZE];

可以使用物理页号作为数组下标,例如用 (uint64)pa / PGSIZE 定位引用计数。实现时也可以换成等价表示,但要保证下面几个函数的语义一致:

  • kalloc() 成功分配出一个新物理页后,应将该页引用计数设置为 1。
  • kfree() 释放页面时先减少引用计数;只有引用计数归零时,才填充调试字节并把页面挂回 freelist。
  • kaddref(pa) 当一个物理页新增一个页表映射时,增加对应引用计数。
  • kgetref(pa) 返回当前物理页的引用计数,供 cow_handle_fault() 判断是否需要复制。

注意事项

  • 引用计数读写需要用 ref_lock 保护,避免多个进程同时 fork、exit 或触发 COW fault 时破坏计数。
  • ref_count > 1 时,kfree() 只能减少引用计数,不能填充调试字节,也不能把页面挂回 freelist。
  • 不要对未页对齐、低于 end、高于等于 PHYSTOP 的无效物理地址操作引用计数。
  • kinit() 初始化阶段会调用 kfree() 把空闲页挂入 freelist,需要让引用计数逻辑与初始化流程兼容。
  • 如果出现“一个进程退出后另一个进程崩溃”,应优先检查共享页是否被提前释放。

3.2.3 修改 uvmcopy():fork 时共享页

uvmcopy()fork() 创建子进程地址空间时使用的函数。没有 COW 时,它会为子进程分配新物理页,并复制父进程已有页面的内容。

启用 COW 后,这一步需要修改成:父子进程先共享已有物理页,并通过页表权限记录“写入时再复制”的状态。

涉及文件

  • kernel/core/vm.c

实现要求

启用 COW 后,uvmcopy() 遍历父进程用户地址空间时,需要按照页面状态分别处理:

页面状态 处理方式
尚未映射的 lazy 页面 跳过,不分配物理页
原本只读的用户页 父子共享,不设置 PTE_COW
原本可写的用户页 父子共享,双方都清除 PTE_W,并设置 PTE_COW

对于共享出去的物理页,需要增加引用计数,确保该物理页不会在某个进程退出或解除映射时被提前释放。

COW fork 需要达成以下要求:

  1. 父子进程读共享页时看到相同内容;
  2. 父子任意一方写共享页时都会触发 Page Fault;
  3. 原本只读的页面仍然保持只读;
  4. 尚未映射的 lazy 页面不会在 fork() 时被提前分配。

因此,如果某个父进程页面原本是可写的,uvmcopy() 必须同时修改父进程和子进程的 PTE。只修改子进程是不够的:如果父进程仍然保留 PTE_W,父进程就可以直接写共享物理页,子进程也会看到这次修改,从而破坏进程隔离。

接口约定

uvmcopy() 应保持原有返回值语义:成功返回 0,失败返回 -1fork() 会根据这个返回值决定是否继续创建子进程。

如果中途失败,uvmcopy() 需要清理已经在子页表中建立的映射,避免资源泄漏。COW 开启后,这些映射可能共享父进程物理页,因此清理时必须依赖正确的引用计数语义,避免提前释放仍被父进程使用的物理页。

注意事项

  • 不要把 text page 这类原本只读的页面标记为 PTE_COW
  • 不要在 fork() 时为尚未映射的 lazy 页面分配物理页。
  • 每个新增的共享映射都需要维护物理页引用计数。
  • 如果中途失败,需要撤销子进程已经建立的映射;因此 kfree() 的引用计数语义必须正确,避免回滚时提前释放共享页。

思考题

Lazy Allocation 开启后,p->sz 以内可能存在尚未映射的虚拟页面。为什么 uvmcopy()fork() 时应该跳过这些页面,而不是为子进程立即分配物理页?子进程之后第一次访问这些地址时,应该发生什么?

3.2.4 在 usertrap() 中优先识别 COW fault

当用户态写入 COW 页面时,由于该页的 PTE_W 已经被清除,硬件会触发 STORE_PAGE_FAULT。这类 fault 不是非法访问,而是 COW 机制刻意制造出来的“可恢复 fault”。

因此,在 usertrap() 处理 Page Fault 时,需要优先判断当前 fault 是否是 COW 写 fault。如果是,就交给 COW 处理逻辑;如果不是,再继续尝试 lazy allocation 或其他 page fault 处理路径。

涉及文件

  • kernel/core/trap.c
  • kernel/core/vm.c

实现要求

usertrap() 在处理 Page Fault 时应满足以下语义:

  • 只有 STORE_PAGE_FAULT 才可能是 COW fault;
  • 需要根据 stval 找到 fault 地址;
  • 需要检查 fault 地址对应页面是否是 COW 页面;
  • 如果是 COW fault,应调用 cow_handle_fault() 处理;
  • 如果 COW 处理成功,应返回用户态重新执行原来的 store 指令;
  • 如果不是 COW fault,应继续交给普通 Page Fault 处理路径,例如 lazy allocation 或 mmap fault handler;
  • 如果所有路径都无法修复,应杀死当前进程。

这里的关键是:COW 判断必须发生在普通 lazy allocation 判断之前

注意事项

  • load fault 不应进入 COW 处理路径。
  • 没有 PTE_COW 的只读页不是 COW 页。
  • 写 text page 应被视为非法访问,而不是 COW fault。
  • 访问 guard page 也不能被 COW 修复。
  • COW 处理失败时,应标记当前进程为 killed。

思考题

为什么 COW fault 必须在 lazy allocation 之前处理?如果把写 COW 页交给普通 Page Fault 路径,会出现什么问题?

3.2.5 实现 cow_handle_fault()

经过 usertrap() 判断后,内核已经知道当前 fault 来自用户态写 COW 页。cow_handle_fault() 的任务,是把这个 COW 页重新变成当前进程可以写的普通用户页。

根据引用计数不同,这个转换有两种情况:

ref_count == 1
  -> 当前进程已经是唯一引用者
  -> 不需要复制
  -> 直接恢复写权限

ref_count > 1
  -> 仍有其他进程共享旧物理页
  -> 分配新页并复制内容
  -> 当前 PTE 指向新页
  -> 释放当前进程对旧页的引用

涉及文件

  • kernel/core/vm.c
  • kernel/core/kalloc.c
  • kernel/include/riscv.h

实现要点

fault_va
  -> PGROUNDDOWN(fault_va)
  -> walk() 找到 PTE
  -> 检查 PTE_V / PTE_U / PTE_COW
  -> oldpa = PTE2PA(*pte)
  -> flags = PTE_FLAGS(*pte)
  -> ref = kgetref(oldpa)

如果 ref == 1:
    清除 PTE_COW
    设置 PTE_W

如果 ref > 1:
    kalloc() 新页
    memmove() 复制旧页内容
    更新 PTE 指向新页
    设置 PTE_W
    清除 PTE_COW
    kfree(oldpa)

ref == 1 时不需要复制,因为当前进程已经是这个物理页的唯一拥有者。只要恢复写权限并去掉 PTE_COW,这页就可以重新变成普通可写页。

ref > 1 时必须复制,因为旧页仍被其他进程引用。此时应先申请新页并复制内容,只有这些步骤都成功后,才能更新当前 PTE 指向新物理页。最后用 kfree(oldpa) 释放当前进程对旧页的那一个引用。

返回值语义

  • 0:处理成功。va 所在页面已经可以被当前进程写入。
  • -1:处理失败。该 fault 不是合法 COW fault,或者处理过程中发生错误。

usertrap() 会在返回 -1 时杀死当前进程;copyout() 会在返回 -1 时终止本次复制并向上返回错误。因此,cow_handle_fault() 应通过返回值报告失败,而不是直接 panic

注意事项

  • fault 地址必须页对齐后再查 PTE。
  • 非 COW 页面必须返回失败,不能被强行改成可写页。
  • kalloc() 失败时不能破坏旧 PTE。
  • 更新 PTE 时应保留必要权限,例如 PTE_RPTE_U,并清除 PTE_COW、设置 PTE_W
  • 旧页引用减少必须走统一释放路径,不要直接操作 freelist。
  • 如果 kgetref(oldpa) 返回异常值,优先检查引用计数初始化、kaddref()kfree()

3.2.6 处理 copyout() 写 COW 页

用户态写 COW 页会触发 STORE_PAGE_FAULT,但 copyout() 写用户缓冲区时,实际写入发生在内核态,不会自动走用户态 COW fault 路径。

例如:

read(fd, buf, n);

如果 buf 是 COW 页,copyout() 必须主动解除 COW,然后再把数据写入当前进程自己的私有页。

这一节和 3.1.6 的 Lazy Allocation 内核访问路径是同一个问题的另一半:内核访问用户地址时,也要维护用户页表中记录的 lazy / COW 语义。

涉及文件

  • kernel/core/vm.c

实现要点

copyout() 每次写入一页前,需要按页完成下面的检查:

检查目标页是否是尚未映射的 lazy 页面
  -> 如果是,先通过 lazy_alloc_walkaddr() 补页

检查目标页是否是 COW 页
  -> 如果是,调用 cow_handle_fault() 进行处理,并检查是否处理成功

重新获取 PTE / PA
确认最终目标页可写
执行 memmove() 拷贝数据

当前 copyout() 已经按页循环处理目标地址,并先调用 lazy_alloc_walkaddr(pagetable, va0, 1)。解除 COW 成功后,原先的 pa0 可能已经指向旧共享页,所以必须重新查 PTE 或重新调用 lazy_alloc_walkaddr()

注意事项

  • 跨页写入时,每一页都要独立检查 lazy 和 COW。
  • COW 处理成功后,旧的 pa0 可能失效。
  • 必须重新获取物理地址,最终 memmove() 的目标应是当前私有可写页。
  • 解除 COW 失败时返回 -1,不要 panic。
  • 非法用户地址仍然应该失败。

3.2.7 Lazy 与 COW 的组合场景

在真实操作系统中,Lazy Allocation 和 COW 会同时存在。因此,同学们在实现时不要把它们理解成两套互不相干的逻辑:uvmcopy() 需要保留尚未映射的 lazy 页面;usertrap() 需要先处理 COW fault,再把其他 Page Fault 交给 lazy / mmap 路径;copyout() 则需要同时处理 lazy 补页和 COW 解除。

下面这个例子可以帮助检查两者是否正确组合:

char *p = sbrk(4 * PGSIZE);
p[0] = 'A';
p[3 * PGSIZE] = 'D';

int pid = fork();

if (pid == 0) {
    p[PGSIZE] = 'B';
    p[2 * PGSIZE] = 'C';
    p[0] = 'X';
}

可以按时间顺序检查正确行为:

  • page 0page 3 在 fork 前已经分配。
  • page 1page 2 在 fork 前仍然只是 sbrk() 预留出来、尚未映射。
  • fork() 时只处理已有有效页,不能补齐这些尚未映射的页面。
  • child 访问 page 1 / page 2 时,应在 child 中 lazy 分配。
  • child 写 page 0 时,应触发 COW。
  • parent 的 page 0 内容不应被 child 修改影响。

这个例子也说明了为什么 uvmcopy() 不能简单地遍历 [0, p->sz) 并要求每一页都有 PTE。p->sz 是逻辑大小,其中可能包含合法但尚未映射的地址范围。

3.2.8 COW 测试与调试

make qemu LAZY_ALLOC=1 COW_ALLOC=1

进入 NexOS shell 后运行:

lazytest
cowtest

cowtest 会覆盖基本 COW、父子写隔离、多进程共享、sbrk() + COW、pipe/read 触发的 copyout()、大内存压力下的 fork、COW 写入和跨页 copyout()

3.3 TODO 索引

Lazy Allocation 和 COW 的共同点是:

先通过页表记录一种“暂时不可直接访问”的状态;
等访问真正发生时,让 Page Fault 或 copyout 路径进入内核;
内核再修改页表,使原来的访问能够成功。

区别在于:

机制 延迟的工作 触发时机 修复动作
Lazy Allocation 分配物理页 第一次访问尚未映射的 lazy 页面 分配零页并建立映射
Copy-on-Write 复制物理页 第一次写共享页 复制或恢复写权限

下面这张表可以作为实现时的 TODO 索引:

TODO 位置 所属主线 主要责任
sys_sbrk() 正增长 Lazy 创建状态 只扩大 p->sz,不分配物理页。
sys_sbrk() 负增长 Lazy 回收状态 立即释放超出新大小的已映射页面,跳过没有物理页的地址。
should_lazy_alloc() Lazy fault 判断 判断 fault 地址是否可以通过 lazy allocation 补页。
user_lazy_alloc() Lazy fault 修复 分配、清零并映射 faulting page。
proc_handle_page_fault() 处理 lazy fault 在处理 page fault 时,调用 user_lazy_alloc() 进行处理。
usertrap() Page Fault 分派 Lazy / COW 使用状态 先识别 COW store fault,再把其他 page fault 交给 proc_handle_page_fault()
lazy_alloc_walkaddr() Lazy 内核访问路径 copyin() / copyout() / copyinstr() 能访问尚未访问过的 lazy 页面。
kaddref() / kgetref() COW 引用计数 记录和查询物理页共享次数。
kalloc() / kfree() COW 生命周期 新页引用为 1,释放时最后一个引用才回到 freelist。
uvmcopy() COW 创建状态 fork() 时共享物理页,设置 COW 权限,保留尚未映射的 lazy 页面。
cow_handle_fault() COW 用户写路径 根据引用计数决定恢复写权限或复制私有页。
copyout() COW 分支 COW 内核写路径 内核写用户 COW 页前主动解除 COW。

4. 核心辅助函数速查

完整说明见 实验3补充材料:虚拟内存辅助函数与宏定义速查。下面是实验中最常用的函数和宏,建议在实现前先熟悉它们的功能和调用关系:

实现场景 优先查看的函数 / 宏 说明
对齐 Page Fault 地址 PGROUNDDOWN() stval 对齐到页起始地址后再查页表或建立映射。
判断和修改 PTE walk()PTE_FLAGS()PTE2PA()PA2PTE() Lazy 和 COW 都需要直接检查 PTE 是否有效、是否用户可访问、是否带 PTE_COW
建立用户页映射 mappages() user_lazy_alloc() 和 COW fork 都需要用它把虚拟页映射到物理页。
分配和释放物理页 kalloc()kfree()kaddref()kgetref() Lazy fault 分配新页;COW 依靠引用计数判断复制或直接恢复写权限。
回收地址空间 uvmunmap()uvmdealloc() sbrk() shrink、进程退出、错误回滚都会走到这些释放路径。
内核访问用户内存 copyin()copyout()copyinstr()lazy_alloc_walkaddr() 尚未访问过的 lazy 页面和内核侧 COW 写入都必须在这里正确处理。
Trap 分派 usertrap()proc_handle_page_fault() usertrap() 负责识别用户态 Page Fault;proc_handle_page_fault() 是已提供的中间分派框架,负责尝试 lazy allocation 或 mmap fault。

5. 本次实验与 AIOS / LLM 推理系统的关系

大家可能会疑问,这次实验实现的全是传统操作系统中的内存管理,与第二次实验中涉及的 AIOS 看起来并无关系。但实际上, 这两个思想并不只存在于传统 OS 中,在 AIOS 和大模型推理系统中,它们同样对应着非常重要的资源管理问题。

一方面,Lazy Allocation 的思想可以用于管理 LLM 推理中的 KV Cache。如果系统一开始就为每条请求预留最大上下文长度对应的 KV Cache 空间,会造成大量显存或内存浪费;而采用按需分配、按页增长的方式,可以只在请求真正生成或访问到对应位置时再分配 KV Cache 页面,从而降低存储开销。

另一方面,Copy-on-Write 的思想也适用于 parallel sampling 等推理场景。多个候选序列在前缀相同或短时间内保持一致时,可以先共享同一部分 KV Cache;只有当 top-k / top-p 采样结果出现分歧、不同分支开始生成不同 token 时,才为分歧后的部分复制出私有缓存。这样可以减少重复 KV Cache 带来的显存冗余。

因此,本实验中的两个机制可以看作是 AIOS 中“按需分配显存”和“共享推理状态”的简化原型。传统操作系统管理的是用户进程的物理页,而 AIOS / LLM 推理系统中管理的则可能是 KV Cache page、显存块或推理请求状态。

更详细的讨论见:附录:Lazy Allocation 与 COW 在 LLM 推理系统中的应用