操作系统MIT6.S081:Lab3->Page tables

本系列文章为MIT6.S081的学习笔记,包含了参考手册、课程、实验三部分的内容,前面的系列文章链接如下
操作系统MIT6.S081:[xv6参考手册第1章]->操作系统接口
操作系统MIT6.S081:P1->Introduction and examples
操作系统MIT6.S081:Lab1->Unix utilities
操作系统MIT6.S081:[xv6参考手册第2章]->操作系统组织结构
操作系统MIT6.S081:P2->OS organization and system calls
操作系统MIT6.S081:Lab2->System calls
操作系统MIT6.S081:[xv6参考手册第3章]->页表
操作系统MIT6.S081:P3->Page tables


前言

在本实验中,你将探索页表并修改它们来简化将数据从用户空间复制到内核空间的函数。在开始实验之前,请阅 xv6参考手册的第3章和相关源代码:

  • kern/memlayout.h,它捕获内存的布局。
  • kern/vm.c,包含大多数虚拟内存(VM)相关的代码。
  • kernel/kalloc.c,包含分配和释放物理内存的代码。

开始实验前,使用以下命令将实验分支切换至pgtbl
在这里插入图片描述


一、Print a page table

1.1 实验描述

实验目的

为了帮助你了解RISC-V页表,并且可能有助于将来的调试,你的首要任务是编写一个打印页表内容的函数,详情如下:
①定义一个名为vmprint()的函数。它接受一个pagetable_t类型的参数,并以下面描述的格式打印页表。
②在exec.c中的return argc之前插入if(p->pid==1) vmprint(p->pagetable)来打印第一个进程的页表。
③如果你通过了make gradepte printout测试,你将获得满分。

测试案例

当你启动xv6时,它应该打印以下输出,描述第一个进程在刚刚完成 exec()初始化时的页表:
在这里插入图片描述
①第一行显示vmprint的参数。
②之后,对每个PTE都输出一行内容。
③每个PTE行都缩进了一些..,表示它在三级页表树结构中的深度。
④每个PTE行显示其页表页面中的PTE索引、从PTE中提取的物理地址。
⑤不要打印无效的PTE。在上面的示例中,第一级页表页面具有条目0和255的映射。条目0的下一层仅映射了索引0,而该索引0的底层具有条目0、1和2映射。
⑥你的代码可能会展示出与上面不同的物理地址,但条目数和虚拟地址应该相同。

实验提示

①你可以将vmprint()放在kernel/vm.c中。
②使用文件kernel/riscv.h末尾的宏。
freewalk函数可能是对你有帮助的。
④在kernel/defs.h中声明vmprint,以便你可以从exec.c中调用它。
⑤在调用printf时使用%p打印出完整的64位十六进制PTE和地址,如测试案例中所示。

思考

根据文中的图3-4解释vmprint的输出。第0页包含什么?第2页是什么?在用户模式下运行时,进程可以读取/写入第1页映射的内存吗?


1.2 实验思路

参照实验提示的步骤一步一步来完成实验:

1、 根据实验提示①,我们先将vmprint()函数放在kernel/vm.c中。我们暂时不知道函数的返回值、参数列表、具体内容,所以先定义一个无返回值、参数和内容的函数。

void vmprint()
{
  
}

2、 根据实验提示②,我们去看看文件kernel/riscv.h末尾的宏。可以看出,这些宏定义了许多重要的信息,如页表的数据类型、每个page的大小、偏移量、PTE的flag、PTE和物理地址的相互转换等等。

#define PGSIZE 4096 // 每一个page的大小:4096 Bytes
#define PGSHIFT 12  // page的offset:12个bit

#define PGROUNDUP(sz)  (((sz)+PGSIZE-1) & ~(PGSIZE-1))
#define PGROUNDDOWN(a) (((a)) & ~(PGSIZE-1))

//PTE中的若干flag
#define PTE_V (1L << 0) // valid
#define PTE_R (1L << 1) // readable
#define PTE_W (1L << 2) // writable
#define PTE_X (1L << 3) // executable
#define PTE_U (1L << 4) // 1 -> user can access

