4. I2C通信模式:主机发送/接收模式、从机发送/接收模式、多主机通信与总线仲裁实战
I2C 的通信模式,说白了就是谁说话、谁听话、谁先停的问题。很多初学者觉得 I2C 协议简单,不就是两根线嘛。但真到了多设备挂一总线的时候,各种奇奇怪怪的问题就冒出来了。我刚开始做项目时也吃过不少亏,今天咱们就把这四种模式掰开揉碎了讲清楚。
4.1 主机发送模式:你主动,你控制时钟
主机发送模式是最常用的场景。主机产生时钟,主动向从机写数据。流程其实很固定:
- 主机发 START 条件:SCL 高电平时,SDA 从高拉低。
- 主机发从机地址 + 写位 (0):7 位地址 + 1 位 R/W,共 8 位。
- 等待从机 ACK:从机拉低 SDA 表示应答。
- 主机发寄存器地址(可选,取决于器件)。
- 主机发数据字节:每发完一个字节,等从机 ACK。
- 主机发 STOP 条件:SCL 高电平时,SDA 从低拉高。
关键点:主机发送模式下,时钟始终由主机产生。从机只能拉低 SCL 做时钟拉伸(Clock Stretching),但不能主动产生时钟。
我在项目中遇到过一个问题:某款温度传感器在发送数据后,需要 10ms 的转换时间。如果主机连续发数据,从机会一直拉低 SCL 做时钟拉伸。我当时没处理这个等待,结果数据全乱了。嗯,这里要注意:一定要检查从机是否在拉伸时钟,否则后续数据会错位。
4.2 主机接收模式:你读数据,从机被动给
主机接收模式,就是主机主动去读从机的数据。流程稍微有点不同:
- 主机发 START + 从机地址 + 读位 (1)。
- 等待从机 ACK。
- 主机发寄存器地址(如果需要指定读哪个寄存器)。
- 主机发重复 START + 从机地址 + 读位 (1):这叫 Repeated Start,很多新手会漏掉。
- 从机发数据,主机收数据并给 ACK:主机每收一个字节,拉低 SDA 表示还要继续读。
- 主机收最后一个字节时给 NACK:告诉从机“够了,别发了”。
- 主机发 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 都是开漏输出,外部有上拉电阻。这意味着:谁拉低,总线就是低电平。这就是“线与”逻辑。
仲裁过程是这样的:
- 两个主机同时发 START,都开始发送地址。
- 每个主机在发送每一位时,都会监测 SDA 的实际电平。
- 如果某个主机发送了高电平,但检测到 SDA 是低电平(被另一个主机拉低了),它就知道自己仲裁失败了。
- 仲裁失败的主机立即释放总线,不再发送数据。
- 仲裁成功的主机继续通信,不受影响。
重要:仲裁不会丢失数据,也不会破坏通信。因为仲裁失败的从机在检测到失利的位时,已经停止发送了。整个过程中,从机只看到一个完整的主机在通信。
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 通信模式全景
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 连在一起,写代码让它们同时发数据,看看仲裁是怎么工作的。嗯,实践出真知。