7、函数指针与结构体:在结构体中封装函数指针,模拟面向对象的多态

说实话,C语言没有class,没有继承,没有虚函数。但我们在嵌入式里照样写出了很灵活的设备驱动框架。怎么做到的?就是用结构体套函数指针。

我刚开始做嵌入式时,总觉得C语言写大项目很别扭。直到我看到了Linux内核的file_operations结构体——一个结构体里塞满了函数指针,不同的设备驱动只要填充不同的函数实现,上层调用完全不用改。那一刻我才明白:原来多态在C里早就有了。

7.1 为什么要在结构体里放函数指针?

你想想看,一个设备可能有多个操作:初始化、发送数据、接收数据、关闭。如果每个设备都写一套独立的函数,上层代码就得写一堆if-else判断设备类型。代码又臭又长,还不好扩展。

把函数指针放进结构体,相当于给每个设备发了一张「操作清单」。上层代码只管调用清单上的函数,具体谁来实现,它不关心。这就是多态的核心思想:同一接口,不同行为。

核心价值: 结构体封装函数指针 = 定义接口契约。实现者负责填充,调用者只管使用。

7.2 基础语法:结构体里的函数指针成员

先看一个最简单的例子。定义一个结构体,里面放两个函数指针:

// 定义一个设备操作接口
struct device_ops {
    int (*init)(void);
    int (*send)(const char *data, int len);
    int (*recv)(char *buf, int max_len);
    void (*deinit)(void);
};

这个结构体本身不干活,它只是定义了「设备应该有哪些操作」。具体干活的是后面填充进去的函数。

我习惯把这类结构体命名为xxx_ops,ops就是operations的缩写。Linux内核里到处都是这种命名风格,比如file_operations、tty_operations。你一看就知道这是个操作接口。

7.3 实战:用结构体实现设备多态

假设我们有两个设备:一个UART,一个SPI。它们都要做初始化、发送、接收。用传统写法,你得写两套函数名:uart_init、spi_init、uart_send、spi_send……上层调用时还得判断当前是哪个设备。

用结构体封装后,代码变成这样:

// UART设备的实现
static int uart_init(void) {
    printf("UART initialized\n");
    // 实际硬件初始化代码...
    return 0;
}

static int uart_send(const char *data, int len) {
    printf("UART send: %s\n", data);
    // 实际发送代码...
    return len;
}

static int uart_recv(char *buf, int max_len) {
    printf("UART receive\n");
    // 实际接收代码...
    return 0;
}

static void uart_deinit(void) {
    printf("UART deinitialized\n");
}

// SPI设备的实现
static int spi_init(void) {
    printf("SPI initialized\n");
    return 0;
}

static int spi_send(const char *data, int len) {
    printf("SPI send: %s\n", data);
    return len;
}

static int spi_recv(char *buf, int max_len) {
    printf("SPI receive\n");
    return 0;
}

static void spi_deinit(void) {
    printf("SPI deinitialized\n");
}

// 定义两个设备实例,填充函数指针
struct device_ops uart_device = {
    .init = uart_init,
    .send = uart_send,
    .recv = uart_recv,
    .deinit = uart_deinit
};

struct device_ops spi_device = {
    .init = spi_init,
    .send = spi_send,
    .recv = spi_recv,
    .deinit = spi_deinit
};

看到没?两个设备的结构体类型一模一样,但里面的函数指针指向了不同的实现。上层代码怎么用呢?

void device_test(struct device_ops *dev) {
    dev->init();      // 调用哪个init?取决于传入的dev
    dev->send("hello", 5);
    dev->deinit();
}

int main() {
    device_test(&uart_device);  // 测试UART
    device_test(&spi_device);   // 测试SPI
    return 0;
}

这就是多态。同一个device_test函数,传入不同的设备结构体,执行不同的行为。你再加一个I2C设备,只需要写一套I2C的实现函数,再定义一个device_ops结构体实例,上层代码一行都不用改。

我的习惯: 定义ops结构体时,我会在注释里写明每个函数指针的语义——参数是什么、返回值代表什么、调用者是否需要释放资源。这样别人接手代码时,看结构体定义就知道怎么用。

7.4 进阶:带上下文的结构体

上面的例子有个问题:函数指针没有参数传递设备相关的上下文。比如UART可能有不同的基地址、波特率配置。如果每个函数都靠全局变量,那多个UART实例就麻烦了。

