嵌入式基础教程
——
STM32单片机卷3片内外设
节06I2C通信

最新版本V4.0
王道嵌入式团队
COPYRIGHT ⓒ 2021-2025 王道版权所有

嵌入式基础教程<br />——<br />STM32单片机卷3片内外设<br/>节06I2C通信<br/><br/>最新版本V4.0<br>王道嵌入式团队<br/>COPYRIGHT ⓒ 2021-2025 王道版权所有串口通信回顾串口通信的特点串口通信的局限性I2C通信 VS 串口通信I2C相关概念I2C是什么?主从模式(重点)I2C总线SCL时钟信号线SDA数据总线从机地址I2C物理层电路结构设备引脚的设置逻辑线与主机发送时钟信号(主机控制SCL总线)I2C写数据的实现(主机控制SDA总线)I2C读数据的实现(从机控制SDA总线)SCL引脚和时钟信号线的作用(重点)总结I2C通信协议时钟线和数据线的空闲状态数据逐位发送的顺序I2C写数据时的数据帧格式起始位和停止位应答位I2C读数据时的数据帧格式寻址字节使用I2C协议驱动OLED显示屏OLED显示屏的硬件从机地址OLED显示屏I2C通信的数据帧格式常见OLED显示屏控制指令读写操作时序图举例硬件I2C和软件I2C软I2C驱动OLED实验按键KEY模块的实现LED模块的实现核心实现:软I2C模块设置必要的宏细节补充1: I2C通信的传输速率如何计算细节补充2: SDA引脚读SDA总线电平Init引脚初始化主机发送起始和终止位主机发送单字节数据到从机主机读取从机的单字节数据主机发送应答信号给从机主机读取从机发来的应答信号整体参考代码核心实现:sOLED模块中断服务程序的编写主程序的编写硬件I2C驱动OLED实验单片机I2C外设的系统框图实验目的USART模块实现实现分析:hOLED模块引脚和I2C外设初始化I2C外设复位操作I2C_Init函数和I2C_Cmd函数核心实现:主机发送多个控制指令I2C外设标志位起始位发送函数停止位发送函数I2C_SendData函数主机发送寻址字节后的处理主机发送普通字节数据后的处理参考实现代码点亮和熄灭OLED核心实现:读取OLED状态字节hOLED模块整体实现外部中断处理函数测试代码I2C通信的优缺点总结The End

串口通信回顾

Gn!

在前面的课程中,我们已经学过UART串口通信了,它是一种简单易用的通信方式。

但在嵌入式编程中光学习一个串口通信,显然无法满足我们的需求,现在我们再来回顾一下串口通信的特点,分析一下串口通信的局限性。

串口通信的特点

Gn!

串口(USART)通信是一种点对点的、全双工、异步的通信方式,它只需要两根线(TX 和 RX)就可以实现数据的发送和接收。

在通信过程中,发送方和接收方通过预先约定好的波特率来控制数据传输的速率,每个数据帧包含起始位、数据位、校验位和停止位。

串口通信方式简单直接,在PC与单片机通信、传感器信号采集、蓝牙Wi-Fi无线通信等场景中广泛被利用。

串口通信的局限性

Gn!

在串口通信中,通信双方通常是一般意义上的点对点通信,虽然也可以通过使用多根导线,连接多个TX/RX端口的方式实现一对多,但是:

  1. 这样做会导致线路复杂,实现起来很麻烦。

  2. 单片机的USART引脚是有限的,这会导致连接设备的数量受限。比如我们使用的STM32F10x系列的单片机,实际上只有三对引脚可供实现串口通信。

  3. 串口通用无法真正做到一对多通信。在同一个时刻某个串口只能作为发送端,将数据发送到某一台设备上。

在嵌入式系统当中,一对多的通信需求并不少见,为了实现多台设备之间的通信,我们还需要学习使用其它的通信方式。

在嵌入式开发中,所有可以进行一对多的通信方式中,I2C通信是非常常见的,所以我们就来学习一下2C通信。

I2C通信 VS 串口通信

Gn!

虽然我们还没有学习I2C通信,但不妨碍我们先来比较一下I2C通信和串口通信的差异,大家权当了解。

待到学完I2C通信后,相信大家会对下列表格的内容有更加深入的理解。

特性UART(串口通信)I2C(I²C通信)
连接方式点对点多设备总线连接
所需引脚每一对设备都需要 2 个引脚(TX、RX)连接多台设备也仅需 2 个引脚(SCL、SDA)
设备数量受 MCU的 UART 端口限制(通常 1~3 个)支持多达 100+个设备
通信模式一对一(点对点)一对多(支持主从模式)
数据传输方式异步传输同步传输
数据速率典型 9600 - 115200 bps(最高大约 1 Mbps)标准模式 100 Kbps,高速模式 400 Kbps,超高速模式 3.4 Mbps
设备寻址无设备寻址,发送端和接收端点对点通信支持设备寻址(7-bit 或 10-bit 地址)
全双工/半双工全双工(可以同时发送和接收数据)半双工(通信时只能单向传输)
适用场景单片机与PC调试通信、GPS、蓝牙Wi-Fi通信传感器网络、EEPROM 存储、嵌入式设备之间通信

串口通信 虽然简单易用,但由于 需要独立的 TX/RX 线、端口数量受限、缺乏寻址能力,在多设备通信场景下并不适用。

因此,在嵌入式开发中,我们更倾向于使用 I2C 或者其它通信方式来进行一对多的通信。

I2C 仅需 两根信号线(SCL 和 SDA),即可实现一对多个设备的通信,同时实现简单易用性强、灵活和扩展性也非常不错。

I2C相关概念

Gn!

按照老规矩,我们在学习一个新的技术模块时,还是先来了解一下与这个技术模块相关的一些概念。

比如一个很简单的问题:I2C是什么东西?

I2C是什么?

Gn!

I2C通信最早由飞利浦(现恩智浦半导体)公司,在1982年左右开发提出。

I2C是英文词组Inter-Integrated Circuit的缩写,更准确的缩写是"I2C",其中的2表示"I"字母的平方,即两个I。

在中文语境下,不要把它读成"I 二 C",而是要读成"I 方 C",其中方表示平方的意思。

如何理解Inter-Integrated Circuit这个词组呢?

  1. Inter-:意为 "相互、互相",表示设备之间的交互通信。

  2. Integrated Circuit(IC):指 "集成电路",也就是各种芯片。

所以I2C通信就可以理解成"集成电路间的通信、芯片之间的通信",意思是用于不同的芯片,不同的集成电路之间的进行数据交互的通信方式。

到如今,I2C通信仍然是嵌入式领域最流行的通信协议之一,它被广泛用于 MCU(微控制器)与各种外设之间的通信。

基于I2C是芯片之间进行数据通信的方式,所以通信双方都会按照既定协议完成通信的过程,整个过程是双方协作完成的,不要理解成单方面的控制!

主从模式(重点)

Gn!

I2C通信,最核心且最重要的特点就是:主从模式。

通过 I²C 总线连接的多台设备,具有 "主-从"(Master-Slave) 关系。整个总线上的设备分为两大类:

  1. 主设备(Master):负责控制总线,发起数据传输,生成时钟信号(SCL)。

  2. 从设备(Slave):响应主设备的请求,执行数据发送或接收。

在我们学习STM32时,为了简单起见,我们就直接固定把单片机视为主机,其它通过I2C总线和单片机相连的设备,一律视为从机。

实际上大多数I2C的应用场景中,也都会把单片机视为主机,进行简单的"一主多从"的I2C通信。

在I2C通信中,任何数据操作都必须由主机发起,从机则无法主动发起数据交互。这是I2C通信协议最重要且最核心的概念!!!

比如:

  1. 主机可以主动给从机发送数据,这就是I2C的写操作。

  2. 主机可以主动要求从机发数据到主机,然后从机再将数据发送到主机。但从机不能主动向主机发数据,这就是I2C的读操作。

本小节,我们讲I2C通信,最主要实现的功能就是:

  1. 由STM32主机发起,向从机写数据。

  2. 由STM32主机发起,主机将从机中的数据读出来。

I2C总线

Gn!

要想理解I2C总线,首先需要理解总线是什么。

我们在讲解STM32基础概念时提到过总线,我们知道:总线(Bus)是计算机系统中多个设备之间共享的数据高速传输通道。

总线类似"高速公路",多个设备共用一条“道路”来传递数据,而不是每个设备单独拉一根“专线”。

总线的使用可以简化电路实现,使得接线数量减少,而且相同总线上连接的设备可以共用同一套通信(数据交互)协议,进一步降低了系统的复杂性。

I2C 总线(Inter-Integrated Circuit Bus,集成电路间总线)是一种 用于多个芯片(IC)之间通信的串行通信总线,它允许多个设备通过 两根信号线(SCL 和 SDA) 进行数据交换。

I2C总线的具体的接线方式,如下图所示:

I2C物理层电路结构-图

需要注意的是:RoRdOg

  1. I2C通信仍然是一种串行的通信协议,I2C总线也是一种"串行总线",意味着数据在总线上的传输仍然是一位一位的进行传输的。

  2. 在I2C通信中,信号线仍然是通过发送高低电平来表示发送数据1/0的。

上述接线图就展示了一个事情:I2C通信当中的所有设备都是通过两根总线连接在一起的。

那么这两条总线的具体作用是什么呢?

下面我们来分析一下。

SCL时钟信号线

Gn!

SCL线(Serial Clock Line),即串行时钟线。

作用:由 主设备(单片机) 产生时钟信号,控制数据传输的时序。

所谓控制时序,就指的是主机严格控制整个通信的流程,比如什么时候开始传输数据,和哪个从机传输数据,是读数据还是写数据,什么结束数据传输等等。 在I2C通信中,由单片机产生的时钟信号,通过I2C总线中的SCL线发送到每一台从设备。

这就好比,主机作为"老大",它通过SCL线主动严格控制每一个"小弟"的工作频率,并严格与主机保持一致。

在I2C通信中,SCL线的控制权始终在主机手中,从机无法掌控SCL线。RoRdOg

关于时钟信号线的作用,也就是主机究竟如何控制所谓的时序,待到下面我们再讲解。

SDA数据总线

Gn!

SDA线(Serial Data Line,串行数据线)

作用:用于双向传输数据,主机和从机之间的数据交换全部是由这一条线完成的。

主机可以控制SDA线向从机发送数据,从机也可以控制SDA线向主机发送数据。所以在I2C通信中,SDA总线的控制权可以在主机/从机之间转换,当然在通信一开始的时候,SDA总线的控制权属于主机。RoRdOg

