iT邦幫忙

2021 iThome 鐵人賽

DAY 17
2
Software Development

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

予焦啦!問題分析

本節是以 Golang 上游 8854368cb076ea9a2b71c8b3c8f675a8e19b751c 為基準做的實驗

予焦啦!正式進入下半場之前,我們必須要很謹慎的分析現在的問題才行。

本節重點概念

  • Golang
    • 作業系統的執行緒抽象

最新的的狀況

...
panic: newosproc: not implemented
fatal error: panic on system stack

runtime stack:                                  
runtime.throw({0xffffffc000060eb7, 0x15})
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:965 +0x60                      
panic({0xffffffc000058b20, 0xffffffc000071e38})
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:740 +0x7e8
runtime.newosproc(...)                                                                          
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/os_opensbi.go:154
runtime.newm1(0xffffffcf0402a000)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2244 +0x104
runtime.newm(0xffffffc000063cd8, 0x0, 0xffffffffffffffff)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2223 +0x108
runtime.main.func1()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:175 +0x3c
runtime.systemstack()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:130 +0x58
...
I000000000000000f
0000000000000000
ffffffc00002b6c0

這一連串的紀錄當中,我們最熟悉的還是最後三行,由 early_halt 所印出的三個狀態暫存器的值。但是,現在的狀況而言,這只會是個果,畢竟 scause 為寫入錯誤(0xf)且 stval 為 0,這很明顯就是 Golang 在 fatalthrow 函數尾聲故意對 0 位址寫入的動作,不是重點。接下來的追查方向應該是,現在我們看到的回溯訊息,以及促使 Golang 印出錯誤訊息的錯誤本身。

錯誤的原因

// May run with m.p==nil, so write barriers are not allowed.
//go:nowritebarrier
func newosproc(mp *m) {
        panic("newosproc: not implemented")
}

一路呼叫過來,直到沒有真正支援的 newosproc 為止,主動地呼叫了 panic 函數,代表執行期至此已經無法處理了。但原本一個正常的 newosproc 呼叫應該會怎麼進行呢?以 Linux 為例:

