简介:面向STM32与嵌入式开发者的PS2遥控小车资料包,围绕PS2手柄解码、舵机PWM控制与振动试验三大主题,搭配详细协议手册与可运行例程,帮助读者从原理到代码完整掌握手柄遥控小车的实现方式。压缩包共95个文件,以C源文件与H头文件为主干,另含hex固件、Keil工程、PDF说明手册、Arduino参考示例及txt说明文档,整体仅1.04MB,便于下载与对照学习。已有659人学习,资料虽小但体系完整:PS2解码通讯手册和常见问题汇总可系统理解手柄信号格式与排错要点;STM32例程覆盖舵机控制与振动实验,从初始化、读取手柄输入、解析数据到驱动执行机构均有代码支撑;Arduino资料则可作为跨平台移植参考。适合正在学习STM32外设、串行通信、PWM信号控制,或想将游戏手柄用于机器人项目的开发者,既可用于课程设计,也可作为竞赛作品的快速起点。
1. PS2遥控小车:从手柄到舵机的完整链路
把 PS2 手柄和 STM32 接在一起,第一次十有八九跑不通。串口打印出来全是 0xFF,红灯不亮,怀疑接线问题、协议问题、电源问题,最后发现只是手柄没按 MODE 键进到遥控模式。这个项目就是一台基于 STM32、用 PS2 手柄遥控的小车:左摇杆控制转向舵机,右摇杆控制前进后退,方块键触发振动试验模块做整机扫频测试。整套代码按硬件驱动、协议解析、应用逻辑三层拆好,照接线图接完板子就能跑。适合手里有 PS2 手柄、想低成本做无线遥控载具、机械臂或移动底盘的人。
2. 硬件与接线:PS2接收器、舵机、电机和振动模块的选型与上电
2.1 器件选型:为什么用C8T6加TB6612这一套
主控选 STM32F103C8T6。72MHz 主频跑 PS2 协议绰绰有余,内部有 3 个串口、4 个通用定时器,出 6 路独立 PWM 完全够用。这个项目里舵机转向、左右电机、振动模块加起来也就 4 路,所以选型上真正的压力不在主控性能,而在电源分配。
PS2 手柄加配套接收器是这些年最成熟的无线方案。接收器和主控之间走 SPI 协议,2.4GHz 射频,空旷距离十米级别,遥控一台小车绰绰有余。手柄自带的两个模拟摇杆天然就是转向和速度的模拟量输入,比用按键控制方向手感好得多。接收器四根线 DAT、CMD、SEL、CLK,兼容 3.3V 逻辑,直接接 STM32 的 GPIO,不需要电平转换。
转向舵机用 SG90 这一档就够。SG90 扭矩 1.8kg/cm,驱动一台 1kg 左右的小车转向轮没问题。如果是给机械臂或者更大功率的载具用,换成 MG995,扭矩能到 13kg/cm,但工作电流会暴涨到 2A 级别,电源方案必须同步升级。舵机信号线是 PWM,供电线必须接 5V 而不是 3.3V,这一点很多人会栽。
电机驱动板我选 TB6612FNG 而不是经典的 L298N。TB6612 的逻辑电平直接兼容 3.3V,L298N 有些板子在 3.3V 高电平下识别不稳定,还得加电平转换。TB6612 导通压降小,同样电压下电机转速损耗更小,发热也低。它的两路输出 PWM 频率能跑到 100kHz,实际用 10kHz,噪音和损耗平衡得比较好。
振动模块用最普通的偏心振动马达,就是手机里那种。额定电压 3V 到 5V,工作电流 80mA 到 150mA,不能直接挂在 STM32 的 GPIO 上,GPIO 推挽输出最大才 20mA 左右。驱动方式是加一个 NPN 三极管做开关,基极串 1k 电阻接 STM32 的 PWM 引脚,集电极接马达负极,马达正极接 5V,发射极接地。
2.2 引脚映射:一张表解决所有接线疑问
直接给一张我自己量过的映射表。需要说明的是,PS2 的 SPI 我没有用板载的硬件 SPI,全部用 GPIO 模拟,好处是时序完全可控,调起 bug 来方便。
| 功能模块 | 信号 | STM32 引脚 | 配置 |
|---|---|---|---|
| PS2 接收器 | DAT(MISO) | PA6 | 输入上拉 |
| PS2 接收器 | CMD(MOSI) | PA7 | 推挽输出 |
| PS2 接收器 | SEL(片选) | PA4 | 推挽输出 |
| PS2 接收器 | CLK(时钟) | PA5 | 推挽输出 |
| 转向舵机 | 信号线 | PA0 | TIM2_CH1 |
| 左电机 | PWM | PB0 | TIM3_CH3 |
| 右电机 | PWM | PB1 | TIM3_CH4 |
| 左电机 | 方向 AIN1 / AIN2 | PB12 / PB13 | 推挽输出 |
| 右电机 | 方向 BIN1 / BIN2 | PB14 / PB15 | 推挽输出 |
| 振动模块 | 三极管基极 | PB8 | TIM4_CH3 |
| 调试串口 | TX / RX | PA9 / PA10 | USART1 |
接线有几个注意点。PS2 接收器的 DAT 线不要漏接上拉,STM32 内部有上拉可以直接开,如果板子上的 IO 没有做内部上拉,外部加一个 4.7k 到 3.3V。马达驱动板的逻辑电源 VCC 接 3.3V,电机电源 VM 接电池正极。舵机三根线棕色负极、红色正极、橙色信号,接反必烧舵机内电路。
2.3 电源设计:舵机一转MCU就复位的根源
我最初图省事,让 5V 的舵机和 3.3V 的 MCU 共用同一个 AMS1117 电源。结果舵机一打角,串口就乱码,严重时 MCU 直接重启。原因是舵机启动瞬间电流可以冲到 700mA 以上,AMS1117 最大输出电流也就 800mA,电压被拉低到 MCU 欠压阈值以下就掉电。
正确的做法是分开供电,最后只共地。
- 7.4V 2S 锂电池直接进 TB6612 的 VM,驱动两个直流电机。
- 电池再进一个 5V 降压模块,比如 MP1584,输出给舵机和振动模块。
- 5V 再接一路 AMS1117-3.3V,只给 MCU 和 PS2 接收器供电。
- 三路电源的 GND 最终在同一个点汇合。
这样舵机拉电流不会拖垮 MCU。电容也别省,5V 输出端并联一个 470uF 电解电容和一个 0.1uF 陶瓷电容,能有效抑制舵机回正时产生的尖峰。TB6612 的 VM 引脚旁边也放一个 100uF 电容,电机启停瞬间电流变化非常大。
提示:如果做了独立供电之后 MCU 还是复位,先检查共地有没有接好。MCU 和舵机不共地时,PWM 信号参考地不对,舵机信号传不过去,表现出来的往往是舵机乱抖而不是直接复位。
3. 工程结构与数据流:PS2小车代码怎么组织才不乱
3.1 Keil工程目录:底层驱动与应用逻辑分离
拿到源码第一件事是看清目录。整个工程按 App、Driver、BSP 三层拆开,底层驱动完全不依赖应用逻辑,换一块板子只需要改 BSP 层的引脚配置。
STM32-PS2-Car/ ├── App/ │ ├── main.c # 主循环:轮询手柄、更新电机舵机 │ ├── remote_control.c # 遥控逻辑:摇杆值到速度/角度换算 │ └── remote_control.h ├── Driver/ │ ├── ps2_driver.c # PS2协议层:GPIO模拟SPI时序、按键解析 │ ├── ps2_driver.h │ ├── servo_driver.c # 舵机驱动:角度到PWM占空比换算 │ ├── servo_driver.h │ ├── motor_driver.c # 电机驱动:方向+速度 │ ├── motor_driver.h │ ├── vibrate_driver.c # 振动模块:频率切换 │ └── vibrate_driver.h ├── BSP/ │ ├── gpio.c # 所有GPIO初始化 │ ├── tim.c # PWM定时器初始化 │ └── uart.c # 调试串口 └── README.md # 接线图、引脚表、操作说明Driver 层只做一件事:把硬件能力暴露成函数接口,比如PS2_SendByte()、Servo_SetAngle()、Motor_SetSpeed()。App 层只负责逻辑,不考虑具体某个引脚在哪个 GPIO。以后改成蓝牙遥控,只需要在 Driver 层新增一个蓝牙驱动,App 层的遥控逻辑不用动。
3.2 主循环数据流:摇杆值到PWM波形的完整链路
主循环没有上操作系统,就是一个 10ms 周期的无限循环。每一轮做三件事:扫描手柄、解析数据、更新 PWM 输出。
- 主循环调用
PS2_Scan(),通过 GPIO 模拟 SPI 时序和手柄交换数据,拿到 9 个字节保存在全局数组ps2_buf[]。 RemoteControl_Update()从ps2_buf[]里提取左摇杆 X 值和右摇杆 Y 值,换算成舵机角度和电机速度。- 换算结果通过
Servo_SetAngle()和Motor_SetSpeed()落到定时器比较寄存器,马上变成舵机和电机的动作。
int main(void) { HAL_Init(); SystemClock_Config(); // 72MHz 主频 MX_GPIO_Init(); MX_TIM2_Init(); // 舵机PWM 50Hz MX_TIM3_Init(); // 电机PWM 10kHz MX_TIM4_Init(); // 振动PWM 1kHz MX_USART1_UART_Init(); // 调试串口 while (1) { PS2_Scan(ps2_buf); // 第一步:读手柄 RemoteControl_Update(ps2_buf); // 第二步:解析并计算 HAL_Delay(10); // 第三步:10ms 周期 } }主循环周期 10ms 不等于舵机更新周期也是 10ms。PS2 手柄本身的刷新率大约在 100Hz 量级,10ms 轮询已经比手柄还快,不会漏按键。舵机的 PWM 是 50Hz,一个周期 20ms,即使每一轮主循环都写一次比较寄存器,舵机看到的还是一个平滑的 50Hz 波形,占空比在平滑渐变。
我试过把这个周期调成 1ms,PS2 数据解析反而出错,因为 GPIO 模拟 SPI 时需要微秒级延时,循环体被拖长;调成 100ms 遥控手感会明显变肉,转向滞后很明显。10ms 是个折中值。
电机速度的换算也放在RemoteControl_Update()里,读的是右摇杆 Y 值:
/* 右摇杆Y:0xFF 表示推到最下,0x00 表示推到最上,0x80 附近为自然回中 */ uint16_t speed; if (ps2_buf[5] < 0x70) { // 上推 Motor_SetDirection(IN_FORWARD); speed = (0x7F - ps2_buf[5]) * 2; // 差值放大两倍,最快约 254 } else if (ps2_buf[5] > 0x90) { // 下推 Motor_SetDirection(IN_BACKWARD); speed = (ps2_buf[5] - 0x7F) * 2; } else { speed = 0; // 中间死区,电机停转 } Motor_SetSpeed(speed);这里速度值直接作为 PWM 比较值写入 TIM3,0 到 254 对应占空比 0% 到 100%。TB6612 的方向由 AIN1、AIN2 两个引脚的组合决定,PWM 只控制输出功率,方向和速度解耦,逻辑上清爽很多。
4. 核心代码拆解:PS2解析、舵机映射与振动扫频
4.1 PS2协议解析:主机是怎么把手柄按键读进内存的
PS2 手柄的物理层就是 SPI,只是时序要求主机主动拉低片选,然后先发命令字节再读数据。完整流程是:拉低 SEL,发送命令 0x01 握手,再发送 0x42 请求按键数据,然后连续读 9 个字节。每个字节的收发在同一组时钟脉冲里完成。
uint8_t PS2_SendByte(uint8_t cmd) { uint8_t ret = 0; for (uint8_t i = 0; i < 8; i++) { LL_GPIO_ResetOutputPin(PS2_CLK_PORT, PS2_CLK_PIN); /* CLK拉低 */ delay_us(2); if (cmd & 0x80) LL_GPIO_SetOutputPin(PS2_CMD_PORT, PS2_CMD_PIN); /* 输出最高位 */ else LL_GPIO_ResetOutputPin(PS2_CMD_PORT, PS2_CMD_PIN); LL_GPIO_SetOutputPin(PS2_CLK_PORT, PS2_CLK_PIN); /* CLK拉高,从机锁存 */ delay_us(2); ret <<= 1; if (LL_GPIO_IsInputPinSet(PS2_DAT_PORT, PS2_DAT_PIN)) ret |= 0x01; /* 从DAT读回一位 */ cmd <<= 1; } return ret; } void PS2_Scan(uint8_t *buf) { LL_GPIO_ResetOutputPin(PS2_SEL_PORT, PS2_SEL_PIN); /* 片选拉低 */ PS2_SendByte(0x01); /* 握手命令 */ PS2_SendByte(0x42); /* 请求数据 */ for (uint8_t i = 0; i < 9; i++) { buf[i] = PS2_SendByte(0x00); /* 空字节换回数据 */ } LL_GPIO_SetOutputPin(PS2_SEL_PORT, PS2_SEL_PIN); /* 片选拉高 */ }为什么用 2us 延时而不是更短。PS2 接收器是 2.4GHz 射频模块,内部状态机翻转需要时间,时钟太快会读到不稳定的数据。2us 的拉高拉低间隔折合时钟约 250kHz,比规范里的 500kHz 慢一半,留了余量。如果你偷懒用HAL_Delay(2)代替微秒延时,整个循环会被拖到毫秒级,遥控手感会非常差。
读回数据后第一件事是判断手柄是否在线。红灯模拟模式下buf[0]固定为 0x5A,如果读到 0xFF,基本可以断定手柄没开电源或者没进遥控模式,这帧数据直接丢弃。按键位定义我沿用常见的约定:buf[1]和buf[2]是数字按键状态,按下的位为 0;buf[4]到buf[7]分别是右摇杆 X、右摇杆 Y、左摇杆 X、左摇杆 Y。
4.2 舵机控制:摇杆模拟量到PWM占空比的换算
舵机控制的核心是 PWM 周期固定、占空比可变。SG90 的周期是 20ms,也就是 50Hz,高电平时间在 0.5ms 到 2.5ms 之间对应 0 到 180 度。TIM2 的配置是预分频 71、自动重载值 19999,计数频率 1MHz,周期正好 20ms。
void Servo_SetAngle(uint16_t angle) { uint32_t pulse; if (angle > 180) angle = 180; pulse = 500 + (uint32_t)angle * 2000 / 180; /* 0度=500, 180度=2500 */ __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pulse); }500 + angle * 2000 / 180的意思是:0 度对应比较值 500,也就是 500us 高电平;180 度对应 2500;中间线性插值。SG90 的角度线性度在正负 3 度以内,不需要做查表校正。
摇杆到角度的映射放在遥控逻辑层,不放在驱动层,这样以后改成自动巡线模式可以直接复用Servo_SetAngle():
uint16_t RemoteControl_Steer(uint8_t lx) { if (lx < 0x6E) return 30; /* 左摇杆左推 */ if (lx > 0x91) return 150; /* 左摇杆右推 */ return 90; /* 中间死区,舵机回正 */ }这里有个玄学细节:左右各留了大约 15% 的摇杆行程做死区。因为摇杆弹簧回中并不能保证完全回到 0x7F,常在 0x78 到 0x86 之间漂移,没有死区的话车会在直道上轻微摆头。死区留太大转向响应慢,我试过 20 就很肉,15 是折中值。
4.3 振动试验:定时器重装载实现四档扫频
振动试验要验证的是偏心马达在不同 PWM 频率下的驱动力矩不同,底盘在某些频率点会发生共振。代码里做了四档频率切换,对应停止、500Hz、2kHz、8kHz 四种状态。
#define VIB_STOP 0 #define VIB_FREQ_1 500 #define VIB_FREQ_2 2000 #define VIB_FREQ_3 8000 void Vibration_SetFreq(uint16_t freq_hz) { if (freq_hz == 0) { __HAL_TIM_SET_COMPARE(&htim4, TIM_CHANNEL_3, 0); /* 停止输出 */ return; } uint32_t arr = 1000000 / freq_hz - 1; /* 计数频率1MHz */ __HAL_TIM_SET_AUTORELOAD(&htim4, arr); __HAL_TIM_SET_COMPARE(&htim4, TIM_CHANNEL_3, arr / 2); /* 固定50%占空比 */ }占空比固定 50% 是为了让频率成为唯一变量。如果频率和占空比一起变,试验结果会出现两个变量,分不清共振点到底是频率引起的还是驱动力矩引起的。频率越高马达转速越快,但超过某个频率后马达感抗会限制电流,驱动力矩反而下降,这个拐点可以在串口打印里标出来。
按键触发逻辑写在主循环里,按方块键切挡。PS2 的按键数据是低电平有效,解析时用位与判断:
uint8_t prev_square = 1; /* 上一帧方块键状态,1=松开 */ if ((ps2_buf[2] & 0x80) == 0) { /* 方块键按下,bit7清零 */ if (prev_square == 1) { /* 只在下降沿触发一次 */ v_freq_idx = (v_freq_idx + 1) % 4; Vibration_SetFreq(v_freq_table[v_freq_idx]); printf("vibrate freq: %d Hz\n", v_freq_table[v_freq_idx]); } } else { prev_square = 0; }这里一定要做边沿检测。主循环周期只有 10ms,而人手按一次键的物理时间在 100ms 以上,主循环会连续检测到十几次按下,不做边缘检测的话频率档位会一次跳好几格,这也是我最初翻车最多的位置。
5. 避坑手册:四个让遥控车趴窝的常见问题
5.1 串口打印全是0xFF
现象:串口一直打印 0xFF,手柄怎么按都没反应。
原因:九成是手柄没有进遥控模式。PS2 手柄上电默认是绿灯模式,绿灯模式下摇杆全输出 0x80,数字按键只有部分有效。只有按手柄背部的 MODE 键切到红灯模式,模拟摇杆数据才完整。
解决:手柄装电池后先按一次 MODE,确认红灯亮再操作。如果红灯亮了还是 0xFF,检查 DAT 和 CMD 两根线有没有接反,我见过不止一次这两个信号对调的情况。
5.2 舵机不转,用镊子触碰信号线却能转
现象:舵机完全不动,但用镊子把信号线短路到 3.3V 时会转一下。
原因:PWM 频率配置错了。舵机要求 50Hz 周期,很多人把定时器算到了 50kHz,输出到舵机的是几乎恒定的高电平,舵机误以为要转到极限角度,然后堵转发烫。用示波器看波形,正常舵机信号是 20ms 周期内一个 0.5ms 到 2.5ms 的高电平,而 50kHz 下整个波形都是高电平。
解决:检查定时器分频和重载值。72MHz 主频下预分频 71 得到 1MHz 计数频率,设置自动重载 19999,输出频率正好是 50Hz。比较值在 500 到 2500 之间变化。
5.3 舵机打角的瞬间MCU复位
现象:小车一动方向,MCU 就重启,串口重新打印启动信息。
原因:电源问题。舵机启动瞬态电流能到 700mA 以上,如果 MCU 和舵机共用一路 5V 电源,电压跌到 MCU 欠压阈值以下就复位。这属于硬件问题,软件调参救不回来。
解决:舵机和 MCU 独立供电、最后共地,5V 输出端并联 470uF 电解电容。注意共地不能省,不共地会表现为舵机随机抖动而不是复位。
5.4 振动马达不转但GPIO有电平
现象:GPIO 量出来有高电平,3V 马达就是转不起来。
原因:GPIO 驱动能力不够。STM32 的 GPIO 推挽输出最大电流 20mA 左右,偏心马达正常工作电流在 80mA 到 150mA,GPIO 输出电压被拉低到 1V 以下,马达只响不转。
解决:用 NPN 三极管做开关,基极串 1k 电阻接 PWM 引脚,集电极接马达负极,马达正极接 5V。PWM 频率在 1kHz 到 8kHz 之间调节,频率太高马达感抗太大反而转不动。
6. 二次开发:把遥控小车改成循迹车的落地思路
最后给一个实际改造路径。不改变硬件大前提,把遥控模式改成自动循迹模式,核心是把手柄输入这一层换成传感器输入这一层。
第一步加硬件。循迹小车最需要的是灰度传感器,两个就够,分别放在车头左右两侧。传感器输出数字量,接 STM32 的空闲 GPIO,比如 PB9 和 PB10。灰度模块自带比较器,检测到黑线输出低电平,白底输出高电平,不需要做 ADC 采样。
第二步在 App 层加模式切换变量:
uint8_t run_mode = MODE_REMOTE; /* MODE_REMOTE / MODE_TRACK */ if (run_mode == MODE_TRACK) { uint8_t l_sensor = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_9); uint8_t r_sensor = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_10); if (l_sensor == 0 && r_sensor == 1) { /* 左传感器压线 */ Servo_SetAngle(140); /* 舵机右转 */ Motor_SetSpeed(150, 100); /* 左轮快右轮慢,差速辅助 */ } else if (l_sensor == 1 && r_sensor == 0) { Servo_SetAngle(40); /* 舵机左转 */ Motor_SetSpeed(100, 150); } else { Servo_SetAngle(90); /* 直线行驶 */ Motor_SetSpeed(150, 150); } }第三步用手柄的 SELECT 键切换run_mode,切换时用串口打印当前模式,方便确认状态。这样整台车同时具备手动和自动两种能力,硬件只多两个传感器和几根线。
这套改造在前面工程结构里的落脚点很清晰:新写一个track_control.c,和remote_control.c实现同一个Control_Update()入口,主循环根据run_mode调用对应的实现。这就是把遥控逻辑和驱动逻辑分离的意义,不是为了让代码好看,是改起功能来真的省时间。从那以后我每次接新传感器都强制走一遍「Driver 层加驱动、App 层注册回调、主循环绑定模式」这个流程,踩坑率明显降下来了。希望帮到你。
本文还有配套的精品资源,点击获取