I2C是一种串行、同步、半双工的通信方式,它的这些特点就可以通过它的接线方式来推导出来。比如:

  1. 串口是全双工的通信方式,是因为通信双方都各有一个发送端和接收端交叉连接,但I2C的数据通信显然不可能是全双工的:

    1. I2C通信中的主机和从机之间的数据交互都是一条SDA线完成的。

    2. 在同一时刻,SDA线的控制权只能是主机或者从机。

    3. 若主机向从机发送数据,即I2C的写操作。

    4. 若从机向主机发送数据,即I2C的读操作。

    5. I2C通信是一种半双工的通信方式,主机和从机不能同时发送和接收数据。

  2. I2C总线是一种串行通信总线,数据是一位一位地RoRdOg依次传输的,所以I2C通信自然就是串行通信手段。

  3. I2C通信中,由主机通过SCL线严格控制数据传输的时序,任何数据的交互都需要双方同时"在线",同时参与。

    1. 比如:主机想向从机发送数据了,就必须通过I2C总线通知从机,从机"回答"了,才能继续下一步。

    2. 所以I2C通信是一种同步的通信方式。

  4. 与之相对应的UART串口通信,它通过规定相同的波特率,使用特定格式的数据帧来保证发送端和接收端的数据同步。这样串口就实现了异步的通信,通信双方不需要保证时刻同时"在线"。

从机地址

Gn!

I²C 通信是一种 "一对多" 的主从式通信方式。

在一条 I²C 总线上,主机(Master)可以连接多个从机(Slave)设备,那么主机如何在多个从机中找到目标设备呢?

这时就需要了解一个"从机地址"的概念。

在 I²C 通信中,从机地址(Slave Address) 是 主设备(Master)用来选择通信对象的唯一标识。每个从设备在总线上都需要有一个唯一的地址,主设备在通信时会先发送目标从机的地址,只有匹配该地址的设备才会响应。

I2C总线当中的从机设备,它的从机地址都由该从机设备制造商再生产时固定配置,一般就是一个固定7位的二进制数。

在I2C通信编程中,获取某个硬件设备它的从机地址,考察的是程序员收集信息的能力。

就目前而言,有以下几种稳定的途径可以获取设备的从机地址:

  1. 从厂商出具的,该设备的官方数据手册中自行查阅获取。

  2. 直接问卖设备的厂家。

  3. 问用过的同学/同事/大佬。

  4. 利用AI,只要你知道你所使用设备的型号,像从机地址这种简单的数据问AI是可以得到正确答案的。

当然无论你从什么渠道获取了从机地址,最终都需要验证一下再相信。

最后,既然从机地址是一个7位的二进制数,那么很明显在一个I2C总线系统内,理论上连接从机数量的最大值是:

27 = 128

但I2C通信协议自保留了一些地址,不能分配给从机设备,比如:

  1. 0000 000(7位0):通用呼叫地址,即主机广播式的向每一台从机发数据。

  2. 1 1 1 1 1 1 1(7位1):无特殊含义用途,但自保留,从机不能使用

  3. ...

所以I2C总线通信中,最大支持的从机数量不是128,而是大约100+,当然这个数量也是完全足够的。

I2C物理层电路结构

Gn!

在上面,我们已经了解了I2C相关的一些概念了,那么紧接着我们分为两个层次来学习I2C总线通信:

  1. I2C物理层电路结构,也就是了解在I2C通信过程中,关于物理电路的连接以及相关的硬件问题。

  2. I2C协议层概念,也就是了解I2C通信协议,比如怎么决定开始发送,结束发送,以及数据帧的格式等等。

首先我们来看一下I2C物理层电路结构。如下图所示:

I2C物理层电路结构-图

下面我们来逐一分析这张图中的信息。

设备引脚的设置

Gn!

在上面我们已经分析了SDA和SCL这两条线,它们一条是数据线,一条是时钟线。

接下来,我们要分析一下I2C通信中,所有设备连接这两条总线所使用的引脚,即SCL引脚和SDA引脚。

这些引脚应该设置为什么工作模式呢?

以单片机作为主机来说,单片机本身没有引脚可以直接作为SDA和SCL引脚,需要通过一些特殊的设置,这就需要查看引脚定义表了。

而I2C通信中,单片机作为主机,它需要:

  1. 利用自身的SDA引脚,控制SDA总线的高低电平,从而控制将数据发送给从机。

  2. 利用自身的SCL引脚,控制SCL总线的高低电平,从而控制通信过程当中的时序。

结合上述物理电路接线方式,单片机的这两个引脚需要设置成什么工作模式呢?

可以直接排除输入模式,SDA引脚和SCL引脚执行的都是输出向的操作。RoRdOg

那么选择开漏输出还是推挽输出呢?

既然外部电路加装了上拉电阻,那自然选择开漏输出了。

这样单片机就可以通过控制SDA引脚和SCL引脚的0/1(低电平/高阻态),从而控制SDA总线和SCL总线的高低电平。

实际上:

  1. I2C通信中SCL和SDA总线上挂载的所有设备,它们相关引脚的工作模式都是开漏输出!(重点!!)RoRdOg

  2. SDA总线的控制权属于哪台设备(主机/从机),该设备就可以控制自身SDA引脚的0/1(低电平/高阻态),从而控制SDA总线的高低电平。RoRdOg

  3. SCL总线的控制权一定属于主机(单片机),只有主机的SCL引脚可以输出0/1(低电平/高阻态),从而控制SCL总线的高低电平。RoRdOg

上述结论是如何得出的呢?

要想弄明白所谓总线控制权的概念,我们还需要明确一个重要原理——逻辑线与

逻辑线与

Gn!

什么是线与?

  1. 总线的电平状态由所有设备的输出共同决定,只有当所有设备输出1(高阻态)时,总线才为高电平;任意一个设备输出低电平,总线电平即被拉低。

  2. 具体表现

    1. 高电平1:所有设备释放总线(输出高阻态),上拉电阻将总线拉高。

    2. 低电平0:至少一个设备主动拉低总线(输出低电平)。

这种具体的表现和逻辑运算符当中的与"&"运算符完全一致,所以我们把这种总线机制称之为"逻辑线与"。

I2C通信当中的逻辑线与是通过设置引脚为开漏输出模式,配合上拉电阻来共同实现的,这是一种硬件实现的逻辑线与。

逻辑线与有啥用呢?

逻辑线与决定了I2C总线上,SCL线以及SDA线的高低电平状态。公式如下:

某条线上的高低电平状态 = 主机该端口输出(0 / 1) & 从机1该端口输出(0 / 1) &....

只要有任意一个设备的端口输出了0(低电平),那么这整条线就是低电平状态,所有设备的此端口都输出了1(高阻态),那么整条线才是高电平状态。

了解了逻辑线与,那么主机发送时钟信号,以及主机和从机之间的数据交互,它们的实现方式就能够理解了。

了解了逻辑线与,那么所谓总线控制权就非常好理解了。

主机发送时钟信号(主机控制SCL总线)

Gn!

时钟信号线的作用我们已经了解了,那么主机是如何发送时钟信号的呢?

答:通过逻辑线与。

具体逻辑可以参考下图:

主机发送时钟信号-图

当主机发送时钟信号时,所有从机的SCL端口都写1,这样它们就从SCL线上断开,那么SCL线的高低电平,也就是时钟信号。就完全由主机控制了:

  1. 只要主机的SCL引脚写1,也就是主机SCL引脚进入高阻抗状态,那么SCL线的时钟信号就是高电平。

  2. 只要主机的SCL引脚写0,也就是主机SCL引脚输出低电平,那么SCL线的时钟信号就是低电平。

这就是主机发送时钟信号的原理,注意这个原理,我们后面要自己写代码实现它。

I2C写数据的实现(主机控制SDA总线)

Gn!

所谓I2C写数据,也就是主机向从机发送数据。

它的实现方式仍然采用逻辑线与的原理,具体逻辑可以参考下图:

I2C写数据的实现-图

当主机写数据时,所有从机的SDA端口都写1,这样它们就从SDA线上断开,那么SDA线的高低电平,也就是主机向从机写数据,究竟写高电平还是低电平,写1还是写0。就完全由主机控制了:

  1. 只要主机的SDA引脚写1,也就是主机SDA引脚进入高阻抗状态,那么SDA线就是高电平状态,主机发送数据1到从机。

  2. 只要主机的SDA引脚写0,也就是主机SDA引脚输出低电平,那么SDA线就是低电平状态,主机发送数据0到从机。

这就是主机发送数据到从机,也就是I2C写数据的实现原理,注意这个原理,我们后面要自己写代码实现它。

I2C读数据的实现(从机控制SDA总线)

Gn!

所谓I2C读数据,也就是从机向主机发送数据。

它的实现方式仍然采用逻辑线与的原理,具体逻辑可以参考下图:

I2C读数据的实现-图

当主机读数据时,主机自身的SDA引脚写1,也就是进入高阻抗状态,主机SDA从SDA总线上断开。

若此时从机1需要发送数据到主机,那么从机2,从机3...等其它从机都必须将自己的SDA引脚写1,从SDA线上断开。

这样一番操作后,主机从SDA线上读到的数据,就完全由从机1控制了:

  1. 只要从机的SDA引脚写1,也就是从机SDA引脚进入高阻抗状态,那么SDA线就是高电平状态,从机发送数据1到主机。

  2. 只要从机的SDA引脚写0,也就是从机SDA引脚输出低电平,那么SDA线就是低电平状态,从机发送数据0到主机。

这就是从机发送数据到主机,也就是I2C读数据的实现原理,注意这个原理,我们后面要自己写代码实现它。

到此为止,I2C硬件层面上的实现原理我们就讲的差不多了。

在下面的小节中,我们将主要来学习I2C协议层的内容,也就是I2C通信的过程,数据帧格式等等内容。

SCL引脚和时钟信号线的作用(重点)

Gn!

在I2C通信中,只有主机可以控制SCL时钟总线,也就是只有单片机的SCL引脚可以切换0/1状态,从而控制SCL总线的高低电平。

主机控制SCL引脚改变SCL总线的高低电平,从而产生了一条高低电平切换的信号线图,这就是"时钟信号线/时钟同步线",如下图所示:

时钟同步线-图

这条时钟信号线,其实就是SCL总线电平切换随时间改变的一个"时序图",主机和所有从机们收到和使用的都是同一条时钟信号线,共用同一个"时序"。那么所谓的时序,有什么作用呢?

下面是重点!!RoRdOg

SCL线用于同步主从设备的时钟信号,也就是说电路中所有的设备,它的时钟信号是相同的。

