11. 结构体与回调函数:函数指针在结构体中的应用、面向对象风格的C编程
说实话,很多C语言开发者写了好几年代码,还是把结构体当成“数据袋子”在用。装几个变量,传参方便点,仅此而已。但你要是看过Linux内核源码,或者用过一些成熟的嵌入式框架,你会发现结构体远不止这么简单。
我个人习惯把结构体看作C语言里的“类”。虽然C没有class关键字,但结构体+函数指针的组合,完全可以模拟出面向对象的行为。今天我们就聊聊这个——怎么用结构体实现回调函数,怎么写出面向对象风格的C代码。
11.1 函数指针回顾:别怕,它就是个地址
先简单回顾一下函数指针。说白了,函数指针就是一个变量,存的是函数的入口地址。你想想看,既然变量有地址,函数当然也有地址。
// 定义一个函数指针类型
typedef int (*operation_t)(int, int);
// 一个具体的函数
int add(int a, int b) {
return a + b;
}
// 使用函数指针
operation_t op = add;
int result = op(3, 4); // result = 7
嗯,这里要注意:operation_t 是一个指针类型,指向一个返回 int、接受两个 int 参数的函数。你给它赋值为 add,然后就能像调用普通函数一样调用它。
我在项目中遇到过不少新手,看到函数指针就发怵。其实你把它当成“函数的别名”就好,没那么玄乎。
11.2 把函数指针塞进结构体
关键来了。如果把函数指针作为结构体的成员,这个结构体就不再只是“数据容器”了——它开始有了“行为”。
// 定义一个“设备”结构体
typedef struct {
char name[32];
int (*init)(void);
int (*read)(uint8_t *buf, uint32_t len);
int (*write)(uint8_t *buf, uint32_t len);
void (*deinit)(void);
} device_t;
你看,这个结构体里既有数据(name),也有操作(init、read、write、deinit)。这像什么?像不像一个类的成员函数?
我曾经在一个物联网项目中,用这种方式管理多种传感器。每种传感器都实现一套相同的接口,然后注册到一个设备表中。主循环遍历设备表,调用统一的 read 函数。代码结构清晰得不得了。
11.3 回调函数的本质:把函数当参数传
回调函数,说白了就是把一个函数的地址传给另一个函数,让它在合适的时机调用你。结构体里的函数指针,天然就是干这个的。
// 定时器结构体
typedef struct {
uint32_t interval;
uint32_t last_tick;
void (*callback)(void *arg);
void *arg;
} timer_t;
// 定时器处理函数
void timer_process(timer_t *timer) {
uint32_t now = get_tick();
if (now - timer->last_tick >= timer->interval) {
timer->last_tick = now;
if (timer->callback) {
timer->callback(timer->arg);
}
}
}
这里 callback 就是一个函数指针,arg 是传给它的参数。你可以在初始化时注册不同的回调函数,实现不同的行为。这就是“控制反转”——框架代码不知道你要做什么,但它会在合适的时机调用你注册的函数。
void * 类型的上下文指针。这样回调函数就能访问到调用者的私有数据,灵活性大大提升。
11.4 面向对象风格的C编程:封装、继承、多态
说到面向对象,大家首先想到的是C++。但C语言也能做到,只是需要你手动实现一些机制。
11.4.1 封装:用结构体隐藏实现
封装就是把数据和操作绑在一起,对外只暴露接口。在C语言里,我们通常用“不透明指针”来实现。
// 头文件中只声明结构体,不定义
typedef struct uart_device uart_device_t;
// 提供操作接口
uart_device_t* uart_create(int port, int baudrate);
int uart_send(uart_device_t *dev, uint8_t *data, uint32_t len);
int uart_recv(uart_device_t *dev, uint8_t *buf, uint32_t len);
void uart_destroy(uart_device_t *dev);
用户看不到结构体内部,只能通过接口操作。这就是封装。我在写驱动库时经常用这种手法,用户根本不需要知道底层寄存器怎么配,给他一个句柄就行。
11.4.2 继承:结构体嵌套
C语言没有继承关键字,但你可以用结构体嵌套来模拟。把“基类”放在第一个成员,然后“派生类”在后面追加自己的成员。
// 基类:通用设备
typedef struct {
char name[32];
int (*open)(void);
int (*close)(void);
} base_device_t;
// 派生类:串口设备
typedef struct {
base_device_t base; // 基类放在第一个
int port;
int baudrate;
int (*config)(int port, int baudrate);
} uart_device_t;
使用时,你可以把 uart_device_t 的指针强制转换成 base_device_t 指针,因为基类成员在内存布局的最前面。这就是C语言里“继承”的实现原理。
11.4.3 多态:函数指针表
多态就是“同一个接口,不同实现”。在C语言里,我们通常用函数指针表(vtable)来实现。
// 定义操作接口表
typedef struct {
int (*init)(void);
int (*read)(uint8_t *buf, uint32_t len);
int (*write)(uint8_t *buf, uint32_t len);
} device_ops_t;
// 设备结构体
typedef struct {
device_ops_t *ops; // 指向操作表
void *priv; // 私有数据
} device_t;
// 具体实现:SPI Flash
static int spi_flash_init(void) { /* ... */ }
static int spi_flash_read(uint8_t *buf, uint32_t len) { /* ... */ }
static int spi_flash_write(uint8_t *buf, uint32_t len) { /* ... */ }
static device_ops_t spi_flash_ops = {
.init = spi_flash_init,
.read = spi_flash_read,
.write = spi_flash_write,
};
// 创建SPI Flash设备
device_t* spi_flash_create(void) {
device_t *dev = malloc(sizeof(device_t));
dev->ops = &spi_flash_ops;
dev->priv = malloc(sizeof(spi_flash_priv_t));
return dev;
}
调用时,你只需要通过 dev->ops->read() 来操作,根本不用关心底层是什么设备。这就是多态。
11.5 实战:一个简单的状态机框架
说了这么多理论,我们来看一个实际例子。用结构体+函数指针实现一个状态机框架。
// 状态处理函数类型
typedef int (*state_handler_t)(void *context, int event);
// 状态节点
typedef struct {
int state_id;
state_handler_t handler;
} state_node_t;
// 状态机
typedef struct {
state_node_t *states;
int state_count;
int current_state;
void *context;
} state_machine_t;
// 注册状态
void sm_register(state_machine_t *sm, state_node_t *states, int count) {
sm->states = states;
sm->state_count = count;
}
// 事件处理
int sm_handle_event(state_machine_t *sm, int event) {
for (int i = 0; i < sm->state_count; i++) {
if (sm->states[i].state_id == sm->current_state) {
int next_state = sm->states[i].handler(sm->context, event);
if (next_state >= 0) {
sm->current_state = next_state;
}
return next_state;
}
}
return -1; // 未找到状态
}
这个框架的核心思想是:每个状态对应一个处理函数,函数返回下一个状态。你只需要实现各个状态的处理逻辑,然后注册到状态机里。主循环调用 sm_handle_event 即可。
我在一个智能家居项目中用过这个框架,管理灯光、窗帘、空调的状态切换。代码量减少了40%,而且每个状态的处理逻辑独立,调试起来特别方便。
11.6 避坑指南
最后,分享几个我踩过的坑:
- 函数指针类型不匹配:我曾经把一个返回
int的函数赋值给返回void的函数指针,编译没报错,运行时栈乱了。一定要用typedef明确类型,不要偷懒。 - 回调函数中的上下文指针:如果回调函数需要访问调用者的数据,一定要通过
void *arg传进去,不要用全局变量。全局变量在多实例场景下会出大问题。 - 结构体对齐:函数指针在结构体中的位置会影响内存布局。如果你打算把结构体写入文件或通过通信接口传输,一定要用
#pragma pack控制对齐。 - 回调函数的生命周期:确保回调函数在调用时,函数指针指向的代码还在。如果回调函数是某个模块的局部函数,而模块已经被卸载了,调用就会崩溃。
11.7 知识体系总览
下面这张图总结了本章的核心内容,从函数指针到结构体应用,再到面向对象风格的实现路径。
这张图把本章的核心脉络理清楚了。从结构体+函数指针这个基础组合出发,往左走是回调函数机制,往右走是面向对象风格的三大特性,往下走是实战中的状态机框架。你写代码时,可以根据实际需求选择不同的路径。
好了,关于结构体与回调函数的内容就聊到这里。记住一点:结构体不只是装数据的,它还能装行为。用好函数指针,你的C代码会变得灵活、可扩展,甚至有点“面向对象”的味道。
公众号:蓝海资料掘金营,微信deep3321