跳转至

实验3补充材料:虚拟内存辅助函数与宏定义速查

定位

本文是 实验3 虚拟内存:Lazy Allocation 与 Copy-on-Write 的补充材料。本文包含了实验中可能用到的底层宏、页表函数、物理页分配函数和用户内存复制函数。

建议同学们把本文当作 API 速查手册:实现某条路径时,先阅读主文档中的调用链,再回到这里确认相关 helper 的参数、返回值、边界条件和常见误用。

本节列出完成实验时可能需要使用的现有工具函数和宏。它们大多不在 TODO 内,但决定了实现是否简洁、正确、能通过边界测试。

1. 页大小、地址对齐与地址转换宏

名称 位置 含义与用法
PGSIZE kernel/include/riscv.h 页大小,固定为 4096 字节。所有物理页分配和用户页映射都以它为粒度。
PGSHIFT kernel/include/riscv.h 页内偏移位数,4KB 对应12位。
PGROUNDUP(sz) kernel/include/riscv.h 将大小向上对齐到页边界。常用于计算需要释放或复制的页数。
PGROUNDDOWN(a) kernel/include/riscv.h 将地址向下对齐到所在页起始地址。Page Fault 处理中必须先对 stval 使用它。
MAXVA kernel/include/riscv.h NexOS 使用的最大虚拟地址上界。任何用户地址达到或超过 MAXVA 都非法。
TRAMPOLINE kernel/include/memlayout.h 最高用户虚拟页,映射 trampoline 代码,不允许普通用户内存覆盖。
TRAPFRAME kernel/include/memlayout.h TRAMPOLINE 下方一页,映射当前进程 trapframe,不允许 sbrk 越过。
PA2PTE(pa) kernel/include/riscv.h 把物理地址编码进 PTE 的 PPN 字段。
PTE2PA(pte) kernel/include/riscv.h 从 PTE 中取出物理页起始地址。
PTE_FLAGS(pte) kernel/include/riscv.h 取出 PTE 低 10 位 flags
PX(level, va) kernel/include/riscv.h 取出 Sv39 某一级页表索引,walk() 内部使用。

2. PTE 权限位

名称 含义 实验中的作用
PTE_V PTE 有效 无此位时,硬件访问会触发 Page Fault。
PTE_R 可读 lazy 分配出的普通用户页应具有读权限。
PTE_W 可写 lazy 分配页应可写;COW 共享页必须临时清除此位。
PTE_X 可执行 普通 heap 页不应设置;文本段通常设置。
PTE_U 用户态可访问 用户页必须设置;guard page 故意清除此位。
PTE_COW COW 标记 软件自定义位,表示“这是一个原本可写、当前只读的 COW 页面”。

3. walk()

函数原型:

pte_t *walk(pagetable_t pagetable, uint64 va, int alloc);

参数:

  • pagetable:根页表。
  • va:要查找的虚拟地址。
  • alloc:如果中间级页表不存在,是否分配新的页表页。

返回值:

  • 成功时返回最低一级 PTE 的地址。
  • 如果页表页不存在且 alloc == 0,不分配新页表页并返回 0
  • 如果 va >= MAXVA,触发 panic("walk")

内部逻辑:

  • 按 Sv39 三级页表,从 level 2 走到 level 0。
  • 中间级 PTE 有 PTE_V 时,说明它指向下一级页表。
  • 中间级 PTE 不存在且 alloc != 0 时,用 kalloc() 分配一页新的页表页,清零后接入页表树。

注意事项:

  • walk() 只找到 PTE,不保证 PTE 有效。
  • 找到 PTE 后仍需检查 *pte & PTE_V*pte & PTE_U 等权限位。
  • 不要对非法高地址调用 walk(),先判断 va < MAXVA

4. walkaddr()

函数原型:

uint64 walkaddr(pagetable_t pagetable, uint64 va);

参数:

  • pagetable:用户页表。
  • va:用户虚拟地址。

返回值:

  • 成功时返回对应物理页起始地址。
  • 失败时返回 0

内部逻辑:

  • 检查 va < MAXVA
  • 调用 walk(pagetable, va, 0) 查 PTE。
  • 要求 PTE 存在,并检查 flags,例如:PTE_V 有效、PTE_U 允许用户访问。
  • 返回 PTE2PA(*pte)

注意事项:

  • walkaddr() 只查询已经存在的映射,不会为 lazy 页面分配物理页。
  • walkaddr() 返回的是页起始的物理地址,不包含页内偏移。
  • 本实验需要通过 lazy_alloc_walkaddr() 包装它,支持尚未分配物理页的 lazy 页面。

5. mappages()

函数原型:

int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm);

参数:

  • pagetable:要修改的页表。
  • va:虚拟地址起点,必须页对齐。
  • size:映射长度,必须页对齐且非 0。
  • pa:物理地址起点。
  • perm:权限位,例如 PTE_R | PTE_W | PTE_U

返回值:

  • 成功返回 0
  • 页表页分配失败返回 -1
  • 如果发现目标 PTE 已经有效,会 panic("mappages: remap")

内部逻辑:

  • [va, va + size) 每一页调用 walk(..., alloc=1)
  • 通过 PA2PTE(pa) | perm | PTE_V 拼接出 PTE。
  • 每页推进 PGSIZE

注意事项:

  • 映射前必须确认目标页不是已经映射的页。
  • 失败时,调用者负责释放已经分配但尚未映射或部分映射的资源。
  • COW fork 中映射子进程时,pa 可以是父进程已有的物理页。

6. kalloc()kfree()

函数原型:

void *kalloc(void);
void kfree(void *pa);

