什么是中断?简单的解释就是系统正在处理某一个正常事件,忽然被另一个需要马上处理的紧急事件打断,系统转而处理这个紧急事件,待处理完毕,再恢复运行刚才被打断的事件。
生活中,我们经常会遇到这样的场景:
当正在专心看书的时候,忽然来一个电话,记下书的页码,去接电话,接完电话后接着刚才的页码继续看书,这是一个典型的中断过程。
电话催促交作业,交作业的优先级比看书高,于是电话挂断后先做作业,等交完作业后再接着刚才的页码继续看书,这是一个典型的在中断中进行任务调度的过程。
这些场景在嵌入式系统中也很常见,当CPU正在处理内部数据时,外界发生了紧急情况,要求CPU暂停当前的工作转去处理这个异步事件。
处理完毕后,再回到原来被中断的地址,继续原来的工作,这样的过程称为中断。
实现这一功能的系统称为中断系统,申请CPU中断的请求源称为中断源。
中断是一种异常,异常是导致处理器脱离正常运行转向执行特殊代码的任何事件,如果不及时进行处理,轻则系统出错,重则会导致系统毁灭性地瘫痪。
所以正确地处理异常,避免错误的发生是提高软件鲁棒性(稳定性)非常重要的一环。

中断处理与CPU架构密切相关。
Cortex-M CPU架构基础
不同于老的经典ARM处理器(例如:ARM7,ARM9),ARM Cortex-M处理器有一个非常不同的架构。
Cortex-M是一个家族系列,其中包括Cortex M0/M3/M4/M7多个不同型号,每个型号之间会有些区别,例如Cortex-M4比Cortex-M3多了浮点计算功能等,但它们编程模型基本一致。
寄存器简介
Cortex-M系列CPU的寄存器组里有R0~R15共16个通用寄存器组和若干特殊功能寄存器,如下图所示。
通用寄存器组里的R13作为堆栈指针寄存器(Stack Pointer,SP);R14作为连接寄存器(Link Register,LR),用于在调用子程序时,存储返回地址;R15作为程序计数器(Program Counter,PC),其中堆栈指针寄存器可以是主堆栈指针(MSP),也可以是进程堆栈指针(PSP)。
特殊功能寄存器包括程序状态字寄存器组(PSRs)、中断屏蔽寄存器组(PRIMASK,FAULTMASK,BASEPRI)、控制寄存器(CONTROL),可以通过MSR/MRS指令来访问特殊功能寄存器,例如:
c
MRS R0,CONTROL ;读取CONTROL到R0中
MSR CONTROL,R0 ;读取R0到CONTROL寄存器中
程序状态字寄存器里保存算术与逻辑标志,例如负数标志,零结果标志,溢出标志等等。
中断屏蔽寄存器组控制Cortex-M的中断使能。
控制寄存器用来定义特权级别和当前使用哪个堆栈指针。
如果是具有浮点单元的Cortex-M4或者Cortex-M7,控制寄存器也用来指示浮点单元当前是否正在使用,浮点单元包含了32个浮点通用寄存器S0~S13和特殊FPSCR寄存器(Floating point status and control register).
操作模式和特权级别
Cortex-M引入了操作模式和特权级别的概念,分为线程模式和处理模式,如果进入异常或中断处理则进入处理模式,其它情况则为线程模式。

Cortex-M有两个运行级别,分别为特权级和用户级,线程模式可以工作在特权级或者用户级,而处理模式总工作在特权级,可通过CONTROL寄存器控制。
Cortex-M的堆栈寄存器SP对应两个物理寄存器MSP和PSP,MSP为主堆栈,PSP为进程堆栈,处理模式总是使用MSP作为堆栈,线程模式可以选择MSP或PSP作为堆栈,同样通过CONTROL寄存器控制。
复位后,Cortex-M默认进入线程模式、特权级、使用MSP堆栈。
嵌套向量中断控制器
Cortex-M中断控制器名为NVIC(嵌套向量中断控制器),支持中断嵌套功能。
当一个中断触发并且系统进行响应时,处理器硬件会将当前运行位置的上下文寄存器自动压入中断栈中,这部分的寄存器包括PSR、PC、LR、R12、R3-R0寄存器。

当系统正在服务一个中断时,如果有一个更高优先级的中断触发,那么处理器同样会打断当前运行的中断服务程序,然后把这个中断服务程序上下文的PSR、PC、LR、R12、R3-R0寄存器自动保存到中断栈中。
PendSV系统调用
PendSV也称为可悬起的系统调用,它是一种异常,可以像普通的中断一样被挂起,它是专门用来辅助操作系统进行上下文切换的。
PendSV异常会被初始化为最低优先级的异常。每次需要进行上下文切换的时候,会手动触发PendSV异常,在PendSV异常处理函数中进行上下文切换。
中断向量表
中断向量表是所有中断处理程序的入口,如图所示是Cortex-M系列的中断处理过程:把一个函数(用户中断服务程序)同一个虚拟中断向量表中的中断向量联系在一起。
当中断向量对应中断发生的时候,被挂接的用户中断服务程序就会被调用执行。
在Cortex-M内核上,所有中断都采用中断向量表的方式进行处理,即当一个中断触发时,处理器将直接判定是哪个中断源,然后直接跳转到相应的固定位置进行处理,每个中断服务程序必须排列放在一起放在统一的地址上(这个地址必须要设置到NVIC的中断向量偏移寄存器中)。
中断向量表一般由一个数组定义或在起始代码中给出,默认采用起始代码给出:
c
__Vectors DCD __initial_sp ; Top of Stack
DCD Reset_Handler ; Reset 处理函数
DCD NMI_Handler ; NMI 处理函数
DCD HardFault_Handler ; Hard Fault 处理函数
DCD MemManage_Handler ; MPU Fault 处理函数
DCD BusFault_Handler ; Bus Fault 处理函数
DCD UsageFault_Handler ; Usage Fault 处理函数
DCD 0 ; 保留
DCD 0 ; 保留
DCD 0 ; 保留
DCD 0 ; 保留
DCD SVC_Handler ; SVCall 处理函数
DCD DebugMon_Handler ; Debug Monitor 处理函数
DCD 0 ; 保留
DCD PendSV_Handler ; PendSV 处理函数
DCD SysTick_Handler ; SysTick 处理函数
... ...
NMI_Handler PROC
EXPORT NMI_Handler [WEAK]
B .
ENDP
HardFault_Handler PROC
EXPORT HardFault_Handler [WEAK]
B .
ENDP
... ...
代码后面的[WEAK]标识,它是符号弱化标识,在[WEAK]前面的符号将被执行弱化处理,如果整个代码在链接时遇到了名称相同的符号,那么代码将使用未被弱化定义的符号,而与弱化符号相关的代码将被自动丢弃。
以SysTick中断为例,在系统启动代码中,需要填上Systick_handler中断入口函数
中断过程
RT-Thread中断管理中,将中断处理程序分为中断前导程序、用户中断服务程序、中断后续程序三部分,如图所示:
中断前导程序
中断前导程序主要工作如下:
- 保存CPU中断现场,这部分跟CPU架构相关,不同CPU架构的实现方式有差异。
对于Cortex-M来说,该工作由硬件自动完成。当一个中断触发并且系统进行响应时,处理器会将当前运行部分的上下文寄存器自动压入中断栈中,这部分的寄存器包括PSR、PC、LR、R12、R3-R0寄存器。 - 通知内核进入中断状态,调用rt_interrupt_enter()函数,作用是把全局变量rt_interrupt_nest加1,用它来记录中断嵌套的层数,代码如下所示。
c
void rt_interrupt_enter(void)
{
rt_base_t level;
level = rt_hw_interrupt_disable();
rt_interrupt_nest++;
rt_hw_interrupt_enbale(level);
}
用户中断服务程序
在用户中断服务程序(ISR)中,分为两种情况,第一种情况是不进行线程切换,这种情况下用户中断服务程序和中断后续程序运行完毕后退出中断模式,返回被中断的线程。
另一种情况是,在中断处理过程中需要进行线程切换,这种情况会调用rt_hw_context_switch_interrupt()函数进行上下文切换,该函数跟CPU架构相关,不同CPU架构的实现方式有差异。
在Cortex-M架构中,rt_hw_context_switch_interrupt() 的函数实现流程如下图所示,
它将设置需要切换的线程rt_interrupt_to_thread变量,然后触发PendSV异常(PendSV异常是专门用来辅助上下文切换的,且被初始化为最低优先级的异常)。
PendSV异常被触发后,不会立即进行PendSV异常中断处理程序,因为此时还在中断处理中,只有当中断后续程序运行完毕,直接退出中断处理后,才进入、
c
void hw_context_switch(uint32_t from,uint32_t to);
//r0 --> from, r1 --> to
hw_context_switch_interrupt
EXPORT hw_context_switch_interrupt
hw_context_switch PROC
EXPORT hw_context_switch
LDR r2, =thread_switch_interrupt_flag
LDR r3, [r2]
CMP r3, #1
BEQ _reswitch
MOV r3, #1
STR r3, [r2]
LDR r2, =interrupt_from_thread ; 设置 interrupt_from_thread
STR r0, [r2]
_reswitch
LDR r2, =interrupt_to_thread ; 设置 interrupt_to_thread
STR r1, [r2]
LDR r0, =NVIC_INT_CTRL
LDR r1, =NVIC_PENDSVSET
STR r1,[r0]
BX LR
ENDP
中断后续程序
通知内核立刻中断状态,通过调用rt_interrupt_leave()函数,将全局变量rt_interrupt_nest减1
c
void rt_interrupt_leave(void)
{
rt_base_t level;
level = rt_hw_interrupt_disable();
rt_interrupt_nest--;
rt_hw_interrupt_enable(level);
}
恢复中断前的 CPU 上下文,如果在中断处理过程中未进行线程切换,那么恢复 from 线程的 CPU 上下文,如果在中断中进行了线程切换,那么恢复 to 线程的 CPU 上下文。

