iT邦幫忙

2021 iThome 鐵人賽

DAY 27
2
Software Development

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

予焦啦!基本的命令列

本節是以 Golang 上游 7ee4c1665477c6cf574cb9128deaf9d00906c69f 為基準做的實驗

予焦啦!昨天我們終於抵達了 Hello World,並也看到了它的效應,但花了更多時間理解並紀錄 UART 提供給軟體的程式設計。藉由監控運行在 QEMU 上的 Linux 的串列裝置驅動程式(serial device drive)行為,我們有一套範本,可以用來處理 UART 輸入的中斷。

所以,今天我們就拿起這把新武器,挑戰一下命令列吧。

本日重點概念

  • RISC-V
    • 外部中斷總覽
  • Golang
    • fmt.Scanf 函數
    • 頻道(channel)應用
    • 共常式排程

實作

前兩天埋首於程式碼中閱讀,並使用 GDB 來回尋訪 Linux 處理外部中斷的一整組行為,就是為了接下來的實作。實作分為兩部分,首先我們會先刻出一個可以處理外部中斷的基本外殼,確保真的能夠取得 UART 輸入之後,再填補如何應對輸入的部分。有了這兩個部分組合在一起,要組成一個類似命令列的使用者互動環境,也就不是難事。

又,這幾天的節奏都很快,今天筆者也沒有減速的意思。確實有很多技術債、很多以長遠發展來看應該要好好考慮的做法,但發展至今,先以階段性目標優先。

更直接地說就是,理想上 ethanol 核心應該要能夠好好地解析裝置樹,從中取得 PLIC 與 UART 的相關資訊再加以使用,但這裡筆者會先略過那些部分,而以編譯時可決定的常數代替裝置資訊。還有,為了簡化 PLIC 設定時的 context 選擇,筆者決定移除 QEMU 啟動時的 smp 選項,如此一來整個系統就是單純的單核心組態。

外部中斷的外殼

runtime 組件部分

首先,比照我們當初啟用的計時器中斷,我們也在 osinit 函數裡面啟用外部中斷:

        ethanol.TimerInterrupt(ON)
+       ethanol.ExternalInterrupt(ON)
        ethanol.Interrupt(ON)

src/runtime/ethanol/trap.gotrap.s 當中,當然也要有相對應的處置;與計時器中斷時的做法九成像,這裡就不再佔版面。總之,這裡會將 sie 控制暫存器的值,由原先只有啟用計時器中斷的 0x20,增加為也啟用了外部中斷的 0x220

另外,在負責分流中斷與例外的 ethanol_trap1 函數裡面,我們也新增一個外部中斷的區域:

                case TIMER_INTERRUPT:
                        ethanol.SetTimer()
                        _g_.m.life -= 1
+               case EXTERNAL_INTERRUPT:
+                       throw("external!!!")
                default:
                        throw("cannot handle other traps now")

main 組件部分

當然,只有這樣是沒有用的。我們經過前兩日的研究,已經知道,沒有 PLIC 中的設定與真正外部裝置的設定,即使 CPU 單方面啟用外部中斷,要是中斷進不來,也不會有任何作用。

所以這裡筆者在 ethanol/main.go 裡加入一些程式碼,順便也試試看一些比較像 Golang 的寫法吧。

