集成电路总线I2C

I2C总线

I2C总线的概念

UART 作为一种经典的串行通信方式,大部分微处理器都集成了相关的通信外设,因此应用非常广泛。然而,随着硬件的发展,UART 在某些应用场景中的限制也越来越大。

UART 最大的缺点在于,它通常只是点对点的通信,不支持一台设备到多台设备间的通信。如果想通过 UART 实现一对多的通信,那么要么使用多个 UART 外设,要么设计一套复杂的应用层实现多个设备的管理和通信。

除此之外,UART 是一种异步的通信方式,没有时钟信号协调双方的通信速率,无法控制何时发送数据,也无法保证双发按照完全相同的速度接收数据。因此,通信双方必须事先设置相同的通信速率(波特率),还要在发送每个字节时提供额外的起始位和停止位,以帮助接收器在数据到达时进行同步。如果发送端的通信过程中被中断干扰,那么接收端将会接收到完全错误的数据。因此 UART 的发送过程非常依赖硬件外设的支持。

IIC 总线 (Inter-Integrated Circuit, 集成电路总线),也写作 I2C 或 I2C,一般读作 I 方 C ,是 Philips 公司设计出来的一种简单、双向的串行总线。I2C 的最大特点为:同步多设备

I2C 只需要使用两根线,即可在连接于总线上的器件之间传送信息:一根是串行数据线(Serial Data Line, SDA),一根是串行时钟线(Serial Clock Line, SCL)。数据线用来传输数据,时钟线用于同步收发的数据。

I2C 总线是一个多主机的总线,所谓主机就是指负责整个系统的任务协调与分配的设备(一般为单片机或类似功能的处理器)。与之相对的概念是从机,从机一般只能被动接收主机的指令,只有在主机允许的情况下才能主动发送信息。主机和从机之间通过总线连接,实现数据通信。因此 I2C 可以连接多于一个能控制总线的器件到总线。

下图展示了一个 I2C 总线的工作情景示意:

I2C 协议为半双工通信协议,即总线可以双向传输数据,但任意时刻只能存在单向的传输,需要等待传输结束才可能更换方向。因此同一时刻只能存在一个主机,不过主机和从机的角色可以互换。

I2C物理层内容

物理层即硬件上的规定,包括硬件如何连接以及输入输出的方式等。

I2C 设备的硬件接线非常简单,只需要将所有设备的 SCL 接在一起、SDA 接在一起即可,就像以上概念图那样。但是这种连接方式有个问题:所有的设备都可能向 SDA 线输出数据,如果一台设备的输出处于高电平状态,另一台设备主动输出低电平时,可能造成输出短路的问题。

为了避免这种状态,I2C 规定连接到 SCL 和 SDA 的设备都需要配置为开漏输出模式,并采用一个阻值较小(一般为 4.7KΩ ,总线上设备较多的话可以适当减小这个阻值)的上拉电阻接到外置驱动电源。当 I2C 设备输出为 1 时,并没有直接输出电平,而是处于高阻态,由上拉电阻把总线拉成高电平;当设备输出 0 时,上拉电阻可以防止电源和地直接接触而导致短路:

开漏输出的特点是,只有在总线处于低电平时才能保证有设备在主动输出。

I2C 具有三种传输模式:标准模式传输速率为 100kb/s ,快速模式为 400kb/s ,高速模式下可达 3.4Mb/s ,但目前大多 I2C 设备尚不支持高速模式。

I2C协议层内容

协议层规定了 I2C 完成物理层连接后,总线上如何产生信号(时序)来表示数据。

  1. 起始和停止

在所有设备初始化后,设备还没有对外输出,因此 SCL 和 SDA 都被上拉电阻拉至高电平。此时,主机可以使时钟线 SCL 保持高电平,同时主动将数据线 SDA 由高电平拉低为低电平,表示起始信号;在所有数据都传输完成后,主机可以先将数据线 SDA 拉低,并将时钟线 SCL 保持为高电平,这期间自然地退出数据线 SDA 的控制,使其恢复为高电平,表示终止传输,回到起始之前的状态,如下图所示:

