予焦啦!基本的命令列

本节是以 Golang 上游 7ee4c1665477c6cf574cb9128deaf9d00906c69f 为基准做的实验

予焦啦!昨天我们终於抵达了 Hello World,并也看到了它的效应,但花了更多时间理解并纪录 UART 提供给软件的程序设计。藉由监控运行在 QEMU 上的 Linux 的串列装置驱动程序(serial device drive)行为,我们有一套范本,可以用来处理 UART 输入的中断。

所以,今天我们就拿起这把新武器,挑战一下命令列吧。

本日重点概念

  • RISC-V
    • 外部中断总览
  • Golang
    • fmt.Scanf 函数
    • 频道(channel)应用
    • 共常式排程

实作

前两天埋首於程序码中阅读,并使用 GDB 来回寻访 Linux 处理外部中断的一整组行为,就是为了接下来的实作。实作分为两部分,首先我们会先刻出一个可以处理外部中断的基本外壳,确保真的能够取得 UART 输入之後,再填补如何应对输入的部分。有了这两个部分组合在一起,要组成一个类似命令列的使用者互动环境,也就不是难事。

又,这几天的节奏都很快,今天笔者也没有减速的意思。确实有很多技术债、很多以长远发展来看应该要好好考虑的做法,但发展至今,先以阶段性目标优先。

更直接地说就是,理想上 ethanol 核心应该要能够好好地解析装置树,从中取得 PLIC 与 UART 的相关资讯再加以使用,但这里笔者会先略过那些部分,而以编译时可决定的常数代替装置资讯。还有,为了简化 PLIC 设定时的 context 选择,笔者决定移除 QEMU 启动时的 smp 选项,如此一来整个系统就是单纯的单核心组态。

外部中断的外壳

runtime 组件部分

首先,比照我们当初启用的计时器中断,我们也在 osinit 函数里面启用外部中断:

        ethanol.TimerInterrupt(ON)
+       ethanol.ExternalInterrupt(ON)
        ethanol.Interrupt(ON)

src/runtime/ethanol/trap.gotrap.s 当中,当然也要有相对应的处置;与计时器中断时的做法九成像,这里就不再占版面。总之,这里会将 sie 控制暂存器的值,由原先只有启用计时器中断的 0x20,增加为也启用了外部中断的 0x220

另外,在负责分流中断与例外的 ethanol_trap1 函数里面,我们也新增一个外部中断的区域:

                case TIMER_INTERRUPT:
                        ethanol.SetTimer()
                        _g_.m.life -= 1
+               case EXTERNAL_INTERRUPT:
+                       throw("external!!!")
                default:
                        throw("cannot handle other traps now")

main 组件部分

当然,只有这样是没有用的。我们经过前两日的研究,已经知道,没有 PLIC 中的设定与真正外部装置的设定,即使 CPU 单方面启用外部中断,要是中断进不来,也不会有任何作用。

所以这里笔者在 ethanol/main.go 里加入一些程序码,顺便也试试看一些比较像 Golang 的写法吧。

