☰
MIT 6.S081 COW Lab实战:写时复制与缺页异常实现解析
2026/10/3 2:54:58 网站建设 项目流程

如果你已经熬过了 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 中常用的低位定义如下:

位宏含义
0PTE_V页有效
1PTE_R可读
2PTE_W可写
3PTE_X可执行
4PTE_U用户态可访问
5PTE_G全局映射
6PTE_A被访问过
7PTE_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()里我们做这几步:

  1. 读stval拿到故障虚拟地址 va;
  2. 用walk()找到对应 PTE,检查 PTE_V 和 PTE_COW;
  3. kalloc()分配一个全新的物理页;
  4. memmove()把旧页内容复制到新页;
  5. 修改 PTE:把新页映射到当前进程,清除 PTE_COW,补上 PTE_W;
  6. 对旧物理页调用一次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.cusertrap 增加 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 找错了、计数错了还是内存分配失败了。定位问题靠二分法,修问题靠一张草稿纸把引用计数的变化路径画清楚,这两招比任何调试器都好使。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询