[MIT 6.S081] Lab 3: page tables

Lab 3: page tables

Print a page table (easy)

要点

  • 不输出无效的 PTE

步骤

  1. kernel/vm.c 中编写函数 vmprint()
    此处可以参考 freewalk() 函数的实现. 对于输出, 此处可以采用循环和递归两种方式实现: 循环的实现即三重循环遍历三级页目录即可; 递归的实现与 freewalk() 函数很相似, 通过其代码可以看到 (PTE_R | PTE_W | PTE_X)) == 0 可以作为判断是否不为最低级页目录的条件, 即对于上两级页目录的 PTE 索引到的内容是下一级页目录的 PTE, 应该均不可读写执行; 而最低级页目录的 PTE 索引到的是一个页表, 其中的内容应该满足读写执行的条件之一, 进而根据该条件作为递归的出口.
  • 循环实现:
// print page tables lab3-1
void vmprint(pagetable_t pagetable) {
    printf("page table %p\n", pagetable);
    // range top page dir
    const int PAGE_SIZE = 512;
    // 遍历最高级页目录
    for (int i = 0; i < PAGE_SIZE; ++i) {
        pte_t top_pte = pagetable[i];
        if (top_pte & PTE_V) {
            printf("..%d: pte %p pa %p\n", i, top_pte, PTE2PA(top_pte));
            // this PTE points to a lower-level page table.
            pagetable_t mid_table = (pagetable_t) PTE2PA(top_pte);
            // 遍历中间级页目录
            for (int j = 0; j < PAGE_SIZE; ++j) {
                pte_t mid_pte = mid_table[j];
                if (mid_pte & PTE_V) {
                    printf(".. ..%d: pte %p pa %p\n",
                           j, mid_pte, PTE2PA(mid_pte));
                    pagetable_t bot_table = (pagetable_t) PTE2PA(mid_pte);
                    // 遍历最低级页目录
                    for (int k = 0; k < PAGE_SIZE; ++k) {
                        pte_t bot_pte = bot_table[k];
                        if (bot_pte & PTE_V) {
                            printf(".. .. ..%d: pte %p pa %p\n",
                                   k, bot_pte, PTE2PA(bot_pte));
                        }
                    }
                }
            }
        }
    }
}
  • 递归实现:
// lab3-1
void vmprinthelper(pagetable_t pagetable, int level) {
    for (int i = 0; i < 512; ++i) {
        pte_t pte = pagetable[i];
        // 判断PTE是否有效
        if (pte & PTE_V) {
            switch(level)
            {
                case 3:
                    printf(".. ");
                case 2:
                    printf(".. ");
                case 1:
                    printf("..%d: pte %p pa %p\n", i, pte, PTE2PA(pte));
            }
            pagetable_t child = (pagetable_t) PTE2PA(pte);
            // 判断是否不为最低级页目录
            if ((pte & (PTE_R | PTE_W | PTE_X)) == 0) {
                vmprinthelper(child, level + 1);
            }
        }
    }
}

// print page tables lab3-1
void vmprint(pagetable_t pagetable) {
    printf("page table %p\n", pagetable);
    vmprinthelper(pagetable,1);
}
  1. kernel/defs.h 文件中添加函数原型
    在这里插入图片描述
  2. kernel/exec.cexec 函数的 return argc 前插入 if(p->pid==1) vmprint(p->pagetable)
    这里考虑 xv6 的启动过程, 推测 pid==1 的进程为 kernel/main.cmain() 函数中调用的 userinit() 函数所建立的控制台进程.
    在这里插入图片描述

测试

编译启动 xv6 时即显示如下:
在这里插入图片描述
在这里插入图片描述

