前言
最近使用国产MCU HPMICRO中读到中断章节,发现向量模式和非向量模式在允许中断嵌套的情况下,对全局中断的开启时机,以及fence io的操作有许多不同;这里的差异在读完um后依旧非常困惑,因此记录本人理解各个要点的过程,供大家参考;再次补充说明,本文仅仅针对trap中的外部中断进行讨论
中断流程
先贴上一张Andes 手册中的外部中断流程图
向量模式中断
在向量模式中,每个中断源都有一个专门的入口地址。这个入口地址被称为“中断向量”。当中断发生时,硬件会自动跳转到这个中断向量,执行对应的中断服务程序(ISR)。
向量模式的工作流程
- 中断发生:硬件检测到中断事件,并通过中断向量直接跳转到特定的ISR。
- 保存上下文:ISR开始执行前,保存当前的寄存器状态和程序计数器(PC),以便在中断处理完成后恢复。
- 处理中断:执行具体的中断处理逻辑。
- 恢复上下文:中断处理完成后,恢复保存的寄存器状态和PC。
- 返回主程序:执行
mret
指令返回到被中断的程序继续执行。
向量模式的应用场景
向量模式适用于实时性要求高、需要多重中断嵌套的系统,如工业控制、自动驾驶等场景。
非向量模式中断
在非向量模式中,所有中断共用一个入口地址。当中断发生时,硬件跳转到这个通用的入口,然后通过软件判断中断源,再调用相应的ISR。
非向量模式的工作流程
- 中断发生:硬件跳转到通用的中断入口。
- 判断中断源:通过软件读取中断原因寄存器,判断是哪一个中断源触发了中断。
- 跳转到ISR:根据中断源,跳转到对应的ISR。
- 处理中断:执行具体的中断处理逻辑。
- 返回主程序:处理完中断后,返回到被中断的程序继续执行。
非向量模式的优势
- 实现简单:不需要复杂的硬件支持,可以通过软件灵活实现中断处理。
- 更低成本:适合低成本、低复杂度的系统。
困惑一:向量模式为什么需要fence io
理论解答:
在向量模式中使用 fence io, io
的原因主要与确保中断处理操作的有序性和系统的正确性有关。具体来说,这涉及到以下几点:
1. 确保中断完成信号的正确性
在 RISC-V 架构中,中断的完成通常是通过写入一个特定的寄存器或内存位置来通知中断控制器(如 PLIC)。如果在中断处理过程中存在内存访问操作(例如读写外设寄存器),这些操作可能会被重新排序,从而导致中断完成信号的发出时间发生偏移。
2. 防止乱序执行
现代处理器可能会对指令进行乱序执行(Out-of-Order Execution),即处理器为了优化性能,可能会改变指令的执行顺序。在中断处理过程中,如果不使用 fence io
,可能会出现以下问题:
- 在完成中断处理的过程中,处理器可能会提前执行一些后续的 I/O 操作,而这些操作可能依赖于中断完成信号的发出。
- 如果中断完成信号未按预期顺序发出,可能会导致系统认为中断处理尚未完成,从而产生不一致或错误的行为。
3. 确保I/O操作的顺序性
fence io, io
指令在 RISC-V 中起到内存屏障的作用,确保在 fence io
之前的所有 I/O 操作都已完成且对系统可见,而 fence io
之后的操作不会被提前执行。这保证了中断完成信号确实是在所有相关的中断处理操作完成后才发出。
4. 提高系统的稳定性
通过使用 fence io, io
,可以避免由于指令乱序或缓存未刷新的问题导致的中断处理不完整或系统不一致。这在一些关键应用场景中尤为重要,如实时系统或安全关键系统。
举个具体的例子
背景
假设我们有一个嵌入式系统,其中CPU处理来自外设A的中断。外设A通过中断通知CPU,要求它读取一个数据寄存器,并将结果写入到另一个外设B的寄存器中。中断服务程序在完成这些操作后,需要向中断控制器(如PLIC)发送中断完成信号,表明中断处理已经完成。
不使用 fence io
的情况
- 中断发生:外设A发出中断信号,CPU进入中断处理程序。
- 处理中断:
- CPU从外设A读取数据(读操作)。
- CPU将读取的数据写入外设B(写操作)。
- 发送中断完成信号:CPU向PLIC写入一个特定值,表示中断处理完成。
然而,由于处理器的优化机制(如乱序执行),CPU可能会改变这些操作的执行顺序。例如,CPU可能会先执行向PLIC写入中断完成信号的操作,然后才执行外设B的写操作。
潜在问题
如果中断完成信号先于对外设B的写操作被发出,系统可能会认为中断处理已经完全结束,并且可能开始处理下一次中断或继续其他操作。但实际上,此时对外设B的写操作尚未完成。这种情况可能导致:
- 数据不一致:外设B可能会在没有接收到完整数据的情况下继续运行,导致数据处理错误。
- 系统不稳定:下一次中断到来时,外设B的状态可能是不正确的,导致整个系统的行为不确定。
使用 fence io
的情况
如果在发送中断完成信号之前加入 fence io, io
指令,CPU会确保在 fence io
之前的所有I/O操作(即对外设A的读取和对外设B的写入)都已完成,且对系统是可见的。只有在这些操作全部完成后,CPU才会发送中断完成信号。
困惑2:非向量模式为什么不需要fence io
向量模式需要fence io是可以理解的,但这也造成了第二个困惑,即非向量模式为什么不需要fence io,这里还是用之前的例子来说明
非向量模式下的处理方式
假设我们还是处理来自外设A的中断,要求读取外设A的数据并写入外设B,并最终发送中断完成信号。
-
中断发生:外设A发出中断信号,CPU跳转到通用的中断入口。
-
识别中断源:CPU通过查询中断原因寄存器,确定是来自外设A的中断。
-
处理中断:
- CPU从外设A读取数据(读操作)。
- CPU将读取的数据写入外设B(写操作)。
-
发送中断完成信号:
- 在ISR的最后一步,在完成所有I/O操作后,CPU向PLIC写入一个特定值,表示中断处理完成。
为什么非向量模式避免了乱序问题
在非向量模式下,以下因素帮助避免了乱序执行问题:
-
ISR的顺序执行:在非向量模式中,所有中断处理都通过通用的中断入口来处理。这意味着整个中断处理程序通常是顺序执行的,从识别中断源到处理中断再到发送完成信号,都是按顺序进行的。在这一模式下,软件通过顺序编写代码来保证中断处理的有序性,而不依赖硬件的向量机制。
-
中断完成信号在ISR最后发送:在非向量模式下,中断完成信号一般放在ISR的最后一步执行。因为在非向量模式中没有显式的硬件向量跳转,因此ISR通常是一个完整的函数,而不是分散在多个位置的代码片段。这种结构性确保了中断完成信号是在所有相关操作执行完毕之后才发送的。
-
没有复杂的乱序执行优化需求:由于非向量模式中断处理通常较为简单,而且系统对中断响应时间的要求可能不如向量模式那么苛刻,因此处理器可能不会对这些I/O操作进行复杂的乱序优化。这减少了乱序执行带来的问题。
在非向量模式下,避免乱序问题的主要原因是中断处理的顺序执行和中断完成信号的发送时机被设计在ISR的最后。因为整个ISR在执行时是一个完整的连续过程,不需要像向量模式那样通过硬件跳转和可能的嵌套中断处理,因此系统对指令乱序的敏感性较低,且软件逻辑天然地保证了处理顺序。这使得在非向量模式下通常不需要使用 fence io, io
指令来显式地确保中断处理的顺序。
进一步的代码探究
接下来,以hpm sdk1.6的代码为例,来详细看一下软件上到底是怎么做的
首先,以GCC编译为例,我们查看gcc的startup.s中的代码
#if !defined(USE_NONVECTOR_MODE) || (USE_NONVECTOR_MODE == 0)
/* Initial machine trap-vector Base */
la t0, __vector_table
csrw mtvec, t0
/* Enable vectored external PLIC interrupt */
csrsi CSR_MMISC_CTL, 2
#else
/* Initial machine trap-vector Base */
la t0, HANDLER_TRAP
csrw mtvec, t0
/* Disable vectored external PLIC interrupt */
csrci CSR_MMISC_CTL, 2
#endif
首先可以看到,编译默认使用向量模式,如果在编译时指定使用非向量模式,才会使用非向量模式
这里我们首先看一下非向量模式下的代码是如何组织的
代码比较
可以看到,非向量模式下,把HANDLER_TRAP这个函数指针放到mtvec寄存器,RISC-V架构的内核在进入trap后,会执行mtvec指向的代码,我们再进一步看一下这个HANDLER_TRAP,他其实也是一个宏,最后根据你使用裸机/freertos等RTOS与否,指向不同的实际函数,这里我们看一下裸机情况下的函数
非向量模式的宏函数仅仅是在中断向量表对应id处绑定了中断处理函数
#define SDK_DECLARE_EXT_ISR_M(irq_num, isr) \
void isr(void) __attribute__((section(".isr_vector")));\
EXTERN_C void ISR_NAME_M(irq_num)(void) __attribute__((section(".isr_vector")));\
void ISR_NAME_M(irq_num)(void) { \
isr(); \
}
非向量模式trap同一入口,删除了环境保存和恢复的部分
void irq_handler_trap(void)
{
#ifdef USE_NONVECTOR_MODE
else if ((mcause & CSR_MCAUSE_INTERRUPT_MASK) && ((mcause & CSR_MCAUSE_EXCEPTION_CODE_MASK) == IRQ_M_EXT)) {
typedef void(*isr_func_t)(void);
/* Machine-level interrupt from PLIC */
uint32_t irq_index = __plic_claim_irq(HPM_PLIC_BASE, HPM_PLIC_TARGET_M_MODE);
if (irq_index) {
/* Workaround: irq number returned by __plic_claim_irq might be 0, which is caused by plic. So skip invalid irq_index as a workaround */
#if !defined(DISABLE_IRQ_PREEMPTIVE) || (DISABLE_IRQ_PREEMPTIVE == 0)
enable_global_irq(CSR_MSTATUS_MIE_MASK);
#endif
((isr_func_t)__vector_table[irq_index])();
__plic_complete_irq(HPM_PLIC_BASE, HPM_PLIC_TARGET_M_MODE, irq_index);
}
}
#endif
}
向量模式的宏函数不仅绑定了中断id号和函数,还将保存上下文等操作放在了自己的isr中
#define SDK_DECLARE_EXT_ISR_M(irq_num, isr) \
void isr(void) __attribute__((section(".isr_vector")));\
EXTERN_C void ISR_NAME_M(irq_num)(void) __attribute__((section(".isr_vector")));\
void ISR_NAME_M(irq_num)(void) \
{ \
SAVE_CALLER_CONTEXT(); \
ENTER_NESTED_IRQ_HANDLING_M();\
__asm volatile("la t1, %0\n\t" : : "i" (isr) : );\
__asm volatile("jalr t1\n");\
COMPLETE_IRQ_HANDLING_M(irq_num);\
EXIT_NESTED_IRQ_HANDLING_M();\
RESTORE_CALLER_CONTEXT();\
__asm volatile("fence io, io");\
__asm volatile("mret\n");\
}
总结
看了上面的代码,终于找到了根本原因,向量模式下,中断函数的跳转是通过jalr直接跳转的,由于 jalr
是一种跳转并链接的指令,结合 RISC-V 的松散内存模型,这种方式可能导致硬件在指令顺序的执行上不够严格,特别是在访问外设的寄存器时,容易出现乱序执行问题。
在非向量模式下,通常通过编译器生成的标准函数调用来执行 ISR。即: ((isr_func_t)__vector_table[irq_index])();
这种调用方式受编译器和调用惯例的控制,编译器会在调用前后插入相应的序言和尾声代码,这些代码负责保存和恢复寄存器状态、管理栈帧等。
这种标准的调用方式本质上更“重”,并且由编译器生成的代码序列通常是有序的。因此,编译器生成的指令序列天然地避免了乱序执行的问题,因为编译器在生成代码时会确保指令的执行顺序符合调用惯例。