「实验记录」MIT 6.S081 Lab3 page tables

I. Source

  1. MIT-6.S081 2020 课程官网
  2. Lab3: page tables 实验主页
  3. MIT-6.S081 2020 xv6 book
  4. B站 - MIT-6.S081 Lec4 Page Tables

II. My Code

  1. Lab3: page tables 的 Gitee
  2. xv6-labs-2020 的 Gitee 总目录

III. Motivation

Lab3: page tables 主要是想让我们熟悉 xv6 的多级页表的工作原理和优化 kernel 与 user 交互机制

在开始实验之前,一定要阅读 xv6-6.S081 的第三章节 Page tables 及 kernel/memlayout.hkernel/vm.ckernel/kalloc.c 的相关代码

IV. Print a page table (easy)

i. Motivation

主要是想让我们通过遍历的方式了解 xv6 三级页表的组织形式(数据结构,树)。在进入 xv6 之后希望打印出以下页表信息,

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

其中 ..0: 表示该页表是2级页表且 PTE 编号为 0 ;随后的 .. ..0: 表示该页表为 1 级页表,其父页表是 ..0: ,同样 PTE 编号也为 0 ;最后 .. .. ..0: 表示该页表为 0 级页表,其父页表是 .. ..0: ,PTE 编号也为 0 。之后的 .. .. ..1:.. .. ..2:.. .. ..0: 的情况相同,都是页表 .. ..0: 的子页表

ii. Solution

首先需要参考 kernel/vm.c 下的 freewalk() 函数,了解 xv6 的三级页表其实是类似于多叉树的递归结构。freewalk() 具体的工作就是递归地去释放每张 2 级或 1 级页表,xv6 三级页表的逻辑图如下,

Figure 3.2 - RISC-V page table hardware

在清楚 xv6 三级页表的逻辑组织结构和 Lab: Print a page table 的要求之后,开始编写代码,

void 
_vmprint(pagetable_t pagetable, int level)
{
  // there are 2^9 = 512 PTEs in a page table.
  for(int i=0; i<512; i++) {
    pte_t pte = pagetable[i];
    if((pte & PTE_V) == 0) 
      continue;
    
    char prefix[32];
    if(level == 0) {
      snprintf(prefix, 32, "..%d:", i);
    }
    if(level == 1) {
      snprintf(prefix, 32, ".. ..%d:", i);
    }
    if(level == 2) {
      snprintf(prefix, 32, ".. .. ..%d:", i);
    }

    printf("%s pte %p pa %p\n", prefix, pte, PTE2PA(pte));
    if((pte & (PTE_R|PTE_W|PTE_X)) == 0) {
      uint64 child = PTE2PA(pte);
      _vmprint((pagetable_t)child, level+1);
    } 
  }
}

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

针对页表中的每个 PTE 都进行级数判断,如果,

(pte & (PTE_R|PTE_W|PTE_X)) == 0

则说明该 PTE 有子页表的。如果,

(pte & PTE_V) == 0

说明该 PTE 是无效的,不予考虑。参考 MIT-6.S081 2020 xv6 book ,书中原话,

It then initializes the PTE to hold the relevant physical page number, the desired permissions ( PTE_W , PTE_X , and/or PTE_R ), and PTE_V to mark the PTE as valid ( kernel/vm.c:163 ) .

最后还有一点需要注意,要在 kernel/defs.h 中添加 vmprint() 的函数声明,以便 kernel 中的其他文件能够访问到该函数

iii. Result

可以通过,

./grade-lab-pgtbl pte

来验证程序是否正确,也可以通过,

make qemu

进入 xv6 来查看打印页表信息确认

V. A kernel page table per process (hard)

i. Motivation

xv6 为了实现现代操作系统三大特性之隔离性(进程之间互不干预),于是为每个进程都分配独立的内核页表,改变所有进程都共享同一内核页表的局面,进而能够避免多进程之间因共享内核代码和内核数据而引发的系统错误

ii. Solution

S1 - 熟悉内核地址空间分配情况

首先需要了解内核地址空间与物理地址的映射关系,如下图,

Figure 3.2 - RISC-V page table hardware

