3. I2C协议深度解析(续):I2C地址与读写操作、多主机仲裁、时钟同步与扩展

上一章我们把I2C的时序基础讲透了。这一章,咱们来啃几个硬骨头——地址怎么分配、读写到底怎么玩、多主机抢总线怎么办。这些知识点,说白了就是你在实际项目中一定会踩的坑。

3.1 I2C设备地址:7位与10位地址模式

每个I2C设备都有一个唯一地址。标准模式是7位地址,后来觉得不够用,又扩展出了10位地址。

3.1.1 7位地址结构

7位地址,范围是0x00~0x7F。但注意,0x00是通用呼叫地址,0x01~0x07保留,0x78~0x7F也保留。真正可用的地址范围是0x08~0x77,一共112个。

我刚开始做项目时,以为7位地址就是7个bit随便用。后来发现,每个芯片厂商会固定分配前几位,后几位由硬件引脚决定。比如AT24C02,地址前四位是1010,后三位由A2、A1、A0引脚电平决定。

地址位 说明 示例(AT24C02)
Bit 6~4 器件类型标识 101(固定)
Bit 3 器件类型标识或扩展 0(固定)
Bit 2~0 硬件引脚配置 A2、A1、A0
我的习惯:画原理图时,把I2C设备的地址引脚用排针引出,或者用0欧电阻配置。这样调试时改地址不用重新打板。

3.1.2 10位地址格式

10位地址是为了解决7位地址不够用的问题。它的格式比较特殊:第一个字节的前5位是11110,后2位是地址的高2位;第二个字节是地址的低8位。

// 10位地址格式
// 第一个字节:1111 0 A9 A8 R/W
// 第二个字节:A7 A6 A5 A4 A3 A2 A1 A0

// 示例:10位地址 0x2B4
// 高2位:10
// 低8位:1011 0100
// 第一个字节:1111 0 1 0 = 0xF2(写操作)
// 第二个字节:1011 0100 = 0xB4

说实话,我在消费类产品中很少用10位地址。但在服务器主板、基站设备里,挂几十个传感器时,10位地址就派上用场了。

3.2 读写操作:从单字节到多字节传输

读写操作是I2C协议的核心。很多人以为读写就是发个地址、收个数据,其实里面门道不少。

3.2.1 单字节写操作

流程很简单:主机发START -> 发设备地址+写位 -> 等ACK -> 发寄存器地址 -> 等ACK -> 发数据 -> 等ACK -> 发STOP。

// 伪代码:单字节写
i2c_start();
i2c_send_byte(0xA0);  // 设备地址+写位
if (!i2c_wait_ack()) error();
i2c_send_byte(0x10);  // 寄存器地址
if (!i2c_wait_ack()) error();
i2c_send_byte(0x55);  // 数据
if (!i2c_wait_ack()) error();
i2c_stop();

3.2.2 单字节读操作

读操作比写操作多一个步骤:先写寄存器地址,然后重新发START,再读数据。

// 伪代码:单字节读
i2c_start();
i2c_send_byte(0xA0);  // 设备地址+写位
if (!i2c_wait_ack()) error();
i2c_send_byte(0x10);  // 寄存器地址
if (!i2c_wait_ack()) error();

i2c_start();          // 重复起始条件
i2c_send_byte(0xA1);  // 设备地址+读位
if (!i2c_wait_ack()) error();
data = i2c_read_byte();
i2c_send_nack();      // 主机发送NACK表示读完了
i2c_stop();
我曾经踩过的坑:读操作时忘记发重复起始条件,直接发了STOP再发START。结果某些芯片会复位内部状态机,读出来的数据全是错的。记住,重复起始条件(Repeated START)是I2C协议允许的,不要多此一举发STOP。

3.2.3 多字节读写:页写与顺序读

很多I2C设备支持连续读写。比如EEPROM,可以一次写一页(通常8~64字节),也可以一次读多个字节。

多字节写的关键:主机在每收到一个ACK后,继续发下一个字节,直到发完所有数据,再发STOP。

多字节读的关键:主机在每收到一个字节后,发ACK表示还要继续读,直到最后一个字节发NACK表示结束。

// 多字节读示例:连续读8个字节
i2c_start();
i2c_send_byte(0xA0);
i2c_wait_ack();
i2c_send_byte(0x00);  // 起始寄存器地址
i2c_wait_ack();

i2c_start();          // 重复起始
i2c_send_byte(0xA1);
i2c_wait_ack();

