MIT-6.S081-2020实验(xv6-riscv64)三:pgtbl


实验文档

概述

这次实验主要涉及虚拟内存的管理,重点是和页表相关的操作。个人觉得难点主要还是在调试方面,因为一旦写到什么非法内存或者哪里内存泄漏了,基本只能抓瞎。我也是参考了github上别人的代码才最终完成了实验。

内容

这个任务比较简单,只要仿照freewalk递归遍历就行了。

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

A kernel page table per process

这个任务需要给每个进程添加一个独立的内核页表,两个任务的总体目的是让每个进程独立拥有一个同时映射了用户内存区和内核内存区的内核页表。这样进程在进入内核态后,可以直接在自己这个内核页表中的用户内存区和内核内存区之间传递数据,不需要经过页表切换。首先我除了给proc结构体添加了kpagetable外,额外加了一个kstackpa表示kstack的物理地址,这一步不是必须的,因为结构体里已经保存了kstack的虚拟地址了,在用之前walk一遍也不是不行。加了之后初始化在申请kstack的时候就顺便保存了物理地址:

      char *pa = kalloc();
      if(pa == 0)
        panic("kalloc");
      p->kstackpa = pa;

然后是allocproc,需要申请kpagetable并对其进行映射:

  p->kpagetable = proc_kpagetable();
  if (p->kpagetable == 0) {
      freeproc(p);
      release(&p->lock);
      return 0;
  }

  if (mappages(p->kpagetable, (uint64)p->kstack, PGSIZE,
               (uint64)p->kstackpa, PTE_R | PTE_W) != 0) {
      freeproc(p);
      release(&p->lock);
      return 0;
  }

proc_kpagetable的实现我写在vm.c里,借鉴了kvminit函数:

pagetable_t proc_kpagetable(void) {
    pagetable_t kpagetable = (pagetable_t) kalloc();
    memset(kpagetable, 0, PGSIZE);

    if (mappages(kpagetable, UART0, PGSIZE, UART0, PTE_R | PTE_W) != 0) return 0;
    if (mappages(kpagetable, VIRTIO0, PGSIZE, VIRTIO0, PTE_R | PTE_W) != 0) return 0;
    if (mappages(kpagetable, PLIC, 0x400000, PLIC, PTE_R | PTE_W) != 0) return 0;
    if (mappages(kpagetable, KERNBASE, (uint64)etext-KERNBASE, KERNBASE, PTE_R | PTE_X) != 0) return 0;
    if (mappages(kpagetable, (uint64)etext, PHYSTOP-(uint64)etext, (uint64)etext, PTE_R | PTE_W) != 0) return 0;
    if (mappages(kpagetable, TRAMPOLINE, PGSIZE, (uint64)trampoline, PTE_R | PTE_X) != 0) return 0;

    return kpagetable;
}

值得注意的是这个CLINT没有被映射,我也不知道这个区域代表什么什么意思,但实验文档中提到:

However, this scheme does limit the maximum size of a user process to be less than the kernel’s lowest virtual address. After the kernel has booted, that address is 0xC000000 in xv6, the address of the PLIC registers;

memlayout.h中CLINT对应的常数是0x2000000,比0xC000000小,按照文档的指示是可以被用户区覆盖的,所以没有映射(映射了可能后面再映射用户内存会报remap错误)。

Update2021.6.29:这个CLINT用来存储发生时钟中断时的一些额外信息,由于存取这些信息的过程都发生在机器态,不受页表控制,所以这个区域无需映射(甚至我认为原版的内核页表也不需要映射这个区域)。

scheduler函数中切换进程后需要切换satp寄存器为这个进程的内核页表(因为现在在内核态)并刷新TLB,这一步还是比较简单的:

        p->state = RUNNING;
        c->proc = p;
        w_satp(MAKE_SATP(p->kpagetable));
        sfence_vma();
        swtch(&c->context, &p->context);

最后是freeproc,基本也是仿照对用户页表的操作依样画葫芦:

  if(p->pagetable)
    proc_freepagetable(p->pagetable, p->sz);
  if (p->kpagetable)
      proc_kfreepagetable(p->kpagetable);
  p->pagetable = 0;
  p->kpagetable = 0;

proc_kfreepagetable我也写在vm.c里,基本上是仿照freewalk函数写的,但是freewalk函数要求把最底层页表的映射全部解除了才能调用,否则会报错。我嫌麻烦就直接一步了,遇到最底层就不递归,直接只释放页表:

void proc_kfreepagetable(pagetable_t pagetable) {
    for(int i = 0; i < 512; i++){
        pte_t pte = pagetable[i];
        if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){
            uint64 child = PTE2PA(pte);
            proc_kfreepagetable((pagetable_t)child);
            pagetable[i] = 0;
        }
    }
    kfree((void*)pagetable);
}

Simplify copyin/copyinstr

