IIC通信协议

前言

        IIC,全称Inter Integrated Circuit Bus(或简写为I^{2}C),是一种两线式串行通信总线标准,由飞利浦公司(现为恩智浦半导体公司)在1980年推出。它主要用于连接微控制器(MCU)及其外围设备或各种电子设备中的其他组件,相比其他通信协议,IIC协议通过两根线SCL(Serial Clock)、SDA(Serial Data),即可实现设备间的通信,极大地简化了硬件连接,节省了布线和空间资源。IIC总线支持多主机和多从机的架构,允许多个设备同时连接到同一总线上,并通过地址识别进行通信,使得IIC协议具有很强的灵活性和可扩展性。

1.IIC基本介绍

1.1SCL、SDA

        首先IIC采用两线串行通信,分别是SCL(Serial Clock)来控制总线传输的时序,同时该SCL时钟线是能够双向传输的,例如:使用STM32作为主机控制外挂模块MPU6050陀螺仪,此时就需要通过STM32来输出时钟,根据时序来进行一系列的操作;当STM32(1)作为从机被其他外设例如另外一个STM32(2)控制时,此时SCL相对STM32(1)为时钟输入。

        然后是SDA(Serial Data),此总线是用来进行数据传输的总线,它可以完成数据的发送以及接收,由于仅有一根线,在同一时间内只能够进行一个操作(发送或是接收)。相比USART串口通信两根线,USART串口在TX发送时,RX接收总线可能会处于空闲状态,导致硬件资源浪费,而IIC通信就不会出现这种情况。

        相比USART通信两根线为全双工模式,对应的IIC单根数据线半双工模式

        USART通信并没有SCL时钟双向同步总线,所以一般USART为异步通信;而IIC通信有SCL时钟同步总线,因此IIC通信采用的同步通信。同步的好处,对于时序的要求不严格某一位的时间长点或者短一点或是中途暂停一点(执行中断)影响不大,所以对于IIC可以用软件模拟的方法实现,也就是通过程序通过按顺序置高低电平适当延时,来实现一个标准IIC的大致时序。而异步,对于时序就比较严格,用软件实现就比较困难,所以一般不在USART串口上用软件实现,而串口收发器硬件电路在单片机中的普及程度较高,并且用硬件实现比较简单(也就是使用标准库里面的函数进行串口通信),所以一般串口USART采用的都是硬件实现

1.2软件、硬件实现IIC区别

        软件IIC是通过软件控制(程序控制)GPIO引脚来模拟IIC协议的时序和数据传输,不依赖单片机内部的模块,因此可以在任何具有GPIO的单片机中是西安IIC的通信。

        软件IIC比较灵活,通过任意的GPIO引脚来实现IIC的时序输出,适应能力较强;同时软件IIC的实现不依赖特定的硬件外设,能够实现在不同平台之间进行移植;同时相对硬件IIC实现较为简单。

        硬件IIC通过单片机内部的IIC硬件电路来实现的,通过使用CPU的时钟信号来控制数据的传输和时序,具有专门的IIC驱动电路以及专用的IIC引脚(GPIO默认复用功能SCL、SDA)。

        硬件IIC传输过程由硬件电路完成,不需要CPU的干预,能够节省CPU资源;由于是由专门的硬件电路来实现的,所以硬件IIC能够实现高速的数据传输;同时时序也由硬件电路控制产生,较为稳定。

        综合以上,实际上软件IIC的优点反过来就是硬件IIC的缺点,同样硬件IIC优点反过来也是软件IIC的缺点。总结就是软件IIC比较灵活,实现简单,而硬件IIC执行效率比较高(不占用CPU资源),节省软件资源,功能比较强大

1.3IIC其他特性

        IIC总线采用的是主从架构,总线上有一个主机(Master)和多个从机(Slave)。主机负责发起和结束通信,而从机只能够被动响应主机的请求,类比老师和学生,老师作为主机教一群学生,只有老师发出提问、要求朗读、开始上课等动作,学生才能够被动做出相应的动作。(接下来会一直采用老师学生模型介绍主从模式)

        一主多从是最为常见的模式,同时还有多主多从模式,在这个模式下,任何一个从机都可以作为主机,就好似在教室里没有老师,只有一群学生,每个学生都可突然站起来作为临时老师,进行讲话,但在同一时间只允许一个人讲话,如果同时有多个人想作为“主机”时,就需要进行仲裁,仲裁胜利的一方作为主机,其余失败的作为从机,这就是多主多从模式,这个模式较为复杂,需要的可以查阅手册了解。

        IIC模块接收和发送数据,并将数据从串行转换成并行,或者并行转换成串行。可以开启或精致中断。IIC接口通过SCL时钟引脚和SDA数据引脚连接到IIC总线,语序连接到标准(高达100kHz)或快速(高达400kHz)的IIC总线,适用于低速设备之间的通信。

