简介:这是一套基于PS2游戏手柄的STM32遥控小车资料包,面向嵌入式初学者与电子制作爱好者,围绕手柄解码、舵机控制与振动试验展开。压缩包约1.04MB,共95个文件,以C源码(27个.c)和头文件(28个.h)为主,另有PDF协议手册、Keil工程文件、Hex固件、批处理脚本及Arduino参考例程,覆盖从协议解析到工程配置的常见环节。已有659人学习使用。资料不仅包含PS2解码通讯手册与常见问题汇总,还提供STM32下「遥控小车」「舵机控制」「振动试验」三类独立例程,以及Arduino版本对照代码,便于理解手柄数据包结构、通信时序与PWM舵机驱动原理。对于想快速跑通PS2手柄控车、验证振动反馈逻辑的开发者,是一份可直接参考的案例集合。
1. PS2 遥控小车:不只是“会跑”,舵机控制和振动试验才是完整闭环
很多人在 STM32 入门后做的第一个能动的项目就是小车,但绝大多数止步于“电机能转、蓝牙能连”。这份 PS2 遥控小车资源真正吸引我的地方在于,它没有停留在跑起来这个层面,而是把舵机控制、振动试验也一起做了,形成一个从无线指令输入到执行机构响应、再到车体稳定性验证的完整闭环。对于准备做课程设计或者想系统接触 PID 调试、PWM 控制、传感器数据采集的开发者来说,这份资源相当于把“学过的外设”全部串起来了。适合手里有一块 STM32 核心板、想从点灯进阶到运动控制的人,也适合需要快速搭一套带反馈验证的遥控车平台的工科学生。
2. 读懂 PS2 手柄的字节流:协议帧结构、按键映射与状态机
2.1 PS2 无线接收器到底在传什么:SPI 时序与 9 字节数据帧
PS2 无线手柄接收器与 STM32 之间通信,本质上是 SPI 从机模式。接收器是主机,每个通信周期拉低 CS(片选),然后通过 CLK 提供时钟,同时用 DI(数据输入)接受命令、DO(数据输出)返回数据。一次完整通信需要收发 9 个字节,前 1 个字节是进入配置模式的命令 0x01,中间 2 个字节是 0x42 查询模式命令,后面 6 个字节是手柄返回的按键与摇杆数据。很多第一次接触的人会误以为这是 UART 或 I2C,结果逻辑分析仪一挂,发现根本没有起始位和停止位,协议解析自然就卡住了。
按键数据的核心规则是低电平有效:对应位为 0 表示按下,为 1 表示松开。比如数据帧第 3 字节的 bit0 是 SELECT,bit1 是 L3,bit2 是 R3,bit3 是 START;第 4 字节的 bit0 到 bit3 分别是上、右、下、左方向键。摇杆数据分布在第 5、6 字节,左摇杆水平、垂直,第 7、8 字节是右摇杆。解析时如果只做“读串口打印”,不做位取反映射,很容易得到一堆反逻辑的数字,然后开始怀疑人生。
2.2 用 GPIO 模拟 SPI 主从时序:从机时钟适配的坑
虽然 STM32 有硬件 SPI,但在实际项目中,我一般会优先用 GPIO 模拟时序。原因不复杂:PS2 接收器的时钟由它自己产生,频率通常在 250kHz 左右,硬件 SPI 需要不断调整极性、相位和分频系数来匹配,而 GPIO 模拟可以逐个 bit 地等待 CLK 变化,兼容性更好。下面这段代码是 PS2 接收一个字节的核心逻辑,我在多个项目里验证过。
uint8_t PS2_ReadByte(void) { uint8_t data = 0; for (uint8_t i = 0; i < 8; i++) { PS2_CLK_HIGH(); // 等待接收器时钟拉高 while (PS2_CLK_READ() == 0); // 阻塞等待 CLK 高电平,适配慢速主机 PS2_DI_SET(data & 0x01); // 向主机发送命令位,低位在前 delay_us(5); // 给接收器足够的采样窗口 PS2_CLK_LOW(); // 拉低时钟,模拟从机采样沿 while (PS2_CLK_READ() == 1); // 等待 CLK 回低 data >>= 1; // 准备下一位 } return data; }这段代码的关键在于两个 while 等待,它们的作用是让 STM32 完全跟随 PS2 接收器的时钟节奏,而不是自己产生时钟。delay_us(5)是经验值,实际使用中要根据 CLK 频率调整;如果通信不稳定,把延时加大到 10 微秒,代价是按键响应略慢,但稳定性明显提升。需要注意,data & 0x01是发送当前 bit 给主机作为命令,PS2 协议里 STM32 每发送 1 bit,接收器就会在 DO 线上返回 1 bit,所以读数据和发命令必须交替进行,不能先发完 9 字节再读 9 字节。
2.3 按键状态机与摇杆死区:解码后如何转成小车指令
拿到 9 字节数据帧之后,不能直接拿来控制电机和舵机,因为按键按下和松开会产生重复帧,摇杆在静止位置也会有微小漂移。我处理时会在主循环里维护一个状态机:每一帧数据进来,先校验第 1 字节是否为 0x01、第 2 字节是否为 0x42,如果不匹配直接丢弃;通过校验之后,比较当前帧和上一帧的按键字节,只有发生变化时才更新控制指令,避免同一个按键按住时不断重复触发。
摇杆的处理则要设置死区。PS2 左摇杆水平方向的数据范围是 0x00 到 0xFF,中心值大约 0x80,但实际静止时读数可能在 0x70 到 0x90 之间波动。如果直接把 0x80 当作 0,小车会在原地轻微抖动。我一般会把中心值做动态校准:上电后读取 50 次摇杆数据取平均,把这个均值作为死区中心,然后设定死区范围 ±0x10,落在区间内就强制输出 0。这个校准逻辑放在初始化阶段,代码量不大,但对操控手感影响很大。
typedef struct { uint8_t left_x; uint8_t left_y; uint8_t right_x; uint8_t right_y; uint8_t keys[4]; } PS2_Frame; void PS2_ProcessFrame(PS2_Frame *frame, int16_t *motor_l, int16_t *motor_r, int16_t *servo_angle) { static int16_t center_x = 0x80; int16_t diff = (int16_t)frame->right_x - center_x; // 右摇杆控制舵机 if (diff > -16 && diff < 16) diff = 0; *servo_angle = 90 + (diff * 90 / 128); // 映射到 0~180 度 }这里把右摇杆水平方向映射到舵机角度,90 度是居中位置,diff的极值 ±128 对应舵机极限 ±90 度。实际调试时你会发现,摇杆不是完美线性器件,映射关系要考虑机械舵机的响应,所以我在代码里留了一个center_x变量,可以单独调整方向盘的零点。
3. 舵机控制:从 PWM 频率到角度映射的完整推导
3.1 为什么舵机要 50Hz:PWM 周期与脉宽的换算逻辑
标准模拟舵机的工作频率是 50Hz,也就是周期 20ms,对应的脉宽范围大约是 0.5ms 到 2.5ms。0.5ms 对应 0 度,1.5ms 对应 90 度,2.5ms 对应 180 度,这是舵机内部电路根据脉宽积分得到电压、再驱动电机转到对应角度的基本原理。如果 PWM 频率偏高,比如 100Hz,周期变成 10ms,舵机内部电位器反馈还没完成一个周期就被重置了,会出现啸叫、抖动、扭矩下降,甚至烧毁舵机驱动芯片。
在 STM32 上配置 PWM 时,首先要计算定时器的预分频系数和自动重装值。假设定时器时钟是 72MHz,目标是 50Hz,周期是 20ms,那么定时器计数总次数是 72000000 / 50 = 1440000。如果预分频系数设置为 143,计数器时钟变成 500kHz,那么自动重装值 ARR 设为 9999,计数周期就是 20ms。这是一组非常常用的配置,适用于绝大多数标准舵机。
3.2 TIM2_CH1 输出 PWM:初始化代码与占空比换算
我以 TIM2 的通道 1 输出舵机 PWM 为例,写一下完整初始化过程。注意,这个场景下舵机要单独供电,不能直接从 STM32 的 3.3V 引脚取电,舵机启动瞬间电流可能到 1A 以上,板载 LDO 扛不住。我用的是外部 5V 电源给舵机供电,共地后 PWM 信号线直连。
void Servo_Init(void) { GPIO_InitTypeDef gpio_init = {0}; TIM_TimeBaseInitTypeDef tim_base = {0}; TIM_OCInitTypeDef tim_oc = {0}; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); gpio_init.GPIO_Pin = GPIO_Pin_0; // PA0 复用为 TIM2_CH1 gpio_init.GPIO_Mode = GPIO_Mode_AF; gpio_init.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio_init); GPIO_PinAFConfig(GPIOA, GPIO_PinSource0, GPIO_AF_TIM2); tim_base.TIM_Prescaler = 143; // 72MHz / (143+1) = 500kHz tim_base.TIM_CounterMode = TIM_CounterMode_Up; tim_base.TIM_Period = 9999; // 20ms 周期 TIM_TimeBaseInit(TIM2, &tim_base); tim_oc.TIM_OCMode = TIM_OCMode_PWM1; tim_oc.TIM_OutputState = TIM_OutputState_Enable; tim_oc.TIM_Pulse = 1500; // 1.5ms,对应 90 度 TIM_OC1Init(TIM2, &tim_oc); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_Cmd(TIM2, ENABLE); }TIM_Pulse的值是计数器计数个数,500kHz 时钟下每个计数是 2 微秒,所以脉冲宽度 =TIM_Pulse× 2 微秒。要转到 0 度,TIM_Pulse设为 250;90 度设为 1500;180 度设为 2500。实际项目中,舵机的物理限位可能不是严格的 0 到 180 度,机械结构会让舵机臂在接近极限时卡死,所以我会在角度映射时限制输出范围,比如只输出 30 到 150 度对应的脉宽,保护舵机不被堵转。
3.3 舵机抖动与供电不足:如何区分信号问题和电源问题
舵机抖动是这个项目里最让人头疼的现象之一。抖动的表现有两种:一种是 PWM 信号正常但舵机在目标角度附近来回震荡,另一种是舵机完全不听指令,自己乱转。前者大概率是电源噪声——舵机转动时反向电动势会拉低电源电压,如果 STM32 和舵机共用一个电源,ADC 采集的电压参考也会跳动。后者则多半是 PWM 频率配置错误,或者定时器没有正确映射到 GPIO 复用功能。
我处理抖动时有一个固定流程:先用示波器看 PWM 波形,确认频率和脉宽;然后用万用表测舵机供电电压,转动瞬间电压跌落是否超过 0.3V;最后检查控制信号线长度和地线连接。常见做法是在舵机电源两端并联一个 470 微法电解电容和一个 0.1 微法瓷片电容,低成本但非常有效。另外一个容易忽略的地方是控制信号线不要和电机电源线绑在一起走线,否则电机换向时产生的尖峰脉冲会耦合到信号线上,舵机就会抽风。
4. 振动试验怎么做:从传感器选型到数据判定
4.1 为什么小车要振动试验:验证机械结构与飞线可靠性
很多开发者不理解遥控小车为什么要做振动试验,直到某一天小车跑着跑着突然舵机失灵,或者电机驱动芯片莫名复位,才意识到问题出在松动和共振上。小车在粗糙地面跑动时,车架振动频率大概在 10Hz 到 100Hz 之间,如果某个螺丝没拧紧,或者飞线焊接不牢固,振动会让接触电阻变大、信号中断,甚至导致 STM32 复位。振动试验的本质是模拟这种工况,让问题在实验室里暴露,而不是在赛场上暴露。
4.2 用 MPU6050 采集振动数据:I2C 初始化与原始加速度读取
MPU6050 是六轴惯性传感器,既能读加速度也能读角速度。做振动试验时,核心关注的是加速度数据,因为振动本质上就是加速度的周期性变化。我一般把 MPU6050 固定在车架中央,用双面胶加扎带双重固定,确保传感器本体不被相对位移干扰。读取加速度的代码并不复杂,I2C 通信做一次初始化、配置量程、然后连续读取 6 个字节的寄存器数据。
#define MPU6050_ADDR 0x68 #define ACCEL_XOUT_H 0x3B #define PWR_MGMT_1 0x6B void MPU6050_Init(void) { I2C_WriteByte(MPU6050_ADDR, PWR_MGMT_1, 0x00); // 退出睡眠模式 I2C_WriteByte(MPU6050_ADDR, 0x1C, 0x00); // 加速度量程 ±2g I2C_WriteByte(MPU6050_ADDR, 0x1B, 0x00); // 陀螺仪量程 ±250dps } void MPU6050_ReadAccel(int16_t *ax, int16_t *ay, int16_t *az) { uint8_t buf[6]; I2C_ReadBytes(MPU6050_ADDR, ACCEL_XOUT_H, buf, 6); *ax = (int16_t)((buf[0] << 8) | buf[1]); *ay = (int16_t)((buf[2] << 8) | buf[3]); *az = (int16_t)((buf[4] << 8) | buf[5]); }量程设置为 ±2g 时,加速度数据的精度是 16384 LSB/g,也就是说原始值除以 16384 就是重力加速度倍数。振动试验时最关心的是 Z 轴和 X 轴的数据,Z 轴反映车体垂直方向的跳动,X 轴反映加减速时的俯仰振动。我把采样频率定在 200Hz,也就是 5ms 读一次,因为小车的机械振动频率不会超过 100Hz,200Hz 采样足够覆盖。
4.3 振动指标的判定逻辑:峰值超过多少算异常
采集到原始加速度之后,不能直接看数据,要计算振动烈度。常用的指标是合成加速度向量幅值,取三个轴的平方和开根号,再减去重力加速度 1g 的偏置。计算出的结果如果持续超过某个阈值,就说明车体存在异常振动。我在设置阈值时参考了 ISO 标准的部分思路,但由于小车是轻量结构,直接套标准不合适,所以我采用相对法:空载静止时的振动残差作为基线,运行时测得的峰值超过基线 3 倍以上才算异常。
uint8_t Vibration_Check(int16_t ax, int16_t ay, int16_t az) { float gx = (float)ax / 16384.0f; float gy = (float)ay / 16384.0f; float gz = (float)az / 16384.0f; float magnitude = sqrtf(gx * gx + gy * gy + gz * gz); float vib = fabsf(magnitude - 1.0f); // 去除重力偏置 static float base_line = 0.05f; if (vib > base_line * 3.0f) { return 1; // 振动超限 } return 0; }这个判定逻辑有两个关键参数:基线的确定方式和倍数的选择。基线 0.05g 是我在平整桌面上实测的静止噪声水平,如果你的车架刚度差,噪声可能更大。倍数选 3 是因为振动信号中偶尔的尖峰不一定代表结构问题,但持续超过基线 3 倍就是机械松动或者轮胎动平衡不良。试验时我会让小车在不同路面上跑,在光滑瓷砖、粗糙水泥地、带减速带的地面各跑 2 分钟,记录超限次数和超限时间,超限次数超过 5 次就说明车架需要加固螺丝。
4.4 手写 FFT 还是看时域峰值:振动分析不炫技
有人会问,振动试验是不是要做 FFT 频谱分析才能得出结论?我的看法是,对于这种级别的项目,时域峰值统计完全够用。FFT 适合分析挂载了高速电机、需要诊断具体频率来源的场景,而小车振动的主要来源是路面激励,宽频随机成分占主导,做 FFT 反而可能被噪声干扰。如果你想看共振点,可以让小车在固定转速下运行,用 FFT 找加速度幅值最大的频率,但那是另一个试验场景,不适合作为日常质量验证的手段。所以这份资源里用峰值检测做振动报警是合适的,简单直接,CPU 占用也低。
5. 避坑与常见问题:五条血泪经验一次说清
5.1 PS2 手柄配对红灯闪烁,接收器收不到任何数据
现象:手柄和接收器之间没有建立连接,接收器红灯闪烁,STM32 一直读到 0xFF。
原因:最常见的是供电电流不足。PS2 无线接收器的功耗大约 25mA,但手柄在搜索配对信号的瞬间峰值电流会冲到 80mA 以上。如果接收器直接接在 STM32 开发板的 3.3V 引脚上,板载稳压器电流余量不足,手柄就永远无法完成配对。
解决:接收器的供电从 5V 电源直接引出,并且接收器、STM32、舵机三者共地。我用的是 LM2596 降压模块输出 5V,电流余量 3A,完全覆盖舵机和手柄的瞬时需求。
5.2 舵机上电后尖啸,转不动,手摸外壳发烫
现象:舵机像卡死一样,输入 PWM 脉宽怎么改都不动,持续异响。
原因:PWM 频率配置成了 100Hz 或更高,舵机内部电路无法正确解码;或者是舵机电源电压低于 4.8V,堵转电流又进一步拉低电压,形成恶性循环。
解决:用示波器或逻辑分析仪抓 PWM 波形,确认周期是否真的是 20ms。如果手边没有示波器,可以写一个测试程序,把TIM_Prescaler和TIM_Period按本章节第 3.2 节的值重写,再串口打印定时器的实际计数频率。
5.3 小车直线行驶时严重跑偏,左右轮速度不一致
现象:方向给 90 度(居中),小车却向右偏出 1 米以上。
原因:左边电机和右边电机即使占空比相同,由于机械阻力、电池电压差异、电机内阻差异,实际转速也不会完全一致。这是直流有刷电机的先天特性,不是代码错误。
解决:在代码里加一个左右轮校准系数。上电后让小车空转,用光电编码器或者手摸轮子感受转速,把慢的一侧 PWM 占空比上调 5% 到 10%,把这个系数存到 Flash 或者写死在配置头文件里。我一般会跑一个 5 米直线测试,调节校准值直到偏差小于 20 厘米。
5.4 振动数据全是乱跳,静止时加速度读数漂移严重
现象:MPU6050 放在桌面不动,读出来的加速度数值每秒变化 0.2g 以上。
原因:I2C 总线上拉电阻缺失或者阻值太大,导致信号边沿变缓,数据采样出现偶然错位;也可能是 MPU6050 的电源引脚和模拟地之间没有做去耦。
解决:在 SCL 和 SDA 线上各接一个 4.7k 欧姆上拉电阻到 3.3V,然后在 MPU6050 电源引脚并联 10 微法和 0.1 微法电容。如果引脚位置紧张,可以直接在杜邦线上串一个电阻模块。
5.5 按键按一下变成了连续触发,小车不受控制乱跑
现象:按下方向键后小车持续转向,松开后还在转,必须重新上电才能恢复。
原因:主循环里直接读取 PS2 接收器的数据,没有做帧间去抖。PS2 手柄在按键按下瞬间会产生多帧重复数据,而接收器返回的按键状态可能在 2 到 3 帧内不稳定。
解决:状态机里增加一个连续 3 帧一致才更新输出的逻辑,同时每次更新前校验帧头。代码如下:
uint8_t stable_count = 0; PS2_Frame last_valid_frame; uint8_t PS2_GetStableFrame(PS2_Frame *out) { PS2_Frame temp; if (!PS2_ReadFrame(&temp)) { stable_count = 0; return 0; } if (PS2_CompareFrame(&temp, &last_valid_frame)) { stable_count++; if (stable_count >= 3) { *out = temp; last_valid_frame = temp; stable_count = 0; return 1; } } else { stable_count = 0; last_valid_frame = temp; } return 0; }这个函数的思路是:只有连续 3 帧数据完全一致,才认为按键状态稳定,允许输出。PS2_CompareFrame按字节比较帧内容,返回 1 表示相同。代价是响应延迟约 15ms,人眼完全感知不到,但有效解决了伪触发问题。
6. 进阶玩法:串口可视化调试面板与舵机角度实时标定
做到这一步,小车已经能稳定遥控、舵机精准响应、振动异常报警,但这还不算一个完整的工程。最后的进阶技巧是搭一个串口可视化调试面板,用上位机把底盘状态和传感器数据实时画出来,方便调参和故障定位。
我一般会在 STM32 固件里维护一个调试结构体,每一帧包含舵机目标角度、实际 PWM 脉宽、左轮转速、右轮转速、振动幅值、电源电压。通过串口以 115200 波特率发送,每 20ms 发一次,格式用逗号分隔:
void Debug_SendStatus(int16_t servo_angle, uint16_t pwm_pulse, int16_t vib) { char buf[64]; sprintf(buf, "%.2f,%.2f,%.2f\r\n", (float)servo_angle, (float)pwm_pulse / 2.0f, // 还原脉宽, 单位毫秒 (float)vib / 1000.0f); UART_SendString(buf); }pwm_pulse / 2.0f是因为 500kHz 时钟下每个计数是 2 微秒,所以脉宽毫秒数等于TIM_Pulse除以 500。上位机我用的是串口猎人或者立创 EDA 的调试助手,把串口数据用逗号分隔解析成曲线。调舵机时观察角度和实际脉宽是否线性对应,调电机时看转速曲线是否平滑,这比裸眼看小车跑要高效得多,尤其是排查车辆跑偏和舵机抖动问题时很有用。
如果你愿意再加一点工作量,可以把振动超限时的加速度原始值连同时间戳一起存到串口日志里,跑完一圈之后对着日志检查哪个路段振动最大,甚至能定位是轮胎问题还是底盘螺丝松动。这个方法在模拟项目 X 里帮我排查过两次底盘异响,一次是右前轮轴承松了,一次是电池盒固定卡扣断裂,都是只看时域曲线很难发现的问题。
从那以后,我每次调小车都强制自己先搭串口可视化再上路测试,不让“感觉差不多”成为调参依据。数据说话,问题定位快很多。希望这些踩过坑的细节能帮你的小车少走几次弯路,也祝你手里的这套 PS2 遥控舵机振动实验平台一次点亮。
本文还有配套的精品资源,点击获取