简介:STM32F103C8T6与MPU6050组合的完整工程,面向物联网、机器人及智能穿戴开发者,提供开箱即用的姿态解算方案。工程基于标准外设库,包含I2C驱动、原始数据读取、姿态解算及串口输出等核心模块,覆盖从底层寄存器配置到上层算法应用的完整链路,适合初学者快速上手,也方便工程师直接移植到飞行器、平衡车等项目。压缩包共188个文件,以C源文件、头文件、编译生成的hex/axf等为主,另含工程配置文件与清理脚本,整体6.16MB,无需额外配置即可编译下载。目前已有2617人学习使用,附带的示例代码和文档可帮助理解MPU6050校准与滤波思路,减少调试弯路,有效加速开发进程。
1. 为什么还要碰MPU6050:一个拿来即用的六轴姿态工程
写嵌入式的人迟早会碰到同一个需求:把物体的倾斜角度、旋转速度变成一串能实时读到的数据,也就是常说的姿态解算。STM32F103C8T6 加 MPU6050 是最常见的低成本六轴组合,一颗最小系统板加一颗传感器,就能产出滚转角、俯仰角、偏航角。这个工程把从 I2C 驱动、DMP 固件加载到串口输出欧拉角的完整链路全部整理好了,解压后直接用 MDK5 打开、编译、烧录就能出数据。适合正在做自平衡小车、云台、体感遥控器的开发者,也适合不想反复调底层时序、想直接拿到一套干净姿态数据的老手。
拿到工程先把 MPU6050 接到板子的 PB6/PB7,连上串口就能在串口助手里看到三轴角度和原始六轴数据。这套工程的代码分层清晰,替换传感器或者对接上层控制逻辑都不用动主循环。下面按实际使用顺序,把工程的每一部分拆开讲。
2. 工程架构与目录结构:从文件布局看懂数据走向
2.1 三层结构:User、Hardware、Driver
这个工程没有把代码一锅烩进 main.c,而是按“应用层 / 板级外设 / 算法驱动”分了三个区域。打开工程目录先看到的是 User、Hardware、Driver 三个文件夹,加上 MDK5 自动生成的 Core 与 FWLIB。
STM32F103_MPU6050/ ├── User/ │ ├── main.c │ └── usart.c ├── Hardware/ │ ├── mpu6050.c │ ├── mpu6050.h │ ├── sys.c │ └── delay.c ├── Driver/ │ ├── inv_mpu.c │ ├── inv_mpu_dmp_motion_driver.c │ ├── inv_mpu.h │ └── dmpKey.h / dmpmap.h ├── Core/ │ ├── core_cm3.c │ └── startup_stm32f10x_md.s └── FWLIB/ ├── src/ └── inc/User 目录只放主流程和串口逻辑,main.c 里不做任何寄存器级操作,这是嵌入式工程该有的基本洁癖。Hardware 目录是板级适配层,mpu6050.c 负责把 MPU6050 的寄存器操作封装成简洁函数,delay.c 提供毫秒和微秒延时,sys.c 里是时钟配置。Driver 目录是姿态解算的核心,inv_mpu.c 与 inv_mpu_dmp_motion_driver.c 这两个文件包含 DMP 固件加载、FIFO 管理和四元数输出的全部实现,正常情况下不需要改动。
习惯上大家会把 inv_mpu.c 和 inv_mpu_dmp_motion_driver.c 当成黑匣子,其实这两个文件也是源码,完全可以单步跟进去看。DMP 固件本身是一段存放在头文件数组里的二进制代码,编译时被链接进程序,运行时再由 inv_mpu.c 里的函数通过 I2C 写到 MPU6050 内部 RAM。弄清楚这一点,就能理解为什么工程一换主控芯片,第一件事往往是重新确认 I2C 地址、时钟延时这些参数。
2.2 关键文件职责:谁在干脏活
先看一张文件职责表,心里有个底,后面读代码时就知道该去哪儿找对应逻辑。
| 文件 | 职责 |
|---|---|
| main.c | 初始化时钟、串口、I2C、MPU6050,主循环里周期性读取姿态并打包发送 |
| usart.c | 串口 1 初始化,提供数据发送函数,默认 115200-8-N-1 |
| mpu6050.c | 传感器上电复位、量程配置、读取加速度/陀螺仪原始值 |
| inv_mpu.c | 底层 I2C 读写、芯片 ID 校验、功耗管理、DMP 固件加载入口 |
| inv_mpu_dmp_motion_driver.c | DMP 功能开关、FIFO 配置、四元数读取 |
| delay.c | 基于 SysTick 的延时实现,为软件 I2C 提供节拍 |
main.c 是最薄的一层,主循环里只做三件事:调用姿态读取函数、转换角度、打包串口帧。这也是合理的架构——换一种传感器或者换一块主控板时,大体只需要替换 Hardware 层,姿态算法和主流程可以原样保留。
2.3 数据从传感器到串口经历了什么
这个工程的数据走向是一条直线,没有任何环路:MPU6050 内部六轴原始数据进入 DMP 硬件解算,结果缓存在 FIFO 里,主控用 I2C 把四元数读出来,转成欧拉角后经串口发出。
DMP 方案的好处是把占 CPU 的四元数解算放到传感器内部完成,主控这边只需要在 FIFO 有数据时读包。姿态计算量几乎可以忽略,CPU 能腾出来做控制或者显示刷新。这也是这个工程选择 DMP 而非在 MCU 里跑互补滤波的核心原因。
while (1) { if (dmp_read_fifo(gyro, accel, quat, &sensor_read, &more) == 0) { Quat_To_Euler(q0, q1, q2, q3, &roll, &pitch, &yaw); Send_Angle(roll, pitch, yaw); } delay_ms(5); }主循环里的逻辑很直观:每次读取一组 FIFO 数据,转成角度后立刻发送,然后等 5 毫秒进入下一轮。dmp_read_fifo 返回 0 表示读取成功,非 0 说明 FIFO 读取失败或者数据异常,这时候跳过本次发包更稳妥。delay_ms(5) 配合 DMP 设定的 20Hz 输出频率,保证一秒钟内大约能发送 20 帧有效姿态数据。
3. 硬件连接与初始化:把 I2C 和供电一次接对
3.1 最小硬件连接:别把 AD0 接错
STM32F103C8T6 和 MPU6050 之间只有四根线必须接对,接线的错误率却比想象中高,大多数问题出在 AD0 和上拉电阻上。
| 引脚 | 连接目标 | 说明 |
|---|---|---|
| VCC | 3.3V | 部分带稳压模块的板子可接 5V,推荐直接 3.3V |
| GND | GND | 与 MCU 共地 |
| SCL | PB6 | 软件 I2C 时钟线 |
| SDA | PB7 | 软件 I2C 数据线 |
| AD0 | GND | 接地时 I2C 地址为 0x68 |
AD0 是地址选择脚。接到 GND 时 MPU6050 的 7 位从机地址是 0x68,接到 3.3V 时是 0x69。工程代码里预置的是 0x68,如果你把 AD0 接到了高电平,或者买的模块上 AD0 默认被拉高,就会遇到 I2C 通信完全不响应的状况。先确认 AD0 电平比改代码快得多。
SCL 和 SDA 在部分模块上已经焊了上拉电阻,没有的话需要外接两个 4.7k 左右的上拉电阻到 3.3V。I2C 总线是开漏输出,没有上拉电阻时信号根本拉不高,表现就是数据全零或者偶尔通一次。这种硬件问题最折腾人,因为它不一定每次上电都复现,容易让人误以为是软件 bug。
3.2 软件模拟 I2C:为什么不用硬件外设
STM32F103 有硬件 I2C 外设,但 F1 系列的 I2C 在不少场景下会出现异常状态位无法清除的怪问题,调试起来非常费时间。这个工程的做法是直接用 GPIO 模拟 I2C 时序,两个引脚随意指定,速度也足够。
void I2C_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; // PB6=SCL, PB7=SDA GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; // 开漏输出 GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_SetBits(GPIOB, GPIO_Pin_6 | GPIO_Pin_7); // 空闲状态拉高 } void I2C_Start(void) { SDA_HIGH(); SCL_HIGH(); delay_us(5); SDA_LOW(); // SCL 高电平时 SDA 拉低表示起始 delay_us(5); SCL_LOW(); } static uint8_t I2C_WriteByte(uint8_t dat) { uint8_t i, ack; for (i = 0; i < 8; i++) { if (dat & 0x80) SDA_HIGH(); else SDA_LOW(); dat <<= 1; delay_us(2); SCL_HIGH(); delay_us(2); SCL_LOW(); delay_us(2); } SDA_HIGH(); // 释放 SDA,准备接收应答 SCL_HIGH(); delay_us(2); ack = SDA_READ(); // 读取从机应答 SCL_LOW(); delay_us(2); return ack; }代码里 SDA_HIGH / SDA_LOW 是对 GPIO 的位操作宏,核心顺序是:先改变 SDA 电平,再拉高 SCL,从机在 SCL 上升沿采样数据。开漏输出模式下把 SDA 释放拉高,再由上拉电阻保证高电平,这样才能正确读取从机的 ACK 应答位。
软件 I2C 的速率由每个电平之间的 delay_us 决定。工程中 delay_us 基于 SysTick 实现,在 72MHz 主频下,延时 2 微秒对应的速率大约在 100kHz 到 200kHz 之间,完全满足 MPU6050 的时序要求。这里有一个容易踩的坑:如果板子外部晶振不是 8MHz,SysTick 延时时间会按倍数偏差,I2C 时序就会乱掉,表现是时通时不通。
3.3 初始化顺序:先复位再配置
MPU6050 上电后默认处于睡眠模式,必须先把 DEVICE_RESET 位置 1,等它复位完成后清除睡眠位,再配置量程和滤波器。
void MPU6050_Init(void) { I2C_GPIO_Config(); delay_ms(50); // 等待传感器上电稳定 MPU_WriteReg(0x6B, 0x80); // PWR_MGMT_1: DEVICE_RESET delay_ms(100); // 复位需要至少 100ms MPU_WriteReg(0x6B, 0x00); // 清除 SLEEP 位,使用内部时钟 MPU_WriteReg(0x19, 0x07); // SMPLRT_DIV = 7 MPU_WriteReg(0x1A, 0x06); // CONFIG: DLPF 开启,带宽约 5Hz MPU_WriteReg(0x1B, 0x18); // GYRO_CONFIG: ±2000°/s MPU_WriteReg(0x1C, 0x08); // ACCEL_CONFIG: ±4g MPU_WriteReg(0x6B, 0x01); // 使用 PLL 时钟源 }注意最后一行把 0x01 写入 0x6B 寄存器,是让内部 PLL 以陀螺仪 X 轴为时钟参考,这是启动 DMP 前推荐的时钟设置,能减少数据漂移。SMPLRT_DIV 把 8kHz 内部采样分频成 1kHz,低通滤波带宽设到 5Hz 左右,用来滤掉高频振动对姿态的影响。
这段初始化完成后可以用 WHO_AM_I 寄存器做一次自检。0x75 寄存器默认值应为 0x68,读不到这个值就说明 I2C 链路有问题,没必要继续往下调试 DMP 固件。我一般会在初始化代码里加一句打印,把读到的 ID 发到串口,确认无误后再往下走。
4. 核心代码拆解:DMP 姿态解算的完整链路
4.1 加载 DMP 固件与使能特性
inv_mpu.c 里的 mpu_init 会完成传感器基础初始化,真正把 DMP 用起来的是 dmp_load_motion_driver_firmware 和 dmp_enable_feature 这两个函数。
uint8_t MPU_DMP_Init(void) { uint8_t ret = 0; ret = mpu_init(); // 完成 MPU6050 基础初始化 if (ret) return 1; ret = dmp_load_motion_driver_firmware(); // 把 DMP 固件写入传感器 RAM if (ret) return 2; dmp_set_orientation(inv_orientation_matrix_to_scalar(0, 0, 0, 0, 0, 0)); dmp_enable_feature(DMP_FEATURE_6X_LP_QUAT | DMP_FEATURE_GYRO_CAL); dmp_set_fifo_rate(20); // 姿态输出频率 20Hz mpu_set_dmp_state(1); // 开启 DMP return 0; }mpu_init 返回 0 表示成功,1 表示 I2C 通信失败,所以调用后必须检查返回值。dmp_load_motion_driver_firmware 会通过 I2C 把约 6KB 的 DMP 固件逐块写入传感器内部 RAM,这个过程在 100kHz I2C 下需要几百毫秒,期间不能让主控进入低功耗模式,也不要被中断打断太久。
dmp_enable_feature 里的 DMP_FEATURE_6X_LP_QUAT 表示使用六轴低通四元数输出,DMP_FEATURE_GYRO_CAL 让 DMP 自动做陀螺仪零偏校准。dmp_set_fifo_rate(20) 把姿态解算输出频率设成 20Hz,如果后面的控制环需要更高频,可以改到 50 或者 100,但要注意 FIFO 的读取速度必须比数据产生速度快,否则会出现 FIFO 溢出。
4.2 读取 FIFO 中的四元数
DMP 计算出来的四元数不会主动推给主控,而是缓存在 MPU6050 的 FIFO 里。每次主循环需要姿态数据时,调用 dmp_read_fifo 取一组最新数据。
short gyro[3], accel[3]; unsigned char sensor_read = 0, more = 0; long quat[4]; if (dmp_read_fifo(gyro, accel, quat, &sensor_read, &more) == 0) { if (sensor_read & INV_WXYZ_QUAT) { float q0 = quat[0] / 1073741824.0f; // Q30 格式,除以 2^30 float q1 = quat[1] / 1073741824.0f; float q2 = quat[2] / 1073741824.0f; float q3 = quat[3] / 1073741824.0f; } }dmp_read_fifo 的五个参数分别是陀螺仪原始值、加速度原始值、四元数输出、数据来源掩码和剩余数据标志。sensor_read 里按位标记本次 FIFO 包里含哪些数据,必须检查 INV_WXYZ_QUAT 位再使用四元数,否则数组里可能还是上一帧的旧值。more 标志表示 FIFO 里是否还有更多未读数据,如果经常为 1,说明读取频率低于数据产生频率,要考虑加大读取节奏。
DMP 输出的四元数不是 float 而是 Q30 定点数,官方把每个分量按 2^30 缩放后存在 32 位整数里。转换成 float 时除以 1073741824.0f 即可。很多移植工程的角度异常,就是漏了这一步,直接把 int 当 float 算,结果角度完全不可用。
4.3 四元数转欧拉角
拿到归一化后的四元数,转成欧拉角用的是标准 ZYX 顺序公式。工程里写成一个独立的 Quat_To_Euler 函数。
#define RAD2DEG 57.2957795f void Quat_To_Euler(float q0, float q1, float q2, float q3, float *roll, float *pitch, float *yaw) { *roll = atan2f(2.0f * (q0 * q1 + q2 * q3), 1.0f - 2.0f * (q1 * q1 + q2 * q2)) * RAD2DEG; *pitch = asinf(2.0f * (q0 * q2 - q3 * q1)) * RAD2DEG; *yaw = atan2f(2.0f * (q0 * q3 + q1 * q2), 1.0f - 2.0f * (q2 * q2 + q3 * q3)) * RAD2DEG; }roll 用 atan2 是因为它天然处理象限问题,输出范围 -180 到 +180 度。pitch 用 asin,范围只有 -90 到 +90 度,当俯仰角接近正负 90 度时会产生绝对值正确但方向模糊的现象,这是欧拉角本身的万向锁问题,对小车和云台这类场景影响不大。
如果想知道姿态解算原理的细节,最常见的对比是互补滤波。互补滤波工程上需要自己调 Kp 和 Ki 两个参数,调不好就出现角度跟随慢或者高频抖动。DMP 方案把参数整定藏在固件里,输出数据已经滤波和校准过,代价是可配置性较低、需要加载官方固件。工程选用 DMP,换来的是开发和调试时间成本大幅下降。
4.4 串口帧打包发送
数据最终要通过串口送到上位机或者别的控制器。工程使用 115200 波特率,每帧固定 9 字节。
void Send_Angle(float roll, float pitch, float yaw) { int16_t r = (int16_t)(roll * 100); int16_t p = (int16_t)(pitch * 100); int16_t y = (int16_t)(yaw * 100); uint8_t buf[9]; uint8_t sum = 0; buf[0] = 0xA5; // 帧头1 buf[1] = 0x5A; // 帧头2 buf[2] = 0x01; // 帧类型: 角度 buf[3] = (uint8_t)(r >> 8); buf[4] = (uint8_t)(r & 0xFF); buf[5] = (uint8_t)(p >> 8); buf[6] = (uint8_t)(p & 0xFF); buf[7] = (uint8_t)(y >> 8); buf[8] = (uint8_t)(y & 0xFF); for (int i = 0; i < 8; i++) sum += buf[i]; // 校验和 USART_SendArray(buf, 9); }角度值先乘 100 转成 int16,保留两位小数,再把高低字节拆开发送。上位机收到后按同样规则拼接并除以 100,就能还原成 float。帧尾的校验和帮助接收端判断一帧数据是否被干扰,出现校验失败时直接丢弃这帧即可。
要对接自己的控制器,只需要改这里的帧格式,把角度替换成你需要的协议字段,比如 PID 给定值或者欧拉角速度,发送逻辑本身不用动。
5. 编译烧录与常见问题排查:让工程在你手上跑通
5.1 在 MDK5 中打开工程并完成配置
下载解压后,找到工程文件目录下的 .uvprojx 后缀工程文件,用 MDK5 打开。首次打开建议先检查三处配置,避免编译出一堆莫名其妙的错误。
打开 Options for Target,在 Device 页确认芯片选择的是 STM32F103C8。在 C/C++ 页的 Define 输入框里确认宏定义包含 STM32F10X_MD 和 USE_STDPERIPH_DRIVER,这两个宏决定了标准外设库编译哪些源文件,漏掉 STM32F10X_MD 时寄存器地址会全部偏掉。同样的页面里,Include Paths 需要把 User、Hardware、Driver、FWLIB/inc 四个目录加进去。
然后检查 Target 页的 Code Generation,勾选 Use MicroLIB。这个选项给嵌入式环境提供精简 C 库,不勾选的话 printf 相关的浮点格式化可能链接报错。最后在 Linker 页保持 Use Memory Layout from Target Dialog 为勾选状态,让它自动使用 Target 页里设置的 Flash 和 RAM 地址。这四处配置对齐后,编译基本一次通过。
5.2 烧录下载的设置与常见失败
烧录最简单的方式是 ST-Link 的 SWD 四线接口,连接 SWDIO、SWCLK、GND、3.3V。打开 Options for Target 的 Debug 页,选择 ST-Link Debugger,点 Settings 确认能识别到目标芯片 ID。Utilities 页同样选择 ST-Link,并在 Flash Download 里勾选 Reset and Run,这样烧录完成后芯片会自动复位运行,不用手动按复位键。
如果点击下载时提示 No target connected,先查 SWDIO 和 SWCLK 是否接反,再查板子有没有正常供电。部分板子的复位脚被调试器占用时会有干扰,在 Debug 页 Settings 里把 Connect 模式改成 Connect under Reset 能解决大部分连接问题。还有一种情况是芯片内部 Flash 被写入了异常代码导致锁死,用 ST-Link 的整片擦除功能清一次 Flash 再试。
5.3 常见问题排查记录
这一节是实际跑工程最容易翻车的地方,我把常见的几个现象按“现象、原因、解决”列出来,遇到类似问题直接对号入座。
现象一:串口输出全 0 或者固定值不变。 原因:I2C 通信没有建立,通常是 AD0 电平不对导致地址错误,或者 SCL/SDA 接反、缺少上拉电阻。 解决:用逻辑分析仪看 SCL/SDA 波形,或者直接读 0x75 寄存器,返回值 0x68 说明链路是通的;先把 AD0 接地,确认模块上的上拉电阻存在。
现象二:角度输出缓慢漂移,静止时数值不稳。 原因:MPU6050 上电后没有充分预热,陀螺仪零偏没有校准;DMP 功能里没有启用 GYRO_CAL;低通滤波器带宽过高。 解决:上电后静止等待 2 秒再开始读取数据,确认 dmp_enable_feature 中包含 DMP_FEATURE_GYRO_CAL,把 CONFIG 寄存器带宽降到 10Hz 以下。
现象三:编译报错 undefined symbol __use_no_semihosting 之类。 原因:MDK 默认使用浮点 printf,但没有启用 MicroLIB。 解决:在 Target 页勾选 Use MicroLIB 后重新编译,这是 MDK 工程最常见的链接类报错。
现象四:mpu_init 或 dmp_load_motion_driver_firmware 返回值非 0。 原因:I2C 速率过快或者传感器供电不稳,固件写入过程中出现字节丢失。 解决:把 delay_us 的延时加大,让 I2C 时钟降到 100kHz 左右;在 VCC 与 GND 之间并联一个 10uF 电容稳定供电。
现象五:角度翻转,roll 超过 90 度后符号不对。 原因:欧拉角定义与安装方向不一致,或者传感器实际安装方向与代码里 dmp_set_orientation 设置的矩阵不匹配。 解决:先用 dmp_set_orientation 配合实际安装方向调整坐标系,如果只是简单测试,可以按板子上的标记方向旋转校准后再固定安装。
注意: 排查 I2C 问题时,优先查硬件而不是改软件。软件 I2C 的容错性已经比较好,真正导致通信失败的大多数是接线、电平、地址这三类物理问题。
6. 二次开发思路:把这个工程改造成自己的姿态子系统
6.1 加一块 OLED 显示实时角度
很多人拿到工程第一个想法就是不对接电脑,直接看数据。加 OLED 比想象中简单:OLED 模块通常是 I2C 接口,常见地址 0x3C。硬件接法上和传感器各用一组引脚即可,代码里新建 oled.c 放到 Hardware 目录,复用 delay 和位操作函数。
需要注意地址冲突:OLED 与 MPU6050 挂在同一 I2C 总线上完全没有问题,前者地址 0x3C,后者 0x68,互不干扰。只要在读取 MPU6050 之外,主循环里每 200ms 刷新一次屏幕就行。
6.2 把角度接到 PID 控制环
自平衡小车、双轴云台这类项目,姿态数据最终要进 PID 控制器。改法是把 Send_Angle 替换成调用一个 Control_Loop 函数,输入是 roll 和 pitch,输出是 PWM 占空比。
需要注意主循环的执行频率。DMP 输出 20Hz 给 PID 用偏低,对自平衡小车这类实时性要求高的控制对象,建议把 dmp_set_fifo_rate 提升到 100Hz,同时把主循环改成定时器中断驱动,让姿态读取的时间点和控制更新的时间点错开,避免 I2C 读取期间被中断干扰导致控制周期抖动。
6.3 换成 9 轴或者多传感器
MPU6050 是六轴,缺少磁场计,yaw 会随时间漂移。如果你想做成航向稳定的设备,可以在同一 I2C 总线上挂一颗磁力计,用磁力数据修正 yaw。原厂 DMP 驱动里本身就支持扩展传感器接口,inv_mpu.c 里预留了 I2C 辅助总线的开关,顺着 mpu_set_bypass 打开即可。
或者换一颗同为 I2C 接口的六轴传感器,硬件层只需要替换 mpu6050.c 里的寄存器配置函数,Driver 里的 DMP 固件大概率要换成新传感器对应的版本。这时候软件的耦合点就在 mpu6050.c 对 Driver 的调用边界上,只要保持函数名不变,上层代码可以不动。
这个工程跑通一次后,你手上等于多了一个可以直接拿来用的姿态子系统。从那以后我每次做带角度检测的设计,都强制先在本机把这套流程完整走一遍:先查 I2C 能不能读到 ID,再确认 DMP 固件加载返回正常,最后看串口帧头对不对,三个点验证完才敢往下写业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取