26.3.22 ZD-EP63 [中断] 通过中断来控制LED
1 中断的使用
1.1 中断流程
我们先来看看我们最开始讲中断时的一个中断流程图:

我们是从后往前讲的也就是线讲的NVIC再到EXTI,现在我们在正式配置中断时就要从前往后:

其中外设中断不在我们本章讲解范围,后续使用到时会说。
可以看到事实上和我们的外部中断简图是一致的,也就是先设置GPIO的输入模式,再设置EXTI和IO的映射关系将信号传导到EXTI,再经由EXTI传导到NVIC设置中断分组及优先级,并使能中断,最后在CPU处理中断。
关于为什么要经由这么多寄存器层层传导,刷到一个视频说的是对于一个抽象系统,每一层只用负责处理本层接受到的信号并输出对应信号,尽管这里是硬件的具象系统,我认为道理还是一样的,每个寄存器只负责处理自己收到的信号并输出自己相应的信号就行,省去了单个寄存器的过多配置,提高传导效率。
1.2 中断配置
那么我们就能理清我们最终要配置的东西:

首先因为我们这里配置的EXTI线是连接在GPIO上的,所以我们首先还是使能GPIO的时钟并配置GPIO的输入模式,之后使能AFIO/SYSCFG的时钟并设置EXTI与IO的对应关系,再在EXTI设置屏蔽与上升/下降沿触发,再设置NVIC的优先级,最后设计中断服务函数执行中断指令。
看起来很复杂吧,但实际上步骤2~5通过HAL_GPIO_Init函数就能一步到位,也就是GPIO初始化函数(对应到外设.c文件中设置完结构体的每个成员变量后的初始化函数),为了方便后续讲解我们这边就先直接展示HAL_GPIO_Init函数的相关处理部分(GPIO输入模式设置不做展示):
/*--------------------- EXTI Mode Configuration ------------------------*/
/* Configure the External Interrupt or event for the current IO */
if((GPIO_Init->Mode & EXTI_MODE) == EXTI_MODE)
{
/* Enable SYSCFG Clock */
__HAL_RCC_SYSCFG_CLK_ENABLE(); //第三步使能时钟
temp = SYSCFG->EXTICR[position >> 2U];
temp &= ~(0x0FU << (4U * (position & 0x03U)));
temp |= ((uint32_t)(GPIO_GET_INDEX(GPIOx)) << (4U * (position & 0x03U)));
SYSCFG->EXTICR[position >> 2U] = temp; //第四步设置EXTI与IO的对应关系
/* Clear EXTI line configuration */
temp = EXTI->IMR;
temp &= ~((uint32_t)iocurrent);
if((GPIO_Init->Mode & GPIO_MODE_IT) == GPIO_MODE_IT)
{
temp |= iocurrent;
}
EXTI->IMR = temp;
temp = EXTI->EMR;
temp &= ~((uint32_t)iocurrent);
if((GPIO_Init->Mode & GPIO_MODE_EVT) == GPIO_MODE_EVT)
{
temp |= iocurrent;
}
EXTI->EMR = temp;
/* Clear Rising Falling edge configuration */
temp = EXTI->RTSR;
temp &= ~((uint32_t)iocurrent);
if((GPIO_Init->Mode & RISING_EDGE) == RISING_EDGE)
{
temp |= iocurrent;
}
EXTI->RTSR = temp;
temp = EXTI->FTSR;
temp &= ~((uint32_t)iocurrent);
if((GPIO_Init->Mode & FALLING_EDGE) == FALLING_EDGE)
{
temp |= iocurrent;
}
EXTI->FTSR = temp; //第五步设置EXTI屏蔽与上下沿检测选择
}具体实现过程参考函数实现了解就行,我们要知道的是在HAL_GPIO_Init函数中有单独一部分区域用于实现EXTI的中断初始化以及该区域内实现的功能,就不用再单独设置步骤2~5了。关于为什么能进行对应关系的设置,我们知道HAL_GPIO_Init输入的两个形参就是端口号和结构体,而结构体中就设置了引脚号,因此能进行一一对应。
由此我们能将配置步骤通过HAL库整理成如下步骤:

