本次实验分为两部分,第一部分我们完成软件I2C协议的时序,第二部分,我们基于I2C协议,来操作读写寄存器,来操控MPU6050.

软件I2C相比硬件I2C的一大优势是端口不受限,可以任意指定。

先来介绍一下思路:

首先建立I2C通信层的.c和.h模块,在通信层里写好I2C底层的GPIO口初始化和6个时序基本单元,分别是起始条件,终止条件,发送一个字节,接收一个字节,发送应答和接收应答。

之后我们再建立MPU6050的.c和.h模块,在这一层,我们将基于I2C通信的模块,来实现指定地址读,指定地址写,再实现写寄存器对芯片进行配置,读寄存器得到传感器数据

最终在main.c里,调用MPU6050的模块,初始化,拿到数据,显示数据。

软件I2C初始化需要做两个任务:

1.把SCL和SDA都初始化成开漏输出模式

2.把SCL和SDA置高电平

我们当前接线:SCL是PB10,SDA是PB11

void MyI2C_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);

GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD;//开漏输出模式下仍可以输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 |GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);

GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11);//开漏输出高电平时因高阻态会变成浮空
//但是MPU那边会有一个上拉电阻使SCL和SDA默认空闲状态高电平

}

接下来我们来实现6个时序基本单元,首先是起始条件和终止条件

我们可以不断地调用SetBits和ResetBits,来手动翻转高低电平,但是这样做的话,会在后面的程序中出现很多指定这个GPIO端口号的地方,一方面,这样的语义不明显,另一方面,如果我们之后需要换一个端口,就需要改动很多地方,所以我们可以在最上面做一个定义,把这个端口号统一换个名字,一种简单的替换方法就是宏定义

#define SCL_PORT     GPIOB;
#define SCL_PIN         GPIO_Pin_10;

GPIO_SetBits(SCL_PORT, SCL_PIN);

如果觉得每次都要定义PORT和PIN比较麻烦,还可以把整个函数都用宏定义进行替换,可以使用有参宏,在宏定义后面加一个括号,里面写上形参,那在实际引用的时候,比如这里调用OLED_W_SCL,实参为1,那替换的时候实参1对应的就是函数里的x。

#define OLED_W_SCL(x)      GPIO_WriteBit(GPIOB, GPIO_Pin_8, (BitAction)(x))//将x的值强制转换为BitAction类型

OLED_W_SCL(1);

这里我们选择使用函数的方法,容易理解也方便加软件延时。

void MyI2C_W_SCL(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);
Delay_us(10);
}
void MyI2C_W_SDA(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue);
Delay_us(10);
}
uint8_t MyI2C_R_SDA(void)
{
uint8_t BitValue;
BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11);
Delay_us(10);
return BitValue;
}
有了这三个函数的封装,我们就实现了函数名称、端口号的替换。同时也很方便地修改时序的延时,当我们需要替换端口,或者把这个程序移植到别的单片机中时,就只需要对上面4个函数里的操作对应更改,然后后面的函数,我们都调用这里封装的新名称进行操作。这样在移植的时候,后面的部分就不需要进行修改了。
void MyI2C_Start(void)
{
MyI2C_W_SDA(1);//释放SCL和SDA
MyI2C_W_SCL(1);//为了兼容重复起始条件Sr,Sr最开始时SCL是低电平,SDA电平不能确定,所以保险起见
//我们趁SCL是低电平,先确保释放SDA,再释放SCL,这时SDA和SCL都是高电平,之后再拉低SDA和SCL
//这样这个函数就能够兼容起始条件和重复起始条件了。
MyI2C_W_SDA(0);//先拉低SDA再拉低SCL,对应起始条件
MyI2C_W_SCL(0);
}
void MyI2C_Stop(void)
{
MyI2C_W_SDA(0);//在这个时序单元开始时,SDA并不一定是低电平,所以为了确保之后释放SDA能产生上升沿
//我们要在时序单元开始时,先拉低SDA。
MyI2C_W_SCL(1);
MyI2C_W_SDA(0);
}
一开始SCL低电平,SDA就选择是否变换数据,高电平时,SDA保持数据稳定,由于是高位先行,所以变换数据的时候,按照先放最高位,再放次高位,直到最低位,依次把一个字节的每一位放在SDA线上,每放完一位,执行释放SCL再拉低SCL的操作,驱动时钟运转。
void MyI2C_SendByte(uint8_t Byte)
{
//发送一个字节时SCL是低电平,除了终止条件SCL以高电平结束,所有的单元我们都会保证SCL以低电平结束
//这样方便各个单元的拼接
MyI2C_W_SDA(Byte & 0x80);//与上0x80就是取Byte字节里的最高位
MyI2C_W_SCL(1);//驱动时钟走一个脉冲,这里释放SCL之后,从机就会立刻把刚才放在SDA的数据读走
MyI2C_W_SCL(0);}