这个任务要求实现对copyin和copyinstr函数的完全替代,实际上这两个函数就是上面所说的直接在进程自己的内核页表中的用户内存区和内核内存区之间传递数据,基本就一个简单的memcpy操作,而且实验文件也已经给了,不用你实现,真正要你做的是在fork、exec、sbrk三个函数中实现内核页表的管理操作。

先看fork函数,fork函数里复制了用户页表,那就依葫芦画瓢,也把内核页表复制一份:

  if(kvmcopy(np->pagetable, np->kpagetable, 0, p->sz) < 0){
    freeproc(np);
    release(&np->lock);
    return -1;
  }

注意这里因为两个进程kstack的物理地址不同,所以不能是两个进程的内核页表互相复制,而应该是新进程的内核页表复制自己的用户页表,因为新进程在申请内核页表时内核区已经映射完毕了,所以只需复制用户区即可,这里的复制指浅拷贝,即不是拷贝物理内存而是让两个页表指向同一个物理地址。

kvmcopy函数在vm.c里,基本可以调已有的函数:

int kvmcopy(pagetable_t old, pagetable_t new, uint64 st, uint64 en) {
    pte_t *pte;
    uint64 pa, i;
    uint flags;

    if (en > PLIC) return -1;

    st = PGROUNDUP(st);

    for(i = st; i < en; i += PGSIZE) {
        if((pte = walk(old, i, 0)) == 0)
            panic("kvmcopy: pte should exist");
        if((*pte & PTE_V) == 0)
            panic("kvmcopy: page not present");
        pa = PTE2PA(*pte);
        flags = PTE_FLAGS(*pte) & (~PTE_U);
        if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0) goto err;
    }
    return 0;
err:
    uvmunmap(new, 0, i / PGSIZE, 0);
    return -1;
}

补个自己的错误,就是忘了st = PGROUNDUP(st) 一句,这句很重要,因为对物理内存的操作都是以页为单位的,如果这句忘了,这个没对齐的地址可能就会落到之前某个已经映射过的页中间,导致重映射错误。害我调试了一个晚上……

然后是exec函数,这里就有点坑了,我们观察到原函数在处理用户页表的时候是先开辟一个新的用户页表,然后该映射映射,再把老的用户页表释放掉,很自然的也会把这番操作套到内核页表上。但是,这样会爆空间!我被这个卡了很久,后来看了实验的测试程序,发现测试非常极限,会先不断申请空间直到空闲空间只剩一丁点的时候运行你的exec函数,这时你要是先开辟一个新的内核页表,老的内核页表还在,自然爆空间。看了别人的代码才知道正确做法是直接把内核页表的用户区全部解除映射,再重新映射上新的用户页表,这样有两个好处,一来省空间;二来不用释放老的内核页表,注意这个删除不是随便删就了事的,因为你当前是在内核态,这个进程正用着这个老页表,删了它直接翻车,还得先把satp切换为新页表并刷新TLB,这个我也调了很久,用上面的方法就不需要考虑这个问题。所以说内存这种东西,尽可能重用,谨慎删除:

  oldpagetable = p->pagetable;
  p->pagetable = pagetable;
  kvmdealloc(p->kpagetable, p->sz, 0);
  if (kvmcopy(p->pagetable, p->kpagetable, 0, sz) < 0) goto bad;

开辟新内核页表的写法比上面还麻烦很多,正确写法空间效率和代码效率均强,真的服气。kvmdealloc函数代码如下,如前所述,只要解除映射即可:

uint64 kvmdealloc(pagetable_t kpagetable, uint64 oldsz, uint64 newsz) {
    if(newsz >= oldsz)
        return oldsz;

    if(PGROUNDUP(newsz) < PGROUNDUP(oldsz)){
        int npages = (PGROUNDUP(oldsz) - PGROUNDUP(newsz)) / PGSIZE;
        uvmunmap(kpagetable, PGROUNDUP(newsz), npages, 0);
    }
    return newsz;
}

sbrk函数直接调用proc.c里的growproc函数,所以直接改这个函数。类似fork函数,内核页表只要随着用户页表来动就行,用户内存扩大,它就复制扩大的部分,用户内存缩小,它就对应解除缩小部分的映射:

  if(n > 0){
    if((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
      return -1;
    }
    if (kvmcopy(p->pagetable, p->kpagetable, p->sz, p->sz + n) != 0) {
        return -1;
    }
  } else if(n < 0){
    sz = uvmdealloc(p->pagetable, sz, sz + n);
    kvmdealloc(p->kpagetable, p->sz, p->sz + n);
  }

总结一下,这个实验难度其实是非常高的,我也因此写加调了好几天。我本人一开始的做法是照葫芦画瓢,直接修改uvmalloc和uvmdealloc函数让其同时处理用户页表和内核页表,结果这种设计到最后调不下去了。原因在于内核页表实际上就是对用户页表的一个引用,所以直接用一个浅拷贝函数kvmcopy就可以轻松直接地完成大量操作,再加上一个解除绑定的kvmdealloc,这种设计就非常简洁且容易调试,但是需要思考。这也说明架构和设计极其重要。