做四轴、平衡小车,或者给机械臂加个姿态反馈,MPU6050基本都是入门首选。这块芯片把三轴陀螺仪和三轴加速度计封装在一起,内部还带了一个叫DMP的数字运动处理器,可以直接输出四元数,省去在MCU上跑卡尔曼滤波的麻烦。STM32F103又是玩嵌入式绕不过去的平台,HAL库则是ST官方现在主推的开发方式。把这三样凑在一起,就是标题里那件事:用HAL库的STM32F103,通过软件I2C读写MPU6050,再移植DMP库完成姿态解算。
写这篇文章的动机,是最近帮几个朋友看代码,发现大家在MPU6050上卡住的点高度一致:要么I2C读不到数据,要么DMP固件加载失败,要么编译报一大串链接错误。其实这些问题都有固定解法,只是网上资料比较分散。这篇我按从底层到上层的顺序,把GPIO模拟I2C、寄存器配置、DMP移植的完整路径走一遍,代码和排查思路都给到。整个过程不需要用到F1系列的硬件I2C外设,所以也就不存在硬件I2C那些著名的坑,很适合不想折腾、想尽快跑通姿态数据的场景。
1. 项目方案拆解:为什么是“软件I2C + HAL库 + DMP”这套组合
1.1 为什么不用F1的硬件I2C
这是个老话题。STM32F103的硬件I2C被吐槽了很多年,尤其是在标准外设库时代,I2C事件处理特别繁琐,加上一些已知的总线卡死问题,劝退过不少人。HAL库发布之后,底层用状态机封装了I2C流程,比标准库好用了不少,但很多工程师做传感器读取时仍然保留着用软件模拟I2C的习惯。
软件I2C在这个场景下有实实在在的优势:
- 引脚任意分配,不在乎I2C外设复用到的具体引脚,改版方便。
- 时序完全透明,出问题时用逻辑分析仪看波形,自己心里有数。
- 速率可控,遇到线长、干扰、器件不识别时,把时钟调慢一点往往就好了。
- 代码基本零依赖,换个平台只需要改两个GPIO操作宏。
对于MPU6050这种数据量不大、读取频率通常在100Hz到200Hz的传感器,软件I2C的带宽完全够用。只有当系统里挂了多个I2C从机、又追求低CPU占用时,硬件I2C加DMA才有明显优势。
1.2 HAL库还是标准库
现在新开项目我基本都推荐HAL库。一是CubeMX生成工程速度快,时钟树、引脚配置一目了然;二是用户代码区域做了隔离,生成代码时不用担心手写部分被覆盖;三是HAL库的命名和封装风格统一,看别人的工程也能少花点时间。软件I2C本质上就是操作GPIO,用HAL库的HAL_GPIO_WritePin和HAL_GPIO_ReadPin就够了,没有额外的复杂度加成。
唯一要稍微留意的,是HAL库的GPIO初始化结构体参数比标准库多一点,但对应用层的代码贡献就是几行配置,后面会给出完整的CubeMX操作流程。
1.3 DMP库解决了什么问题
DMP是MPU6050内部的一块可编程处理器,I2C主机把固件写进去之后,姿态解算就在传感器内部完成了。MCU只需要按设定的频率从FIFO寄存器里读取四元数,再做一次角度换算,就能得到roll、pitch、yaw。这样省去了MCU端跑互补滤波或卡尔曼滤波的算力开销,也避开了调滤波参数的麻烦,对入门和小型项目都非常友好。
不过DMP固件并不是开放源码的,实际使用中是调用InvenSense提供的驱动库,也就是几个.c和.h文件拼起来的方案。库文件的版本比较多,网上找来的代码细节往往有出入,只要搭配合适,都能正常工作。
2. MPU6050关键寄存器速查:读写前必须搞清的协议细节
2.1 器件地址与引脚连接
MPU6050的I2C从机地址由AD0引脚决定。模块上AD0接地时地址是0x68,AD0接高电平时地址是0x69。大多数成品模块默认接地,所以代码里通常看到0x68。在I2C总线上发送时,这个7位地址需要左移一位,再拼上读写位:写地址0xD0,读地址0xD1。
接线方面,模块的VCC接3.3V,GND接GND,SCL和SDA分别接到单片机的两个GPIO。如果用的是裸芯片而不是模块,SCL和SDA上各需要加一个4.7kΩ的上拉电阻到3.3V。成品模块一般板载了上拉电阻,直接接线即可。INT引脚建议也接到MCU的一个GPIO上,用于DMP的FIFO中断通知,后面会细说。
2.2 常用寄存器清单
| 寄存器名 | 地址 | 上电默认值 | 作用 |
|---|---|---|---|
| WHO_AM_I | 0x75 | 0x68 | 读取器件ID,用于确认I2C通信正常 |
| PWR_MGMT_1 | 0x6B | 0x40 | 电源管理,bit6为SLEEP位,需清零唤醒 |
| SMPLRT_DIV | 0x19 | 0x00 | 采样率分频,实际采样率 = 内部采样率 / (1+该值) |
| CONFIG | 0x1A | 0x00 | 数字低通滤波DLPF配置,影响带宽和延时 |
| GYRO_CONFIG | 0x1B | 0x00 | 陀螺仪量程选择 |
| ACCEL_CONFIG | 0x1C | 0x00 | 加速度计量程选择 |
| FIFO_EN | 0x23 | 0x00 | 选择哪些数据写入FIFO |
| INT_ENABLE | 0x38 | 0x00 | 中断使能 |
| INT_STATUS | 0x3A | 0x00 | 中断状态标志 |
| USER_CTRL | 0x6A | 0x00 | FIFO复位、I2C主模式、传感器复位 |
| FIFO_COUNT_H/L | 0x72/0x73 | 0x00 | FIFO中未读数据的字节数 |
| ACCEL_XOUT_H | 0x3B | - | 三轴加速度计数据,从0x3B到0x40共6字节 |
| GYRO_XOUT_H | 0x43 | - | 三轴陀螺仪数据,从0x43到0x48共6字节 |
初始化时最核心的就三个寄存器:先写电源管理寄存器唤醒设备,再配置CONFIG设置低通滤波,最后分别设置陀螺仪和加速度计的量程。采样率分频寄存器如果保持默认,采样率是1kHz,也能用,但实际项目里一般会配合DLPF设置成200Hz或100Hz。
2.3 I2C读写MPU6050的时序要点
MPU6050是标准I2C从机,读写时序没有特殊要求。写一个字节的过程是:起始位、发送从机地址加写位、等待ACK、发送寄存器地址、等待ACK、发送数据、等待ACK、停止位。读多个连续寄存器时,先发送寄存器起始地址,然后发一个重复起始位,再把从机地址切到读模式,之后每个字节回ACK,只有最后一个字节回NACK。
很多新手在软件模拟时漏掉“重复起始位”,导致读取失败。先写寄存器地址、再切读模式,这一步必须通过重复起始位来完成,不能在中间插入停止位,否则读操作会错乱。
3. 软件I2C驱动编写:GPIO模拟时序的完整实现
3.1 引脚选择与CubeMX初始化配置
我习惯用PB6做SCL、PB7做SDA。这两个引脚在F103上靠近,布线方便,而且不在JTAG占用的默认引脚里。在CubeMX里把PB6和PB7都设置为GPIO_Output模式,输出类型选择Open Drain开漏,启用内部上拉。
开漏输出配合上拉电阻,是标准I2C推荐的接法。这样做的原因是I2C总线上的设备通过拉低电平来通信,高电平由电阻提供,避免多个设备同时驱动总线造成短路。CubeMX里勾选Pull-up后,内部上拉电阻大约40kΩ左右,可以直接使用。如果你选择推挽输出,也能工作,但严谨场景下我不建议,因为推挽输出强拉高电平,如果总线上某个从机正在拉低电平,就会形成冲突。
GPIO初始化代码大致长这样:
GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6 | GPIO_PIN_7, GPIO_PIN_SET); GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);串口调试和LED灯如果占用了其他引脚,正常配置即可,不冲突。
3.2 核心时序代码:起始、停止、发送、接收
软件I2C的核心难点在于把时序写对。我用宏定义封装了引脚操作,这样以后换引脚只改宏就行。
#define I2C_SCL_PORT GPIOB #define I2C_SCL_PIN GPIO_PIN_6 #define I2C_SDA_PORT GPIOB #define I2C_SDA_PIN GPIO_PIN_7 #define I2C_SCL_H() HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET) #define I2C_SCL_L() HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET) #define I2C_SDA_H() HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET) #define I2C_SDA_L() HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET) #define I2C_SDA_READ() HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN) static void I2C_Delay(void) { uint8_t i = 20; while (i--); }延时函数决定了I2C时钟频率。F103跑72MHz时,一次空循环大概几个周期,i等于20时半周期大概5us左右,整体时钟在100kHz上下,正好符合标准I2C的速率要求。如果线比较长或者干扰大,把i调大,时钟放慢,稳定性会明显提升。
void I2C_Start(void) { I2C_SDA_H(); I2C_SCL_H(); I2C_Delay(); I2C_SDA_L(); I2C_Delay(); I2C_SCL_L(); } void I2C_Stop(void) { I2C_SCL_L(); I2C_SDA_L(); I2C_Delay(); I2C_SCL_H(); I2C_Delay(); I2C_SDA_H(); }起始位的定义是SCL高电平期间SDA从高变低,停止位是SCL高电平期间SDA从低变高。这两个时序必须严格满足,很多I2C通信失败就是起始位和停止位顺序写错了。
发送一个字节并读取从机ACK的代码:
uint8_t I2C_SendByte(uint8_t data) { uint8_t i; for (i = 0; i < 8; i++) { if (data & 0x80) I2C_SDA_H(); else I2C_SDA_L(); data <<= 1; I2C_Delay(); I2C_SCL_H(); I2C_Delay(); I2C_SCL_L(); } // 释放SDA,由从机拉低表示ACK I2C_SDA_H(); I2C_Delay(); I2C_SCL_H(); I2C_Delay(); uint8_t ack = I2C_SDA_READ(); I2C_SCL_L(); return (ack == 0) ? 0 : 1; }数据位在SCL高电平期间必须保持稳定,从机在SCL高电平时采样SDA。所以每次数据翻转要等SCL拉低之后再操作,SCL拉高之后不能再动SDA。
接收一个字节并决定是否回ACK:
uint8_t I2C_ReadByte(uint8_t ack) { uint8_t i, data = 0; I2C_SDA_H(); // 释放SDA总线 for (i = 0; i < 8; i++) { data <<= 1; I2C_SCL_H(); I2C_Delay(); if (I2C_SDA_READ()) data |= 0x01; I2C_SCL_L(); I2C_Delay(); } if (ack) I2C_SDA_L(); // 回ACK else I2C_SDA_H(); // 最后一字节回NACK I2C_SCL_H(); I2C_Delay(); I2C_SCL_L(); I2C_SDA_H(); return data; }接收字节时,要先把SDA切换成输入状态,或者至少确保当前输出是高电平,否则读到的永远是自己的输出值。上面代码用I2C_SDA_H()释放总线,配合开漏输出模式,等效于让出SDA控制权,此时读取引脚电平是有效的。
3.3 软件I2C的注意事项
实际操作中有几个点需要特别注意:
第一,发送地址后从机没有拉低ACK,最常见原因是器件地址不对。模块上AD0如果接高电平,地址就是0x69而不是0x68,所有读写地址都要跟着变。
第二,读多字节时,最后一个字节必须回NACK。如果最后一个字节也回ACK,从机会认为主机还想继续读,导致后面总线状态错乱。
第三,程序里尽量减少中断对时序的干扰。软件I2C对延时敏感,如果系统里开了多个中断,且中断处理时间较长,SCL高电平时间会被拉长。I2C协议本身允许时钟拉伸,所以一般问题不大,但极端情况下会降低通信速率。
4. MPU6050驱动实现:初始化到数据读取
4.1 初始化流程:唤醒、量程、滤波、采样率
MPU6050上电默认处于睡眠模式,PWR_MGMT_1寄存器bit6为1。初始化第一步就是清掉这个睡眠位。
[ PWR_MGMT_1 = 0x01 ]
写入0x01不仅唤醒设备,还顺便把时钟源切到了X轴陀螺仪PLL。默认时钟源是内部8M RC振荡器,精度一般且受温度影响,陀螺仪数据容易产生漂移。X轴陀螺仪PLL的时钟精度高得多,对姿态解算有实际帮助。
量程设置上,陀螺仪我一般选±2000°/s,写入GYRO_CONFIG寄存器0x18。加速度计量程选±2g,ACCEL_CONFIG写0x00。对于一般的平衡车、机械臂项目,±2000°/s足够,±2g加速度量程也能保证分辨率最高。如果你做的是剧烈运动的无人机,加速度计量程可能需要调大,灵敏度会相应下降。
CONFIG寄存器设置数字低通滤波。DLPF=3时,3dB带宽约44Hz,采样率约1kHz。对于姿态解算来说,44Hz带宽能把高频振动滤掉不少,同时延迟也不算大。如果运动频率较高,可以调低DLPF的档位让带宽更大,比如配置为0x02对应94Hz。
SMPLRT_DIV寄存器用来分频采样率,设置采样率为200Hz时,写入值4,因为内部1kHz采样率要除以(1+4)=5。
初始化函数完整代码:
uint8_t MPU6050_Init(void) { uint8_t val; // 读WHO_AM_I,验证I2C通路 MPU6050_Read_Byte(0x75, &val); if (val != 0x68) return 1; // 唤醒MPU6050,选择X轴陀螺仪PLL时钟 val = 0x01; MPU6050_Write_Byte(0x6B, val); // 配置数字低通滤波 44Hz val = 0x03; MPU6050_Write_Byte(0x1A, val); // 陀螺仪量程 ±2000°/s,灵敏度16.4 LSB/(°/s) val = 0x18; MPU6050_Write_Byte(0x1B, val); // 加速度计量程 ±2g,灵敏度16384 LSB/g val = 0x00; MPU6050_Write_Byte(0x1C, val); // 采样率 1kHz / (1+4) = 200Hz val = 0x04; MPU6050_Write_Byte(0x19, val); return 0; }这里有个小设计经验:先读WHO_AM_I,如果通信不通,后面配置再多次也是白费。实测中WHO_AM_I能读到0x68,基本可以确认接线、地址和I2C时序都没问题,接下来调试心态会稳很多。
4.2 读取六轴原始数据
六轴数据从加速度计起始寄存器0x3B开始,连续读取14个字节,一次就能把三轴加速度、温度、三轴陀螺仪全部取完。数据都是16位有符号整数,高字节在前。
typedef struct { int16_t ax; int16_t ay; int16_t az; int16_t temp; int16_t gx; int16_t gy; int16_t gz; } MPU6050_DataTypeDef; uint8_t MPU6050_Read_Accel_Gyro(MPU6050_DataTypeDef *data) { uint8_t buf[14]; if (MPU6050_Read_Len(0x3B, buf, 14)) return 1; >#define MPU6050_INCLUDE_DMP这个宏加上之后,inv_mpu.c里的DMP相关代码才会被编译进去,否则dmp_开头的函数都是未定义的。有些版本还会用到MPU6050_DRIVER_USE_I2C之类的宏,按参考工程里的定义来即可。
头文件路径要添加进工程,否则编译会报找不到inv_mpu.h。Keil里在Options for Target -> C/C++ -> Include Paths里添加对应目录。
有两点编译经验先说在前面:
第一,inv_mpu.c在Keil默认AC5编译器下基本能直接过,AC6编译器下可能会报一些类型转换警告,不影响使用,但建议把警告级别适当放宽。
第二,DMP库内部有大量一次性初始化数组,占用RAM较多,F103的20KB RAM完全够用,但如果你在工程里开了多个大buffer,注意观察编译输出的RW和ZI数据量。
5.3 平台适配:四个关键回调函数
inv_mpu.c内部通过宏定义调用了平台相关的底层函数,这是移植的核心。需要自己实现i2c_write、i2c_read、delay_ms、get_ms和reg_int_cb五个接口。
先用宏定义把库函数映射到我们自己的驱动函数:
#define i2c_write MPU6050_Write_Len #define i2c_read MPU6050_Read_Len #define delay_ms delay_ms #define get_ms get_ms #define reg_int_cb reg_int_cb然后实现这些函数。注意库内部传入的从机地址是7位地址,也就是0x68,需要左移一位拼上读写位:
int MPU6050_Write_Len(uint8_t addr, uint8_t reg, uint8_t len, uint8_t *data) { I2C_Start(); if (I2C_SendByte((addr << 1) | 0)) // 写模式 { I2C_Stop(); return 1; } I2C_SendByte(reg); for (uint8_t i = 0; i < len; i++) I2C_SendByte(data[i]); I2C_Stop(); return 0; } int MPU6050_Read_Len(uint8_t addr, uint8_t reg, uint8_t len, uint8_t *data) { I2C_Start(); if (I2C_SendByte((addr << 1) | 0)) // 写模式,先发寄存器地址 { I2C_Stop(); return 1; } I2C_SendByte(reg); I2C_Start(); // 重复起始位 if (I2C_SendByte((addr << 1) | 1)) // 切到读模式 { I2C_Stop(); return 1; } for (uint8_t i = 0; i < len - 1; i++) data[i] = I2C_ReadByte(1); // 回ACK data[len - 1] = I2C_ReadByte(0); // 最后一字节回NACK I2C_Stop(); return 0; }延时函数在DMP固件加载时会被调用,用来等待传感器内部处理完成。用HAL库的HAL_Delay即可:
void delay_ms(unsigned long ms) { HAL_Delay(ms); } long get_ms(unsigned long *count) { *count = HAL_GetTick(); return 0; }reg_int_cb用于注册中断回调,如果使用轮询方式读取FIFO,这个回调可以先留空,不影响后续流程。
5.4 DMP初始化调用顺序
DMP初始化有严格的调用顺序,顺序乱了轻则输出异常,重则加载固件失败。我踩过几次坑之后,总结了一套稳定的调用序列:
struct int_param_s int_param; int ret; // 1. 初始化MPU6050基础配置 ret = mpu_init(&int_param); // 2. 开启传感数据 ret = mpu_set_sensors(INV_XYZ_GYRO | INV_XYZ_ACCEL); // 3. 配置FIFO ret = mpu_configure_fifo(INV_XYZ_GYRO | INV_XYZ_ACCEL); // 4. 设置采样率 ret = mpu_set_sample_rate(200); // 5. 加载DMP固件 ret = dmp_load_motion_driver_firmware(); // 6. 设置传感器方向 ret = dmp_set_orientation(inv_orientation_matrix_to_scalar(gyro_orientation)); // 7. 使能DMP功能 ret = dmp_enable_feature(DMP_FEATURE_6X_LP_QUAT | DMP_FEATURE_SEND_RAW_ACCEL); // 8. 设置DMP输出频率 ret = dmp_set_fifo_rate(100); // 9. 开启DMP ret = mpu_set_dmp_state(1);每一步的返回值都应该检查,我见过有人在dmp_load_motion_driver_firmware这一步返回非零,后面读四元数全是零。固件加载失败最常见的原因是I2C时序不够稳定,把软件I2C的延时调大一点基本能解决。
gyro_orientation数组在inv_mpu.c里有一份默认定义,描述的是传感器安装方向。如果MPU6050平放,且芯片丝印朝上,默认值一般不用改。如果你的模块立着安装或者倒着安装,需要按实际方向改这个数组,否则roll和pitch符号会反。
5.5 读取四元数与欧拉角换算
DMP使能后,FIFO里会按设定频率产生姿态数据。读取代码:
short gyro[3]; short accel[3]; long quat[4]; unsigned long timestamp; short sensors; unsigned char more; ret = dmp_read_fifo(gyro, accel, quat, ×tamp, &sensors, &more);dmp_read_fifo返回非零表示FIFO里暂时没有新数据,正常读取流程是每次都读,如果more标志为1,说明FIFO里还有滞留数据,需要继续读,直到more为0。否则数据会出现滞后,姿态角变化不流畅。
DMP输出的四元数每个分量都是Q30格式,也就是实际数值等于原始值除以(2^{30}),即1073741824。换算欧拉角的代码:
float q0 = (float)quat[0] / 1073741824.0f; float q1 = (float)quat[1] / 1073741824.0f; float q2 = (float)quat[2] / 1073741824.0f; float q3 = (float)quat[3] / 1073741824.0f; float roll = atan2f(2.0f * (q0 * q1 + q2 * q3), 1.0f - 2.0f * (q1 * q1 + q2 * q2)) * 57.2958f; float pitch = asinf(2.0f * (q0 * q2 - q1 * q3)) * 57.2958f; float yaw = atan2f(2.0f * (q0 * q3 + q1 * q2), 1.0f - 2.0f * (q2 * q2 + q3 * q3)) * 57.2958f;这里乘以57.2958是把弧度转成角度。有些版本的DMP库还提供了dmp_get_yaw_pitch_roll函数,可以直接返回欧拉角,但不同版本函数行为差异比较大,我建议还是自己用四元数换算,看得明白,也方便后面做姿态控制的调试。
5.6 中断与轮询的选择
DMP有两种读取方式。一种是把INT引脚接到MCU的EXTI,下降沿触发中断,在中断里置一个标志位,主循环检测到标志位后再读取FIFO。另一种是主循环里每隔一段时间主动调用dmp_read_fifo,不管有没有新数据,全靠函数返回值判断。
实测下来,轮询方式在F103上完全可行,100Hz的DMP输出频率下,主循环每5ms到10ms去读一次FIFO,数据不会丢。中断方式的好处是CPU不用反复查询,但要注意中断服务函数里尽量别做耗时操作,只置标志位。
如果使用中断方式,在初始化传int_param_s结构体时,需要正确配置中断回调。我的经验是,新手上来先用轮询方式跑通,确认DMP输出正常后,再改中断方式不迟。
6. 常见问题排查:从编译报错到数据异常
6.1 编译报错“undefined symbol mpu6050”
这个错误在Keil里非常常见,说的是链接器找不到名为mpu6050的符号。一般原因是参考示例代码里定义了一个全局变量MPU6050_DataTypeDef mpu6050;,但你的工程里没有定义,或者把定义写在了某个函数内部,作用域不对。
解决步骤:
- 在某个.c文件的全局位置定义:
MPU6050_DataTypeDef mpu6050; - 在需要使用该变量的文件里加extern声明:
extern MPU6050_DataTypeDef mpu6050; - 检查所有.c文件是否都添加到了Keil的Source Group里,漏加文件也会导致符号缺失。
6.2 WHO_AM_I读不到0x68
这是接线问题最常见的表现。按优先级排查:
| 检查项 | 操作 |
|---|---|
| 供电 | VCC必须3.3V,不能接5V |
| 地址 | AD0接了高电平就改成0x69 |
| 接线 | SCL、SDA是否接反,GND是否共地 |
| 外部上拉 | 裸芯片需要加4.7k上拉电阻,模块一般不用 |
| 波形 | 用逻辑分析仪看SCL和SDA是否有数据 |
有次帮人排查,发现SDA和SCL两根线在杜邦线中间断了,信号时有时无,读WHO_AM_I时好时坏。这种物理层面的问题,用万用表量一下通断就能查出来。
6.3 数据全是0或固定不变
如果WHO_AM_I能读到0x68,但加速度计和陀螺仪数据全是0,大概率是设备还处于睡眠状态。检查PWR_MGMT_1寄存器是否成功写入0x01。可以读回来核对一下:
uint8_t val; MPU6050_Read_Byte(0x6B, &val);正常情况val应该等于0x01。如果读到0x40,说明写入失败,回看I2C时序或地址配置。如果写入成功但数据还是0,检查GYRO_CONFIG和ACCEL_CONFIG的写入值,有人会在寄存器地址上写错一位。
6.4 DMP固件加载失败的排查
dmp_load_motion_driver_firmware返回值非零,大概率是I2C传输问题。固件大小有几千字节,加载过程中任何一位数据出错,传感器内部的校验都可能失败。常见原因:
第一,软件I2C延时太短,在连续传输几千字节时出错率升高。把I2C_Delay里的循环次数翻倍,时钟降到50kHz左右,再试一次。
第二,加载过程中有其他中断频繁抢占,拖慢了I2C时序。可以临时关闭一些无关中断,确认是否改善。
第三,库文件版本与MPU6050固件版本不匹配。换一个来源的DMP库文件,比如从其他开源平衡车项目里找一套完整的,通常能解决。
6.5 DMP输出的姿态角漂移大
首先明确一点,yaw角在室内、没有磁力计参考的情况下,飘是正常的。静置几分钟后yaw慢慢变化,属于陀螺仪零偏累积的结果。如果yaw漂移速度非常快,比如每秒几度,那先检查采样率配置,DMP输出频率和实际读取频率是否匹配。
roll和pitch在静止时误差超过1度,考虑先校准陀螺仪零偏。初始化后静置几十秒,取陀螺仪数值的平均值,后续读取时减去这个偏移量。有些版本的DMP库内部会自动统计零偏,长时间开启后姿态数据会越来越稳。
6.6 数据跳变与毛刺
如果数据偶尔出现一个很大的跳变,优先怀疑供电。MPU6050对电源纹波比较敏感,VCC上可以并联一个10uF钽电容加0.1uF陶瓷电容,数采会稳定很多。其次是接线太长,杜邦线超过20cm后,I2C信号质量会明显下降,把时钟放慢能缓解。
另一个容易被忽略的点是数字低通滤波配置。CONFIG寄存器设置DLPF后,FIFO数据是经过滤波的。如果为了追求响应速度把DLPF关掉,数据噪声会变大,姿态角也会跟着抖。平衡车这类项目,DLPF设在带宽44Hz或者94Hz都是常用选择。
6.7 程序运行一段时间后数据不更新
FIFO溢出是常见原因。DMP输出频率设为100Hz,如果主循环里读取不及时,FIFO会积满数据,新数据进不去,数据看起来就像卡死了。解决方式是检查FIFO计数,或者调低DMP输出频率。
uint16_t fifo_count; MPU6050_Read_Byte(0x72, &val); fifo_count = (uint16_t)val << 8; MPU6050_Read_Byte(0x73, &val); fifo_count |= val;如果fifo_count长期等于最大值1024,说明读取不及时。要么提高读取频率,要么降低DMP输出频率,要么在确认数据不需要时调用mpu_reset_fifo清空。
写在最后的使用体会
这套方案我在F103C8T6和F103RCT6上都跑过,后来移植到GD32F103和AT32F403A也没有改太多东西,GPIO操作宏和延时函数换一下,DMP库文件几乎不用动。MPU6050本身是个很皮实的传感器,大多数问题出在I2C通信和库文件适配这两个环节,把这两块跑通,后面加数据滤波、姿态控制都是水到渠成的事。
如果后续项目要接多个I2C传感器,比如OLED显示屏、气压计、磁力计都在同一条总线上,建议把软件I2C再封装一层,提供一个统一的读写接口,这样应用层调用时不需要关心具体用的是软件I2C还是硬件I2C,代码结构会清爽很多。做完这一版之后,我最大的体会是:DMP库并没有想象中难移植,真正花时间的是理解I2C时序的细节,把底层基础打牢,上层什么都好说。