ARTICLE DETAIL

资讯详情

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

MIT6.S081 COW Lab实战:写时复制实现与避坑指南

MIT6.S081 COW Lab实战:写时复制实现与避坑指南 做到MIT6.S081第六个lab终于轮到写时复制COW了。前面几个lab把系统调用、页表、陷阱都过了一遍这个lab差不多是把它们串起来用的。题目要求不算复杂但坑非常多尤其是物理页生命周期和引用计数这两块稍不注意就是usertests跑着跑着突然panic而且报错位置千奇百怪。这篇文章把我从读题到跑通cowtest和usertests的整个过程整理了一遍包括每个修改点的理由、代码里那些微妙的地方、以及我踩过之后才明白的坑给同样在刷这个lab的同学一个参考。1. 先把问题说清楚COW到底解决了什么1.1 fork的老毛病整页复制太浪费没有COW的xv6里fork的流程很简单创建子进程页表然后对父进程地址空间里的每一页调用kalloc分配一块新物理页再用memmove把旧页内容完整拷过去。这一步做完父子进程各自拥有一份完全独立的物理内存。这件事在教科书里看起来天经地义但实际上相当奢侈。绝大多数fork之后紧接着就是execexec会直接把进程地址空间整个换掉前面辛辛苦苦复制出来的那些页面很快就被放弃并释放了。也就是说大量内存带宽和物理页分配都被浪费在“复制马上要被丢弃的数据”上。更麻烦的是即便不exec父子进程通常也只会读写各自的一小部分页面剩下大部分共享只读内容完全可以不复制。写时复制的思路就是针对这个场景做的优化fork的时候不复制物理页只把父子进程的页表项指向同一块物理内存同时把这些页设置为只读。任何一方想要写入时硬件会触发store page fault操作系统在陷阱处理程序里把这一页复制一份出来再放行写入。谁先写谁就触发拷贝拷贝只发生一次而且只针对真正被写的那一页。1.2 写时复制的核心机制要理解COW关键是抓住一个转换把一个“物理内存复制”问题变成一个“页表权限 缺页处理”问题。正常fork是同步拷贝COW则是把拷贝动作延迟到真正冲突的那一刻也就是写入一个只读共享页的时候。具体到xv6里这个机制需要三样东西首先是一个额外的页表标志位PTE_COW用来标记“这一页是COW共享页”。xv6的RISC-V PTE有36个可用位软件自定义标志位随便挑一个就行一般用第8位也就是(1L 8)。其次是让所有共享页在物理上保持只读。直接清掉PTE_W标志这样任何store指令访问这一页都会触发异常。注意这里有个容易被忽略的细节只读页本来就不该变成COW页否则后续行为会变得混乱后面第3节会具体讲。最后是缺页处理程序。当store page fault发生时通过stval寄存器拿到出错虚拟地址检查对应的PTE如果确认是COW页就分配新物理页、复制内容、更新页表为可写如果分配失败就kill进程。1.3 实验验收标准cowtest 与 usertests这个lab的官方测试有两套第一套是cowtest在xv6-labs-2020的user/cowtest.c里专门测COW的基本行为比如fork后写页面、多个进程共享页面是否正常。第二套是usertests这是综合回归测试覆盖了系统调用、文件系统、fork/exec/wait等几乎所有用户态场景。我的建议是cowtest能过不代表lab完成必须usertests全绿才算数。原因很简单cowtest只用最直接的路径覆盖了COW而usertests里藏着大量边角情况比如父子进程同时写共享页、管道读写直接把数据拷到用户缓冲区、uvmcopy中途失败回滚等。很多第一次实现COW的同学cowtest一次通过然后usertests在某个奇怪的地方panic基本都栽在这些边角情况上。2. 动手前必须搞懂的三个关键点2.1 页表结构与walk函数xv6用的是三级页表虚拟地址被切成三个9位的索引最后12位是页内偏移。walk函数的职责就是给定一个页表根地址和虚拟地址返回对应页表项的指针。整个COW实现几乎都是围绕PTE操作展开的所以你必须先对walk非常熟悉。COW lab里用到walk的地方主要有两个一个是uvmcopy里遍历父进程地址空间的每一页用walk拿到父进程页表项再将相同的物理地址映射到子进程另一个是缺页处理时用walk拿到出错地址的PTE检查标志位并更新它。这里有一个非常容易写错但很难发现的点在某些版本xv6的uvmunmap里如果遍历到一个无效PTE会panic报错信息是uvmunmap: not mapped。COW实现如果没处理好引用计数或者fork失败回滚usertests经常就会撞上这个panic而且触发的位置往往让你完全摸不着头脑。2.2 PTE标志位如何安放PTE_COWRISC-V的PTE低10位是硬件定义的标准标志包括V、R、W、X、U等。高位里的空闲位可以随便拿来当软件标志。xv6的kernel/vm.c里原本就用了PTE_U这类标志你需要在kernel/riscv.h里加一行#define PTE_COW (1L 8)选PTE第8位主要是为了避开常用位RISC-V手册说第8到第9位是给软件用的xv6自己的PTE_U也只是用了第4位互不冲突。实际用下来也没问题但也有同学顺手用第9位一样没问题只要别覆盖到硬件保留位就行。页面权限的判断要小心PTE_FLAGS这个宏可以取出PTE的标志位部分修改PTE时最稳妥的写法是把原来的flags取出来改掉需要的位再写回去不要直接整段赋值。否则很容易把V位或R位搞丢导致页表项变成不可用状态。2.3 页面错误的完整处理链路RISC-V中store page fault的scause是15。当用户程序对只读页执行store指令时CPU会陷入内核进入usertrapscause寄存器就是15stval寄存器存放触发异常的虚拟地址。usertrap里的处理顺序很关键。xv6原本在syscall处理之外遇到任何未知scause都会直接杀掉进程。我们只需要在原来的else分支里加一个判断如果scause等于15就尝试走COW展开流程如果展开失败说明这个地址本来就不该写再kill进程也不迟。判断逻辑大概是if (r_scause() 15) { uint64 va r_stval(); if (cow_handle(p-pagetable, va) ! 0) { p-killed 1; } } else { p-killed 1; }这里有个细节值得一说va不一定页对齐。写代码时最好先PGROUNDDOWN(va)或者至少不要依赖va的低12位因为walk只看中间的27位索引。不过即使不向下取整walk也能正确工作因为低位索引天然被忽略。但要读取PTE内容时最好还是按页对齐处理逻辑更清晰。3. 一步一步实现内核侧COW3.1 改造kalloc物理页引用计数先说结论不做引用计数cowtest大概率能过usertests大概率会炸。原因在于COW让多个进程的页表项指向同一个物理页而kfree并不知道这个物理页还被别人引用着。某个进程先exit或者被kill时它会释放自己的页表kfree把共享物理页真的归还给free list其他进程还在用这块内存后面一访问就是use-after-free表现出来就是各种诡异的数据损坏和panic。所以第一步就是在kalloc.c里为每个物理页维护引用计数。简单做法是int refcount[(PHYSTOP - KERNBASE) / PGSIZE];初始化时所有页都是0。kalloc返回一个物理页后把对应槽位置为1。kfree里递减引用计数只有降到0才真正把页面塞回free listvoid kfree(void *pa) { int idx ((uint64)pa - KERNBASE) / PGSIZE; if (refcount[idx] 0) panic(kfree: refcount underflow); refcount[idx]--; if (refcount[idx] 0) return; // 原有释放逻辑 memset(pa, 1, PGSIZE); ... }muti-core环境下refcount的增减存在竞争严格来说需要一把锁保护。xv6的kalloc里已经有锁但kfree又会被uvmunmap在进程退出时调用这时拿锁的安全性和锁粒度都要考虑。实验场景下单核QEMU跑测试问题不大但我还是建议直接用一把新的spinlock保护refcount操作避免多核跑usertests时偶发崩溃。还有一个细节kalloc分配出来的页refcount初始要设置为1。如果之后fork共享给子进程每多一个页表项指向该页refcount就加1。这样kfree被调用两次分别代表父进程和子进程各自的引用释放第二次才会真正回收页面。3.2 修改uvmcopy共享页表映射原始的uvmcopy是逐页kalloc memmove。改成COW版本后逻辑变成遍历父进程每一页如果该页是可写的就清掉PTE_W打上PTE_COW然后把同一个物理地址映射到子进程页表如果该页本来就是只读的则保持原样共享映射。下面是改动后的核心代码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); if (flags PTE_W) { flags (flags ~PTE_W) | PTE_COW; *pte PA2PTE(pa) | flags; } if (mappages(new, i, PGSIZE, pa, flags) ! 0) { uvmunmap(new, 0, i, 1); return -1; } refcount[(pa - KERNBASE) / PGSIZE]; } return 0; }这里必须注意*pte PA2PTE(pa) | flags;这行是在修改父进程页表让父进程随后对共享页的写入也走COW路径。这个顺序很关键一定要在mappages把子进程页表项建立之前完成否则子进程建立映射后父进程还是可写状态逻辑就坏了。另外只给flags PTE_W的页打COW标记。如果父进程某页本身就是只读的比如代码段、字符串常量映射就原样共享不设置PTE_COW。这一点在写测试程序时尤其重要如果你给只读页也加上COW标记之后该页发生store fault时COW处理程序会误判这是COW页疯狂分配内存复制反而掩盖了真正的写保护错误。更糟的是这类只读页可能在exec时与其他映射共用破坏只读属性后整个程序行为都会错乱。3.3 usertrap中响应store page faultusertrap里的修改其实很短核心是cow_handle函数。这个函数做的事情是拿到出错地址的PTE确认确实是PTE_COW标记页然后分配新物理页把旧内容复制过去再更新PTE为可写并去掉COW标记。int cow_handle(pagetable_t pagetable, uint64 va) { pte_t *pte; uint64 pa; uint flags; char *mem; va PGROUNDDOWN(va); if ((pte walk(pagetable, va, 0)) 0) return -1; if ((*pte PTE_V) 0) return -1; if ((*pte PTE_COW) 0) return -1; pa PTE2PA(*pte); if (refcount[(pa - KERNBASE) / PGSIZE] 1) { // 只有自己引用直接改写PTE即可 flags (PTE_FLAGS(*pte) ~PTE_COW) | PTE_W; *pte PA2PTE(pa) | flags; return 0; } if ((mem kalloc()) 0) return -1; memmove(mem, (char*)pa, PGSIZE); kfree((char*)pa); flags (PTE_FLAGS(*pte) ~PTE_COW) | PTE_W; *pte PA2PTE((uint64)mem) | flags; return 0; }参考计数为1时的快速路径很多人会漏掉。如果不加这个优化即使只有当前进程引用某一页也会白白分配并复制一块物理页。虽然功能上没错但浪费了一次kalloc和memmove性能无谓下降。加了快速路径后等于把“最后一个引用进程独占该页”的情况零成本解决。这个优化虽然对测试结果没有直接影响但能帮你养成一个习惯每次分配前先问一句这页真的需要复制吗后续做更复杂的内核优化时会很有帮助。注意kfree旧页这一步因为cow_handle里已经通过refcount判断当前进程不是唯一引用者kfree只会递减引用计数而不会真的释放页面所以旧物理页依然活着供其他进程继续共享只读。等到所有引用者都写完退出后最后一次kfree才会真正把页面物归原主。3.4 copyout容易被漏掉的坑pipe_read和某些系统调用会通过copyout把内核数据写进用户空间缓冲区。copyout走的是内核直接写内存的路径不像用户态store那样经过usertrap所以COW展开逻辑不会自动触发。如果不处理内核会把数据写进一个只读COW页直接触发硬件写保护错误轻则copyout返回失败重则panic。在copyout的开头对每一个目标虚拟地址先判断对应PTE是否带PTE_COW标记如果是就调用cow_handle展开。关键代码形如int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len) { uint64 n, va0, pa0; while (len 0) { va0 PGROUNDDOWN(dstva); if (cow_handle(pagetable, va0) ! 0) return -1; pa0 walkaddr(pagetable, va0); if (pa0 0) return -1; ... } }注意cow_handle的入参pagetable就是你正在操作的那个进程的页表。如果你在copyout里直接写死myproc()-pagetable可能会出问题因为陷入内核时其他内核路径也可能调用copyout。稳妥做法是使用传入的pagetable参数。3.5 关于释放和kfree的收尾细节COW引入了共享物理页进程退出时释放页表的流程也要跟着适配。xv6的进程退出会调用uvmunmap原本它会对每个PTE调用kfree物理页。有了引用计数之后kfree自己会判断要不要真的释放所以uvmunmap本身不需要大改。真正要确保的是uvmcopy里每多映射一次就refcountkfree里每释放一次就refcount--两边严格配平。另一个容易出问题的地方是fork中途失败的回滚。uvmcopy在mappages失败时要调用uvmunmap(new, 0, i, 1)释放已经建立的子进程页表。这个释放过程同样会通过kfree递减引用计数所以只要前面refcount的次数正确就不会泄漏也不会多释放。相反如果忘记维护引用计数哪怕只写漏了一个分支usertests都会在几十个测试之后突然崩掉原因就是某个物理页被提前回收。4. 常见问题与排查心得4.1 典型报错速查表这个lab跑起来最常见的问题我整理成了下面这张表。不一定覆盖所有情况但命中率极高现象可能原因排查方向提示uvmunmap: not mappeduvmcopy失败时回滚不完整或页表项被错误删除检查fork失败路径或者refcount维护是否配平提示panic: kfree: refcount underflow某个物理页被释放次数多于引用次数检查uvmcopy的refcount是否覆盖所有分支cowtest打印FAILPTE_COW标记没有正确设置或者store fault没进COW处理打印PTE标志位确认UV位和COW位usertests在forktest或exec相关测试卡死页表项权限错误比如只读页被误标为COW检查uvmcopy里是否对非W页也加了COW标志copyout失败导致read或write系统调用返回-1copyout没有先处理COW页检查copyout是否调用了cow_handle死循环或无限触发page faultCOW展开后PTE仍是只读或者cow_handle没有清PTE_COW检查cow_handle更新PTE后是否去掉了COW位并置上W位4.2 调试工具与方法内核panic时最好用的办法是直接看panic信息和回归现场。xv6的panic会把当前CPU的栈打印出来但信息量有限。我在调试时最常用的还是print在关键路径上输出虚拟地址、物理地址、PTE标志位、refcount值。一个容易忽略的坑xv6里同一个物理地址通过PA2PTE来回转换DEBUG时最好统一用(uint64)pa十六进制打印别混用指针打印格式否则你可能会被指针算术搞糊涂。另外调usertests这类长时间测试时可以先只跑单个出问题的测试。usertests支持参数指定要执行的测试项比如usertests forkfork就只跑forkfork相关测试。这样定位速度快得多不用每次都从头等几十个测试跑完。4.3 设计取舍引用计数到底值不值得做一开始我图省事想着COW lab不强制要求引用计数能不能只做uvmcopy cow_handle不碰kalloc。跑了cowtest居然通过了。然后跑usertests跑到exec相关测试时开始出现数据错乱再过一会直接panic。原因就是我前面说的use-after-free子进程退出时把共享物理页释放了父进程还攥着那个物理地址在读写。所以我强烈建议引用计数不是可选项而是必须项。即使你侥幸让cowtest过了那也只是测试场景没有覆盖到多进程寿命周期差异。真正可靠的做法是老老实实把refcount加到kalloc.c并且在uvmcopy和kfree里严格配平。代码量不大也就三四十行换来的是整个lab的可靠性和你debug时的心态稳定。不过引用计数的实现也有粗细之分。最懒但是能用的版本是在kalloc.c里声明一个全局数组每次kalloc就置1每次kfree就减1。多核下会有竞争但xv6的测试默认单核跑没问题。稍微严谨一点是加一把锁保护refcount的修改。再进一步还有free list和refcount数组的结合优化但那就超出lab要求了不必过度设计。最后分享一个我调试这个lab时的个人体会COW最难的不是理解概念而是把“每一页现在被多少个页表项引用”这件事随时记在脑子里。页表是天然的多对一结构一个物理页可以被多个PTE同时指向也可能在fork失败回滚时被快速加上又减掉多个引用。只要脑子里始终有一本“引用账本”每个修改点都追问一句这里的引用数变化对吗这个lab的坑基本就不会踩了。如果你正在被usertests的某个panic折磨不妨把报错位置对应的物理页引用追踪一遍多半能一击命中。
返回列表