3. I2C总线详解:时序、寻址、读写操作与多主机仲裁

各位同学,今天我们来啃一块硬骨头——I2C总线。说实话,我入行那会儿,第一次调I2C驱动,被时序折腾得够呛。示波器一挂上去,波形乱七八糟,愣是调了两天才跑通。后来我才明白,I2C这东西,看着协议简单,但细节里全是坑。

I2C全称是Inter-Integrated Circuit,飞利浦公司发明的。它只用两根线——SCL(时钟)和SDA(数据),就能挂一堆设备。你想想看,这得多省IO口?我做过一个项目,主控只有8个GPIO,要接温度、湿度、气压、加速度计四个传感器,不用I2C根本搞不定。

3.1 I2C总线物理层

先说说物理层。I2C总线是开漏输出,加上拉电阻。什么意思呢?就是芯片只能把总线拉低,不能主动拉高。高电平全靠上拉电阻提供。嗯,这里要注意:上拉电阻的阻值很关键。

避坑指南:我曾经在一个项目里选了4.7kΩ的上拉电阻,结果总线电容太大,波形上升沿慢得像蜗牛。后来换成2.2kΩ,问题解决。一般100kHz标准模式下用4.7kΩ,400kHz快速模式下用2.2kΩ,具体还得看总线电容。

I2C总线上每个设备都有唯一地址。7位地址模式最多挂127个设备,10位地址模式能挂1023个。不过实际项目中,7位地址就够用了。我很少见到谁真挂上百个传感器。

3.2 I2C时序详解

时序是I2C的核心。说白了,就是SCL和SDA两根线在什么时间点该干什么事。我画了一张时序图,你们看看:

I2C总线时序图 SCL SDA 起始 停止 D7 D6 D5 D4 D3 D2 D1 D0 ACK 7位从机地址 R/W ACK 时序关键点: 1. 起始条件:SCL高电平时,SDA从高变低 2. 数据采样:SCL高电平时,SDA必须稳定(数据在SCL低电平时变化) 3. 停止条件:SCL高电平时,SDA从低变高 4. 每传输8位数据后,从机发送ACK(拉低SDA) 5. 时钟频率:标准模式100kHz,快速模式400kHz,高速模式3.4MHz

时序里最核心的就是起始条件和停止条件。起始条件是SCL高电平时,SDA从高变低。停止条件是SCL高电平时,SDA从低变高。这两个条件之外的任何SDA变化都不算数。

个人经验:我习惯在写驱动时,把起始和停止条件封装成两个独立函数。这样代码清晰,调试也方便。别小看这个习惯,有一次我排查一个I2C死锁问题,就是因为起始条件没发完整,总线一直处于忙状态。

3.3 I2C寻址机制

I2C寻址分7位和10位两种。7位地址最常见。每个从机设备出厂时,厂家会分配一个固定地址,比如MPU6050的地址是0x68。但有些芯片会留出几个引脚,让你自己配置地址位。

举个例子,温度传感器LM75的地址是0x48~0x4F,具体取决于A0、A1、A2三个引脚的电平。我做过一个项目,用了8个LM75,每个引脚配置不同,地址从0x48到0x4F全用上了。这样一根I2C总线上就能挂8个温度传感器。

寻址过程是这样的:主机发送起始条件后,先发7位从机地址,再加1位读写位(0表示写,1表示读)。然后等待从机应答。如果从机拉低SDA,表示应答(ACK),否则表示非应答(NACK)。

重点:I2C总线上所有从机都会收到主机发送的地址。只有地址匹配的那个从机才会应答。其他从机保持沉默。这就是I2C的寻址原理。

3.4 I2C读写操作

读写操作是I2C协议的核心。我直接上代码,你们看看实际怎么写:

// I2C写操作示例(伪代码)
// 向从机地址0x68的寄存器0x3B写入数据0x01

void i2c_write_byte(uint8_t slave_addr, uint8_t reg_addr, uint8_t data) {
    i2c_start();                    // 发送起始条件
    i2c_send_byte(slave_addr << 1 | 0);  // 发送从机地址 + 写位
    i2c_wait_ack();                 // 等待从机应答
    i2c_send_byte(reg_addr);        // 发送寄存器地址
    i2c_wait_ack();                 // 等待从机应答
    i2c_send_byte(data);            // 发送数据
    i2c_wait_ack();                 // 等待从机应答
    i2c_stop();                     // 发送停止条件
}

