iT邦幫忙

2021 iThome 鐵人賽

DAY 11
1
Software Development

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

予焦啦!在 ethanol 中啟用虛擬記憶體

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

予焦啦!昨日直接瞄準一組虛擬到物理位址的轉換,並展示每一個步驟所需要的資料。最後自由運行(free run)下去,卻發現整個系統卡住了。其實啟用虛擬記憶體,需要更周全的準備才行。

本節重點概念

  • RISC-V
    • 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)的兩種事件之間的執行順序:

  1. 隱式(implicit)的讀寫記憶體,而且是針對記憶體管理的內容(大致上就是在說各種頁表項的相關存取)
  2. 顯式(explicit)的讀寫記憶體

之所以需要這個指令,是因為現代 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 函式。看來踩到了相當嚴重的錯誤。

小結

予焦啦!今天我們正式將虛擬位址啟用,放下去跑之後到達了發生嚴重錯誤的部分。至於為何如此,各位讀者,就讓我們明日再探討吧!


上一篇
予焦啦!RISC-V 虛擬記憶體機制簡說
下一篇
予焦啦!虛擬記憶體啟用後的除錯
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索32

尚未有邦友留言

立即登入留言