☰
深入解析MIT 6.S081页表实验:从虚拟地址到内核页表的设计与实现
2026/10/1 3:37:54 网站建设 项目流程

做实验最怕的是“概念都懂,代码一跑就崩”。MIT 6.S081的实验3(Lab: Page tables)正好属于这种类型:实验目标听着简单,无非是围绕页表做三件事——打印页表、给每个进程配独立内核页表、优化copyin/copyinstr,但只要上手就知道,这里每一步都在逼你理解RISC-V虚拟内存的底层细节。建议正在啃6.S081的同学,或者想彻底搞懂“页表到底怎么工作”的人,认真把这个实验做完,它能帮你把虚拟地址、物理地址、PTE、地址空间切换这些抽象概念全部落到实处。

这个实验适合两类人:一类是正在做MIT 6.S081系列实验的学生,另一类是工作多年但内存管理始终靠“背诵”的开发者。实验本身不需要你写出多复杂的算法,代码量也就几百行,但它要求你弄清楚一个核心问题——当CPU执行一条访问内存的指令时,页表是如何把虚拟地址翻译成物理地址的,以及操作系统应该如何设计和管理这套翻译机制。

1. 实验背景:为什么页表是操作系统的核心话题

1.1 虚拟地址、物理地址和那个“翻译官”

要理解这个实验,先得把页表的本质想清楚。CPU执行指令时发出的地址是虚拟地址,这个地址不能直接去访问内存条上的物理单元,必须经过MMU(内存管理单元)的翻译。而翻译依据的“字典”就是页表。页表里每一行是一个页表项(PTE),记录了某个虚拟页对应哪个物理页,以及该页的访问权限。虚拟地址和物理地址就是这样被页表关联起来的。

一个最直观的类比是酒店的前台。客人报出房间号(虚拟地址),前台查一下登记表(页表),告诉你实际房间在几楼几号(物理地址)。如果没有前台,客人就得自己满楼乱找,既慢又容易串门;如果每个客人都有自己的登记表,那房间之间就彻底隔离了。操作系统里的每个进程都有一张自己的页表,这就是进程地址空间隔离的基础。

RISC-V上的xv6使用的是Sv39分页机制,也就是39位虚拟地址、三级页表、每级9位索引、每页4KB。一个64位的页表项里,有效位(V)、读写执行位(R/W/X)、用户位(U)等标志位负责描述权限,高44位则存放物理页号。虚拟地址翻译的时候,MMU会拿虚拟地址的bit 38-30去查一级页表,拿到二级页表的物理地址;再用bit 29-21去查二级页表,拿到三级页表的物理地址;最后用bit 20-12去查三级页表,得到物理页号,拼接上页内偏移,就是最终的物理地址。

1.2 实验3在6.S081里的位置与难度

6.S081前一个实验是系统调用,后一个实验是陷阱(trap),页表实验刚好卡在中间,起的是承上启下的作用。系统调用实验里,你已经接触了copyin、copyout这些接口,但它们内部通过walkaddr手工翻译用户虚拟地址;到了trap实验,你又会发现用户态和内核态切换时需要切换地址空间、保存寄存器,这些都和页表强相关。实验3就是把这些零散知识点串起来,逼你动手改内核页表。

这个实验的难度不在于代码量,而在于调试。代码写错了,往往不是报一个明确错误,而是启动时直接panic,甚至表现为莫名其妙的中断、缺页、死循环。我见过很多人卡在task2的“释放内核页表”问题上,一释放就panic,不释放就内存泄漏。遇到这种问题,靠肉眼查代码效率很低,必须理解每一张页表里到底映射了哪些物理页,以及这些物理页的生命周期归谁管。

2. 动手之前:必须吃透的几个内核机制

2.1 环境准备与实验命令

