项目实战四:嵌入式传感器驱动框架(抽象层、硬件层、应用层)
好,咱们今天聊点实在的。前面几章我们把模块化编程的理论、多文件编译、接口设计都过了一遍。但说实话,这些东西光看代码是记不住的,你得亲手搭一个框架,踩几个坑,才能真正理解。
这一章,我带你做一个嵌入式传感器驱动框架。说白了,就是一套通用的代码结构,让你以后换传感器、换MCU,都不用从头写驱动。我自己的经验是,框架搭得好,后面能省80%的调试时间。
为什么需要分层?
你想想看,一个传感器驱动,最核心的需求是什么?
- 能读到数据
- 数据是准确的
- 换一个传感器,改动尽量小
- 换一个MCU,改动也尽量小
嗯,这就是分层的意义。我们把驱动拆成三层:
- 硬件层:管I2C、SPI、GPIO这些底层操作
- 抽象层:管传感器的寄存器读写、初始化、数据转换
- 应用层:管业务逻辑,比如温度高了报警、数据上报频率
我在一个智能家居项目里,一开始没分层,所有代码揉在一个文件里。后来客户要换传感器,我改了三天,还改出了bug。从那以后,我再也不敢不分层了。
核心原则:硬件层只做"怎么读写",抽象层只做"传感器是什么",应用层只做"数据怎么用"。三层之间通过接口函数通信,不要跨层调用。
先画个框架图
下面这张图,是我做驱动框架时必画的。你照着这个结构去写代码,基本不会乱。
你看,这个结构很清晰。每一层只跟相邻层打交道,绝不越级。我见过有人直接在应用层里写I2C读写函数,那叫一个酸爽——换MCU的时候,所有代码都得重写。
硬件层:把底层操作封装好
硬件层是最底层的。它负责跟MCU的寄存器打交道。我一般会把它做成一个独立的模块,叫 hal_i2c.c 或者 hal_spi.c。
举个例子,一个I2C读写的接口,我会这样设计:
// hal_i2c.h
#ifndef HAL_I2C_H
#define HAL_I2C_H
#include <stdint.h>
// 初始化I2C外设
void hal_i2c_init(uint32_t freq);
// 写一个字节到从设备
int hal_i2c_write_byte(uint8_t dev_addr, uint8_t reg_addr, uint8_t data);
// 从从设备读一个字节
int hal_i2c_read_byte(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data);
// 从从设备读多个字节
int hal_i2c_read_multi(uint8_t dev_addr, uint8_t reg_addr,
uint8_t *buf, uint16_t len);
#endif
这里有个细节:返回值用 int,0表示成功,负数表示错误。为什么?因为I2C通信可能失败(比如设备没应答),你不能假设每次读写都成功。我在一个项目里吃过这个亏——没检查返回值,结果读到的全是0xFF,还以为是传感器坏了。
我的习惯:硬件层的函数名统一加 hal_ 前缀。这样在代码里一眼就能看出哪些是硬件相关操作,方便后期移植。
抽象层:把传感器逻辑独立出来
抽象层是核心。它不关心I2C怎么实现的,它只关心传感器的寄存器怎么配置、数据怎么转换。
比如一个温湿度传感器SHT30,我会这样设计:
// sensor_drv.h
#ifndef SENSOR_DRV_H
#define SENSOR_DRV_H
#include <stdint.h>
// 传感器数据结构
typedef struct {
float temperature;
float humidity;
uint8_t crc_ok; // 校验是否通过
} sensor_data_t;
// 初始化传感器
int sensor_init(void);
// 读取温湿度
int sensor_read(sensor_data_t *data);
// 获取上次读取的时间戳(毫秒)
uint32_t sensor_get_last_read_time(void);
#endif
你看,抽象层的接口跟硬件完全无关。它只暴露"初始化"、"读取数据"、"获取时间"这些概念。至于底层是用I2C还是SPI,抽象层不关心。
实现的时候,抽象层会调用硬件层的接口:
// sensor_drv.c
#include "sensor_drv.h"
#include "hal_i2c.h"
#define SHT30_ADDR 0x44
#define CMD_MEASURE 0x2C06
int sensor_init(void) {
// 初始化I2C
hal_i2c_init(100000); // 100kHz
// 发送初始化命令
int ret = hal_i2c_write_byte(SHT30_ADDR, 0x00, 0x00);
if (ret != 0) {
return -1; // 初始化失败
}
return 0;
}
int sensor_read(sensor_data_t *data) {
uint8_t buf[6];
// 触发测量
hal_i2c_write_byte(SHT30_ADDR, 0x2C, 0x06);
// 等待测量完成(实际项目中用延时或轮询)
delay_ms(20);
// 读取6字节数据
hal_i2c_read_multi(SHT30_ADDR, 0x00, buf, 6);
// 数据转换(SHT30的原始数据转温湿度)
data->temperature = -45.0 + 175.0 * (buf[0] << 8 | buf[1]) / 65535.0;
data->humidity = 100.0 * (buf[3] << 8 | buf[4]) / 65535.0;
// CRC校验(略)
data->crc_ok = 1;
return 0;
}
注意:抽象层里不要出现任何跟MCU相关的代码。比如 delay_ms() 函数,它应该由应用层提供,或者通过回调函数注入。我曾经在一个项目里直接在抽象层调了 HAL_Delay(),结果换到另一家MCU时,所有延时都得改,烦死了。
应用层:只管业务逻辑
应用层是最上层的。它只调用抽象层的接口,不关心传感器怎么工作的。
// app_sensor.c
#include "sensor_drv.h"
#include "app_sensor.h"
#define TEMP_ALARM_THRESHOLD 50.0f // 温度报警阈值
void app_sensor_task(void) {
sensor_data_t data;
// 初始化传感器
if (sensor_init() != 0) {
// 处理初始化失败
return;
}
while (1) {
// 读取传感器数据
if (sensor_read(&data) == 0) {
// 检查温度是否超限
if (data.temperature > TEMP_ALARM_THRESHOLD) {
app_alarm_trigger(); // 触发报警
}
// 上报数据
app_data_report(data.temperature, data.humidity);
}
// 每5秒采集一次
delay_ms(5000);
}
}
你看,应用层里没有I2C读写,没有寄存器地址,没有数据转换公式。它只关心"温度高了报警"、"每5秒采集一次"这些业务逻辑。
这样做的好处是什么?我举个例子。假设客户说"把报警阈值改成60度",你只需要改应用层的一个宏定义。如果客户说"换一个传感器",你只需要重写抽象层,应用层几乎不用动。
避坑指南:我踩过的几个坑
嗯,这里分享几个我实际项目中遇到的坑,你写代码的时候注意避开。
- 坑一:硬件层不检查返回值。我曾经在I2C读写后直接使用数据,结果设备没应答,读到的全是0xFF,导致温度显示-45度。从那以后,我每个I2C操作都检查返回值。
- 坑二:抽象层里写死延时。不同MCU的延时函数不一样,抽象层里写死
HAL_Delay()会导致移植困难。我现在的做法是:抽象层定义一个delay_ms函数指针,由应用层在初始化时注入。 - 坑三:应用层直接操作寄存器。我见过有人为了"性能优化",在应用层直接调用I2C读写。结果换传感器时,应用层代码改得面目全非。记住:应用层只调用抽象层的接口,别越级。
总结一下
这个传感器驱动框架,说白了就是三个字:分层解耦。硬件层管通信,抽象层管传感器,应用层管业务。每一层只做自己的事,通过接口跟相邻层通信。
我做了这么多年嵌入式,最大的体会就是:代码结构比代码本身更重要。结构好了,后期维护、移植、扩展都轻松。结构乱了,改一个功能可能引发三个bug。
好,这一章就到这里。你把这个框架理解透了,后面做多传感器系统、做驱动库,都会顺手很多。
一句话记住:硬件层管"怎么读写",抽象层管"传感器是什么",应用层管"数据怎么用"。别越级,别混层。
公众号:蓝海资料掘金营,微信deep3321