第10章 I2C通信

I2C通信理论部分

I2C通信简介

  • I2C通信(Inter IC BUS)是由Philips公司开发的一种通用数据总线
  • 两根通信线:SCL(Serial Clock)、SDA(Serial Data)
  • 同步,半双工
  • 带数据应答
  • 支持总线挂载多设备(一主多从、多主多从)

USART是异步的时序,也就是发送方和接收方约定的传输速率是非常严格的,时钟不能有过大的偏差,也不能说是,在传输过程中,单片机有点事,进中断了,这个时序能不能暂停一下啊,对于异步时序来说,这是不行的,你单片机发一个字节暂停了,接收方可是不知道的,它仍然会按照原来哪个约定的速率读取,这就会导致传输出错,所以异步时序的缺点就是,非常依赖硬件外设的支持,必须要有USART电路才能方便地使用。而I2C是同步的协议,另外加一条时钟线来指导对方读写,由于存在时钟线,对传输的时间要求就不高了,单片机也可以随时暂停传输,去处理其他事情,因为暂停传输的同时,时钟线也暂停了,所以传输双方都能定格在暂停的时刻,可以过一段时间再来继续,不会对传输造成影响,这就是同步时序的好处,使用同步时序就可以极大地降低单片机对硬件电路的依赖,即使没有硬件电路的支持,也可以很方便地用软件手动翻转电平来实现通信。

作为一个通信协议,它必须要在硬件和软件上,都作出规定。硬件上的规定,就是你的电路应该如何连接,端口的输入输出模式都是啥样的,软件上的规定,你的时序是怎么定义的,字节如何传输,高位先行还是低位先行。一个完整的时序由哪些部分构成。硬件的规定和软件的规定配合起来,就是一个完整的通信协议。

硬件电路

  • 所有I2C设备的SCL连在一起,SDA连在一起
  • 设备的SCL和SDA均要配置成开漏输出模式
  • SCL和SDA各添加一个上拉电阻,阻值一般为4.7KΩ左右

在这里插入图片描述
这是一个一主多从的模型,左边CPU就是我们的单片机,作为总线的主机,主机的权力很大,包括,对SCL线的完全控制,任何时候,都是主机完全控制SCL线,另外在空闲状态下,主机可以主动发起对SDA的控制,只有在从机发送数据和从机应答的时候,主机才会转交SDA的控制权给从机,这就是主机的权力。然后看下面,这一系列都是被控IC,也就是,挂载在I2C总线上的从机,这些从机可以是姿态传感器、OLED、存储器、时钟模块等等,从机的权利比较小,对于SCL时钟线,在任何时刻都只能被动的读取,从机不允许控制SCL线。对于SDA数据线,从机不允许主动发起对SDA的控制,只有在主机发送读取从机命令后,或者从机应答的时候,从机才能短暂地获取SDA的控制权。

那如何规定每个设备SCL和SDA的输入输出模式呢,SCL好规定,因为现在是一主多从,主机拥有SCL的绝对控制权,所以主机的SCL可以配置成推挽输出,所有从机的SCL都配置成浮空输入或者上拉输入。数据流向是,主机发送,所有从机接收。但是到SDA线这里,就比较麻烦了,因为这是半双工的协议,所以主机的SDA在发送的时候是输出,在接收的时候是输入,同样,从机的SDA也会在输入和输出之间反复切换,如果能协调好输入输出的切换时机,那其实也没问题,但如果这样做,如果总线时序没协调好,极有可能发生两个引脚同时处于输出状态,如果这又正好是一个输出高电平,一个输出低电平,那这个状态就是电源短路,这个状态是要极力避免的,所以为了避免总线没协调好导致电源短路这个问题,I2C的设计是,禁止所有设备输出强上拉的高电平,采用外置弱上拉电阻加开漏输出的电路结构,这两点规定就是上面的这两条,设备的SCL和SDA均要配置成开漏输出模式,SCL和SDA各添加一个上拉电阻,阻值一般为4.7KΩ左右。所有的设备,包括CPU和被控IC,它引脚的内部结构,都是右图这样的,左边这一块是SCL的结构,这里SCLK就是SCL的意思,右边这一块是SDA的结构,这里DATA就是SDA的意思,首先引脚的信号进来,都可以通过一个数据缓冲器或者是施密特触发器,进行输入,因为输入对电路没有任何影响,所以任何设备在任何时刻都是可以输入的,但是在输出的这部分,采用的是开漏输出的配置,这样的话,所有的设备都只能输出低电平,而不能输出高电平,为了避免高电平造成的引脚浮空,这时候就需要在总线外面,SCL和SDA各外置一个上拉电阻,这是通过一个电阻拉到高电平的,所以这是一个弱上拉,用我们之前将的弹簧和杆子的模型来解释就是,SCL或SDA就是一根杆子,为了防止有人向上推杆子,有人向下拉杆子,造成冲突,我们就规定,所有的人,不准向上推杆子,只能选择向下拉或者放手,然后,我们再外置一根弹簧向上拉,你要输出低电平,就往下拽,这根弹簧肯定拽不赢你,所以弹簧被拉伸,杆子处于低电平状态,你要输出高电平,就放手,杆子在弹簧的拉力下,回弹到高电平,这就是一个弱上拉的高电平,但是完全不影响数据传输。
第一,完全杜绝了电源短路的现象,保证电路的安全。第二,避免了引脚模式的频繁切换,开漏加弱上拉的模式,同时间兼具了输入和输出的功能。第三,就是这个模式会有一个“线与”的现象,就是只要有任意一个或多个设备输出了低电平,总线就处于低电平,只有所有的设备都输出高电平,总线才处于高电平,I2C可以利用这个电路特性,执行多主机模式下的时钟同步和总线仲裁,所以这里SCL虽然在一主多从模式下可以用推挽输出,但是它仍然采用了开漏加上拉输出的模式,因为在多主机的模式下,会用到这个特征。