func newosproc(mp *m) {
        stk := unsafe.Pointer(mp.g0.stack.hi)
        /*
         * note: strace gets confused if we use CLONE_PTRACE here.
         */
        if false {
                print("newosproc stk=", stk, " m=", mp, " g=", mp.g0, " clone=", abi.FuncPCABI0(clone), " id=", mp.id, " ostk=", &mp, "\n")
        }

        // Disable signals during clone, so that the new thread starts
        // with signals disabled. It will enable them in minit.
        var oset sigset
        sigprocmask(_SIG_SETMASK, &sigset_all, &oset)
        ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(abi.FuncPCABI0(mstart)))
        sigprocmask(_SIG_SETMASK, &oset, nil)

        if ret < 0 {
                print("runtime: failed to create new OS thread ...

它先透過傳入的 mp 結構取得堆疊(mp.g0.stack.hi,別忘了堆疊在這裡是從高位往低位成長)、所屬的主要共常式(goroutine: g0),以及執行緒進入點(mstart),然後使用系統呼叫 clone 來生成一個新的行程或是執行緒。當然還有一些訊號(signal)處理,不過它們無關宏旨。隨之而來的是一堆問題:現在怎麼辦?人家 linux/riscv64 系統組合有這麼多功能,怎麼複製過來?還可以繼續問下去。

但我們先暫且不追究太深。OpenSBI 只是韌體層,無論如何都不可能幫我們生出需要複雜行程管理的作業系統功能。我們先把問題現狀看完,再做決定。前文省略的回溯訊息頗長,以下分為兩段進行重點剖析。

兩段回溯:較短的這組

goroutine 1 [running]:
runtime.systemstack_switch()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:93 +0x8 fp=0xffffffcf04026
7a8 sp=0xffffffcf040267a0 pc=0xffffffc000051178
runtime.main()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:174 +0x88 fp=0xffffffcf040267d8 
sp=0xffffffcf040267a8 pc=0xffffffc0000312c0
runtime.goexit()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:494 +0x4 fp=0xffffffcf0402
67d8 sp=0xffffffcf040267d8 pc=0xffffffc0000513e4

首先啟人疑竇的是,這一段回溯看起來從 goexit 開始。可是,我們一開始都是從 rt0_go 出發的,何以現在回溯訊息看起來面目全非?這是因為在 rt0_go 的尾聲,有一段設置:

...
        CALL    runtime·osinit(SB)
        CALL    runtime·schedinit(SB)

        // create a new goroutine to start program
        MOV     $runtime·mainPC(SB), T0         // entry
        ADD     $-16, X2
        MOV     T0, 8(X2)
        MOV     ZERO, 0(X2)
        CALL    runtime·newproc(SB)
        ADD     $16, X2

        // start this M
        CALL    runtime·mstart(SB)
...

前面延續自我們前幾日已經很熟悉的 osinit 函數,以及 mcommoninitgoargs 等函數所在的 schedinit 函數。接下來則是準備了參數並呼叫 newproc函數。

根據該函數本身的註解,newproc 其實就是一般 Golang 程式在創造共常式的時候的 go fn() 語法背後的函數。這裡相當於是創造一個共常式。

但切莫誤會,創造出來和能夠開始執行是兩回事。newproc 函數之尾聲,有個 runqput,即是將新的共常式放置到執行佇列之中。一般的 Golang 共常式的生命週期就應當如此。而我們此時執行的 g0 是特殊的執行期初始用共常式。

與其身份相當的是 m0,是特殊的執行期初始執行緒。執行緒(M)這種單位,在一般的 Golang 使用情境之內,隱約對應到作業系統執行緒去,如我們前一小節所見到的 newosproc 那樣。無論如何,在 rt0_go 的最後,mstart 呼叫,讓 m0 正式起始運作。

呼叫 mstart 為止的這段時間,如果使用 gdb 觀察,可以發現這時候的回溯紀錄都還是從 rt0 起算(為何不是從 rt1_opensbi_riscv64 開始算呢?因為我們是跳躍過來,而非呼叫的)。但是到了 mstart 之後,

TEXT runtime·mstart(SB),NOSPLIT|TOPFRAME,$0
        CALL    runtime·mstart0(SB)
        RET // not reached

這個特殊的 TOPFRAME 關鍵字生效,取而代之而成為回溯堆疊中的最原始函數。以運行到後來的狀況為例:

(gdb) bt
#0  runtime.gogo () at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:231
#1  0xffffffc000035d34 in runtime.execute (gp=0xffffffcf040001a0, inheritTime=true)
    at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2693
#2  0xffffffc000037cbc in runtime.schedule ()
    at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:3399
#3  0xffffffc000033d2c in runtime.mstart1 ()
    at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:1407
#4  0xffffffc000033c44 in runtime.mstart0 ()
    at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:1358
#5  0xffffffc000051150 in runtime.mstart ()
    at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:73

這看起來已經像是從 mstart 開始的了。無論如何,參考引用的回溯堆疊的話,schedule 正是這個執行緒準備好了適當的資源,並自執行佇列當中試圖拿出一個共常式來執行的呼叫。由於先前也只配置了一個新的共常式並置放進入,這時也會將之取出。execute 試著執行之,到 gogo 是使用組合語言的部分,轉接應用二進制介面(ABI, Application Binary Interface):

// func gogo(buf *gobuf)
TEXT runtime·gogo(SB), NOSPLIT|NOFRAME, $0-8
        MOV     buf+0(FP), T0
        MOV     gobuf_g(T0), T1
        MOV     0(T1), ZERO // make sure g != nil
        JMP     gogo<>(SB)

TEXT gogo<>(SB), NOSPLIT|NOFRAME, $0
        MOV     T1, g
        CALL    runtime·save_g(SB)

        MOV     gobuf_sp(T0), X2
        MOV     gobuf_lr(T0), RA
        MOV     gobuf_ret(T0), A0
        MOV     gobuf_ctxt(T0), CTXT
        MOV     ZERO, gobuf_sp(T0)
        MOV     ZERO, gobuf_ret(T0)
        MOV     ZERO, gobuf_lr(T0)
        MOV     ZERO, gobuf_ctxt(T0)
        MOV     gobuf_pc(T0), T0
        JALR    ZERO, T0

gobuf 取出的資訊當中,最重要的就是在 T1 暫存器當中的共常式本身。透過 gobuf,也陸續取出堆疊指標(X2)、回傳位址(RA,這就是為什麼回溯記錄會看到 goexit 函數,因為稍早在 newproc 函數內指定了)、回傳值(A0) 與上下文(CTXTruntime.mainPC)。這些內容相當於是 g.sched 子成員結構的內容,且最後提取的程式指標置放在 T0 暫存器,並跳躍過去(實質內容為 runtime.main)執行。

mainPC 是在組合語言檔案裡面宣告的,架構相依的唯讀變數,用以存放 runtime.main。可參考 src/runtime/asm_riscv64.s

DATA    runtime·mainPC+0(SB)/8,$runtime·main(SB)
GLOBL   runtime·mainPC(SB),RODATA,$8

所以這就是為什麼這一組回溯紀錄裡面看到 goexit 出發、在 runtime.main 裡面執行到 stack_switch

兩段回溯:較長的這組

runtime stack:                                  
runtime.throw({0xffffffc000060eb7, 0x15})
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:965 +0x60                      
panic({0xffffffc000058b20, 0xffffffc000071e38})
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:740 +0x7e8
runtime.newosproc(...)                                                                          
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/os_opensbi.go:154
runtime.newm1(0xffffffcf0402a000)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2244 +0x104
runtime.newm(0xffffffc000063cd8, 0x0, 0xffffffffffffffff)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2223 +0x108
runtime.main.func1()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:175 +0x3c
runtime.systemstack()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:130 +0x58

main 裡面的以下片段:

        if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
                // For runtime_syscall_doAllThreadsSyscall, we
                // register sysmon is not ready for the world to be
                // stopped.
                atomic.Store(&sched.sysmonStarting, 1)
                systemstack(func() {
                        newm(sysmon, nil, -1)
                })
        }

一般來說,一個執行緒內(M)會有的共常式有三種:g0gsignal 與其他正常的共常式。透過 systemstack 函數,執行緒本身會切換共常式,只有當當前共常式為 g0gsignal 兩者其中之一的執行環境才符合所謂的系統堆疊。由於進到這裡之時,是一個配置出來的普通共常式,因此切換堆疊之後,就形成前一小節的回溯紀錄;切換回 g0 之後的,則在這裡進入 newm 呼叫,最終呼叫到沒有實作的 newosproc

newm 的命名當然代表了該函數的目的是要創造一個執行緒,且令它以 sysmon 函數作為入口,開始運行。雖然我們不會深入介紹 sysmon,但大致上可以將之理解為 Golang 執行期的一個背景監控執行緒。至於為什麼非得切換堆疊不可,筆者此時的理解只能從註解中窺得一點點:g0 共常式的堆疊來自於作業系統,且 gsignal 的堆疊來自訊號處理初始化時的設置,兩者都比一般的共常式所受到的限制還要少。

分析

同前一大章的記憶體管理部分,我們缺乏作業系統支援,所以就自己做。現在也是一樣,我們缺乏多程式(multiprogramming)能力,當然也得自己做了。

為什麼說缺乏多程式?設想,這些 Golang 初始化過程,要是在一部單核心電腦的 Linux 作業系統上執行,其實整個 Golang 可以很舒服地待在這個抽象層裡面;當前的執行緒會被 Linux 核心做上下文切換,而也許過一陣子之後就換成這裡新創出來的 sysmon 執行緒去執行。而又過一陣子,或許又切回來 m0 本身。以高速的切換,達到分時(time sharing)的效果。

也就是說,接下來 Golang 執行期需要的功能,其實相當於一般作業系統的兩個部分:

  • 中斷(interrupt):上述的段落中都有提到「過一陣子」。要是沒有計時器中斷(timer interrupt)進入,根本沒有辦法判斷。
  • 行程管理:假設我們也將執行緒當作一種行程的話,那麼 Golang 本身預期這些執行緒開起來之後,是要能夠自動被底下的神奇機制切換執行的。

小結

予焦啦!我們走訪了一下在 runtime.main 函數之前的追蹤與分析,以理解我們接下來需要哪些機制。光看錯誤訊息的話,當然是可以對應到 Linux 的 clone 或是 NetBSD 的 lwp_create 這種系統呼叫,但就算我們能夠複製整個執行緒需要的所有資料節構,還是什麼也沒有,單核心之上還是只有一個東西在跑。我們需要更整體的機制分析,才有辦法度過這個挑戰。

今天以前的重頭戲是讓 opensbi/riscv64 系統組合能夠有個環境可以開發,並將 Golang 記憶體管理機制跑在 RISC-V 的作業系統模式之上。至今,所有的行為都是同步的(synchronous)。但生命不可能只有同步的事件。非同步(asynchronous)事件,也就是中斷的處理,就是接下來的重頭戲了。各位讀者,我們在明天開始的下半場再會吧!


上一篇
予焦啦!參數與環境變數
下一篇
予焦啦!RISC-V 的計時器中斷機制
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索32

尚未有邦友留言

立即登入留言