☰
STM32软件I2C读写MPU6050与DMP库移植实战指南
2026/10/3 11:12:02 网站建设 项目流程

干嵌入式这几年,I2C 一直是个让人又爱又恨的协议。爱它简单,两根线能挂一堆传感器;恨它调试起来玄学频出,尤其在 STM32F103 搭配 HAL 库的时候,硬件 I2C 的 BUSY 位卡死问题可以把人磨到怀疑人生。所以从某个项目开始,我就把 MPU6050 这类低速传感器的通信从硬件 I2C 换成了软件模拟,效果出乎意料地稳定。这篇文章就把我在 STM32F103 + HAL 库环境下,用软件 I2C 读写 MPU6050,以及把官方 DMP 运动驱动库移植进来的完整过程分享出来,包括实现原理、关键代码、移植步骤,还有那些时序上容易踩的坑和调试心得,希望能给正被这个问题困扰的朋友一些参考。

1. 为什么我最终放弃了硬件I2C:F103上的那几个经典问题

1.1 硬件I2C的"老毛病"

STM32F103 的硬件 I2C 外设在工程师圈子里一直名声不太好,这话不是我发明的,你去各大论坛搜一下"I2C BUSY"或者"硬件I2C卡死",能翻出一堆血泪帖。核心问题集中在两点:一是总线繁忙标志(BUSY)在某些异常时序下会被锁死,不复位清不掉;二是事件中断处理逻辑比较复杂,配合 HAL 库的状态机,一旦时序临界就容易进I2C_WaitOnFlagUntilTimeout超时。超时之后如果暴力复位外设,SDA 和 SCL 的电平状态又不一定在线程安全的位置,总线就彻底乱了。

在我做的几个运动控制项目里,主循环里要频繁读取传感器并处理 PID,中断又比较多,硬件 I2C 偶尔就卡住一次。虽然加上超时恢复机制能把系统拉回来,但恢复过程中传感器数据会出现一段时间的空窗,对于实时性要求高的场景来说非常致命。

1.2 为什么软件I2C在这里更合适

MPU6050 的通信速率要求并不高,官方数据手册上说 I2C 时钟最高 400kHz(快速模式),但即便到了这个速率,普通的 GPIO 翻转也完全能轻松满足。软件模拟 I2C 最大的优势有两个。

引脚任意映射。在画 PCB 或飞线调试时,SCL/SDA 不必绑定在硬件的 I2C 引脚上,哪个脚空余用哪个,板子布局自由度大了很多。行为完全可控。所有时序都是由你自己写的代码生成的,出了问题都能用逻辑分析仪一步步分析,不会被芯片内部外设的状态机"黑盒化"。

说白了,在裸机或简单实时系统上,软件 I2C 读 MPU6050 这种低速传感器,CPU 开销根本不值一提,换来的是极大的调试便利。所以我个人在这个场景下会直接放弃硬件 I2C,除非你要求极致的功耗和性能,否则软件 I2C 是完全够用的方案。

1.3 软件I2C的适用边界

当然软件 I2C 也不是万能。如果你要驱动高速 I2C 设备(比如某些高速 ADC),或者需要在中断里高频读取大量数据,GPIO 模拟的劣势就会显现。另外,如果 MCU 的 GPIO 没有真正的开漏模式,就需要外部上拉电阻配合推挽输出模拟,F103 的 GPIO 支持开漏输出,所以没这个问题。如果你是其他型号,要注意确认引脚是否支持开漏,否则需要外部电路来配合。

2. 先搭底层的软件I2C:GPIO模拟时序的每个关键点

2.1 硬件连接与引脚配置

我用的引脚是 PB6 做 SCL、PB7 做 SDA,板上外接 4.7kΩ 上拉电阻到 3.3V。连接关系如下:

  • VCC → 3.3V
  • GND → GND
  • SCL → PB6
  • SDA → PB7
  • AD0 → GND(此时 I2C 地址为 0x68)
  • INT → 可选,DMP 如果用中断方式可以接

代码里用 CubeMX 把 PB6、PB7 都配置成开漏输出GPIO_MODE_OUTPUT_OD,速度设为 HIGH。开漏输出模式的好处是引脚既能主动拉低,又能释放总线由外部上拉至高,完全兼容 I2C 总线协议。如果配置成推挽输出,在引脚输出高电平时会主动驱动到 3.3V,虽然多数情况也能工作,但一旦总线有其他设备也在驱动,容易产生电流冲突,不符合 I2C 规范。

