V4.0
嵌入式基础教程<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端口的方式实现一对多,但是:
这样做会导致线路复杂,实现起来很麻烦。
单片机的USART引脚是有限的,这会导致连接设备的数量受限。比如我们使用的STM32F10x系列的单片机,实际上只有三对引脚可供实现串口通信。
串口通用无法真正做到一对多通信。在同一个时刻某个串口只能作为发送端,将数据发送到某一台设备上。
在嵌入式系统当中,一对多的通信需求并不少见,为了实现多台设备之间的通信,我们还需要学习使用其它的通信方式。
在嵌入式开发中,所有可以进行一对多的通信方式中,I2C通信是非常常见的,所以我们就来学习一下2C通信。
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),即可实现一对多个设备的通信,同时实现简单易用性强、灵活和扩展性也非常不错。
Gn!
按照老规矩,我们在学习一个新的技术模块时,还是先来了解一下与这个技术模块相关的一些概念。
比如一个很简单的问题:I2C是什么东西?
Gn!
I2C通信最早由飞利浦(现恩智浦半导体)公司,在1982年左右开发提出。
I2C是英文词组Inter-Integrated Circuit的缩写,更准确的缩写是"I2C",其中的2表示"I"字母的平方,即两个I。
在中文语境下,不要把它读成"I 二 C",而是要读成"I 方 C",其中方表示平方的意思。
如何理解Inter-Integrated Circuit这个词组呢?
Inter-:意为 "相互、互相",表示设备之间的交互通信。
Integrated Circuit(IC):指 "集成电路",也就是各种芯片。
所以I2C通信就可以理解成"集成电路间的通信、芯片之间的通信",意思是用于不同的芯片,不同的集成电路之间的进行数据交互的通信方式。
到如今,I2C通信仍然是嵌入式领域最流行的通信协议之一,它被广泛用于 MCU(微控制器)与各种外设之间的通信。
基于I2C是芯片之间进行数据通信的方式,所以通信双方都会按照既定协议完成通信的过程,整个过程是双方协作完成的,不要理解成单方面的控制!
Gn!
I2C通信,最核心且最重要的特点就是:主从模式。
通过 I²C 总线连接的多台设备,具有 "主-从"(Master-Slave) 关系。整个总线上的设备分为两大类:
主设备(Master):负责控制总线,发起数据传输,生成时钟信号(SCL)。
从设备(Slave):响应主设备的请求,执行数据发送或接收。
在我们学习STM32时,为了简单起见,我们就直接固定把单片机视为主机,其它通过I2C总线和单片机相连的设备,一律视为从机。
实际上大多数I2C的应用场景中,也都会把单片机视为主机,进行简单的"一主多从"的I2C通信。
在I2C通信中,任何数据操作都必须由主机发起,从机则无法主动发起数据交互。这是I2C通信协议最重要且最核心的概念!!!
比如:
主机可以主动给从机发送数据,这就是I2C的写操作。
主机可以主动要求从机发数据到主机,然后从机再将数据发送到主机。但从机不能主动向主机发数据,这就是I2C的读操作。
本小节,我们讲I2C通信,最主要实现的功能就是:
由STM32主机发起,向从机写数据。
由STM32主机发起,主机将从机中的数据读出来。
Gn!
要想理解I2C总线,首先需要理解总线是什么。
我们在讲解STM32基础概念时提到过总线,我们知道:总线(Bus)是计算机系统中多个设备之间共享的数据高速传输通道。
总线类似"高速公路",多个设备共用一条“道路”来传递数据,而不是每个设备单独拉一根“专线”。
总线的使用可以简化电路实现,使得接线数量减少,而且相同总线上连接的设备可以共用同一套通信(数据交互)协议,进一步降低了系统的复杂性。
I2C 总线(Inter-Integrated Circuit Bus,集成电路间总线)是一种 用于多个芯片(IC)之间通信的串行通信总线,它允许多个设备通过 两根信号线(SCL 和 SDA) 进行数据交换。
I2C总线的具体的接线方式,如下图所示:
需要注意的是:RoRdOg
I2C通信仍然是一种串行的通信协议,I2C总线也是一种"串行总线",意味着数据在总线上的传输仍然是一位一位的进行传输的。
在I2C通信中,信号线仍然是通过发送高低电平来表示发送数据1/0的。
上述接线图就展示了一个事情:I2C通信当中的所有设备都是通过两根总线连接在一起的。
那么这两条总线的具体作用是什么呢?
下面我们来分析一下。
Gn!
SCL线(Serial Clock Line),即串行时钟线。
作用:由 主设备(单片机) 产生时钟信号,控制数据传输的时序。
所谓控制时序,就指的是主机严格控制整个通信的流程,比如什么时候开始传输数据,和哪个从机传输数据,是读数据还是写数据,什么结束数据传输等等。 在I2C通信中,由单片机产生的时钟信号,通过I2C总线中的SCL线发送到每一台从设备。
这就好比,主机作为"老大",它通过SCL线主动严格控制每一个"小弟"的工作频率,并严格与主机保持一致。
在I2C通信中,SCL线的控制权始终在主机手中,从机无法掌控SCL线。RoRdOg
关于时钟信号线的作用,也就是主机究竟如何控制所谓的时序,待到下面我们再讲解。
Gn!
SDA线(Serial Data Line,串行数据线)
作用:用于双向传输数据,主机和从机之间的数据交换全部是由这一条线完成的。
主机可以控制SDA线向从机发送数据,从机也可以控制SDA线向主机发送数据。所以在I2C通信中,SDA总线的控制权可以在主机/从机之间转换,当然在通信一开始的时候,SDA总线的控制权属于主机。RoRdOg
I2C是一种串行、同步、半双工的通信方式,它的这些特点就可以通过它的接线方式来推导出来。比如:
串口是全双工的通信方式,是因为通信双方都各有一个发送端和接收端交叉连接,但I2C的数据通信显然不可能是全双工的:
I2C通信中的主机和从机之间的数据交互都是一条SDA线完成的。
在同一时刻,SDA线的控制权只能是主机或者从机。
若主机向从机发送数据,即I2C的写操作。
若从机向主机发送数据,即I2C的读操作。
I2C通信是一种半双工的通信方式,主机和从机不能同时发送和接收数据。
I2C总线是一种串行通信总线,数据是一位一位地RoRdOg依次传输的,所以I2C通信自然就是串行通信手段。
I2C通信中,由主机通过SCL线严格控制数据传输的时序,任何数据的交互都需要双方同时"在线",同时参与。
比如:主机想向从机发送数据了,就必须通过I2C总线通知从机,从机"回答"了,才能继续下一步。
所以I2C通信是一种同步的通信方式。
与之相对应的UART串口通信,它通过规定相同的波特率,使用特定格式的数据帧来保证发送端和接收端的数据同步。这样串口就实现了异步的通信,通信双方不需要保证时刻同时"在线"。
Gn!
I²C 通信是一种 "一对多" 的主从式通信方式。
在一条 I²C 总线上,主机(Master)可以连接多个从机(Slave)设备,那么主机如何在多个从机中找到目标设备呢?
这时就需要了解一个"从机地址"的概念。
在 I²C 通信中,从机地址(Slave Address) 是 主设备(Master)用来选择通信对象的唯一标识。每个从设备在总线上都需要有一个唯一的地址,主设备在通信时会先发送目标从机的地址,只有匹配该地址的设备才会响应。
I2C总线当中的从机设备,它的从机地址都由该从机设备制造商再生产时固定配置,一般就是一个固定7位的二进制数。
在I2C通信编程中,获取某个硬件设备它的从机地址,考察的是程序员收集信息的能力。
就目前而言,有以下几种稳定的途径可以获取设备的从机地址:
从厂商出具的,该设备的官方数据手册中自行查阅获取。
直接问卖设备的厂家。
问用过的同学/同事/大佬。
利用AI,只要你知道你所使用设备的型号,像从机地址这种简单的数据问AI是可以得到正确答案的。
当然无论你从什么渠道获取了从机地址,最终都需要验证一下再相信。
最后,既然从机地址是一个7位的二进制数,那么很明显在一个I2C总线系统内,理论上连接从机数量的最大值是:
27 = 128
但I2C通信协议自保留了一些地址,不能分配给从机设备,比如:
0000 000(7位0):通用呼叫地址,即主机广播式的向每一台从机发数据。
1 1 1 1 1 1 1(7位1):无特殊含义用途,但自保留,从机不能使用
...
所以I2C总线通信中,最大支持的从机数量不是128,而是大约100+,当然这个数量也是完全足够的。
Gn!
在上面,我们已经了解了I2C相关的一些概念了,那么紧接着我们分为两个层次来学习I2C总线通信:
I2C物理层电路结构,也就是了解在I2C通信过程中,关于物理电路的连接以及相关的硬件问题。
I2C协议层概念,也就是了解I2C通信协议,比如怎么决定开始发送,结束发送,以及数据帧的格式等等。
首先我们来看一下I2C物理层电路结构。如下图所示:
下面我们来逐一分析这张图中的信息。
Gn!
在上面我们已经分析了SDA和SCL这两条线,它们一条是数据线,一条是时钟线。
接下来,我们要分析一下I2C通信中,所有设备连接这两条总线所使用的引脚,即SCL引脚和SDA引脚。
这些引脚应该设置为什么工作模式呢?
以单片机作为主机来说,单片机本身没有引脚可以直接作为SDA和SCL引脚,需要通过一些特殊的设置,这就需要查看引脚定义表了。
而I2C通信中,单片机作为主机,它需要:
利用自身的SDA引脚,控制SDA总线的高低电平,从而控制将数据发送给从机。
利用自身的SCL引脚,控制SCL总线的高低电平,从而控制通信过程当中的时序。
结合上述物理电路接线方式,单片机的这两个引脚需要设置成什么工作模式呢?
可以直接排除输入模式,SDA引脚和SCL引脚执行的都是输出向的操作。RoRdOg
那么选择开漏输出还是推挽输出呢?
既然外部电路加装了上拉电阻,那自然选择开漏输出了。
这样单片机就可以通过控制SDA引脚和SCL引脚的0/1(低电平/高阻态),从而控制SDA总线和SCL总线的高低电平。
实际上:
I2C通信中SCL和SDA总线上挂载的所有设备,它们相关引脚的工作模式都是开漏输出!(重点!!)RoRdOg
SDA总线的控制权属于哪台设备(主机/从机),该设备就可以控制自身SDA引脚的0/1(低电平/高阻态),从而控制SDA总线的高低电平。RoRdOg
SCL总线的控制权一定属于主机(单片机),只有主机的SCL引脚可以输出0/1(低电平/高阻态),从而控制SCL总线的高低电平。RoRdOg
上述结论是如何得出的呢?
要想弄明白所谓总线控制权的概念,我们还需要明确一个重要原理——逻辑线与。
Gn!
什么是线与?
总线的电平状态由所有设备的输出共同决定,只有当所有设备输出1(高阻态)时,总线才为高电平;任意一个设备输出低电平,总线电平即被拉低。
具体表现:
高电平1:所有设备释放总线(输出高阻态),上拉电阻将总线拉高。
低电平0:至少一个设备主动拉低总线(输出低电平)。
这种具体的表现和逻辑运算符当中的与"&"运算符完全一致,所以我们把这种总线机制称之为"逻辑线与"。
I2C通信当中的逻辑线与是通过设置引脚为开漏输出模式,配合上拉电阻来共同实现的,这是一种硬件实现的逻辑线与。
逻辑线与有啥用呢?
逻辑线与决定了I2C总线上,SCL线以及SDA线的高低电平状态。公式如下:
某条线上的高低电平状态 = 主机该端口输出(0 / 1) & 从机1该端口输出(0 / 1) &....
只要有任意一个设备的端口输出了0(低电平),那么这整条线就是低电平状态,所有设备的此端口都输出了1(高阻态),那么整条线才是高电平状态。
了解了逻辑线与,那么主机发送时钟信号,以及主机和从机之间的数据交互,它们的实现方式就能够理解了。
了解了逻辑线与,那么所谓总线控制权就非常好理解了。
Gn!
时钟信号线的作用我们已经了解了,那么主机是如何发送时钟信号的呢?
答:通过逻辑线与。
具体逻辑可以参考下图:
当主机发送时钟信号时,所有从机的SCL端口都写1,这样它们就从SCL线上断开,那么SCL线的高低电平,也就是时钟信号。就完全由主机控制了:
只要主机的SCL引脚写1,也就是主机SCL引脚进入高阻抗状态,那么SCL线的时钟信号就是高电平。
只要主机的SCL引脚写0,也就是主机SCL引脚输出低电平,那么SCL线的时钟信号就是低电平。
这就是主机发送时钟信号的原理,注意这个原理,我们后面要自己写代码实现它。
Gn!
所谓I2C写数据,也就是主机向从机发送数据。
它的实现方式仍然采用逻辑线与的原理,具体逻辑可以参考下图:
当主机写数据时,所有从机的SDA端口都写1,这样它们就从SDA线上断开,那么SDA线的高低电平,也就是主机向从机写数据,究竟写高电平还是低电平,写1还是写0。就完全由主机控制了:
只要主机的SDA引脚写1,也就是主机SDA引脚进入高阻抗状态,那么SDA线就是高电平状态,主机发送数据1到从机。
只要主机的SDA引脚写0,也就是主机SDA引脚输出低电平,那么SDA线就是低电平状态,主机发送数据0到从机。
这就是主机发送数据到从机,也就是I2C写数据的实现原理,注意这个原理,我们后面要自己写代码实现它。
Gn!
所谓I2C读数据,也就是从机向主机发送数据。
它的实现方式仍然采用逻辑线与的原理,具体逻辑可以参考下图:
当主机读数据时,主机自身的SDA引脚写1,也就是进入高阻抗状态,主机SDA从SDA总线上断开。
若此时从机1需要发送数据到主机,那么从机2,从机3...等其它从机都必须将自己的SDA引脚写1,从SDA线上断开。
这样一番操作后,主机从SDA线上读到的数据,就完全由从机1控制了:
只要从机的SDA引脚写1,也就是从机SDA引脚进入高阻抗状态,那么SDA线就是高电平状态,从机发送数据1到主机。
只要从机的SDA引脚写0,也就是从机SDA引脚输出低电平,那么SDA线就是低电平状态,从机发送数据0到主机。
这就是从机发送数据到主机,也就是I2C读数据的实现原理,注意这个原理,我们后面要自己写代码实现它。
到此为止,I2C硬件层面上的实现原理我们就讲的差不多了。
在下面的小节中,我们将主要来学习I2C协议层的内容,也就是I2C通信的过程,数据帧格式等等内容。
Gn!
在I2C通信中,只有主机可以控制SCL时钟总线,也就是只有单片机的SCL引脚可以切换0/1状态,从而控制SCL总线的高低电平。
主机控制SCL引脚改变SCL总线的高低电平,从而产生了一条高低电平切换的信号线图,这就是"时钟信号线/时钟同步线",如下图所示:
这条时钟信号线,其实就是SCL总线电平切换随时间改变的一个"时序图",主机和所有从机们收到和使用的都是同一条时钟信号线,共用同一个"时序"。那么所谓的时序,有什么作用呢?
下面是重点!!RoRdOg
SCL线用于同步主从设备的时钟信号,也就是说电路中所有的设备,它的时钟信号是相同的。
I2C通信不是异步通信,它不依赖于数据帧中的特殊起始位或结束位以及设定相同的数据传输速率来实现通信。
而是根据时钟周期,来确定数据如何传输,以及如何接收等问题。
什么是时钟周期呢?
简单来说,一个时钟周期就是一个完整的“高电平 + 低电平”的组合。
于是在I2C通信中,一个时钟周期被分成了两个部分:
工作时间:一个时钟周期中的高电平部分属于工作部分,此时SDA输出的电平决定了传输数据是0还是1。
休息时间:一个时钟周期中的低电平部分属于休息部分,此时SDA输出的电平是无效数据,这段时间是留给设备,来准备下一个工作时间发送数据的。
如下图所示:
我们可以结合下面这张图来分析一下,在这个通信过程中,SDA发送的数据是多少呢?
这并不难理解,此时SDA引脚发出的数据是:1 0 1 0 1 1 1 1。
注意:SDA发送数据,一定是在时钟整个工作时间内都保持高低电平,才表示发送数据1和0。
通过了解这个知识点,我们又加深了对I2C通信的理解,可以总结出以下内容:
I2C是一种同步的串行通信手段,主机和从机必须同步时钟信号,以确保能够在时钟信号高电平(工作状态)时发数据。
时钟信号的频率越高,相同时间内"工作状态"出现的次数就越多,传输数据的速度就越快。当然I2C通信的速率不能直接看系统时钟频率,具体我们下面再讲。
Rd!
到此为止,有关I2C通信硬件电路设计相关的问题,我们就全部结束了。这里做一个要点总结:
I2C 总线由 SDA(数据线)与 SCL(时钟线)组成,所有主从设备共享这两条线,形成“多设备挂载”的总线结构。
通信中所有设备的SDA和SCL引脚,都需要设置为 开漏输出模式。
两条总线的电平受所有设备引脚“线与逻辑”的共同决定——任一拉低(0)即为低电平,全部释放(1)方为高电平。
SCL 总线由主设备独占控制,用于产生时钟信号,控制时序。
SDA 总线控制权在通信时根据读写角色动态切换,主机写时主机控制SDA总线,主机读时从机控制SDA总线。
所谓总线控制权,指的是某一设备具有对总线电平的主动控制能力,即其对应引脚可以在 0 和 1(低电平与高阻态)之间切换,而其他设备必须将对应引脚置为高阻态(输出 1),以释放总线控制权。RoRdOg
I2C 是同步通信协议,所有设备共享主机生成的时钟信号。
每个时钟周期(高电平 + 低电平)被划分为:
休息时间(SCL 低电平):用于数据准备,控制SDA总线的设备拉高总线(准备发1),也可以拉低总线(准备发0);
工作时间(SCL 高电平):用于数据发送与接收采样,此时 SDA总线 必须稳定电平,表示发送数据以及接收数据。
下面,我们来学一学I2C通信软件通信相关的内容。
Gn!
为了讲清楚I2C通信协议,我们就以"主机向从机写数据"这个最常见的场景为例子,描述一下I2C通信的过程。
Gn!
在 I²C 通信中,时钟线 (SCL) 和 数据线 (SDA) 的初始状态(空闲状态)为 高电平。
这和串口通信时的空闲状态非常类似。
如何实现的呢?
只需要让两条总线上连接的所有设备引脚,都输出高阻态,这样两条总线就默认为高电平了。
Gn!
I2C和串口通信一样,都是串行通信方式,所以数据都是逐位发送的。
在串口通信中,数据逐位发送的顺序是从最低位发到最高位的,但I2C通信则完全与此相反!
注意:在I2C通信中,数据都是逐位从最高位发送到最低位的!
Gn!
I2C写数据帧时的数据帧格式:
实际上这个数据帧格式,也揭示了I2C通信发送数据的流程(以主机发送数据为例,从机发送数据原理完全一致):
主机发送起始信号,表示开始主机发送数据的过程。
主机发送设备地址 + 读/写位,表示主机向某个从机读/写数据。主机发送数据时,需要使用"写"标志位。
主机每发送1个字节(8位)数据,从机都必须应答,以表示收到数据。这就是应答位,应答位只占1位。
再往后,就是主机要发送的数据,1个字节的逐位发出去,没发送1个字节,从机都需要回复一个应答位。
...
主机发送完毕所有数据,主机发送停止信号,表示结束主机发送数据的过程。
下面就针对这个过程,我们来逐一讲解一下每个过程是怎么做的。
Gn!
起始位和停止位决定了主机发送数据的开始和结束,那么起始位和停止位的实现原理是什么样的呢?
I2C通信中所有设备都依据时钟周期中的工作时间来发送数据0和1,那么该如何传达开始传输数据和停止传输数据的信号呢?
再来回顾一下SDA线发送数据的方式:
SDA发送数据,一定是在时钟整个工作时间内都保持高低电平,才表示发送数据1和0。
那么在一个工作时间内,若SDA输出的高低电平不是始终保持,而是发生了改变:
由低电平变为高电平,区别于逻辑0和1,此状态表示逻辑0变1
由高电平变为低电平,区别于逻辑0和1,此状态表示逻辑1变0
这两种新出现的状态,不就恰好可以用于表示起始位和停止位嘛?于是I2C通信协议规定:
在一个工作时间内,当SDA从1变0,就表示数据发送的起始位。
在一个工作时间内,当SDA从0变1,就表示数据发送的停止位。
下面我们不着急看寻址字节,我们先来看一下应答位。
Gn!
I2C通信是单双工的,意味着同一时刻下,数据是单向发出的。
在这种情况下,如果从设备在整个数据传输过程中都不回应的话,那么主机就无法判断从机是否成功接收到数据。
于是I2C通信协议规定:
从机每接收主机发送的1个字节数据,在下一个工作时间内,就必须向主机回复一个应答位,以表示自己已收到传输数据。
那么应答位的格式是什么呢?从机是发送0还是发送1呢?
这里我们就要从新看一下主机发送数据的原理,如下图所示:
主机发送数据时,从机的SDA都只为1,SDA线的高低电平完全由主机控制。
每当主机发送完毕一个字节的数据后,主机就会将自身SDA引脚也置为1,将自己从SDA线上断开,放弃SDA线的控制权:
如果从机确定收到了正确的数据,那么从机应该在此时主动将自己的SDA引脚置为0。
如此从机在下个工作时间内,就实现了向主机发送0低电平的功能。
此时从机主动向主机发送了一个低电平0,这就是ACK(Acknowledge)确认应答。
只要主机收到了从机发送来的一个0,就表示这一个字节的数据传输工作完成了。
反之若从机认为自己没有收到数据,或者数据有误,那么从机将不会做任何事情,从机的SDA引脚也置1挂起。
于是整个SDA线的所有引脚都置为1(高阻态),依赖于上拉电阻,SDA表现为高电平。
此时相当于从机向主机发送了一个高电平1,这就是NACK(Not Acknowledge)非确认应答。
只要主机收到了从机发送来的一个1,就表示这一个字节的数据传输工作有问题。
如此结合起始位、停止位以及应答位,主机发送1个字节数据的时序图如下所示:
这里提到了一个新概念时序图,所谓时序图就是"电路中电平随时间变化"的流程图。从左到右表示时间的流逝,而电平则只有高电平和低电平,非常简单。
下面这张图则更清晰的展示了"1个字节数据",从主机发送到从机的过程:
上面讲的是主机发送数据,也就是I2C写数据。主机接收数据,也就是I2C读数据,过程也完全类似。流程图如下所示:
特别需要注意的点是,I2C通信采用主从模式,即便是从机向主机发送数据,这个过程也是由主机"主动"发起的!!
通过这里的讲解,我们就发现了,在I2C通信的过程中,数据的流向是始终变化的,主机发一段,从机就要响应一下,反之亦然。
Gn!
I2C读数据时的数据帧格式,和写数据时没有本质区别。如下所示:
总之,在I2C通信时,总是主从机交替发送数据的,要搞清楚到底是谁在发,谁在接,不要弄混淆了。
Gn!
寻址字节数据相对比较复杂,因为它兼顾两个作用:
前7位是接收数据的从机地址,唯一指示某个从机与主机进行通信。
第8位是读写标志位。I2C通信协议规定:
读写标志位是0时,表示主机向从机写数据。
读写标志位是1时,表示从机发送数据到主机,也就是主机读从机数据。
也就是说,只要知道了7位从机地址,然后再配合1位读写标志,我们就能够知道I2C通信中,数据帧的第一个字节应该如何发送了。
到此为止,关于I2C软件通信协议部分,就全部结束了。
下面我们就以一款支持I2C通信协议的设备"SSD1306 OLED显示屏"RoRdOg为例子,来讲讲该硬件的使用。随后完成单片机与这款屏幕的I2C通信。
Gn!
若想使用I2C协议驱动OLED显示屏,你需要知道以下内容:
OLED显示屏的硬件从机地址。
OLED显示屏I2C通信的数据帧格式。
常见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的硬件电路图,如下图所示:
其中D/C引脚部分的电路图如下所示:
所以D/C引脚的电平值,可以是高电平的1,也可以是低电平的0,具体使用哪一个要看引脚被焊接到哪一个电阻上。
那这怎么看呢?把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屏幕的SDA和SCL引脚都各自内置了一个4.7KΩ的上拉电阻,这就意味着OLED屏幕接入电路后,完全不需要再手动添加上拉电阻了。
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 如何处理后续的字节数据。
Gn!
由于我们本章节只用于I2C通信入门,所以我们只需要了解简单的控制指令用于实现I2C通信即可。
慢慢查阅手册比较麻烦,我这里就列出两个我们做实验会用到的指令字节序列。
假如我想实现一个简单功能:开启OLED,并点亮全部像素。
此时只需要控制主机向从机屏幕发送以下指令序列就可以了:
xxxxxxxxxx
61uint8_t commands[] = {
20x00, // 下面的指令是命令流
30x8D, 0x14, // 开启电荷泵
40xAF, // 打开屏幕
50xA5 // 让屏幕全亮,点亮所有像素点
6};
组指令可以用于初始化 OLED 并让屏幕全亮。以下是解释:
0x00
: 表示发送的是命令流。
0x8D, 0x14
: 打开 OLED 的内部电荷泵,为 OLED 提供驱动电压。
0xAF
: 打开 OLED 显示。
0xA5
: 让屏幕所有像素点亮。假如我想实现一个简单功能:完全关闭OLED。
也就是上面操作的逆向操作,你只需要操作主机向OLED从机发送以下指令序列即可:
xxxxxxxxxx
61uint8_t commands[] = {
20x00, // 下面的指令是命令流
30xA4, // 普通显示模式
40xAE, // 关闭 OLED 显示
50x8D, 0x10 // 关闭电荷泵
6};
说明:
0x00
: 表示发送的是命令流。
0xA4
: 将 OLED 从 "全屏像素点亮" 模式(0xA5
)切换到全部像素熄灭状态。
0xAE
: 关闭 OLED 显示。
0x8D, 0x10
:关闭电荷泵,停止为 OLED 提供驱动电压,彻底关闭OLED。主机读OLED。
上面是实现了写OLED屏幕的功能,实际上OLED在I2C通信中几乎只需要主机写功能,OLED作为从机几乎没必要向主机发送数据。
毕竟OLED既不是存储器也不是传感器,但OLED仍然具有唯一一个向主机发送数据的功能:返回状态字节。
如下图所示:
SSD1306控制芯片的OLED屏幕没有提供明确的可读寄存器地址,主机读OLED从机时,会直接出现以下情况:
主机发送起始信号。
主机发送从机地址 + 读模式(
0x78 | 0x01
)。从机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。
Gn!
什么是硬件I2C呢?
我们使用的STM32F10x系列单片机,其本身就内置了I2C外设模块,这些模块专门设计用于处理 I²C 协议,能够自动完成通信中的大部分操作。
如果选择使用单片机内置的I2C模块来实现I2C通信,这就是硬件I2C(简称硬I2C,HI2C)
那什么是软件I2C呢?
在上述讲解的过程中,大家肯定不难发现:
I2C通信中主机的SDA和SCL引脚完全可以通过普通GPIO口来模拟实现。
如果使用普通GPIO引脚,通过编写软件的方式模拟I2C的通信过程,实现I2C通信,这就是软件I2C(简称软I2C,SI2C)
那硬I2C和软I2C该怎么选择呢?
事实上两种方式,我们都应该学习。
相比较而言:
硬I2C使用相应硬件实现功能,数据传输速率会更高一些,但必须使用单片机指定引脚,所以灵活性差一些。
软I2C依靠纯软件实现,没有相应硬件支持,数据传输速率会更慢一些,但可以使用任意两个普通IO引脚,所以灵活性好一些。
除此之外,对于STM32F10x系列这样早期的单片机,ST公司为了规避飞利浦公司的收费专利,在设计I2C硬件时搞得比较复杂,还有一些共识的缺陷。
所以对于STM32F10x系列芯片而言,软I2C实现起来可能会更简单容易一些,若通信的需求不是大量数据传输也不追求高性能,比如驱动OLED,使用软I2C应该是更好的选择。
当然,如果你使用的是更新的芯片(比如F4系列),或者你需求更高的性能(高速传感器,频繁的存储器数据交互),或者干脆使用HAL库编程,那么硬I2C应该是更好的选择。
Gn!
鉴于软I2C在我们所使用的STM32F10x系列芯片上,可能会更加简单易用一些。我们先来完成软I2C驱动OLED的实验,借此来演示一下软I2C的使用。
电路接线图非常简单,在整个软硬I2C的学习中,我们都只简单驱动OLED屏幕完成实验。所以接线图都是一样的:
实物接线图如下图所示:
注意:
这里OLED屏幕的接线方式和之前仍然是一样的,不需要做改动。需要使用跳线把OLED引脚和单片机引脚连接起来。接线方式是:
OLED屏幕的SCL -- PB10
OLED屏幕的SDA -- PB11
需要使用跳线连接!
两个按键和LED仍然保留前面外部中断实验时的状态即可:
LED的正极接电源正极,负极接入PA3引脚
两个按键一脚接入电源正极,另一脚分别接入PB6和PB8引脚。
实验目的:
利用外部中断机制:
按下并弹起左边按键PB6,表示开启屏幕并点亮所有像素。
按下并弹起右边按键PB8,表示关闭屏幕并熄灭所有像素。
在中断处理中,读取OLED状态字节,若OLED屏幕点亮,则点亮PA3指示灯,否则熄灭。
要求使用软I2C来完成实验。
由于此实验涉及到的外设相对多一些,代码也会更复杂一些,这里我们演示一下模块化编程,也就是使用头文件。
在之前的C阶段,我们实现单链表时,就用到了模块化编程的思路,这里就不再赘述了。
首先,我们直接把工程目录"Tools"文件夹下的OLED相关文件删掉,再新建以下文件:
按键模块的KEY.h和KEY.c文件
LED模块的LED.h和LED.c文件
软I2C模块的sI2C.h和sI2C.c文件
OLED模块的sOLED.h和sOLED.c文件
随后你还需要在Keil5的Group中将它们添加进去,这些操作都比较简单之前都做过,不再赘述具体的流程。
注意:不要在Keil5软件当中直接新建.c/或者.h文件,而是要去磁盘目录下手动新建文件,然后再添加到Keil5软件当中。
外部中断处理函数(中断服务程序),则放到main.c文件中去实现,当然你也可以放到按键模块,毕竟是由按键触发的外部中断。
按照惯例,我们先来实现我们熟悉的、简单的模块,比如按键KEY模块。
Gn!
按键模块没什么花里胡哨的东西,只需要初始化GPIO引脚、映射EXTI线、初始化EXTI外设以及最终初始化NVIC即可。
这些代码之前就已经都写过了。
KEY.h头文件的参考代码如下:
x123
45
6// 初始化两个按键并设置外部中断
7void KEY_Init(void);
8
9// !__KEY_H
10
注意头文件保护语法,由于实现中使用了外设标准库函数,所以还需要包含stm32f10x.h头文件。
KEY.c源文件的实现也很简单,参考代码如下所示:
xxxxxxxxxx
4112
3/**
4* @brief 配置两个按键的GPIO引脚以及它们的外部中断
5* @param 无
6* @retval 无
7* @note PB6和PB8设置为下拉输入
8*/
9void KEY_Init(void) {
10// 1.开启GPIOB以及AFIO外设时钟
11RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE);
12
13// 2.配置两个GPIO引脚,设置它们的工作模式
14GPIO_InitTypeDef GPIO_InitStruct;
15GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_8;
16GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPD; // 下拉输入模式
17GPIO_Init(GPIOB, &GPIO_InitStruct);
18
19// 3.映射GPIO引脚到对应的EXTI线
20GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource6); // 映射PB6到EXTI线6
21GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource8); // 映射PB8到EXTI线8
22
23// 4.配置EXTI,开启中断请求
24// 线6和线8一起处理
25EXTI_InitTypeDef EXTI_InitStruct;
26EXTI_InitStruct.EXTI_Line = EXTI_Line6 | EXTI_Line8;
27EXTI_InitStruct.EXTI_LineCmd = ENABLE;
28EXTI_InitStruct.EXTI_Mode = EXTI_Mode_Interrupt;
29EXTI_InitStruct.EXTI_Trigger = EXTI_Trigger_Rising; // 上升沿触发
30EXTI_Init(&EXTI_InitStruct);
31
32// 5.配置NVIC,配置中断的优先级以及最终开启中断
33NVIC_InitTypeDef NVIC_InitStruct;
34// 配置线6和线8的中断请求
35NVIC_InitStruct.NVIC_IRQChannel = EXTI9_5_IRQn;
36NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
37NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 0;
38NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0;
39NVIC_Init(&NVIC_InitStruct);
40}
41
这段代码非常简单,只要按照既定的流程实现即可,不再赘述。
注意,每写完一个模块建议都编译一下,这样可以及时的排查错误。
Gn!
LED模块也很简单,它仅需要三个函数即可实现全部功能。
LED.h头文件的参考代码如下:
xxxxxxxxxx
14123
45
6// 初始化LED(开漏接法,PA3引脚)
7void LED_Init(void);
8// 点亮LED
9void LED_ON(void);
10// 熄灭LED
11void LED_OFF(void);
12
13// !__LED_H
14
LED.c源文件的实现也很简单,参考代码如下所示:
xxxxxxxxxx
3712
3/**
4* @brief 初始化PA3引脚,LED正极接电源正极,负极接PA3引脚
5* @param 无
6* @retval 无
7*/
8void LED_Init(void) {
9// 开启GPIOA时钟
10RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
11GPIO_InitTypeDef GPIO_InitStruct;
12GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出模式
13GPIO_InitStruct.GPIO_Pin = GPIO_Pin_3;
14GPIO_InitStruct.GPIO_Speed = GPIO_Speed_2MHz;
15GPIO_Init(GPIOA, &GPIO_InitStruct);
16// LED默认熄灭状态
17GPIO_SetBits(GPIOA, GPIO_Pin_3);
18}
19
20/**
21* @brief PA3引脚设置为低电平,LED点亮
22* @param 无
23* @retval 无
24*/
25void LED_ON(void){
26GPIO_ResetBits(GPIOA, GPIO_Pin_3);
27}
28
29/**
30* @brief PA3引脚设置为高电平,LED熄灭
31* @param 无
32* @retval 无
33*/
34void LED_OFF(void){
35GPIO_SetBits(GPIOA, GPIO_Pin_3);
36}
37
注意,每写完一个模块建议都编译一下,这样可以及时的排查错误。
Gn!
我们使用软件模拟I2C通信,实际上就是用一个个函数来模拟I2C通信的一个个小过程。
那么I2C通信一共有哪些子过程呢?
假如是I2C主机向从机发送数据,流程是:
主机发送起始位
主机发送寻址字节(也就是主机发7位从机地址,加上1位读写标志给从机)
从机发送ACK(也就是主机读从机的ACK)
主机发送其它字节...从机发送ACK给主机
主机发送停止位
假如是I2C主机读从机数据,流程是:
主机发送起始位
主机发送寻址字节(也就是主机发7位从机地址,加上1位读写标志给从机)
从机发送ACK(也就是主机读从机的ACK)
从机发送其它字节...主机发送ACK给从机
主机发送NACK给从机,表示不再读从机数据
主机发送停止位
总之对于I2C通信而言,我们只要实现下列7个函数,就足够模拟I2C通信的全部功能。
sI2C.h头文件的参考代码如下:
xxxxxxxxxx
15123
45
6// 软I2C的基本操作
7void sI2C_Init(void); // 初始化软I2C的SDA和SCL两个引脚,实际这两个引脚可以是任意普通IO引脚
8void sI2C_Start(void); // 主机发送起始位
9void sI2C_Stop(void); // 主机发送停止位
10void sI2C_WriteByte(uint8_t Data); // 主机发送1个字节数据到从机
11uint8_t sI2C_ReadByte(void); // 主机读取从机的1个字节数据
12void sI2C_WriteAck(uint8_t AckType); // 主机发送应答/非应答信号给从机
13uint8_t sI2C_ReadAck(void); // 主机读取从机发送的应答/非应答信号,并返回读到的信号
14
15// !__SI2C_H
下面逐一来讲解实现这些函数的实现。
Gn!
为了提升代码可读性,灵活性,也使得代码写起来轻松一些,我们提取出一些反复使用的函数宏,以及必要的宏定义。
如下所示:
x123
4/*------------- 硬件引脚配置宏 ------------------*/
5678
9/*----------------- 主机读写两条总线的操作函数 -----------------*/
10/**
11* @brief 主机通过SCL引脚控制时钟信号线
12* @param BitValue: 0-拉低SCL总线电平, 1-释放(拉高)SCL总线电平
13* @note 只有主机才拥有SCL总线的控制权
14*/
15static void sI2C_SCL_Write(uint8_t BitValue) {
16GPIO_WriteBit(I2C_GPIO_PORT, I2C_SCL_PIN, (BitAction)BitValue);
17Delay_Us(5); // 延迟一段时间, 保持电平稳定
18}
19
20/**
21* @brief 主机通过SCL引脚控制数据信号线, 以实现向从机发送数据
22* @param BitValue: 0-拉低SDA总线电平, 1-释放(拉高)SDA总线电平
23* @note 主机发送时,主机拥有对SDA总线的控制, 从机需要读SDA总线的状态以接收数据
24*/
25static void sI2C_SDA_Write(uint8_t BitValue) {
26GPIO_WriteBit(I2C_GPIO_PORT, I2C_SDA_PIN, (BitAction)BitValue);
27Delay_Us(5); // 延迟一段时间, 保持电平稳定
28}
29
30/**
31* @brief 主机读取SDA总线电平,用于实现读从机ACK或者读从机发送的数据并且返回这个数据
32* @retval Bit_RESET-低电平, Bit_SET-高电平
33*/
34static uint8_t sI2C_SDA_Read(void) {
35uint8_t Val = GPIO_ReadInputDataBit(I2C_GPIO_PORT, I2C_SDA_PIN);
36Delay_Us(5); // 保持采样稳定
37return Val;
38}
可以看到在上述三个函数里,实现任何一个功能后,都会选择延时5us,这个5us的延时是什么意思呢?这个数值可以是别的吗?
在I2C通信中,将时钟线的高电平视为工作时间,用于读写数据,所以时钟线的频率,也就是 SCL 信号每秒钟切换的次数(Hz)是决定I2C通信速率的核心要素。
时钟频率越快,SCL信号切换高电平的次数就越多,1s内的工作时间越多,收发字节数量就越多。
I2C协议当中的规定了典型的三种SCL时钟线频率,如下表所示:
模式 最大 SCL 频率 SCL 一个时钟周期 理论最大传输速率(单位:千字节每秒) 标准模式 (SM) 100 kHz (100,000 Hz) 10 μs 11.1KB/s 快速模式 (FM) 400 kHz (400,000 Hz) 2.5 μs 44.4KB/s 高速模式 (HS) 1 MHz (1,000,000 Hz) 1 μs 111.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。
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,当然实际传输速率肯定比这个理论最大速度要慢一些。
其余模式的理论最大传输速率,也都是这么计算出来的。
Gn!
在实现宏函数
sI2C_SDA_Read
时,我们通过GPIO_ReadInputDataBit
函数读取了SDA引脚的输入电平,但这里就有一个很重要的问题了:SDA引脚不是开漏输出模式吗?那么还能够读引脚的输入高低电平吗?
是不是需要将SDA引脚先切换到输入模式(比如浮空输入)然后再读引脚输入电平呢?
这是不需要的,因为当GPIO引脚设置为开漏输出模式时,可以直接读输入数据寄存器获取IO引脚输入电平的高低。(在官方手册当中明确规定)
Gn!
引脚初始化非常简单,只需要将引脚设置为"通用开漏输出模式"即可。
为什么是通用呢?
软件 I2C使用 GPIO引脚输出 直接模拟 I2C通信的时序,只需要让引脚输出高阻态和低电平两种状态就可以了。
除此之外,不要忘记SCL和SDA线默认用高电平表示空闲状态,所以初始化的末尾要将它们置为高电平。
Init函数的参考代码如下:
xxxxxxxxxx
181/**
2* @brief I2C总线初始化
3* @param 无
4* @retval 无
5* @note 配置PB10和PB11为开漏输出模式
6* 初始化后总线处于空闲状态(SCL和SDA均为高电平)
7*/
8void sI2C_Init(void) {
9RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // 开启GPIOB时钟
10GPIO_InitTypeDef GPIO_InitStruct;
11GPIO_InitStruct.GPIO_Pin = I2C_SCL_PIN | I2C_SDA_PIN;
12GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_OD; // 通用开漏输出模式
13GPIO_InitStruct.GPIO_Speed = GPIO_Speed_2MHz; // 2MHz的引脚输出速度足够了
14GPIO_Init(I2C_GPIO_PORT, &GPIO_InitStruct);
15
16sI2C_SCL_Write(1); // 初始化SCL总线为高电平空闲状态
17sI2C_SDA_Write(1); // 初始化SDA总线为高电平空闲状态
18}
这个函数的实现应当是非常简单的。
Gn!
主机发送起始位的实现逻辑如下:
拉高 SDA 和 SCL 线:这一步是为了让 I2C 总线处于空闲状态,因为 I2C 协议规定在空闲时 SDA 和 SCL 都为高电平,为产生起始信号做准备。
在SCL总线为高电平时,拉低 SDA 总线:这样在 SCL 高电平期间就产生了一个下降沿,这是 I2C 协议定义的起始信号标志,表明一次新的通信开始。
最后,再拉低 SCL 总线,进入休息时间,主机开始准备下一位待发送的数据,即拉低/拉高SDA总线
实际上,我们在设计实现软I2C的各个操作函数时,都遵循这样的原则:执行完一个操作后就拉低SCL总线,进入休息时间!!
主机发送终止位的实现逻辑如下:
拉低 SDA 总线:为下一个工作时间产生上升沿做准备,使 SDA 总线先处于低电平状态。
拉高 SCL 总线:拉高 SCL 总线进入工作时间,为在 SCL 高电平期间产生上升沿创造条件。
SCL 总线为高电平时拉高 SDA总线:在 SCL 高电平期间拉高 SDA,产生上升沿,这是 I2C 协议规定的停止信号标志,用来表示本次通信结束。
参考代码代码如下所示:
xxxxxxxxxx
291/**
2* @brief 主机发送I2C起始位,通信开始
3* @param 无
4* @retval 无
5* @note 时序要求:SCL高电平期间SDA产生下降沿
6*/
7void sI2C_Start(void) {
8// 两条总线全部拉高,表示两条总线都处于空闲状态
9sI2C_SDA_Write(1);
10sI2C_SCL_Write(1);
11
12// 此时SCL总线处于高电平状态, 将SDA总线拉低产生一个下降沿表示通信开始
13sI2C_SDA_Write(0);
14// 拉低SCL总线, 表示进入休息时间, 主机准备下一位待发送数据
15sI2C_SCL_Write(0);
16}
17
18/**
19* @brief 主机发送I2C终止位,通信结束
20* @param 无
21* @retval 无
22* @note 时序要求:SCL高电平期间SDA产生上升沿
23*/
24void sI2C_Stop(void) {
25sI2C_SDA_Write(0); // 先拉低SDA总线, 使得SDA总线上能够产生一个上升沿
26sI2C_SCL_Write(1); // 再拉高SCL总线,进入工作时间
27
28sI2C_SDA_Write(1); // SCL总线高电平状态时拉高SDA总线,产生上升沿,表示通信结束
29}
只要理解了I2C协议的发送位和终止位的逻辑,那么这两个函数的实现还是非常简单的。
Gn!
在I2C通信中,主机发送1个字节的数据,总是从高地址发送到低地址的。
所以它的大体步骤思路是:
步骤 1:开启 8 次循环
操作:启动一个循环,循环 8 次,用于逐位发送一个字节(8 位)的数据。
原因:一个字节由 8 位二进制数组成,I2C 协议规定每个时钟周期传输 1 位数据,因此需要循环 8 次才能完成一个字节数据的传输。
步骤 2:判断并设置最高位数据
操作:判断待发送数据的最高位是 0 还是 1,然后将 SDA 线电平设置为对应的 0 或 1。
原因:在 I2C 协议中,SCL 低电平时 SDA 可以改变电平来准备数据。此时通过判断数据最高位并设置 SDA 电平,是为后续 SCL 高电平时从机采集数据做准备。也就是在准备时间里,SDA总线准备待发送的数据。
步骤 3:数据左移一位
操作:将待发送的数据左移一位,让之前的倒数第二位变成新的最高位。
原因:为了在下一次循环中发送下一位数据,通过左移操作使下一位数据成为最高位,方便后续继续按照从高位到低位的顺序逐位发送数据。
步骤 4:拉高 SCL 线进入工作时间
操作:将 SCL 线电平拉高。
原因:拉高 SCL 线进入工作时间,此时根据 I2C 协议要求,SDA 线电平要保持稳定,以便从机在 SCL 高电平期间采集 SDA 线上的数据。
步骤 5:拉低 SCL 线进入休息时间
操作:将 SCL 线电平拉低。
原因:拉低 SCL 线进入下一个时钟周期的休息时间,在 SCL 低电平时,SDA 线又可以改变电平,为发送下一位数据做准备,这样就可以继续循环发送后续的数据位。
参考实现代码如下所示:
xxxxxxxxxx
291/**
2* @brief 主机发送1个字节的数据到从机
3* @param Data: 待发送的1个字节的数据
4* @retval 无
5* @note 每个时钟周期发送1位数据
6* SCL总线低电平时, 改变SDA总线的高低电平, 准备发送数据
7* SCL总线高电平时, SDA总线保持稳定的高低电平,主机发送数据, 从机采集数据
8*/
9void sI2C_WriteByte(uint8_t Data) {
10// 循环发送8位1个字节的数据, 从最高位发到最低位
11for (uint8_t i = 0; i < 8; i++) {
12/*
13当前SCL总线处于低电平状态, I2C通信处于休息时间
14此时拉低或者拉高SDA总线, 以准备待发送的1位数据
15*/
16if (Data & 0x80) { // 0x80就是8位二进制数1000 0000
17// 相与的结果是非0的, 说明Data的最高位是1
18sI2C_SDA_Write(1); // 拉高SDA总线, 准备发送数据位1
19} else {
20// 相与的结果是0, 说明Data的最高位是0
21sI2C_SDA_Write(0); // 拉低SDA总线, 准备发送数据位0
22}
23Data <<= 1; // 将Data左移一位, 原本的最高位丢弃, 最低位补0, 这样原本的倒数第二高位就成为了新的最高位
24
25// 待发送数据准备好了,拉高SCL总线,进入工作时间
26sI2C_SCL_Write(1); // 该函数在拉高SCL总线后延时5us,此时SDA总线状态保持不变,足够从机采集数据
27sI2C_SCL_Write(0); // 拉低SCL总线,进入休息时间
28}
29}
还是要强调一下,软I2C通信的这些函数的设计都遵循同一个原则:每执行完成一个操作,都要拉低SCL总线进入休息时间!
Gn!
看完了I2C的写操作,再来实现I2C的读操作,也就是主机读取从机的单字节数据。
在I2C通信中,主机发送数据是从高位发送到低位的,那么主机读取(接收)数据也自然是从高位接收到低位的。
其大体的实现思路是这样的:
步骤 1:初始化接收数据变量
操作:定义一个无符号 8 位整型变量
ReceiveData
并初始化为 0。原因:这个变量用于存储从从机读取到的一个字节的数据。初始化为 0 是因为当读到的数据位为低电平时,不需要对
ReceiveData
进行额外赋值操作。步骤 2:主机释放 SDA 线
操作:调用
sI2C_SDA_Write(1)
函数将 SDA 线拉高,释放对 SDA 线的控制。原因:在主机接收从机数据之前,需要确保释放 SDA 线,避免主机对 SDA 线的控制干扰从机的数据发送,这样从机才能在 SDA 线上输出要发送的数据。
步骤 3:逐位读取数据(8 次循环)
启动一个循环,循环 8 次,每次循环读取一位数据。
拉高 SCL 线:调用
sI2C_SCL_Write(1)
函数将 SCL 线拉高,进入工作时间。读取 SDA 线电平:调用
sI2C_SDA_Read()
函数读取 SDA 线的电平。如果 SDA 线为高电平,说明当前读取的数据位是 1,将对应的位设置到ReceiveData
变量中。拉低 SCL 线:调用
sI2C_SCL_Write(0)
函数将 SCL 线拉低,进入休息时间,为读取下一位数据做准备。I2C 协议规定每个时钟周期传输 1 位数据,一个字节由 8 位组成,所以需要循环 8 次来完成一个字节数据的读取。
在 SCL 高电平期间,从机将数据放在 SDA 线上,主机读取 SDA 线的电平来获取数据位。拉低 SCL 线后,SDA 线可以准备下一位数据。
步骤 4:返回读取到的数据
操作:循环结束后,将存储读取数据的
ReceiveData
变量返回。原因:函数的目的是读取从机的一个字节数据,循环结束后
ReceiveData
变量中已经存储了完整的一个字节数据,将其返回给调用者,以便后续使用。参考实现代码如下所示:
xxxxxxxxxx
361/**
2* @brief 主机读取从机发送的1个字节的数据
3* @param 无
4* @retval 从机发送, 主机读取到的1个字节的数据(uint8_t)
5* @note 在I2C通信中,主机发送数据是从高位发送到低位的,那么主机接收数据也是从高位接收到低位
6* 一开始主机具有SDA总线的控制权,所以主机要主动让自身SDA引脚输出高阻态,放弃SDA总线控制权
7* 随后在下一个工作时间里读SDA总线的高低电平,即可实现主机接收数据
8*/
9uint8_t sI2C_ReadByte(void) {
10// 初始化作为返回值的1字节变量,初始值设为0,如果某位读到低电平0则无需赋值
11uint8_t ReceiveData = 0;
12// 主机让自身SDA引脚进入高阻态,放弃SDA总线控制器,从机控制SDA总线开始发送数据
13sI2C_SDA_Write(1);
14
15// 用于构建接收数据的掩码,与这些掩码做|=运算,表示接收数据从最高位到最低位是1
16const uint8_t Masks[8] = {0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01};
17
18// 主机接收从机发来的数据,逐位读取,循环8次,第一次读到的是最终数据的最高位
19for (uint8_t i = 0; i < 8; i++) {
20/*
21现在SCL总线处于低电平休息时间,这个时间是留给从机准备待发送数据的
22紧接着就拉高SCL总线,进入工作时间
23主机在工作时间里读SDA总线的电平状态,从而接收数据
24*/
25sI2C_SCL_Write(1);
26if (sI2C_SDA_Read() == Bit_SET) {
27// 主机读到SDA总线为高电平状态,说明读到了最高位数据是1
28// 将第8-i位的值设置为1
29ReceiveData |= Masks[i];
30}
31// 主机每读完1位,就拉低SCL总线,进入休息时间,继续循环读下一位
32sI2C_SCL_Write(0);
33}
34// 循环结束,主机就读完了一整个字节的从机数据, 当然此时SCL总线是低电平的, 处于休息时间
35return ReceiveData;
36}
这部分实现当中,从最高位到最低位构建
ReceiveData
的实现方式并不是唯一的,你也可以选择一些其它的实现方式。我这里给出的实现,是我个人认为最好理解的。
Gn!
主机发送应答信号给从机,其实就是主机向从机发送一个bit的数据,可以是ACK(0),也可以是NACK(1)。
其实现思路是这样的:
步骤 1:准备应答信号数据
操作:根据传入的参数
AckType
的值,调用sI2C_SDA_Write(ack)
函数设置 SDA 线的电平。当AckType
为 0 时,表示发送 ACK 信号,SDA 线被拉低;当AckType
为非 0 时,表示发送 NACK 信号,SDA 线被拉高。原因:此时 SCL 线处于低电平,根据 I2C 协议,在 SCL 低电平时,SDA 线可以改变电平,所以在这个时候准备好应答信号的数据。
步骤 2:拉高 SCL 线
操作:调用
sI2C_SCL_Write(1)
函数将 SCL 线拉高。原因:拉高 SCL 线后进入工作时间,按照 I2C 协议,在 SCL 高电平期间,从机需要读取 SDA 线上的应答信号,以确定主机是否成功接收数据。
步骤 3:拉低 SCL 线
操作:调用
sI2C_SCL_Write(0)
函数将 SCL 线拉低。原因:拉低 SCL 线后,进入下一个时钟周期的休息时间,SDA 线又可以进行电平的改变,为后续的通信操作做准备。
其参考代码如下所示:
xxxxxxxxxx
151/**
2* @brief 主机发送应答信号给从机
3* @param AckType, 即应答类型: 0-ACK,1-NACK
4* @retval 无
5* @note 既然是主机发送应答信号,那么主机就拥有SDA总线控制权
6* ACK: 在SCL高电平期间, 保持SDA低电平, 表示主机应答
7* NACK: 在SCL高电平期间, 保持SDA高电平, 表示主机无应答
8*/
9void sI2C_WriteAck(uint8_t AckType) {
10// 只有主机读完从机1个字节数据后,才需要发送应答信号给从机
11// 所以此时SCL处于低电平状态,是休息时间,准备SDA数据
12sI2C_SDA_Write(AckType); // 主机把应答位数据准备好
13sI2C_SCL_Write(1); // 拉高SCL,进入工作时间,从机在SCL高电平期间,读取应答位
14sI2C_SCL_Write(0); // 拉低SCL,开始下一个休息时间
15}
需要注意的小细节是:从机如何去读到这个ACK/NACK,然后如何进行处理,这是从机自身设定的问题,和我们主机的代码是无关的。
Gn!
主机读取从机发来的应答信号,其实就是主机读取从机中的一个bit的数据。
其思路是这样的:
步骤 1:主机释放 SDA 线
操作:调用
sI2C_SDA_Write(1)
函数将 SDA 线拉高,释放对 SDA 线的控制。原因:在主机接收从机的应答信号之前,需要确保释放 SDA 线,避免主机对 SDA 线的控制干扰从机发送应答信号。释放 SDA 线后,从机才能在 SDA 线上输出自己的应答信号。
步骤 2:拉高 SCL 线
操作:调用
sI2C_SCL_Write(1)
函数将 SCL 线拉高。原因:拉高 SCL 线进入工作时间,根据 I2C 协议,在 SCL 高电平期间,从机会将应答信号放在 SDA 线上,主机可以在这个时间段读取 SDA 线的电平来获取应答信号。
步骤 3:读取 SDA 线电平
操作:
定义一个无符号 8 位整型变量
ReceiveAckType
,用于存储从机发送的应答信号。调用
sI2C_SDA_Read()
函数读取 SDA 线的电平,并将读取到的结果存储到ReceiveAckType
变量中。原因:SDA 线的电平代表了从机的应答信号,低电平(0)表示 ACK(应答),高电平(1)表示 NACK(非应答)。通过读取 SDA 线电平,主机可以获取从机的应答信息。
步骤 4:拉低 SCL 线
操作:调用
sI2C_SCL_Write(0)
函数将 SCL 线拉低。原因:拉低 SCL 线后,进入下一个时钟周期的休息时间,SDA 线又可以进行电平的改变,为后续的通信操作做准备。
步骤 5:返回应答信号
操作:将存储应答信号的
ReceiveAckType
变量作为函数的返回值返回。原因:调用者可以根据返回的应答信号判断数据传输是否成功,并进行相应的处理。例如,如果返回值为 0,表示从机应答成功,数据传输正常;如果返回值为 1,表示从机非应答,可能存在数据传输错误等问题。
具体的参考代码如下:
xxxxxxxxxx
141/**
2* @brief 主机读取从机发来的应答信号, 可以是ACK(0), 也可以是NACK(1)
3* @param 无
4* @retval 返回收到的应答位,0表示ACK,1表示NACK
5*/
6uint8_t sI2C_ReadAck(void) {
7// 主机发送完1个字节数据, 从机才需要应答, 此时主机应该放弃SDA总线的控制权
8sI2C_SDA_Write(1);
9// 当前SCL总线处于低电平休息时间
10sI2C_SCL_Write(1); // 拉高SCL总线,进入工作时间,主机在SCL高电平期间,读取从机发送的应答位
11uint8_t ReceiveAckType = sI2C_SDA_Read(); // 主机接收从机应答并存储起来, 用作返回值
12sI2C_SCL_Write(0); // 拉低SCL,开始下一个休息时间
13return ReceiveAckType; // 返回主机接收到的应答信号
14}
需要注意其中的实现细节,更好的理解I2C通信的时序。
Gn!
整体参考代码如下所示:
xxxxxxxxxx
187123
4/*------------- 硬件引脚配置宏 ------------------*/
5678
9/*----------------- 主机读写两条总线的操作宏函数 -----------------*/
10/**
11* @brief 主机通过SCL引脚控制时钟信号线
12* @param BitValue: 0-拉低SCL总线电平, 1-释放(拉高)SCL总线电平
13* @note 只有主机才拥有SCL总线的控制权
14*/
15static void sI2C_SCL_Write(uint8_t BitValue) {
16GPIO_WriteBit(I2C_GPIO_PORT, I2C_SCL_PIN, (BitAction)BitValue);
17Delay_Us(5); // 延迟一段时间, 保持电平稳定
18}
19
20/**
21* @brief 主机通过SDA引脚控制数据信号线, 以实现向从机发送数据
22* @param BitValue: 0-拉低SDA总线电平, 1-释放(拉高)SDA总线电平
23* @note 主机发送时,主机拥有对SDA总线的控制, 从机需要读SDA总线的状态以接收数据
24*/
25static void sI2C_SDA_Write(uint8_t BitValue) {
26GPIO_WriteBit(I2C_GPIO_PORT, I2C_SDA_PIN, (BitAction)BitValue);
27Delay_Us(5); // 延迟一段时间, 保持电平稳定
28}
29
30/**
31* @brief 主机读取SDA总线电平,用于实现读从机ACK或者读从机发送的1位数据并且返回这1位数据
32* @retval Bit_RESET-低电平, Bit_SET-高电平
33*/
34static uint8_t sI2C_SDA_Read(void) {
35uint8_t Val = GPIO_ReadInputDataBit(I2C_GPIO_PORT, I2C_SDA_PIN);
36Delay_Us(5); // 保持采样稳定
37return Val;
38}
39
40/**
41* @brief I2C总线初始化
42* @param 无
43* @retval 无
44* @note 配置PB10和PB11为开漏输出模式
45* 初始化后总线处于空闲状态(SCL和SDA均为高电平)
46*/
47void sI2C_Init(void) {
48RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // 开启GPIOB时钟
49GPIO_InitTypeDef GPIO_InitStruct;
50GPIO_InitStruct.GPIO_Pin = I2C_SCL_PIN | I2C_SDA_PIN;
51GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_OD; // 通用开漏输出模式
52GPIO_InitStruct.GPIO_Speed = GPIO_Speed_2MHz; // 2MHz的引脚输出速度足够了
53GPIO_Init(I2C_GPIO_PORT, &GPIO_InitStruct);
54
55sI2C_SCL_Write(1); // 初始化SCL总线为高电平空闲状态
56sI2C_SDA_Write(1); // 初始化SCL总线为高电平空闲状态
57}
58
59/**
60* @brief 主机发送I2C起始位,通信开始
61* @param 无
62* @retval 无
63* @note 时序要求:SCL高电平期间SDA产生下降沿
64*/
65void sI2C_Start(void) {
66// 两条总线全部拉高,表示两条总线都处于空闲状态
67sI2C_SDA_Write(1);
68sI2C_SCL_Write(1);
69
70// 此时SCL总线处于高电平状态, 将SDA总线拉低产生一个下降沿表示通信开始
71sI2C_SDA_Write(0);
72// 拉低SCL总线, 表示进入休息时间, 主机准备下一位待发送数据
73sI2C_SCL_Write(0);
74}
75
76/**
77* @brief 主机发送I2C终止位,通信结束
78* @param 无
79* @retval 无
80* @note 时序要求:SCL高电平期间SDA产生上升沿
81*/
82void sI2C_Stop(void) {
83sI2C_SDA_Write(0); // 先拉低SDA总线, 使得SDA总线上能够产生一个上升沿
84sI2C_SCL_Write(1); // 再拉高SCL总线,进入工作时间
85
86sI2C_SDA_Write(1); // SCL总线高电平状态时拉高SDA总线,产生上升沿,表示通信结束
87}
88
89/**
90* @brief 主机发送1个字节的数据到从机
91* @param Data: 待发送的1个字节的数据
92* @retval 无
93* @note 每个时钟周期发送1位数据
94* SCL总线低电平时, 改变SDA总线的高低电平, 准备发送数据
95* SCL总线高电平时, SDA总线保持稳定的高低电平,主机发送数据, 从机采集数据
96*/
97void sI2C_WriteByte(uint8_t Data) {
98// 循环发送8位1个字节的数据, 从最高位发到最低位
99for (uint8_t i = 0; i < 8; i++) {
100/*
101当前SCL总线处于低电平状态, I2C通信处于休息时间
102此时拉低或者拉高SDA总线, 以准备待发送的1位数据
103*/
104if (Data & 0x80) { // 0x80就是8位二进制数1000 0000
105// 相与的结果是非0的, 说明Data的最高位是1
106sI2C_SDA_Write(1); // 拉高SDA总线, 准备发送数据位1
107} else {
108// 相与的结果是0, 说明Data的最高位是0
109sI2C_SDA_Write(0); // 拉低SDA总线, 准备发送数据位0
110}
111Data <<= 1; // 将Data左移一位, 原本的最高位丢弃, 最低位补0, 这样原本的倒数第二高位就成为了新的最高位
112
113// 待发送数据准备好了,拉高SCL总线,进入工作时间
114sI2C_SCL_Write(1); // 该函数在拉高SCL总线后延时5us,此时SDA总线状态保持不变,足够从机采集数据
115sI2C_SCL_Write(0); // 拉低SCL总线,进入休息时间
116}
117}
118
119/**
120* @brief 主机读取从机发送的1个字节的数据
121* @param 无
122* @retval 从机发送, 主机读取到的1个字节的数据(uint8_t)
123* @note 在I2C通信中,主机发送数据是从高位发送到低位的,那么主机接收数据也是从高位接收到低位
124* 一开始主机具有SDA总线的控制权,所以主机要主动让自身SDA引脚输出高阻态,放弃SDA总线控制权
125* 随后在下一个工作时间里读SDA总线的高低电平,即可实现主机接收数据
126*/
127uint8_t sI2C_ReadByte(void) {
128// 初始化作为返回值的1字节变量,初始值设为0,如果某位读到低电平0则无需赋值
129uint8_t ReceiveData = 0;
130// 主机让自身SDA引脚进入高阻态,放弃SDA总线控制器,从机控制SDA总线开始发送数据
131sI2C_SDA_Write(1);
132
133// 用于构建接收数据的掩码,与这些掩码做|=运算,表示接收数据从最高位到最低位是1
134const uint8_t Masks[8] = {0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01};
135
136// 主机接收从机发来的数据,逐位读取,循环8次,第一次读到的是最终数据的最高位
137for (uint8_t i = 0; i < 8; i++) {
138/*
139现在SCL总线处于低电平休息时间,这个时间是留给从机准备待发送数据的
140紧接着就拉高SCL总线,进入工作时间
141主机在工作时间里读SDA总线的电平状态,从而接收数据
142*/
143sI2C_SCL_Write(1);
144if (sI2C_SDA_Read() == Bit_SET) {
145// 主机读到SDA总线为高电平状态,说明读到了最高位数据是1
146// 将第8-i位的值设置为1
147ReceiveData |= Masks[i];
148}
149// 主机每读完1位,就拉低SCL总线,进入休息时间,继续循环读下一位
150sI2C_SCL_Write(0);
151}
152// 循环结束,主机就读完了一整个字节的从机数据, 当然此时SCL总线是低电平的, 处于休息时间
153return ReceiveData;
154}
155
156/**
157* @brief 主机发送应答信号给从机
158* @param AckType, 即应答类型: 0-ACK,1-NACK
159* @retval 无
160* @note 既然是主机发送应答信号,那么主机就拥有SDA总线控制权
161* ACK: 在SCL高电平期间, 保持SDA低电平, 表示主机应答
162* NACK: 在SCL高电平期间, 保持SDA高电平, 表示主机无应答
163*/
164void sI2C_WriteAck(uint8_t AckType) {
165// 只有主机读完从机1个字节数据后,才需要发送应答信号给从机
166// 所以此时SCL处于低电平状态,是休息时间,准备SDA数据
167sI2C_SDA_Write(AckType); // 主机把应答位数据准备好
168sI2C_SCL_Write(1); // 拉高SCL,进入工作时间,从机在SCL高电平期间,读取应答位
169sI2C_SCL_Write(0); // 拉低SCL,开始下一个休息时间
170}
171
172
173/**
174* @brief 主机读取从机发来的应答信号, 可以是ACK(0), 也可以是NACK(1)
175* @param 无
176* @retval 返回收到的应答位,0表示ACK,1表示NACK
177*/
178uint8_t sI2C_ReadAck(void) {
179// 主机发送完1个字节数据, 从机才需要应答, 此时主机应该放弃SDA总线的控制权
180sI2C_SDA_Write(1);
181// 当前SCL总线处于低电平休息时间
182sI2C_SCL_Write(1); // 拉高SCL总线,进入工作时间,主机在SCL高电平期间,读取从机发送的应答位
183uint8_t ReceiveAckType = sI2C_SDA_Read(); // 主机接收从机应答并存储起来, 用作返回值
184sI2C_SCL_Write(0); // 拉低SCL,开始下一个休息时间
185return ReceiveAckType; // 返回主机接收到的应答信号
186}
187
大家不妨参考一下上述实现,自行手动实现一下。
Gn!
首先需要我们实现的函数有以下内容:
sOLED.h头文件的参考代码如下:
xxxxxxxxxx
12123
45
6// 基本功能
7void OLED_On(void); // 全屏点亮像素
8void OLED_Off(void); // 彻底关闭OLED
9void OLED_ReadStatus(uint8_t *OLED_StatusPtr); // 读取显示状态(0关闭/1开启)
10
11// !__OLED_H
12
sOLED.c源文件的参考代码如下:
xxxxxxxxxx
1181234
5/*----------------- OLED屏幕的硬件寻址字节 -----------------*/
6// 写模式的OLED硬件的寻址字节
7// 读模式的OLED硬件的寻址字节
8
9/**
10* @brief 单片机向OLED发送多个字节数据, 包含控制字节和后续的控制指令
11* @param Cmds: 待发送指令的字节数组
12* @param Length: 待发送指令字节数组的长度, 也就是需要发送字节的数量
13* @retval 无
14*/
15static void OLED_WriteCmds(const uint8_t *Cmds, uint8_t Length) {
16// 1.发送起始位
17sI2C_Start();
18
19// 2.主机发送寻址字节, 和从机建立通信
20sI2C_WriteByte(OLED_ADDRESS_WRITE); // 发送 OLED 地址(写模式)
21
22// 3.主机读取从机发送的ACK
23if (sI2C_ReadAck() != 0) {
24// 从机无应答, 直接发送终止位停止通信
25sI2C_Stop();
26return;
27}
28
29// 4.主机向从机发送控制指令0x00,表示OLED进入指令控制模式
30sI2C_WriteByte(0x00);
31
32// 5.主机再次读取从机发送的ACK
33if (sI2C_ReadAck() != 0) {
34// 从机无应答, 直接发送终止位停止通信
35sI2C_Stop();
36return;
37}
38
39// 6.逐个字节的发送指令给从机OLED
40for (uint8_t i = 0; i < Length; i++) {
41sI2C_WriteByte(Cmds[i]);
42
43// 主机每发送1个字节, 从机就要应答1次
44// 主机读取从机ACK
45if (sI2C_ReadAck() != 0) {
46// 从机无应答, 直接发送终止位停止通信
47sI2C_Stop();
48return;
49}
50}
51
52// 7.所有指令都已发送,停止通信
53sI2C_Stop();
54}
55
56/**
57* @brief 开启OLED屏幕,并且点亮所有的像素
58* @param 无
59* @retval 无
60*/
61void OLED_On(void) {
62// 开启屏幕的指令
63uint8_t OLED_ONCmds[] = {
640x8D, 0x14, // 开启电荷泵
650xAF, // 打开屏幕
660xA5 // 让屏幕全亮,点亮所有像素点
67};
68// 将开启屏幕的指令发送给从机OLED
69OLED_WriteCmds(OLED_ONCmds, sizeof(OLED_ONCmds));
70}
71
72/**
73* @brief 彻底关闭OLED屏幕
74* @param 无
75* @retval 无
76*/
77void OLED_Off(void) {
78// 关闭屏幕的指令
79uint8_t OLED_OFFCmds[] = {
800xA4, // 普通显示模式
810xAE, // 关闭 OLED 显示
820x8D, 0x10 // 关闭电荷泵
83};
84// 将关闭屏幕的指令发送给从机OLED
85OLED_WriteCmds(OLED_OFFCmds, sizeof(OLED_OFFCmds));
86}
87
88/**
89* @brief 获取当前屏幕点亮的状态
90* @param OLED_StatusPtr是一个外部变量的指针,传入函数的目的是获取当前OLED屏幕的点亮状态, 起着返回值的作用
91* @retval 无
92* @note 状态字节的bit6如果是1表示屏幕没有点亮, 如果是0表示屏幕已点亮
93*/
94void OLED_ReadStatus(uint8_t *OLED_StatusPtr) {
95// 1.发送起始位
96sI2C_Start();
97// 2.主机向从机发送寻址字节(读模式)
98sI2C_WriteByte(OLED_ADDRESS_READ);
99
100// 3.主机读从机发送的ACK
101if (sI2C_ReadAck() != 0) {
102// 从机无应答, 直接发送终止位停止通信
103sI2C_Stop();
104return;
105}
106
107// 4.主机读从机发送来的1个字节的数据(状态字节)
108uint8_t ReceiveData = sI2C_ReadByte(); // 主机获取状态字节
109*OLED_StatusPtr = ((ReceiveData >> 6) & 1); // 获取ReceiveData的bit6的取值(0/1)
110
111// 5.主机成功读到从机发送的状态字节,主机不再需要其它数据了
112// 主机发送NACK
113sI2C_WriteAck(1);
114
115// 6.通信结束
116sI2C_Stop();
117}
118
需要注意的细节有:
主机向OLED从机发数据后,从机会向主机发送应答信号。无应答可以选择像上面代码里一样处理,也完全可以不做任何处理。但一定要接收应答!
在实现
OLED_ReadStatus
函数时,通过一个传入传出的指针类型参数实现了返回值的效果。
Gn!
中断服务程序的写法也非常简单,首先确定此函数的声明如下:
xxxxxxxxxx
11void EXTI9_5_IRQHandler(void);
其次中断服务程序就只做以下几件事情:
先确定由哪条EXTI线,也就是哪个按键触发了外部中断。
按键必须按下--弹起后才执行开启/关闭OLED和以及通过I2C读来控制LED的操作。
中断服务程序的参考代码如下所示:
xxxxxxxxxx
491/**
2* @brief 获取当前OLED屏幕的状态,然后控制LED和OLED状态保持一致
3* @param 无
4* @retval 无
5*/
6void LED_ControlByOLEDStatus(void) {
7uint8_t OLED_Status;
8OLED_ReadStatus(&OLED_Status); // 获取当前OLED屏幕的开关状态
9if (OLED_Status == 1) {
10// OLED处于关闭状态,LED同步熄灭
11LED_Off();
12} else {
13// OLED处于开启状态,LED同步点亮
14LED_On();
15}
16}
17
18/**
19* @brief EXTI线6和线8触发中断时的中断处理函数
20* @param 无
21* @retval 无
22*/
23void EXTI9_5_IRQHandler(void) {
24// 检查触发此中断的是不是EXTI线6,也就是PB6按键按下了
25if (EXTI_GetITStatus(EXTI_Line6) == SET) {
26// 清零此中断挂起标志位
27EXTI_ClearITPendingBit(EXTI_Line6);
28
29// 等待按键弹起
30while (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_6) == Bit_SET);
31
32OLED_On(); // 点亮屏幕
33// 获取当前OLED屏幕的状态,然后控制LED和OLED状态保持一致
34LED_ControlByOLEDStatus();
35}
36
37// 检查触发此中断的是不是EXTI线8,也就是PB8按键按下了
38if (EXTI_GetITStatus(EXTI_Line8) == SET) {
39// 清零此中断挂起标志位
40EXTI_ClearITPendingBit(EXTI_Line8);
41
42// 等待按键弹起
43while (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_8) == Bit_SET);
44
45OLED_Off(); // 关闭屏幕
46// 获取当前OLED屏幕的状态,然后控制LED和OLED状态保持一致
47LED_ControlByOLEDStatus();
48}
49}
相信学到这里,这个中断服务程序的编写,对大家而言还是相当容易和简单的。
Gn!
主程序的编写也非常简单,只需要完成各种初始化即可,剩余逻辑交给按键的外部中断触发。
参考代码如下:
xxxxxxxxxx
151int main(void) {
2NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); // 设置中断优先级分组为分组2
3
4KEY_Init(); // 初始化按键中断
5LED_Init(); // 配置LED控制引脚
6sI2C_Init(); // 配置I2C引脚,初始化软I2C
7
8// OLED和LED初始状态熄灭
9OLED_Off();
10LED_Off();
11
12while (1) {
13// 程序的具体执行全部依靠外部中断来完成
14}
15}
编译和烧录程序,分别按下释放两个按键,若能够看到屏幕被点亮、熄灭,以及LED随之点亮熄灭,则说明程序执行成功。
Gn!
所谓硬件I2C,指的就是利用单片机内部自带的I2C硬件来完成I2C通信的过程,关于I2C通信的过程,我们已经学过了。
所以基本上所有原理性的东西我们都已经了解了,使用硬件I2C无非就是调用外设标准库函数,基于单片机的I2C外设来实现I2C通信的过程。
由于我们也已经使用过软件I2C了,所以对我们而言,硬件I2C的使用难度并不会很大,最多就是调用标准外设库函数的过程会比较繁琐。
我们仍然以一个实验案例来讲解学习硬件I2C,并且在这里我们将I2C和串口两种通信结合起来。
于是实验的电路接线图,就如下图所示:
学习I2C,顺带再复习一下串口通信。当然单片机USART外设接收数据的方式也要使用中断,而不是一般的轮询方式。
Gn!
使用引脚I2C通信,就需要使用单片机的I2C硬件外设。
其系统框图如下图所示:
这个框图将在后续的内容讲解中起到重要作用。
Gn!
实验目的:
在PC端,使用串口调试工具向STM32单片机发送指令:
发送指令'0',表示关闭OLED屏幕,同时单片机向PC端回复消息"OLED-TurnOff: Success"
发送指令'1',表示开启OLED屏幕并点亮所有像素,同时单片机向PC端回复消息"OLED-TurnOn: Success"
发送指令'2',表示询问OLED屏幕当前点亮状态,并回复PC端。比如:"OLED-Status: OFF"或者"OLED-Status: ON"
所以本质上和软件I2C实现的功能是差不多的,只不过把串口通信模块加进来了。
当然这里我们继续使用模块化编程的思想,我们先来实现简单的模块。
Gn!
串口通信我们已经学过了,像上述实验目的中的串口通信功能对我们而言,已经可以说是手拿把掐了。
在工程的"Tools"目录下新建两个文件:"USART1.c"和"USART1.h",然后把它们加入Keil5软件的Tools Group组当中。
串口通信模块,我们只需要实现下列两个功能就可以了:
配置USART1外设。包括: 初始化引脚,初始化USART1外设,开启RXNE标志位中断以及初始化NVIC。
单片机向PC端发送字符串。
注:单片机接收PC端发送的控制指令,没有必要单独作为一个函数去定义,可以直接在中断服务程序当中接收数据。
"USART.h"文件的参考代码如下:
xxxxxxxxxx
13123
45
6// 配置USART1外设,包括: 初始化引脚,初始化USART1外设,开启RXNE标志位中断等
7void USART1_Config(void);
8
9// 单片机向PC端发送字符串消息
10void USART1_SendString(const char *Message);
11
12// !__USART_H
13
"USART.c"文件的参考代码如下:
xxxxxxxxxx
6312
3/**
4* @brief 配置USART1外设,包括:
5* 包括: 初始化引脚,初始化USART1外设,开启RXNE标志位中断以及初始化NVIC外设。
6* @param 无
7* @retval 无
8*/
9void USART1_Config(void) {
10// 1.开启 GPIOA和USART1 外设时钟
11RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE);
12
13// 2.配置 PA9 引脚的工作模式是复用推挽输出模式
14GPIO_InitTypeDef GPIO_InitStructure;
15GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
16GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽输出
17GPIO_InitStructure.GPIO_Speed = GPIO_Speed_2MHz;
18GPIO_Init(GPIOA, &GPIO_InitStructure);
19
20// 3.配置 PA10 引脚的工作模式是浮空输入模式
21GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
22GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入模式
23GPIO_Init(GPIOA, &GPIO_InitStructure);
24
25// 4.初始化USART1外设, 设置USART1外设的各项参数
26USART_InitTypeDef USART_InitStruct;
27USART_InitStruct.USART_BaudRate = 115200; // 波特率设置为115200
28USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None; // 不使用硬件流控
29USART_InitStruct.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; // 可发可接模式
30USART_InitStruct.USART_Parity = USART_Parity_No; // 不使用校验位
31USART_InitStruct.USART_StopBits = USART_StopBits_1; // 停止位1位
32USART_InitStruct.USART_WordLength = USART_WordLength_8b; // 数据位1个字节
33USART_Init(USART1, &USART_InitStruct);
34
35// 5.开启USART1外设
36USART_Cmd(USART1, ENABLE);
37
38// 6.开启USART1外设RXNE标志位中断
39USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
40
41// 7.初始化NVIC外设
42NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); // 设置优先级分组
43NVIC_InitTypeDef NVIC_InitStruct;
44NVIC_InitStruct.NVIC_IRQChannel = USART1_IRQn; // 中断号即USART1外设全局中断
45NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 0; // 抢占优先级 0
46NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0; // 子优先级 0
47NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE; // 开启中断
48NVIC_Init(&NVIC_InitStruct);
49}
50
51/**
52* @brief 通过USART发送字符串
53* @param str 待发送的字符串
54* @retval None
55*/
56void USART1_SendString(const char *Message) {
57while (*Message) {
58// 等待发送数据寄存器为空,才可以继续发送下一个字符
59while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
60USART_SendData(USART1, *Message++);
61}
62}
63
上面这部分实现都非常简单,不再赘述。
中断处理相关的中断服务程序,放到下面实现了OLED屏幕的操作后再去实现,直接放在main.c文件中。
Gn!
在工程的"Tools"目录下新建两个文件:"hOLED.c"和"hOLED.h",然后把它们加入Keil5软件的Tools group组当中。
首先硬件IC2在实现时,直接使用了STM32的I2C外设,采用的编程手段是标准外设库函数实现。
所以我们没有必要再去弄一个"hI2C"模块,可以直接在hOLED模块中调用标准库函数,实现我们需要的功能。
hOLED模块需要的功能一共有四个,"hOLED.h"的参考代码如下:
xxxxxxxxxx
21123
45
6// OLED模块基本功能
7
8// 初始化OLED引脚以及I2C外设
9void hOLED_Init(void);
10
11// 开启屏幕,点亮全部像素并返回执行结果。0表示成功,其他值表示失败
12int8_t hOLED_On(void);
13
14// 彻底关闭OLED并返回执行结果。0表示成功,其他值表示失败
15int8_t hOLED_Off(void);
16
17// 读取OLED开关状态利用指针传参获取这个状态,函数返回值返回查询的结果。0表示查询成功,其他值表示失败
18int8_t hOLED_ReadStatus(uint8_t *OLED_StatusPtr);
19
20// !__HOLED_H
21
注意下方三个涉及到和OLED屏幕通信的函数,都是具有返回值的。
这是因为我们通过标准库来实现I2C通信时,可以通过I2C外设内置的各种标志位进行错误校验和处理,所以这几个函数我们给定了
int8_t
类型的返回值。在"hOLED.c"中,我们还可以提取了几个宏定义用于提升代码的可读性以及扩展性:
xxxxxxxxxx
31// OLED硬件的寻址字节
2// 写模式的OLED硬件的寻址字节
3// 读模式的OLED硬件的寻址字节
下面看一下具体的每一个函数的实现。
Gn!
在上述实验中,STM32单片机中我们使用的I2C引脚是:
SCL --- PB10
SDA --- PB11
在一般情况下,PB10和PB11两个引脚都只是一般的、普通IO引脚。那么这两个引脚可以作为实现硬件I2C通信的引脚吗?
我们可以通过查表来获取这两个引脚的复用和重定义功能,以确定如何初始化这两个引脚。
具体的引脚定义表可以参考之前的文档:引脚定义表
通过查表,我们可以得出下列信息,STM32F103C8T6共有下列三对可用为I2C通信的引脚:
假如使用引脚的复用功能的话:
第一对:
PB6的复用功能是: I2C1_SCL,即I2C1外设的SCL引脚。
PB7的复用功能是: I2C1_SDA,即I2C1外设的SDA引脚。
第二对:
PB10的复用功能是: I2C2_SCL,即I2C2外设的SCL引脚。
PB11的复用功能是: I2C2_SDA,即I2C2外设的SDA引脚。
根据我们选择的接线方式,我们使用的I2C硬件外设是I2C2,PB10作为SCL引脚,PB11作为SDA引脚。
当然,这两个引脚都需要设置为复用开漏输出模式。
第三对:
PB8的重定义功能是: I2C1_SCL,即I2C1外设的SCL引脚。
PB9的重定义功能是: I2C1_SDA,即I2C1外设的SDA引脚。
总之,有三对实现硬件I2C的引脚可以选择,但我们选择其中的第二对。
hOLED_Init函数的参考实现代码如下:
xxxxxxxxxx
341/**
2* @brief 初始化OLED接入单片机的SCL引脚(PB10)和SDA引脚(PB11)
3* 初始化I2C2外设
4* @param 无
5* @retval 无
6*/
7void hOLED_Init(void) {
8// 1.初始化PB10 和 PB11 为复用开漏输出
9RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // 开启GPIOB 时钟
10GPIO_InitTypeDef GPIO_InitStruct;
11GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
12GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_OD; // 复用开漏输出模式
13GPIO_InitStruct.GPIO_Speed = GPIO_Speed_2MHz; // 2MHz引脚输出速度足够
14GPIO_Init(GPIOB, &GPIO_InitStruct);
15
16// 2.复位I2C2外设
17// 初始化I2C2外设之前,先复位此外设,以清除上一次I2C通信导致的错误
18RCC_APB1PeriphResetCmd(RCC_APB1Periph_I2C2, ENABLE); // 施加复位信号,类似按下单片机复位按键
19RCC_APB1PeriphResetCmd(RCC_APB1Periph_I2C2, DISABLE); // 释放复位信号,类似释放单片机复位按键
20
21// 3.初始化I2C2外设模块
22// 开启I2C2外设时钟,注意此外设挂载在APB1外设总线上
23RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);
24// 初始化 I2C2 模块
25I2C_InitTypeDef I2C_InitStruct;
26I2C_InitStruct.I2C_Ack = I2C_Ack_Enable; // 开启主机ACK,主机应答从机发送数据
27I2C_InitStruct.I2C_ClockSpeed = 400000; // 设置 I2C 时钟线频率为 400kHz,也就是快速模式
28I2C_InitStruct.I2C_Mode = I2C_Mode_I2C; // 设置为 标准I2C 模式
29I2C_InitStruct.I2C_DutyCycle = I2C_DutyCycle_2; // 设置占空比为2, 即低电平:高电平 = 2
30I2C_Init(I2C2, &I2C_InitStruct);
31
32// 4.开启I2C2外设
33I2C_Cmd(I2C2, ENABLE);
34}
这里有三处我们之前没有学过的内容,这里简单说一下。
Gn!
I2C外设的复位:
代码中出现的下面两行代码:
xxxxxxxxxx
21RCC_APB1PeriphResetCmd(OLED_ClockI2Cx, ENABLE); // 施加复位信号
2RCC_APB1PeriphResetCmd(OLED_ClockI2Cx, DISABLE); // 释放复位信号
它们的作用就是复位I2C外设,类似最小系统板上的复用按钮,它的主要作用是:
清除 I2C2 可能存在的错误状态(如 BUSY、ARBLOST 等)。
确保 I2C2 处于一个已知的初始状态,避免软件初始化后仍然异常。
重新初始化 I2C2 的内部寄存器,避免上一次的残留配置影响新初始化。
如果I2C外设之前被启动过,而且存在一些错误没有被清除,直接调用I2C_Init函数初始化它有可能无法重新工作。
所以利用硬件复位操作,让I2C外设彻底清空并重新开始工作是初始化I2C外设的常见做法.
但是一定要注意,该操作会清空重置I2C外设的一切配置,所以应该在I2C_Init函数初始化之前调用。
由于I2C外设的特殊性,所以在初始化I2C外设之前初始化它,是一个推荐的做法!
Gn!
I2C_Init函数和I2C_Cmd函数:
这一对函数同时都要调用,用于初始化I2C外设。类似的操作,我们之前也见过,和初始化USART外设是一样的。
通俗点说这两个函数的作用是:
I2C_Init函数用于配置I2C外设。
I2C_Cmd函数相当于一个控制此外设的总开关,用于开启和关闭此I2C外设。
下面来简单介绍一下这两个函数:
I2C_Cmd函数:
其函数声明如下:
xxxxxxxxxx
11void I2C_Cmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
其传参的选择十分简单,如下所示:
参数 类型 说明 I2Cx
I2C_TypeDef*
指定要控制的 I2C 外设,取值范围: I2C1
或I2C2
(STM32F103)NewState
FunctionalState
开启或禁用 I2C 外设,取值: ENABLE
(开启) 或DISABLE
(禁用)。I2C_Init函数:
I2C_Init
是 STM32 标准外设库提供的一个函数,用于初始化和配置 I2C 外设,包括时钟速度、地址模式、应答模式等。其函数声明如下:
xxxxxxxxxx
11void I2C_Init(I2C_TypeDef* I2Cx, I2C_InitTypeDef* I2C_InitStruct);
其第一个参数很简单,传参想要初始化的I2C外设,对于STM32F103系列来说就只有两个I2C外设,即
I2C1
或I2C2
其核心参数是第二个参数,和以往学习的所有Init函数一样,需要传入一个
I2C_InitTypeDef
类型的结构图对象指针。这个结构体的类型定义如下:
xxxxxxxxxx
81typedef struct{
2uint32_t I2C_ClockSpeed; // 设置 I2C 时钟频率(单位:Hz)
3uint16_t I2C_Mode; // 设置 I2C 工作模式
4uint16_t I2C_DutyCycle; // 配置 I2C 时钟的占空比
5uint16_t I2C_OwnAddress1; // 配置设备自身的 I2C 地址
6uint16_t I2C_Ack; // 启用或禁用 I2C 硬件的应答(ACK)
7uint16_t I2C_AcknowledgedAddress; // 配置 I2C 的应答地址模式
8} I2C_InitTypeDef;
逐一来解释一下这些成员:
I2C_ClockSpeed成员:
该成员需要传参一个32位无符号整数,用于设置 I2C 的时钟频率。
I2C的时钟频率是衡量I2C通信数据传输速率的核心要素,在上面软I2C的实现中我们已经讲了这个概念。
I2C协议当中的规定了典型的三种时钟线频率,如下表所示:
模式 最大 SCL 频率 SCL 一个时钟周期 理论最大传输速率(单位:千字节每秒) 标准模式 (SM) 100 kHz (100,000 Hz) 10 μs 11.1KB/s 快速模式 (FM) 400 kHz (400,000 Hz) 2.5 μs 44.4KB/s 高速模式 (HS) 1 MHz (1,000,000 Hz) 1 μs 111.1KB/s 但我们使用的STM32F10x系列芯片最大只支持快速模式,也就是只能选择标准模式和快速模式,高速模式乃至于更高速度的模式一般只有更高性能的芯片才支持(比如F4系列)。
在实际传参时,我们可以直接手动传参
400000
这个整数,来表示选择使用I2C通信的快速模式,即SCL线时钟频率是40kHz。I2C_Mode成员:
用于设置I2C外设的工作模式。
标准库中给定了三个可以选择的取值:
xxxxxxxxxx
3123
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时,提供了两个占空比选项:
I2C_DutyCycle_16_9
表示"低电平:高电平"的比值为 16:9,是一个高电平占比更高的占空比。
I2C_DutyCycle_2
表示"低电平:高电平"的比值为 2:1,是一个低电平占比更高的占空比。在实际使用时,我们可以优先选择
I2C_DutyCycle_2
占空比,一个标准的选择,增强抗干扰能力。I2C_OwnAddress1成员:
I2C通信是主从模式的,一般情况下单片机都被视为主机,但I2C也支持将单片机作为从机使用。
若选择单片机作为从机的模式,为了让外部主机寻址到该从机单片机,就需要提供此单片机的地址。
这个参数就用于表示这个地址。
但我们不会选择将单片机作为从机使用,所以该成员可以忽略,不进行配置。
I2C_Ack成员:
I2C_Ack
用于配置单片机作为接收端时(也就是单片机读从机数据时),是否向发送端回复ACK。它的传参选项有两个:
I2C_Ack_Enable
:启用应答,单片机收到数据后会发送 ACK,表示数据成功接收。
I2C_Ack_Disable
:禁用应答,单片机收到数据后会直接发送 NACK。禁用应答是很少见的情况,所以我们直接给该成员配置为
I2C_Ack_Enable
即可。I2C_AcknowledgedAddress成员:
配置单片机在作为从机时,其从机设备地址的长度是7位还是10位。
但我们不会选择将单片机作为从机使用,所以该成员可以忽略,不进行配置。
Gn!
在单片机与OLED屏幕进行I2C通信的过程中,单片机需要向OLED从机发送多个控制指令,从而实现控制OLED的功能。
这里需要实现的核心函数就是:
xxxxxxxxxx
11static int8_t hOLED_I2CWriteCmds(const uint8_t *Cmds, uint8_t Length)
该函数我们在软I2C中也实现过类似的,现在我们改用STM32标准外设库来实现这个函数。
Gn!
使用硬件I2C实现通信的过程中,不得不提的一个概念就是——I2C外设标志位。
STM32的I2C外设中,存在两个用于存储表示当前I2C通信状态、硬件状态的寄存器:
SR1,即Status Register1
SR2,即Status Register2
通过这些标志位,用户可以了解 I2C 通信的进展,检查错误状态,或者确保数据传输的每个步骤正确执行。
以下是 STM32 的 I2C 外设常用标志位的表格描述:
标志位 英文词组 标志位含义 标志位置为 1 时的条件 用途 I2C_FLAG_BUSY
BUSY flag
总线忙碌标志 当 I2C 总线正在传输数据时,标志位置为 1 用于判断总线是否空闲,确保在总线空闲时开始通信 I2C_FLAG_SB
START Bit flag
起始位发送完成标志 起始位(START)发送完成时,标志位置为 1 用于判断起始信号是否成功发送,确保后续步骤可进行 I2C_FLAG_ADDR
Address flag
从机地址匹配成功标志 主机地址与从机地址匹配时,标志位置为 1 确保主从机地址匹配完成,可以进入数据传输阶段 I2C_FLAG_AF
Acknowledge Failure flag
主机收到从机NACK标志 从机发送 NACK时,标志位置为 1 用于检测 ACK 错误,通常表示接收失败或通信中断 I2C_FLAG_TXE
Transmit Empty flag
发送数据寄存器空标志 发送寄存器为空时,,标志位置为 1 用于判断是否可以向数据寄存器写入新的数据(不能用于发地址) I2C_FLAG_RXNE
Receive Not Empty flag
接收数据寄存器非空标志 接收寄存器中有数据时,标志位置为 1 用于判断接收寄存器是否有数据,确保可以读取数据 I2C_FLAG_BTF
Byte Transfer Finished flag
字节传输完成标志 当前字节传输完成,标志位置为 1 用于判断接收和移位寄存器都为空,确保数据传输已成功结束 I2C_FLAG_STOPF
STOP flag
停止位发送完成标志 停止位已发送完成时,标志位置为 1 用于判断通信是否完全结束,确认 I2C 总线空闲 那么如何来获取和清零这些标志位呢?
这就需要使用两个标准库函数了,即I2C_GetFlagStatus函数和I2C_ClearFlag函数。
I2C_GetFlagStatus函数: 用于获取某个I2C外设的某个标志位的取值。
其函数声明如下:
xxxxxxxxxx
11FlagStatus I2C_GetFlagStatus(I2C_TypeDef* I2Cx, uint32_t I2C_FLAG);
函数的两个形参:
I2Cx
: 指示要获取标志位的IC2外设名,可以传参I2C1
或者I2C2
I2C_FLAG
: 要检查的标志位。标志位可以是各种 I2C 状态寄存器中的标志,例如:I2C_FLAG_SB
、I2C_FLAG_ADDR
、I2C_FLAG_RXNE
等。函数的返回值:
函数返回
SET
也就是1,表示标志位已被设置,标志位的条件已经满足。函数返回
RESET
也就是0,表示标志位已被清零,标志位的条件未满足。I2C_ClearFlag函数:用于清零某个I2C外设的某个标志位。
其函数声明如下:
xxxxxxxxxx
11void I2C_ClearFlag(I2C_TypeDef* I2Cx, uint32_t I2C_FLAG);
这个函数没有返回值,形参的使用和上面的函数完全一致,不再赘述。
了解了I2C外设的标志位概念后,下面我们就按照软I2C的实现思路,来一一找到我们需要的库函数:
主机发送起始位信号
主机发送寻址字节
主机读取从机发送的ACK确认
主机写命令模式的控制字节,也就是主机发送
0x00
这1个字节的数据到从机主机读取从机发送的ACK确认
主机发送函数形参
cmd
这1个字节的数据到从机主机读取从机发送的ACK确认
主机发送停止位信号
Gn!
若想实现主机发送起始位信号,需要调用:I2C_GenerateSTART函数。这个函数非常简单,就表示主机发送起始位信号,其函数形参如下:
xxxxxxxxxx
11void I2C_GenerateSTART(I2C_TypeDef* I2Cx, FunctionalState NewState);
第一个参数指示生成起始信号的I2C外设,可以传参
I2C1
或者I2C2
第二个参数我们也很熟悉,它可以传参以下枚举类型:
xxxxxxxxxx
11typedef enum {DISABLE = 0, ENABLE = !DISABLE} FunctionalState;
传参
ENABLE
即表示主机发送了一个起始位信号。调用完这个函数后,不要着急继续发送寻址字节,标志位中的
I2C_FLAG_SB
用于指示发送起始信号完成。该函数的调用表示通信开始,建议采用以下方式来调用这个函数:
调用该函数之前,先检查总线是否忙碌,若总线处于忙碌状态,即总线正在传输数据,则无法开始新的通信数据传输。
所以需要等待总线空闲:
xxxxxxxxxx
11while (I2C_GetFlagStatus(OLED_I2Cx, I2C_FLAG_BUSY) == SET); // 等待 I2C 总线空闲
除此之外,在调用此函数发送起始信号后,还需要调用:
xxxxxxxxxx
11while (I2C_GetFlagStatus(OLED_I2Cx, I2C_FLAG_SB) == RESET);
用于等待主机发送起始信号结束。
上面两个细节加在一起,所以此函数调用的推荐方式是:
xxxxxxxxxx
81// 等待 I2C 总线空闲
2while (I2C_GetFlagStatus(OLED_I2Cx, I2C_FLAG_BUSY) == SET);
3
4// 主机发送起始位信号
5I2C_GenerateSTART(OLED_I2Cx, ENABLE);
6
7// 等待确认起始位发送完成
8while (I2C_GetFlagStatus(OLED_I2Cx, I2C_FLAG_SB) == RESET);
不要忘记使用I2C外设寄存器的标志位!依靠标志位指示通信状态,是硬件I2C和软I2C非常显著的区别。
Gn!
起始位和停止位发送函数的名字非常类似,所以我们直接一起看。
只需要把I2C_GenerateSTART函数名,改成I2C_GenerateSTOP即可表示主机发送停止位。
其函数声明如下:
xxxxxxxxxx
11void I2C_GenerateSTOP(I2C_TypeDef* I2Cx, FunctionalState NewState);
调用方式和发送起始位的函数也是一样的,这里就不再赘述了。
Gn!
I2C通信中,实现主机向从机发送1个字节的数据,就需要使用函数:I2C_SendData函数。
该函数的声明如下:
xxxxxxxxxx
11void I2C_SendData(I2C_TypeDef* I2Cx, uint8_t Data);
这个函数的调用十分的简单,两个参数就表示主机的I2Cx外设,向从机发送了1个字节的数据Data。
Gn!
通过调用I2C_SendData函数,就可以实现发送寻址字节的功能,而寻址字节对于任何I2C通信来说都是待发送数据的第一个字节数据。
那么主机发送寻址字节后,应该如何处理呢?
怎么确定发送成功了呢?
仍然需要校验标志位来判断。
主机发送寻址字节后,有两种可能性:
若从机应答ACK(地址匹配成功):
I2C_FLAG_ADDR
会被置1,表示地址匹配成功。
I2C_FLAG_AF
保持为0,主机正常收到从机发送的ACK。若从机应答NACK(地址匹配失败):
I2C_FLAG_AF
会被置1,表示ACK失败,从机发送NACK非确认应答。
I2C_FLAG_ADDR
保持为0,表示地址匹配失败。所以在主机发送寻址字节后,一般需要执行下列处理:
xxxxxxxxxx
111while (1) {
2if (I2C_GetFlagStatus(OLED_I2Cx, I2C_FLAG_ADDR) == SET) {
3// 从机寻址成功,清除ADDR标志
4break;
5}
6if (I2C_GetFlagStatus(OLED_I2Cx, I2C_FLAG_AF) == SET) {
7// 清除AF标志位
8I2C_GenerateSTOP(OLED_I2Cx, ENABLE); // 主机发送停止位信号
9return -1; // 从机寻址失败,返回错误标志
10}
11}
在硬件I2C使用标准库完成I2C通信的过程中,某些关键I2C标志位被置1后必须及时手动清零,否则会导致通信异常甚至总线锁死。
必须手动清零的标志位有:
I2C_FLAG_AF
标志位置为1时:
表示从机无应答,主机接收到NACK。
AF
标志未清除时,I2C外设会认为当前传输失败,总线始终处于错误状态,无法继续后续操作。
I2C_FLAG_ADDR
标志位置为1时:
当主机发送从机地址并收到ACK应答后,
ADDR
标志会置为1,表示地址匹配成功。按照ST公司的I2C外设设计,当
ADDR
标志置为1后,总线会等待手动清除此标志位,否则I2C外设将始终等待无法进行后续的数据传输工作。除此之外,I2C外设作为硬件设备,标志位状态信息存储在状态寄存器中,若标志位不及时清零不仅影响当前的I2C通信还会影响下一次通信。
这也是我们在初始化I2C外设时,建议复位I2C外设的原因。
如何清除AF标志位呢?
很简单,只要调用下列函数即可:
xxxxxxxxxx
11I2C_ClearFlag(I2Cx, I2C_FLAG_AF); // 手动清除AF标志位
如何清除ADDR标志位呢?
ST公司关于清除ADDR标志位的设计比较繁琐,要求必须使用
I2C_ReadRegister
函数先读SR1寄存器,后读SR2寄存器才能够完全清除ADDR标志位。具体的函数调用代码就是:
xxxxxxxxxx
31// 清除 ADDR 标志
2I2C_ReadRegister(OLED_I2Cx, I2C_Register_SR1); // 读取 SR1 寄存器
3I2C_ReadRegister(OLED_I2Cx, I2C_Register_SR2); // 读取 SR2 寄存器
ADDR
标志位被ST公司设计为必须依赖读SR1和SR2两个硬件寄存器才能被清除(清零),无法通过手动调用函数软件清除!总之,STM32 的 I2C 外设依赖标志位推进通信过程,需严格遵循ST公司的规定进行操作!
最后,主机发送寻址字节后,我们一般采用这种处理手段:
xxxxxxxxxx
211// 主机发送寻址字节后的处理逻辑(重点)
2while (1) {
3if (I2C_GetFlagStatus(I2C2, I2C_FLAG_ADDR) == SET) {
4// 从机地址匹配成功,ADDR标志位自动置1,需要手动把ADDR标志位置为0
5// 手动清零ADDR标志位,若不清零后续发送过程无法开发
6// I2C_ClearFlag(I2C2, I2C_FLAG_ADDR); 这种软件清零的手段对ADDR标志位无效
7// ST公司规定 必须先读SR1寄存器, 再读SR2寄存器, 才能清零这个标志位
8I2C_ReadRegister(I2C2, I2C_Register_SR1); // 读SR1寄存器
9I2C_ReadRegister(I2C2, I2C_Register_SR2); // 读SR2寄存器
10break;
11}
12
13if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
14// AF标志位置为1,说明从机无应答,寻址失败
15// 需要手动清零AF标志位,并处理通信失败
16I2C_ClearFlag(I2C2, I2C_FLAG_AF);
17// 主机发送停止信号,结束通信
18I2C_GenerateSTOP(I2C2, ENABLE);
19return -1;
20}
21}
如此,我们就完成一个重要的部分:主机寻址从机以及后续处理。
Gn!
在主机发送数据到从机的过程中,除了第一个字节的寻址字节外,其余要发送的数据都是普通的字节数据。
发送普通字节后,应该进行什么处理呢?
肯定和发送寻址字节的处理不同。
主机发送普通字节后,有以下两种可能性:
若从机应答ACK,表示主机发送数据成功:
I2C_FLAG_TXE
和I2C_FLAG_BTF
这两个标志位都会被置为1其中TXE表示发送数据寄存器为空,BTF表示数据传输已完成
我们可以使用BTF这个标志位,是否置1,来判断1个字节的数据是否发送完成。
若从机无应答,也就是应答NACK,则表示从机拒收,主机发送数据失败:
I2C_FLAG_AF
会被置1,表示ACK失败,从机发送NACK非确认应答。注意,不要忘记处理完后,手动清除AF标志位。
主机发送普通字节数据后的处理逻辑是这样的:
等待BTF标志位为空,即等待主机发送全部数据,然后再判断AF标志位处理从机ACK。
参考的处理方式如下:
xxxxxxxxxx
121// 主机发送非寻址字节数据后的处理逻辑(重点)
2// 等待此1个字节的数据发送完成
3while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BTF) == RESET);
4//
5
6// 再检查AF标志位是否置1, 判断从机是否应答
7if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
8// 从机无应答, 拒收此数据
9I2C_GenerateSTOP(I2C2, ENABLE); // 停止通信
10I2C_ClearFlag(I2C2, I2C_FLAG_AF); // 手动清零AF标志位
11return -2;
12}
注意:
在上面的操作中,我们手动清零了AF标志位,但并没有手动清零TXE和BTF标志位。
这是因为:根据ST公司的I2C外设设计,这两个标志位是只读的、自动置1和清零的标志位,不需要程序员手动操作!
Gn!
综上所示参考的实现代码如下:
xxxxxxxxxx
841/**
2* @brief 主机向OLED从机发送多个控制指令
3* @param Cmds: 待发送控制指令的字节数组
4* @param Length: 待发送控制指令的数量, 也就是Cmds字节数组的长度
5* @retval 0 表示正常完成通信 -1 表示寻址失败 -2 表示从机拒收数据
6*/
7static int8_t hOLED_I2CWriteCmds(const uint8_t *Cmds, uint8_t Length) {
8// 1.在开始通信(发送起始位)之前,要判断总线是否处于忙碌状态
9// 若总线忙碌就等到总线不忙碌为止, 即等待总线进入空闲状态
10while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BUSY) == SET);
11
12// 当代码运行到这里标志位BUSY为RESET, 表示总线不再忙碌可以开始通信了
13// 2.主机发送起始信号, 开始通信
14I2C_GenerateSTART(I2C2, ENABLE);
15
16// 等待起始信号发送完毕
17while (I2C_GetFlagStatus(I2C2, I2C_FLAG_SB) == RESET);
18
19// 当代码运行到这里起始信号就发送完了,SB标志位为SET,可以发送寻址字节了
20// 3.发送寻址字节
21I2C_SendData(I2C2, OLED_ADDRESS_WRITE); // 主机发送寻址字节给从机
22
23// 主机发送寻址字节后的处理逻辑(重点)
24while (1) {
25if (I2C_GetFlagStatus(I2C2, I2C_FLAG_ADDR) == SET) {
26// 从机地址匹配成功,ADDR标志位自动置1,需要手动把ADDR标志位置为0
27// 手动清零ADDR标志位,若不清零后续发送过程无法开发
28// I2C_ClearFlag(I2C2, I2C_FLAG_ADDR); 这种软件清零的手段对ADDR标志位无效
29// ST公司规定 必须先读SR1寄存器, 再读SR2寄存器, 才能清零这个标志位
30I2C_ReadRegister(I2C2, I2C_Register_SR1); // 读SR1寄存器
31I2C_ReadRegister(I2C2, I2C_Register_SR2); // 读SR2寄存器
32break;
33}
34
35if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
36// AF标志位置为1,说明从机无应答,寻址失败
37// 需要手动清零AF标志位,并处理通信失败
38I2C_ClearFlag(I2C2, I2C_FLAG_AF);
39// 主机发送停止信号,结束通信
40I2C_GenerateSTOP(I2C2, ENABLE);
41return -1;
42}
43}
44
45// 从机寻址成功, 开始发送下一个字节数据
46// 4.向OLED从机发送0x00, 表示OLED从机进入命令控制模式
47I2C_SendData(I2C2, 0x00);
48
49// 主机发送非寻址字节数据后的处理逻辑(重点)
50// 等待此1个字节的数据发送完成
51while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BTF) == RESET);
52
53// 再检查AF标志位是否置1, 判断从机是否应答
54if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
55// 从机无应答, 拒收此数据
56I2C_GenerateSTOP(I2C2, ENABLE); // 停止通信
57I2C_ClearFlag(I2C2, I2C_FLAG_AF); // 手动清零AF标志位
58return -2;
59}
60
61
62// 5.循环得1个字节1个字节发送后续指令
63for (uint8_t i = 0; i < Length; i++) {
64I2C_SendData(I2C2, Cmds[i]); // 发送1个字节指令到OLED
65
66// 主机每发送1个字节,就处理一次从机ACK
67// 主机发送非寻址字节数据后的处理逻辑(重点)
68// 等待此1个字节的数据发送完成
69while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BTF) == RESET);
70
71// 再检查AF标志位是否置1, 判断从机是否应答
72if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
73// 从机无应答, 拒收此数据
74I2C_GenerateSTOP(I2C2, ENABLE); // 停止通信
75I2C_ClearFlag(I2C2, I2C_FLAG_AF); // 手动清零AF标志位
76return -2;
77}
78}
79
80// 所有指令都已经发出, 通信应该结束了
81// 主机发送停止信号, 通信结束
82I2C_GenerateSTOP(I2C2, ENABLE);
83return 0;
84}
你自己能全部写出来吗?
Gn!
把单片机发送指令的
hOLED_WriteCommand
函数实现后,相应的点亮和熄灭OLED这两个函数就可以直接写出来了:xxxxxxxxxx
371/**
2* @brief 启动 OLED 显示并点亮全屏像素
3* 该函数通过一系列命令启动 OLED,并全屏点亮显示。
4* @retval 0 表示成功,-1表示寻址失败, 返回-2表示OLED拒收
5*/
6int8_t hOLED_On(void) {
7// 开启屏幕的指令
8uint8_t OLED_ONCmds[] = {
90x8D, 0x14, // 开启电荷泵
100xAF, // 打开屏幕
110xA5 // 让屏幕全亮,点亮所有像素点
12};
13uint8_t SendResult = hOLED_I2CWriteCmds(OLED_ONCmds, sizeof(OLED_ONCmds));
14if(SendResult != 0){
15return SendResult; // 发送指令给OLED的过程中,出现了各种错误
16}
17return 0; // 发送成功
18}
19
20/**
21* @brief 彻底关闭 OLED 显示
22* 该函数通过一系列命令关闭 OLED 显示并关闭电荷泵。
23* @retval 0 表示成功,-1表示寻址失败, 返回-2表示OLED拒收
24*/
25int8_t hOLED_Off(void) {
26// 关闭屏幕的指令
27uint8_t OLED_OFFCmds[] = {
280xA4, // 普通显示模式
290xAE, // 关闭 OLED 显示
300x8D, 0x10 // 关闭电荷泵
31};
32uint8_t SendResult = hOLED_I2CWriteCmds(OLED_OFFCmds, sizeof(OLED_OFFCmds));
33if(SendResult != 0){
34return SendResult; // 发送指令给OLED的过程中,出现了各种错误
35}
36return 0; // 发送成功
37}
以上。
Gn!
现在只剩下最后一个核心函数没有实现了,它就是:
xxxxxxxxxx
11int8_t hOLED_ReadStatus(uint8_t *oled_flag);
有了上面的基础上,这个函数实现起来也非常的容易。
这里我们再来学习几个函数:
I2C_ReceiveData函数:
有上面的I2C_SendData函数,就有相应的接收函数。此函数的声明如下:
xxxxxxxxxx
11uint8_t I2C_ReceiveData(I2C_TypeDef* I2Cx);
此函数的形参只需要填入要接收数据的外设,可以是
I2C1
或I2C2
。它的返回值是一个8位的无符号数,也就是返回接收到的1个字节数据。
这个函数非常简单,但需要注意:最好在
I2C_FLAG_RXNE
接收数据寄存器非空标志位置为1时再调用,也就是有数据了再去读数据。所以此函数的一般调用方式如下:
xxxxxxxxxx
51// 等待接收数据
2while (I2C_GetFlagStatus(OLED_I2Cx, I2C_FLAG_RXNE) == RESET); // 等待接收缓冲区非空
3
4// 读取数据
5uint8_t status = I2C_ReceiveData(OLED_I2Cx); // 读取 OLED 状态数据
紧接着,我们还需要知道主机如何发送NACK给从机,表示主机不再希望接收从机数据。
这就需要使用函数
I2C_AcknowledgeConfig
了。I2C_AcknowledgeConfig函数:
在硬件I2C当中,主机发送ACK还是NACK是自动的,而且是在配置初始化I2C外设时就决定了。
就是调用
I2C_Init
函数时,I2C_Ack
成员的设置。在前面我们已经将该成员设置为
I2C_Ack_Enable
,这表示:主机收到从机数据时回复ACK给从机。而我们现在不需要主机发送ACK了,所以只需要改一下这个配置即可。
I2C_AcknowledgeConfig函数的声明如下:
xxxxxxxxxx
11void I2C_AcknowledgeConfig(I2C_TypeDef* I2Cx, FunctionalState NewState);
调用它的参数格式如下:
参数 类型 说明 I2Cx
I2C_TypeDef*
选择 I2C 外设(如 I2C1
或I2C2
)NewState
FunctionalState
ENABLE
(发送 ACK)或DISABLE
(禁用 ACK,发送NACK)所以只需要调用:
xxxxxxxxxx
11I2C_AcknowledgeConfig(OLED_I2Cx, DISABLE); // 禁用ACK, 主机发送NACK
即可让主机在接收到数据后,发送NACK。
整体的参考实现代码如下:
xxxxxxxxxx
591/**
2* @brief 读取 OLED 显示的状态
3* 通过读OLED显示屏的状态字节的bit6, 从而获取当前OLED点亮与否的状态
4* @param 指向oled_flag状态值的指针,取值0表示OLED打开,1表示关闭
5* @retval 0 表示成功,-1表示寻址失败, 返回-2表示OLED拒收
6*/
7int8_t hOLED_ReadStatus(uint8_t *OLED_StatusPtr) {
8// 1.在开始通信(发送起始位)之前,要判断总线是否处于忙碌状态.
9// 若总线忙碌就等到总线不忙碌为止
10while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BUSY) == SET);
11
12// 当代码运行到这里标志位BUSY为RESET, 表示总线不再忙碌可以开始通信了
13// 2.主机发送起始信号, 开始通信
14I2C_GenerateSTART(I2C2, ENABLE);
15
16// 等待起始信号发送完毕
17while (I2C_GetFlagStatus(I2C2, I2C_FLAG_SB) == RESET);
18
19// 做法1: 任何时候想要发一个字节时,都手动清零AF标志位
20
21// 当代码运行到这里起始信号就发送完了,SB标志位为SET
22// 3.发送寻址字节
23I2C_SendData(I2C2, OLED_ADDRESS_READ); // 表示向OLED从机写数据
24
25// 发送寻址字节后的处理逻辑
26while (1) {
27if (I2C_GetFlagStatus(I2C2, I2C_FLAG_ADDR) == SET) {
28// 手动清零ADDR标志位,若不清零后续发送过程无法开发
29// I2C_ClearFlag(I2C2, I2C_FLAG_ADDR); 这种软件清零的手段对ADDR标志位无效
30// ST公司规定 必须先读SR1寄存器, 再读SR2寄存器, 才能清零这个标志位
31I2C_ReadRegister(I2C2, I2C_Register_SR1); // 读SR1寄存器
32I2C_ReadRegister(I2C2, I2C_Register_SR2); // 读SR2寄存器
33// 处理从机地址匹配成功
34break;
35}
36
37if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
38// 做法2: 一旦AF被置为1了, 处理中就手动清零AF标志位
39I2C_ClearFlag(I2C2, I2C_FLAG_AF);
40// 处理从机地址匹配失败
41I2C_GenerateSTOP(I2C2, ENABLE); // 停止通信
42return -1;
43}
44}
45
46// 主机要读OLED状态字节,此时OLED会直接将状态字节数据发给主机
47// 主机会把这个状态字节数据存在自身的接收数据寄存器中
48while (I2C_GetFlagStatus(I2C2, I2C_FLAG_RXNE) == RESET); // 等待接收数据寄存器非空
49
50// 4.主机读接收数据寄存器, 获取OLED状态字节
51uint8_t status_byte = I2C_ReceiveData(I2C2);
52*OLED_StatusPtr = ((status_byte >> 6) & 0x01);
53
54//5.主机发送NACK给从机表示不再接收数据,然后发送停止信号结束通信
55// 禁用主机ACK,主机收到从机数据后会发NACK表示不再接收数据了
56I2C_AcknowledgeConfig(I2C2, DISABLE);
57I2C_GenerateSTOP(I2C2, ENABLE); // 发送停止位, 结束通信
58return 0;
59}
以上。
Gn!
hOLED模块整体实现参考以下代码:
xxxxxxxxxx
221123
4// OLED硬件的寻址字节
5// 写模式的OLED硬件的寻址字节
6// 读模式的OLED硬件的寻址字节
7
8/**
9* @brief 初始化OLED接入单片机的SCL引脚(PB10)和SDA引脚(PB11)
10* 初始化I2C2外设
11* @param 无
12* @retval 无
13*/
14void hOLED_Init(void) {
15// 1.初始化PB10 和 PB11 为复用开漏输出
16RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // 开启GPIOB 时钟
17GPIO_InitTypeDef GPIO_InitStruct;
18GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
19GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_OD; // 复用开漏输出模式
20GPIO_InitStruct.GPIO_Speed = GPIO_Speed_2MHz; // 2MHz引脚输出速度足够
21GPIO_Init(GPIOB, &GPIO_InitStruct);
22
23// 2.复位I2C2外设
24// 初始化I2C2外设之前,先复位此外设,以清除上一次I2C通信导致的错误
25RCC_APB1PeriphResetCmd(RCC_APB1Periph_I2C2, ENABLE); // 施加复位信号,类似按下单片机复位按键
26RCC_APB1PeriphResetCmd(RCC_APB1Periph_I2C2, DISABLE); // 释放复位信号,类似释放单片机复位按键
27
28// 3.初始化I2C2外设模块
29// 开启I2C2外设时钟,注意此外设挂载在APB1外设总线上
30RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);
31// 初始化 I2C2 模块
32I2C_InitTypeDef I2C_InitStruct;
33I2C_InitStruct.I2C_Ack = I2C_Ack_Enable; // 开启主机ACK,主机应答从机发送数据
34I2C_InitStruct.I2C_ClockSpeed = 400000; // 设置 I2C 时钟线频率为 400kHz,也就是快速模式
35I2C_InitStruct.I2C_Mode = I2C_Mode_I2C; // 设置为 标准I2C 模式
36I2C_InitStruct.I2C_DutyCycle = I2C_DutyCycle_2; // 设置占空比为2, 即低电平:高电平 = 2
37I2C_Init(I2C2, &I2C_InitStruct);
38
39// 4.开启I2C2外设
40I2C_Cmd(I2C2, ENABLE);
41}
42
43/**
44* @brief 主机向OLED从机发送多个控制指令
45* @param Cmds: 待发送控制指令的字节数组
46* @param Length: 待发送控制指令的数量, 也就是Cmds字节数组的长度
47* @retval 0 表示正常完成通信 -1 表示寻址失败 -2 表示从机拒收数据
48*/
49static int8_t hOLED_I2CWriteCmds(const uint8_t *Cmds, uint8_t Length) {
50// 1.在开始通信(发送起始位)之前,要判断总线是否处于忙碌状态
51// 若总线忙碌就等到总线不忙碌为止, 即等待总线进入空闲状态
52while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BUSY) == SET);
53
54// 当代码运行到这里标志位BUSY为RESET, 表示总线不再忙碌可以开始通信了
55// 2.主机发送起始信号, 开始通信
56I2C_GenerateSTART(I2C2, ENABLE);
57
58// 等待起始信号发送完毕
59while (I2C_GetFlagStatus(I2C2, I2C_FLAG_SB) == RESET);
60
61// 当代码运行到这里起始信号就发送完了,SB标志位为SET,可以发送寻址字节了
62// 3.发送寻址字节
63I2C_SendData(I2C2, OLED_ADDRESS_WRITE); // 主机发送寻址字节给从机
64
65// 主机发送寻址字节后的处理逻辑(重点)
66while (1) {
67if (I2C_GetFlagStatus(I2C2, I2C_FLAG_ADDR) == SET) {
68// 从机地址匹配成功,ADDR标志位自动置1,需要手动把ADDR标志位置为0
69// 手动清零ADDR标志位,若不清零后续发送过程无法开发
70// I2C_ClearFlag(I2C2, I2C_FLAG_ADDR); 这种软件清零的手段对ADDR标志位无效
71// ST公司规定 必须先读SR1寄存器, 再读SR2寄存器, 才能清零这个标志位
72I2C_ReadRegister(I2C2, I2C_Register_SR1); // 读SR1寄存器
73I2C_ReadRegister(I2C2, I2C_Register_SR2); // 读SR2寄存器
74break;
75}
76
77if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
78// AF标志位置为1,说明从机无应答,寻址失败
79// 需要手动清零AF标志位,并处理通信失败
80I2C_ClearFlag(I2C2, I2C_FLAG_AF);
81// 主机发送停止信号,结束通信
82I2C_GenerateSTOP(I2C2, ENABLE);
83return -1;
84}
85}
86
87// 从机寻址成功, 开始发送下一个字节数据
88// 4.向OLED从机发送0x00, 表示OLED从机进入命令控制模式
89I2C_SendData(I2C2, 0x00);
90
91// 主机发送非寻址字节数据后的处理逻辑(重点)
92// 等待此1个字节的数据发送完成
93while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BTF) == RESET);
94
95// 再检查AF标志位是否置1, 判断从机是否应答
96if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
97// 从机无应答, 拒收此数据
98I2C_GenerateSTOP(I2C2, ENABLE); // 停止通信
99I2C_ClearFlag(I2C2, I2C_FLAG_AF); // 手动清零AF标志位
100return -2;
101}
102
103
104// 5.循环得1个字节1个字节发送后续指令
105for (uint8_t i = 0; i < Length; i++) {
106I2C_SendData(I2C2, Cmds[i]); // 发送1个字节指令到OLED
107
108// 主机每发送1个字节,就处理一次从机ACK
109// 主机发送非寻址字节数据后的处理逻辑(重点)
110// 等待此1个字节的数据发送完成
111while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BTF) == RESET);
112
113// 再检查AF标志位是否置1, 判断从机是否应答
114if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
115// 从机无应答, 拒收此数据
116I2C_GenerateSTOP(I2C2, ENABLE); // 停止通信
117I2C_ClearFlag(I2C2, I2C_FLAG_AF); // 手动清零AF标志位
118return -2;
119}
120}
121
122// 所有指令都已经发出, 通信应该结束了
123// 主机发送停止信号, 通信结束
124I2C_GenerateSTOP(I2C2, ENABLE);
125return 0;
126}
127
128// 开启屏幕,并点亮每一个像素
129int8_t hOLED_On(void) {
130// 开启屏幕的指令
131uint8_t OLED_ONCmds[] = {
1320x8D, 0x14, // 开启电荷泵
1330xAF, // 打开屏幕
1340xA5 // 让屏幕全亮,点亮所有像素点
135};
136uint8_t SendResult = hOLED_I2CWriteCmds(OLED_ONCmds, sizeof(OLED_ONCmds));
137
138if (SendResult != 0) {
139return SendResult; // 发送指令给OLED的过程中,出现了各种错误
140}
141
142return 0; // 发送成功
143}
144
145// 关闭熄灭屏幕
146int8_t hOLED_Off(void) {
147// 关闭屏幕的指令
148uint8_t OLED_OFFCmds[] = {
1490xA4, // 普通显示模式
1500xAE, // 关闭 OLED 显示
1510x8D, 0x10 // 关闭电荷泵
152};
153uint8_t SendResult = hOLED_I2CWriteCmds(OLED_OFFCmds, sizeof(OLED_OFFCmds));
154
155if (SendResult != 0) {
156return SendResult; // 发送指令给OLED的过程中,出现了各种错误
157}
158
159return 0; // 发送成功
160}
161
162/**
163* @brief 读取 OLED 显示的状态
164* 通过读OLED显示屏的状态字节的bit6, 从而获取当前OLED点亮与否的状态
165* @param 指向oled_flag状态值的指针,取值0表示OLED打开,1表示关闭
166* @retval 0 表示成功,-1表示寻址失败, 返回-2表示OLED拒收
167*/
168int8_t hOLED_ReadStatus(uint8_t *OLED_StatusPtr) {
169// 1.在开始通信(发送起始位)之前,要判断总线是否处于忙碌状态.
170// 若总线忙碌就等到总线不忙碌为止
171while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BUSY) == SET);
172
173// 当代码运行到这里标志位BUSY为RESET, 表示总线不再忙碌可以开始通信了
174// 2.主机发送起始信号, 开始通信
175I2C_GenerateSTART(I2C2, ENABLE);
176
177// 等待起始信号发送完毕
178while (I2C_GetFlagStatus(I2C2, I2C_FLAG_SB) == RESET);
179
180// 做法1: 任何时候想要发一个字节时,都手动清零AF标志位
181
182// 当代码运行到这里起始信号就发送完了,SB标志位为SET
183// 3.发送寻址字节
184I2C_SendData(I2C2, OLED_ADDRESS_READ); // 表示向OLED从机写数据
185
186// 发送寻址字节后的处理逻辑
187while (1) {
188if (I2C_GetFlagStatus(I2C2, I2C_FLAG_ADDR) == SET) {
189// 手动清零ADDR标志位,若不清零后续发送过程无法开发
190// I2C_ClearFlag(I2C2, I2C_FLAG_ADDR); 这种软件清零的手段对ADDR标志位无效
191// ST公司规定 必须先读SR1寄存器, 再读SR2寄存器, 才能清零这个标志位
192I2C_ReadRegister(I2C2, I2C_Register_SR1); // 读SR1寄存器
193I2C_ReadRegister(I2C2, I2C_Register_SR2); // 读SR2寄存器
194// 处理从机地址匹配成功
195break;
196}
197
198if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
199// 做法2: 一旦AF被置为1了, 处理中就手动清零AF标志位
200I2C_ClearFlag(I2C2, I2C_FLAG_AF);
201// 处理从机地址匹配失败
202I2C_GenerateSTOP(I2C2, ENABLE); // 停止通信
203return -1;
204}
205}
206
207// 主机要读OLED状态字节,此时OLED会直接将状态字节数据发给主机
208// 主机会把这个状态字节数据存在自身的接收数据寄存器中
209while (I2C_GetFlagStatus(I2C2, I2C_FLAG_RXNE) == RESET); // 等待接收数据寄存器非空
210
211// 4.主机读接收数据寄存器, 获取OLED状态字节
212uint8_t status_byte = I2C_ReceiveData(I2C2);
213*OLED_StatusPtr = ((status_byte >> 6) & 0x01);
214
215//5.主机发送NACK给从机表示不再接收数据,然后发送停止信号结束通信
216// 禁用主机ACK,主机收到从机数据后会发NACK表示不再接收数据了
217I2C_AcknowledgeConfig(I2C2, DISABLE);
218I2C_GenerateSTOP(I2C2, ENABLE); // 发送停止位, 结束通信
219return 0;
220}
221
以上。
Gn!
为了实现外部中断的处理函数,需要声明以下函数:
xxxxxxxxxx
81// 开启屏幕并发送开启成功与否给PC端
2void hOLED_HandleOn(void);
3// 关闭屏幕并发送关闭成功与否给PC端
4void hOLED_HandleOff(void);
5// 读取屏幕开启状态并把这个状态发送给PC端
6void hOLED_HandleStatus(void);
7// USART外设全局中断处理函数, 在这里我们处理RXNE标志位中断
8void USART1_IRQHandler(void);
它们的实现也非常简单,如下所示:
xxxxxxxxxx
571// USART外设全局中断处理函数, 在这里我们处理RXNE标志位中断
2void USART1_IRQHandler(void) {
3if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) {
4uint8_t receivedData = USART_ReceiveData(USART1); // 读取接收数据
5// 根据命令执行相应操作
6switch (receivedData) {
7case '1': // 打开OLED
8hOLED_HandleOn();
9break;
10
11case '0': // 关闭OLED
12hOLED_HandleOff();
13break;
14
15case '2': // 查询OLED状态
16hOLED_HandleStatus();
17break;
18
19default:
20USART1_SendString("Command Error: Invalid command\r\n");
21break;
22}
23}
24}
25
26
27// 打开OLED的处理函数
28void hOLED_HandleOn(void) {
29if (hOLED_On() == 0) {
30USART1_SendString("OLED-TurnOn: Success\r\n");
31} else {
32USART1_SendString("OLED-TurnOff: Failed\r\n");
33}
34}
35
36// 关闭OLED的处理函数
37void hOLED_HandleOff(void) {
38if (hOLED_Off() == 0) {
39USART1_SendString("OLED-TurnOFF: Success\r\n");
40} else {
41USART1_SendString("OLED-TurnOFF: Failed\r\n");
42}
43}
44
45// 查询OLED状态的处理函数
46void hOLED_HandleStatus(void) {
47uint8_t OLED_Status;
48if (hOLED_ReadStatus(&OLED_Status) == 0) {
49if (OLED_Status == 0) {
50USART1_SendString("OLED-Status: ON\r\n");
51} else {
52USART1_SendString("OLED-Status: OFF\r\n");
53}
54} else {
55USART1_SendString("OLED-Status: Failed to read\r\n");
56}
57}
以上。
Gn!
测试代码,整个main.c文件的完整代码如下:
xxxxxxxxxx
8112345
6// 开启屏幕并发送开启成功与否给PC端
7void hOLED_HandleOn(void);
8// 关闭屏幕并发送关闭成功与否给PC端
9void hOLED_HandleOff(void);
10// 读取屏幕开启状态并把这个状态发送给PC端
11void hOLED_HandleStatus(void);
12// USART外设全局中断处理函数, 在这里我们处理RXNE标志位中断
13void USART1_IRQHandler(void);
14
15int main(void) {
16// 初始化 USART 和 OLED
17USART1_Config();
18hOLED_Init();
19
20while (1) {
21// 所有处理逻辑都交给中断触发处理
22}
23}
24
25void USART1_IRQHandler(void) {
26if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) {
27uint8_t receivedData = USART_ReceiveData(USART1); // 读取接收数据
28// 根据命令执行相应操作
29switch (receivedData) {
30case '1': // 打开OLED
31hOLED_HandleOn();
32break;
33
34case '0': // 关闭OLED
35hOLED_HandleOff();
36break;
37
38case '2': // 查询OLED状态
39hOLED_HandleStatus();
40break;
41
42default:
43USART1_SendString("Command Error: Invalid command\r\n");
44break;
45}
46}
47}
48
49
50// 打开OLED的处理函数
51void hOLED_HandleOn(void) {
52if (hOLED_On() == 0) {
53USART1_SendString("OLED-TurnOn: Success\r\n");
54} else {
55USART1_SendString("OLED-TurnOff: Failed\r\n");
56}
57}
58
59// 关闭OLED的处理函数
60void hOLED_HandleOff(void) {
61if (hOLED_Off() == 0) {
62USART1_SendString("OLED-TurnOFF: Success\r\n");
63} else {
64USART1_SendString("OLED-TurnOFF: Failed\r\n");
65}
66}
67
68// 查询OLED状态的处理函数
69void hOLED_HandleStatus(void) {
70uint8_t OLED_Status;
71if (hOLED_ReadStatus(&OLED_Status) == 0) {
72if (OLED_Status == 0) {
73USART1_SendString("OLED-Status: ON\r\n");
74} else {
75USART1_SendString("OLED-Status: OFF\r\n");
76}
77} else {
78USART1_SendString("OLED-Status: Failed to read\r\n");
79}
80}
81
以上关于I2C通信的部分,就暂时告一段落了,大家可以自行好好练习一下。
Gn!
至此,我们已经掌握了 I2C 通信的原理与基本用法。最后,我们来系统地总结一下 I2C 的优势与局限。
首先来看一下I2C通信的优势:
只需两根信号线(SCL、SDA),节省引脚和布线,适合板载多设备之间的通信;
支持多个主机和多个从机,灵活性很强;
软件和硬件实现皆可,特别适合嵌入式系统中的外设驱动;
功能完善强大,支持寻址,应答,主机切换,主从数据发送等功能;
低成本设计,使用通用 IO 和简单硬件即可完成通信,性价比极高。
I2C的劣势如下:
通信距离有限,受限于总线设计,尤其是上拉电阻的存在,不适合长线传输;
通信速度慢,由于I2C通信基于上拉电阻产生高电平,所以电平"低 -> 高"比较缓慢,拖累了通信速率。
功耗略高于推挽结构,持续电阻上拉带来静态功耗;
如果选择多主机模式,容易出现总线冲突,通信过程比较复杂,出错几率高。
总之,I2C通信是期望花最少的钱,用最少的资源,实现更多、更强大、更灵活的功能,而实际上I2C通信确实做到了这一点。
I2C是一种性价比极高、强大且灵活的通信协议,是嵌入式系统中必学且最常用的通信方式之一。