我们分析一下下面的原理图,不难看出,对于KEY0-KEY2这样的按钮,只要按下就与GND导通了。所以,我们要检测这个按钮是否按下,就可以读取这个按钮相应的GPIO的电平情况:检测为低电平就是有按钮按下。
HAL_GPIO_ReadPin(GPIOx, GPIO_Pin)
有写就有读嘛,参数就是我们之前学的哪些参数,返回值就是高电平或者低电平(GPIO_PIN_SET/GPIO_PIN_RESET)
那代码的核心思想有了,我们再来想想流程:
(基础三件套以后就不重复了:时钟树(先使能RCC),Debug工具配置,勾选生成.c/.h)
1. 配置GPIO
a) 输入/输出(既然要读它的电平,那么这次就得是配置为输入模式了)
b) 电气属性:
GPIO 速度:GPIO速度上我们不作要求
GPIO上/下拉:上拉。这样可以保证在我们按下按钮之前这个GPIO的电平为高电平。
2. 检测方式:这里我们先用最简单的方式while+if来检测按键按下
/* USER CODE BEGIN WHILE */
while (1)
{
if(GPIO_PIN_RESET == HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin))
{
HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin);
}
/* USER CODE END WHILE */
不过,很多朋友还是发现这样的按钮时不时会失灵,这是为什么呢?
这其实就是因为我们按下按钮的时候产生了抖动
/* USER CODE BEGIN WHILE */
while (1)
{
if(GPIO_PIN_RESET == HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin))
{
HAL_Delay(20);
HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin);
while(GPIO_PIN_RESET == HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin));
HAL_Delay(20);
}
/* USER CODE END WHILE */
这样我们的按钮至少很听话了。
假如我们的按键很多,那么就需要扫描按键。
创建HardWare/key
key.h
//
// Created by Whisky on 2023/1/8.
//
#ifndef HELLOWORLD_KEY_H
#define HELLOWORLD_KEY_H
#include "main.h"
#define KEY2_Pin GPIO_PIN_2
#define KEY2_GPIO_Port GPIOE
#define KEY1_Pin GPIO_PIN_3
#define KEY1_GPIO_Port GPIOE
#define KEY0_Pin GPIO_PIN_4
#define KEY0_GPIO_Port GPIOE
#define KEYWKUP_Pin GPIO_PIN_0
#define KEYWKUP_GPIO_Port GPIOA
#define __KEY_ALL_CLK_ON() do{__HAL_RCC_GPIOA_CLK_ENABLE();__HAL_RCC_GPIOE_CLK_ENABLE();}while(0)
#define KEY0 HAL_GPIO_ReadPin(KEY0_GPIO_Port,KEY0_Pin) //KEY0按键PE4
#define KEY1 HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin) //KEY1按键PE3
#define KEY2 HAL_GPIO_ReadPin(KEY2_GPIO_Port,KEY2_Pin) //KEY2按键PE2
#define WK_UP HAL_GPIO_ReadPin(KEYWKUP_GPIO_Port,KEYWKUP_Pin) //WKUP按键PA0
#define KEY0_PRES 1
#define KEY1_PRES 2
#define KEY2_PRES 3
#define KEYWKUP_PRES 4
void key_init(void);
uint8_t key_scan(uint8_t mode);
#endif //HELLOWORLD_KEY_H
key.c
//
// Created by Whisky on 2023/1/8.
//
#include "key.h"
void key_init(void)
{
GPIO_InitTypeDef GPIO_Initure;
__KEY_ALL_CLK_ON();
GPIO_Initure.Mode=GPIO_MODE_INPUT;
GPIO_Initure.Pull=GPIO_PULLDOWN;
GPIO_Initure.Speed=GPIO_SPEED_FREQ_HIGH;
GPIO_Initure.Pin=KEYWKUP_Pin;
HAL_GPIO_Init(KEYWKUP_GPIO_Port,&GPIO_Initure);
GPIO_Initure.Mode=GPIO_MODE_INPUT;
GPIO_Initure.Pull=GPIO_PULLUP;
GPIO_Initure.Speed=GPIO_SPEED_FREQ_HIGH;
GPIO_Initure.Pin=KEY0_Pin;
HAL_GPIO_Init(KEY0_GPIO_Port,&GPIO_Initure);
GPIO_Initure.Pin=KEY1_Pin;
HAL_GPIO_Init(KEY1_GPIO_Port,&GPIO_Initure);
GPIO_Initure.Pin=KEY2_Pin;
HAL_GPIO_Init(KEY2_GPIO_Port,&GPIO_Initure);
}
//同时按下的优先级 : KEYWKUP > KEY2 > KEY1 > KEY0
//mode : 0 不连续按 当按键按下不放时,只返回第一次按下的值
// 1 连续按 当按键按下不放时,每次调用这个函数都会返回值
uint8_t key_scan(uint8_t mode)
{
static uint8_t key_up=1; //按键松开标志
if(mode==1)key_up=1; //支持连按
if(key_up&&(KEY0==0||KEY1==0||KEY2==0||WK_UP==1))
{
delay_ms(10);
key_up=0;
if(KEY0==0) return KEY0_PRES;
else if(KEY1==0) return KEY1_PRES;
else if(KEY2==0) return KEY2_PRES;
else if(WK_UP==1) return KEYWKUP_PRES;
}else if(KEY0==1&&KEY1==1&&KEY2==1&&WK_UP==0)key_up=1;
return 0; //无按键按下
}
这里主要参考了原子的代码,扫描的时候按键消抖延时也不能太久,不然会出现“读太慢”现象。
这样我们在while
中调用key_sacn
就能实现对多个按键的扫描了。
不过,有的时候,我们需要做一些别的事情,而不能用一直轮询的方式检测按键(即在while
中调用key_sacn
)。毕竟,一直轮询的方式检测按键的电平实在是太过浪费咱们CPU的处理能力,这种事情其实可以交给外部中断来处理。
处理器中的中断:在处理器中,中断是一个过程,即CPU在正常执行程序的过程中,遇到外部/内部的紧急事件需要处理,暂时中止当前程序的执行,转而去为处理紧急的事件,待处理完毕后再返回被打断的程序处继续往下执行。中断在计算机多任务处理,尤其是即时系统中尤为重要。比如uCOS,FreeRTOS等。
意义:中断能提高CPU的效率,同时能对突发事件做出实时处理。实现程序的并行化,实现嵌入式系统进程之间的切换。
那有朋友就要问了,它是怎么实现的呢?
这里不得不提到中断向量表了。
我们打开/Core/Startup 里面的.s文件,这是一个由arm汇编编写的stm32的启动文件,它的工作主要是:
我们主要看一下我们的主角:中断向量表
这里写出了很多当发生了某个事件就要执行对应中断服务函数的清单,这就是中断向量表。
一些非一开始就要用上的中断服务函数,HAL库进行了虚函数处理(__weak函数用于定义变量或者函数,常见于定义函数,在MDK ARM链接时优先链接定义为非weak的函数或变量,如果找不到则再链接weak函数,默认的一般里面也是空函数)
而我们重写了的中断服务函数的处理过程一般就是
进入中断:
(1)保存现场(XPSR、PC、LR、R12、R3、R2、R1 和 R0 这 8 个寄存器,具体含义可以参考arm体系结构)到堆栈里面
(2)一旦入栈结束,ISR(中断服务例程,就是中断服务函数)便开始执行
晚到的中断会重新取ISR地址,但无需再次保存现场(晚到中断机制)
退出中断:
(1)中断前的现场被自动从堆栈中恢复
(2)一旦出栈完成,继续执行被中断打断的指令
出栈的过程也可被打断,使得随时可以响应新的中断,而不再进行现场保存(咬尾中断机制)
这里我们可以知道:中断操作有三部曲:入栈+ISR+出栈
采用了咬尾中断机制和晚到中断机制来避免对一次连续的嵌套中断反复出入栈,浪费时间的操作。
其实刚刚提到中断嵌套的时候就有朋友发现了,中断是有优先级的
STM32的中断有两种优先级:1、抢占式优先级 2、响应式优先级。
抢占式优先级的特点是:具有高抢占式优先级的中断可以在具有低抢占式优先级的中断处理过程中被响应,即中断嵌套。
响应式优先级的特点是:当两个中断源的抢占式优先级相同时,高响应优先级的中断优先被响应,这两个中断将没有嵌套关系。
相同时,如果有低响应优先级中断正在执行,那么高响应优先级的中断要等待已被响应的低响应优先级的中断执行结束后才能得到响应。当一个中断到来后,如果正在处理另一个中断,这个后到来的中断就要等到前一个中断处理完之后才能被处理。如果这两个中断同时到达,则中断控制器根据他们的响应优先级高低来决定先处理哪一个;如果他们的抢占式优先级和响应优先级都相等,则根据他们在中断表中的排位顺序决定先处理哪一个。每一个中断源都必须定义2个优先级。
STM32设置了组(Group)的概念来管理这些优先级。每一个中断都有一个专门的寄存器(Interrupt Priority Registers)来描述该中断的抢占式优先级和响应式优先级。在这个寄存器中STM32使用了4个二进制位来描述优先级。4位的中断优先级可以分成2组,从高位看,前面定义的是抢占式优先级,后面是响应优先级。按照这种分组,4位一共可以分成5组,分别为:
第0组:所有4位用于指定响应式优先级;
第1组:最高1位用于指定抢占式优先级,后面3位用于指定响应式优先级;
第2组:最高2位用于指定抢占式优先级,后面2位用于指定响应式优先级;
第3组:最高3位用于指定抢占式优先级,后面1位用于指定响应式优先级;
第4组:所有4位用于指定抢占式优先级。
这里是HAL库的宏,可以眼熟一下
#define NVIC_PriorityGroup_0 ((uint32_t)0x700) /*!< 0 bits for pre-emption priority
4 bits for subpriority */
#define NVIC_PriorityGroup_1 ((uint32_t)0x600) /*!< 1 bits for pre-emption priority
3 bits for subpriority */
#define NVIC_PriorityGroup_2 ((uint32_t)0x500) /*!< 2 bits for pre-emption priority
2 bits for subpriority */
#define NVIC_PriorityGroup_3 ((uint32_t)0x400) /*!< 3 bits for pre-emption priority
1 bits for subpriority */
#define NVIC_PriorityGroup_4 ((uint32_t)0x300) /*!< 4 bits for pre-emption priority
0 bits for subpriority */
知道了这些概念,我们再用NVIC串一下知识
NVIC的全称是Nested vectoredinterrupt controller,即嵌套向量中断控制器。
功能:
我们直接上手册
外部信号进入经过1的边沿检测电路,检测是否符合(有2和3的上升沿和下降沿选择寄存器决定),产生信号,然后和4软件中断事件寄存器或值,(在这里也就说可以写入软件中断事件寄存器模拟中断和事件),之后产生信号一分为二,看5挂起屏蔽寄存器和7事件屏蔽寄存器,中断挂起,如果事件没有屏蔽,首先会产生事件,进入脉冲发生器。如果6中断屏蔽寄存器也没有屏蔽,则然后进入NVIC。
基本的外部中断就是MCU上GPIO引脚作为输入引脚时,电平变化产生的中断。
GPIO的映射关系图如下所示
那我们检测按键需要干嘛,主要就是检测下降沿或者上升沿嘛,这里笔者就以下降为例。
先配置GPIO
这里我们选择中断,不过我也趁机补充一下中断与事件的区别
事件:是表示检测到某一动作(电平边沿)触发事件发生了。
中断:有某个事件发生并产生中断,并跳转到对应的中断处理程序中。
中断有可能被更优先的中断屏蔽,事件不会。
事件本质上就是一个触发信号,是用来触发特定的外设模块或核心本身(唤醒)。
在NVIC中使能这个EXTI
生成工程
在/Core/Src/stm32xxx_it.c文件中找到我们的中断服务函数
然后我们分析一下这个函数
__HAL_GPIO_EXTI_GET_IT(GPIO_Pin) //检查某个外部中断是否挂起
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin); //清除挂起标志位
HAL_GPIO_EXTI_Callback(GPIO_Pin); //调用回调函数
一个外部中断进去了NVIC之后,它的外部中断挂起标志位就标志它现在正在挂起,等待执行,这个时候我们清除它的标志位,执行回调函数,一次外部中断就完成了。
在HAL库中我们只需要再重写身为虚函数的回调函数就可以了。
这里笔者选择在/Core/Src/stm32xxx_it.c重写回调函数
/* USER CODE BEGIN 1 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if(GPIO_Pin == KEY1_Pin)
{
HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin);
}
}
/* USER CODE END 1 */
不过细心的朋友发现这个还是有一定的抖动,得做消抖于是加入了Delay延时,然后灯就亮一次就再也不灭了。
原因是因为系统时钟设置里给滴答定时器的抢占优先级为15,所以在中断里调用HAL_Delay会卡死。
所以我们需要去调高滴答定时器的抢占优先级,调低中断的抢占优先级。
/* USER CODE BEGIN 1 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if(GPIO_Pin == KEY1_Pin)
{
HAL_Delay(20);
HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin);
while(HAL_GPIO_ReadPin(KEY1_GPIO_Port, KEY1_Pin) == GPIO_PIN_RESET);
HAL_Delay(20);
}
}
/* USER CODE END 1 */
OHHH!这样我们的按键就可以消抖成功了!
不过,细心的朋友可能已经发现,现在只要我们快速连续点击按键,效果还是可能出错。
这是因为HAL库的中断服务函数和我们的需求不符合造成的。HAL库的中断服务函数把清除中断挂起标志位放在中断回调函数的后面,这样确实很棒,因为后面要是有新的中断挂起申请,可以立马响应。不过,我们做按键就不希望这样,我们希望我们上一次的中断回调函数被完整执行了再处理下一次的指令(即先执行回调函数,执行完了再清除中断挂起标志位)。所以,我们修改这两句话的顺序:
void HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin){
if (__HAL_GPIO_EXTI_GET_IT(GPIO_Pin) != 0x00u){
HAL_GPIO_EXTI_Callback(GPIO_Pin);
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin);
}
}
这样,我们就得到了一个比较完善的按键检测了。
不过,在你每次生成代码的时候这段代码都会被覆盖,这还是非常地让人upset。
所以,我们直接模块化,作为我们新的积木代码。
在笔者的HardWare 目录下面增加了exti文件,并且包含了头文件 exti.h,extic.c 文件。
exti.h
//
// Created by Whisky on 2023/1/10.
//
#ifndef HELLOWORLD_EXTI_H
#define HELLOWORLD_EXTI_H
#include "main.h"
void exti_init(void);
#endif //HELLOWORLD_EXTI_H
exti.c
//
// Created by Whisky on 2023/1/10.
//
#include "exti.h"
void exti_init(void)
{
GPIO_InitTypeDef GPIO_Initure;
__HAL_RCC_GPIOA_CLK_ENABLE(); //开启GPIOA时钟
__HAL_RCC_GPIOE_CLK_ENABLE(); //开启GPIOE时钟
GPIO_Initure.Pin=GPIO_PIN_0; //PA0 ,这是正点原子战舰开发板的一个特殊按钮,连接的是VCC,所以为检测高电平有效
GPIO_Initure.Mode=GPIO_MODE_IT_RISING; //上升沿触发
GPIO_Initure.Pull=GPIO_PULLDOWN;
HAL_GPIO_Init(GPIOA,&GPIO_Initure);
GPIO_Initure.Pin=GPIO_PIN_2|GPIO_PIN_3|GPIO_PIN_4; //PE2,3,4
GPIO_Initure.Mode=GPIO_MODE_IT_FALLING; //下降沿触发
GPIO_Initure.Pull=GPIO_PULLUP;
HAL_GPIO_Init(GPIOE,&GPIO_Initure);
//中断线0-PA0
HAL_NVIC_SetPriority(EXTI0_IRQn,2,0); //抢占优先级为2,子优先级为0
HAL_NVIC_EnableIRQ(EXTI0_IRQn); //使能中断线0
//中断线2-PE2
HAL_NVIC_SetPriority(EXTI2_IRQn,2,1); //抢占优先级为2,子优先级为1
HAL_NVIC_EnableIRQ(EXTI2_IRQn); //使能中断线2
//中断线3-PE3
HAL_NVIC_SetPriority(EXTI3_IRQn,2,2); //抢占优先级为2,子优先级为2
HAL_NVIC_EnableIRQ(EXTI3_IRQn); //使能中断线2
//中断线4-PE4
HAL_NVIC_SetPriority(EXTI4_IRQn,2,3); //抢占优先级为2,子优先级为3
HAL_NVIC_EnableIRQ(EXTI4_IRQn); //使能中断线4
}
void KEY_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin)
{
if (__HAL_GPIO_EXTI_GET_IT(GPIO_Pin) != 0x00u){
HAL_GPIO_EXTI_Callback(GPIO_Pin);
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin);
}
}
//中断服务函数
void EXTI0_IRQHandler(void)
{
KEY_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}
void EXTI2_IRQHandler(void)
{
KEY_GPIO_EXTI_IRQHandler(GPIO_PIN_2);
}
void EXTI3_IRQHandler(void)
{
KEY_GPIO_EXTI_IRQHandler(GPIO_PIN_3);
}
void EXTI4_IRQHandler(void)
{
KEY_GPIO_EXTI_IRQHandler(GPIO_PIN_4);
}
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
delay_ms(100); //消抖
switch(GPIO_Pin)
{
case GPIO_PIN_0:
if(WK_UP==1)
{
LED1(0);
}
break;
case GPIO_PIN_2:
if(KEY2==0)
{
LED0(0);
}
break;
case GPIO_PIN_3:
if(KEY1==0)
{
LED0(1);
}
break;
case GPIO_PIN_4:
if(KEY0==0)
{
LED1(1);
}
break;
}
}
刚刚我们发现了中断框图里面有一个软件中断事件寄存器
其实我们按下按钮的动作的事件可以靠软件模拟的,让我们来认识一下这个宏
__HAL_GPIO_EXTI_GENERATE_SWIT(__EXTI_LINE__)
于是我们在main.c里面模拟每一秒钟按下按钮
/* USER CODE BEGIN WHILE */
while (1){
__HAL_GPIO_EXTI_GENERATE_SWIT(KEY1_Pin);
delay_ms(1000);
/* USER CODE END WHILE */
这样,我们就有了一个每一秒钟自动按下的按钮啦。