詳解RISC v中斷

聲明

本文爲本人原創,未經許可嚴禁轉載。部分圖源自網絡,如有侵權,聯繫刪除。

RISC-V 中斷與異常

trap(陷阱)可以分爲異常與中斷。在 RISC v 下,中斷有三種來源:software interrupt、timer interrupt(顧名思義,時鐘中斷)、external interrupt。

有同學可能見過 NMI,但是這是一種中斷類型而非中斷來源。Non-maskable interrupt,不可屏蔽中斷,與之相對的就是可屏蔽中斷。NMI 都是硬件中斷,只有在發生嚴重錯誤時纔會觸發這種類型的中斷。

有同學可能接觸過 Linux 中的軟中斷,即 softirq,但是請注意 software interrupt 與 softirq 是完完全全不一樣的。如果你沒有接觸過 softirq 就請現在就暫停本文去了解一下,否則把 Linux 中的 softirq 與 software interrupt 搞混是會貽笑大方的。

本文將全面介紹 RISC v 下的中斷髮送與處理、軟件中斷、用戶態中斷和特權級轉換,並結合 xv6 內核、rcore、Linux 內核等實現進行介紹。

與中斷有關的寄存器

下面所述的都是軟件中斷、外部中斷和異常相關的內容,時鐘中斷比較特殊將單獨介紹。

常規中斷

M-mode 的寄存器

mstatusmtvecmedelegmidelegmipmiemepcmcausemtval

S-mode 的寄存器

sstatusstvecsipsiesepcscausestvalsatp

在後文中,我們可能會有 xstatus`xtvec` 等的寫法,其中 x 表示特權級 m 或者 s 或者 u(u 僅僅在實現了用戶態中斷的 CPU 上存在)。

mcause

如果陷阱是由中斷引起的,則 mcause 寄存器中的“Interrupt”位被設置。Exception Code 字段用於標識最後一個異常或中斷的代碼。下表列出了可能的機器級異常代碼。異常代碼是 WLRL 字段,因此僅保證包含受支持的異常代碼。

(PS: 讀者可能疑惑爲啥在 mcause 中會存在 Supervissor software interrupt [TODO])

mstatus

MIE 與 SIE 是全局中斷使能位。當 xIE 爲 1 時,允許在 x 特權級發生中斷,否則不允許中斷。

當 hart 處於 x 特權級時,當 xIE 爲 0 時,x 特權級的中斷被全部禁用,否則被全部啓用。當 xIE 爲 0 時,對於任意的 w<x,w 特權級的中斷都是處於全局禁用狀態。對於任意的 y>x,y 特權級的中斷默認處於全局啓用狀態,無論 xIE 是否爲 1。

爲支持嵌套陷阱,每個可以響應中斷的特權模式 x 都有一個兩級中斷使能位和特權模式堆棧。xPIE 保存陷阱之前活動的中斷使能位的值,xPP 保存之前的特權模式。xPP 字段只能保存 x 及以下特權模式,因此 MPP 爲兩位寬,SPP 爲一位寬。當從特權模式 y 進入特權模式 x 時,xPIE 設置爲 xIE 的值;xIE 設置爲 0;xPP 設置爲 y。對於 MPP,可以設置的值有 0b00(用戶模式),0b01(S-mode),0b10(reserved),0b11(M-mode)

在 M 模式或 S 模式中,使用 MRET 或 SRET 指令返回陷阱。執行 xRET 指令時,將 xIE 設置爲 xPIE;將 xPIE 設置爲 1;假設 xPP 值爲 y,則將特權模式更改爲 y;將 xPP 設置爲 U(如果不支持用戶模式,則爲 M)。如果 xPP≠M,則 xRET 還會設置 MPRV=0。

mtvec

mtvec 記錄的是異常處理函數的起始地址。BASE 字段中的值必須始終對齊於 4 字節邊界,並且 MODE 設置可能會對 BASE 字段中的值施加額外的對齊約束。

MODE 目前可以取兩種值:

如果 MODE 爲 0,那麼所有的異常處理都有同一個入口地址,否則的話異常處理的入口地址是 BASE+4*CAUSE。(cause 記錄在 xcause 中)

要求異常處理函數的入口地址必須是 4 字節對齊的。

medeleg 與 mideleg

默認情況下,各個特權級的陷阱都是被捕捉到了 M-mode,可以通過代碼實現將 trap 轉發到其它特權級進行處理,爲了提高轉發的性能在 CPU 級別做了改進並提供了 medelegmideleg 兩個寄存器。

medeleg (machine exception delegation)用於指示轉發哪些異常到 S-mode;mideleg(machine interrupt delegation)用於指示轉發哪些中斷到 S-mode。

當將陷阱委託給 S 模式時,scause 寄存器會寫入陷阱原因;sepc 寄存器會寫入引發陷阱的指令的虛擬地址;stval 寄存器會寫入特定於異常的數據;mstatus 的 SPP 字段會寫入發生陷阱時的活動特權級;mstatusSPIE 字段會寫入發生陷阱時的 SIE 字段的值;mstatusSIE 字段會被清除。mcausemepcmtval 寄存器以及 mstatus 的 MPP 和 MPIE 字段不會被寫入。