中断嵌套
在允许中断嵌套的情况下,在执行中断服务程序的过程中,如果出现高优先级的中断,当前中断服务程序的执行将被打断,以执行高优先级中断的中断服务程序,当高优先级中断的处理完成后,被打断的中断服务程序才又得到继续执行,如果需要进行线程调度,线程的上下文切换将在所有中断处理程序都运行结束时才发生。
中断栈
在中断处理过程中,在系统响应中断前,软件代码需要把当前线程的上下文保存下来(通常保存在当前线程的线程栈中)。
在进行中断处理时,中断处理函数很可能会有自己的局部变量,这些都需要相应的栈空间来保存,所以中断响应依然需要一个栈空间来作为上下文,运行中断处理函数。
中断栈可以与线程栈完全分离开来,每次进入中断时,在保存完打断线程上下文后,切换到新的中断栈中独立运行。
在中断退出后,再做相应的上下文恢复。
使用独立中断栈相对来说更容易实现,RT-Thread采用的方式是提供独立的中断栈,即当中断发生时,中断的前期处理程序会将用户的栈指针更换为系统事先留出的中断栈空间,等中断退出时再恢复用户的栈指针。
这样中断就不会占用线程的栈空间,从而提高内存空间的利用率,且随着线程的增加,这种减少内存占用的效率也越明显。
在Cortex-M处理器内核里有两个堆栈指针,一个是主堆栈指针(MSP),是默认的堆栈指针,在运行第一个线程之前和在中断和异常服务程序里使用;另一个是线程堆栈指针(PSP),在线程里使用。
在中断和异常服务程序退出时,修改LR寄存器的第2位的值为1,线程的SP就由MSP切换到PSP。
中断的底半处理
RT-Thread不对中断服务程序所需要的处理时间做任何假设、限制,用户需要保证所有的中断服务程序在尽可能短的时间内完成(中断服务程序在系统中相当于拥有最高的优先级,会抢占所有线程优先执行)。
这样在发生中断嵌套,或屏蔽了相应中断源的过程中,不会耽误嵌套的其它中断处理过程,或自身中断源的下一次中断信号。
当一个中断发生时,中断服务程序需要取得相应的硬件状态或者数据。
如果中断服务程序接下来需要对状态或者数据进行简单处理,比如CPU时钟中断,中断服务程序只需要对一个系统时钟变量进行加一操作,然后就结束中断服务程序。这类中断需要的运行时间往往都比较短。
但对于另一些中断,中断服务程序在取得硬件状态或数据以后,还需要进行一系列更耗时的处理过程,通常需要将中断分割为两部分,即上半部分(Top Half)和底半部分(Bottom Half)。
在上半部分中,取得硬件状态和数据后,打开被屏蔽的中断,给相关线程发送一条通知,然后结束中断服务程序。
接下来,相关的线程在接收到通知后,对状态或数据进行进一步的处理,这一过程称之为底半处理。
中断源管理
通常在ISR准备处理某个中断信号之前,我们需要先屏蔽该中断源,在ISR处理完状态或数据以后,及时的打开之前被屏蔽的中断源。
屏蔽中断源可以保证在接下来的处理过程中硬件状态或者数据不会受到干扰,可调用下面这个函数接口:
c
void rt_hw_interrupt_mask(int vector);
中断通知
当整个系统被中断打断,进入中断处理函数时,需要通知内核当前已经进入到中断状态。针对这种情况,可通过以下接口:
c
void rt_interrupt_enter(void);
void rt_interrupt_leave(void);
这两个接口分别用在中断前导程序和中断后续程序中,均会对rt_interrupt_nest(中断嵌套深度)的值进行修改:
每当进入中断时,可以调用 rt_interrupt_enter() 函数,用于通知内核,当前已经进入了中断状态,并增加中断嵌套深度(执行 rt_interrupt_nest++);
每当退出中断时,可以调用 rt_interrupt_leave() 函数,用于通知内核,当前已经离开了中断状态,并减少中断嵌套深度(执行 rt_interrupt_nest --)。
注意不要在应用程序中调用这两个接口函数。
使用rt_interrupt_enter/leave()的作用是,在中断服务程序中,如果调用了内核相关的函数(如释放信号量),则可以通过判断当前中断状态,让内核及时调整相应的行为。
例如:在中断中释放了一个信号量,唤醒了某线程,但通过判断发现当前系统处于中断上下文环境中,那么在进行线程切换时应该采取中断中线程切换的策略,而不是立即进行切换。
c
rt_uint8_t rt_interrupt_get_nest(void);
- 0:当前系统不处于中断上下文环境中
- 1:当前系统处于中断上下文环境中
- 大于1:当前中断嵌套层次
中断与轮询
当驱动外设工作时,其编程模式到底采用中断模式触发还是轮询模式触发往往是驱动开发人员首先要考虑的问题,并且这个问题在实时操作系统与分时操作系统中差异还非常大。
因为轮询模式本身采用顺序执行的方式:查询到相应的事件然后进行对应的处理,所以轮询模式从实现上来说,相对简单清晰。例如往串口中写入数据,仅当串口控制器写完一个数据时,程序代码才写入下一个数据(否则这个数据丢弃掉)。
c
while(size)
{
while(!(uart->uart_device->SR & USART_FLAG_TXE));
uart->uart_device->DR = (*ptr & 0x1FF);
++ptr;
--size;
}
在实时系统中轮训模式可能会出现非常大问题,因为实时操作系统中,当一个程序持续地执行时(轮询时),它所在的线程会一直运行,比它优先级低的线程都不会得到运行。而分时系统中,这点恰恰相反,几乎没有优先级之分,可以在一个时间片运行这个程序,然后在另外一段时间片上运行另外一段程序。
所以通常情况下,实时系统中更多采用的是中断模式来驱动外设。当数据达到时,由中断唤醒相关的处理线程,再继续进行后续的动作。
例如一些携带FIFO的串口外设,其写入过程是:
线程先向串口的FIFO中写入数据,当FIFO满时,线程主动挂起。
串口控制器持续地从FIFO中取出数据并以配置的波特率(例如115200)发送出去。
当FIFO中所有数据都发送完成时,将向处理器触发一个中断;当中断服务程序得到执行时,可以唤醒这个线程。
这里举例的是 FIFO 类型的设备,在现实中也有 DMA 类型的设备,原理类似。