iT邦幫忙

2021 iThome 鐵人賽

DAY 12
1
Software Development

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

予焦啦!虛擬記憶體啟用後的除錯

本節是以 Golang 上游 4b654c0eeca65ffc6588ffd9c99387a7e48002c1 為基準做的實驗

予焦啦!昨日啟用了虛擬位址的使用,試跑之後也走到比較遠的地方了,但看起來似乎觸發了嚴重的錯誤。嚴重的用詞不是很精確,實際上這裡指稱的是,進入到了 Golang 執行期的函數 fatalthrow 裡面。

本節重點概念

  • RISC-V
    • 除錯技巧
  • Golang
    • 記憶體管理抽象層
    • 打通 throw 機制
    • 使用 Golang 支援除錯

除錯目前狀況

目前我們已知的是出現錯誤的虛擬位址在 0xffffffc00002f650,且試試能否在 gdb 中這樣使用:

(gdb) b *0xffffffc00002f650
Cannot access memory at address 0xffffffc00002f650

事實上是不行的。筆者猜測,GDB 與 QEMU 之間的除錯機制並沒有聰明到能夠理解尚未建立的虛擬位址,而且這時候,分頁表都還沒建立。儘管如此,我們還是可以使用 gdb 的,但使用上稍微迂迴一點。我們可以總是預先停在 satp 控制暫存器寫入時的該行指令,然後

  1. 使用 si 指令推進一行,理論上啟用了虛擬位址
  2. 直接重設程式指標(pc)到 stvec 中紀錄的位址。既然我們有除錯器的幫助,何必讓指令頁面錯誤發生再跳轉呢?
  3. 在可用虛擬位址的新狀況下繼續除錯。

由於實在太迂迴,筆者直接準備一個輔助腳本在 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.spinningsched.nmspinning。如果有一個以上的空轉工作者線程,整個 Golang 排程機制就不會再啟動新的工作者線程。若最後一個空轉的工作者線程取得了工作,則是要啟動一個新的工作者線程。

mallocinit

接下來從 schedinit 進入 mallocinit 函式。這個函式的開頭是一百多行的註解,在描述作業系統的記憶體管理抽象層(OS Memory Management Abstraction Layer)。這個抽象層是在作業系統自己的記憶體管理機制之上的 Golang 機制,由四個狀態與七種轉移函式組成:

記憶體狀態

  1. 不可使用(None):沒有被預留也沒有被映射的記憶體區域,是所有區域的初始狀態。
  2. 預留(Reserved):由執行期佔據,若是存取預留區域的話則會產生錯誤。不算在這個行程(process)的記憶體足跡(memory footprint)。
  3. 已準備(Prepared):已預留。可以很簡單的轉換到就緒狀態。存取這類記憶體的行為是未定義的。
  4. 就緒(Ready):可合法存取。

狀態轉移函數

  1. sysAlloc:從不可使用就緒狀態。自作業系統取得已經清零的區塊。
  2. sysReserve:從不可使用被預留狀態。如果傳入非空指標(non-nil pointer),僅作為提示使用,實際上還是可能配置並回傳另外的位址。
  3. sysFree:從任何狀態變回不可使用。如果 sysReserve 總是能夠確保回傳對齊(align)於堆疊配置器(heap allocator)的記憶體區域,就可以不需要實作這個狀態轉移。
  4. sysMap:從被預留已準備狀態。必須確保回傳的區域能夠很快轉換成就緒。
  5. sysUsed:從已準備就緒狀態。這個步驟是在有嚴格需求的作業系統上,如 Windows,用以通知核心,如今這個區域有確實的需求了。
  6. sysUnused:從就緒變回已準備狀態。通知作業系統可以取回該區域。
  7. 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 這個系統組合現有的 sysAllocsrc/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 函式。

throwfatalthrow

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,再從這個位置讀出一個字元到 A0A7A6 則與先前的使用方式相同。

相關的實驗可以使用已經更新的 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 提供的除錯支援

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 記憶體抽象層功能才行。我們已經開始面臨一些不單純的衝突,但這是早先就已經預見的。無論如何,各位讀者,我們明天再會!


上一篇
予焦啦!在 ethanol 中啟用虛擬記憶體
下一篇
予焦啦!裝置樹(DTB)解析
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索32

尚未有邦友留言

立即登入留言