假如被委託的中斷會導致該中斷在委託者所在的特權級屏蔽掉。比如說 M-mode 將一些中斷委託給了 S-mode,那麼 M-mode 就無法捕捉到這些中斷了。

mip 與 mie

mipmie 是分別用於保存 pending interrupt 和 pending interrupt enable bits。每個中斷都有中斷號 i(定義在 mcause 表中),每個中斷號如果被 pending 了,那麼對應的第 i 位就會被置爲 1. 因爲 RISC v spec 定義了 16 個標準的中斷,因此低 16bit 是用於標準用途,其它位則*臺自定義。

如下圖所示是低 16bit 的 mipmie 寄存器。其實比較好記憶,只需要知道 mcause 中的中斷源即可。例如 SSIP 就是 supervisor software interrupt pending, SSIE 就是 supervisor software interrupt enable。

如果全局中斷被啓用了,且 miemip 的第 i 位都爲 1,那麼中斷 i 將會被處理。默認情況下,如果當前特權級小於 M 或者當前特權級爲 M 切 MIE 是 1 的話,全局中斷就是被啓用的;如果 mideleg 的第 i 位爲 1,那麼噹噹前特權級爲被委託的特權級 x(或者是小於 x),且 mstatus 中的 xIE 爲 1 那麼就認爲是全局中斷是被啓用的。

寄存器 mip 中的每個位都可以是可寫的或只讀的。當 mip 中的第 i 位可寫時,可以通過向該位寫入 0 來清除掛起的中斷 i。如果中斷 i 可以變爲掛起但 mip 中的位 i 是隻讀的,則實現必須提供一些其他機制來清除掛起的中斷。如果相應的中斷可以變爲掛起,則 mie 中的位必須是可寫的。不可寫的 mie 位必須硬連線爲零。

mip .MEIP 和 mie .MEIE 是 M-mode 外部中斷的中斷掛起和中斷允許位。 MEIP 在 mip 中是隻讀的,由*臺特定的中斷控制器設置和清除。

mip .MTIP 和 mie .MTIE 是 M-mode 定時器中斷的中斷掛起和中斷允許位。 MTIP 在 mip 中是隻讀的,通過寫入映射到內存的 mtimecmp 來清除。

mip .MSIP 和 mie .MSIE 是機器級軟件中斷的中斷掛起和中斷允許位。 MSIP 在 mip 中是隻讀的,通過訪問內存映射控制寄存器寫入,遠程 harts 使用這些寄存器來提供 M-mode 處理器間中斷。 hart 可以使用相同的內存映射控制寄存器寫入自己的 MSIP 位。

如果實現了 S-mode,位 mip .SEIP 和 mie .SEIE 是 S-mode 外部中斷的中斷掛起和中斷允許位。 SEIP 在 mip 中是可寫的,並且可以由 M 模式軟件寫入以向 S 模式指示外部中斷正在掛起。此外,*臺級中斷控制器(PLIC)可以生成 S-mode 外部中斷。SEIP 位是可寫的,因此需要根據 SEIP 和外部中斷控制器的信號進行邏輯或運算的結果,來判斷是否有掛起的 S-mode 外部中斷。當使用 CSR 指令讀取 mip 時, rd 目標寄存器中返回的 SEIP 位的值是 mip.SEIP 與來自中斷控制器的中斷信號的邏輯或。但是,CSRRS 或 CSRRC 指令的讀取-修改-寫入序列中使用的值僅包含軟件可寫 SEIP 位,忽略來自外部中斷控制器的中斷值。

SEIP 字段行爲旨在允許更高權限層乾淨地模擬外部中斷,而不會丟失任何真實的外部中斷。因此,CSR 指令的行爲與常規 CSR 訪問略有不同。

如果實現了 S-mode, mip .STIP 和 mie .STIE 是 S-mode 定時器中斷的中斷掛起和中斷允許位。 STIP 在 mip 中是可寫的,並且可以由 M 模式軟件編寫以將定時器中斷傳遞給 S 模式。

mip .SSIP 和 mie .SSIE 是管理級軟件中斷的中斷掛起和中斷允許位。 SSIP 在 mip 中是可寫的。

S-mode 的 interprocessor interrrupts 與實現機制有關,有的是通過調用 System-Level Exception Environment(SEE)來實現的,調用 SEE 最終會導致在 M-mode 將 MSIP 位置爲 1. 我們只允許 hart 修改它自己的 SSIP bit,不允許修改其它 hart 的 SSIP,這是因爲其它的 hart 可能處於虛擬化的狀態、也可能被更高的 descheduled。因此我們必須通過調用 SEE 來實現 interprocessor interrrupt。M-mode 是不允許被虛擬化的,而且已經是最高特權級了,因此可以直接修改其它位的 MSIP,通常是使用非緩衝 IO 寫入 memory-mapped control registers 來實現的,具體依賴於*臺的實現機制。

多個同時中斷按以下優先級遞減順序處理:MEI、MSI、MTI、SEI、SSI、STI。異常的優先級低於所有中斷。

mepc

當 trap 陷入到 M-mode 時,mepc 會被 CPU 自動寫入引發 trap 的指令的虛擬地址或者是被中斷的指令的虛擬地址。

