本節是以 Golang 上游 4b654c0eeca65ffc6588ffd9c99387a7e48002c1 為基準做的實驗
予焦啦!昨日啟用了虛擬位址的使用,試跑之後也走到比較遠的地方了,但看起來似乎觸發了嚴重的錯誤。嚴重的用詞不是很精確,實際上這裡指稱的是,進入到了 Golang 執行期的函數 fatalthrow
裡面。
throw
機制目前我們已知的是出現錯誤的虛擬位址在 0xffffffc00002f650
,且試試能否在 gdb 中這樣使用:
(gdb) b *0xffffffc00002f650
Cannot access memory at address 0xffffffc00002f650
事實上是不行的。筆者猜測,GDB 與 QEMU 之間的除錯機制並沒有聰明到能夠理解尚未建立的虛擬位址,而且這時候,分頁表都還沒建立。儘管如此,我們還是可以使用 gdb 的,但使用上稍微迂迴一點。我們可以總是預先停在 satp
控制暫存器寫入時的該行指令,然後
si
指令推進一行,理論上啟用了虛擬位址pc
)到 stvec
中紀錄的位址。既然我們有除錯器的幫助,何必讓指令頁面錯誤發生再跳轉呢?由於實在太迂迴,筆者直接準備一個輔助腳本在 hoddarla 專案本身裡面 misc/mkgdbrc.sh
:
$ cat misc/mkgdbrc.sh
#!/bin/bash
PREFIX=riscv64-buildroot-linux-musl-
KERNEL=ethanol/ethanol
echo target remote :1234
echo b *0x802$("$PREFIX"objdump -d "$KERNEL" | grep satp | sed -e "s/^.*\([0-9a-f]\{5\}\):.*$/\1/")
echo c
echo d 1
echo si
echo set \$pc=\$stvec
echo file $KERNEL
這樣,使用者或開發者可以在第一個終端機開啟 QEMU:
$ make EXTRA_FLAGS='-S -s'
make -C ethanol/
make[1]: 進入目錄「/home/noner/FOSS/hoddarla/ithome/ethanol」
make[1]: 對「all」無需做任何事。
make[1]: 離開目錄「/home/noner/FOSS/hoddarla/ithome/ethanol」
qemu-system-riscv64 \
-smp 4 \
-M virt \
-m 512M \
-nographic \
-bios misc/opensbi/build/platform/generic/firmware/fw_jump.bin \
-device loader,file=ethanol/goto/goto.bin,addr=0x80200000 \
-device loader,file=ethanol/hw,addr=0x80201000,force-raw=on -S -s
在第二個終端機則可以使用上述輔助腳本開啟 GDB:
$ make debug
riscv64-linux-gnu-gdb -x /tmp/gdbrc
...
Breakpoint 1 at 0x80255594
[Switching to Thread 1.2]
Thread 2 hit Breakpoint 1, 0x0000000080255594 in ?? ()
0x0000000080255598 in ?? ()
...
(gdb) x/10i $pc -0x10
0xffffffc000055590 <relocate+128>: sfence.vma
0xffffffc000055594 <relocate+132>: csrw satp,t0
0xffffffc000055598 <relocate+136>: ebreak
0xffffffc00005559c: unimp
0xffffffc00005559e: unimp
=> 0xffffffc0000555a0 <_rt1_riscv64_opensbi>: auipc a0,0x0
0xffffffc0000555a4 <_rt1_riscv64_opensbi+4>: addi a0,a0,-264
0xffffffc0000555a8 <_rt1_riscv64_opensbi+8>: csrw stvec,a0
0xffffffc0000555ac <_rt1_riscv64_opensbi+12>: auipc t0,0xffffd
0xffffffc0000555b0 <_rt1_riscv64_opensbi+16>: addi t0,t0,-348
前述腳本根據映像檔內容生成 /tmp/gdbrc
之後,自動定位到虛擬位址啟用的指令,然後停留在能夠使用虛擬位址除錯的階段。
繼續追查最終來到 fatalthrow
的原因,我們如以往一般使用除錯器指令設置中斷點:
(gdb) b *0xffffffc00002f650
Breakpoint 2 at 0xffffffc00002f650: file /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go, line 1263.
(gdb) c
Continuing.
Thread 2 hit Breakpoint 2, 0xffffffc00002f650 in runtime.fatalthrow ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:1263
1263 *(*int)(nil) = 0 // not reached
讀者或許會疑惑,為什麼筆者不使用符號設置中斷點,而是要先查詢確切位址再設置?這是因為筆者開發環境習慣會另外開一個終端機,列出
ethanol/ethanol
的反組譯組合語言碼;通常要開始除錯之前,都已經先在這個終端機端詳許久,所以切換到 GDB 所屬的終端機前,只需要複製個想要除錯的位址即可。為求行文風格的敘述一致性,在撰寫除錯相關段落時,一律使用位址來做除錯。但本日最後的小節,筆者會附上更無縫的除錯體驗,供各位讀者參考。
中斷點這個位址顯然是流程當中故意要出問題的,因為這裡將 nil
空指標轉型成整數指標之後,對之賦值,所以觸發寫入錯誤也是當然的了。
再來看看,目前為止 ethanol 的執行經歷過什麼樣的歷史軌跡:
(gdb) bt
#0 0xffffffc00002f650 in runtime.fatalthrow ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:1263
#1 0xffffffc00002f410 in runtime.throw (s=...)
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:1198
#2 0xffffffc00000a0f0 in runtime.persistentalloc1 (size=256, align=8,
sysStat=0xffffffc0000d8778 <runtime.memstats+152>, ~r3=<optimized out>)
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/malloc.go:1413
#3 0xffffffc000009e14 in runtime.persistentalloc.func1 ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/malloc.go:1367
#4 0xffffffc000009db8 in runtime.persistentalloc (size=256, align=8,
sysStat=0xffffffc0000d8778 <runtime.memstats+152>, ~r3=<optimized out>)
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/malloc.go:1366
#5 0xffffffc00002a1dc in runtime.(*addrRanges).init (
a=0xffffffc0000d0cf8 <runtime.mheap_+65688>,
sysStat=0xffffffc0000d8778 <runtime.memstats+152>)
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/mranges.go:170
#6 0xffffffc000023b84 in runtime.(*pageAlloc).init (p=0xffffffc0000c0c68 <runtime.mheap_+8>,
mheapLock=0xffffffc0000c0c60 <runtime.mheap_>,
sysStat=0xffffffc0000d8778 <runtime.memstats+152>)
#7 0xffffffc000020cf4 in runtime.(*mheap).init (h=0xffffffc0000c0c60 <runtime.mheap_>)
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/mheap.go:726
#8 0xffffffc000008050 in runtime.mallocinit ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/malloc.go:481
#9 0xffffffc0000324cc in runtime.schedinit ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:689
#10 0xffffffc0000524ec in runtime.rt0_go ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:56
以下再細分小節來簡述目前,Golang 的執行期環境原本想要初始化什麼項目。
schedinit
(gdb) f 9
#9 0xffffffc0000324cc in runtime.schedinit ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:689
689 mallocinit()
(gdb) l
684 // The world starts stopped.
685 worldStopped()
686
687 moduledataverify()
688 stackinit()
689 mallocinit()
690 fastrandinit() // must run before mcommoninit
691 mcommoninit(_g_.m, -1)
692 cpuinit() // must run before alginit
693 alginit() // maps must not be used before this call
從 rt0_go
進入的主要函式 schedinit
,在拙作當中(第八天到第十二天)有過一些簡略的描述。這是 Golang 排程器(scheduler)需要的機制的初始化函數。以下相關的說明,參考自 src/runtime/proc.go
開頭的註解。
總的來講,Golang 排程器的主要機制可以拆分成幾個主題:基本單元、工作者線程的啟動與暫停機制(unpark/park)、工作者線程的空轉狀態轉換(spinning/non-spinning)。
G
代表共常式(之後本系列文在指稱 Goroutine 時,以共常式稱,這是國家教育研究院針對 coroutine 的翻譯。coroutine 在其他程式語言通常指稱使用者空間的協程機制),是執行 Golang 程式的基本單元。M
代表工作者線程(worker thread),通常直接對應到底下的作業系統提供的 pthread 或是輕量執行緒(LWP, Light-Weight Thread)。P
代表處理器(processor),是指工作者線程執行共常式所必須要具備的資源。
要能夠有效率地使用系統資源,有兩個目標是 Golang 排程器想要平衡取捨的:一個是,工作者線程的數量要夠大才能夠利用作業系統與硬體給予的平行度;另一個則是,工作者線程的數量不能過多,否則運算資源與能量會過度消耗。
啟動的條件有二:當有 P
閒置時,或者沒有空轉的工作者線程之時。
若是一個工作者線程找不到本地(P
處理器)的工作,或是在全域(global)的執行佇列找不到工作可執行,則為空轉(spinning)的。
空轉狀態記錄在兩組變數當中:m.spinning
與 sched.nmspinning
。如果有一個以上的空轉工作者線程,整個 Golang 排程機制就不會再啟動新的工作者線程。若最後一個空轉的工作者線程取得了工作,則是要啟動一個新的工作者線程。
mallocinit
接下來從 schedinit
進入 mallocinit
函式。這個函式的開頭是一百多行的註解,在描述作業系統的記憶體管理抽象層(OS Memory Management Abstraction Layer)。這個抽象層是在作業系統自己的記憶體管理機制之上的 Golang 機制,由四個狀態與七種轉移函式組成:
sysAlloc
:從不可使用到就緒狀態。自作業系統取得已經清零的區塊。sysReserve
:從不可使用到被預留狀態。如果傳入非空指標(non-nil pointer),僅作為提示使用,實際上還是可能配置並回傳另外的位址。sysFree
:從任何狀態變回不可使用。如果 sysReserve
總是能夠確保回傳對齊(align)於堆疊配置器(heap allocator)的記憶體區域,就可以不需要實作這個狀態轉移。sysMap
:從被預留到已準備狀態。必須確保回傳的區域能夠很快轉換成就緒。sysUsed
:從已準備到就緒狀態。這個步驟是在有嚴格需求的作業系統上,如 Windows,用以通知核心,如今這個區域有確實的需求了。sysUnused
:從就緒變回已準備狀態。通知作業系統可以取回該區域。sysFault
:從就緒或是已準備變回預留狀態。這僅是執行期除錯需求,而標記某些記憶體區域產生錯誤。persistentalloc1
跳過了中間的數層呼叫,是因為這個階段都在建立 Golang 的記憶體管理機制。它們抽象化到最後,就是上述介紹的狀態機與轉移函數。別忘了,我們的如意算盤是直接挪用這些 Golang 機制做為作業系統的記憶體管理機制,至於成敗如何,還得看接下來怎麼走。
(gdb) l
1408 persistent.base = (*notInHeap)(sysAlloc(persistentChunkSize, &memstats.other_sys))
1409 if persistent.base == nil {
1410 if persistent == &globalAlloc.persistentAlloc {
1411 unlock(&globalAlloc.mutex)
1412 }
1413 throw("runtime: cannot allocate memory")
1414 }
我們可以看到,這裡執行期呼叫 throw
的部分,是由 persistent.base
是否為空指標來判斷的。而這個判斷之所以成立,就是因爲前一個 sysAlloc
我們完全沒有實作的緣故。
事實上,opensbi/riscv64
這個系統組合現有的 sysAlloc
在 src/runtime/mem_opensbi.go
中,實作為:
func sysAlloc(n uintptr, sysStat *sysMemStat) unsafe.Pointer {
p := sysReserve(nil, n)
sysMap(p, n, sysStat)
return p
}
...
func sysReserve(v unsafe.Pointer, n uintptr) unsafe.Pointer {
return v
}
func sysMap(v unsafe.Pointer, n uintptr, sysStat *sysMemStat) {
sysStat.add(int64(n))
}
所以也難怪在 persistentalloc1
這裡會取回空指標了。這裡先暫時打住,我們來觀察一下執行期組件裡面的 throw
函式。
throw
到 fatalthrow
throw
函式在執行期可以回報錯誤字串且回溯歷史。但是顯然我們還缺乏一些機制,使得最後整個系統抵達了 fatalthrow
的最後一行,並且在控制台(console)完全沒有看到任何相關的訊息。
換個角度思考。如果是一般的 Golang 程式,發生了這種等級的問題,應該還是要能夠顯示訊息到控制台上才對。既然是需要顯示的情境,那就一定和系統呼叫 write
有關才對。
在 runtime
組件裡面,我們可以加入以下內容,使得我們可以嫁接原本要給 POSIX 系作業系統的 write
系統呼叫,變成轉給 OpenSBI 的控制台字元印出的呼叫:
diff --git a/src/runtime/os_opensbi.go b/src/runtime/os_opensbi.go [1/1806]
index c6b44995da..af9979a16b 100644
--- a/src/runtime/os_opensbi.go
+++ b/src/runtime/os_opensbi.go
@@ -15,8 +15,14 @@ func exit(code int32) {
return
}
+func write2(p uintptr)
func write1(fd uintptr, p unsafe.Pointer, n int32) int32 {
- return 0
+ if fd == 2 || fd == 1 {
+ for i := uintptr(0); i < uintptr(n); i++ {
+ write2(uintptr(p) + i)
+ }
+ }
+ return n
}
這裡針對檔案描述子(fd)為標準輸出或是標準錯誤的狀態,將每個字元指標傳給新增的 write2
函式。write2
函式則以組合語言實作,因為需要進行暫存器的操作:
diff --git a/src/runtime/sys_opensbi_riscv64.s b/src/runtime/sys_opensbi_riscv64.s
new file mode 100644
index 0000000000..ddf3897ffe
--- /dev/null
+++ b/src/runtime/sys_opensbi_riscv64.s
@@ -0,0 +1,11 @@
+#include "textflag.h"
+#include "go_asm.h"
+
+// func write2(p uintptr)
+TEXT runtime·write2(SB),NOSPLIT|NOFRAME,$0-8
+ MOV p+0(FP), A0
+ LB 0(A0), A0
+ MOV $1, A7
+ MOV $0, A6
+ ECALL
+ RET
這裡新創造一個檔案來放這個函式。將 p
中的指標先傳出來到 A0
,再從這個位置讀出一個字元到 A0
。A7
與 A6
則與先前的使用方式相同。
相關的實驗可以使用已經更新的 Hoddarla 得到。
重新編譯並執行,獲得的結果像是:
Boot HART MIDELEG : 0x0000000000000222
Boot HART MEDELEG : 0x000000000000b109
Hfatal error: runtime: cannot allocate memory
runtime stack:
runtime.throw({0xffffffc000064338, 0x1f})
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:1198 +0x60 fp=0xffffffc000001e4
8 sp=0xffffffc000001e20 pc=0xffffffc00002f490
runtime.persistentalloc1(0x100, 0x8, 0xffffffc0000d8798)
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/malloc.go:1413 +0x2c0 fp=0xffffffc000001
e90 sp=0xffffffc000001e48 pc=0xffffffc00000a0f0
runtime.persistentalloc.func1()
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/malloc.go:1367 +0x44 fp=0xffffffc000001e
c0 sp=0xffffffc000001e90 pc=0xffffffc000009e14
runtime.persistentalloc(0x100, 0x8, 0xffffffc0000d8798)
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/malloc.go:1366 +0x68 fp=0xffffffc000001f
00 sp=0xffffffc000001ec0 pc=0xffffffc000009db8
runtime.(*addrRanges).init(0xffffffc0000d0d18, 0xffffffc0000d8798)
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/mranges.go:170 +0x4c fp=0xffffffc000001f
28 sp=0xffffffc000001f00 pc=0xffffffc00002a1dc
runtime.(*pageAlloc).init(0xffffffc0000c0c88, 0xffffffc0000c0c80, 0xffffffc0000d8798)
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/mpagealloc.go:314 +0x8c fp=0xffffffc0000
01f48 sp=0xffffffc000001f28 pc=0xffffffc000023b84
runtime.(*mheap).init(0xffffffc0000c0c80)
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/mheap.go:726 +0x5dc fp=0xffffffc000001f6
8 sp=0xffffffc000001f48 pc=0xffffffc000020cf4
runtime.mallocinit()
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/malloc.go:481 +0x100 fp=0xffffffc000001f
88 sp=0xffffffc000001f68 pc=0xffffffc000008050
runtime.schedinit()
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:689 +0x6c fp=0xffffffc000001fe0
sp=0xffffffc000001f88 pc=0xffffffc0000325d4
runtime.rt0_go()
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:56 +0x9c fp=0xffffffc00000
2000 sp=0xffffffc000001fe0 pc=0xffffffc000052624
I000000000000000f
0000000000000000
ffffffc00002f6d0
大獲成功!之後就可以因此多一種除錯工具了。
Golang 實際上有提供腳本,讓除錯器更容易以符號的方式除錯。該腳本位在 src/runtime/runtime-gdb.py
,使用時只需:
(gdb) source /home/noner/FOSS/hoddarla/ithome/go/src/runtime/runtime-gdb.py
Loading Go Runtime support.
就可以進行以下操作,比方說函數反組譯:
(gdb) x/10i 'runtime.throw'
0xffffffc000034a20 <runtime.throw>: sd ra,-40(sp)
0xffffffc000034a24 <runtime.throw+4>: addi sp,sp,-40
0xffffffc000034a28 <runtime.throw+8>: sd zero,16(sp)
0xffffffc000034a2c <runtime.throw+12>: sd zero,24(sp)
0xffffffc000034a30 <runtime.throw+16>: sd zero,32(sp)
0xffffffc000034a34 <runtime.throw+20>: auipc gp,0x0
0xffffffc000034a38 <runtime.throw+24>: addi gp,gp,100
0xffffffc000034a3c <runtime.throw+28>: sd gp,16(sp)
0xffffffc000034a40 <runtime.throw+32>: ld gp,48(sp)
0xffffffc000034a44 <runtime.throw+36>: sd gp,24(sp)
或是窺探資料結構內容:
(gdb) p/x 'runtime.g0'
$2 = {stack = {lo = 0xffffffbfffff1fe0, hi = 0xffffffc000001fe0},
stackguard0 = 0xffffffbfffff2380, stackguard1 = 0xffffffbfffff2380, _panic = 0x0,
_defer = 0x0, m = 0xffffffc0001195e0, sched = {sp = 0xffffffc000001fb8,
pc = 0xffffffc000039ea8, g = 0xffffffc000119440, ctxt = 0x0, ret = 0x0, lr = 0x0,
bp = 0x0}, syscallsp = 0x0, syscallpc = 0x0, stktopsp = 0x0, param = 0x0,
atomicstatus = 0x0, stackLock = 0x0, goid = 0x0, schedlink = 0x0, waitsince = 0x0,
...
需注意單引號('
),一定要加才能夠正常除錯。它可以保護 Golang 符號裡面常常出現的、表達從屬關係的運算元(.
)。這在表達 Golang 符號的時候是很重要的元素,因為它連接了符號與所屬的組件。所以儘管 GDB 本身也使用 .
符號作為結構與成員之間的從屬運算元,衝突的語意就可以被消弭。
予焦啦!除了在除錯方面導入兩個小技巧(串接 throw
的顯示與啟用 Golang 除錯支援)之外,今天簡單預習了 Golang 的記憶體管理抽象層設計,我們應當可以考慮將記憶體管理實作在這裡。但畢竟 Golang 原本都是建立在完整的作業系統之上,所以這裡該如何設計?今天的這個例子是,呼叫端 persistentalloc1
傳遞給 sysAlloc
的建議用指標是 nil
,也就是說,它只需要取得一個作業系統已經處理好的區塊,其餘一概沒有意見。但我們這裡還沒有什麼機制可以決定,到底該怎麼分配記憶體才好?在實體記憶體的哪個位置?該分配哪裡的虛擬記憶體位址給它?
看來,我們接下來要正式解決的話,必須先實作 sysAlloc
已降的 Golang 記憶體抽象層功能才行。我們已經開始面臨一些不單純的衝突,但這是早先就已經預見的。無論如何,各位讀者,我們明天再會!