Lazy allocation (moderate)
实验目标
这是 Lab5Lazy page allocation的第二步,也是真正让"惰性分配"落地的核心关。
上一步(Eliminate allocation from sbrk)我们删掉了sbrk里的实际分配,只记账、不建映射,结果访问新地址就panic。本步的目标:在缺页发生时,由内核当场补上这块内存。
练习目标:
- 在
usertrap()里识别用户态缺页异常(scause == 13或15),为其现场分配一页物理内存并建好映射,让进程从缺页指令处无缝继续。 - 修改
uvmunmap(),让它在解除映射时遇到"未映射的页"不再 panic,而是直接跳过(否则上一步埋下的"账记了、页没给"在进程退出时仍会炸)。 - 最终让
echo hi这类简单命令跑通,理解 lazy allocation 的完整闭环:申请时只记账 → 访问时缺页 → 内核补分配 → 继续运行。
官方 lab 原文把这一步描述为:
Modify the code in
trap.cto respond to a page fault from user space by mapping a newly-allocated page of physical memory at the faulting address, and then returning back to user space to let the process continue executing. You should add your code just before theprintfcall that produced the “usertrap(): …” message.
前置知识
1. 缺页异常落在usertrap
承接上一步:进程访问sbrk新记大的、却没映射的地址 → MMU 发现 PTE 无效 → CPU 触发页错误异常,经uservec陷入内核、进入usertrap()。所以在usertrap里插桩,正是处理它的正确位置。
scause编码(xv6kernel/riscv.h):
scause | 含义 |
|---|---|
8(0x8) | 系统调用(ecall) |
13(0xd) | 读访问缺页(load page fault) |
15(0xf) | 写访问缺页(store/AMO page fault) |
r_stval()返回触发缺页的虚拟地址;r_sepc()返回缺页指令地址(处理完从这里重执行)。
2. lazy handler 的"三板斧"
为缺页地址补一页,本质是照抄uvmalloc()的核心逻辑(官方 hint 就是"Steal code from uvmalloc()"):
kalloc():从空闲链表抠出一页物理内存。memset(pa, 0, PGSIZE):必须清零。xv6 的物理页可能此前装过内核/其他进程数据,不清零会把残留内容(甚至内核机密)暴露给用户进程——这是安全与正确性的硬要求。mappages():在页表里把"缺页虚拟地址(页对齐)↔ 这块物理页"建好映射,并赋予正确权限。
3. PTE 权限位
kernel/riscv.h里定义的页表项标志位:
| 位 | 含义 |
|---|---|
PTE_V | Valid,映射有效 |
PTE_R | 可读 |
PTE_W | 可写 |
PTE_X | 可执行 |
PTE_U | 用户态可访问(U-mode 能访问) |
用户进程的堆页必须带PTE_U才能被用户态访问;R|W是读写所需。
4. 为什么必须改uvmunmap
上一步我们讲透过:panic发生在进程退出、内核uvmunmap(pagetable, 0, sz, 1)解映射时——它走到sbrk记大却从未建映射的那段,发现 PTE 无效就panic("uvmunmap: not mapped")。
现在惰性分配下,"[0, sz)里有空洞(未分配页)"是常态而非错误。所以要让uvmunmap遇到无效 PTE 时跳过而非 panic——这是本步必须的配套改动。
5. 本步改动文件
| 文件 | 改动 |
|---|---|
kernel/trap.c | usertrap()新增缺页分支(lazy handler) |
kernel/vm.c | uvmunmap()遇无效 PTE 改continue跳过 |
实现思路
整体闭环:
进程访问未映射地址 ──(MMU 缺页)──> usertrap(scause=13/15) │ ├─ 取 fault_va = r_stval() ├─ 校验合法性:fault_va 在"栈顶之上、sz 之下"(属于合法堆区) ├─ kalloc() 拿一页物理内存 ├─ memset(pa, 0, PGSIZE) 清零 ├─ mappages() 建映射(R|W|X|U) └─ usertrapret() 返回用户态,从缺页指令处重新执行 ──> 这次访问成功关键设计点:
- 插入位置:在
usertrap原有if(scause==8)(系统调用)、else if(devintr)(设备中断)之后,原else(unexpected)之前,新增else if(r_scause()==13 || r_scause()==15)分支。这样缺页会被优先识别、不再落进"unexpected"兜底。 - 合法性判断:
PGROUNDUP(p->trapframe->sp) - 1 < fault_va && fault_va < p->sz。含义:缺页地址必须高于用户栈顶(sp之上才是堆区)、且低于进程大小sz(在sbrk记账的范围内)。这一步把"合法惰性分配请求"和"真正非法的访问"区分开。 - 分配 + 清零 + 映射:三板斧一气呵成;
mappages失败(极少见,如页表满)则释放刚拿到的物理页并标记进程killed。 uvmunmap配套:两处panic改为continue,让解映射对空洞鲁棒。
注:这一步是官方定义的moderate阶段,目标是让
echo hi工作。判断条件用的是"栈顶之上、sz 之下"的简化版;更严格的边界(越界kill、栈下方 Guard 页、fork 复制、系统调用传参、OOM)在后续lazytests阶段补齐,见"复盘·前瞻"。
代码实现
kernel/trap.c——usertrap新增缺页分支
/* * kernel/trap.c */voidusertrap(void){intwhich_dev=0;if((r_sstatus()&SSTATUS_SPP)!=0)panic("usertrap: not from user mode");// send interrupts and exceptions to kerneltrap(),// since we're now in the kernel.w_stvec((uint64)kernelvec);structproc*p=myproc();// save user program counter.p->trapframe->epc=r_sepc();if(r_scause()==8){// system callif(p->killed)exit(-1);// sepc points to the ecall instruction,// but we want to return to the next instruction.p->trapframe->epc+=4;// an interrupt will change sstatus &c registers,// so don't enable until done with those registers.intr_on();syscall();}elseif((which_dev=devintr())!=0){// ok}elseif((r_scause()==13||r_scause()==15)){// 惰性分配导致的缺页异常uint64 fault_va=r_stval();// 获取引发缺页异常的虚拟地址char*pa=0;// 虚拟地址对应的物理地址(待映射)// 判断虚拟地址 fault_va 是否在进程栈空间之中if(PGROUNDUP(p->trapframe->sp)-1<fault_va&&fault_va<p->sz){// 分配一页物理内存if((pa=kalloc())==0)panic("usertrap: kalloc\n");memset(pa,0,PGSIZE);// 将 fault_va 映射到 paif(mappages(p->pagetable,PGROUNDDOWN(fault_va),PGSIZE,(uint64)pa,PTE_R|PTE_W|PTE_X|PTE_U)!=0){printf("lazy alloc: failed to map page\n");kfree(pa);p->killed=1;}}}else{printf("usertrap(): unexpected scause %p pid=%d\n",r_scause(),p->pid);printf(" sepc=%p stval=%p\n",r_sepc(),r_stval());p->killed=1;}if(p->killed)exit(-1);// give up the CPU if this is a timer interrupt.if(which_dev==2)yield();usertrapret();}逐行理解:
else if ((r_scause() == 13 || r_scause() == 15)):识别读/写缺页(lazy 触发的页面错误)。fault_va = r_stval();:拿到触发缺页的虚拟地址。PGROUNDUP(p->trapframe->sp) - 1 < fault_va && fault_va < p->sz:合法性闸门——只在"栈顶之上、进程大小sz之下"的堆区才补分配。kalloc()拿物理页;memset(pa, 0, PGSIZE)清零(安全必需);mappages(..., PTE_R|PTE_W|PTE_X|PTE_U)建映射(R/W/X/U 全给,教学代码图省事)。mappages失败则kfree(pa)回收物理页并p->killed = 1。- 注意:成功时什么也没改、也没 return——
usertrap会自然走到末尾的usertrapret(),带着新映射返回用户态;CPU 重新执行那条缺页指令,这次地址已映射,访问成功。
kernel/vm.c——uvmunmap不再为缺页 panic
/* * kernel/vm.c */voiduvmunmap(pagetable_tpagetable,uint64 va,uint64 npages,intdo_free){uint64 a;pte_t*pte;if((va%PGSIZE)!=0)panic("uvmunmap: not aligned");for(a=va;a<va+npages*PGSIZE;a+=PGSIZE){if((pte=walk(pagetable,a,0))==0)// panic("uvmunmap: walk");continue;// 惰性分配:遇到不存在的 pte 就跳过if((*pte&PTE_V)==0)// panic("uvmunmap: not mapped");continue;// 同上if(PTE_FLAGS(*pte)==PTE_V)panic("uvmunmap: not a leaf");if(do_free){uint64 pa=PTE2PA(*pte);kfree((void*)pa);}*pte=0;}}改动极小但关键:
walk()返回 0(页表中间层都还没建)或PTE_V == 0(叶子 PTE 无效)→ 原代码panic,现改为continue跳过。- 这样惰性分配留下的"空洞"在进程退出/exec 解映射时安静跳过,上一步的
panic: uvmunmap: not mapped彻底消失。 - 其余逻辑(释放物理页、
*pte = 0)不变——确实映射过的页照常回收。
验证
按官方步骤启动 xv6,执行最简单命令:
$makeqemu$echohi hi应正常打印hi而不再 panic。这说明:shell 经sbrk申请的堆(只记账),在被访问时由usertrap的 lazy handler 当场补了一页物理内存并映射,进程得以继续。
为确认"确实发生了惰性分配",可借助 Lab3pgtbl的vmprint(若已在该分支)或临时printf缺页分支,观察echo hi期间至少触发了一次(甚至两次)缺页——这正是 lazy 的实证。
提示:本步只要求
echo hi工作。更严苛的lazytests/usertests还会测越界、fork、系统调用传参等边界,当前实现在这些场景可能仍不过——它们是下一个阶段要补的,见"复盘·前瞻"。
复盘
本实验解决了什么
- lazy allocation 真正闭环了:
sbrk记账(第一步)→ 访问缺页(CPU)→usertrap补分配(本步)→ 重执行成功。三步走完,内存从"申请即分配"变成了"用到才给"。 - 亲手实现了一次缺页处理:把 Lab4 学的"异常 →
usertrap"框架,第一次用于真正修复一个问题(而非打印/报警)。scause/stval/sepc三个寄存器从概念变成了手里的工具。 uvmunmap的鲁棒化:理解了"解映射遇到空洞"是惰性分配的固有常态,配套改成跳过——这一步让整个机制自洽。
与真实操作系统(Linux)的对比
demand paging 是现代 OS 的标配,本步就是它的极简版:
- Linux 的堆(
brk/mmap)、文件映射、共享库,默认都是访问时才分配物理页(minor fault);若还需从磁盘读则是 major fault。 - Linux 的缺页处理在
do_page_fault()里,同样要区分"合法匿名页/文件页/非法访问",非法访问会发SIGSEGV杀进程——对应我们下一步要加的"越界kill"。 - xv6 这里
kalloc失败直接panic,而 Linux 会走 OOM killer 选进程牺牲——复杂度差异巨大,但骨架一致。
收获
- demand paging / lazy allocation:能讲清"为什么不在申请时分配"(快、省、支持稀疏/超额),是 OS 内存管理章节高频题。
- page fault 全流程:
scause13/15 区分读写缺页、stval给地址、sepc用于重执行;缺页不是错误而是"正常的中断信号",handler 补完映射后从原指令重试。 kalloc后必须memset 0:信息安全视角的硬要求(不清零会泄露内核/其他进程数据),也是面试加分点。uvmunmap的"空洞容忍":理解"映射空间"与"实际分配"解耦后,所有解映射/回收路径都必须对空洞鲁棒。
前瞻:lazytests 阶段还要补的边界
当前实现能跑echo hi,但官方lazytests/usertests会测更多场景,需要继续加固:
- 越界要
kill:若fault_va >= p->sz(超过sbrk分配的上界),应p->killed = 1而非默默分配——否则会无限缺页死循环(注意当前else if已匹配scause13/15,越界地址不会落进原else,会静默无操作 → 回用户态又缺页 → busy loop)。 - 栈下方 Guard 页:
fault_va落在栈正下方那页(非法)也要kill。 - fork 复制:
uvmcopy要能处理"父进程有惰性空洞"的情况(空洞子进程也记为空洞,不能试图复制不存在的页)。 - 系统调用传参:
read/write等传入sbrk地址但内存未分配时,内核copyin/copyout也要能触发分配。 - OOM:
kalloc()失败时不应panic整个内核,而应kill当前进程(用p->killed = 1)。
建议把"前置知识里的 PTE 权限表"和"实现思路里的 lazy 闭环图"存下——Lab6 的 Copy-on-Write fork 会复用同一套缺页处理框架(只不过 handler 从"分配新页"变成"复制父页"),思维模型一脉相承。