mtval

當 trap 陷入到 M-mode 時,mtval 會被置零或者被寫入與異常相關的信息來輔助處理 trap。當觸發硬件斷點、地址未對齊、access fault、page fault 時,mtval 記錄的是引發這些問題的虛擬地址。

stastus

與中斷相關的字段是 SIE、SPIE、SPP。

SPP 位指示處理器進入 supervisor 模式之前的特權級別。當發生陷阱時,如果該陷阱來自用戶模式,則 SPP 設置爲 0;否則設置爲 1。當執行 SRET 指令從陷阱處理程序返回時,如果 SPP 位爲 0,則特權級別設置爲用戶模式;如果 SPP 位爲 1,則特權級別設置爲 supervisor 模式;然後將 SPP 設置爲 0。

SIE 位在 supervisor 模式下啓用或禁用所有中斷。當 SIE 爲零時,在 supervisor 模式下不會進行中斷處理。當處理器在用戶模式下運行時,忽略 SIE 的值,並啓用 supervisor 級別的中斷。可以使用 sie 寄存器 來禁用單箇中斷源。

SPIE 位指示陷入 supervisor 模式之前是否啓用了 supervisor 級別的中斷。當執行跳轉到 supervisor 模式的陷阱時,將 SPIE 設置爲 SIE,並將 SIE 設置爲 0。當執行 SRET 指令時,將 SIE 設置爲 SPIE,然後將 SPIE 設置爲 1。

其它 s 特權級寄存器

stvec, sip, sie,sepc, scause, stval 與 m-mode 的相應寄存器區別不大,讀者可自行參閱 RISC v 的 spec。

satp 比較特殊,在 M-mode 沒有對應的寄存器,因爲 M-mode 沒有分頁,satp 記錄的是根頁表物理地址的頁幀號。在從 U 切換到 S 時,需要切換頁表,也即是切換 satp 的根頁表物理地址的頁幀號。

特權級轉換

我在這裏只介紹了 U 和 S 之間的切換,其實 S 和 M 之間的切換過程也是一樣的,只不過使用到的寄存器不一樣了而已。比如說保存 pc 的寄存,S 保存 U 的 pc 值使用的是 sepc,M 保存 S 的 pc 使用的是 mepc。此外,U 切換到 S 時一般需要切換頁表,而從 S 切換到 M 時不需要切換頁表,因爲 M 沒有實現分頁,也沒有 matp 寄存器(頁表根地址存儲在 satp 寄存器中,所以我這裏胡謅了個 matp)。

U 與 S 之間的切換

U 切換到 S

當執行一個 trap 時,除了 timer interrupt,所有的過程都是相同的,硬件會自動完成下述過程:

  1. 如果該 trap 是一個設備中斷並且 sstatus 的 SIE bit 爲 0,那麼不再執行下述過程
  2. 通過置零 SIE 禁用中斷
  3. 將 pc 拷貝到 sepc
  4. 保存當前的特權級到 sstatus 的 SPP 字段
  5. scause 設置成 trap 的原因
  6. 設置當前特權級爲 supervisor
  7. 拷貝 stvec(中斷服務程序的首地址)到 pc
  8. 開始執行中斷服務程序

CPU 不會自動切換到內核的頁表,也不會切換到內核棧,也不會保存除了 pc 之外的寄存器的值,內核需要自行完成。

對於沒有開啓分頁,如何切換特權級可以參考:實現特權級的切換 - rCore-Tutorial-Book-v3 3.6.0-alpha.1 文檔

如果啓用了分頁,當陷入到 S 模式時,CPU 沒有切換頁表(換出進程的頁表,換入內核頁表),內核需要自行切換頁表,參考:內核與應用的地址空間 - rCore-Tutorial-Book-v3 3.6.0-alpha.1 文檔基於地址空間的分時多任務 - rCore-Tutorial-Book-v3 3.6.0-alpha.1 文檔

其實切換頁表的過程也很簡單,只需要將內核的頁表地址寫入 satp 寄存器即可。

在執行中斷服務例程時還需要首先判斷 sstatus 的 SPP 字段是不是 0,如果是 0 表示之前是 U 模式,否則表示 S 模式。如果 SPP 是 1 那就出現了嚴重錯誤(因爲既然是從 U 切換到 S 的過程,怎麼可以 SPP 是 S 模式呢?當然,如果是內核執行時發生了中斷 SPP 是 1 那自然是對的,內核執行時發生中斷時如果檢查 SPP 是 0 那也是嚴重的錯誤)。

S 切換到 U

在從 S 切換到 U 時,要手動清除 sstatus 的 SPP 字段,將其置爲零;將 sstatus 的 SPIE 字段置爲 1,啓用用戶中斷;設置 sepc 爲用戶進程的 PC 值(你可能疑惑在 U 轉換到 S 時不是已經將用戶進程的保存在了 sepc 了嗎?因爲在 S-mode 也會發生中斷呀,那麼 sepc 就會被用來保存發生中斷位置時的 PC 了)。如果啓用了頁表,就需要想還原用戶進程的頁表,即將用戶進程的頁表地址寫入 satp,之後恢復上下文,然後執行 sret 指令,硬件會自動完成以下操作:

  1. sepc 寄存器中取出要恢復的下一條指令地址,將其複製到程序計數器 pc 中,以恢復現場;
  2. sstatus 寄存器中取出用戶模式的相關狀態,包括中斷使能位、虛擬存儲模式等,以恢復用戶模式的狀態;
  3. 將當前特權模式設置爲用戶模式,即取消特權模式,回到用戶模式。