因为要这样子连续发送8次,所以改用for循环替代
void MyI2C_SendByte(uint8_t Byte)
{for (uint8_t 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,因为会有上拉电路把浮空的SDA拉到高电平,如果从机想发0,就拉低SDA。然后主机释放SCL,在SCL高电平期,读取SDA,再拉低SCL,低电平期间,从机就会把下一个数据放到SDA上。这样重复8次,主机就能够读到一个字节了。
uint8_t MyI2C_RecieveByte(void)
{
uint8_t Byte = 0x00;
MyI2C_W_SDA(1);//主机释放SDA,让从机写数据到SDA
for (uint8_t i = 0; i < 8; i++)
{
MyI2C_W_SCL(1);
if (MyI2C_R_SDA() == 1){Byte |= (0x80 >> i);}//因为是一位一位传过来的,读函数是1就是高电平
//不然就是低电平,不触发if条件就默认为0低电平了
MyI2C_W_SCL(0);
}
return Byte;
}
发送应答和接收应答就是发送一个字节和接收一个字节的简化版,发送一个字节是发8位,发送应答就是发送一位。所以直接复制上面两个函数去掉for循环,稍加修改就好了。
void MyI2C_SendAck(uint8_t AckBit)
{
MyI2C_W_SDA(AckBit);//函数发生时,SCL低电平,主机把AckBit放到SDA上
MyI2C_W_SCL(1);//SCL置为高电平,从机读取应答
MyI2C_W_SCL(0);//SCL低电平,进入下一个时序单元
}
uint8_t MyI2C_RecieveAck(void)
{
uint8_t AckBit;//进入函数时,SCL低电平
MyI2C_W_SDA(1);//主机释放SDA,防止干扰从机,从机会把应答位放在SDA上,如果要输出低电平,从机就拉低SDA
//如果要输出高电平,从机就不管SDA,会有弱上拉将其置为高电平,读到0说明从机给了应答,读到1代表从机没给应答
MyI2C_W_SCL(1);//SCL置高电平,主机读取应答位
AckBit = MyI2C_R_SDA();//取出主机读取好的应答位
MyI2C_W_SCL(0);//SCL置低电平,进入下一个时序单元
return AckBit;
}
到这里6个时序基本单元就完成了,接下来先测试一下有无问题
#include “stm32f10x.h”                  // Device header
#include “Delay.h”
#include “OLED.h”
#include “MyI2C.h”
int main(void)
{
OLED_Init();
MyI2C_Init();
//我们按照指定地址写和指定地址读的时序结构,来拼接一下完整的时序
MyI2C_Start();//起始后,主机必须首先发送一个字节,内容是从机地址+读写位,进行寻址
MyI2C_SendByte(0xD0);      //1101 000 0 从机地址为1101000,读写位0表示写
//发送一个字节后,我们要接收一下应答位。
uint8_t Ack = MyI2C_ReceiveAck();//这里先测试一下应答位
MyI2C_Stop();
OLED_ShowNum(1, 1, Ack, 3);
while (1)
{
}
}
应答位为1的话,说明是默认的高电平,从机没有应答,为0的话说明从机收到指令并拉低了SDA进行了应答。
把发送的地址更改一下,不是对应MPU6050的地址,它就不会应答了
我们可以利用这个类似点名的时序,来完成从机地址扫描的功能,使用for循环遍历所有的从机地址,然后把应答位为0的地址统计下来,这样就能实现扫描总线上设备的功能了。
接下来我们继续写建立在MyI2C模块之上的MPU6050模块
我们先封装好指定地址写和指定地址读的时序
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
MyI2C_Start();
MyI2C_SendByte(MPU6050_ADDRESS);//为了方便修改参数,并且突出它是从机地址,使用宏定义替换数据
MyI2C_ReceiveAck();//这里有应答位,可以判断从机有没有收到数据就行了,不对返回值进行判断也行。
MyI2C_SendByte(RegAddress);//第二个字节的内容就是指定寄存器地址了,这个字节,会存在MPU6050的当前地址指针里
//用于指定具体读写哪个寄存器
MyI2C_ReceiveAck();//同样的发送一个字节后也得接收一下应答。
MyI2C_SendByte(Data);//第三个字节就是在我指定要写入的寄存器下要写入的数据了
MyI2C_ReceiveAck();
MyI2C_Stop();
}
如果想要指定地址写多个字节的话,就可以用for循环,把写入数据的代码套起来,多执行几遍,然后依次把一个数组的各个字节发送出去。
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
uint8_t Data;
MyI2C_Start();
MyI2C_SendByte(MPU6050_ADDRESS);
MyI2C_ReceiveAck();
MyI2C_SendByte(RegAddress);
MyI2C_ReceiveAck();//前面这一部分和指定地制写一样,就是在设置MPU6050的当前地址指针。接下来要转入读的时序
//要转入读的时序,就必须重新指定读写位,就必须要重新起始
MyI2C_Start();
MyI2C_SendByte(MPU6050_ADDRESS | 0x01);//改为读操作
MyI2C_ReceiveAck();
Data = MyI2C_ReceiveByte();//总线控制权交给从机了
MyI2C_SendAck(1);//主机接收到数据要发送应答,参数给1不给从机应答,意思是不再读取了,所以该函数只读一个字节
MyI2C_Stop();return Data;
}

