4. 驱动层与中间件层分离:如何让驱动代码不依赖具体芯片,中间件层只调用HAL接口
这一章,我们聊一个很实在的问题。
很多项目做到一半,突然要换芯片。这时候你发现,驱动代码和业务逻辑搅在一起,改起来像拆炸弹。我早期就吃过这个亏——一个电机控制项目,从STM32F1换到GD32,本以为只是改改寄存器,结果中间件层也跟着重写,整整折腾了两周。
后来我总结出一条铁律:驱动层和中间件层必须分离。驱动层只跟芯片寄存器打交道,中间件层只调用HAL接口。两者之间,靠一个抽象层隔开。
4.1 为什么要分离?
说白了,就是降低耦合。驱动层负责“怎么操作硬件”,中间件层负责“业务逻辑”。如果这两层混在一起,换芯片就等于重写整个系统。
举个例子:
- 驱动层:配置GPIO、读写SPI、设置定时器。
- 中间件层:协议解析、数据缓存、状态机管理。
你想想看,协议解析跟GPIO的推挽输出有关系吗?没有。那为什么要让它们互相依赖?
核心原则:驱动层只暴露HAL接口,中间件层只调用这些接口。驱动层不知道上层在做什么,中间件层不知道底层是什么芯片。
4.2 抽象层怎么设计?
我习惯用一个结构体指针来做抽象。这个结构体里放的是函数指针,指向具体的HAL实现。中间件层只操作这个结构体,不直接调用任何HAL函数。
来看一个实际例子。假设我们要抽象一个UART驱动:
/* uart_hal.h - 抽象层头文件 */
typedef struct {
void (*init)(uint32_t baudrate);
void (*send)(const uint8_t *data, uint16_t len);
uint16_t (*receive)(uint8_t *buffer, uint16_t max_len);
uint8_t (*is_data_available)(void);
} uart_hal_t;
/* 中间件层通过这个结构体操作UART */
void middleware_process(uart_hal_t *uart) {
uint8_t buf[64];
if (uart->is_data_available()) {
uint16_t len = uart->receive(buf, 64);
// 处理数据...
uart->send(buf, len);
}
}
这样设计的好处很明显:中间件层根本不关心底层是哪个芯片的UART。它只知道调用uart->send(),至于这个函数背后是STM32的HAL库,还是GD32的固件库,中间件层完全不知道。
4.3 驱动层的具体实现
驱动层要做的,就是把芯片相关的HAL函数,塞进这个结构体里。比如在STM32上:
/* stm32_uart_driver.c */
#include "uart_hal.h"
static void stm32_uart_init(uint32_t baudrate) {
// 调用STM32的HAL库
huart1.Instance = USART1;
huart1.Init.BaudRate = baudrate;
HAL_UART_Init(&huart1);
}
static void stm32_uart_send(const uint8_t *data, uint16_t len) {
HAL_UART_Transmit(&huart1, data, len, 1000);
}
/* 注册到抽象层 */
uart_hal_t stm32_uart = {
.init = stm32_uart_init,
.send = stm32_uart_send,
.receive = stm32_uart_receive,
.is_data_available = stm32_uart_is_data_available
};
换到GD32呢?只需要重新实现这四个函数,结构体名字改成gd32_uart。中间件层代码一行都不用改。
我的习惯:每个外设都单独建一个驱动文件,比如stm32_uart.c、gd32_uart.c。编译时通过宏定义选择用哪个。这样移植时只需要添加新文件,不会影响已有代码。
4.4 中间件层的调用方式
中间件层在初始化时,接收一个uart_hal_t指针。之后所有操作都通过这个指针完成。
/* middleware.c */
static uart_hal_t *g_uart;
void middleware_init(uart_hal_t *uart) {
g_uart = uart;
g_uart->init(115200);
}
void middleware_task(void) {
if (g_uart->is_data_available()) {
// 业务逻辑...
}
}
这样做的好处是:中间件层完全不知道底层芯片是谁。它只认这个结构体。你甚至可以写一个模拟的UART驱动,在PC上调试中间件逻辑。
4.5 避坑指南
我曾经踩过一个坑:在抽象层里放了太多功能。比如把DMA、中断、超时配置都塞进结构体。结果每个芯片的实现都不一样,抽象层反而成了负担。
后来我总结了几条经验:
- 抽象层要薄:只放最核心的操作,比如初始化、发送、接收。不要试图抽象所有功能。
- 函数参数要通用:尽量用基本类型,比如
uint8_t*、uint16_t。不要用芯片特有的结构体。 - 错误处理要统一:返回值用标准错误码,比如
0表示成功,-1表示失败。不要用HAL库特有的错误类型。
特别注意:不要在抽象层里做内存分配。驱动层和中间件层的内存管理应该分开。我曾经见过有人在驱动层里malloc,结果换芯片后堆大小不一样,程序直接跑飞。
4.6 整体架构图
下面这张图展示了驱动层、抽象层、中间件层的关系。你可以看到,中间件层只跟抽象层交互,驱动层负责把芯片HAL包装成抽象接口。
4.7 实际项目中的经验
我记得有一个项目,用了这种分层架构后,从STM32F4移植到GD32F4,只花了三天。其中两天在写GD32的驱动层,一天在调试。中间件层代码完全没动。
还有一个项目,客户要求同时支持两种芯片,通过编译宏选择。我们只需要在编译时指定用哪个驱动文件,中间件层代码完全复用。这种灵活性,在项目后期维护时特别爽。
总结一下:驱动层和中间件层分离,核心就是加一个抽象层。这个抽象层用结构体+函数指针实现,中间件层只操作这个结构体。换芯片时,只需要重写驱动层,中间件层代码完全不变。
嗯,这一章就到这里。记住:抽象层要薄,接口要通用,错误处理要统一。做到这三点,你的代码移植起来会轻松很多。