iT邦幫忙

2021 iThome 鐵人賽

DAY 5
2
Software Development

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

予焦啦!支援 RISC-V 權限指令與暫存器

本節是以 Golang 上游 1a708bcf1d17171056a42ec1597ca8848c854d2a 為基準做的實驗。

予焦啦!回顧昨日,當我們期待系統輸出一個 H 後便在某處陷入未知的錯誤狀態時,我們觀察到的卻是這個 H 字母不斷印出的洗頻現象。

為何如此?就是接下來探究的重點問題。然而,要回應這個問題,我們首先還缺了一點工具。

本節重點概念

  • 基礎
    • Linux 早期錯誤處理
  • Golang
    • 編譯器物件定義
    • 支援新指令
  • RISC-V
    • 指令格式 I-type 簡述
    • wfi 權限指令
    • stvec 控制暫存器

簡單的實驗

事實上,我們可以透過一個簡單的實驗,證明我們昨日的分析與實作都還是正確的,至少在印出第一個 H 字元之後。使用以下修改:

@@ -9,6 +9,8 @@ TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        MOV     $1, A7
        MOV     $0, A6
        ECALL
+halt:
+       JMP     halt
        MOV     0(X2), A0       // argc

可以確保系統開機至此只經歷了唯一的單行道,之後就陷在 halt 標籤代表的無窮迴圈之中。 JMP 助憶符在這裏也是 Golang 對所有 CPU 架構都有支援的通用指令,代表無條件的跳躍。

提醒:由於這裡是 runtime 組件的修改,這個內容是會直接與要開發的程式碼連結在一起的,因此無需重編工具鏈。

...
Boot HART MIDELEG         : 0x0000000000000222
Boot HART MEDELEG         : 0x000000000000a109
H

果然停在這裡!這裡得請各位讀者先按捺想要直接往下除錯的工程師症狀,這裡有些事情可以先做。

早期錯誤處理:以 RISC-V Linux 為例

一個系統如果在它進入穩定狀態之前就遭遇了錯誤,那麼這時候可能連能夠正常回報錯誤訊息的能力都沒有。比方說,還沒有設置好控制台(console)輸出導致根本沒有錯誤訊息可以顯示的狀態、或是還沒有初始化儲存裝置以至於無法記錄系統日誌(log)之類的。

那麼,其實能夠做的事情,也就與我們這裡的做法不會相差太多了。由於這種事態非同小可,一定要想辦法保留現場才能夠提供足夠的資訊來給工程師除錯。我們可以參考一下 Linux 的 RISC-V 版本當中是怎麼處理這個問題的。

RISC-V Linux 的進入位置在於 arch/riscv/kernel/head.S 之中,從 GNU 連結器預設的 _start 符號開始。茫茫組語,正愁不知何謂早期錯誤處理之時,很快就可以看見這一段程式碼與註解:

    /* Set trap vector to spin forever to help debug */
    la a3, .Lsecondary_park
    csrw CSR_TVEC, a3

關鍵字是設置陷阱向量(trap vector),以能夠卡在無窮迴圈裡面幫助除錯。la 是 RISC-V 組語的虛擬指令(pseudo instruction),代表載入一個位址到暫存器。這裡就是將 secondary_park 函式的位址寫到 a3 當中。

由於控制暫存器(CSR,Control and Status Register)登場了,為了與通用暫存器(GPR,General-Purpose Register)作出區隔,之後若是只有簡說暫存器的時候,意指為後者,也就是像是昨日也看過的 a0a6a7,可參見拙作

筆者也覺得「陷阱向量」說起來很好笑,事實上也沒人這樣講,但國家教育研究院確實是這樣翻譯的。

csrw 的功能是「寫入控制暫存器」。這裡是將 a3 中的位址寫到 CSR_TVEC,這是 Linux 為了兼容機器模式與作業系統模式而做的巨集(macro),替換之後的控制暫存器名稱為 stvec,代表的即是陷阱向量。

在 RISC-V 中,例外(exception)與中斷(interrupt)都算是一種陷阱,因此 CPU 遭遇到陷阱事件時,當時程式的執行流程就會停在當時的狀態,跳轉到陷阱向量繼續執行下去。昨日介紹的 ecall 指令環境呼叫也是一種例外,實際上是跳轉到 mtvec 所對應的 OpenSBI 中的陷阱向量。

Linux 這裡的 CSR_TVEC 巨集若是在不同的模式下分別展開,就會分別成為stvecmtvec。控制暫存器的首字母代表他們運作的權限層級。

