过年耽误了不少时间,再加上看OSTEP,Lab3就一直拖到了现在。
注意:ArchLinux下按照官网的Guide安装的环境里qemu的版本是更新过的,2020版本的实验执行qemu后会导致挂起,前两个实验因为用的2021的环境所以没发现这个问题,但是Lab3的内容不一样导致我修了半天环境才继续做。
Lab3的主要内容是页表,难度相对前两个Lab难了不是一点,理解起来也比较抽象,涉及到内核态页表,用户态页表,还有多级页表的寻址,虚拟地址与物理地址的映射,页表条目之间的转换以及申请新的页表时,栈空间,进程空间还有符号位的各种标记,需要精读xv6这一块的代码才能比较好的理解。
具体的笔记等以后有空再补吧,先把实验细节补充上。
本次实验的前置知识:xv6 Book的Chapter 3,阅读代码kernel/vm.c
,kernel/kalloc.c
,kernel/memlayout.h
。
其实还应该看一下kernel/proc.c
,kernel/exec
,kernel/sysproc.c
,理解页表的创建和调用过程。
Print a page table
第一个任务是打印页表,要求打印页表的pte和pa,同时对多级页表进行分层的显示,具体格式见实验网页。
这是本次实验最简单的任务,同时也能作为后续任务的调试工具。
根据任务提示,freewalk()
会是我们的突破口,仿照该函数我们可以写出递归打印页表的函数
void vmprinter(pagetable_t pagetable, int level) {
for (int i = 0; i < 512; i++) {
pte_t pte = pagetable[i];
if (pte & PTE_V) {
uint64 pa = PTE2PA(pte);
for (int j = 0; j < level; j++) {
if (j) printf(" ");
printf("..");
}
printf("%d: pte %p pa %p\n", i, pte, pa);
if ((pte & (PTE_R | PTE_W | PTE_X)) == 0) {
vmprinter((pagetable_t)pa, level+1);
}
}
}
}
怎么判断是否到达多级页表树的叶节点?看代码可以知道叶节点的页表的RWX位一定不是全为0的,用这个可以判断,freewalk()
也写的很清楚。
然后打印页表即可,并且将函数定义添加到kernel/defs.h
中。
void vmprint(pagetable_t pagetable) {
printf("page table %p\n", pagetable);
vmprinter(pagetable, 1);
}
为了能在一开始就打印页表,我们还需要在exec.c
中exec()
的return argc;
前添加if (p->pid == 1) { vmprint(p->pagetable); }
这样任务一就算完成了。
A kernel page table per process
任务二比较抽象,也是本次实验综合难度最高的一个任务。
首先要理解内核态页表是什么:xv6维护一个公用的内核态页表,进程在切换到内核态时,页表也会切换成公用的内核态页表,而内核态页表和用户态页表最大的不同就是除了栈和蹦床帧等几个地方外,其余的都是采用直接映射的方式来转换地址,因此进程在用户态的页表中引用的虚拟地址需要在用户态再经过一次转换才能被内核态页表转换,例如,传递给write() 的缓冲区指针。任务二和任务三的目标是在内核态直接翻译进程的虚拟地址。
回到任务二,这个任务的要求是为每个进程增加一个内核态页表,这样进程切换到内核态时就可以直接用该进程对应的内核态页表,而不是公用的,这样就免去了多一次转换的功夫。看完代码后就可以开始添加了,按照任务提示一步步完成。
第一步:添加kernel pagetable
在 kernel/proc.h
中的 proc
结构体中添加一个字段 pagetable_t 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
pagetable_t kpagetable; // 在这里添加
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)
};
第二步:初始化内核态页表
kernel/vm.c
里面提供了kvminit()
来初始化原本的公用页表,仿照其写一个初始化函数即可。
pagetable_t ukvminit()
{
pagetable_t kpagetable = (pagetable_t) kalloc();
memset(kpagetable, 0, PGSIZE);
// uart registers
ukvmmap(kpagetable, UART0, UART0, PGSIZE, PTE_R | PTE_W);
// virtio mmio disk interface
ukvmmap(kpagetable, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
// CLINT
ukvmmap(kpagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
// PLIC
ukvmmap(kpagetable, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
// map kernel text executable and read-only.
ukvmmap(kpagetable, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
// map kernel data and the physical RAM we'll make use of.
ukvmmap(kpagetable, (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.
ukvmmap(kpagetable, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
return kpagetable;
}
注意:原本的kvmmap是直接为公用页表添加映射的,所以我们还要小改一下这个函数。
void ukvmmap(pagetable_t kpagetable,uint64 va, uint64 pa, uint64 sz, int perm)
{
if(mappages(kpagetable, va, sz, pa, perm) != 0)
panic("ukvmmap");
}
然后在原本调用kvminit()
的地方增加对ukvminit()
的调用,根据提示可知是在在 kernel/proc.c
中的 allocproc
函数里:
// An empty user page table.
p->pagetable = proc_pagetable(p);
if(p->pagetable == 0){
freeproc(p);
release(&p->lock);
return 0;
}
// 在下面添加
// An empty user kernel page table.
p->kpagetable = ukvminit();
if(p->kpagetable == 0) {
freeproc(p);
release(&p->lock);
return 0;
}
第二步完成,注意把ukvminit()
添加到defs.h
第三步:初始化内核栈
根据任务提示,要求我们把原来是在 kernel/proc.c
中的 procinit
函数内的相关代码移到allocproc()
中,因此在第二步添加的地方下面把procinit
内的代码直接移入 allocproc
中。
// An empty user kernel page table.
p->kpagetable = ukvminit();
if(p->kpagetable == 0) {
freeproc(p);
release(&p->lock);
return 0;
}
// 在下面添加
char *pa = kalloc();
if(pa == 0)
panic("kalloc");
uint64 va = KSTACK((int)(p - proc));
ukvmmap(p->kpagetable, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
p->kstack = va;
第四步:在进程调度时切换内核页
其实前三步就已经完成了每个进程独有的内核态页表的创建,第四步是在调度时的修改。
从xv6 Book中可以得知内核页的管理使用的是SATP寄存器,任务提示你要修改schduler()
并且不要忘记在调用w_satp()
之后调用sfence_vma()
,可以参考kvminithart()
。注意要切换回来。
// change satp
w_satp(MAKE_SATP(p->kpagetable));
sfence_vma();
// change process
swtch(&c->context, &p->context);
// change back
kvminithart();
第五步:释放内核栈内存
有始有终,既然添加了页表就要能够释放掉。
释放页表的函数位于kernel/proc.c
的freeproc()
中,我们需要对这个函数进行修改
释放页表的第一步是先释放页表内的内核栈,因为页表内存储的内核栈地址本身就是一个虚拟地址,需要先将这个地址指向的物理地址进行释放:
if(p->trapframe)
kfree((void*)p->trapframe);
p->trapframe = 0;
//在这里添加
if(p->kstack) {
pte_t* pte = walk(p->kpagetable, p->kstack, 0);
if(pte == 0)
panic("freeproc: walk");
kfree((void*)PTE2PA(*pte));
}
p->kstack = 0;
注意需要将 walk
函数的定义添加到 kernel/defs.h
中,该函数的作用是从制定页表中的虚拟地址找到对应条目,然后通过PTE2PA
转换成物理地址释放掉。
第六步:释放内核页表
直接遍历所有的页表,释放所有有效的页表项即可。仿照 freewalk
函数。由于 freewalk
函数将对应的物理地址也直接释放了,我们这里释放的内核页表仅仅只是用户进程的一个备份,释放时仅释放页表的映射关系即可,不能将真实的物理地址也释放了。因此不能直接调用freewalk
函数,而是需要进行更改:
void proc_freewalk(pagetable_t pagetable) {
for (int i = 0; i < 512; i++) {
pte_t pte = pagetable[i];
if (pte & PTE_V) {
pagetable[i] = 0;
if ((pte & (PTE_R | PTE_W | PTE_X)) == 0) {
uint64 child = PTE2PA(pte);
proc_freewalk((pagetable_t)child);
}
}
}
kfree((void*)pagetable);
}
再在 freeproc
中进行调用:
// delete user pagetable
if(p->pagetable)
proc_freepagetable(p->pagetable, p->sz);
p->pagetable = 0;
// 在这里添加
if(p->kpagetable) {
proc_freewalk(p->kpagetable);
}
这样,我们对整个页表的创建,调度和释放的工作就做完了,只剩下修改原本进程切入内核态所使用的页表了。
第七步:切换进程内核页表
在vm.c
中添加头文件:
#include "spinlock.h"
#include "proc.h"
然后更改 kvmpa
函数:
pte = walk(myproc()->kpagetable, va, 0);
Simplify copyin/copyinstr
通过任务二,我们已经完成了对每个进程创建一个对应的内核态页表,但最关键的问题是:它是空的!
因此任务三的目标就是将用户进程页表的所有内容都复制到内核页表中,这样就可以完成内核态直接转换虚拟地址,同时该实验为我们提供了直接转换的简化版copyin_new/copyinstr_new
函数,我们需要将原本的间接转换版本替换成新版。
第一步:复制页表内容
仿照uvmcopy()
,我们可以照猫画虎一个u2kvmcopy()
:
void u2kvmcopy(pagetable_t upagetable, pagetable_t kpagetable, uint64 oldsz, uint64 newsz)
{
pte_t *pte_u, *pte_k;
uint64 pa, i;
uint flags;
oldsz = PGROUNDUP(oldsz);
for(i = oldsz; i < newsz; i += PGSIZE)
{
if((pte_u = walk(upagetable, i, 0)) == 0)
panic("u2kvmcopy: pte_u should exist");
if((pte_k = walk(kpagetable, i, 1)) == 0)
panic("u2kvmcopy: pte_k should exist");
pa = PTE2PA(*pte_u);
flags = PTE_FLAGS(*pte_u) & (~PTE_U);
*pte_k = PA2PTE(pa) | flags;
}
}
注意,因为xv6设定了内核态不能访问PTE_U位为1的页表,因此我们最后要把该位设为0。
第二步:在对应位置调用页表复制
根据任务提示,我们需要修改的地方是三个函数:fork()
,sbrk()
,exec()
。
fork()
np->cwd = idup(p->cwd); // 添加代码 u2kvmcopy(np->pagetable, np->kpagetable, 0, np->sz); safestrcpy(np->name, p->name, sizeof(p->name));
sbrk()
sbrk() 需要到
sysproc.c
找,可以发现,调用的是growproc
函数,在其中添加防止溢出的函数:if(PGROUNDUP(sz + n) >= PLIC) return -1;
exec()
在执行新的程序前,初始化之后,进行页表拷贝:
u2kvmcopy(pagetable, p->kpagetable, 0, sz); // Push argument strings, prepare rest of stack in ustack. for(argc = 0; argv[argc]; argc++) { ......
除了这三个函数外,任务提示我们不要忘记在userinit
的内核页表中包含第一个进程的用户页表。(userinit()
在kernel/proc.c
中)
uvminit(p->pagetable, initcode, sizeof(initcode));
p->sz = PGSIZE;
u2kvmcopy(p->pagetable, p->kpagetable, 0, p->sz);
第三步:将copyin/copyinstr
换成copyin_new/copyinstr_new
将copyin和copyinstr函数内部全部注释掉,改为调用copyin_new和copyinstr_new函数即可。
总结
Lab3的难度相当大,需要花费不少时间去看对应的代码并理解其作用,但做完一整套的流程下来,能够掌握xv6的页表创建,调用,释放的流程,加深了理解,总体来说收获还是非常丰富的。