3、I2C通信协议:从原理到实战

各位同学,今天我们来聊聊I2C。说实话,I2C是我在嵌入式开发中用得最多的总线之一。它简单、灵活,两根线就能搞定多个设备通信。但越是简单的东西,坑往往也越多。我刚开始做RTC芯片驱动时,就在I2C上栽过跟头——时钟线拉不下来,设备地址写错,数据对不上……嗯,今天咱们就把这些坑一个个填上。

3.1 I2C总线原理:两根线走天下

I2C总线只有两根线:SCL(时钟线)SDA(数据线)。这两根线都是开漏输出,需要外接上拉电阻。为什么用开漏?说白了,就是为了实现多设备“线与”功能——任何一个设备都能把总线拉低,大家都能控制。

我习惯把I2C比作一个会议室:

  • SCL 是主持人,控制发言节奏
  • SDA 是话筒,谁拿到谁说话
  • 上拉电阻 是默认的安静状态(高电平)

你想想看,如果没人说话,总线就是高电平。一旦有人要发言,就把话筒(SDA)拉低,告诉所有人:“我要开始说了!”

关键参数:标准模式100kHz,快速模式400kHz,高速模式3.4MHz。做RTC应用时,我一般用100kHz或400kHz,够用且稳定。

3.2 起始条件与停止条件

这两个条件是I2C通信的“开关”。没有它们,设备不知道什么时候开始、什么时候结束。

起始条件(Start Condition):SCL为高电平时,SDA从高电平跳变到低电平。

停止条件(Stop Condition):SCL为高电平时,SDA从低电平跳变到高电平。

我记得第一次写I2C驱动时,时序没卡好,起始条件发出去后从机没反应。后来用示波器一看,SDA下降沿和SCL高电平没对齐——差了几个微秒。嗯,时序这东西,差一点都不行。

我的习惯:在代码里把起始和停止条件封装成独立函数,方便复用。比如:

void i2c_start(void) {
    SDA_HIGH();
    SCL_HIGH();
    delay_us(5);  // 保持时间
    SDA_LOW();
    delay_us(5);  // 建立时间
    SCL_LOW();
}

3.3 数据帧格式:7位地址与读写位

I2C的数据帧格式其实很固定。每次传输包含:

  1. 起始条件
  2. 7位从机地址 + 读写位(1位)
  3. 从机应答位(ACK/NACK)
  4. 数据字节(8位)+ 应答位(可重复多次)
  5. 停止条件

举个例子,读写RTC芯片DS3231的地址:

  • 7位地址:0x68(1101000)
  • 写操作:0xD0(11010000)
  • 读操作:0xD1(11010001)

你可能会问:“为什么地址要左移一位?”其实这是I2C协议规定的——7位地址放在高7位,最低位是读写位。我刚开始也搞混过,把0x68直接当8位地址发出去,结果从机死活不应答。后来查手册才发现,地址要左移一位再拼读写位。

避坑指南:我曾经因为地址搞错,浪费了整整一个下午。建议你在代码里这样定义:

#define RTC_ADDR_WRITE  (0x68 << 1)  // 0xD0
#define RTC_ADDR_READ   ((0x68 << 1) | 0x01)  // 0xD1

这样写,一眼就能看出是写还是读,不容易出错。

3.4 时钟同步与仲裁:多设备怎么不打架?

多设备共享两根线,难免会“抢话筒”。I2C解决这个问题靠两招:时钟同步仲裁

时钟同步:多个主机同时发送时钟时,SCL线会被拉低到最慢的那个设备释放为止。说白了,就是“大家跟着最慢的节奏走”。

仲裁:多个主机同时发送数据时,谁先拉低SDA谁就赢。仲裁发生在SDA线上,主机在发送数据的同时会监测SDA电平。如果自己发的是1但SDA是0,说明有别的设备在拉低总线,自己就退出。

我记得有一次调试一个多主机系统,两个MCU同时访问同一个RTC芯片。结果数据经常错乱。后来发现是仲裁机制没处理好——其中一个MCU没有正确监测SDA电平。加了个中断检测就解决了。

实战经验:在RTC应用中,我建议尽量用单主机模式。多主机虽然理论上可行,但调试起来很麻烦。除非你非用不可,否则别给自己找事。

3.5 多设备通信:地址不冲突就行

I2C总线上可以挂多个设备,只要地址不重复。每个设备有唯一的7位地址,最多127个设备(实际上受总线电容限制,一般不超过10个)。

常见的RTC芯片地址:

芯片型号 7位地址 备注
DS3231 0x68 固定地址
PCF8563 0x51 固定地址
MCP7940M 0x6F 可配置

你想想看,如果总线上同时挂了DS3231和PCF8563,地址分别是0x68和0x51,完全没问题。但如果你挂了两片DS3231,地址就冲突了——除非你用I2C多路复用器(比如PCA9548)来分时访问。

我的建议:设计电路时,给每个I2C设备留一个地址配置引脚。万一地址冲突,还能通过硬件跳线改一下。我曾经就因为没留配置引脚,被迫改PCB,那叫一个后悔。

3.6 实战:用I2C读写RTC寄存器

说了这么多,咱们来点实际的。下面是一个完整的I2C读写RTC寄存器示例:

// 写RTC寄存器
uint8_t rtc_write_reg(uint8_t reg_addr, uint8_t data) {
    i2c_start();
    if (i2c_send_byte(RTC_ADDR_WRITE) != 0) {  // 发送从机地址+写
        i2c_stop();
        return 1;  // 无应答
    }
    i2c_send_byte(reg_addr);  // 发送寄存器地址
    i2c_send_byte(data);      // 发送数据
    i2c_stop();
    return 0;
}

// 读RTC寄存器
uint8_t rtc_read_reg(uint8_t reg_addr) {
    uint8_t data;
    i2c_start();
    i2c_send_byte(RTC_ADDR_WRITE);  // 先写寄存器地址
    i2c_send_byte(reg_addr);
    i2c_start();                     // 重复起始条件
    i2c_send_byte(RTC_ADDR_READ);   // 发送从机地址+读
    data = i2c_recv_byte();          // 读取数据
    i2c_send_nack();                 // 发送非应答
    i2c_stop();
    return data;
}

这里有个细节:读操作时用了重复起始条件。为什么不用停止条件再重新开始?因为重复起始条件可以保证总线不被其他设备抢占,原子操作更安全。我习惯在所有读写操作中都这么用。

注意:发送完寄存器地址后,一定要检查从机是否应答。如果从机没应答,说明地址错了或者设备没上电。我曾经因为忘记检查应答,读回来的数据全是0xFF,排查了半天才发现是从机没焊好。

3.7 总结

I2C协议其实不复杂,但细节决定成败。记住几个关键点:

  • 起始/停止条件时序要卡准
  • 地址要左移一位再拼读写位
  • 多设备时地址不能冲突
  • 每次传输都要检查应答

最后说一句:别怕用示波器。我每次调I2C,第一件事就是把SCL和SDA接到示波器上。看到波形,心里就有底了。纸上谈兵不如一针见血,对吧?

下一章咱们聊聊RTC芯片的寄存器配置,到时候会用到今天讲的I2C知识。记得复习一下。