回到文章列表
sv39-risc-v-mmu

【RISC-V】Sv39 - 開啟MMU實現虛擬記憶體

發布於 2026-04-23·225·
OSRISC-V

虛擬記憶體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。

c
|              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。

c
|   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轉址過程就會是:

  1. === Level 2 ===
  2. CPU去讀satp(Supervisor Address Translation and Protection)暫存器,裡面會存放Level 2 Page Table的PPN
  3. 用此PPN去Physical Page找到Level 2 Page Table (大小為512的PTE陣列)
  4. 取Level 2 Page Table index為 VPN[2] (VA的30~38bit)的PTE,即PTE[VA的30~38bit]
  5. 這個PTE就包含Level 1 Page Table的PPN,以及各種權限與狀態,如果是非法存取硬體會直接Page Fault
  6. === Level 1 ===
  7. 用此PPN去Physical Page找到Level 1 Page Table (大小為512的PTE陣列)
  8. 取Level 1 Page Table index為 VPN[1] (VA的21~29bit)的PTE,即PTE[VA的21~29bit]
  9. 這個PTE就包含Level 0 Page Table的PPN,以及各種權限與狀態,如果是非法存取硬體會直接Page Fault
  10. === Level 0 ===
  11. 用此PPN去Physical Page找到Level 0 Page Table (大小為512的PTE陣列)
  12. 取Level 0 Page Table index為 VPN[0] (VA的12~20bit)的PTE,即PTE[VA的12~20bit]
  13. 這個PTE就包含真實資料或指令存放的Page的PPN,以及各種權限與狀態,如果是非法存取硬體會直接Page Fault
  14. === 真實資料或指令的Page ===
  15. 用此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來說明。

在開始之前,要先定義好一些我們需要的資料型別

c
typedef uint64 pte_t;
typedef uint64 *pagetable_t;

以及先來宣告一個最開始的Kernel Page Table

c
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

c
// 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函數

c
// 回傳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的函數

c
// 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的映射。

c
// 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的格式如下

c
// 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的內容

c
#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

c
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才可以

c
asm volatile("fence.i");

References

Github: xv6-riscv

AbydOS开发日记 (-1) - 第一次上板运行(C906)