// shift a physical address to the right place for a PTE.
//物理地址转换为PTE
#define PA2PTE(pa) ((((uint64)pa) >> 12) << 10)

//PTE转换为物理地址
#define PTE2PA(pte) (((pte) >> 10) << 12)

// 取出PTE中的标志位
#define PTE_FLAGS(pte) ((pte) & 0x3FF)

// 指示每一级页表的页表偏移为9位
// extract the three 9-bit page table indices from a virtual address.
#define PXMASK          0x1FF // 9 bits
// 根据页表的级数(0, 1, 2),取对应VPN的偏移
#define PXSHIFT(level)  (PGSHIFT+(9*(level)))
//得到相应级数页表的页表偏移
#define PX(level, va) ((((uint64) (va)) >> PXSHIFT(level)) & PXMASK)

// one beyond the highest possible virtual address.
// MAXVA is actually one bit less than the max allowed by
// Sv39, to avoid having to sign-extend virtual addresses
// that have the high bit set.
#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))

typedef uint64 pte_t;
typedef uint64 *pagetable_t; // 512 PTEs

3、 根据实验提示③,查看kernel/vm.c中的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.
  //遍历当前level页表中的PTE
  for(int i = 0; i < 512; i++){
    pte_t pte = pagetable[i];
     // 查看flag是否被设置,若被设置,则为最低一层。只有在页表的最后一级,才可进行读、写、执行。
     // 如果不是最低一层,则继续往下走,直到最后一层开始回溯。
    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);
}

可以看出:

此函数实现的功能是递归地去释放页表的各个page,具体来讲:
①页表的数据结构是pagetable_t,在上面定义宏的代码中可以看到pagetable_t实际上就是uint64 *,即一个指针,所以页表实际上是一个数组。
②该函数首先有一个循环来遍历各级页表数组中的512个PTE。
③在循环中,判断当前PTE是否存在且有效。
----如果满足条件,就获取该PTE对应的物理page(下一级PTE所在的page),并将其作为参数递归调用当前的freewalk函数,直到走到叶子。最后将当前PTE置0。
----如果不满足条件,就触发一个panic
④调用kfree释放内存。


4、 根据上面的freewalk,我们可以类似的写出完整的vmprint函数。

void vmp(pagetable_t pagetable, uint64 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映射下一级页表的物理地址
      //打印pte的编号、pte地址、pte对应的物理地址(下一级页表的物理地址)
      printf("%d: pte %p pa %p\n", i, pte, PTE2PA(pte));
      // 查看是否到了最后一级,如果没有则继续递归调用当前函数。
      if ((pte & (PTE_R | PTE_W | PTE_X)) == 0)
      {
        vmp((pagetable_t)child, level+1);
      }    
    }
  }
}

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

5、 根据实验提示④,在kernel/defs.h中定义vmprintvmp的原型。

void vmp(pagetable_t pagetable, uint64 level);
void vmprint(pagetable_t);

6、 根据实验提示⑤,在exec.c文件的return argc;之前添加以下代码,用于打印第一个进程的页表信息。

//进程的pid=1代表是第一个进程
if (p->pid == 1) {
	vmprint(p->pagetable);
}

1.3 实验结果

测试

①启动xv6,对应结果如下:
在这里插入图片描述
②执行./grade-lab-pgtbl pte print,结果如下:
在这里插入图片描述


二、A kernel page table per process

2.1 实验描述

实验背景

xv6有一个独立的内核页表,每当在内核中执行操作时都会使用它。内核页表直接映射到物理地址,因此内核虚拟地址 x 直接映射到物理地址 x。xv6为每个进程的用户地址空间提供了一个单独的页表,仅包含该进程的用户内存的映射,从虚拟地址0开始。因为内核页表不包含这些映射,所以用户地址在内核中是无效的。因此,当内核需要使用通过系统调用传递的用户指针(例如,传递给write()的缓冲区指针)时,内核必须首先将指针转换为物理地址。本节和下一节的目标是允许内核直接使用用户指针。