S 與 M 之間的切換

S 切換到 M

S 切換到 M 與從 U 切換到 M 類似,都是從低特權級到高特權級的切換。在 S 運行的代碼,也可以通過 ecall 指令陷入到 M 中。

  1. S-mode 的代碼執行一個指令觸發了異常或陷阱,例如環境調用(ECALL)指令
  2. 處理器將當前的 S-mode 上下文的狀態保存下來,包括程序計數器 (PC)、S-mode 特權級別和其他相關寄存器,保存在當前特權級別堆棧中的 S-MODE 陷阱幀(trap frame,其實就是一個頁面)中
  3. 處理器通過將 mstatus 寄存器中的 MPP 字段設置爲 0b11(表示先前的模式是 S 模式)將特權級別設置爲 M-mode
  4. 處理器將程序計數器設置爲在 M-mode 中的陷阱處理程序例程的地址
  5. 處理器還在 mstatus 寄存器中設置 M-mode 中斷使能位 (MIE) 爲 0,以在陷阱處理程序中禁用中斷

系統調用的實現

系統調用是利用異常機制實現的。在 mcause 中我們看到有 Environment call from U-mode 和 Environment call from S-mode 兩個異常類型。那麼如何觸發這兩個異常呢?分別在 U-mode 和 S-mode 執行 ecall 指令就能觸發這兩個異常了。

異常觸發之後,就會被捕捉到 M-mode(我之前提過,RISC v 下默認是把所有的異常、中斷捕捉到 M-mode,當且僅當對應的陷阱被委託給了其它模式纔會陷入到被委託的模式中)。假如說

地址空間佈局

啓用分頁模式下,內核代碼的訪存地址也會被視爲一個虛擬地址並需要經過 MMU 的地址轉換,因此我們也需要爲內核對應構造一個地址空間,它除了仍然需要允許內核的各數據段能夠被正常訪問之後,還需要包含所有應用的內核棧以及一個 跳板 (Trampoline) 。

值得注意的是,下面是是 rCore 的內核地址空間分佈,不同的 OS 設計不同。

高 256GB 內核地址空間 低 256GB 內核地址空間
應用程序高 256GB 地址空間 應用程序低 256GB 地址空間

跳板機制

使能了分頁機制之後,我們必須在 trap 過程中同時完成地址空間的切換。具體來說,當 __alltraps 保存 Trap 上下文的時候,我們必須通過修改 satp 從應用地址空間切換到內核地址空間,因爲 trap handler 只有在內核地址空間中才能訪問;同理,在 __restore 恢復 Trap 上下文的時候,我們也必須從內核地址空間切換回應用地址空間,因爲應用的代碼和數據只能在它自己的地址空間中才能訪問,應用是看不到內核地址空間的。這樣就要求地址空間的切換不能影響指令的連續執行,即要求應用和內核地址空間在切換地址空間指令附*是*滑的。

我們爲何將應用的 Trap 上下文放到應用地址空間的次高頁面而不是內核地址空間中的內核棧中呢?原因在於,在保存 Trap 上下文到內核棧中之前,我們必須完成兩項工作:1)必須先切換到內核地址空間,這就需要將內核地址空間的 頁表地址寫入 satp 寄存器;2)之後還需要保存應用的內核棧棧頂的位置,這樣才能以它爲基址保存 Trap 上下文。這兩步需要用寄存器作爲臨時週轉,然而我們無法在不破壞任何一個通用寄存器的情況下做到這一點。因爲事實上我們需要用到內核的兩條信息:內核地址空間的 頁表地址,以及應用的內核棧棧頂的位置,RISC-V 卻只提供一個 sscratch 寄存器可用來進行週轉。所以,我們不得不將 Trap 上下文保存在應用地址空間的一個虛擬頁面中,而不是切換到內核地址空間去保存。

Page fault

當 CPU 無法將虛擬地址轉換爲物理地址時,CPU 會生成頁面錯誤異常。RISC-V 有三種不同類型的頁面錯誤:加載頁面錯誤(當加載指令無法轉換其虛擬地址時)、存儲頁面錯誤(當存儲指令無法轉換其虛擬地址時)和指令頁面錯誤(當指令的地址不轉換時)。scause 寄存器中的值指示頁面錯誤的類型,而 stval 寄存器中包含無法轉換的地址。

Cow(copy on write) Fork 中的基本方案是讓父子進程在最開始時共享所有物理頁面,但將它們映射爲只讀。因此,當子進程或父進程執行存儲指令時,RISC-V CPU 會引發頁面錯誤異常。作爲對此異常的響應,內核會複製包含錯誤地址的頁面。它將一個副本映射到子進程的地址空間中,並將另一個副本映射到父進程的地址空間中。在更新頁表之後,內核在導致錯誤的指令處恢復出錯進程。因爲內核已經更新了相關的 PTE 以允許寫入,所以出錯指令現在將正常執行。

