4. I2C总线通信:从协议到实战

I2C总线,说实话,是嵌入式开发里最常用的串行通信协议之一。两根线,挂一堆设备,简单又灵活。我最早接触I2C是在一个传感器采集项目上,当时被时钟延展和应答位折腾得够呛。今天咱们就把I2C协议掰开揉碎,再结合ESP-IDF的驱动框架,手把手读写EEPROM。

4.1 I2C协议核心要点

I2C只有两条线:SCL(时钟线)和SDA(数据线)。所有设备都挂在这两根线上,通过地址区分。主设备发起通信,从设备响应。

关键特性速览

  • 两线制:SCL + SDA,半双工
  • 多主多从:支持总线仲裁
  • 7位/10位地址:常用7位,最多127个设备
  • 标准速率:100kHz(标准模式)、400kHz(快速模式)
  • 开漏输出:需要上拉电阻,典型值4.7kΩ

4.2 通信时序详解

I2C的时序其实不复杂,但有几个关键点必须吃透。我画了一张流程图,把一次完整的读写过程串起来。

I2C 完整读写流程 起始条件 (S) 发送从机地址 (7位) + R/W=0 等待从机应答 (ACK) 发送寄存器地址 (1~2字节) 等待从机应答 (ACK) 发送/接收数据 (N字节) 停止条件 (P) 注意 SCL高电平时,SDA 从高到低跳变 = 起始 从低到高跳变 = 停止

嗯,这里要注意:起始条件和停止条件都是由主设备控制的。SCL为高时,SDA从高变低是起始,从低变高是停止。这个时序必须严格,否则从设备会懵。

4.3 应答机制与时钟延展

I2C的应答位很有意思。每发送一个字节(8位),接收方必须在第9个时钟周期拉低SDA表示应答(ACK),否则就是非应答(NACK)。

避坑指南

我曾经在一个项目里,从设备因为忙没来得及拉低SDA,主设备没等到ACK就直接停了总线。结果整个I2C总线卡死,所有设备都动不了。后来加了超时重试机制才解决。

时钟延展(Clock Stretching)是从设备的一种流控手段。从设备可以拉低SCL,告诉主设备“我还没准备好,你等等”。主设备必须检测SCL电平,如果被拉低就等待。ESP-IDF的驱动默认支持时钟延展,但有些第三方库没处理,容易出问题。

4.4 ESP-IDF I2C驱动框架

ESP-IDF的I2C驱动封装得挺完善。核心是 i2c_master_cmd_begin() 这个函数,它把一系列命令打包成链表,一次性发送。我个人习惯用这种“命令链”的方式,代码清晰,调试也方便。

驱动初始化分三步:

  1. 配置参数:设置GPIO引脚、时钟频率、是否启用内部上拉
  2. 安装驱动:分配资源,配置中断
  3. 创建命令链:用 i2c_cmd_link_create() 创建,然后添加读写命令

初始化代码模板

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 = 100000
};
i2c_param_config(I2C_NUM_0, &conf);
i2c_driver_install(I2C_NUM_0, I2C_MODE_MASTER, 0, 0, 0);

4.5 读写EEPROM实战

EEPROM是I2C设备的典型代表。以AT24C02为例,容量256字节,地址0x50(7位)。写操作需要先发设备地址,再发存储地址,最后发数据。读操作分两步:先伪写(发设备地址+存储地址),再重新发设备地址+读位,然后收数据。

我直接上代码,注释写清楚每一步在干什么。

EEPROM写操作

esp_err_t eeprom_write_byte(uint8_t dev_addr, uint8_t mem_addr, uint8_t data) {
    i2c_cmd_handle_t cmd = i2c_cmd_link_create();
    i2c_master_start(cmd);
    i2c_master_write_byte(cmd, (dev_addr << 1) | I2C_MASTER_WRITE, true);
    i2c_master_write_byte(cmd, mem_addr, true);
    i2c_master_write_byte(cmd, data, true);
    i2c_master_stop(cmd);
    esp_err_t ret = i2c_master_cmd_begin(I2C_NUM_0, cmd, pdMS_TO_TICKS(1000));
    i2c_cmd_link_delete(cmd);
    return ret;
}

EEPROM读操作

esp_err_t eeprom_read_byte(uint8_t dev_addr, uint8_t mem_addr, uint8_t *data) {
    i2c_cmd_handle_t cmd = i2c_cmd_link_create();
    // 伪写:发送设备地址+存储地址
    i2c_master_start(cmd);
    i2c_master_write_byte(cmd, (dev_addr << 1) | I2C_MASTER_WRITE, true);
    i2c_master_write_byte(cmd, mem_addr, true);
    // 重新开始:发送设备地址+读位
    i2c_master_start(cmd);
    i2c_master_write_byte(cmd, (dev_addr << 1) | I2C_MASTER_READ, true);
    i2c_master_read_byte(cmd, data, I2C_MASTER_NACK);
    i2c_master_stop(cmd);
    esp_err_t ret = i2c_master_cmd_begin(I2C_NUM_0, cmd, pdMS_TO_TICKS(1000));
    i2c_cmd_link_delete(cmd);
    return ret;
}

注意:EEPROM写周期

AT24C02每写一个字节后,内部需要5ms左右的时间来完成编程。这期间不会响应任何I2C命令。我建议在每次写操作后加一个10ms的延时,或者轮询设备直到收到ACK为止。否则连续写会丢数据。

4.6 常见问题与调试技巧

调试I2C,示波器是最好的朋友。没有示波器的话,用逻辑分析仪也行。我总结了几种常见故障:

现象 可能原因 解决方案
总线卡死,SCL/SDA一直低 从设备未释放总线 复位从设备,或加超时重试
收不到ACK 地址错误/设备未上电 检查地址位和上拉电阻
数据错位 时钟频率不匹配 降低频率到100kHz试试
写数据后读回全0xFF 写周期未完成 增加写后延时

调试小技巧

我习惯在代码里加一个I2C扫描函数,遍历所有可能的设备地址,看哪些设备在线。这样能快速确认硬件连接和地址配置是否正确。ESP-IDF的例程里就有现成的i2c_scanner,拿来就能用。

好了,I2C这块的核心内容就这些。协议不难,但细节多。你写代码的时候,多想想时序图,多想想应答位,基本不会出大问题。EEPROM的读写是I2C最经典的应用场景,吃透了这个,其他I2C传感器、RTC芯片什么的,原理都一样。