操作系统xv6实验Lab3

267 阅读26分钟

Page tables

Print a page table (easy)

为了帮助您了解RISC-V页表,也许为了帮助将来的调试,您的第一个任务是编写一个打印页表内容的函数。

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

现在,当您启动xv6时,它应该像这样打印输出来描述第一个进程刚刚完成exec()inginit时的页表:

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和地址

QUESTION

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

解答

该实验需要实现一个打印页表内容的函数,以示例所示的格式打印传进的页表。

​ 在Sv39模式下,页表是一个三级树型结构根页表是这棵树的根节点,它是一个4KB(4096字节)的页,每个页有512个PTE,每个PTE记录了下一级页表的位置(也就是下一级页表的物理地址,最后一级页表的PTE指向的是最终映射的物理地址)。

需要模拟查询页表的过程,对三级页表进行遍历并打印。而kernel/vm.c中的freewalk()函数已经实现了递归遍历页表并将其释放,所以只要模仿其逻辑实现打印功能即可。代码:

// kernel/vm.c
//递归打印页表
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) {      //如果页表项有效,按格式打印页表项
            printf("..");
            for (int j = 0;j < depth;++j)
                printf(" ..");
            printf("%d: pte %p pa %p\n", i, pte, PTE2PA(pte));


            //如果该节点不是叶节点,递归打印子节点
            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;
}

//打印页表
int vmprint(pagetable_t pagetable) {
    printf("page table %p\n", pagetable);
    return pgtblprint(pagetable, 0);
}

然后在内核头文件添加函数声明

// kernel/defs.h
// vm.c
void            kvminit(void);
void            kvminithart(void);
uint64          kvmpa(uint64);
void            kvmmap(uint64, uint64, uint64, int);
int             mappages(pagetable_t, uint64, uint64, uint64, int);
pagetable_t     uvmcreate(void);
void            uvminit(pagetable_t, uchar *, uint);
uint64          uvmalloc(pagetable_t, uint64, uint64);
uint64          uvmdealloc(pagetable_t, uint64, uint64);
#ifdef SOL_COW
#else
int             uvmcopy(pagetable_t, pagetable_t, uint64);
#endif
void            uvmfree(pagetable_t, uint64);
void            uvmunmap(pagetable_t, uint64, uint64, int);
void            uvmclear(pagetable_t, uint64);
uint64          walkaddr(pagetable_t, uint64);
int             copyout(pagetable_t, uint64, char *, uint64);
int             copyin(pagetable_t, char *, uint64, uint64);
int             copyinstr(pagetable_t, char *, uint64, uint64);
int             vmprint(pagetable_t pagetable);         //打印页表内容函数声明

照实验需求,在exec.c中的return argc之前插入if(p->pid==1) vmprint(p->pagetable),以打印第一个进程的页表

int exec(char *path, char **argv) {

    ...... 
    
    if (p->pid == 1) vmprint(p->pagetable); //exec返回之前打印一下页表 
    return argc; // this ends up in a0, the first argument to main(argc, argv) 

    bad: 
     if(pagetable) proc_freepagetable(pagetable, sz); 
     if(ip){ 
        iunlockput(ip); 
        end_op(); 
     } 
    return -1; 
}

到此可以执行make qemu启动xv6,验证是否得到和示例相似的结果,打印出了第一个进程的页表

A kernel page table per process (hard)

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

解答

当前xv6操作系统中,在用户态下的每个用户进程都使用各自的用户态页表。一旦进入了内核态(例如系统调用)就会切换到内核态页表(通过修改 satp 寄存器,trampoline.S)。然而这个内核态页表是全局共享的,所有进程进入内核态之后都会共用一个内核态页表。

​ 共享一个内核页表有什么弊端呢?

​ 进程可能会意外或恶意地访问其他进程的内核数据。如果一个进程因为 bug 或恶意操作访问了内核中的敏感数据,它可能会影响其他进程或系统的整体稳定性。

