本文將會詳細介紹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體系結構中使用cs
、ds
、ss
、es
寄存器存放段寄存器的索引。此時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函數中會重新建立新的頁表,並把這個頁表丟棄。
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
),並在調度器中切換為新進程的頁表。