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️⃣ 声明函数指针
用于访问回调函数,可以简单声明为:
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 硬编码分支,不妨试试回调注册机制,代码会优雅很多。
【往期推荐】
环形队列(FIFO)的 C 语言实现:串口收发、ADC 采集都好用
C 语言状态机标准写法:enum 枚举多状态切换,设备寄存器采集实操示例