4、I2C总线与温湿度传感器:I2C协议原理、DHT22/BMP280驱动编写、数据读取与校验

好,咱们进入第四章。前面几章我们把GPIO、UART、ADC这些基础外设都过了一遍,从这章开始,要接触嵌入式系统里最常用的总线之一——I2C。

说实话,I2C这玩意儿,看着简单,但坑不少。我刚开始做产品时,就因为I2C上拉电阻没选对,折腾了整整两天。嗯,咱们慢慢聊。

4.1 I2C总线协议原理

I2C,全称Inter-Integrated Circuit,是飞利浦(现在的NXP)在80年代搞出来的。它只需要两根线:SCL(时钟线)和SDA(数据线)。

你想想看,两根线就能挂几十个设备,是不是很省IO?

核心特点:

  • 主从架构:一个主机(通常是MCU),多个从机(传感器、存储器等)
  • 7位或10位地址:7位地址最多挂127个设备(实际要留一些保留地址)
  • 双向半双工:同一时刻只能一个方向传输数据
  • 速率模式:标准模式100kHz,快速模式400kHz,高速模式3.4MHz

I2C的通信流程,说白了就是三步:

  1. 起始条件:SCL高电平时,SDA从高变低
  2. 数据传输:每8位数据后跟一个应答位(ACK/NACK)
  3. 停止条件:SCL高电平时,SDA从低变高

我画了个流程图,你看一眼就明白了:

I2C 单次通信完整流程 起始条件 从机地址 + R/W ACK 寄存器地址 重复起始条件 从机地址 + 读 ACK 数据字节 ACK/NACK 更多数据... 停止条件 说明: 读操作通常需要“写寄存器地址 → 重复起始 → 读数据”三步,写操作只需“写地址 → 写数据”两步。 每个字节后主机或从机必须发送ACK,否则通信会中断。

避坑指南:

我曾经在一个项目里,I2C总线挂了8个传感器,结果发现SCL/SDA波形严重失真。查了半天,原来是上拉电阻选错了。记住:

  • 3.3V系统,上拉电阻推荐4.7kΩ
  • 5V系统,上拉电阻推荐2.2kΩ
  • 总线电容大(线长或设备多),电阻要适当减小

4.2 传感器选型:DHT22 vs BMP280

咱们这章用两个传感器:DHT22和BMP280。为啥选这俩?

参数 DHT22 BMP280
测量内容 温度 + 湿度 温度 + 气压
接口类型 单总线(自己搞的协议) I2C / SPI
温度精度 ±0.5°C ±1.0°C
湿度精度 ±2% RH —
气压精度 — ±1 hPa
采样周期 ≥2秒 可配置(最快0.5ms)
工作电压 3.3V ~ 5.5V 1.71V ~ 3.6V
典型价格 ¥10 ~ 15 ¥5 ~ 10

你看,DHT22走的是单总线,不是标准I2C。但它的协议简单,适合用来理解“类I2C”的时序。BMP280是正经的I2C设备,寄存器映射清晰,适合练手。

我个人习惯是:新手先用DHT22找感觉,再用BMP280深入理解I2C寄存器操作。

4.3 DHT22驱动编写

DHT22的协议,说白了就是主机拉低总线触发,然后传感器回传40位数据。这40位包括:

  • 16位湿度数据(整数部分+小数部分)
  • 16位温度数据(整数部分+小数部分)
  • 8位校验和

校验规则:湿度高8位 + 湿度低8位 + 温度高8位 + 温度低8位 = 校验和(取低8位)

注意:DHT22的时序要求很严格。主机拉低至少1ms(我习惯用1.5ms),然后释放。传感器会在20~40us后拉低总线80us,再拉高80us,然后开始传数据。

每个数据位:

  • 0:低电平50us + 高电平26~28us
  • 1:低电平50us + 高电平70us

来看代码:

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"

#define DHT22_PIN GPIO_NUM_4

// 微秒级延时(ESP32专用)
static void delay_us(uint32_t us) {
    uint32_t start = esp_timer_get_time();
    while (esp_timer_get_time() - start < us) {}
}

