C 回调函数(函数指针)完整教程

目录

  1. 函数指针基础
  2. 回调模式
  3. 相关类型
  4. 回调变种
  5. 常用场景
  6. 底层原理
  7. 进阶:闭包模拟
  8. 练习

1. 函数指针基础

函数名就是地址

#include <stdio.h>

void hello(void)
{
    printf("Hello\n");
}

int main(void)
{
    printf("hello  = %p\n", hello);
    printf("&hello = %p\n", &hello);
    /* 输出一样,函数名就是地址 */
    return 0;
}

函数名和数组名一样,在表达式中隐式 decay 成地址。hello 和 &hello 值相同,类型不同。

声明一个函数指针

/* 声明一个指向 void(void) 的函数指针 */
void (*p)(void);

p = hello;       /* OK:hello decay 成地址 */
p = &hello;      /* OK:等价 */
(*p)();          /* 调用:显式解引用 */
p();             /* 调用:语法糖,等价 */

口诀:"变量名先跟 * 再跟 ()"

void (*p)(void);
     ^ ^
     | └── 圆括号表示"这是个函数指针"
     └──── 星号表示"这是个指针"

/* 如果不加括号:*/
void *p(void);   /* 这是个返回 void* 的函数,不是函数指针!*/

带参数的函数指针

void add(int a, int b) {
    printf("%d\n", a + b);
}

void (*p)(int, int) = add;
p(3, 4);  /* 输出 7 */

用 typedef 简化

/* 不用 typedef:每次写完整签名 */
void (*p1)(int, int) = add;
void (*p2)(int, int);
p2 = sub;

/* 用 typedef:变成类型名 */
typedef void (*op_func_t)(int, int);

op_func_t p1 = add;
op_func_t p2 = sub;

typedef 的读法:去掉 typedef 和末尾的名字,剩下的就是类型名

typedef void (*op_func_t)(int, int);
// 去掉 typedef        去掉名字
// 剩下:void (*)(int, int)  ← 这就是 op_func_t 代表的类型

2. 回调模式

回调是函数指针最常见的应用。分三步:

1. 定义一个函数指针类型(回调的"签名")
2. 接收方:存地址,在合适的时机调用
3. 调用方:传一个符合签名的函数进去

完整例子

#include <stdio.h>

/* 1. 定义回调类型 */
typedef void (*notify_t)(int code);

/* 2. 接收方:存回调 + 触发 */
static notify_t g_cb = NULL;

void register_cb(notify_t cb)
{
    g_cb = cb;            /* 存函数地址 */
}

static void do_something(void)
{
    /* 一些操作... */
    if (g_cb) {
        g_cb(42);         /* 回调通知 */
    }
}

/* 3. 调用方:写一个匹配的函数 */
static void my_handler(int code)
{
    printf("code = %d\n", code);
}

int main(void)
{
    register_cb(my_handler);   /* 注册 */
    do_something();            /* 触发 */
    return 0;
}

内存分布

BSS/数据段               .text 段
────────────             ──────────────────────
g_cb = 0x401234  ──────→ my_handler:
                            push  rbp
                            mov   esi, edi
                            call  printf
                            pop   rbp
                            ret

执行 g_cb(42):
  1. 从 g_cb 读取 0x401234
  2. 将 42 存入 rdi(参数寄存器)
  3. call 0x401234
  4. 执行 my_handler 的机器码
  5. ret 回到调用点

3. 相关类型

typedef 的几种常用形式:

/* 最简单的:无参数无返回值 */
typedef void (*cb_t)(void);

/* 带参数 */
typedef void (*cb_t)(int code);
typedef void (*cb_t)(int code, void *user_data);

/* 带返回值 */
typedef int  (*cmp_t)(const void *a, const void *b);

/* 数组指针(用于回调表) */
typedef void (*btn_handler_t[])(void);

读法一致:去掉 typedef 和末尾的名字就是类型。


4. 回调变种

4.1 带上下文指针

给回调传一个 void *,让它能访问外部数据:

typedef void (*event_cb_t)(int code, void *ctx);

