【STM32 学习笔记】I2C通信协议
注:通信协议的设计背景 3:00~10:13
I2C 通讯协议(Inter-Integrated Circuit)是由Phiilps公司开发的,由于它引脚少,硬件实现简单,可扩展性强, 不需要USART、CAN等通讯协议的外部收发设备,现在被广泛地使用在系统内多个集成电路(IC)间的通讯。
I2C总线是一种用于芯片之间进行通信的串行总线。它由两条线组成:串行时钟线(SCL)和串行数据线(SDA)。这种总线允许多个设备在同一条总线上进行通信。
物理层
I2C通讯设备之间的常用连接方式见图
I2C通信协议是一种通用的总线协议。I2C通信协议有以下特征:
- (1) 它是一个支持设备的总线。“总线”指多个设备共用的信号线。在一个I2C通讯总线中, 可连接多个I2C通讯设备,支持多个通讯主机及多个通讯从机。
- (2) 一个I2C总线只使用两条总线线路,一条双向串行数据线(SDA) , 一条串行时钟线 (SCL)。数据线即用来表示数据,时钟线用于数据收发同步。
- (3) 每个连接到总线的设备都有一个独立的地址, 主机可以利用这个地址进行不同设备之间的访问。
- (4) 总线通过上拉电阻接到电源。当I2C设备空闲时,会输出高阻态, 而当所有设备都空闲,都输出高阻态时,由上拉电阻把总线拉成高电平。
- (5) 多个主机同时使用总线时,为了防止数据冲突, 会利用仲裁方式决定由哪个设备占用总线。
- (6) 具有三种传输模式:标准模式传输速率为100kbit/s ,快速模式为400kbit/s , 高速模式下可达 3.4Mbit/s,但目前大多I2C设备尚不支持高速模式。
- (7) 连接到相同总线的 IC 数量受到总线的最大电容 400pF 限制
- SDA数据线在每个SCL的时钟周期传输一位数据,SCL为高电平的时候SDA表示的数据有效。
- 应答信号和非应答信号I2C的数据和地址传输都带响应。
一主多从是指单片机作为主机,主导I2C总线的运行。挂在I2C总线上的所有外部模块都是从机,只有被主机点名后才能控制I2C总线,不能在未经允许的情况下访问I2C总线,以防止冲突。这就像在课堂上,老师是主机,学生是从机。未经点名允许,学生不能发言,但可以被动地听老师讲课。
另外,I2C还支持多主多从模型,即多个主机。在多主多从模型中,总线上任何一个模块都可以主动跳出来说,接下来我就是主机,你们都得听我的。这就像在教室里,老师正在讲课,突然一个学生站起来说,打断一下,接下来让我来说,所有同学听我指挥。但是,同一个时间只能有一个人说话,这时就相当于发生了总线冲突。在总线冲突时,I2C协议会进行仲裁,仲裁胜利的一方取得总线控制权,失败的一方自动变回从机。由于时钟线也由主机控制,所以在多主机的模型下还要进行时钟同步。多主机的情况下,协议是比较复杂的。本课程仅使用一主多从模型。
以上是有关I2C的设计背景和基本功能。接下来我们将详细分析I2C如何实现这些功能。 作为一个通信协议,I2C必须在硬件和软件上作出规定。硬件上的规定包括电路的连接方式、端口的输入输出模式等;软件上的规定包括时序的定义、字节的传输方式、高位先行还是低位先行等。这些硬件和软件的规定结合起来构成了一个完整的通信协议。
协议层
I2C的协议定义了通讯的起始和停止信号、数据有效性、响应、仲裁、时钟同步和地址广播等环节。
I2C基本读写过程
先看看I2C通讯过程的基本结构,它的通讯过程见图
接下来我们先看一下12C的硬件规定,也就是I2C的硬件电路
I2C的硬件电路
这个图就是I2C的典型电路模型,这个模型采用了一主多从的结构。在左侧,我们可以看到CPU作为主设备,控制着总线并拥有很大的权利。其中,主机对SCL线拥有完全的控制权,无论何时何地,主机都负责掌控SCL线。在空闲状态下,主机还可以主动发起对SDA的控制。但是,从机发送数据或应答时,主机需要将SDA的控制权转交给从机。
接下来,我们看到了一系列被控IC,它们是挂载在12C总线上的从机设备,如姿态传感器、OLED、存储器、时钟模块等。这些从机的权利相对较小。对于SCL时钟线,它们在任何时刻都只能被动的读取,不允许控制SCL线;对于SDA数据线,从机也不允许主动发起控制,只有在主机发送读取从机的命令后,或从机应答时,从机才能短暂地取得SDA的控制权。这就是一主多从模型中协议的规定。
然后我们来看接线部分。所有I2C设备的SCL和SDA都连接在一起。主机的SCL线拉出来,所有从机的SCL都接在这上面。主机的SDA线也是一样,拉出来,所有从机的SDA接在这上面。这就是SCL和SDA的接线方式。
那到现在,我们先不继续往后看了,先忽略这两个电阻,那到现在,假设我们就这样连接,那如何规定每个设备SCL和SDA的输入输出模式呢?
由于现在是一主多从结构,主机拥有SCL的绝对控制权,因此主机的SCL可以配置成推挽输出,所有从机的SCL都配置成浮空输入或上拉输入。数据流向为主机发送、所有从机接收。但是到SDA线这里就比较复杂了,因为这是半双工协议,所以主机的SDA在发送时是输出,在接收时是输入。同样地,从机的SDA也会在输入和输出之间反复切换。如果能够协调好输入输出的切换时机就没有问题。但是这样做的话,如果总线时序没有协调好,就极有可能发生两个引脚同时处于输出的状态。如果此时一个引脚输出高电平,一个引脚输出低电平,就会造成电源短路的情况,这是要极力避免的。
为了避免这种情况的发生,I2C的设计规定所有设备不输出强上拉的高电平,而是采用外置弱上拉电阻加开漏输出的电路结构。这两点规定对应于前面提到的“设备的SCL和SDA均要配置成开漏输出模式”以及“SCL和SDA各添加一个上拉电阻,阻值一般为4.7KΩ左右”。对应上面这个图。
所有的设备,包括CPU和被控IC,它们的引脚内部结构都如上图所示。图左侧展示的是SCL的结构,其中SClk代表SCL;右侧则是SDA的结构,其中DATA代表SDA。引脚的信号输入都可以通过一个数据缓冲器或施密特触发器进行,因为输入对电路无影响,所以任何设备在任何时刻都可以输入。然而,在输出部分,采用的是开漏输出的配置。
正常的推挽输出方式如下:上面一个开关管连接正极,下面一个开关管连接负极。当上面导通时,输出高电平;下面导通时,输出低电平。因为这是通过开关管直接连接到正负极的,所以这是强上拉和强下拉的模式。
而开漏输出呢,就是去掉这个强上拉的开关管,输出低电平时,下管导通,是强下拉,输出高电平时,下管断开,但是没有上管了,此时引脚处于浮空的状态,这就是开漏输出。
和这里图示是一样的,输出低电平,这个开关管导通,引脚直接接地,是强下拉,输出高电平,这个开关管断开,引脚什么都不接,处于浮空状态,这样的话,所有的设备都只能输出低电平而不能输出高电平,为了避免高电平造成的引脚浮空,这时就需要在总线外面,SCL和SDA各外置一个上拉电阻,这是通过一个电阻拉到高电平的,所以这是一个弱上拉。
UP主用弹簧和杆子的模型解释这一段硬件电路设计
这样做的好处是:
- 第一,完全杜绝了电源短路现象,保证电路的安全。你看所有人无论怎么拉杆子或者放手,杆子都不会处于一个被同时强拉和强推的状态,即使有多个人同时往下拉杆子,也没问题
- 第二,避免了引脚模式的频繁切换。开漏加弱上拉的模式,同时兼具了输入和输出的功能,你要是想输出,就去拉杆子或放手,操作杆子变化就行了,你要是想输入,就直接放手,然后观察杆子高低就行了,因为开漏模式下,输出高电平就相当于断开引脚,所以在输入之前,可以直接输出高电平,不需要再切换成输入模式了。
- 第三,就是这个模式会有一个“线与”的现象。就是只要有任意一个或多个设备输出了低电平,总线就处于低电平,只有所有设备都输出高电平,总线才处于高电平。I2C可以利用这个电路特性执行多主机模式下的时钟同步和总线仲裁,所以这里SCL虽然在一主多从模式下可以用推挽输出,但是它仍然采用了开漏加上拉输出的模式,因为在多主机模式下会利用到这个特征。
好,以上就是I2C的硬件电路设计,那接下来,我们就要来学习软件,也就是时序的设计了。
I2C时序设计
首先我们来学习一下I2C规定的一些时序基本单元。
起始和终止条件
起始条件是指SCL高电平期间,SDA从高电平切换到低电平。在I2C总线处于空闲状态时,SCL和SDA都处于高电平状态,由外挂的上拉电阻保持。当主机需要数据收发时,会首先产生一个起始条件。这个起始条件是,SCL保持高电平,然后把SDA拉低,产生一个下降沿。当从机捕获到这个SCL高电平,SDA下降沿信号时,就会进行自身的复位,等待主机的召唤。之后,主机需要将SCL拉低。这样做一方面是占用这个总线,另一方面也是为了方便这些基本单元的拼接。这样,除了起始和终止条件,每个时序单元的SCL都是以低电平开始,低电平结束。
终止条件是,SCL高电平期间,SDA从低电平切换到高电平。SCL先放开并回弹到高电平,SDA再放开并回弹高电平,产生一个上升沿。这个上升沿触发终止条件,同时终止条件之后,SCL和SDA都是高电平,回归到最初的平静状态。这个起始条件和终止条件就类似串口时序里的起始位和停止位。一个完整的数据帧总是以起始条件开始、终止条件结束。另外,起始和终止都是由主机产生的。因此,从机必须始终保持双手放开,不允许主动跳出来去碰总线。如果允许从机这样做,那么就会变成多主机模型,不在本节的讨论范围之内。这就是起始条件和终止条件的含义。
发送一个字节
接着继续看,在起始条件之后,这时就可以紧跟着一个发送一个字节的时序单元,如何发送一个字节呢?
就这样的流程,主机拉低SCL,把数据放在SDA上,主机松开SCL,从机读取SDA的数据,在SCL的同步下,依次进行主机发送和从机接收,循环8次,就发送了8位数据,也就是一个字节,另外注意,这里是高位先行,所以第一位是一个字节的最高位B7,然后依次是次高位B6…
接收一个字节
那我们再继续看最后两个基本单元,就是应答机制的设计。
发送应答和接收应答
发一字节收一位,收一字节发一位
应用:
I2C从机地址
12C的完整时序,主要有指定地址写,当前地址读和指定地址读这3种。
首先注意的是,我们这个12C是一主多从的模型,主机可以访问总线上的任何一个设备,那如何发出指令,来确定要访问的是哪个设备呢?
为了解决这个问题,我们需要为每个从设备分配一个唯一的设备地址。这些地址就像是每个设备的名字,主机通过发送这些地址来确定要与哪个设备通信。
当主机发送一个地址时,所有的从设备都会收到这个地址。但是,只有与发送的地址匹配的设备会响应主机的读写操作。
在I2C总线中,每个挂载的设备的地址必须是唯一的,否则当主机发送一个地址时,多个设备响应,就会导致混乱。
在12C协议标准中,从机设备地址分为7位和10位两种。我们今天主要讨论7位地址,因为它们相对简单且应用广泛。
每个I2C设备在出厂时都会被分配一个7位的地址。例如,MPU6050的7位地址是1101 000,而AT24C02的7位地址是1010 000。不同型号的芯片地址是不同的,但相同型号的芯片地址是相同的。
如果多个相同型号的芯片挂载在同一条总线上,我们可以通过调整地址的最后几位来解决这个问题。例如,MPU6050的地址可以通过ADO引脚来改变,而AT24C02的地址可以通过A0、A1、A2引脚来改变。这样,即使相同型号的芯片,挂载在同一个总线上,也可以通过切换地址低位的方式,保证每个设备的地址都是唯一的。这就是12C设备的从机地址。
下面时序讲解详情
注意:时序里面的RA是接收从机的应答位(Receive Ack, RA)
指定地址写
(Sláve Address + R/W) 中最后一位 0=W(写),根据协议规定,紧跟着的单元,就得是接收从机的应答位(Receive Ack, RA),在这个时刻,主机要释放SDA,
所以如果单看主机的波形,应该是这样,
释放SDA之后,引脚电平回弹到高电平,但是根据协议规定,从机要在这个位拉低SDA,所以单看从机的波形,应该是这样(绿色线)
该应答的时候,从机立刻拽住SDA,然后应答结束之后,从机再放开SDA,那现在综合两者的波形,结合线与的特性,在主机释放SDA之后,由于SDA也被从机拽住了,所以主机松手后,SDA并没有回弹高电平,这个过程,就代表从机产生了应答。最终高电平期间,主机读取SDA,发现是0,就说明,我进行寻址,有人给我应答了。如果主机读取SDA,发现是1,就说明,我进行寻址,应答位期间,我松手了,但是没人拽住它,没人给我应答,那就直接产生停止条件吧,并提示一些信息,这就是应答位。
然后这个上升沿,就是应答位结束后,从机释放SDA产生的,从机交出了SDA的控制权,因为从机要在低电平尽快变换数据,所以这个上升沿和SCL的下降沿,几乎是同时发生的。
当前地址读
指定地址读
指定地址读=指定地址写+当前地址读
Sr (Start Repeat)的意思就是重复起始条件,因为指定读写标志位只能是跟着起始条件的第一个字节,所以如果想切换读写方向,只能再来个起始条件。然后起始条件后,重新寻址并且指定读写标志位
代码实战:10-1 软件I2C读写MPU6050
由于我们这个代码使用的是软件I2C,就是用普通的GPIO口,手动翻转电平实现的协议,它并不需要STM32内部的外设资源支持,所以这里的端口(SDA,SCL),其实可以任意指定,不局限于这两个端口,你也可以SCL接PAO,SDA接PB12,或者SCL接PA8,SDA接PA9看,等等等等,接在任意的两个普通的GPIO口就可以。
软件I2C,只需要用gpio的读写函数就行了,就不用I2C的库函数了。
程序的整体框架:
MyI2C.h
#ifndef __MYI2C_H
#define __MYI2C_Hvoid 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"/*引脚配置层*//*** 函 数:I2C写SCL引脚电平* 参 数:BitValue 协议层传入的当前需要写入SCL的电平,范围0~1* 返 回 值:无* 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SCL为低电平,当BitValue为1时,需要置SCL为高电平*/
void MyI2C_W_SCL(uint8_t BitValue)
{GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue); //根据BitValue,设置SCL引脚的电平Delay_us(10); //延时10us,防止时序频率超过要求
}/*** 函 数:I2C写SDA引脚电平* 参 数:BitValue 协议层传入的当前需要写入SDA的电平,范围0~0xFF* 返 回 值:无* 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SDA为低电平,当BitValue非0时,需要置SDA为高电平*/
void MyI2C_W_SDA(uint8_t BitValue)
{GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue); //根据BitValue,设置SDA引脚的电平,BitValue要实现非0即1的特性Delay_us(10); //延时10us,防止时序频率超过要求
}/*** 函 数:I2C读SDA引脚电平* 参 数:无* 返 回 值:协议层需要得到的当前SDA的电平,范围0~1* 注意事项:此函数需要用户实现内容,当前SDA为低电平时,返回0,当前SDA为高电平时,返回1*/
uint8_t MyI2C_R_SDA(void)
{uint8_t BitValue;BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11); //读取SDA电平Delay_us(10); //延时10us,防止时序频率超过要求return BitValue; //返回SDA电平
}/*** 函 数:I2C初始化* 参 数:无* 返 回 值:无* 注意事项:此函数需要用户实现内容,实现SCL和SDA引脚的初始化*/
void MyI2C_Init(void)
{/*开启时钟*/RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); //开启GPIOB的时钟/*GPIO初始化*/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); //将PB10和PB11引脚初始化为开漏输出/*设置默认电平*/GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11); //设置PB10和PB11引脚初始化后默认为高电平(释放总线状态)
}/*协议层*//*** 函 数:I2C起始* 参 数:无* 返 回 值:无*/
void MyI2C_Start(void)
{MyI2C_W_SDA(1); //释放SDA,确保SDA为高电平MyI2C_W_SCL(1); //释放SCL,确保SCL为高电平MyI2C_W_SDA(0); //在SCL高电平期间,拉低SDA,产生起始信号MyI2C_W_SCL(0); //起始后把SCL也拉低,即为了占用总线,也为了方便总线时序的拼接
}/*** 函 数:I2C终止* 参 数:无* 返 回 值:无*/
void MyI2C_Stop(void)
{MyI2C_W_SDA(0); //拉低SDA,确保SDA为低电平MyI2C_W_SCL(1); //释放SCL,使SCL呈现高电平MyI2C_W_SDA(1); //在SCL高电平期间,释放SDA,产生终止信号
}/*** 函 数:I2C发送一个字节* 参 数:Byte 要发送的一个字节数据,范围:0x00~0xFF* 返 回 值:无*/
void MyI2C_SendByte(uint8_t Byte)
{uint8_t i;for (i = 0; i < 8; i ++) //循环8次,主机依次发送数据的每一位{MyI2C_W_SDA(Byte & (0x80 >> i)); //使用掩码的方式取出Byte的指定一位数据并写入到SDA线MyI2C_W_SCL(1); //释放SCL,从机在SCL高电平期间读取SDAMyI2C_W_SCL(0); //拉低SCL,主机开始发送下一位数据}
}/*** 函 数:I2C接收一个字节* 参 数:无* 返 回 值:接收到的一个字节数据,范围:0x00~0xFF*/
uint8_t MyI2C_ReceiveByte(void)
{uint8_t i, Byte = 0x00; //定义接收的数据,并赋初值0x00,此处必须赋初值0x00,后面会用到MyI2C_W_SDA(1); //接收前,主机先确保释放SDA,避免干扰从机的数据发送for (i = 0; i < 8; i ++) //循环8次,主机依次接收数据的每一位{MyI2C_W_SCL(1); //释放SCL,主机机在SCL高电平期间读取SDAif (MyI2C_R_SDA() == 1){Byte |= (0x80 >> i);} //读取SDA数据,并存储到Byte变量//当SDA为1时,置变量指定位为1,当SDA为0时,不做处理,指定位为默认的初值0MyI2C_W_SCL(0); //拉低SCL,从机在SCL低电平期间写入SDA}return Byte; //返回接收到的一个字节数据
}/*** 函 数:I2C发送应答位* 参 数:Byte 要发送的应答位,范围:0~1,0表示应答,1表示非应答* 返 回 值:无*/
void MyI2C_SendAck(uint8_t AckBit)
{MyI2C_W_SDA(AckBit); //主机把应答位数据放到SDA线MyI2C_W_SCL(1); //释放SCL,从机在SCL高电平期间,读取应答位MyI2C_W_SCL(0); //拉低SCL,开始下一个时序模块
}/*** 函 数:I2C接收应答位* 参 数:无* 返 回 值:接收到的应答位,范围:0~1,0表示应答,1表示非应答*/
uint8_t MyI2C_ReceiveAck(void)
{uint8_t AckBit; //定义应答位变量MyI2C_W_SDA(1); //接收前,主机先确保释放SDA,避免干扰从机的数据发送MyI2C_W_SCL(1); //释放SCL,主机机在SCL高电平期间读取SDAAckBit = MyI2C_R_SDA(); //将应答位存储到变量里MyI2C_W_SCL(0); //拉低SCL,开始下一个时序模块return AckBit; //返回定义应答位变量
}
函数逻辑:
-
void MyI2C_Start(void)
如果起始条件之前SCL和SDA已经是高电平了,那先释放哪一个是一样的效果,但是在指定地址读中,为了改变读写标志位,我们这个Start还要兼容这里的重复起始条件Sr。
Sr最开始,SCL是低电平,SDA电平不敢确定,所以保险起见,我们趁SCL是低电平,先确保释放SDA,再释放SCL,这时SDA和SCL都是高电平,然后再拉低SDA、拉低SCL,这样这个Start就可以兼容起始条件和重复起始条件了。
【如果先释放SCL,在SCL高电平期间再释放SDA会被误以为是终止条件;这里Sr是需要重新生成一个开始条件即SCL高电平期间,SDA从高变低。如果不先拉低SDA,就容易造成。SCL高电平期间,SDA从低变高。变成结束信号了。】 -
void MyI2C_Stop(void)
在这里,如果Stop开始时,那就先释放SCL,再释放SDA就行了,但是在这个时序单元开始时,SDA并不一定是低电平,所以为了确保之后释放SDA能产生上升沿,我们要在时序单元开始时,先拉低SDA,然后再释放SCL、释放SDA。 -
void MyI2C_SendByte(uint8_t Byte)
发送一个字节时序开始时,SCL是低电平,实际上,除了终止条件,SCL以高电平结束,所有的单元我们都会保证SCL以低电平结束,这样方便各个单元的拼接。
补充:
Byte & 0x80
就是用按位与的方式,取出数据的某一位或某几位,感觉这里准确的讲是检查位是否为1,而不是取出最高位 -
…
MPU6050_Reg.h
#ifndef __MPU6050_REG_H
#define __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
#define MPU6050_PWR_MGMT_2 0x6C
#define MPU6050_WHO_AM_I 0x75#endif
MPU6050.h
#ifndef __MPU6050_H
#define __MPU6050_Hvoid 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 //MPU6050的I2C从机地址/*** 函 数:MPU6050写寄存器* 参 数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述* 参 数:Data 要写入寄存器的数据,范围:0x00~0xFF* 返 回 值:无*/
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{MyI2C_Start(); //I2C起始MyI2C_SendByte(MPU6050_ADDRESS); //发送从机地址,读写位为0,表示即将写入MyI2C_ReceiveAck(); //接收应答MyI2C_SendByte(RegAddress); //发送寄存器地址MyI2C_ReceiveAck(); //接收应答MyI2C_SendByte(Data); //发送要写入寄存器的数据MyI2C_ReceiveAck(); //接收应答MyI2C_Stop(); //I2C终止
}/*** 函 数:MPU6050读寄存器* 参 数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述* 返 回 值:读取寄存器的数据,范围:0x00~0xFF*/
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{uint8_t Data;MyI2C_Start(); //I2C起始MyI2C_SendByte(MPU6050_ADDRESS); //发送从机地址,读写位为0,表示即将写入MyI2C_ReceiveAck(); //接收应答MyI2C_SendByte(RegAddress); //发送寄存器地址MyI2C_ReceiveAck(); //接收应答MyI2C_Start(); //I2C重复起始MyI2C_SendByte(MPU6050_ADDRESS | 0x01); //发送从机地址,读写位为1,表示即将读取MyI2C_ReceiveAck(); //接收应答Data = MyI2C_ReceiveByte(); //接收指定寄存器的数据MyI2C_SendAck(1); //发送应答,给从机非应答,终止从机的数据输出MyI2C_Stop(); //I2C终止return Data;
}/*** 函 数:MPU6050初始化* 参 数:无* 返 回 值:无*/
void MPU6050_Init(void)
{MyI2C_Init(); //先初始化底层的I2C/*MPU6050寄存器初始化,需要对照MPU6050手册的寄存器描述配置,此处仅配置了部分重要的寄存器*/MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01); //电源管理寄存器1,取消睡眠模式,选择时钟源为X轴陀螺仪MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00); //电源管理寄存器2,保持默认值0,所有轴均不待机MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09); //采样率分频寄存器,配置采样率MPU6050_WriteReg(MPU6050_CONFIG, 0x06); //配置寄存器,配置DLPFMPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18); //陀螺仪配置寄存器,选择满量程为±2000°/sMPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18); //加速度计配置寄存器,选择满量程为±16g
}/*** 函 数:MPU6050获取ID号* 参 数:无* 返 回 值:MPU6050的ID号*/
uint8_t MPU6050_GetID(void)
{return MPU6050_ReadReg(MPU6050_WHO_AM_I); //返回WHO_AM_I寄存器的值
}/*** 函 数:MPU6050获取数据* 参 数:AccX AccY AccZ 加速度计X、Y、Z轴的数据,使用输出参数的形式返回,范围:-32768~32767* 参 数:GyroX GyroY GyroZ 陀螺仪X、Y、Z轴的数据,使用输出参数的形式返回,范围:-32768~32767* 返 回 值:无*/
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; //定义数据高8位和低8位的变量DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H); //读取加速度计X轴的高8位数据DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L); //读取加速度计X轴的低8位数据*AccX = (DataH << 8) | DataL; //数据拼接,通过输出参数返回DataH = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_H); //读取加速度计Y轴的高8位数据DataL = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_L); //读取加速度计Y轴的低8位数据*AccY = (DataH << 8) | DataL; //数据拼接,通过输出参数返回DataH = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_H); //读取加速度计Z轴的高8位数据DataL = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_L); //读取加速度计Z轴的低8位数据*AccZ = (DataH << 8) | DataL; //数据拼接,通过输出参数返回DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H); //读取陀螺仪X轴的高8位数据DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L); //读取陀螺仪X轴的低8位数据*GyroX = (DataH << 8) | DataL; //数据拼接,通过输出参数返回DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H); //读取陀螺仪Y轴的高8位数据DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L); //读取陀螺仪Y轴的低8位数据*GyroY = (DataH << 8) | DataL; //数据拼接,通过输出参数返回DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H); //读取陀螺仪Z轴的高8位数据DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L); //读取陀螺仪Z轴的低8位数据*GyroZ = (DataH << 8) | DataL; //数据拼接,通过输出参数返回
}
main.c
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "MPU6050.h"uint8_t ID; //定义用于存放ID号的变量
int16_t AX, AY, AZ, GX, GY, GZ; //定义用于存放各个数据的变量int main(void)
{/*模块初始化*/OLED_Init(); //OLED初始化MPU6050_Init(); //MPU6050初始化/*显示ID号*/OLED_ShowString(1, 1, "ID:"); //显示静态字符串ID = MPU6050_GetID(); //获取MPU6050的ID号OLED_ShowHexNum(1, 4, ID, 2); //OLED显示ID号while (1){MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ); //获取MPU6050的数据OLED_ShowSignedNum(2, 1, AX, 5); //OLED显示数据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,手动拉低或释放时钟线,然后再手动对每个数据位进行判断,拉低或释放数据线,这样来产生这个的波形,这是软件I2C。由于12C是同步时序,这每一位的持续时间要求不严格,或许中途暂停一下时序,影响都不大,所以2C是比较容易用软件模拟的。
在实际项目中,软件模拟的I2C也是非常常见的,但是作为一个协议标准,I2C通信,也是可以有硬件收发电路的。就像之前的串口通信一样,我们先讲了串口的时序波形,但是在程序中,我们并没有用软件去手动翻转电平来实现这个波形,这是因为串口是异步时序,每一位的时间要求很严格,不能过长也不能过短,所以串口时序虽然可以用软件模拟,但是操作起来比较困难。另外,由于串口的硬件收发器在单片机中的普及程度非常高,基本上每个单片机都有串口的硬件资源,而且硬件实现的串口使用起来还非常简单,所以,串口通信,我们基本都是借助硬件收发器来实现的。
I2C通信外设
硬件实现串口(USART)的使用流程:首先配置USART外设,然后写入数据寄存器DR,然后硬件收发器就会自动生成波形发送出去,最后我们等待发送完成的标志位即可。
回到I2C这里,I2C也可以有软件模拟和硬件收发器自动操作这两种异步时序,对于串口这样的异步时序,软件实现麻烦,硬件实现简单,所以串口的实现基本是全部倒向硬件。而对于I2C这样的同步时序来说,软件实现简单灵活,硬件实现麻烦,但可以节省软件资源、可以实现完整的多主机通信模型等,各有优缺点。
I2C框图
I2C基本结构
代码实战:10-2硬件I2C读写MPU6050
MPU6050.h
#ifndef __MPU6050_H
#define __MPU6050_Hvoid 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_REG.h
#ifndef __MPU6050_REG_H
#define __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
#define MPU6050_PWR_MGMT_2 0x6C
#define MPU6050_WHO_AM_I 0x75#endif
MPU6050.c
#include "stm32f10x.h" // Device header
#include "MPU6050_Reg.h"#define MPU6050_ADDRESS 0xD0 //MPU6050的I2C从机地址/*** 函 数:MPU6050等待事件* 参 数:同I2C_CheckEvent* 返 回 值:无*/
void MPU6050_WaitEvent(I2C_TypeDef* I2Cx, uint32_t I2C_EVENT)
{uint32_t Timeout;Timeout = 10000; //给定超时计数时间while (I2C_CheckEvent(I2Cx, I2C_EVENT) != SUCCESS) //循环等待指定事件{Timeout --; //等待时,计数值自减if (Timeout == 0) //自减到0后,等待超时{/*超时的错误处理代码,可以添加到此处*/break; //跳出等待,不等了}}
}/*** 函 数:MPU6050写寄存器* 参 数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述* 参 数:Data 要写入寄存器的数据,范围:0x00~0xFF* 返 回 值:无*/
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{I2C_GenerateSTART(I2C2, ENABLE); //硬件I2C生成起始条件MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT); //等待EV5I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter); //硬件I2C发送从机地址,方向为发送MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED); //等待EV6I2C_SendData(I2C2, RegAddress); //硬件I2C发送寄存器地址MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING); //等待EV8I2C_SendData(I2C2, Data); //硬件I2C发送数据MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED); //等待EV8_2I2C_GenerateSTOP(I2C2, ENABLE); //硬件I2C生成终止条件
}/*** 函 数:MPU6050读寄存器* 参 数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述* 返 回 值:读取寄存器的数据,范围:0x00~0xFF*/
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{uint8_t Data;I2C_GenerateSTART(I2C2, ENABLE); //硬件I2C生成起始条件MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT); //等待EV5I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter); //硬件I2C发送从机地址,方向为发送MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED); //等待EV6I2C_SendData(I2C2, RegAddress); //硬件I2C发送寄存器地址MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED); //等待EV8_2I2C_GenerateSTART(I2C2, ENABLE); //硬件I2C生成重复起始条件MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT); //等待EV5I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Receiver); //硬件I2C发送从机地址,方向为接收MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED); //等待EV6I2C_AcknowledgeConfig(I2C2, DISABLE); //在接收最后一个字节之前提前将应答失能I2C_GenerateSTOP(I2C2, ENABLE); //在接收最后一个字节之前提前申请停止条件MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_RECEIVED); //等待EV7Data = I2C_ReceiveData(I2C2); //接收数据寄存器I2C_AcknowledgeConfig(I2C2, ENABLE); //将应答恢复为使能,为了不影响后续可能产生的读取多字节操作return Data;
}/*** 函 数:MPU6050初始化* 参 数:无* 返 回 值:无*/
void MPU6050_Init(void)
{/*开启时钟*/RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE); //开启I2C2的时钟RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); //开启GPIOB的时钟/*GPIO初始化*/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); //将PB10和PB11引脚初始化为复用开漏输出/*I2C初始化*/I2C_InitTypeDef I2C_InitStructure; //定义结构体变量I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; //模式,选择为I2C模式I2C_InitStructure.I2C_ClockSpeed = 50000; //时钟速度,选择为50KHzI2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; //时钟占空比,选择Tlow/Thigh = 2I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; //应答,选择使能I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; //应答地址,选择7位,从机模式下才有效I2C_InitStructure.I2C_OwnAddress1 = 0x00; //自身地址,从机模式下才有效I2C_Init(I2C2, &I2C_InitStructure); //将结构体变量交给I2C_Init,配置I2C2/*I2C使能*/I2C_Cmd(I2C2, ENABLE); //使能I2C2,开始运行/*MPU6050寄存器初始化,需要对照MPU6050手册的寄存器描述配置,此处仅配置了部分重要的寄存器*/MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01); //电源管理寄存器1,取消睡眠模式,选择时钟源为X轴陀螺仪MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00); //电源管理寄存器2,保持默认值0,所有轴均不待机MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09); //采样率分频寄存器,配置采样率MPU6050_WriteReg(MPU6050_CONFIG, 0x06); //配置寄存器,配置DLPFMPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18); //陀螺仪配置寄存器,选择满量程为±2000°/sMPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18); //加速度计配置寄存器,选择满量程为±16g
}/*** 函 数:MPU6050获取ID号* 参 数:无* 返 回 值:MPU6050的ID号*/
uint8_t MPU6050_GetID(void)
{return MPU6050_ReadReg(MPU6050_WHO_AM_I); //返回WHO_AM_I寄存器的值
}/*** 函 数:MPU6050获取数据* 参 数:AccX AccY AccZ 加速度计X、Y、Z轴的数据,使用输出参数的形式返回,范围:-32768~32767* 参 数:GyroX GyroY GyroZ 陀螺仪X、Y、Z轴的数据,使用输出参数的形式返回,范围:-32768~32767* 返 回 值:无*/
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; //定义数据高8位和低8位的变量DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H); //读取加速度计X轴的高8位数据DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L); //读取加速度计X轴的低8位数据*AccX = (DataH << 8) | DataL; //数据拼接,通过输出参数返回DataH = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_H); //读取加速度计Y轴的高8位数据DataL = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_L); //读取加速度计Y轴的低8位数据*AccY = (DataH << 8) | DataL; //数据拼接,通过输出参数返回DataH = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_H); //读取加速度计Z轴的高8位数据DataL = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_L); //读取加速度计Z轴的低8位数据*AccZ = (DataH << 8) | DataL; //数据拼接,通过输出参数返回DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H); //读取陀螺仪X轴的高8位数据DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L); //读取陀螺仪X轴的低8位数据*GyroX = (DataH << 8) | DataL; //数据拼接,通过输出参数返回DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H); //读取陀螺仪Y轴的高8位数据DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L); //读取陀螺仪Y轴的低8位数据*GyroY = (DataH << 8) | DataL; //数据拼接,通过输出参数返回DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H); //读取陀螺仪Z轴的高8位数据DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L); //读取陀螺仪Z轴的低8位数据*GyroZ = (DataH << 8) | DataL; //数据拼接,通过输出参数返回
}
main.c
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "MPU6050.h"uint8_t ID; //定义用于存放ID号的变量
int16_t AX, AY, AZ, GX, GY, GZ; //定义用于存放各个数据的变量int main(void)
{/*模块初始化*/OLED_Init(); //OLED初始化MPU6050_Init(); //MPU6050初始化/*显示ID号*/OLED_ShowString(1, 1, "ID:"); //显示静态字符串ID = MPU6050_GetID(); //获取MPU6050的ID号OLED_ShowHexNum(1, 4, ID, 2); //OLED显示ID号while (1){MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ); //获取MPU6050的数据OLED_ShowSignedNum(2, 1, AX, 5); //OLED显示数据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);}
}
SPI通信协议
SPI协议是由摩托罗拉公司提出的通讯协议(Serial Peripheral Interface),即串行外围设备接口, 是一种高速全双工的通信总线。它被广泛地使用在ADC、LCD等设备与MCU间,要求通讯速率较高的场合。
学习本章时,可与I2C章节对比阅读,体会两种通讯总线的差异以及EEPROM存储器与FLASH存储器的区别。下面我们分别对SPI协议的物理层及协议层进行讲解。
SPI物理层
SPI通讯设备之间的常用连接方式见图
SPI通讯使用3条总线及片选线,3条总线分别为SCK、MOSI、MISO,片选线为SS,它们的作用介绍如下:
(1) SS ( Slave Select):从设备选择信号线,常称为片选信号线,也称为NSS、CS,以下用NSS表示。当有多个SPI从设备与SPI主机相连时, 设备的其它信号线SCK、MOSI及MISO同时并联到相同的SPI总线上,即无论有多少个从设备,都共同只使用这3条总线; 而每个从设备都有独立的这一条NSS信号线,本信号线独占主机的一个引脚,即有多少个从设备,就有多少条片选信号线。 I2C协议中通过设备地址来寻址、选中总线上的某个设备并与其进行通讯;而SPI协议中没有设备地址,它使用NSS信号线来寻址, 当主机要选择从设备时,把该从设备的NSS信号线设置为低电平,该从设备即被选中,即片选有效, 接着主机开始与被选中的从设备进行SPI通讯。所以SPI通讯以NSS线置低电平为开始信号,以NSS线被拉高作为结束信号。
(2) SCK (Serial Clock):时钟信号线,用于通讯数据同步。它由通讯主机产生,决定了通讯的速率,不同的设备支持的最高时钟频率不一样, 如STM32的SPI时钟频率最大为fpclk/2,两个设备之间通讯时,通讯速率受限于低速设备。
(3) MOSI (Master Output, Slave Input):主设备输出/从设备输入引脚。主机的数据从这条信号线输出, 从机由这条信号线读入主机发送的数据,即这条线上数据的方向为主机到从机。
(4) MISO (Master Input,,Slave Output):主设备输入/从设备输出引脚。主机从这条信号线读入数据, 从机的数据由这条信号线输出到主机,即在这条线上数据的方向为从机到主机。
SPI协议层
与I2C的类似,SPI协议定义了通讯的起始和停止信号、数据有效性、时钟同步等环节。
SPI基本通讯过程
先看看SPI通讯的通讯时序,
这是一个主机的通讯时序。NSS、SCK、MOSI信号都由主机控制产生,而MISO的信号由从机产生,主机通过该信号线读取从机的数据。 MOSI与MISO的信号只在NSS为低电平的时候才有效,在SCK的每个时钟周期MOSI和MISO传输一位数据。
以上通讯流程中包含的各个信号分解如下:
1.通讯的起始和停止信号
在图 SPI通讯时序 中的标号处,NSS信号线由高变低,是SPI通讯的起始信号。NSS是每个从机各自独占的信号线, 当从机在自己的NSS线检测到起始信号后,就知道自己被主机选中了,开始准备与主机通讯。在图中的标号处,NSS信号由低变高, 是SPI通讯的停止信号,表示本次通讯结束,从机的选中状态被取消。
2.数据有效性
MSB高位先行,LSB低位先行
SPI使用MOSI及MISO信号线来传输数据,使用SCK信号线进行数据同步。MOSI及MISO数据线在SCK的每个时钟周期传输一位数据, 且数据输入输出是同时进行的。数据传输时,MSB先行或LSB先行并没有作硬性规定,但要保证两个SPI通讯设备之间使用同样的协定, 一般都会采用图 SPI通讯时序 中的MSB先行模式。
观察图中的标号处,MOSI及MISO的数据在SCK的上升沿期间变化输出,在SCK的下降沿时被采样。即在SCK的下降沿时刻, MOSI及MISO的数据有效,高电平时表示数据“1”,为低电平时表示数据“0”。在其它时刻,数据无效,MOSI及MISO为下一次表示数据做准备。
SPI每次数据传输可以8位或16位为单位,每次传输的单位数不受限制。
3.CPOL/CPHA及通讯模式
上面讲述的图 SPI通讯时序 中的时序只是SPI中的其中一种通讯模式,SPI一共有四种通讯模式, 它们的主要区别是总线空闲时SCK的时钟状态以及数据采样时刻。为方便说明,在此引入“时钟极性CPOL”和“时钟相位CPHA”的概念。
时钟极性CPOL是指SPI通讯设备处于空闲状态时,SCK信号线的电平信号(即SPI通讯开始前、 NSS线为高电平时SCK的状态)。CPOL=0时, SCK在空闲状态时为低电平,CPOL=1时,则相反。
时钟相位CPHA是指数据的采样的时刻,当CPHA=0时,MOSI或MISO数据线上的信号将会在SCK时钟线的“奇数边沿”被采样。当CPHA=1时, 数据线在SCK的“偶数边沿”采样。见图 CPHA = 0时的SPI通讯模式 及图 CPHA = 1时的SPI通讯模式 。
我们来分析这个CPHA=0的时序图。首先,根据SCK在空闲状态时的电平,分为两种情况。 SCK信号线在空闲状态为低电平时,CPOL=0;空闲状态为高电平时,CPOL=1。
无论CPOL=0还是=1,因为我们配置的时钟相位CPHA=0,在图中可以看到,采样时刻都是在SCK的奇数边沿。 注意当CPOL=0的时候,时钟的奇数边沿是上升沿,而CPOL=1的时候,时钟的奇数边沿是下降沿。所以SPI的采样时刻不是由上升/下降沿决定的。 MOSI和MISO数据线的有效信号在SCK的奇数边沿保持不变,数据信号将在SCK奇数边沿时被采样,在非采样时刻,MOSI和MISO的有效信号才发生切换。
类似地,当CPHA=1时,不受CPOL的影响,数据信号在SCK的偶数边沿被采样,见图 CPHA=1时的SPI通讯模式_ 。
由CPOL及CPHA的不同状态,SPI分成了四种模式,见表 SPI的四种模式 , 主机与从机需要工作在相同的模式下才可以正常通讯,实际中采用较多的是“模式0”与“模式3”。
STM32的SPI特性及架构
与I2C外设一样,STM32芯片也集成了专门用于SPI协议通讯的外设。
1.STM32的SPI外设简介
STM32的SPI外设可用作通讯的主机及从机, 支持最高的SCK时钟频率为fpclk/2 (STM32F103型号的芯片默认fpclk1为36MHz, fpclk2为72MHz),完全支持SPI协议的4种模式,数据帧长度可设置为8位或16位, 可设置数据MSB先行或LSB先行。它还支持双线全双工(前面小节说明的都是这种模式)、双线单向以及单线模式。 其中双线单向模式可以同时使用MOSI及MISO数据线向一个方向传输数据,可以加快一倍的传输速度。而单线模式则可以减少硬件接线, 当然这样速率会受到影响。我们只讲解双线全双工模式。
2. STM32的SPI架构剖析
1.通讯引脚
SPI的所有硬件架构都从图 SPI架构图 中左侧MOSI、MISO、SCK及NSS线展开的。STM32芯片有多个SPI外设, 它们的SPI通讯信号引出到不同的GPIO引脚上,使用时必须配置到这些指定的引脚,见表 STM32F10x的SPI引脚 。 关于GPIO引脚的复用功能,可查阅《STM32F10x规格书》,以它为准。
其中SPI1是APB2上的设备,最高通信速率达36Mbtis/s,SPI2、SPI3是APB1上的设备,最高通信速率为18Mbits/s。除了通讯速率, 在其它功能上没有差异。其中SPI3用到了下载接口的引脚,这几个引脚默认功能是下载,第二功能才是IO口,如果想使用SPI3接口, 则程序上必须先禁用掉这几个IO口的下载功能。一般在资源不是十分紧张的情况下,这几个IO口是专门用于下载和调试程序,不会复用为SPI3。
2. 时钟控制逻辑
SCK线的时钟信号,由波特率发生器根据“控制寄存器CR1”中的BR[0:2]位控制,该位是对fpclk时钟的分频因子, 对fpclk的分频结果就是SCK引脚的输出时钟频率,计算方法见表 BR位对fpclk的分频 。
其中的fpclk频率是指SPI所在的APB总线频率, APB1为fpclk1,APB2为fpckl2。
通过配置“控制寄存器CR”的“CPOL位”及“CPHA”位可以把SPI设置成前面分析的4种SPI模式。
3. 数据控制逻辑
SPI的MOSI及MISO都连接到数据移位寄存器上,数据移位寄存器的数据来源及目标接收、发送缓冲区以及MISO、MOSI线。 当向外发送数据的时候,数据移位寄存器以“发送缓冲区”为数据源,把数据一位一位地通过数据线发送出去;当从外部接收数据的时候, 数据移位寄存器把数据线采样到的数据一位一位地存储到“接收缓冲区”中。通过写SPI的“数据寄存器DR”把数据填充到发送缓冲区中, 通讯读“数据寄存器DR”,可以获取接收缓冲区中的内容。其中数据帧长度可以通过“控制寄存器CR1”的“DFF位”配置成8位及16位模式; 配置“LSBFIRST位”可选择MSB先行还是LSB先行。
4. 整体控制逻辑
整体控制逻辑负责协调整个SPI外设,控制逻辑的工作模式根据我们配置的“控制寄存器(CR1/CR2)”的参数而改变, 基本的控制参数包括前面提到的SPI模式、波特率、LSB先行、主从模式、单双向模式等等。在外设工作时, 控制逻辑会根据外设的工作状态修改“状态寄存器(SR)”,我们只要读取状态寄存器相关的寄存器位, 就可以了解SPI的工作状态了。除此之外,控制逻辑还根据要求,负责控制产生SPI中断信号、DMA请求及控制NSS信号线。
实际应用中,我们一般不使用STM32 SPI外设的标准NSS信号线,而是更简单地使用普通的GPIO,软件控制它的电平输出,从而产生通讯起始和停止信号。
3.通讯过程
STM32使用SPI外设通讯时,在通讯的不同阶段它会对“状态寄存器SR”的不同数据位写入参数,我们通过读取这些寄存器标志来了解通讯状态。
图 主发送器通讯过程 中的是“主模式”流程,即STM32作为SPI通讯的主机端时的数据收发过程。
主模式收发流程及事件说明如下:
(1) 控制NSS信号线, 产生起始信号(图中没有画出);
(2) 把要发送的数据写入到“数据寄存器DR”中, 该数据会被存储到发送缓冲区;
(3) 通讯开始,SCK时钟开始运行。MOSI把发送缓冲区中的数据一位一位地传输出去; MISO则把数据一位一位地存储进接收缓冲区中;
(4) 当发送完一帧数据的时候,“状态寄存器SR”中的“TXE标志位”会被置1,表示传输完一帧,发送缓冲区已空;类似地, 当接收完一帧数据的时候,“RXNE标志位”会被置1,表示传输完一帧,接收缓冲区非空;
(5) 等待到“TXE标志位”为1时,若还要继续发送数据,则再次往“数据寄存器DR”写入数据即可;等待到“RXNE标志位”为1时, 通过读取“数据寄存器DR”可以获取接收缓冲区中的内容。
假如我们使能了TXE或RXNE中断,TXE或RXNE置1时会产生SPI中断信号,进入同一个中断服务函数,到SPI中断服务程序后, 可通过检查寄存器位来了解是哪一个事件,再分别进行处理。也可以使用DMA方式来收发“数据寄存器DR”中的数据。
4.实战——读写串行FLASH
1.硬件连接
注意:
如果SPI通讯中的三个从机都是推挽输出(Push-Pull Output),那么在没有适当管理的情况下,当多个从设备同时驱动MISO线时,就会发生线路冲突,这可能导致数据错误和通信故障。为了解决这个问题,【从机】一般采用下面的处理方式:
片选(Chip Select):每个从设备都有一个独立的片选线,用于启用或禁用该设备的输出。当主设备想要与某个从设备通信时,它会通过激活相应的片选线来选择该从设备。未被选中的从设备会将其MISO输出设置为高阻态(High-Impedance),从而不会对MISO线产生影响。
2.软件设计
编程要点:
- 初始化通讯使用的目标引脚及端口时钟;
- 使能SPI外设的时钟;
- 配置SPI外设的模式、地址、速率等参数并使能SPI外设;
- 编写基本SPI按字节收发的函数;
- 编写对FLASH擦除及读写操作的的函数;
- 编写测试程序,对读写数据进行校验。
扩展1:SPI物理层第二种连接方式:菊花链
在数字通信世界中,在设备信号(总线信号或中断信号)以串行的方式从一 个设备依次传到下一个设备,不断循环直到数据到达目标设备的方式被称为菊花链
。
菊花链的最大缺点是因为是信号串行传输,所以一旦数据链路中的某设备发生故障的时候,它下面优先级较低的设备就不可能得到服务了;
另一方面,距离主机越远的从机,获得服务的优先级越低,所以需要安排好从机的优先级,并且设置总线检测器,如果某个从机超时,则对该从机进行短路,防止单个从机损坏造成整个链路崩溃的情况;
具体的连接如下图所示:
采用一个/SS (或者/CS) 信号控制所有从器件的/CS 输入; 所有从器件接收同一个时钟信号。只有链上的第一个从器件(SLAVE 1) 从微控制器直接接收命令。 其他所有从器件都从链上前一个器件的 DOUT 输出获得其 DIN 数据。要保证菊链正常工作, 每一个从器件就必须能在给定的命令周期内(定义为每一个命令所需的时钟数) 从 DIN 引脚读入命令, 而在下一个命令周期从 DOUT 引脚输出同样的命令。 显然,从 DIN 到 DOUT 会有一个命令周期的延迟。 另外, 各个从器件只能在/CS 的上升沿执行写入的命令。 这意味着只要/CS 保持低电平, 从器件将不会执行命令, 并且会在下一个命令周期将命令通过 DOUT 引脚输出。 如果在给定命令周期之后/CS 变高, 所有从器件将立即执行写入 DIN 引脚的命令。 如果/CS 变高, 数据将不会从 DOUT 输出, 这就使得链上每个从器件可以执行不同的命令。只要菊链的这些要求能够满足, 微控制器只需三个信号(/SS、SCK 和 MOSI)就能控制网络上的所有从器件。
所以最终的数据流向图可以表示为:
SCK为时钟信号,8clks表示8个边沿信号;
其中D为数据,X为无效数据
所以不难发现,菊花链模式充分使用了SPI其移位寄存器的功能,整个链充当通信移位寄存器,每个从机在下一个时钟周期将输入数据复制到输出。
详细原理请看:SPI菊花链原理和配置
扩展2:关于stm32硬件spi的MISO口配置
在我们刚使用spi时,对于spi的io口配置可能会有一些疑惑吧,miso明明是一个输入口却配置成了复用推挽输出,是不是会有一点疑惑呢?
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7;GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用的推挽输出
MISO不是应该设置成为输入端口(GPIO_Mode_IN_FLOATING)才行的吗?其实可以设置成为输入模式,也可以设置成为复用的推挽输出。其工作都是正常的,不过建议大家还是设置成为输入端口的好,容易理解。
具体产生这一问题的原因是:从功能上来说,MISO应该配置为输入模式才对,但为什么也可以配置为GPIO_Mode_AF_PP?请看下面的GPIO复用功能配置框图。当一个GPIO端口配置为GPIO_Mode_AF_PP是,这个端口的内部结构框图如下:图中可以看到,片上外设的复用功能输出信号会连接到输出控制电路,然后在端口上产生输出信号。但是在芯片内部,MISO是SPI模块的输入引脚,而不是输出引脚,也就是说图中的"复用功能输出信号"根本不存在,因此"输出控制电路"不能对外产生输出信号。而另一方面看,即使在GPIO_Mode_AF_PP模式下,复用功能输入信号却与外部引脚之间相互连接,既MISO得到了外部信号的电平,实现了输入的功能。
参考文章:
什么是串行与并行?串行和并行各自有什么优越点和应用场景?
什么是同步通信?什么是异步通信?两者的优缺点是什么?
SPI协议详解(图文并茂+超详细)
关于stm32硬件spi的miso口配置
相关文章:

【STM32 学习笔记】I2C通信协议
注:通信协议的设计背景 3:00~10:13 I2C 通讯协议(Inter-Integrated Circuit)是由Phiilps公司开发的,由于它引脚少,硬件实现简单,可扩展性强, 不需要USART、CAN等通讯协议的外部收发设备,现在被广…...
【java】jdk8及以后的时间类总结
目录 1. LocalDate 2. LocalTime 4. ZonedDateTime 5. Duration 6. Period 7. DateTimeFormatter 1. LocalDate 说明:表示不带时区的日期(年、月、日),不可变且线程安全。 import java.time.LocalDate;public class Local…...
深入理解 Istio 的工作原理 v1.26.0
解读最新版本的 Istio 源码确实是一项庞大的工程,但我可以为你梳理出一个清晰的脉络,并指出关键模块和代码路径,帮助你深入理解 Istio 的工作原理。 我们主要关注 Istio 的核心组件 Istiod 和数据平面的 Envoy Proxy。 前提: Go…...

深入理解卷积神经网络的输入层:数据的起点与预处理核心
内容摘要 本文围绕卷积神经网络输入层展开,详细介绍其在网络中的重要作用,包括接收不同领域数据的形式及传递数据的过程。深入解读数据预处理的关键操作,如去均值、归一化和PCA/白化。助力读者透彻理解输入层,为构建高效卷积神经…...

