27、回调函数上下文:函数指针与void*参数传递的规范

回调函数,说白了就是把一个函数当作参数传给另一个函数。这个机制在C语言里非常强大,但也是个容易翻车的地方。我见过太多因为回调上下文没处理好导致的野指针、内存泄漏,甚至莫名其妙的死机。

今天咱们就聊聊,怎么把回调函数写得既灵活又安全。

回调函数的基本形态

先看一个最简单的例子。你写了一个排序函数,想让调用者自定义比较规则:

// 比较函数的类型
typedef int (*CompareFunc)(int a, int b);

// 排序函数,接收一个比较回调
void sort(int* arr, int size, CompareFunc cmp) {
    for (int i = 0; i < size - 1; i++) {
        for (int j = 0; j < size - 1 - i; j++) {
            if (cmp(arr[j], arr[j + 1]) > 0) {
                int tmp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = tmp;
            }
        }
    }
}

// 调用者定义比较规则
int ascending(int a, int b) {
    return a - b;
}

int main() {
    int data[] = {3, 1, 4, 1, 5, 9};
    sort(data, 6, ascending);
    return 0;
}

这个模式很清晰。但问题来了——如果比较逻辑需要依赖外部数据呢?比如按某个权重排序,权重值在运行时才能确定。

void* 参数:传递上下文的钥匙

我早期做嵌入式项目时,遇到过一个场景:一个定时器回调需要知道是哪个设备触发的。当时我用了全局变量来传递设备ID,结果代码耦合得一塌糊涂。后来才意识到,void* 参数就是干这个用的。

标准做法是这样的:

// 回调类型,带一个 void* 上下文
typedef void (*TimerCallback)(void* context);

// 定时器结构体
typedef struct {
    TimerCallback callback;
    void* context;
    uint32_t interval;
} Timer;

// 启动定时器
void timer_start(Timer* t) {
    // 内部实现...
    // 到期时调用 t->callback(t->context);
}

// 使用示例
typedef struct {
    int device_id;
    char* name;
} DeviceContext;

void device_timeout_handler(void* ctx) {
    DeviceContext* dc = (DeviceContext*)ctx;
    printf("Device %d (%s) timeout!\n", dc->device_id, dc->name);
}

int main() {
    DeviceContext dev = {.device_id = 1, .name = "sensor"};
    Timer t = {
        .callback = device_timeout_handler,
        .context = &dev,
        .interval = 1000
    };
    timer_start(&t);
    return 0;
}

你看,通过 void* context,回调函数就能拿到它需要的所有信息。不需要全局变量,不需要硬编码。

避坑指南:我踩过的三个大坑

坑一:生命周期管理

我曾经在项目里传了一个栈上变量的地址给回调,结果回调执行时那个变量已经销毁了。程序跑飞,查了两天才找到原因。

记住一条铁律:传给回调的上下文指针,其指向的内存必须在回调执行时仍然有效

  • 不要传局部变量的地址
  • 如果传堆内存,记得在回调执行完后释放
  • 如果传全局变量,注意多线程竞争

坑二:类型安全

void* 虽然灵活,但编译器不会帮你检查类型。我见过有人把 int 强转成 void* 传进去,然后在回调里又强转成结构体指针——结果就是访问了非法地址。

我的建议是:尽量用结构体包装上下文,不要直接传基本类型。结构体可以扩展,也方便加断言检查。

// 不推荐
void handler(void* ctx) {
    int val = (int)ctx;  // 危险!int 和指针可能不等长
}

// 推荐
typedef struct {
    int value;
} Context;

void handler(void* ctx) {
    Context* c = (Context*)ctx;
    // 可以加断言
    assert(c != NULL);
    int val = c->value;
}

坑三:回调嵌套与重入

嗯,这里要注意。如果回调里又触发了另一个回调,或者回调被多个线程同时调用,你的上下文就可能被覆盖或破坏。

我处理过一个网络协议栈,它的回调里又调用了发送函数,发送函数完成时又触发了另一个回调。结果上下文指针被中间层修改了,数据全乱了。

解决方案:要么保证回调不重入,要么在回调里用局部变量保存上下文副本。

标准库中的典范:qsort 和 bsearch