xv6 中是如何設置 stvec 的

我們已經知道 stvec 寄存器保存的是中斷服務程序的首地址,另外在 U 模式下,stvec 必須指向的是 uservec,在 S 模式下,stvec 必須指向的是 kernelvec,這樣做的原因是需要在 uservec 切換頁表。

那麼 xv6 是如何設置 stvec 的呢?首先在 uservec 例程中除了執行保存上下文、切換頁表等操作之外,還會在 usertrap 中將 stvec 指向 kernelvec,這裏的切換的目的是當前已經執行到了 S 模式,所有的中斷、陷阱等都必須由 kernelvec 負責處理。

當需要返回 usertrap 時,usertrap 會調用 usertrapretusertrapret 會重新設置 stvec 的值使其指向 uservec,之後跳轉到 userret,恢復上下文和切換頁表。

第一次的 stvec 是如何設置的

main 中,cpu0 調用了 userinit() 創建了第一個用戶進程,並在 scheduler 中會切換到該進程。該進程的上下文中的 ra(返回地址)被設置成了 forkret(),當 scheduler 執行 swtch 函數時,會將進程上下文中的 ra 寫入到 ra 寄存器中,這樣當要從 swtch() 中返回時,就會返回到了 forkret(),在 forkret() 中會直接調用 usertrapret 以實現 stvec 的設置和頁表的切換。

與中斷有關的硬件單元

在 RISC v 中,與中斷有關的硬件單元主要有 ACLINT、CLINT、PLICCLIC

CLINT 的全稱是 Core Local Interrupt,ACLINT 的全稱是 Advanced Core Local Interrupt, CLIC 的全稱是 Core-Local Interrupt Controller。

PLIC 的全稱 Platform-Level Interrupt Controller。

儘管 CLIC 與 PLIC 名稱相似,但是 CLIC 其實是爲取代 CLINT 而設計的。ACLINT 是爲了取代 SiFive CLINT 而設計的,本質上講,ACLINT 相比於 CLINT 的優勢就在於進行了模塊化設計,將定時器和 IPI 功能分開了,同時能夠支持 NUMA 系統。但是 ACLINT 和 CLINT 都還是 RISC-V basic local Interrupts 的範疇。

PLIC 和 CLIC 的區別在於,前者負責的是整個*臺的外部中斷,CLIC 負責的是每個 HART 的本地中斷。

PLIC

ACLINT

ACLINT 的規範翻譯參見 RISC-V ACLIT

根據 Linux RISC-V ACLINT Support 的說法,大多數現有的 RISC-V *臺使用 SiFive CLINT 來提供 M 級定時器和 IPI 支持,而 S 級使用 SBI 調用定時器和 IPI。此外,SiFive CLINT 設備是一個單一的設備,所以 RISC-V *臺不能部分實現提供定時器和 IPI 的替代機制。RISC-V 高級核心本地中斷器(ACLINT)嘗試通過以下方式解決 SiFive CLINT 的限制:

  1. 採用模塊化方法,分離定時器和 IPI 功能爲不同的設備,以便 RISC-V *臺可以只包括所需的設備
  2. 爲 S 級 IPI 提供專用的 MMIO 設備,以便 SBI 調用可以避免在 Linux RISC-V 中使用 IPI
  3. 允許定時器和 IPI 設備的多個實例多 sockets NUMA 系統

RISC-V ACLINT 規範向後兼容 SiFive CLINT。

CLIC

spec 參見 riscv-fast-interrupt/clic.adoc

RISC-V 特權架構規範定義了 CSR,例如 x ipx ie 和中斷行爲。爲這種 RISC-V 中斷方案提供處理器間中斷和定時器功能的簡單中斷控制器被稱爲 CLINT。當 x tvec .mode 設置爲 0001 時,本規範將使用術語 CLINT 模式。

在前文介紹 mtvec 時提到了 mode 字段,在 RISC-V 目前的特權級規範中,mode 字段只能取 00 或 01,其它值是 reserved。從 spec 的描述中我們可以看出,mode 字段無論是 00 還是 01,都是 CLINT 模式,因此我們在前文介紹的有關中斷的介紹都是 CLINT 模式(包括 ACLINT)。

我目前不太清除 CLIC 是否在

時鐘中斷

“定時器中斷”是由一個獨立的計時器電路發出的信號,表示預定的時間間隔已經結束。計時器子系統將中斷當前正在執行的代碼。定時器中斷可以由操作系統處理,用於實現時間片多線程,但是對於 MTIME 和 MTIMECMP 的讀寫只能由 M-mode 的代碼實現,因此內核需要調用 SBI 的服務。

我相信你已經在 RISC-V ACLIT 已經瞭解到了時鐘中斷的基本原理,現在我們看一下如何處理時鐘中斷。

時鐘中斷相關的寄存器

