【6S081】Lab3详解: Page Tables

188 阅读28分钟

【6S081】Lab3 page tables

注:鉴于这章实验难度较高,需要充分了解page tables的有关知识之后再进行实验会比较得心应手。

故请先按照官方要求:image-20250401083424975

进行学习之后,再开始实验。

如果你想要快速开始实验,这里会提供前置知识概要,仅供参考。

另外,从这次实验开始,决定进行一些术语的解释:

  • 每次我们完成一个lab之后,都会进行测验和测试,这里的具体操作是

    1.make cleanmake qemu进行测验

    2.CTRL+A+X退出xv6,然后make grade进行测试

    那么我们直接将这样检验结果和测试的操作称为 test,后续不再赘述。

  • 我们进行完一次大实验之后,都会进行github的上传与提交,这里的具体操作是

    git checkout util // 填对应实验分支
    git push github util:util // 这里也是
    git add . // 进行添加
    git commit -m "完成了第一个作业" // 提交
    git push github util:util // 推送
    

    这里我们直接称为 提交 ,后续不再赘述。

  • 所有的方法写完之后都需要在defs.h中进行声明。这里称为 声明,后续不再赘述。

前置知识概要

该部分知识仅仅介绍实验所会涉及到的框架知识,包括三个部分:

  • 物理内存地址与虚拟内存地址的转换
  • 页表与映射相关概念

物理内存地址与虚拟内存地址的转换

物理内存指的是计算机中的实际RAM(Random Access Memory),用于存储操作系统和运行中的程序数据。

虚拟内存是一种由操作系统和硬件提供的机制,它为进程提供一个连续的、统一的地址空间,即使物理内存不足,也可以通过存储设备(如磁盘)扩展可用内存。

当物理内存不足的时候,操作系统可以通过虚存来进行地址空间的扩展。

虚拟地址(Virtual Address, VA)需要转换为物理地址(Physical Address, PA)才能访问实际的物理内存。转换的方式的方式是通过映射

虚拟内存的特点:

1.地址空间扩展:即使物理内存不足,操作系统也能利用磁盘上的**页面交换(paging)**来运行大程序。

2.进程隔离:每个进程拥有独立的地址空间,避免了不同进程间的内存冲突。

3.简化内存管理:程序可以使用连续的地址空间,而不必关心底层的物理内存如何分配。

以上几点特点在实验中都会有所涉及。

页表与映射相关概念

在RISC-V中,地址是64位,但仅仅使用低位的39位来表示 虚拟地址

这39位中,27位为页表项(PTE)的编号,12位为offset

PTE由物理块号和flags组成。

物理块号对应物理内存中高44位的地址,这44位加上offset的12位就构成了物理地址

总结一下,我们知道了物理地址和虚拟地址的映射关系:

虚拟地址=PTE编号+offset

通过PTE编号和页表基地址来获取物理块号(PPN)

物理地址=物理块号+offset

那么映射的过程具体是怎么样的呢?

我们需要映射首先就要通过PTE编号和页表基地址来找到物理块号,从而找到物理地址。这个过程称为寻址

1.MMU从satp寄存器中获取页表的基地址 2.从虚拟地址中取出PTE编号,再结合页表的基地址,在内存中找到该页表,然后遍历页表,由PTE编号找到相应的PTE,我们要找的PNN就在其中。 3.从PTE中取出PNN,如果是单级页表,则PNN+offset则为最终物理地址;如果是多级页表,则PNN作为下一级页表的基地址。

那么这里的多级页表概念,将在操作系统中有所涉及,这里不做过多赘述,只需要知道上述寻址过程中第三个步骤对于其地址的理解即可,接下来通过图片大致了解其内容。

image-20250401092229918

对于这个三级页表,我们需要知道的是如何取出最终的物理地址,那么就需要不断获取每一级的基地址来得到源头地址。具体映射过程如下:

1.从satp寄存器中取出三级页表的基地址A3 计算三级页表条目地址(PTE):A3+L2x4 (每个PTE占4字节) 读取该PTE对应的物理块号PNN,读取结果作为二级页表的基地址A2

2.获取二级页表的基地址A2 计算二级页表条目地址(PTE):A2+L1x4 (每个PTE占4字节) 读取该PTE对应的物理块号PNN,读取结果作为一级页表的基地址A1

3.获取一级页表的基地址A1 计算一级页表条目地址(PTE):A1+L0x4 (每个PTE占4字节) 读取该PTE对应的物理块号PNN,读取结果作为最终物理页面的基地址A0

4.最终物理地址为:A0+offset

以上前置内容了解之后,可以开始实验,但最好还是阅读了xv6原文较为稳妥。

实验1 Print a page table (难度:Easy)

实验要求

定义一个名为vmprint()的函数。它应当接收一个pagetable_t作为参数,并以下面描述的格式打印该页表。在exec.c中的return argc之前插入if(p->pid==1) vmprint(p->pagetable),以打印第一个进程的页表。如果你通过了pte printout测试的make grade,你将获得此作业的满分。

输入与预期输出

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

解释:

第一行显示vmprint的参数。

之后的每行对应一个PTE,包含树中指向页表页的PTE。每个PTE行都有一些“..”的缩进表明它在树中的深度。每个PTE行显示其在页表页中的PTE索引、PTE比特位以及从PTE提取的物理地址。

不要打印无效的PTE。在上面的示例中,顶级页表页具有条目0和255的映射。条目0的下一级只映射了索引0,该索引0的下一级映射了条目0、1和2。

您的代码可能会发出与上面显示的不同的物理地址。条目数和虚拟地址应相同。

提示

  • 你可以将vmprint()放在*kernel/vm.c*
  • 使用定义在*kernel/riscv.h*末尾处的宏
  • 函数freewalk可能会对你有所启发
  • vmprint的原型定义在*kernel/defs.h*中,这样你就可以在exec.c中调用它了
  • 在你的printf调用中使用%p来打印像上面示例中的完成的64比特的十六进制PTE和地址

实验思路与操作

仔细阅读上述的输出解释,会发现它明确指出了多级页表的特点,那么我们就要使用到递归的知识来进行层级页表的分别打印。

image-20250401100717133

看图来解释。

satp是存放根页表再物理内存中的地址,我们可以把它看成父节点,在它以下的都是它的子节点。页表以三级树型结构存储在物理内存中。

每一级页表的satpSupervisor Address Translation and Protection)的主要作用是提供虚拟地址到物理地址的映射入口。它存储了根页表的物理地址(PPN),CPU 通过该地址找到第一级页表,并逐级解析,最终得到物理地址。

也就是说我们通过satp再结合Ln可以找到下一级的基地址。

那么在我们的遍历打印实验中,只需要找到这几个参数并进行相应的索引打印即可。

那么我们设置的层级打印函数必须就要有两个参数,一个是指定的页表,另一个是层级(depth)。

我们先定义和编写函数vmprint()。这个函数用来进行根页表的打印。

// kernel/vm.c
void            
vmprint(pagetable_t pagetable){
  // 打印根页表
  printf("page table %p\n", pagetable);
  // 重新写个函数是为了传递level级和递归
  pgtblprint(pagetable, 1);
}

然后除了根页表的打印,我们还需要使用递归来进行层级页表的打印,上述代码也已经提示。

对于这里的递归实现,我们看提示,可以参考freewalk的遍历方式,那么我们去看这个函数。

image-20250401102034822

发现可以通过pte&PTE_V判断pte的有效性,同时在这个前提下来判断具体是哪一级页表: (pte & (PTE_R|PTE_W|PTE_X)) == 0)

这里具体的判定规则如下:

(1) 判断是否有效条目

通过检查 PTE_V 位:

if (pte & PTE_V)
  • 如果 PTE_V 被设置,说明该条目有效。

(2) 判断是否是叶子页表

通过检查 PTE_RPTE_WPTE_X 位:

if ((pte & (PTE_R | PTE_W | PTE_X)) == 0)
  • 如果这些位都未设置,说明该条目不是叶子页表,而是指向下一级页表。
  • 如果这些位中有任何一个被设置,说明该条目是叶子页表。

其他就是递归和如果到最后页表时的判定。仿造编写即可。如下:

int pgtblprint(pagetable_t pagetable, int depth) {
    // 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) {
      // print
      printf("..");
      for(int j=0;j<depth;j++) {
        printf(" ..");
      }
      printf("%d: pte %p pa %p\n", i, pte, PTE2PA(pte));

      // if not a leaf page table, recursively print out the child table
      if((pte & (PTE_R|PTE_W|PTE_X)) == 0){
        // this PTE points to a lower-level page table.
        uint64 child = PTE2PA(pte);
        pgtblprint((pagetable_t)child,depth+1);
      }
    }
  }
  return 0;
}

注意,上述函数都要在def.h中声明。

// vm.c
...
int 			pgtblprint(pagetable_t pagetable, int); 
int 			vmprint(pagetable_t pagetable);

然后进行 test。通过即完成。

实验2 A kernel page table per process (难度:Hard)

到这里,lab3开始上强度了。如果不真正理解页表与虚拟地址的知识,我想说根本做不出来。(哪个实验不是理解了知识才能做出来)

