编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程虚拟地址空间
      • 栈与堆底层对决
      • 指针本质与多级解引
      • 指针运算底层真相
      • 函数指针与回调机制
        • 1. 案例引入
          • 1.1 一段崩在哪
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 函数指针全景
          • 2.2 编译期未知
        • 3. 函数名即地址
          • 3.1 函数名退化
          • 3.2 call 指令的汇编级跳转
          • 3.3 调用汇编差异
        • 4. 声明与typedef 技巧
          • 4.1 声明阶梯
          • 4.2 typedef可读
          • 4.3 返回函数指针
        • 5. qsort源码逐行拆解
          • 5.1 qsort契约
          • 5.2 泛型排序
          • 5.3 函数指针运算
        • 6. 回调上下文
          • 6.1 无上下文回调的局限
          • 6.2 void上下文
          • 6.3 观察者模式
        • 7. 函数指针数组
          • 7.1 用函数指针数组替代 switch-case
          • 7.2 状态机
          • 7.3 跳转表
        • 8. 信号处理
          • 8.1 signal签名
          • 8.2 atexit——退出时回调
          • 8.3 异步安全
        • 9. 函数指针的安全陷阱
          • 9.1 强转反模式
          • 9.2 不可互转
          • 9.3 悬空函数
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 插件生命
          • 10.3 设计哲学回扣
          • 10.4 函数指针速查
      • 限定符与指针语义
      • 补码与位运算原理
      • IEEE754浮点本质
      • 数组与指针的纠葛
      • 结构体对齐与优化
      • 字符串存储与安全
      • 预处理器宏与条件编译
      • 编译到汇编全流程
      • 链接器符号与重定位
      • 静态库与动态库对比
      • Make与CMake构建
      • 文件IO与系统调用
      • 动态内存管理揭秘
      • 未定义行为与防御
      • C工程化与设计哲学
    • 标准集库

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • C语言入门精通
  • 专栏博客
杨充
2026-06-10
目录

函数指针与回调机制

# 05.函数指针与回调机制

# 目录介绍

  • 1. 案例引入
    • 1.1 一段崩在哪
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 函数指针全景
    • 2.2 编译期未知
  • 3. 函数名即地址
    • 3.1 函数名退化
    • 3.2 call 指令的汇编级跳转
    • 3.3 调用汇编差异
  • 4. 声明与typedef 技巧
    • 4.1 声明阶梯
    • 4.2 typedef可读
    • 4.3 返回函数指针
  • 5. qsort源码逐行拆解
    • 5.1 qsort契约
    • 5.2 泛型排序
    • 5.3 函数指针运算
  • 6. 回调上下文
    • 6.1 无上下文回调的局限
    • 6.2 void上下文
    • 6.3 观察者模式
  • 7. 函数指针数组
    • 7.1 用函数指针数组替代 switch-case
    • 7.2 状态机
    • 7.3 跳转表
  • 8. 信号处理
    • 8.1 signal签名
    • 8.2 atexit——退出时回调
    • 8.3 异步安全
  • 9. 函数指针的安全陷阱
    • 9.1 强转反模式
    • 9.2 不可互转
    • 9.3 悬空函数
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 插件生命
    • 10.3 设计哲学回扣
    • 10.4 函数指针速查

# 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;
}
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
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
   ← 这个地址已经不是可读内存了!
1
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 消失了!
1
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 章
1
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
1
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 的比较函数           │ 用例:事件监听器         │ 用例:状态机、命令表
└─────────────────────────────────────────────────────────┘
1
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                │
                                └──────────────────────┘
1
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   (间接调用)
1
2
3
4
5
6

汇编差异:

; 直接调用
call    my_func                 ; 目标地址编码在机器码的偏移量中

; 函数指针调用
mov     rax, QWORD PTR [fp]    ; 从内存读出目标地址
call    rax                     ; 以寄存器值为目标地址跳转
1
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 sizeof or 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——套娃无限解引用也是它 */
1
2
3
4
5
6

这就是函数指针的一个反直觉特性:对函数名取地址和解引用可以无限套娃。

/* 全部等价——全部返回 &hello */
(**********hello) == hello;       /* ✅ 合法! */
1
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;
}
1
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
1
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;
}
1
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
1
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) 类型的函数指针
*/
1
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);
1
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);
1
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*)
*/
1
2
3
4
5
6
7
8
9

通用模板:要声明一个返回函数指针的函数,写法是:

返回类型 (*函数名(参数列表))(函数指针指向的函数的参数列表)
1

这就是为什么 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 *));  /* 比较函数指针 */
1
2
3
4

契约(程序员和 qsort 之间的约定):

qsort 承诺:
  ✓ 对 base 做原地排序
  ✓ 调用 compar(a, b) 来确定元素顺序
  ✓ compar 返回 <0 时 a 在 b 前面;>0 时 a 在 b 后面;==0 时不确定

程序员承诺:
  ✓ compar 是一个全序关系(传递、反对称、完全)
  ✓ compar 不会修改 base 中的元素
  ✓ compar 的参数是两个指向 base 中元素的有效指针
1
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);
    }
}
1
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);
1
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);        /* 函数指针:外包比较逻辑 */
1
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
1
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);  /* ⚠️ 能用但非常不优雅 */
1
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 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

void 上下文模式的核心设计*:

  1. 调用者分配一个上下文结构体(栈上或堆上)
  2. 传递给遍历函数的 ctx 参数
  3. 回调函数收到 ctx,通过强制转换恢复原始类型
  4. 回调函数通过 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});  /* 触发通知 → 写入日志 */
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
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) 分发 */
}
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);  /* 当前状态处理 → 返回下一状态 */
}
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
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 的地址
1
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);
1
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;
}
1
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 顺序) */
}
1
2
3
4
5
6
7
8
9
10
11
12
13

atexit 的 LIFO 栈:

atexit 注册栈(LIFO):
┌──────────────┐
│ cleanup_db   │ ← 第一个注册,最后执行
├──────────────┤
│ cleanup_log  │ ← 第二个注册,第一个执行
└──────────────┘
1
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 的读写保证原子性 */
}
1
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 位)! */
1
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 */
1
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();           /* 取回并调用 */
1
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 就变悬空 */
1
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 */
}
1
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);
}
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
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         │
  └─────────────────────────┘
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
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. 函数指针在调用前被初始化了吗?  ← 未初始化 → 随机跳转
1
2
3
4

下一篇:函数指针让行为变成了可传递的值,下一步进入 06.限定符与指针语义——const/volatile/restrict 如何与指针组合,让编译器在优化、安全、硬件交互三个维度上精准发力。

上次更新: 2026/06/11, 08:54:53
指针运算底层真相
限定符与指针语义

← 指针运算底层真相 限定符与指针语义→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式