至於為什麼「來自 S-mode 的環境呼叫例外」歸屬於 mtvec 的管轄範圍,而 S-mode 的疑難雜症(也就是那些早期 Linux 錯誤處理想要涵蓋的範圍)歸屬於 stvec 管理,讀者可以先當作思考練習,日後有機會介紹。

secodary_park

那麼這個符號所代表的函式又做了些什麼呢?可見

.Lsecondary_park:
	/* We lack SMP support or have too many harts, so park this hart */
	wfi
	j .Lsecondary_park

從字面上來看,這個函式本來是要提供

  1. 系統僅提供單核心組態,因此多餘的核心需要進入這裡停車(park)
  2. 系統提供多核心組態,但實體硬體上太多核了,不應參與這個系統的開機,因此停止於此

然而現在,也有另一個用途,就是陷阱發生在系統早期階段時,可以讓他們停在這裡,不修改任何暫存器與記憶體狀態,這樣可以方便除錯。

註解中的 hart 名詞代表硬體執行緒(hardware thread)。

組語的內容方面,除了筆者原先描述的無窮迴圈模式之外,其中還有一個 wfi 指令,代表 Wait-For-Interrupt。要詳細介紹這個指令的話會有點超過本系列文範圍,事實上距離一個實驗性的系統要用到這個指令的完整功能也還有點遠,所以這裡就只簡單的描述:wfi 指令,CPU 可以合乎規格的將之實作成是省電用的指令,也可以是無作用(NOP:No Operation),這也是為什麼這個指令的標準用法都會包在迴圈之中。

如法炮製

那麼,我們就比照目前 RISC-V 世界當中功能最齊全也最成熟的 Linux,一模一樣的打造這個功能:

+TEXT early_halt(SB),NOSPLIT|NOFRAME,$0
+       WFI
+       JMP early_halt(SB)
+
 TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        MOV     $0x48, A0
        MOV     $1, A7
        MOV     $0, A6
        ECALL
+       MOV     early_halt(SB), A0
+       CSRRW   A0, STVEC, X0
...

控制暫存器指令

與 Linux 的不同點是,筆者使用了 csrrw 指令而非 csrw 指令,然而我們並不需要讀取原先的 stvec 的功能,為何如此選擇呢?這是因為實際上,控制暫存器指令(定義於基本指令集規格中的 -Zicsr 擴充)只有六個,它們分別是:

  1. csrrw:原子性(atmoic)地將控制暫存器的內容讀取出至指定的目標暫存器,並自指定的來源暫存器寫入值至控制暫存器中。由於原子性必須由 CPU 的真正硬體保證,因此並不會有讀寫衝突的問題。
  2. csrrwi:原子性地將控制暫存器的內容讀取出至指定的目標暫存器,並寫入常數值至控制暫存器中。
  3. csrrscsrrc:原子性地將暫存器的內容讀取出至指定的目標暫存器,並將來源暫存器解讀為位元欄(bit field),對控制暫存器進行設置(set)或是清除(clear)的動作
  4. csrrsicsrrci:上述敘述將來源暫存器取代為常數。

規格書中也認知到單純讀取或單純寫入功能的重要性,因此明訂幾個虛擬指令,這裡列出其中兩個(以 GNU 組譯器預設順序):

  1. csrwcsrrw x0, csr, rs,由 rs 暫存器寫至 csr 控制暫存器。之所以能夠這樣操作,是因為 RISC-V 的 x0 為僅供讀取的 0 值,所有對其寫入的效果皆會被忽略。
  2. csrrcsrrs rd, csr, x0,由 csr 控制暫存器寫至 rd 暫存器。要是使用 csrrw 作為本體,則會使用 x0 暫存器,也就會導致控制暫存器被清空。這裡因此使用原先為設置位元的 csrrs 指令,這麼一來就算以 x0(也就是全為 0 值的暫存器)做設置的動作,也不會有任何實際效果。

這裡筆者使用 CSRRW A0, STVEC, X0,只是與 GNU 採取反方向的指令,意圖上的語法是相同的。

錯誤:控制暫存器未定義

但是編譯之後,會有如下錯誤訊息:

$ GOOS=opensbi GOARCH=riscv64 go build -ldflags='-R 0x1000 -T -0x7fffffe000' ethanol/ethanol.go
# runtime
../go/src/runtime/rt0_opensbi_riscv64.s:17: illegal or missing addressing mode for symbol STVEC 
asm: assembly of ../go/src/runtime/rt0_opensbi_riscv64.s failed

