I2C协议基础:物理层、起始条件、停止条件、数据有效性与应答机制

各位同学,今天我们正式进入I2C协议的核心地带。说实话,很多做嵌入式开发的朋友,调了几年I2C设备,遇到总线锁死还是只会复位。我个人觉得,这往往是因为对协议底层缺乏直觉。今天我们就从物理层开始,把I2C的每一个细节掰开揉碎。

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

I2C总线只需要两根线:SDA(串行数据线)和SCL(串行时钟线)。但这两根线不是普通的推挽输出,而是开漏输出。

什么叫开漏?说白了,就是芯片只能把线拉低到GND,不能主动拉高。那高电平谁来给?上拉电阻。总线空闲时,两根线都被上拉电阻拉到VDD(通常是3.3V或5V)。

关键点:开漏结构允许多个设备共用总线。任何设备都可以拉低总线,但谁都不能独占。这就是I2C“多主多从”的物理基础。

我在项目中遇到过一个问题:OLED屏幕在长线传输时偶尔花屏。查了半天,发现是上拉电阻选大了,信号上升沿太慢。嗯,这里要注意——上拉电阻的取值直接影响通信速率。

总线速率 推荐上拉电阻 总线电容
标准模式(100kHz) 4.7kΩ ~ 10kΩ < 400pF
快速模式(400kHz) 2.2kΩ ~ 4.7kΩ < 200pF
高速模式(3.4MHz) 1kΩ ~ 2.2kΩ < 100pF

我的经验:调试OLED时,先用4.7kΩ电阻。如果波形上升沿太缓,再换2.2kΩ。别一上来就用1kΩ,电流大了反而可能烧IO口。

2.2 起始条件与停止条件:总线的“开始”和“结束”

I2C通信的起点和终点,由SDA和SCL的特定电平变化来定义。你想想看,如果总线上挂着好几个设备,怎么告诉它们“我要开始说话了”?

  • 起始条件(S):SCL为高电平时,SDA从高电平跳变到低电平。
  • 停止条件(P):SCL为高电平时,SDA从低电平跳变到高电平。

为什么这么设计?因为SCL高电平时,SDA本应保持稳定。一旦SDA变化,就表示“这不是正常数据,是控制信号”。

曾经踩过的坑:有一次我写代码,起始条件后忘了发停止条件,结果总线一直处于“忙”状态,其他设备全都不响应。后来我养成习惯:每次通信结束,一定检查是否发送了停止条件。

我们来看一段典型的起始和停止代码(基于STM32 HAL库):

// 起始条件:SCL高时,SDA从高变低
void I2C_Start(void) {
    SDA_HIGH();
    SCL_HIGH();
    delay_us(5);          // 保持时间 > 4.7μs
    SDA_LOW();
    delay_us(5);          // 保持时间 > 4μs
    SCL_LOW();            // 准备传输数据
}

// 停止条件:SCL高时,SDA从低变高
void I2C_Stop(void) {
    SDA_LOW();
    SCL_HIGH();
    delay_us(5);          // 保持时间 > 4μs
    SDA_HIGH();
    delay_us(5);          // 总线释放时间
}

2.3 数据有效性:什么时候读数据?

I2C的数据传输有个铁律:数据在SCL低电平时变化,在SCL高电平时采样。

为什么会这样?你想想看,如果数据在SCL高电平时变化,接收方就不知道哪个时刻是“稳定”的。所以协议规定:

  • SCL为低电平时,SDA可以变化(发送方准备下一位数据)。
  • SCL为高电平时,SDA必须保持稳定(接收方读取数据)。

我建议你在看逻辑分析仪波形时,重点关注SCL的上升沿。每个上升沿,就是一次数据采样点。

数据有效性规则:每个SCL时钟传输1位数据,高位在前(MSB first)。8个时钟后,完成一个字节的传输。

来看一个字节发送的时序图:

