简介:基于STM32的四旋翼无人机姿态解算与匿名上位机通信资源,面向嵌入式开发者和无人机爱好者,完整呈现MPU6050六轴传感器数据采集、互补滤波/卡尔曼滤波姿态解算、UART串口通信及PWM电机控制等核心环节。代码涵盖传感器初始化、原始数据读取与预处理、姿态解算、匿名上位机协议解析、电机转速输出等模块,可帮助理解稳定的姿态估计算法与实时控制链条。资源包含1033个文件,以C源程序、头文件、启动文件为主,另含STM32CubeMX配置、链接脚本、编译工程及说明文档,压缩包共28.44MB。已有12975人浏览学习。通过这份代码可快速掌握STM32 HAL库的串口、定时器、中断配置,理解陀螺仪与加速度计的数据融合方法,并参照匿名上位机实现实时状态监控与调试,为完整飞控系统开发提供可靠参考。
1. MPU6050姿态解算:四旋翼稳定飞行的第一块拼图
调四旋翼的人都懂一个死循环:飞机不稳,想调PID;要调PID,得先有可靠的姿态数据;而姿态数据恰恰是很多人默认“传感器会直接给我们的”。实际上一颗MPU6050从I2C读出来,到上位机显示波形,中间隔着寄存器初始化、滤波、数据融合、串口组帧四道坎,哪一道翻车,飞机都是抖的。这个项目就是专门拆掉这几道坎:基于STM32完整实现MPU6050的I2C驱动、DMP与互补滤波两套姿态解算,以及匿名上位机的串口通讯协议。对正在从“能起飞”走向“能稳住”的开发者来说,这是一份可以直接抄、直接改、直接跑的最小工程;对刚接触IMU的新手,它也是一条少绕路的完整学习路径。
2. 系统架构与硬件接线:一条从传感器到上位机的完整链路
2.1 系统组成与数据流
整个工程可以拆成四个环节:MCU主控负责I2C通讯、姿态解算和串口发送;MPU6050六轴IMU输出三轴加速度和三轴角速度;算法层完成DMP或互补滤波解算,把原始数据转换成欧拉角;传输层通过USART按匿名协议组帧,往地面站实时发送姿态。
数据流方向是从传感器到上位机单向的:MPU6050通过I2C总线把原始数据交给STM32,STM32经过解算得到横滚角roll、俯仰角pitch、航向角yaw,再通过串口发送给上位机实时显示。将来如果加PID控制,也是在这份姿态数据上做算法,所以这版工程本质上是一块“能看见姿态的最小飞控前端”。
工程代码采用分层组织,核心文件就几个,这样的结构非常适合二次开发:
| 文件/模块 | 职责 | 关键接口 |
|---|---|---|
| sim_i2c.c | 模拟I2C通讯 | I2C_WriteByte / I2C_ReadBytes |
| mpu6050.c | 传感器寄存器配置与原始数据读取 | MPU6050_Init / MPU6050_ReadRaw |
| attitude.c | 姿态解算与欧拉角输出 | Attitude_Update / Get_Attitude |
| ano_serial.c | 匿名协议组帧与发送 | ANO_Send_Data / Send_Attitude |
分层改动起来的优势很明显:换一颗传感器只改mpu6050.c,换通讯协议只改ano_serial.c,算法层和通讯层互不污染。
2.2 硬件接线与供电细节
MPU6050和STM32之间实际只走两根线。我常用的接法是模拟I2C:SCL接PB10,SDA接PB11,VCC接3.3V,GND接地。为什么不直接选STM32的硬件I2C外设?因为F1系列的硬件I2C在一些场景下会出现总线忙标志卡死的问题,后面避坑章会细说。这里先记一句话:模拟I2C是很多飞控项目里更省心的选择,够快也足够可靠。
有两个引脚容易被忽视。一个是AD0,它决定I2C地址:接地时地址是0x68,接3.3V时是0x69。如果板上还要挂别的I2C设备,靠它切换地址。另一个是INT中断引脚,在DMP模式下它会在姿态更新完成时输出脉冲,如果你想让主循环以事件驱动方式读取姿态,可以把INT引到STM32的EXTI引脚,工程里预留了这个思路但默认轮询读取。
I2C总线是开漏输出,所以SCL和SDA必须要有上拉电阻。成品模块板上一般自带,如果是自己搭的最小系统板,需要加两个4.7kΩ电阻到3.3V。没有上拉时总线读到的全是低电平,现象就是设备地址怎么扫描都找不到。
2.3 代码分层与可移植性设计
这套代码的移植性不错,核心在于模拟I2C层把GPIO操作全部封装成宏。换主控时只需要改宏定义里的GPIO端口和引脚号,上层驱动不用动。我在自己的项目里把这个文件从F103移植到F407,改的仅仅是引脚宏和延时函数,一共不到十分钟。
MPU6050驱动层同样只暴露两个接口:初始化和读取原始数据。初始化做完寄存器配置,读取函数把六轴原始数据一次性读回来。上层算法不关心数据是怎么从传感器拿到的,只关心读到的int16_t数组长什么样。这种接口粒度对工程调试很友好,串口打印、SD卡存储、PID控制都可以直接复用这两步操作。
2.4 关键技术选型:为什么是这套组合
先说姿态解算。工程里同时保留两套方案:DMP官方库和互补滤波。DMP相当于把姿态融合放到传感器内部的协处理器里跑,主控只需要读取它算好的四元数,代码量小、CPU占用低;互补滤波则完全由单片机完成,适合理解原理、脱离官方库做底层控制的场景。两套方案共用同一个串口发送模块,切换时只改主循环里的数据来源。
串口协议选择匿名上位机也是刻意为之。相比自己定义一个纯文本协议,匿名协议是二进制定长帧,带有帧头、功能字、长度和累加和校验,解析效率和鲁棒性都更好。在STM32的软件搭配里,姿态显示插件几乎是调飞控的刚需,匿名上位机恰好支持姿态波形、原始数据、三维模型显示,一套协议解决数据回传和可视化问题。
3. 核心代码拆解:从I2C字节流到姿态四元数
3.1 模拟I2C时序与读写封装
模拟I2C的核心是四个操作:起始、停止、发字节、收字节。起始条件是SCL为高时SDA拉低,停止是SCL为高时SDA拉高。代码将引脚操作封装为宏,让后续读写函数保持简洁:
// 模拟I2C引脚宏定义:SCL=PB10, SDA=PB11 #define SCL_H() GPIOB->BSRR = GPIO_Pin_10 #define SCL_L() GPIOB->BRR = GPIO_Pin_10 #define SDA_H() GPIOB->BSRR = GPIO_Pin_11 #define SDA_L() GPIOB->BRR = GPIO_Pin_11 #define SDA_READ() (GPIOB->IDR & GPIO_Pin_11) static void I2C_Start(void) { SDA_H(); SCL_H(); delay_us(5); SDA_L(); // SCL高电平期间SDA下降沿 => 起始 delay_us(5); SCL_L(); } static void I2C_Stop(void) { SDA_L(); SCL_H(); delay_us(5); SDA_H(); // SCL高电平期间SDA上升沿 => 停止 delay_us(5); }起始和停止的时序要求SCL为高时SDA跳变,这正好对应I2C协议规范。延时大约5微秒,对应100kHz左右的标准模式,MPU6050完全能接受。
单字节读写是最底层的操作。写字节时从高位开始逐位输出,读字节时逐位移入,每发8位后读一次ACK:
static void I2C_SendByte(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { if (dat & 0x80) SDA_H(); else SDA_L(); dat <<= 1; SCL_H(); delay_us(2); SCL_L(); delay_us(2); } // 释放SDA,接收从机ACK GPIOB->CRL &= ~GPIO_CNF_CNF11_1; // 把SDA切成输入 SCL_H(); delay_us(2); SCL_L(); GPIOB->CRL |= GPIO_CNF_CNF11_1; // 切回输出 } static uint8_t I2C_ReadByte(void) { uint8_t val = 0; GPIOB->CRL &= ~GPIO_CNF_CNF11_1; // SDA切输入 for (uint8_t i = 0; i < 8; i++) { val <<= 1; SCL_H(); delay_us(2); if (SDA_READ()) val |= 1; SCL_L(); delay_us(2); } GPIOB->CRL |= GPIO_CNF_CNF11_1; // 切回输出 return val; }这里有一个容易忽略的细节:发送完8位数据后必须释放SDA,才能读到从机的应答位。很多初学者直接在输出模式下读ACK,结果永远是1,误以为设备没应答。模拟I2C的关键就是GPIO方向切换,一条总线上半双工工作。
基于这两层封装,对外提供两个统一接口:写单个寄存器,以及连续读多个寄存器。MPU6050所有操作都建立在这两个接口之上:
void I2C_WriteByte(uint8_t dev_addr, uint8_t reg_addr, uint8_t data) { I2C_Start(); I2C_SendByte(dev_addr << 1 | 0); // 设备地址 + 写位 I2C_SendByte(reg_addr); // 寄存器地址 I2C_SendByte(data); I2C_Stop(); } void I2C_ReadBytes(uint8_t dev_addr, uint8_t reg_addr, uint8_t* buf, uint16_t len) { I2C_Start(); I2C_SendByte(dev_addr << 1 | 0); I2C_SendByte(reg_addr); I2C_Start(); // 重启,切换读方向 I2C_SendByte(dev_addr << 1 | 1); for (uint16_t i = 0; i < len; i++) { buf[i] = I2C_ReadByte(); // 最后一个字节不回ACK if (i == len - 1) continue; I2C_SendByte(0xFF); // 主机回ACK } I2C_Stop(); }连续读多个寄存器是姿态采集效率的关键。MPU6050的加速度计和陀螺仪数据寄存器在地址上是连续的,可以从0x3B一次读出14个字节,比单字节读14次省掉大量重复的起始和地址发送,这也是项目里主循环读取的默认方式。
3.2 MPU6050寄存器初始化配置
MPU6050上电默认处于休眠状态,不做配置时读到的加速度全为0。初始化按“复位 → 唤醒 → 配采样率 → 配量程 → 配低通滤波”的固定顺序来:
void MPU6050_Init(void) { // 1. 复位整个芯片 I2C_WriteByte(MPU6050_ADDR, PWR_MGMT_1, 0x80); delay_ms(100); // 复位后必须等待 // 2. 唤醒,选择时钟源为X轴陀螺 I2C_WriteByte(MPU6050_ADDR, PWR_MGMT_1, 0x00); // 3. 采样率分频:1kHz / (1+4) = 200Hz I2C_WriteByte(MPU6050_ADDR, SMPLRT_DIV, 0x04); // 4. 数字低通滤波:带宽约42Hz I2C_WriteByte(MPU6050_ADDR, CONFIG, 0x03); // 5. 陀螺仪量程 ±500°/s I2C_WriteByte(MPU6050_ADDR, GYRO_CONFIG, 0x08); // 6. 加速度计量程 ±2g I2C_WriteByte(MPU6050_ADDR, ACCEL_CONFIG, 0x00); }参数说明:PWR_MGMT_1写0x80是触发全芯片复位,复位完成后必须延时约100ms再写后续寄存器,否则芯片还在复位中,写进去的数据会被丢掉。SMPLRT_DIV是采样率分频器,实际采样率等于1kHz除以分频值加一,写4得到200Hz,做四旋翼够用。GYRO_CONFIG写0x08把量程设为±500°/s,对应灵敏度131 LSB/(°/s);ACCEL_CONFIG写0x00是±2g量程,灵敏度16384 LSB/g。在后续读取原始值时,用这两个灵敏度做单位换算。
3.3 六轴原始数据读取与预处理
初始化完成后,读取流程就很固定了:从ACCEL_XOUT_H地址起连读14字节,前6字节是加速度,中间2字节是温度,后6字节是陀螺仪。每个轴的数据都是高8位在前、低8位在后,拼成一个int16_t:
void MPU6050_ReadRaw(int16_t* ax, int16_t* ay, int16_t* az, int16_t* gx, int16_t* gy, int16_t* gz) { uint8_t buf[14] = {0}; I2C_ReadBytes(MPU6050_ADDR, ACCEL_XOUT_H, buf, 14); *ax = (int16_t)((buf[0] << 8) | buf[1]); *ay = (int16_t)((buf[2] << 8) | buf[3]); *az = (int16_t)((buf[4] << 8) | buf[5]); *gx = (int16_t)((buf[8] << 8) | buf[9]); *gy = (int16_t)((buf[10] << 8) | buf[11]); *gz = (int16_t)((buf[12] << 8) | buf[13]); }读取出来的原始值要换算成物理量才直观。加速度实际数值等于原始值除以16384,单位是g;陀螺仪角速度等于原始值除以131,单位是°/s。比如静止平放时z轴加速度原始值约等于16384,读出来恰好是1g;陀螺仪静止时原始值理论为0,实际会有几十甚至上百的零偏,这需要在解算前处理。
磨损和噪声是IMU的老朋友。工程里对加速度做了滑动均值滤波,窗口大小取4:
#define FILTER_N 4 static int32_t acc_buf[3][FILTER_N]; static uint8_t acc_idx; void Acc_Filter(int16_t* ax, int16_t* ay, int16_t* az) { int32_t sum[3] = {0}; acc_buf[0][acc_idx] = *ax; acc_buf[1][acc_idx] = *ay; acc_buf[2][acc_idx] = *az; acc_idx = (acc_idx + 1) % FILTER_N; for (uint8_t i = 0; i < FILTER_N; i++) { sum[0] += acc_buf[0][i]; sum[1] += acc_buf[1][i]; sum[2] += acc_buf[2][i]; } *ax = sum[0] / FILTER_N; *ay = sum[1] / FILTER_N; *az = sum[2] / FILTER_N; }滑动窗口本质上是N点平均滤波器,窗口越大噪声压得越低,但延迟也越大。在四旋翼场景,窗口4到8是比较折中的区间。延迟太大会让控制环感觉“肉”,迟滞加剧振荡,这属于调参时能直观感受到的变化。
3.4 姿态解算:DMP官方库与互补滤波两条路
DMP方案调用官方motion driver库,初始化之后主循环直接读四元数,然后做一次欧拉角转换:
// 主循环前的初始化 mpu_dmp_init(); // 主循环内读取姿态 short quat[4]; unsigned long timestamp; short gyro[3], accel[3]; while (1) { if (dmp_read_fifo(gyro, accel, quat, ×tamp, &more) == 0) { float q0 = quat[0] / 16384.0f; float q1 = quat[1] / 16384.0f; float q2 = quat[2] / 16384.0f; float q3 = quat[3] / 16384.0f; float roll = atan2f(2.0f * (q0 * q1 + q2 * q3), 1 - 2.0f * (q1 * q1 + q2 * q2)) * 57.3f; float pitch = asinf(2.0f * (q0 * q2 - q3 * q1)) * 57.3f; float yaw = atan2f(2.0f * (q0 * q3 + q1 * q2), 1 - 2.0f * (q2 * q2 + q3 * q3)) * 57.3f; } delay_ms(5); }DMP库输出的四元数是定点格式,需要除以16384还原成浮点四元数。四元数转欧拉角用上面三个反三角函数公式即可,roll和pitch的取值范围是±90°或±180°,atan2f能正确处理象限问题。DMP方案的好处是主控不需要关心融合细节,传感器内部完成了陀螺仪积分和加速度修正。
互补滤波方案则是完全在STM32里做数据融合。思路是先由陀螺仪积分得到一个角度,再用加速度计算出的重力方向反向修正漂移:
#define dt 0.005f // 采样周期5ms,与200Hz采样率对应 #define Kp 0.98f // 陀螺仪权重 void Complementary_Filter(float* roll, float* pitch, float gx, float gy, float acc_roll, float acc_pitch) { // 陀螺仪积分:角度 += 角速度 * 周期 *roll += gx * dt; *pitch += gy * dt; // 加速度修正:把积分结果拉向加速度计参考值 *roll = Kp * (*roll) + (1.0f - Kp) * acc_roll; *pitch = Kp * (*pitch) + (1.0f - Kp) * acc_pitch; }其中acc_roll和acc_pitch由加速度分量计算得到:acc_roll = atan2f(ay, az),acc_pitch = atan2f(-ax, sqrtf(ayay + azaz))。Kp取0.98表示高度信任陀螺仪积分,只拿2%的加速度结果做长期修正。注意yaw没有对应加速度参考,所以互补滤波方案里yaw只能靠陀螺仪积分,漂移不可避免,工程中默认yaw输出原始积分值,如果需要长时间稳定,就得靠后续加磁力计。
4. 匿名上位机协议实现:把姿态数据实时送出去
4.1 协议帧结构解析
本地算出来的角度要发到上位机,不能直接用printf打印,因为上位机需要稳定、定长、可校验的二进制帧。匿名协议帧结构如下:
| 字节位置 | 内容 | 长度 |
|---|---|---|
| 0 | 帧头0xAA | 1字节 |
| 1 | 帧头0x55 | 1字节 |
| 2 | 功能字,0x51表示姿态 | 1字节 |
| 3 | 数据长度 | 1字节 |
| 4 ~ 4+len-1 | 数据区 | len字节 |
| 末尾 | 校验和 | 1字节 |
校验和的计算范围是从功能字到数据区末尾的累加和,不包含两个帧头字节。上位机先找帧头,再用校验和判断这一帧是否完整有效。0x51是姿态数据帧,数据区通常放12字节:roll、pitch、yaw各占4字节float。
4.2 组帧与串口发送代码
发送模块分成两层:底层是通用组帧函数,上层是姿态专用打包函数。这样以后要发传感器原始数据或遥控通道值,只需加一个包装函数:
void ANO_Send_Data(uint8_t fun, uint8_t* data, uint8_t len) { uint8_t buf[64]; uint8_t crc = 0; buf[0] = 0xAA; buf[1] = 0x55; buf[2] = fun; buf[3] = len; for (uint8_t i = 0; i < len; i++) { buf[4 + i] = data[i]; crc += fun + data[i]; // 累加和,范围从功能字开始 } buf[4 + len] = crc; // 一次性发出整帧 for (uint8_t i = 0; i < 4 + len + 1; i++) { while (!USART_GetFlagStatus(USART1, USART_FLAG_TXE)); USART_SendData(USART1, buf[i]); } } void Send_Attitude(float roll, float pitch, float yaw) { uint8_t data[12]; // 小端序直接拷贝float字节 memcpy(&data[0], &roll, 4); memcpy(&data[4], &pitch, 4); memcpy(&data[8], &yaw, 4); ANO_Send_Data(0x51, data, 12); }float在STM32里本身就是4字节小端序,直接memcpy到字节数组即可,上位机按小端解析。如果你从别的平台移植过来,顺序不对时角度会显示成乱码,这时要考虑字节序转换。发送前等待TXE标志位是USART发送的常规操作,保证上一个字节还没挪出移位寄存器时不覆盖数据。
注意校验和计算方式:匿名协议累加和是逐字节累加,有些上位机固件要求把功能字也加进去。如果上位机能显示数据但偶尔丢帧,先检查校验和算法是否和上位机端一致。
4.3 上位机配置与验证
上位机验证是整个项目里最有成就感的一步。打开匿名上位机,选择串口,波特率设成115200,打开串口后把开发板平放,能看到roll和pitch接近0度;把板子绕z轴转90度,yaw跟着变化;把板子倾斜,横滚角立即响应。如果这些都对,说明I2C读取、姿态解算、串口组帧、协议解析四层链路全部打通。
上位机里主要看三个东西:波形曲线用于观察角度连续性和噪声;三维模型用于直觉判断姿态是否匹配物理动作;原始数据窗口用于排查传感器数值是否异常。调试时我习惯同时开波形和三维模型,一边用手转动板子一边观察,数据有没有跳变、延迟有多大,一眼就能看出来。
ST官方库版本不同导致USART发送API有细微差别,但如果用的是标准外设库,上面代码可以直接编译。移植到HAL库时把USART发送部分换成HAL_UART_Transmit,帧结构和组帧逻辑完全不用改。
5. 避坑指南:MPU6050常见故障排查记录
5.1 I2C总线忙死与设备扫不到
现象:程序卡在I2C等待标志位,无限循环,或者上电后设备地址0x68读回来全是0xFF。
原因:这条我踩过好几次。F1系列硬件I2C外设存在标志位被中断抢占后无法复位的偶发情况,表现就是BUSY标志一直为1,代码死在等待里;另外GPIO如果配置成推挽输出而非开漏,再接上拉电阻,也会拉低电平失效。
解决:最省心的方案就是项目里这套模拟I2C,绕开硬件外设,用GPIO时序直接干活。GPIO配置为开漏输出模式,加上4.7kΩ上拉电阻;如果设备还是扫不到,量一下SCL和SDA引脚对地电压,应该是3.3V左右,哪一端是0V就说明那一侧被拉死了。
5.2 静止时角度漂移和翻面后跳变
现象:板子放在桌上不动,yaw慢慢往一个方向漂,几分钟能偏好几度;快速翻转板子后,roll或pitch数值瞬间跳到离谱的值,要过一会儿才恢复正常。
原因:yaw没有任何外部参考量,纯靠陀螺仪积分,只要陀螺零偏不为零,积分误差就会累积成漂移;pitch和roll的跳变则是加速度计在快速运动时叠加了运动加速度,直接用atan2算出来的角度包含了运动分量,和高频振动混在一起后更明显。
解决:陀螺仪零偏需要上电时静止采样求平均,把这部分补偿掉再积分;加速度计尽量保留滤波,至少做均值滤波。如果跳变还很大,把互补滤波Kp从0.98适当降低,让加速度修正更平缓,代价是动态响应变钝。
5.3 DMP初始化失败与FIFO溢出
现象:dmp_load_firmware返回非0,或者运行一段时间后dmp_read_fifo一直返回FIFO overflow错误。
原因:DMP固件加载对I2C时序比较敏感,模拟I2C延时太小、读写速度太快,固件加载就会失败;FIFO溢出则通常是主循环读取太慢,比如串口阻塞发送时间过长,DMP采样率固定,FIFO堆积后溢出。
解决:把模拟I2C的延时从2us加大到5us,重新初始化往往能过;主循环里如果要做耗时操作,先读FIFO再处理数据,或者把串口发送改成DMA。DMP库的FIFO缓冲区也可以从默认的512字节加大到1024字节,增加容错空间。
5.4 同一套代码换个板子角速度全部反向
现象:换了主控板或换了传感器模块后,从地面站看到的角度变化方向与实际翻转方向相反。
原因:这不是软件bug,而是传感器安装方向变了。MPU6050的xyz轴方向是固定的,换板子后芯片朝向不同,同一方向的旋转在传感器看来符号就不一样。
解决:打印原始陀螺仪数据,确认翻转方向与数据符号对应关系,然后在代码里做符号取反。更通用的做法是在上层定义一个轴向配置结构体,把正反装、侧装等安装方式做成可配置项,二次开发换机型时就不用改算法。
6. 二次开发方向:从姿态解算到完整飞控
6.1 把串口协议改成自己的格式
匿名协议虽然好用,但如果接的是自己写的地面站,帧格式未必匹配。改造思路很直接:保留组帧框架,只改AN O_Send_Data里的帧头、校验算法和数据区排列。比如我已经把它改成帧头用0xCC 0xCC,校验换成CRC16,地面上位机按新格式解析。注意改协议后先跑一个回环测试,把开发板和上位机连起来,确认每帧都能被正确解析再上机。
6.2 融合磁力计修正yaw漂移
互补滤波版本的yaw漂移困扰了我很久,后来加了一颗磁力计修正yaw。常见做法是拿磁力计算出磁航向作为yaw的参考值,再套用互补滤波思路:yaw = Kp * (yaw + gz*dt) + (1-Kp) * mag_yaw。磁力计容易受电机磁场干扰,装机后要做椭球校准,否则修正方向本身就偏。这一步做完,悬停航向稳定性会明显改善。
6.3 上电校准惯用流程
从那以后,我每次拿到新IMU模块,都强制先走一遍完整校准流程:先用匿名上位机看原始数据是否波动正常,再静止采样200ms求零偏写入Flash,最后转动机体确认三轴方向正确,确认后再开始写控制算法。这套顺序帮我少走了很多弯路,希望帮到你。
本文还有配套的精品资源,点击获取