redis bitmap数据类型调研
一、bitmap是什么? redis原文: Bitmaps are not an actual data type, but a set of bit-oriented operations defined on the String type . This means that bitmaps can be used with string commands, and most importantly with SET and GET. 翻…...
如何用数学思想填报高考志愿
人一辈子有很多四年,但是很少有哪个四年对你一生的影响能超过大学这四年。 从18岁到22岁的这几年,是一个人真正成年的过程,很多人会在这段时间里认识一生的朋友,谈第一次真正的恋爱,第一次离开父母,自己生…...

LabVIEW 2019 与 NI VISA 20.0 安装及报错处理
在使用 Windows 11 操作系统的电脑上,同时安装了 LabVIEW 2019 32 位和 64 位版本的软件。此前安装的 NI VISA 2024 Q1 版,该版本与 LabVIEW 2019 32 位和 64 位不兼容,之后重新安装了 NI VISA 20.0。从说明书来看,NI VISA 20.0 …...

探索 JWT(JSON Web Token):原理、结构与实践应用对比
目录 前言1. 什么是 JWT?2. JWT 的组成结构详解2.1 Header(头部)2.2 Payload(负载)2.3 Signature(签名) 3. JWT 的实际作用3.1 身份认证3.2 信息传递与授权 4. JWT 与 Cookie、API Key 的比较4.…...
互联网大厂Java求职面试:云原生与AI融合下的系统设计挑战-1
互联网大厂Java求职面试:云原生与AI融合下的系统设计挑战-1 在当今云计算和人工智能迅猛发展的背景下,互联网大厂对Java工程师的要求已从传统的单体架构和业务逻辑处理,转向了更复杂的云原生架构设计、AI模型集成以及高并发系统的性能优化能…...
【Redis进阶】持久化
一、MySQL事务特性及Redis持久化需求 (一)MySQL事务特性 MySQL的事务具有四大核心特性,这些特性对于保证数据库操作的准确性和可靠性至关重要。 原子性:事务中的所有操作要么全部成功,要么全部失败…...

