lab / 2023.10.24

6.s081 lab2 Page tables

在本 lab 中,我们将进行页表机制的探索。

在本 lab 中,我们将进行页表机制的探索。

Preparation

切换到对应分支

1$ git fetch
2$ git checkout pgtbl
3$ make clean

Task1: Speed up system calls

该任务要求我们在进程虚拟地址空间中添加一个固定项:USYSCALL,该页的开头字段为当前进程 pid,这样一来 getpid() 就不用陷入内核,而是直接通过访问虚拟地址即可获取,从而加快速度。

用户层函数 ugetpid() 已经为我们实现好了,我们只需要关注内核中,更具体来讲,是新建进程时初始化虚拟地址空间动作中发生的事。

进程会在两个时候被创建,一个是 userinit()(kernel/proc.c)时,一个是调用 fork() 时。进程的创建会调用 kernel/proc.c 中的 allocproc(),然后通过 proc_pagetable() 来初始化页表。

我们要做的很简单。

第一步,在页表初始化时加入一个 {USYSCALL => PHYPAGE} 的 PTE。USYSCALLkernel/memlayout.h 中定义(就是 TRAPFRAME 的前一页),而 PHYPAGE 则需要我们调用 kalloc()(kernel/kalloc.c) 进行空闲页分配。

第二步,也就是正确完成页表初始化后,将 pid 写入 PHYPAGE

#ifdef LAB_PGTBL 是灰的不要紧,make 时会自动帮我们 define。

PHYPAGE 的分配操作只能在 allocproc() 中进行,因为一旦分配失败,我们需要释放当前进程。为了让同一 PHYPAGE 能被 proc_pagetable() 访问,我在 struct proc 中添加了 usyscall 字段(这些都是参考了 trapframe 的初始化流程)。

 1static struct proc*
 2allocproc(void)
 3{
 4  ...
 5found:
 6  ...
 7  // Allocate a trapframe page and a usyscall page.
 8  if((p->trapframe = (struct trapframe *)kalloc()) == 0 ||
 9     (p->usyscall = (struct usyscall *)kalloc()) == 0){
10    ...
11  }
12
13  // An empty user page table.
14  p->pagetable = proc_pagetable(p);
15  if(p->pagetable == 0){
16    ...
17  }
18
19  p->usyscall->pid = p->pid;
20  ...
21}

添加 PTE 的操作很简单,页表相关操作都在 kernel/vm.c 中,我们需要关注 mempages() 函数,该函数就是为指定页表添加 PTE。唯一需要关注的就是符号控制位,根据我们的需求,该页面为只读型,而且能被用户层函数访问,那么只赋予 PTE_R|PTE_U 即可。有效位会在 mappages() 内部添加,无需操心。

如果添加失败,需要把已经添加的表项清空,并且释放页表,参考上面的代码即可。

 1pagetable_t
 2proc_pagetable(struct proc *p)
 3{
 4  ...
 5  // map the trapframe just below TRAPFRAME, for speeding up getpid()
 6  if (mappages(pagetable, USYSCALL, PGSIZE,
 7              (uint64)p->usyscall, PTE_R | PTE_U) < 0) {
 8    uvmunmap(pagetable, TRAMPOLINE, 1, 0);
 9    uvmunmap(pagetable, TRAPFRAME, 1, 0);
10    uvmfree(pagetable, 0);
11    return 0;
12  }
13  ...
14}

当然,不仅要考虑分配,还要考虑释放的操作。如果到这一步就急匆匆地 make qemu; ./pgtbltest,那你马上就会得到一个 panic: freewalk leaf。这是因为我们没有在清空页表时清空我们已分配的 PHYPAGE,而这一步在 freeproc() 函数中实现。

 1static void
 2freeproc(struct proc *p)
 3{
 4  if(p->trapframe)
 5    kfree((void*)p->trapframe);
 6  p->trapframe = 0;
 7
 8  if (p->usyscall)              // (!new)
 9    kfree((void*)p->usyscall);  // (!new)
10  p->usyscall = 0;              // (!new)
11
12  if(p->pagetable)
13    proc_freepagetable(p->pagetable, p->sz);
14  p->pagetable = 0;
15  p->sz = 0;
16  p->pid = 0;
17  p->parent = 0;
18  p->name[0] = 0;
19  p->chan = 0;
20  p->killed = 0;
21  p->xstate = 0;
22  p->state = UNUSED;
23}