也就是說,組譯器認為,STVEC 這個符號是非法的(illegal)或者定址模式(addressing mode)未定義。這也是很正常的事。原本 Golang 只處理使用者應用程式,根本不需要也沒有權限碰觸陷阱向量控制暫存器。

不巧的是,理論上使用者空間可以操作的一個控制暫存器,浮點數運算單元控制暫存器(fcsr:FPU CSR)在 Golang 裡面也實際上未使用,所以過往的 Golang RISC-V 框架內,並沒有一個能夠讓我們直接套用的設計。

這裡我們實際上有兩個選擇。一個是研究一般暫存器如何被組譯器解析,然後將控制暫存器加入類似的路徑,使它們在組譯過程中被正確地解析;另一個是利用 RISC-V 指令格式的做法,最小限度的完成控制暫存器的支援。

筆者在權衡利弊之後選擇後者。畢竟 Hoddarla 專案會不斷的隨著時間針對 Golang 上游 git 重定基底(rebase),後者可以保持最小改動。

控制暫存器指令格式

RISC-V 指令有六種不同的編碼格式,可以參考拙作有更深入的描述。

從規格書上可以觀察到,控制暫存器的指令編碼格式屬於 I-type,

| 31                20 | 19   15 | 14      12 | 11  07 | 06      00 |
+----------------------+---------+------------+--------+------------+
|   immediate[11:0]    |   rs1   |   funct3   |   rd   |   opcode   |
+----------------------+---------+------------+--------+------------+

只不過不一樣的是,控制暫存器指令的常數部在這裡代表的是控制暫存器的編號(定義在權限指令集之中)。以 stvec 為例,這個數字是 0x105。

作為一個先行版的測試,我們先試試看修改成這個寫法:

+TEXT early_halt(SB),NOSPLIT|NOFRAME,$0
+       WFI
+       JMP early_halt(SB)
+
 TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        MOV     $0x48, A0
        MOV     $1, A7
        MOV     $0, A6
        ECALL
+       MOV     early_halt(SB), A0
+       CSRRW   A0, 0x105, X0
        MOV     0(X2), A0       // argc 
        ADD     $8, X2, A1      // argv 

結果錯誤變成:

# runtime
../go/src/runtime/rt0_opensbi_riscv64.s:17: CSRRW: expected register; found $261
asm: assembly of ../go/src/runtime/rt0_opensbi_riscv64.s failed

相關的判斷在 src/cmd/asm/internal/asm/asm.go 檔案中,我們可以觀察到,中間的運算元總是被當作暫存器來解讀,因此我們現在的這種用法悖離組譯器的期待而被當作錯誤。

巧合的是,新增的 CSRRW 行之下的原本的兩行,恰好就是 I-type 的兩個代表:MOV 將會轉換成載入(ld),而 ADD 將會轉換成 addi,因為有一個參數是常數。兩種格式相比的話,雖然都不完美,但後者看起來不會帶有定址模式的語義,至少比較適合一點。

所以,再修改一個版本如下:

+TEXT early_halt(SB),NOSPLIT|NOFRAME,$0
+       WFI
+       JMP early_halt(SB)
+
 TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        MOV     $0x48, A0
        MOV     $1, A7
        MOV     $0, A6
        ECALL
+       MOV     early_halt(SB), A0
+       CSRRW   $0x105, A0, X0
        MOV     0(X2), A0       // argc

之後,錯誤又改變為

$ GOOS=opensbi GOARCH=riscv64 go build  -ldflags='-R 0x1000 -T -0x7fffffe000' ethanol/ethanol.go
# runtime
asm: encodingForAs: no encoding for instruction WFI
asm: encodingForAs: no encoding for instruction CSRRW
asm: assembly failed

錯誤變為,組譯器向我們抱怨,他遭遇到的 WFICSRRW 是它所不認識的指令編碼。至少現在我們沒有格式問題了。

Golang 如何支援 RISC-V 組合語言編碼

支援 WFI 指令

想要無中生有、直接支援新指令在工具鏈之中是很困難的,尤其是筆者並沒有相關的經驗。然而幸好,這裡有一個個方面來說都與 wfi 相似的指令,那就是 ecall。它們都是固定的編碼內容,也沒有常數或是暫存器作為運算元。

所以我們可以先參考 ECALL 在 Golang 裡面出現的地方,再將 WFI 照著做,理論上就可以完成相關的支援。所以我們搜尋:

$ grep ECALL -R ./src/cmd/internal/obj/riscv
./src/cmd/internal/obj/riscv/anames.go: "ECALL",
./src/cmd/internal/obj/riscv/cpu.go:    AECALL
./src/cmd/internal/obj/riscv/inst.go:   case AECALL:
./src/cmd/internal/obj/riscv/obj.go:            // SCALL is the old name for ECALL.
./src/cmd/internal/obj/riscv/obj.go:            p.As = AECALL
./src/cmd/internal/obj/riscv/obj.go:    AECALL & obj.AMask:  iIEncoding,
./src/cmd/internal/obj/riscv/obj.go:    case AECALL, AEBREAK, ARDCYCLE, ARDTIME, ARDINSTRET:

WFI 顯然就少很多:

$ grep WFI -R ./src/cmd/internal/obj/riscv
./src/cmd/internal/obj/riscv/anames.go: "WFI",
./src/cmd/internal/obj/riscv/cpu.go:    AWFI
./src/cmd/internal/obj/riscv/inst.go:   case AWFI:

因此看來關鍵就在 ./src/cmd/internal/obj/riscv/obj.go 檔案中的支援了。我們可以發現 WFI 本身其實是有被定義在 Golang 裡面的部分,且 inst.go 裡面的編碼也與規格書中並無不同。

ECALLobj.go 中的第一個出現,是在 progedit 函式中,為了將一個比較舊版規格中的 SCALL 替換為 ECALL 的操作,事實上 WFI 不需考慮。

第二個出現的用途,在一長串的變數宣告 var encodings = [ALAST & obj.AMask]encoding 當中,將自己定義為 I-type。稍後可以幫 WFI 加入為這種編碼格式。

第三個出現的用途,在函式 instructionsForProg 當中,大部份指令在這裡做暫存器順序的微調。我們就直接將 WFI 加入為 ECALL 的那個條件式內。

綜合以上,現在的修改為:

diff --git a/src/cmd/internal/obj/riscv/obj.go b/src/cmd/internal/obj/riscv/obj.go
index a305edab4b..a3f1d49936 100644
--- a/src/cmd/internal/obj/riscv/obj.go
+++ b/src/cmd/internal/obj/riscv/obj.go
@@ -1684,6 +1684,7 @@ var encodings = [ALAST & obj.AMask]encoding{
        // 3.2.1: Environment Call and Breakpoint
        AECALL & obj.AMask:  iIEncoding,
        AEBREAK & obj.AMask: iIEncoding,
+       AWFI & obj.AMask:    iIEncoding,
 
        // Escape hatch
        AWORD & obj.AMask: rawEncoding,
@@ -1857,7 +1858,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 AECALL, AEBREAK, ARDCYCLE, ARDTIME, ARDINSTRET:
+       case AWFI, AECALL, AEBREAK, ARDCYCLE, ARDTIME, ARDINSTRET:
                insEnc := encode(p.As)
                if p.To.Type == obj.TYPE_NONE {
                        ins.rd = REG_ZERO
diff --git a/src/runtime/rt0_opensbi_riscv64.s b/src/runtime/rt0_opensbi_riscv64.s
index c0de6b6716..cff93d3e82 100644
--- a/src/runtime/rt0_opensbi_riscv64.s
+++ b/src/runtime/rt0_opensbi_riscv64.s
@@ -4,11 +4,17 @@
 
 #include "textflag.h"
 
+TEXT early_halt(SB),NOSPLIT|NOFRAME,$0
+       MOV     $0x49, A0
+       MOV     $1, A7
+       MOV     $0, A6
+       ECALL       
+       WFI
+       JMP early_halt(SB)
+
 TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        MOV     $0x48, A0
        MOV     $1, A7
        MOV     $0, A6
        ECALL
+       MOV     $early_halt(SB), A0
+       //CSRRW $0x105, A0, X0
        MOV     0(X2), A0       // argc
        ADD     $8, X2, A1      // argv
        JMP     main(SB)

我們將 CSRRW 暫時註解掉,確認我們針對 wfi 的修改是否成功。重新編譯工具鏈並重新編譯 ethanol,再透過 objdump 工具觀察:

ffffff8000055418 <early_halt>:
ffffff8000055418:       10500073                wfi
ffffff800005541c:       00000f97                auipc   t6,0x0
ffffff8000055420:       ffcf8067                jr      -4(t6) # ffffff8000055418 <early_halt>
ffffff8000055424:       0000                    unimp

成功啦!

支援 CSRRW

WFIECALL 不同,在我們調整運算元順序之後,實際上這個指令已經和正常的 I-type 指令一模一樣,所以我們這裡直接先試試直接加上編碼模式:

$ git diff                                      
diff --git a/src/cmd/internal/obj/riscv/obj.go b/src/cmd/internal/obj/riscv/obj.go
index a3f1d49936..6180f6be79 100644
--- a/src/cmd/internal/obj/riscv/obj.go                                                         
+++ b/src/cmd/internal/obj/riscv/obj.go                                                         
@@ -1685,6 +1685,7 @@ var encodings = [ALAST & obj.AMask]encoding{
        AECALL & obj.AMask:  iIEncoding,                                                        
        AEBREAK & obj.AMask: iIEncoding,
        AWFI & obj.AMask:    iIEncoding,
+       ACSRRW & obj.AMask:  iIEncoding,
  
        // Escape hatch
        AWORD & obj.AMask: rawEncoding,

也將 CSRRW 原先的註解移除:

diff --git a/src/runtime/rt0_opensbi_riscv64.s b/src/runtime/rt0_opensbi_riscv64.s
index cff93d3e82..7ab893574c 100644
--- a/src/runtime/rt0_opensbi_riscv64.s
+++ b/src/runtime/rt0_opensbi_riscv64.s
@@ -14,7 +14,7 @@ TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        MOV     $0, A6
        ECALL
        MOV     $early_halt(SB), A0
-       //CSRRW $0x105, A0, X0
+       CSRRW   $0x105, A0, X0
        MOV     0(X2), A0       // argc
        ADD     $8, X2, A1      // argv
        JMP     main(SB)

結果是:

ffffff8000055428 <_rt0_riscv64_opensbi>:
ffffff8000055428:       0480051b                addiw   a0,zero,72
ffffff800005542c:       0010089b                addiw   a7,zero,1
ffffff8000055430:       0000081b                sext.w  a6,zero
ffffff8000055434:       00000073                ecall
ffffff8000055438:       00000517                auipc   a0,0x0
ffffff800005543c:       fe053503                ld      a0,-32(a0) # ffffff8000055418 <early_halt>
ffffff8000055440:       10551073                csrw    stvec,a0

看起來效果完全符合預期!但以現在的結果, 0x105 用來代表 STVEC,還是不太理想。幸好 Golang 也支援 .h 檔以及與 C 語言類似的語法,這裡我們可以加入一個新的檔案:

$ cat src/runtime/opensbi/csr.h
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// CSR encoding
#define CSR_STVEC     $0x105

並引用這個修改:

diff --git a/src/runtime/rt0_opensbi_riscv64.s b/src/runtime/rt0_opensbi_riscv64.s
index 7ab893574c..6a32edcc2d 100644
--- a/src/runtime/rt0_opensbi_riscv64.s
+++ b/src/runtime/rt0_opensbi_riscv64.s
@@ -3,3 +3,4 @@
 // license that can be found in the LICENSE file.
 
 #include "textflag.h"
+#include "opensbi/csr.h"
@@ -14,7 +15,7 @@ TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        MOV     $0, A6
        ECALL
        MOV     $early_halt(SB), A0
-       CSRRW   $0x105, A0, X0
+       CSRRW   CSR_STVEC, A0, X0
        MOV     0(X2), A0       // argc
        ADD     $8, X2, A1      // argv
        JMP     main(SB)

編譯仍然可以成功。

執行結果

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

...
Boot HART MIDELEG         : 0x0000000000000222
Boot HART MEDELEG         : 0x000000000000a109
HIQEMU: Terminated

果然陷入到 stvec 設定的早期錯誤陷阱之中了!除了 _rt0_riscv64_opensbiH,也印出了 early_haltI,然後就此卡在 wfi 指令當中了吧。

小結

予焦啦!今日的收穫是知道如何支援控制暫存器和原本應用程式等級的 Golang 無需使用的權限指令,並且也開始學習如何操作 RISC-V 在作業系統模式之下提供的控制暫存器 stvec

目前可以算是已經完成了主要的開發工具鏈:opensbi/riscv64 系統組合 Golang、能夠成功編譯簡單的 .go 檔、能夠運行在 QEMU 模擬器上。至此,第零章的開發工具階段告一段落。但接下來,我們先不急著推進,而是先把除錯工具搞定。各位讀者,我們明日再會!

至於為什麼昨日會是整面洗頻的 H 印出,這個問題且待我們後面比較熟悉除錯器之後再解開謎團。


上一篇
予焦啦!檢驗核心映像檔:開機流程、OpenSBI 慣例、ELF 淺談
下一篇
予焦啦!使用暫存器除錯
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索32

尚未有邦友留言

立即登入留言