适用人群:会点亮 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

三个模块,三种死法,而我当时的三个反应估计你也会有:

  1. “是不是传感器烧了?” —— 又买了两个,一模一样。(浪费了 30 块和三天)
  2. “是不是库不对?” —— 换了四五个 Arduino 库改到 ESP-IDF 上,还是错。
  3. “是不是 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 CBME280 的校准参数没读,全是 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²CI²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
量程-40125 °C、0100 %RH-4085 °C、0100 %RH、300~1100 hPa

⚠️ 两颗都能测温湿度,为什么还都要? 不是为了冗余好玩: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 的全部魔法

三条接线上的硬规矩,缺一个都会让你调一整天:

  1. 上拉电阻必须有,4.7 kΩ 接 3V3,SDA/SCL 各一个。很多模块板上已经带了(背面有两个小电阻),多个模块并联时上拉会越并越小,三个模块各带 4.7 kΩ 并起来只剩 1.6 kΩ,驱动能力不够的从机会拉不低电平。真挂多个模块时,留一个模块的上拉、其余的把板上上拉焊掉(或者干脆都拆掉,自己在总线上焊一对)。
  2. 地址选择脚不许悬空。SHT30 手册白纸黑字写着"The ADDR pin must not be left floating";BME280 手册也写着 SDO 悬空会导致"I²C address will be undefined"。悬空 = 地址随缘,你会看到"有时候能读到、有时候读不到"这种最难查的故障。
  3. BME280 的 CSB 必须接 VDDIO,否则它认为你要用 SPI,I²C 根本不理你。四线模块(只有 VCC/GND/SDA/SCL)板上已经接好了,六线的裸模块要自己接。

二、SHT30:命令式传感器的标准写法

2.1 一份 22 页的手册,你只需要五行

《Datasheet SHT3x-DIS》我第一次翻的时候完全懵。后来发现,写一个能跑的驱动,只需要下面这五条信息

我要知道的手册怎么说出处
地址是多少ADDR 接低 → 0x44(默认);接高 → 0x45不许悬空Table 8
怎么让它测一次单次测量,16 位命令。高重复性 + 关时钟拉伸 = 0x2400;开时钟拉伸版是 0x2C06Table 9
测一次要多久高重复性:典型 12.5 ms,最长 15 ms(2.4~5.5 V 供电,Table 4)Table 4
读回来什么格式6 字节:温度 MSB、温度 LSB、CRC,湿度 MSB、湿度 LSB、CRC4.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 °C0.00 %总线被短到地、或者从机一直不放数据
0x666625.00 °C40.00 %正常室温
0x800042.50 °C50.00 %正常(夏天贴着 MCU 就有可能)
0xFFFF130.00 °C100.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.hi2c_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。官方文档写得很清楚:-1 means 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 读出来能测什么买错了会怎样
BMP2800x58温度 + 气压(没有湿度湿度永远读出 0,你会以为湿度传感器坏了
BME2800x60温度 + 气压 + 湿度——
BME6800x61温湿压 + 气体(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 字节      只读

三个必须记住的坑点,全都写在手册里,但全都容易被略过:

  1. 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
  2. config(0xF5) 在 normal 模式下写可能被忽略(手册 5.4.6)。所以要趁复位后还在 sleep 模式时把它写好。
  3. 数据区 8 个字节必须一次 burst 读完(手册 4.1)。分几次读,可能读到"一半是上一次测量、一半是这一次"的混合数据。这个 bug 特别阴险:99% 的时候数据是对的,偶尔冒一个离谱值。

3.3 三种模式:为什么我选 forced

模式mode[1:0]行为适合谁
sleep00不测量,最省电(复位后就是这个状态)待机
forced0110测一次,自动回 sleep✅ 低速采样(几秒~几分钟一次),省电,时序可控
normal11按 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 / 0x89dig_T1 [7:0] / [15:8]unsigned short
0x8A / 0x8Bdig_T2signed short
0x8C / 0x8Ddig_T3signed short
0x8E / 0x8Fdig_P1unsigned short
0x90 ~ 0x9Fdig_P2 ~ dig_P9signed short × 8
0xA0(保留,跳过)
0xA1dig_H1unsigned char
0xE1 / 0xE2dig_H2signed short
0xE3dig_H3unsigned char
0xE4 / 0xE5[3:0]dig_H4 [11:4] / [3:0]signed short(12 位!)
0xE5[7:4] / 0xE6dig_H5 [3:0] / [11:4]signed short(12 位!)
0xE7dig_H6signed char

这张表里有三个致命细节,我第一次写的时候三个全踩了:

  1. 全部是小端:低字节在前。dig_T1 = calib[1] << 8 | calib[0],写反了温度就会变成天文数字。(不熟这块的话去看我《C语言_05 大端小端详解》。)
  2. 有符号和无符号混着来dig_T1dig_P1 是无符号,dig_T2dig_P2… 是有符号。全按无符号读,气压会算出几百 hPa 的离谱值——这是最经典的"能跑出数,但数是错的"bug。
  3. 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 = 19001900 - 2048 = -148 ✅。

3.5 补偿公式:请不要自己"优化"

Bosch 在手册 4.2.3 节明说了:强烈建议用官方 API;如果你要自己写,可以用下面这段代码,风险自负

我的态度很明确:这段公式一个字符都别改,照抄。 理由:

  • 里面全是移位和魔数(>>11<<353125419430400…),是 Bosch 按传感器物理模型推出来的定点算法,你看不出它在干什么,也就不可能"优化"对
  • 它已经处理好了溢出边界:压力那段用 64 位中间量,湿度那段最后做了 0 ~ 419430400 的钳位(对应 0~100 %RH);
  • 唯一允许的改动是把全局变量换成参数——因为原版用了一堆全局 dig_T1t_fine,多实例(比如你挂两颗 BME280)会互相打架。

我做的改动只有dig_T1c->dig_T1t_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);
}

