予焦啦!虚拟记忆体启用後的除错

本节是以 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 记忆体抽象层功能才行。我们已经开始面临一些不单纯的冲突,但这是早先就已经预见的。无论如何,各位读者,我们明天再会!


<<:  【Day 9】Introduction - Practice 1

>>:  Spring Framework X Kotlin Day 16 Why Kotlin

Day 26 Socket的实际应用(二)

Day 26 Socket的实际应用(二) 昨天我们讲了Socket应用中的python客户端,我们...

Day15 AR装置的编年史(上) 最早的AR其实在半个世纪前就有个雏型了!?

1968年 在1968年,犹他大学的Ivan Sutherland和他的学生Bob Sproull创...

Day09 - 套用 Html Helper - 复杂型别 object + object collection

Case01 跟 Day06、Day08 范例差不多,重点差异如下: Controller 於 Ge...

【Day 18】QGIS

回到地图 我们在前几天讨论到地图,但是我们不用 Google API,因为怕被收钱钱,我们用的是 O...

[Day30]颁发和注销访问token

安装passport套件 安装套件cmd执行以下 composer require laravel/...