第五章 抽象接口层:用结构体封装函数指针,模拟C++虚函数表

说实话,很多嵌入式工程师一听到「面向对象」就头大。觉得那是做上层应用的人才需要的东西,跟咱们底层打交道的C语言没关系。

但我得说,这个想法得改改了。

我在项目中遇到过好几次这样的场景:一个系统要支持三种不同的传感器,每种传感器的初始化、读取、校准流程都不一样。如果写三个独立的驱动文件,那上层逻辑就得写三套if-else。代码量翻倍不说,后期加一个新传感器,得改好几个文件。

这时候,我就特别怀念C++的虚函数表。但咱们做嵌入式的,很多时候只能用C。怎么办?

其实C语言也能实现类似的效果。核心就一句话:用结构体把函数指针包起来

5.1 函数指针:C语言里的「接口契约」

先别急着上复杂结构。咱们先搞清楚函数指针是什么。

函数指针,说白了就是一个变量,它存的是函数的地址。你可以通过这个变量去调用函数。

// 定义一个函数指针类型
typedef int (*sensor_read_t)(uint8_t *buf, uint32_t len);

// 两个不同的实现
int read_temperature(uint8_t *buf, uint32_t len) {
    // 读取温度传感器
    return 0;
}

int read_humidity(uint8_t *buf, uint32_t len) {
    // 读取湿度传感器
    return 0;
}

// 使用
sensor_read_t read_func = read_temperature;
read_func(buffer, 10);  // 调用的是温度传感器的读取函数

你看,同一个函数指针,可以指向不同的函数。这就是多态的基础。

嗯,这里要注意:函数指针的签名必须完全一致。返回值、参数类型、参数个数,一个都不能差。否则编译器会报错,或者更糟——运行时崩溃。

5.2 结构体封装:造一个「虚函数表」

单个函数指针只能代表一个操作。但一个设备通常有多个操作:初始化、读取、写入、关闭。这时候就需要把多个函数指针打包成一个结构体。

// 定义一个设备操作接口
typedef struct {
    int (*init)(void);
    int (*read)(uint8_t *buf, uint32_t len);
    int (*write)(const uint8_t *buf, uint32_t len);
    int (*ioctl)(uint32_t cmd, void *arg);
    void (*deinit)(void);
} device_ops_t;

这个结构体,就是C语言版的「虚函数表」。每个字段都是一个函数指针,代表一个操作。

我个人习惯把这个结构体命名为 xxx_ops_t,ops就是operations的缩写。这样一看就知道是操作接口。

5.3 具体实现:给每个设备填一张表

有了接口定义,接下来就是给每个具体的设备实现这些函数,然后填到结构体里。

// 温度传感器的实现
static int temp_init(void) {
    // 初始化温度传感器硬件
    return 0;
}

static int temp_read(uint8_t *buf, uint32_t len) {
    // 读取温度数据
    return 0;
}

// 把函数指针填到结构体里
const device_ops_t temp_ops = {
    .init   = temp_init,
    .read   = temp_read,
    .write  = NULL,      // 温度传感器不支持写入
    .ioctl  = NULL,
    .deinit = NULL
};

// 湿度传感器的实现
static int hum_init(void) {
    // 初始化湿度传感器
    return 0;
}

static int hum_read(uint8_t *buf, uint32_t len) {
    // 读取湿度数据
    return 0;
}

const device_ops_t hum_ops = {
    .init   = hum_init,
    .read   = hum_read,
    .write  = NULL,
    .ioctl  = NULL,
    .deinit = NULL
};

注意看,我把不支持的操作设成了NULL。这很重要。调用方在使用前必须检查指针是否为空,否则会出大问题。

我曾经踩过的坑:有一次我忘了检查NULL指针,结果在某个设备上调了write操作。那个设备根本没有实现write,函数指针是NULL。程序直接跳到了地址0x00000000,然后就是HardFault。从那以后,我养成了一个习惯:每次调用函数指针前,先判断是不是NULL。

5.4 使用接口:上层代码不再关心底层细节

有了这个抽象层,上层代码就变得非常干净。

// 设备控制块
typedef struct {
    const device_ops_t *ops;
    void *private_data;   // 设备私有数据
} device_t;

// 统一的设备操作函数
int device_init(device_t *dev) {
    if (dev && dev->ops && dev->ops->init) {
        return dev->ops->init();
    }
    return -1;
}

int device_read(device_t *dev, uint8_t *buf, uint32_t len) {
    if (dev && dev->ops && dev->ops->read) {
        return dev->ops->read(buf, len);
    }
    return -1;
}

// 使用示例
device_t temp_dev = { .ops = &temp_ops };
device_t hum_dev  = { .ops = &hum_ops };

