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的驱动实现。到时候我会分享一些调试时的「血泪史」,敬请期待。