4. I2C通信模式:主机发送/接收模式、从机发送/接收模式、多主机通信与总线仲裁实战

I2C 的通信模式,说白了就是谁说话、谁听话、谁先停的问题。很多初学者觉得 I2C 协议简单,不就是两根线嘛。但真到了多设备挂一总线的时候,各种奇奇怪怪的问题就冒出来了。我刚开始做项目时也吃过不少亏,今天咱们就把这四种模式掰开揉碎了讲清楚。

4.1 主机发送模式:你主动,你控制时钟

主机发送模式是最常用的场景。主机产生时钟,主动向从机写数据。流程其实很固定:

  1. 主机发 START 条件:SCL 高电平时,SDA 从高拉低。
  2. 主机发从机地址 + 写位 (0):7 位地址 + 1 位 R/W,共 8 位。
  3. 等待从机 ACK:从机拉低 SDA 表示应答。
  4. 主机发寄存器地址(可选,取决于器件)。
  5. 主机发数据字节:每发完一个字节,等从机 ACK。
  6. 主机发 STOP 条件:SCL 高电平时,SDA 从低拉高。

关键点:主机发送模式下,时钟始终由主机产生。从机只能拉低 SCL 做时钟拉伸(Clock Stretching),但不能主动产生时钟。

我在项目中遇到过一个问题:某款温度传感器在发送数据后,需要 10ms 的转换时间。如果主机连续发数据,从机会一直拉低 SCL 做时钟拉伸。我当时没处理这个等待,结果数据全乱了。嗯,这里要注意:一定要检查从机是否在拉伸时钟,否则后续数据会错位。

4.2 主机接收模式:你读数据,从机被动给

主机接收模式,就是主机主动去读从机的数据。流程稍微有点不同:

  1. 主机发 START + 从机地址 + 读位 (1)。
  2. 等待从机 ACK。
  3. 主机发寄存器地址(如果需要指定读哪个寄存器)。
  4. 主机发重复 START + 从机地址 + 读位 (1):这叫 Repeated Start,很多新手会漏掉。
  5. 从机发数据,主机收数据并给 ACK:主机每收一个字节,拉低 SDA 表示还要继续读。
  6. 主机收最后一个字节时给 NACK:告诉从机“够了,别发了”。
  7. 主机发 STOP。

避坑指南:我曾经在主机接收模式下忘记发 NACK,结果从机一直发数据,总线被锁死了。记住:最后一个字节必须给 NACK,否则从机以为你还要数据,会一直占用总线。

4.3 从机发送/接收模式:你被动响应,别抢时钟

从机模式相对简单,因为你不能主动发起通信。从机只需要:

  • 监听地址:检测 START 条件后,比对收到的地址是否匹配。
  • 发送 ACK/NACK:地址匹配就拉低 SDA 应答。
  • 根据 R/W 位决定方向:
    • R/W = 0:从机接收模式,主机写数据给你。
    • R/W = 1:从机发送模式,你发数据给主机。
  • 处理时钟拉伸:如果你需要时间准备数据,可以拉低 SCL 让主机等待。

你想想看,从机其实就是一个听话的角色。但很多 MCU 的从机模式实现有坑。我记得有一次调试一个 I2C 从机,发现主机发地址后从机不 ACK。查了半天,原来是从机的中断优先级太低,导致地址匹配中断没来得及响应。嗯,从机的中断响应时间必须小于 SCL 低电平时间,否则会丢数据。

4.4 多主机通信与总线仲裁:谁先抢到谁说话

多主机通信是 I2C 协议最精彩的部分。多个主机可以挂在同一总线上,谁先抢到总线谁说话。这靠的是总线仲裁机制。

4.4.1 仲裁原理:线与逻辑

I2C 的 SDA 和 SCL 都是开漏输出,外部有上拉电阻。这意味着:谁拉低,总线就是低电平。这就是“线与”逻辑。

仲裁过程是这样的:

  1. 两个主机同时发 START,都开始发送地址。
  2. 每个主机在发送每一位时,都会监测 SDA 的实际电平。
  3. 如果某个主机发送了高电平,但检测到 SDA 是低电平(被另一个主机拉低了),它就知道自己仲裁失败了。
  4. 仲裁失败的主机立即释放总线,不再发送数据。
  5. 仲裁成功的主机继续通信,不受影响。

重要:仲裁不会丢失数据,也不会破坏通信。因为仲裁失败的从机在检测到失利的位时,已经停止发送了。整个过程中,从机只看到一个完整的主机在通信。

