【理解ARM架构】异常处理
🐱作者:一只大喵咪1201
🐱专栏:《理解ARM架构》
🔥格言:你只管努力,剩下的交给时间!
目录
- ⚡ARM系统中异常与中断处理流程
- 🍢向量表
- 🍢保存现场
- 🍢恢复现场
- ⚡异常处理
- 🍢未定义指令异常
- 🍢SVC异常
- 🍢SysTick异常
- ⚡总结
⚡ARM系统中异常与中断处理流程
如上图所示arm系统中异常与中断的硬件框图,左侧的按键,定时器其他等等被叫做中断源,它们发出的中断汇聚到中断控制器,也就是NVIC,再由中断控制器将中断发信号给CPU,告诉它发生了那些紧急情况,CPU会中断当前正在执行的代码去处理中断。
除了中断,异常也可以打断CPU的运行,如上图所示右侧框中:
- 指令不对
- 数据访问有问题
- reset信号
等等情况,这些都可以打断CPU运行,这些都属于异常。
- 中断属于一种异常。
ARM系统中处理异常与中断的重点在于保存现场以及恢复现场,中断的使用过程如下:
-
初始化
- 设置中断源,让它可以产生中断
- 设置中断控制器(可以屏蔽某个中断,优先级)
- 设置CPU总开关,使能中断
-
执行其他程序:正常程序
-
产生中断,举例:按下按键—>中断控制器—>CPU
-
cpu每执行完一条指令都会检查有无中断/异常产生
-
发现有中断/异常产生,开始处理:
- 保存现场
- 分辨异常/中断,调用对于异常/中断的处理函数
- 恢复现场
🍢向量表
不同的芯片,不同的架构,在这方面的处理稍有差别。先来认识一下向量表。向量,在数学定义里是有方向的量,在程序里可以认为向量就是一个数组,里面有多个项,在ARM架构里,对于异常/中断,它们的处理入口函数会整齐地排放在向量表中。
如上图所示,我们在使用CubeMX或者固件库创建好的工程中,在start.s
中存在一个向量表__Vectors
,其中上面的蓝色框中是处理异常的入口地址,下面的蓝色框中是处理中断的入口地址。
板子上电以后,从__Vectors
处的第一个DCD
处执行,这里是设置栈顶的,__initial_sp
就是栈顶的地址。然后再执行第二个DCD
处的Reset_Handler
,我们的main
函数等就放在这里。
cortex M3/M4:
M3/M4的向量表中,放置的是具体异常/中断的处理函数的地址。比如发生Reset
异常时,CPU就会从向量表里找到第1项,得到Reset_Handler
函数的地址,跳转去执行。
比如发生EXTI Line 0
中断时,CPU就会从向量表里找到第22项,得到EXTI0_IRQHandler
函数的地址,跳转去执行。
cortex A7:
如上图所示A7的向量表中,放置的是某类异常的跳转指令。比如发生Reset
异常时,CPU就会从向量表里找到第0项,得到b reset
指令,执行后就跳转到reset
函数。
比如发生任何的中断时,CPU就会从向量表里找到第6项,得到ldr pc, _irq
指令,执行后就跳转到_irq函数。
🍢保存现场
在跳转到向量表执行入口函数之前,先要保存现场,也就是将CPU中寄存器中的值先保存下来。
为什么要保存现场?
如上图所示代码示意图,任何程序,最终都会转换为机器码,上述C代码可以转换为右边的汇编指令。
对于这4条指令,它们可能随时被异常打断,怎么保证异常处理完后,被打断的程序还能正确运行?
- 这4条指令涉及R0、R1寄存器,程序被打断、恢复运行时,R0、R1要保持不变。
- 执行完第3条指令时,比较结果保存在程序状态寄存器里,程序被打断、恢复运行时,程序状态寄存器要保持不变。
- 这4条指令,读取a、b内存,程序被打断、恢复运行时,a、b内存要保持不变
内存保持不变,这很容易实现,程序不越界就可以。所以,关键在于R0、R1、程序状态寄存器要保持不变(当然不止这些寄存器),因为这些寄存器在中断中也有可能用到,此时就会改变原本的值。
如上图所示,在ARM处理器中有这些寄存器,而且在ARM中有个ATPCS规则(ARM-THUMB procedure call standard(ARM-Thumb过程调用标准)约定R0-R15寄存器的用途。
如上图所示,R0-R3
用在调用者和被调用者之间传参数,R4~R11
在被调用者(函数)内使用,R12~R15
是特殊用途的寄存器,还有一个程序状态寄存器,对于M3/M4它被称为XPSR。
- 保存现场就是在保存
R0~R15
以及XPSR
寄存器。
在发生异常/中断后,在处理异常/中断前,需要保存现场,难道需要保存所有这些寄存器吗?不是的。
- 这些这些寄存器被拆分成2部分:调用者保存的寄存器(R0-R3,R12,LR,PSR)、被调用者保存的寄存器(R4-R11)。
怎么理解呢?(R0-R3,R12,LR,PSR)
这些寄存器是用来传参或者保存返回地址的,调用者主动将这些寄存器给被调用者直接使用,站在被调用者的角度,它认为它得到了允许,既然是你让我用的,那我就随便用了。
站在调用者的角度,就有责任来保证自己不受影响,所以在给被调用者使用之前,需要将这些寄存器的值保存起来,调用结束以后方便将值恢复到这些寄存器中。
(R4-R11)
这些寄存器被调用者在使用的时候,并没有得到调用者的允许,所以它在使用之前有责任将这些寄存器原本的值保存起来,在使用完毕后再将值恢复到寄存器中,以防影响到调用者。
- 所以在处理中断/异常之前,要将
R0~R3
,R12
,LR
,XPSR
寄存器中的值保存。
保存现场时寄存器中的值保存到哪里呢?
如上图所示,在保存现场时,将调用者要保存的寄存器挨个压栈,高编号寄存器值放在高地址。
- 在M3/M4中,现场保存是由硬件完成的,我们写程序的不用关心。
- 异常/中断类型的分辨也是由硬件完成的。
在保存完现场以后,就直接跳转到向量表中对于的处理入口执行对应的处理函数。
🍢恢复现场
如上图所示现场保护时栈的情况,在处理函数执行完毕后,它返回LR所指示的位置(普通调用是这样),难道把LR设置为被中断时程序的地址就行了吗?
如果只是返回LR所指示的地方,也就是执行MOV PC, LR
,此时程序直接就返回到产生中断/异常的位置开始执行代码了,硬件帮我们保存在栈里的寄存器,怎么恢复?
所以M3/M4在调用异常处理函数前,把LR设置为一个特殊的值,该特殊的值被称为EXC_RETURN。
如上图所示,该特殊值是一个32位的地址,它具有特别的意义,以后会具体讲解它的意义。
当处理函数执行完毕以后,会执行MOV PC, LR
,当PC寄存器的值等于EXC_RETURN时,会触发异常返回机制,简单地说:会从栈里恢复R0-R3,R12,LR,PC,PSR
等寄存器。
然后再把栈中红色框中的返回地址赋值给PC
寄存器,让程序从产生中断/异常位置继续执行。
- 恢复现场是由软件触发,硬件恢复的。
所谓软件触发就是我们在处理函数中执行return
函数,此时就会触发异常/中断返回机制,由硬件将栈中保存的值恢复到寄存器中。
⚡异常处理
在了解了异常/中断的处理流程以后,来写代码感受一下。继续使用前面的代码。
如上图,修改散列文件,让代码段的加载地址和链接地址相等,不再需要代码段重定位,让代码在Flash上运行。
🍢未定义指令异常
所谓未定义指令就是写一条CPU不认识的指令,此时就会出异常,硬件就会让程序跳转到向量表中对应的处理入口,去执行处理函数。
如上图,在向量表中只保留HardFault_Handler
和UsageFault_Handler
两个异常处理入口,并且声明这两个函数。
如上图,定义HardFault_Handler
和UsageFault_Handler
两个异常处理函数,在函数里打印一句话,然后陷入死循环。
如上图所示,声明串口初始化函数,然后在执行未定义指令之前初始化串口,否则就无法看到打印的东西了,因为串口还没有初始化就发生了异常。
然后会执行DCD 0XFFFFFFFF
未定义指令,此时就会产生异常,这属于一个使用异常,所以应该会去UsageFault_Handler
处执行处理函数。
如上图,但是此时从串口助手上看到的是HardFault_Handler
,说明执行的是HardFault_Handler
处理函数,而不是UsageFault_Handler
函数,这是为什么呢?
如上图所示,未定义指令属于"处理器操作相关的错误",如果没有使能Usage Fault"
,发就会触发Hard Fault
,所以上面执行的就是HardFault_Handler
处理函数。
为了执行HardFault_Handler
处理函数,需要将Usage Fault
使能,在M3/M4内核中,有一个用于异常和中断控制的SCB
寄存器:
如上图所示SCB
寄存器部分位,详细内容在ARM Cortex-M3与Cortex-M4权威指南
这本书中有详细接收,该寄存器的基地址是0xE000ED00
。
如上图,为了访问SCB寄存器方便,将该寄存器使用结构体描述出来。
如上图,定义一个函数UsageFaultInit
,在里面将SCB
寄存器的第18位,也就是SHCSR
位置一,在执行未定义指令之前调用该函数,此时就使能了UsageFault
。
如上图,在用法错误异常处理函数UsageFault_Handler
中,只打印异常名,不陷入死循环。
如上图,此时就会疯狂打印UsageFault_Handler
,说明不停的在执行UsageFault_Handler
处理函数。为什么会不停执行呢?执行一遍不就可以了吗?
- 用法错误异常仍然存在,虽然执行了
UsageFault_Handler
处理函数,但是没有将该异常清除。
如上图,在UsageFault_Handler
函数中,先打印出保护现场时,调用者保护的R0~R3,R12,LR,返回地址,XPSR
,这七项,它们存在栈中。
如上图所示,由于要在UsageFault_Handler
函数中打印栈中存放的寄存器值,所以在调用该函数的时候要进行传参,而向量表中存放的入口处理函数指针是没有形参的。
所以重新定义一个入口处理函数UsageFault_Handler_asm
,如上图红色框,将该函数放入到向量表中,当发生UsageFault
的时候,就会跳转去执行该函数。
在该函数中,通过R0
寄存器传参栈顶指针SP
,然后再调用我们之前实现的UsageFault_Handler
。
- 调用
UsageFault_Handler
函数的时候不能使用BL
指令,因为这是异常处理函数,不能直接返回到LR
中的地址处,需要触发恢复现场机制。- 所以只能使用
B
来调用UsageFault_Handler
,现场恢复机制不在这里触发。
如上图所示,此时串口仍然疯狂输出,我们截取打印内容中栈里的值,发现在调用UsageFault_Handler
处理函数之前的现场保存时,存放到栈中的返回地址是0x08000068
,程序执行完处理函数后会返回到这个地址继续执行。
如上图,打开反汇编文件,查看0x08000068
地址处的内容,发现该地址处就是那条未定义指令。
也就是说,未定义指令引起异常后调用处理函数,处理完毕以后又回到了异常指令这里,再次执行,再次引发异常,如此反复导致疯狂输出。
如上图所示,在UsageFault_Handler
函数中,设置栈中的返回地址,让其指向下一条指令,也就是在调用异常处理函数结束以后,硬件进行现场恢复完成,然后让PC
指向未定义指令的下一条指令。
如上图所示,此时程序就能正常执行了。
🍢SVC异常
在ARM指令中,有一条指令:
SVC #VAL
其中,VAL
是个立即数,代表着一个编号,当SVC
异常产生时,会调用对应编号的处理函数,默认情况下我们只有一个处理函数,所以该值一般填1。
当CPU执行了SVC
指令后,会触发一个异常,在操作系统中,比如各类RTOS或者Linux,都会使用SVC
指令故意触发异常,从而导致内核的异常处理函数被调用,进而去使用内核的服务(系统调用)。
比如Linux中,各类文件操作的函数open
、read
、write
,它的实质都是SVC
指令。本喵这里不讲解这些,只是看一下SVC
异常发生后的现象。
如上图,定义一个SVC_Handler
函数来处理SVC
异常。
如上图,在启动文件中,将SVC_Handler
处理函数放入向量表并且声明,然后在Reset_Handler
中执行SVC #1
指令产生异常。
如上图所示,此时可以看到,SVC_Handler
处理函数被调用了,所以说,产生SVC
异常时,会去执行对应的处理函数。
如上图所示,先给R0~R3,R12,LR
赋值,然后在产生SVC
异常后进入处理函数时停下来,查看此时栈中的内容,可以看到,我们原本赋给寄存器中的值此时保存在栈中。
- 在调用异常处理函数之前,硬件进行了现场保存,将调用者保存的寄存器中的值放到了栈中。
🍢SysTick异常
Cortex-M处理器内部集成了一个小型的、名为SysTick
的定时器,也叫做滴答定时器。可以使用它来为操作系统提供系统时钟,也可以把它当做一般的定时器。
它是一个24位的定时器,向下计数,在时钟源的驱动下,计数值到达0时,可以触发SysTick
异常。
如上图所示SysTick
定时器框图,每到了一次时钟信号,VAL
计数器就会减一,当减到0以后会产生一次SysTick
异常。
然后再自动从LOAD
重装载寄存器中读取计数值到VAL
中,如此反复产生多次异常。
控制SysTick
定时器的寄存器基地址为0xE000E010
。
如上图所示STCK_CTRL
控制寄存器,通过BIT2
来选择时钟源,该位是1时选择处理器时钟,也就是晶振直接作为时钟,STM32F103ZET6
的晶振频率是8MHZ。
通过BIT1
来使能SysTick
异常,将该位设置为1,通过BIT0
来使能SysTick
定时器,将该位设置成1。
如上图所示计数器STK_VAL
寄存器,其bit0~bit23
存放的是计数值,要给它设置一个初始值。
如上图所示STK_LOAD
重装载寄存器,VAL
减为0以后会从这里重新拿值,所以该寄存器的值要设置成和VAL
中的值一样。
如上图所示SCB_ICSR
寄存器,SysTick
异常发生以后,需要在处理函数中清除异常,将该寄存器的BIT25
设置为1。
如上图,为了使用方便,同样将SysTick
定时器用到的寄存器用结构体描述出来。
如上图所示,定义一个SysTickInit
函数来初始化滴答定时器,将VAL
和LOAD
寄存器的值都设置为8000,定时时间为1s,因为晶振时钟频率是8000。
再设置CTRL
控制寄存器中的bit0~bi2
,全部设置为1,表示选择晶振作为时钟源,使能SysTick
异常,使能SysTick
定时器。
如上图,定义异常处理函数SysTick_Handler
,在里面清除SysTick
异常,并且打印异常名字。
如上图所示,声明异常处理函数SysTick_Handler
,并将其放到向量表中。再声明定时器初始化函数SysTickInit
,并在调用mymain
之前调用,完成滴答定时器初始化。
如上图,此时每隔一秒钟会产生一次SysTick
中断,会调用一次SysTick_Handler
异常处理函数。
⚡总结
要清楚异常发生的流程,包括现场保存,分辨异常源且执行相应的处理函数,通过软件触发现场恢复机制。其中现场保存和现场恢复是由硬件完成的,包括异常源的分辨也是。
- 异常并不会经过中断控制器NVIC。