☰
MIT6.S081 Lab3页表实验深度解析:从vmprint到per-process内核页表
2026/10/1 11:17:11 网站建设 项目流程

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的walk

2.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_V(bit 0):有效位,表示这个PTE是否可用
  • PTE_R/W/X(bit 1-3):读/写/执行权限
  • PTE_U(bit 4):用户态可访问标志。注意,如果这个位是1,S-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里,内核地址空间的大部分区域采用恒等映射(va=pa),所以物理地址可以直接当虚拟地址来访问。pagetable变量存放的是页表页的物理地址,但因为恒等映射,CPU访问这个地址时,MMU翻译出来的还是同一个地址,所以代码能正常工作。这是一个很tricky的设计,理解不了的话后面调试会很痛苦。

PX(level, va)宏是将虚拟地址右移12 + 9 * level位,再取低9位,得到对应级别的索引。walk从level=2开始,逐级往下找。如果某级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 1:vmprint的递归实现与格式化输出

3.1 在exec中挂钩子:为什么选pid==1

Task 1的需求很明确:写一个vmprint(pagetable_t)函数,打印页表内容,并且当init进程(pid==1)执行时,在内核返回用户空间之前调用它。

为什么要选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 0x0000000087f67c00

3.3 一个小坑:打印物理地址要用%p

xv6的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等设备区域 // 这些地址都低于KERNBASE,vmcopy时需要单独处理 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 3:copyin_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_pagetable:

int 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_U=1的PTE会让内核态的MMU访问直接触发权限异常。

解决方法是:在把用户映射同步到内核页表时,将PTE中的PTE_U位清零。这通常在uvmalloc等函数里完成。这里也提醒一下,xv6的walkaddr不同版本可能有差异,做实验时要看清自己版本的实现。

5.3 sbrk、fork、exec全链路同步用户映射

Task 3最难的部分不是copyin_new本身,而是保证进程的地址空间发生变化时,内核页表里的用户映射能同步更新。进程地址空间的变化主要出现在这几个地方:

  • fork:uvmcopy会复制一份用户页表,同时也需要在子进程的内核页表里复制对应的用户映射
  • exec:加载新程序时会建立新的用户地址空间,需要同步更新内核页表
  • sbrk:uvmalloc和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 4(traps)、Lab 5(COW)里都会加倍还回来。尤其是per-process kernel page table和PTE_U的处理方式,在后面的实验里会反复用到。如果你做完了还是觉得某些细节模糊,建议把vm.c整个文件多读两遍,再配合RISC-V特权手册的Sv39章节,把每一行代码和硬件行为对应上。这关过了,后面几周你会顺畅很多。

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

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

立即咨询