三个必须看懂的点(不用看懂公式,看懂这三点就够用):

  1. t_fine 是三者的纽带:温度补偿算出 t_fine,压力和湿度补偿都要用它。所以顺序不能变:先算温度,再算压力和湿度。有人为了"只要气压"就跳过温度补偿,结果 t_fine = 0,气压直接错到姥姥家。
  2. 返回值都不是最终单位,得自己再除一次:
    • 温度:0.01 °C → 除以 100
    • 气压:Q24.8 定点的 Pa → 除以 256
    • 湿度:Q22.10 定点的 %RH → 除以 1024
      这一步漏了,就是本篇开头说的"单位搞混"的典型现场。(定点数表示法不熟的话,可以理解成"这个整数里最低 8 位是小数部分"。)
  3. 压力那段需要 64 位整数。ESP32/STM32 都是 32 位 MCU,但 GCC 支持 long longint64_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, &reg, 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_T1dig_P1 到日志:换一块模块、或者怀疑读错了,一眼就能看出来。真实模块上这俩通常是 2700029000 和 3500038000 这种量级;如果打出来是 655350,别怀疑了,就是读的问题。
  • 初始化里的 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_td[0] << 12d[0]uint8_t,会先整型提升成 int0xFF << 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 靠地址区分设备,所以第一件事是把总线上所有设备的地址列出来,确认没有两个人叫同一个名字

设备可选地址怎么改
SHT300x44 / 0x45ADDR 脚接 GND / 接 VDD
BME2800x76 / 0x77SDO 脚接 GND / 接 VDDIO
SSD1306 OLED(顺带一提)0x3C / 0x3D模块背面通常有个 0Ω 电阻位

这三个默认地址两两不冲突,所以同一条总线上挂 SHT30(0x44) + BME280(0x76) + OLED(0x3C) 是完全没问题的,不需要任何额外硬件。

真会冲突的是这两种情况:

  1. 两颗同型号:两个 BME280 只能一个 SDO 接地(0x76)、一个接 VDDIO(0x77);想挂第三个?I²C 层面没辙了,得上 I²C 多路复用器(TCA9548A 之类)或者换一路 I²C 端口。
  2. 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 规范里 0x000x07 和 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;

三条我踩出来的规矩:

  1. 内部一律存 SI 基本单位(Pa 而不是 hPa,°C 而不是 0.01 °C),只在打印/上报的最后一刻转换。混着存,迟早有人把 hPa 当 Pa 加起来。
  2. 每一路都带一个 xxx_ok 标志。传感器读失败时,temp_c 里躺着的是上一次的值还是 0?没有 ok 标志谁也说不清。有了它,上层能明确区分"读到了 0 °C"和"没读到"。
  3. 结构体别塞成大杂烩。这里只有 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);

四个"为什么这么写":

  1. 为什么顺序读,不开两个任务并发读? ESP-IDF 官方文档写得很清楚:i2c_new_master_bus() 本身是线程安全的,但其他 API(收发)不是——两个任务同时对同一条总线发起传输,行为不可预期。要真需要多任务访问,你得自己加一把互斥量(xSemaphoreCreateMutex())把每次"完整事务"包起来。对一个 2 秒采一次的项目,顺序读简单又够用,别为了并发而并发。(这和《项目实战_01》里"拆任务是为了结构清晰,不是为了性能"是同一个道理。)
  2. 为什么用 vTaskDelayUntil 而不是 vTaskDelay vTaskDelay(2000ms) 是"干完活再睡 2 秒",周期会被采样耗时慢慢拖长(漂移);vTaskDelayUntil 是"每 2 秒醒一次",周期恒定。做数据采集,要的是恒定周期
  3. 栈给 4096 字节怎么来的? 这个任务里有几十字节的局部数组(calib_a[26]d[8]rx[6])、若干 float,加上 ESP_LOGI 里的 %f 格式化——printf 系列的浮点格式化很吃栈,在 ESP32 上没有一两 KB 打不住。别抄"1024 就够"的说法,实测才算数:跑起来后加一句 uxTaskGetStackHighWaterMark(NULL) 看剩多少,剩得太多再往下调。
  4. 一颗传感器挂了,另一颗照常工作。 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 个坑

