予焦啦!RISC-V 外部中断机制

予焦啦!上一章,我们完成了基本的排程;至少,程序的流程不会再因为单一的执行绪需要睡眠或是为了取得某些锁而卡住。虽然也和本系列文的其他机制一样粗暴而粗糙,但我们可以继续往前走了。

对,我们昨日实际上也实作了整个程序的离开函数,使之成为关机,从而具有关闭 QEMU 上的系统的效果。

从今天开始的新篇章,我们要探究的就是,为什麽离开之前都还没有显示出 Hello World 字样呢?以及如何正确地显示之。不过在那之前,我们会先回头打基础,研究一下 RISC-V 的外部中断(external interrupt)机制。

笔者也推荐 EN 大大的微自干作业系统的轻旅行系列的 PLIC 介绍。笔者会试图从其它的角度切入,希望能够讲出一点新意。

本日重点概念

  • RISC-V
    • 外部中断机制介绍:PLIC
    • 装置树:中断机制

外部中断

有了计时器中断的经验之後,外部中断本身也没有什麽太不一样的地方。一样是三个要件:

  • 总开关:sstatus 控制暂存器当中的 SIE 位元,代表作业系统模式的中断启用与否。
  • 次开关:sie 控制暂存器中的 SEI 位元,代表作业系统模式下的外部中断是否开启。
  • 中断条件达成,事件已搁置:sip 状态暂存器中的 SEI 位元,代表作业系统模式下,已有外部活动搁置等待处理。

三者都符合的时候,就会真正发生外部中断,程序流程因而被重新导到陷阱向量(stvec 所在位址),且附带有 scause 的值被设定成 0x8000000000000009 的状态。如先前介绍过的计时器中断,最高位元的 8 代表的是中断;9 则是作业系统模式外部中断的代码。

其中,无论是总开关还是次开关都很容易理解。比较复杂的总是如何判定搁置的状况。在计时器中断的场合,这个由平台暂存器 mtimemtimecmp 决定:当 mtime 超过 mtimecmp 时,搁置自然产生。

外部中断事件的搁置状况又如何呢?权限指令规格书轻描淡写地带过,说是由平台等级中断控制器(Platform-Level Interrupt Controler, PLIC)决定的(4.1.3 小节):

If implemented, SEIP is read-only in sip, and is set and cleared by the execution environment, typically through a platform-specific interrupt controller.

以外部中断为例,权限指令规格书习惯将 sip 的搁置位元称为 SEIPsie 的启用位元称为 SEIE,笔者在目前为止的系列文都称为 SEI,是想要彰显它们位在同一个位址,应该共享意义的意味。笔者不敢宣称独创,但也不认爲这麽做不恰当。

规格书中其实说是平台自行设定的中断控制器,而没有限定是 PLIC。现在 PLIC 在 RISC-V 世界已经有点专有名词的感觉了。

观察外部中断行为

首先,回头参考先前考察讯号机制时使用的 Debian 虚拟机,先使之开机开到命令列,如下:

debian login: debian
Password: 
Linux debian 5.10.0-8-riscv64 #1 SMP Debian 5.10.46-4 (2021-08-03) riscv64
...
Last login: Mon Sep 20 09:29:48 UTC 2021 on ttyS0
debian@debian:~$

在另外一个终端机,以 GDB 连上这个 QEMU,

0xffffffe000201cf4 in ?? ()
(gdb) p/x $sie
$1 = 0x222
(gdb) set $sie=0x22
(gdb) p/x $sip
$2 = 0x0
(gdb)

因为没有任何工作在操烦它,所以应该会是没有任何中断事件正在搁置的状态(从 sip 为 0 可知)。这里做一个手脚是,故意把第 9 个位元从 sie 的启用状态移除掉;事实上,它就是代表外部中断的位元。

接下来的实验展示,外部事件的搁置如何发生。我们可以在目前的设置之下,继续系统执行,并偶尔中断 GDB,观察 sip 里面是否有外部中断事件搁置,但我们会发现总是没有:

