頁表是操作系統中非常重要的一部分,用於將虛擬地址轉化為物理地址。虛擬內存是操作系統實現進程隔離的關鍵技術。
在 XV6 中通過 RISC-V 的頁表機構完成了虛擬地址向物理地址的轉換。
分頁硬件機構
XV6 運行於 Sv39 RISC-V 上,64 位地址中的低 39 位被使用。RISC-V 的頁表邏輯上是 page table entries (PTEs) 的數組,長度為 2^27。PTE 包含 44 位物理地址號(PPN)。頁的大小為 4KB,因此,分頁硬件使用 39 位中的高 27 位查找 PTE,之后轉化為 56 位的物理地址。
而實際上,RISC-V 使用的三級頁表,1 級頁表為 1 頁(4KB),包含 512 個 PTE。27 位頁號中的高 9 位為一級頁表,中間 9 位為 2 級頁表,末 9 位為三級頁表。
在 PTE 中,低 8 位為標志位,其中 PTE_V 代表地址是否有效,當訪問無效頁面時會觸發page fault;PTE_U代表地址能否在用戶模式被訪問,如果未設置則頁面只能在 supervisor mode 中訪問。
為了使分頁機構能夠正常運行,操作系統必須設置satp
寄存器為1級頁表的物理地址。
內核地址空間
XV6 每個進程擁有一個獨立頁表,同時內核也擁有一個頁表用於描述內核地址空間。內核會將自身地址空間直接映射到物理地址上,來方便訪問物理內存和硬件資源。內核地址空間定義在memlayout.h
中,如下圖所示:
在QEMU中,0~0x80000000用於映射設備接口,而0x80000000(KERNBASE) ~ 0x86400000(PHYSTOP)為RAM。
有一小部分內核地址空間不是直接映射的,Trampoline 頁面在地址空間最高的位置,隨后是每個進程對應的內核棧,每個棧之間都有一個 Guard page ,該頁的 PTE_V 設置為 0,用於避免緩沖區溢出。
如果內核棧使用直接映射的方法,那么 Guard page 相對應的物理內存中將會產生很多空洞,導致內存管理變得困難。
Code: 創建地址空間
與地址空間有關的代碼主要在vm.c
文件中。pagetable_t
代表一級頁表,實際數據類型是一個指針,指向頁表的物理地址。
walk
函數是最核心的函數,該函數通過頁表pagetable
將虛擬地址va
轉換為PTE,如果alloc
為1
就會分配一個新頁面。
kvminit
分析
kvminit
函數用於初始化內核頁表,該函數在內核啟動開啟分頁機制前被調用,因此是直接對物理地址進行操作的。函數首先通過kalloc
申請了一個頁面用於保存一級頁表。kalloc
函數就簡單地從kmem.freelist
中取出一個空閑頁面。
而kmem
結構體的初始化在kinit
中進行,該函數在kvminit
之前被調用。該函數首先初始化鎖,之后使用freerange
函數將內核之后的全部空閑 RAM 以 4KB 為一頁加入該鏈表。
void kinit()
{
initlock(&kmem.lock, "kmem");
freerange(end, (void*)PHYSTOP);
}
回到kvminit
函數,在申請到頁表后,通過調用kvmmap
函數,將物理地址中的UART0
CLINT
等映射到內核頁表中,完成了內核頁表的初始化。
在kvminit
函數完成后,main
函數緊接着就會調用kvminithart
函數。在該函數中,使用MAKE_SATP
產生SATP
的值,將該值寫入satp
寄存器中,之后使用sfence_vma
刷新 TLB,完成了虛擬地址轉換的開啟,之后代碼中的地址就全部會通過地址轉換機構進行轉換。
#define SATP_SV39 (8L << 60)
#define MAKE_SATP(pagetable) (SATP_SV39 | (((uint64)pagetable) >> 12))
而在MAKE_SATP
中使用SATP_SV39
設置 MODE 域為 8,即開啟 SV39 地址轉換,如下圖所示。
在kvminithart
函數執行完成后,就會調用procinit
函數,初始化所有進程結構體,對每個進程結構體申請兩個頁面作為內核棧,之后將該頁面映射到內核地址空間的高位上。最后再次調用kvminithart
函數,刷新 TLB,使硬件知道新 PTEs 的加入,防止使用舊的 TLB 項。
sfence.vma
sfence.vma rs1, rs2
指令是一條特權指令,用於通知處理器頁表的修改。rs1
指示了頁表哪個虛址對應的轉換被修改了;rs2
給出了被修改頁表的進程的地址空間標識符(ASID)。如果兩者都是x0
,便會刷新整個 TLB。
sfence.vma 僅影響執行當前指令的 hart 的地址轉換硬件。當 hart 更改了另一個 hart 正在使用的頁表時,前一個 hart 必須用處理器間中斷來通知后一個 hart,他應該執行 sfence.vma 指令。這個過程通常被稱為 TLB 擊落。
在 XV6 中,兩個地方使用了sfence.vma
指令,一個是上文提到的kvminithart
函數,另一個就是trampoline.S
中,當陷入內核以及返回用戶態時會調用。
進程地址空間
每個進程擁有獨立的地址空間,當進程切換時同時會對頁表進行切換。XV6 進程地址空間從 0 開始到 MAXVA,即 256GB。
當進程申請內存時,內核就會先調用kalloc
函數申請物理頁面,之后構造PTE加入進程對應的頁表項中。
在進程地址空間的最高位置為 trampoline,所有進程的該頁面映射到同一個物理頁面上。同樣地,在用戶棧的下方也設置了一個 guard page 來防止緩沖區溢出。
Code: sbrk
系統調用char* sbrk(int)
用於增加或減少物理內存,當參數為正數時增加,負數時減少。sbrk
實際通過growproc
進行,growproc
調用uvmalloc
或uvmdealloc
完成工作。
進程地址空間是從 0 開始連續向上增長的,因此通過
proc.sz
獲取已分配字節數,就可以計算得到當前已分配空間的頂部地址,之后就可以得到對應的頁面地址。
uvmalloc
函數先計算需要申請的頁面數,之后在進程地址空間頂部再申請所需的連續的頁面。函數通過kalloc
申請物理頁面,之后使用mappages
函數映射到進程頁表中。
uvmdealloc
函數先計算需要減少的頁面數,之后通過uvmunmap
刪除頁面。在uvmunmap
函數內部通過walk
獲取對應 PTE,將PTE_V設置為0,最后通過kfree
函數將該物理頁面添加到空閑鏈表中。
Code: exec
系統調用exec
用於創建進程地址空間。函數首先使用namei
獲取可執行文件,讀取 ELF 頭,檢查 ELF 中的 magic。之后使用proc_pagetable
創建進程頁表。
在proc_pagetable
函數中,先使用uvmcreate
函數申請一個頁面,之后將 trampoline 和 trapframe 映射到高位地址空間中。
exec
之后使用uvmalloc
申請內存空間,再使用loadseg
函數將程序加載到對應頁面中。在 Program Header 中描述了各段的 filesz,memsz等信息,當 filesz 小於 memsz 時,中間的空隙用 0 填充(如C語言中的全局變量)。
程序加載完成后,再申請兩塊頁面,第一塊為 guard page ,使用uvmclear
函數將該頁面PTE_U設置為0,即不允許 user mode 訪問。第二頁設置為進程的棧。然后將argc
、argv
和返回地址壓棧,完成棧的准備工作。
最后,exec
函數更新進程結構體,將舊頁表釋放。
在 Program Header 的vaddr
中,程序可以指定被加載到的虛擬地址,而這可能是危險的,因此在exec
中會檢查if(ph.vaddr + ph.memsz < ph.vaddr)
,避免發生加法溢出。
實際操作系統
在 XV6 中,內核直接加載到 0x80000000 的位置上,而在實際操作系統中,一般會使用 kaslr 技術,即內核地址隨機化,使攻擊者不能直接通過反匯編獲取內核變量和函數的地址。
在 RISC-V 中支持 super pages,即大小為4MB的頁面,用於降低大內存機器上的頁表開銷。
XV6 也缺少類似於 malloc 的機制來減少使用sbrk
大量分配小對象的開銷。