48、回调函数:如何用函数指针实现回调?

回调函数,这名字听着挺唬人。说白了就是:你写一个函数,但你不直接调用它,而是把它的地址传给另一个函数,让那个函数在合适的时机去调用它

我刚开始学C语言那会儿,看到“回调”两个字就头大。心想:我直接调用不就行了?干嘛要绕一圈?后来做嵌入式项目,写按键驱动、定时器处理、通信协议栈……才发现,没有回调函数,很多代码根本没法写。

嗯,今天咱们就把这个“绕一圈”的逻辑彻底讲清楚。

48.1 函数指针:回调的基石

要实现回调,首先得搞懂函数指针。函数指针,就是指向函数的指针变量。它跟普通指针不一样——普通指针存的是数据的地址,函数指针存的是函数的入口地址。

声明一个函数指针的语法,说实话有点反人类:

// 声明一个函数指针,指向返回值为int、参数为两个int的函数
int (*pFunc)(int, int);

注意那个括号!(*pFunc) 必须括起来。如果不括,写成 int *pFunc(int, int),那就变成了“声明一个返回int指针的函数”——完全是两码事。这个坑我踩过,调试了半天才发现是括号的问题。

函数指针的赋值和调用,看这个例子:

#include <stdio.h>

int add(int a, int b) {
    return a + b;
}

int main() {
    // 定义一个函数指针,指向add函数
    int (*calc)(int, int) = add;
    
    // 通过函数指针调用函数
    int result = calc(3, 5);
    printf("结果: %d\n", result);  // 输出 8
    
    return 0;
}

这里 calc 就是函数指针,它指向 add 函数。调用 calc(3, 5) 等价于调用 add(3, 5)

小技巧:typedef 给函数指针起个别名,代码会清爽很多。比如 typedef int (*CalcFunc)(int, int);,之后直接用 CalcFunc calc = add; 就行了。

48.2 回调函数的典型模式

回调函数的核心模式其实就三步:

  1. 定义回调函数的类型(用函数指针类型)
  2. 写一个“注册”函数,接收函数指针作为参数
  3. 在合适的时机调用回调

我画了一张图,帮你理解这个流程:

回调函数调用流程 主调函数 register_callback() 注册回调 回调注册表 存储函数指针 事件触发 事件源 定时器/中断/消息 调用回调函数 回调函数 用户自定义逻辑 返回结果 流程:主调函数注册回调 → 事件触发 → 调用回调 → 返回结果

看明白了吗?主调函数不关心回调函数里具体做了什么,它只负责在事件发生时“通知”你。这种解耦的设计,在嵌入式系统里太常见了。

48.3 实战:用回调实现一个定时器框架

我在做物联网网关项目时,需要管理多个定时任务。每个任务到期后要执行不同的操作——有的要上报数据,有的要重启设备,有的要切换工作模式。如果写一堆 if-else,代码会变成一坨屎山。

用回调函数,代码就优雅多了:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>  // sleep函数

// 定义回调函数类型
typedef void (*TimerCallback)(void* context);

// 定时器结构体
typedef struct {
    int id;
    int interval;          // 定时时间(秒)
    int remaining;         // 剩余时间
    TimerCallback callback; // 回调函数指针
    void* context;         // 回调参数
    int active;            // 是否激活
} Timer;

// 定时器管理器
typedef struct {
    Timer timers[10];
    int count;
} TimerManager;

// 初始化定时器管理器
void timer_init(TimerManager* mgr) {
    mgr->count = 0;
}

// 注册一个定时器
int timer_register(TimerManager* mgr, int id, int interval, 
                   TimerCallback cb, void* ctx) {
    if (mgr->count >= 10) {
        return -1;  // 满了
    }
    Timer* t = &mgr->timers[mgr->count++];
    t->id = id;
    t->interval = interval;
    t->remaining = interval;
    t->callback = cb;
    t->context = ctx;
    t->active = 1;
    return 0;
}

// 定时器滴答(每秒调用一次)
void timer_tick(TimerManager* mgr) {
    for (int i = 0; i < mgr->count; i++) {
        if (!mgr->timers[i].active) continue;
        
        mgr->timers[i].remaining--;
        if (mgr->timers[i].remaining <= 0) {
            // 时间到!调用回调函数
            if (mgr->timers[i].callback) {
                mgr->timers[i].callback(mgr->timers[i].context);
            }
            // 重置定时器(循环定时)
            mgr->timers[i].remaining = mgr->timers[i].interval;
        }
    }
}