软件部分

I2C时序基本单元

  • 起始条件:SCL高电平期间,SDA从高电平切换到低电平
  • 终止条件:SCL高电平期间,SDA从低电平切换到高电平
    在这里插入图片描述

在I2C总线处于空闲状态时,SCL和SDA都处于高电平状态,也就是没有任何一个设备去碰SCL和SDA,SCL和SDA由外挂的上拉电阻拉高至高电平。打破宁静状态的起始条件就是,SCL处于高电平不去动他,然后把SDA拽下来,产生一个下降沿,当从机捕获到这个SCL高电平,SDA下降沿信号时,就会进行自身的复位,等待主机的召唤,然后在SDA下降沿之后,主机要再把SCL拽下来,拽下SCL,一方面是占用这个总线,另一方面也是为了方便我们这些基本单位的拼接,就是之后我们会保证,除了起始条件和终止条件,每个时序单元的SCL都是以低电平开始,低电平结束,这样这些单元拼接起来,SCL才能续得上。

这个起始条件和终止条件就类似串口时序里的起始位和停止位,一个完整的数据帧,总是以起始条件开始、终止条件结束,另外,起始和终止,都是由主机产生的,从机不允许产生起始和终止,所以在总线空闲状态时,从机必须始终双手放开,不允许主动跳出来,去碰总线,如果允许的话,那就是多主机模型了。

发送一个字节
  • 发送一个字节:SCL低电平期间,主机将数据位依次放到SDA线上(高位先行),然后释放SCL,从机将在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许由数据变化,依次循环上述过程8次,即可发送一个字节。
    在这里插入图片描述

起始条件之后,第一个字节,也必须是主机发送的,主机如何发送呢,就是最开始,SCL低电平,主机如果想发送0,就拉低SDA到低电平,如果想发送1,就放手,SDA回弹到高电平,在SCL低电平期间,允许改变SDA的电平,当这一位放好之后,主机就松手时钟线,SCL回弹到高电平,在高电平期间,是从机读取SDA的时候,所以高电平期间,SDA不允许变化,SCL处于高电平之后,从机需要尽快地读取SDA,一般都是在上升沿这个时刻,从机就已经读取完成了,因为时钟是主机控制的,从机并不知道什么时候就会产生下降沿了,你从机要是磨磨唧唧的,主机可不会等你,所以从机在上升沿时,就会立刻把数据读走,那主机在放手SCL一段时间后,就可以继续拉低SCL,传输下一位了,主机也需要在SCL下降沿之后,尽快把数据放到SDA上,但是主机有时钟的主导权,所以主机并不需要那么着急,只需要在低电平的任意时刻把数据放在SDA上就行了,晚点也没关系,数据放完之后,主机再松手SCL,SCL高电平,从机读取这一位,就这样的流程,主机拉低SCL,把数据放在SDA上,主机松开SCL,从机读取SDA的数据,在SCL的同步下,依次进行主机发送和从机接收,循环8此,就发送了8位数据,也就是一个字节,另外注意,这里是高位先行,所以第一位是一个字节的最高位B7,接着是B6,最后发送给最低位B0。这个和串口是不一样的,串口时序是低位先行,这里I2C是高位先行。

另外,由于这里有时钟线进行同步,所以如果主机一个字节发送一半,突然进中断了,不操作SCL和SDA了,那时序就会在中断的位置不断拉长,SCL和SDA电平都暂停变化,传输也完全暂停,等中断结束后,主机回来继续操作,传输仍然不会出问题,这就是同步时序的好处。

接收一个字节
  • 接收一个字节:SCL低电平期间,从机将数据位依次放到SDA线上(高位先行),然后释放SCL,主机将在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许有数据变化,依次循环上述过程8次,即可接收一个字节(主机在接收之前,需要释放SDA)
    可以这么理解,所有设备包括主机都处于输入模式,当主机需要发送的时候,就可以主动去拉低SDA,而主机在被动接收的时候,就必须释放SDA,不要去动它,以免影响别人发送,因为总线是线与的特征,任何一个设备被拉低了,总线就是低电平,如果你接收的时候,还拽着SDA不放手,那别人无论发什么数据,总线都始终是低电平。
    在这里插入图片描述
    发送一个字节和接收一个字节,区别在于SDA线,主机在接收之前要释放SDA,然后这时从机就取得了SDA的控制权,从机需要发送0,就把SDA拉低,从机需要发送1,就放手,SDA回弹高电平。然后同样的,低电平变换数据,高电平读取数据。
应答机制

