XV6操做系統代碼閱讀心得(四):虛擬內存

本文將會詳細介紹Xv6操做系統中虛擬內存的初始化過程。緩存

基本概念

32位X86體系結構採用二級頁表來管理虛擬內存。之因此使用二級頁表, 是爲了節省頁表所佔用的內存,由於沒有內存映射的二級頁表能夠不用分配地址來存儲。在這個二級頁表結構中,每一個頁的大小爲4KB,每一個頁表的大小也爲4KB,每一個頁表項的大小爲4字節,一個頁表包含1024個頁表項。一級頁表表項存儲的是二級頁表的地址,二級頁表表項存儲的是對應的物理地址。虛擬地址和物理地址的最後12位老是相同,所以頁表表項中的這12位能夠被用做標記其餘信息。對於一個32位虛擬地址,能夠經過前10位來找到其對應的一級頁表表項的索引,讀出二級頁表表項的地址,並經過訪問二級頁表,獲得對應的物理地址。顯然,這樣會使得一次虛擬內存的訪問變成三次物理內存的訪問,爲了最小化其性能影響,CPU中額外有TLB緩存會緩存最近訪問的虛擬地址所對應的頁表項。虛擬地址到物理地址的轉換圖以下數據結構

X86還額外支持4MB大頁模式,讓一個一級頁表表項直接映射到4MB大小的頁。有些狀況下,這樣分配會更加方便。後文會提到Xv6系統初始化時,會使用到4MB大頁。併發

須要注意的是,虛擬地址到物理地址的映射過程是由硬件完成的,不是由某個函數完成的。硬件經過cr3控制寄存器中的一級頁表地址取出對應的頁表表項,自動完成虛擬地址的翻譯,操做系統只負責初始化頁表、設置控制寄存器和設置正確的頁表表項的值。函數

main()函數執行前內存的狀況

物理地址的內容

0x0000-0x7c00     引導程序的棧
0x7c00-0x7d00     引導程序的代碼(512字節)
0x10000-0x11000   內核ELF文件頭(4096字節)
0xA0000-0x100000  設備區
0x100000-0x400000 Xv6操做系統(未用滿)

執行到main.c中的main()函數開頭時,物理地址的具體內容如上。這裏面引導程序是由BIOS負責載入內存,設備區是硬件規定佔用的區域,而內核ELF文件頭和Xv6操做系統是由引導程序(bootmain.c)加載進內存的。佈局

全局描述符表的內容

索引 條目內容 條目含義
[0] 0 空條目
[1] SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) 內核代碼段
[2] SEG_ASM(STA_W, 0x0, 0xffffffff) 內核數據段
[3] 還沒有設置 用戶代碼段
[4] 還沒有設置 用戶數據段
[5] 還沒有設置 Task State Segment

X86體系結構中,全局描述符表用於分段管理內存。爲了可移植性,類Unix通常只會以最少的方式使用全局描述符表對內存進行分段。在main.c裏的初始化函數執行前,全局描述符表的內容如上。IA32體系結構中使用csdssses寄存器存放段寄存器的索引。此時cs寄存器存的索引值是1,ds,ss,es存的索引值是2,對應內核數據段和內核代碼段。除了權限不一樣外,兩個條目的內容徹底相同,都是將基地址設爲0,最大偏移設爲4GB,這樣就和通常的32位直接尋址使用起來同樣了。性能

在main.c中,操做系統還會調用seginit()函數從新設置全局描述符表,並補充未設置的內容。Task State Segment會在第一個用戶進程被建立時設置(具體是在switchuvm()函數中)。操作系統

頁表的內容

在進入entry.S以前,系統是運行在段尋址模式下的,entry.S中設置了初始的頁表並進入基於頁表的虛擬尋址模式,頁大小爲4MB,初始的一級頁表聲明以下翻譯

__attribute__((__aligned__(PGSIZE)))
pde_t entrypgdir[NPDENTRIES] = {
  // Map VA's [0, 4MB) to PA's [0, 4MB)
  [0] = (0) | PTE_P | PTE_W | PTE_PS,
  // Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB)
  [KERNBASE>>PDXSHIFT] = (0) | PTE_P | PTE_W | PTE_PS,
};

