STM32驱动PS2手柄:从SPI协议解析到嵌入式控制实战

发布时间:2026/8/8 10:56:07
STM32驱动PS2手柄:从SPI协议解析到嵌入式控制实战 1. 项目缘起为什么要把PS2手柄接到STM32上几年前我在做一个基于STM32的桌面级小机器人项目需要一套灵活、可靠且成本低廉的遥控方案。市面上的2.4G遥控器要么太玩具化要么协议封闭难以深度定制蓝牙手柄虽然方便但连接稳定性和低延迟在复杂的电磁环境下是个考验。翻箱倒柜时我找到了一个尘封已久的索尼PS2手柄就是那种带震动的“震动手柄DualShock 2”。一个念头冒出来这玩意儿协议是公开的手感一流按键和摇杆资源丰富关键是存量巨大、价格便宜简直是嵌入式开发的“宝藏外设”。说干就干但过程并非一帆风顺。网上资料虽多但大多语焉不详只给代码不讲原理或者移植的工程依赖特定库耦合严重。我在调试过程中踩了不少坑从时序理解错误到数据解析混乱再到如何优雅地集成到RTOS或裸机系统中都交了“学费”。这份笔记就是把这些零散的经验、核心的原理和踩过的坑系统性地整理出来目标是让你拿到就能用用了就能懂避免重走我的弯路。无论你是想做个遥控小车、机械臂控制器还是游戏模拟器这篇内容都能提供一个扎实的起点。2. PS2手柄协议深度拆解SPI不是自定义协议很多人第一眼看到PS2手柄接口那9根线实际上常用8根会下意识地以为它是SPISerial Peripheral Interface设备。这确实是个合理的猜测因为它有DI手柄到主机、DO主机到手柄、CS片选和CLK时钟这四根线与SPI的MISO,MOSI,CS,SCK非常相似。但严格来说它是一种基于SPI时序框架的自定义同步串行协议。理解这一点差异是成功移植的关键。2.1 引脚定义与电气连接首先我们得把手柄连上STM32。原装PS2手柄接口是9针的Mini-DIN我们需要一个对应的接收器模块市面上几块钱一个或者自己焊接线序。以下是核心的8个引脚定义引脚编号名称颜色常见方向说明1DATA/MISO棕输出手柄数据输出接STM32的MISO或普通GPIO输入。2COMMAND/MOSI橙输入主机命令输出接STM32的MOSI或普通GPIO输出。3N/C--7.6V电源原用于电机震动我们通常不用。4GND黑-电源地。5VCC红-电源3.3V重要。原装手柄是3.3V逻辑电平。6ATT/CS黄输入片选信号低电平有效接STM32的CS或普通GPIO输出。7CLOCK蓝输入时钟信号接STM32的SCK或普通GPIO输出。8N/C--原手柄接地通常不用。9ACK白输出应答信号极少使用通常悬空。重要提示电源必须是3.3V如果你用5V供电很可能烧毁手柄或STM32的IO口。STM32的IO口也是3.3V电平直接连接即可无需电平转换。最基本的连接只需要1(DATA), 2(COMMAND), 4(GND), 5(VCC), 6(ATT), 7(CLOCK)这六根线。在我的项目中我使用了STM32F103C8T6的硬件SPI1接口来模拟主机连接如下PA4(SPI1_NSS) -ATT(引脚6)PA5(SPI1_SCK) -CLOCK(引脚7)PA6(SPI1_MISO) -DATA(引脚1)PA7(SPI1_MOSI) -COMMAND(引脚2)3.3V-VCC(引脚5)GND-GND(引脚4)使用硬件SPI的好处是时序精准不占用CPU时间进行延时模拟。当然用任意四个GPIO口通过“模拟SPI”的方式也能驱动这在IO紧张时是备选方案。2.2 通信时序与数据帧解析协议以字节为单位进行传输。主机STM32控制时钟CLK每个时钟周期传输一位数据。关键特性数据在时钟CLK的下降沿从高到低跳变变化在CLK的上升沿从低到高跳变被采样。这与标准SPI的CPOL1, CPHA1模式即时钟空闲时为高在第二个边沿采样完全一致。所以在配置STM32硬件SPI时必须设置为CPOLHigh,CPHA2Edge在HAL库中对应SPI_POLARITY_HIGH和SPI_PHASE_2EDGE。一次完整的通信过程如下主机拉低ATT信号开始一次会话。主机通过COMMAND线发送一个8位命令字节给手柄。手柄在接收到命令后通过DATA线回传一个8位数据字节。重复步骤2和3通常主机发送9个字节的命令序列手柄回复9个字节的数据。主机拉高ATT结束本次会话。最常用的命令是0x01请求读取数据后面跟一个0x42查询设备类型然后是一串0x00。标准的数据请求命令序列是0x01, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00。手柄的回复数据包9个字节结构才是我们关心的精华字节位7位6位5位4位3位2位1位0说明00xFF0x730x5A0xFF0xFF0xFF0xFF0xFF设备ID数字手柄通常为0x41或0x73模拟手柄红绿灯模式为0x73。1SLCTSTART^vL2R2第1个按键字节0表示按下1表示松开。2L1R1△○×□L3R3第2个按键字节0表示按下1表示松开。3右摇杆X轴模拟值范围0x00最左~0xFF最右中间约0x80。4右摇杆Y轴模拟值范围0x00最上~0xFF最下中间约0x80。5左摇杆X轴模拟值范围0x00最左~0xFF最右中间约0x80。6左摇杆Y轴模拟值范围0x00最上~0xFF最下中间约0x80。7右侧方向键4个方向键的模拟值仅在某些模式下通常我们更关注字节1/2的按键。8左侧方向键同上通常忽略。注意字节1和2的按键数据格式这是最容易出错的地方。协议规定按键按下时对应位为0松开时为1。这与我们通常的逻辑1表示按下相反。所以在代码中判断按键是否按下应该是if ((data[1] (1 BIT_POSITION)) 0)。2.3 “红灯”与“绿灯”模式模拟与数字的切换这是PS2手柄一个有趣且重要的特性。手柄上有一个ANALOG模拟按键和对应的LED指示灯。“红灯”模式数字模式ANALOG灯熄灭。此时手柄仅返回按键信息字节1和2摇杆数据字节3-6是无效的固定值通常是0xFF或0x00。所有按键以数字方式上报。“绿灯”模式模拟模式按下ANALOG键红灯亮起。此时手柄进入模拟模式除了返回按键信息还会返回两个摇杆的精确模拟量字节3-6。同时按键L3和R3按下摇杆功能才可用。在初始化时我们需要发送一个特殊命令序列来“解锁”或确认模拟模式。一个常见的初始化/模式设置命令序列是0x01, 0x43, 0x00, 0x01, 0x00。发送此命令后再发送标准查询命令如果手柄回复的第一个字节是0x73并且ANALOG灯亮起就说明成功进入了模拟模式。我们的移植通常都以支持模拟模式为目标。3. STM32驱动层实现硬件SPI与软件模拟的抉择理解了协议接下来就是在STM32上实现驱动。核心就是模拟主机行为按照时序发送命令、读取数据。这里有两个主流方案硬件SPI和GPIO模拟软件SPI。3.1 方案一使用硬件SPI推荐这是最稳定、最省CPU的方案。如前所述PS2协议时序与SPI模式CPOL1, CPHA1完全匹配。配置步骤以STM32CubeMX/HAL库为例启用一个SPI外设如SPI1设置为主机模式Master。配置参数Data Size: 8 bitsFirst Bit: MSB FirstClock Polarity (CPOL): HighClock Phase (CPHA): 2 EdgeBaud Rate: 不需要很快250kHz到1MHz之间足够稳定。我常用500kHz (Prescaler设为SPI_BAUDRATEPRESCALER_32等)。NSS Signal: 选择Software。因为ATT信号需要在数据收发前后手动控制不适合用硬件的NSS管脚自动管理。分配好SCK,MISO,MOSI的管脚。ATT信号连接到一个普通的GPIO输出口如PA4。生成代码。核心驱动函数示例// ps2.h typedef struct { uint8_t btn_word0; // 对应数据包字节1 uint8_t btn_word1; // 对应数据包字节2 uint8_t rjx; // 右摇杆X uint8_t rjy; // 右摇杆Y uint8_t ljx; // 左摇杆X uint8_t ljy; // 左摇杆Y } PS2_Data_t; // ps2.c SPI_HandleTypeDef hspi1; GPIO_TypeDef* ATT_GPIO_PORT GPIOA; uint16_t ATT_GPIO_PIN GPIO_PIN_4; uint8_t PS2_RW_Byte(uint8_t tx_data) { uint8_t rx_data; HAL_SPI_TransmitReceive(hspi1, tx_data, rx_data, 1, HAL_MAX_DELAY); // HAL库的SPI收发是阻塞的对于500kHz速率一个字节约16us可以接受。 // 如果追求非阻塞可以使用中断或DMA但复杂度增加对于手柄读取非必需。 return rx_data; } HAL_StatusTypeDef PS2_ReadData(PS2_Data_t *ps2_data) { uint8_t tx_cmd[9] {0x01, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t rx_data[9] {0}; // 1. 拉低ATT开始通信 HAL_GPIO_WritePin(ATT_GPIO_PORT, ATT_GPIO_PIN, GPIO_PIN_RESET); // 2. 发送命令并接收数据 for (int i 0; i 9; i) { rx_data[i] PS2_RW_Byte(tx_cmd[i]); } // 3. 拉高ATT结束通信 HAL_GPIO_WritePin(ATT_GPIO_PORT, ATT_GPIO_PIN, GPIO_PIN_SET); // 4. 校验与数据填充 // 检查第一个字节是否为已知的设备ID0x41, 0x73等 if (rx_data[0] ! 0x41 rx_data[0] ! 0x73) { return HAL_ERROR; // 通信失败或手柄未连接 } ps2_data-btn_word0 rx_data[1]; ps2_data-btn_word1 rx_data[2]; ps2_data-rjx rx_data[3]; ps2_data-rjy rx_data[4]; ps2_data-ljx rx_data[5]; ps2_data-ljy rx_data[6]; return HAL_OK; }使用硬件SPI时时序由硬件保证非常精确抗干扰能力强。你只需要关注ATT信号的控制和数据包的解析。3.2 方案二GPIO模拟SPI软件SPI当硬件SPI引脚被其他功能占用时可以用任意四个GPIO口来模拟。这提供了极大的灵活性但代价是CPU占用率高且时序容易受中断干扰。实现要点// 定义四个GPIO #define PS2_ATT_CLR() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define PS2_ATT_SET() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET) #define PS2_CLK_CLR() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET) #define PS2_CLK_SET() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET) #define PS2_DO_CLR() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET) #define PS2_DO_SET() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_SET) #define PS2_DI_READ() HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_2) // 微秒级延时函数需要根据你的系统时钟精确调整 static void delay_us(uint32_t us) { // 使用SysTick或定时器实现 } uint8_t PS2_Software_RW_Byte(uint8_t tx_data) { uint8_t rx_data 0; for (int i 0; i 8; i) { // 先设置数据位在时钟下降沿变化 if (tx_data 0x80) { PS2_DO_SET(); } else { PS2_DO_CLR(); } tx_data 1; delay_us(1); // 短暂稳定 // 产生时钟下降沿 PS2_CLK_CLR(); delay_us(2); // 保持低电平时间 // 在时钟上升沿采样 PS2_CLK_SET(); rx_data 1; if (PS2_DI_READ() GPIO_PIN_SET) { rx_data | 0x01; } delay_us(2); // 保持高电平时间 } return rx_data; }软件模拟的坑最大的问题是延时精度。delay_us函数如果不准或者系统有高优先级中断打断会导致通信失败。务必关闭所有可能打断字节传输的中断或者将PS2_ReadData函数放在临界区如__disable_irq()和__enable_irq()中执行。对于实时性要求高的系统如运行了FreeRTOS这不是个好选择。我的选择与建议优先使用硬件SPI。它稳定、高效、不占CPU。只有当硬件SPI真的被占用且读取频率不高比如100Hz以下时才考虑软件模拟。4. 应用层数据处理与滤波从原始数据到可靠控制成功读取到9字节数据包只是第一步。如何把这些原始的uint8_t数据转换成应用程序方便使用的状态并处理摇杆的抖动和死区是提升体验的关键。4.1 按键状态解析与事件检测我们需要定义一个结构体来友好地表示手柄状态并实现按键的“按下”和“释放”事件检测。// ps2_app.h typedef struct { // 按键状态1为按下0为释放这里已经做了逻辑反转 struct { uint8_t select : 1; uint8_t start : 1; uint8_t up : 1; uint8_t right : 1; uint8_t down : 1; uint8_t left : 1; 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; uint8_t l3 : 1; uint8_t r3 : 1; uint8_t analog : 1; // ANALOG键状态需从其他途径获取或忽略 } buttons; // 摇杆模拟量已做中心校准和死区处理 int16_t lx, ly; // 左摇杆X,Y范围[-100, 100] int16_t rx, ry; // 右摇杆X,Y范围[-100, 100] // 上一次的按键状态用于边缘检测 uint16_t prev_btn_state; } PS2_Controller_t; // 按键位定义对应数据包字节1和2的位 #define PS2_MASK_SELECT (1 0) #define PS2_MASK_START (1 3) // ... 其他按键掩码定义 void PS2_Update(PS2_Controller_t *ctrl) { PS2_Data_t raw_data; if (PS2_ReadData(raw_data) ! HAL_OK) { // 处理通信失败可以设置一个超时标志 ctrl-buttons.all 0; ctrl-lx ctrl-ly ctrl-rx ctrl-ry 0; return; } // 1. 解析按键注意逻辑反转 uint16_t current_btn_state ((~raw_data.btn_word0) 8) | (~raw_data.btn_word1); ctrl-buttons.all current_btn_state; // 假设结构体位域与掩码对应 // 2. 事件检测可选 uint16_t changed current_btn_state ^ ctrl-prev_btn_state; uint16_t pressed changed current_btn_state; uint16_t released changed (~current_btn_state); if (pressed PS2_MASK_CROSS) { // 处理×键按下事件 // printf(Cross pressed!\n); } ctrl-prev_btn_state current_btn_state; // 3. 处理摇杆原始数据 ProcessAnalogStick(ctrl-lx, ctrl-ly, raw_data.ljx, raw_data.ljy); ProcessAnalogStick(ctrl-rx, ctrl-ry, raw_data.rjx, raw_data.rjy); }4.2 摇杆校准、死区与滤波摇杆的机械特性决定了它无法精确地停留在理论中心值0x80。直接使用原始值会导致设备在“松开摇杆”时仍有微小输出产生漂移。第一步中心校准上电后在摇杆自然松开的状态下连续读取几十次原始数据分别对lx_raw,ly_raw,rx_raw,ry_raw求平均值得到每个轴的中心偏移量lx_center,ly_center等。保存这些值。第二步应用死区死区Dead Zone是指将中心附近一小段范围内的输出强制归零避免漂移。void ProcessAnalogStick(int16_t *out_x, int16_t *out_y, uint8_t raw_x, uint8_t raw_y) { static int16_t center_x 128, center_y 128; // 假设已校准 const int16_t dead_zone 15; // 死区大小可根据手感调整 const int16_t max_value 100; // 输出最大值 int16_t dx (int16_t)raw_x - center_x; int16_t dy (int16_t)raw_y - center_y; // 计算距离中心的幅度 int16_t dist sqrt(dx*dx dy*dy); // 可用查表或近似算法避免浮点 if (dist dead_zone) { *out_x 0; *out_y 0; return; } // 超出死区进行归一化映射 // 可选进行非线性映射如指数曲线让操控更跟手 float ratio (float)(dist - dead_zone) / (127.0 - dead_zone); // 127是最大偏移 ratio (ratio 1.0f) ? 1.0f : ratio; *out_x (int16_t)((dx / (float)dist) * ratio * max_value); *out_y (int16_t)((dy / (float)dist) * ratio * max_value); }第三步低通滤波即使有了死区摇杆的ADC读数也可能有高频噪声。一个简单的一阶低通滤波器IIR可以平滑数据// 在结构体中增加滤波状态 typedef struct { float lx_filtered, ly_filtered; float rx_filtered, ry_filtered; } PS2_Filter_t; void LowPassFilter(float *filtered, float new, float alpha) { // alpha dt / (dt RC)通常取0.1~0.3 *filtered alpha * new (1.0f - alpha) * (*filtered); }在ProcessAnalogStick输出out_x后再对其进行一次低通滤波能显著提升控制平滑度尤其适用于机器人或云台的慢速精密控制。5. 系统集成与高级话题RTOS、非阻塞与震动控制在简单的裸机while循环中调用PS2_Update没问题但在复杂的嵌入式系统中我们需要考虑更多。5.1 在RTOS如FreeRTOS中安全读取在RTOS中直接使用阻塞式的HAL_SPI_TransmitReceive可能会长时间占用任务影响系统响应。更优雅的方式是使用信号量进行互斥如果SPI总线被多个设备共享访问PS2手柄前需要获取信号量。将读取过程放在独立的中低优先级任务中void PS2_Task(void *argument) { PS2_Controller_t ctrl; TickType_t xLastWakeTime xTaskGetTickCount(); const TickType_t xFrequency pdMS_TO_TICKS(10); // 100Hz读取 while(1) { // 获取SPI总线访问权如果共享 if (xSemaphoreTake(spi_mutex, portMAX_DELAY) pdTRUE) { PS2_Update(ctrl); xSemaphoreGive(spi_mutex); // 将处理好的数据通过队列发送给其他任务 xQueueSend(ps2_data_queue, ctrl, 0); } vTaskDelayUntil(xLastWakeTime, xFrequency); } }这样控制逻辑任务只需要从队列中接收最新的手柄状态无需关心底层通信细节实现了模块化解耦。5.2 非阻塞读取与超时处理即使使用硬件SPI一次完整的9字节传输也可能因为手柄断开而卡在HAL_SPI_TransmitReceive里。必须添加超时机制。HAL_StatusTypeDef PS2_ReadData(PS2_Data_t *ps2_data) { uint8_t tx_cmd[9] {0x01, 0x42, ...}; uint8_t rx_data[9] {0}; HAL_StatusTypeDef status; HAL_GPIO_WritePin(ATT_GPIO_PORT, ATT_GPIO_PIN, GPIO_PIN_RESET); for (int i 0; i 9; i) { // 为每个字节的传输设置超时例如5ms status HAL_SPI_TransmitReceive(hspi1, tx_cmd[i], rx_data[i], 1, 5); if (status ! HAL_OK) { HAL_GPIO_WritePin(ATT_GPIO_PORT, ATT_GPIO_PIN, GPIO_PIN_SET); return HAL_ERROR; // 超时或错误 } } HAL_GPIO_WritePin(ATT_GPIO_PORT, ATT_GPIO_PIN, GPIO_PIN_SET); // ... 数据校验与填充 return HAL_OK; }在应用层如果连续多次如10次读取失败则可以判定手柄断开连接并进入等待重连或安全模式。5.3 实现震动马达控制进阶PS2手柄内置两个震动马达一个大的低频率强震和一个小的高频率弱震。控制它们需要向手柄发送模式配置和马达控制命令。进入配置模式发送命令0x01, 0x43, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00。如果成功手柄会回复0xF3, 0x5A, ...。设置马达模式发送命令0x01, 0x4D, 0x00, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF。最后一个字节的0xFF表示启用两个马达。发送震动数据之后在每次正常的查询命令0x01, 0x42, ...中第三个命令字节用于控制小马达强度0x00-0xFF第四个命令字节用于控制大马达强度0x00-0xFF。例如发送0x01, 0x42, 0x80, 0x40, ...会让两个马达以不同强度震动。注意震动功能耗电较大需确保你的3.3V电源能提供足够电流峰值可能超过200mA。频繁启停可能引起电源电压跌落导致STM32复位。建议在电源入口处并联一个大电容如470uF。6. 调试技巧与常见问题排查移植过程中十有八九不会一次成功。以下是我总结的排查清单问题1完全读不到数据或者数据全是0xFF/0x00。检查电源万用表测量手柄VCC引脚是否为稳定的3.3VGND是否共地检查连接线是否接错、虚焊特别是DATA和COMMAND方向是否反了检查SPI模式这是最常见的问题确认STM32的SPI配置为CPOLHigh,CPHA2Edge。用逻辑分析仪或示波器看CLK和DATA的波形看是否在CLK上升沿时DATA数据是稳定的。检查ATT时序ATT必须在整个9字节收发期间保持低电平。用示波器看ATT信号是否在发送第一个字节前变低在最后一个字节后变高。问题2数据能读到但第一个字节不是0x41或0x73。可能是手柄未进入正确的模式。尝试在初始化流程中发送模式设置命令0x01, 0x43, 0x00, 0x01, 0x00然后再进行常规读取。有些第三方手柄兼容性差可能需要特殊的初始化序列需要查阅其数据手册如果找得到的话。问题3按键状态相反或混乱。确认按键解析逻辑协议是按下为0松开为1。你的代码是否做了逻辑取反~操作检查字节顺序数据包字节1和字节2的位定义是否与你代码中的掩码定义一一对应问题4摇杆数据漂移中心不在0x80。这是正常现象。必须实现软件校准功能。上电后手柄静止在桌面几秒钟程序自动记录这段时间内各通道的平均值作为中心点。增加死区处理。没有死区的摇杆是无法使用的。问题5在RTOS或中断环境中读取不稳定。软件模拟SPI时通信函数被中断打断。将通信函数放入临界区taskENTER_CRITICAL()/taskEXIT_CRITICAL()。SPI总线被多个任务抢占。使用互斥信号量保护SPI总线访问。读取频率过高。将读取任务频率降低到50-100Hz足以满足大多数应用过高频率无意义且增加系统负担。必备调试工具逻辑分析仪几十块钱的8通道逻辑分析仪足矣。同时抓取CLK,ATT,COMMAND,DATA四根线可以清晰看到每个字节的收发过程是排查时序问题的终极利器。串口打印将读取到的9个字节原始数据通过串口打印到PC用串口助手查看是最直观的方法。万用表检查电源和连通性。最后分享一个我个人的小技巧在调试初期可以写一个简单的测试程序循环读取并打印原始数据然后你依次按下每个按键、拨动每个摇杆观察打印输出的哪个字节哪一位在变化。这样能最直观地建立数据包与物理操作的映射关系比单纯看文档要牢靠得多。

相关新闻