简介:STM32 PS/2解码工程基于STM32CubeMX配置工具与HAL库编写,面向需要解析PS/2键盘、鼠标通信数据的嵌入式工程师及电子爱好者。工程将PS/2协议的两线通信(数据线、时钟线)完整落地为可运行代码,涵盖GPIO输入模式设置、内部上拉电阻配置、时钟下降沿检测、数据位读取与奇偶校验等关键环节,可用于快速接入常见PS/2外设并提取按键扫描码或鼠标位移信息。压缩包共176个文件,以HAL驱动源码(.c/.h)、Keil工程文件(.uvprojx)、CubeMX配置文件(.ioc)以及编译生成的固件(.axf/.hex)为主,附带说明文档,整体约5.07MB,结构清晰,可直接导入开发环境编译调试。目前已有967人学习下载,适合具有一定单片机基础、希望深入理解串行同步协议或进行STM32项目实战的开发者。通过学习该工程,既能掌握CubeMX图形化初始化外设的流程,也能熟悉HAL库函数调用与中断处理逻辑,为后续实现其他通信协议提供可复用的解码思路。 做智能小车遥控的时候,我一直在蓝牙、2.4G模块和PS2手柄之间纠结。蓝牙要配对、延迟还看脸,2.4G遥控器要么贵要么手感稀烂,直到我从抽屉里翻出一个吃灰的PS2手柄——双摇杆加十来个按键,接收器插上就能用,几十块一套,皮实耐造,简直是给STM32项目量身定做的无线遥控方案。但问题也随之而来:STM32怎么把PS2手柄的按键和摇杆数据稳定地解出来?
我用手头的STM32F103C8T6把整套方案从零跑了一遍,基于CUBEMX生成工程、HAL库写驱动,解码代码全部调通,中间踩了不少坑。这篇就把协议原理、接线方式、CubeMX配置、HAL代码实现和排错经验一次讲清楚。不管你是做智能车、机械臂,还是毕设项目需要无线遥控,这篇都能给你一套能直接落地的参考方案。
1. 方案选型:为什么是PS2手柄 + CUBEMX + HAL库
1.1 遥控方案对比
做遥控项目,方案其实就那么几类:蓝牙、2.4G模块、PS2手柄、航模遥控器。实际用下来,差距非常明显。
| 方案 | 成本 | 延迟 | 摇杆/按键 | 开发量 | 稳定性 |
|---|---|---|---|---|---|
| 蓝牙模块 | 低 | 中等 | 需自研协议 | 大 | 一般,易受干扰 |
| 2.4G模块 | 中 | 低 | 需自研协议 | 很大 | 较好 |
| PS2手柄+接收器 | 低 | 很低 | 齐全,双摇杆 | 很小 | 很好 |
| 航模遥控器 | 高 | 低 | 齐全 | 大 | 好 |
蓝牙的问题在于配对和延迟,做遥控车还好,做机械臂或者需要实时响应的场景,蓝牙那点不确定性会让你很痛苦。2.4G模块需要自己定通信协议,从收发缓冲区到数据校验全得自己写,一个项目下来光协议就够喝一壶。
PS2手柄最香的地方在于:硬件层面是现成的SPI-like接口,软件层面协议公开且固定,STM32的HAL库本身就带SPI外设驱动,等于解码工作只剩“调通SPI + 解析数据帧”两步。加上手柄本身自带双模拟摇杆、方向键、Select/Start、L1/L2/R1/R2、L3/R3,十几个输入通道,做复杂控制完全够用。
1.2 CUBEMX + HAL库的开发流为什么省心
以前写STM32初始化,尤其是SPI这种外设,总是要对着一堆寄存器操作:CR1、CR2、SR,哪个寄存器配错了,波形就是不对。用CUBEMX之后,SPI的模式、时钟极性和相位、预分频值全部在图形界面里选好,生成代码直接能用。
HAL库的另一层好处是API抽象得清楚,SPI收发就几个函数:HAL_SPI_Transmit、HAL_SPI_Receive、HAL_SPI_TransmitReceive。PS2解码这种低速通信(时钟几百kHz),HAL库封装的效率完全不是瓶颈,省下来的开发时间却是实打实的。
还有一点就是可迁移性。CubeMX工程换芯片型号很方便,换个MCU重新生成一遍初始化代码,业务逻辑不用重写。对这个项目来说,用F103C8T6跑通之后,换到F407、H743也基本是几分钟的事。
2. PS2手柄解码协议核心原理解读
2.1 物理连接与引脚定义
PS2手柄接收器引出一组线,常规定义如下:
| 引脚 | 名称 | 作用 | 接STM32 |
|---|---|---|---|
| 1 | DATA | 手柄向主机返回数据 | MISO |
| 2 | CMD | 主机向手柄发送命令 | MOSI |
| 3 | GND | 地线 | GND |
| 4 | VCC | 供电,5V或3.3V | 3.3V或5V |
| 5 | CLK | 时钟 | SCK |
| 6 | ATT | 片选信号,低有效 | 任意GPIO(软件CS) |
| 7 | ACK | 应答信号 | 可不接 |
注意一点:不同来源的接收器线色并不统一,别只凭线的颜色接线,最好用万用表通断档对着针脚定义量一遍。我第一次就是凭颜色盲接,结果DATA和CMD搞反,读回来的数据全是0xFF。
电平方面,PS2手柄原生是5V逻辑,STM32F103大部分IO是5V容忍的,直接用问题不大。我实测用3.3V给手柄供电也能正常识别,但如果你用的MCU不是5V容忍引脚,建议加电平转换或者用3.3V供电,别硬怼。
ACK线很多教程都不接,我也没接,解码完全正常。它只是手柄对命令的一个应答脉冲,普通应用场景用不上。
2.2 时序与数据帧格式
PS2手柄的通信时序和SPI Mode 0高度相似:时钟空闲为低、在第一个边沿采样、8位数据、MSB先发。所以直接拿STM32的硬件SPI外设来做物理层通信,完全可行。
关键是ATT引脚(片选)的行为:整个命令序列传输期间,ATT必须一直保持低电平,发完一帧再把ATT拉高。千万不要做成每发一个字节就拉高一次ATT,那样手柄根本不认。这是很多人用硬件SPI解码失败的最常见原因。
标准的读取命令是发送下面这9个字节:
0x01 0x42 0x00 0x00 0x00 0x00 0x00 0x00 0x00其中0x01表示进入命令模式,0x42表示“读取手柄数据”,后面的7个字节是填充位。发送这9个字节的同时,SPI会从MISO线上收到手柄返回的9个字节,数据帧格式如下:
| 索引 | 内容 | 说明 |
|---|---|---|
| 0 | 0xFF | 握手/前导字节 |
| 1 | ID | 0x41=数字模式,0x73=模拟模式 |
| 2 | 0x5A | 数据就绪标志 |
| 3 | 按键高字节 | Select、L3、R3、Start、方向键 |
| 4 | 按键低字节 | L2、R2、L1、R1、三角、圆圈、叉、方块 |
| 5 | RX | 右摇杆X,0x00~0xFF |
| 6 | RY | 右摇杆Y |
| 7 | LX | 左摇杆X |
| 8 | LY | 左摇杆Y |
按键字节是低有效:对应位为0表示按下,为1表示松开。这一点非常容易搞反,后面调试时如果发现按键逻辑反了,先检查这里。
DualShock 2手柄默认上电是数字模式,也就是红灯不亮,这时候摇杆通道返回的数值通常固定在中间值附近,不会随摇杆变化。要想让摇杆生效,需要先发一帧配置命令切到模拟模式(红灯亮),我在代码部分会给出具体命令。
按键编码具体对应关系如下。
按键高字节(返回帧索引3):
| Bit | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|
| 按键 | 左 | 下 | 右 | 上 | Start | R3 | L3 | Select |
按键低字节(返回帧索引4):
| Bit | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|
| 按键 | 方块 | 叉 | 圆圈 | 三角 | R1 | L1 | R2 | L2 |
摇杆值范围是0~255,自然回中位置大概在128附近,不同手柄个体会有偏差,应用层最好做死区和中心校准,这个后面讲。
3. CUBEMX配置与HAL库代码实现
3.1 CubeMX里的关键配置
我用的是STM32F103C8T6,时钟树配到72MHz。CubeMX里按下面几步配置:
- 新建工程选芯片STM32F103C8T6。
- RCC里选Crystal/Ceramic Resonator,打开外部晶振。
- 需要调试的话,SYS里把Debug选成Serial Wire。
- 打开SPI1,Mode选Full-Duplex Master。
- SPI参数按下面配置:
- Clock Prescaler:先选128,得到562.5kHz的SCK,稳定优先
- CPOL:Low
- CPHA:1 Edge
- NSS:Disable
- Data Size:8 bits
- First Bit:MSB First
- 把PB12配置为GPIO_Output,命名CS,作为ATT片选脚。
- 需要看调试数据的话,再开一个USART1,115200-8-N-1。
这里有两个参数容易踩坑:
第一个是SPI时钟频率。PS2手柄手册建议时钟在250kHz左右,实际很多工程用500k~1MHz也能跑,但为了稳,我建议CubeMX生成后先看下工具提示的实际SCK频率,控制在1MHz以内。SPI1挂在APB2上(72MHz),Prescaler选128就是562.5kHz,非常稳。
第二个是CPHA千万别选错。PS2协议对应SPI Mode 0,也就是CPOL=Low、CPHA=1 Edge。选成2 Edge,数据采样时机就偏了,最常见的结果就是读回一堆0xFF。
3.2 解码代码架构与关键函数实现
CubeMX生成工程后,新建一个ps2.c/ps2.h,把解码逻辑放进去。整体分三块:数据结构、初始化配置、读取解析。
先定义手柄数据结构:
typedef struct { uint8_t id; // 手柄ID,0x41数字模式,0x73模拟模式 uint8_t status; // 0x5A表示数据有效 uint8_t btn_high; // 按键高字节 uint8_t btn_low; // 按键低字节 uint8_t rx; // 右摇杆X uint8_t ry; // 右摇杆Y uint8_t lx; // 左摇杆X uint8_t ly; // 左摇杆Y } ps2_data_t; static ps2_data_t ps2;片选控制直接宏定义,清晰一点:
#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)然后是读取函数,这是整个解码工程的核心:
int ps2_read(void) { uint8_t cmd[9] = {0x01, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t rsp[9] = {0}; PS2_CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, cmd, rsp, 9, 100); PS2_CS_HIGH(); // 前两个字节有时候是0xFF,不同手柄略有差异 if (rsp[0] != 0xFF || rsp[2] != 0x5A) { return -1; } ps2.id = rsp[1]; ps2.status = rsp[2]; ps2.btn_high = rsp[3]; ps2.btn_low = rsp[4]; ps2.rx = rsp[5]; ps2.ry = rsp[6]; ps2.lx = rsp[7]; ps2.ly = rsp[8]; return 0; }这里有个细节要说明:HAL_SPI_TransmitReceive是全双工,发送命令字节的同时就能收到返回字节,所以发送缓冲区和接收缓冲区可以共用。ATT片选必须在整帧传输期间保持低电平,调用前拉低、调用后拉高,不能用硬件NSS自动控制,否则发送过程中CS被SPI外设自己拉高,PS2手柄就会丢弃这帧命令。
初始化的时候发一帧配置命令,把手柄切到模拟模式,这样摇杆才有数据:
int ps2_init(void) { uint8_t cmd[9] = {0x01, 0x43, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t rsp[9] = {0}; PS2_CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, cmd, rsp, 9, 100); PS2_CS_HIGH(); HAL_Delay(10); return ps2_read(); }0x43是进入配置模式,0x01表示启用模拟模式。配置完成后,手柄红灯亮起,读回来的ID会变成0x73。有些手柄不执行这段配置也能在数字模式下用,但摇杆通道没有真实值,所以建议初始化时执行一次。
主循环里这样调用:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); MX_USART1_UART_Init(); HAL_Delay(500); // 等手柄接收器上电稳定 ps2_init(); while (1) { if (ps2_read() == 0) { printf("H:0x%02X L:0x%02X RX:%d RY:%d LX:%d LY:%d\r\n", ps2.btn_high, ps2.btn_low, ps2.rx, ps2.ry, ps2.lx, ps2.ly); } HAL_Delay(5); // 避免读太快导致手柄丢帧 } }第一次上电后手柄需要几百毫秒初始化,所以主循环前加一个500ms延时比较稳妥。每帧读取之间加5ms的延时,实测PS2手柄对连续读取间隔有要求,读太频繁会偶尔返回无效数据,这个延时加上之后数据就稳了。
如果你用串口打印,记得先做printf重定向,HAL库工程里重写fputc指向串口即可。
3.3 按键边沿检测与摇杆死区处理
拿到原始按键字节只是第一步,实际项目里通常需要的是“某次扫描中哪个按键被按下”的边沿信号,而不是电平信号。PS2按键是低有效,检测按下沿可以这样做:
uint8_t detect_press(uint8_t now, uint8_t *last) { uint8_t edge = (~now) & (*last); *last = now; return edge; }原理很简单:按键从松开(1)变成按下(0)时,~now对应位变成1,而last里那一位还是1,与运算出1,说明这个按键刚被按下。等下次扫描时last已经更新成0,就不会重复触发了。
摇杆数据也需要处理。原始值0~255,自然回中在128附近,但手柄复归后往往不是精确的128,会有一两个数的漂移。做电机控制时这点漂移就会让车缓慢转向。
int16_t left_x = (int16_t)ps2.lx - 128; int16_t left_y = (int16_t)ps2.ly - 128; if (left_x > -10 && left_x < 10) left_x = 0; if (left_y > -10 && left_y < 10) left_y = 0;死区范围我一般取10左右,具体看手柄个体差异。大的摇杆偏移可以直接映射成PWM占空比或者目标速度,控制效果非常丝滑。
4. 实测遇到的问题与排查方法
4.1 典型问题记录
把网上常遇到的问题和我自己踩过的坑汇总成一张速查表:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 读回数据全是0xFF | DATA和CMD接反;CS没拉低;SPI模式不对 | 核对接线;确认PB12为推挽输出;CPOL=Low、CPHA=1 Edge |
| 数据有变化但status不是0x5A | 硬件NSS干预了CS;读取频率太快 | NSS设为Disable,用软件GPIO控制CS;读取间隔加到5ms以上 |
| 按键逻辑反了 | 低有效处理错误 | 检测按下用(~btn) & bit_mask |
| 摇杆值只有中间值不变 | 没切模拟模式 | 上电执行0x43配置命令,确认ID为0x73 |
| 摇杆漂移严重 | 供电不足;电位器老化 | 改善供电;软件加死区和滑动滤波 |
| 第一帧读失败,后续正常 | 上电后手柄未就绪 | 主循环前加200-500ms延时 |
“数据全是0xFF”是出现频率最高的问题,我第一次调试时也卡在这。折腾半天发现是DATA和CMD接反了:PS2手柄的DATA是往主机发数据的,要接STM32的MISO,不是MOSI。接线确认无误后,再查SPI模式,基本就能解决。
还有个隐藏问题值得一提:CubeMX里SPI如果选了硬件NSS,传输过程中SPI外设会自动控制片选信号,这跟PS2手柄要求的ATT时序会冲突。解决办法就是NSS选Disable,片选完全用普通GPIO手动控制。
4.2 排查工具与调试技巧
遇到解码数据不对,最有效的排查方式是抓原始数据。
我强烈建议先别管按键映射,直接把返回的9个字节全部打印出来,看每个字节的变化规律。比如按下Select时,正常情况下索引3的bit0会从1变成0。如果按下按键时变化的位不符合预期,就要检查字节序或者SPI的MSB/LSB配置。
如果串口打印看不出问题,逻辑分析仪是更好的选择。抓一下ATT、CLK、CMD、DAT四根线,重点看两件事:
- ATT低电平期间,是否刚好有9个字节的时钟脉冲
- 时钟空闲是否为低、数据是否在SCK上升沿稳定
逻辑分析仪上如果能看到手柄在CLK下降沿驱动数据、主机在上升沿采样,那物理通信就是对的,问题多半出在SPI配置或者数据处理逻辑上。
没有逻辑分析仪的话,示波器也能看,只是效率低一些。最笨但最有效的方法是逐个验证:发0x42读命令前,先用万用表确认供电和地线,再确认CLK引脚有波形,最后确认DATA线上有数据返回。这三步走完,问题基本就能定位在软件层还是硬件层。
最后说几句实际心得
这个PS2解码工程我前后调了两天,卡在几个地方:一是DATA和CMD接反,二是硬件NSS干扰了片选时序,三是忘记切模拟模式导致摇杆没有数据。每个坑单独看都不难,但串在一起确实折腾人。
如果你按照这套流程走下来还是读不到数据,别急着换方案,先把原始数据帧完整打印出来,对照速查表一条条排查。PS2手柄解码本质上就是“物理层SPI通信 + 应用层协议解析”两件事,底层时序对了,解析就是几个字节的事。这个方案跑顺之后,后面做智能车、机械臂、云台遥控,都是直接复用的事。
本文还有配套的精品资源,点击获取