简介:一套基于STM32F103与MPU6050的体感遥控车完整项目资料包,面向嵌入式学习者和电子设计爱好者,旨在改善传统按键遥控的繁琐操作。项目同时支持体感遥感与推杆两种控制模式,利用手势姿态替代按键输入,覆盖原理图设计、PCB绘制到嵌入式软件开发的完整流程。资料包共449个文件,包含STM32的C/H源码、Keil工程文件、Altium Designer原理图/PCB文档、PDF设计说明及3D模型文件等,整体约56.2MB,目录按3D模型、硬件、软件模块清晰划分,此外还提供系统功能框图与遥控器流程图,便于理解整体设计方案。目前已有573人学习下载,通过该包可获得车载主控板与遥控器的电路设计、完整的STM32工程源代码,以及基于MPU6050的姿态检测与控制实现思路,适合用作课程设计、毕业设计或项目练手的参考。
1. 体感遥控车:按一下午按键,不如晃动一个MPU6050
玩具车的遥控器如果还是一排按键,拿到手的新鲜感撑不过十分钟。这个项目把遥控器换成了一块MPU6050姿态传感器:手向前倾,车就前进;手腕往左拧,车头就左转。本质上是把“按键事件”变成了“姿态连续量”,再用STM32F103读取、组帧、无线发到车体。更特别的是它保留了推杆(摇杆)模式,一套遥控器、两种操控手感,切换只靠一个按键。这个项目适合正在做嵌入式课程设计、想把陀螺仪真正接到电机控制环路里的人,也适合想搞清楚MPU6050姿态解算中角速度积分漂移怎么被压下去的开发者。下面从传感器读取开始,逐步把这条路走通。
2. MPU6050姿态解算:从I2C读原始数据到角度融合
2.1 为什么选MPU6050而不是9轴或传统按键
MPU6050是六轴传感器,内部集成了三轴加速度计和三轴陀螺仪,通过I2C接口输出原始数据。相比九轴传感器,它少了一颗磁力计,也就少了地磁校准的那套流程,对手势遥控这种短时间、大角度的场景来说已经足够。九轴的优势在于长时间航向不漂移,但遥控车一局操作不过几分钟,遥控器拿在手里也不需要用罗盘去校正方向。
反直觉的一点是,陀螺仪单独积分出来的角度会越漂越离谱,而加速度计测静态角度又会在运动时被振动污染。所以项目里不能直接把加速计算出的角度拿来用,也不能只信陀螺仪的角速度积分。正确做法是把两者融合。常见方案是MPU6050内置DMP直接输出四元数,但在遥控器这种要求低延迟、逻辑可控的场景,我一般倾向于自己用一阶互补滤波做,省掉DMP库的移植和内存开销。
2.2 硬件接口与寄存器初始化
遥控器端的接线非常固定:MPU6050的VCC接3.3V,GND接GND,SCL和SDA分别接STM32F103的PB6、PB7(I2C1),AD0接低电平,这样器件地址为0x68。注意STM32F103的I2C模块在总线超时处理上容易把人逼疯,许多人会改用软件模拟I2C,但这里用硬件I2C也可以,只要加一个超时判断。接口关系如下:
| 引脚 | 连接到STM32 | 说明 |
|---|---|---|
| VCC | 3.3V | 不可接5V,会烧模块 |
| GND | GND | 共地 |
| SCL | PB6 | I2C1_SCL,上拉4.7kΩ |
| SDA | PB7 | I2C1_SDA,上拉4.7kΩ |
| AD0 | GND | 选择0x68地址 |
初始化代码里最关键的几个寄存器是电源管理、采样率、滤波带宽。下面是一段适用于STM32标准外设库和HAL库共用的初始化函数,使用寄存器地址直接操作。
#define MPU6050_ADDR 0xD0 // 0x68左移一位 void MPU6050_Init(void) { I2C_WriteByte(MPU6050_ADDR, 0x6B, 0x00); // PWR_MGMT_1,解除休眠 I2C_WriteByte(MPU6050_ADDR, 0x19, 0x07); // SMPLRT_DIV,采样率分频 I2C_WriteByte(MPU6050_ADDR, 0x1A, 0x06); // CONFIG,低通滤波42Hz I2C_WriteByte(MPU6050_ADDR, 0x1B, 0x18); // GYRO_CONFIG,±2000°/s I2C_WriteByte(MPU6050_ADDR, 0x1C, 0x08); // ACCEL_CONFIG,±4g }这里每个寄存器都需要解释:0x6B写入0x00是让芯片从休眠模式唤醒,写0x40是复位,实际初始化时先复位再解除休眠更稳定;0x19样本率分频为7,表示输出频率为1kHz除以8,也就是125Hz,这个频率对遥控协议更新足够;0x1A低通滤波设置为6时对应42Hz截止频率,可以有效滤除高频机械振动;陀螺仪量程选择±2000°/s是因为手势挥动时的瞬时角速度可能超过500°/s,而量程越小灵敏度越高,需要根据实际动作取舍;加速度计量程选±4g可以在快速倾斜时避免饱和。
2.3 读取加速度和陀螺仪原始数据
初始化完成后,只需要循环读取数据寄存器。加速度计原始值的高字节从0x3B开始,陀螺仪从0x43开始。下面是读取函数。
typedef struct { int16_t acc_x, acc_y, acc_z; int16_t gyro_x, gyro_y, gyro_z; } MPU6050_Data; void MPU6050_ReadRaw(MPU6050_Data *d) { uint8_t buf[14]; I2C_ReadBytes(MPU6050_ADDR, 0x3B, buf, 14); // 一次读出加速度+温度+陀螺仪 d->acc_x = (buf[0] << 8) | buf[1]; d->acc_y = (buf[2] << 8) | buf[3]; d->acc_z = (buf[4] << 8) | buf[5]; d->gyro_x = (buf[8] << 8) | buf[9]; d->gyro_y = (buf[10] << 8) | buf[11]; d->gyro_z = (buf[12] << 8) | buf[13]; }这段代码的作用是连续读取14字节,因为MPU6050的寄存器地址是自动递增的,从0x3B开始读,一次就能拿到加速度、温度、陀螺仪的全部数据,省去多次I2C通信的开销。注意buf[6]和buf[7]是温度,本项目用不到但读取时不能跳过。原始值是16位有符号数,要换算成物理量需要除以对应量程的满刻度系数:±2000°/s对应16.4 LSB/°/s,±4g对应8192 LSB/g。注意:I2C读时序必须在发送寄存器地址后加入重复起始信号,否则器件不回复数据。
2.4 一阶互补滤波计算姿态角
MPU6050姿态解算的常见思路有三种:直接读DMP四元数、用Mahony算法、用一阶互补滤波。这里用手势遥控场景,要求响应快而非绝对精度,所以选用一阶互补滤波即可。基本原理是:加速度计计算出的角度在低频段可信,陀螺仪积分出的角度在高频段可信,把两者以系数α加权。
#define alpha 0.96f #define dt 0.008f // 125Hz采样间隔 float pitch_angle, roll_angle; void MPU6050_UpdateFilter(void) { MPU6050_Data raw; MPU6050_ReadRaw(&raw); float acc_pitch = atan2f(raw.acc_y, raw.acc_z) * 57.29578f; float acc_roll = atan2f(raw.acc_x, raw.acc_z) * 57.29578f; float gyro_pitch = raw.gyro_x / 16.4f; float gyro_roll = raw.gyro_y / 16.4f; pitch_angle = alpha * (pitch_angle + gyro_pitch * dt) + (1 - alpha) * acc_pitch; roll_angle = alpha * (roll_angle + gyro_roll * dt) + (1 - alpha) * acc_roll; }融合逻辑很直白:每次递推先用陀螺仪角速度乘以dt做积分,再用加速度计角度做长期修正。alpha越接近1,姿态角越平滑但滞后越大;alpha越小,响应越快但噪声越大。实际调试时我先把alpha设为0.96,0.8ms的采样周期下,手转90度,滤波后的角度大约滞后30ms,这个延迟在遥控链路里感知不强。关键点是atan2f要用y和z轴计算俯仰,x和z轴计算横滚,而yaw不能靠加速度计计算,所以偏航角在遥控车里不能直接用互补滤波,只能靠陀螺仪积分,这也是为什么实际项目中偏航方向容易漂移,通常不把yaw作为长时间稳定依据。
3. 遥控器双模式切换与无线协议帧设计
3.1 遥控器主循环:推杆与手势模式的无缝切换
遥控器端的MCU承担三个任务:读取MPU6050姿态角、读取摇杆电压、把数据打包发给车体。项目里“遥感”模式指手势模式,“推杆”模式指传统摇杆。主循环要实时检测按键切换,同时根据模式选择不同的数据来源。推杆模式下,摇杆电位器输出的电压经过ADC转换后得到0到4095的原始值,需要映射到-1024到1024的有符号范围;手势模式下,MPU6050的pitch角映射成油门,roll角映射成转向。两者共用同一个无线数据帧,只是填充方式不同。
while (1) { MPU6050_UpdateFilter(); if (KEY_Scan() == MODE_KEY) { control_mode = !control_mode; // 切换手势/推杆 } Rc_Data_t rc; if (control_mode == 0) // 手势模式 { rc.speed = (int16_t)(pitch_angle * 12); // 前倾为正 rc.steer = (int16_t)(roll_angle * 8); // 右倾为正 } else // 推杆模式 { rc.speed = (int16_t)((ADC_Read(CH1) - 2048) / 2); rc.steer = (int16_t)((ADC_Read(CH2) - 2048) / 2); } rc.mode = control_mode; Radio_SendFrame(&rc); delay_ms(8); }这段循环里有几个容易出问题的点。第一是pitch_angle乘以12后,手前倾30度,speed就有360的数值,恰好落在-1024到1024范围内;如果乘数太大,高角度会饱和,车子一直全速,很危险。第二是roll_angle乘以8,映射到转向时要注意左右方向是否与人的直觉一致。第三是按键扫描必须做消抖,我用的是状态机扫描方式,连续两次读到按键状态一致才切换。注意:ADC读取摇杆和MPU6050姿态更新共用同一个延时周期,若把delay_ms改成systick调度,更好控制时间同步。
3.2 无线链路选型:为什么用NRF24L01
项目中车体和遥控器之间需要低延迟通信,常见选择有蓝牙、Wi-Fi和NRF24L01。蓝牙模块的从机配对和主机模式配置在量产中比较麻烦,传统蓝牙延迟在15ms到40ms之间;Wi-Fi延迟更高且功耗大;NRF24L01是一颗2.4GHz射频芯片,SPI接口,在2Mbps速率下单向空中延迟不到1ms,实际加上MCU处理也能做到3ms以内。所以这里采用NRF24L01作为链路。它的缺点是只有125个频道,在2.4GHz拥挤环境中需要重发机制,但对于一台玩具车来说,一分钟内偶尔丢掉一帧不会造成可感知的影响。
3.3 数据帧格式定义与收发解析
无线数据必须定义帧结构,不能裸发,否则接收端无法判断一帧从哪开始。我用一个固定的结构体,在C语言里直接强转成字节数组。
typedef struct { uint8_t head; // 0xAA 帧头 uint8_t mode; // 0=手势 1=推杆 int16_t speed; // 油门,-1024~1024 int16_t steer; // 转向,-1024~1024 uint8_t checksum; // 校验和 } Rc_Data_t;发送端按结构体顺序填充并计算校验和:head到steer的最后一个字节累加。接收端拿到数据后先判断head是否为0xAA,再校验checksum,合法才更新PWM。注意结构体可能有对齐问题,如果编译器设置了字节对齐为4,结构体大小会变成12而不是8,两边必须一致。我一般在代码里使用#pragma pack(1)消除对齐。
校验和的计算和判断代码很轻量,如下。
uint8_t Rc_Checksum(Rc_Data_t *rc) { uint8_t ck = 0; ck += rc->head; ck += rc->mode; ck += (uint8_t)(rc->speed & 0xFF); ck += (uint8_t)(rc->speed >> 8); ck += (uint8_t)(rc->steer & 0xFF); ck += (uint8_t)(rc->steer >> 8); return ck; }发送方把checksum填入结构体倒数第二个字节。接收方每收到一帧就重新计算checksum并与最后一字节比对,不相等就丢弃。这能挡住大部分无线干扰产生的坏帧。注意NRF24L01的payload长度要设成结构体大小,且自动应答关闭时,接收端需要在每次收到数据后清空RX_DR标志位,否则中断会一直触发。实际调试中我把payload长度固定为8字节,和结构体完全一致,避免NRF24L01在缓冲区里读到旧数据。
3.4 NRF24L01收发配置要点
NRF24L01使用SPI,STC或STM32的硬件SPI频率建议在8MHz以内。无线速度选择2Mbps,空中延时最短。发射功率选0dBm,玩具车场景十几米内足够。配置寄存器时,发送端和接收端的地址和无线频道必须一致,管脚CE需要控制收发状态。标准步骤是:初始化SPI,写TX_ADDR和RX_ADDR,写RF_CH、RF_SETUP、CONFIG,最后把CE拉高进入接收或发送模式。
void RF_Init(void) { SPI_WriteReg(0x00, 0x3E); // CONFIG: 上使能CRC,16位 SPI_WriteReg(0x05, 0x00); // RF_CH: 2.400GHz SPI_WriteReg(0x06, 0x0E); // RF_SETUP: 2Mbps, 0dBm SPI_WriteReg(0x11, 0x02); // RX_PW_P0: 8字节 }寄存器含义是:CONFIG中的EN_CRC和CRCO位决定CRC校验,必须开启;RF_CH写入0表示2400MHz,实际通信时两边都要设成同一个频道,比如写20就跳到2420MHz,避开Wi-Fi低频段;RF_SETUP里RF_DR为0时选2Mbps,RF_PWR为11时对应0dBm发射功率;RX_PW_P0设为8,和帧长度一致。注意:如果接收端清RX_DR标志位太慢,或者把CE和CSN引脚顺序搞反,会出现“error: no stm32 target found”这类调试器连接正常但射频不工作的怪现象,先检查引脚初始化。
4. 车载主控板的PWM电机控制与差速转向实现
4.1 车载端硬件组成
车体端主控仍然是STM32F103,它负责接收无线数据、解析帧、输出PWM给电机驱动。驱动芯片我使用的是TB6612FNG,相比L298N它体积小、压降低,更适合这种小遥控车。TB6612的PWMA和PWMB分别控制两个电机转速,AIN1、AIN2和BIN1、BIN2控制转向。STM32F103的定时器输出PWM到这两个引脚,同时两个GPIO控制方向。下表是常用引脚分配:
| 功能 | STM32引脚 | 驱动芯片引脚 |
|---|---|---|
| 左电机PWM | PA8 | PWMA |
| 左电机方向1 | PB12 | AIN1 |
| 左电机方向2 | PB13 | AIN2 |
| 右电机PWM | PA9 | PWMB |
| 右电机方向1 | PB14 | BIN1 |
| 右电机方向2 | PB15 | BIN2 |
选择PA8和PA9是为了用TIM1的两个通道,这两个定时器有互补PWM输出,不过这里只需要普通PWM,用TIM1的CH1和CH2即可。注意不要跟I2C的PB6/PB7冲突,遥控器端的引脚与车载端可以不同,如果车体也用MPU6050则需重新分配。
4.2 定时器PWM配置流程
电机驱动需要20kHz左右的PWM频率,以避开人耳可听噪声,同时保证mos管开关损耗不至于太高。下面代码用STM32标准外设库配置TIM1输出PWM。
void PWM_Init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tim; TIM_OCInitTypeDef oc; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOC | RCC_APB2Periph_TIM1, ENABLE); gpio.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_9; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); tim.TIM_Prescaler = 72 - 1; // 72MHz/72 = 1MHz tim.TIM_CounterMode = TIM_CounterMode_Up; tim.TIM_Period = 50 - 1; // 1MHz/50 = 20kHz TIM_TimeBaseInit(TIM1, &tim); oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_Pulse = 0; oc.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM1, &oc); TIM_OC2Init(TIM1, &oc); TIM_CtrlPWMOutputs(TIM1, ENABLE); // 高级定时器必须开主输出 }配置逻辑是:先把72MHz系统时钟分频到1MHz,计数器周期设为50,得到20kHz的PWM频率,和常见的舵机50Hz不同,电机驱动更适合高频。TIM1是高级定时器,比普通定时器多一步TIM_CtrlPWMOutputs,如果不开启MOE引脚,PWM输出不会有波形。注意:如果换用TIM2、TIM3,则不需要这一句,很多人把TIM1代码改到TIM3后忘记删除,导致烧录后PWM无输出。
PWM的占空比通过修改TIM_SetCompare1(TIM1, duty)和TIM_SetCompare2(TIM1, duty)控制,duty范围0到50。车速控制逻辑是把接收到的speed值从-1024~1024映射到0~50,同时根据正负决定方向引脚电平。
4.3 手势转向与差速融合
体感遥控车在转向时,比较自然的操控是:遥控器向右roll,车上左右轮产生速度差,实现右转。这里不能直接把steer加到左边、从右边减去,需要先计算一个基准速度,再做差速限幅。
int16_t base_speed = rc.speed; int16_t turn = rc.steer; int16_t left_speed = base_speed + turn; int16_t right_speed = base_speed - turn; left_speed = CLAMP(left_speed, -1024, 1024); right_speed = CLAMP(right_speed, -1024, 1024); Motor_SetSpeed(LEFT, left_speed); Motor_SetSpeed(RIGHT, right_speed);Motor_SetSpeed内部根据正负拆分方向和占空比。注意如果车是前轮转向型,这套差速逻辑就要改成把转向量映射到舵机PWM,我把这部分在代码里封装成MOTOR_TYPE宏,用户可以根据自己的车架切换。常见错误是把左右speed直接赋给PWM比较寄存器,没有考虑正负方向引脚;正转和反转需要ATOMIC操作,避免方向引脚和PWM值之间产生竞争。
转向系数的调参也有讲究。假设手势roll角最大30度,映射后steer最大240,如果base_speed是600,那么左轮840、右轮360,差速比接近2.3:1,转弯半径明显变小。如果base_speed只有200,左轮440、右轮-40,右边电机进入反转,车子几乎原地旋转,这在遥控车漂移时有趣,但正常行驶容易失控。所以我一般把turn的范围限制在base_speed * 0.6以内,保证同向行驶优先。
4.4 电机驱动保护与电源处理
车载端的电源拓扑特别容易被忽略:电机瞬间堵转时电流可到2A,而STM32和NRF24L01如果共用同一路5V,电压会被拉低导致复位。正确做法是用一节2S锂电池经过DC-DC降到5V给逻辑供电,电机从电池直接取电。TB6612的逻辑电源和电机电源要分开,逻辑电源接5V,电机电源接7.4V或根据电机额定电压选择。如果发现单片机偶尔重启,用示波器观察VCC跌落波形,多半是电源处理问题而不是代码问题。注意在电机引脚靠近驱动芯片的位置并联100nF陶瓷电容和一个470uF电解电容,能明显减少PWM开关噪声对地平面的影响。
5. 调试技巧:用串口把姿态角和无线帧延迟一起拉出来
写代码时最怕的不是逻辑错,而是无从判断错在哪一步。这两个链路的交点可以用一个串口监视器同时验证:在遥控器端把MPU6050滤波后的pitch、roll和发送的数据打印出来,在车体端把接收到的speed、steer打印出来,中间差多少毫秒就有数了。STM32F103只有一个串口时,可以重定向printf到USART1,使用下面的代码快速输出调试信息:
int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); return ch; }调试时我在遥控器端每100ms打印一帧完整角度,在车载端每100ms打印接收到的PWM值。把两个串口连到电脑不同串口,或者用两个串口转USB模块,就能对着屏幕来回晃动遥控器,观察车端数据是否同步变化。如果车端数据变化但电机不动,问题出在PWM配置或方向引脚;如果车端数据根本不更新,问题出在无线模块配置或帧头校验。
姿态角验证有个技巧:把遥控器平放在桌面上,读取roll和pitch,记录静止值。正常情况下加速度计的噪声应该在0.1g以内,换算成角度误差不超过1度。手拿着快速晃动,pitch变化应该跟手同步,但不会出现剧烈跳变。如果角度在静止时缓慢漂移,说明互补滤波的alpha太小或陀螺仪零点偏置没有校准。零点偏置可以在初始化后读取100次陀螺仪求平均值,存到全局变量,每次更新时减去这个偏移。
最后再排查无线丢包。NRF24L01的自动重发次数寄存器SETUP_RETR可以设置重发延时和次数,我通常写SPI_WriteReg(0x04, 0x1A),即重发延时500us,重发10次。如果车端偶尔抽搐,就把数据帧率从125Hz降到50Hz,去掉delay_ms(8)改为20ms。这个项目最关键的调试心得是:不要在代码里一上来就加各种滤波和融合,先把原始值打印出来确认传感器和无线链路都正常,再一层层加控制逻辑。
本文还有配套的精品资源,点击获取