static event_cb_t g_cb;
static void *g_ctx;

void register_cb(event_cb_t cb, void *ctx)
{
    g_cb = cb;
    g_ctx = ctx;      /* 上下文透传给回调 */
}

// 触发
if (g_cb) g_cb(42, g_ctx);

// 调用方
static void my_handler(int code, void *ctx)
{
    struct my_data *d = ctx;   /* 还原指针 */
    printf("code=%d, name=%s\n", code, d->name);
}

struct my_data data = { .name = "sensor1" };
register_cb(my_handler, &data);

这是最常用的模式——pthread_create、qsort、LVGL 事件回调都用这种形式。

4.2 多回调(订阅模式)

#define MAX_CB 4

typedef void (*cb_t)(int);
static cb_t cbs[MAX_CB];
static int  cb_cnt;

void add_cb(cb_t cb)
{
    if (cb_cnt < MAX_CB)
        cbs[cb_cnt++] = cb;
}

void fire(int code)
{
    for (int i = 0; i < cb_cnt; i++)
        cbs[i](code);          /* 通知所有订阅者 */
}

4.3 回调表(多路分发)

typedef void (*btn_handler_t)(void);

void on_up(void)    { printf("UP\n"); }
void on_down(void)  { printf("DOWN\n"); }
void on_left(void)  { printf("LEFT\n"); }
void on_enter(void) { printf("ENTER\n"); }

/* 用表格代替 switch-case */
const btn_handler_t btn_table[] = {
    [KEY_UP]    = on_up,
    [KEY_DOWN]  = on_down,
    [KEY_LEFT]  = on_left,
    [KEY_ENTER] = on_enter,
};

/* 调用 */
void dispatch(int key)
{
    if (btn_table[key])
        btn_table[key]();      /* 直接跳转 */
}

4.4 链式回调

typedef void (*cb_t)(int);

static cb_t g_prev = NULL;

void wrap(cb_t new_cb)
{
    g_prev = new_cb;           /* 存旧的 */
    register_cb(my_wrapper);   /* 注册新的 */
}

static void my_wrapper(int code)
{
    printf("before\n");
    if (g_prev) g_prev(code);  /* 调到旧的 */
    printf("after\n");
}

5. 常用场景

5.1 qsort —— 标准库回调

int cmp_int(const void *a, const void *b)
{
    int ia = *(int*)a, ib = *(int*)b;
    return (ia > ib) - (ia < ib);
}

int arr[] = {3, 1, 4, 1, 5};
qsort(arr, 5, sizeof(int), cmp_int);
//                         ^ 函数指针

qsort 不知道你的数组元素怎么比大小——你用回调告诉它。

5.2 pthread_create —— 线程入口

void *worker(void *arg)
{
    printf("thread arg=%s\n", (char*)arg);
    return NULL;
}

pthread_t tid;
pthread_create(&tid, NULL, worker, "hello");
//                         ^ 回调函数
//                              ^ 上下文指针

5.3 signal —— 系统信号

void on_sigint(int sig)
{
    printf("Ctrl+C pressed\n");
    _exit(0);
}

signal(SIGINT, on_sigint);
//             ^ 注册回调
//               内核收到 Ctrl+C 时调它

5.4 LVGL 事件

这就是你项目里的用法:

lv_obj_add_event_cb(btn, my_handler, LV_EVENT_CLICKED, NULL);
//                           ^ 回调           ^ 事件类型   ^ 上下文

// 你的回调函数:
static void my_handler(lv_event_t *e)
{
    /* lv_event_get_user_data(e) 可以拿上下文 */
}

5.5 长按回调

/* hal/btn-input.h 声明类型 */
typedef void (*btn_longpress_handler_t)(uint16_t code);

/* 注册:main.c */
btn_input_set_longpress(3000, on_power_longpress);

/* 触发:indev.c process_events() */
if (tick_ms() - press_tick >= longpress_ms) {
    longpress_handler(held_code);
}

6. 底层原理

x86_64 汇编视角

void hello(int n) {
    printf("%d\n", n);
}