MIT 6.S081的实验环境以RISC-V架构的xv6为基础,一般需要准备RISC-V交叉编译工具链、QEMU模拟器,以及可选的gdb-multiarch用于调试。如果你用的是官方提供的开发环境镜像,通常已经装好了这些工具;如果是自己搭环境,最容易踩的坑是工具链版本过旧或路径配置错误。建议先用make qemu跑一遍原始xv6,确认能正常启动到shell,再开始改代码。

做实验的过程中,最常用的命令有三个:

make qemu # 启动xv6,检查运行效果 make grade # 跑全部测试 make GRADEFLAGS=pgtbl grade # 只跑pgtbl相关测试

如果你习惯用gdb调试,可以开两个终端,一个执行make qemu-gdb,另一个执行gdb-multiarch kernel/kernel,连接后设置断点调试。需要注意,QEMU的gdb调试端口默认是26000,连接成功后先执行continue让xv6跑起来,再在合适的位置下断点。

2.2 关键数据结构和函数速查

开始改代码前,建议先把kernel/vm.c和kernel/proc.c从头到尾读一遍。这个实验要用的核心函数基本都在这两个文件里,我整理了一张速查表:

函数/宏作用备注
walk(pagetable_t, uint64 va, int alloc)根据虚拟地址逐级查找PTE,返回PTE指针alloc为1时自动分配缺失的页表页
walkaddr(pagetable_t, uint64 va)返回虚拟地址对应的物理地址找不到映射或权限不足时返回0
mappages(pagetable_t, uint64 va, uint64 size, uint64 pa, int perm)建立一段虚拟地址到物理地址的映射一般按页对齐调用
uvmcreate()创建用户页表根页表只分配一页物理内存
kvminit()创建内核页表,映射内核代码段、设备等lab3中需要改造成可复用的版本
kvmmap(...)向内核页表添加一段映射原实现固定操作全局kernel_pagetable
PTE2PA(pte)/PA2PTE(pa)在PTE和物理地址之间转换本质是位运算
MAKE_SATP(pagetable)生成写入satp寄存器的值物理地址右移12位等

除此之外,PTE的标志位也很关键。xv6中常用的有PTE_V(有效)、PTE_R(可读)、PTE_W(可写)、PTE_X(可执行)、PTE_U(用户态可访问)。实验里判断一个PTE是不是叶子项,通常就看PTE_R | PTE_W | PTE_X这一组标志:如果这三个位全为0,说明它指向的是下一级页表;如果至少有一个为1,说明它指向的是一个物理页,也就是叶子映射。

2.3 RISC-V页表项的位级别真相

很多人写实验时会写错PTE2PA和PA2PTE,就是因为没有理解PTE的位布局。RISC-V的页表项是64位的,低10位是标志位,其中bit 0是V,bit 1是R,bit 2是W,bit 3是X,bit 4是U,bit 5是A(访问),bit 6是D(脏),bit 7是G(全局),bit 8-9留给软件用。bit 10-53是PPN(物理页号),bit 54-63保留。

所以PTE2PA的核心操作是:把PTE右移10位去掉标志位,再左移12位补上页内偏移,得到物理地址。而PA2PTE正好相反:把物理地址右移12位得到物理页号,再左移10位放到PPN的位置。xv6里的宏通常还带掩码操作,但原理就是这个。很多panic都是因为这里移位数搞错了,导致映射到了错误的物理地址,进而触发缺页或非法访问。

3. 任务一:vmprint——把页表“打印”出来

3.1 到底要输出什么:先把预期结果摆出来

task1的要求是实现一个vmprint(pagetable_t)函数,在第一个用户进程启动时打印它的页表结构。官方给了明确的输出格式,大概长这样:

page table 0x0000000087f6b000 ..0: pte 0x0000000021fda801 pa 0x0000000087f6a000 .. ..0: pte 0x0000000021fda401 pa 0x0000000087f69000 .. .. ..0: pte 0x0000000021fdac1f pa 0x0000000087f6b000 .. .. ..1: pte 0x0000000021fda00f pa 0x0000000087f68000 .. .. ..2: pte 0x0000000021fd9c1f pa 0x0000000087f67000 ..255: pte 0x0000000021fdb401 pa 0x0000000087f6d000 .. ..511: pte 0x0000000021fdb001 pa 0x0000000087f6c000 .. .. ..510: pte 0x0000000021fdd807 pa 0x0000000087f6e000 .. .. ..511: pte 0x0000000021fddc07 pa 0x0000000087f6f000

