3. GPIO驱动分层设计(下):按键输入与消抖处理、中断服务函数分层(ISR与Callback分离)、实战:按键控制LED亮灭
上一讲我们把GPIO输出层的架子搭好了,LED能听话地亮灭。但嵌入式系统不能只会输出,还得会“听”——按键输入就是最典型的输入场景。
这一讲我们重点解决三个问题:按键怎么读才准?中断服务函数怎么写才不乱?怎么把输入和输出串成一个完整的功能?
我个人习惯把按键驱动也拆成两层:硬件抽象层(HAL) 负责读引脚电平、配置中断;应用层 负责消抖、判断长按短按、触发回调。这样换一个平台,改的只是HAL层那几行代码。
3.1 按键输入的基础:电平读取与硬件连接
先看硬件。大多数开发板上的按键电路长这样:
- 一端接GND,另一端通过10kΩ上拉电阻接VCC,再接到GPIO引脚
- 按下时引脚读到低电平(0),松开时读到高电平(1)
- 也有反接的——按下读高电平,取决于电路设计
嗯,这里要注意:按键引脚必须配置为输入模式,并且使能内部上拉或下拉。如果外部已经有上拉电阻,内部上拉可以关掉,但别悬空——悬空引脚在电磁干扰下会乱跳,我在项目中遇到过因为省一个上拉电阻导致按键误触发的情况,排查了两天才找到原因。
3.2 消抖处理:为什么不能直接读按键?
直接读按键?你试试看。按下一次,程序可能读到几十次电平跳变。这就是机械抖动——按键内部的金属弹片在接触瞬间会反弹几次,持续5~20ms。
消抖方法主要有两种:
| 方法 | 原理 | 适用场景 | 缺点 |
|---|---|---|---|
| 硬件消抖 | RC低通滤波 + 施密特触发器 | 对响应速度要求高的场合 | 增加BOM成本,不灵活 |
| 软件延时消抖 | 检测到电平变化后延时10~20ms再读一次 | 大多数通用场景 | 阻塞CPU,不适合中断中直接使用 |
| 状态机消抖 | 连续采样N次,N次结果一致才确认 | 低功耗、多按键场景 | 代码稍复杂,但最可靠 |
我个人最常用的是状态机消抖。为什么?因为它不阻塞、不依赖定时器延时,而且能同时处理多个按键。下面给出一个典型实现:
/* 按键状态机 */
typedef enum {
KEY_STATE_IDLE, // 空闲
KEY_STATE_DEBOUNCE, // 消抖中
KEY_STATE_PRESSED, // 确认按下
KEY_STATE_RELEASE // 等待释放
} KeyState_t;
typedef struct {
GPIO_TypeDef *port;
uint16_t pin;
KeyState_t state;
uint8_t debounce_cnt;
uint8_t debounce_threshold; // 通常设为3~5
} Key_t;
uint8_t Key_Scan(Key_t *key) {
uint8_t level = HAL_GPIO_ReadPin(key->port, key->pin);
switch (key->state) {
case KEY_STATE_IDLE:
if (level == 0) { // 检测到按下
key->state = KEY_STATE_DEBOUNCE;
key->debounce_cnt = 0;
}
break;
case KEY_STATE_DEBOUNCE:
if (level == 0) {
key->debounce_cnt++;
if (key->debounce_cnt >= key->debounce_threshold) {
key->state = KEY_STATE_PRESSED;
return 1; // 返回按键有效
}
} else {
key->state = KEY_STATE_IDLE; // 抖动回弹,复位
}
break;
case KEY_STATE_PRESSED:
if (level == 1) {
key->state = KEY_STATE_RELEASE;
}
break;
case KEY_STATE_RELEASE:
key->state = KEY_STATE_IDLE;
break;
}
return 0;
}
3.3 中断服务函数分层:ISR与Callback分离
轮询读按键在简单场景下够用,但如果你想让系统在按键按下时立刻响应,或者想在睡眠中唤醒MCU,那就得上外部中断。
很多初学者把整个业务逻辑直接写在中断服务函数里——这是个大坑。中断服务函数应该短、快、不阻塞。我在项目中见过有人在ISR里调printf、做延时、甚至跑文件系统,结果系统动不动就死机。
我的做法是:ISR只做一件事——设置标志位或唤醒信号量;真正的处理逻辑放在Callback回调函数中,由主循环或任务调度器去执行。
看代码:
/* 中断服务函数 —— 只做最轻量的事 */
void EXTI0_IRQHandler(void) {
if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET) {
/* 清除中断标志位 —— 必须做 */
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
/* 设置按键按下标志,不在这里做任何业务处理 */
g_key_pending = 1;
}
}
/* 应用层回调 —— 在主循环中调用 */
void Key_ProcessCallback(void) {
if (g_key_pending) {
g_key_pending = 0; // 清标志
/* 这里才做消抖和按键判断 */
if (Key_Scan(&user_key)) {
/* 按键有效,触发上层动作 */
LED_Toggle(LED_BUILTIN);
}
}
}
为什么要这样分?说白了就两个原因:
- 实时性:ISR执行时间过长会阻塞其他中断,尤其是低优先级中断可能永远得不到响应
- 可移植性:换MCU时只需要重写ISR(硬件相关),Callback逻辑完全不用动
3.4 实战:按键控制LED亮灭
理论说完了,咱们动手。目标很简单:按一下按键,LED状态翻转(亮→灭→亮)。
整体架构图如下:
完整代码实现:
/* main.c —— 主循环 */
int main(void) {
HAL_Init();
SystemClock_Config();
MX_GPIO_Init(); // 初始化LED引脚(输出)和按键引脚(输入+中断)
while (1) {
Key_ProcessCallback(); // 处理按键事件
/* 其他任务... */
}
}
/* gpio.c —— 硬件初始化 */
void MX_GPIO_Init(void) {
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_GPIOC_CLK_ENABLE();
/* LED引脚:PA5,推挽输出 */
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_5;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
/* 按键引脚:PC13,上拉输入 + 下降沿中断 */
GPIO_InitStruct.Pin = GPIO_PIN_13;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
/* 使能中断线 */
HAL_NVIC_SetPriority(EXTI15_10_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(EXTI15_10_IRQn);
}
/* stm32f1xx_it.c —— 中断服务函数 */
void EXTI15_10_IRQHandler(void) {
if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_13) != RESET) {
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_13);
g_key_pending = 1; // 只设标志,不做其他
}
}
3.5 避坑总结
最后,把我在项目中踩过的坑列出来,你遇到了可以直接对照:
- 中断标志位没清干净:有些MCU需要先读再写才能清除,只写一次可能清不掉,导致反复进中断
- 消抖阈值设得太小:我曾经设成2(对应10ms),结果在强电磁环境下按键还是误触发,改成5之后稳了
- Callback里做了阻塞操作:比如在Callback里调了HAL_Delay(100),导致下一次中断响应延迟,按键感觉“卡顿”
- 忘了配置NVIC优先级分组:默认分组可能不是你想要的,中断嵌套时优先级乱套