int main(void) {
    void (*p)(int) = hello;
    p(42);
    return 0;
}

编译后(AT&T 语法,简化):

# main:
    leaq  hello(%rip), %rax     # rax = hello 的地址 (.text 段)
    movq  %rax, -8(%rbp)        # p = rax (在栈上)

    movq  -8(%rbp), %rax        # rax = p
    movl  $42, %edi             # 第一个参数放入 edi
    call  *%rax                 # 间接调用:call hello的地址

# hello:
    movl  %edi, %esi            # 参数从 edi 移到 esi(printf 第二个参数)
    leaq  .LC0(%rip), %rdi      # 格式化字符串
    call  printf@PLT
    ret

关键指令:call *%rax —— 从寄存器取地址跳转,这就是"间接调用"。

对比:普通调用 vs 间接调用

hello(42);      // call hello      — 编译时决定地址
p(42);          // call *%rax      — 运行时决定地址(多一层间接)

间接调用的代价:一次额外的内存读取(从变量或内存中加载地址)。

为什么函数代码不会被释放

程序地址空间:
┌──────────────────────────────────┐
│  .text 段(只读)                 │
│  0x401000: hello 的机器码         │ ← p 指向这里
│  0x401050: world 的机器码         │
│  0x401100: main 的机器码          │
├──────────────────────────────────┤
│  .data / .bss 段(读写)          │
│  0x502000: p 变量(存 0x401000)   │
└──────────────────────────────────┘

.text 段在进程退出前永远不会被释放。所以函数指针永远有效——你用函数名取的地址在 .text 段。这和局部变量的栈分配完全是两套机制。


7. 进阶:闭包模拟

C 没有闭包,但可以用结构体 + 函数指针模拟:

/* 定义一个"对象":数据 + 方法 */
typedef struct {
    int count;
    void (*on_event)(struct counter *self, int n);
} counter_t;

/* 方法实现 */
static void counter_handler(counter_t *self, int n)
{
    self->count += n;
    printf("count = %d\n", self->count);
}

/* 初始化 */
void counter_init(counter_t *c)
{
    c->count = 0;
    c->on_event = counter_handler;   /* 绑定方法 */
}

/* 使用 */
counter_t c;
counter_init(&c);
c.on_event(&c, 5);  /* 输出 count = 5 */
c.on_event(&c, 3);  /* 输出 count = 8 */

这就是 C 里模拟"面向对象"的典型手法——结构体存数据和函数指针,函数指针第一个参数传结构体自身(类似 C++ 的 this)。


8. 练习

下面几个例子你在项目里跑一遍,理解回调的工作方式:

练习 1:手写一个回调注册 + 触发

#include <stdio.h>

typedef void (*cb_t)(int);

static cb_t g_cb;

void reg(cb_t cb) {
    g_cb = cb;
}

void fire(int n) {
    if (g_cb) g_cb(n);
}

static void my(int n) {
    printf("n=%d\n", n);
}

int main(void) {
    reg(my);
    fire(100);   /* 期望输出 n=100 */
    return 0;
}

练习 2:带上下文

struct ctx {
    int id;
};

typedef void (*cb_t)(int code, void *ctx);

/* 自行实现 reg / fire / handler / main */
/* 要求 handler 能打印 ctx->id */

练习 3:读懂本项目现有的回调链

// 1. hal/btn-input.h 声明类型 btn_longpress_handler_t
// 2. main.c 调用 btn_input_set_longpress(3000, on_power_longpress)
// 3. indev.c 的 process_events() 在计时到 3s 时触发回调
// 4. on_power_longpress(115) 被调用

总结

概念一句话
函数名就是地址,func 和 &func 一样
函数指针void (*p)(int) —— p 存的是函数地址
typedeftypedef void (*F)(int) —— 把类型换成名字
回调把函数地址传给别人,别人到时候调回来
上下文指针传一个 void * 给回调,让它能访问外部数据
.text 段存代码,只读,程序退出前不释放
间接调用call *%rax —— 比普通 call 多一次内存读取