回调机制与 HAL 中断处理
HAL 库的精髓在于"回调"。理解 HAL 如何将硬件中断转化为用户可控的回调函数,是编写健壮嵌入式代码的关键。本章剖析 HAL 的中断处理链,并展示 GSRL 如何在这一机制之上构建 Driver 层。
上一篇:中断与 NVIC
1. 概念——回调解决了什么问题
1.1 问题的提出
每个外设中断都需要一个 ISR(中断服务函数),而 ISR 的名字和数量由启动文件中的中断向量表固定。例如 CAN1 接收中断的 ISR 必须叫 CAN1_RX0_IRQHandler。
如果让你直接在 CAN1_RX0_IRQHandler 里写数据处理逻辑:
// ❌ 问题1:直接耦合
void CAN1_RX0_IRQHandler(void)
{
// CAN1 收电机反馈 → 解析 → 更新电机状态
// CAN1 收裁判系统 → 解析 → 更新裁判数据
// 两种完全不同的逻辑混在一个 ISR 里
}
// ❌ 问题2:不可复用
// 另一个工程也用 CAN1,但外设完全不同,ISR 代码得重写
HAL 库的解决方案:
CAN1_RX0_IRQHandler (固定入口,CubeMX 生成)
│
▼
HAL_CAN_IRQHandler (HAL 库统一分发,判中断源)
│
▼
HAL_CAN_RxFifo0MsgPendingCallback (用户实现的回调,可自由定义)
ISR 只做通用的中断清理(清标志位、读寄存器),具体的业务逻辑交给回调函数。这样用户只需要实现回调,无需关心 ISR 入口和中断标志。
为什么要把 ISR 和用户回调分开? 这种分层设计有两个关键好处:第一,容错隔离——ISR 中的寄存器清理是硬件正确运行的前提(不清理标志位会导致中断反复触发),这部分逻辑不能出错。如果用户直接在 ISR 中写业务代码,一旦用户代码访问野指针或除零触发 HardFault,整个中断处理链就断了,系统难以恢复。分层后,HAL 的 ISR 先把硬件"伺候好"(清标志、读数据),再调用用户回调——即使回调崩溃,至少中断系统还能正常运行。第二,多路复用——同一个 CAN 中断可能对应多个用户(电机模块订阅、裁判系统模块订阅),分层后可以在中间层维护一个回调列表,一个中断触发,通知所有订阅者。
1.2 回调的本质
回调(Callback)即"事件发生时,由框架调用你的函数"。你注册一个函数指针,框架在合适的时机调用它。
// 伪代码:回调的本质
typedef void (*CallbackFunc)(void *data); // 回调函数类型
struct Driver {
CallbackFunc userCallback; // 存储用户提供的函数指针
};
// 用户注册回调
driver.userCallback = myHandler;
// 框架在事件发生时调用
void onEvent(struct Driver *drv) {
if (drv->userCallback != NULL) {
drv->userCallback(eventData); // 回调用户函数
}
}
2. HAL 的中断处理链路
2.1 完整链路图
以 CAN 接收中断为例,完整的调用链路如下:
┌─────────────────────────────────────────────────────┐
│ 硬件层 │
│ CAN 控制器 RX FIFO 非空 → 产生中断信号 │
└────────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ 中断向量表 → CAN1_RX0_IRQHandler │
│ (startup_stm32f407xx.s / stm32f4xx_it.c) │
│ │
│ void CAN1_RX0_IRQHandler(void) │
│ { │
│ HAL_CAN_IRQHandler(&hcan1); ← 传入句柄 │
│ } │
└────────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ HAL_CAN_IRQHandler (HAL 库) │
│ │
│ ① 检查中断状态寄存器,确认是哪个事件 │
│ ② 读取数据:HAL_CAN_GetRxMessage() │
│ ③ 清除中断标志 │
│ ④ 调用回调函数 │
│ │
│ if (__HAL_CAN_GET_FLAG(hcan, CAN_FLAG_RF0M)) │
│ { │
│ __HAL_CAN_CLEAR_FLAG(hcan, CAN_FLAG_RF0M); │
│ HAL_CAN_GetRxMessage(hcan, ..., &header, data);│
│ #if USE_HAL_CAN_REGISTER_CALLBACKS │
│ hcan->RxFifo0MsgPendingCallback(hcan); │
│ #else │
│ HAL_CAN_RxFifo0MsgPendingCallback(hcan); │
│ #endif │
│ } │
└────────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ HAL_CAN_RxFifo0MsgPendingCallback (GSRL 实现) │
│ (文件: GSRL/Driver/src/drv_can.c) │
│ │
│ void HAL_CAN_RxFifo0MsgPendingCallback(...) │
│ { │
│ ① 从 HAL 句柄读取数据 │
│ ② 入 FreeRTOS 队列 │
│ ③ 调用用户注册的回调函数(如果有) │
│ } │
└────────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ 用户回调 (如 tsk_test.cpp 中的 can1RxCallback)│
│ │
│ void can1RxCallback(can_rx_message_t *pRxMsg) │
│ { │
│ motor.decodeCanRxMessageFromISR(pRxMsg); │
│ // 解析电机反馈数据 │
│ } │
└─────────────────────────────────────────────────────┘
2.2 HAL 回调的类型
HAL 为每个外设定义了一套 __weak 回调函数:
| 外设 | 常见回调 | 触发时机 |
|---|---|---|
| CAN | HAL_CAN_RxFifo0MsgPendingCallback |
CAN 收到数据 |
| UART | HAL_UARTEx_RxEventCallback |
UART 空闲中断(一帧收完) |
| UART | HAL_UART_ErrorCallback |
UART 通信错误 |
| SPI | HAL_SPI_TxRxCpltCallback |
SPI 收发完成 |
| SPI | HAL_SPI_ErrorCallback |
SPI 通信错误 |
| TIM | HAL_TIM_PeriodElapsedCallback |
定时器溢出 |
| GPIO | HAL_GPIO_EXTI_Callback |
外部中断触发 |
__weak 意味着 HAL 库提供了默认的空实现,用户可以在自己的代码中重写:
// HAL 库中的弱定义(什么都不做)
__weak void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan)
{
/* NOTE: This function should not be modified, and can be implemented
in the user file */
}
// 用户在 drv_can.c 中重写(强定义覆盖弱定义)
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan)
{
// 实际的数据处理逻辑
}
为什么用弱定义(__weak)? 这是嵌入式软件开发中一个关键的工程模式——实现"生成代码与用户代码的分离"。HAL 库的源码和 CubeMX 生成的代码都不需要被用户修改,所有回调默认指向空实现,编译链接完全正常。当用户需要某个回调时,只需在自己的文件中定义一个同名函数,链接器就会自动用"强定义"覆盖默认的"弱定义"。这意味着:HAL 库升级时,用户只需替换库文件,自己的回调代码毫发无伤;CubeMX 重新生成代码时,也不会覆盖用户的回调实现(因为回调写在独立的用户文件中)。这种"弱-强覆盖"机制让固件工程师可以在不修改第三方代码的前提下定制行为,是 STM32 开发生态中最优雅的设计之一。
3. GSRL Driver 层的回调管理
3.1 问题:一个外设,多种用途
同一个 CAN 总线上可能挂着电机、裁判系统、超级电容等多种设备。HAL 层的回调只有一个入口,如何区分数据去向?
GSRL 的解决方案:在 Driver 层实现 HAL 回调,内部维护用户注册的回调函数指针。
以 CAN 驱动为例(drv_can.c):
// CAN 管理对象
typedef struct {
CAN_HandleTypeDef *hcan; // HAL 句柄
CAN_Call_Back rxCallbackFunction; // 用户注册的回调
} CAN_Manage_Object_t;
// 初始化时注册回调
void CAN_Init(CAN_HandleTypeDef *hcan, CAN_Call_Back rxCallbackFunction)
{
CAN_Manage_Object_t *can_obj = CAN_Get_Object(hcan);
can_obj->hcan = hcan;
can_obj->rxCallbackFunction = rxCallbackFunction; // 保存用户回调
// ...
}
// HAL 回调中:先入队列,再调用用户回调
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan)
{
can_rx_message_t s_rx_msg;
HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &s_rx_msg.header, s_rx_msg.data);
// 1. 放入 FreeRTOS 队列(给任务线程使用)
xQueueSendToBackFromISR(canRxQueueHandle, &s_rx_msg, &woken);
// 2. 调用用户注册的回调(给 ISR 直接处理)
CAN_Manage_Object_t *can_obj = CAN_Get_Object(hcan);
if (can_obj->rxCallbackFunction != NULL) {
can_obj->rxCallbackFunction(&s_rx_msg);
}
}
3.2 UART 驱动的回调
UART 也采用同样的模式(drv_uart.c):
typedef struct {
UART_HandleTypeDef *huart;
UART_Call_Back rxCallbackFunction; // 用户回调
uint8_t rxBuffer[UART_BUFFER_SIZE]; // DMA 接收缓冲区
uint16_t rxDataLimit; // 最大接收长度
} UART_Manage_Object_t;
// 初始化:启动 DMA 接收,注册回调
void UART_Init(UART_HandleTypeDef *huart, UART_Call_Back rxCallbackFunction,
uint16_t rxDataLimit)
{
UART_Manage_Object_t *uart_obj = UART_Get_Object(huart);
uart_obj->rxCallbackFunction = rxCallbackFunction;
HAL_UARTEx_ReceiveToIdle_DMA(huart, uart_obj->rxBuffer, rxDataLimit);
}
// HAL 空闲中断回调
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
UART_Manage_Object_t *uart_obj = UART_Get_Object(huart);
if (uart_obj->rxCallbackFunction != NULL) {
uart_obj->rxCallbackFunction(uart_obj->rxBuffer, Size);
}
// 重启 DMA 接收,准备下一帧
HAL_UARTEx_ReceiveToIdle_DMA(huart, uart_obj->rxBuffer, uart_obj->rxDataLimit);
}
为什么必须用 DMA + IDLE 中断接收 UART 数据? UART 数据帧的长度通常是可变的(遥控器数据 18 字节、裁判系统数据可能 128 字节),如果没有 IDLE 中断,接收方无法判断一帧数据什么时候结束——只能在每个字节到达时盲目地往缓冲区里塞。而如果每个字节都触发一次中断(字节中断模式),在 115200 bps 波特率下,每秒会有约 11520 个字节 → 11520 次中断 → 每次中断至少几十个 CPU 周期,合计约 8.6% 的 CPU 时间全部浪费在 UART 接收上。DMA 解决了"谁来搬数据"的问题:硬件自动将每个收到的字节写入内存,CPU 完全不参与。IDLE 中断解决了"什么时候一帧结束"的问题:当总线空闲超过一个字节时间,硬件自动产生 IDLE 中断,此时 DMA 已将所有收到的字节搬完,回调只需取走完整的一帧。DMA + IDLE 的组合让 CPU 从"逐字节接客"变成"收完一帧叫我",效率提升数百倍。
3.3 用户侧的使用方式
在任务中分别初始化不同外设的驱动,注册各自的回调:
// tsk_test.cpp
extern "C" void test_task(void *argument)
{
CAN_Init(&hcan1, can1RxCallback); // CAN1: 处理电机反馈
UART_Init(&huart3, dr16ITCallback, 36); // UART3: 处理遥控器数据
// ...
}
// CAN 接收回调
extern "C" void can1RxCallback(can_rx_message_t *pRxMsg)
{
motor.decodeCanRxMessageFromISR(pRxMsg);
}
// UART 接收回调
extern "C" void dr16ITCallback(uint8_t *Buffer, uint16_t Length)
{
dr16.receiveRxDataFromISR(Buffer);
}
4. 中断上半部 vs 下半部
4.1 模式对比
在嵌入式系统中,"中断处理"通常分为两半:
┌──────────────────────────────────────────────┐
│ 上半部 (Top Half / ISR 中) │
│ - 读取硬件寄存器数据 │
│ - 清除中断标志 │
│ - 将数据放入缓冲区/队列 │
│ - 执行时间:微秒级 │
│ - 不允许阻塞/耗时操作 │
├──────────────────────────────────────────────┤
│ 下半部 (Bottom Half / 任务中) │
│ - 解析协议 │
│ - PID 计算 │
│ - 状态机更新 │
│ - 发送响应数据 │
│ - 执行时间:毫秒级 │
│ - 可以被高优先级任务打断 │
└──────────────────────────────────────────────┘
4.2 GSRL 中的体现
GSRL 的 CAN 驱动同时支持两种路径:
CAN 中断触发
│
┌─────────────┴─────────────┐
▼ ▼
┌───────────────┐ ┌───────────────────┐
│ 用户回调 (ISR) │ │ FreeRTOS 队列 │
│ 直接解析数据 │ │ 延迟到任务中解析 │
│ (轻量操作) │ │ (重量操作) │
└───────────────┘ └──────────┬────────┘
│ │
│ ▼
│ ┌───────────────────┐
│ │ 任务线程中调用 │
│ │ decodeCanRxMessage │
│ │ FromQueue() │
└──────────┬────────┘───────────────────┘
│
▼
┌─────────────────┐
│ Motor 对象 │
│ 更新电机状态 │
└─────────────────┘
如何选择用回调还是队列? 这是一个执行时间和操作复杂度的权衡。ISR 回调适合 1~2 微秒级的极轻量操作——例如把 CAN 数据帧中的某几个字节提取出来赋值给一个标志位。FreeRTOS 队列适合涉及状态机、协议解析或多级计算的复杂操作。举一个具体例子:电机通过 CAN 发回位置反馈(8 字节原始数据),在 ISR 回调中只做"提取 8 字节 raw data 放到一个结构体"(约 2μs),然后通过队列发给 Motor 任务;Motor 任务收到后执行完整的协议解析、零点偏移补偿、单位换算,最终更新电机对象的状态(约 50μs)。如果这 50μs 的操作放在 ISR 回调中,不仅阻塞所有低优先级中断,还会累积出可观的 CPU 占用。
在 Motor 类中对应两个方法:
// ISR 中直接调用(上半部)
bool decodeCanRxMessageFromISR(const can_rx_message_t *rxMessage);
// 任务中从队列取出后调用(下半部)
bool decodeCanRxMessageFromQueue(const can_rx_message_t *rxMessage, uint8_t Size);
5. 常见陷阱
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
| ISR 中调用阻塞函数 | 系统死锁或优先级反转 | ISR 只能用 FromISR 后缀的 FreeRTOS API |
| ISR 中执行耗时计算 | 低优先级中断永远得不到响应 | 把复杂逻辑移到任务中 |
| 忘记清除中断标志 | 中断反复触发,CPU 被 ISR 占满 | HAL 已处理,直接操作寄存器时要注意 |
| 回调函数未注册 | 收到数据但无人处理 | 初始化时确保注册了有效的回调函数 |
| 在回调中忘记重启 DMA 接收 | 只接收了一帧就停止 | UART 空闲回调末尾必须重调用 ReceiveToIdle_DMA |
6. 总结
| 要点 | 说明 |
|---|---|
| 回调机制 | ISR(固定入口)→ HAL 分发 → 用户回调(弱定义覆盖) |
| GSRL Driver 层 | 实现 HAL 回调,维护用户注册的回调函数指针 |
| 两种回调路径 | ISR 中直接回调(轻量)+ FreeRTOS 队列(重量级延迟处理) |
| 上半部/下半部 | ISR 只做数据搬运,任务线程做协议解析和业务逻辑 |
| UART 空闲中断 | 一帧数据接收完成后触发,处理后需重启 DMA 接收 |
与 GSRL 的关系
GSRL 的 Driver 层(drv_can.c、drv_uart.c、drv_spi.c)正是围绕 HAL 回调机制设计的。它重写了 HAL 的 __weak 回调函数,在回调内部管理 FreeRTOS 队列和用户注册的回调链,屏蔽了裸 HAL 的使用复杂度。
下一篇:定时器与 PWM
作者: Qing | 修改日期: 2026-07-18