不少人在调试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内联汇编(基于芯来平台)