I2C通信不是异步通信,它不依赖于数据帧中的特殊起始位或结束位以及设定相同的数据传输速率来实现通信。

而是根据时钟周期,来确定数据如何传输,以及如何接收等问题。

什么是时钟周期呢?

简单来说,一个时钟周期就是一个完整的“高电平 + 低电平”的组合。

于是在I2C通信中,一个时钟周期被分成了两个部分:

  1. 工作时间:一个时钟周期中的高电平部分属于工作部分,此时SDA输出的电平决定了传输数据是0还是1。

  2. 休息时间:一个时钟周期中的低电平部分属于休息部分,此时SDA输出的电平是无效数据,这段时间是留给设备,来准备下一个工作时间发送数据的。

如下图所示:

时钟同步线的作用-图1

我们可以结合下面这张图来分析一下,在这个通信过程中,SDA发送的数据是多少呢?

时钟同步线的作用-图2

这并不难理解,此时SDA引脚发出的数据是:1 0 1 0 1 1 1 1。

注意:SDA发送数据,一定是在时钟整个工作时间内都保持高低电平,才表示发送数据1和0。

通过了解这个知识点,我们又加深了对I2C通信的理解,可以总结出以下内容:

  1. I2C是一种同步的串行通信手段,主机和从机必须同步时钟信号,以确保能够在时钟信号高电平(工作状态)时发数据。

  2. 时钟信号的频率越高,相同时间内"工作状态"出现的次数就越多,传输数据的速度就越快。当然I2C通信的速率不能直接看系统时钟频率,具体我们下面再讲。

总结

Rd!

到此为止,有关I2C通信硬件电路设计相关的问题,我们就全部结束了。这里做一个要点总结:

  1. I2C 总线由 SDA(数据线)与 SCL(时钟线)组成,所有主从设备共享这两条线,形成“多设备挂载”的总线结构。

  2. 通信中所有设备的SDA和SCL引脚,都需要设置为 开漏输出模式。

  3. 两条总线的电平受所有设备引脚“线与逻辑”的共同决定——任一拉低(0)即为低电平,全部释放(1)方为高电平

  4. SCL 总线由主设备独占控制,用于产生时钟信号,控制时序。

  5. SDA 总线控制权在通信时根据读写角色动态切换,主机写时主机控制SDA总线,主机读时从机控制SDA总线。

  6. 所谓总线控制权,指的是某一设备具有对总线电平的主动控制能力,即其对应引脚可以在 0 和 1(低电平与高阻态)之间切换,而其他设备必须将对应引脚置为高阻态(输出 1),以释放总线控制权。RoRdOg

  7. I2C 是同步通信协议,所有设备共享主机生成的时钟信号。

  8. 每个时钟周期(高电平 + 低电平)被划分为:

    1. 休息时间(SCL 低电平):用于数据准备,控制SDA总线的设备拉高总线(准备发1),也可以拉低总线(准备发0);

    2. 工作时间(SCL 高电平):用于数据发送与接收采样,此时 SDA总线 必须稳定电平,表示发送数据以及接收数据。

下面,我们来学一学I2C通信软件通信相关的内容。

 

I2C通信协议

Gn!

为了讲清楚I2C通信协议,我们就以"主机向从机写数据"这个最常见的场景为例子,描述一下I2C通信的过程。

时钟线和数据线的空闲状态

Gn!

在 I²C 通信中,时钟线 (SCL) 和 数据线 (SDA) 的初始状态(空闲状态)为 高电平。

这和串口通信时的空闲状态非常类似。

如何实现的呢?

只需要让两条总线上连接的所有设备引脚,都输出高阻态,这样两条总线就默认为高电平了。

数据逐位发送的顺序

Gn!

I2C和串口通信一样,都是串行通信方式,所以数据都是逐位发送的。

在串口通信中,数据逐位发送的顺序是从最低位发到最高位的,但I2C通信则完全与此相反!

注意:在I2C通信中,数据都是逐位从最高位发送到最低位的!

I2C写数据时的数据帧格式

Gn!

I2C写数据帧时的数据帧格式:

I2C数据帧格式-图

实际上这个数据帧格式,也揭示了I2C通信发送数据的流程(以主机发送数据为例,从机发送数据原理完全一致):

  1. 主机发送起始信号,表示开始主机发送数据的过程。

  2. 主机发送设备地址 + 读/写位,表示主机向某个从机读/写数据。主机发送数据时,需要使用"写"标志位。

  3. 主机每发送1个字节(8位)数据,从机都必须应答,以表示收到数据。这就是应答位,应答位只占1位。

  4. 再往后,就是主机要发送的数据,1个字节的逐位发出去,没发送1个字节,从机都需要回复一个应答位。

  5. ...

  6. 主机发送完毕所有数据,主机发送停止信号,表示结束主机发送数据的过程。

下面就针对这个过程,我们来逐一讲解一下每个过程是怎么做的。

起始位和停止位

Gn!

起始位和停止位决定了主机发送数据的开始和结束,那么起始位和停止位的实现原理是什么样的呢?

I2C通信中所有设备都依据时钟周期中的工作时间来发送数据0和1,那么该如何传达开始传输数据和停止传输数据的信号呢?

再来回顾一下SDA线发送数据的方式:

SDA发送数据,一定是在时钟整个工作时间内都保持高低电平,才表示发送数据1和0。

那么在一个工作时间内,若SDA输出的高低电平不是始终保持,而是发生了改变:

  1. 由低电平变为高电平,区别于逻辑0和1,此状态表示逻辑0变1

  2. 由高电平变为低电平,区别于逻辑0和1,此状态表示逻辑1变0

这两种新出现的状态,不就恰好可以用于表示起始位和停止位嘛?于是I2C通信协议规定:

  1. 在一个工作时间内,当SDA从1变0,就表示数据发送的起始位。

  2. 在一个工作时间内,当SDA从0变1,就表示数据发送的停止位。

下面我们不着急看寻址字节,我们先来看一下应答位。

应答位

Gn!

I2C通信是单双工的,意味着同一时刻下,数据是单向发出的。

在这种情况下,如果从设备在整个数据传输过程中都不回应的话,那么主机就无法判断从机是否成功接收到数据。

于是I2C通信协议规定:

从机每接收主机发送的1个字节数据,在下一个工作时间内,就必须向主机回复一个应答位,以表示自己已收到传输数据。

那么应答位的格式是什么呢?从机是发送0还是发送1呢?

这里我们就要从新看一下主机发送数据的原理,如下图所示:

I2C写数据的实现-图

主机发送数据时,从机的SDA都只为1,SDA线的高低电平完全由主机控制。

每当主机发送完毕一个字节的数据后,主机就会将自身SDA引脚也置为1,将自己从SDA线上断开,放弃SDA线的控制权:

  1. 如果从机确定收到了正确的数据,那么从机应该在此时主动将自己的SDA引脚置为0。

    1. 如此从机在下个工作时间内,就实现了向主机发送0低电平的功能。

    2. 此时从机主动向主机发送了一个低电平0,这就是ACK(Acknowledge)确认应答。

    3. 只要主机收到了从机发送来的一个0,就表示这一个字节的数据传输工作完成了。

  2. 反之若从机认为自己没有收到数据,或者数据有误,那么从机将不会做任何事情,从机的SDA引脚也置1挂起。

    1. 于是整个SDA线的所有引脚都置为1(高阻态),依赖于上拉电阻,SDA表现为高电平。

    2. 此时相当于从机向主机发送了一个高电平1,这就是NACK(Not Acknowledge)非确认应答。

    3. 只要主机收到了从机发送来的一个1,就表示这一个字节的数据传输工作有问题。

如此结合起始位、停止位以及应答位,主机发送1个字节数据的时序图如下所示:

主机发送数据的时序-图

这里提到了一个新概念时序图,所谓时序图就是"电路中电平随时间变化"的流程图。从左到右表示时间的流逝,而电平则只有高电平和低电平,非常简单。

下面这张图则更清晰的展示了"1个字节数据",从主机发送到从机的过程:

发送数据的流程-图

上面讲的是主机发送数据,也就是I2C写数据。主机接收数据,也就是I2C读数据,过程也完全类似。流程图如下所示:

主机接收数据的流程-图

特别需要注意的点是,I2C通信采用主从模式,即便是从机向主机发送数据,这个过程也是由主机"主动"发起的!!

通过这里的讲解,我们就发现了,在I2C通信的过程中,数据的流向是始终变化的,主机发一段,从机就要响应一下,反之亦然。

I2C读数据时的数据帧格式

Gn!

I2C读数据时的数据帧格式,和写数据时没有本质区别。如下所示:

I2C读数据时的数据帧格式-图

总之,在I2C通信时,总是主从机交替发送数据的,要搞清楚到底是谁在发,谁在接,不要弄混淆了。

寻址字节

Gn!

寻址字节数据相对比较复杂,因为它兼顾两个作用:

  1. 前7位是接收数据的从机地址,唯一指示某个从机与主机进行通信。

  2. 第8位是读写标志位。I2C通信协议规定:

    1. 读写标志位是0时,表示主机向从机写数据。

    2. 读写标志位是1时,表示从机发送数据到主机,也就是主机读从机数据。

也就是说,只要知道了7位从机地址,然后再配合1位读写标志,我们就能够知道I2C通信中,数据帧的第一个字节应该如何发送了。

到此为止,关于I2C软件通信协议部分,就全部结束了。

下面我们就以一款支持I2C通信协议的设备"SSD1306 OLED显示屏"RoRdOg为例子,来讲讲该硬件的使用。随后完成单片机与这款屏幕的I2C通信。

使用I2C协议驱动OLED显示屏

Gn!

若想使用I2C协议驱动OLED显示屏,你需要知道以下内容:

  1. OLED显示屏的硬件从机地址。

  2. OLED显示屏I2C通信的数据帧格式。

  3. 常见OLED显示屏控制指令。

下面逐一讲解这些内容。

OLED显示屏的硬件从机地址

Gn!

通过查阅手册,我们知道OLED显示器的硬件从机地址是下面这样的:

0 1 1 1 1 0 SA0

其中的SA0是什么呢?

手册中的原话是:

D/C# pin acts as SA0 for slave address selection.

D/C#引脚作为SA0用于从地址选择。

也就是说,D/C 引脚作为SA0用于从地址选择。那么D/C引脚在哪里呢?

我们可以直接查看OLED的硬件电路图,如下图所示:

OLED显示屏-硬件电路图

其中D/C引脚部分的电路图如下所示:

OLED显示屏-硬件电路图2