实验目的

①你的第一项工作是修改内核,以便每个进程在内核中执行时都使用自己的内核页表副本。
②修改struct proc为每个进程维护一个内核页表,并修改调度器使其在切换进程时切换内核页表。对于这一步,每个进程的内核页表应该与现有的全局内核页表相同。
③如果usertests运行正确,你就通过了这部分的实验。
:阅读本实验开始时提到的参考手册中对应的章节和代码。了解虚拟内存代码的工作原理后,将更容易正确地修改虚拟内存代码。页表设置中的错误可能会由于缺少映射而导致陷阱、可能导致加载和存储影响物理内存的意外页面、可能导致从错误的内存页面执行指令。

实验提示

①为进程添加一个代表内核页表的字段到struct proc
②为一个新的进程生成内核页表的合理方法是实现kvminit的修改版本,该修改版本生成新页表,而不是修改kernel_pagetable。你需要从allocproc调用此函数。
③确保每个进程的内核页表都有该进程的内核栈的映射。在未修改的xv6中,所有内核栈都在procinit中设置。你需要将部分或全部功能移至allocproc
④修改scheduler()以便将进程的内核页表加载到内核的satp寄存器中(请参阅kvminithart以获得灵感)。在调用w_satp()之后不要忘记调用sfence_vma()
scheduler()应该在没有进程运行时使用kernel_pagetable
⑥在freeproc中释放进程的内核页表。
⑦你将需要一种方法来释放页表,而无需同时释放叶物理内存页面。
vmprint在调试页表时可能会派上用场。
⑨修改xv6的函数或添加新的函数都可以。你可能至少需要在kernel/vm.ckernel/proc.c中执行此操作。(但是,不要修改kernel/vmcopyin.ckernel/stats.cuser/usertests.cuser/stats.c。)
⑩缺少页表映射可能会导致内核遇到页面错误。它将打印一个包含sepc=0x00000000XXXXXXXX的错误。你可以通过在kernel/kernel.asm中搜索XXXXXXXX来找出故障发生的位置。


2.2 实验思路

参照实验提示的步骤一步一步来完成实验:

1、 根据实验提示①,先去kernen/proc.h中查看struct proc

enum procstate { UNUSED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };

// Per-process state
struct proc {
  struct spinlock lock;

  // p->lock must be held when using these:
  enum procstate state;        // Process state
  struct proc *parent;         // Parent process
  void *chan;                  // If non-zero, sleeping on chan
  int killed;                  // If non-zero, have been killed
  int xstate;                  // Exit status to be returned to parent's wait
  int pid;                     // Process ID

  // these are private to the process, so p->lock need not be held.
  uint64 kstack;               // Virtual address of kernel stack
  uint64 sz;                   // Size of process memory (bytes)
  pagetable_t pagetable;       // User page table
  struct trapframe *trapframe; // data page for trampoline.S
  struct context context;      // swtch() here to run process
  struct file *ofile[NOFILE];  // Open files
  struct inode *cwd;           // Current directory
  char name[16];               // Process name (debugging)
};

proc结构体保存了进程相关的信息,可以看到里面有成员变量pagetable代表用户页表。现在我们添加一个成员变量kernel_pagetable,代表进程对应的内核页表。

pagetable_t proc_kernel_pagetable; 

2、 根据实验提示②,首先查看kernel/vm.c中的kvminit函数。该函数通过kalloc为内核创建一个空的页表,然后通过kvmmap完成直接映射操作。

/*
 * the kernel's page table.
 */
pagetable_t kernel_pagetable;
extern char etext[];  // kernel.ld sets this to end of kernel code.
extern char trampoline[]; // trampoline.S

/*
 * create a direct-map page table for the kernel.
 */