(gdb) c
Continuing.
^C
Program received signal SIGINT, Interrupt.
0xffffffe000201cf4 in ?? ()
(gdb) p/x $sip
$3 = 0x0
(gdb) c
Continuing.

这时候,如果开启另外一个终端机,并从它发起 SSH 连线到这个 Debian 系统,当然是无法接通的,毕竟 sie 的外部中断已经是非启用的状态。不过,这时候如果我们再观察 sip 状态暂存器,则:

^C
Program received signal SIGINT, Interrupt.
0xffffffe000201cf4 in ?? ()
(gdb) p/x $sip
$4 = 0x200

正是外部中断事件显示为搁置!

如果我们违抗规格书,试图清掉这个位元的话:

(gdb) set $sip=0x0


Ignoring packet error, continuing...
^C^CThe target is not responding to GDB commands.
Stop debugging it? (y or n) y
Disconnected from target.

会卡住很久,然後 QEMU 那边会遭遇到 Segmentation Fault,造成 GDB 这一端无法继续除错。

显示为搁置是没有问题。但外部中断毕竟不像计时器中断一样很明确,进入到陷阱向量之後,作业系统核心就可以知道要处理计时器事件。假设刚才外部中断是启用状态,那麽事件搁置的瞬间,就会触发外部中断跳转到陷阱向量,可是接下来又怎麽办呢?

作业系统该如何判断这个造成中断的外部事件,是来自网路还是键盘输入?

又,如果 sip.SEI 位元是唯读的,作业系统该怎麽在结束服务(比方说针对来自网路的中断接收封包、针对来自键盘的中断显示键入的字元、...)之後,将该位元清空

这些问题的答案,就是 PLIC 存在的目的。

PLIC 行为实验

PLIC 是平台等级的一个模组。但何谓平台(platform)?如果读者对於平台的概念不甚熟悉,可以将之想像为包含 CPU 的一个更大的集合,上面会有汇流排、记忆体、外部设备等其它的模组。

装置树描述的就是整个平台的装置组态,常用於嵌入式系统的系统组态表示。

以下,笔者会先从装置树里面厘清从各种外部装置到 CPU 核心(一个或多个,Hoddarla 现在是只支援单核心没有错,但 Debian 实验的话,可以观察多核心的行为)的外部中断路径上,PLIC 所扮演的角色为何。然後会进入到 PLIC 规格书中撷取一些有用的情报,最後再统整 PLIC 整体(当然,是从软件的角度)的图像。

从装置树中厘清外部装置与 PLIC 的关系

一样,我们附加 ,dumpdtb=dtb-machine virt 之後,这麽一来 QEMU 就不会真正模拟系统并开机,而是会输出所使用的装置树到档案 dtb 去。再将之反组译後,得到人类可读的装置树原始码。

首先我们可以找到 plic 节点:

        plic@c000000 {
                phandle = <0x09>;
                riscv,ndev = <0x35>;
                reg = <0x00 0xc000000 0x00 0x210000>;
                interrupts-extended = <0x08 0x0b 0x08 0x09 0x06 0x0b 0x06 0x09 0x04 0x0b 0x04 0x09 0x02 0x0b 0x02 0x09>;
                interrupt-controller;
                compatible = "sifive,plic-1.0.0\0riscv,plic0";
                #interrupt-cells = <0x01>;
                #address-cells = <0x00>;
        };

其中,reg 显然是我们在处理记忆体初始化时的熟面孔。第一组代表的是起始位址 0xc000000,第二组表示这个部分占据的空间是 2MB 再加上 64KB 的大小。起始位址通常也会出现在节点名称上面。除此之外的重要项目,我们再分以下小节探讨。

参考装置树规格书,更有根据。

身份标记:phandle

对应到规格书 2.3.3。

phandle 是一个标记,提供给其他节点参照。这里这个 plic 节点就具有一个 0x09