2.IIC的硬件电路

  • 上图为IIC一主多从模式下的硬件电路,CPU作为主机(Master)控制其他从设备(IC1、IC2、IC3、IC4)(Slave)。
  •  所有I2C设备的SCL连在一起,SDA连在一起
  • 设备的SCL和SDA均要配置成开漏输出模式
  • SCL和SDA各添加一个上拉电阻,阻值一般为4.7KΩ左右

        由于SDA数据线在CPU主机发送的时候是输出的,而在接收的时候为输入,如果时序没有协调好,CPU主机SDA输出高电平,而IC1的SDA输出低电平,此时就会导致SDA短路。同样SCL也会在输入输出之间切换。

        所以为了避免上面总线没有协调好造成的短路现象,IIC设计就禁止所有设备输出强上拉的高电平,所以IIC就采用外置弱上拉电阻加开漏输出的模式。

        在开漏输出模式下:

        对于输入部分,输入数据经过一个施密特触发器进行整形,然后输入到存储寄存器中,由于输入对电路没有任何影响,所以任何设备在任何时刻输出都没有影响

        对于输出部分,如果此时输出的低电平,强上拉的P-MOS被截止N-MOS导通,此时也即强下拉SDA输出的也就是低电平。而此时输出的是高电平,此时强下拉的N-MOS截止,由于此时为开漏输出P-MOS也是处于截止状态,此时输出的引脚就属于浮空状态

        下图为更为具体的表现:

        这样所有的设备都只能够输出低电平而不能够输出高电平为了避免高电平造成的电平浮空,在此时(浮空状态)分别在SDA、SCL总线上外置一个上拉电阻(弱上拉),该引脚就表现出弱上拉的高电平。这样就完全杜绝了电路短路的现象保证电路的安全。同时开漏和弱上拉的模式,兼具输入和输出的功能。最后这个模式会有一个“线与“现象,只要有一个设备输出低电平,该总线就处于低电平,只有当全部设备输出高电平时,该总线才表现为高电平,对此,IIC就可以利用这个特性来实现多主机模式下的时钟同步SCL和SDA总线仲裁

3.IIC的时序(软件)

  1. 起始信号:通信开始时,主机发出起始信号。起始信号由SCL为高电平时,SDA由高电平跳变为低电平来表示。
  2. 发送设备地址:随后,主机发送一个字节的数据,其中包括从机的设备地址和后续字节的传送方向(读或写)。
  3. 应答信号:被寻址的从机在接收到设备地址后,会发送一个应答信号(ACK)以确认其已被正确寻址。
  4. 数据传输:在确认从机后,主机和从机之间开始数据传输。每传输一个字节的数据后,接收方都会发送一个应答信号。
  5. 停止信号:通信结束时,主机发出停止信号。停止信号由SCL为高电平时,SDA由低电平跳变为高电平来表示。

        上述为一般的时序实现过程,接下来一一介绍:

3.1主模式下的时序

3.1.1起始条件:

        SCL高电平期间,SDA从高电平切换到低电平。这样就能开启IIC的时序,进行下一个发送地址时序。

