iT邦幫忙

2021 iThome 鐵人賽

DAY 19
2

本節是以 Golang 上游 8854368cb076ea9a2b71c8b3c8f675a8e19b751c 為基準做的實驗

予焦啦!上下文(context),代表的是一個行程(process)的狀態,作業系統負責維持這些狀態的一致性,好讓使用者空間程式可以按照它自己被設計的方式執行,而毋需考量中斷、缺頁例外之類的事件。理想上應該做到這個程度,

但是,我們昨天開始導入了中斷機制之後,一切都變了。行程將會隨時被中斷,改變程式流程到 stvec 所在之處。在 early_halt 之中,暫存器被任意使用,但萬一那些暫存器中斷前正在被使用的話,這個中斷處理本身就鑄下無法恢復的大錯。

本節重點概念

  • RISC-V
    • scratch:暫用控制暫存器
    • Linux 如何使用 sscratch
    • OpensbI 如何使用 mscratch

為何需要暫用控制暫存器?

試想一個突然被中斷的程式,CPU 懷抱著當下的狀態,被迫將程式指標指向了 stvec。以我們一直仰賴它回報錯誤狀態的 early_halt 為例:

TEXT early_halt(SB),NOSPLIT|NOFRAME,$0
        MOV     $0x49, A0
        MOV     $1, A7
        MOV     $0, A6
        ECALL
        CSRRS   CSR_SCAUSE, ZERO, A0
        CALL    dump(SB)
        ...

是的,我們至今還殘留著最一開始隨意印出的那個 I 字元。不過這無所謂。我們很明顯看到的是,A0A6A7 三個暫存器,就這樣直接被覆寫掉了。也就是說,除非有將原本的暫存器的值儲存下來,否則原本被中斷的程式已經失去了部份資訊,就算能夠回到被中斷時的程式指標繼續下去,也很可能已無法順利執行原先的邏輯了

所以這就是為什麼我們需要一個額外的空間騰出來儲存資訊,這個東西就是作業系統模式底下的 sscratch 與機器模式底下的 mscratch

有趣的是,我們目前為止看到的所有控制與狀態暫存器的使用,都需要仰賴 CSR* 系列指令,而其中都必然會牽涉到一般的暫存器。若是為了保護一般暫存器作為行程的上下文而使用 scratch,但使用之又必然需要破壞一般的暫存器,這又該怎麼辦?話又說回來,這一個暫存器也只有 64 個位元大小,要怎麼儲存一堆暫存器的內容呢?

以下,我們將觀察兩個風格略有不同的系統軟體如何使用這個控制暫存器。

Linux

在 Linux 的例外與中斷入口,是這樣的程式碼:

ENTRY(handle_exception)
        /*
         * If coming from userspace, preserve the user thread pointer and load
         * the kernel thread pointer.  If we came from the kernel, the scratch
         * register will contain 0, and we should continue on the current TP.
         */
        csrrw tp, CSR_SCRATCH, tp
        bnez tp, _save_context

_restore_kernel_tpsp:
        csrr tp, CSR_SCRATCH
        REG_S sp, TASK_TI_KERNEL_SP(tp)
_save_context:
        REG_S sp, TASK_TI_USER_SP(tp)
        REG_L sp, TASK_TI_KERNEL_SP(tp)
        addi sp, sp, -(PT_SIZE_ON_STACK)

首先,tp 暫存器與 sscratch 控制暫存器的內容會互換,從而沒有任何資訊被破壞;這是因為 CSRRW 能夠確保原子性的讀寫,所以讓 tp 同為來源與目標,即可完成兩者的互換。這也解答了先前的第一個問題。

之後的流程,基本上註解就解釋了一切。如果是從使用者模式進來的,使用者空間用到一半的 tp 被存放到 sscratch 之中,而原先在 sscratch 當中的值,恰好是 Linux 核心用來表達該使用者行程的結構體(型別為 struct task_struct)的指標。透過這個指標,就能夠存取到核心管理的資訊,比方說這個行程在核心之中有資格使用的堆疊位址(KERNEL_SP),就可以提取出來放置到堆疊指標暫存器 sp 之中,從而繼續執行下去呼叫 C 語言函數也不會有問題。

因為更後面的程式碼保證 sscratch 會被清成 0,所以如果是從作業系統模式進入,也就是說在核心內部觸發了例外或是中斷的情況,就能夠透過 tp 是否為 0 來判斷來自何處。而之後無論如何,核心本身都可以使用 tp 暫存器存取所需的資訊,其中最重要的可以說是堆疊位址。

因為,頗為大量的暫存器內容,放在堆疊之中是最理想的。如果設計了某個特別用來放置的空間,在巢狀中斷(nested interrupt)發生的情況,就又得特別處理了。

OpenSBI

OpenSBI 是不同的風格。在機器模式初始化時,OpenSBI 就會配置一塊空間,且確保每一個處理器核都有自己專屬的 mscratch 指標。

進入點 mtvec 之後馬上用到的巨集名為 TRAP_SAVE_AND_SETUP_SP_T0,所以如果說 Linux 的策略是針對 tp,那麼 OpenSBI 的策略就是一次搞定 sp 堆疊指標和 t0

以下分為外殼與內部的兩個小節,分別拆解之。

