MIT6.s081_Lab3 pgtbl: Page tables

MIT6.s081 Lab3:Page tables

Lab3虚拟内存页面管理号称s081最难的Lab,断断续续做了4天,感觉操作系统真的太牛了。第一个打印页表属于比较简单的内容,就是遍历+递归,可以很快的写出来,但是需要了解一些细节;第二个是每个进程一个内核页表,这个一定要理解题目,否则会费很多时间;第三个是在第二个题目的基础上,优化copyincopyinstr

代码,这个提交记录不太明确,因为在做第三个simplify的时候发现可以改进,就多了点提交,后面会做一些详细的说明。

后面课中有个Q&A老师会讲他的方法。

1. Print a page table

  1. PTE2PA

    先介绍一下PTE2PA,这个是将页表项 (Page Table Entry, PTE)转为物理地址 (Physical Address, PA)的宏。

    定义如下:

    #define PTE2PA(pte) (((pte) >> 10) << 12)
    

    PTE左移10位,然后右移12位,在 RISC-V 的 SV39 虚拟地址方案中,PTE 的低 10 位是保留位和标志位。页的大小是 4KB (2^12 字节),所以物理地址总是 4KB 对齐的。这意味着物理地址的最低 12 位总是 0。将右移后的结果左移 12 位,相当于在物理页帧号的后面补上 12 个 0,从而将其转换为完整的物理页的基地址

    XV6PTE为54位,其中44位为物理页号(Physical Page Number, PPN),剩下10位为Flags,作为“权限位”,更具体的可以看XV6的书。

  2. 要求进程PID为1的时候打印Pagetable信息,只需遍历PageTable即可,看看下面的代码,总体不难,为了不打印最后的物理页信息,使用levels来进行限制,还可以使用其他的权限位来进行限制。

    代码如下:

    void
    vmprintf(pagetable_t pagetable, uint64 level)
    {
      for(int i = 0; i < 512; i++){
        pte_t pte = pagetable[i];
        if((pte & PTE_V) && level < 3){
          // this PTE points to a lower-level page table.
          uint64 child = PTE2PA(pte);
          for (int i = 0; i <= level; i++){
            printf(" ..");
          }
          printf("%d: pte %p pa %p\n", i, pte, child);
          vmprintf((pagetable_t)child, level + 1);
        }
      }
    }
    
    void
    vmprint(pagetable_t pagetable)
    {
      printf("page table %p\n", pagetable);
      vmprintf(pagetable, 0);
    }
    

2. A kernel page table per process

说一下自己踩的坑。最初,我为每个进程构建内核页表,在内核栈的地方,我访问了所有的内核页表,包括内核本身和每个进程的,每次创建一个进程时,每个页表都复制一份Kstack,刚开始无法通过测试,我和人工智能battle了半天,他说这样无法实现,是我代码的问题,进行了“越界访问”。可是逻辑上是完全没问题的,只能说人工不智能,我最后检查代码,发现是某处写错了,所以无法通过,具体是哪处我忘记了,代码我也没有保留。

(后来想起来了一点,好像是子进程映射np的地方有错误,就是map哪一端,修改后的也是如此,当时好久没test通过看了别人的实现,一点一点对还是不行,最后自己找到了错误,印象深刻)

但是,这样的写法确实破坏了隔离性,后来才知道,无需为每一个进程的内核页表复制每个进程的kstack,哪怕是内核页表本身也不需要进行保存,修改完之后,代码更为简单,也通过了测试。

思路

之前的是只有一个内核页表,现在需要每个进程一个内核页表,因此,需要在proc中添加一个数据pagetable_t kpagetable,这样就算是每个进程可以存储一个内核页表了。接下来是初始化,procinit中无需在内核中管理所有进程的kstack;在allocproc的时候,对kpagetable进行初始化。初始化的时候,需要复制内核中的一些必要的数据,并且给进程的内核页表分配一个Kstack,这样就算初始化进程内核页表完成了。

这里还没有考虑forksbrk等一些情况,这里先按下不表。进程结束的时候,多的就是释放kpagetable,和释放内核页表差不多,就是需要注意有的地方是解除映射不是释放内存了。

