ARTICLE DETAIL

资讯详情

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

xv6 lab6 COW实验全解析:写时复制、页表与缺页中断

xv6 lab6 COW实验全解析:写时复制、页表与缺页中断 “xv6 lab6 cow”这个实验是 6.S081 系列里公认最考验“把地址空间和物理内存打通”理解的一个。我见过太多人卡在这里不是不懂 COWCopy-On-Write写时复制的概念而是栽在 riscv64 页表标志位、物理页引用计数、以及缺页中断返回路径这些细节上。这篇文章我就用自己的完整实现过程把这个 lab 从原理到代码、再到排查思路彻底讲透。内容会覆盖 fork 为什么昂贵、写时复制真正要解决的问题、五处核心代码改动、以及 cowtest 之外容易被隐藏用例拷打的边界情况适合正在写 lab6 的同学也适合想真正搞懂缺页中断和页表机制的读者。1. 为什么 xv6 要引入写时复制——fork 的昂贵代价与核心思路1.1 复制整个地址空间到底有多浪费先看 xv6 原本的 fork 逻辑。用户进程调用 fork 时内核会走uvmcopy把父进程的用户地址空间从虚拟地址 0 到p-sz逐页复制一遍。每复制一页都要执行一次kalloc()分配新的物理页再通过memmove把原页面内容完整搬过去。这里的问题不在于复制本身而在于复制的“时机”和“范围”。子进程 fork 出来之后绝大多数情况下会立刻调用 exec 加载新的程序exec 的核心动作之一是释放旧地址空间也就是说刚刚辛辛苦苦复制出来的那一堆页面瞬间就全废了。就算子进程不 exec父子进程在 fork 之后往往也只是各自修改一小部分页面其他页面完全可以安全地共享。全量复制在这种场景下是纯浪费。我在做实验的时候算过一笔账一个进程如果地址空间有 8MBfork 一次就要复制 2048 个页面。如果是 4KB 一页、每页 memmove 拷贝 4096 字节那就是将近 8MB 的内存搬运。而实际程序里绝大多数页面是只读代码段、只读常量或者 fork 后从未被修改过的数据页。理想情况下这些页面一个字都不用复制。x86 和 riscv 架构都支持页表项的权限位硬件本身就能阻止对只读页的写入。COW 的思路就是利用这一机制把共享页先标记成“不可写”一旦有人真的试图写就让 CPU 触发缺页异常内核在异常处理里临时复制物理页再让发起写入的那个进程的页表项指向新的物理页并恢复可写权限。1.2 把共享、延迟、按需分配三件事拆开看写时复制表面上是一个机制实际上是把三件事绑在了一起共享父子进程的多个虚拟页映射到同一个物理页不再为子进程分配独立物理页。延迟物理页的复制被推迟到第一次写入时才发生而不是 fork 时就发生。按需分配真正发生写入的进程才获得自己的物理页副本没写入的进程继续共享原页。用个生活化的类比宿舍楼公告栏贴了一张通知所有人都能看没必要给每人复印一份。只有当某个人想在通知上做标记时才去复印一张属于他自己的版本。这个人在自己的版本上随便画其他同学看的仍然是原版。在 xv6 的实现层面这个思路落地成几个具体问题怎么让父子进程共享同一个物理页——修改uvmcopy不再kalloc新页直接让子进程页表项映射到父进程的物理地址。怎么保证写入会触发缺页中断——把父子进程的页表项都去掉PTE_W置为只读。缺页中断来了之后怎么区分“真的写了一个不该写的页”和“写了一个 COW 页”——需要一个额外的软件标志位PTE_COW。物理页被多个进程共享时谁负责真正释放——给每个物理页维护引用计数。后面所有代码改动本质上都是在回答这些问题。1.3 这个实验的验收标准MIT 6.S081 的 lab6 要求实现 COW并跑通cowtest。cowtest会 fork 子进程让父子进程同时向共享页写入检查系统是否能正确处理缺页并且不会破坏数据隔离。cowtest通过只是第一步隐藏的usertests里还有大量 fork、exec、exit、pipe 相关的组合场景对错误路径和引用计数的一致性要求更高。我在做的过程中发现能过cowtest的代码不一定能过usertests能过usertests的代码才算真正理解了 COW。2. 动手之前必须先理清的三件事页表遍历、PTE 标志位、物理页生命周期2.1 Sv39 三级页表和 PTE 标志位怎么玩xv6 运行在 riscv64 的 Sv39 模式下虚拟地址总共 39 位其中低 12 位是页内偏移剩余 27 位分成三级 9 位的索引分别对应 L2、L1、L0 三级页表。每一级页表是 4KB正好放 512 个 8 字节的页表项。PTE 布局里硬件关心的是这些位位名称含义bit 0V页表项是否有效bit 1R可读bit 2W可写bit 3X可执行bit 4U用户态可访问bit 5G全局映射bit 6A已访问bit 7D已写入脏页bit 8-9RSW保留给软件使用COW 标志一般就放在 RSW 的第一位也就是PTE_COW 1L 8。硬件不会对 RSW 位做任何主动操作CPU 在访问页面时只关心 R/W/X/U/V 这些权限位软件想怎么用 RSW 都行。这里有个很重要的细节在 xv6 的PTE_FLAGS宏中会把 PTE 的低 10 位取出来作为标志所以 COW 标志天然包含在 flags 里操作起来很顺手。调试页表时我习惯用 xv6 自带的vmprint工具它会递归打印三级页表结构。修改页表相关代码后先用vmprint看一眼前后差异比直接猜问题快得多。2.2 kalloc/kfree 的物理页所有权模型以及为什么要加引用计数xv6 原本的物理内存管理非常简单kinit把内核末尾到PHYSTOP之间的所有空闲物理页串成一个空闲链表kalloc从链表头取一页kfree把页清空后挂回链表。在原始模型里一个物理页在任意时刻只有一个“所有者”。页被kalloc分配出去之后只有分配它的那个进程的页表会映射它当进程退出或某个映射被解除时会通过uvmunmap找到对应页并kfree。因为所有权唯一所以kfree时直接回收是安全的。COW 打破了这个假设。一个物理页可能同时被父进程、子进程、甚至孙进程的页表映射。如果父进程退出时直接kfree子进程还在用这个页就等于用了一个已经归还给内核的页轻则数据错乱重则直接 panic。所以必须给每个物理页增加一个引用计数记录当前有多少个页表项映射它。只有引用计数降到 0 时才允许真正把页归还给空闲链表。这个改动在 xv6 里很简单用一个全局数组 一把自旋锁就够。数组大小按照物理内存页数来定xv6 默认 KERNBASE 到 PHYSTOP 之间有 128MB4KB 一页就是 32768 项。2.3 walk 与 mappages 的边界行为最容易踩的分配坑walk(pagetable, va, alloc)是页表操作的核心函数。它会从 L2 一路往下找返回最后一次查到的 PTE 指针。如果中间某一级页表不存在且alloc为 1它就会用kalloc分配一个新的页表页并继续向下。这里有一个非常经典的坑在uvmcopy里我们要同时修改父进程的 PTE 以及给子进程建立映射。很多人的第一反应是调两次walk一次walk(old, i, 0)拿父进程 PTE一次walk(new, i, 1)直接写子进程 PTE。这个做法在中间页表已经存在时没问题但一旦子进程地址空间还没有对应的中间目录walk(new, i, 1)会悄悄分配新页表。如果分配失败或后续出错错误路径处理不好就会泄漏页表页。更稳妥的做法是父进程侧用walk(old, i, 0)拿到 PTE 并直接修改子进程侧用mappages(new, i, PGSIZE, pa, flags)来建立映射让mappages内部去处理中间页表的分配。mappages失败时它会负责清理已经创建的部分页表结构错误路径更干净。另外注意mappages内部最终写 PTE 时会自动设置PTE_V。如果你传入的flags里已经带了 COW 标志但没有 W 位它不会额外帮你加 W。这一点在后面写uvmcopy时很关键。3. 五处代码修改让 COW 真正跑起来3.1 riscv.h 里定义 PTE_COW并明确它占用的位打开kernel/riscv.h在 PTE 相关宏定义附近加上#define PTE_COW (1L 8)我建议再顺手加两个辅助宏后面用起来会舒服很多#define PTE2PA(pte) (((pte) 10) 12) #define PA2PTE(pa) ((((uint64)pa) 12) 10) #define PTE_FLAGS(pte) ((pte) 0x3FF)PTE_COW占的是 RSW 位也就是硬件不会去碰的第 8 位。选这一位的原因是它既不影响 CPU 的权限检查又能跟其他标志位一起通过PTE_FLAGS被取出操作起来最自然。3.2 kalloc.c给每个物理页加上引用计数和自旋锁先定义全局数组和锁#define NPAGE ((PHYSTOP - KERNBASE) / PGSIZE) struct spinlock refcnt_lock; int refcnt[NPAGE];kinit里初始化锁并把所有物理页的引用计数初始化成 1。注意这个初始化顺序先设为 1再调kfree这样kfree会把计数减到 0并把这个页真正挂回空闲链表逻辑上自洽。void kinit() { initlock(refcnt_lock, refcnt); freerange(end, (void*)PHYSTOP); } void freerange(void *pa_start, void *pa_end) { char *p; p (char*)PGROUNDUP((uint64)pa_start); for(; p PGSIZE (char*)pa_end; p PGSIZE) { acquire(refcnt_lock); refcnt[(uint64)p / PGSIZE] 1; release(refcnt_lock); kfree(p); } }kfree的核心变化在于不是每次调用都释放页而是先把引用计数减一只有减到 0 才真正把页挂回空闲链表。void kfree(void *pa) { struct run *r; if(((uint64)pa % PGSIZE) ! 0 || (char*)pa end || (uint64)pa PHYSTOP) panic(kfree); int idx (uint64)pa / PGSIZE; acquire(refcnt_lock); if(refcnt[idx] 1) panic(kfree: refcnt 1); refcnt[idx]--; int free_page (refcnt[idx] 0); release(refcnt_lock); if(free_page) { memset(pa, 1, PGSIZE); 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) { acquire(refcnt_lock); refcnt[(uint64)r / PGSIZE] 1; release(refcnt_lock); } return (void*)r; }另外新增一个incref函数供uvmcopy和后续 COW 复制时增加引用计数void incref(void *pa) { acquire(refcnt_lock); refcnt[(uint64)pa / PGSIZE]; release(refcnt_lock); }这里有几个容易犯的错误我在后面踩坑章节会专门展开。先记住核心原则kalloc分配成功时计数必须是 1kfree是递减而不是直接释放incref用于共享映射时增加计数。3.3 vm.c重写 uvmcopy新增 cowpage 和 cowcopyvm.c是整个 lab 的心脏。先重写uvmcopyint 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); if(flags PTE_W) { // 父进程页清除写位并标记 COW *pte (*pte ~PTE_W) | PTE_COW; // 子进程页同样清除写位并标记 COW flags (flags ~PTE_W) | PTE_COW; } if(mappages(new, i, PGSIZE, pa, flags) ! 0) goto err; incref((char*)pa); } return 0; err: return -1; }重点在于uvmcopy不再kalloc而是直接用mappages把父进程物理页映射到子进程页表。修改父进程 PTE 时需要把W位去掉并加上 COW 标志。这里我特意加了个判断if(flags PTE_W)只对可写页做 COW 处理。如果原本就是只读页比如代码段只有 RX就不需要也不应该标记成 COW否则一个本该永远只读的页面会在缺页处理时被复制成可写页破坏只读语义。然后是cowpage函数判断某个虚拟地址对应的页是否是 COW 页int cowpage(pagetable_t pagetable, uint64 va) { pte_t *pte; if(va MAXVA) return 0; va PGROUNDDOWN(va); pte walk(pagetable, va, 0); if(pte 0) return 0; if((*pte PTE_V) 0) return 0; if((*pte PTE_COW) 0) return 0; return 1; }cowcopy是真正执行复制的函数int cowcopy(pagetable_t pagetable, uint64 va) { pte_t *pte; uint64 pa; uint flags; char *mem; if(va MAXVA) return -1; va PGROUNDDOWN(va); pte walk(pagetable, va, 0); if(pte 0) return -1; if((*pte PTE_V) 0) return -1; if((*pte PTE_COW) 0) return -1; pa PTE2PA(*pte); flags PTE_FLAGS(*pte); if((mem kalloc()) 0) return -1; memmove(mem, (char*)pa, PGSIZE); // 新页清除 COW恢复可写 flags (flags | PTE_W) ~PTE_COW; *pte PA2PTE((uint64)mem) | flags; // 旧页引用计数减一可能真正释放 kfree((char*)pa); return 0; }cowcopy的逻辑要注意顺序先分配新页、复制内容再修改 PTE最后kfree旧页。如果先kfree旧页再复制内容万一引用计数减到 0旧页已经被清空复制出来的内容就是错的。3.4 trap.c接管 store page fault拦截 scause15用户程序对 COW 页写入时CPU 触发 store page faultscause的值是 15。xv6 的usertrap会把异常分发给几个分支在合适的位置加上 COW 处理} else if(r_scause() 15) { uint64 va r_stval(); if(cowpage(p-pagetable, va) cowcopy(p-pagetable, va) 0) { // 复制成功回到用户态重放指令 } else { p-killed 1; } }注意r_stval()返回的是触发异常的虚拟地址它不一定是页对齐的所以在cowpage和cowcopy里面都要先PGROUNDDOWN。如果地址非法、不是 COW 页、或者kalloc失败说明这不是一个可以靠复制解决的缺页只能把进程杀掉。这个分支必须在处理其他 trap 的代码之前还是之后xv6 原始usertrap先判断系统调用再判断设备中断剩下的走scause分支。COW 的 store page fault 属于用户态异常放到最后的else if分支里最自然。3.5 copyout内核写用户缓冲区的隐藏路径这是一个非常容易漏掉的修改点。copyout被系统调用用来把内核数据拷贝到用户空间比如write系统调用内部会把用户传入的缓冲区内容拷到内核或者反过来read系统调用会把内核读到的数据拷贝到用户指定的缓冲区。这个拷贝过程是内核态主动写用户页CPU 不会因为你写了 COW 页就触发用户态缺页中断而是直接以机器模式权限执行写入。所以如果copyout的目标地址落在 COW 页上内核就会直接写共享物理页导致父子进程数据互相污染。必须在copyout里显式计算目标页是否是 COW 页是的话先调cowcopy把页复制一份再写int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len) { uint64 n, va0, pa0; while(len 0){ va0 PGROUNDDOWN(dstva); if(va0 MAXVA) return -1; if(cowpage(pagetable, va0) cowcopy(pagetable, va0) 0) return -1; pte_t *pte walk(pagetable, va0, 0); if(pte 0 || (*pte PTE_V) 0) return -1; pa0 PTE2PA(*pte); n PGSIZE - (dstva - va0); if(n len) n len; memmove((void *)(pa0 (dstva - va0)), src, n); dstva n; src n; len - n; } return 0; }这里必须强调cowcopy成功之后pa0可能已经指向了新分配的物理页因为cowcopy内部修改了 PTE。所以不能在调用cowcopy之前就把pa0算出来复用必须重新walk一次并重新取pa0。这个点非常容易导致copyout写到了旧物理地址数据丢失现象很隐蔽。4. 实验中被反复拷打的边界情况我的完整排查链路记录4.1 现象cowtest 卡死先别慌用 vmprint 看页表现状我第一次写完代码运行cowtest时直接卡死。不是 panic就是屏幕上某行输出之后再无动静CPU 占用却拉满。这种卡死的本质是用户态执行 store 指令触发缺页中断usertrap里 COW 处理失败返回用户态后同样的指令再次执行再次触发缺页中断。循环往复看起来就像死循环。排查的第一步永远是确认循环里发生了什么。我在usertrap的 COW 分支前临时加了两行printf打印r_scause()和r_stval()。输出显示scause15一直在重复stval也稳定在某个地址。然后我单独调用cowpage打印返回值发现cowpage返回 0说明这根本不被当成 COW 页。问题就出在 PTE 写入上。我打开vmprint看父子进程的页表发现子进程确实映射了物理页但 PTE 里没有 COW 位。往回一查uvmcopy里flags在调用mappages之前被覆盖了没把 COW 标志传进去。这是个典型低级错误但现象很有迷惑性。所以我的建议是卡死的时候先确认usertrap是不是在反复处理同一个地址再看这个地址的 PTE 长什么样。4.2 引用计数从一次 kfree panic 说起跑usertests的时候我遇到了panic: kfree: refcnt 1。这个 panic 是我自己加在kfree开头的防御性检查。它说明某个物理页的引用计数已经被减到了 0 以下。我排查过的错误模式有三种都值得单独说模式一kalloc里忘了把引用计数置 1。页从空闲链表取出后refcnt还是 0。uvmcopy里incref会把它加 1但cowcopy之后kfree又会把它减掉最后归零时可能被kfree真正释放两次造成空闲链表循环或 panic。模式二uvmcopy中对子进程映射失败时没有正确处理引用计数。如果mappages在映射第 N 页时失败前面已经incref过的页必须通过后续的uvmunmap递减回来。xv6 的fork在uvmcopy返回 -1 后会调用freeproc最终会释放子进程页表但如果你的错误路径没有完整解除已建立的映射引用计数就会偏大内存泄漏如果错误路径里对未映射的地址调用了kfree又会偏小panic。我在 final 版本里对uvmcopy的err分支做了严格处理参考原始实现调用uvmunmap(new, 0, i / PGSIZE, 1)。模式三cowcopy里对旧页过度调用kfree。比如复制成功后原本只应该减一次引用计数但如果代码里既对旧页kfree又调用了某个封装函数导致二次释放就会触发refcnt 1。解决方式是把kfree理解为“递减引用计数”而非“释放内存”盯着refcnt数组的增减是否一一对应不要只关心kfree的调用次数。4.3 COW 标志的继承与普通只读页的边界text 段不能跟着变可写这是一个语义层面的坑隐藏得很深cowtest不会暴露usertests会。很多人写uvmcopy时图省事把所有共享页都一律清除W位并加上PTE_COW。这样做的问题是程序代码段text本身就是只读页RX没有 W。如果你给它加上 COW 标志一旦用户程序因为某种不可描述的原因尝试写代码段缺页处理逻辑会认为这是一个 COW 页于是复制出一份可写副本并把这个进程的 PTE 改成可写。这等于绕过了硬件的只读保护让本来应该 crash 的程序活了下来而且行为不可预测。正确的做法就是我 3.3 节写的只有flags PTE_W的页才做 COW 标记。原本只读的页保持原样继续只读它不需要 COW因为根本不应该有写入动作发生。这个判断同样影响cowcopy里的恢复逻辑复制之后恢复W位没有问题因为能走到cowcopy的页一定是被标记过 COW 的而标记 COW 的前提就是它曾经可写。4.4 copyout 重新取 pa0一个隐蔽的数据错乱问题我前面说过cowcopy成功后会修改 PTE所以copyout里必须重新取pa0。这里再补充一个更隐蔽的版本我在第一版copyout里确实是先调cowcopy再重新walk但我重新walk时用的是原始未对齐的dstva而不是va0。walk的入参虚拟地址索引的是 4KB 对齐的页号如果dstva本身不对齐walk(pagetable, dstva, 0)找到的可能是当前页的下一个页表项。结果就是memmove写到错误的页上。这个 bug 排查了很久最后用printf打印dstva、va0和PTE2PA(*pte)的十六进制值才发现va0和dstva差了一个偏移量。所以copyout的标准姿势是先用va0 PGROUNDDOWN(dstva)对齐后续所有walk都用va0偏移量在最后的memmove里通过dstva - va0计算。4.5 死循环之外的另一类故障进程被误杀有些时候cowtest不会卡死而是输出usertrap(): unexpected scause或者直接“process killed”。我遇到过一种情况是usertrap的 COW 分支写错了条件if(cowpage(...) cowcopy(...) 0) { // ok } else { p-killed 1; }如果cowcopy已经成功但返回了非 0 值或者cowpage因为地址没对齐返回 0都会误杀进程。COW 缺页处理的判定要非常严谨先判断地址是否合法、PTE 是否存在、是否有 COW 标志再调用cowcopycowcopy内部失败才返回 -1。这三步顺序不能乱尤其不能把cowpage和检查va MAXVA合并成一个条件否则会掩盖错误。5. 验证、优化与复盘cowtest 之外还能做什么5.1 cowtest 与 usertests 的正确打开方式代码写完后进入验证阶段。先在 xv6 用户态跑cowtest应该看到simple: ok simple: ok three: ok three: ok three: ok COW test passedcowtest通过后我强烈建议完整跑一遍usertests。这个测试程序会覆盖 fork、exec、pipe、wait、exit 的各种组合对引用计数和错误路径的要求比cowtest高一个量级。很多实现能过cowtest但过不了usertests常见输出是test forkfork: FAILED或者panic: freeing free page。跑usertests的方法是在 xv6 shell 里直接输入usertests如果时间紧张可以指定单个测试项比如usertests forkfork便于快速定位问题。整个过程可能需要几分钟但值得。5.2 一个可选优化引用计数为 1 时直接恢复可写cowcopy的标准实现是无论原始页被多少个进程共享只要发生写入就无条件分配新页、复制内容。但有一种情况其实可以优化如果原始引用计数是 1说明当前只有这个进程映射着该物理页根本没有共享的必要。此时不需要分配新页直接把 PTE 的W位恢复并清除 COW 标志就行。代码很简单if(cowpage(pagetable, va)) { pte_t *pte walk(pagetable, va, 0); int idx (uint64)PTE2PA(*pte) / PGSIZE; acquire(refcnt_lock); int is_sole (refcnt[idx] 1); release(refcnt_lock); if(!is_sole) { // 执行复制流程 } else { *pte (*pte | PTE_W) ~PTE_COW; } }这个优化对 lab 成绩没有影响但理解它有助于加深对“COW 真正代价”的认识写时复制的核心是共享而当一个页已经独享时保护机制本身就成了多余开销撤掉保护即可。Linux 内核里也有类似思路。5.3 做完 lab 之后我对 COW 全貌的理解整个 lab 做下来最大的感受是COW 不是一个孤立的技巧它是“地址空间隔离”和“物理内存复用”之间的一个平衡点。xv6 的原始实现用全量复制换取简单性而 COW 用共享加缺页异常换取效率代价是内存管理和异常处理的复杂度显著上升。这背后的思想在现代操作系统里无处不在。Linux 的 fork 本身就是基于 COW 的mmap 的私有映射、写时复制文件映射也都依赖类似机制。你在 xv6 里手写的cowcopy对应到 Linux 里就是缺页异常处理函数里的do_wp_page你在kfree里维护的引用计数对应到 Linux 里就是物理页的_refcount。从工程角度看这个 lab 教会我最重要的一件事是修改内存管理代码前一定先把“页的一生”完整画出来——从kalloc分配、被页表引用、经历 COW 复制到最后一个引用释放时归还内核。这个生命周期里每一个环节的引用计数都必须严格匹配错一次就会在完全不相干的地方爆炸。如果让我给正在做这个 lab 的人一个实用建议那就是不要一上来就闷头写代码先用 30 分钟把walk、mappages、uvmcopy、usertrap、copyout这五个函数的原始代码通读一遍搞清楚它们各自的入参语义和失败路径。理解了这五条链路COW 的实现就是水到渠成的事。反过来说如果这五个函数你还没看明白照着网上的 patch 抄一遍代码跑通了也大概率讲不出为什么后续遇到隐藏 bug 还是会束手无策。
返回列表