所以D/C引脚的电平值,可以是高电平的1,也可以是低电平的0,具体使用哪一个要看引脚被焊接到哪一个电阻上。

那这怎么看呢?把OLED屏幕反着放置在桌上,就能看到如下内容:

OLED屏幕背面-图

那么这张图表示SA0是低电平0还是高电平1呢?

首先这里的两个值Ox78和Ox7A,都不是它的从机地址,因为两位十六进制数表示8位二进制数,而从机地址是7位的。

这两个数是寻址字节的取值,也就是"从机地址的7位二进制,再添上一个0(写标志)"得到的8位数值。

0x78的二进制表示是:

0 1 1 1 1 0 0 0

和上面的:

0 1 1 1 1 0 SA0

这说明SA0的取值是0,我们使用的OLED屏幕D/C引脚实际是接地的。

除此之外,我们还需要注意这个电路图的这个部分:

OLED显示屏-硬件电路图3

OLED屏幕的SDA和SCL引脚都各自内置了一个4.7KΩ的上拉电阻,这就意味着OLED屏幕接入电路后,完全不需要再手动添加上拉电阻了。

OLED内部电路中的上拉电阻就可以帮助我们实现,I2C通信中"逻辑线与"的功能。

想一想,我们早就使用过OLED屏幕了,使用的工具函数就是基于I2C通信协议实现的,但我们并没有在电路中接入上拉电阻,原因就在于此。

OLED显示屏I2C通信的数据帧格式

Gn!

I2C通信可以实现单片机和外部其它芯片/集成电路的数据通信,在上面我们讲解是通用的数据帧格式。即先发起始位,再发寻址字节表示读/写某个从机,随后就是正常的数据交互过程。

但不同的外设,它们的数据帧,往往在第二个字节的数据发送处会有所不同。

比如像EEPROM(以字节为单位读写的非易失性存储器,类似一个小Falsh闪存),以及各类支持I2C通信的传感器。

主机与它们通信时,就需要通过发送的第二个字节的数据,指出读/写的寄存器的地址,从而实现数据存储,获取数据等功能。

所以这类设备的数据帧格式如下所示(以写数据为例):

[S] [寻址字节] [ACK] [寄存器地址] [ACK] ...[数据字节] [ACK] [P]

更详细的一个数据帧的格式就如下所示:

[起始位] [从机地址][读写标志] 从机应答 [需要写的从机寄存器地址]从机应答[写到从机寄存器的1个字节数据]从机应答...从机应答[停止位]

所以在使用这些设备时,你还需要通过查阅手册,获取你想要实现功能所需的寄存器的地址,操作还是比较繁琐的。

但OLED屏幕使用时的数据帧格式相对比较简单,因为它本身不是用于存储数据的外设,所以寄存器设计简单。

总之OLED显示屏I2C通信的数据帧格式,如下所示:

[起始位] OLED设备从机地址[读写标志] 从机应答 [控制字节]从机应答[后续指令/需要写的字节数据]从机应答...从机应答[停止位]

当然,使用OLED时你也需要查一查控制字节有哪些,控制指令有哪些,写字节数据时具体的写法。

其中第二个控制字节的选项只有两个:

控制字节作用
0x00发送 命令(如清屏、设置光标位置)
0x40发送 待显示数据(写入 OLED 显存)

在向 OLED 发送数据时,必须在寻址字节后面 再发送一个控制字节,告诉 OLED 如何处理后续的字节数据。

常见OLED显示屏控制指令

Gn!

由于我们本章节只用于I2C通信入门,所以我们只需要了解简单的控制指令用于实现I2C通信即可。

慢慢查阅手册比较麻烦,我这里就列出两个我们做实验会用到的指令字节序列。

假如我想实现一个简单功能:开启OLED,并点亮全部像素。

此时只需要控制主机向从机屏幕发送以下指令序列就可以了:

组指令可以用于初始化 OLED 并让屏幕全亮。以下是解释:

  1. 0x00: 表示发送的是命令流。

  2. 0x8D, 0x14: 打开 OLED 的内部电荷泵,为 OLED 提供驱动电压。

  3. 0xAF: 打开 OLED 显示。

  4. 0xA5: 让屏幕所有像素点亮。

假如我想实现一个简单功能:完全关闭OLED。

也就是上面操作的逆向操作,你只需要操作主机向OLED从机发送以下指令序列即可:

说明:

  1. 0x00: 表示发送的是命令流。

  2. 0xA4: 将 OLED 从 "全屏像素点亮" 模式(0xA5)切换到全部像素熄灭状态。

  3. 0xAE: 关闭 OLED 显示。

  4. 0x8D, 0x10:关闭电荷泵,停止为 OLED 提供驱动电压,彻底关闭OLED。

主机读OLED。

上面是实现了写OLED屏幕的功能,实际上OLED在I2C通信中几乎只需要主机写功能,OLED作为从机几乎没必要向主机发送数据。

毕竟OLED既不是存储器也不是传感器,但OLED仍然具有唯一一个向主机发送数据的功能:返回状态字节

如下图所示:

读OLED状态寄存器-图

SSD1306控制芯片的OLED屏幕没有提供明确的可读寄存器地址,主机读OLED从机时,会直接出现以下情况:

  1. 主机发送起始信号。

  2. 主机发送从机地址 + 读模式(0x78 | 0x01)。

  3. 从机OLED直接返回状态字节给主机。

状态字节中只有bit6是有效的,它用于指示当前OLED是否开启,比如刚刚执行完0xAE指令,OLED就处于关闭状态,bit6的值就是1。

所以我们在演示I2C主机读OLED时,可以使用这个操作,读一下OLED屏幕的当前亮灭状态。

读写操作时序图举例

Gn!

举例一:主机向OLED显示屏中写两个字节0x66和Ox88。

此时从机寻址字节是:从机地址 + 写标志位0,即0 1 1 1 1 0 0 0也就是0X78

时序图空白-图

此时这个发送写过程完整的时序图就如下图所示:

发送数据时序图-图

举例二:主机从OLED显示屏中读状态字节,假设读到的是0x40。

此时从机寻址字节是:从机地址 + 读标志位1,即0 1 1 1 1 0 0 1也就是0X79

读到的数据是0x40,也就是0100 0000,即表示当前OLED处于关闭状态。

此时这个接收读过程完整的时序图就如下图所示:

读数据时序图-图

以上我们万事具备,I2C通信所有概念相关的内容就都讲完结束了。

下面我们再来看一个概念——硬件I2C和软件I2C。

硬件I2C和软件I2C

Gn!

什么是硬件I2C呢?

我们使用的STM32F10x系列单片机,其本身就内置了I2C外设模块,这些模块专门设计用于处理 I²C 协议,能够自动完成通信中的大部分操作。

STM32F103C8T6-系统结构图

如果选择使用单片机内置的I2C模块来实现I2C通信,这就是硬件I2C(简称硬I2C,HI2C)

那什么是软件I2C呢?

在上述讲解的过程中,大家肯定不难发现:

I2C通信中主机的SDA和SCL引脚完全可以通过普通GPIO口来模拟实现。

如果使用普通GPIO引脚,通过编写软件的方式模拟I2C的通信过程,实现I2C通信,这就是软件I2C(简称软I2C,SI2C)

那硬I2C和软I2C该怎么选择呢?

事实上两种方式,我们都应该学习。

相比较而言:

  1. 硬I2C使用相应硬件实现功能,数据传输速率会更高一些,但必须使用单片机指定引脚,所以灵活性差一些。

  2. 软I2C依靠纯软件实现,没有相应硬件支持,数据传输速率会更慢一些,但可以使用任意两个普通IO引脚,所以灵活性好一些。

除此之外,对于STM32F10x系列这样早期的单片机,ST公司为了规避飞利浦公司的收费专利,在设计I2C硬件时搞得比较复杂,还有一些共识的缺陷。

所以对于STM32F10x系列芯片而言,软I2C实现起来可能会更简单容易一些,若通信的需求不是大量数据传输也不追求高性能,比如驱动OLED,使用软I2C应该是更好的选择。

当然,如果你使用的是更新的芯片(比如F4系列),或者你需求更高的性能(高速传感器,频繁的存储器数据交互),或者干脆使用HAL库编程,那么硬I2C应该是更好的选择。

软I2C驱动OLED实验

Gn!

鉴于软I2C在我们所使用的STM32F10x系列芯片上,可能会更加简单易用一些。我们先来完成软I2C驱动OLED的实验,借此来演示一下软I2C的使用。

电路接线图非常简单,在整个软硬I2C的学习中,我们都只简单驱动OLED屏幕完成实验。所以接线图都是一样的:

软I2C驱动OLED实验-接线图

实物接线图如下图所示:

软I2C驱动OLED实验-接线图2

注意:

  1. 这里OLED屏幕的接线方式和之前仍然是一样的,不需要做改动。需要使用跳线把OLED引脚和单片机引脚连接起来。接线方式是:

    1. OLED屏幕的SCL -- PB10

    2. OLED屏幕的SDA -- PB11

    3. 需要使用跳线连接!

  2. 两个按键和LED仍然保留前面外部中断实验时的状态即可:

    1. LED的正极接电源正极,负极接入PA3引脚

    2. 两个按键一脚接入电源正极,另一脚分别接入PB6和PB8引脚。

实验目的:

利用外部中断机制:

  1. 按下并弹起左边按键PB6,表示开启屏幕并点亮所有像素。

  2. 按下并弹起右边按键PB8,表示关闭屏幕并熄灭所有像素。

  3. 在中断处理中,读取OLED状态字节,若OLED屏幕点亮,则点亮PA3指示灯,否则熄灭。

要求使用软I2C来完成实验。

由于此实验涉及到的外设相对多一些,代码也会更复杂一些,这里我们演示一下模块化编程,也就是使用头文件。

在之前的C阶段,我们实现单链表时,就用到了模块化编程的思路,这里就不再赘述了。

首先,我们直接把工程目录"Tools"文件夹下的OLED相关文件删掉,再新建以下文件:

  1. 按键模块的KEY.h和KEY.c文件

  2. LED模块的LED.h和LED.c文件

  3. 软I2C模块的sI2C.h和sI2C.c文件

  4. OLED模块的sOLED.h和sOLED.c文件

随后你还需要在Keil5的Group中将它们添加进去,这些操作都比较简单之前都做过,不再赘述具体的流程。

注意:不要在Keil5软件当中直接新建.c/或者.h文件,而是要去磁盘目录下手动新建文件,然后再添加到Keil5软件当中。

