1. I2C 协议核心概念

I2C(Inter-Integrated Circuit)由 Philips 设计,是一种半双工同步多主机总线。物理层只有两根线:

  • SCL:串行时钟线,由主机产生
  • SDA:串行数据线,双向传输,开漏输出

每根线必须接上拉电阻(典型 4.7kΩ),这也是调试时最容易被忽略的点。

1.1 帧格式与地址

一次完整传输包含:

START | 7-bit 设备地址 | R/W | ACK | 数据... | ACK/NACK | STOP
  • START:SCL 高电平时,SDA 产生下降沿
  • STOP:SCL 高电平时,SDA 产生上升沿
  • 设备地址:7 位,通常硬件引脚(A0/A1/A2)决定低几位
  • R/W:0 表示写,1 表示读
  • ACK:接收方在第 9 个时钟拉低 SDA 表示应答

以 AT24C02 为例,其地址固定为 0b1010,加上 A2/A1/A0 引脚电平,再拼上 R/W 位,得到:

#define AT24C02_ADDR_W 0xA0  // 写地址
#define AT24C02_ADDR_R 0xA1  // 读地址

2. STM32 硬件 I2C 外设与初始化

STM32 的 I2C 外设功能强大,但寄存器操作复杂。推荐使用 STM32 HAL 库,既有性能保障又易于维护。

2.1 初始化步骤

  1. 开启 GPIO 时钟和 I2C 时钟
  2. 配置 SCL/SDA 引脚为开漏复用模式
  3. 设置 I2C 时序参数(时钟速度、上升时间等)
  4. 使能 I2C 外设

HAL 库中,一切封装为 I2C_HandleTypeDef

I2C_HandleTypeDef hi2c1;

void MX_I2C1_Init(void)
{
    hi2c1.Instance = I2C1;
    hi2c1.Init.ClockSpeed = 100000;        // 100kHz 标准模式
    hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;
    hi2c1.Init.OwnAddress1 = 0;            // 主机模式不需要
    hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
    hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
    hi2c1.Init.GeneralCallMode = I2D_GENERALCALL_DISABLE;
    hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;

    if (HAL_I2C_Init(&hi2c1) != HAL_OK)
    {
        Error_Handler();
    }
}

注意:部分 STM32 型号(如 F1 系列)的硬件 I2C 有已知 bug,如果通信不稳定,可考虑用 GPIO 模拟 I2C。后文代码基于硬件 I2C,但思路同样适用。

3. 读写 EEPROM(AT24C02)

AT24C02 容量 256 字节,按页(8 字节)组织,写入时需要先发送设备地址,再发送内部字节地址。

3.1 写单个字节

HAL_StatusTypeDef EEPROM_WriteByte(uint8_t addr, uint8_t data)
{
    uint8_t buf[2] = { addr, data };
    return HAL_I2C_Master_Transmit(&hi2c1, AT24C02_ADDR_W, buf, 2, 100);
}

3.2 读单个字节

读操作需要先写后读——先发设备地址+字节地址,然后重新发送起始条件,再发读地址:

HAL_StatusTypeDef EEPROM_ReadByte(uint8_t addr, uint8_t *data)
{
    // 先写字节地址
    if (HAL_I2C_Master_Transmit(&hi2c1, AT24C02_ADDR_W, &addr, 1, 100) != HAL_OK)
        return HAL_ERROR;
    // 然后读数据
    return HAL_I2C_Master_Receive(&hi2c1, AT24C02_ADDR_R, data, 1, 100);
}

关键:AT24C02 执行写操作后会进入内部编程周期(约 5ms),此时不响应任何请求,所以写完要延时或轮询 ACK。

3.3 页写入与连续读

连续读写时,HAL 提供了便捷函数:

// 连续写 16 字节到地址 0x00
HAL_I2C_Mem_Write(&hi2c1, AT24C02_ADDR_W, 0x00, I2C_MEMADD_SIZE_8BIT, txBuffer, 16, 200);

// 连续读 16 字节从地址 0x00
HAL_I2C_Mem_Read(&hi2c1, AT24C02_ADDR_R, 0x00, I2C_MEMADD_SIZE_8BIT, rxBuffer, 16, 200);

注意页边界:跨页写入会回卷覆盖,必须拆分为两次写操作。

4. 读写传感器(SHT30 温湿度)

SHT30 是 I2C 接口的温湿度传感器,地址固定 0x44(写 0x88,读 0x89)。先发送命令,再等待测量完成,最后读取 6 个字节(温度 2 字节 + CRC + 湿度 2 字节 + CRC)。

#define SHT30_ADDR_W 0x88
#define SHT30_ADDR_R 0x89

HAL_StatusTypeDef SHT30_ReadData(float *temp, float *humi)
{
    uint8_t cmd[2] = {0x2C, 0x06};  // 高重复性测量命令
    uint8_t buf[6];

    // 发送测量命令
    if (HAL_I2C_Master_Transmit(&hi2c1, SHT30_ADDR_W, cmd, 2, 100) != HAL_OK)
        return HAL_ERROR;

    HAL_Delay(20);  // 等待测量完成(典型 15ms)

    // 读取 6 字节数据
    if (HAL_I2C_Master_Receive(&hi2c1, SHT30_ADDR_R, buf, 6, 100) != HAL_OK)
        return HAL_ERROR;

    // 解析温度:14 位有效数据,公式 = -45 + 175 * raw / 65535
    uint16_t rawT = (buf[0] << 8) | buf[1];
    *temp = -45.0f + 175.0f * rawT / 65535.0f;

    // 解析湿度:公式 = 100 * raw / 65535
    uint16_t rawH = (buf[3] << 8) | buf[4];
    *humi = 100.0f * rawH / 65535.0f;

    // CRC 校验由 buf[2] 和 buf[5] 提供,如需严格校验请实现 CRC8 算法
    return HAL_OK;
}

5. 常见问题与排查技巧

5.1 总线死锁(SDA 被拉低)

设备在异常时可能持续拉低 SDA,导致总线永久阻塞。解决思路:

  • 在初始化前强制切换 SDA 为 GPIO 输出,发送 9 个时钟脉冲,释放总线
  • 或使用硬件复位引脚复位传感器

5.2 上拉电阻阻值不合适

  • 100kHz 标准模式:4.7kΩ - 10kΩ
  • 400kHz 快速模式:1kΩ - 4.7kΩ
  • 过大的电阻会导致上升沿变慢,波形畸变

5.3 地址错误

使用逻辑分析仪抓波形,确认发送的地址字节与设备地址完全匹配。注意 7 位地址与 8 位地址(含 R/W)的换算。

5.4 时序问题

  • 某些传感器需要延长 START 到 CLK 的时间,可适当降低 ClockSpeed
  • 对于睡眠唤醒型设备,写命令后必须延时

6. 总结

I2C 的优雅在于极简的物理连接,但代价是协议状态机的复杂性。使用 STM32 HAL 库能大幅降低开发门槛,但底层时序仍需开发者心中有数。核心要点:

  • 正确配置上拉电阻
  • 理解设备地址与 R/W 位的组合
  • 注意 EEPROM 的页边界与写周期延时
  • 用示波器或逻辑分析仪验证时序

掌握了这些,你就能轻松驾驭大多数 I2C 外设。下次遇到传感器无响应,记得先查地址、再查波形、最后查时序——大概率是这三个问题之一了。