本文为 MIT 6.S081 2020 操作系统 实验三解析。
MIT 6.S081课程前置基础参考: 基于RISC-V搭建操作系统系列
在本实验中,您将探索页表并对其进行修改,以简化将数据从用户空间复制到内核空间的函数。
开始编码之前,请阅读xv6手册的第3章和相关文件:
要启动实验,请切换到pgtbl分支:
$ git fetch
$ git checkout pgtbl
$ make clean
为了帮助您了解RISC-V页表,也许为了帮助将来的调试,您的第一个任务是编写一个打印页表内容的函数。
YOUR JOB
vmprint()
的函数。它应当接收一个pagetable_t
作为参数,并以下面描述的格式打印该页表。exec.c
中的return argc
之前插入if(p->pid==1) vmprint(p->pagetable)
,以打印第一个进程的页表。如果你通过了pte printout
测试的make grade
,你将获得此作业的满分。现在,当您启动xv6时,它应该像这样打印输出来描述第一个进程刚刚完成exec()
--> init
时的页表:
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
的参数。..
”的缩进表明它在树中的深度。您的代码可能会发出与上面显示的不同的物理地址。条目数和虚拟地址应相同。
一些提示:
vmprint()
放在kernel/vm.c中freewalk
可能会对你有所启发vmprint
的原型定义在kernel/defs.h中,这样你就可以在exec.c
中调用它了printf
调用中使用%p
来打印像上面示例中的完成的64比特的十六进制PTE和地址QUESTION
vmprint
的输出。page 0包含什么?page 2中是什么?在用户模式下运行时,进程是否可以读取/写入page 1映射的内存?本实验主要是实现一个打印页表内容的函数, 首先根据提示在exec.c
中的return argc
之前插入if(p->pid==1) vmprint(p->pagetable)
然后看一下kernel/vm.c里面的freewalk
方法,主要的代码如下:
// Recursively free page-table pages.
// All leaf mappings must already have been removed.
void
freewalk(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];
if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){
// this PTE points to a lower-level page table.
uint64 child = PTE2PA(pte);
freewalk((pagetable_t)child);
pagetable[i] = 0;
} else if(pte & PTE_V){
panic("freewalk: leaf");
}
}
kfree((void*)pagetable);
}
它首先会遍历整个页表。当遇到有效的页表项并且不在最后一层的时候,它会递归调用。PTE_V
是用来判断页表项是否有效,而(pte & (PTE_R|PTE_W|PTE_X)) == 0
则是用来判断是否不在最后一层。因为最后一层页表中页表项中W位,R位,X位起码有一位会被设置为1。注释里面说所有最后一层的页表项已经被释放了,所以遇到不符合的情况就panic("freewalk: leaf")
。
那么,根据freewalk
,我们可以写下递归函数。对于每一个有效的页表项都打印其和其子项的内容。如果不是最后一层的页表就继续递归。通过level
来控制前缀..
的数量。
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];
// PTE_V is a flag for whether the page table is valid
if(pte & PTE_V){
// 按照层级输出.. .. ..
for (int j = 0; j < level; j++){
if (j) printf(" ");
printf("..");
}
// 得到pte指向的物理页起始地址
uint64 child = PTE2PA(pte);
// 输出当前页表项的内容
printf("%d: pte %p pa %p\n", i, pte, child);
// 是否是叶子层页表项--如果不是的话,就继续递归打印
if((pte & (PTE_R|PTE_W|PTE_X)) == 0){
// this PTE points to a lower-level page table.
_vmprint((pagetable_t)child, level + 1);
}
}
}
}
void vmprint(pagetable_t pagetable){
//打印根页表起始地址
printf("page table %p\n", pagetable);
_vmprint(pagetable, 1);
}
// 添加到vm.c下
void vmprint(pagetable_t);
Xv6有一个单独的用于在内核中执行程序时的内核页表。内核页表直接映射(恒等映射)到物理地址,也就是说内核虚拟地址x
映射到物理地址仍然是x
。Xv6还为每个进程的用户地址空间提供了一个单独的页表,只包含该进程用户内存的映射,从虚拟地址0开始。因为内核页表不包含这些映射,所以用户地址在内核中无效。因此,当内核需要使用在系统调用中传递的用户指针(例如,传递给write()
的缓冲区指针)时,内核必须首先将指针转换为物理地址。本节和下一节的目标是允许内核直接解引用用户指针。
YOUR JOB
struct proc
来为每一个进程维护一个内核页表,修改调度程序使得切换进程时也切换内核页表。usertests
程序正确运行了,那么你就通过了这个实验。阅读本作业开头提到的章节和代码;了解虚拟内存代码的工作原理后,正确修改虚拟内存代码将更容易。页表设置中的错误可能会由于缺少映射而导致陷阱,可能会导致加载和存储影响到意料之外的物理页存页面,并且可能会导致执行来自错误内存页的指令。
提示:
struct proc
中为进程的内核页表增加一个字段kvminit
,这个版本中应当创造一个新的页表而不是修改kernel_pagetable
。你将会考虑在allocproc
中调用这个函数procinit
中设置。你将要把这个功能部分或全部的迁移到allocproc
中scheduler()
来加载进程的内核页表到核心的satp
寄存器(参阅kvminithart
来获取启发)。不要忘记在调用完w_satp()
后调用sfence_vma()
scheduler()
应当使用kernel_pagetable
freeproc
中释放一个进程的内核页表vmprint
能派上用场sepc=0x00000000XXXXXXXX
的错误提示。你可以在kernel/kernel.asm通过查询XXXXXXXX
来定位错误。注意一点: 实验二的要求是让每个进程在内核中执行时,使用自己的页表副本
本实验主要是让每个进程都有自己的内核页表,这样在内核中执行时使用它自己的内核页表的副本。
(1). 首先给kernel/proc.h里面的struct proc
加上内核页表的字段。
uint64 kstack; // Virtual address of kernel stack
uint64 sz; // Size of process memory (bytes)
pagetable_t pagetable; // User page table
pagetable_t kernelpt; // 进程的内核页表
struct trapframe *trapframe; // data page for trampoline.S
(2). 在vm.c
中添加新的方法proc_kpt_init
,该方法用于在allocproc
中初始化进程的内核页表。这个函数还需要一个辅助函数uvmmap
,该函数和kvmmap
方法几乎一致,不同的是kvmmap
是对Xv6的内核页表进行映射,而uvmmap
将用于进程的内核页表进行映射。
// Just follow the kvmmap on vm.c
void
uvmmap(pagetable_t pagetable, uint64 va, uint64 pa, uint64 sz, int perm)
{
//与kvmmap区别就在于 mappages传入的pagetable不是固定的kernel_pagetable
if(mappages(pagetable, va, sz, pa, perm) != 0)
panic("uvmmap");
}
// Create a kernel page table for the process
//整个函数代码可以参考kvminit
pagetable_t
proc_kpt_init(){
//为当前进程创建一个空的页表
pagetable_t kernelpt = uvmcreate();
if (kernelpt == 0) return 0;
uvmmap(kernelpt, UART0, UART0, PGSIZE, PTE_R | PTE_W);
uvmmap(kernelpt, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
uvmmap(kernelpt, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
uvmmap(kernelpt, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
uvmmap(kernelpt, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
uvmmap(kernelpt, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);
uvmmap(kernelpt, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
return kernelpt;
}
添加proc_kpt_init和uvmmap函数定义到kernel/defs.h里面。
// 添加到vm.c下
void uvmmap(pagetable_t pagetable, uint64 va, uint64 pa, uint64 sz, int perm);
pagetable_t proc_kpt_init();
然后在kernel/proc.c里面的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;
//遍历进程数组,找到一个UNUSED槽
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.
// 分配trapframe页面
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;
}
//----------我们补充的代码-----------------
//为当前进程分配一个空的内核态根页表
p->kernelpt = proc_kpt_init(p);
if(p->pagetable == 0){
freeproc(p);
release(&p->lock);
return 0;
}
//-----------补充结束----------------------
// 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;
}
(3). 根据提示,为了确保每一个进程的内核页表都关于该进程的内核栈有一个映射。我们需要将procinit
方法中相关的代码迁移到allocproc
方法中。很明显就是下面这段代码,将其剪切到上述内核页表初始化的代码后。
//----------我们补充的代码-----------------
// 为当前进程分配一个空的内核态根页表
p->kernelpt = proc_kpt_init(p);
if (p->pagetable == 0)
{
freeproc(p);
release(&p->lock);
return 0;
}
// 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));
// 当前进程的内核栈映射到当前进程的自己内核页表中
uvmmap(p->kernelpt, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
p->kstack = va;
//-----------补充结束----------------------
在procinit方法中,会将每个进程的内核栈映射到全局内核页表中,这部分的逻辑移除,每个进程的内核栈由自身的内核页表进行维护:
(4). 我们需要修改scheduler()
来加载进程的内核页表到SATP寄存器。提示里面请求阅读kvminithart()
。
// Switch h/w page table register to the kernel's page table,
// and enable paging.
void
kvminithart()
{
w_satp(MAKE_SATP(kernel_pagetable));
sfence_vma();
}
kvminithart
是用于原先的内核页表,我们将进程的内核页表传进去就可以。在vm.c里面添加一个新方法proc_inithart
。
// Store kernel page table to SATP register
void
proc_inithart(pagetable_t kpt){
w_satp(MAKE_SATP(kpt));
sfence_vma();
}
添加proc_inithart函数定义到kernel/defs.h里面。
void proc_inithart(pagetable_t kpt);
然后在scheduler()
内调用即可,但在结束的时候,需要切换回原先的kernel_pagetable
。直接调用调用上面的kvminithart()
就能把Xv6的内核页表加载回去。
...
p->state = RUNNING;
c->proc = p;
// Store the kernal page table into the SATP
proc_inithart(p->kernelpt);
swtch(&c->context, &p->context);
// Come back to the global kernel page table
kvminithart();
...
(5). 在freeproc
中释放一个进程的内核页表。首先释放页表内的内核栈,调用uvmunmap
可以解除映射,最后的一个参数(do_free
)为一的时候,会释放实际内存。
// free the kernel stack in the RAM
uvmunmap(p->kernelpt, p->kstack, 1, 1);
p->kstack = 0;
然后释放进程的内核页表,先在kernel/proc.c里面添加一个方法proc_freekernelpt
。如下,历遍整个内核页表,然后将所有有效的页表项清空为零。如果这个页表项不在最后一层的页表上,需要继续进行递归。
void
proc_freekernelpt(pagetable_t kernelpt)
{
// similar to the freewalk method
// there are 2^9 = 512 PTEs in a page table.
for(int i = 0; i < 512; i++){
pte_t pte = kernelpt[i];
if(pte & PTE_V){
kernelpt[i] = 0;
if ((pte & (PTE_R|PTE_W|PTE_X)) == 0){
uint64 child = PTE2PA(pte);
proc_freekernelpt((pagetable_t)child);
}
}
}
kfree((void*)kernelpt);
}
注意: 当进程销毁时,我们只会释放用户态页表映射的物理内存,而内核态页表映射的物理内存不用释放,因为内核态映射的物理内存是和内核进程,以及其他所有进程共享的。
(6). 修改vm.c
中的kvmpa
,将原先的kernel_pagetable
改成myproc()->kernelpt
,使用进程的内核页表。
//注意添加下面这两个头文件
#include "spinlock.h"
#include "proc.h"
// translate a kernel virtual address to
// a physical address. only needed for
// addresses on the stack.
// assumes va is page aligned.
uint64
kvmpa(uint64 va)
{
uint64 off = va % PGSIZE;
pte_t *pte;
uint64 pa;
pte = walk(myproc()->kernelpt, va, 0); // 修改这里
if(pte == 0)
panic("kvmpa");
if((*pte & PTE_V) == 0)
panic("kvmpa");
pa = PTE2PA(*pte);
return pa+off;
}
之所以修改kvmpa函数。是因为kvmpa用来将当前进程内核栈虚拟地址翻译为物理地址,由于我们已经将进程内核栈映射交给了各个进程的内核页表管理,所以这里我们需要将原本的全局页表替换为各个进程自己的内核页表:
(8). 测试一下我们的代码,先跑起qemu
,然后跑一下usertests
。这部分耗时会比较长。
$ make qemu
> usertests
内核的copyin
函数读取用户指针指向的内存。它通过将用户指针转换为内核可以直接解引用的物理地址来实现这一点。这个转换是通过在软件中遍历进程页表来执行的。在本部分的实验中,您的工作是将用户空间的映射添加到每个进程的内核页表(上一节中创建),以允许copyin
(和相关的字符串函数copyinstr
)直接解引用用户指针。
YOUR JOB
copyin
的主题内容替换为对copyin_new
的调用(在kernel/vmcopyin.c中定义);copyinstr
和copyinstr_new
执行相同的操作。copyin_new
和copyinstr_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_U
的页面)Linux使用的技术与您已经实现的技术类似。直到几年前,许多内核在用户和内核空间中都为当前进程使用相同的自身进程页表,并为用户和内核地址进行映射以避免在用户和内核空间之间切换时必须切换页表。然而,这种设置允许边信道攻击,如Meltdown和Spectre。
QUESTION
copyin_new()
中需要第三个测试srcva + len < srcva
:给出srcva
和len
值的例子,这样的值将使前两个测试为假(即它们不会导致返回-1),但是第三个测试为真 (导致返回-1)。本实验是实现将用户空间的映射添加到每个进程的内核页表,将进程的用户态页表复制一份到进程的内核态页表就好。
首先添加复制函数。需要注意的是,在内核模式下,无法访问设置了PTE_U的页面,所以我们要将其移除。
//vm.c
void
// 复制进程想用户态页表到内核态页表中
u2kvmcopy(pagetable_t pagetable, pagetable_t kernelpt, uint64 oldsz, uint64 newsz){
pte_t *pte_from, *pte_to;
//向上对齐
oldsz = PGROUNDUP(oldsz);
for (uint64 i = oldsz; i < newsz; i += PGSIZE){
//在用户态页表中定位虚地址关联的PTE
if((pte_from = walk(pagetable, i, 0)) == 0)
panic("u2kvmcopy: src pte does not exist");
//在内核态页表中为当前虚地址创建好对应的映射关系---最后一个参数传入的是1
//表示当pte映射关系没建立时,进行初始化,而不是直接返回0
if((pte_to = walk(kernelpt, i, 1)) == 0)
panic("u2kvmcopy: pte walk failed");
//获取用户态页表中虚地址对应物理地址
uint64 pa = PTE2PA(*pte_from);
//获取用户态PTE的权限,设置U位为0
uint flags = (PTE_FLAGS(*pte_from)) & (~PTE_U);
//将物理地址转换为PTE,同时设置权限信息
*pte_to = PA2PTE(pa) | flags;
}
}
将u2kvmcopy函数定义添加到defs.h头文件中:
void u2kvmcopy(pagetable_t pagetable, pagetable_t kernelpt, uint64 oldsz, uint64 newsz);
然后在内核更改进程用户映射的每一处 (fork()
, exec()
, 和sbrk()
),都复制一份到进程的内核页表。
当我们更改当前进程的用户态页表映射时,我们需要将更改同步到当前进程的内核态页表中
exec()
int
exec(char *path, char **argv){
...
sp = sz;
stackbase = sp - PGSIZE;
// 添加复制逻辑
u2kvmcopy(pagetable, p->kernelpt, 0, sz);
// Push argument strings, prepare rest of stack in ustack.
for(argc = 0; argv[argc]; argc++) {
...
}
fork():
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;
...
// 复制到新进程的内核页表
u2kvmcopy(np->pagetable, np->kernelpt, 0, np->sz);
...
}
sbrk()
, 在kernel/sysproc.c里面找到sys_sbrk(void)
,可以知道只有growproc
是负责将用户内存增加或缩小 n 个字节。以防止用户进程增长到超过PLIC
的地址,我们需要给它加个限制。int
growproc(int n)
{
uint sz;
struct proc *p = myproc();
sz = p->sz;
if(n > 0){
// 加上PLIC限制
if (PGROUNDUP(sz + n) >= PLIC){
return -1;
}
if((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
return -1;
}
// 复制一份到内核页表
u2kvmcopy(p->pagetable, p->kernelpt, sz - n, sz);
} else if(n < 0){
sz = uvmdealloc(p->pagetable, sz, sz + n);
}
p->sz = sz;
return 0;
}
然后替换掉原有的copyin()
和copyinstr()
原有的实现:
// Copy from user to kernel.
// Copy len bytes to dst from virtual address srcva in a given page table.
// Return 0 on success, -1 on error.
int
// 当前进程用户态根页表,目的地址(内核态下的虚拟地址空间),源地址(用户态下的虚拟地址空间),长度
copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len)
{
uint64 n, va0, pa0;
while(len > 0){
// 向下对齐
va0 = PGROUNDDOWN(srcva);
// 通过遍历pagetable将用户态下的虚拟地址转换为物理地址返回
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0)
return -1;
n = PGSIZE - (srcva - va0);
if(n > len)
n = len;
// 从物理地址 pa0 + (srcva - va0) 起始拷贝n个字节到dst
// 对于内核态来说,由于采用的是等价映射,所以物理地址和虚拟地址没有区别
memmove(dst, (void *)(pa0 + (srcva - va0)), n);
// 往前推进,直到把所有数据copy完成
len -= n;
dst += n;
srcva = va0 + PGSIZE;
}
return 0;
}
// Copy a null-terminated string from user to kernel.
// Copy bytes to dst from virtual address srcva in a given page table,
// until a '\0', or max.
// Return 0 on success, -1 on error.
int
// copy带有'\0'结束符号的字符串
copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
uint64 n, va0, pa0;
int got_null = 0;
while(got_null == 0 && max > 0){
va0 = PGROUNDDOWN(srcva);
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0)
return -1;
n = PGSIZE - (srcva - va0);
if(n > max)
n = max;
// 整体实现思路和 copyin 一致
//不同之处在于,由于事先不清楚copy数据的长度,只能一个个字节的copy,边copy边判断是否到达字符串末尾
char *p = (char *) (pa0 + (srcva - va0));
while(n > 0){
if(*p == '\0'){
*dst = '\0';
got_null = 1;
break;
} else {
// 由于内核态下采用的是等价映射,所以才可以直接这样玩
//毕竟dst代表内核态的虚拟地址,而p代表物理地址
*dst = *p;
}
--n;
--max;
p++;
dst++;
}
srcva = va0 + PGSIZE;
}
if(got_null){
return 0;
} else {
return -1;
}
}
替换后新的copyin和copyinstr实现;
//vm.c
// Copy from user to kernel.
// Copy len bytes to dst from virtual address srcva in a given page table.
// Return 0 on success, -1 on error.
int
copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len)
{
return copyin_new(pagetable, dst, srcva, len);
}
// Copy a null-terminated string from user to kernel.
// Copy bytes to dst from virtual address srcva in a given page table,
// until a '\0', or max.
// Return 0 on success, -1 on error.
int
copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
return copyinstr_new(pagetable, dst, srcva, max);
}
copyin_new和copyinstr_new函数实现在vmcopyin.c文件中:
并且添加到 kernel/defs.h
中
// vmcopyin.c
int copyin_new(pagetable_t, char *, uint64, uint64);
int copyinstr_new(pagetable_t, char *, uint64, uint64);
测试:
make qemu
sbrk(1)
为其地址空间增加一个字节。运行该程序并研究调用sbrk
之前和调用sbrk
之后该程序的页表。内核分配了多少空间?新内存的PTE包含什么?exec
的Unix实现包括对shell脚本的特殊处理。如果要执行的文件以文本#!
开头, 那么第一行将被视为解释此文件的程序来运行。例如,如果调用exec
来运行myprog arg1
,而myprog
的第一行是#!/interp
,那么exec
将使用命令行/interp myprog arg1
运行 /interp
。在xv6中实现对该约定的支持。