Home MIT 6.s081 Lab page tables
Post
Cancel

MIT 6.s081 Lab page tables


Lab: page tables (mit.edu)

实现vmprint函数,接收一个pagetable_t类型的参数,然后按照规定格式输出pte和pa,功能是输出给定进程的页表,例如:

1
2
3
4
5
6
7
8
9
10
page table 0x0000000087f6e000
..0: pte 0x0000000021fda801 pa 0x0000000087f6a000
.. ..0: pte 0x0000000021fda401 pa 0x0000000087f69000
.. .. ..0: pte 0x0000000021fdac1f pa 0x0000000087f6b000
.. .. ..1: pte 0x0000000021fda00f pa 0x0000000087f68000
.. .. ..2: pte 0x0000000021fd9c1f pa 0x0000000087f67000
..255: pte 0x0000000021fdb401 pa 0x0000000087f6d000
.. ..511: pte 0x0000000021fdb001 pa 0x0000000087f6c000
.. .. ..510: pte 0x0000000021fdd807 pa 0x0000000087f76000
.. .. ..511: pte 0x0000000020001c0b pa 0x0000000080007000

risc-v采用三级页表,因此当页表深度>3时直接返回。每个页表占1页,每页4096字节,每个pte64位,共8字节,因此每个页中有512个pte。对于每个pagetable,遍历512个pte,如果该pte的PTE_V位为1,说明有效,可以通过PTE2PA获得该pte所对应的物理地址,输出pte和pa,并将pa作为下一级页表地址,继续输出,完整代码如下:

void 
vmprint_r(pagetable_t pagetable, int curdepth) {
  if (curdepth > 3) 
    return;
  for (int i = 0; i < 512; i++) {
    pte_t pte = pagetable[i];
    if(pte & PTE_V) {
      printf("..");
      for (int j = 1; j < curdepth; j++) {
        printf(" ..");
      }
      uint64 pa = PTE2PA(pte);
      printf("%d: pte %p pa %p\n", i, pte, pa);
      vmprint_r((pagetable_t)pa, curdepth + 1);
    }
  }
}

void
vmprint(pagetable_t pagetable) {
  printf("page table %p\n", pagetable);
  vmprint_r(pagetable, 1);
}

A kernel page table per process

xv6在内核态执行时,不同进程共用一个内核页表,采用直接映射方式,即内核态下虚拟地址x直接映射到物理地址x,而在用户态执行时,不同进程各自有一个用户地址空间,从虚拟地址0开始映射到物理地址。

在内核态下采用直接映射的好处是可以简化内核对物理地址读写的操作。例如,在fork时,需要为子进程分配用户内存,分配器返回物理地址,然后fork在将父进程的用户内存拷贝到子进程的用户内存时,不需要再进行一次内存映射,而是直接将分配器返回的物理地址当作虚拟地址来使用。

内核页表中不存在到用户内存的映射,因此用户地址空间的地址在内核态下是无效的,因此当内核需要使用在系统调用中传递的用户空间的指针时,首先需要将该指针转换为物理地址。这部分的目的就是为了解决这个问题,在内核页表中添加到用户空间的映射,从而能够直接根据内核页表对用户地址进行解引用而不需要进行额外的地址翻译操作。

首先需要为每个进程添加一个内核页表,从而使每个进程在内核态下执行时具有单独的地址空间。在struct proc中添加一个字段:

struct proc {
  ...
  uint64 sz;                   // Size of process memory (bytes)
  pagetable_t pagetable;       // User page table
  pagetable_t kernel_pagetable;      // Kernel page table
  struct trapframe *trapframe; // data page for trampoline.S
  ...
};

在allocproc中创建进程时,需要对内核页表进行初始化,分别仿照kvminit和procinit设置内核页表和内核栈:

static struct proc*
allocproc(void)
{
	......

found:
  p->kernel_pagetable = kernelvminit();
  if (p->kernel_pagetable == 0) {
    freeproc(p);
    release(&p->lock);
    return 0;
  }
  if (mappages(p->kernel_pagetable, TRAPFRAME, PGSIZE,
              (uint64)(p->trapframe), PTE_R | PTE_W) != 0)
    panic("map frame");

  // An empty user page table.
  p->pagetable = proc_pagetable(p);
  if(p->pagetable == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
  }

  char *pa = kalloc();
  if (pa == 0) 
    panic("alloc KSTACK");
  memset(pa, 0, PGSIZE);
  uint64 va = KSTACK((int) 0);
  if (mappages(p->kernel_pagetable, va, PGSIZE, (uint64)pa, PTE_R | PTE_W) != 0) 
    panic("map kstack");
  p->kstack = va;

	......

  return p;
}