// 读取DHT22数据
int dht22_read(float *temperature, float *humidity) {
    uint8_t data[5] = {0};
    uint8_t bit;
    int timeout;

    // 1. 主机拉低总线,触发传感器
    gpio_set_direction(DHT22_PIN, GPIO_MODE_OUTPUT);
    gpio_set_level(DHT22_PIN, 0);
    delay_us(1500);  // 至少1ms,我用1.5ms确保稳定
    gpio_set_level(DHT22_PIN, 1);
    delay_us(30);    // 释放后等待30us

    // 2. 切换为输入,等待传感器响应
    gpio_set_direction(DHT22_PIN, GPIO_MODE_INPUT);

    // 等待传感器拉低(80us)
    timeout = 0;
    while (gpio_get_level(DHT22_PIN) == 1) {
        if (++timeout > 100) return -1;  // 超时
        delay_us(1);
    }
    // 等待传感器拉高(80us)
    timeout = 0;
    while (gpio_get_level(DHT22_PIN) == 0) {
        if (++timeout > 100) return -1;
        delay_us(1);
    }

    // 3. 读取40位数据
    for (int i = 0; i < 40; i++) {
        // 等待低电平开始
        timeout = 0;
        while (gpio_get_level(DHT22_PIN) == 0) {
            if (++timeout > 100) return -1;
            delay_us(1);
        }
        // 延时40us后采样
        delay_us(40);
        if (gpio_get_level(DHT22_PIN) == 1) {
            data[i / 8] |= (1 << (7 - (i % 8)));  // 高位在前
        }
        // 等待高电平结束
        timeout = 0;
        while (gpio_get_level(DHT22_PIN) == 1) {
            if (++timeout > 100) return -1;
            delay_us(1);
        }
    }

    // 4. 校验
    uint8_t checksum = data[0] + data[1] + data[2] + data[3];
    if (checksum != data[4]) {
        return -2;  // 校验失败
    }

    // 5. 解析数据
    uint16_t raw_hum = ((uint16_t)data[0] << 8) | data[1];
    uint16_t raw_temp = ((uint16_t)data[2] << 8) | data[3];

    *humidity = raw_hum / 10.0f;
    *temperature = raw_temp / 10.0f;

    return 0;  // 成功
}

经验之谈:

我刚开始写DHT22驱动时,总在延时上翻车。ESP32的时钟和Arduino不一样,直接用Arduino的delayMicroseconds()会偏。后来我改用esp_timer_get_time()做微秒级延时,才稳定下来。

另外,DHT22两次读取间隔必须≥2秒,否则传感器不响应。这个坑我踩过,当时还以为是传感器坏了。

4.4 BMP280驱动编写

BMP280是博世的气压传感器,正经的I2C设备。它的I2C地址是0x76(SDO接地)或0x77(SDO接VCC)。

BMP280有几个关键寄存器:

寄存器地址 名称 说明
0xD0 ID 芯片ID,固定为0x58
0xF4 CTRL_MEAS 控制测量模式(睡眠/强制/正常)
0xF5 CONFIG 配置滤波器、采样率等
0xF7~0xFC PRESS/TEMP 原始气压和温度数据(20位)
0x88~0xA1 校准参数 出厂校准值,共24字节

BMP280的驱动流程:

  1. 读取校准参数(上电后只需读一次)
  2. 配置测量模式(我习惯用正常模式,2倍过采样)
  3. 等待测量完成(约5ms)
  4. 读取原始温度/气压数据(6字节)
  5. 用校准参数补偿计算真实值

来看代码:

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/i2c.h"

#define BMP280_I2C_ADDR 0x76
#define I2C_MASTER_SCL GPIO_NUM_22
#define I2C_MASTER_SDA GPIO_NUM_21

// 校准参数结构体
typedef struct {
    uint16_t dig_T1;
    int16_t  dig_T2, dig_T3;
    uint16_t dig_P1;
    int16_t  dig_P2, dig_P3, dig_P4, dig_P5, dig_P6, dig_P7, dig_P8, dig_P9;
} bmp280_calib_t;

static bmp280_calib_t calib;

// I2C写寄存器
static esp_err_t bmp280_write_reg(uint8_t reg, uint8_t value) {
    i2c_cmd_handle_t cmd = i2c_cmd_link_create();
    i2c_master_start(cmd);
    i2c_master_write_byte(cmd, (BMP280_I2C_ADDR << 1) | I2C_MASTER_WRITE, true);
    i2c_master_write_byte(cmd, reg, true);
    i2c_master_write_byte(cmd, value, true);
    i2c_master_stop(cmd);
    esp_err_t ret = i2c_master_cmd_begin(I2C_NUM_0, cmd, 1000 / portTICK_PERIOD_MS);
    i2c_cmd_link_delete(cmd);
    return ret;
}

// I2C读多字节
static esp_err_t bmp280_read_bytes(uint8_t reg, uint8_t *buf, size_t len) {
    i2c_cmd_handle_t cmd = i2c_cmd_link_create();
    i2c_master_start(cmd);
    i2c_master_write_byte(cmd, (BMP280_I2C_ADDR << 1) | I2C_MASTER_WRITE, true);
    i2c_master_write_byte(cmd, reg, true);
    i2c_master_start(cmd);
    i2c_master_write_byte(cmd, (BMP280_I2C_ADDR << 1) | I2C_MASTER_READ, true);
    i2c_master_read(cmd, buf, len, I2C_MASTER_LAST_NACK);
    i2c_master_stop(cmd);
    esp_err_t ret = i2c_master_cmd_begin(I2C_NUM_0, cmd, 1000 / portTICK_PERIOD_MS);
    i2c_cmd_link_delete(cmd);
    return ret;
}

