
简介基于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种手势码离散指令识别距离上限约15cmMPU6050自带DMP能直接输出四元数但对小车这个量级属于杀鸡用牛刀裸读加速度和角速度配一节互补滤波就足够省下DMP初始化那几百行也不容易踩固件版本坑。顺带说同样是智能小车循迹小车读的是线上的一维信息手势小车读的是三维姿态后者更接近“人直接操作机器”的形态这也是它作为毕业设计选题常年热门的原因。2.3 电机驱动TB6612与L298N的实际差异两轮差速车的执行部件是左右两个直流减速电机加一个万向轮转向靠左右轮速差。驱动芯片常见三个选择L298N、TB6612FNG、DRV8833。L298N饱和压降2~3V6V供电到电机只剩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); // STBY1, 解除待机 }方向脚用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电解。带PALNA的nRF24L01模块发射峰值电流能到100mA量级如果从F103的3.3V LDO取电丢包率会明显上升给它单独一路3.3V更稳。整套物料清单如下表按这个组合买不会有兼容性问题模块推荐型号关键参数备注主控STM32F103C8T672MHz、3个定时器、I2C/SPI/USART64脚版本买最小系统板即可IMUMPU6050±2g、±500dps、I2C 400kHz不用DMP裸读寄存器无线nRF24L012.4G、SPI、2Mbps选带PA和天线的版本距离更稳电机驱动TB6612FNG连续1.2A、峰值2A逻辑侧3.3V直连电机N20带编码器减速电机减速比1:30、6V编码器为PID留接口电池2S锂电7.4V500~1000mAh电机与逻辑供电分开提示MPU6050默认I2C地址是0x68AD0拉高后变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_DIV9, 输出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四路PWM10kHz的工程折中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/(711)1MHz, 1MHz/(991)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/10010kHz。占空比由CCR决定CCR70就是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))。roll30°时内侧轮不减速刚进入转向判定时车保持直行平稳过渡roll60°时内侧轮降到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; // headrollpitchcmd的累加和 } 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下次上电直接读项目就从调试态切到使用态。本文还有配套的精品资源点击获取