简介:一套基于STM32与PS2手柄通信的完整工程源码,适合正在学习HAL库外设驱动、CubeMX图形化配置或游戏控制器协议解析的嵌入式开发者,也适用于从寄存器编程转向库函数开发的入门到进阶人群。压缩包共970个文件,大小约22.2MB,主体为559个c源文件和243个h头文件,同时包含51个汇编文件、CubeMX配置所需的ioc文件、MDK工程文件及hex、axf等编译产物,c/h文件覆盖主程序、驱动函数与中间层接口,工程配置与链接脚本则完整保留生成环境与内存布局信息。资源已有5400余人学习下载。通过研读这套源码,读者可重点掌握PS2手柄串行协议的数据收发流程、GPIO与定时器在HAL库中的初始化方式,以及CubeMX工程中ioc配置与代码生成的联动关系,对独立完成基于STM32的手柄控制或类似外设驱动开发有直接的参考价值。
1. PS2手柄配STM32:为什么直接抄HAL库源码比从零写更稳
写PS2手柄驱动,最大的拦路虎往往不在通信协议本身,而在时序和初始化细节上。PS2手柄虽然用的是SPI-like协议,但它不是标准的SPI设备,时钟极性和相位、字节顺序、应答时序都有讲究,用STM32的硬件SPI去对接还好,一旦有人想用IO口模拟,踩坑概率直线上升。手头这份基于HAL库和CubeMX的源码包,就是针对这个痛点来的——它把底层SPI配置、手柄初始化序列、按键读取和模拟量解析都封装好了,拿到手改改引脚就能跑通,适合正在做遥控小车、机械臂、平衡车或者机器人课设的开发者。
这个源码不是单纯贴一堆寄存器操作,而是把CubeMX的.ioc配置文件和HAL库驱动代码一起给全了,意味着你不需要自己琢磨时钟树怎么分频、SPI复用引脚怎么映射,直接在CubeMX里导入配置就能复现。对于想快速验证手柄功能、又不想被底层通信细节卡住的人来说,这是一条捷径。
2. 硬件连接与通信机制:先搞清PS2手柄的脾气再动手
2.1 PS2手柄的SPI通信本质:它不按标准SPI出牌
PS2手柄接收器引出6根线:DAT、CMD、CS、CLK、VCC、GND。前三根加上CLK,本质上就是SPI的四根线,但它的时序偏要搞特殊。标准SPI有四种模式,PS2手柄实际用的是CPOL=1、CPHA=1,也就是SPI Mode 3,8位数据格式,MSB先行。关键在于,手柄在时钟下降沿锁存数据,上升沿输出数据,你要是用默认的Mode 0去读,读出来的数据全是乱的。
另一个特立独行的点是,PS2手柄的通信不是纯单向的。主机发送命令字节的同时,手柄会通过DAT线回传数据。也就是说,每次SPI收发都是同时完成的,一个时钟周期内,主机发一个bit,手柄回一个bit。这在HAL库的SPI收发接口里正好对应HAL_SPI_TransmitReceive(),而不是分开的发送和接收函数。很多从51或者Arduino转过来的开发者习惯先发后收,在这里就会卡住。
还有一个细节是字节间的间隔。PS2手柄对字节间的时序比较敏感,尤其是进入配置模式的时候,两次传输之间最好有微秒级的延时。我一般会在每次SPI传输后加一个delay_us(5),实测能显著降低通信失败率。当然,这个延时也不是越大越好,太大了会影响整体响应速度,模拟量读取频率会掉下来。
2.2 CubeMX里的SPI引脚映射与GPIO配置:直接照着设不会翻车
拿到源码先看.ioc文件。打开CubeMX,选择对应的STM32型号,导入.ioc后,SPI的引脚分配会自动完成。如果是从空白工程开始,注意以下几个关键配置。首先是SPI模式,选Full-Duplex Master,这是主机模式,时钟由STM32产生。然后是参数配置,时钟极性设为High,时钟相位设为Edge 2(对应CPOL=1、CPHA=1),NSS禁用软件管理,因为PS2的CS引脚是单独GPIO控制的,不走硬件片选。
CS引脚单独配置为GPIO输出,推挽模式,初始电平拉高。CLK、MOSI(对应CMD)、MISO(对应DAT)这三个引脚由CubeMX自动分配,注意查看它们在数据手册上的默认复用功能。我经手的项目里,有人选了不支持SPI复用的引脚,导致怎么调都不出数据,最后查原理图才发现引脚选错了。
GPIO速度设置也值得留意。SPI时钟频率我一般配置在250kHz到500kHz之间,PS2手柄的时序要求时钟周期不小于4微秒,也就是频率不高于250kHz,但实测500kHz也能跑,只是偶尔出错。稳妥起见,源码里默认是250kHz,这个参数直接影响读取稳定性。
3. HAL库驱动代码拆解:初始化、命令序列与数据解析逐段过
3.1 手柄初始化序列:0x01进入配置模式的玄机
PS2手柄上电后并不会直接进入可读取状态,需要主机发送一系列特定命令来配置它。源码里的初始化函数大概长这样:
void PS2_Init(void) { uint8_t cmd[9] = {0x01, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t data[9] = {0}; CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, cmd, data, 9, 100); HAL_Delay(10); CS_HIGH(); HAL_Delay(10); }逻辑说明:第一条命令0x01告诉手柄“我要进入配置模式”。9个字节是一轮完整的数据帧,前两个字节是命令和参数,后面七个字节是占位。发送完成后,data数组里收到的数据可以忽略,这一轮主要是握手。
参数说明:CS_LOW()拉低片选,表示一次通信开始;HAL_SPI_TransmitReceive()是同时收发,data数组用来接收手柄回传的数据;HAL_Delay(10)是为了让手柄有足够时间处理命令。注意这里用的是毫秒级延时,因为初始化阶段不追求速度。
接下来是把手柄设置为模拟模式。PS2手柄有两种工作模式:数字模式和模拟模式(红膜)。模拟模式才能读到摇杆的模拟量,做机器人控制基本必须开这个模式。源码里的切换逻辑:
void PS2_SetAnalogMode(void) { uint8_t cmd[9] = {0x01, 0x44, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t data[9] = {0}; CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, cmd, data, 9, 100); HAL_Delay(10); CS_HIGH(); HAL_Delay(10); }逻辑说明:第二条命令0x44是让手柄开启模拟模式。如果手柄是原装或者兼容性好的,发送这条命令后,手柄上的LED灯会从红灯变成红灯加绿灯,表示模拟模式生效。
参数说明:这里同样走的是9字节帧格式。有些兼容手柄需要先发送0x01再发送0x44,顺序不能反。源码里把这两步分开在两个函数里,是为了方便排查问题。
3.2 数据轮询读取:0x42命令与9字节数据帧的完整解读
配置完成后,每次读取手柄状态都要发送0x42命令。源码里读取函数会返回一个结构体,里面包含了所有按键状态和摇杆模拟值:
typedef struct { uint8_t btn_select; uint8_t btn_start; uint8_t btn_up; uint8_t btn_down; uint8_t btn_left; uint8_t btn_right; uint8_t btn_l1; uint8_t btn_r1; uint8_t btn_l2; uint8_t btn_r2; uint8_t btn_l3; uint8_t btn_r3; uint8_t btn_cross; uint8_t btn_square; uint8_t btn_circle; uint8_t btn_triangle; uint8_t joy_rx; uint8_t joy_ry; uint8_t joy_lx; uint8_t joy_ly; } PS2_Data_t; void PS2_ReadData(PS2_Data_t *ps2) { uint8_t cmd[9] = {0x01, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t data[9] = {0}; CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, cmd, data, 9, 100); CS_HIGH(); // 解析按键数据,data[4]和data[5]是按键状态 ps2->btn_select = (data[4] >> 0) & 0x01; ps2->btn_start = (data[4] >> 3) & 0x01; ps2->btn_up = (data[4] >> 4) & 0x01; ps2->btn_down = (data[4] >> 6) & 0x01; ps2->btn_left = (data[5] >> 6) & 0x01; ps2->btn_right = (data[5] >> 7) & 0x01; // 其他按键类似,具体位偏移看源码注释 // 摇杆模拟量,data[6]到data[9]是高四位 ps2->joy_rx = data[6]; ps2->joy_ry = data[7]; ps2->joy_lx = data[8]; ps2->joy_ly = data[9]; }逻辑说明:轮询时CMD发送0x42,手柄会根据先前配置好的模式回传数据。注意接收到的data数组,前两个字节是ID和状态,后面才是真正的按键和摇杆数据。按键数据是低电平有效,也就是对应位为0表示按下,为1表示松开,如果直接拿返回值去判断,逻辑会反。
参数说明:摇杆模拟量的范围是0到255,对应8位ADC。这意味着你不需要再做额外的归一化处理,直接映射到PWM占空比或者电机速度就可以。不过要注意,摇杆的零点往往不在128,实际静止时可能在125到131之间,工程上需要做死区处理。
3.3 按键防抖与组合键:为什么直接读原始值会误触发
PS2手柄的按键是机械触点,按下和释放的过程中会有抖动,持续时间大约5到10毫秒。如果没有防抖处理,在临界状态读取按键,可能在一帧内读到按下、下一帧读到释放,造成控制逻辑频繁切换。源码里我一般会加一个简单的软件防抖,用连续两次读取一致来确认状态:
uint8_t PS2_ReadButton(uint8_t (*read_fn)(void)) { uint8_t state1 = read_fn(); delay_ms(2); uint8_t state2 = read_fn(); if (state1 == state2) { return state1; } return 0xFF; // 不稳定状态 }参数说明:这里做两次读取,间隔2毫秒,相当于一个初级滤波器。对于要求更高的场景,可以换成定时器中断采样,或者用状态机做消抖。另外,某些兼容手柄的按键数据存在偶发跳变,连续读取三次取多数投票会更稳。
4. 从源码包到自己的工程:移植步骤与关键改动点
4.1 基于CubeMX的工程合并:.ioc文件的导入与引脚冲突检查
拿到源码包以后,最快的方式是直接基于它的.ioc工程改动。打开CubeMX,选择“Load Project”,找到.ioc文件,确认芯片型号和你手头的一致。如果芯片不一样,CubeMX会弹出迁移选项,可以自动重新分配引脚,但需要手动检查SPI引脚是否冲突。
移植的时候有几个地方特别容易忽略。第一是SPI外设的中断优先级,如果工程里还有其他中断,优先级配置不当会导致SPI通信被频繁打断。第二是系统时钟频率,HAL库函数里很多延时是基于HAL_GetTick()的,如果你把主频改了,延时参数也要相应调整。第三是引脚上拉电阻,PS2的DAT线和CLK线上建议开启内部上拉,因为手柄接收器的输出是开漏结构,不加下拉电阻读不到稳定的高电平。
从源码包复制ps2.c和ps2.h到自己工程的Src和Inc目录之后,需要在main.c里包含头文件,并声明一个PS2_Data_t全局变量。还有一点,ps2.c里用到的hspi1句柄是CubeMX生成的,如果你把SPI重命名成了hspi2,记得把源文件里的引用也改掉,不然编译器直接报未定义。
4.2 轮询频率的选择与时间片分配
PS2手柄的数据读取频率不是越高越好。手柄内部有处理周期,官方规格大约是每1毫秒更新一次状态。如果主机这边轮询太频繁,手柄来不及更新数据,读到的内容可能和上一帧一样,白白消耗CPU时间。我一般把读取函数放在主循环里,配合定时器做个10毫秒的调度,也就是大约100Hz的刷新率,轮询小车控制完全够用,而且给其他外设留出了时间。
在操作系统或者前后台架构的工程里,把PS2读取放到一个独立的任务里,用信号量唤醒,比在主循环里硬轮询更优雅。但要注意,SPI操作本身是阻塞式的,如果配置了SPI中断且优先级高于当前任务,回调里再触发PS2读取会有重入风险。
4.3 模拟量映射到电机控制:死区处理与反向修正
摇杆数据拿到手之后,直接用来控制电机,大概率会有一个问题:摇杆回中时,电机并没有完全停止,缓慢转动。原因就是前面说的零点偏移,加上PWM占空比有一个最小有效值。我的做法是把摇杆值减去128,得到一个正负偏差,然后加一个死区判断:
int16_t speed = (int16_t)ps2->joy_ly - 128; if (speed > -15 && speed < 15) { speed = 0; } else { speed = speed * 2; // 放大到-255到255范围 }参数说明:死区阈值15对应摇杆大约6%的行程,可以根据实际情况调整。放大系数乘以2是因为差值范围是-128到127,乘2后变成-256到254,方便映射到PWM的比较值。反向问题也值得注意,不同厂家的小车电机接线方向可能不同,代码里加一个MOTOR_DIRECTION宏定义,取反时改一个宏就行,别在控制函数里到处写负号。
5. PS2手柄HAL库移植避坑记录:五个高频翻车点
5.1 现象:第一次上电手柄无任何响应,LED不亮
原因:CS引脚配置成了复用功能,被SPI外设占用,导致片选信号根本没有拉低。解决:在CubeMX里把CS引脚从复用模式改回GPIO输出模式,手动控制电平。检查方法:用示波器或者万用表量CS引脚,通信时应该能观察到低电平脉冲。
5.2 现象:能读到数据,但按键状态完全反了
原因:PS2手柄按键是低电平有效,源码里的位判断默认返回0表示按下,但是部分兼容手柄的寄存器逻辑是反的,或者读取时字节顺序不对。解决:换一个已知正常的手柄测试,如果问题消失,说明手柄兼容性问题,把位运算结果取反即可。如果还是反的,检查字节解析顺序,data[4]和data[5]是否和源码注释一致。
5.3 现象:摇杆模拟量跳到255或0,像开关一样没有渐变
原因:SPI通信协议配置成了Mode 0(CPOL=0, CPHA=0),导致数据采样点偏了,高位数据丢失。另一种可能原因是CLK时钟频率过高,超过500kHz后部分手柄会出错。解决:在CubeMX里确认SPI配置是CPOL=High、CPHA=Edge 2,并把时钟分频系数调低,让CLK降到250kHz左右。
5.4 现象:正常通信几分钟后,突然读不到数据
原因:长时间通信后手柄进入了休眠模式或者自动关闭了模拟模式。部分兼容手柄有超时休眠机制,超过一定时间没有0x42命令,会自动切断输出。解决:在读取函数之前加一个状态检测,如果连续10帧数据ID不对,重新执行初始化序列。源码里提供了一个PS2_CheckConnection()函数,返回0表示正常,返回非0就重新调用PS2_Init()。
5.5 现象:两个手柄都能用,但其中一个的L3/R3按键无效
原因:L3/R3是摇杆按压,属于数字按键,但兼容手柄对这两个按键的处理和原装有差异,偶尔会把它们和摇杆模拟量混在一起。解决:如果项目里不需要摇杆按压功能,直接忽略这两个按键。如果必须要用,尝试在初始化命令里额外发送0x03命令设置按键模式,部分兼容手柄需要这个额外步骤。
6. 调通后的验证技巧:逻辑分析仪抓时序与按键映射自查表
把驱动跑通只是第一步,真正要确认手柄数据可靠,还得做一轮系统验证。我最常用的方法是把按键状态和摇杆值都映射到调试串口上,打印成字符串,一边按手柄一边看输出,比对零散数据和实际动作是否一致。但盲打容易漏掉瞬间的跳变,这时候逻辑分析仪才是靠谱的检查手段。
接上逻辑分析仪,触发条件设为CS下降沿,抓一轮完整的通信帧,能看到CLK、CMD、DAT三根线的时序关系。对照PS2协议手册,确认DAT线上回传的9个字节,每个bit的位置都对得上。重点检查字节间是否有异常的毛刺,毛刺通常意味着SPI时钟配置有隐患,只是当前频率下还没暴露。如果波形完全正常,那基本可以确认HAL库底层的SPI配置没有瑕疵。
另一个验证手段是做一个按键映射自查程序,把每一个按键的对应数据位输出成独立的变量,然后在调试器的Live Watch窗口里观察,逐个按键按下、松开,记录下变化。这样能一次性确认所有的位偏移都正确,尤其适合新手排查按键错位问题。模拟量那边可以通过摇杆画圆的方式,把四个通道的值打印出来看是不是连续渐变,如果有跳变,先检查死区设置,再检查电平转换电路。
从那以后我每次配新的PS2手柄工程,都会强制走一遍这个流程,先确认硬件接线,再导入.ioc核对SPI参数,然后调试串口打印按键,最后抓波形验证时序。这套组合拳打下来,虽然多花半小时,但能省掉后续无头绪的排查时间,希望帮到你。
本文还有配套的精品资源,点击获取