以前我对操作系统的内存管理一直停留在“malloc一调用,背后肯定默默给你分配了一大块物理内存”这种认知。直到做完MIT6.S081的Lab4(惰性分配),我才发现自己错得离谱:真正的操作系统根本没那么“勤快”,你申请地址空间,它只是先记一笔账,等你真去读写那一页时,才临时把物理页塞给你。
这篇记录是我MIT6.S081学习系列的第五篇,专门讲xv6实验里的lazy allocation(惰性分配)。我尽量把原理、代码改动、调试过程和踩坑经历都写清楚,适合正在做Lab4但被缺页异常和守护页搞到一头雾水的同学,也适合想了解现代操作系统为何普遍采用惰性内存分配策略的读者。如果你只想要现成答案,下文也有可直接抄的代码;如果你想弄明白“为什么这么改”,那就更要多看几遍调试实录。
1. 惰性分配到底想解决什么问题
1.1 传统sbrk的“即时分配”行为
xv6里用户程序扩展堆内存,靠的是sys_sbrk这个系统调用。默认实现是收到一个n字节的请求后,立刻把地址空间扩大,并且伴随uvmalloc逐页分配物理内存、建立页表映射。也就是说,sbrk(10000)一返回,你要的那一万字节已经被真实物理页占住了,哪怕你永远不会写它们。
问题就在这。很多程序会一次性申请很大一块地址空间,但实际只触碰其中一小部分。比如:
- 读取一个大文件时,先根据文件大小预留整个缓冲区;
- 动态数组扩容时,习惯性翻倍预留空间;
- 某些程序干脆预留了几乎不可能全部用到的栈空间或稀疏数组。
如果操作系统在每次malloc背后都老老实实分配物理页,轻则浪费物理内存,重则拖慢系统。因为uvmalloc要遍历每个页、kalloc分配内存、再逐页填写页表项(PTE),一个sbrk系统调用里可能做了成千上万次操作。
1.2 惰性分配的核心思想
惰性分配的原则很简单:延迟到最后一刻。sbrk只修改进程地址空间的上界(p->sz),不分配任何物理页,也不建立页表映射。之后CPU访问这一范围内的空地址时,MMU找不到对应的PTE,就会触发缺页异常;内核在缺页异常处理里发现这个地址“合法”,此时才kalloc一页物理内存,把PTE补上。
这样一改,系统调用本身变成O(1)操作,物理内存也只在实际使用的时候才消耗。整个分配粒度也从“一次申请多少就给多少”变成“一页一页按需供给”。
为了方便对比,我把两种方式的关键差异整理成了一张表:
| 对比维度 | 立即分配 | 惰性分配 |
|---|---|---|
| sbrk系统调用开销 | 与分配字节数成正比 | 固定,基本忽略不计 |
| 物理内存占用 | 分配时立即占用 | 实际访问才占用 |
| 地址空间越界行为 | 分配时返回错误 | 运行时触发缺页,需额外判断 |
| 对物理内存碎片的影响 | 较高 | 较低 |
| 实现复杂度 | 简单 | 需处理缺页异常和边界情况 |
从工程角度看,惰性分配不是“省了系统调用的时间”这么简单,它还为更高级的内存特性铺路。Linux里malloc大块内存后没有立即触碰,物理内存迟迟不分配,就是这个机制在起作用;fork的写时复制、mmap文件映射也大量依赖延迟分配的思想。xv6这个实验虽然只是最小实现,但它把这条链路上的核心环节全部暴露出来了,所以非常值得亲手做完。
2. 动手之前必须搞清的三个底层概念
2.1 虚拟地址、页表与PTE的组成
在xv6中,每个用户进程都有一个独立页表。页表由多级页表组成(RISC-V版是三级页表),最后一级的页表项(PTE)里包含了关键的标志位:
PTE_V:该页是否有效,也就是是否已经映射了物理页;PTE_R、PTE_W、PTE_X:可读、可写、可执行;PTE_U:用户态可访问;- PPN字段:物理页号,指向实际的物理页。
当PTE_V为0时,CPU访问这个虚拟地址就会触发缺页异常。惰性分配的核心操作之一,就是让sbrk不再为新增的地址建立PTE,而只记住“这个范围属于进程”,等缺页时再补建。
xv6的地址空间布局也很关键:开头是代码段,往上依次是数据段、堆,栈放在用户地址空间的最高处,栈下方有一个独立的守护页(guard page)。进程地址空间上界记录在p->sz中,它代表“合法虚拟地址的最大值”。任何大于等于p->sz的用户地址,在正常堆操作下都被视为非法,但栈地址是特例,它从高处往低处增长,实验中通常也需要单独考虑(xv6的trapframe已经映射了其他细节)。
2.2 从虚拟地址到物理地址的缺失路径
CPU执行指令访问某个虚拟地址时,会先查页表。页表命中就拿到物理地址,直接访问;没命中且PTE_V=0,就会触发缺页异常,陷入内核,进入usertrap。在xv6的trap.c里,用户态缺页异常会通过scause寄存器判断异常原因。RISC-V中:
scause=12:指令页错误;scause=13:读页错误;scause=15:写页错误。
如何知道“缺在了哪个虚拟地址”?答案是读stval寄存器,它保存触发页错误的虚拟地址。所以惰性分配实验的常规做法,就是在usertrap里识别scause是否为13或15,再读stval拿到故障地址,判断是否需要补页。
2.3 用户态与内核态的转换
xv6每个用户进程都保存了一个trapframe,用于陷入内核时保存用户寄存器状态。usertrap处理完缺页异常后,会返回用户态继续执行之前那条发生缺页的指令。这里有个容易忽略的细节:缺页异常处理完成后,CPU会重新执行触发异常的指令。如果我们在缺页处理里正确建立了映射,被中断的指令就会顺利完成;如果处理失败,必须杀掉进程,否则还会再次进入缺页循环。
如果对这几个概念还不太熟,我建议先动手读一读xv6源码里的vm.c、trap.c和proc.c,不要急着改代码。我在做实验时最大的感受是:只要理解了“页表只是地址映射,不保证页面一定存在”这一点,整个惰性分配的实现逻辑就清晰了大半。
3. 核心实现:只改两处代码,让sbrk“只记账不付款”
3.1 修改sys_sbrk,去掉物理页分配
xv6中系统调用的入口在kernel/sysproc.c,本来长这样:
uint64 sys_sbrk(void) { int addr; int n; struct proc *p = myproc(); if(argint(0, &n) < 0) return -1; addr = p->sz; if(growproc(n) < 0) return -1; return addr; }而growproc内部会根据n的正负调用uvmalloc或uvmdealloc,完成实际物理内存分配。惰性分配的思路是绕过growproc,直接修改p->sz,不分配物理页:
uint64 sys_sbrk(void) { int addr; int n; struct proc *p = myproc(); if(argint(0, &n) < 0) return -1; addr = p->sz; p->sz += n; return addr; }注意原来的growproc还做了负数判断和地址溢出检查,这里为了简洁先不管,实验测试也一般不会传负数。但我建议至少判断一下n为负时不能让p->sz变成负数,否则后面访问任何地址都会被认为是合法范围,隐患很大。
就这么几行改动,sys_sbrk立刻变得“飞快”。用户程序申请1GB内存也只是一次寄存器操作,物理内存一点不动。
3.2 在usertrap中处理缺页异常
接下来是重头戏:在kernel/trap.c的usertrap中,识别用户态缺页异常并补页。
先读异常原因和故障地址:
uint64 scause = r_scause(); uint64 stval = r_stval();然后判断:
if((scause == 13 || scause == 15) && stval < p->sz) { // 合法范围内,按需分配一页 uint64 va = PGROUNDDOWN(stval); char *mem = kalloc(); if(mem == 0) { p->killed = 1; } else { memset(mem, 0, PGSIZE); if(mappages(p->pagetable, va, PGSIZE, (uint64)mem, PTE_W|PTE_X|PTE_R|PTE_U) != 0) { kfree(mem); p->killed = 1; } } }这里几个细节需要解释:
scause等于13或15,分别对应读、写缺页,实验要求一般只处理这两种;stval < p->sz判断故障地址是否在进程合法地址空间内。惰性分配允许的范围就是“已经被sbrk扩展但尚未建立映射”的地址段;PGROUNDDOWN(stval)把故障地址向下取整到页边界。缺页发生在某个页内的任意位置,我们一次性补一整页;kalloc()分配物理页,memset清零,然后用mappages建立PTE并设置标志位:可读可写可执行,对应普通数据页;- 如果
mappages失败,说明这个虚拟地址本身有其他映射冲突,必须释放物理页并杀掉进程。
这段代码放的位置也有讲究,一般放在usertrap中处理其他例外之前。xv6默认的处理流程是:如果scause是8(系统调用),走系统调用处理;否则如果进程被杀或被信号标记,就退出。缺页异常就走新增的这个分支。
3.3 为什么不直接调用uvmalloc
可能有同学会想:既然要建映射,为什么不直接调用现成的uvmalloc?我给个理由:uvmalloc是从oldsz到newsz按范围循环建映射,它内部会自己处理从旧大小开始的每次分配,而且它的设计默认“旧范围已全映射”,在惰性分配下,旧范围可能根本没映射,用它反而会把不该映射的页也补上。另外,缺页异常是“一页一页”发生的,按故障地址精确补一页即可,用uvmalloc会造成多余的分配和页表遍历。
我最初偷懒直接调uvmalloc(p->pagetable, PGROUNDDOWN(stval), PGROUNDDOWN(stval)+PGSIZE),结果出现了双重映射和旧边界处理混乱,最后还是老老实实按单页分配。做实验,别怕多写几行代码。
4. 调试实录:三连panic背后的真正原因
4.1 panic: uvmunmap: not mapped
改完代码,第一个跑的就是课程提供的lazytests或简单sbrk测试。结果程序一启动,内核直接panic:
panic: uvmunmap: not mapped这个panic来自uvmunmap。它通常在进程退出、回收地址空间时被调用,逐页遍历页表并释放映射。原版的uvmunmap要求所有PTE都是有效的,一旦遇到PTE_V=0的页就会panic。
而惰性分配恰恰打破了这个假设:sbrk扩展了p->sz,但那些新地址范围根本没有PTE,退出进程时,uvmunmap从0到p->sz遍历,自然撞上大量无效PTE。
解决方法是修改uvmunmap,遇到无效PTE直接跳过,而不是panic:
if((pte = walk(pagetable, a, 0)) == 0) panic("uvmunmap: walk"); if((*pte & PTE_V) == 0) continue; // 跳过未映射页,而不是panic这里面的哲学是:惰性分配让“地址空间有效”和“页表已映射”不再是同一件事。回收地址空间时,只回收真正建立映射的页就行,没映射的地址直接跳过。
4.2 panic: uvmcopy: page not present
修复第一个panic后,接着遇到第二个,这次发生在fork时:
panic: uvmcopy: page not presentfork会调用uvmcopy,把父进程的用户页表完整复制一份给子进程。原版uvmcopy也假设源页表全部有效,逐页复制PTE、分配新的物理页。现在父进程存在一个“已扩展但未映射”的地址段,uvmcopy遍历到这些地址时,同样看到PTE_V=0,于是panic。
修复方式与uvmunmap一致:遇到无效PTE就跳过。
if((pte = walk(oldpagetable, i, 0)) == 0) panic("uvmcopy: pte should exist"); if((*pte & PTE_V) == 0) continue;这里值得多思考几秒:跳过未映射页是否会让子进程出错?不会。惰性分配的子进程同样遵循“缺页时再补”的约定,子进程访问那些地址时,会用自己的usertrap补页,所以跳过是完全合理的。这种分布式的“按需补页”机制,正是惰性分配能够自然推广到fork的基础。
4.3 第三个坑:地址范围判断过松
两个panic修完后,基础测试能跑了,但我手工测试时又碰到一个新问题。我给一段超过p->sz的地址赋值,程序居然也“正常”分配了页并返回,完全不像一个非法访问该有的样子。
排查后发现,问题出在缺页处理的分支顺序上。我把缺页异常处理逻辑放在usertrap的入口,却没有在最终执行“杀进程”逻辑时正确标记错误。更隐蔽的是,有些情况下scause不是13或15,被默认分支杀掉是正常的;但如果是合法的15,stval却大于等于p->sz,就说明程序越界访问了,这时必须设置p->killed = 1杀掉进程,而不是分配内存。
一个更完整的判断应该长这样:
if(scause == 13 || scause == 15) { if(stval < p->sz) { // 合法惰性范围,补页 } else if((p->trapframe->sp - stval) < PGSIZE) { // 可能是栈向下增长,越过栈顶但还在一个页范围内 } else { p->killed = 1; } }栈的情况后面细说,这里最关键的教训是:越界的合法地址判断必须足够严,不能让非法访问被当成惰性分配请求。我后来复盘时意识到,真正严谨的实验要求里,访问超过p->sz的地址就应该杀进程,这样stval < p->sz这一个条件就够用。栈的特殊映射另说。
4.4 排查思路总结
调试这类问题,我通常按三步走:
- 看panic信息中的函数名,找准是哪个系统调用触发的;
- 顺着调用栈回到页表操作函数,思考“为什么这里假设页全部映射”;
- 把假设条件改成适应惰性分配的语义,再重新跑测试。
这三个panic其实暴露了同一个底层认知:xv6原版中“PTE_V=0”既意味着地址非法,也意味着页不存在;但在惰性分配下,这两个概念被拆开了。后面所有panic都源于代码还在用旧假设,逐个改到就好。
5. 边界情况、测试验证与背后的设计取舍
5.1 地址边界:守护页、栈增长与越界保护
xv6的用户栈使用方式比较特别,栈从高地址向下生长,栈顶在p->sz附近,但栈底其实是操作系统的trapframe下方那个固定页。当栈向内核空间方向越界时,会碰到一个未被映射的守护页。在惰性分配机制下,需要区分几种情况:
- 普通堆访问:
stval < p->sz,合法惰性范围,补页; - 栈正常向下增长:
stval可能在p->sz之上一点点。xv6的默认growproc并不支持栈自动增长,但有些同学在Lab里顺便把栈增长也做成惰性分配,这就需要在补页判断中加入特殊逻辑; - 真正的越界访问:
stval远超p->sz或落在守护页上,应当杀进程。
我个人的建议是:先按课程标准要求做,只处理stval < p->sz这个分支,不额外做栈自动增长,避免把实验范围扩大。我在尝试同时实现栈惰性增长时,花了比核心实验更多的时间去调整边界条件,虽然有趣,但并非必需。
5.2 惰性分配与fork、exit、exec的联动
惰性分配会影响所有涉及页表复制的路径,除了上面提到的uvmcopy、uvmunmap,还要注意exec。
进程调用exec加载新程序时,会新建页表并释放旧页表。旧进程使用过但未映射的惰性页不会在exec中造成问题,因为它直接被整张旧页表替换。但如果旧进程通过fork产生了子进程,父进程在惰性分配后fork,子进程会复制全部已映射页,未映射的页通过缺页机制自行补齐。这要求uvmcopy跳过未映射页,且子进程缺页处理与父进程完全相同,没有新增的工作量。
这里有个值得一提的点:如果某个惰性页已经使用并建立了PTE,fork会照常复制它并分配新物理页;如果没有使用,fork不复制,子进程之后再用时补页。这相当于把“按需分配”的语义自然带到了子进程中,不需要为fork写专门逻辑。
5.3 测试:性能收益和正确性验证
课程提供的lazytests包含几个典型用例:大块内存申请后只访问部分区域、越界访问杀进程、fork后子进程访问惰性页。我自己还写了一个小测试,申请100MB内存但只碰前面几页,然后用time命令对比修改前后的性能。
修改前,sbrk(100MB)会瞬间分配26000多页,系统调用耗时明显;修改后系统调用几乎为零开销,只有访问到的少数页会触发缺页,整体程序运行时间大幅缩短。这背后的计算很简单:立即分配要执行26000多次kalloc和页表写入,惰性分配只在第一次访问时执行一次。
正确性验证方面,我建议至少覆盖:
sbrk分配后,只访问前几页,程序能正常运行;- 访问超过
p->sz的地址,进程被杀死,而不是补页成功; fork后子进程能正常使用惰性分配的地址段;- 连续多次
sbrk和malloc混合调用,没有内存泄漏; - 使用
ealloc等测试工具或自己写小循环反复调用sbrk,确认不会panic。
我给这几个用例画了个核对表,方便自检:
| 测试场景 | 预期行为 | 我的实测结果 |
|---|---|---|
| 申请大块内存,访问部分页面 | 程序正常,物理内存占用低 | 通过 |
| 访问超过p->sz的页面 | 进程被立即杀死 | 通过 |
| fork后子进程访问惰性页 | 子进程缺页补页,正常访问 | 通过 |
| 多次sbrk后退出 | 无panic无泄漏 | 通过 |
| 对未分配页执行写操作触发scause=15 | 分配新页并重试指令 | 通过 |
5.4 惰性分配与真实操作系统的对照
xv6的惰性分配既然是实验性质,和真实系统比还是有区别的。Linux的mmap和堆分配通常也使用惰性策略,但内核还要额外处理内存过载时的OOM、页表错误导致的段错误、并发缺页的同步等问题。xv6的单核简化模型里,这些都被省略了。
不过有一个真实系统里经常讨论的取舍,值得在这个实验后想一想:惰性分配虽然省了物理内存,却可能让错误暴露得更晚。立即分配时,sbrk失败说明物理内存不足;惰性分配时,内存压力可能推迟到实际访问那一刻才出现。对某些系统来说,这种“延迟报错”会让程序在奇怪的地方崩溃,反而更难排查。这一点实验里不会体现,但真正做工程时一定要权衡。
写到这里,我觉得这个实验最值的部分是它把“虚拟地址空间”和“物理页”之间的鸿沟摆到了眼前。你写一行sbrk,操作系统只是画了一个空壳,等你的程序真正去踩那页内存时,它才手忙脚乱地搬来一块物理页填上。这个机制在现代操作系统里无处不在,而xv6用几百行代码就把它讲明白了。
最后再分享一个实测中的小技巧:调试惰性分配时,别光看pp->killed的报错信息,多留意scause和stval的值。我第一次调试时傻傻盯着p->sz看了半天,后来打印了stval才发现故障地址和我想的完全不是一回事。用printf在缺页处理分支里打印关键寄存器值,能帮你少走一大半弯路。