MPU6050是一个 6 轴运动传感器,包括 3 轴加速度传感器和 3 轴陀螺仪,具有 2 组 I2C 接口,1 组用于与 MCU 通信,另外 1 组可以外接另外一个传感器(比如磁力计),组成 9 轴传感器系统。

为了方便,这里我用的 MPU6050 模块,在优信那里购买(模块居然比单芯片便宜,就离谱)。

引脚说明

总共有 8 个引脚,各引脚说明如下:

编号 引脚名称 引脚说明
1 VCC 电源输入,3.3V 或 5V
2 GND 接地
3 SCL I2C 从时钟线
4 SDA I2C 从数据线
5 XDA I2C 主数据线,可外接其他传感器
6 XCL I2C 主时钟线,可外接其他传感器
7 AD0 从机地址引脚
接地或悬空时,地址为 0x68
接 VCC 时,地址为 0x69
8 INT 中断输出
芯片使用 DMP 库时,每当其内部处理完一次数据,就产生一次中断,提醒 MCU 接收数据

接线说明

由于网上不少人说使用 STM32 的硬件 I2C 与 MPU6050 通信时会出问题,所以这里选择软件模拟I2C通信时序。并且读取原始数据用不到 DMP 库,因此这里只需要将 SCL 和 SDA 接入 STM32 任意引脚,VCC 接 3.3V 并接好地,其他引脚悬空即可。

软件模拟 I2C

直接用杜邦线连的 MPU6050,没有加上拉电阻,所以模拟 I2C 时不能用开漏输入模式读取到电平变化,而 I2C 总线上也只有一个 MPU6050 从设备,因此可以采用推挽输出+浮空输入的方式,必要时通过寄存器切换 I/O 方向即可。如何切换请看这里

2023-12-07更正:MPU6050 模块内部有上拉电阻,因此 GPIO 可以使用开漏输出模式。下面的代码是推挽模式的,只需要去掉 I2C 时序中读写模式切换的相关代码、把 I2C 对应 GPIO 改为初始化为开漏输出(GPIO_MODE_OUTPUT_OD)即可,其余部分不用修改。

到底是推挽还是开漏

  • 如果只有一个从设备,那么推挽和开漏都可以,此时不会出现短路以及线与的需求;
  • 如果有多个从设备,那么就只能用开漏输出。因为推挽模式下,有多个从设备挂载在一个总线上时,一个设备输出高电平(推挽),另一个输出低电平,此时就会发生短路,并且推挽模式无法实现线与功能

头文件定义

/**********引脚定义***********/
#define SCL_PIN GPIO_PIN_10
#define SDA_PIN GPIO_PIN_11
#define SCL_GPIO_PORT GPIOB
#define SDA_GPIO_PORT GPIOB
#define SCL_GPIO_CLK_ENABLE() __HAL_RCC_GPIOB_CLK_ENABLE()
#define SDA_GPIO_CLK_ENABLE() __HAL_RCC_GPIOB_CLK_ENABLE()

/**********高低电平***********/
#define SCL_HIGH() HAL_GPIO_WritePin(SCL_GPIO_PORT,SCL_PIN,GPIO_PIN_SET)
#define SCL_LOW() HAL_GPIO_WritePin(SCL_GPIO_PORT,SCL_PIN,GPIO_PIN_RESET)
#define SDA_HIGH() HAL_GPIO_WritePin(SDA_GPIO_PORT,SDA_PIN,GPIO_PIN_SET)
#define SDA_LOW() HAL_GPIO_WritePin(SDA_GPIO_PORT,SDA_PIN,GPIO_PIN_RESET)

/**********SDA读写模式切换****/
#define SDA_IN() {SDA_GPIO_PORT->CRH &= 0xFFFF0FFF;SDA_GPIO_PORT->CRH |= 4<<12;}//配置SDA引脚为浮空输入
#define SDA_OUT() {SDA_GPIO_PORT->CRH &= 0xFFFF0FFF;SDA_GPIO_PORT->CRH |= 3<<12;}//配置SDA引脚为高速推挽输出

/**********读取SDA状态*******/
#define SDA_READ() HAL_GPIO_ReadPin(SDA_GPIO_PORT,SDA_PIN)

/**********I2C应答位*********/
#define I2C_NOACK 1
#define I2C_ACK 0

/**********I2C读写方向位*/
#define I2C_READ 1
#define I2C_WRITE 0

这里我略去了函数声明。

函数实现

I2C 初始化