中断控制器:interrupt-controller

对应到规格书 2.4.1。

我们可以观察到这个没有其余参数存在的属性(attribute)。具有这个属性的节点才会被当作中断控制器,因而受到规格书 2.4 当中描述的、中断相关组态的规范。

连结到中断控制器的装置

对应到规格书 2.4.1。

在装置树中搜索,可以看到有些装置连结到中断控制器的 PLIC 来。以 UART 为例:

        uart@10000000 {
                interrupts = <0x0a>;
                interrupt-parent = <0x09>;
                clock-frequency = "\08@";
                reg = <0x00 0x10000000 0x00 0x100>;
                compatible = "ns16550a";
        };

interrupt-parent 属性描述的值,恰是 PLIC 的 phandle 值。在一个还不那麽复杂的 RISC-V 系统里面,这些节点都会具有这样的特性。

interrupts 属性,代表产生的中断号码。这里的 0x0a 表示,PLIC 端如果接收到 0x0a 这个中断,就表示它来自 UART 装置。

除了这个之外,还有其它的节点显然也是连接到 PLIC 的装置,如:

        virtio_mmio@10008000 {
                interrupts = <0x08>;
                interrupt-parent = <0x09>;
                reg = <0x00 0x10008000 0x00 0x1000>;
                compatible = "virtio,mmio";
        };

        virtio_mmio@10007000 {
                interrupts = <0x07>;
                interrupt-parent = <0x09>;
                reg = <0x00 0x10007000 0x00 0x1000>;
                compatible = "virtio,mmio";
        };

        virtio_mmio@10006000 {
                interrupts = <0x06>;
                interrupt-parent = <0x09>;
                reg = <0x00 0x10006000 0x00 0x1000>;
                compatible = "virtio,mmio";
        };

这些都是虚拟输入输出(virtio)装置的节点,虽然这里都显示为 virtio_mmio,实际上是会对应到不同的虚拟硬体,像是虚拟网卡或虚拟硬碟。至於它们的对应方式,可以参考这份文件以获得精准的对应。基本上我们可以确定,上述三个节点依序分别对应到虚拟硬碟、虚拟网卡、与一个乱数产生器,而它们的中断号码分别是 8、7、6。

在 UART 之前还有一个 RTC 节点,中断号码是 0xb,也就是 11。所以说,这个 QEMU 模拟出来的 RISC-V 系统,这样看起来有 5 个外部中断装置连接到 PLIC 上。

连结到中断控制器的 CPU 核心

对应到规格书 2.4.1。

从另外一个方向来看,PLIC 的另外一端是 CPU,这在装置树当中也有相关的讯息,记录在 interrupt-extended 属性之中。要理解这个属性,还是得先回顾一下上一小节提到的一般装置。

一般装置节点中的 interrupt-parent 指定一个中断控制器,该控制器负责接收自己的中断;之後再使用 interrupts 表示自己送出的一个或多个中断号码为何。

规格书上说,interrupt-extended 是比较复杂的系统上可能会有多个中断控制器,因此可以使用类似以下语法:

interrupts-extended = <&pic 0xA 8>, <&gic 0xda>;

来分别指定不同的中断控制器(上例的 picgic),以及每个中断器各自需要的资料(上例的 pic 需要两个资料块,而 gic 仅需要一个)。

因此让我们再仔细看看 PLIC 的这个属性栏位:

        interrupts-extended = <0x08 0x0b 0x08 0x09 0x06 0x0b 0x06 0x09 0x04 0x0b 0x04 0x09 0x02 0x0b 0x02 0x09>;

总共有 8 组内容,分别是

  • 0x08 中断控制器,中断号码为 0x0b
  • 0x08 中断控制器,中断号码为 0x09
  • 0x06 中断控制器,中断号码为 0x0b
  • 0x06 中断控制器,中断号码为 0x09
  • 0x04 中断控制器,中断号码为 0x0b
  • 0x04 中断控制器,中断号码为 0x09
  • 0x02 中断控制器,中断号码为 0x0b
  • 0x02 中断控制器,中断号码为 0x09

