简介:一份面向STM32开发者的PS2手柄通信实例源码包,基于HAL库与CubeMX配置,适合正在学习外设驱动、串行协议或准备做遥控小车/机器人项目的嵌入式爱好者。压缩包共970个文件,约22.2MB,以C源文件(559个)和头文件(243个)为主,同时包含汇编、链接脚本、工程配置及编译输出文件,其中.ioc和.mxproject可用于直接打开CubeMX与MDK工程,Src目录下提供PS2初始化、数据读取与中断处理等关键驱动实现。资源已有5409人学习,项目结构清晰,从CubeMX图形化配置到HAL库底层操作均有覆盖,读者可借助完整工程快速理解PS2协议帧格式与GPIO/定时器配置思路,并在此基础上扩展按键映射或无线控制功能。无论初学者还是有一定经验的开发者,都能通过这份源码减少移植成本,直接获得一套可编译、可调试的参考实现。 做STM32项目的人,大概率都会经历这样一个阶段:板子能跑、灯能亮、串口能打印了,接下来就想做个稍微像样的东西出来。这时候“PS2手柄HAL库+CubeMX源码”这套组合,几乎是最顺手的输入方案之一。PS2无线手柄的接收器拆开之后,和STM32之间其实只跑着一路SPI协议,按键状态和双摇杆模拟量全部塞在9个字节的帧里来回传。配上STM32F103C8T6这种烂大街的板子和HAL库工程,从CubeMX配好引脚到代码里读出数据,一两个小时就能跑通。这篇文章就是我实际验证过的完整方案,适合想给遥控小车、机械臂、机器人底盘加无线手柄控制的新手,也适合已经能跑串口、GPIO但没碰过SPI协议的人拿来练手,整个思路可以平移到任何带SPI外设的STM32型号上。
1. 项目整体思路与方案选型
1.1 先搞清楚PS2手柄在底层到底传什么
很多人一听到“PS2手柄”就觉得它是什么专用总线,其实不是。PS2无线手柄的接收器,本质上就是一个SPI从机,它和单片机之间只有四根线在跑数据:SCK时钟、一个主机发命令的线路、一个从机回数据的线路、还有片选CS。主机拉低CS,按SCK节奏把命令字节发过去,与此同时从机把自己状态数据一个字节一个字节回传。整个通信就像是两个人在对暗号,主机问一句,从机答一句,一问一答同时发生。
所以这个项目的技术核心,并不是什么高深算法,而是把SPI用好,并且严格遵守PS2手柄的帧协议。你发出去的每一帧,既决定了手柄进入什么模式,也决定了它返回什么数据。我见过不少人把代码抄过去,结果手柄红绿灯乱跳、摇杆乱抖,就是没搞懂这一层。
1.2 为什么我选HAL库加CubeMX而不是标准库
STM32的开发方式现在就两大派,标准外设库和HAL库。标准库的寄存器封装更薄、代码执行效率高,但初始化外设时要自己对照手册一个个配寄存器。HAL库虽然代码量大、运行开销高一些,但它把SPI、I2C这类外设的初始化和收发都封装成了现成接口,再加上CubeMX图形化配置工具,很多容易踩坑的时钟树、引脚复用问题,在生成工程的时候就自动解决了。
我这个项目之所以推荐HAL库加CubeMX,不是因为它比标准库高级,而是因为PS2手柄的SPI通信,真正难调的是时序细节,不是寄存器本身。用CubeMX把SPI参数选对之后,驱动部分只需要调用HAL_SPI_TransmitReceive这一个函数,就能在一问一答的过程中同时拿到手柄回传的数据。代码量少,逻辑清晰,对新手来说排错也容易。其实标准库也能做,只是SPI初始化那堆代码会分散注意力。
1.3 硬件SPI还是模拟SPI:两种方案对比
PS2手柄驱动在网上有两种写法,一种是用STM32的硬件SPI外设,一种是用GPIO模拟SPI时序。硬件SPI的优点是占用CPU少、时钟稳定,CubeMX配好之后收发代码非常干净,缺点是SPI的极性和相位必须选对,否则手柄完全不响应。模拟SPI的优点是引脚随便选、时序可以软件里随意调整,缺点是每个字节都要自己拉CLK,主循环会被通信过程卡住,而且代码量明显膨胀。
我自己实际使用下来的建议是:优先用硬件SPI加软件控制CS。PS2手柄接收器对时钟极性的要求其实只有两种兼容组合,只要把CPOL和CPHA配置成SPI mode 0或者mode 3,再加上时钟频率压到1MHz附近,基本都能稳定读。模拟SPI可以作为备用方案,比如你用到的单片机刚好没空闲SPI外设,或者想彻底搞清楚时序再动手,那时候再翻出来用。
2. 核心细节解析:PS2协议帧与数据格式
2.1 一条完整的命令帧长什么样
PS2手柄的命令帧固定是9个字节,主机每次以CS拉低开始,连续发送9个字节,同时接收9个字节回包。回包的前2个字节基本是命令回显,真正有价值的数据从第3个字节开始。最常见的三条命令如下表:
| 命令目的 | 第1字节 | 第2字节 | 后续字节 | 作用 |
|---|---|---|---|---|
| 进入配置模式 | 0x01 | 0x43 | 0x00 0x01 0x00... | 解锁手柄设置 |
| 切换模拟模式 | 0x01 | 0x44 | 0x00 0x01 0x03 0x00... | 启动模拟摇杆和按键连发 |
| 轮询读取数据 | 0x01 | 0x42 | 0x00 x 7 | 读取当前按键和摇杆状态 |
其中0x44那一条命令里有一个重要参数,表示模式选择。0x01是数字模式,只支持方向键和部分功能键,摇杆没有输出;0x03是模拟模式,红灯亮起,摇杆才开始工作。很多人在网上抄代码发现摇杆读出来一直是128左右,十有八九就是模式没切过去。实测下来,进入配置模式后最好加10毫秒左右的延时再切模拟模式,切完再延时一下,让手柄完成内部状态切换,紧接着再发读数据命令。
2.2 按键数据的位映射
读数据命令返回的9个字节中,第3字节和第4字节分别对应两排按键状态,每个键占一位。PS2手柄的设计是按下为0,松开为1,这和大部分按键扫描电路正好相反,一开始特别容易写反逻辑。
第3字节(数据帧中的rx[2])的位定义是:
| 位 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|
| 按键 | 左 | 下 | 右 | 上 | Start | R3 | L3 | Select |
第4字节(数据帧中的rx[3])的位定义是:
| 位 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|
| 按键 | 方框 | 叉 | 圆圈 | 三角 | R1 | L1 | R2 | L2 |
第一次调通时,建议直接用串口把rx[2]和rx[3]的十六进制值打印出来,然后逐一手动按键,对着表格核对一遍。不同批次的手柄接收器在某些键位的定义上可能有细微区别,实测为准比什么都重要。
2.3 摇杆模拟量的读取与判断
模拟模式下,读回来的第5到第8字节分别对应两个摇杆的X轴和Y轴,每个轴是8位无符号数,范围0到255。摇杆居中时读数一般在128附近,不同手柄会有10个点左右的偏差,摇杆推到一端是0,推到另一端是255。需要注意的是,左右摇杆四路数据的排列顺序偶尔会因为驱动代码不同而错位,网上流传的版本里,有的把rx[4]定义成左摇杆X,有的定义成右摇杆X,这很让人头疼。
我的做法是拿到手柄后先做个简单标定,把手柄静止不动时读到的四个值打印出来,再分别推四个方向,看哪一路数据在变化,用实测结果统一到自己的结构体里。摇杆数据在跳变时会有一些抖动,特别是旧手柄碳膜磨损后,读数会在目标值附近来回飘。这一层用软件滤波就能压下去。比如我做小车底盘遥控时,对摇杆数据做了一阶低通滤波,新读数只取30%的权重,效果很明显,电机左右轮速度差就稳下来了。
3. CubeMX配置与HAL库驱动实现
3.1 CubeMX引脚分配和SPI参数怎么填
CubeMX部分不复杂,但有几个选项填错了,后面会折腾很久。我以STM32F103C8T6为例说明。芯片选好之后,先配置调试口,SYS页面把Debug选成Serial Wire,避免调试器和代码打架。然后进入SPI1配置页面,Mode选择Full-Duplex Master,硬件NSS选择Disable,因为PS2接收器片选信号我们自己用普通GPIO控制。PB引脚不用管,SPI1默认可以映射到PA5、PA6、PA7,正好是SCK、MISO、MOSI。
关键参数照着下面这张表填:
| 参数项 | 配置值 | 说明 |
|---|---|---|
| Mode | Full-Duplex Master | 标准SPI主机 |
| Hardware NSS Signal | Disable | 用软件控制CS |
| Data Size | 8 Bits | 每次收发一字节 |
| First Bit | MSB First | 协议要求高位先发 |
| Clock Polarity (CPOL) | Low | 空闲时钟为低电平 |
| Clock Phase (CPHA) | 1 Edge | 第一个边沿采样数据 |
| Prescaler | 64 | 约1.125MHz |
SPI时钟频率很关键,PS2手柄最早是基于3.3V逻辑设计的,通信速度本来就不高,建议控制在250kHz到1MHz之间。我直接把Prescaler设成64,72MHz系统时钟算下来约1.125MHz,实测配合杜邦线也没问题。CS引脚我用的PA4,设置成GPIO输出,初始电平拉高,代表不选中手柄。
3.2 驱动源码关键函数拆解
CubeMX生成工程之后,核心驱动其实就两个动作:CS拉低,发命令,读回数据,CS拉高。我封装成一个底层命令函数:
#define PS2_CS_LOW() HAL_GPIO_WritePin(PS2_CS_GPIO_Port, PS2_CS_Pin, GPIO_PIN_RESET) #define PS2_CS_HIGH() HAL_GPIO_WritePin(PS2_CS_GPIO_Port, PS2_CS_Pin, GPIO_PIN_SET) void PS2_Cmd(uint8_t *tx, uint8_t *rx, uint8_t len) { PS2_CS_LOW(); HAL_Delay(1); HAL_SPI_TransmitReceive(&hspi1, tx, rx, len, 100); HAL_Delay(1); PS2_CS_HIGH(); }这里有两个小细节。CS拉低之后最好加1毫秒延时再开始发数据,让手柄接收器稳定响应。数据发完之后也别立刻拉高CS,稍微等一拍再释放片选。虽然这些延时看起来不严谨,但PS2手柄属于低速设备,CS时序稍微容错一点反而更稳,实测下来没有因为多这1毫秒影响任何性能,还省掉很多奇奇怪怪的偶发问题。
3.3 初始化命令与轮询读取实现
手柄初始化写成一个函数,依次完成进入配置模式和切换模拟模式。特别提醒一下,在发送0x44那条命令时,字节顺序必须精确,很多踩坑都是因为手动修改代码时把其中一个参数丢了。
uint8_t PS2_Init(void) { uint8_t tx[9] = {0}; uint8_t rx[9] = {0}; // 进入配置模式 tx[0] = 0x01; tx[1] = 0x43; tx[2] = 0x00; tx[3] = 0x01; tx[4] = 0x00; PS2_Cmd(tx, rx, 9); HAL_Delay(10); // 切换模拟模式 memset(tx, 0, sizeof(tx)); tx[0] = 0x01; tx[1] = 0x44; tx[2] = 0x00; tx[3] = 0x01; tx[4] = 0x03; PS2_Cmd(tx, rx, 9); HAL_Delay(10); return 0; }轮询读取函数也只做一件事,发送0x01 0x42开头的帧,然后从回包里提取有效数据。我习惯把返回的按键和摇杆数据直接存到一个结构体里,这样主循环里用起来非常顺手。
typedef struct { uint8_t select, l3, r3, start, up, right, down, left; uint8_t l2, r2, l1, r1, triangle, circle, cross, square; uint8_t r_x, r_y, l_x, l_y; } PS2_Data_t; uint8_t PS2_Read(PS2_Data_t *data) { uint8_t tx[9] = {0x01, 0x42, 0, 0, 0, 0, 0, 0, 0}; uint8_t rx[9] = {0}; PS2_Cmd(tx, rx, 9); >uint8_t filtered; static uint8_t lx_last; lx_last = (uint16_t)(lx_last * 7 + style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />