智果芯
服务于百万大学生和电子工程师!

深入理解单片机HAL库中的回调函数!

摘要:在标准库开发中很少遇到回调函数,但是在HAL库开发中经常要使用到回调函数,不知你是不是也有这样的疑惑,我们为什么需要回调函数这个概念呢?直接调用函数不就可以了?回调函数到底有什么作用?程序员到底该如何理解回调函数?

这篇文章就来为你解答这些问题,读完这篇文章后你的武器库将新增一件功能强大的利器

其实回调函数和普通函数没有本质的区别。首先让我们来看看普通的函数调用,假设我们在fun函数中调用函数fun1:

void fun(void ) 
{
   ...
   fun1();
   ...
}

想一想,你怎么知道可以调用func呢?哦,原来fun1是你自己定义的:

void fun1(void ) 
{
  blablabla;
}

这很简单吧,现在假设你编写的这段代码无比之牛逼,全世界的程序员都无比疯狂的想引入到自己的项目中,这时你会把A函数编写成一个库供全世界的码农使用。

但此时所有人都发现一个问题,那就是他们都想在A函数中的某个特定点上执行一段自己的代码,作为这个库的创作者你可能会这样实现:

void A() {
   ...   
   if (张三) 
   {
     func1();
   } else if (李四) 
   {
     func2();
   }
   ...
}

假设全世界有一千万码农,那你是不是要有一千万个if else。。。想想这样的代码就很刺激有没有!

更好的办法是什么呢?把函数也当做变量!你可以这样定义fun函数:

void fun (fun1 f) {
   ...
   f();
   ...
}

任何一个码农在调用你的A函数时传入一个函数变量,A函数在合适的时机调用你传入的函数变量,从而节省了一千万行代码。为了让这个过程更加难懂一些,这个函数变量起了一个无比难懂的称呼:回调函数。现在你应该明白了回调函数是怎么一回事了吧,相比回调函数来说我更愿意将其看做函数变量。

什么?你说你还是不理解?那我下面这样说你明白了吗?

回调函数

一般解释:回调函数就是一个通过函数指针调用的函数。如果你把函数的地址传递给中间函数的形参,中间函数通过函数指针调用其所指向的函数时,我们就说这是回调函数。

通俗解释:函数Fun1调用函数Fun2,同时将函数Fun3作为形参传递给Fun2,此时,Fun1为应用层函数,Fun2为中间层函数,Fun3为回调函数,回调函数是一种说法而已。

fun1()
{
fun2(fun3);
}

意义何在:

  1. 利于代码结构,将代码分为应用层,中间层,硬件驱动层,彼此独立,方便程序的编辑,阅读,修改与移植;
  2. 结构化编程时,结构体只需要定义中间函数,减小内存的开销。

为什么不在Fun2里面直接调用Fun3

Fun1()
{
    Fun2()
    {
        Fun3_1();
        Fun3_2();
    }
}

通过函数指针的好处是,Fun2Fun3实现隔离,比如Fun3具有多个功能函数,增加或减少时,不需要修改Fun2的代码。

这会应该明白了吧?什么还是不太清楚,那下面就来详细说一说回调函数的吧!

一切要从这样的需求说起

假设你们公司要开发下一代国民App明日油条,一款主打解决国民早餐问题的App,为了加快开发进度,这款应用由A小组和B小组协同开发。

其中有一个核心模块由A小组开发然后供B小组调用,这个核心模块被封装成了一个函数,这个函数就叫make_youtiao()

如果make_youtiao()这个函数执行的很快并可以立即返回,那么B小组的同学只需要:

  1. 调用make_youtiao()
  2. 等待该函数执行完成
  3. 该函数执行完后继续后续流程

从程序执行的角度看这个过程是这样的:

  1. 保存当前被执行函数的上下文
  2. 开始执行make_youtiao()这个函数
  3. make_youtiao()执行完后,控制转回到调用函数中

如果世界上所有的函数都像make_youtiao()这么简单,那么程序员大概率就要失业了,还好程序的世界是复杂的,这样程序员才有了存在的价值。

现实并不容易

现实中make_youtiao()这个函数需要处理的数据非常庞大,假设有10000个,那么make_youtiao(10000)不会立刻返回,而是可能需要10分钟才执行完成并返回。

这时你该怎么办呢?想一想这个问题。

可能有的同学会问,和刚才一样直接调用不可以吗,这样多简单。

是的,这样做没有问题,但就像爱因斯坦说的那样“一切都应该尽可能简单,但是不能过于简单”。

想一想直接调用会有什么问题?

显然直接调用的话,那么调用线程会被阻塞暂停,在等待10分钟后才能继续运行。在这10分钟内该线程不会被操作系统分配CPU,也就是说该线程得不到任何推进。

这并不是一种高效的做法。

没有一个程序员想死盯着屏幕10分钟后才能得到结果。

那么有没有一种更加高效的做法呢?

我们知道了这种一直等待直到另一个任务完成的模式叫做同步

如果你是老板的话你会什么都不干一直盯着员工写代码吗?因此一种更好的做法是程序员在代码的时候老板该干啥干啥,程序员写完后自然会通知老板,这样老板和程序员都不需要相互等待,这种模式被称为异步

回到我们的主题,这里一种更好的方式是调用make_youtiao()这个函数后不再等待这个函数执行完成,而是直接返回继续后续流程,这样A小组的程序就可以和make_youtiao()这个函数同时进行了,就像这样:

在这种情况下,回调(callback)就必须出场了

为什么我们需要回调callback

有的同学可能还没有明白为什么在这种情况下需要回调,别着急,我们慢慢讲。假设我们明日油条App代码第一版是这样写的:

make_youtiao(10000);
sell();

可以看到这是最简单的写法,意思很简单,制作好油条后卖出去。

我们已经知道了由于make_youtiao(10000)这个函数10分钟才能返回,你不想一直死盯着屏幕10分钟等待结果,那么一种更好的方法是让make_youtiao()这个函数知道制作完油条后该干什么,即,更好的调用make_youtiao的方式是这样的:“制作10000个油条,炸好后卖出去”,因此调用make_youtiao就变出这样了:

make_youtiao(10000, sell);

看到了吧,现在make_youtiao这个函数多了一个参数,除了指定制作油条的数量外还可以指定制作好后该干什么,第二个被make_youtiao这个函数调用的函数就叫回调,callback

现在你应该看出来了吧,虽然sell函数是你定义的,但是这个函数却是被其它模块调用执行的,就像这样:

make_youtiao这个函数是怎么实现的呢,很简单:

//make_youtiao这个函数是怎么实现的呢,很简单:
void make_youtiao(int num, func call_back) 
{
    // 制作油条
    call_back(); //执行回调 
}

这样你就不用死盯着屏幕了,因为你把make_youtiao这个函数执行完后该做的任务交代给make_youtiao这个函数了,该函数制作完油条后知道该干些什么,这样就解放了你的程序。

有的同学可能还是有疑问,为什么编写make_youtiao这个小组不直接定义sell函数然后调用呢?

不要忘了明日油条这个App是由A小组和B小组同时开发的,A小组在编写make_youtiao时怎么知道B小组要怎么用这个模块,假设A小组真的自己定义sell函数就会这样写:

void make_youtiao(int num)
 {
    real_make_youtiao(num);
    sell(); //执行回调 
}

同时A小组设计的模块非常好用,这时C小组也想用这个模块,然而C小组的需求是制作完油条后放到仓库而不是不是直接卖掉,要满足这一需求那么A小组该怎么写呢

void make_youtiao(int num) {
    real_make_youtiao(num);

    if (Team_B) {
       sell(); // 执行回调
    } else if (Team_D) {
       store(); // 放到仓库
    }
}

故事还没完,假设这时D小组又想使用呢,难道还要接着添加if else吗?这样的话A小组的同学只需要维护make_youtiao这个函数就能做到工作量饱满了,显然这是一种非常糟糕的设计。