这些中断控制器又是指什麽呢?根据 phandle 来寻找,可以发现是在 CPU 核心内的中断控制器,实际上,也就代表该 CPU 可以接收的中断的意思。这里以 CPU 0 为例:

        cpu@0 {
                phandle = <0x07>;
                device_type = "cpu";
                reg = <0x00>;
                status = "okay";
                compatible = "riscv";
                riscv,isa = "rv64imafdcsu";
                mmu-type = "riscv,sv48";

                interrupt-controller {
                        #interrupt-cells = <0x01>;
                        interrupt-controller;
                        compatible = "riscv,cpu-intc";
                        phandle = <0x08>;
                        };
                };

我们可以看到它自带一个中断控制器,且 phandle 正是我们在寻找的 0x08,而 CPU 本身的 phandle 则是 0x07。所以这个四核心系统,就会有其余三个中断控制器分别是 0x060x040x02。这就解答了 PLIC 的 interrupt-extended 的一半部分。

另外一半是 0x0b0x09 如何而来?事实上这对应到 RISC-V 内建的几种中断类型。我们在建立计时器中断的时候,曾经遇到中断号码 0x70x5,它们分别是机器模式计时器中断与作业系统模式计时器中断。其实,0x0b 代表的是机器模式的外部中断,而 0x09 是作业系统模式的外部中断。

所以这麽一来,关於 PLIC 的中断,从装置到 CPU 的流向就都解明了。但要更深入的话,必须观察到 PLIC 的内部才行。

PLIC 记忆体映射

参考 PLIC 规格书第 3 章的记忆体映射章节,笔者直接用实验的方式对照 PLIC 的每个区域代表的意义。

这里所谓的「每个区域」,其实是指说,虽然看起来是单纯的物理记忆体位址,CPU 也可以去读写,但真正的效果是与输入输出(I/O)有关。所以这些区段也叫做记忆体映射输入输出(MMIO,Memory-Mapped I/O)暂存器。

再度比较一下计时器中断:当时我们停留在作业系统模式的抽象层,而没有深入到机器模式去观察,OpenSBI 究竟是如何协助作业系统进行计时器的设置。若是当时有观察的话,就会类似这里观察装置树中的 plic 结点一样,观察位於 clint 中的共用的时间值(相当於 mtime)与各个硬体核心(hart)各自拥有的计时器中断的时限(对应到 mtimecmp)。

0x0 - 0x1000:每个中断来源的优先权

每 4 个位元组定义一个中断来源(interrupt source),所以这个区段总共由 1024 个记忆体映射输入输出暂存器构成。

base + 0x000000:保留,中断输入源 0 号不存在
base + 0x000004:中断输入源 1 号的优先权
base + 0x000008:中断输入源 2 号的优先权
...
base + 0x000FFC:中断输入源 1023 号的优先权

其中,base 代表基底位址。我们现在探讨的 QEMU 系统的 PLIC 位於 0xc000000

