予焦啦!支援 RISC-V 权限指令与暂存器

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

予焦啦!回顾昨日,当我们期待系统输出一个 H 後便在某处陷入未知的错误状态时,我们观察到的却是这个 H 字母不断印出的洗频现象。

为何如此?就是接下来探究的重点问题。然而,要回应这个问题,我们首先还缺了一点工具。

本节重点概念

  • 基础
    • Linux 早期错误处理
  • Golang
    • 编译器物件定义
    • 支援新指令
  • RISC-V
    • 指令格式 I-type 简述
    • wfi 权限指令
    • stvec 控制暂存器

简单的实验

事实上,我们可以透过一个简单的实验,证明我们昨日的分析与实作都还是正确的,至少在印出第一个 H 字元之後。使用以下修改:

@@ -9,6 +9,8 @@ TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        MOV     $1, A7
        MOV     $0, A6
        ECALL
+halt:
+       JMP     halt
        MOV     0(X2), A0       // argc

可以确保系统开机至此只经历了唯一的单行道,之後就陷在 halt 标签代表的无穷回圈之中。 JMP 助忆符在这里也是 Golang 对所有 CPU 架构都有支援的通用指令,代表无条件的跳跃。

提醒:由於这里是 runtime 组件的修改,这个内容是会直接与要开发的程序码连结在一起的,因此无需重编工具链。

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

果然停在这里!这里得请各位读者先按捺想要直接往下除错的工程师症状,这里有些事情可以先做。

早期错误处理:以 RISC-V Linux 为例

一个系统如果在它进入稳定状态之前就遭遇了错误,那麽这时候可能连能够正常回报错误讯息的能力都没有。比方说,还没有设置好控制台(console)输出导致根本没有错误讯息可以显示的状态、或是还没有初始化储存装置以至於无法记录系统日志(log)之类的。

那麽,其实能够做的事情,也就与我们这里的做法不会相差太多了。由於这种事态非同小可,一定要想办法保留现场才能够提供足够的资讯来给工程师除错。我们可以参考一下 Linux 的 RISC-V 版本当中是怎麽处理这个问题的。

RISC-V Linux 的进入位置在於 arch/riscv/kernel/head.S 之中,从 GNU 连结器预设的 _start 符号开始。茫茫组语,正愁不知何谓早期错误处理之时,很快就可以看见这一段程序码与注解:

    /* Set trap vector to spin forever to help debug */
    la a3, .Lsecondary_park
    csrw CSR_TVEC, a3

关键字是设置陷阱向量(trap vector),以能够卡在无穷回圈里面帮助除错。la 是 RISC-V 组语的虚拟指令(pseudo instruction),代表载入一个位址到暂存器。这里就是将 secondary_park 函式的位址写到 a3 当中。

由於控制暂存器(CSR,Control and Status Register)登场了,为了与通用暂存器(GPR,General-Purpose Register)作出区隔,之後若是只有简说暂存器的时候,意指为後者,也就是像是昨日也看过的 a0a6a7,可参见拙作

笔者也觉得「陷阱向量」说起来很好笑,事实上也没人这样讲,但国家教育研究院确实是这样翻译的。

csrw 的功能是「写入控制暂存器」。这里是将 a3 中的位址写到 CSR_TVEC,这是 Linux 为了兼容机器模式与作业系统模式而做的巨集(macro),替换之後的控制暂存器名称为 stvec,代表的即是陷阱向量。

在 RISC-V 中,例外(exception)与中断(interrupt)都算是一种陷阱,因此 CPU 遭遇到陷阱事件时,当时程序的执行流程就会停在当时的状态,跳转到陷阱向量继续执行下去。昨日介绍的 ecall 指令环境呼叫也是一种例外,实际上是跳转到 mtvec 所对应的 OpenSBI 中的陷阱向量。

Linux 这里的 CSR_TVEC 巨集若是在不同的模式下分别展开,就会分别成为stvecmtvec。控制暂存器的首字母代表他们运作的权限层级。

至於为什麽「来自 S-mode 的环境呼叫例外」归属於 mtvec 的管辖范围,而 S-mode 的疑难杂症(也就是那些早期 Linux 错误处理想要涵盖的范围)归属於 stvec 管理,读者可以先当作思考练习,日後有机会介绍。

secodary_park

那麽这个符号所代表的函式又做了些什麽呢?可见

.Lsecondary_park:
	/* We lack SMP support or have too many harts, so park this hart */
	wfi
	j .Lsecondary_park