​ 每次创建或删除进程时,都需要小心更新共享的页表条目,以确保不同进程之间的内存不会冲突或被错误覆盖。这会增加系统的复杂性,并且在多核系统中,这种全局共享的管理会增加同步开销和冲突的可能性

​ 如果每个进程进入内核态之后,都能有自己独立的内核页表,可以避免很多麻烦,这就是这个实验的目的

先在进程的结构体proc中添加一个新的内核页表属性,用来存储进程独享的内核态页表:

// proc.h
// 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)
  pagetable_t kernelpgtbl;     //存储进程独享的内核态页表 
};

内核进程需要依赖内核页表内一些固定的映射才能正常工作,例如 UART 控制、硬盘界面、中断控制等。而 kvminit 原本只为全局内核页表 kernel_pagetable 添加这些映射,所以接下来大幅度改动kernel/vm.c,使其他进程也可以创建独享的内核页表。

先将原本的kvminit抽象,全局内核页表仍然使用这个函数来初始化:

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

实现kvminit_newpgtbl函数,创建一个页表并初始化映射,返回这个页表

// kernel/vm.c
pagetable_t
kvminit_newpgtbl()
{
    pagetable_t pgtbl = (pagetable_t) kalloc();
    memset(pgtbl, 0, PGSIZE);

    kvm_map_pagetable(pgtbl);

    return pgtbl;
}

注意!要在头文件中defs.h声明这个函数

// vm.c
......
int             copyin(pagetable_t, char *, uint64, uint64);
int             copyinstr(pagetable_t, char *, uint64, uint64);
int             vmprint(pagetable_t pagetable);         //打印页表内容函数声明
pagetable_t     kvminit_newpgtbl();                     //声明新函数

初始化页表映射函数kvm_map_pagetable

// kernel/vm.c
void kvm_map_pagetable(pagetable_t pgtbl) {
    // 将各种内核需要的 direct mapping 添加到页表 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);

    // map kernel text executable and read-only.
    kvmmap(pgtbl, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);

    // map kernel data and the physical RAM we'll make use of.
    kvmmap(pgtbl, (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(pgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}

注意:代码的顺序!!!函数实现的编写应该按照kvm_map_pagetablekvminit_newpgtblkvminit的顺序,否则会报错

由于修改了kvmmap,所以需要在头文件defs.h中修改对应的函数声明

// defs.h
// vm.c
void            kvminit(void);
void            kvminithart(void);
uint64          kvmpa(pagetable_t,uint64); //修改参数列表
void            kvmmap(pagetable_t,uint64, uint64, uint64, int);  //修改参数列表
int             mappages(pagetable_t, uint64, uint64, uint64, int);
pagetable_t     uvmcreate(void);
void            uvminit(pagetable_t, uchar *, uint);
uint64          uvmalloc(pagetable_t, uint64, uint64);
uint64          uvmdealloc(pagetable_t, uint64, uint64);
#ifdef SOL_COW
#else
int             uvmcopy(pagetable_t, pagetable_t, uint64);
#endif
void            uvmfree(pagetable_t, uint64);
void            uvmunmap(pagetable_t, uint64, uint64, int);
void            uvmclear(pagetable_t, uint64);
uint64          walkaddr(pagetable_t, uint64);
int             copyout(pagetable_t, uint64, char *, uint64);
int             copyin(pagetable_t, char *, uint64, uint64);
int             copyinstr(pagetable_t, char *, uint64, uint64);
int             vmprint(pagetable_t pagetable);         //打印页表内容函数声明
pagetable_t     kvminit_newpgtbl();                     //声明新函数

现在普通进程也可以通过调用kvminit_newpgtbl函数来创建自己的内核页表了,此时在内核态中就有两种页表:一种是内核进程独享的页表,另一种是其他进程各自独享的页表。所以关于内核页表处理的一些函数需要做一些改动。

比如kvmmap函数,将虚拟地址映射到物理地址,现在只处理内核进程的页表,所以修改一下让这个函数可以处理所有的页表

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

还有一个kvmpa函数,将虚拟地址翻译成物理地址,原来也是只处理内核进程的页表,同样修改一下:

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

    pte = walk(pgtbl, va, 0);			//kernel_pagetable改为参数pgtbl
    if (pte == 0)
        panic("kvmpa");
    if ((*pte & PTE_V) == 0)
        panic("kvmpa");
    pa = PTE2PA(*pte);
    return pa + off;
}

这样可以创建进程间相互独立的内核页表了,但是还有一个东西需要处理:内核栈。 原本的 xv6 设计中,所有处于内核态的进程都共享同一个页表,即意味着共享同一个地址空间。由于 xv6 支持多核/多进程调度,同一时间可能会有多个进程处于内核态,所以需要对所有处于内核态的进程创建其独立的内核态内的栈,也就是内核栈,供给其内核态代码执行过程。

在已经添加的新修改中,每一个进程都会有自己独立的内核页表。而现在需要每个进程只访问自己的内核栈,所以可以把每个进程的内核栈映射到各自内核页表的固定位置(不同页表内的同一逻辑地址,指向不同物理内存)

原本xv6为每一个进程分配好内核栈(在共享空间中),所以先把这部分代码去掉:

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

      //注释掉了上面的代码(为所有进程预分配内核栈的代码),变为创建进程的时候再创建内核栈,见 allocproc()
  }
  kvminithart();
}

