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标准库的 qsort 和 bsearch 是回调设计的经典案例。它们都接收一个比较函数,但早期版本没有 void* 上下文参数——这意味着比较函数只能用全局变量。
后来 POSIX 标准引入了 qsort_r 和 bsearch_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:回调上下文的核心逻辑
写在最后
回调函数加上 void* 上下文,是C语言里实现「面向对象」风格的重要手段。它让代码更灵活,但也对程序员的要求更高。
我个人习惯是:每个回调函数都配一个结构体上下文,哪怕当前只有一个字段。这样以后扩展时不用改接口,也方便加调试信息。
你想想看,如果你的代码里到处都是全局变量来传递状态,那维护起来得多痛苦?用回调 + 上下文,每个模块各管各的数据,清爽多了。
公众号:蓝海资料掘金营,微信deep3321