本節是以 Golang 上游 8854368cb076ea9a2b71c8b3c8f675a8e19b751c 為基準做的實驗
予焦啦!正式進入下半場之前,我們必須要很謹慎的分析現在的問題才行。
...
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
函數,以及 mcommoninit
與 goargs
等函數所在的 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
) 與上下文(CTXT
,runtime.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
)會有的共常式有三種:g0
、gsignal
與其他正常的共常式。透過 systemstack
函數,執行緒本身會切換共常式,只有當當前共常式為 g0
或 gsignal
兩者其中之一的執行環境才符合所謂的系統堆疊。由於進到這裡之時,是一個配置出來的普通共常式,因此切換堆疊之後,就形成前一小節的回溯紀錄;切換回 g0
之後的,則在這裡進入 newm
呼叫,最終呼叫到沒有實作的 newosproc
。
newm
的命名當然代表了該函數的目的是要創造一個執行緒,且令它以 sysmon
函數作為入口,開始運行。雖然我們不會深入介紹 sysmon
,但大致上可以將之理解為 Golang 執行期的一個背景監控執行緒。至於為什麼非得切換堆疊不可,筆者此時的理解只能從註解中窺得一點點:g0
共常式的堆疊來自於作業系統,且 gsignal
的堆疊來自訊號處理初始化時的設置,兩者都比一般的共常式所受到的限制還要少。
同前一大章的記憶體管理部分,我們缺乏作業系統支援,所以就自己做。現在也是一樣,我們缺乏多程式(multiprogramming)能力,當然也得自己做了。
為什麼說缺乏多程式?設想,這些 Golang 初始化過程,要是在一部單核心電腦的 Linux 作業系統上執行,其實整個 Golang 可以很舒服地待在這個抽象層裡面;當前的執行緒會被 Linux 核心做上下文切換,而也許過一陣子之後就換成這裡新創出來的 sysmon
執行緒去執行。而又過一陣子,或許又切回來 m0
本身。以高速的切換,達到分時(time sharing)的效果。
也就是說,接下來 Golang 執行期需要的功能,其實相當於一般作業系統的兩個部分:
予焦啦!我們走訪了一下在 runtime.main
函數之前的追蹤與分析,以理解我們接下來需要哪些機制。光看錯誤訊息的話,當然是可以對應到 Linux 的 clone
或是 NetBSD 的 lwp_create
這種系統呼叫,但就算我們能夠複製整個執行緒需要的所有資料節構,還是什麼也沒有,單核心之上還是只有一個東西在跑。我們需要更整體的機制分析,才有辦法度過這個挑戰。
今天以前的重頭戲是讓 opensbi/riscv64
系統組合能夠有個環境可以開發,並將 Golang 記憶體管理機制跑在 RISC-V 的作業系統模式之上。至今,所有的行為都是同步的(synchronous)。但生命不可能只有同步的事件。非同步(asynchronous)事件,也就是中斷的處理,就是接下來的重頭戲了。各位讀者,我們在明天開始的下半場再會吧!