news 2026/10/19 2:58:40

PS2手柄遥控STM32小车:从SPI通信到PWM舵机控制的完整实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PS2手柄遥控STM32小车:从SPI通信到PWM舵机控制的完整实战

简介:这是一份基于 STM32 的 PS2 游戏手柄遥控小车实现方案,适合嵌入式爱好者、STM32 初学者以及正在完成智能小车课程设计的学生参考。资料围绕 PS2 手柄解码、舵机角度控制与振动试验三条主线展开,通信协议与工程代码并重,对应项目式学习路径。压缩包内含 95 个文件,总大小 1.04MB,以 C/H 源码、hex 固件、Keil 工程以及 PDF 文档为主,还附带 Arduino 对照资料和连接示意图,便于跨平台理解。内容包含 PS2 解码通讯手册、常见问题汇总,以及 STM32 下的遥控小车、舵机控制、振动试验三套可直接参考的工程;代码涵盖手柄数据读取、按键解析、PWM 舵机驱动和电机联动等关键环节,既能学习协议细节,也能照例程实现整车调试。手册集中梳理帧格式与排错思路,遇到通讯异常时可快速定位;目前已有 659 人学习过这份资源,对想从零搭建 PS2 遥控小车的读者来说,是一份紧凑而实用的入门参考资料。

1. 项目定位:为什么用 PS2 手柄拼 STM32 小车值得一试

很多开发者第一次接触 PS2 手柄无线遥控,都是从某宝买一套现成的接收模块加上一块蓝板 STM32 开始的。这个项目把 PS2 遥控小车、舵机控制、振动试验三件事放在了一起:左手摇杆当油门,右手摇杆转向舵机,另外把 L2/R2 按键改成了可调频率的振动试验功能。适合正在学 STM32 的 SPI、PWM 和中断的同学,也适合想把手头一辆小车从固定动作跑改成“手柄实时控制”的动手派。你不需要写复杂的通信协议栈,只需要把 PS2 接收器当一个 SPI 从设备来读,把舵机和电机当两路 PWM 来调,就能在一下午搭出一辆能跑、能转、能“按摩”的遥控小车。

2. 系统架构与工程目录:先搞清数据怎么流

动手前先看整体分工,这样后面接错线时至少知道该查哪个模块。

2.1 功能划分:手柄、电机、舵机、振动各管一摊

整个系统围绕 STM32F103C8T6 最小系统板展开,外设挂得并不复杂,但每部分职责必须划清楚。

模块硬件接口方式职责
手柄接收PS2 无线接收器SPI2(PB12-PB15)读取摇杆和按键原始数据
电机驱动TB6612FNG 双路驱动GPIO 方向 + TIM3 PWM控制左右轮正反转和速度
舵机SG90 或 MG995TIM2_CH1 输出 50Hz PWM控制转向角度
振动试验偏心振动马达 + MOS 驱动TIM4_CH1 PWM按设定频率和强度断续振动
供电7.4V 锂电池组 + 降压模块5V / 3.3V 分开供避免电机与舵机共地干扰

把振动马达单独挂在 TIM4 而不是和电机混在一起,是因为振动试验通常要按“振动 3 秒、停 2 秒”的节拍运行,单独一个定时器通道会让代码逻辑清晰很多。PS2 接收器推荐放到 SPI2,因为 SPI1 的 PA6/PA7 会被电机的 TIM3 通道占用,硬要挤在一起的话 PCB 走线和引脚分配都会很难受。

2.2 工程文件怎么分:CubeMX 生成骨架,应用代码单独放

用 STM32CubeMX 生成工程后,建议把应用层代码从 main.c 里拆出来。实际工程的目录结构大致长这样:

Project/ ├── Core/ │ ├── Inc/main.h │ └── Src/main.c # 只放 HAL 初始化和主循环壳 ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ ├── App/ │ ├── ps2_driver.c/h # PS2 SPI 读时序 │ ├── motor.c/h # TB6612 方向与 PWM │ ├── servo.c/h # 舵机脉宽映射 │ ├── vibrator.c/h # 振动试验节拍状态机 │ └── remote_control.c/h # 把摇杆数据翻译成电机/舵机动作 └── MDK-ARM/