SCL:  ▔▁▔▁▔▁▔▁▔▁▔▁▔▁▔▁▔▁▔▁▔▁▔▁
SDA:  ▔▁▁▁▔▔▔▁▁▔▔▁▁▔▔▔▁▁▔▔▁▔
       ↑  ↑  ↑  ↑  ↑  ↑  ↑  ↑
       7  6  5  4  3  2  1  0  (数据位,MSB first)

2.4 应答机制:确认收到

每发送完一个字节(8位数据),发送方会释放SDA,接收方需要在第9个时钟周期拉低SDA,表示“我收到了”。这就是应答位(ACK)。

如果接收方不拉低SDA(保持高电平),那就是非应答(NACK)。NACK通常表示:

  • 从设备忙,无法接收数据。
  • 从设备不支持该命令。
  • 数据接收完毕,不再需要更多数据。

调试技巧:我曾经调一个OLED驱动,发现发送地址后总是收到NACK。用示波器一看,地址字节的第8位(读写位)写反了。记住:地址左移1位,最低位是读写位(0=写,1=读)。

应答检测的代码实现:

// 检测从设备应答
uint8_t I2C_CheckACK(void) {
    SDA_INPUT();          // 释放SDA,改为输入模式
    SCL_HIGH();
    delay_us(5);
    uint8_t ack = SDA_READ();  // 读取SDA电平
    SCL_LOW();
    SDA_OUTPUT();         // 恢复输出模式
    return (ack == 0) ? 1 : 0; // 0=ACK, 1=NACK
}

2.5 知识体系总览

下面这张图,把I2C协议的核心要素串在一起。我建议你把它存下来,调试时对照着看。

I2C协议核心知识体系 物理层 起始/停止条件 数据有效性 应答机制 关键要点: • 开漏输出 + 上拉电阻 = 多设备共享总线 • 起始条件:SCL高时SDA下降沿;停止条件:SCL高时SDA上升沿 • 数据在SCL低电平时变化,高电平时采样 • 每字节后第9个时钟为应答位,低电平=ACK,高电平=NACK

2.6 完整通信流程

一个完整的I2C写操作流程是这样的:

  1. 主机发送起始条件(S)
  2. 主机发送从机地址(7位地址 + 1位读写位,写=0)
  3. 从机应答(ACK)
  4. 主机发送寄存器地址(告诉从机要写哪个寄存器)
  5. 从机应答(ACK)
  6. 主机发送数据字节(可以连续发多个)
  7. 从机应答(每字节后都有ACK)
  8. 主机发送停止条件(P)

记住这个流程:起始 → 地址+读写位 → ACK → 数据 → ACK → ... → 停止。这是所有I2C设备通信的通用模板。

以OLED驱动芯片SSD1306为例,写一个字节到命令寄存器的代码:

void OLED_WriteCmd(uint8_t cmd) {
    I2C_Start();                    // 1. 起始条件
    I2C_SendByte(0x78 << 1 | 0);   // 2. 地址0x78 + 写位
    I2C_CheckACK();                 // 3. 等待应答
    I2C_SendByte(0x00);             // 4. 命令寄存器标识
    I2C_CheckACK();                 // 5. 等待应答
    I2C_SendByte(cmd);              // 6. 发送命令字节
    I2C_CheckACK();                 // 7. 等待应答
    I2C_Stop();                     // 8. 停止条件
}

曾经踩过的坑:有一次我连续写多个字节,忘了在每字节后检测ACK。结果从机在第3个字节时NACK了,我还在继续发数据。从那以后,我所有I2C代码都加了ACK检测,一旦收到NACK立即停止并重试。

好了,I2C协议的基础就讲到这里。这些内容看起来简单,但每一个细节都可能在调试时给你“惊喜”。我个人建议你花点时间,用逻辑分析仪抓一下实际波形,对照着协议规范看一遍。相信我,这比看十遍文档都管用。