尧图精选

MIT6.S081 Lab5惰性分配实践:解析虚拟内存与缺页异常

🕒 发布时间:2026/9/28 8:55:01 📁 来源:尧图网络
写这篇总结的时候我刚刚跑完MIT6.S081的Lab 5惰性分配所有测试。这个实验做下来最大的感受是一旦理解了“分配”和“访问”可以分离整个虚拟内存的运作方式在脑子里就彻底串起来了。MIT6.S081的lazy allocation实验要求改的代码量不大但每一个改动的背后都牵动着一整套用户页表、缺页异常和进程生命周期机制。这篇记录我尽量把思路拆细把我在实际调试中踩过的坑也一并放进来给正在做这个实验的同学一个参考。1. 惰性分配到底在解决什么问题1.1 一次malloc背后xv6原本做了多少多余的事在动手改代码之前我们需要先看清楚原版xv6在响应一个内存分配请求时实际发生了什么。用户态malloc底层调用的是sbrk系统调用xv6中的sys_sbrk逻辑非常简单拿到用户传入的字节数n把进程的p-sz边界往后推n个字节然后调用uvmalloc把这段虚拟地址区间逐一映射到物理内存。uvmalloc内部的工作量其实不小。它从旧边界开始按页PGSIZE通常是4096字节逐个处理每一页都要调用kalloc分配物理页然后用memset把整页清零最后通过mappages在进程页表中建立虚拟页到物理页的映射。也就是说进程申请多大空间xv6当场就实打实分配多少物理内存并把映射关系立刻写进页表。问题在于进程申请了内存不代表立刻会用。我用一个生活化的类比这相当于你提前给一整个宴会厅的客人备菜每桌都上齐冷盘热菜结果客人们只点了两桌剩下几十桌的菜只能倒掉。在真实程序中这种一次性分配又立即释放的情况比比皆是尤其是那些启动时预留大缓冲区的应用。每次sbrk都做完整的分配、清零、映射不仅占用物理内存还白花CPU时间。1.2 缺页异常才是惰性分配的核心赌注惰性分配lazy allocation的思路恰恰相反当你申请内存时内核只在账本上记一笔“这个进程的地址空间变大了”真正对应的物理页先不给页表里这些虚拟地址对应的PTE页表项保持无效状态。直到进程真的去读写某个地址CPU访问到一个PTE_V标志为0的页表项时硬件MMU会抛出一个缺页异常打断当前用户进程的执行把控制权交给内核的trap处理流程。内核这时候才姗姗来迟地分配物理页、建立映射然后让进程从头执行那条出错的指令。这套机制的底层依赖是硬件分页和异常处理链路。xv6运行在RISC-V架构上当页表项无效或权限不匹配时CPU会把异常原因写入scause寄存器把出错的虚拟地址写入stval寄存器然后跳转到内核的trampoline代码最终进入trap.c中的usertrap函数。在usertrap里你就能拿到这两个关键信息一个告诉你这次异常是不是page fault另一个告诉你是哪个地址访问出问题了。所以惰性分配本质上是拿时间和空间做了一个交换代价是多了一次缺页异常的内核陷入开销收益是不再为用不到的内存浪费物理页。更妙的是这个思路并不仅限于Lab 5之后做mmap实验、阅读Linux内核中demand paging的实现时你会发现它们用的都是同一套“推迟到首次访问”的思想。理解了这里的道理后面很多实验都会豁然开朗。2. 原版xv6的内存分配流程以及该从哪里动手2.1 从sys_sbrk到uvmalloc一次完整的“立即付款”走读先看原版代码。在kernel/sysproc.c中xv6的sbrk实现大致是这样uint64 sys_sbrk(void) { int addr; int n; struct proc *p myproc(); if(argint(0, n) 0) return -1; addr p-sz; if(uvmalloc(p-pagetable, p-sz, p-sz n) 0) return -1; p-sz n; return addr; }流程拆开看argint从用户寄存器里取出参数naddr记录当前地址空间上界uvmalloc把从旧sz到新sz的这段区间在页表里做实映射最后把p-sz加上n并返回旧的上界。uvmalloc内部则是典型的“逐步分配”循环。它从旧地址边界开始只要还有空间要覆盖就kalloc拿一页物理内存memset清零然后mappages建立映射再看看下一块。如果中途物理内存耗尽kalloc返回0uvmalloc返回0这时sys_sbrk返回-1用户态malloc会得到NULL表示分配失败。这里有个容易被忽视的细节p-sz只是进程虚拟地址空间的逻辑边界它不代表物理内存的使用量。原版xv6在sbrk返回之前就已经把物理页消耗掉了所以p-sz的增长几乎总是伴随着物理内存的同步消耗。惰性分配要做的就是打破这个“同步”关系。2.2 为什么只能在系统调用层动手而不是改用户态malloc有人可能会问惰性分配是不是可以在用户态malloc库里做比如malloc时只记录大小等用户访问时再触发什么。答案是行不通的。页表的建立、物理内存的分配是内核特权级操作用户态程序没有权限修改自己的页表也没有能力处理缺页异常。用户态能做的最多只是在malloc时减少实际物理内存的预分配行为但一旦真正的访问发生了如果对应的页表项不可用CPU会立刻陷入内核用户态代码没有机会兜底。所以正确的做法是保持sbrk系统调用的语义不变——它仍然向用户返回新地址空间的起始地址但内核内部把“建立映射”这个动作推迟到缺页异常发生的那一瞬间。用户进程完全感知不到这个过程它只会觉得malloc变快了而首次访问那部分内存时会有一点点额外的延迟。这正是操作系统对应用“抠门”但又不破坏应用预期的典型做法。2.3 这次实验需要动刀的四个地方Lab 5的改动按重要性排序大致是这样的第一修改sys_sbrk不再调用uvmalloc做实际映射只调整p-sz记录边界。这是惰性分配最核心的一处改动也是整个实验的入口。第二在trap.c中处理缺页异常识别RISC-V的page fault类型scause为13的读异常或15的写异常根据stval拿到的出错虚拟地址判断这个地址是否合法如果合法就当场分配一页物理内存并建立映射。第三修改vm.c中的uvmunmap或freewalk相关逻辑因为页表里现在存在大量PTE_V为0但尚未分配的地址区间原版代码在释放页表时会因为遇到无效PTE而直接panic。第四视情况调整copyin/copyout等内核访问用户内存的路径系统调用把用户传进来的缓冲区指针当作已映射内存使用时碰上一个尚未分配的惰性页就会失败必要时需要进行相同的按需分配处理。别看只做了这么几件事每一个环节都有不少边界条件要处理。接下来我按实际代码改动的顺序把关键实现拆开讲。3. 核心代码改动拆解从syscall到缺页处理3.1 修改sys_sbrk只记账不发货第一刀切在sys_sbrk。改动后的核心逻辑uint64 sys_sbrk(void) { int n; struct proc *p myproc(); if(argint(0, n) 0) return -1; uint64 oldsz p-sz; if(n 0) { // 地址空间收缩释放真正已经映射的部分 p-sz uvmdealloc(p-pagetable, oldsz, oldsz n); } else { // 地址空间扩大什么也不做只是记账 p-sz n; } return oldsz; }理解这个改动有一个关键点当n大于0时我们只是增加了p-sz没有在页表里添加任何映射。用户的地址空间确实“变大”了页表项却仍然是空的。当进程后续访问这个区间中的某个地址时CPU会发现在对应的页表项上PTE_V为0于是乖乖触发缺页异常把麻烦抛给内核。当n小于0时情况稍微复杂一些。因为惰性分配下p-sz记录的地址区间里有的页已经被真正映射过因为进程访问过有的页还只是“纸上谈兵”。uvmdealloc的作用是释放从oldsz到新边界之间那些真正有映射的页对无效PTE则跳过。这同样依赖vm.c中修改过的uvmunmap能够容忍无效PTE。这里有个容易忽略的返回语义sbrk系统调用需要返回“改变前”的旧地址边界而不是改变后的新边界。用户态malloc会基于这个返回值计算新分配区域的起始地址如果这里写错了后面所有指针运算都会乱掉。还需要注意一个极端情况如果收缩量过大oldsz n可能变成一个非常小甚至为负的数。在无符号数运算下这种下溢会带来灾难实际做这个实验时建议在sbrk入口就检查一下n的合法性必要时返回-1。3.2 缺页异常入口trap.c里认领13和15xv6在trap.c的usertrap函数里统一处理用户态异常。缺页异常发生时RISC-V的scause寄存器会给出具体的异常原因。我们需要关心三个值13对应load page fault读15对应store page fault写还有一个12对应instruction page fault取指。基本的实验要求里一般覆盖13和15但实际测试中如果程序跳转到未映射的指令页也会触发page fault建议一并处理。在usertrap里加上这样一段逻辑if(r_scause() 13 || r_scause() 15 || r_scause() 12){ uint64 va r_stval(); if(va p-sz || va MAXVA || (va 0xfff) 0x???...){ p-killed 1; } else { if(lazy_alloc(va) 0){ p-killed 1; } } }这里有一个获取地址的细节用户出错访问的是虚拟地址它由stval寄存器保存。注意stval里存的可能不是页对齐的地址而是引发异常的那个精确虚拟地址比如堆区中间第5个字节。我们在后续分配时要先把地址向下对齐到页边界也就是PGROUNDDOWN(va)再以页为单位进行映射。另一个关键点是地址合法性判断的顺序。一般建议先判断va是否大于或等于p-sz也就是查询这个地址是否超出了当前进程的堆区上界。如果是直接把进程标记为killed不允许它随便访问记录之外的地址。然后再判断va是否超过MAXVAxv6中用户地址空间的最大值防止进程把地址弄到内核保留区域。最后还需要检查是不是访问了一个PTE已经有效、本来不该缺页的地址——如果PTE_V已经置位还触发page fault意味着要么是权限异常要么是内核某个地方有问题这种情况也应当kill进程而不是傻乎乎地再映射一遍。3.3 缺页处理核心lazy_alloc函数我把分配逻辑单独封装成一个函数方便在trap里调用也让代码结构更清晰。核心实现如下int lazy_alloc(uint64 va) { struct proc *p myproc(); uint64 pa; // 地址必须落在当前进程堆区边界之内 if(va p-sz) return -1; // 不能访问到 tample/trapframe 所在的内核保留页 if(PGROUNDDOWN(va) TRAPFRAME) return -1; va PGROUNDDOWN(va); // 如果这一页已经映射说明不是真正的惰性缺页属于异常 pte_t *pte walk(p-pagetable, va, 0); if(pte 0 || (*pte PTE_V) 0){ // 正常缺页 } else { return -1; } pa (uint64) kalloc(); if(pa 0) return -1; // 物理内存耗尽 memset((void *)pa, 0, PGSIZE); if(mappages(p-pagetable, va, PGSIZE, pa, PTE_R | PTE_W | PTE_X | PTE_U) ! 0){ kfree((void *)pa); return -1; } return 0; }这段代码里有几个点值得单独强调。第一物理地址获取后需要清零。原版uvmalloc之所以打磨memset是因为用户看到的新内存区域必须像“新买的白纸”一样干净不能残留其他进程的敏感数据。惰性分配推迟了映射但不能推迟清零这一步否则就是严重的安全漏洞。第二mappages的权限位不能漏。用户页必须包含PTE_U标志否则用户态代码一访问就产生权限异常再次陷入内核表现为诡异的双重page fault。我一开始实现时写漏了PTE_U结果usertests跑得七零八落最后逐行比对权限位才找到问题。第三所有的失败路径都要返回错误码让上层把进程标记为killed。惰性分配有一个天然的风险当多个未映射页同时被touch物理内存可能瞬间被耗尽。实验预期是遇到这种情况就杀进程而不是像某些操作系统那样尝试换出页面。xv6没有完整的swap机制所以暴力处理是最稳妥的选择。第四关于栈区的问题。xv6的栈是预映射的初始栈页在进程创建时就已经分配好栈向下增长到未映射区域时同样会触发缺页。如果你希望栈也能惰性扩展需要在lazy_alloc里区分“堆区缺页”和“栈缺页”。但在本次实验中xv6并没有为栈提供自动扩展能力原版进程的栈空间是固定的所以最简实现就是只认p-sz以内的地址栈溢出直接kill。这一条我在第4节还会细说。3.4 配套修改uvmunmap必须容忍无效PTE做完sys_sbrk和trap处理后一个绕不开的问题马上会出现当进程退出内核需要释放页表遍历到那些“只记账、没映射”的地址时原版uvmunmap一看到PTE_V为0就会panic导致整个内核崩溃。在kernel/vm.c中uvmunmap里有这样的逻辑if((*pte PTE_V) 0) panic(uvmunmap: not mapped);在惰性分配环境下这种panic是必然会发生的。因为你扩大了p-sz但页表里有一段区间根本没有有效PTE。释放页表时必须把这些无效项当作“本来就不存在”来跳过而不能视为内核bug。这里有一个比较经典的迷宫很多人只改了uvmunmap发现freewalk还是会panic于是继续改freewalk。实际上这两处的核心逻辑是一致的遇到PTE_V为0的项就continue而不是panic。修改成类似这样if((*pte PTE_V) 0){ continue; }但要注意一点uvmunmap里除了检查有效位还会在最后对叶子PTE执行kfree释放物理页。继续跳过无效PTE是正确的但如果某个PTE有效且指向物理页仍然需要正常释放。这个修改不能矫枉过正把整个循环改成“见什么都continue”那样会造成物理页泄漏。改动时最好一行一行确认逻辑。3.5 copyout/copyin的隐藏要求当你完成了上面的改动后你可能会惊喜地发现lazytests基本通过了但等到跑一个简单的write系统调用时程序突然报错了。原因是用户进程通过write(fd, buf, len)把buf指针传给内核内核的copyout函数需要把用户缓冲区的内容拷贝到内核缓冲区这个过程会通过walkaddr遍历用户页表查找buf对应的物理地址。如果buf落在惰性分配的区域里页表里根本没有有效PTEwalkaddr返回0于是write直接失败。xv6的惰性实验对这一点是否强制要求不同年份的版本要求不太一样。但从工程完整性角度我建议在copyin和copyout中类似地加一段“遇到用户地址未映射时尝试按需分配”的处理逻辑。逻辑不复杂判断地址在p-sz以内且页表项无效就调用lazy_alloc分配一页然后继续。这样既能保证系统调用的可靠性也更贴近真实操作系统在copy_to_user里的行为。4. 边界条件清单与实战问题速查4.1 必须检查的边界条件惰性分配的实现难点不在主流程而在边界条件的判定。我根据实际测试数据整理了下面这张表每一条都是实验中会直接踩中的雷区情况预期行为原因va p-szkill进程超出了进程分配的堆区边界不合法访问va MAXVAkill进程进入内核地址空间保留区域PTE_V已经置位但仍缺页kill进程可能是权限错误或页表本身不一致kalloc返回0kill进程物理内存耗尽且xv6无swap机制va恰好不按页对齐向下对齐到PGROUNDDOWN(va)再分配缺页异常给出的地址是精确访问地址PGROUNDDOWN(va)落在TRAPFRAME之上拒绝映射并kill启动时trampoline/trapframe映射不能覆盖sbrk收缩到负地址检查后返回-1无符号下溢会导致p-sz失控值得注意的是越早返回错误、越早kill越容易排查问题。惰性分配的陷阱在于它会把一个本来应该在系统调用时报错的非法访问推迟到几毫秒甚至几秒之后的某次缺页异常。到那时候栈上的调用信息早就变了想回溯到底是谁触发了非法访问会变得非常困难。所以处理缺页时宁可保守多杀进程也不要轻易放行可疑地址。4.2 我踩过的坑栈缺页的误判我在第一次实现lazy_alloc时犯了一个典型的错误我自作聪明地在trap处理里加了一段“如果va接近用户栈顶就按栈扩展处理”理由是真实操作系统都会动态扩展栈。结果usertests跑得极其不稳定一会儿栈溢出、一会儿地址越界花了整整一个下午调试。后来我回头读xv6的进程初始化代码才意识到xv6的用户栈从一开始就是固定大小映射的第一个用户进程在exec时会分配固定的栈页而且栈区上方紧挨着TRAPFRAME下方没有额外的自动增长机制。既然栈不会扩展那么任何落在p-sz之外的缺页地址本质上都应该是非法地址。我之前“好心”为栈扩展加的逻辑反而让内核把本来该杀掉的进程给放行了导致后续访问到了不该映射的区域。这个教训很直接做实验前先搞清楚设计前提再决定要不要画蛇添足。xv6就是一个教学内核很多机制做了极简处理不要拿真实Linux的行为直接往它身上套。4.3 常见问题速查表我把实验过程中遇到的高频问题整理成一张速查表方便大家在调试时对照现象可能原因解决办法运行usertests报“panic: uvmunmap: not mapped”uvmunmap遇到惰性未映射页仍然panic修改uvmunmap对PTE_V为0的项continuefreewalk再次panicuvmunmap修完但freewalk对无效PTE处理不完整同样让freewalk跳过无效PTE大量物理内存耗尽进程被无条件杀掉kalloc失败且没有处理信号确认kalloc失败路径返回错误码并kill进程用户访问刚分配的内存仍然缺页PTE_USER标志漏加检查mappages的perm参数是否包含PTE_U系统调用write传入未touch缓冲区失败copyout无法解析惰性页在copyin/copyout里补充按需分配逻辑程序跑完后清理卡死sbrk收缩或exit释放逻辑不一致检查uvmdealloc/freewalk对无效PTE的容忍度4.4 测试心得写完代码后跑测试也有讲究。除了官方提供的lazytests和usertests我建议自己写一个很小的程序验证核心语义先malloc一个大块内存用clock_gettime或简单计时观察malloc几乎瞬间返回然后逐页touch那块内存同时观察内存分配的效果最后故意写一个超出p-sz的地址确认进程被kill而不是让内核panic。有一点我特别想提醒不要在一个巨型测试里反复死循环xv6本身没有特别强大的调试器panic信息非常朴素。我第一次遇到freewalk崩溃时花了很久才意识到问题出在uvmunmap的panic上因为报错信息只显示了函数名没有任何栈回溯。遇到panic时沉住气先把panic的函数打开看一遍通常就能定位问题。整个实验做下来我最大的体会是惰性分配一点都不“懒”它实际上是操作系统的精打细算——把真正昂贵的物理页分配动作延迟到不得不做的那一刻。真正动手改代码后你会发现难的不是那几十行改动而是搞清楚每一个边界条件背后的设计意图。如果你正在做这个实验建议先把原版的sbrk、uvmalloc、trap流程完整读通再动手写代码不要一上来就照着网上的补丁抄。把“分配时机”这个核心概念想透之后Lab 5就只是水到渠成的事了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →