在我们平时开发STM32或者其它单片机时,我们经常都会用到原厂提供的固件库函数,固件库函数中有非常多回调函数。那么,什么是回调函数呢?
回调函数是作为参数传递给另一个函数的函数。接受回调作为参数的函数预计会在某个时间点执行它。回调机制允许下层软件层调用上层软件层定义的函数。
2fbcdd64d9974a3b85dacb2f4769358f~noop.image?_iz=58558&from=article.jpg

上图表示用户应用程序代码和硬件驱动程序之间的交互。硬件驱动程序是一个独立的可重用驱动程序,它不了解上面的层(在本例中为用户应用程序)。硬件驱动程序提供 API 函数,允许用户应用程序将函数注册为回调。然后,此回调函数由硬件驱动程序作为执行的一部分进行调用。如果不使用回调,就会被编码为直接调用。这将使硬件驱动程序特定于特定的高级软件级别,并降低其可重用性。回调机制的另一个好处是,在程序执行期间可以动态更改被调用的回调函数。

1、C语言中的回调

不同的编程语言有不同的实现回调的方式。在本文中,我们将重点介绍C编程语言,因为它是用于嵌入式软件开发的最流行的语言。C语言中的回调是使用函数指针实现的。函数指针就像普通指针一样,但它不是指向变量的地址,而是指向函数的地址。在程序运行期间,可以设置相同的函数指针指向不同的函数。在下面的代码中,我们可以看到如何使用函数指针将函数作为参数传递给函数。该函数将函数指针和两个整数值作为参数和。将执行的算术运算取决于将传递给函数指针参数的函数。
uint16_t cal_sum(uint8_t a, uint8_t b) {
  •     return a + b;
  • }
  • uint16_t cal_mul(uint8_t a, uint8_t b) {
  •     return a * b;
  • }
  • uint16_t cal_op (uint16_t (*callback_func)(uint8_t, uint8_t),uint8_t a, uint8_t b) {   
  •     return callback_func(a,b);
  • }
  • void main() {
  •     cal_op(cal_mul,4,10);
  •     cal_op(cal_sum,9,5);
  • }
  • 复制代码
    2、回调的实际使用
    回调可用于多种情况,并广泛用于嵌入式固件开发。它们提供了更大的代码灵活性,并允许我们开发可由最终用户进行微调而无需更改代码的驱动程序。
    在我们的代码中具有回调功能所需的元素是:

    • 将被调用的函数(回调函数)
    • 将用于访问回调函数的函数指针
    • 将调用回调函数的函数("调用函数")
    接下来,介绍一下使用回调函数的简单流程。首先声明一个函数指针,用于访问回调函数我们可以简单地将函数指针声明为:
    uint8_t (*p_CallbackFunc)(void);
    复制代码
    但是,对于更清晰的代码,最好定义一个函数指针类型:
    typedef uint8_t (*CallbackFunc_t) (void);
    复制代码
    定义回调函数——重要的是要注意回调函数只是一个函数。由于它的使用方式(通过函数指针访问),我们将其称为回调。所以这一步只是我们之前声明的指针将指向的函数的定义。
    uint8_t Handler_Event(void) {
  • /* code of the function */
  • }
  • 复制代码
    注册回调函数——这是为函数指针分配地址的操作。在我们的例子中,地址应该是回调函数的地址。可以有一个专门的函数来注册回调函数,如下所示:
    static CallbackFunc_t HandlerCompleted;
  • /*用来注册回调函数的功能函数*/
  • void CallbackRegister (CallbackFunc_t callback_func) {
  •      HandlerCompleted = callback_func;
  • }
  • /* 注册Handler_Event作为回调*/
  • CallbackRegister(Handler_Event);
  • 复制代码
    3、代码应用案例
    3.1、事件回调
    在这个例子中,我们展示了如何使用回调来处理事件。下面的示例代码是基于较低级别物理通信接口(例如 UART、SPI、I2C 等)构建的数据通信协议栈。通信协议栈实现了两种不同类型的帧——标准通信帧和增强型通信帧。有两种不同的函数用于处理接收到的字节事件。在初始化函数中,函数指针被分配了应该使用的函数的地址用于处理事件。这是注册回调函数的操作。
    /*指向回调函数的函数指针*/
  • uint8_t ( *Receive_Byte) ( void );
  • /*
  • * 简化的初始化函数
  • * 这里函数指针被分配了一个函数的地址(注册回调函数)
  • */
  • void Comm_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_t  StdRxFSM(void) {
  •     //在这里完成处理工作
  • }
  • uint8_t  EnhancedRxFSM(void) {
  •     //在这里完成处理工作
  • }
  • 复制代码
    当从物理通信接口(例如 UART)接收到新字节(事件)时,用户应用程序代码会调用我们示例中的回调函数。
    extern uint8_t (*Receive_Byte)( void );
  • void receive_new_byte()
  • {
  •    Receive_Byte();
  • }
  • 复制代码
    3.2、寄存器中的多个回调

    这个例子展示了我们如何创建一个寄存器来存储回调函数。它是使用数据类型元素的数组实现的。数据类型是具有成员和成员的结构。用于为寄存器中的每个回调函数分配一个标识(唯一编号)。函数指针被分配与唯一关联的回调函数的地址。以下实现的是添加和删除回调的功能:
    #define FUNC_REGISTER_SIZE 255
  • #define FUNC_ID_MAX 127
  • //函数指针类型
  • typedef  uint8_t (*callback_func) ( uint8_t * p_data, uint16_t len );
  • typedef struct
  • {
  •     uint8_t           function_id;
  •     callback_func p_callback_func;
  • } function_register_t;
  • //一组函数处理程序,每个处理程序都有一个id
  • static function_register_t func_register[FUNC_REGISTER_SIZE];
  • //注册函数回调
  • uint8_t RegisterCallback (uint8_t function_id, callback_func p_callback_func ) {
  •     uint8_t    status;
  •     if ((0 < function_id) && (function_id <= FUNC_ID_MAX))
  •     {
  •         //向寄存器添加函数
  •         if ( p_callback_func != NULL ) {
  •             for (int i = 0; i < FUNC_REGISTER_SIZE; i++ ) {
  •                 if (( func_register[i].p_callback_func == NULL ) ||
  •                     ( func_register[i].p_callback_func == p_callback_func )) {
  •                     func_register[i].function_id = function_id;
  •                     func_register[i].p_callback_func = p_callback_func;
  •                     break;
  •                 }
  •             }
  •      if (i != FUNC_REGISTER_SIZE) {
  •         status = SUCESSFULL;
  •      }
  •      else {
  •         status = FAILURE;
  •      }
  •         }
  •         else {
  •             //从寄存器中删除
  •             for ( i = 0; i < FUNC_REGISTER_SIZE; i++ ) {
  •                 if ( func_register[i].function_id == function_id ) {
  •                     func_register[i].function_id = 0;
  •                     func_register[i].p_callback_func = NULL;
  •                     break;
  •                 }
  •             }
  •             status = SUCESSFULL;
  •         }
  •     }
  •     else {
  •         status = FAILURE; /* Invalid argument */
  •     }
  •     return status;
  • }
  • 复制代码
    在下面的代码中,我们可以看到一个函数示例,该函数可用于根据函数 id 调用回调。
    //具有特定函数代码的回调函数如何被调用的示例
  • uint8_t execute_callback(uint8_t FuncCode, uint8_t * p_data_buf, uint16_t len)
  • {  
  •     uint8_t status;
  •     status = FAILURE;
  •     for( i = 0; i < FUNC_REGISTER_SIZE; i++ ){
  •         /* No more callbacks registered, exit. */
  •         if( func_register[i].function_id == 0 ){
  •             break;
  •         }
  •         else if( func_register[i].function_id == FuncCode) {
  •             status = func_register[i].p_callback_func( p_data_buf, len );
  •             break;
  •         }
  •      }
  •      return status;
  • }
  • 复制代码
    4、结论
    我们可以编写不使用回调的程序,但是通过将它们添加到我们的工具库中,它们可以使我们的代码更高效且更易于维护。明智地使用它们很重要,否则过度使用回调(函数指针)会使代码难以进行排查和调试。另一件需要考虑的事情是使用函数指针可能会阻止编译器执行的一些优化(例如函数内联)。

    5、文献引用

    [1]王铬. 回调函数在软件设计中的应用[J]. 河南教育学院学报:自然科学版, 2003, 12(3):3.
    [2]李建波, 陈榕福, & 王劲. (2020). Stm32cube mx串口中断回调函数的研究. 电子世界(5), 2.