https://tinylab.org/riscv-timer/
mtime 需要以固定的頻率遞增,並在發生溢出時迴繞。當 mtime 大於或等於 mtimecmp 時,由核內中斷控制器 (CLINT, Core-Local Interrupt Controller) 產生 timer 中斷。中斷的使能由 mie 寄存器中的 MTIESTIE 位控制,mip 中的 MPIESPIE 則指示了 timer 中斷是否處於 pending。在 RV32 中讀取 mtimecmp 結果爲低 32 位, mtimecmp 的高 32 位需要讀取 mtimecmph 得到。
由於 mtimecmp 只能在 M 模式下訪問,對於 S/HS 模式下的內核和 VU/VS 模式下的虛擬機需要通過 SBI 才能訪問,會造成較大的中斷延遲和性能開銷。爲了解決這一問題,RISC-V 新增了 Sstc 拓展支持(已批准但尚未最終集成到規範中)。
Sstc 擴展爲 HS 模式和 VS 模式分別新增了 stimecmpvstimecmp 寄存器,當\(time >= stimecmp\)或者\(time + htimedelta >= vstimecmp\)是會產生 timer 中斷,不再需要通過 SBI 陷入到其它模式。

時鐘中斷的基本處理過程

如下圖所示是時鐘中斷的基本過程(xv6 的處理過程):

圖源:https://shakti.org.in/docs/risc-v-asm-manual.pdf

讓我們首先回顧一下有關 timer 的寄存器。首先要明確的是,timer 的寄存器在 timer 設備裏,不在 CPU 中,是通過 MMIO 的方式映射到內存中的。

mtime 寄存器是一個同步計數器。它從處理器上電開始運行,並以 tick 單位提供當前的實時時間。

mtimecmp 寄存器用於存儲定時器中斷應該發生的時間間隔。mtimecmp 的值與 mtime 寄存器進行比較。當 mtime 值變得大於 mtimecmp 時,就會產生一個定時器中斷。mtimemtimecmp 寄存器都是 64 位內存映射寄存器,因此可以直接按照內存讀寫的方式修改這兩個寄存器的值。

xv6 的實現

xv6 對於時鐘中斷的處理方式是這樣的:在 M-mode 設置好時鐘中斷的處理函數,當發生時鐘中斷時就由 M-mode 的代碼讀寫 mtimemtimecmp,然後激活 sip.SSIP 以軟件中斷的形式通知內核。內核在收到軟件中斷之後會遞增 ticks 變量,並調用 wakeup 函數喚醒沉睡的進程。 內核本身也會收到時鐘中斷,此時內核會判斷當前運行的是不是進程號爲 0 的進程,如果不是就會調用 yield() 函數使當前進程放棄 CPU 並調度下一個進程;如果使進程號爲 0 的進程,那就不做處理。

timer_init

// core local interruptor (CLINT), which contains the timer.
#define CLINT 0x2000000L
#define CLINT_MTIMECMP(hartid) (CLINT + 0x4000 + 8*(hartid))
#define CLINT_MTIME (CLINT + 0xBFF8) // cycles since boot.

void
timerinit()
{
  // each CPU has a separate source of timer interrupts.
  int id = r_mhartid();

  // ask the CLINT for a timer interrupt.
  int interval = 1000000; // cycles; about 1/10th second in qemu.
  
  // 我已經提過,mtimecmp 是映射到了物理地址中的,因此可以直接按照內存讀寫的方式
  // 修改寄存器的值
  // MTIME 寄存器映射到了 0x2000_BFF8
  // 一塊CPU有一個MTIME,所有的hart都共用這一個 MTIME
  // MTIMECMP 的內存基地址是 0x2000000L
  // 每個寄存器佔 8個字節,每個hart都有一個MTIMECMP寄存器
  // 因此呢,第id個(從0開始計數)的hart對應的 MTIMECMP 的寄存器的物理地址就是
  // 0x2000000L + 8 * id
  // 因此呢就容易理解下面的操作了,實際上就是根據 MTIME 初始化 MTIMECMP
  *(uint64*)CLINT_MTIMECMP(id) = *(uint64*)CLINT_MTIME + interval;

  // prepare information in scratch[] for timervec.
  // scratch[0..2] : space for timervec to save registers.
  // scratch[3] : address of CLINT MTIMECMP register.
  // scratch[4] : desired interval (in cycles) between timer interrupts.
  uint64 *scratch = &timer_scratch[id][0];
  scratch[3] = CLINT_MTIMECMP(id);//記錄當前hart對應的 MTIMECMP 寄存器映射到的物理地址
  scratch[4] = interval;
  w_mscratch((uint64)scratch);//將數組指針寫入mscratch

  // set the machine-mode trap handler.
  w_mtvec((uint64)timervec);

  // enable machine-mode interrupts.
  w_mstatus(r_mstatus() | MSTATUS_MIE);

  // enable machine-mode timer interrupts.
  w_mie(r_mie() | MIE_MTIE);
}

時鐘中斷處理函數

在下面的代碼中,首先是將 mscratcha0 寄存器交換了值,此時 a0 保存的值就是個數組指針(這一點在前面的 timer_init 中已經分析了)。

