下面用 C 语言的语境来介绍这种写法。

你说的这种:

Hal_CommcProtocFuncInfoHandle

里面集中放一组函数指针,上层只通过这个句柄调用,而不直接调用具体函数,本质上就是一种 面向接口编程 的写法。

在 C++ 里类似虚函数表 vtable,在 C 里通常用 函数指针结构体 来模拟。


1. 基本思想

假设系统里有多种通信协议:

  • UART 协议
  • SPI 协议
  • CAN 协议
  • TCP 协议
  • Modbus 协议
  • 自定义协议

它们可能都有类似的操作:

init
deinit
send
recv
set_baudrate
get_status

如果上层直接写:

Uart_Init();
Uart_Send(data, len);

那么上层就和 UART 绑定死了。

以后如果换成 CAN,就要改成:

Can_Init();
Can_Send(data, len);

这种耦合比较强。

而面向接口的写法是让上层只知道一套抽象接口:

comm->init();
comm->send(data, len);
comm->recv(buf, len);

至于底层到底是 UART、CAN、TCP,交给具体实现去决定。


2. 一个简单例子

先定义一张函数表。

typedef struct
{
    int (*init)(void);
    int (*deinit)(void);
    int (*send)(const unsigned char *data, unsigned int len);
    int (*recv)(unsigned char *buf, unsigned int len);
    int (*get_status)(void);

} Hal_CommcProtocFuncInfoHandle;

这个结构体里没有真正的数据处理逻辑,只有函数指针。

它描述的是:

一个通信协议层应该具备哪些能力。

也可以理解为“接口定义”。


3. UART 协议实现

比如 UART 的具体实现:

static int Uart_Deinit(void)
{
    // 关闭 UART
    return 0;
}

static int Uart_Send(const unsigned char *data, unsigned int len)
{
    // UART 发送数据
    return len;
}

static int Uart_Recv(unsigned char *buf, unsigned int len)
{
    // UART 接收数据
    return 0;
}

static int Uart_GetStatus(void)
{
    // 获取 UART 状态
    return 0;
}


然后把这些函数装进函数表:

const Hal_CommcProtocFuncInfoHandle g_uartCommcFunc = 
{
    .init       = Uart_Init,
    .deinit     = Uart_Deinit,
    .send       = Uart_Send,
    .recv       = Uart_Recv,
    .get_status = Uart_GetStatus,
};

4. CAN 协议实现

同样,CAN 也可以实现同一套接口:

static int Can_Init(void)
{
    // 初始化 CAN
    return 0;
}

static int Can_Deinit(void)
{
    // 关闭 CAN
    return 0;
}

static int Can_Send(const unsigned char *data, unsigned int len)
{
    // CAN 发送数据
    return len;
}

static int Can_Recv(unsigned char *buf, unsigned int len)
{
    // CAN 接收数据
    return 0;
}

static int Can_GetStatus(void)
{
    return 0;
}

注册到函数表:

const Hal_CommcProtocFuncInfoHandle g_canCommcFunc =
{
    .init       = Can_Init,
    .deinit     = Can_Deinit,
    .send       = Can_Send,
    .recv       = Can_Recv,
    .get_status = Can_GetStatus,
};

5. 上层如何使用

上层只保存一个接口指针:

static const Hal_CommcProtocFuncInfoHandle *g_commcProtocol = NULL;

初始化时选择具体协议:

void App_SetProtocol(const Hal_CommcProtocFuncInfoHandle *handle)
{
    g_commcProtocol = handle;
}

应用层调用:

void App_Run(void)
{
    unsigned char data[] = {0x01, 0x02, 0x03};

    if (g_commcProtocol == NULL)
    {
        return;
    }

    g_commcProtocol->init();

    g_commcProtocol->send(data, sizeof(data));

    g_commcProtocol->deinit();
}

如果使用 UART:

App_SetProtocol(&g_uartCommcFunc);
App_Run();

如果使用 CAN:

App_SetProtocol(&g_canCommcFunc);
App_Run();

上层代码 App_Run() 不需要改。

这就是面向接口的核心价值。

6. 更接近工程的写法:带上下文指针

实际项目中,函数通常不仅需要函数表,还需要“对象实例数据”。

例如 UART1、UART2 都使用同一套 UART 函数,但是硬件资源不同:

  • UART1 的寄存器地址不同
  • UART2 的 DMA 通道不同
  • 缓冲区不同
  • 波特率不同

这时可以加入一个 void *context

typedef struct
{
    int (*init)(void *ctx);
    int (*deinit)(void *ctx);
    int (*send)(void *ctx, const unsigned char *data, unsigned int len);
    int (*recv)(void *ctx, unsigned char *buf, unsigned int len);
    int (*get_status)(void *ctx);

} Hal_CommcProtocFuncInfoHandle;

定义通信对象:

typedef struct
{
    const Hal_CommcProtocFuncInfoHandle *ops;
    void *ctx;

} Hal_CommcProtocHandle;

这个结构体就更像一个“对象”:

typedef struct
{
    int uart_id;
    unsigned int baudrate;
} UartContext;

UART 实现:

static int Uart_Init(void *ctx)
{
    UartContext *uart = (UartContext *)ctx;

    // 根据 uart->uart_id 初始化对应 UART
    // 根据 uart->baudrate 设置波特率

    return 0;
}

static int Uart_Send(void *ctx, const unsigned char *data, unsigned int len)
{
    UartContext *uart = (UartContext *)ctx;

    // 使用 uart->uart_id 发送数据

    return len;
}

函数表:

const Hal_CommcProtocFuncInfoHandle g_uartOps =
{
    .init       = Uart_Init,
    .deinit     = Uart_Deinit,
    .send       = Uart_Send,
    .recv       = Uart_Recv,
    .get_status = Uart_GetStatus,
};

对象实例:

static UartContext g_uart1Ctx =
{
    .uart_id = 1,
    .baudrate = 115200,
};

static Hal_CommcProtocHandle g_uart1 =
{
    .ops = &g_uartOps,
    .ctx = &g_uart1Ctx,
};

上层调用:

g_uart1.ops->init(g_uart1.ctx);
g_uart1.ops->send(g_uart1.ctx, data, len);

这种方式比单纯的全局函数表更灵活。

7. 再封装一层,调用更安全

直接这样写:

g_uart1.ops->send(g_uart1.ctx, data, len);

每次都要检查空指针,有点繁琐。

可以封装成统一 API。

int Hal_Commc_Init(Hal_CommcProtocHandle *handle)
{
    if (handle == NULL || handle->ops == NULL || handle->ops->init == NULL)
    {
        return -1;
    }

    return handle->ops->init(handle->ctx);
}

int Hal_Commc_Send(Hal_CommcProtocHandle *handle,
                   const unsigned char *data,
                   unsigned int len)
{
    if (handle == NULL || handle->ops == NULL || handle->ops->send == NULL)
    {
        return -1;
    }

    return handle->ops->send(handle->ctx, data, len);
}

上层就可以写:

Hal_Commc_Init(&g_uart1);
Hal_Commc_Send(&g_uart1, data, len);

这样上层甚至不用直接访问 ops

8. 这种写法的优点

1. 解耦上层和底层

上层不依赖具体的 UART、CAN、TCP 实现,只依赖抽象接口。

Hal_Commc_Send(...)

底层换协议时,上层基本不用改。


2. 方便替换实现

比如调试时可以使用 mock 实现:

const Hal_CommcProtocFuncInfoHandle g_mockCommcOps =
{
    .init = Mock_Init,
    .send = Mock_Send,
    .recv = Mock_Recv,
};

测试环境使用 mock,真实环境使用 uart/can。

3. 支持多态

同一个调用:

handle->ops->send(handle->ctx, data, len);

如果 handle 是 UART 对象,就调用 UART_Send。

如果 handle 是 CAN 对象,就调用 CAN_Send。

这就是 C 语言里模拟多态。