从字面上来看,这个函式本来是要提供

  1. 系统仅提供单核心组态,因此多余的核心需要进入这里停车(park)
  2. 系统提供多核心组态,但实体硬体上太多核了,不应参与这个系统的开机,因此停止於此

然而现在,也有另一个用途,就是陷阱发生在系统早期阶段时,可以让他们停在这里,不修改任何暂存器与记忆体状态,这样可以方便除错。

注解中的 hart 名词代表硬体执行绪(hardware thread)。

组语的内容方面,除了笔者原先描述的无穷回圈模式之外,其中还有一个 wfi 指令,代表 Wait-For-Interrupt。要详细介绍这个指令的话会有点超过本系列文范围,事实上距离一个实验性的系统要用到这个指令的完整功能也还有点远,所以这里就只简单的描述:wfi 指令,CPU 可以合乎规格的将之实作成是省电用的指令,也可以是无作用(NOP:No Operation),这也是为什麽这个指令的标准用法都会包在回圈之中。

如法炮制

那麽,我们就比照目前 RISC-V 世界当中功能最齐全也最成熟的 Linux,一模一样的打造这个功能:

+TEXT early_halt(SB),NOSPLIT|NOFRAME,$0
+       WFI
+       JMP early_halt(SB)
+
 TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        MOV     $0x48, A0
        MOV     $1, A7
        MOV     $0, A6
        ECALL
+       MOV     early_halt(SB), A0
+       CSRRW   A0, STVEC, X0
...

控制暂存器指令

与 Linux 的不同点是,笔者使用了 csrrw 指令而非 csrw 指令,然而我们并不需要读取原先的 stvec 的功能,为何如此选择呢?这是因为实际上,控制暂存器指令(定义於基本指令集规格中的 -Zicsr 扩充)只有六个,它们分别是:

  1. csrrw:原子性(atmoic)地将控制暂存器的内容读取出至指定的目标暂存器,并自指定的来源暂存器写入值至控制暂存器中。由於原子性必须由 CPU 的真正硬体保证,因此并不会有读写冲突的问题。
  2. csrrwi:原子性地将控制暂存器的内容读取出至指定的目标暂存器,并写入常数值至控制暂存器中。
  3. csrrscsrrc:原子性地将暂存器的内容读取出至指定的目标暂存器,并将来源暂存器解读为位元栏(bit field),对控制暂存器进行设置(set)或是清除(clear)的动作
  4. csrrsicsrrci:上述叙述将来源暂存器取代为常数。

规格书中也认知到单纯读取或单纯写入功能的重要性,因此明订几个虚拟指令,这里列出其中两个(以 GNU 组译器预设顺序):

  1. csrwcsrrw x0, csr, rs,由 rs 暂存器写至 csr 控制暂存器。之所以能够这样操作,是因为 RISC-V 的 x0 为仅供读取的 0 值,所有对其写入的效果皆会被忽略。
  2. csrrcsrrs rd, csr, x0,由 csr 控制暂存器写至 rd 暂存器。要是使用 csrrw 作为本体,则会使用 x0 暂存器,也就会导致控制暂存器被清空。这里因此使用原先为设置位元的 csrrs 指令,这麽一来就算以 x0(也就是全为 0 值的暂存器)做设置的动作,也不会有任何实际效果。

这里笔者使用 CSRRW A0, STVEC, X0,只是与 GNU 采取反方向的指令,意图上的语法是相同的。

错误:控制暂存器未定义

但是编译之後,会有如下错误讯息:

$ GOOS=opensbi GOARCH=riscv64 go build -ldflags='-R 0x1000 -T -0x7fffffe000' ethanol/ethanol.go
# runtime
../go/src/runtime/rt0_opensbi_riscv64.s:17: illegal or missing addressing mode for symbol STVEC 
asm: assembly of ../go/src/runtime/rt0_opensbi_riscv64.s failed

也就是说,组译器认为,STVEC 这个符号是非法的(illegal)或者定址模式(addressing mode)未定义。这也是很正常的事。原本 Golang 只处理使用者应用程序,根本不需要也没有权限碰触陷阱向量控制暂存器。

不巧的是,理论上使用者空间可以操作的一个控制暂存器,浮点数运算单元控制暂存器(fcsr:FPU CSR)在 Golang 里面也实际上未使用,所以过往的 Golang RISC-V 框架内,并没有一个能够让我们直接套用的设计。

这里我们实际上有两个选择。一个是研究一般暂存器如何被组译器解析,然後将控制暂存器加入类似的路径,使它们在组译过程中被正确地解析;另一个是利用 RISC-V 指令格式的做法,最小限度的完成控制暂存器的支援。

