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语言里“继承”的实现原理。

注意:这种强制转换只在基类成员位于结构体开头时才安全。如果基类不是第一个成员,或者有虚继承(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 知识体系总览

下面这张图总结了本章的核心内容,从函数指针到结构体应用,再到面向对象风格的实现路径。

结构体与回调函数:知识体系 结构体 + 函数指针 回调函数机制 控制反转:框架调用你的代码 void *arg 传递上下文 面向对象风格 封装:不透明指针隐藏实现 继承:结构体嵌套 + 强制转换 多态:vtable 函数指针表 实战:状态机框架 state_handler_t 函数指针 状态注册 + 事件分发

这张图把本章的核心脉络理清楚了。从结构体+函数指针这个基础组合出发,往左走是回调函数机制,往右走是面向对象风格的三大特性,往下走是实战中的状态机框架。你写代码时,可以根据实际需求选择不同的路径。

好了,关于结构体与回调函数的内容就聊到这里。记住一点:结构体不只是装数据的,它还能装行为。用好函数指针,你的C代码会变得灵活、可扩展,甚至有点“面向对象”的味道。


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