for (i = 0; i < 7; i++) {
    data[i] = i2c_read_byte();
    i2c_send_ack();   // 前7个字节发ACK
}
data[7] = i2c_read_byte();
i2c_send_nack();      // 最后一个字节发NACK
i2c_stop();

3.3 多主机仲裁:谁抢到总线谁说话

I2C总线允许多个主机。但同一时刻只能有一个主机控制总线。如果两个主机同时发数据,怎么办?仲裁机制来解决。

3.3.1 仲裁原理

仲裁发生在SCL为高电平时,多个主机在SDA上发数据。谁先发低电平,谁就赢了。因为I2C总线是线与逻辑——只要有一个主机拉低SDA,总线就是低电平。

我举个例子:主机A发数据0x55(01010101),主机B发数据0xAA(10101010)。

  • 第1位:A发0(低),B发1(高)。A拉低SDA,B检测到SDA是低,知道自己输了,立即释放总线。
  • 后续位:A继续发完自己的数据,B转为从机模式。
关键点:仲裁不会丢失数据。赢的主机继续传输,输的主机自动退出。整个过程对从机透明。

3.3.2 仲裁丢失的处理

主机检测到仲裁丢失后,应该怎么做?

  1. 立即释放SDA和SCL,转为从机模式。
  2. 停止发送数据。
  3. 等待总线空闲后,重新尝试发送。

我在一个多主机的项目中,两个MCU通过I2C通信。其中一个MCU的I2C驱动写得有问题,仲裁丢失后没有释放总线,导致另一个MCU也发不了数据。查了两天才找到原因。

3.4 时钟同步与扩展:让慢设备也能上高速总线

时钟同步是I2C协议里一个巧妙的设计。它让慢速设备能和快速主机一起工作。

3.4.1 时钟拉伸(Clock Stretching)

从机可以拉低SCL,让主机等待。等从机准备好后,再释放SCL。这就是时钟拉伸。

举个例子:一个慢速ADC,转换需要100μs。主机发完读命令后,ADC拉低SCL,告诉主机“等等,我还没转换完”。转换完成后,ADC释放SCL,主机继续发时钟。

我的建议:写驱动时,一定要处理时钟拉伸。很多MCU的I2C外设默认不支持时钟拉伸,需要配置成“开漏输出”模式。我曾经用STM32的I2C外设,默认是推挽输出,结果从机拉不低SCL,通信一直失败。

3.4.2 时钟同步在多主机场景

多主机时,时钟同步也很重要。每个主机都有自己的时钟源。如果两个主机同时发时钟,SCL的低电平时间由最长的那个决定,高电平时间由最短的那个决定。

说白了,就是所有主机一起拉低SCL,谁先释放SCL,SCL也不会变高,直到所有主机都释放了。这样,慢速主机就能“拖慢”快速主机。

3.5 知识结构图

下面这张图把本章的核心知识点串起来了。我建议你保存下来,写代码时对照着看。

I2C协议深度解析知识结构图 I2C协议深度解析(续)知识结构 地址与读写操作 多主机仲裁 时钟同步与扩展 7位地址 10位地址 单字节读写 多字节读写 线与仲裁 仲裁丢失处理 时钟拉伸 多主机同步 核心要点:地址唯一性、读写时序完整性、仲裁不丢数据、时钟拉伸兼容性 实际项目中,80%的I2C问题出在地址配置错误和读写时序不完整

3.6 实际项目中的避坑指南

最后,分享几个我在项目中遇到的坑,希望能帮你少走弯路。

坑1:地址冲突
我曾经在一个项目里用了两个相同地址的I2C传感器。结果两个设备都响应,数据全乱了。解决方案:用I2C多路复用器(如PCA9548),或者换不同地址的型号。
坑2:上拉电阻选型
总线电容太大,上拉电阻太小,会导致信号边沿变缓。我遇到过100kHz下用1kΩ上拉电阻,波形都成三角波了。一般100kHz用4.7kΩ,400kHz用2.2kΩ,具体要算。
坑3:从机不响应ACK
从机忙或者地址不对,会不拉低SDA发ACK。驱动里一定要做超时处理,否则会死等。我习惯设5ms超时,超过就报错重试。

嗯,这一章的内容就到这里。地址、读写、仲裁、时钟同步,这四个知识点是I2C协议的骨架。理解了它们,你写驱动时就能游刃有余了。