MIT 6.S081 pgtbl 实验篇(lab3):A kernel page table per process
2026/7/30 12:16:55 网站建设 项目流程

A kernel page table per process (hard)


实验目标

本实验要求为xv6 中每一个进程都配备一份独立的内核页表(per-process kernel page table),取代原本"所有进程进入内核态后共享同一张全局内核页表"的设计。

最终效果:进程在用户态使用自己的用户页表;一旦通过trampoline.S陷入内核、切换到内核页表(改写satp)后,用的也是属于自己进程的那一份内核页表,而非全局共享的那张。

这个改动看似只是"多建几张页表",但它牵动了内核页表的创建、内核栈映射、调度切换、销毁回收一整条生命周期,可以极大地帮助我们理解 xv6 地址空间管理。

前置知识

xv6 原本的页表设计

在原始 xv6 中:

也就是说,内核页表是全局唯一、所有进程共享的。

共享一张内核页表有什么弊端?

  1. 隔离性弱:进程进入内核态后,理论上能访问内核中其他进程的内核数据。若某进程因 bug 或恶意行为越界访问,可能影响其他进程甚至整个系统的稳定性。
  2. 生命周期管理复杂:每次创建 / 删除进程,都要小心地维护这张共享页表里的条目,避免不同进程的内存冲突或被错误覆盖;在多核系统里,这种全局共享还会带来同步开销与竞争。
  3. 内核栈也必须共享:既然地址空间共享,所有进程的内核栈也挤在同一片内核虚拟地址里,只能靠启动期一次性预分配来划分。

如果每个进程进入内核态后都能用自己独立的内核页表,上述问题都能自然规避——本实验的目的正在于此。

两个关键概念铺垫

实现思路

整体改造可以拆成 7 步,逻辑上是"先能建 → 再能通用 → 接进生命周期 → 最后善后":

  1. 加字段:在struct proc中新增perproc_kernel_pagetable,保存进程独享的内核页表。
  2. 重构创建函数:把原本写死给全局页表的kvminit抽象成kvminit_perproc(),让任意进程都能用同一套直接映射建出自己的内核页表;全局页表kernel_pagetable改为调用它来初始化。
  3. 泛化工具函数kvmmapkvmpa原本写死用kernel_pagetable,改为把目标页表作为第一个参数传入,从而能作用于任意页表。
  4. 内核栈迁移:从启动期procinit的"统一预分配"改为在allocproc中"按进程创建"——分配物理页、映射到进程内核页表的固定地址。
  5. 调度切换:在scheduler把 CPU 交给进程前,用w_satp切到该进程的内核页表;切回时恢复全局内核页表。
  6. 销毁回收:在freeproc中按创建的逆序释放内核栈与内核页表本身(注意只释放页表结构,不能释放其指向的物理内存)。
  7. 补齐声明与调用点:在defs.h补声明,并修正virtio_disk.c中对kvmpa的调用。

代码实现

kernel/proc.h:进程控制块新增内核页表字段

