【操作系统】MIT 6.s081 LAB3

LAB 3: Page tables

原文地址:YSBLOG
参考:[mit6.s081] 笔记 Lab3: Page tables | 页表 | Miigon’s blog

实验目的:学习页表的实现机制,简化用户态拷贝数据到内核态的方法。

在xv6原本的设计中,用户进程在用户态使用各自的用户态页表,当需要进入内核态时(例如执行系统调用),则切换到内核页表(修改SATP寄存器的值),这个内核页表是全局共享的,在xv6源码中的定义在vm.c中。本次实验中,将其称之为全局内核页表。

// vm.c
pagetable_t kernel_pagetable;

若当进程处于内核态且需要访问用户页表中某个数据时,无法通过当前数据的在用户态中虚拟地址进行访问,因为此时内核态中使用的页表为全局内核页表,在该页表中不存在用户进程页表中的内容,所以内核无法使用CPU中硬件MMU来翻译对应的虚拟地址,只能通过软件模拟的方式(vm.c中的walkaddr函数)来访问,访问效率比较低。

该实验想要改变上述特性,希望内核能够访问用户进程对应的虚拟地址,在该实验中,需要给每个进程各自维护一张不同的进程内核页表(user_kernel_pagetable),当陷入内核时,将该进程的用户进程页表内容填入到进程内核页表中,然后切换到这个进程内核页表中,内核就可以直接使用虚拟地址来访问系统调用函数了。

Print a page table (easy)

为了方便后续调试,第一个页表相关的任务是打印页表内容。

kernel/defs.h文件中添加函数定义

void vmprint(pagetable_t);

exec.c return argc;之前添加

if (p->pid == 1) {
	vmprint(p->pagetable);
}

参考kernel/vm.cfreewalk(pagetable_t pagetable)函数访问页表的方式递归输出页表信息,在kernel/vm.c中添加

void _vmprint(pagetable_t pagetable, int level) {
  for (int i = 0; i < 512; ++i) {
    pte_t pte = pagetable[i];
    if ((pte & PTE_V)) {
      for (int j = 0; j < level; ++j) {
        if (j == 0) printf("..");
        else printf(" ..");
      }
      uint64 child = PTE2PA(pte); // 通过pte映射下一级页表的物理地址
      printf("%d: pte %p pa %p\n", i, pte, child);
      // 查看flag是否被设置,若被设置,则为最低一层
      // 只有在页表的最后一级,才可进行读、写、执行
      if ((pte & (PTE_R | PTE_W | PTE_X)) == 0)
        _vmprint((pagetable_t)child, level + 1);
    }
  }
}

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

重新启动xv6后得到对应结果。

image-20211220111822887

A kernel page table per process (hard)

在这个任务中需要在用户进程中构建对应的用户进程内核页表,在实验过程中需要注意,要让进程的内核页表与全局的内核页表完全一致。

实验步骤如下:

kernel/proc.h中的struct proc添加新成员变量kernel_pagetable

kernel/vm.c中增加一个proc_kvminit(),逻辑与kvminit基本一致。同时需要参考kvmmap实现对应的辅助函数ukvmmap

void
ukvmmap(pagetable_t pagetable, uint64 va, uint64 pa, uint64 sz, int perm)
{
  if(mappages(pagetable, va, sz, pa, perm) != 0)
    panic("ukvmmap");
}