外部中断处理函数(中断服务程序),则放到main.c文件中去实现,当然你也可以放到按键模块,毕竟是由按键触发的外部中断。

按照惯例,我们先来实现我们熟悉的、简单的模块,比如按键KEY模块。

按键KEY模块的实现

Gn!

按键模块没什么花里胡哨的东西,只需要初始化GPIO引脚、映射EXTI线、初始化EXTI外设以及最终初始化NVIC即可。

这些代码之前就已经都写过了。

KEY.h头文件的参考代码如下:

注意头文件保护语法,由于实现中使用了外设标准库函数,所以还需要包含stm32f10x.h头文件。

KEY.c源文件的实现也很简单,参考代码如下所示:

这段代码非常简单,只要按照既定的流程实现即可,不再赘述。

注意,每写完一个模块建议都编译一下,这样可以及时的排查错误。

LED模块的实现

Gn!

LED模块也很简单,它仅需要三个函数即可实现全部功能。

LED.h头文件的参考代码如下:

LED.c源文件的实现也很简单,参考代码如下所示:

注意,每写完一个模块建议都编译一下,这样可以及时的排查错误。

核心实现:软I2C模块

Gn!

我们使用软件模拟I2C通信,实际上就是用一个个函数来模拟I2C通信的一个个小过程。

那么I2C通信一共有哪些子过程呢?

假如是I2C主机向从机发送数据,流程是:

  1. 主机发送起始位

  2. 主机发送寻址字节(也就是主机发7位从机地址,加上1位读写标志给从机)

  3. 从机发送ACK(也就是主机读从机的ACK)

  4. 主机发送其它字节...从机发送ACK给主机

  5. 主机发送停止位

假如是I2C主机读从机数据,流程是:

  1. 主机发送起始位

  2. 主机发送寻址字节(也就是主机发7位从机地址,加上1位读写标志给从机)

  3. 从机发送ACK(也就是主机读从机的ACK)

  4. 从机发送其它字节...主机发送ACK给从机

  5. 主机发送NACK给从机,表示不再读从机数据

  6. 主机发送停止位

总之对于I2C通信而言,我们只要实现下列7个函数,就足够模拟I2C通信的全部功能。

sI2C.h头文件的参考代码如下:

下面逐一来讲解实现这些函数的实现。

设置必要的宏

Gn!

为了提升代码可读性,灵活性,也使得代码写起来轻松一些,我们提取出一些反复使用的函数宏,以及必要的宏定义。

如下所示:

可以看到在上述三个函数里,实现任何一个功能后,都会选择延时5us,这个5us的延时是什么意思呢?这个数值可以是别的吗?

在I2C通信中,将时钟线的高电平视为工作时间,用于读写数据,所以时钟线的频率,也就是 SCL 信号每秒钟切换的次数(Hz)是决定I2C通信速率的核心要素。

时钟频率越快,SCL信号切换高电平的次数就越多,1s内的工作时间越多,收发字节数量就越多。

I2C协议当中的规定了典型的三种SCL时钟线频率,如下表所示:

模式最大 SCL 频率SCL 一个时钟周期理论最大传输速率(单位:千字节每秒)
标准模式 (SM)100 kHz (100,000 Hz)10 μs11.1KB/s
快速模式 (FM)400 kHz (400,000 Hz)2.5 μs44.4KB/s
高速模式 (HS)1 MHz (1,000,000 Hz)1 μs111.1KB/s

拿标准模式举例,SCL时钟线频率是100kHz,也就是100 000Hz,也就是1秒钟内,SCL线上有100 000个时钟周期(1个时钟周期由1个高电平和1个低电平组成)

于是每一个时钟周期的时间就是:1 / 100 000 = 0.00001 = 0.01ms = 10us,如果平分这个时间,那就是高低电平各占5us。

由于软件I2C本身就不追求传输速率,所以我们可以在切换总线高低电平后统一延时5us,这样就软件模拟了I2C通信当中的标准模式。

当然你也可以减少这个延时,这样数据传输的速率就会更快一些,但一般没有必要,我们使用软件I2C更多是追求灵活和兼容性,而不是传输速率。

我们现在使用软件模拟 I²C来控制 OLED,需要手动翻转 SCL 线,并在每次翻转后进行延时,以保证 SCL 时钟能够满足通信的需求。

延时时间设置-图

如果不追求效率,我们可以直接用一个非常粗略的、保守的延迟策略,只要翻转电平或者执行了电平相关的操作,统一延时5us。

细节补充1: I2C通信的传输速率如何计算

Gn!

我们就以I2C通信的标准模式为例。

在100kHz标准时钟频率模式下,10us(1个时钟周期)可以接收/发送1位的数据,而I2C通信每传输1个字节数据就需要应答一次,也就是说传输1个字节其实要传输9位,那么大概发送1个字节的数据就需要90us的时间。

1s就是1000 000us,那么1秒钟,理论最大传输字节数量就是1000 000 / 90 = 11111.11字节左右,也就是约11.1 KB/s,当然实际传输速率肯定比这个理论最大速度要慢一些。

其余模式的理论最大传输速率,也都是这么计算出来的。

细节补充2: SDA引脚读SDA总线电平

Gn!

在实现宏函数sI2C_SDA_Read时,我们通过GPIO_ReadInputDataBit函数读取了SDA引脚的输入电平,但这里就有一个很重要的问题了:

SDA引脚不是开漏输出模式吗?那么还能够读引脚的输入高低电平吗?

是不是需要将SDA引脚先切换到输入模式(比如浮空输入)然后再读引脚输入电平呢?

这是不需要的,因为当GPIO引脚设置为开漏输出模式时,可以直接读输入数据寄存器获取IO引脚输入电平的高低。(在官方手册当中明确规定)

Init引脚初始化

Gn!

引脚初始化非常简单,只需要将引脚设置为"通用开漏输出模式"即可。

为什么是通用呢?

软件 I2C使用 GPIO引脚输出 直接模拟 I2C通信的时序,只需要让引脚输出高阻态和低电平两种状态就可以了。

除此之外,不要忘记SCL和SDA线默认用高电平表示空闲状态,所以初始化的末尾要将它们置为高电平。

Init函数的参考代码如下:

这个函数的实现应当是非常简单的。

主机发送起始和终止位

Gn!

主机发送起始位的实现逻辑如下:

  1. 拉高 SDA 和 SCL 线:这一步是为了让 I2C 总线处于空闲状态,因为 I2C 协议规定在空闲时 SDA 和 SCL 都为高电平,为产生起始信号做准备。

  2. 在SCL总线为高电平时,拉低 SDA 总线:这样在 SCL 高电平期间就产生了一个下降沿,这是 I2C 协议定义的起始信号标志,表明一次新的通信开始。

  3. 最后,再拉低 SCL 总线,进入休息时间,主机开始准备下一位待发送的数据,即拉低/拉高SDA总线

实际上,我们在设计实现软I2C的各个操作函数时,都遵循这样的原则:执行完一个操作后就拉低SCL总线,进入休息时间!!

主机发送终止位的实现逻辑如下:

  1. 拉低 SDA 总线:为下一个工作时间产生上升沿做准备,使 SDA 总线先处于低电平状态。

  2. 拉高 SCL 总线:拉高 SCL 总线进入工作时间,为在 SCL 高电平期间产生上升沿创造条件。

  3. SCL 总线为高电平时拉高 SDA总线:在 SCL 高电平期间拉高 SDA,产生上升沿,这是 I2C 协议规定的停止信号标志,用来表示本次通信结束。

参考代码代码如下所示:

只要理解了I2C协议的发送位和终止位的逻辑,那么这两个函数的实现还是非常简单的。

主机发送单字节数据到从机

Gn!

在I2C通信中,主机发送1个字节的数据,总是从高地址发送到低地址的。

所以它的大体步骤思路是:

步骤 1:开启 8 次循环

  1. 操作:启动一个循环,循环 8 次,用于逐位发送一个字节(8 位)的数据。

  2. 原因:一个字节由 8 位二进制数组成,I2C 协议规定每个时钟周期传输 1 位数据,因此需要循环 8 次才能完成一个字节数据的传输。

步骤 2:判断并设置最高位数据

  1. 操作:判断待发送数据的最高位是 0 还是 1,然后将 SDA 线电平设置为对应的 0 或 1。

  2. 原因:在 I2C 协议中,SCL 低电平时 SDA 可以改变电平来准备数据。此时通过判断数据最高位并设置 SDA 电平,是为后续 SCL 高电平时从机采集数据做准备。也就是在准备时间里,SDA总线准备待发送的数据。

步骤 3:数据左移一位

  1. 操作:将待发送的数据左移一位,让之前的倒数第二位变成新的最高位。

  2. 原因:为了在下一次循环中发送下一位数据,通过左移操作使下一位数据成为最高位,方便后续继续按照从高位到低位的顺序逐位发送数据。

步骤 4:拉高 SCL 线进入工作时间

  1. 操作:将 SCL 线电平拉高。

  2. 原因:拉高 SCL 线进入工作时间,此时根据 I2C 协议要求,SDA 线电平要保持稳定,以便从机在 SCL 高电平期间采集 SDA 线上的数据。

步骤 5:拉低 SCL 线进入休息时间

  1. 操作:将 SCL 线电平拉低。

  2. 原因:拉低 SCL 线进入下一个时钟周期的休息时间,在 SCL 低电平时,SDA 线又可以改变电平,为发送下一位数据做准备,这样就可以继续循环发送后续的数据位。

参考实现代码如下所示:

还是要强调一下,软I2C通信的这些函数的设计都遵循同一个原则:每执行完成一个操作,都要拉低SCL总线进入休息时间!

主机读取从机的单字节数据

Gn!

看完了I2C的写操作,再来实现I2C的读操作,也就是主机读取从机的单字节数据。

在I2C通信中,主机发送数据是从高位发送到低位的,那么主机读取(接收)数据也自然是从高位接收到低位的。

其大体的实现思路是这样的:

步骤 1:初始化接收数据变量

  1. 操作:定义一个无符号 8 位整型变量 ReceiveData 并初始化为 0。

  2. 原因:这个变量用于存储从从机读取到的一个字节的数据。初始化为 0 是因为当读到的数据位为低电平时,不需要对 ReceiveData 进行额外赋值操作。

步骤 2:主机释放 SDA 线

  1. 操作:调用 sI2C_SDA_Write(1) 函数将 SDA 线拉高,释放对 SDA 线的控制。

  2. 原因:在主机接收从机数据之前,需要确保释放 SDA 线,避免主机对 SDA 线的控制干扰从机的数据发送,这样从机才能在 SDA 线上输出要发送的数据。

