简介:基于STM32的四旋翼无人机项目第二阶段资料包,聚焦MPU6050姿态解算与匿名上位机串口通信,适合正在学习嵌入式飞控、STM32外设开发及滤波算法的开发者和学生。整个压缩包28.44MB,包含1033个文件,其中566个C源码文件与251个头文件构成工程主体,另有.s启动文件、.d/.o等编译中间文件、.uvprojx/.uvoptx工程配置、.hex/.axf可执行文件以及docx说明文档,覆盖从源码到编译、烧录、调试的完整资源链。目前已有12975人学习下载,资源热度较高。内容围绕传感器初始化、原始数据读取与温度补偿、互补滤波姿态解算、UART与匿名上位机通信、电机PWM控制等关键环节展开,并附有工程中常用的数学库与链接脚本,可帮助读者系统理解从加速度计/陀螺仪数据到俯仰、横滚、偏航角输出的全过程,也能掌握STM32 HAL库配置串口、定时器、中断的方法。对深入实践四旋翼无人机控制和嵌入式系统开发很有价值。
1. 从PID调参到姿态解算:为什么你会卡在MPU6050这关
四旋翼无人机开发里,姿态解算是绕不过去的一道门槛。很多人在电机没转之前,先卡在“姿态角出不来”:MPU6050通电了,I2C也连上了,但串口输出的横滚角始终是0,上位机波形纹丝不动。这类问题大多是时序配置、串口协议封装和寄存器理解上的坑,不是硬件坏了。这个项目把STM32读取MPU6050、DMP姿态解算、把姿态角通过匿名上位机协议发出来的完整流程打包成了可直接运行的工程代码,适合正在DIY飞控或打算自己写姿态环的开发者,拿来跑通了再改。
2. 整体架构:MPU6050数据到上位机波形的完整链路
2.1 硬件连接与软件分层
先看硬件连接。MPU6050模块与STM32之间走的是I2C总线,常见接法是SCL接PB6、SDA接PB7,对应STM32F103的I2C1外设。这个工程里没有用软件模拟I2C,而是直接跑HAL库的硬件I2C,这也是我更推荐的做法——软件I2C容易受中断延迟影响,在高速读取时偶尔会出现位翻转。硬件I2C需要模块带4.7K上拉电阻,如果是从某宝买的散装MPU6050模块,很多板子上其实已经焊了,但有些精简板没有,你要自己补上,否则读出来的数据一会出现一会消失,那种“时好时坏”的故障最折磨人。
串口方面,STM32的USART1负责把姿态数据发给匿名上位机,波特率默认设成115200。电脑端用USB转TTL模块连接,TXD接RXD,RXD接TXD,这个交叉接线很多人第一次会接反。上位机收到的是打包好的二进制帧,不是文本,所以不能用串口助手直接看,需要用匿名上位机或者支持十六进制显示的软件去解析。
整个数据链路可以拆成三层:传感器层负责通过I2C读取加速度和角速度原始值,算法层负责DMP固件加载与四元数解算,通讯层负责把欧拉角封装成匿名协议的帧格式。分层清晰带来的好处是,以后你想换传感器芯片或者换上位机软件,只需要动其中一层,其余代码不用大改。
2.2 工程目录与关键文件解析
我拿到这份工程后先扫了目录结构,属于典型的STM32CubeMX生成再手写逻辑的组织方式。关键文件大致如下:
Project/ ├── Core/ │ ├── Inc/ │ │ ├── mpu6050.h │ │ ├── usart.h │ │ └── delay.h │ └── Src/ │ ├── mpu6050.c │ ├── usart.c │ └── delay.c ├── Drivers/ │ ├── stm32f1xx_hal_driver/ │ └── CMSIS/ └── User/ ├── attitude_estimate.c ├── attitude_estimate.h ├── anotc_comm.c └── main.c其中mpu6050.c是传感器的寄存器驱动,包含I2C读写函数、DMP固件数组、初始化和FIFO读取逻辑。attitude_estimate.c是姿态解算入口,它把DMP输出的四元数转换成滚转、俯仰、偏航三个欧拉角,同时保留了一个宏开关USE_DMP,调成0之后可以切换到纯软件互补滤波,这个设计给二次开发留了很大余地。anotc_comm.c是专门适配匿名上位机的协议层,功能是把姿态角按指定字节序填入帧结构,然后往串口发送。这个文件相当于一个“协议插件”,之后你想换其他上位机,只需要重写这个文件里的打包和解析逻辑。
各文件的职责可以用一张表概括:
| 文件 | 主要职责 | 依赖说明 |
|---|---|---|
| mpu6050.c | 寄存器读写、DMP固件加载、FIFO读取 | 依赖HAL I2C |
| attitude_estimate.c | 四元数转欧拉角、互补滤波 | 依赖mpu6050.h |
| anotc_comm.c | 匿名协议帧组装、串口发送 | 依赖usart.h |
| usart.c | 串口底层驱动、printf重定向 | 依赖HAL UART |
另外,main.c里完成了时钟配置、GPIO初始化、I2C和USART1的外设初始化,然后进入主循环。
2.3 裸机主循环与时间片调度
这个项目没有跑RTOS,是一个典型的裸机主循环。我在里面看到了一种很实用的时间片调度方式:用HAL_GetTick()记录上次执行时间,按固定周期调用不同模块。
while (1) { if (HAL_GetTick() - lastRead >= 5) { MPU6050_ReadFifoData(); Attitude_Update(); lastRead = HAL_GetTick(); } if (HAL_GetTick() - lastSend >= 20) { AnoTc_SendAttitude(imu.roll, imu.pitch, imu.yaw); lastSend = HAL_GetTick(); } }第一段每5毫秒读一次FIFO并更新姿态,第二段每20毫秒向上位机发送一帧数据。读取和发送分开,可以避免串口发送长时间占用CPU导致I2C读取超时。我之前在一个项目里贪方便,把发送也塞到5毫秒的任务里,结果上位机波形出现周期性的毛刺,排查了很久才发现是串口发送期间DMP FIFO溢出导致丢帧。这种调度结构简单但有效,也为后续跑PID控制环提供了一个稳定的时基。
3. 核心模块拆解:I2C驱动、DMP解算与匿名协议实现
3.1 寄存器读写与传感器初始化
先看最底层的I2C通信。工程里把单字节和多字节读写封装得很干净,底层调用HAL库的阻塞传输接口:
uint8_t MPU6050_ReadBytes(uint8_t reg, uint8_t *buf, uint8_t len) { HAL_I2C_Master_Transmit(&hi2c1, MPU6050_ADDR << 1, ®, 1, HAL_MAX_DELAY); HAL_I2C_Master_Receive(&hi2c1, MPU6050_ADDR << 1, buf, len, HAL_MAX_DELAY); return 0; }这里最关键的是MPU6050_ADDR。MPU6050的I2C从机地址是0x68还是0x69,取决于AD0引脚的电平。默认模块AD0接地,地址是0x68;如果AD0接VCC,地址就变成0x69。工程里宏定义写的是0x68,如果你的模块在另一块板子上被拉高了AD0,怎么调都读不到ID,那就把宏改成0x69再试。MPU6050_ADDR << 1是HAL库在HAL_I2C_Master_Transmit里要求的左移一位格式,这个细节如果写错,I2C总线波形会完全对不上。
初始化时先唤醒传感器,再配置量程。工程里的默认配置是加速度计±8g、陀螺仪±2000dps,这是针对四旋翼大机动的场景。如果用在云台或者自平衡小车上,量程改小一点能得到更高的分辨率。唤醒之后还要配置采样率分频和数字低通滤波,这些寄存器组合起来决定了传感器输出的更新频率和噪声水平。常见的做法是先用默认值跑通,再根据实际波形微调。
3.2 DMP固件加载与FIFO数据读取
DMP姿态解算是这个项目的核心。MPU6050内部有一个数字运动处理器,需要先往芯片RAM里加载一段官方固件数组,加载成功后才能让芯片自己算四元数。这个加载过程在代码里看起来就是连续往特定RAM寄存器写数据:
uint8_t MPU6050_DMPInit(void) { uint8_t buf[16]; MPU6050_WriteByte(MPU6050_RA_PWR_MGMT_1, 0x00); HAL_Delay(100); MPU6050_LoadDMPFirmware(); MPU6050_WriteByte(MPU6050_RA_FIFO_EN, 0x00); MPU6050_WriteByte(MPU6050_RA_ACCEL_CONFIG2, 0x18); return MPU6050_CheckDMP(); }这里有几个容易出问题的点。第一,MPU6050_LoadDMPFirmware函数体内是一串很长的I2C写操作,耗时可能超过50毫秒,期间如果有中断频繁打断,固件写入会失败,返回的校验结果就不对。第二,固件数组必须和DMP库版本匹配,工程里带的这一份通常是官方旧版DMP固件,如果你从其他工程里复制了一份不同来源的数组混进来,初始化会直接卡在MPU6050_CheckDMP()。第三,FIFO_EN寄存器要正确设置,DMP输出的是四元数,需要使能FIFO并配置输出速率。
初始化之后,主循环通过读FIFO_COUNT_H/L寄存器判断当前有多少字节待读取,然后从FIFO里把数据读出来。这里有个玄学:当FIFO满到1024字节时,如果不去复位,之后读出来的数据全是对不齐的错位字节。我一般会加一个判断,满则复位再等下一帧。这一点代码里已经做了,你在二次开发时不要随手删掉。
3.3 四元数到欧拉角的换算
DMP直接输出的是四元数q0、q1、q2、q3,做控制或者看波形时需要转换成欧拉角。工程里用的是一套标准的四元数转欧拉角公式:
roll = atan2(2 * (q0 * q1 + q2 * q3), 1 - 2 * (q1 * q1 + q2 * q2)) * 180.0f / PI; pitch = asin(2 * (q0 * q2 - q3 * q1)) * 180.0f / PI; yaw = atan2(2 * (q0 * q3 + q1 * q2), 1 - 2 * (q2 * q2 + q3 * q3)) * 180.0f / PI;这三个公式里最容易翻车的是第二个asin的输入范围。2 * (q0 * q2 - q3 * q1)这个值理论上在[-1, 1]之间,但浮点运算误差可能导致它变成1.0000001,asin就会返回NaN,一旦出现一次NaN,后面所有角度都会污染。我在代码里会套一层constrain函数把它夹在[-1, 1]里:
float v = 2 * (q0 * q2 - q3 * q1); v = v > 1.0f ? 1.0f : (v < -1.0f ? -1.0f : v); pitch = asin(v) * 180.0f / PI;另外,STM32F103的FPU算浮点三角函数还是比较费时间的,所以在发送上位机之前,工程会把角度乘以100取整,也就是用定点数表示0.01度。这样既保留了精度,又让串口数据包更紧凑,上位机显示也更平滑。
3.4 匿名上位机协议封装与发送
匿名上位机协议是这个工程名字里特别强调的部分。它的帧格式不算复杂,但很值得拆开讲。一帧数据由帧头、功能字、数据长度、数据体和校验和组成。工程里通过一个结构体来定义:
typedef struct { uint8_t head; // 0x88 uint8_t fun; // 0x01 表示姿态角 uint8_t len; // 数据长度,这里固定为6 int16_t roll; // 横滚角,单位0.01度 int16_t pitch; // 俯仰角 int16_t yaw; // 偏航角 uint8_t sum; // 校验和 } AnoTcData;实际发送时不能直接把这个结构体指针转成字节数组往串口扔,因为编译器结构体可能存在内存对齐填充,导致字节顺序和上位机预期不一致。工程里是老老实实逐字节填充到一个uint8_t数组里再发送:
void AnoTc_SendAttitude(int16_t roll, int16_t pitch, int16_t yaw) { uint8_t buf[10]; buf[0] = 0x88; buf[1] = 0x01; buf[2] = 6; buf[3] = (roll >> 8) & 0xFF; buf[4] = roll & 0xFF; buf[5] = (pitch >> 8) & 0xFF; buf[6] = pitch & 0xFF; buf[7] = (yaw >> 8) & 0xFF; buf[8] = yaw & 0xFF; buf[9] = buf[0] + buf[1] + buf[2] + buf[3] + buf[4] + buf[5] + buf[6] + buf[7] + buf[8]; HAL_UART_Transmit(&huart1, buf, 10, 100); }注意这里发送的是大端序,高8位在前,低8位在后。匿名上位机协议文档里也是这么要求的。如果你从其他飞控代码里移植过来,发送端是小端序,上位机收到后横滚角看起来就会像随机噪声一样跳来跳去。校验和是简单累加,不是CRC,只需要把前面所有字节加起来取低8位。上位机计算不一致会直接丢弃该帧,所以一旦你发现波形数据量明显减少,优先检查校验和。
我在拆这份代码时还注意到,串口接收中断里额外做了数据解析,能接收上位机下发的指令。这意味着你不光能看波形,还可以从电脑端给飞控发解锁、校准或者模式切换命令。这个功能给后续调试带来了很大的便利,相当于把上位机变成了一个“人机交互插件”。
4. 从CubeMX配置到匿名上位机联调
4.1 用CubeMX生成STM32基础工程
新建工程时建议用STM32CubeMX生成,选好MCU型号后重点配置三个外设:I2C1、USART1、和一个定时器时基。I2C1的速率要调到400KHz,这是MPU6050支持的高速模式,如果保持默认100KHz,DMP固件加载时间会明显变长,看起来就像卡死。USART1配置成异步模式,波特率115200,字长8位,无校验,停止位1位。时基用SysTick,主频设72MHz。
生成代码后,把mpu6050.c、attitude_estimate.c、anotc_comm.c这几个文件拷贝进工程并加入编译。如果你用的是Keil,记得在C/C++选项里添加USE_HAL_DRIVER和STM32F103xE这两个预处理宏,否则很多HAL外设头文件不会被包含,编译报错会集中在main.h里。这一步吃过亏的人不在少数,看着像代码问题,其实就是宏没加。
串口重定向也要在这一步搞定。在usart.c末尾补上如下代码,让printf往USART1输出:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100); return ch; }注意printf输出的调试信息和之后的上位机协议帧不要混在同一个串口的同一时间线上。调试信息是文本,上位机解析的是二进制帧,如果同时往USART1里发,上位机大概率会频繁解析失败。我一般把调试信息通过另一个串口打印,或者在上位机数据发送的间隙里插入调试输出,但那样容易踩坑,所以建议直接分开。
4.2 烧录后验证I2C通路和基础数据
烧录后第一步不是连上位机,而是验证传感器通路。工程里通常会在初始化阶段打印WHO_AM_I寄存器的值,就是下面这段:
uint8_t id = MPU6050_ReadID(); printf("MPU6050 ID: 0x%02X\r\n", id);在串口助手里看到0x68,说明I2C通了。如果看到0xFF或者收不到任何输出,先检查上拉电阻和接线,然后确认地址宏是0x68还是0x69。这是整个项目里最容易出错的第一步,我见过有人卡在这个阶段三天,最后发现是杜邦线松了。
通路验证之后,再让DMP跑起来,打印一个原始四元数看是否在变化。用手轻轻转动MPU6050模块,如果四元数跟着变,说明DMP固件加载成功。四元数数值变化正常才能接上位机,否则后面看到错误的欧拉角会分不清到底哪一层出了问题。
4.3 匿名上位机配置与波形观察
打开匿名上位机软件,先选串口端口和波特率。端口在设备管理器里找对应的COM号,波特率设115200。然后在飞控波形页面里添加横滚角、俯仰角、偏航角三个通道。协议类型选“匿名协议”,上位机会自动识别帧头0x88。
连接成功后,波形应当实时跟随模块姿态变化。如果波形不出来,先在串口接收区开十六进制显示,确认收到的是不是88 01 06 ...这样的帧。手动算一遍校验和,看和代码里算出来的是否一致。很多时候是fputc调试和协议帧共用一个串口,导致协议帧被文本干扰,接收区里能看到一串ASCII字符夹着乱码。
如果波形出来了但曲线抖动剧烈,可以检查一下模块固定是否牢固,或者尝试降低发送频率。工程默认20毫秒一帧,即50Hz刷新率,这个频率在大多数上位机上都很流畅。提高到100Hz并不会让上位机显示更细腻,反而可能因为主控负载加重而丢帧。
5. 避坑指南:MPU6050与上位机通讯的常见问题
5.1 读WHO_AM_I返回0xFF
现象:打印出来的ID一直显示0xFF,程序卡死在DMP初始化。
原因:最常见的是I2C地址不对,AD0引脚电平决定从机地址是0x68还是0x69。其次是模块的SDA和SCL接反,但我发现更隐蔽的问题是供电不足。MPU6050模块如果从STM32板子的3.3V引脚取电,而板子的LDO负载能力太弱,会导致模块一工作就进入欠压状态。
解决:先量模块VCC对地电压,确认稳定在3.3V左右。然后尝试把代码里的MPU6050_ADDR宏改成0x69再试一次。如果还不行,检查SDA和SCL是否接反,并在I2C总线上加4.7K上拉电阻。这种问题通常不是一处原因,需要从上到下排查。
5.2 上位机收不到完整波形或数据错乱
现象:上位机串口能收到数据,但波形图要么是直线,要么是乱码一样的毛刺。
原因:协议帧不完整,或者校验和错误,被上位机默默丢弃。我排查过的一个真实案例是,发送端用结构体指针直接强制转换,导致结构体内存对齐填充了额外字节,一帧数据长度比预期多出两个字节,上位机把填充字节当成下一个帧的数据,后续所有帧全部错位。
解决:打开上位机的十六进制显示,截取一段完整的帧数据,手工验算校验和。如果发现帧与帧之间夹杂着多余字节,就得检查发送代码是否用了结构体直接强转。工程里逐字节填充的方式就没这个问题。还有一点,确保波特率两端完全一致,115200差一位数都会导致数据错乱。
5.3 DMP初始化成功但四元数全为零
现象:初始化函数返回正常,打印出来的四元数q0是1,其他三个全是0,姿态角也不变。
原因:DMP的FIFO输出没有真正使能,或者FIFO配置的寄存器被后续初始化覆盖。有些代码在DMP初始化完成之后又执行了MPU6050_SetGyroRange这类函数,这个函数会改写FIFO_EN寄存器,把DMP的使能位清掉。
解决:DMP初始化之后,再手动写一次MPU6050_WriteByte(MPU6050_RA_FIFO_EN, 0xE0),使能FIFO和DMP输出。如果还不行,检查DMP_CFG_1寄存器里的输出速率配置,确保不是0。我一般会在主循环第一次读取前调用一次MPU6050_ResetFIFO(),把历史残留数据清掉。
5.4 姿态角静止时有漂移
现象:模块平放在桌面上,上位机显示横滚角和俯仰角不变,但偏航角每过几分钟就偏转几度。
原因:MPU6050的陀螺仪有零偏,而且零偏随温度变化。DMP对横滚和俯仰的加速度校准能抑制漂移,但偏航角只能靠陀螺仪积分,零偏一累积就漂了。
解决:在初始化后让模块静止采集100次陀螺仪原始值,取平均值作为零偏,在DMP输出四元数之后补偿。也可以在上电后保持模块静止几秒,让DMP内部自动校准一部分零偏。对于长期稳定运行,还可以增加一个磁力计做航向修正,这就进入了二次开发的范畴。
5.5 硬件I2C返回HAL_BUSY导致系统卡死
现象:程序运行一段时间后,上位机波形突然不更新,调试发现卡在HAL_I2C_Master_Transmit里返回HAL_BUSY。
原因:I2C外设状态机被中断打断,或者I2C总线上有设备没有释放时钟线。MPU6050和主控之间如果有较长杜邦线,在电机转动时电磁干扰也会把I2C时序打乱。
解决:优先把I2C速率降到400KHz以下,同时确保SCL和SDA各有一个上拉电阻。如果工程里用了DMA和中断嵌套,注意I2C回调里不要做耗时操作。我在电机试验中遇到过类似问题,最后给I2C加了一个超时重试机制,出错后释放总线重新初始化,整个系统才算稳定下来。
6. 二次开发:从DMP到互补滤波以及协议自定义扩展
这份工程最值得动手的地方,是把attitude_estimate.c里的USE_DMP宏从1改成0,切换到互补滤波路径。互补滤波不像DMP那样依赖固件,而是直接融合加速度计和陀螺仪数据。核心代码如下:
void ComplementaryFilter(float ax, float ay, float az, float gx, float gy, float gz, float dt) { float norm = sqrt(ax * ax + ay * ay + az * az); if (norm < 1e-6f) return; ax /= norm; ay /= norm; az /= norm; float sinRoll = -ay; float accRoll = asin(sinRoll); float accPitch = atan2(ax, az); gyroRoll += gx * dt; gyroPitch += gy * dt; roll = 0.98f * gyroRoll + 0.02f * accRoll; pitch = 0.98f * gyroPitch + 0.02f * accPitch; }这里的0.98和0.02是比例系数,代表你更相信陀螺仪还是加速度计。0.98对于大多数飞行场景够用,但如果你发现姿态角有明显滞后,比如快速旋转后回不到零位,可以试试0.95;如果发现静态噪声大,就调高到0.99。调参这件事没什么玄学,唯一的办法是在上位机上记录一段静态和一段剧烈晃动的波形,对着曲线反复试。
协议扩展也是一个很有意思的方向。匿名上位机的fun字段可以区分不同数据类型,你可以在anotc_comm.c里增加一个fun=0x02的帧,把磁力计或者气压计数据打包发出去。上层上位机只需要按对应fun解析新数据,原有姿态角帧完全不受影响。我在一个后续项目里就往这个通道里塞了电池电压和电机转速,调试时能直接看电压掉到多少开始报警,省去了额外接线。
每次改动完协议,都要同步修改上位机端的解析脚本,否则下位机发了上位机不认,那种“黑匣子”状态特别折磨人。从那以后,我每次改协议都会先在上位机十六进制工具里手动模拟一帧,验证解析逻辑正确了再动代码,这个习惯帮我省了很多排查时间。希望这篇拆解能帮到你。
本文还有配套的精品资源,点击获取