I2C 的开始和结束表示方法

起始和终止信号都是由主机发出的。一次完整的数据传输总是以起始条件开始,终止条件结束。

  1. 传输的数据

在起止信号之间,I2C 总线可以传送数据。当主机主动控制时钟线 SCL ,将其拉低时,主机或从机可以控制数据线 SDA 并准备数据;当主机停止控制 SCL ,恢复为高电平期间,数据线上的数据必须保持稳定,同时主机或从机可以读取数据:

I2C 的数据表示方法

总之,SCL 低电平时,发送方可以发送数据;SCL 高电平时,接收方需要接收数据。通过这种时钟同步的设计,收发两方只需要听时钟线的指挥即可,不用预先指定通信速率。

同时,如果主机因为某种原因(如进入中断)暂停发送或接受数据,那么时钟线也暂停指挥,从机的动作也将暂停。

时钟线每一轮变化,就可以发送一个二进制位,依次循环上述过程就可以发送所有的数据。

  1. 数据帧的构成:地址

通过以上的协议层规定,I2C 总线已经可以发送二进制信息了。不过这些二进制信息并不都是实际的数据。就像 UART 的数据帧还包含了校验位和停止位一样,I2C 的数据帧也包含了额外的信息。

下图展示了 I2C 的数据帧格式,每个数据帧都位于一对起始和终止信号内:

I2C 数据帧格式

首先,一个 I2C 总线可能挂载多个设备。为了确定数据发给哪个从机,需要为每个从机编排一个地址。在 I2C 协议中,总线上的每个设备都具有一个地址,I2C 协议标准规定地址可以是 7 位或 10 位的,但目前应用最多的还是 7 位地址。这个地址会在每个 I2C 设备出厂时由厂商分配,因此每个设备的地址都是固定的,通常每种型号设备的地址也都是相同的,可以在对应的 datasheet 里找到。

很多情况下,I2C 器件都会提供一些硬件接口,可以通过修改这些硬件接口(例如通过外部引脚读入电平)来更改设备地址的低几位,从而实现灵活地切换地址,避免因为地址相同造成的冲突。

主机在发送起始信号后,第一步需要发送的就是从机的地址,以指定和哪个从机通信,其余的从机判断地址不一致后就无需读取后面的数据了。如果主机需要换一台设备通信,那么可以不产生终止信号,立即再次发出起始信号并发送其它地址指定别的从机。

  1. 数据帧的构成:读写标志

紧跟设备地址的一个二进制位用来表示数据传输方向,它是数据方向位 R/W ,该位为 0 表示主机向从机写数据,由主机控制 SDA ;为 1 表示主机由从机读数据;由从机控制 SDA 。

  1. 数据帧的构成:应答

I2C 协议规定,每个数据帧的传输都以字节为单位,每次传输的字节数不受限制。但是每当发送器件传输完一个字节的数据后,接收到数据的从机需要控制数据线 SDA 发出一个校验位。这个校验位包括“应答(ACK)”和“非应答(NACK)”两种信号,应答时需要由从机主动将 SDA 拉低得到:

从机的应答和非应答

如果从机发送应答(ACK)信号,表示希望对方继续发送字节,主机接到信号后可以继续发送;如果从机发送非应答(NACK)信号,表示接收端希望结束数据传输,主机接收到该信号后应该产生一个停止信号,并结束信号传输。

不管是主机发送数据还是从机发送数据,时钟线 SCL 总是由主机控制,从机根据时钟决定何时发送信号

  1. 数据的构成:数据

除了开头的第一个字节表示地址和读写标志以外,其余所有的字节都表示传输的实际数据。当然,这些数据接收方到底应该如何解读,那就是应用层的规定了,I2C 只负责将这些数据交付到位。

别忘了每个字节发送完成后,接收方都需要发送一个应答信号。

还要注意的是,I2C 的数据发生过程是高位先行,所以第一个发送的是一个字节的最高位,第二个发送的是次高位,以此类推。