void
kvminit()
{
  kernel_pagetable = (pagetable_t) kalloc();
  memset(kernel_pagetable, 0, PGSIZE);
  // uart registers
  kvmmap(UART0, UART0, PGSIZE, PTE_R | PTE_W);
  // virtio mmio disk interface
  kvmmap(VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
  // CLINT
  kvmmap(CLINT, CLINT, 0x10000, PTE_R | PTE_W);
  // PLIC
  kvmmap(PLIC, PLIC, 0x400000, PTE_R | PTE_W);
  // map kernel text executable and read-only.
  kvmmap(KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
  // map kernel data and the physical RAM we'll make use of.
  kvmmap((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.
  kvmmap(TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}

// add a mapping to the kernel page table.
// only used when booting.
// does not flush TLB or enable paging.
void
kvmmap(uint64 va, uint64 pa, uint64 sz, int perm)
{
  if(mappages(kernel_pagetable, va, sz, pa, perm) != 0)
    panic("kvmmap");
}

现在要为每个新进程生成一个内核页表,所以需要参考kvminit重新实现一个为进程生成内核页表的版本,而不是在kvminit中修改全局的内核页表。于是我们仿照这两个函数重新编写针对用户进程的版本,我们创建两个新的函数ukvminitukvmmap
----对于ukvminit,我们需要让其返回一个页表kpagetable,然后将其赋给struct proc中的kernel_pagetable
----对于ukvmmap,我们需要在参数列表中新加一个参数kpagetable,用于指明是哪个进程的内核页表。

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

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

  ukvmmap(kpagetable, UART0, UART0, PGSIZE, PTE_R | PTE_W);
  ukvmmap(kpagetable, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
  ukvmmap(kpagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
  ukvmmap(kpagetable, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
  ukvmmap(kpagetable, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
  ukvmmap(kpagetable, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);
  ukvmmap(kpagetable, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);

  return kpagetable;
}

现在我们已经完成了为进程创建内核页表的函数,接下来进程需要调用该函数,然后将返回值赋给proc中的proc_kernel_pagetable。根据提示,我们需要在kernel/proc.c中的allocproc函数里添加调用ukvminit函数的代码,allocproc函数的定义如下。

// Look in the process table for an UNUSED proc.
// If found, initialize state required to run in the kernel,
// and return with p->lock held.
// If there are no free procs, or a memory allocation fails, return 0.
static struct proc*
allocproc(void)
{
  struct proc *p;

  for(p = proc; p < &proc[NPROC]; p++) {
    acquire(&p->lock);
    if(p->state == UNUSED) {
      goto found;
    } else {
      release(&p->lock);
    }
  }
  return 0;

found:
  p->pid = allocpid();

  // Allocate a trapframe page.
  if((p->trapframe = (struct trapframe *)kalloc()) == 0){
    release(&p->lock);
    return 0;
  }

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

  // Set up new context to start executing at forkret,
  // which returns to user space.
  memset(&p->context, 0, sizeof(p->context));
  p->context.ra = (uint64)forkret;
  p->context.sp = p->kstack + PGSIZE;

  return p;
}

allocproc的功能是:在进程表中查看各个进程的状态,如果是UNUSED,则为进程分配内存并初始化在内核中运行所需要的状态。现在我们要先将ukvminitukvmmap函数的声明添加在kernel/defs.h中,否则会链接不到这两个函数。

void ukvmmap(pagetable_t kpagetable, uint64 va, uint64 pa, uint64 sz, int perm);
pagetable_t ukvminit();

allocproc中有创建空用户页表的代码

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

我们仿照这段代码写出我们创建空用户内核页表的代码,并将其放在上面那段代码的后面。

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

3、 现在可以在进程间创建相互独立的用户进程内核页表了,但是想要让进程在多进程模式下正确调度,还需要给每个用户进程页表分配一个内核栈。在原本的xv6中,由于同一时间可能有多个进程处于内核态,所以不同进程的内核栈需要相互独立。procinit()函数会为所有进程(xv6默认构建64个进程)都预先分配了内核栈kstack,并将其map到内核的高地址空间中,每个进程使用一个页作为kstack,并且两个不同的kstack中间隔一个无映射的guard page用于检测栈溢出错误。在我们新的设计中,由于每个进程拥有一张独立的用户进程内核页表,不在需要考虑不同进程之间内核栈访问溢出的情况,所以我们可以将所有的内核栈map到各自的用户进程内核页表中的固定位置中,也无需增加guard page。根据实验提示③,内核栈的初始化原来是在kernel/proc.c中的procinit函数内。

// initialize the proc table at boot time.
void
procinit(void)
{
  struct proc *p;jiang
  
  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;
      //内核栈初始化过程结束
      //这一段后面要删除,换成我们的版本并在allocproc中调用
  }
  kvminithart();
}

根据实验提示③,需要把上面初始化所有进程的内核栈的代码删除掉,将每个进程的内核栈的空间申请和映射放在创建该进程时(allocproc)。针对要删除的代码,我们重新写一个针对各用户进程的版本。

char *pa = kalloc();
if(pa == 0)
  panic("kalloc");
uint64 va = KSTACK((int) (p - proc));
ukvmmap(p->proc_kernel_pagetable,va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
p->kstack = va;

然后将其放入allocproc中为进程创建内核页表(调用ukvminit)的后面。
在这里插入图片描述


4、 进行到这一步,独立的用户进程内核页表创建完成了,进程对应的内核栈也已经映射完成了。下一步需要确保在切换进程时能够将对应进程的用户内核页表的地址载入SATP寄存器中,所以要在kernel/proc.cscheduler函数中进行修改。

// Switch h/w page table register to the kernel's page table,
// and enable paging.
void
kvminithart()
{
  //将内核页表放入SATP寄存器
  w_satp(MAKE_SATP(kernel_pagetable));
  sfence_vma(); 
}

我们再来看下scheduler函数,完成的是进程的调度。

// Per-CPU process scheduler.
// Each CPU calls scheduler() after setting itself up.
// Scheduler never returns.  It loops, doing:
//  - choose a process to run.
//  - swtch to start running that process.
//  - eventually that process transfers control
//    via swtch back to the scheduler.
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;

		//调度进程
        swtch(&c->context, &p->context);

        // Process is done running for now.
        // It should have changed its p->state before coming back.
        c->proc = 0;
        found = 1;
      }
      release(&p->lock);
    }
#if !defined (LAB_FS)
    if(found == 0) {
      intr_on();
      asm volatile("wfi");
    }
#else
    ;
#endif
  }
}

根据实验提示④,在这里我们要将进程的内核页表添加进satp。根据实验提示⑤,在没有进程运行时使用kernel_pagetable,所以最后再使用kvminithart()切换回来。

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->proc_kernel_pagetable));
        // 清除快表缓存
        sfence_vma();
        swtch(&c->context, &p->context);   
        //该进程执行结束后,将SATP寄存器的值设置为全局内核页表地址
        kvminithart(); 
        
        
        c->proc = 0;
        found = 1;
      }
      release(&p->lock);
    }