3.12发送设备地址:

        地址一般分为7位以及10位,在写入时略有区别,当地址位7位时,在第8位写上读写位(1为只读,0为只写,分别对应接收和发送两种模式),如下图:

        上图表明,发送了七位设备地址为1101 000读写位为0,表明是进行写操作,也就是发送

        这里的发送设备地址是指:需要控制的从机ID号,基本上每一个从机都有一个相对应的ID号,如果挂载的多个设备ID会重复,那么在从机的引脚上一般都有一个可改变ID号最低位的引脚,只需要对该引脚置1,就可以改变该ID号为1,如果有两个引脚则可以改变ID号的低两位,对应就有4种选择,这样就能够避免ID重复。

        例如,查阅手册MPU6050陀螺仪加速计的ID号为0x68,也就是110 1000

        对MPU6050的AD0引脚进行置1,就会使得最低位为1,也即ID号为:110 1001,对应十进制0x69,也即下图种的AD0:

        

        主机发送该ID号通过SDA到各个从机设备,每个从机设备就会用自身的ID号来匹配主机发送的ID号,来明白主机叫的是不是自己。换句话说就是,老师(主机)叫学生的名字(ID号),被叫的到的学生就会站起来,等待老师接下来的操作。

        在十位地址下,发送设备地址会占用两个字节(16位),主机首先发送11110这五位固定序列,对应剩下11位为10位从机设备地址以及1位读写位:

        主机发送数据时,SCL低电平SDA变换数据,而SCL高电平时,不允许SDA数据变换,此时稳定数据供从机接收(途中虚线部分)。实际上,由于主机拥有绝对的控制权,在SCL的下降沿结束瞬间,主机就开始对SDA进行数据变换,而从机需要尽快接主机收数据,因为主机不会等待从机接收数据,所以从机会在SCL刚一到高电平立即进行数据接收。

        同时需要注意,发送数据是高位先行

3.1.3接收应答:

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

        在主机发送模式下,主机每发送一个字节的数据,就需要等待应答。应答位是用来表示从机是否回应主机发送的数据,如果应答0表示从机接收到数据,还可以继续发送;如果位1表示没有从机接收到数据,或者是从机接收到了数据但是没有回应。

        在从机发送应答ACK前需要先将SDA的控制权交给从机,如上图虚线就为从机控制SDA的情况,然后从机发送完ACK应答位后又要将控制权还回主机:

        

        这样就会在ACK应答位前面的低电平产生一个小尖峰,一般可以忽略看作一个低电平,上图为ACK应答位为0表示应答的情况。应答完毕在ACK的下降沿之后归还控制权给主机,此时处于SCL的低电平,主机就会立刻转换数据。

3.1.4数据传输:

        此传输建立在前面确定从机地址的前提下,进行数据传输。

        首先,需要传入需要写入寄存器的地址,然后从机传回应答位,接着在写入需要写入寄存器的数据,然后传回应答位,如下图:

        SCL低电平跳变数据,SCL高电平稳定数据,供读取。

3.1.5终止条件:

        终止条件:SCL高电平期间,SDA从低电平切换到高电平。

        实际上,起始条件和终止条件都是特殊的时序,因为SCL处于高电平时是不允许SDA发生变化的,SCL高电平时SDA在数据发送和接收种都是只读的,不允许改变,而起始条件是SCL为高电平时,SDA为高电平跳变到低电平,终止条件则是SCL为高电平时SDA为低电平跳变到高电平。

        这样使得起始和终止时序的唯一性,不会在传输的时造成停止或者重复起始。

3.1.6主机接收:

        此时,主机将控制权交给从机,从机会发送数据给主机,供主机接收。

        也是和主机发送类似,当SCL为低电平时从机跳变数据,为高电平时主机接收数据,用图表示就是

        同样虚线为从机控制的时候,也是高位先发送,发送以一个字节(8位)然后主机就发送一个应答。

3.1.7发送应答:

        发送应答:主机在接收完一个字节之后,在下一个时钟发送一位数据,数据0表示应答,数据1表示非应答。

        主机发送应答位,从机通过对应答位的判断,决定从机是否需要再次发送数据。

        同样,在主机发送应答位之前,从机需要将SDA总线的控制权交还给主机,如果ACK为0,则主机就会再次将总线控制权交给从机,这循环一直至ACK为1或是遇到停止条件时序。

3.1.8小结

        通过以上的时序,拼接就能拼成一个完整的主机发送时序:

        意义是在指定从机(设备)(0xD0),在指定地址(寄存器)(0x19)写入指定的数据(0xAA)。

        然后是接收时序:

        意义是,对于指定设备(0xD1),在当前地址指针指示的地址下,读取从机数据(0x0F)。

        这个接收时序有着明显的缺陷,只能从该设备的第一个寄存器进行数据接收,相比发送时序能够指定寄存器(地址)进行发送。如果只指向该设备(从机)则只会从第一个寄存器开始接收数据。

        相应的,就有复合接收时序:

        意义是在指定主机(设备)(0xD1),在指定地址(寄存器)(0x19)读取指定的数据(0xAA)。

        将发送时序的发送设备时序以及寄存器(地址)时序与接收时序拼接在一起,实际上前半段复合的发送时序中起作用的是指定需要接收的寄存器(地址),这样再次起始Sr,再次写入接收的设备地址,之前的设备地址就会被覆盖,这样主机就能够接收到指定设备(从机)下指定的寄存器(地址)下的数据。

