STM32手势控制智能小车:MPU6050姿态融合与差速转向
2026/9/16 5:09:09 网站建设 项目流程

简介:基于STM32F103C8T6的手势控制小车完整工程,面向嵌入式初学者、物联网爱好者与电子设计竞赛参赛者,聚焦手势识别、2.4G无线通信与电机运动控制的联动实现,可帮助读者快速掌握从传感器数据采集到执行器响应的完整开发链路。资源包共494个文件,压缩后约17.18MB,以Keil工程文件为主,涵盖.c/.h源代码、.uvprojx工程配置、.axf/.hex可执行固件,以及.map链接映射、.lst列表文件等构建中间产物,同时提供keilkill.bat等辅助清理脚本,便于直接打开工程编译、烧录与二次修改。项目源码覆盖STM32F103外设驱动、六轴陀螺仪手势解析(含滤波与阈值判断)、2.4G无线串口收发、电机PWM调速等核心模块,目录结构清晰,能按硬件接口快速定位对应代码;已有1636人学习下载。参考完整工程可系统理解手势控制小车的嵌入式实现,并可结合JTAG/SWD调试接口进行单步调试与性能优化,适合在STM32最小系统板与智能小车底盘上做进一步扩展。

1. STM32手势小车:从“手一晃”到“车一动”的完整链路

手势控制小车大概是STM32入门项目里外观看上去最玄幻、原理拆开最朴素:手上绑一块MPU6050,车体放一块STM32F103,手掌倾斜角度换算成PWM占空比,由两轮差速把左转、右转、前进、停止执行出来。难点反而在完整链路每一环的坑:I2C时序不稳时角度像噪声,滤波没调好数据越走越漂,PWM频率选低了电机嗡嗡响,电源余量不够一启动就复位。下面按自己做两轮差速小车的顺序讲:先定硬件选型和电源架构,再铺开MPU6050数据链路与手势状态机,最后落到定时器PWM和TB6612驱动,附一套能直接抄的初始化和联调思路。这个项目最值得吃的不是“手势”这个噱头,而是把传感器、滤波、状态机、电机控制串成一条完整控制链路的工程方法。

2. 手势小车硬件架构:传感器选型、电机驱动与电源分配

2.1 主控为什么还是STM32F103

手势小车不是吃算力的项目,MPU6050的I2C只开到400kHz,四路PWM加一路SPI给无线模块,STM32F103在72MHz主频下全部从容。选F103更实在的理由是生态:从寄存器手册到库函数例程,从点亮LED到编码器测速,教程密度几乎没有别家能比,正点原子、野火的开发板资料里都能直接搜到对应外设章节,卡在任何环节都能查得到。对比ESP32做同样的事当然不难,但F103的定时器结构更标准,对想理解PWM计数器、编码器接口底层机制的人更友好。

如果只求快速出效果,Arduino加一块电机扩展板也能跑,但占空比和方向逻辑全被封装成黑盒,后面要叠PID速度环时处处受制。芯片具体选C8T6还是ZET6,手柄势小车这个外设规模C8T6足够,64脚封装性价比最高;ZET6多出来的定时器和引脚,留给后续想加OLED显示、矩阵按键这些扩展再换不迟。

2.2 手势传感器三选一:MPU6050、APDS9960与PAJ7620

“手势”在硬件上有三条路线:IMU姿态、光学接近、光学手势识别,选错路线整个项目方向就偏了。APDS9960和PAJ7620的有效识别距离都在10~20cm级别,适合放在车体上做“挥手换挡”“握拳停车”这类离散指令;但手势小车要的是连续转向——手往左歪30°,车就以某个半径左转,歪得越多转得越急,只有MPU6050输出的连续roll/pitch能映射成这种模拟量。所以主流方案是:人手端一块MPU6050,姿态角变成控制量,再通过无线发给车体。

型号类型安装位置输出形态在手势小车里的角色
MPU6050六轴IMU手背/手套三轴加速度、三轴角速度姿态角→速度/转向连续映射
APDS9960光学接近/手势车体固定上下左右挥手中断离散指令,如换挡、急停
PAJ7620光学手势识别车体固定9种手势码离散指令,识别距离上限约15cm

MPU6050自带DMP能直接输出四元数,但对小车这个量级属于杀鸡用牛刀:裸读加速度和角速度,配一节互补滤波就足够,省下DMP初始化那几百行,也不容易踩固件版本坑。顺带说,同样是智能小车,循迹小车读的是线上的一维信息,手势小车读的是三维姿态,后者更接近“人直接操作机器”的形态,这也是它作为毕业设计选题常年热门的原因。

