MIT6.S081 Lab3:Page tables

1,001 阅读8分钟

进行Lab3之前,需要阅读Chap3,和相关文件:

  • kernel/memlayout.h:体现了内存布局
  • kernel/vm.c:包括了大部分虚拟内存代码
  • kernel/kalloc.c:包括了分配和释放物理内存的代码

切换到Lab3分支:

 git fetch
 git checkout pgtbl
 make clean

Lab3:Page tables

Print a page table(easy)

要求:

 定义一个 vmprint() 函数。
 它接受一个 pagetable_t 参数,并按格式打印页表。
 在exec.c中 return argc 之前,插入 if (p->pid == 1) vmprint(p->pagetable) 来打印第一个进程的页表
 ​
 不要打印无效的PTE
 每个PTE行都由一些".."缩进,表示其在页表树中的深度

提示:

  • vmprint()放在kernel/vm.c
  • 使用文件kernel/riscv.h末尾的宏
  • 可以查看freewalk函数
  • kernel/defs.h中定义vmprint()的函数原型,这样就能在exec.c中调用它
  • 在printf调用中使用 %p 打印出完整的64位十六进制pte和地址 freewalk函数:
 // Recursively free page-table pages.
 // All leaf mappings must already have been removed.
 void
 freewalk(pagetable_t pagetable)
 {
   // there are 2^9 = 512 PTEs in a page table.
   for(int i = 0; i < 512; i++){
     pte_t pte = pagetable[i];
     if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){
       // this PTE points to a lower-level page table.
       uint64 child = PTE2PA(pte);
       freewalk((pagetable_t)child);
       pagetable[i] = 0;
     } else if(pte & PTE_V){
       panic("freewalk: leaf");
     }
   }
   kfree((void*)pagetable);
 }

代码:

 // kernel/vm.c
 // 打印页表
 #define RETRACT ".."
 void recur(pagetable_t pagetable, int level) {
   for (int i = 0; i < 512; ++i) {
     pte_t pte = pagetable[i];
     // 不打印无效的PTE
     if ((pte & PTE_V) == 0)
       continue;
 ​
     int layer = level;
     while (layer--) {
       if (layer == 0)
         printf(RETRACT);
       else {
         printf(RETRACT);
         printf(" ");
       }
     }
 ​
     printf("%d: pte %p pa %p\n", i, pte, PTE2PA(pte));
     if (level < 3) {
       uint64 child = PTE2PA(pte);
       recur((pagetable_t)child, level + 1);
     }
   }
 }
 ​
 void vmprint(pagetable_t pagetable) {
   printf("page table %p\n", pagetable);
   recur(pagetable, 1);
 }

测试: image-20220504145631355

A kernel page table per process(hard)

xv6只有一个在内核中执行时使用的内核页表。内核页表是到物理地址的直接映射,因此内核虚拟地址 x 映射到物理地址 x。这个内核页表是全局共享的,即全部进程进入内核态都共用同一个内核页表:

 // kernel/vm.c
 /*
  * the kernel's page table.  内核页表
  */  
 pagetable_t kernel_pagetable;

xv6对于每个进程的用户地址空间也有一个单独的页表,只包含该进程的用户内存的映射,从虚拟地址0开始。因为内核页表不包含这些映射,所以用户地址在内核中是无效的

因此,当内核需要使用在系统调用中传递的用户指针(比如,传递给write()的缓冲区指针)时,内核必须首先将指针转换为物理地址

本部分和下一部分的目标是允许内核直接解引用用户指针

本部分的目标就是让每一个进程进入内核态后,都能有自己独立的内核页表

要求:

 第一项工作是修改内核,以便每个进程在内核中执行时使用 自己的 内核页表副本
 然后,修改 struct proc,为每个进程维护一个内核页表,并修改调度器,在切换进程时切换内核页表,对于这个步骤,每个进程的内核页表应该与现有的全局内核页表相同