#if !defined (LAB_FS)
    if(found == 0) {
      intr_on();
      asm volatile("wfi");
    }
#else
    ;
#endif
  }
}

5、 根据实验提示⑥,下一步我们需要考虑在销毁进程时释放对应的内核页表。对应的代码在kernel/proc.cfreeproc

// 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->pagetable)
    proc_freepagetable(p->pagetable, p->sz);
  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;
}

需要注意的是: 释放页表的第一步是先释放页表内的内核栈,因为页表内存储的内核栈地址本身就是一个虚拟地址,需要先将这个地址指向的物理地址进行释放。然后是释放页表,直接遍历所有的页表,释放所有有效的页表项即可,这个功能可以仿照freewalk函数。由于freewalk函数将对应的物理地址也直接释放了,我们这里释放的进程的内核页表仅仅只是用户进程的一个备份,释放时仅释放页表的映射关系即可,不能将真实的物理地址也释放了。因此不能直接调用freewalk函数,而是需要进行更改,我们创建一个针对释放进程内核页表的版本proc_freekernelpagetable

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


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->proc_kernel_pagetable, p->kstack, 0);
    if (pte == 0)
      panic("freeproc : kstack");
    // 删除页表项对应的物理地址
    kfree((void*)PTE2PA(*pte));
  }
  p->kstack = 0;


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


