3. 按键输入与中断:从轮询到EXTI的实战之路
按键,可以说是嵌入式开发里最基础、也最容易出问题的外设了。很多初学者觉得按键不就是读个IO电平吗?但真正做产品时你会发现,按键消抖、中断响应、优先级配置,每一个环节都可能让你加班到深夜。今天我就把这块内容掰开了讲,结合我这些年踩过的坑,帮你把按键驱动彻底搞明白。
3.1 按键消抖原理:为什么不能直接读?
先问个问题:你用手按一下按键,从按下到稳定,中间发生了什么?
实际上,机械按键在按下和释放的瞬间,触点会弹跳几次,产生一连串的脉冲信号。这个过程通常持续5-20ms。如果你直接读IO,可能会读到多次电平变化——按一次,程序却认为按了十次。
消抖的本质:跳过弹跳期,只认稳定状态。
消抖有两种主流方式:
| 方式 | 原理 | 适用场景 | 缺点 |
|---|---|---|---|
| 硬件消抖 | RC低通滤波 + 施密特触发器 | 对实时性要求高的产品 | 增加BOM成本 |
| 软件消抖 | 延时采样或定时器去抖 | 大多数嵌入式项目 | 占用CPU时间 |
我个人习惯用软件消抖,毕竟省成本。但要注意,延时消抖不能用阻塞式delay,否则整个系统都被你卡死了。后面我会讲怎么用定时器或状态机来做。
3.2 轮询模式:最简单也最危险
轮询模式,就是主循环里不断读GPIO电平。代码写起来确实简单:
// 轮询模式按键检测(不推荐用于产品)
while (1) {
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) {
HAL_Delay(20); // 消抖延时
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) {
// 按键有效,执行动作
}
}
}
这段代码有什么问题?我告诉你,问题大了去了。
- 阻塞延时:HAL_Delay(20) 让CPU空转20ms,系统响应能力直接下降
- 无法处理长按:你只能检测到按下,区分不了短按、长按、双击
- 功耗高:CPU一直在跑,没法进休眠模式
我曾经在一个智能家居项目里用了纯轮询按键,结果用户反馈“按一下灯闪两下”。查了一整天,发现是轮询周期和按键弹跳周期重叠了。从那以后,我对轮询模式就格外谨慎。
轮询模式不是不能用,但只适合非实时、单任务、对功耗不敏感的场景。但凡你的系统里还有别的任务在跑,我建议你直接上中断。
3.3 外部中断EXTI配置:一步步来
EXTI(External Interrupt)是STM32处理外部事件的标准方式。配置起来其实不复杂,但有几个关键点容易忽略。
3.3.1 配置步骤
- 使能GPIO时钟:没有时钟,IO口就是死的
- 配置GPIO为输入模式:上拉或下拉根据电路决定
- 连接EXTI线:GPIO和EXTI线之间的映射,不同引脚对应不同线
- 配置中断触发方式:上升沿、下降沿、双边沿
- 配置NVIC优先级:中断嵌套全靠它
- 编写中断服务函数:ISR里要快进快出
用HAL库的话,大部分工作可以在CubeMX里图形化完成。但你要理解背后发生了什么,不然出了问题都不知道怎么查。
3.3.2 关键参数表
| 参数 | 选项 | 说明 |
|---|---|---|
| GPIO模式 | GPIO_MODE_IT_FALLING | 下降沿触发(按键按下) |
| GPIO模式 | GPIO_MODE_IT_RISING | 上升沿触发(按键释放) |
| GPIO模式 | GPIO_MODE_IT_RISING_FALLING | 双边沿触发 |
| 上拉/下拉 | GPIO_PULLUP | 外部按键接地时使用 |
| 优先级分组 | NVIC_PRIORITYGROUP_4 | 4位抢占优先级,0位子优先级 |
我的习惯:按键按下用下降沿触发,释放用上升沿。如果只关心按下动作,用下降沿就够了,省得ISR被频繁调用。
3.4 中断服务函数编写:快进快出是铁律
中断服务函数(ISR)里最忌讳什么?做复杂的事情。你在ISR里跑个延时、做个浮点运算、调个printf,系统不崩才怪。
正确的做法是:ISR里只做标记,具体处理交给主循环或任务。
// 正确的ISR写法
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
if (GPIO_Pin == KEY_Pin) {
// 只设置标志位,不做具体处理
key_pressed_flag = 1;
// 记录按下时刻,用于长按检测
key_press_tick = HAL_GetTick();
}
}
// 主循环中处理
void Key_Process(void) {
if (key_pressed_flag) {
key_pressed_flag = 0;
// 这里做消抖和按键识别
uint32_t duration = HAL_GetTick() - key_press_tick;
if (duration > 50) { // 消抖阈值
if (duration < 1000) {
// 短按
} else {
// 长按
}
}
}
}
你看,ISR里只做了两件事:置标志位、记录时间戳。真正的消抖和按键识别放到了主循环里。这样既保证了中断的实时性,又不会阻塞系统。
我曾经在一个项目里把按键处理全塞进了ISR,结果中断嵌套一多,系统直接死机。后来花了三天才定位到问题——ISR里调用了HAL_Delay。记住:中断里绝对不要用延时函数。
3.5 知识体系总览
下面这张图把按键输入与中断的核心流程串起来了,你可以对照着理解:
3.6 实战中的避坑指南
最后,我把自己这些年踩过的坑整理成清单,你写代码时对着检查一遍:
- 中断优先级别乱设:按键中断优先级不要设得太高,否则会抢占系统心跳。我一般设到2或3(数值越小优先级越高)。
- 别忘了清除中断标志位:HAL库的EXTI回调函数会自动清除,但如果你直接操作寄存器,一定要手动清。
- 上拉电阻要选对:外部按键接地时用内部上拉,按键接VCC时用内部下拉。搞反了IO电平会一直跳。
- 消抖时间不是越长越好:20ms是经验值,但不同按键的弹跳特性不一样。我习惯用示波器抓一下实际波形,再定消抖时间。
- 长按和短按要分开处理:用定时器记录按下时长,而不是在ISR里做延时判断。
嗯,这里再补充一句:如果你要在FreeRTOS里用按键中断,记得在ISR里用xSemaphoreGiveFromISR()或xQueueSendFromISR()来通知任务,千万别在ISR里直接调用任务处理函数。这部分内容后面讲FreeRTOS移植时会详细展开。
按键驱动看似简单,但做好了能体现一个嵌入式工程师的基本功。从轮询到中断,从消抖到状态机,每一步都有讲究。希望今天的内容能帮你少走弯路。