註釋中解釋了初始的虛擬地址到物理地址的映射關係。KERNBASE爲0x80000000。PTE_P表示這個頁表項存在,PTE_W表示可寫,PTE_PS表示這是4MB大頁,沒有設置PTE_U,代表這是內核頁。注意其中用於內核區域的頁只有一個,所以這就限制了內核代碼段+數據段的總大小不能超過4MB(其實是3MB,由於0x0-0x100000的物理地址在啓動時被使用,且被設備區佔用,實際的內核從物理地址0x100000開始)。指針

這只是一個初始的頁表,在以後的main函數中會從新創建新的頁表,並把這個頁表丟棄。code

Xv6對虛擬內存頁的管理

管理虛擬內存頁的代碼在kalloc.c中。kalloc.c的內存管理思想是把全部可用的空閒內存頁串在一塊兒造成一個大鏈表。每當有內存頁被釋放時,就將這個內存頁加入這個鏈表(kfree()函數);分配內存頁時,就從鏈表頭部取出一個內存頁返回(kalloc()函數)。這個內存分配器必須知道它要負責管理的內存範圍,並在初始化時將整個物理地址空間都歸入其管理範圍。後文會提到,一開始,這個內存分配器管理的物理內存空間是[end, 0x400000],而後會擴展到[end, 0xE00000]。這就暗含了一個假設,就是物理地址0xE00000必須存在,這就要求Xv6鎖運行的系統至少擁有240MB的內存。

用於內存頁管理的數據結構定義以下

struct {
  struct spinlock lock;
  int use_lock;
  struct run *freelist;
} kmem;

一開始,鎖是沒有啓動的,直到main()函數調用了kvinit2()以後鎖纔會被使用,由於從這裏以後可能會有多個進程和多個處理器併發地訪問這個數據結構。 struct run *freelist就是空閒鏈表的聲明。

對於每個空內存頁,由於這個內存頁是空的,因此Xv6可使用前4個字節來保存指向下一個空內存頁的地址。所以,一個空內存頁的定義以下

struct run {
  struct run *next;
};

具體對應到添加和刪除操做以下(注意其中的強制類型轉換)

// In kfree()
// Add virtual page v to freelist
r = (struct run*)v;
r->next = kmem.freelist;
kmem.freelist = r;

// In kalloc()
// Return a free page r and remove r from list
r = kmem.freelist;
if(r) kmem.freelist = r->next;

kalloc()kfree()函數的具體實現中還有一些關於鎖和錯誤檢查的細節,在此略去。

在使用這個內存分配器時,使用kfree()就能夠向其中添加空閒的內存頁,使用kalloc()就能夠從中請求一個內存頁。

main()函數中虛擬內存的初始化過程

Xv6系統使用end指針來標記Xv6的ELF文件所標記的結尾位置,這樣,[PGROUNDUP(end), 0x400000]範圍內的物理內存頁是能夠被用做內存頁分配的。Xv6調用kinit1(end, P2V(0x400000))來首先將這部份內存歸入虛擬內存頁管理。雖然這部分在以前的頁表中已經被映射爲4MB大頁,可是咱們的目標是創建一個新的頁表,這個頁表使用的頁大小爲4KB。因爲這部份內存已經被分配爲一個4MB內存大頁,且硬件已經會自動執行虛擬內存地址翻譯,故須要使用P2V()函數將物理地址轉換爲虛擬地址。以後的代碼裏還會存在不少這樣的虛擬地址到物理地址的轉換。

Xv6的內存分配器必須知道它要負責管理的內存範圍。因爲此時虛擬內存已經開啓,且頁表表項只有兩條,所以Xv6必須利用已有的虛擬地址空間,在其中建立新的頁表。這就是main()函數中kinit1()kvmalloc()所作的事情。

kinit1()函數會調用freerange()函數,按照前文敘述的方式,創建從PGROUNDUP(end)地址開始直到0x400000爲止的所有內存頁的鏈表。這樣,咱們獲得了第一組可使用的虛擬內存頁,而後內核就能夠運行kvmalloc()使用這些內存頁了。kvmalloc()函數得到一個虛擬內存頁並將其初始化一級頁表。這個一級頁表的內容在vm.c中的kmap處被定義,具體內容以下