// 删除kernel pagetable
  if (p->proc_kernel_pagetable) 
    proc_freekernelpagetable(p->proc_kernel_pagetable);

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

由于在这里调用了walk,所以要将walk声明在defs.h

pte_t * walk(pagetable_t pagetable, uint64 va, int alloc);

6、 运行xv6,发现会报个错误panic:kvmpa
在这里插入图片描述
这是因为kvmpa会在进程执行期间调用,此时需要修改为获取进程内核页表,而不是全局内核页表。即需要在kvmpa中将walk调用的全局的kernel_pagetable改为进程自己的proc_kernel_pagetable。

uint64
kvmpa(uint64 va)
{
  uint64 off = va % PGSIZE;
  pte_t *pte;
  uint64 pa;
  
  //注释掉
  //pte = walk(kernel_pagetable, va, 0);
  
  //新添加的
  struct proc *p = myproc();
  pte = walk(p->proc_kernel_pagetable, va, 0);

  if(pte == 0)
    panic("kvmpa");
  if((*pte & PTE_V) == 0)
    panic("kvmpa");
  pa = PTE2PA(*pte);
  return pa+off;
}

最后,在vm.c中添加头文件,因为使用了结构体proc

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

2.3 实验结果

测试

运行xv6,执行usertests,结果如下。
在这里插入图片描述


三、Simplify copyin/copyinstr

3.1 实验描述

实验目的

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

测试条件

kernel/vm.c中的copyin主体替换为对copyin_new的调用(在kernel/vmcopyin.c中定义)。对copyinstrcopyinstr_new执行相同的操作。将用户地址的映射添加到每个进程的内核页表,以便copyin_newcopyinstr_new工作。如果usertests运行正确并且所有的make Grade测试都通过了,那么你就通过了这个实验。
:该方案依赖于用户虚拟地址范围,不与内核用于其自己的指令和数据的虚拟地址范围重叠。xv6使用从零开始的虚拟地址作为用户地址空间,幸运的是内核的内存从更高的地址开始。但是,这种方案确实将用户进程的最大大小限制为小于内核的最低虚拟地址。内核启动后,该地址是xv6中的0xC000000,即PLIC寄存器的地址。参见kernel/vm.ckernel/memlayout.h中的kvminit()以及文中的图3-4。你需要修改xv6以防止用户进程变得大于PLIC地址。

实验提示

①先用对copyin_new的调用替换copyin(),然后让它工作,然后再转到copyinstr
②在内核更改进程的用户映射的每一点,都以相同的方式更改进程的内核页表。 这些点包括fork()exec()sbrk()
③不要忘记在userinit的内核页表中包含第一个进程的用户页表。
④用户地址的PTE在进程的内核页表中需要什么权限?(在内核模式下无法访问设置了PTE_U的页面)
⑤不要忘记上述PLIC限制。

实际应用

Linux使用与你实现的技术类似的技术。直到几年前,许多内核在用户和内核空间中使用相同的每进程页表,并为用户和内核地址提供映射,以避免在用户和内核空间之间切换时切换页表。但是,该设置允许诸如MeltdownSpectre之类的侧信道攻击。

实验思考

解释为什么第三个测试srcva + len < srcvacopyin_new()中是必需的:给出前两个测试失败的srcva和len值(即它们不会导致返回 -1)但第三个测试为真(导致返回-1)。


3.2 实验思路

参照实验提示的步骤一步一步来完成实验:

1、 根据实验提示①,需要将vm.ccopyin的内容替换为对copyin_new的调用,将vm.ccopyinstr的内容替换为对copyinstr_new的调用。其中,copyincopyinstr的定义如下。

// Copy from user to kernel.
// Copy len bytes to dst from virtual address srcva in a given page table.
// Return 0 on success, -1 on error.
int
copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len)
{
  uint64 n, va0, pa0;

  while(len > 0){
    va0 = PGROUNDDOWN(srcva);
    pa0 = walkaddr(pagetable, va0);
    if(pa0 == 0)
      return -1;
    n = PGSIZE - (srcva - va0);
    if(n > len)
      n = len;
    memmove(dst, (void *)(pa0 + (srcva - va0)), n);

    len -= n;
    dst += n;
    srcva = va0 + PGSIZE;
  }
  return 0;
}

