09 ops/vtable:行为表与对象绑定

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 结构

上一篇讲了函数指针。它能把一个动作传来传去,但真实对象通常不止一个动作。

一颗 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);

一句话先懂:ops 就是一张行为表,把一个对象的一整套动作打包在一起,再绑定到对象身上。

先看问题:动作应该成套出现

对一个具体 LED 来说,on/off/set_brightness/deinit 是配套的:

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

这些函数不应该散落在调用点,而应该作为一个整体描述“这种 LED 怎么工作”。

定义 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;

这张表不是数据状态,而是一组行为入口。C++ 里编译器会帮你生成 vtable;C 语言里我们手动写出这张表。

把 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
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;
}

再把函数填入一张静态表:

1
2
3
4
5
6
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

PWM LED 的数据和行为不同,所以它有自己的操作表:

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 指针 指向行为表

也就是说,对象不仅有数据,还知道“自己应该用哪套函数操作”。

新人常见误区

误区 更准确的理解
ops 只是函数指针数组 它表达的是一组接口能力
每个对象都要复制一张 ops 通常同类型对象共用一张 static const
ops 可以运行时随便改 除非明确需要热切换,否则初始化后应保持稳定
有了 ops 就可以直接调用 真实项目里应通过 dispatch 统一检查

C/C++ 对照

C++ C
虚函数 函数指针
虚函数表 vtable ops 结构体
对象里的 vptr const LedOps_t *ops
obj->on() obj->ops->on(obj)
纯虚接口 只定义函数指针槽位

嵌入式项目意义

ops 在驱动设计里非常常见:

场景 ops 示例
LED 驱动 on/off/set_brightness
存储驱动 read/write/erase
通信接口 send/recv/ioctl
文件系统 open/read/write/close
网络设备 open/stop/transmit

新增一种硬件时,只需要提供一张新的操作表,上层接口可以保持稳定。

总结

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