kernelvminit和kvminit的区别在于,kvminit用于初始化整个内核的内核页表,kernelvminit用于初始化各个进程的内核页表。在为每个进程添加了内核页表之后,全局的kernel_pagetable仅在CPU空转时由0号进程使用,而其他进程占用CPU时使用自己的内核页表,对应地,由于CLINT仅在内核启动时使用,因此在初始化每个进程的内核页表时不需要映射这部分:

void 
pagemap(pagetable_t pagetable, uint64 va, uint64 pa, uint64 sz, int perm) 
{
  if(mappages(pagetable, va, sz, pa, perm) != 0)
    panic("pagemap");
}
// implement a modified version of kvminit that makes a new page table 
// instead of modifying kernel_pagetable
pagetable_t 
kernelvminit() {
  pagetable_t pagetable = (pagetable_t) kalloc();
  memset(pagetable, 0, PGSIZE);
  // 应该map到新创建的pagetabe,而不是映射到原本的页表

  // uart registers
  pagemap(pagetable, UART0, UART0, PGSIZE, PTE_R | PTE_W);

  // virtio mmio disk interface
  pagemap(pagetable, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);

  // CLINT
  // pagemap(pagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);

  // PLIC
  pagemap(pagetable, PLIC, PLIC, 0x400000, PTE_R | PTE_W);

  // map kernel text executable and read-only.
  pagemap(pagetable, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);

  // map kernel data and the physical RAM we'll make use of.
  pagemap(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.
  pagemap(pagetable, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
  
  return pagetable;
}

在scheduler中,切换进程时,需要将改进程的内核页表切换到satp寄存器中并刷新页表缓存,如果没找到合适的进程切换,应当使用全局内核页表代替。在这里有个我想了很久才想明白的问题,为什么在swtch之后调用kvminithart()而不是在没找到可运行进程时再调用,我认为原因是在swtch时,已经从scheduler切换到了选定的进程执行,而swtch返回是因为进程执行结束或者其他情况需要调度器调度,此事应该切换回来,使用init进程本身的内核页表,并且刷新快表缓存:

void
scheduler(void)
{
 	......
        // switch h/w page table register to the kernel's page table
        w_satp(MAKE_SATP(p->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.
    	kvminithart();
        c->proc = 0;

        found = 1;
      }
      release(&p->lock);
    }
	......
}

在释放进程时,需要加入对内核页表和内核栈的处理,只需要释放页表而不需要释放页表映射的最低一级的物理页:

static void
freeproc(struct proc *p)
{
  if(p->trapframe)
    kfree((void*)p->trapframe);
  p->trapframe = 0;
  if(p->pagetable)
    proc_freepagetable(p->pagetable, p->sz);
  p->pagetable = 0;

  void *kstack_pa = (void *)kvmpa(p->kernel_pagetable, p->kstack);
  kfree(kstack_pa);
  p->kstack = 0;
  // free a page table without also freeing the leaf physical memory pages
  if (p->kernel_pagetable) 
    proc_freevmpagetable(p->kernel_pagetable, p->sz);
  p->kernel_pagetable = 0;
  p->sz = 0;
  p->pid = 0;
  p->parent = 0;
  p->name[0] = 0;
  p->chan = 0;
  p->killed = 0;
  p->xstate = 0;
  p->state = UNUSED;
}

void 
proc_freevmpagetable(pagetable_t pagetable, uint64 sz)
{
  for(int i = 0; i < 512; i++){
    pte_t pte = pagetable[i];
    uint64 child = PTE2PA(pte);
    if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){ // 如果该页表项指向更低一级的页表
      // 递归释放低一级页表及其页表项
      proc_freevmpagetable((pagetable_t)child, sz);
      pagetable[i] = 0;
    }
  }
  kfree((void*)pagetable);
}

在进行了上述修改后,运行make qemu,会出现panic:virtio status,究其原因,是因为在virtio_disk_rw中,原本的获得buf0的虚拟地址采用的是kvmpa函数,而kvmpa通过对全局的内核页表进行查询以获得va对应的物理地址,在为每个进程分配一个内核页表后,应该对kvmpa作出修改,以使其查找自己的内核页表以获得物理地址,修改后可成功运行:

void
virtio_disk_rw(struct buf *b, int write)
{
  ......
  buf0.reserved = 0;
  buf0.sector = sector;
  // buf0 is on a kernel stack, which is not direct mapped,
  // thus the call to kvmpa().
  pagetable_t pagetbl = myproc()->kernel_pagetable;
  disk.desc[idx[0]].addr = (uint64) kvmpa(pagetbl, (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];
  ......
}

对应的kvmpa:

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;
}

Simplify copyin/copyinstr

内核的copyin函数通过遍历进程的用户页表将用户提供的指针翻译成内核可以直接解引用的物理地址,而这部分的任务是在每个进程的内核页表中添加到用户内存的映射,从而能够使内核直接解引用用户提供的虚拟地址而不需要再去遍历用户页表,从而可以利用CPU的硬件寻址功能进行寻址,并且可以使用快表加速。

这一机制依赖于用户空间的虚拟地址不会与内核的虚拟地址空间发生重叠,在xv6中,内核虚拟地址空间在0xC000000以上,这个值保存在PLIC寄存器中,而用户地址空间从0开始,因此需要限制用户空间的增长不要超过PLIC。

为了实现以上目的,需要在每处内核对用户页表进行修改时,将同样的修改同步到内核页表中,使得两个页表的程序段地址空间的映射同步。

首先实现将用户页表的指定地址空间拷贝到内核页表中:

int kvmcopy_pagetbl(pagetable_t src, pagetable_t dst, uint64 start, uint64 sz) {
	pte_t *pte;
	uint64 pa, i;
	uint64 flags;
	
	for (i = PGROUNDUP(start); i < start + sz; i += PGSIZE){
		if ((pte = walk(src, i, 0)) == 0)
			panic("kvmcopy_pagetbl: walk");
		if ((*pte & PTE_V) == 0)
            panic("kvmcopy_pagetbl: invalid page");
        pa = PTE2PA(*pte);
        
        flags = PTE_FLAGS(*pte) & ~PTE_U;
        if (mappages(dst, i, PGSIZE, pa, flags) != 0) {
            goto err;
        }
	}
    return 0;
err:
    uvmunmap(dst, PGROUNDUP(start), (i - PGROUNDUP(start)) / PGSIZE, 0);
    return -1;
}

对应地,添加解除映射的函数:

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;
		uvmunmap(pagetable, PGROUNDUP(newsz), npages, 0);
	}
	
	return newsz;
}