// Copy a null-terminated string from user to kernel.
// Copy bytes to dst from virtual address srcva in a given page table,
// until a '\0', or max.
// Return 0 on success, -1 on error.
int
copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
  uint64 n, va0, pa0;
  int got_null = 0;

  while(got_null == 0 && max > 0){
    va0 = PGROUNDDOWN(srcva);
    pa0 = walkaddr(pagetable, va0);
    if(pa0 == 0)
      return -1;
    n = PGSIZE - (srcva - va0);
    if(n > max)
      n = max;

    char *p = (char *) (pa0 + (srcva - va0));
    while(n > 0){
      if(*p == '\0'){
        *dst = '\0';
        got_null = 1;
        break;
      } else {
        *dst = *p;
      }
      --n;
      --max;
      p++;
      dst++;
    }

    srcva = va0 + PGSIZE;
  }
  if(got_null){
    return 0;
  } else {
    return -1;
  }
}

copyin_newcopyinstr_new是源码就有的,在kernel/vmcopyin.c

// Copy from user to kernel.
// Copy len bytes to dst from virtual address srcva in a given page table.
// Return 0 on success, -1 on error.
int
copyin_new(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len)
{
  struct proc *p = myproc();

  if (srcva >= p->sz || srcva+len >= p->sz || srcva+len < srcva)
    return -1;
  memmove((void *) dst, (void *)srcva, len);
  stats.ncopyin++;   // XXX lock
  return 0;
}

// Copy a null-terminated string from user to kernel.
// Copy bytes to dst from virtual address srcva in a given page table,
// until a '\0', or max.
// Return 0 on success, -1 on error.
int
copyinstr_new(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
  struct proc *p = myproc();
  char *s = (char *) srcva;
  
  stats.ncopyinstr++;   // XXX lock
  for(int i = 0; i < max && srcva + i < p->sz; i++){
    dst[i] = s[i];
    if(s[i] == '\0')
      return 0;
  }
  return -1;
}

现在需要先将copyin_newcopyinstr_new的声明添加到kernel/defs.h

int copyin_new(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len);
int copyinstr_new(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max);

然后修改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);
}

2、 根据实验提示②,forkexecgrowproc(sbrk通过growproc完成内存伸缩)会改变进程的用户页表,需要加上随之改变进程的内核页表的功能,即将进程的用户页表的映射关系复制一份到进程的内核页表中。具体的赋值操作可参考uvmcopy,其作用是在fork子进程时,拷贝父进程的页表。

// Given a parent process's page table, copy
// its memory into a child's page table.
// Copies both the page table and the
// physical memory.
// returns 0 on success, -1 on failure.
// frees any allocated pages on failure.
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
  pte_t *pte;
  uint64 pa, i;
  uint flags;
  char *mem;

  for(i = 0; i < sz; i += PGSIZE){
    if((pte = walk(old, i, 0)) == 0)
      panic("uvmcopy: pte should exist");
    if((*pte & PTE_V) == 0)
      panic("uvmcopy: page not present");
    pa = PTE2PA(*pte);
    flags = PTE_FLAGS(*pte);
    if((mem = kalloc()) == 0)
      goto err;
    memmove(mem, (char*)pa, PGSIZE);
    if(mappages(new, i, PGSIZE, (uint64)mem, flags) != 0){
      kfree(mem);
      goto err;
    }
  }
  return 0;

 err:
  uvmunmap(new, 0, i / PGSIZE, 1);
  return -1;
}

根据uvmcopy,写出我们自己的版本。根据实验提示④,注意权限,需要把PTE_U去掉,因为CPU在suprivisor模式时不能访问设置PTE_U的页。同时,不要释放物理内存,拷贝映射关系即可。

