STM32+PS2手柄无线遥控实战:CUBEMX配置与HAL库解码
2026/9/8 7:11:36 网站建设 项目流程

简介: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_TransmitHAL_SPI_ReceiveHAL_SPI_TransmitReceive。PS2解码这种低速通信(时钟几百kHz),HAL库封装的效率完全不是瓶颈,省下来的开发时间却是实打实的。

还有一点就是可迁移性。CubeMX工程换芯片型号很方便,换个MCU重新生成一遍初始化代码,业务逻辑不用重写。对这个项目来说,用F103C8T6跑通之后,换到F407、H743也基本是几分钟的事。

2. PS2手柄解码协议核心原理解读

2.1 物理连接与引脚定义

PS2手柄接收器引出一组线,常规定义如下:

引脚名称作用接STM32
1DATA手柄向主机返回数据MISO
2CMD主机向手柄发送命令MOSI
3GND地线GND
4VCC供电,5V或3.3V3.3V或5V
5CLK时钟SCK
6ATT片选信号,低有效任意GPIO(软件CS)
7ACK应答信号可不接

注意一点:不同来源的接收器线色并不统一,别只凭线的颜色接线,最好用万用表通断档对着针脚定义量一遍。我第一次就是凭颜色盲接,结果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个字节,数据帧格式如下:

索引内容说明
00xFF握手/前导字节
1ID0x41=数字模式,0x73=模拟模式
20x5A数据就绪标志
3按键高字节Select、L3、R3、Start、方向键
4按键低字节L2、R2、L1、R1、三角、圆圈、叉、方块
5RX右摇杆X,0x00~0xFF
6RY右摇杆Y
7LX左摇杆X
8LY左摇杆Y

按键字节是低有效:对应位为0表示按下,为1表示松开。这一点非常容易搞反,后面调试时如果发现按键逻辑反了,先检查这里。

DualShock 2手柄默认上电是数字模式,也就是红灯不亮,这时候摇杆通道返回的数值通常固定在中间值附近,不会随摇杆变化。要想让摇杆生效,需要先发一帧配置命令切到模拟模式(红灯亮),我在代码部分会给出具体命令。

按键编码具体对应关系如下。

按键高字节(返回帧索引3):

Bit76543210
按键StartR3L3Select

按键低字节(返回帧索引4):

Bit76543210
按键方块圆圈三角R1L1R2L2

摇杆值范围是0~255,自然回中位置大概在128附近,不同手柄个体会有偏差,应用层最好做死区和中心校准,这个后面讲。

3. CUBEMX配置与HAL库代码实现

3.1 CubeMX里的关键配置

我用的是STM32F103C8T6,时钟树配到72MHz。CubeMX里按下面几步配置:

  1. 新建工程选芯片STM32F103C8T6。
  2. RCC里选Crystal/Ceramic Resonator,打开外部晶振。
  3. 需要调试的话,SYS里把Debug选成Serial Wire。
  4. 打开SPI1,Mode选Full-Duplex Master。
  5. SPI参数按下面配置:
    • Clock Prescaler:先选128,得到562.5kHz的SCK,稳定优先
    • CPOL:Low
    • CPHA:1 Edge
    • NSS:Disable
    • Data Size:8 bits
    • First Bit:MSB First
  6. 把PB12配置为GPIO_Output,命名CS,作为ATT片选脚。
  7. 需要看调试数据的话,再开一个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 典型问题记录

把网上常遇到的问题和我自己踩过的坑汇总成一张速查表:

现象可能原因解决办法
读回数据全是0xFFDATA和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通信 + 应用层协议解析”两件事,底层时序对了,解析就是几个字节的事。这个方案跑顺之后,后面做智能车、机械臂、云台遥控,都是直接复用的事。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询