

芯片均为 STM32F1。不同版本 CubeMX 界面可能略有差异,但中断配置的核心流程一致。
一、前言:中断是检验库理解的试金石
GPIO 点灯阶段,HAL 库和标准库的差别还只是"谁写初始化代码"的问题。但到了中断这里,两条路线的差异会真正暴露出来:
-
标准库:你自己开时钟、配 NVIC、写中断服务函数,每一步都看得见;
-
HAL 库:CubeMX 帮你勾好引脚,但你得搞清楚
HAL_GPIO_EXTI_Callback()和真正的中断服务函数EXTI15_10_IRQHandler()之间隔了几层。
很多人 HAL 库中断学了半天还是晕,就是因为只看到了回调函数,没看到底下那套"分发机制"。这篇文章就用最经典的外部中断(按键控制 LED)例子,把两套流程彻底讲透。
二、知识点讲解:中断配置的三部曲
不管是哪套库,STM32 的外部中断配置永远逃不开三件事:
cs
① 开启时钟 + 配置 GPIO 为输入
② 配置 AFIO / SYSCFG,把引脚连接到 EXTI 线
③ 配置 NVIC(中断优先级 + 使能)
两套库的区别只在"谁帮你做"和"代码长什么样"。
2.1 标准库写法(江协科技风格)
具体的标准库讲解配置可以看我的文章:STM32 EXTI 外部中断:函数全解析 + 5 步实战,从时钟到中断一次打通
以最经典的"按键 EXTI0 控制 LED"为例:
cs
#include "stm32f10x.h" // 标准库总头文件
void Key_Exit_Init(void)
{
GPIO_InitTypeDef GPIO_InitStructure; // GPIO初始化结构体
EXTI_InitTypeDef EXTI_InitStructure; // 外部中断初始化结构体
NVIC_InitTypeDef NVIC_InitStructure; // NVIC初始化结构体
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// ↑ 第一步:开GPIOA时钟,PA0没时钟就不工作
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
// ↑ 第二步:开AFIO时钟!EXTI引脚映射靠AFIO,这个特别容易忘
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入,按键另一端接地
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; // 选PA0
GPIO_Init(GPIOA, &GPIO_InitStructure); // 写入配置
GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0);
// ↑ 第三步:把PA0映射到EXTI Line0,AFIO的选择器
EXTI_InitStructure.EXTI_Line = EXTI_Line0; // 选中断线0
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt; // 中断模式(还有事件模式)
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling; // 下降沿触发(按下为低)
EXTI_InitStructure.EXTI_LineCmd = ENABLE; // 使能这条线
EXTI_Init(&EXTI_InitStructure); // 写入配置
NVIC_InitStructure.NVIC_IRQChannel = EXTI0_IRQn; // EXTI0对应的中断通道
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; // 抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; // 响应优先级
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; // 使能通道
NVIC_Init(&NVIC_InitStructure); // 写入配置
}
// ↓ 中断服务函数:名字必须和启动文件里的向量表一致,写错名字不报错但进不来
void EXTI0_IRQHandler(void)
{
if (EXTI_GetITStatus(EXTI_Line0) != RESET)
// ↑ 标准库必须手动检查:是不是Line0产生的中断(EXTI0~4各自有独立服务函数)
{
GPIO_ToggleBits(GPIOC, GPIO_Pin_13); // 翻转LED
EXTI_ClearITPendingBit(EXTI_Line0);
// ↑ 必须手动清除中断标志位!不清的话会一直反复进中断
}
}
特点 :流程非常线性------开时钟、映射、配 EXTI、配 NVIC、写服务函数、清标志位,一步都不能少,但每一步在干什么完全透明。
2.2 HAL 库写法(正点原子风格)
具体的代码配置讲解可以看正点原子的开发指南!
在 CubeMX 里:把 PA0 设为 GPIO_EXTI0,触发方式选 Falling,NVIC 里勾选 EXTI0 中断,生成代码。得到的是这样一套:
cs
// ↓ 这是CubeMX生成的真·中断服务函数,在stm32f1xx_it.c里
void EXTI0_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
// ↑ HAL的处理方式:自己不干活,转交给统一的HAL处理函数
}
// ↓ 统一的HAL处理函数(HAL库源码里),会帮你做两件事:
// 1. 检查并清除中断标志位 2. 调用回调函数
void HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin)
{
if (__HAL_GPIO_EXTI_GET_IT(GPIO_Pin) != 0x00u) // 判断标志位是否置起
{
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin); // 清除标志位(替你做了!)
HAL_GPIO_EXTI_Callback(GPIO_Pin); // 调用回调函数,业务写在这里
}
}
// ↓ 弱定义(weak)的回调函数,CubeMX在main.c里让你重写它
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_0) // HAL是共用一个回调的,要自己判断是哪根线
{
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); // 翻转LED
}
}
特点 :标志位检查、清除这些"杂活"HAL 都帮你干了,你只在回调函数里写业务逻辑。代价是中断响应路径变长了:EXTI0_IRQHandler → HAL_GPIO_EXTI_IRQHandler → HAL_GPIO_EXTI_Callback,隔了两层函数调用。
2.3 两套库的 NVIC 配置差异
标准库配优先级:
cs
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
// ↑ 整个工程只配一次,决定抢占/响应各占几位,必须在配中断前设置
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; // 抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; // 响应优先级
HAL 库配优先级(CubeMX 生成):
cs
HAL_NVIC_SetPriority(EXTI0_IRQn, 1, 1); // 一行搞定:通道、抢占、响应
HAL_NVIC_EnableIRQ(EXTI0_IRQn); // 单独使能
// ↑ 优先级分组在HAL_Init()里默认设为Group 4,可自己改
三、个人理解/心得
我的理解:HAL 库的中断设计,本质是"回调函数 + 统一分发"的思路。
刚开始我觉得 HAL 太绕了:一个按键中断要跳三层函数才能看到我写的代码。但后来我想知道这到底为什么是现在要都用这个HAL库开发,就上网查了一下到底优势在哪里,后来看一个人写过一个项目里有 6 个外部中断源,我立刻真香了------标准库里要写 6 个 EXTIxx_IRQHandler 并各自清标志位;HAL 库里所有 EXTI 的处理函数都是同一个,最后汇聚到一个 HAL_GPIO_EXTI_Callback(),我用 if (GPIO_Pin == ...) 一分发就完事。中断源越多,HAL 这套结构越省事。
另一个心得是关于学习顺序 的:标准库让我彻底搞懂了"中断标志位"这个概念------它是硬件挂起的"待办事项",不清掉 CPU 就会一直中断当前程序去处理它。理解了这一点之后,再看 HAL 帮我清标志位,就不是"黑盒",而是"替我干活"。底层原理先懂,HAL 的封装才不是负担而是工具。
四、踩坑记录
坑 1:中断服务函数名字写错,编译不报错但中断永远不触发
标准库里 EXTI0_IRQHandler 这个名字必须和启动文件 startup_stm32f10x_md.s 里的向量表一字不差 。我曾经写成 EXTI0_IRQHandle(少个 r),Keil 编译毫不报错------因为它认为你定义了一个普通函数,而真正的中断向量指向了启动文件里的默认空函数。症状就是:配置全对,断点打死都进不去。 后来我的习惯是:名字直接从启动文件里复制,绝不手打。
坑 2:在 HAL 的回调函数里调 HAL_Delay(),程序直接卡死
这个坑在前面 HAL 库的文章里也提过,但用中断时踩中率极高:SysTick 默认优先级不高,HAL_Delay() 靠 SysTick 中断计数,而我在 EXTI 回调(也是中断上下文)里调 HAL_Delay(),SysTick 永远轮不上,死锁。正确做法是中断里只置标志位,延时代码放主循环里做。回调函数里保持"快进快出",这既是 HAL 的规矩,也是所有中断编程的通用纪律。
坑 3:按键抖动导致一次按下触发 N 次中断
这个坑和标准库 HAL 库无关,是中断的通病。机械按键抖动会产生多个下降沿,EXTI 每个沿都触发。我最开始的解决方案是在中断服务函数里加 Delay(20) 消抖------后来发现这办法既拖慢中断响应又治标不治本(还差点踩进坑 3)。最终方案:中断里不消抖,只翻转状态并记录时间戳,或者用定时器中断做周期扫描。 把消抖交给时间,而不是交给延时。
五、总结表格
| 对比维度 | 标准库(江协科技) | HAL 库(正点原子) |
|---|---|---|
| 标志位清除 | 手动 EXTI_ClearITPendingBit() |
HAL 自动清除 |
| 中断服务函数 | 每个通道独立,自己写 | CubeMX 生成,转交统一处理函数 |
| 业务代码位置 | 直接写在服务函数里 | 写在回调函数 HAL_GPIO_EXTI_Callback() |
| 多中断源管理 | 多个服务函数,各自为政 | 统一分发,一个回调里判断引脚 |
| NVIC 配置 | 分组需先单独设置 | HAL_NVIC_SetPriority() 一行搞定 |
| 响应路径 | 短,直捣寄存器 | 长,隔了两层调用 |
| 出错可调试性 | 出错就是寄存器没配对,好查 | 要理清调用链才能定位 |
| 适合场景 | 学习原理、中断源少的项目 | 中断源多、赶进度的项目 |
一句话总结:标准库让你亲手摸一遍中断的每个环节,HAL 库把这些环节打包成"回调函数"直接给你。先懂原理,再用工具。
正点原子的 HAL 库与江科大标准库对比
正点原子和江科大是 STM32 学习中常见的两个教学资源,它们在库函数使用上各有侧重。正点原子更倾向于 HAL 库 的教学,代码风格更趋向于偏向就业的企业级风格,而江科大则以 标准库 为主,更适合0基础的我们准备入门嵌入式的。如果我们已经学了江科大的标准库了,我们就可以挑着看正点原子的STM32HAL开发!
📌 参考资料:正点原子 HAL 库开发指南、江协科技 STM32 入门教程、《STM32F10xxx参考手册》中的 EXTI 与 NVIC 章节
写这篇文章的时候,我又对照正点原子的例程把 HAL_GPIO_EXTI_IRQHandler 的源码逐行读了一遍,才终于把"回调函数"这层窗户纸捅破。原来 HAL 并不是什么黑魔法,它只是把标准库里我亲手写的清标志位、判断中断线这些动作,默默替我做了而已。想明白这一层之后,再看两种库的代码,忽然觉得它们没那么对立了------殊途同归,只是一个教你怎么做,一个帮你做。
如果本文对你有帮助,欢迎点赞 👍 收藏 ⭐ !有任何问题欢迎在评论区交流,一起学习进步!
