夜雨聆风学习资料网

ARTICLE · 1125509

嵌入式软件开发之回调函数注册机制

嵌入式软件开发之回调函数注册机制

一、前言

回调函数是作为参数传递给另一个函数的函数。接受回调作为参数的函数,预计会在某个时间点执行它。回调机制允许下层软件层调用上层软件层定义的函数。

这样做的好处很明显:

  • 增加可重用性;
  • 在程序执行期间可以动态更改被调用的回调函数。

这也是为什么回调机制在嵌入式固件开发中如此常见。


二、什么是回调函数?

C 语言中的回调,是使用函数指针实现的。函数指针就像普通指针一样,但它不是指向变量的地址,而是指向函数的地址。在程序运行期间,可以设置相同的函数指针指向不同的函数。

下面这段代码,展示了如何用函数指针把函数作为参数传递给另一个函数:

uint16_tcal_sum(uint8_t a, uint8_t b){return a + b;}uint16_tcal_mul(uint8_t a, uint8_t b){return a * b;}uint16_tcal_op(uint16_t (*callback_func)(uint8_t, uint8_t), uint8_t a, uint8_t b){   return callback_func(a, b);}voidmain(){    cal_op(cal_mul, 4, 10);     cal_op(cal_sum, 9, 5); }

cal_op 把函数指针和两个整数作为参数。最终执行哪种算术运算,取决于传给函数指针参数的是哪个函数。

💡 同一个 cal_op,传入 cal_mul 就是乘法,传入 cal_sum 就是加法——这就是回调的灵活性。


三、回调函数的实际应用

回调可用于多种情况,并广泛用于嵌入式固件开发。它们提供了更大的代码灵活性,并允许我们开发可由最终用户进行微调而无需更改代码的驱动程序。

在代码中具有回调功能,需要的元素有:

  1. 将被调用的函数(回调函数)
  2. 将用于访问回调函数的函数指针
  3. 将调用回调函数的函数(“调用函数”)

使用回调函数的简单流程如下:

1️⃣ 声明函数指针

用于访问回调函数,可以简单声明为:

uint8_t (*p_CallbackFunc)(void);

对于更清晰的代码,最好定义一个函数指针类型:

typedefuint8_t(*CallbackFunc_t)(void);

2️⃣ 定义回调函数

重要的是要注意:回调函数只是一个函数。由于它的使用方式(通过函数指针访问),我们才将其称为回调。所以这一步只是定义我们之前声明的指针将要指向的函数。

uint8_tHandler_Event(void){/* code of the function */}

3️⃣ 注册回调函数

这是为函数指针分配地址的操作。在我们的例子中,地址应该是回调函数的地址。可以有一个专门的函数来注册回调函数:

static CallbackFunc_t HandlerCompleted;/* 用来注册回调函数的功能函数 */voidCallbackRegister(CallbackFunc_t callback_func){    HandlerCompleted = callback_func;}/* 注册 Handler_Event 作为回调 */CallbackRegister(Handler_Event);

四、代码案例

4.1 事件回调

在这个例子中,我们展示如何使用回调来处理事件。下面的示例代码是基于较低级别物理通信接口(例如 UART、SPI、I2C 等)构建的数据通信协议栈。

通信协议栈实现了两种不同类型的帧——标准通信帧和增强型通信帧。有两种不同的函数用于处理接收到的字节事件。在初始化函数中,函数指针被分配了应该使用的函数地址,用于处理事件。这就是注册回调函数的操作。

