iT邦幫忙

2021 iThome 鐵人賽

DAY 10
1
Software Development

予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索系列 第 10

予焦啦!RISC-V 虛擬記憶體機制簡說

本節是以 Golang 上游 4b654c0eeca65ffc6588ffd9c99387a7e48002c1 為基準做的實驗

予焦啦!昨日最後觀察到的錯誤來自於連結器在連結時期就已經將虛擬記憶體位址存放在最後產出的可執行檔的資料區內。正常情況下來說,Golang 的可執行檔並沒有預期自己會面臨到記憶體位址切換的情況,這通常只有系統軟體才會如此操作。

延續昨日,我們沒有辦法再得過且過、修修改改就期望能夠通過 Golang 的執行期初始階段了。我們得支援虛擬記憶體才行,而且就是現在、馬上。

本節重點概念

  • 基礎
    • 虛擬記憶體機制
  • RISC-V
    • Sv39 分頁(paging)機制
    • Sv39 的虛擬記憶體轉換過程

虛擬記憶體

現代的作業系統為了更有彈性地處理記憶體管理的諸般問題,收斂至今的一個通用解決方案就是虛擬記憶體系統。也就是說,CPU 本身執行一行行指令的時候,無論是它抓取指令時需要讀取的 pc 所代表的位置,或是指定某個區域的讀取或寫入,它所經手的那些位址,實際上都不是硬體的記憶體模組真正接收到的東西。

一個簡單的類比是地址。物理上來講,針對每一間房子,我們都可以計算出它的經緯度,但這其實對於戶政事務所來說不是很好管理。比較簡單的方法,還是幫每一條路制定路名,然後 1 號、2 號、3 號加以排列。所以其實門牌號碼正有點像是虛擬位址,而背後有真正獨一無二的經緯度位址。經過行政區重劃,甚至天翻地覆的改朝換代,地址會隨著時間遷移,但那個地點本身的經緯度,以地球的尺度來講是不太會改變的。

回到 RISC-V 系統開機的過程來理解。一個系統啟動時,平台本身的重設(reset)機制能確保系統在初始化狀態。之後的第一行、第二行、第三行等等的指令在執行的時候,當然是還沒有虛擬記憶體這樣的機制的。這時候系統使用的是在機器模式(M-mode),主要處理低階的初始化,並且準備將系統的主控權轉給作業系統。這階段中使用的記憶體位址都還是實體記憶體位址,就如我們目前為止的實驗一樣。我們使用的位址大多在 0x802xxxxx 的部份,這是 QEMU 的 RISC-V 通用平台的物理記憶體位址。

轉移到作業系統模式(S-mode)之後的狀況又是如何?以 Linux 為例,在它非常早期的階段,它就已經建立核心部分的頁表(page table),從而在剩下的絕大部分的核心生命週期中,真正使用的都是虛擬位址。這對於核心本身的意義,就是要能夠方便調度管理。作業系統希望對整個系統有更徹底的控制,當然也包含記憶體的權限管理與分配,因此 CPU 會提供相關的功能給作業系統操作。又,日後進到使用者空間之後,所有的行程也因此可以受惠,因為那些行程大多在被編譯的時候是使用預設的連結器腳本(linker script),因而在 ELF 檔中使用的都是同一個區段的虛擬位址。一旦虛擬位址轉換的機制啟用,作業系統就能夠分別對應這些行程的虛擬位址到不同的物理位址去,避免混淆。

以下筆者常會交互使用物理位址實體位址

虛擬記憶體名詞解釋

特權指令規格書中的4.34.5章分別介紹了當前 RISC-V 支援的三種虛擬記憶體轉換模式:

  • Sv32:僅支援 32-bit 系統
  • Sv39:是當前 64-bit 系統通常支援的模式,理論上可以花費 1G 的空間存放頁表而管理 512GB 的記憶體
  • Sv48:理論上可以支援到256TB

我們將以 Sv39 作為主要的支援模式。接下來,這裡解釋一些馬上就會用到的概念:

  1. satp 控制暫存器:Supervisor Address Translation and Protection,代表虛擬記憶體位址轉換與相關的保護設定。歷史上,這個暫存器曾經在 1.9 版以前的權限指令規格書中被稱為 sptbr:Supervisor Page Table Base Register,也許是雖不能盡表其義卻更一目了然的描述方式。這個控制暫存器是位址轉換的起點,其格式為(請參見權限指令規格書 4.1.0)
 |63  60|59  44|43                   0|
 +------+------+----------------------+
 | mode | ASID | physical page number |  
 +------+------+----------------------+ 
  1. Sv39 虛擬記憶體:虛擬記憶體有效位址(effective address)雖然是 64 位元,但是這個模式之下,從 39 到 63 位元都必須與第 38 位元相等才行。格式如下:
|38    30|29    21|20    12|11          0|
+--------+--------+--------+-------------+ 
| VPN[2] | VPN[1] | VPN[0] | page offset | 
+--------+--------+--------+-------------+

其中,VPN 代表虛擬頁面編號(virtual page number),而中括號內的數字代表將虛擬頁面編號分成三組,各佔 9 個位元。最後留有頁面偏移量的 12 位元。

與各位讀者說聲抱歉:先前自系列文開始,筆者就將 ethanol 的程式碼區段設置在 0xffffff8000000000 位址,但其實這是不合規格的 Sv39 位址!因為第 38 位元為 0,但第 39 位元之後又都是 1。自今日起,筆者已將之更正為 0xffffffc000000000 了。但先前就開始存取 github 的讀者不必擔心,因為目前的版本已經是修正過的版本。

  1. Sv39 物理記憶體格式:
|55    30|29    21|20    12|11          0|
+--------+--------+--------+-------------+
| PPN[2] | PPN[1] | PPN[0] | page offset |
+--------+--------+--------+-------------+

與虛擬記憶體格式唯一不同之處在於,PPN[0] 佔據 26 個位元而非 9 個位元。這是因為,在 Sv39 模式當中,合於規格的物理頁面編號應該是 44 個位元,如同 satp 控制暫存器中的寬度。
4. 頁面(page):Sv39 模式底下,一個頁面的大小是 4096 (也常寫作 4K 或是 16 進位下的 0x1000)個位元組。各位讀者可以留意到,雖然虛擬記憶體與物理記憶體位址格式略有不同,但最後都有 12 個位元個空間用來表示頁面偏移量(page offset),因為 12 個位元的空間恰好可以用來定位一個頁面內的每一個位元組的位置。
5. 頁表(page table):整個位址轉移的過程,就是相關的硬體(MMU 或 TLB)以虛擬位址為輸入,物理位址為輸出。其中的祕訣,是因為有一個概念上呈現樹狀的頁表讓硬體能夠查詢並對照。
6. 頁表項(page table entry,PTE):頁表的最小單位,每一筆 頁表項都會對應到一個頁面,內容記載著該頁面的屬性(低位的 8 個位元),以及與物理記憶體位址格式一樣的、分成三段的物理頁面編號:

|63     54|53    28|27    19|18    10|9   8|7|6|5|4|3|2|1|0|
+---------+--------+--------+--------+-----+-+-+-+-+-+-+-+-+
| reserve | PPN[2] | PPN[1] | PPN[0] | RSW |D|A|G|U|X|W|R|V|
+---------+--------+--------+--------+-----+-+-+-+-+-+-+-+-+

RISC-V 的位址轉換

上一小節的名詞解釋相當於是演員介紹,位址轉換的過程則相當於是正式演出了。假設一個虛擬位址 0xffffffc013579bdf 要轉換成物理位址 0x93779bdf 的話,在 satp 控制暫存器與記憶體中的頁表內容都必須有相對應的設置與硬體的轉換流程。接下來我們拆解整個流程,並且邊走訪邊設計路過的頁表項,使得這個轉換能夠成功。

起點:satp

整個建構完成的頁表會呈現樹狀結構,最深可以達到三層之多,而satp 的物理頁面編號(PPN)就是頁表的根。現在為了作為範例,我們就隨意假設一個物理位址作為頁表的根:0x80100000。需注意的是,物理位址要轉換為物理頁面編號的話,需要除以一個頁面大小,也就是除以 4096,或是邏輯運算的右移 12 個位元:0x80100 再擴充為 44 位元的 PPN 區段。

設置完成後的 satp 會像是:

  1. MODE 的內容是代表 Sv39的 8 (這是從規格書上對應的編碼)
  2. ASID 我們不需用到,設為 0
  3. PPN 設置為 0x80100,未展示的第 20 位元到第 43 位元為 0

第一層頁表