kalloc()

  • kmem.freelist 取出一页物理内存。
  • 成功时返回页起始物理地址,失败时返回 0
  • 当前实现会把新页填充为 5,帮助暴露未初始化使用。
  • 本实验中,真正暴露给用户的 lazy 页面必须再用 memset(mem, 0, PGSIZE) 清零。

kfree()

  • 要求 pa 页对齐。
  • 要求 pa 位于 [end, PHYSTOP)
  • 当前实现会把释放页填充为 1,再挂回空闲链表。
  • COW 实验需要让 kfree() 先减少引用计数;只有引用计数变成 0 时,才能真正放回 freelist

COW 后 kfree() 不再等价于立即释放

当一个物理页被多个进程共享时,某个进程退出或解除映射只代表引用数减一。只有最后一个引用离开,页面才能进入空闲链表。

7. uvmunmap()

函数原型:

void uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free);

参数:

  • pagetable:目标页表。
  • va:起始虚拟地址,必须页对齐。
  • npages:要解除映射的页数。
  • do_free:是否释放叶子 PTE 指向的物理页。

返回值:

  • 无返回值。
  • 对未映射页或无效 PTE,当前实现会直接跳过。

内部逻辑:

  • 遍历每一页,调用 walk(..., alloc=0)
  • 如果 PTE 无效,跳过。
  • 如果 do_free != 0,取 PTE2PA(*pte) 并调用 kfree()
  • 最后清空 PTE。

注意事项:

  • 它已经能跳过尚未映射的页面:没有有效 PTE 的页面不会被强行释放。
  • COW 实验中,do_free 调用的 kfree() 应只减少引用计数,不能错误释放共享页。
  • uvmunmap() 解除的是页级映射,不理解 p->sz 的字节级边界。

8. uvmalloc()uvmdealloc()

函数原型:

uint64 uvmalloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz, int xperm);
uint64 uvmdealloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz);

uvmalloc()

  • eager 分配 [oldsz, newsz) 范围中的用户页。
  • 每页调用 kalloc()、清零、mappages()
  • 权限为 PTE_R | PTE_U | xperm
  • 成功返回 newsz,失败返回 0 并回滚已分配页。

uvmdealloc()

  • 释放 [newsz, oldsz) 中跨过页边界的页面。
  • 内部调用 uvmunmap(..., do_free=1)
  • 返回 newsz

注意事项:

  • Lazy 正增长不能调用 uvmalloc(),否则会退化成 eager allocation。
  • Lazy 负增长不应该“延迟释放”,必须立即释放已经映射的物理页,并清理逻辑大小。
  • uvmdealloc() 能安全跳过尚未映射的 lazy 页面,因为底层 uvmunmap() 会跳过无效 PTE。

9. uvmcopy()

函数原型:

int uvmcopy(pagetable_t old, pagetable_t new, uint64 sz);

参数:

  • old:父进程页表。
  • new:子进程页表。
  • sz:需要复制或共享的逻辑用户地址范围。

返回值:

  • 成功返回 0
  • 失败返回 -1,并清理子进程已经建立的映射。

当前逻辑:

  • 遍历 [0, sz) 的每一页。
  • 跳过不存在或无效的 PTE。
  • 在非 COW 路径下,会为每个有效页分配新物理页并复制内容。

注意事项:

  • COW_ALLOC 开启时,把 eager copy 替换为 COW 共享。
  • 必须保留尚未映射的 lazy 页面:父进程通过 sbrk() 预留但尚未访问的页面没有 PTE,fork() 时不应分配。
  • 必须正确处理只读页、可写页、已经是 COW 的页、错误回滚。

10. proc_handle_page_fault()

函数原型:

int proc_handle_page_fault(uint64 fault_va, int write);

参数:

  • fault_va:来自 stval 的 faulting virtual address。
  • write:是否为写 fault。

返回值:

  • 成功处理返回 0
  • 无法处理返回 -1

内部逻辑:

  1. 检查当前进程和页表有效。
  2. fault_va 向下对齐。
  3. 如果该页已经存在有效 PTE,返回失败。
  4. 如果启用了 lazy allocation,则优先调用 user_lazy_alloc()
  5. 如果不是 lazy fault,再尝试查找 vma 并处理 mmap fault。

注意事项:

  • COW fault 不应该直接交给它,因为 COW 页已有有效 PTE,只是缺少写权限。
  • 它会拒绝已经有效的 PTE,因此能防止把文本段写 fault 错误当成 lazy fault。

11. copyin()copyout()copyinstr()

函数原型:

int copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len);
int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len);
int copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max);

作用:

  • copyin():从用户地址复制普通字节到内核地址。
  • copyout():从内核地址复制普通字节到用户地址。
  • copyinstr():从用户地址复制以 \0 结尾的字符串到内核地址。

内部逻辑:

  • 每轮处理一个页内片段。
  • PGROUNDDOWN() 得到页起始虚拟地址。
  • lazy_alloc_walkaddr() 得到物理页地址。
  • 再加上页内偏移执行 memmove()

12. memset()memmove()

函数原型:

void *memset(void *dst, int c, uint n);
void *memmove(void *dst, const void *src, uint n);

注意事项:

  • Lazy 分配出的用户页必须用 memset(mem, 0, PGSIZE) 清零。
  • COW fault 分配出的私有页必须用 memmove(newmem, oldmem, PGSIZE) 复制旧页内容。
  • 页表页分配后必须清零,否则旧数据会被解释为随机 PTE。

用户页必须 zero-fill

kalloc() 返回的页会被填充为调试字节 5。如果忘记在 lazy 分配时清零,用户程序第一次读尚未访问过的 lazy 页面会读到非零数据,copyin 相关测试也会失败。