[docker基础一]docker简介
目录 一 消除恐惧 1) 什么是虚拟化,容器化 2)案例 3)为什么需要虚拟化,容器化 二 虚拟化实现方式 1)应用程序执行环境分层 2)虚拟化常见类别 3)常见虚拟化实现 一)主机虚拟化(虚拟机)实现 二)容器虚拟化实现 一 消除恐…...

Texify - 数学公式OCR转换工具
文章目录 一、项目概览相关资源核心特性 二、安装指南三、使用示例1、命令行转换2、Python API调用3、交互式应用 四、性能基准运行你自己的基准测试 五、局限性 一、项目概览 Texify 是一个OCR模型,可将包含数学公式的图片或PDF转换为Markdown和LaTeX格式…...

RISC-V CLINT、PLIC及芯来ECLIC中断机制分析 —— RISC-V中断机制(一)
在长期的嵌入式开发实践中,对中断机制的理解始终停留在表面层次,特别当开发者长期局限于纯软件抽象层面时,对中断机制的理解极易陷入"知其然而不知其所以然"的困境,这种认知的局限更为明显;随着工作需要不断…...
时钟晶振锁相环pll方向技术要点和大厂题目解析
本专栏预计更新60期左右。当前第9期。 本专栏不仅适用于硬件的笔试面试,同样也适用于梳理硬件核心的知识点。 通过本文能得到什么? 首先,根据实战经验总结时钟晶振,锁相环的主要知识点,技术要点,面试考点; 然后,列出时钟晶振,锁相环的笔试面试的主要题型真题和模拟题,…...
图像处理篇--- HTTP|RTSP|MJPEG视频流格式
文章目录 前言一、MJPEG (Motion JPEG)基本概念技术特点编码方式传输协议数据格式 优势实现简单低延迟兼容性好容错性强 劣势带宽效率低不支持音频缺乏标准控制 典型应用 二、RTSP (Real Time Streaming Protocol)基本概念技术特点协议栈工作流程传输模式 优势专业流媒体支持高…...
【Harbor v2.13.0 详细安装步骤 安装证书启用 HTTPS】
Harbor v2.13.0 详细安装步骤(启用 HTTPS) 1. 环境准备 系统要求:至少 4GB 内存,100GB 磁盘空间。 已安装组件: Docker(版本 ≥ 20.10)Docker Compose(版本 ≥ v2.0) 域…...
C++中的static_cast:类型转换的安全卫士
C中的static_cast:类型转换的安全卫士 在C编程中,类型转换是不可避免的操作,而static_cast作为C四大强制类型转换运算符之一,是最常用且相对安全的一种转换方式。今天我们就来深入探讨一下这个重要的类型转换工具。 一、static_…...