main 说起
 func main() {
-       fmt.Println("Hello World!")
+       uart := uart{device{0xfffffff000000000, 0x10000000}}
+       plic := plic{device{0xfffffff000200000, 0xc000000}}
+
+       alld := make([]driver, 2)
+       alld[0] = uart
+       alld[1] = plic
+
+       for _, d := range alld {
+               d.deviceInit()
+       }
+
+       fmt.Printf("hdla > ")
+       for {
+       } 
... 

将 PLIC 与 UART 都做成 device,也就是装置物件。装置物件有两个成员变数,分别是 pa,也就是对应到第 2 个参数的装置物理位址,另一个则是作业系统模式在虚拟记忆体启用的状态底下能够使用的虚拟位址 va。之所以使用这两个成员,是因为我们需要将这两个位址之间的转换建立起来才行。

再次,这里也是技术债。能够使用的虚拟记忆体位址是需要好好管里的,这里贸然决定使用的这两个位址,是笔者先行考察确定不会与他者冲突的虚拟位址。

接下来制作 alld,其型别为 driver 的切片(slice),包含先前宣告的两个装置物件。之所以 driver 型别能够容纳这两个物件,是因为 driver 是一个介面型别(interface type),而之後我们会看到 plicuart 物件都实作了 driver 物件要求的函数,所以这里可以如此赋值。

最後的回圈就是走访 alld,并执行 driver 介面当中的 deviceInit 函数。最後就是印出一个命令列提示 hdla >,然後卡在无穷回圈里面,否则整个系统本身就会马上结束了。

上面提到了 4 种型别,是先前都没有出现过的,分别定义如下:

型别定义
+type driver interface {
+       deviceInit()
+       write(off, val uintptr)
+       read(off uintptr) uintptr
+}
+
+type device struct {
+       va uintptr
+       pa uintptr
+}
+
+type uart struct {
+       device
+}
+
+type plic struct {
+       device
+}

如引用所示,uartplic 装置物件都只包含 device 作为一个匿名成员。这约略可以看成 Golang 版的继承。这里的做法可以理解为不涉及到函数方法的继承。

两种结构使用的 deviceInit 当然必须有所不同,如下所示:

初始函数定义
+func (u uart) deviceInit() {
+       ethanol.MemoryMap(u.va, u.pa, 0x1000)
+       // Setup IER
+       u.write(uintptr(0x1), uintptr(1))
+}
+
+func (p plic) deviceInit() {
+       // Setup mapping
+       ethanol.MemoryMap(p.va, p.pa, 0x200000)
+       ethanol.MemoryMap(p.va+0x200000, p.pa+0x200000, 0x200000)
+       // Priority of UART (10)
+       p.write(uintptr(0x28), uintptr(1))
+       // Priority threshold of context 1
+       p.write(uintptr(0x201000), uintptr(0))
+       // Enable bit of UART (10) for context 1
+       p.write(uintptr(0x2080), uintptr(0x400))
+}

由於虚拟到实体的位址对应已经在 src/runtime/ethanol 里面做好了,所以为了引用 MemoryMap 函数,必须要在 Golang 档案的开头处引用 runtime/ethanol,这里省略。

UART 的状态比较单纯。只需要在中断启用暂存器(偏移量为 1)的地方设置第 0 个位元,则可以启动接收字元的中断。这里呼叫了写入函数,後续我们会在看到其中的细节。

PLIC 就较复杂些。就算作业系统的单核心只需要处理 context 1,根据规格,也仍然会使用到超过 2MB 的映射。所以这里我们对应了两组记忆体虚拟页面,然後进行三组写入的设定。前两者是关於中断优先权的设定,我们就比照正常运行时的 Linux 系统,将允许的阈值设为 0,并将 UART 所代表的优先权设为 1。最後一笔,则是启用 UART 装置作为一个外部中断的来源。

输出函数

至於这两种装置的 write 函数分别为何,

+func (u uart) write(off, val uintptr) {
+       writeb(u.va, off, uint8(val))
+}
+
+func (p plic) write(off, val uintptr) {
+       writew(p.va, off, uint32(val))
+}

它们使用的,具有型别资讯的 writex 函数,可参考这次新增的 ethanol/helper.s 档案,

// func writeb(addr, offset uintptr, val uint8)
TEXT main·writeb(SB), NOSPLIT|NOFRAME, $0-17
        MOV     addr+0(FP), A0
        MOV     offset+8(FP), A1
        MOVBU   val+16(FP), A2
        ADD     A0, A1, A0
        MOVB    A2, 0(A0)
        RET

// func writew(addr, offset uintptr, val uint32)
TEXT main·writew(SB), NOSPLIT|NOFRAME, $0-20
        MOV     addr+0(FP), A0
        MOV     offset+8(FP), A1
        MOVWU   val+16(FP), A2
        ADD     A0, A1, A0
        MOVW    A2, 0(A0)
        RET

需注意的是逻辑上,这种写入 MMIO 区域的动作,在 RISC-V 里面都需要 fence 指令,使得输入输出与记忆体读写的顺序符合预期,但这个部分笔者也先略过了。当然,逻辑上是有瑕疵的。

试跑

main 函数执行到底,印出 hdla > 的提示字元之後,随意敲击一个键,就会看到我们的外部中断大获成功:

Boot HART MEDELEG         : 0x000000000000b109
Memory Base: 0x80000000
Memory Size: 0x40000000
hdla > fatal error: external!!!

goroutine 0 [idle]:
runtime.throw({0xffffffc000098a58, 0xb})
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:965 +0x60
runtime.ethanol_trap1(0xffffffcf04037ee0)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/os_opensbi.go:314 +0xdc
runtime.ethanol_trap()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/sys_opensbi_riscv64.s:78 +0xe4

goroutine 1 [running]:
        goroutine running on other thread; stack unavailable

输入的处理

处理输入与输出大不相同的地方在於,後者是为人所熟悉的(四处都有 Hello World)、主动的、同步的行为,前者则是被动的非同步行为,并且实际上横跨许多抽象层,才能够成功获得输入的结果。

以一般的 read 系统呼叫来讲,很有可能的情境是,作业系统让进行该系统呼叫的使用者执行绪进入睡眠模式,然後自己就做别的事去了。之後,作业系统一如往常地处理各种装置中断时,发现某个来自键盘的中断,於是进入到键盘的驱动程序去处理。相关的资料则被储存在某个缓冲区,等待其他核心执行绪接手,发现该资料应该要接到某个虚拟终端(pseudo terminal),於是就进行这个嫁接;而後也许就叫醒正在该终端工作的、正在睡眠状态的执行绪,於是一个 read 呼叫才取得这个资料。

无论如何,我们现在就是要试着把简单版本的这个过程做进来。和昨日我们印出的 write 函数相对的,我们一样在 src/os/file_opensbi.go 里面可以找到一个还没有实作东西的 read 函数。

确认从 fmt.Scanfread 的连通性

试试将之插入一个 panic 呼叫在 read 函数中,然後在 main.main 主函数中呼叫 fmt.Scanf,分别像这样:

 func (f *File) read(b []byte) (n int, err error) {
+       panic("???")
        return 0, nil

以及,

 func main() {
-       fmt.Println("Hello World!")
+       var s string
+       fmt.Scanf("%s", &s)
+       fmt.Println(s)

重新编译并执行後,确实可以跑出错误的回溯。这里就先省略了。除了证明这两者的连通性,也能够玩个小游戏来让这个 Scanf 回传字串到 s 里面,然後再透过後续的 Println 函数印出。游戏性地修改 read 函数如下:

+var countdown = 6
+
 func (f *File) read(b []byte) (n int, err error) {
-       return 0, nil
+       switch countdown {
+       case 5:
+               b[0] = 'H'
+       case 4:
+               b[0] = 'e'
+       case 3:
+               b[0] = 'l'
+       case 2:
+               b[0] = 'l'
+       case 1:
+               b[0] = 'o'
+       case 0:
+               b[0] = ' '
+       }
+       countdown = countdown - 1
+       return 1, nil
 }

重新编译後应该就可以看到,在 main 函数当中已经可以印出 Hello 字样。虽然笔者中间省略了很多介绍,但凭藉着我们对於这两种介面的认识,就可以理解这麽作能够成功的原因。fmt.Scanf 给定格式字串 %s 的行为是,遭遇到广义的空白字元之前,都继续累积下来;遭遇到之後,则回传。经过层层累积抵达的最後在 file 组件内的 read 函数,其行为则是回传所取得的字元数量,并将那些字元存放在传入的 b 字元切片中。所以这里随着一个倒数变数,逐步将 Hello 存入,上层会一直呼叫进来直到遭遇我们最後传入的空白字元。

作战计画

上一小节只是展示我们对於 Golang 的 fmt 组件输入方法的基本理解,接下来是该拟定作战计画了。让我们试着达到这个流程吧:

  1. 主执行绪:main 函数里面跑一个简单的无穷回圈,包着 fmt.Scanf 函数,试图不断取得使用者的输入。搭配适当的输出,应该可以让它看起来很像命令列。
  2. 中断处理执行绪:我们用 Golang 写的陷阱处理函数 ethanol_trap1 里面,如先前的小节所描述的,已经能够感应到外部中断事件。回顾昨日的 UART 行为纪录,我们合理地处理(包含宣告(claim)与完成(complete))中断事件,以确保 PLIC 和 UART 的状态都维持正常而能够持续接受使用者输入。
  3. 中断处理执行绪:稍早我们只有启用接收到字元时的中断。完成并清除这个中断搁置的方法,是读取接收暂存器;事实上,读取接收暂存器之後,也能够一并取得使用者输入的字元。所以 UART 的完成应该不是问题。
  4. 中断处理执行绪:将取得的字元直接写到输出暂存器 THR 去,这麽一来使用者就能看到自己的输入。
  5. 中断处理执行绪:PLIC 的部份也在前天介绍过宣告与完成暂存器,只要将代表 UART 的 10 写回去,就能够通知 PLIC 说,UART 装置的这个中断已经被解决了。所以 PLIC 应该也不是问题。
  6. 主执行绪:将 3. 步骤中取得的字元,传递给 1. 步骤中的 read 函数。
  7. 主执行绪:从 fmt.Scanf 回传之後,针对取得的字串判断处理一下,大致上就有个类似命令列的东西了。

笔者不确定各位读者阅读至此觉得哪一个最具挑战性,但笔者认为是 6.。这个字元到底该怎麽跨越执行绪传递过去呢?先把简单的都打通,最後再来处理这个部份好了。

1.7. 的命令列逻辑

与一般的 Golang 程序没什麽两样,

+       fmt.Printf("hdla > ")
+       for {
+               var s string
+               fmt.Scanf("%s", &s)
+               fmt.Printf("\n")
+               if s == "exit" {
+                       os.Exit(0)
+               } else if s == "cheers" {
+                       fmt.Println("Hoddarla!")
+                       fmt.Println("Hoddarla is a OS project powered by RISC-V and Golang.")
+               }
+               fmt.Printf("hdla > ")
+       }

exit 命令能够相当於离开整个程序,且定义 cheers 命令让它输出讯息。没有什麽大不了的。

2.3.4.5. 的中断处理逻辑

让人烦恼的是,ethanol_trap1 身在 runtime 组件,而想要控制的 plicuart 都是 main 组件里面才定义的 device 结构且实作了 driver 介面。如果可以的话,在 main 组件里面执行简单得多。

所以这里我们借用 Golang 的 linkname 命令,让外部中断处理转一手到 main 里面的 eisr 函数处理。之所以这样命名,是取外部中断处理常式(External Interrupt Service Routine)。我们先如此改写 ethanol_trap1

+//go:linkname eisr main.eisr
+func eisr(ctxt *rv64ctxt)
+
 //go:nosplit
 func ethanol_trap1(ctxt *rv64ctxt) {
        _g_ := getg()
...
                case TIMER_INTERRUPT:
                        ethanol.SetTimer()
                        _g_.m.life -= 1
+               case EXTERNAL_INTERRUPT:
+                       print("user input")
+                       fn := eisr
+                       fn(ctxt)
                default:

在中断的情况下,先取得 eisr 作为一个函数指标,在予以呼叫。这迂回的写法在正常的 Golang 里面也会使用到。因为 runtime 组件是其它组件的根源相依组件,所以它若要呼叫其它组件的函数,则使用这样的作法,能够让连结器有所准备。

回到 ethanol/main.go 的部份,当然就是必须新增 eisr 函数:

+var plic0 plic
+var uart0 uart
+
+func eisr(c uintptr) {
+       pp := uint32(plic0.read(uintptr(0x201004)))
+       up := uint8(uart0.read(uintptr(0x0)))
+       uart0.write(uintptr(0x0), uintptr(up))
+       plic0.write(uintptr(0x201004), uintptr(pp))
+}

 func main() {
-       uart := uart{device{0xfffffff000000000, 0x10000000}}
-       plic := plic{device{0xfffffff000200000, 0xc000000}}
+       uart0 = uart{device{0xfffffff000000000, 0x10000000}}
+       plic0 = plic{device{0xfffffff000200000, 0xc000000}}
...

再欠个技术债吧,这里我们让两个装置成为全域的,好让 maineisr 都能够拿到。在 eisr 当中,第一行与最後一行是 PLIC 的宣告与完成。第二行是读取接收暂存器;理论上,驱动程序的标准行为应该是要先检查 UART 本身搁置的中断类型为何,但我们只有启用一种中断,所以就连那一步都先省略了。第三行写回偏移量 0 的位置,是因为直接写的时候代表输出。

执行看看的话,可以发现,输入字元和 user input 字样都会一并显示出来。但是由於这些字元都没有经过 fmt.Scanf 的过程,所以我们的命令列提示字元 hdla > 的部份都没有动静。应该会有类似下图的操作结果(输入 exit 与四个空格):

Memory Base: 0x80000000
Memory Size: 0x20000000
hdla > user input
euser input
xuser input
iuser input
tuser input
 user input
 user input
 user input

6. 使字元穿透组件

所以我们只剩下最後一个关卡。既然都用 Golang 来做这个专案了,那麽就试试看 Golang 的经典同步机制:频道(channel)吧!

我们可以让 read 在频道的等待端,等待着接收一个字元;另一方面,在 eisr 里面则是设定一个频道的传输端,使字元穿透过去。首先是在 read 函数的新增:

+var UartChannel chan byte
+
 func (f *File) read(b []byte) (n int, err error) {
-       return 0, nil
+       b[0] = <-UartChannel
+       return 1, nil
 }

然後是 main 组件内的一些新增:

 func eisr(c uintptr) {
        pp := uint32(plic0.read(uintptr(0x201004)))
        up := uint8(uart0.read(uintptr(0x0)))
+       os.UartChannel <- byte(up)
        uart0.write(uintptr(0x0), uintptr(up))
        plic0.write(uintptr(0x201004), uintptr(pp))
 }

 func main() {
...
        alld[0] = uart0
        alld[1] = plic0

+       os.UartChannel = make(chan byte)

看起来很棒吧!但是编译之後,试跑下去,还来不及等到命令列出现以进行使用者输入的实验,就会遇到错误并有以下错误回溯讯息:

Memory Base: 0x80000000
Memory Size: 0x20000000
hdla > fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
os.(*File).read(...)
        /home/noner/FOSS/hoddarla/Hoddarla/go/src/os/file_opensbi.go:74
os.(*File).Read(0x0, {0xffffffcf0000e1d0, 0x1, 0x4})
        /home/noner/FOSS/hoddarla/Hoddarla/go/src/os/file.go:119 +0x40
...
fmt.Scanf(...)
        /home/noner/FOSS/hoddarla/Hoddarla/go/src/fmt/scan.go:81
main.main()
        /home/noner/FOSS/hoddarla/Hoddarla/ethanol/main.go:98 +0x264

原来是因为,主执行绪顺着执行,一路到了 read 函数内,就会因为这个等待频道的传输而被阻塞(blocking)。Golang 执行期进而判断,已经没有其它共常式可以排程,那就相当於这整个程序已经不可能再有状态的改变了;所有的共常式都在睡眠状态,这是死结(deadlock)!

好吧,那麽我们有没有可能把它绕过去?这样看起来应该硬是排一个共常式给它就可以了吧?

新增一个无作用的 Golang 共常式

...
        for _, d := range alld {
                d.deviceInit()
        }
+
+       go func() {
+               for {
+               }
+       }()
+
        fmt.Printf("hdla > ")
...

重新编译是可以执行,也不会出现死结的错误,但是一旦给了输入事件的中断,根据行为分类,有两种(或以上)不同的错误;但共同点都是,我们可以看到:

fatal error: unexpected exception

而这是我们加在 ethanol_trap1 里面的。目前我们处理了计时器中断与外部中断,但所有例外的部份都没有处置,只有 default 预设印出这个讯息,通知使用者发生了一个非预期的例外。我们使用 GDB 停在这里看看,可以发现是 runtime.runqput 函数里面,需要取用 _p_ 的时候出的问题。

我们在这整个系列文里面,大量地遭遇过 Golang 当中的抽象物件共常式(g)与执行绪(m),但是一直都没有提过处理器资源(p)。笔者自己其实也还不太清楚这个抽象物件的真正功能,但可以确定的是,有时候执行绪会需要拥有处理器资源才能够进行某些操作,这也是我们现在遭遇的问题之一。

作为参考,我们启用 Golang 的除错环境变数(GODEBUG=schedtrace=1,scheddetail=1)当作核心参数,再执行一遍:

SCHED 116ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=0 idlethreads=1 runqueue=0 gcwaiting=0 nmidlelocked=0 stopwait=0 sysmonwait=0
  P0: status=1 schedtick=4 syscalltick=0 m=0 runqsize=0 gfreecnt=0 timerslen=0
  M2: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=-1
  M1: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=-1
  M0: p=0 curg=5 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=-1
  G1: status=4(chan receive) m=-1 lockedm=-1
  G2: status=4(force gc (idle)) m=-1 lockedm=-1
  G3: status=4(GC sweep wait) m=-1 lockedm=-1
  G4: status=4(GC scavenge wait) m=-1 lockedm=-1
  G5: status=2() m=0 lockedm=-1

我们可以看到,现在的 Hoddarla/ethanol 核心,只有一个处理器资源,但有三个执行绪。拥有处理器资源的只有 M0 而已。万一使用者给输入的时候,当时被中断的执行绪刚好不是 M0 的话,不幸的是,它为了处理我们前前一小节加入的频道通讯,而会触发某些需要用到处理器资源的场景。也就是说,我们在先前处理上下文的时候,机关算尽,还挪用了 ethanol 不会用到的 gsignal 成员,但如果当初中断的执行绪不对,仍然会遇到现在的问题。

这个其实颇为难解,这里先用另外一个方法绕过去。概念很简单:如果进来的是外部中断,就先看看自己是不是 m0,如果不是,就做上下文交换。由於这个外部中断没有处理,所以交换完之後回到中断启用模式的时候,这个外部中断又会立刻触发。直到被中断的执行绪确实是 m0,理论上就可以绕过这个问题。

强迫 m0 处理外部中断

...
                case TIMER_INTERRUPT:
                        ethanol.SetTimer()
                        _g_.m.life -= 1
                case EXTERNAL_INTERRUPT:
                        print("user input\n")
-                       fn := eisr
-                       fn(ctxt)
+                       if _g_.m != &m0 {
+                               _g_.m.life = 0
+                       } else {
+                               fn := eisr
+                               fn(ctxt)
+                       }
                default:
...
                default:
+                       print("sepc: ", unsafe.Pointer(uintptr(ctxt.sepc)), "\n")
+                       print("scause: ", unsafe.Pointer(uintptr(ctxt.scause)), "\n")
+                       print("stval: ", unsafe.Pointer(uintptr(ctxt.stval)), "\n")
                        throw("unexpected exception")

重新编译并执行!结果还是出现了错误,这个页表错误(0xd 代表的是读取时的页表错误),是错误中的错误(panic in panic);根据错误讯息,问题是在更早的 fatal error: malloc during signal 错误。这个发生在 src/runtime/malloc.go 里面的 mallocgc 函数,使用 GDB 可以观察到这个回溯:

Breakpoint 2, runtime.mallocgc (size=88, typ=0xffffffc00009f740, needzero=true, ~r0=<optimized out>)
    at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/malloc.go:971
971                     throw("malloc during signal")
(gdb) bt
#0  runtime.mallocgc (size=88, typ=0xffffffc00009f740, needzero=true, ~r0=<optimized out>)
    at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/malloc.go:971
#1  0xffffffc00000bf24 in runtime.newobject (typ=0xffffffc00009f740, ~r0=<optimized out>)
    at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/malloc.go:1221
#2  0xffffffc000033920 in runtime.acquireSudog (~r0=<optimized out>) at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/proc.go:405
#3  0xffffffc000004a3c in runtime.chansend (c=0xffffffcf00050060, ep=0xffffffcf00009ea3, block=true, callerpc=<optimized out>,
    ~r0=<optimized out>) at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/chan.go:238
#4  0xffffffc0000047d0 in runtime.chansend1 (c=0xffffffcf00050060, elem=0xffffffcf00009ea3)
    at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/chan.go:144
#5  0xffffffc00008ca1c in main.eisr (c=<optimized out>) at /home/noner/FOSS/hoddarla/Hoddarla/ethanol/main.go:76
#6  0xffffffc00002e554 in runtime.ethanol_trap1 (ctxt=0xffffffcf00009ee0)
    at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/os_opensbi.go:317
#7  0xffffffc000059b2c in runtime.ethanol_trap () at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/sys_opensbi_riscv64.s:78

这个历程是,eisr 试图传送 UART 当中的那个字元,而 Golang 帮我们把频道操作转换成 chansend* 系列呼叫。在 acquireSudog 里面,符合了某些条件之後,它会试图创建一个新的物件。而又因为我们现在处在 m0.gsignal 共常式里面,就会触发记忆体配置控制里面的判断,认定我们在讯号处理当中不该额外配置记忆体。

前後搜寻可以突破的线索,发现了这个(在 chansend1 函数中):

231         if !block {
232                 unlock(&c.lock)
233                 return false
234         }
235
236         // Block on the channel. Some receiver will complete our operation for us.
237         gp := getg()
238         mysg := acquireSudog()

这里,阻塞与否的判断显然是个很重要的关键字。在正式进入 acquireSudog 之前,甚至还有一个控制区块是,如果是非阻塞模式就会回传的状态。笔者先前其实并不是一个很有经验的 Golang 使用者,所以其实在这之前都没有考虑过要使用非阻塞式的频道。

如果我们定性分析一下现在的状况,会发现确实有道理。如果是非阻塞式频道的传输端,假设频道上限还未达到的情况,没有道理需要当前共常式进入睡眠状态。总之,将这个频道改为非阻塞式试试。

改写 ByteChannel 为非阻塞频道

        alld[1] = plic0

-       os.UartChannel = make(chan byte)
+       os.UartChannel = make(chan byte, 1)

        for _, d := range alld {

感谢 Golang 的简洁语法,我们可以新增一个参数,使它的性质变为非阻塞。重新编译之後,无奈,还是会再遇到问题:

Memory Base: 0x80000000
Memory Size: 0x20000000
hdla > asfatal error: malloc during signal

goroutine 0 [idle]:
main.eisr(0xffffffcf00009ee0)
        /home/noner/FOSS/hoddarla/Hoddarla/ethanol/main.go:76 +0x84
...

还是一样是 malloc during signal!使用 GDB 观察,发现

(gdb) bt
#0  runtime.mallocgc (size=88, typ=0xffffffc00009f740, needzero=true, ~r0=<optimized out>)
    at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/malloc.go:971
#1  0xffffffc00000bf24 in runtime.newobject (typ=0xffffffc00009f740, ~r0=<optimized out>)                                                   at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/malloc.go:1221
#2  0xffffffc0000338f8 in runtime.acquireSudog (~r0=<optimized out>) at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/proc.go:405
#3  0xffffffc000004a3c in runtime.chansend (c=0xffffffcf00042070, ep=0xffffffcf00009ea3, block=true, callerpc=<optimized out>,
    ~r0=<optimized out>) at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/chan.go:238
#4  0xffffffc0000047d0 in runtime.chansend1 (c=0xffffffcf00042070, elem=0xffffffcf00009ea3)
    at /home/noner/FOSS/hoddarla/Hoddarla/go/src/runtime/chan.go:144
#5  0xffffffc00008c9f4 in main.eisr (c=<optimized out>) at /home/noner/FOSS/hoddarla/Hoddarla/ethanol/main.go:76
...

特别值得玩味的是,chansend 函数的参数里面,block 竟然为真。我们难道不是已经宣告成非阻塞频道了吗?而且笔者输入的时候,还特地间隔较久的时间,就是不希望输入太快打坏了仅有一个字元容量的频道。

事实上,这可能表示接收端没有成功接收这个频道的讯息。共常式的排程不像执行绪的排程,在我们没有非同步抢占(asynchrous preemption)支援的情况下,如果有些共常式很自私的话,很有可能其它的共常式没有办法被排程回来。比方说我们前几个小节加入的无穷回圈。

正确的作法应该是让那个无穷回圈共常式有办法让出执行资源,也就是主动触发执行绪内的共常式排程。这个不需要我们额外再设计什麽机制,只要套用原本就有的 runtime.Gosched 函数就可以了。

使空共常式让出运算资源

...
                d.deviceInit()
        }
 
        go func() {
                for {
+                       runtime.Gosched()
                }
        }()
 
        fmt.Printf("hdla > ")
...

然後再执行(老样子,在 Hoddarla repo

笔者稍早推上 github 的版本没有包含到 patch,惭愧!再补上一张执行动图。

如图:
执行

小结

予焦啦!这就是这届铁人赛,Hoddarla 这个专案初次亮相的所有技术内容。在最後一章里面,我们探索了 RISC-V 的外部中断机制,然後专攻 UART 的输入处理。今天,我们综合前两天的知识,也顺手使用了 Golang 的介面功能与频道同步机制,虽然过程中吃了不少苦头,但也逐步分析并解决了。笔者很庆幸现在能够达成这样的阶段性目标。

但当然,三十天的挑战尚未结束。笔者相信今天以前,这系列的技术内容已经相当足够,实际上也留下很多技术债,若是要进一步成熟化是不可忽视的项目。不过最後收尾的这几天,且让笔者挪用为附录,分享一些与 Hoddarla 专案本身弱相关,但与 RISC-V 或是 Golang 或是作业系统主题相关的资讯。各位读者,我们明日再会!


<<:  [Day22]程序菜鸟自学C++资料结构演算法 – 气泡排序法(Bubble Sort)

>>:  【Day 21】薛丁格的 Process (下) - Process Hollowing

Day 12. Unity可以做线上游戏吗?

嗨嗨,这里是学一学Unity程序又突然冒出来的疑问,因此就简单的搜索一下。 同样作为游戏引擎,Uni...

[Golang]同步工具-sync包的RWMutex-心智图总结

1. RWMutex,读写锁,又称读/写互斥锁。 读写锁是把对共享资源的"读操作"...

数位逻辑 2B OR NOT 2B

数位逻辑 (Digital Logic) 是用来代表电路输入与输出的控制,横跨非常多领域,可以用电子...

React中的优先级

点击进入React源码调试仓库。 UI产生交互的根本原因是各种事件,这也就意味着事件与更新有着直接关...

伸缩自如的Flask [day13] 档案上传

首先是上传档案, 可以先看一下Flask官网的范例: import os from flask im...