4.4.2 仲裁的细节:地址和数据都能仲裁

很多人以为仲裁只发生在地址阶段。其实数据阶段也能仲裁。比如两个主机都想写数据到不同的从机,它们发的地址不同,仲裁会在地址阶段决出胜负。但如果两个主机发相同的地址,仲裁会延续到数据阶段。

仲裁阶段 仲裁对象 结果
START 条件 SDA 下降沿 谁先拉低谁赢
地址阶段 地址位 发低电平的赢
R/W 位 读写位 写 (0) 赢读 (1)
数据阶段 数据位 发低电平的赢
ACK 阶段 应答位 从机拉低即 ACK

4.4.3 多主机通信的实战要点

我在一个项目里做过双主机系统:一个主控 MCU 和一个协处理器共享 I2C 总线。刚开始没处理好仲裁,两个主机同时发数据时总线乱套了。后来总结了几个要点:

  • 每个主机必须有唯一的地址:虽然仲裁能解决冲突,但最好避免两个主机同时发相同地址。
  • 主机在发送前要检测总线空闲:检测到 STOP 条件后,等待至少 4.7μs(标准模式)再发 START。
  • 处理仲裁失败的中断:大多数 MCU 的 I2C 外设在仲裁失败时会触发中断,你需要在这个中断里释放总线并重试。
  • 时钟同步:多主机模式下,所有主机的 SCL 会通过线与逻辑自动同步。最快的主机会被拉低等待,最慢的主机决定 SCL 高电平时间。

个人经验:我建议在多主机系统中,每个主机都实现一个“退避重试”机制。仲裁失败后,等待一个随机时间再重试。这样可以避免两个主机反复冲突。我曾经用定时器产生伪随机数做退避,效果很好。

4.5 四种模式对比总结

模式 谁产生时钟 谁控制数据方向 典型应用
主机发送 主机 主机→从机 配置传感器寄存器、写 EEPROM
主机接收 主机 从机→主机 读取传感器数据、读 EEPROM
从机发送 主机 从机→主机 从机主动上报数据
从机接收 主机 主机→从机 从机接收配置命令
多主机 仲裁胜出的主机 由胜出主机决定 双 MCU 共享总线、热插拔设备

4.6 知识结构图:I2C 通信模式全景

I2C 通信模式全景图 I2C 通信模式 主机发送模式 主机接收模式 从机发送模式 从机接收模式 多主机通信与总线仲裁 线与逻辑仲裁 地址/数据仲裁 时钟同步机制 实战要点 • 仲裁失败后必须释放总线并重试 • 最后一个字节必须发 NACK

4.7 实战代码片段:多主机仲裁处理

下面是一个简化的多主机仲裁处理伪代码,我用 STM32 HAL 库的风格写的。实际项目中要根据你的 MCU 调整:

// 多主机发送函数,带仲裁重试
uint8_t I2C_MultiMaster_Transmit(uint8_t addr, uint8_t *data, uint16_t len)
{
    uint8_t retry = 3;
    uint8_t result = 0;
    
    while (retry--) {
        // 检测总线是否空闲
        while (!I2C_IsBusFree());
        
        // 发送 START + 地址
        if (I2C_Start() != HAL_OK) {
            // 仲裁失败,释放总线
            I2C_Stop();
            // 随机退避,避免再次冲突
            delay_ms(rand() % 10 + 1);
            continue;
        }
        
        // 发送数据
        result = I2C_SendData(addr, data, len);
        
        // 发送 STOP
        I2C_Stop();
        
        if (result == HAL_OK) return HAL_OK;
        
        // 如果仲裁失败,重试
        if (result == HAL_I2C_ERROR_ARBITRATION_LOST) {
            I2C_Stop();
            delay_ms(rand() % 10 + 1);
        } else {
            return result;
        }
    }
    return HAL_TIMEOUT;
}

注意:上面的代码中,I2C_IsBusFree() 检测的是 SDA 和 SCL 是否都为高电平。但要注意:总线空闲检测必须在检测到 STOP 条件后至少等待 4.7μs(标准模式 100kHz)才能确认。否则可能误判。

好了,四种模式和多主机仲裁就讲到这里。这些内容在实际项目中几乎天天用到。尤其是多主机仲裁,很多人觉得用不上,但一旦系统复杂起来,比如有多个 MCU 或者带热插拔设备,仲裁机制就是保命符。我建议你在开发板上亲手试一下双主机通信,把两个 MCU 的 SDA/SCL 连在一起,写代码让它们同时发数据,看看仲裁是怎么工作的。嗯,实践出真知。