首先,应答机制分为发送应答和接收应答

  • 发送应答:主机在接受完一个字节之后,在下一个时钟发送一位数据,数据0表示应答,数据1表示非应答。
  • 接收应答:主机在发送完一个字节之后,在下一个时钟接收一位数据,判断从机是否应答,数据0表示应答,数据1表示非应答(主机在接收之前,需要释放SDA)

I2C时序

  • I2C的完整时序,主要有指定地址写,当前地址读和指定地址读这3种。
  • 我们这个I2C是一主多从的模型,主机可以访问总线上的任意一个设备,那如何发送指令,来确定要访问的是哪个设备呢,这就需要把每个从设备都确定一个唯一的设备地址,从机设备地址就相当于每个设备的名字,主机在起始条件之后,要先发送一个字节叫一下从机名字,所有从机都会收到第一个字节,和自己的名字进行比较,如果不一样,则认为主机没有叫我,之后的时序我就不管了,如果一样,就说明,主机现在在叫我,那我就响应之后主机的读写操作,在同一条I2C总线里,挂载的每个设备地址必须不一样,从机设备地址,在I2C协议标准里分为7位地址和10位地址,江科大目前只讲7位地址的模式,因为7位地址比较简单而且应用范围最广。在每个I2C设备出厂时,厂商都会为它分配一个7位的地址,这个地址具体是什么,可以在芯片手册里找到,比如MPU6050这个芯片的7位地址,是1101000,AT24C02的地址是1010000,一般不同型号的芯片地址都是不同的,相同型号的芯片地址都是一样的,如果有相同的芯片挂载在同一条总线上,这就需要用到地址中的可变部分了,一般器件地址的最后几位是可以在电路中改变的,比如MPU6050地址的最后一位,就可以由这个板子上的AD0引脚确定,这个引脚接低电平,那它的地址就是1101000,这个引脚接高电平,那它的地址就是1101001,比如AT24C02地址的最后三位,都可以分别由这个板子上的A0、A1、A2引脚确定,比如A0引脚接低电平,地址对应的位就是0,接高电平,地址对应的位就是1,A1、A2也是同理,一般I2C的从机设备地址,高位都是厂商确定的,低位可以由引脚来灵活切换,这样,即使相同型号的芯片,挂载在同一个总线上,也可以通过切换地址低位的方式,保证每个设备的地址都不一样。
指定地址写
  • 对于指定设备(Slave Address),在指定地址(Reg Address)下,写入指定数据(Data)。
    对于指定设备,指定设备,通过Slave Address,从机地址来确定,在指定地址下,这个指定地址,就是某个设备内部的Reg Address,寄存器地址,写入指定数据,就是要在这个寄存器中写入的Data数据。
当前地址读
  • 对于指定设备(Slave Address),在当前地址指针指示的地址下,读取从机数据(Data)。

I2C协议的规定是,主机进行寻址时,一旦读写标志位给1了,下一个字节就要立马转为读的时序,所以主机还来不及指定,我想要读哪个寄存器,就得开始接收了,所以这里没有指定地址这个环节,那主机并没有指定寄存器的地址,从机到底该发哪个寄存器的数据呢,这就需要用到我们上面说的当前地址指针了。在从机中,所有的寄存器被分配到了一个线性区域中,并且,会有一个单独的指针变量,指示着其中一个寄存器,这个指针上电默认,一般指向0地址,并且,每写入一个字节和读出一个字节后,这个指针就会自动自增一次,移动到下一个位置,那么在调用当前地址读的时序时,主机没有指定要读哪个地址,从机就会返回当前指针指向的寄存器的值。

那假设,刚刚调用了这个指定地址写的时序,在0x19的位置写入了0xAA,那么指针就会+1,移动到0x1A位置,我再调用这个当前地址读的时序,返回的就是0x1A地址下的值,如果再调用一次呢,返回的就是0x1B下的值。

由于当前地址读并不能指定读的地址,所以这个时序用的不是很多。

指定地址读
  • 对于指定设备(Slave Address),在指定地址(Reg Address)下,读取从机数据(Data)。
进阶的时序

除了上面的3个时序,I2C还有这些时序的进阶版本,上面的三个时序,比如指定地址写,只是写一个字节,当前地址读和指定地址读,也都是读一个字节,那进阶版本就是指定地址写多个字节,当前地址读多个字节和指定地址读多个字节,时序上呢,和上面都非常相似,只需要增加一些小细节就行。

比如,指定地址,然后写入一个字节,如果只想写一个字节,那写完一个就停止,如果想写多个字节,就可把写的部分,多重复几次,比如,重复三遍发送一个字节和接收应答,这样第一个数据就写入到了指定地址的0x19的位置,然后,不要忘了上面说的,写入一个数据后,地址指针会自动+1,变成0x1A,所以这第二个数据就写入了0x1A的位置,同理,第三个数据就写入的时0x1B的位置,以此类推。这样这个时序就进阶为,在指定的位置开始,按顺序连续写入多个字节,比如你需要连续写入多个寄存器,就可以考虑这样来操作,这样在一条数据帧里,就可以同时写入多个字节。

然后同理,当前位置读和指定位置读,也可以多次执行这最后一部分时序,由于地址指针在读后也会自增,所以这样就可以连续读出一片区域的寄存器。

