接口设计原则:高内聚低耦合、面向接口编程、回调函数机制
驱动层封装这件事,说白了就是给硬件穿上一件得体的外衣。穿得太紧,上层代码一换平台就得扒层皮;穿得太松,又漏了一堆硬件细节出去。我这些年折腾过不少MCU平台,从Cortex-M0到M4,从国产RISC-V到老牌PIC,踩过的坑多了,慢慢总结出三条铁律——高内聚低耦合、面向接口编程、回调函数机制。今天咱们就掰开揉碎了聊聊。
2.1 高内聚低耦合:模块的自我修养
先问个问题:你写一个UART驱动,上层应用能不能直接操作UART的寄存器?
很多新手会这么干。图省事嘛,直接在应用层写 UART0->DR = 0x55。嗯,当时是爽了,等你要把代码从STM32移植到GD32,或者从GD32换到AT32,你就知道什么叫「牵一发而动全身」了。
高内聚,就是让一个模块只干一件事,并且把这件事干彻底。UART驱动就管UART的收发配置,别掺和协议解析;I2C驱动就管I2C的时序控制,别管传感器数据怎么换算。
低耦合,就是模块之间只通过定义好的接口通信,不互相窥探内部实现。我举个例子:
// 耦合度高 —— 应用层直接操作寄存器
void app_send_byte(uint8_t data) {
UART0->DR = data; // 硬件相关
while(!(UART0->SR & (1<<6))); // 等待发送完成
}
// 耦合度低 —— 应用层只调用接口
void app_send_byte(uint8_t data) {
uart_send_byte(UART_PORT_0, data);
}
你看,第二种写法里,应用层根本不知道底层用的是哪个寄存器、哪个状态位。将来换芯片,只要把 uart_send_byte 这个函数重新实现一遍,应用层一行代码都不用改。
2.2 面向接口编程:定义契约,而非实现
我个人习惯,在写任何驱动之前,先定义接口。就像盖房子先画图纸一样,接口就是驱动层和应用层之间的「合同」。
面向接口编程的核心思想是:调用方只依赖抽象接口,不依赖具体实现。在C语言里,我们通常用结构体函数指针来实现这种抽象。
来看一个典型的驱动接口定义:
/* uart_interface.h —— 只定义接口,不涉及任何硬件 */
typedef struct {
int32_t (*init)(void *config);
int32_t (*send)(uint8_t *data, uint16_t len);
int32_t (*receive)(uint8_t *data, uint16_t len);
int32_t (*ioctl)(uint32_t cmd, void *param);
} uart_ops_t;
typedef struct {
uint32_t port;
uint32_t baudrate;
uint8_t data_bits;
uint8_t stop_bits;
uart_ops_t ops; /* 函数指针表 */
} uart_device_t;
这样做的好处是什么?
- 可替换性:同一套接口,可以挂载不同芯片的UART驱动实现
- 可测试性:可以写一个模拟的UART驱动,在PC上跑单元测试
- 可扩展性:新增一个UART外设,只需要实现接口里的四个函数
我曾经在一个项目里同时用了三款不同厂商的MCU做产品线覆盖。就是因为提前定义好了这套接口,应用层代码完全复用,底层驱动各写各的。项目周期缩短了将近40%。
2.3 回调函数机制:把控制权交还给上层
回调函数,说白了就是「你告诉我该干什么,到时候我喊你」。在驱动层里,回调机制主要用于两种情况:
- 异步事件通知:比如UART收到一帧数据,驱动层不知道上层要怎么处理,那就调用一个注册好的回调函数,让上层自己决定
- 底层依赖的上层操作:比如SPI驱动在发送数据前需要拉高CS引脚,但这个引脚可能接在GPIO的任意端口上,驱动层不知道具体是哪个引脚,那就通过回调让上层来操作
看一个实际例子:
/* 驱动层定义回调类型 */
typedef void (*uart_rx_callback_t)(uint8_t data);
/* 驱动层提供注册接口 */
void uart_register_rx_callback(uart_rx_callback_t cb);
/* 驱动层内部 —— 收到数据时调用回调 */
void uart_isr_handler(void) {
uint8_t rx_byte = UART0->DR;
if (rx_callback) {
rx_callback(rx_byte); /* 把数据交给上层处理 */
}
}
/* 应用层 —— 注册自己的处理函数 */
void my_uart_handler(uint8_t data) {
/* 这里可以放协议解析、数据缓存等逻辑 */
buffer_push(&rx_buffer, data);
}
void main(void) {
uart_register_rx_callback(my_uart_handler);
/* 启动后,驱动层收到数据会自动调用 my_uart_handler */
}
这里有个坑,我当年踩过。回调函数是在中断上下文里被调用的,所以回调函数里绝对不能做耗时操作,比如打印日志、动态内存分配、延时等待。正确的做法是:回调里只做最轻量级的数据搬运,比如把数据塞进一个环形缓冲区,真正的处理放到主循环或者任务里去做。
2.4 三条原则的关系与落地
这三条原则不是孤立的,它们相辅相成:
| 原则 | 解决的问题 | 实现手段 | 典型场景 |
|---|---|---|---|
| 高内聚低耦合 | 模块职责不清、牵一发动全身 | 模块只暴露必要接口,隐藏内部实现 | 驱动层与硬件寄存器隔离 |
| 面向接口编程 | 实现与抽象耦合、难以替换 | 结构体函数指针、抽象接口层 | 多平台驱动统一调用方式 |
| 回调函数机制 | 驱动层无法预知上层处理逻辑 | 函数指针注册、中断/事件中调用 | 异步数据接收、底层依赖的上层操作 |
在实际项目中,我通常这样组织代码:
- 接口层(.h文件):定义抽象接口、回调类型、数据结构
- 驱动实现层(.c文件):实现接口函数,操作具体寄存器
- 应用层(其他模块):只通过接口层头文件调用驱动,不包含任何硬件头文件
这样分层之后,移植工作变得非常清晰。换芯片时,只需要重写驱动实现层的.c文件,接口层和应用层完全不动。