在这个lab中,你将探索页表,并且修改它们以简化从用户空间拷贝数据到内核空间的函数
在开始之前,需要完成
- 阅读xv6 book的第3章
kern/memlayout.h
有关内存的布局kern/vm.c
包含大部分虚拟内存的代码kernel/kalloc.c
分配和释放虚拟内存的代码
定义一个叫做vmprint(pagetable_t)
的函数,用下面的格式打印页表
添加if(p->pid==1) vmprint(p->pagetable)
到exec.c
中return argc
语句前面,这将会打印第一个进程的页表
通过make grade
的pte printout
进行测试,也可以
./grade-lab-pgtbl pte printout
打印的格式如下
x
级的页表,那就先打印x
个..
val
,那么pa=((val>>10)<<12)
,xv6中已经定义了一个宏来实现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
kernel/vm.c
中完成这个函数kernel/riscv.h
中定义的宏freewalk
将给你灵感kernel/defs.h
中声明你的函数,这样才可以在exec
函数中使用它%p
去打印64bit的pte和addressfreewalk
中是如何遍历这三级页表的,思路就比较清晰了int depth = 0;
void print_prefix(int i) {
for (int i = 0; i <= depth; i++) {
printf("..");
if (i < depth) {
printf(" ");
}
}
printf("%d: ", i);
}
void vmprint(pagetable_t pagetable) {
printf("page table %p\n", pagetable);
for (int i = 0; i < 512; i++) {
pte_t pte = pagetable[i];
// 如果这一项有效
if (pte & PTE_V) {
print_prefix(i);
printf("pte %p ", pte);
printf("pa %p", PTE2PA(pte));
printf("\n");
if (depth < 2) {
depth++;
vmprint((pagetable_t)PTE2PA(pte));
depth--;
}
}
}
}
这个task和下一个task的目标就是使得内核可以直接解引用进程传递的指针
struct proc
为每个进程都维护一个内核页表scheduler
使得进程切换的时候切换内核的页表usertests
就说明完成了这个task在struct proc
中增加一个字段表示这个进程独有的内核页表
为一个新进程创造一个内核页表的合理的方法是
实现一个kvminit
的新版本,这个新版本会创建一个新的页表,而不是修改已有的页表
你需要在allocproc
中调用这个新的函数
保证每个进程的内核页表都有一个映射,这个映射可以找到进程的内核栈
在未修改的xv6中,所有的内核栈都在procinit
中被创造
你需要去移动procinit
中的部分或者全部到allocproc
中
修改scheduler()
去将进程的内核页面加载到satp
寄存器, 可以通过kvminithart
学习这个的用法
不要忘记在调用w_satp
之后调用sfence_vma
调度器应该在没有进程运行时使用kernal_pagetable
在freeproc
中释放一个进程的内核页表
你将需要一个方法,这个方法在释放页表的同时不会释放真正的物理页面
vmprint
可以在debug页表的时候办法
可以说,跟着hint一步一步走,就成功了,但是我个人觉得hint或者说这个文档没有说的非常清楚,导致有一点歧义,接下来一个hint一个hint分析
proc
中增加一个字段,这个就不用说了pagetable_t kernel_pgtbl;
kvminit
创建出来的的内核页表一样的就行了,也就是最原始的那种,只有内核的代码和数据以及一些外设,这些代码都在kvminit
代码中,所以直接抄一份就行了通过这个代码可以发现,创建一个最原始的内核页表,就三步
kvmmap
操作)最后在allocproc
中进程被正确创建之后,给这个进程的内核页表赋值p->kernel_pgtbl = new_kernel_pgtbl();
void init_kernel_pgtbl(pagetable_t pgtbl) {
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);
}
pagetable_t new_kernel_pgtbl() {
pagetable_t pgtbl = (pagetable_t)kalloc();
init_kernel_pgtbl(pgtbl);
return pgtbl;
}
/*
* create a direct-map page table for the kernel.
*/
void kvminit() {
kernel_pagetable = new_kernel_pgtbl();
}
需要注意的是,我们在这里修改了kvmmap
函数的声明,因为之前它是默认使用内核页表的,现在需要用每个进程自己的内核页表,这里主要要修改两个函数,分别是kvmmap
和kvmpa
,注意要将修改更新到defs.h
文件以及所有用到这两个函数的地方,有个比较隐秘的是在virtio_disk.c
正常来说,内核栈是在procinit
的时候对proc
数组的所有进程进行初始化,然后将地址映射放到唯一的内核页表中
而我们现在只需要在allocproc
中申请内存栈并将这个地址变换写到这个进程的内存页表即可
具体步骤如下
将procinit
函数中和内存栈相关的代码给剪切
将代码复制到allocproc
的合适位置,放在进程内核页表被初始化的后面就不错
char *pa = kalloc();
if (pa == 0)
panic("kalloc");
uint64 va = KSTACK((int)(0));
kvmmap(p->kernel_pgtbl, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
p->kstack = va;
第4点就是要求我们在进程获得cpu的时候把它自己的内核栈给切换上去,即修改寄存器satp
第5点则是要求在没有进程使用的时候,切换到内核唯一的那个页表,这个可以通过在进程执行完之后就切换satp
寄存器为唯一的内存页表
具体实现如下,在swtch
函数执行前后进行切换即可
w_satp(MAKE_SATP(p->kernel_pgtbl));
sfence_vma();
swtch(&c->context, &p->context);
kvminithart();
首先补充一个hints没有说的,我们还需要回收这个进程的内核栈的那个页面,否则会造成内存浪费
在freeproc
函数中加入如下代码
if (p->kernel_pgtbl) {
free_kernel_stack(p->kernel_pgtbl, p->kstack);
p->kstack = 0;
free_kernel_pgtbl(p->kernel_pgtbl, 0);
p->kernel_pgtbl = 0;
}
其中free_kernel_stack
就是通过栈的虚拟地址,经过内核页表,找到物理地址,将其free
void free_kernel_stack(pagetable_t pgtbl, uint64 stack_p) {
void *real_p = (void *)kvmpa(pgtbl, stack_p);
kfree(real_p);
}
其中free_kernel_pgtbl
就复杂一些,需要递归地删除这个内核页表,并且不能真正地删除物理页面
void free_kernel_pgtbl(pagetable_t pgtbl, int depth) {
if (depth == 2) {
kfree((void *)pgtbl);
return;
}
for (int i = 0; i < 512; i++) {
pte_t *pte = &pgtbl[i];
if (*pte & PTE_V) {
free_kernel_pgtbl((pagetable_t)(PTE2PA(*pte)), depth + 1);
}
}
kfree((void *)pgtbl);
}
至此,第二个task结束,可以运行
./grade-lab-pgtbl usertests
检查这个检查的过程非常长,在我这运行了100s,一度以为是死锁了写错了
vm.c
中的copyin
函数的函数体替换成对copyin_new
的调用,对copyinstr
也是一样的处理make grade
通过就说明成功了PLIC
寄存器的地址先确定copyin
正确,再去尝试copyinstr
每次内核改变用户的映射时,都要同步修改到这个用户的内核页表
包括fork
exec
sbrk
不要忘记了在userinit
中将第一个进程的用户也更新到他的内核页表
PTE_U
不要也拷贝到了kernel的内核页表中
不要忘记了PLIC
的限制
umalloc.c
文件的morecore
函数中对sbrk
函数的返回值进行判断fork
函数,主要是修改uvmcopy
函数,在它使用mappages
给new
增加页表项时,成功后给kernal
也增加页表项exec
函数,有好多地方需要修改,一个一个来
proc_pagetable
去创建一个只有顶部两个和trap相关的页面,其他的都为空,这应该相当于清空,内核该怎么办呢?也清空自己吗。目前是清空原有的内核页表,然后生成一个新的内核页表,最后将内核栈的映射加上去uvmalloc
中增加对kernel的操作uvmclear
中增加对kernel的操作,这个好像不需要在vm.c
中创造函数copy_to_kernal
和dealloc_kernal
,其中的copy函数会将用户标志位给取消
copy_to_kernel
函数如下
有几个细节,或者说是有点坑的地方
mappages
函数失败的情况,这个函数失败,说明walk
失败,进一步说明是kalloc
失败,这本质上就是没有空闲页面了。这时候也不应该用panic
报错,而是返回一个特殊值,表示内存不够用了,并且将已经记录的地址映射删除。如果不处理这种情况,会在sbrkmuch
这个测试点过不去。PGROUNDUP
,可以不用吗?或者可以用PGROUNDDOWN
吗
oldsz
在的那个页面本来就存在于内核页表中,不需要复制,所以就从向上取整的那个开始uint64 copy_to_kernal(pagetable_t user, pagetable_t kernel, uint64 oldsize, uint64 newsize) {
uint64 va, pa;
pte_t *pte;
uint flags;
for (va = PGROUNDUP(oldsize); va < newsize; va += PGSIZE) {
pte = walk(user, va, 0);
pa = PTE2PA(*pte);
flags = PTE_FLAGS(*pte);
flags &= ~PTE_U;
if (mappages(kernel, va, PGSIZE, pa, flags) != 0) {
uvmunmap(kernel, PGROUNDUP(oldsize), (va - PGROUNDUP(oldsize)) / PGSIZE, 0);
return -1;
}
}
return newsize;
}
dealloc_kernal
函数如下,基本照抄uvmdealloc
函数,只需要将uvmunmap
最后的dofree
参数改成0就行了
这个函数的本质就是将用户进程的虚拟地址给free掉了,没有影响内核本身的那些外设和代码数据
uint64 dealloc_kernal(pagetable_t kernel, uint64 oldsz, uint64 newsz) {
if (newsz >= oldsz)
return oldsz;
if (PGROUNDUP(newsz) < PGROUNDUP(oldsz)) {
int npages = (PGROUNDUP(oldsz) - PGROUNDUP(newsz)) / PGSIZE;
uvmunmap(kernel, PGROUNDUP(newsz), npages, 0);
}
return newsz;
}
fork
,在父进程拷贝内存到子进程之后,调用copy_to_kernel
函数
注意,也要判断是否失败
// Copy user memory from parent to child. // 将child的用户态页表复制到child的内核页表
if (uvmcopy(p->pagetable, np->pagetable, p->sz) < 0 || copy_to_kernal(np->pagetable, np->kernel_pgtbl, 0, p->sz) < 0) {
freeproc(np);
release(&np->lock);
return -1;
}
exec
,找个合适的位置(进程的页表被初始化完之后就行),先释放再拷贝
dealloc_kernal(p->kernel_pgtbl, oldsz, 0);
copy_to_kernal(p->pagetable, p->kernel_pgtbl, 0, sz);
sbrk
应该是在sys_sbrk
中调用的growproc
函数,分别在分配和释放的情况下调用函数。其中在分配的时候,如果我们的copy函数失败了,还需要将进程的用户态页表的映射给抹去再返回-1
if (n > 0) {
if ((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
return -1;
}
// 内核页表
if (copy_to_kernal(p->pagetable, p->kernel_pgtbl, oldsz, oldsz + n) < 0) {
uvmdealloc(p->pagetable, sz, oldsz);
return -1;
}
} else if (n < 0) {
sz = uvmdealloc(p->pagetable, sz, sz + n);
// 内核页表
dealloc_kernal(p->kernel_pgtbl, oldsz, oldsz + n);
}
userinit
,在uvminit
之后调用拷贝函数
copy_to_kernal(p->pagetable, p->kernel_pgtbl, 0, PGSIZE);
PLIC
的限制
将CLINT
变成只有最初的内核页表才分配,后面申请的内核页表都不用,这样每个进程的内核页表就不会在PLIC
下面还有虚拟地址了。至于这这个CLINT
为什么在只需要在最初的内核页表需要,现在还不太清楚,好像后面会讲的,就当个黑盒子使用了
具体实现就是将kvmmap(kernel_pagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
从task2中定义的init_kernel_pgtbl
中移到kvminit
控制用户进程的地址空间,不要超过了PLIC
,我觉得这里需要在两个地方进行控制,第一个是exec
函数,即进程初始的虚拟地址空间大小,第二个是sbrk
函数,即进程在运行的过程中动态申请内存空间,也不能超过PLIC
,这个就体现在sbrk
调用的growproc
函数了
在exec
中,是在一个for循环里不断通过uvmalloc
给这个进程的用户页表建议映射的,因此在这个函数后面加上一个判断即可
if ((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz)) == 0)
goto bad;
if (sz1 >= PLIC) {
goto bad;
}
在growproc
中,先判断一下当前的大小加上n是否超过了PLIC
if (PGROUNDUP(sz + n) >= PLIC) {
return -1;
}
至此,硬核的内容结束了
但是如果想拿到满分,还需要在项目根目录下创建两个txt文件,一个叫time.txt
,一个叫answers-pgtbl
,一个用来记录完成lab的总耗时,一个用来回答问题,可以直接乱填