1. 项目缘起与整体设计思路
1.1 这个项目到底在做什么
手头有一块吃灰很久的PS2手柄接收器,加上一块闲置的ZTMR开发板,与其让它们继续躺在元件盒里,不如让它们干点正事。这个项目的核心目标很直接:用PS2手柄的按键去控制ZTMR板载的LED灯,按下手柄上的某个键,板子上的灯就亮,松开就灭,或者用不同按键切换不同的灯光模式。
听起来简单,但这里面涉及的东西其实不少。PS2手柄和开发板之间的通信协议、SPI总线的时序匹配、按键数据的解析、LED的驱动逻辑,每一个环节都有坑。我之所以选这个题目来写,是因为它刚好卡在一个很舒服的位置——难度不算高,新手能跟着做下来,但涉及的知识点又足够典型,做完之后对SPI通信、GPIO控制、状态机设计都会有一个比较实在的理解。
适合谁来参考这篇内容?如果你手上有PS2手柄接收器(那种带转接板的,或者直接剪线接出来的),又有一块带SPI接口的开发板,那就可以直接抄作业。如果你只是想了解PS2手柄的通信原理和SPI的实操细节,这篇也能给你不少参考。我尽量把每一步为什么这么做讲清楚,而不是只丢一段代码让你自己悟。
1.2 为什么选PS2手柄加ZTMR这个组合
PS2手柄在DIY圈子里算是老面孔了。它的通信协议是SPI,但又不是标准SPI——时钟极性、数据位顺序、应答机制都有自己的一套规矩。很多人第一次接PS2手柄的时候,照着网上的代码抄,结果读出来的数据全是0xFF或者0x00,就是因为时序没对上。
选ZTMR这块板子,一方面是因为它板载了LED,省去了外接LED和限流电阻的麻烦,另一方面它的SPI外设配置比较灵活,时钟极性和相位都能调,正好能匹配PS2手柄的要求。而且板载LED直接挂在GPIO上,控制逻辑简单,不会把注意力从核心的通信问题上分散掉。
这个组合的优势在于:硬件连接少(就几根线),代码量可控(核心逻辑两三百行),但覆盖的知识面够宽。你做完之后,SPI的四种模式、移位寄存器的概念、轮询和中断的取舍,这些都会有一个具体的落脚点。
1.3 整体方案的设计取舍
方案上我做了几个关键决定,这里说一下背后的考量。
第一个决定是用软件模拟SPI还是硬件SPI。PS2手柄的SPI时钟频率不高,大概在250kHz左右,硬件SPI完全跑得起来。但PS2手柄有个特殊的地方:它的数据是在时钟下降沿输出、上升沿采样,而且每次通信之间需要拉低片选一段时间。硬件SPI的时序虽然可以配置,但片选的控制往往不够灵活。我最后选了硬件SPI加手动控制片选的方式,这样既保证了时序的稳定性,又能精确控制通信的起止。
第二个决定是轮询还是中断。PS2手柄的数据是持续更新的,但更新频率不高,大概每秒几十次。用轮询的方式在主循环里定时读取完全够用,而且逻辑简单,不容易出bug。中断方式虽然响应快,但在这个场景下没有明显优势,反而增加了调试的复杂度。所以我选了轮询。
第三个决定是LED的控制逻辑。板载LED只有一个,但手柄按键有十几个。我的做法是用不同的按键映射不同的灯光模式:比如按下某个键就常亮,按下另一个键就慢闪,再按一个键就快闪。这样用一个LED也能做出可玩性,同时代码里用状态机来管理,逻辑清晰。
提示:如果你手头的板子有多个LED,可以把每个按键映射到一个独立的LED,那样更直观。但单LED的方案更能锻炼状态机的设计能力。
2. PS2手柄通信协议的核心细节
2.1 PS2手柄的SPI时序到底特殊在哪
标准SPI有四种模式,由时钟极性(CPOL)和时钟相位(CPHA)决定。PS2手柄用的是类似Mode 3的方式,但又不完全一样。具体来说:时钟空闲时为高电平,数据在时钟下降沿改变,在上升沿被采样。这听起来像CPOL=1、CPHA=1,但PS2手柄多了一个要求——每次通信的第一个字节(0x01)是起始命令,手柄收到之后才会在后续字节里返回数据。
更麻烦的是应答位。手柄在每个字节的最后一个时钟周期会拉低数据线,表示“我收到了”。如果你在读数据的时候没有检查这个应答位,可能会在通信失败的时候浑然不觉,读出一堆无效数据还以为是对的。
我实测下来,最容易出问题的地方是时钟频率。PS2手柄的数据手册里写的是250kHz,但实际用的时候,频率稍微高一点也能工作,低一点也没问题。但如果你把频率拉到1MHz以上,手柄就直接不响应了。所以硬件SPI的分频系数要算好,确保时钟在200kHz到500kHz之间。
2.2 按键数据的解析方式
PS2手柄返回的数据是一串字节,不同的手柄型号(比如带压力感应的、不带压力感应的)返回的长度不一样。但前几个字节是固定的:第一个字节是0x41或者0x73,表示手柄的类型;第二个字节是0x5A,表示数据有效;后面才是按键的具体状态。
按键状态是用位来表示的。比如第三个字节的bit 0到bit 7分别对应不同的按键,按下为0,松开为1。这里要注意,是低电平有效,不是高电平有效。我第一次写代码的时候没注意,结果按键逻辑全反了,按下去灯灭,松开灯亮,排查了半天才发现是这里的问题。
解析的时候,我建议用一个结构体来存按键状态,每个按键一个位域,这样代码可读性好,后面做映射也方便。比如:
typedef 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; } ps2_buttons_t;这样用的时候直接buttons.up就能判断上键是否按下,比用位运算去取要直观得多。
2.3 通信失败的常见原因和排查思路
PS2手柄通信失败,表现通常是读出来的数据全是0xFF或者全是0x00。这两种情况的原因不一样。
全是0xFF,一般是数据线被拉高了,手柄没有响应。可能的原因有:片选没有拉低、时钟没有输出、手柄供电不足。我遇到过的一次是供电问题——手柄的电源引脚需要3.3V,但我接的是5V,结果手柄直接不工作。后来加了一个电平转换才解决。
全是0x00,一般是数据线被拉低了,可能是接线接反了,或者手柄的某个引脚短路了。还有一种可能是时钟极性配错了,手柄在错误的边沿采样,读出来的数据自然不对。
排查的时候,我建议先用示波器或者逻辑分析仪看一下时钟和数据线的波形。如果没有这些工具,可以用一个笨办法:在代码里把每次读到的原始数据打印出来,看看第一个字节是不是0x01的应答,第二个字节是不是0x41或0x73。如果第一个字节就不对,那肯定是时序或者接线的问题。
注意:PS2手柄的接收器有不同的版本,有的需要3.3V供电,有的可以接受5V。接之前一定要确认清楚,不然可能烧掉接收器。
3. 硬件连接与开发环境搭建
3.1 接线方案与引脚分配
PS2手柄接收器一般有六个引脚:VCC、GND、DATA、CMD、CS、CLK。有的接收器还多一个ACK引脚,但大多数情况下用不到。接线的时候,VCC接3.3V,GND接GND,剩下的四根线接到开发板的SPI引脚上。
具体的引脚分配要看开发板的SPI外设支持哪些引脚。以我用的这块ZTMR为例,SPI1的默认引脚是PA5(CLK)、PA6(MISO)、PA7(MOSI),片选我用了一个普通的GPIO,PA4。这样分配的原因是PA4离SPI引脚近,布线方便,而且PA4没有被其他外设占用。
接线的时候有一个细节:PS2手柄的DATA引脚是双向的,但大多数开发板的MISO引脚是单向输入。所以DATA要接到MISO上,CMD接到MOSI上。如果你接反了,数据就传不过去。
另外,PS2手柄的接收器对电源比较敏感,建议在VCC和GND之间加一个100nF的电容,滤掉高频噪声。我一开始没加,读数据的时候偶尔会出现跳变,加了电容之后就稳定了。
3.2 开发环境的配置要点
开发环境我用的是常见的嵌入式IDE,配置上主要注意两点:一是SPI外设的初始化,二是GPIO的初始化。
SPI的初始化参数里,时钟极性要设成高电平空闲,时钟相位要设成第二个边沿采样,也就是CPOL=1、CPHA=1。分频系数根据系统时钟来算,假设系统时钟是72MHz,要得到250kHz的SPI时钟,分频系数就是72M / 250k ≈ 288,取最接近的256,实际时钟就是281kHz,在可接受范围内。
GPIO的初始化里,片选引脚要设成推挽输出,初始状态为高电平。因为PS2手柄的片选是低电平有效,所以通信开始前要拉低,通信结束后要拉高。
// SPI初始化示例 SPI_InitTypeDef spi; spi.SPI_Direction = SPI_Direction_2Lines_FullDuplex; spi.SPI_Mode = SPI_Mode_Master; spi.SPI_DataSize = SPI_DataSize_8b; spi.SPI_CPOL = SPI_CPOL_High; spi.SPI_CPHA = SPI_CPHA_2Edge; spi.SPI_NSS = SPI_NSS_Soft; spi.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_256; spi.SPI_FirstBit = SPI_FirstBit_MSB; SPI_Init(SPI1, &spi); SPI_Cmd(SPI1, ENABLE);这段代码里,SPI_NSS_Soft表示片选由软件控制,SPI_FirstBit_MSB表示高位先出。这两个参数如果配错了,通信也会失败。
3.3 板载LED的控制方式
板载LED一般是通过一个GPIO引脚控制的,有的是高电平点亮,有的是低电平点亮。这个要查开发板的原理图确认。我用的这块板子是低电平点亮,所以设置GPIO为低的时候灯亮,为高的时候灯灭。
控制LED的代码很简单,就是设置GPIO的输出电平。但为了后面做状态机,我建议把LED的操作封装成函数,比如led_on()、led_off()、led_toggle()。这样后面改逻辑的时候不用去动底层的寄存器操作。
void led_on(void) { GPIO_ResetBits(GPIOA, GPIO_Pin_1); // 低电平点亮 } void led_off(void) { GPIO_SetBits(GPIOA, GPIO_Pin_1); } void led_toggle(void) { GPIO_WriteBit(GPIOA, GPIO_Pin_1, !GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_1)); }这三个函数看起来简单,但后面做闪烁模式的时候会反复用到,封装好之后代码会清爽很多。
4. 核心代码实现与状态机设计
4.1 PS2手柄的初始化流程
PS2手柄上电之后需要初始化,主要是配置它进入正确的数据模式。初始化的流程是:先发送0x01命令,然后发送0x42、0x00、0x00等配置字节,最后发送0x01、0x43、0x00、0x00、0x00让手柄进入持续数据模式。
这个过程看起来繁琐,但其实就是按照固定的序列发送字节,然后检查应答。我写了一个ps2_init()函数来封装这些操作,里面用了一个ps2_send_cmd()函数来发送命令并读取应答。
uint8_t ps2_send_cmd(uint8_t cmd) { uint8_t res; PS2_CS_LOW(); spi_transfer(cmd); res = spi_transfer(0x00); PS2_CS_HIGH(); return res; }这里spi_transfer()是一个阻塞式的SPI收发函数,发送一个字节的同时接收一个字节。PS2手柄的通信是全双工的,发送命令的同时会收到手柄返回的数据。
初始化的时候,每个命令之间要加一点延时,大概10微秒左右。我试过不加延时,结果手柄偶尔会不响应。加了延时之后就没问题了。
4.2 按键读取与数据解析
初始化完成之后,就可以持续读取按键数据了。读取的流程是:拉低片选,发送0x01,然后发送0x42,接着发送0x00,最后发送0x00,同时接收手柄返回的数据。返回的数据里,第三个字节和第四个字节就是按键状态。
void ps2_read(ps2_data_t *data) { PS2_CS_LOW(); spi_transfer(0x01); spi_transfer(0x42); >typedef union { uint8_t byte; 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; } bits; } ps2_byte_t;这样用的时候直接data.buttons.bits.up就能判断上键是否按下,代码可读性很好。
4.3 LED状态机的设计与实现
LED的控制逻辑我用了一个简单的状态机。状态机有三个状态:常亮、慢闪、快闪。手柄上的不同按键用来切换状态。
typedef enum { LED_OFF, LED_ON, LED_SLOW_BLINK, LED_FAST_BLINK } led_state_t; led_state_t led_state = LED_OFF; uint32_t last_toggle_time = 0;在主循环里,根据当前状态和按键输入来更新LED:
void led_update(void) { uint32_t now = get_tick(); switch (led_state) { case LED_OFF: led_off(); break; case LED_ON: led_on(); break; case LED_SLOW_BLINK: if (now - last_toggle_time > 500) { led_toggle(); last_toggle_time = now; } break; case LED_FAST_BLINK: if (now - last_toggle_time > 100) { led_toggle(); last_toggle_time = now; } break; } }按键切换状态的逻辑放在按键处理函数里:
void handle_buttons(ps2_data_t *data) { if (data->buttons.bits.up) { led_state = LED_ON; } else if (data->buttons.bits.down) { led_state = LED_OFF; } else if (data->buttons.bits.left) { led_state = LED_SLOW_BLINK; } else if (data->buttons.bits.right) { led_state = LED_FAST_BLINK; } }这样整个逻辑就很清晰了:主循环里先读手柄数据,然后处理按键,最后更新LED。三个步骤各司其职,调试的时候也容易定位问题。
4.4 主循环的组织方式
主循环的结构很简单,就是一个无限循环,里面依次调用三个函数:
int main(void) { system_init(); ps2_init(); led_init(); while (1) { ps2_data_t data; ps2_read(&data); handle_buttons(&data); led_update(); delay_ms(10); } }这里的delay_ms(10)是为了控制循环频率,避免读手柄太频繁。PS2手柄的数据更新率大概是每秒60次左右,所以10毫秒读一次刚好合适。如果读得太快,手柄可能来不及更新数据,读出来的就是旧值。
提示:如果你发现按键响应有延迟,可以把延时减小到5毫秒试试。但不要低于1毫秒,否则SPI通信可能会出错。
5. 调试过程中踩过的坑与排查技巧
5.1 数据全是0xFF的排查过程
第一次上电的时候,读出来的数据全是0xFF。我一开始以为是接线问题,检查了一遍发现接线没错。然后用逻辑分析仪看波形,发现时钟和数据线都有信号,但数据线在片选拉低之后一直是高电平,说明手柄根本没有拉低数据线来应答。
后来查了资料才知道,PS2手柄的接收器需要一定的启动时间,上电之后不能立刻发命令,要等大概100毫秒。我在ps2_init()前面加了一个delay_ms(100),问题就解决了。
这个坑很典型:很多人在初始化外设的时候忽略了上电稳定时间,结果通信失败还找不到原因。我的经验是,任何外设上电之后都先等100毫秒再操作,宁可多等一会儿,也不要因为时序问题浪费时间。
5.2 按键逻辑反了的解决方式
数据读出来之后,我发现按键逻辑是反的:按下按键灯灭,松开灯亮。这是因为PS2手柄的按键是低电平有效,按下的时候对应位是0,松开是1。我在代码里判断的是if (data->buttons.bits.up),这个条件在按下的时候是假,松开的时候是真,所以逻辑反了。
改成if (!data->buttons.bits.up)就好了。这个坑不大,但很容易忽略,尤其是第一次接触PS2手柄的时候。
5.3 LED闪烁不均匀的原因分析
做闪烁模式的时候,我发现LED闪烁的间隔不均匀,有时候快有时候慢。排查之后发现是last_toggle_time的更新时机不对。我一开始是在led_toggle()里面更新last_toggle_time,但led_toggle()在常亮和常灭状态下也会被调用,导致时间戳被意外刷新。
后来把last_toggle_time的更新移到闪烁状态的判断分支里,只在真正切换LED状态的时候更新,问题就解决了。这个坑提醒我:状态机里的时间戳更新一定要和状态切换绑定,不能随便放在通用的函数里。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 数据全是0xFF | 手柄未启动、片选未拉低、供电不足 | 加100ms上电延时、检查片选信号、确认供电电压 |
| 数据全是0x00 | 接线接反、时钟极性错误 | 检查DATA和CMD接线、确认CPOL和CPHA配置 |
| 按键逻辑反了 | 低电平有效未取反 | 在判断条件前加逻辑非 |
| LED闪烁不均匀 | 时间戳更新时机错误 | 把时间戳更新放在状态切换分支内 |
| 通信偶尔失败 | 电源噪声、延时不足 | 加滤波电容、增加命令间延时 |
| 按键响应延迟 | 主循环延时过长 | 减小延时到5ms |
这张表里的问题都是我实际遇到过的,排查思路也经过了验证。如果你遇到了表里没有的问题,可以先从供电和接线查起,这两个是最常见的故障源。
5.5 几个实用的调试技巧
第一个技巧是用LED做状态指示。在初始化的不同阶段让LED闪烁不同的次数,比如初始化成功闪三下,失败闪五下。这样不用接串口就能知道程序跑到哪一步了。
第二个技巧是打印原始数据。如果条件允许,把每次读到的原始字节打印出来,看看第一个字节是不是0x01的应答,第二个字节是不是0x41或0x73。这两个字节对了,后面的数据基本就是对的。
第三个技巧是分步验证。不要一次性把代码全写完再调试,先写SPI的收发函数,用一个简单的回环测试验证SPI能正常工作;再写PS2的初始化,验证手柄能进入数据模式;最后写按键解析和LED控制。每一步都验证通过之后再往下走,这样出问题的时候容易定位。
注意:PS2手柄的接收器有不同的版本,有的需要3.3V供电,有的可以接受5V。接之前一定要确认清楚,不然可能烧掉接收器。如果不确定,先用3.3V试,大多数接收器都支持3.3V。
6. 功能扩展与进阶玩法
6.1 用摇杆控制LED亮度
按键控制只能做开关和闪烁,如果想做亮度调节,可以用手柄的摇杆。PS2手柄的摇杆返回的是模拟量,范围是0到255。用PWM来控制LED的亮度,把摇杆的值映射到PWM的占空比上,就能实现用摇杆调光的效果。
实现的时候,需要把LED的GPIO配置成PWM输出模式,然后根据摇杆的值设置比较寄存器的值。摇杆的值越大,占空比越高,LED越亮。这个扩展不算复杂,但能让项目的可玩性提升不少。
6.2 多LED的灯光模式
如果板子上有多个LED,可以把每个按键映射到一个独立的LED,或者用多个LED组合出流水灯、呼吸灯等效果。代码上只需要把LED的控制函数扩展成数组,然后根据按键状态去更新每个LED的状态。
多LED的方案更适合做视觉反馈,比如按下不同的按键亮不同颜色的灯,或者用LED的排列来表示手柄的按键布局。如果你手头有RGB LED,还可以做颜色混合,可玩性更高。
6.3 无线化的可能性
PS2手柄本身是有线的,但市面上有PS2手柄的无线接收器,原理和有线接收器一样,只是把有线连接换成了无线。如果你手头有无线接收器,可以直接替换有线的,代码不用改。
无线化之后,手柄的使用场景就更灵活了,可以拿着手柄在房间里走动,不用担心线缆的束缚。不过无线接收器的供电和信号稳定性可能不如有线的,调试的时候要注意。
6.4 用OLED显示按键状态
如果手头有OLED屏幕,可以把按键状态显示在屏幕上,这样调试的时候更直观。OLED一般用I2C接口,和SPI不冲突,可以同时使用。显示的内容可以包括当前按下的按键、LED的状态、通信的成功率等。
这个扩展的价值在于调试方便,尤其是当你不确定按键是否被正确识别的时候,看一眼屏幕就知道了。而且OLED的驱动代码比较成熟,网上有很多现成的库可以用,移植起来不费劲。
7. 个人实操体会与建议
这个项目我从开始到调通大概花了两个晚上,其中大部分时间花在排查通信问题上。回头看,如果一开始就知道PS2手柄需要上电延时、按键是低电平有效、片选要手动控制,可能一个小时就能搞定。所以我把这些坑都写出来,希望能帮你省点时间。
我的建议是:先确保SPI能正常收发,再调PS2的初始化,最后做按键和LED的逻辑。每一步都单独验证,不要跳步。另外,逻辑分析仪或者示波器真的很有用,如果没有,至少准备一个USB转串口的模块,用来打印调试信息。
代码方面,我建议把PS2的驱动和LED的控制分成两个独立的模块,模块之间通过一个结构体来传递数据。这样后面换手柄或者换板子的时候,只需要改一个模块,不用动全部代码。这个习惯在嵌入式开发里很重要,因为硬件平台经常变,代码的可移植性直接影响开发效率。
最后说一个细节:PS2手柄的接收器在长时间不用之后,第一次上电可能需要更长的时间来稳定,我遇到过等200毫秒才响应的情况。所以如果你的代码在别的板子上跑得好好的,换一块板子就不行了,先试试把上电延时加长一点,说不定就解决了。