传感器驱动框架设计:从 Linux IIO、RT-Thread 到 ESP32-S3 的统一设备表
嵌入式系统里,每个传感器厂商都会提供一套自己的库函数。两款温湿度传感器,用法可能长这样:
/* AHT30 */
aht30_init_i2c(&handle, I2C_NUM_0, 0x38);
aht30_get_data(&handle, &temp, &humi);
/* 换成 SHT41 后 */
sht41_init(&dev, I2C_PORT_0);
sht41_measure(&dev, SHT41_PRECISION_HIGH, &result);
float temp = result.temperature;这两个传感器做同一件事,调用方式却完全不同。换传感器时,每一处使用了旧库的代码都要跟着改。接入的传感器越多,这个问题越严重。
解决方向只有一个:找到各类传感器的共同点,定义统一的操作接口,把型号差异隔在驱动内部。
分类是统一接口的前提
不同型号的温度传感器,做的事情本质相同:初始化硬件,读回一个温度值。差别只在于寄存器地址不同、I2C 地址不同、换算公式不同。这些都是实现细节,不是接口本身。
把传感器按类型分类(温度、湿度、气压、加速度……),每类定义统一的操作契约:所有这类传感器都要实现同样的 init / read 方法,返回同样形状的数据。上层代码只认类型,不认型号。
这就是传感器驱动框架的核心思路。RT-Thread 和 Linux 都用这个思路,但各自的规模和侧重有所不同。
Linux:按传感器类别接入对应子系统
Linux 根据设备的数据模型和主要使用场景提供不同子系统,同一种物理传感器可能因用途不同进入不同框架:
| 子系统 | 侧重 | 典型设备 |
|---|---|---|
| hwmon | 板级监控 | VRM 温度、电源轨电压、风扇转速 |
| IIO | 工业测量和连续采样 | 气压、湿度、加速度计、ADC |
| input | 用户交互事件 | 触摸屏、按键、某些姿态传感器 |
驱动注册进哪个子系统,决定了它向上层暴露什么接口。以 IIO 为例,驱动只描述通道类型和读原始值的方法,子系统负责生成统一的对外入口:
/* 驱动侧:描述通道 + 提供 read_raw + 注册 */
static const struct iio_chan_spec channels[] = {
{ .type = IIO_TEMP,
.info_mask_separate = BIT(IIO_CHAN_INFO_PROCESSED) },
{ .type = IIO_HUMIDITYRELATIVE,
.info_mask_separate = BIT(IIO_CHAN_INFO_PROCESSED) },
};
static const struct iio_info sensor_info = {
.read_raw = sensor_read_raw,
};
indio_dev->info = &sensor_info;
indio_dev->channels = channels;
indio_dev->num_channels = ARRAY_SIZE(channels);
return devm_iio_device_register(&client->dev, indio_dev);注册完成后,子系统自动开出三条消费路径,适配不同场景:
| 谁来读 | 走哪条路 | 拿到什么 |
|---|---|---|
| 状态页、shell 脚本 | sysfs 属性文件 | 单次当前值 |
| 波形采集、批量记录 | /dev/iio:deviceN 缓冲 |
带时间戳的连续样本流 |
| 内核控制逻辑 | IIO consumer 接口 | 换算后的数值 |
前两条路只读文件,第三条在内核模块里直接拿通道句柄:
/* 内核 consumer:只认通道类型,不认具体芯片 */
struct iio_channel *chan = devm_iio_channel_get(dev, "humidity");
int val;
iio_read_channel_processed(chan, &val);实际工程中,consumer 通常通过设备树或 ACPI 描述 channel 名称,与具体驱动解耦;以上示例只展示 consumer 如何通过统一接口获取数据,不代表字符串可以随意定义。
结果:驱动按类别注册一次,三类消费者各取所需,谁也不需要知道芯片挂在哪条总线上。
RT-Thread:类型枚举与统一设备访问
RT-Thread 不按传感器类别拆分子系统,而是用一套统一的设备模型 + 类型枚举覆盖所有传感器。
每个驱动填一张操作表并声明自己的类型,然后注册进框架:
/* 驱动侧:填操作表 + 声明类型 + 注册 */
sensor->info.type = RT_SENSOR_CLASS_HUMI; /* 湿度传感器 */
sensor->ops = &my_sensor_ops; /* fetch_data / control */
rt_hw_sensor_register(sensor, "humi", RT_DEVICE_FLAG_RDONLY, RT_NULL);rt_sensor_ops 提供统一的数据采集和控制入口,主要包括 fetch_data、control 等接口,不同版本的实现细节可能略有差异。无论是 I2C 传感器、SPI 传感器还是 UART 传感器,驱动都填这同一张表。
应用侧的调用顺序固定为 find / open / control / read,全程不出现芯片型号:
/* 应用侧:统一打开、配置、读取 */
struct rt_sensor_data data;
rt_device_t dev = rt_device_find("humi_sht41");
rt_device_open(dev, RT_DEVICE_FLAG_RDONLY);
rt_device_control(dev, RT_SENSOR_CTRL_SET_ODR, (void *)1); /* 1 Hz */
if (rt_device_read(dev, 0, &data, 1) == 1) {
rt_kprintf("humi: %d.%d %%\n", data.data.humi / 10, data.data.humi % 10);
}data.data 是一个联合体,成员由 sensor->info.type 决定:温度传感器访问 data.data.temp,湿度传感器访问 data.data.humi,加速度传感器访问 data.data.acce.x/y/z。类型不同,接口形状统一。
在接口稳定的前提下,上层业务代码无需感知具体芯片型号的变化。
两个框架共有的四层
把 RT-Thread 和 Linux 缩小,骨架是一样的:
硬件驱动 → 统一接口 → 设备表 → 采样调度 → 统一样本 → 消费者每一层挡住一种变化:
| 层 | 负责什么 | 挡住哪种变化 |
|---|---|---|
| 统一接口 | init / read / control 操作约定 | 芯片型号和总线协议换了 |
| 设备表 | 系统当前有哪些传感器 | 传感器数量变了 |
| 采样调度 | 何时读、按什么顺序读 | 采样节奏变了 |
| 统一样本 | 数值、单位、有效状态、时间 | 上层用途变了 |
前三层管的是"怎么读",第四层管的是"怎么传"。RT-Thread 用 rt_sensor_data,IIO 用带 scale 的通道值,落到一个最小实现上,一条样本需要携带的信息就这几项:
typedef struct {
float value; /* 已换算成物理量的数值 */
uint8_t valid; /* 本次采样是否可信 */
int64_t timestamp_ms; /* 采样时刻 */
} sensor_sample_t;valid 和 timestamp_ms 容易被省掉,但它们决定上层能不能正确处理数据:没有 valid,读失败和读到 0 无法区分;没有时间戳,曲线和超时判断都无从下手。
少一层就会漏一种耦合:没有统一接口,换型号就要改上层;没有设备表,调度代码里会写死传感器名单;没有统一样本,每个消费者都要自己解析各传感器的格式。
结果:框架的价值来自这四层职责固定,不来自接口数量多。
ESP32-S3:三种总线接入同一张驱动表
ESP32-S3 上不需要完整的 RTOS 设备体系,只需要实现这四层的最小版本。
以同时接入三种总线的传感器为例:Modbus RTU、UART 私有协议、I2C。统一接口只有三个成员,read 直接填上一节那个 sensor_sample_t:
typedef struct sensor_device {
const char *name;
esp_err_t (*init)(struct sensor_device *dev);
esp_err_t (*read)(struct sensor_device *dev,
sensor_sample_t *sample);
} sensor_device_t;三个驱动各自导出一个实例,Modbus 请求帧、UART 帧解析、I2C 时序都关在各自的 .c 文件里。驱动表是一个数组,加传感器只加一行:
传感器A驱动 ─ Modbus RTU ─┐
传感器B驱动 ─ UART ───────┼─→ 驱动表 ─→ 采样任务
传感器C驱动 ─ I2C ────────┘static sensor_device_t *g_sensors[] = {
&sensor_a_device,
&sensor_b_device,
&sensor_c_device,
};采样任务只遍历这张表,不判断当前设备用哪种总线:
/* 顺序初始化 */
for (size_t i = 0; i < SENSOR_COUNT; i++)
g_sensors[i]->init(g_sensors[i]);
/* 顺序轮询 + 聚合 */
sensor_sample_t samples[SENSOR_COUNT];
while (1) {
for (size_t i = 0; i < SENSOR_COUNT; i++)
g_sensors[i]->read(g_sensors[i], &samples[i]);
assemble_reading(samples, &reading); /* 各驱动样本 → 统一环境读数 */
sensor_queue_push(&reading);
vTaskDelay(pdMS_TO_TICKS(POLL_MS));
}结果:新增传感器写一个驱动文件并在驱动表加一行,轮询任务不改。
统一数据的消费者与驱动边界
聚合层输出统一的环境读数,下游有多个消费者,都只认这一种结构:
统一环境读数 ─┬─→ 数据队列 (取连续样本)
├─→ 最新值缓存 (取当前状态)
└─→ 历史环形缓冲(取时间段曲线)Web 页面从这三者里选需要的那一份。它不知道哪个传感器走 Modbus,也不知道哪个挂在 I2C 上。
这条边界就是框架和业务的分界:驱动负责把数据从硬件读出来,上层负责决定数据拿来做什么。
结果:加一个新消费者、换一种存储策略,都不会回头修改传感器驱动。