news 2026/10/2 5:48:42

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入解析 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 fault,CPU 会跳到stvec指向的地址,也就是uservec,然后切换页表、保存上下文,最后跳到usertrap()这个 C 函数。

usertrap()里会检查scause:如果是 8,表示系统调用;如果是 13 或 15,表示读/写导致 page fault;其他值可能是中断或其他异常。我们要处理的就是 13(load page fault)和 15(store page fault),有时也可能是 12(instruction 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 allocation

4.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->sz:

uint64 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是直接增加的,但页表没有对应 PTE,walk返回 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会遍历地址空间并释放物理页。如果页表中存在无效 PTE,uvmunmap会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 测试时系统直接 panic:uvmunmap: 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_sz+100),而p->sz是old_sz+100,访问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返回 0,write 返回 -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里调用mappages,mappages本身可能导致内存分配(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。静下心来,把错误信息读清楚,一行一行跟进去,总能找到问题。这道题值得你花时间,因为它会让你真正理解“虚拟内存是操作系统里最优雅的谎言之一”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 5:47:48

Excel可视化分析全攻略:从图表选型到实操避坑指南

一张表能说明白的事&#xff0c;何必开三个会。做数据分析最怕的不是数据多&#xff0c;而是数据摆在那没人看得懂。Excel可视化分析这件事&#xff0c;说难不难&#xff0c;说简单也有一堆细节坑。我这几年用Excel做报表、做汇报、做业务复盘&#xff0c;柱形图、条形图、饼图…

作者头像 李华
网站建设 2026/10/2 5:47:42

腾讯WeKnora深度实践:Agentic RAG知识库部署与调优指南

1. 为什么我花了两周时间折腾 WeKnora第一次看到 WeKnora 这个名字&#xff0c;是在一个做企业知识管理的群里。有人甩了张截图&#xff0c;说腾讯微信团队开源了一个 AI 知识库项目&#xff0c;能直接把一堆 PDF、Word、Markdown 丢进去&#xff0c;然后用自然语言问它问题&am…

作者头像 李华
网站建设 2026/10/2 5:47:41

零空间(Null Space)是什么?从矩阵映射到机器学习盲区

矩阵这玩意儿吧&#xff0c;我刚学的时候也觉得它就是一堆数排成矩形&#xff0c;用来解方程组的。直到后来做数据降维、看特征值、搞深度学习里的各种分解&#xff0c;才发现矩阵的本质是个“映射”——它把一个向量空间的点搬到另一个空间去。而在这个视角下&#xff0c;有个…

作者头像 李华
网站建设 2026/10/2 5:46:24

回形针思想实验:从目标函数设计到AI对齐与安全治理

好几次我在给非技术背景的朋友讲AI安全风险&#xff0c;都会从"paperclip"这个词开始。他们多数人的第一反应是&#xff1a;一个做回形针的AI有什么好怕的&#xff0c;产量拉满不就完了&#xff1f;直到我把整个推演一步步摊开——它会意识到人类可能关掉电源、意识到…

作者头像 李华
网站建设 2026/10/2 5:46:13

对接第三方API的实战指南:从联调到上线,避开这些坑

1. 接到对接需求后&#xff0c;别急着写代码&#xff0c;先把边界画清楚我见过太多人一拿到第三方的接口文档就撸起袖子写代码&#xff0c;结果联调阶段被各种意外吊打。我自己早年间也干过这种事——产品经理扔过来一句话"我们要和金蝶云星空做数据同步&#xff0c;你拉一…

作者头像 李华
网站建设 2026/10/2 5:44:24

Redis原生能力深度指南:告别Jev迷思

1. 这不是一场技术狂欢&#xff0c;而是一次集体误读的现场复盘最近刷屏的“Jev”这个词&#xff0c;几乎以病毒式速度席卷了开发者社区、技术群聊和招聘JD——有人把它当新晋AI框架&#xff0c;有人拿它当Redis替代方案&#xff0c;还有人连夜在简历里加了“精通JevRedis双栈”…

作者头像 李华