思考题

  • Q: Explain the output of vmprint in terms of Fig 3-4 from the text. What does page 0 contain? What is in page 2? When running in user mode, could the process read/write the memory mapped by page 1?
    A:
    根据 exec() 函数和 xv6 指导书可以看到如下代码会为 ELF 文件的段(segment)分配内存并进行加载, 因此推测该内容即为 page 0 所存放的.
    在这里插入图片描述
    根据下端代码的注释可以看到, 在加载 ELF 段后会分配两个 page, 对应 page1 和 page2, 而 page 2 用作用户的栈.
    在这里插入图片描述
    在这里插入图片描述
    进一步根据 图3.4 可以看出, page0 应该主要是应用程序的代码段和数据段; 而 page2 即对应着用户栈; 中间的 page1 应该是 guard page, 无物理地址实际映射, 用于溢出检测.

A kernel page table per process (hard)

要点

  • 每个用户进程有一个独立的内核页表. 这里的内核页表同样是带有三级页目录的页表结构.
  • 修改 struct proc , 并在进程切换时切换到用户进程的内核页表到 STAP 寄存器.
  • 当没有进程运行时使用原本的内核页表 kernel_pagetable

步骤

1. 在 proc 结构体中添加内核页表成员

kernel/proc.h 中的 struct proc 结构体中添加成员变量 kpagetable, 代表进程的内核页表. 此外, 该内核页表是进程私有的, 因此置于结构体下半部分.

// 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 kpagetable;      // kernel pagetable - lab3-1
};
2. 构建进程的内核页表的映射函数 proc_kpagetable()
  • 参考 kernel/vm.c 中的 kvminit() 函数, kvminit() 中通过调用 kvmmap() 函数完成 UARTO、VIRTIO0、CLINT 等部分的映射, 其底层是调用的 mappages() 函数, 默认页表为全局的内核页表 kernel_pagetable . 因此此处不能直接调用该函数, 而是需要替换 kvmmap() 函数从而不是构建全局内核页表.
  • 此处同样可以借鉴 kernel/proc.c 中的 proc_pagetable() 函数, 构建页表可以直接复用 uvmcreate() 函数.
  • 接下来考虑 UARTO、VIRTIO0、CLINT 等部分的映射. 通过比较可以看到: 在 kvminit() 中对应构建失败的情况是直接 panic("kvmmap") 引发 panic 终止; 而在 proc_pagetable(), 则是通过 uvmunmap()uvmfree() 来分别取消先前的页表映射和释放页表三级结构, 然后返回 0, 由上层函数处理失败.
    在这里插入图片描述
  • 这里笔者最终选择了类似 kvminit() 的方法. 原因有二:
    1. 一方面内核页表的映射结构比较复杂, 每次在使用 uvmunmap() 取消先前映射时代码比较冗长;
    2. 而更重要的一点是 uvmunmap() 函数的第 3 个参数是 npages, 即需要是页表大小 PGSIZE 的整数倍, 而对于内核的代码段的长度为 etext-KERNBASE0x8000, 不为 PGSIZE 的整数倍, 但 uvmunmap() 要求取消映射的页表中的所有 PTE 必须是有效的, 若不为 PGSIZE 的整数倍则意味着一部分 PTE 不能释放(向下取整), 或者引发 panic (向上取整), 虽然实际上基本该情况基本不会发生, 但本身存在逻辑错误.
      在这里插入图片描述
  • 最终代码首先在 kernel/vm.c 中仿照 kvmmap() 编写了函数 uvmmap(), 区别在于添加了 pagetable_t 的参数, 用于传入进程自身的内核页表结构体.
void uvmmap(pagetable_t pagetable, uint64 va, uint64 pa, uint64 sz, int perm) {
    if(mappages(pagetable, va, sz, pa, perm) != 0) {
        panic("uvmmap");
    }
}
  • proc_kpagetable() 则基本参照 kvminit() 编写即可.
