

前言
学完了中断、NVIC、EXTI,又啃完了 USART 的寄存器,结果一打开正点原子的例程,人傻了:
void USART1_IRQHandler(void)
{
HAL_UART_IRQHandler(&g_uart1_handle); // 中断服务函数里就这一句?
}
我代码写哪儿?接收到的数据在哪儿?说好的"中断来了进中断函数写业务"呢?
别急,这正是我要解决的问题:HAL 库的回调机制。这玩意儿是 HAL 库的灵魂,搞懂了它,后面串口、定时器、SPI、I2C 全是同一套套路,越学越顺。这篇就用人话把它彻底讲明白。
一、知识点讲解:HAL 库回调机制到底是个啥
1.1 一句话版本
HAL 库把"中断来了之后该干的脏活累活"都帮你干完了(读标志位、清标志位、搬数据),然后在关键节点上留了一个"钩子"函数让你填自己的代码,这个钩子就叫回调函数(HAL_GPIO_EXTI_Callback)。
HAL 库中断处理与回调函数调用关系(来源:正点原子第 70 讲课件)
1.2 对比标准库,痛点在哪儿
以前写标准库,中断服务函数得自己从头到尾写:
void USART1_IRQHandler(void) // 标准库风格:什么都自己来
{
if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) // ① 自己判断是不是接收中断
{
u8 data = USART_ReceiveData(USART1); // ② 自己读数据
USART_ClearITPendingBit(USART1, USART_IT_RXNE); // ③ 自己清标志位(不清就反复进中断!)
/* ④ 自己的业务代码写在这里 */
}
}
问题很明显:每个中断都要自己判断标志、自己清标志,写错一步就是"中断卡死"或者"进不去中断"。
HAL 库的思路是:这些固定动作我全包了,你只写第 ④ 步,这就是HAL库的优势。
1.3 完整调用链:一个串口字节是怎么到你手里的
以"串口 1 中断接收 1 个字节"为例,整条链路是这样的:
串口收到数据
↓ (硬件置位 RXNE 标志)
NVIC 把 USART1 的中断向量表入口指向 USART1_IRQHandler
↓
USART1_IRQHandler(void) ← 启动文件里的中断服务函数(用户文件)
就一句话:调用 HAL_UART_IRQHandler(&huart1)
↓
HAL_UART_IRQHandler(UART_HandleTypeDef *huart) ← HAL 库函数,干了约 200 行的活
├─ 读 SR、CR1、CR3 寄存器,判断到底是接收中断还是发送中断还是错误中断
├─ 是接收中断 → 调用内部函数 UART_Receive_IT(huart)
│ ├─ 从 DR 寄存器读出数据,存进你之前指定的缓冲区
│ ├─ 自动清除 RXNE 标志位
│ └─ 如果收够了约定个数 → 调用 HAL_UART_RxCpltCallback(huart) ★ 回调就是这里
↓
HAL_UART_RxCpltCallback(huart) ← 默认是空函数(__weak),等你重定义
你自己的业务代码在这里执行
三级调用链梳理
我的理解:把这条链想象成"外卖配送"。HAL_UART_IRQHandler 是外卖平台(接单、调度、送达全包),HAL_UART_RxCpltCallback 是你家门口的接收点——饭送到了(数据收够了)平台自动敲你家门,你只需要开门拿饭(处理数据)。你既不用关心餐厅怎么做饭(读 DR),也不用管路上怎么骑(清标志位)。
1.4 __weak:回调函数为什么"可覆盖"
打开 stm32f1xx_hal_uart.c,能看到这么一段:
__weak void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) // __weak 关键字:弱定义
{
UNUSED(huart); // 防止编译器报"参数未使用"警告,HAL 的惯用写法
/* NOTE: This function should not be modified, when the callback is needed,
the HAL_UART_RxCpltCallback could be implemented in the user file */
/* 注释翻译:需要用到回调时,请在用户文件里重新实现这个函数,别改我 */
}
__weak 是 ARM 编译器(Keil)的扩展关键字,意思是"弱函数":
-
如果整个工程里只有这一个定义,链接器就用它(所以默认回调是个空函数,程序不会崩);
-
如果你在自己的文件里写了一个同名、同参数、不带 __weak 的函数,链接器就会优先采用你的版本,HAL 库里那个空壳被丢弃。
这就实现了一个特别优雅的设计:"框架留钩子,用户填内容"。你哪怕一个回调都不写,工程照样能编译能跑(只是啥也不做)。
1.5 常用回调函数速览
| UART | HAL_UART_RxCpltCallback | 接收完成(收够指定字节数) |
| UART | HAL_UART_TxCpltCallback | 发送完成 |
| UART | HAL_UART_ErrorCallback | 发生帧错误/噪声/溢出等错误 |
| EXTI | HAL_GPIO_EXTI_Callback | 外部中断触发 |
| TIM | HAL_TIM_PeriodElapsedCallback | 定时器溢出更新(定时到点) |
| TIM | HAL_TIM_IC_CaptureCallback | 输入捕获到边沿 |
| SPI | HAL_SPI_TxRxCpltCallback | SPI 收发完成 |
| ADC | HAL_ADC_ConvCpltCallback | ADC 转换完成 |
⚠️ 注意:所有定时器共用一个 HAL_TIM_PeriodElapsedCallback,所以里面必须先用 if (htim->Instance == TIMx) 判断是哪个定时器(下面代码里有示范)。
1.5 HAL 库外设初始化 MSP 回调机制
中断回调不是 HAL 回调的全部。初始化也有回调:用户调用 HAL_PPP_Init() 配置工作参数(波特率、模式这些),它内部会自动调用 HAL_PPP_MspInit() 去配置该外设用到的硬件——GPIO、NVIC、时钟。和回调函数一样,HAL_PPP_MspInit 也被 __weak 修饰,用户可以在自己的文件里重定义它(正点原子例程就是这么干的,所有串口的 HAL_UART_MspInit 都集中写在 uart.c 里,通过判断句柄里的寄存器基地址区分是哪个串口)。一个 PPP 外设共用一份 MSP 函数,文件不好管理时可以不重定义它,直接在初始化里配硬件——课件的这点提示值得记住。
HAL 库外设初始化 MSP 回调机制(来源:正点原子第 70 讲课件)
1.6 实战代码:串口中断接收 + 回调处理(每行带解析)
/* ============ uart.c 里的用户代码 ============ */
uint8_t g_rx_byte; // 接收缓冲区:只装 1 个字节
/**
* @brief 串口1接收完成回调函数(重定义 HAL 弱函数,每行解析)
* @param huart: 触发回调的串口句柄指针(哪个串口调用它就是谁)
* @note 此函数由 HAL_UART_IRQHandler 自动调用,禁止手动调用
*/
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) // 第1行:判断是不是串口1触发的(多串口共用一个回调,必须区分!)
{
HAL_UART_Transmit(&huart1, &g_rx_byte, 1, 100); // 第2行:把收到的 1 字节原样发回(串口回显验证用)
HAL_UART_Receive_IT(&huart1, &g_rx_byte, 1); // 第3行:★关键★ 重新开启接收中断!
// 原因:HAL 库接收是"一次性"的,收满 1 字节后中断就关闭了,
// 不重开的话,第二次收到的数据再也不会进这个回调(新手第一大坑!)
}
}
/* ============ 初始化部分(以正点原子例程风格简化) ============ */
void uart_init(uint32_t baudrate)
{
g_uart1_handle.Instance = USART1; // 指定外设:串口1
g_uart1_handle.Init.BaudRate = baudrate; // 波特率,如 115200
g_uart1_handle.Init.WordLength = UART_WORDLENGTH_8B; // 8 位数据位
g_uart1_handle.Init.StopBits = UART_STOPBITS_1; // 1 位停止位
g_uart1_handle.Init.Parity = UART_PARITY_NONE; // 无校验
g_uart1_handle.Init.Mode = UART_MODE_TX_RX; // 收发模式都开
g_uart1_handle.Init.HwFlowCtl = UART_HWCONTROL_NONE; // 不用硬件流控
HAL_UART_Init(&g_uart1_handle); // 调用 HAL 初始化(内部会自动调用 HAL_UART_MspInit 配 GPIO/中断)
HAL_UART_Receive_IT(&g_uart1_handle, (uint8_t*)&g_rx_byte, 1); // 开启接收中断:收 1 字节就回调一次
HAL_NVIC_SetPriority(USART1_IRQn, 1, 0); // 设置中断优先级:抢占 1,子优先级 0
HAL_NVIC_EnableIRQ(USART1_IRQn); // 使能串口1中断通道
}
/* ============ 中断服务函数(正点原子习惯放在 uart.c) ============ */
void USART1_IRQHandler(void)
{
HAL_UART_IRQHandler(&g_uart1_handle); // 全部交给 HAL 处理,别在这里加业务代码!
}
1.7 外部中断的回调长啥样(顺手掌握)
/**
* @brief EXTI 外部中断回调(同样 __weak 弱定义,重定义即可)
* @param GPIO_Pin: 触发中断的引脚编号,判断是哪个引脚
*/
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == KEY0_PIN) // 第1行:判断是不是 KEY0 按键引脚触发的
{
LED0_TOGGLE(); // 第2行:翻转 LED0(按键按一次灯状态切一次)
}
}
二、个人理解与心得
以下为本人的一些理解,不一定完全准确,仅供参考,欢迎评论区交流指正。
1. 回调机制本质上是 C 语言"函数指针 + 弱符号"的工程化应用。 视频里一句话带过,我课后翻了翻 HAL 源码才反应过来:__weak 让"默认实现"和"用户实现"可以共存,链接器自动做二选一。
2. 为什么非要搞回调,而不让我直接写 IRQHandler? 我的理解:一是统一,所有外设中断入口都收敛成 HAL_PPP_IRQHandler 一个函数,HAL 好维护;二是分层,驱动层(读寄存器)和应用层(处理数据)彻底分开,移植代码时几乎不用改;三是安全,标志位清零这种"忘了就死机"的操作收归库管,把新手最大的坑填上了。
三、踩坑记录
坑 1:回调只进一次,之后串口就"哑了"(最痛) 现象:第一次发送能收到回显,之后再发毫无反应。 原因:HAL_UART_Receive_IT 是一次性的,收满指定字节后 HAL 自动关闭接收中断。 解决:在回调函数最后必须再调一次 HAL_UART_Receive_IT 重新开启。
坑 2:多个串口一起用,A 串口触发回调却处理了 B 的数据 现象:USART1 发数据,结果 USART2 的逻辑被执行了。 原因:所有 UART 共用一个 HAL_UART_RxCpltCallback。 解决:函数第一行必须加 if (huart->Instance == USART1) 判断句柄里的实例,这是开始就该养成的习惯。
坑 3:在 USART1_IRQHandler 里塞业务代码,时好时坏 现象:把数据处理写进 IRQHandler,偶尔丢字节。 原因:IRQHandler 里 HAL 还没处理完所有标志,自己加代码容易打乱状态机,且中断里执行太久会丢数据。 解决:业务代码一律放回调,IRQHandler 只留 HAL_UART_IRQHandler 一句话。
四、总结表格
| 回调本质 | HAL 在事件完成时通过函数指针反调用户函数,用户用"重定义 __weak 函数"的方式接入 |
| 弱函数规则 | 用户定义同名同参函数(不带 __weak)→ 链接器采用用户版本;不定义 → 用空默认 |
| 调用链 | 中断触发 → PPP_IRQHandler → HAL_PPP_IRQHandler → HAL_PPP_XxxCpltCallback |
| 用户要做的事 | ① 正常初始化 + 开启中断(如 HAL_UART_Receive_IT)② 在回调里写业务 ③ 回调末尾按需重新开启中断 |
| 三不要 | 不要改库文件、不要在 IRQHandler 塞业务、不要忘了区分多外设实例 |
| 常见回调 | RxCpltCallback / PeriodElapsedCallback / GPIO_EXTI_Callback / ErrorCallback 等 |
写这篇文章的时候,我把正点原子的视频又回顾了一遍,配合源码逐行跟踪了一遍 HAL_UART_IRQHandler,以前"回调为啥会被自动调用"的疑惑才算彻底解开。HAL 库刚开始看确实觉得绕,但把这条调用链走通一次之后,再回头看,其实就一层窗户纸。
📌 开发环境:Keil5 + STM32F103 开发板 + ST-Link(F4/F7 板子同样适用)
📌 参考资料:正点原子《手把手教你学 STM32 HAL 库开发》第 69~71 讲、ST 官方《STM32F1xx HAL 库参考手册》、《STM32F10xxx 参考手册》
如果本文对你有帮助,欢迎点赞 👍 收藏 ⭐ !有任何问题欢迎在评论区交流,一起学习进步!