有一点要注意一下,如果在读完一个字节之后,想停止的话,一定要给从机发个非应答(SendAck,SA),非应答,就是该主机应答的时候,主机不把SDA拉低,从机读到SDA为1,就代表主机没有应答,从机收到非应答之后,就知道主机不想继续了,从机就会释放总线,把SDA控制权交还给主机,如果主机读完,仍然给从机应答了,从机就会认为主机还想要数据,就会继续发送下一个数据,而这时,主机如果想产生停止条件,SDA就可能因为被从机拽住了,而不能正常弹回高电平。

从机控制SDA发送一个字节的权利,开始于读写标志位为1,结束于主机给应答为1。

MPU6050

  • MPU6050是一个6轴姿态传感器,可以测量芯片自身X、Y、Z轴的加速度、角速度参数、通过数据融合,可进一步得到姿态角,常应用于平衡车、飞行器等需要检测自身姿态的场景
  • 3轴加速度计(Accelerometer):测量X、Y、Z轴的加速度
  • 3轴陀螺仪传感器(Gyroscope):测量X、Y、Z轴的角速度
  • 6轴就是3轴加速度和3轴角速度;9轴就是3轴加速度、3轴角速度和3轴磁场强度;10轴就是3轴加速度、3轴角速度、3轴磁场强度和1个气压强度

MPU6050参数

  • 16位ADC采集传感器的模拟信号,量化范围:-32768~32767
  • 加速度计满量程选择:+/2、+/4、+/8、+/16(g)
  • 陀螺仪满量程选择:+/250、+/500、+/1000、+/2000(°/sec)
    这里呢,如果你所测量的物体运动非常剧烈,就可以把满量程(上面两个参数)选择大一些,防止你的加速度或者角速度超出了量程,如果比较平缓,就可以选择比较小的量程,这样测量的分辨率就会更大。
  • 可配置的数字低同i滤波器
    在这个芯片里,可以配置寄存器来选择对输出数据进行低通滤波,如果你觉得输出数据抖动太厉害,就可以加一点低头滤波,这样输出数据就会平缓一些。
  • 可配置的时钟源
  • 可配置的采样分配
    上面两个参数是配合使用的,时钟源,经过这个分频器的分频,可以为AD转换和内部其他电路提供时钟,控制分频系数,就可以控制AD转换的快慢了
  • I2C从机地址:1101000(AD0 = 0)
    1101001(AD0 = 1)

从机地址的第一种表示方式是直接把7位地址转换十六进制得到的,在实际发送第一个字节的时候,不要忘了先左移1位,再或上读写位。

从机地址还有一种表示方式,就是把0x68左移1位后的数据,当作从机地址,0x68左移一位之后,是0xD0,那这样MPU6050的从机地址就是0xD0,这时在实际发送第一个字节时,如果你要写,就直接把0xD0当作第一个字节,如果你要读,就把0xD0或上0x01,即0xD1当作第一个字节。这种表示方式,是把读写位也融入到从机地址里了,0xD0是写地址,0xD1是读地址,这样表示的。

无论哪种表示方式,在I2C中第一个字节表示方式都是一样的。

硬件电路

在这里插入图片描述
上图展示的就是MPU6050模块的原理图,左上角是一个LDO,低压差线性稳压器。

MPU6050融合出来的姿态角是有缺陷的,这个缺陷就是绕Z轴的角度,也就是偏航角,它的偏移无法通过加速度计进行纠正。而提供长时间的稳定偏航角进行参考,这就是9轴姿态传感器多出的磁力计的作用,另外,如果要制作无人机,需要定高飞行,这时候就还需要增加气压计,扩展为10轴,提供一个高度信息的稳定参考,所以,根据项目要求,这个6轴传感器可能不够用,这个时候,XCL和XDA就可以起作用了,XCL和XDA通常就是用于外接磁力计或者气压计,当接上磁力计或气压计之后,MPU6050的主机接口就可以直接访问这些扩展芯片的数据,把这些扩展芯片的数据读取到MPU6050里面,在MPU6050里面会有DMP单元,进行数据融合和姿态解算,当然,如果你不需要MPU6050 的解算功能的话,也可以把这个磁力计或者气压计,直接挂载在SCL和SDA这条总线上,因为I2C本来就可以挂载多设备,所以把多个设备都挂载在一起也是没问题的,这就是XCL和XDA的用途。

而AD0这个口,如果引脚悬空,则是低电平,如果想接高电平,可以把AD0直接引到VCC,强上拉至高电平。

最后一个引脚INT,也就是中断输出引脚,可以配置芯片内部的一些事件,来触发中断引脚的输出,比如数据准备好了、I2C主机错误。

另外芯片内部还内置了一些实用的小功能,比如自由落体检测、运动检测、零运动检测等。这些信号都可以触发INT引脚产生电平跳变,需要的话,可以进行中断信号的配置,但如果不需要的话,也可以不配置。

输入端电压可以在5V~3.3V,然后通过稳压器,输出稳定的3.3V电压,给芯片端供电。

MPU6050框图

在这里插入图片描述
其中左上角的是时钟系统,不过一般使用内部时钟。另外这个芯片还有温度传感器,如果想用这个来测量温度,也是没有问题的。

软件I2C读写MPU6050代码部分

MPU6050的接线