注意和 UART 相区分,UART 的发送过程中是低位先发送。

接下来通过具体的程序实现 I2C 总线通信。

I2C读写示例

软件I2C的实现

I2C 是同步时序,主机可以自由决定通信的速率,因此控制起来比较轻松。这里介绍通过软件控制 GPIO ,产生 I2C 所需要的时序的基本代码。

首先是 I2C 的起始和终止的信号:SCL 保持高电平期间,将 SDA 从高拉低表示起始信号,将 SDA 从低回高表示终止:

void sI2C_Start(void) {
    SDA_OutMode();
    SDA = 1;
    SCL = 1;
    delay_us(10);
    SDA = 0;
    delay_us(10);
}

void sI2C_Stop(void) {
    SDA_OutMode();
    SDA = 0;
    SCL = 1;
    delay_us(10);
    SDA = 1;
    delay_us(10);
}

如果是使用 7 位地址的话,可以将这 7 位地址和 1 位读写标志合并为一个字节,并且之后主机发送的单位也都是字节,因此程序只需要统一编写一个发送字节的函数即可。

在发送字节时,需要先发送高位,这点可以通过掩码与左移位实现;在发送二进制位时,可以通过 SCL 确定当前的操作:在 SCL = 0 时,主机准备数据直到确定 SDA 输出,然后让 SCL = 1 ,等待一小段时间让从机读取数据,然后再让 SCL = 0 ,准备下一个数据;在发完所有数据后,不要忘记让 SDA = 1 释放总线的控制权:

void sI2C_SendByte(uint8_t SendData) {
    SDA_OutMode();
    SCL = 0;
    for (uint8_t Bit = 0; Bit < 8; Bit++) {
        if ((SendData & 0x80) == 0x80)
            SDA = 1;
        else
            SDA = 0;

        SCL = 1;
        delay_us(5);
        SCL = 0;
        delay_us(5);

        SendData <<= 1;
    }
    SCL = 1;
}

在发送完成后,需要等待从机的 ACK 信号,同样也是拉低 SCL 准备数据,回高 SCL 读数据,不过要注意读到 1 代表从机为非应答:(在 SendByte() 函数中,发送完成之后有拉低 SCL 准备的行为,在最后一次发送时从机就会产生 ACK 信号了)

bool sI2C_WaitAck(void) {
    SDA_InMode();
    SCL = 1;
    delay_us(5);

    bool ack = 1;
    if (SDA == 0)
        ack = 0;
    else
        ack = 1;

    SCL = 0;
    delay_us(5);
    return ack;
}

在发送一个字节后,设备需要处理,因此等待应答时,应该预留足够的时间。如果要考虑通用性,此处可以做超时处理。

综合以上函数,主机通过 I2C 发送数据的工具已经具备了:主机先通过 Start() 函数产生起始信号,然后逐一通过 SendByte() 发送地址+读写的字节和每个数据字节,每次发送完成后通过 WaitAck() 读取从机应答。最后,主机通过 Stop() 函数结束本次通信。

设备作为主机也可能读取数据,读取数据的方式和发送的方式也是类似的,可以通过 SCL 的状态拆解接收流程:SCL = 0 等待从机准备数据,SCL = 1 读取数据:

uint8_t sI2C_RecvByte(void) {
    SDA_InMode();
    SCL = 0;
    uint8_t RecvData = 0;
    for (uint8_t i = 0; i < 8; i++) {
        RecvData <<= 1;

        SCL = 1;
        delay_us(5);
        if (SDA == 1) {
            RecvData++;
        }

        SCL = 0;
        delay_us(5);
    }
    return RecvData;
}

设备接收完数据后,可以视情况发送 ACK 和 NACK 应答:ACK 就是在 SCL 为高时拉低 SDA ,NACK 就是让 SCL 和 SDA 都为高:

void sI2C_Ack(void) {
    SDA_OutMode();
    SCL = 0;
    SDA = 0;
    delay_us(5);
    SCL = 1;
    delay_us(5);
    SCL = 0;
    delay_us(5);
}

