兄弟们,我XV6又回来啦,这个栏目的灰都能把人埋了,鸽子王实锤(当然不是因为懒(⁎⁍̴̛ᴗ⁍̴̛⁎))。不过虽迟但到!主要有时候看完文档和课程后又去搞其他飞机了,回来时,发现一切回到原点。这周发现再不搞真搞不完了,Lab3是关于页表的,文档戳这。
❖ Coding
☑︎ Speed up system calls (easy)
由于用户在使用系统调用时需要从用户空间跳转到内核空间,存在一定的时间开销。我们可以通过建立一段用户(只读)和内核共享的内存空间来避免空间跳转的时间,从而加速系统调用。这是一些操作系统的常见优化方法,本实验就是希望你以此来优化getpid系统调用。
我们知道每个用户进程都拥有自己的虚拟空间,通过页表查找到物理地址后再进行相应的操作。因此这段共享空间的起始地址也需要记录在进程的页表上。实验文档指出,每个进程在创建时都需要新建一个只读页(shared memory),并将其物理地址映射到虚拟地址USYSCALL(一个已经存在的宏定义),然后在这一页的开头存储一个struct usyscall
(memlayout.h),并把进程pid存入这个结构体中:
76 struct usyscall {
77 int pid; // Process ID
78 };
接着我们再来瞅瞅user/ulib.c中ugetpid的具体实现:
144 │ #ifdef LAB_PGTBL
145 │ int
146 │ ugetpid(void)
147 │ {
148 │ struct usyscall *u = (struct usyscall *)USYSCALL;
149 │ return u->pid;
150 │ }
151 │ #endif
这个函数直接访问USYSCALL地址,通过我们设置好的页表就可以直接到相应的usyscall结构体取得pid,从而可以替代原先需要进入内核空间的getpid函数。
页表相关操作基本都在kernel/proc.c文件中,我们先找到实现主要地址映射的proc_pagetable函数,可以看到这个函数通过mappages函数完成了Trampoline(系统调用返回地址,也是用户虚拟空间中的最高地址)和Trapframe(当中断来临时存储当前寄存器值的地址,就位于Trampoline的下方)的映射。显然,我们也需要用mappages来实现USYSCALL虚拟地址的映射,但问题是我们需要知道相应的物理地址。
由于这个只读页是在进程创建时产生的,那么我们来到创建进程的allocproc函数:
100 │ // Look in the process table for an UNUSED proc.
101 │ // If found, initialize state required to run in the kernel,
102 │ // and return with p->lock held.
103 │ // If there are no free procs, or a memory allocation fails, return 0.
104 │ static struct proc*
105 │ allocproc(void)
106 │ {
107 │ struct proc *p;
108 │
109 │ for(p = proc; p < &proc[NPROC]; p++) {
110 │ acquire(&p->lock);
111 │ if(p->state == UNUSED) {
112 │ goto found;
113 │ } else {
114 │ release(&p->lock);
115 │ }
116 │ }
117 │ return 0;
118 │
119 │ found:
120 │ ......
allocproc函数从进程表中找到一个未使用的进程块然后为它分配相应的页表空间(kalloc:分配一块页大小512B的空间),那么我们就在此仿照trapframe的做法新建只读页,并存入pid:
130 + │ // ADD
131 + │ if((p->usys = (struct usyscall *)kalloc())==0)
132 + │ {
133 + │ // kfree((void*)p->trapframe);
134 + │ freeproc(p);
135 + │ release(&p->lock);
136 + │ return 0;
137 + │ }
138 + │ p->usys->pid=p->pid;
139 + │
140 │ // An empty user page table.
141 │ p->pagetable = proc_pagetable(p);
142 │ ......
由于只读页的物理地址还要在proc_pagetable中使用,因此我们需要在proc结构体(kernel/proc.h)中加入struct usyscall *usys
。
⚠️ 新建只读页必须在调用proc_pagetable(141)之前,因为需要用物理地址做映射。
然后我们就可以在proc_pagetable里建立映射了:
210 + │ // ADD
211 + │ if (mappages(pagetable, USYSCALL, PGSIZE, (uint64)(p->usys), PTE_R | PTE_U) < 0)
212 + │ {
213 + │ uvmunmap(pagetable, TRAMPOLINE, 1, 0);
214 + │ uvmunmap(pagetable, TRAPFRAME, 1, 0);
215 + │ uvmfree(pagetable, 0);
216 + │ return 0;
217 + │ }
218 + │
219 │ return pagetable;
220 │ }
⚠️权限是只读(PTE_R),同时在用户空间内运行(PTE_U)。如果map失败就需要解除前面TRAMPOLINE和TRAPFRAME的映射。
最后不要忘记在freeproc中释放分配来的物理空间:
168 + │ if(p->usys)
169 + │ kfree((void*)p->usys);
170 + │ p->usys = 0;
还有很重要的一点是解除映射,否则会panic——“freewalk: leaf”:
224 │ void proc_freepagetable(pagetable_t pagetable, uint64 sz) {
225 │ uvmunmap(pagetable, TRAMPOLINE, 1, 0);
226 │ uvmunmap(pagetable, TRAPFRAME, 1, 0);
227 + │ uvmunmap(pagetable, USYSCALL, 1, 0);
228 │ uvmfree(pagetable, sz);
229 │ }
在释放页表空间后会调用freewalk函数来检查该页表中的所有叶子pte(三级映射最后的物理地址)是否有效(PTE_V是否为1),如果存在物理地址有效就会panic。
☑︎ Print a page table(easy)
实现函数vmprint打印出页表树。页表是三级查找结构,把所有PTE条目看作一个节点,物理地址所对应的PTE条目为其子节点,那么从宏观上来看每个L2 page directory的物理地址都对应一个深度不超过3的树。
只要知道了如何判断PTE(page table entry)和PT就能写出递归函数了。我们可以参考freewalk函数(kernel/vm.c),对就是上面刚刚出现的那位老哥,它就是通过递归遍历整棵树来检查物理地址是否还有效的:
266 │ void
267 │ freewalk(pagetable_t pagetable)
268 │ {
269 │ // there are 2^9 = 512 PTEs in a page table.
270 │ for(int i = 0; i < 512; i++){
271 │ pte_t pte = pagetable[i];
272 │ if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){
273 │ // this PTE points to a lower-level page table.
274 │ uint64 child = PTE2PA(pte);
275 │ freewalk((pagetable_t)child);
276 │ pagetable[i] = 0;
277 │ } else if(pte & PTE_V){
278 │ panic("freewalk: leaf");
279 │ }
280 │ }
281 │ kfree((void*)pagetable);
282 │ }
可见如果PTE_V是有效的,没有任何权限则说明这是一个PTE,需要继续递归,反之则说明是叶子节点,即虚拟地址对应的真实物理地址。
由于我们需要用【 ..】的个数来表示深度,因此在vmprint中设置一个static int depth
,递归进入子函数前+1,退出时-1。
首先在kernel/exec.c的exec函数中加入
143 + │ if(p->pid==1)
144 + │ vmprint(p->pagetable);
145 + │
146 │ return argc; // this ends up in a0, the first argument to main(argc, argv)
对于vmprint的实现,我是直接在exec函数前插入的,实验文档推荐在kernel/vm.c里实现,那就别忘了在kernel/defs.h里声明。
12 + │ void vmprint(pagetable_t pg)
13 + │ {
14 + │ static int depth = 1;
15 + │ if (depth == 1)
16 + │ printf("page table %p\n", *pg);
17 + │ for (int i = 0; i < 512; ++i)
18 + │ {
19 + │ pte_t pte = pg[i];
20 + │ if (pte & PTE_V)
21 + │ {
22 + │ uint64 child = PTE2PA(pte);
23 + │ for (int j = 0; j < depth; ++j)
24 + │ printf(" ..");
25 + │ printf("%d: pte %p pa %p\n", i, pte, child);
26 + │ if ((pte & (PTE_R | PTE_W | PTE_X)) == 0)
27 + │ {
28 + │ depth += 1;
29 + │ vmprint((pagetable_t) child);
30 + │ }
31 + │ }
32 + │ }
33 + │ depth -= 1;
34 + │ }
然后启动qemu后就会输出
hart 2 starting
hart 1 starting
page table 0x0000000021fda801
..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
.. .. ..509: pte 0x0000000021fdd813 pa 0x0000000087f76000
.. .. ..510: pte 0x0000000021fddc07 pa 0x0000000087f77000
.. .. ..511: pte 0x0000000020001c0b pa 0x0000000080007000
☑︎ Detecting which pages have been accessed (hard)
这里我们需要实现一个系统调用pgaccess,来检查给定物理页们是否被访问过(accessed:read or write),用one bit来表示一个物理页的结果,并将结果拷贝至用户空间。
pgaccess会接受三个参数——第一个是起始页的虚拟地址,第二个是需要往后检查的页数,最后一个是需要将结果拷贝到用户空间的物理地址。文档中说最后用户空间会用一个bitmask的数据结构存储结果,其中a bit代表1页且第一页位于最低有效位,另外可以自行设置一个页数上限。在内核中运行时,我们可以用一个uint64来存储结果,那么页数上限即为64。另外我们需要自己在kernel/risv.h中定义PTE_A,至于这一位在物理地址中的哪一位则需要查询riscv手册(P70):
你可能会问PTE_A不是我们自行定义的吗。事实上,pgaccess只是实现一个检查的功能,真正置位的是riscv硬件。文档中指出“The RISC-V hardware page walker marks these bits in the PTE whenever it resolves a TLB miss”,当CPU需要那一物理页时,发现Translation Look-aside Buffer未命中(cache中没有),就会去access这一页,然后将其PTE_A置1,因此PTE_A的位置是由硬件规定的。
根据上图可知PTE_A位于倒数第6位,那么就在kernel/riscv.h中加入
346 + │ #define PTE_A (1L << 6) // access bit
准确地来讲,页表中存储的并不是物理地址,而是0(10 bits)+PPN(44 bits)+flags(10 bits)=PTE(64 bits),因此我们可以看到kernel/riscv.h中很多转换宏定义:
348 │ // shift a physical address to the right place for a PTE.
349 │ #define PA2PTE(pa) ((((uint64)pa) >> 12) << 10)
350 │
351 │ #define PTE2PA(pte) (((pte) >> 10) << 12)
352 │
353 │ #define PTE_FLAGS(pte) ((pte) & 0x3FF)
想要获取PTE_A只需要(PTE&PTE_A)>>6
即可。另外由于参数是从用户空间传入,因此我们需要将这些参数通过argaddr(64 bits)和argint(32 bits)拷贝过来。由于传入的是虚拟地址,因此我们需要用walk函数找到实际的物理地址。
kernel/sysproc.c中已经指明了函数的实现位置,直接撸就完事:
79 │#ifdef LAB_PGTBL
80 │int
81 │sys_pgaccess(void)
82 │{
83 │ // lab pgtbl: your code here.
84 + │ uint64 sa, ua, buf = 0;
85 + │ int n;
86 + │ if (argaddr(0, &sa) < 0 || argint(1, &n) < 0 || argaddr(2, &ua) < 0)
87 + │ return -1;
88 + │
89 + │ if (n > 64) return -1;
90 + │
91 + │ struct proc *p = myproc();
92 + │ uint64 pg = sa;
93 + │ for (int i = 0; i < n; ++i)
94 + │ {
95 + │ pte_t *pte = walk(p->pagetable, pg, 0);
96 + │ buf += ((*pte & PTE_A) > 0) << i;
97 + │ (*pte) &= (~PTE_A);
98 + │ pg += PGSIZE;
99 + │ }
100 + │
101 + │ return copyout(p->pagetable, ua, (char *) &buf, sizeof(buf));
102 │}
103 │#endif
文档中指出我们检查完后还需要将PTE_A置0,因为硬件只顾着在访问时置1,如果不复位那么PTE_A就永远是1了。因此别忘记(*pte) &= (~PTE_A)
,另外别写出(*pte & PTE_A > 0)
,【&】的优先级比【>】低。