异常主要是指来自CPU内部的意外事件,比如执行了未定义指令、算术溢出、除零运算等发生在CPU内部的意外事件,这些异常的发生,会引起CPU运行相应的异常处理程序;中断一般来自硬件(如片上外设、外部I/O输入等)发生的事件,当这些硬件产生中断信号时,CPU会暂停当前运行的程序,转而去处理相关硬件的中断服务程序。但无论是异常还是中断,都会引起程序执行偏离正常的流程,转而去执行异常/中断的处理函数。
下面对异常和中断的介绍,如果中断信号的产生原因来自CPU内部,则称之为异常;如果中断信号来自CPU外部,则称之为中断。有些场合如果没有明确指出是异常还是中断,就统称为中断。
在网上看到一个非常有意思的例子,这里引用下类似的例子来说明为什么需要中断。当你正在看一部很喜欢的电影时,这个时候你觉得有点口渴了,需要去烧一壶开水,假设水烧开需要10分钟。那么请问你如何知道水烧开了呢?也许你会有这两种选择:
第一种情况的处理方式,实际上就是不断的查询水是否被烧开了,如果烧开了就关火,没烧开就接着继续看电影,这种处理方式可能会累死你,而且你也会觉得自己有点笨。写程序描述如下:
while (1)
{
see a film(看电影)
check water boiling(查看水是否烧开)
if (水还没开)
return(继续看电影)
else
关火
}
第二种方式可以看作是烧开水的声音发出了一个信号给你,你接收到这个信号,然后就知道了我该去关火了,也就是说这个信号中断了你看电影的过程,而在这之前你可以一直享受看电影的过程。写程序描述如下:
while (1)
{
see a film(看电影)
}
中断服务程序()
{
关火
}
这个例子类比于CPU的话,CPU也是可以选择这两种方式去处理意外事件的,查询方式或者中断方式。对于查询方式很明显会使得CPU的资源无法得到充分的利用,因为CPU的速度是远远大于外设的速度的,CPU在查询外设的状态时就需要等待外设的响应,使得CPU做了很多无用功。而对于中断方式,当外部事件还没达到就绪状态时(类比就是水还没烧开这件事),CPU可以专心的做其他任务,一旦CPU接收到中断信号时,转而去处理中断请求,CPU处理完毕之后再接着执行原来的任务。
从这个类比的例子可以看出,中断机制使得CPU具有了异步处理能力。有了中断机制之后,CPU可以一直专心的执行它的主任务,不用一直去查询设备的状态。设备本身如果达到了就绪状态,需要CPU去处理的时候,此时设备发出一个中断信号给CPU,通知CPU说:“我要你来处理一下了,赶紧来吧”!CPU收到通知之后,先把主任务暂停一会,然后跳转到相应外设的中断服务函数处理该外设的中断请求,处理完之后CPU再继续回去执行主任务。
在ARM体系中,使用中断的过程一般有如下步骤:
中断初始化
1.1 设置中断源,让某个外设可以产生中断;
1.2 设置中断控制器,使能/屏蔽某个外设的中断通道,设置中断优先级等;
1.3 使能CPU中断总开关
CPU在运行正常的程序
产生中断,比如用户按下了按键 —> 中断控制器 —> CPU
CPU每执行完一条指令都会检查是否有异常/中断产生
发现有异常/中断产生,开始处理:
5.1 保存现场
5.2 分辨异常/中断,调用对应的异常/中断处理函数
5.3 恢复现场
上面讲到当CPU产生异常/中断时,CPU会暂停当前程序的运行,转而去处理异常/中断的处理函数。但是这里有一些疑问:CPU如何调用异常/中断的处理函数?调用完之后如何返回?在运行中断处理函数之后,如何保证原来被打断的运行环境不被中断处理函数所改变?这几个问题是异常与中断处理流程中非常关键的问题,这些问题会在下面进行讨论,并给出解答。
当异常/中断发生时,ARM架构对于异常/中断的处理流程基本都是:
但是细分到不同的架构(比如M3和A7架构)时,具体的处理流程还是有一些差异的,而对于STM32来说,中断的整个处理流程硬件帮我们做了大部分的工作,比如保存现场、分别异常/中断,跳转执行、恢复现场等工作,都是硬件帮我们实现了的。那么我们要做的事情是什么?我们只需要编写中断服务函数中具体想要实现的逻辑代码即可,发生异常/中断后,CPU跳转执行的就是我们实现的这部分代码。
对于STM32,当某一个外设的中断发生时,CPU如何去调用相应外设的中断服务函数?这就需要引入异常向量表的概念了。STM32的中断向量表可以看成是一个指针数组,这个数组里面存放的就是一个个的中断服务函数的入口地址(向量表首地址规定是栈顶指针)。当CPU检查到某个中断产生时,硬件会根据我们提供的中断号自动跳转到向量表中与这个中断号对应的这个中断服务函数的入口地址,从而执行相应的中断服务函数。
比如发生了Reset异常,那么CPU会从异常向量表的第1项找到Reset_Handler函数的地址,然后跳转到该函数执行;如果发生其他中断,CPU同样可以从表格里面找到对应的中断服务函数的地址,然后跳转到该函数执行。
STM32F103的中断向量表如下所示:
__Vectors DCD __initial_sp ; Top of Stack
DCD Reset_Handler ; Reset Handler
DCD NMI_Handler ; NMI Handler
DCD HardFault_Handler ; Hard Fault Handler
DCD MemManage_Handler ; MPU Fault Handler
DCD BusFault_Handler ; Bus Fault Handler
DCD UsageFault_Handler ; Usage Fault Handler
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD SVC_Handler ; SVCall Handler
DCD DebugMon_Handler ; Debug Monitor Handler
DCD 0 ; Reserved
DCD PendSV_Handler ; PendSV Handler
DCD SysTick_Handler ; SysTick Handler
; External Interrupts
DCD WWDG_IRQHandler ; Window Watchdog
DCD PVD_IRQHandler ; PVD through EXTI Line detect
DCD TAMPER_IRQHandler ; Tamper
DCD RTC_IRQHandler ; RTC
DCD FLASH_IRQHandler ; Flash
DCD RCC_IRQHandler ; RCC
DCD EXTI0_IRQHandler ; EXTI Line 0
DCD EXTI1_IRQHandler ; EXTI Line 1
DCD EXTI2_IRQHandler ; EXTI Line 2
DCD EXTI3_IRQHandler ; EXTI Line 3
DCD EXTI4_IRQHandler ; EXTI Line 4
DCD DMA1_Channel1_IRQHandler ; DMA1 Channel 1
DCD DMA1_Channel2_IRQHandler ; DMA1 Channel 2
DCD DMA1_Channel3_IRQHandler ; DMA1 Channel 3
DCD DMA1_Channel4_IRQHandler ; DMA1 Channel 4
DCD DMA1_Channel5_IRQHandler ; DMA1 Channel 5
DCD DMA1_Channel6_IRQHandler ; DMA1 Channel 6
DCD DMA1_Channel7_IRQHandler ; DMA1 Channel 7
DCD ADC1_2_IRQHandler ; ADC1 & ADC2
DCD USB_HP_CAN1_TX_IRQHandler ; USB High Priority or CAN1 TX
DCD USB_LP_CAN1_RX0_IRQHandler ; USB Low Priority or CAN1 RX0
DCD CAN1_RX1_IRQHandler ; CAN1 RX1
DCD CAN1_SCE_IRQHandler ; CAN1 SCE
DCD EXTI9_5_IRQHandler ; EXTI Line 9..5
DCD TIM1_BRK_IRQHandler ; TIM1 Break
DCD TIM1_UP_IRQHandler ; TIM1 Update
DCD TIM1_TRG_COM_IRQHandler ; TIM1 Trigger and Commutation
DCD TIM1_CC_IRQHandler ; TIM1 Capture Compare
DCD TIM2_IRQHandler ; TIM2
DCD TIM3_IRQHandler ; TIM3
DCD TIM4_IRQHandler ; TIM4
DCD I2C1_EV_IRQHandler ; I2C1 Event
DCD I2C1_ER_IRQHandler ; I2C1 Error
DCD I2C2_EV_IRQHandler ; I2C2 Event
DCD I2C2_ER_IRQHandler ; I2C2 Error
DCD SPI1_IRQHandler ; SPI1
DCD SPI2_IRQHandler ; SPI2
DCD USART1_IRQHandler ; USART1
DCD USART2_IRQHandler ; USART2
DCD USART3_IRQHandler ; USART3
DCD EXTI15_10_IRQHandler ; EXTI Line 15..10
DCD RTCAlarm_IRQHandler ; RTC Alarm through EXTI Line
DCD USBWakeUp_IRQHandler ; USB Wakeup from suspend
DCD TIM8_BRK_IRQHandler ; TIM8 Break
DCD TIM8_UP_IRQHandler ; TIM8 Update
DCD TIM8_TRG_COM_IRQHandler ; TIM8 Trigger and Commutation
DCD TIM8_CC_IRQHandler ; TIM8 Capture Compare
DCD ADC3_IRQHandler ; ADC3
DCD FSMC_IRQHandler ; FSMC
DCD SDIO_IRQHandler ; SDIO
DCD TIM5_IRQHandler ; TIM5
DCD SPI3_IRQHandler ; SPI3
DCD UART4_IRQHandler ; UART4
DCD UART5_IRQHandler ; UART5
DCD TIM6_IRQHandler ; TIM6
DCD TIM7_IRQHandler ; TIM7
DCD DMA2_Channel1_IRQHandler ; DMA2 Channel1
DCD DMA2_Channel2_IRQHandler ; DMA2 Channel2
DCD DMA2_Channel3_IRQHandler ; DMA2 Channel3
DCD DMA2_Channel4_5_IRQHandler ; DMA2 Channel4 & Channel5
__Vectors_End
一般来说向量表都会被链接到一个程序存储的最前面的位置的,对于STM32F103来说,程序运行和加载的起始地址都是0x08000000,所以STM32的异常向量表也会会被加载到0x08000000地址处的。
对于ARM架构,规定向量表的基地址默认是0x00000000或0xFFFF0000位置的(对于STM32向量表的默认基地址就是0地址),而STM32向量表的基地址是被链接在了0x08000000的地址处,这样当中断发生时,不就跳转到了错误的函数入口地址了吗?为了解决这个问题,ARM允许可以修改异常向量表的基地址,这样就可以把异常向量表的基地址重新修改在内部Flash的起始地址0x08000000位置了。完成这一工作的是官方固件库的SystemInit函数,相关代码如下:
#define FLASH_BASE ((uint32_t)0x08000000) /*!< FLASH base address in the alias region */
#define VECT_TAB_OFFSET 0x0 /*!< Vector Table base offset field. This value must be a multiple of 0x200. */
#define SCB ((SCB_Type *) SCB_BASE) /* !< SCB configuration struct */
void SystemInit (void)
{
/****************此处省略部分代码****************/
#ifdef VECT_TAB_SRAM
SCB->VTOR = SRAM_BASE | VECT_TAB_OFFSET; /* Vector Table Relocation in Internal SRAM. */
#else
SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET; /* Vector Table Relocation in Internal FLASH. */
#endif
}
其中 SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET; 这一行代码就是把异常向量表基地址更改到了0x08000000的地址中去了。
补充:我曾自己写过简单的启动文件测试,并没有去调用SystemInit函数,自己也没有在其他地方对向量表的基地址更改到0x08000000的地址中去,但是当CPU检查到有中断发生时,还是可以跳转到正确的中断服务函数的入口地址处。为什么会这样?我查阅了下相关的资料,了解到当STM32从Flash启动时,实际上会把Flash的那段存储空间0x08000000 ~ 0x0807FFFF映射到0x00000000 ~ 0x0007FFFF这一段地址的存储空间,也就是说当CPU从Flash启动时,CPU从0地址处开始执行指令和从0x08000000地址处开始执行指令是一样的。所以,这个时候我没有去更改异常向量表的基地址,CPU仍然可以跳转到正确的中断服务程序。
我们在Keil里面写一段非常简单的代码,实现 a = a + b; 这个式子。当然我们并不是要去关心这个式子本身,而是利用Keil的仿真功能,去查看一下CPU执行这个式子后,其内部寄存器的变化。仿真截图如下:
从上面这个仿真的汇编代码可以看出,CPU要完成 a = a + b; 的运算,实际上是先去读取a, b的内存所保存的数据到内部寄存器r0, r1中,然后再执行r0 = r0 + r1的运算,最后把r0的值写回到内存a中,这样才完成了一次a = a + b; 的运算。另外,可以观察到CPU在运行程序的过程中,它内部的r0 - r15,还有程序状态寄存器xPSR的值,都是在不断的变化中的。
那么问题来了,假设CPU在运行 a = a + b; 这个式子的中途,程序运行过程被中断信号打断了。要想这个式子可以得到正确的结果,那么就要确保中断前CPU的这几个寄存器的值以及中断返回后这些寄存器的值都要保持不变,还有保存在内存空间a, b的值也不会被改变。这样运行这个式子得到的最终结果才是正确的。
对于保持a, b内存的值不变,这个好办,中断程序中不要去改写a, b的值即可。但是我们如何保持中断前后CPU内部寄存器的值不被改变呢?因为中断程序的运行肯定也是需要用到CPU内部的寄存器的,所以必须得有一种机制去保持中断前后CPU内部寄存器的值不被改变。也就是说,所谓的现场,实际上就是CPU运行到某一时刻时,CPU内部寄存器的值。
那么如何保存现场和恢复现场呢?这就需要用到栈。CPU在跳转到异常/中断程序前,就需要把这些不希望被中断程序所改变的寄存器的值保存到栈中,这就是保存现场。在异常/中断处理完之后,从栈中恢复这些寄存器的值,这就是恢复现场。
上面说了,所谓保存现场就是保存CPU内部一些寄存器的值到栈中。CPU内部常用的寄存器有 r0 ~ r15,再加上程序状态寄存器,一共16个(当然其他模式下可能还有对应的其他寄存器)。下图就是STM32F103内部寄存器示例(M3内核不具有浮点运算单元,这里不讨论浮点运算单元的寄存器了):
当发生异常/中断时,CPU跳转之前,就需要把这些寄存器的值保存到栈中,那么我们需要把CPU的这16个寄存器都保存到栈中吗?很显然是不需要的。
ARM公司推出了一个ATPCS标准,即ARM-THUMB procedure call standard(ARM-Thumb过程调用标准)。在这份标准里面,都规定了这16个寄存器的使用规则,如下表所示:
寄存器 | 别名 | 使用规则 |
---|---|---|
r15 | pc | 程序计数器 |
r14 | lr | 连接寄存器 |
r13 | sp | 数据栈指针 |
r12 | ip | 子程序内部调用的scratch寄存器 |
r11 | v8 | ARM状态局部变量寄存器8 |
r10 | v7、s1 | ARM状态局部变量寄存器7、在支持数据栈检查的ATPCS中为数据栈限制指针 |
r9 | v6、sb | ARM状态局部变量寄存器6、在支持RWPI的ATPCS中为静态基址寄存器 |
r8 | v5 | ARM状态局部变量寄存器5 |
r7 | v4、wr | ARM状态局部变量寄存器4、Thumb状态工作寄存器 |
r6 | v3 | ARM状态局部变量寄存器3 |
r5 | v2 | ARM状态局部变量寄存器2 |
r4 | v1 | ARM状态局部变量寄存器1 |
r3 | a4 | 参数/结果/scratch 寄存器4 |
r2 | a3 | 参数/结果/scratch 寄存器3 |
r1 | a2 | 参数/结果/scratch 寄存器2 |
r0 | a1 | 参数/结果/scratch 寄存器1 |
其中,r0 ~ r3是函数调用时用来传递参数或者保存函数返回值的,r4 ~ r11是用来保存局部变量的,r12 ~ r15是特殊功能寄存器。这些寄存器一般会被拆分成2部分:调用者保存的寄存器(r0-r3, r12, lr, psr)、被调用者保存的寄存器(r4-r11)。
比如函数A调用函数B,那么函数A就是调用者,函数B就是被调用者。那么根据这份ATPCS规则,函数A在调用函数B之前,函数A就应该要保存r0-r3, r12, lr, psr这几个调用者要保存的寄存器;而对于r4-r11这几个寄存器,调用函数B的前后,函数B本身会保证调用前后这些寄存器的值保持不变。
那么,如果函数B是中断服务函数,因为函数B本身会保证r4-r11的值不会被改变,所以保存现场也就是保存r0-r3, r12, lr, psr这几个寄存器的值即可。
我们已经知道了什么是现场,保存现场要保存哪些寄存器的值了。那么对于STM32来说,是如何保存这些寄存器的值到栈中的呢?
非常幸运的是,STM32保存现场的工作是CPU的硬件自动帮我们完成的(我们不要想当然的认为所有架构的硬件都会帮我们完成这部分工作,对于Cortex-A7架构来说就不是硬件帮我们完成的),我们不需要自己写代码去把中断前需要保存的寄存器的值存放到栈中。具体过程如下图所示:
恢复现场就是指CPU执行完异常/中断服务函数后,返回到LR寄存器所指示的地址处(就是中断前的下一条指令的地址),并且把保存在栈中寄存器的值恢复到寄存器中去。
对于如何返回到中断前下一条指令的地址,其实很容易就可以实现,只要我们把下一条指令的地址存放到LR寄存器即可,CPU返回的话就是把LR寄存器的值赋值给PC程序计数器,这样就可以返回到了中断前下一条指令中继续运行程序了。但是如果这样做的话,硬件帮我们保存在栈中的寄存器的值,返回去是怎么恢复那些寄存器的值?
对于这个问题,STM32帮我们实现了一套机制。CPU进入异常/中断服务程序时,LR寄存器保存的并不是中断前下一条指令的地址,而是会保存一个特殊的数值,被称为EXC_RETURN。对于EXC_RETURN位域的定义和具体的数值含义,可以查看《ARM Crotex-M3与Cortex-M4权威指南》一书中第八章的描述。这里截图如下作为参考:
所以,当异常/中断返回时,LR寄存器赋值给PC的值是一个被称为EXC_RETURN的值,而一旦CPU识别到PC的值等于EXC_RETURN的话,那么就会触发异常/中断返回机制,这个机制会帮我们把保存在栈中r0-r3, r12, lr, psr的寄存器的值恢复回去。