第一次看这个输出可能有点懵,但它其实就是把三级页表逐层展开:第一级只有1个有效项,第二级有2个有效项,第三级再把每个二级项下的叶子PTE打出来。缩进的..数量表示当前是哪一级页表,pte后面的十六进制是页表项本身,pa后面的十六进制是该PTE指向的物理地址。

3.2 递归实现的思路与代码

实现思路很直接:写一个递归函数,遍历当前页表的512个PTE,如果PTE_V有效,就打印它;如果这个PTE不是叶子项(也就是PTE_R | PTE_W | PTE_X全为0),就递归打印它指向的下一级页表。缩进级别用depth参数控制。

我当时的实现是参考了xv6源码风格,先写一个公开的vmprint,再写一个静态递归辅助函数:

void vmprint(pagetable_t pagetable) { printf("page table %p\n", pagetable); vmprint_recursive(pagetable, 0); } void vmprint_recursive(pagetable_t pagetable, int depth) { for (int i = 0; i < 512; i++) { pte_t pte = pagetable[i]; if (pte & PTE_V) { uint64 pa = PTE2PA(pte); for (int j = 0; j <= depth; j++) printf(".."); printf("%d: pte %p pa %p\n", i, pte, pa); if ((pte & (PTE_R | PTE_W | PTE_X)) == 0) { vmprint_recursive((pagetable_t)pa, depth + 1); } } } }

调用位置也很讲究。官方要求是“在第一个用户进程启动时打印”,常见做法是在kernel/exec.c的exec函数返回用户空间之前,判断当前进程p->pid == 1,然后调用vmprint(p->pagetable)。这样既能确保页表已经构建完成,又不会在后续进程运行时刷屏。注意不要放到fork或sbrk里,否则输出会非常乱。

3.3 任务一容易踩的坑

第一个坑是递归条件的判断。如果只用pte & PTE_V作为递归条件,会把叶子PTE也当成下一级页表指针来递归,结果就是拿物理页的数据当页表项读,输出一串莫名其妙的地址,严重时直接死循环。判断叶子项必须用(pte & (PTE_R | PTE_W | PTE_X)) == 0,这个条件意味着它只是一个指向下级页表的中间节点,而不是真正的内存页映射。

第二个坑是遍历范围。RISC-V Sv39每一级页表的大小是512个PTE,遍历时要用i < 512,不要想当然写成256或者循环到64。xv6的pagetable_t就是uint64*,指向一页物理内存,按64位PTE算正好能放512项。

第三个坑是格式化输出。printf里%p和%d不能混用,PTE和PA都是uint64,用%p打印没问题;但如果你用了%x,注意补齐位数,否则调试时看地址很容易眼花。另外缩进是用..叠加,不是打印空格,官方测试程序会严格比对输出格式,格式不对会被判失败。

4. 任务二:每个进程一个内核页表

4.1 为什么需要进程级内核页表

原始xv6的设计是所有进程在陷入内核后共用同一个kernel_pagetable,这个页表只包含内核映射,不包含任何用户映射。好处是简单,坏处是内核不能直接访问用户内存。于是copyin、copyout这类函数只能自己拿着用户页表去walkaddr,翻译出物理地址后再读写。这种间接访存在两个问题:一是慢,每次都要手动查三级页表;二是麻烦,需要额外处理缺页、权限校验、地址溢出等边界情况。

task2要求给每个进程都分配一个独立的内核页表。这样调度器切换到某个进程时,satp寄存器指向的是该进程专属的内核页表。虽然这个阶段的内核页表仍然只映射内核地址,不包含用户映射,但它为task3“直接把用户映射合并进来”铺好了路。换句话说,task2是一个基础设施改造,task3是在这个基础设施上做优化。