pagetable_t
proc_kvminit() {
  // 申请一个页表空间
  pagetable_t proc_kernel_pagetable = (pagetable_t) kalloc();
  if (proc_kernel_pagetable == 0)
    return 0;
  memset(proc_kernel_pagetable, 0, PGSIZE);
  // 与vminint内容上保持一致
  ukvmmap(proc_kernel_pagetable, UART0, UART0, PGSIZE, PTE_R | PTE_W);
  ukvmmap(proc_kernel_pagetable, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
  ukvmmap(proc_kernel_pagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
  ukvmmap(proc_kernel_pagetable, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
  ukvmmap(proc_kernel_pagetable, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
  ukvmmap(proc_kernel_pagetable, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);
  ukvmmap(proc_kernel_pagetable, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
  return proc_kernel_pagetable;
}

现在可以在进程间创建相互独立的用户进程内核页表了,但是想要让进程在多进程模式下正确调度,还需要给每个用户进程页表分配一个内核栈。

在原本的xv6中,由于同一时间可能有多个进程处于内核态,所以不同进程的内核栈需要相互独立。procinit()函数会为所有进程(xv6默认构建64个进程)都预先分配了内核栈kstack,并将其map到内核的高地址空间中(参考xv6原始版本中procinit()实现),每个进程使用一个页作为kstack,并且两个不同的kstack中间隔一个无映射的guard page用于检测栈溢出错误。

image-20211225183528509

在我们新的设计中,由于每个进程拥有一张独立的用户进程内核页表,不在需要考虑不同进程之间内核栈访问溢出的情况,所以我们可以将所以的内核栈map到各自的用户进程内核页表中的固定位置中,也无需增加guard page

接着,在kernel/proc.c中的allocproc函数中添加调用proc_kvminit()的代码段,以便在初始化进程空间时初始化用户内核页表。然后参考kernel/proc.c中的procinit()中代码,为每个内核页表初始化内核栈。同时注释原本初始化内核栈的代码。

// kernel/proc.c void procinit(void)
// initialize the proc table at boot time.
void
procinit(void)
{
  struct proc *p;
  
  initlock(&pid_lock, "nextpid");
  for(p = proc; p < &proc[NPROC]; p++) {
      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));
  //     kvmmap(va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
  //     p->kstack = va;
  }
  kvminithart();
}

// 创建进程时,为进程分配独立的内核页表,以及内核栈
// kernel/proc.c static struct proc* allocproc(void)
// 空的user kernel page table
// An empty user kernel page table
p->kernel_pagetable = proc_kvminit();
if (p->kernel_pagetable == 0) {
  freeproc(p);
  release(&p->lock);
  return 0;
}
// 初始化当前内核页表的内核栈
char *pa = kalloc();
if (pa == 0)
  panic("kalloc");
// 将内核栈映射到用户内核页表固定的部分
uint64 va = KSTACK((int)0);
// 添加kernel stack的映射到用户的kernel pagetable中
ukvmmap(p->kernel_pagetable, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
p->kstack = va;

进行到这一步,独立的用户进程内核页表以及创建完成了,下一步,需要确保在切换进程是能够将对应进程的用户内核页表的地址载入SATP寄存器中,所以要在kernel/proc.cscheduler函数中进行修改。修改方式参考kernel/vm.c kvminithart函数。根据要求,需要在每个任务执行结束后切换回kernel_pagetable

void
scheduler(void)
{
  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);
      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;
        
        // 在切换任务前,将用户内核页表替换到stap寄存器中
        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.
        // 该进程执行结束后,将SATP寄存器的值设置为全局内核页表地址
        kvminithart();
        c->proc = 0;

        found = 1;
      }
      release(&p->lock);
    }
#if !defined (LAB_FS)
    if(found == 0) {
      intr_on();
      asm volatile("wfi");
    }
#else
    ;
#endif
  }
}

根据提示,下一步我们需要考虑在销毁进程时释放对应的内核页表。需要注意的是,在释放内核页表前需要先释放进程对应的内核栈空间。在kernel/proc.c freeproc中进行修改

