3、I2C通信协议基础:从原理到实战

各位同学,今天我们聊聊I2C。这个协议在嵌入式系统里太常见了,EEPROM、传感器、ADC……几乎每个板子上都能找到它的身影。我刚开始做硬件那会儿,总觉得I2C很简单,不就是两根线嘛。直到有一次,一个项目里EEPROM死活读不出数据,排查了整整两天,最后发现是起始条件没满足——嗯,从那以后,我再也不敢小看这个“简单”的协议了。

3.1 I2C总线原理:两根线,一个世界

I2C总线,说白了就是两根线:SCL(时钟线)SDA(数据线)。所有设备都挂在这两根线上,通过地址来区分谁跟谁说话。

你想想看,一个主设备(比如MCU)可以挂多个从设备(比如EEPROM、温度传感器),每个从设备有自己的地址。主设备想跟谁说话,就在总线上喊一声那个地址,对方应答了,就开始通信。

这里有个关键点:两根线都是开漏输出。什么意思?就是设备只能把线拉低,不能主动拉高。拉高靠的是上拉电阻。我见过不少新手画原理图时忘了加上拉电阻,结果总线一直低电平,通信失败。所以,上拉电阻一定要加,一般4.7kΩ左右,具体看总线电容和速率。

核心要点:

  • SCL:时钟线,由主设备控制
  • SDA:数据线,双向传输
  • 开漏输出 + 上拉电阻 = 线与逻辑
  • 支持多主多从,但同一时间只能有一个主设备控制总线

3.2 起始条件和停止条件:通信的“开关”

I2C通信的开始和结束,不是随便拉高拉低就行的。它有严格的时序要求。我个人习惯用逻辑分析仪抓波形,一眼就能看出问题。

起始条件(Start Condition):

SCL为高电平时,SDA从高电平跳变到低电平。这就是“我要开始通信了”的信号。

停止条件(Stop Condition):

SCL为高电平时,SDA从低电平跳变到高电平。这就是“通信结束”的信号。

注意:这两个条件都是SCL为高时,SDA发生跳变。而在数据传输过程中,SDA只能在SCL为低时变化。为什么?因为SCL高时,SDA要保持稳定,这样接收端才能正确采样。

我曾经踩过的坑:

有一次,我在代码里连续发送多个字节,中间忘了发停止条件。结果下一个起始条件被误判为数据位,整个通信乱套了。所以,每次通信结束,一定要发停止条件。除非你想用重复起始条件(Repeated Start),那是另一种玩法。

3.3 数据帧格式:字节、应答和时序

I2C的数据传输是以字节为单位的。每个字节8位,后面跟一个应答位(ACK/NACK)。

基本流程是这样的:

  1. 主设备发送起始条件
  2. 主设备发送从设备地址(7位或10位)+ 读写位(1位)
  3. 从设备应答(ACK)
  4. 主设备发送/接收数据字节,每个字节后跟一个应答位
  5. 主设备发送停止条件

应答位是怎么回事?发送方每发完一个字节,会释放SDA线(拉高),然后接收方如果应答,就把SDA拉低一个时钟周期。如果不应答(NACK),就保持高电平。NACK通常表示“我收完了,别再发了”或者“出错了”。

小技巧:

读EEPROM时,最后一个字节要发NACK,然后发停止条件。因为EEPROM知道你要结束了,就不再发送数据。我刚开始写驱动时,最后一个字节发了ACK,结果EEPROM又多发了一个字节,导致数据错位。嗯,这个细节很容易忽略。

3.4 7位地址与10位地址:够用与扩展

I2C地址有两种:7位和10位。最常见的还是7位地址。

7位地址:

地址范围0x00~0x7F,但有些地址是保留的(比如0x00用于通用呼叫)。实际可用的大概112个。对于大多数应用,7位地址足够了。比如EEPROM AT24C02,地址是0x50(1010000),加上读写位后,发送0xA0(写)或0xA1(读)。

你想想看,一个I2C总线上挂7个设备,每个设备地址不同,7位地址完全够用。但如果你要挂几十个设备,那就得用10位地址了。

10位地址:

10位地址的格式稍微复杂一点。它用两个字节来传输地址:

  • 第一个字节:11110 + 地址高2位 + 读写位
  • 第二个字节:地址低8位

这样地址范围就扩展到了0x000~0x3FF,总共1024个地址。不过,10位地址的设备不多,我做了这么多年项目,也就用过一两次。

地址类型 地址范围 可用地址数 常见应用
7位地址 0x00~0x7F 约112个 EEPROM、传感器、ADC等
10位地址 0x000~0x3FF 1024个 多设备系统、扩展应用

避坑指南:

我曾经在一个项目里,用了两个相同型号的EEPROM,结果发现它们地址一样,都是0x50。两个设备抢答,通信乱成一锅粥。后来我查了数据手册,发现AT24C02的地址引脚A0、A1、A2可以配置。我把一个的A0拉高,地址变成0x51,问题解决。所以,选型时一定要确认地址是否可配置,否则多个相同设备会冲突。

3.5 实际应用:EEPROM读写示例

说了这么多,我们来看个实际例子。以AT24C02为例,它的7位地址是0x50(1010000)。写一个字节到地址0x00:

// 伪代码示例
I2C_Start();                    // 起始条件
I2C_SendByte(0xA0);             // 设备地址 + 写位 (0x50 << 1 | 0)
I2C_WaitAck();                  // 等待应答
I2C_SendByte(0x00);             // 存储器地址
I2C_WaitAck();                  // 等待应答
I2C_SendByte(0x55);             // 要写入的数据
I2C_WaitAck();                  // 等待应答
I2C_Stop();                     // 停止条件

读一个字节从地址0x00:

// 伪代码示例
I2C_Start();                    // 起始条件
I2C_SendByte(0xA0);             // 设备地址 + 写位
I2C_WaitAck();                  // 等待应答
I2C_SendByte(0x00);             // 存储器地址
I2C_WaitAck();                  // 等待应答
I2C_Start();                    // 重复起始条件
I2C_SendByte(0xA1);             // 设备地址 + 读位
I2C_WaitAck();                  // 等待应答
data = I2C_ReadByte();          // 读取数据
I2C_SendNack();                 // 发送NACK
I2C_Stop();                     // 停止条件

注意读操作时,要先写地址,再重新起始,然后读数据。这个“重复起始条件”很多人会忘,直接用停止+起始代替。虽然也能工作,但不符合规范。我个人习惯严格按照规范来,避免兼容性问题。

调试建议:

如果你遇到I2C通信问题,别急着改代码。先用逻辑分析仪抓波形,看看起始条件、地址、应答位对不对。我90%的I2C问题,都是通过看波形解决的。波形不会骗人,代码才会。

好了,I2C协议基础就讲到这里。下一节我们会深入EEPROM的读写时序和掉电保护设计。记住,I2C看似简单,但细节决定成败。多动手,多抓波形,你也能成为I2C高手。