main 里只保留 HAL_Init、系统时钟配置、外设初始化,然后循环调用 RemoteControl_Process()。PS2 原始读取放在中断里还是主循环里,取决于你的手柄刷新率需求。常见做法是拆成两层:底层 PS2_ReadRaw 负责和接收器做 SPI 字节交换,上层 RemoteControl_Process 每 5 到 10 毫秒调用一次读数据并解析。这样以后想接蓝牙手柄或者换成手机 WiFi 控制,只需要替换底层函数,解析逻辑不用动。

2.3 数据流:从手指按下到轮子转动的完整链路

一辆摇杆控制的遥控小车,数据流其实只有五步。

第一步是 PS2 手柄把摇杆模拟量通过 2.4G 无线发给接收器;第二步是 STM32 的 SPI 从接收器读回一帧 9 字节的原始报文;第三步是解析函数把第 6 个字节的左摇杆 Y 值换算成速度值,把第 5 个字节的右摇杆 X 值换算成舵机角度;第四步是 PWM 定时器更新比较寄存器,同时切换 TB6612 的方向引脚;第五步是电机和舵机执行动作。整条链路里最容易出问题的是第二步和第三步:SPI 的时序稍微不对读回来的全是 0xFF,摇杆中间值没做死区处理小车就会自己画龙。

3. 核心模块拆解:PS2 读时机、PWM 换算和振动状态机

这一章是整份工程的灵魂。代码以 STM32 HAL 库为底,你如果用的是标准外设库,照逻辑平移即可。

3.1 PS2 SPI 读取:握手、命令字节与 9 字节帧结构

PS2 接收器看起来是 SPI 从设备,但没有规范的寄存器地址。所有通信都是主机先发一个命令字节,然后从机在接下来若干个时钟周期里把数据回填回来。读取手柄状态的标准命令是 0x01,进入配置模式是 0x43,模拟模式设置是 0x44。初始化时会用一串命令把手柄切到模拟红绿灯模式,否则摇杆的模拟量读不到精确数值。

底层读取函数可以封装成这样:

