3. 按键输入与中断系统:从物理抖动到NVIC实战
按键,看起来最简单的外设。按下,松开,完事?
真这么简单就好了。我在做第一个电机控制项目时,就栽在按键上。按一下启动,电机抖三下——不是电机坏了,是按键抖动把程序搞懵了。从那以后,我养成了一个习惯:任何按键,先做消抖,再谈逻辑。
3.1 按键消抖:为什么需要?怎么做?
机械按键的本质是金属弹片的物理接触。按下时,弹片会反弹几次才稳定。这个过程叫抖动,一般持续5~20ms。
抖动的后果:一次按下,CPU检测到多次电平跳变。在电机控制中,这可能意味着一次启停指令被误判为多次,电机频繁启停,电流冲击大,甚至烧驱动。
消抖方法分两种:硬件消抖和软件消抖。
| 方法 | 原理 | 适用场景 | 成本 |
|---|---|---|---|
| 硬件消抖(RC滤波) | 电阻+电容构成低通滤波,平滑跳变沿 | 对实时性要求高、按键数量少 | 增加BOM成本 |
| 软件消抖(延时) | 检测到电平变化后,延时10~20ms再读一次 | 通用,成本低 | 占用CPU时间 |
| 软件消抖(状态机) | 连续多次采样,多数一致才确认 | 抗干扰强,适合工业环境 | 代码稍复杂 |
我个人习惯用状态机消抖。不阻塞CPU,还能顺便做长按、短按检测。下面是我常用的代码模板:
// 按键状态机消抖示例(伪代码)
typedef enum {
KEY_IDLE,
KEY_DEBOUNCE,
KEY_PRESSED,
KEY_RELEASE
} KeyState;
KeyState keyState = KEY_IDLE;
uint8_t stableCount = 0;
void Key_Scan(void) {
uint8_t level = GPIO_ReadPin(KEY_PIN);
switch(keyState) {
case KEY_IDLE:
if(level == 0) { // 检测到按下
keyState = KEY_DEBOUNCE;
stableCount = 0;
}
break;
case KEY_DEBOUNCE:
if(level == 0) {
stableCount++;
if(stableCount >= 5) { // 连续5次采样为低
keyState = KEY_PRESSED;
// 触发按键事件
Key_Event(KEY_PRESSED);
}
} else {
keyState = KEY_IDLE; // 抖动,复位
}
break;
case KEY_PRESSED:
if(level == 1) { // 检测到松开
keyState = KEY_RELEASE;
stableCount = 0;
}
break;
case KEY_RELEASE:
if(level == 1) {
stableCount++;
if(stableCount >= 5) {
keyState = KEY_IDLE;
Key_Event(KEY_RELEASED);
}
} else {
keyState = KEY_PRESSED;
}
break;
}
}
我的经验:采样间隔取2~4ms,连续5次一致,总消抖时间约10~20ms。这个参数在大多数按键上都稳。如果遇到特别老的按键,把次数调到8~10次。
3.2 外部中断配置:让CPU不再空等
用轮询读按键?CPU大部分时间都在空转。电机控制里,CPU要算PID、要发PWM、要处理通信,哪有空一直盯着按键?
这时候就该外部中断出场了。按键按下时,触发中断,CPU暂停当前任务,处理按键事件,然后回去继续干活。
配置外部中断,一般分三步:
- GPIO配置:设为输入模式,开启上拉或下拉
- 中断触发方式:上升沿、下降沿、双边沿
- 中断服务函数:写处理逻辑,注意清标志位
以STM32为例,配置代码大概这样:
// 外部中断配置示例(STM32 HAL库)
void EXTI_Key_Init(void) {
GPIO_InitTypeDef GPIO_InitStruct = {0};
// 1. 使能GPIO时钟
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_SYSCFG_CLK_ENABLE();
// 2. 配置GPIO为输入,上拉
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; // 下降沿触发
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 3. 配置中断优先级
HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}
// 中断服务函数
void EXTI0_IRQHandler(void) {
// 检查中断标志
if(__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET) {
// 延时消抖(注意:中断里不要用HAL_Delay!)
// 建议用状态机或定时器辅助
Key_Event(KEY_PRESSED);
// 清标志位
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
}
}
注意:中断服务函数里不要做耗时操作。消抖、长按检测这些,建议用定时器中断辅助,或者用状态机在main循环里处理。中断里只做最轻量的标记。
3.3 中断优先级与嵌套:谁先响应?
一个系统里不可能只有一个中断。电机控制中,你可能同时有:
- 定时器中断(PWM周期更新)
- 外部中断(按键)
- 串口中断(通信)
- ADC中断(电流采样)
问题来了:如果按键中断来了,刚好定时器中断也在等处理,谁先执行?
这就涉及到中断优先级。
大多数ARM Cortex-M内核(如STM32)支持抢占优先级和子优先级:
| 概念 | 说明 | 作用 |
|---|---|---|
| 抢占优先级 | 数值越小,优先级越高 | 高优先级可以打断低优先级中断 |
| 子优先级 | 数值越小,优先级越高 | 同抢占优先级时,先响应子优先级高的 |
举个例子:
- 定时器中断:抢占优先级0,子优先级0
- 按键中断:抢占优先级1,子优先级0
- 串口中断:抢占优先级1,子优先级1
定时器中断优先级最高,可以打断按键和串口。按键和串口同级,但按键子优先级更高,所以按键先响应。
电机控制中的优先级分配建议:
- 最高优先级:电流采样ADC中断(实时性要求最高)
- 次高优先级:定时器中断(PWM周期控制)
- 中等优先级:通信中断(如CAN、SPI)
- 最低优先级:按键、显示等外设中断
3.4 NVIC详解:中断的“总调度中心”
NVIC(Nested Vectored Interrupt Controller),嵌套向量中断控制器。说白了,它就是CPU内部负责管理所有中断的“调度中心”。
NVIC的核心功能:
- 使能/禁止中断:控制某个中断是否响应
- 设置优先级:决定中断的响应顺序
- 中断嵌套管理:高优先级打断低优先级
- 中断向量表:每个中断对应一个入口地址
下面这张图展示了NVIC在中断处理中的位置:
NVIC的优先级分组也很关键。Cortex-M3/M4支持4位优先级,可以分成不同的组:
| 分组 | 抢占优先级位数 | 子优先级位数 | 说明 |
|---|---|---|---|
| NVIC_PriorityGroup_0 | 0 | 4 | 所有中断同级,无嵌套 |
| NVIC_PriorityGroup_1 | 1 | 3 | 2级抢占,8级子优先级 |
| NVIC_PriorityGroup_2 | 2 | 2 | 4级抢占,4级子优先级 |
| NVIC_PriorityGroup_3 | 3 | 1 | 8级抢占,2级子优先级 |
| NVIC_PriorityGroup_4 | 4 | 0 | 16级抢占,无子优先级 |
我的建议:电机控制项目用 NVIC_PriorityGroup_2(4级抢占,4级子优先级)。4级抢占够用,子优先级还能在同级里做微调。别用Group_0,所有中断同级,一个卡住全完蛋。
3.5 实战避坑:中断里那些“不能做的事”
做中断开发,有些坑我踩过不止一次。列出来,你少走弯路:
- 不要在ISR里用HAL_Delay():HAL_Delay依赖SysTick中断,如果SysTick优先级比当前中断低,就死锁了。我曾经因为这个查了两天。
- ISR里不要做浮点运算:浮点运算耗时,而且可能涉及FPU寄存器保存,增加中断延迟。
- 全局变量记得加volatile:中断和主循环共享的变量,不加volatile,编译器优化可能让你读到错误的值。
- 中断标志位必须清:不清标志位,中断会反复触发,CPU卡死在ISR里出不来。
- 优先级不要设成一样:同优先级中断不会嵌套,会排队。如果某个中断耗时太长,后面的就等着吧。
曾经有一次,我在一个项目里把按键中断和定时器中断设成了同一优先级。按键按下时,定时器中断刚好触发,结果按键处理被延迟了整整一个PWM周期。电机在那个周期里没收到更新指令,转速抖了一下。从那以后,我所有中断优先级都仔细规划,绝不偷懒。
好了,按键和中断系统就聊到这儿。记住:消抖是基础,优先级是灵魂,NVIC是总指挥。这三样搞明白,电机控制里的中断处理基本不会出大问题。