回到 debian 虚拟机。我们使用 GDB 可以观察一下这个位址(但毕竟通常系统的虚拟记忆体转换是启动状态,没有办法直接看到这个物理位址。所以在实验前可以先关掉 satp、实验後再恢复之。

结果如下:

(gdb) p/x $satp
$1 = 0x800000000008ac05
(gdb) set $satp=0x0
(gdb) x/16wx 0xc000000
0xc000000:      0x00000000      0x00000000      0x00000000      0x00000000
0xc000010:      0x00000000      0x00000000      0x00000001      0x00000001
0xc000020:      0x00000001      0x00000000      0x00000001      0x00000001
0xc000030:      0x00000000      0x00000000      0x00000000      0x00000000

算起来,第一个有值的是 0xc000018,是中断来源 6。这五个具有优先权的中断来源,分别是 6、7、8、10、11,正是前一节列出的五个外部装置连结到 PLIC 的中断。

该如何理解中断的优先权?又该如何理解优先权的值?这就必须解释一下中断优先阈值的概念。

0x200000 + 0x1000 * contextID:每个 context 的中断优先阈值

context 在先前曾经以「上下文」作为翻译,代表行程或执行绪的上下文。但显然上下文很难兼用到这里的情境。

base + 0x200000:context 0 的中断优先阈值
base + 0x201000:context 1 的中断优先阈值
base + 0x202000:context 2 的中断优先阈值
...

还记得 PLIC 里面的 interrupt-extended 吗?那个顺序就是这里的 context 的顺序,也就是说

  • CPU0 的机器模式外部中断,对应到 context 0
  • CPU0 的作业系统模式外部中断,对应到 context 1
  • CPU1 的机器模式外部中断,对应到 context 2
  • CPU1 的作业系统模式外部中断,对应到 context 3
  • CPU2 的机器模式外部中断,对应到 context 4
  • CPU2 的作业系统模式外部中断,对应到 context 5
  • CPU3 的机器模式外部中断,对应到 context 6
  • CPU3 的作业系统模式外部中断,对应到 context 7

使用 GDB 观察:

(gdb) x/2wx 0xc200000
0xc200000:      0x00000007      0x00000000
(gdb) x/2wx 0xc201000
0xc201000:      0x00000000      0x00000000
(gdb) x/2wx 0xc202000
0xc202000:      0x00000007      0x00000000
(gdb) x/2wx 0xc203000
0xc203000:      0x00000000      0x00000000
(gdb) x/2wx 0xc204000
0xc204000:      0x00000007      0x00000000
(gdb) x/2wx 0xc205000
0xc205000:      0x00000000      0x00000000
(gdb) x/2wx 0xc206000
0xc206000:      0x00000007      0x00000000
(gdb) x/2wx 0xc207000
0xc207000:      0x00000000      0x00000000

可见,机器模式的中断优先阈值都设成 7,而作业系统模式为 0。规格书上说,如果一个中断来源的优先权小於或等於中断阈值的话,就不会在那个 context 上发生中断。也就是说,基本上机器模式已经全权将外部中断的处理交付给作业系统模式的意思。

这也可以从 mie 或是 mideleg 等控制暂存器的值推敲出来。不再赘述。

也就是说,4 个核心都有机会接到作业系统模式的外部中断,因为 5 个外部装置的优先权都是 1,这是我们正在使用的 Linux + OpenSBI 的预设值。实际上,如果一个系统希望这些外部事件有不同的优先权,当然也可以在前一节提到的中断来源优先权设定为不同的值,越高的优先权越高。否则若是优先权设定为一样的值,比方说像这里都是 1 的状况,序号越低则优先权越高。後面的实验,我们会看到对应的例子。

动态的实验设定

接下来我们一样先取消 sie 中的外部中断启用设定,再恢复系统运行,然後试着从别的终端机 SSH 连接到虚拟机去。

这大致上与前面小节的实验相同。取消了 sie.SEI 之後,这些来自外部的中断事件会搁置,但无法形成真正的外部中断。只是方便我们作实验观察而已。

就像 RISC-V CPU 针对整个系统设定了 3 种中断,并有与之对应的启用与搁置(比方说作业系统模式的 siesip)暂存器一样,PLIC 本身就是对於外部装置的中断控制器,所以自己也定义了中断启用与中断搁置的控制方法。一样是可存取的 MMIO 暂存器。

0x2000 + 0x80 * contextID:每个 context 的中断启用位元

先讲启用的部分,

base + 0x2000:context 0 的中断来源 0~31 启用位元
base + 0x2004:context 0 的中断来源 32~63 启用位元
...
base + 0x2080:context 1 的中断来源 0~31 启用位元
...
base + 0x2100:context 2 的中断来源 0~31 启用位元
...

事实上以我们现在的简单系统,都只会用到第一组(0~31)启用位元而已。所以我们可以观察一下每一个核心的启用状态。它们分别会是 context 1、3、5、7,因为都是作业系统模式的外部中断:

(gdb) x/wx 0xc002080
0xc002080:      0x00000dc0
(gdb) x/wx 0xc002180
0xc002180:      0x00000000
(gdb) x/wx 0xc002280
0xc002280:      0x00000000
(gdb) x/wx 0xc002380
0xc002380:      0x00000000

预设来讲,只有 CPU 0 的外部装置中断是有启用的。而且 0xdc0,刚好是 5 个位元,也是对应到 11, 10, 8, 7, 6 去。所以这里我们观察到的都是一致的现象。

严格来说,作业系统如 Linux 会选择自己重新建立 CPU 核心的序数,而未必会选择 RISC-V 机器模式的 mhartid 状态暂存器的序数。这里的 CPU 0 指的都是後者。

0x1000 - 0x2000:中断事件搁置位元

base + 0x001000: 中断事件 0-31 的搁置位元
base + 0x001004: 中断事件 32-63 的搁置位元
...

外部中断事件搁置是整个平台共有的,而不是 context 各自拥有的属性,所以这里只有一个区段。根据先前的实验设置,这里应该要至少有一个的搁置才对,因为有来自虚拟网路卡的请求。观察一下:

(gdb) x/wx 0xc001000
0xc001000:      0x00000580
(gdb) thread apply all p/x $sip

Thread 4 (Thread 1.4 (CPU#3 [halted ])):
$44 = 0x0

Thread 3 (Thread 1.3 (CPU#2 [halted ])):
$45 = 0x0

Thread 2 (Thread 1.2 (CPU#1 [halted ])):
$46 = 0x0

Thread 1 (Thread 1.1 (CPU#0 [halted ])):
$47 = 0x200

应该是因为只有 CPU 0 有设定这五个事件的启用,所以 sip 外部中断搁置也就只有 CPU 0 显示为搁置。观察 PLIC 的 MMIO 暂存器,我们看到的则是 0x580,也就是 UART(10)、虚拟硬碟(8)与虚拟网卡(7)中断。

最後的问题就只剩下,就算 CPU 0 打开了 sie.SEI 以表示它愿意接受这些个正式的外部中断事件,硬体(不管是 RISC-V CPU 或是 PLIC)又提供了什麽样的界面,让它可以正确地服务这些来自不同外部装置的中断呢?这就来到我们 PLIC 介绍的最後一个区块:宣告与完成暂存器。

0x200004 + 0x1000 * contextID:每个 context 的中断宣告(claim)与完成(complete)

中断宣告,指的是系统软件端可以使用中断宣告,得知它应该去服务的中断来源为何中断完成则是,在结束相关的服务之後,通知中断控制器与相关来源,前一个中断已经被服务了。

base + 0x200004:context 0 的中断宣告与完成
base + 0x201004:context 1 的中断宣告与完成
...

比较特殊的是,这组 MMIO 暂存器有其他的效应(side effect)。先说明中断宣告的效果如下:当这个暂存器被读取的时候,这个行为即是中断的宣告。该次中断事件的搁置位元,将会因此被(原子性地)清除掉。可以实验证明如下:

(gdb) x/wx 0xc001000
0xc001000:      0x00000580
(gdb) x/wx 0xc201004
0xc201004:      0x00000007 
(gdb) x/wx 0xc001000
0xc001000:      0x00000500
(gdb) x/wx 0xc201004
0xc201004:      0x00000008
(gdb) x/wx 0xc001000
0xc001000:      0x00000400
(gdb) x/wx 0xc201004
0xc201004:      0x0000000a
(gdb) x/wx 0xc001000
0xc001000:      0x00000000

GDB 的 x/wx 指令,在这里也相当於是一个读取的行为,於是被 PLIC 视作中断宣告。我们可以看到中断来源的事件搁置位元,由低位往高位一个一个减少,这就是相同优先权设置时的标准行为。但接下来我们也还需要进行中断完成的手续才行;也就是,必须将取得的中断来源序号(如这里的 7、8、10)写回这个 MMIO 暂存器,但顺序不须相同。

插曲:这个实验不容易完成

这个小节描述的实验方法比较复杂,实际上也可能代表某些笔者尚不明了的 PLIC 原理。欢迎对於 RISC-V 有研究的读者给予评论指正。其余读者的话,我则是建议跳过本小节。

在正常的软件流程来说,中断完成应当是如上述的操作便足够。但是笔者上述设计的实验,总是会出问题。简单来说是,在完成中断宣告之後(读取完这组 MMIO 暂存器),中断来源搁置位元确实消失了;然而,将对应的来源做中断完成(写回这组暂存器)之後,却似乎没有真正成功,该种类的事件就再也无法被系统取得并服务了。有两种可能,一种是,透过 GDB 的写入发生了什麽问题;另外一种是,也许直接由 GDB 端决定中断完成太过武断,对於中断来源装置本身应该也得进行一些处理,才能够使得中断完成有效。

因此笔者重新设计实验如下:

  1. 重开 debian 虚拟机,登入至 shell 闲置之。
  2. 另开 GDB 终端机,设置条件断点 b *$stvec if $scause == 0x8000000000000009,并使用 c 指令等待击中。这是为了在不调整 sie 的情况下抓到一个真正的外部中断,并观察之。实际上这个大约静置 5 秒内会发生,该事件通常是编号 8 的虚拟硬碟,或许是因为一些档案系统的操作吧。
  3. 观察 sip,值为 0x200;观察 x/wx 0xc001000,值为 0x100
  4. 进行中断宣告,x/wx 0xc203004(这次是 CPU 1 负责这个外部中断),取得 0x8 虚拟硬碟,完全符合预期。再观察 sip0xc001000,确实已经清空。
  5. 已知使用 GDB 进行写入(如指令 set *(unsigned int*)0xc203004=0x8)会有问题,疑似未生效。笔者这里采用比较激进的作法:
(gdb) p/x $satp
$12 = 0x800000000008b5be
(gdb) set $satp=0x0
(gdb) x/1000i $pc - 0xffffffe000000000 + 0x80200000
...
0x804024da: sw      a5,-76(s0)
...

由於关闭了虚拟记忆体,因此需要自行转换回物理位址(Linux 的位址偏移量如附上的计算式)。无论如何,重点是,如果 GDB 直接写入无效,那如果假 CPU 之手写入,结果又如何呢?这里我们看到一个写入指令 sw,宽度正好是我们要的 32 位元写入。因此,我们可以故意让 a5 的值为 0x8,而让 s0 的值为 0xc203050,如此一来扣除 76 的偏移之後,恰好能够让这行指令等效於 GDB 指令 set *(unsigned int*)0xc203004=0x8。当然,我们也得将程序指标(pc)堆移到那里才行。所以,

(gdb) p/x $satp
$1 = 0x8000000000082db6
(gdb) set $satp=0x0
(gdb) p/x $a5
$2 = 0xffffffe03fd2e540
(gdb) p/x $s0
$3 = 0xffffffe001003ea0
(gdb) set $a5=0x8
(gdb) set $s0=0xc203050
(gdb) set $pc=0x804024da
(gdb) x/i $pc
=> 0x804024da:  sw      a5,-76(s0)

在修改之前都必须要纪录原值,否则实验结束後无法复原,也是麻烦。由此万事具备,执行下去之後,

(gdb) si
0x00000000804024de in ?? ()
(gdb) set $satp=0x8000000000082db6
(gdb) p/x $stvec
$4 = 0xffffffe000201a14
(gdb) set $a5=0xffffffe03fd2e540
(gdb) set $s0=0xffffffe001003ea0
(gdb) x/1000i $stvec
...
   0xffffffe000201baa:  sret
   0xffffffe000201bae:  auipc   ra,0x0
   0xffffffe000201bb2:  addi    ra,ra,-114
   0xffffffe000201bb6:  andi    s1,s0,8
--Type <RET> for more, q to quit, c to continue without paging--q
Quit
(gdb) set $pc=0xffffffe000201baa
(gdb) si

看起来是成功了(事实上,笔者曾经思虑欠周,使用 sd 指令进行这个流程,结果触发写入错误,回到 OpenSBI 里面),推进到下一行去。那麽我们就假设这个外部中断已经成功地走过宣告与完成的流程,接下来是试试看回复所有条件。需注意的是,我们直接搜寻陷阱向量最後的 sret 指令,使系统回归到被外部中断之前的流程。且看这麽做,会发生什麽事情?

debian@debian:~$ ls /
bin   dev   initrd.img      lost+found  opt   run   sys  var
boot  etc   initrd.img.old  media       proc  sbin  tmp  vmlinuz
core  home  lib             mnt         root  srv   usr  vmlinuz.old

这次系统就顺利继续下去了!若是原本透过 GDB 写入的话,光是 ls 指令,就已经再也无法看到任何档案了,笔者认为这个非常繁琐的实验流程,至少代表我们走完了一次外部中断的省略版流程,因为显然原本虚拟硬碟通知我们的事情并没有被正确处理,也就是说,我们没有真正通过一个中断处理函数(interrupt service routine)。但也许在该装置上仍保存了该次中断事件的状态,使得它後续接手之後只要重发外部中断就不会出问题。

虽然笔者很疑惑,不确定为什麽不能透过 GDB 进行这个操作,但先暂且打住吧。不只是无法写入中断完成 MMIO 暂存器,连先前的那些中断来源启用暂存器之类的,也都无法写入修改。基本上我相信这也是 QEMU 的问题。

统整 PLIC 行为与外部中断的故事

用最精简的方法描述 PLIC 怎麽协助 RISC-V CPU 控制多个不同的外部设备的中断:

  1. 可以设定中断来源的优先权。
  2. 每个 CPU 可以对应到不同的 context 去。
  3. 每个 context 可以设定自己要启用哪些中断来源。
  4. 每个 context 可以设定自己的中断优先阈值。
  5. 外部中断发生!在整个处理的流程里面,先进行中断宣告(以读取的方式),服务完毕之後,进行中断完成(以写入的方式)。

小结

予焦啦!今天先就 PLIC 提供的界面进行了全盘的介绍。虽然在 Hoddarla 这个部份没有多新增什麽程序码来确保 Hello World 的生成,但理解外部中断的机制对於这个目标来说是至关重要的。无论如何,各位读者,我们明日再会!


<<:  Day 19 ml5.js 将 tensorflow 模型转换为 ml5 模型

>>:  [DAY19] 用 Azure Machine Learning SDK 建立 Datastore

成员 24 人:来玩一场「收尾游戏」吧!

「责任感,就是我们与猫的区别。」 如果有同事提离职了, 如何妥善处理离职同仁的交接问题呢? 没有要慰...

[DAY 06]环境建置 : 软件(1)

前言 我们已经讲完了环境建置中的硬体选择等议题了,但空有一台机器没有 OS 等软件还是无法去使用,所...

前言:新手入门–忐忑不安的开始

大家好!我是第一次参加iT铁人赛,看到iT邦里高手如云,我一个菜鸟入门,心里真的是感到万分的忐忑与不...

Day28|将 GitHub 的档案抓取下来到自己的本地端 - git pull 指令与冲突时的解决方法

上篇介绍了如何将档案 Push 到 GitHub 後,今天就来练习如何从 GitHub 下载档案吧!...

Day 15 - 从Business Intelligence(BI)到AI

图片来源 很多时候讲多了数据分析, 会让人傻傻分不清楚到底是要用AI(人工智慧)还是BI(商业智慧...