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 表,上层代码完全不用改。

我的习惯: 函数指针表用 const 修饰,放在 Flash 里。这样不占 RAM,而且防止指针被意外篡改。

4.3 模拟虚函数表:更完整的实现

上面的例子是基础版。实际项目中,我们往往需要更完整的“类”结构。我一般会这样设计:

  1. 设备对象结构体:存放设备的状态数据、配置参数。
  2. 操作函数表结构体:存放所有操作函数的指针。
  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 配置
}
我曾经踩过的坑: 函数指针表里的函数签名必须完全一致。如果某个函数不需要参数,也要保持 void * 占位,否则编译器会报警告,而且调用时栈会乱掉。

4.4 虚函数表的内存布局

你想想看,C++ 的虚函数表在内存里是什么样子?每个类有一张表,表里存着函数地址。每个对象有一个 vptr 指向这张表。我们用 C 模拟的也是同样的结构。

下面这张图展示了我们模拟的虚函数表在内存中的布局:

模拟虚函数表内存布局 sensor_t 对象实例 ops 指针 (4/8字节) priv 指针 (4/8字节) 其他状态数据... (存储在 RAM 中) 指向 sensor_ops_t 函数指针表 init → &ds18b20_init read → &ds18b20_read deinit → NULL (存储在 Flash 中,const 修饰) 跳转 函数体 关键点: • 每个对象实例有自己的 ops 指针,但多个同类对象可以共享同一张函数指针表 • 函数指针表放在 Flash 中,不占 RAM,适合资源受限的 MCU • 通过 ops 指针间接调用函数,实现运行时多态

4.5 实际项目中的设计模式

我在做物联网网关项目时,经常需要同时管理多种传感器。我一般会设计一个设备管理器,统一管理所有设备实例:

组件 职责 存储位置
设备对象 保存设备状态、配置、ops 指针 RAM(BSS/堆)
函数指针表 保存所有操作函数的地址 Flash(const)
设备管理器 维护设备列表,提供统一的注册、查找、轮询接口 RAM + Flash
具体驱动 实现硬件相关的操作函数 Flash(代码段)

这样做的好处很明显:

  • 新增设备类型:只需要写一套 ops 表,注册到管理器即可。
  • 替换设备:上层代码完全不变,只改初始化时的设备类型。
  • 测试:可以写一套 mock 的 ops 表,在 PC 上做单元测试。
我的建议: 函数指针表里的函数尽量返回 int16_t 或 int32_t 的错误码。0 表示成功,负数表示错误类型。这样上层可以统一处理错误,不用关心底层细节。

4.6 避坑指南与性能考量

用函数指针模拟虚函数表,虽然灵活,但也有代价。我总结几个常见的坑:

  • 函数调用开销:每次调用都要先取指针,再间接跳转。比直接调用多几条指令。对性能敏感的中断服务函数,建议不要用这种方式。
  • 指针判空:调用前一定要检查 ops 和函数指针是否为 NULL。我见过因为忘记初始化 ops 导致跑飞的案例。
  • const 正确性:函数指针表用 const 修饰,但函数本身可能修改对象状态。注意 const 的位置,不要误把函数指针本身也变成 const。
  • 调试难度:间接调用时,调试器可能无法直接显示调用栈。我的习惯是在每个函数入口加一个调试打印宏,方便追踪。
我曾经踩过的坑: 有一次我把函数指针表定义在局部变量里,结果函数返回后表被销毁了。设备对象里的 ops 指针变成了野指针,一调用就 HardFault。从那以后,我所有的函数指针表都定义为 static const 全局变量。

性能方面,我做过实测:在 72MHz 的 Cortex-M3 上,一次间接函数调用比直接调用多花约 50-80ns。对于大多数外设驱动来说,这个开销完全可以接受。但如果你的系统里每秒有几十万次调用,那就需要权衡了。

好了,这一章的内容就到这里。结构体加函数指针这套组合拳,是嵌入式 C 语言实现面向对象设计的基石。掌握了它,你就能写出更灵活、更易维护的嵌入式软件。