概述
這次實驗主要涉及虛擬內存的管理,重點是和頁表相關的操作。個人覺得難點主要還是在調試方面,因為一旦寫到什么非法內存或者哪里內存泄漏了,基本只能抓瞎。我也是參考了github上別人的代碼才最終完成了實驗。
內容
Print a page table
這個任務比較簡單,只要仿照freewalk遞歸遍歷就行了。
void printwalk(pagetable_t pt, int dep) {
for(int i = 0; i < 512; i++){
pte_t pte = pt[i];
if (pte & PTE_V) {
for (int j = 0; j < dep - 1; j++) printf(".. ");
printf("..%d: pte %p ", i, pte);
uint64 child = PTE2PA(pte);
printf("pa %p\n", child);
if ((pte & (PTE_R|PTE_W|PTE_X)) == 0)
printwalk((pagetable_t)child, dep + 1);
}
}
}
void vmprint(pagetable_t pt) {
printf("page table %p\n", pt);
printwalk(pt, 1);
}
A kernel page table per process
這個任務需要給每個進程添加一個獨立的內核頁表,兩個任務的總體目的是讓每個進程獨立擁有一個同時映射了用戶內存區和內核內存區的內核頁表。這樣進程在進入內核態后,可以直接在自己這個內核頁表中的用戶內存區和內核內存區之間傳遞數據,不需要經過頁表切換。首先我除了給proc結構體添加了kpagetable外,額外加了一個kstackpa表示kstack的物理地址,這一步不是必須的,因為結構體里已經保存了kstack的虛擬地址了,在用之前walk一遍也不是不行。加了之后初始化在申請kstack的時候就順便保存了物理地址:
char *pa = kalloc();
if(pa == 0)
panic("kalloc");
p->kstackpa = pa;
然后是allocproc,需要申請kpagetable並對其進行映射:
p->kpagetable = proc_kpagetable();
if (p->kpagetable == 0) {
freeproc(p);
release(&p->lock);
return 0;
}
if (mappages(p->kpagetable, (uint64)p->kstack, PGSIZE,
(uint64)p->kstackpa, PTE_R | PTE_W) != 0) {
freeproc(p);
release(&p->lock);
return 0;
}
proc_kpagetable的實現我寫在vm.c里,借鑒了kvminit函數:
pagetable_t proc_kpagetable(void) {
pagetable_t kpagetable = (pagetable_t) kalloc();
memset(kpagetable, 0, PGSIZE);
if (mappages(kpagetable, UART0, PGSIZE, UART0, PTE_R | PTE_W) != 0) return 0;
if (mappages(kpagetable, VIRTIO0, PGSIZE, VIRTIO0, PTE_R | PTE_W) != 0) return 0;
if (mappages(kpagetable, PLIC, 0x400000, PLIC, PTE_R | PTE_W) != 0) return 0;
if (mappages(kpagetable, KERNBASE, (uint64)etext-KERNBASE, KERNBASE, PTE_R | PTE_X) != 0) return 0;
if (mappages(kpagetable, (uint64)etext, PHYSTOP-(uint64)etext, (uint64)etext, PTE_R | PTE_W) != 0) return 0;
if (mappages(kpagetable, TRAMPOLINE, PGSIZE, (uint64)trampoline, PTE_R | PTE_X) != 0) return 0;
return kpagetable;
}
值得注意的是這個CLINT沒有被映射,我也不知道這個區域代表什么什么意思,但實驗文檔中提到:
However, this scheme does limit the maximum size of a user process to be less than the kernel's lowest virtual address. After the kernel has booted, that address is
0xC000000
in xv6, the address of the PLIC registers;
memlayout.h中CLINT對應的常數是0x2000000,比0xC000000小,按照文檔的指示是可以被用戶區覆蓋的,所以沒有映射(映射了可能后面再映射用戶內存會報remap錯誤)。
Update2021.6.29:這個CLINT用來存儲發生時鍾中斷時的一些額外信息,由於存取這些信息的過程都發生在機器態,不受頁表控制,所以這個區域無需映射(甚至我認為原版的內核頁表也不需要映射這個區域)。
scheduler函數中切換進程后需要切換satp寄存器為這個進程的內核頁表(因為現在在內核態)並刷新TLB,這一步還是比較簡單的:
p->state = RUNNING;
c->proc = p;
w_satp(MAKE_SATP(p->kpagetable));
sfence_vma();
swtch(&c->context, &p->context);
最后是freeproc,基本也是仿照對用戶頁表的操作依樣畫葫蘆:
if(p->pagetable)
proc_freepagetable(p->pagetable, p->sz);
if (p->kpagetable)
proc_kfreepagetable(p->kpagetable);
p->pagetable = 0;
p->kpagetable = 0;
proc_kfreepagetable我也寫在vm.c里,基本上是仿照freewalk函數寫的,但是freewalk函數要求把最底層頁表的映射全部解除了才能調用,否則會報錯。我嫌麻煩就直接一步了,遇到最底層就不遞歸,直接只釋放頁表:
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);
}
Simplify copyin/copyinstr
這個任務要求實現對copyin和copyinstr函數的完全替代,實際上這兩個函數就是上面所說的直接在進程自己的內核頁表中的用戶內存區和內核內存區之間傳遞數據,基本就一個簡單的memcpy操作,而且實驗文件也已經給了,不用你實現,真正要你做的是在fork、exec、sbrk三個函數中實現內核頁表的管理操作。
先看fork函數,fork函數里復制了用戶頁表,那就依葫蘆畫瓢,也把內核頁表復制一份:
if(kvmcopy(np->pagetable, np->kpagetable, 0, p->sz) < 0){
freeproc(np);
release(&np->lock);
return -1;
}
注意這里因為兩個進程kstack的物理地址不同,所以不能是兩個進程的內核頁表互相復制,而應該是新進程的內核頁表復制自己的用戶頁表,因為新進程在申請內核頁表時內核區已經映射完畢了,所以只需復制用戶區即可,這里的復制指淺拷貝,即不是拷貝物理內存而是讓兩個頁表指向同一個物理地址。
kvmcopy函數在vm.c里,基本可以調已有的函數:
int kvmcopy(pagetable_t old, pagetable_t new, uint64 st, uint64 en) {
pte_t *pte;
uint64 pa, i;
uint flags;
if (en > PLIC) return -1;
st = PGROUNDUP(st);
for(i = st; i < en; i += PGSIZE) {
if((pte = walk(old, i, 0)) == 0)
panic("kvmcopy: pte should exist");
if((*pte & PTE_V) == 0)
panic("kvmcopy: page not present");
pa = PTE2PA(*pte);
flags = PTE_FLAGS(*pte) & (~PTE_U);
if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0) goto err;
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 0);
return -1;
}
補個自己的錯誤,就是忘了st = PGROUNDUP(st)
一句,這句很重要,因為對物理內存的操作都是以頁為單位的,如果這句忘了,這個沒對齊的地址可能就會落到之前某個已經映射過的頁中間,導致重映射錯誤。害我調試了一個晚上……
然后是exec函數,這里就有點坑了,我們觀察到原函數在處理用戶頁表的時候是先開辟一個新的用戶頁表,然后該映射映射,再把老的用戶頁表釋放掉,很自然的也會把這番操作套到內核頁表上。但是,這樣會爆空間!我被這個卡了很久,后來看了實驗的測試程序,發現測試非常極限,會先不斷申請空間直到空閑空間只剩一丁點的時候運行你的exec函數,這時你要是先開辟一個新的內核頁表,老的內核頁表還在,自然爆空間。看了別人的代碼才知道正確做法是直接把內核頁表的用戶區全部解除映射,再重新映射上新的用戶頁表,這樣有兩個好處,一來省空間;二來不用釋放老的內核頁表,注意這個刪除不是隨便刪就了事的,因為你當前是在內核態,這個進程正用着這個老頁表,刪了它直接翻車,還得先把satp切換為新頁表並刷新TLB,這個我也調了很久,用上面的方法就不需要考慮這個問題。所以說內存這種東西,盡可能重用,謹慎刪除:
oldpagetable = p->pagetable;
p->pagetable = pagetable;
kvmdealloc(p->kpagetable, p->sz, 0);
if (kvmcopy(p->pagetable, p->kpagetable, 0, sz) < 0) goto bad;
開辟新內核頁表的寫法比上面還麻煩很多,正確寫法空間效率和代碼效率均強,真的服氣。kvmdealloc函數代碼如下,如前所述,只要解除映射即可:
uint64 kvmdealloc(pagetable_t kpagetable, uint64 oldsz, uint64 newsz) {
if(newsz >= oldsz)
return oldsz;
if(PGROUNDUP(newsz) < PGROUNDUP(oldsz)){
int npages = (PGROUNDUP(oldsz) - PGROUNDUP(newsz)) / PGSIZE;
uvmunmap(kpagetable, PGROUNDUP(newsz), npages, 0);
}
return newsz;
}
sbrk函數直接調用proc.c里的growproc函數,所以直接改這個函數。類似fork函數,內核頁表只要隨着用戶頁表來動就行,用戶內存擴大,它就復制擴大的部分,用戶內存縮小,它就對應解除縮小部分的映射:
if(n > 0){
if((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
return -1;
}
if (kvmcopy(p->pagetable, p->kpagetable, p->sz, p->sz + n) != 0) {
return -1;
}
} else if(n < 0){
sz = uvmdealloc(p->pagetable, sz, sz + n);
kvmdealloc(p->kpagetable, p->sz, p->sz + n);
}
總結一下,這個實驗難度其實是非常高的,我也因此寫加調了好幾天。我本人一開始的做法是照葫蘆畫瓢,直接修改uvmalloc和uvmdealloc函數讓其同時處理用戶頁表和內核頁表,結果這種設計到最后調不下去了。原因在於內核頁表實際上就是對用戶頁表的一個引用,所以直接用一個淺拷貝函數kvmcopy就可以輕松直接地完成大量操作,再加上一個解除綁定的kvmdealloc,這種設計就非常簡潔且容易調試,但是需要思考。這也說明架構和設計極其重要。