恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
STM32+PS2手柄控制四电机小车:硬件接线与整体调度完整方案
首页
资讯中心
/
STM32+PS2手柄控制四电机小车:硬件接线与整体调度完整方案
STM32+PS2手柄控制四电机小车:硬件接线与整体调度完整方案
发布时间:2026/10/17 4:09:23
简介这是一份以STM32RCT6为核心、PS2手柄遥控四轮全向轮小车的完整工程资料面向嵌入式入门开发者与STM32项目实战爱好者解决手柄输入解析、多电机协调控制等问题。工程包含PS2通信解析、PWM调速、全向轮运动逻辑等关键代码涵盖HAL库与标准外设库的使用方式并提供Keil工程文件和一键编译清理脚本便于直接打开、编译和烧录验证。资源共233个文件以C语言源码、头文件、编译生成文件为主另有uvprojx工程配置、axf/hex可执行文件、bat脚本及txt说明文档整体约8.28MB。目前已有746人学习下载。资料目录结构划分清晰结合源码与说明可帮助读者理解BSP驱动、USER应用层与系统文件的组织关系适合对照PS2手柄通信和4路电机控制实例进行项目复现。1. 用 PS2 手柄控一台 4 电机小车硬件接线到整体调度的完整方案STM32 加 PS2 手柄控制 4 电机小车是很多做智能车、机器人底盘的人绕不开的组合。PS2 无线手柄这套东西看起来老但用在遥控小车上其实非常合适按键多、摇杆双轴模拟量、无线距离够用而且 STM32 的标准库和 HAL 库都有成熟案例可参考。这篇文章把一整套做过的方案拆开讲清楚从电机驱动怎么选、PS2 协议怎么读、按键映射怎么做到最终怎么让前后左右和速度控制像个正常的车而不是玩具。刚入门的朋友照着接线和代码能跑通基础遥控功能老手也能看下电机响应曲线调参和通信时序容错这些细节怎么处理。整个项目只用了一块 STM32F103C8T6 最小系统板、一个 PS2 手柄接收器、两块 TB6612 驱动板加 4 个 N20 减速电机属于典型的小成本高性能折腾方案。2. 硬件架构与接线主机引脚分配、供电分组和驱动板选型2.1 系统整体架构从手柄信号到电机转动的链路整个系统的信号链路是用户拨动摇杆或按键PS2 手柄把键值通过 2.4GHz 无线发给接收器接收器再把数据用 SPI 协议发给 STM32STM32 解析出通道值通过定时器 PWM 输出到 TB6612 的 AIN/BIN 引脚TB6612 对应通道输出电压驱动 4 个 N20 电机。供电这块最容易翻车。4 个 N20 电机堵转时电流能到 1.5A 以上如果和 STM32 共用 LDO 供电CPU 直接复位这就是很多人说「一开电机就重启」的根源。我一般把系统拆成三组电源11.1V 3S 锂电池直接给 TB6612 的 VM 供电STM32 的 5V 从锂电经过降压模块取电PS2 接收器直接吃 STM32 板载 3.3V。逻辑地和功率地单点相连驱动板的地线尽量粗短否则环路电阻导致的地弹会让 SPI 数据出错到时候按键随机乱跳很难排查。2.2 STM32 引脚分配与接线图我用的引脚分配如下在标准库工程里都是一些常规的 GPIO 复用操作模块引脚用途PS2 接收器 DATPA6SPI1 MISO输入PS2 接收器 CMDPA7SPI1 MOSI输出PS2 接收器 CSPA4片选PS2 接收器 CLKPA5SPI1 时钟左前电机 PWMPA8TIM1_CH1右前电机 PWMPA9TIM1_CH2左后电机 PWMPA10TIM1_CH3右后电机 PWMPA11TIM1_CH4电机 1 AIN1/AIN2PB0/PB1方向控制电机 2 BIN1/BIN2PB2/PB10方向控制接线图就是两路 TB6612 的 VM 接锂电正极GND 接功率地STBY 引脚拉高让驱动模块待机电机接线端子直接插 N20 电机引线。如果发现某个电机正转和反转逻辑反了不用改硬件直接在软件层面对调 AIN1 和 AIN2 的翻转顺序就行这是非常省事的做法。2.3 TB6612 与 L298N 的实测选型差异老教程里很多人推荐 L298N但 L298N 内部是三极管 H 桥饱和压降能到 2V 多4 个 N20 电机一跑电池电压掉得相当快。TB6612 是 MOSFET 架构导通电阻小得多同样的电压下电机转速明显更快。我先后两款驱动板换着用过实测同一块电池、同样代码TB6612 下小车空载速度能多出 12% 左右。更重要的是 TB6612 的 PWM 频率可以做到 20kHz 以上L298N 超过 10kHz 就容易发热而且在低频下能明显听到电机啸叫。对追求丝滑加速体验的车友来说这个差距体验上其实还挺明显。如果你的电机是堵转电流特别大的强磁电机用 TB6612 超载跑是可能过热保护的要换成带电流采样引脚的驱动方案比如 DRV8701 加外部 H 桥。不过常规项目里 N20 减速电机配 TB6612 是黄金搭配性价比拉满。3. 核心模块拆解PS2 手柄通信协议与按键解析的实现3.1 PS2 手柄通信协议SPI 时序和 9 字节帧格式PS2 手柄接收器对外暴露的就是一个 SPI 从机接口CLK 由主机STM32提供频率上限约 500kHz。整个通信流程是 CS 拉低后主机发一字节命令给 CMD同时从 DAT 读一字节数据。一帧数据 9 个字节格式固定字节序号方向含义0x01主机→从机0x01 表示进入数据读取模式0x02主机→从机0x42 读取数字按键状态0x03主机→从机0x00 查询数据0x04从机→主机0x5A 表示数据就绪0x05从机→主机按键状态字节 10x06从机→主机按键状态字节 20x07从机→主机右摇杆 X 轴0x08从机→主机右摇杆 Y 轴0x09从机→主机左摇杆 X/Y 轴关键点在于发送和接收是同时进行的主机在发送命令字节的那个 CLK 的上升沿从机同时在 DAT 上输出对应数据。通信结束后 CS 拉高。如果读取速度太慢或者时序不稳定PS2 手柄会进入配置模式此时读到的一大堆字节都是 0xFF逐渐开始的判断逻辑是老玩家最怕遇到的现象——出现了就要重新上电初始化。3.2 STM32 GPIO 模拟 SPI 读 PS2 数据的完整代码我没直接用硬件 SPI 来做是因为STM32 的硬件 SPI 的波特率预设最低档也偏高很多人遇到通信不稳定就把锅甩给硬件 SPI宁可自己用 GPIO 脚一点一点掏时钟。下面是标准库的 GPIO 模拟实现把关键部分贴出来// ps2_read 函数GPIO模拟SPI读取一帧手柄数据 uint8_t PS2_ReadRaw(uint8_t* rx_buf) { uint8_t i, j, bit; uint8_t tx_cmd[9] {0x01, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t count 0; PS2_CS_LOW(); // 片选拉低开始通信 for (i 0; i 9; i) { for (j 0; j 8; j) { // 数据位从高位开始发送 bit (tx_cmd[i] (7 - j)) 0x01; if (bit) { PS2_CMD_HIGH(); } else { PS2_CMD_LOW(); } // 时钟上升沿从机在此时采样CMD并输出DAT PS2_CLK_HIGH(); delay_us(10); PS2_CLK_LOW(); delay_us(10); } } // 按同样的时序读出从机返回的数据 for (i 0; i 9; i) { for (j 0; j 8; j) { PS2_CLK_HIGH(); delay_us(10); if (PS2_DAT_READ()) { rx_buf[i] | (0x80 j); } PS2_CLK_LOW(); delay_us(10); } } PS2_CS_HIGH(); return count; }这里的核心思路是先把发送命令的时序走一遍把 DAT 线上的返回数据也存下来但实际处理时更常见的做法是边发边读。因为 SPI 本来就是全双工发送一字节的同时就能读到一字节。上面的代码是分成两步的简化写法虽然慢了一点但逻辑非常直观适合第一次接触这个协议的人去理解。参数容易出错的地方是 delay_us(10)。如果 STM32 主频是 72MHzGPIO 翻转一次大概要几个周期delay 太小会导致通信时钟频率太高而丢数据太大又会让手柄进入低功耗模式。实测在 72MHz 主频下CLK 高电平和低电平各 10~20 微秒是比较稳的窗口。3.3 键值解析组合键与摇杆模拟量的归一化处理拿到原始数据后要把按键字节和摇杆值抽出来。PS2 数字按键的每一位对应一个键值逻辑低电平 0 表示按键被按下所以我初始化时先全部置 1。解析部分的代码一般长这样void PS2_Parse(uint8_t* raw_data, PS2_TypeDef* ps2) { // 0x5A 是数据有效标志字节读到这个才说明手柄在线 if (raw_data[3] ! 0x5A) { ps2-online 0; return; } ps2-online 1; ps2-select (raw_data[4] 0x01) ? 0 : 1; ps2-start (raw_data[4] 0x08) ? 0 : 1; ps2-up (raw_data[4] 0x10) ? 0 : 1; ps2-down (raw_data[4] 0x20) ? 0 : 1; ps2-left (raw_data[4] 0x40) ? 0 : 1; ps2-right (raw_data[4] 0x80) ? 0 : 1; // 字节5是另外一组按键 ps2-triangle (raw_data[5] 0x10) ? 0 : 1; ps2-circle (raw_data[5] 0x20) ? 0 : 1; ps2-cross (raw_data[5] 0x40) ? 0 : 1; ps2-square (raw_data[5] 0x80) ? 0 : 1; // 摇杆范围0~255居中大概在128左右 ps2-joy_rx raw_data[7]; ps2-joy_ry raw_data[8]; ps2-joy_lx raw_data[6]; ps2-joy_ly raw_data[9]; // 摇杆自动校准如果用了一段时间发现居中不是128 // 就保存一开机没动时的读数为死区中心 ps2-rx_center 128; }摇杆数据处理有个细节就是十字键和摇杆共用同一组数据位如果拨动了十字键对应的摇杆值可能会漂。多数情况下不用管但如果你想做精细控制最好在检测到十字键按下时降低摇杆的控制优先级。实际操作中左右摇杆的居中值受机械磨损影响很大用久了可能从 128 漂到 140这时候如果在控制函数里直接用 128 当作零位小车不碰摇杆也会自己走。解决办法是上电后先存一组居中校准值等摇杆读数与校准值之差大于等于一个阈值比如 15才参与控制否则视为零位。这个小改动能让整车的操控手感上一个档次强烈建议加上。4. 四电机驱动与运动控制差速转向和 PWM 调速链路4.1 四电机独立控制的 PWM 基本配置电机驱动是 4 路独立 PWM使用 STM32 的 TIM1 高级定时器。注意 TIM1 是高级定时器它的 PWM 输出需要额外开启 MOE 主输出使能这一步漏掉了坑很多人一晚上。初始化代码核心部分如下void Motor_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB | RCC_APB2Periph_TIM1, ENABLE); // PA8~PA11引脚配置为复用推挽输出驱动PWM信号 GPIO_InitStructure.GPIO_Pin GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10 | GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 方向引脚PB0、PB1、PB2、PB10配置为通用推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_Init(GPIOB, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_Init(GPIOB, GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period 999; // PWM频率 72MHz/(9991)72kHz TIM_TimeBaseStructure.TIM_Prescaler 0; TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM1, TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 0; TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM1, TIM_OCInitStructure); TIM_OC2Init(TIM1, TIM_OCInitStructure); TIM_OC3Init(TIM1, TIM_OCInitStructure); TIM_OC4Init(TIM1, TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC3PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC4PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM1, ENABLE); TIM_CtrlPWMOutputs(TIM1, ENABLE); // MOE主输出使能这是高级定时器特有的 TIM_Cmd(TIM1, ENABLE); }PWM 频率这里选了 72kHz配合 TB6612 完全没有啸叫问题。如果用的是 L298N 或者电机电感量比较大要适当降频率到 20kHz 左右否则电流纹波会变大。对于 N20 这种小电机72kHz 没问题。然后是映射关系TIM_Period 999 就是 ARR 值Pulse 从 0 到 999 对应占空比 0% 到 100%初步把 0~255 的摇杆值线性映射到 0~999 再除以 4 得到最终占空比。4.2 差速转向算法如何把摇杆角度转成四轮差速四电机小车的转向不是像普通车那样打方向盘而是靠左右两侧电机转速差实现。前进时左侧两个电机的 PWM 一致右侧两个电机的 PWM 一致差速调节就实现了转向。如果左右转速相同车走直线右侧转速低于左侧车会向右转弯。所以控制程序里我一般把 4 个电机分成 2 组控制而不是单独操作每个轮子。核心控制逻辑如下——目标值来自摇杆通道左边摇杆的前后方向值决定整体速度右边摇杆的左右方向值决定转向强度void Car_Control(int16_t speed, int16_t steer) { // 基础PWM值由摇杆速度决定steer是转向量 int16_t left_speed speed steer; int16_t right_speed speed - steer; // 限幅保护防止PWM超过999 if (left_speed 999) left_speed 999; if (right_speed 999) right_speed 999; if (left_speed -999) left_speed -999; if (right_speed -999) right_speed -999; // 正转反转处理 Motor_SetSpeed(MOTOR_LF, left_speed); Motor_SetSpeed(MOTOR_LB, left_speed); Motor_SetSpeed(MOTOR_RF, right_speed); Motor_SetSpeed(MOTOR_RB, right_speed); }这个差速控制的数学逻辑很简单但有一个玄学点四个电机虽然同一个型号装配摩擦和减速箱阻力不完全一样所以给定同样的 PWM实际转速可能差出 10% 以上。这意味着你把前面的代码写好之后小车大概率不是走直线的而是缓慢地偏向某一侧。调参经验是在控制函数里加一组微调系数left_speed (left_speed * left_bias) / 100初始都设置为 100然后实际跑直线看偏哪边每跑一次调 2~3 个数字。反向调整也一样。这套微调机制在后续二次开发中会一直用到值得做成单独的头文件保存下来。4.3 从摇杆值到 PWM 占空比死区和限幅的细节处理摇杆的原始值是 0~255 的模拟量直接拿来做速度控制会有两个问题一是摇杆居中的漂移导致电机低速转动二是猛然推到满量程会让电流冲击过大容易把电池电压拉崩。所以在解析代码之后需要有一段映射和滤波uint16_t speed_map(uint8_t joy_val, uint8_t center, uint16_t max_pwm) { int16_t delta (int16_t)joy_val - (int16_t)center; // 死区处理幅度小于15当作零 if (delta -15 delta 15) { return 0; } // 支持正反向正向范围center15到255 int16_t range 255 - (int16_t)center - 15; if (delta 0) { return (uint16_t)((delta - 15) * max_pwm / range); } else { range (int16_t)center - 15; return (uint16_t)(-(delta 15) * max_pwm / range); } }这个函数接受摇杆值和中心值返回 0~max_pwm 的 PWM 目标。有个细节值得多说一句如果想把控制做得更细腻可以在映射之后加一个简单的滑动平均滤波让速度变化有加速度过渡效果。不然每次推满摇杆PWM 瞬间从 0 跳到 800小车会冲出去而不是柔和地加速。我后面在二次开发部分写了用定时器中断做速度斜坡的代码那个方案更丝滑。5. 项目结构和避坑记录目录设计、常见故障排查与调试经验5.1 工程目录结构设计整个标准库工程我做的是模块化拆分这样后面加传感器或者换控制算法不用动主循环Project/ ├── Core/ │ ├── main.c // 主循环读手柄、控制电机、状态切换 │ ├── stm32f10x_it.c // 中断服务函数 │ └── system_stm32f10x.c ├── Hardware/ │ ├── ps2.c / ps2.h // PS2手柄通信和解析 │ ├── motor.c / motor.h // 电机PWM和方向控制 │ ├── battery.c / battery.h // ADC读电池电压可选 │ └── led.c / led.h // 状态指示 └── App/ ├── control.c / control.h // 运动控制逻辑差速算法 └── mode.c / mode.h // 遥控模式切换这个目录结构把硬件驱动和上层逻辑分开。主循环只做三件事周期性读手柄数据、调用运动控制函数、检查状态标志。如果后面要加超声波避障直接在 App 层加一个 sensor.c读取结果在 control 之前做一个叠加就行。5.2 避坑记录4 个实际踩过的坑和解决方法第一个坑是电机一启动就复位。现象是小车通电后手按 PS2 的十字键电机刚转一下整个系统就重启现象重复出现。原因是电机瞬间电流过大把锂电池电压拉低到 STM32 的复位阈值以下。解决方案是换了独立降压模块给 MCU 供电并且在电机 PWM 初始化时先让占空比从 0 开始做斜坡启动。后来我还加了一颗 1000uF 的电解电容在电池输入端整车稳定性提升明显。第二个坑是 PS2 手柄偶尔失控、按键没反应。现象是跑着跑着灯还在亮但是按键怎么按都没反应重新上电又好了。原因是通信超时后手柄进入了配置模式接收器还在发数据但 S反了。解决方法是每次读数据时检查第 4 字节是不是 0x5A如果连续读 3 次都不是执行一次手柄初始化序列重新配置手柄。这相当于一个软件自恢复机制非常实用。第三个坑是开机时车会突然往前窜一下。现象是刚上电还没按任何键四个轮子突然同时转一下然后停住。原因是上电瞬间 GPIO 口是浮空状态PWM 默认输出高电平电机驱动板直接给满占空比。解决方法是初始化 GPIO 时先把方向引脚和 PWM 引脚都拉低到确定电平再初始化定时器最后再给电机 PWM 赋初始值 0。代码顺序很重要上电时序里 GPIO 初始化必须在 TIM 之前。第四个坑是电池电压测量不准。现象是用 ADC 读电池电压显示值比万用表高 0.4V。原因是 ADC 的参考电压 VREF 直接接在了 3.3V LDO 输出上而 STM32 板载 3.3V 的稳压精度一般温度漂移也大。解决方法是看了数据手册之后用 VREFINT 内部参考电压通道做校准反推实际 VREF 值再算电池电压。这个校准思路在很多 STM32 项目中都能用上值得记下来。5.3 调试工具和手段逻辑分析仪和串口调试调 PS2 协议的时候强烈建议别只用眼睛看现象把 CLK、CMD、DAT 三根线接到逻辑分析仪上采样率设 1MHz 以上然后抓一段通信数据对照协议帧格式检查波形。这个动作能帮你在 5 分钟内判断是时序问题还是解析问题而不是靠猜。我遇到过一个问题CLK 引脚有干扰毛刺导致从机多采了几个 0 进去波形上一眼就能看出来。用逻辑分析仪确认时序正确后再配合串口把解析出来的按键值打印出来一套组合拳下来调试效率翻倍。串口打印的时候注意波特率至少 115200并且不要在 PS2 通信的中断里做输出否则时序会被打断。6. 二次开发思路速度斜坡控制、换硬件适配和算法扩展如果已经把手柄控车跑通下一步想升级我一般会先做速度斜坡。目前直接映射摇杆值到占空比推杆快车就冲松杆车就停操控体验像开关。加一个斜坡函数让实际 PWM 每次只朝目标值靠近一小步// 在TIM2定时器中断里每10ms调用一次 void Speed_Ramp(void) { for (uint8_t i 0; i 4; i) { if (motor_cur_pwm[i] motor_target_pwm[i]) { motor_cur_pwm[i] 12; // 每10ms增加12约0.8秒到满速 if (motor_cur_pwm[i] motor_target_pwm[i]) { motor_cur_pwm[i] motor_target_pwm[i]; } } else if (motor_cur_pwm[i] motor_target_pwm[i]) { motor_cur_pwm[i] - 12; if (motor_cur_pwm[i] motor_target_pwm[i]) { motor_cur_pwm[i] motor_target_pwm[i]; } } } }这个电梯式的斜率控制看似简单实际用起来效果特别明显车起步柔和可控电池寿命也长了。增量值 12 要根据控制周期和期望加速时间调整如果定时器中断是 10ms12 意味着大约 0.83 秒从零加速到满占空比这是比较恰当的。如果想换硬件平台比如换到 STM32G431 或者 GD32主要是把标准库的 GPIO 和 TIM 驱动层改一下 HAL 即可PS2 的 GPIO 模拟协议完全不用动。如果换成树莓派 PicoRP2040做主机协议逻辑也能直接平移用 MicroPython 写的话注意 GPIO 翻转速度不够delay 要放大到 20us 以上这是血泪经验。再往深一层做可以在 App 层加一个状态机支持自动巡航和手柄遥控切换。输入来源从「仅手柄」扩展为「手柄 超声波 陀螺仪」控制链路变成先读传感器数据再融合手柄量最后输出 PWM。这样做的时候只要保持Car_Control这个函数接口的入参不变上面的所有代码都不用推倒重来这就是一开始模块化设计的价值所在。从那以后我每次接新的控制算法或者换主控都强制先在旧板子上跑通 PS2 数据解析和电机基本运动再动新功能。这套顺序节省了太多调试时间希望帮到你。本文还有配套的精品资源点击获取