4.2 核心改动点:从allocproc到scheduler

实现task2需要动好几个文件,主线逻辑是三条:创建、切换、释放。

第一,在kernel/proc.h的struct proc里加一个字段,一般叫pagetable_t kernel_pagetable;,用来存放该进程专属的内核页表。

第二,在allocproc里创建这个内核页表。原始kvminit函数是固定往全局kernel_pagetable里映射的,不能直接复用,需要先改造。常见做法是写一个函数,比如proc_kernel_pagetable或kvminit_new,流程是:先用uvmcreate分配一个空根页表,然后按照kvminit里的映射顺序,把UART0、VIRTIO0、CLINT、PLIC、内核代码段、内核数据段、TRAMPOLINE等一一映射进去。映射权限也要一致,例如内核文本段是PTE_R | PTE_X,数据段和设备是PTE_R | PTE_W。

由于kvmmap原本操作的是全局kernel_pagetable,你需要决定是把它改造成接收pagetable_t参数的版本,还是新写一个内部函数。我建议改造成接收参数,这样后面task3复用起来也方便。

第三,在scheduler里切换页表。调度器找到下一个要运行的进程后,除了加载它的context、trapframe,还要把satp寄存器切换到该进程的内核页表:

w_satp(MAKE_SATP(p->kernel_pagetable)); sfence_vma();

这两行不能省,顺序也不能反。先写satp再执行sfence_vma,作用是让CPU立刻使用新的地址空间,并刷掉旧地址空间的TLB缓存。如果你忘了刷新TLB,很可能会出现“新页表下访问了旧映射”的诡异问题,表现为随机panic或数据错乱。

第四,在freeproc里释放进程的内核页表。这一步是最容易出问题的,后面单独讲。

还有一处隐藏改动是kvmpa。这个函数原本一直用全局kernel_pagetable做地址翻译,但task2之后,内核代码运行在进程的内核页表上,某些设备驱动会调用kvmpa,如果它仍然翻译全局内核页表,可能拿不到正确的物理地址。需要改成根据当前进程的kernel_pagetable来做翻译,或者通过参数传入要翻译的页表。

4.3 释放内核页表的经典坑

释放进程内核页表时,最容易犯的错误是把内核映射指向的物理页也一起释放了。比如kernel_pagetable里映射了内核文本段、数据段、设备地址,这些物理页并不属于当前进程,而是操作系统全局共享的资源。如果你在释放页表时遍历所有PTE,发现是叶子映射就kfree对应的物理页,那几乎必然导致内核代码段被释放,后续一执行指令就panic。

正确的做法是:释放页表树本身的物理页(也就是各级页表页),但不释放叶子PTE指向的物理页。实现时可以自己写一个遍历函数,用PTE2PA拿到下一级页表的物理地址,递归回收,但遇到叶子PTE(有R/W/X标志)时,只回收页表页,不回收最终物理页。另外,如果task3把用户页表也合并进了内核页表,那么释放时还要注意区分哪些物理页属于用户进程、哪些属于内核全局映射,这时候要格外小心,不能一刀切。

我在做实验时特意写了一个调试用的printf,释放前打印每个PTE的权限和物理地址,确认没有把0x80000000附近的内核文本段地址释放掉,才放心继续跑测试。这个方法虽然笨,但排查这类问题特别有效。

5. 任务三:copyin/copyinstr简化

5.1 优化的核心思路:给内核开一扇“直通门”

task3的目标是简化copyin和copyinstr。原始实现里,这两个函数拿到用户虚拟地址srcva后,要通过用户页表walkaddr翻译成物理地址,再以物理地址为基址做内存拷贝。翻译过程本身不复杂,麻烦的是要处理各种边界情况,比如地址越界、页表项不存在、跨页拷贝等。

