4. 驱动框架搭建:分层设计思想、HAL层抽象、设备注册机制、回调函数设计
好,咱们进入第四章。说实话,驱动框架搭建这部分,是很多初学者容易忽略的。大家总觉得「能跑就行」,但我在项目里吃过不少亏——代码写成一锅粥,换个传感器就得重写一遍。后来我学乖了,老老实实搭框架。
这一章,咱们就聊聊怎么把热电堆传感器的驱动写得既清晰又通用。核心就四个词:分层、抽象、注册、回调。
4.1 为什么要分层?
你想想看,一个红外体温计,硬件上可能有不同的MCU、不同的ADC、不同的通信接口(I2C或SPI)。如果驱动代码跟硬件绑死,换个平台就得重写。这谁受得了?
我个人的习惯是:把驱动拆成三层。
- 应用层:负责业务逻辑,比如温度计算、报警判断。
- 驱动层:负责传感器协议解析,比如读寄存器、写配置。
- 硬件抽象层(HAL):负责跟MCU打交道,比如I2C读写、GPIO控制。
这样做的好处很明显:
- 换MCU?只改HAL层。
- 换传感器?只改驱动层。
- 改业务逻辑?只改应用层。
核心原则:上层不直接调用下层硬件函数,而是通过接口调用。说白了,就是「依赖倒置」——依赖接口,不依赖实现。
4.2 HAL层抽象:让驱动「跑」在任何MCU上
HAL层,说白了就是给驱动层提供一套「标准工具」。比如读I2C、写I2C、延时、获取时间戳。这些函数在不同MCU上实现不同,但接口可以统一。
我在项目中遇到过一个问题:某款MCU的I2C读写函数返回0表示成功,另一款返回1表示成功。驱动层如果直接调用,代码就乱了。所以我在HAL层做了个封装:
/* hal_thermopile.h */
typedef struct {
int32_t (*i2c_read)(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, uint16_t len);
int32_t (*i2c_write)(uint8_t dev_addr, uint8_t reg_addr, const uint8_t *data, uint16_t len);
void (*delay_ms)(uint32_t ms);
uint32_t (*get_tick)(void);
} hal_thermopile_t;
你看,这里定义了一个结构体,里面全是函数指针。驱动层只需要调用这些指针,不用关心底层是STM32还是GD32。
小技巧:HAL层的函数命名,我习惯加个前缀,比如 hal_,这样一眼就能看出是硬件抽象层的东西。别小看命名规范,代码写多了你就知道它的重要性了。
4.3 设备注册机制:让驱动「认识」硬件
驱动写好了,怎么跟具体的硬件实例绑定?这就用到设备注册机制了。
我见过很多新手直接把硬件配置写死在驱动里。比如:
/* 错误示范 */
#define I2C_ADDR 0x44
#define I2C_PORT 1
这样写,换个地址就得改代码,太不灵活了。正确的做法是:把设备信息作为参数传进来。
我设计了一个设备注册函数:
/* thermopile_drv.h */
typedef struct {
hal_thermopile_t *hal; /* HAL接口 */
uint8_t dev_addr; /* 设备地址 */
uint32_t timeout_ms; /* 超时时间 */
} thermopile_dev_t;
int32_t thermopile_register(thermopile_dev_t *dev);
调用方只需要填充这个结构体,然后调用注册函数。驱动内部会保存这个设备句柄,后续所有操作都基于这个句柄。
注意:注册函数里通常会做一次「设备探测」——比如读一下芯片ID寄存器。如果读不到,就返回错误。这样能尽早发现硬件问题,而不是等到运行时才报错。
4.4 回调函数设计:让驱动「通知」上层
回调函数,说白了就是「你告诉我怎么做,我做完通知你」。这在传感器驱动里非常实用。
举个例子:热电堆传感器测量需要时间,比如一次转换要100ms。如果驱动层傻等,CPU就浪费了。更好的做法是:启动测量后,设置一个回调,等测量完成再通知上层。
我习惯这样设计:
/* thermopile_drv.h */
typedef void (*thermopile_callback_t)(int32_t status, float temperature, void *user_data);
int32_t thermopile_start_measurement(thermopile_dev_t *dev,
thermopile_callback_t cb,
void *user_data);
调用方传入一个回调函数,驱动层在测量完成后调用它。这样上层可以继续做其他事,不用干等。
避坑指南:我曾经在回调函数里直接做了耗时操作,比如打印日志、写Flash。结果导致中断响应变慢,系统卡死。记住:回调函数里不要做耗时操作,尽量只做状态标记或消息投递。
4.5 完整框架示例
说了这么多,咱们看个完整的例子。假设我们要驱动一个MLX90614热电堆传感器:
/* main.c */
#include "hal_thermopile.h"
#include "thermopile_drv.h"
/* 硬件抽象层实现 */
static int32_t my_i2c_read(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, uint16_t len) {
/* 调用MCU的I2C库 */
return HAL_I2C_Mem_Read(&hi2c1, dev_addr, reg_addr, 1, data, len, 100);
}
static int32_t my_i2c_write(uint8_t dev_addr, uint8_t reg_addr, const uint8_t *data, uint16_t len) {
return HAL_I2C_Mem_Write(&hi2c1, dev_addr, reg_addr, 1, (uint8_t *)data, len, 100);
}
static void my_delay_ms(uint32_t ms) {
HAL_Delay(ms);
}
static uint32_t my_get_tick(void) {
return HAL_GetTick();
}
/* 回调函数 */
void temp_ready_cb(int32_t status, float temperature, void *user_data) {
if (status == 0) {
printf("温度: %.2f°C\n", temperature);
} else {
printf("测量失败\n");
}
}
int main(void) {
hal_thermopile_t hal = {
.i2c_read = my_i2c_read,
.i2c_write = my_i2c_write,
.delay_ms = my_delay_ms,
.get_tick = my_get_tick
};
thermopile_dev_t dev = {
.hal = &hal,
.dev_addr = 0x5A,
.timeout_ms = 500
};
/* 注册设备 */
if (thermopile_register(&dev) != 0) {
printf("设备注册失败\n");
return -1;
}
/* 启动测量,异步等待结果 */
thermopile_start_measurement(&dev, temp_ready_cb, NULL);
while(1) {
/* 主循环做其他事 */
}
}
你看,整个框架非常清晰:
- HAL层封装了硬件差异。
- 驱动层通过结构体接收配置。
- 回调函数实现了异步通知。
我的经验:刚开始写驱动时,别追求一步到位。先写一个「能用」的版本,跑通了再优化。比如先不用回调,用轮询方式。等框架稳定了,再改成异步。这样调试起来更轻松。
4.6 小结
这一章咱们聊了驱动框架的四个核心点:
- 分层设计:应用层、驱动层、HAL层各司其职。
- HAL层抽象:用函数指针结构体屏蔽硬件差异。
- 设备注册机制:把设备信息作为参数传入,避免硬编码。
- 回调函数设计:实现异步通知,提高CPU利用率。
嗯,这套框架我用了好几年,从STM32到GD32再到国产MCU,基本没怎么改过。你试试看,绝对能省不少事。
下一章,咱们聊聊具体的传感器通信协议——I2C和SPI的驱动实现。到时候我会分享一些调试时的「血泪史」,敬请期待。