// 上层代码完全不知道底层是什么设备
device_init(&temp_dev);
device_read(&temp_dev, buffer, sizeof(buffer));

device_init(&hum_dev);
device_read(&hum_dev, buffer, sizeof(buffer));

你想想看,如果以后要加一个气压传感器,只需要实现三个函数,填一个pressure_ops结构体。上层代码一行都不用改。

这就是解耦的魅力。

5.5 更复杂的场景:带状态的多态接口

有时候,设备需要保存自己的状态。比如一个UART设备,可能有波特率、数据位、停止位等配置。这时候,函数指针结构体里可以加一个上下文参数。

// 带上下文的多态接口
typedef struct {
    int (*init)(void *ctx);
    int (*send)(void *ctx, const uint8_t *data, uint32_t len);
    int (*recv)(void *ctx, uint8_t *data, uint32_t len);
} uart_ops_t;

// UART1的实现
typedef struct {
    uint32_t baudrate;
    uint8_t  data_bits;
    uint8_t  stop_bits;
} uart1_ctx_t;

static int uart1_init(void *ctx) {
    uart1_ctx_t *c = (uart1_ctx_t *)ctx;
    // 根据c->baudrate等参数初始化硬件
    return 0;
}

const uart_ops_t uart1_ops = {
    .init = uart1_init,
    .send = uart1_send,
    .recv = uart1_recv
};

// 使用时
uart1_ctx_t uart1_cfg = {
    .baudrate = 115200,
    .data_bits = 8,
    .stop_bits = 1
};

device_init(&uart1_ops, &uart1_cfg);

这种模式在驱动层非常常见。每个设备实例都有自己的上下文,互不干扰。

5.6 整体架构图

下面这张图展示了整个抽象接口层的核心逻辑。你可以看到,上层应用只跟device_ops_t打交道,完全不关心底层是温度传感器还是湿度传感器。

抽象接口层架构图 上层应用代码 device_ops_t 抽象接口 init | read | write | ioctl | deinit 温度传感器 temp_ops 湿度传感器 hum_ops 气压传感器 pressure_ops 上层代码通过统一的 device_ops_t 接口调用,无需关心具体设备类型

5.7 避坑指南与最佳实践

用结构体封装函数指针,说起来简单,但实际用起来有几个坑。我一个个说。

核心原则:接口要稳定,实现要灵活。一旦接口定义好了,尽量不要改。否则所有实现都得跟着改,那就失去解耦的意义了。
  1. 函数指针必须检查NULL——这个前面说过了,不再重复。
  2. 接口函数尽量少——我见过有人把接口定义成20个函数。结果每个设备都要实现20个空函数。没必要。一般5-8个就够了。
  3. 用const修饰接口结构体——接口定义好后不应该被修改。加上const,编译器会帮你检查。
  4. 命名要统一——我个人习惯用xxx_ops_t命名接口类型,用xxx_ops命名实例。这样一看就知道是啥。
  5. 考虑可扩展性——如果以后可能要加新操作,可以在接口结构体末尾预留一个void *reserved字段。或者用版本号机制。
小技巧:如果你用的是GCC,可以用__attribute__((weak))给接口函数提供默认实现。这样设备只需要实现自己需要的函数,其他的用默认的就行。我在一个项目里用过这个技巧,减少了大约30%的代码量。

5.8 性能考量

有人会担心:用函数指针会不会影响性能?

说实话,会有一点点。每次调用函数指针,多了一次间接寻址。但这点开销在绝大多数嵌入式系统里可以忽略不计。

我做过测试:在72MHz的STM32上,100万次函数指针调用比直接函数调用多了大约0.3秒。对于大多数应用场景,这点延迟完全不是问题。

但如果你的系统对实时性要求极高(比如中断服务函数里),那还是直接用函数调用吧。别为了设计模式牺牲实时性。

调用方式 间接寻址开销 代码可维护性 适用场景
直接函数调用 低(紧耦合) 性能敏感、固定设备
函数指针调用 约2-3个CPU周期 高(松耦合) 多设备、需要扩展
函数指针+NULL检查 约3-5个CPU周期 最高(安全) 生产环境、安全关键

你看,性能开销其实很小。换来的却是代码的可维护性和可扩展性大幅提升。我个人觉得这笔买卖很划算。

好了,关于抽象接口层就讲这么多。核心思想就是:用结构体封装函数指针,把接口和实现分开。这样你的代码就能像搭积木一样,想换哪个模块就换哪个模块,不用动上层逻辑。

下次你写驱动的时候,不妨试试这个模式。一开始可能会觉得多写了几行代码,但等到项目后期要加新功能的时候,你就会感谢当初的自己了。

公众号:蓝海资料掘金营,微信deep3321