SHT30 + BME280:把温湿度气压三件套挂到同一条 I²C 上(ESP-IDF v5.x 完整驱动)
适用人群:会点亮 LED、抄过一份传感器例程能跑出数,但一遇到"两颗传感器挂同一条 I²C"就开始怀疑人生的同学。
读完你能得到:
① 一份能直接用的 SHT30 驱动:命令码、等待时间、CRC-8 校验、“为什么分母是 65535 不是 65536”;
② 一份能直接用的 BME280 驱动:chip id 验明正身、32 字节校准参数的读取与解析、官方补偿公式、强制模式的完整时序;
③ 一条i2c_master_bus挂两个 device 的正确写法(ESP-IDF v5.x 新驱动),以及为什么不能开两个任务各读各的;
④ 一张"读出来全是 0xFFFF / 全是 0 / 气压 887 hPa"的对症下药表;
⑤ 单位统一 + 合理性校验的做法,让上层代码永远不用猜"这个数到底是 Pa 还是 hPa"。
没看过我《项目实战_01 从 0 做一个带 OTA 的低功耗气象节点》也能读这篇。那篇里 SHT30 只是整个节点里的一个零件、只占一节;这篇把它拆开讲透,并且把 BME280 补上,最后把两颗挂到同一条总线上。反过来,读完这篇再回头看那篇的采样任务,会顺很多。
一、为什么要把传感器"整合"起来
1.1 先说个我自己的周末
大三下的某个周六,我想给宿舍做个"环境监测小盒子":温度、湿度、气压都要,顺手再挂个 OLED 显示。淘宝上买了三个模块,面包板一插,杜邦线一飞,代码从三份不同的例程里各抄一段,编译,烧录。
串口打出来是这样的:
[env] T = 130.00 C, RH = 100.00 %
[env] T = 130.00 C, RH = 100.00 %
[env] P = 0.00 hPa, T = 0.00 C
[env] OLED init failed: ESP_ERR_TIMEOUT
三个模块,三种死法,而我当时的三个反应估计你也会有:
- “是不是传感器烧了?” —— 又买了两个,一模一样。(浪费了 30 块和三天)
- “是不是库不对?” —— 换了四五个 Arduino 库改到 ESP-IDF 上,还是错。
- “是不是 ESP32 的 I²C 有毛病?” —— 冤枉了人家。
真相很朴素,而且三个症状对应三个完全不同的原因:
| 串口打出来的现象 | 真实原因 | 一句话解释 |
|---|---|---|
T = 130.00 C, RH = 100.00 % | SDA 上没有上拉,读回来的每个字节都是 0xFF | 原始值 0xFFFF 代进换算公式:-45 + 175 × 65535/65535 = 130 °C,湿度正好 100% |
P = 0.00 hPa, T = 0.00 C | BME280 的校准参数没读,全是 0 | 补偿公式里 dig_P1 == 0 会触发官方代码的"除零保护"直接 return 0;温度那条也全乘 0 |
OLED init failed: TIMEOUT | 三个模块的 I²C 地址我根本没查过 | 地址撞车 / 地址填错,主机发地址后没人应答(NACK) |
这三条我后面每一条都会给出能自己复现、也能自己修好的完整过程。
💡 注意第一条那个
130.00 °C。很多人看到 130 会以为"传感器坏了在乱跳",其实它是一个非常确定的数——只要你的换算公式没写错,0xFFFF必然算出 130.00 °C 和 100.00 %RH。看到 130/100 这对组合,几乎可以直接判定"总线读回来全是 1",去查上拉电阻,别查代码。这就是"把数字看懂"的价值。
1.2 "整合"到底整合什么
新手容易以为"整合 = 把两份例程复制到一个文件里"。真不是。整合要统一的是这四样东西:
| 要统一的 | 不统一会怎样 |
|---|---|
| 一条总线,多个设备句柄 | 每个传感器各建一条 I²C 总线 → 引脚冲突、i2c_new_master_bus 直接返回错误 |
| 一套错误处理 | 一颗传感器掉线,整个采样任务卡死或者直接崩,另一颗好的也白搭 |
| 一套单位 | A 传感器返回 hPa、B 返回 Pa、C 返回 0.01 °C,上层算平均值时神仙也救不了 |
| 一套合理性校验 | 错值一路传到服务器/显示屏,你看到 -45 °C 才发现问题,那时候日志早翻页了 |
1.3 两颗芯片的性格对照表
在写一行代码之前,先看清楚它俩有多不一样——这张表就是本篇后面所有代码的原因:
| SHT30(Sensirion SHT3x-DIS) | BME280(Bosch) | |
|---|---|---|
| 测什么 | 温度 + 湿度 | 温度 + 湿度 + 气压 |
| 接口 | 只有 I²C | I²C 或 SPI(CSB 接 VDDIO 才是 I²C 模式) |
| 7 位地址 | 0x44(ADDR 接低,默认)/ 0x45(接高) | 0x76(SDO 接 GND)/ 0x77(SDO 接 VDDIO) |
| 交互模型 | 16 位命令码,没有寄存器地图,像"下指令" | 8 位寄存器地图,像个小 EEPROM,读写寄存器 |
| 要不要补偿 | 不要。芯片内部已线性化,一条公式换算完事 | 必须。先读 32 字节校准参数,走官方补偿算法 |
| 数据自带校验 | 每 2 字节数据跟 1 字节 CRC-8 | 没有 CRC,只能靠 chip id + 量程判断 |
| 典型精度 | ±0.2 °C(0~65 °C typ.)、±2 %RH | ±0.5 °C(25 °C)、±3 %RH、气压 ±1.0 hPa |
| 量程 | -40 | -40 |
⚠️ 两颗都能测温湿度,为什么还都要? 不是为了冗余好玩:SHT30 的温湿度精度明显更好(±0.2 °C vs ±0.5 °C、±2 %RH vs ±3 %RH),而气压只有 BME280 有。所以我的用法是:湿度信 SHT30、气压信 BME280、温度两个都拿来互相验证。第四节会把这套"谁说了算"的逻辑写成代码。
1.4 硬件长这样
ESP32-WROOM-32
┌──────────────┐
│ │
3V3 ───────┤ 3V3 IO21├──────┬────────┬───── SDA
│ │ │ │ │
│ │ IO22├────┬─┼────────┼───── SCL
│ │ │ │ │ │
GND ───────┤ GND │ │ │ │
└──────────────┘ │ │ │
│ │ │
4.7k ───────────────────┘ │ │ ← SCL 上拉到 3V3
4.7k ──────────────────────┘ │ ← SDA 上拉到 3V3
│ │
3V3 ┌────────────┴────────────┐
│ │
┌──────┴──────┐ ┌───────┴──────┐
│ SHT30 │ │ BME280 │
│ ADDR→GND │ │ SDO→GND │
│ 地址 0x44 │ │ CSB→VDDIO │
└─────────────┘ │ 地址 0x76 │
└──────────────┘
两颗共用同一对 SDA/SCL,靠"地址不同"区分 —— 这就是 I²C 的全部魔法
三条接线上的硬规矩,缺一个都会让你调一整天:
- 上拉电阻必须有,4.7 kΩ 接 3V3,SDA/SCL 各一个。很多模块板上已经带了(背面有两个小电阻),多个模块并联时上拉会越并越小,三个模块各带 4.7 kΩ 并起来只剩 1.6 kΩ,驱动能力不够的从机会拉不低电平。真挂多个模块时,留一个模块的上拉、其余的把板上上拉焊掉(或者干脆都拆掉,自己在总线上焊一对)。
- 地址选择脚不许悬空。SHT30 手册白纸黑字写着"The ADDR pin must not be left floating";BME280 手册也写着 SDO 悬空会导致"I²C address will be undefined"。悬空 = 地址随缘,你会看到"有时候能读到、有时候读不到"这种最难查的故障。
- BME280 的 CSB 必须接 VDDIO,否则它认为你要用 SPI,I²C 根本不理你。四线模块(只有 VCC/GND/SDA/SCL)板上已经接好了,六线的裸模块要自己接。
二、SHT30:命令式传感器的标准写法
2.1 一份 22 页的手册,你只需要五行
《Datasheet SHT3x-DIS》我第一次翻的时候完全懵。后来发现,写一个能跑的驱动,只需要下面这五条信息:
| 我要知道的 | 手册怎么说 | 出处 |
|---|---|---|
| 地址是多少 | ADDR 接低 → 0x44(默认);接高 → 0x45;不许悬空 | Table 8 |
| 怎么让它测一次 | 单次测量,16 位命令。高重复性 + 关时钟拉伸 = 0x2400;开时钟拉伸版是 0x2C06 | Table 9 |
| 测一次要多久 | 高重复性:典型 12.5 ms,最长 15 ms(2.4~5.5 V 供电,Table 4) | Table 4 |
| 读回来什么格式 | 6 字节:温度 MSB、温度 LSB、CRC,湿度 MSB、湿度 LSB、CRC | 4.4 节 |
| 怎么变成人能看的数 | T = -45 + 175 × S_T/(2¹⁶-1),RH = 100 × S_RH/(2¹⁶-1) | 4.13 节 |
一次完整的单次测量长这样:
主机 SHT30
│ │
├── S │ 0x44+W │ 0x24 │ 0x00 │ P ──────────────────▶ 收到命令,开始测
│ ACK ACK ACK │
│ │ ← 高重复性最长 15 ms
│ (主机在这里必须等,至少等到测量完成) │
│ │
├── S │ 0x44+R │◀── T_MSB T_LSB CRC RH_MSB RH_LSB CRC ──┤ 6 字节
│ ACK │
│ 没测完就来读 → NACK
│
关键:0x2400 是"不用时钟拉伸",从机不会替你把 SCL 拽住,
所以"等够时间"这件事 100% 是主机的责任
⚠️ 为什么我选 0x2400(关时钟拉伸)而不是 0x2C06(开)? 时钟拉伸是从机在没测完时把 SCL 拽住不放,主机干等。听起来省心,但 ESP32 的 I²C 主机对 SCL 被拉低有超时限制(
i2c_device_config_t里的scl_wait_us),15 ms 的拉伸远超默认值,配不好就是一个ESP_ERR_TIMEOUT,而且你还会以为是接线问题。用 0x2400 + 自己延时,逻辑直白,出错也好查。
2.2 换算公式:分母是 65535,不是 65536
T[°C] = -45 + 175 × S_T / (2^16 - 1) S_T、S_RH 都是 16 位无符号原始值
RH[%] = 100 × S_RH / (2^16 - 1) (2^16 - 1) = 65535
这个 -1 特别容易被抄丢。丢了会怎样?误差是 1/65536 ≈ 0.0015%,在 175 °C 的跨度上是 0.0027 °C——根本测不出来。所以它不是"精度问题",而是"你到底有没有照着手册写"的问题。我把它拎出来讲,是因为面试的时候真有人问过我"你这个 65535 是哪来的",答"抄的库"和答"手册 4.13 节,因为 16 位满量程对应的是 2¹⁶-1 个刻度"是两种印象。
几个能当自检用的特征值(自己拿计算器验一遍,比背公式管用):
| 原始值 S_T / S_RH | 算出来的温度 | 算出来的湿度 | 什么时候会看到 |
|---|---|---|---|
0x0000 | -45.00 °C | 0.00 % | 总线被短到地、或者从机一直不放数据 |
0x6666 | 25.00 °C | 40.00 % | 正常室温 |
0x8000 | 42.50 °C | 50.00 % | 正常(夏天贴着 MCU 就有可能) |
0xFFFF | 130.00 °C | 100.00 % | 没上拉 / SDA 悬空,读回来全是 1 |
2.3 CRC-8:两行代码换来"再也不怀疑人生"
SHT30 每 2 字节数据后面跟 1 字节 CRC,参数在手册 Table 20:
| 属性 | 值 |
|---|---|
| 名称 | CRC-8 |
| 多项式 | 0x31(x⁸ + x⁵ + x⁴ + 1) |
| 初值 | 0xFF |
| 输入/输出反转 | 都不反转 |
| 最后异或 | 0x00(即不异或) |
| 手册给的自测向量 | CRC(0xBEEF) = 0x92 |
写完 CRC 函数第一件事,就是拿 0xBEEF 验一下,别等到读不出数据才回来怀疑 CRC。
顺手做个更有意思的验算,这也是我强烈建议你别省掉 CRC 校验的理由:
crc8(0xFF, 0xFF) = 0xAC ≠ 0xFF → 总线悬空全 1 的假数据,CRC 挡得住
crc8(0x00, 0x00) = 0x81 ≠ 0x00 → 总线短地全 0 的假数据,CRC 也挡得住
crc8(0xBE, 0xEF) = 0x92 → 手册自测向量,对上了说明函数没写错
也就是说,两种最常见的硬件故障产生的"假数据",都会被 CRC 直接拦下来。你只需要写 8 行代码,就换来"错值不会流到上层"。这笔买卖太划算了。
2.4 完整驱动代码
先说清楚 ESP-IDF v5.x 的 I²C 心智模型——它和你在网上搜到的老代码完全不一样:
i2c_new_master_bus() → 建一条总线 bus_handle(配 SDA/SCL/时钟源)
│
i2c_master_bus_add_device() → 在这条总线上挂一个设备 dev_handle(配地址/速率)
│ ★ 同一条 bus 可以挂多个 device,这就是本篇第四节的基础
i2c_master_transmit() → 往这个设备写
i2c_master_receive() → 从这个设备读
i2c_master_transmit_receive() → 写完立刻重复起始条件再读(寄存器型器件必备)
│ 每个都带 xfer_timeout_ms
i2c_master_bus_rm_device() / i2c_del_master_bus() → 用完拆掉
老的 driver/i2c.h(i2c_param_config / i2c_master_cmd_begin 那一套)在 ESP-IDF v5.x 文档里已经被标为 legacy 驱动,新工程别再用了。
/* ===== sht30.h ===== */
#pragma once
#include <stdint.h>
#include "driver/i2c_master.h"
#include "esp_err.h"
#define SHT30_ADDR_LOW 0x44 /* ADDR 引脚接低(模块默认) */
#define SHT30_ADDR_HIGH 0x45 /* ADDR 引脚接高 */
/* 在已有的 I2C 总线上挂一个 SHT30,返回设备句柄 */
esp_err_t sht30_add(i2c_master_bus_handle_t bus, uint16_t addr,
i2c_master_dev_handle_t *out_dev);
/* 单次测量:高重复性 + 不用时钟拉伸。温度单位 °C,湿度单位 %RH */
esp_err_t sht30_read(i2c_master_dev_handle_t dev, float *temp_c, float *rh);
/* ===== sht30.c —— SHT30/SHT35 单次测量(ESP-IDF v5.x,driver/i2c_master.h) ===== */
#include <stddef.h>
#include "sht30.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
static const char *TAG = "sht30";
#define SHT30_I2C_HZ 100000 /* 杜邦线连接时老实用 100 kHz */
#define SHT30_TIMEOUT_MS 100 /* 绝不写 -1(-1 = 永远等) */
/* 手册 Table 20:CRC-8 / 多项式 0x31 / 初值 0xFF / 输入输出都不反转 / 最后不异或
自测向量:sht30_crc8((uint8_t[]){0xBE, 0xEF}, 2) == 0x92 */
static uint8_t sht30_crc8(const uint8_t *data, size_t len)
{
uint8_t crc = 0xFF;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (int bit = 0; bit < 8; bit++) {
crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x31)
: (uint8_t)(crc << 1);
}
}
return crc;
}
esp_err_t sht30_add(i2c_master_bus_handle_t bus, uint16_t addr,
i2c_master_dev_handle_t *out_dev)
{
if (bus == NULL || out_dev == NULL) {
return ESP_ERR_INVALID_ARG;
}
if (addr != SHT30_ADDR_LOW && addr != SHT30_ADDR_HIGH) {
ESP_LOGE(TAG, "地址只能是 0x44 或 0x45,你填的是 0x%02X", addr);
return ESP_ERR_INVALID_ARG;
}
i2c_device_config_t dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = addr, /* 7 位原始地址,新驱动自己拼读写位 */
.scl_speed_hz = SHT30_I2C_HZ,
};
return i2c_master_bus_add_device(bus, &dev_cfg, out_dev);
}
esp_err_t sht30_read(i2c_master_dev_handle_t dev, float *temp_c, float *rh)
{
if (dev == NULL || temp_c == NULL || rh == NULL) {
return ESP_ERR_INVALID_ARG;
}
/* 手册 Table 9:高重复性 + 关闭时钟拉伸 = 0x2400 */
const uint8_t cmd[2] = { 0x24, 0x00 };
uint8_t rx[6] = { 0 };
esp_err_t err = i2c_master_transmit(dev, cmd, sizeof(cmd), SHT30_TIMEOUT_MS);
if (err != ESP_OK) {
ESP_LOGE(TAG, "发测量命令失败: %s", esp_err_to_name(err));
return err;
}
/* 手册 Table 4:高重复性典型 12.5 ms、最长 15 ms(2.4~5.5 V)。
⚠️ 别写 pdMS_TO_TICKS(15)!ESP-IDF 默认 tick = 100 Hz(10 ms 一跳),
vTaskDelay(1 tick) 实际可能只睡不到 10 ms —— 传感器还没测完你就去读,
得到的就是 NACK。给 30 ms(3 tick,最短也有 20 ms)才稳。 */
vTaskDelay(pdMS_TO_TICKS(30));
err = i2c_master_receive(dev, rx, sizeof(rx), SHT30_TIMEOUT_MS);
if (err != ESP_OK) {
ESP_LOGE(TAG, "读结果失败: %s(多半是等待时间不够,被 NACK 了)",
esp_err_to_name(err));
return err;
}
/* 6 字节 = 温度MSB, 温度LSB, CRC, 湿度MSB, 湿度LSB, CRC */
if (sht30_crc8(&rx[0], 2) != rx[2] || sht30_crc8(&rx[3], 2) != rx[5]) {
ESP_LOGE(TAG, "CRC 不匹配,这一包丢掉");
return ESP_ERR_INVALID_CRC;
}
uint16_t s_t = (uint16_t)(((uint16_t)rx[0] << 8) | rx[1]);
uint16_t s_rh = (uint16_t)(((uint16_t)rx[3] << 8) | rx[4]);
/* 手册 4.13 节,分母是 2^16 - 1 = 65535,不是 65536 */
*temp_c = -45.0f + 175.0f * ((float)s_t / 65535.0f);
*rh = 100.0f * ((float)s_rh / 65535.0f);
return ESP_OK;
}
几个新手最容易跳过、但一跳过就出事的点:
device_address填的是 7 位原始地址 0x44,新驱动会自己拼读写位。老驱动时代那种"要不要左移一位变成 0x88"的纠结,在这套 API 里不存在了。你要是自己左移了,就会去敲 0x88 那个地址,然后收获一个 NACK。xfer_timeout_ms千万别填 -1。官方文档写得很清楚:-1means wait forever。总线被拉死(比如从机没上电把 SDA 咬住)时,你的任务就永久阻塞在那里,看门狗都不一定救得回来。老实给 100 ms。sizeof(rx)而不是写死6:数组大小改了,读的长度自动跟着改。(这属于 C 的基本功,但真见过有人rx[6]却读 8 字节,然后栈上相邻变量被踩,症状是"另一个变量莫名其妙变了"。)- CRC 不匹配要
return,不要"打个警告继续用"。错值流到上层比没有值可怕得多。
2.5 几个你迟早会用到的附加命令
SHT30 是命令式器件,除了测量还有这些(手册 4.9~4.11):
| 用途 | 16 位命令 | 什么时候用 |
|---|---|---|
| 软复位 | 0x30A2 | 上电初始化时来一发,保证从已知状态开始 |
| 读状态寄存器 | 0xF32D | 想知道"上一条命令有没有执行成功"(bit1)、“是不是刚复位过”(bit4) |
| 清状态寄存器 | 0x3041 | 读完状态位清掉,方便下次判断 |
| 加热器开 / 关 | 0x306D / 0x3066 | 高湿环境下结露了,开加热器烤一下(别常开,会把温度读数抬上去) |
| 停止周期测量(break) | 0x3093 | 从周期模式切回单次模式之前必须发 |
⚠️ 状态寄存器读回来是 3 字节:2 字节状态 + 1 字节 CRC。别按 2 字节读,会少读一个字节导致后面的时序全乱。
三、BME280:寄存器式传感器,以及"必须做补偿"这件事
如果说 SHT30 是"发个命令等结果",BME280 就完全是另一个物种:它是一片寄存器,你要像操作 EEPROM 一样去读写;而且它吐出来的是没有物理意义的 ADC 原始值,必须用芯片里烧死的校准参数做一遍相当复杂的补偿运算,才能变成温度、气压、湿度。
这就是新手在 BME280 上翻车的根本原因:SHT30 那套"读出来除一下"的经验,在这里完全不适用。
3.1 第一步永远是验明正身:chip id
你手上那块蓝色小板子,很可能不是 BME280。三兄弟长得一模一样,价格差一倍:
| 芯片 | 寄存器 0xD0 读出来 | 能测什么 | 买错了会怎样 |
|---|---|---|---|
| BMP280 | 0x58 | 温度 + 气压(没有湿度) | 湿度永远读出 0,你会以为湿度传感器坏了 |
| BME280 | 0x60 | 温度 + 气压 + 湿度 | —— |
| BME680 | 0x61 | 温湿压 + 气体(VOC) | 寄存器地图不同,BME280 的代码读它只会读出乱码 |
所以驱动的第一件事不是配置,而是:
读 0xD0 → 是不是 0x60 → 不是就直接报错返回,别往下走
我见过太多"BME280 数据不对"的帖子,最后发现买的是 BMP280。花 3 行代码做 chip id 校验,能省你三个小时。
3.2 寄存器地图:一张表看完
BME280 的寄存器不多,按用途分成四块(手册 Table 18):
地址 名字 干什么用 读/写
─────────────────────────────────────────────────────────────────────────
0x88~0xA1 calib00~25 校准参数第一段(温度+压力+dig_H1) 只读
0xD0 id chip_id,必须是 0x60 只读
0xE0 reset 写 0xB6 = 软复位 只写
0xE1~0xE7 calib26~41 校准参数第二段(湿度 dig_H2~H6) 只读
0xF2 ctrl_hum 湿度过采样 osrs_h[2:0] 读写
0xF3 status bit3=measuring, bit0=im_update 只读
0xF4 ctrl_meas osrs_t[7:5] osrs_p[4:2] mode[1:0] 读写
0xF5 config t_sb[7:5] filter[4:2] spi3w_en[0] 读写
0xF7~0xFE 数据区 压力3B + 温度3B + 湿度2B = 8 字节 只读
三个必须记住的坑点,全都写在手册里,但全都容易被略过:
ctrl_hum(0xF2) 写完不会立刻生效,手册 5.4.3 原文:“Changes to this register only become effective after a write operation to ctrl_meas”。所以顺序永远是先写 0xF2,再写 0xF4。反过来写,湿度过采样就还是 0(= skipped),湿度输出恒为0x8000。config(0xF5) 在 normal 模式下写可能被忽略(手册 5.4.6)。所以要趁复位后还在 sleep 模式时把它写好。- 数据区 8 个字节必须一次 burst 读完(手册 4.1)。分几次读,可能读到"一半是上一次测量、一半是这一次"的混合数据。这个 bug 特别阴险:99% 的时候数据是对的,偶尔冒一个离谱值。
3.3 三种模式:为什么我选 forced
| 模式 | mode[1:0] | 行为 | 适合谁 |
|---|---|---|---|
| sleep | 00 | 不测量,最省电(复位后就是这个状态) | 待机 |
| forced | 01 或 10 | 测一次,自动回 sleep | ✅ 低速采样(几秒~几分钟一次),省电,时序可控 |
| normal | 11 | 按 t_sb 间隔自动连续测 | 需要高频连续数据(比如做 IIR 滤波的室内导航) |
我选 forced,理由和 SHT30 那边选"关时钟拉伸"是同一个:主动权在我手上。每次要数据就"踢它一脚",测完它自己睡回去,功耗和时序都由我控制。手册 3.5.1 节给的 “weather monitoring” 推荐配置也正是这个:
forced mode, 1 sample / minute;pressure ×1, temperature ×1, humidity ×1;IIR filter off;典型电流 0.16 µA。
一次 forced 测量的完整时序:
主机 BME280
│ │
├── 写 0xF2 = osrs_h ──────────────────────────────────▶ │ 湿度过采样(还没生效)
├── 写 0xF4 = osrs_t<<5 | osrs_p<<2 | 0x01 ────────────▶ │ ★ 这一写同时让 0xF2 生效
│ │ 并且触发一次测量
│ │
│ 等 t_measure,max(×1×1×1 时约 9.3 ms) │ status.measuring = 1
│ 然后轮询 0xF3 直到 measuring 位变 0 │
│ │ 测完 → 自动回 sleep
│ │ status.measuring = 0
├── 写 0xF7 → 重复起始 → 连读 8 字节 ◀──────────────────┤ press[3] temp[3] hum[2]
│ │
└── 拿 20 位/20 位/16 位 ADC 值去做补偿运算 ──────────▶ 真实的 °C / Pa / %RH
测量时间怎么算? 手册 9.1 节给了公式(最坏情况):
t_measure,max [ms] = 1.25
+ 2.3 × osrs_t
+ (2.3 × osrs_p + 0.575) ← 压力没跳过才加
+ (2.3 × osrs_h + 0.575) ← 湿度没跳过才加
其中 osrs_x 取 1 / 2 / 4 / 8 / 16(跳过时该项整个不算)
三个都 ×1: 1.25 + 2.3 + 2.875 + 2.875 = 9.3 ms → 向上取整给 10 ms
×2 温度、×16 压力、×1 湿度: = 46.1 ms → 给 47 ms
⚠️ 别照抄网上那句"BME280 测一次大概 10 ms 你 delay(100) 就行了"。过采样开到 ×16,测量时间会涨到 40 ms 以上;而如果你为了省电把周期拉到几分钟一次,多等 90 ms 又是白白浪费电。按公式算,然后再用 status 位兜底——这才是既准又稳的做法。
3.4 校准参数:不读它,你只会得到 0
这是 BME280 和 SHT30 最本质的区别,也是本篇最想让你记住的一件事:
BME280 出厂时,每一颗芯片的补偿系数都是单独测出来、烧进它自己 NVM 里的。
你必须先把这 32 个字节读出来,才能把 ADC 原始值换算成物理量。
这些系数每颗芯片都不一样,不能写死在代码里,也不能从别人的博客里抄。
不读会怎样?我拿全零的校准参数跑了一遍官方补偿公式,结果是确定的:
校准参数全 0 时:
温度 = 0.00 °C ← t_fine 全程乘 0,最后 (0*5+128)>>8 = 0
气压 = 0.00 Pa ← dig_P1 == 0 触发官方代码里的"除零保护",直接 return 0
湿度 = 0.00 %RH
看到"温度 0.00 °C、气压 0 Pa"这个组合,第一反应就该是"校准没读到",而不是"传感器坏了"。这和 SHT30 的 130 °C / 100 % 是一个道理——把错误现象和原因绑定起来记,排查速度天差地别。
校准参数分两段存(手册 Table 16),中间隔着一大片别的寄存器,所以必须读两次:
| 地址 | 内容 | 类型 |
|---|---|---|
| 0x88 / 0x89 | dig_T1 [7:0] / [15:8] | unsigned short |
| 0x8A / 0x8B | dig_T2 | signed short |
| 0x8C / 0x8D | dig_T3 | signed short |
| 0x8E / 0x8F | dig_P1 | unsigned short |
| 0x90 ~ 0x9F | dig_P2 ~ dig_P9 | signed short × 8 |
| 0xA0 | (保留,跳过) | — |
| 0xA1 | dig_H1 | unsigned char |
| 0xE1 / 0xE2 | dig_H2 | signed short |
| 0xE3 | dig_H3 | unsigned char |
| 0xE4 / 0xE5[3:0] | dig_H4 [11:4] / [3:0] | signed short(12 位!) |
| 0xE5[7:4] / 0xE6 | dig_H5 [3:0] / [11:4] | signed short(12 位!) |
| 0xE7 | dig_H6 | signed char |
这张表里有三个致命细节,我第一次写的时候三个全踩了:
- 全部是小端:低字节在前。
dig_T1 = calib[1] << 8 | calib[0],写反了温度就会变成天文数字。(不熟这块的话去看我《C语言_05 大端小端详解》。) - 有符号和无符号混着来:
dig_T1、dig_P1是无符号,dig_T2、dig_P2… 是有符号。全按无符号读,气压会算出几百 hPa 的离谱值——这是最经典的"能跑出数,但数是错的"bug。 - dig_H4 / dig_H5 是拧在一起的 12 位有符号数,共用
0xE5这一个字节的高低半字节。这地方我第一次写成了 16 位,湿度读出来一直在 100% 和 0% 之间反复横跳。
/* ---------- 校准参数解析:小端 + 12 位符号扩展 ---------- */
static uint16_t rd_u16_le(const uint8_t *p)
{
return (uint16_t)(((uint16_t)p[1] << 8) | p[0]);
}
/* 把 16 位无符号按二补码解释成有符号,结果一定落在 int16_t 范围内,
不依赖"超范围转换"的实现定义行为(呼应 C语言_05 大小端那篇) */
static int16_t rd_s16_le(const uint8_t *p)
{
uint32_t u = rd_u16_le(p);
return (int16_t)((int32_t)(u ^ 0x8000u) - 0x8000);
}
/* 12 位有符号数的符号扩展:raw ∈ [0,4095],bit11 是符号位 */
static int16_t sign_extend_12(uint16_t raw)
{
raw &= 0x0FFF;
return (int16_t)((int32_t)(raw ^ 0x0800u) - 0x0800);
}
static void bme280_parse_calib(bme280_calib_t *c,
const uint8_t *a /* 0x88 起 26 字节 */,
const uint8_t *e /* 0xE1 起 7 字节 */)
{
c->dig_T1 = rd_u16_le(&a[0]); /* 0x88/0x89 */
c->dig_T2 = rd_s16_le(&a[2]); /* 0x8A/0x8B */
c->dig_T3 = rd_s16_le(&a[4]);
c->dig_P1 = rd_u16_le(&a[6]); /* 0x8E/0x8F */
c->dig_P2 = rd_s16_le(&a[8]);
c->dig_P3 = rd_s16_le(&a[10]);
c->dig_P4 = rd_s16_le(&a[12]);
c->dig_P5 = rd_s16_le(&a[14]);
c->dig_P6 = rd_s16_le(&a[16]);
c->dig_P7 = rd_s16_le(&a[18]);
c->dig_P8 = rd_s16_le(&a[20]);
c->dig_P9 = rd_s16_le(&a[22]);
/* a[24] 是 0xA0,保留字节,跳过 */
c->dig_H1 = a[25]; /* 0xA1,unsigned char */
c->dig_H2 = rd_s16_le(&e[0]); /* 0xE1/0xE2 */
c->dig_H3 = e[2]; /* 0xE3,unsigned char */
/* 0xE4 = dig_H4[11:4],0xE5[3:0] = dig_H4[3:0] */
c->dig_H4 = sign_extend_12((uint16_t)(((uint16_t)e[3] << 4) | (e[4] & 0x0F)));
/* 0xE6 = dig_H5[11:4],0xE5[7:4] = dig_H5[3:0] */
c->dig_H5 = sign_extend_12((uint16_t)(((uint16_t)e[5] << 4) | (e[4] >> 4)));
c->dig_H6 = (int8_t)((int32_t)(e[6] ^ 0x80u) - 0x80); /* 0xE7,signed char */
}
💡 那个
(x ^ 0x800) - 0x800是什么魔法? 这是可移植的符号扩展写法。直接写(int16_t)raw在 raw > 32767 时属于实现定义行为(C17 及以前),虽然 GCC/Clang 上结果符合预期,但它不是标准保证的。异或再减这一招算出来的值一定落在目标类型范围内,转换就是完全良定义的。验算一下:0xF6C(12 位表示的 -148)→0xF6C ^ 0x800 = 0x76C = 1900,1900 - 2048 = -148✅。
3.5 补偿公式:请不要自己"优化"
Bosch 在手册 4.2.3 节明说了:强烈建议用官方 API;如果你要自己写,可以用下面这段代码,风险自负。
我的态度很明确:这段公式一个字符都别改,照抄。 理由:
- 里面全是移位和魔数(
>>11、<<35、3125、419430400…),是 Bosch 按传感器物理模型推出来的定点算法,你看不出它在干什么,也就不可能"优化"对; - 它已经处理好了溢出边界:压力那段用 64 位中间量,湿度那段最后做了
0 ~ 419430400的钳位(对应 0~100 %RH); - 唯一允许的改动是把全局变量换成参数——因为原版用了一堆全局
dig_T1、t_fine,多实例(比如你挂两颗 BME280)会互相打架。
我做的改动只有:dig_T1 → c->dig_T1,t_fine 从全局变量变成传入/传出参数。运算式的每个符号都没动。为了确认这一点,我把原版(全局变量版)和改造版编到一起,用 20 万组随机校准值 + 随机 ADC 值跑对比,两者输出逐位一致——建议你改任何"手册代码"时都这么验一遍,比肉眼比对靠谱。
/* ---------- 补偿公式:手册 4.2.3 原文,只把全局变量换成入参 ---------- */
typedef int32_t BME280_S32_t;
typedef uint32_t BME280_U32_t;
typedef int64_t BME280_S64_t;
/* 返回值单位 0.01 °C:5123 表示 51.23 °C。顺带算出 t_fine 给压力/湿度用 */
static BME280_S32_t bme280_compensate_T_int32(const bme280_calib_t *c,
BME280_S32_t adc_T,
BME280_S32_t *t_fine)
{
BME280_S32_t var1, var2, T;
var1 = ((((adc_T >> 3) - ((BME280_S32_t)c->dig_T1 << 1))) * ((BME280_S32_t)c->dig_T2)) >> 11;
var2 = (((((adc_T >> 4) - ((BME280_S32_t)c->dig_T1)) * ((adc_T >> 4) - ((BME280_S32_t)c->dig_T1))) >> 12) *
((BME280_S32_t)c->dig_T3)) >> 14;
*t_fine = var1 + var2;
T = (*t_fine * 5 + 128) >> 8;
return T;
}
/* 返回 Q24.8 格式的 Pa:24674867 表示 24674867/256 = 96386.2 Pa */
static BME280_U32_t bme280_compensate_P_int64(const bme280_calib_t *c,
BME280_S32_t adc_P,
BME280_S32_t t_fine)
{
BME280_S64_t var1, var2, p;
var1 = ((BME280_S64_t)t_fine) - 128000;
var2 = var1 * var1 * (BME280_S64_t)c->dig_P6;
var2 = var2 + ((var1 * (BME280_S64_t)c->dig_P5) << 17);
var2 = var2 + (((BME280_S64_t)c->dig_P4) << 35);
var1 = ((var1 * var1 * (BME280_S64_t)c->dig_P3) >> 8) + ((var1 * (BME280_S64_t)c->dig_P2) << 12);
var1 = (((((BME280_S64_t)1) << 47) + var1)) * ((BME280_S64_t)c->dig_P1) >> 33;
if (var1 == 0) {
return 0; /* 避免除零 —— 手册原文就有这一句,别删 */
}
p = 1048576 - adc_P;
p = (((p << 31) - var2) * 3125) / var1;
var1 = (((BME280_S64_t)c->dig_P9) * (p >> 13) * (p >> 13)) >> 25;
var2 = (((BME280_S64_t)c->dig_P8) * p) >> 19;
p = ((p + var1 + var2) >> 8) + (((BME280_S64_t)c->dig_P7) << 4);
return (BME280_U32_t)p;
}
/* 返回 Q22.10 格式的 %RH:47445 表示 47445/1024 = 46.333 %RH */
static BME280_U32_t bme280_compensate_H_int32(const bme280_calib_t *c,
BME280_S32_t adc_H,
BME280_S32_t t_fine)
{
BME280_S32_t v_x1_u32r;
v_x1_u32r = (t_fine - ((BME280_S32_t)76800));
v_x1_u32r = (((((adc_H << 14) - (((BME280_S32_t)c->dig_H4) << 20) - (((BME280_S32_t)c->dig_H5) * v_x1_u32r)) +
((BME280_S32_t)16384)) >> 15) * (((((((v_x1_u32r * ((BME280_S32_t)c->dig_H6)) >> 10) * (((v_x1_u32r *
((BME280_S32_t)c->dig_H3)) >> 11) + ((BME280_S32_t)32768))) >> 10) + ((BME280_S32_t)2097152)) *
((BME280_S32_t)c->dig_H2) + 8192) >> 14));
v_x1_u32r = (v_x1_u32r - (((((v_x1_u32r >> 15) * (v_x1_u32r >> 15)) >> 7) * ((BME280_S32_t)c->dig_H1)) >> 4));
v_x1_u32r = (v_x1_u32r < 0 ? 0 : v_x1_u32r);
v_x1_u32r = (v_x1_u32r > 419430400 ? 419430400 : v_x1_u32r);
return (BME280_U32_t)(v_x1_u32r >> 12);
}
三个必须看懂的点(不用看懂公式,看懂这三点就够用):
t_fine是三者的纽带:温度补偿算出t_fine,压力和湿度补偿都要用它。所以顺序不能变:先算温度,再算压力和湿度。有人为了"只要气压"就跳过温度补偿,结果t_fine = 0,气压直接错到姥姥家。- 返回值都不是最终单位,得自己再除一次:
- 温度:
0.01 °C→ 除以 100 - 气压:Q24.8 定点的 Pa → 除以 256
- 湿度:Q22.10 定点的 %RH → 除以 1024
这一步漏了,就是本篇开头说的"单位搞混"的典型现场。(定点数表示法不熟的话,可以理解成"这个整数里最低 8 位是小数部分"。)
- 温度:
- 压力那段需要 64 位整数。ESP32/STM32 都是 32 位 MCU,但 GCC 支持
long long,int64_t能用,只是一次 64 位乘法会被展开成好几条指令。手册 Table 15 给了个参考:Cortex-M3 上 64 位压力补偿约 1400 个时钟周期,32 位版约 112 个。几秒采一次的场景,1400 周期完全无所谓,别为这个去用精度更差的 32 位版。
3.6 完整驱动:初始化
/* ===== bme280.h ===== */
#pragma once
#include <stdint.h>
#include "driver/i2c_master.h"
#include "esp_err.h"
#define BME280_ADDR_LOW 0x76 /* SDO 接 GND(模块默认,手册 6.2) */
#define BME280_ADDR_HIGH 0x77 /* SDO 接 VDDIO —— 和 BMP280 撞同一个地址 */
/* 过采样档位,数值就是写进 osrs_x[2:0] 的值(手册 Table 20/23/24) */
typedef enum {
BME280_OSRS_SKIP = 0,
BME280_OSRS_X1 = 1,
BME280_OSRS_X2 = 2,
BME280_OSRS_X4 = 3,
BME280_OSRS_X8 = 4,
BME280_OSRS_X16 = 5,
} bme280_osrs_t;
/* 校准参数,名字和手册 Table 16 一一对应,类型也照抄,不能想当然改 */
typedef struct {
uint16_t dig_T1; int16_t dig_T2; int16_t dig_T3;
uint16_t dig_P1; int16_t dig_P2; int16_t dig_P3; int16_t dig_P4;
int16_t dig_P5; int16_t dig_P6; int16_t dig_P7; int16_t dig_P8; int16_t dig_P9;
uint8_t dig_H1; int16_t dig_H2; uint8_t dig_H3;
int16_t dig_H4; int16_t dig_H5; int8_t dig_H6;
} bme280_calib_t;
typedef struct {
i2c_master_dev_handle_t dev;
bme280_calib_t cal;
int32_t t_fine; /* 温度补偿的中间量,压力/湿度都要用它 */
uint8_t osrs_t, osrs_p, osrs_h;
uint32_t meas_delay_ms; /* 按手册 9.1 算出来的最长测量时间 */
} bme280_t;
esp_err_t bme280_init(i2c_master_bus_handle_t bus, uint16_t addr, bme280_t *s);
esp_err_t bme280_read_forced(bme280_t *s, float *temp_c, float *press_pa, float *rh);
float bme280_altitude_m(float press_pa, float sea_level_pa);
/* ===== bme280.c 第 1 段:寄存器定义 + 底层读写 + 测量时长 ===== */
#include <string.h>
#include <math.h>
#include "bme280.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
static const char *TAG = "bme280";
#define BME280_I2C_HZ 100000
#define BME280_TIMEOUT_MS 100
#define BME280_REG_ID 0xD0
#define BME280_REG_RESET 0xE0
#define BME280_REG_CALIB00 0x88 /* 0x88~0xA1,26 字节 */
#define BME280_REG_CALIB26 0xE1 /* 0xE1~0xE7,7 字节 */
#define BME280_REG_CTRL_HUM 0xF2
#define BME280_REG_STATUS 0xF3
#define BME280_REG_CTRL_MEAS 0xF4
#define BME280_REG_CONFIG 0xF5
#define BME280_REG_PRESS_MSB 0xF7 /* 0xF7~0xFE,8 字节数据区 */
#define BME280_CHIP_ID 0x60 /* 手册 5.4.1;BMP280 是 0x58,BME680 是 0x61 */
#define BME280_RESET_WORD 0xB6
#define BME280_MODE_FORCED 0x01
#define BME280_STATUS_MEASURING 0x08 /* bit3 */
#define BME280_STATUS_IM_UPDATE 0x01 /* bit0 */
static esp_err_t bme280_rd(bme280_t *s, uint8_t reg, uint8_t *buf, size_t len)
{
/* 先写寄存器地址,再重复起始条件读回 —— 寄存器型器件的标准姿势 */
return i2c_master_transmit_receive(s->dev, ®, 1, buf, len, BME280_TIMEOUT_MS);
}
static esp_err_t bme280_wr(bme280_t *s, uint8_t reg, uint8_t val)
{
const uint8_t buf[2] = { reg, val };
return i2c_master_transmit(s->dev, buf, sizeof(buf), BME280_TIMEOUT_MS);
}
static uint32_t bme280_meas_delay_ms(const bme280_t *s)
{
/* t_measure,max = 1.25 + 2.3·osrs_t + (2.3·osrs_p + 0.575) + (2.3·osrs_h + 0.575) [ms]
全程用 µs 做整数运算,最后向上取整到 ms —— 不引入浮点,MCU 上更省事 */
static const uint16_t mul[6] = { 0, 1, 2, 4, 8, 16 };
uint32_t us = 1250;
us += 2300u * mul[s->osrs_t];
if (s->osrs_p != BME280_OSRS_SKIP) { us += 2300u * mul[s->osrs_p] + 575u; }
if (s->osrs_h != BME280_OSRS_SKIP) { us += 2300u * mul[s->osrs_h] + 575u; }
return (us + 999u) / 1000u;
}
💡
bme280_rd()用的是i2c_master_transmit_receive()——一次调用里"写寄存器地址 → 重复起始 → 读数据"。别拆成transmit()+receive()两次调用:那样中间会插入一个 STOP 条件,虽然 BME280 手册说停止条件也行(6.2.2 节写的是 “either a stop or a repeated start”),但多设备总线上多一个 STOP 就多一个被别人抢占的窗口,而且这是所有寄存器型器件的通用写法,养成习惯不吃亏。
初始化函数,注意它的六步顺序一步都不能调换:
/* ===== bme280.c 第 2 段:初始化 ===== */
esp_err_t bme280_init(i2c_master_bus_handle_t bus, uint16_t addr, bme280_t *s)
{
if (bus == NULL || s == NULL) {
return ESP_ERR_INVALID_ARG;
}
if (addr != BME280_ADDR_LOW && addr != BME280_ADDR_HIGH) {
ESP_LOGE(TAG, "地址只能是 0x76 或 0x77,你填的是 0x%02X", addr);
return ESP_ERR_INVALID_ARG;
}
memset(s, 0, sizeof(*s));
/* 手册 3.5.1 "weather monitoring":温/压/湿都 ×1、滤波关、强制模式 */
s->osrs_t = BME280_OSRS_X1;
s->osrs_p = BME280_OSRS_X1;
s->osrs_h = BME280_OSRS_X1;
i2c_device_config_t dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = addr,
.scl_speed_hz = BME280_I2C_HZ,
};
esp_err_t err = i2c_master_bus_add_device(bus, &dev_cfg, &s->dev);
if (err != ESP_OK) {
ESP_LOGE(TAG, "挂设备失败: %s", esp_err_to_name(err));
return err;
}
/* 1) 先验明正身:chip id 必须是 0x60 */
uint8_t id = 0;
err = bme280_rd(s, BME280_REG_ID, &id, 1);
if (err != ESP_OK) {
ESP_LOGE(TAG, "读 chip id 失败: %s(地址 0x%02X 上没人应答?)",
esp_err_to_name(err), addr);
goto fail;
}
if (id != BME280_CHIP_ID) {
ESP_LOGE(TAG, "chip id = 0x%02X,不是 BME280(0x60)。"
"0x58 = BMP280(没有湿度),0x61 = BME680(寄存器完全不同)", id);
err = ESP_ERR_NOT_FOUND;
goto fail;
}
/* 2) 软复位,保证从已知状态开始(手册 5.4.2:写 0xB6) */
err = bme280_wr(s, BME280_REG_RESET, BME280_RESET_WORD);
if (err != ESP_OK) { goto fail; }
vTaskDelay(pdMS_TO_TICKS(10)); /* 手册 Table 1:tstartup 典型 2 ms,给足 10 ms */
/* 3) 等 NVM 里的校准值拷进镜像寄存器(status.im_update == 0) */
uint8_t status = BME280_STATUS_IM_UPDATE;
for (int i = 0; i < 10 && (status & BME280_STATUS_IM_UPDATE); i++) {
err = bme280_rd(s, BME280_REG_STATUS, &status, 1);
if (err != ESP_OK) { goto fail; }
if (status & BME280_STATUS_IM_UPDATE) { vTaskDelay(pdMS_TO_TICKS(10)); }
}
if (status & BME280_STATUS_IM_UPDATE) {
ESP_LOGE(TAG, "等 NVM 拷贝超时,status=0x%02X", status);
err = ESP_ERR_TIMEOUT;
goto fail;
}
/* 4) 读校准参数:两段地址,一段都不能少 */
uint8_t calib_a[26] = { 0 };
uint8_t calib_e[7] = { 0 };
err = bme280_rd(s, BME280_REG_CALIB00, calib_a, sizeof(calib_a));
if (err != ESP_OK) { goto fail; }
err = bme280_rd(s, BME280_REG_CALIB26, calib_e, sizeof(calib_e));
if (err != ESP_OK) { goto fail; }
bme280_parse_calib(&s->cal, calib_a, calib_e);
if (s->cal.dig_T1 == 0 || s->cal.dig_P1 == 0) {
/* 出厂就烧好的值不可能是 0,读成 0 说明校准根本没读到 */
ESP_LOGE(TAG, "校准参数异常 dig_T1=%u dig_P1=%u,别往下算了",
s->cal.dig_T1, s->cal.dig_P1);
err = ESP_ERR_INVALID_RESPONSE;
goto fail;
}
/* 5) config:关 IIR 滤波 + 关 3 线 SPI(强制模式下 t_sb 无所谓)。
手册 5.4.6:normal 模式下写 config 可能被忽略,所以要趁复位后还在 sleep 时写 */
err = bme280_wr(s, BME280_REG_CONFIG, 0x00);
if (err != ESP_OK) { goto fail; }
/* 6) ctrl_hum 先写好(真正生效要等下一次写 ctrl_meas,手册 5.4.3) */
err = bme280_wr(s, BME280_REG_CTRL_HUM, s->osrs_h);
if (err != ESP_OK) { goto fail; }
s->meas_delay_ms = bme280_meas_delay_ms(s);
ESP_LOGI(TAG, "BME280 就绪:dig_T1=%u dig_P1=%u,单次测量最长 %u ms",
s->cal.dig_T1, s->cal.dig_P1, (unsigned)s->meas_delay_ms);
return ESP_OK;
fail:
i2c_master_bus_rm_device(s->dev);
s->dev = NULL;
return err;
}
这里有几个"看着啰嗦但都是血泪"的设计:
goto fail不是坏味道。 中间任何一步失败,都要把已经挂上去的 device 摘掉、句柄置 NULL,否则总线上会留一个"僵尸设备",下次再init就可能报资源不足。C 语言里做这种"多步初始化 + 统一清理",goto是最干净的写法(Linux 内核满地都是)。dig_T1 == 0 || dig_P1 == 0的检查是我自己加的,手册没写。理由很简单:这两个值出厂烧录,物理上不可能是 0;读成 0 只有一个解释——校准根本没读进来。与其让上层收到0.00 °C / 0 Pa去猜,不如在这里就报错。这种"用领域知识做的自检",比任何assert都值钱。- 打印
dig_T1、dig_P1到日志:换一块模块、或者怀疑读错了,一眼就能看出来。真实模块上这俩通常是 2700029000 和 3500038000 这种量级;如果打出来是65535或0,别怀疑了,就是读的问题。 - 初始化里的
ESP_LOGI顺便把算出来的meas_delay_ms打出来——把"我算了什么"暴露给自己看,是嵌入式调试最省时间的习惯之一。
3.7 完整驱动:一次强制模式测量
/* ===== bme280.c 第 3 段:一次 forced 测量 ===== */
esp_err_t bme280_read_forced(bme280_t *s, float *temp_c, float *press_pa, float *rh)
{
if (s == NULL || s->dev == NULL || temp_c == NULL || press_pa == NULL || rh == NULL) {
return ESP_ERR_INVALID_ARG;
}
/* 1) 先 ctrl_hum 再 ctrl_meas —— 顺序反了湿度设置不生效(手册 5.4.3)。
每次都重写一遍,是为了万一芯片被上电复位过,配置还能自动补回来 */
esp_err_t err = bme280_wr(s, BME280_REG_CTRL_HUM, s->osrs_h);
if (err != ESP_OK) {
ESP_LOGE(TAG, "写 ctrl_hum 失败: %s", esp_err_to_name(err));
return err;
}
const uint8_t ctrl_meas = (uint8_t)((s->osrs_t << 5) | (s->osrs_p << 2) | BME280_MODE_FORCED);
err = bme280_wr(s, BME280_REG_CTRL_MEAS, ctrl_meas);
if (err != ESP_OK) {
ESP_LOGE(TAG, "写 ctrl_meas 失败: %s", esp_err_to_name(err));
return err;
}
/* 2) 先睡够手册算出来的最长测量时间,再以 status.measuring 为准。
为什么两步?ESP-IDF 默认 tick = 100 Hz,vTaskDelay 分辨率只有 10 ms,
"睡刚好 10 ms"可能只睡了 9 ms —— 所以最后必须查状态位,不能只靠延时。 */
vTaskDelay(pdMS_TO_TICKS(s->meas_delay_ms));
uint8_t status = BME280_STATUS_MEASURING;
for (int i = 0; i < 20 && (status & BME280_STATUS_MEASURING); i++) {
err = bme280_rd(s, BME280_REG_STATUS, &status, 1);
if (err != ESP_OK) {
ESP_LOGE(TAG, "读 status 失败: %s", esp_err_to_name(err));
return err;
}
if (status & BME280_STATUS_MEASURING) { vTaskDelay(pdMS_TO_TICKS(10)); }
}
if (status & BME280_STATUS_MEASURING) {
ESP_LOGE(TAG, "等测量完成超时(最多等了 200 ms),status=0x%02X", status);
return ESP_ERR_TIMEOUT;
}
/* 3) 8 个数据寄存器一次 burst 读完(手册 4.1:分几次读可能读到不一致的数据) */
uint8_t d[8] = { 0 };
err = bme280_rd(s, BME280_REG_PRESS_MSB, d, sizeof(d));
if (err != ESP_OK) {
ESP_LOGE(TAG, "读数据寄存器失败: %s", esp_err_to_name(err));
return err;
}
/* 4) 拼 ADC 原始值:压力/温度 20 位,湿度 16 位 */
int32_t adc_p = (int32_t)(((uint32_t)d[0] << 12) | ((uint32_t)d[1] << 4) | ((uint32_t)d[2] >> 4));
int32_t adc_t = (int32_t)(((uint32_t)d[3] << 12) | ((uint32_t)d[4] << 4) | ((uint32_t)d[5] >> 4));
int32_t adc_h = (int32_t)(((uint32_t)d[6] << 8) | (uint32_t)d[7]);
if (adc_t == 0x80000) { /* 复位值,说明这一路被 skip 了或者压根没测 */
ESP_LOGE(TAG, "温度 ADC = 0x80000(skipped),检查 osrs_t 是不是写成 0 了");
return ESP_ERR_INVALID_RESPONSE;
}
/* 5) 补偿:温度必须第一个算,它产出的 t_fine 是另外两个的输入 */
int32_t T = bme280_compensate_T_int32(&s->cal, adc_t, &s->t_fine);
uint32_t P = bme280_compensate_P_int64(&s->cal, adc_p, s->t_fine);
uint32_t H = bme280_compensate_H_int32(&s->cal, adc_h, s->t_fine);
*temp_c = (float)T / 100.0f; /* 0.01 °C → °C */
*press_pa = (float)P / 256.0f; /* Q24.8 Pa → Pa */
*rh = (float)H / 1024.0f; /* Q22.10 % → %RH */
return ESP_OK;
}
ADC 拼装那三行,是新手第二个高频翻车点,单独拆开讲:
数据区 8 字节(0xF7 起):
d[0] d[1] d[2] d[3] d[4] d[5] d[6] d[7]
press_msb press_lsb press_xlsb temp_msb temp_lsb temp_xlsb hum_msb hum_lsb
┌──────┬────────┬─────────┐ ┌──────┬────────┬─────────┐ ┌──────┬──────┐
│ 8 位 │ 8 位 │ 高4位有效│ │ 8 位 │ 8 位 │ 高4位有效│ │ 8 位 │ 8 位 │
└──────┴────────┴─────────┘ └──────┴────────┴─────────┘ └──────┴──────┘
20 位压力 20 位温度 16 位湿度
adc_p = d[0]<<12 | d[1]<<4 | d[2]>>4 ← xlsb 只有高 4 位有效,所以要 >>4
adc_h = d[6]<<8 | d[7] ← 湿度只有 2 字节,没有 xlsb
d[2] >> 4 这个右移特别容易写掉,写掉了气压就会差出十几倍。另外注意这里全程用 uint32_t 移位再转 int32_t:d[0] << 12 里 d[0] 是 uint8_t,会先整型提升成 int,0xFF << 12 还没溢出所以侥幸没事——但养成"移位前先转成明确宽度的无符号类型"的习惯,能避开一整类 UB。(这条在《C语言_07 位运算详解》里我专门讲过。)
3.8 气压 → 海拔:能算,但别太当真
/* ---------- 气压 → 海拔(BMP180 手册 3.6 的国际气压高度公式) ---------- */
float bme280_altitude_m(float press_pa, float sea_level_pa)
{
if (press_pa <= 0.0f || sea_level_pa <= 0.0f) {
return 0.0f;
}
return 44330.0f * (1.0f - powf(press_pa / sea_level_pa, 1.0f / 5.255f));
}
公式来自 Bosch BMP180 手册 3.6 节的"国际气压高度公式":
altitude[m] = 44330 × ( 1 − (p / p0)^(1/5.255) )
p0 = 海平面气压,标准值 101325 Pa(= 1013.25 hPa)
代几个数验算一下(这三个数我跑过):
p = 101325 Pa → 0.00 m
p = 100000 Pa → 110.90 m
p = 95000 Pa → 540.42 m
⚠️ 这个"海拔"的绝对值基本不能用。 因为
p0用的是标准大气压,而真实的海平面气压每天都在变(台风天能掉 30 hPa 以上)。BMP180 手册自己也写了:海平面 1 hPa 的气压变化 ≈ 8.43 m。也就是说,天气变化带来的误差轻松上百米。
那它有什么用? 用在相对变化上:气压计算高度的分辨率非常高(BME280 相对精度 ±0.12 hPa ≈ ±1 m),所以"电梯上升了几层""无人机相对起飞点升了多少米"这类短时间内的相对高度差,它测得很准。用绝对海拔去标高程,那是想多了。
四、整合:一条总线,两个设备
前面两节各自都能跑了,现在把它们放到同一条 I²C 上。这一节才是本篇的标题所在。
4.1 地址不冲突,是整合的前提
I²C 靠地址区分设备,所以第一件事是把总线上所有设备的地址列出来,确认没有两个人叫同一个名字:
| 设备 | 可选地址 | 怎么改 |
|---|---|---|
| SHT30 | 0x44 / 0x45 | ADDR 脚接 GND / 接 VDD |
| BME280 | 0x76 / 0x77 | SDO 脚接 GND / 接 VDDIO |
| SSD1306 OLED(顺带一提) | 0x3C / 0x3D | 模块背面通常有个 0Ω 电阻位 |
这三个默认地址两两不冲突,所以同一条总线上挂 SHT30(0x44) + BME280(0x76) + OLED(0x3C) 是完全没问题的,不需要任何额外硬件。
真会冲突的是这两种情况:
- 两颗同型号:两个 BME280 只能一个 SDO 接地(0x76)、一个接 VDDIO(0x77);想挂第三个?I²C 层面没辙了,得上 I²C 多路复用器(TCA9548A 之类)或者换一路 I²C 端口。
- BME280(0x77) 和 BMP280(0x77):手册里明确写了 BME280 的 0x77 “is the same as BMP280’s I²C address”。如果你同时挂了这两颗又都默认接 VDDIO,那就是两个人抢同一个名字,总线上会出现两个从机同时应答,读出来的数据是两者电平"线与"的结果——这种故障的现象是"数据看起来像随机数但又不完全随机",特别难查。
调试期一定要先扫一遍总线,让板子自己告诉你上面有谁:
/* 调试期先扫一遍总线:谁在、谁不在,一目了然 */
static void i2c_bus_scan(i2c_master_bus_handle_t bus)
{
ESP_LOGI(TAG, "开始扫描 I2C 总线...");
for (uint16_t addr = 0x08; addr <= 0x77; addr++) {
if (i2c_master_probe(bus, addr, 50) == ESP_OK) {
ESP_LOGI(TAG, " 发现设备:0x%02X", addr);
}
}
ESP_LOGI(TAG, "扫描结束");
}
正常应该打出来:
I (312) env: 开始扫描 I2C 总线...
I (352) env: 发现设备:0x44 ← SHT30
I (378) env: 发现设备:0x76 ← BME280
I (455) env: 扫描结束
如果一个都扫不到,99% 是硬件:上拉没焊、VCC 没接、SDA/SCL 接反。这时候再怎么改代码都没用。
如果扫描扫出一大堆地址(比如 0x08~0x77 全中),那是 SDA 一直被拉低,主机把每一个地址都当成"有应答"了——去查是不是有芯片把 SDA 咬死了。
💡 地址范围为什么从 0x08 开始、到 0x77 结束?I²C 规范里 0x00
0x07 和 0x780x7F 是保留地址(广播呼叫、10 位地址前缀等),普通器件不会用。
4.2 一条 bus,两个 device
ESP-IDF v5.x 的新驱动天生就是为这个设计的——总线和设备是分开的两层:
i2c_new_master_bus() 一次,配 GPIO21/22
│
▼
i2c_master_bus_handle_t bus
│
┌──────────┴───────────┐
▼ ▼
add_device(0x44) add_device(0x76)
│ │
▼ ▼
dev_handle(SHT30) bme280_t.dev(BME280)
│ │
i2c_master_transmit() i2c_master_transmit_receive()
收发时驱动自动帮你发对应的地址字节,你再也不用手动拼地址
对比一下老驱动(driver/i2c.h):那时候地址是每次传输时手动塞进命令链表的(i2c_master_write_byte(cmd, (addr << 1) | I2C_MASTER_WRITE, true)),非常容易在某一处写错、或者忘了左移。新驱动把地址绑在 device handle 上,一次配对,终身受用,这是实实在在的进步。
4.3 单位统一:在结构体这一层就把话说死
整合最容易被忽略、但后患最大的是单位。我的做法是定义一个采样结构体,单位写在注释里、并且全项目只认这一套:
/* 采样结果:单位在这里就统一死,后面谁都别再自己换算 */
typedef struct {
float temp_c; /* °C —— 主值取 BME280 */
float rh; /* %RH —— 主值取 SHT30(湿度精度更高) */
float press_pa; /* Pa —— 存 Pa,打印时才 /100 变 hPa */
float altitude_m; /* m —— 由气压推算,只在"相对变化"上有意义 */
float temp_diff; /* 两颗传感器的温差,用来互检 */
bool sht30_ok;
bool bme280_ok;
} env_sample_t;
三条我踩出来的规矩:
- 内部一律存 SI 基本单位(Pa 而不是 hPa,°C 而不是 0.01 °C),只在打印/上报的最后一刻转换。混着存,迟早有人把 hPa 当 Pa 加起来。
- 每一路都带一个
xxx_ok标志。传感器读失败时,temp_c里躺着的是上一次的值还是 0?没有ok标志谁也说不清。有了它,上层能明确区分"读到了 0 °C"和"没读到"。 - 结构体别塞成大杂烩。这里只有 7 个成员、28 字节左右,可以直接按值传进 FreeRTOS 队列(
xQueueSend是拷贝语义),不用动态分配、不用传指针,省掉一整类生命周期问题。(成员顺序按float在前bool在后排,也顺手避开了结构体填充浪费,这块见《C语言_02 struct 内存对齐详解》。)
4.4 合理性校验:让错值死在采样任务里
数据出了采样任务就很难再判断真假了,所以校验必须放在这里。我分两层:
/* 合理性校验:先按各自手册的量程卡,再做交叉验证 */
static bool env_sanity_check(env_sample_t *e)
{
bool ok = true;
if (e->sht30_ok) {
/* SHT3x-DIS 手册:温度 -40~125 °C,湿度 0~100 %RH */
if (e->temp_c < -40.0f || e->temp_c > 125.0f || e->rh < 0.0f || e->rh > 100.0f) {
ESP_LOGW(TAG, "SHT30 读数出量程:%.2f °C / %.2f %%RH", e->temp_c, e->rh);
e->sht30_ok = false;
ok = false;
}
}
if (e->bme280_ok) {
/* BME280 手册:气压 300~1100 hPa = 30000~110000 Pa */
if (e->press_pa < 30000.0f || e->press_pa > 110000.0f) {
ESP_LOGW(TAG, "BME280 气压出量程:%.0f Pa —— 十有八九是没读校准参数",
e->press_pa);
e->bme280_ok = false;
ok = false;
}
}
/* 交叉验证:两颗芯片挨在一起,温差超过 3 °C 说明至少有一个不对劲
(BME280 贴着 MCU 会被自热抬高 1~2 °C,所以阈值别卡太死) */
if (e->sht30_ok && e->bme280_ok && fabsf(e->temp_diff) > 3.0f) {
ESP_LOGW(TAG, "两路温差 %.2f °C,怀疑自热或某一路读错", e->temp_diff);
ok = false;
}
return ok;
}
- 第一层:量程校验。 阈值直接抄手册的 Specified Range,不要自己拍脑袋。前面那个"没读校准 → 气压 0 Pa"的经典错误,会被
< 30000这条当场抓住。 - 第二层:交叉验证。 这是"挂两颗传感器"额外送你的礼物:两颗芯片相距几厘米,温度不可能差很多。差太多,说明至少有一颗在骗你。
⚠️ 阈值为什么定 3 °C 而不是 0.5 °C?因为 BME280 的自热是真实存在的:它离 MCU 近、又是在测自己封装内部的温度,实测比环境温度高个 1~2 °C 很常见(这也是我为什么用 SHT30 做湿度主值、BME280 只做气压主值的原因之一)。阈值定太死,你会天天收到假警报,最后就把校验关掉了——能被关掉的校验等于没有校验。
4.5 采样任务:把上面这些串起来
/* ===== env_sensors.c —— 一条 I²C 总线挂 SHT30 + BME280 ===== */
#include <stdbool.h>
#include <math.h>
#include "sht30.h"
#include "bme280.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
static const char *TAG = "env";
#define I2C_SDA_IO GPIO_NUM_21
#define I2C_SCL_IO GPIO_NUM_22
#define SEA_LEVEL_PA 101325.0f /* 标准大气压,海拔换算的基准 */
#define SAMPLE_PERIOD_MS 2000
/* env_sample_t / env_sanity_check() / i2c_bus_scan() 见 4.1~4.4 节 */
void env_task(void *arg)
{
(void)arg;
i2c_master_bus_handle_t bus = NULL;
i2c_master_dev_handle_t sht = NULL;
bme280_t bme = { 0 };
i2c_master_bus_config_t bus_cfg = {
.i2c_port = I2C_NUM_0,
.sda_io_num = I2C_SDA_IO,
.scl_io_num = I2C_SCL_IO,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7, /* 官方例程的典型值 */
.flags.enable_internal_pullup = true, /* 只能救急,正式板焊 4.7 kΩ 外部上拉 */
};
esp_err_t err = i2c_new_master_bus(&bus_cfg, &bus);
if (err != ESP_OK) {
ESP_LOGE(TAG, "建总线失败: %s", esp_err_to_name(err));
vTaskDelete(NULL);
return;
}
i2c_bus_scan(bus);
/* 同一条 bus,挂两个 device:地址不同,互不干扰 */
bool have_sht = (sht30_add(bus, SHT30_ADDR_LOW, &sht) == ESP_OK);
bool have_bme = (bme280_init(bus, BME280_ADDR_LOW, &bme) == ESP_OK);
if (!have_sht && !have_bme) {
ESP_LOGE(TAG, "两颗都没挂上,检查接线和上拉,先别急着调软件");
}
TickType_t last = xTaskGetTickCount();
for (;;) {
env_sample_t e = { 0 };
float sht_t = 0.0f, sht_rh = 0.0f;
float bme_t = 0.0f, bme_p = 0.0f, bme_rh = 0.0f;
/* 顺序读,不并发 —— 同一条总线上的 i2c_master_* 不是线程安全的,
两个任务同时读同一条 bus,迟早给你一个错乱的波形 */
if (have_sht && sht30_read(sht, &sht_t, &sht_rh) == ESP_OK) {
e.sht30_ok = true;
e.temp_c = sht_t;
e.rh = sht_rh;
}
if (have_bme && bme280_read_forced(&bme, &bme_t, &bme_p, &bme_rh) == ESP_OK) {
e.bme280_ok = true;
e.press_pa = bme_p;
e.altitude_m = bme280_altitude_m(bme_p, SEA_LEVEL_PA);
e.temp_c = bme_t; /* 温度主值用 BME280 */
if (!e.sht30_ok) { e.rh = bme_rh; } /* SHT30 挂了才用 BME280 的湿度 */
}
if (e.sht30_ok && e.bme280_ok) {
e.temp_diff = sht_t - bme_t;
}
if (env_sanity_check(&e)) {
ESP_LOGI(TAG, "T=%.2f °C RH=%.2f %% P=%.2f hPa H=%.1f m (ΔT=%.2f °C)",
e.temp_c, e.rh, e.press_pa / 100.0f, e.altitude_m, e.temp_diff);
} else {
ESP_LOGW(TAG, "本轮数据可疑:sht30_ok=%d bme280_ok=%d",
(int)e.sht30_ok, (int)e.bme280_ok);
}
vTaskDelayUntil(&last, pdMS_TO_TICKS(SAMPLE_PERIOD_MS));
}
}
任务创建这么写(栈大小不是猜的,理由见下):
xTaskCreate(env_task, "env", 4096, NULL, 5, NULL);
四个"为什么这么写":
- 为什么顺序读,不开两个任务并发读? ESP-IDF 官方文档写得很清楚:
i2c_new_master_bus()本身是线程安全的,但其他 API(收发)不是——两个任务同时对同一条总线发起传输,行为不可预期。要真需要多任务访问,你得自己加一把互斥量(xSemaphoreCreateMutex())把每次"完整事务"包起来。对一个 2 秒采一次的项目,顺序读简单又够用,别为了并发而并发。(这和《项目实战_01》里"拆任务是为了结构清晰,不是为了性能"是同一个道理。) - 为什么用
vTaskDelayUntil而不是vTaskDelay?vTaskDelay(2000ms)是"干完活再睡 2 秒",周期会被采样耗时慢慢拖长(漂移);vTaskDelayUntil是"每 2 秒醒一次",周期恒定。做数据采集,要的是恒定周期。 - 栈给 4096 字节怎么来的? 这个任务里有几十字节的局部数组(
calib_a[26]、d[8]、rx[6])、若干float,加上ESP_LOGI里的%f格式化——printf系列的浮点格式化很吃栈,在 ESP32 上没有一两 KB 打不住。别抄"1024 就够"的说法,实测才算数:跑起来后加一句uxTaskGetStackHighWaterMark(NULL)看剩多少,剩得太多再往下调。 - 一颗传感器挂了,另一颗照常工作。
have_sht/have_bme两个标志把它们彻底解耦。整合最忌讳的就是"一个零件坏了整台机器停摆"。
⚠️ 想用 Deep Sleep 省电的话:ESP32 深睡醒来相当于复位,I²C 总线和两个 device 句柄都得重建(
i2c_new_master_bus+add_device+bme280_init全套重来)。BME280 每次init都要重新读 32 字节校准参数,这是必需的开销(除非你把它存进 RTC 慢速内存里带过 Deep Sleep——那是《项目实战_01》里boot_count用的招)。
五、新手必踩的 N 个坑
我把自己在这两个传感器上栽过的跟头、以及论坛里问得最多的"玄学报错"都列出来了。每一条都标了现象 → 根因 → 怎么确诊 → 怎么修。这节建议收藏(不是逼你关注啊,是真心建议你回头对照查错)。
| # | 坑 | 你看到的现象 | 根因 | 怎么确诊 / 怎么修 |
|---|---|---|---|---|
| 1 | I²C 地址焊错 / 写错 | i2c_master_probe 或读数据返回 ESP_FAIL,逻辑分析仪看到 SDA 上 NAK | 把 0x44 写成 0x88(那是器件地址的"7 位写法"和"8 位写法"混了),或者 ADDR 引脚电平接错导致地址变成 0x45 | 先 i2c_bus_scan() 扫一遍真实挂在总线上的地址;牢记 SHT30 是 7 位地址 0x44/0x45,i2c_master_bus_add_device 的 dev_addr 填 7 位值 |
| 2 | 忘了上拉电阻 | 总线偶尔读到 0xFF,或干脆 ESP_FAIL;同一块板子换根杜邦线又好一阵 | I²C 是开漏总线,SDA/SCL 必须外部 4.7kΩ 上拉到 VDD。很多开发板"恰好"内部有弱上拉所以能跑,一换长线或换板就崩 | 用万用表量 SDA/SCL 空闲时是不是高电平;没有就焊两个 4.7kΩ 上拉。ESP32 的 gpio_pullup 是弱上拉,不能当外接上拉用 |
| 3 | BME280 没读校准就直接算 | 温度恒为 0.00°C、气压 0.00 Pa、湿度 0.00%;或者数值乱跳 | dig_* 全是 0,补偿公式里乘法全 0。这正对应《项目实战_01》里"温度永远 0.00°C"那次事故 | 在 bme280_init 读校准后打一行 dig_T1 的值,正常应在几万量级(实测 27504);是 0 就说明校准没读到 |
| 4 | SHT30 没等测量完成就抢读 | 读到的两个值恒为 0x0000 或旧值,CRC 还对不上 | 发完 0x2400 后,芯片还要 12~15ms 才把数据算好。你在命令刚发完就 i2c_master_receive,读的是还没更新的缓冲 | 命令后 vTaskDelay(pdMS_TO_TICKS(30));别贪那 15ms,留点余量省得偶发读旧值 |
| 5 | 单位漏换算 / 换算公式写错 | 湿度显示 4000%、温度 2500℃、气压 88773 hPa 写成 88773 Pa | SHT30 要 /65535 再 *175-45;BME280 是 Q24.8 要 /256。把"raw 直接当物理量"是最典型的错 | 把公式和"raw=0x6666 应得 25.00°C / 40.00%RH"对照测一遍,差一个数量级就是少了 /65535 或 /256 |
| 6 | CRC 没校验,偶发错值 | 天气好好的,湿度每隔几小时蹦出一个 -3.5%RH 或 131.0°C | I²C 干扰让某字节翻转,没 CRC 就当真值用了。SHT30 每 2 字节带一个 CRC-8,正是为此 | 打开 sht30_read 里的 CRC 校验;实测 CRC(0xFFFF)=0xAC、CRC(0x0000)=0x81,说明全 1 / 全 0 都能被 CRC 抓出来(全 1 往往就是 NAK 后的缓冲残留) |
| 7 | 读出来是 0xFF 因为 NAK | 一整帧都是 0xFF,日志满屏 data mismatch | NAK 之后 i2c_master_receive 读到的是总线"全高"的电平残留。0xFF 不是数据,是"没读着"的尸体 | 先判返回值再碰数据;任何 ESP_FAIL 都别继续解析。配合坑 1 的扫地址一起查 |
| 8 | BME280 校准参数符号/字节序读错 | 温度能凑合,湿度却出现负数或离谱大值 | dig_H4/dig_H5 是 12 位、跨字节、要符号扩展;dig_T1/dig_P1 是无符号,其余是有符号。读反了符号或大小端就完蛋 | 用 rd_s16_le/rd_u16_le/sign_extend_12 封装;打印一组 dig_H4/H5/H6 验证(实测 -148 / -4 / -30 这种量级才正常) |
💡 坑 3 和坑 5 是我最想强调的两个:因为它们不是"读不出",而是"读得出但全是错的"——新手最容易以为驱动写对了,结果把错的数值当真用了一整天。凡遇到"数值特别整、或对不上常识",第一反应就该怀疑校准有没有读和单位有没有换算。
六、动手练一练
光看不练假把式。下面三个练习特意安排了"先故意搞坏,再看现象",因为只有亲眼见过错误长什么样,真出问题时你才认得出来。
练习 1:故意把 SHT30 的地址写错,看看 NAK 长什么样
把 sht30_dev 创建时的 dev_addr 从 0x44 改成 0x46(一个总线上根本没人的地址):
i2c_device_config_t dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = 0x46, // ← 故意写错,本来是 0x44
.scl_speed_hz = 100000,
};
跑起来,预期你会看到:
I (1234) SHT30: i2c_master_bus_add_device ok
I (1240) SHT30: read err=0x105 (ESP_FAIL)
W (1240) ENV: SHT30 没响应,跳过本次读取
0x105 就是 ESP_FAIL。重点不是报错,而是报错后系统没崩——env_sanity_check 里 have_sht 没置位,日志只是 WARN,BME280 照常出气压。这就是整合该有的"降级"行为。
练习 2:故意跳过 BME280 校准,看气压有多离谱
在 bme280_init 里把 bme280_parse_calib() 这一行注释掉,再读一次。预期:
T = 0.00 °C
P = 0.00 Pa ← 不是 90000 左右,是 0
H = 0.00 %RH
全部归零,因为 dig_* 全是 0,补偿公式乘出来全是 0。把这行加回来,气压立刻回到 ~887 hPa 量级。这一下对比,比我说一百遍"校准很重要"都管用。
进阶作死:把
dig_T1单独读成 0 而其它正常,你会看到温度变成几十度甚至上百度的鬼值——这就对应坑 8 里"读错一个参数全盘皆输"。
练习 3:把 SHT30 的等待时间删掉,看偶发旧值
把 sht30_read 里 vTaskDelay(pdMS_TO_TICKS(30)) 删了,直接读。多数时候还"正常",但偶尔会读到 0x0000 或上一次的温湿度(CRC 这时往往校验失败打 WARN)。这就是坑 4——时序紧的时候时序 bug 最难抓,所以留 30ms 余量比卡 15ms 极限稳得多。
练完之后,试着回答自己三个问题(检验真懂了没)
- 如果我把 SHT30 的 ADDR 引脚接 VDD,它的 7 位地址变成多少?
i2c_bus_scan会扫到几个设备? - BME280 的
ctrl_meas里osrs_t=×2、osrs_p=×16,bme280_meas_delay_ms算出来该是多少毫秒?(提示:第 三 节的延迟公式) - 为什么
env_sample_t里温度存int16_t(单位 0.01°C)而不是float?什么场景下你会改用float?
这三个问题没有标准答案贴,但你能答上来,就算真正把"整合"这件事吃透了。答不上来的,回头翻 二、三、四 节对应位置——它们不是装饰,是为了回答这些问题才写的。
小结
把两个 I²C 传感器"整合"到一起,听起来是"多挂一个设备"的小事,落到代码里其实是五件不同的事:共用一条总线、各自独立设备句柄、各自独立的错误处理、统一单位的采样结果、以及一眼能看出的合理性校验。这五件事做全了,才叫"整合",否则只是"两个例程拼一起"。
回顾一下本篇的核心:
- SHT30 是"命令式"传感器:发
0x2400、等 30ms、读 6 字节、每 2 字节带 CRC-8(多项式0x31、初值0xFF)。转换记住/65535再套线性公式。 - BME280 是"寄存器式"传感器:先验证
chip_id == 0x60、读 32 字节校准参数(这一步漏了数值全盘归零)、用官方 64 位补偿公式算出 0.01°C、Q24.8 气压、Q22.10 湿度,再用气压反推海拔。 - 同总线整合靠
i2c_new_master_bus+ 两个i2c_master_bus_add_device,顺序读、固定周期、单颗挂掉另一颗照常——这就是整合该有的"韧性"。 - 那些最坑的错,不是读不出,是读得出但全是错的:没读校准(归零)、单位漏换算(数值差一个数量级)、CRC 没校验(偶发鬼值)。
写驱动这事,技术点其实就那么些;真正的功力在"出错时你知道去哪查"。所以这篇里每一段代码我都把错误返回值和 sanity check 留着——它们不是冗余,是你半夜调不通时的救命绳。
下一篇我们聊聊另一个完全不同类型的传感器:MPU6050 六轴 IMU。它会用 I²C 的"突发读 FIFO"、要处理原始加速度/角速度的姿态解算——和温湿度这种"读个数就完事"的传感器完全不是一个玩法。我会从"为什么陀螺仪会漂、为什么需要加速度计来校正"这种最朴素的问题讲起,咱们继续一起啃。
下一篇:传感器_02_MPU6050六轴IMU姿态入门
更多推荐


所有评论(0)