——STM32 HAL库开发系列<4>
本章主要设计NVIC,Nested Vector Interrupt Controller也就是嵌套向量中断控制器的使用与其原理解构,将会以一种通俗易懂的方式讲述什么是中断以及展示中断这一十分重要的功能在单片机嵌入式开发中的简单实用,最后将以一个按钮的驱动代码作为例子。
本页内容概览
1.中断的基本概念与处理流程
相信各位读者在日常学习与开发工作之中经常看到“中断”这个词以及与其相关的各种概念和各种不明就里的名词,这常常是初学者感到困惑,什么是中断?为什么要使用中断?中断控制器和中断优先级是什么呢?我们将会重点讨论以下几个概念:
- 中断(Interrupt)是什么
- 中断通常如何处理
- 中断如何进行基本的标记管理
- 中断优先级是什么
1.1 中断的基本概念
若各位读者看到这里依然感觉乏味或者一头雾水,我们不妨先举一个生动形象的例子。一个周六的晚上,你按照往常的习惯在刷短视频打发时间,刷短视频你周六晚上这一小段时间内的常态,也就是说假设我们不知道你此时此刻在做什么,那么你正常情况下应当在刷短视频。让你感到愉悦的是,你下单的外卖已经送到了你家单元楼下,外卖骑手因为无法上楼而给你打电话请你下楼取餐。当你电话铃声响起的时候,我们就说“外卖骑手使用电话联系的方式触发了你的中断,要求你做出下楼取餐的处理。”在这个场景中,打电话触发了你从刷短视频这一常态变动到做其他事情的暂态的中断,而你对于这个中断的处理方式是下楼取餐。当你完成了接电话,取餐,用餐等步骤后,你仍然会回到刷短视频的常态当中去。
如上图所示,中断的触发本质上就是“打破程序常态运行流程”的一件事情,例如某个引脚上的电平发生变化,某个定时器完成了一个周期性的计时工作,某个通讯接口收到了外界传来的有价值的复合通讯协议的信号接入……而中断来临时,首先暂停常态程序运行流程,进入中断事件的处理流程,等待中断处理完毕后返回原先程序断点继续运行。
1.2 中断的优先级与标记处理方法
在这个纷繁复杂的世界中,事情的发展和意外的产生往往不如我们设想的那样简单,我们对以上例子做一点小小的改变:在你穿戴完毕准备下楼取外卖的途中,发生了这样两件事:1.突然夜空电闪雷鸣大雨倾盆,而你洗好的明天要穿的所有衣服目前处于露天晾晒状态;2.你的手机突然发出一声提示音,某个你关注的博主更新了ta的沙雕视频;请问大部分人会如何选择呢?显然我们可能会一边收衣服一边告知外卖骑手将餐食放置到指定地点或者请骑手稍作等待,而沙雕视频更新的消息可能全然抛之脑后,或许未来几十分钟内会忽然想起来用作电子榨菜下饭。
在以上的例子中,我们很好的展现了“中断的优先级”这一概念。我们通常将中断的优先级划分为抢占优先级和响应优先级两类,分别对应“打断”和“并发”两类中断处理程序冲突的状况。我们可以用以下文字规则对这两套优先级标准进行表述:
- 当某流程A正在执行时,如果事件X发生并且希望执行B流程,那么比对它们的抢占优先级:如果B优先级不高于A,则A不会被打断,X不会被响应故而B也不会被执行。[此处注意,没有中断的系统程序正常执行的状态可以认为是一种优先级无限低的特殊状态]
- 当某时刻同时有X和Y事件发生,对应流程C和流程D,且二者均通过了抢占优先级的检验,那么检测他们的响应优先级:假设C的响应优先级高于D,那么C会优先被执行,C执行结束后才会考虑D流程是否需要被执行。[此处注意,显然C和D具有同等抢占优先级,否则不会出现需要比对的情况]
我们以上讨论的例子都是极其简单且处理流程显而易见的中断事件,我们现在想象你是一个复杂自动化系统中的一名运维工程师。显然你很有可能在每天的日常维护任务之余还会面对各类突发状况,也就是不同的中断;但是以人类的大脑显然无法记住每一种错误代码对应的原因和处理流程,因此你的上司在你入职时给予了你一本《X系统各类故障代码对照表及处理手册》,我们现在不放想象,你遇到了一个棘手的错误代码,你会如何处理呢?
- 假设当前你正在进行一项维护工作,你显然需要对比二者孰重孰轻
- 我们假设新发生的错误更严重,那么你显然需要暂停掉你正在进行的工作,并且保护施工现场
- 为了使你的同事或者客户暂不使用你维护的部分,你可能需要发出一个通知、公告或者标识
- 你打开了你的《手册》找到了错误代码
- 你翻到了目录中记录的该错误代码的处理流程对应的书页
- 作为一个优秀的运维,你处理好了问题,消除了报错
- 显然你需要取消第三步骤中你发出的任何信息,代表这部分设备重新正常上线工作了
- 拯救完你的老板之后,你还要继续原来的维护以拯救自己的薪水
以上几个步骤就对应着处理中断时对应的具体执行步骤:
- 对比中断的抢占优先级和响应优先级,决定是否处理
- 记录当前程序执行断点,将当前执行语句的地址保存
- 向对应寄存器中写入中断标志位
- 通过嵌套向量控制器查询中断向量表
- 跳转到中断向量表记录的该中断处理程序的对应地址
- 执行中断处理程序
- 执行完毕后在寄存器中清除中断标志位
- 读取步骤一中的断点语句对应地址并且跳转继续执行
2.嵌套向量中断控制器简述
NVIC,Nested Vector Interrupt Controller,中文译名嵌套向量中断控制器,是STM32单片机中专门用于管理中断以及处理中断对应流程的器件。由于NVIC与Arm Cortex-M内核相关接口紧密结合,可以看到下图中红框部分即NVIC在逻辑框图之中的位置,这就保证了NVIC能够操作大部分内核功能,能够快速与系统总线沟通并且维持极低的通信延迟。STM32F1中所有的中断,包括ARM Cortex-M内核带有的中断(或者成为异常:Exception)都通过NVIC进行控制和管理。
这里需要说明的是,在很多ST官方放出的开发文档和数据手册之中,经常把中断使用“异常”进行代称,略微违反常识的是,这种Exception和面向对象编程中代码执行错误抛出的异常并不相同,它仅仅表示“异于常态的事件和状态”,而并不特指执行发生的错误,像是WWDG与IWDG看门狗检测到的程序执行Error或者干脆是硬件错误(Hard Fault)其实都是异常的一种,都是异常的一部分。就连单片机最特殊最重要的信号:复位信号,也就是重启信号,也属于异常体系的一部分而已。
2.1 中断向量表
我们在前文中讲到,中断处理程序并不嵌入在主程序之中,可能往往是存放在单独的部分,在中断产生时,从主程序跳转到中断处理程序,在处理程序结束后再重新跳回主程序继续执行,那么存放中断程序地址的“地址簿”也就是《异常处理手册》的“目录”就是中断向量表,这也是NVIC名字中带有一个Vector的原因,一旦中断触发,系统就会按照表中存在的“向量”跳转执行程序。几个F103系列单片机中重要而常用的中断在中断向量表中的描述如下表所示;
优先度 | 可自定义 | 中断名称 | 中断功能描述 | 中断处理地址 |
-3 | False | Reset | 复位中断,可以让STM32单片机进入重启流程 | 0x00000004 |
-2 | False | NMI | 不可屏蔽中断,此类中断一定要被处理,不可忽略 | 0x00000008 |
-1 | False | HardFault | 硬件错误类型的事件引发的中断 | 0x0000000C |
0 | True | MemManage | 系统内存管理中断 | 0x00000010 |
1 | True | BusFault | 总线数据传输错误引发的中断 | 0x00000014 |
2 | True | UsageFault | 试图执行未知指令或者非法状态触发的中断 | 0x00000018 |
3 | True | SVCall | 通过软件中断(SWI)调用的系统服务引起的中断 | 0x0000002C |
4 | True | DebugMonitor | 调试接口监视器中断 | 0x00000030 |
5 | True | PendSV | 系统服务的挂起请求中断 | 0x00000038 |
6 | True | SysTick | 系统嘀嗒时钟中断,维持整个系统的时序 | 0x0000003C |
7 | True | WWDG | 窗口看门狗中断 | 0x00000040 |
8 | True | PVD | 供电掉电检测中断 | 0x00000044 |
9 | True | Tamper | STM32自带的机箱/壳入侵检测中断 | 0x00000048 |
10 | True | RTC | Real-Time Clock时钟的全局中断 | 0x0000004C |
48 | True | RTC Alarm | Real-Time Clock通过EXTI外部中断线发出的警报/提醒信息 | 0x000000E4 |
11 | True | FLASH | 系统自带的片上Flash全局中断 | 0x00000050 |
12 | True | RCC | 重启选项与时钟树的全局中断 | 0x00000054 |
55 | True | FSMC | 可变静态存储控制器FSMC的全局中断 | 0x00000100 |
56 | True | SDIO | SD数据传输协议全局中断 | 0x00000104 |
18-24 | True | DMA1 | DMA1 1-7通道各自的中断 | 0x000000[6C:84] |
63-66 | True | DMA2 | 63-65对应DMA2 1-3通道中断,4-5通道共用66 | 0x00000[120:12C] |
25 | True | ADC 1-2 | ADC 1-2两个组件对应的中断 | 0x00000088 |
54 | True | ADC3 | ADC 3号组件对应的中断 | 0x000000FC |
13-17 | True | EXTI[0:4] | 外部中断线EXTI从0通道到4通道,每个都有独立中断 | 0x000000[58:68] |
30 | True | EXTI9_5 | 外部中断线EXIT从5通道到9通道,五个通道共用一个相同的中断 | 0x0000009C |
47 | True | EXTI5_10 | 外部中断线EXTI从10通道到15通道,六个通道公用一条相同的中断 | 0x000000E0 |
49 | True | USB_WKUP | 通过EXTI外部中断线传输的USB唤醒信号 | 0x000000E8 |
26 | True | USB_LP_CAN_TX | USB低优先级/CAN通信TX 中断 | 0x0000008C |
27 | True | USB_LP_CAN_RX0 | USB高优先级/CAN通信RX[0] 中断 | 0x00000090 |
28 | True | CAN_RX1 | CAN通信RX[1] 中断 | 0x00000094 |
29 | True | CAN_SCE | CAN通信 SCE信号中断 | 0x00000098 |
38-41 | True | I2C[1:2]_[EV:ER] | 对应I2C总线1号-2号的Event和Error的四个中断 | 0x000000[BC:C8] |
42-43 | True | SPI[1:2] | 对应SPI总线1号-2号的两个中断 | 0x000000[CC:D0] |
58 | True | SPI3 | 对应SPI总线3号的中断 | 0x0000010C |
44-46 | True | USART[1:3] | 通用同步异步收发器1-3号总线中断 | 0x000000[D4:DC] |
59-60 | True | UART[4:5] | 通用异步收发器4-5号总线中断 | 0x00000[110:114] |
31 | True | TIM1_BRK | 高级定时器Timer1的Break中断 | 0x000000A0 |
32 | True | TIM1_UP | 高级定时器Timer1的Update中断 | 0x000000A4 |
33 | True | TIM1_TRG_COM | 高级定时器Timer1的触发器和通讯中断 | 0x000000A8 |
34 | True | TIM1_CC | 高级定时器Timer1的Compare中断 | 0x000000AC |
35-37 | True | TIM[2:4] | 通用定时器Timer 2-4的中断 | 0x000000[B0:B8] |
57 | True | TIM5 | 通用定时器Timer5的中断 | 0x00000108 |
61-62 | True | TIM[6:7] | 基本定时器Timer6与Timer7的中断 | 0x00000[118:11C] |
50 | True | TIM8_BRK | 高级定时器Timer8的Break中断 | 0x000000EC |
51 | True | TIM8_UP | 高级定时器Timer8的Update中断 | 0x000000F0 |
52 | True | TIM8_TRG_COM | 高级定时器Timer8的触发器和通讯中断 | 0x000000F4 |
53 | True | TIM8_CC | 高级定时器Timer8的Compare中断 | 0x000000F8 |
2.2 外部中断/事件管理器EXTI
EXTI,也就是External Interrupt/Event Controller,外部中断/事件管理器,在普通的芯片上拥有19个可以检测电压边沿的中断线,任何一个中断线都可以独立配置和选择类型(中断/事件)并且可以设置触发模式,例如上升沿/下降沿/双侧触发等。19条中断线通过0-15号16个通用GPIO中断线+USB唤醒+RTC提醒+掉电检测器组成,其中我们最常用的部分是对应GPIO的0-15通用中断线。
以上是EXTI控制器的逻辑框图,首先所有的EXTI中断线与管理器都接在APB总线上,通过PCLK2时钟接入接口。当中断从Input Line输入后,首先会经过电平边沿检测电路,这个电路与总接口之间连接有上升沿和下降沿两个触发寄存器,用于检测电平变化信号从而触发中断。
外部输入的中断和软件中断通过一个或门进行连接,统一输入到后期处理元件中,也就是说外部输入中断和软件中断任意发生一个就会触发后期处理部分。后期处理中,通过两个与门逻辑电路出发两个不同的功能:与中断掩码对比决定是否输入到中断请求管理器中进而输入NVIC控制器;与事件掩码对比决定是否输出脉冲信号。
2.3 中断优先级配置与其他部分
STM32中的中断优先级通过一个4-bit代码标定,这4个bit可以划分为抢占优先级和响应优先级,显而易见4个bit的代码有\(2^4=16\)个状态,根据两组优先级的划分可以有如下几种状况:
分组方式[抢占:响应/子段] | 抢占优先级数量 | 响应优先级数量 |
分组[0:4] | 0 | 0-15 |
分组[1:3] | 0-1 | 0-7 |
分组[2:2] | 0-3 | 0-3 |
分组[3:1] | 0-7 | 0-1 |
分组[4:0] | 0-15 | 0 |
NVIC嵌套向量中断控制器还有以下一些功能:
- 系统嘀嗒定时器SysTick具有中断,通过HAL_Delay(uint32_t ms)的延迟函数就是通过这个中断实现
- PA0引脚具有硬件唤醒功能「WakeUp」USB接口中具有软件唤醒功能,全部通过中断实现
- 系统中断可以划分为 Interrupt/Event 两类,其中软件中断SWI拥有20条中断线,硬件中断中F105和F107作为通讯类[Connectivity]产品拥有额外的一条以太网[Ethernet]中断线。
如上图所示,0-15共有16条外部中断线,接入到GPIO中,通过图中的逻辑示意图,我们可以看到假设我们将PA0设置为GPIO外部中断,那么PB0的中断不可能同时与PA0使用,二者触发的是同一中断,因此在进行相关部分开发时,我们通常需要避免两个可能同时触发的重要功能接入同一条中断线。响应的,在处理中断时我们也需要区分中断的来源究竟是哪一组GPIO的哪一个引脚触发的。
3.STM32 HAL库中使用NVIC管理使用中断
STM32 HAL库中对于中断的处理通常要经过如下七个步骤,其中设置优先级,触发条件,使能某个外部中断都可以在STM32CubeMX中图形化的完成,CubeMX会自动生成这部分相关代码。而清除中断标志通常是STM32 HAL驱动库函数已经默认执行的步骤,所以真正需要开发者在代码中详细编写的部分只有两个:判断中断来源和处理中断事务。
- 设置中断抢占优先级和响应优先级
- 设置中断触发条件 事件/上升沿/下降沿/双侧触发
- 使能外部中断
- 中断触发后判断中断来源
- 处理中断
- 中断处理后清除中断出发标志位
3.1 HAL库中相关文件和函数
- startup_stm32fxxx.s 启动文件:内含中断向量表,通常由厂家提供,程序员不需要做出特别的改动,启动文件内部对于每一个中断都预先制定了执行程序,这些程序通常由简单的“清除中断标志位”或者干脆是一个死循环构成,并不实现任何功能,只是初始化中断向量表而已。
- stm32fxxx_it.c 中断服务程序文件:各个中断发生时实际执行的服务程序本体存放于此文件中,这些中断服务程序按照其来源和功能大致可以分为两个部分:
- 第一类是不需要用户做出特殊改动的ARM内核级别的异常:例如重启RESET信号,NMI中断,系统硬件错误对应的Error Handler以及最常见的系统滴答定时器SysTick触发的中断
- 第二类是用户自定义或者授权开启的外设中断,例如SPI通信总线,例如GPIO外部中断线等等,这部分服务程序通常命名为HAL_xxx_IRQHandler(),一旦用户在CubeMX中选中开通相对应的功能,这部分代码就会在Generate Code时注入到本文件中来。
在HAL库函数的处理流程中,中断需要先在主程序中进行初始化后进入等待触发模式,而后中断触发时接入外部中断通用处理函数也就是xxx_IRQHandler(),在这个过程中的相关接口和流程通常是不暴露给主程序的,最后,IRQHandler将会调用中断服务程序回调函数,以下是这些函数的典型组成部分,我们以PA0触发EXTI0外部中断为典型案例进行分析。
//首先是外部中断线的触发函数:这个函数直面硬件层面和内核层面中断触发的具体事件
void EXTI0_IRQHandler(void){
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0); //调用外部中断通用处理函数
}
//外部中断通用处理函数:所有的外部中断都会接入到这个函数进行处理
void HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin){
if(__HAL_GPIO_EXTI_GET_IT(GPIO_Pin)!= RESET){ //检测对应的中断标志是否置位
_HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin); // 清除对应的中断标志
HAL_GPIO_EXTI_Callback(GPIO_Pin); // 调用回调函数执行具体任务
}
}
//中断回调函数:中断服务程序真正有价值的部分,通常以函数指针的形式广泛出现
__weak void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin){
UNUSED(GPIO_Pin); //声明未使用形式参数,避免编译器Warning
//当用户需要改动中断服务程序时,不需要也不推荐改动这个函数,这个函数是__weak定义的占位函数
//用户应当重新编写一个与现有函数声明完全一致的非__weak函数,在编译时进行覆盖
}
3.2 中断服务程序的关键: 回调&弱声明
细心的同学可能注意到了,在以上终端服务程序中,我们可以不改动中断服务程序所在文件的代码,通过在主程序中重写相关函数就可以完成功能,对C语言语法较为了解的同学可能还会有更深一层的疑问,那就是C语言明明不是面向对象的语言但是我们为什么能在这里声明一个和原函数原型一摸一样的函数呢,编译器是怎样实现了这种类似于“函数重载”的功能呢?下面我们来看一张图:
上图中,左侧为主程序,右侧为中断服务程序,我我们可以发现主程序中实际只完成了两个动作,也就是开启中断监听和中断服务程序回调。特别的,Callback函数并不是通过主程序中的顺序执行或者其他什么逻辑结构生效的,而是通过中断服务程序触发执行的,简单而朴素的理解,相当于中断服务程序在处理中断时“回头向前”调用了某个写好的功能,因此我们称之为“回调函数”,也就是Callback Function。
值得一提的是,回调函数并不是在这里独有的写法,事实上在各种异步服务程序,无论什么语言或者什么不同的应用目标和编写框架,这种思想是广泛存在的,例如在Java中我们经常会在控件的服务程序处编写一个匿名内部类或者干脆使用Lambda函数完成回调。在C语言中,由于C语言并不是面向对象的开发语言,所以我们经常使用函数指针这一功能完成回调函数的编写。
最后,回答关于C语言为什么能够从某种程度上实现重载函数部分功能的疑问。各位读者也许观察到在上文中的代码中,回调函数void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)前方有一个标识符__weak,这就代表着这个函数在此处是弱声明,这种弱声明我们可以理解为某种Default式的默认定义,也就是说如果在编译过程中编译器会默认编译这个函数,当连接器linker没有找到其他同名函数的定义,那么系统就将使用这个函数封装可执行程序;如果linker找到了其他同名函数的定义,那么这个weak声明的函数就会被放弃使用,转而使用另一个强声明的函数。
4.以驱动多功能开关为例使用中断
本次例程实验使用的原理图如图所示,主控芯片使用STM32F03RCT6,外接一个有三个等效按键的多功能开关和一颗发光LED,三个物理按键分别接入到PC0,PC1和PC2三个GPIO引脚,LED的阴极接入PB0引脚。物理按键的等效电路中,电阻用于上拉并且限流,电容用于滤波防止误触以及平滑波形。本次实验的目的如下所示:
- LED初始状态为闪烁,频率为LED亮1秒灭一秒。
- 当按下PC1按键时,LED的闪烁使能状态改变,也就是SW2可以启动/暂停LED频闪
- 当按下PC0按键时,LED的闪烁时间间隙缩短100毫秒,最短为0毫秒
- 当按下PC2按键时,LED的闪烁时间间隙增长100毫秒,最长为2000毫秒
4.1 EXTI例程STM32CubeMX工程基础配置
如上图所示,Debug方式选择Serial Wire,高速外部时钟HSE选择Crystal/Ceramic Resonator,系统时钟树主频按照惯例选择72MHz,其余部分不重要。为了驱动发光二极管,使PB0的GPIO功能选择为输出模式。
4.2 EXTI例程STM32CubeMX配置外部中断与优先级
如图所示,首先将PC0 PC1 PC2配置为GPIO_EXTI外部中断线。因为按钮不按下时引脚被上拉电阻拉高,锁定在高电平,按钮按下时引脚接地,故而配置三路EXTI为外部中断模式,使用下降沿触发。同时也不要忘了在NVIC中将对应的三个中断使能,否则这三路外部中断将会仍然被配置为屏蔽模式。
重点:显然在中断处理函数中我们会用到HAL_Delay(uint32_tint ms)作为延迟函数,该函数的实现原理基于SysTick的中断,如果SysTick中断的抢占优先级低于按键中断的抢占优先级,那么系统就会因为SysTick中断无法抢占触发从而锁死在HAL_Delay(uint32_t ms)的死循环中。故而将SysTick中断优先级配置为0,按键中断优先级配置为1,就能够顺利的在Callback中使用延迟函数。这是新手开发者经常犯的错误。
4.3 EXTI例程Callback函数重写与主函数程序
//定义全局变量标志LED点亮和熄灭的时间,默认为1000ms
int interval=1000;
//定义全局变量标志LED闪烁动作是否能够继续,默认为1,相当于bool类型的true
uint8_t enable=1;
//main函数主循环部分:
while(1){
//根据enable的值确定是否进行闪烁
if(enable){
//反转LED相关GPIO状态并且延迟相应时间
HAL_GPIO_TogglePin(GPIOB,GPIO_PIN_0);
HAL_Delay(interval);
}
}
//重写EXTI回调函数,实现中断控制LED灯的功能
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin){
//如果中断来源是PC1,就反转enable状态决定闪烁能否继续
if(GPIO_Pin==GPIO_PIN_1) enable = !enable;
//如果中断来源为PC0,那么将闪烁周期减小100ms
//同时确保interval改动后不小于0
else if(GPIO_Pin==GPIO_PIN_0)
if(interval>0) interval-=100;
//如果中断来源为PC0,那么将闪烁周期增大100ms
//同时确保interval改动后不大于2000
else if(GPIO_Pin==GPIO_PIN_2)
if(interval<2000) interval+=100;
}