STM32手势控制小车:C/C++驱动PAJ7620U2与状态机实现

发布时间:2026/9/16 7:37:11
STM32手势控制小车:C/C++驱动PAJ7620U2与状态机实现 简介这是一套基于STM32与MPU6050的手势控制小车完整工程源码面向电子竞赛、毕业设计及嵌入式进阶学习者解决从手势信号采集、姿态解算到电机控制的全流程设计问题。项目利用MPU6050的陀螺仪与加速度计捕捉手势动作经STM32解析后输出控制指令涵盖C/C底层驱动、数字滤波、PID运动控制、液晶显示、闪存存储及无线扩展等完整功能模块。压缩包约21.63MB共379个文件以C语言源码、编译产物、工程配置文件为主并包含调试映射等辅助文件便于直接导入开发环境查看、编译与修改。目前已吸引517人学习下载。代码结构清晰、注释较完整保留传感器初始化、姿态解算、滤波去噪、PID控制等关键算法可作为智能车或手势交互项目的参考模板帮助快速复现实验并迁移到自己的控板方案。1. 手势控制小车为什么先用 C/C 而不是图形化编程一个手掌从传感器上方划过小车向前、后退、左转、右转整个过程不碰遥控器也不依赖摄像头做视觉识别。这种手势小车本质上是一个「传感器输入 状态机决策 电机执行」的嵌入式闭环系统而 C/C 正是这个闭环里最直接的语言选择。你不需要跑操作系统也不需要等待 Python 解释器在裸机上启动一次 I2C 读取、一次 PWM 占空比修改在 C 里就对应几行寄存器和结构体操作。对于已经有 C 语言基础但还没把指针、结构体和硬件寄存器串起来的人这是很好的工程练习对于想把手势识别算法滤波、去抖、状态转移塞进单片机的从业者C/C 的底层控制力和可预测性是最稳的。下面这套方案以 STM32 为主控、PAJ7620U2 手势传感器、TB6612 电机驱动为例展开换成 ESP32 或 Arduino 时改动只在 HAL 层。2. 手势小车的传感器选型与 C 驱动层搭建2.1 手势传感器选型PAJ7620U2 还是 APDS-9960手势识别传感器的选择直接决定整个手势小车项目的代码复杂度。目前最常用的两个方案是 PAJ7620U2 与 APDS-9960二者都通过 I2C 接口输出手势识别结果但在“识别方式”上差异很大。PAJ7620U2 内部集成了光学矩阵和九种手势识别算法它直接输出上、下、左、右、前、后、顺时针、逆时针、挥手这些手势编号不需要在 MCU 上跑任何算法C 代码里只要读两个寄存器就能拿到结果。APDS-9960 输出的是原始光强数据它提供方向性的色光传感器没有手势结果只能读取各个方向的红外反射强度需要在控制器里自己计算方向判定逻辑。对于 C/C 开发者来说PAJ7620U2 相当于把模式识别工作前置完成了APDS-9960 则把算法空间留给你自己发挥。注意PAJ7620U2 返回的上、下手势编号是相对于传感器自身坐标系而不是小车的前进方向。传感器安装方向不同同一个“向上挥手”在代码里可能对应前进也可能对应后退。这意味着驱动函数里拿到手势枚举值后需要做一次方向映射而不是直接拿来驱动电机。我一般会在结构体里保存一个gestureOrientationOffset字段在初始化时按实际安装角度做偏移校正这个映射在第三章的驱动层里体现。2.2 I2C 驱动用 C 写最小手势读取函数无论是 STM32 还是 ESP32驱动 PAJ7620U2 的核心都是 I2C 通信。先看一段最小可用代码它在 STM32 HAL 库环境下读取手势结果。#include paj7620.h #define PAJ7620_I2C_ADDR 0x73 // 7-bit 地址8-bit 写入时为 0xE6 #define PAJ7620_BANK_SEL 0xEF #define PAJ7620_REG_GESTURE_RESULT 0x43 // 手势结果寄存器 // 从传感器读一个字节 static uint8_t paj7620_read_reg(uint8_t reg) { uint8_t val 0; HAL_I2C_Mem_Read(hi2c1, PAJ7620_I2C_ADDR 1, reg, I2C_MEMADD_SIZE_8BIT, val, 1, 100); return val; } // 初始化 PAJ7620U2 uint8_t paj7620_init(void) { HAL_Delay(100); // 软件复位 paj7620_write_reg(PAJ7620_BANK_SEL, 0x00); paj7620_write_reg(0x00, 0x00); // 切换到寄存器 bank 1使能手势识别 paj7620_write_reg(PAJ7620_BANK_SEL, 0x01); paj7620_write_reg(0x42, 0x41); // 允许手势中断 paj7620_write_reg(0x43, 0x17); // 使能全部手势 // bank 0 下配置工作模式 paj7620_write_reg(PAJ7620_BANK_SEL, 0x00); paj7620_write_reg(0x41, 0x00); return 0x00; } // 读取当前手势编号 uint8_t paj7620_read_gesture(void) { return paj7620_read_reg(PAJ7620_REG_GESTURE_RESULT); }这段代码的逻辑分三层。第一层是底层 I2C 内存读HAL_I2C_Mem_Read是 STM32 HAL 的标准接口它把寄存器地址和读数据两个步骤合并成一次带内存地址的 I2C 事务比直接调HAL_I2C_Master_Transmit再Receive更简单可靠。第二层是初始化序列PAJ7620U2 上电后必须先软件复位再通过 bank 选择寄存器 0xEF 切换到 bank 1 配置手势使能位最后切回 bank 0 开始采样。第三层是读手势结果0x43 是手势结果寄存器返回值直接对应手势编号表。提示paj7620_write_reg函数在代码中未展开实现方式与 read 对称只是把HAL_I2C_Mem_Read换成HAL_I2C_Mem_Write即可。务必保证延时足够PAJ7620U2 上电后需要至少 100ms 稳定时间否则后续读写都可能失败。2.2.1 初始化序列的坑很多人的手势小车第一次上电后读到的全是 0xFF不是传感器坏了而是初始化顺序不对。PAJ7620U2 的寄存器按 bank 分组0xEF 寄存器决定当前访问的是 bank 0 还是 bank 1。bank 0 存放中断标志和手势结果bank 1 存放使能配置。初始化时如果先写 bank 1 的 0x43 再切到 bank 0等于白写。正确顺序是复位 - 切 bank 1 - 使能手势 - 切回 bank 0。这个顺序问题在寄存器手册里写得很隐晦C 代码里却不难处理只要把寄存器写入封装成一个带 bank 自动切换的函数问题就消失。2.3 驱动层参数调优参数推荐值说明传感器 I2C 速率100kHz避免高速模式下 SCL 信号质量差导致偶发读取错误手势去抖间隔50ms同一手势在 50ms 内的重复读取只算一次动作手势超时复位时间500ms超过 500ms 无新手势状态机强制回到空闲态PWM 频率10kHz电机驱动工作在 10kHz 时噪声最小避免产生人耳可听见的啸叫I2C 速率是很容易被忽略的一项。PAJ7620U2 手册标称最高 400kHz但手势小车的供电环境通常比较嘈杂电机启停瞬间会拉低电源电压I2C 上拉电阻的供电随之波动。用 400kHz 时偶尔会读到中间态数据比如手势编号变成 0x00 和 0x03 之间的某个非法组合用 100kHz 能大幅降低这种问题。单次读取手势结果只有 1 字节100kHz 下耗时不到 0.5ms性能完全够用。3. 手势到动作的映射状态机与 PWM 控制3.1 状态机分层去抖要放在手势判定之前手势从“被传感器识别”到“小车执行动作”中间必须经过状态机过滤。直接把手势编号交给电机驱动会面临两个问题一是手势识别本身有噪声连续几帧返回不同结果二是传感器在手掌停留时会反复触发同一个手势编号。合理分层是这样的底层读手势编号中层做去抖和连续确认上层做动作映射最外层是电机驱动。typedef enum { CAR_STATE_IDLE, CAR_STATE_FORWARD, CAR_STATE_BACKWARD, CAR_STATE_LEFT, CAR_STATE_RIGHT, CAR_STATE_STOP } car_state_t; typedef enum { GESTURE_NONE 0x00, GESTURE_UP 0x01, GESTURE_DOWN 0x02, GESTURE_LEFT 0x03, GESTURE_RIGHT 0x04, GESTURE_FORWARD 0x05, GESTURE_BACKWARD 0x06 } gesture_t; car_state_t car_current_state CAR_STATE_IDLE; gesture_t last_gesture GESTURE_NONE; uint32_t gesture_confirm_cnt 0; // 手势确认连续 N 帧相同才认为有效 gesture_t gesture_filter(gesture_t raw) { if (raw last_gesture) { gesture_confirm_cnt; } else { last_gesture raw; gesture_confirm_cnt 0; } if (gesture_confirm_cnt 3) { return raw; } return GESTURE_NONE; } // 手势到小车状态的映射 car_state_t gesture_to_state(gesture_t gesture) { switch (gesture) { case GESTURE_UP: return CAR_STATE_FORWARD; case GESTURE_DOWN: return CAR_STATE_BACKWARD; case GESTURE_LEFT: return CAR_STATE_LEFT; case GESTURE_RIGHT: return CAR_STATE_RIGHT; default: return CAR_STATE_STOP; } }gesture_filter实现的是“连续帧确认”消抖只有连续读到三次相同的手势数值才认为该手势是用户的有意操作避免单帧毛刺误触发。gesture_confirm_cnt是计数变量每次主循环调用一次不需要额外的时间戳在裸机和 RTOS 环境下都能工作。gesture_to_state是纯映射逻辑不牵扯硬件方便做单元测试。注意动作语义的设计很多新手直接把手势“向上”映射为“前进”但实际小车上传感器可能横着装此时“向上”对应的是车身的左或右。这里的解决方式是在映射前增加一个安装角度偏移查询表把传感器坐标转成车身坐标。上面代码里我为了展示核心逻辑省略了这个偏移实际工程中gesture_to_state传入的应该是经过偏移校正后的手势值。3.2 C 封装用枚举和回调实现小车逻辑与硬件分离当项目从“跑通”走向“可维护”C 的全局变量和 switch-case 就会变得失控。此时用一个 C 类把手势小车封装成高内聚模块是正确方向。手势小车的业务逻辑天然适合抽象成事件驱动硬件产生事件业务层消费事件。#include functional enum class Gesture : uint8_t { None 0x00, Up, Down, Left, Right, Forward, Backward }; enum class CarAction : uint8_t { Stop, Forward, Backward, TurnLeft, TurnRight }; class GestureCar { public: using MotorDriver std::functionvoid(CarAction action, uint8_t pwm_left, uint8_t pwm_right); explicit GestureCar(GestureSensor sensor, MotorDriver driver) : sensor_(sensor), driver_(std::move(driver)) {} void Tick() { Gesture raw sensor_.ReadGesture(); Gesture filtered Filter(raw); if (filtered Gesture::None) { return; } CarAction action Map(filtered); // 左右轮占空比分别计算支持差速转弯 uint8_t pwm_l (action CarAction::TurnLeft) ? 40 : 80; uint8_t pwm_r (action CarAction::TurnRight) ? 40 : 80; driver_(action, pwm_l, pwm_r); } private: GestureSensor sensor_; MotorDriver driver_; Gesture last_gesture_ Gesture::None; uint8_t confirm_count_ 0; Gesture Filter(Gesture raw) { if (raw last_gesture_) { confirm_count_; } else { last_gesture_ raw; confirm_count_ 0; } return confirm_count_ 3 ? raw : Gesture::None; } CarAction Map(Gesture gesture) const { switch (gesture) { case Gesture::Up: return CarAction::Forward; case Gesture::Down: return CarAction::Backward; case Gesture::Left: return CarAction::TurnLeft; case Gesture::Right: return CarAction::TurnRight; default: return CarAction::Stop; } } };这个类设计把驱动函数以std::function的形式注入Tick()作为周期调用入口外部可以用定时器中断或者主循环驱动。这样手势小车的算法逻辑完全不依赖具体的单片机型号和电机驱动芯片只要实现一个GestureSensor接口就能在 STM32、ESP32 甚至 PC 上跑同一套逻辑做仿真验证。转身的时候左右 PWM 值是否相同上述代码里转弯时给了 40 和 80 的固定差速具体数值取决于电机特性。如果小车两个驱动轮转速差异明显需要调这套值。我习惯把差速比做成构造参数这样换电机时不用改类内部逻辑。3.3 电机响应参数速查动作左轮 PWM右轮 PWM持续时间说明前进8080常驻直至下一个手势直线行驶后退6060常驻PWM 稍小防止起步打滑左转4080300ms差速转弯半径较小右转8040300ms差速转弯刹车00立即TB6612 的刹车位是低电平而非高阻PWM 值从 0 到 100代表占空比百分比。实际电机的 PWM 线性区间不在 0 到 100很多直流电机在占空比低于 30 时根本转不起来高于 90 时转速差异不明显。所以我用了 4080 这个区间留出调整余量。刹车时 TB6612 的 AIN1/AIN2 两个引脚都需要接地而不是让其中一个为高、另一个为低后者是惰性停机小车会靠惯性滑行。4. 调试与排错串口日志、波形和常见故障4.1 串口输出原始手势值先看数据再谈算法手势小车调试的第一步永远不是调电机而是确认传感器读数正确。写一个简单的串口输出函数把每次读取到的手势编号和连续计数打印出来。#include stdio.h #include stm32f1xx_hal.h extern UART_HandleTypeDef huart1; void debug_gesture_log(uint8_t raw, uint8_t filtered) { char buf[64]; int len snprintf(buf, sizeof(buf), raw0x%02X filtered%d\r\n, raw, filtered); HAL_UART_Transmit(huart1, (uint8_t*)buf, len, 100); }输出的时间戳也很重要加上HAL_GetTick()可以定位“哪个时刻出现了误触发”。在日志里看到 raw 从 0x00 跳到 0xFF 再回到 0x00说明 I2C 读取出错看到 raw 稳定在 0x03 但连续计数不到 3说明手掌停留时间太短可以适当调小确认阈值。这块配合 VSCode 配置 C/C 环境做调试很方便。用 VSCode 打开工程配置launch.json里cortex-debug插件连接 ST-Link可以直接在gesture_filter函数里打断点观察gesture_confirm_cnt变量的变化。这比纯用串口日志直观得多尤其是怀疑状态机跳转条件写错的时候。4.2 三个高频故障的定位路径第一个高频故障就是传感器初始化失败。现象是串口打印 I2C 读写超时代码卡在paj7620_init的读寄存器步骤。先量 SCL 和 SDA 引脚的静态电平正常时因为有上拉电阻两条线都应该稳定在 3.3V。如果 SCL 正常 SDA 只有 1V多半是接线接触不良如果两条线都是 0V检查是否忘记开启上拉。PAJ7620U2 模块通常自带 4.7k 上拉但如果你用的杜邦线超过 20cm建议外接 2.2k 电阻增强驱动能力。第二个故障是电机有反应但小车不走直线。两个电机转速不一致导致手势“前进”时小车向右偏。这在硬件上很难完全消除只能通过软件微调。方法是在 CAN 总线或串口调试模式下让小车前进观察偏转方向然后给较慢的那个轮子增加 510 的 PWM 补偿值。补偿值建议做成宏定义在代码头部集中管理。第三个故障是手掌在传感器上方晃动时小车不停顿地切换动作。这是去抖参数和传感器灵敏度不匹配导致的。PAJ7620U2 的检测距离一般在 10~20cm手距离传感器太近或者太快中间帧会被跳过连续计数逻辑失效。解决方式是缩短手势确认窗口从连续 3 帧确认改为连续 2 帧确认并把时间窗口限制在 150ms 内超时则清空计数。4.3 主循环优先级的坑手势小车的代码在裸机上通常是一个while(1)大循环。循环里如果既跑传感器读取又跑电机控制还有一个小的软件延时就要小心时序耦合。手一划过来的时间可能只有 200msI2C 读 1 字节在 100kHz 下花费约 1ms这是很宽裕的但如果电机控制代码里有HAL_Delay(50)连续延时超过 100ms手势快速滑动时中间帧就丢了。我一般会把HAL_Delay全部替换成基于HAL_GetTick()的非阻塞延时主循环的耗时就能稳定在几毫秒以内。这也是 C/C 手势小车项目里常见的一个 C 八股考点阻塞延时与状态机时间基准的关系。考试时可能只问“延时函数有什么副作用”实际项目里它直接决定手势识别率。5. 进阶让手势小车识别更稳的三个技巧5.1 滑动窗口投票替代连续计数连续 N 帧相同的确认方式对手势滑过时间敏感。滑动窗口投票法更适合手势速度不稳定的场景维护一个长度为 5 的环形缓冲区每帧把新数值压入弹出最旧的统计缓冲区里出现次数最多的手势值。只要该值出现次数达到 3 次以上就认为识别成功。这个算法对速度变化不敏感因为它在窗口时间内容忍个别帧异常。C 实现也就是一个固定数组加两个指针的移位操作内存占用可预估。5.2 动态阈值处理检测距离漂移PAJ7620U2 这类光学传感器会随环境光强变化出现灵敏度漂移。典型表现是上午手掌离传感器 15cm 能识别到了傍晚同样的距离识别率明显下降。一个轻量做法是定期采样“空闲时的基准值”把手势判定阈值设为基准值加固定偏移。具体到 PAJ7620U2它自身没有提供光强数值寄存器此时可以切换到 APDS-9960 并用原始红外强度数据实现动态基线。若不想换硬件另一种折中方案是限制手势识别距离在 10cm 以内并保证小车使用环境光照稳定。5.3 用双传感器交叉验证降低误触发单传感器的“挥手方向正确但不想触发小车动作”这类误触发本质上是算法无法区分有意手势与无意擦过。如果小车前后各装一个 PAJ7620U2且两个传感器的识别结果方向一致才执行动作误触发率能显著下降。实现时两个传感器共用一条 I2C 总线地址通过模块上的 A0/A1 引脚设置。代码层面就是原来的GestureCar::Tick()里多读一个传感器把两次结果做与操作。代价是主循环耗时从 1ms 增到 2ms对手势小车来说完全可以接受。交叉验证通过后再叠加动作执行时的 LED 状态提示用户就能明确知道系统是否处于可操控状态。本文还有配套的精品资源点击获取