void sI2C_NAck(void) {
    SDA_OutMode();
    SCL = 0;
    SDA = 1;
    delay_us(5);
    SCL = 1;
    delay_us(5);
    SCL = 0;
    delay_us(5);
}

现在,主机接收数据的工具也都具备了。主机在 Start() 之后发送带读标志的字节,然后通过 ReadByte() 读读取每个数据字节,每次读取完成后视情况发送合适的应答。

接下来通过一个具体的器件 AT24C02 实现软件 I2C 通信。

AT24C02器件简介

本次使用 I2C 读写的示例器件是 AT24C02 ,它是一个 EEPROM 存储器,可以存储 256 bytes 的数据。并且相比 Flash ,它具有很高的稳定性:多达 1 百万次的擦写寿命,并且可以轻松将数据保存 100 年之久。

该器件的典型引脚结构如下图所示:

其中,VCC 是输入电源,一般为 1.8V 至 5.5V ,VSS 接地,SDA 和 SCL 是 I2C 通信接口,这些没什么好说的。

值得注意的是 A0/A1/A2 这三个引脚。在 AT24C02 中,器件的地址是由器件类型号(固有地址)和 A0/A1/A2 这三个输入地址构成。其中器件类型号 4 位固定为 0b1010 ,输入地址则由 A0/A1/A2 输入引脚的电平决定。通过 A0/A1/A2 输入引脚的不同接法,At24C02 的器件地址可以是 0b1010000~0b1010111 这 8 个地址,因此同一个 I2C 总线上最多可以挂载 8 台这样的设备。

一般情况下,将 A0/A1/A2 均接地,得到 AT24C02 的设备地址为 0b10100000x50

AT24C02 芯片还有一个写保护引脚 WP ,当该引脚为高电平时,将会禁止数据写入,从而防止数据被意外修改。如果不使用写保护功能,直接将该引脚接地即可。

软件I2C读写AT24C02

AT24C02 的写入有两种方式:一种是按字节写(byte write),字节写要求在发送器件地址和写标志后,发送的第一个字节代表要写入数据的地址,第二个字节代表要写入的数据。如果 AT24C02 对这三个字节都回复 ACK ,代表这个字节写入成功,接着主设备发送停止信号终止本次写入。

AT24C02 还支持按页写(page write),主设备如果在发送要写入的数据后,没有发送停止条件,那么它在接收到 ACK 应答以后,可以继续发送 7 个字节数据,这 8 个字节(一“页”)都将被写入 EEPROM 中。AT24C02 内部会将会指定的写入地址保存为一个指针,每写入一个字节,这个指针的值就加 1 ,这样下一个字节就可以写入后一个地址位置。不过要注意的是,页写的方式下写入的数据不能超过页的大小(一般为 8 个字节),否则这个指针将会返回一开始指定的位置,并覆盖最先写入的数据。

这么看来,AT24C02 只支持 8 位地址寻址,最多只能读写 255 bytes 的数据。而一条总线上最多挂载 8 台这种型号的设备,使得单个主设备实际最多可以读写 2KB 的数据。AT24C16 利用这种思路,可以通过外部引脚切换读写的块,实现单个设备 2KB 的容量。

通过以上的介绍,利用以上提供的工具即可实现 AT24C02 的字节写入。在起始和结束信号前,需要发送 3 个字节,并等待 3 次响应。这 3 个字节的含义分别为:

  1. AT24C02 地址和读写标志
  2. 要写入的地址
  3. 要写入的数据

以上过程对应为函数就是:

void AT24C02_WriteByte(uint8_t Address, uint8_t Data) {
    sI2C_Start();
    sI2C_SendByte(0xA0);
    sI2C_WaitAck();
    sI2C_SendByte(Address);
    sI2C_WaitAck();
    sI2C_SendByte(Data);
    sI2C_WaitAck();
    sI2C_Stop();
}

