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的通信过程,说白了就是主机控制时钟,按帧收发数据。我习惯把它拆成四个阶段:

  1. 起始条件(START):SCL高电平时,SDA从高变低
  2. 地址+读写位发送:主机发送7位从机地址 + 1位读写标志(0写1读)
  3. 数据帧传输:每字节8位数据 + 1位应答位(ACK/NACK)
  4. 停止条件(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,我总结了一套"三板斧":

  1. 测波形:逻辑分析仪看START、地址、ACK、STOP是否完整
  2. 查上拉:用万用表测SDA和SCL对地电压,空闲时应为VCC(3.3V)
  3. 扫地址:写一个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协议核心 SDA + SCL 双线 开漏输出 + 上拉电阻 START → 地址 → 数据 → STOP 通信时序 起始条件 / 停止条件 7位地址 + R/W位 ACK / NACK 应答机制 ESP32实现 I2C0 / I2C1 双控制器 命令链API / 底层API 引脚可任意映射 实战应用 MPU6050读取 多设备地址冲突 TCA9548A复用 调试三板斧 核心:两根线、一个地址、一套时序,搞定大部分传感器通信

I2C看起来简单,但真正用好它,需要理解时序细节、硬件特性,以及调试方法。我个人觉得,I2C是嵌入式开发里"易学难精"的典型代表。但只要掌握了今天讲的这些,大部分I2C设备你都能轻松驾驭。