08 函数指针:把行为作为参数

C 语言 OOP 系列导航

本系列面向会 C 和单片机基础、刚进入企业项目的同学,从 struct 封装一路讲到 ops、多态和类 Linux 驱动框架。

  1. C 语言面向对象设计路线
  2. 封装:struct 数据与 me 指针
  3. 信息隐藏:static 与头文件边界
  4. 类的组织:前缀、init/deinit 与生命周期
  5. 数据归位:struct 成员与 static 变量
  6. HAL 映射:工业库的封装结构
  7. 继承:struct 嵌套复用公共字段
  8. 函数指针:把行为作为参数 ⇐ 当前位置
  9. ops/vtable:行为表与对象绑定
  10. 多态与转型:base 指针与 dispatch
  11. 接口设计:ops 合同与默认实现
  12. 完整框架:抽象层与硬件解耦
  13. 终章:自动注册与 Linux 内核 OOP 结构

继承复用了公共字段,但还没有解决行为差异。

GPIO LED 的开灯是写电平,PWM LED 的开灯是设置占空比,RGB LED 的开灯可能要同时驱动三个通道。它们都叫“开灯”,但具体动作不同。

如果把动作写死:

1
2
3
4
void led_run_once(LedBase_t *led)
{
led_base_on(led);
}

那这个函数只能执行固定动作。想支持关灯、翻转、闪烁,就要继续加函数。

一句话先懂:函数指针让“调用哪个函数”变成一个可以传递、保存、替换的值。

先看问题:对象相同,动作可能不同

硬编码调用会让上层越来越臃肿:

1
2
3
void led_run_on(LedBase_t *led);
void led_run_off(LedBase_t *led);
void led_run_toggle(LedBase_t *led);

更好的方向是:对象保持不变,动作由调用方传进来。

函数名本身可以表示函数地址

先定义几个动作:

1
2
3
int led_base_on(LedBase_t *me);
int led_base_off(LedBase_t *me);
int led_base_toggle(LedBase_t *me);

它们都有相同的函数类型:

1
int (*)(LedBase_t *me)

这个写法第一次看会很别扭,所以通常用 typedef 起别名:

1
typedef int (*LedAction_t)(LedBase_t *me);

LedAction_t 的意思是:指向一个函数,这个函数接收 LedBase_t *,返回 int

把动作当参数传

通用执行函数可以接收一个动作指针:

1
2
3
4
5
6
7
8
9
10
11
12
int led_run_action(LedBase_t *me, LedAction_t action)
{
if (me == NULL) {
return -1;
}

if (action == NULL) {
return -2;
}

return action(me);
}

调用时传入不同函数:

1
2
3
led_run_action(&red.base, led_base_on);
led_run_action(&red.base, led_base_off);
led_run_action(&red.base, led_base_toggle);

注意函数名加不加括号的区别:

写法 含义
led_base_on 函数地址
led_base_on(&red.base) 立即调用函数
LedAction_t action = led_base_on; 保存函数地址
action(&red.base); 通过函数指针调用

运行时再决定调用谁

函数指针可以把“调用谁”推迟到运行时:

1
2
3
4
5
6
7
8
9
10
11
LedAction_t action = NULL;

if (mode == 0) {
action = led_base_on;
} else if (mode == 1) {
action = led_base_off;
} else {
action = led_base_toggle;
}

led_run_action(&red.base, action);

这就是从固定调用走向可替换行为。

回调:嵌入式最常见的函数指针形态

按钮模块不应该依赖 LED 模块。它只需要在检测到按下时,调用使用者注册的函数:

1
2
3
4
5
6
7
typedef void (*ButtonCallback_t)(void *context);

typedef struct {
uint8_t pin;
ButtonCallback_t on_pressed;
void *context;
} Button_t;

初始化时把回调和用户数据存进去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int button_init(Button_t *me, uint8_t pin,
ButtonCallback_t on_pressed, void *context)
{
if (me == NULL) {
return -1;
}

me->pin = pin;
me->on_pressed = on_pressed;
me->context = context;

gpio_init(pin);
return 0;
}

扫描时触发回调:

1
2
3
4
5
6
7
8
9
10
void button_scan(Button_t *me)
{
if (me == NULL) {
return;
}

if (gpio_read(me->pin) && me->on_pressed != NULL) {
me->on_pressed(me->context);
}
}

业务层把 LED 操作绑定到按钮事件:

1
2
3
4
5
6
7
8
9
10
static void user_led_toggle(void *context)
{
LedBase_t *led = (LedBase_t *)context;
led_base_toggle(led);
}

Button_t button;
LedBase_t led;

button_init(&button, 1, user_led_toggle, &led);

按钮模块不知道 LED 的存在,LED 模块也不知道按钮的存在。两者通过函数指针连接。

函数指针的风险

风险 防御方式
指针为 NULL 调用前检查
函数签名不匹配 typedef 固定类型
context 类型转错 明确约定传入对象类型
回调里做太多事 中断场景下保持短小

函数指针解决的是“调用谁”。至于什么时候调用、回调里能不能阻塞、能不能访问共享资源,仍然要按嵌入式场景判断。

新人常见误区

误区 更稳的理解
函数指针就是高级语法 它主要解决模块解耦和运行时选择
函数名和函数调用差不多 不加括号是地址,加括号才是调用
void *context 可以随便转 必须由接口约定清楚真实类型
回调里直接做复杂业务 中断或定时器回调应尽量短

C/C++ 对照

C 写法 C++ 写法
函数指针 函数对象 / lambda
typedef int (*Action)(LedBase_t *) std::function<int(LedBase*)>
action(me) action(me)
回调函数 callback
void *context 捕获变量 / 用户数据

嵌入式项目意义

函数指针在嵌入式里非常常见:

场景 用法
按键 按下后调用回调
定时器 超时后调用处理函数
通信协议 收到命令后调用 handler
驱动适配 不同硬件绑定不同函数
状态机 状态对应处理函数

它是从“写死调用”走向“可替换行为”的第一步。

总结

函数指针的本质,是把行为当成数据保存和传递;函数名不加括号是地址,加括号才是调用。