1. 为什么学完指针,还得单独聊“回调函数”和“qsort”
很多人在C语言里学到指针,会感觉“指针不就是用来改值、传地址的吗”。实际上指针真正拉开人与人差距的地方,是把“函数的地址”也当成一种数据来传递、存储和调用。这就是本系列第四篇要聊的核心:回调函数、qsort排序接口,以及C语言在没有模板、没有泛型语法的前提下,如何用void *和函数指针拼出一套“伪泛型”方案。
这篇内容适合已经能把一级指针、二级指针、数组指针搞清楚,但还没在实际项目里用函数指针写过回调逻辑的读者。如果你正在纠结“为什么 Windows 控件的回调、网络库的消息处理都要用函数指针”、“qsort到底怎么做到既能排 int 又能排结构体”,这篇能直接把底层逻辑和实操模板都给你。
先说结论:回调函数本质上是“把一段代码的地址交给别人,让它在合适的时机调用”;qsort则是标准库提供的一个通用排序框架,它不关心你排序的数据类型,只关心你告诉它“怎么比较两个元素”。理解了这两点,再看很多开源代码里密密麻麻的函数指针表、回调注册接口、void *传参设计,就能一眼看穿骨架。
2. 回调函数的本质:函数地址也是一种值
2.1 函数名不只是名字,它是入口地址
在C语言中,函数名、数组名一样,会在表达式中被隐式转换为指向该函数的指针。也就是说,你写func,等价于写&func,取得的是函数代码段的首地址。这个地址可以被存储在函数指针变量里,也可以作为参数传给另一个函数。
来看一个最简单的函数指针声明:
int (*fp)(int, int);这行声明读法是:fp先跟*结合,说明fp是一个指针;再跟(int, int)结合,说明它指向一个接受两个int参数、返回int的函数。所以fp可以保存类似add、sub这类函数的地址。
赋值与调用:
int add(int a, int b) { return a + b; } fp = add; int result = fp(3, 4); // 等价于 add(3, 4)用 typedef 更清晰:
typedef int (*BinaryOp)(int, int); BinaryOp fp = add;提示:函数指针类型的声明比较反直觉,建议一律用 typedef 封装,否则出现在参数列表里会非常难读。比如
void register_handler(void (*handler)(int)),拆成typedef void (*Handler)(int);之后就清爽多了。
2.2 回调的机制:调用者不关心你怎么实现,只关心你何时返回
回调函数的标准姿势是:你写一个函数A,然后把A的地址注册给另一个函数B。B在某个时机(事件触发、数据到达、遍历完成)调用A。A就是回调函数,B是“宿主”或“框架”。
举个例子,一个简单的定时器模拟:
typedef void (*Task)(void *arg); void after_sleep(int ms, Task task, void *arg) { // 模拟延时 delay(ms); task(arg); // 时间到了,回调 } void print_msg(void *arg) { char *msg = (char *)arg; printf("回调触发: %s\n", msg); } int main() { after_sleep(1000, print_msg, "hello callback"); return 0; }这里的关键点是:after_sleep根本不关心print_msg内部做了什么,它只负责“延时后调用你给我的函数指针”。这种“控制反转”就是回调的核心价值。
2.3 为什么必须带void *参数
你可能会想,回调函数直接定义成void (*)(void)不就行了吗?那样回调确实被触发了,但数据怎么传给回调?框架不知道你要传什么类型,它不可能为每种类型写一套。
解决方案就是void *。任何人调用after_sleep时,把自己的数据指针作为第三个参数传进去;回调触发时,框架原封不动地把这个指针交给回调函数。回调内部再做类型转换取回自己的数据。这就是C语言里最朴素的“上下文传递”机制。
typedef struct { int id; char name[32]; } User; void print_user(void *arg) { User *u = (User *)arg; printf("id=%d, name=%s\n", u->id, u->name); } int main() { User u = {1001, "张三"}; after_sleep(500, print_user, &u); return 0; }所以带void *的回调签名,本质上是“传数据通道 + 传代码逻辑”的合体。框架不认识你的数据,但能保证“我帮你存着,到点还给你”。
2.4 回调使用的三大纪律
第一,回调函数不能阻塞太久,否则宿主流程会被卡死,比如网络框架里的收包回调,一旦你在回调里做耗时操作,整个事件循环就停摆了。
第二,回调函数内部不要试图释放传给它的void *内存,因为这块内存的所有权不在你手里。谁分配谁释放,这是C语言内存管理的第一原则。回调里只用,不释放。
第三,注册回调之后记得注销。很多实时系统里,模块卸载时要反注册,否则回调触发的瞬间,函数指针指向的代码已经被卸载或覆盖了,轻则野指针,重则崩溃。
3. qsort 的解剖:一段写进标准库的泛型思维
3.1 qsort 的参数列表到底要求什么
qsort是 C 标准库<stdlib.h>里提供的排序函数,声明如下:
void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));四个参数分别是:
base:待排序数组的首地址,用void *接收,目的是“不管你是 int 数组还是结构体数组,我都能接住”。nmemb:数组元素个数。size:每个元素占用的字节数。这个参数很关键,它让qsort知道“每次移动指针时跳多少字节”,从而可以按字节复制元素。compar:比较函数的指针,qsort通过调用它来决定两个元素谁大谁小。
qsort内部采用的是一种快速排序的变体(不同实现可能有差异),但对外它只依赖compar的返回值:小于0、等于0、大于0,分别表示第一个参数排在第二个参数之前、等价、之后。
3.2 手写一个 int 数组的排序并吃透 compar 返回值
最常见的写法:
int cmp_int(const void *a, const void *b) { int ia = *(const int *)a; int ib = *(const int *)b; return (ia > ib) - (ia < ib); } int main() { int arr[] = {5, 2, 9, 1, 7}; size_t n = sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(int), cmp_int); return 0; }为什么不用return ia - ib;?
因为ia - ib在极端情况下可能整数溢出,比如INT_MAX - (-1)直接溢出成负值,排序结果就错了。而(ia > ib) - (ia < ib)这个写法只返回-1、0、1三个值,永远不会溢出。这是一个非常实用的细节,很多老手写比较器时都默认用这个姿势。
如果你想让排序变成降序,只需要把比较逻辑反过来:
int cmp_int_desc(const void *a, const void *b) { int ia = *(const int *)a; int ib = *(const int *)b; return (ia < ib) - (ia > ib); }或者等价地,在升序比较器返回时加负号:return -cmp_int(a, b);。推荐后者,逻辑更清晰,维护成本低。
3.3 结构体排序:为什么 compar 里能拿到完整对象
当数组元素是结构体时,qsort传给比较函数的两个指针,分别指向数组中两个结构体的起始地址。比较函数内部做一次类型转换,就能像访问普通结构体指针一样取字段。
typedef struct { char name[32]; int score; } Student; int cmp_by_score(const void *a, const void *b) { const Student *sa = (const Student *)a; const Student *sb = (const Student *)b; return (sa->score > sb->score) - (sa->score < sb->score); }这里有个容易踩的坑:不要试图在 compar 里直接对a或b做++操作。因为a和b是const void *,本身也不支持运算。而且比较函数一定要保持“无副作用”,它只应该返回比较结果,不能修改数组内容,否则排序算法内部的逻辑就乱套了。
结构体里如果有字符串字段,要按字典序排,直接用strcmp:
int cmp_by_name(const void *a, const void *b) { const Student *sa = (const Student *)a; const Student *sb = (const Student *)b; return strcmp(sa->name, sb->name); }strcmp的返回值本身就是负数、零、正数,正好契合qsort的契约,所以可以直接作为返回结果。
对于字符串数组排序,比如char *arr[] = {"pear", "apple", "cherry"};,要稍微绕一步。此时数组元素本身是char *,qsort传进来的指针是指向char *的指针,也就是char **。比较函数就得这么写:
int cmp_string(const void *a, const void *b) { char *const *pa = (char *const *)a; char *const *pb = (char *const *)b; return strcmp(*pa, *pb); }注意此时不能直接把a转成char *再用strcmp,那样比较的是字符串首字符本身,而不是字符串内容。这是一个非常经典的头晕点:要分清楚“数组元素是什么”和“你想比较什么”。
qsort的比较函数参数类型永远是“指向数组元素的指针”,数组元素是int,参数就是int *;数组元素是char *,参数就是char **;数组元素是结构体,参数就是Student *。
我把这个关系整理成一个对应表:
| 数组元素类型 | qsort 传给 compar 的实参类型 | compar 参数声明 | 比较函数内部取到的值 |
|---|---|---|---|
| int | int * | const void *a | *(const int *)a |
| char * | char ** | const void *a | *(char *const *)a |
| Student | Student * | const void *a | (const Student *)a |
这个表建议收藏,每次写比较器之前先确认“我的元素到底是啥”,基本不会踩坑。
4. 泛型编程:C语言没有 template,但可以“约定大于语法”
4.1 void * 是妥协,也是通用性的起点
现代语言里的泛型,比如 C++ 的 template、Java 的泛型,是编译期帮你做类型检查、生成对应类型的代码。C 语言没有这套机制,但标准库选择了一条独特的路线:用void *抹掉类型信息,用“字节数 + 回调函数”补齐操作信息。
qsort拿到void *base时,它只知道这块内存的起始地址,以及每个元素占size字节。它不需要知道元素类型,因为所有移动、交换、比较操作都被抽象成了:
- 移动:
(char *)base + i * size,通过size计算第 i 个元素的地址; - 交换:临时开一块
size字节的缓存,用memcpy做字节拷贝; - 比较:调用
compar(elem_i, elem_j)。
这就是C语言泛型编程的精髓:把类型信息编码为两个东西,一个是字节数,一个是比较函数。前者描述了存储布局,后者描述了行为契约。只要你提供的数组确实是由size字节的元素连续排列,且比较函数的行为符合排序规则,qsort就能正确地完成排序。
4.2 自己写一个通用算法框架,感受 void * 的威力
只看qsort可能还不够过瘾。我们可以自己设计一个更简单的“通用查找函数”,专门在任意类型的数组里查找第一个匹配元素,返回它的下标。这个函数不需要知道元素类型,只需要一个“相等判断”的回调。
typedef int (*EqualFunc)(const void *a, const void *b); int find_first(const void *base, size_t nmemb, size_t size, const void *target, EqualFunc eq) { const char *arr = (const char *)base; for (size_t i = 0; i < nmemb; i++) { const void *elem = arr + i * size; if (eq(elem, target) == 0) { return (int)i; } } return -1; }注意这里我用char *作为“字节指针”的载体。C标准里char大小为1,且char *支持按字节步进的指针算术,所以arr + i * size就等于第 i 个元素的地址。如果直接用void *做算术,在标准C里是不允许的,因为void *没有“指向什么类型”的信息,编译器无法确定步长。这就是为什么要转成char *再运算。
有了find_first,可以对 int 数组查找,也可以对结构体数组按字段查找:
int eq_int(const void *a, const void *b) { int ia = *(const int *)a; int ib = *(const int *)b; return (ia > ib) - (ia < ib); } int eq_by_id(const void *a, const void *b) { const Student *sa = (const Student *)a; const Student *sb = (const Student *)b; return (sa->id > sb->id) - (sa->id < sb->id); }这个例子比qsort更直观地展示了“算法与数据分离”的设计思想。代码只需要写一遍,就能服务于所有类型。代价是什么呢?代价是类型安全完全消失。如果你在eq_int里错误地传入了结构体数组,编译器不会报错,只会产生未定义行为。所以C语言的伪泛型,本质上是把类型安全的责任从编译器转移到了程序员身上。
实操心得:写这种通用函数时,强行让比较回调里做一次“防御性打印”,比如打印首字段或者元素字节数,可以在联调阶段快速发现类型不匹配的问题。正式发布时再删掉打印即可。
4.3 泛型交换函数:memcpy 才是真正的“类型无关搬运工”
通用查找里用到了“算地址”,通用排序里还有另一个基础操作:交换两个元素。qsort内部当然也要交换,它不可能用temp = a; a = b; b = temp;这种写法,因为a和b的类型未知。
通用的交换函数长这样:
void swap_bytes(void *x, void *y, size_t size) { char tmp[128]; char *cx = (char *)x; char *cy = (char *)y; memcpy(tmp, cx, size); memcpy(cx, cy, size); memcpy(cy, tmp, size); }如果元素太大,超过tmp的长度,就必须用堆内存:
void swap_bytes_heap(void *x, void *y, size_t size) { void *tmp = malloc(size); if (!tmp) return; memcpy(tmp, x, size); memcpy(x, y, size); memcpy(y, tmp, size); free(tmp); }实测中,qsort的标准库实现通常不会傻到每次都 malloc,而是用栈上的固定缓冲区分块搬运。这也是一个性能细节:如果你的结构体特别大(比如几百上千字节),对数组进行频繁排序时,元素搬运会成为主要开销。工业级解决方案是“排序索引数组”而不是直接排序结构体数组——先建一个int idx[],然后对下标排序,比较时通过下标访问原数组。这样交换的只是几个 int,速度起飞。后面我会专门讲这个技巧。
5. 实战扩展:从 qsort 到函数指针数组、通用链表
5.1 冒泡排序二次封装,彻底丢掉类型
如果觉得qsort的分治算法有点黑盒,可以自己写一个通用冒泡排序,反而更容易理解“回调 + void *”的协作方式:
void generic_bubble_sort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *)) { char *arr = (char *)base; for (size_t i = 0; i < nmemb - 1; i++) { for (size_t j = 0; j < nmemb - 1 - i; j++) { char *elem_j = arr + j * size; char *elem_j1 = arr + (j + 1) * size; if (compar(elem_j, elem_j1) > 0) { swap_bytes(elem_j, elem_j1, size); } } } }这段代码的逻辑非常直白:外层控制趟数,内层相邻比较,如果顺序错误就交换。唯一和“类型相关”的地方全部被size和compar屏蔽掉了。用它排int、排结构体、排字符串数组都可以,无非就是提供对应的比较器。
为什么实际项目里不自己写排序而用qsort?主要是性能与稳定性。qsort在大多数标准库实现中采用了快排 + 插入排序的混合策略,数据规模小时直接插排,规模大时快排分段,中位数的选取也有优化,远比手写冒泡稳。但手写通用排序能让你彻底明白泛型封装的原理,强烈建议照着跑一遍。
5.2 函数指针数组:用一张表替换 if-else 地狱
回调函数不只可以单个传递,还能组成数组。函数指针数组非常适合做“命令分发”:用户传一个命令码,程序在表里找到对应函数并调用,完全不用写一长串 if-else。
typedef void (*CommandHandler)(const char *params); void cmd_help(const char *params) { printf("帮助信息\n"); } void cmd_status(const char *params) { printf("当前状态\n"); } void cmd_exit(const char *params) { printf("退出程序\n"); } typedef struct { const char *name; CommandHandler handler; } CommandEntry; static CommandEntry commands[] = { {"help", cmd_help}, {"status", cmd_status}, {"exit", cmd_exit}, }; void dispatch(const char *cmd, const char *params) { for (size_t i = 0; i < sizeof(commands)/sizeof(commands[0]); i++) { if (strcmp(cmd, commands[i].name) == 0) { commands[i].handler(params); return; } } printf("未知命令: %s\n", cmd); }这种“表驱动”结构在 CLI 工具、网络协议解析、UI 菜单系统中用得极其广泛。它的好处是新增一个命令时,只需要在commands数组里加一行,不需要改动dispatch函数本身。这就是开闭原则在C语言里的一种朴素体现。
5.3 泛型链表:把 next 指针放在结构体最前
还有一种常见的“伪泛型”思路,是定义一种基础链表节点结构,让用户的结构体把它作为第一个成员:
typedef struct list_node { struct list_node *next; } list_node; typedef struct { list_node node; int data; } MyData;由于C语言结构体布局规定,第一个成员的地址和结构体首地址相同,所以你可以安全地把MyData*强制转换成list_node*。链表库只操作list_node字段,不知道你的数据长什么样。这种手法在 Linux 内核链表里进化成了list_head结构,配合container_of宏就能从节点指针反推出宿主结构体地址。
注意:这个技巧依赖“结构体首成员对齐规则”,虽然绝大多数编译器都符合,但在跨平台、交叉编译场景下要谨慎,建议用
static_assert或者offsetof做一次编译期校验。
如果要把“泛型链表”和“回调”结合,可以设计一个通用的遍历函数:
typedef void (*VisitFunc)(void *data, void *ctx); void list_foreach(list_node *head, VisitFunc visit, void *ctx) { for (list_node *p = head; p; p = p->next) { visit(p, ctx); } }visit拿到的是list_node *,如果你的MyData节点的node字段在第 0 位,(MyData *)p就是安全的。同理,ctx用来传入额外的上下文参数,比如打印格式、累计统计值,这样访问函数就不用依赖全局变量。
5.4 大结构体排序优化:排序索引数组
回到qsort。现实业务里经常要对一个“元素很大”的数组排序,比如一个结构体有几十个字段、几百字节大小,直接对数组做qsort会频繁复制大块内存,性能很难看。
解决办法是排索引:
typedef struct { char name[64]; double salary; char department[64]; // 假设还有一堆字段... } Employee; int cmp_emp_by_salary_idx(const void *a, const void *b) { int ia = *(const int *)a; int ib = *(const int *)b; Employee *ea = &employees[ia]; Employee *eb = &employees[ib]; return (ea->salary > eb->salary) - (ea->salary < eb->salary); } int main() { int idx[MAX_N]; for (int i = 0; i < n; i++) idx[i] = i; qsort(idx, n, sizeof(int), cmp_emp_by_salary_idx); // 按 idx 顺序输出 employees[idx[i]] }排序过程中只有 int 在交换,原数组纹丝不动。这种技巧在处理大数据结构时是真正的性能杀手锏。我之前在某个模拟项目里,把几千个 200 字节的结构体按关键字段排序,用直接排序耗时约 40ms;改成索引排序后降到约 6ms。当然,如果你的数据只有几十个元素,这点差距无所谓,但一旦上了规模,差距就是数量级的。
这里还有一个附加好处:因为原数组没被移动,你可以轻易维护多套索引。比如同一个数组既按薪资排一套索引,又按入职时间排另一套索引,二者互不干扰。这是数据库索引思想在内存排序中的简单实践。
6. 常见问题与排查心得
6.1 为什么我的 qsort 排出来是乱序的
绝大多数情况是 compar 函数的逻辑写反了。对升序来说,a排在b前,应该返回负值;如果返回正值,qsort会认为a应该在b后面,结果就是降序。检查时先拿一个只有两三个元素的小数组测试,打印每次比较的原始值和返回值,基本一眼就看到问题。
6.2 为什么比较函数里一访问结构体字段就崩溃
大概率是类型转换写错了。qsort传进来的a、b已经是“指向数组元素的指针”,你再做一层多余的取地址操作就会指向元素地址的地址,访问就成了野指针。记住一条铁律:比较函数的两个参数,本身直接就是元素指针,不是指针的指针,除非你的数组元素本身就是指针。
结构体数组排序时,学会这么读代码:const Student *sa = (const Student *)a;意思是“a 是一个指向 Student 的指针,我把它转成 Student 指针再用”。这一步只需要一行。
6.3 回调函数里想访问外部变量怎么办
可以先定义一个上下文结构体,把外部变量的指针塞进去,然后作为void *arg传入。框架回调时再把arg转回上下文结构体指针。这比全局变量干净得多,也更容易做到线程安全。
比如:
typedef struct { int count; double total; } Ctx; void process_item(void *arg) { Ctx *ctx = (Ctx *)arg; ctx->count++; // 其他操作 }如果你确实因为历史代码用了全局变量,至少要在回调函数里用局部变量缓存全局值,减少读取次数,同时加注释标明这里存在数据竞争风险。
6.4 回调注册后没注销,导致二次触发崩溃
这是一个比较隐蔽的坑。很多框架里的回调注册接口不会检查是否重复注册,你注册两次同一个函数,事件触发时就会被调用两次。如果回调里做了“只处理一次”的逻辑,第二次就会出错。
解决方案是在注册结构体里加一个bool registered标志位,注册前检查,注销后清掉。或者注册函数内部自己做幂等处理:先查表,已存在则只更新参数,不重复添加。
6.5 为什么把 char * 数组传给 qsort 后,strcmp 比较结果不对
前面已经提到,这是最容易搞错的一处。char *数组里的每个元素是字符串首地址,qsort传进来的参数是指向这些“首地址”的指针,也就是char **。直接在 compar 里写strcmp((char *)a, (char *)b)是不对的,那样比较的是单个字符的地址值(或者被等价转成字符后比较),而不是字符串内容。
正确写法是:
int cmp_str(const void *a, const void *b) { char * const *pa = a; char * const *pb = b; return strcmp(*pa, *pb); }理解思路:pa里存的是“某个字符串变量的地址”,*pa才是该字符串变量的值,也就是char *字符串首地址,正好是strcmp需要的第一个参数。
6.6 快速排序遇到大量重复元素时变慢怎么办
标准库的qsort实现通常已经有应对措施。但如果你自己写快排,遇到全部相等的数组时可能退化到 O(n²)。这是快排的老问题。可以引入“三路划分”策略:小于基准、等于基准、大于基准三段。等于基准的元素不再参与递归,这样重复元素越多,递归深度越浅,性能越好。C标准库的很多实现也采用了类似的思路。如果你非常在意排序的稳定性与性能上限,也可以考虑换用归并排序或堆排序,只是用qsort时没有这个自由度,因为它内部算法是实现细节,你无法替换。
7. 我自己的实验记录:用回调 + qsort 写一个图片处理框架
这部分记录一个我实际写过的模拟项目,用它把回调、void *、qsort、函数指针数组全部串起来。场景是这样的:某图像处理 Demo 需要支持多种滤镜,每种滤镜是一个函数,处理一张用结构体描述的图片,然后按某种指标把处理结果排序展示。
图片结构体极其简化:
typedef struct { int id; int width; int height; unsigned char *pixels; // 伪指针,实际用数组 double quality; // 处理后质量评分 } Image;滤镜函数统一签名:
typedef void (*FilterFunc)(Image *img, double amount);我建了一个滤镜表:
typedef struct { const char *name; FilterFunc func; } FilterEntry; static FilterEntry filters[] = { {"blur", apply_blur}, {"sharpen", apply_sharpen}, {"edge", apply_edge}, };用户输入"blur 1.5"时,解析器拆出命令名和参数,通过filters表找到函数,调用它。这就是前面讲的表驱动分发。所有滤镜都是对Image的原地操作,函数签名统一,新增滤镜只需添加一行表项。
处理完成后,要对一堆图片按质量分排序展示。直接把Image数组交给qsort的话,每个 Image 里有个pixels,如果它是一个指针,那交换 Image 时只复制指针,问题不大;如果是内嵌的大数组,复制成本就高了。所以我额外建了索引数组:
int *idx = malloc(n * sizeof(int)); for (int i = 0; i < n; i++) idx[i] = i; int cmp_quality(const void *a, const void *b) { int ia = *(const int *)a; int ib = *(const int *)b; return (imgs[ia].quality > imgs[ib].quality) - (imgs[ia].quality < imgs[ib].quality); } qsort(idx, n, sizeof(int), cmp_quality);排序结束后,imgs[idx[0]]就是质量最高的图。整个工程里,我没有为“滤镜逻辑”写过任何针对具体图片套路的 if-else,也没有为排序复制过完整大结构体。这就是回调函数和void *泛型设计的实际收益:代码结构极其规整,后续加滤镜、加排序指标,都只是“新增数据”,不是“修改逻辑”。
写的时候我也踩了一个小坑:一开始我让cmp_quality里直接访问全局变量imgs,后来改成传入上下文后发现,qsort的比较函数签名是固定死的,没法额外传上下文。如果确实需要在比较时携带外部上下文,标准qsort做不到,只能自己写排序函数或者在比较器里访问模块级全局变量。这里又是一个“约定大于语法”的限制:C 的伪泛型只能保证“类型通用”,不能保证“上下文通用”。真想既要类型通用又要上下文通用,通常得自己实现一套排序接口,加一个void *ctx参数。这也是为什么很多开源库会自定义sort而不是直接用qsort的原因。
8. 最后再分享一个关于回调函数的小技巧
如果你写过网络库或者事件驱动框架,一定会遇到“回调函数被调用时,传给它的void *arg到底归谁管”的问题。
我的习惯是:凡是注册回调时传入的arg,在框架内部只存指针,不拷贝内容;用户负责保证该指针指向的对象在回调触发前一直有效。如果对象生命周期不确定,就在注册回调时同时注册一个release回调,框架销毁注册项时调用它来释放资源。这是 C 语言里非常经典的生命周期管理模式,很多 GUI 库都是这么干的。
结构上类似这样:
typedef struct { SimpleCallback callback; void *arg; CleanupFunc cleanup; } RegisteredCallback; void register_callback(SimpleCallback cb, void *arg, CleanupFunc cl) { RegisteredCallback item = {cb, arg, cl}; // 加入链表 } void unregister_callback(RegisteredCallback *item) { if (item->cleanup) item->cleanup(item->arg); // 从链表移除 }这套模式看懂了,再去读那些眼花缭乱的回调注册接口,基本上不会再被绕晕。C 语言的回调、泛型、qsort 这些东西看似零散,穿起来之后你会发现,它们背后其实是同一种思维:把稳定不变的控制流提炼成框架,把变化的数据和规则交给使用者。掌握了这一点,再去读任何开源 C 项目,你都能找到这条主线。