我把自己在这两个传感器上栽过的跟头、以及论坛里问得最多的"玄学报错"都列出来了。每一条都标了现象 → 根因 → 怎么确诊 → 怎么修。这节建议收藏(不是逼你关注啊,是真心建议你回头对照查错)。

#你看到的现象根因怎么确诊 / 怎么修
1I²C 地址焊错 / 写错i2c_master_probe 或读数据返回 ESP_FAIL,逻辑分析仪看到 SDA 上 NAK0x44 写成 0x88(那是器件地址的"7 位写法"和"8 位写法"混了),或者 ADDR 引脚电平接错导致地址变成 0x45i2c_bus_scan() 扫一遍真实挂在总线上的地址;牢记 SHT30 是 7 位地址 0x44/0x45i2c_master_bus_add_devicedev_addr 填 7 位值
2忘了上拉电阻总线偶尔读到 0xFF,或干脆 ESP_FAIL;同一块板子换根杜邦线又好一阵I²C 是开漏总线,SDA/SCL 必须外部 4.7kΩ 上拉到 VDD。很多开发板"恰好"内部有弱上拉所以能跑,一换长线或换板就崩用万用表量 SDA/SCL 空闲时是不是高电平;没有就焊两个 4.7kΩ 上拉。ESP32 的 gpio_pullup 是弱上拉,不能当外接上拉用
3BME280 没读校准就直接算温度恒为 0.00°C、气压 0.00 Pa、湿度 0.00%;或者数值乱跳dig_* 全是 0,补偿公式里乘法全 0。这正对应《项目实战_01》里"温度永远 0.00°C"那次事故bme280_init 读校准后打一行 dig_T1 的值,正常应在几万量级(实测 27504);是 0 就说明校准没读到
4SHT30 没等测量完成就抢读读到的两个值恒为 0x0000 或旧值,CRC 还对不上发完 0x2400 后,芯片还要 12~15ms 才把数据算好。你在命令刚发完就 i2c_master_receive,读的是还没更新的缓冲命令后 vTaskDelay(pdMS_TO_TICKS(30));别贪那 15ms,留点余量省得偶发读旧值
5单位漏换算 / 换算公式写错湿度显示 4000%、温度 2500℃、气压 88773 hPa 写成 88773 PaSHT30 要 /65535*175-45;BME280 是 Q24.8 要 /256。把"raw 直接当物理量"是最典型的错把公式和"raw=0x6666 应得 25.00°C / 40.00%RH"对照测一遍,差一个数量级就是少了 /65535/256
6CRC 没校验,偶发错值天气好好的,湿度每隔几小时蹦出一个 -3.5%RH131.0°CI²C 干扰让某字节翻转,没 CRC 就当真值用了。SHT30 每 2 字节带一个 CRC-8,正是为此打开 sht30_read 里的 CRC 校验;实测 CRC(0xFFFF)=0xACCRC(0x0000)=0x81,说明全 1 / 全 0 都能被 CRC 抓出来(全 1 往往就是 NAK 后的缓冲残留)
7读出来是 0xFF 因为 NAK一整帧都是 0xFF,日志满屏 data mismatchNAK 之后 i2c_master_receive 读到的是总线"全高"的电平残留。0xFF 不是数据,是"没读着"的尸体先判返回值再碰数据;任何 ESP_FAIL 都别继续解析。配合坑 1 的扫地址一起查
8BME280 校准参数符号/字节序读错温度能凑合,湿度却出现负数或离谱大值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_addr0x44 改成 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_checkhave_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_readvTaskDelay(pdMS_TO_TICKS(30)) 删了,直接读。多数时候还"正常",但偶尔会读到 0x0000 或上一次的温湿度(CRC 这时往往校验失败打 WARN)。这就是坑 4——时序紧的时候时序 bug 最难抓,所以留 30ms 余量比卡 15ms 极限稳得多。

练完之后,试着回答自己三个问题(检验真懂了没)

  1. 如果我把 SHT30 的 ADDR 引脚接 VDD,它的 7 位地址变成多少?i2c_bus_scan 会扫到几个设备?
  2. BME280 的 ctrl_measosrs_t=×2osrs_p=×16bme280_meas_delay_ms 算出来该是多少毫秒?(提示:第 三 节的延迟公式)
  3. 为什么 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姿态入门

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