所以你会看到,制作完油条后接下来该做什么不是实现make_youtiao的A小组该关心的事情,很明显只有调用make_youtiao这个函数的使用方才知道。

因此make_youtiao的A小组完全可以通过回调函数将接下来该干什么交给调用方实现,A小组的同学只需要针对回调函数这一抽象概念进行编程就好了,这样调用方在制作完油条后不管是卖掉、放到库存还是自己吃掉等等想做什么都可以,A小组的make_youtiao函数根本不用做任何改动,因为A小组是针对回调函数这一抽象概念来编程的。

以上就是回调函数的作用,当然这也是针对抽象而不是具体实现进行编程这一思想的威力所在。面向对象中的多态本质上就是让你用来针对抽象而不是针对实现来编程的

正式定义

在计算机科学中,回调函数是指一段以参数的形式传递给其它代码的可执行代码。这就是回调函数的定义了。

回调函数就是一个函数,和其它函数没有任何区别。

注意,回调函数是一种软件设计上的概念,和某个编程语言没有关系,几乎所有的编程语言都能实现回调函数。

对于一般的函数来说,我们自己编写的函数会在自己的程序内部调用,也就是说函数的编写方是我们自己,调用方也是我们自己。

但回调函数不是这样的,虽然函数编写方是我们自己,但是函数调用方不是我们,而是我们引用的其它模块,也就是第三方库,我们调用第三方库中的函数,并把回调函数传递给第三方库,第三方库中的函数调用我们编写的回调函数,如图所示:

定节点,比如我们举的例子油条制作完成、接收到网络数据、文件读取完成等之后该做什么,这些只有库的使用方才知道,因此第三方库的编写者无法针对具体的实现来写代码,而只能对外提供一个回调函数,库的使用方来实现该函数,第三方库在特定的节点调用该回调函数就可以了。

另一点值得注意的是,从图中我们可以看出回调函数和我们的主程序位于同一层中,我们只负责编写该回调函数,但并不是我们来调用的。

最后值得注意的一点就是回调函数被调用的时间节点,回调函数只在某些特定的节点被调用,就像上面说的油条制作完成、接收到网络数据、文件读取完成等,这些都是事件,也就是event,本质上我们编写的回调函数就是用来处理event的,因此从这个角度看回调函数不过就是event handler,因此回调函数天然适用于事件驱动编程event-driven

在HAL库中我们的中断服务函数里我们不需要清楚中断标志位。在标准库中,串口中断了以后,我们要先在中断中判断是否是接收中断,然后读出数据,顺便清除中断标志位,然后再是对数据的处理,这样如果我们在一个中断函数中写这么多代码,就会显得很混乱:

标准库写法

//中断服务程序
void TIM3_IRQHandler(void)
{
    if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) 
        {
        TIM_ClearITPendingBit(TIM3, TIM_IT_Update  );  
        LED1=!LED1;
        }
}

HAL库回调函数写法

//中断服务函数
void TIM3_IRQHandler(void)
{
    HAL_TIM_IRQHandler(&TIM3_Handler);
}

//回调函数
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if(htim==(&TIM3_Handler))
    {
        LED1=!LED1; 
    }
}

现在应该明白回调函数是什么了吧!

赞(6) 打赏
未经允许不得转载:智果芯 » 深入理解单片机HAL库中的回调函数!

评论 5

  1. #0

    答主也说到了,只是程序没有和文字对应,搞得第一次没看懂。

    匿名2年前 (2022-08-02)回复
  2. #0

    https://blog.csdn.net/qq_45396672/article/details/119396143 这里有具体的列子,有程序,配合这篇文章可以理解更好

    匿名2年前 (2022-08-02)回复
  3. #0

    那么HAL库中的回调为什么不用清除中断标志位呢?

    匿名2年前 (2022-04-02)回复
    • 因为在别的地方已经清除过了

      智果芯2年前 (2022-06-15)回复
      • 所以说还是没说明白呗

        匿名2年前 (2022-08-02)回复

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

支付宝扫一扫打赏

微信扫一扫打赏