// create a kernel page table for the given process - lab3-2
pagetable_t proc_kpagetable(struct proc *p) {
    // 创建空页表
    pagetable_t kpagetable = uvmcreate();
    if(kpagetable == 0){
        return 0;
    }

    uvmmap(kpagetable, UART0, UART0, PGSIZE, PTE_R | PTE_W);
    uvmmap(kpagetable, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
    uvmmap(kpagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
    uvmmap(kpagetable, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
    uvmmap(kpagetable, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
    uvmmap(kpagetable, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);
    uvmmap(kpagetable, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);    // 注意va为TRAMPOLINE

    return kpagetable;
}
  • 最后在 kernel/proc.callocproc() 函数中调用, 该函数如其名称所示, 是用于分配一个进程.
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;
  }
  // process's kernel page table - lab3-2
  p->kpagetable = proc_kpagetable(p);
  if (p->kpagetable == 0) {
    freeproc(p);
    release(&p->lock);
    return 0;
  }  
  //...
}
3. 为进程的内核页表添加内核栈的映射
  • 参考 kernel/proc.cprocinit() 函数, 其中通过循环遍历进程数组 proc[NPPROC] 中的每个进程, 来为其初始化进程的自旋锁和内核栈. 首先便是将其中为每个进程在全局内核页表中创建内核栈的部分代码注释掉, 因为之后进程的内核栈被映射到了自身的内核页表中了.
// initialize the proc table at boot time.
void
procinit(void)
{
  struct proc *p;
  
  initlock(&pid_lock, "nextpid");
  for(p = proc; p < &proc[NPROC]; p++) {
      initlock(&p->lock, "proc");
// lab3-2
//      // 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;
  }
  kvminithart();
}
  • 将上述注释的构建内核栈映射的代码可以直接置于 allocproc() 中来对进程自身内核栈进行映射. 值得一提的是, 由于每个进程有自己的内核页表, 因此不会像全局内核页表一样会有所有进程的内核栈, 因此映射时可以直接固定地址空间. 最终 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;
  }

  // process's kernel page table - lab3-2
  p->kpagetable = proc_kpagetable(p);
  if (p->kpagetable == 0) {
    freeproc(p);
    release(&p->lock);
    return 0;
  }
  // Allocate a page for the process's kernel stack. - lab3-2
  char *pa = kalloc();    // 分配页面
  if(pa == 0) {
    panic("kalloc");
  }
  uint64 va = KSTACK(0);
  // 进行虚拟地址和物理地址的映射
  uvmmap(p->kpagetable,va, (uint64)pa,PGSIZE,PTE_R | PTE_W);
  p->kstack = va;

  // 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;
}
  • 注: 这里有一个很需要注意的地方, 可以看到 p->context.sp = p->kstack + PGSIZE; 这一行代码设置了内核栈顶指针, 因此在这之前必须保证内核栈已经分配且映射完成. 否则指向就会出错, 运行后 xv6 会卡住.
    在这里插入图片描述
4. 修改进程调度函数加载内核页表到 SATP 寄存器
  • kernel/proc.cscheduler() 函数会在 kernel/main.cmain() 的最后调用, 其中有一个死循环, 会一直遍历线程数组 proc[NPROC]选取一个可运行进程进行运行. 选取该进程后会设置进程的状态并切换 CPU 的上下文.
  • 由于原本是使用的全局的内核页表(在 kernel/main.cmain() 函数中 调用 kvminithart()设置), 因此当前运行进程时需要同时切换到进程自己的内核页表.
  • 代码直接参考 kvminithart(), 其中 w_satp() 函数用于设置最高级页目录地址的寄存器 SATP, sfence_vam() 用于清空当前 TLB.
  • 同时, 根据要求, 当没有进程运行时使用全局的内核页表. 根据注释, 在 swtch() 之后, 进程已经运行结束, 此时调用 kvminithart() 切换会全局内核页表. 注意, 此处开始认为是当 found==0 时才切换回全局内核页表, 但这样测试的最后一个样例不能通过.
// 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;
        // load the process's kernel page table - lab3-2
        w_satp(MAKE_SATP(p->kpagetable));
        // flush the TLB - lab3-2
        sfence_vma();
        swtch(&c->context, &p->context);

        // Process is done running for now.
        // use kernel_pagetable when no process is running - lab3-2
//        kvminithart();
        // It should have changed its p->state before coming back.
        c->proc = 0;

        found = 1;
      }
      release(&p->lock);
    }
    if(found == 0) {
        kvminithart();
    }
