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结构体实例,上层代码一行都不用改。
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接口来操作。
7.5 知识体系:结构体封装函数指针的核心逻辑
下面这张图展示了整个设计模式的核心脉络:
从上图可以看得很清楚:接口定义在最上层,下面是不同的实现,再下面是实例化,最上层是调用。每一层各司其职,互不耦合。
7.6 实际项目中的应用场景
我在几个真实项目里用过这个模式:
- 传感器驱动框架: 温度传感器、湿度传感器、压力传感器,都实现read_data和get_status两个接口。上层采集逻辑统一调用,换传感器只需换结构体实例。
- 存储设备抽象: Flash、SD卡、EEPROM,都实现read、write、erase。文件系统层不关心底层是什么存储介质。
- 通信协议栈: 不同的物理层(UART、SPI、I2C)都实现send_frame和recv_frame。协议解析层只跟接口打交道。
7.7 避坑指南
用这个模式时,有几个地方容易出问题:
- 函数指针类型不匹配: 定义结构体时,函数指针的参数列表和返回值必须和实现函数完全一致。我曾经因为一个参数类型写成了int16_t而实现里是int,编译器报警了我没注意,结果传参时数据被截断。嗯,从那以后我每个函数指针都加上了完整的类型声明,并且打开编译器的所有警告。
- 空指针调用: 如果某个函数指针没有被赋值,调用时就会崩溃。我习惯在调用前加一个断言:assert(dev->ops.send != NULL);
- 上下文生命周期管理: ctx指针指向的内存必须在设备整个生命周期内有效。设备销毁时,记得先调用deinit,再释放ctx内存。
结构体封装函数指针,说白了就是用C语言实现面向对象的多态。它没有C++的语法糖,但更轻量、更可控,在嵌入式系统里特别实用。你掌握了这个模式,写出来的代码扩展性会好很多,而且更容易让别人接手。
公众号:蓝海资料掘金营,微信deep3321