Pre
在这个lab中,你将探索页表,并且修改它们以简化从用户空间拷贝数据到内核空间的函数
在开始之前,需要完成
- 阅读xv6 book的第3章
kern/memlayout.h
有关内存的布局kern/vm.c
包含大部分虚拟内存的代码kernel/kalloc.c
分配和释放虚拟内存的代码
内存布局
Print a page table
task
-
定义一个叫做
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
个..
- 然后打印这个页表项在这个页表中的索引
- pte后面紧跟着的内容是这个页表项的所有内容,总共64bit
- pa后面紧跟着的就是这个页表项指向的地址,假设pte页表项的内容是
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
hints
- 在
kernel/vm.c
中完成这个函数 - 使用
kernel/riscv.h
中定义的宏 freewalk
将给你灵感- 记得在
kernel/defs.h
中声明你的函数,这样才可以在exec
函数中使用它 - 使用
%p
去打印64bit的pte和address
思路
- 先看看
freewalk
中是如何遍历这三级页表的,思路就比较清晰了 - 这里用了一个depth控制当前位于第几级页表,方便打印和控制递归的终点
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--;
}
}
}
}
A kernel page table per process
这个task和下一个task的目标就是使得内核可以直接解引用进程传递的指针
task
- 在这个task中,你先修改kernel,使得每个进程在内核态的时候都可以用它自己对内核页表的拷贝
- 修改
struct proc
为每个进程都维护一个内核页表 - 修改
scheduler
使得进程切换的时候切换内核的页表 - 完成到这一步时,每个进程中的内核页表都应该和现在的全局内核页表相同
- 如果你能通过
usertests
就说明完成了这个task
hints
-
在
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;
- 这是创建内核页表的关键。我之前以为是要把现在的内核页表给复刻一遍,结果写了个递归函数去copy当前的内核页表。但是实际上,只需要我们创建一个和
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;
- hints的第4和第5点一起考虑
第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,一度以为是死锁了写错了
Simplify
task
- 将用户的映射都加入到进程的内核页表中
- 将
vm.c
中的copyin
函数的函数体替换成对copyin_new
的调用,对copyinstr
也是一样的处理 make grade
通过就说明成功了- 你需要修改xv6使得用户进程的虚拟地址不会超过
PLIC
寄存器的地址
hints
-
先确定
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函数失败了,还需要将进程的用户态页表的映射给抹去再返回-1if (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的总耗时,一个用来回答问题,可以直接乱填 -