STM32+PS2手柄遥控小车:SPI协议解析与PWM电机控制实战
简介基于STM32F103C8T6的PS2遥控小车源码及接线资源面向嵌入式初学者、电子DIY爱好者以及课程设计开发人员解决PS2手柄无线遥控小车从协议解析到电机驱动的完整实现问题。包内共80个文件以C源码和H头文件为主包含PS2协议解析、PWM控制、GPIO配置、时序控制及错误检测等关键模块另有STM32启动文件、Keil工程配置与已调通的接线图整体仅557KB目录结构清晰便于对照学习或二次开发。项目基于ARM Cortex-M3内核的STM32F103C8T6主控通过PS2手柄接收器接入数据线与时钟线由PWM信号调节电机速度与方向已在硬件上完成验证接线图标注了引脚对应关系可快速完成硬件连接。已有619人学习下载适合希望快速上手STM32外设编程、无线通信协议解析及小车运动控制的开发者参考借鉴也可在此框架上继续扩展避障、循迹、蓝牙遥控等功能。1. 用 PS2 手柄控车为什么比蓝牙和 2.4G 更省事做遥控小车主控选 STM32F103C8T6 基本没有争议72MHz 主频、20KB RAM、丰富的定时器和 USART几十块钱的最小系统板就能跑项目。真正让新手卡住的往往是遥控方案——蓝牙模块要配对、要写上位机协议2.4G 模块要自己订帧格式一折腾就是两三天。而 PS2 手柄这套方案接收器直接输出 SPI 时序信号主控只要模拟时序读回按键数据剩下的事情就是控制电机整个链路最短。这个项目的源码包是已经调通的工程基于标准外设库StdPeriph_Lib打开 Keil 编译直接能烧录。它覆盖了三块核心内容PS2 手柄的 SPI 协议解析、PWM 电机调速、GPIO 控制电机正反转。适合刚学完 GPIO 和定时器、想做一个完整可跑小车的入门者也适合想把手柄协议吃透后移植到其他主控的开发者。后面所有分析都围绕这个工程展开接线图和代码结构也会逐一拆开讲。2. PS2 协议解析SPI 时序、按键映射与轮询逻辑2.1 PS2 接收器工作在 SPI Slave 模式PS2 无线接收器对外有 6 根引脚DAT、CMD、CS、CLK、VCC、GND。DAT 输出数据CMD 接收主机命令CS 片选CLK 时钟。接收器本质是一个 SPI 从设备主机STM32主动发起通信时钟频率在 250kHz 到 500kHz 之间比较稳定用 GPIO 模拟时序比硬件 SPI 更好控制。每次完整通信需要发送 9 个字节。主机发送 0x01 命令后接收器进入数据输出模式随后主机发送 0x42 读取数据再连续读 6 个字节。整个流程如下表字节序主机发送接收器返回含义10x010xFF进入命令模式20x420x41读取数据请求30x000x5A数据头40x00按键状态字节1数字按键50x00按键状态字节2数字按键60x00右摇杆X模拟量 0x00-0xFF70x00右摇杆Y模拟量 0x00-0xFF80x00左摇杆X模拟量 0x00-0xFF90x00左摇杆Y模拟量 0x00-0xFFCS 在整个通信过程中拉低9 字节传完后拉高。每个字节在 CLK 下降沿采样数据低位在前。源码里PS2_ReadData()函数的核心逻辑就是按这个时序一位一位读。2.2 按键映射与模拟量归一化按键状态字节按位取反后映射bit0 对应 SELECTbit1 对应 L3bit2 对应 R3bit3 对应 STARTbit4 对应上bit5 对应右bit6 对应下bit7 对应左。第二个字节则对应 L2、R2、L1、R1、△、○、✕、□。源码中PS2_HandleData()函数对读回的 6 个字节做判断把按键按下状态存到全局结构体PS2_Data中。// ps2.c 按键解析部分节选 u8 PS2_HandleData(void) { if (PS2_ReadData() SUCCESS) // 读取成功 { // 按键字节取反按下为1 PS2_Data.key1 ~Data[3]; // 第一组按键 PS2_Data.key2 ~Data[4]; // 第二组按键 // 摇杆模拟量直接保存0x80是中间位置 PS2_Data.RX Data[5]; // 右摇杆X PS2_Data.RY Data[6]; // 右摇杆Y PS2_Data.LX Data[7]; // 左摇杆X PS2_Data.LY Data[8]; // 左摇杆Y return SUCCESS; } return ERROR; }摇杆数据是 8 位模拟量静止时在 0x80128附近推到极限是 0x00 到 0xFF。控制小车时不能直接把原始值给 PWM需要做差值和死区处理。本项目用的是方向键控制摇杆数据预留了但没用到后面第 5 章会讲如何把摇杆映射成线性速度。2.3 轮询频率与按键抖动处理遥控车的响应速度取决于 PS2 数据的轮询频率。工程里把PS2_HandleData()放在主循环里调用每个循环周期大约 2ms也就是 500Hz 的刷新率。这个频率对小车控制足够了——电机响应带宽本身只有几十 HzPS2 接收器内部也有硬件去抖软件里不需要额外做延时消抖。一个值得注意的细节PS2 协议要求 CS 拉低后必须等至少 10us 再开始传时钟否则接收器可能不响应。源码里在 CS 拉低后有一个delay_us(10)这个延时不能省。3. 电机控制链路PWM 生成、方向切换与驱动芯片选型3.1 GPIO 配置与 PWM 通道分配小车的两个电机左轮、右轮各需要两路控制一路 PWM 调速一路 DIR方向信号决定正反转。源码里用的是 TIM2 定时器CH1 和 CH2 分别输出两路 PWM对应引脚 PA0 和 PA1。方向控制引脚是 PB5 和 PB6通过 GPIO 输出高低电平切换驱动芯片的 IN 引脚。// main.c 定时器初始化部分节选 void TIM2_PWM_Init(u16 arr, u16 psc) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // PA0 和 PA1 复用为 TIM2 的 CH1/CH2 输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period arr; TIM_TimeBaseStructure.TIM_Prescaler psc; TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, 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(TIM2, TIM_OCInitStructure); TIM_OC2Init(TIM2, TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_EnableARRPreload(TIM2, ENABLE); TIM_Cmd(TIM2, ENABLE); }这段配置把 TIM2 的时钟源设为 APB136MHzpsc 设为 71 后计数频率变成 500kHzarr 设为 999 时 PWM 频率为 500Hz。PWM 频率选 500Hz 是为了配合电机驱动芯片的滤波电容频率太低电机会有明显啸叫频率太高驱动芯片开关损耗变大。3.2 驱动芯片接线TB6612 与 L298N 的差异源码包附带的接线图是基于 TB6612FNG 驱动芯片的这款芯片最大电流 1.2A体积小适合小型小车。它的逻辑输入直接兼容 3.3VSTM32 的 GPIO 不需要电平转换。替换方案是 L298N但 L298N 的逻辑高电平最低需要 2.3V虽然也能用 3.3V 驱动但发热大、效率低不推荐。TB6612 的接线要点VM 接电池正极7V-12VVCC 接 3.3V 或 5V 逻辑电源GND 与 STM32 共地。AIN1/AIN2 控制电机 A 方向PWMA 控制速度BIN1/BIN2 和 PWMB 同理。源码里 PB5 控制 AIN1PB6 控制 AIN2PA0 输出 PWMAPA1 输出 PWMB。// main.c 电机控制逻辑节选 void Motor_Control(int left_speed, int right_speed) { // 左电机速度为正前进为负后退 if (left_speed 0) { GPIO_SetBits(GPIOB, GPIO_Pin_5); // AIN1 1 GPIO_ResetBits(GPIOB, GPIO_Pin_6); // AIN2 0 TIM_SetCompare1(TIM2, left_speed); // 设置占空比 } else { GPIO_ResetBits(GPIOB, GPIO_Pin_5); // AIN1 0 GPIO_SetBits(GPIOB, GPIO_Pin_6); // AIN2 1 TIM_SetCompare1(TIM2, -left_speed); } // 右电机逻辑与左电机相同使用 PB6 与 TIM2_CH2 // ... }这里TIM_SetCompare1()的第二个参数范围是 0 到 arr999也就是占空比 0% 到 100%。注意传入的值不能超过 arr否则 PWM 输出会一直为高电机会全速转这是新手最容易踩的坑。另一个坑是切换方向时先要把 PWM 降为 0再改 GPIO 电平否则电机在高速转动时瞬间反接冲击电流可能烧驱动芯片。4. 工程源码结构与 Keil 移植要点4.1 标准外设库版本与启动文件选择项目用的是 ST 标准外设库STM32F10x_FWLib不是 HAL 库。源码目录里有两个启动文件startup_stm32f10x_hd.s和startup_stm32f10x_md.s。STM32F103C8T6 是 64KB Flash、20KB RAM 的中等容量芯片必须选 md 版本。如果误选了 hd 版本程序也能编译但烧录后可能进 HardFault因为中断向量表地址对不上。工程的 OBJ 目录里已经编译出了LED.hex说明作者用的是 Keil MDK-ARM 并已生成烧录文件。keilkilll.bat这个脚本的作用是清理编译中间文件包括 OBJ 目录下的 .o、.d、.crf 等重新打开工程后全量编译一次。这个脚本在网上下载的工程里很常见本质是删掉 Keil 生成的临时文件让工程体积变小也避免残留文件导致的编译异常。4.2 工程文件结构逐层拆解从文件列表看工程结构很清晰。HARDWARE 目录放外设驱动SYSTEM 目录放延时和串口CORE 目录放内核相关文件。这种分层方式是从正点原子工程模板沿袭下来的逻辑上把芯片内核、系统组件、外设驱动分开了。目录文件作用COREcore_cm3.c/.h、startup_stm32f10x_md.sCortex-M3 内核访问函数、中断向量表SYSTEMsys.c、delay.c、usart.c系统时钟配置、延时函数、串口打印HARDWAREPS2、LED手柄驱动与指示灯控制USERmain.c、stm32f10x_it.c、stm32f10x_conf.h主逻辑、中断服务函数、外设配置头文件STM32F10x_FWLibinc/src标准外设库源码和头文件stm32f10x_it.c里只有系统滴答定时器中断和几个默认中断处理函数。项目没有用外部中断读取 PS2 数据而是用轮询方式所以中断部分很简单。如果以后要加编码器测速可以在stm32f10x_it.c里补上定时器中断处理函数TIMx_IRQHandler。4.3 keilkilll.bat 的工作机制这个批处理脚本的内容实际上很直接它遍历工程目录删除 Keil 编译时产生的中间文件。关键删除项包括 OBJ 目录下的全部内容、.uvguix.*和.uvopt文件。.uvgui文件是 Keil 的界面布局配置删掉后重新打开工程会恢复默认视图。在命令行执行keilkilll.bat前要先关闭 Keil 工程否则正在被占用的 .uvopt 文件会删除失败。执行完脚本后再打开 STM32_PS2.uvprojxKeil 会重新生成缺失的文件并触发全量编译。如果编译不过先检查stm32f10x_conf.h里的外设头文件包含是否齐全——标准外设库工程最常见的编译错误就是这个头文件里没注释掉对应的宏导致调用的外设库函数找不到声明。5. 调试技巧串口打印按键值、逻辑分析仪抓时序与摇杆线性映射5.1 用串口验证 PS2 通信是否正常上电后第一步不是控制电机而是确认手柄数据读回来了。源码里的 usart.c 已经初始化了 USART1默认引脚 PA9TX和 PA10RX。在PS2_HandleData()返回成功之后把按键值和摇杆值通过串口发到调试助手波特率 115200// main.c 调试输出节选 while (1) { if (PS2_HandleData() SUCCESS) { printf(key10x%02X key20x%02X\r\n, PS2_Data.key1, PS2_Data.key2); printf(LX%d LY%d RX%d RY%d\r\n, PS2_Data.LX, PS2_Data.LY, PS2_Data.RX, PS2_Data.RY); } delay_ms(50); }打开串口助手按下手柄按键时如果 key1/key2 的十六进制值有对应变化说明通信链路正常。如果始终打印 0xFF检查接收器的 VCC 是否接了 3.3V——很多 PS2 接收器标称 5V 供电但 STM32 的 GPIO 是 3.3V 电平5V 供电时 DAT 引脚输出的高电平会超过 3.3V可能直接损伤 GPIO。稳妥做法是给接收器单独供 5V然后 DAT/CMD/CLK/CS 四根信号线串联 1kΩ 电阻再做电平匹配。5.2 逻辑分析仪看时序CLK 频率和 CS 时序如果串口打印的数值乱跳或者按下按键没反应用逻辑分析仪抓 CLK、DAT、CS 三根线采样率设 1MHz 以上。正常波形应该是一个通信周期约 9 × 8 72 个时钟脉冲CS 在脉冲序列期间保持低电平DAT 在 CLK 下降沿稳定有效。抓到的时序如果只有 16 个脉冲就停了说明读数据的字节数配置不对检查循环里读 6 个字节的那段代码是否有数组越界。另一个常规问题CLK 的频率范围要控制在 250kHz 到 500kHz 这个区间如果主循环里的delay_us()延时太短CLK 频率会飙到 1MHz 以上接收器会直接丢弃命令。用逻辑分析仪测一下 CLK 的实际频率超过 500kHz 就给延时函数加几微秒。5.3 摇杆线性映射与死区处理方向键控制只有开关量无法做到平滑调速。用摇杆实现线性控制时需要把原始摇杆值做归一化处理并添加死区避免静止时抖动导致电机微动// 摇杆转速度的映射函数 int map_joystick(u8 raw) { int value (int)raw - 128; // 转为 -128 到 127 if (value -15 value 15) // 死区 { return 0; } value (value * 999) / 128; // 映射到 PWM 范围 if (value 999) value 999; if (value -999) value -999; return value; }注意这里死区设为 ±15对应摇杆在中间位置附近约 12% 的行程。死区太小静止时 PWM 会有微小输出电机发出嗡嗡声死区太大摇杆推到一定角度车才动操控感变差。±10 到 ±20 之间都是合理范围取决于摇杆的物理回中精度。映射用乘法做归一化而不是除法是因为 STM32F103 的硬件除法较慢整数乘法更高效。扩展思路如果后续项目要加测速闭环可以在电机输出轴加霍尔编码器把 TIM2 的空闲通道配置为编码器模式读回真实转速后与 PS2 摇杆给定值做 PID 运算。PS2 协议解析部分不需要改动只需在Motor_Control()函数外面包一层速度环即可。这个工程的手柄协议解析和 PWM 输出已经分离做闭环时改动量很小。本文还有配套的精品资源点击获取