MPU6050的这个代码使用的是软件I2C,就是用普通的GPIO口,手动翻转电平实现的协议,它并不需要STM32内部的外设资源支持,所以这里的端口,其实可以任意指定,不局限,接在任意两个普通的GPIO口就行,然后在程序中配置并操作SCL和SDA对应的端口就行了。这算是软件I2C相比硬件I2C的一大优势。

I2C代码

软件I2C,只需要用GPIO的读写函数就好了,所以库函数不用看

I2C初始化

  • 初始化这边有两个任务
    1.把SCL和SDA都初始化为开漏输出模式
    2.把SCL和SDA置高电平
void MyI2C_Init(void)
{
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);

	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOB, &GPIO_InitStructure);
	
	GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11);
}

I2C起始条件

也可以使用宏定义
#define SCL_PORT GPIOB
#define SCL_PIN GPIO_Pin_10

除了这种把引脚宏定义的,也可以把整个函数进行宏定义替换的
下面这种叫有参宏
#define OLED_W_SCL(x)   GPIO_WriteBit(GPIOB, GPIO_Pin_8, (BitAction)(x))
#define OLED_W_SDA(x) 	GPIO_WriteBit(GPIOB, GPIO_Pin_9, (BitAction)(x))
这种就是直接写函数,直接把函数封装
void MyI2C_W_SCL(uint8_t BitValue)
{
	//BitAction是32自己带的一个枚举变量名,包括RESET和SET两个
	GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);
	Delay_us(10);
	//对于stm32f1系列,这里即使不加任何延迟,这个引脚翻转速度,MPU6050也能跟的上
}

void MyI2C_W_SDA(uint8_t BitValue)
{
	GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue);
	Delay_us(10);
}

//返回读到SDA线的电平
uint8_t MyI2C_R_SDA(void)
{
	uint8_t BitValue;
	BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11);
	Delay_us(10);
	return BitValue;
}

void MyI2C_Start(void)
{
	//把SCL和SDA都释放,也就是都输出1 
	/*而这里一般为了保险起见,先释放SDA,再释放SCL,
	  因为防止SCL高电平期间再释放SDA会被误以为是终止条件*/
	MyI2C_W_SDA(1);
	MyI2C_W_SCL(1);
	//先拉低SDA再拉低SCL就可以产生起始条件了
	MyI2C_W_SDA(0);
	MyI2C_W_SCL(0);
}

I2C终止条件

void MyI2C_Stop(void)
{
/*为了确保释放SDA能产生上升沿,要在时序单元开始时,先拉低SDA*/
	MyI2C_W_SDA(0);
	MyI2C_W_SCL(1);
	MyI2C_W_SDA(1);
}

发送一个字节

首先趁着SCL低电平,先把Byte的最高位放在SDA线上,

void MyI2C_SendByte(uint8_t Byte)
{
	uint8_t i;
	for (i = 0; i < 8; i ++)
	{
		/*写1还是0呢,取决于Byte的最高位,这里需要取出Byte的最高位
		(Byte & 0x80)可以取出Byte的最高位,这是单片机中的常见操作,
		就是用按位与的方式,取出数据的某一位或某几位
		但是这个式子计算结果是0x80或者0x00,
		但是考虑到,我们这个函数里面,
		调用的BitAction函数,具有非0即1的特性,
		所以,即使传入0x80,也相当于传入了1*/
		/*而 0x80 >> 1,得到0x40,再将(Byte & 0x40),可以得到次高位*/
		MyI2C_W_SDA(Byte & (0x80 >> i));
		MyI2C_W_SCL(1);
		MyI2C_W_SCL(0);
	}
}

接收一个字节

起始终止和数据传输的波形有本质区别,数据传输,SCL高电平不许动SDA,起始终止,SCL高电平必须动SDA。

uint8_t MyI2C_ReceiveByte(void)
{
	uint8_t i, Byte = 0x00;
	MyI2C_W_SDA(1);
	for (i = 0; i < 8; i ++)
	{
		MyI2C_W_SCL(1);
		if (MyI2C_R_SDA() == 1){Byte |= (0x80 >> i);}
		/*上面那句代码,Byte|=0x80,即把Byte最高位置1,其余同理*/
		MyI2C_W_SCL(0);
	}
	return Byte;
}

发送应答和接收应答

发送应答和接收应答其实就是发送一个字节和接收一个字节的简化版,发送一个字节是发8位,发送应答是发1位,接收一个字节是收8位,接收应答是收1位。

void MyI2C_SendAck(uint8_t AckBit)
{
	MyI2C_W_SDA(AckBit);
	MyI2C_W_SCL(1);
	MyI2C_W_SCL(0);
}

uint8_t MyI2C_ReceiveAck(void)
{
	uint8_t AckBit;
	/*主机释放SDA防止主机干扰*/
	MyI2C_W_SDA(1);
	MyI2C_W_SCL(1);
	AckBit = MyI2C_R_SDA();
	MyI2C_W_SCL(0);
	return AckBit;
}

测试代码

MyI2C_Init();
MyI2C_Start();//起始条件
MyI2C_SendByte(0xD0);//1101000 0
uint8_t Ack = MyI2C_ReceiveAck();
MyI2C_Stop();
OLED_ShowNum(1, 1, Ack, 3);

