4. 总线仲裁与冲突检测归纳总结

总线仲裁,说白了就是谁抢到话权的问题。

我刚开始接触 I2C 的时候,总觉得仲裁这事儿挺玄乎。后来在线路上抓波形,亲眼看到两个主控同时拉低 SDA,才发现——原来硬件层面早就把这套规则定死了。

4.1 仲裁的本质:谁先拉低谁说了算

I2C 总线上可以有多个主控同时发起传输。但总线只能用一根 SDA 线。那怎么让?答案是:谁先输出低电平,谁就占用总线。

想一下,SDA 是开漏结构,外加上拉电阻。

  • 主控输出高电平时,是释放总线,由外部上拉电阻拉高。
  • 输出低电平时,强行将总线拉低。

如果两个主控同时输出数据,一个输出高、一个输出低,总线上的电平就是低。输出高的那个主控,就会在 SCL 的采样点发现自己输出了高电平却读回了低电平——哎呀,丢了总线。

我在项目里遇到过,两个从机地址完全一样的情况。那个混乱啊,通信根本没法正常进行。后来我改用唯一地址,加了一级硬件地址跳线,问题才平息。

仲裁结果判定

主控器1输出主控器2输出总线状态仲裁结果
高高高不冲突,双方继续
低高低主控器2失去总线
低低低不冲突,双方继续
高低低主控器1失去总线

为什么仲裁不会造成数据损坏?因为 I2C 只关心数据线上的电平,哪个设备拉低了总线,哪个设备就继续控制。失去总线的设备,在仲裁失去时刻必须释放 SDA 并转为从模式。注意,是 释放 SDA。

4.2 SCL 同步:时钟的握手规则

多个主控同时跑 I2C,时钟怎么对齐?靠 SCL 同步。

每个主控都有自己的时钟发生器。SCL 同样是开漏结构,所以多个主控一起拉低 SCL 时,总线上的 SCL 周期会被拉长较慢的那个主控所控制。换言之,SCL 的低电平时间是所有主控中最长的,高电平时间是所有主控中最短的。

举个例子:主控A的低电平持续 10us,高电平持续 10us;主控B的低电平持续 12us,高电平持续 8us。

  1. 总线低电平:取最长值,12us。
  2. 总线高电平:取最短值,8us。

结果就是,总线以较慢的节奏走完一个周期。这就是 I2C 的时钟同步机制。我之前调试过一个传感器,从机对时钟低电平时间要求比较严格,卡在 10us 以下就会采样出错。后来通过同步机制,我故意在软件里拉长主控的时钟,反而解决了问题。

如果软件上只做一次仲裁判断而不重试,一旦遇到总线冲突,通信就永远卡死了。所以我建议你至少重试三次,每次等待一个仲裁失败标志清除的间隔再发起。

4.3 冲突检测与错误位处理

仲裁失败后,I2C 控制器通常会在状态寄存器里置一个仲裁丢失标志。你需要做三件事:

  • 停止当前传输:发出 STOP 条件,或让总线还处于空闲状态。
  • 清除中断标志:一般读状态寄存器再写某些位可以清零。
  • 重新发起 START:别着急,等一个最小空闲时间(总线空闲时间)后再发。

这里有个容易踩坑的地方:

我曾经在一个 I2C 扩展芯片的驱动里,连续两次仲裁失败后直接返回错误码。结果上层软件看到错误就复位了整条总线。后来我把重试次数增加到 5 次,每次间隔 10 微秒,再也没有因为仲裁失败导致系统卡死。

另外,我建议你检查一下从机地址在传输过程中是否被其他主控的地址覆盖。标准的数据范本:

// I2C 仲裁恢复伪代码示例
while (retry_count < MAX_RETRY) {
    I2C_Start();
    I2C_SendByte(slave_addr << 1 | I2C_WRITE);
    
    if (I2C_GetACK() == ACK) {
        // 成功传输数据
        break;
    } else {
        // 仲裁丢失或 NACK
        I2C_Stop();
        retry_count++;
        delay_us(10); // 等待总线释放
        
        // 读取状态清除仲裁标志
        status = I2C_ReadStatus();
        if (status & I2C_ARBITRATION_LOST) {
            I2C_ClearArbitrationLostFlag();
        }
    }
}

if (retry_count >= MAX_RETRY) {
    // 上报错误,等待用户干预
    error_handler();
}

很多人会问:为什么地址阶段也可能仲裁?因为多个主控可能同时寻址同一个从机,但它们的地址字节相同,到了数据阶段才可能分开。实际上,从起始条件后的第一个字节就开始仲裁了。地址一样的时候,后面数据阶段才会真正分出胜负。

4.4 仲裁与重试决策流程

我把仲裁和重试的整体逻辑整理成一个流程框架:

I2C 总线仲裁与重试决策流程 开始传输 主控发送地址 监控 SDA 电平 SDA 与输出一致? (判断是否仲裁) 一致(无冲突) 不一致(仲裁失败) 正常传输后续数据 发送 STOP 条件 传输成功 仲裁丢失 释放 SDA,转为从模式 检查重试计数 重试 < 次数 重试 ≥ 次数 延时等待总线空闲 回到开始传输 更新重试计数 报告错误 错误处理 避免无限制重试,建议最大重试 3~5 次

我习惯给业务层提供一个定时器接口,用来控制重试的间隔和最大次数。比如这样:

typedef struct {
    uint8_t max_retry;
    uint16_t retry_interval_us;
    uint16_t current_attempt;
} I2C_RetryConfig;

// 初始化配置
I2C_RetryConfig config = {5, 10, 0};

// 每次仲裁失败后的重试调用
I2C_Status I2C_RetrySend(I2C_RetryConfig *cfg) {
    while (cfg->current_attempt < cfg->max_retry) {
        if (I2C_SendBytes() == I2C_OK) {
            cfg->current_attempt = 0;
            return I2C_OK;
        }
        cfg->current_attempt++;
        delay_us(cfg->retry_interval_us);
    }
    cfg->current_attempt = 0;
    return I2C_ERROR_ARBITRATION_LOST;
}

注意,重试间隔不能太短。我有个血的教训:在一个多主控的板子上,我设的间隔只有 2us,结果总线一直处于仲裁-失败-重试的死循环里。后来改成 15us,问题才消失。

不要以为从机地址不同就不会冲突。两个主控同时发 START 条件,即使它们的目标从机地址不同,在地址的第一个位就开始仲裁了。输的那一方必须立刻放弃,不要继续发送剩余位。

说到底,总线仲裁就是一次绅士游戏——谁先拉低 SDA 谁走路,失败者退到一边等下一轮。但你别傻等着,记得做重试。