同时还有很重要的一点是理解题目意思,这里我会进行拆分理解。

  • 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.c*kernel/proc.c*中这样做(但不要修改*kernel/vmcopyin.c*, *kernel/stats.c*, user/usertests.c*, 和user/stats.c*
  • 页表映射丢失很可能导致内核遭遇页面错误。这将导致打印一段包含sepc=0x00000000XXXXXXXX的错误提示。你可以在*kernel/kernel.asm*通过查询XXXXXXXX来定位错误。

实验思路与操作

我们根据提示来一步一步完成。

  • struct proc中为进程的内核页表增加一个字段
// proc.h
struct proc {
  ...
  pagetable_t kernelpgtbl;      // 内核页表
};
  • 为一个新进程生成一个内核页表的合理方案是实现一个修改版的kvminit,这个版本中应当创造一个新的页表而不是修改kernel_pagetable。你将会考虑在allocproc中调用这个函数

注意,我们需要创建一个新的页表而不是修改原有内核页表,那么我们就是说要模仿它的操作来进行仿写。首先是kvminit

image-20250401110914966

看这个函数,我们仿写即可

void
kvminit()
{
  kernel_pagetable = kvminit_newpgtbl(); // 仍然需要有全局的内核页表,用于内核 boot 过程,以及无进程在运行时使用。
}

然后这里kvminit_newpgtbl()就是创建内核页表

pagetable_t
kvminit_newpgtbl()
{
  pagetable_t pgtbl = (pagetable_t) kalloc();
  memset(pgtbl, 0, PGSIZE);

  kvm_map_pagetable(pgtbl);

  return pgtbl;
}

这里的 kvm_map_pagetable(pgtbl)即接下来要写的创建函数,我们模仿即可。

void kvm_map_pagetable(pagetable_t pgtbl) {
  // 将各种内核需要的 direct mapping 添加到页表 pgtbl 中。

  // 映射 UART 寄存器
  kvmmap(pgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);

  // 映射 VirtIO 磁盘接口
  kvmmap(pgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);

  // 映射 PLIC(中断控制器)
  kvmmap(pgtbl, PLIC, PLIC, 0x400000, PTE_R | PTE_W);

  // 映射内核代码段(只读 + 可执行)
  kvmmap(pgtbl, KERNBASE, KERNBASE, (uint64)etext - KERNBASE, PTE_R | PTE_X);

  // 映射内核数据段和物理内存(读写)
  kvmmap(pgtbl, (uint64)etext, (uint64)etext, PHYSTOP - (uint64)etext, PTE_R | PTE_W);

  // 映射 trampoline(陷入/退出的跳板代码)
  kvmmap(pgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}


// 将某个逻辑地址映射到某个物理地址(添加第一个参数 pgtbl)
void
kvmmap(pagetable_t pgtbl, uint64 va, uint64 pa, uint64 sz, int perm)
{
  if(mappages(pgtbl, va, sz, pa, perm) != 0)
    panic("kvmmap");
}

进行完以上的仿写之后,我们还要将内核逻辑地址转换为物理地址,这是提示里没说的,但是是必要的。

// kvmpa 将内核逻辑地址转换为物理地址(添加第一个参数 kernelpgtbl)
uint64
kvmpa(pagetable_t pgtbl, uint64 va)
{
  uint64 off = va % PGSIZE;
  pte_t *pte;
  uint64 pa;

  pte = walk(pgtbl, va, 0);
  if(pte == 0)
    panic("kvmpa");
  if((*pte & PTE_V) == 0)
    panic("kvmpa");
  pa = PTE2PA(*pte);
  return pa+off;
}
  • 确保每一个进程的内核页表都关于该进程的内核栈有一个映射。在未修改的XV6中,所有的内核栈都在procinit中设置。你将要把这个功能部分或全部的迁移到allocproc

我们看procinit中的内核栈,它是为不同进程创建多个内核栈,并map到不同位置。但我们的新设计,每一个进程都由自己独立的内核页表,而每个进程也只能访问自己的内核栈,也就是一一对应的形式,那么我们只需要将每个进程的内核栈都map到各自的内核页表内的固定位置即可。

// 这是我们需要迁移的代码(在procinit中)
struct proc *p;
  
initlock(&pid_lock, "nextpid");
for(p = proc; p < &proc[NPROC]; p++) {
      initlock(&p->lock, "proc");
}

迁移到proc.callocproc

// kernel/proc.c

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

//----------------------- 新加部分 start ---------------------

  // 为新进程创建独立的内核页表,并将内核所需要的各种映射添加到新页表上
  p->kernelpgtbl = kvminit_newpgtbl();
  // printf("kernel_pagetable: %p\n", p->kernelpgtbl);

  // 分配一个物理页,作为新进程的内核栈使用
  char *pa = kalloc();
  if(pa == 0)
    panic("kalloc");
  uint64 va = KSTACK((int)0); // 将内核栈映射到固定的逻辑地址上
  // printf("map krnlstack va: %p to pa: %p\n", va, pa);
  kvmmap(p->kernelpgtbl, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
  p->kstack = va; // 记录内核栈的逻辑地址,其实已经是固定的了,依然这样记录是为了避免需要修改其他部分 xv6 代码

//--------------------------新加部分 end ----------------------

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

ok,到这里,我们每个进程独立的内核页表创建就完成了。接下来我们需要实现切换到进程内核页表。

  • 修改scheduler()来加载进程的内核页表到核心的satp寄存器(参阅kvminithart来获取启发)。不要忘记在调用完w_satp()后调用sfence_vma()
  • 没有进程运行时scheduler()应当使用kernel_pagetable

这里的具体实现,实际上就是在调度器将CPU交给进程执行之前,就要切换到该进程对应的内核页表。

// kernel/proc.c
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;

        // 切换到进程独立的内核页表
        w_satp(MAKE_SATP(p->kernelpgtbl));
        sfence_vma(); // 清除快表缓存
        
        // 调度,执行进程
        swtch(&c->context, &p->context);

        // 切换回全局内核页表
        kvminithart();

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

这样,每个进程就只会采用自己独立的内核页表了。

  • freeproc中释放一个进程的内核页表
  • 你需要一种方法来释放页表,而不必释放叶子物理内存页面。
  • 调式页表时,也许vmprint能派上用场

最后需要做的是在进程结束后,进行释放内核页表。我们需要注意,因为我们需要一一释放进程们单独的内核页表,那么就容易出现大量的内存泄漏(代码错误导致每释放一次都会有内存泄漏),从而出现panic kvmmap的错误,所以我们应该仔细检查是否每一处分配内存都释放干净。

// kernel/proc.c
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;
  
  // 释放进程的内核栈
  void *kstack_pa = (void *)kvmpa(p->kernelpgtbl, p->kstack);
  // printf("trace: free kstack %p\n", kstack_pa);
  kfree(kstack_pa);
  p->kstack = 0;
  
  // 注意:此处不能使用 proc_freepagetable,因为其不仅会释放页表本身,还会把页表内所有的叶节点对应的物理页也释放掉。
  // 这会导致内核运行所需要的关键物理页被释放,从而导致内核崩溃。
  // 这里使用 kfree(p->kernelpgtbl) 也是不足够的,因为这只释放了**一级页表本身**,而不释放二级以及三级页表所占用的空间。
  
  // 递归释放进程独享的页表,释放页表本身所占用的空间,但**不释放页表指向的物理页**
  kvm_free_kernelpgtbl(p->kernelpgtbl); //这里用于递归释放多级页表
  p->kernelpgtbl = 0;
  p->state = UNUSED;
}

多级页表释放的代码如下:

// kernel/vm.c

// 递归释放一个内核页表中的所有 mapping,但是不释放其指向的物理页
void
kvm_free_kernelpgtbl(pagetable_t pagetable)
{
  // there are 2^9 = 512 PTEs in a page table.
  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){ // 如果该页表项指向更低一级的页表
      // 递归释放低一级页表及其页表项
      kvm_free_kernelpgtbl((pagetable_t)child);
      pagetable[i] = 0;
    }
  }
  kfree((void*)pagetable); // 释放当前级别页表所占用空间
}

