3、设备对象模型:面向对象思想在C语言中的实现、设备结构体设计、操作函数指针表

说实话,很多做嵌入式开发的朋友,一开始接触的都是“面向过程”的写法。一个LED驱动,一个UART驱动,都是各自为战。代码写多了,你会发现一个问题:换个芯片,或者加个同类型的新设备,代码得重写一大半。

我早年做项目时也踩过这个坑。一个项目里挂了三个不同厂家的温度传感器,每个传感器的初始化、读取、配置流程都不一样。我那时候图省事,写了三个独立的驱动文件,每个文件里一堆全局变量和函数。后来产品要升级,加第四个传感器……嗯,那感觉,就像在乱麻里找线头。

为什么会这样?因为我们缺少一个统一的“设备对象模型”。说白了,就是没把设备当成一个“对象”来管理。这一章,我们就来聊聊怎么用C语言实现面向对象的思想,把设备抽象成一个结构体,再用函数指针表把操作统一起来。

3.1 为什么C语言也需要“对象”

你可能会问:“C语言又不是C++,搞什么对象?”

其实,面向对象是一种思想,不是某种语言的专利。C语言虽然没有class关键字,但结构体(struct)和函数指针(function pointer)组合起来,完全可以模拟出“封装”、“继承”和“多态”的效果。

我在项目中总结了一个经验:驱动框架的核心,就是把“设备”这个实体,抽象成一个可操作的数据结构。这个数据结构里,既要有设备的属性(比如寄存器基地址、中断号、状态标志),也要有设备的行为(比如初始化、读、写、控制)。

核心思想: 设备 = 属性 + 行为。属性用结构体成员表示,行为用函数指针表示。

这样做的好处很明显:

  • 代码复用: 同类型设备,只需要换一套操作函数,结构体定义可以共用。
  • 易于扩展: 新设备加入,只需要实现对应的操作函数,然后注册到框架里。
  • 分层清晰: 上层应用不需要关心底层硬件细节,它只跟“设备对象”打交道。

3.2 设备结构体设计:从零开始搭积木

我们先来看一个最基础的设备结构体应该长什么样。我个人习惯,把设备结构体分成三个区域:标识区、资源区、操作区。

区域 包含内容 作用
标识区 设备名称、设备ID、设备类型 用于区分不同的设备实例
资源区 寄存器基地址、中断号、GPIO引脚、时钟源 描述设备占用的硬件资源
操作区 函数指针表(ops) 定义设备支持的操作接口

下面是一个典型的UART设备结构体定义。你可以把它当成一个模板,以后写其他设备驱动时,照着这个思路来就行。

/* uart_device.h */
#ifndef __UART_DEVICE_H__
#define __UART_DEVICE_H__

#include <stdint.h>

/* 前向声明 */
struct uart_device;

/* 操作函数指针表 */
struct uart_ops {
    int32_t (*init)(struct uart_device *dev);
    int32_t (*send)(struct uart_device *dev, const uint8_t *data, uint32_t len);
    int32_t (*recv)(struct uart_device *dev, uint8_t *buffer, uint32_t len);
    int32_t (*ioctl)(struct uart_device *dev, uint32_t cmd, void *arg);
};

/* UART设备结构体 */
struct uart_device {
    /* 标识区 */
    char        name[16];       /* 设备名称,如 "uart1" */
    uint32_t    dev_id;         /* 设备ID,用于索引 */

    /* 资源区 */
    uint32_t    base_addr;      /* 寄存器基地址 */
    uint32_t    irq_num;        /* 中断号 */
    uint32_t    baud_rate;      /* 波特率 */

    /* 操作区 */
    const struct uart_ops *ops; /* 指向操作函数指针表 */

    /* 私有数据(可选) */
    void *private_data;         /* 指向驱动内部状态 */
};

#endif /* __UART_DEVICE_H__ */

注意看,我把操作函数指针表定义成了另一个结构体 struct uart_ops,然后在设备结构体里只放了一个指向它的指针。这样做的好处是:同一个设备的不同实例,可以共享同一套操作函数,节省内存。而且,如果以后要扩展操作接口,只需要修改 uart_ops 结构体,设备结构体本身不用动。

我的小技巧: 操作函数指针表通常定义为 const 类型,放在只读数据段(如Flash),这样既能防止意外修改,又能节省RAM。我在一个资源紧张的MCU项目里,靠这个技巧省出了几百字节的RAM。

3.3 操作函数指针表:让设备“动”起来

结构体定义好了,接下来就是怎么用。操作函数指针表,说白了就是一组函数指针的集合。每个指针指向一个具体的驱动函数。

我们来看一个具体的实现例子。假设我们有一个基于STM32的UART驱动,它的操作函数表可以这样定义:

/* uart_stm32.c */
#include "uart_device.h"

/* 具体的驱动函数实现 */
static int32_t stm32_uart_init(struct uart_device *dev) {
    /* 这里写STM32 UART的初始化代码 */
    /* 比如:使能时钟、配置GPIO、设置波特率、使能中断 */
    printf("STM32 UART %s init OK, base addr: 0x%08X\n", dev->name, dev->base_addr);
    return 0;
}

