lab3: pgtbl
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-g1VAtxqH-1638793994235)(/Users/liuzhilei/workspace-git/study-repo/6.S081/lab/pgtbl.assets/image-20211206202845356.png)]
Print a page table(easy)
添加一个打印页表的内核函数,以如如下格式打印出传进的页表,用于后面两个实验调试用:
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
如下图所示,RISC-V 的逻辑地址寻址是采用三级页表的形式,9 bit 一级索引找到二级页表,9 bit 二级索引找到三级页表,9 bit 三级索引找到内存页,最低 12 bit 为页内偏移(即一个页 4096 bytes)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qBSb4aAE-1638793994243)(/Users/liuzhilei/workspace-git/study-repo/6.S081/lab/pgtbl.assets/image-20211204230201300.png)]
理解了页表的结构原理,该task就能够轻松解决;实际上我们只要遍历整个三级页表,打印出有效的页表项((*pte & PTE_V) != 0)
即可,下面给出递归打印页表的实现:
// kernel/exec.c
void iPrintf(int i) {
for (;i > 0;i--) printf(".. ");
printf("..");
}
// 递归打印页表
// depth表示页表的深度,depth=0表示顶级页表
// curPt表示当前页表的基地址
void rPrintf(pagetable_t curPt, int depth) {
if (depth > 2) return;
for (int i = 0; i < 512; i++) {
pte_t* pte = curPt + i;
if ((*pte & PTE_V) == 0) continue; // 跳过无效表项
iPrintf(depth);
printf("%d: pte %p pa %p\n", i, *pte, PTE2PA(*pte));
rPrintf((pde_t*)PTE2PA(*pte), depth + 1);
}
}
void vmprint(pagetable_t pt)
{
printf("page table %p\n", pt);
rPrintf(pt, 0);
}
int exec(char* path, char** argv){
...
vmprint(p->pagetable);
return argc;
...
}
A kernel page table per process (hard)
这个task的目的是为每一个进程创建一个内核页表,与全局的内核页表相比除了只有自己的内核栈之外没什么不同;这样当进程通过trap进入内核态时,我们可以直接使用进程的私有内核页表。至于为什么要这么做,做第三个task的时候就知道了。
为proc结构体增加两个字段kpagetable
、kstackpa
,kpagetable
是必须的而kstackpa
是可选的是为了记录内核栈的物理地址,简化向内核页表添加映射以及释放内核栈物理空间;
// proc.h
struct proc {
...
pagetable_t kernelpgtbl; // kernel page table
char* kstackpa; // physical address of kstack
...
}
我们也不能省略全局内核页表,因为在系统初始化阶段即还没有用户进程阶段,内核仍然需要全局内核页表;我们需要扩展部分内核页表管理的接口,具体的为其添加一个pagetable_t
的参数即可;然后就是在创建进程时为其创建私有的内核页表,并分配内核栈;在销毁进程时释放对应的内核页表,同时释放内核栈。
初始化内核页表
首先要扩展内核页表的初始化函数,使其为每个进程创建并初始化内核页表:
// kernel/vm.c
// create and init a kernel_pagetable
// 添加到def.h
pagetable_t kvminit2() {
pagetable_t pgtbl = (pagetable_t)kalloc();
memset(pgtbl, 0, PGSIZE);
// 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);
return pgtbl;
}
// create a direct-map page table for the kernel.
void
kvminit()
{
kernel_pagetable = kvminit2();
kvmmap(kernel_pagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
}
// add a mapping to a kernel page table.
void
kvmmap(pagetable_t pgtbl, uint64 va, uint64 pa, uint64 sz, int perm)
{
if (mappages(pgtbl, va, sz, pa, perm) != 0)
panic("kvmmap");
}
通过扩展kvminit2()
、kvmmap()
能够为任意进程创建并初始化其内核页表,此时每个内核页表都有相同的映射;kvminit()
仍然需要在main()
中初始化全局页表;
allocproc
在allocproc()
中为其创建私有内核页表,并且为其分配内核栈(因此不用在initproc()
中将内核栈映射到全局页表了):
// kernel/proc.c
static struct proc*
allocproc(void){
...
found:
p->pid = allocpid();
p->kernelpgtbl = kvminit2();
if (p->kernelpgtbl == 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)(0));
p->kstack = va;
p->kstackpa = pa;
if (mappages(p->kernelpgtbl, (uint64)p->kstack, PGSIZE,
(uint64)p->kstackpa, PTE_R | PTE_W) != 0) {
freeproc(p);
release(&p->lock);
return 0;
}
// ...
}
scheduler
这样在进程切换前,我们可以直接加载进程的私有内核页表而不用全局内核页表(修改scheduler()
):
// kernel/proc.c
void
scheduler(void)
{
...
// 切换到进程独立的内核页表
w_satp(MAKE_SATP(p->kernel_pagetable));
sfence_vma(); // 清除快表缓存
// 调度,执行进程
swtch(&c->context, &p->context);
// 切换回全局内核页表
kvminithart();
...
}
在进程退出时我们需要及时切换回全局内核页表,这是因为在下一个task中,进程的内核页表中没有CLINT的映射,如果不切换回全局内核页表,会影响操作系统的资源管理能力。
freeproc
最后还需要在销毁进程时同时销毁其内核页表和释放内核栈:
// 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);
if (p->kernelpgtbl)
proc_kfreepagetable(p->kernelpgtbl);
if (p->kstack)
kfree((void *)p->kstackpa);
p->kstackpa = 0;
p->pagetable = 0;
p->kernelpgtbl = 0;
p->sz = 0;
p->pid = 0;
p->parent = 0;
p->name[0] = 0;
p->chan = 0;
p->killed = 0;
p->xstate = 0;
p->state = UNUSED;
}
// 只销毁页表,不会释放内存空间
void
proc_kfreepagetable(pagetable_t pagetable)
{
for(int i = 0; i < 512; i++){
pte_t pte = pagetable[i];
if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){
uint64 child = PTE2PA(pte);
proc_kfreepagetable((pagetable_t)child);
pagetable[i] = 0;
}
}
kfree((void*)pagetable);
}
freewalk2()
会在遍历页表时销毁各个目录所在的页面,但是不会销毁虚拟页面映射到的物理页面;
最终还有一个要修改:
// kernel/virtio_disk.c
void
virtio_disk_rw(struct buf *b, int write)
{
...
// buf0 is on a kernel stack, which is not direct mapped,
// thus the call to kvmpa().
disk.desc[idx[0]].addr = (uint64) kvmpa((uint64) &buf0);
...
}
观看注释可知,buf0
位于内核栈中,因此需要修改kvmpa()
不能让其直接引用全局内核页表,而是进程的私有内核页表:
#include "spinlock.h"#include "proc.h" // 为了调用myproc(),需要导入proc.h、spinlock.h// kernel/proc.cuint64kvmpa(uint64 va){ uint64 off = va % PGSIZE; pte_t* pte; uint64 pa; pte = walk(myproc()->kernel_pagetable, va, 0); // 需要解析内核栈的物理地址,因此必须使用进程的内核页表 if (pte == 0) panic("kvmpa"); if ((*pte & PTE_V) == 0) panic("kvmpa"); pa = PTE2PA(*pte); return pa + off;}
Simplify copyin/copyinstr (hard)
这个task的目的是让我们将进程的地址空间映射同步到内核页表中,这样使得内核态可以直接对对用户态传进来的指针(逻辑地址)进行解引用(硬件实现的),避免了os查询进程页表的过程(只是软件实现的)。
要实现这样的效果,我们需要在每一处内核对用户页表进行修改的时候,将同样的修改也同步应用在进程的内核页表上,使得两个页表的程序段(0 到 PLIC 段)地址空间的映射同步。如下图所示,假设左图表示进程的内核页表(借用一下书中的图,事实上目前一个进程的内核页表只有当前进程的内核栈)我们要做的是利用进程内核页表[0…PLIC]这段虚拟地址空间映射进程的物理地址,使得内核可以直接访问进程的物理地址空间。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FuOFcynR-1638793994245)(/Users/liuzhilei/workspace-git/study-repo/6.S081/lab/pgtbl.assets/image-20211206183840124.png)]
实验要求中说明了,进程的虚拟地址空间不能超过PLIC;
因此需要在向内核页表同步映射时中加入检查,防止程序虚拟地址空间超过 PLIC,详见kvmcopymappings()
。
copyin、copyist
首先根据hints修改copyin
、copyist
// 声明新函数原型
int copyin_new(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len);
int copyinstr_new(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max);
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);
}
目前内核不需要再查询p->pagetable获取到用户地址空间后才能拷贝数据了;相应的我们也需要将进程虚拟地址空间映射同步到内核页表中。
kvmcopymappings、kvmdealloc
首先实现一些辅助方法,将进程页表的映射添加到内核页表:
// kernel/vm.c
// 注:需要在 defs.h 中添加相应的函数声明
// 将 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: 对齐页边界,防止 remap
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, 0, i / PGSIZE, 0);
return -1;
}
// 与 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;
}
接下来就是在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
// 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); // 同步程序内存映射到进程内核页表中
// ......
}
到这里,两个页表的同步操作就都完成了。