// free a proc structure and the data hanging from it,
// including user pages.
// p->lock must be held.
static void
freeproc(struct proc *p)
{
  if(p->trapframe)
    kfree((void*)p->trapframe);
  p->trapframe = 0;
  
  // 删除内核栈
  if (p->kstack) {
    // 通过页表地址, kstack虚拟地址 找到最后一级的页表项
    pte_t* pte = walk(p->kernel_pagetable, p->kstack, 0);
    if (pte == 0)
      panic("freeproc : kstack");
    // 删除页表项对应的物理地址
    kfree((void*)PTE2PA(*pte));
  }
  if(p->pagetable)
    proc_freepagetable(p->pagetable, p->sz);
  
  // 删除kernel pagetable
  if (p->kernel_pagetable) 
    proc_freekernelpagetable(p->kernel_pagetable);
    
  p->kernel_pagetable = 0;
  p->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_freekernelpagetable(pagetable_t pagetable){
  for (int i = 0; i < 512; ++i) {
    pte_t pte = pagetable[i];
    if ((pte & PTE_V)) {
      pagetable[i] = 0;
      if ((pte & (PTE_R | PTE_W | PTE_X)) == 0) {
        uint64 child = PTE2PA(pte);
        proc_freekernelpagetable((pagetable_t)child);
      }
    } else if (pte & PTE_V) {
      panic("proc free kernelpagetable : leaf");
    }
  }
  kfree((void*)pagetable);
}

需要将 walk 函数的定义添加到 kernel/defs.h 中,否则无法直接引用。

最后,修改kernel/vm.ckvmpa,将walk函数使用的全局内核页表地址换成进程自己的内核页表地址。

// translate a kernel virtual address to
// a physical address. only needed for
// addresses on the stack.
// assumes va is page aligned.
uint64
kvmpa(uint64 va)
{
  uint64 off = va % PGSIZE;
  pte_t *pte;
  uint64 pa;

  // 使用用户进程自己的内核页表地址来翻译虚拟地址
  struct proc *p = myproc();
  pte = walk(p->kernel_pagetable, va, 0);
 
  if(pte == 0)
    panic("kvmpa");
  if((*pte & PTE_V) == 0)
    panic("kvmpa");
  pa = PTE2PA(*pte);
  return pa+off;
}

记得在kernel/vm.c中添加对应头文件

#include "spinlock.h"
#include "proc.h"

Simplify copyin/copyinstr(hard)

承接上一个任务,现在每个进程已经拥有独立的用户进程内核页表了,这个任务的目的就是在用户进程内核页表中添加用户页表映射的副本,这样当该进程陷入内核时,就能够通过内核正确翻译虚拟地址,从而访问用户进程中的数据。

这样做相比原来 copyin 的实现的优势是,原来的 copyin 是通过软件模拟访问页表的过程获取物理地址的,而在内核页表内维护映射副本的话,可以利用 CPU 的硬件(MMU)寻址功能进行寻址,效率更高并且可以受快表加速。

首先,我们要将用户页表的变化同步到用户进程内核页表中,则需要实现映射和缩减两个操作。

kernel/vm.c中添加kvmcopymappingskvmdealloc

// kernel/vm.c

// 注:需要在 defs.h 中添加相应的函数声明,这里省略。

// 将 src 页表的一部分页映射关系拷贝到 dst 页表中。
// 只拷贝页表项,不拷贝实际的物理页内存。
// 成功返回0,失败返回 -1
int
kvmcopymappings(pagetable_t src, pagetable_t dst, uint64 start, uint64 sz)
{
  pte_t *pte;
  uint64 pa, i;
  uint flags;

  // PGROUNDUP: 对齐页边界,防止 remap
  for(i = PGROUNDUP(start); i < start + sz; i += PGSIZE){
    if((pte = walk(src, i, 0)) == 0) // 找到虚拟地址的最后一级页表项
      panic("kvmcopymappings: pte should exist");
    if((*pte & PTE_V) == 0)	// 判断页表项是否有效
      panic("kvmcopymappings: page not present");
    pa = PTE2PA(*pte);	 // 将页表项转换为物理地址页起始位置
    // `& ~PTE_U` 表示将该页的权限设置为非用户页
    // 必须设置该权限,RISC-V 中内核是无法直接访问用户页的。
    flags = PTE_FLAGS(*pte) & ~PTE_U;
    // 将pa这一页的PTEs映射到dst上同样的虚拟地址
    if(mappages(dst, i, PGSIZE, pa, flags) != 0){
      // 清除已经映射的部分,但不释放内存
      uvmunmap(dst, 0, i / PGSIZE, 0);
      return -1;
    }
  }
  return 0;
}

// 与 uvmdealloc 功能类似,将程序内存从 oldsz 缩减到 newsz。但区别在于不释放实际内存
// 用于内核页表内程序内存映射与用户页表程序内存映射之间的同步
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;
}