开源与商业:图形化编程工具的博弈与共生
一、开源生态的破局之路:从技术实验到行业标准 在 2025 年全球开发者生态大会上,iVX 凭借 “全栈代码生成 AI 驱动开发” 的技术架构,被行业权威机构评选为 “年度技术创新典范”。作为 2012 年启动的开源项目,iVX 历经 17 年技…...
Docker + Watchtower 实现容器自动更新:高效运维的终极方案
文章目录 前言一、Watchtower 简介二、Watchtower 安装与基本使用1. 快速安装 Watchtower2. 监控特定容器 三、Watchtower 高级配置1. 设置检查间隔2. 配置更新策略3. 清理旧镜像4. 通知设置 四、生产环境最佳实践1. 使用标签控制更新2. 更新前执行健康检查3. 结合CI/CD流水线 …...

(二)Linux下基本指令 2
【知识预告】 16. date 指令 17. cal 指令 18. find 指令 19. which指令 20. whereis 指令 21. alias 指令 22. grep 指令 23. zip/unzip 指令 24. tar 指令 25. bc 指令 26. uname ‒r 指令 27. 重要的⼏个热键 28. 关机 16 date 指令 指定格式显⽰时间:date %Y-…...
1 2 3 4 5顺序插入,形成一个红黑树
红黑树的特性与优点 红黑树是一种自平衡的二叉搜索树,通过额外的颜色标记和平衡性约束,确保树的高度始终保持在 O(log n)。其核心特性如下: 每个节点要么是红色,要么是黑色。根节点和叶子节点(NIL节点)是…...

