09 操作表:使用 ops 组织对象接口

C 语言面向对象系列

  1. 总纲:C 语言面向对象设计概览
  2. 封装:结构体与多实例接口
  3. 信息隐藏:公共接口与内部实现
  4. 模块组织:命名约定与对象生命周期
  5. 数据组织:实例状态、模块状态与常量
  6. HAL 实例:STM32 外设接口中的封装设计
  7. 结构体嵌套:公共数据与行为的复用
  8. 函数指针:可替换行为与回调
  9. 操作表:使用 ops 组织对象接口(当前篇)
  10. 多态:统一接口、行为分发与类型转换
  11. 接口约定:必选操作、可选操作与默认实现
  12. 框架整合:应用、驱动与平台的分层
  13. 工程扩展:自动注册与 Linux 内核中的对象组织

函数指针可以替换一个操作,而设备通常需要一组相关操作。例如 LED 有开启、关闭、亮度设置和释放资源;文件有打开、读取、写入和关闭;网卡也有启动、停止和发送等操作。

本节将这些相关函数指针放进一个结构体,称为操作表,通常命名为 ops。

将相关操作放在一起

如果每次调用都单独传入具体函数,代码可能呈现为下面的形式:

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

调用方需要分别选择每个函数。对于同一种设备,这些操作通常应来自同一个实现:

操作 GPIO LED PWM LED
on 输出高电平 设置 PWM 占空比
off 输出低电平 将占空比设为 0
set_brightness 保存数值,并按零与非零设置开关 设置或保存占空比
deinit 释放 GPIO 配置 按资源约定停止或释放 PWM

可以把一组实现集中存放,由对象记录使用哪一组。

定义操作表类型

操作函数需要接收 LedBase_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;

每个成员都是函数指针。on、off、deinit 接收对象指针,set_brightness 还接收亮度参数。

在对象中保存操作表指针

基础结构体增加 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 实现操作表

先实现各个操作,再将函数填入 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,
};

这些函数处理 GPIO LED 的开关、亮度记录和资源释放。其中 set_brightness 仍然只按零与非零设置电平,并没有实现 PWM 调光。

初始化对象时,将它绑定到这份操作表:

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

me->base.ops = &gpio_led_ops 建立了对象与实现之间的关系。其他 GPIO LED 实例也可以使用同一份表,各自保留独立状态。

为 PWM LED 提供另一份实现

PWM LED 使用相同的 LedOps_t 类型,但填入不同函数:

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,表示这份表没有提供该操作。调用方必须处理缺失操作,第 11 篇会说明必选操作、可选操作和默认实现。

第二,这段简化代码仍通过 base.pin 调用 pwm_set_duty(),尚未读取具体结构体中的 pwm_channel。如果底层接口接收 PWM 通道号,就需要从基础指针取得具体对象,再读取通道成员;第 10 篇会展示这一过程。

通过操作表调用函数

对象完成初始化并绑定有效操作表后,可以沿着 ops 找到对应函数:

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

以 led->ops->on(led) 为例:先取得对象的操作表,再读取 on 函数指针,最后将当前对象传给它。

这种直接调用要求对象、操作表和相应函数指针都有效。下一篇会提供统一入口,将检查和调用放在同一个函数中。

对象中的内容 用途
普通成员 保存配置和运行状态
ops 指针 指向该对象使用的一组操作

这种操作表与 C++ 实现虚函数调用时常用的虚函数表(vtable)用途相近。这里的表和分发过程由 C 代码显式定义,具体的类型检查和接口约定也需要自行处理。

将操作表用于其他接口

同样的组织方式可以用于存储驱动的 read/write/erase、通信接口的 send/recv/ioctl、文件系统的 open/read/write/close,以及网络设备的 open/stop/transmit。

当新硬件能够遵循现有接口约定时,可以增加对应的操作表及实现,让上层继续使用相同接口。