虛擬記憶體Virtual Memory絕對是現代OS最重要的核心機制,同時也是最複雜的系統之一,這篇文章就來記錄我對他的理解以及怎麼在OS實作。
Why Virutual Memory(VM)?
傳統作法
首先我們要先了解傳統的記憶體管理像是分段Segment,有哪些缺點,以及VM如何改善的。
以分段為例,假設Process A的實體記憶體位址是0x00001000-0x00002000,Process B是0x00002000-0x00003000,可能會有以下問題:
載入時位址需Relocation
- 一般編譯器編譯出來的可執行檔,記憶體位址預設都是從
0x00000000開始,如果此時OS分配該Process的起始位址到0x00001000,loader就要負責處理這個Offset
擴充不易
- 如果Process A記憶體不夠用了,想要擴充,就只能申請
0x00003000以後的位址使用,導致Process A內部要存取時必須處理位址不連續的狀況
權限管理不易
- 如果某個Process意外存取另一個Process的位址空間應該要觸發Exception;或是某段位址空間理論上應該要W/R only,Segement都很難做到
外部碎片化
- 當系統長時間運行後,可能不同Process間會有空閒的記憶體區塊,但卻因為太小不能放下新的連續空間的Process,導致整體看起來記憶體還夠,實際上卻放不了的狀況
Virutual Memory(VM)作法
VM就是為了解決以上問題出現的,他的概念就是將記憶體切分成多個統一大小的Page,一般是4096 Bytes(4kb)當作基本分配記憶體的單位,並導入邏輯/虛擬位址(Logical/Virtual Address,VA)轉實體位址(Physical Address,PA)的概念,VA就是編譯後可執行檔從0x00000000開始的位址,或是說整個Process內部實際使用的位址,PA則是真實物理記憶體的位址,也就是實際存放指令跟資料的地方。
虛擬Page我們稱之為Virtual Page
實體Page則稱作Physical Page (有些地方稱作Physical Frame,是等價的東西)
整個VM運作的機制是,當某個Process被創建出來時,OS就會分配一個Physical Page給他,並將該Process的Virtual Page映射到這塊Physical Page上,假設這塊Physical Page起始位址是0x00001000,那就會把此Process的VA 0x00000000-0x00001000 映射到 PA 0x00001000-0x00002000上,而當Process開始工作後,想要存取VA 0x00000000時,CPU就會透過另一個硬體MMU來做轉址,找到PA 0x00001000的地方去存取真實的內容。
如果哪天這個Process要用超過0x00001000的記憶體空間(VA)的話,只需要再向OS申請一塊新的Physical Page,假設其起始位址是0x00004000,那就只需要將VA0x00001000-0x00002000映射到PA0x00004000-0x00005000就可以了。

(至於MMU轉址是怎麼實現的,我們稍後再來探討。)
如此一來就可以有以下優點:
不須Relocation
- 每個Process的VA都是從
0x00000000開始,看起來最直觀,從Process角度看出去都以為自己擁有整個完整的記憶體空間,實際執行時完全按照編譯器出來的邏輯位址(VA)去找即可,實際映射到真實資料OS會全權負責,開發者無須擔心不連續的問題
易於擴充
- 每當Process需要更多記憶體空間,只需要向OS請求一塊新的Physical Page,OS就會自動建立映射,Process內部即可無痛使用
方便權限管理/安全性佳
- 可以根據需求指定每個Page的存取權限,只要權限不足OS就會觸發Exception(Page Fault),保證了隔離性與安全性
沒有外部碎片化
- 以Page為單位去劃分記憶體就不會有外部碎片化的問題,但就會有內部碎片化,不過這相對來說影響比較不大
易於共享記憶體或做動態Library
- 只需要將共用的記憶體開一個新的Physical Page,映射到不同Process內部的Virtual Page即可
Page Table & Page Table Entry
在進入RISC-V的VM前,要先講清楚MMU(Memory Managmement Unit)到底怎麼做轉址的,為甚麼輸入VA就可以幫我們找到PA?
其實MMU本身就只是一連串複雜的硬體電路而已,這個電路會將VA當作input去查表,output就會是最終的PA,而這個表我們稱之為Page Table。
我們都知道64位元的CPU,記憶體位址的長度就是64 bits,可以定址2的64次方範圍內的記憶體,而剛剛也有提到一個Page是4096(2的12次方)個Bytes,因此理論上一個Process最多可以有(2的64次方) / (2的12次方) = (2的52次方)個Virtual Page。
而實體記憶的部分,假設我們電腦上裝了4GB(2的32次方Bytes)的記憶體,那就是整個系統會有(2的32次方) / (2的12次方) = (2的20次方)個Physical Page。
也就是說,我們只需要建立好Virtual Page Number(VPN,一個Process的第幾個Virtual Page)當作index(key),Physical Page Number(PPN,整個系統的第幾個Physical Page)當作value的Page Table,就可以讓MMU正常轉址。

