:A kernel page table per process)
A kernel page table per process (hard)实验目标本实验要求为xv6 中每一个进程都配备一份独立的内核页表per-process kernel page table取代原本所有进程进入内核态后共享同一张全局内核页表的设计。最终效果进程在用户态使用自己的用户页表一旦通过trampoline.S陷入内核、切换到内核页表改写satp后用的也是属于自己进程的那一份内核页表而非全局共享的那张。这个改动看似只是多建几张页表但它牵动了内核页表的创建、内核栈映射、调度切换、销毁回收一整条生命周期可以极大地帮助我们理解 xv6 地址空间管理。前置知识xv6 原本的页表设计在原始 xv6 中用户态每个进程有各自独立的用户页表p-pagetable。内核态所有进程陷入内核后都通过trampoline.S把satp切到同一张全局内核页表kernel_pagetable。也就是说内核页表是全局唯一、所有进程共享的。共享一张内核页表有什么弊端隔离性弱进程进入内核态后理论上能访问内核中其他进程的内核数据。若某进程因 bug 或恶意行为越界访问可能影响其他进程甚至整个系统的稳定性。生命周期管理复杂每次创建 / 删除进程都要小心地维护这张共享页表里的条目避免不同进程的内存冲突或被错误覆盖在多核系统里这种全局共享还会带来同步开销与竞争。内核栈也必须共享既然地址空间共享所有进程的内核栈也挤在同一片内核虚拟地址里只能靠启动期一次性预分配来划分。如果每个进程进入内核态后都能用自己独立的内核页表上述问题都能自然规避——本实验的目的正在于此。两个关键概念铺垫直接映射direct mappingxv6 的内核页表里绝大部分虚拟地址都等于物理地址va pa。UART、VirtIO、CLINT、PLIC、内核代码/数据段等都靠这种映射让内核能直接访问硬件与物理内存。每张进程内核页表都需要建立这套相同的直接映射。内核栈也要 per-processxv6 支持多核调度同一时刻可能有多个进程处于内核态。进程有了独立内核页表后应把自己的内核栈映射到该页表的固定虚拟地址不同页表里同一逻辑地址指向不同物理内存从而互不干扰。实现思路整体改造可以拆成 7 步逻辑上是先能建 → 再能通用 → 接进生命周期 → 最后善后加字段在struct proc中新增perproc_kernel_pagetable保存进程独享的内核页表。重构创建函数把原本写死给全局页表的kvminit抽象成kvminit_perproc()让任意进程都能用同一套直接映射建出自己的内核页表全局页表kernel_pagetable改为调用它来初始化。泛化工具函数kvmmap和kvmpa原本写死用kernel_pagetable改为把目标页表作为第一个参数传入从而能作用于任意页表。内核栈迁移从启动期procinit的统一预分配改为在allocproc中按进程创建——分配物理页、映射到进程内核页表的固定地址。调度切换在scheduler把 CPU 交给进程前用w_satp切到该进程的内核页表切回时恢复全局内核页表。销毁回收在freeproc中按创建的逆序释放内核栈与内核页表本身注意只释放页表结构不能释放其指向的物理内存。补齐声明与调用点在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_perproc与kvmmap_perproc全局内核页表仍然由kvminit初始化但它不再自己写映射而是委托给kvminit_perproc/* * create a direct-map page table for the kernel. */voidkvminit(){kernel_pagetablekvminit_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 / 内核代码段 / 数据段 / trampolinevoidkvmmap_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 well 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泛化kvmmap与kvmpa让它们支持任意页表原来这两个函数写死使用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 offva%PGSIZE;pte_t*pte;uint64 pa;ptewalk(pagetable,va,0);// kernel_pagetable 改为 pagetableif(pte0)panic(kvmpa);if((*ptePTE_V)0)panic(kvmpa);paPTE2PA(*pte);returnpaoff;}3新增kvm_free_perproc_kernel_pagetable只释放页表结构本身进程退出时要回收内核页表但不能用proc_freepagetable——后者会连同页表指向的物理内存一起释放而 per-process 内核页表里的直接映射内核代码、物理 RAM 等是全局共享的释放它们会让内核崩溃// Free a processs 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(inti0;i512;i){pte_tptepagetable[i];uint64 childPTE2PA(pte);// 如果 pte 指向更低一级的页表if((ptePTE_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把内核页表接入进程生命周期1procinit去掉内核栈的预分配原版在启动期就给NPROC个进程一次性分配好内核栈并映射到共享内核页表。现在改为进程创建时再建所以把这段注释掉// initialize the proc table at boot time.voidprocinit(void){structproc*p;initlock(pid_lock,nextpid);for(pproc;pproc[NPROC];p){initlock(p-lock,proc);// Allocate a page for the processs 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();}2allocproc中创建进程内核页表与内核栈在分配好用户页表之后紧接着建出进程的内核页表并分配一页物理内存作为内核栈、映射到该内核页表的固定虚拟地址KSTACK(0)。注意这里用kvmmap(p-perproc_kernel_pagetable, ...)把栈映射进进程自己的内核页表staticstructproc*allocproc(void){...// An empty user page table.p-pagetableproc_pagetable(p);if(p-pagetable0){freeproc(p);release(p-lock);return0;}// 为每个进程的内核页表分配内存并做直接映射p-perproc_kernel_pagetablekvminit_perproc();// 内核栈char*pakalloc();if(pa0)panic(kalloc);uint64 vaKSTACK((int)(0));kvmmap(p-perproc_kernel_pagetable,va,(uint64)pa,PGSIZE,PTE_R|PTE_W);p-kstackva;// 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.spp-kstackPGSIZE;returnp;}3scheduler在运行进程前切换到其内核页表把 CPU 交给进程之前用w_satp加载该进程的内核页表并sfence_vma()刷新 TLB进程让出 CPU 后再用kvminithart()切回全局内核页表// kernel/proc.cvoidscheduler(void){...p-stateRUNNING;c-procp;// 切换到进程独立的内核页表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-proc0;...}4freeproc按逆序释放内核栈与内核页表先通过kvmpa把内核栈虚拟地址翻译回物理地址并kfree再递归释放内核页表结构本身// kernel/proc.cstaticvoidfreeproc(structproc*p){...p-xstate0;// 释放进程独享页表的内核栈void*kstack_pa(void*)kvmpa(p-perproc_kernel_pagetable,p-kstack);kfree(kstack_pa);p-kstack0;// 不能使用 proc_freepagetable 释放进程独享的内核页表// 因为会释放掉其对应的物理内存。// 递归释放进程独享的页表释放页表本身所占的空间但不释放对应的物理内存。kvm_free_perproc_kernel_pagetable(p-perproc_kernel_pagetable);p-perproc_kernel_pagetable0;p-stateUNUSED;}kernel/defs.h补齐函数声明由于改动了kvmmap/kvmpa的签名并新增了三个函数需要在defs.h的vm.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...#includefs.h#includebuf.h#includevirtio.h#includeproc.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/kernelis up to date.Test usertests(197.9s)Test usertests: copyinusertests: copyin: OKTest usertests: copyinstr1usertests: copyinstr1: OKTest usertests: copyinstr2usertests: copyinstr2: OKTest usertests: copyinstr3usertests: copyinstr3: OKTest usertests: sbrkmuchusertests: sbrkmuch: OKTest usertests: all testsusertests: all tests: OK该命令会运行pgtbl相关的测试项以及完整的usertests。全部通过即说明 per-process 内核页表的创建、切换、回收均正确且没有引入内存泄漏或并发问题。也可以用make qemu启动观察系统应能正常引导、进入sh、运行用户程序行为与改动前一致——这正是本实验追求的对外透明、对内隔离。复盘共享 → 独立的本质是生命周期下沉把内核页表与内核栈的创建/销毁从启动期一次性全局下放到每进程创建/退出局部从而换来隔离性与更清晰的所有权。直接映射是公共部分无论哪份内核页表UART/VirtIO/CLINT/PLIC/内核镜像/trampoline 的映射都完全一致差异只在属于哪个进程。这正是抽出kvmmap_perproc的意义。释放时要分清页表结构与物理内存kvm_free_perproc_kernel_pagetable只 kfree 页表页本身绝不释放其指向的物理页一旦误用proc_freepagetable/uvmfree就会把全局共享的内核物理内存 Free 掉内核随即崩溃。这是本实验最容易踩的坑。satp切换必须配sfence_vma每次换页表后都要刷新 TLB否则旧页表项的缓存会导致地址翻译错误。