解决方案:在结构体里再加一个void *ctx指针,用来保存设备上下文。

struct device {
    void *ctx;               // 设备上下文,指向具体的设备数据
    struct device_ops ops;   // 操作接口
};

// UART设备上下文
struct uart_context {
    uint32_t base_addr;
    int baudrate;
    int irq_num;
};

// 初始化UART设备
struct device *uart_create(uint32_t base_addr, int baudrate) {
    struct device *dev = malloc(sizeof(struct device));
    struct uart_context *ctx = malloc(sizeof(struct uart_context));
    
    ctx->base_addr = base_addr;
    ctx->baudrate = baudrate;
    
    dev->ctx = ctx;
    dev->ops.init = uart_init;
    dev->ops.send = uart_send;
    dev->ops.recv = uart_recv;
    dev->ops.deinit = uart_deinit;
    
    return dev;
}

// 在实现函数中,通过ctx获取设备信息
static int uart_send(struct device *dev, const char *data, int len) {
    struct uart_context *ctx = (struct uart_context *)dev->ctx;
    // 使用ctx->base_addr操作硬件寄存器
    // ...
    return len;
}

这样,每个设备实例都有自己的上下文,互不干扰。你可以在系统里创建多个UART设备,每个有不同的基地址和配置,但都通过同一个struct device接口来操作。

我曾经踩过的坑: ctx指针的类型转换一定要小心。我在一个项目里把SPI的上下文强转成了UART的上下文,结果程序跑飞了。后来我养成了一个习惯:在ops结构体里加一个type字段,用来标识设备类型,初始化时赋值,使用前检查。

7.5 知识体系:结构体封装函数指针的核心逻辑

下面这张图展示了整个设计模式的核心脉络:

结构体封装函数指针 — 模拟多态的核心逻辑 struct device_ops { 函数指针 } 定义接口契约:init, send, recv, deinit UART实现 uart_init, uart_send, uart_recv SPI实现 spi_init, spi_send, spi_recv struct device uart_dev struct device spi_dev 上层调用:device_test(struct device *dev) 不关心具体设备类型,只通过函数指针调用

从上图可以看得很清楚:接口定义在最上层,下面是不同的实现,再下面是实例化,最上层是调用。每一层各司其职,互不耦合。

7.6 实际项目中的应用场景

我在几个真实项目里用过这个模式:

  • 传感器驱动框架: 温度传感器、湿度传感器、压力传感器,都实现read_data和get_status两个接口。上层采集逻辑统一调用,换传感器只需换结构体实例。
  • 存储设备抽象: Flash、SD卡、EEPROM,都实现read、write、erase。文件系统层不关心底层是什么存储介质。
  • 通信协议栈: 不同的物理层(UART、SPI、I2C)都实现send_frame和recv_frame。协议解析层只跟接口打交道。
一个经验数据: 用这个模式重构一个设备驱动层后,代码量通常能减少30%-50%,而且新增设备时只需要写新增的实现文件,原有代码完全不动。我在一个物联网网关项目里,用这个模式管理了12种不同的传感器,上层采集代码不到200行。

7.7 避坑指南

用这个模式时,有几个地方容易出问题:

  1. 函数指针类型不匹配: 定义结构体时,函数指针的参数列表和返回值必须和实现函数完全一致。我曾经因为一个参数类型写成了int16_t而实现里是int,编译器报警了我没注意,结果传参时数据被截断。嗯,从那以后我每个函数指针都加上了完整的类型声明,并且打开编译器的所有警告。
  2. 空指针调用: 如果某个函数指针没有被赋值,调用时就会崩溃。我习惯在调用前加一个断言:assert(dev->ops.send != NULL);
  3. 上下文生命周期管理: ctx指针指向的内存必须在设备整个生命周期内有效。设备销毁时,记得先调用deinit,再释放ctx内存。
我的一个小技巧: 在结构体里加一个magic number字段,初始化时赋一个固定值(比如0xDEADBEEF),每次调用函数前检查magic number是否正确。这样可以快速发现野指针或者内存越界导致的结构体内容被篡改。

结构体封装函数指针,说白了就是用C语言实现面向对象的多态。它没有C++的语法糖,但更轻量、更可控,在嵌入式系统里特别实用。你掌握了这个模式,写出来的代码扩展性会好很多,而且更容易让别人接手。


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