予焦啦!使用暂存器除错

本节是以 Golang 上游 ee91bb83198f61aa8f26c3100ca7558d302c0a98 为基准做的实验。

予焦啦!回顾第零章,我们有了一支可以充分支援开发的工具链,也建立了模拟器的使用经验。开机启动流程上了轨道,我们已经确实进入到 ethanol 的映像档了。并且,就在昨日,我们导入早期错误处理用的微程序,成功捕捉第一个未知的错误。

然而,在真正开始开发任何认真的功能之前,为了避免除错所需的时间耗损,我们还是先准备一些除错工具和技能吧!

本节重点概念

  • RISC-V
    • 简单的组合语言设计
    • 控制暂存器
      • scause
      • sepc
      • stval
    • 例外:存取错误

前置准备

仔细想想,到昨日为止,我们成功完成的事情也就只是卡在 ethanol 核心最一开始的地步。虽然卡住了,但也没有办法回答这些问题:

  • 是因爲什麽错误而触发 early_halt
  • 在哪里?
  • 错误的内容是什麽?

但有一就有二,我们能印出一个字元,理论上就能够印出更多,提供更全面的讯息。以上三个问题,都是很理所当然的基本问题,RISC-V 当然也是支援的。事实上,以上三个问题的解答,可以分别从三个状态暂存器取得:scausesepc 以及 stval

为什麽先前都说控制暂存器,现在又说状态暂存器?呃,因为 CSR 的意义就是控制与状态暂存器,但为了描述三个字母的一个名词而用上八个字实在是太多了。之後会依场合决定如何称呼,但请读者知悉,控制暂存器与状态暂存器在本系列文中都用以指涉 CSR

我们先分别将这三个状态暂存器(编号都是来自权限指令集规格书)导入既有的框架中吧:

$ git diff
diff --git a/src/runtime/opensbi/csr.h b/src/runtime/opensbi/csr.h
index 40edffbba1..2ee6d54498 100644
--- a/src/runtime/opensbi/csr.h
+++ b/src/runtime/opensbi/csr.h
@@ -3,4 +3,7 @@
 // license that can be found in the LICENSE file.
 
 // CSR encoding
 #define CSR_STVEC      $0x105
+#define CSR_SEPC       $0x141
+#define CSR_SCAUSE     $0x142
+#define CSR_STVAL      $0x143

而且,我们仍需应用 csrrs 来生成虚拟指令 csrr 的纯粹读取状态暂存器的效果。如下:

diff --git a/src/cmd/internal/obj/riscv/obj.go b/src/cmd/internal/obj/riscv/obj.go
index 6180f6be79..fcb953d03d 100644
--- a/src/cmd/internal/obj/riscv/obj.go
+++ b/src/cmd/internal/obj/riscv/obj.go
@@ -1686,6 +1686,7 @@ var encodings = [ALAST & obj.AMask]encoding{
        AEBREAK & obj.AMask: iIEncoding,
        AWFI & obj.AMask:    iIEncoding,
        ACSRRW & obj.AMask:  iIEncoding,
+       ACSRRS & obj.AMask:  iIEncoding,

实作:通用显示函式

有了这些工具之後,我们还欠缺一个关键,那就是能够将取得的资料转换为一个一个 ASCII 码,再比照使用先前的控制台输出方法进行输出。

也许熟悉 Golang 的读者想问,为何不能呼叫 fmt.Println 或是其他 Print 函式?一来 runtime 组件绝对先於 fmt 组件,所以现阶段不可能使用 fmt 系列呼叫;二来就算接通了,也还没有控制台或是任何输出装置的驱动程序可使用。

但也不必做得太通用。既然我们现在只要看到状态暂存器内容就可满足,那麽就设计一个会执行 16 次的回圈,每次印出一个 16 进位码即可。也就是说,将 A1 暂存器设为 15(X0 恒为 0),每次回圈内容结束後减一,若是仍然大於或等於(BGE,Branch if Greater or Equal to)0 的话,则回到 loop 符号,否则就继续往下执行。这个骨架的实作为:

 #include "opensbi/csr.h"
 
+TEXT dump(SB),NOSPLIT|NOFRAME,$0
+       ADDI    $15, X0, A1
+loop:
+       ADDI    $-1, A1, A1
+       BGE     A1, X0, loop
+
 TEXT early_halt(SB),NOSPLIT|NOFRAME,$0

A1 只能算是一个回圈变数,这个东西要有用的话,应该要是每 4 个位元一组,先从目标 64 位元数字当中抽取出来。做法有很多,这里是先计算出每一次需要处理的最低位元索引(A1 乘以 4,也就是左移 2),然後将这 4 个位元先对齐到最低位,再用 0xF 作为位元遮罩取得目标的 4 个位元,存在 A0

 
+// the input is in A0
 TEXT dump(SB),NOSPLIT|NOFRAME,$0
        ADDI    $15, X0, A1
 loop:
+       // calculate the lowest bit to preserve in A0
+       SLLI    $2, A1, A2
+       SRL     A2, A0, A3
+       // we only need 4 bits
+       ADDI    $0xF, X0, A2
+       AND     A3, A2, A0
+
+       // the end of loop
        ADDI    $-1, A1, A1
        BGE     A1, X0, loop

需留意组合语言语义与暂存器之间的方向性。与 GNU 是完全相反,所以已经建立起习惯的读者在阅读时需转换一下。

这个 4 位元恰好可以形成一个 16 进位码,所以只剩下最後一个判断:要是这个值小於等於 9,那麽就应该用 0-9 的 ASCII 码(48~57)输出;反之,则应该用 A-F 的 ASCII 码(87~92)输出。由於 SBI 呼叫必然会摧毁 A0A6A7,所以 A0 的输入值应该要在一开始被保存起来:

 TEXT dump(SB),NOSPLIT|NOFRAME,$0
+       MOV     A0, A4
        ADDI    $15, X0, A1
 loop:
+       // recover the input
+       MOV     A4, A0
...
        AND     A3, A2, A0
 
+       // compare to 9
+       ADDI    $9, X0, A2
+       BLT     A2, A0, hexa
+hexn:  // number
+       ADD     $48, A0, A0
+       JMP     hex
+hexa:  // alphabet
+       ADD     $87, A0, A0
+hex:
+       // print: A0 is already done
+       MOV     $1, A7
+       MOV     $0, A6
+       ECALL

        // the end of loop
        ADDI    $-1, A1, A1
        BGE     A1, X0, loop
        
+       // newline
+       MOV     $10, A0
+       MOV     $1, A7
+       MOV     $0, A6
+       ECALL
+

最後再搭配一个回传(return)指令:

        ECALL
+       RET

就能够当作函式来呼叫了。

观察 scause

那麽,会进到早期错误处理函式的原因究竟是什麽呢?这个问题的答案就在 scause 状态暂存器之中。要直接观察的话,我们在早期错误处理函式之中补上这一段:

        ECALL
+       CSRRS   CSR_SCAUSE, X0, A0
+       CALL    dump(SB)
 
        WFI

RISC-V 小常识:CALLRET 实际上都是虚拟指令,对应到 JALR (或 JAL),也就是无条件跳跃指令。在呼叫时,下一行的位址会被作为回传时应抵达的位址,存放在 ra 暂存器之中;回传时,则是会无条件跳跃至 ra 暂存器内容之位址。

scause 状态暂存器的内容读取到 a0 之中,重编之後的执行结果:

...
Boot HART MIDELEG         : 0x0000000000000222
Boot HART MEDELEG         : 0x000000000000a109
HI0000000000000005

我们拿到了一个例外代码 5。之所以能够断言这是例外而非中断,是因为 RISC-V 规格当中,以 *cause 状态暂存器最高位元当作这个判断,1 则为中断,0 则为例外。

那麽 5 代表的又是什麽例外呢?这是记忆体读取错误(Load Access Fault),可参考权限指令集规格书的列表 3.6。

观察 stval

若是只知道例外的成因,也仍然不太方便除错,因此有一部分的例外发生时,会设置相关的资讯在 stval 当中。此外,也因为这类的错误原本大多只有记忆体存取相关的错误,因此在旧版本的规格书中,这个状态暂存器曾经被称为 sbadaddr。总之,我们这里使用一样的手法:

        CSRRS   CSR_SCAUSE, X0, A0
        CALL    dump(SB)
+       CSRRS   CSR_STVAL, X0, A0
+       CALL    dump(SB)
 
        WFI

可以观察到:

Boot HART MIDELEG         : 0x0000000000000222
Boot HART MEDELEG         : 0x000000000000a109
HI0000000000000005                              
0000000080017ee0

0x80017ee0?QEMU 的 RISC-V 通用装置的记忆体从 0x80000000 开始,这个位置算起还不到 1MB,怎麽可能会有问题呢?当然会有的。

OpenSBI 是运行在机器模式的常驻韧体,它自己有自己的资料结构。要是作业系统模式的系统无视它,而将它的记忆体区段内容全数充公,那系统还怎麽分工明确地运行下去呢?所以 OpenSBI 使用物理记忆体保护(PMP)功能,解决这个问题。稍微往上巡视旧的控制台输出内容,则可以看到

Domain0 HARTs             : 0*,1*,2*,3*
Domain0 Region00          : 0x0000000080000000-0x000000008001ffff ()
Domain0 Region01          : 0x0000000000000000-0xffffffffffffffff (R,W,X)
Domain0 Next Address      : 0x0000000080200000

stval 这里面的值 0x80017ee0 坐落在 Region00 里面,没有读取(R)、写入(W)和执行(X)之中的任何权限,所以若是对它读取了,就会触发读取的错误,代码 5 号。

观察 sepc

可以存取 github 以进行以下实验。

最後一个关键问题就是例外发生的地方了,记录在 sepc 里面。一样:

        CSRRS   CSR_STVAL, X0, A0
        CALL    dump(SB)
+       CSRRS   CSR_SEPC, X0, A0
+       CALL    dump(SB)
 
        WFI

执行结果为:

Boot HART MIDELEG         : 0x0000000000000222
Boot HART MEDELEG         : 0x000000000000a109
HI0000000000000005
000000008001bee0
00000000802554cc

0x802554cc 是物理位址,日後(我们还没启用)对应到的虚拟记忆体位址是 0xffffff80000554cc,这个位置是:

ffffff80000554b0 <_rt0_riscv64_opensbi>:
ffffff80000554b0:       0480051b                addiw   a0,zero,72
ffffff80000554b4:       0010089b                addiw   a7,zero,1
ffffff80000554b8:       0000081b                sext.w  a6,zero
ffffff80000554bc:       00000073                ecall
ffffff80000554c0:       00000517                auipc   a0,0x0
ffffff80000554c4:       fb050513                addi    a0,a0,-80 # ffffff8000055470 <early_halt
>
ffffff80000554c8:       10551073                csrw    stvec,a0
ffffff80000554cc:       00013503                ld      a0,0(sp)
ffffff80000554d0:       00810593                addi    a1,sp,8
ffffff80000554d4:       00000f97                auipc   t6,0x0
ffffff80000554d8:       00cf8067                jr      12(t6) # ffffff80000554e0 <main>
ffffff80000554dc:       0000                    unimp

事实上,就在我们设置 stvec 之後的第一行!这时候对 sp 暂存器所在的位址(应该就是前述的 0x8001bee0),读取一个值到 a0 暂存器,触发了读取存取例外。

小结

予焦啦!今天看了很多组语,也实际上卷起袖子来写组语程序,虽然都还很微小,但是已经可以透过这些简单的方法做除错以及蒐集线索。无论如何,我们明日再会!


<<:  Progressive Web App 加入主画面 : PWA 究竟加入和安装了什麽 (2)

>>:  Dungeon Mizarka 003

[Android Studio 30天自我挑战] RadioGroup,RadioButton元件介绍

RaidioButton为单选元件,然而RadioGroup则是放RadioButton的选项 如果...

30天轻松学会unity自制游戏-安装unity

首先工欲善其事,必先利其器,制作游戏有很多好用的工具。 选择一种适合自己的开始,如已有确认过要开发的...

Vue.js 从零开始:v-if,v-show

条件判断 v-if v-show v-if 与 v-show 的区别 <div id=&quo...

{DAY 28} Matplotlib 绘图2

前言 这篇文章会延伸昨天所学 改变参数的使用 并且画出更多的图表 文章内容分别是 3. 折线图、散布...

[DAY 30] 复刻 Rails - View 威力加强版 - 2

终於到最後一天了,那就不罗嗦直接进入正题吧! 关於 rendering.rb 之前我们的做法是把 r...