第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,说白了就三步:
- 选引脚:哪个GPIO引脚要当中断源
- 选触发方式:上升沿、下降沿、还是双边沿
- 连中断线: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为例,中断来了之后是这样的:
- CPU跳转到
EXTI0_IRQHandler() - HAL库内部调用
HAL_GPIO_EXTI_IRQHandler() - 这个函数清除中断标志位,然后调用
HAL_GPIO_EXTI_Callback() - 你在回调函数里写自己的逻辑
关键点:回调函数是在中断上下文中执行的。所以回调里不能做耗时操作,比如延时、打印、复杂计算。我一般只在回调里设置一个标志位,主循环去处理。
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 中断系统知识图谱
我把整个中断系统的脉络画成了一张图,方便你理解各个模块之间的关系:
这张图把中断系统的三个层次串起来了:中断源产生事件,NVIC决定优先级,HAL库提供回调机制。你写代码时,脑子里要有这个结构。
最后提醒一句:中断优先级不是越大越好。我见过有人把所有中断都设成抢占优先级0(最高级),结果一个高频定时器中断把其他所有中断都堵死了。合理分配优先级,才是嵌入式工程师的基本功。