uint8_t PS2_ReadRaw(uint8_t *raw) { uint8_t cmd = 0x01; // CS 拉低表示开始一次 SPI 事务 HAL_GPIO_WritePin(PS2_CS_GPIO_Port, PS2_CS_Pin, GPIO_PIN_RESET); // 先发命令字节 0x01,同时从机返回第一个字节(通常是 0xFF) HAL_SPI_TransmitReceive(&hspi2, &cmd, &raw[0], 1, 100); // 后续连续交换 8 个空字节,把从机按时钟吐出的数据收进来 for (uint8_t i = 1; i < 9; i++) { cmd = 0x00; HAL_SPI_TransmitReceive(&hspi2, &cmd, &raw[i], 1, 100); } // CS 拉高结束事务 HAL_GPIO_WritePin(PS2_CS_GPIO_Port, PS2_CS_Pin, GPIO_PIN_SET); // 第 1 个字节正常应为 0x5A,代表握手成功 return (raw[1] == 0x5A) ? 1 : 0; }

这里有个关键点:HAL_SPI_TransmitReceive 必须要先发后收,不能只发不读。因为 PS2 从机是边收边吐,你不读数据,CLK 就不会继续产生,从机也就不会给后续字节。CS 引脚必须用普通 GPIO 手动控制,不能开成 SPI 硬件 NSS,否则主机会抢片选,导致一次事务多字节之间被硬件打断。SPI 配置上一般用模式 0(CPOL=0,CPHA=0),波特率设在 250kHz 左右,这个速率对 PS2 接收器来说足够稳定,再快容易受杜邦线干扰。

3.2 摇杆解析:死区、负向映射和按键处理

手柄返回的摇杆数值范围是 0 到 255,居中大约在 128 附近。PS2 协议里左摇杆 Y 值在第 6 个字节,右摇杆 X 值在第 5 个字节。解析不能直接拿原始值用,必须做几步处理。

void RemoteControl_Decode(RemoteData_t *out, uint8_t *raw) { int16_t raw_x = raw[5] - 128; // 右摇杆 X:-128 ~ +127 int16_t raw_y = raw[6] - 128; // 左摇杆 Y:-128 ~ +127 // 死区:小于阈值的摇杆位移直接归零,防止舵机抖和电机蠕行 if (raw_x > -15 && raw_x < 15) raw_x = 0; if (raw_y > -15 && raw_y < 15) raw_y = 0; out->speed = (raw_y > 0) ? raw_y * 78 : (-raw_y) * 78; out->angle = (raw_x * 90) / 128; // 映射到 -90 ~ +90,方便舵机用 if (out->angle > 90) out->angle = 90; if (out->angle < -90) out->angle = -90; }

乘以 78 是因为电机 PWM 的满量程是 9999,而 128 乘以 78 正好约等于 9999,这样不用做浮点除法。死区设成 15 可能听着不大,但实际摇杆松手后回不了完美中心,如果死区太小,小车会出现“手没碰手柄但轮子在微动”的情况,这种玄学问题排查起来非常耗时间。按键处理注意把 raw[3] 和 raw[4] 按位取反再判断,PS2 协议里按键按下时对应位是 0,新手第一次读容易把判断逻辑写反。

3.3 电机控制:方向引脚与 PWM 占空比的配合

TB6612 控制直流电机需要两路 GPIO 决定正反转,一路 PWM 决定速度。下面这段用 TIM3 两个通道输出 PWM,PB8/PB9 和 PB4/PB5 做方向控制。

void Motor_SetSpeed(Motor_Type_t motor, int16_t speed) { if (speed > 0) { Motor_SetDirection(motor, FORWARD); if (motor == MOTOR_LEFT) __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, speed); else __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, speed); } else if (speed < 0) { Motor_SetDirection(motor, BACKWARD); speed = -speed; if (motor == MOTOR_LEFT) __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, speed); else __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, speed); } else { // 停机时方向引脚全部拉低,PWM 输出 0 Motor_SetDirection(motor, STOP); if (motor == MOTOR_LEFT) __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 0); else __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 0); } }

电机 PWM 频率一般设在 10kHz,比舵机的 50Hz 高得多。10kHz 下电机线圈电流更平滑,噪音也更小。如果你手上的电机在低速时“咔咔”响,多半是高占空比下 PWM 频率太低,把 TIM3 预分频调低一档就能改善。TB6612 的 STBY 引脚必须拉高,不然电机完全不给电,这个坑后面章节还会展开讲。

3.4 舵机控制:把 -90 度到 90 度换算成 CCR 寄存器值

舵机不是靠占空比调速的,它看的是脉冲宽度。标准舵机每 20ms 收到一次周期脉冲,当脉宽为 0.5ms 时转到 0 度,1.5ms 时转到 90 度,2.5ms 时转到 180 度。要让 TIM2 输出 50Hz PWM,在 72MHz 时钟下把预分频设为 71,自动重载值 ARR 设为 19999,那么计数频率就是 1MHz,20ms 对应 20000 个计数。脉宽换算成计数如下。

// 角度范围 -90 ~ +90 void Servo_SetAngle(int8_t angle) { uint32_t pulse; if (angle > 90) angle = 90; if (angle < -90) angle = -90; // 0 度对应 1.5ms 脉宽 = 1500 计数 // 1 度对应约 5.56 计数,这里取 6 pulse = 1500 + (int32_t)angle * 6; __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pulse); }

这个换算精度大约是 0.18 度一档,对遥控小车转向足够用。要注意一个容易犯错的地方:不同舵机的中位脉宽不完全是 1.5ms,有的便宜舵机实际是 1.45ms,装车后方向会跑偏。常见做法是先写成宏定义,比如 SERVO_MIDDLE_PULSE 1500,然后在调试时通过串口调整这个值。舵机不允许从 90 度突然切到 -90 度,瞬间的大电流会拉低主控电压,严重时直接复位,所以转向控制一般加一层斜率限制,每次只让命令角度变化 3 到 5 度。

3.5 振动试验:频率可调的偏心马达状态机

振动试验不是简单给马达通电。要实现“试验”效果,就得让马达按你设定的节拍间歇振动。这里用 TIM4_CH1 输出 20kHz PWM 控转速,再用软件状态机切开关时刻。

typedef struct { uint8_t running; uint16_t freq_hz; // 振动频率,单位 0.1Hz,比如 25 = 2.5Hz uint8_t strength; // 振动强度 0 ~ 100,映射到 PWM 占空比 uint16_t on_ticks; uint16_t off_ticks; uint16_t counter; } Vibration_State_t; void Vibration_Process(void) { if (!VibTest.running) return; VibTest.counter++; if (VibTest.counter >= VibTest.on_ticks) { __HAL_TIM_SET_COMPARE(&htim4, TIM_CHANNEL_1, 0); if (VibTest.counter >= (VibTest.on_ticks + VibTest.off_ticks)) { VibTest.counter = 0; } } else { __HAL_TIM_SET_COMPARE(&htim4, TIM_CHANNEL_1, VibTest.strength * 200); } }

on_ticks 对应振动持续时长,off_ticks 对应停止时长,单位是主循环的调用次数。如果主循环每 5ms 调一次 Vibration_Process,on_ticks 设为 600,就表示振动 3 秒。强度映射到 20kHz PWM 的占空比,strength 为 50 时比较值就是 10000,马达转速大约在中高速区间。这样做的好处是试验参数全部可调:想模拟结构耐久测试,就把频率调低、持续时间调长;想演示触觉反馈,就把频率调到 10Hz 以上并减少 on_ticks。驱动偏心马达的 MOS 管旁边一定要反接续流二极管,否则停止瞬间的反向电动势容易把单片机电死。

4. 编译烧录与避坑:从 CubeMX 到下地测试

代码写完不算完,首跑阶段才是真正劝退新手的环节。

4.1 CubeMX 外设配置清单

打开 STM32CubeMX,选择 STM32F103C8T6 芯片后按下面的表配置外设,生成工程后再把 3.2 到 3.5 的应用代码加进去。

外设配置项值
RCCHSECrystal/Ceramic Resonator
SPI2ModeTransmit Only Master
SPI2NSSDisable(CS 用 GPIO 控制)
SPI2Prescaler32(72MHz / 32 = 2.25MHz,偏低更稳)
TIM2Channel1PWM Generation CH1,PSC=71,ARR=19999
TIM3Channel1/2PWM Generation CH1/CH2,PSC=71,ARR=999
TIM4Channel1PWM Generation CH1,PSC=71,ARR=19999
USART1ModeAsynchronous,波特率 115200
GPIOCS/C 引脚GPIO_Output,Initial Level High
GPIOTB6612 方向脚GPIO_Output,Initial Level Low

SPI2 时钟配置选 2.25MHz 时可能收到误码,如果读回来的数据偶尔错乱,把 Prescaler 提高到 64 或 128。USART1 用于打印手柄报文和调参,一开始就接好,等出了问题再飞线接串口难度会翻倍。

4.2 编译烧录与首次点灯确认

用 MDK-ARM 打开工程后,记得把 C/C++ 选项里的 Optimization 调到 Level 0,原因是大多数 HAL 代码在开 O2 优化后,SPI 时序里的 delay 会被编译器优化掉,导致握手不稳定。烧录器推荐 ST-Link V2,SWD 接口接 PA13 SWDIO、PA14 SWCLK 和 GND。

编译烧录后先别急着放地上跑。把程序里的 Main 循环改成只做三件事:初始化、读一次 PS2、串口打印 raw[1]。如果串口助手能稳定打印 0x5A,再往下走。如果打印不出来,多半是 SPI 接线顺序不对,或者接收器供电不够。检查顺序是:先量接收器 VCC 引脚电压是否在 3.3V 左右,再用示波器或逻辑分析仪看 CS 是否有拉低动作。

4.3 五个常见问题排查记录

第一个问题:读回来的手柄值全是 0xFF。现象是串口打印稳定输出 255,手柄按键毫无反应。原因大概率是 SPI2 的 MOSI 和 MISO 接反,或者 CS 引脚没有在 CubeMX 里设成 GPIO 输出。解决方法是先看原理图,确认 PS2 接收器的 DAT 信号线接到 STM32 的 MISO(在 SPI2 上是 PB14),CMD 信号线接到 MOSI(PB15)。DAT 接错的话,设备根本不会回复有效数据。

第二个问题:舵机每隔几秒抽动一下。这是典型的电源纹波干扰。舵机启动瞬间电流可达几百毫安,如果和单片机共用同一个 5V 稳压块,电压会被拉低到 3V 以下,舵机控制芯片就会误判脉宽。我的做法是给舵机单独接一路 5V 输出,地和主板共地,逻辑电压由主板直接供。切记不能把 PS2 接收器、舵机、电机全挂在同一个降压模块上。

第三个问题:电机方向只有一个转,另一边没反应。先看 TB6612 的 STBY 接在哪了。这块芯片的 STBY 内部有下拉,必须显式拉高才能让电机输出。很多新手把 STBY 悬空就想看电机转,结果呵呵。此外如果有万用表,可以量一下没转那一路的 AIN1/AIN2 电压,正常时一个应该是高电平、一个是低电平,两个都低说明软件里的方向引脚配置错了。

第四个问题:小车能跑但 S 形画龙。摇杆回中后死区太小,再加上左右电机机械差异,两个轮子转速略有不同就会偏移。解决方法是调大死区到 20 左右,同时把电机速度映射函数改成带一段“低速拐点”的分段线性,让低速区间更粗、高速区间更细。

第五个问题:振动试验一启动,整板复位。偏心马达电刷打火产生的反向尖峰电压击穿了主控的电源轨。这时候去换程序没用,硬件上必须整改:马达两端并联一个 0.1uF 陶瓷电容,靠近 MOS 管漏极端并联一个 SS34 肖特基二极管,再把 7.4V 电池和 5V 降压模块之间的线换成粗一点的硅胶线。

4.4 下地测试前的一条原则

代码下地前,把遥控主循环里的电机输出先注释掉,只留舵机和振动。确认手柄摇杆在串口里的数值变化和遥控器摇杆机械方向一致,再开电机。这么做能省下大量追线时间。我第一次跑这车的时候,发现方向总是反的,折腾了半小时,最后发现是左摇杆 Y 值解析时符号弄反了,这种低级错误如果能在串口阶段发现,根本不用下地。

5. 二次开发:把小车的遥控手感改成你自己的调试平台

整台小车最值得改造的地方不是电机,不是舵机,而是摇杆映射逻辑。

首先建议把 3.2 里的线性和直接映射改成指数曲线。线性映射在小幅摇杆偏移时输出太大了,新手很容易把车开得一顿一顿。我常用的函数是让输出速度等于最大速度乘以偏移比例的平方,比如偏移量只有最大值的 20% 时,速度只出 4%。改造点在 remote_control.c 里,把 out->speed 的计算改成这样:

int16_t speed_raw = abs(raw_y); float ratio = (float)speed_raw / 128.0f; float expo = ratio * ratio; // 指数曲线,低速区更细腻 out->speed = (int16_t)(expo * 9999.0f);

这样做的好处是小幅打杆车走得非常稳,适合在室内窄通道调试;而需要快速急转时,打满杆照样能输出全速。

第二个建议是做一个固件自检函数 SelfTest(),在上电后自动读取一次按键矩阵并串口打印。PS2 手柄的按键矩阵分布在 raw[3] 和 raw[4] 两个字节里,每个 bit 对应一个键。自检函数可以循环等待 3 秒,把手柄上按下的键打印成表格,确认硬件接对后再进主循环。我从那次画龙排查之后,每次改动代码都会强制走一遍自检函数,确认底层数据没问题再验证上层逻辑,省下的时间够写一版新功能。

第三个实用改造是保存舵机中位和摇杆死区到内部 Flash。STM32F103 自带 64KB Flash,可以预留最后一页存参数。用 HAL_FLASH_Program 写入结构体,上电时读出来覆盖代码里的默认值。这样每辆车的舵机中位偏差都能在免编译的情况下单独校准,批量做教学项目时非常香。具体做法是定义一页地址为 0x0800F800,长度不超过 2KB,结构体里放 ServoMiddlePulse、StickDeadzone、VibrationStrength 三个变量。希望这些改动思路能帮你把这台小车从“能遥控”一步步调成“好操作、好价值”的个人调试台,少走我当年走的弯路,毕竟谁都不想因为一根杜邦线松了就在调试台上发一下午呆。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 2:58:25

低代码+条码/RFID:中小工厂低成本物料追踪系统落地指南

这个标题挺有意思&#xff0c;低代码、条码、RFID&#xff0c;这三个词放在一起&#xff0c;其实已经把一套完整的物料追踪方案给概括完了。我这两年实际搭过几套类似的系统&#xff0c;从最初的手工账、Excel台账&#xff0c;到后来用低代码平台配合扫码枪和RFID通道&#xff…

作者头像 李华
网站建设 2026/10/19 2:58:16

PARSEC与xFoil数据链路:MATLAB翼型优化流程拆解

简介&#xff1a;一套MATLAB环境下的机翼气动优化源码包&#xff0c;面向从事飞行器初步设计或翼型优化的工程师、研究生与高年级本科生&#xff0c;用于解决基于xFoil与Parsec几何参数化开展升阻性能自动寻优的问题。压缩包共8个文件&#xff0c;含7个MATLAB脚本和1个txt许可说…

作者头像 李华
网站建设 2026/10/19 2:57:02

IGMPv3源过滤原理与万级终端组播精准控制实战

1. 项目概述&#xff1a;为什么IGMPv3是组播控制的分水岭组播不是“群发邮件”&#xff0c;也不是“广播喇叭”&#xff0c;它更像一场精准调度的物流配送——源端只发一份数据&#xff0c;网络设备根据终端“订货单”决定把这份数据复制几份、送到哪几个出口。而IGMPv3&#x…

作者头像 李华
网站建设 2026/10/19 2:56:51

nreal AR开发实战:从硬件特性到真机调优的全流程指南

1. 项目概述&#xff1a;从“看到AR”到“做出AR”&#xff0c;nreal开发不是调API那么简单最近在某高校实验室带一个跨专业学生小组做空间交互Demo&#xff0c;设备选型阶段翻遍了主流消费级AR眼镜参数表&#xff0c;最后锁定了nreal——不是因为它最便宜&#xff0c;也不是因…

作者头像 李华
网站建设 2026/10/19 2:54:20

CNN-GRU-Attention混合模型实现电力负荷短期预测完整实践

直接说结论&#xff1a;这个项目不是那种“跑个公开数据集、贴张loss曲线就完事”的演示型代码。它把卷积、循环网络和注意力机制三套东西拧在一起&#xff0c;专门用来做电力负荷的短期预测&#xff0c;而且给了完整可跑的Python实现。我拿到手之后没有直接当黑盒跑&#xff0…

作者头像 李华
网站建设 2026/10/19 2:54:20

轻量级目标检测与实例分割架构YOLO-Master:从原理到部署实践

最近在折腾边缘端目标检测&#xff0c;手里有几个模型因为体积和计算量的问题被卡在部署环节&#xff0c;正头疼的时候看到了腾讯开源的轻量级YOLO架构YOLO-Master。从仓库描述来看&#xff0c;它不仅覆盖了标准的检测推理&#xff0c;还把实例分割一并做了&#xff0c;官方提供…

作者头像 李华