void I2C_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct;

    //初始化引脚时钟
    SCL_GPIO_CLK_ENABLE();
    SDA_GPIO_CLK_ENABLE();

    //初始化SCL
    GPIO_InitStruct.Pin = SCL_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;//推挽输出
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(SCL_GPIO_PORT,&GPIO_InitStruct);

    //初始化SDA
    GPIO_InitStruct.Pin = SDA_PIN;
    HAL_GPIO_Init(SDA_GPIO_PORT,&GPIO_InitStruct);

    SDA_HIGH();
    SCL_HIGH();
}

将 SCL、SDA 对应引脚初始化为高速推挽输出。最后将两条线的电平都拉高,因为 I2C 空闲时二者皆为高电平。

初始化时,配置为上拉不会让引脚默认为高电平。
原因是输出模式下,引脚电平取决于输出数据寄存器,而输出数据寄存器初始为 0,所以即使初始化为上拉输出后,引脚的电平仍然为低电平,上拉只能小幅提高输出电流能力,不会影响引脚的默认状态

起始和停止信号

void I2C_Start(void)
{
    SDA_OUT();//SDA设置为推挽输出,MCU向MPU6050发送数据

    SCL_HIGH();
    SDA_HIGH();
    delay_5us();
    SDA_LOW();
    delay_5us();
    SCL_LOW();
    delay_5us();
}

在发出起始信号之前,需要先把 I/O 方向调整为输出。

SDA 保持不少于 4.7us 的高电平后拉低,SCL 继续保持不少于 4us 的高电平后再拉低

void I2C_Stop(void)
{
    SDA_OUT();

    SCL_LOW();
    SDA_LOW();
    SCL_HIGH();
    delay_5us();
    SDA_HIGH();
    delay_5us();
}

与起始信号类似,就不赘述了。

写应答和读应答

void I2C_WriteACK(uint8_t ack)
{
    SDA_OUT();

    SCL_LOW();
    if(ack)//非应答
        SDA_HIGH();
    else//应答
        SDA_LOW();
    SCL_HIGH();
    delay_5us();
    SCL_LOW();
    SDA_HIGH();//释放数据线
}
uint8_t I2C_ReadACK(void)
{
    uint8_t ack;

    SDA_IN();//切换为输入模式

    SCL_LOW();
    SCL_HIGH();
    delay_5us();
    if(SDA_READ())
        ack = I2C_NOACK;
    else
        ack = I2C_ACK;
    SCL_LOW();
    delay_5us();
    SDA_OUT();
    return ack;
}

严格按照 I2C 的通信时序就行了。值得注意的是,在读取应答之前,需要先把 SDA 切换为输入模式。

读写字节

void I2C_WriteByte(uint8_t byte)
{
    uint8_t i;
    SDA_OUT();
    for(i = 0;i < 8;i++)
    {
        SCL_LOW();
        if(byte & 0x80)//取出最高位,1则拉高SDA
            SDA_HIGH();
        else
            SDA_LOW();
        SCL_HIGH();
        byte <<= 1;
    }
    SCL_LOW();
    SDA_HIGH();
}

uint8_t I2C_ReadByte(void)
{
    uint8_t byte,i;
    SDA_IN();
    for(i = 0;i < 8;i++)
    {
        byte <<= 1;
        SCL_LOW();
        SCL_HIGH();
        if(SDA_READ())
            byte |= 0x01;
    }
    return byte;
}

一样的,注意 I/O 方向的切换即可。

读取 MPU6050 模块

终于到主题了 2333

头文件定义

#define MPU6050_ADDR 0x68     //mpu6050 i2c地址(AD0悬空或接地)
#define MPU6050_WHO_AM_I 0x75 //检验I2C通信是否正常的寄存器,如正常,应读取到的值为0x68

/***********配置寄存器地址**********/
#define PWR_MGMT_1 0x6B   //电源管理1,典型值0x80(上电复位,关闭睡眠、循环模式,使能温度传感器,使用内部晶振)
#define GYRO_CONFIG 0x1B  //陀螺仪配置寄存器(不自检,±2000/0x18)
#define ACCEL_CONFIG 0x1C //加速度配置寄存器(不自检 ±16g/0x18 ±8g/0x10 ±4/0x08 ±2g/0x00)
#define CONFIG 0x1A       //配置寄存器,典型值为0x06(5Hz低通滤波)
#define SMPRT_DIV 0x19    //陀螺仪输出速率的分频器,典型值0x07(对应采样频率125Hz)