若Ack为0,说明MPU6050给我应答了

可以用for循环把上面这个程序套起来,遍历一下所有的从机地址,然后把应答位为0的地址统计下来,这样就能实现扫描总线上设备的功能了。遍历的时候,只要遍历前7位地址就行了,最后一位要始终保持为0,否则你一旦交出了总线控制权,从机就会干扰你后续的遍历。

MPU6050的代码

MPU6050初始化

在指定地址写寄存器里面,MyI2C_ReceiveAck( ); 可以判断从机有没有收到数据,但判断的代码,江科大并没有写,如果需要,得自己写

#include "MyI2C.h"
#define MPU6050_ADDRESS		0xD0

/*指定地址写寄存器*/
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
	MyI2C_Start();
	MyI2C_SendByte(MPU6050_ADDRESS);
	MyI2C_ReceiveAck();
	//这里有应答位可以判断从机有没有收到数据,但判断的代码这里没有写
	MyI2C_SendByte(RegAddress);//用于指定具体读写哪个寄存器
	MyI2C_ReceiveAck();
	
	MyI2C_SendByte(Data);
	MyI2C_ReceiveAck();
	如果想用指定地址写多个字节的话,就可以用for循环,把
	上面两句套起来,多执行几遍
	MyI2C_Stop();
}

/*指定地址读*/
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
	uint8_t Data;
	
	MyI2C_Start();
	MyI2C_SendByte(MPU6050_ADDRESS);
	MyI2C_ReceiveAck();
	MyI2C_SendByte(RegAddress);
	MyI2C_ReceiveAck();
	
	MyI2C_Start();
	MyI2C_SendByte(MPU6050_ADDRESS | 0x01);//这样便指定了,接下来要读从机地址了
	MyI2C_ReceiveAck();
	
	Data = MyI2C_ReceiveByte();
	MyI2C_SendAck(1);
	如果想指定地址读多个字节,可以用for循环把这两行套起来,
	重复读取多次,就能读取多个数据了,但要注意,读完最后一个字节
	给非应答,之前都给应答
	/*这里给0,就是给从机应答,参数给1,
	就是不给从机应答,如果想继续读多个字节,
	就要给应答,从机收到应答后,就会继续发送数据*/
	MyI2C_Stop();
	
	return Data;
}

每个寄存器的地址和寄存器内数据的意义,可以在手册内查询

MPU6050_Init();
uint8_t ID = MPU6050_ReadReg(0x75);返回值是ID号的内容
OLED_ShowHexNum(1, 1, ID, 2);

想要写寄存器,首先需要接触芯片的睡眠模式,否则写入是无效的,睡眠模式是电源管理寄存器1的这一位SLEEP控制的,我们可以直接把这位寄存器写入0x00,这样就解除睡眠模式了,那这个寄存器的地址是0x6B

MPU6050_Init();
MPU6050_WriteReg(0x6B, 0x00);
OLED_ShowHexNum(1, 1, ID, 2);

上述代码这样就是在电源管理寄存器1,写入0x00,解除睡眠模式

接着可以测试一下,是否解除睡眠成功

MPU6050_Init();
MPU6050_WriteReg(0x6B, 0x00);
MPU6050_WriteReg(0x19, 0xAA);
uint8_t ID = MPU6050_ReadReg(0x19);
OLED_ShowHexNum(1, 1, ID, 2);

如果解除睡眠成功,可以看到,显示0xAA

  • 接着开始写初始化函数

在MPU6050_Reg.h里面存放了宏定义

#define	MPU6050_SMPLRT_DIV		0x19
#define	MPU6050_CONFIG			0x1A
#define	MPU6050_GYRO_CONFIG		0x1B
#define	MPU6050_ACCEL_CONFIG	0x1C

#define	MPU6050_ACCEL_XOUT_H	0x3B
#define	MPU6050_ACCEL_XOUT_L	0x3C
#define	MPU6050_ACCEL_YOUT_H	0x3D
#define	MPU6050_ACCEL_YOUT_L	0x3E
#define	MPU6050_ACCEL_ZOUT_H	0x3F
#define	MPU6050_ACCEL_ZOUT_L	0x40
#define	MPU6050_TEMP_OUT_H		0x41
#define	MPU6050_TEMP_OUT_L		0x42
#define	MPU6050_GYRO_XOUT_H		0x43
#define	MPU6050_GYRO_XOUT_L		0x44
#define	MPU6050_GYRO_YOUT_H		0x45
#define	MPU6050_GYRO_YOUT_L		0x46
#define	MPU6050_GYRO_ZOUT_H		0x47
#define	MPU6050_GYRO_ZOUT_L		0x48

#define	MPU6050_PWR_MGMT_1		0x6B//电源管理寄存器1,地址是0x68
#define	MPU6050_PWR_MGMT_2		0x6C
#define	MPU6050_WHO_AM_I		0x75
  • 在配置电源管理寄存器1时
    1.Bit7 设备复位给0,不复位
    2.Bit6 睡眠模式,给0,解除睡眠
    3.Bit5 循环模式,给0,不需要循环
    4.Bit4 无关位,给0即可
    5.Bit3 温度传感器失能,给0,不失能
    6.最后三位选择时钟,给000,选择内部时钟,但是上面说了,它非常建议我们选择陀螺仪时钟,所以我们可以给个001,选择X轴的陀螺仪时钟
    所以这个寄存器写入的数据就是0x01