/* 指向回调函数的函数指针 */uint8_t (*Receive_Byte)(void);/* * 简化的初始化函数 * 这里函数指针被分配了一个函数的地址(注册回调函数) */voidComm_Init(uint8_t op_mode){switch (op_mode) {case STD_FRAME:                   Receive_Byte = StdRxFSM;break;case ENHANCED_FRAME:          Receive_Byte = EnhancedRxFSM;break;default:        Receive_Byte = EnhancedRxFSM;    }}/* 这些是在通信栈中实现的函数(回调) * 它们不会在任何地方直接调用,而是使用函数指针来访问它们 */uint8_tStdRxFSM(void){// 在这里完成处理工作}uint8_tEnhancedRxFSM(void){// 在这里完成处理工作}

当从物理通信接口(例如 UART)接收到新字节(事件)时,用户应用程序代码会调用我们示例中的回调函数:

externuint8_t(*Receive_Byte)(void);voidreceive_new_byte(){   Receive_Byte(); }

这个设计妙在哪?

  • 上层应用只管调用 Receive_Byte(),不关心底层用的是标准帧还是增强帧;
  • 底层协议栈通过 Comm_Init 决定挂载哪个状态机;
  • 切换帧类型,只需要改初始化参数,不用改一行调用代码。

4.2 寄存器中的多个回调

在嵌入式开发中,我们经常会遇到一个外设需要在不同事件下触发不同回调的场景。例如一个 UART 外设,既有“发送完成”事件,也有“接收完成”事件,甚至还有“接收半满”“发送半满”等中间状态。如果只用一个函数指针,显然无法同时处理这些事件。

这就引出了寄存器中的多个回调机制。

从“单一全局回调”到“实例级多回调”

早期的 HAL 库通常采用弱函数(weak function)覆盖的方式:

__weak voidHAL_UART_TxCpltCallback(UART_HandleTypeDef *huart){/* 默认空实现,用户可重写 */}

这种方式的问题是:一个外设只能有一个全局回调。如果你在项目中的两个模块都使用了 UART,并且都需要自定义发送完成回调,那么后一个模块会覆盖前一个模块的实现,导致冲突。

ST 的解决方案是引入 Register Callback 模型:通过编译开关 USE_HAL_UART_REGISTER_CALLBACKS,允许用户动态注册回调函数,并且这些回调是实例相关(instance-dependent) 的,而不是外设相关(peripheral-dependent)的。

回调指针的存储结构

启用 Register Callback 后,UART 的句柄结构体会增加一组函数指针成员:

structhal_uart_handle_s {/* ... 其他成员 ... */#if defined(USE_HAL_UART_REGISTER_CALLBACKS)hal_uart_cb_t p_tx_half_cplt_cb;      /* 发送半完成回调 */hal_uart_cb_t p_tx_cplt_cb;           /* 发送完成回调 */hal_uart_cb_t p_rx_half_cplt_cb;      /* 接收半完成回调 */hal_uart_rx_cplt_cb_t p_rx_cplt_cb;   /* 接收完成回调 */#endif}

每一个句柄(即每一个 UART 实例)都独立持有自己的回调指针数组。这意味着 huart1 和 huart2 可以注册完全不同的回调函数,互不干扰。

注册接口的设计

HAL 为每一个事件提供了对应的注册函数:

HAL_UART_RegisterCallback(UART_HandleTypeDef *huart,                          HAL_UART_CallbackIDTypeDef CallbackID,                          pUART_CallbackTypeDef pCallback);

调用方式如下:

/* 为 huart1 注册发送完成回调 */HAL_UART_RegisterCallback(&huart1, HAL_UART_TX_COMPLETE_CB_ID, My_TxComplete);/* 为 huart2 注册不同的发送完成回调 */HAL_UART_RegisterCallback(&huart2, HAL_UART_TX_COMPLETE_CB_ID, Another_TxComplete);

初始化时,HAL 会将所有回调指针默认指向弱函数;一旦用户注册了自己的回调,对应的函数指针就被替换为用户实现。

这种设计解决了什么问题?

问题
传统弱函数覆盖
多回调注册机制
多实例冲突
后注册者覆盖前者
每个实例独立存储
动态切换行为
不支持
运行时可随时重注册
恢复默认行为
需要手动删除代码
重新传入默认弱函数即可

一个简化的 C 语言实现示例

如果你在自己的项目中想实现类似的机制,可以这样设计:

typedefstruct {uint8_t uart_id;void (*tx_cplt_cb)(void);void (*rx_cplt_cb)(void);} uart_instance_t;staticuart_instance_t uart_instances[MAX_UART];voiduart_register_tx_callback(uint8_t id, void (*cb)(void)){if (id < MAX_UART) {        uart_instances[id].tx_cplt_cb = cb;    }}/* 在中断服务程序中根据实例 ID 调用对应回调 */voiduart_tx_isr(uint8_t id){if (uart_instances[id].tx_cplt_cb) {        uart_instances[id].tx_cplt_cb();    }}

💡 核心思想:回调函数指针不再是一份“全局变量”,而是每个实例独立拥有一份,通过句柄或 ID 来区分。

小结

寄存器中的多个回调机制,本质是把“回调指针”从“全局弱函数”升级为“实例成员”。它让同一外设的多个实例可以拥有完全独立的行为,同时保留运行时动态切换和恢复默认的能力。在 STM32 HAL 库中,这一机制通过 USE_HAL_xxx_REGISTER_CALLBACKS 编译开关控制,按需启用,不会给不需要的项目增加开销。


五、总结

回调函数的注册机制,本质上就是用函数指针把“调用”和“实现”分离:

角色
作用
回调函数
上层定义的具体业务逻辑
函数指针
访问回调函数的桥梁
注册函数
把回调函数地址赋给函数指针
调用函数
在合适的时机通过指针触发回调

在嵌入式开发中,它的典型价值:

  • 解耦:下层驱动不依赖上层具体实现;
  • 灵活:运行期可动态切换行为;
  • 可扩展:新增帧类型/事件类型,不改调用方;
  • 可重用:同一套框架适配多种场景。

如果你还在用一堆 if-else 或 switch 硬编码分支,不妨试试回调注册机制,代码会优雅很多。


【往期推荐】

充电宝新国标来了!GB 47372-2026全面解读

嵌入式C语言函数设计规范:一个健壮、可读性强的函数模板

环形队列(FIFO)的 C 语言实现:串口收发、ADC 采集都好用

C 语言状态机标准写法:enum 枚举多状态切换,设备寄存器采集实操示例

Bootloader 跳转 APP 后死机?多半是中断没关好

英集芯IP5385偶发充不进电!从现象追踪到 SMBus 通信底层 bug 排查实录

相关学习资料