根据提示,用户页表在用户内核页表中的映射范围为[0,PLIC],但是从xv6 book中可以看到,全局内核页表的定义中在[0,PLIC]之间存在一个CLINT核心本地中断,CLINT仅在内核启动时使用,所以用户进程内核页表中无需再存在CLINT,所以我们将proc_kvminit()中CLINT映射的部分注释掉。防止再映射用户页表时出现remap

image-20211230170843797
// vm.c
pagetable_t
proc_kvminit() {
  // 申请一个页表空间
  pagetable_t proc_kernel_pagetable = (pagetable_t) kalloc();
  if (proc_kernel_pagetable == 0)
    return 0;
  memset(proc_kernel_pagetable, 0, PGSIZE);
  // 与vminint内容上保持一致
  ukvmmap(proc_kernel_pagetable, UART0, UART0, PGSIZE, PTE_R | PTE_W);
  ukvmmap(proc_kernel_pagetable, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
  // 用户进程内核页表无需在映射CLINT,将空间留出映射用户页表
  // ukvmmap(proc_kernel_pagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
  ukvmmap(proc_kernel_pagetable, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
  ukvmmap(proc_kernel_pagetable, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
  ukvmmap(proc_kernel_pagetable, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);
  ukvmmap(proc_kernel_pagetable, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
  return proc_kernel_pagetable;
}

根据提示,需要在fork(),sbrk(),exec()中进行修改,需要在这些函数改变用户进程页表后改变用户内核页表。

// kernel/proc.c
int
fork(void)
{
  // ......

  // Copy user memory from parent to child. (调用 kvmcopymappings,将**新进程**用户页表映射拷贝一份到新进程内核页表中)
  if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0 ||
     kvmcopymappings(np->pagetable, np->kernelpgtbl, 0, p->sz) < 0){
    freeproc(np);
    release(&np->lock);
    return -1;
  }
  np->sz = p->sz;

  // ......
}

sysproc.c中的sys_sbrk()中可以发现,执行内存相关的函数为growproc(),所以我们对growproc()进行修改。需要注意的是,用户页表的扩大和缩小都需要进行同步。

// kernel/proc.c
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(kvmcopymappings(p->pagetable, p->kernelpgtbl, 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->kernelpgtbl, sz, sz + n);
  }
  p->sz = sz;
  return 0;
}

修改exec(),在映射之前要先检测程序大小是否超过PLIC,防止remap,同时映射前要先清除[0,PLIC]中原本的内容,在将要执行的程序映射到[0,PLIC]中。

// kernel/exec.c
int
exec(char *path, char **argv)
{
  // ......
  // Load program into memory.
  for(i=0, off=elf.phoff; i<elf.phnum; i++, off+=sizeof(ph)){
    if(readi(ip, 0, (uint64)&ph, off, sizeof(ph)) != sizeof(ph))
      goto bad;
    if(ph.type != ELF_PROG_LOAD)
      continue;
    if(ph.memsz < ph.filesz)
      goto bad;
    if(ph.vaddr + ph.memsz < ph.vaddr)
      goto bad;
    uint64 sz1;
    if((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz)) == 0)
      goto bad;
    // 添加检测,防止程序大小超过 PLIC
    if(sz1 >= PLIC) {
      goto bad;
    }
    sz = sz1;
    if(ph.vaddr % PGSIZE != 0)
      goto bad;
    if(loadseg(pagetable, ph.vaddr, ip, ph.off, ph.filesz) < 0)
      goto bad;
  }
  // ......

  // 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->kernelpgtbl, 0, PGROUNDUP(oldsz)/PGSIZE, 0);
  kvmcopymappings(pagetable, p->kernelpgtbl, 0, sz);
  
  // 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);
  // ......
}

然后,根据提示,需要在userinit的内核页表中包含第一个进程的用户页表

// 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;
  kvmcopymappings(p->pagetable, p->kernelpgtbl, 0, p->sz); // 同步程序内存映射到进程内核页表中

  // ......
}

最后,重写copyincopyinstr函数,用copyin_newcopyinstr_new进行替换,其相关实现已经在vmprint.c中完成。注意在kernel/defs.h中添加对于函数声明。

// defs.h
// vmcopyin.c
int             copyin_new(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len);
int             copyinstr_new(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max);

// vm.c
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);
}
  • 6
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值