task3的思路是:既然每个进程已经有独立的内核页表了,那干脆把用户页表的映射也合并进这个内核页表。这样,当进程陷入内核时,虽然CPU运行在内核态、使用的是进程的内核页表,但这个页表里同时包含了用户地址段(低地址部分)的映射。于是内核可以直接用用户虚拟地址访问用户内存,copyin就退化成一次带边界检查的memmove。

这个过程可以理解成:以前内核想去用户家里拿东西,必须先问用户要一份“路线图”(用户页表),然后自己按路线图绕路(walkaddr)。改造之后,内核直接把用户家的门牌号挂在了自己办公室的墙上,想拿东西抬脚就走。

5.2 把用户映射合并进进程内核页表

这一步的做法有好几种,核心都是让进程的内核页表能看到用户地址段。比较常见的一种做法是在allocproc里创建好内核页表之后,把用户页表里的所有有效用户映射复制到内核页表里。复制时要注意几点:

第一,用户页表根页表本身已经由进程初始化好了,你需要在进程创建后、调度运行前完成合并。如果合并晚了,第一次进入内核时就访问不到用户内存。

第二,用户映射的权限要保留,但PTE_U这个位建议清掉。因为合并后的映射位于内核页表,它不应该允许用户态访问。虽然正常情况下用户态不会使用内核页表,但清掉PTE_U更安全,也符合“最小权限”原则。

第三,要处理动态内存变化。用户进程调用sbrk扩展堆内存时,uvmalloc会往用户页表里添加新映射;uvmfree或uvmdealloc会删除映射。如果内核页表里的映射是静态复制过来的,那么sbrk之后内核页表就会“过期”,表现在copyin能翻译旧地址,访问新申请的内存却失败。一个比较省事的方案是同步修改uvmalloc和uvmdealloc,在更新用户页表的同一处也更新进程内核页表;另一个更优雅的方案是不复制用户映射,而是把用户根页表页本身映射到内核页表的一个保留地址窗口,让内核能直接读取和修改用户页表的内容。前一种实现直观但改动点多,后一种对地址布局要求高,需要预留一段不冲突的内核虚拟地址。我在实验中选了“复制+同步”的方案,改动都在allocproc、uvmalloc、uvmdealloc这几个函数里,逻辑比较清晰。

5.3 copyin/copyinstr的新实现与边界检查

合并完成后,copyin可以改成这样:

int copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len) { struct proc *p = myproc(); if (srcva >= p->sz || len > p->sz - srcva) return -1; memmove(dst, (void *)srcva, len); return 0; }

注意这里的细节:srcva >= p->sz表示起始地址越界;len > p->sz - srcva检查整段拷贝不会越过用户内存上界,同时天然处理了len过大导致的整数溢出问题。为什么能直接用srcva访问?因为当前CPU运行在内核态,satp指向的是进程的内核页表,而这个内核页表已经包含了用户地址映射,所以srcva这个虚拟地址对MMU来说是“合法”的。

copyinstr类似,逐字节拷贝直到遇到\0,同样要检查地址不越过p->sz。我这里先附上一个最简单的实现:

int copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max) { struct proc *p = myproc(); uint64 i; for (i = 0; i < max; i++) { if (srcva + i >= p->sz) return -1; dst[i] = *(char *)(srcva + i); if (dst[i] == 0) return 0; } return -1; }

这里还有一个容易被忽略的问题:copyin和copyinstr的函数签名里都有pagetable_t pagetable参数,但改造后我们直接用myproc()访问当前进程,实际上忽略了传入的pagetable。如果确实有场景会传入非当前进程的页表,比如某些代码路径里用别的进程的页表做拷贝,那“无脑直访”就不正确了。我做实验时比较保守,保留了一个判断:当pagetable != myproc()->pagetable时,退回原来的walkaddr逻辑;只有当前进程页表时才走快速路径。这样既完成了实验要求,又保证代码在边界情况下不会翻车。