static int32_t stm32_uart_send(struct uart_device *dev, const uint8_t *data, uint32_t len) {
    /* 发送数据,轮询或DMA方式 */
    for (uint32_t i = 0; i < len; i++) {
        /* 等待发送寄存器空 */
        while (!(*(volatile uint32_t *)(dev->base_addr + 0x00) & (1 << 7)));
        /* 写入数据寄存器 */
        *(volatile uint32_t *)(dev->base_addr + 0x04) = data[i];
    }
    return len;
}

static int32_t stm32_uart_recv(struct uart_device *dev, uint8_t *buffer, uint32_t len) {
    /* 接收数据,这里简化处理 */
    for (uint32_t i = 0; i < len; i++) {
        /* 等待接收数据有效 */
        while (!(*(volatile uint32_t *)(dev->base_addr + 0x00) & (1 << 5)));
        buffer[i] = *(volatile uint32_t *)(dev->base_addr + 0x04);
    }
    return len;
}

static int32_t stm32_uart_ioctl(struct uart_device *dev, uint32_t cmd, void *arg) {
    /* 控制命令,比如修改波特率、设置校验位等 */
    switch (cmd) {
        case 0x01: /* 设置波特率 */
            dev->baud_rate = *(uint32_t *)arg;
            /* 重新配置硬件 */
            break;
        default:
            break;
    }
    return 0;
}

/* 定义操作函数指针表,注意是const */
const struct uart_ops stm32_uart_ops = {
    .init  = stm32_uart_init,
    .send  = stm32_uart_send,
    .recv  = stm32_uart_recv,
    .ioctl = stm32_uart_ioctl,
};

你看,操作函数表 stm32_uart_ops 是一个全局常量,里面填好了具体的函数地址。接下来,我们只需要把设备结构体和这个操作表关联起来:

/* 定义一个UART设备实例 */
struct uart_device uart1 = {
    .name       = "uart1",
    .dev_id     = 0,
    .base_addr  = 0x40011000,  /* STM32 USART1基地址 */
    .irq_num    = 37,
    .baud_rate  = 115200,
    .ops        = &stm32_uart_ops,  /* 关联操作表 */
    .private_data = NULL,
};

/* 使用设备 */
void app_main(void) {
    uint8_t tx_buf[] = "Hello, Device Model!\r\n";

    /* 通过操作表调用初始化 */
    uart1.ops->init(&uart1);

    /* 通过操作表发送数据 */
    uart1.ops->send(&uart1, tx_buf, sizeof(tx_buf) - 1);
}

看到没?上层应用调用时,根本不知道底层是STM32还是GD32,它只认 ops 这个接口。这就是多态的雏形。

3.4 设备对象模型的核心逻辑图

为了让你更直观地理解整个模型,我画了一张SVG图。这张图展示了设备对象、操作函数表、具体驱动实现三者之间的关系。

设备对象模型核心逻辑 设备对象 (struct uart_device) name: "uart1" base_addr: 0x40011000 irq_num: 37 baud_rate: 115200 ops: 0x08001234 操作函数表 (struct uart_ops) init = stm32_uart_init send = stm32_uart_send recv = stm32_uart_recv ioctl = stm32_uart_ioctl 驱动实现 (stm32) stm32_uart_init() stm32_uart_send() stm32_uart_recv() stm32_uart_ioctl() 指向 调用 核心关系 设备对象通过 ops 指针,找到对应的操作函数表。 操作函数表里存放的是具体驱动函数的地址,实现“调用”关系。

3.5 避坑指南:我踩过的几个坑

模型看起来简单,但实际用起来,有几个地方特别容易翻车。我把自己踩过的坑分享给你,希望能帮你省点调试时间。

坑一:函数指针表没有初始化

我曾经在一个项目里,定义了一个设备结构体,但忘了给 ops 指针赋值。结果一调用 dev->ops->init(),直接跑飞到HardFault。排查了半天,才发现是空指针。所以,建议在设备结构体初始化时,显式检查 ops 是否为 NULL。

坑二:操作函数表被放在了RAM里

有些朋友习惯把所有变量都定义在RAM里,包括操作函数表。但操作函数表一旦被意外修改(比如野指针写坏了),整个驱动就崩了。我建议把操作函数表定义为 const 并放在Flash里,这样既安全又省RAM。

坑三:多实例共享操作表时的数据竞争

如果两个设备实例共享同一个操作函数表,但操作函数里用到了全局变量或静态变量,就会出问题。比如两个UART实例都用同一个发送函数,但发送函数里有一个静态的“当前发送长度”变量,那这两个实例就会互相干扰。解决办法是:把需要保存的状态放到设备结构体的 private_data 里,每个实例一份。

3.6 小结:这个模型到底好在哪?

好了,这一章的内容就这些。我们来捋一捋:

  • 我们用 struct 实现了设备的“封装”,把属性和行为打包在一起。
  • 我们用 const struct uart_ops 实现了“多态”,不同的硬件实现只需要换一套操作函数表。
  • 我们通过 private_data 指针,让每个设备实例可以拥有自己的私有数据,实现了“实例化”。

你想想看,有了这个模型,以后你写一个新的UART驱动,只需要做三件事:

  1. 实现具体的操作函数(init、send、recv、ioctl)。
  2. 定义一个 const 的操作函数表。
  3. 定义一个设备结构体实例,把资源信息和操作表填进去。