最后,我们应该注意到之前kvmpa()的修改影响了virtio_disk.c,我们进行修改即可。

// virtio_disk.c
#include "proc.h" // 添加头文件引入

// ......

void
virtio_disk_rw(struct buf *b, int write)
{
// ......
disk.desc[idx[0]].addr = (uint64) kvmpa(myproc()->kernelpgtbl, (uint64) &buf0); // 调用 myproc(),获取进程内核页表
// ......
}

然后进行 test操作。

实验3 Simplify copyin/copyinstr(难度:Hard)

前一个实验实际上是在为这个最后的实验做预备。

我们直接看内容并解读。

内核的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.c*中的kvminit() kernel/memlayout.h和文中的图3-4。您需要修改xv6,以防止用户进程增长到超过PLIC的地址。

提示

  • 先用对copyin_new的调用替换copyin(),确保正常工作后再去修改copyinstr
  • 在内核更改进程的用户映射的每一处,都以相同的方式更改进程的内核页表。包括fork(), exec(), 和sbrk().
  • 不要忘记在userinit的内核页表中包含第一个进程的用户页表
  • 用户地址的PTE在进程的内核页表中需要什么权限?(在内核模式下,无法访问设置了PTE_U的页面)
  • 别忘了上面提到的PLIC限制

实验思路与操作

我们在上个实验中已经实现了每个进程都拥有自己独立的内核页表,而这个实验的目的就是运用这个特性,使得内核态可以对用户态传进来的指针进行解引用。这一点也在之前的题目中有所提及。

image-20250401134525379

我们先来看提示中提到的copyin代码:

image-20250401134745288

我们发现,这里的copyin是通过软件模拟访问页表的过程获取物理地址的,现在我们需要重写这个copyin,从而使得内核页表内维护映射副本,从而利用CPU硬件寻址功能来提速。

那么我们只需要在每次内核对用户页表进行修改时,将同样的修改对应在内核页表上,也就使得这两个页表的程序段地址空间的映射同步,从而达到提速。

