2. I2C协议详解:I2C总线物理层、时序、起始/停止条件、数据帧格式

大家好,欢迎来到第二讲。上一章我们聊了EEPROM和Flash的基本概念,这一章咱们得把I2C协议彻底吃透。说实话,I2C协议是嵌入式开发里最常用的串行总线之一,但也是坑最多的地方。我见过不少工程师,代码能跑通,但一换芯片、一换线长就出问题,根本原因就是对物理层和时序理解不够深。

2.1 I2C总线物理层:两根线怎么干活?

I2C总线就两根线:SCL(时钟线)和SDA(数据线)。但你别小看这两根线,它们的设计其实很巧妙。

开漏输出 + 上拉电阻,这是I2C物理层的核心。什么叫开漏?说白了,就是芯片的引脚只能拉低电平,不能主动拉高。要拉高怎么办?靠外部的上拉电阻。所以总线空闲时,SCL和SDA都是高电平。

我在项目中遇到过一个问题:有次用了一个长距离的I2C总线,大概50厘米,结果通信老是出错。查了半天,发现是上拉电阻选得太大了,信号上升沿太慢。嗯,这里要注意,上拉电阻的取值跟总线电容有关。一般4.7kΩ是常用值,但总线长了、设备多了,就得用更小的电阻,比如2.2kΩ甚至1kΩ。

上拉电阻选择经验:

  • 标准模式(100kHz):4.7kΩ ~ 10kΩ
  • 快速模式(400kHz):2.2kΩ ~ 4.7kΩ
  • 高速模式(3.4MHz):1kΩ ~ 2.2kΩ
  • 总线电容每增加100pF,电阻建议减小30%

还有一个容易忽略的点:I2C设备地址。每个I2C设备都有一个7位或10位的地址。7位地址最多挂127个设备,但实际中因为地址冲突,我一般建议不超过10个。你想想看,地址线只有两根,设备多了地址冲突的概率就大。

2.2 时序基础:时钟和数据怎么配合?

I2C的时序其实不复杂,但细节决定成败。咱们先看最基本的时序图。

SCL是时钟线,由主设备控制。SDA是数据线,数据在SCL低电平时变化,在SCL高电平时采样。这个规则一定要记住:数据在时钟高电平时必须稳定

为什么会这样?因为接收方是在SCL的上升沿或高电平期间读取SDA的。如果SDA在此时变化,读到的数据就是错的。我刚开始做驱动时,有一次写了个I2C驱动,数据老是错位,查了两天才发现是SDA在SCL高电平时发生了跳变。后来我加了个延时,确保SDA只在SCL低电平时变化,问题就解决了。

个人习惯:我在写I2C时序代码时,会在SCL拉低后加一个微小的延时(比如1us),确保SDA完全稳定后再改变。这个习惯帮我避免了很多奇怪的问题。

2.3 起始条件和停止条件

I2C通信的开始和结束,靠的是两个特殊的信号组合:

  • 起始条件(Start Condition):SCL高电平时,SDA从高电平跳变到低电平。
  • 停止条件(Stop Condition):SCL高电平时,SDA从低电平跳变到高电平。

注意,这两个条件都要求SCL在高电平。为什么?因为正常数据传输时,SCL高电平时SDA是不允许变化的。所以起始和停止条件就是利用了这个"不允许"的时刻,制造了一个特殊的信号。

我曾经在一个项目中,用逻辑分析仪抓I2C波形,发现停止条件之后总线没有释放。查了半天,原来是从设备在停止条件后还拉着SDA不放。这种情况通常是从设备有bug,或者主设备没有正确发送停止条件。解决办法是:在发送停止条件后,加一个延时再释放总线,或者用GPIO模拟I2C时强制拉高SDA。

避坑指南:我曾经在STM32上用硬件I2C,发现停止条件有时发不出去。后来查手册才知道,硬件I2C的停止条件需要在特定寄存器位清零后才能发送。如果你用硬件I2C遇到类似问题,建议先检查一下总线是否处于忙状态。

2.4 数据帧格式:一个字节怎么传?

I2C的数据帧格式很规整。每个字节8位,高位在前(MSB first)。传输完8位数据后,接收方要发送一个应答位(ACK)。