C标准库的 qsortbsearch 是回调设计的经典案例。它们都接收一个比较函数,但早期版本没有 void* 上下文参数——这意味着比较函数只能用全局变量。

后来 POSIX 标准引入了 qsort_rbsearch_r,加上了 void* 参数:

// 带上下文的比较函数
typedef int (*CompareWithContext)(const void* a, const void* b, void* context);

void qsort_r(void* base, size_t nmemb, size_t size,
             CompareWithContext compar, void* context);

这个设计思路值得学习:回调函数永远不要依赖全局状态,所有需要的数据都通过参数传递

设计规范总结

我把这些年积累的规范整理成了一张表,你写代码时可以参考:

规范项 说明 反例
上下文必须显式传递 不要用全局变量或静态变量传递上下文 static int ctx; callback(ctx);
上下文类型必须明确 用结构体包装,不要传裸指针或整数 void* ctx = (void*)42;
生命周期必须管理 确保回调执行时上下文有效 传局部变量地址给异步回调
回调必须检查空指针 调用前检查函数指针和上下文 if (cb) cb(ctx); 但没检查 ctx
避免回调嵌套过深 超过3层回调建议用状态机重构 回调里注册回调,再注册回调

一个完整的示例:事件分发器

最后,我给你看一个我实际项目中用过的模式。一个简单的事件分发器,用回调 + 上下文实现松耦合:

// 事件类型
typedef enum {
    EVENT_KEY_PRESS,
    EVENT_TIMER,
    EVENT_SENSOR
} EventType;

// 事件处理器
typedef struct {
    EventType type;
    void (*handler)(EventType type, void* context);
    void* context;
} EventHandler;

// 事件分发器
typedef struct {
    EventHandler handlers[10];
    int count;
} EventDispatcher;

void dispatcher_register(EventDispatcher* d, EventType type,
                         void (*handler)(EventType, void*),
                         void* context) {
    if (d->count >= 10) return;
    d->handlers[d->count].type = type;
    d->handlers[d->count].handler = handler;
    d->handlers[d->count].context = context;
    d->count++;
}

void dispatcher_dispatch(EventDispatcher* d, EventType type) {
    for (int i = 0; i < d->count; i++) {
        if (d->handlers[i].type == type && d->handlers[i].handler) {
            d->handlers[i].handler(type, d->handlers[i].context);
        }
    }
}

// 使用
typedef struct {
    int key_code;
} KeyContext;

void key_handler(EventType type, void* ctx) {
    KeyContext* k = (KeyContext*)ctx;
    printf("Key %d pressed\n", k->key_code);
}

int main() {
    EventDispatcher disp = {0};
    KeyContext kctx = {.key_code = 0x0A};
    
    dispatcher_register(&disp, EVENT_KEY_PRESS, key_handler, &kctx);
    dispatcher_dispatch(&disp, EVENT_KEY_PRESS);
    return 0;
}

这个模式的好处是:事件源和事件处理完全解耦。你可以在运行时动态注册和注销处理器,每个处理器都有自己的上下文。

SVG:回调上下文的核心逻辑

回调上下文传递核心逻辑 调用者 准备上下文数据 注册回调 存储函数指针+上下文 传递 事件触发 调用回调函数 事件发生 回调函数 处理事件,使用上下文 执行 上下文数据 设备ID、状态、配置等 void* 传递 关键原则 上下文生命周期 > 回调生命周期 | 避免全局变量 | 类型安全转换

写在最后

回调函数加上 void* 上下文,是C语言里实现「面向对象」风格的重要手段。它让代码更灵活,但也对程序员的要求更高。

我个人习惯是:每个回调函数都配一个结构体上下文,哪怕当前只有一个字段。这样以后扩展时不用改接口,也方便加调试信息。

你想想看,如果你的代码里到处都是全局变量来传递状态,那维护起来得多痛苦?用回调 + 上下文,每个模块各管各的数据,清爽多了。

小技巧:调试回调问题时,可以在上下文结构体里加一个 magic number 字段,初始化为固定值(比如 0xDEADBEEF)。回调执行时检查这个值,能快速发现上下文被破坏或传错的情况。
警告:在多线程环境中,回调函数可能被多个线程同时调用。如果上下文中有共享数据,记得加锁,或者用线程局部存储(TLS)来隔离。

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