【编程思路】面向接口编程的写法
文章目录
下面用 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 语言里非常典型的接口抽象设计。
更多推荐



所有评论(0)