3.2软件实现IIC通信

        有了以上的时序,我们就能够通过GPIO口产生一个IIC的通信时序。

// 函数:I2C写SCL引脚电平
void MyI2C_W_SCL(uint8_t BitValue)
{
	GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);		//根据BitValue,设置SCL引脚的电平
	Delay_us(10);												//延时10us,防止时序频率超过要求
}
// 函数:I2C写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引脚电平
uint8_t MyI2C_R_SDA(void)
{
	uint8_t BitValue;
	BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11);		//读取SDA电平
	Delay_us(10);												//延时10us,防止时序频率超过要求
	return BitValue;											//返回SDA电平
}

        通过对SCL的电平翻转来模拟IIC通信中的时钟,在对应时序下,SDA做出相应的变化。

        例如,在SCL为高电平时,读取SDA的数据,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引脚初始化后默认为高电平(释放总线状态)
}

        然后是,GPIO端口的初始化,实际上,IIC的SDA、SCL默认复用功能也是GPIO_Pin_10、GPIO_Pin_11这两个端口:

        最后还需要将SCL、SDA设置为高电平,也就是处于空闲状态

        接下来就是对具体时序的配置了:

//起始
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也拉低,即为了占用总线,也为了方便总线时序的拼接
}

         起始条件,在SCL为高电平的时候,SDA由高电平跳变到低电平。

//发送字节
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高电平期间读取SDA
		MyI2C_W_SCL(0);						//拉低SCL,主机开始发送下一位数据
	}
}

        以发送一个字节为单位,也即i=8,通过与的方式来取出对应的位,例如:1000 1000&0100 0100,此时会等于0000 0000此变量的布尔值就为0,再如,1110 1101&1001 0010会等于1000 0000,此时布尔值就为1,只要有一位为1布尔值就为1,也就是bitaction。0x80也即为0x1000 0000,对应最高位,对应前面所说的高位先行的原则。

        开始,SCL为低电平,此时写入数据,然后SCL为高电平,读取数据,这样循环8此,即完成数据的写入,以及从机的读取。

//接收数据
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高电平期间读取SDA
		if (MyI2C_R_SDA() == 1){Byte |= (0x80 >> i);}	//读取SDA数据,并存储到Byte变量
														//当SDA为1时,置变量指定位为1,当SDA为0时,不做处理,指定位为默认的初值0
		MyI2C_W_SCL(0);						//拉低SCL,从机在SCL低电平期间写入SDA
	}
	return Byte;							//返回接收到的一个字节数据
}

        接收数据,相比发送数据需要变量来存储接收值,同时形参为void空,同时需要return存储在变量中的接收值。接受前,需要将控制权交给从机,也即释放SDA(默认弱上拉)为高电平。

        同样变量Byte或与0x80,相当于将0x80存入变量中Byte中。例如:1111 0000|0000 1111会等于1111 1111。

        上述执行的顺序是,先判断最高位SDA上的数据是否为1,如果为1则最高位或上1也就是|0x80,接着拉低SCL写入下一位数据,同时i++,这样依次循环8次。

//发送ACK应答
void MyI2C_SendAck(uint8_t AckBit)
{
	MyI2C_W_SDA(AckBit);					//主机把应答位数据放到SDA线
	MyI2C_W_SCL(1);							//释放SCL,从机在SCL高电平期间,读取应答位
	MyI2C_W_SCL(0);							//拉低SCL,开始下一个时序模块
}

        可以看到每个动作执行完后,SCL默认是被拉低到低电平,所以再每一个动作开始时,默认SCL就被拉低为低电平。

        再低电平时放上对应的应答位ACK,通过ACKBit形参进行写入SDA中,根据实际需求来写入ACK,如果还需要发送数据则需要写入ACK为0,不需要则写入ACK为1。