同样也是可以使用for循环来实现读取多字节。
程序写到这,我们就可以进一步进行测试了
我们先来读取MPU6050的WHO_AM_I寄存器,地址是0x75,内容是ID号,默认值为0x68

MPU6050_Init();

uint8_t ID = MPU6050_ReadReg(0x75);

OLED_ShowHexNum(1, 1, ID, 2);

接下来验证一下写寄存器有没有问题,首先要接触睡眠模式,否则写入是无效的。睡眠模式是电源管理寄存器1的第六位SLEEP控制的。我们可以直接把这个寄存器写入0x00.这样就能接触睡眠模式了。这个寄存器的地址是0x6B
int main(void)
{
OLED_Init();
MPU6050_Init();
MPU6050_WriteReg(0x6B, 0x00);//给电源管理寄存器1写入0x00,解除睡眠模式。
MPU6050_WriteReg(0x19, 0xAA);//使用采样分频寄存器来测试是否能够写入
uint8_t ID = MPU6050_ReadReg(0x19);
OLED_ShowHexNum(1, 1, ID, 2);
while (1)
{
}
}
接下来我们就可以通过寄存器来控制电路了。首先初始化之后我们还需要在写入一些寄存器,对MPU6050硬件电路进行初始化配置。
这里我们一般会用宏定义,先把寄存器的地址都用一个字符串来表示。不然每次都查手册会比较麻烦,而且光写一个数据地址也不好理解。如果要定义的比较多的话,我们可以再建一个单独的头文件存放。