笔者在权衡利弊之後选择後者。毕竟 Hoddarla 专案会不断的随着时间针对 Golang 上游 git 重定基底(rebase),後者可以保持最小改动。

控制暂存器指令格式

RISC-V 指令有六种不同的编码格式,可以参考拙作有更深入的描述。

从规格书上可以观察到,控制暂存器的指令编码格式属於 I-type,

| 31                20 | 19   15 | 14      12 | 11  07 | 06      00 |
+----------------------+---------+------------+--------+------------+
|   immediate[11:0]    |   rs1   |   funct3   |   rd   |   opcode   |
+----------------------+---------+------------+--------+------------+

只不过不一样的是,控制暂存器指令的常数部在这里代表的是控制暂存器的编号(定义在权限指令集之中)。以 stvec 为例,这个数字是 0x105。

作为一个先行版的测试,我们先试试看修改成这个写法:

+TEXT early_halt(SB),NOSPLIT|NOFRAME,$0
+       WFI
+       JMP early_halt(SB)
+
 TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        MOV     $0x48, A0
        MOV     $1, A7
        MOV     $0, A6
        ECALL
+       MOV     early_halt(SB), A0
+       CSRRW   A0, 0x105, X0
        MOV     0(X2), A0       // argc 
        ADD     $8, X2, A1      // argv 

结果错误变成:

# runtime
../go/src/runtime/rt0_opensbi_riscv64.s:17: CSRRW: expected register; found $261
asm: assembly of ../go/src/runtime/rt0_opensbi_riscv64.s failed

相关的判断在 src/cmd/asm/internal/asm/asm.go 档案中,我们可以观察到,中间的运算元总是被当作暂存器来解读,因此我们现在的这种用法悖离组译器的期待而被当作错误。

巧合的是,新增的 CSRRW 行之下的原本的两行,恰好就是 I-type 的两个代表:MOV 将会转换成载入(ld),而 ADD 将会转换成 addi,因为有一个参数是常数。两种格式相比的话,虽然都不完美,但後者看起来不会带有定址模式的语义,至少比较适合一点。

所以,再修改一个版本如下:

+TEXT early_halt(SB),NOSPLIT|NOFRAME,$0
+       WFI
+       JMP early_halt(SB)
+
 TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        MOV     $0x48, A0
        MOV     $1, A7
        MOV     $0, A6
        ECALL
+       MOV     early_halt(SB), A0
+       CSRRW   $0x105, A0, X0
        MOV     0(X2), A0       // argc

之後,错误又改变为

$ GOOS=opensbi GOARCH=riscv64 go build  -ldflags='-R 0x1000 -T -0x7fffffe000' ethanol/ethanol.go
# runtime
asm: encodingForAs: no encoding for instruction WFI
asm: encodingForAs: no encoding for instruction CSRRW
asm: assembly failed

错误变为,组译器向我们抱怨,他遭遇到的 WFICSRRW 是它所不认识的指令编码。至少现在我们没有格式问题了。

Golang 如何支援 RISC-V 组合语言编码

支援 WFI 指令

想要无中生有、直接支援新指令在工具链之中是很困难的,尤其是笔者并没有相关的经验。然而幸好,这里有一个个方面来说都与 wfi 相似的指令,那就是 ecall。它们都是固定的编码内容,也没有常数或是暂存器作为运算元。

所以我们可以先参考 ECALL 在 Golang 里面出现的地方,再将 WFI 照着做,理论上就可以完成相关的支援。所以我们搜寻:

$ grep ECALL -R ./src/cmd/internal/obj/riscv
./src/cmd/internal/obj/riscv/anames.go: "ECALL",
./src/cmd/internal/obj/riscv/cpu.go:    AECALL
./src/cmd/internal/obj/riscv/inst.go:   case AECALL:
./src/cmd/internal/obj/riscv/obj.go:            // SCALL is the old name for ECALL.
./src/cmd/internal/obj/riscv/obj.go:            p.As = AECALL
./src/cmd/internal/obj/riscv/obj.go:    AECALL & obj.AMask:  iIEncoding,
./src/cmd/internal/obj/riscv/obj.go:    case AECALL, AEBREAK, ARDCYCLE, ARDTIME, ARDINSTRET:

WFI 显然就少很多:

$ grep WFI -R ./src/cmd/internal/obj/riscv
./src/cmd/internal/obj/riscv/anames.go: "WFI",
./src/cmd/internal/obj/riscv/cpu.go:    AWFI
./src/cmd/internal/obj/riscv/inst.go:   case AWFI:

因此看来关键就在 ./src/cmd/internal/obj/riscv/obj.go 档案中的支援了。我们可以发现 WFI 本身其实是有被定义在 Golang 里面的部分,且 inst.go 里面的编码也与规格书中并无不同。

ECALLobj.go 中的第一个出现,是在 progedit 函式中,为了将一个比较旧版规格中的 SCALL 替换为 ECALL 的操作,事实上 WFI 不需考虑。

第二个出现的用途,在一长串的变数宣告 var encodings = [ALAST & obj.AMask]encoding 当中,将自己定义为 I-type。稍後可以帮 WFI 加入为这种编码格式。

第三个出现的用途,在函式 instructionsForProg 当中,大部份指令在这里做暂存器顺序的微调。我们就直接将 WFI 加入为 ECALL 的那个条件式内。

综合以上,现在的修改为:

diff --git a/src/cmd/internal/obj/riscv/obj.go b/src/cmd/internal/obj/riscv/obj.go
index a305edab4b..a3f1d49936 100644
--- a/src/cmd/internal/obj/riscv/obj.go
+++ b/src/cmd/internal/obj/riscv/obj.go
@@ -1684,6 +1684,7 @@ var encodings = [ALAST & obj.AMask]encoding{
        // 3.2.1: Environment Call and Breakpoint
        AECALL & obj.AMask:  iIEncoding,
        AEBREAK & obj.AMask: iIEncoding,
+       AWFI & obj.AMask:    iIEncoding,
 
        // Escape hatch
        AWORD & obj.AMask: rawEncoding,
@@ -1857,7 +1858,7 @@ func instructionsForProg(p *obj.Prog) []*instruction {
                ins.funct7 = 2
                ins.rd, ins.rs1, ins.rs2 = uint32(p.RegTo2), uint32(p.To.Reg), uint32(p.From.Reg
)
 
-       case AECALL, AEBREAK, ARDCYCLE, ARDTIME, ARDINSTRET:
+       case AWFI, AECALL, AEBREAK, ARDCYCLE, ARDTIME, ARDINSTRET:
                insEnc := encode(p.As)
                if p.To.Type == obj.TYPE_NONE {
                        ins.rd = REG_ZERO
diff --git a/src/runtime/rt0_opensbi_riscv64.s b/src/runtime/rt0_opensbi_riscv64.s
index c0de6b6716..cff93d3e82 100644
--- a/src/runtime/rt0_opensbi_riscv64.s
+++ b/src/runtime/rt0_opensbi_riscv64.s
@@ -4,11 +4,17 @@
 
 #include "textflag.h"
 
+TEXT early_halt(SB),NOSPLIT|NOFRAME,$0
+       MOV     $0x49, A0
+       MOV     $1, A7
+       MOV     $0, A6
+       ECALL       
+       WFI
+       JMP early_halt(SB)
+
 TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        MOV     $0x48, A0
        MOV     $1, A7
        MOV     $0, A6
        ECALL
+       MOV     $early_halt(SB), A0
+       //CSRRW $0x105, A0, X0
        MOV     0(X2), A0       // argc
        ADD     $8, X2, A1      // argv
        JMP     main(SB)

我们将 CSRRW 暂时注解掉,确认我们针对 wfi 的修改是否成功。重新编译工具链并重新编译 ethanol,再透过 objdump 工具观察:

ffffff8000055418 <early_halt>:
ffffff8000055418:       10500073                wfi
ffffff800005541c:       00000f97                auipc   t6,0x0
ffffff8000055420:       ffcf8067                jr      -4(t6) # ffffff8000055418 <early_halt>
ffffff8000055424:       0000                    unimp

成功啦!

支援 CSRRW

WFIECALL 不同,在我们调整运算元顺序之後,实际上这个指令已经和正常的 I-type 指令一模一样,所以我们这里直接先试试直接加上编码模式:

$ git diff                                      
diff --git a/src/cmd/internal/obj/riscv/obj.go b/src/cmd/internal/obj/riscv/obj.go
index a3f1d49936..6180f6be79 100644
--- a/src/cmd/internal/obj/riscv/obj.go                                                         
+++ b/src/cmd/internal/obj/riscv/obj.go                                                         
@@ -1685,6 +1685,7 @@ var encodings = [ALAST & obj.AMask]encoding{
        AECALL & obj.AMask:  iIEncoding,                                                        
        AEBREAK & obj.AMask: iIEncoding,
        AWFI & obj.AMask:    iIEncoding,
+       ACSRRW & obj.AMask:  iIEncoding,
  
        // Escape hatch
        AWORD & obj.AMask: rawEncoding,

也将 CSRRW 原先的注解移除:

diff --git a/src/runtime/rt0_opensbi_riscv64.s b/src/runtime/rt0_opensbi_riscv64.s
index cff93d3e82..7ab893574c 100644
--- a/src/runtime/rt0_opensbi_riscv64.s
+++ b/src/runtime/rt0_opensbi_riscv64.s
@@ -14,7 +14,7 @@ TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        MOV     $0, A6
        ECALL
        MOV     $early_halt(SB), A0
-       //CSRRW $0x105, A0, X0
+       CSRRW   $0x105, A0, X0
        MOV     0(X2), A0       // argc
        ADD     $8, X2, A1      // argv
        JMP     main(SB)

结果是:

ffffff8000055428 <_rt0_riscv64_opensbi>:
ffffff8000055428:       0480051b                addiw   a0,zero,72
ffffff800005542c:       0010089b                addiw   a7,zero,1
ffffff8000055430:       0000081b                sext.w  a6,zero
ffffff8000055434:       00000073                ecall
ffffff8000055438:       00000517                auipc   a0,0x0
ffffff800005543c:       fe053503                ld      a0,-32(a0) # ffffff8000055418 <early_halt>
ffffff8000055440:       10551073                csrw    stvec,a0

看起来效果完全符合预期!但以现在的结果, 0x105 用来代表 STVEC,还是不太理想。幸好 Golang 也支援 .h 档以及与 C 语言类似的语法,这里我们可以加入一个新的档案:

$ cat src/runtime/opensbi/csr.h
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// CSR encoding
#define CSR_STVEC     $0x105

并引用这个修改:

diff --git a/src/runtime/rt0_opensbi_riscv64.s b/src/runtime/rt0_opensbi_riscv64.s
index 7ab893574c..6a32edcc2d 100644
--- a/src/runtime/rt0_opensbi_riscv64.s
+++ b/src/runtime/rt0_opensbi_riscv64.s
@@ -3,3 +3,4 @@
 // license that can be found in the LICENSE file.
 
 #include "textflag.h"
+#include "opensbi/csr.h"
@@ -14,7 +15,7 @@ TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        MOV     $0, A6
        ECALL
        MOV     $early_halt(SB), A0
-       CSRRW   $0x105, A0, X0
+       CSRRW   CSR_STVEC, A0, X0
        MOV     0(X2), A0       // argc
        ADD     $8, X2, A1      // argv
        JMP     main(SB)

编译仍然可以成功。

执行结果

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

...
Boot HART MIDELEG         : 0x0000000000000222
Boot HART MEDELEG         : 0x000000000000a109
HIQEMU: Terminated

果然陷入到 stvec 设定的早期错误陷阱之中了!除了 _rt0_riscv64_opensbiH,也印出了 early_haltI,然後就此卡在 wfi 指令当中了吧。

小结

予焦啦!今日的收获是知道如何支援控制暂存器和原本应用程序等级的 Golang 无需使用的权限指令,并且也开始学习如何操作 RISC-V 在作业系统模式之下提供的控制暂存器 stvec

目前可以算是已经完成了主要的开发工具链:opensbi/riscv64 系统组合 Golang、能够成功编译简单的 .go 档、能够运行在 QEMU 模拟器上。至此,第零章的开发工具阶段告一段落。但接下来,我们先不急着推进,而是先把除错工具搞定。各位读者,我们明日再会!

至於为什麽昨日会是整面洗频的 H 印出,这个问题且待我们後面比较熟悉除错器之後再解开谜团。


<<:  musl libc 简介与其 porting(四)Nobody speak.

>>:  [Day 14] .Net 非同步概念整理

追求JS小姊姊系列 Day6 -- 郑列展现的工具力(上)

前情提要 郑列疑似烙下狠话,正要展现工具实力?! 郑列:我说啊,你知道身为工具人要会做哪些事吗? 答...

CIA安全目标

曾就「资讯本身的破坏」和「资讯或资讯系统获取或使用中断」进行了辩论。然而,FISMA和FIPS 19...

Jupyter Notebook 输入栏位设计(2)

前言 上一篇介绍 interact 基本的用法,可以设计使用者介面(UI),但无法取得输入值,本篇介...

【Day 03】又是 Print Spooler 搞的鬼 - CVE-2021-1675 PrintNightmare

环境 Windows Server 2019 (目标环境不能被更新过) Visual Studio ...

铁人赛 Day15 -- RWD响应式网页 -- 用手机、电脑、平板的拢来啦

什麽是RWD? 响应式网页设计(Responsive Web Design),可以让不同的设备都可以...