实验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()¶
函数原型:
参数:
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()¶
函数原型:
参数:
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()¶
函数原型:
参数:
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()¶
函数原型:
kalloc():
- 从
kmem.freelist取出一页物理内存。 - 成功时返回页起始物理地址,失败时返回
0。 - 当前实现会把新页填充为
5,帮助暴露未初始化使用。 - 本实验中,真正暴露给用户的 lazy 页面必须再用
memset(mem, 0, PGSIZE)清零。
kfree():
- 要求
pa页对齐。 - 要求
pa位于[end, PHYSTOP)。 - 当前实现会把释放页填充为
1,再挂回空闲链表。 - COW 实验需要让
kfree()先减少引用计数;只有引用计数变成 0 时,才能真正放回freelist。
COW 后 kfree() 不再等价于立即释放
当一个物理页被多个进程共享时,某个进程退出或解除映射只代表引用数减一。只有最后一个引用离开,页面才能进入空闲链表。
7. uvmunmap()¶
函数原型:
参数:
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()¶
函数原型:
参数:
old:父进程页表。new:子进程页表。sz:需要复制或共享的逻辑用户地址范围。
返回值:
- 成功返回
0。 - 失败返回
-1,并清理子进程已经建立的映射。
当前逻辑:
- 遍历
[0, sz)的每一页。 - 跳过不存在或无效的 PTE。
- 在非 COW 路径下,会为每个有效页分配新物理页并复制内容。
注意事项:
- 在
COW_ALLOC开启时,把 eager copy 替换为 COW 共享。 - 必须保留尚未映射的 lazy 页面:父进程通过
sbrk()预留但尚未访问的页面没有 PTE,fork()时不应分配。 - 必须正确处理只读页、可写页、已经是 COW 的页、错误回滚。
10. proc_handle_page_fault()¶
函数原型:
参数:
fault_va:来自stval的 faulting virtual address。write:是否为写 fault。
返回值:
- 成功处理返回
0。 - 无法处理返回
-1。
内部逻辑:
- 检查当前进程和页表有效。
- 将
fault_va向下对齐。 - 如果该页已经存在有效 PTE,返回失败。
- 如果启用了
lazy allocation,则优先调用user_lazy_alloc()。 - 如果不是 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()¶
函数原型:
注意事项:
- Lazy 分配出的用户页必须用
memset(mem, 0, PGSIZE)清零。 - COW fault 分配出的私有页必须用
memmove(newmem, oldmem, PGSIZE)复制旧页内容。 - 页表页分配后必须清零,否则旧数据会被解释为随机 PTE。
用户页必须 zero-fill
kalloc() 返回的页会被填充为调试字节 5。如果忘记在 lazy 分配时清零,用户程序第一次读尚未访问过的 lazy 页面会读到非零数据,copyin 相关测试也会失败。