ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32四按键EXTI中断检测:从GPIO映射到回调防抖的完整实现

STM32四按键EXTI中断检测:从GPIO映射到回调防抖的完整实现 简介面向嵌入式开发者的按键中断实战练习包用四个独立按键控制四颗LED点亮完整演示外部中断配置、中断服务程序编写以及按键消抖处理。压缩包共13个文件以C源码和Makefile为核心另含编译生成的模块对象、测试程序及备份配置整包仅17KB便于直接在板级工程中移植也适合分析嵌入式模块化编译流程。已有260人学习适合正在熟悉微控制器中断机制、GPIO输入输出和按键消抖的初学者。透过源码可梳理电平触发与边沿触发的差异掌握中断向量表的设置方法并理解如何在中断处理中准确识别按键号、切换LED状态同时还能看到初始化I/O口、配置触发方式以及中断使能等完整代码为后续实现矩阵键盘扫描、组合按键或多级菜单交互奠定基础。1. 把 interrupt_key 拆开看四个按键的中断检测在做什么拿到一个名为 interrupt_key 的工程包先别急着解压找代码值得先想清楚这个标题要解决的问题四个独立按键每个按键按下都要产生一次中断而不是让 CPU 一直在 while 里读引脚电平。做过轮询的人都有体会反复读 GPIO 不仅占 CPU 时间还容易错过毫秒级的抖动边缘导致一次按键被记成两次。按键中断的价值在于平时 CPU 可以该干嘛干嘛按键按下时由 EXTI外部中断把任务打断再通过回调函数把「哪个键、什么事件」交出去。这条路看起来不复杂但引脚映射、触发沿、防抖位置、回调里的动作取舍每一步都有容易翻车的地方。下面的内容就从硬件接法开始一路把四个按键的中断链路讲完整。2. 四个按键的硬件接法与 EXTI 引脚映射2.1 按键接法决定触发电平上拉输入还是下拉输入四个按键的硬件接法直接决定 EXTI 该配上升沿还是下降沿。最常见的做法是把按键一端接 GND另一端接到 MCU 的 GPIO同时在 GPIO 上使能内部上拉电阻。这样常态下引脚读到高电平按下时引脚被拉到低电平对应的是下降沿触发。接法常态电平按下电平EXTI 触发沿注意事项GPIO 内部上拉 按键到 GND高低下降沿最常用按键接 GND 方便布线GPIO 内部下拉 按键到 VCC低高上升沿按键端接电源注意电压匹配外部上拉电阻 按键到 GND高低下降沿电阻选 4.7k10k增强抗干扰实际做产品时我一般优先选第一种。原因不只是布线顺手还因为 MCU 内部上拉电阻的阻值通常在 30k50k 之间本身就有一定的限流和抗静电作用外部只要再加一个小电容到地就能把按键线上的高频噪声压掉一部分。如果你选了下拉接 VCC按键线直接暴露在电源上走线稍长就容易在按下瞬间引入毛刺。// 按键硬件接法示例KEY1 一端接 PA0另一端接 GND GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_0; GPIO_InitStruct.Mode GPIO_MODE_IT_FALLING; // 下降沿触发外部中断 GPIO_InitStruct.Pull GPIO_PULLUP; // 内部上拉常态为高 HAL_GPIO_Init(GPIOA, GPIO_InitStruct);这段配置里Mode 的取值是整个按键中断的关键。GPIO_MODE_IT_FALLING表示把该引脚配成外部中断输入并且在下降沿产生中断请求。HAL 库同时还有GPIO_MODE_IT_RISING和GPIO_MODE_IT_RISING_FALLING两种选择对应上升沿和双边沿。按键场景通常只用下降沿因为按下动作是明确的电平变化双边沿会额外在释放时再触发一次需要你额外做按下和释放的区分逻辑上多一层判断。Pull 参数在上拉接法下必须配成GPIO_PULLUP否则引脚悬空时电平不确定按键没按也会随机触发中断。2.2 四个按键的引脚选择先避开 EXTI Line 冲突STM32 的 EXTI 不是每个引脚独立一条中断线的它按端口序号共享 line。PA0、PB0、PC0 都挂在 EXTI0 上PA1、PB1、PC1 挂在 EXTI1 上以此类推。这意味着同一个 line 上如果接了多个按键中断发生时你无法从 line 号判断是哪个引脚触发的必须去读 IO 状态再分辨这违背了「中断里尽量少做判断」的原则。四个按键要选四个不同 line 的引脚。以 STM32F103 为例我常用的组合是 PA0、PE2、PE4、PE5分别对应 EXTI0、EXTI2、EXTI4、EXTI5。其中 PA0 和 PA1 自带 WAKEUP 功能很多板子把它们默认接在用户按键上拿到 interrupt_key 这类工程时可以先看看原理图确认手上这块板子具体把按键接在哪几个端口。按键推荐引脚EXTI Line对应 IRQnKEY1PA0EXTI0EXTI0_IRQnKEY2PE2EXTI2EXTI2_IRQnKEY3PE4EXTI4EXTI4_IRQnKEY4PE5EXTI5EXTI9_5_IRQn注意 EXTI5 到 EXTI9 共用同一个中断服务函数EXTI9_5_IRQHandler所以 KEY4 和后面若要加的 KEY5、KEY6 都会进同一个 Handler再在函数里用HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_5)这类调用去区分具体引脚。选引脚时除了避开同 line还要顺便看一眼该引脚是否被其他外设占用比如 PE4 和 PE5 在 F103 上常被 FSMC 的数据线复用如果你的板子跑了 LCD这两个脚可能已经被占掉了那就换 PE3、PE6 之类的空闲脚。2.3 同一 IRQ 里挂多个按键时的写法四个按键分布在三个不同的 IRQn 上写中断服务函数时会有三种形态。PA0 单独走EXTI0_IRQHandlerPE2 和 PE4 各自走EXTI2_IRQHandler和EXTI4_IRQHandlerPE5 走EXTI9_5_IRQHandler并在这个函数里用参数区分。HAL 库在所有 Handler 里都只需要调用同一个函数它会自动清中断标志并分发到回调。void EXTI0_IRQHandler(void) { HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0); // KEY1 } void EXTI9_5_IRQHandler(void) { HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_5); // KEY4 }这里HAL_GPIO_EXTI_IRQHandler的参数是引发中断的引脚号它会把这次中断的 pin 值透传给后面的回调函数。每次 Handler 执行完库里会自动把 EXTI 对应的 pending 位清掉不需要手动操作。需要留神的是如果你在回调里粘贴了一段HAL_GPIO_EXTI_Callback而这段代码判断了多个 pin那要把所有涉及的 Handler 都补上漏掉任何一个按键中断就会「按了没反应」。3. 按键中断初始化GPIO、EXTI、NVIC 三段配置3.1 GPIO 输入模式下的三个关键参数按键中断的初始化分为三层GPIO 配置、EXTI 配置、NVIC 配置。很多人在 GPIO 配置阶段就把 Speed 填进去其实对输入模式没有任何作用速度项只影响输出引脚的翻转速率用在按键上反而容易误导排查思路。按键的 GPIO 只需要关注 Pin、Mode、Pull 三个参数前面已经列出了逐个的含义这里再补一个容易看漏的信息Mode 一旦配成GPIO_MODE_IT_FALLING这类带 IT 的取值HAL 库内部会同时完成 GPIO 复用映射和 EXTI 的 line 使能你不需要再单独调用某个 EXTI 初始化函数。这就是 HAL 库把 EXTI 相关的寄存器操作封装进了 GPIO 配置里的结果。如果你在用标准库而非 HAL 库对应的配置是分开的两步先GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN配上拉输入再调GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0)把 PA0 映射到 EXTI0然后填EXTI_InitStructure.EXTI_Trigger EXTI_Trigger_Falling。HAL 库把这两步合并了理解底层的人迁移起来没有障碍新手却容易误以为 EXTI 不配也能触发这个观念要纠正过来。3.2 EXTI 触发方式与 NVIC 优先级参数NVIC 的作用是告诉内核外部中断来了之后应该用什么样的优先级去响应。每个按键对应一个 IRQn四个键之间可以设置一样的抢占优先级因为同时按下的概率极小更重要的是让外部中断整体抢占优先级高于主循环里那些非实时任务。以下是按键场景常用的 NVIC 参数参数推荐值说明IRQnEXTI0_IRQn / EXTI2_IRQn / EXTI4_IRQn / EXTI9_5_IRQn与按键所在 line 对应PreemptionPriority1抢占优先级数字越小越优先SubPriority0子优先级同抢占优先级之间使用EnableIRQ使能遗漏此步则中断永不触发HAL_NVIC_SetPriority(EXTI0_IRQn, 1, 0); HAL_NVIC_EnableIRQ(EXTI0_IRQn); HAL_NVIC_SetPriority(EXTI9_5_IRQn, 1, 0); HAL_NVIC_EnableIRQ(EXTI9_5_IRQn);优先级的数值含义取决于你在HAL_NVIC_SetPriorityGrouping里设定的分组方式。默认分组通常是抢占优先级占 4 位、子优先级占 0 位这种情况下HAL_NVIC_SetPriority(EXTI0_IRQn, 1, 0)的第二个参数 1 就是绝对的抢占优先级别。如果工程里同时用到了定时器中断和串口中断建议给按键中断留一个比串口更高、比系统滴答更低的档位这样既不会让按键被串口大量收发数据饿死也不会因为按键频繁打断HAL_Delay导致别的模块时序漂移。3.3 中断服务函数和回调函数的分工HAL 库把中断处理拆成了两段。上半段是EXTIx_IRQHandler它面向硬件负责把中断标志位读出来、清掉、然后调用底层的公共处理函数下半段是HAL_GPIO_EXTI_Callback它面向业务把具体引脚号交给用户。Callback 默认是一个弱定义的空函数你只需要在自己的源文件里重写它不必修改 HAL 库代码。void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin GPIO_PIN_0) { // KEY1 的动作放这里越短越好 key_flag | 0x01; } else if (GPIO_Pin GPIO_PIN_4) { key_flag | 0x04; } }这里有个隐性约定回调里的参数GPIO_Pin表示本次触发的中断源不表示当前电平。所以不能用它去区分按下还是释放那取决于你配置的触发沿。下降沿触发时回调被调用的那一刻引脚已经被拉低你在回调里读引脚也读到低但那是结果不是原因。四个按键想要区分按下和释放要么配双边沿触发再在回调里读电平判断当前状态要么就只关心按下事件让释放动作由后续的轮询逻辑去收尾。4. 中断回调里的实战处理防抖、标志位与动作分发4.1 回调里该做什么、不该做什么初学者最容易犯的错误是把整个按键处理逻辑都塞进回调在HAL_GPIO_EXTI_Callback里延时、翻转 LED、处理菜单切换。后果是中断处理时间被无限拉长其他中断进不来主程序也卡住。按键中断的正确模型是事件通知 延迟处理回调只负责记录「哪个键被按了」这个事实真正的动作放到主循环里执行。操作是否适合放回调原因置标志位适合耗时极短可重入读取当前引脚电平确认适合一条读操作判断抖动HAL_Delay 延时不适合阻塞中断引发优先级反转printf 串口打印不适合串口发送时间不可控修改菜单状态机不适合状态切换往往需要多步操作中断回调里最多做一个动作读取按键当前电平确认它确实还是低电平然后置标志位。如果按下抖动导致第一次触发时电平还没稳定这次确认能过滤掉大部分毛刺比在外面套一层延时可靠得多。特别注意不要在回调里调用任何带_Delay名字的函数因为HAL_Delay的实现依赖 SysTick 中断若 SysTick 的优先级低于当前外部中断延时调用会永远卡住。4.2 用标志位把四个按键的事件交还给主循环四个按键对应四个事件最直观的表示方式是每个按键占一个位。按下 KEY1 置 bit0按下 KEY2 置 bit1类推到 KEY4 就是 bit3。主循环每次遍历这几个位处理完了立刻清除避免同一个按键事件被消费两次。volatile uint8_t key_flag 0; // 回调中每组按键只做一件事置位 if (GPIO_Pin GPIO_PIN_0) { key_flag | (1 0); // KEY1 按下 } else if (GPIO_Pin GPIO_PIN_2) { key_flag | (1 1); // KEY2 按下 } // 主循环中逐个消费标志位 while (1) { if (key_flag (1 0)) { key_flag ~(1 0); // 清标志后再执行动作 key_action(KEY1_PRESS); // 具体业务切页面、调音量等 } if (key_flag (1 1)) { key_flag ~(1 1); key_action(KEY2_PRESS); } }key_flag用volatile修饰是必要的因为它会在中断上下文被修改而主循环读取它时编译器如果把它缓存到寄存器里就可能读不到最新值。清标志位的时机也有讲究建议先清位再执行key_action而不是执行完再清。如果key_action内部阻塞了中断里新来的按键事件就能通过位判断再次置位不会丢事件反过来先执行后清位可能把执行期间新按下的同键事件抹掉。4.3 防抖放哪一层延时读回与定时器两种做法机械按键的弹片在按下的瞬间会连续开合几次时长在 5ms20ms 不等这期间会触发多次下降沿中断。处理抖动有三种层次的做法从简单到可靠排列。第一种是降频法在回调里读一次引脚读到低电平后启动一个软件延时比如 20ms再读一次引脚如果仍然为低才认为按键有效。这种方法实现最简单但缺点是延时期间中断被占住。第二种是读回法触发中断后不延时而是立刻读引脚若读到高电平说明是抖动产生的假边沿丢弃即可。第三种是定时器扫描法外部中断只负责唤醒一个软定时器由定时器在 20ms 后采样电平采样结果稳定才置标志位这个做法不阻塞中断适合多按键系统。// 回调中的读回防抖一次中断 一次电平确认 if (GPIO_Pin GPIO_PIN_0) { // 此刻引脚仍然为低才确认是有效按下 if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_RESET) { key_flag | (1 0); } }这个写法只做一次电平确认省掉了 20ms 延时代价是对 5ms 以内的极短抖动过滤能力弱一些。工程上折中方案是在外部加一个 100nF 电容并联在按键两端让机械抖动在硬件层面就被 RC 电路滤掉大半此时读回法已经足够。定时器扫描法虽然可靠但每个按键都要维护一个超时计数四个按键就是四组状态代码量明显变大如果产品对防抖要求不高读回法加硬件电容是最划算的组合。5. 把四个按键中断收敛成按键服务5.1 用结构体数组管理按键表四个按键的中断回调写了四个 if每个按键动作写成一个 case一旦按键数量从四个涨到八个回调里会堆满分支。常见的做法是把按键的硬件信息整理成一张表用数组存起来回调里通过查表得到按键编号业务代码和硬件细节就此分离。typedef struct { GPIO_TypeDef *port; uint16_t pin; uint8_t key_id; } key_map_t; static const key_map_t key_table[] { {GPIOA, GPIO_PIN_0, 0}, // KEY1 - id 0 {GPIOE, GPIO_PIN_2, 1}, // KEY2 - id 1 {GPIOE, GPIO_PIN_4, 2}, // KEY3 - id 2 {GPIOE, GPIO_PIN_5, 3}, // KEY4 - id 3 }; #define KEY_TABLE_SIZE (sizeof(key_table) / sizeof(key_map_t))这张表的字段含义很清楚port 是按键所在 GPIO 组pin 是具体引脚key_id 是业务层用的逻辑编号。业务代码不关心 KEY1 到底挂在 PA0 还是 PB1只关心当前事件的 key_id 是 0 还是 1。改硬件、换引脚时只动这张表回调函数和主循环一行都不用改。5.2 在回调里统一分发 key_id回调函数收到GPIO_Pin后不再用一串 if 去比较而是循环查表。查表的次数等于按键数量四个按键时最多比较四次带来的延迟可以忽略不计。这种写法牺牲了一点运行时性能换来了无限的可扩展性任何按键配置变化都集中在一张静态数据里。void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { for (uint8_t i 0; i KEY_TABLE_SIZE; i) { if (key_table[i].pin GPIO_Pin) { // 先做一次电平确认再登记按键事件 if (HAL_GPIO_ReadPin(key_table[i].port, key_table[i].pin) GPIO_PIN_RESET) { key_event_set(key_table[i].key_id); } break; } } }key_event_set内部做的事情和前面置标志位一样只是把位操作包裹成了一个按 key_id 寻址的函数方便主循环按编号取事件。这个函数的设计原则是同一个 key_id 的事件在消费前再次触发直接覆盖旧事件还是累积成计数取决于产品需求。一般按键操作频率低覆盖即可但用于音量调节这类连发场景就要改成计数累加否则连续按两下只记一次。5.3 长按检测用 tick 差值而不是计数器按键服务想要再进一步可以加入长按判定。按下时记录当前系统时基释放时再读一次时基两者相减就是按住时长超过阈值判为长按否则是短按。这里不能用累加计数的办法因为计数器的溢出周期和 Tick 的分辨率都要额外维护直接用HAL_GetTick()是硬件无关、平台通用的方案。// 按下瞬间记录起始时间放到回调里 uint32_t press_tick; if (HAL_GPIO_ReadPin(key_table[i].port, key_table[i].pin) GPIO_PIN_RESET) { press_tick HAL_GetTick(); key_event_set(key_table[i].key_id); } // 释放检测在主循环轮询中做 if (HAL_GPIO_ReadPin(key_table[i].port, key_table[i].pin) GPIO_PIN_SET) { uint32_t elapsed HAL_GetTick() - press_tick; if (elapsed 1000) // 按住超过 1 秒按长按处理 { key_event_set_long(key_table[i].key_id); } }HAL_GetTick() - press_tick这种写法天然规避了 Tick 溢出问题因为无符号减法在环形时间轴上总能给出正确差值。长按阈值的设定要参考你的按键弹片和菜单逻辑1000ms 是大多数嵌入式菜单的常识值太短容易和短按混淆太长用户会以为是按键失灵。释放检测放在主循环里做是为了不占用中断时间反正释放不产生中断轮询本身开销也极小。这套按键服务框架配合前面的查表设计四个按键和四十个按键维护成本几乎一样。本文还有配套的精品资源点击获取
返回列表