资讯动态

MIT6.S081 Lab3页表实验深度解析:从vmprint到per-process内核页表

发布时间:2026/10/1 11:17:17 来源:尧图企业网站定制
Mit6.S081的Lab 3应该是整个课程里第一个让我真正卡住的实验。前两个实验Util和Syscall虽然也有难度但本质上还是在已有的框架里加功能、加系统调用顺着cshell和trace的提示一步步做总能磨出来。到了Page tables这关情况完全变了你需要在操作系统最底层的内存管理机制上动刀而且一改就是全局性的连锁反应。我记得当时第一次打开kernel/vm.c看着那一堆PTE2PA、walk、kvmmap的宏和函数第一反应是这真的是一个两周内能做完的实验吗。这篇文章就是给准备做或者正在做这个实验的同学看的。我会把Lab 3的三个Task当成一条完整的技术链路来拆解先讲清楚页表翻译的底层机制再逐个击破vmprint、per-process kernel page table、copyin_new这三个关卡最后把我踩过的坑和排查思路完整记录下来。不管你是刚做完Lab 2准备开新坑还是卡在某个Task里出不来这篇文章应该能帮你省下大量对着RISC-V手册发呆的时间。1. 实验3到底在考什么三个Task其实是一条线1.1 为什么这个实验难倒了一大片人先说结论Lab 3难不是难在代码量大而是难在它要求你建立一个完整的地址翻译心智模型。前两个实验你只要知道系统调用会从用户态陷入内核态然后根据系统调用号执行对应的内核函数就能做题。但页表这个东西它横跨了硬件RISC-V MMU、内核xv6的内存管理代码、用户进程地址空间的分配与释放三个层次任何一个环节理解不到位写出来的代码就会以各种匪夷所思的方式崩溃。我见过很多同学卡在Task 2里出不来症状非常统一改完代码后一运行就panic: kvminithart或者直接qemu重启。这种情况十有八九是内核页表的映射关系没搞对而根因往往是他们对内核页表和用户页表到底是什么关系这个概念没吃透。所以这篇文章我会花不少篇幅先把机制讲明白再上代码。1.2 三个Task的内在逻辑链表面上Lab 3的三个Task是三个独立的功能点Task 1写一个vmprint函数打印进程的页表结构Task 2给每个进程分配一个独立的内核页表并在调度时切换Task 3利用Task 2的内核页表简化copyin和copyinstr的实现但实际做完你会发现它们的逻辑是层层递进的。Task 1逼你把页表的三级结构彻底搞清楚否则你连递归打印都写不对。Task 2让你理解内核页表不是天生的全局唯一而是可以per-process的这为Task 3铺路。Task 3则是把Task 2改造的成果用起来既然每个进程的内核页表里已经包含了它自己的用户地址空间映射那copyin就不用再手动遍历用户页表了。用一个类比来帮助理解页表就像一本字典的索引。原始xv6里内核只有一本全局字典global kernel page table想查某个用户进程的单词虚拟地址得先找到那个进程自己的小字典user page table再翻页。Task 2相当于给每个进程配了一本合并字典per-process kernel page table里面既有全局的内核词条也有这个进程自己的用户词条。Task 3就是基于这本合并字典把查单词这件事简化了。2. 动手前必须吃透的页表底层机制SV39与xv6的walk2.1 SV39地址格式27位虚拟地址如何拆成三段RISC-V的Sv39模式意味着虚拟地址是39位的。xv6里任何一个用户虚拟地址在MMU眼里都被拆成四部分| 63--39 | 38--30 | 29--21 | 20--12 | 11--0 | | 未使用 | VPN[2] | VPN[1] | VPN[0] | offset |高27位38到12位被分成三组每组9位分别作为三级页表的索引。低12位是页内偏移一页是4096字节所以2的12次方刚好是一页的大小。每个页表项PTE是64位的其中低10位是标志位第10到53位是物理页号PPN高10位保留。我们最关心的标志位是这几个PTE_Vbit 0有效位表示这个PTE是否可用PTE_R/W/Xbit 1-3读/写/执行权限PTE_Ubit 4用户态可访问标志。注意如果这个位是1S-mode内核态默认是不能访问的除非设置sstatus的SUM位或者把U位清掉为什么虚拟地址要拆成三段而不是直接用一个大的页表最直接的原因是节省内存。如果只用一级页表4GB地址空间就得有上百万个页表项每个进程都维护这么一张表内存直接爆炸。三级页表的好处是顶层页表只需要512个条目而且很多二级、三级页表可以按需分配不用的地址范围根本不占内存。2.2 walk函数MMU翻页的软件模拟walk是xv6里最核心的页表遍历函数它的作用是根据一个虚拟地址找到对应PTE的地址注意是PTE的地址不是物理地址。代码如下pte_t * walk(pagetable_t pagetable, uint64 va, int alloc) { if(va MAXVA) panic(walk); for(int level 2; level 0; level--) { pte_t *pte pagetable[PX(level, va)]; if(*pte PTE_V) { pagetable (pagetable_t)PTE2PA(*pte); } else { if(!alloc || (pagetable (pde_t*)kalloc()) 0) return 0; memset(pagetable, 0, PGSIZE); *pte PA2PTE(pagetable) | PTE_V; } } return pagetable[PX(0, va)]; }这个函数初看会有个很大的疑惑pagetable这个变量一会儿被当作数组用pagetable[PX(level, va)]一会儿被赋值为PTE2PA(*pte)它到底是虚拟地址还是物理地址答案是在xv6里内核地址空间的大部分区域采用恒等映射vapa所以物理地址可以直接当虚拟地址来访问。pagetable变量存放的是页表页的物理地址但因为恒等映射CPU访问这个地址时MMU翻译出来的还是同一个地址所以代码能正常工作。这是一个很tricky的设计理解不了的话后面调试会很痛苦。PX(level, va)宏是将虚拟地址右移12 9 * level位再取低9位得到对应级别的索引。walk从level2开始逐级往下找。如果某级PTE的V位为0且alloc为1就分配一个新的页表页并挂上去。最后返回最底层的PTE地址。2.3 叶子节点判断R/W/X位才是关键做Task 1的vmprint时你需要判断一个PTE指向的是下一级页表还是最终映射的物理页。最直接的判断方式是看PTE_R | PTE_W | PTE_X这三个位如果三个位都是0说明这个PTE不是叶子节点它指向的是一个下一级页表如果只要有一个位是1说明这就是一个叶子PTE它指向真正的物理页。原理很简单RISC-V硬件规定叶子PTE必须至少设置R/W/X中的一个权限位。而中间层级的页表项这三个位必须全为0。所以代码里可以用一个位运算快速判断#define PTE_LEAF (PTE_R | PTE_W | PTE_X) if ((pte PTE_V) (pte PTE_LEAF) 0) { // 这个PTE指向下一级页表 }这个技巧在实现vmprint和后面的per-process内核页表释放函数里都会用到。如果你用walk配合PTE2PA去找下一级页表也能达到目的但直接判断位标志会更直观。3. Task 1vmprint的递归实现与格式化输出3.1 在exec中挂钩子为什么选pid1Task 1的需求很明确写一个vmprint(pagetable_t)函数打印页表内容并且当init进程pid1执行时在内核返回用户空间之前调用它。为什么要选init进程因为init是第一个用户进程它的页表会映射一个简单的用户程序页表结构相对清晰适合用来验证输出。而且init进程不会退出打印出来的信息稳定可复现。在kernel/exec.c的exec函数末尾找到返回用户空间之前的代码if(p-pid 1) { vmprint(p-pagetable); }这样在init进程通过exec加载用户程序后vmprint就会把它的完整页表打印出来。注意p-pagetable是用户页表不是内核页表。3.2 递归打印和官方格式对不齐的问题我的vmprint实现是这样的void vmprint(pagetable_t pagetable) { printf(page table %p\n, pagetable); vmprint_rec(pagetable, 0); } void vmprint_rec(pagetable_t pagetable, int depth) { for(int i 0; i 512; i) { pte_t pte pagetable[i]; if(pte PTE_V) { uint64 child PTE2PA(pte); // 打印缩进 for(int j 0; j depth; j) { printf(..); if(j depth) printf( ); } printf(%d: pte %p pa %p\n, i, pte, child); if((pte (PTE_R|PTE_W|PTE_X)) 0) { vmprint_rec((pagetable_t)child, depth 1); } } } }这里最关键的是叶子节点的判断正如前面说的通过R/W/X位判断是否继续递归。很多同学第一版会把递归条件写成if (walk(pagetable, va, 0) ! 0)不仅复杂而且容易出错。输出格式上官方测试pgtbltest会检查特定行最坑的是缩进要求。官方格式是每级缩进两个点..并且点和数字之间没有多余空格。我第一次实现时在缩进后面多打了一个空格make grade直接报格式错误。所以建议严格按照官方示例对齐page table 0x0000000087f6e000 .. 0: pte 0x0000000021fda801 pa 0x0000000087f6a000 .. 1: pte 0x0000000021fda401 pa 0x0000000087f69000 .. .. 0: pte 0x0000000021fd9f1f pa 0x0000000087f67c003.3 一个小坑打印物理地址要用%pxv6的printf对%p的支持是把uint64按十六进制打印直接传pte和PA就行。我见有人用一个char buf[16]自己格式化完全没有必要。printf本身就能正确处理64位值别在这上面浪费时间。Task 1整体是比较友好的热身但它强迫你把walk和PTE结构完全搞懂。如果你在写vmprint时还需要反复翻riscv.h里的宏定义那说明基础还没打牢建议先把第2章内容再过一遍。4. Task 2每个进程独立内核页表引发的架构改动4.1 为什么原始的全局内核页表不够用xv6原始设计里内核只有一个全局kernel_pagetable所有进程的内核态都共享这一份映射。这在功能上没有任何问题毕竟内核地址空间的映射是固定的。但它带来一个限制内核态想访问用户进程的地址空间必须通过copyin/copyout这类函数在软件层手动遍历用户页表找到物理地址后再搬运数据。这个过程既繁琐又不是很高雅。Task 2的思路是给每个进程维护一个自带用户映射的内核页表。这样内核在运行某个进程时如果satp指向该进程的内核页表那么内核代码不仅能看到内核自身的映射设备、内核代码、物理内存还能直接看到该进程的用户地址空间映射。这个改动是Task 3的基石——copyin_new之所以能简化本质上就是因为它不再需要从用户页表里手动翻译地址而是直接在内核页表里就能查到了。4.2 struct proc的改动与页表生命周期管理第一步是在kernel/proc.h的struct proc里加一个字段struct proc { // ... pagetable_t pagetable; // 用户页表已经存在 pagetable_t kernel_pagetable; // 每个进程独立的内核页表新增 // ... };然后在kernel/proc.c的allocproc里为进程创建内核页表。一种干净的做法是封装一个proc_kernel_pagetable函数pagetable_t proc_kernel_pagetable(void) { pagetable_t kpt uvmcreate(); if (kpt 0) return 0; // 复制全局kernel_pagetable的所有内核映射 vmcopy(kernel_pagetable, kpt, 0, (uint64)PHYSTOP); // 同时还需要映射CLINT、PLIC、UART0等设备区域 // 这些地址都低于KERNBASEvmcopy时需要单独处理 return kpt; }这里有个值得注意的取舍为什么不用kvmmap把所有内核区域重新映射一遍因为那样要维护一大串设备地址和etext之类的边界值代码很丑而且漏一个就崩。更好的做法是直接写一个递归复制函数遍历全局kernel_pagetable的每一级PTE把有效的项复制到新的内核页表里。内核映射大部分是恒等映射所以复制PTE本身即可不需要重新翻译地址。具体递归复制函数比较长但逻辑其实和vmprint类似遍历PTE如果非叶子就递归往下复制如果是叶子就直接把PTE原样拷过去。这样能保证新内核页表和全局内核页表在内容上完全一致。4.3 内核栈映射不能用procinit里那套了这是Task 2最容易踩的坑之一我再强调一遍。原始xv6在procinit里给每个进程分配内核栈并且用kvmmap把这些栈映射到全局内核页表uint64 va KSTACK((int) (p - proc)); kvmmap(va, (uint64)p-kstack, PGSIZE, PTE_R | PTE_W);问题在于现在每个进程有自己的内核页表你不能在一个统一的地方kvmmap了否则所有进程的内核页表都会包含所有进程的内核栈映射这倒不是致命错误但会破坏每个进程的内核页表只包含自己的映射这个设计初衷。正确做法是procinit仍然负责分配kstack的物理内存但把kvmmap这段删掉改成在allocproc创建进程内核页表时只把当前进程自己的kstack映射进去// allocproc中 kvmmap_p(p-kernel_pagetable, KSTACK((int)(p - proc)), (uint64)p-kstack, PGSIZE, PTE_R | PTE_W);注意这里映射的虚拟地址仍然是KSTACK(p - proc)。原始的KSTACK宏定义为TRAMPOLINE - ((p)1)* 2*PGSIZE也就是说每个进程的内核栈在虚拟地址空间里是有独立区间的所以即使每个进程的内核页表只映射自己的栈也不会冲突。4.4 scheduler中切换satp的正确姿势改完进程内核页表的创建接下来的重头戏是scheduler。当一个进程被调度到CPU上运行时我们需要把satp寄存器切换成该进程的内核页表这样才能让MMU把地址翻译到正确的映射上。同时切换后要执行sfence.vma刷新TLB因为TLB里可能还缓存着上一个进程的地址翻译结果。// scheduler中 if(p-state RUNNABLE) { p-state RUNNING; c-proc p; w_satp(MAKE_SATP(p-kernel_pagetable)); sfence_vma(); swtch(c-context, p-context); // 回到全局内核页表 kvminithart(); }这里最关键的是swtch返回后也就是当前进程让出CPU后必须把satp切回全局kernel_pagetable。否则接下来scheduler要继续找下一个RUNNABLE进程如果此时MAP没切回去访问的内核地址可能落在错误进程的内核页表映射里轻则page fault重则直接panic。4.5 释放进程内核页表的坑只能释放页表页不能释放物理页进程退出时freeproc需要释放进程的用户页表和内核页表。用户页表的释放沿用原来的proc_freepagetable它会递归释放页表页并释放底层的物理页。而内核页表的释放则需要格外小心内核页表里的很多映射设备区域、内核代码段、物理内存恒等映射并不归这个进程所有如果同步释放这些物理页系统瞬间崩溃。正确的proc_freekernel_pagetable只释放页表页本身不碰底层的物理页void proc_freekernel_pagetable(pagetable_t pagetable) { // 遍历所有PTE // 如果指向下一级页表递归释放 // 最后kfree这个页表页 }判断指向下一级页表的方法仍然是用R/W/X位是否为0。最底层的叶子PTE虽然指向物理页但这个物理页的所有权在用户页表那边或者属于内核全局映射不能在这里kfree。我在第一次实现时偷懒直接对内核页表的每个PTE调用kfree((void*)PTE2PA(pte))结果系统在第一个进程退出时就panic了。后来才意识到内核页表只是借用了这些物理页的映射并不拥有它们。5. Task 3copyin_new与用户映射同步的关键设计5.1 copyin原本的低效之处在哪里先看看原始的copyin实现int copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len) { uint64 n, va0, pa0; while(len 0) { va0 PGROUNDDOWN(srcva); pa0 walkaddr(pagetable, va0); if(pa0 0) return -1; n PGSIZE - (srcva - va0); if(n len) n len; memmove(dst, (void *)(pa0 (srcva - va0)), n); srcva va0 PGSIZE; dst n; len - n; } return 0; }它需要传入用户页表然后对每个页面调用walkaddr去查找物理地址再手动处理跨页边界。这个过程本身没有错但既然Task 2已经让内核页表包含了用户地址空间的映射那sget相关代码再走一套独立的地址翻译逻辑就显得多余。5.2 PTE_U标志是最大的坑Task 3的思路是修改copyin和copyinstr让它们使用当前进程的内核页表来翻译地址而不是用户页表。最直接的做法是写一个新的copyin_new它仍然使用walkaddr但传入的是p-kernel_pagetableint copyin_new(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len) { // 这里的pagetable实际传入的是p-kernel_pagetable // 其他逻辑和原copyin一样 uint64 n, va0, pa0; while(len 0) { va0 PGROUNDDOWN(srcva); pa0 walkaddr(pagetable, va0); if(pa0 0) return -1; n PGSIZE - (srcva - va0); if(n len) n len; memmove(dst, (void *)(pa0 (srcva - va0)), n); srcva va0 PGSIZE; dst n; len - n; } return 0; }听起来简单但这里面藏着一个致命的细节walkaddr在遍历页表时会检查PTE_U标志吗实际上walkaddr只检查PTE_V不关心PTE_U。所以在内核页表里walk用户地址时只要PTE的V位为1就能拿到物理地址。但在某些版本的实现里walkaddr会检查(*pte PTE_U) 0因为从内核态访问用户页时RISC-V硬件要求PTE_U必须为0除非设置SUM位。所以如果你把用户页表的PTE原样复制到内核页表那些PTE_U1的PTE会让内核态的MMU访问直接触发权限异常。解决方法是在把用户映射同步到内核页表时将PTE中的PTE_U位清零。这通常在uvmalloc等函数里完成。这里也提醒一下xv6的walkaddr不同版本可能有差异做实验时要看清自己版本的实现。5.3 sbrk、fork、exec全链路同步用户映射Task 3最难的部分不是copyin_new本身而是保证进程的地址空间发生变化时内核页表里的用户映射能同步更新。进程地址空间的变化主要出现在这几个地方forkuvmcopy会复制一份用户页表同时也需要在子进程的内核页表里复制对应的用户映射exec加载新程序时会建立新的用户地址空间需要同步更新内核页表sbrkuvmalloc和uvmdealloc会增减用户内存需要同步到内核页表我当时的做法是在vm.c里新增一个辅助函数专门把某个用户页表的映射同步到指定的内核页表void update_kernel_pagetable(struct proc *p) { // 把p-pagetable中的用户映射复制到p-kernel_pagetable // 同时清除PTE_U位 }然后在uvmalloc、uvmdealloc、exec、fork这些关键路径上调用它。这样做的好处是集中管理逻辑清晰坏处是容易漏掉某个调用点。如果你在usertests里跑挂了大概率就是某个地址空间变更路径漏了同步。5.4 一个更省事的方案在copyin_new里动态建立映射有些参考实现选择不在uvmalloc里做同步而是在copyin_new被调用时动态地把用户页表的PTE复制到内核页表并清除U位。这个方案的优点是改动集中缺点是每次copyin都要遍历两层页表性能上差一点而且还要处理反复映射、TLB刷新之类的问题。我个人不太推荐这个方案因为它的调试难度更高。你想想如果一个地址被内核页表映射过一次后面用户进程又把这块内存munmap了内核页表里的旧映射就成了一个悬空映射下次copyin用的时候可能访问到已经释放的物理页这种bug非常难查。相比之下在地址空间变更的源头做同步语义上更清晰。6. 测试排错记录从make grade失败到usertests全绿6.1 自测方法比make grade多走一步make grade是最终验收标准但如果你每次都等到所有代码写完再跑它出了问题会非常难定位。我建议整个Lab 3期间配合两个工具自测第一是vmprint。Task 1做完后make qemu启动时init进程的页表会自动打印。你可以肉眼检查输出是否合理正常情况下应该有三层结构页表项的数量不会太多。如果发现哪一级页表项数量异常基本可以断定vmprint的递归逻辑有问题。第二是pgtbltest。这是官方专门为这个实验准备的测试程序跑make grade之前可以单独运行pgtbltest。它会测试vmprint的格式、Task 2的内核页表切换、Task 3的copyin行为。如果pgtbltest挂了直接看它打印的测试用例名就能定位到具体是哪个Task的问题。6.2 三个高发问题的排查过程问题一panic: kvminithart。这几乎是Task 2最常见的崩溃。kvminithart的作用是把kernel_pagetable加载进satp。如果在allocproc创建进程内核页表之前某个地方提前调用了w_satp指向一个不完整的内核页表就会panic。我当时遇到这个问题的原因是在scheduler里切换satp后swtch返回时忘了切回全局内核页表导致下次调度时kvminithart加载了一个已经释放的进程内核页表。排查方法是在kvminithart里加打印看看触发panic时satp的值是多少再反推是哪个进程的内核页表。问题二usertests的copyin测试失败。这个多半是Task 3的映射同步不完整。我当时的排查方法是在walkaddr里临时加一个打印输出查询的虚拟地址和结果。如果发现某些用户地址在内核页表里查不到就去检查uvmalloc是否同步了。另外注意fork出来的子进程它的内核页表必须也复制父进程的用户映射这个很容易漏。问题三进程退出时内存泄漏。freeproc里如果只释放了用户页表忘了释放内核页表系统跑久了可用内存会越来越少。xv6没有现成的内存泄漏检测工具我是在kalloc里加了一个计数器每分配一次加一每释放一次减一。跑完usertests后如果计数不为0就说明有泄漏再结合freeproc的调用路径逐个排查。6.3 调试中的几条实战心得第一sfence.vma该刷就刷。xv6在kvmswitch这类函数里手动调用了sfence_vma()但你如果在自己的代码里改了某个进程内核页表的映射比如同步用户映射时最好也主动刷一下TLB否则MMU可能还在用缓存的旧翻译结果。第二不要怕加临时打印。很多人觉得加打印low但xv6这种裸机环境里printf就是最有效的调试手段。我经常会在关键路径上打印页表指针、虚拟地址、PTE值跑一轮测试后根据输出定位问题效率远高于对着代码干瞪眼。第三做完Task 2一定要先把usertests跑了再开Task 3。我见过不少同学做完Task 2就开始改copyin结果跑usertests时一堆莫名其妙的问题混在一起根本分不清是Task 2的锅还是Task 3的锅。先确保Task 2的改动在usertests下全绿再进行下一步能省下大量排查时间。Lab 3这个实验做完之后最大的感受是页表不是孤立的概念它贯穿了进程生命周期、调度、内存分配和系统调用处理的每一个环节。你现在花在这些机制上的时间在后面Lab 4traps、Lab 5COW里都会加倍还回来。尤其是per-process kernel page table和PTE_U的处理方式在后面的实验里会反复用到。如果你做完了还是觉得某些细节模糊建议把vm.c整个文件多读两遍再配合RISC-V特权手册的Sv39章节把每一行代码和硬件行为对应上。这关过了后面几周你会顺畅很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