函数指针与回调机制
# 05.函数指针与回调机制
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 函数名即地址
- 4. 声明与typedef 技巧
- 5. qsort源码逐行拆解
- 6. 回调上下文
- 7. 函数指针数组
- 8. 信号处理
- 9. 函数指针的安全陷阱
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段崩在哪
看一段在工控设备上跑的代码——PLC 通信中间件,运行半年后的一次固件热更新中崩溃,gdb 回溯到一行 call rax:
// plc_gateway.c —— 工业协议网关
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_HANDLERS 32
/* 协议处理器接口 */
typedef void (*handler_t)(const char *payload);
/* 协议名 → 处理函数的映射表 */
typedef struct {
const char *name;
handler_t handler;
} HandlerEntry;
static HandlerEntry handlers[MAX_HANDLERS];
static int handler_count = 0;
/* 注册一个新的协议处理器 */
void register_handler(const char *name, handler_t func) {
if (handler_count < MAX_HANDLERS) {
handlers[handler_count].name = name;
handlers[handler_count].handler = func;
handler_count++;
}
}
/* 根据协议名分发到对应的处理函数 */
void dispatch(const char *proto_name, const char *payload) {
for (int i = 0; i < handler_count; i++) {
if (strcmp(handlers[i].name, proto_name) == 0) {
handlers[i].handler(payload); /* ← 崩溃点 */
return;
}
}
printf("未知协议: %s\n", proto_name);
}
/* ---------- 具体处理器 ---------- */
void handle_modbus(const char *data) {
printf("Modbus: %s\n", data);
}
void handle_opcua(const char *data) {
printf("OPC UA: %s\n", data);
}
/* ---------- 主程序 ---------- */
int main() {
register_handler("modbus", handle_modbus);
register_handler("opcua", handle_opcua);
/* 从配置文件动态加载协议 */
FILE *fp = fopen("/etc/plc/protocols.conf", "r");
char line[128];
while (fgets(line, sizeof(line), fp)) {
char name[32], path[64];
if (sscanf(line, "%s %s", name, path) == 2) {
/* ⚠️ 从 .so 中动态加载处理函数 */
void *so = dlopen(path, RTLD_LAZY);
handler_t h = (handler_t)dlsym(so, "handle"); /* ← Line X */
if (h) register_handler(name, h);
/* ⚠️ 注意:so 没有被 dlclose——但函数指针保留了! */
}
}
fclose(fp);
/* 运行时调度 */
dispatch("modbus", "hello"); /* ✅ 正常 */
dispatch("custom", "world"); /* ❌ 崩溃! */
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
现象:
- 内置处理器(modbus/opcua):100% 通过
- 插件处理器(
custom.so):第一次调用正常,第二次及以后崩溃 - gdb 崩溃回溯:
handlers[i].handler(payload)→call rax,但rax的值是0x00007f...附近的一个地址,看起来像是已被 munmap 的动态库地址
直觉怀疑:是不是 dlopen 返回的 .so 被提前 dlclose 了?但检查代码——没有显式的 dlclose。再用 cat /proc/PID/maps 一看:custom.so 的 r-x 段确实还在内存映射里。
# 1.2 顺藤摸到根因
在 gdb 里逐步跟踪:
(gdb) p handlers[2].handler
$1 = (handler_t) 0x7f1234561230 ← 第一次调用时的地址
(gdb) info symbol 0x7f1234561230
No symbol matches 0x7f1234561230. ← 地址不在任何已知符号里
(gdb) p handlers[2].handler
$2 = (handler_t) 0x7f1234561230 ← 第二次调用时——地址相同
(gdb) x/4i 0x7f1234561230
0x7f1234561230: Cannot access memory at address 0x7f1234561230
← 这个地址已经不是可读内存了!
2
3
4
5
6
7
8
9
10
11
12
再查 /proc/PID/maps:
# 第一次调用时:
7f1234500000-7f1234570000 r-xp 00000000 /tmp/custom.so ← custom.so 的代码段
# 第二次调用时:
# custom.so 消失了!
2
3
4
5
根因:dlsym 返回的地址在 custom.so 的 text 段内。热更新进程(守护进程)重新加载配置时,dlopen 了新版本的 custom.so,而旧版本的 .so 被 glibc 的引用计数归零后 munmap 了。但 handlers[2].handler 仍然指向旧版本的地址——一个已经被内核回收的虚拟地址区域。
这暴露了函数指针的 6 个核心问题:
① 函数名到底是什么?handler = handle_modbus 时发生了什么事? → 第 3 章
② 函数指针类型声明那么复杂——有什么规律吗? → 第 4 章
③ qsort 是怎么用一个函数指针实现通用排序的? → 第 5 章
④ 回调函数怎么把外部数据传进去?void* 上下文模式是什么? → 第 6 章
⑤ 用函数指针数组替代大段 switch-case 的性能与可读性如何? → 第 7 章
⑥ 函数指针的安全边界在哪?什么情况下指向的函数会失效? → 第 9 章
2
3
4
5
6
# 1.3 我们要回答什么
这个案例就是本篇的主线。我们从"函数名退化为地址"的汇编层面出发,拆解 qsort 的泛型魔法,构建回调与状态机的完整实现,最后在第 10 章回到这个 PLC 网关,用"函数指针生命周期管理"彻底解决 crash。
本篇路线:
三层抽象全景 (第 2 章)
↓
函数名即地址 (第 3 章) ─→ 解开① + 汇编级 call vs call rax
↓
声明与 typedef (第 4 章) ─→ 解开②
↓
qsort 源码拆解 (第 5 章) ─→ 解开③
↓
回调模式与状态机 (第 6-7 章) ─→ 解开④~⑤
↓
信号处理 (第 8 章) ─→ 系统级回调
↓
安全陷阱 (第 9 章) ─→ 解开⑥
↓
综合案例 (第 10 章) ─→ 插件系统的安全 rewrite
2
3
4
5
6
7
8
9
10
11
12
13
14
15
📌 本篇定位:第 03-04 篇拆了数据指针,本篇把指针的终极形态——函数指针——从函数名、声明、qsort 到回调架构全部串起来。函数指针是 C 语言实现多态、插件系统、策略模式的唯一手段。
# 2. 架构概览
# 2.1 函数指针全景
函数指针在 C 语言中的三种形态:
┌─────────────────────────────────────────────────────────┐
│ 形态 1 │ 形态 2 │ 形态 3
│ 初级:单个函数指针 │ 中级:回调 + void* 上下文 │ 高级:函数指针数组/表
│ │ │
│ void (*fp)(int); │ void (*cb)(void *ctx, │ handler_t table[] = {
│ fp = my_func; │ int data); │ [STATE_IDLE] = handle_idle,
│ fp(42); /* 调用 */ │ cb(user_ptr, 42); │ [STATE_RUN] = handle_run,
│ │ │ };
│ 用例:qsort 的比较函数 │ 用例:事件监听器 │ 用例:状态机、命令表
└─────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
函数指针的本质——数据段或代码段中存的 8 字节地址,指向 .text 段某个函数的入口点:
内存布局:
.data / 栈上: .text 段 (代码段):
┌─────────────────┐ ┌──────────────────────┐
│ fp = 0x401050 │──┐ │ 0x401050 <my_func>: │
│ (8字节地址) │ │ │ push rbp │
└─────────────────┘ │ │ mov rbp, rsp │
└───────→│ ... │
│ ret │
└──────────────────────┘
2
3
4
5
6
7
8
9
函数指针和代码段的关系:函数指针的值(那个 8 字节数字),就是 call 指令的目标地址——它是 text 段中的某个偏移。
# 2.2 编译期未知
直接调用 vs 函数指针调用:
/* 编译期就知道调用谁——call 指令的目标写死在机器码里 */
my_func(); // 编译为:call my_func (相对偏移量)
/* 运行期才知道调用谁——call 指令的目标从寄存器/内存读出 */
handler_t fp = select_handler();
fp(); // 编译为:call rax (间接调用)
2
3
4
5
6
汇编差异:
; 直接调用
call my_func ; 目标地址编码在机器码的偏移量中
; 函数指针调用
mov rax, QWORD PTR [fp] ; 从内存读出目标地址
call rax ; 以寄存器值为目标地址跳转
2
3
4
5
6
间接调用多了一次内存访问:先读函数指针的值,再以这个值为目标 call。这就是函数指针调用的微小性能代价——但在绝大多数场景下,这个代价远小于它带来的架构灵活性。
# 3. 函数名即地址
# 3.1 函数名退化
疑惑:handler_t fp = my_func; 为什么能编译?my_func 不是函数吗,怎么能赋给指针变量?
论证——C 标准(C11 §6.3.2.1)规定:
Except when it is the operand of
sizeofor the unary&operator, a function designator with type "function returning T" is converted to an expression with type "pointer to function returning T".
函数名在表达式中自动退化为函数指针,就像数组名退化为指针一样:
void hello(void) { printf("hello\n"); }
void (*fp1)(void) = hello; /* ✅ hello 退化为 &hello */
void (*fp2)(void) = &hello; /* ✅ 显式取地址——同上 */
void (*fp3)(void) = *hello; /* ✅ *hello = hello(退化) → 再退化 → 还是 &hello ! */
void (*fp4)(void) = **hello; /* ✅ 还是 &hello——套娃无限解引用也是它 */
2
3
4
5
6
这就是函数指针的一个反直觉特性:对函数名取地址和解引用可以无限套娃。
/* 全部等价——全部返回 &hello */
(**********hello) == hello; /* ✅ 合法! */
2
C 标准规定:对函数指针解引用的结果还是一个函数指示符,而函数指示符又立刻退化为函数指针——所以这个循环永远不会终止,* 操作永远消除不了函数指针的类型。
# 3.2 call 指令的汇编级跳转
int add(int a, int b) { return a + b; }
int main() {
int (*fp)(int, int) = add; /* 函数名退化为指针 */
int result = fp(3, 4); /* 通过函数指针调用 */
return 0;
}
2
3
4
5
6
7
汇编(gcc -O0):
; int (*fp)(int, int) = add;
lea rax, [rip + add] ; 加载 add 的地址(PIE 下用 RIP-relative)
mov QWORD PTR [rbp-8], rax ; fp = &add
; int result = fp(3, 4);
mov rax, QWORD PTR [rbp-8] ; 从栈上读出 fp 的值
mov esi, 4 ; 第二个参数 → ESI
mov edi, 3 ; 第一个参数 → EDI
call rax ; ← 间接调用!目标地址在寄存器
mov DWORD PTR [rbp-12], eax ; result = eax
2
3
4
5
6
7
8
9
10
关键观察——call rax:CPU 不关心 rax 里的值是来自编译时写的立即数还是运行时算出来的——它只管跳到那个地址。这就是函数指针的物理本质:把代码段的某个位置变成一个可以传递、存储、比较的值。
# 3.3 调用汇编差异
void direct() { }
void indirect_call(void (*fp)()) { fp(); }
int main() {
direct(); /* 方式 A */
indirect_call(direct); /* 方式 B */
return 0;
}
2
3
4
5
6
7
8
; main 中 direct() 的调用——直接 call
call direct ; 1 条指令
; indirect_call 中 fp() 的调用——间接 call
mov rax, rdi ; rdi = fp
call rax ; 1 条指令(但多了一个 mov)
; 在 -O2 下,如果编译器能证明 fp 总指向 direct,会优化回直接的 call
2
3
4
5
6
7
8
性能影响:间接 call rax 比直接 call offset 多一次寄存器移动操作。但在 CPU 的分支预测器面前,这个差异微乎其微——函数指针的真正成本不在于调用本身,而在于它让编译器无法内联。直接调用 direct() 在 -O2 下可能被完全内联消除——但 fp() 调用永远不行(编译器不知道 fp 指向谁)。
# 4. 声明与typedef 技巧
# 4.1 声明阶梯
C 语言的函数指针声明,遵循"从内向外、从右向左"的阅读法则:
/* 阶梯 1:无参数无返回值 */
void (*fp1)(void); /* fp1 是指向 void fn(void) 的指针 */
/* 阶梯 2:有参数有返回值 */
int (*fp2)(int, int); /* fp2 是指向 int fn(int,int) 的指针 */
/* 阶梯 3:指针参数 */
void (*fp3)(const char *, int); /* fp3 指向 void fn(const char*, int) */
/* 阶梯 4:函数指针作为参数 */
void (*fp4)(void (*cb)(int)); /* fp4 指向 void fn(void (*)(int)) */
/* 阶梯 5:返回函数指针的函数(声明炸裂区) */
int (*get_handler(void))(int, int);
/* 读法:get_handler 是一个函数(参数 void),
返回一个 int (*)(int, int) 类型的函数指针
*/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
核心规则——括号包裹 * 名 是关键:
int *fp(int);→ fp 是函数,返回int*(不是函数指针!)int (*fp)(int);→ fp 是函数指针,指向返回int的函数
这组括号不能省略——它把"指向函数的指针"和"返回指针的函数"区分开来。
# 4.2 typedef可读
/* ❌ 不用 typedef——声明极其丑陋 */
void (*handlers[10])(const char *, void *);
/* 读法:handlers 是数组,有 10 个元素,
每个元素是函数指针,指向 void fn(const char*, void*) */
/* ✅ 用 typedef——一目了然 */
typedef void (*handler_t)(const char *, void *);
handler_t handlers[10];
/* ✅ 传递给函数时——干净 */
void register_handler(const char *name, handler_t func);
2
3
4
5
6
7
8
9
10
11
typedef 三连:
/* 1. 定义函数指针类型 */
typedef int (*cmp_t)(const void *, const void *);
/* 2. 用这个类型声明变量 */
cmp_t my_cmp;
/* 3. 用这个类型声明函数参数 */
void my_sort(void *base, size_t n, size_t size, cmp_t cmp);
2
3
4
5
6
7
8
这就是 qsort 的声明方式——cmp_t 让所有使用它的地方都可读。
# 4.3 返回函数指针
/* 场景:一个根据协议名返回对应处理函数的工厂 */
handler_t get_handler(const char *name); /* ← typedef 救了我们的命 */
/* 如果不用 typedef—— */
void (*get_handler_raw(const char *name))(const char *);
/* ↑ 函数名 ↑ 返回类型的参数部分
读法:get_handler_raw 是一个函数(参数 const char*),
返回 void (*)(const char*)
*/
2
3
4
5
6
7
8
9
通用模板:要声明一个返回函数指针的函数,写法是:
返回类型 (*函数名(参数列表))(函数指针指向的函数的参数列表)
这就是为什么 C 语言的函数指针声明常被称为"螺旋法则"——你的视线要从标识符开始向右转到 ),再向左转到 (,再向右,再向左……
# 5. qsort源码逐行拆解
# 5.1 qsort契约
glibc 的 qsort 声明(<stdlib.h>):
void qsort(void *base, /* 数组首地址 */
size_t nmemb, /* 元素个数 */
size_t size, /* 每个元素的字节数 */
int (*compar)(const void *, const void *)); /* 比较函数指针 */
2
3
4
契约(程序员和 qsort 之间的约定):
qsort 承诺:
✓ 对 base 做原地排序
✓ 调用 compar(a, b) 来确定元素顺序
✓ compar 返回 <0 时 a 在 b 前面;>0 时 a 在 b 后面;==0 时不确定
程序员承诺:
✓ compar 是一个全序关系(传递、反对称、完全)
✓ compar 不会修改 base 中的元素
✓ compar 的参数是两个指向 base 中元素的有效指针
2
3
4
5
6
7
8
9
# 5.2 泛型排序
qsort 的核心魔法——不知道元素类型,但能排序任意类型:
/* qsort 内部通过 compar 函数指针间接比较两个元素 */
void qsort(void *base, size_t n, size_t size,
int (*cmp)(const void *, const void *)) {
/* 通过指针运算定位元素 */
char *ptr = (char *)base;
/* 比较 ptr[i] 和 ptr[j]——但怎么比?调用比较函数! */
if (cmp(ptr + i * size, ptr + j * size) > 0) {
/* 交换 ptr[i] 和 ptr[j] */
swap(ptr + i * size, ptr + j * size, size);
}
}
2
3
4
5
6
7
8
9
10
11
12
qsort 不需要知道 element 是什么类型:
- 它用
char *做字节级指针运算(第 04 篇的结论) - 它用
cmp函数指针把"比较"这个操作外包给调用者 swap的size参数让交换也不需要知道类型
这就是泛型编程的 C 语言实现——void* + 指针运算 + 函数指针 = 零开销的类型泛化。
# 5.3 函数指针运算
以对一个 int 数组排序为例,看整条调用链的汇编:
int cmp_int(const void *a, const void *b) {
return (*(int *)a - *(int *)b);
}
int arr[] = {3, 1, 4, 1, 5, 9};
qsort(arr, 6, sizeof(int), cmp_int);
2
3
4
5
6
qsort 内核对 arr[2] 和 arr[3] 比较时的完整路径:
/* qsort 内部对两个元素的比较——语义等价于 */
char *bytes = (char *)base;
const void *elem_a = bytes + 2 * size; /* 指针运算:定位 arr[2] */
const void *elem_b = bytes + 3 * size; /* 指针运算:定位 arr[3] */
int result = cmp(elem_a, elem_b); /* 函数指针:外包比较逻辑 */
2
3
4
5
汇编级完整流程:
; 1. 字节指针运算定位 arr[2]
lea rdi, [base + 2 * size] ; elem_a = base + 8
; 2. 字节指针运算定位 arr[3]
lea rsi, [base + 3 * size] ; elem_b = base + 12
; 3. 通过函数指针调用 cmp
call [cmp_ptr] ; 间接调用 cmp_int
; 4. cmp_int 内部
mov eax, DWORD PTR [rdi] ; *(int*)a
mov ecx, DWORD PTR [rsi] ; *(int*)b
sub eax, ecx ; a - b
ret
2
3
4
5
6
7
8
9
10
11
12
13
14
qsort 是理解 C 语言"函数指针 + 指针运算 = 通用算法"的最佳范本——它只用 4 个参数就实现了完整的泛型排序,不需要模板,不需要运行时类型信息。
# 6. 回调上下文
# 6.1 无上下文回调的局限
/* 场景:遍历一个整数数组,对每个元素执行操作 */
typedef void (*foreach_fn)(int);
void foreach(int *arr, size_t n, foreach_fn fn) {
for (size_t i = 0; i < n; i++) {
fn(arr[i]);
}
}
/* 用例 1:打印每个元素 */
void print_elem(int x) { printf("%d ", x); }
foreach(arr, 5, print_elem); /* ✅ 简单场景能行 */
/* 用例 2:累加——函数无法访问外部变量! */
int sum = 0;
void add_to_sum(int x) {
sum += x; /* ← sum 是全局变量——必须用全局变量传递状态 */
}
foreach(arr, 5, add_to_sum); /* ⚠️ 能用但非常不优雅 */
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
无上下文回调的问题:回调函数只能访问全局变量或参数本身,无法接收调用者的"运行时状态"。
# 6.2 void上下文
/* ✅ 升级版——回调带上 void* 上下文 */
typedef void (*foreach_ctx_fn)(int x, void *user_data);
void foreach_ctx(int *arr, size_t n, foreach_ctx_fn fn, void *ctx) {
for (size_t i = 0; i < n; i++) {
fn(arr[i], ctx);
}
}
/* 用例 2 重写——通过上下文传递累加器 */
typedef struct {
int sum;
int count;
} AccumCtx;
void accumulate(int x, void *ctx) {
AccumCtx *c = (AccumCtx *)ctx;
c->sum += x;
c->count += 1;
}
AccumCtx ctx = {0};
foreach_ctx(arr, 5, accumulate, &ctx);
printf("sum=%d, count=%d\n", ctx.sum, ctx.count); /* sum=15, count=5 */
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void 上下文模式的核心设计*:
- 调用者分配一个上下文结构体(栈上或堆上)
- 传递给遍历函数的
ctx参数 - 回调函数收到
ctx,通过强制转换恢复原始类型 - 回调函数通过
ctx读写调用者的状态
这是 C 语言实现闭包的经典方式——把"捕获的变量"手动打包成一个 void* 传递。
# 6.3 观察者模式
#define MAX_OBSERVERS 16
typedef void (*observer_fn)(int event_id, void *data, void *ctx);
typedef struct {
observer_fn func;
void *ctx;
} Observer;
static Observer observers[MAX_OBSERVERS];
static int obs_count = 0;
/* 注册 */
void subscribe(observer_fn fn, void *ctx) {
if (obs_count < MAX_OBSERVERS) {
observers[obs_count].func = fn;
observers[obs_count].ctx = ctx;
obs_count++;
}
}
/* 通知 */
void notify(int event_id, void *data) {
for (int i = 0; i < obs_count; i++) {
observers[i].func(event_id, data, observers[i].ctx);
}
}
/* 使用示例——日志模块订阅温度传感器事件 */
typedef struct { FILE *fp; } LogCtx;
void log_temp(int event, void *data, void *ctx) {
LogCtx *lc = (LogCtx *)ctx;
fprintf(lc->fp, "[温度事件] id=%d, val=%.1f\n",
event, *(float *)data);
}
LogCtx lc = { .fp = stderr };
subscribe(log_temp, &lc);
notify(1, &(float){36.5f}); /* 触发通知 → 写入日志 */
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 7. 函数指针数组
# 7.1 用函数指针数组替代 switch-case
/* ❌ 原版——大段 switch-case,每加一个命令都要改这个函数 */
void handle_command(int cmd, void *data) {
switch (cmd) {
case CMD_OPEN: return handle_open(data);
case CMD_CLOSE: return handle_close(data);
case CMD_READ: return handle_read(data);
case CMD_WRITE: return handle_write(data);
case CMD_SEEK: return handle_seek(data);
default: return handle_unknown(data);
}
}
/* ✅ 函数指针数组——加命令只需在数组中加一行 */
typedef void (*cmd_handler_t)(void *);
static cmd_handler_t cmd_table[] = {
[CMD_OPEN] = handle_open,
[CMD_CLOSE] = handle_close,
[CMD_READ] = handle_read,
[CMD_WRITE] = handle_write,
[CMD_SEEK] = handle_seek,
};
void handle_command_v2(int cmd, void *data) {
if (cmd < 0 || cmd >= (int)(sizeof(cmd_table)/sizeof(cmd_table[0]))) {
handle_unknown(data);
return;
}
cmd_table[cmd](data); /* O(1) 分发 */
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
对比:
| 维度 | switch-case | 函数指针数组 |
|---|---|---|
| 添加新命令 | 修改 switch(可能忘记 break) | 数组追加一行 |
| 分发复杂度 | O(n) 或 O(log n)(编译器优化) | O(1)(确定的数组下标) |
| 运行时动态修改 | 不可能 | 可以(cmd_table[i] = new_handler) |
| 内存占用 | 无额外内存 | N × 8 字节(N 个函数指针) |
# 7.2 状态机
/* TCP 连接状态机——经典的四态模型 */
typedef enum {
STATE_CLOSED,
STATE_LISTEN,
STATE_ESTABLISHED,
STATE_CLOSE_WAIT,
} ConnState;
typedef struct {
int fd;
ConnState state;
} Connection;
/* 状态处理函数签名——处理输入事件,返回下一状态 */
typedef ConnState (*state_handler_t)(Connection *conn, int event);
/* 各状态的实现 */
ConnState handle_closed(Connection *conn, int event) {
if (event == EVT_ACTIVE_OPEN) {
printf("CLOSED → ESTABLISHED\n");
return STATE_ESTABLISHED;
}
return STATE_CLOSED;
}
ConnState handle_listen(Connection *conn, int event) {
if (event == EVT_RCV_SYN) {
printf("LISTEN → ESTABLISHED\n");
return STATE_ESTABLISHED;
}
return STATE_LISTEN;
}
ConnState handle_established(Connection *conn, int event) {
if (event == EVT_CLOSE) {
printf("ESTABLISHED → CLOSE_WAIT\n");
return STATE_CLOSE_WAIT;
}
return STATE_ESTABLISHED;
}
ConnState handle_close_wait(Connection *conn, int event) {
if (event == EVT_CLOSE_ACK) {
printf("CLOSE_WAIT → CLOSED\n");
return STATE_CLOSED;
}
return STATE_CLOSE_WAIT;
}
/* 状态转换表 */
static state_handler_t state_table[] = {
[STATE_CLOSED] = handle_closed,
[STATE_LISTEN] = handle_listen,
[STATE_ESTABLISHED] = handle_established,
[STATE_CLOSE_WAIT] = handle_close_wait,
};
/* 事件驱动——O(1) 状态转换 */
void process_event(Connection *conn, int event) {
state_handler_t handler = state_table[conn->state];
conn->state = handler(conn, event); /* 当前状态处理 → 返回下一状态 */
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
函数指针状态机的优势:
- 加一个新状态 = 写一个函数 + 在数组中加一行——不修改任何现有代码
- 状态转换路径在表中一目了然
- 可以在运行时替换某个状态的实现(热更新场景)
# 7.3 跳转表
函数指针数组在编译后,数组本身放在 .rodata 段(只读数据段):
$ objdump -s -j .rodata a.out
Contents of section .rodata:
4010 00000000 00000000 ← handle_closed 的地址
4018 10204000 00000000 ← handle_listen 的地址
4020 20304000 00000000 ← handle_established 的地址
4028 30405000 00000000 ← handle_close_wait 的地址
2
3
4
5
6
为什么放 rodata:函数指针数组在编译期就确定了,运行时不应被意外修改——放在只读段既能防止 bug 又能被多个进程共享(因为 rodata 是只读的,可以跨进程共享同一份物理内存)。
# 8. 信号处理
# 8.1 signal签名
#include <signal.h>
/* signal 的签名——就是一个"注册函数指针"的 API */
void (*signal(int signum, void (*handler)(int)))(int);
/* 用 typedef 简化 */
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
2
3
4
5
6
7
8
信号处理的工作流:
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
volatile sig_atomic_t g_got_signal = 0;
void my_handler(int sig) {
g_got_signal = 1; /* ← 只设标志位,不做任何重操作 */
}
int main() {
/* 注册 SIGINT (Ctrl+C) 的处理函数 */
signal(SIGINT, my_handler);
while (1) {
if (g_got_signal) {
printf("收到 SIGINT,优雅退出\n");
break;
}
sleep(1);
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
信号处理就是操作系统的"中断回调"——内核在某个事件发生时,通过信号机制调用你注册的函数。这就是为什么 signal() 的签名和 register_handler() 如出一辙——它们都是"注册一个函数,等事件发生时调用"的回调模式。
# 8.2 atexit——退出时回调
#include <stdlib.h>
void cleanup_db(void) { printf("关闭数据库连接\n"); }
void cleanup_log(void) { printf("刷新日志缓冲区\n"); }
int main() {
atexit(cleanup_db);
atexit(cleanup_log);
printf("主逻辑执行中...\n");
return 0;
/* 退出时自动调用:cleanup_log → cleanup_db (LIFO 顺序) */
}
2
3
4
5
6
7
8
9
10
11
12
13
atexit 的 LIFO 栈:
atexit 注册栈(LIFO):
┌──────────────┐
│ cleanup_db │ ← 第一个注册,最后执行
├──────────────┤
│ cleanup_log │ ← 第二个注册,第一个执行
└──────────────┘
2
3
4
5
6
这与 C++ 的全局对象析构顺序(栈逆序析构)是同一个哲学——后创建的先销毁。
# 8.3 异步安全
信号处理函数的运行环境极其受限——它可能在任何时刻打断主程序的执行:
/* ❌ 信号处理函数中绝对不能做的事情 */
void bad_handler(int sig) {
printf("收到信号 %d\n", sig); /* ❌ printf 不是异步信号安全的! */
void *p = malloc(1024); /* ❌ malloc 不是异步信号安全的! */
/* 如果主程序正好在 malloc 中间(持有了 arena 锁),
信号处理再调 malloc → 死锁! */
}
/* ✅ 安全的信号处理器只做"设标志位" */
volatile sig_atomic_t flag = 0;
void good_handler(int sig) {
flag = 1; /* ✅ sig_atomic_t 的读写保证原子性 */
}
2
3
4
5
6
7
8
9
10
11
12
13
异步信号安全的函数白名单(POSIX 规定,完整列表见 man 7 signal-safety):
_exit(), write(), close(), dup2(), sig_atomic_t 读写 —— 仅此而已。
# 9. 函数指针的安全陷阱
# 9.1 强转反模式
/* ❌ 极其危险的"shellcode"模式 */
unsigned char shellcode[] = {
0xb8, 0x2a, 0x00, 0x00, 0x00, /* mov eax, 42 */
0xc3 /* ret */
};
typedef int (*fn_t)(void);
fn_t f = (fn_t)shellcode; /* 把栈上数据强转为函数指针 */
// int result = f(); /* ❌ 可能 SIGSEGV——栈不可执行(NX 位)! */
2
3
4
5
6
7
8
9
10
在现代 OS 上,栈和堆都有 NX 位保护——数据页不可执行。把数据当代码执行在现代 Linux x86-64 上默认行不通(除非显式 mprotect)。
# 9.2 不可互转
void my_func(void) { }
void *vp = (void *)my_func; /* ⚠️ C 标准不保证 */
/* 函数指针 → void* 的转换在 C 标准中是未定义行为!
虽然 POSIX 和常见实现都允许,但严格按 C 标准这是 UB */
2
3
4
5
正确的跨类型存储——用 union 或专门的数据结构:
/* ✅ 用 union 统一存储函数指针和数据指针 */
typedef union {
void *data_ptr;
void (*func_ptr)(void);
} CallbackPtr;
CallbackPtr cp;
cp.func_ptr = my_func; /* 存函数指针 */
/* ... */
cp.func_ptr(); /* 取回并调用 */
2
3
4
5
6
7
8
9
10
# 9.3 悬空函数
回到第 1 章的崩溃。问题不是指针错了——是指向的代码没了:
/* 反模式——保存了动态库中函数的地址,但没有保护动态库不被卸载 */
void *so = dlopen("plugin.so", RTLD_LAZY);
handler_t h = dlsym(so, "handle");
register_handler("plugin", h);
/* ⚠️ 没有引用计数保护——如果后续 dlclose(so),h 就变悬空 */
2
3
4
5
正确做法:把 so 句柄和函数指针打包在一起,保持引用计数:
typedef struct {
void *so_handle; /* dlopen 的句柄——保证 .so 不被卸载 */
handler_t func; /* 函数地址 */
} PluginHandler;
PluginHandler *load_plugin(const char *path) {
PluginHandler *ph = malloc(sizeof(PluginHandler));
ph->so_handle = dlopen(path, RTLD_LAZY);
if (!ph->so_handle) { free(ph); return NULL; }
ph->func = dlsym(ph->so_handle, "handle");
if (!ph->func) { dlclose(ph->so_handle); free(ph); return NULL; }
return ph;
}
void unload_plugin(PluginHandler *ph) {
dlclose(ph->so_handle); /* ✅ 保证 func 失效的同时 so 被卸载 */
free(ph); /* 调用者必须先取消注册,
确保没有任何地方还在用 ph->func */
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
核心原则:函数指针的生命周期 ≤ 它指向的代码的生命周期。
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的 PLC 网关,六个疑问逐条作答:
| 疑问 | 答案 |
|---|---|
| ① 函数名到底是什么? | 第 3.1:函数名在表达式中自动退化为函数指针(就像数组名退化为指针),handler = handle_modbus 等价于 handler = &handle_modbus |
| ② 函数指针声明规律? | 第 4:括号包裹 *name 是关键——void (*fp)(int) vs void *fp(int),typedef 是最佳可读性解决方案 |
| ③ qsort 怎么用一个指针实现通用排序? | 第 5:void* + 字节级指针运算(定位元素)+ 函数指针(外包比较逻辑)= 零开销泛型 |
| ④ 回调怎么传外部数据? | 第 6.2:void* user_data 上下文模式——调用者分配结构体,回调通过 user_data 访问 |
| ⑤ 函数指针数组 vs switch-case? | 第 7.1:O(1) 分发 + 运行时可修改 + 不必修改现有代码 |
| ⑥ 函数指针什么时候会失效? | 第 9.3:指向动态库的函数指针在库被 dlclose 后悬空——必须保持 so 句柄的引用计数 |
这个 PLC 网关的完整 rewrite:
/* 修复要点:
1. dlopen 句柄和函数指针打包在一起(PluginHandler)
2. 热加载前先取消旧插件的注册,再 dlclose 旧句柄
3. 热加载后注册新插件
4. dispatch 之前检查插件是否有效
*/
typedef struct {
void *so_handle;
const char *name;
handler_t handler;
int valid; /* 有效性标记 */
} PluginEntry;
static PluginEntry plugins[MAX_HANDLERS];
/* 加载插件——保持 so_handle 生命周期 */
int load_plugin(const char *name, const char *path) {
void *so = dlopen(path, RTLD_LAZY);
if (!so) return -1;
handler_t h = (handler_t)dlsym(so, "handle");
if (!h) { dlclose(so); return -1; }
for (int i = 0; i < MAX_HANDLERS; i++) {
if (!plugins[i].valid) {
plugins[i].so_handle = so;
plugins[i].name = strdup(name);
plugins[i].handler = h;
plugins[i].valid = 1;
return 0;
}
}
dlclose(so);
return -1;
}
/* 安全卸载 */
void unload_plugin(const char *name) {
for (int i = 0; i < MAX_HANDLERS; i++) {
if (plugins[i].valid && strcmp(plugins[i].name, name) == 0) {
plugins[i].valid = 0; /* 先标记——阻止 dispatch 使用 */
dlclose(plugins[i].so_handle); /* 再卸载——安全(因为没人调用了) */
free((void *)plugins[i].name);
return;
}
}
}
/* 分发——加有效性检查 */
void dispatch_safe(const char *name, const char *payload) {
for (int i = 0; i < MAX_HANDLERS; i++) {
if (plugins[i].valid && strcmp(plugins[i].name, name) == 0) {
plugins[i].handler(payload); /* ✅ so_handle 未 dlclose,函数地址有效 */
return;
}
}
printf("未知协议: %s\n", name);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# 10.2 插件生命
插件系统的完整生命周期:
dlopen("plugin.so")
│
▼
┌─────────────────────────┐
│ so_handle (void*) │ ← 内核引用计数 +1
│ = 动态库在 mmap 区的基址 │
└─────────────────────────┘
│ dlsym(so, "handle")
▼
┌─────────────────────────┐
│ handler = 0x7f...1230 │ ← plugin.so 的 text 段中 handle 函数的地址
│ 这个地址在 so_handle │
│ 的映射范围内有效 │
└─────────────────────────┘
│ register(name, handler)
▼
┌─────────────────────────┐
│ plugins[i] = { │
│ .so_handle = so, │ ← 两者绑定在一起
│ .handler = h │
│ } │
└─────────────────────────┘
─── 运行中 ───
dispatch("plugin", data)
│ plugins[i].handler(data)
▼
handle 函数在 .text 段被执行
← so_handle 保持 dlopen 状态,内核不会 munmap 它
─── 热更新 ───
unload_plugin("plugin")
│ plugins[i].valid = 0 ← dispatch 不再使用
│ dlclose(plugins[i].so_handle) ← 内核引用计数 -1,munmap
▼
┌─────────────────────────┐
│ handler 的地址区域 │ ← 已被内核回收
│ 访问它会 SIGSEGV │
└─────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
关键设计:so_handle 和 handler 绑定在同一个 PluginEntry 中。只要 PluginEntry 活着(未被 unload_plugin),so_handle 就不会被 dlclose——函数指针就始终有效。
# 10.3 设计哲学回扣
哲学 1:代码即数据——函数指针把"行为"变成"值"
int x = 5; 把数据变成了值。void (*fp)() = my_func; 把行为变成了值。函数指针让代码可以存储在数组里、嵌入结构体里、作为参数传递、在运行时动态替换——行为不再是编译期硬编码的死代码,而是可以操作的一等公民。qsort 不会排序、状态机不会转换状态——它们只是框架,具体的行为由你注入的函数指针决定。
哲学 2:契约优于实现——函数指针的类型签名就是编程契约
int (*cmp)(const void *, const void *) 这个类型本身就是一份契约:它声明了"比较两个东西,返回整数表示顺序"。qsort 不需要知道 cmp 内部怎么实现的——只要符合这个签名即可。这就是里氏替换原则在 C 语言中的体现:类型签名 = 接口契约。
哲学 3:数据驱动——函数指针数组把控制流变成数据流
switch-case 是控制流驱动的——每增加一个分支,控制流复杂度线性增长。函数指针数组是数据驱动的——加一个新命令 = 在数组中加一行数据,不修改任何控制流代码。从控制流驱动到数据驱动,是 C 语言程序架构升级的标志性转变。
哲学 4:生命周期的铁律——指针不能比指向的对象活得更久
数据指针不能比 malloc 的内存活得更久(悬空指针)。函数指针不能比 dlopen 的库活得更久(悬空函数指针)。这个规则对数据指针和函数指针同样适用——指针的值只是一个数字,真正有意义的是它所指向的东西是否存在。
# 10.4 函数指针速查
| 操作 | 语法 | 说明 |
|---|---|---|
| 声明函数指针 | void (*fp)(int); | 括号包裹 * 是关键 |
| typedef 别名 | typedef void (*handler_t)(int); | 强烈推荐——可读性飞跃 |
| 赋值 | fp = my_func; 或 fp = &my_func; | 函数名自动退化,取地址也等价 |
| 调用 | fp(42); 或 (*fp)(42); | 两种写法完全等价 |
| 作为参数 | void sort(void*, size_t, int(*)(const void*,const void*)); | qsort 式泛型 |
| 作为返回值 | int (*get_handler(void))(int,int); | 难读——优先用 typedef |
| 数组 | handler_t table[] = {h1, h2, h3}; | 函数指针数组 = 命令表/状态机 |
| void* 上下文 | void (*cb)(void *ctx, int data); | 闭包模拟 |
函数指针安全清单:
1. 函数指针的值是可执行地址吗? ← 不是 → 调用即 SIGSEGV
2. 该地址所在的代码段还存在吗? ← 动态库被 dlclose → SIGSEGV
3. 函数签名匹配吗? ← 不匹配 → UB
4. 函数指针在调用前被初始化了吗? ← 未初始化 → 随机跳转
2
3
4
下一篇:函数指针让行为变成了可传递的值,下一步进入 06.限定符与指针语义——
const/volatile/restrict如何与指针组合,让编译器在优化、安全、硬件交互三个维度上精准发力。