如果你已经熬过了 lazy allocation 那个“先不给分配、等用到再补”的 Lab,这次 COW Lab 看起来多少会有点眼熟——同样是拿缺页异常做文章,同样是“能偷懒就偷懒”的哲学。只不过 lazy 是拖到访问才分配内存,而写时复制 COW(Copy-on-Write)是反过来:内存早就分配好了,但在 fork 的时候先不急着复制,让父子进程共享同一份物理页,等真有人要写入了,再单独复制一份出来。
这个 Lab 是 MIT 6.S081 里很经典的一道硬菜,核心就一句话:把 xv6 里 fork 的“全量复制内存”改成“共享 + 延迟复制”。它考察的东西非常综合:页表结构、缺页异常流程、物理内存管理、锁的顺序,甚至还有 syscall 调用路径里 kernel 读写用户内存的细节。适合已经做完 traps 和 lazy allocation、对 xv6 的虚拟内存机制有一定熟悉度的同学来啃。这篇文章我会按我自己的实现顺序把思路、代码、踩过的坑全部摊开讲,尽量让你照着也能跑通。
1. 这个 Lab 到底在逼你解决什么问题
1.1 fork 的“老实人”做法有多浪费
先看看 xv6 原始的 fork 干了什么。fork 的核心是uvmcopy(),它遍历父进程整个用户地址空间的每一页,kalloc()申请新页,memmove()把内容原样拷过去,再把新页映射到子进程页表里。
这个做法在功能上完全正确,但有两个致命问题。
第一是慢。假设父进程占了 100MB 内存,fork 一次就是实打实的 100MB 分配加拷贝。第二是浪费。现实中 fork 之后绝大多数字进程马上就 exec 了,exec 会直接丢弃整个旧地址空间、加载新程序,你前面辛辛苦苦复制的那 100MB 内容,一个字都没用上就被全部释放。
这个浪费其实非常离谱:就像你要复印一本书,复印之前突然被告知“这份复印件马上要当草稿纸糊墙”,你会觉得还不如直接拿原件看着写算了。COW 的思路正是如此——既然子进程大概率用不上这份拷贝,那就先不拷,跟父进程共用原稿,等哪一页真的要被改写了再临时复印那单独一页。
1.2 COW 的核心补偿逻辑
写时复制的做法拆开来看其实不复杂:
- fork 的时候不再给子进程逐页分配内存,而是把父进程的物理页直接映射到子进程的页表里;
- 对于原本可写的页,父子双方的 PTE 都清掉 PTE_W,打上一个“COW”标记;
- 之后谁先对这片共享页做写操作,谁就会触发 store page fault,在缺页处理里现场分配一个新页、拷贝内容、把对应 PTE 改成可写;
- 两个进程各自的写操作互不干扰,最终各自拥有独立的物理页。
关键点在于:只有真的发生写入的那个页面才会被复制。只读页面(比如代码段)根本不用复制,父子直接共享就行;写共享页时也只在那个时点、那一页付出代价。理想情况下,一个“fork 后马上 exec”的程序,COW 可以把内存复制的开销降到近乎为零。
1.3 这个 Lab 到底在考什么
从表面看就是个“改四个文件”的活,实际上它在考你对 xv6 虚拟内存机制的完整理解:
- 你要知道 PTE 里哪些位是硬件定义、哪些位是软件可自留的;
- 你要理解缺页异常从用户态陷进内核后,usertrap 该怎么根据 scause 区分故障类型;
- 你要能协调物理页分配器,让同一物理页在多个进程间共享时不被提前释放;
- 你还得处理一个隐蔽但必考的坑——syscall 里内核用 copyout 替用户写数据时,也要绕过 COW 保护。
这四项对应kernel/riscv.h、kernel/kalloc.c、kernel/vm.c、kernel/trap.c四个文件的改动,任何一环漏掉,测试都会以诡异的方式失败。
2. 动手之前,先把三块地基检查一遍
2.1 PTE 里到底有哪些位能用
RISC-V sv39 页表里,每个 PTE 占 64 位。xv6 中常用的低位定义如下:
| 位 | 宏 | 含义 |
|---|---|---|
| 0 | PTE_V | 页有效 |
| 1 | PTE_R | 可读 |
| 2 | PTE_W | 可写 |
| 3 | PTE_X | 可执行 |
| 4 | PTE_U | 用户态可访问 |
| 5 | PTE_G | 全局映射 |
| 6 | PTE_A | 被访问过 |
| 7 | PTE_D | 被写入过 |
第 8、9 位在 RISC-V 规范里是留给软件(Supervisor)自己用的,叫 RSW 位,硬件不会碰它。xv6 平时不用这两位,所以 COW Lab 的常规做法就是拿第 8 位当 PTE_COW。
这里有个容易困惑的点:COW 页在硬件层面仍然是被映射的、可读的,只是 PTE_W 被清掉了。所以硬件并不知道什么叫“写时复制”,它只知道“这页不可写”。当进程尝试写入时,硬件产生 store page fault,真正判断该不该复制、怎么复制的是你这是软件代码。
2.2 缺页异常最终会汇入 usertrap
xv6 的异常流程不算复杂:用户态发生异常时,硬件跳转到stvec指定的地址(用户态运行期间stvec指向uservec),uservec负责保存寄存器、切换到内核页表、跳转到usertrap()。
usertrap()里通过读scause寄存器来区分原因:
scause == 8:环境调用,也就是 syscall,处理完后sepc + 4返回下一条指令;scause == 15:store page fault,也就是写入时缺页;scause == 13:load page fault,读取时缺页;devintr()非零:外部中断或定时器中断。
x86 里判断缺页还要再读 CR2 寄存器拿虚地址,RISC-V 对应的是stval。我们在 COW 缺页处理里就会用到r_stval()拿到触发故障的虚拟地址。特别注意:处理完 store page fault 之后,sepc 不需要加 4,因为异常指令根本没执行成功,返回用户态后要重新执行这条写指令。而 syscall 不一样,scall 指令本身已经执行成功,所以要 +4 跳过它。
2.3 物理页的一生由 kalloc 和 kfree 掌控
xv6 的物理内存分配器是一个极简的空闲链表,用一个kmem自旋锁保护。kalloc()从链表头部摘一页返回,kfree()把页头当链表节点插回链表。
原始版本里,kfree()无条件回收物理页。COW 之后这个逻辑必须改:一个物理页同时映射在父进程和子进程的页表里,如果一方的进程退出就把它释放,另一方还在用着,那另一方的数据就瞬间变成野指针了。所以必须给每个物理页维护一个引用计数,只有计数归零时才能真的把页还给空闲链表。
计数数组放在kalloc.c里最自然,因为物理页分配和释放都在这一层,kalloc/kfree是唯一合法管理页生命周期的地方。
3. 整体方案设计:四步改造
3.1 在 PTE 里新增 COW 标志位
操作很简单,直接在kernel/riscv.h加一行:
#define PTE_COW (1L << 8)第 8 位是 RSW 位,硬件啥也不管,纯粹是给我们软件记状态用的。以后只要某个页的 PTE 里有 PTE_COW,就表示“这页当前是写时复制页,别直接写”。
有人在实现中会把 COW 页设计成 PTE_W 也保留、用 COW 位做判断,但我建议按标准做法:COW 页必须清除 PTE_W。原因很简单,你的缺页处理完全依赖硬件“不让你写”这件事来触发,如果你 PTE_W 和 PTE_COW 同时存在,硬件认为这页可写,就永远不会触发 store fault,COW 机制直接失效。
3.2 给物理页加引用计数“账本”
在kernel/kalloc.c里增加一个全局计数数组:
struct { struct spinlock lock; int cnt[PHYSTOP / PGSIZE]; } refcnt;数组大小是物理内存按页数量计算,xv6 的 PHYSTOP 通常是 128MB,除以 4KB 也就 32768 个 int,内存开销可以忽略。加锁是因为多核环境下父子进程可能同时在写同一个物理页的计数。
设计规则是三条:
kalloc()成功返回一页时,把对应计数置 1;- 每次把该物理页映射到一个新地址空间(比如 COW fork 的共享映射),计数 +1;
- 每次解除一个映射(比如进程退出、uvmunmap),计数 -1,归零才真正释放。
这里有个新手很容易想不通的点:只读页并没有被打 COW 标记,为什么也要加计数?因为 COW 之后连只读页都是父子共享的同一物理页,两个进程的页表都指向它。如果子进程退出时把它释放了,父进程还在用它做代码段或字符串常量。所以不管是 COW 页、可写页还是只读共享页,只要是共享映射,都必须 +1。
3.3 fork 的地址空间复制改成共享映射
原始的uvmcopy()是“新开页 + memmove”,COW 版本的核心差异是:不分配、不拷贝,只复制映射关系。
伪代码思路:
int uvmcopy(old, new, sz) { for (每一页) { pte = walk(old, i, 0); pa = PTE2PA(*pte); flags = PTE_FLAGS(*pte); if (flags & PTE_W) { // 可写页:父进程和即将创建的共享页都变成 COW flags = (flags & ~PTE_W) | PTE_COW; *pte = PA2PTE(pa) | flags; } mappages(new, i, PGSIZE, pa, flags); // 子进程映射到同一物理页 refcnt_inc(pa); // 共享计数 +1 } }注意第一处细节:我们必须修改父进程原来的 PTE,把 PTE_W 清掉、加上 PTE_COW。否则 fork 之后父进程还能自由写这页,子进程却是只读,那父进程一写就把子进程的共享数据冲掉了。所以共享必须是双向的,父子双方都不能直接写。
第二处细节:如果某个页原本就是只读页(比如代码段),flags & PTE_W为假,那就原封不动地共享映射,同时计数 +1。如果某页已经是 COW 页(比如子进程再 fork 孙进程),同样直接共享。
3.4 缺页异常时执行“真正的复制”
当父子任一进程写入 COW 页,硬件触发scause == 15的 store page fault。在usertrap()里我们做这几步:
- 读
stval拿到故障虚拟地址 va; - 用
walk()找到对应 PTE,检查 PTE_V 和 PTE_COW; kalloc()分配一个全新的物理页;memmove()把旧页内容复制到新页;- 修改 PTE:把新页映射到当前进程,清除 PTE_COW,补上 PTE_W;
- 对旧物理页调用一次
kfree(),等效于引用计数 -1。
这里整个动作都是“现场补票”:谁写谁复制,写哪页复制哪页。如果kalloc()失败,说明系统内存耗尽,直接把进程标记为killed交给系统回收。
3.5 这次改动波及哪些模块
| 文件 | 改动内容 | 职责 |
|---|---|---|
| kernel/riscv.h | 增加 PTE_COW 宏 | 定义 COW 页标记 |
| kernel/kalloc.c | 增加引用计数数组与锁,修改 kalloc/kfree,新增 refcnt 辅助函数 | 保证共享物理页不被提前释放 |
| kernel/vm.c | 重写 uvmcopy,修改 copyout | 地址空间复制与内核写用户内存 |
| kernel/trap.c | usertrap 增加 store page fault 处理 | 缺页时完成真正的页复制 |
| kernel/defs.h | 声明新函数引用 | 编译链接 |
影响范围看起来只有五个文件,但行为影响是整个操作系统层面的:所有 fork、exec、文件读写的用户缓冲路径都会受影响。xv6 自带的usertests全家桶就是靠这些回归测试来验证你没把系统搞坏。
4. 实操落地:关键代码与实现细节
4.1 kalloc.c:引用计数的完整改动
先加计数结构体和锁,在kinit()里初始化锁:
struct { struct spinlock lock; int cnt[PHYSTOP / PGSIZE]; } refcnt; void kinit() { initlock(&kmem.lock, "kmem"); initlock(&refcnt.lock, "refcnt"); freerange(end, (void*)PHYSTOP); }然后是核心的 kfree 改动。关键逻辑是:先对计数做减一,如果减完还大于 0,说明还有其他进程引用这页,直接返回,绝不能释放:
void kfree(void *pa) { struct run *r; if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP) panic("kfree"); acquire(&refcnt.lock); int n = --refcnt.cnt[(uint64)pa / PGSIZE]; release(&refcnt.lock); if(n > 0) return; memset(pa, 1, PGSIZE); // fill with junk to catch dangling refs r = (struct run*)pa; acquire(&kmem.lock); r->next = kmem.freelist; kmem.freelist = r; release(&kmem.lock); }kalloc()里,拿到空闲页后把计数置 1:
void * kalloc(void) { struct run *r; acquire(&kmem.lock); r = kmem.freelist; if(r) kmem.freelist = r->next; release(&kmem.lock); if(r){ memset((char*)r, 5, PGSIZE); acquire(&refcnt.lock); refcnt.cnt[(uint64)r / PGSIZE] = 1; release(&refcnt.lock); } return (void*)r; }再加两个辅助函数,供 vm.c 和 trap.c 调用:
void refcnt_inc(uint64 pa) { acquire(&refcnt.lock); refcnt.cnt[pa / PGSIZE]++; release(&refcnt.lock); } int refcnt_get(uint64 pa) { int n; acquire(&refcnt.lock); n = refcnt.cnt[pa / PGSIZE]; release(&refcnt.lock); return n; }这里有个隐藏的细节我必须单独讲:xv6 的kinit()会调用freerange(),而freerange()内部是靠kfree()把每个物理页初始化进空闲链表的。这时候每页的计数还是 0,--0变成 -1,因为-1 > 0不成立,所以页面仍然正常挂进空闲链表。这个负计数不会有什么实际影响,因为之后kalloc()拿到页时会重置为 1。看到计数变成 -1 不要慌,这是初始化路径的正常副产物。如果实在觉得别扭,也可以在freerange()里先手动给每页计数设 1 再调 kfree,效果一样。
另一个值得强调的点:kfree()里我刻意让refcnt.lock和kmem.lock不嵌套持有——要么先拿先放,要么后拿后放,永远不存在同时持有两把锁的路径。这避免了经典死锁问题,后面第 5 节还会细说。
4.2 vm.c:uvmcopy 改造
uvmcopy 的完整实现如下:
int uvmcopy(pagetable_t old, pagetable_t new, uint64 sz) { pte_t *pte; uint64 pa, i; uint flags; 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); // 可写页转换为 COW 页,父进程原 PTE 也要同步修改 if(flags & PTE_W){ flags = (flags & ~PTE_W) | PTE_COW; *pte = PA2PTE(pa) | flags; } // 把物理页映射进子进程,注意不分配、不拷贝 if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0){ uvmunmap(new, 0, i / PGSIZE, 1); return -1; } refcnt_inc(pa); } return 0; }关于失败路径,我用了uvmunmap(new, 0, i / PGSIZE, 1),第三个参数传 1 表示“解除映射时调用 kfree”。在 COW 版本里这个 kfree 并不会真正释放页,而是做一次引用计数 -1,把我之前循环里每页 +1 的计数抵消掉,这是正确的回滚方式。如果你沿用原始的do_free=1直觉认为这是在“把还没复制完的页还回去”,那在新逻辑里它的语义变成了“释放一次引用”,反而更准确。
4.3 trap.c:usertrap 补页
在usertrap()里,syscall 分支之后增加scause == 15的处理:
} else if(r_scause() == 15){ // store page fault:写 COW 页 uint64 va = PGROUNDDOWN(r_stval()); if(va >= p->sz || va >= MAXVA){ p->killed = 1; } else { pte_t *pte = walk(p->pagetable, va, 0); if(pte == 0 || (*pte & PTE_V) == 0 || (*pte & PTE_COW) == 0){ p->killed = 1; } else { uint64 oldpa = PTE2PA(*pte); uint64 newpa = (uint64)kalloc(); if(newpa == 0){ p->killed = 1; } else { memmove((void*)newpa, (void*)oldpa, PGSIZE); *pte = (PA2PTE(newpa) | (PTE_FLAGS(*pte) & ~PTE_COW) | PTE_W); kfree((void*)oldpa); } } } } else if((which_dev = devintr()) != 0){几个容易踩的点:
va 最好 PGROUNDDOWN 一下。walk()内部会做页对齐处理,不取整其实也能跑,但 PTE 操作和后面的比较统一用页对齐地址更清晰,也不容易埋隐患。
*pte的直接赋值是我推荐的做法。如果你用mappages()去映射新页,它会重新 walk 一遍,而且不会自动帮你处理旧映射;直接用当前 pte 的地址覆盖值,一行解决。注意保留原来的 PTE_U、PTE_R、PTE_X 等标志,只把 PTE_COW 清掉、PTE_W 加回来。
执行顺序必须是“分配新页 → 拷贝 → 改 PTE → kfree 旧页”。尤其不能先 kfree 旧页再 memmove。kfree 在计数归零时会把页面内容用 0x01 填充,你后拷贝的话拷的就是一堆垃圾。这个顺序错误非常隐蔽,而且越是在内存紧张的场景越容易触发。
还有一个细节:这个 Lab 中有人会问,为什么处理完缺页不把p->trapframe->epc += 4?因为缺页指令没执行成功,返回用户态时硬件会从 sepc 重新执行刚才那条 store 指令;现在 PTE 已经可写了,这条指令会正常通过。
4.4 copyout:内核替用户写数据也必须处理 COW
这个是 Lab 里隐藏最深的坑,也是最容易让人卡一整晚的地方。
想想这个场景:fork 之后,父进程调用write(fd, buf, len),其中 buf 指向一块 COW 共享页。系统调用的参数是用户虚拟地址,内核最终靠copyout()把内核缓冲的数据写到用户地址空间去。
问题来了:copyout()是内核代码,通过内核页表直接访问物理地址,根本不经过用户页表的权限检查。也就是说,它不会触发任何 page fault,而是直接往共享物理页里写!这一写,父进程自己的 COW 页被篡改,子进程共享的那份数据也被连带篡改,整个 COW 的隔离性就崩了。
所以必须给copyout()加一层“遇到 COW 页就现场复制”的逻辑,和 usertrap 里的处理几乎一样:
int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len) { uint64 n, va0, pa0; pte_t *pte; while(len > 0){ va0 = PGROUNDDOWN(dstva); if(va0 >= MAXVA) return -1; pte = walk(pagetable, va0, 0); if(pte == 0 || (*pte & PTE_V) == 0) return -1; // 目标页是 COW 页:先复制一份私有的再写 if(*pte & PTE_COW){ uint64 oldpa = PTE2PA(*pte); uint64 newpa = (uint64)kalloc(); if(newpa == 0) return -1; memmove((void*)newpa, (void*)oldpa, PGSIZE); *pte = (PA2PTE(newpa) | (PTE_FLAGS(*pte) & ~PTE_COW) | PTE_W); kfree((void*)oldpa); } pa0 = PTE2PA(*pte); n = PGSIZE - (dstva - va0); if(n > len) n = len; memmove((void *)(pa0 + (dstva - va0)), src, n); len -= n; dstva = va0 + PGSIZE; src += n; } return 0; }你没看错,这里就是把 usertrap 里那段 COW 处理又抄了一遍。为什么不抽个公共函数?因为这两个地方一个在 trap.c 一个在 vm.c,跨文件调用要加声明,而且中间还有锁和错误处理的差异。实际做的时候我建议抽一个类似cow_alloc(pte, va)的工具函数放到 vm.c,省得两边维护两份逻辑,但为了让你先看明白全貌,我就分开列了。
顺带说一句:copyin()和copyinstr()不需要改,因为它们只读用户内存,而 COW 页是可读的,不会触发故障,也不会有写坏共享页的风险。
4.5 跑通测试的验证过程
Lab 会给一个cowtest.c,里面主要是 simpletest 和 threetest。前者验证 fork 后父子各自写页不冲突,后者用三个进程反复写共享页,专门考验并发正确性。
我还会额外写一个最简单的验证逻辑:父进程分配一块内存并写入固定值,fork 之后子进程逐页覆盖写入,然后父进程检查自己的数据是否完好。再用几个usertests里的 fork、exec 相关用例做回归。整个 COW Lab 的评判标准其实就一句话:make grade里 cowtest 全过、usertests 不挂。
5. 常见问题与排查实录
5.1 死锁:两把锁的获取顺序
如果你给每把锁都嵌套持有,很容易写出死锁。比如 kfree 里先拿 kmem.lock 再拿 refcnt.lock,另一个函数反过来先拿 refcnt.lock 再拿 kmem.lock,多核下两个 CPU 各持一把锁等对方释放,直接卡死。
我的建议:refcnt.lock 和 kmem.lock 不要嵌套。要么就是“拿锁 → 改计数 → 放锁”,要么就是“拿锁 → 改链表 → 放锁”,全部分开。这样在任何时刻一个 CPU 只持有其中一把锁,死锁条件中的“持锁等待”就不成立。
如果真的想统一顺序也行,但最省心的就是干脆各自独立,反正计数操作和链表操作都很快,锁粒度细一点也没多少性能损失。
5.2 uvmunmap 的 panic 与引用计数泄漏
一个常见的 panic 是uvmunmap: not mapped。触发原因多半是你对某个 PTE 提前做了修改,或者映射关系在回滚路径里不匹配。排查思路很简单:在 panic 前打印出错时的虚拟地址和 PTE 内容,看是父进程的还是子进程的映射出了问题。
另一个更难发现的 bug 是引用计数泄漏:进程退出了,但物理页还在,free list 越来越短,最后 kalloc 返回 0。泄漏多半来自 uvmcopy 失败路径。原始 uvmcopy 的错误回滚用的是uvmunmap(..., 1),这个传参在 COW 里恰好等价于“抵消之前的 +1”,千万别改成 0,否则每个失败的 fork 都会给一堆页永久 +1,内存迟早耗尽。
5.3 数据神秘“穿越”:父子互相污染
如果你写完整个 Lab 发现simpletest就挂了,比如子进程写完数据父进程读到了脏值,十有八九是这两个原因之一:
第一,uvmcopy 里忘了修改父进程原 PTE。父进程那页还是可写的,父进程一写,共享页直接变了,子进程全遭殃。检查点:fork 之后父进程页表里所有原可写页应该 PTE_W == 0 且 PTE_COW == 1。
第二,copyout 没处理 COW。这个症状很妖——可能普通的内存写测试都过了,但任何一个使用write系统调用往用户空间缓冲区输出的程序都会数据错乱,比如 printf 输出乱码。如果你发现“用户态直接写没问题,一调系统调用就崩”,优先怀疑这里。
5.4 kfree 的负计数与 freerange
前面说过,freerange()在系统启动时用 kfree 初始化空闲链表,此时计数从 0 减到 -1。这个值本身无伤大雅,但如果你在 kfree 里写了if(n <= 0) return;这样“防御性过强”的代码,就会把启动过程卡死——因为所有物理页都进不了空闲链表,系统直接开不了机。
血的教训:kfree 里判断提前返回的条件是n > 0,不是n <= 0。把 freerange 这个初始化路径单独想清楚,你就不会再在这个低级问题上浪费一晚上。
5.5 并发写共享页会不会 race
我做完之后自己问过:父子两个 CPU 同时写同一个 COW 页,会不会 race?理论上不会,因为两个进程各自触发缺页、各自分配各自的新页,然后各自把 PTE 指向自己的新页,旧页计数减两次。唯一共享的变量是 refcnt 数组,有锁保护,不会重入出错。真正需要小心的是 memmove 从旧页拷贝时,另一个 CPU 会不会也正在拷贝同一页——也不会,因为双方都是只读旧页,多读一页没问题。
如果你在此之上想再做一层“同一个物理页同一时刻只让一个 CPU 复制”的优化,那才是引入复杂并发控制的开始,Lab 本身不做这个要求。
5.6 写完之后的自我检查清单
我每次提交前都会对着这几个问题自查:
- 所有原可写页在 fork 后,父子 PTE 都满足“PTE_W 清 0、PTE_COW 置 1”吗?
- 引用计数在 kalloc、uvmcopy、uvmunmap、kfree 之间严格守恒吗?
- usertrap 的 store fault 分支里,分配失败真的会把进程杀掉吗?
- copyout 对 COW 页的处理跟 usertrap 一致吗?
make grade的 cowtest 和 usertests 结果如何?
把这五条都过一遍,Lab 基本就稳了。
这个 Lab 做完之后我最大的体会是:COW 的核心代码加起来可能不超过一百行,但难的是把整个内存生命周期串成一条逻辑链。从 PTE 的一个标志位,到物理页的引用计数,再到缺页异常和系统调用的双向边界,每一步都有它存在的理由。尤其是 copyout 那个坑,如果只看题目描述很容易漏掉,它提醒我一件事:操作系统里“内核读写用户内存”从来不是理所当然的,只要有共享页在,边界上的每一个入口都得重新过一遍安全的脑子。
最后再分享一个小技巧:调试这种 Lab 时,与其看各种 printf,不如直接在关键路径上临时挂panic("xxx")来快速定位是哪条路径出了问题。比如在 usertrap 的 store fault 分支里每个 if 分支都打上不同标记,跑一次 cowtest 看 panic 落在哪个分支,基本就能锁定是 pte 找错了、计数错了还是内存分配失败了。定位问题靠二分法,修问题靠一张草稿纸把引用计数的变化路径画清楚,这两招比任何调试器都好使。