#if !defined (LAB_FS)
    if(found == 0) {
      intr_on();
      asm volatile("wfi");
    }
#else
    ;
#endif
  }
}
5. 释放内核页表和内核栈
  • 线程释放时会调用 kernel/proc.cfreeproc() 函数, 其中会将结构体 struct proc 中的成员变量清零.
  • 此处需要考虑将内核页表包括内核栈的映射进行清空. 按照要求, 内核页表清除映射但不清除物理内存, 而内核栈由于是进程独有, 因此可以连同清除物理内存.
  • 对于内核栈, 由于其是分配在线程的内核页表中的, 因此只需要考虑清除映射以及释放物理内存, 可以直接使用 uvmunmap(), 该函数的第四个参数 do_free 设置为正值, 会将其物理内存同时释放.
  • 对于整个内核页表, 可以参考 proc_freepagetable() 函数进行映射的清除和页表结构的释放. proc_freepagetable() 中和kvminit() 中构建失败的情况是一致的, 通过 uvmunmap()uvmfree() 来分别取消先前的页表映射和释放页表三级结构. 此处可以直接借鉴. 与之前 proc_kpagetable() 不同, 由于此时是对整个内核页表清除映射, 因此可以将内核的代码段(长度 etext-KERNBASE)和数据段、FreeMemory(长度 PHYSTOP-etext)一起释放, 这样其就满足 PGSIZE 整数倍的要求, 便可以使用 uvmunmap() 函数清除映射, 将页面对应的最低级页目录中的 PTE 清零; 然后 uvmfree()会调用 freewalk() 函数将三级页目录结构进行内存释放. 需要说明的是 uvmunmap() 的第 4 个参数 do_free 表征是否释放其物理内存, 以及 uvmfree() 的第 2 个参数表示释放物理内存的大小, 由于需要保留内核地址空间的物理内存的内容, 因此均置零.
// free kernel page table without freeing physical memory - lab3-2
void proc_freekpagetable(pagetable_t kpagetable) {
    uvmunmap(kpagetable, UART0, 1, 0);
    uvmunmap(kpagetable, VIRTIO0, 1, 0);
    uvmunmap(kpagetable, CLINT, 0x10000 / PGSIZE, 0);
    uvmunmap(kpagetable, PLIC, 0x400000 / PGSIZE, 0);
    uvmunmap(kpagetable, KERNBASE, (PHYSTOP - KERNBASE) / PGSIZE, 0);
    uvmunmap(kpagetable, TRAMPOLINE, 1, 0);
    uvmfree(kpagetable, 0);
}
  • 除了上述方法之外, 可以参考 freewalk() 函数之间进行三级页表结构的清除以及页目录内存释放. 与 freewalk() 不同的是, freewalk() 执行前需要通过 uvmunmap() 将最低级页目录的映射清除, 即最低级页目录的 PTE 均为 0, 负责清除前两级页目录的映射结构和三级页目录的物理内存. 而下述代码则是同时将最低级页目录的 PTE 清零.
// free kernel page table without freeing physical memory - lab3-2
void proc_freekpagetable(pagetable_t kpagetable) {
    for(int i = 0; i < 512; i++){
        pte_t pte = kpagetable[i];
        if((pte & PTE_V)){
            kpagetable[i] = 0;    // 对于有效的PTE都清零
            // 递归清除
            if ((pte & (PTE_R|PTE_W|PTE_X)) == 0) {
                uint64 child = PTE2PA(pte);
                proc_freekpagetable((pagetable_t)child);
            }
        }
    }
    kfree((void*)kpagetable);
}
  • 以上两种 proc_kfreepagetable() 均可通过所有测试.
  • freeproc() 代码如下, 还需注意的一点是, 内核栈 p->stack 需要在内核页表 p->kpagetable 之前清除.
