第3章 中断系统与NVIC:让CPU学会“插队”

中断这玩意儿,说白了就是让CPU能处理“突发事件”。

我刚开始学单片机时,总觉得轮询也能干活——主循环里不断查标志位,不也一样?直到有一次做工业控制项目,一个紧急停止信号因为主循环正在处理显示刷新,延迟了200ms才响应……嗯,从那以后我再也不敢轻视中断了。

3.1 中断优先级管理——谁更重要?

STM32的中断系统,核心是NVIC(嵌套向量中断控制器)。它管两件事:哪个中断能打断当前任务,以及多个中断同时来的时候先处理谁。

核心概念:抢占优先级 vs 响应优先级(也叫子优先级)

  • 抢占优先级:决定能否嵌套。高抢占优先级的中断可以打断正在执行的低抢占优先级中断。
  • 响应优先级:当两个中断抢占优先级相同时,谁响应优先级高谁先执行。但不能互相打断。

你想想看,如果所有中断优先级都一样,那跟轮询有什么区别?

3.1.1 优先级分组

STM32用4位二进制位来表示优先级。HAL库通过HAL_NVIC_SetPriorityGrouping()来分配这4位中多少位给抢占优先级,多少位给响应优先级。

分组编号 抢占优先级位数 响应优先级位数 典型场景
NVIC_PriorityGroup_0 0位 4位 所有中断同级,无嵌套
NVIC_PriorityGroup_1 1位 3位 2级嵌套,适合简单系统
NVIC_PriorityGroup_2 2位 2位 4级嵌套,最常用
NVIC_PriorityGroup_3 3位 1位 8级嵌套,响应优先级只有2级
NVIC_PriorityGroup_4 4位 0位 16级嵌套,无子优先级

我的习惯:项目初期就定好分组策略,中途改分组会导致所有中断优先级重新计算,容易出bug。我个人偏爱NVIC_PriorityGroup_2,4级抢占够用,4级响应也灵活。

3.2 外部中断(EXTI)配置——让引脚学会“喊人”

EXTI就是外部中断控制器。它能检测GPIO引脚上的电平变化,然后通知NVIC去叫CPU。

配置EXTI,说白了就三步:

  1. 选引脚:哪个GPIO引脚要当中断源
  2. 选触发方式:上升沿、下降沿、还是双边沿
  3. 连中断线:EXTI有16条中断线(EXTI0~EXTI15),每个引脚对应一条线

我曾经踩过的坑:同一个EXTI线号只能映射一个引脚。比如PA0和PB0都映射到EXTI0线,不能同时用。我有个同事把两个按键分别放在PA0和PB0上,结果一个按键触发两个中断……排查了一下午。

3.2.1 配置代码示例

用HAL库配置EXTI,其实很简洁。以PA0引脚为例:

// 1. 使能GPIO时钟和SYSCFG时钟
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_SYSCFG_CLK_ENABLE();

// 2. 配置GPIO为中断模式
GPIO_InitTypeDef GPIO_InitStruct = {0};
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. 配置NVIC
HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0);       // 抢占2,响应0
HAL_NVIC_EnableIRQ(EXTI0_IRQn);

你看,就这么几行。但要注意,GPIO_MODE_IT_FALLING这个模式,它既配置了GPIO,又自动连好了EXTI线。HAL库帮你省了一步。

3.3 HAL库中断回调机制——别在中断里写太多代码

这是很多新手容易犯的错:在中断服务函数里写几百行代码。

HAL库的设计哲学是:中断服务函数只做最轻量的事,真正的业务逻辑放到回调函数里。

3.3.1 回调流程

以EXTI为例,中断来了之后是这样的:

  1. CPU跳转到EXTI0_IRQHandler()
  2. HAL库内部调用HAL_GPIO_EXTI_IRQHandler()
  3. 这个函数清除中断标志位,然后调用HAL_GPIO_EXTI_Callback()
  4. 你在回调函数里写自己的逻辑

关键点:回调函数是在中断上下文中执行的。所以回调里不能做耗时操作,比如延时、打印、复杂计算。我一般只在回调里设置一个标志位,主循环去处理。

3.3.2 回调函数示例

// 在用户文件中重写这个弱函数
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_Pin == GPIO_PIN_0)
    {
        // 按键按下,设置标志位
        key_pressed = 1;
    }
}

嗯,这里要注意:回调函数是__weak弱定义的,你在自己的文件里重新实现它就行。别去修改HAL库的源文件,否则下次更新库就覆盖了。

3.4 按键中断实战——从理论到代码

光说不练假把式。我们做一个按键中断控制LED的实战。

3.4.1 硬件连接

元件 引脚 配置
按键 PA0 下降沿触发,内部上拉
LED PC13 推挽输出

3.4.2 完整代码

// main.c
volatile uint8_t key_pressed = 0;

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    
    // 配置LED
    __HAL_RCC_GPIOC_CLK_ENABLE();
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = GPIO_PIN_13;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
    
    // 配置按键中断
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_SYSCFG_CLK_ENABLE();
    GPIO_InitStruct.Pin = GPIO_PIN_0;
    GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    // 配置NVIC
    HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0);
    HAL_NVIC_EnableIRQ(EXTI0_IRQn);
    
    while (1)
    {
        if (key_pressed)
        {
            key_pressed = 0;
            HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
        }
    }
}

// 回调函数
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_Pin == GPIO_PIN_0)
    {
        key_pressed = 1;
    }
}

避坑指南:按键有机械抖动,按下时会产生多个边沿。我一般会在回调里加一个20ms的软件消抖延时——但注意,不能在中断里用HAL_Delay()!它依赖SysTick中断,优先级比EXTI低的话会死锁。我的做法是用一个定时器或者状态机来做消抖。

3.5 中断系统知识图谱

我把整个中断系统的脉络画成了一张图,方便你理解各个模块之间的关系:

STM32中断系统知识结构图 中断源(EXTI/定时器等) NVIC(嵌套向量中断控制器) 优先级分组(4位分配) 抢占优先级(嵌套) 响应优先级(排队) HAL库中断处理框架 中断服务函数(ISR) 回调函数(Callback) ISR只做清标志和调回调,业务逻辑写在Callback中,保持中断处理轻量化

这张图把中断系统的三个层次串起来了:中断源产生事件,NVIC决定优先级,HAL库提供回调机制。你写代码时,脑子里要有这个结构。

最后提醒一句:中断优先级不是越大越好。我见过有人把所有中断都设成抢占优先级0(最高级),结果一个高频定时器中断把其他所有中断都堵死了。合理分配优先级,才是嵌入式工程师的基本功。