可以看到原先集中在EXTI的设置中心转到了NVIC上(其实就是拓展开来了),首先还是使能GPIO时钟,通过__HAL_RCC_GPIOx_CLK_ENABLE()函数来实现,其次就是GPIO/SYSCFG/EXTI等一系列通过HAL_GPIO_Init进行的设置了,之后就开始设置中断分组、优先级、使能,分别通过HAL_NVIC_SetPriorityGrouping(),HAL_NVIC_SetPriority(),HAL_NVIC_EnableIRQ()函数来实现,其中SetPriorityGrouping已经在HAL_Init()函数中进行了设置,而且只用设置这一次(后续多次设置以最后一次为主),因此要对优先级进行调整也要到HAL_Init()函数中调整(该函数的定义在stm32fxxx_hal.c函数中),最后就是设计中断服务函数了,直接定义EXTIx_IRQHandler()以及待会讲到的中断回调函数HAL_GPIO_EXTI_Callback()即可。
那么中断回调是个什么东西?
1.3 中断回调处理机制

如图,在HAL库中,当系统接受到中断时,会先启用硬件中断服务函数,该函数调用了HAL库中断处理公用函数(EXTIx_IRQHandler,只有0~4,9_5,15_10共七个),在公用函数中又调用了数据处理回调函数(HAL_GPIO_EXTI_Callback(),弱定义函数,后续会提到),也就是在这个函数中我们才真正开始编写中断处理程序。最后再层层返回,将处理程序重新传回硬件中断服务函数。
那么下面我们就正式开始编写这个程序。
2 通过外部中断来控制LED(探索者系列为例)
我们这里采用KEY的外部中断来控制LED,所以我们还是先看到KEY的原理图:

可以看到除KEY_UP外均为接地收集到低电平表示按钮按下,因此要设置上拉电阻,而KEY_UP则设置下拉电阻。
那么我们先对相关函数进行讲解:
2.1 外部中断配置函数
- 使能 IO 时钟:即__HAL_RCC_GPIOx_CLK_ENABLE(),不再过多解释。
- 设置 IO 口模式,触发条件,开启SYSCFG时钟,设置映射关系:均由HAL_GPIO_Init()函数实现,我们设置对应成员即可,这里也不过多解释,详细可以查看 26.3.14 ZD-EP49 [GPIO寄存器与配置步骤] GPIO寄存器介绍及其配置流程
- 配置中断优先级(NVIC),并使能中断:通过HAL_NVIC_SetPriority()和HAL_NVIC_EnableIRQ()实现,其函数声明分别如下:
void HAL_NVIC_SetPriority(IRQn_Type IRQn, uint32_t PreemptPriority, uint32_t SubPriority)HAL_NVIC_SetPriority()函数声明
void HAL_NVIC_EnableIRQ(IRQn_Type IRQn)HAL_NVIC_EnableIRQ()函数声明
其中SetPriority函数有三个形参,分别为IRQn_Type,PreemptPriority,SubPriority即EXTI线编号,抢占优先级和响应优先级,其中IRQn_Type的typedef类型定义为enum,本质为整数类型,因此该结构体内的定义为EXTI线的优先级排序,下面选取一段片段作参考:
typedef enum
{
/****** Cortex-M4 Processor Exceptions Numbers ****************************************************************/
NonMaskableInt_IRQn = -14, /*!< 2 Non Maskable Interrupt */
MemoryManagement_IRQn = -12, /*!< 4 Cortex-M4 Memory Management Interrupt */
BusFault_IRQn = -11, /*!< 5 Cortex-M4 Bus Fault Interrupt */
UsageFault_IRQn = -10, /*!< 6 Cortex-M4 Usage Fault Interrupt */
SVCall_IRQn = -5, /*!< 11 Cortex-M4 SV Call Interrupt */
DebugMonitor_IRQn = -4, /*!< 12 Cortex-M4 Debug Monitor Interrupt */
PendSV_IRQn = -2, /*!< 14 Cortex-M4 Pend SV Interrupt */
SysTick_IRQn = -1, /*!< 15 Cortex-M4 System Tick Interrupt */IRQn_Type结构体定义参考
后面的抢占优先级和响应优先级根据分组的定义进行相应设置。如分组2可设置范围为抢占优先级4bit,响应优先级4bit。
而EnableIRQn的形参就只有IRQn一个。
- 编写中断服务函数:每开启一个中断,就必须编写其对应的中断服务函数,否则将导致死机(CPU 将找不到中断服务函数)。中断服务函数接口(即函数声明)厂家已经在 startup_stm32f407xx.s 的中断向量表中写好了:
DCD EXTI0_IRQHandler ; EXTI Line0
DCD EXTI1_IRQHandler ; EXTI Line1
DCD EXTI2_IRQHandler ; EXTI Line2
DCD EXTI3_IRQHandler ; EXTI Line3
DCD EXTI4_IRQHandler ; EXTI Line4
...
DCD EXTI9_5_IRQHandler ; External Line[9:5]s
...
DCD EXTI15_10_IRQHandler ; External Line[15:10]s中断向量表中有关EXTIx的声明
可以看到我们能够编写的中断服务函数有如上七个,而非一个EXTI线对应一个中断服务函数。
我们在中断服务函数中编写的内容只有两个:中断通用入口函数HAL_GPIO_EXTI_IRQHandler()函数和清除中断挂起标志函数__HAL_GPIO_EXTI_CLEAR_IT()函数,如下所示:
void KEY0_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(KEY0_GPIO_PIN); /* 调用中断处理公用函数 清除KEY0所在中断线 的中断标志位 */
__HAL_GPIO_EXTI_CLEAR_IT(KEY0_GPIO_PIN); /* HAL库默认先清中断再处理回调,退出时再清一次中断,避免按键抖动误触发 */
}一个中断服务函数的定义内容示意,其中KEY0_IRQHandler已宏定义为EXTI4_IRQHandler
而中断通用入口函数HAL_GPIO_EXTI_IRQHandler()定义如下:
void HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin)
{
/* EXTI line interrupt detected */
if(__HAL_GPIO_EXTI_GET_IT(GPIO_Pin) != RESET)
{
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin);
HAL_GPIO_EXTI_Callback(GPIO_Pin);
}
}HAL_GPIO_EXTI_IRQHandler()函数定义
其形参为对应要操作的Pin引脚也就是对应的EXTI线,首先他会通过__HAL_GPIO_EXTI_GET_IT(GPIO_Pin)函数检测该EXTI线的状态是否为挂起状态,如果是就先通过__HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin)将挂起状态清除,再调用HAL_GPIO_EXTI_Callback(GPIO_Pin)执行中断程序。
回到中断服务函数,我们发现这里还有一个__HAL_GPIO_EXTI_CLEAR_IT(KEY0_GPIO_Pin),其目的就是避免按键抖动导致中断再次挂起。
- 编写中断处理回调函数:我们在中断服务函数中的主要目的其实就是调用中断回调函数HAL_GPIO_EXTI_Callback(),其原函数定义如下:
__weak void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
/* Prevent unused argument(s) compilation warning */
UNUSED(GPIO_Pin);
/* NOTE: This function Should not be modified, when the callback is needed,
the HAL_GPIO_EXTI_Callback could be implemented in the user file
*/
}HAL_GPIO_EXTI_Callback()函数定义
可以看到它实际上是一个弱定义函数,要我们重新对它定义来执行我们想要的中断程序。
2.2 外部中断配置
由此我们可以开始正式对外部中断进行配置。
首先还是先添加板级驱动文件exti.c和exti.h在BSP/EXTI目录下,这里不过多赘述。
下面我们直接看到exti.h文件:
#ifndef __EXTI_H
#define __EXTI_H
#include "./SYSTEM/sys/sys.h"
#define KEY_D_GPIO_CLK_ENABLE() do{__HAL_RCC_GPIOE_CLK_ENABLE();}while(0)
#define KEY_U_GPIO_CLK_ENABLE() do{__HAL_RCC_GPIOA_CLK_ENABLE();}while(0)
#define KEY0_GPIO_PORT GPIOE
#define KEY0_GPIO_PIN GPIO_PIN_4
#define KEY0_IRQn EXTI4_IRQn
#define KEY0_IRQHandler EXTI4_IRQHandler
#define KEY1_GPIO_PORT GPIOE
#define KEY1_GPIO_PIN GPIO_PIN_3
#define KEY1_IRQn EXTI3_IRQn
#define KEY1_IRQHandler EXTI3_IRQHandler
#define KEY2_GPIO_PORT GPIOE
#define KEY2_GPIO_PIN GPIO_PIN_2
#define KEY2_IRQn EXTI2_IRQn
#define KEY2_IRQHandler EXTI2_IRQHandler
#define KEY_UP_GPIO_PORT GPIOA
#define KEY_UP_GPIO_PIN GPIO_PIN_0
#define KEY_UP_IRQn EXTI0_IRQn
#define KEY_UP_IRQHandler EXTI0_IRQHandler
void exti_init(void); /* 外部中断初始化 */
#endifexti.h定义内容
可以看到因为我们这里配置的是KEY的中断操作,所以整体配置内容和key.h大差不差,多的内容是IRQn和IRQHandler的定义,实际上也是可有可无的,只是做了一个辨识度区分。
我们主要看到exti.c文件:
#include "./SYSTEM/delay/delay.h"
#include "./BSP/LED/led.h"
#include "./BSP/BEEP/beep.h"
#include "./BSP/KEY/key.h"
#include "./BSP/EXTI/exti.h"
void exti_init(void)
{
GPIO_InitTypeDef GPIO_EXTI;
key_init(); /* KEY初始化函数中已经配置了GPIO为输入模式,这里只需要修改为中断模式即可,
此外KEY初始化中已使能时钟,不用重复使能 */
GPIO_EXTI.Pin = KEY0_GPIO_PIN;
GPIO_EXTI.Mode = GPIO_MODE_IT_FALLING; /* 下降沿触发 */
GPIO_EXTI.Pull = GPIO_PULLUP;
HAL_GPIO_Init(KEY0_GPIO_PORT, &GPIO_EXTI); /* KEY0配置为下降沿触发中断 */
GPIO_EXTI.Pin = KEY1_GPIO_PIN;
HAL_GPIO_Init(KEY1_GPIO_PORT, &GPIO_EXTI); /* KEY1配置为下降沿触发中断 */
GPIO_EXTI.Pin = KEY2_GPIO_PIN;
HAL_GPIO_Init(KEY2_GPIO_PORT, &GPIO_EXTI); /* KEY2配置为下降沿触发中断 */
GPIO_EXTI.Pin = KEY_UP_GPIO_PIN;
GPIO_EXTI.Mode = GPIO_MODE_IT_RISING; /* 上升沿触发 */
GPIO_EXTI.Pull = GPIO_PULLDOWN;
HAL_GPIO_Init(KEY_UP_GPIO_PORT, &GPIO_EXTI); /* KEY_UP配置为上升沿触发中断 */
HAL_NVIC_SetPriority(KEY0_IRQn, 0, 2); /* 抢占0,子优先级2 */
HAL_NVIC_EnableIRQ(KEY0_IRQn); /* 使能中断线4 */
HAL_NVIC_SetPriority(KEY1_IRQn, 1, 2); /* 抢占1,子优先级2 */
HAL_NVIC_EnableIRQ(KEY1_IRQn); /* 使能中断线3 */
HAL_NVIC_SetPriority(KEY2_IRQn, 2, 2); /* 抢占2,子优先级2 */
HAL_NVIC_EnableIRQ(KEY2_IRQn); /* 使能中断线2 */
HAL_NVIC_SetPriority(KEY_UP_IRQn, 3, 2); /* 抢占3,子优先级2 */
HAL_NVIC_EnableIRQ(KEY_UP_IRQn); /* 使能中断线0 */
}
void KEY0_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(KEY0_GPIO_PIN); /* 调用中断处理公用函数 清除KEY0所在中断线 的中断标志位 */
__HAL_GPIO_EXTI_CLEAR_IT(KEY0_GPIO_PIN); /* HAL库默认先清中断再处理回调,退出时再清一次中断,避免按键抖动误触发 */
}
void KEY1_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(KEY1_GPIO_PIN); /* 调用中断处理公用函数 清除KEY1所在中断线 的中断标志位,中断下半部在HAL_GPIO_EXTI_Callback执行 */
__HAL_GPIO_EXTI_CLEAR_IT(KEY1_GPIO_PIN); /* HAL库默认先清中断再处理回调,退出时再清一次中断,避免按键抖动误触发 */
}
void KEY2_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(KEY2_GPIO_PIN); /* 调用中断处理公用函数 清除KEY2所在中断线 的中断标志位,中断下半部在HAL_GPIO_EXTI_Callback执行 */
__HAL_GPIO_EXTI_CLEAR_IT(KEY2_GPIO_PIN); /* HAL库默认先清中断再处理回调,退出时再清一次中断,避免按键抖动误触发 */
}
void KEY_UP_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(KEY_UP_GPIO_PIN); /* 调用中断处理公用函数 清除KEY_UP所在中断线 的中断标志位,中断下半部在HAL_GPIO_EXTI_Callback执行 */
__HAL_GPIO_EXTI_CLEAR_IT(KEY_UP_GPIO_PIN); /* HAL库默认先清中断再处理回调,退出时再清一次中断,避免按键抖动误触发 */
}
...exti.c文件内容(上部分)
看起来很复杂吧,我也觉得。。
首先看到exti_init(void)函数,实际上和key_init函数大差不差,都是设置GPIO结构体成员并进行GPIO初始化,而我们这里的成员设置不同的点在于我们对引脚模式的设置为GPIO_MODE_IT_XXXX,这里的IT就是Interrupt的缩写,对应的XXX有FALLING,RISING和RISING_FALLING,对应下降/上升/双边沿触发。需要注意的是这里我们直接调用了key_init()函数对key的端口进行了初始化,因此不用再进行时钟使能操作。
在进行完HAL_GPIO_Init后我们就要对NVIC进行配置了,也就是SetPriority和EnableIRQ,根据前面的函数介绍按顺序配置即可,这里不再过多解释。由此就完成了exti_init的操作。
下面就开始对中断服务函数EXTIx_Handler进行定义,我们前面已经对Handler的原声明函数进行了宏定义,这里直接使用宏定义后的函数即可,每个Handler进行的操作正如我们前面说的,就是调用中断通用入口函数HAL_GPIO_EXTI_IRQHandler()和清除中断挂起标志函数__HAL_GPIO_EXTI_CLEAR_IT(),每个EXTI线都定义一次函数中断服务函数后我们就可以开始编写中断处理回调函数了。
在编写之前我们先理清一遍我们要做什么:

我们的主程序(左边部分)其实就是一直在循环延时一秒的这个操作,并在这个过程中处理中断程序,而右边部分就是我们的中断处理部分,逻辑其实和我们处理KEY按钮事件的程序差不多,都是在触发中断(按钮事件)后再读取一遍中断源(具体按钮状态)并执行对应操作最后清除中断标志(按钮状态),这一套逻辑本质上还是通用的,因此我们就能得到:
...
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
delay_ms(20); /* 消抖 */
switch(GPIO_Pin)
{
case KEY0_GPIO_PIN:
if (KEY0 == 0)
{
LED0_Toggle(); /* LED0 状态取反 */
}
break;
case KEY1_GPIO_PIN:
if (KEY1 == 0)
{
LED1_Toggle(); /* LED1 状态取反 */
}
break;
case KEY2_GPIO_PIN:
if (KEY2 == 0)
{
LED1_Toggle(); /* LED1 状态取反 */
LED0_Toggle(); /* LED0 状态取反 */
}
break;
case KEY_UP_GPIO_PIN:
if (KEY_UP == 1)
{
BEEP_Toggle(); /* 蜂鸣器状态取反 */
}
break;
default : break;
}
}exti.c文件内容(下部分)
逻辑还是很好理解的,就不过多解释了。
最后在main.c中加上我们的exti_init(),并在主循环中一直处理我们的延时函数就实现了我们目的。
3 拓展?
听完这一段课我只觉得云里雾里,最后的编程实战部分我也觉得,这个实例完全能通过KEY的直接操控来实现,没必要通过中断,尽管我知道这只是用来举例中断到底能干嘛,想来想去我就想到一个使用场景:通过中断在每次按下按钮后都执行一次蜂鸣器的响灭。虽然最后的效果不尽人意,但是骡子是马总得拿出来溜溜。
我以上一次程序(通过KEY控制LED)为基础,加上EXTI相关文件,开始着手实现我的想法。
首先参考正点原子给的HAL_GPIO_EXTI_Callback()函数,我最先的想法也是先消抖,然后只要检测到任意一个按钮按下就执行蜂鸣器操作,于是有了第一版的想法实现:
delay_ms(10); //消抖
if(GPIO_Pin == KEY0_GPIO_PIN || GPIO_Pin == KEY1_GPIO_PIN || GPIO_Pin == KEY2_GPIO_PIN || GPIO_Pin == KEY_UP_GPIO_PIN)
{
if((KEY0 == 0) || (KEY1 == 0) || (KEY2 == 0) || (KEY_UP == 1))
{
BEEP_Toggle();
delay_ms(50);
BEEP_Toggle();
}
}第一版想法
可以看到就是非常的简单粗暴,直接把对LED的控制改成BEEP就是了,但是问题是,这个BEEP的延迟加上消抖的延迟,导致蜂鸣器的响应和LED灯的控制非常不协调。
其实后续经过减断蜂鸣器的延迟时间的处理程序已经非常可用了,这里就直接展示后面用AI优化的第二版吧:
static uint32_t key_last_tick[4] = {0, 0, 0, 0};
const uint32_t debounce_ms = 25;
uint32_t now = HAL_GetTick();
uint8_t key_index;
switch (GPIO_Pin)
{
case KEY0_GPIO_PIN: key_index = 0; break;
case KEY1_GPIO_PIN: key_index = 1; break;
case KEY2_GPIO_PIN: key_index = 2; break;
case KEY_UP_GPIO_PIN: key_index = 3; break;
default: return;
}
if ((now - key_last_tick[key_index]) < debounce_ms)
{
return;
}
key_last_tick[key_index] = now;
/* 先蜂鸣,再执行按键动作 */
BEEP_Toggle();
delay_ms(50);
BEEP_Toggle();AI优化的第二版
这一版的逻辑就是通过获取时间计算差值来代替原先的消抖操作,优点就是省去了消抖用的10ms,并且能调节消抖的时长范围,能够调整按钮的响应速度。
但是如上操作都在HAL_GPIO_EXTI_Callback()函数中引入了延时操作,这里是非常不推荐的,因为延时会导致其他程序一直处于挂起/暂停状态,会影响程序的响应速度,这里因为只用于驱动LED灯,因此延迟范围还算可以接受。当然如果要优化了话,其实可以让中断程序只翻转一次BEPP的电平或者直接置1,结合AI优化的版本可以做到中断事件无延迟,再在main函数中结束灯光操作后再翻转一次BEEP的电平或置0,这样操作后响应速度会大大提升,但问题就是操作比较麻烦,而且对连按模式的适配可能不是很好,总之能实现这个想法也是不错的,讲的不是很清楚能理解就行。
4 总结
这个中断真的搞得我脑壳痛。