步骤 3:逐位读取数据(8 次循环)

启动一个循环,循环 8 次,每次循环读取一位数据。

  1. 拉高 SCL 线:调用 sI2C_SCL_Write(1) 函数将 SCL 线拉高,进入工作时间。

  2. 读取 SDA 线电平:调用 sI2C_SDA_Read() 函数读取 SDA 线的电平。如果 SDA 线为高电平,说明当前读取的数据位是 1,将对应的位设置到 ReceiveData 变量中。

  3. 拉低 SCL 线:调用 sI2C_SCL_Write(0) 函数将 SCL 线拉低,进入休息时间,为读取下一位数据做准备。

I2C 协议规定每个时钟周期传输 1 位数据,一个字节由 8 位组成,所以需要循环 8 次来完成一个字节数据的读取。

在 SCL 高电平期间,从机将数据放在 SDA 线上,主机读取 SDA 线的电平来获取数据位。拉低 SCL 线后,SDA 线可以准备下一位数据。

步骤 4:返回读取到的数据

  1. 操作:循环结束后,将存储读取数据的 ReceiveData 变量返回。

  2. 原因:函数的目的是读取从机的一个字节数据,循环结束后 ReceiveData 变量中已经存储了完整的一个字节数据,将其返回给调用者,以便后续使用。

参考实现代码如下所示:

这部分实现当中,从最高位到最低位构建ReceiveData的实现方式并不是唯一的,你也可以选择一些其它的实现方式。

我这里给出的实现,是我个人认为最好理解的。

主机发送应答信号给从机

Gn!

主机发送应答信号给从机,其实就是主机向从机发送一个bit的数据,可以是ACK(0),也可以是NACK(1)。

其实现思路是这样的:

步骤 1:准备应答信号数据

  1. 操作:根据传入的参数 AckType 的值,调用 sI2C_SDA_Write(ack) 函数设置 SDA 线的电平。当 AckType 为 0 时,表示发送 ACK 信号,SDA 线被拉低;当 AckType 为非 0 时,表示发送 NACK 信号,SDA 线被拉高。

  2. 原因:此时 SCL 线处于低电平,根据 I2C 协议,在 SCL 低电平时,SDA 线可以改变电平,所以在这个时候准备好应答信号的数据。

步骤 2:拉高 SCL 线

  1. 操作:调用 sI2C_SCL_Write(1) 函数将 SCL 线拉高。

  2. 原因:拉高 SCL 线后进入工作时间,按照 I2C 协议,在 SCL 高电平期间,从机需要读取 SDA 线上的应答信号,以确定主机是否成功接收数据。

步骤 3:拉低 SCL 线

  1. 操作:调用 sI2C_SCL_Write(0) 函数将 SCL 线拉低。

  2. 原因:拉低 SCL 线后,进入下一个时钟周期的休息时间,SDA 线又可以进行电平的改变,为后续的通信操作做准备。

其参考代码如下所示:

需要注意的小细节是:从机如何去读到这个ACK/NACK,然后如何进行处理,这是从机自身设定的问题,和我们主机的代码是无关的。

主机读取从机发来的应答信号

Gn!

主机读取从机发来的应答信号,其实就是主机读取从机中的一个bit的数据。

其思路是这样的:

步骤 1:主机释放 SDA 线

  1. 操作:调用 sI2C_SDA_Write(1) 函数将 SDA 线拉高,释放对 SDA 线的控制。

  2. 原因:在主机接收从机的应答信号之前,需要确保释放 SDA 线,避免主机对 SDA 线的控制干扰从机发送应答信号。释放 SDA 线后,从机才能在 SDA 线上输出自己的应答信号。

步骤 2:拉高 SCL 线

  1. 操作:调用 sI2C_SCL_Write(1) 函数将 SCL 线拉高。

  2. 原因:拉高 SCL 线进入工作时间,根据 I2C 协议,在 SCL 高电平期间,从机会将应答信号放在 SDA 线上,主机可以在这个时间段读取 SDA 线的电平来获取应答信号。

步骤 3:读取 SDA 线电平

  1. 操作

    1. 定义一个无符号 8 位整型变量 ReceiveAckType,用于存储从机发送的应答信号。

  2. 调用 sI2C_SDA_Read() 函数读取 SDA 线的电平,并将读取到的结果存储到 ReceiveAckType 变量中。

  3. 原因:SDA 线的电平代表了从机的应答信号,低电平(0)表示 ACK(应答),高电平(1)表示 NACK(非应答)。通过读取 SDA 线电平,主机可以获取从机的应答信息。

步骤 4:拉低 SCL 线

  1. 操作:调用 sI2C_SCL_Write(0) 函数将 SCL 线拉低。

  2. 原因:拉低 SCL 线后,进入下一个时钟周期的休息时间,SDA 线又可以进行电平的改变,为后续的通信操作做准备。

步骤 5:返回应答信号

  1. 操作:将存储应答信号的 ReceiveAckType 变量作为函数的返回值返回。

  2. 原因:调用者可以根据返回的应答信号判断数据传输是否成功,并进行相应的处理。例如,如果返回值为 0,表示从机应答成功,数据传输正常;如果返回值为 1,表示从机非应答,可能存在数据传输错误等问题。

具体的参考代码如下:

需要注意其中的实现细节,更好的理解I2C通信的时序。

整体参考代码

Gn!

整体参考代码如下所示:

大家不妨参考一下上述实现,自行手动实现一下。

核心实现:sOLED模块

Gn!

首先需要我们实现的函数有以下内容:

sOLED.h头文件的参考代码如下:

sOLED.c源文件的参考代码如下:

需要注意的细节有:

  1. 主机向OLED从机发数据后,从机会向主机发送应答信号。无应答可以选择像上面代码里一样处理,也完全可以不做任何处理。但一定要接收应答!

  2. 在实现OLED_ReadStatus函数时,通过一个传入传出的指针类型参数实现了返回值的效果。

中断服务程序的编写

Gn!

中断服务程序的写法也非常简单,首先确定此函数的声明如下:

其次中断服务程序就只做以下几件事情:

  1. 先确定由哪条EXTI线,也就是哪个按键触发了外部中断。

  2. 按键必须按下--弹起后才执行开启/关闭OLED和以及通过I2C读来控制LED的操作。

中断服务程序的参考代码如下所示:

相信学到这里,这个中断服务程序的编写,对大家而言还是相当容易和简单的。

主程序的编写

Gn!

主程序的编写也非常简单,只需要完成各种初始化即可,剩余逻辑交给按键的外部中断触发。

参考代码如下:

编译和烧录程序,分别按下释放两个按键,若能够看到屏幕被点亮、熄灭,以及LED随之点亮熄灭,则说明程序执行成功。

硬件I2C驱动OLED实验

Gn!

所谓硬件I2C,指的就是利用单片机内部自带的I2C硬件来完成I2C通信的过程,关于I2C通信的过程,我们已经学过了。

所以基本上所有原理性的东西我们都已经了解了,使用硬件I2C无非就是调用外设标准库函数,基于单片机的I2C外设来实现I2C通信的过程。

STM32F103C8T6-系统结构图

由于我们也已经使用过软件I2C了,所以对我们而言,硬件I2C的使用难度并不会很大,最多就是调用标准外设库函数的过程会比较繁琐。

我们仍然以一个实验案例来讲解学习硬件I2C,并且在这里我们将I2C和串口两种通信结合起来。

于是实验的电路接线图,就如下图所示:

串口通信接线-图

学习I2C,顺带再复习一下串口通信。当然单片机USART外设接收数据的方式也要使用中断,而不是一般的轮询方式。

单片机I2C外设的系统框图

Gn!

使用引脚I2C通信,就需要使用单片机的I2C硬件外设。

其系统框图如下图所示:

单片机I2C外设的系统框图

这个框图将在后续的内容讲解中起到重要作用。

实验目的

Gn!

实验目的:

在PC端,使用串口调试工具向STM32单片机发送指令:

  1. 发送指令'0',表示关闭OLED屏幕,同时单片机向PC端回复消息"OLED-TurnOff: Success"

  2. 发送指令'1',表示开启OLED屏幕并点亮所有像素,同时单片机向PC端回复消息"OLED-TurnOn: Success"

  3. 发送指令'2',表示询问OLED屏幕当前点亮状态,并回复PC端。比如:"OLED-Status: OFF"或者"OLED-Status: ON"

所以本质上和软件I2C实现的功能是差不多的,只不过把串口通信模块加进来了。

当然这里我们继续使用模块化编程的思想,我们先来实现简单的模块。

USART模块实现

Gn!

串口通信我们已经学过了,像上述实验目的中的串口通信功能对我们而言,已经可以说是手拿把掐了。

在工程的"Tools"目录下新建两个文件:"USART1.c"和"USART1.h",然后把它们加入Keil5软件的Tools Group组当中。

串口通信模块,我们只需要实现下列两个功能就可以了:

  1. 配置USART1外设。包括: 初始化引脚,初始化USART1外设,开启RXNE标志位中断以及初始化NVIC。

  2. 单片机向PC端发送字符串。

注:单片机接收PC端发送的控制指令,没有必要单独作为一个函数去定义,可以直接在中断服务程序当中接收数据。

"USART.h"文件的参考代码如下:

"USART.c"文件的参考代码如下:

上面这部分实现都非常简单,不再赘述。

中断处理相关的中断服务程序,放到下面实现了OLED屏幕的操作后再去实现,直接放在main.c文件中。

实现分析:hOLED模块

Gn!

在工程的"Tools"目录下新建两个文件:"hOLED.c"和"hOLED.h",然后把它们加入Keil5软件的Tools group组当中。

首先硬件IC2在实现时,直接使用了STM32的I2C外设,采用的编程手段是标准外设库函数实现。

所以我们没有必要再去弄一个"hI2C"模块,可以直接在hOLED模块中调用标准库函数,实现我们需要的功能。

hOLED模块需要的功能一共有四个,"hOLED.h"的参考代码如下:

注意下方三个涉及到和OLED屏幕通信的函数,都是具有返回值的。

这是因为我们通过标准库来实现I2C通信时,可以通过I2C外设内置的各种标志位进行错误校验和处理,所以这几个函数我们给定了int8_t类型的返回值。

在"hOLED.c"中,我们还可以提取了几个宏定义用于提升代码的可读性以及扩展性:

下面看一下具体的每一个函数的实现。

引脚和I2C外设初始化

Gn!

在上述实验中,STM32单片机中我们使用的I2C引脚是:

  1. SCL --- PB10

  2. SDA --- PB11