一個頁面的大小是 4096 個位元組,而一筆頁表項的大小是 8 個位元組,也就是說,每一個頁面可以存放 512 個頁表項。

上個小節,satpPPN 設置了 0x80100,代表頁表的根,也就是說,這一組虛擬到物理的記憶體位址轉換的第一站,這第一個頁表項的 8 個位元組,實際上位在該頁面(也就是範圍 0x80100000~0x80101000)。然而,又是 512 當中的哪一個頁表項呢?

決定這件事情的,就是 VPN[2] 項,也就是虛擬記憶體位址的第 38 位元到第 30 位元的這 9 個位元。9 個位元,恰好可以表示最小為 0,最大為 511 的數值,也就作為定位頁表的頁面當中的特定頁表項的索引。若是 0 的話,就對應到 0x80100000 的頁表項;1 的話,就對應到 0x80100008;2 的話,就對應到 0x80100010;511 的話,就是最後一個頁表項的 0x80100ff8 。相當於是將 VPN[2] 乘以 8,再加上該物理頁面位址。

以這個例子來說,0xffffffc013579bdfVPN[2] 相當於是 b'100000000,最左端的位元 1 是來自 c 的尾端的位元,而其餘都是 0,是因為最靠近的位元 1 在第 28 個位元的部分。

所以,第一層的頁表項位在 0x80100800

第一層的頁表項

我們這個假定的情境,沒有預先設定記憶體內容,所以這裡可以隨便我們設計。這裡就令 0x80100800 起算 8 個位元組內的這個頁表項的值為 0x00000000_20040401。對應到先前介紹頁表項的每一個欄位,拆解如下:

  • V(第 0 個位元):有效與否的指示位元。如果這個位元的值是 0 的話,那麼這 8 個位元組當中的資料都不是有效的頁表項。所以這裡當然是 1。
  • R(第 1 個位元):對應到的頁面是否具有讀取權限。這裡是 0。
  • W(第 2 個位元):對應到的頁面是否具有寫入權限。這裡是 0。
  • X(第 3 個位元):對應到的頁面是否具有執行權限。這裡是 0。怪了,綜合讀取、寫入與執行三種權限來看,這個頁面都是沒有權限的話,那到底對應到的有效頁面又能做什麼?這個就是 RISC-V 的一個設計:如果頁表項所對應到的頁面空有有效性質,卻缺乏其他三項權限,那就是代表下一個層級的頁表。
  • U、G、A、D(第 4 到第 7 位元):其他我們尚未使用的屬性,這裡都是 0。
  • RSW(第 8 到第 9 位元):保留。這裡都是 0。
  • PPN(第 10 到第 53 位元):這個頁表項所對應的物理頁面編號。這裡是 0x80101
  • Reserve(第 54 位元到第 63 位元):保留,這裡都是 0。

所以,第二層的頁表,物理位址在 0x80101000

第二層的頁表項