timervec:
        # start.c has set up the memory that mscratch points to:
        # scratch[0,8,16] : register save area.
        # scratch[24] : address of CLINT's MTIMECMP register.
        # scratch[32] : desired interval between interrupts.
        
        csrrw a0, mscratch, a0
        
        # 保存寄存器的上下文
        sd a1, 0(a0)
        sd a2, 8(a0)
        sd a3, 16(a0)

        # schedule the next timer interrupt
        # by adding interval to mtimecmp.
        # 實際上執行的就是 MTIMECMP = MTIME + INTERVAL
        ld a1, 24(a0) # CLINT_MTIMECMP(hart)
        ld a2, 32(a0) # interval
        ld a3, 0(a1)
        add a3, a3, a2
        sd a3, 0(a1)

        # arrange for a supervisor software interrupt
        # after this handler returns.
        # 通過supervisor software 中斷的方式通知 S-mode 的內核處理時鐘中斷
        # 實際上呢,時鐘中斷已經在M-mode被處理掉了
        # 之所以還要通知S-mode的內核是因爲內核的進程調度器依賴於對時間的掌握
        # S-mode只是根據時鐘變化去做進程調度器相關的處理
        li a1, 2
        csrw sip, a1

        # 恢復上下文
        ld a3, 16(a0)
        ld a2, 8(a0)
        ld a1, 0(a0)
        csrrw a0, mscratch, a0

        mret

Linux 的時鐘中斷的實現

參見 RISC-V timer 在 Linux 中的實現 - 泰曉科技

QEMU 的時鐘中斷的邏輯

參見 https://wangzhou.github.io/riscv-timer%E7%9A%84%E5%9F%BA%E6%9C%AC%E9%80%BB%E8%BE%91/

參考文獻

軟件中斷

所謂軟件中斷就是軟件觸發的中斷,也是所謂的核間中斷(inter-process interrupt,IPI)。在 RISC v 中,核間中斷是通過設置 MIP 的 MSIP 或者 SSIP 實現的。

下面以 Linux 和 opensbi 爲例介紹 S-MODE 的軟件中斷的實現。

中斷髮送

Linux 內核實現

arch/riscv/kernel/smp.c 中實現了 ipi 發送和處理的若干函數。

首先應當明確的是,IPI 是核間中斷,也就是一個核向另一個核發送的中斷,那麼就是軟件運行時出於某種目的向另一個/些核發送了中斷,那麼就需要告知這個/些核,讓這些核做某些事情,這就需要向其它核發送消息。

smp.c 中定義了枚舉值:

enum ipi_message_type {
    IPI_RESCHEDULE,
    IPI_CALL_FUNC,
    IPI_CPU_STOP,
    IPI_IRQ_WORK,
    IPI_TIMER,
    IPI_MAX
};

從這些枚舉值我們可以看出,一個軟件中斷可以傳遞 5 種不同的中斷消息。

這些消息需要保存在變量裏,因此在 smp.c 中也定義了靜態變量 ipi_data

首先看靜態變量 ipi_data,該變量定義如下:

static struct {
    unsigned long stats[IPI_MAX] ____cacheline_aligned;//記錄對應類型的IPI收到了多少個
    unsigned long bits ____cacheline_aligned;//記錄對應的IPI是否被激活
} ipi_data[NR_CPUS] __cacheline_aligned;

從定義中我們可以看出,每個 HART 都有一個獨立的 ipi_data 且是緩存行對齊的。其中 stats 記錄了發送的軟件中斷的所傳遞的消息。在發送 IPI 之前,當前核心需要將信息寫入到 ipi_data 變量中,這樣當其它核心收到 IPI 並處理時,就可以根據 ipi_data 中記錄的值進行相關操作。

這裏我以向單個核發送 IPI 爲例進行介紹:

static void send_ipi_single(int cpu, enum ipi_message_type op)
{
    smp_mb__before_atomic();
    set_bit(op, &ipi_data[cpu].bits);
    smp_mb__after_atomic();

    if (ipi_ops && ipi_ops->ipi_inject)
        ipi_ops->ipi_inject(cpumask_of(cpu));
    else
        pr_warn("SMP: IPI inject method not available\n");
}

我們可以看到兩個參數,第一個參數 cpu 是要發送到哪個核心的編號,op 則是要傳遞的 IPI 類型。

set_bit 就是激活對應的 IPI 類型。

這裏比較關鍵的是調用了 ipi_inject,這是個函數指針,該函數指針指向了 sbi_send_cpumask_ipi 函數。

arch/riscv/kernel/sbi.c 中,我們看到 sbi_send_cpumask_ipi 也是一個函數指針,它的實現實際上與 sbi 的標準有關,比如有 __sbi_send_ipi_v01__sbi_send_ipi_v02 等函數。

無論是哪種規範吧,反正最終是調用到了 sbi,下面我們以 opensbi 爲例繼續介紹軟件中斷的過程。

Opensbi

opensbi/lib/sbi/sbi_ipi.c 中實現了 ipi send 的相關函數。

從調用函數棧中,可以看出,最終調用到了 mswi_ipi_send 函數:

static void mswi_ipi_send(u32 target_hart)
{
    u32 *msip;
    struct aclint_mswi_data *mswi;

    if (SBI_HARTMASK_MAX_BITS <= target_hart)
        return;
    mswi = mswi_hartid2data[target_hart];
    if (!mswi)
        return;

    /* Set ACLINT IPI */
    msip = (void *)mswi->addr;
    writel(1, &msip[target_hart - mswi->first_hartid]);
}