在一般情况下,PB10和PB11两个引脚都只是一般的、普通IO引脚。那么这两个引脚可以作为实现硬件I2C通信的引脚吗?

我们可以通过查表来获取这两个引脚的复用和重定义功能,以确定如何初始化这两个引脚。

具体的引脚定义表可以参考之前的文档:引脚定义表

通过查表,我们可以得出下列信息,STM32F103C8T6共有下列三对可用为I2C通信的引脚:

假如使用引脚的复用功能的话:

第一对:

  1. PB6的复用功能是: I2C1_SCL,即I2C1外设的SCL引脚。

  2. PB7的复用功能是: I2C1_SDA,即I2C1外设的SDA引脚。

第二对:

  1. PB10的复用功能是: I2C2_SCL,即I2C2外设的SCL引脚。

  2. PB11的复用功能是: I2C2_SDA,即I2C2外设的SDA引脚。

根据我们选择的接线方式,我们使用的I2C硬件外设是I2C2,PB10作为SCL引脚,PB11作为SDA引脚。

当然,这两个引脚都需要设置为复用开漏输出模式。

第三对:

  1. PB8的重定义功能是: I2C1_SCL,即I2C1外设的SCL引脚。

  2. PB9的重定义功能是: I2C1_SDA,即I2C1外设的SDA引脚。

总之,有三对实现硬件I2C的引脚可以选择,但我们选择其中的第二对。

hOLED_Init函数的参考实现代码如下:

这里有三处我们之前没有学过的内容,这里简单说一下。

I2C外设复位操作

Gn!

I2C外设的复位:

代码中出现的下面两行代码:

它们的作用就是复位I2C外设,类似最小系统板上的复用按钮,它的主要作用是:

  1. 清除 I2C2 可能存在的错误状态(如 BUSY、ARBLOST 等)。

  2. 确保 I2C2 处于一个已知的初始状态,避免软件初始化后仍然异常。

  3. 重新初始化 I2C2 的内部寄存器,避免上一次的残留配置影响新初始化。

如果I2C外设之前被启动过,而且存在一些错误没有被清除,直接调用I2C_Init函数初始化它有可能无法重新工作。

所以利用硬件复位操作,让I2C外设彻底清空并重新开始工作是初始化I2C外设的常见做法.

但是一定要注意,该操作会清空重置I2C外设的一切配置,所以应该在I2C_Init函数初始化之前调用。

由于I2C外设的特殊性,所以在初始化I2C外设之前初始化它,是一个推荐的做法!

I2C_Init函数和I2C_Cmd函数

Gn!

I2C_Init函数和I2C_Cmd函数:

这一对函数同时都要调用,用于初始化I2C外设。类似的操作,我们之前也见过,和初始化USART外设是一样的。

通俗点说这两个函数的作用是:

  1. I2C_Init函数用于配置I2C外设。

  2. I2C_Cmd函数相当于一个控制此外设的总开关,用于开启和关闭此I2C外设。

下面来简单介绍一下这两个函数:

I2C_Cmd函数:

其函数声明如下:

其传参的选择十分简单,如下所示:

参数类型说明
I2CxI2C_TypeDef*指定要控制的 I2C 外设,取值范围:I2C1I2C2(STM32F103)
NewStateFunctionalState开启或禁用 I2C 外设,取值:ENABLE(开启) 或 DISABLE(禁用)。

I2C_Init函数:

I2C_Init 是 STM32 标准外设库提供的一个函数,用于初始化和配置 I2C 外设,包括时钟速度、地址模式、应答模式等。

其函数声明如下:

其第一个参数很简单,传参想要初始化的I2C外设,对于STM32F103系列来说就只有两个I2C外设,即I2C1I2C2

其核心参数是第二个参数,和以往学习的所有Init函数一样,需要传入一个I2C_InitTypeDef类型的结构图对象指针。

这个结构体的类型定义如下:

逐一来解释一下这些成员:

I2C_ClockSpeed成员:

该成员需要传参一个32位无符号整数,用于设置 I2C 的时钟频率。

I2C的时钟频率是衡量I2C通信数据传输速率的核心要素,在上面软I2C的实现中我们已经讲了这个概念。

I2C协议当中的规定了典型的三种时钟线频率,如下表所示:

模式最大 SCL 频率SCL 一个时钟周期理论最大传输速率(单位:千字节每秒)
标准模式 (SM)100 kHz (100,000 Hz)10 μs11.1KB/s
快速模式 (FM)400 kHz (400,000 Hz)2.5 μs44.4KB/s
高速模式 (HS)1 MHz (1,000,000 Hz)1 μs111.1KB/s

但我们使用的STM32F10x系列芯片最大只支持快速模式,也就是只能选择标准模式和快速模式,高速模式乃至于更高速度的模式一般只有更高性能的芯片才支持(比如F4系列)。

在实际传参时,我们可以直接手动传参400000这个整数,来表示选择使用I2C通信的快速模式,即SCL线时钟频率是40kHz。

I2C_Mode成员:

用于设置I2C外设的工作模式。

标准库中给定了三个可以选择的取值:

I2C_Mode_I2C:最常用的 I2C 模式,也是标准的 I2C 模式,通常用于正常的 I2C 数据传输。

I2C_Mode_SMBusDevice:SMBus 从机模式。

I2C_Mode_SMBusHost:SMBus 主机模式。

SMBus(系统管理总线)是一种在 I2C 基础上进行扩展的协议,当前我们就选择使用标准I2C通信模式即可。

所以此成员只需要直接设置成I2C_Mode_I2C即可。

I2C_DutyCycle成员:

I2C_DutyCycle 成员是用于设置 I2C 时钟的占空比 的一个配置项。

所谓占空比,是描述1个时钟周期内高电平与低电平时间比例的参数。也就是说占空比影响 SCL 时钟信号线上的高电平和低电平的持续时间比。

占空比有啥用呢?

在一个时钟周期内低电平的比例越大,高电平比例就越小,此时抗干扰能力就会更强一些,但数据传输速率会降低一些(因为高电平次数更少)。

在一个时钟周期内低电平的比例越小,高电平比例就增多,数据传输的速率就会提升(高电平占比多,工作时间多),但相应的抗干扰能力就会减弱。

标准库为程序员配置I2C时,提供了两个占空比选项:

  1. I2C_DutyCycle_16_9 表示"低电平:高电平"的比值为 16:9,是一个高电平占比更高的占空比。

  2. I2C_DutyCycle_2 表示"低电平:高电平"的比值为 2:1,是一个低电平占比更高的占空比。

在实际使用时,我们可以优先选择I2C_DutyCycle_2占空比,一个标准的选择,增强抗干扰能力。

I2C_OwnAddress1成员:

I2C通信是主从模式的,一般情况下单片机都被视为主机,但I2C也支持将单片机作为从机使用。

若选择单片机作为从机的模式,为了让外部主机寻址到该从机单片机,就需要提供此单片机的地址。

这个参数就用于表示这个地址。

但我们不会选择将单片机作为从机使用,所以该成员可以忽略,不进行配置。

I2C_Ack成员:

I2C_Ack用于配置单片机作为接收端时(也就是单片机读从机数据时),是否向发送端回复ACK。

它的传参选项有两个:

  1. I2C_Ack_Enable:启用应答,单片机收到数据后会发送 ACK,表示数据成功接收。

  2. I2C_Ack_Disable:禁用应答,单片机收到数据后会直接发送 NACK。

禁用应答是很少见的情况,所以我们直接给该成员配置为I2C_Ack_Enable即可。

I2C_AcknowledgedAddress成员:

配置单片机在作为从机时,其从机设备地址的长度是7位还是10位。

但我们不会选择将单片机作为从机使用,所以该成员可以忽略,不进行配置。

核心实现:主机发送多个控制指令

Gn!

在单片机与OLED屏幕进行I2C通信的过程中,单片机需要向OLED从机发送多个控制指令,从而实现控制OLED的功能。

这里需要实现的核心函数就是:

该函数我们在软I2C中也实现过类似的,现在我们改用STM32标准外设库来实现这个函数。

I2C外设标志位

Gn!

使用硬件I2C实现通信的过程中,不得不提的一个概念就是——I2C外设标志位。

STM32的I2C外设中,存在两个用于存储表示当前I2C通信状态、硬件状态的寄存器:

  1. SR1,即Status Register1

  2. SR2,即Status Register2

通过这些标志位,用户可以了解 I2C 通信的进展,检查错误状态,或者确保数据传输的每个步骤正确执行。

以下是 STM32 的 I2C 外设常用标志位的表格描述:

标志位英文词组标志位含义标志位置为 1 时的条件用途
I2C_FLAG_BUSYBUSY flag总线忙碌标志当 I2C 总线正在传输数据时,标志位置为 1用于判断总线是否空闲,确保在总线空闲时开始通信
I2C_FLAG_SBSTART Bit flag起始位发送完成标志起始位(START)发送完成时,标志位置为 1用于判断起始信号是否成功发送,确保后续步骤可进行
I2C_FLAG_ADDRAddress flag从机地址匹配成功标志主机地址与从机地址匹配时,标志位置为 1确保主从机地址匹配完成,可以进入数据传输阶段
I2C_FLAG_AFAcknowledge Failure flag主机收到从机NACK标志从机发送 NACK时,标志位置为 1用于检测 ACK 错误,通常表示接收失败或通信中断
I2C_FLAG_TXETransmit Empty flag发送数据寄存器空标志发送寄存器为空时,,标志位置为 1用于判断是否可以向数据寄存器写入新的数据(不能用于发地址)
I2C_FLAG_RXNEReceive Not Empty flag接收数据寄存器非空标志接收寄存器中有数据时,标志位置为 1用于判断接收寄存器是否有数据,确保可以读取数据
I2C_FLAG_BTFByte Transfer Finished flag字节传输完成标志当前字节传输完成,标志位置为 1用于判断接收和移位寄存器都为空,确保数据传输已成功结束
I2C_FLAG_STOPFSTOP flag停止位发送完成标志停止位已发送完成时,标志位置为 1用于判断通信是否完全结束,确认 I2C 总线空闲

那么如何来获取和清零这些标志位呢?

这就需要使用两个标准库函数了,即I2C_GetFlagStatus函数I2C_ClearFlag函数

I2C_GetFlagStatus函数: 用于获取某个I2C外设的某个标志位的取值。

其函数声明如下:

函数的两个形参:

  1. I2Cx: 指示要获取标志位的IC2外设名,可以传参 I2C1或者I2C2

  2. I2C_FLAG: 要检查的标志位。标志位可以是各种 I2C 状态寄存器中的标志,例如:I2C_FLAG_SBI2C_FLAG_ADDRI2C_FLAG_RXNE 等。

