4. 外部中断与按键响应:NVIC中断优先级配置、EXTI外部中断触发方式、中断服务函数编写规范

好,咱们接着聊。电梯召唤面板里,按键检测是最基础的功能。但你想想看,如果CPU一直在那轮询按键状态,是不是太浪费了?就像你一直盯着电梯按钮看它亮了没,多累啊。

所以,我们得用中断。按键按下去,CPU才去处理。这就是外部中断的典型应用场景。今天我就把这块掰开了讲清楚。

4.1 NVIC中断优先级配置

NVIC,全称是Nested Vectored Interrupt Controller,嵌套向量中断控制器。名字挺长,说白了就是ARM Cortex-M内核里管中断的“大管家”。

我个人习惯,拿到一个新项目,第一件事就是先把NVIC的优先级分组搞清楚。为什么?因为一旦你搞错了,中断嵌套的时候就会出各种诡异的问题。

4.1.1 优先级分组

STM32里,中断优先级分为抢占优先级和响应优先级(也叫子优先级)。

  • 抢占优先级:数值越小,优先级越高。高抢占优先级可以打断低抢占优先级的中断服务。
  • 响应优先级:数值越小,优先级越高。但响应优先级不能打断,只在同一抢占优先级下决定谁先执行。

配置函数是 NVIC_PriorityGroupConfig()。有5种分组方式:

分组 抢占优先级位数 响应优先级位数
NVIC_PriorityGroup_0 0位 4位
NVIC_PriorityGroup_1 1位 3位
NVIC_PriorityGroup_2 2位 2位
NVIC_PriorityGroup_3 3位 1位
NVIC_PriorityGroup_4 4位 0位
我的经验: 电梯面板这种实时性要求高的场景,我一般用NVIC_PriorityGroup_2。2位给抢占优先级,2位给响应优先级。这样既能保证紧急任务(比如急停按钮)能打断普通任务,又留了足够的优先级层次。

4.1.2 配置示例

看代码吧,这样更直观:

// 设置优先级分组
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);

// 配置外部中断(比如EXTI0)
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = EXTI0_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;  // 抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;         // 响应优先级
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);

嗯,这里要注意:整个系统里,NVIC_PriorityGroupConfig() 只能调用一次。你想想看,如果不同模块各调各的,那不乱套了?

4.2 EXTI外部中断触发方式

EXTI,External Interrupt,外部中断。它负责检测GPIO引脚上的电平变化。

触发方式有三种:

  • 上升沿触发:从低电平变高电平时触发。适合检测按键松开。
  • 下降沿触发:从高电平变低电平时触发。适合检测按键按下。
  • 双边沿触发:上升沿和下降沿都触发。适合需要同时检测按下和松开的场景。
我曾经踩过的坑: 有一次做电梯面板,按键用了下降沿触发。结果发现按键按下一次,中断却触发了两次。查了半天,原来是按键抖动导致的。所以,硬件上一定要加去抖电路,或者软件里做延时消抖。

4.2.1 配置步骤

配置EXTI,一般分三步走:

  1. 使能GPIO时钟和AFIO时钟:AFIO是复用功能IO,用来把GPIO映射到EXTI。
  2. 配置GPIO为输入模式:一般是上拉输入或下拉输入。
  3. 配置EXTI线路:选择触发方式,并使能中断。

看个例子:

// 1. 使能时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE);

// 2. 配置GPIO
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;  // 上拉输入
GPIO_Init(GPIOA, &GPIO_InitStructure);

// 3. 配置EXTI
GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0);

EXTI_InitTypeDef EXTI_InitStructure;
EXTI_InitStructure.EXTI_Line = EXTI_Line0;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling;  // 下降沿触发
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);

你可能会问:为什么用上拉输入?因为按键另一端接地,按下时引脚变低电平,松开时被拉高。这样下降沿触发正好对应按键按下。

4.3 中断服务函数编写规范

中断服务函数,ISR(Interrupt Service Routine)。这是中断系统的核心。写不好,整个系统都可能崩溃。

我总结了几个原则,你记一下:

4.3.1 短小精悍

中断服务函数里,不要做复杂运算,不要调用延时函数,不要做打印输出。为什么?因为中断是在打断主程序执行的,你处理时间越长,主程序响应就越慢。

黄金法则: 中断里只做三件事——读取数据、设置标志位、清除中断标志。其他事情,交给主循环去处理。

4.3.2 清除中断标志

这个太重要了。不清除中断标志,中断会反复触发,CPU就卡死在中断里了。

void EXTI0_IRQHandler(void)
{
    if(EXTI_GetITStatus(EXTI_Line0) != RESET)
    {
        // 设置按键按下标志
        KeyPressFlag = 1;
        
        // 清除中断标志
        EXTI_ClearITPendingBit(EXTI_Line0);
    }
}

4.3.3 使用volatile修饰共享变量

中断和主程序都会访问的变量,一定要加 volatile 关键字。不然编译器优化时,可能会把变量值缓存到寄存器里,导致主程序读不到中断里修改的值。

volatile uint8_t KeyPressFlag = 0;  // 中断和主程序共享

4.3.4 中断嵌套要小心

如果允许中断嵌套,高优先级中断可以打断低优先级中断。但嵌套层数太多,栈空间可能不够。我一般建议,除非必要,否则不要开太多嵌套。

我的习惯: 电梯面板里,急停按钮用最高优先级,其他按键用普通优先级。这样急停能立刻响应,其他按键稍微等一等也没关系。

4.4 实战:按键消抖与中断结合

前面说了,按键有抖动。硬件上可以加RC滤波,但软件消抖更灵活。我常用的方法是:中断触发后,延时10-20ms再检测一次电平状态。

volatile uint8_t KeyPressFlag = 0;

void EXTI0_IRQHandler(void)
{
    if(EXTI_GetITStatus(EXTI_Line0) != RESET)
    {
        // 延时消抖
        Delay_ms(20);
        
        // 再次检测按键状态
        if(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == RESET)
        {
            KeyPressFlag = 1;  // 确认按键按下
        }
        
        EXTI_ClearITPendingBit(EXTI_Line0);
    }
}

你想想看,如果不用延时,直接在中断里设置标志位,那抖动期间可能会触发多次中断,标志位被反复设置,主程序根本分不清是按下一次还是多次。

注意: 延时函数在中断里用,要确保延时时间很短(毫秒级)。如果延时太长,会影响其他中断响应。实在不行,可以用定时器来做延时,或者用状态机消抖。

好了,外部中断这块就讲这么多。说白了,就是配置好NVIC优先级,选对EXTI触发方式,写好短小精悍的中断服务函数。再加上软件消抖,按键响应就稳了。

下一章,咱们聊聊定时器。电梯面板里,定时器用来做啥?嘿嘿,到时候你就知道了。