I2C 通信时,常将 7 位地址跟读写方向位合并构成一个 8 位的字节统一发送,因此当 R/W 位为 0(写方向)时,将合并后的字节 0xA0 称为该 I2C 设备的“写地址”;当 R/W 位为 1(读方向)时,将合并后的字节 0xA1 称为设备的“读地址”。

以上程序并没有对是否应答做判断,如果为了程序的稳定,最好判断应答状态,注意读到 0 才为应答。

按页写入的程序编写非常类似,只要重复发送数据的环节即可。

然后是数据的读取,读操作与写操作类似,注意器件地址中的读/写标志位应为 1 。数据的读取有三种方式,最基本的是随机读(random read),随机读也由这样三个字节构成:

  1. 器件的地址
  2. 要读出数据的地址
  3. 读出的数据

由于要读出数据的地址应该由主机发送,而读出的数据应该由主机接收,因此主机需要发送两次器件地址来切换读写标志位,也就是需要产生两次起始信号(中途可以不产生终止信号),相应的实现为:

void AT24C02_ReadByte(uint8_t Address, uint8_t* Data) {
    sI2C_Start();
    sI2C_SendByte(0xA0);
    sI2C_WaitAck();
    sI2C_SendByte(Address);
    sI2C_WaitAck();
   
    sI2C_Start();
    sI2C_SendByte(0xA1);
    sI2C_WaitAck();
    *Data = sI2C_RecvByte();    
    sI2C_NAck();
    sI2C_Stop();
}

在发送前两个字节时,AT24C02 都应该应答 ACK ,为了检验数据的有效性,可以考虑在程序中检查是否有 ACK 。在发送读标志后,AT24C02 随时钟送出数据,主机通过非应答 NACK 结束本次读取,并发送停止信号。

AT24C02 还有一种当前地址读(current address read)的方式。只要 AT24C02 有电,它的内部会保存着上次访问时最后一个地址 +1 的指针。如果不指定读的地址,那么就会通过该指针继续读取下一个字节。当读到最后一个可用的地址时,指针会绕回 0 重新读取。

除此之外,AT24C02 还可以通过顺序读(sequential read)的方式读取多个字节。如果在当前地址读或随机读接收到数据后,应答了 ACK ,AT24C02 将自动增加地址,并继续随时钟发送后面的数据。只要主机一直应答,就可以一直读取数据(若读到最后一个可用的地址,地址自动绕回到 0 ,仍可继续顺序读取数据),相应的程序实现如下:

void AT24C02_Read(uint8_t Address, uint8_t* Buffer, uint8_t Size) {
    uint8_t *p = Buffer;
    sI2C_Start();
    sI2C_SendByte(0xA0);
    sI2C_WaitAck();
    sI2C_SendByte(Address);
    sI2C_WaitAck();
    sI2C_Start();
    sI2C_SendByte(0xA1);
    sI2C_WaitAck();
    Size -= 1;
    while (Size--) {
        *p++ = sI2C_RecvByte();
        sI2C_Ack();
    }
    *p = sI2C_RecvByte();
    sI2C_NAck();
    sI2C_Stop();
}

总的来说,I2C 是一种较为经典的同步串行通信协议,本节介绍了该协议物理层和协议层的内容,并通过器件 AT24C02 展示了 I2C 的应用。AT24C02 作为一种经典的 I2C 器件,许多 I2C 设备的读写过程也是类似的器件地址 + 寄存器或存储器地址 + 数据的形式,熟悉了 AT24C02 后就很容易举一反三,因而非常具有研究价值。

由于篇幅限制,本文只介绍了 I2C 协议的部分规定。I2C 协议还包含了很多其它内容,包括 10 位地址的发送方式、多主机的总线仲裁、从机接收的软件处理等。如果想更好地了解 I2C 通信的细节,可以阅读以下的官方文档。

参考资料/延伸阅读

I2C-bus specification and user manual

I2C Manual

AT24C0x datasheet

京ICP备2021034974号
contact me by hello@frozencandles.fun