下面看看代码,最初的写法,很多地方可以改进。

  1. defs.h中添加三个方法声明

    //释放进程的kpagetable
    void            proc_freekpagetable(pagetable_t, uint64, uint64);
    //初始化进程的内核页表
    pagetable_t     pkvminit();
    //被proc_freekpagetable调用,释放除TRAMPOLINE和Kstack的其他占用的内存
    void            ukvmfree(pagetable_t);
    
  2. kernel/proc.h中添加字段

    pagetable_t kpagetable;
    
  3. 修改procinit

    只保留初始化进程锁,其他的都不要了

  4. 修改allocproc

    需要初始化kpagetable,并且在进程的页表中申请kstatck

      if((p->trapframe = (struct trapframe *)kalloc()) == 0){
        release(&p->lock);
        return 0;
      }
    
      // An empty user page table.
      p->pagetable = proc_pagetable(p);
      
      p->kpagetable = pkvminit();
      char *pa = kalloc();
      if(pa == 0)
        panic("kalloc");
      uint64 va = KSTACK((int) (p - proc));
      p->kstack = va;
    
      if(mappages(p->kpagetable, va, PGSIZE, (uint64)pa, PTE_R | PTE_W) < 0) {
        // 如果映射失败,需要释放刚刚分配的物理页 pa
        kfree(pa); 
        freeproc(p);
        release(&p->lock);
        return 0;
      }
    
      if(p->pagetable == 0){
        freeproc(p);
        release(&p->lock);
        return 0;
      }
    
  5. 修改kernel/vm.c,增加pkvminit方法

    pagetable_t
    pkvminit()
    {
      pagetable_t proc_kernel_pagetable = (pagetable_t) kalloc();
      memset(proc_kernel_pagetable, 0, PGSIZE);
    
      // uart registers
      mappages(proc_kernel_pagetable, UART0, PGSIZE, UART0, PTE_R | PTE_W);
    
      // virtio mmio disk interface
      mappages(proc_kernel_pagetable, VIRTIO0, PGSIZE, VIRTIO0, PTE_R | PTE_W);
    
      // CLINT
      mappages(proc_kernel_pagetable, CLINT, 0x10000, CLINT, PTE_R | PTE_W);
    
      // PLIC]
      mappages(proc_kernel_pagetable, PLIC, 0x400000, PLIC, PTE_R | PTE_W);
    
      // map kernel text executable and read-only.
      mappages(proc_kernel_pagetable, KERNBASE, (uint64)etext-KERNBASE, KERNBASE, PTE_R | PTE_X);
    
      // map kernel data and the physical RAM we'll make use of.
      mappages(proc_kernel_pagetable, (uint64)etext, PHYSTOP-(uint64)etext, (uint64)etext, PTE_R | PTE_W);
    
      // map the trampoline for trap entry/exit to
      // the highest virtual address in the kernel.
      mappages(proc_kernel_pagetable, TRAMPOLINE, PGSIZE, (uint64)trampoline, PTE_R | PTE_X);
    
      return proc_kernel_pagetable;
    }
    
  6. 修改freeproc

    增加释放进程的内核页表

      if(p->pagetable)
        proc_freepagetable(p->pagetable, p->sz);
      p->pagetable = 0;
      if(p->kpagetable)
        proc_freekpagetable(p->kpagetable, p->sz, p->kstack);
      p->kpagetable = 0;
      p->sz = 0;
      p->pid = 0;
    
  7. kernel/proc.c中添加proc_freekpagetable方法

    void
    proc_freekpagetable(pagetable_t pagetable, uint64 sz, uint64 vm)
    {
      uvmunmap(pagetable, TRAMPOLINE, 1, 0);
      uvmunmap(pagetable, vm, 1, 1);
      ukvmfree(pagetable);
    }
    
  8. kernel/vm.c中添加ukvmfree方法

    释放进程内核页表

    void
    ukvmfree(pagetable_t pagetable)
    {
      uvmunmap(pagetable, UART0, PGSIZE/PGSIZE, 0);
    
      // virtio mmio disk interface
      uvmunmap(pagetable, VIRTIO0, PGSIZE/PGSIZE, 0);
    
      // CLINT
      uvmunmap(pagetable, CLINT, PGROUNDUP(0x10000)/PGSIZE, 0);
    
      // PLIC]
      uvmunmap(pagetable, PLIC, PGROUNDUP(0x400000)/PGSIZE, 0);
    
      // map kernel text executable and read-only.
      uvmunmap(pagetable, KERNBASE, PGROUNDUP((uint64)etext-KERNBASE)/PGSIZE, 0);
    
      // map kernel data and the physical RAM we'll make use of.
      uvmunmap(pagetable, (uint64)etext, PGROUNDUP(PHYSTOP-(uint64)etext)/PGSIZE, 0);
    
      freewalk(pagetable);
    }
    

    上述所有修改完成后,其实还差两处没有修改。

    第一个就是进程调度,切换进程那个地方,在切换进程之前,需要切换为进程的内核页表,然后就是执行完进程后,切换为内核的页表,为什么呢?这里需要考虑到,如果进程运行结束,会释放掉之前的进程内核页表,此时再使用之前的显然是不合适的,因此需要修改scheduler

    这里附上运行新进程的时机,答案来自Gemini2.5pro。

    新进程不是在swtch调用完之后开始运行的,而是在 swtch 函数执行的过程中,具体来说,是在它执行最后一条 ret 指令的那一瞬间开始运行的。

    代码如下

          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;
            w_satp(MAKE_SATP(p->kpagetable));
            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;
              ……
    

    下面还有一个关键的点,就是内核虚拟地址转物理地址,之前的代码是直接使用的kernel_pagetable,现在每个进程一个内核页表,就不能直接用kernel_pagetable,需要使用进程的内核页表进行转换。(其实这里有点忘记具体的原因了,强行解释)这里可以直接读取satp的数据来进行转换。

    看看人工智障的解释,比我说的好。

    你的这次修改,将 kvmpa 函数的行为从一个静态的、固定的查询,转变成了一个动态的、上下文相关的查询

    1. 移除了对 kernel_pagetable 的依赖
      • 你原来的第一行 pte = walk(kernel_pagetable, va, 0); 现在变得多余了,因为它的结果 pte 马上就被后面的 walk 调用给覆盖了。你可以安全地删除它。
      • 这解决了我们之前讨论的根本问题:内核软件函数不应该固执地去查询一个可能已经不是当前活动状态的全局页表。
    2. kpagetable = (pagetable_t) (r_satp() << 12);
      • r_satp(): 这是一个读取 satp 寄存器当前值的函数。satp 寄存器是CPU硬件的“唯一真理”,它里面存放着当前正在被MMU使用的那个页表的物理页号(PPN)。
      • << 12: 将物理页号(PPN)左移12位,就把它还原成了页表在物理内存中的完整起始地址。
      • kpagetable = ...: 现在,kpagetable 这个变量里存储的,不再是某个固定的全局地址,而是当前CPU正在使用的那个页表的真实地址
        • 如果当前是 scheduler 在运行,它就是 kernel_pagetable 的地址。
        • 如果当前是进程 p 在运行,它就是 p->kpagetable 的地址。
    3. pte = walk(kpagetable, va, 0);
      • 现在,walk 函数被赋予了正确的“地图”。它将使用与硬件MMU完全相同的页表来进行软件层面的地址翻译。
      • 这就保证了软件(kvmpa函数)和硬件(MMU)的状态永远是同步的、一致的

    修改kernel/vm.c中的kvmpa方法,代码如下

    uint64
    kvmpa(uint64 va)
    {
      uint64 off = va % PGSIZE;
      pte_t *pte;
      uint64 pa;
      pte = walk(kernel_pagetable, va, 0);
      pagetable_t kpagetable;
      kpagetable = (pagetable_t) (r_satp() << 12);
      pte = walk(kpagetable, va, 0);
      if(pte == 0)
        panic("kvmpa");
      if((*pte & PTE_V) == 0)
        panic("kvmpa");
      pa = PTE2PA(*pte);
      return pa+off;
    }
    

    第一次修改,这里修改了一些,减少了代码量,增加了一个方法。

    其他修改的地方这里就不写了,可以在commit记录中看到

