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 |
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();
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 仲裁丢失的处理
主机检测到仲裁丢失后,应该怎么做?
- 立即释放SDA和SCL,转为从机模式。
- 停止发送数据。
- 等待总线空闲后,重新尝试发送。
我在一个多主机的项目中,两个MCU通过I2C通信。其中一个MCU的I2C驱动写得有问题,仲裁丢失后没有释放总线,导致另一个MCU也发不了数据。查了两天才找到原因。
3.4 时钟同步与扩展:让慢设备也能上高速总线
时钟同步是I2C协议里一个巧妙的设计。它让慢速设备能和快速主机一起工作。
3.4.1 时钟拉伸(Clock Stretching)
从机可以拉低SCL,让主机等待。等从机准备好后,再释放SCL。这就是时钟拉伸。
举个例子:一个慢速ADC,转换需要100μs。主机发完读命令后,ADC拉低SCL,告诉主机“等等,我还没转换完”。转换完成后,ADC释放SCL,主机继续发时钟。
3.4.2 时钟同步在多主机场景
多主机时,时钟同步也很重要。每个主机都有自己的时钟源。如果两个主机同时发时钟,SCL的低电平时间由最长的那个决定,高电平时间由最短的那个决定。
说白了,就是所有主机一起拉低SCL,谁先释放SCL,SCL也不会变高,直到所有主机都释放了。这样,慢速主机就能“拖慢”快速主机。
3.5 知识结构图
下面这张图把本章的核心知识点串起来了。我建议你保存下来,写代码时对照着看。
3.6 实际项目中的避坑指南
最后,分享几个我在项目中遇到的坑,希望能帮你少走弯路。
我曾经在一个项目里用了两个相同地址的I2C传感器。结果两个设备都响应,数据全乱了。解决方案:用I2C多路复用器(如PCA9548),或者换不同地址的型号。
总线电容太大,上拉电阻太小,会导致信号边沿变缓。我遇到过100kHz下用1kΩ上拉电阻,波形都成三角波了。一般100kHz用4.7kΩ,400kHz用2.2kΩ,具体要算。
从机忙或者地址不对,会不拉低SDA发ACK。驱动里一定要做超时处理,否则会死等。我习惯设5ms超时,超过就报错重试。
嗯,这一章的内容就到这里。地址、读写、仲裁、时钟同步,这四个知识点是I2C协议的骨架。理解了它们,你写驱动时就能游刃有余了。