int
uvmcopy_not_physical(pagetable_t old, pagetable_t new, uint64 begin, uint64 end)
{
  pte_t *pte, *newPte;
  uint64 pa, i;
  uint flags;
  char *mem;

  for(i = PGROUNDDOWN(begin); i < end; i += PGSIZE){
    if((pte = walk(old, i, 0)) == 0)
      panic("uvmcopy_not_physical: pte should exist");
    if((newPte = walk(new, i, 1)) == 0)
      panic("uvmcopy_not_physical:page not present");
    pa = PTE2PA(*pte);
    flags = PTE_FLAGS(*pte) & (~PTE_U);

    *newPte = PA2PTE(pa) | flags;
  }
  return 0;
}

接着将此函数声明在defs.h中,因为后续函数会用到。接着来修改fork()exec()growproc()

int uvmcopy_not_physical(pagetable_t old, pagetable_t new, uint64 begin, uint64 end);

fork
首先是forkfork中下面几行代码涉及页表的操作。

// Copy user memory from parent to child.
if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0){
  freeproc(np);
  release(&np->lock);
  return -1;
}

我们需要进行修改,把fork出来的子进程的用户pagetable复制给kpagetable。

  // Copy user memory from parent to child.
  if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0 || uvmcopy_not_physical(np->pagetable, np->proc_kernel_pagetable, 0, p->sz) < 0){
    freeproc(np);
    release(&np->lock);
    return -1;
  }

exec
根据试验提示⑤,在映射之前要先检测程序大小是否超过PLIC,防止remap。同时,映射前要先清除[0,PLIC]中原本的内容,再将要执行的程序映射到[0,PLIC]中。

 ------
  if((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz)) == 0)
     goto bad;

   // 添加检测,防止程序大小超过 PLIC
   if(sz1 >= PLIC)
     goto bad;

  ------
    
  // Commit to the user image.
  oldpagetable = p->pagetable;
  p->pagetable = pagetable;


//  清除内核页表中对程序内存的旧映射,然后重新建立映射。
  uvmunmap(p->proc_kernel_pagetable, 0, PGROUNDDOWN(p->sz)/PGSIZE, 0);
  uvmcopy_not_physical(pagetable, p->proc_kernel_pagetable, 0, sz);


  p->sz = sz;
  p->trapframe->epc = elf.entry;  // initial program counter = main
  p->trapframe->sp = sp; // initial stack pointer
  proc_freepagetable(oldpagetable, oldsz);
  ------
}

growproc
sysproc.c中的sys_sbrk中可以发现,执行内存相关的函数为growproc

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

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

于是我们对growproc进行修改。

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

  sz = p->sz;
  if(n > 0){
    if(PGROUNDDOWN(sz + n) >= PLIC)
      return -1;
    if((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
      return -1;
    }
    uvmcopy_not_physical(p->pagetable, p->proc_kernel_pagetable, p->sz, sz);
  } else if(n < 0){
    sz = uvmdealloc(p->pagetable, sz, sz + n);
	
	// 缩小 kernel_pagetable 的相应映射
    int newsz = p->sz + n;
    if(PGROUNDDOWN(newsz) < PGROUNDUP(p->sz))
    {
      int npages = (PGROUNDUP(p->sz) - PGROUNDUP(newsz)) / PGSIZE;
      uvmunmap(p->proc_kernel_pagetable, PGROUNDUP(newsz), npages, 0);
    }

  }
  p->sz = sz;
  return 0;
}

3、 根据实验提示②,再userinit的内核页表中包含第一个进程的用户页表。

 ------
uvminit(p->pagetable, initcode, sizeof(initcode));
  p->sz = PGSIZE;

//添加内容
  uvmcopy_not_physical(p->pagetable, p->proc_kernel_pagetable, 0, p->sz);
 
  // prepare for the very first "return" from kernel to user.
  p->trapframe->epc = 0;      // user program counter
  p->trapframe->sp = PGSIZE;  // user stack pointer

------

3.3 实验结果

测试

进入xv6工作目录,运行make grade,结果如下。
在这里插入图片描述

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

知初与修一

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值