本節是以 Golang 上游 4b654c0eeca65ffc6588ffd9c99387a7e48002c1 為基準做的實驗
予焦啦!昨日最後觀察到的錯誤來自於連結器在連結時期就已經將虛擬記憶體位址存放在最後產出的可執行檔的資料區內。正常情況下來說,Golang 的可執行檔並沒有預期自己會面臨到記憶體位址切換的情況,這通常只有系統軟體才會如此操作。
延續昨日,我們沒有辦法再得過且過、修修改改就期望能夠通過 Golang 的執行期初始階段了。我們得支援虛擬記憶體才行,而且就是現在、馬上。
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.3
到4.5
章分別介紹了當前 RISC-V 支援的三種虛擬記憶體轉換模式:
Sv32
:僅支援 32-bit 系統Sv39
:是當前 64-bit 系統通常支援的模式,理論上可以花費 1G
的空間存放頁表而管理 512GB
的記憶體Sv48
:理論上可以支援到256TB
。我們將以 Sv39
作為主要的支援模式。接下來,這裡解釋一些馬上就會用到的概念:
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 |
+------+------+----------------------+
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 的讀者不必擔心,因為目前的版本已經是修正過的版本。
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|
+---------+--------+--------+--------+-----+-+-+-+-+-+-+-+-+
上一小節的名詞解釋相當於是演員介紹,位址轉換的過程則相當於是正式演出了。假設一個虛擬位址 0xffffffc013579bdf
要轉換成物理位址 0x93779bdf
的話,在 satp
控制暫存器與記憶體中的頁表內容都必須有相對應的設置與硬體的轉換流程。接下來我們拆解整個流程,並且邊走訪邊設計路過的頁表項,使得這個轉換能夠成功。
satp
整個建構完成的頁表會呈現樹狀結構,最深可以達到三層之多,而satp
的物理頁面編號(PPN
)就是頁表的根。現在為了作為範例,我們就隨意假設一個物理位址作為頁表的根:0x80100000
。需注意的是,物理位址要轉換為物理頁面編號的話,需要除以一個頁面大小,也就是除以 4096,或是邏輯運算的右移 12 個位元:0x80100
再擴充為 44 位元的 PPN
區段。
設置完成後的 satp
會像是:
MODE
的內容是代表 Sv39
的 8 (這是從規格書上對應的編碼)ASID
我們不需用到,設為 0PPN
設置為 0x80100
,未展示的第 20 位元到第 43 位元為 0一個頁面的大小是 4096 個位元組,而一筆頁表項的大小是 8 個位元組,也就是說,每一個頁面可以存放 512 個頁表項。
上個小節,satp
的 PPN
設置了 0x80100
,代表頁表的根,也就是說,這一組虛擬到物理的記憶體位址轉換的第一站,這第一個頁表項的 8 個位元組,實際上位在該頁面(也就是範圍 0x80100000~0x80101000
)。然而,又是 512 當中的哪一個頁表項呢?
決定這件事情的,就是 VPN[2]
項,也就是虛擬記憶體位址的第 38 位元到第 30 位元的這 9 個位元。9 個位元,恰好可以表示最小為 0,最大為 511 的數值,也就作為定位頁表的頁面當中的特定頁表項的索引。若是 0 的話,就對應到 0x80100000
的頁表項;1 的話,就對應到 0x80100008
;2 的話,就對應到 0x80100010
;511 的話,就是最後一個頁表項的 0x80100ff8
。相當於是將 VPN[2]
乘以 8,再加上該物理頁面位址。
以這個例子來說,0xffffffc013579bdf
的 VPN[2]
相當於是 b'100000000
,最左端的位元 1 是來自 c
的尾端的位元,而其餘都是 0,是因為最靠近的位元 1 在第 28 個位元的部分。
所以,第一層的頁表項位在 0x80100800
。
我們這個假定的情境,沒有預先設定記憶體內容,所以這裡可以隨便我們設計。這裡就令 0x80100800
起算 8 個位元組內的這個頁表項的值為 0x00000000_20040401
。對應到先前介紹頁表項的每一個欄位,拆解如下:
0x80101
。所以,第二層的頁表,物理位址在 0x80101000
。
和第一層取得頁表項時的方法相同,只是當時的索引由 VPN[2]
取得,現在則要退一階,使用 VPN[1]
的 9 個位元(第 29 到第 21 位元,也就是 b'010011010
,也就是 0xffffffc013579bdf
的 135
當中的 1 取 後 2 位元,3 取全部 4 位元,5 則取前 3 位元)。
讀者可以自己驗算,第二層的頁表項,在 0x801014d0
。
我們令 0x00000000_20040801
為這個頁表項的內容。由於權限位元與第一層完全相同,所以還有第三層的存在。計算 PPN
之後,不難推得第三層的頁表在物理頁面 0x80102000
之處。
由 VPN[0]
取得索引為 b'101111001
,也就是說第三層的頁表項為在 0x80102bc8
之處。
我們令 0x00000000_24dde40f
為這個頁表項的內容。與第一層時不同的部分在於:
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 將虛擬記憶體位址轉換為物理位址的過程,並且針對一個頁面,實際操作所需要的修改。雖然針對該頁面內容,系統行為符合預期,但是後續執行之後,不確定原因的卡住了。至於實際情況為何,我們馬上就會探討到。
各位讀者,我們明日再會!