本節是以 Golang 上游 4b654c0eeca65ffc6588ffd9c99387a7e48002c1 為基準做的實驗
予焦啦!昨日直接瞄準一組虛擬到物理位址的轉換,並展示每一個步驟所需要的資料。最後自由運行(free run)下去,卻發現整個系統卡住了。其實啟用虛擬記憶體,需要更周全的準備才行。
SFENCEVMA
指令還記得嗎?我們只準備了三個頁表項(page table entry),其中兩個頁表項是僅設置有效位元(V bit)的中繼站,最後一個才是第三層的終端節點,給一組特定的 4KB 頁面轉換。所以若要使用虛擬位址存取該頁面之外的記憶體空間,再不擴增更多頁表項的話,是不可能的。筆者曾提及,完成更高的的頁表會是樹狀結構(只有有效位元的頁表項就是其中的節點。而終端節點的讀取、寫入或是可執行屬性必然不可全為 0)。當然,昨日的一條鞭式設置廣義來說是一種簡化的樹,但顯然包含的範圍不夠廣。
應該說,我們可以設想一下 satp
暫存器剛執行完寫入之後的情境。在 csrw
指令過後,程式指標(pc
)向前推進一道指令,落在的位置,其實也是個物理位址!畢竟,CPU 的擷取指令單元(instruction fetch)並沒有厲害到知道我們改動位址或是如何改動,所以一定也是依序往前推進。而且,我們並沒有為 0x80200000
這附近的值設置頁表項,而是遠在 0x93779xxx
這個頁面才有。
反正昨日也只是個範例,不妨就讓我們來重新調整,將從 0x80202000
開始的程式碼區域造成可以從 0xffffffc000002000
虛擬位址映射到的區域。
昨日的範例走訪了完整三層頁表項,製造了 4KB 的對應;但其實也如筆者註記中提及的那樣,我們也可以製造大一點的對應。比方說,其實現階段的 ethanol 非常小,只有 1MB 出頭而已。所以我們可以改造成,只走兩層頁表項,對應到 2MB 的區域。
這需要以下的改動:
- // TEST: 'A' at 0x93779bdf
...
- // Level 3
...
// Level 2
- MOV $0x801014d0, T0
+ MOV $0x80101000, T0
MOV ZERO, T1
- ADD $0x20040801, T1, T1
+ ADD $0x2008000f, T1, T1
其實只有第二層需要改動。原先測試用的寫入 A
字元不再需要了,刪去;第三層也不需要了,刪去。第一層爲什麼不需要動呢?因為
0xffffff c01 3579bdf
和
0xffffff c00 0002000
這兩個虛擬位址的 VPN[2]
一樣都是 b'100000000
,再加上我們沒有調整 satp
控制暫存器,所以第一層的設定完全一樣。
第二層,不像昨日的範例虛擬位址的 VPN[1]
有值。這裡完全是 0,所以第二層的頁表項就直接在 0x80101000
的位址。但這個頁表項換算起來對應到 0x80200000
的物理頁面編號,而不是 0x80202000
,為什麼這樣還能用呢?因為,這次在第二層結束了位址的轉換,因此不只最後的 12 位元,連 VPN[0]
的 9 個位元也會被納入,作為 2MB 大小的中型頁面(megapage)的頁面內偏移量。這裡就是 0x002000
(因為用的是十六進位表示法,多了最高位的 3 個 0),補上物理頁面編號就成為我們的目標 0x80202000
,沒有問題。
使用除錯器驗證,也和昨天一樣容易:
Thread 1 hit Breakpoint 1, 0x000000008025554c in ?? ()
(gdb) x/2gx 0x80202000
0x80202000: 0xfd010593010db503 0x00050f9700b56863
(gdb) x/2gx 0xffffffc000002000
0xffffffc000002000: Cannot access memory at address 0xffffffc000002000
(gdb) si
0x0000000080255550 in ?? ()
(gdb) x/2gx 0xffffffc000002000
0xffffffc000002000: 0xfd010593010db503 0x00050f9700b56863
可見,程式碼內容都有確實對應出來。可是問題是,現在的情況也如前一小節的分析,這裡的程式指標仍然對應到物理位址,沒有跳轉。就算在寫入暫存器指令後立刻放置一道跳躍指令,也沒有辦法讓它跳轉,畢竟光程式指標就停留在物理位址了,CPU 根本無法取得該處的指令加以執行。
但這當然是做得到的。老樣子,我們可以參考作業系統老大哥 Linux 怎麼處理這個部分:
la a1, kernel_virt_addr
...
/* Point stvec to virtual address of intruction after satp write */
la a2, 1f
add a2, a2, a1
csrw CSR_TVEC, a2
讓我們直譯這段註解:將 stvec
狀態暫存器設定為,指向,寫入 satp
的指令,之後的指令,的虛擬位址?為什麼要這麼做呢?我們先前已將 stvec
指向 early_halt
,當時生效的當然都是物理位址。
...
/*
* Load trampoline page directory, which will cause us to trap to
* stvec if VA != PA, or simply fall through if VA == PA. We need a
* full fence here because setup_vm() just wrote these PTEs and we need
* to ensure the new translations are in use.
*/
直譯的話是:載入跳躍用(trampoline)頁表。若虛擬位址與物理位址不同,我們可以藉此跳到 stvec
;反之,則可以直接繼續執行下去。這裡需要完整的籬(fence),因為 setup_vm()
函式才剛寫入這些頁表項,而我們必須確保新的轉換正常運作。
...
sfence.vma
csrw CSR_SATP, a0
.align 2
1:
/* Set trap vector to spin forever to help debug */
la a0, .Lsecondary_park
csrw CSR_TVEC, a0
在第一段中的 la a2, 1f
效果是載入第三段的 1:
標籤的位址到 a2
暫存器。但由於這時候還是物理位址,所以需要再加上 Linux 自己維護的虛擬位址偏移值,獲得一個以虛擬位址表示的 1:
,並將之作為錯誤發生時的進入點而寫入 stvec
。
因此整個流程是像這樣:當第三段中的 satp
控制暫存器的寫入生效之後,虛擬位址啟用(根據規格以及 CPU 提供的功能),之後 CPU 仍然會試著去下一行的物理位址取指令,而這個位址不可能是個合法的虛擬位址,所以對於 RISC-V 來說會觸發指令缺頁錯失(instruction page fault)。這是一個例外(exception),所以當然就會進入到陷阱向量 stvec
。
第一段已經在 stvec
內準備了虛擬位址表示的 1:
,所以實際上,程式就會實際上原地著執行下去了,雖然概念上,這已經經歷了一個位址轉換,其變動可以說是相當劇烈的。
這段的啟動過程中,還包含最後一個謎團。
sfence.vma
指令也許再過一陣子,官方會通過權限指令更新,提出新的一些指令來更加細分這個指令相關的行為。
這個作業系統模式的權限指令全名為記憶體管理藩籬指令(Supervisor Memory Management Fence Instruction),可以用來保障在同一個硬體核心(hart)的兩種事件之間的執行順序:
之所以需要這個指令,是因為現代 CPU 為了效能很可能最佳化指令的順序,進而打亂原本軟體真正想要呈現出來的邏輯。所以,所謂藩籬(fence)型的指令,就是為了這種場合而存在。
QEMU 畢竟是個模擬器,似乎未必有模擬到那麼真實的硬體行為。筆者我們目前為止使用過數個版本的 QEMU 驗證本系列文的實驗,有時儘管沒有正確地使用
sfence.vma
指令,也還是跑出了預期的結果。當然,邏輯上對真實硬體來說,這些偶然並不是正確的做法就是了。今天的進度也會包含到相關的修正。
以啟動流程為例,寫入 satp
的稍早已經寫過一些頁表項到記憶體中,所以必須使用 sfence.vma
指令使之生效。
為了讓 Golang 工具鏈支援這個指令,這裡加入以下修正並重編:
diff --git a/src/cmd/internal/obj/riscv/obj.go b/src/cmd/internal/obj/riscv/obj.go
index fcb953d03d..e8614471b1 100644
--- a/src/cmd/internal/obj/riscv/obj.go
+++ b/src/cmd/internal/obj/riscv/obj.go
@@ -1682,11 +1682,12 @@ var encodings = [ALAST & obj.AMask]encoding{
// Privileged ISA
// 3.2.1: Environment Call and Breakpoint
- AECALL & obj.AMask: iIEncoding,
- AEBREAK & obj.AMask: iIEncoding,
- AWFI & obj.AMask: iIEncoding,
- ACSRRW & obj.AMask: iIEncoding,
- ACSRRS & obj.AMask: iIEncoding,
+ AECALL & obj.AMask: iIEncoding,
+ AEBREAK & obj.AMask: iIEncoding,
+ AWFI & obj.AMask: iIEncoding,
+ ACSRRW & obj.AMask: iIEncoding,
+ ACSRRS & obj.AMask: iIEncoding,
+ ASFENCEVMA & obj.AMask: iIEncoding,
// Escape hatch
AWORD & obj.AMask: rawEncoding,
@@ -1860,7 +1861,7 @@ func instructionsForProg(p *obj.Prog) []*instruction {
ins.funct7 = 2
ins.rd, ins.rs1, ins.rs2 = uint32(p.RegTo2), uint32(p.To.Reg), uint32(p.From.Reg
)
- case AWFI, AECALL, AEBREAK, ARDCYCLE, ARDTIME, ARDINSTRET:
+ case AWFI, ASFENCEVMA, AECALL, AEBREAK, ARDCYCLE, ARDTIME, ARDINSTRET:
insEnc := encode(p.As)
if p.To.Type == obj.TYPE_NONE {
ins.rd = REG_ZERO
我們現在只缺少最後一塊拼圖:stvec
的設置。然而,為了設置這個控制暫存器,我們必須要能夠取得物理位址到虛擬位址的偏移量,加上執行時期的物理位址,才能夠用以設置。
本小節大幅參考自 OpenSBI 的實作,搭配一些 Golang 環境的處理。
物理位址必須在執行期才能取得,而理論上虛擬位址是連結時就已經可以決定的值,所以只要設法在執行期讓兩者相減之後,就是一個可以通用的偏移量了。為此,先建一個 Golang 檔案:
$ cat src/runtime/opensbi/pivot.go
package opensbi
import "unsafe"
var Pivot uintptr
var LoadAddr uintptr
var LinkAddr = uintptr(unsafe.Pointer(&Pivot))
取這個檔案名稱為軸(pivot),是因為我們將以它為基準來比較這兩種位址。LinkAddr
在編譯時就能夠指定其內容為 Pivot
變數的指標。由於這在編譯完成之後就已經賦值,因此我們可以預期 LinkAddr
變數存在資料區段,且可以直接觀察到這個值的內容:
[ 9] .noptrdata PROGBITS ffffffc0000aa020 000a9020
00000000000012a0 0000000000000000 WA 0 0 32
[10] .data PROGBITS ffffffc0000ab2c0 000aa2c0
0000000000001990 0000000000000000 WA 0 0 32
...
1086: ffffffc0000d7838 8 OBJECT GLOBAL DEFAULT 12 runtime/opensbi.Pivot
1087: ffffffc0000d7830 8 OBJECT GLOBAL DEFAULT 12 runtime/opensbi.LoadAddr
1088: ffffffc0000aa070 8 OBJECT GLOBAL DEFAULT 9 runtime/opensbi.LinkAddr
使用 readelf 工具可以判斷出這個變數在 noptrdata
區段,而且換算起來是對應在檔案內的 0xa9070
,可以用 hexdump 工具觀察:
$ hexdump -C hw | less
...
000a9060 00 00 10 00 00 00 00 00 00 00 10 00 00 00 00 00 |................|
000a9070 38 78 0d 00 c0 ff ff ff 00 00 00 00 00 00 00 00 |8x..............|
000a9080 0e 00 00 00 00 00 00 00 03 00 00 00 00 00 00 00 |................|
是的,0xffffffc0000d7838
正是 Pivot
所在的位址。
其實沒有特別的理由另外將這些變數藏在另外一個組件裡面,但是筆者認為這樣可以把概念上更加特屬於 opensbi 的東西獨立於
runtime
組件之外。
至於執行期才能夠計算的物理位址,我們也在以下函式中計算
$ cat src/runtime/indirect_opensbi.go
package runtime
import (
"runtime/opensbi"
"unsafe"
)
var AddrOffset uintptr
func setAddrOffset() {
opensbi.LoadAddr = uintptr(unsafe.Pointer(&opensbi.Pivot))
AddrOffset = opensbi.LinkAddr - opensbi.LoadAddr
}
雖然看起來一樣是從 Pivot
變數的指標賦值,但連結期決定與執行期決定的意義截然不同,前者對應到我們預期調整的虛擬位址,後者則對應到硬體相關的物理位址。然後,我們可以在組語當中呼叫這個函式,以設定 AddrOffset
變數。
+ // setup stvec for enabling VA
+ MOV $_rt1_riscv64_opensbi(SB), A0
+ CALL runtime·setAddrOffset(SB)
+ MOV $runtime·AddrOffset(SB), A1
+ LD 0(A1), A1
+ ADD A0, A1, A0
+ CSRRW CSR_STVEC, A0, X0
這裡我們有個新的函式 _rt1_riscv64_opensbi
是打算在虛擬位址啟用之後跳轉的目的地。這裡我們寫入的是,已經補上偏移量而成為虛擬位址的結果。
結合之前所提及的所有要點,啟用虛擬位址且繼續運行也不是那麼困難。
+ SFENCEVMA
CSRRW CSR_SATP, T0, X0
+ // never reach here
+ NOP
+ EBREAK
+
+TEXT _rt1_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
+ MOV $early_halt(SB), A0
+ CSRRW CSR_STVEC, A0, X0
MOV $runtime·rt0_go(SB), T0
JALR ZERO, T0
可以存取 github 以進行以下實驗。
...
Boot HART MIDELEG : 0x0000000000000222
Boot HART MEDELEG : 0x000000000000a109
HI000000000000000f
ffffffc00fdffff8
ffffffc000052450
試跑之後發現,會卡在 early_halt
回報的存取錯誤,而位置在 rt0_go
的第一行,將 ra
暫存器寫入 -8(sp)
的位址(ffffffc000052450
)。我們先前都還在使用物理位址的階段,使用的是 0x90000000
,即使是補上虛擬位址的偏移量,這個位址區域也沒有被我們稍早的調整涵蓋。我們稍早,只有對應一個 2MB 的頁面而已。
所以如果這裡我們再做一個調整:
+ // setup SP in VA
+ MOV $0x80202000, X2
+ ADD X2, A1, X2
這個位址算起來剛好在 ELF 檔裡面的程式區段的起頭處,而且又有被我們目前準備的 2MB 中型頁面涵蓋。又,A1
來自稍早計算的虛擬位址偏移量,這裡當然也需要提早補上才行,否則之後就沒有虛擬位址的堆疊指標可以使用了。這麼調整之後,再度試跑:
...
Boot HART MIDELEG : 0x0000000000000222
Boot HART MEDELEG : 0x000000000000b109
HI000000000000000f
0000000000000000
ffffffc00002e138
對應一下這個錯誤指標,可以發現我們走到一個比較遠的地方:runtime.fatalthrow
函式。看來踩到了相當嚴重的錯誤。
予焦啦!今天我們正式將虛擬位址啟用,放下去跑之後到達了發生嚴重錯誤的部分。至於為何如此,各位讀者,就讓我們明日再探討吧!