虛擬地址 映射到物理地址 內容
[0x80000000, 0x80100000] [0, 0x100000] I/O設備
[0x80100000, 0x80000000+data] [0x100000, data] 內核代碼和只讀數據
[0x80000000+data, 0x80E00000] [data, 0xE00000] 內核數據+可用物理內存
[0xFE000000, 0] [0xFE000000, 0] 其餘經過內存映射的I/O設備

注意以上映射規則會被生成爲x86所要求的對應一級頁表和二級頁表。須要的時候,kvmalloc()函數所調用的walkpgdir()函數會申請新的內存頁用做二級頁表。

以後,main()函數會調用seginit()函數從新設置GDT。新的GDT與以前的GDT的主要區別在於設置了用戶數據段和用戶代碼段。雖然這些段依然是對32位偏移進行直接映射,但其執行權限與內核的段有所不一樣。GDT中的TSS表項直到第一個用戶進程創立時纔會被設置,而且其內容會隨着當前用戶進程的切換而改變。

最後,main()函數會調用kinit2()將[0x400000, 0xE00000]範圍內的物理地址歸入到內存頁管理之中。至此,Xv6的內存頁管理系統和內核頁表已經所有創建完畢。須要注意的是,這個內核頁表(kpgdir變量)只會在調度器運行時被使用。對於每個用戶進程,都會擁有本身獨自的完整頁表,其中也包含了一份如出一轍的內核頁表。

下面咱們來看看第一個用戶進程的虛擬地址空間是如何初始化的。main()函數在kinit2()以後緊接着調用userinit()來初始化第一個用戶進程。userinit()在完成有關進程數據結構管理的工做後,會初始化這個進程本身的頁表(struct proc中的pgdir)。首先,userinit()會使用setupkvm()生成與前述如出一轍的內核頁表,而後使用inituvm()生成第一個用戶內存頁(映射到虛擬地址0x0),並將用戶進程初始化代碼移動至這個內存頁中(這就要求初始化代碼不能超過4KB,初始化代碼參見initcode.S)。

initcode.S中包含了一個exec系統調用,經過這個系統調用來加載進一個真正的用戶進程。exec系統調用的實如今exec.c中。exec會從磁盤裏加載一個ELF文件。ELF文件中包含了全部代碼段和數據段的信息,而且描述了這些段應該被加載到的虛擬地址(這是在編譯時就已經肯定好的,因此編譯器必須遵循某些約定來分配這些虛擬地址)。

最後,exec會分配兩個虛擬內存頁,第一個頁設置爲不可訪問,第二個頁用做用戶棧。因爲棧是從上往下增加的,因此當棧的大小超過一個頁(4KB)時,會觸發錯誤,所以Xv6系統的用戶進程最多隻能使用4KB的棧。

最終的虛擬內存佈局

這裏咱們列出init進程的頁表中所記錄的所有虛擬地址到物理地址的映射關係。每個用戶進程都有一個這樣的頁表。其中,有關內核的部分(也就是最後四項)對於全部用戶進程都是同樣的,而前面的映射會有所不一樣,表中的信息根據init的進程的ELF文件信息和exec調用的代碼肯定。

虛擬地址 映射到物理地址 內容
[0x0, 0x1000] 由分配器提供的地址 用戶進程的代碼和數據
[0x1000, 0x2000] 由分配器提供的地址 不可訪問頁,用於檢測棧溢出
[0x2000, 0x3000] 由分配器提供的地址 用戶進程的棧
[0x80000000, 0x80100000] [0, 0x100000] I/O設備
[0x80100000, 0x80000000+data] [0x100000, data] 內核代碼和只讀數據
[0x80000000+data, 0x80E00000] [data, 0xE00000] 內核數據+可用物理內存
[0xFE000000, 0] [0xFE000000, 0] 其餘經過內存映射的I/O設備

中斷、進程調度與虛擬內存

中斷髮生時,使用的的頁表依然是對應用戶進程的頁表。因爲每個用戶進程都有一份如出一轍的內核頁表條目,所以陷入的內核代碼依然能夠正常執行。只有當中斷處理程序決定退出當前進程或者切換到其餘進程時,當前頁表纔會被切換爲調度器的頁表(全局變量kpgdir),並在調度器中切換爲新進程的頁表。

相關文章
相關標籤/搜索