4. 方便分层

一般嵌入式项目里常见分层:

APP 应用层
  |
Protocol 协议层
  |
HAL 抽象层
  |
Driver 驱动层
  |
Hardware 硬件

函数表一般放在 HAL 或协议抽象层,用来隔离具体驱动。

9. 这种写法的缺点

1. 可读性比直接调用差一点

直接调用:

Uart_Send(data, len);

很清楚。

函数指针调用:

handle->ops->send(handle->ctx, data, len);

需要追踪 ops 到底指向谁。


2. 调试时需要看函数指针实际值

如果配置错了,可能会调用到错误实现。

所以初始化、注册过程要清晰。


3. 空指针风险

如果某个函数指针没有赋值,直接调用会崩溃。

所以建议统一封装调用接口,并做空指针检查。


4. 类型约束不如 C++ 强

C 的函数指针签名必须匹配,否则容易出问题。

建议所有接口函数的参数和返回值设计统一。

10. 一个推荐模板

你可以按这个模式理解:

typedef struct
{
    int (*init)(void *ctx);
    int (*deinit)(void *ctx);
    int (*send)(void *ctx, const uint8_t *data, uint32_t len);
    int (*recv)(void *ctx, uint8_t *buf, uint32_t len);
    int (*ioctl)(void *ctx, uint32_t cmd, void *arg);

} Hal_CommcProtocFuncInfoHandle;

再定义对象句柄:

typedef struct
{
    const Hal_CommcProtocFuncInfoHandle *ops;
    void *ctx;

} Hal_CommcProtocHandle;

然后提供统一 API:

int Hal_CommcProtoc_Init(Hal_CommcProtocHandle *handle);
int Hal_CommcProtoc_Deinit(Hal_CommcProtocHandle *handle);
int Hal_CommcProtoc_Send(Hal_CommcProtocHandle *handle,
                         const uint8_t *data,
                         uint32_t len);
int Hal_CommcProtoc_Recv(Hal_CommcProtocHandle *handle,
                         uint8_t *buf,
                         uint32_t len);
int Hal_CommcProtoc_Ioctl(Hal_CommcProtocHandle *handle,
                          uint32_t cmd,
                          void *arg);

具体协议只需要实现这张表:

const Hal_CommcProtocFuncInfoHandle g_uartProtocolOps =
{
    .init   = UartProtocol_Init,
    .deinit = UartProtocol_Deinit,
    .send   = UartProtocol_Send,
    .recv   = UartProtocol_Recv,
    .ioctl  = UartProtocol_Ioctl,
};

11. 可以怎么理解这个命名

Hal_CommcProtocFuncInfoHandle 这个名字可以拆开看:

Hal       硬件抽象层
Commc     Communication,通信
Protoc    Protocol,协议
FuncInfo  函数信息,也就是函数指针集合
Handle    句柄

所以它大概表示:

通信协议层的一组操作函数句柄。

不过从命名习惯上,我个人更建议叫:

Hal_CommcProtocolOps

或者:

Hal_CommcProtocolVtbl

或者:

Hal_CommcProtocolInterface

例如:

typedef struct
{
    int (*init)(void *ctx);
    int (*send)(void *ctx, const uint8_t *data, uint32_t len);
    int (*recv)(void *ctx, uint8_t *buf, uint32_t len);
} Hal_CommcProtocolOps;

Ops 是很多 C 工程里常见写法,比如 Linux 内核里经常有:

file_operations
net_device_ops
platform_driver

12. 总结一句话

这种写法的核心是:

用一个结构体保存一组函数指针,把“具体实现”隐藏在函数表后面,上层只依赖这张接口表调用,从而实现解耦、可替换和类似面向对象的多态。

最常见的完整结构是:

对象句柄 = 函数表 ops + 上下文 ctx

也就是:

typedef struct
{
    const XxxOps *ops;
    void *ctx;
} XxxHandle;

然后调用:

handle->ops->send(handle->ctx, data, len);

这就是 C 语言里非常典型的接口抽象设计。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