4、面向对象思想在嵌入式中的应用:结构体与函数指针模拟虚函数表
很多从 PC 或 Java 转过来的工程师,刚接触嵌入式时都会问一个问题:C 语言不是面向对象的,那嵌入式里怎么做模块化?
说实话,C 语言确实没有 class、继承、多态这些语法糖。但嵌入式系统里,我们照样能把面向对象的思想用起来。怎么用?靠的就是结构体 + 函数指针。说白了,就是用 C 语言模拟出 C++ 里虚函数表的效果。
我在项目中遇到过不少这样的场景:一个系统里要支持多种传感器,或者多种通信协议。如果每种传感器都写一套独立的 API,那代码量翻倍不说,维护起来也让人头疼。这时候,面向对象的思路就派上用场了。
4.1 为什么嵌入式需要“虚函数表”?
先想一个问题:你写了一个温控系统,今天用 DS18B20,明天客户要求换成 SHT30。如果代码里到处都是 ds18b20_read(),那换传感器就得改一堆地方。
更好的做法是什么?定义一个统一的接口:sensor_init()、sensor_read()、sensor_deinit()。不管底层是什么传感器,上层都调用同一套函数名。这就是多态的思想。
C 语言里没有虚函数,但我们可以用函数指针来模拟。每个传感器实例就是一个结构体,里面存着它自己的操作函数指针。调用时,通过结构体找到对应的函数去执行。这就是嵌入式里的“虚函数表”。
4.2 结构体 + 函数指针:基础实现
我们先看一个最简单的例子。假设要抽象一个 LED 设备,它有点亮、熄灭、状态反转三个操作。
// led.h
typedef struct {
void (*init)(void);
void (*on)(void);
void (*off)(void);
void (*toggle)(void);
} led_ops_t;
typedef struct {
uint8_t port;
uint8_t pin;
uint8_t active_level; // 0:低电平点亮, 1:高电平点亮
const led_ops_t *ops; // 指向操作函数表
} led_device_t;
然后我们实现一个具体的 LED 实例:
// led_gpio.c
static void gpio_led_init(void) {
// 配置 GPIO 为输出
}
static void gpio_led_on(void) {
// 拉高/拉低电平
}
static void gpio_led_off(void) {
// 拉低/拉高电平
}
static void gpio_led_toggle(void) {
// 翻转电平
}
static const led_ops_t gpio_led_ops = {
.init = gpio_led_init,
.on = gpio_led_on,
.off = gpio_led_off,
.toggle = gpio_led_toggle,
};
led_device_t led1 = {
.port = GPIOA,
.pin = 5,
.active_level = 1,
.ops = &gpio_led_ops,
};
上层使用时,只需要调用 led1.ops->on() 即可。如果换了一个 I2C 控制的 LED,只需要换一套 ops 表,上层代码完全不用改。
4.3 模拟虚函数表:更完整的实现
上面的例子是基础版。实际项目中,我们往往需要更完整的“类”结构。我一般会这样设计:
- 设备对象结构体:存放设备的状态数据、配置参数。
- 操作函数表结构体:存放所有操作函数的指针。
- 构造函数:初始化设备对象,绑定操作函数表。
来看一个传感器驱动的例子:
// sensor.h
typedef struct {
int16_t (*init)(void *self, void *config);
int16_t (*read)(void *self, void *data);
int16_t (*deinit)(void *self);
} sensor_ops_t;
typedef struct {
const sensor_ops_t *ops;
void *priv; // 私有数据,指向具体传感器的上下文
} sensor_t;
// 统一的初始化接口
static inline int16_t sensor_init(sensor_t *dev, void *config) {
if (dev && dev->ops && dev->ops->init) {
return dev->ops->init(dev, config);
}
return -1;
}
// 统一的读取接口
static inline int16_t sensor_read(sensor_t *dev, void *data) {
if (dev && dev->ops && dev->ops->read) {
return dev->ops->read(dev, data);
}
return -1;
}
具体传感器实现时,只需要填充 ops 表:
// ds18b20.c
static int16_t ds18b20_init(void *self, void *config) {
sensor_t *sensor = (sensor_t *)self;
// 解析 config,初始化 DS18B20
return 0;
}
static int16_t ds18b20_read(void *self, void *data) {
sensor_t *sensor = (sensor_t *)self;
// 读取温度值,存入 data
return 0;
}
static const sensor_ops_t ds18b20_ops = {
.init = ds18b20_init,
.read = ds18b20_read,
.deinit = NULL, // 不需要 deinit
};
// 构造函数
void ds18b20_create(sensor_t *dev, gpio_cfg_t *cfg) {
dev->ops = &ds18b20_ops;
dev->priv = cfg; // 私有数据指向 GPIO 配置
}
4.4 虚函数表的内存布局
你想想看,C++ 的虚函数表在内存里是什么样子?每个类有一张表,表里存着函数地址。每个对象有一个 vptr 指向这张表。我们用 C 模拟的也是同样的结构。
下面这张图展示了我们模拟的虚函数表在内存中的布局:
4.5 实际项目中的设计模式
我在做物联网网关项目时,经常需要同时管理多种传感器。我一般会设计一个设备管理器,统一管理所有设备实例:
| 组件 | 职责 | 存储位置 |
|---|---|---|
| 设备对象 | 保存设备状态、配置、ops 指针 | RAM(BSS/堆) |
| 函数指针表 | 保存所有操作函数的地址 | Flash(const) |
| 设备管理器 | 维护设备列表,提供统一的注册、查找、轮询接口 | RAM + Flash |
| 具体驱动 | 实现硬件相关的操作函数 | Flash(代码段) |
这样做的好处很明显:
- 新增设备类型:只需要写一套 ops 表,注册到管理器即可。
- 替换设备:上层代码完全不变,只改初始化时的设备类型。
- 测试:可以写一套 mock 的 ops 表,在 PC 上做单元测试。
4.6 避坑指南与性能考量
用函数指针模拟虚函数表,虽然灵活,但也有代价。我总结几个常见的坑:
- 函数调用开销:每次调用都要先取指针,再间接跳转。比直接调用多几条指令。对性能敏感的中断服务函数,建议不要用这种方式。
- 指针判空:调用前一定要检查 ops 和函数指针是否为 NULL。我见过因为忘记初始化 ops 导致跑飞的案例。
- const 正确性:函数指针表用 const 修饰,但函数本身可能修改对象状态。注意 const 的位置,不要误把函数指针本身也变成 const。
- 调试难度:间接调用时,调试器可能无法直接显示调用栈。我的习惯是在每个函数入口加一个调试打印宏,方便追踪。
性能方面,我做过实测:在 72MHz 的 Cortex-M3 上,一次间接函数调用比直接调用多花约 50-80ns。对于大多数外设驱动来说,这个开销完全可以接受。但如果你的系统里每秒有几十万次调用,那就需要权衡了。
好了,这一章的内容就到这里。结构体加函数指针这套组合拳,是嵌入式 C 语言实现面向对象设计的基石。掌握了它,你就能写出更灵活、更易维护的嵌入式软件。