在创建进程的时候,为进程创建独立的内核页表,然后将专属的内核栈固定到内核页表的固定位置,建立映射

// kernel/proc.c
static struct proc*
allocproc(void)
{
    
  ......

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

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

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

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

现在进程的内核页表就创建完成了,但是进程进入内核态时还是会使用全局的内核进程页表,需要在 scheduler() 中进行相关修改。在调度器将 CPU 交给进程执行之前,加载进程的内核页表到SATP寄存器,切换到该进程对应的内核页表:

// kernel/proc.c
void
scheduler(void)
{
        ......
        
        p->state = RUNNING;
        c->proc = p;

        // 切换到进程独立的内核页表
        w_satp(MAKE_SATP(p->kernelpgtbl));
        sfence_vma();       // 清除快表缓存,刷新TLB缓存,以确保地址转换表的更改生效

        // 调度,执行进程
        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;
        
        ......
}

现在,每个进程都会在内核态使用自己独立的内核页表了

在进程结束后,应该释放进程独享的页表以及内核栈,回收资源,否则会导致内存泄漏。

原本释放内存的函数在kernel/proc.c中,在此修改。这里按创建的顺序反着来,先释放进程的内核栈,再释放进程的内核页表:

// 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);
  kfree(kstack_pa);
  p->kstack = 0;

  // 此处不能使用 proc_freepagetable,因为其不仅会释放页表本身,还会把页表内所有的叶节点对应的物理页也释放掉。
  // 这会导致内核运行所需要的关键物理页被释放,导致内核崩溃。
  
  // 递归释放进程独享的页表,释放页表本身所占用的空间,但不释放页表指向的物理页
  kvm_free_kernelpgtbl(p->kernelpgtbl);
  p->kernelpgtbl = 0;


  p->state = UNUSED;
}

如果使用proc_freepagetable函数,会同时释放掉内核进程必要的映射,导致内核崩溃。proc_freepagetable函数如下

void
proc_freepagetable(pagetable_t pagetable, uint64 sz)
{
  uvmunmap(pagetable, TRAMPOLINE, 1, 0);
  uvmunmap(pagetable, TRAPFRAME, 1, 0);
  uvmfree(pagetable, sz);
}

所以在vm.c中另写一个kvm_free_kernelpgtbl函数,不释放页表指向的物理页:

// vm.c
// 递归释放一个内核页表中的所有 mapping,但是不释放其指向的物理页
void
kvm_free_kernelpgtbl(pagetable_t pagetable) {
    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);        // 释放当前级别页表所占用空间
}

注意,这里使用到了新函数kvm_free_kernelpgtbl,需要在头文件defs.h中声明