#include "MPU6050_Reg.h"
void MPU6050_Init(void)
{
	MyI2C_Init();
	MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);//配置电源管理寄存器1
	MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00);//配置电源管理寄存器2
	MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09);//采样率分频,决定了数据输出的快慢,值越小越快,这个可以根据实际需求来,给个0x09,也就是10分频
	MPU6050_WriteReg(MPU6050_CONFIG, 0x06);//后面3位是数字低通滤波器,这个也是根据需求来,我们可以给个110,这就是最平滑的滤波
	MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);//陀螺仪配置寄存器Bit4与Bit5,是满量程选择,也是根据实际需求来,就给11,选择最大量程,最后面三位是无关位
	MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);//加速度计配置寄存器
}

获取数据

根据任务需求,下面这个函数需要返回6个int16_t的数据,分别表示XYZ的加速度值和陀螺仪值。但是C语言中,函数的返回值只能有一个,所以这里就需要一些特殊操作来实现返回6个值的任务。
可以利用指针,进行变量的地址传递,来实现多返回值

void MPU6050_GetData(int16_t *AccX, int16_t *AccY, int16_t *AccZ, 
						int16_t *GyroX, int16_t *GyroY, int16_t *GyroZ)
{
	uint8_t DataH, DataL;
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H);//首先读取加速度寄存器X轴的高8位
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L);//然后再读取加速度寄存器的低八位
	*AccX = (DataH << 8) | DataL;//这就是加速度计X轴的16位数据
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_H);//加速度寄存器Y轴数据
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_L);
	*AccY = (DataH << 8) | DataL;
	
	DataH = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_H);//加速度寄存器Z轴数据
	DataL = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_L);
	*AccZ = (DataH << 8) | DataL;
	
	//下面是陀螺仪的XYZ轴
	DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H);
	DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L);
	*GyroX = (DataH << 8) | DataL;
	
	DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H);
	DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L);
	*GyroY = (DataH << 8) | DataL;
	
	DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H);
	DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L);
	*GyroZ = (DataH << 8) | DataL;
}

上述程序的逻辑是,分别读取6个轴数据寄存器的高位和低位,拼接成16位数据,再通过指针变量返回。

但实际上,还有一种更高效的方法,就是,使用上面提到的,I2C读取多个字节的时序,从一个基地址开始,连续读取一片的寄存器,因为这些寄存器的地址,是连续的,所以可以从第一个寄存器的地址0x3B开始,连续读取14个字节,这样,在时序上,读取效率就会大大提升。

接着测试上述代码

int16_t AX, AY, AZ, GX, GY, GZ;
int main(void)
{
	OLED_Init();
	MPU6050_Init():
	while(1)
	{
		MPU6050_GetData(&AX, cAY, &AZ, &GX, &GY, &GZ);
		OLED_ShowSignedNum(2, 1, AX, 5);
		OLED_ShowSignedNum(3, 1, AY, 5);
		OLED_ShowSignedNum(4, 1, AZ, 5);
		OLED_ShowSignedNum(2, 8, GX, 5);
		OLED_ShowSignedNum(3, 8, GY, 5);
		OLED_ShowSignedNum(4, 8, GZ, 5);
	}
}	

I2C外设简介

  • STM32内部集成了硬件I2C收发电路,可以由硬件自动执行时钟生成、起始终止条件生成、应答位收发、数据收发等功能,减轻CPU的负担
  • 支持多主机模型
  • 支持7位/10位地址模式
  • 支持不同的通讯速度,标准速度(高达100kHz),快速(高达400kHz)
  • 支持DMA
  • 兼容SMBus协议
  • STM32F103C8T6 硬件I2C资源:I2C1、I2C2

I2C外设的框图

在这里插入图片描述
数据收发的核心部分,是这里的数据寄存器和数据移位寄存器,当我们需要发送数据时,可以把一个字节数据写到数据寄存器DR,当移位寄存器没有数据移位时这个数据寄存器的值就会进一步,转到移位寄存器里,在移位的过程中,我们就可以直接把下一个数据放到数据寄存器里等着了,一旦前一个数据移位完成,下一个数据就可以无缝衔接,继续发送,当数据由数据寄存器转到移位寄存器时,就会置状态寄存器的TXE位为1,表示发送寄存器为空,这就是发送的流程。

那在接收时,也是这一路,输入的数据,一位一位地,从引脚移入到移位寄存器里,当一个字节的数据收齐之后,数据就整体从移位寄存器转到数据寄存器,同时置标志位RXNE,表示接收寄存器非空,这时我们就可以把数据从数据寄存器里读出来了。

I2C基本结构图

在这里插入图片描述
首先,移位寄存器和数据寄存器DR的配合,是通信的核心部分,这里因为I2C是高位先行,所以这个移位寄存器是向左移位,在发送的时候,最高位先移出去,然后是次高位,等等。一个SCL时钟,移位一次,移位8次,这样就能把一个字节,由高位到低位,依次放到SDA线上了。

