RT-Thread 传感器驱动分层实战:换一颗传感器要改 11 个文件、加第二路又要重来——我把四层职责和三条红线画成了一张表
手上这台环境采集终端,硬件不复杂:STM32F407 + RT-Thread 4.0.x,I2C1 上挂三颗传感器——SHT30 测温湿度、BMP280 测气压、LIS3DH 测三轴加速度。软件是两年前从 FreeRTOS 项目搬过来的,当时图快,一个 .c 文件从寄存器读到最后上报全干完。跑了一年多没出过事,所以我一直觉得它挺好的。
然后同一个下午,三件事一起来了。
一、三个问题同时爆的那个下午
第一件,换型。 SHT30 停产了,仓库里只剩 SHT31。同族芯片、寄存器基本兼容,我原以为改个地址就完事。结果应用层连着改了 11 个文件——因为温度在业务代码里被硬编成了各种形态:temp_raw、temp_x10、temp_c、tempe 四个变量名轮着用,有的地方是 0.1℃、有的地方是 0.01℃、有的地方直接是芯片码;湿度更乱,一处 %、一处 ‰。我改完 SHT31,回头一看,BMP280 那条路径的”温度”又是另一套写法。
第二件,加第二路。 客户要”多点测温”:除了板上那颗,再接一路外部温度探头。我翻了半天才发现,业务代码里是 get_room_temp() 这种全局单例写法,底下直接读固定设备。要加第二路,等于把所有调用点都改成”带参数”的版本——又是一轮大改。
第三件,最邪门。 气压读数和温度读数偶发互换。注意不是错,是”互换”:屏幕上气压显示成 20 点多(那是温度),温度显示成 1000 多(那是气压)。连续跑 72 小时,异常帧占 0.37%,大概每 16 分钟出现一次,复位就好,怎么看都像”随机”。
三件事看起来互不相干,但我在改到第三天的时候意识到:它们的根子是同一个——我的代码里没有”层”。
驱动层里塞着业务判断,应用层里塞着寄存器细节,单位散落在每一层,锁各加各的。芯片一换、路数一变、并发一上来,整个结构就散了。
那天晚上我做的第一件事不是写代码,是把 RT-Thread 的 rt_sensor 框架从头读了一遍,然后把四层的边界重新画了一遍。这篇文章就是那次重构的记录。
二、先把 rt_sensor 放回它该在的那一层
先说清楚一句话:rt_sensor 不是”给传感器用的驱动”,它是”传感器这一类设备的上层抽象”。 它本身不碰任何寄存器,它管的是”应用怎么统一地问传感器要数据”。
2.1 四层结构,从下往上
┌──────────────────────────────────────────────────────────┐
│ 应用层 业务逻辑 / 工程单位 / 滤波 / 标定 / 上报 │
├──────────────────────────────────────────────────────────┤
│ rt_sensor 统一接口 open/read/control、统一单位、模式、 │
│ 框架层 时间戳、module 互斥 │
├──────────────────────────────────────────────────────────┤
│ 传感器 寄存器 <-> 框架单位;量程/ODR/电源模式落寄存器 │
│ 驱动层 ops->fetch_data / ops->control │
├──────────────────────────────────────────────────────────┤
│ 总线/设备 I2C/SPI 字节收发、片选、时序 │
│ 层 rt_i2c_bus_device / rt_spi_device │
└──────────────────────────────────────────────────────────┘
| 层 | 干的事 | 关键对象 |
|---|---|---|
| 应用层 | 业务逻辑、单位换算到工程单位、滤波、标定、上报 | 你的 task / 你自己的模块 |
| rt_sensor 框架层 | 统一访问接口、统一单位口径、模式与时间戳、module 互斥 | rt_sensor_device / rt_sensor_data |
| 传感器驱动层 | 寄存器 ↔ 框架单位;量程/ODR/电源模式落寄存器 | rt_sensor_ops 的 fetch_data / control |
| 总线/设备层 | I2C/SPI 字节收发、片选、时序 | rt_i2c_bus_device / rt_spi_device |
自下而上的依赖是单向的:总线层不知道上面挂的是谁;驱动层不知道业务为什么要这个数;框架层不知道芯片型号;应用层不知道寄存器地址。 任何一层越过自己的边界去干上一层的活,重构的账就会在半年后一次性找上门。
2.2 这篇和《RT-Thread 设备框架与驱动移植》那篇的边界
站内 10-01 那篇《RT-Thread 设备框架与驱动移植实战》讲的是最下面那一层半:rt_device 的统一套路、SPI 的”总线一层、CS 一层”、send_then_recv 的选型、互斥归属、rx_indicate 的用法。那篇的结论是”把总线设备层补成 rt_device“。
这篇是它上面那一层:当你的总线设备已经是 rt_device 了,传感器这一类的”要不要再抽象一层”的问题。答案是要,因为传感器有个总线设备没有的特性——“单位”。
一个 SPI Flash 你给它地址它就给你字节,字节的含义是唯一的。但一颗温度传感器,芯片手册给你的是”65535 对应 130℃”,框架给你的是”0.1℃ 的整数”,业务要的是”23.7℃ 的字符串”。同一个物理量在链路里换了三次衣服,没人负责记账,就一定会在某个环节穿错。
rt_sensor 这一层存在的全部理由,就是把”单位”这件事固定在一个地方。
2.3 框架到底替我们做了什么
三个东西,一个比一个值钱:
- 统一的访问接口:不管底下是 I2C 还是 SPI、是温度还是加速度,应用层一律
rt_device_open/rt_device_read/rt_device_control。换芯片,应用层不认识新名字也照跑。 - 统一的单位口径:框架给每一类传感器定死了单位(见第四节那张表)。驱动层负责把芯片的原始码换算到框架单位,这是它唯一的”翻译”职责。
- 模式与互斥:轮询/中断/FIFO 三种取数模式由框架统一调度;同一颗芯片的多个功能由框架的
module机制统一加锁。这两件事以前是我在驱动里手写的,写了两遍、错了三处。
关键结构体长这样(以你手上的 sensor.h 为准,不同版本有 v1/v2 两套实现,字段可能略有差异):
/* 框架定义的驱动侧回调,就两个函数指针 */
struct rt_sensor_ops
{
rt_size_t (*fetch_data)(struct rt_sensor_device *sensor, void *buf, rt_size_t len);
rt_err_t (*control) (struct rt_sensor_device *sensor, int cmd, void *arg);
};
/* 设备控制块:继承标准 rt_device,再挂上描述/配置/回调 */
struct rt_sensor_device
{
struct rt_device parent; /* 所以能用 rt_device_read/control */
struct rt_sensor_info info; /* vendor/model/unit/range/period_min */
struct rt_sensor_config config; /* intf/range/odr/power/mode */
struct rt_sensor_ops *ops; /* 你的驱动填这里 */
struct rt_sensor_module *module; /* 多功能的互斥归属 */
};
注册就一个函数,注意它的设备名是”类别前缀 + 你给的名字”:
int rt_hw_sensor_register(rt_sensor_t sensor, const char *name,
rt_uint32_t flag, void *data);
/* 你若传 name = "sht3x",实际注册出来的设备名可能是 "temp_sht3x" / "humi_sht3x" */
这一条后面第七节会用到——第二路传感器的重名冲突就出在这里。
三、四层职责表(本篇第一件交付物)
重构的第一天,我把这十年在这类项目里踩过的坑逐条归位,做成了一张表。这张表贴在我工位显示器边上,改代码前先看一眼。
| 这件事 | 该在哪一层 | 为什么 |
|---|---|---|
| 读/写 I2C/SPI 字节、片选、时序 | 总线/设备层 | 它不该知道传感器是谁 |
| 寄存器原始码 → 框架单位 | 驱动层 fetch_data |
驱动唯一的翻译职责 |
| 量程 / ODR / 电源模式落寄存器 | 驱动层 control |
只有它知道寄存器怎么改 |
| 判定 POLLING / INT / FIFO 取哪个缓冲 | 驱动层 | 由 open 时的 flag 决定 |
| 打时间戳 | 框架层 rt_sensor_get_ts() |
驱动不要自己造时基 |
| 同一芯片多功能的互斥 | 框架层 module 锁 |
驱动不要各加一把锁 |
| 框架单位 → 工程单位 | 应用层 | 驱动不替上层决定精度 |
| 零点 / 增益 / 温度补偿标定 | 应用层 | 标定随安装走,不随芯片走 |
| 滑动滤波 / IIR / 中值 | 应用层 | 驱动做滤波会掩盖故障 |
| 超量程 / 故障判定与上报 | 应用层 | 驱动只返回原始事实 |
表不长,但压着三条红线,是我这次重构真正的收获:
红线一:单位只向上细化,不向下回退。 驱动层出口必须是框架单位。驱动层”顺手”给出更细的 0.01℃,上层就会误以为框架单位就是 0.01℃,一换芯片差 10 倍。
红线二:标定只准放应用层。 驱动层是 datasheet 级的东西,不含安装、结构、批次。标定系数换一块板就要变,它不属于驱动。
红线三:一次”写地址 + 读数据”必须是一次复合事务;同一芯片的多功能必须归入同一个 module。 第三条是那 0.37% 异常帧换来的,第六节细讲。
四、第一个试金石:单位的三层链
判断一个驱动写得”分层”还是”糊成一团”,不用看结构体,看单位就够了。
4.1 框架单位表
rt_sensor 给每一类传感器定死了单位。这张表要背下来,因为驱动层出口必须落在这里:
| 类别 | 框架单位 | 说明 |
|---|---|---|
加速度 ACCE |
mg | 1 g = 1000 mg |
陀螺仪 GYRO |
mdps | 1 dps = 1000 mdps |
磁力计 MAG |
mGauss | 1 G = 1000 mGauss |
温度 TEMP |
0.1 ℃ | 框架侧是 0.1 摄氏度的整数 |
湿度 HUMI |
‰ | 相对湿度千分比 |
气压 BARO |
Pa | 100 Pa = 1 hPa |
光照 LIGHT |
lux | 光照度 |
接近 PROXIMITY |
cm | 到物体距离 |
| TVOC / eCO2 | ppb / ppm | 空气质量 |
测距 TOF |
mm | 飞行时间 |
顺手说一句:这张表本身就是”分层”的产物。 如果框架说”温度单位你随便,我看不懂”,那就等于把换算责任推给了每个应用;正因为框架把口径钉死了,驱动和应用才可能各自独立演进。
4.2 谁负责往细里走
三层链是这么走的。以温度为例:
① 芯片原始码 24500(满量程 65535,区间 -45 ~ 130 ℃)
│ 驱动层:T = -45 + 175 * S / 65535
▼
② 框架单位:20.4 ℃ → 存成整数 204(0.1 ℃)
│ 应用层:×10(想更细就再乘)
▼
③ 工程单位:2042(0.01 ℃)→ 格式化后是 "20.42"
谁往细里走,谁负责精度。 驱动层只承诺到 0.1℃;应用层要 0.01℃,那精度就是应用层自己”借”来的,它得清楚这一点。反过来的写法——驱动直接吐 0.01℃——短期看起来更方便,但它偷偷改掉了框架的口径,等哪天换一颗本身就是 0.01℃ 分辨率的芯片,应用层会莫名其妙多算 10 倍。
湿度同理。SHT3x 的公式是 RH = 100 * S_RH / 65535(单位 %),框架单位是 ‰,所以驱动层要乘 10。这条换算只准出现一次,就在驱动的 fetch_data 里。
4.3 定点输出:站内的老规矩
我们这个项目有一条硬约束:业务路径不许用 %f,不许 malloc。理由以前几篇里写过——newlib-nano 的 %f 内部会走堆分配,而且某些 Release 配置下 snprintf("%f") 编译零告警、运行时空输出。
好处是:rt_sensor_data 本来就是整数,框架单位这条路天然和这条约束是合得来的。真正的坑只剩一处——应用层要把 0.1℃ 显示成 “23.7” 那一步。写法很简单,纯整数:
/* 0.1 为单位的整数摄氏度 -> "23.7" 这样的字符串。零 malloc、无 %f */
static void fmt_temp_0p1(char *out, size_t n, int t_x10)
{
int neg = (t_x10 < 0);
int v = neg ? -t_x10 : t_x10;
rt_snprintf(out, n, "%s%d.%d", neg ? "-" : "", v / 10, v % 10);
}
想再细一位(0.01℃),就是把 v % 100 / 10 和 v % 10 拆成两段。仍然是整数运算。
注意这里的 rt_snprintf 传的是 %d,不是 %f。 用一个整数除法和取余代替浮点格式化,是这套约束下最省心的做法。
五、mode / ODR / 数据就绪:中断该在哪一层
传感器和普通设备最大的不同,是它”有自己的节奏”。
5.1 三种模式
rt_sensor 支持三种取数模式,对应 open 时给的 flag:
| 模式 | open flag | 谁触发数据 | 适用 |
|---|---|---|---|
轮询 POLLING |
RT_DEVICE_FLAG_RDONLY |
应用 read 时才读 | 低频、低速传感器 |
中断 INT |
RT_DEVICE_FLAG_INT_RX |
数据就绪脚触发 | 中高频、要低延迟 |
| FIFO | RT_DEVICE_FLAG_FIFO_RX |
攒够水印后一起取 | 高频、要降功耗 |
驱动里的 fetch_data 第一件事就是按 open 时的 flag 判断”这次数据从哪个缓冲取”:
static rt_size_t sht3x_fetch_data(struct rt_sensor_device *sensor, void *buf, rt_size_t len)
{
struct sht3x_dev *dev = sensor->parent.user_data;
struct rt_sensor_data *d = buf;
if (sensor->parent.open_flag & RT_DEVICE_FLAG_RDONLY)
{
return sht3x_polling_get(sensor, buf, len); /* 主动发起一次转换并读 */
}
else if (sensor->parent.open_flag & RT_DEVICE_FLAG_INT_RX)
{
return sht3x_int_get(sensor, buf, len); /* 从就绪缓冲里搬 */
}
return 0;
}
返回值是实际填了几条数据,不是”成功/失败”。这一点很容易写错:返回 RT_EOK(0) 会让框架以为”一条都没读到”,上层就永远收不到数据。
5.2 中断里别读 I2C
这是我在 FreeRTOS 时代留下的习惯性错误:数据就绪中断里直接去读传感器。
I2C 读是阻塞的(等 ACK、等时钟拉伸),在中断上下文里干这件事,轻则拖长中断,重则触发断言。正确做法是老一套:
/* 中断上下文:只投递信号量,不做任何总线操作 */
static void sht3x_drdy_isr(void *args)
{
struct sht3x_dev *dev = args;
rt_sem_release(&dev->drdy_sem); /* 或者直接 rt_mb_send 一条"该读了" */
}
真正的读放在线程里。rt_sensor 的 INT 模式已经把”就绪 → 唤醒读线程”这一步收进框架了,驱动要做的只是在 fetch_data 里从缓冲搬数据——别在这里再发起一次总线读,那等于把中断延迟又拉回来了。
5.3 ODR 与量程切换的原子性
ODR(输出数据速率)和量程是通过 control 命令改的:
static rt_err_t sht3x_control(struct rt_sensor_device *sensor, int cmd, void *arg)
{
switch (cmd)
{
case RT_SENSOR_CTRL_SET_ODR:
/* arg 是目标 ODR;落寄存器,并让下次转换按新速率来 */
return sht3x_set_odr(sensor, (rt_uint32_t)(rt_ubase_t)arg);
case RT_SENSOR_CTRL_SET_RANGE:
return sht3x_set_range(sensor, (rt_int32_t)(rt_ubase_t)arg);
}
return -RT_ERROR;
}
框架会在 control 返回成功之后,把 sensor->config.odr / config.range 更新掉。这里有两个坑:
- 重配置必须原子。 如果改 ODR 需要”停转换 → 写寄存器 → 重启 FIFO”三步,而这三步中间被另一路读取插进来,就会读到半配置状态(表现为”改完 ODR 第一次读出来的数据是错的”)。这一段的互斥,交给
module锁(下一节)。 - 别在驱动里”记忆”业务意图。 驱动只执行”设成多少”,不判断”业务该不该设这么多”。业务问
RT_SENSOR_CTRL_GET_INFO拿period_min,自己算上限。
5.4 FIFO 水印与溢出的账
FIFO 模式看着省事,但水印设错就会丢帧,而且丢得很难看——不是”读不出来”,是”读出来了但不连续”,看起来像信号有毛刺。
账很好算:FIFO 填满的时间 = 级数 ÷ ODR。三轴 16 位、一帧 6 字节,ODR = 100 Hz,32 级 FIFO:
一帧数据量 6 字节
采样间隔 10 ms
填满 32 级 320 ms
读一次总线 约 1.5 ms(8 字节复合事务实测 190 µs,加上处理开销)
水印建议 21 级(留 1/3 余量)→ 210 ms 内必须读走
结论一句话:溢出时间远大于读取耗时,这类丢帧就不是 FIFO 的锅。 反过来,如果 ODR 拉到 400 Hz、水印设 30 级,75 ms 就得读一次,而你的读线程被别的活压住 100 ms,那必然丢——这种在日志里表现为”数据不连续”,但根源是任务优先级,不是传感器。
顺便说:别指望 FIFO 能救”端到端延迟”。 我们这台的时延账是这样的(见文末配套脚本 budget):
| 层 | 耗时 ms | 占比 |
|---|---|---|
| 总线/设备层 | 0.300 | 3.6% |
| 传感器驱动层 | 0.050 | 0.6% |
| rt_sensor 框架层 | 0.020 | 0.2% |
| 应用层滤波 | 8.000 | 95.6% |
| 端到端 | 8.370 |
大头在应用层的滑动滤波上。要压延迟,先压自己的滤波和任务调度,别去折腾传感器。 这一条我在别的项目上踩过——花了半天优化 I2C 速率,最后发现瓶颈是应用层一个 32 点的滑窗。
六、module 锁:一颗芯片两种功能,读串了
现在讲那 0.37%。
6.1 现象
BMP280 一颗芯片同时出气压和温度。我把它注册成了两个 rt_sensor 设备:baro_bmp280 和 temp_bmp280。平时都对,偶尔会互换——气压那个显示温度值,温度那个显示气压值。
6.2 根因一:没归到同一个 module
我最初的驱动写法是这样的(简化后):
/* 错误示范:两颗 sensor 共用一份 static 缓存 */
static rt_uint8_t g_raw[6]; /* 0xF7..0xFC:气压 3 字节 + 温度 3 字节 */
static rt_size_t bmp280_baro_fetch(struct rt_sensor_device *s, void *buf, rt_size_t len)
{
bmp280_burst_read(g_raw, 6); /* 一次读 6 字节到全局缓存 */
/* 气压取前 3 字节 */
return fill_baro(buf, g_raw[0], g_raw[1], g_raw[2]);
}
static rt_size_t bmp280_temp_fetch(struct rt_sensor_device *s, void *buf, rt_size_t len)
{
bmp280_burst_read(g_raw, 6);
/* 温度取后 3 字节 */
return fill_temp(buf, g_raw[3], g_raw[4], g_raw[5]);
}
看着没问题——每次读都刷新缓存,各取各的片。但两个 fetch_data 之间没有任何互斥,而它们跑在两个不同的采集线程里。时序一旦凑巧:
T0 气压线程 burst_read -> g_raw = [P0 P1 P2 T0 T1 T2]
T1 温度线程 burst_read -> g_raw = [P0 P1 P2 T0 T1 T2] ← 又刷了一遍
T2 气压线程 取 g_raw[0..2] = 气压值 ← 正常
T3 温度线程 取 g_raw[3..5] = 温度值 ← 正常
正常情况没问题,因为两边的数据来自同一次采样的同一块寄存器。但当”重配置”插进来时就不一样了:一次量程/ODR 变更会让芯片重新开始转换,g_raw 在两次 burst 之间被第三路刷新,于是气压线程第 T2 步取到的,是”温度线程那次 burst 之后又被刷新过”的字节。最丑的地方是这个 bug 只有在”两颗功能读取间隔和重配置撞上”时才出现,所以它是 0.37%,不是 37%。
修法就是框架给的:把这两颗 sensor 归到同一个 module。
/* 框架的 module:把有数据耦合的多功能 sensor 归到一起,框架统一加锁 */
struct rt_sensor_module
{
rt_mutex_t lock; /* 框架在注册时创建 */
rt_sensor_t sen[RT_SENSOR_MODULE_MAX]; /* 同 module 的 sensor 列表 */
rt_uint8_t sen_num;
};
static struct rt_sensor_module g_bmp280_module = {0};
/* 注册前,把两颗 sensor 的 module 指针指过去,并把它们登记进 sen[] */
baro_dev->module = &g_bmp280_module;
baro_dev->module->sen[g_bmp280_module.sen_num++] = baro_dev;
temp_dev->module = &g_bmp280_module;
temp_dev->module->sen[g_bmp280_module.sen_num++] = temp_dev;
rt_hw_sensor_register(baro_dev, "bmp280", RT_DEVICE_FLAG_RDONLY, &baro_ctx);
rt_hw_sensor_register(temp_dev, "bmp280", RT_DEVICE_FLAG_RDONLY, &temp_ctx);
之后框架在读和 control 时都会自动持 module->lock(框架内部的 read/control 路径会 rt_mutex_take(sensor->module->lock),出口再 release),两个 fetch_data 之间就被天然隔开了。
这里有一个容易忽略的细节:module->lock 是框架在注册第一个 sensor 时创建的(如果 module != NULL && module->lock == NULL,框架帮你 rt_mutex_create)。所以你不该自己再建一把锁——那等于加了两道锁,还可能顺序不一致。这一点和 10-01 那篇”互斥归属:把应用层的锁全删掉”是同一个道理:框架能管的互斥,就别自己管。
版本提醒:不同 RT-Thread 版本里 sensor 框架有 v1 / v2 两套实现,
module相关字段名可能略有差异。动手前先看你仓库里那份sensor.h,别照抄网上文章。
6.3 根因二:一次读被拆成两次事务
module 锁修掉了”两个 fetch_data 之间的竞争”,但还有一种更隐蔽的读串,跟锁无关:总线事务被拆开了。
I2C 读一个寄存器,物理上是这么两步:先把寄存器地址写进去,再重启(Repeated Start)后读数据。合起来才算一次完整事务。我原来图省事写成两次独立调用:
/* 错误示范:写地址、读数据 分成两次独立事务 */
rt_i2c_master_send(bus, addr, 0, ®, 1); /* 起-写-停 */
rt_i2c_master_recv(bus, addr, 0, buf, 6); /* 起-读-停 */
两次事务之间,I2C 总线是”释放”状态,同总线上别的设备随时可以插进来。插进来一次读操作,会改变总线状态机的上下文——回到我们这只传感器时,指针已经不是我们写进去那个了,读回来的 6 个字节属于别的寄存器块。落到应用上就是”数据串了”。
正确写法是一次复合事务(RT-Thread 的 I2C 框架支持 msgs 数组一次投递):
/* 正确:写地址 + 重启读,作为一个 msgs 数组一次投递 */
static rt_err_t bmp280_burst_read(struct rt_i2c_bus_device *bus,
rt_uint8_t addr, rt_uint8_t reg,
rt_uint8_t *buf, rt_size_t len)
{
struct rt_i2c_msg msgs[2];
msgs[0].addr = addr;
msgs[0].flags = RT_I2C_WR; /* 写寄存器地址 */
msgs[0].buf = ®
msgs[0].len = 1;
msgs[1].addr = addr;
msgs[1].flags = RT_I2C_RD; /* 重启后读,无 STOP */
msgs[1].buf = buf;
msgs[1].len = len;
return rt_i2c_transfer(bus, msgs, 2);
}
时间的账其实很小——8 字节在 400 kHz 下,复合事务 190 µs,拆成两次也就多一组起停。问题从来不是那几微秒,是”原子性”。 这也是红线三的后半句。
6.4 两个根因为什么同时存在才暴露
事后复盘:module 没归类,让两个 fetch 可以并行;事务被拆开,让它们有机会在中间被插队。单独修哪一个,异常率都能大幅下降,但都不会归零。 我先把 module 补上,异常率从 0.37% 掉到 0.02%;再改成复合事务,才彻底归零。
连续 72 小时复测:异常帧 0,三路数据的时序平均错位从 1.4 ms 降到 0.2 ms。
七、命名与多路:第二路传感器怎么加
那 0.37% 解决之后,我回头看第二个问题——加第二路。这时候分层的优势就出来了。
rt_hw_sensor_register 的设备名是类别前缀 + 你给的名字(框架内部用 sensor_name_str[info.type] 拼前缀)。所以:
/* 第一路:板载 */
rt_hw_sensor_register(&onboard_temp, "sht3x", RT_DEVICE_FLAG_RDONLY, &onboard_ctx);
/* 实际设备名可能是 "temp_sht3x" */
/* 第二路:外部探头,名字必须不同 */
rt_hw_sensor_register(&probe_temp, "probe", RT_DEVICE_FLAG_RDONLY, &probe_ctx);
/* 实际设备名是 "temp_probe" —— 不重名,注册成功 */
两条纪律:
- 同一类别的多路传感器,
name必须不同。 重名会注册失败或覆盖,而且框架的报错信息不一定醒目,容易误判成”没接上”。 - 应用层不许有全局单例。 我原来那个
get_room_temp()就是要删掉的东西。正确写法是按设备名 open:
static rt_device_t open_sensor(const char *dev_name)
{
rt_device_t dev = rt_device_find(dev_name);
if (dev == RT_NULL)
{
rt_kprintf("sensor not found: %s\n", dev_name);
return RT_NULL;
}
if (rt_device_open(dev, RT_DEVICE_FLAG_RDONLY) != RT_EOK)
{
rt_kprintf("open failed: %s\n", dev_name);
return RT_NULL;
}
return dev;
}
于是”多点测温”变成一句话:业务层多 open 一个设备,多读一路。 板载那颗和外部探头的驱动是两个文件、两套上下文,互不干扰。业务层改动:0 行。(真的 0 行,因为业务层本来就该是”给我 N 路温度”,N 是参数。)
八、第二个试金石:标定放哪一层
现在说红线二。这是我重构时和同事吵得最久的一条。
标定(校准)到底放哪?我见过三种写法:
- 放驱动层:驱动里塞一组
k、b,读到原始值就顺手修正。看着最省事。 - 放应用层:驱动吐”框架单位”的原始事实,应用层做零点/增益/温度补偿。
- 两层都放:驱动修正芯片级偏差,应用层修正系统级偏差。
正确答案是第二种,或者第三种(如果芯片内部确实有校准寄存器)。第一种是错的。
理由一句话:标定系数随安装、结构、批次走,不随芯片走。
同一个型号的温度探头,装在机箱里的和装在外部杆上的,标定系数就不一样;换一块 PCB,安装应力变了,零点就漂了。如果标定写进驱动,那”同一个驱动”要服务 N 种安装场景,最后必然演变成”驱动里一堆 if 判断板号”——这就是典型的层错。
驱动层唯一允许出现的”修正”,是读取芯片内部的出厂校准值。 有些气压/温湿度芯片自带校准系数寄存器(出厂烧录),驱动把它读出来、按手册公式算进结果——这是 datasheet 级的行为,属于驱动。系统级的零点/增益/温度补偿,一律在应用层。
应用层的标定就两步:两点定线,定点实现。
/* 两点标定:零点 + 增益。K、B 放大 1e6 存整数,全整数运算 */
#define CAL_K 987500 /* 0.9875 */
#define CAL_B 6250000 /* 6.25 */
static int apply_cal(int raw)
{
return (int)(((int64_t)CAL_K * raw + CAL_B) / 1000000);
}
温度补偿再叠一层,也是一阶的:
/* 一阶温度补偿:t 是框架单位的 0.1℃ 整数 */
static int apply_temp_comp(int v, int t_0p1)
{
int dt = t_0p1 - 250; /* 相对 25.0℃ 的偏差 */
return v + (C_TEMPCO * dt) / 1000; /* C_TEMPCO 单位:每 0.1℃ 的漂移量 */
}
全部整数,没有一处浮点。 这条和站内那几篇”零 malloc / 无 %f“是同一套纪律,在标定这里尤其重要——标定值要存 Flash、要在量产时逐台写入,浮点的表示误差会让”标定”本身变成误差源。
九、验证与定位:msh 三板斧 + 症状分诊表
分层重构之后,最直接的收益是排障路径变成了线性的,因为每一层都有独立的可观察点。
rt_sensor 自带三个 msh 命令,是分层排障的利器:
msh > sensor probe temp_sht3x /* 探测并选中某颗传感器,打印 device id */
msh > sensor info /* vendor / model / unit / range / period_min / fifo_max */
msh > sensor read 5 /* 连读 5 条,含时间戳 */
关键在于 sensor read 是直接调用框架、绕开你的应用层。于是定位流程变成:
sensor read 的值对不对?
├─ 对 -> 问题在应用层(单位换算 / 标定 / 滤波 / 上报)
└─ 错 -> 问题在驱动层或总线(换算 / 寄存器 / 接线)
└─ 再上逻辑分析仪看物理层
以前我是一头扎进应用层打 printf,现在先敲一句 sensor read,五分钟定层。 这张分诊表可以贴在调试台:
| 症状 | 该查哪一层 |
|---|---|
sensor read 与业务层数值不一致 |
应用层(单位 / 标定 / 滤波) |
sensor read 本身就是错的 |
驱动层(换算 / 寄存器读取) |
| 数值恒为 0 / 恒为 0xFF | 驱动层或总线(未通、地址错、上拉缺失) |
| 数值冻结不变 | 驱动层(未触发新转换、FIFO 未推进) |
| 两颗功能偶发互换数值 | module 未归类 或 复合事务被拆散 |
| 改 ODR 后节奏没变 | 驱动层 control 未生效 / config.odr 未更新 |
| 第二路传感器注册失败 | 命名冲突(设备名 = 类别前缀 + name) |
| 偶发跳变、数据不连续 | FIFO 溢出(水印 < 读取耗时) |
| 温漂后误差变大 | 应用层未做温度补偿 / 换算用了浮点 |
| 量产偏差大且集中 | 应用层缺逐台出厂标定流程 |
最后一行是我在别的项目上交过学费的:样机都对、量产 5% 偏大,最后查出来是标定没逐台做。 标定流程属应用层职责,但它的”该不该逐台做”必须在设计评审时就定下来,而不是量产出问题再补。
十、改动的账:11 个文件 → 1 个文件夹
重构完,我把改动账算了一遍。这是我最想给还在”一个文件干到底”的朋友看的东西。
| 场景 | 重构前 | 重构后 |
|---|---|---|
| 换传感器型号(SHT30→SHT31) | 改 11 个文件 | 改 1 个文件夹(驱动 + Kconfig + 一处注册) |
| 加一路同类传感器 | 业务层全改(单例) | 业务层 0 改动,加一个驱动实例 |
| 偶发读串(0.37%) | 无法定位 | 定位到 2 个根因,彻底归零 |
| 应用层单元测试 | 必须上硬件 | 打桩 rt_device_read 即可跑 |
| 单位口径 | 4 种写法混用 | 每一层只有一种 |
单位那行是我觉得最值钱的一条。 11 个文件之所以要改,本质不是”传感器换了”,而是”单位从来不统一”。分层把单位钉死在框架单位上,换型号就真的只改一个文件夹了。
代价也得说清楚:多了一层,就多了一层调用。 一个轮询读过去要经过 rt_device_read → 框架 → fetch_data → 总线,实测框架层开销 0.02 ms 量级(见第五节那张时延表),比应用层一个滑窗小两个数量级。换句话说,这一层的开销远低于它省下来的重构成本。 当然,如果你做的是亚毫秒级的闭环控制,那传感器这条路本来就不该走通用框架——这也是分层的判断,不是教条。
十一、自查清单
给同样在做传感器驱动重构的朋友,一份照着勾的清单:
- ☐ 驱动层只做”寄存器 ↔ 框架单位”的翻译,不含任何业务判断
- ☐ 驱动
fetch_data出口的单位,严格等于框架规定的框架单位(TEMP 是 0.1℃,HUMI 是 ‰) - ☐ 单位只向上细化,不向下回退;更高的精度由应用层自己借
- ☐
fetch_data的返回值是”填了几条”,不是RT_EOK - ☐ 数据就绪中断里只投递信号量,不做任何总线读写
- ☐ ODR / 量程 / 电源模式的重配置是原子的(含 FIFO 重启)
- ☐ 同一颗芯片的多功能 sensor,已归入同一个
module(module->lock交由框架创建) - ☐ 一次”写寄存器地址 + 读数据”是复合事务(
msgs[]一次rt_i2c_transfer),没拆成两次独立调用 - ☐ 同类别多路传感器的
name不重复,注册后设备名逐一确认过 - ☐ 应用层没有全局单例,全部按设备名
open,路数是参数 - ☐ 标定只放在应用层;驱动层最多读取芯片出厂校准值
- ☐ 标定与温度补偿全整数实现,没有
%f、没有malloc - ☐ FIFO 水印算过溢出时间,且留了 1/3 余量
- ☐ 端到端时延预算做过(四层分开算过),瓶颈不在总线层
- ☐
sensor probe/sensor info/sensor read三板斧能通 - ☐
msh直读与业务层数值一致(能借此判定问题在不在应用层) - ☐ 换型演练做过一次:模拟换一颗同类别芯片,确认应用层 0 改动
- ☐ 加第二路演练做过一次:确认业务层 0 改动
- ☐ 72 小时长跑复测过(尤其是并发读取 + 重配置交错)
- ☐ 验收前用
nm/ map 确认 Release 版没有引入堆分配
结语
那三个问题,最后是同一件事的三个面。
换型要改 11 个文件,是因为单位没有归属;加第二路要全改,是因为应用层耦合了具体设备;0.37% 的读串,是因为”芯片的多功能”和”总线的原子性”这两件事,都没人负责。
rt_sensor 这一层不是银弹——它只是把”传感器这一类设备”共有的三件事(接口、单位、并发)收拢到一个地方。剩下的分工,得你自己画。而我这次最大的收获,其实是那张四层职责表和三条红线: 表贴上去之后,团队里两个人同时改驱动,居然没再互相踩过。
如果你也在做传感器驱动,或者正好在 RT-Thread 上从”一个文件干到底”往分层搬,欢迎留言聊聊——我特别好奇”标定放哪层”这件事,别人是怎么吵的。
相关阅读
- RT-Thread 设备框架与驱动移植实战:从 FreeRTOS”各写各的 SPI”搬过来,我把 rt_device 这层统一套路补齐了 —— 本篇的下半层:
rt_device统一套路、SPI 的总线/CS 两层、send_then_recv选型、互斥归属(”框架能管的互斥就别自己管”这条即出自那篇) - RT-Thread 内核对象与 FinSH 进阶实战:客户现场敲
list_thread一片空白 —— 本篇第九节的sensor probe/info/read三板斧,和那篇的”对象容器 + FinSH”是同一套排障思路 - I2C 总线避坑实战:上拉电阻、时钟拉伸与总线死锁恢复 —— 第六节那个”复合事务不能拆”的问题,物理层的原因(Repeated Start 的语义、总线状态机)在这篇
- ADC 采样精度实战:同一颗 12 位 ADC,凭什么别人做到 11.5 bit 有效精度,我只有 9 bit? —— 第八节的”零点/增益两点标定 + 温度补偿”和那篇的校准是同一套方法,可以对照着看
- 低功耗外设与传感器电源门控实战:给传感器加了断电开关,功耗反而涨了 —— 驱动层的
RT_SENSOR_CTRL_SET_POWER与门控开关怎么配合,那篇是硬件的账 - 嵌入式 C 代码规范与防御性编程实践 —— 本篇强调的”每个返回值都要检查””失败要分类上报”,那篇是这套纪律的系统版本;定点替代
%f也出自同一条纪律
本文配套脚本:_tools/rtt_sensor_lay.py —— RT-Thread 传感器驱动分层现场估算器。mode: unit(芯片原始码 → 框架单位,覆盖 ACCE/GYRO/MAG/TEMP/HUMI/BARO 等类别)、chain(三层单位链逐层打印,含定点输出串)、odr(目标 ODR 与可选档位匹配、采样周期、奈奎斯特与抗混叠建议)、fifo(FIFO 水印与溢出时间预算,含读取耗时对照)、bus(I2C/SPI 单次事务与复合事务耗时预算)、cal(两点标定增益/零点与定点系数 K、B,含余量检查)、budget(四层时延预算,看瓶颈到底在哪层)、layer(四层职责自查 + 三条红线)、triage(症状 → 层 定位表)。纯标准库,全离线,不联网。自带 --self-test(4 项)。
下次打算写:电流采样链路的分层(分流电阻 + 隔离放大器,从”电流值随温度漂了 8%”讲起,正好补上硬件侧这块),或者 CAN/LIN 之外的 24V 系统与 SAE J2602 差异。哪个先写,看这周的活先到哪个。