// 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;
  // free kernel stack - lab3-2
  if(p->kstack) {
    uvmunmap(p->kpagetable, p->kstack, 1, 1);
  }
  p->kstack = 0;
  // free kernel page table without freeing physical memory - lab3-2
  if(p->kpagetable){
    proc_freekpagetable(p->kpagetable);
  }
  p->kpagetable=0;
  p->state = UNUSED;
}
6. 修改 kvmpa() 函数
  • kvmpa() 函数用于将内核虚拟地址转换为物理地址, 其中调用 walk() 函数时使用了全局的内核页表. 此时需要换位当前进程的内核页表. 修改方法有两种, 一种是直接修改 kvmpa() 内部, 将 walk() 的第一个参数改为 myproc()->kpagetable; 第二种则是将 kvmpa() 的参数增加一个 pagetable_t, 在 kernel/virtio_disk.cvirtio_disk_rw() 调用时传入 myproc()->kpagetable. 两种方法效果是一样的, 此处选择了第二种, 主要考虑到未来扩展时可能会使用全局内核页表的情况, 则第一种不能适用.
    在这里插入图片描述
    在这里插入图片描述
  • 注: 该改动在实验指导中并没有提及, 若不进行改动会出现 panic: kvmpa 或者 virtio_disk_intr status 的错误, 使 xv6 卡住.

测试

make qemu 启动 xv6 后, 执行 usertests 进行测试的结果:
在这里插入图片描述
在这里插入图片描述

补充

在 Lec 7 Q&A labs 中, 提供了解决该部分实验的两种做法: 一种是"复制(copy)"方法, 另一种是"共享(share)"方法.
上述代码实现的即为"复制"方法, 每个进程是创建了一个完整的内核页表的副本.
而"共享"方法更为巧妙, 这也是 Lec 7 中上课老师所使用的方法. 根据内核页表的组成, 我们可以发现, 在 CLINT 及其之上, 每个进程的内核页表与全局内核页表是一致的(此处考虑进程的内核栈的位置与其在全局内核页表中的位置一致). 因此, 可以不完成的创建内核页表的副本, 对于这些相同的部分完全可以让进程内核页表直接共享全局内核页表的内容, 而只需要创建低地址空间用来映射用户程序的部分(这是下一部分实验). 而对于共享部分, 只需要将全局内核页表的最高级页目录拷贝到进程内核页表的最高级页目录即可, 低两级的页目录就可以直接使用全局内核页表的了. 这样可以减少低两级的页目录的分配和设置, 大幅度简化实验.
这里需要注意的是, 由于最高级页目录 1 个 PTE 对应 0x40000000B 大小的地址空间, 因此低地址空间中 UART0, VIRTIO0, CLINT 以及 PLIC 都是属于一个最高级 PTE 的, 因此该 PTE 对应的下面低级的页目录以及虚拟空间需要单独映射.