每一筆VPN to PPN的映射關係我們稱為Page Table Entry (PTE),通常每個entry是8個Bytes
如此一來,轉址的公式就非常簡單,首先我們會拿到input是 64 bits的VA,他前面的52 bits代表的含意就是Virual Page Number,後面12bits就是Page裡的Offset。
| VPN | Offset |
63 12 11 0
然後只需要把這52 bits的VPN去Page Table查表 找對對應的PPN,再把這個PPN接上Offset就是最後的PA了(因為實體&虛擬Page都是4096 Bytes,所以資料在Page內的「相對位置」完全一樣)。
以上就是一個最簡單的VM機制,然而現實中我們通常不會這樣做,因為實際上這個Page Table也會存放在實體記憶體中,剛剛也有提到理論上VPN最多可以有2的52次方個,也就是說一張Page Table就會需要有這麼多筆Entry,而每個Entry通常佔8 Bytes(紀錄PPN跟一些其他資訊如Permission, Valid等等),這樣會導致一個Process的Page Table太大(2^52 x 8 Bytes = 2^55 Bytes = 32PB)。
RISC-V的VM - Sv39
因此實務上我們會採用Multi-level Page Table (或稱Page the Page Table)機制,其實概念也很簡單,就是將Page Table也變成Page管理,而RISC-V也是採用這種模式。
RISC-V規格中有多種VM規範,以64位元來說最常見的就是Sv39,至於為甚麼要叫做39呢?主要就是因為一般來說根本用不到64 bits這麼多位元來定址,39個Bits就能應付絕大多數情境了,因此Sv39設計成最前面的25bits都當作Reserved的,不會去使用,真正有意義的只有後面39個Bits。
Sv39 VA設計如下,總共有3層Page Table (Level 2,1,0),每個Page Table的大小就是一個Page,每個PTE佔8 Bytes,所以一個Page Table裡會有4096 / 8 = 2的9次方 = 512個PTE。
| Reserved (25 bits) | VPN[2] (9) | VPN[1] (9) | VPN[0] (9) | Offset (12) |
63 39 38 30 29 21 20 12 11 0
所以說我們也可以把Page Table看成是一個大小為512的PTE陣列Page Table = PTE[512],而VPN[2], VPN[1], VPN[0] 分別就是代表Level 2,1,0 Page Table/PTE陣列的index (因為都是9 Bits,所以範圍是0~511)。

