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暂停当前任务,处理按键事件,然后回去继续干活。

配置外部中断,一般分三步:

  1. GPIO配置:设为输入模式,开启上拉或下拉
  2. 中断触发方式:上升沿、下降沿、双边沿
  3. 中断服务函数:写处理逻辑,注意清标志位

以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 中断处理流程 外部中断(按键) 定时器中断 串口中断 ADC中断 NVIC 优先级比较 中断嵌套管理 向量表查找 中断屏蔽 尾链优化 CPU 执行ISR 中断服务函数(ISR) 关键说明: 1. 多个中断同时到达时,NVIC根据优先级决定谁先进入CPU 2. 高优先级中断可以打断正在执行的低优先级中断(嵌套) 3. CPU执行完ISR后,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是总指挥。这三样搞明白,电机控制里的中断处理基本不会出大问题。