處理 t0sp 並暫時挪用 tp 的外殼

	/* Swap TP and MSCRATCH */
	csrrw	tp, CSR_MSCRATCH, tp

	/* Save T0 in scratch space */
	REG_S	t0, SBI_SCRATCH_TMP0_OFFSET(tp)
	
    ...
    /*內部的炫技部分*/
    ...
	/* Save original SP on exception stack */
	REG_S	sp, (SBI_TRAP_REGS_OFFSET(sp) - SBI_TRAP_REGS_SIZE)(t0)

	/* Set SP to exception stack and make room for trap registers */
	add	sp, t0, -(SBI_TRAP_REGS_SIZE)

	/* Restore T0 from scratch space */
	REG_L	t0, SBI_SCRATCH_TMP0_OFFSET(tp)

	/* Save T0 on stack */
	REG_S	t0, SBI_TRAP_REGS_OFFSET(t0)(sp)

	/* Swap TP and MSCRATCH */
	csrrw	tp, CSR_MSCRATCH, tp
.endm

一開始還是直接交換了 tpmscratch。這個 tp ,對於每個核來說,都指向自己專屬的區域。裡面預留了一個空間可以暫時存放 t0,偏移量在 SBI_SCRATCH_TMP0_OFFSET,前半用以存放,後半晚期將之提取出。因此,t0 在這之間的內部區域,就能夠用來當作計算時候的暫時空間使用。

在內部結束之後,其實 t0 是成為一個當前可運作的堆疊指標。從下半部第二行開始解釋比較簡單。下半部第二行,是將之後將要使用的堆疊指標計算出來。減去的 SBI_TRAP_REGS_SIZE 是所需存放的上下文的總量,t0 則是當前的堆疊指標。回頭看下半部第一行,很複雜的偏移量計算是為了先不調整 sp 本身(因為第二行才要調整),但又要將 sp 存到對的地方去。

下半部第三行提取先前存放的 t0(相對於 OpenSBI 的 scratch 結構體,這裡省略細節),並相對於新的堆疊,存放到屬於 t0 的位置去。最後,再將 tp 切換回原本的內容。

內部的位元操作:判定可用堆疊

Linux 比較單純,是因為從行程結構體當中,可以在不同的偏移量找到使用者模式或是作業系統模式的堆疊指標,但 OpenSBI 的 scratch 結構體沒有這麼方便,並且也有諸多不同的低權限等級要處理。所以這裡,單單是藉助一個可以用的 t0

	/*
	 * Set T0 to appropriate exception stack
	 *
	 * Came_From_M_Mode = ((MSTATUS.MPP < PRV_M) ? 1 : 0) - 1;
	 * Exception_Stack = TP ^ (Came_From_M_Mode & (SP ^ TP))
	 *
	 * Came_From_M_Mode = 0    ==>    Exception_Stack = TP
	 * Came_From_M_Mode = -1   ==>    Exception_Stack = SP
	 */
	csrr	t0, CSR_MSTATUS
	srl	t0, t0, MSTATUS_MPP_SHIFT
	and	t0, t0, PRV_M
	slti	t0, t0, PRV_M
	add	t0, t0, -1
	xor	sp, sp, tp
	and	t0, t0, sp
	xor	sp, sp, tp
	xor	t0, tp, t0

註解一樣提供比較多的指引。簡單來說這一段結束之後,即要符合前一小節中的下半部的預期,也就是 t0 必須是 CPU 來到機器模式處理例外或中斷時,接著使用的堆疊指標。如何做到?

首先,先做一個判斷。因應機器模式所需面對的諸多低權限等級,以及可能來自機器模式本身,先設法創造一個區別出來。slti 指令之前就是在做這個區分。但是由於該指令只能產生 0 或是 1 的數值,對於接下來的互斥或(eXclusive OR)技巧幫助不大,所以再做了一個減 1 的算術轉換,使之成為 -1(原本來自機器模式)與 0(來自其他權限等級)。

且看註解中的 Exception_stack 虛擬碼變數。若是前述判斷是 -1,那麼它之後的邏輯且(& 符號,and)相當於是每一個位元都繼續留存,而整個敘述就會變成 tp 參與了兩次互斥或而抵消效應,成為 sp 留存的運算結果;而若是判斷為 0,後面的部分就全部被清掉,使得最後的結果為與 0 進行互斥或的 tp

之所以從機器模式進入就應使用 sp 是因為,sp 本就是機器模式運行期間使用的堆疊指標,繼續使用不會有權限的問題;其餘模式進入者,與其說是使用 tp,不如說是使用來自 mscratch 的內容。但無論是何者,由於還沒有正式決定要存放上下文的堆疊,所以真正要使用的堆疊指標還是存放在 t0 當中。

之所以說這一段是在炫技,是因為 sptp 都參與其中,但卻都沒有被損壞;真正被拿來使用的,以結果來說只有 t0 而已。還有為了使用互斥或技法而使用的減一,都很精彩。註解的部分也提供一個範本,當你必須使用一連串可讀性不高的方法來寫程式的時候,應該如何透過註解教育後進。

小結

予焦啦!今日分別介紹了作業系統模式與機器模式兩大系統軟體的進入點處理時,如何透過 scratch 控制暫存器完成上下文存放前的安排。說是安排,實際上是決定了接下來任何的例外處理或是中斷處理所需要的堆疊之所在,因為那是最適合存放上下文的地方。

懷抱着這份理解,我們一如往常的必須回到 Hoddarla 本位來思考,那就是 Golang 執行期裡面,是否有什麼框架可以套用呢?今天我們沒有寫什麼新的程式碼,明天因為要延續這個思緒繼續探索的緣故,應該也不會有吧。

各位讀者,我們明天再會!


上一篇
予焦啦!RISC-V 的計時器中斷機制
下一篇
予焦啦!Golang 當中的訊號(signal)機制
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言