STM32 GPIO模拟SPI驱动PS2手柄:协议解析与嵌入式输入设备实践

📅 2026/8/5 13:52:30
STM32 GPIO模拟SPI驱动PS2手柄:协议解析与嵌入式输入设备实践
1. 项目概述当复古手柄遇上现代MCU最近在整理工作室的旧物翻出来一个尘封已久的PS2原装手柄。看着这个陪伴了无数个游戏夜晚的老伙计突然萌生了一个想法能不能把它“复活”用在现在的嵌入式项目里比如用它来控制一个自己做的机器人底盘或者作为一个自定义的输入设备接入到STM32这类常见的微控制器上。这个念头一起就有点收不住了。说干就干。PS2手柄虽然是个老物件但它的接口协议清晰、手感扎实而且存量巨大是DIY项目的绝佳输入源。而STM32作为嵌入式领域的“瑞士军刀”资源丰富、生态完善两者结合既能重温经典硬件的魅力又能实现灵活的功能定制。整个过程说白了就是让STM32学会“听懂”PS2手柄发来的数据然后解析出我们关心的按键、摇杆状态。这听起来像是个简单的通信问题但里面涉及到时序、协议、抗干扰等一系列细节踩坑是必然的。这篇笔记就记录了我从连线、调试到最终稳定读取的全过程以及那些只有亲手做过才会知道的“坑点”。2. 核心思路与协议拆解SPI模拟与PS2协议要把PS2手柄接到STM32首先得搞清楚两者怎么“对话”。PS2手柄接口是一个9针的Mini-DIN接口它使用的是一种索尼自定义的同步串行协议。对于我们开发者来说最幸运的一点是这个协议在电气特性和时序上与标准的SPISerial Peripheral Interface总线高度相似。这就为我们打开了一扇大门不需要额外的专用芯片直接用STM32的GPIO口来模拟SPI的主机时序就能和手柄通信。2.1 为什么选择GPIO模拟SPI你可能会问STM32很多型号都自带硬件SPI外设为什么不用硬件SPI而要用GPIO模拟这里主要有两个考虑协议兼容性PS2协议虽然类似SPI但并非100%兼容。例如它的数据交换是在时钟的下降沿进行而标准SPI模式可配置为上升沿或下降沿。更重要的是PS2协议要求主机STM32在发送命令的同时也要读取手柄的回复这是一个“半双工”的过程且对时钟控制、片选信号ATT的时序有非常严格的要求。用GPIO模拟可以给我们最大的灵活性精细地控制每一个时序细节确保通信的稳定。资源与复杂度对于这个应用通信速率并不高时钟频率通常在250kHz左右用GPIO模拟完全能满足要求且不占用宝贵的硬件SPI外设可以留给SD卡、屏幕等更需要高速或更复杂的设备。代码层面上模拟SPI的核心就是一个精心编写的shiftInOut函数反而比配置硬件SPIDMA更直观便于调试。所以决策就很清晰了用四个GPIO口分别模拟SPI的时钟CLK、主出从入MOSI/CMD、主入从出MISO/DAT和片选ATT通过软件精确控制时序实现与手柄的对话。2.2 PS2通信协议核心流程一次完整的PS2通信周期可以理解为STM32向手柄“询问”状态手柄“回答”的过程。以下是核心步骤拉低ATT片选这是通信开始的信号。ATT线从高电平拉到低电平告诉手柄“主机要和你通话了准备好。”发送命令0x01, 0x42, 0x00, 0x00, 0x00STM32会先发送一个5字节的命令序列。这个序列是固定的目的是请求手柄返回其状态数据。STM32在CLK的下降沿将每个bit从最低位LSB开始放到CMD线上。同时读取数据关键点来了在STM32发送每个命令字节的8个时钟周期里手柄也会在CLK的上升沿注意是上升沿与主机发送的下降沿错开将它的状态数据的一个bit放到DAT线上。因此STM32需要在生成时钟的同时在合适的时刻去读取DAT线的电平。这是一个“边发边收”的过程。读取9字节状态数据发送完5字节命令后通信并未结束。手柄会紧接着返回9个字节的数据。此时STM32只需要继续产生时钟通常发送0x00作为虚拟数据并在每个时钟周期读取DAT线就能获得这9个字节。拉高ATT9字节数据读取完毕后将ATT线拉高结束本次通信周期。这9个字节的数据就包含了所有按键和摇杆的信息。每个bit都对应着手柄上的一个物理状态。注意手柄有两种模式“红灯模式”模拟模式和“绿灯模式”数字模式。我们通常希望使用模拟模式以获得两个摇杆的模拟量数据。上电后默认可能是数字模式需要通过发送特定的“进入模拟模式”命令0x01, 0x44, 0x00, 0x01, 0x03来切换。这个初始化命令只需要在通信开始前发送一次。3. 硬件连接与引脚定义连线是第一步也是最容易出错的一步。PS2手柄接口的9个针脚定义如下面对接口缺口朝上针脚编号信号名称说明连接STM321DATA手柄→主机数据线MISOGPIO输入引脚2COMMAND主机→手柄命令线MOSIGPIO输出引脚3N/C未连接早期版本为9V不接4GND地GND5VCC电源3.3V3.3V6ATT片选低电平有效GPIO输出引脚7CLOCK时钟线GPIO输出引脚8N/C未连接不接9ACK应答通常不用可不接实操要点与避坑指南电压匹配是生命线绝对、必须、一定要使用3.3V为手柄供电虽然老式PS2主机给手柄的电压可能是5V但绝大多数现代手柄和兼容手柄的芯片逻辑电平是3.3V。将5V接到STM32的GPIO耐压通常3.3V会直接烧毁引脚。我的STM32开发板有3.3V输出引脚直接使用即可。上拉电阻DATA线MISO建议在STM32端接一个4.7kΩ - 10kΩ的上拉电阻到3.3V。这可以确保在空闲时DATA线处于确定的高电平状态提高通信稳定性。很多STM32引脚可以配置为内部上拉但为了保险我习惯外加一个物理电阻。连线顺序建议先连接GND和VCC用万用表确认手柄供电正常后再连接信号线。连接信号线时最后连接CLK线防止误触发通信。引脚选择STM32的GPIO口选择任意即可但建议避开JTAG/SWD调试引脚如PA13, PA14, PA15, PB3, PB4以免影响程序下载和调试。我选择了PA4(ATT), PA5(CLK), PA6(MISO), PA7(MOSI)这组它们恰好是SPI1的引脚位方便以后对比但当前是软件模拟。4. 软件驱动实现与代码解析硬件准备妥当后就是重头戏——编写驱动代码。核心就是一个模拟SPI收发函数以及围绕它构建的初始化、数据读取和解析逻辑。4.1 GPIO模拟SPI底层驱动首先初始化用到的四个GPIO引脚。ATT和CLK初始状态为高电平MOSI初始状态随意MISO配置为上拉输入。// 引脚定义 (以STM32F103C8T6为例使用GPIOA) #define PS2_ATT_PIN GPIO_PIN_4 #define PS2_CLK_PIN GPIO_PIN_5 #define PS2_DAT_PIN GPIO_PIN_6 // MISO 输入 #define PS2_CMD_PIN GPIO_PIN_7 // MOSI 输出 #define PS2_PORT GPIOA void PS2_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); // ATT 和 CLK 推挽输出默认高 GPIO_InitStruct.Pin PS2_ATT_PIN | PS2_CLK_PIN | PS2_CMD_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_WritePin(PS2_PORT, PS2_ATT_PIN | PS2_CLK_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(PS2_PORT, PS2_CMD_PIN, GPIO_PIN_RESET); // CMD默认低 HAL_GPIO_Init(PS2_PORT, GPIO_InitStruct); // DAT 上拉输入 GPIO_InitStruct.Pin PS2_DAT_PIN; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(PS2_PORT, GPIO_InitStruct); }接下来是最核心的字节收发函数。它模拟了SPI模式3CPOL1, CPHA1即时钟空闲时为高电平在下降沿输出数据在上升沿采样数据。/** * brief 模拟SPI收发一个字节 (模式3) * param txByte: 要发送的字节 * retval 接收到的字节 */ uint8_t PS2_Spi_Byte(uint8_t txByte) { uint8_t rxByte 0; for (int8_t i 7; i 0; i--) { // 从最高位(MSB)开始PS2协议要求 // 1. 准备数据位 (在时钟下降沿之前稳定) if ((txByte i) 0x01) { HAL_GPIO_WritePin(PS2_PORT, PS2_CMD_PIN, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(PS2_PORT, PS2_CMD_PIN, GPIO_PIN_RESET); } // 2. 产生时钟下降沿手柄在此刻采样CMD线 HAL_GPIO_WritePin(PS2_PORT, PS2_CLK_PIN, GPIO_PIN_RESET); // 此处需要短暂延时确保下降沿稳定。用几个NOP或微秒级延时。 DWT_Delay_us(5); // 假设有一个微秒延时函数 // 3. 在时钟上升沿主机采样DAT线 HAL_GPIO_WritePin(PS2_PORT, PS2_CLK_PIN, GPIO_PIN_SET); if (HAL_GPIO_ReadPin(PS2_PORT, PS2_DAT_PIN)) { rxByte | (1 i); } DWT_Delay_us(5); // 时钟高电平保持时间 } return rxByte; }实操心得时序是灵魂DWT_Delay_us(5)这里的延时至关重要。PS2协议要求时钟频率在250kHz左右周期约4微秒。高电平和低电平各占一半约2微秒。我留了5微秒是偏保守的做法确保了在最差的接线条件下也能稳定。你可以根据实际情况调整但必须用示波器或者逻辑分析仪查看CLK和DAT的波形来验证。如果时序不对读回来的数据会是乱码。没有仪器的话就保守一点用稍长的延时。4.2 协议层实现与数据读取有了底层收发函数就可以构建完整的通信流程了。我们定义一个函数来执行一次完整的“询问-应答”周期。/** * brief 读取一次PS2手柄全部数据 * param dataBuf: 用于存放9字节数据的数组 * retval 成功与否 (1:成功, 0:失败) */ uint8_t PS2_Read(uint8_t *dataBuf) { uint8_t cmd[5] {0x01, 0x42, 0x00, 0x00, 0x00}; // 查询命令 uint8_t i; // 开始通信拉低ATT HAL_GPIO_WritePin(PS2_PORT, PS2_ATT_PIN, GPIO_PIN_RESET); DWT_Delay_us(10); // 阶段1: 发送5字节命令同时接收前5字节数据 for (i 0; i 5; i) { dataBuf[i] PS2_Spi_Byte(cmd[i]); } // 阶段2: 继续产生时钟接收剩下的4字节数据 for (i 5; i 9; i) { dataBuf[i] PS2_Spi_Byte(0x00); // 发送虚拟数据0x00 } // 结束通信拉高ATT HAL_GPIO_WritePin(PS2_PORT, PS2_ATT_PIN, GPIO_PIN_SET); return 1; }初始化手柄进入模拟模式在系统上电后、主循环开始前需要先发送一个配置命令。void PS2_EnterAnalogMode(void) { uint8_t enter_cmd[5] {0x01, 0x44, 0x00, 0x01, 0x03}; // 进入模拟模式并开启马达振动0x03 HAL_GPIO_WritePin(PS2_PORT, PS2_ATT_PIN, GPIO_PIN_RESET); DWT_Delay_us(10); for (int i0; i5; i) { PS2_Spi_Byte(enter_cmd[i]); } HAL_GPIO_WritePin(PS2_PORT, PS2_ATT_PIN, GPIO_PIN_SET); HAL_Delay(100); // 等待手柄处理命令 }4.3 数据解析与应用成功读取9字节数据后就需要解析出具体的按键和摇杆值。这9个字节Data[0] ~ Data[8]的定义如下Data[0]设备ID模拟模式下通常是0x73或0x79用于校验。Data[1]设备ID第二字节。Data[2]右摇杆X轴 (0x00左 ~ 0xFF右 0x80中心)。Data[3]右摇杆Y轴 (0x00上 ~ 0xFF下 0x80中心)。Data[4]左摇杆X轴。Data[5]左摇杆Y轴。Data[6]功能按键状态低4位。注意PS2协议是低电平有效0表示按下。Bit0: Select (0x01)Bit1: L3 (左摇杆按下)Bit2: R3 (右摇杆按下)Bit3: Start (0x08)Data[7]方向键和动作键状态低8位。同样是低电平有效。Bit0: 上 (0x01)Bit1: 右 (0x02)Bit2: 下 (0x04)Bit3: 左 (0x08)Bit4: L2 (0x10)Bit5: R2 (0x20)Bit6: L1 (0x40)Bit7: R1 (0x80)Data[8]动作键状态高4位。低电平有效。Bit0: 三角 (0x01)Bit1: 圆圈 (0x02)Bit2: 叉号 (0x04)Bit3: 方块 (0x08)解析代码示例typedef struct { uint8_t id[2]; // Data[0], Data[1] uint8_t rx, ry; // 右摇杆 X, Y uint8_t lx, ly; // 左摇杆 X, Y struct { uint8_t select :1; uint8_t l3 :1; uint8_t r3 :1; uint8_t start :1; uint8_t up :1; uint8_t right :1; uint8_t down :1; uint8_t left :1; } dpad; // 方向键和功能键1表示按下 struct { uint8_t l2 :1; uint8_t r2 :1; uint8_t l1 :1; uint8_t r1 :1; uint8_t triangle :1; uint8_t circle :1; uint8_t cross :1; uint8_t square :1; } buttons; // 动作键1表示按下 uint8_t pressure[12]; // 模拟按键压力值需要额外命令读取此处简化 } PS2_Analog_Data_t; void PS2_ParseData(uint8_t *raw, PS2_Analog_Data_t *parsed) { parsed-id[0] raw[0]; parsed-id[1] raw[1]; parsed-rx raw[2]; parsed-ry raw[3]; parsed-lx raw[4]; parsed-ly raw[5]; // 解析按键注意取反因为原始数据是低电平有效 uint8_t func_key raw[6]; parsed-dpad.select !(func_key 0x01); parsed-dpad.l3 !(func_key 0x02); parsed-dpad.r3 !(func_key 0x04); parsed-dpad.start !(func_key 0x08); uint8_t dir_key raw[7]; parsed-dpad.up !(dir_key 0x01); parsed-dpad.right !(dir_key 0x02); parsed-dpad.down !(dir_key 0x04); parsed-dpad.left !(dir_key 0x08); parsed-buttons.l2 !(dir_key 0x10); parsed-buttons.r2 !(dir_key 0x20); parsed-buttons.l1 !(dir_key 0x40); parsed-buttons.r1 !(dir_key 0x80); uint8_t act_key raw[8]; parsed-buttons.triangle !(act_key 0x01); parsed-buttons.circle !(act_key 0x02); parsed-buttons.cross !(act_key 0x04); parsed-buttons.square !(act_key 0x08); }在主循环中定期比如每20ms调用PS2_Read和PS2_ParseData就能得到手柄的全部状态然后就可以用这些数据去控制电机、舵机或者通过串口发送给上位机了。5. 调试过程与常见问题实录理论很美好调试很骨感。下面是我在实现过程中遇到的一些典型问题及解决方法希望能帮你少走弯路。5.1 问题一读回来的数据全是0xFF或0x00这是最常见的问题意味着通信根本没建立起来。检查电源和地线万用表测量手柄VCC和GND之间是否为稳定的3.3V。检查ATT信号用示波器或LED观察在调用PS2_Read时ATT引脚是否有明显的高低电平变化。如果没有检查GPIO初始化代码。检查CLK信号这是关键。用示波器看CLK引脚在通信时是否有规整的方波脉冲。频率是否在预期范围内~250kHz如果没有波形检查PS2_Spi_Byte函数中的时钟控制逻辑和延时。检查连线尤其是DATA和CMD线是否接反了用万用表通断档仔细核对。5.2 问题二数据不稳定偶尔正确大部分是乱码这通常是时序问题。延时不够精确DWT_Delay_us函数是否准确如果用的是简单的for循环延时受编译器优化和主频影响很大。建议使用STM32的SysTick定时器或者DWTData Watchpoint and Trace单元来实现精确的微秒延时。网上有DWT_Delay_us的通用实现代码。时序容限尝试增加PS2_Spi_Byte中两个DWT_Delay_us的数值比如从5微秒增加到8或10微秒。降低通信速率可以提高稳定性。电源干扰手柄电机振动如果开启了可能会引起电源波动。在STM32的3.3V和GND之间以及手柄接口的VCC和GND之间就近并联一个10uF的电解电容和一个0.1uF的瓷片电容可以很好地滤除干扰。5.3 问题三按键解析相反按下为1松开为0这就是协议理解错误。务必记住PS2协议是低电平有效Active Low。所以当按键按下时对应的数据位是0。我在解析代码中通过!(raw_data mask)进行了取反让结构体中的1代表按下这样更符合我们的思维习惯。5.4 问题四摇杆中心值不是0x80有漂移这是正常现象。由于电位器工艺和磨损摇杆的中心点电压未必精确对应0x80128。我的一个手柄中心值在125-130之间波动。软件死区处理在应用层代码中为摇杆值设置一个“死区”。例如定义一个中心范围[CENTER_DEADZONE_LOW, CENTER_DEADZONE_HIGH]当摇杆值在这个范围内时就认为是中心输出0。#define CENTER_VALUE 128 #define DEADZONE 10 int16_t processed_lx (int16_t)parsed_data.lx - CENTER_VALUE; if (abs(processed_lx) DEADZONE) processed_lx 0; // 现在 processed_lx 就是经过死区处理的、以0为中心的摇杆偏移量了校准更高级的做法是上电时让用户将摇杆置于中心记录下此时的ADC值对于PS2是读取的字节值作为实际中心值后续读取时减去这个校准值。5.5 问题五通信一段时间后卡死看门狗确保在长时间循环读取的代码中及时“喂狗”如果使用了独立看门狗IWDG。缓冲区与状态机不要在中断服务程序里进行长时间的PS2_Read操作这会阻塞系统。建议在主循环中轮询或者设置一个定时器在定时器中断里只触发一个“需要读取”的标志位在主循环里执行实际的读取和解析。错误恢复在PS2_Read函数中如果检测到设备IDData[0]不是预期的0x73或0x79可以尝试重新初始化手柄重新拉高ATT延时再拉低开始新通信或者重置整个通信序列。6. 项目进阶与扩展思路当基础功能稳定后这个项目还有很多可以玩的地方无线化原装PS2手柄有无线版本带接收器。其通信协议与有线版在高层一致只是物理层变成了2.4GHz。网上有现成的无线接收器模块通常也是SPI接口你可以将接收器拆开将其数据线接入STM32实现无线控制。力反馈震动PS2手柄内置两个震动马达。通过发送特定的命令如0x01, 0x4D, 0x00, 0x00, 0x00后面跟控制马达大小0x00-0xFF的数据包就可以控制马达震动。这需要你在PS2_Read周期之外单独发起一个“写”操作。压力感应按键部分PS2手柄的△○×□、L1/R1、L2/R2键是模拟的能感知按压力度。这需要发送更复杂的命令来读取额外的压力数据。这属于高阶应用可以让你做出“轻推摇杆慢走重推快跑”等更细腻的控制。多设备与协议抽象将PS2驱动代码封装成一个良好的硬件抽象层HAL。定义统一的输入设备接口如get_axis(),get_button()这样你的主控代码就不需要关心底层是PS2手柄、蓝牙手柄还是其他什么设备便于项目扩展。结合其他外设最直接的应用就是控制双电机驱动的底盘。将左摇杆Y轴映射为前进后退速度右摇杆X轴映射为转向角实现差速控制。你还可以用按键触发不同的动作序列比如按“方块”键让机器人跳一段舞执行一系列预设的电机指令。移植PS2手柄到STM32是一个非常好的嵌入式系统综合练习。它涵盖了GPIO操作、精密时序控制、自定义协议解析、数据滤波处理、模块化编程等多个方面。当你看到自己写的代码成功驱动了二十年前的游戏手柄并灵活控制着现代的机器人时那种跨越时空的创造乐趣正是嵌入式开发最吸引人的地方。希望这篇详细的笔记能为你扫清障碍祝你玩得开心如果在实现过程中遇到新的问题不妨回头仔细检查时序和电源这两者解决了问题就解决了一大半。