- Level 2 Page Table的PTE存放的內容就是Level 1 Page Table的PPN,且Level 2 Page Table是整個Process最上層的Page Table,一個Process只會有一張。
- Level 1 Page Table的PTE存放的內容就是Level 0 Page Table的PPN;
- Level 0 Page Table的PTE存放的內容就是真實資料或指令存放的Page的PPN;
如此一來,Sv39的MMU轉址過程就會是:
- === Level 2 ===
- CPU去讀
satp(Supervisor Address Translation and Protection)暫存器,裡面會存放Level 2 Page Table的PPN - 用此PPN去Physical Page找到Level 2 Page Table (大小為512的PTE陣列)
- 取Level 2 Page Table index為
VPN[2] (VA的30~38bit)的PTE,即PTE[VA的30~38bit] - 這個PTE就包含Level 1 Page Table的PPN,以及各種權限與狀態,如果是非法存取硬體會直接Page Fault
- === Level 1 ===
- 用此PPN去Physical Page找到Level 1 Page Table (大小為512的PTE陣列)
- 取Level 1 Page Table index為
VPN[1] (VA的21~29bit)的PTE,即PTE[VA的21~29bit] - 這個PTE就包含Level 0 Page Table的PPN,以及各種權限與狀態,如果是非法存取硬體會直接Page Fault
- === Level 0 ===
- 用此PPN去Physical Page找到Level 0 Page Table (大小為512的PTE陣列)
- 取Level 0 Page Table index為
VPN[0] (VA的12~20bit)的PTE,即PTE[VA的12~20bit] - 這個PTE就包含真實資料或指令存放的Page的PPN,以及各種權限與狀態,如果是非法存取硬體會直接Page Fault
- === 真實資料或指令的Page ===
- 用此PPN + Offset(VA的0~11bit) = 最終的PA
以上就是Sv39 VA 轉 PA的整個過程。
Coding - 怎麼實作到OS中
前面提到的都還只是概念而已,且整個轉址過程都是在MMU內部的硬體電路做掉了,不用OS開發者寫這些流程的Code,那我們到底要負責做些什麼呢?
- 開啟MMU: 在整個系統最開始啟動時,RISC-V預設是沒有開啟VM與MMU的,都是直接拿實體位址去RAM找資料,我們需要設置satp暫存器來開啟MMU
- 建立Page Table的Function: 就像剛剛提到的,MMU只負責「查表」而已,我們開發者還要負責「填表」才能讓MMU正確工作,因此我們會需要準備一些Functions來填Page Table
- 建立Kernel的Level 2, 1, 0 Page Table與Identical Mapping: 我們開啟MMU前後都是在Kernel的Code中,因此為了讓開啟MMU後也能正常存取Kernel,要先建立一個最基本Page Table給Kernel用。而Identical Mapping是整個VM系統最複雜最抽象的一個部分,後面專門留一些篇幅來介紹
註: 接下來我都是參考xv6的Code來說明。
在開始之前,要先定義好一些我們需要的資料型別
typedef uint64 pte_t;
typedef uint64 *pagetable_t;
以及先來宣告一個最開始的Kernel Page Table
pagetable_t kernel_pagetable;
kernel_pagetable = (pagetable_t)kalloc(); // kalloc請求一塊實體Page
建立Page Table的Function
為了能夠建立給MMU查詢的Page Table,我們也需要模仿MMU轉址的行為,也就是從input要轉換的VA跟Level 2 Page Table,到output是PA的整個過程,只不過現在我們要做的是填表,而不是output PA,所以要把PA的PPN寫到Level 0 Page Table中,這整個從Level 2到Level 0的轉址過程我們稱之為walk。
在看walk之前,要先了解RISC-V的PTE的格式長怎樣,以及定義一些方便操作的macro
// PTE 格式
// 63 54 53 10 9 8 7 6 5 4 3 2 1 0
// [ reserved ] [ PPN ] [RSW] D A G U X W R V
// Flag bits (RISC-V 硬體定義,不可更改):
// V (bit 0): Valid,0 = 此 PTE 無效,MMU 觸發 page fault
// R (bit 1): Readable
// W (bit 2): Writable
// X (bit 3): Executable
// U (bit 4): User-accessible,0 = 只有 kernel 可存取
// G (bit 5): Global,存在於所有 address space(kernel 用)
// A (bit 6): Accessed,硬體自動設定,表示此頁被存取過
// D (bit 7): Dirty,硬體自動設定,表示此頁被寫過
// RSW (bit 8~9): 保留給 OS 自用
// PPN (bit 10~53): Physical Page Number,實體位址 = PPN << 12 + offset
// - 中間層 PTE(level 2, 1):PPN 指向下一層 page table 的實體位址
// - leaf PTE(level 0):PPN 指向最終的實體頁框
// - 判斷方法:R=W=X=0 表示中間層,否則為 leaf
// PTE權限
#define PTE_V (1L << 0)
#define PTE_R (1L << 1)
#define PTE_W (1L << 2)
#define PTE_X (1L << 3)
#define PTE_U (1L << 4)
#define PTE_A (1L << 6)
#define PTE_D (1L << 7)
#define PG_SIZE 4096
#define PA_2_PTE(paddr) ((((uint64) paddr) >> 12) << 10)
#define PTE_2_PA(pte) (((pte) >> 10) << 12)
#define PX_MASK 0x1FF // 0000 0001 1111 1111 => 9 bits
#define PX_SHIFT(level) (PG_SHIFT + ((level) * 9))
#define PX(level, vaddr) ((((uint64) (vaddr)) >> PX_SHIFT(level)) & PX_MASK)
#define MAX_VADDR (1L << (9 + 9 + 9 + 12 - 1))
然後就可以寫walk函數
// 回傳Level 0 Page Table的 pte指標
pte_t* walk(pagetable_t pagetable, uint64 vaddr, int alloc){
if(vaddr >= MAX_VADDR)
panic("walk error vaddr exceed the limit");
for(int level = 2; level > 0; level--){
pte_t *pte = &pagetable[PX(level, vaddr)];
if(*pte & PTE_V){
pagetable = (pagetable_t)(PTE_2_PA(*pte));
} else {
if(!alloc || (pagetable = (pte_t*)kalloc()) == 0)
return 0;
memset(pagetable, 0, PG_SIZE);
*pte = PA_2_PTE(pagetable) | PTE_V;
}
}
return &pagetable[PX(0, vaddr)];
}
這個walk函數我們可以把它當作是軟體版本的MMU,最核心的目的就是給OS從Level 2 Page Table 找到 Level 0 Page Table 的PTE填表用的,有了他我們就可以很容易的將VA映射給PA。
接著就要來實作拿到Level 0 Page Table PTE後,填入PA的函數
// return 0 代表成功,否則失敗
int mappages(pagetable_t pagetable, uint64 vaddr, uint64 size, uint64 paddr, uint64 permissions){
if((vaddr % PG_SIZE) != 0)
panic("vaddr not aligned");
if((size % PG_SIZE) != 0)
panic("size not aligned");
if(size == 0)
panic("size == 0");
uint64 a, last;
pte_t *pte;
a = vaddr;
last = vaddr + size - PG_SIZE;
for(;;){
if((pte = walk(pagetable, a, 1)) == 0)
return -1;
if(*pte & PTE_V) // 避免覆蓋掉已存在的PTE
panic("remap");
*pte = PA_2_PTE(paddr) | permissions | PTE_V;
if(a == last)
break;
a += PG_SIZE;
paddr += PG_SIZE;
}
return 0;
}
mappages的功能是將位置為vaddr ~ vaddr+size的所有Virtual Page映射到paddr ~ paddr+size的Physical Page,並為這些Page設置permissions權限。
建立Kernel的Page Table 與 Identical Mapping
在有了mappages之後,我們就可以建立給Kernel的Page Table,讓Kernel在開起MMU後也能正常運作(因為開啟MMU之前CPU每次存取指令都是PA不會有問題,但只要開啟MMU之後,每次CPU存取指令都一定會經過MMU轉址,如果沒有設置好最開始的Page Table,就會找不到PA導致Page Fault)。
那到底Kernel Page Table裡要映射哪些東西以及從哪裡映射到哪裡呢?
這就涉及到整個VM我認為最複雜的概念,Identical Mapping了,首先我們都知道整個OS Kernel的Code跟Data最開始Boot時一定是從某個實體記憶體位址PA開始放起的,以QEMU來說就是0x80000000,而Kernel是不屬於Physical Page管理的,是一塊獨立且特別的記憶體區塊,從Kernel之後到整個實體記憶體結束才是Physical Page管理的範圍。