/***********加速度输出寄存器********/
#define ACCEL_XOUT_H 0x3B
#define ACCEL_XOUT_L 0x3C
#define ACCEL_YOUT_H 0x3D
#define ACCEL_YOUT_L 0x3E
#define ACCEL_ZOUT_H 0x3F
#define ACCEL_ZOUT_L 0x40

/***********温度输出寄存器**********/
#define TEMP_OUT_H 0x41
#define TEMP_OUT_L 0x42

/***********陀螺仪输出寄存器********/
#define GYRO_XOUT_H 0x43
#define GYRO_XOUT_L 0x44
#define GYRO_YOUT_H 0x45
#define GYRO_YOUT_L 0x46
#define GYRO_ZOUT_H 0x47
#define GYRO_ZOUT_L 0x48

/***********量程***************/
#define GYRO_RANGE (float)2000         //陀螺仪量程
#define ACCEL_RANGE (float)2           //加速度计量程
#define COUNT_UPPER_LIMIT (float)32767 //ADC计数上限

/*****加速度、陀螺仪数据********/
extern float ACCEL_X;
extern float ACCEL_Y;
extern float ACCEL_Z;
extern float GYRO_X;
extern float GYRO_Y;
extern float GYRO_Z;

如前面所言,MPU6050 模块在 AD0 引脚悬空或接地时,其 I2C 地址为 0x68(110 1000);接电源时,为 0x69(110 1000)。该地址会作为 I2C 起始信号之后的第一个数据帧的前 7 位传输给 MPU6050,而该数据帧的最后一位(数据帧有 8 位)表示读写方向。

剩下的寄存器地址定义和配置可以看这篇文章:关于MPU6050学习的一些总结之一MPU6050芯片手册的整理-CSDN

函数实现

初始化 MPU6050

void MPU6050_Init(void)
{
    I2C_Init();
    if(MPU6050_ReadReg(MPU6050_WHO_AM_I) == MPU6050_ADDR)
    {
        MPU6050_WriteReg(PWR_MGMT_1,0x00);    //解除休眠
        MPU6050_WriteReg(SMPRT_DIV,0x07);     //配置陀螺仪输出速率分频器,生成采样率  
        MPU6050_WriteReg(CONFIG,0x06);        //配置低通滤波  
        MPU6050_WriteReg(GYRO_CONFIG,0x18);   //配置陀螺仪量程  
        MPU6050_WriteReg(ACCEL_CONFIG,0x00);  //配置加速度计量程
    }else{
        printf("MPU6050 Init Failed\n");
    }
}

在配置寄存器之前,需要先确定 I2C 通信正常,这就需要读取寄存器 WHO_AM_I 的值,与设备地址做对比。

建议在调用 MPU6050_Init 之前加一段时间的延时,大概几十 ms。因为 MPU6050 上电后需要等待一段时间才能正常读写数据,上电立马开始读写会导致后续读到的数据一直为 0

读写寄存器

void MPU6050_WriteReg(uint8_t regAddr,uint8_t regData)
{
    //发送起始信号
    I2C_Start();

    //发送从机地址,并表示写入方向
    I2C_WriteByte((MPU6050_ADDR << 1)+I2C_WRITE);
    if(I2C_ReadACK())
    {
        I2C_Stop();
        return;
    }

    //发送寄存器地址
    I2C_WriteByte(regAddr);
    if(I2C_ReadACK())
    {
        I2C_Stop();
        return;
    }

    //发送写入数据到寄存器
    I2C_WriteByte(regData);
    if(I2C_ReadACK())
    {
        I2C_Stop();
        return;
    }
}

就是标准的 I2C 写流程:起始信号-->发送地址+写方向-->发送寄存器地址-->写入数据

uint8_t MPU6050_ReadReg(uint8_t regAddr)
{
    uint8_t regData;

    //发送起始信号
    I2C_Start();

    //发送从机地址,并表示写入方向
    I2C_WriteByte((MPU6050_ADDR << 1)+I2C_WRITE);
    if(I2C_ReadACK())
    {
        I2C_Stop();
        return 0;
    }

    //发送寄存器地址
    I2C_WriteByte(regAddr);
    if(I2C_ReadACK())
    {
        I2C_Stop();
        return 0;
    }

    //重新发送起始信号
    I2C_Start();

    //发送从机地址,并表示读方向
    I2C_WriteByte((MPU6050_ADDR << 1)+I2C_READ);
    if(I2C_ReadACK())
    {
        I2C_Stop();
        return 0;
    }

    regData = I2C_ReadByte(); //读取寄存器数据
    I2C_WriteACK(1);          //发送非应答
    I2C_Stop();               //发送停止信号

    return regData;   
}

