I2C 通信协议详解

I2C 通信协议(Inter-Integrated Circuit)是飞利浦设计的两线同步串行总线,仅用 SCL(时钟)和 SDA(数据)两根线即可实现一主多从通信。本文结合 ESP32-C3 + SHT40 实战,从 I2C 通信协议时序原理到 MicroPython 代码逐层拆解。


一、I2C 通信时序详解

ℹ️ 四个核心信号
信号 解决的问题 触发条件
START 通信开始边界 SCL 高电平期间,SDA 下降沿
地址帧 多设备寻址(7位地址 + R/W位) START 后紧跟的首个字节
ACK/NACK 实时可靠性确认 每 8 位后的第 9 个时钟周期
STOP 通信结束边界 SCL 高电平期间,SDA 上升沿

1.1 带外信令:START/STOP 为何能与数据共存于 2 根线

数据传输铁律:SCL 高时 SDA 绝对不允许变化(接收方正在采样)。

飞利浦工程师将「SCL 高时 SDA 变化」这个正常传输中永远不会出现的非法状态重新定义为控制信号:

  • SCL 高 + SDA 下降沿START
  • SCL 高 + SDA 上升沿STOP

天然无歧义,无需第三根线,不占用任何数据编码空间。这种「借用不可能状态传控制信息」的技巧称为带外信令(Out-of-band Signaling),USB 的 SE0 复位状态、RS-232 的 BREAK 信号均为同类设计。


1.2 整体流程概览

写操作(主机向从机发数据):

主机发出:[START] → [7位地址 + W位(0)] → [寄存器/命令字节] → [数据字节] → [STOP]
从机响应:                          ↑ACK↑              ↑ACK↑         ↑ACK↑

读操作(主机从从机读数据):

主机发出:[START] → [7位地址 + R位(1)] → (释放SDA) → ... → [NACK] → [STOP]
从机响应:                          ↑ACK↑   [数据字节]  [数据字节]

1.3 START 条件(起始信号)

规则SCL 高电平期间,SDA 由高变低(下降沿)

i2c起始终止信号

ℹ️ 设计原因:空闲时总线两线都是高电平。SDA 在 SCL 高时变化是特殊事件,所有从机都会检测到这个「下降沿」,知道通信要开始了。

1.4 地址帧 + R/W 位(共 8 位)