通過將 CSR_MIP.SSIP 置爲就實現了 S-MODE 軟件中斷,因爲根據 RISC v 的中斷委託機制,中斷會最終拉高 CSR_SIP.SSIP,並在 S-MODE 對軟件中斷進行處理。下面我們來看 Linux 是如何對軟件中斷進行處理的。

中斷處理

S-MODE 的軟件中斷處理自然在 Linux 內核中。在 arch/riscv/kernel/smp.chandle_IPI 函數就是軟件中斷處理函數。

void handle_IPI(struct pt_regs *regs)
{
    unsigned long *pending_ipis = &ipi_data[smp_processor_id()].bits;
    unsigned long *stats = ipi_data[smp_processor_id()].stats;

    riscv_clear_ipi();//這裏並不會丟失IPI,因爲IPI發送的數量和激活狀態已經記錄在了ipi_data裏面
    // 下面就是對ipi的具體處理嘍,讀者有興趣可自行查看
    while (true) {
        unsigned long ops;

        /* Order bit clearing and data access. */
        mb();

        ops = xchg(pending_ipis, 0);
        if (ops == 0)
            return;

        if (ops & (1 << IPI_RESCHEDULE)) {
            stats[IPI_RESCHEDULE]++;
            scheduler_ipi();
        }

        if (ops & (1 << IPI_CALL_FUNC)) {
            stats[IPI_CALL_FUNC]++;
            generic_smp_call_function_interrupt();
        }

        if (ops & (1 << IPI_CPU_STOP)) {
            stats[IPI_CPU_STOP]++;
            ipi_stop();
        }

        if (ops & (1 << IPI_IRQ_WORK)) {
            stats[IPI_IRQ_WORK]++;
            irq_work_run();
        }

#ifdef CONFIG_GENERIC_CLOCKEVENTS_BROADCAST
        if (ops & (1 << IPI_TIMER)) {
            stats[IPI_TIMER]++;
            tick_receive_broadcast();
        }
#endif
        BUG_ON((ops >> IPI_MAX) != 0);

        /* Order data access and bit testing. */
        mb();
    }
}

User-Level interrupt

上一節敘述的是在 M-S-U 的 CPU 中的標準中斷,這一節描述用戶態中斷。

用戶態中斷是 N Standard Extension,相關實現可以參考 https://github.com/TRCYX/riscv-user-level-interrupthttps://gallium70.github.io/rv-n-ext-impl/ch1_1_priv_and_trap.html

事實上用戶態中斷比較罕見,但是 x86 已經完全支持用戶態中斷了。

與用戶態中斷有關的寄存器有:ustatus, uip, uie, sedeleg, sideleg, uscratch, uepc, utevc, utval。其中 sedelegsideleg 就是爲實現用戶態中斷而添加的,如果 S-mode 不委託異常、中斷到 U-mode,那麼用戶態中斷是無法實現的。sedeleg/sidelegmedeleg/mideleg 是完全一致的,不贅述。

uscratch/uepc/utevc/utval 與相應的 M-mode 的寄存器也是一致的,不再贅述。這裏僅重點介紹 ustatus, uip, uie

ustatus

ustatus 是很簡單的,就兩個值得注意的字段 UPIE 和 UIE。如果 UIE 爲 0 就禁用用戶態中斷,否則啓用用戶態中斷。在處理用戶態中斷時,使用 UPIE 記錄 UIE,之後會將 UIE 置零。值得注意的是,ustatus 裏面沒有 UPP,因爲沒有比 U-mode 更低的特權級了,陷入到 U-mode 的一定是 U-mode 的特權級,因此也就沒有必要記錄發生中斷前的特權級了。

uip 與 uie

本規範定義了三種中斷類型:軟件中斷、定時器中斷和外部中斷。可以通過向 uip 寄存器的用戶軟件中斷掛起(USIP)位寫入 1,來觸發當前處理器上的用戶級軟件中斷。可以通過向 uip 寄存器的 USIP 位寫入 0,來清除掛起的用戶級軟件中斷。當 uie 寄存器中的 USIE 位清零時,用戶級軟件中斷將被禁用。

ABI 應該提供一種機制,以發送處理器間中斷到其他處理器,從而最終導致接收處理器的 uip 寄存器中的 USIP 位被設置。

除了 uip 寄存器中的 USIP 位之外,其餘所有位都是隻讀的。

如果 uip 寄存器中的 UTIP 位被設置,則表示用戶級定時器中斷掛起。當 uie 寄存器中的 UTIE 位清零時,將禁用用戶級定時器中斷。ABI 應該提供一種機制來清除掛起的定時器中斷。

如果 uip 寄存器中的 UEIP 位被設置,則表示用戶級外部中斷掛起。當 uie 寄存器中的 UEIE 位清零時,將禁用用戶級外部中斷。ABI 應該提供一些方法來屏蔽、解除屏蔽和查詢外部中斷的原因。

uip 和 uie 寄存器是 mip 和 mie 寄存器的子集。讀取 uip/uie 的任何字段或寫入其任何可寫字段,都會導致 mip/mie 中同名字段的讀寫。如果實現了 S 模式,則 uip 和 uie 寄存器也是 sip 和 sie 寄存器的子集。

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章