2.3 电机驱动:TB6612与L298N的实际差异

两轮差速车的执行部件是左右两个直流减速电机加一个万向轮,转向靠左右轮速差。驱动芯片常见三个选择:L298N、TB6612FNG、DRV8833。L298N饱和压降2~3V,6V供电到电机只剩4V不到,效率低、发热大,优点是插排接口适合实验室飞线;DRV8833体积小但散热差,堵转时容易触发热保护;TB6612FNG是MOSFET H桥,压降不到0.5V,连续1.2A、峰值2A,对N20减速电机绰绰有余,还带STBY待机脚。

推荐TB6612的理由很直接:弱电系统里每一伏压降都会折成转速损失,而手势小车的转向依赖左右轮速比,两路桥压降不对称的话车会一直画弧,还会被误判成陀螺仪漂移。方向脚和PWM脚的初始化应当分开做,STBY位不拉高整桥不工作:

void Motor_GPIO_Init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // AIN1/AIN2 控制左轮方向, BIN1/BIN2 控制右轮方向 gpio.GPIO_Pin = GPIO_Pin_12 | GPIO_Pin_13; gpio.GPIO_Pin |= GPIO_Pin_14 | GPIO_Pin_15; gpio.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出, 电平摆幅干净 gpio.GPIO_Speed = GPIO_Speed_2MHz; // 方向脚不需要高速翻转 GPIO_Init(GPIOB, &gpio); GPIO_SetBits(GPIOB, GPIO_Pin_12 | GPIO_Pin_14); // 默认设为前进 GPIO_SetBits(GPIOB, STBY_PIN); // STBY=1, 解除待机 }

方向脚用2MHz输出速度足够,反而能减少GPIO翻转时的振铃;STBY是TB6612的总开关,很多“两边电机都不转”的问题最后都查到这里。PWM引脚要复用为定时器通道,不能和方向脚共用一组GPIO配置,否则两个外设互相抢引脚定义,编译能过、功能全乱。

2.4 电源树:一启动就复位,九成是这里的问题

电源分配的核心原则:电机电流不流经逻辑回路。最典型的反面教材是用L298N板载5V稳压给STM32供电,电机起步瞬间总线被拉到4V以下,F103立即复位,表现为“跑着跑着自己重启”。推荐结构是7.4V 2S锂电池直接进TB6612的VM;逻辑侧单独用DC-DC降到5V,再经LDO出3.3V给F103和MPU6050;电机地与逻辑地单点共地,电机引脚旁放100nF瓷片电容加220uF电解。

带PA+LNA的nRF24L01模块发射峰值电流能到100mA量级,如果从F103的3.3V LDO取电,丢包率会明显上升,给它单独一路3.3V更稳。整套物料清单如下表,按这个组合买不会有兼容性问题:

模块推荐型号关键参数备注
主控STM32F103C8T672MHz、3个定时器、I2C/SPI/USART64脚版本,买最小系统板即可
IMUMPU6050±2g、±500dps、I2C 400kHz不用DMP,裸读寄存器
无线nRF24L01+2.4G、SPI、2Mbps选带PA和天线的版本,距离更稳
电机驱动TB6612FNG连续1.2A、峰值2A逻辑侧3.3V直连
电机N20带编码器减速电机减速比1:30、6V编码器为PID留接口
电池2S锂电7.4V500~1000mAh电机与逻辑供电分开

提示:MPU6050默认I2C地址是0x68,AD0拉高后变0x69;焊接前先确认模块AD0的接法,地址写错时I2C读回全是0xFF。

3. 手势识别核心:MPU6050寄存器初始化、角度融合与状态机

3.1 初始化顺序:先复位、等稳定,再配置量程

寄存器初始化顺序直接影响后续读数质量,MPU6050上电后内部时钟需要短暂稳定,立刻读数据会得到全零或0xFF,很多人卡在第一天,其实只是少了一次复位和两个延时。正确顺序如下:

void MPU6050_Init(void) { I2C_WriteReg(0x68, 0x6B, 0x80); // PWR_MGMT_1 写复位位, 全部寄存器回默认 Delay(100); // 等内部逻辑稳定 I2C_WriteReg(0x68, 0x6B, 0x00); // 唤醒, 时钟默认内部8MHz I2C_WriteReg(0x68, 0x19, 0x09); // SMPLRT_DIV=9, 输出100Hz I2C_WriteReg(0x68, 0x1A, 0x04); // CONFIG, DLPF截止20Hz I2C_WriteReg(0x68, 0x1B, 0x08); // GYRO_CONFIG, ±500dps I2C_WriteReg(0x68, 0x1C, 0x00); // ACCEL_CONFIG, ±2g Delay(150); // 等传感器数据有效 }

0x6B是电源管理寄存器,先写复位位再写0唤醒,是规避状态残留的标准做法;0x19是采样率分频器,配合DLPF后1kHz的陀螺内部采样率,实际输出100Hz,也就是10ms一个新角度样本,对手势控制足够。量程选±500dps而不是±250dps,因为人手挥动角速度时不时瞬时超过250°/s,量程不够会削顶,映射到车上就是一段顿挫的转向。DLPF设20Hz滤掉的是手臂肌肉抖动和电机振动的高频噪声,不是人手的真实动作,20Hz以上成分对转向判定没有意义。

3.2 突发读14字节:一次读完整组数据

单个寄存器分开读会引入数据一致性问题,正确做法是从ACCEL_XOUT_H地址连续突发读14字节:

void MPU6050_Read_Raw(int16_t *ax, int16_t *ay, int16_t *az, int16_t *gx, int16_t *gy, int16_t *gz) { uint8_t buf[14]; I2C_ReadBuf(0x68, 0x3B, buf, 14); // ACCEL_XOUT_H地址起连续读 *ax = (buf[0] << 8) | buf[1]; *ay = (buf[2] << 8) | buf[3]; *az = (buf[4] << 8) | buf[5]; *gx = (buf[8] << 8) | buf[9]; *gy = (buf[10] << 8) | buf[11]; *gz = (buf[12] << 8) | buf[13]; // buf[6]/buf[7]是温度寄存器, 跳过 }

突出说明一点:突发读比六次单读更快,关键在数据一致性——单次读期间传感器可能已经更新,AX和GZ可能来自不同时刻,融合时会产生虚假角速度。量纲换算放在融合函数里做:加速度原始值除以16384得到g,陀螺仪除以65.5得到°/s,这两个常数对应3.1里0x08和0x00两行配置,改量程就必须同步改系数,最常见的手势乱跳就是这里没对上。

提示:软件模拟I2C时跑400kHz很容易出错位,先按100kHz调通,再逐步提速率;硬件I2C则确认上拉电阻存在且阻值在4.7k左右。

3.3 互补滤波:一个α就能把角度稳住

卡尔曼在手势识别里当然更“正确”,但调Q/R矩阵的时间足够写完整个状态机了。互补滤波只有一个参数α,原理是陀螺仪短时积分准、长时会漂,加速度计长时稳定、瞬时噪声大,两者在频域互补:

#define ALPHA 0.98f #define RAD2DEG (57.29578f) float roll_angle = 0.0f, pitch_angle = 0.0f; void Angle_Fusion(float ax, float ay, float az, float gx, float gy, float gz, float dt) { float acc_roll = atan2f(ay, az) * RAD2DEG; float acc_pitch = atan2f(-ax, sqrtf(ay * ay + az * az)) * RAD2DEG; roll_angle += gx * dt; // 陀螺仪短时积分 pitch_angle += gy * dt; // 长时信加速度, 短时信陀螺 roll_angle = ALPHA * roll_angle + (1.0f - ALPHA) * acc_roll; pitch_angle = ALPHA * pitch_angle + (1.0f - ALPHA) * acc_pitch; }

α=0.98表示每个周期积分结果只被加速度计修正2%,在100Hz采样下抬手、停住、再抬的完整动作约0.5Hz,这个权重足够跟上节奏。α调小到0.95,角度响应更快但噪声更明显;调大到0.99,输出很平滑但会带上秒级漂移尾巴,这就是常说的“跟手”和“稳”之间的取舍。dt不要写死0.01,用SysTick维护毫秒计数器,diff出的真实间隔除以1000再传进来。角速度单位必须是°/s,否则gx*dt的量纲是乱的,这是滤波结果漂移时第一个要查的地方。

3.4 手势状态机:优先级、死区与连续确认

状态机的输出只有五个:停止、前进、后退、左转、右转,关键在设计优先级和阈值。转向判定优先于前进后退,两个轴上的阈值构成一个死区,小幅晃动手腕不会触发任何动作:

typedef enum { GS_STOP, GS_FWD, GS_BACK, GS_LEFT, GS_RIGHT } GestureState; GestureState Gesture_Classify(float roll, float pitch) { if (fabsf(roll) > 30.0f) // 转向优先于前进/后退 return (roll > 0.0f) ? GS_RIGHT : GS_LEFT; if (pitch > 20.0f) return GS_BACK; if (pitch < -20.0f) return GS_FWD; return GS_STOP; }

判定顺序先roll后pitch,手同时既歪又前压时先按转向处理,避免转弯动作被前进指令吞掉。30°和20°只是经验起点,最终值按第5章的标定流程落定,但方向必须明确:判定要“刻意”,用户小幅晃手腕不应触发指令,死区等于天然防抖。滤波后的角度已经在频率上平滑过,再叠加时间维度的确认就更稳,常见做法是连续5帧(50ms)得到同一状态才切换输出,误触发的概率能压到很低。

优先级判定条件输出命令执行端动作
1roll > +30°右转左轮保持、右轮降速
1roll < -30°左转右轮保持、左轮降速
2pitch > +20°后退两轮反转,限50%占空比
2pitch < -20°前进两轮正转,70%占空比
3其余区间停止两轮惰转

4. 小车执行端:定时器PWM、方向逻辑与差速转向

4.1 TIM3四路PWM:10kHz的工程折中

PWM频率不是越高越好。低于5kHz,耳朵能听到明显的电机啸叫,电流纹波还会干扰编码器信号;太高则占空比分辨率下降。10kHz是电机驱动参考设计里常见的起点,TIM3四通道刚好带两路电机:

void Motor_PWM_Init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tim; TIM_OCInitTypeDef oc; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); // CH1/CH2复用PA6/PA7(左轮), CH3/CH4复用PB0/PB1(右轮) GPIO_StructInit(&gpio); gpio.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; GPIO_Init(GPIOB, &gpio); // 72MHz/(71+1)=1MHz, 1MHz/(99+1)=10kHz tim.TIM_Prescaler = 71; tim.TIM_Period = 99; tim.TIM_CounterMode = TIM_CounterMode_Up; tim.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, &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(TIM3, &oc); TIM_OC2Init(TIM3, &oc); TIM_OC3Init(TIM3, &oc); TIM_OC4Init(TIM3, &oc); TIM_Cmd(TIM3, ENABLE); }

分频器71把72MHz降到1MHz计数时钟,自动重装值99让计数器100个周期翻转一次,PWM频率=1MHz/100=10kHz。占空比由CCR决定,CCR=70就是70%左右,1%级别的分辨率对手势控制足够平滑。如果希望20kHz,把ARR改成49,但分辨率减半;改完先示波器量一下再上车,很多事情是频率选型造成的,不是代码问题。

4.2 方向引脚与PWM的四种组合

TB6612每个通道有两个方向脚加一个PWM脚,行为表要先背下来:

AIN1AIN2PWMA电机行为
01PWM正转
10PWM反转
11任意刹车(绕组短路)
00任意惰转(滑行)

方向逻辑落到代码上就是两组GPIO的位操作,注意切换方向前先确认PWM输出已处于低占空比,避免H桥上下管直通产生瞬间大电流:

void Motor_SetDir(uint8_t ch, uint8_t dir) { if (ch == MOTOR_LEFT) { GPIO_WriteBit(GPIOB, AIN1, (dir == DIR_FWD) ? 1 : 0); GPIO_WriteBit(GPIOB, AIN2, (dir == DIR_FWD) ? 0 : 1); } else { GPIO_WriteBit(GPIOB, BIN1, (dir == DIR_FWD) ? 1 : 0); GPIO_WriteBit(GPIOB, BIN2, (dir == DIR_FWD) ? 0 : 1); } }

前进的定义取决于电机线序,装好车发现“前进”变“后退”,交换AIN1/AIN2接线即可,不用改状态机。“刹车”靠两个方向脚同时置位让绕组短路,停止响应比惰转快,但长时间堵转电流大,手势小车只在状态切换时用几百毫秒刹车然后切回惰转。那个STBY位再说一遍:必须保持高电平,很多“电机完全不转”并不是PWM没配好,而是STBY悬空。

4.3 速差转弯:半径连续可调

两轮差速转向的基本关系是左右轮速比决定转弯半径,不是左右轮转不转。新手常见错误是“左转=左轮反转”,那会变成原地掉头,速度突变还会让万向轮剧烈跳动;正确做法是内侧轮降速、外侧轮保持,车走一条弧线。

指令左轮占空比右轮占空比轨迹
直行70%70%直线
左转35%70%半径0.3~0.5m弧线
右转70%35%半径0.3~0.5m弧线
原地左转反转70%正转70%原地旋转

要连续量,用roll的绝对值做内侧轮降速比例,但起点要对齐状态机阈值:inner = 70 × (1 − 0.6 × min((|roll| − 30°) / 30°, 1))。roll=30°时内侧轮不减速,刚进入转向判定时车保持直行平稳过渡;roll=60°时内侧轮降到28%,手歪得越多转得越急。这个公式在状态机判定为转向后再执行,和3.4是两级结构:状态机决定“转不转”,这个公式决定“转多少”。

4.4 编码器速度环:手势小车PID的最小实现

开环PWM对演示足够,但N20减速电机个体差异、轮胎打滑、地形起伏都会让两轮实际转速偏离,车就画弧。带编码器的电机可以跑增量式PID:

#define ENCODER_TIM_MS 10 // 10ms采样一次编码器计数 int16_t Speed_PID(int16_t target, int16_t current) { static int16_t err_prev = 0, integral = 0; float kp = 2.0f, ki = 0.6f, kd = 0.5f; int16_t err = target - current; integral += err; if (integral > 400) integral = 400; // 积分限幅, 防堵转饱和 if (integral < -400) integral = -400; int16_t out = (int16_t)(kp * err + ki * integral + kd * (err - err_prev)); err_prev = err; return out; // 输出叠加到开环占空比上 }

target和current的单位是“每10ms的编码器脉冲数”,由定时器编码器模式或外部中断计数得到。PID输出不是直接给PWM占空比,而是在开环占空比基础上做修正,这样即使参数没调好,车也不会完全失控。kp从2.0起步,出现等幅振荡就降下来;ki必须配积分限幅,否则堵转时积分无限累计,障碍解除后车会猛冲。编码器AB相接反的典型症状是PID正反馈,车越跑越偏,验证方法在5.3里。

5. STM32手势小车联调:无线链路、阈值标定与三个常见故障

5.1 无线链路:手势端和车体端各一块F103

最常见的手势小车结构是两块板:手势端挂MPU6050和nRF24L01,车体端放驱动和另一块nRF24L01。数据包压缩成8字节:

typedef struct { uint8_t head; // 0xAA帧头, 收端用于对齐 int8_t roll; // 四舍五入到整数度, -90~+90 int8_t pitch; // 四舍五入到整数度, -90~+90 uint8_t cmd; // 手势命令码, 保留给急停/标定 uint8_t chksum; // head+roll+pitch+cmd的累加和 } Gesture_Pkt;

角度量化到int8_t,损失的小数对10°级别的阈值判定毫无影响,但省出的字节能减少空中传输时间。nRF24L01的EN_AA自动重传打开后,多数丢包能在链路层补偿;观察MAX_RT中断位,如果频繁置位说明信道干扰大,换频道比重传次数更有效。发送频率30~50Hz就够,不必和手势端100Hz的角度更新对齐。

5.2 阈值标定:每个人歪手的角度不一样

固定30°阈值只适合开发者自己。给不同的人演示时,手腕活动度差异很大,有人最多只能歪25°。常见做法是做标定模式:按住按键,分别保持“停车、向左、向右、向前”各3秒,代码记下每个动作的roll/pitch均值,转向阈值取0.6×roll_left + 0.4×roll_neutral,前进阈值取0.6×pitch_fwd + 0.4×pitch_neutral。标定结果写入F103内部Flash最后一页,上电先读,读不到再用默认值。这套流程不到两分钟,比每次改宏定义重刷固件快得多。

5.3 三个常见故障的定位顺序

故障一,电机一启动整车复位:先量3.3V在启动瞬间的跌落,低于2.8V就会触发复位。治本方案是电机与逻辑分电源,治标方案是加大母线电容。

故障二,角度乱跳但手没动:先断开车轮只转传感器,排除电机振动传导;再查I2C上拉电阻,模块没上拉或上拉10k过大,400kHz下上升沿会出错位数据,换4.7k上拉到3.3V通常能解决。

故障三,车走不直:开环状态先对调左右轮PWM,偏转方向跟着对调就是电机个体差异,用4.4的PID拉齐;若PID已生效,手转左轮看编码器计数值应为正向递增,反了就把AB两相在初始化里交换,不用改硬件线。

链路从传感器到无线到车轮全通之后,把这次标定得到的零偏值和阈值写进Flash,下次上电直接读,项目就从调试态切到使用态。

本文还有配套的精品资源,点击获取

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

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

立即咨询