09 ops/vtable:把一组函数指针装进对象

C 语言 OOP 系列导航

  1. C语言你真的会封装吗?
  2. 三个LED写了三份代码?
  3. 同事改一行,LED全乱了
  4. 手搓class:前缀、init/deinit和生命周期
  5. 你的全局变量,该死了
  6. 工业库也是同一套封装套路
  7. struct嵌套消灭复制粘贴
  8. 写死的函数怎么换
  9. 把一组函数指针装进对象 ⇐ 当前位置
  10. 一个指针管所有LED
  11. 虚函数不实现会怎样
  12. 换硬件不改应用
  13. 从自动注册到Linux内核OOP全貌

函数指针能替换一个动作。但一个对象通常不止一个动作——LED 有 onoffset_brightnessdeinit;文件有 openreadwriterelease;网卡有 openstopstart_xmit

痛点:一个个传函数指针太散

如果只用单个函数指针:

1
2
3
led_run_action(led, gpio_led_on);
led_run_action(led, gpio_led_off);
led_run_set(led, gpio_led_set_brightness);

动作之间没有被组织起来。但对一个具体的 LED 来说,”一整套操作”应该是配套的:

操作 GPIO LED PWM LED
on 写 GPIO 高电平 设置 PWM 占空比
off 写 GPIO 低电平 占空比设 0
set_brightness 保存亮度 改 PWM duty
deinit 释放 GPIO 停止 PWM

这组函数应该打包在一起。

定义 LedOps_t

先前置声明基础类型:

1
typedef struct LedBase LedBase_t;

再定义操作表:

1
2
3
4
5
6
typedef struct {
int (*on)(LedBase_t *me);
int (*off)(LedBase_t *me);
int (*set_brightness)(LedBase_t *me, uint8_t brightness);
int (*deinit)(LedBase_t *me);
} LedOps_t;

这就是 ops — 把一组函数指针打包成一张表。

ops 放进对象

基础对象保存一个 ops 指针:

1
2
3
4
5
6
7
struct LedBase {
const LedOps_t *ops;
uint8_t pin;
uint8_t brightness;
bool is_on;
bool initialized;
};

为什么是 const LedOps_t *?因为操作表通常是静态只读的,某种类型的操作表一般固定,不应该在运行时被随便改。

GPIO LED 提供自己的 ops

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
typedef struct {
LedBase_t base;
} GpioLed_t;

static int gpio_led_on(LedBase_t *me)
{
me->is_on = true;
gpio_write(me->pin, true);
return 0;
}

static int gpio_led_off(LedBase_t *me)
{
me->is_on = false;
gpio_write(me->pin, false);
return 0;
}

static int gpio_led_set_brightness(LedBase_t *me, uint8_t brightness)
{
me->brightness = brightness;
me->is_on = (brightness > 0);
gpio_write(me->pin, me->is_on);
return 0;
}

static int gpio_led_deinit(LedBase_t *me)
{
gpio_led_off(me);
gpio_deinit(me->pin);
me->initialized = false;
return 0;
}

static const LedOps_t gpio_led_ops = {
.on = gpio_led_on,
.off = gpio_led_off,
.set_brightness = gpio_led_set_brightness,
.deinit = gpio_led_deinit,
};

初始化时绑定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int gpio_led_init(GpioLed_t *me, uint8_t pin)
{
if (me == NULL) {
return -1;
}

me->base.ops = &gpio_led_ops;
me->base.pin = pin;
me->base.brightness = 0;
me->base.is_on = false;
me->base.initialized = true;

gpio_init(pin);
return 0;
}

PWM LED 也提供自己的 ops

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
typedef struct {
LedBase_t base;
uint8_t pwm_channel;
} PwmLed_t;

static int pwm_led_on(LedBase_t *me)
{
me->is_on = true;
pwm_set_duty(me->pin, me->brightness);
return 0;
}

static int pwm_led_off(LedBase_t *me)
{
me->is_on = false;
pwm_set_duty(me->pin, 0);
return 0;
}

static int pwm_led_set_brightness(LedBase_t *me, uint8_t brightness)
{
me->brightness = brightness;

if (me->is_on) {
pwm_set_duty(me->pin, brightness);
}

return 0;
}

static const LedOps_t pwm_led_ops = {
.on = pwm_led_on,
.off = pwm_led_off,
.set_brightness = pwm_led_set_brightness,
.deinit = NULL,
};

这里故意把 deinit 写成 NULL — 接口缺失时必须处理,下一篇讲接口合同时会展开。

调用方式

有了 ops 后,调用形态是:

1
2
3
led->ops->on(led);
led->ops->set_brightness(led, 80);
led->ops->off(led);

从结构上看,对象现在有两部分:

内容 作用
普通字段 保存状态
ops 指针 指向行为表

对象不光”有数据”,还”知道自己该怎么被操作”。(C++ 里编译器生成 vtable 做这件事,C 里我们手动写出来,但思路完全一样。)

嵌入式里的 ops

ops 在驱动设计中到处可见 — LED 驱动(on/off/set_brightness)、存储驱动(read/write/erase)、通信接口(send/recv/ioctl)、文件系统(open/read/write/close)、网络设备(open/stop/transmit)。新增一种硬件时,只需要提供一套新的操作表,上层接口不用动。

单个函数指针替换一个动作,ops 操作表替换一整套行为。这就是 C 语言里的 vtable。