尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32通过SPI协议驱动PS2无线手柄实现低成本遥控方案

STM32通过SPI协议驱动PS2无线手柄实现低成本遥控方案 1. 项目缘起为什么是STM32、SPI与PS2手柄最近在做一个需要无线遥控的小项目核心需求是找一个成本低、响应快、编程相对简单的无线控制方案。蓝牙和2.4G模块虽然常见但要么配对麻烦要么需要复杂的协议栈。翻箱倒柜找出了一个尘封已久的PS2游戏手柄就是那种带两个摇杆、一堆按键的无线手柄。这玩意儿当年在游戏机上用现在能不能让它在我的STM32项目里“复活”成为一个定制化的遥控器呢答案是肯定的。PS2手柄采用一种基于SPI协议的通信方式虽然它长得不像我们常见的SPI设备比如Flash、屏幕但底层就是SPI的变种。对于STM32来说用它的硬件SPI外设去“对话”再配合HAL库和CUBEMX进行图形化配置可以大大降低开发门槛。这个组合的优势很明显硬件成本极低一个二手PS2手柄加一个接收器不到50块通信稳定可靠SPI是全双工高速通信资源丰富网上有大量解码协议可以参考可玩性高摇杆、按键、振动电机都能利用起来。无论是做遥控小车、机械臂控制器还是游戏模拟器都是一个非常务实的选择。2. 核心原理拆解PS2手柄协议与SPI的“非典型”握手很多人一听到SPI就想到SCK、MOSI、MISO、CS四根线主从设备严格同步。PS2手柄的通信确实基于SPI但它加入了自己的“游戏规则”理解这些规则是成功解码的关键。2.1 PS2通信的物理层与信号逻辑PS2接收器就是那个插在主机上的小方块有9个引脚但我们核心只用其中6个VCC和GND供电通常接3.3V。DI/DAT 手柄 - 接收器的数据线对应MISO。DO/CMD 接收器 - 手柄的命令线对应MOSI。CS/SEL 片选信号低电平有效。CLK 时钟信号由接收器主机产生。这里第一个关键点信号电平。原始的PS2接口是5V TTL电平而STM32是3.3V CMOS电平。直接连接有损坏STM32IO口的风险。稳妥的做法是使用电平转换芯片如TXS0108E或者简单的电阻分压电路针对CMD、CLK从STM32到接收器的方向。对于从手柄返回的DAT信号实测很多3.3V单片机可以直接读取因为其高电平阈值可能低于3.3V但为了系统稳定建议也做电平匹配。2.2 通信帧格式与“一问一答”机制PS2通信不是简单的连续数据流而是一种严格的主机轮询从机响应模式。你可以把它想象成一场快问快答片选拉低CS - LowSTM32拉低CS引脚告诉手柄“注意我要开始和你说话了”。发送命令字节CMDSTM32通过MOSI线在每个时钟下降沿发送一个8位的命令字节给手柄。最常用的命令是0x01请求读取数据。读取响应字节DAT几乎在同时手柄会在时钟上升沿通过MISO线返回一个8位的响应数据。注意这个响应并不是对当前命令的回复而是对上一个周期命令的回复。这是一个重要的时序概念。循环上述步骤2和3重复进行通常持续32个时钟周期即发送32个命令字节读取32个响应字节。片选拉高CS - High通信结束STM32拉高CS。这32个字节的响应数据就包含了所有按键和摇杆的状态。协议定义了两种模式“红灯模式”和“绿灯模式”区别在于摇杆数据是模拟量0-255还是数字量0/1。我们通常初始化为“红灯模式”以获取模拟摇杆值。2.3 数据包解析从字节流到按键状态读取到的32字节数据包其结构是固定的。前2个字节通常是ID如0x73, 0x41表明手柄类型。后面的字节每一位对应一个按键。例如字节[3]的 bit4~bit7 分别对应 Select、L3、R3、Start 键。字节[4]的 bit0~bit7 分别对应 上、右、下、左、L2、R2、L1、R1 键。字节[5]的 bit0~bit7 分别对应 三角、圆圈、叉、方块 键。字节[6]和[7]是右摇杆的X、Y模拟值。字节[8]和[9]是左摇杆的X、Y模拟值。按键是“低电平有效”即该位为0时表示按键被按下。摇杆的模拟值中心点通常在0x7F127附近最大值接近0xFF255最小值接近0x000。3. 环境搭建CUBEMX配置与HAL库SPI驱动编写理论清楚了接下来就是动手实现。我们使用STM32CubeMX进行图形化配置它能自动生成HAL库初始化代码让我们专注于应用逻辑。3.1 CUBEMX工程创建与SPI外设配置首先在CUBEMX中选择你的STM32型号。然后找到SPI外设例如SPI1。Mode: 选择Full-Duplex Master。我们是主机手柄是从机。Hardware NSS Signal: 选择Disable。我们将使用一个普通的GPIO口来软件控制CS片选信号这样更灵活。假设我们使用PA4作为CS引脚。Frame Format:Motorola。Data Size:8 Bits。First Bit:MSB First。Clock Polarity (CPOL):设置为Low。这是关键时钟空闲时为低电平。Clock Phase (CPHA):设置为1 Edge。这意味着数据在时钟的第一个边沿即从低到高的上升沿被采样。结合CPOLLow即上升沿采样下降沿切换数据。这对应SPI的Mode 0。绝大多数PS2手柄协议分析都指出其使用SPI Mode 0或Mode 3实测Mode 0兼容性最好。Baud Rate: 可以设置为较低速度如1 MHz以确保稳定调试成功后可以适当提高。配置好SPI后别忘了配置CS引脚PA4为GPIO_Output并初始化为高电平空闲状态。同时为CLK、MOSI、MISO引脚选择正确的复用功能。3.2 生成代码与基础通信函数封装生成Keil或IAR工程后我们需要编写几个核心函数。首先是单字节收发函数。HAL库提供了HAL_SPI_TransmitReceive。但注意PS2通信是“同时”收发且CS在整个32字节传输期间要保持低电平。// 软件片选引脚定义 #define PS2_CS_GPIO_Port GPIOA #define PS2_CS_Pin GPIO_PIN_4 // 单字节收发核心 uint8_t PS2_ReadWriteByte(uint8_t TxData) { uint8_t RxData 0; HAL_SPI_TransmitReceive(hspi1, TxData, RxData, 1, 1000); // 超时1秒 return RxData; } // 进入通信拉低CS延迟一小段时间 void PS2_CS_Low(void) { HAL_GPIO_WritePin(PS2_CS_GPIO_Port, PS2_CS_Pin, GPIO_PIN_RESET); HAL_DelayMicroseconds(10); // 短暂延时确保手柄检测到CS变化 } // 结束通信拉高CS void PS2_CS_High(void) { HAL_GPIO_WritePin(PS2_CS_GPIO_Port, PS2_CS_Pin, GPIO_PIN_SET); }其次是读取手柄数据的函数。它需要完成完整的32字节轮询。uint8_t PS2_RxData[32]; // 存储读取到的数据 uint8_t PS2_Cmd[32]; // 要发送的命令缓冲区 void PS2_ReadData(void) { PS2_CS_Low(); // 开始通信 // 第一个命令通常是进入配置模式的0x01 PS2_Cmd[0] 0x01; // 后续命令在普通读取模式下可以全部发送0x42请求数据 for(uint8_t i1; i32; i) { PS2_Cmd[i] 0x42; } // 循环收发32字节 for(uint8_t i0; i32; i) { PS2_RxData[i] PS2_ReadWriteByte(PS2_Cmd[i]); } PS2_CS_High(); // 结束通信 // 可选添加一个小延时防止轮询过快 HAL_Delay(5); // 5ms间隔 }注意这里有一个常见的坑。HAL_SPI_TransmitReceive函数内部可能会处理数据对齐等问题确保你的SPI配置为8位数据并且编译器没有对数据进行奇怪的打包。如果发现读取的数据错位可以尝试使用__packed关键字定义数据缓冲区或者检查内存对齐设置。3.3 手柄初始化与模式设置刚上电的手柄可能不在我们想要的“红灯模式”模拟摇杆模式。我们需要一个初始化序列。uint8_t PS2_EnterRedLightMode(void) { uint8_t retry 0; uint8_t id[2] {0}; // 步骤1尝试读取手柄ID PS2_ReadData(); id[0] PS2_RxData[0]; id[1] PS2_RxData[1]; // 常见ID0x41, 0x73 (红绿灯模式)0x79 (模拟模式) if(id[0] 0x41 id[1] 0x5A) { // 已经是模拟模式 return 0; } // 步骤2进入配置模式 PS2_CS_Low(); PS2_ReadWriteByte(0x01); // 发送0x01进入配置 PS2_ReadWriteByte(0x43); // 发送0x43请求配置 PS2_ReadWriteByte(0x00); // 参数1 PS2_ReadWriteByte(0x01); // 参数2开启模拟模式 PS2_ReadWriteByte(0x00); // 参数3 PS2_CS_High(); HAL_Delay(20); // 步骤3保存设置并退出配置模式 PS2_CS_Low(); PS2_ReadWriteByte(0x01); PS2_ReadWriteByte(0x43); PS2_ReadWriteByte(0x00); PS2_ReadWriteByte(0x00); // 参数20x00表示关闭配置即保存并退出 PS2_ReadWriteByte(0x00); PS2_ReadWriteByte(0x00); PS2_ReadWriteByte(0x00); PS2_ReadWriteByte(0x00); PS2_CS_High(); HAL_Delay(20); // 步骤4重新读取ID验证 PS2_ReadData(); if(PS2_RxData[0] 0x73 PS2_RxData[1] 0x41) { // 成功进入红灯模式模拟模式 return 0; } return 1; // 失败 }这个初始化过程比较“古董”需要严格按照时序发送特定的命令字节串。失败的原因通常是时序不对或电平不匹配多调试几次。4. 数据解码与工程化处理成功读取到数据包后我们需要将其转换为程序员容易理解的状态结构体。4.1 定义数据结构与解码函数typedef struct { // 数字按键 (1:按下, 0:松开) uint8_t btn_select; uint8_t btn_l3; uint8_t btn_r3; uint8_t btn_start; uint8_t btn_up; uint8_t btn_right; uint8_t btn_down; uint8_t btn_left; uint8_t btn_l2; uint8_t btn_r2; uint8_t btn_l1; uint8_t btn_r1; uint8_t btn_triangle; uint8_t btn_circle; uint8_t btn_cross; uint8_t btn_square; uint8_t btn_ps; // PS键需要特殊读取 // 模拟摇杆值 (0~255) uint8_t lx, ly; // 左摇杆 X, Y uint8_t rx, ry; // 右摇杆 X, Y // 模拟肩键值 (0~255, 某些手柄支持) uint8_t l2_pressure; uint8_t r2_pressure; } PS2_Data_t; PS2_Data_t g_ps2_data; void PS2_DecodeData(void) { // 先读取原始数据 PS2_ReadData(); // 解码按键 g_ps2_data.btn_select !((PS2_RxData[3] 0) 0x01); // 注意取反因为低电平有效 g_ps2_data.btn_l3 !((PS2_RxData[3] 1) 0x01); g_ps2_data.btn_r3 !((PS2_RxData[3] 2) 0x01); g_ps2_data.btn_start !((PS2_RxData[3] 3) 0x01); g_ps2_data.btn_up !((PS2_RxData[4] 4) 0x01); g_ps2_data.btn_right !((PS2_RxData[4] 5) 0x01); g_ps2_data.btn_down !((PS2_RxData[4] 6) 0x01); g_ps2_data.btn_left !((PS2_RxData[4] 7) 0x01); g_ps2_data.btn_l2 !((PS2_RxData[4] 2) 0x01); g_ps2_data.btn_r2 !((PS2_RxData[4] 3) 0x01); g_ps2_data.btn_l1 !((PS2_RxData[4] 0) 0x01); g_ps2_data.btn_r1 !((PS2_RxData[4] 1) 0x01); g_ps2_data.btn_triangle !((PS2_RxData[5] 4) 0x01); g_ps2_data.btn_circle !((PS2_RxData[5] 5) 0x01); g_ps2_data.btn_cross !((PS2_RxData[5] 6) 0x01); g_ps2_data.btn_square !((PS2_RxData[5] 7) 0x01); // 解码摇杆模拟量 g_ps2_data.lx PS2_RxData[6]; g_ps2_data.ly PS2_RxData[7]; g_ps2_data.rx PS2_RxData[8]; g_ps2_data.ry PS2_RxData[9]; // PS键需要发送特殊命令0x01 0x42 0x00 0x00 0x00 0x00 0x00 0x00 0x00来读取 // 这里简化处理可以单独写一个函数 }4.2 摇杆值校准与死区处理直接从手柄读出的摇杆原始值存在两个问题中心点偏移和抖动。不同手柄甚至同一手柄在不同温度下中心点的ADC值可能不是精确的127。// 校准参数需要在手柄静止于中心点时采样获得 typedef struct { uint8_t lx_center, ly_center; uint8_t rx_center, ry_center; uint8_t dead_zone; // 死区范围例如10 } PS2_Calib_t; PS2_Calib_t g_ps2_calib {127, 127, 127, 127, 10}; // 获取校准后的摇杆值-100 ~ 100 int8_t PS2_GetStickCalibrated(uint8_t raw_val, uint8_t center, uint8_t dead_zone) { int16_t diff (int16_t)raw_val - (int16_t)center; // 应用死区 if(diff -dead_zone diff dead_zone) { return 0; } // 归一化到-100~100 int8_t result; if(diff 0) { result (diff * 100) / (255 - center); } else { result (diff * 100) / (center); } // 限制范围 if(result 100) result 100; if(result -100) result -100; return result; } // 使用示例 int8_t lx_percent PS2_GetStickCalibrated(g_ps2_data.lx, g_ps2_calib.lx_center, g_ps2_calib.dead_zone); int8_t ly_percent PS2_GetStickCalibrated(g_ps2_data.ly, g_ps2_calib.ly_center, g_ps2_calib.dead_zone);这样我们就能得到范围在-100到100之间、中心点为0、消除了抖动影响的摇杆百分比值非常适合直接用于控制电机的PWM占空比或舵机角度。4.3 按键事件检测按下、释放与长按对于项目控制我们往往需要检测按键事件而不仅仅是状态。typedef struct { uint8_t curr_state; // 当前状态 uint8_t last_state; // 上一次状态 uint32_t press_time_stamp; // 按下时的时间戳 } PS2_Key_Event_t; PS2_Key_Event_t g_btn_circle; // 以圆圈键为例 void PS2_Key_Update(PS2_Key_Event_t *key, uint8_t current_state, uint32_t current_tick) { key-last_state key-curr_state; key-curr_state current_state; if(key-curr_state !key-last_state) { // 按下事件 key-press_time_stamp current_tick; // 可以触发按下回调函数 } else if(!key-curr_state key-last_state) { // 释放事件 // 可以触发释放回调函数 } } // 在main循环中调用 uint32_t tick HAL_GetTick(); PS2_DecodeData(); // 先解码数据 PS2_Key_Update(g_btn_circle, g_ps2_data.btn_circle, tick); // 判断长按超过500ms if(g_btn_circle.curr_state) { if((tick - g_btn_circle.press_time_stamp) 500) { // 长按事件触发 } }通过这种事件驱动的方式我们可以实现单击、双击、长按等复杂交互逻辑让手柄控制更加灵活。5. 进阶应用与避坑指南把基础功能跑通只是第一步在实际项目中集成时会遇到更多实际问题。5.1 通信稳定性优化与抗干扰PS2通信对时序比较敏感在复杂的电磁环境或长线连接时可能出错。对策一增加CRC校验简单版。虽然原生协议没有但我们可以在应用层添加。例如在每次读取的32字节后再发送一个特定的命令如0x00手柄会返回一个字节我们可以把这个字节作为前面数据的简单校验和或者忽略它只利用这个通信过程来验证链路是否通畅。如果连续多次通信失败则触发重新初始化。对策二超时与重试机制。HAL_SPI_TransmitReceive函数有超时参数但我们可以封装一个更健壮的读写函数。uint8_t PS2_ReadWriteByte_Robust(uint8_t TxData, uint8_t retry_cnt) { uint8_t RxData 0; HAL_StatusTypeDef status; for(uint8_t i0; iretry_cnt; i) { status HAL_SPI_TransmitReceive(hspi1, TxData, RxData, 1, 10); // 缩短单次超时 if(status HAL_OK) { return RxData; } // 短暂延时后重试 HAL_DelayMicroseconds(5); } // 重试失败返回一个标志值或触发错误处理 return 0xFF; }对策三电源滤波与接地。手柄接收器模块的电源一定要稳定最好在VCC和GND之间并联一个100uF的电解电容和一个0.1uF的瓷片电容分别滤除低频和高频噪声。STM32和接收器之间的地线要粗且短形成良好的共地。5.2 多任务环境下的SPI资源管理如果你的工程使用了RTOS如FreeRTOS或者在主循环中频繁操作SPI与其他设备如屏幕、SD卡通信就需要考虑SPI总线冲突。核心矛盾HAL库的SPI函数内部通常有锁机制__HAL_LOCK但它是基于简单的状态标志并非真正的互斥锁。在RTOS中如果高优先级任务打断了低优先级任务的SPI传输可能导致状态混乱。解决方案使用信号量Mutex在调用任何SPI传输函数前先获取一个二值信号量。SemaphoreHandle_t spi1_mutex; spi1_mutex xSemaphoreCreateMutex(); if(xSemaphoreTake(spi1_mutex, portMAX_DELAY) pdTRUE) { PS2_ReadData(); // 安全的SPI操作 xSemaphoreGive(spi1_mutex); }关闭中断对于简单的裸机程序如果PS2读取过程不能被中断可以在关键通信段暂时关闭全局中断。__disable_irq(); PS2_CS_Low(); // ... 快速完成32字节收发 PS2_CS_High(); __enable_irq();注意这种方法要慎用会严重影响系统实时性只适用于极短的关键段。5.3 功耗考量与无线距离优化PS2手柄本身是无线设备其有效距离和稳定性受接收器模块和环境影响。接收器天线原装接收器内部有一个螺旋天线。如果自己制作接收器模块可以尝试外接一小段导线作为天线长度约为2.4GHz波长的1/4约3cm能有效改善接收灵敏度。STM32端功耗如果项目是电池供电需要优化。PS2通信是主机轮询频繁读取比如每10ms一次会持续消耗电流。可以根据应用场景调整轮询频率。在等待用户输入时可以降低到每100ms读取一次在需要高响应的游戏控制中则保持高频读取。手柄省电PS2手柄在无通信一段时间后会进入休眠。频繁的CS信号会阻止它休眠。如果项目需要极低功耗可以在长时间不操作时完全拉高CS并停止SPI时钟让手柄自动进入省电模式。5.4 常见问题排查表遇到问题可以按以下顺序排查现象可能原因排查步骤完全读不到数据ID为01. 物理连接错误线接反、虚焊2. 电平不匹配信号无法识别3. CS引脚控制时序不对1. 用万用表或逻辑分析仪检查VCC、GND、CS、CLK、CMD、DAT电平。2. 确保CS在通信前已拉低并保持足够低电平时间1us。3. 用逻辑分析仪抓取SPI波形看CLK、CMD是否有输出DAT是否有回波。能读到数据但全是0xFF或0x001. SPI模式CPOL/CPHA设置错误2. 字节顺序MSB/LSB错误3. 手柄未初始化或模式不对1.重点检查CPOL和CPHA务必设置为Mode 0 (CPOL0, CPHA0) 或 Mode 3 (CPOL1, CPHA1) 尝试。2. 确认Data Size为8位First Bit为MSB。3. 调用初始化函数并检查返回的手柄ID。数据不稳定时对时错1. 电源噪声大2. 通信速度过快3. 导线过长或接触不良4. 未正确处理SPI总线冲突1. 在电源引脚增加滤波电容。2. 降低SPI波特率如从8M降到1M。3. 缩短连接线使用屏蔽线。4. 检查代码中是否有其他地方如中断同时操作了SPI。摇杆中心值漂移手柄模拟电位器固有误差或电源波动1. 上电后手柄静止时自动采样一段时间的摇杆值取平均作为动态中心点。2. 在软件中设置死区。按键响应迟钝主循环轮询频率太低提高PS2_ReadData()的调用频率确保在20-50ms以内。可以使用定时器中断来定期触发读取。5.5 项目集成示例遥控小车电机控制最后我们以一个简单的双电机差速小车为例展示如何将解码后的手柄数据转化为控制指令。假设左摇杆LY控制前进后退LX控制左右转向差速。void PS2_CarControl(int8_t speed_percent, int8_t turn_percent, int16_t *pwm_left, int16_t *pwm_right) { // speed_percent: -100(后退) ~ 100(前进) // turn_percent: -100(左转) ~ 100(右转) // pwm_left/right: 输出给电机的PWM值范围自己定义如-1000~1000 // 差速算法基础速度 /- 转向量 int16_t base_speed (int16_t)speed_percent * 10; // 放大到PWM范围 int16_t turn_offset (int16_t)turn_percent * 5; // 转向系数可调 *pwm_left base_speed turn_offset; *pwm_right base_speed - turn_offset; // 限制PWM输出在合法范围内 // ... 限幅代码 } // 在主循环中 int main(void) { // ... 初始化 PS2_EnterRedLightMode(); while(1) { PS2_DecodeData(); int8_t speed -PS2_GetStickCalibrated(g_ps2_data.ly, ...); // 注意摇杆向前推是Y值减小所以取反 int8_t turn PS2_GetStickCalibrated(g_ps2_data.lx, ...); int16_t pwm_l, pwm_r; PS2_CarControl(speed, turn, pwm_l, pwm_r); // 设置电机PWM __HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_1, abs(pwm_l)); __HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_2, abs(pwm_r)); // 设置电机方向 HAL_GPIO_WritePin(MOTOR_L_DIR_GPIO_Port, MOTOR_L_DIR_Pin, (pwm_l 0)); HAL_GPIO_WritePin(MOTOR_R_DIR_GPIO_Port, MOTOR_R_DIR_Pin, (pwm_r 0)); HAL_Delay(10); // 控制周期10ms } }通过这个流程我们就完成了一个从硬件连接到软件解码再到实际应用控制的完整闭环。整个过程最需要耐心的是调试阶段特别是逻辑分析仪是排查SPI通信问题的神器没有之一。一旦调通这个古老的PS2手柄就能在你的STM32项目中焕发新生成为一个性价比极高的无线人机交互设备。
返回列表