6. 常见问题与排查技巧

6.1 启动即panic的几类原因

这个实验调试起来最头疼的一点是:问题往往在启动早期就暴露,但panic信息短小精悍,信息量有限。我把常见的几类症状整理成了表格,方便对照排查:

症状可能原因排查方向
panic: kvmpa内核页表切换后,kvmpa还在用全局kernel_pagetable翻译检查kvmpa是否改成使用当前进程的内核页表
scause 0x000000000000000f(Store/AMO page fault)copyin/copyinstr直接访问的虚拟地址没有被映射到当前内核页表检查用户映射是否成功合并进内核页表,打印p->sz与PTE对照
Illegal instruction或用户进程刚启动就崩溃trampoline映射缺失,或用户页表U位不正确检查proc_pagetable里trampoline和trapframe的映射
释放进程时死循环或panic释放内核页表时误释放了内核共享物理页在freeproc里打印PTE权限,确认没有kfree内核文本段
sbrk之后访问新内存失败uvmalloc只更新用户页表,没有同步到内核页表检查uvmalloc/uvmdealloc是否同步修改进程内核页表
多个进程互相干扰不同进程的内核页表共享了不该共享的页表页检查是否误用了同一个kernel_pagetable,确认每个进程都独立创建

只要是和页表相关的panic,第一件事不是去看panic发生的那一行代码,而是问自己三个问题:当前CPU用的是哪张页表?被访问的虚拟地址在这张页表里有没有映射?映射权限对不对?这三个问题想清楚了,80%的问题都能定位。

6.2 GDB、QEMU和打印调试三板斧

我在调试这个实验时,最常用的工具其实就是printf。不是不想用gdb,而是页表相关的bug往往涉及地址空间切换,gdb里看到的变量值容易让人误解。比如你在某个函数里设断点,但CPU可能已经切换到了另一张页表,此时打印某些指针的值并没有意义。

如果确实要用gdb,建议配合QEMU的-s -S参数,分别在两个终端跑make qemu-gdb和gdb-multiarch kernel/kernel。常用操作有这么几个:用info registers看satp寄存器的值,确认当前地址空间;用x/8gx 0x80000000查看某个虚拟地址处的内存内容;用b *地址对特定内核地址下断点。但说实话,对页表这种结构,纯gdb调试效率不高,不如在walk成功或失败的地方加一行printf,打印函数名、虚拟地址、PTE、物理地址,跑了之后看日志一目了然。

另一个好用的技巧是“复用task1的vmprint”。当你怀疑某个页表不对时,在关键路径上调用vmprint,打印用户页表和进程内核页表的完整内容,对比两张页表的差异。这个实验里很多问题的本质就是“进程内核页表里缺了某段用户映射”,打印出来立刻就能看到。

6.3 扩展思考:做完实验后还可以怎么玩

如果你顺利通过了所有测试,我建议不要急着做下一个实验,而是回头再想几个问题:如果想让内核彻底不能访问用户页表的U位映射,该怎么办?如果一页一页地同步用户映射到内核页表,性能开销有多大?如果换成共享根页表方案,地址布局又该怎么设计?这些问题在xv6里可能没有标准答案,但思考它们能帮你把页表这件事想得更透。

另外,后续实验里trap、mmap、copyout等都会直接或间接用到这里的内核页表和用户映射设计。现在多花的每一分钟,都会在后面的实验里还回来。

做这个实验时,我个人最大的感受是:页表这东西,必须画图才能想清楚。把trampoline、trapframe、内核文本段、用户内存段、PLIC这些区域在一张纸上画出来,标注它们在虚拟地址空间和物理地址空间的位置,再动手改代码,效率会高很多。千万别说“代码我看得懂就直接改”,这个实验的坑点几乎都在你“以为懂”的地方。最后再提一句,实验过程中如果遇到莫名的panic,先看一眼p->sz和walkaddr的返回值,这两个值能帮你排除一半以上的问题。

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

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

立即咨询