structproc{...charname[16];// Process name (debugging)pagetable_tperproc_kernel_pagetable;// 每个进程独享的内核页表};

kernel/vm.c:内核页表的创建、泛化与释放

(1)重构kvminit,抽象出可复用的kvminit_perprockvmmap_perproc

全局内核页表仍然由kvminit初始化,但它不再自己写映射,而是委托给kvminit_perproc

/* * create a direct-map page table for the kernel. */voidkvminit(){kernel_pagetable=kvminit_perproc();}

kvminit_perproc负责"分配一页 → 清空 → 建立直接映射 → 返回页表",任何进程都能调用它得到一份独立的内核页表:

pagetable_tkvminit_perproc(){pagetable_tpagetable=(pagetable_t)kalloc();memset(pagetable,0,PGSIZE);kvmmap_perproc(pagetable);returnpagetable;}

kvmmap_perproc就是把原来kvminit里那一串直接映射照搬过来,区别是目标页表由参数指定(UART / VirtIO / CLINT / PLIC / 内核代码段 / 数据段 / trampoline):

voidkvmmap_perproc(pagetable_tpagetable){// 原来的 kvminit 函数的直接映射部分:// uart registerskvmmap(pagetable,UART0,UART0,PGSIZE,PTE_R|PTE_W);// virtio mmio disk interfacekvmmap(pagetable,VIRTIO0,VIRTIO0,PGSIZE,PTE_R|PTE_W);// CLINTkvmmap(pagetable,CLINT,CLINT,0x10000,PTE_R|PTE_W);// PLICkvmmap(pagetable,PLIC,PLIC,0x400000,PTE_R|PTE_W);// map kernel text executable and read-only.kvmmap(pagetable,KERNBASE,KERNBASE,(uint64)etext-KERNBASE,PTE_R|PTE_X);// map kernel data and the physical RAM we'll make use of.kvmmap(pagetable,(uint64)etext,(uint64)etext,PHYSTOP-(uint64)etext,PTE_R|PTE_W);// map the trampoline for trap entry/exit to// the highest virtual address in the kernel.kvmmap(pagetable,TRAMPOLINE,(uint64)trampoline,PGSIZE,PTE_R|PTE_X);}

(2)泛化kvmmapkvmpa,让它们支持任意页表

原来这两个函数写死使用kernel_pagetable,现在把目标页表作为第一个参数传入:

// kernel/vm.c// 添加第一个参数voidkvmmap(pagetable_tpagetable,uint64 va,uint64 pa,uint64 sz,intperm){if(mappages(pagetable,va,sz,pa,perm)!=0)panic("kvmmap");}
// kernel/vm.c// 添加第一个参数uint64kvmpa(pagetable_tpagetable,uint64 va){uint64 off=va%PGSIZE;pte_t*pte;uint64 pa;pte=walk(pagetable,va,0);// kernel_pagetable 改为 pagetableif(pte==0)panic("kvmpa");if((*pte&PTE_V)==0)panic("kvmpa");pa=PTE2PA(*pte);returnpa+off;}

(3)新增kvm_free_perproc_kernel_pagetable,只释放页表结构本身

进程退出时要回收内核页表,但不能proc_freepagetable——后者会连同页表指向的物理内存一起释放,而 per-process 内核页表里的直接映射(内核代码、物理 RAM 等)是全局共享的,释放它们会让内核崩溃:

// Free a process's page table, and free the// physical memory it refers to.voidproc_freepagetable(pagetable_tpagetable,uint64 sz){uvmunmap(pagetable,TRAMPOLINE,1,0);uvmunmap(pagetable,TRAPFRAME,1,0);uvmfree(pagetable,sz);}

因此单独写一个释放函数,递归只 kfree 各级页表页本身,不碰其指向的物理页:

// kernel/vm.cvoidkvm_free_perproc_kernel_pagetable(pagetable_tpagetable){for(inti=0;i<512;++i){pte_tpte=pagetable[i];uint64 child=PTE2PA(pte);// 如果 pte 指向更低一级的页表if((pte&PTE_V)&&(pte&(PTE_R|PTE_W|PTE_X))==0){// 递归释放低一级的页表及页表项kvm_free_perproc_kernel_pagetable((pagetable_t)child);pagetable[i]=0;}}kfree((void*)pagetable);// 释放当前级别页表所占空间}

kernel/proc.c:把内核页表接入进程生命周期

(1)procinit去掉内核栈的预分配

原版在启动期就给NPROC个进程一次性分配好内核栈并映射到共享内核页表。现在改为"进程创建时再建",所以把这段注释掉:

// initialize the proc table at boot time.voidprocinit(void){structproc*p;initlock(&pid_lock,"nextpid");for(p=proc;p<&proc[NPROC];p++){initlock(&p->lock,"proc");// Allocate a page for the process's kernel stack.// Map it high in memory, followed by an invalid// guard page.// 注释掉以下代码(为所有进程预分配内核栈的代码),变为创建进程的时候再创建内核栈/* char *pa = kalloc(); if(pa == 0) panic("kalloc"); uint64 va = KSTACK((int) (p - proc)); kvmmap(va, (uint64)pa, PGSIZE, PTE_R | PTE_W); p->kstack = va; */}kvminithart();}

(2)allocproc中创建进程内核页表与内核栈

在分配好用户页表之后,紧接着建出进程的内核页表,并分配一页物理内存作为内核栈、映射到该内核页表的固定虚拟地址(KSTACK(0))。注意这里用kvmmap(p->perproc_kernel_pagetable, ...)把栈映射进进程自己的内核页表:

staticstructproc*allocproc(void){...// An empty user page table.p->pagetable=proc_pagetable(p);if(p->pagetable==0){freeproc(p);release(&p->lock);return0;}// 为每个进程的内核页表分配内存并做直接映射p->perproc_kernel_pagetable=kvminit_perproc();// 内核栈char*pa=kalloc();if(pa==0)panic("kalloc");uint64 va=KSTACK((int)(0));kvmmap(p->perproc_kernel_pagetable,va,(uint64)pa,PGSIZE,PTE_R|PTE_W);p->kstack=va;// Set up new context to start executing at forkret,// which returns to user space.memset(&p->context,0,sizeof(p->context));p->context.ra=(uint64)forkret;p->context.sp=p->kstack+PGSIZE;returnp;}

(3)scheduler在运行进程前切换到其内核页表

把 CPU 交给进程之前,用w_satp加载该进程的内核页表并sfence_vma()刷新 TLB;进程让出 CPU 后,再用kvminithart()切回全局内核页表:

// kernel/proc.cvoidscheduler(void){...p->state=RUNNING;c->proc=p;// 切换到进程独立的内核页表w_satp(MAKE_SATP(p->perproc_kernel_pagetable));sfence_vma();// 调度,执行进程swtch(&c->context,&p->context);// 切换回全局内核页表kvminithart();// Process is done running for now.// It should have changed its p->state before coming back.c->proc=0;...}

(4)freeproc按逆序释放内核栈与内核页表

先通过kvmpa把内核栈虚拟地址翻译回物理地址并kfree,再递归释放内核页表结构本身:

// kernel/proc.cstaticvoidfreeproc(structproc*p){...p->xstate=0;// 释放进程独享页表的内核栈void*kstack_pa=(void*)kvmpa(p->perproc_kernel_pagetable,p->kstack);kfree(kstack_pa);p->kstack=0;// 不能使用 proc_freepagetable 释放进程独享的内核页表,// 因为会释放掉其对应的物理内存。// 递归释放进程独享的页表,释放页表本身所占的空间,但不释放对应的物理内存。kvm_free_perproc_kernel_pagetable(p->perproc_kernel_pagetable);p->perproc_kernel_pagetable=0;p->state=UNUSED;}

kernel/defs.h:补齐函数声明

由于改动了kvmmap/kvmpa的签名,并新增了三个函数,需要在defs.hvm.c声明区同步更新(否则其他文件会因"隐式声明 / 参数不匹配"而编译失败):

// kernel/defs.h...// vm.cvoidkvminit(void);voidkvminithart(void);uint64kvmpa(pagetable_t,uint64);// 添加第一个入口参数voidkvmmap(pagetable_t,uint64,uint64,uint64,int);// 添加第一个入口参数...intcopyinstr(pagetable_t,char*,uint64,uint64);intvmprint(pagetable_t);// Print a page tablepagetable_tkvminit_perproc();voidkvmmap_perproc(pagetable_t);voidkvm_free_perproc_kernel_pagetable(pagetable_t);

kernel/virtio_disk.c:修正kvmpa调用

kvmpa增加页表参数后,原本的调用点也要同步。这里磁盘 DMA 发生在当前进程上下文,应传入该进程的内核页表;同时需要#include "proc.h"才能访问myproc()返回的struct proc完整定义:

// kernel/virtio_diskc.c...#include"fs.h"#include"buf.h"#include"virtio.h"#include"proc.h"// 添加头文件...voidvirtio_disk_rw(structbuf*b,intwrite){...disk.desc[idx[0]].addr=(uint64)kvmpa(myproc()->perproc_kernel_pagetable,(uint64)&buf0);...}

验证

回到 xv6 目录,本实验的测评命令为:

./grade-lab-pgtbl usertests

需要等待较久的时间,至少我是这样,所以需要一点耐心,也要相信自己。

make:'kernel/kernel'is up to date.==Test usertests==(197.9s)==Test usertests: copyin==usertests: copyin: OK==Test usertests: copyinstr1==usertests: copyinstr1: OK==Test usertests: copyinstr2==usertests: copyinstr2: OK==Test usertests: copyinstr3==usertests: copyinstr3: OK==Test usertests: sbrkmuch==usertests: sbrkmuch: OK==Test usertests: all tests==usertests: all tests: OK

该命令会运行pgtbl相关的测试项以及完整的usertests。全部通过即说明 per-process 内核页表的创建、切换、回收均正确,且没有引入内存泄漏或并发问题。

也可以用make qemu启动观察:系统应能正常引导、进入sh、运行用户程序,行为与改动前一致——这正是本实验追求的"对外透明、对内隔离"。

复盘

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

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

立即咨询