首先我们写一个函数来将用户页表的一部分映射复制到内核页表中。它只复制页表的映射关系(即虚拟地址到物理地址的映射),而不复制实际的物理内存内容。

int
kvmcopymappings(pagetable_t src, pagetable_t dst, uint64 start, uint64 sz)
{
  pte_t *pte;
  uint64 pa, i;
  uint flags;

  // PGROUNDUP: prevent re-mapping already mapped pages (eg. when doing growproc)
  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;
    if(mappages(dst, i, PGSIZE, pa, flags) != 0){
      goto err;
    }
  }

  return 0;

 err:
  uvmunmap(dst, PGROUNDUP(start), (i - PGROUNDUP(start)) / PGSIZE, 0);
  return -1;
}

我们看这个函数:

image-20250401140552681

它实现了用户页表的映射释放以及物理内存的释放。那么我们的函数需要做到什么呢?

我们只需要释放内核页表的映射,对于物理内存,我们应该保存。所以只需将参数改一下就行。

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);// 注意这里的0,就是不要释放物理内存
  }

  return newsz;
}

在写完函数后不要忘了函数的声明。

完成这些工作之后,我们要为映射程序内存做准备。

首先看提示得知,**我们需要去除CLINT的映射,这是为了防止CLINT与程序内存映射冲突。**原因如下图,我么发现PLIC与CLINT隔得很近,并且CLINT只有在内核启动的时候会用得到,so用户进程在内核态中的操作并不需要使用这个映射,那我们就把它注释掉,防止发生冲突。

image-20250401141101064

同时我们需要注意,这是为了保全大局而将其注释掉,但是我们启动的时候还是需要用到CLINT呀,这下该怎么办呢?

那么我们可以在kvminit()中再单独给它映射不就好了,反正这个函数就是用来启动内核的。

如下修改:

// kernel/vm.c


void kvm_map_pagetable(pagetable_t pgtbl) {
  
  // uart registers
  kvmmap(pgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);

  // virtio mmio disk interface
  kvmmap(pgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);

  // CLINT孤立你
  // kvmmap(pgtbl, CLINT, CLINT, 0x10000, PTE_R | PTE_W);

  // PLIC
  kvmmap(pgtbl, PLIC, PLIC, 0x400000, PTE_R | PTE_W);

  // ......
}

// ......

void
kvminit()
{
  kernel_pagetable = kvminit_newpgtbl();
  // CLINT 进行单独的映射
  kvmmap(kernel_pagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
}

这下防止了冲突,但是我们也要避免程序内存超过PLIC,那么就在exec中加入检查:

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;
    if(sz1 >= PLIC) { // 添加检测,防止程序大小超过 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;
  }
  iunlockput(ip);
  end_op();
  ip = 0;
  // .......

ok,这下有关安全性的问题解决了,接下来要来修改几个函数,使得每个修改到用户页表的位置,能够将相应的修改同步到内核页表中。

fork()

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

  // ......
}

exec()

// kernel/exec.c
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->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);
  // ......
}

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

userinit()

对于 init 进程,由于不像其他进程,init 不是 fork 得来的,所以需要在 userinit 中也添加同步映射的代码。

// kernel/proc.c
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); // 同步程序内存映射到进程内核页表中

  // ......
}

到这里,两个页表的同步操作就都完成了。

ok,回到提示的第一句话:先用对copyin_new的调用替换copyin(),确保正常工作后再去修改copyinstr

现在我们可以替换copyin()和修改cpoyinstr了。

// kernel/vm.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);

// 将 copyin、copyinstr 改为转发到新函数
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);
}

如果想要看这两个new函数的具体代码,可以看vmcopyin.c

image-20250401143204379

ok大功告成,只需要 test 即可。

注:鉴于此实验较难,笔者参考了这位大佬的笔记和代码:blog.miigon.net/posts/s081-…