在接收的时候,数据通过GPIO口从右边依次移进来,最终移八次,一个字节就接收完成了。使用硬件I2C的时候,配置这两个GPIO口,都要配置成复用开漏输出的模式。复用,就是GPIO口的状态是交由片上外设来控制的,开漏输出,这是I2C协议要求的端口配置。

最后,还是有个开关控制,也就是I2C_Cmd,配置好了,就使能外设,外设就能正常工作了,这些就是这个I2C外设的基本结构图。

主机发送

在这里插入图片描述

主机接收

在这里插入图片描述

硬件I2C读写MPU6050代码部分

软件I2C与硬件I2C的区别,就在通信的底层,也就是之前写的MyI2C.c这个文件,之前都是用程序手动翻转引脚,也就是软件I2C,那有了硬件,这些底层的东西,就可以交给硬件来完成,所以现在这个工程就不需要MyI2C的模块了,要把MyI2C这个模块移出工程。

由于我们只替换最底层的通信层,所以后面这些基于通信层的芯片配置和读取数据,这些逻辑,都不需要更改。

第一步,配置I2C外设,对I2C外设进行初始化,来替换之前的MyI2C_Init( );
第二步,控制外设电路,实现指定地址写的时序,来替换之前的MPU6050_WriteReg
第三步,控制外设电路,实现指定地址读的时序,来替换之前的ReadReg

初始化

void MPU6050_Init(void)
{
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
	
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD;//复用开漏模式
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOB, &GPIO_InitStructure);
	
	I2C_InitTypeDef I2C_InitStructure;
	I2C_InitStructure.I2C_Mode = I2C_Mode_I2C;//I2C的模式
	I2C_InitStructure.I2C_ClockSpeed = 50000;
	/*时钟速度,这个参数可以配置SCl的时钟频率,数值越大,
	SCL频率越高,数据传输就越快。比如50000就是50kHz*/
	I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2;
	/*时钟占空比,这个时钟占空比参数,只有在时钟频率大于100kHz,
	也就是进入到快速状态时才有用,在小于等于100kHz的标准速度下,
	占空比是固定的1:1,也就是低电平时间比高电平时间,
	约等于1:1。而这里,就是低电平时间和高电平时间是2:1的比例关系。*/
	I2C_InitStructure.I2C_Ack = I2C_Ack_Enable;
	/*用于在确定一个字节后,是否给从机应答,而Enable,默认是给应答的*/
	I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
	/*这个是STM32作为从机,一般响应几位地址,这是STM32作为从机才会用到的*/
	I2C_InitStructure.I2C_OwnAddress1 = 0x00;
	/*自身地址1,这个也是STM32作为从机使用的,
	用于指定STM32的自身地址,方便别的主机呼叫它,
	如果上面响应7位地址,那么这里就给STM32指定一个自身的7位地址*/
	I2C_Init(I2C2, &I2C_InitStructure);
	
	I2C_Cmd(I2C2, ENABLE);//使能I2C
	
	MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);
	MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00);
	MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09);
	MPU6050_WriteReg(MPU6050_CONFIG, 0x06);
	MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);
	MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);
}

指定地址写一个字节

void MPU6050_WaitEvent(I2C_TypeDef* I2Cx, uint32_t I2C_EVENT)
{
	uint32_t Timeout;
	Timeout = 10000;
	while (I2C_CheckEvent(I2Cx, I2C_EVENT) != SUCCESS)
	/*比如while(I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT) != SUCCESS), 
	EV5事件不等于SUCCESS,就一直空循环等待,否则就跳出循环,
	但为了防止卡死,设计一个超时退出的机制*/
	{
		Timeout --;
		if (Timeout == 0)
		{
			break;
		}
	}
}

void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
	I2C_GenerateSTART(I2C2, ENABLE);//生成起始条件
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
	
	I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);//发送从机地址
	/*在这个库函数中,发送数据都自带了接收应答过程,
	同样,接收数据也自带了发送应答的过程,
	如果应答错误,硬件会通过置标志位和中断来提醒我们,
	所以,发送地址之后,应答位就不需要处理了*/
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);
	
	I2C_SendData(I2C2, RegAddress);//直接写入DR,发送数据
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING);
	
	I2C_SendData(I2C2, Data);
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);
	
	I2C_GenerateSTOP(I2C2, ENABLE);//终止时序
}

指定地址读一个字节

uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
	uint8_t Data;
	
	I2C_GenerateSTART(I2C2, ENABLE);
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
	
	I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);
	
	I2C_SendData(I2C2, RegAddress);
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);
	
	I2C_GenerateSTART(I2C2, ENABLE);
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
	
	I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Receiver);
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED);
	
	I2C_AcknowledgeConfig(I2C2, DISABLE);
	I2C_GenerateSTOP(I2C2, ENABLE);
	
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_RECEIVED);
	Data = I2C_ReceiveData(I2C2);
	
	I2C_AcknowledgeConfig(I2C2, ENABLE);
	
	return Data;
}

如果是读取多个字节,那就直接等待EV7事件,读取DR,就能收到数据了,这样依次接收,在接收最后一个字节之前,也就是这里的EV7_1事件,需要提前把ACK置0,STOP置1,如果只需要读取一个字节,那在EV6事件之后,就要立刻ACk置0,STOP置1,要不然你设置晚了,时序上就会多一个字节出来。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值