xv6 内存管理
前文講述了 xv6 的啟動過程,本文接著講述 xv6 內(nèi)存管理的部分,直接來看。
- 公眾號:Rand_cs
啟動部分完善
前文只是介紹了啟動的過程,但是各類函數(shù)之間的調(diào)用,地址的變換,內(nèi)存布局的變化并沒有詳細(xì)說明明,本節(jié)來完善。
BIOS
還是從 BIOS 開始,入口點是 0xffff00xffff00xffff0,是一跳轉(zhuǎn)指令 jmpf000:e05bjmp \ \ f000:e05bjmp??f000:e05b,然后開始執(zhí)行 BIOS 的代碼,內(nèi)存低 1M 的頂部 64KB 都是分配給 BIOS 的,所以此時內(nèi)存布局為:
bootblock
xv6 沒有實際的 MBR,bootasm.S 和 bootmain.c 兩文件聯(lián)合在一起編譯成二進制文件 bootblock 放在磁盤最開始的那個扇區(qū),然后被 BIOS 加載到 0x7c000x7c000x7c00 處,從 0x7c000x7c000x7c00 處開始執(zhí)行。
此時內(nèi)存布局為:
bootmain.c
bootmain 加載內(nèi)核,來看看是怎么加載的,加載到哪兒。
elf = (struct elfhdr*)0x10000; readseg((uchar*)elf, 4096, 0); //從磁盤讀4096字節(jié)到物理地址 0x10000這里 readseg 函數(shù)的意思是從磁盤的 1 扇區(qū)讀取 4096字節(jié)到物理地址 0x10000 處。內(nèi)核文件在磁盤的扇區(qū) 1 ,注意這里雖然參數(shù)傳的是 0,但是函數(shù)內(nèi)部加了 1,所以是從扇區(qū) 1 讀取的。這個函數(shù)后面講述磁盤再詳述,這里知道作用就行。
0x10000 有什么意義?再來看一眼內(nèi)存低 1M 的布局圖:
所以沒什么特殊意義,就是找了一塊空閑地兒,來存放內(nèi)核的開始的 4096 字節(jié)。
那這 4096 字節(jié)有什么用?這就加載內(nèi)核了?當(dāng)然不是,xv6 的內(nèi)核有 200 多 KB,開始的 4096 字節(jié)只是包括了 elf 文件的一些頭部信息:
這是從我虛擬機上截的圖,使用 readelf -h kernel 命令來查看內(nèi)核的 elf 頭信息,從截圖上可知程序頭的相對 elf 文件開始的偏移量為 52 字節(jié),有 3 個程序頭,每個 32 字節(jié),所以這 4096 字節(jié)至少包括內(nèi)核的 elf 頭和程序頭表,而這是我們加載內(nèi)核正需要的信息。
此時內(nèi)存中的布局:
運行 bootmain.c 的時候是將 0x7c00 以下作為棧使用,根據(jù)內(nèi)存低 1M 布局圖可以看出,0x7c00 以下有大約 30K 的空閑空間可用,這段代碼很少,棧空間用不了多少,30K 太足夠了,不會有什么問題。
下面就開始正式加載內(nèi)核了,加載到哪兒是一個問題,這就需要程序頭中記載的信息了:
ph = (struct proghdr*)((uchar*)elf + elf->phoff); //第一個程序段的位置eph = ph + elf->phnum;for(; ph < eph; ph++){pa = (uchar*)ph->paddr;readseg(pa, ph->filesz, ph->off); //從ph->off所在的扇區(qū)讀取ph->filesz字節(jié)到物理地址paif(ph->memsz > ph->filesz)stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz); //調(diào)用 stosb 將段的剩余部分置零上下結(jié)合來看得知將內(nèi)核加載到了物理地址的 0x100000 處。
此時的內(nèi)存布局:
end 為內(nèi)核末尾地址,不同版本有稍許不同,可以在 kernel.sym 文件中查找,也可以直接讀取 elf 的程序頭,根據(jù) PhysAddr+MemSizePhysAddr + MemSizePhysAddr+MemSize 計算出來。
前面都是在未開啟分頁機制下運行,涉及到的地址都是實際的物理地址,從 bootmain.c 中 跳到 entry.S 就開啟分頁機制,分頁必然要建立頁表,涉及到內(nèi)存管理,下面一一來看:
臨時頁表
xv6 在啟動的時候建立了一個臨時頁表,在 main.c 文件的最后部分:
pde_t entrypgdir[NPDENTRIES] = {// 將虛擬地址的[0,4M)映射到物理地址[0,4M)[0] = (0) | PTE_P | PTE_W | PTE_PS,// 將虛擬地址[800 0000,800 0000+40 0000)映射到[0,4M)[KERNBASE>>PDXSHIFT] = (0) | PTE_P | PTE_W | PTE_PS, };xv6 定義虛擬地址 0x800 0000 以上為內(nèi)核部分,虛擬地址空間和物理地址空間具體怎么映射的后面建立正式的頁表時候再說。
為啥要將虛擬地址不同的兩部分映射到相同的物理地址?這需要看 entrypgdir 用在什么地方,entrypgdir 定義在 main.c 中,用在 entry.S 文件中。啟動那篇文章說過,entry.S 主要就是開啟分頁機制。本身代碼是在物理地址低 4M 內(nèi),必須保證分頁機制前的線性地址與分頁機制的虛擬地址對應(yīng)的物理地址一致,也就是必須使開啟分頁機制和跳到高地址之間的指令能夠正確執(zhí)行。
頁面大小擴展
entry.S 代碼里面有這么幾句指令:
# Turn on page size extension for 4Mbyte pages # 開啟頁面大小擴展,每頁 4 M movl %cr4, %eax orl $(CR4_PSE), %eax movl %eax, %cr4將 CR4 寄存器的 PSE 位置 1,以及設(shè)置頁目錄項的 PS 位,便可以設(shè)置每頁的大小為 4M,但是此時對虛擬地址的解析有了變化,如果使用二級頁表的話,我們是將虛擬地址的高 10 位作為頁目錄的索引,得到一級頁表的物理地址,將中 10 位作為頁表的索引,得到頁框的物理地址,再加上后面 12 位的偏移地址得到最終目標(biāo)的物理地址。示意圖如下:
如果是使用一級頁表的話,將虛擬地址的前 20 位作為頁表的索引,得到頁框的物理地址,加上后面 12 位的索引得到最終目標(biāo)的物理地址,示意圖如下:
但如果是開啟頁面大小擴展,有點類似與一級頁表,但又有所不同,它是將虛擬地址的高 10 位作為頁表的索引,得到頁框的物理地址,加上低 22 位的偏移量得到最終目標(biāo)的物理地址,示意圖如下:
所以這就解釋了為什么 entrypgdir 簡簡單單的兩項,兩條語句就映射了 4M 的地址空間。那為什么要使用頁面大小擴展呢?我合理的猜測下:就是簡單方便,語句少,想想如果使用二級頁表,頁面大小不進行擴展只有 4K 的情況要怎么映射,兩部分地址空間,得有兩個頁目錄項,對應(yīng)兩個一級頁表,4M 有 1024 個 4K,得有 1024 個頁表項。雖然 4M 沒有全用,不用全映射,但是總的來說使用頁面大小擴展之后更加簡單方便。
內(nèi)存管理
建立正式頁表之前先來看看 xv6 是如何對內(nèi)存進行組織管理的,任何一個操作系統(tǒng)都需要對內(nèi)存進行管理,將內(nèi)存以某種方式組織起來,用的時候可以分配,不再使用的時候回收。組織方式常見的有鏈?zhǔn)胶臀粓D,xv6 里面是用鏈表的形式將空閑空間給組織起來,相關(guān)代碼在 kalloc.c 文件中,我們來具體分析一下:
首先定義了兩個結(jié)構(gòu)體:
struct run {struct run *next; };struct {struct spinlock lock;int use_lock;struct run *freelist; } kmem;這兩個結(jié)構(gòu)體什么意思,有什么用?看個圖就明白了:
所以 kmem 就像個內(nèi)存分配器,這個 freelist 就是這片空閑頁鏈表的鏈頭,分配內(nèi)存的時候就將它先分配出去,然后每頁里面有一個指針,指向下一個空閑頁。有了這個了解之后來看具體的實現(xiàn)代碼:
char* kalloc(void) {struct run *r; //聲明run結(jié)構(gòu)體指針if(kmem.use_lock) //加鎖acquire(&kmem.lock);r = kmem.freelist; //第一個空閑頁地址賦給rif(r)kmem.freelist = r->next; //鏈頭移動到下一頁,相當(dāng)于把鏈頭給分配出去了if(kmem.use_lock) //釋放鎖release(&kmem.lock);return (char*)r; //返回第一個空閑頁的地址 }代碼很簡單,就是加鎖,取鏈頭地址,鏈頭移到下一個空閑頁,釋放鎖,返回取到的鏈頭地址。
void kfree(char *v) //釋放頁v {struct run *r;//這個頁應(yīng)該在這些范圍內(nèi)且邊界為4K的倍數(shù)if((uint)v % PGSIZE || v < end || V2P(v) >= PHYSTOP) panic("kfree");// Fill with junk to catch dangling refs.memset(v, 1, PGSIZE); //將這個頁填充無用信息,全置為1if(kmem.use_lock) //取鎖acquire(&kmem.lock);r = (struct run*)v; //頭插法將這個頁放在鏈?zhǔn)?/span>r->next = kmem.freelist;kmem.freelist = r; if(kmem.use_lock) //釋放鎖release(&kmem.lock); }基本上是 kalloc 的逆操作,先檢查要釋放的頁合理與否,然后填充無效信息,再取鎖,使用頭插法將這個頁放在鏈?zhǔn)?#xff0c;釋放鎖。從這看出這應(yīng)該是用的頭插法。
void freerange(void *vstart, void *vend) //連續(xù)釋放vstart到vend之間的頁 {char *p;p = (char*)PGROUNDUP((uint)vstart);for(; p + PGSIZE <= (char*)vend; p += PGSIZE)kfree(p); }還有兩個函數(shù) kinit1,kinit2 是上述 freerange 函數(shù)的封裝:
void kinit1(void *vstart, void *vend) //kinit1(end, P2V(4*1024*1024)); {initlock(&kmem.lock, "kmem");kmem.use_lock = 0;freerange(vstart, vend); }void kinit2(void *vstart, void *vend) //kinit2(P2V(4*1024*1024), P2V(PHYSTOP)); {freerange(vstart, vend);kmem.use_lock = 1; }它倆是在 main.c 的 main() 函數(shù)中被調(diào)用,調(diào)用的參數(shù)也已經(jīng)注釋在后邊。調(diào)用這兩個函數(shù)就是初始化內(nèi)存,將內(nèi)存一頁一頁的使用頭插法鏈在一起。
內(nèi)核頁表
要了解其他幾個參數(shù)還需要先來了解 xv6 的虛擬地址空間和實際的物理地址空間的映射關(guān)系,這也有相應(yīng)的結(jié)構(gòu)體表示:
#define EXTMEM 0x100000 // Start of extended memory #define PHYSTOP 0xE000000 // Top physical memory#define DEVSPACE 0xFE000000 // 一些設(shè)備的地址,比如apic的一些寄存器// Key addresses for address space layout (see kmap in vm.c for layout) #define KERNBASE 0x80000000 // 內(nèi)核的起始虛擬地址 #define KERNLINK (KERNBASE+EXTMEM) // 內(nèi)核文件的鏈接地址#define V2P(a) (((uint) (a)) - KERNBASE) //內(nèi)核虛擬地址轉(zhuǎn)物理地址 #define P2V(a) ((void *)(((char *) (a)) + KERNBASE)) //物理地址轉(zhuǎn)內(nèi)核虛擬地址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 };上面那一坨就是說明虛擬地址空間內(nèi)核部分到物理內(nèi)存的映射關(guān)系,看起來可能很麻雜,做了一張表格和圖:
所以從這張圖可以看出,內(nèi)核部分的虛擬地址空間和物理地址空間就是一一對應(yīng)的,只是相差了 0x8000 0000,所以這就是為什么簡單的宏 V2P,P2V 就可以實現(xiàn)虛擬地址物理地址之間的轉(zhuǎn)換,當(dāng)然這只是內(nèi)核部分才行。用戶態(tài)部分的我們還沒有涉及,用戶態(tài)下的虛擬地址到物理地址之間的轉(zhuǎn)換就必須要使用頁表了,相關(guān)部分在進程我們再詳述。
再者也可以看出 xv6 并沒有使用全部的 4G 地址空間,有很大一部分都沒有使用,除開這部分所有的物理內(nèi)存實際都映射到內(nèi)核中去了,那用戶部分呢?用戶部分是通過頁表映射到了物理地址空間的空閑部分,這部分物理地址空間又可以通過 P2V 映射到內(nèi)核部分去,是不是很繞,后面講述進程的時候慢慢說這部分。
另外關(guān)于設(shè)備部分是直接映射的,是真的一一對應(yīng),虛擬地址和物理地址一樣,這部分地址空間是分配給一些設(shè)別的,比如 APIC 的一些寄存器,詳見:
實現(xiàn)上述的映射得建立相應(yīng)的頁表,來看相關(guān)代碼:
#define PDX(va) (((uint)(va) >> PDXSHIFT) & 0x3FF) //高10位 #define PTX(va) (((uint)(va) >> PTXSHIFT) & 0x3FF) //中10位 #define PGADDR(d, t, o) ((uint)((d) << PDXSHIFT | (t) << PTXSHIFT | (o))) //d為高10位,t為中10位,o為低12位,將他們組合成虛擬地址static pte_t * walkpgdir(pde_t *pgdir, const void *va, int alloc) //根據(jù)虛擬地址 va 返回相應(yīng)的頁表項地址 {pde_t *pde; //頁目錄項地址pte_t *pgtab; //一級頁表地址pde = &pgdir[PDX(va)]; //va取高12位->頁目錄項if(*pde & PTE_P){ //若一級頁表存在pgtab = (pte_t*)P2V(PTE_ADDR(*pde)); //取一級頁表的物理地址,轉(zhuǎn)化成虛擬地址} else {if(!alloc || (pgtab = (pte_t*)kalloc()) == 0) //否則分配一頁出來做頁表return 0;// Make sure all those PTE_P bits are zero.memset(pgtab, 0, PGSIZE); //初始化置0*pde = V2P(pgtab) | PTE_P | PTE_W | PTE_U; //將新分配出來的以及頁表記錄在頁目錄中}return &pgtab[PTX(va)]; //va取中10位->頁表項 } static int mappages(pde_t *pgdir, void *va, uint size, uint pa, int perm) {char *a, *last;pte_t *pte;a = (char*)PGROUNDDOWN((uint)va); //虛擬地址va以4K為單位的下邊界last = (char*)PGROUNDDOWN(((uint)va) + size - 1); //偏移量,所以減1for(;;){if((pte = walkpgdir(pgdir, a, 1)) == 0) //獲取地址a的頁表項地址return -1;if(*pte & PTE_P) //如果該頁本來就存在panic("remap");*pte = pa | perm | PTE_P; //填寫地址a相應(yīng)的頁表項if(a == last) //映射完了退出循環(huán)break;a += PGSIZE;pa += PGSIZE;}return 0; }mappages 映射虛擬地址 va 到物理地址 pa,映射大小為 size,實現(xiàn)方式將相應(yīng)的頁表項填進 pgdir 指向的頁表中去。總的來說分為兩步,調(diào)用 walkpgdir 獲取虛擬地址相應(yīng)的頁表項,然后將物理地址屬性位填進這個頁表項。這就是映射一頁的操作,重復(fù)這個操作映射從 va 開始的 size 大小區(qū)域。
現(xiàn)在有了內(nèi)核映射的要求和實現(xiàn)方法,可以建立內(nèi)核正式的頁表了:
#define NELEM(x) (sizeof(x)/sizeof((x)[0])) //x有多少項 pde_t* setupkvm(void) //建立內(nèi)核頁表 {pde_t *pgdir;struct kmap *k;if((pgdir = (pde_t*)kalloc()) == 0) //分配一頁作為頁目錄表return 0; memset(pgdir, 0, PGSIZE); //頁目錄表置0if (P2V(PHYSTOP) > (void*)DEVSPACE) //PHYSTOP的地址不能高于DEVSPACEpanic("PHYSTOP too high");for(k = kmap; k < &kmap[NELEM(kmap)]; k++) //映射4項,循環(huán)4次if(mappages(pgdir, k->virt, k->phys_end - k->phys_start, (uint)k->phys_start, k->perm) < 0) {freevm(pgdir);return 0;}return pgdir; }setupkvm() 相當(dāng)于 mappages() 的封裝,它循環(huán)四次,將 kmap 給出的信息當(dāng)作參數(shù)傳給 mappages,映射相應(yīng)的地址空間。
注意 kmap 最后一項的 phys_end 為0,kmap 結(jié)構(gòu)體中聲明的物理地址都是無符號數(shù),所以最后一項k->phys_end - k->phys_start,如此計算也是沒有問題的,對于數(shù)值問題有疑惑的請看我這篇文章:
建好頁表就該切換頁表,就是將頁表的及地址賦給 CR3,看下面對 setupkvm() 封裝的函數(shù):
pde_t *kpgdir; void kvmalloc(void) {kpgdir = setupkvm(); //建立頁表switchkvm(); //切換頁表 } void switchkvm(void) {lcr3(V2P(kpgdir)); //加載內(nèi)核頁表到cr3寄存器,cr3存放的是頁目錄物理地址 }kpgdir 是個全局變量,為內(nèi)核頁表的地址,kvmalloc() 調(diào)用 setupkvm() 建立頁表,返回的頁表地址賦給 kpgdir,然后調(diào)用 switchkvm() 切換成內(nèi)核頁表,也就是將 kpgdir 的物理地址加載到 CR3 寄存器。
頁表的事完成之后,內(nèi)核完全運行在高地址之上了,相應(yīng)的一些結(jié)構(gòu)的地址也得切換到高地址上面去,比如說 GDTR 中存放的 GDT 地址和界限。最開始的 GDT 是在 bootasm.S 文件建立的,放在物理地址值的低 1M,后來分頁機制開啟之后使用的臨時頁表,映射了虛擬地址空間低 4M 和 內(nèi)核之上的低 4M 到物理地址空間的低 4M,所以 GDTR 中的地址沒問題,CPU 能夠找到 GDT。但是切換成正式頁表之后不再映射虛擬地址空間的低地址部分,低地址部分是給用戶態(tài)用的,內(nèi)核都處于高地址,所以 GDTR 中的地址不再有效。況且 GDT 還需要重新建立正式的 GDT,所以有了如下的 seginit():
void seginit(void) //設(shè)置內(nèi)核用戶的代碼段和數(shù)據(jù)段 {struct cpu *c;c = &cpus[cpuid()]; //獲取當(dāng)前CPU//建立段描述符,內(nèi)核態(tài)用戶態(tài)的代碼段和數(shù)據(jù)段 c->gdt[SEG_KCODE] = SEG(STA_X|STA_R, 0, 0xffffffff, 0);c->gdt[SEG_KDATA] = SEG(STA_W, 0, 0xffffffff, 0);c->gdt[SEG_UCODE] = SEG(STA_X|STA_R, 0, 0xffffffff, DPL_USER);c->gdt[SEG_UDATA] = SEG(STA_W, 0, 0xffffffff, DPL_USER);lgdt(c->gdt, sizeof(c->gdt)); //加載到GDTR }每個 CPU 有自己的結(jié)構(gòu),cpus 這個結(jié)構(gòu)體數(shù)組本身位于內(nèi)核,內(nèi)核現(xiàn)已運行在高地址,GDT 放在 CPU 結(jié)構(gòu)體中,那么也就相當(dāng)于放在了高地址上。設(shè)置好段描述符,建立好 GDT 之后,便將 GDT 的新地址和界限寫進 GDTR 寄存器中去。
上述講述了內(nèi)核頁表的過程,有了這全局的認(rèn)識之后,來解決上述遺留的一些問題:
- 為什么要分兩次初始化內(nèi)存:kinit1() 和 kinit2()
- 為什么 kinit2() 必須在 startothers() 之后
解決這兩個問題,我們要來看看 xv6 的設(shè)計思路,當(dāng)然只是看和內(nèi)存相關(guān)比較緊密的部分:
最開始內(nèi)核加載到物理地址 0x10 0000 處,xv6 內(nèi)核很小,整個內(nèi)核只有 200 多 K。內(nèi)核一開始就先運行 entry.S 的代碼,開啟分頁機制,分頁當(dāng)然得有頁表,為簡單方便將頁面大小擴展到了 4M,制作了一個啟動時用的臨時頁表,映射了低 4M 的內(nèi)存。entry.S 代碼運行完之后跳到 main() 中去。
int main(void) {kinit1(end, P2V(4*1024*1024)); // phys page allocatorkvmalloc(); // kernel page table/*********/seginit(); // segment descriptors/*********/startothers(); // start other processorskinit2(P2V(4*1024*1024), P2V(PHYSTOP)); // must come after startothers()/*********/ }首先就是初始化內(nèi)核結(jié)束點到 4M 之間的內(nèi)存,kinit1() 使用的地址是虛擬地址,此時的頁表只映射了低 4M,所以傳的參數(shù)為 end 到 P2V(4*1024*1024)。
初始化了 end 到 4M 之間的內(nèi)存區(qū)域之后就可以構(gòu)建正式的內(nèi)核頁表映射更多的地址空間,所以緊接著調(diào)用了 kvmalloc() 建立內(nèi)核部分的頁表。
原本內(nèi)核在低地址,由于分頁機制的開啟,內(nèi)核跑到高地址上面去了,需要改變一些寄存器中記錄的值,比如記錄 GDT 地址和界限的 GDTR 寄存器,所以有了 seginit() 重新初始化 GDT,然后將 GDT 的虛擬地址和界限寫到 GDTR 中去。
現(xiàn)在已經(jīng)建立了正式的內(nèi)核頁表,映射了整個內(nèi)核部分,有更多的虛擬地址空間可用,所以可以初始化更多的內(nèi)存了,因此有了 kinit2(),初始化的區(qū)域是 4M 到 PHYSTOP,這個宏定義可以在一定范圍內(nèi)改變,從這個宏定義可以看出,xv6 實際并沒有用到 32 位全部的 4G 空間。
那為什么 kinit2() 必須在 startothers() 后面呢?原因就在于其他 CPU 啟動的時候也是用的那張臨時頁表,只映射了物理地址的低 4M, kinit2() 的初始化內(nèi)存是用頭插法依次鏈接在頭部的,如果先執(zhí)行 kinit2() 的話,那么在執(zhí)行 startothers() 時候給 APs 分配內(nèi)存的時候就會先分配高處的內(nèi)存,而這些內(nèi)存的地址臨時頁表是沒有映射的,就會引發(fā)錯誤,所以 kinit2() 必須在 startothers() 之后。
至于其他 APs 的啟動,大都重復(fù) BSP 的過程,只不過 APs 的啟動代碼放在了 0x7000 處,其他的基本一樣就不再贅述了。
本文講述了 xv6 的內(nèi)存管理部分,完善了啟動過程中的內(nèi)存布局變化,但也只涉及了內(nèi)核部分,用戶部分將和進程結(jié)合在一起敘述。好啦本文就到這里,有什么錯誤還請批評指正,也歡迎大家來同我討論交流。
- 公眾號:Rand_cs
找了一個相關(guān)資料的網(wǎng)站,有各種手冊:
bochs: The Open Source IA-32 Emulation Project (Tech Specs) (sourceforge.io)
總結(jié)
- 上一篇: secoclient隧道保活超时或协商超
- 下一篇: wsdl文件是怎么生成的_C++ 动态库