// ========== 用户自定义的回调函数 ==========

void report_data(void* ctx) {
    int sensor_id = *(int*)ctx;
    printf("[回调] 传感器 %d 上报数据: 温度 25.3°C\n", sensor_id);
}

void reboot_device(void* ctx) {
    printf("[回调] 设备重启中...\n");
}

void switch_mode(void* ctx) {
    char* mode = (char*)ctx;
    printf("[回调] 切换到工作模式: %s\n", mode);
}

int main() {
    TimerManager mgr;
    timer_init(&mgr);
    
    int sensor_id = 1;
    char mode[] = "节能模式";
    
    // 注册三个定时任务
    timer_register(&mgr, 1, 3, report_data, &sensor_id);   // 每3秒上报
    timer_register(&mgr, 2, 10, reboot_device, NULL);      // 每10秒重启
    timer_register(&mgr, 3, 7, switch_mode, mode);         // 每7秒切换模式
    
    // 模拟运行20秒
    for (int i = 0; i < 20; i++) {
        printf("第 %d 秒: ", i + 1);
        timer_tick(&mgr);
        sleep(1);
    }
    
    return 0;
}
关键点: 注意 void* context 这个参数。回调函数需要知道“谁触发了它”,但又不能提前知道具体是什么类型。用 void* 传递上下文,在回调函数里再转换成实际类型——这是C语言里非常经典的做法。

48.4 回调函数的常见应用场景

回调函数在嵌入式系统里无处不在。我列几个最常见的:

场景 说明 典型例子
中断处理 硬件中断发生时,调用注册的回调 GPIO中断、定时器中断
定时器任务 定时时间到,执行回调 心跳检测、超时重传
通信协议 收到数据包后,调用解析回调 UART接收、TCP数据到达
状态机 状态切换时,调用进入/退出回调 按键状态机、协议状态机
排序/搜索 标准库的 qsort 需要比较回调 qsort(arr, n, size, cmp_func)

你看 qsort 这个函数,它就是一个典型的回调应用。你只需要提供一个比较函数,qsort 就帮你把数组排好序。至于它内部是用快排还是归并排序,你根本不用关心。

48.5 避坑指南

我曾经踩过的坑,你千万别再踩:
  • 回调函数里不要做阻塞操作——比如在中断回调里调用 printfmalloc。中断上下文里做这些事,轻则死机,重则烧板子。我有个同事就因为这个,把一块开发板的串口驱动搞崩了三次。
  • 检查回调指针是否为NULL——调用回调之前,一定要判断 if (callback != NULL)。否则注册时忘了传回调,程序直接跑飞。
  • 注意线程安全——如果回调在多线程环境下被调用,回调函数里访问的全局变量要做好同步保护。用互斥锁或者关中断,看具体场景。
  • 回调函数的生命周期——确保回调函数在调用时仍然有效。如果回调函数是某个对象的成员函数,对象已经被销毁了还去调用,那就是典型的“悬空指针”问题。

48.6 回调 vs 直接调用:什么时候用?

你可能会问:什么时候该用回调,什么时候直接调用就行?

我的经验是:如果你不确定“什么时候”要执行某个操作,就用回调。比如:

  • 你写了一个按键驱动,但不知道用户按下键后想干什么——用回调,让用户自己注册处理函数。
  • 你写了一个网络库,但不知道收到数据后怎么处理——用回调,让上层应用决定。
  • 你写了一个排序函数,但不知道比较规则——用回调,让调用者提供比较函数。

反过来,如果调用关系是固定的、确定的,直接调用更简单直接。别为了用回调而用回调,那叫过度设计。

个人习惯: 我一般在设计模块接口时,会先问自己一个问题:“这个模块的调用者,是否需要自定义行为?” 如果需要,就用回调;如果不需要,就提供固定的API。这个原则帮我避免了很多不必要的复杂度。

回调函数,说白了就是“把函数当作参数传递”。它让代码变得灵活、可扩展、低耦合。在嵌入式系统里,从按键处理到协议栈,从定时器到任务调度,回调无处不在。掌握了它,你的C语言水平会上一个台阶。


公众号:蓝海资料掘金营,微信deep3321