不少人在調試RISC-V core時,面對異常的出現不知所措,不知道如何定位代碼問題。這里將從RISC-V異常機制以及幾個異常實例學習下。
1 異常機制
1.1 什么是異常
異常是軟件程序員不得不要深入了解的,首先在學習異常機制前,對異常要有一個明確的理解。
狹義上來說異常和中斷的最大區別在于:中斷往往是外部原因導致,而異常則是因為處理器內部原因或者程序執行引起,譬如硬件故障、程序故障,或者執行系統調用引起,簡而言之異常來源于內因。
實際上,廣義上來說,中斷也是異常的一種,站在處理器的角度來看,無論是異常還是中斷發生時,處理器都會暫停當前執行的程序,轉而去處理中斷或者異常的程序,處理完成之后視情況恢復之前被暫停的程序。
處理器廣義上的異常,通常只分為同步異常(Synchronous Exception)和異步異常(Asynchronous Exception)。
- 同步異常
同步異常時由于執行程序指令流或者試圖執行程序指令流造成的異常。這種異常能夠通過異常指令(PC)直接定位到,另外這種異常時穩定復現的。(比如指令非對齊、非法指令或者訪問地址屬性出錯等) - 異步異常
異步異常是由“外因”引起,比如“外部中斷”,或者執行程序時堆棧溢出,導致的異常。另外對于異步異常可以分為兩種:- 精確異步異常( Precise Asynchronous Exception),指令在響應異常后,處理器狀態能夠精確反應為某一條指令的邊界,比如中斷。
- 非精確異步異常( Imprecise Asynchronous Exception ),指令在響應異常后,處理器狀態無法精確反應為某一條指令的邊界,比如讀寫存儲器異常。
當然,一般現在商用的CPU很難說用戶發現了硬件異常,因為發布之前已經都做了充分的驗證了,一般用戶拿到,常見的都是因為軟件原因造成的。
通常情況下,對于軟件工程師來說,理論上可以把中斷當作異常的來看,但實際上各廠家的設計中斷一般還是異常分開處理的,所以使用上還是基于狹義上的異常概念來區分異常和中斷,前面有講過RISC-V的中斷機制和應用:
RISC-V CLINT、PLIC及芯來ECLIC中斷機制分析 —— RISC-V中斷機制(一)
ECLIC中斷流程及實際應用 —— RISC-V中斷機制(二)
1.2 相關CSR
RISC-V有四種特權模式:M/H/S/U,之前有介紹過 RISC-V特權模式及切換_risc-v 模式,感興趣的可以移步學習,RISC-V提供了M模式和S模式下分別的異常處理相關寄存器。
-
**??M模式 (Machine Mode)**?? 是最高特權級別,所有 RISC-V 處理器都必須實現。它提供了一整套以 ‘m’ 開頭的異常寄存器(如
mtvec, mepc, mcause, mstatus, mie, mip, mtval, mscratch
),用于處理最底層的硬件異常和中斷。此模式是系統啟動和初始化的默認環境,簡單嵌入式系統可能僅運行于此模式。 -
**??S模式 (Supervisor Mode)**?? 旨在支持運行類 Unix 等現代操作系統。它配備了一套以 ‘s’ 開頭的異常寄存器(如
stvec, sepc, scause, sstatus, sie, sip, stval, sscratch
),其功能與 M 模式下的相應寄存器類似,但用于操作系統內核的異常處理。
默認情況下,所有異常首先由 M 模式處理。但通過 ??異常委托機制??(使用 medeleg 和 mideleg 寄存器),可以選擇性地將部分中斷和同步異常委托給 S 模式處理,從而減少特權模式切換的開銷,提升操作系統的處理效率,這里也以M模式下異常處理機制為例介紹。
1.2.1 mcause(Machine Exception Cause)
RISC-V標準的mcause格式如下:其中最高bit Interrupt用來指示當前是中斷還是異常,位bit用來記錄異常code。
異常code
另外,如果一條指令引發多個同步異常,下面圖示指明了mcause異常code的優先順序。
另外之前在中斷時有介紹過RISC-V官方還未將CLIC中斷納入標準,但是有些業界RISC-V設計公司是基于CLIC做了設計,比如芯來科技的ECLIC,支持中斷嵌套和中斷咬尾,對mcause進行了修改,如下:
EXCCODE字段在異常時為異常編碼,中斷時為中斷號,其他位域不再詳細說明,可以參見中斷相關博客。
1.2.2 mtvec(Machine Trap-Vector Base-Address Register)
該寄存器用于保存異常向量地址,由向量基址和向量模式組成
向量模式字段,其中非向量中模式,全部異常指向同一地址,而非向量模式,異步中斷地址指向BASE+4xcause(中斷號)
另外,要求異常的BASE地址必須4字節對齊,前面也有介紹過芯來科技中斷架構,也是對mtvec做了修改。
具體時如何實現向量和非向量中斷的,同樣參考中斷相關博客。
1.2.3 mepc (Machine Exception Program Counter)
機器模式異常程序計數器,它指向發生異常的指令。對于同步異常,mepc 指向導致異常的指令;對于中斷,它指向中斷處理后應該恢復執行的位置。
另外,值得注意的是,雖然 mepc 寄存器會在異常發生時自動被硬件更新,但是 mepc 寄存器本身也是一個(在 Machine Mode 下)可讀可寫的寄存器,因此軟件也可以直接寫該寄存器以修改它的值。
1.2.4 mie(Machine Interrupt Enable)和mip(Machine Interrupt Pending)
MEIE/MEIP、MTIE/MTIP、MSIE/MSIE,分別對應M模式下的外部中斷、timer中斷、軟中斷的enable和pending。
如果使用芯來的ECLIC,是不需要使用mie和mip的,具體參考中斷相關博客
1.2.5 mstatus(Machine Status)
機器模式(M-mode)下的一個核心??控制與狀態寄存器??(CSR)。它主要負責全局中斷管理、特權模式切換及處理器狀態監控。
SD:status dirty 狀態臟位,
MIE/SIE:機器/監督者模式全局中斷使能
MPIE/SPIE:發生異常前MIE/SIE的使能狀態,會被保存到這里。(P:previous)
MPP/SPP:發生異常時,硬件將異常前的特權模式保持到這里。執行mret或者sret,處理器將恢復為MPP/SPP所指定的模式。
FS/XS/VS:浮點單元狀態/擴展單元狀態/向量單元狀態(RVV擴展)
UBE:字節序控制。0表示小端,1表示大端。通常固定為小端。
SUM:允許S模式下訪問U模式的頁面(用于操作系統讀寫用戶程序數據)
MXR:使能可執行讀取,置1表示所有可讀頁表變為可執行
TSR/TW/TVM/MPRV筆者還沒有使用過,不在列舉,可以自行搜索了解。
1.2.6 mtval(Machine Trap Value Register)
在異常發生時,由硬件自動更新的??控制狀態寄存器??(CSR)。它的主要作用是??提供與異常相關的附加信息??,幫助軟件診斷和處理異常。
mtval 提供的是??輔助信息??,確定異常的根本原因主要還需結合 ??mcause??(異常原因寄存器)和 ??mepc??(異常程序計數器)的值,另外mtval的具體行為??取決于硬件實現??,并非所有異常或所有芯片都一定會填充有效值
1.2.7 mscratch(Machine Scratch)
mscratch 的具體使用方式??很大程度上取決于軟件的實現??,比如:可以在異常時暫存某些通用寄存器的值,防止關鍵數據被破壞;還可以在調試時利用mscratch里存儲臨時調試信息或者斷點信息等
1.3 異常處理流程
前面說中斷也是異常的一種,所以異常的處理流程和中斷一樣。在bootloader階段,軟件初始化了mtvec寄存器,把異常handler的地址初始化到mtvec。然后軟件配合硬件來完成異常處理,具體流程如下:
當異常發生時,處理過程分為??硬件自動執行??和??軟件處理??兩大部分:
- 第一階段:硬件自動響應(處理器單元)
一旦檢測到異常,硬件會??自動且立即??執行以下操作
- 關鍵信息保存??:
- 將當前 PC 值存入 ??mepc??,作為返回地址。
- 將異常原因寫入 ??mcause??。
- 將異常相關的附加信息(如出錯的地址或指令)寫入 ??mtval??。
- ?狀態切換??:
- 將當前權限模式保存到 ??mstatus.MPP??,然后切換到 ??M 模式??(Machine Mode)。
- 將當前全局中斷使能位 ??mstatus.MIE?? 保存到 ??mstatus.MPIE??,然后??清除 MIE(關閉全局中斷)??,防止處理過程被新的中斷打斷。
- ??跳轉執行??:
- 處理器從 ??mtvec?? 寄存器指向的地址開始取指執行,即跳轉到預先設置好的異常處理程序。
- 第二階段:軟件處理(操作系統/固件)
這是操作系統或固件編寫者需要實現的代碼邏輯,主要步驟包括:
- ??保存執行上下文??:
- 硬件??不會自動保存??通用寄存器(x0-x31)。??軟件必須??首先將所有的通用寄存器壓入棧(通常是內核棧)中,以防止破壞被中斷程序的現場。
- 診斷異常原因??:
- 軟件讀取 ??mcause?? 寄存器,根據其中的異常編碼判斷具體的異常或中斷類型。
- ?執行處理程序??:
- 根據異常類型,跳轉到相應的處理例程(如系統調用處理、中斷服務程序等)。
- 恢復現場并返回??:
- 處理完成后,從棧中??恢復所有通用寄存器??的原始值。
- 執行 ??mret?? 指令。
最后,mret指令會觸發硬件:
- 將 ??mepc?? 中的值載入 PC,從而返回到原來的執行流。
- 根據 ??mstatus?? 中保存的信息(MPIE, MPP)恢復之前的權限模式和中斷使能狀態。
這里有幾點需要注意:
- 1、RISC-V標準中,中斷和異常硬件處理是一樣的,處理函數入口都是在mtvec,軟件根據mcause Interrupt字段的值來區分異常中斷
- 2、但這里效率不高,社區開源的CLIC對這塊進行了優化,把中斷和異常分開處理,mtvec自作為異常的處理入口,中斷單獨定義一組寄存器,可以參考我之前的博客:
- 3、一般出現異常時,我們就會在異常服務函數里dump出來一些關鍵信息,然后把core給停掉(已經異常了,要去debug異常問題去了,在跑下去也沒什么意義的),所以就不會有后面的流程(恢復寄存器狀態、推出異常處理流程等),比如跑linux時經常會看見oops一堆的打印,就是內核在異常時系統拋出的信息,以方便定位問題。
2 異常定位
RISC-V異常機制是很直接的,前面有提到會在異常處理函數時打印出來關鍵信息方便分析定位問題,下面就針對常見的幾種異常舉例說明。
我這里使用的芯來科技的QEMU來實現的異常,平臺搭建可以參考:
RISC-V匯編學習(四)—— RISCV QEMU平臺搭建(基于芯來平臺)
2.1 異常處理函數
可以看到在進入異常之后,會把異常相關的CSR、通用寄存器和堆棧信息打印出來。
/*** \brief System Default Exception Handler* \details* This function provides a default exception and NMI handler for all exception ids.* By default, It will just print some information for debug, Vendor can customize it according to its requirements.* \param [in] mcause code indicating the reason that caused the trap in machine mode* \param [in] sp stack pointer*/static void system_default_exception_handler(unsigned long mcause, unsigned long sp)
{/* TODO: Uncomment this if you have implement printf function */printf("MCAUSE : 0x%lx\r\n", mcause);printf("MDCAUSE: 0x%lx\r\n", __RV_CSR_READ(CSR_MDCAUSE));printf("MEPC : 0x%lx\r\n", __RV_CSR_READ(CSR_MEPC));printf("MTVAL : 0x%lx\r\n", __RV_CSR_READ(CSR_MTVAL));printf("HARTID : %u\r\n", (unsigned int)__get_hart_id());Exception_DumpFrame(sp, PRV_M);
#if defined(SIMULATION_MODE)extern void simulation_exit(int status);simulation_exit(1);
#else#ifdef CFG_SIMULATIONsimulation_fail();#endifwhile (1);
#endif
}/*** \brief Dump Exception Frame* \details* This function provided feature to dump exception frame stored in stack.* \param [in] sp stackpoint* \param [in] mode privileged mode to decide whether to dump msubm CSR*/
void Exception_DumpFrame(unsigned long sp, uint8_t mode)
{EXC_Frame_Type *exc_frame = (EXC_Frame_Type *)sp;
#ifndef __riscv_32eprintf("ra: 0x%lx, tp: 0x%lx, t0: 0x%lx, t1: 0x%lx, t2: 0x%lx, t3: 0x%lx, t4: 0x%lx, t5: 0x%lx, t6: 0x%lx\n" "a0: 0x%lx, a1: 0x%lx, a2: 0x%lx, a3: 0x%lx, a4: 0x%lx, a5: 0x%lx, a6: 0x%lx, a7: 0x%lx\n" "cause: 0x%lx, epc: 0x%lx\n", exc_frame->ra, exc_frame->tp, exc_frame->t0, exc_frame->t1, exc_frame->t2, exc_frame->t3, exc_frame->t4, exc_frame->t5, exc_frame->t6, exc_frame->a0, exc_frame->a1, exc_frame->a2, exc_frame->a3, exc_frame->a4, exc_frame->a5, exc_frame->a6, exc_frame->a7, exc_frame->cause, exc_frame->epc);
#elseprintf("ra: 0x%lx, tp: 0x%lx, t0: 0x%lx, t1: 0x%lx, t2: 0x%lx\n" "a0: 0x%lx, a1: 0x%lx, a2: 0x%lx, a3: 0x%lx, a4: 0x%lx, a5: 0x%lx\n" "cause: 0x%lx, epc: 0x%lx\n", exc_frame->ra, exc_frame->tp, exc_frame->t0, exc_frame->t1, exc_frame->t2, exc_frame->a0, exc_frame->a1, exc_frame->a2, exc_frame->a3, exc_frame->a4, exc_frame->a5, exc_frame->cause, exc_frame->epc);
#endifif (PRV_M == mode) {/* msubm is exclusive to machine mode */printf("msubm: 0x%lx\n", exc_frame->msubm);}
}
2.2 讀寫訪問異常定位
該異常發生時,異常打印如下,我們來分析定位下:
當然一開始我們并不清楚是什么異常,并且是哪里,什么造成的原因造成的這種異常;接下來就來分析下。
mcause:最高bit是Interrupt域,值為0,表明當前是一個異常,EXCODE=5,一個load access 異常,也就是說程序里讀了一個非法地址(最高byte 0x3是MPP表示中斷前就是在M模式)
mdcause:這個是芯來科技RISC-V core自定義的CSR,用來進一步查看異常的原因(該興趣自行找資料了解下)
mepc:0x8800120e,異常地址,但讀寫異常時非精確的異常,該地址并不能精確定位異常位置(一般異常位置在該地址之前)。
mtval:0xff00b000,異常地址,該地址可以正確反映到異常訪問地址的,說明我們讀了一個0xff00b000的非法地址。
ra是返回地址,當前執行結束之后會跳到該地址,也就是說在ra前出現了訪問異常。
當然到這里已經很清晰了,實際就是我們讀了一個非法地址,這里故意讀了下0xff00b000,
uint32_t addr_load_test(void)
{uint32_t * test_addr = (uint32_t *) 0xff00b000;uint32_t value = REG32( test_addr);
}
匯編:
880011f8 <addr_load_test>:
880011f8: 1101 add sp,sp,-32
880011fa: ce06 sw ra,28(sp)
880011fc: cc22 sw s0,24(sp)
880011fe: 1000 add s0,sp,32
88001200: ff00b7b7 lui a5,0xff00b
88001204: fef42623 sw a5,-20(s0)
88001208: fec42783 lw a5,-20(s0)
8800120c: 439c lw a5,0(a5)
8800120e: fef42423 sw a5,-24(s0)
88001212: 0001 nop
88001214: 853e mv a0,a5
88001216: 40f2 lw ra,28(sp)
88001218: 4462 lw s0,24(sp)
8800121a: 6105 add sp,sp,32
8800121c: 8082 ret
mepc是0x8800120e,實際是上一條 8800120c: 439c lw a5,0(a5)
執行報錯,這里從內存地址 a5 + 0(0xff00b000)處讀取一個 32 位的字(4 字節),并將其寫入寄存器 a5。
這里通用寄存器是可以正確反映異常前的信息的,如果想要從通用寄存器來定位,就需要直到RISC-V的abi規則了,后面會展示下。
當然寫異常也是一樣的。
只需要修改下代碼,向非法地址0xff00b000中寫入數據即可。
uint32_t addr_load_test(void)
{uint32_t * test_addr = (uint32_t *) 0xff00b000;REG32( test_addr) = 0x1;
}
運行代碼將會出現下面為store非法地址異常打印:
讀寫異常當然并非一定是訪問了非法地址,比如訪問的IP模塊沒有時鐘或者復位被拉住,此時訪問IP內部的寄存器或者memory一樣會產生讀寫異常。
2.3 非法指令異常
2.3.1 text段被異常改寫
異常前后的匯編:
88001274: 301027f3 csrr a5,misa
88001278: fcf42e23 sw a5,-36(s0)
8800127c: fdc42783 lw a5,-36(s0)
88001280: fef42023 sw a5,-32(s0)
通過gdb來讀取異常地址處的指令值,如下:
0x88001274處期望的指令0x301027f3
被改寫為了0x00001234
。
接下來可以通過watchpoint
來監控0x88001274
地址的改動,便可以發現有代碼(這里故意修改)修改了text段的代碼(當然也是我們故意造的異常點)
2.3.2 棧幀被異常修改
一般我們會故意修改text段代碼,但有時間,軟件代碼不合理,造成了棧溢出、數據污染等也會造成指令異常。
查看上面的異常打印,通過CSR寄存器mcause知道是指令異常,但如何其他CSR比如MEPC,MTVAL都不是預期的(不在正常的內存分配地址),可能很多人看到這里無從下手,不好定位異常位置,當然原因是,不熟悉RISCV abi規則,對通用寄存器使用不熟悉的。
我之前在
RISC-V匯編學習(五)—— 匯編實戰、GCC內聯匯編(基于芯來平臺)的博客中有深入分析過riscv的abi規則,可以移步學習。
如果調試經驗多的話,容易分析,當前可能是因為堆棧溢出,導致了數據污染。我們可以看到打印里已經有很多寄存器包括ra,tp等寄存器的值已經不真實了,還有哪些是可信的呢?s0-sp表示當前使用的棧幀(RISC-V用s0和sp來填充棧幀)。
此時通過gdb回到異常現場,讀取s0和sp的值:
注意異常入口必須把軟件處理部分干掉,不然此時將會進入異常的棧幀,并可能破壞掉當前異常的棧幀。
同樣可以用watchpoint來定位軟件code,發現有兩處用到了該棧地址。
很容易就可以定位到問題代碼的位置,實際上是我們定義了一個10個無符號整形變量,但初始化15個地址,棧幀溢出,導致地址踩臟。實際代碼如下:
void test(void)
{uint32_t test_data[10] = {0};for(int i = 0; i < 15;i++){test_data[i] = i;}
}
把上面代碼修改正確查看下該函數棧幀內容,如下:
這里是把返回地址和上一個棧頂指針地址覆蓋了,導致了指令異常。
當然異常場景還有不少,這里僅展示幾個常見的;實際無論什么樣的異常都是可以從軟硬件的角度,去分析問題,前提是對ISA相對比較熟悉;另外一般裸機或者簡單rtos下的代碼量比較小時,通過異常機制可以幫忙快速定位問題;如果時linux下多線程任務的異常,當然也可以用,只是定位會相對會比較麻煩很多,如果有trance來dump指令流,將會事半功倍。
參考:
手把手教你設計CPU——RISC-V處理器篇(胡振波)
RISC-V匯編學習(五)—— 匯編實戰、GCC內聯匯編(基于芯來平臺)