格式[A6][A5][A4][A3][A2][A1][A0][R/W]

  • 高 7 位:目标从机地址(如 SHT40 = 0x44 = 1000100
  • 第 8 位:0 = 写(Write),1 = 读(Read)

每一位的传输规则

  • SCL 低电平期间,主机改变 SDA(允许数据变化)
  • SCL 高电平期间,SDA 必须稳定(从机在此采样)
SCL:  ___/‾‾‾\___/‾‾‾\___/‾‾‾\___
         ↑采样  ↑采样  ↑采样
SDA:  ══[A6]══════[A5]══════[A4]══  (低电平期间可变,高电平期间锁定)
⚠️ 关键约束:数据只能在 SCL 低电平期间变化,SCL 高电平期间 SDA 必须保持稳定,否则会被误判为 START 或 STOP!

1.5 ACK / NACK(应答位)

每传完 1 字节(8 位) 后,第 9 个时钟周期为应答位,SDA 由接收方驱动:

场景 谁驱动 SDA 0(低)= ACK 1(高)= NACK
主机写,从机应答 从机(主机先释放 SDA) 「收到,继续」 「未识别 / 忙」
主机读,主机应答 主机 「继续发下一字节」 「读完了,停」

ack应答

💡 一句话记忆谁接收,谁应答;低电平 = ACK,高电平 = NACK。
主机写 → 从机应答;主机读最后一字节 → 主机发 NACK 通知从机停止。

1.6 STOP 条件(停止信号)

规则SCL 高电平期间,SDA 由低变高(上升沿)

i2c起始终止信号

所有从机检测到这个信号后,知道本次通信结束,总线释放回空闲状态。


1.7 完整时序图:以读 SHT40 为例

SHT40 读取流程分两步:

  1. :发送测量命令 0xFD
  2. :读回 6 字节数据

ESP32-C3_SHT40_I2C 测量时序图


1.8 时序参数速查

参数 标准模式(100kHz) 快速模式(400kHz) 说明
SCL 频率 100 kHz 400 kHz 时钟速率
SCL 高电平时间 ≥ 4.0 μs ≥ 0.6 μs 采样窗口
SCL 低电平时间 ≥ 4.7 μs ≥ 1.3 μs 数据变化窗口
START 保持时间 ≥ 4.0 μs ≥ 0.6 μs START 后到第一个 SCL 下降沿
数据建立时间 ≥ 250 ns ≥ 100 ns SDA 变化到 SCL 上升沿的间隔
💡 实践建议:ESP32-C3 的硬件 I2C 会自动满足上述时序要求,只需设置 freq 参数。遇到通信不稳定时,先把频率从 400kHz 降到 100kHz 试试。

二、ESP32-C3 接线

2.1 SHT40 接线

SHT40 引脚 ESP32-C3 逻辑分析仪通道
VDD 3.3V
GND GND
SDA GPIO4 ch0
SCL GPIO5 ch1

2.2 逻辑分析仪 PulseView 设置

  1. 采样率:设置为目标频率的 10 倍以上
    • 400kHz 快速模式 → 采样率 ≥ 4MHz
    • 100kHz 标准模式 → 采样率 ≥ 1MHz
    • 经验规则:采样率 = 通信频率 × 10(奈奎斯特要求 2 倍,10 倍是工程安全裕量)
  2. 解码器:添加 I2C 协议解码器,指定 SCLSDA 对应通道
  3. 运行程序 → 点击「Run」采集 → 观察解码结果

i2c时钟频率400khz

解码结果可以看到:

  • START 条件(SDA 在 SCL 高时下降)
  • 地址帧:如 0x44 W(写 SHT40)或 0x44 R(读 SHT40)
  • 每字节后的 ACK / NACK
  • 数据字节的十六进制值
  • STOP 条件

三、MicroPython 代码

3.1 扫描总线上的设备

第一步永远是扫描,确认设备已正确连接并响应。

from machine import I2C, Pin

# 初始化 I2C,SDA=GPIO4,SCL=GPIO5,频率 400kHz
i2c = I2C(0, scl=Pin(5), sda=Pin(4), freq=400000)

# 扫描总线,返回所有响应设备的地址列表
devices = i2c.scan()

if devices:
    print("发现设备:", [hex(d) for d in devices])
else:
    print("未发现任何设备,请检查接线")

正常输出示例:

发现设备: ['0x3c', '0x44']

其中 0x3c 是 OLED,0x44 是 SHT40。


3.2 读取 SHT40 温湿度

from machine import I2C, Pin
import time

i2c = I2C(0, scl=Pin(5), sda=Pin(4), freq=400000)
SHT40_ADDR = 0x44  # SHT40 默认地址

# 发送测量命令:高精度单次测量
i2c.writeto(SHT40_ADDR, bytes([0xFD]))
# 等待测量完成(高精度模式约需 10ms)
time.sleep_ms(10)

# 读取 6 字节原始数据
# 格式:[温度高位, 温度低位, 温度CRC, 湿度高位, 湿度低位, 湿度CRC]
data = i2c.readfrom(SHT40_ADDR, 6)

for byte in data:
    print(hex(byte))

# 拼合温度原始值并换算
raw_temp = (data[0] << 8) | data[1]
temperature = -45 + 175 * raw_temp / 65535

# 拼合湿度原始值并换算
raw_hum = (data[3] << 8) | data[4]
humidity = -6 + 125 * raw_hum / 65535
humidity = max(0, min(100, humidity))

print(f"温度:{round(temperature, 1)} 湿度:{round(humidity, 1)}")

时序图


3.3 关键波形规则

写操作(主机 → 从机)

  • 主机控制 SCL,在 SCL 低电平期间改变 SDA
  • 从机在 SCL 高电平期间采样 SDA
  • 每字节结束,主机释放 SDA,从机拉低表示 ACK

读操作(从机 → 主机)

  • 主机先发地址帧(R位=1),释放 SDA
  • 从机接管 SDA,逐位输出数据
  • 主机在最后一字节后发 NACK,再发 STOP

四、进阶知识

4.1 开漏输出 + 上拉电阻

I2C 总线允许多主机、多从机挂在同一对线上。若使用推挽输出,一个设备输出高、另一个输出低,会直接导致电源短路。

开漏 + 上拉电阻的优势

特性 说明
线与逻辑(Wired-AND) 任何设备拉低即为低;所有设备释放才被上拉到高,无短路风险
时钟延展(Clock Stretching) 从机可拉低 SCL 暂停通信,请求主机等待
热插拔安全 设备接入时不产生浪涌电流冲突

典型上拉电阻:4.7 kΩ(标准模式)/ 2.2 kΩ(快速模式)。阻值越小上升沿越快,但静态功耗越大。

4.2 时钟延展(Clock Stretching)

从机在 SCL 变高之前主动将 SCL 拉低,强迫主机等待,直到从机处理完毕才释放 SCL。

  • 适用场景:从机需较长时间完成 ADC 转换、EEPROM 写入等操作
  • ESP32-C3:硬件 I2C 控制器支持作为主机时检测从机的时钟延展,会自动暂停并等待 SCL 释放

4.3 地址冲突解决方案

同一总线上两个设备地址相同时,数据会发生冲突,可能导致总线永久阻塞。

解决方案

  1. 硬件地址引脚:许多芯片有 ADDR 引脚,接 VCC 或 GND 可切换地址(如 SHT40:0x44 / 0x45
  2. 使用不同总线:ESP32-C3 有两组硬件 I2C,可将冲突设备分开
  3. I2C Mux:如 TCA9548A,将多个同地址设备接到不同通道,通过通道切换访问

五、I2C vs SPI vs UART

特性 I2C SPI UART
引脚数 2(SCL + SDA) 4+(SCLK/MOSI/MISO/CS×N) 2(TX + RX)
拓扑 一主多从(地址寻址) 一主多从(片选线寻址) 点对点
速率 100k / 400kHz 可达数十 MHz 典型 115200 bps
全双工 半双工 全双工 全双工
时钟 同步(主机提供 SCL) 同步 异步(需配置波特率)
适用场景 低速传感器、EEPROM 高速外设(屏幕、Flash) 调试串口、GPS 模块

📋 常用设备地址速查
设备 默认地址 备注
SHT40 0x44 / 0x45 温湿度传感器,ADDR 引脚决定
OLED SSD1306 0x3C / 0x3D OLED 显示屏
BMP280 0x76 / 0x77 气压/温度传感器
MPU6050 0x68 / 0x69 六轴 IMU,AD0 引脚决定
EEPROM AT24Cxx 0x500x57 A0/A1/A2 引脚决定

六、常见问题

Q1:START 和 STOP 信号如何定义?为什么这样设计?

- **START**:SCL 高电平期间,SDA 产生**下降沿**(高→低)
- **STOP**:SCL 高电平期间,SDA 产生**上升沿**(低→高)

正常数据传输铁律是「SCL 高时 SDA 不得变化」,因此「SCL 高 + SDA 变化」在正常通信中是**永远不会出现的非法状态**。飞利浦工程师将这两个非法状态重新定义为控制信号,天然无歧义,无需额外引脚——即**带外信令(Out-of-band Signaling)**。

Q2:I2C 的 ACK / NACK 机制是什么?谁来发送?

每传完 1 字节(8 位)后,第 9 个时钟周期为应答位,由**接收方**驱动 SDA:

| 场景 | 发送方 | ACK(SDA=0) | NACK(SDA=1) |
| :--- | :--- | :--- | :--- |
| 主机写数据 | 从机 | 收到,请继续 | 未识别 / 忙 |
| 主机读数据(最后一字节) | 主机 | 继续发下一字节 | 读完了,停止 |

口诀:**谁接收,谁应答;低电平 = ACK,高电平 = NACK。**

Q3:I2C 为什么使用开漏输出 + 上拉电阻?

多设备共线时推挽输出会短路。开漏结构实现线与逻辑,任何设备均可安全拉低总线,不会与其他设备冲突。同时支持时钟延展,从机可主动暂停通信。

Q4:7 位地址最多支持多少设备?

理论 128 个地址(0x00–0x7F),保留 0x00(广播)、0x01–0x07(特殊用途)、0x78–0x7F(10位地址扩展前缀),实际可用约 **112 个**。需要更多设备可使用 10 位扩展地址模式(最多 1024 个)。

Q5:读取 SHT40 的完整 I2C 流程是什么?

**阶段一:写命令**
```
[START] → [0x44 + W(0)] → ACK → [0xFD(高精度命令)] → ACK → [STOP]
```

**阶段二:等待 ≥10ms 后读数据**
```
[START] → [0x44 + R(1)] → ACK → [data0] ACK → [data1] ACK → [data2] ACK
→ [data3] ACK → [data4] ACK → [data5] NACK → [STOP]
```

返回 6 字节:温度高字节、温度低字节、温度CRC、湿度高字节、湿度低字节、湿度CRC。

换算公式:
- temperature = -45 + 175 × raw_temp / 65535
- humidity = -6 + 125 × raw_hum / 65535(clamp 到 [0, 100])