iT邦幫忙

2021 iThome 鐵人賽

DAY 25
2
Software Development

予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索系列 第 25

予焦啦!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 的生成,但理解外部中斷的機制對於這個目標來說是至關重要的。無論如何,各位讀者,我們明日再會!


上一篇
予焦啦!Golang 執行期的鎖
下一篇
予焦啦!Hello World 與 Uart 機制觀察
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索33

尚未有邦友留言

立即登入留言