在exec中加入代码防止用户内存超出PLIC的限制:

int
exec(char *path, char **argv)
{
	//......
 for(i=0, off=elf.phoff; i<elf.phnum; i++, off+=sizeof(ph)){
 	//.....
     if((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz)) == 0)
      goto bad;
    if (sz1 >= PLIC)
      goto bad;
    sz = sz1;
    //......
  }
  //......
}

在fork中将用户内存从父进程拷贝到子进程中之后,将子进程用户页表的起始地址映射到内核页表中:

int fork(void) {
	// ......
  if (kvmcopy_pagetbl(np->pagetable, np->kernel_pagetable, 0, p->sz) != 0){
    freeproc(np);
    release(&np->lock);
    return -1;
  }
  // ......
}

在exec中,由于需要使用新的程序映像替换,因此需要首先清除内核页表中对于旧的用户地址空间的映射,然后加入新的内存映射:

int
exec(char *path, char **argv)
{
 //......
 // Save program name for debugging.
  for(last=s=path; *s; s++)
    if(*s == '/')
      last = s+1;
  safestrcpy(p->name, last, sizeof(p->name));
    
  uvmunmap(p->kernel_pagetable, 0, PGROUNDUP(oldsz) / PGSIZE, 0);
  if (kvmcopy_pagetbl(pagetable, p->kernel_pagetable, 0, sz) != 0)
    goto bad;
  // 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);

  //......
}

growproc中,首先需要加入对于映射的用户空间大小的限制,然后在proc的用户空间增长和缩小时同步缩小对应的映射:

int growproc(int n)
{
  uint sz;
  struct proc *p = myproc();

  sz = p->sz;
  if(n > 0){
    uint64 newsz;
    if((newsz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
      return -1;
    }
    // 内核页表中的映射同步扩大
    if(kvmcopy_pagetbl(p->pagetable, p->kernel_pagetable, sz, n) != 0) {
      uvmdealloc(p->pagetable, newsz, sz);
      return -1;
    }
    sz = newsz;
  } else if(n < 0){
    uvmdealloc(p->pagetable, sz, sz + n);
    // 内核页表中的映射同步缩小
    sz = kvmdealloc(p->kernel_pagetable, sz, sz + n);
  }
  p->sz = sz;
  return 0;
}

对于init进程,由于不是由fork创建的,因此在userinit中同样需要加入映射代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
  kvmcopy_pagetbl(p->pagetable, p->kernel_pagetable, 0, p->sz);

  // ......
}

将copyin和copyinstr函数注释掉,改为直接调用copyinnew和copyinstr_new两个函数,make qemu运行usertests,成功通过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
== Test count copyin == 
$ make qemu-gdb
count copyin: OK (1.2s) 
== Test usertests == 
$ make qemu-gdb
(194.3s) 
== 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 
== Test time == 
time: OK 
This post is licensed under CC BY 4.0 by the author.