ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入解析 xv6 Lazy Page Allocation:从缺页异常到虚拟内存优化的完整实践

深入解析 xv6 Lazy Page Allocation:从缺页异常到虚拟内存优化的完整实践 1. 从一道作业题说起为什么大家都在折腾 lazy page allocation如果你正在啃 MIT6.828现在叫 6.S081大概率会卡在 Homework4 这道题上。它让你给 xv6 实现 lazy page allocation也就是“懒加载物理内存”。说实话第一次看到这个题目时我脑子里全是问号xv6 本来就够难读了为什么还要搞一个“偷懒”的内存分配直到我照着实验手册一步步改完代码、跑通测试才真正理解这个机制的地位——它不仅是 xv6 的一个补丁更是现代操作系统虚拟内存管理的核心思路之一。这道题的背景其实很朴素。传统的内存分配器比如 xv6 默认的sbrk()实现会在进程申请内存时立刻分配物理页并且把虚拟地址映射到物理地址。但问题来了很多程序申请的内存并不会马上全部使用有些甚至申请完就再也不碰了。如果每次申请都老老实实分配物理页内存利用率会很低尤其是遇到那种“先申请一大块、再一点点用”的程序浪费非常明显。Lazy allocation 的思路反其道而行之进程调用sbrk()时我只修改进程的地址空间大小即sz字段不真正分配物理内存等到 CPU 真的访问到那块虚拟地址时触发缺页异常page fault我再在异常处理程序中顺手分配物理页、建立映射。这样一来物理内存的分配时机从“申请时”推后到了“第一次访问时”省掉了大量闲置内存。这道题适合谁来做我觉得只要你在学操作系统、想弄懂虚拟内存和缺页中断的同学都应该亲手做一遍。它不需要你有多深厚的底层功底但需要你对 xv6 的进程结构、页表操作、trap 流程有一定了解。整个实验大概会花掉你一个下午加一个晚上过程中你会反复在proc.c、vm.c、trap.c之间跳转最后看到lazytests全部通过时那种“噢原来内存是这样运作的”的爽感是看多少篇博客都换不来的。这篇文章我不打算复述实验手册而是把我自己从读题、改代码、翻源码、踩坑到最终通过测试的完整过程写下来包括每一步为什么这么改、背后的原理是什么、遇到各种奇奇怪怪的 bug 怎么排查。如果你想直接抄作业下面的代码可以直接用但如果你想真正学会这道题建议跟着我的思路走一遍。2. 动手之前先把 xv6 环境跑起来2.1 环境配置的几个坑我帮你提前踩了在做 lazy allocation 之前你得先把 xv6 跑起来。这一块看起来简单实际上坑不少尤其是用新版 RISC-V 版本的 xv6也就是 6.S081 用的那个。网上很多教程还在讲 x86 版的 xv6编译工具链完全不同照着做必然是浪费时间。我的建议是直接用官方提供的 riscv-gnu-toolchain如果你用的是 Ubuntu 或者 Debian 系发行版可以用包管理器安装交叉编译工具sudo apt-get install gcc-riscv64-unknown-elf gdb-multiarch qemu-system-misc这里最容易出问题的是 gcc 版本。早期包管理器里可能没有riscv64-unknown-elf-gcc而是riscv64-linux-gnu-gcc两者在链接和启动文件上有差异直接编译 xv6 会报一堆莫名其妙的错误。我后来干脆用官方脚本从源码构建工具链虽然编译时间长了点但一次搞定后续没再折腾过。编译 xv6 本身很简单git clone git://g.csail.mit.edu/xv6-labs-2020 cd xv6-labs-2020 make qemu如果你能看到init: starting sh这样的输出说明环境没问题。这里我强烈建议你在做 Homework4 之前先把课程里之前几个 lab 的基础代码搞清楚尤其是pgtbl那个 lab。lazy allocation 本质上是页表和 trap 的结合如果对walkaddr、mappages、uvmalloc这些函数不熟后面会很吃力。2.2 了解 xv6 内存分配的“不懒”版本要理解 lazy allocation先看 xv6 默认的内存分配流程。进程通过sbrk()系统调用申请内存内核里对应的是sys_sbrk()它调用growproc()来扩展或收缩地址空间。growproc()在proc.c里int growproc(int n) { uint sz p-sz; if(n 0){ if((sz uvmalloc(p-pagetable, sz, sz n)) 0) return -1; } else if(n 0){ sz uvmdealloc(p-pagetable, sz, sz n); } p-sz sz; return 0; }核心在uvmalloc()它会对[oldsz, newsz)这段地址逐页调用mappages()而mappages()又会调用kalloc()分配物理页然后写入页表项。换句话说每次sbrk()都会立马分配物理内存而且是所有页一次性分完。这种“立即分配”的做法有一个致命的场景比如你写一个程序malloc(1GB)但只用了前几 MB剩下的物理内存全部白白占着。如果机器内存紧张这种浪费可能会导致程序被 OOM killer 干掉或者触发 swap 把系统拖慢。lazy allocation 就是来解决这个问题的。还有一点要注意growproc()只能处理正向增长和反向收缩如果n是负数它会调用uvmdealloc()立刻释放物理页。在 lazy 版本里收缩部分仍然要立即释放因为你要缩小地址空间那些页已经不需要了不释放就是泄漏。2.3 实验手册到底想让你做什么MIT6.828 的 Homework4 其实是 6.S081 的 Lazy lab 的前身两者要求几乎一样只是 Homework4 相对更简单一些。它要求你做三件事第一修改sys_sbrk()让它只增加p-sz不分配内存。第二修改trap.c里的缺页异常处理当产生 page fault 时如果地址小于p-sz就分配物理页并映射否则杀掉进程。第三处理一个边角情况fork 之后父子进程共享了什么或者更准确地说uvmcopy在复制页表时会访问哪些地址如果遇到“懒分配但从未实际分配”的页uvmcopy会出错需要跳过这些无效映射。当然实际做的时候问题远不止这三条。后面我会详细讲每一步的写法和踩坑过程。3. 改代码前必懂的三件事页表、trap、地址空间3.1 页表和物理内存的映射关系一句话讲透RISC-V 的 Sv39 分页机制把虚拟地址分成 39 位其中低 12 位是页内偏移高 27 位分成 3 级页表索引每级 9 位。这意味着一个页表项PTE指向下一级页表或者物理页每级页表有 512 个 PTE正好填满一个 4KB 页。在 xv6 里pagetable_t就是根页表的物理地址所有进程共享同一个内核页表通过kvmmake建立每个进程有自己的用户页表。当发生系统调用或中断时硬件会自动切换到内核页表返回用户态时又切回用户页表。lazy allocation 的关键在于当进程遇到一个合法但尚未映射的虚拟地址时硬件会触发 page fault。这时 CPU 会把出错的虚拟地址保存在stval寄存器里同时把异常原因保存在scause寄存器中。我们在 trap 处理代码中读取这些寄存器就能知道是哪个地址出了问题进而决定要不要分配内存。3.2 trap 流程从异常发生到返回用户态xv6 的 trap 处理分为两条路来自用户态的 trap 和来自内核态的 trap。对于用户态 page faultCPU 会跳到stvec指向的地址也就是uservec然后切换页表、保存上下文最后跳到usertrap()这个 C 函数。usertrap()里会检查scause如果是 8表示系统调用如果是 13 或 15表示读/写导致 page fault其他值可能是中断或其他异常。我们要处理的就是 13load page fault和 15store page fault有时也可能是 12instruction page fault但一般只处理前两种。在usertrap()中调用r_scause()拿到异常原因调用r_stval()拿到出错虚拟地址。如果原因匹配且地址在进程地址空间范围内就调用uvmalloc或者更底层的内存分配逻辑来补上这一页如果不匹配直接exit(-1)。这里有一个经典的大坑如果你在usertrap()中分配内存失败或者地址非法不能直接调用panic()因为那会禁用中断并打印堆栈导致整个系统崩掉。正确的做法是设置p-killed 1然后回到用户态由用户态代码在下次系统调用或 trap 时发现killed标志并退出进程。这也是 xv6 常规的“杀进程”方式。3.3 地址空间范围p-sz不是你想的那样在 xv6 中每个进程的p-sz表示用户虚拟地址空间的大小也就是用户栈顶之上的地址边界。它从 0 开始到MAXVA通常是 256GB结束但实际使用的部分不会超过p-sz。但注意xv6 给用户栈预留了一页 guard page位于p-sz下方一页用来检测栈溢出。如果你想判断一个虚拟地址是否合法不能只判断va p-sz还要考虑栈的位置。不过大部分测试程序都不会去访问 guard page所以简单判断也能过测试。但严谨的做法是限定va PGROUNDDOWN(p-sz) - PGSIZE之类的范围这个我在后面实现部分会细说。另外还需要注意p-sz并不是页对齐的sbrk()的n也不一定是页大小的整数倍。在 lazy 分配时我们需要把出错地址向下取整到页边界然后逐页分配直到覆盖va因为一次分配一页是最小粒度。3.4 为什么需要修改uvmcopy如果你只修改了sys_sbrk和usertrap运行fork相关测试时会发现进程在 fork 时崩溃。原因很简单fork()调用uvmcopy()把父进程的整个用户页表复制一份给子进程。uvmcopy()的典型实现是遍历父进程页表中的所有 PTE凡是有效且具备权限的页就分配新物理页并复制内容。问题在于lazy 模式下父进程的地址空间里有些虚存区域根本没有 PTE甚至有些页表项是无效的。uvmcopy()如果直接按老逻辑处理只能遍历到已有的映射这倒不会出错。真正会出错的是另一种情况如果父进程已经因为 lazy 分配建立了映射uvmcopy会复制它但如果父进程调用fork的时机是在“修改了sz但尚未访问新内存”的时刻那么父进程页表里根本没有这些新页子进程页表也没有这其实是合理的因为两者都没有访问过。那为什么会有 bug关键在于uvmcopy()里有一个if((pte walk(parent, i, 0)) 0) panic(uvmcopy: pte should exist);之类的断言。当一个虚拟地址在p-sz范围内但没有 PTE 时walk 返回 0老代码会直接 panic。所以必须改成“如果 PTE 不存在就跳过这一页”而不是 panic。另外还要考虑另一种情况父进程某页的 PTE 存在但*pte PTE_V为 0表示该页尚未分配。这同样要跳过。总之uvmcopy必须容忍缺失的页表项。4. 核心实现手把手改出可用的 lazy allocation4.1 第一步改sys_sbrk——只改大小不忙分配打开sysproc.c找到sys_sbrk()。原代码是这样的uint64 sys_sbrk(void) { int addr; int n; if(argint(0, n) 0) return -1; addr p-sz; if(growproc(n) 0) return -1; return addr; }growproc()会真的去分配内存。我们要绕过它直接修改p-szuint64 sys_sbrk(void) { int addr; int n; if(argint(0, n) 0) return -1; addr p-sz; if(n 0) { // 收缩内存时仍然需要真正释放。 // 这里可以调用 uvmdealloc但要注意负数的处理。 if(p-sz n 0) // 防止下溢 return -1; p-sz uvmdealloc(p-pagetable, p-sz, p-sz n); } else { // 懒分配只扩展大小不分配内存。 // 注意不能超过 MAXVA否则后续访问会变成非法地址。 if(p-sz n MAXVA) return -1; p-sz n; } return addr; }这里有一个细节uvmdealloc会检查newsz oldsz并在映射存在时释放物理页。如果n为负数我们必须调用它来释放已有内存。不能直接p-sz n因为那样会造成物理页泄漏。还要注意正数的边界如果n是正数但我们只加p-sz这个过程中不检查n是否超过MAXVA。xv6 的MAXVA是(1 38)即 256GB正常情况下不会超过但如果用户恶意调用sbrk(0x7fffffffffff)就可能溢出。为了安全需要加一个判断。我自己的实现里还加了一个对齐的考虑吗其实不需要对齐p-sz可以不是页对齐后续分配时我们会用PGROUNDDOWN(va)来对齐。而sbrk返回的 addr 是旧的p-sz这个值必须是页对齐的吗不一定标准 Unix 中sbrk返回的是旧的 break 位置可能不是页对齐但 xv6 很多地方会用p-sz做页对齐运算如果它不是页对齐的在uvmalloc里会先oldsz PGROUNDUP(oldsz)所以没太大问题。我们 lazy 版本里直接加 n 也没关系。4.2 第二步在usertrap中处理 page fault打开trap.c找到usertrap()在syscall()调用之前或之后加上 page fault 处理。关键在于我们必须在syscall()之前处理吗不一定但必须在检查scause之后处理。我的代码放在syscall()调用后面反正 page fault 不会触发系统调用。核心逻辑} else if(r_scause() 13 || r_scause() 15) { uint64 va r_stval(); if(va p-sz || va MAXVA || va PGROUNDDOWN(p-sz) - PGSIZE) { // 非法地址或者访问到 guard page 下方的栈溢出区域杀掉进程。 p-killed 1; } else { uint64 pa (uint64) kalloc(); if(pa 0) { // 内存不够 p-killed 1; } else { memset((void*)pa, 0, PGSIZE); va PGROUNDDOWN(va); if(mappages(p-pagetable, va, PGSIZE, pa, PTE_U|PTE_W|PTE_X|PTE_R) ! 0) { kfree((void*)pa); p-killed 1; } } } }这里有一个需要思考的点mappages的权限位该怎么设置用户进程的代码段可读可执行数据段可读可写栈可读可写堆通常可读可写。而且 lazy 分配通常发生在数据段或栈所以给PTE_R|PTE_W|PTE_U一般是够的。但有些场景下堆内存需要执行权限吗不需要但我们无法区分是哪种类型的内存。其实只要可读可写不会影响正常使用。我在这里加了PTE_X是为了防止某些测试用例比如执行代码段时发生缺页导致失败。虽然 xv6 的代码段通常是静态加载的但有些 lab 允许动态加载用户代码加上执行权限更安全。还有一个重要问题如果用户访问的地址低于p-sz但这块地址已经在页表中有映射了还会触发 page fault 吗不会只有 PTE 无效时才会缺页。所以不会重复映射。但有一种情况例如同一页的某部分被映射了另一部分没映射而我们用mappages映射 4KB可能覆盖已有映射。这种情况不会发生因为mappages以页为单位同一个虚拟页要么完全映射要么完全不映射。关于va PGROUNDDOWN(p-sz) - PGSIZE这个条件是我后来加上的。因为 xv6 的用户地址空间底端从 0 开始栈在地址空间顶端。如果进程访问了小于栈底一大截的地址那很可能是野指针不该给它分配。但简单版本只判断va p-sz是否合法其实也能过测试。实验手册并没有强制要求检查 guard page不过我们自己实现时多做一层防护没有坏处。4.3 第三步处理uvmcopy和fork的兼容uvmcopy在vm.c中。原代码int uvmcopy(pagetable_t old, pagetable_t new, uint64 sz) { pte_t *pte; uint64 pa, i; uint flags; char *mem; for(i 0; i sz; i PGSIZE){ if((pte walk(old, i, 0)) 0) panic(uvmcopy: pte should exist); if((*pte PTE_V) 0) panic(uvmcopy: page not present); pa PTE2PA(*pte); flags PTE_FLAGS(*pte); if((mem kalloc()) 0) goto err; memmove(mem, (char*)pa, PGSIZE); if(mappages(new, i, PGSIZE, (uint64)mem, flags) ! 0){ kfree(mem); goto err; } } return 0; err: uvmunmap(new, 0, i / PGSIZE, 1); return -1; }在 lazy 模式下由于sz是直接增加的但页表没有对应 PTEwalk返回 0于是触发panic。我们需要把这两个panic改成跳过for(i 0; i sz; i PGSIZE){ if((pte walk(old, i, 0)) 0) continue; if((*pte PTE_V) 0) continue; // 正常复制 }单纯continue可能导致子进程缺少对应页的映射但这是合理的父进程也没映射子进程就不该有。等子进程将来访问该地址时它会触发自己的 page fault再通过 lazy 分配补上。这样就平滑实现了父子进程共享“懒加载”的语义。如果你还想更严谨一点可以将缺失的 PTE 看作“清零页”即不分配物理页而是在子进程页表中留下一个无效 PTE。但没必要因为无效 PTE 和缺失 PTE 在访问时都会触发 page fault处理方式一样。需要注意err标签下的uvmunmap(new, 0, i / PGSIZE, 1)如果中途 kalloc 失败我们只 unmap 已经复制成功的页这个逻辑没问题。4.4 第四步补齐其他缺漏只改这三处还不够我在测试中遇到了几个隐蔽问题。第一个是exec之后的初次访问。xv6 的exec加载 ELF 文件时会先调用uvmalloc分配虚拟地址空间但那是真正的分配与 lazy 无关。然而exec在设置栈的时候会用到p-sz如果栈页没有映射同样会触发 page fault这时候我们已经能处理所以没问题。第二个是read/write等系统调用。当用户程序调用write时内核需要将用户缓冲区地址转换为物理地址通常使用walkaddr函数。如果用户缓冲区所在页尚未分配lazy 场景walkaddr会返回 0系统调用就会失败。怎么办有两种方案一是在walkaddr中进行 lazy 分配但这会污染内核通用的地址转换函数不推荐。二是确保系统调用前用户已经访问过缓冲区但这不可控。我们需要在walkaddr中加一个判断如果地址在进程地址空间内且 PTE 不存在则分配并映射一页然后返回物理地址。我们来看walkaddr的原始实现uint64 walkaddr(pagetable_t pagetable, uint64 va) { pte_t *pte; uint64 pa; if(va MAXVA) return 0; pte walk(pagetable, va, 0); if(pte 0) return 0; if((*pte PTE_V) 0) return 0; if((*pte PTE_U) 0) return 0; pa PTE2PA(*pte); return pa; }如果pte不存在或无效直接返回 0。当内核需要访问用户缓冲区时比如copyin/copyout或者sys_write中直接使用walkaddr就会失败。但 lazy 模式下用户程序可能刚刚通过sbrk申请了内存还没访问就把它指针传给write系统调用这时候没有映射walkaddr返回 0系统调用失败返回 -1程序崩溃。为了支持这种情况最好在walkaddr中判断va p-sz时如果 PTE 不存在或无效则分配一页并映射。注意walkaddr没有传入进程指针怎么拿到p-sz我们可以通过myproc()获取当前进程因为walkaddr通常在进程上下文调用。但这样会改变函数的通用性而且在内核态调用时可能myproc()为 0。有没有更好的办法MIT 的官方 lazy lab 似乎不要求改walkaddr其实在 6.S081 的 lazy lab 中确实没有强制要求但测试程序可能覆盖了这种场景。为了保险起见我在实现 Homework4 时加了这段逻辑。具体做法是uint64 walkaddr(pagetable_t pagetable, uint64 va) { pte_t *pte; uint64 pa; if(va MAXVA) return 0; pte walk(pagetable, va, 0); if(pte 0) { struct proc *p myproc(); if(p va p-sz) { // lazy allocate char *mem kalloc(); if(mem 0) return 0; memset(mem, 0, PGSIZE); if(mappages(pagetable, PGROUNDDOWN(va), PGSIZE, (uint64)mem, PTE_U|PTE_W|PTE_R) ! 0){ kfree(mem); return 0; } return (uint64)mem; } return 0; } if((*pte PTE_V) 0) { struct proc *p myproc(); if(p va p-sz) { char *mem kalloc(); if(mem 0) return 0; memset(mem, 0, PGSIZE); if(mappages(pagetable, PGROUNDDOWN(va), PGSIZE, (uint64)mem, PTE_U|PTE_W|PTE_R) ! 0){ kfree(mem); return 0; } return (uint64)mem; } return 0; } ... }但这里要注意当mappages映射成功后pte指针可能因为页表重新分配而失效所以最好重新调用walk获取新 PTE。不过我直接返回了mem也没有问题因为物理地址就是mem。不过这个改法可能会破坏某些测试的预期比如copyout在写入用户空间时访问到用户栈顶之上的地址合法的栈顶上方一页是 guard page如果va p-sz但它是 guard page我们不该分配。所以walkaddr里的判断最好加上栈保护区判断或者依赖usertrap来做完整性检查。但实际上walkaddr更多被用于系统调用参数传递那些地址通常是用户已经映射或即将映射的堆栈区极少会访问 guard page所以问题不大。其实在官方实验的实现中还有一种做法是修改copyin/copyout这两个函数让它们在访问用户地址时也支持 lazy。不过那要改底层内存访问函数风险更大。相比之下改walkaddr比较简单且通用。第三个问题是fork之后子进程的sz是复制父进程的但子进程页表中缺失的 PTE 是否会导致fork返回后父进程继续运行出错不会因为每个进程都有独立的页表fork只是复制了现有映射缺的仍然缺。第四个问题是exit或exec时uvmunmap会遍历地址空间并释放物理页。如果页表中存在无效 PTEuvmunmap会panic。我们需要检查uvmunmap的实现。原代码中for(a va; a va npages*PGSIZE; a PGSIZE){ if((pte walk(pagetable, a, 0)) 0) panic(uvmunmap: walk); if((*pte PTE_V) 0) panic(uvmunmap: not mapped); ... }在 lazy 模式下如果我们只增加了sz而没有映射地址空间中会存在一段虚拟地址没有 PTE。当exit或exec调用uvmunmap清空页表时就会触发 panic。所以要么对所有“缺失 PTE”的情况都宽容处理要么在 lazy 模式下保证sz范围内每一页都有 PTE哪怕 PTE 无效。因为无效 PTE 也会让uvmunmappanic所以必须容忍这些空项。因此需要修改uvmunmap把panic改为continue。同理uvmcopy里的panic我们已经改了。还有其他遍历页表的地方比如freewalk它只递归释放页表页不检查 PTE_V但会在遇到非叶子节点时继续递归。如果叶子节点存在但无效freewalk 会认为它不是页表页而跳过这没问题。不过为了安全我们也可以检查一下 freewalk 对无效 PTE 的处理不过它只判断(*pte PTE_V)来决定是否递归所以无效 PTE 会被忽略不 panic。总结一下需要修改至少四处sys_sbrk、usertrap、uvmcopy、uvmunmap。如果考虑系统调用参数传递还要改walkaddr或者copyin/copyout。我在完成实验时共改了五个文件。4.5 第五步构造你的测试程序xv6 里自带user/lazytests.c和user/lazy.c吗我印象中 Homework4 没有自带专门测试但 6.S081 的 lazy lab 提供了lazytests.c。Homework4 的实验手册建议你自己写几个简单的 xv6 用户程序来测试。我当时写了一个testlazy.c代码很简单#include kernel/types.h #include kernel/stat.h #include user/user.h int main(void) { uint64 sz (uint64) sbrk(4096 * 100); // 申请 400KB // 不访问直接 fork int pid fork(); if(pid 0) { // 子进程访问触发 lazy alloc char *p (char*) sz; p[0] a; printf(child: %c\n, p[0]); exit(0); } else { wait(0); printf(parent ok\n); exit(0); } }另外还测了“先申请一大块然后只访问其中一页”的场景观察物理内存是否真的节省了。xv6 里可以用kalloc统计信息没有现成的但可以通过观察free命令的输出。xv6 的free命令只能显示空闲页数比较繁琐但也能大致验证。还有一个经典测试是申请内存后访问超出p-sz的地址进程应当被杀掉。例如char *p (char*) sbrk(100); p[4096] 1; // 超出了已申请的区域 // 应当导致进程退出不应当导致系统崩溃如果我们的usertrap判断正确该进程会被 kill控制台会输出usertrap(): unexpected scause 0x000000000000000d pid...之类的信息然后 shell 继续运行。5. 常见问题与排查技巧实录5.1 测试时系统直接 panicuvmunmap: not mapped这是我第一次改完代码跑make qemu后遇到的头号问题。原因很简单uvmunmap里遇到了无效或缺失 PTE。解决方案是把那几个panic改成continue。但你要小心如果uvmunmap本来就是为了释放所有映射而设计跳过无映射页是完全合理的。真正的 bug 是调用方传错了地址范围所以改成 continue 后表面上不崩了但如果调用方逻辑有误可能会掩盖问题。不过在 lazy 场景下跳过无效页完全正确。修改后最好重新跑一下之前的 lab 测试比如 pgtbl 的测试确保没有破坏exec、fork等原有功能。5.2 程序运行到一半被杀usertrap(): unexpected scause这个输出来自usertrap的默认分支。如果你在usertrap里添加的分支没有捕获 page fault或者判断条件过严就会走到默认分支。常见情况是没有覆盖scause 12指令页错误。虽然不常见但为了稳妥可以一并处理。此外有时r_stval()返回的 va 对齐是 0 或者一个奇怪的值可能是因为sbrk申请的内存太小连一个页都没按页对齐。比如sbrk(100)然后访问p-sz处的地址由于p-sz向上取整后可能已经超出了之前的地址此时 va 是p-sz本身但va p-sz条件会触发从而杀进程。实际上用户申请了 100 字节但他只能访问[old_sz, old_sz100)而p-sz是old_sz100访问p-sz本身就是越界杀进程合理。如果你希望sbrk(100)后能访问更多页那是另一个语义。5.3 fork 之后子进程崩在访问父进程未访问过的地址这个问题我之前提过通常是uvmcopy的panic没改干净。检查一下是否所有panic都替换成了continue尤其是循环内的两个panic。有些同学只改了第一个漏了第二个就会在遇到PTE_V 0时再次 panic。5.4 系统调用传指针时失败我一开始没改walkaddr结果跑echo hello file这类命令时shell 可能调用write系统调用其用户缓冲区可能在 lazy 区域导致walkaddr返回 0write 返回 -1。但奇怪的是shell 在exec之前已经映射了缓冲区所以可能不触发。不过当用户程序自己sbrk后直接传给read、write时问题就暴露了。解决方案就是前面提到的在walkaddr里做 lazy 分配。需要注意walkaddr返回的物理地址必须满足内核的访问需求分配物理页后应清零否则用户读到旧数据会出诡异 bug。5.5 怎么确认内存分配真的是 lazy 的可以写一个测试程序先sbrk(4096 * 100)然后观察free输出的空闲页数。如果没有调用访问空闲页数应该没有变化。然后访问其中第一页空闲页数减少一页访问第二页再减少一页。这样就能直观看到 lazy 的效果。xv6 的free命令打印空闲内存页数但在 shell 里比较难连续观察。我当时的办法是在usertrap里临时添加打印每次 lazy 分配时打印一条信息。或者写个用户程序在分配前后调用free不现实因为 xv6 没有提供获取空闲页数的系统调用。你可以通过kalloc的统计信息来调试加一个全局计数器但这属于额外工作。我在调试时直接在mappages成功分支加了一行printf(lazy alloc: va%p\n, va);跑完测试后删掉。这虽然会影响性能但调试很方便。5.6 权限位到底要不要加PTE_X加PTE_X可能会带来安全隐患但 xv6 本身不区分用户代码/数据页的权限它的用户程序加载时会给所有用户 PTE 加上读、写、执行权限其实不是xv6 的 ELF 加载会根据程序段设置权限但sbrk分配的堆内存一般只有读写xv6 的uvmalloc传递的权限是PTE_W|PTE_X|PTE_R|PTE_U让我回忆一下xv6 的uvmalloc中调用mappages(pagetable, a, PGSIZE, (uint64)mem, PTE_W|PTE_X|PTE_R|PTE_U)。是的xv6 默认堆内存带执行权限虽然这不符合现代操作系统的 W^X 安全原则但为了兼容老程序它们一直这么做。所以我们在 lazy 分配时也加上 PTE_X 与原有行为保持一致。5.7 死锁或死循环usertrap里分配内存又触发 trap如果你在usertrap里调用mappagesmappages本身可能导致内存分配walk需要给新的页表项分配页使用kalloc而kalloc可能会触发锁等待但不会再触发 page fault所以不会递归。但如果你错误地在usertrap中调用了copyin或copyout它们可能访问用户内存如果用户内存也未映射会再次触发 page fault导致递归。所以usertrap里不要调用任何可能触发缺页的函数只做简单的kalloc和mappages。5.8 最后别忘了跑回归测试改完 lazy 之后最好把之前做过的 lab 测试都跑一遍尤其是pgtbl、syscall、traps。因为sbrk是基础系统调用很多测试都会用到。如果growproc被改掉了某些依赖旧语义的测试可能失败。作业题没要求全部通过但至少保证原有功能不死机、不 panic。6. 我的踩坑记录与一点心得我前后花了一个下午加一个晚上才把这道题跑通最大的感悟是不要急着在网上找现成代码先自己理解地址空间的布局和 trap 流程再动手改这样遇到 bug 时才知道从哪里查。改代码本身只有几十行但排查问题的过程才是最有价值的。说说我在调试过程中觉得最值得注意的几点。第一要善用printf。xv6 没有 GDB 调试的图形界面虽然你可以用gdb-multiarch连 QEMU 调试但对于这种小规模内核直接加打印反而快。我会在usertrap中打印scause、stval、p-sz一下子就能判断出是否走到了预期的分支。第二要把p-sz的更新点整理清楚。sbrk、exec、fork、exit都会涉及p-sz。修改sbrk后要确保fork和exit都能处理“地址空间内存在无效 PTE”的情况。如果不确定把所有遍历页表的panic检查一遍凡是遇到“必须存在 PTE”的断言都要考虑 lazy 场景是否成立。第三测试用例一定要有“边界用例”。比如访问正好在p-sz边界的地址、访问地址空间的顶部、访问栈下方未映射区域、访问负地址实际是很大的数。这些边界条件往往是 bug 的高发区。最后说一个小的扩展点做完这道题你可以进一步实现“内存压缩”或者“按需清零”的优化。例如当sbrk申请大量内存时可以让所有新页都映射到同一个零页写时复制等真正写入时再分配物理页。这是一种更高级的 lazy 策略。MIT 6.S081 后续的COWlab 就是基于类似思路做完 Homework4 再去做 COW你会发现很多概念都是相通的。如果你卡在某一步不用怀疑自己正常。当年我调试uvmunmap的 panic 调了整整两个小时最后发现只是漏改了一个continue。静下心来把错误信息读清楚一行一行跟进去总能找到问题。这道题值得你花时间因为它会让你真正理解“虚拟内存是操作系统里最优雅的谎言之一”。
返回列表