xv6原始碼分析(四):記憶體管理
xv6通過頁表機制實現了對記憶體空間的控制。頁表使得 xv6 能夠讓不同程序各自的地址空間對映到相同的實體記憶體上,還能夠為不同程序的記憶體提供保護。 除此之外,我們還能夠通過使用頁表來間接地實現一些特殊功能。xv6 主要利用頁表來區分多個地址空間,保護記憶體。另外,它也使用了一些簡單的技巧,即把不同地址空間的多段記憶體對映到同一段實體記憶體(核心部分),在同一地址空間中多次對映同一段實體記憶體(使用者部分的每一頁都會對映到核心部分),以及通過一個沒有對映的頁保護使用者棧。
預備知識
前面已經說過,在x86體系下,有三種地址,程式中各種符號地址為虛擬地址,CPU實際訪問的記憶體地址為實體地址,x86體系下三種地址有以下關係:
虛擬地址-------->線性地址-------->實體地址
分段 分頁
在xv6中,除了每個CPU獨立的資料具有非零的段基址外,其餘包括核心資料段,核心程式碼段,使用者資料段,使用者程式碼段都是段基址為0的描述符,這樣大大簡化了地址轉換操作和程式的程式設計,因為分段機制是由程式設計師控制,而分頁機制是由作業系統負責的,在xv6中,可以簡化為以下地址對映:
虛擬地址-------->實體地址
分頁
分頁機制是通過頁表來進行轉換的,具體轉換關係如圖:
x86中所有的虛擬地址都經過頁表來完成地址轉換,頁表由cr3暫存器指定的實體地址來表示,記憶體地址轉換單元mmu通過查詢頁表來確定最後的實體地址,通過給cr3賦值便能實現不同程序擁有不同的頁表,也就是不同程序擁有不同的地址空間。
頁表由一級的頁目錄項和二級的頁表項組成,每個頁目錄項下級有1024個連續的頁表項(每個頁表項4Byte,剛好佔用4K空間,也就是一頁),頁目錄項同時也是連續的,一共有1024個頁目錄項,由於32位系統地址線只有32位,所以最高支援4G的地址空間,頁目錄項也是連續的,所以頁目錄剛好也佔用一頁。
每個頁目錄項和頁表項由下一級的實體地址和相關標誌位組成,頁目錄項擁有下一級頁表的實體地址,頁表項擁有實際實體地址的部分,通過設定許可權位可以實現核心和使用者程序程式碼和資料的保護。
具體轉換過程如圖所示,虛擬地址前十位作為頁目錄的偏移找到頁目錄項,找到頁表基地址,接下來十位作為頁表項的偏移找到頁表項,最後將頁表項的基址+最後12位偏移得到實際的實體地址。
xv6記憶體初始化
在初始化main函式最開始處,xv6具有以下的記憶體佈局:
核心程式碼存在於實體地址低地址的0x100000處,頁表為main.c檔案中的entrypgdir陣列,其中虛擬地址低4M對映實體地址低4M,虛擬地址 [KERNBASE, KERNBASE+4MB) 對映到 實體地址[0, 4MB)。可見現在核心實際能用的虛擬地址空間顯然是不足以完成正常工作的,所以初始化過程中需要重新設定頁表。
1.實體記憶體的初始化
xv6在main函式中呼叫kinit1和kinit2來初始化實體記憶體,kinit1初始化核心末尾到實體記憶體4M的實體記憶體空間為未使用
kinit1(end, P2V(4*1024*1024)); // phys page allocator
kinit2初始化剩餘核心空間到PHYSTOP為未使用
kinit2(P2V(4*1024*1024), P2V(PHYSTOP)); // must come after startothers()
兩者的區別在於kinit1呼叫前使用的還是最初的頁表(也就是是上面的記憶體佈局),所以只能初始化4M,同時由於後期再構建新頁表時也要使用頁錶轉換機制來找到實際存放頁表的實體記憶體空間,這就構成了自舉問題,xv6通過在main函式最開始處釋放核心末尾到4Mb的空間來分配頁表,由於在最開始時多核CPU還未啟動,所以沒有設定鎖機制。
initlock(&kmem.lock, "kmem");
kmem.use_lock = 0;
freerange(vstart, vend);
kinit2在核心構建了新頁表後,能夠完全訪問核心的虛擬地址空間,所以在這裡初始化所有實體記憶體,並開始了鎖機制保護空閒記憶體連結串列。
void
kinit2(void *vstart, void *vend)
{
freerange(vstart, vend);
kmem.use_lock = 1;
}
2.核心新頁表初始化
main函式通過呼叫kvmalloc函式來實現核心新頁表的初始化
pde_t *kpgdir; // for use in scheduler()
void
kvmalloc(void)
{
kpgdir = setupkvm();
switchkvm();
}
通過初始化,最後記憶體佈局和地址空間如下:
核心末尾實體地址到實體地址PHYSTOP的記憶體空間未使用
虛擬地址空間KERNBASE以上部分對映到實體記憶體低地址相應位置
// This table defines the kernel's mappings, which are present in
// every process's page table.
static struct kmap {
void *virt;
uint phys_start;
uint phys_end;
int perm;
} kmap[] = {
{ (void*)KERNBASE, 0, EXTMEM, PTE_W}, // I/O space
{ (void*)KERNLINK, V2P(KERNLINK), V2P(data), 0}, // kern text+rodata
{ (void*)data, V2P(data), PHYSTOP, PTE_W}, // kern data+memory
{ (void*)DEVSPACE, DEVSPACE, 0, PTE_W}, // more devices
};
實體記憶體管理
xv6對上層提供kalloc和kfree介面來管理實體記憶體,上層無需知道具體的細節,kalloc返回虛擬地址空間的地址,kfree以虛擬地址為引數,通過kalloc和kfree能夠有效管理實體記憶體,讓上層只需要考慮虛擬地址空間。
xv6通過將未分配的記憶體構成一個簡單的連結串列來管理實體記憶體,具體的連結串列結構如下:
struct run {
struct run *next;
};
struct {
struct spinlock lock;
int use_lock;
struct run *freelist;
} kmem;
剛開始對於run結構的使用我很迷惑,其實xv6使用了空閒記憶體的前部分作為指標域來指向下一頁空閒記憶體,實體記憶體管理是以頁(4K)為單位進行分配的。也就是說實體記憶體空間上空閒的每一頁,都有一個指標域(虛擬地址)指向下一個空閒頁,最後一個空閒頁為NULL
通過這種方式,只需要儲存著虛擬地址空間上的freelist地址即可,kalloc和kfree操作的地址都是虛擬地址,那麼為什麼就能夠準確找到每一物理頁的位置呢,這是還是通過頁表來完成的,仔細看核心對映佈局
{ (void*)data, V2P(data), PHYSTOP, PTE_W}, // kern data+memory
由此可知 VA’s [KERNBASE, KERNBASE+PHYSTOP) to PA’s [0, PHYSTOP),
所以可以想成就算kalloc和kfree操作的是虛擬地址,但是也能夠總是準確找到每一頁的位置,其實也不用關心kalloc和kfree是如何組織空閒頁的,只要保證正確並能供上層呼叫即可,上層無需關心是如何實現的。上層呼叫的只需要想著“虛擬地址a對應的一頁釋放為空閒頁”“分配一頁返回虛擬地址給我”即可
有了上面的基礎,很容易看懂kalloc和kfree的程式碼實現了
void
freerange(void *vstart, void *vend)
{
char *p;
p = (char*)PGROUNDUP((uint)vstart);
for(; p + PGSIZE <= (char*)vend; p += PGSIZE)
kfree(p);
}
void
kfree(char *v)
{
struct run *r;
if((uint)v % PGSIZE || v < end || V2P(v) >= PHYSTOP)
panic("kfree");
// Fill with junk to catch dangling refs.
memset(v, 1, PGSIZE);
if(kmem.use_lock)
acquire(&kmem.lock);
r = (struct run*)v;
r->next = kmem.freelist;
kmem.freelist = r;
if(kmem.use_lock)
release(&kmem.lock);
}
char*
kalloc(void)
{
struct run *r;
if(kmem.use_lock)
acquire(&kmem.lock);
r = kmem.freelist;
if(r)
kmem.freelist = r->next;
if(kmem.use_lock)
release(&kmem.lock);
return (char*)r;
}
通過kalloc和kfree,遮蔽了對實體記憶體的管理,使得呼叫者只需要關心虛擬地址空間,在需要使用新記憶體空間的時候呼叫kalloc,在需要釋放記憶體空間的時候呼叫kfree
xv6記憶體管理函式
xv6通過提供幾個介面來實現核心頁表的控制和使用者頁表的控制,xv6讓每個程序都有獨立的頁表結構,在切換程序時總是需要切換頁表,切換頁表的介面如下:
// Switch h/w page table register to the kernel-only page table,
// for when no process is running.
void
switchkvm(void)
{
lcr3(V2P(kpgdir)); // switch to the kernel page table
}
// Switch TSS and h/w page table to correspond to process p.
void
switchuvm(struct proc *p)
{
pushcli();
cpu->gdt[SEG_TSS] = SEG16(STS_T32A, &cpu->ts, sizeof(cpu->ts)-1, 0);
cpu->gdt[SEG_TSS].s = 0;
cpu->ts.ss0 = SEG_KDATA << 3;
cpu->ts.esp0 = (uint)proc->kstack + KSTACKSIZE;
// setting IOPL=0 in eflags *and* iomb beyond the tss segment limit
// forbids I/O instructions (e.g., inb and outb) from user space
cpu->ts.iomb = (ushort) 0xFFFF;
ltr(SEG_TSS << 3);
if(p->pgdir == 0)
panic("switchuvm: no pgdir");
lcr3(V2P(p->pgdir)); // switch to process's address space
popcli();
}
switchkvm簡單地將kpgdir設定為cr3暫存器的值,這個頁表僅僅在 scheduler核心執行緒中使用。
頁表和核心棧都是每個程序獨有的,xv6使用結構體proc將它們統一起來,在程序切換的時候,他們也往往隨著程序切換而切換,核心中模擬出了一個核心執行緒,它獨佔核心棧和核心頁表kpgdir,它是所有程序排程的基礎。
switchuvm通過傳入的proc結構負責切換相關的程序獨有的資料結構,其中包括TSS相關的操作,然後將程序特有的頁表載入cr3暫存器,完成設定程序相關的虛擬地址空間環境。
程序的頁表在使用前往往需要初始化,其中必須包含核心程式碼的對映,這樣程序在進入核心時便不需要再次切換頁表,程序使用虛擬地址空間的低地址部分,高地址部分留給核心,設定頁表時通過呼叫setupkvm、allocuvm、deallocuvm介面完成相關操作
setupkvm通過kalloc分配一頁記憶體作為頁目錄,然後將按照kmap資料結構對映核心虛擬地址空間到實體地址空間。期間呼叫了工具函式mappages,mappages的具體實現下文再解釋。
// Set up kernel part of a page table.
pde_t*
setupkvm(void)
{
pde_t *pgdir;
struct kmap *k;
if((pgdir = (pde_t*)kalloc()) == 0)
return 0;
memset(pgdir, 0, PGSIZE);
if (P2V(PHYSTOP) > (void*)DEVSPACE)
panic("PHYSTOP too high");
for(k = kmap; k < &kmap[NELEM(kmap)]; k++)
if(mappages(pgdir, k->virt, k->phys_end - k->phys_start,
(uint)k->phys_start, k->perm) < 0)
return 0;
return pgdir;
}
allocuvm、deallocuvm負責完成使用者程序的記憶體空間,allocuvm在設定頁表的同時還會分配實體記憶體供使用者程序使用allocuvm中的引數oldsz,newsz我沒怎麼搞懂為什麼要這樣命名,但是意思就是分配虛擬地址oldsz到newsz的以頁為單位的記憶體,deallocuvm則相反,它將newsz到oldsz對應的虛擬地址空間記憶體置為空閒。
int
allocuvm(pde_t *pgdir, uint oldsz, uint newsz)
{
char *mem;
uint a;
if(newsz >= KERNBASE)
return 0;
if(newsz < oldsz)
return oldsz;
a = PGROUNDUP(oldsz);
for(; a < newsz; a += PGSIZE){
mem = kalloc();
if(mem == 0){
cprintf("allocuvm out of memory\n");
deallocuvm(pgdir, newsz, oldsz);
return 0;
}
memset(mem, 0, PGSIZE);
if(mappages(pgdir, (char*)a, PGSIZE, V2P(mem), PTE_W|PTE_U) < 0){
cprintf("allocuvm out of memory (2)\n");
deallocuvm(pgdir, newsz, oldsz);
kfree(mem);
return 0;
}
}
return newsz;
}
int
deallocuvm(pde_t *pgdir, uint oldsz, uint newsz)
{
pte_t *pte;
uint a, pa;
if(newsz >= oldsz)
return oldsz;
a = PGROUNDUP(newsz);
for(; a < oldsz; a += PGSIZE){
pte = walkpgdir(pgdir, (char*)a, 0);
if(!pte)
a += (NPTENTRIES - 1) * PGSIZE;
else if((*pte & PTE_P) != 0){
pa = PTE_ADDR(*pte);
if(pa == 0)
panic("kfree");
char *v = P2V(pa);
kfree(v);
*pte = 0;
}
}
return newsz;
}
到了這裡,xv6記憶體管理提供的主要介面也差不多了,兩個切換介面,一個設定核心頁表的介面,兩個為使用者程序管理記憶體,設定使用者內碼表表的介面,xv6 vm.c檔案中還提供了loaduvm將檔案系統上的i節點內容讀取載入到相應的地址上,通過allocuvm介面為使用者程序分配記憶體和設定頁表,然後呼叫loaduvm介面將檔案系統上的程式載入到記憶體,便能夠為exec系統呼叫提供介面,為使用者程序的正式執行做準備。
// Load a program segment into pgdir. addr must be page-aligned
// and the pages from addr to addr+sz must already be mapped.
int
loaduvm(pde_t *pgdir, char *addr, struct inode *ip, uint offset, uint sz)
{
uint i, pa, n;
pte_t *pte;
if((uint) addr % PGSIZE != 0)
panic("loaduvm: addr must be page aligned");
for(i = 0; i < sz; i += PGSIZE){
if((pte = walkpgdir(pgdir, addr+i, 0)) == 0)
panic("loaduvm: address should exist");
pa = PTE_ADDR(*pte);
if(sz - i < PGSIZE)
n = sz - i;
else
n = PGSIZE;
if(readi(ip, P2V(pa), offset+i, n) != n)
return -1;
}
return 0;
}
vm.C中還有一個inituvm函式,為第一個程序所使用,通過呼叫它能夠初始化虛擬地址為0的initcode.S的虛擬地址環境,initcode.S是獨立於核心編譯和連結的,它的載入地址和執行地址都為0。
// Load the initcode into address 0 of pgdir.
// sz must be less than a page.
void
inituvm(pde_t *pgdir, char *init, uint sz)
{
char *mem;
if(sz >= PGSIZE)
panic("inituvm: more than a page");
mem = kalloc();
memset(mem, 0, PGSIZE);
mappages(pgdir, 0, PGSIZE, V2P(mem), PTE_W|PTE_U);
memmove(mem, init, sz);
}
當程序銷燬需要回收記憶體時,可以呼叫freevm清除使用者程序相關的記憶體環境,freevm首先呼叫deallocuvm將0到KERNBASE的虛擬地址空間回收,然後銷燬整個程序的頁表
// Free a page table and all the physical memory pages
// in the user part.
void
freevm(pde_t *pgdir)
{
uint i;
if(pgdir == 0)
panic("freevm: no pgdir");
deallocuvm(pgdir, KERNBASE, 0);
for(i = 0; i < NPDENTRIES; i++){
if(pgdir[i] & PTE_P){
char * v = P2V(PTE_ADDR(pgdir[i]));
kfree(v);
}
}
kfree((char*)pgdir);
}
在vm.c中,copyuvm負責複製一個新的頁表並分配新的記憶體,新的記憶體佈局和舊的完全一樣,xv6使用這個函式作為fork的底層實現
// Given a parent process's page table, create a copy
// of it for a child.
pde_t*
copyuvm(pde_t *pgdir, uint sz)
{
pde_t *d;
pte_t *pte;
uint pa, i, flags;
char *mem;
if((d = setupkvm()) == 0)
return 0;
for(i = 0; i < sz; i += PGSIZE){
if((pte = walkpgdir(pgdir, (void *) i, 0)) == 0)
panic("copyuvm: pte should exist");
if(!(*pte & PTE_P))
panic("copyuvm: page not present");
pa = PTE_ADDR(*pte);
flags = PTE_FLAGS(*pte);
if((mem = kalloc()) == 0)
goto bad;
memmove(mem, (char*)P2V(pa), PGSIZE);
if(mappages(d, (void*)i, PGSIZE, V2P(mem), flags) < 0)
goto bad;
}
return d;
bad:
freevm(d);
return 0;
}
在vm.c的最後,還有兩個函式,不過我還不清楚它有什麼作用,其中uva2ka將一個使用者地址轉化為核心地址,也就是通過使用者地址找到對應的實體地址,然後退出這個實體地址在核心頁表中的虛擬地址並返回,copyout則呼叫uva2ka則拷貝p地址len位元組到使用者地址va中,這兩個函式我不清楚到底是被誰呼叫的,先寫在這裡後面補充。
// Map user virtual address to kernel address.
char*
uva2ka(pde_t *pgdir, char *uva)
{
pte_t *pte;
pte = walkpgdir(pgdir, uva, 0);
if((*pte & PTE_P) == 0)
return 0;
if((*pte & PTE_U) == 0)
return 0;
return (char*)P2V(PTE_ADDR(*pte));
}
// Copy len bytes from p to user address va in page table pgdir.
// Most useful when pgdir is not the current page table.
// uva2ka ensures this only works for PTE_U pages.
int
copyout(pde_t *pgdir, uint va, void *p, uint len)
{
char *buf, *pa0;
uint n, va0;
buf = (char*)p;
while(len > 0){
va0 = (uint)PGROUNDDOWN(va);
pa0 = uva2ka(pgdir, (char*)va0);
if(pa0 == 0)
return -1;
n = PGSIZE - (va - va0);
if(n > len)
n = len;
memmove(pa0 + (va - va0), buf, n);
len -= n;
buf += n;
va = va0 + PGSIZE;
}
return 0;
}
總結
至此,xv6記憶體管理內容大致瞭解了,xv6對於實體記憶體的管理較為簡單,只是將每一空閒頁用連結串列連結起來,向上提供kalloc和kfree介面來遮蔽管理實體記憶體的細節,xv6將記憶體管理分為核心地址空間管理和使用者地址空間管理,並提供幾個函式供系統呼叫過程呼叫,很多需要管理記憶體的系統函式例如exec,fork都需要使用到這些介面,vm.c和kalloc.c包含了記憶體管理的大部分內容,系統呼叫過程使用這些函式來初始化和處理頁表結構。