在内核地址空间的很多地方都是直接映射成对应的物理地址的,为此 教授 给出的解释是为了方便教学而放弃较为复杂的映射方案(现实世界中应该不会这么简单)

在知道内核虚拟地址和物理地址的映射关系之后,开始思考如何为每个进程都分配一张独立的内核页表

S2 - 为每个进程分配内核页表

根据 Lab3: page tables 实验主页 的提示逐一完善 kernel/vm.ckernel/proc.c 。首先需要在 kernel/proc.hstruct proc 中添加 kpagetable 字段,指向内核页表,

pagetable_t kpagetable;      

kernel/vm.c 中添加新函数 vminit() ,写法类似于 kvminit()kvminit() 主要是初始化全局共享的独一份内核页表 kernel_page 。我们此次整改的目的就是打破全局共享的局面,为每个进程都创建一个独立的内核页表,

void 
vminit(pagetable_t pagetable)
{
  // uart registers
  vmmap(pagetable, UART0, UART0, PGSIZE, PTE_R | PTE_W);
  // virtio mmio disk interface
  vmmap(pagetable, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
  // // CLINT
  // vmmap(pagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
  // PLIC
  vmmap(pagetable, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
  // map kernel text executable and read-only.
  vmmap(pagetable, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
  // map kernel data and the physical RAM we'll make use of.
  vmmap(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.
  vmmap(pagetable, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}

vminit() 没有调用 kalloc() 的原因是在调用方 kernel/proc.c:allocproc() 中已经为新建的进程分配好了内核页表空间,添加的细节如下,

static struct proc*
allocproc(void)
{
 	...
  /** 创建内核页表 */
  p->kpagetable = proc_kpagetable(p);
  if(p->kpagetable == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
  }

  /** 理顺内核栈的映射关系 */
  char *pa = kalloc();
  if(pa == 0)
    panic("kalloc");
  uint64 va = KSTACK(0);
  vmmap(p->kpagetable, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
  p->kstack = va;
 	...
}

其中有一个新增函数 proc_kpagetable() ,它负责为新建进程分配内核页表空间,

/** 为给定的进程创建内核页表 */
pagetable_t
proc_kpagetable(struct proc* p)
{
  pagetable_t kpagetable;

  // An empty page table.
  kpagetable = kvmcreate();
  if(kpagetable == 0)
    return 0;

  vminit(kpagetable);

  return kpagetable;
}

struct proc* p 在函数中并未使用到,但为了接口的统一仍这般书写。其中的 kvmcreate() 直接调用 vmcreate() ,这是我有意为之,目的就是为了区分内核函数和用户态函数,

pagetable_t
kvmcreate()
{
  return uvmcreate();
}

然后将 kernel/proc.c:procinit() 函数中分配内核栈的代码移到 allocproc() 中,修改后的代码如下,

void
procinit(void)
{
  struct proc *p;
  
  initlock(&pid_lock, "nextpid");
  for(p = proc; p < &proc[NPROC]; p++) {
      initlock(&p->lock, "proc");
  }
  kvminithart();
}

接着在 kernel/proc.c:scheduler() 函数中添加切换页表寄存器 satp 的指令,具体添加如下,

void
scheduler(void)
{
 ...
    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;

        vminithart(p->kpagetable);
 ...
}

当 xv6 无进程运行时需要将页表切回至 kernel_pagetable 。为了代码复用,将切换 satp 寄存器指令封装成 vminithart() 函数,

void 
vminithart(pagetable_t pagetable)
{
  w_satp(MAKE_SATP(pagetable));
  sfence_vma();
}

其中 sfence_vma() 用于清空 TLB 。此外还需要考虑如何释放内核页表,Lab3: page tables 实验主页 给出的提示是释放页表但不要释放最外围的物理内存页,通过新增函数 proc_freekpagetable() 完成该项工作,

void 
proc_freekpagetable(pagetable_t kpagetable, uint64 sz)
{
  // there are 2^9 = 512 PTEs in a page table.
  for(int i=0; i<512; i++) {
    pte_t pte = kpagetable[i];
    if((pte & PTE_V) == 0) 
      continue;

    /** 该PTE有效(内部节点和叶子节点) */
    if((pte & (PTE_R|PTE_W|PTE_X)) == 0) {
      uint64 child = PTE2PA(pte);
      proc_freekpagetable((pagetable_t)child, sz);
      kpagetable[i] = 0;
    }
  }
  kfree((void*)kpagetable);
}

同时在 kernel/proc.c:freeproc() 中添加释放内核栈的功能代码,其实就是解引用,具体的释放工作交由下面的 proc_freekpagetable() 来完成,

static void
freeproc(struct proc *p)
{
 	...
  p->xstate = 0;
  
  /** 在释放内核页表之前需要释放内核栈 */
  if(p->kstack)
    uvmunmap(p->kpagetable, p->kstack, 1, 1);
  p->kstack = 0;

  if(p->kpagetable)
    proc_freekpagetable(p->kpagetable, p->sz);
  p->kpagetable = 0;
  
  ...
  p->state = UNUSED;
}

此外还需要修改 kernel/virtio_disk.c 中的地址写入指令,

// buf0 is on a kernel stack, which is not direct mapped,
// thus the call to kvmpa().
disk.desc[idx[0]].addr = (uint64) vmpa(myproc()->kpagetable, (uint64) &buf0);

其中 vmpa()kvmpa() 的宽泛版本,它可以接受任意页表,

uint64
vmpa(pagetable_t pagetable, uint64 va)
{
  uint64 off = va % PGSIZE;
  pte_t *pte;
  uint64 pa;
  
  pte = walk(pagetable, va, 0);
  if(pte == 0)
    panic("vmpa");
  if((*pte & PTE_V) == 0)
    panic("vmpa");
  pa = PTE2PA(*pte);
  return pa+off;
}

以上就是为每个进程创建独立内核页表的相关内容,最后还需注意在 kernel/defs.h 中添加新增函数的声明

iii. Result

手动进入 qemu ,

make qemu
$usertests

进入 xv6 来查看输出结果

VI. Simplify copying/copyinstr (hard)

i. Motivation

xv6 为了方便让已进入内核的进程获取用户态的地址空间,设计了一种将用户态页表塞到内核页表中的机制。如此一来,当已进入内核的进程想要查询进程用户态的某一地址空间时,无需再切回用户态,直接在内核中就能实现查询和地址翻译工作。这样大大节省了 user 和 kernel 之间来来回回切换的代价,这是典型的空间换时间案例

ii. Solution

S1 - 熟悉内核地址空间的起始空间

首先需要了解 xv6 在 0 ~ 0xC000000(PLIC寄存器的首地址)的地址空间范围内腾出了空间,专门用来存放即将塞入内核页表的用户态页表,Lab3: page tables 实验主页 原话,

Xv6 uses virtual addresses that start at zero for user address spaces, and luckily the kernel’s memory starts at higher addresses. 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;

在清楚空间布局后可以做具体的迁移工作了,同时还要注意迁移只是复制页表目录而不是实际的物理内存页(只是索引,类似于C++浅拷贝)

S2 - 将 user 页表塞入 kernel 页表中

根据 Lab3: page tables 实验主页 提示把 kernel/vm.c 中的 copyin()copyinstr() 替换成 kernel/vmcopyin.ccopyin_new()copyinstr_new()

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

接着编写该任务最重要的浅拷贝页表代码(可以参考 kernel/vm.cuvmcopy() ),我取名叫 u2kvmcopy() ,寓意很明确,就是将用户态页表塞到内核态页表中,

int
u2kvmcopy(pagetable_t old, pagetable_t new, uint64 start, uint64 end)
{
  pte_t *pte;
  uint64 pa, i;
  uint flags;

  for(i=PGROUNDUP(start); i<end; i+=PGSIZE){
    if((pte = walk(old, i, 0)) == 0)
      panic("u2kvmcopy: pte should exist");
    if((*pte & PTE_V) == 0)
      panic("u2kvmcopy: page not present");
    pa = PTE2PA(*pte);
    /** 需要将PTE_U标记位置0,内核才能访问用户态页表 */
    flags = PTE_FLAGS(*pte) & (~PTE_U);

    if(mappages(new, i, PGSIZE, pa, flags) != 0)
      goto err;
  }
  return 0;

 err:
  /** 注意不要释放用户态页表物理空间 */
  uvmunmap(new, start, (i-start)/PGSIZE, 0);
  return -1;
}

完成了用户态页表浅拷贝编码工作之后就可以在 fork()exec()growproc() 中调用了

首先完善 fork() ,给出的第一步:子进程复制了父进程的所有(用户态页表)。交给我们的第二步相对而言就容易多了,照猫画虎即可,

int
fork(void)
{
  ...
  np->sz = p->sz;
  
  /** 将子进程的用户态页表塞到子进程的内核页表中 */
  if(u2kvmcopy(np->pagetable, np->kpagetable, 0, np->sz) < 0) {
    freeproc(np);
    release(&np->lock);
    return -1;
  }

  np->parent = p;
  ...
}

接着添加 exec() ,该函数的逻辑就是启用了一个新的应用,在新应用上台之前需要完成页表更新工作。所以丢给我们的问题:需要抹掉进程内核页表中对用户态页表的旧映射,然后再将新的用户态页表塞到内核页表中,

int
exec(char *path, char **argv)
{
 	...
  proc_freepagetable(oldpagetable, oldsz);

  /** 清除内核页表中对用户态页表的旧映射 */
  uvmunmap(p->kpagetable, 0, PGROUNDUP(oldsz)/PGSIZE, 0);
  /** 在替换原用户页表之后,将新用户页表塞进内核页表中 */
  if(u2kvmcopy(p->pagetable, p->kpagetable, 0, p->sz) < 0) 
    goto bad;

  if(p->pid == 1)
    vmprint(p->pagetable);
  
  return argc; // this ends up in a0, the first argument to main(argc, argv)
	...
}

最后修改 growproc() ,该函数会被 sbrk() 调用,主要负责伸缩内存空间。需要注意:内存扩张有上限,不能超过 PLIC 的基地址;在内存收缩时不仅要更新用户态页表,而且还要使内核页表与时俱进,

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

  sz = p->sz;
  if(n > 0){
    if(PGROUNDUP(sz+n) >= PLIC)
      return -1;
    if((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0) 
      return -1;
    if(u2kvmcopy(p->pagetable, p->kpagetable, p->sz, sz) < 0)
      return -1;
  } else if(n < 0){
    sz = uvmdealloc(p->pagetable, sz, sz + n);  
    /** sz已变小,sz <- sz-|n|,此时p->sz > sz */
    sz = vmdealloc(p->kpagetable, p->sz, sz, 0);
  }
  p->sz = sz;
  return 0;
}

其中的 vmdealloc()uvmdealloc() 的宽泛版本,多了一个 do_free 状态位,标记是否需要释放内存,

uint64
vmdealloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz, int do_free)
{
  if(newsz >= oldsz)
    return oldsz;

  if(PGROUNDUP(newsz) < PGROUNDUP(oldsz)){
    int npages = (PGROUNDUP(oldsz) - PGROUNDUP(newsz)) / PGSIZE;
    uvmunmap(pagetable, PGROUNDUP(newsz), npages, do_free);
  }

  return newsz;
}

S3 - 启动用户进程

上述是进程对象自己应该完成的修补工作,这部分将着眼全局,从 xv6 的 0 号进程( kernel/proc.c:userinit() )出发,在其中添加页表相关的业务逻辑(用户态页表塞到内核态),

void
userinit(void)
{
  ...
  p->sz = PGSIZE;

  u2kvmcopy(p->pagetable, p->kpagetable, 0, p->sz);

  // prepare for the very first "return" from kernel to user.
  p->trapframe->epc = 0;      // user program counter
  ...
}

iii. Result

手动进入 qemu ,

make qemu
$usertests

进入 xv6 来查看输出结果

期间有个问题:我是用 2G 2核心的云服务器跑 xv6 ,在 qemu 内运行 usertests 有时会卡住,我怀疑是我的机器不大行。而且执行测试脚本的时候也会卡住和失败,通过调研好像确实是机器配置的问题。在确保内存管理模块逻辑畅通的情况下,无需过多地去纠结实验测试的细节

VII. Reference

  1. 知乎 - MIT 6.S081 Lab3: page tables
  • 5
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值