//主机接收应答
uint8_t MyI2C_ReceiveAck(void)
{
	uint8_t AckBit;							//定义应答位变量
	MyI2C_W_SDA(1);							//接收前,主机先确保释放SDA,避免干扰从机的数据发送
	MyI2C_W_SCL(1);							//释放SCL,主机机在SCL高电平期间读取SDA
	AckBit = MyI2C_R_SDA();					//将应答位存储到变量里
	MyI2C_W_SCL(0);							//拉低SCL,开始下一个时序模块
	return AckBit;							//返回定义应答位变量
}

        接收应答,需要先将SDA总线的控制权交给从机,从机就会立刻写入ACK应答位(因为此时SCL就为低电平,需要再高电平前写入数据ACK),当SCL为高电平时主机读取写入ACK应答位。

        再代码中表现就是,先释放SDA的控制权,此时SCL为低电平,从机写入ACK应答位,然后释放SCL为高电平供主机读取ACK应答位,用AckBit存储得到的SDA应答位,然后从机再将SDA的控制权交还给主机。

//终止条件
void MyI2C_Stop(void)
{
	MyI2C_W_SDA(0);							//拉低SDA,确保SDA为低电平
	MyI2C_W_SCL(1);							//释放SCL,使SCL呈现高电平
	MyI2C_W_SDA(1);							//在SCL高电平期间,释放SDA,产生终止信号
}

        最后是终止条件,当SCL、SDA都为低电平,此时SCL先由低电平跳变到高电平,然后再由SDA由低电平跳变到高电平,此时产生终止信号。

        以上就是一个完整的时序,下面调用函数,实现写操作的函数:

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终止
}

        需要注意的,第一个MyI2C-SendByte(MPU6050_ADDRESS)的最低为为0,前七位则为该MPU6050的七位地址。

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;
}

        然后是接收数据,这里使用的是复合接收时序,这里需要注意的是,在重复起始Sr后,需要写入设备地址的读写位写上1,MyI2C_SendByte(MPU6050_ADDRESS | 0x01),这里或上1(|1)保证了为接收操作(读操作)最后返回存储的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);			//配置寄存器,配置DLPF
	MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);	//陀螺仪配置寄存器,选择满量程为±2000°/s
	MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);	//加速度计配置寄存器,选择满量程为±16g
}

        同样的,在MPU6050中对应寄存器的地址,使用的是宏定义,自己在使用前需要进行一个宏定义的头文件包含,具体宏定义如下:

#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

        最后通过指针的方法进行值的回传:

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;						//数据拼接,通过输出参数返回
}

        掉用一下上述的函数,即可回传参数回形参:

MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ);

        以上就为对IIC软件实现的全部过程了,接下来介绍硬件的实现方法,实际上就是通过标准库中配置好的函数进行按需配置即可。

4.IIC的时序(硬件)

4.1IIC硬件结构图

        硬件就从原理图触发,可以看到SDA总线硬件和USART串口类似。

        原理也是类似,当开始写入一个数据时,数据被写入数据寄存器,因为移位寄存器为空,所以此时数据会立刻进入移位寄存器,同时置标志位TXE,表示数据寄存器为空。

        在发送地址阶段不会置改位,也就是前面两个字节,先发送设备地址,然后发送寄存器地址都不会置TXE标志位。同时若此时又写入了一个数据进入DR寄存器,此时DR寄存器为非空,软件会自动清除该位;或者是发生了一个起始或停止条件,也会清除该位.

        或是PE位=0,禁用IIC模块:

        接下来是下面的比较器

        该比较器是用在作为从机的模式下的,写入自身地址寄存器一个ID号,这样就可以通过该ID号来控制本设备。

        同时下面还有一个双地址寄存器也就是STM32支持同时相应两个从机地址,用在多主机模式下,较为复杂,感兴趣的可以了解一下。

        然后是错误帧校验PEC,帧错误PEC是一种简单而有效的校验机制,通过异或运算生成的校验值来确保数据帧的完整性和准确性。在IIC通信过程中,使用PEC值可以大大提高数据传输的可靠性,具体细节可以自行查阅相关资料了解。

        然后是下面,SCL时钟输出,就是通过寄存器来控制对应的状态。

4.2硬件IIC时序

        根据上图来进行分析,7位地址和10位地址,实际上就是开始的传送序列(发送地址)的区别,10位地址需要先写入特定的帧头11110 然后再写入地址3位地址凑成一个字节(8位)然后应答接着再写入7位地址以及一位读写位。相比7位地址,10位地址需要发送两个字节才能完成发送地址的操作。

        一般都是7位设备地址的发送,这里以7位地址来进行展开,实际上10位地址也大差不差的:

4.2.1 S

        首先是S=Start,也就是起始条件,对应Start位:

        可以看到在从模式下,置START位为1才会产生起始条件。而STM32作为主机如何产生起始条件呢?

        实际上,该模块默认地工作于从模式,接口在生成起始条件后自动地从从模式切换到主模式。

        这样,在初始时刻置START位就能够产生起始条件,然后在由从模式转换为主模式。在后续接收数据时,需要产生重复起始条件,也就是在该设备转换为主机置START位产生的Sr。

4.2.2 EV5

        接着是产生EV5事件,对应SB位置1,读SR1然后将地址写入DR寄存器将清除该事件

        该位就表示起始条件已发送,软件读取到该位(SB位),然后写入DR数据寄存器操作就会清除SB位,或者PE=0,也就是失能IIC模块。

        该位不能在通讯没结束之前被清除,这样会导致通讯数据丢失。同样当该位被清除后,所有位都会被清除,其次IIC模块会进入到空闲状态。

4.2.3 地址、A

        然后写入七位地址以及一位读写位,紧接着一位应答位,AF:

        返回应答位就置AF位为0,无应答返回时,硬件置该位为。AF位由软件写0后清除,同样失能IIC模块(PE=0)也能清除。

4.2.4 EV6        

        接着是EV6事件,ADDR位为1,读SR1然后读SR2清除该位:

        在主模式下,用于表示地址发送是否结束,7位设备地址收完后,接收到ACK位后置ADDR位为1。(而10位设备地址会在接收完第二个地址的ACK后才置ADDR为1)

        注:当ACK位接收到NACK,也就是AF位为1,表示没有接收到应答位,此时不会置ADDR位为1。也就说即使发送完全地址,如果介接收到的ACK位不为9(为1也即NACK),ADDR位也不会置1。

        在从模式下,作为是否匹配地址的依据位,也就是前面所说的主机发送地址到所有IIC上挂载的从机,从机将自己的设备地址和主机发送的地址进行匹配,如果匹配则置ADDR位为1,反之则为0。也就老师叫了一个学生的名字,在教室里面的学生都会对比一下自己名字是否匹配,匹配的学生就会站起来,对应ADDR位置1,则其他设备ADDR位置0。

4.2.5 EV8_1

        然后是EV8_1事件,移位寄存器、数据寄存器都为空,就是置TXE为1:

        和串口的TXE标志位一样,DR数据寄存器为空,也就是DR数据寄存器中的数据转移到移位寄存器中,在一开传输数据进DR寄存器时,该数据会立刻进移位寄存器,同时置TXE为1。跟上图注中写的意思是一样的。

        同时该位在发送地址阶段(也就是发送从机设备地址),此时DR数据寄存器为空不会置TXE位为1。同样写入数据到DR数据寄存器中会清除该位,失能IIC模块(PE=0)也同理。

        如果接收到应答位ACK为NCAK(为1非应答)时,或者PEC数据包检测出错时,不会置TXE位,也就是说该传输的数据无效。

4.2.6 EV8_2

        然后是EV8_2事件,也即移位寄存器为非空,数据寄存器为空,此时TXE还是为1(由于DR寄存器为空),也就对应EV8的下一个状态。此时将会写入下一个数据到数据寄存器中,同样写入诗句到DR寄存器就会清除TXE位。

        需要注意的和EV8_1类似,这里不在赘述。

        紧接着就传输数据,然后置应答位,这样循环直至应答位为1。

4.2.7 EV8_2

        接着是EV8_2事件,对应的TXE=1,BTF=1,请求停止位P。在产生停止条件(也即P位置1)后由硬件自动清除。

        BTF置1,表示此时字节已经发送结束,而TXE置1就是数据寄存器为空,没有数据写入。

        一个首标志位TXE,一个尾标志位BTF,同时都置1就代表字节已经发送完全了。

        BTF和TXE同时置1,也即在发送时,一个新数据将被发送且数据寄存器还未被写入新的数据

        BTF和RXNE同时置1,也即在接收时,接收到一个新字节(包括ACK脉冲)且数据寄存器还未被读取。

        同样也会在被软件读取该位后会被清除,TXE、RXNE、BTF都属于SR1寄存器内的位,同理失能IIC模块也会清除该位。

        实际上,想要清除对应的标志位,通过软件读取该位所在的寄存器,或是失能IIC(置PE=0),这两种通用的方法。

        注意事项也是和前面差不多,NACK、PEC都不会使得BTF置1,因为数据出现异常。