main 說起
 func main() {
-       fmt.Println("Hello World!")
+       uart := uart{device{0xfffffff000000000, 0x10000000}}
+       plic := plic{device{0xfffffff000200000, 0xc000000}}
+
+       alld := make([]driver, 2)
+       alld[0] = uart
+       alld[1] = plic
+
+       for _, d := range alld {
+               d.deviceInit()
+       }
+
+       fmt.Printf("hdla > ")
+       for {
+       } 
... 

將 PLIC 與 UART 都做成 device,也就是裝置物件。裝置物件有兩個成員變數,分別是 pa,也就是對應到第 2 個參數的裝置物理位址,另一個則是作業系統模式在虛擬記憶體啟用的狀態底下能夠使用的虛擬位址 va。之所以使用這兩個成員,是因為我們需要將這兩個位址之間的轉換建立起來才行。

再次,這裡也是技術債。能夠使用的虛擬記憶體位址是需要好好管裡的,這裡貿然決定使用的這兩個位址,是筆者先行考察確定不會與他者衝突的虛擬位址。

接下來製作 alld,其型別為 driver 的切片(slice),包含先前宣告的兩個裝置物件。之所以 driver 型別能夠容納這兩個物件,是因為 driver 是一個介面型別(interface type),而之後我們會看到 plicuart 物件都實作了 driver 物件要求的函數,所以這裡可以如此賦值。

最後的迴圈就是走訪 alld,並執行 driver 介面當中的 deviceInit 函數。最後就是印出一個命令列提示 hdla >,然後卡在無窮迴圈裡面,否則整個系統本身就會馬上結束了。

上面提到了 4 種型別,是先前都沒有出現過的,分別定義如下:

型別定義
+type driver interface {
+       deviceInit()
+       write(off, val uintptr)
+       read(off uintptr) uintptr
+}
+
+type device struct {
+       va uintptr
+       pa uintptr
+}
+
+type uart struct {
+       device
+}
+
+type plic struct {
+       device
+}

如引用所示,uartplic 裝置物件都只包含 device 作為一個匿名成員。這約略可以看成 Golang 版的繼承。這裡的做法可以理解為不涉及到函數方法的繼承。

兩種結構使用的 deviceInit 當然必須有所不同,如下所示:

初始函數定義
+func (u uart) deviceInit() {
+       ethanol.MemoryMap(u.va, u.pa, 0x1000)
+       // Setup IER
+       u.write(uintptr(0x1), uintptr(1))
+}
+
+func (p plic) deviceInit() {
+       // Setup mapping
+       ethanol.MemoryMap(p.va, p.pa, 0x200000)
+       ethanol.MemoryMap(p.va+0x200000, p.pa+0x200000, 0x200000)
+       // Priority of UART (10)
+       p.write(uintptr(0x28), uintptr(1))
+       // Priority threshold of context 1
+       p.write(uintptr(0x201000), uintptr(0))
+       // Enable bit of UART (10) for context 1
+       p.write(uintptr(0x2080), uintptr(0x400))
+}

由於虛擬到實體的位址對應已經在 src/runtime/ethanol 裡面做好了,所以為了引用 MemoryMap 函數,必須要在 Golang 檔案的開頭處引用 runtime/ethanol,這裡省略。

UART 的狀態比較單純。只需要在中斷啟用暫存器(偏移量為 1)的地方設置第 0 個位元,則可以啟動接收字元的中斷。這裡呼叫了寫入函數,後續我們會在看到其中的細節。

PLIC 就較複雜些。就算作業系統的單核心只需要處理 context 1,根據規格,也仍然會使用到超過 2MB 的映射。所以這裡我們對應了兩組記憶體虛擬頁面,然後進行三組寫入的設定。前兩者是關於中斷優先權的設定,我們就比照正常運行時的 Linux 系統,將允許的閾值設為 0,並將 UART 所代表的優先權設為 1。最後一筆,則是啟用 UART 裝置作為一個外部中斷的來源。

輸出函數

至於這兩種裝置的 write 函數分別為何,

+func (u uart) write(off, val uintptr) {
+       writeb(u.va, off, uint8(val))
+}
+
+func (p plic) write(off, val uintptr) {
+       writew(p.va, off, uint32(val))
+}

它們使用的,具有型別資訊的 writex 函數,可參考這次新增的 ethanol/helper.s 檔案,

// func writeb(addr, offset uintptr, val uint8)
TEXT main·writeb(SB), NOSPLIT|NOFRAME, $0-17
        MOV     addr+0(FP), A0
        MOV     offset+8(FP), A1
        MOVBU   val+16(FP), A2
        ADD     A0, A1, A0
        MOVB    A2, 0(A0)
        RET

// func writew(addr, offset uintptr, val uint32)
TEXT main·writew(SB), NOSPLIT|NOFRAME, $0-20
        MOV     addr+0(FP), A0
        MOV     offset+8(FP), A1
        MOVWU   val+16(FP), A2
        ADD     A0, A1, A0
        MOVW    A2, 0(A0)
        RET

需注意的是邏輯上,這種寫入 MMIO 區域的動作,在 RISC-V 裡面都需要 fence 指令,使得輸入輸出與記憶體讀寫的順序符合預期,但這個部分筆者也先略過了。當然,邏輯上是有瑕疵的。

試跑

main 函數執行到底,印出 hdla > 的提示字元之後,隨意敲擊一個鍵,就會看到我們的外部中斷大獲成功:

Boot HART MEDELEG         : 0x000000000000b109
Memory Base: 0x80000000
Memory Size: 0x40000000
hdla > fatal error: external!!!

goroutine 0 [idle]:
runtime.throw({0xffffffc000098a58, 0xb})
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:965 +0x60
runtime.ethanol_trap1(0xffffffcf04037ee0)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/os_opensbi.go:314 +0xdc
runtime.ethanol_trap()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/sys_opensbi_riscv64.s:78 +0xe4

goroutine 1 [running]:
        goroutine running on other thread; stack unavailable

輸入的處理

處理輸入與輸出大不相同的地方在於,後者是為人所熟悉的(四處都有 Hello World)、主動的、同步的行為,前者則是被動的非同步行為,並且實際上橫跨許多抽象層,才能夠成功獲得輸入的結果。

以一般的 read 系統呼叫來講,很有可能的情境是,作業系統讓進行該系統呼叫的使用者執行緒進入睡眠模式,然後自己就做別的事去了。之後,作業系統一如往常地處理各種裝置中斷時,發現某個來自鍵盤的中斷,於是進入到鍵盤的驅動程式去處理。相關的資料則被儲存在某個緩衝區,等待其他核心執行緒接手,發現該資料應該要接到某個虛擬終端(pseudo terminal),於是就進行這個嫁接;而後也許就叫醒正在該終端工作的、正在睡眠狀態的執行緒,於是一個 read 呼叫才取得這個資料。

無論如何,我們現在就是要試著把簡單版本的這個過程做進來。和昨日我們印出的 write 函數相對的,我們一樣在 src/os/file_opensbi.go 裡面可以找到一個還沒有實作東西的 read 函數。

確認從 fmt.Scanfread 的連通性

試試將之插入一個 panic 呼叫在 read 函數中,然後在 main.main 主函數中呼叫 fmt.Scanf,分別像這樣:

 func (f *File) read(b []byte) (n int, err error) {
+       panic("???")
        return 0, nil

以及,

 func main() {
-       fmt.Println("Hello World!")
+       var s string
+       fmt.Scanf("%s", &s)
+       fmt.Println(s)

重新編譯並執行後,確實可以跑出錯誤的回溯。這裡就先省略了。除了證明這兩者的連通性,也能夠玩個小遊戲來讓這個 Scanf 回傳字串到 s 裡面,然後再透過後續的 Println 函數印出。遊戲性地修改 read 函數如下:

+var countdown = 6
+
 func (f *File) read(b []byte) (n int, err error) {
-       return 0, nil
+       switch countdown {
+       case 5:
+               b[0] = 'H'
+       case 4:
+               b[0] = 'e'
+       case 3:
+               b[0] = 'l'
+       case 2:
+               b[0] = 'l'
+       case 1:
+               b[0] = 'o'
+       case 0:
+               b[0] = ' '
+       }
+       countdown = countdown - 1
+       return 1, nil
 }

重新編譯後應該就可以看到,在 main 函數當中已經可以印出 Hello 字樣。雖然筆者中間省略了很多介紹,但憑藉著我們對於這兩種介面的認識,就可以理解這麼作能夠成功的原因。fmt.Scanf 給定格式字串 %s 的行為是,遭遇到廣義的空白字元之前,都繼續累積下來;遭遇到之後,則回傳。經過層層累積抵達的最後在 file 組件內的 read 函數,其行為則是回傳所取得的字元數量,並將那些字元存放在傳入的 b 字元切片中。所以這裡隨著一個倒數變數,逐步將 Hello 存入,上層會一直呼叫進來直到遭遇我們最後傳入的空白字元。

作戰計畫

上一小節只是展示我們對於 Golang 的 fmt 組件輸入方法的基本理解,接下來是該擬定作戰計畫了。讓我們試著達到這個流程吧:

  1. 主執行緒:main 函數裡面跑一個簡單的無窮迴圈,包著 fmt.Scanf 函數,試圖不斷取得使用者的輸入。搭配適當的輸出,應該可以讓它看起來很像命令列。
  2. 中斷處理執行緒:我們用 Golang 寫的陷阱處理函數 ethanol_trap1 裡面,如先前的小節所描述的,已經能夠感應到外部中斷事件。回顧昨日的 UART 行為紀錄,我們合理地處理(包含宣告(claim)與完成(complete))中斷事件,以確保 PLIC 和 UART 的狀態都維持正常而能夠持續接受使用者輸入。
  3. 中斷處理執行緒:稍早我們只有啟用接收到字元時的中斷。完成並清除這個中斷擱置的方法,是讀取接收暫存器;事實上,讀取接收暫存器之後,也能夠一併取得使用者輸入的字元。所以 UART 的完成應該不是問題。
  4. 中斷處理執行緒:將取得的字元直接寫到輸出暫存器 THR 去,這麼一來使用者就能看到自己的輸入。
  5. 中斷處理執行緒:PLIC 的部份也在前天介紹過宣告與完成暫存器,只要將代表 UART 的 10 寫回去,就能夠通知 PLIC 說,UART 裝置的這個中斷已經被解決了。所以 PLIC 應該也不是問題。
  6. 主執行緒:將 3. 步驟中取得的字元,傳遞給 1. 步驟中的 read 函數。
  7. 主執行緒:從 fmt.Scanf 回傳之後,針對取得的字串判斷處理一下,大致上就有個類似命令列的東西了。

筆者不確定各位讀者閱讀至此覺得哪一個最具挑戰性,但筆者認為是 6.。這個字元到底該怎麼跨越執行緒傳遞過去呢?先把簡單的都打通,最後再來處理這個部份好了。

1.7. 的命令列邏輯

與一般的 Golang 程式沒什麼兩樣,

+       fmt.Printf("hdla > ")
+       for {
+               var s string
+               fmt.Scanf("%s", &s)
+               fmt.Printf("\n")
+               if s == "exit" {
+                       os.Exit(0)
+               } else if s == "cheers" {
+                       fmt.Println("Hoddarla!")
+                       fmt.Println("Hoddarla is a OS project powered by RISC-V and Golang.")
+               }
+               fmt.Printf("hdla > ")
+       }

exit 命令能夠相當於離開整個程式,且定義 cheers 命令讓它輸出訊息。沒有什麼大不了的。

2.3.4.5. 的中斷處理邏輯

讓人煩惱的是,ethanol_trap1 身在 runtime 組件,而想要控制的 plicuart 都是 main 組件裡面才定義的 device 結構且實作了 driver 介面。如果可以的話,在 main 組件裡面執行簡單得多。

所以這裡我們借用 Golang 的 linkname 命令,讓外部中斷處理轉一手到 main 裡面的 eisr 函數處理。之所以這樣命名,是取外部中斷處理常式(External Interrupt Service Routine)。我們先如此改寫 ethanol_trap1

+//go:linkname eisr main.eisr
+func eisr(ctxt *rv64ctxt)
+
 //go:nosplit
 func ethanol_trap1(ctxt *rv64ctxt) {
        _g_ := getg()
...
                case TIMER_INTERRUPT:
                        ethanol.SetTimer()
                        _g_.m.life -= 1
+               case EXTERNAL_INTERRUPT:
+                       print("user input")
+                       fn := eisr
+                       fn(ctxt)
                default:

在中斷的情況下,先取得 eisr 作為一個函數指標,在予以呼叫。這迂迴的寫法在正常的 Golang 裡面也會使用到。因為 runtime 組件是其它組件的根源相依組件,所以它若要呼叫其它組件的函數,則使用這樣的作法,能夠讓連結器有所準備。

回到 ethanol/main.go 的部份,當然就是必須新增 eisr 函數:

+var plic0 plic
+var uart0 uart
+
+func eisr(c uintptr) {
+       pp := uint32(plic0.read(uintptr(0x201004)))
+       up := uint8(uart0.read(uintptr(0x0)))
+       uart0.write(uintptr(0x0), uintptr(up))
+       plic0.write(uintptr(0x201004), uintptr(pp))
+}

 func main() {
-       uart := uart{device{0xfffffff000000000, 0x10000000}}
-       plic := plic{device{0xfffffff000200000, 0xc000000}}
+       uart0 = uart{device{0xfffffff000000000, 0x10000000}}
+       plic0 = plic{device{0xfffffff000200000, 0xc000000}}
...

再欠個技術債吧,這裡我們讓兩個裝置成為全域的,好讓 maineisr 都能夠拿到。在 eisr 當中,第一行與最後一行是 PLIC 的宣告與完成。第二行是讀取接收暫存器;理論上,驅動程式的標準行為應該是要先檢查 UART 本身擱置的中斷類型為何,但我們只有啟用一種中斷,所以就連那一步都先省略了。第三行寫回偏移量 0 的位置,是因為直接寫的時候代表輸出。

執行看看的話,可以發現,輸入字元和 user input 字樣都會一併顯示出來。但是由於這些字元都沒有經過 fmt.Scanf 的過程,所以我們的命令列提示字元 hdla > 的部份都沒有動靜。應該會有類似下圖的操作結果(輸入 exit 與四個空格):

Memory Base: 0x80000000
Memory Size: 0x20000000
hdla > user input
euser input
xuser input
iuser input
tuser input
 user input
 user input
 user input

6. 使字元穿透組件

所以我們只剩下最後一個關卡。既然都用 Golang 來做這個專案了,那麼就試試看 Golang 的經典同步機制:頻道(channel)吧!

我們可以讓 read 在頻道的等待端,等待著接收一個字元;另一方面,在 eisr 裡面則是設定一個頻道的傳輸端,使字元穿透過去。首先是在 read 函數的新增:

+var UartChannel chan byte
+
 func (f *File) read(b []byte) (n int, err error) {
-       return 0, nil
+       b[0] = <-UartChannel
+       return 1, nil
 }

然後是 main 組件內的一些新增:

 func eisr(c uintptr) {
        pp := uint32(plic0.read(uintptr(0x201004)))
        up := uint8(uart0.read(uintptr(0x0)))
+       os.UartChannel <- byte(up)
        uart0.write(uintptr(0x0), uintptr(up))
        plic0.write(uintptr(0x201004), uintptr(pp))
 }

 func main() {
...
        alld[0] = uart0
        alld[1] = plic0

+       os.UartChannel = make(chan byte)

看起來很棒吧!但是編譯之後,試跑下去,還來不及等到命令列出現以進行使用者輸入的實驗,就會遇到錯誤並有以下錯誤回溯訊息:

Memory Base: 0x80000000
Memory Size: 0x20000000
hdla > fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
os.(*File).read(...)
        /home/noner/FOSS/hoddarla/Hoddarla/go/src/os/file_opensbi.go:74
os.(*File).Read(0x0, {0xffffffcf0000e1d0, 0x1, 0x4})
        /home/noner/FOSS/hoddarla/Hoddarla/go/src/os/file.go:119 +0x40
...
fmt.Scanf(...)
        /home/noner/FOSS/hoddarla/Hoddarla/go/src/fmt/scan.go:81
main.main()
        /home/noner/FOSS/hoddarla/Hoddarla/ethanol/main.go:98 +0x264

原來是因為,主執行緒順著執行,一路到了 read 函數內,就會因為這個等待頻道的傳輸而被阻塞(blocking)。Golang 執行期進而判斷,已經沒有其它共常式可以排程,那就相當於這整個程式已經不可能再有狀態的改變了;所有的共常式都在睡眠狀態,這是死結(deadlock)!

好吧,那麼我們有沒有可能把它繞過去?這樣看起來應該硬是排一個共常式給它就可以了吧?

新增一個無作用的 Golang 共常式

...
        for _, d := range alld {
                d.deviceInit()
        }
+
+       go func() {
+               for {
+               }
+       }()
+
        fmt.Printf("hdla > ")
...

重新編譯是可以執行,也不會出現死結的錯誤,但是一旦給了輸入事件的中斷,根據行為分類,有兩種(或以上)不同的錯誤;但共同點都是,我們可以看到:

fatal error: unexpected exception

而這是我們加在 ethanol_trap1 裡面的。目前我們處理了計時器中斷與外部中斷,但所有例外的部份都沒有處置,只有 default 預設印出這個訊息,通知使用者發生了一個非預期的例外。我們使用 GDB 停在這裡看看,可以發現是 runtime.runqput 函數裡面,需要取用 _p_ 的時候出的問題。

我們在這整個系列文裡面,大量地遭遇過 Golang 當中的抽象物件共常式(g)與執行緒(m),但是一直都沒有提過處理器資源(p)。筆者自己其實也還不太清楚這個抽象物件的真正功能,但可以確定的是,有時候執行緒會需要擁有處理器資源才能夠進行某些操作,這也是我們現在遭遇的問題之一。

作為參考,我們啟用 Golang 的除錯環境變數(GODEBUG=schedtrace=1,scheddetail=1)當作核心參數,再執行一遍:

SCHED 116ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=0 idlethreads=1 runqueue=0 gcwaiting=0 nmidlelocked=0 stopwait=0 sysmonwait=0
  P0: status=1 schedtick=4 syscalltick=0 m=0 runqsize=0 gfreecnt=0 timerslen=0
  M2: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=-1
  M1: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=-1
  M0: p=0 curg=5 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=-1
  G1: status=4(chan receive) m=-1 lockedm=-1
  G2: status=4(force gc (idle)) m=-1 lockedm=-1
  G3: status=4(GC sweep wait) m=-1 lockedm=-1
  G4: status=4(GC scavenge wait) m=-1 lockedm=-1
  G5: status=2() m=0 lockedm=-1

我們可以看到,現在的 Hoddarla/ethanol 核心,只有一個處理器資源,但有三個執行緒。擁有處理器資源的只有 M0 而已。萬一使用者給輸入的時候,當時被中斷的執行緒剛好不是 M0 的話,不幸的是,它為了處理我們前前一小節加入的頻道通訊,而會觸發某些需要用到處理器資源的場景。也就是說,我們在先前處理上下文的時候,機關算盡,還挪用了 ethanol 不會用到的 gsignal 成員,但如果當初中斷的執行緒不對,仍然會遇到現在的問題。

這個其實頗為難解,這裡先用另外一個方法繞過去。概念很簡單:如果進來的是外部中斷,就先看看自己是不是 m0,如果不是,就做上下文交換。由於這個外部中斷沒有處理,所以交換完之後回到中斷啟用模式的時候,這個外部中斷又會立刻觸發。直到被中斷的執行緒確實是 m0,理論上就可以繞過這個問題。

強迫 m0 處理外部中斷

...
                case TIMER_INTERRUPT:
                        ethanol.SetTimer()
                        _g_.m.life -= 1
                case EXTERNAL_INTERRUPT:
                        print("user input\n")
-                       fn := eisr
-                       fn(ctxt)
+                       if _g_.m != &m0 {
+                               _g_.m.life = 0
+                       } else {
+                               fn := eisr
+                               fn(ctxt)
+                       }
                default:
...
                default:
+                       print("sepc: ", unsafe.Pointer(uintptr(ctxt.sepc)), "\n")
+                       print("scause: ", unsafe.Pointer(uintptr(ctxt.scause)), "\n")
+                       print("stval: ", unsafe.Pointer(uintptr(ctxt.stval)), "\n")
                        throw("unexpected exception")

重新編譯並執行!結果還是出現了錯誤,這個頁表錯誤(0xd 代表的是讀取時的頁表錯誤),是錯誤中的錯誤(panic in panic);根據錯誤訊息,問題是在更早的 fatal error: malloc during signal 錯誤。這個發生在 src/runtime/malloc.go 裡面的 mallocgc 函數,使用 GDB 可以觀察到這個回溯:

Breakpoint 2, runtime.mallocgc (size=88, typ=0xffffffc00009f740, needzero=true, ~r0=<optimized out>)
    at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/malloc.go:971
971                     throw("malloc during signal")
(gdb) bt
#0  runtime.mallocgc (size=88, typ=0xffffffc00009f740, needzero=true, ~r0=<optimized out>)
    at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/malloc.go:971
#1  0xffffffc00000bf24 in runtime.newobject (typ=0xffffffc00009f740, ~r0=<optimized out>)
    at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/malloc.go:1221
#2  0xffffffc000033920 in runtime.acquireSudog (~r0=<optimized out>) at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/proc.go:405
#3  0xffffffc000004a3c in runtime.chansend (c=0xffffffcf00050060, ep=0xffffffcf00009ea3, block=true, callerpc=<optimized out>,
    ~r0=<optimized out>) at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/chan.go:238
#4  0xffffffc0000047d0 in runtime.chansend1 (c=0xffffffcf00050060, elem=0xffffffcf00009ea3)
    at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/chan.go:144
#5  0xffffffc00008ca1c in main.eisr (c=<optimized out>) at /home/noner/FOSS/hoddarla/Hoddarla/ethanol/main.go:76
#6  0xffffffc00002e554 in runtime.ethanol_trap1 (ctxt=0xffffffcf00009ee0)
    at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/os_opensbi.go:317
#7  0xffffffc000059b2c in runtime.ethanol_trap () at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/sys_opensbi_riscv64.s:78

這個歷程是,eisr 試圖傳送 UART 當中的那個字元,而 Golang 幫我們把頻道操作轉換成 chansend* 系列呼叫。在 acquireSudog 裡面,符合了某些條件之後,它會試圖創建一個新的物件。而又因為我們現在處在 m0.gsignal 共常式裡面,就會觸發記憶體配置控制裡面的判斷,認定我們在訊號處理當中不該額外配置記憶體。

前後搜尋可以突破的線索,發現了這個(在 chansend1 函數中):

231         if !block {
232                 unlock(&c.lock)
233                 return false
234         }
235
236         // Block on the channel. Some receiver will complete our operation for us.
237         gp := getg()
238         mysg := acquireSudog()

這裡,阻塞與否的判斷顯然是個很重要的關鍵字。在正式進入 acquireSudog 之前,甚至還有一個控制區塊是,如果是非阻塞模式就會回傳的狀態。筆者先前其實並不是一個很有經驗的 Golang 使用者,所以其實在這之前都沒有考慮過要使用非阻塞式的頻道。

如果我們定性分析一下現在的狀況,會發現確實有道理。如果是非阻塞式頻道的傳輸端,假設頻道上限還未達到的情況,沒有道理需要當前共常式進入睡眠狀態。總之,將這個頻道改為非阻塞式試試。

改寫 ByteChannel 為非阻塞頻道

        alld[1] = plic0

-       os.UartChannel = make(chan byte)
+       os.UartChannel = make(chan byte, 1)

        for _, d := range alld {

感謝 Golang 的簡潔語法,我們可以新增一個參數,使它的性質變為非阻塞。重新編譯之後,無奈,還是會再遇到問題:

Memory Base: 0x80000000
Memory Size: 0x20000000
hdla > asfatal error: malloc during signal

goroutine 0 [idle]:
main.eisr(0xffffffcf00009ee0)
        /home/noner/FOSS/hoddarla/Hoddarla/ethanol/main.go:76 +0x84
...

還是一樣是 malloc during signal!使用 GDB 觀察,發現

(gdb) bt
#0  runtime.mallocgc (size=88, typ=0xffffffc00009f740, needzero=true, ~r0=<optimized out>)
    at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/malloc.go:971
#1  0xffffffc00000bf24 in runtime.newobject (typ=0xffffffc00009f740, ~r0=<optimized out>)                                                   at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/malloc.go:1221
#2  0xffffffc0000338f8 in runtime.acquireSudog (~r0=<optimized out>) at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/proc.go:405
#3  0xffffffc000004a3c in runtime.chansend (c=0xffffffcf00042070, ep=0xffffffcf00009ea3, block=true, callerpc=<optimized out>,
    ~r0=<optimized out>) at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/chan.go:238
#4  0xffffffc0000047d0 in runtime.chansend1 (c=0xffffffcf00042070, elem=0xffffffcf00009ea3)
    at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/chan.go:144
#5  0xffffffc00008c9f4 in main.eisr (c=<optimized out>) at /home/noner/FOSS/hoddarla/Hoddarla/ethanol/main.go:76
...

特別值得玩味的是,chansend 函數的參數裡面,block 竟然為真。我們難道不是已經宣告成非阻塞頻道了嗎?而且筆者輸入的時候,還特地間隔較久的時間,就是不希望輸入太快打壞了僅有一個字元容量的頻道。

事實上,這可能表示接收端沒有成功接收這個頻道的訊息。共常式的排程不像執行緒的排程,在我們沒有非同步搶佔(asynchrous preemption)支援的情況下,如果有些共常式很自私的話,很有可能其它的共常式沒有辦法被排程回來。比方說我們前幾個小節加入的無窮迴圈。

正確的作法應該是讓那個無窮迴圈共常式有辦法讓出執行資源,也就是主動觸發執行緒內的共常式排程。這個不需要我們額外再設計什麼機制,只要套用原本就有的 runtime.Gosched 函數就可以了。

使空共常式讓出運算資源

...
                d.deviceInit()
        }
 
        go func() {
                for {
+                       runtime.Gosched()
                }
        }()
 
        fmt.Printf("hdla > ")
...

然後再執行(老樣子,在 Hoddarla repo

筆者稍早推上 github 的版本沒有包含到 patch,慚愧!再補上一張執行動圖。

如圖:
執行

小結

予焦啦!這就是這屆鐵人賽,Hoddarla 這個專案初次亮相的所有技術內容。在最後一章裡面,我們探索了 RISC-V 的外部中斷機制,然後專攻 UART 的輸入處理。今天,我們綜合前兩天的知識,也順手使用了 Golang 的介面功能與頻道同步機制,雖然過程中吃了不少苦頭,但也逐步分析並解決了。筆者很慶幸現在能夠達成這樣的階段性目標。

但當然,三十天的挑戰尚未結束。筆者相信今天以前,這系列的技術內容已經相當足夠,實際上也留下很多技術債,若是要進一步成熟化是不可忽視的項目。不過最後收尾的這幾天,且讓筆者挪用為附錄,分享一些與 Hoddarla 專案本身弱相關,但與 RISC-V 或是 Golang 或是作業系統主題相關的資訊。各位讀者,我們明日再會!


上一篇
予焦啦!Hello World 與 Uart 機制觀察
下一篇
予焦啦!附錄:旅途拾貝
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索33

1 則留言

0
ycliang
iT邦新手 5 級 ‧ 2021-10-06 10:17:28

高魁良 iT邦新手 4 級 ‧ 2021-10-06 10:20:41 檢舉

已檢舉

ycliang iT邦新手 5 級 ‧ 2021-10-07 17:23:57 檢舉

nooooooooooooooooo

我要留言

立即登入留言