這個不管有沒有開啟MMU都是不變的,因此只要我們想要存取Kernel,都是存取PA是0x8000????之類的位址,但開啟MMU後就一定會有轉址的過程,所以為了讓我們在開啟MMU後依舊可以正常存取躺在PA0x8000????的Kernel,我們需要建立Identical Mapping,也就是建立一個VA=PA的映射。
// kernel code (read only)
// etext - KERNBASE 代表Kernel Code範圍 (.text)
kvm_map(kernel_pagetable, KERNBASE, KERNBASE, (uint64)etext - KERNBASE, PTE_R | PTE_X | PTE_A | PTE_D);
// kernel data / free memory
// PHY_STOP - (uint64)etext 代表Kernel Data範圍(.rodata .data .bss),也包含整個Physical Page
kvm_map(kernel_pagetable, (uint64)etext, (uint64)etext, PHY_STOP - (uint64)etext, PTE_R | PTE_W | PTE_A | PTE_D);
void kvm_map(pagetable_t kpg_table, uint64 vaddr, uint64 paddr, uint64 size, uint64 permissions){
if(mappages(kpg_table, vaddr, size, paddr, permissions) != 0)
panic("kvm_map");
}
如此一來開啟MMU後就可以繼續正常存取Kernel而不會Page Fault了。
開啟MMU
現在我們都準備好各種前置作業,可以準備開啟MMU了,在RISC-V中,我們可以透過設定satp暫存器來開啟MMU,satp的格式如下
// satp 格式(RISC-V 硬體定義):
// 63 60 59 44 43 0
// [ MODE ] [ ASID ] [ PPN ]
// MODE (bit 63~60): 翻譯模式,8 = Sv39(三層 page table),0 = 關閉 MMU
// ASID (bit 59~44): Address Space ID,用來區分不同 process 的 TLB 快取,可暫時先填 0
// PPN (bit 43~0) : root page table 的實體位址 >> 12
接著定義一些helper function來填入satp的內容
#define SATP_SV39 (8L << 60)
#define MAKE_SATP(pagetable) (SATP_SV39 | (((uint64)pagetable) >> 12))
static inline void set_satp(uint64 x){
asm volatile("csrw satp, %0" : : "r" (x));
}
然後就可以設置satp開啟MMU
asm volatile("sfence.vma zero, zero"); // 刷新TLB(Page Table的Cache)避免有舊的資料被Cache住
set_satp(MAKE_SATP(kernel_pagetable)); // 開啟MMU
asm volatile("sfence.vma zero, zero"); // 刷新TLB(Page Table的Cache)避免有舊的資料被Cache住
至此,我們就成功的啟用了RISC-V的Virtual Memory機制了。
在實體RISC-V CPU上踩的坑 (Milkv Duo S, C906 CPU)
完成以上步驟,我就可以正常的在QEMU中使用VM了,然而一到實體RISC-V CPU C906上就直接Page Fault,這困擾了我超級久最後才發現C906這顆CPU比較嚴格,要設置PTE_A PTE_D這兩個權限才可以,除此之外還因為真實CPU上還有Cache,所以除了要清空TLB之外,也要清除I-Cache才可以
asm volatile("fence.i");