// I2C读操作示例(伪代码)
// 从从机地址0x68的寄存器0x3B读取1字节数据

uint8_t i2c_read_byte(uint8_t slave_addr, uint8_t reg_addr) {
    uint8_t data;
    
    // 先写寄存器地址
    i2c_start();                    // 发送起始条件
    i2c_send_byte(slave_addr << 1 | 0);  // 发送从机地址 + 写位
    i2c_wait_ack();                 // 等待从机应答
    i2c_send_byte(reg_addr);        // 发送寄存器地址
    i2c_wait_ack();                 // 等待从机应答
    
    // 再读数据(重复起始条件)
    i2c_start();                    // 发送重复起始条件
    i2c_send_byte(slave_addr << 1 | 1);  // 发送从机地址 + 读位
    i2c_wait_ack();                 // 等待从机应答
    data = i2c_read_byte();         // 读取数据
    i2c_send_nack();                // 发送非应答(表示读完了)
    i2c_stop();                     // 发送停止条件
    
    return data;
}

读操作里有个关键点——重复起始条件。为什么不用停止条件再发起始条件?因为这样会释放总线,其他主机可能抢占总线。重复起始条件保证整个操作是原子的,不会被中断。

避坑指南:我曾经在一个项目里,读操作时忘了发重复起始条件,直接发了停止再起始。结果在总线上有多个主机时,数据被另一个主机篡改了。排查了三天才发现这个问题。记住:读操作一定要用重复起始条件!

3.5 多主机仲裁

多主机仲裁是I2C最精妙的设计。多个主机可以同时挂在总线上,谁先拉低SDA,谁就赢得仲裁。输的那个主机自动退出,不破坏总线状态。

仲裁原理很简单:每个主机在发送数据时,会同时监控SDA电平。如果自己发送的是高电平,但检测到SDA是低电平,说明有其他主机在拉低总线。这时候,这个主机就输了,立即停止发送。

我画个流程图,你们看看仲裁过程:

I2C多主机仲裁流程图 多个主机同时发送 SDA电平是否匹配? 是 继续发送 否 退出仲裁,释放总线 等待总线空闲后重试 仲裁规则:多个主机同时发送时,谁先拉低SDA谁赢。输的主机自动退出,不破坏总线。 仲裁过程中,SCL时钟由所有主机共同驱动,直到仲裁结束。

仲裁过程中,SCL时钟是由所有主机共同驱动的。每个主机都会输出自己的时钟,但最终SCL的低电平时间由最长的那个决定,高电平时间由最短的那个决定。嗯,这个细节很多人不知道。

关键点:I2C仲裁不会丢失数据。赢得仲裁的主机继续发送,输的主机检测到总线被占用后,会在下一个字节开始时重新尝试。整个过程对上层应用是透明的。

3.6 实际项目中的I2C调试技巧

最后,我分享几个实战中总结的调试技巧:

  • 用示波器抓波形:别光看代码,波形会告诉你真相。我习惯抓起始条件、地址、ACK三个关键点。
  • 注意总线电容:长距离走线或挂太多设备,总线电容会变大,导致波形畸变。这时候要么降低速率,要么换小阻值上拉电阻。
  • 处理NACK:从机不应答时,主机要能正确处理。我见过不少代码,NACK后直接死循环,导致系统卡死。
  • 超时机制:I2C总线可能被从机拉死(比如从机死机一直拉低SCL)。加个超时机制,超时后复位总线。
个人习惯:我写I2C驱动时,一定会加一个总线恢复函数。当检测到总线忙时,发送9个时钟脉冲,让从机释放总线。这个技巧救过我很多次。

好了,I2C总线的核心内容就这些。时序、寻址、读写、仲裁,每个点都值得你花时间吃透。下次调I2C驱动时,记得先看波形,再查代码。波形对了,代码基本没问题。

总结:I2C总线虽然只有两根线,但协议设计得非常精巧。理解时序是基础,掌握寻址和读写是核心,搞懂仲裁才能应对复杂系统。把这些吃透了,你就能在项目中游刃有余。

可可精品资料库