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的通信流程,说白了就是三步:
- 起始条件:SCL高电平时,SDA从高变低
- 数据传输:每8位数据后跟一个应答位(ACK/NACK)
- 停止条件:SCL高电平时,SDA从低变高
我画了个流程图,你看一眼就明白了:
避坑指南:
我曾经在一个项目里,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的驱动流程:
- 读取校准参数(上电后只需读一次)
- 配置测量模式(我习惯用正常模式,2倍过采样)
- 等待测量完成(约5ms)
- 读取原始温度/气压数据(6字节)
- 用校准参数补偿计算真实值
来看代码:
#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 数据读取与校验
数据读回来了,怎么保证数据可靠?我一般做三层校验:
- 硬件层:I2C通信本身有ACK/NACK机制,每次传输后检查返回值
- 传感器层:DHT22有校验和,BMP280有ID寄存器(0xD0固定为0x58)
- 应用层:做范围检查,比如温度-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总线的核心就是两根线加一个协议,搞懂了时序,什么传感器都不怕。