4.2.8 P

        P也即STOP位,停止位:

        软件可以手动清除该位,也即对应软件实现IIC。同样在检测到停止条件时(停止时序),会由硬件请画出,同样检测到超时错误,也会被软件置1,也即开始停止。

        在主从模式分别对应不同的效果,也就是在主模式下表示产生停止条件,而对于从模式下,表示SCL、SDA线被释放为高电平(弱上拉)。

        最后注,如果设置了STOP、START、PEC位,如果在硬件清除这些位之前,进行了这些位的写操作,写0或者写1,就有可能导致2次设置STOP、START、PEC位,也就是会导致这些位重复执行,例如再次软件置位STOP,当STOP结束第一次或者还未结束,就会重新开始执行第二次STOP时序,这里需要注意。

4.2.9 EV9

        EV9是在10位设备地址下的事件,ADDR10=1:

        和前面的ADDR类似,这里不在赘述。

        以上就是发送操作下的所有事件,然后是接收的时序。

4.2.10 接收时序

        实际上,是类比发送事件,例如EV8、EV8_1类比EV7、EV7_1,其他也是类似的,每一位都提及了,这里就不在赘述。

        同样也分为10位设备地址和7位设备地址,这里需要注意的是,这里采用十位地址设备时,在输入完两个字节后,相比10位发送需要多出一个重复起始Sr,然后再次写入帧头(11110),这里的做法类似,前面提到的复合接收时序。同样在发送完设备地址及寄存器两位字节后,重复起始Sr,然后发送7位设备加上一位读(低位为1),这里也是类似的作用。

        通过观察硬件发送和接收时序,对比软件少了寄存器地址写入,这是因为在指定地址(寄存器)写入被用在函数中,具体参照下面的硬件配置IIC。同时在最后一个应答位,发送是应答位会置应答,此时EV8_2事件会产生停止事件P,而接收则是置非应答来产生停止条件,这里需要注意。

