关于 I2C 通信的内容主要分为 两大块。
- 第一块:介绍协议规则,然后用软件模拟的形式来实现协议。
- 第二块:介绍 STM32 的 I2C 外设,然后用硬件来实现协议。
因为 I2C 是同步时序,软件模拟协议也非常方便,目前也存在很多软件模拟 I2C 的代码,所以我们先介绍软件 I2C,再介绍硬件 I2C,至于哪个更方便,各自的优势和劣势,等介绍完后你应该会自有定论。
本节我们会使用 MPU6050 陀螺仪、加速器传感器来学习 I2C,大家可以对比一下 I2C 在不同器件的应用有什么异同,也可以加深大家对 I2C 协议的理解。
I2C 的设计背景
在学 I2C 之前,我们已经学习了串口通信,串口通信,就是从 TX 引脚向 RX 引脚发送数据流,数据流以字节为单位,我们可以组合多个字节,变成多字节的数据包传输。另外串口通信的设计是:一条发送线、一条接收线,没有时钟线的异步全双工的协议。这是我们学习串口通信的时候了解到的。
假如一个公司开发出了一款芯片,可以干很多事情,比如 AD 转换、温湿度测量、姿态测量等等,像我们单片机一样,这个芯片里的众多外设,也都是通过读写寄存器来控制运行的,寄存器本身也是存储器的一种,这个芯片所有的寄存也都是被分配到了一个线性的存储空间,如果我们想要读写寄存器来控制硬件电路,我们就至少需要定义两个字节的数据,一个字节是我们要读写哪个寄存器,也就是指定寄存器的地址,另一个字节就是这个地址下存储器存的内容。写入内容就是控制电路,读出内容就是获取电路状态,这整个流程和我们单片机 CPU 操作外设的原理是一样的。
那现在问题来了,单片机读写自己的寄存器,可以直接通过内部的数据总线来实现,直接用指针操作就行,不需要我们操心;但是现在这个模块的寄存器在单片机的外面,你要是直接把单片机内部的数据总线拽出来,把两个芯片合为一体,那可能不太现实。所以现在公司要求你给它设计一种通信协议,在单片机和外部模块连接少量的几根线,实现单片机读写外部模块寄存器的功能,这时你可能会想,这不太简单了,我们就用串口的数据包通信就可以完成任务,比如我就用 HEX 数据包,定义一个 3 个字节的数据包,从单片机向外挂模块发过去,第一个字节,表示读写,发送 0,表示这是一个写数据包,发送 1,表示这是一个读数据包;第二个字节,表示读写的地址;第三个字节,表示写入的数据。比如我发送数据包为 0x00,0x06,0xAA,这就表示在 0x06 地址下写入 0xAA,模块收到之后,就执行这个写入操作;如果我发送数据包为 0x01,0x06,0x00,这就表示我要读取 0x06 地址下的数据,注意,这个读的数据包第 3 个字节无效。模块收到之后,就要再给我发送一个字节,返回 0x06 地址下的数据,这样就行了,是不是完美完成任务啊。
但是呢,这个公司对这个通信协议的要求非常多。其中,
- 要求 1:目前串口这个设计,是一个需要两根通信线的全双工协议,但是可以明显地发现,我们这个操作流程是一种基于对话的形式来进行的,我们在整个过程中并不需要同时进行发送和接收,发送的时候就不需要接收,接收的时候就不需要发送,这样就会导致,始终存在一条信号线处于空闲状态,这就是资源的浪费。所以要求 1 就是 删掉一根通信线,只能在同一根线上进行发送和接收,也就是把全双工变成半双工。
- 要求 2:我们这个协议并没有一个应答机制,也就是单片机发送了一个数据,对方有没有收到,单片机是完全不了解的,所以为了安全起见,公司要求增加应答机制,要求每发送一个字节,对方都要给我一个应答;每接收一个字节,我也要给对方一个应答。
- 要求 3:公司说你这一根线只能接一个模块,不给力,它要求,你这一根线上能同时接多个模块,单片机可以指定,和任意一个模块进行通信,同时单片机在跟某个模块进行通信时,其他模块不能对正常的通信产生干扰。
- 要求 4:这个串口是异步的时序,也就是发送方和接收方约定的传输速率是非常严格的,时钟不能有过大的偏差,也不能说是,在传输过程中,单片机有点事,进中断了,这个时序能不能暂停一下啊,对于异步时序来说,这是不行的,你单片机一个字节发一半暂停了,接收方可是不知道的,它仍然会按照原来的那个约定的速率读取,这就会导致传输出错。
所以异步时序的缺点就是:非常依赖硬件外设的支持,必须要有 USART 电路才能方便的使用,如果没有 USART 硬件电路的支持,那么串口是很难用软件来模拟的,虽然说软件模拟串口通信也是行的通的,但是由于异步时序对时间要求很严格,一般我们很少用软件来模拟串口通信。
所以公司的要求是,你要把这个协议改成同步的协议,另外加一条时钟线来指导对方读写,由于存在时钟线,对传输的时间要求就不高了,单片机也可以随时暂停传输,去处理其他事情,因为暂停传输的同时,时钟线也暂停了,所以传输双方都能定格在暂停的时刻,可以过一段时间再来继续,不会对传输造成影响,这就是同步时序的好处。使用同步时序就可以极大地降低单片机对硬件电路的依赖,即使没有硬件电路的支持,也可以很方便的用软件手动翻转电平来实现通信。(比如 51 单片机里没有 I2C 的硬件外设,但是同样不影响 51 单片机进行软件模拟的 I2C 通信)
异步时序:
- 好处:就是省一根时钟线,节省资源;
- 缺点:就是对时间要求严格,对硬件电路的依赖比较严重。
同步时序:
- 好处:就是反过来,对时间要求不严格,对硬件电路不怎么依赖。在一些低端单片机,没有硬件资源的情况下,也很容易使用软件来模拟时序;
- 缺点就是多一根时钟线。
这就是同步和异步的区别。公司考虑到这个协议要主打下沉市场,所以它需要一个同步的协议。
其实通信协议就是一个很灵活的设计方案,并没有很严格的要求,说它必须是这样;只要你的设计能实现项目要求、符合电路原理、性能和稳定性好,那你的设计就是好设计。
项目要求:
- 最基本的任务是:通过通信线,实现单片机读写外挂模块寄存器的功能,其中至少要实现,在指定的位置写寄存器和在指定的位置读寄存器,这两个功能,实现了读写寄存器,就实现了对这个外挂模块的完全控制。
- 另外刚才说的公司的 4 点要求,也别忘了,必须要满足公司的要求才行。
1. I2C 通信简介
1. 1 I2C 的基本功能
I2C(Inter IC Bus,缩写 IIC / I2C,一般习惯称为 I2C)是由 Philips 公司开发的一种通用数据总线
目前应用还是非常广泛的,已经有很多模块都使用了 I2C 的协议标准了。比如我们套件里的 MPU6050 模块,可以进行姿态测量,使用了 I2C 通信协议;我们套件里的 OLED 模块,可以显示字符、图片等信息,也是 I2C 协议;AT24C02 存储器模块(51 单片机中学习 I2C 的模块);DS3231 实时时钟模块,也是使用 I2C 通信;等等。还有很多模块,都支持 I2C 通信,使用了这个通用的协议,对于我们开发者来说,就非常方便了是吧,同样的协议在不同的硬件上,操作方法都是极为相似的,学会了其中一个硬件,再学其他的硬件就很容易了。
两根通信线:SCL(Serial Clock)、SDA(Serial Data)
那 I2C 的标志性引脚,就是两根通信线,SCL,全称 Serial Clock,串行时钟线;SDA,全称 Serial Data,串行数据线。使用 I2C 通信的器件,都有 SCL 和 SDA 这两个引脚。那 SCL 时钟线,就满足了刚才公司提出的要求 4,要使用同步的时序,降低对硬件的依赖,同时同步的时序稳定性也比异步时序更高。然后只有一根 SDA 数据线,就满足了公司提出的要求 1,变全双工为半双工,一根线兼具发送和接收,最大化利用资源,所以我们可以看到下一条。
同步,半双工
带数据应答
满足了设计要求 2
支持总线挂载多设备(一主多从、多主多从)
满足了设计要求 3。并且这个挂载多设备,支持两种模型,一主多从和多主多从,一主多从的意思就是,单片机作为主机,主导 I2C 总线的运行,挂载在 I2C 总线的所有外设模块都是从机,从机只有被主机点名之后才能控制 I2C 总线,不能在未经允许的情况下去碰 I2C 总线,防止冲突。这就像在教室里,老师是主机,主导课程的进行,所有学生都是从机,所有从机可以同时被动的听老师讲课,但是从机只有在被老师点名之后才能说话,不可以在未经允许的情况下说话,这样课堂才能有条不紊的进行,这就是一主多从的模式。我们使用 I2C 的绝大多数场景都是一主多从的模式,一个单片机作为主机,挂载一个或多个模块作为从机,另外 I2C 其实还支持多主多从的模型,也就是多个主机。多主多从的模型,在总线上任何一个模块都可以主动跳出来,说,接下来我就是主机,你们都得听我的,这就像是在教室里,老师正在讲课,突然有个学生站起来说,老师打断一下,接下来让我来说,所有同学听我指挥,但是,同一个时间只能有一个人说话,这时就相当于发生了总线冲突,在总线冲突时,I2C 协议会进行仲裁,仲裁胜利的一方取得总线控制权,失败的一方自动变回从机。当然由于时钟线也是由主机控制的,所以在多主机的模型下,还要进行时钟同步,多主机的情况下,协议是比较复杂的,大家感兴趣可以自行了。我们本节仅使用一主多从的模型,多主多从的部分不做要求。
1.2 I2C 硬件规定
功能实现原理:
作为一个通信协议,他必须要在硬件和软件上都作出规定。硬件上的规定,就是你的电路应该如何连接,端口的输入输出模式都是啥样的,这些东西;软件上的规定,就是你的时序是怎么定义的,字节如何传输,高位先行还是低位先行,一个完整的时序有哪些部分构成,这些东西。硬件的规定和软件的规定配合起来就是一个完整的通信协议。
也就是硬件电路部分。
这个图就是 I2C 的一个典型的电路模型,是一个一主多从的模型。左边 CPU 就是我们的单片机,作为总线的主机,主机的权力很大,包括对 SCL 线的完全控制,任何时候,都是主机完全掌握 SCL 线,另外在空闲状态下,主机可以主动发起对 SDA 的控制,只有在从机发送数据和从机应答的时候,主机才会转交 SDA 的控制权给从机,这就是主机的权力。
下面这一系列都是被控 IC,也就是挂载在 I2C 总线上的从机,这些从机可以是姿态传感器、OLED、存储器、时钟模块等等,从机的权力比较小,对于 SCL 时钟线,在任何时刻都只能被动的读取,从机不允许控制 SCL 线,对于 SDA 数据线,从机不允许主动发起对 SDA 的控制,只有在主机发送读取从机命令后,或者从机应答的时候,从机才能短暂的取得 SDA 的控制权,这就是一主多从模型中协议的规定。
然后看接线要求:
- 所有 I2C 设备的 SCL 连在一起,SDA 连在一起
在图里,主机 SCL 线一条拽出来,所有从机的 SCL 都接在这上面;主机 SDA 线也是一样,拽出来,所有从机的 SDA 都接在这上面,这就是 SCL 和 SDA 的接线方式。
- 设备的SCL和SDA均要配置成开漏输出模式
- SCL和SDA各添加一个上拉电阻,阻值一般为4.7KΩ左右
先忽略两个电阻,假设我们就这样连接,那如何规定每个设备 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 的硬件电路设计。
1.3 I2C 软件
接下来,我们就要来学习软件,也就是时序的设计了。首先我们来学习下 I2C 规定的一些时序基本单元。
1.3.1 起始条件与结束条件
首先就是起始条件:SCL高电平期间,SDA从高电平切换到低电平。
看一下上图的左边,在 I2C 总线处于空闲状态时,SCL 和 SDA 都处于高电平状态,也就是没有任何一个设备去碰 SCL 和 SDA,SCL 和 SDA 有外挂的上拉电阻拉高至高电平,总线处于平静的高电平状态。当主机需要进行数据收发时,首先就要打破总线的宁静,产生一个起始条件。这个起始条件就是 SCL 处于高电平不去动它,然后把 SDA 拽下来,产生一个下降沿,当从机捕获到这个 SCL 高电平,SDA 下降沿信号时,就会进行自身的复位,等待主机的召唤,然后在 SDA 下降沿之后,主机要再把 SCL 拽下来,拽下 SCL,一方面是占用这个总线,另一方面也是为了方便我们这些基本单元的拼接,就是我们之后会保证,除了起始和终止条件,每个时序单元的 SCL 都是以低电平开始,低电平结束,这样这些单元拼接起来,SCL 才能续的上是吧。
终止条件:SCL高电平期间,SDA从低电平切换到高电平
然后继续看,终止条件是 …。也就是这样, SCL 先放手,回弹到高电平,SDA 再放手,回弹高电平,产生一个上升沿,这个上升沿触发终止条件,同时终止条件之后,SCL 和 SDA 都是高电平,回归到最初的平静状态,这个起始条件和终止条件就类似串口时序里的起始位和停止位。一个完整的数据帧,总是以起始条件开始、终止条件结束,另外,起始和终止,都是由主机产生的,从机不允许产生起始和终止。所以在总线空闲状态时,从机必须始终双手放开,不允许主动跳出来,去碰总线,如果允许的话,那就是多主机模型了,不在本机的讨论范围之内。
这就是起始条件和终止条件。
1.3.2 发送一个字节与接收一个字节
接着继续看,在起始条件之后,这时就可以紧跟着一个发送一个字节的时序单元。如何发送一个字节呢?就是 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低电平期间,从机将数据位依次放到SDA线上(高位先行),然后释放SCL,主机将在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许有数据变化,依次循环上述过程8次,即可接收一个字节(主机在接收之前,需要释放SDA)。
刚才我们说了,释放 SDA 其实就相当于切换成输入模式了,或者这样来理解,所有设备包括主机都始终处于输入模式,当主机需要发送的时候,就可以主动去拉低 SDA,而主机在被动接收的时候,就必须先释放 SDA,不要去动它,以免影响别人发送,因为总线是线与的特征,任何一个设备拉低了,总线就是低电平;如果你接收的时候,还拽着 SDA 不放手,那别人无论发什么数据,总线都始终是低电平,你自己给它拽着不放,还让别人怎么发送呢,是吧。所以主机在接收之前,需要释放 SDA。
从流程上来看,接收一个字节和发送一个字节是非常相似的,区别就是发送一个字节是低电平主机放数据,高电平从机读数据,而接收一个字节是低电平从机放数据,高电平主机读数据。
然后看一下时序图,和上面的基本一样,区别就是 SDA 线,主机在接收之前要释放 SDA,然后这是从机就取得了 SDA 的控制权,从机需要发送 0,就把 SDA 拉低,从机需要发送 1,就放手,SDA 回弹高电平,然后同样的,低电平变换数据,高电平读取数据,这里实线部分表示主机控制的电平,虚线部分表示从机控制的电平,SCL 全程由主机控制,SDA 主机在接收前要释放,交由从机控制,之后还是一样,因为 SCL 时钟是由主机控制的,所以从机的数据变换基本上都是贴着 SCL 下降沿进行的,而主机可以在 SCL 高电平的任意时刻读取,这就是接收一个字节的时序。
1.3.3 应答机制(发送应答和接收应答)
那我们再继续看最后两个基本单元,就是应答机制的设计。首先应答机制分为发送应答和接收应答。它们的时序分别和发送一个字节、接收一个字节的其中一位是相同的。你可以理解成发送一位和接收一位,这一位就用来作为应答。
首先是发送应答:主机在接收完一个字节之后,在下一个时钟发送一位数据,数据0表示应答,数据1表示非应答
然后是接收应答:主机在发送完一个字节之后,在下一个时钟接收一位数据,判断从机是否应答,数据0表示应答,数据1表示非应答(主机在接收之前,需要释放SDA)
这个意思就是,我们在调用发送一个字节之后,就要紧跟着调用接收应答的时序,用来判断从机有没有收到刚才给它的数据。如果从机收到了,那在应答位这里,主机释放 SDA 的时候,从机就应该立刻把 SDA 拉下来,然后在 SCL 高电平期间,主机读取应答位,如果应答位为 0,就说明从机确实收到了。这个场景就是,主机刚发送一个字节,然后说,有没有人收到啊,我现在把 SDA 放手了,如果有人收到的话,就把 SDA 拽下来,然后主机高电平读取数据,发现确实有人给它拽下来了,那就说明有人收到了,如果主机发现,我松手了,结果这个 SDA 就跟着回弹到高电平了,那就说明没有人回应我,刚发的一个字节可能没人收到,或者它收到了但是没给我回应,这就是发送一个字节,接收应答的流程。同理,在接收一个字节之后,我们也要给从机发送一个应答位,发送应答位的目的是告诉从机,你是不是还要继续发,如果从机发送一个数据后,得到了主机的应答。那从机就还会继续发送,如果从机没得到主机的应答,那从机就会认为,我发送了一个数据,但是主机不理我,可能主机不想要了吧,这时从机就会乖乖的释放 SDA,交出 SDA 的控制权,防止干扰主机之后的操作。这就是应答位的执行逻辑。
好,那到这里,我们 I2C 的 6 块拼图就已经集齐了。分别是起始条件、终止条件、发送一个字节、接收一个字节、发送应答、接收应答。
1.4 I2C 完整时序
接下来我们就来拼接这些基本单元,组成一个完整的数据帧吧。I2C 的完整时序,主要有指定地址写,当前地址读和指定地址读这 3 种。
我们这个 I2C 是一主多从的模型,主机可以访问总线上的任何一个设备,那如何发送指令,来确定要访问的是哪个设备呢?这就需要首先把每个从设备都确定一个唯一的设备地址,从机设备地址就相当于每个设备的名字。主机在起始条件之后,要先发送一个字节叫一下从机名字,所有从机都会收到第一个字节,和自己的名字进行比较,如果不一样,则认为主机没有叫我,之后的时序我就不管了;如果一样,就说明,主机现在在叫我,那我就响应之后主机的读写操作,在同一条 I2C 总线里,挂载的每个设备地址必须不一样,否则,主机叫一个地址,有多个设备都响应,那不就乱套了么?是吧。从机设备地址,在 I2C 协议标准里分为 7 位地址和 10 位地址,我们目前只讲 7 位地址的模式,因为 7 位地址比较简单而且应用范围最广。那在每个 I2C 设备出厂时,厂商都会为它分配一个 7 位的地址,这个地址具体是什么,可以在芯片手册里找到,比如我们 MPU6050 这个芯片的 7 位地址,是 1101 000,之前我们学习 AT24C02 的 7 位地址,是 1010 000,一般不同型号的芯片地址都是不同的,相同型号的芯片地址都是一样的,那如果有相同的芯片挂载在同一条总线怎么办呢?这就需要用到地址中的可变部分了,一般器件地址的最后几位是可以在电路中改变的,比如 MPU6050 地址的最后一位,就可以由这个板子上的 AD0 引脚确定,这个引脚接低电平,那它的地址就是 1101 000,这个引脚接高电平,那它的地址就是 1101 001;比如 AT24C02 地址的最后 3 位,都可以分别由这个板子上的 A0、A1、A2 引脚确定。比如 A0 引脚接低电平,地址对应的位就是 0,接高电平,地址对应的位就是 1,A1、A2 也是同理,一般 I2C 的从机设备地址,高位都是厂商确定的,低位可以由引脚来灵活切换。这样,即使相同型号的芯片,挂载在同一个总线上,也可以通过切换地址低位的方式,保证每个设备的地址都不一样,这就是 I2C 设备的从机地址。
然后我们来看一下 I2C 时序。
- 第一个时序,是指定地址写
它完成的任务是,对于指定设备(指定设备,通过 Slave Address,从机地址来确定),在指定地址(就是某个设备内部的 Reg Address,寄存器地址)下,写入指定数据(就是要在这个寄存器中写入 Data 数据)。
这个时序是在示波器下实际抓到的波形,大家也可以用逻辑分析仪抓这个波形,而且逻辑分析仪还自带协议解析的功能,还是非常方便的。我们来一一分析一下:
在这里,上面的线是 SCL,下面的线是 SDA。
- 空闲状态,它两都是高电平
- 主机需要给从机写入数据的时候:
- 首先 SCL 高电平期间,拉低 SDA,产生起始条件(Start,S),在起始条件之后,紧跟着的时序,必须是发送一个字节的时序,字节的内容,必须是从机地址 + 读写位,正好是从机地址是 7 位,读写位是 1 位,加起来是一个字节,8 位。发送从机地址,就是确定通信的对象;发送读写位,就是确认我接下来是要写入还是要读出。
- 具体发送的时候,低电平期间,SDA 变换数据,高电平期间,从机读取 SDA,图中用绿色的线来标明了从机读到的数据,比如图中的波形,那从机收到的第一位就是高电平 1,然后 SCL 低电平期间,主机继续变换数据,因为第二位还是 1,所以这里 SDA 电平没有变换,然后 SCL 高电平,从机读到第二位是 1,之后继续,低电平变换数据,高电平读取数据,第三位就是 0,这样持续 8 次,就发送了一个字节数据。其中,这个数据的定义是,高 7 位表示从机地址,比如图中波形下,主机寻找的从机地址就是 1101 000,这个就是 MPU6050 的地址,然后最低位,表示读写位。0 表示之后的时序主机要进行写入操作;1 表示之后的时序主机要进行读出操作,这里是 0,说明之后我们要进行写入操作,那目前主机发送了一个字节,字节的内容转换为 16 进制,高位先行,就是 0xD0。
- 然后根据协议规定,紧跟着的单元,就得是接收从机的应答位(Receive Ack,RA)。在这个时刻,主机要释放 SDA,所以如果单看主机的波形,释放 SDA 之后,引脚电平回弹到高电平,但是根据协议规定,从机要在这个位拉低 SDA,所以单看从机的波形,该应答的时候,从机立刻拽住 SDA,然后应答结束之后,从机再放开 SDA。那现在综合两者的波形,结合线与的特性,在主机释放 SDA 之后,由于 SDA 也被从机拽住了,所以主机松手后,SDA 并没有回弹高电平,这个过程,就代表从机产生了应答。最终高电平期间,主机读取 SDA,发现是 0,就说明,我进行寻址,有人给我应答了,传输没问题。如果主机读取 SDA 发现是 1,就说明,我进行寻址,应答位期间,我松手了,但是没人拽住它,没人给我应答,那就直接产生停止条件吧,并提示一些信息。这就是应答位。
- 之后的 SDA 上升沿就是应答位结束后,从机释放 SDA 产生的,从机交出了 SDA 控制权,因为从机要在低电平尽快变化数据,所以这个上升沿和 SCL 的下降沿,几乎是同时发生的。
- 然后继续往后,由于之前我们读写位给了 0,所以应答结束后,我们要继续发送一个字节,同样的时序,再来一遍,第二个字节,就可以送到指定设备的内部了。从机设备可以自己定义第二个字节和后续字节的用途,一般第二个字节可以是寄存器地址或者是指令控制字等,比如 MPU6050 定义的第二个字节就是寄存器地址;比如 AD 转换器,第二个字节可能就是指令控制字;比如存储器,第二个字节可能就是存储器地址。图中主机发送的波形,我们一一判定,数据为 0001 1001。即主机向从机发送了 0x19 这个数据,在 MPU6050 里,就表示我要操作你 0x19 地址下的寄存器了。
- 接着同样,是从机应答,主机释放 SDA,从机抓住 SDA,SDA 表现为低电平,主机收到应答位为 0,表示收到了从机的应答。
- 然后继续,同样的流程再来一遍。主机再发送一个字节,这个字节就是主机想要写入到 0x19 地址下寄存器的内容了,比如我这里发送了 0xAA 的波形,就表示,我要在 0x19 地址下,写入 0xAA。
- 最后是接收应答位,如果主机不需要继续传输了,就可以产生停止条件(Stop,P)。在停止条件之前,先拉底 SDA,为后续 SDA 的上升沿做准备,然后释放 SCL,再释放 SDA,这样就产生了 SCL 高电平期间,SDA 的上升沿,这样一个完整的数据帧就拼接完成了。这个数据帧的目的就是对于指定从机地址为 1101 000 的设备,在其内部 0x19 地址的寄存器中,写入 0xAA 这个数据,这就是指定地址写的时序。
接下来我们继续看下一个时序。
- 第二个时序,是当前地址读
它完成的任务是对于指定设备(Slave Address),在当前地址指针指示的地址下,读取从机数据(Data)
这个时序是当前地址读的时序,如果主机想要读取从机的数据,就可以执行这个时序:
- 那最开始还是 SCL 高电平期间,拉低 SDA,产生起始条件(Start,S),起始条件开始后,主机必须首先调用发送一个字节,来进行从机的寻址何指定读写标志位。比如图中波形下,表示本次寻址的目标是 1101 000 的设备,同时最后一位读写标志位为 1,表示主机接下来想要读取数据。
- 紧跟着发送一个字节之后,接收一下从机的应答位(Receive Ack,RA)。从机应答 0,代表从机收到了第一个字节。
- 在从机应答之后,数据的传输方向就要反过来。因为刚才主机发出了读的指令,所以这之后,主机就不能继续发送了,要把 SDA 的控制权交给从机,主机调用接收一个字节的时序,进行接收操作。
- 然后从机就得到了主机的允许,可以在 SCL 低电平期间写入 SDA,然后主机在 SCL 高电平期间读取 SDA。那最终,主机在 SCL 高电平期间依次读取 8 位,就接收到了从机发送的一个字节数据 0000 1111,也就是 0x0F。
那现在问题就来了,这个 0x0F 是从机哪个寄存器的数据呢?
我们看到,在读的时序中,I2C 协议的规定是,主机进行寻址时,一旦读写标志位给 1 了,下一个字节就要立马转为读的时序,所以主机还来不及指定,我想要都哪个寄存器,就得开始接收了,所以这里就没有指定地址这个环节。那主机并没有指定寄存器的地址,从机到底该发哪个寄存器的数据呢?这就需要用到我们上面说的当前地址指针了。在从机中,所有的寄存器被分配到了一个线形区域中,并且,会有一个单独的指针变量,指示着其中一个寄存器。这个指针上电默认,一般指向 0 地址。并且,每写入一个字节和读出一个字节后,这个指针就会自动自增一次。移动到下一个位置,那么在调用当前地址读的时序时,主机没有指定要读哪个地址,从机就会返回当前指针指向的寄存器的值。
那假设,我刚刚调用了这个指定地址写的时序,在 0x19 的位置写入 0xAA,那指针就会 +1,移动到 0x1A 的位置,我再调用这个当前地址读的时序,返回的就是 0x1A 地址下的值,如果再调用一次呢?返回的就是 0x1B 地址下的值,以此类推。这就是当前地址读时序的操作逻辑。
由于当前地址读并不能指定读的地址,所以这个时序用的不是很多。
那最后,我们就继续来看下一个时序。
- 第三个时序,是指定地址读
它完成的任务是对于指定设备(Slave Address),在指定地址(Reg Address)下,读取从机数据(Data)
那这个时序为什么能指定读的地址呢?我们看一下指定地址写:
- 在指定地址写的前面一部分,就是指定地址的时序,我们把最后面的写数据的这一部分给去掉,然后把前面这一段设置地址,还没有指定写什么数据的时序,给它追加到这个当前地址读时序的前面,就得到了指定地址读的时序。一般我们也把它称作复合格式。
- 下面的时序在 RA 和 Sr 之间分隔一下。前面的部分是指定地址写,但是只指定了地址,还没来得及写,后面的部分是当前地址读,因为我们刚指定了地址,所以再调用当前地址读,两者加在一起,就是指定地址读了,所以指定地址读的时序会复杂一些。我们来详细分析一下看看:
- 首先最开始,仍然是启动条件,然后发送一个字节,进行寻址,这里指定从机地址是 1101 000,读写标志位是 0,代表我要进行写的操作。经过从机应答之后,再发送一个字节,第二个字节,用来指定地址,这个数据就写入到从机的地址指针里了,也就是说,从机接收到这个数据之后,它的寄存器指针就指向了 0x19 这个位置。
- 之后,我们要写入的数据,不给它发,而是直接再来个起始条件,这个 Sr(Start Repeat)的意思就是重复起始条件,相当于另起一个时序,因为指定读写标志位只能是跟着起始条件的第一个字节。所以如果想切换读写方向,只能再来个起始条件,然后起始条件后,重新寻址并且指定读写标志位,此时读写标志位是 1,代表我要开始读了,接着,主机接收一个字节,这个字节是不是就是 0x19 地址下的数据,这就是指定地址读。
- 另外在 RA 和 Sr 之间,你也可以再加一个停止条件,这样也行,这样的话,就是两个完整的时序了。先起始,写入地址,停止,因为写入的地址会存在地址指针里面,所以这个地址并不会因为时序的停止而消失,我们就可以再起始,读当前位置,停止,这样两条时序也可以完成任务,但是 I2C 协议官方规定的复合格式是一整个数据帧。就是先起始,再重复起始,再停止,相当于把两条时序拼接成一条了。
这些就是这 3 个 I2C 完整时序的介绍了。其中第一个指定地址写和第三个指定地址读用的比较多,也是我们本节代码使用的时序。
然后除了这 3 个时序,I2C 还有这些时序的进阶版本,大概介绍一下。就是我们这些时序,指定地址写,只是写一个字节,当前地址读和指定地址读,也都是读一个字节,那进阶版本就是指定地址写多个字节,当前地址读多个字节和指定地址读多个字节。时序上呢,和这些都非常相似,只需要增加一些小细节就行。我们看一下:
- 在指定地址写中,指定地址,然后写入一个字节,如果你只想写一个字节,那就停止,就行了,如果你想写多个字节,就可以把这最后一部分,多重复几次,比如重复三遍发送一个字节和接收应答,这样第一个数据就写入到了指定地址 0x19 的位置,然后不要忘了刚才说的,写入一次数据后,地址指针会自动 +1,变成 0x1A,所以这第二个数据就写入到了 0x1A 的位置。同理,第三个数据就写入的是 0x1B 的位置,以此类推,这样这个时序就进阶为,在指定的位置开始,按顺序连续写入多个字节。比如你需要连续写入多个寄存器。就可以考虑这样来操作。这样在一条数据帧里,就可以同时写入多个字节,执行效率就会比较高。
- 然后同理,当前位置读和指定位置读,也可以多次执行这最后一部分时序,由于地址指针在读后也会自增,所以这样就会连续读出一片区域的寄存器,效率也会非常高。然后这里还要注意一下,如果你只想读一个字节就停止的话,在读完一个字节之后,一定要给从机发个非应答(Send Ack,SA),非应答,就是该主机应答的时候,主机不把 SDA 拉低,从机读到 SDA 为 1,就代表主机没有应答。从机收到非应答之后,就知道主机不想要继续了,从机就会释放总线,把 SDA 控制权交还给主机。如果主机读完仍然给从机应答了,从机就会认为主机还想要数据,就会继续发送下一个数据,而这时,主机如果想产生停止条件,SDA 可能就会因为被从机拽住了,而不能正常弹回高电平,这个注意一下。如果主机想连续读取多个字节,就需要在最后一个字节给非应答,而之前的所有字节都要给应答,简单来说就是主机给应答了,从机就会继续发,主机给非应答了,从机就不会再发了,交出 SDA 控制权,从机控制 SDA 发送一个字节的权利,开始于读写标志位为 1,结束于主机给应答为 1,这就是主机给从机发送应答位的作用。
以上就是 I2C 总线的硬件规定和软件规定了,有了这些规定,我们就可以按照硬件规定来连接线路,用软件规定来操作总线,以此实现指定位置写寄存器和指定位置读寄存器。有了这两个功能,主机就可以完全掌控外挂模块的运行了,也就实现了我们设计这个协议的目的,那这就是 I2C 协议的内容。
2. MPU6050 简介
这一小节我们来了解 MPU6050 这个芯片,看看它是怎样工作的,有哪些寄存器,以及如何利用寄存器控制硬件电路的运行。另见 STM32 外设介绍章节。
3. 一个 软件 I2C 读写 MPU6050 功能案例
正如前两小节介绍的知识点那样,本小节的代码主要分为两个部分:第一部分,我们完成软件 I2C 协议的时序;第二部分,我们基于 I2C 协议,读写寄存器,来操控 MPU6050。
3.1 硬件电路
那先看一下本节代码的硬件电路
这是本节课的接线图,由于这个模块把各种基础电路都封装好了,所以接线图也是比较简单的。
MPU6050 模块,VCC 和 GND 分别接到了电源正负极进行供电。然后 SCL 引到了 STM32 的 PB10 号引脚,SDA 引到了 PB11 号引脚,由于我们这个代码使用的是软件 I2C,就是用普通的 GPIO 口,手动翻转电平实现的协议,它并不需要 STM32 内部的外设资源支持,所以这里的端口,其实可以任意指定,不局限于这两个端口,你也可以 SCL 接 PA0,SDA 接 PB12 或者 SCL 接 PB8,SDA 接 PB9,等等等等,接在任意的两个普通的 GPIO 口就可以。然后我们只需要在程序中,配置并操作 SCL 和 SDA 对应的端口就行了,这算是软件 I2C 相比硬件 I2C 的一大优势,就是端口不受限,可以任意指定。然后继续看,根据 I2C 协议的硬件规定,SCL 和 SDA 都应该外挂一个上拉电阻,但是这里并没有外挂一个上拉电阻,因为上一小节我们分析模块电路的时候提到过,这个模块内部自带了上拉电阻,所以外部的上拉电阻就不需要接了。
目前这里 STM32 是主机,MPU6050 是从机,是一主一从的模型。当然,主机和从机的执行逻辑是完全不同的,我们程序中,一般只关注主机端的程序,然后后面 XCL 和 XDA 用于扩展的接口,我们用不到;AD0 引脚,修改从机地址的最低位,我们等会写程序的时候可以试一下这个功能,这里由于模块内置了下拉电阻,所以引脚悬空的话,就相当于接地;最后 INT,中断信号输出脚,我们用不到,可以不接,那这就是硬件电路。
然后我们看一下面包板,来接一下线。接线完成后,插上 STLINK,MPU6050 模式上的电源指示灯亮起,如果你插上电之后,电源指示灯不亮,那赶紧断电检查一下是不是 VCC 和 GND 接反了,或者供电引脚没电,或者模块坏了,再排查一下问题。这些就是硬件电路部分。
3.2 代码整体框架
这个框架和 51 单片机的 I2C 是一样的。如果了解 51 单片机的 I2C 通信,那应该就有些印象。
- 我们首先建立 I2C 通信层的 .c 和 .h 模块,在通信层里,写好 I2C 底层的 GPIO 初始化 和 6 个时序基本单元,也就是起始、终止,发送一个字节、接受一个字节,发送应答和接收应答。
- 写好 I2C 通信层之后,我们再建立 MPU6050 的 .c 和 .h 模块,在这一层,我们将基于 I2C 通信的模块,来实现指定地址读、指定地址写,再实现写寄存器对芯片进行配置,读寄存器得到传感器数据。
- 最后在 main.c 里,调用 MPU6050 的模块,初始化,拿到数据,显示数据,这就是程序的整体架构。
那我们现在来开始写程序。
3.2.1 软件 I2C 模块
首先在 Hardware 目录下添加文件,为了防止和库函数里面的函数重名,这里模块名称,我就统一叫作 MyI2C。那这里,模块建好了,我们先写个初始化函数。那由于本代码要使用软件 I2C,所以这个库里这个 I2C 的库函数,我们就不用看了,软件 I2C,只需要用 GPIO 的读写函数就行了,所以库函数我们不用看的。然后
- 软件 I2C 初始化,我们要做两个任务。第一个任务,把 SCL 和 SDA 都初始化为开漏输出模式;第二个任务,把 SCL 和 SDA 置高电平。我们当前的接线,SCL 是 PB10,SDA 是 PB11,所以初始化开启时钟为 GPIOB,端口为 Pin_10 和 Pin_11,PB10 和 PB11 都要配置成开漏输出的模式。开漏输出,虽然名字上带了个输出,但这并不代表它只能输出,开漏输出模式仍然可以输入。输入时,先输入 1,再直接读取输入数据寄存器就行了,这个过程,我们在介绍 I2C 硬件规定时介绍过,那初始化结束之后,调用 SetBits,把 GPIOB 的 Pin_10 和 Pin_11 都置高电平,这样 I2C 初始化就完成了。
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 配置好默认低电平
GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11);//将 GPIO 配置为高电平
}
调用 MyI2C_Init 函数,PB10 和 PB11 两个端口就被初始化为开漏输出模式,然后释放总线,SCL 和 SDA 处于高电平,此时 I2C 总线处于空闲状态。然后接下来
- 我们就根据时序基本单元的波形,来完成 I2C 的 6 个时序基本单元。
- 第一个基本单元是起始条件,那对应写一个函数。
在这里面,看一下波形,这里我们首先把 SCL 和 SDA 都确保释放,然后先拉低 SDA,再拉低 SCL,这样就能产生起始条件了。
那在这里,我们可以不断的调用 SetBits 和 ResetBits 来手动翻转高低电平,但是这样做的话,会在后面的程序中,出现非常多的地方,来指定这个 GPIO 端口号。
- 一方面,这样做语义不是很明显。
- 另一方面,如果我们之后需要换一个端口,那就需要改动非常多的地方。
所以这时,我们就需要在上面做个定义。 把这个端口号统一替换一个名字,这样无论是语义还是端口的修改,都会非常方便,那给端口号换一个名字呢,有很多方法都能实现功能,在 51 单片机中,我们一般用 sbit 来定义端口的名称,但是 sbit 并不是标准 C 语言的语法,STM32 也不支持这么做。
- 那这里,一种简单的替换方法就是宏定义,比如
#define SCL_PORT GPIOB
,#define SCL_PIN GPIO_Pin_10
。之后,如果想释放 SCL,就GPIO_SetBits(SCL_PORT, SCL_PIN);
SCL_PORT 就是 GPIOB,SCL_PIN 就是 GPIO_Pin_10,这样语义比较明确。而且修改引脚的时候,直接在上面修改一下宏定义,下面所有应用宏定义的地方,都会自动更改,这是一种简单可行的方法。在 STM32 程序中国也是挺常见的一个操作,大家可以了解一下。- 那进一步的,如果你觉得每次都需要定义 PORT 和 PIN,比较麻烦,还可以把这整个函数用宏定义进行替换,比如在 OLED 程序里,就使用了这种方法。例如:
#define OLED_W_SCL(x) GPIO_WriteBit(GPIOB, GPIO_Pin_8, (BitAction)(x))
这里,我直接用宏定义把 GPIO_WriteBit 包括参数在内的整个函数,用宏定义替换了个名字,新的名字叫做 OLED_W_SCL,之后我们再需要操作 SCL 的时候,就可以使用这个新名字,这样函数比较简短,语义比较明确。并且这里使用了带参数的宏定义,也就是有参宏,有参宏是这样使用的:在宏定义后面加一个括号,里面写入形参,那在实际应用的时候呢,比如调用 OLED_W_SCL,实参给 1,那替换的时候,实参 1 就对应形参 x,然后再进一步替换到函数里的 x,经过有参宏替换之后,OLED_W_SCL(1) 实际上就等效于 GPIO_WriteBit(GPIOB, GPIO_Pin_8, (BitAction)(1)),这两句是一样的效果,就是这个意思。
大家感兴趣的可以再百度了解了解。这是用有参宏替换整个函数的方法。但是这种方法比较难懂,在移植到其他库或者其他种类单片机时,很多人都不知道怎么修改,另外还有,这种宏定义的方法,如果换到一个主频很高的单片机中,需要对软件的时序进行延时操作的时候,也不太方便进一步修改。- 所以综合以上遇到过的宏定义替换的缺点。在这里,我就直接一点,干脆再套个函数得了。这样既容易理解,又方便加软件延时。
//所以在这里,我直接定义函数,对操作端口的库函数进行封装,第一个函数,
void MyI2C_W_SCL(uint8_t BitValue) {//W 代表写的意思
GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);
Delay_us(10);//写之后,我们延时个 10 us
}
这样套一个函数替换之后,我后面再调用这个 W_SCL,参数给 1 或 0,就可以释放或拉低 SCL 了。
如果说你要把这个程序移植到别的单片机,就可以把这个函数里的操作,替换为其他单片机对应的操作。比如 SCL 是 51 单片机 P10 口,就可以把这句替换为 P10 = BitValue; 这样就行了。
另外,如果你单片机主频比较快,这里也非常方便加一些延时。比如我这里要求每次操作引脚之后,都要延时 10us,那可以这样,先#include "Delay.h"
,再在这里Delay_us(10);
这样就能很方便的进行引脚延时操作了。
那对于 MPU6050 来说,经过实测,对于 STM32F1 系列,这里既是不加任何延时,这个引脚反转速度,MPU6050 也能跟得上,但是保险起见,我们还是延时个 10 us 吧。I2C 可以慢一些,多慢都行。但是快的话,就还是要看一下手册里对时序时间的要求。
//然后继续,我们复制一份这个函数,把 SDA 也封装一下。
void MyI2C_W_SDA(uint8_t BitValue) {
GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue);
Delay_us(10);
}
//另外我们还要再来个读 SDA 的函数,因为 STM32 库函数中,读和写不是同一个寄存器
uint8_t MyI2C_R_SDA(void) {//R 代表读的意思
//函数里面,先定义一个变量
uint8_t BitValue;
BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11);
Delay_us(10);//读之后,我们也延时个 10 us
return BitValue;//返回读到 SDA 线的电平。
}
有了这 3 个函数的封装,我们就实现了函数名称、端口号的替换。同时,也可以很方便的修改时序的延时,当我们需要替换端口,或者把这个程序移植到别的单片机中时,就只需要对这前 4 个函数里的操作对应更改。然后后面的函数,我们都调用这里封装的新名称进行操作,这样在移植的时候,后面的部分就不需要再进行修改了。好,那关于引脚操作的封装和改名,我们就完成了。
我们回到这里,继续写这个程序
void MyI2C_Start(void) {
//在这里,我就直接一点,干脆再套个函数得了。这样既容易理解,又方便加软件延时。
//1.在起始条件里,我们需要先把 SCL 和 SDA 都释放,也就是都输出 1
MyI2C_W_SDA(1);
MyI2C_W_SCL(1);
//2.然后,先拉底 SDA,再拉低 SCL
MyI2C_W_SDA(0);
MyI2C_W_SCL(0);
//这就是起始条件的执行逻辑。
}
那这里注意一下,在第一步中,我们最好把释放 SDA 的放在前面,这样保险一些。如果起始条件之前 SCL 和 SDA 已经是高电平了,那先释放哪一个是一样的效果。
但是在这里,我们这个 Start 还要兼容这里的重复起始条件 Sr。Sr 最开始,SCL 是低电平,SDA 电平不敢确定。
所以保险起见,我们趁 SCL 是低电平,先确保释放 SDA,再释放 SCL,这时 SDA 和 SCL 都是高电平,然后再拉低 SDA、拉低 SCL,这样这个 Start 就可以兼容起始条件和重复起始条件了,所以我们这个起始条件是这个逻辑。
- 接下来继续,第二个基本单元是终止条件,那对应写一个函数。
在这里面,看一下波形,如果 Stop 开始时,SCL 和 SDA 都已经是低电平了,那就先释放 SCL,再释放 SDA 就行了,但是在这个时序单元开始时,SDA 并不一定是低电平,所以为了确保之后释放 SDA 能产生上升沿,我们要在时序单元开始时,先拉低 SDA,然后再释放 SCL,释放 SDA。
所以 Stop 的逻辑是:先拉低 SDA,再释放 SCL,释放 SDA
void MyI2C_Stop(void) {
//Stop 的逻辑是:先拉低 SDA,再释放 SCL,释放 SDA
MyI2C_W_SDA(0);
MyI2C_W_SCL(1);
MyI2C_W_SDA(1);
//这就是终止条件的执行逻辑。终止条件之后,SCL 和 SDA 都回归到高电平。
}
终止条件之后,SCL 和 SDA 都回归到高电平。
- 然后第三个基本单元是发送一个字节,那对应写一个函数。
发送一个字节时序开始时,SCL 是低电平,实际上,除了终止条件,SCL 以高电平结束,所有的单元我们都会保证 SCL 以低电平结束,这样方便各个单元的拼接。
看一下波形,这里,SCL 低电平,变换数据,高电平,保持数据稳定,由于是高位先行,所以变化数据的时候,按照先放最高位,再放次高位,等等,最后最低位,这样的顺序依次把一个字节的每一位放在 SDA 线上,每放完一位后,执行释放 SCL,拉低 SCL 的操作,驱动时钟运转。那在程序中的操作就是
void MyI2C_SendByte(uint8_t Byte) {//参数是要发送的一个字节
//1.首先趁 SCL 低电平,先把 Byte 的最高位放在 SDA 线上
MyI2C_W_SDA(Byte&0x80);//用按位与的方式,取出数据的最高位。
//2.那继续,这样最高位数据就放好了,我们再释放 SCL
MyI2C_W_SCL(1);
MyI2C_W_SCL(0);//拉低 SCL,驱动时钟走一个脉冲。
//这里释放 SCL 之后,从机就会立刻把我刚才放在 SDA 的数据读走,再拉低 SCL。
//3.我们可以继续放下一位数据了,下一位是次高位。
MyI2C_W_SDA(Byte&0x40);//取出次高位放在 SDA 线上
MyI2C_W_SCL(1);
MyI2C_W_SCL(0);//再驱动 SCL,来一个时钟
//4.发送下一位
MyI2C_W_SDA(Byte&0x20);
MyI2C_W_SCL(1);
MyI2C_W_SCL(0);
...//这样来 8 次这个操作,就可以写入一个字节了,但是复制 8 次,显得我们水平太低。
MyI2C_W_SDA(Byte&0x80);
这是一个单片机中非常常见的操作,就是用按位与的方式,取出数据的某一位或某几位。
列个式子比划一下,这里 Byte 可以是任意的数据 xxxx xxxx,0x80 是 1000 0000,两者按位与,结果就是 x000 0000,这低七位因为和 0 相与,所以结果不受 Byte 数据的影响,始终是 0。最高位,和 1 相与,所以结果取决于 Byte 的最高位,如果 Byte 最高位是 1,结果就是 1000 0000,也就是 0x80;如果 Byte 最高位是 0,结果就是 0000 0000,也就是 0x00,这就相当于把 Byte 的最高位取出来了。
注意一下,这个式子计算结果是 0x80 或 0x00,而不是 1 或 0,但是考虑到我们调用的这个函数,具有非 0 即 1 的特性,所以即使传入 0x80,也相当于传入 1,所以这里可以直接这样写。如果你不放心的话,也可以写if (Byte & 0x80 == 0); MyI2C_W_SDA(0) else MyI2C_W_SDA(1);
,这样也行,方法有很多,不局限于我这一种写法。
//所以这里可以套个for 循环,循环 8 次就够了。
uint8_t i;
for (i = 0; i < 8; i++) {
MyI2C_W_SDA(Byte & (0x80 >> i));//通式
MyI2C_W_SCL(1);
MyI2C_W_SCL(0);
}
}
这就是发送一个字节的逻辑,我们就完成了
- 接着继续第四个基本单元是接收一个字节,那对应写一个函数。
看一下波形,这里接收一个字节时序开始时,SCL 低电平,此时从机需要把数据放在 SDA 上,为了防止主机干扰从机写入数据,主机需要先释放 SDA,释放 SDA 也相当于切换为输入模式。那在 SCL 低电平时,从机会把数据放在 SDA,如果从机想发 1,就释放 SDA,如果从机想发 0,就拉底 SDA,然后主机释放 SCL,在 SCL 高电平期间,读取 SDA,再拉低 SCL,低电平期间,从机就会把下一位数据放在 SDA 上,这样重复 8 次,主机就能读到一个字节了。
其实这里可以发现,SCL 低电平变换数据,高电平读取数据,实际上就是一种读写分离的设计,低电平时间定义为写的时间,高电平时间定义为读的时间,就像我们小时候玩的 123木头人 的游戏,主机说 1、2、3,这个时候大家该动就可以动,主机说木头人,这时所有人就都不能动了,这个读写数据就是类似的流程。那在 SCL 高电平期间,如果你非要动 SDA,来破坏游戏规则的话,那这个信号就是起始条件和终止条件,SCL 高电平时,SDA 下降沿为起始条件,SDA 上升沿为终止条件。这个设计也保证了起始和终止的特异性,能够让我们在连续不断的波形中,快速的定位起始和终止,因为起始终止和数据传输的波形有本质区别。数据传输,SCL 高电平不许动 SDA,起始终止,SCL 高电平必须动 SDA。这就是这个设计,还是非常巧妙地,简单说一下。
uint8_t Byte = 0x00;//初始值为 0x00
//1.接收一个字节,进来之后 SCl 是低电平,主机释放 SDA,从机把数据放在 SDA,这时,主机释放 SCL,SCL 高电平,主机就能读取数据了,读取数据,我们用 MyI2C_R_SDA 函数
MyI2C_W_SDA(1);
MyI2C_W_SCL(1);
if (MyI2C_R_SDA() == 1) {//if 成立,我们就知道接收的这一位为 1 了,那先定义一个数据 Byte
Byte |= 0x80;//如果第一次读 SDA 为 1,我们就 Byte |= 0x80; 把 Byte 最高位置 1
//如果读 SDA 为 0,if 不成立,Byte 默认为 0x00,就相当于写入 0 了。
}
//2.那这里,读取一位之后,我们再把 SCL 拉低,这时从机就会把下一位数据放在 SDA 上。
MyI2C_W_SCL(0);
//我们再执行相同的流程 8 次,就能接收一个字节了。
实现这样逻辑的方法有很多,不局限于我这样的写法,大家如果有自己的想法,也可以试试看,写代码本身就是思维的一种体现,只要思路说得通,代码就能运行。
uint8_t MyI2C_ReceiveByte(void) {
uint8_t Byte = 0x00;//初始值为 0x00
uint8_t i = 0;
//所以我们写个 for 循环,先定义变量 i。
MyI2C_W_SDA(1);
for (i = 0; i < 8; i++) {
MyI2C_W_SCL(1);
if (MyI2C_R_SDA() == 1) {//依次从高位到低位进行判断
Byte |= (0x80 >> i);//通式:接收一个字节
}
MyI2C_W_SCL(0);
}
return Byte;//最后别忘了把接收的 Byte 返回回去
}
这就是接收一个字节。
- 然后继续第五个和第六个基本单元是发送应答和接收应答,那对应写两个函数。
看一下波形,这里发送应答和接收应答其实就是发送一个字节和接收一个字节的简化版,发送一个字节是发 8 位,发送应答就是发 1 位,接收一个字节是收 8 位,接收应答就是收 1 位,所以在程序这里。我们可以复制一下发送一个字节和接收一个字节函数,把 for 循环去掉,稍微修改一下就成了。
发送应答:
void MyI2C_SendAck(uint8_t AckBit) {//参数是要发送的 1 位
MyI2C_W_SDA(AckBit);//把发送的 1 位改成 AckBit
MyI2C_W_SCL(1);
MyI2C_W_SCL(0);
}
现在的逻辑是,函数进来时,SCL 低电平,主机把 AckBit 放在 SDA 上,SCL 高电平,从机读取应答,SCL 低电平,进入下一个时序单元,这样就完事了。
接收应答:
uint8_t MyI2C_ReceiveAck(void) {
uint8_t AckBit;//初值可以不给,读 SDA 时,直接把读到的值,赋值给 AckBit 就行了
MyI2C_W_SDA(1);
MyI2C_W_SCL(1);
AckBit = MyI2C_R_SDA();
MyI2C_W_SCL(0);
return AckBit;//最后返回 AckBit
}
那这个地方,有人说程序里主机先把 SDA 置 1 了,然后再读取 SDA,这应答位可能是 1 啊,这有什么意义呢?那问出这样的问题,说明你对两个知识点还不理解。
- I2C 的引脚都是开漏输出+弱上拉的配置,主机输出 1,并不是强置 SDA 位高电平,而是释放 SDA
- 你要明白 I2C 是在进行通信,主机释放了 SDA,那从机又不是在外面看戏,从机如果在的话,它是有义务在此时把 SDA 再拉低的。
所以这里,即使之前主机把 SDA 置 1 了,之后再读取 SDA,读到的值也可能是 0,读到 0,代表从机给了应答,读到 1,代表从机没给应答,这就是接收应答的执行流程。
另外还有人说接收一个字节这里不断读取 SDA,但是你 for 循环中又没写过 SDA,那 SDA 读出来应该始终是一个值啊,这有啥意义呢?
- 这个问题和刚才那个也是一样的。就是我们 I2C 是在进行通信的,通信是有从机的,当主机不断驱动 SCL 时钟时,从机就有义务去改变 SDA 的电平,所以主机每次循环读取 SDA 的时候,这个读取到的数据是从机控制的,这个数据也正是从机想要给我们发送的数据,所以这个时序叫做接收一个字节。如果你自己写 SDA,自己读 SDA,那还要通信干啥呢?这是这个问题。
总结而言,就是你要明白我们是在通信,通信是有时序的,有些引脚的电平,我们之前读和之后读,读的值就是不一样的,这个了解一下。
现在的逻辑是,函数进来时,SCL 低电平,主机释放 SDA,防止干扰从机,同时,从机把应答位放在 SDA 上,SCL 高电平,主机读取应答位,SCL 低电平,进入下一个时序单元。
到这里,我们这个 I2C 通信的 6 个基本单元就完成了。我们现在可以来进行一下测试。验证一下这些代码到底对不对。先把这 6 个函数,和初始化函数放在头文件里声明一下。
- 在主函数里,我们单独对 MyI2C 模块进行一下测试
测试方法:就是时序结构图,我们按照指定地址写和指定地址读的时序结构,来拼接一下完整的时序,看看实验现象是不是和我们想象的一样。
main.c 文件实现
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "MyI2C.h"
int main(void) {
OLED_Init();
MyI2C_Init();//初始化 I2C 的引脚
//1.先看一下指定地址写,在这里,主机如果想开始一次传输,首先需要调用 Start,对应程序这里就是
MyI2C_Start();//产生起始条件,开始一次传输
//2.之后呢,根据协议规定,起始之后,主机必须首先发送一个字节,内容是从机地址+读写位,进行寻址,对应程序这里就是
MyI2C_SendByte(0xD0);//0xD0 换成二进制就是 1101 0000,前 7 位从机地址,1101 000 是 MPU6050 的从机地址,最后一位读写位,0 代表即将进入写入操作,这样寻址就完成了。
//3.接着继续,发送一个字节之后,我们要接收一下应答位,看看从机有没有收到刚才的数据,对应程序这里就是
uint8_t Ack = MyI2C_ReceiveAck();//这样我们判断这个 Ack 的值,就知道从机有没有给我应答了
//4.之后继续,接收应答之后,我们还要继续在发送一个字节,写入寄存器地址,这个我们等会再测试写入寄存器的现象。
//5.那现在呢,我们先测试一下应答的功能是不是正常,因为这里已经接收到应答位了。所以后面这一段时序我们先不给了,直接快进到停止,那对应程序这里就是
MyI2C_Stop();
//6.之后,我们用 OLED 显示一下 Ack,看看是 1 还是 0。
OLED_ShowNum(1, 1, Ack, 3);
while(1){
}
}
接收应答之后,我们还要继续在发送一个字节,写入寄存器地址,这个我们等会再测试写入寄存器的现象,因为上一小节将寄存器的时候说了,这个芯片上电之后,默认是睡眠模式。测试后发现睡眠模式写入寄存器是无效的,所以这个完整的时序先别急,等会儿我们把读写函数封装一下再来测试这个功能。那现在呢,我们先测试一下应答的功能是不是正常,因为这里已经接收到应答位了。所以后面这一段时序我们先不给了,直接快进到停止。
这样,这就是个测试从机给不给应答的简化时序。
目前时序的流程,带入一下老师在课堂上课的场景解释一下,就是主机起始,也就是老师说,所有人给我听好了,我要点名了。那这时下面的学生,无论是睡觉的、走神的还是说小话的,都肯定立刻打起精神了,等待老师的召唤,然后主机寻址,就是老师说,MPU6050,你在不在?我跟你说个事。之后主机接收应答,如果班里确实有这个人,那 MPU6050 必须在此时说,我在,对应应答位就是低电平,如果没有这号人,那就没有应答,应答位就是默认的高电平。应答之后,主机应该就继续说话了,但是这里我目前啥都不说,直接停止,让这个同学坐下,我其实啥都不想说,就是看看你这个人在不在。所以这个精简的时序,你可以把它看成是点名时序,就是看看有没有这号人,之后我们显示应答位,这就是程序的内容。
编译下载后可以看到,目前 OLED 上显示的是 0,也就是应答位为 0,这说明我点名 MPU6050,它给我应答了,现象没问题,符合我们的预期。然后程序中,我们再测试一下,把这个寻址改一下,随便改一个,比如 0xA0,那我点名 0xA0 的设备,我们总线上没有这个设备,按理说就没有应答了。编译下载可以看到,显示的应答位是 1,符合我们的预期。
所以在程序这里,我们可以利用这个点名的时序,来完成一个功能,就是从机地址的扫描。 我们可以用 for 循环把这个程序套起来,遍历一下所有的从机地址,然后把应答位为 0 的地址统计下来,这样就能实现扫描总线上设备的功能了。这个功能感兴趣的话可以自己试一下。不过注意一下,遍历的时候只遍历前 7 位地址就行了,最后一位要始终保持为 0,否则一旦你交出了总线控制权,从机就会干扰你后续的遍历。这是这个功能。
那继续这个程序,我们再测试一下通过 AD0 引脚改名的功能,目前,我们把寻址改到 0xD0,下载看一下,目前 MPU 应答为 0,给了回应。然后我们找一根飞线,给这个从机改一下地址,飞线一头接在 AD0 引脚,另一头接在高电平,这时,MPU6050 的从机地址就是 1101 001 了,对应班级里的学生,地址就是学生的姓名,老师点名的时候用的。我们复位一下。可以看到,我们再用原来的 1101 000 的名字叫它,它就不理人了。那在程序中,我们修改寻址,用新名字 1101 001 去叫他试一下,对应 16 进制为 0xD2,下载可以看到,改名之后,我们用名字去叫它,它就给回应了。如果把飞线拔掉,换回原来的名字呢?复位,他就又不理人了。在程序中,改回 0xD0,下载,他就又理人了,这就是改名的实验现象。目前我们这个芯片只有 AD0 一个引脚,它就只能拥有总共两个名字。如果有 AD0 和 AD1 两个引脚,那就可以拥有总共 4 个名字,如果有更多的可配置引脚,那就有更多的改名机会。当你需要一条总线挂载多个相同型号的设备时,就可以利用这个改名的功能,避免名字,也就是从机地址的重复,这是这个功能。
那我们目前已经大体上验证了 I2C 时序的功能,目前这个点名应答的功能是没问题的,这说明硬件接线和这几个时序没问题。
接下来我们就继续来写建立在 MyI2C 模块上的 MPU6050 模块。
3.2.2 MPU6050 模块
在 Hardware 目录下新建一个模块,名字叫 MPU6050。模块建好了,先写个初始化函数。
- MPU6050 模块初始化
#include "stm32f10x.h" // Device header
#include "MyI2C.h"
void MPU6050_Init(void) {
MyI2C_Init();
}
首先我们这个模块建立在 MyI2C 之上的,所以我们这个模块要包含底层的函数。先 #include "MyI2C.h"
。初始化时先把底层初始化一下,这个有点像类的继承。
之后我们在初始化函数上面,我们先封装指定地址写和指定地址读的时序。
- 指定地址写寄存器。
#define MPU6050_ADDRESS 0xD0
//指定地址写寄存器
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data){//参数是 8 位的寄存器地址 和 8 位的数据
//在这里面,拼接指定地址写时序结构
//1.起始
MyI2C_Start();
//2.发送一个字节
MyI2C_SendByte(MPU6050_ADDRESS);//从机地址+读写位
//3.发送从机地址之后,接收应答
MyI2C_ReceiveAck();//这个函数会返回一个值,应答位,我们这里就不处理应答位了。
//4.之后,寻址找到从机之后,就可以继续发送下一个字节了。
MyI2C_SendByte(RegAddress);//第二个字节的内容,就是指定寄存器地址了。
MyI2C_ReceiveAck();//同样,发送一个字节之后,也得接收一下应答。
//5.再之后,继续发送第三个字节
MyI2C_SendByte(Data);//第三个字节,就是指定我要写入指定寄存器地址下的数据了
MyI2C_ReceiveAck();//然后接收应答
//6.最后,终止这个时序。
MyI2C_Stop();
}
MyI2C_SendByte(MPU6050_ADDRESS);
参数是从机地址+读写位。
那这里为了方便修改参数,并且突出它是从机地址,我们可以用宏定义替换一下这个数据,#define MPU6050_ADDRESS 0xD0
之后,用这个宏定义来表示地址。MyI2C_ReceiveAck();
这个函数会返回一个值,应答位,我们这里就不处理应答位了。
如果你想处理应答位,可以在这里接收应答位,判断一下。如果没有应答,就怎么怎么地,但是这个事后处理就比较麻烦了。展开写的话,会增加很多代码。所以为了保证时序结构的清晰,方便大家学习,这里就不对返回值进行判断了。大家知道一下,这里有应答位可以判断从机有没有收到数据就行了。MyI2C_SendByte(RegAddress);
第二个字节的内容,就是指定寄存器地址了,这个字节,会存在 MPU6050 的当前地址指针里,用于指定具体读写哪个寄存器。
这样就能实现指定地址写了,大家可以对照时序图,和我们代码的流程是一一对应的。
那如果你想指定地址写多个字节的话,就可以用 for 循环,把步骤 5 下的这两句套起来,多执行几遍,然后依次把一个数组的各个字节发送出去。这个大家感兴趣的话,可以自己完成一下这个进阶任务。这里就不再演示了。
到这里我们指定地址写一个字节的时序就完成了。
接着继续,我们按照这个指定地址读一个字节的时序,来完成以下读的函数。
- 指定地址读寄存器
//指定地址读寄存器
uint8_t MPU6050_ReadReg(uint8_t RegAddress) {//参数只有一个,指定读的地址
uint8_t Data;
//在这里面,拼接指定地址读时序结构,前面一大部分和指定地址写是一样的,指定了地址,还没有来得及写
//所以程序中,我们复制步骤 1~4。
//1.起始
MyI2C_Start();
//2.发送一个字节
MyI2C_SendByte(MPU6050_ADDRESS);//从机地址+读写位
//3.发送从机地址之后,接收应答
MyI2C_ReceiveAck();//这个函数会返回一个值,应答位,我们这里就不处理应答位了。
//4.之后,寻址找到从机之后,就可以继续发送下一个字节了。
MyI2C_SendByte(RegAddress);//第二个字节的内容,就是指定寄存器地址。
MyI2C_ReceiveAck();//同样,发送一个字节之后,也得接收一下应答。
//所以,设置完地址之后,我们要转入读的时序,转入读的时序,就必须重新指定读写位,重新指定读写位,就必须重新起始。
//1.所以这里直接来个重复起始条件
MyI2C_Start();
//2.起始之后,紧跟发送一个字节,指定从机地址和读写位
MyI2C_SendByte(MPU6050_ADDRESS | 0x01);//从机地址再或上 0x01,读写位为 1,这样就指定了接下来要读从机的数据了。
//3.当然发送读指令后,别忘了还是要先接收应答的。
MyI2C_ReceiveAck();//在接收应答之后,总线控制权就正式交给从机了。
//4.从机开始发送一个字节。那主机就是调用
Data = MyI2C_ReceiveByte();//接收一个字节,这个函数的返回值就是接收到的数据,所以我们先定义一个变量 Data,然后把接收到的数据存起来
//5.之后根据协议规定,主机接收一个字节之后,要给从机发送一个应答。
MyI2C_SendAck(1);//这里参数给 0,就是给从机应答,参数给 1,就是不给从机应答。那这里,我们只需要读取一个字节,所以就给 1,不给从机应答
//6.然后,终止这个时序
MyI2C_Stop();
//7.最后别忘了把读到的数据返回回去
return Data;
}
MyI2C_SendByte(MPU6050_ADDRESS | 0x01);
从机地址仍然是 MPU6050 的地址,但是我们这个 0xD0 是写地址,所以要再或上 0x01,变成 0xD1,读写位为 1。这样就指定了,我接下来要读从机的数据了。MyI2C_SendAck(1);
这里参数给 0,就是给从机应答,参数给 1,就是不给从机应答。那这里,我们只需要读取一个字节,所以就给 1,不给从机应答。
上一小节说过,如果你想继续读多个字节,就要给应答,从机收到应答之后,就会继续发送数据。如果你不想继续读了,就不能给从机应答了,主机收回总线的控制权,防止之后进入从机以为你还想要,但你实际不想要的冲突状态。- 同样这里,如果你想进阶为指定地址读多个字节,可以用 for 循环把步骤 5 和 6 这两行套起来,重复读取多次,就能读取多个数据了。当然要注意,读完最后一个字节给非应答,之前都给应答,这个进阶的任务也留给大家自行完成。
到这里我们指定地址读一个字节的时序就完成了。对照时序图,和我们代码的流程是一一对应的。这个如果还没看懂的话,可以再对照着详细研究一下。
好,程序写到这里,我们就可以进一步来进行测试了。先把这 3 个函数放在头文件声明一下。
#ifndef __MPU6050_H
#define __MPU6050_H
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data);
uint8_t MPU6050_ReadReg(uint8_t RegAddress);
void MPU6050_Init(void);
#endif
编译一下没有问题。
- main.c 文件测试
我们来调用读写寄存器的函数试试看,那每个寄存器的地址和寄存器内数据的意义,我们上一小节最后已经看过了,现在就来读写它们试一下。
测试案例 1:读寄存器的功能
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "MPU6050.h"//首先头文件这里我们就可以直接使用上层模块 MPU6050.h 了。
int main(void) {
OLED_Init();
MPU6050_Init();//然后这里,在这个上层模块的 Init 里面,就已经调用了底层的 MyI2C_Init 了,所以这时 MyI2C 的初始化是没问题的。
//1.首先,我们可以试一下读取这最后一个寄存器,芯片的 ID 号,验证看看对不对。
//WHO_AM_I 寄存器,它是只读的。地址是 0x75,内容是 ID 号,默认值是 0x68
uint8_t ID = MPU6050_ReadReg(0x75);//读寄存器,参数是要读的寄存器地址,返回值是 ID 号的内容。
OLED_ShowHexNum(1, 1, ID, 2);//显示一下
while(1){
}
}
下载看一下,这时可以看到,读出的 ID 号是 0x68.这说明我们指定地址读一个字节的时序没问题。另外有的芯片 ID 号是 0x98,这个也没问题,可能是批次或者其他什么原因,ID 号有点不一样。这个从网上还没查到是什么原因,不过影响不大,知道一下就行了。
回到程序,现在就验证了读寄存器的功能没问题。接下来验证一下写寄存器的功能。
测试案例 2:写寄存器的功能
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "MPU6050.h"
int main(void) {
OLED_Init();
MPU6050_Init();
//1.首先解除芯片的睡眠模式
MPU6050_WriteReg(0x6B, 0x00);//在这里,第一个参数,指定寄存器地址 0x6B,第二个参数,是要写入的数据,给个 0x00
//2.之后我们再换一个寄存器,写试一下。比如手册里我们可以找一下采样率分频寄存器,它的地址是 0x19,值的内容是采样分频
MPU6050_WriteReg(0x19, 0x66);//我们这里试一下,地址是 0x19,值可以先随便给一个,比如 0xAA
//3.那到底有没有写入成功呢,我们再读寄存器,把 0x19 地址下的数据读出来,存到一个变量里,我们放在 ID 这个变量里。
uint8_t ID = MPU6050_ReadReg(0x19);
//4.之后显示这个变量,就是寄存器 0x19 地址下的内容了。
OLED_ShowHexNum(1, 1, ID, 2);
while(1){
}
}
那要想写寄存器呢,首先需要解除芯片的睡眠模式,否则写入是无效的,睡眠模式是电源管理寄存器 1 的 SLEEP 位控制的,我们可以直接把这个寄存器写入 0x00,这样就能解除睡眠模式了。那这个寄存器的地址是 0x6B,所以程序这里,我们使用
MPU6050_WriteReg(0x6B, 0x00);
在这里,第一个参数,指定寄存器地址 0x6B,第二个参数,是要写入的数据,给个 0x00。这样就是在电源管理寄存器 1,写入 0x00,解除睡眠模式。
按理说,它应该是我们刚写入的 0xAA。下载看一下,可以看到,显示 0xAA。
我们改一下再试试呢,比如写入 0x66,下载,读出来是 0x66,这就说明了,读写寄存器都是没问题的。
其实这个程序,目前我们是把 MPU6050 当成一个存储器来使用的,写某个存储器,读某个存储器,其实读写存储器芯片,也是完全一样的逻辑,如果你学过 51 单片机的 I2C,你就会发现到现在,我们这个程序和读写 AT24C02 存储器芯片,是一样的流程,寄存器也是一种存储器,只不过普通的存储器只能写和读,里面的数据并没有赋予什么实际意义。但是寄存器就不一样了,寄存器的每一位数据,都对应了硬件电路的状态,寄存器和外设的硬件电路,是可以进行互动的。
所以程序到这里,我们就可以通过寄存器来控制电路了。
我们继续来写这个模块的程序
- MPU6050 模块初始化完善
首先在初始化函数这里,初始化之后,我们还要再写入一些寄存器,对 MPU6050 硬件电路进行初始化配置。
- 初始化第一步,配置电源管理寄存器。
那在这里,我们一般会用宏定义,先把寄存器的地址都用一个字符串来表示,要不然每次都查手册,比较麻烦;而且光写一个数据的地址放这,也不容易理解。寄存器如果比较少的话,可以直接在这上面进行宏定义;如果比较多的话,我们可以再新建一个单独的头文件存放。省的放在这里,比较占地方,所以我们在 Hardware 文件夹中再添加一个头文件,名字叫 MPU6050_Reg.h,存放路径指定一下。
MPU6050_Reg.h 文件
#ifndef __MPU6050_REG_H
#define __MPU6050_REG_H
//这里防止重复包含的固定格式也写一下。
//在这里,进行宏定义 #define 寄存器名称 对应的地址
//这个可以看一下手册,把这每个寄存器的名称和地址,搬到这里来
#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
#define MPU6050_PWR_MGMT_2 0x6C
#define MPU6050_WHO_AM_I 0x75
#endif
这个宏定义和手册是对应的。比如电源管理寄存器 1,地址是 0x68 就对应手册这里电源管理寄存器 1,地址是 0x68。然后剩下的都是一样的操作,大家可以自己写一下。这里根据个人习惯呢,模块的东西,一般都习惯加一个模块名字当前缀,这个加不加都可以,看个人喜好。
- 然后呢,有了这个定义,我们就可以在 MPU6050 模块里引用
#include "MPU6050_Reg.h"//先把寄存器地址定义的头文件包含进来
void MPU6050_Init(void) {
MyI2C_Init();
//1.先配置电源管理寄存器 1,写入的内容看一下手册,这每一位的解释上一小节已经介绍过了,不清楚的话,可以再翻一翻手册后面的详细解释。
MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);
//设备复位,给 0,不复位;
//睡眠模式,给 0,解除睡眠;
//循环模式,给 0,不需要循环;
//无关位,给 0 即可;
//温度传感器失能,给 0,不失能;
//最后 3 位选择时钟,给 001,选择 X 轴的陀螺仪时钟。
//所以这个寄存器写入的数据就是 0x01。编译一下没问题。
//2.然后继续,我们配置电源管理寄存器 2,看一下手册。
MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00);
//前两位,循环模式唤醒频率,给 00,不需要;
//后 6 位,每一个轴的待机位,全都给 0,不需要待机。
//所以这个寄存器写入的值就是 0x00。
//3.接着继续,我们配置上面这 4 个寄存器
MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09);//采样率分频。
//看一下手册,这 8 位决定了数据输出的快慢,值越小越快,这个可以根据实际需求来。
//我们给个 0x09,也就是 10 分频。
MPU6050_WriteReg(MPU6050_CONFIG, 0x06);//配置寄存器
//外部同步,全都给 0,不需要;
//数字低通滤波器,这个也是根据需求来,我们可以给个 110,这就是最平滑的滤波。
//所以这整个寄存器的值就是 0x06
MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);//陀螺仪配置寄存器
//前面 3 位是自测使能,这里手册写漏了,我们就不自测了,都给 0;
//满量程选择,这个也是根据实际需求来,我们就给 11,选择最大量程;
//后面 3 位无关位。
//所以这个寄存器就是 0x18
MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);//加速度计配置寄存器
//自测给 000;
//满量程,暂时也给最大量程,11;
//高通滤波器,我们用不到,给 00。
//所以这个寄存器的值也是 0x18
}
最后 3 位选择时钟,给 000,选择内部时钟,但是上一节说了,它非常建议我们选择陀螺仪时钟,所以我们可以给个 001,选择 X 轴的陀螺仪时钟。测试看的话,选择哪个时钟,影响并不大,大家可以看着选。
这样初始化配置就完成了,目前的配置主要是,解除睡眠,选择陀螺仪时钟,6 个轴均不待机,采样分配为 10,滤波参数给最大,陀螺仪和加速度计都选择最大量程,这就是目前给的配置,大家根据实际项目的需求,可以对于更改。
那配置完之后,陀螺仪内部就在连续不断地进行数据转换了,输出的数据,就存放在数据寄存器里。
接下来我们想获取数据的话,只需要再写一个获取数据寄存器的函数即可。
- 获取数据寄存器函数
根据任务需求,这个函数需要返回 6 个 int16_t 的数据,分别表示 XYZ 的加速度值和陀螺仪值。但是 C 语言中,函数的返回值只能有 1 个,所以,这里就需要一些特殊操作来实现返回 6 个值的任务。
多返回值函数的设计,方法有很多:
- 第一种,最简单的方法就是在函数外面定义 6 个全局变量,子函数读到的数据直接写到全局变量里,然后 6 个全局变量在主函数进行共享,这样就相当于返回了 6 个值。这是一种简单且直接的方法,比较适合用在规模比较小的项目中,但这种方法不太利于封装。
- 那第二种,进阶一点的方法,是用指针进行变量的地址传递来实现多返回值。
- 然后第三种,更进一步,更高阶的方法,就是用结构体,对多个变量进行打包,然后再统一进行传递,这种方法,就是 STM32 的库函数里使用到的,例如
GPIO_Init(GPIOB, &GPIO_InitStructure);
这里是结构体打包输入参数,但是输出参数或返回值,也可以这样进行打包。总之,参数的传递方法有很多,一般项目越大,就越要考虑使用这些高级语法,这样更有利于工程的管理。
那在这里,就使用第二种方法了,用指针的地址传递,所以在函数参数这里,写上 6 个输出参数。这 6 个参数,均是 int16_t 的指针类型,之后我们会在主函数里定义变量,通过指针,把主函数变量的地址传递到子函数来,子函数中,通过传递过来的地址,操作主函数的变量,这样,子函数结束之后,主函数变量的值,就是子函数想要返回的值。这就是使用指针实现函数多返回值的设计。
void MPU6050_GetData(int16_t* AccX, int16_t* AccY, int16_t* AccZ,
int16_t* GyroX, int16_t* GyroY, int16_t* GyroZ) {
//1.想要获取数据,我们就通过 ReadReg 函数读取数据寄存器。
//首先读取加速度寄存器 X 轴的高 8 位,然后再读取加速度寄存器的低 8 位。前面定义两个变量
uint8_t DataH, DataL;
DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H);//读取高 8 位的值放在 DataH 中。
DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L);//读取低 8 位的值放在 DataL 中。
*AccX = (DataH << 8) | DataL;//之后,高 8 位左移 8 位再 | 上低 8 位,这就是加速度计 X 轴的 16 位数据。
//得到 16 位数据之后,用指针引用传递进来的地址,把读到的数据,通过指针返回回去,这样 AccX 的值就完成了。
//2.接下里读取后续的数据,同样的操作。
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;
//3.然后陀螺仪的 XYZ 轴,也是一样。这里寄存器名字看一下
DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H);//陀螺仪 X 轴
DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L);
*GyroX = (DataH << 8) | DataL;
DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H);//陀螺仪 Y 轴
DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L);
*GyroY = (DataH << 8) | DataL;
DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H);//陀螺仪 Z 轴
DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L);
*GyroZ = (DataH << 8) | DataL;
}
然后这里你可能会问,这个 DataH 是 8 位的数据,它再左移 8 位,会不会出问题。
- 这个经过测试,是没问题的,因为最终赋值的变量是 16 位,所以 8 位数据左移之后,会自动进行类型转换(整型提升),移出去的位并不会丢失。当然如果你不放心的话,可以把这两个数据改成 16 位的。这样就肯定没问题了。另外,因为手册里说过,这个 16 位数据是一个用补码表示的有符号数,所以最终直接赋值给 int16_t,也是没问题的,这个问题说明一下。
这样这个读取函数就完成了,程序逻辑是,分别读取 6 个轴数据寄存器的高位和低位,拼接成 16 位的数据,再通过指针变量返回。
那这里,我们是用读取一个寄存器的函数,连续调用了 12 次,才读取完 12 个寄存器。但实际上,还有一种更高效的方法,就是使用我们之前提到的 I2C 读取多个字节的时序,从一个基地址开始,连续读取一片的寄存器,因为我们这个寄存器的地址,是连续的,所以可以从第一个寄存器的地址 0x3B 开始,连续读取 14 个字节,这样就可以一次性的把加速度值,陀螺仪值,当然还包括两个字节的温度值,都读出来了。这样,在时序上,读取效率就会大大提升,有兴趣的话,可以自己写程序试一下。那在这里,我还是保留这种笨办法的操作,这个也好理解一些。
好,那我们来测试一下看看。把这个函数放在头文件声明一下。编译一下,没问题。
main.c 文件测试
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "MPU6050.h"
int main(void) {
OLED_Init();
MPU6050_Init();
//1.然后主函数这里,我们先定义 6 个变量。
int16_t AX, AY, AZ, GX, GY, GZ;//这 6 个值,分别用来接收 XYZ 轴的加速度值和陀螺仪值。
while(1){
//2.之后,在主循环里,不断地读取数据,调用
MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ);//参数是 6 个指针变量,我们要分别把这 6 个变量的地址传递进去,这样就能读取 6 个轴的数据了
//3.然后,用 OLED 显示一下看看
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);
}
}
编译,没问题,下载,看一下。可以看到,目前 OLED 显示了 6 个数据,并且不断在刷新,晃动传感器,数据也都有变化。
然后按照之前我们介绍的加速度计和陀螺仪的模型,大概验证一下这些数的含义,首先这里左边三个数是 XYZ 轴的加速度计,我们按照之前说的一个正方体里面放置一个小球的模型来理解一下。小球压在哪个面上,就产生对应轴的输出,目前这个芯片是水平放置,对应正方体 4 个侧面,应该不受力,所以这里显示的 XY 轴数据基本为 0,小球压在底面上,产生 1 个 g 的重力加速度。
这里显示的数据是 1837,这个数据对应的重力加速度值可以算一下。目前初始化配置这里,我们选择的满量程是最大的 16 g,所以按比例算一下。 1837 / 32768 = x / 16g,解得 x 就是测量值。拿计算器计算一下,1837/32768*16 = 0.897g,即测得 Z 轴的加速度值是 0.897g,这里标准的答案应该是 1 个 g,所以测量基本没问题。
然后再看一下,这个板子上标的有 X 轴和 Y 轴的示意图,可能比较小,大家可以自己看一下,画的是纵向为 X 轴,横向为 Y 轴,剩下一个 Z 轴是垂直于芯片的那个轴,这里没标。那我们上下倾斜,就应该是加速度的 X 轴两个面受力,可以看第一个数据,上倾,X 轴正值;下倾,X 轴负值,没问题。左右倾斜,就是 Y 轴两个面受力,这个大家可以自己实验观察一下。
那我们注意到,这里无论怎么倾斜,Z 轴加速度都是正值,那如何让 Z 轴出现负值呢,我们知道,在正方体和小球的模型中,Z 轴代表上下两个面的受力,下面受力是正值,上面受力就是负值了。所以要想让 Z 轴输出负值,得让上面一个面受力。那显然,我们把这个芯片翻过来,这样 Z 轴就是负值了。这个大家自己实验观察一下。
那这些就是加速度大小的体现,然后我们看一下陀螺仪,这里右边 3 个数据为 3 个轴的角速度,那我们绕 Z 轴旋转,陀螺仪 Z 轴会输出对应的角速度;上下倾斜是绕 Y 轴的转动,陀螺仪 Y 轴数据变化;左右倾斜是绕 X 轴的转动,陀螺仪 X 轴数据变化,大家可以试一下。具体每个轴旋转的角速度是多少也是按照我们刚才说的比例公式计算,读取的数据/32768 = x/满量程,解得 x 就是具体的角速度值。那这些就是这个传感器测量各轴姿态数据的实验现象,实验现象我们就演示到这里。目前这个代码的任务也基本完成了。
我们最终在 MPU6050 模块中把获取 ID 号的代码也加上。
- 获取 ID 号
uint8_t MPU6050_GetID(void) {//获取 ID 号
return MPU6050_ReadReg(MPU6050_WHO_AM_I);//这样就能得到芯片的 ID 号了
}
main.c 文件测试
uint8_t ID;
OLED_ShowString(1, 1, "ID:");
ID = MPU6050_GetID();
OLED_ShowHexNum(1, 4, ID, 2);
这里显示 ID 号是 16 进制的 68,没问题。
最终程序现象为:最上面的数据是设备的 ID 号,MPU6050 的 ID 号固定是 0x68,一般我们可以读出这个 ID 号,验证看看是不是 0x68,用来测试 I2C 读取数据的功能是不是正常。另外之前还测试了不同批次的芯片,发现有的芯片 ID 号是 0x98,ID 号可能会有些不同,不过如果数据读出来没问题的话,这也不影响,知道一下就行了。
下面左边 3 个,是加速度传感器的输出数据,分别是 x 轴,y 轴 和 z 轴的加速度,右边 3 个,是陀螺仪传感器的输出数据,分别是 x 轴,y 轴 和 z 轴的角速度。我们可以改变 MPU6050 传感器的姿态,这 6 个数据就会对应变化。
以上就是本节课代码的全部内容了,在这一节里,我们用了多层的模块架构,最底层 I2C 协议层,主要关注点是,引脚是哪两个,什么配置,时序什么时候高电平,什么时候低电平,这些协议相关的内容。
之后协议层之上,是 MPU6050 的驱动层,主要关注点是如何读写寄存器,怎么配置寄存器,怎么读取数据,这些驱动相关的内容。
最后就是主函数的应用层了,在这里,我们只需要调用 GetData 函数得到数据就行了。剩下的,我们主要关注点是如何用这些数据,来完成我们程序的功能设计,当然我们目前的功能只是简单的显示一下数据。
这就是分层的框架,有了良好的分层结构,我们在写每一层的时候,就可以专注每一层的任务,其他层的事情不需要管,完成一层之后,就尽量对这一层进行测试,测试通过了,再继续后续的代码,这种编程方式,大家之后可以再进一步研究研究。
好,那本小节的程序部分,到这里就结束了。
最终给出示例代码作为参考:
MyI2C.h
#ifndef __MYI2C_H
#define __MYI2C_H
void MyI2C_Init(void);
void MyI2C_Start(void);
void MyI2C_Stop(void);
void MyI2C_SendByte(uint8_t Byte);
uint8_t MyI2C_ReceiveByte(void);
void MyI2C_SendAck(uint8_t AckBit);
uint8_t MyI2C_ReceiveAck(void);
#endif
MyI2C.c
#include "stm32f10x.h" // Device header
#include "Delay.h"
//直接定义函数,对操作端口的库函数进行封装,第一个函数,
void MyI2C_W_SCL(uint8_t BitValue) {//W 代表写的意思
GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);//这样套一个函数替换之后,我后面再调用这个 W_SCL,参数给 1 或 0,就可以释放或拉低 SCL 了
Delay_us(10);//这样就能很方便的进行引脚延时操作了,那对于 MPU6050 来说,经过实测,对于 STM32F1 系列,这里既是不加任何延时,这个引脚反转速度,MPU6050 也能跟得上,但是保险起见,我们还是延时个 10 us 吧。
}
//然后继续,把 SDA 也封装一下。
void MyI2C_W_SDA(uint8_t BitValue) {
GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue);
Delay_us(10);
}
//还要再来个读 SDA 的函数,因为 STM32 库函数中,读和写不是同一个寄存器
uint8_t MyI2C_R_SDA(void) {//R 代表读的意思
//函数里面,先定义一个变量
uint8_t BitValue;
BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11);
Delay_us(10);//读之后,我们也延时个 10 us
return BitValue;//返回读到 SDA 线的电平。
}
//1.有了这 3 个函数的封装,我们就实现了函数名称、端口号的替换。
//同时,也可以很方便的修改时序的延时,当我们需要替换端口,或者把这个程序移植到别的单片机中时,就只需要对这前 4 个函数里的操作对应更改。
//2.然后后面的函数,我们都调用这里封装的新名称进行操作.
//这样在移植的时候,后面的部分就不需要再进行修改了。好,那关于引脚操作的封装和改名,我们就完成了。
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 配置好默认低电平
GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11);//将 GPIO 配置为高电平
}
void MyI2C_Start(void) {
//1.在起始条件里,我们需要先把 SCL 和 SDA 都释放,也就是都输出 1
MyI2C_W_SDA(1);
MyI2C_W_SCL(1);
//2.然后,先拉底 SDA,再拉低 SCL
MyI2C_W_SDA(0);
MyI2C_W_SCL(0);
}
//接下来继续,终止条件
void MyI2C_Stop(void) {
//1.Stop 的逻辑是:先拉低 SDA,再释放 SCL,释放 SDA
MyI2C_W_SDA(0);
MyI2C_W_SCL(1);
MyI2C_W_SDA(1);
}
//然后是发送一个字节
void MyI2C_SendByte(uint8_t Byte) {//参数是要发送的一个字节
//1.首先趁 SCL 低电平,先把 Byte 的最高位放在 SDA 线上
// MyI2C_W_SDA(Byte&0x80);//这是一个单片机中非常常见的操作,就是用按位与的方式,取出数据的某一位或某几位。
//那继续,这样最高位数据就放好了,我们再释放 SCL
// MyI2C_W_SCL(1);
// MyI2C_W_SCL(0);//拉低 SCL,驱动时钟走一个脉冲。这里释放 SCL 之后,从机就会立刻把我刚才放在 SDA 的数据读走,再拉低 SCL。
//2.我们可以继续放下一位数据了,下一位是次高位。
// MyI2C_W_SDA(Byte&0x40);//取出次高位放在 SDA 线上
// MyI2C_W_SCL(1);
// MyI2C_W_SCL(0);//再驱动 SCL,来一个时钟
//3.发送下一位
// MyI2C_W_SDA(Byte&0x20);
// MyI2C_W_SCL(1);
// MyI2C_W_SCL(0);
//这样来 8 次这个操作,就可以写入一个字节了,但是复制 8 次,显得我们水平太低。
//4.所以这里可以套个for 循环,循环 8 次就够了。
uint8_t i;
for (i = 0; i < 8; i++) {
MyI2C_W_SDA(Byte & (0x80 >> i));//通式
MyI2C_W_SCL(1);
MyI2C_W_SCL(0);
}
}
//接着继续,接收一个字节
uint8_t MyI2C_ReceiveByte(void) {
uint8_t Byte = 0x00;//初始值为 0x00
uint8_t i = 0;
//1.接收一个字节,进来之后 SCl 是低电平,主机释放 SDA,从机把数据放在 SDA,这时,主机释放 SCL,SCL 高电平,主机就能读取数据了,读取数据,我们用 MyI2C_R_SDA 函数
/*
MyI2C_W_SDA(1);
MyI2C_W_SCL(1);
if (MyI2C_R_SDA() == 1) {//if 成立,我们就知道接收的这一位为 1 了,那先定义一个数据 Byte
Byte |= 0x80;//如果第一次读 SDA 为 1,我们就 Byte |= 0x80; 把 Byte 最高位置 1
//如果读 SDA 为 0,if 不成立,Byte 默认为 0x00,就相当于写入 0 了,当然还是一样,实现这样逻辑的方法有很多,不局限于我这样的写法,大家如果有自己的想法,也可以试试看,
}
//那这里,读取一位之后,我们再把 SCL 拉低,这时从机就会把下一位数据放在 SDA 上。
MyI2C_W_SCL(0);
*/
//2.我们再执行相同的流程 8 次,就能接收一个字节了。
MyI2C_W_SDA(1);
for (i = 0; i < 8; i++) {
MyI2C_W_SCL(1);
if (MyI2C_R_SDA() == 1) {//依次从高位到低位进行判断
Byte |= (0x80 >> i);//通式:接收一个字节
}
MyI2C_W_SCL(0);
}
return Byte;//最后别忘了把接收的 Byte 返回回去
}
//首先发送应答
void MyI2C_SendAck(uint8_t AckBit) {//参数是要发送的 1 位
MyI2C_W_SDA(AckBit);//把发送的 1 位改成 AckBit
MyI2C_W_SCL(1);
MyI2C_W_SCL(0);
}
//之后接收应答
uint8_t MyI2C_ReceiveAck(void) {
uint8_t AckBit;//初值可以不给,读 SDA 时,直接把读到的值,赋值给 AckBit 就行了
MyI2C_W_SDA(1);
MyI2C_W_SCL(1);
AckBit = MyI2C_R_SDA();
MyI2C_W_SCL(0);
return AckBit;//最后返回 AckBit
}
MPU6050_Reg.h
#ifndef __MPU6050_REG_H
#define __MPU6050_REG_H
//这里防止重复包含的固定格式也写一下。
//在这里,进行宏定义 #define 寄存器名称 对应的地址
//这个可以看一下手册,把这每个寄存器的名称和地址,搬到这里来
#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
#define MPU6050_PWR_MGMT_2 0x6C
#define MPU6050_WHO_AM_I 0x75
//这个宏定义和手册是对应的。比如电源管理寄存器 1,地址是 0x68 就对应手册这里电源管理寄存器 1,地址是 0x68。然后剩下的都是一样的操作,大家可以自己写一下。
#endif
MPU6050.h
#ifndef __MPU6050_H
#define __MPU6050_H
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data);
uint8_t MPU6050_ReadReg(uint8_t RegAddress);
void MPU6050_Init(void);
uint8_t MPU6050_GetID(void);
void MPU6050_GetData(int16_t* AccX, int16_t* AccY, int16_t* AccZ,
int16_t* GyroX, int16_t* GyroY, int16_t* GyroZ);
#endif
MPU6050.c
#include "stm32f10x.h" // Device header
#include "MyI2C.h"
#include "MPU6050_Reg.h"
#define MPU6050_ADDRESS 0xD0
//指定地址写寄存器
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data){//参数是 8 位的寄存器地址 和 8 位的数据
//在这里面,拼接指定地址写时序结构
//1.起始
MyI2C_Start();
//2.发送一个字节
MyI2C_SendByte(MPU6050_ADDRESS);//从机地址+读写位,那这里为了方便修改参数,并且突出它是从机地址,我们可以用宏定义替换一下这个数据,#define MPU6050_ADDRESS 0xD0
//3.发送从机地址之后,接收应答
MyI2C_ReceiveAck();//这个函数会返回一个值,应答位,我们这里就不处理应答位了。
//4.之后,寻址找到从机之后,就可以继续发送下一个字节了。
MyI2C_SendByte(RegAddress);//第二个字节的内容,就是指定寄存器地址了,这个字节,会存在 MPU6050 的当前地址指针里,用于指定具体读写哪个寄存器。
MyI2C_ReceiveAck();//同样,发送一个字节之后,也得接收一下应答。
//5.再之后,继续发送第三个字节
MyI2C_SendByte(Data);//第三个字节,就是指定我要写入指定寄存器地址下的数据了
MyI2C_ReceiveAck();//然后接收应答
//6.最后,终止这个时序。
MyI2C_Stop();
}
//接着继续,我们按照这个指定地址读一个字节的时序,来完成以下读的函数。
uint8_t MPU6050_ReadReg(uint8_t RegAddress) {//参数只有一个,指定读的地址
uint8_t Data;
//在这里面,拼接指定地址读时序结构,前面一大部分和指定地址写是一样的,指定了地址,还没有来得及写
//所以程序中,我们复制步骤 1~4。
//1.起始
MyI2C_Start();
//2.发送一个字节
MyI2C_SendByte(MPU6050_ADDRESS);//从机地址+读写位
//3.发送从机地址之后,接收应答
MyI2C_ReceiveAck();//这个函数会返回一个值,应答位,我们这里就不处理应答位了。
//4.之后,寻址找到从机之后,就可以继续发送下一个字节了。
MyI2C_SendByte(RegAddress);//第二个字节的内容,就是指定寄存器地址了,指定地址,就是设置了 MPU6050 的当前地址指针。
MyI2C_ReceiveAck();//同样,发送一个字节之后,也得接收一下应答。
//所以,设置完地址之后,我们要转入读的时序,转入读的时序,就必须重新指定读写位,重新指定读写位,就必须重新起始。
//1.所以这里直接来个重复起始条件
MyI2C_Start();
//2.起始之后,紧跟发送一个字节,指定从机地址和读写位
MyI2C_SendByte(MPU6050_ADDRESS | 0x01);//从机地址仍然是 MPU6050 的地址,但是我们这个 0xD0 是写地址,所以要再或上 0x01,变成 0xD1,读写位为 1
//3.当然发送读指令后,别忘了还是要先接收应答的。
MyI2C_ReceiveAck();//在接收应答之后,总线控制权就正式交给从机了。
//4.从机开始发送一个字节。那主机就是调用
Data = MyI2C_ReceiveByte();//接收一个字节,这个函数的返回值就是接收到的数据,所以我们先定义一个变量 Data,然后把接收到的数据存起来
//5.之后根据协议规定,主机接收一个字节之后,要给从机发送一个应答。
MyI2C_SendAck(1);//这里参数给 0,就是给从机应答,参数给 1,就是不给从机应答。那这里,我们只需要读取一个字节,所以就给 1,不给从机应答。
//6.然后,终止这个时序
MyI2C_Stop();
//7.最后别忘了把读到的数据返回回去
return Data;
}
//好,程序写到这里,我们就可以进一步来进行测试了。先把这 3 个函数放在头文件声明一下。编译一下没有问题。
void MPU6050_Init(void) {
MyI2C_Init();
//配置电源管理寄存器 1
MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);//设备复位,给 0,不复位;睡眠模式,给 0,解除睡眠;循环模式,给 0,不需要循环;无关位,给 0 即可;温度传感器失能,给 0,不失能;最后 3 位选择时钟,给 000,选择内部时钟,但是上一节说了,它非常建议我们选择陀螺仪时钟,所以我们可以给个 001,选择 X 轴的陀螺仪时钟。测试看的话,选择哪个时钟,影响并不大,大家可以看着选。
//所以这个寄存器写入的数据就是 0x01。编译一下没问题。
//然后继续,我们配置电源管理寄存器 2,看一下手册。
MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00);//前两位,循环模式唤醒频率,给 00,不需要;后 6 位,每一个轴的待机位,全都给 0,不需要待机,所以这个寄存器写入的值就是 0x00。
//接着继续,我们配置上面这 4 个寄存器
MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09);//采样率分频,这 8 位决定了数据输出的快慢,值越小越快,这个可以根据实际需求来,我们给个 0x09,也就是 10 分频。
MPU6050_WriteReg(MPU6050_CONFIG, 0x06);//配置寄存器,在这里,外部同步,全都给 0,不需要;数字低通滤波器,这个也是根据需求来,我们可以给个 110,这就是最平滑的滤波,所以这整个寄存器的值就是 0x06
MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);//陀螺仪配置寄存器,前面 3 位是自测使能,这里手册写漏了,我们就不自测了,都给 0;满量程选择,这个也是根据实际需求来,我们就给 11,选择最大量程,后面 3 位无关位,所以这个寄存器就是 0x18
MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);//加速度计配置寄存器,在这里,自测给 000,满量程,暂时也给最大量程,11,最后高通滤波器,我们用不到,给 00,所以这个寄存器的值也是 0x18
}
//可以在这里,再写个函数
uint8_t MPU6050_GetID(void) {//获取 ID 号
return MPU6050_ReadReg(MPU6050_WHO_AM_I);//这样就能得到芯片的 ID 号了
}
//接下来我们想获取数据的话,只需要再写一个获取数据寄存器的函数即可。所以这里来个
void MPU6050_GetData(int16_t* AccX, int16_t* AccY, int16_t* AccZ,
int16_t* GyroX, int16_t* GyroY, int16_t* GyroZ) {
//想要获取数据,我们就通过 ReadReg 函数,读取数据寄存器,首先读取加速度寄存器 X 轴的高 8 位,然后再读取加速度寄存器的低 8 位。前面定义两个变量
uint8_t DataH, DataL;
DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H);//读取高 8 位的值放在 DataH 中。
DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L);//读取低 8 位的值放在 DataL 中。
*AccX = (DataH << 8) | DataL;//之后,高 8 位左移 8 位再 | 上低 8 位,这就是加速度计 X 轴的 16 位数据,得到 16 位数据之后,用指针引用传递进来的地址,把读到的数据,通过指针返回回去,这样 AccX 的值就完成了。
//接下里读取后续的数据,同样的操作。
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);//陀螺仪 X 轴
DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L);
*GyroX = (DataH << 8) | DataL;
DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H);//陀螺仪 Y 轴
DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L);
*GyroY = (DataH << 8) | DataL;
DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H);//陀螺仪 Z 轴
DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L);
*GyroZ = (DataH << 8) | DataL;
}
main.c
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "MPU6050.h"
int main(void) {
OLED_Init();
MPU6050_Init();//在这个上层模块的 Init 里面,就已经调用了底层的 MyI2C_Init 了,所以这时 MyI2C 的初始化是没问题的。
//主函数这里,我们先定义 6 个变量。
int16_t AX, AY, AZ, GX, GY, GZ;//这 6 个值,分别用来接收 XYZ 轴的加速度值和陀螺仪值。
uint8_t ID;
OLED_ShowString(1, 1, "ID:");
ID = MPU6050_GetID();
OLED_ShowHexNum(1, 4, ID, 2);//这里显示 ID 号是 16 进制的 68,没问题。
while(1){
//之后,在主循环里,不断地读取数据,调用
MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ);//参数是 6 个指针变量,我们要分别把这 6 个变量的地址传递进去,这样就能读取 6 个轴的数据了
//然后,用 OLED 显示一下看看
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);
//这样显示程序就写好了
}
}
4. 一个 硬件 I2C 读写 MPU6050 功能案例
本小节我们来学习硬件 I2C 的代码部分。
4.1 硬件电路
那首先还是先看一下本节代码的接线图。
这里展示的就是硬件 I2C 读写 MPU6050 的接线图,这个接线图和软件 I2C 读写 MPU6050 的接线图是一样的。这里也是,VCC 和 GND 接供电。
SCL 接 PB10,SDA 接 PB11。当然,之前介绍过,软件 I2C 这两个通信引脚是可以任意更改的,在程序中,我们应该也有体会,软件 I2C 的引脚就是普通的开漏输出模式,我们硬件接在哪个引脚上,程序中就对应操作哪个引脚即可。但是这里,硬件 I2C 通信引脚是不可以任意指定的,我们需要查询引脚定义表,来进行引脚规划,在引脚定义表中可以看到,如果使用硬件的 I2C1,需要接在 PB6 和 PB7,I2C1 可以重映射,这样还可以更换为 PB8 和 PB9,但是在我们这个板子上,PB6、7、8、9 这 4 个引脚在板子的右下角,被 OLED 占用了,不太方便接线,所以我们就继续查表,可以看到这里硬件的 I2C2,引脚是 PB10 和 PB11,然后重映射这一栏没有找到 I2C2 的重映射,所以目前 I2C2 只能选择 PB10 和 PB11,并且这两个引脚也必须是 SCL 对应 PB10,SDA 对应 PB11,两者不能互换。所以对应我们这个接线图,SCL 就接在了 PB10,SDA 接在 PB11,这样才是硬件 I2C2,这个外设的正确接线。
如果你私自把引脚改到了其他位置,那 I2C2 是无法操作的。好,这就是硬件 I2C 的接线图。
然后看一下面包板,这里 硬件电路和上一个代码是一样的,没有变化,所以接线这里就不用改了。
这里为了方便,上一个软件 I2C 的代码也选择了硬件 I2C 的引脚,那在这两个引脚上,就既可以使用软件 I2C,又可以使用硬件 I2C。所以如果你之后画板子设计电路的时候,不确定是使用硬件 I2C 还是软件 I2C,就可以这样,我就干脆直接接在硬件 I2C 的引脚上,这样硬件 I2C、软件 I2C 想使用哪个都可以,那不就留下了之后选择的余地了嘛。
好,接线图我们就看到这里,接下来我们开始写代码。
4.2 代码整体框架
在这个工程里,我们最终的应用层,也就是主函数的程序现象,和软件 I2C 都是一样的。软件 I2C 和硬件 I2C 的区别呢。就在通信的底层,也就是我们之前在软件 I2C 写的 MyI2C.c 这个文件,这里面都是用程序手动翻转引脚,也就是软件 I2C,那我们有了硬件,这些底层的东西,就可以交给硬件来完成,所以我们这个工程就不再需要这个 MyI2C 的模块了。
我们要把 MyI2C 这个模块移出工程。把模块移出工程的步骤呢?就是,
第一步:在选项卡对应的文件这里右键,把 .c 和 .h 的文件 close 掉。
第二步,在工程数对应的文件这里右键,把 .c 和 .h 的文件 Remove 掉。
这样我们就已经把模块移出了工程。当然最后,我们最好还是要到文件夹工程目录这里来,打开 Hardware 文件夹,把 .c 和 .h 的文件删掉。保证工程树和工程目录里的文件一致,这样有利于工程管理。
好,回到 Keil 软件,这样,最底层的 MyI2C 通信层就没有了,我们到 MPU6050.c 这里来,那这些原来调用 MyI2C 模块里面的代码,目前就不存在了。上面 #include "MyI2C.h"
这一句删掉,下面 WriteReg 函数里面先注释掉,最后再删,这样等会儿方便参考一下,然后 ReadReg 函数里面这些也注释掉,最后还有 MyI2C_Init,也注释掉。那目前注释的这些,都是软件 I2C 实现读写时序的相关代码,所以接下来我们的任务就是,利用硬件的 I2C 外设来替换这些注释的代码,实现相同的功能。然后下面这里,由于我们只替换最底层的通信层,所以后面这些基于通信层的芯片配置和读取数据,这些逻辑,都不需要更改。
那看一下注释的这些代码,我们的步骤就是:
第一步,配置 I2C 外设,对 I2C2 外设进行初始化,来替换这里的 MyI2C_Init。
第二步,控制外设电路,实现指定地址写的时序,来替换这里的 WriteReg。
第三步,控制外设电路,实现指定地址读的时序,来替换这里的 ReadReg。
好,现在程序的目的就清楚了。接下来看一下,我们配置 I2C 外设就参考硬件电路的框图,实现读写时序就参考主机发送和接收的流程图,这样就行了。
那我们看一下基本结构图。实际上配置 I2C 外设也不难,库函数的使用流程,我们已经操作很多遍了,这里也是一样。
- 开启 I2C 外设和对应 GPIO 口的时钟。
- 把 I2C 外设对应的 GPIO 口初始化为复用开漏模式。
- 使用结构体,对整个 I2C 进行配置。
- I2C_Cmd,使能 I2C。
这样初始化配置就完成了。
我们的目的有了,接下来就是看一下库函数,去寻找能实现我们想法的函数。
- I2C 通信库函数(i2c.h 文件)
//常规函数
void I2C_DeInit(I2C_TypeDef* I2Cx);
void I2C_Init(I2C_TypeDef* I2Cx, I2C_InitTypeDef* I2C_InitStruct);//初始化,参数第二个是初始化的结构体
void I2C_StructInit(I2C_InitTypeDef* I2C_InitStruct);
void I2C_Cmd(I2C_TypeDef* I2Cx, FunctionalState NewState);//使能或失能 I2C 外设
void I2C_DMACmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_DMALastTransferCmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
//要学习的函数
void I2C_GenerateSTART(I2C_TypeDef* I2Cx, FunctionalState NewState);//生成起始条件,其实这个函数执行的操作也很简单。可以转到定义看一下,函数内容就是,如果 NewState 不等于 DISABLE,就把 CR1 寄存器的 START 位置 1,否者,把 START 位清 0。START 位的意义呢,可以通过查看手册里的寄存器描述来了解:START 置 1,在从模式下就是产生起始条件,在主模式下,就是产生重复起始条件,说白了,就是这一位置 1,产生起始条件。
void I2C_GenerateSTOP(I2C_TypeDef* I2Cx, FunctionalState NewState);//生成终止条件,转到定义也可以看一下,它里面其实就是操作 CR1 的 STOP 位。STOP 位在手册里也可以看到,这里 STOP,就是产生停止条件,手册里的解释可以提供库函数没体现出来的更多细节问题,所以说如果你想对库函数实际执行的操作和可能产生的问题有深入了解,还是得对照一下寄存器来分析,好那这就是这个函数。就是设置 STOP 位,产生停止条件。
void I2C_AcknowledgeConfig(I2C_TypeDef* I2Cx, FunctionalState NewState);//配置在收到一个字节之后,是否给从机应答。转到定义看一下,这个函数就是配置 CR1 的 ACK 这一位,那在手册里,ACK 就是应答使能,就是 STM32 作为主机,在接收到一个字节后是给从机应答还是非应答呢,就取决于这个 ACK 这一位。在应答的时候,如果 ACK 是 1,就给从机应答;如果 ACK 是 0,就不给从机应答。
void I2C_OwnAddress2Config(I2C_TypeDef* I2Cx, uint8_t Address);
void I2C_DualAddressCmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_GeneralCallCmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_ITConfig(I2C_TypeDef* I2Cx, uint16_t I2C_IT, FunctionalState NewState);
//要学习的函数
void I2C_SendData(I2C_TypeDef* I2Cx, uint8_t Data);//发送数据,转到定义看一下,实际就是把 Data 这个数据,直接写入到 DR 寄存器。那这个函数的意义呢,看一下手册,这里 DR 数据寄存器用于存放接收到的数据或放置用于发送到总线的数据:在发送器模式下,当写一个字节至 DR 寄存器时,自动启动数据传输,一旦传输开始,也就是 TxE = 1,发送寄存器空,如果能及时把下一个需要传输的数据写入 DR 寄存器,I2C 模块将保持连续的数据流。从这里可以看出两个信息,一个是写入 DR,自动启动数据传输,也就是产生发送一个字节的波形;另一个是,在上一个数据移位传输过程中,如果及时把下一个数据放在 DR 里等着,这样就能保持连续的数据流。所以到这里,SendData 这个函数我们就理解了,就是写数据到数据寄存器 DR。
uint8_t I2C_ReceiveData(I2C_TypeDef* I2Cx);//同样转到定义,这个函数就是读取 DR 的数据,作为返回值,他的意思呢,同样查看手册,在接收器模式下,接收到的字节被拷贝到 DR 寄存器,这时就是 RxNE = 1,接收寄存器非空,那在接收到下一个字节之前读出数据寄存器,即可实现连续的数据传送,这里也能看出两个信息:一个是,接收移位完成时,收到的一个字节由移位寄存器转到数据寄存器,我们读取数据寄存器,就能接收一个字节了;另一个是,你要再下一个字节收到之前,及时把上一个字节取走,防止数据覆盖,这样才能实现连续的数据流。所以这就是 ReceiveData 这个函数的意义,就是读取 DR,接收数据。
void I2C_Send7bitAddress(I2C_TypeDef* I2Cx, uint8_t Address, uint8_t I2C_Direction);//发送 7 位地址的专用函数。转到定义看一下,这里可以看出,实际上,Address 这个参数也是通过 DR 发送的,只不过是,它在发送之前,帮我们设置了 Address 最低位的读写位。这里意思就是,如果 Direction 不是发送,就把 Address 的最低位置 1,也就是读;否则,就把 Address 的最低位清 0,也就是写,所以我们在发送地址的时候,可以用一下这个函数,当然如果你觉得,不就是设置一下最低位嘛,这么简单的操作,就不用你库函数操心了,我自己来就行,那我们也可以直接调用上面的 SendData 函数来发送地址,这也是可以的,那这就是这个函数。
uint16_t I2C_ReadRegister(I2C_TypeDef* I2Cx, uint8_t I2C_Register);
void I2C_SoftwareResetCmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_NACKPositionConfig(I2C_TypeDef* I2Cx, uint16_t I2C_NACKPosition);
void I2C_SMBusAlertConfig(I2C_TypeDef* I2Cx, uint16_t I2C_SMBusAlert);
void I2C_TransmitPEC(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_PECPositionConfig(I2C_TypeDef* I2Cx, uint16_t I2C_PECPosition);
void I2C_CalculatePEC(I2C_TypeDef* I2Cx, FunctionalState NewState);
uint8_t I2C_GetPEC(I2C_TypeDef* I2Cx);
void I2C_ARPCmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_StretchClockCmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_FastModeDutyCycleConfig(I2C_TypeDef* I2Cx, uint16_t I2C_DutyCycle);
/**
* @brief
****************************************************************************************
*
* I2C State Monitoring Functions
*
****************************************************************************************
* This I2C driver provides three different ways for I2C state monitoring
* depending on the application requirements and constraints:
*
*
* 1) Basic state monitoring:
* Using I2C_CheckEvent() function:
* It compares the status registers (SR1 and SR2) content to a given event
* (can be the combination of one or more flags).
* It returns SUCCESS if the current status includes the given flags
* and returns ERROR if one or more flags are missing in the current status.
* - When to use:
* - This function is suitable for most applications as well as for startup
* activity since the events are fully described in the product reference manual
* (RM0008).
* - It is also suitable for users who need to define their own events.
* - Limitations:
* - If an error occurs (ie. error flags are set besides to the monitored flags),
* the I2C_CheckEvent() function may return SUCCESS despite the communication
* hold or corrupted real state.
* In this case, it is advised to use error interrupts to monitor the error
* events and handle them in the interrupt IRQ handler.
*
* @note
* For error management, it is advised to use the following functions:
* - I2C_ITConfig() to configure and enable the error interrupts (I2C_IT_ERR).
* - I2Cx_ER_IRQHandler() which is called when the error interrupt occurs.
* Where x is the peripheral instance (I2C1, I2C2 ...)
* - I2C_GetFlagStatus() or I2C_GetITStatus() to be called into I2Cx_ER_IRQHandler()
* in order to determine which error occurred.
* - I2C_ClearFlag() or I2C_ClearITPendingBit() and/or I2C_SoftwareResetCmd()
* and/or I2C_GenerateStop() in order to clear the error flag and source,
* and return to correct communication status.
*
*
* 2) Advanced state monitoring:
* Using the function I2C_GetLastEvent() which returns the image of both status
* registers in a single word (uint32_t) (Status Register 2 value is shifted left
* by 16 bits and concatenated to Status Register 1).
* - When to use:
* - This function is suitable for the same applications above but it allows to
* overcome the limitations of I2C_GetFlagStatus() function (see below).
* The returned value could be compared to events already defined in the
* library (stm32f10x_i2c.h) or to custom values defined by user.
* - This function is suitable when multiple flags are monitored at the same time.
* - At the opposite of I2C_CheckEvent() function, this function allows user to
* choose when an event is accepted (when all events flags are set and no
* other flags are set or just when the needed flags are set like
* I2C_CheckEvent() function).
* - Limitations:
* - User may need to define his own events.
* - Same remark concerning the error management is applicable for this
* function if user decides to check only regular communication flags (and
* ignores error flags).
*
*
* 3) Flag-based state monitoring:
* Using the function I2C_GetFlagStatus() which simply returns the status of
* one single flag (ie. I2C_FLAG_RXNE ...).
* - When to use:
* - This function could be used for specific applications or in debug phase.
* - It is suitable when only one flag checking is needed (most I2C events
* are monitored through multiple flags).
* - Limitations:
* - When calling this function, the Status register is accessed. Some flags are
* cleared when the status register is accessed. So checking the status
* of one Flag, may clear other ones.
* - Function may need to be called twice or more in order to monitor one
* single event.
*
*/
/**
*
* 1) Basic state monitoring
*******************************************************************************
*/
ErrorStatus I2C_CheckEvent(I2C_TypeDef* I2Cx, uint32_t I2C_EVENT);
/**
*
* 2) Advanced state monitoring
*******************************************************************************
*/
uint32_t I2C_GetLastEvent(I2C_TypeDef* I2Cx);
/**
*
* 3) Flag-based state monitoring
*******************************************************************************
*/
FlagStatus I2C_GetFlagStatus(I2C_TypeDef* I2Cx, uint32_t I2C_FLAG);
/**
*
*******************************************************************************
*/
//然后下面 3 个,就还是我们的老朋友,清标志位,读取中断标志位,清中断标志位。
void I2C_ClearFlag(I2C_TypeDef* I2Cx, uint32_t I2C_FLAG);
ITStatus I2C_GetITStatus(I2C_TypeDef* I2Cx, uint32_t I2C_IT);
void I2C_ClearITPendingBit(I2C_TypeDef* I2Cx, uint32_t I2C_IT);
这里的函数也是非常多,我们目前只需要挑一部分重要的看就行。
接着函数的后面,就出现了一大段注释,这个看着怪突然的。实际上,这里描述的就是 I2C 的状态监控函数,还记得我们上一小节主发送器传送序列图中说的,这个 EV几 EV几 这个事件嘛,当时介绍的是,STM32 有的状态可能会同时置多个标志位,如果你只检查某一个标志位就认为这个状态已经发生了,可能不太严谨,而如果你用 GetFlagStatus 函数读多次,再进行判断,又可能比较麻烦,所以这里库函数就给了我们多种监控标志位的方案。其中第一种,叫做基本状态监控,使用 I2C_CheckEvent 这个函数,这个方式就是就是同时判断一个或多个标志位,来确定 EV几 EV几 这个状态是否发生,和我们主发送器传送序列图中的流程是对应的,所以推荐使用第一种方法来监控状态,那这里有一大堆解释,感兴趣的话可以自己再看看。然后是第二种,叫做高级状态监控,使用 I2C_GetLastEvent 这个函数,当然这高级的方法实际上并不高级,它就是直接把 SR1 和 SR2 这两个状态寄存器拼接成 16 位的数据扔给你,爱咋处理随便你,所以这个函数,我们一般不用,了解即可。然后最后,第三种,叫做基于标志位的状态监控,使用 I2C_GetFlagStatus 这个函数,那这个就是我们之前一直在使用的方法,可以判断某一个标志位是否置 1 了,这就是这三种状态监控的函数。那下面就是这三个函数的声明了,其中 GetFlagStatus 是我们熟悉的方法,CheckEvent 是需要我们掌握的方法,GetLastEvent 了解即可。
好,到这里,我们这个库函数就看完了,接下来我们来开始写硬件 I2C 的代码。那根据之前规划的流程。
- 首先是初始化函数内部,我们要对硬件 I2C 进行初始化。
void MPU6050_Init(void) {
//1. 开启 I2C 外设和对应 GPIO 口的时钟。
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);//I2C1 和 I2C2 都是 APB1 的外设,不要写错了。参数,转到定义看一下,上面可以看到 I2C1 和 I2C2 的参数,我们使用的是 I2C2,所以选择后面这个参数。第二个参数,ENABLE,开启 I2C2 的时钟,这样,I2C2 的时钟就打开了。
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);//GPIO 是 APB2 的外设,这两个的 APB 总线并不一样。当然我们还是转到定义看一下,我们只要从函数上面的注释里选参数,这样 APB1 还是 APB2,就肯定搞不错,那这里,GPIO 我们要用的是 PB10 和 PB11,所以这里需要开启 GPIOB 的时钟。第二个参数,ENABLE,这样就开启了 GPIOB 的时钟,开启时钟就完成了。
//2. 把 I2C 外设对应的 GPIO 口初始化为复用开漏模式。
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD;//复用开漏模式,开漏是 I2C 协议的设计要求,复用就是 GPIO 的控制权要交给硬件外设。我们这是硬件 I2C,那控制引脚的任务肯定得交给外设来做了,如果是之前的软件 I2C 的话,我们通过程序来控制引脚,那就是通用开漏模式,这个注意一下。
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);//GPIO 配置好默认低电平
//3. 使用结构体,初始化 I2C2 外设。这里还是一样的套路,我们调用 I2C_Init 函数,通过结构体来初始化 I2C2,第一个参数,I2Cx,我们给 I2C2,第二个参数,配置结构体。我就直接在上面写了,其实写多了就能记下来了,都是一个套路。
I2C_Init(I2C2, );
//4. I2C_Cmd,使能 I2C。
I2C_Cmd(I2C2, ENABLE);
//这样整个 I2C2,就初始化完了
MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);//配置电源管理寄存器 1
MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00);//配置电源管理寄存器 2
//接着继续,我们配置 4 个寄存器
MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09);//采样率分频
MPU6050_WriteReg(MPU6050_CONFIG, 0x06);//配置寄存器
MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);//陀螺仪配置寄存器
MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);//加速度计配置寄存器
}
好,到这里,初始化替换的任务就完成了。接下来我们继续来替换上面这两个函数里的内容。
- 写寄存器,也就是指定地址写一个字节的时序。
我们来用硬件 I2C 的方法实现同样的功能。那我们对照着软件 I2C 的流程,同时也对照着主机发送的序列图,来写程序。
- 生成起始条件
I2C_GenerateSTART(I2C2, ENABLE);//这样就能生成起始条件了,这个就替换软件 I2C 的 Start 函数。
另外,软件 I2C 的这些函数,内部都有 Delay 操作,是一种阻塞式的流程。也就是函数运行完成之后,对于的波形也肯定发送完毕了,所以上一个函数运行完之后,就可以紧跟下一个函数。
但是 I2C_GenerateSTART 这个函数,包括之后的硬件 I2C 函数 ,都不是阻塞式的,这些硬件 I2C 函数只管给寄存器的位置 1,或者只在 DR 写入数据,就结束,退出函数,至于波形是否发送完毕,它是不管的。所以对于这种非阻塞式的程序,在函数结束之后,我们都要等待相应的标志位,来确保这个函数的操作执行到位了。看一下主机发送的序列图,当起始条件的波形确实发出了,会产生 EV5 事件。
- 等待 EV5 事件的到来
如何检查 EV5 事件呢,我们看一下库函数,这里要用到状态监控函数了,我们使用第一种方法即可。
while(I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT) != SUCCESS);
参数第二个就是指定要检查哪个事件了。转到定义看一下,上面一大片都是参数列表,刚才说了,我们要等待 EV5 事件,右边这里找一下,EV5 事件,就是对应的参数,库函数给这个 EV5 事件还起了个名字,叫做主机模式选择。因为 STM32 默认为从机,发送起始条件后变为主机,所以 EV5 事件也可以叫做主机模式已选择的事件。这样就是监测 EV5 事件是否发生了。
然后再看一下返回值,返回值是 SUCCESS,表示最后一个事件等于我们指定的事件,也就是说指定事件发生了;或者 ERROR,就是表示指定时间没发生。那在这里,为了等待指定事件的发生,我们需要套一个 while 循环,这样,加一个 while,如果检查 EV5 事件 != SUCCESS,就一直空循环等待。否则,就跳出循环,这样就能实现功能了。和我们串口那里基本是一样的逻辑,当然等会程序中还要加很多这样的 while 死循环等待,这种 while 循环加多了,一旦总线出问题了,就很容易造成整个程序卡死,
所以我们需要设计一个超时退出的机制,这个我们最后再来完善。在超时退出的机制中会将该语句封装为:
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
目前程序先这样,我们继续起始条件发出后,我们就要发送从机地址,接收应答了,看一下序列图,这里也是指示我们发送地址,接收应答。
- 发送从机地址
就是发送一个字节,直接向 DR 寄存器写入一个字节就行了。/刚才说了这里 SendData 和 Send7bitAddress 都可以完成这个功能,就用它给我们提供的专用函数来完成。
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);
第二个参数是从机地址,我们把宏定义的从机地址拿过来;第三个参数,是方向,也就是从机地址的最低位,读写位。转到定义看一下,这里如果选择 Transmitter,发送,它就给你的地址最低位清 0;如果选择 Receiver,接收,它就给你的地址最低位置 1,那我们目前是要发送。
这样从机地址就发送完成了,然后接收应答,这里并不需要一个函数来操作,在这个库函数中,发送数据都自带了接收应答的过程,同样接收数据也自带了发送应答的过程,如果应答错误,硬件会通过置标志位和中断来提示我们,所以发送地址之后,应答位就不需要处理了。
- 直接等待事件
看一下时序图,当地址发出,接收应答位之后,就会产生 EV6 事件。
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);
转到定义看一下,EV6 事件这里有两个,通过名字应该很容易区分,上面这个是发送模式已选择,下面这个是接收模式已选择。
目前我们是主机发送的时序,所以显然要用上面这个 EV6 事件。这样就能等待 EV6 事件。
EV6 事件之后,我们继续,EV6 事件之后有个 EV8_1 事件,这个 EV8_1 事件是告诉你,该写入 DR 发送数据了,我们并不需要等待这个 EV8_1 事件的。在库函数里看一下,这个参数列表里也没有 EV8_1 事件的参数。
- 所以这时我们就是直接写入 DR,发送数据。
I2C_SendData(I2C2, RegAddress);
参数第二个,是一个字节的数据,对照一下软件 I2C,这时我们就应该发送 RegAddress 了。
- 这个操作之后,还是等待事件。
看一下时序图,这个时刻,我们写入了 DR,DR 立刻转移到移位寄存器进行发送,此时波形产生,我们写入 DR 后,需要等待的是 EV8 事件。
可以看出,EV8 事件出现得非常快,基本是不用等的,因为有两级缓存嘛,第一个数据写入 DR 了,会立刻跑到移位寄存器,这时不用等第一个数据发完,第二个数据就可以写进去等着了。
那在程序中,我们写完 DR 之后,还是要例行检查一下 EV8 事件的。
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING);//EV8 事件的参数,名字是字节正在发送,
那根据我们的分析,这个 while 循环,应该不用等很久,然后继续
- 当等到了 EV8 事件,此时应该是在数据 1 发送的开始时刻,我们可以直接写入下一个数据。
I2C_SendData(I2C2, Data);//发送的内容,就是上面这里的 Data
- 之后同样是等待事件
由于我们这个 Data 是最后一个字节,发送完 Data 之后,就需要终止了,所以最后等待的这个事件有所不同。当我们有连续的数据需要发送时,在发送过程中,我们需要等待 EV8 事件,而当我们发送完最后一个字节时,需要等待的就是 EV8_2 事件了,什么时候会产生 EV8_2 呢,下面这里解释是 BTF 标志位为 1,也就是移位完成了,并且没有新的数据可以发的时候,置 BTF,也就是 EV8_2,就像是我们排队一样,当我们这个队伍不进新人了,并且把当前队伍里已经排着的人都消化完之后,这才表示,所有的人都已经服务完毕了,同理在这个时序的最后,我们需要等待硬件把两级缓存,所有的数据都清空,才能产生终止条件。
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);//EV8_2 事件,名字是字节已经发送完毕
总结一下,当我们需要发送多个字节的数据流时,中间的字节,写入 DR 之后,需要等待 EV8 事件,也就是 I2C_EVENT_MASTER_BYTE_TRANSMITTING;最后一个字节,写入 DR 之后,需要等待 EV8_2 事件,也就是 I2C_EVENT_MASTER_BYTE_TRANSMITTED,这就是流程。
- 那在 EV8_2 事件之后,我们就可以终止了。
I2C_GenerateSTOP(I2C2, ENABLE);//终止时序
这样我们就用硬件 I2C 的代码替换了上面软件 I2C 的代码,两种方式产生的时序波形是一样的,完成的任务也是一样的。
不过可以看出,两者代码上的区别还是挺大的,但是只要我们把 I2C 协议理解了,硬件外设也研究清楚了,这些操作其实还是不难的。
//指定地址写寄存器
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data){//参数是 8 位的寄存器地址 和 8 位的数据
//1.生成起始条件
I2C_GenerateSTART(I2C2, ENABLE);//这样就能生成起始条件了
//2.等待 EV5 事件的到来
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);//参数第二个就是指定要检查哪个事件了。
//3.需要发送从机地址
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);//第二个参数是从机地址,我们把宏定义的从机地址拿过来,第三个参数,是方向,也就是从机地址的最低位,读写位。
//4.我们直接等待事件,看一下时序图,当地址发出,接收应答位之后,就会产生 EV6 事件。
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);//目前我们是主机发送的时序,所以显然要用上面这个 EV6 事件。这样就能等待 EV6 事件。
//5.是直接写入 DR,发送数据。
I2C_SendData(I2C2, RegAddress);//参数第二个,是一个字节的数据,对照一下软件 I2C,这时我们就应该发送 RegAddress 了。
//6.这个操作之后,还是等待事件。我们写完 DR 之后,还是要例行检查一下 EV8 事件的。
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING);//EV8 事件的参数,名字是字节正在发送,
//7.当等到了 EV8 事件,此时应该是在数据 1 发送的开始时刻,我们可以直接写入下一个数据,
I2C_SendData(I2C2, Data);//发送的内容,就是上面这里的 Data
//8.之后同样是等待事件。当我们发送完最后一个字节时,需要等待的就是 EV8_2 事件了
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);//EV8_2 事件,名字是字节已经发送完毕
//9.那在 EV8_2 事件之后,我们就可以终止了,
I2C_GenerateSTOP(I2C2, ENABLE);//终止时序
}
那到这里,我们这个写寄存器的函数就替换好了。接着是下一个,读寄存器的函数。
- 读寄存器,也就是指定地址读一个字节的时序。
- 复制复合格式
那指定地址读寄存器呢,上面这里复合格式的前面一部分和上面是一样的,我们同样也复制一下。
//1.生成起始条件
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
//2.发送从机地址
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);
//3.直接写入 DR,发送数据。
I2C_SendData(I2C2, RegAddress);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);
//这是复合格式指定地址的一部分。
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);
当然这个事件这里,有个细节可以研究一下。就是这个位置,是用 I2C_EVENT_MASTER_BYTE_TRANSMITTING 还是 I2C_EVENT_MASTER_BYTE_TRANSMITTED,如果用 I2C_EVENT_MASTER_BYTE_TRANSMITTING,那实际这个事件发生时,RegAddress 的波形其实还没有完全发送完毕,这时在直接产生重复起始条件,会不会把这个数据截断呢,但经过实测,其实并不会截断,当我们调用起始条件之后,如果当前还有字节正在移位,那这个起始条件会延迟,等待当前字节发送完毕后,才能产生。
所以这里用 I2C_EVENT_MASTER_BYTE_TRANSMITTING 还是 I2C_EVENT_MASTER_BYTE_TRANSMITTED 都没问题,如果用 I2C_EVENT_MASTER_BYTE_TRANSMITTING,那下面重复起始条件之后将会等待,如果用 I2C_EVENT_MASTER_BYTE_TRANSMITTED,那等待就是在这里,等波形全都发完了,再产生重复起始条件,那这里我们保险起见,这里就还是用 I2C_EVENT_MASTER_BYTE_TRANSMITTED,也就是按照设计要求来,在数据流的最后一个字节,还是等待 I2C_EVENT_MASTER_BYTE_TRANSMITTED 比较好。
- 生成重复起始条件
接下来,对照软件 I2C 的代码,在指定地址之后,我们要生成重复起始条件
I2C_GenerateSTART(I2C2, ENABLE);//再来个起始条件
那我们继续往后看,起始条件之后,接下来我们参考主机接收的序列图。
- 起始条件之后,需要等待 EV5 事件
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
- 接着继续,下一步,还是发送从机地址,不过这里的读写位就要是读的方向了
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Receiver);//把第三个参数改成 Receiver,接收的方向。
那使用 Receiver 的参数之后,函数内部就自动把这个地址最低位置 1,就不再需要我们自己写一个 | 0x01 了。
- 那这之后,还是等待事件。我们看一下,在寻址之后,我们也是等待 EV6 事件。
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED);//但是主机接收的 EV6 并不是主机发送的 EV6,所以这个参数和上面不一样。
我们转到定义看一下,这里有两个 EV6 事件,现在是主机接收,所以这时我们要用下面这个 I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED。这个注意一下,不要搞错了。
接着我们继续,进入到主机接收的模式之后,就开始接收从机发的数据波形了。看一下序列图,在接收一个字节时,有个 EV6_1 事件,这个事件没有标志位,也不需要我们等待,它是适合接收一个字节的情况,那我们目前是要接收几个字节呢,就是一个字节。
我们是指定地址读一个字节,所以在 EV6_1 时,也就是 EV6 事件之后,我们要注意什么呢,就是后面这里说的,恰好在 EV6 之后,要清除响应和停止条件的产生,也就是告诉我们,在代码的这个时刻,我们要把应答位 ACK 置 0,同时把停止条件生成位 STOP 置 1。
那你可能会问,这时不应该是接收数据吗,数据都没收到,就要产生停止条件?
答案确实如此,这里规定就是,在接收最后一个字节之前,就要提前把 ACK 置 0,同时设置停止位 STOP,因为我们目前是接收一个字节,所以在进入接收模式之后,就要立刻 ACK 置 0,STOP 置 1。
为什么要这样设计呢?
我们看一下时序图,这里,如果你不提前在数据还没收到的时候给 ACK 置 0,那等时序到了应答位和数据 2这里,数据已经收到了,你再说,我要置 0,我要给非应答,这时是不是就晚了。数据收到之前,应答位就已经发送出去了,你这是再给 ACK 置 0,那只能在下一个数据之后给非应答了。时序不等人,所以在最后一个数据之前,就要给 ACK 置 0,同时这里也建议我们提前设置 STOP 终止条件,这个终止条件,也不会截断当前字节,它会等当前字节接收完成后,再产生终止条件的波形。
所以总结一下就是,如果你是读取多个字节,那直接等待 EV7 事件,读取 DR,就能收到数据了,这样依次接收,在接收最后一个字节之前,也就是这里的 EV7_1 事件,需要提前把 ACK 置 0,STOP 置 1。
- 如果你只需要读取一个字节,那在 EV6 事件之后,就要立刻 ACK 置 0,STOP 置 1,
要不然你设置晚了,时序上就会多一个字节出来,那回到代码,我们目前只需要读取一个字节,所以在这个 EV6 事件之后。
I2C_AcknowledgeConfig(I2C2, DISABLE);//配置 ACK 位,这就是设置 ACK = 0,不给应答
I2C_GenerateSTOP(I2C2, ENABLE);//配置停止位,这就是设置 STOP = 1,申请产生终止条件
- 等这些做完之后,我们再等待 EV7 事件,EV7 事件就是接收到一个字节后会产生。
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_RECEIVED);
- 等 EV7 事件产生后,一个字节的数据就已经在 DR 里面了,我们读取 DR 即可拿出这一个字节。
uint8_t Data;
Data = I2C_ReceiveData(I2C2);//读取 DR,它的返回值,就是 DR 的数据,我们把它存到 Data 变量里,这样就完成了。
- 当然最后别忘了把 ACK 再置回 1
I2C_AcknowledgeConfig(I2C2, ENABLE);
为什么要怎样做呢?我们的想法是,默认状态下 ACK 就是 1,给从机应答,在收最后一个字节之前,临时把 ACK 置 0,给非应答,所以在接收函数的最后,要恢复默认的 ACK = 1,这个流程是为了方便指定地址收多个字节。虽然我们下载程序中至始至终只有收一个字节的,我们从来就没给过应答,但是这个形式还是要写一下的,方便我们之后进一步改进代码,这个说明一下。
到此,这个指定地址收一个字节的函数就写完了。这个逻辑看上去是不是比较绕,尤其要注意这最后一个字节,在接收之前,要提前设置 ACK 和 STOP,如果你要改进代码,接收多个字节的话,就可以把 步骤5、6、7 这 4 行用 for 循环套起来,循环执行,然后在接收前面字节时,只执行后面两行,再加个 if,如果计数到了最后一个字节,if 成立,那就是 4 行全都执行,这样就能完成指定地址读一个字节到指定地址读多个字节的升级了,这个功能,学有余力的同学可以尝试一下。
uint8_t MPU6050_ReadReg(uint8_t RegAddress) {//参数只有一个,指定读的地址
uint8_t Data;
1.那指定地址读寄存器呢,上面这里复合格式的前面一部分和上面是一样的,我们同样也复制一下。
//1.生成起始条件
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
//2.发送从机地址
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);
//3.直接写入 DR,发送数据。
I2C_SendData(I2C2, RegAddress);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);
//这是复合格式指定地址的一部分。
//2.接下来,对照软件 I2C 的代码,在指定地址之后,我们要生成重复起始条件
I2C_GenerateSTART(I2C2, ENABLE);//再来个起始条件
//3.起始条件之后,需要等待 EV5 事件
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
//4.接着继续,下一步,还是发送从机地址,不过这里的读写位就要是读的方向了
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Receiver);//把第三个参数改成 Receiver,接收的方向。
//5.那这之后,还是等待事件。我们看一下,在寻址之后,我们也是等待 EV6 事件。
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED);//现在是主机接收,所以这时我们要用下面这个 I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED。
//6.如果你只需要读取一个字节,那在 EV6 事件之后,就要立刻 ACK 置 0,STOP 置 1,
I2C_AcknowledgeConfig(I2C2, DISABLE);//配置 ACK 位,这就是设置 ACK = 0,不给应答
I2C_GenerateSTOP(I2C2, ENABLE);//配置停止位,这就是设置 STOP = 1,申请产生终止条件
//7.等这些做完之后,我们再等待 EV7 事件,EV7 事件就是接收到一个字节后会产生。
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_RECEIVED);
//8.等 EV7 事件产生后,一个字节的数据就已经在 DR 里面了,我们读取 DR 即可拿出这一个字节。
Data = I2C_ReceiveData(I2C2);//读取 DR,它的返回值,就是 DR 的数据,我们把它存到 Data 变量里,这样就完成了。
//9.当然最后别忘了把 ACK 再置回 1
I2C_AcknowledgeConfig(I2C2, ENABLE);
return Data;
}
好,现在我们这个读寄存器的函数也转换完毕了。
这样,整个代码,由软件 I2C 到硬件 I2C 的转变,就完成了。我们测试一下,编译,看一下,读取 ID 是 68,下面是数据,目前程序也是完全没问题的。程序现象和软件 I2C 的是一样的。那程序到这里,我们本节课的硬件 I2C,就实现成功了。
最后,我们来解决一下之前提到的死循环的问题,目前可以看到,这个程序中,出现了大量 while 死循环等待,这种大量的死循环等待,在程序中是比较危险的,一旦有一个事件一直没有产生,就会让整个程序卡死,所以这种死循环等待,我们可以给它加一个超时退出机制,这个超时退出机制也不用写的很高大上,说我用个定时器,来计时退出,这也没必要,太麻烦了,我们就用一个简单的计数等待就行了。
如果你不嫌麻烦的话,可以把每个 while 循环都改成超时退出机制的形式。当然我还是觉得这样改太麻烦,而且不美观,所以我打算把 CheckEvent 函数封装一下,变成一个带有超时退出机制的 WaitEvent 函数。
- 带有超时退出机制的 WaitEvent 函数
void MPU6050_WaitEvent(I2C_TypeDef* I2Cx, uint32_t I2C_EVENT){//参数呢,转到 I2C_CheckEvent 函数的定义,把它的参数原封不动的复制过来,
//函数里面呢,先定义 Timeout 变量,变量类型给大点
uint32_t Timeout;
//然后计时之前,Timeout 给一个比较大的数,比如 10000,然后 while 的分号去掉,给个循环体,每循环一次,
Timeout = 10000;
while(I2C_CheckEvent(I2Cx, I2C_EVENT) != SUCCESS) {//等待事件,参数把上面的形参传过来,变成一个通用的等待函数
Timeout--;
if (Timeout == 0) {//也就是 10000 减到 0 了,循环 10000 次了,你 while 咋还在循环呢,超时了,我可等不及了
break;
}
}
}
这样封装完之后,原来的 CheckEvent,全都替换成封装好的 WaitEvent 就行了。
在 if 里面,可以使用 break,break 是跳出循环的关键字,就是强行打破 while 循环,继续执行后面的代码。当然也可以用 return,return 是函数返回值的关键字,同时它也能结束整个函数,就是 while 我不循环了,后面的代码我也不执行了,直接跳出整个函数,不过这里的 return 就只能结束这个子函数了,原来外层的函数,就没法直接 return 了,这里就改成 break 吧,这算是个小缺点,不过也不影响。
当然这个循环 10000 次到底花了多长时间,不太好确定,实际应该需要给多大的参数,可以自己手动调值测试一下,最终通过实验得到一个比较合理的计次值,这就是比较简单且常用的计次计时、超时退出的机制。
然后这样:MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
参数原封不动的传给 WaitEvent 函数,在 WaitEvent 函数里面进行等待和超时退出,这样就行了。然后下面的等待,我们全都可以这样来替换。这样看着,代码是不是更整洁一些,结构比较清晰。
好我们再试一下,下载,看一下,目前程序现象也是没问题的,但是这个代码是有超时等待的,可以防止意外卡死。当然更进一步,在实际项目中,如果想让代码更加完善,在这个超时退出的时刻,就不能说是简单的 break 了。这里还应该做一些相应的错误处理操作,比如说打印错误日志,继续系统的复位,或者说,如果项目涉及危险的机械结构,就要评估一下,是不是应该进行紧急停机的操作,这样代码才会更加健全,更加安全。那这些错误处理的内容呢,大家可以在之后的实践中考虑一下。
好,本小姐硬件 I2C 的代码部分,到这里就结束了。
最终给出示例代码作为参考:
MPU6050.c
#include "stm32f10x.h" // Device header
#include "MPU6050_Reg.h"
#define MPU6050_ADDRESS 0xD0
void MPU6050_WaitEvent(I2C_TypeDef* I2Cx, uint32_t I2C_EVENT){//参数呢,转到 I2C_CheckEvent 函数的定义,把它的参数原封不动的复制过来,
//首先定义一个变量,变量类型给大点
uint32_t Timeout;
//然后计时之前,Timeout 给一个比较大的数,比如 10000,然后 while 的分号去掉,给个循环体,每循环一次,
Timeout = 10000;
while(I2C_CheckEvent(I2Cx, I2C_EVENT) != SUCCESS) {//等待事件,参数把上面的形参传过来,变成一个通用的等待函数
Timeout--;
if (Timeout == 0) {//也就是 10000 减到 0 了,循环 10000 次了,你 while 咋还在循环呢,超时了,我可等不及了
break;//在 if 里面,可以使用 break,break 是跳出循环的关键字,就是强行打破 while 循环,继续执行后面的代码。
}
}
}
//指定地址写寄存器
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data){//参数是 8 位的寄存器地址 和 8 位的数据
//1.生成起始条件
I2C_GenerateSTART(I2C2, ENABLE);//这样就能生成起始条件了
//2.等待 EV5 时间的到来
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);//参数第二个就是指定要检查哪个事件了。我们要等待 EV5 事件
//3.发送从机地址
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);//第二个参数是从机地址,我们把宏定义的从机地址拿过来,第三个参数,是方向,也就是从机地址的最低位,读写位。
//4.直接等待事件,看一下时序图,当地址发出,接收应答位之后,就会产生 EV6 事件。
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);//转到定义看一下,EV6 事件这里有两个,通过名字应该很容易区分,上面这个是发送模式已选择,下面这个是接收模式已选择。
//5.所以这时我们就是直接写入 DR,发送数据。
I2C_SendData(I2C2, RegAddress);//参数第二个,是一个字节的数据,对照一下软件 I2C,这时我们就应该发送 RegAddress 了。
//6.这个操作之后,还是等待事件。我们写入 DR 后,需要等待的是 EV8 事件。
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING);//EV8 事件的参数,名字是字节正在发送,
//7.当等到了 EV8 事件,此时应该是在数据 1 发送的开始时刻,我们可以直接写入下一个数据,
I2C_SendData(I2C2, Data);//发送的内容,就是上面这里的 Data
//8.之后同样是等待事件,当我们发送完最后一个字节时,需要等待的就是 EV8_2 事件了
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);//EV8_2 事件,名字是字节已经发送完毕
//9.那在 EV8_2 事件之后,我们就可以终止了,
I2C_GenerateSTOP(I2C2, ENABLE);//终止时序
}
//那到这里,我们这个写寄存器的函数就替换好了。接着是下一个,读寄存器的函数。
uint8_t MPU6050_ReadReg(uint8_t RegAddress) {//参数只有一个,指定读的地址
uint8_t Data;
1.那指定地址读寄存器呢,上面这里复合格式的前面一部分和上面是一样的,我们同样也复制一下。
//1.生成起始条件
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
//2.发送从机地址
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);
//3.直接写入 DR,发送数据。
I2C_SendData(I2C2, RegAddress);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);
//这是复合格式指定地址的一部分。
//2.接下来,对照软件 I2C 的代码,在指定地址之后,我们要生成重复起始条件
I2C_GenerateSTART(I2C2, ENABLE);//再来个起始条件
//3.起始条件之后,需要等待 EV5 事件
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
//4.接着继续,下一步,还是发送从机地址,不过这里的读写位就要是读的方向了
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Receiver);//把第三个参数改成 Receiver,接收的方向。
//5.那这之后,还是等待事件。我们看一下,在寻址之后,我们也是等待 EV6 事件。
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED);//现在是主机接收,所以这时我们要用下面这个 I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED。
//6.如果你只需要读取一个字节,那在 EV6 事件之后,就要立刻 ACK 置 0,STOP 置 1,
I2C_AcknowledgeConfig(I2C2, DISABLE);//配置 ACK 位,这就是设置 ACK = 0,不给应答
I2C_GenerateSTOP(I2C2, ENABLE);//配置停止位,这就是设置 STOP = 1,申请产生终止条件
//7.等这些做完之后,我们再等待 EV7 事件,EV7 事件就是接收到一个字节后会产生。
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_RECEIVED);
//8.等 EV7 事件产生后,一个字节的数据就已经在 DR 里面了,我们读取 DR 即可拿出这一个字节。
Data = I2C_ReceiveData(I2C2);//读取 DR,它的返回值,就是 DR 的数据,我们把它存到 Data 变量里,这样就完成了。
//9.当然最后别忘了把 ACK 再置回 1
I2C_AcknowledgeConfig(I2C2, ENABLE);
return Data;
}
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;//复用开漏模式,开漏是 I2C 协议的设计要求,复用就是 GPIO 的控制权要交给硬件外设。
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);//GPIO 配置好默认低电平
I2C_InitTypeDef I2C_InitStructure;
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C;//I2C 模式,我们用的是 I2C,所以选择 I2C 这个参数。
I2C_InitStructure.I2C_ClockSpeed = 50000;//时钟速度,这个参数可以配置 SCL 的时钟频率,写一个数就行了。数值越大,SCL 频率越高,数据传输就越快。
//你要是写时钟频率在 0~100 kHz 的范围,I2C 就处于一个标准速度状态;如果你写的 100 kHz ~ 400 kHz 的范围,I2C 就处于快速的状态,那快速状态最大也只能有 400 kHz。
I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2;//时钟占空比,这个时钟占空比参数,只有在时钟频率大于 100 kHz,也就是进入到快速状态时才有用。
//在小于等于 100 kHz 的标准速度下,占空比是固定的 1:1,也就是低电平时间比高电平时间,约等于 1:1。看一下参数列表,这里时钟占空比有 16:9 和 2,2 就是 2:1,16:9 就是 SCL 时钟的低电平时间和高电平时间是 16:9 的比例关系;2:1 就是低电平时间和高电平时间是 2:1 的比例关系。
I2C_InitStructure.I2C_Ack = I2C_Ack_Enable;//应答位配置,这个参数也是配置寄存器的 ACK 位的,用于确定在接收一个字节后是否给从机应答。
I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;//这个是指定 STM32 作为从机,可以响应几位的地址,可以选择响应 10 位地址或响应 7 位的地址。
I2C_InitStructure.I2C_OwnAddress1 = 0x00;//自身地址 1,这个也是 STM32 作为从机使用的,用于指定 STM32 的自身地址,方便别的主机呼叫它,如果上一个参数选择了响应 7 位地址,
I2C_Init(I2C2, &I2C_InitStructure);//那这样我们这个 I2C 的初始化就完成了。
I2C_Cmd(I2C2, ENABLE);
//配置电源管理寄存器 1
MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);
//配置电源管理寄存器 2
MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00);
//接着继续,我们配置上面这 4 个寄存器
MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09);//采样率分频
MPU6050_WriteReg(MPU6050_CONFIG, 0x06);//配置寄存器
MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);//陀螺仪配置寄存器
MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);//加速度计配置寄存器
}
//可以在这里,再写个函数
uint8_t MPU6050_GetID(void) {//获取 ID 号
return MPU6050_ReadReg(MPU6050_WHO_AM_I);//这样就能得到芯片的 ID 号了
}
//接下来我们想获取数据的话,只需要再写一个获取数据寄存器的函数即可。所以这里来个
void MPU6050_GetData(int16_t* AccX, int16_t* AccY, int16_t* AccZ,
int16_t* GyroX, int16_t* GyroY, int16_t* GyroZ) {
//想要获取数据,我们就通过 ReadReg 函数,读取数据寄存器,首先读取加速度寄存器 X 轴的高 8 位,然后再读取加速度寄存器的低 8 位。前面定义两个变量
uint8_t DataH, DataL;
DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H);//读取高 8 位的值放在 DataH 中。
DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L);//读取低 8 位的值放在 DataL 中。
*AccX = (DataH << 8) | DataL;//之后,高 8 位左移 8 位再 | 上低 8 位,这就是加速度计 X 轴的 16 位数据,得到 16 位数据之后,用指针引用传递进来的地址,把读到的数据,通过指针返回回去,这样 AccX 的值就完成了。
//接下里读取后续的数据,同样的操作。
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);//陀螺仪 X 轴
DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L);
*GyroX = (DataH << 8) | DataL;
DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H);//陀螺仪 Y 轴
DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L);
*GyroY = (DataH << 8) | DataL;
DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H);//陀螺仪 Z 轴
DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L);
*GyroZ = (DataH << 8) | DataL;
}