1. 从一个吃灰的PS2手柄说起
手头攒了太多电子垃圾,这是每个折腾嵌入式的人都逃不过的宿命。前阵子翻箱子,翻出来一个落满灰的PS2手柄接收器,就是那种十几年前插在电脑USB口上、用来接两个PS2手柄玩模拟器的小板子。当年这东西便宜得很,现在早就被蓝牙手柄取代了。但扔了可惜,拆开看看里面是什么芯片,说不定还能废物利用一下。
拆开之后发现,接收器里面是一颗常见的USB HID转接芯片,负责把两个PS2手柄的信号转成USB协议发给电脑。而PS2手柄本身,其实是一个非常经典的嵌入式外设——它用的是SPI类似的同步串行协议,只需要几根线就能读出按键和摇杆的全部状态。这就意味着,只要我有一块带SPI接口的单片机板子,就能直接跟PS2手柄通信,不需要那个接收器。
于是就有了这个项目:用一块STM32最小系统板,直接读取PS2手柄的数据,并且用手柄上的按键去控制板载的LED灯。听起来很简单对吧?但实际做下来,从协议时序到电源电平,从按键映射到调试排错,坑是一个接一个。这篇文章就把整个折腾过程完整记录下来,包括硬件接线、协议解析、代码实现、调试技巧,以及我踩过的那些坑。如果你手头也有吃灰的PS2手柄,或者想找一个练手SPI通信的小项目,这篇内容应该能帮你省下不少时间。
这个项目适合谁呢?刚学完STM32的GPIO和SPI外设、想找个实际项目练手的朋友;手头有PS2手柄想改造利用的电子爱好者;以及需要给某个设备加一个低成本物理控制器的开发者。不需要太深的基础,但至少要会用Keil或者STM32CubeIDE建工程、会看时序图、会用万用表测通断。下面我会尽量把每一步都讲清楚,包括为什么这么做、不这么做会怎样。
2. 整体方案设计与选型考量
2.1 为什么选PS2手柄而不是其他方案
市面上能买到的物理控制器方案其实不少:蓝牙手柄、2.4G无线手柄、USB手柄、甚至自己用摇杆模块搭一个。但PS2手柄有几个独特的优势,让它特别适合嵌入式练手项目。
第一是协议简单且公开。PS2手柄用的是类似SPI的同步串行协议,时钟频率可以低到几十kHz,对单片机没有任何压力。协议文档在网上流传很广,数据格式清晰,不需要逆向工程。相比之下,蓝牙手柄要处理蓝牙协议栈,USB手柄要写USB Host驱动,复杂度高了好几个数量级。
第二是硬件成本极低。一个二手PS2手柄加一个接收器,在二手市场上可能只要十几块钱。如果只用手柄本体,连接收器都不需要,直接飞线就能用。手柄内部有现成的按键、摇杆、震动马达,结构完整,手感也不错,比买散件自己搭要划算得多。
第三是供电简单。PS2手柄工作电压是3.3V到5V,电流大概几十毫安,直接用单片机板子上的3.3V或者5V供电就行,不需要额外的电源管理。这一点比某些需要特定电压的传感器要友好得多。
当然也有缺点。PS2手柄的接口是那种薄薄的排线插座,不太好焊接,需要小心操作。而且手柄线缆里除了电源和地,还有几根信号线,颜色定义在不同批次的手柄上可能不一样,需要自己测。但这些问题都有成熟的解决方案,后面会详细说。
2.2 主控板的选择与资源分配
我手头用的是一块常见的STM32F103C8T6最小系统板,也就是大家常说的“蓝色药丸”。这块板子便宜、资料多、GPIO丰富,用来做这个项目绰绰有余。选它的理由很简单:有硬件SPI接口,可以配置成主机模式;有足够的GPIO做片选和应答;板载LED可以直接用来做实验,不需要额外接线。
具体资源分配是这样的:SPI1的SCK和MOSI用来给手柄提供时钟和发送命令,MISO用来接收手柄返回的数据。片选信号ATT用任意一个GPIO控制,我选的是PA4。手柄还有一个ACK信号,用来指示它什么时候准备好数据,我接在PA5上。板载LED用的是PC13,这是蓝色药丸上自带的LED,低电平点亮。
这里有个细节需要注意:PS2手柄的SPI协议跟标准SPI不太一样。标准SPI是全双工的,主机发一个字节的同时从机回一个字节。但PS2手柄是半双工的,主机先发命令,然后手柄再回数据,中间有一个应答过程。所以不能直接用STM32的硬件SPI外设去读,需要手动控制时序。我试过用硬件SPI,发现时序对不上,最后还是老老实实用GPIO模拟SPI。这一点后面会详细解释。
2.3 电平匹配与电源方案
PS2手柄的工作电压范围是3.3V到5V,但它的信号线电平是跟供电电压相关的。如果手柄用3.3V供电,信号线就是3.3V电平,可以直接跟STM32的3.3V GPIO对接。如果手柄用5V供电,信号线就是5V电平,直接接STM32的GPIO可能会损坏芯片。
我一开始想用手柄原装的5V供电,因为接收器是插USB的,USB就是5V。但后来发现STM32的GPIO耐压虽然是5V(部分引脚),但为了保险起见,还是统一用3.3V供电更稳妥。实测下来,PS2手柄在3.3V下工作完全正常,按键响应和摇杆数据都没有问题。震动马达可能会因为电压低而力度弱一些,但这个项目里用不到震动,所以无所谓。
电源方面,我直接从蓝色药丸的3.3V引脚取电给手柄。需要注意的是,PS2手柄的电流峰值可能达到50mA左右,如果板子上的3.3V稳压器余量不够,可能会导致单片机复位。我实测下来,蓝色药丸上的AMS1117稳压器带一个手柄没问题,但如果同时接两个手柄或者加上其他外设,最好还是单独供电。
接线方面,PS2手柄的排线里通常有9根线,但实际用到的只有6根:电源、地、ATT、CMD、CLK、DAT。有些手柄还有ACK和震动马达的电源线。不同批次的手柄线序可能不同,我建议先用万用表测一下,找到电源和地,然后再根据协议文档确定信号线。网上有现成的PS2手柄引脚图,但最好还是自己验证一下,避免接错烧板子。
3. PS2手柄协议深度解析与实操要点
3.1 手柄接口的物理层与信号定义
PS2手柄的接口是一个9针的排线插座,但实际用到的引脚只有6个。从手柄端看过去,引脚定义通常是这样的:1脚是DAT(数据),2脚是CMD(命令),3脚是震动马达电源,4脚是GND,5脚是VCC(3.3V),6脚是ATT(片选),7脚是CLK(时钟),8脚是空,9脚是ACK(应答)。但不同厂家、不同批次的手柄,线序可能会有差异,所以一定要先确认。
我手头这个手柄的排线颜色是:棕色、橙色、红色、黑色、绿色、蓝色、黄色、灰色、白色。用万用表测了一下,黑色是GND,红色是VCC,其他几根需要根据协议来判断。我的方法是:先接好电源和地,然后用示波器或者逻辑分析仪看手柄上电后的信号线状态。ATT和CLK在空闲时是高电平,CMD和DAT也是高电平。如果某根线一直是低电平,那可能是震动马达的电源或者ACK。
确认线序之后,接线就简单了。我把手柄的DAT接到STM32的MISO(PA6),CMD接到MOSI(PA7),CLK接到SCK(PA5),ATT接到PA4,ACK接到PA3。VCC和GND分别接3.3V和地。注意,PS2手柄的DAT和CMD线需要上拉电阻,通常手柄内部已经有上拉了,但如果没有,需要在单片机端加上4.7k到10k的上拉电阻。我实测下来,手柄内部有上拉,所以直接接就行。
注意:接线之前一定要断电,接好之后再上电。热插拔可能会导致手柄内部的芯片损坏,尤其是电源线接反的情况。我有个朋友就是因为电源接反,把手柄烧了,虽然不贵,但重新买一个也要等快递。
3.2 通信时序的逐帧拆解
PS2手柄的通信协议是主机发起、从机响应的模式。一次完整的通信包含8个字节的数据交换,每个字节8位,低位在前。主机先拉低ATT,然后在每个时钟周期发送一位命令,同时从机在时钟下降沿之后把数据放到DAT线上。主机在时钟上升沿读取DAT线上的数据。
具体时序是这样的:空闲时,ATT、CLK、CMD都是高电平。通信开始时,主机拉低ATT,然后开始发送第一个字节0x01,这是起始命令。发送完8位之后,主机释放CMD线,然后发送一个字节0xFF作为占位,同时读取从机返回的第一个字节。从机返回的第一个字节通常是0xFF或者0x41,表示手柄的类型。接下来的几个字节依次是按键数据、摇杆数据等。
这里有个关键点:PS2手柄的时钟极性是空闲高电平,数据在下降沿改变,在上升沿采样。这跟标准SPI的Mode 3(CPOL=1,CPHA=1)有点像,但又不完全一样。标准SPI是全双工的,主机发一位的同时从机回一位。但PS2手柄是主机先发命令,然后从机再回数据,中间有一个字节的延迟。所以不能用硬件SPI的收发函数直接读,需要手动控制时序。
我一开始尝试用STM32的硬件SPI,配置成Mode 3,然后调用HAL_SPI_TransmitReceive函数。结果读出来的数据全是0xFF,完全不对。后来用逻辑分析仪抓了一下时序,发现硬件SPI的时钟频率太高,而且片选信号的控制方式也不对。PS2手柄要求时钟频率在100kHz到500kHz之间,太高了不行。而且ATT信号需要在发送第一个字节之前拉低,发送完最后一个字节之后拉高,中间不能有间隙。硬件SPI的片选控制是自动的,很难精确控制这个时序。
最后我还是用GPIO模拟SPI,手动控制CLK和CMD线,在时钟上升沿读取DAT线。这样虽然代码麻烦一点,但时序完全可控,实测下来非常稳定。下面是一个典型的通信时序:
// 拉低ATT,开始通信 GPIO_ResetBits(GPIOA, GPIO_Pin_4); // 发送第一个字节0x01 for (int i = 0; i < 8; i++) { // 在时钟下降沿设置CMD线 GPIO_ResetBits(GPIOA, GPIO_Pin_5); if (cmd & 0x01) { GPIO_SetBits(GPIOA, GPIO_Pin_7); } else { GPIO_ResetBits(GPIOA, GPIO_Pin_7); } cmd >>= 1; // 在时钟上升沿读取DAT线 GPIO_SetBits(GPIOA, GPIO_Pin_5); if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6)) { data |= (1 << i); } } // 发送完8位之后,释放CMD线 GPIO_SetBits(GPIOA, GPIO_Pin_7); // 继续发送0xFF,读取从机返回的数据 // ... // 拉高ATT,结束通信 GPIO_SetBits(GPIOA, GPIO_Pin_4);这段代码只是示意,实际使用的时候需要把8个字节的收发都封装好。注意,每个字节之间要有一定的延时,通常10微秒左右就够了。延时太短了手柄反应不过来,太长了会影响刷新率。我实测下来,10微秒的延时可以稳定工作,手柄的刷新率大概在100Hz左右,完全够用。
3.3 数据帧格式与按键映射
PS2手柄返回的数据帧是8个字节,每个字节的含义如下:
| 字节序号 | 含义 | 说明 |
|---|---|---|
| 0 | 起始字节 | 通常为0xFF或0x41 |
| 1 | 手柄类型 | 0x41表示数字手柄,0x73表示模拟手柄 |
| 2 | 按键数据1 | 包含SELECT、L3、R3、START、上、右、下、左 |
| 3 | 按键数据2 | 包含L2、R2、L1、R1、三角、圆圈、叉、方块 |
| 4 | 右摇杆X | 0x00到0xFF,中间值0x80 |
| 5 | 右摇杆Y | 0x00到0xFF,中间值0x80 |
| 6 | 左摇杆X | 0x00到0xFF,中间值0x80 |
| 7 | 左摇杆Y | 0x00到0xFF,中间值0x80 |
按键数据是低电平有效的,也就是说,如果某个按键被按下,对应的位是0,否则是1。比如字节2的bit0是SELECT键,如果SELECT被按下,bit0就是0。字节3的bit3是三角键,如果三角被按下,bit3就是0。
这里有个坑:不同型号的手柄,数据帧的格式可能略有不同。比如有些第三方手柄的起始字节不是0xFF,而是0x41或者0x79。而且模拟手柄和数字手柄的返回数据长度也不一样。数字手柄只返回前4个字节,后面的摇杆数据是0xFF。模拟手柄返回全部8个字节。我手头这个是模拟手柄,所以能读到摇杆数据。
还有一个坑:手柄的按键是低电平有效的,但摇杆数据是正常的0x00到0xFF。所以在处理按键的时候,需要取反或者判断是否为0。我一开始没注意,以为按键按下是1,结果读出来的数据全是反的,调了半天才发现这个问题。
3.4 供电与信号完整性的注意事项
PS2手柄的供电电压范围是3.3V到5V,但它的信号线电平是跟供电电压相关的。如果手柄用3.3V供电,信号线就是3.3V电平,可以直接跟STM32的3.3V GPIO对接。如果手柄用5V供电,信号线就是5V电平,直接接STM32的GPIO可能会损坏芯片。
我一开始想用手柄原装的5V供电,因为接收器是插USB的,USB就是5V。但后来发现STM32的GPIO耐压虽然是5V(部分引脚),但为了保险起见,还是统一用3.3V供电更稳妥。实测下来,PS2手柄在3.3V下工作完全正常,按键响应和摇杆数据都没有问题。震动马达可能会因为电压低而力度弱一些,但这个项目里用不到震动,所以无所谓。
电源方面,我直接从蓝色药丸的3.3V引脚取电给手柄。需要注意的是,PS2手柄的电流峰值可能达到50mA左右,如果板子上的3.3V稳压器余量不够,可能会导致单片机复位。我实测下来,蓝色药丸上的AMS1117稳压器带一个手柄没问题,但如果同时接两个手柄或者加上其他外设,最好还是单独供电。
信号完整性方面,PS2手柄的通信速率不高,所以对走线要求不严格。但如果线太长,比如超过30厘米,可能会出现数据错误。我建议尽量用手柄原装的排线,不要自己延长。如果一定要延长,最好用屏蔽线,并且在单片机端加上拉电阻。另外,CLK和DAT线不要跟电源线平行走,避免串扰。
4. 代码实现与核心环节拆解
4.1 GPIO模拟SPI的底层驱动
前面说了,PS2手柄的时序跟标准SPI不太一样,所以不能用硬件SPI外设。我用的是GPIO模拟的方式,手动控制CLK、CMD、ATT和ACK线。下面是一个完整的字节收发函数:
// 向PS2手柄发送一个字节,并返回接收到的字节 uint8_t PS2_Transfer(uint8_t cmd) { uint8_t data = 0; for (int i = 0; i < 8; i++) { // 在时钟下降沿设置CMD线 GPIO_ResetBits(GPIOA, GPIO_Pin_5); // CLK低 if (cmd & 0x01) { GPIO_SetBits(GPIOA, GPIO_Pin_7); // CMD高 } else { GPIO_ResetBits(GPIOA, GPIO_Pin_7); // CMD低 } cmd >>= 1; // 延时,保证时序 delay_us(5); // 在时钟上升沿读取DAT线 GPIO_SetBits(GPIOA, GPIO_Pin_5); // CLK高 if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6)) { data |= (1 << i); } delay_us(5); } return data; }这个函数每次收发一个字节,低位在前。注意,在时钟下降沿设置CMD线,在时钟上升沿读取DAT线。延时5微秒是为了保证时序,实测下来这个延时可以稳定工作。如果延时太短,手柄可能反应不过来;如果太长,刷新率会下降。
接下来是完整的通信函数,一次读取手柄的全部数据:
// 读取PS2手柄的数据 void PS2_ReadData(uint8_t *data) { // 拉低ATT,开始通信 GPIO_ResetBits(GPIOA, GPIO_Pin_4); // 发送起始命令0x01 PS2_Transfer(0x01); // 发送0x42,请求数据 PS2_Transfer(0x42); // 发送0x00,占位 PS2_Transfer(0x00); // 读取后续数据 for (int i = 0; i < 6; i++) { data[i] = PS2_Transfer(0x00); } // 拉高ATT,结束通信 GPIO_SetBits(GPIOA, GPIO_Pin_4); // 字节之间的延时 delay_us(10); }这个函数先发送0x01作为起始命令,然后发送0x42请求数据,接着发送0x00占位,最后读取6个字节的数据。注意,第一个字节0x01是固定的,第二个字节0x42是请求数据,后面的0x00是占位,因为手柄需要时间准备数据。读取到的6个字节依次是:手柄类型、按键数据1、按键数据2、右摇杆X、右摇杆Y、左摇杆X、左摇杆Y。实际上第一个字节是0xFF或者0x41,第二个字节才是手柄类型。
这里有个细节:PS2手柄在收到0x01之后,会返回一个0xFF或者0x41,表示它已经准备好了。然后主机发送0x42,手柄返回0x5A,表示确认。接着主机发送0x00,手柄返回按键数据。所以完整的通信过程是:0x01 -> 0xFF,0x42 -> 0x5A,0x00 -> 按键数据1,0x00 -> 按键数据2,以此类推。
我一开始没注意这个细节,以为发送0x01之后直接读数据就行,结果读出来的全是0xFF。后来看了协议文档才发现,需要先发送0x01,等手柄返回0xFF之后,再发送0x42,等手柄返回0x5A之后,才能开始读数据。这个握手过程很重要,少了这一步手柄不会返回有效数据。
4.2 按键解析与LED控制逻辑
读取到手柄数据之后,下一步就是解析按键,然后控制LED。PS2手柄的按键数据是低电平有效的,所以需要取反或者判断是否为0。下面是一个简单的按键解析函数:
// 解析按键数据,返回按键状态 uint16_t PS2_GetButtons(uint8_t *data) { uint16_t buttons = 0; // 字节2的bit0到bit7分别对应SELECT、L3、R3、START、上、右、下、左 if (!(data[1] & 0x01)) buttons |= BUTTON_SELECT; if (!(data[1] & 0x02)) buttons |= BUTTON_L3; if (!(data[1] & 0x04)) buttons |= BUTTON_R3; if (!(data[1] & 0x08)) buttons |= BUTTON_START; if (!(data[1] & 0x10)) buttons |= BUTTON_UP; if (!(data[1] & 0x20)) buttons |= BUTTON_RIGHT; if (!(data[1] & 0x40)) buttons |= BUTTON_DOWN; if (!(data[1] & 0x80)) buttons |= BUTTON_LEFT; // 字节3的bit0到bit7分别对应L2、R2、L1、R1、三角、圆圈、叉、方块 if (!(data[2] & 0x01)) buttons |= BUTTON_L2; if (!(data[2] & 0x02)) buttons |= BUTTON_R2; if (!(data[2] & 0x04)) buttons |= BUTTON_L1; if (!(data[2] & 0x08)) buttons |= BUTTON_R1; if (!(data[2] & 0x10)) buttons |= BUTTON_TRIANGLE; if (!(data[2] & 0x20)) buttons |= BUTTON_CIRCLE; if (!(data[2] & 0x40)) buttons |= BUTTON_CROSS; if (!(data[2] & 0x80)) buttons |= BUTTON_SQUARE; return buttons; }这个函数把按键数据解析成一个16位的位掩码,每个位对应一个按键。然后就可以根据按键状态控制LED了。比如,按下叉键点亮LED,松开熄灭:
// 主循环 while (1) { uint8_t data[8]; PS2_ReadData(data); uint16_t buttons = PS2_GetButtons(data); if (buttons & BUTTON_CROSS) { GPIO_ResetBits(GPIOC, GPIO_Pin_13); // 点亮LED } else { GPIO_SetBits(GPIOC, GPIO_Pin_13); // 熄灭LED } delay_ms(10); }这段代码很简单,但实际使用的时候需要注意几点。第一,PS2_ReadData函数需要一定的时间,大概几百微秒,所以主循环的刷新率不会太高。实测下来,10毫秒的延时可以稳定工作,LED的响应也很跟手。第二,按键需要消抖,虽然PS2手柄的按键质量不错,但偶尔也会有抖动。我加了一个简单的软件消抖,连续两次读到相同的按键状态才认为有效。
4.3 摇杆数据的处理与扩展应用
除了按键,PS2手柄还有两个摇杆,每个摇杆有两个轴,总共4个模拟量。摇杆数据的范围是0x00到0xFF,中间值是0x80。我可以用摇杆来控制LED的亮度,或者控制其他外设。比如,用左摇杆的X轴控制PWM的占空比,从而调节LED的亮度:
// 读取左摇杆X轴的值 uint8_t lx = data[5]; // 把0x00到0xFF映射到PWM的占空比 uint16_t duty = (lx * 1000) / 255; // 设置PWM占空比 TIM_SetCompare1(TIM2, duty);这段代码把摇杆的0x00到0xFF映射到PWM的0到1000,然后设置定时器的比较值。实测下来,摇杆的控制非常平滑,LED的亮度变化也很自然。不过需要注意的是,摇杆的中间值可能不是精确的0x80,会有一些偏差。我实测下来,中间值大概在0x7C到0x84之间波动。如果要做精确控制,需要先校准中间值。
摇杆还有一个问题:它的输出是模拟量,但STM32的GPIO是数字的,所以只能通过SPI读取数字化的摇杆值。PS2手柄内部有一个ADC,把摇杆的模拟量转换成8位的数字量。这个ADC的精度不高,只有8位,所以摇杆的分辨率有限。如果需要更高的精度,可能需要换用其他手柄或者自己加ADC。
4.4 完整工程结构与编译配置
整个工程的目录结构大概是这样的:
PS2_LED_Control/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── ps2.h │ │ └── stm32f1xx_hal_conf.h │ └── Src/ │ ├── main.c │ ├── ps2.c │ └── stm32f1xx_hal_msp.c ├── Drivers/ │ ├── CMSIS/ │ └── STM32F1xx_HAL_Driver/ ├── MDK-ARM/ │ └── PS2_LED_Control.uvprojx └── README.mdps2.c里面是PS2手柄的驱动代码,包括GPIO初始化、字节收发、数据读取和按键解析。main.c里面是主循环和LED控制逻辑。编译配置方面,我用的是Keil MDK,需要开启STM32F1xx的HAL库,并且配置好时钟和GPIO。注意,GPIO的初始化需要把CLK、CMD、ATT配置成推挽输出,DAT和ACK配置成上拉输入。SPI的时钟频率不需要配置,因为用的是GPIO模拟。
编译的时候可能会遇到一些问题,比如HAL库的版本不匹配、头文件路径不对等。我建议直接用STM32CubeMX生成工程,然后手动添加ps2.c和ps2.h。CubeMX会自动配置好时钟和GPIO,省去很多麻烦。生成工程之后,把PS2手柄的驱动代码加进去,然后在main.c里面调用就行了。
提示:如果编译的时候提示找不到HAL库的头文件,检查一下Include Paths里面有没有添加Drivers/STM32F1xx_HAL_Driver/Inc和Drivers/CMSIS/Device/ST/STM32F1xx/Include。这两个路径是必须的。
5. 调试排错与常见问题实录
5.1 读不到数据怎么办
这是最常见的问题,也是最让人头疼的。我一开始接好线之后,读出来的数据全是0xFF,完全不对。排查了半天,发现是几个原因叠加在一起。
第一个原因是时序不对。PS2手柄要求时钟频率在100kHz到500kHz之间,我一开始用的延时是1微秒,时钟频率大概在500kHz左右,理论上没问题。但实际用逻辑分析仪抓了一下,发现时钟的占空比不对,高电平时间太短,手柄反应不过来。后来把延时改成5微秒,时钟频率降到100kHz左右,就正常了。
第二个原因是片选信号的控制不对。PS2手柄要求ATT信号在发送第一个字节之前拉低,发送完最后一个字节之后拉高,中间不能有间隙。我一开始用的是硬件SPI的片选控制,结果片选信号在字节之间会拉高,导致手柄复位。后来改成手动控制ATT,问题就解决了。
第三个原因是电源问题。我一开始用手柄原装的5V供电,但STM32的GPIO是3.3V的,电平不匹配。虽然STM32的GPIO耐压是5V,但输入高电平的门限是0.7VDD,也就是2.31V。5V的信号虽然不会烧芯片,但可能会导致输入不稳定。后来改成3.3V供电,问题就解决了。
排查的时候,我建议先用逻辑分析仪或者示波器抓一下时序,看看CLK、CMD、DAT和ATT的信号是否正常。如果没有逻辑分析仪,可以用万用表测一下电压,ATT和CLK在空闲时应该是高电平,通信时会有脉冲。如果电压不对,检查一下接线和电源。
5.2 按键响应不灵敏或误触发
按键响应不灵敏或者误触发,通常有几个原因。第一个原因是消抖没做好。PS2手柄的按键是机械触点,按下和松开的时候会有抖动,如果不消抖,可能会读到多次按键。我加了一个简单的软件消抖,连续两次读到相同的按键状态才认为有效,问题就解决了。
第二个原因是刷新率太低。如果主循环的延时太长,按键的响应会变慢。我一开始用的是100毫秒的延时,结果按键要按很久才有反应。后来改成10毫秒,响应就快多了。但延时也不能太短,太短了手柄反应不过来,数据会出错。实测下来,10毫秒是一个比较合适的值。
第三个原因是电源噪声。如果手柄的电源不干净,可能会导致数据错误。我建议在电源引脚旁边加一个100微法的电解电容和一个0.1微法的陶瓷电容,滤除高频和低频噪声。实测下来,加了电容之后,数据的稳定性明显提高。
还有一个可能的原因是手柄本身的问题。有些第三方手柄的兼容性不好,返回的数据格式可能跟原装手柄不一样。如果排查了半天还是不行,换一个原装手柄试试。我手头这个手柄是原装的,所以没遇到这个问题,但网上有人反映第三方手柄会有兼容性问题。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 读不到数据,全是0xFF | 时序不对 | 用逻辑分析仪抓时序 | 调整延时,降低时钟频率 |
| 读不到数据,全是0x00 | 接线错误 | 检查DAT和CMD线是否接反 | 交换DAT和CMD线 |
| 数据偶尔出错 | 电源噪声 | 用示波器看电源纹波 | 加滤波电容 |
| 按键响应慢 | 刷新率太低 | 检查主循环延时 | 减小延时到10毫秒 |
| 按键误触发 | 消抖没做好 | 检查按键数据是否抖动 | 加软件消抖 |
| 摇杆数据不准 | 中间值偏差 | 读取摇杆中间值 | 校准中间值 |
| 手柄发热 | 电源接反 | 检查VCC和GND | 立即断电,重新接线 |
| 单片机复位 | 电源电流不足 | 测量3.3V电压 | 单独给手柄供电 |
这个表格是我在实际调试中总结出来的,基本上涵盖了常见的问题。如果你遇到了其他问题,可以在评论区留言,我会尽量帮忙排查。
5.4 几个容易被忽略的细节
第一个细节是PS2手柄的排线插座。那个插座是1.0毫米间距的,焊接的时候需要小心,不要短路。我建议用细一点的漆包线,先焊好再插到手柄上。如果直接用手柄原装的排线,注意不要反复弯折,容易断。
第二个细节是STM32的GPIO模式。CLK、CMD、ATT需要配置成推挽输出,DAT和ACK需要配置成上拉输入。如果配置错了,可能会导致信号无法正常传输。我一开始把DAT配置成了推挽输出,结果读不到数据,后来改成上拉输入就好了。
第三个细节是延时函数。我用的是HAL_Delay,但这个函数是毫秒级的,微秒级的延时需要自己实现。我用的是SysTick定时器,配置成1微秒的中断,然后在中断里计数。也可以用空循环来实现,但精度不高。我建议用SysTick,精度高,而且不占用CPU。
第四个细节是手柄的初始化。PS2手柄上电之后需要一定的时间来初始化,大概几百毫秒。如果上电之后立刻通信,可能会读不到数据。我建议上电之后延时500毫秒再开始通信。实测下来,这个延时是必要的。
第五个细节是震动马达。PS2手柄的震动马达需要额外的电源,通常是7.5V左右。如果只给3.3V,震动马达不会转,但不会影响按键和摇杆的功能。如果需要震动功能,需要单独给马达供电,并且加一个驱动电路。这个项目里用不到震动,所以没接。
6. 项目扩展与个人经验分享
6.1 从LED控制到更多应用场景
这个项目虽然简单,但扩展性很强。掌握了PS2手柄的通信协议之后,你可以把它用到很多地方。比如,用摇杆控制舵机,做一个简单的机械臂;用按键控制继电器,做一个智能家居的控制器;或者用摇杆控制电机的转速和方向,做一个小车。PS2手柄的按键多、摇杆精度够用、成本低,非常适合做原型验证。
我自己后来把这个项目扩展了一下,用左摇杆控制两个舵机,做了一个二自由度的云台。右摇杆控制LED的亮度和颜色,按键控制云台的复位和校准。整个系统用一块STM32F103就能搞定,成本不到50块钱。如果你手头有吃灰的PS2手柄,不妨试试这个方向。
还有一个有意思的扩展是用PS2手柄控制电脑。虽然PS2手柄原本就是用来玩游戏的,但通过STM32转成USB HID设备,可以让电脑把它识别成一个标准的游戏手柄。这样就不用买那个接收器了,直接一根USB线就能用。这个扩展需要用到STM32的USB外设,稍微复杂一点,但网上有现成的例程可以参考。
6.2 我踩过的坑与实操心得
第一个坑是电源接反。我一开始没注意线序,把VCC和GND接反了,结果手柄发热,幸好发现得早,没烧坏。后来我用万用表测了一下,确认了线序才接。所以接线之前一定要测一下,不要凭感觉。
第二个坑是时序不对。我一开始用硬件SPI,配置成Mode 3,结果读出来的数据全是0xFF。后来用逻辑分析仪抓了一下,发现硬件SPI的时钟频率太高,而且片选信号的控制方式也不对。最后改成GPIO模拟SPI,问题就解决了。所以不要迷信硬件外设,有时候软件模拟更灵活。
第三个坑是消抖没做好。我一开始没加消抖,结果按键偶尔会误触发。后来加了一个简单的软件消抖,连续两次读到相同的按键状态才认为有效,问题就解决了。消抖的时间不能太长,太长会影响响应速度,我实测下来,10毫秒的消抖时间比较合适。
第四个坑是摇杆的中间值偏差。我一开始以为摇杆的中间值是0x80,结果实测下来是0x7C到0x84之间波动。如果要做精确控制,需要先校准中间值。我的做法是上电之后先读几次摇杆数据,取平均值作为中间值,然后在后续的处理中减去这个偏差。
第五个坑是排线插座焊接。那个插座是1.0毫米间距的,焊接的时候很容易短路。我一开始焊了好几次都不行,后来用细一点的漆包线,先焊好再插到手柄上,问题就解决了。如果你没有焊接经验,建议买一个现成的转接板,省去焊接的麻烦。
6.3 后续可以继续折腾的方向
这个项目做完之后,还有很多可以继续折腾的方向。比如,把PS2手柄的数据通过串口发送到电脑,用Python或者Processing做一个可视化界面,实时显示按键和摇杆的状态。这样调试的时候会更直观,也方便做数据分析。
还可以把PS2手柄的数据通过无线模块发送出去,做一个无线的控制器。比如用NRF24L01或者蓝牙模块,把STM32采集到的数据发送到另一个STM32或者电脑上。这样就不用拖着一条线了,使用起来更方便。
另外一个方向是优化代码,提高刷新率。目前主循环的延时是10毫秒,刷新率大概在100Hz左右。如果优化一下代码,把延时降到5毫秒,刷新率可以提高到200Hz。不过PS2手柄本身的响应速度有限,太高的刷新率也没有太大意义。我实测下来,100Hz的刷新率已经足够跟手了。
最后,如果你对PS2手柄的协议感兴趣,可以深入研究一下它的震动马达控制和压力感应按键。PS2手柄的按键是压力感应的,可以读出按压力度,这个功能在有些游戏里会用到。不过这个功能需要额外的命令来启用,而且不同型号的手柄支持程度不一样,折腾起来比较麻烦。如果你有兴趣,可以试试看。
这个项目从头到尾大概花了我一个周末的时间,其中大部分时间都花在调试时序和排查接线上了。但做完之后,看着手柄上的按键控制LED亮灭,还是挺有成就感的。如果你也在折腾类似的项目,希望这篇文章能帮你少走一些弯路。有什么问题欢迎在评论区交流,我看到都会回复。