简介:这份资源面向嵌入式开发初学者与STM32实践者,提供一套用PS2游戏手柄控制四轮全向轮小车的完整工程,解决手柄信号解析、电机PWM调速与全向移动控制等典型问题。压缩包共233个文件,约8.28MB,以c与h源码、o与crf编译中间文件、d依赖文件为主,另含uvprojx工程、hex固件、map映射、bat清理脚本及README说明,覆盖BSP、USER、CORE、SYSTEM与固件库等模块,便于直接编译与二次开发。已有746人学习下载。读者可从中获得PS2手柄SPI通信解析、STM32RCT6定时器PWM输出、四轮独立电机控制逻辑与Keil工程组织方式,适合作为课程设计、竞赛小车或嵌入式入门的参考范例。
1. 从零搭一台 PS2 手柄控制的四驱小车:为什么 STM32 是绕不开的那块板
很多人第一次做遥控小车,脑子里想的是「买个蓝牙模块,手机 App 划一划就完事」。真上手才发现,手机触屏没有手感,延迟忽高忽低,想做个比例调速还得自己写 UI。于是绕一圈回来,PS2 手柄配 STM32 这套组合依然是性价比最高的方案——手柄自带双摇杆、十字键、振动马达,接收器走 SPI 协议,STM32 这边一个定时器出四路 PWM 就能把四个电机管得服服帖帖。这份资源就是围绕「PS2 控制 4 电机小车」这个经典命题展开的完整工程,包含主控固件、电机驱动逻辑和手柄解析代码。它适合已经点过 LED、串口能打印、但还没把「输入设备 + 执行机构」串成闭环的嵌入式新手,也适合想拿它当底盘控制模板、往上叠摄像头或机械臂的老手。下面我按自己拆包复现的顺序,把选型、接线、代码和踩过的坑一次讲清。
2. 硬件选型与接线:四路 PWM 怎么分、PS2 接收器挂哪个 SPI
2.1 主控与电机驱动的搭配逻辑
STM32 家族里做小车最顺手的是 F103C8T6 这类「蓝板」或者 F407 这类带更多定时器通道的型号。核心诉求只有两条:一是至少能输出四路独立 PWM,二是留出一路 SPI 给 PS2 接收器。F103C8T6 的 TIM1 有四个通道,CH1 到 CH4 正好对应四个电机,PA8 到 PA11 引出即可;SPI1 用 PA5/PA6/PA7 三根线接接收器的 DAT、CMD、CS,再加一根 PB0 读 ATT 信号判断手柄是否连上。电机驱动别直接用 IO 推,电流扛不住,常见做法是上 L298N 或 TB6612。L298N 便宜、耐造,但压降大,电池 7.4V 进去出来只剩 6V 左右;TB6612 效率高、发热小,代价是贵一点、接线要看清 STBY 脚。我一般给新手推 L298N,烧了不心疼,逻辑也直观:每路两个方向脚加一个使能脚,使能脚接 STM32 的 PWM 输出。
供电是另一条命脉。PS2 接收器要 3.3V,STM32 也要 3.3V,但电机驱动和电机本身吃的是电池电压。千万别把电机那路电直接灌到主控的 3.3V 上,电机启动瞬间的浪涌能把 MCU 拉复位。正确做法是电池先过降压模块出两路:一路 5V 给 L298N 的逻辑供电和 STM32 的板载稳压,一路 3.3V 单独喂 PS2 接收器。共地必须做,否则 SPI 通信时好时坏,这种玄学问题查起来最费时间。
2.2 接线表与上电前检查
把线接成下面这样,基本不会翻车:
| STM32 引脚 | 连接对象 | 说明 |
|---|---|---|
| PA8 / PA9 / PA10 / PA11 | L298N 四个使能脚 | TIM1 CH1~CH4,出 PWM |
| PB12~PB15 | L298N 八个方向脚 | 每两个控制一个电机正反转 |
| PA5 / PA6 / PA7 | PS2 接收器 CLK/CMD/DAT | SPI1 三线 |
| PB0 | PS2 接收器 ATT | 片选/注意信号 |
| 5V / GND | L298N 逻辑电源 | 与主控共地 |
| 3.3V / GND | PS2 接收器电源 | 单独走线更稳 |
上电前拿万用表蜂鸣档扫一遍:电源正负有没有短路、电机线有没有搭到信号线上。我见过一次因为电机引线毛刺碰到 PB12,一上电方向脚直接拉低,电机疯转,手柄还没连上小车就窜出去了。检查完再插电池,先不装轮子,看四个电机是不是按预期正反转,确认无误再落地跑。
3. PS2 手柄协议解析:SPI 时序、按键位和摇杆死区怎么处理
3.1 接收器通信的时序要点
PS2 接收器和手柄之间是无线,但接收器和 STM32 之间是标准 SPI,模式 3(CPOL=1,CPHA=1),时钟别超过 250kHz,太快了接收器反应不过来。每次读数据要拉低 ATT,发一个 0x01 起始字节,再发 0x42 请求数据,然后连续读回 9 个字节。第 3、4 字节是按键状态,第 5 到第 8 字节是左右摇杆的 X/Y 轴,第 9 字节留着不用。很多人卡在「读回来全是 0xFF」,八成是 ATT 拉低后没等够时间,或者 SPI 相位设错了。下面这段是初始化加读取的核心逻辑:
// PS2 接收器初始化:配置 SPI1 和 ATT 引脚 void PS2_Init(void) { // SPI1 模式3,时钟分频到 128,约 70kHz SPI1->CR1 = SPI_CR1_MSTR | SPI_CR1_CPOL | SPI_CR1_CPHA | SPI_CR1_SSM | SPI_CR1_SSI | SPI_CR1_BR_2 | SPI_CR1_BR_1; SPI1->CR1 |= SPI_CR1_SPE; // ATT 推挽输出,默认拉高 GPIOB->CRH &= ~(0xF << 0); GPIOB->CRH |= (0x3 << 0); GPIOB->BSRR = GPIO_BSRR_BS0; } // 读一次完整数据包,buf 至少 9 字节 void PS2_ReadData(uint8_t *buf) { GPIOB->BRR = GPIO_BRR_BR0; // ATT 拉低,开始通信 delay_us(10); // 给接收器准备时间 buf[0] = PS2_Transfer(0x01); // 起始 buf[1] = PS2_Transfer(0x42); // 请求数据 buf[2] = PS2_Transfer(0x00); // 占位 for (int i = 3; i < 9; i++) { buf[i] = PS2_Transfer(0x00); // 读回按键和摇杆 } GPIOB->BSRR = GPIO_BSRR_BS0; // ATT 拉高,结束 delay_us(10); }PS2_Transfer就是标准的「写一个字节同时读一个字节」,等 TXE 置位写 DR,等 RXNE 置位读 DR。注意delay_us(10)这两处别省,接收器内部要时间切换状态,省了就会间歇性读到乱码。按键字节里每一位对应一个键,比如第 3 字节的 bit4 是上、bit5 是右、bit6 是下、bit7 是左,第 4 字节的 bit6 是叉、bit7 是圈。摇杆是 0 到 255,中间值约 128,但实际手柄有偏差,得做死区。
3.2 摇杆死区与按键消抖
摇杆回中时读数不会正好是 128,可能是 124 或 132,直接拿去做比例控制,小车会自己慢慢爬。常见做法是设一个死区,比如 ±10,落在死区内就归零。代码里这样写:
// 摇杆死区处理,raw 是原始值,返回 -100 到 100 的百分比 int8_t Joystick_Deadzone(uint8_t raw) { int16_t center = 128; int16_t diff = (int16_t)raw - center; if (diff > -10 && diff < 10) return 0; // 死区内归零 // 把剩余范围映射到 -100~100 if (diff > 0) return (int8_t)((diff - 10) * 100 / (127 - 10)); else return (int8_t)((diff + 10) * 100 / (128 - 10)); }死区大小不是固定的,手柄用久了电位器磨损,死区可能要放到 ±15 甚至 ±20。按键那边也要消抖,但 PS2 手柄本身有硬件消抖,软件里读到的按键状态已经比较干净,一般不用额外处理,除非你发现按一下触发好几次,那就在应用层加个 20ms 的锁。
4. 四电机 PWM 控制:差速转向、调速曲线和方向逻辑
4.1 四路 PWM 的配置与占空比映射
TIM1 配置成 PWM 模式 1,预分频器设 71,自动重装载值设 999,这样计数频率是 72MHz/72/1000 = 1kHz,电机驱动听这个频率刚好,太高了 L298N 开关损耗大,太低了电机有啸叫。四个通道的占空比用TIM1->CCR1到CCR4直接写,范围 0 到 999。把摇杆的 -100 到 100 映射到 0 到 999 时要注意,负值代表反转,得同时改方向脚和占空比:
// 设置单个电机的速度和方向,motor 取 0~3,speed 取 -100~100 void Motor_Set(int motor, int8_t speed) { uint16_t duty = (speed >= 0) ? (speed * 10) : (-speed * 10); if (duty > 999) duty = 999; // 方向脚:正转拉高一个、拉低另一个,反转反之 switch (motor) { case 0: // 左前 if (speed >= 0) { GPIOB->BSRR = GPIO_BSRR_BS12; GPIOB->BRR = GPIO_BRR_BR13; } else { GPIOB->BRR = GPIO_BRR_BR12; GPIOB->BSRR = GPIO_BSRR_BS13; } TIM1->CCR1 = duty; break; case 1: // 右前 if (speed >= 0) { GPIOB->BSRR = GPIO_BSRR_BS14; GPIOB->BRR = GPIO_BRR_BR15; } else { GPIOB->BRR = GPIO_BRR_BR14; GPIOB->BSRR = GPIO_BSRR_BS15; } TIM1->CCR2 = duty; break; // 后两个电机同理,对应 CCR3 和 CCR4 } }speed * 10是因为摇杆范围 100 对应占空比 1000,但重装载值是 999,所以最后要夹一下。方向脚切换和占空比写入之间最好加几个微秒延时,让 L298N 内部先完成方向切换再给 PWM,不然会有瞬间直通电流,驱动芯片发烫。
4.2 差速转向与原地旋转
四驱小车的转向靠左右两侧速度差。左摇杆 Y 轴控制前进后退,X 轴控制转向。常见做法是:
// 混控:ly 是前后,lx 是左右,输出四个电机速度 void MixControl(int8_t ly, int8_t lx) { int16_t left = ly + lx; // 左侧两个电机 int16_t right = ly - lx; // 右侧两个电机 // 限幅到 -100~100 if (left > 100) left = 100; if (left < -100) left = -100; if (right > 100) right = 100; if (right < -100) right = -100; Motor_Set(0, left); // 左前 Motor_Set(2, left); // 左后 Motor_Set(1, right); // 右前 Motor_Set(3, right); // 右后 }这样推左摇杆往前是直行,往左是左转,往左上角是左前弧线。如果想让右摇杆单独控制原地旋转,可以把右摇杆 X 轴也加进来,左加右减,四个轮子就能原地打转。调速曲线方面,线性映射够用,但如果你觉得低速段太窜,可以加个平方曲线,speed = speed * abs(speed) / 100,低速更细腻,高速依然有力。
5. 避坑与排查:从「手柄没反应」到「电机抽风」的五个血泪现场
5.1 手柄连不上,读回来全是 0xFF
现象:程序跑起来,串口打印按键数据,永远是 255。原因通常是 SPI 模式不对或者 ATT 时序太紧。PS2 接收器要求模式 3,且 ATT 拉低后至少等 5 微秒再发第一个字节。解决:把 SPI 的 CPOL 和 CPHA 都置 1,在 ATT 拉低后加delay_us(10),拉高后也加。还不行就降 SPI 时钟,分频到 256 试试。
5.2 电机不转,但驱动板发热
现象:上电后 L298N 烫手,电机纹丝不动。原因是方向脚同时拉高了,或者 PWM 和方向脚接反。L298N 每路两个方向脚不能同时为高,否则内部桥臂直通。解决:用万用表测方向脚电压,确认同一时刻只有一个为高;检查代码里Motor_Set的 case 分支有没有写错引脚。
5.3 小车跑起来往一边偏
现象:摇杆回中,小车自己慢慢转圈。原因是摇杆死区没设,或者四个电机的机械阻力不一致。解决:先把死区加到 ±15,如果还偏,就在混控输出后给每个电机加一个微调系数,比如左前乘 0.95,右后乘 1.02,用实验法配平。别指望开环控制能走直线,这是物理问题,不是代码问题。
5.4 一给大油门就复位
现象:摇杆推到底,STM32 重启。原因是电机启动电流把电池电压拉低,主控的 3.3V 稳压扛不住。解决:电池和主控之间加个大电容(比如 470μF)缓冲,或者把电机电源和主控电源彻底分开走线,只在电池端共地。还不行就换更大容量的电池,或者给电机加软启动,占空比从 0 慢慢爬到目标值。
5.5 按键响应延迟或连击
现象:按一下叉键,串口打印好几次。原因是主循环里读 PS2 的间隔太短,手柄还没更新状态就被读走了。解决:把 PS2 读取放在定时器中断里,固定 20ms 读一次,主循环只处理逻辑。这样既消抖又不会丢按键。
6. 进阶玩法:用 PS2 手柄做底盘标定与数据回传
把基础跑通之后,我习惯做一件事:给小车加一套标定模式。按住 L1 加 R1 进入标定,此时摇杆推到底记录四个电机的最大占空比,松手记录死区中心值,存到 Flash 里。下次上电直接读,不用每次改代码。这个技巧在换电池、换电机之后特别省事,因为电压变了,同样的占空比转速就不一样。
具体实现上,用 STM32 的 Flash 模拟 EEPROM,存一个结构体:
typedef struct { uint8_t center[4]; // 四个摇杆通道的中心值 uint8_t deadzone[4]; // 四个通道的死区 uint16_t max_duty[4]; // 四个电机最大占空比 uint16_t crc; // 校验 } CalibData;标定时把摇杆推到极限,记录原始值,算出中心和死区。运行时用标定值替代硬编码的 128 和 10。CRC 用来判断 Flash 里是不是有效数据,第一次上电全是 0xFF,CRC 对不上就加载默认值。
另一个实用技巧是数据回传。把 PS2 的原始数据、混控后的电机速度、电池电压通过串口以 50Hz 发出来,上位机用任意串口工具接上,就能看到摇杆曲线和电机响应。调死区、调混控比例的时候,盯着曲线改参数,比盲猜快得多。我一般会在串口协议里加一个帧头 0xAA 和帧尾 0x55,中间放 8 个字节的数据,上位机解析起来不容易错位。
最后说个习惯:每次改完电机接线或者换电池,我都会先架空轮子跑一遍全油门,看四个轮子的转向和速度是不是一致,再落地。这个动作花不了一分钟,但能避免小车一上电就窜出去撞墙。从那以后我每次调完参数都强制走一遍架空测试,希望帮到你。
本文还有配套的精品资源,点击获取