2.2 软件I2C代码实现

软件 I2C 的实现核心是 5 个基础操作:起始信号、停止信号、发送字节、读取字节、应答位处理。下面是我基于 HAL 库封装的标准代码,72MHz 主频下经过实测稳定运行。

宏定义部分:

#define I2C_SCL_GPIO_PORT GPIOB #define I2C_SCL_PIN GPIO_PIN_6 #define I2C_SDA_GPIO_PORT GPIOB #define I2C_SDA_PIN GPIO_PIN_7 #define I2C_SCL_H() HAL_GPIO_WritePin(I2C_SCL_GPIO_PORT, I2C_SCL_PIN, GPIO_PIN_SET) #define I2C_SCL_L() HAL_GPIO_WritePin(I2C_SCL_GPIO_PORT, I2C_SCL_PIN, GPIO_PIN_RESET) #define I2C_SDA_H() HAL_GPIO_WritePin(I2C_SDA_GPIO_PORT, I2C_SDA_PIN, GPIO_PIN_SET) #define I2C_SDA_L() HAL_GPIO_WritePin(I2C_SDA_GPIO_PORT, I2C_SDA_PIN, GPIO_PIN_RESET) #define I2C_SDA_READ() HAL_GPIO_ReadPin(I2C_SDA_GPIO_PORT, I2C_SDA_PIN)

注意:如果是开漏输出,在需要读 SDA 电平之前,必须先将 SDA 引脚输出 1(释放总线),否则引脚输出寄存器为 0 时会把总线一直拉低。这个顺序写错会导致接收的数据永远是 0。

延时函数:

static void i2c_delay(void) { for (uint8_t i = 0; i < 10; i++); }

实际延时大概零点几微秒,配合函数调用开销,实测 I2C 时钟大约在 100~200kHz,完全满足 MPU6050 的要求。如果你发现时序太快,就把这里的循环次数加大,或者直接用Delay_us(2)。

起始和停止:

void i2c_start(void) { I2C_SDA_H(); I2C_SCL_H(); i2c_delay(); I2C_SDA_L(); // SCL 为高时 SDA 拉低 => 起始 i2c_delay(); I2C_SCL_L(); i2c_delay(); }

停止则是 SCL 为高时 SDA 由低变高。这两段顺序不能乱。有些初学者会把 SCL 先拉低再动 SDA,那就不是 I2C 时序了。

发送字节:

void i2c_send_byte(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { if (dat & 0x80) I2C_SDA_H(); else I2C_SDA_L(); dat <<= 1; I2C_SCL_H(); i2c_delay(); I2C_SCL_L(); i2c_delay(); } }

读取字节:

uint8_t i2c_recv_byte(void) { uint8_t dat = 0; I2C_SDA_H(); // 释放 SDA,让从机控制 for (uint8_t i = 0; i < 8; i++) { dat <<= 1; I2C_SCL_H(); i2c_delay(); if (I2C_SDA_READ()) dat |= 0x01; I2C_SCL_L(); i2c_delay(); } return dat; }

应答位处理:

void i2c_send_ack(uint8_t ack) { if (ack) I2C_SDA_H(); // NACK else I2C_SDA_L(); // ACK I2C_SCL_H(); i2c_delay(); I2C_SCL_L(); i2c_delay(); I2C_SDA_H(); // 释放 }

这里有个细节值得强调:STM32 的 GPIO 输出模式即使配置成开漏,读引脚也建议使用HAL_GPIO_ReadPin,不要直接读 IDR 寄存器而忽略输出设置,尤其在同一引脚又输出又输入的切换场景下。用库函数虽然多一层开销,但逻辑清晰不易错。

2.3 时序验证是第一步

写完后不要立刻接 MPU6050,先接个逻辑分析仪,或者用示波器量 SCL/SDA 波形。没有仪器的话,可以写一段测试代码,在 SCL 引脚上用 GPIO 翻转输出一段固定波形,肉眼对比 I2C 时序图。我习惯先用逻辑分析仪抓一次单字节发送,确认起始、停止、数据位都有,再往下走。这一步能省去之后至少两个小时的找茬时间。

3. MPU6050寄存器操作:基础读写的完整封装

3.1 几个必须配置的寄存器

MPU6050 内部有上百个寄存器,但最常用到的就那么几个:

  • 电源管理 1(0x6B):默认上电是睡眠状态,必须写 0 唤醒。
  • 采样率分频(0x19):配合 0x1A 设置采样频率,默认是 1kHz。
  • 配置(0x1A):设置数字低通滤波器和同步。
  • 陀螺仪配置(0x1B):选择量程,0x00 对应 ±250°/s,0x08 对应 ±500°/s,0x10 对应 ±1000°/s,0x18 对应 ±2000°/s。
  • 加速度计配置(0x1C):选择量程,0x00 对应 ±2g,0x08 对应 ±4g,0x10 对应 ±8g,0x18 对应 ±16g。

我把初始化函数贴出来:

void MPU6050_Init(void) { uint8_t reg; // 唤醒 reg = 0x00; MPU6050_WriteReg(0x6B, reg); // 陀螺仪量程 ±2000dps reg = 0x18; MPU6050_WriteReg(0x1B, reg); // 加速度计量程 ±16g reg = 0x18; MPU6050_WriteReg(0x1C, reg); // 采样率分频:8kHz / (1 + 7) = 1kHz reg = 0x07; MPU6050_WriteReg(0x19, reg); // 配置低通滤波 reg = 0x06; MPU6050_WriteReg(0x1A, reg); }

这里的 0x19 分频寄存器的值不是直接写入 7 就完事了,采样率计算方式在手册里写得很清楚,8kHz 除以(寄存器值+1)。我见过不少人在这一步把采样率设错,导致后面 DMP 输出的欧拉角更新率异常。

3.2 完整读数据函数

加速度和陀螺仪的数据都以 16 位补码形式存储在连续的寄存器中,高字节在前。读写函数:

uint8_t MPU6050_ReadReg(uint8_t reg) { uint8_t val; i2c_start(); i2c_send_byte(0x68 << 1); // 写地址 i2c_send_byte(reg); i2c_start(); // 重复起始 i2c_send_byte((0x68 << 1) | 0x01); // 读地址 val = i2c_recv_byte(); i2c_send_ack(1); // NACK i2c_stop(); return val; }

读取 accel/gyro 的多字节读取则是连续读,除最后一个字节外都要返回 ACK,最后字节返回 NACK。这个细节务必注意:如果最后一个字节应答了,从机还会继续传输,数据帧就乱了。

3.3 数值转换为物理量

16 位原始值要除以量程对应的灵敏度。例如量程 ±2000dps 时,陀螺仪灵敏度是 16.4 LSB/(°/s);量程 ±16g 时,加速度计灵敏度是 2048 LSB/g。换算函数:

float gyro_x = (int16_t)((uint16_t)buffer[0] << 8 | buffer[1]) / 16.4f;

注意要把 buffer 合并成 int16_t 后再除以浮点数,否则整数除法会把小数丢掉。我在这里踩过坑:直接用两个 uint8_t 拼接成 uint16_t,再除以 16.4,编译器做了整数除法,结果全是 0。

3.4 验证原始数据是否正常

初始化后把两个寄存器的值读回来打印,静止状态下加速度计 Z 轴应该接近 4096(±16g 量程下 1g 对应 2048),X/Y 轴接近 0,陀螺仪三轴接近 0。如果读到全 0,大概率是地址写错或芯片没唤醒;如果读到 0xFF,大概率是 I2C 时序不对,没有正常收到数据。这些是快速判断问题方向的经验。

4. DMP库移植:把姿态解算交给MPU6050内部

4.1 DMP是什么,为什么值得移植

MPU6050 内部除了一个三轴加速度计和三轴陀螺仪,还藏着一个数字运动处理器 DMP(Digital Motion Processor)。在 DMP 里可以运行 InvenSense 官方的姿态解算算法,自动把加速度计和陀螺仪的数据融合成四元数,直接通过 FIFO 读出来。这意味着你不需要在 MCU 上写卡尔曼滤波或互补滤波,能省下大量 CPU 算力,姿态输出频率也稳定。

之前我在一个两轮平衡车项目里自己写过互补滤波,调参调到怀疑人生。后来切到 DMP,直接把四元数读出来换算成角度,稳定性好了很多,而且省下的主循环时间去处理电机控制和蓝牙通信刚刚好。

4.2 库文件与来源

网上流传最广的 DMP 库是 motion_driver 里的mpu6050相关源码,常见文件有:

  • inv_mpu.c / inv_mpu.h
  • inv_mpu_dmp_motion_driver.c / inv_mpu_dmp_motion_driver.h
  • dmpKey.h / dmpmap.h
  • mpu6050.h / mpu6050.c

这些文件在 GitHub 上有很多整理好的版本,文件名基本一致。如果你用 STM32,搜"inv_mpu stm32 hal"能找到不少已经适配过的版本,但强烈建议你自己把 I2C 底层换掉,因为不同人改的兼容性不一定适配,遇到问题后还是得自己排查。

4.3 移植的唯一关键:重定向I2C接口

DMP 库底层依赖两个函数:i2c_write和i2c_read。这是库默认调用的名字,你需要在inv_mpu.c里把它们替换成自带的软件 I2C 函数:

int i2c_write(unsigned char slave_addr, unsigned char reg_addr, unsigned char length, unsigned char const *data) { i2c_start(); i2c_send_byte(slave_addr << 1); i2c_send_byte(reg_addr); for (int i = 0; i < length; i++) i2c_send_byte(data[i]); i2c_stop(); return 0; }
int i2c_read(unsigned char slave_addr, unsigned char reg_addr, unsigned char length, unsigned char *data) { i2c_start(); i2c_send_byte(slave_addr << 1); i2c_send_byte(reg_addr); i2c_start(); i2c_send_byte((slave_addr << 1) | 1); for (int i = 0; i < length - 1; i++) { data[i] = i2c_recv_byte(); i2c_send_ack(0); // ACK } data[length - 1] = i2c_recv_byte(); i2c_send_ack(1); // NACK i2c_stop(); return 0; }

这两个函数的参数类型是unsigned char,而 HAL 库里的uint8_t本质相同,不会有类型问题。唯一要注意的是库中有些调用会传入length=0的特殊情况,比如写入 0 字节,我们的i2c_start和i2c_stop必须支持空操作,函数开头可以对 length 做判断。

4.4 DMP初始化流程

移植完成后,初始化 DMP 的典型流程:

  1. 调用mpu_init(NULL)完成传感器基础初始化。
  2. 调用mpu_set_sensors(INV_XYZ_GYRO | INV_XYZ_ACCEL)开启三轴陀螺仪和三轴加速度计。
  3. 调用mpu_configure_fifo(INV_XYZ_GYRO | INV_XYZ_ACCEL)配置 FIFO。
  4. 调用mpu_set_sample_rate(100)设置 100Hz 输出。
  5. 最关键的一步:dmp_load_motion_driver_firmware(),把固件二进制加载到 MPU6050 内部。
  6. 调用dmp_set_fifo_rate(100)设置 DMP 输出频率。
  7. 调用mpu_set_dmp_state(1)使能 DMP。
  8. 清空 FIFO。

记得在mpu_init里,它默认也会去读 WHO_AM_I (0x75),所以如果 I2C 底层地址不对,初始化会失败,返回负数。我在这里遇到过mpu_init返回 -5 之类的情况,查半天查不出问题,最后用逻辑分析仪发现 slave_addr 传进来是 0x68 而不是 0x68<<1,因为 DMP 库内部用的地址是 7 位地址,而我在 I2C 发送函数里又左移了一次,导致地址错位。解决办法是检查底层 I2C 接口里有没有重复左移。

4.5 FIFO读取与格式解析

DMP 输出到 FIFO 的是一个固定格式的数据包,前 16 字节是四元数,顺序是q0, q1, q2, q3,每项 4 字节,采用 q30 格式(即实际值 = 存储值 / 2^30)。库自带的dmp_read_fifo会帮你解析好:

short gyro[3], accel[3]; unsigned long timestamp; unsigned char more; long quat[4]; dmp_read_fifo(gyro, accel, quat, &timestamp, &more, NULL);

读取后quat[0]~quat[3]就是四元数。但如果你用 I2C 的频率太低,FIFO 溢出会导致dmp_read_fifo返回 -1 或more标志位状态不正常,这种情况下可以读取 FIFO 计数寄存器(0x72),手动重置。

5. 数据处理:从四元数到欧拉角

5.1 四元数与欧拉角的换算

拿到四元数后,最常见的需求是转换成 roll、pitch、yaw。标准公式如下:

float q0 = quat[0] / 1073741824.0f; // 2^30 float q1 = quat[1] / 1073741824.0f; float q2 = quat[2] / 1073741824.0f; float q3 = quat[3] / 1073741824.0f; roll = atan2f(2.0f * (q0 * q1 + q2 * q3), 1.0f - 2.0f * (q1 * q1 + q2 * q2)); pitch = asinf(2.0f * (q0 * q2 - q3 * q1)); yaw = atan2f(2.0f * (q0 * q3 + q1 * q2), 1.0f - 2.0f * (q2 * q2 + q3 * q3));

然后把弧度转成角度:roll * 57.2958f。这里用atan2f和asinf要注意时钟精度问题,不要用双精度的atan2,否则编译后代码体积会变大,在 F103 上没必要。另外asinf的参数要保证在 -1 到 1 范围内,否则会返回 NaN,影响后续逻辑。

5.2 为什么DMP四元数比原始数据更好用

如果我直接把加速度计和陀螺仪的原始数据打印出来,你会发现静止时加速度 Z 轴有噪声,陀螺仪零偏漂移明显。DMP 内部做了传感器融合,输出的四元数实时性高且平滑得多。这就是为什么很多平衡车、云台项目直接用 MPU6050 的 DMP 而不是自己写姿态解算。

5.3 一个值得留意的数据跳变问题

DMP 在刚上电前几秒钟,四元数可能还没有收敛,欧拉角会有明显的跳变,尤其是 yaw 角。建议上电后先让模块静止半秒到一秒再开始记录,或者在软件里做上电延时。另外如果 DMP 输出的 FIFO 频率设置太高(比如 200Hz 以上),I2C 读取速度跟不上就容易丢包,出现角度跳变。我一般把dmp_set_fifo_rate设置成 50~100Hz,稳定性和实时性都能接受。

6. 我在这套方案里踩过的坑与最终验证结果

6.1 典型问题:SDA 总是读不到高电平

这个现象是:所有数据读出来都是 0xFF 或读操作超时。排查顺序如下。

  1. 先确认 MPU6050 供电和上拉电阻。没有上拉电阻,SDA 无法回到高电平,I2C 读不出来。
  2. 用万用表量 SCL/SDA 静态电平,正常都应是高。
  3. 如果 SDA 静态是低,多半是某个 GPIO 输出方向没配置对,或者配置成了推挽输出且输出为 0,把总线拉死了。
  4. 再看寄存器地址,确认 AD0 接法,地址是 0x68 还是 0x69。

6.2 GPIO初始化顺序有个坑

CubeMX 会把多个引脚配置成开漏输出。如果你在初始化代码里先配置了 PB7 为推挽输出并输出 0,然后重新配置成开漏输出,中间那段时间 PB7 可能把总线拉低。这通常是上电顺序问题,尤其是你用一个 GPIO 模拟 I2C,又在同一引脚挂了其他设备。我的做法是:在代码里先拉高再配置模式,或者把初始化顺序调整到所有 I2C 引脚配置完再设置数据。

6.3 上拉电阻选择

MPU6050 的 I2C 总线上拉电阻,常见用 4.7kΩ。如果总线长度很短,10kΩ 也能用;长度较长或设备较多,用 2.2kΩ 更稳定。我试过一次在飞线环境下用 10kΩ,读出来的数据偶发错误,换成 4.7kΩ 就正常了。这个属于典型的信号完整性问题,如果你发现随机丢失字节,先别急着怀疑代码,拿示波器看一下上升沿,往往就是上拉电阻选大了。

6.4 最终实测结果

在 72MHz 主频下,软件 I2C 的 SCL 实测频率大约在 150kHz 左右。DMP 初始化 100Hz 输出时,FIFO 读取偶发出现数据包间隔不平均,但我用逻辑分析仪观察,读取耗时约几十微秒,整体稳定。静止状态下读取欧拉角,roll/pitch 波动在 ±0.5° 以内,yaw 在持续上电 5 分钟内的漂移小于 1°,完全满足我的项目需求。

最后再分享一个小技巧:如果 DMP 库编译出来代码量很大,而且用了很多浮点运算,可以尝试把编译器优化级别调到 -O1 或 -O2,效果明显,代码体积能缩小不少。另外,如果要用在正式项目里,建议把软件 I2C 的i2c_delay改成基于定时器的微秒级延时,这样在系统主频变化或者被中断频繁打断时时序还是稳的。整个过程下来你会发现,软件 I2C 读 MPU6050 并不复杂,只要把时序写对、把底层接口接对,DMP 移植就是个半天的活。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询