当然还要关注页表对应页的释放操作,proc_freepagetable()(kernel/proc.c) 的注释讲的很清楚:Free a process’s page table, and free the physical memory it refers to. 并且它也给了提示:要调用 uvmunmap()。所以新增一行即可,实际的释放操作会在 uvmunmap() 中完成:

1void
2proc_freepagetable(pagetable_t pagetable, uint64 sz)
3{
4  uvmunmap(pagetable, TRAMPOLINE, 1, 0);
5  uvmunmap(pagetable, TRAPFRAME, 1, 0);
6  uvmunmap(pagetable, USYSCALL, 1, 0); // (!new)
7  uvmfree(pagetable, sz);
8}

Task2: Print a page table

现在我们需要按照给定格式,在 kernel/exec.c:119 插入 if(p->pid==1) vmprint(p->pagetable) 来输出初始进程的页表,以可视化并有助于 debug。

参考 kernel/vm.c 中的 freewalk(),我们主要关注的是它以怎样的方式遍历页目录结构——获取表项,得到物理地址,根据标志位判断这一物理地址是页表还是数据页,决定是否需要进一步深入。

在 xv6 中,每个页表项的低位为标志位,如下图所示:

kernel/riscv.h 中已经为我们定义好了若干标志位,直接使用即可。

 1void
 2freewalk(pagetable_t pagetable)
 3{
 4  ...
 5  for(int i = 0; i < 512; i++){
 6    pte_t pte = pagetable[i];
 7    if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){
 8      // this PTE points to a lower-level page table.
 9      uint64 child = PTE2PA(pte);
10      freewalk((pagetable_t)child);
11      ...
12    }
13    ...
14  }
15  ...
16}

不难发现,判断的条件就是 (pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X) == 0PTE_V 位意味着该页表项中的物理地址是已分配的,PTE_R|PTE_W|PTE_X 意味着这一物理地址对应的是页表,而非数据项。

此外,我们还需要根据当前页表的 level 来打印,毕竟 level 越低,前面的 .. 越多,所以还需要一个额外的 vmprintwalk() 函数。

那么先在 kernel/def.h 中添加函数签名,然后修改 kernel/exec.c,最后就是在 kernel/vm.c 下加入两个函数的代码了。

 1void
 2vmprint(pagetable_t pagetable)
 3{
 4  printf("page table %p\n", pagetable); // 用 %p 来进行十六进制格式化
 5  vmprintwalk(pagetable, 2);
 6}
 7
 8void
 9vmprintwalk(pagetable_t pagetable, int level)
10{
11  // there are 2^9 = 512 PTEs in a page table.
12  for (int i = 0; i < 512; i++) {
13    pte_t pte = pagetable[i];
14    if (pte & PTE_V) {
15      uint64 child = PTE2PA(pte);
16      for (int i = 0; i < 2-level; i++) {
17        printf(".. ");
18      }
19      printf("..%d: pte %p pa %p\n", i, pte, child);
20      if (level && (pte & (PTE_R|PTE_W|PTE_X)) == 0) {
21        vmprintwalk((pagetable_t)child, level-1); // 这里需要用类型转换,否则编译报错
22      }
23    }
24  }
25}

Task3: Detecting which pages have been accessed

该任务要求我们利用跟踪哪些页被访问过,并且通过用户层函数 pgaccess() 来获取自从上一次调用以来被访问过的页。

既然如此,那每个页表项就需要一个「访问位」咯,根据上面那张图,我们可以在 kernel/riscv.h 中添加一行 #define PTE_A (1L << 6),方便进行位运算。

lab 已经贴心地为我们做好了所有准备工作,包括修改 usys.pl、注册系统调用等,所以我们只需要在 kernel/sysproc.c 中实现 sys_pgaccess() 函数即可。

查看 user/user.hpgaccess() 的函数签名我们发现,我们需要接收三个参数,分别是一个虚拟地址 vba,一个整型 npages,和一个指针 vp,用 argaddr()argint() 处理得到参数后,检查一下地址是否越界——pgaccess 的作用是追踪 vba 开始 npages 个页面的被访问情况,那么最大的地址一定不能超过 MAXVA,否则直接返回 -1