和第一層取得頁表項時的方法相同,只是當時的索引由 VPN[2] 取得,現在則要退一階,使用 VPN[1] 的 9 個位元(第 29 到第 21 位元,也就是 b'010011010,也就是 0xffffffc013579bdf135 當中的 1 取 後 2 位元,3 取全部 4 位元,5 則取前 3 位元)。

讀者可以自己驗算,第二層的頁表項,在 0x801014d0

我們令 0x00000000_20040801 為這個頁表項的內容。由於權限位元與第一層完全相同,所以還有第三層的存在。計算 PPN 之後,不難推得第三層的頁表在物理頁面 0x80102000 之處。

第三層的頁表項

VPN[0] 取得索引為 b'101111001,也就是說第三層的頁表項為在 0x80102bc8 之處。

我們令 0x00000000_24dde40f 為這個頁表項的內容。與第一層時不同的部分在於:

  • R、W、X 權限位元全部開啟,可知對應到的物理頁面權限全開,可讀寫也可執行。
  • 物理頁面編號,可以從 0x24dde4 向右移 2 個位元計算而得,正是為了搭配我們原本的目標位址 0x93779bdf 所屬頁面的頁面編號 0x93779。至此就大功告成了。

結論:所需配置的頁表內容

// 狀態暫存器內容,指定第一層頁表為 0x80100000
// 最高位的位元代表 Sv39 的啟用
satp = 0x80000000_00080100

// 第一層頁表項設置,0x800 來自 VPN[2] 的 b'100000000
// PPN 為 0x80101,表示第二層頁表為 0x80101000
*0x80100800 = 0x00000000_20040401

// 第二層頁表項設置,0x4d0 來自 VPN[1] 的 b'010011010
// PPN 為 0x80102,表示第三層頁表為 0x80102000
*0x801014d0 = 0x00000000_20040801

// 第三層頁表項設置,0xbc8 來自 VPN[0] 的 b'101111001
// PPN 為 0x93779,表示對應到的物理頁面為 0x93779000
// 權限位元已經全數設置,所以轉換到此結束
*0x80102bc8 = 0x00000000_24dde40f

只要有這 satp 狀態暫存器的設置搭配三個作為頁表項的記憶體內容,那軟體使用虛擬位址 0xffffffc013579bdf 的時候,就應當可以對應到物理位址 0x93779bdf 了。

這個範例裡面,Sv39 最大的三層頁表都已經走訪完畢,而最後餘下 12 個低位位元,是用來存取一個 4KB 頁面內的偏移量。

RISC-V 可以支援頁表項在第一層甚至第二層就取得足夠的權限,而立刻完成轉換。第一層就完成的情況,相當於是一個巨型頁面(gigapage)的轉換,偏移量就使用虛擬位址中的 VPN[1]VPN[0] 與最低位 12 個位元共 30 個位元;偏移量使用 30 個位元就代表可以定址 1GB 的內容。第二層就完成的狀況,相當於是一個中型頁面(megapage)的轉換,偏移量就使用虛擬位址中的 VPN[0] 與最低位 12 個位元共 21 個位元;偏移量使用 21 個位元就代表可以定址 2MB 的內容。

啟用虛擬記憶體

所以我們來做個實驗吧!先使用物理位址 0x93779bdf,在這裡設置一個位元組的值之後,按照前一小節的做法,先特別為這個頁面設置轉換用的頁表,然後寫入 satp 狀態暫存器以啟用虛擬記憶體。然後我們觀察,先前設置的值是否能夠透過虛擬位址 0xffffffc013579bdf 存取。

對應頁表設置,在 src/runtime/rt0_opensbi_riscv64.s 當中:

diff --git a/src/runtime/rt0_opensbi_riscv64.s b/src/runtime/rt0_opensbi_riscv64.s
index 5676184343..c062c0adea 100644
--- a/src/runtime/rt0_opensbi_riscv64.s
+++ b/src/runtime/rt0_opensbi_riscv64.s
@@ -79,5 +79,32 @@ zeroize:
        ADD     $8, T0, T0
        BLT     T0, T1, zeroize
 
+       // TEST: 'A' at 0x93779bdf
+       MOV     $0x93779bdf, T0
+       MOV     ZERO, T1
+       ADD     $0x41, T1, T1
+       SB      T1, 0(T0)
+       // Level 3
+       MOV     $0x80102bc8, T0
+       MOV     ZERO, T1
+       ADD     $0x24dde40f, T1, T1
+       SD      T1, 0(T0)
+       // Level 2
+       MOV     $0x801014d0, T0
+       MOV     ZERO, T1
+       ADD     $0x20040801, T1, T1
+       SD      T1, 0(T0)
+       // Level 1
+       MOV     $0x80100800, T0
+       MOV     ZERO, T1
+       ADD     $0x20040401, T1, T1
+       SD      T1, 0(T0)
+       // SATP
+       MOV     $0x80000000, T0
+       SLL     $32, T0, T0
+       ADD     $0x80100, T0, T0
+       CSRRW   CSR_SATP, T0, X0
+
        MOV     $runtime·rt0_go(SB), T0

這裡的程式碼並沒有包含虛擬位址的檢驗,原因等一下我們就會看到了。但我們可以使用除錯器,並在 satp 啟用虛擬記憶體之後,觀察記憶體的內容。

當然,我們也必須增加 satp 暫存器,(而且,由於這個改動會動到工具鏈,所以必須要重編)

diff --git a/src/runtime/opensbi/csr.h b/src/runtime/opensbi/csr.h
index 2ee6d54498..bfb7f7a880 100644
--- a/src/runtime/opensbi/csr.h
+++ b/src/runtime/opensbi/csr.h
@@ -7,3 +7,4 @@
 #define CSR_SEPC       $0x141
 #define CSR_SCAUSE     $0x142
 #define CSR_STVAL      $0x143
+#define CSR_SATP       $0x180

使用除錯器檢驗虛擬記憶體

如前幾日展示的那樣,先啟動 QEMU 模擬器

$ make run EXTRA_FLAGS='-S -s'             
make -C ethanol/                                
make[1]: 進入目錄「/home/noner/FOSS/hoddarla/ithome/ethanol」
make[1]: 對「all」無需做任何事。                
make[1]: 離開目錄「/home/noner/FOSS/hoddarla/ithome/ethanol」
qemu-system-riscv64 \                           
        -smp 4 \                                                                                
        -M virt \                               
        -m 512M \                               
        -nographic \                            
        -bios misc/opensbi/build/platform/generic/firmware/fw_jump.bin \
        -device loader,file=ethanol/goto/goto.bin,addr=0x80200000 \
        -device loader,file=ethanol/ethanol,addr=0x80201000,force-raw=on -S -s

注意,我們這裡已經將記憶體大小(-m 參數)調整為 512MB,因為 0x93779xxx 位址已經超過原先的 256MB 大小了。

然後在另外一個終端機開啟除錯器:

$ riscv64-elf-gdb -ex "target remote :1234"                                                     
GNU gdb (GDB) 10.2 
Copyright (C) 2021 Free Software Foundation, Inc.
...
determining executable automatically.  Try using the "file" command.
0x0000000000001000 in ?? ()
(gdb) b *0x8025557c
Breakpoint 1 at 0x8025557c
(gdb) c
Continuing.
[Switching to Thread 1.4]

Thread 4 hit Breakpoint 1, 0x000000008025557c in ?? ()
(gdb) x/i $pc
=> 0x8025557c:  csrw    satp,t0

這個斷點是預先使用 objdump 工具找到的。所以在這裡為止,我們都還是在物理位址模式下運作。

(gdb) x/b 0x93779bdf                             
0x93779bdf:     0x41
(gdb) si
0x0000000080255580 in ?? ()
(gdb) x/b 0xffffffc013579bdf
0xffffffc013579bdf:     0x41

完全符合預期。我們也可以做一些周邊的檢驗:

(gdb) x/b 0x93779bdf
0x93779bdf:     Cannot access memory at address 0x93779bdf
(gdb) set $satp=0
(gdb) x/b 0x93779bdf
0x93779bdf:     0x41
(gdb) x/b 0xffffffc013579bdf
0xffffffc013579bdf:     Cannot access memory at address 0xffffffc013579bdf

這時候,原先的物理位址已經無法使用。而若重新將 satp 寫回 0 以關閉虛擬記憶體,物理位址就會又重新可以使用,反而是虛擬位址的存取會由除錯器回報無法存取。

(gdb) set $satp=0x8000000000080100
(gdb) x/b 0xffffffc013579bdf
0xffffffc013579bdf:     0x41
(gdb) set *0xffffffc013579bdf=0x42
(gdb) x/b 0xffffffc013579bdf
0xffffffc013579bdf:     0x42
(gdb) set $satp=0
(gdb) x/b 0x93779bdf
0x93779bdf:     0x42

我們也可以反過來重新啟用之後,修改記憶體內容,再關閉虛擬記憶體,且於物理位址驗證確實內容已經遭到修改了。

所以,這就是成功了!

試跑

可以存取 github 以進行以下實驗。

但如果直接執行現在的 ethanol,我們會得到的結果是:

$ qemu-system-riscv64 \                           
        -smp 4 \                                                                                
        -M virt \                               
        -m 512M \
...
Boot HART MHPM Count      : 0
Boot HART MIDELEG         : 0x0000000000000222
Boot HART MEDELEG         : 0x000000000000b109
HI000000000000000c
000000008024fb40
000000008024fb40

卡在這裡動彈不得!也出現了第一次看到的 scause 內容為 0xc 的例外。關於啟動虛擬記憶體,我們才剛開始而已。

小結

予焦啦!我們今天徹底走過一輪 RISC-V 將虛擬記憶體位址轉換為物理位址的過程,並且針對一個頁面,實際操作所需要的修改。雖然針對該頁面內容,系統行為符合預期,但是後續執行之後,不確定原因的卡住了。至於實際情況為何,我們馬上就會探討到。

各位讀者,我們明日再會!


上一篇
予焦啦!BSS 初始化
下一篇
予焦啦!在 ethanol 中啟用虛擬記憶體
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索32

尚未有邦友留言

立即登入留言