pagetable_t proc_kpagetable(struct proc *p) {
    int i;
    // 创建空页表
    pagetable_t kpagetable = uvmcreate();
    if(kpagetable == 0){
        return 0;
    }
    // 0x40000000之上部分与全局内核页表共享
    for(i = 1; i < 512; ++i) {  
        kpagetable[i] = kernel_pagetable[i];
    }
    // 第一个PTE对应的虚拟空间单独映射
    uvmmap(kpagetable, UART0, UART0, PGSIZE, PTE_R | PTE_W);
    uvmmap(kpagetable, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
    uvmmap(kpagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
    uvmmap(kpagetable, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
    return kpagetable;
}

对于释放进程页表也是一样的, 由于只使用了一个最高级 PTE, 其余均共享的全局内核页表. 因此释放的时候只需要释放第 0 个最高级 PTE 下对应的低级页目录即可(虚拟空间对应的物理内存不释放).

void proc_freekpagetable(pagetable_t kpagetable) {
    pte_t pte = kpagetable[0];    // 第1个PTE
    pagetable_t level1 = (pagetable_t) PTE2PA(pte);    // 第1个PTE对应的中间级页目录
    // 遍历中间级页目录
    for(int i = 0; i < 512; i++){
        pte_t pte = level1[i];    // 中间级 PTE
        if((pte & PTE_V)){
            uint64 level2 = PTE2PA(pte);
            kfree((void*)level2);    // 释放最低级页目录
            level1[i] = 0;
        }
    }
    kfree((void*)level1);    // 释放中间级页目录
    kfree((void*)kpagetable);    // 释放最高级页目录
}

需要额外说明的是, 在上述 #步骤3 中, 注释掉了内核页表中为进程分配的内核栈, 而在这种方式下要保留全局页表中内核栈的分配, 因为进程内核页表中的内核栈直接映射到了全局内核页表中该进程的内核栈.

Simplify copyin/copyinstr (hard)

要点

  • 修改 xv6 以防止进程地址超过内核的 PLIC 地址
  • 使用 copyin_new()copyinstr_new
  • 在进程的内核页表中添加用户地址的映射.
  • 在内核对用户页表映射进行改动时要同时要调整进程的内核页表映射.
  • 注意 PTE_U 用户权限的页表不在内核态访问

思路

通过添加用户地址空间的映射到进程的内核页表, 这样在内核系统调用 copyin()copyinstr() 就不用使用 walk() 函数让操作系统将虚拟地址换为物理地址进行字符拷贝(因为全局内核页表不知道用户地址空间的映射情况); 而是直接通过 MMU 完成虚拟地址到物理地址的转换. 这需要保证用户页表 p->pagetable 变动的同时修改 p->kpagetable, 根据指导书主要修改 fork(), exec(), sbrk() 三个函数.

步骤

1. 编写用户页表拷贝到内核页表的函数 u2kvmcopy()
  • 其可以参考 kernel/vm.c 中的 uvmcopy() 函数, 该函数是在 fork() 时用于将父进程用户页表拷贝到子进程.
  • 此处的 uvmcopy() 在拷贝时并没有使用写时复制, 而是直接分配相应的页面(物理内存)并复制字节. 而将用户页表拷贝到内核页表时, 物理地址空间实际上是不变的, 只是多了一次映射, 因此 u2kvmcopy() 中就没有使用 kalloc() 分配页面, 而是直接复用的 walk() 返回的物理地址.
  • 此外考虑到后面 growproc() 函数的需要, u2kvmcopy()可以设置复制的起始位置(uvmcopy() 直接为 0). 此处对于起始位置需要使用 PGROUND() 向上取整, 因为其向下取整的页面之前已经被映射过了.
  • 另一方面, 指导书中提到, 带有 PTE_U 标志的 PTE 不能被内核访问, 因此需要在拷贝用户页表时, 将 PTE 中的 PTE_U 标志清除掉. 这一点比较难以想到.
  • 在这里有一点需要特别说明, 即调用的 mappages() 函数, 该函数会在发现当前 PTE 重映射时引发 panic. 根据指导书, 内核页表拷贝的用户页表的地址空间不应超过 PLIC 部分, 但是根据实验的第二部分和课程视频中可以看到, PLIC 的低地址处是有一个 CLINT 部分的, 因此在页表复制是可能会引发重映射, 但在最新的 xv6 指导书中会发现内核虚拟地址 PLIC 下面是没有 CLINT部分的. 解决方案有两种, 一个是网上普遍采用的方案, 即重写一个类 mappages() 函数, 注释掉中间的重映射代码 if(*pte & PTE_V) panic("remap");; 另一个方案是将 Lab3-2 中 proc_kpagetable() 函数中为 CLINT 部分创建映射的代码注释掉, 因为其存在重映射的可能, 也就从另一方面说明了该部分应该不会被实际映射. 经过测试两种方法都可通过测试. 这里笔者采用的是第二种方法.
// copy the user page table into its kernel page table from 'begin' to 'end'  - lab3-3
int u2kvmcopy(pagetable_t upagetable, pagetable_t kpagetable, uint64 begin, uint64 end) {
    pte_t *pte;
    uint64 pa, i;
    uint flags;
    uint64 begin_page = PGROUNDUP(begin);    // 向上取整
    for(i = begin_page; i < end; i += PGSIZE){
        if((pte = walk(upagetable, i, 0)) == 0)
            panic("uvmcopy2kvm: pte should exist");
        if((*pte & PTE_V) == 0)
            panic("uvmcopy2kvm: page not present");
        pa = PTE2PA(*pte);
        flags = PTE_FLAGS(*pte) & (~PTE_U); // clear PTE_U flag
        // map to the physical memory same as user's pa instead of kalloc()
        if(mappages(kpagetable, i, PGSIZE, pa, flags) != 0){
            goto err;
        }
    }
    return 0;

err:
    uvmunmap(kpagetable, begin_page, (i- begin_page) / PGSIZE, 0);
    return -1;
}
2. 修改 fork() 函数
  • kernel/proc.c 中的 fork() 函数用于创建子进程, 其中在用户页表从父进程复制到子进程之后, 同样要将子进程的用户页表拷贝到子进程的内核页表, 使用 u2kvmcopy().
  • 需要注意的是, 这里不能使用 uvmcopy() 拷贝父进程的内核页表到子进程, 因为 uvmcopy() 会分配新的页面, 而非指向子进程的用户空间.
// Create a new process, copying the parent.
// Sets up child kernel stack to return as if from fork() system call.
int
fork(void)
{
  // ...
  // Copy user memory from parent to child.
  if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0){
    freeproc(np);
    release(&np->lock);
    return -1;
  }
  np->sz = p->sz;
  // copy user page table to kernel page table - lab3-3
  if(u2kvmcopy(np->pagetable, np->kpagetable, 0, np->sz) < 0) {
    freeproc(np);
    release(&np->lock);
    return -1;
  }

  np->parent = p;
  // ...
}
3. 修改 exec() 函数
  • kernel/exec.c 中的 exec() 函数用于替换进程镜像, 在替换之后会将原用户页表释放替换为新的用户页表, 因此需要同样更新进程的内核页表.
  • 由于内核页表中虚拟地址实际上指向的也是用户空间的物理地址, 因此不需要像用户页表一样连同物理空间一并释放, 而是使用 uvmunmap() 清除映射, 然后使用 u2kvmcopy() 进行页表的复制.
int
exec(char *path, char **argv)
{
  // ...  
  // 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);

  // unmap old kernel page table, and copy the new one - lab3-3
  uvmunmap(p->kpagetable, 0, PGROUNDUP(oldsz)/PGSIZE, 0);
  if(u2kvmcopy(p->pagetable, p->kpagetable, 0, p->sz) < 0){
      goto bad;
  }

  // print page table - lab3-1
  if (p->pid == 1) {
    vmprint(p->pagetable);
  }
  // ...
}
4. 修改 growproc() 函数
  • sbrk() 函数即系统调用 sys_brk() 函数, 最终会调用 kernel/proc.c 中的 growproc() 函数, 用来增长或减少虚拟内存空间. 根据指导书要求, 要保证用户空间的大小在 PLIC 部分之下, 因此, 在 n>0 时要判断 sz + n > PLIC 的情况, 满足则返回失败. 此处不取等, 是因为 sz+n 是个空间大小, 在与 PLIC 相等时恰好未使用 PLIC 地址.
  • 对于 n>0 时, 则在 uvmalloc() 分配新的内存后, 将新增的用户地址空间使用 u2kvmcopy() 复制到内核页表.
  • 对于 n<0 时, 则需要在 uvmdealloc() 之后连通将内核页表的这部分空间解除映射. 需要注意的是, uvmdealloc() 底层调用的 uvmunmap() 函数是连通物理地址一起释放了, 因此在这了不能调用该函数释放内核页表, 会导致物理地址重复释放, 必须直接调用 uvmunmap() 并将第 4 个参数置零.
  • 另外还需要注意的一点是, sz 变量在 uvmalloc()uvmdealloc() 调用后会被更新, 在调整内核页表时需要注意.
// Grow or shrink user memory by n bytes.
// Return 0 on success, -1 on failure.
int
growproc(int n)
{
  uint sz;
  struct proc *p = myproc();

  sz = p->sz;
  if(n > 0){
    // prevent process from growing to PLIC address - lab3-3
    if(sz + n > PLIC){
      return -1;
    }
    if((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
      return -1;
    }
    // copy the increase user page table to kernel page table - lab3-3
    if(u2kvmcopy(p->pagetable, p->kpagetable, p->sz, sz) < 0){
      return -1;
    }
  } else if(n < 0){
    sz = uvmdealloc(p->pagetable, sz, sz + n);
    // free process's kernel page table without free physical memory - lab3-3
    if (PGROUNDUP(sz) < PGROUNDUP(p->sz)) {
      uvmunmap(p->kpagetable, PGROUNDUP(sz),
               (PGROUNDUP(p->sz) - PGROUNDUP(sz)) / PGSIZE, 0);
    }
  }
  p->sz = sz;
  return 0;
}
5. 修改 userinit() 函数
  • kernel/proc.c 中的 userinit() 函数用于初始化 xv6 启动时第一个用户进程, 该进程的加载是独立的, 因此也需要将其用户页表拷贝到内核页表.
// Set up first user process.
void
userinit(void)
{
  struct proc *p;

  p = allocproc();
  initproc = p;
  
  // allocate one user page and copy init's instructions
  // and data into it.
  uvminit(p->pagetable, initcode, sizeof(initcode));
  p->sz = PGSIZE;
  // init kernel pagetable - lab3-3
  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
  p->trapframe->sp = PGSIZE;  // user stack pointer

  safestrcpy(p->name, "initcode", sizeof(p->name));
  p->cwd = namei("/");

  p->state = RUNNABLE;

  release(&p->lock);
}
6. 替换 copyin()copyinstr()
  • 直接将 copyin()copyinstr() 两个函数的原有代码注释掉, 并分别调用已提供好的 kernel/vmcopyin.c 中的 copyin_new()copyinstr_new() 函数. 可以看到这两个函数相比原来的函数, 其中未使用 walk() 获取物理地址, 而是直接对虚拟地址进行拷贝操作.
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);
}

遇到问题

  • xv6 卡住, 一直不能进行正常显示, 如下图:
    在这里插入图片描述
    解决: 这是由于 u2kvmcopy() 中使用了 kalloc() 新分配了内存而非映射到用户空间.
  • 执行命令没有输出, 如下图所示:
    在这里插入图片描述
    解决: 这是由于 fork() 创建进程时调用 u2kvmcopy() 复制的是错误的父进程用户页表, 应该复制子进程页表.
  • 正常执行命令通过, 执行 usertests 测试引发重映射 panic, 如下图所示:
    在这里插入图片描述
    解决: 这是由于 CLINT 部分导致的重映射, 具体解决方案有两种, 上文已经阐述.

测试

  • xv6 中执行 usertests 测试:
    在这里插入图片描述
  • make grade 测试:
    在这里插入图片描述

思考题

  • Q: Explain why the third test srcva + len < srcva is necessary in copyin_new(): give values for srcva and len for which the first two test fail (i.e., they will not cause to return -1) but for which the third one is true (resulting in returning -1).
    A: 三个 return -1 的条件分别为 srcva >= p->sz, srcva+len >= p->szsrcva+len < srcva. 很显然, 此处第三个条件主要是进行溢出检测, 防止无符号整数上溢. 由于 srcvalen 均为 uint64 类型的变量, 当 srcva 小于 p->sz 但是 len 为一个极大的数时, 如 0xffff...ffff(即对应 -1), 由于无符号整数溢出便可以满足 srcva+len < p->sz 这一条件, 但实际上复制了大量内存. 但通过 srcva+len < srcva 这一判断条件能够检测出溢出.

真实情况

Linux 系统中的进程虚拟地址的实现与此处实现的内核页表类似, 是使用同一个进程页表来同时映射用户空间和内核空间, 而没有再使用一个用户页表. 32 位操作系统中用户空间为 3GB, 内核空间为 1GB, 且内核空间不同进程映射的是相同的共享空间, 用于用户态陷入内核调用系统调用等.

评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值