与写寄存器不同点在于,写入寄存器之后,需要重新发出起始信号并转换为读方向。

读取测量数据

int16_t MPU6050_GetData(uint8_t regAddr)
{
    uint16_t data_L,data_H;
    int16_t data;

    data_H = MPU6050_ReadReg(regAddr);
    data_L = MPU6050_ReadReg(regAddr + 1);
    data = (data_H << 8) | data_L;//合成数据

    return data;
}

MPU6050 的 ADC 是 16 位的,因此每个轴测量得到的数据会由两个 8 位寄存器保存,因此读取数据时需要合成数据。

MPU6050 输出的数据是补码格式,不能用 uint 类型存储,必须用 int 类型存储才可以读出符号。

这里传入的寄存器地址为指定轴的高位输出寄存器。

数据转换

void MPU6050_Update(void)
{
    /* 更新 x, y, z 轴加速度 */
    ACCEL_X = (float)(MPU6050_GetData(ACCEL_XOUT_H) - ACCEL_X_Calibrate) * ACCEL_RANGE / COUNT_UPPER_LIMIT;
    ACCEL_Y = (float)(MPU6050_GetData(ACCEL_YOUT_H) - ACCEL_Y_Calibrate) * ACCEL_RANGE / COUNT_UPPER_LIMIT;
    ACCEL_Z = (float)(MPU6050_GetData(ACCEL_ZOUT_H) - ACCEL_Z_Calibrate) * ACCEL_RANGE / COUNT_UPPER_LIMIT;

    /* 更新 x, y, z 轴角速度 */
    GYRO_X = (float)(MPU6050_GetData(GYRO_XOUT_H) - GYRO_X_Calibrate) * GYRO_RANGE / COUNT_UPPER_LIMIT;
    GYRO_Y = (float)(MPU6050_GetData(GYRO_YOUT_H) - GYRO_Y_Calibrate) * GYRO_RANGE / COUNT_UPPER_LIMIT;
    GYRO_Z = (float)(MPU6050_GetData(GYRO_ZOUT_H) - GYRO_Z_Calibrate) * GYRO_RANGE / COUNT_UPPER_LIMIT;
}

MPU6050 直接输出的数据是 ADC 的计数值,需要经过转换才能得到加速度值/角速度值。

转换之前需要知道 ADC 计数一次等于多少对应的物理量,即用量程除以计数上限,然后再用这个值乘以计数值就能完成数据转换。

以加速度为例。这里我设置加速度计量程为±2g,而 MPU6050 的 ADC 为 16 位,计数范围为 -32768~+32767,因此转换公式如下:

$$
加速度 = ADC计数值\times 2/32767
$$

陀螺仪同理。

上电校准

void MPU6050_Calibrate(void)
{
    uint8_t i;
    for(i = 0;i < 30;i++)
    {
        ACCEL_X_Calibrate += MPU6050_GetData(ACCEL_XOUT_H);
        ACCEL_Y_Calibrate += MPU6050_GetData(ACCEL_YOUT_H);
        ACCEL_Z_Calibrate += MPU6050_GetData(ACCEL_ZOUT_H);
        GYRO_Y_Calibrate += MPU6050_GetData(GYRO_YOUT_H);
        GYRO_Z_Calibrate += MPU6050_GetData(GYRO_ZOUT_H);
        GYRO_X_Calibrate += MPU6050_GetData(GYRO_XOUT_H);
    }
    ACCEL_X_Calibrate /= 30;
    ACCEL_Y_Calibrate /= 30;
    ACCEL_Z_Calibrate /= 30;
    GYRO_X_Calibrate /= 30;
    GYRO_Y_Calibrate /= 30;
    GYRO_Z_Calibrate /= 30;
}

MPU6050 在未校准的情况下会存在一定的误差,且不能忽略,因此最简单的办法就是在上电时读取数据、取平均,之后每次读取数据时都减掉这个平均值。

  • 该函数应当在上电一段时间、等待 MPU6050 读数稳定后调用一次
  • 读取次数可自行修改并测试
  • 读取角速度的代码的顺序不能是 XYZ,否则 X 轴测量得到的角速度值在校准之后仍然会有较大偏差,原因未知

需要指出的是,这种方法只能解决上电时的零漂(零点漂移),而零漂在工作一段时间后会发生接近线性的变化。除此之外还有温漂等会对测量结果造成误差的因素。

最后修改:2023 年 12 月 15 日
如果觉得我的文章对你有用,请随意赞赏