// 读取校准参数
static void bmp280_read_calibration(void) {
    uint8_t buf[24];
    bmp280_read_bytes(0x88, buf, 24);
    calib.dig_T1 = (buf[1] << 8) | buf[0];
    calib.dig_T2 = (buf[3] << 8) | buf[2];
    calib.dig_T3 = (buf[5] << 8) | buf[4];
    calib.dig_P1 = (buf[7] << 8) | buf[6];
    calib.dig_P2 = (buf[9] << 8) | buf[8];
    calib.dig_P3 = (buf[11] << 8) | buf[10];
    calib.dig_P4 = (buf[13] << 8) | buf[12];
    calib.dig_P5 = (buf[15] << 8) | buf[14];
    calib.dig_P6 = (buf[17] << 8) | buf[16];
    calib.dig_P7 = (buf[19] << 8) | buf[18];
    calib.dig_P8 = (buf[21] << 8) | buf[20];
    calib.dig_P9 = (buf[23] << 8) | buf[22];
}

// 初始化BMP280
void bmp280_init(void) {
    // 配置I2C
    i2c_config_t conf = {
        .mode = I2C_MODE_MASTER,
        .sda_io_num = I2C_MASTER_SDA,
        .scl_io_num = I2C_MASTER_SCL,
        .sda_pullup_en = GPIO_PULLUP_ENABLE,
        .scl_pullup_en = GPIO_PULLUP_ENABLE,
        .master.clk_speed = 100000  // 100kHz标准模式
    };
    i2c_param_config(I2C_NUM_0, &conf);
    i2c_driver_install(I2C_NUM_0, I2C_MODE_MASTER, 0, 0, 0);

    // 读取校准参数
    bmp280_read_calibration();

    // 配置:正常模式,2倍过采样
    bmp280_write_reg(0xF4, 0x27);  // CTRL_MEAS: 温度×2, 气压×2, 正常模式
    bmp280_write_reg(0xF5, 0xA0);  // CONFIG: 滤波器关, 1ms采样率
}

// 补偿温度(单位:0.01°C)
static int32_t bmp280_compensate_temp(int32_t adc_T, int32_t *t_fine) {
    int32_t var1, var2, T;
    var1 = ((((adc_T >> 3) - ((int32_t)calib.dig_T1 << 1))) * ((int32_t)calib.dig_T2)) >> 11;
    var2 = (((((adc_T >> 4) - ((int32_t)calib.dig_T1)) * ((adc_T >> 4) - ((int32_t)calib.dig_T1))) >> 12) * ((int32_t)calib.dig_T3)) >> 14;
    *t_fine = var1 + var2;
    T = (*t_fine * 5 + 128) >> 8;
    return T;
}

// 读取温度(单位:°C)
float bmp280_read_temperature(void) {
    uint8_t buf[3];
    bmp280_read_bytes(0xFA, buf, 3);  // 温度寄存器
    int32_t adc_T = ((int32_t)buf[0] << 12) | ((int32_t)buf[1] << 4) | ((int32_t)buf[2] >> 4);
    int32_t t_fine;
    int32_t T = bmp280_compensate_temp(adc_T, &t_fine);
    return T / 100.0f;
}

关键点:

  • BMP280的原始数据是20位,左对齐存储在3个字节里
  • 校准参数是出厂时写入的,每个芯片都不一样,必须读出来用
  • 补偿计算有固定的公式,博世官方文档里写得很清楚
  • I2C读写时,读操作需要先写寄存器地址,再发重复起始条件,然后读数据

4.5 数据读取与校验

数据读回来了,怎么保证数据可靠?我一般做三层校验:

  1. 硬件层:I2C通信本身有ACK/NACK机制,每次传输后检查返回值
  2. 传感器层:DHT22有校验和,BMP280有ID寄存器(0xD0固定为0x58)
  3. 应用层:做范围检查,比如温度-40~80°C,湿度0~100%RH,气压900~1100hPa

来看一个完整的数据采集示例:

void sensor_task(void *pvParameters) {
    float temp_dht, hum, temp_bmp, pressure;

    while (1) {
        // 读取DHT22
        int ret = dht22_read(&temp_dht, &hum);
        if (ret == 0) {
            // 范围校验
            if (temp_dht > -40.0f && temp_dht < 80.0f &&
                hum >= 0.0f && hum <= 100.0f) {
                printf("DHT22: %.1f°C, %.1f%%\n", temp_dht, hum);
            } else {
                printf("DHT22数据异常!\n");
            }
        } else {
            printf("DHT22读取失败,错误码:%d\n", ret);
        }

        // 读取BMP280
        temp_bmp = bmp280_read_temperature();
        if (temp_bmp > -40.0f && temp_bmp < 80.0f) {
            printf("BMP280: %.2f°C\n", temp_bmp);
        } else {
            printf("BMP280数据异常!\n");
        }

        vTaskDelay(2000 / portTICK_PERIOD_MS);  // DHT22要求至少2秒间隔
    }
}

避坑指南:

我曾经在一个户外气象站项目里,发现BMP280的数据偶尔跳变。排查后发现是I2C总线受到电机干扰。解决办法:

  • I2C线用双绞线,远离电机和电源线
  • 在传感器VCC和GND之间加100nF去耦电容
  • 软件上做多次采样取平均(我一般采5次去极值再平均)

嗯,这一章的内容就到这儿。DHT22和BMP280的驱动代码,你可以在ESP32上直接跑起来试试。记住,I2C总线的核心就是两根线加一个协议,搞懂了时序,什么传感器都不怕。