4.3硬件实现IIC通信

        根据上述大致的图,来进行IIC通信的硬件实现。

	/*开启时钟*/
	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引脚初始化为复用开漏输出

        先初始化GPIO对应的默认复用引脚。

        然后初始化IIC:

	/*I2C初始化*/
	I2C_InitTypeDef I2C_InitStructure;						//定义结构体变量
	I2C_InitStructure.I2C_Mode = I2C_Mode_I2C;				//模式,选择为I2C模式
	I2C_InitStructure.I2C_ClockSpeed = 50000;				//时钟速度,选择为50KHz
	I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2;		//时钟占空比,选择Tlow/Thigh = 2
	I2C_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,开始运行
	

        这里对IIC的时钟和时钟占空比进行具体展开,其他的看注释即可理解:

        IIC支持标准通讯速度(最高100KHz),以及快速通讯模式(最高400KHz)。

        ClockSpeed的值根据自身的需求来设计,也要考虑从机的最大通讯速率。

        然后是时钟占空比,时钟占空比只有在快速模式下(也即时钟速度>100KHz),才有效,在小于100KHz下,固定占空比为1:1(低电平时间比高电平时间),这个占空比是为了快速模式下的快速传输而准备的。

        下图为通讯速度为50KHz的情况:

        通过上图,能看到低电平比高电平为1:1,也即占空比为50%。

        下图为通讯速度为100KHz的情况:

        通过上图,此时低电平比高电平仍然为1:1,也即占空比为50%。        

        同时观察到在SCL和SDA的上升沿变得更加”弯曲“,而下降沿依然是直的向下,这是因为SCL和SDA是开漏输出,弱上拉,这样当输出低电平就会被强下拉至低电平,而输出高电平会变为弱上拉的高电平。

        下图为通讯速度为101KHz的情况:

        此时就超过了100KHz的标准速度的阈值,此时进入快速状态,IIC就会通过配置的占空比来进行低电平、高电平之间的调节。可以看出低电平比高电平大概为2:1,增大低电平的比例,由于低电平是进行电平变化,而高电平是进行数据读取。电平变化需要一定的时间,这样给低电平更多的资源,这样就会避免在快速模式下,数据还未跳变就开始读取数据。这就是在快速模式下2:1的占空比的原因。

        下图为通讯速度为200KHz的情况:

        下图为通讯速度为400KHz极限快速速度的情况: 

        从上面图就能够很直观的看出,弱上拉在速度越快的情况下,SCL高电平图形就越趋近于三角形,此时就更应该给足低电平的占空比率,能够由足够的时间跳变数据。

        然后是MPU6050从机的初始化,需要对照手册:

    /*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);					//配置寄存器,配置DLPF
	MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);			//陀螺仪配置寄存器,选择满量程为±2000°/s
	MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);			//加速度计配置寄存器,选择满量程为±16g

        然后是等待超时函数:

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;										//跳出等待,不等了
		}
	}
}

        这里一般不采用while死循环,这样如果一直没有等到某个时间(例如EV8),则程序会卡死,这样设计一个超时退出函数,就可以避免程序卡死现象。

        接着是发送函数:

void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
	I2C_GenerateSTART(I2C2, ENABLE);										//硬件I2C生成起始条件
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);					//等待EV5
	
	I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);	//硬件I2C发送从机地址,方向为发送
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);	//等待EV6
	
	I2C_SendData(I2C2, RegAddress);											//硬件I2C发送寄存器地址
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING);			//等待EV8
	
	I2C_SendData(I2C2, Data);												//硬件I2C发送数据
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);				//等待EV8_2
	
	I2C_GenerateSTOP(I2C2, ENABLE);											//硬件I2C生成终止条件
}

        根据发送时序写入对应的函数,其中I2C_GenerateSTART、I2C_Send7bitAddress等函数都为标准库函数,需要的可以自行查阅理解。

        对应的接收函数:

uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
	uint8_t Data;
	
	I2C_GenerateSTART(I2C2, ENABLE);										//硬件I2C生成起始条件
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);					//等待EV5
	
	I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);	//硬件I2C发送从机地址,方向为发送
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);	//等待EV6
	
	I2C_SendData(I2C2, RegAddress);											//硬件I2C发送寄存器地址
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);				//等待EV8_2
	
	I2C_GenerateSTART(I2C2, ENABLE);										//硬件I2C生成重复起始条件
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);					//等待EV5
	
	I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Receiver);		//硬件I2C发送从机地址,方向为接收
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED);		//等待EV6
	
	I2C_AcknowledgeConfig(I2C2, DISABLE);									//在接收最后一个字节之前提前将应答失能
	I2C_GenerateSTOP(I2C2, ENABLE);											//在接收最后一个字节之前提前申请停止条件
	
	MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_RECEIVED);				//等待EV7
	Data = I2C_ReceiveData(I2C2);											//接收数据寄存器
	
	I2C_AcknowledgeConfig(I2C2, ENABLE);									//将应答恢复为使能,为了不影响后续可能产生的读取多字节操作
	
	return Data;
}

        也是类似的,根据前面的硬件接收时序进行配置对应函数,根据注释基本能够理解,这里就不在展开了。

        同样类似软件IIC使用指针返回MPU6050发送的数据:

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;						//数据拼接,通过输出参数返回
}
MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ);		//获取MPU6050的数据

        硬件IIC配置到这就完成了,归根结底是要理解IIC时序的顺序。

5.总结

        以上就是对IIC的全部内容了,IIC的发送时序、接收时序,以及软硬件实现。欢迎各位的阅读以及错误指正。

        最后提一下,本篇文章也是在江科大的视频下完成的,同时今天收工之日也是无畏契约的全球总决赛,EDG VS TH,本人在边写文章边看比赛,怀着忐忑的心情,最终释怀见证EDG夺冠,去年无畏契约才开启国服,今年CN瓦就夺冠,本人百感交集,白驹过隙,六年前同样身处仁川的IG也在韩国首尔取得冠军,在赛前就有预感,虽然每次被TH拿分,韩国主场会爆发出雷鸣的掌声,但我却觉得EDG每次关键的残局决策,获得胜利的沉默更加震耳欲聋,即使现场也有许多支持EDG的粉丝,即使EDG队员身处对方主场,EDG队员每一位都打出了自己的风采,更是打出了CNFPS的风采,即使输了我也丝毫不觉得比冠军差,同样EDG的队友在最后取得冠军后激动落泪,这一幕不可多得,S1Mon在奇乐大招内的残局更是看的令人惊心动魄,以及最后绝境EDG被连翻6分后ZmjjKK戍卫的奋起四杀,奠定获胜的基础,等等等,每一次队员都有关键时刻,这些都最终铸就了EDG的成功,出道即冠军,捧杯吧,EDG!!!这就是CNFPS!!!让我把FMVP给到世界第一决斗康康!!!

参考文章:[10-5] 硬件I2C读写MPU6050_哔哩哔哩_bilibili

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值