然后根据得到的基地址,逐页查页表,检查指向数据页的那个页表项的 PTE_A 位,如果为 1,则将 vp 指针指向内存的第 i 个 bit 置 1。这就是一个简单的检查某页是否被访问过的流程。

根据 sys_sysinfo() 的知识,这里必须用 copyout() 拷贝数据到 vp 指向的内存。

那么如何根据页表找到最终的页表项呢?kernel/vm.c 中为我们提供了 walk() 的 API,它根据虚拟地址,返回指向数据页的那个页表项的指针。还有一个叫 walkaddr() 的,是直接返回物理页的起始地址,后面有用,这里暂时用不到。

 1int
 2sys_pgaccess(void)
 3{
 4  uint64 vba; // virtual base address
 5  int npages;
 6  uint64 vp;
 7  if (argaddr(0, &vba) < 0 || argint(1, &npages) < 0 || argaddr(2, &vp) < 0) {
 8    return -1;
 9  }
10
11  // 检查地址是否溢出虚拟内存空间
12  vba = PGROUNDDOWN(vba);
13  if (vba + npages*PGSIZE > MAXVA) {
14    return -1;
15  }
16
17  unsigned int abits = 0;
18  struct proc* p = myproc();
19  pagetable_t pagetable = p->pagetable;
20
21  for (int i = 0; i < npages; i++) {
22    uint64 va = vba + i*PGSIZE;
23    pte_t* pte = walk(pagetable, va, 0);  // 直接用会报 error: implicit declaration of function ‘walk’
24                                          // 需要在 kernel/def.h 中添加声明
25    if (*pte & PTE_A) {
26      abits |= 1 << i;
27      *pte &= ~PTE_A;                     // 由于是上一次调用以来的访问情况,故每次调用都要解除访问
28    }
29  }
30
31  copyout(pagetable, vp, (char*)&abits, sizeof(abits));
32  return 0;
33}

现在还有一个问题,PTE_A 位是我们新增的,也就是说 lab 并没有实现在什么地方将其置 1,就需要我们找一个合适的位置去置位,不然 *pte & PTE_A 将毫无意义。

前面提到的 walkaddr() 可以解决该问题。毕竟每次访问物理页的时候必然要经过这个调用,它返回的物理页起始地址,那肯定是要有人去访问的——就算没人访页,那也视为去访问了——所以,u know,在返回前置位即可。毕竟,这是访问物理页的唯一通道。

如果加在 walk() 里,那么 sys_pgaccess() 检查的每一页,都会因为调用了 walk() 而置 PTE_A=1,这肯定是不行的。

 1uint64
 2walkaddr(pagetable_t pagetable, uint64 va)
 3{
 4  pte_t *pte;
 5  uint64 pa;
 6
 7  if(va >= MAXVA)
 8    return 0;
 9
10  pte = walk(pagetable, va, 0);
11  if(pte == 0)
12    return 0;
13  if((*pte & PTE_V) == 0)
14    return 0;
15  if((*pte & PTE_U) == 0)
16    return 0;
17  *pte |= PTE_A; // (!new)
18  pa = PTE2PA(*pte);
19  return pa;
20}

测试结果

 1$ make grade
 2...
 3== Test pgtbltest ==
 4$ make qemu-gdb
 5(3.4s)
 6== Test   pgtbltest: ugetpid ==
 7  pgtbltest: ugetpid: OK
 8== Test   pgtbltest: pgaccess ==
 9  pgtbltest: pgaccess: OK
10== Test pte printout ==
11$ make qemu-gdb
12pte printout: OK (1.0s)
13== Test answers-pgtbl.txt == answers-pgtbl.txt: OK
14== Test usertests ==
15$ make qemu-gdb
16(125.0s)
17== Test   usertests: all tests ==
18  usertests: all tests: OK
19== Test time ==
20time: OK
21Score: 46/46

最后的工作

  1. git commit -am "" 将所有修改提交到本地;
  2. 执行 make handin。由于 lab0 保存了 APIKey,故直接成功提交;

可选的挑战再说吧,没有什么想做的欲望。