提示:

  • 为进程的内核页表添加一个字段到 struct proc
  • 为新进程生成内核页表的合理方法是实现kvminit的修改版本,生成一个新的页表,而不是修改kernel_pagetable。你需要从allocproc调用这个函数
  • 确保每个进程的内核页表具有该进程的内核堆栈的映射。在未修改的xv6中,所有内核堆栈都设置在procinit中。你需要将该函数的部分或全部移动到allocproc
  • 修改scheduler(),将进程的内核页表加载到satp寄存器中(见kvminithart)。不要忘记调用w_satp()之后调用sfence_vma()。(通过sfence_vma()刷新TLB)
  • 当没有进程运行时,scheduler()应该使用kernel_pagetable
  • freeproc中释放进程的内核页表
  • 你需要一种方法来释放页表,同时不释放叶子节点的物理内存页
  • 调试页表时,可以使用vmprint()
  • 可以修改xv6函数或添加新函数;你可能至少需要在kernel/vm.ckernel/proc.c中做这个。(但是,不要修改kernel/vmcopyin.ckernel/stats.cuser/usertest.cuser/stats.c
  • 缺少页表映射可能导致内核遇到页错误(page fault)。它将打印一个包含sepc = 0x00000000XXXXXXXX的错误。可以在kernel/kernel.asm中搜索XXXXXXXX来查找故障发生的位置。

代码:

添加内核页表字段:

 // kernel/proc.h
 struct proc {
   ...
   pagetable_t pagetable;       // User page table
   pagetable_t proc_kernel_pagetable; // 每个进程维护的一个内核页表(A kernel page table per process)
 };

内核需要依赖内核页表内一些固定的映射的存在才能正常工作,例如UART控制、硬盘界面,中断控制等。

实现kvminit的修改版本,生成进程的内核页表(注意:还需要将函数原型加到kernel/defs.h中):

 // kernel/vm.c
 // 修改的kvmmap
 void modifyKvmmap(pagetable_t pagetable, uint64 va, uint64 pa, uint64 sz, int perm) {
   if (mappages(pagetable, va, sz, pa, perm) != 0)
     panic("modifyKvmmap");
 }
 ​
 // 为每个进程生成内核页表
 pagetable_t procKernelPagetableInit(pagetable_t pagetable) {
 ​
   pagetable_t pagetable = uvmcreate();
   if (pagetable == 0)
     return 0;
   // uart registers
   modifyKvmmap(pagetable, UART0, UART0, PGSIZE, PTE_R | PTE_W);
 ​
   // virtio mmio disk interface
   modifyKvmmap(pagetable, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
 ​
   // CLINT
   // 这里需要注释,因为CLINT只有在内核启动时需要用到,且它在PLIC之下,会导致重复映射remap
   // modifyKvmmap(pagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
 ​
   // PLIC
   modifyKvmmap(pagetable, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
 ​
   // map kernel text executable and read-only.
   modifyKvmmap(pagetable, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
 ​
   // map kernel data and the physical RAM we'll make use of.
   modifyKvmmap(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.
   modifyKvmmap(pagetable, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
     
   return pagetable;
 }

原本的xv6设计中,所有处于内核态的进程都共享同一个页表,即意味着共享同一个地址空间。但是由于xv6支持多核/多进程调度,同一时间可能会有多个进程处于内核态,所以需要对所有处于内核态的进程创建其独立的内核态内的栈,即独立的内核栈,供给其内核态代码执行过程。

所以,在xv6原来的设计中,由于所有进程共用一个内核页表,那么就需要为不同进程创建多个内核栈,并映射到不同位置,见KSTACK宏的定义:

 // map kernel stacks beneath the trampoline,
 // each surrounded by invalid guard pages.
 #define KSTACK(p) (TRAMPOLINE - ((p)+1)* 2*PGSIZE)
 ​
 // KSTACK宏的使用
 uint64 va = KSTACK((int) (p - proc));

而在修改后的版本中, 每一个进程都会有自己独立的内核页表,并且每个进程也只需要访问自己的内核栈,而不需要也不可以访问其他进程的内核栈,所以可以将所有进程的内核栈映射到其各自内核页表内的固定位置,而不同页表内的同一逻辑地址,指向不同的物理内存。

allocproc中调用修改版本的kvminit生成进程的内核页表,并保证内核页表中有该进程的内核堆栈的映射:

 // kernel/proc.c
 static struct proc*
 allocproc(void)
 {
   ...
   // 创建一个进程的内核页表
   procKernelPagetableInit(p->proc_kernel_pagetable);
   if (p->proc_kernel_pagetable == 0) {
     freeproc(p);
     release(&p->lock);
     return 0;
   }
 ​
   // 保证内核页表中有该进程的内核堆栈的映射
   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));
   modifyKvmmap(p->proc_kernel_pagetable, va, (uint64)pa, PGSIZE, PTE_R | PTE_W); // * 将映射的PTE加载到内核地址空间(进程的内核页表)
   p->kstack = va;
   // kvminithart();  // * 将内核页表重载到satp寄存器,告诉硬件新PTE的存在
   ...
 }

为了保证用户进程进入内核态后,使用自己的内核页表,还应该修改调度器,使得其加载每个进程独立的内核页表,并在进程执行完后,切换回全局内核页表。

修改scheduler(),将进程的内核页表加载到satp寄存器中,如果没有进程运行,则应该使用kernel_pagetable:

 // kernel/proc.c
 void scheduler() {
   struct proc *p;
   struct cpu *c = mycpu();
   
   c->proc = 0;
   for(;;){
     // Avoid deadlock by ensuring that devices can interrupt.
     intr_on();
     
     int found = 0;
     for(p = proc; p < &proc[NPROC]; p++) {
       acquire(&p->lock);
       // 如果有进程运行就将该进程的内核页表加载到satp寄存器
       if(p->state == RUNNABLE) {
         // Switch to chosen process.  It is the process's job
         // to release its lock and then reacquire it
         // before jumping back to us.
         p->state = RUNNING;
         c->proc = p;
         // 将进程的内核页表加载到satp寄存器中,并刷新TLB
         w_satp((MAKE_SATP(p->proc_kernel_pagetable)));
         sfence_vma();
         // 调度,执行进程,进程执行完后需要切换回全局内核页表
         swtch(&c->context, &p->context);
 ​
         // Process is done running for now.
         // It should have changed its p->state before coming back.
         // 当没有进程运行时,需要使用kernel_pagetable
         c->proc = 0;
         kvminithart();  // 没有进程运行时,这里需要切换回kernel_pagetable
         found = 1; 
       }
       release(&p->lock);
     }
     ...
   }
 }

释放时,应该释放进程独享的内核页表以及内核栈,释放内核栈时,需要释放所分配的物理内存,而释放页表其他部分时,只需要释放页表,不用释放叶子节点的物理内存,因为如果释放了叶子节点的物理内存,会导致内核运行所需要的关键物理页被释放,从而导致内核崩溃。(内核栈由于是进程独有,所以可以同时释放内核栈的物理内存)

注意,内核栈所占的物理内存需要在释放进程内核页表之前被释放

freeproc()中释放进程的内核页表,同时不释放叶子节点的物理内存页

 static void freeproc(struct proc* p) {
     ...
   // 释放进程的内核栈
   if (p->kstack) {
     uint64 pa = kvmpa(p->proc_kernel_pagetable, p->kstack);
     // 通过kfree释放物理内存
     kfree((void*) pa);
   }
   // 释放进程的内核页表
   if(p->proc_kernel_pagetable)
     free_proc_kernel_pagetable(p->proc_kernel_pagetable);
   p->kstack = 0; 
   p->proc_kernel_pagetable = 0;
     ...
 }
 ​
 // 释放进程的内核页表
 void free_proc_kernel_pagetable(pagetable_t pagetable) {
   for (int i = 0; i < 512; ++i) {
     pte_t pte = pagetable[i];
     if (pte & PTE_V) {
       pagetable[i] = 0;
       // 若当前pte还指向下一级页表
       if ((pte & (PTE_R | PTE_W | PTE_X)) == 0) {
         uint64 child = PTE2PA(pte);
         free_proc_kernel_pagetable((pagetable_t)child);
       }
     }
   }
   kfree((void*) pagetable);
 }

测试: 测试时遇到错误panic: virtio_disk_intr status

由于原先kvmpa()函数如下,调用walk时,使用的是全局内核页表,所以应该进行修改,使其使用进程的内核页表:

 // kernel/vm.c
 // * 将内核虚拟地址转换为物理地址
 uint64
 kvmpa(uint64 va)
 {
   uint64 off = va % PGSIZE;
   pte_t *pte;
   uint64 pa;
   
   pte = walk(kernel_pagetable, va, 0);  // walk时使用的全局内核页表
   if(pte == 0)
     panic("kvmpa");
   if((*pte & PTE_V) == 0)
     panic("kvmpa");
   pa = PTE2PA(*pte);
   return pa+off;
 }

修改后如下:

 // kernel/vm.c
 // * 将内核虚拟地址转换为物理地址
 uint64
 kvmpa(pagetable_t pagetable, uint64 va)
 {
   uint64 off = va % PGSIZE;
   pte_t *pte;
   uint64 pa;
   pte = walk(pagetable, va, 0);
   if(pte == 0)
     panic("kvmpa");
   if((*pte & PTE_V) == 0)
     panic("kvmpa");
   pa = PTE2PA(*pte);
   return pa+off;
 }

同时,还需要修改kernel/virtio_disk.c,同时在该文件中引入头文件proc.h

   void virtio_disk_rw(struct buf *b, int write) {
   ...
   // buf0 is on a kernel stack, which is not direct mapped,
   // thus the call to kvmpa().
   // disk.desc[idx[0]].addr = (uint64) kvmpa((uint64) &buf0);  这里需要修改,添加pagetable
   disk.desc[idx[0]].addr = (uint64) kvmpa(myproc()->proc_kernel_pagetable, (uint64) &buf0);
   disk.desc[idx[0]].len = sizeof(buf0);
   disk.desc[idx[0]].flags = VRING_DESC_F_NEXT;
   disk.desc[idx[0]].next = idx[1];
   ...
  }

Simplify copyin/copyinstr(hard)

内核的copyin函数读取用户指针指向的内存。它通过将它们转换为物理地址来实现这一点,内核可以直接对物理地址解引用。它通过在软件中遍历进程页表来执行这种转换(虚拟地址到物理地址的转换)。在这部分实验中,任务是将用户映射添加到每个进程的内核页表(上一节创建的内核页表),从而允许copyin(以及相关的字符串函数copyinstr直接解引用用户指针

这个实验的目标是,在每个进程的内核页表中维护一个用户态页表映射的副本,使得内核态可以对用户态传进来的指针解引用。

相比原来的copyin的实现的优势是,原来的copyin是通过软件模拟访问页表的过程,将虚拟地址转换为物理地址的,而在进程内核页表中维护映射副本的话,可以利用CPU的硬件寻址功能进行寻址,效率更高,且可以受快表加速

所以,需要在每一处内核对用户页表进行修改的时候,将同样的修改也同步应用在进程的内核页表上,使得两个页表的程序段(0到PLIC段)地址空间的映射同步。

要求:

 将kernel/vm.c中的copyin函数体替换为 copyin_new 的调用(定义在kernel/vmcopyin.c中)
 对 copyinstr 和 copyinstr_new 执行相同的操作。
 ​
 将用户地址映射添加到每个进程的内核页表,以便 copyin_new 和 copyinstr_new 可以工作

这种方案依赖于用户虚拟地址范围不与内核的指令和数据使用的虚拟地址范围重叠。xv6使用从0开始的虚拟地址作为用户的地址空间,而内核内存从更高的地址开始。但是,这种方案确实将用户进程的最大大小限制为小于内核的最低虚拟地址。

在内核boot后,在xv6中的地址是0xC000000,这是PLIC寄存器的地址(kernel/memlayout.h)。你需要修改xv6,以防止用户进程的大小超过PLIC地址。

提示:

  • 首先用copyin_new调用替换copyin(),并使其工作,然后是copyinstr
  • fork()exec()sbrk()中,当内核改变进程的用户映射时,用同样的方法改变进程的内核页表
  • 不要忘记在userinit将第一个进程的用户页表包含到它的内核页表中
  • 在一个进程的内核页表中,用户地址的pte需要什么权限?(设置了PTE_U的页面,在内核模式下无法访问RISC-V中内核无法直接访问用户页)(所以应该不设置PTE_U,使其可以在内核模式下访问)
  • 注意PLIC限制

思考:

为什么copyin_new()中需要第三个测试srcva + len < srcva?给出srcvalen的值,前两个测试失败(即,它们不会返回-1),但第三个测试为真(导致返回-1)

srcva + len < srcva 说明srcva + len超过了uint64的范围 如果srcva + len溢出了,那么srcva + len的值会小于srcva,也会小于p->sz,所以第二个判断条件会为false(即第二个判断条件无法判断srcva + len是否溢出),那么就需要第三个判断条件,对srcva + len是否溢出进行判断

代码:

实现一个拷贝页表映射关系的函数(只拷贝页表项,不拷贝实际的物理内存),并将函数原型写在kernel/defs.h中:

 // kernel/vm.c
 // * 将src页表的从start开始的部分映射拷贝到dst页表中
 int kvmCopyPagetableMappings(pagetable_t src, pagetable_t dst, uint64 start, uint64 sz) {
   pte_t *pte;
   uint64 pa;
   uint64 i;
   int perm;
 ​
   for (i = PGROUNDUP(start); i < start + sz; i += PGSIZE) {
     pte = walk(src, i, 0);
     if (pte == 0)
       panic("kvmCopyPagetableMappings: walk");
     if ((*pte & PTE_V) == 0)
       panic("kvmCopyPagetableMappings: PTE_V is not set");
     // * 通过~PTE_U将该页面设置为非用户页,这样内核才能访问
     // * 在RISC-V中内核无法直接访问用户页
     perm = PTE_FLAGS(*pte) & ~PTE_U;
     pa = PTE2PA(*pte);
     if (mappages(dst, i, PGSIZE, pa, perm) != 0) {
       goto err;
     }
   }
   return 0;
 ​
   err:
     uvmunmap(dst, PGROUNDUP(start), (i - PGROUNDUP(start)) / PGSIZE, 0);
     return -1;
 }

实现一个释放进程内核页表映射的函数,使进程内核页表与用户页表映射同步:

 // kernel/vm.c
 // * 释放进程内核页表的映射,使进程内核页表与用户页表的映射同步
 uint64 kvmdealloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz)
 {
   if(newsz >= oldsz)
     return oldsz;
 ​
   if(PGROUNDUP(newsz) < PGROUNDUP(oldsz)){
     int npages = (PGROUNDUP(oldsz) - PGROUNDUP(newsz)) / PGSIZE;
     // * 最后一个参数是0,代表只取消映射,不释放物理内存
     uvmunmap(pagetable, PGROUNDUP(newsz), npages, 0);
   }
 ​
   return newsz;
 }

由提示可知,内核启动后,在xv6中的最低地址是0xC000000,即PLIC寄存器的地址。所以,需要将用户内存映射到0xC000000地址以下,即[0, 0xC000000)。 之后就需要在fork()exec()sbrk()中,当用户页表映射被改变时,将改变同步到进程的内核页表。

fork中:

 // kernel/proc.c
 int fork(void) {
   // Allocate process.
   // 在allocproc中创建了进程内核页表
   if((np = allocproc()) == 0){
     return -1;
   }
 ​
   // Copy user memory from parent to child.
   if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0){
     freeproc(np);
     release(&np->lock);
     return -1;
   }
   // 将子进程用户页表的映射拷贝到子进程的进程内核页表中
   if (kvmCopyPagetableMappings(np->pagetable, np->proc_kernel_pagetable, 0, p->sz) < 0) {
     freeproc(np);
     release(&np->lock);
     return -1;
   }
 }

exec中:

 int exec(char *path, char **argv) {
   ...
   // Commit to the user image.
   oldpagetable = p->pagetable;
   p->pagetable = pagetable;
   p->sz = sz;
   p->trapframe->epc = elf.entry;  // initial program counter = main
   p->trapframe->sp = sp; // initial stack pointer
   proc_freepagetable(oldpagetable, oldsz);
 ​
   // 清除进程内核页表中对用户内存的旧映射
   uvmunmap(p->proc_kernel_pagetable, 0, PGROUNDUP(oldsz) / PGSIZE, 0);
   // 建立新的映射
   if (kvmCopyPagetableMappings(p->pagetable, p->proc_kernel_pagetable, 0, p->sz) < 0) {
     goto bad;
   }
   ...
 }

growproc中,注意调用uvmalloc()uvmdealloc()sz会被更新,所以在调用kvmCopyPagetableMappings()kvmdealloc()时应该注意传入正确的参数大小,同时当n大于0时,要注意空间大小不能超过PLIC

 // kernel/proc.c
 // Grow or shrink user memory by n bytes.
 // Return 0 on success, -1 on failure.
 // * 增加或减少用户内存
 int
 growproc(int n)
 {
   uint sz;
   struct proc *p = myproc();
 ​
   sz = p->sz;
   // * 根据n的正负来调用 uvmalloc / uvmdealloc
   if(n > 0){
     if (sz + n > PLIC) // n大于0时,要注意空间大小不能超过PLIC
       return -1;
     if((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
       return -1;
     }
     // 拷贝映射到进程内核页表
     if (kvmCopyPagetableMappings(p->pagetable, p->proc_kernel_pagetable, p->sz, n) < 0) {
       uvmdealloc(p->pagetable, sz, p->sz);
       return -1;
     }
   } else if(n < 0){
     uvmdealloc(p->pagetable, sz, sz + n);
     // 释放进程内核页表的映射,使其与用户页表同步
     sz = kvmdealloc(p->proc_kernel_pagetable, sz, sz + n);
   }
   p->sz = sz;
   return 0;
 }

对于 init 进程,由于其不是由fork得来的,所以也需要修改它,使其进程的内核页表包含用户页表

 // kernel/proc.c
 // Set up first user process.
 // * 设置第一个用户进程
 void userinit(void) {
   ...
   // allocate one user page and copy init's instructions
   // and data into it.
   uvminit(p->pagetable, initcode, sizeof(initcode));
   p->sz = PGSIZE;
   // 将用户页表的映射同步到进程内核页表中
   kvmCopyPagetableMappings(p->pagetable, p->proc_kernel_pagetable, 0, p->sz);
   ...
 }

copyin函数和copyinstr函数中,分别调用copyin_newcopyinstr_new。(注意,需要在kernel/vm.c中添加函数copyin_newcopyinstr_new的声明)

错误:

image-20220508220948932

可见报错panic:remap,查阅资料后发现,

image-20220508221034131 如上图所示,在PLIC之前其实还有一个CLINT(核心本地中断器)的映射,该映射会与我们要映射的程序内存冲突([0, 0xC000000))。代码可见kernel/memlayout.h

 // kernel/memlayout.h
 // local interrupt controller, which contains the timer.
 #define CLINT 0x2000000L
 #define CLINT_MTIMECMP(hartid) (CLINT + 0x4000 + 8*(hartid))
 #define CLINT_MTIME (CLINT + 0xBFF8) // cycles since boot.

而在Chap5以及start.c中可知,CLINT仅在内核启动的时候需要用到,而用户进程在内核态的操作不需要使用该映射。所以,在生成每个进程的内核页表时,将建立CLINT映射的语句注释掉即可。

 // kernel/vm.c
 pagetable_t procKernelPagetableInit() {
     ...
   // CLINT
   //modifyKvmmap(pagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
     ...
 }

总体测试(注意此次测试还需要添加一个answers-pgtbl.txt的文件):

image-20220508220846810