函数的返回值:

  1. 函数返回SET也就是1,表示标志位已被设置,标志位的条件已经满足。

  2. 函数返回RESET也就是0,表示标志位已被清零,标志位的条件未满足。

I2C_ClearFlag函数:用于清零某个I2C外设的某个标志位。

其函数声明如下:

这个函数没有返回值,形参的使用和上面的函数完全一致,不再赘述。

了解了I2C外设的标志位概念后,下面我们就按照软I2C的实现思路,来一一找到我们需要的库函数:

  1. 主机发送起始位信号

  2. 主机发送寻址字节

  3. 主机读取从机发送的ACK确认

  4. 主机写命令模式的控制字节,也就是主机发送0x00这1个字节的数据到从机

  5. 主机读取从机发送的ACK确认

  6. 主机发送函数形参cmd这1个字节的数据到从机

  7. 主机读取从机发送的ACK确认

  8. 主机发送停止位信号

起始位发送函数

Gn!

若想实现主机发送起始位信号,需要调用:I2C_GenerateSTART函数。这个函数非常简单,就表示主机发送起始位信号,其函数形参如下:

第一个参数指示生成起始信号的I2C外设,可以传参 I2C1或者I2C2

第二个参数我们也很熟悉,它可以传参以下枚举类型:

传参ENABLE即表示主机发送了一个起始位信号。

调用完这个函数后,不要着急继续发送寻址字节,标志位中的I2C_FLAG_SB用于指示发送起始信号完成。

该函数的调用表示通信开始,建议采用以下方式来调用这个函数:

调用该函数之前,先检查总线是否忙碌,若总线处于忙碌状态,即总线正在传输数据,则无法开始新的通信数据传输。

所以需要等待总线空闲:

除此之外,在调用此函数发送起始信号后,还需要调用:

用于等待主机发送起始信号结束。

上面两个细节加在一起,所以此函数调用的推荐方式是:

不要忘记使用I2C外设寄存器的标志位!依靠标志位指示通信状态,是硬件I2C和软I2C非常显著的区别。

停止位发送函数

Gn!

起始位和停止位发送函数的名字非常类似,所以我们直接一起看。

只需要把I2C_GenerateSTART函数名,改成I2C_GenerateSTOP即可表示主机发送停止位。

其函数声明如下:

调用方式和发送起始位的函数也是一样的,这里就不再赘述了。

I2C_SendData函数

Gn!

I2C通信中,实现主机向从机发送1个字节的数据,就需要使用函数:I2C_SendData函数。

该函数的声明如下:

这个函数的调用十分的简单,两个参数就表示主机的I2Cx外设,向从机发送了1个字节的数据Data。

主机发送寻址字节后的处理

Gn!

通过调用I2C_SendData函数,就可以实现发送寻址字节的功能,而寻址字节对于任何I2C通信来说都是待发送数据的第一个字节数据。

那么主机发送寻址字节后,应该如何处理呢?

怎么确定发送成功了呢?

仍然需要校验标志位来判断。

主机发送寻址字节后,有两种可能性:

  1. 若从机应答ACK(地址匹配成功)

    1. I2C_FLAG_ADDR会被置1,表示地址匹配成功。

    2. I2C_FLAG_AF保持为0,主机正常收到从机发送的ACK。

  2. 若从机应答NACK(地址匹配失败)

    1. I2C_FLAG_AF会被置1,表示ACK失败,从机发送NACK非确认应答。

    2. I2C_FLAG_ADDR保持为0,表示地址匹配失败。

所以在主机发送寻址字节后,一般需要执行下列处理:

在硬件I2C使用标准库完成I2C通信的过程中,某些关键I2C标志位被置1后必须及时手动清零,否则会导致通信异常甚至总线锁死。

必须手动清零的标志位有:

  1. I2C_FLAG_AF标志位置为1时:

    1. 表示从机无应答,主机接收到NACK。

    2. AF标志未清除时,I2C外设会认为当前传输失败,总线始终处于错误状态,无法继续后续操作。

  2. I2C_FLAG_ADDR标志位置为1时:

    1. 当主机发送从机地址并收到ACK应答后,ADDR标志会置为1,表示地址匹配成功。

    2. 按照ST公司的I2C外设设计,当ADDR标志置为1后,总线会等待手动清除此标志位,否则I2C外设将始终等待无法进行后续的数据传输工作。

除此之外,I2C外设作为硬件设备,标志位状态信息存储在状态寄存器中,若标志位不及时清零不仅影响当前的I2C通信还会影响下一次通信。

这也是我们在初始化I2C外设时,建议复位I2C外设的原因。

如何清除AF标志位呢?

很简单,只要调用下列函数即可:

如何清除ADDR标志位呢?

ST公司关于清除ADDR标志位的设计比较繁琐,要求必须使用I2C_ReadRegister函数先读SR1寄存器,后读SR2寄存器才能够完全清除ADDR标志位。

具体的函数调用代码就是:

ADDR 标志位被ST公司设计为必须依赖读SR1和SR2两个硬件寄存器才能被清除(清零),无法通过手动调用函数软件清除!

总之,STM32 的 I2C 外设依赖标志位推进通信过程,需严格遵循ST公司的规定进行操作!

最后,主机发送寻址字节后,我们一般采用这种处理手段:

如此,我们就完成一个重要的部分:主机寻址从机以及后续处理。

主机发送普通字节数据后的处理

Gn!

在主机发送数据到从机的过程中,除了第一个字节的寻址字节外,其余要发送的数据都是普通的字节数据。

发送普通字节后,应该进行什么处理呢?

肯定和发送寻址字节的处理不同。

主机发送普通字节后,有以下两种可能性:

  1. 若从机应答ACK,表示主机发送数据成功:

    1. I2C_FLAG_TXEI2C_FLAG_BTF这两个标志位都会被置为1

    2. 其中TXE表示发送数据寄存器为空,BTF表示数据传输已完成

    3. 我们可以使用BTF这个标志位,是否置1,来判断1个字节的数据是否发送完成。

  2. 若从机无应答,也就是应答NACK,则表示从机拒收,主机发送数据失败:

    1. I2C_FLAG_AF会被置1,表示ACK失败,从机发送NACK非确认应答。

    2. 注意,不要忘记处理完后,手动清除AF标志位。

主机发送普通字节数据后的处理逻辑是这样的:

等待BTF标志位为空,即等待主机发送全部数据,然后再判断AF标志位处理从机ACK。

参考的处理方式如下:

注意:

在上面的操作中,我们手动清零了AF标志位,但并没有手动清零TXE和BTF标志位。

这是因为:根据ST公司的I2C外设设计,这两个标志位是只读的、自动置1和清零的标志位,不需要程序员手动操作!

参考实现代码

Gn!

综上所示参考的实现代码如下:

你自己能全部写出来吗?

点亮和熄灭OLED

Gn!

把单片机发送指令的hOLED_WriteCommand函数实现后,相应的点亮和熄灭OLED这两个函数就可以直接写出来了:

以上。

核心实现:读取OLED状态字节

Gn!

现在只剩下最后一个核心函数没有实现了,它就是:

有了上面的基础上,这个函数实现起来也非常的容易。

这里我们再来学习几个函数:

I2C_ReceiveData函数:

有上面的I2C_SendData函数,就有相应的接收函数。此函数的声明如下:

此函数的形参只需要填入要接收数据的外设,可以是I2C1I2C2

它的返回值是一个8位的无符号数,也就是返回接收到的1个字节数据。

这个函数非常简单,但需要注意:最好在I2C_FLAG_RXNE接收数据寄存器非空标志位置为1时再调用,也就是有数据了再去读数据。

所以此函数的一般调用方式如下:

紧接着,我们还需要知道主机如何发送NACK给从机,表示主机不再希望接收从机数据。

这就需要使用函数I2C_AcknowledgeConfig了。

I2C_AcknowledgeConfig函数:

在硬件I2C当中,主机发送ACK还是NACK是自动的,而且是在配置初始化I2C外设时就决定了。

就是调用I2C_Init函数时,I2C_Ack成员的设置。

在前面我们已经将该成员设置为I2C_Ack_Enable,这表示:主机收到从机数据时回复ACK给从机。

而我们现在不需要主机发送ACK了,所以只需要改一下这个配置即可。

I2C_AcknowledgeConfig函数的声明如下:

调用它的参数格式如下:

参数类型说明
I2CxI2C_TypeDef*选择 I2C 外设(如 I2C1I2C2
NewStateFunctionalStateENABLE(发送 ACK)或 DISABLE(禁用 ACK,发送NACK)

所以只需要调用:

即可让主机在接收到数据后,发送NACK。

整体的参考实现代码如下:

以上。

hOLED模块整体实现

Gn!

hOLED模块整体实现参考以下代码:

以上。

外部中断处理函数

Gn!

为了实现外部中断的处理函数,需要声明以下函数:

它们的实现也非常简单,如下所示:

以上。

测试代码

Gn!

测试代码,整个main.c文件的完整代码如下:

以上关于I2C通信的部分,就暂时告一段落了,大家可以自行好好练习一下。

I2C通信的优缺点总结

Gn!

至此,我们已经掌握了 I2C 通信的原理与基本用法。最后,我们来系统地总结一下 I2C 的优势与局限。

首先来看一下I2C通信的优势:

  1. 只需两根信号线(SCL、SDA),节省引脚和布线,适合板载多设备之间的通信;

  2. 支持多个主机和多个从机,灵活性很强;

  3. 软件和硬件实现皆可,特别适合嵌入式系统中的外设驱动;

  4. 功能完善强大,支持寻址,应答,主机切换,主从数据发送等功能;

  5. 低成本设计,使用通用 IO 和简单硬件即可完成通信,性价比极高。

I2C的劣势如下:

  1. 通信距离有限,受限于总线设计,尤其是上拉电阻的存在,不适合长线传输;

  2. 通信速度慢,由于I2C通信基于上拉电阻产生高电平,所以电平"低 -> 高"比较缓慢,拖累了通信速率。

  3. 功耗略高于推挽结构,持续电阻上拉带来静态功耗;

  4. 如果选择多主机模式,容易出现总线冲突,通信过程比较复杂,出错几率高。

总之,I2C通信是期望花最少的钱,用最少的资源,实现更多、更强大、更灵活的功能,而实际上I2C通信确实做到了这一点。

I2C是一种性价比极高、强大且灵活的通信协议,是嵌入式系统中必学且最常用的通信方式之一。

The End