无线网络设备中AP和AC是什么?有什么区别?
无线网络设备中AP和AC是什么?有什么区别? 一. 什么是AP?二. 什么是AC?三. AP与AC的关系 前言 肝文不易,点个免费的赞和关注,有错误的地方请指出,看个人主页有惊喜。 作者:神的孩子都…...

Web自动化测试入门详解
🍅 点击文末小卡片,免费获取软件测试全套资料,资料在手,涨薪更快 一、目的 web自动化测试作为软件自动化测试领域中绕不过去的一个“香饽饽”,通常都会作为广大测试从业者的首选学习对象,相较于C/S架…...
WHAT - 简单服务发现
文章目录 简单理解举个例子简单服务发现方式1. 静态配置(最简单,但不灵活)2. DNS 发现3. 使用服务注册中心(稍高级) 总结 “简单服务发现”(Simple Service Discovery)通常指的是一种让系统中的…...

uniapp+vue3+firstUI时间轴 提现进度样式
展示 说明:“status”: 0, //状态:0待审核,1审核通过,2审核驳回,3提现成功,4提现失败 第一种:5种类型归纳为三种显示样式 <fui-timeaxis background"#fff" :padding"[10rpx,16rpx,0]"><!-- 动态生成步骤节点 --><f…...

【日撸 Java 三百行】Day 10(综合任务 1)
目录 Day 10:综合任务 1 一、题目分析 1. 数据结构 2. 相关函数基本知识 二、模块介绍 1. 初始化与成绩矩阵的构建 2. 创建总成绩数组 3. 寻找成绩极值 三、代码与测试 小结 拓展:关于求极值的相关算法 Day 10:综合任务 1 Task&…...
VR 南锣鼓巷:古老街区的数字化绘卷与沉浸式遨游
VR 技术,即虚拟现实技术,是一种能够创建和体验虚拟世界的计算机仿真系统。它利用计算机生成一种模拟环境,通过多源信息融合的交互式三维动态视景和实体行为的系统仿真,使用户沉浸到该环境中,产生身临其境的感受 。简单…...
使用Java NIO 实现一个socket通信框架
使用Java NIO(非阻塞I/O)实现一个Socket通信框架,可以让你构建高性能的网络应用。NIO提供了Channel、Buffer和Selector等核心组件,支持非阻塞模式下的网络编程。下面是一个简单的例子,展示了如何使用Java NIO创建一个基本的服务器端和客户端进行Socket通信。 1.服务器端 …...
Android开发-常用布局
在Android应用开发中,布局决定了用户界面的结构和元素之间的相对位置。选择合适的布局不仅能够提升用户体验,还能提高代码的可维护性和灵活性。本文将介绍几种最常用的Android布局方式,包括LinearLayout、RelativeLayout、ConstraintLayout以…...

macOS 15.4.1 Chrome不能访问本地网络
前言 最近使用macmini m4,自带macOS15系统,对于开发者简直是一言难尽,Chrome浏览器的本地网络有bug,可以访问本机,但是不能访问路由器上的其他机器,路由器提供的页面也不能访问,如下是折腾解决…...