3. Simplify copyin/copyinstr

这个是基于第二个题目的,为了可以直接复制用户空间的数据到内核空间中,将进程的数据映射向内核中备份了一份,限制了内存的大小,同时改变权限。这里也需要增加一些地方的映射fork()exec()sbrk()

  1. kernel/vm.c中增加copyutb2uktb方法

    这里实现进程页表复制到进程内核页表,并且对页表的权限进行的限制

    void
    copyutb2uktb(pagetable_t upagetable, pagetable_t ukpagetable, uint64 oldsz, uint64 newsz)
    {
      uint a;
      pte_t* pte;
      uint64 pa;
      uint64 flags;
      for(a = oldsz; a < newsz; a += PGSIZE) {
        pte = walk(upagetable, a, 0);
        pa = PTE2PA(*pte);
        flags = PTE_FLAGS(*pte) & (~PTE_U);
        mappages(ukpagetable, a, PGSIZE, pa, flags);
      }
    }
    
    
  2. kernel/exec.cexec()修改

    需要理解exec究竟做了什么,可以理解为,只改变内核页面的映射,其他的所有内容都不变。

      safestrcpy(p->name, last, sizeof(p->name));
    
      //解除之前进程的内核页面对之前进程的映射,这里不用释放界面,是因为后面还会有释放的函数
      uvmunmap(p->kpagetable, 0, PGROUNDUP(oldsz)/PGSIZE, 0);
      //复制新程序的页面到不变的内核页面
      copyutb2uktb(pagetable, p->kpagetable, 0, sz);
        
      // Commit to the user image.
      oldpagetable = p->pagetable;
    
  3. sbrk()growproc

    int
    growproc(int n)
    {
      uint sz;
      struct proc *p = myproc();
    
      sz = p->sz;
      //判断进程内存大小,为了限制不超过内核的`PLIC`
      if(sz+n >= PLIC || sz+n < 0) {
        return -1;
      }
      if(n > 0){
        if((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
          return -1;
        }
        //增加大小
        copyutb2uktb(p->pagetable, p->kpagetable, PGROUNDUP(sz-n), sz);
      } else if(n < 0){
        sz = uvmdealloc(p->pagetable, sz, sz + n);
        //减少大小
        uvmunmap(p->kpagetable, PGROUNDUP(sz), (PGROUNDUP(sz-n)-PGROUNDUP(sz))/PGSIZE, 0);
      }
      p->sz = sz;
      return 0;
    }
    
  4. 修改userinit()

    复制页面

      uvminit(p->pagetable, initcode, sizeof(initcode));
      p->sz = PGSIZE;
      // 复制页面到kpagetable
      copyutb2uktb(p->pagetable, p->kpagetable, 0, PGSIZE);
      // prepare for the very first "return" from kernel to user.
      p->trapframe->epc = 0;      // user program counter
      p->trapframe->sp = PGSIZE;  // user stack pointer
    
  5. 修改fork()

    好像之前提到的错误就是这里的,检查了很久很久,哭死,后来课里的老师好像也提到这里了(),一定得是np

      np->sz = p->sz;
    
      copyutb2uktb(np->pagetable, np->kpagetable, 0, np->sz);
    
      np->parent = p;
    
  6. 删除CLINT映射

  7. 替换copyincopyinstr

    int
    copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len)
    {
      return copyin_new(pagetable, dst, srcva, len);
    }
    int
    copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
    {
      return copyinstr_new(pagetable, dst, srcva, max);
    }
    
  8. kernel/defs.h增加copyin_newcopyinstr_new

    int             copyin_new(pagetable_t, char *, uint64, uint64);
    int             copyinstr_new(pagetable_t, char *, uint64, uint64);
    

以上就是Lab3的全部

posted @ 2025-07-11 23:50  BuerH  阅读(27)  评论(0)    收藏  举报