具体流程是这样的:

  1. 主设备发送起始条件
  2. 主设备发送7位从设备地址 + 1位读写位(0=写,1=读)
  3. 从设备发送应答位(ACK=0表示应答,NACK=1表示不应答)
  4. 主设备发送数据字节(或接收数据字节)
  5. 每发送完一个字节,接收方发送应答位
  6. 主设备发送停止条件

这里有个细节:应答位是在第9个时钟周期产生的。主设备在第9个时钟周期释放SDA,从设备拉低SDA表示应答。如果从设备不应答(比如地址不对、设备忙),SDA保持高电平,主设备就知道出问题了。

我写个简单的代码示例,用GPIO模拟I2C发送一个字节:

// GPIO模拟I2C发送一个字节
void i2c_send_byte(uint8_t data) {
    for (int i = 0; i < 8; i++) {
        // SCL拉低,准备改变SDA
        SCL_LOW();
        delay_us(1);
        
        // 设置SDA,高位在前
        if (data & 0x80) {
            SDA_HIGH();
        } else {
            SDA_LOW();
        }
        data <<= 1;  // 左移,准备下一位
        
        // SCL拉高,从设备采样
        delay_us(1);
        SCL_HIGH();
        delay_us(2);  // 保持高电平足够时间
    }
    
    // 第9个时钟:释放SDA,等待应答
    SCL_LOW();
    delay_us(1);
    SDA_INPUT();  // 释放SDA,改为输入模式
    SCL_HIGH();
    delay_us(1);
    
    // 读取应答位
    if (SDA_READ() == 0) {
        // ACK
    } else {
        // NACK,处理错误
    }
    
    SCL_LOW();
    delay_us(1);
    SDA_OUTPUT();  // 恢复输出模式
}

这段代码我实际用过很多次,在AT24C02、M24C64等EEPROM上都验证过。注意看,我在SCL拉低后都加了延时,这是为了保证时序的稳定性。

2.5 多字节传输和重复起始条件

实际应用中,我们经常要读写多个字节。比如读一个EEPROM,先发送地址,再读取数据。这时候就需要用到重复起始条件(Repeated Start)

重复起始条件就是在没有停止条件的情况下,再次发送起始条件。这样做的好处是:总线不会被释放,其他主设备不会中途插入。

典型的读操作流程:

  1. 起始条件
  2. 发送设备地址 + 写位(0)
  3. 等待应答
  4. 发送要读取的内存地址(1字节或2字节)
  5. 等待应答
  6. 重复起始条件
  7. 发送设备地址 + 读位(1)
  8. 等待应答
  9. 读取数据字节,每读一个字节发送ACK
  10. 读取最后一个字节时发送NACK,表示不再读了
  11. 停止条件

你想想看,如果没有重复起始条件,就得先发停止条件,再发起始条件。这中间总线可能被其他设备抢走,导致数据错乱。所以重复起始条件在多主设备系统中特别重要。

关键点总结:

信号 SCL状态 SDA变化 说明
起始条件 高电平 高→低 通信开始
数据位 低电平 可变化 数据准备
数据位 高电平 必须稳定 数据采样
应答位 第9个时钟 从设备拉低 ACK=0
停止条件 高电平 低→高 通信结束

2.6 实际调试中的常见问题

最后,我分享几个实际调试中遇到的I2C问题,希望能帮你少走弯路。

问题1:总线卡死,SCL或SDA一直被拉低。
这种情况通常是从设备没有释放总线。解决办法:主设备主动发送9个时钟脉冲,让从设备释放SDA。我写过一个小函数,专门用来恢复I2C总线。

问题2:地址发送后没有应答。
先检查地址对不对。7位地址左移1位,再加上读写位。比如设备地址是0x50,写操作就是0xA0,读操作是0xA1。我见过有人直接发0x50,结果没有应答,因为0x50是7位地址,不是8位地址字节。

问题3:时序太快,从设备跟不上。
EEPROM的写操作通常需要时间(比如5ms),如果连续写太快,从设备会不应答。解决办法:每次写操作后加延时,或者检查应答位,如果NACK就重试。

我的调试工具:逻辑分析仪是调试I2C的利器。我习惯用Saleae Logic,便宜又好用。抓一次波形,所有问题一目了然。如果你没有逻辑分析仪,用示波器也可以,但触发设置要调好。

好了,这一章的内容就到这里。I2C协议看起来简单,但实际用起来细节很多。下一章我们会讲EEPROM的具体驱动实现,到时候会用到今天学的所有知识。记住,理解物理层和时序,是写好驱动的基础。