#ifndef __MPU6050REG_H
#define __MPU6050REG_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的使用手册。

void MPU6050_Init(void)
{
MyI2C_Init();
MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);//配置电源管理寄存器1,接触睡眠,选择陀螺仪时钟
MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00);//配置电源管理寄存器2,6个轴均不待机
MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09);//配置采样分频寄存器,决定了数据输出的快慢,值越小越快。这里为10
MPU6050_WriteReg(MPU6050_CONFIG, 0x06);//配置寄存器,滤波参数给最大
MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);//陀螺仪配置寄存器,前面三位是自测使能,接下来满量程选择。
MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);//加速度计配置寄存器,陀螺仪和加速度计都选择了最大量程。
//配置完后,陀螺仪内部就在连续不断地进行数据转换了。输出的数据就存放在相应的寄存器里。想要获取数据的话
//只需要再写一个获取数据寄存器的函数。

}

根据任务需求,加下来这个函数需要返回6个uint16_t的数据,分别表示xyz的加速度值和陀螺仪值,但是C语言中,函数的返回值只能有一个,所以需要一些特殊操作来实现返回6个值的任务。

第一种最简单的方法,在函数外面定义6个全局变量,子函数读到数据,直接写到全局变量里,然后6个全局变量,在主函数进行分享。比较适合用在规模比较小的项目。

第二种,用指针,进行变量的地址传递,来实现多返回值

第三种,使用结构体,对多个变量进行打包,再统一进行传递。

这里使用第二种方法。

void MPU6050_GetData(int16_t *AccX, int16_t *AccY, int16_t *AccZ,
int16_t *GyroX, int16_t *GyroY, int16_t *GyroZ)//之后我们会在主函数定义变量,通过指针
//把主函数变量的地址传递到子函数来。子函数中,通过传递过来的地址,操作主函数的变量,这样子函数结束后
//就是主函数变量的值,就是子函数想要返回的值。
{
uint8_t DataH, DataL;//读取高八位放在DataH,低八位放在DataL

DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H);//先读取加速地寄存器X轴的高8位
DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L);//然后再读取加速度寄存器的低8位
*AccX = (DataH << 8) | DataL;//高8位左移8位再或上低8位即可得到16位数据,再将其赋到指针传进来的地址中去

DataH = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_H);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_L);
*AccY = (DataH << 8) | DataL;

DataH = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_H);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_L);
*AccZ = (DataH << 8) | DataL;

DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L);
*GyroX = (DataH << 8) | DataL;

DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L);
*GyroY = (DataH << 8) | DataL;

DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L);
*GyroZ = (DataH << 8) | DataL;
}

这里还有一个更加高效的方法,就是使用I2C读取多个字节的时序,从一个基地址开始,连续读取一片的寄存器,因为这里的寄存器地址是连续的,所以可以从第一个寄存器的地址0x3B开始,连续读取14个字节,这样就可以一次性地把加速度值,陀螺仪值,包括两个字节的温度值,都读取出来。

#include “stm32f10x.h”                  // Device header
#include “Delay.h”
#include “OLED.h”
#include “MPU6050.h”
int16_t AX, AY, AZ, GX, GY, GZ;
int main(void)
{
OLED_Init();
MPU6050_Init();
while (1)
{
MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ);
OLED_ShowSignedNum(2, 1, AX, 5);
OLED_ShowSignedNum(3, 1, AY, 5);
OLED_ShowSignedNum(4, 1, AZ, 5);
OLED_ShowSignedNum(2, 8, GX, 5);
OLED_ShowSignedNum(3, 8, GY, 5);
OLED_ShowSignedNum(4, 8, GZ, 5);
}
}
左边三个数是XYZ轴的加速度计,右边三个数是XYZ轴的陀螺仪

今天的内容感觉很多,感觉做了有3,4个小时了。做到半夜1点多,还是要提前做,别留的太晚了。还是挺累的,感觉一沾枕头就能睡着了。