本節是以 Golang 上游 8854368cb076ea9a2b71c8b3c8f675a8e19b751c 為基準做的實驗
予焦啦!在前兩天的斷章當中,我們已經理解到我們必須審慎處理接下來的問題,準備不同的元件才能夠有機會達成最快速打通流程、最大限度利用 Golang 原生機制作為作業系統內部機制的目標。今天就來準備其中一個元件,回歸基本學習一下 RISC-V 的中斷,尤其是計時器中斷的機制。
mideleg
與 medeleg
mtime
與 mtimecmp
sstatus
、sie
與 sip
可能隔了一段時間了,但這裡還是要提起一個 RISC-V 世界的基本常識:最高的執行權限發生在機器模式(M-mode),而在我們的實驗當中,這個部分的系統都是由 OpenSBI 專案負責的。
至今為止,筆者在節錄 QEMU 輸出時,通常都會展室到這兩行:
Boot HART MIDELEG : 0x0000000000000222
Boot HART MEDELEG : 0x000000000000b109
以 RISC-V 系統的預設行為來講,所有的中斷與例外都會進到 MTVEC
的機器模式處理向量去,但這麼做顯然效能不會太好。因此有了這兩個控制暫存器,讓不同的中斷或是例外狀況,可以被委派(delegation)到作業系統模式的權限等級去。
這兩個控制暫存器的意義分別是:中斷委派與例外委派。其中的每個位元都是真偽的標記,代表的意義如下(粗體意為 OpenSBI 設置為預設委派給作業系統模式):
MIDELEG
(同 MIP
狀態暫存器的配置)
[3:0]
:機器模式軟體中斷、保留、作業系統模式軟體中斷、保留[7:4]
:機器模式計時器中斷、保留、作業系統模式計時器中斷、保留[11:8]
:機器模式外部中斷、保留、作業系統模式外部中斷、保留MEDELEG
(同 MCAUSE
狀態暫存器的配置)
[3:0]
:指令位址未對齊、指令存取錯誤、不合法的指令、除錯斷點
[7:4]
:讀取位址未對齊、讀取存取錯誤、儲存或原子操作未對齊、儲存或原子操作存取錯誤[11:8]
:來自機器模式的環境呼叫、保留、來自作業系統模式的環境呼叫、來自使用者模式的環境呼叫
[15:12]
:讀取或原子操作頁面錯誤、保留、讀取頁面錯誤、存取指令頁面錯誤
中斷的部分比較直截了當,作業系統模式的中斷,許多都是與裝置相關的,由作業系統的驅動程式本身來處理是最直接的。計時器與軟體中斷也是在作業系統模式處理。
例外的部分這裡只解釋幾例:頁面錯誤也沒有什麼理由要在機器模式處理,因為這個抽象層完全是在作業系統層級的;另一個是使用者模式的環境呼叫,也就是系統呼叫(system call),如果還要到機器模式再轉,未免太沒效能。
RISC-V 提供的計時器中斷機制基於兩種記憶體映射暫存器:mtime
與 mtimecmp
。規格書當中詳細地說明了為什麼這些比較適合作為記憶體映射的暫存器而非每個核心私有的控制暫存器。
主要是因為,mtime
的機制是穩定增加、頻率守恆的經過時間(wall time)計時器,而不是單純的時脈計數器。因為先進的處理器多半會支援在運作期間改變頻率的功能以調節效能與用電之間的平衡,所以單純的時脈計數器並不是 mtime
的設計理念。要達成這個目標,通常會需要類似石英震盪器之類的硬體,因此讓它成為全系統共享的裝置,就顯得理所當然。
值得一提的是,在 OpenSBI 的相關程式碼中看來,很有可能也會有些計時器設計會提供非共享式的
mtime
,但筆者以 QEMU 作為實驗平臺,暫不考慮那些架構。
mtimecmp
,則是每個核心都自己獨有的,且可自由讀寫。當時間隨著系統運行不斷流逝,直到 mtime
的值變為大於或等於 mtimecmp
之時,這顆核心收到計時器中斷的第一個必要條件就圓滿了。
如同字面上顯示的,在作業系統軟體的角度來看,這些工作大多是 OpenSBI 在負責。實務上,OpenSBI 對作業系統開放一些環境呼叫(enviroment call),讓作業系統可以設置過期時間,而讓 OpenSBI 自己轉換之後設置 mtimecmp
。這個 SBI 呼叫是 sbi_set_timer
,我們以組合語言實作之:
// func SetTimer()
TEXT runtime∕ethanol·SetTimer(SB), NOSPLIT|NOFRAME, $0-0
CSRRS CSR_TIME, ZERO, A0
ADD $0x1000000, A0, A0
MOV $0, A6
MOV $0x54494D45, A7
ECALL
RET
這個 CSRRS
指令,之前沒有使用過。原本是用來設置(set)狀態暫存器的意義,但這裡,當來源暫存器使用 ZERO
時,這就成為了官方推薦的虛擬指令(pseudo instruction)CSRR
,也就是單純的將控制暫存器內容讀取到目地暫存器。這裡,我們讀取的是 time
,這是 mtime
在作業系統模式的一個可讀取的別名。
CSRRS
可以比照CSRRC
新增,但加完之後需要重編 Golang 工具鏈。
之後,我們需要設置一個下一個過期的時間點給予計時器,這裡胡亂定一個值,日後再好好處理。以 QEMU 的這個平臺來講,這個值大概有 1.5 秒的體感時間可以觀察。
A6
與 A7
暫存器的賦值是 SBI 呼叫的慣例,對應到的即是 sbi_set_timer
。
還有幾組必要條件,散落在不同的控制暫存器裡面。
mideleg
這先前已經介紹過了。
sstatus
:作業系統模式系統狀態暫存器sstatus
或是 mstatus
都包含非常多面向的控制與狀態位元,這裡只會介紹一個索引在 1 的位元:SIE
。這個位元的意義代表,作業系統模式的中斷是否被啟用,相當於是所有中斷的總開關。
我們在今天的實驗中,實作一個組合語言函數來操作這個暫存器:
// func Interrupt(on bool)
TEXT runtime∕ethanol·Interrupt(SB), NOSPLIT|NOFRAME, $0-8
MOV on+0(FP), A0
MOV SSTATUS_SIE, A1
BEQ A0, ZERO, clear
CSRRS CSR_SSTATUS, A1, ZERO
RET
clear:
CSRRC CSR_SSTATUS, A1, ZERO
RET
並在 osinit
當中啟用之:
const (
ON = true
OFF = false
)
func osinit() {
...
argc = ethanol.GetArgc()
print("argc: ", argc, "\n")
+ ethanol.Interrupt(ON)
}
sie
與 sip
:作業系統模式中斷啟用與擱置最後一個計時器中斷的要素是這兩個控制暫存器。
今天探討的作業系統計時器中斷位元在兩者當中都是佔據第 5 位元,也就是 0x20
的位址。在今天的實驗中,我們先寫入這個位元到 sie
之中,
...
argc = ethanol.GetArgc()
print("argc: ", argc, "\n")
ethanol.Interrupt(ON)
+ ethanol.TimerInterrupt(ON)
...
而這個實作同樣位在新增的 src/rumtime/ethanol/trap.s
當中:
// func TimerInterrupt(on bool)
TEXT runtime∕ethanol·TimerInterrupt(SB), NOSPLIT|NOFRAME, $0-8
MOV on+0(FP), A0
MOV SIE_STI, A1
BEQ A0, ZERO, clear
CSRRS CSR_SIE, A1, ZERO
RET
clear:
CSRRC CSR_SIE, A1, ZERO
RET
這些要素都齊備了之後,就會以
scause
為0x8000000000000005
的中斷代碼,進入到stvec
所在的位址。那麼為什麼進入之後,計時器中斷不會無限觸發呢?因為,sstatus
在進入中斷之後,會將SPIE
位元設為SIE
位元的值,並將SIE
的值設置為 0;當SRET
指令執行之後,會反過來將SIE
位元設為SPIE
的值,並將SPIE
的值設為 1。這是一種避免巢狀呼叫的方式。
如果我們只是加上這些作業系統計時器中斷的功能的話,也許在那個中斷真的到來之前,還是會踩到沒有實作的 newosproc
的那個錯誤。
所以我們先在 newosproc
裡面插入一個無窮迴圈讓它卡住執行流程:
func newosproc(mp *m) {
+ for {
+ }
panic("newosproc: not implemented")
}
並且,要是一切執行符合預期,那麼在 early_halt
,也就是我們現在的 stvec
的中斷處理函式之內,我們印完相關的狀態暫存器之後,也應該要再設置一個新的過期時間,否則計時器中斷就會變成僅此一次的事件。所以,我們加入以下邏輯覆蓋掉原本的 WFI
行為:
diff --git a/src/runtime/rt0_opensbi_riscv64.s b/src/runtime/rt0_opensbi_riscv64.s
index 13b3626a4e..a7c9baca2f 100644
--- a/src/runtime/rt0_opensbi_riscv64.s
+++ b/src/runtime/rt0_opensbi_riscv64.s
@@ -58,6 +58,8 @@ TEXT early_halt(SB),NOSPLIT|NOFRAME,$0
CSRRS CSR_SEPC, ZERO, A0
CALL dump(SB)
+ CALL runtime∕ethanol·SetTimer(SB)
+ SRET
WFI
JMP early_halt(SB)
這樣的處理之後,我們就會看到原本的 scause
、stval
、sepc
輸出,每隔一段時間,就會印出來的效果!
以下的實驗結果可以透過今天更新的 Hoddarla repo 獲得。
道歉啟事:昨日如果有讀者引用了 patch 之後重編工具鏈應該會發生編譯錯誤,應該是印出參數和環境變數造成的一些附加效應使然。今天筆者就會將它們移除了。
...
Alloc: 0x10000 bytes
Reserve: 0x10000 bytes, at 0x0 but at 0xffffffc500010000
Map: 0x10000 bytes, at 0xffffffc500010000
I8000000000000005
0000000000000000
ffffffc000030dd8
I8000000000000005
0000000000000000
ffffffc000030dd8
I8000000000000005
0000000000000000
ffffffc000030dd8
I8000000000000005
0000000000000000
ffffffc000030dd8
...
予焦啦!整理一下 RISC-V 正常的計時器中斷的一整個流程:
0. 在作業系統模式,sstatus.SIE
的中斷總開關已經設置;sie.STI
計時器中斷也已經設置。
sbi_set_timer
,寫入 mtimecmp
。sbi_set_timer
尾聲,設定 mie.MTI
。mstatus.MIE
有設置,或者是中斷發生在較低的權限等級。這裡是後者。mie.MTI
與 mip.MTI
都已設置(後者是由於 mtime
增長到超過 mtimecmp
的緣故)。mideleg
的 MTI
位元沒有設定(事實上,就算設定了也沒有用)。mtvec
至 sbi_timer_process
:
mie.MTI
。mip.STI
。這個效應將會連帶影響到 sip.STI
。sstatus.SIE
有設置,或者是中斷發生在較低的權限等級。這裡是前者。sie.STI
與 sip.STI
都已設置。stvec
時,sstatus.SIE
被清為 0,因此不會無限觸發,進而允許中斷處置函數運作。今天我們使用最直接暴力的手法先打通了,但其實這樣會餘下許多問題是我們目前都還沒有探討過的。不過我們就將那些問題留到明天吧。各位讀者,我們明天再會!