异常和中断
异常和中断这样一个过程:当CPU内部或者外部出现某种事件(中断源)需要处理时,暂停正在执行的程序(断点),转去执行请求中断的那个事件的处理程序(中断服务程序),执行完成后,在返回被暂停执行的程序(中断返回),从断点处继续执行。其执行流程如下图所示。
异常和中断的处理过程基本一致,但二者并不是完全等同,习惯上将CPU内部产生中断叫异常,外部控制器产生中断叫中断。本文无特别说明,统一将中断和异常称为异常。
异常类型
Cortex‐M3在内核水平上搭载了一个异常响应系统,支持为数众多的系统异常和外部中断。其中,编号为1-15的对应系统异常,大于等于16的则全是外部中断。除了个别异常的优先级被定死外,其它异常的优先级都是可编程的。
类型编号为1-15的系统异常(注意:没有编号为0的异常),从16开始的外部中断类型。异常和中断列表如下图所示。
Cortex‐M3内核管理中断的模块叫NVIC(嵌套中断控制器), NVIC的中断控制及状态寄存器中,有一个VECTACTIVE位段;另外,还有一个特殊功能寄存器IPSR。在它们二者的里面,都记录了当前正服务异常的编号。
优先级的定义
在CM3中,优先级对于异常来说很关键的,它会影响一个异常是否能被响应,以及何时可以响应。优先级的数值越小,则优先级越高。CM3支持中断嵌套,使得高优先级异常会抢(preempt)低优先级异常。有3个系统异常:复位,NMI以及硬fault,它们有固定的优先级,并且它们的优先级号是负数,从而高于所有其它异常。所有其它异常的优先级则都是可编程的(但不能编程为负数)。
CM_3的每个中断源都使用一个字节管理优先级,原则上,CM3支持3个固定的高优先级和多达256级的可编程优先级,并且支持128级抢占。但是,绝大多数CM3芯片都会精简设计,以致实际上支持的优先级数会更少,如8级,16级,32级等。它们在设计时会裁掉表达优先级的几个低端有效位,以达到减少优先级数的目的。
举例来说,如果只使用了3个位来表达优先级,则优先级配置寄存器的结构会如下图所示。在图中,[4:0]没有被实现,所以读它们总是返回零,写它们则忽略写入的值。因此,对于3个位的情况,能够使用的8个优先级为:0x00(最高),0x20,0x40,0x60,0x80,0xA0,0xC0 以及 0xE0。
如果使用更多的位来表达优先级,则能够使用的值也更多,同时需要的门也更多——带来更多的成本和功耗。CM3允许的最少使用位数为3个位,亦即至少要支持8级优先级。下图给出3个优先级位和4个优先级位的对比如下图所示:
3 位、5 位和 8 位表达优先级时,优先级寄存器的使用情况:
CM3还把256级优先级按位分成高低两段,分别是抢占优先级和亚优先级,NVIC中有一个寄存器是“应用程序中断及复位控制寄存器”,它里面有一个位段名为“优先级组”。该位段的值对每一个优先级可配置的异常都有影响,把其优先级分为两端段:MSB所在的位段(左边的)对应抢占优先级,而LSB所在的位段(右边的)对应亚优先级。抢占优先级和亚优先级的表达,位数与分组位置的关系如下图所示。
应用程序中断及复位控制寄存器(AIRCR)(地址:0xE000_ED00)
抢占优先级决定了抢占行为:当系统正在响应某异常L时,如果来了抢占优先级更高的异常H,则H可以抢占L。亚优先级则处理“内务”:当抢占优先级相同的异常有不止一个悬起时,就优先响应亚优先级最高的异常。这种优先级分组规定:亚优先级至少是1个位。因此抢占优先级最多是7个位,造成了最多只有128级抢占的现象。但是CM3允许从比特7处分组,此时所有的位都表达亚优先级,没有任何位表达抢占优先级,因而所有优先级可编程的异常之间就不会发生抢占——相当于在它们之中除能了CM3的中断嵌套机制。当然还有凌架于规则之上的三个异常:复位,NMI和硬fault。它们无论何时出现,都立即无条件抢占所有优先级可编程的异常。
在计算抢占优先级和亚优先级的有效位数时,必须先求出下列值:
- 芯片实际使用了多少位来表达优先级
- 优先级组是如何划分的。
举个例子,如果只使用 3 个位来表达优先级([7:5]),并且优先级组的值是5(从比特5处分组),则你得到4级抢占优先级,且在每个抢占优先级的内部有2个亚优先级,寄存器优先划分如下图所示。
其可用优先级的具体情况如下图。
请注意:虽然[4:0]未使用,却允许从它们中分组。例如,如果优先级组为1,则所有可用的8个优先级都是抢占优先级,中断寄存器和中断配置值如下图所示。
向量表
当发生了异常并且要响应它时,CM3需要定位其处理例程的入口地址。这些入口地址存储在所谓的“(异常)向量表”中。缺省情况下,CM3认为该表位于零地址处,且各向量占用4字节,因此每个表项占用4字节,如下表所示。
因为地址0处应该存储引导代码,所以它通常是Flash或者是ROM器件,并且它们的值不得在运行时改变。然而,为了动态重分发中断,CM3允许向量表重定位——从其它地址处开始定位各异常向量。这些地址对应的区域可以是代码区,但也可以是RAM区。在RAM区就可以修改向量的入口地址了。为了实现这个功能,NVIC中有一个寄存器,称为“向量表偏移量寄存器”(在地址0xE000_ED08处),通过修改它的值就能定位向量表。但必须注意的是:向量表的起始地址是有要求的:必须先求出系统中共有多少个向量,再把这个数字向上增大到是2的整次幂,而起始地址必须对齐到后者的边界上。例如,如果一共有32个中断,则共有32+16(系统异常)=48 个向量,向上增大到2的整次幂后值为64,因此地址必须能被64*4=256整除,从而合法的起始地址可以是:0x0, 0x100, 0x200等。向量表偏移量寄存器的定义如下表所示。
如果需要动态地更改向量表,则对于任何器件来说,向量表的起始处都必须包含以下向量:
- 主堆栈指针(MSP)的初始值
- 复位向量
- NMI
- 硬 fault 服务例程
后两者也是必需的,因为有可能在引导过程中发生这两种异常。
中断输入及悬起行为
本节开始讨论中断的输入和悬起行为。这也适用于NMI,只是NMI将会立即无条件执行,除了特殊情况:若当前已经在执行NMI服务例程,或者CPU被调试器喊停(halted),或者被一些严重的系统错误锁定(Lock up),则新的NMI请求也将悬起。中断悬起示意图如下图所示:
当中断输入脚被 assert后,该中断就被悬起。即使后来中断源取消了中断请求,已经被标记成悬起的中断也被记录下来。到了系统中它的优先级最高的时候,就会得到响应。但是,如果在某个中断得到响应之前,其悬起状态被清除了(例如,在PRIMASK或FAULTMASK置位的时候软件清除了悬起状态标志),则中断被取消,中断在得到处理器响应之前被清除悬起状态示意图如下图所示。
当某中断的服务例程开始执行时,就称此中断进入了“活跃”状态,并且其悬起位会被硬件自动清除,如下图所示。在一个中断活跃后,直到其服务例程执行完毕,并且返回了,才能对该中断的新请求予以响应。当然,新请求的响应亦是由硬件自动清零悬起标志位。中断服务例程也可以在执行过程中把自己对应的中断重新悬起(使用时要注意避免进入“死循环”)。
如果中断源咬住请求信号不放,该中断就会在其上次服务例程返回后再次被置为悬起状态,示意图如下图所示。
如果某个中断在得到响应之前,其请求信号以若干的脉冲的方式呈现,则被视为只有一次中断请求,多出的请求脉冲全部错失——这是中断请求太快,以致于超出处理器反应限度的情况。示意图如下图所示。
如果在服务例程执行时,中断请求释放了,但是在服务例程返回前又重新被置为有效,则 CM3 会记住此动作,重新悬起该中断。示意图如下图所示。
Fault 类异常
有若干个系统异常专用于fault处理。CM3中的Faults可分为以下几类:
- 总线 faults
- 存储器管理 faults
- 用法 faults
- 硬 fault
总线 Faults
当AHB接口上正在传送数据时,如果回复了一个错误信号(error response),则会产生总
线faults,产生的场合可以是:
- 取指,通常被称作“预取流产”(prefetch abort)
- 数据读/写,通常被称作“数据流产”(data abort)
在CM3中执行如下动作可以触发总线异常:
- 中断处理起始阶段的堆栈PUSH动作。 称为“入栈错误”
- 中断处理收尾阶段的堆栈POP动作。 称为“出栈错误”
- 在处理器启动中断处理序列(sequence)后的向量读取时。这是一种罕见的特殊情况,
被归类为硬 fault。
当上述总线faults发生时(取向量的除外),只要没有同级或更高优先级的异常正在服务,且FAULTMASK=0,就会执行总线fault的服务例程。如果在检测到总线 fault 时还检测到了更高优先级的异常,则先处理后者,而总线fault则被标记成悬起。最后,如果总线fault被除能,或者总线fault发生时正在处理同级或更高优先级异常,则总线fault被迫成为“硬伤”——上访成硬fault,使得最后执行的是硬fault的服务例程(如果当前没有执行 NMI服务例程,则立即执行硬fault服务例程)。如果在硬fault服务例程的执行中又产生了总线 fault,内核将进入锁定状态。
使能总线fault服务例程,需要在NVIC的“系统Handler控制及状态寄存器”中置位BUSFAULTENA位。要注意的是:在使能之前,总线fault服务例程的入口地址必须已经在向量表中配置好,否则就成了作法自毙——程序可能跑飞。
发生了总线fault后,如何找出该 fault 的事故原因呢?NVIC提供了若干个 fault 状态寄存器,其中一个名为“总线 fault 状态寄存器”(BFSR)的。通过它,总线fault服务例程可以确定产生fault的场合:是在数据访问时,在取指时,还是在中断的堆栈操作时。对于精确的总线fault(见下框说明),肇事的指令的地址被压在堆栈中。如果BFSR中的BFARVALID位为 1,还可以找出是在访问哪块存储器时产生该总线fault的——该存储器的地址被放到“总线 fault地址寄存器(BFAR)”中。然而,如果是不精确的总线 fault,就无从定位了。因为在发生fault时,处理器已经在执行肇事指令后,不知又流逝了多少个周期了。
BFSR 寄存器的程序员模型如下所示:是一个8位的寄存器,并且可以使用字传送和字节传送来读取它。如果以字方式访问,地址是0xE000_ED28,并且第2个字节有效;如果以字节方式访问,则地址直接就是0xE000_ED29,如下表所示。
存储器管理 faults
存储器管理faults多与MPU有关,其诱因常常是某次访问触犯了MPU设置的保护策略。另外,某些非法访问,例如,在不可执行的存储器区域试图取指,也会触发一个MemManage fault,而且即使没有MPU也会触发。MemManage faults的常见诱因如下所示:
- 访问了 MPU 设置区域覆盖范围之外的地址
- 往只读 region 写数据
- 用户级下访问了只允许在特权级下访问的地址
在MemManage fault发生后,如果其服务例程是使能的,则执行服务例程。如果同时还发生了其它高优先级异常,则优先处理这些高优先级的异常,MemManage异常被悬起。如果此时处理器已经在处理同级或高优先级异常,或者MemManage fault服务例程被除能,则和总线fault一样:上访成硬fault,最终执行的是硬fault的服务例程。如果硬fault服务例程或NMI服务例程的执行也导致了MemManage fault,那就不可救要了——内核将被锁定。可见,和总线 fault一样,MemManage fault必须被使能才能正常响应。MemManage fault在NVIC“系统 handler控制及状态寄存器”中的使能位是MEMFAULTENA。如果把向量表置于RAM中,应优先建立好MemManage fault服务例程的入口地址。
为了调查MemManage fault的案发现场,NVIC中有一个“存储器管理fault状态寄存器(MFSR)”,它指出导致MemManage fault的原因。如果是因为一个数据访问违例(DACCVIOL位)或是一个取指访问违例(IACCVIOL位),则违例指令的地址已经被压入栈中。如果还有MMARVALID位被置位,则还能进一步查出引发此fault时访问的地址——读 NVIC“存储器管理地址寄存(MMAR)”的值。
MFSR 寄存器的程序员模型如下图所示。是一个8位的寄存器,并且可以使用字传送和字节传送来读取它。并且两种访问方式的地址都是0xE000_ED28,按字访问时第1个字节有效。
用法 faults
用法 faults 发生的场合可以是:
- 执行了未定义的指令
- 执行了协处理器指令(Cortex‐M3不支持协处理器,但是可以通过fault异常机制来使用软件模拟协处理器的功能,从而可以方便地在其它Cortex处理器间移植)
- 尝试进入ARM状态(因为CM3不支持ARM状态,所以用法fault会在切换时产生。软件可以利用此机制来测试某处理器是否支持ARM状态)
- 无效的中断返回(LR中包含了无效/错误的值)
- 使用多重加载/存储指令时,地址没有对齐。
另外,通过设置NVIC的对应控制位,可以在下列场合下也产生用法fault:
- 除数为零
- 任何未对齐的访问
如果用法fault被使能,在发生用法fault时通常会执行其服务例程。但是如果当时还发生了更高优先级的异常,则用法fault被悬起。如果此时处理器已经在处理同级或高优先级异常,或者用法fault服务例程被除能,则和总线fault和 MemManage fault一样:上访成硬fault,最终执行的是硬fault的服务例程。如果硬fault服务例程或NMI服务例程的执行竟然导致了用法fault,那就不可救要了——内核又将被锁定。可见,和总线fault和MemManage fault一样用法fault必须被使能才能正常响应。用法fault在NVIC“系统handler控制及状态寄存器”中的使能位是USGFAULTENA。如果把向量表置于RAM中,应优先建立好用法fault服务例程的入口地址。
为了调查用法fault的案发现场,NVIC中有一个“用法fault状态寄存器(UFSR)”,它指出导致用法fault的原因。在服务例程中,导致用法fault的指令地址被压入堆栈中。UFSR的定义如下图所示。占用了2个字节,可以被按半字访问或是按字访问。按字访问时的地址是0xE000_ED28,高半字有效;按半字访问时的地址是0xE000_ED2A。和其它的FAULT状态寄存器一样,它里面的位可以通过写1来清零。
硬 fault
硬fault是上文讨论的总线fault、存储器管理fault以及用法fault上访的结果。如果这些fault的服务例程无法执行,它们就会成为“硬伤”——上访(escalation)成硬 fault。另外,在取向量(异常处理是对异常向量表的读取)时产生的总线fault也按硬fault处理。在NVIC中有一个硬fault状态寄存器(HFSR),它指出产生硬fault的原因。如果不是由于取向量造成的,则硬fault服务例程必须检查其它的 fault 状态寄存器,以最终决定是谁上访的。
HFSR 的定义如下表所示。
SVC 和 PendSV
SVC(系统服务调用,亦简称系统调用)和 PendSV(可悬起系统调用),它们多用于在操作系统之上的软件开发中。SVC用于产生系统函数的调用请求。例如,操作系统不让用户程序直接访问硬件,而是通过提供一些系统服务函数,用户程序使用SVC发出对系统服务函数的呼叫请求,以这种方法调用它们来间接访问硬件。因此,当用户程序想要控制特定的硬件时,它就会产生一个SVC异常,然后操作系统提供的SVC异常服务例程得到执行,它再调用相关的操作系统函数,后者完成用户程序请求的服务。
这种“提出要求——得到满足”的方式,很好、很强大、很方便、很灵活、很能可持续发展。首先,它使用户程序从控制硬件的繁文缛节中解脱出来,而是由OS负责控制具体的硬件。第二,OS的代码可以经过充分的测试,从而能使系统更加健壮和可靠。第三,它使用户程序无需在特权级下执行,用户程序无需承担因误操作而瘫痪整个系统的风险。第四,通过SVC的机制,还让用户程序变得与硬件无关,因此在开发应用程序时无需了解硬件的操作细节,从而简化了开发的难度和繁琐度,并且使应用程序跨硬件平台移植成为可能。开发应用程序唯一需要知道的就是操作系统提供的应用编程接口(API),并且了解各个请求代号和参数表,然后就可以使用 SVC来提出要求了(事实上,为使用方便,操作系统往往会提供一层封皮,以使系统调用的形式看起来和普通的函数调用一致。各封皮函数会正确使用SVC指令来执行系统调用——译者注)。其实,严格地讲,操作硬件的工作是由设备驱动程序完成的,只是对应用程序来说,它们也是操作系统的一部分。示意图如下图所示。
SVC异常通过执行”SVC”指令来产生。该指令需要一个立即数,充当系统调用代号。SVC异常服务例程稍后会提取出此代号,从而解释本次调用的具体要求,再调用相应的服务函数。
例如,
SVC 0x3 ; 调用 3 号系统服务
在SVC服务例程执行后,上次执行的SVC指令地址可以根据自动入栈的返回地址计算出。找到了SVC指令后,就可以读取该SVC指令的机器码,从机器码中萃取出立即数,就获知了请求执行的功能代号。如果用户程序使用的是PSP,服务例程还需要先执行MRS Rn,PSP指令来获取应用程序的堆栈指针。通过分析LR的值,可以获知在SVC指令执行时,正在使用哪个堆栈。
由CM3 的中断优先级模型可知,不能在SVC服务例程中嵌套使用SVC指令,因为同优先级的异常不能抢占自身。这种作法会产生一个用法fault。同理,在NMI服务例程中也不得使用SVC,否则将触发硬fault。
另一个相关的异常是PendSV(可悬起的系统调用),它和SVC协同使用。一方面,SVC异常是必须立即得到响应的(若因优先级不比当前正处理的高,或是其它原因使之无法立即响应,将上访成硬 fault——译者注),应用程序执行SVC时都是希望所需的请求立即得到响应。另一方面PendSV则不同,它是可以像普通的中断一样被悬起的(不像SVC那样会上访)。OS可以利用它“缓期执行”一个异常——直到其它重要的任务完成后才执行动作。悬起PendSV的方法是:手工往NVIC的PendSV悬起寄存器中写 1。悬起后,如果优先级不够高,则将缓期等待执行。PendSV的典型使用场合是在上下文切换时(在不同任务之间切换)。例如,一个系统中有两个就绪的任务,上下文切换被触发的场合可以是:
- 执行一个系统调用
- 系统滴答定时器(SYSTICK)中断,(轮转调度中需要)
举个简单的例子来辅助理解。假设有这么一个系统,里面有两个就绪的任务,并且通过 SysTick 异常启动上下文切换。如下图所示。
上图是两个任务轮转调度的示意图。但若在产生SysTick异常时正在响应一个中断,则SysTick异常会抢占其ISR。在这种情况下,OS不得执行上下文切换,否则将使中断请求被延迟,而且在真实系统中延迟时间还往往不可预知——任何有一丁点实时要求的系统都决不能容忍这种事。因此,在CM3中也是严禁没商量——如果OS在某中断活跃时尝试切入线程模式,将触犯用法fault 异常。
为解决此问题,早期的OS大多会检测当前是否有中断在活跃中,只有没有任何中断需要响时,才执行上下文切换(切换期间无法响应中断)。然而,这种方法的弊端在于,它可以把任务切换动作拖延很久(因为如果抢占了IRQ,则本次SysTick在执行后不得作上下文切换,只能等待下一次SysTick异常),尤其是当某中断源的频率和SysTick异常的频率比较接近时,会发生“共振”。
现在好了,PendSV来完美解决这个问题了。PendSV异常会自动延迟上下文切换的请求,直到其它的ISR都完成了处理后才放行。为实现这个机制,需要把PendSV编程为最低优先级的异常如果OS检测到某IRQ正在活动并且被SysTick抢占,它将悬起一个PendSV异常,以便缓期执行上下文切换。如下图所示
事件的流水描述如下:
- 任务 A 呼叫 SVC 来请求任务切换(例如,等待某些工作完成)
- OS 接收到请求,做好上下文切换的准备,并且 pend 一个 PendSV 异常。
- 当 CPU 退出 SVC 后,它立即进入 PendSV,从而执行上下文切换。
- 当 PendSV 执行完毕后,将返回到任务 B,同时进入线程模式。
- 发生了一个中断,并且中断服务程序开始执行
- 在 ISR 执行过程中,发生 SysTick 异常,并且抢占了该 ISR。
- OS 执行必要的操作,然后 pend 起 PendSV 异常以作好上下文切换的准备。
- 当 SysTick 退出后,回到先前被抢占的 ISR 中,ISR 继续执行
- ISR 执行完毕并退出后,PendSV 服务例程开始执行,并且在里面执行上下文切换
- 当 PendSV 执行完毕后,回到任务 A,同时系统再次进入线程模式。