//defs.h
// vm.c
......
int             copyinstr(pagetable_t, char *, uint64, uint64);
int             vmprint(pagetable_t pagetable);         //打印页表内容函数声明
pagetable_t     kvminit_newpgtbl();                     //声明新函数
void            kvm_free_kernelpgtbl(pagetable_t);      //声明新函数

最后一个小问题。因为上面改动了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(),获取进程内核页表 

    ......
}

现在可以执行./grade-lab-pgtbl usertests,验证实验是否完成

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限制

Linux使用的技术与您已经实现的技术类似。直到几年前,许多内核在用户和内核空间中都为当前进程使用相同的自身进程页表,并为用户和内核地址进行映射以避免在用户和内核空间之间切换时必须切换页表。然而,这种设置允许边信道攻击,如Meltdown和Spectre。

解答

上一个实验已经让每一个进程都有独立的内核态页表了,该实验需要将用户态的映射添加到每个进程的内核页表,也就是将用户态的页表复制到内核态的页表。这样使得内核态也可以对用户态传进来的指针(逻辑地址)进行解引用。

​ 原来的copyin函数通过软件模拟访问页表的过程获取物理地址的,而在内核页表内维护映射副本的话,可以利用 CPU 的硬件寻址功能进行寻址,效率更高并且可以通过快表加速。

​ 要实现这样的效果,我们需要在每一处内核对用户页表进行修改的时候,将同样的修改也同步应用在进程的内核页表上,使得两个页表的程序段(0 到 PLIC 段)地址空间的映射同步。

首先实现一个复制页表的函数:

// kernel/vm.c
// 将 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: 将地址向上取整到页边界,防止重新映射已经映射的页,特别是在执行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;
}

再实现一个缩减内存的函数,用于同步内核页表和用户页表:

// kernel/vm.c
// 与 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;
}

既然新增了函数,就要在defs.h中声明

// kernel/defs.h/vm.c
int kvmcopymappings(pagetable_t src, pagetable_t dst, uint64 start, uint64 sz);     //声明新函数
uint64 kvmdealloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz);       //声明新函数

xv6内核中,用于映射程序内存的地址范围是[0,PLIC),PLIC:

// kernel/memlayout.h
// qemu puts programmable interrupt controller here.
#define PLIC 0x0c000000L

需要把进程的程序内存映射到其内核页表的这个范围,首先确认这个范围内没有和其他映射冲突。

在xv6手册中,可以看到这个范围中有一个CLINT(核心本地中断器)的映射,这个映射和刚才的说的程序内存映射有冲突了。

image.png

不过在手册中也可知,CLINT映射只在内核启动的时候需要使用,在内核态的用户进程并不需要使用这个映射。

所以可以在上一个实验中的kvm_map_pagetable函数中修改一下,把CLINT这个映射去掉:

void kvm_map_pagetable(pagetable_t pgtbl) {
    // 将各种内核需要的 direct mapping 添加到页表 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);

    // map kernel text executable and read-only.
    kvmmap(pgtbl, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);

    // map kernel data and the physical RAM we'll make use of.
    kvmmap(pgtbl, (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(pgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}

这样进程的内核页表中就不会有程序内存映射和CLINT映射冲突的问题了。但是这个映射是内核启动所必须的,所以可以在全局内核页表初始化中加上这个映射:

// kernel/vm.c
void
kvminit()
{
  kernel_pagetable = kvminit_newpgtbl(); // 仍然需要有全局的内核页表,用于内核 boot 过程,以及无进程在运行时使用。
  kvmmap(kernel_pagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);     // 全局内核页表仍需要映射 CLINT
}

在 exec 中加入检查,防止程序内存超过 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; 
        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; 
    } 
    ....... 
}

之后涉及到用户态页表的修改,都要把相应的修改同步到进程的内核页表中,包括:fork()exec()growproc()userinit()

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()

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()

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

这样就实现了进程用户态页表和内核态页表的同步

再按照实验要求替换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);
}

既然新增了函数,就需要在defs.h中声明

// kernel/defs.h/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_newcopyinstr已在vmcopyin.c中实现

到此可以执行make grade,验证全部实验是否正确完成