4、I2C总线通信:协议原理与ESP32实战
I2C总线,说实话,是我在嵌入式开发中用得最多的串行通信协议之一。它只需要两根线,就能挂载一堆传感器、存储器、IO扩展芯片。你想想看,一个MPU6050六轴传感器,一根SDA、一根SCL,再加VCC和GND,四根线就能读到加速度和角速度,多方便。
但方便归方便,I2C也有它的脾气。地址冲突、时钟拉伸、总线锁死……我在项目里都踩过。今天咱们就把I2C从原理到ESP32实战,掰开揉碎讲清楚。
4.1 I2C协议核心原理
I2C(Inter-Integrated Circuit)是飞利浦公司(现NXP)在1980年代发明的同步、半双工、多主机总线。它只有两条线:
- SDA(Serial Data Line)—— 数据线
- SCL(Serial Clock Line)—— 时钟线
每条线上都需要上拉电阻(典型值4.7kΩ)。为什么?因为I2C的驱动方式是开漏输出,只能拉低,不能主动拉高。上拉电阻负责把总线拉回高电平。
关键特性速览
- 标准模式:100 kbps
- 快速模式:400 kbps
- 高速模式:3.4 Mbps(ESP32硬件支持)
- 最多挂载设备数:受限于总线电容(通常不超过8-16个)
- 7位地址空间:理论上128个地址,但可用地址约112个
4.2 I2C通信时序详解
I2C的通信过程,说白了就是主机控制时钟,按帧收发数据。我习惯把它拆成四个阶段:
- 起始条件(START):SCL高电平时,SDA从高变低
- 地址+读写位发送:主机发送7位从机地址 + 1位读写标志(0写1读)
- 数据帧传输:每字节8位数据 + 1位应答位(ACK/NACK)
- 停止条件(STOP):SCL高电平时,SDA从低变高
这里有个细节——应答位。每发送完一个字节,接收方必须拉低SDA表示ACK。如果接收方没准备好或不想接收,就保持高电平(NACK),主机收到NACK后通常会发STOP终止通信。
我的经验:调试I2C时,用逻辑分析仪抓波形是最快的。我曾经花了两小时查代码,最后发现是上拉电阻虚焊导致SDA波形畸变。示波器一看,上升沿慢得像蜗牛爬……
4.3 ESP32的I2C硬件与驱动
ESP32有两个I2C控制器,每个都可以配置为主机或从机。默认引脚如下:
| I2C控制器 | SDA默认引脚 | SCL默认引脚 |
|---|---|---|
| I2C0 | GPIO21 | GPIO22 |
| I2C1 | GPIO25 | GPIO26 |
当然,你可以用i2c_set_pin()任意映射到其他GPIO。我个人习惯把I2C0留给传感器,I2C1留给OLED屏幕或EEPROM,这样互不干扰。
ESP-IDF提供了两套I2C API:
- 底层驱动(
i2c_master_...):灵活,但代码量稍大 - 上层驱动(
i2c_cmd_link_create+ 命令链):更易读,适合快速开发
我推荐新手用命令链方式,逻辑清晰,不容易出错。
4.4 实战:ESP32读取MPU6050
MPU6050是六轴惯性测量单元(IMU),内部集成3轴加速度计和3轴陀螺仪。它的I2C地址是0x68(AD0引脚接地)或0x69(AD0接VCC)。
下面是一个完整的读取示例:
#include <stdio.h>
#include "driver/i2c.h"
#include "esp_log.h"
#define I2C_MASTER_NUM I2C_NUM_0
#define I2C_MASTER_FREQ_HZ 400000
#define MPU6050_ADDR 0x68
#define MPU6050_WHO_AM_I 0x75
#define MPU6050_ACCEL_XOUT_H 0x3B
static esp_err_t i2c_master_init(void)
{
i2c_config_t conf = {
.mode = I2C_MODE_MASTER,
.sda_io_num = GPIO_NUM_21,
.scl_io_num = GPIO_NUM_22,
.sda_pullup_en = GPIO_PULLUP_ENABLE,
.scl_pullup_en = GPIO_PULLUP_ENABLE,
.master.clk_speed = I2C_MASTER_FREQ_HZ,
};
esp_err_t ret = i2c_param_config(I2C_MASTER_NUM, &conf);
if (ret != ESP_OK) return ret;
return i2c_driver_install(I2C_MASTER_NUM, conf.mode, 0, 0, 0);
}
static esp_err_t mpu6050_read(uint8_t reg, uint8_t *data, size_t len)
{
i2c_cmd_handle_t cmd = i2c_cmd_link_create();
i2c_master_start(cmd);
i2c_master_write_byte(cmd, (MPU6050_ADDR << 1) | I2C_MASTER_WRITE, true);
i2c_master_write_byte(cmd, reg, true);
i2c_master_start(cmd);
i2c_master_write_byte(cmd, (MPU6050_ADDR << 1) | I2C_MASTER_READ, true);
i2c_master_read(cmd, data, len, I2C_MASTER_LAST_NACK);
i2c_master_stop(cmd);
esp_err_t ret = i2c_master_cmd_begin(I2C_MASTER_NUM, cmd, pdMS_TO_TICKS(100));
i2c_cmd_link_delete(cmd);
return ret;
}
void app_main(void)
{
ESP_ERROR_CHECK(i2c_master_init());
uint8_t who_am_i;
mpu6050_read(MPU6050_WHO_AM_I, &who_am_i, 1);
ESP_LOGI("MPU6050", "WHO_AM_I = 0x%02X", who_am_i);
uint8_t accel_data[6];
while (1) {
mpu6050_read(MPU6050_ACCEL_XOUT_H, accel_data, 6);
int16_t ax = (accel_data[0] << 8) | accel_data[1];
int16_t ay = (accel_data[2] << 8) | accel_data[3];
int16_t az = (accel_data[4] << 8) | accel_data[5];
ESP_LOGI("MPU6050", "Accel: %d %d %d", ax, ay, az);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
注意:MPU6050上电后默认处于睡眠模式。你需要往寄存器0x6B写0x00来唤醒它。否则读出来的加速度和角速度全是0。我曾经被这个坑过,排查了半天……
4.5 多设备地址冲突与解决
I2C总线上每个设备必须有唯一的地址。但现实是,很多传感器(比如多个MPU6050)出厂地址都一样。怎么办?
常用的解决方案有四种:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 地址引脚配置 | 通过AD0等引脚电平切换地址 | MPU6050、ADS1115等 |
| I2C多路复用器 | 使用TCA9548A等芯片分时选通 | 同地址设备超过2个 |
| 软件地址映射 | 通过GPIO模拟I2C,每个设备独立总线 | 设备数量少、引脚充裕 |
| 更换从机地址 | 部分设备支持软件改地址(如AT24C系列) | 设备支持可编程地址 |
我遇到过最头疼的情况:一个项目里用了4个相同的温湿度传感器,地址全是0x44。最后我用了TCA9548A,8通道I2C多路复用器,每个传感器挂一个通道,分时读取。代码里加了个通道切换函数,问题就解决了。
避坑指南:使用多路复用器时,记得在切换通道后加一个短暂延时(至少1ms),让总线稳定下来。我曾经因为没加延时,读到的数据偶尔会串通道……
4.6 I2C总线调试技巧
调试I2C,我总结了一套"三板斧":
- 测波形:逻辑分析仪看START、地址、ACK、STOP是否完整
- 查上拉:用万用表测SDA和SCL对地电压,空闲时应为VCC(3.3V)
- 扫地址:写一个I2C地址扫描函数,看哪些设备在响应
地址扫描的代码很简单:
void i2c_scan(void)
{
for (uint8_t addr = 1; addr < 127; addr++) {
i2c_cmd_handle_t cmd = i2c_cmd_link_create();
i2c_master_start(cmd);
i2c_master_write_byte(cmd, (addr << 1) | I2C_MASTER_WRITE, true);
i2c_master_stop(cmd);
esp_err_t ret = i2c_master_cmd_begin(I2C_MASTER_NUM, cmd, pdMS_TO_TICKS(50));
i2c_cmd_link_delete(cmd);
if (ret == ESP_OK) {
printf("Found device at 0x%02X\n", addr);
}
}
}
这个函数我几乎每个项目都会跑一遍,确认硬件连接无误再开始写业务逻辑。
常见I2C故障排查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 扫描不到设备 | 上拉电阻缺失或虚焊 | 检查4.7kΩ上拉,或启用ESP32内部上拉 |
| 数据偶尔错误 | 时钟频率过高或总线过长 | 降低频率到100kHz,缩短总线长度 |
| 总线锁死(SDA一直低) | 从机异常或通信中断 | 复位从机,或发送9个时钟脉冲恢复 |
| ACK丢失 | 地址错误或设备未上电 | 核对地址,检查设备供电 |
4.7 I2C通信知识体系
下面这张图,是我梳理的I2C通信知识体系。你可以把它当作一个快速索引:
I2C看起来简单,但真正用好它,需要理解时序细节、硬件特性,以及调试方法。我个人觉得,I2C是嵌入式开发里"易学难精"的典型代表。但只要掌握了今天讲的这些,大部分I2C设备你都能轻松驾驭。