
简介面向嵌入式初学者的STM32手柄控制工程整合PS2手柄通信驱动与HAL库实现。借助CubeMX图形化配置底层引脚和时钟分配一目了然驱动层提供初始化、按键读取和中断处理函数可直接迁移到实际项目中。资源包共970个文件压缩后22.2MB以559个C源文件、243个头文件为主体另含汇编、链接脚本、库文件以及MDK/IAR工程配置文件整体覆盖从编译、链接到调试下载的完整链路。目录按User、Inc、Src等模块组织主程序、板级支持与驱动代码分离适合对照学习PS2时序协议、GPIO操作、定时器应用和HAL库API调用。工程关键代码包含ps2_init、ps2_read_data和ps2_isr并在CubeMX配置中保留外设初始化细节烧录后即可验证手柄输入。目前已有5409人学习适用于毕业设计、电子竞赛及嵌入式入门实战。 做小车、做机械臂、做遥控云台想用手柄遥控又不想在底层寄存器里耗尽耐心用STM32F103C8T6配合HAL库在CubeMX里配好SPI再移植一套PS2手柄驱动源码是目前最省事的路线。PS2手柄便宜、按键摇杆齐全通信又是标准的同步时序和STM32的SPI外设天然对得上所以这篇就把我从CubeMX建工程到跑通手柄的完整思路、核心代码和踩坑记录全部拆开讲。这篇文章适合两类人一类是刚开始玩STM32想给项目加个遥控输入设备的新手另一类是被PS2手柄时序和HAL库收发坑过想找个稳定参考方案的开发者。读完你至少能明白三件事CubeMX里SPI到底该怎么配、HAL库驱动PS2的代码逻辑是什么、摇杆数据为什么要滤波和校准——后面这点我还会拿磁编码器的方案做对照原理是通用的。1. 整体思路与选型分析1.1 为什么是HAL库为什么选SPI先说选型。STM32F103C8T6这颗芯片虽然老但胜在便宜、资料多、性能足够。PS2手柄的通信协议本质上是一根时钟线加一根双向数据线的同步串行协议发送命令的同时要接收返回数据这一看就是SPI全双工主模式能干的事。用GPIO模拟当然也能做但要多写不少位翻转代码还要自己卡延时远不如直接拿SPI外设省心。HAL库和标准外设库比最大的优势是CubeMX能可视化配置时钟树、引脚复用和SPI参数生成工程后代码结构清晰。真遇到通信问题HAL库把每一次收发都封装成HAL_SPI_TransmitReceive调试时可以单步看返回值对新手友好很多。代价是HAL库的函数调用层次深、效率不如寄存器操作高但PS2手柄的数据率本来就不高HAL库这点开销完全可以忽略。1.2 通信协议拆解一条命令一帧数据PS2手柄的协议说简单也简单主机STM32通过拉低ATT引脚来使能手柄然后在CLK时钟下逐字节发送命令每发一个字节的同时手柄会从DAT引脚回传一个字节。注意这里不是像普通SPI那样一次读写一大块而是“发一个命令字节收一个状态字节”命令与命令之间必须留出短延时手柄内部单片机需要时间处理。所以整个数据帧的形态是ATT拉低 - 发送一串命令字节并同步接收返回数据 - ATT拉高。比如进入模拟模式发送的序列是0x01 0x44 0x00 0x01 0x03 0x00 0x00 0x00 0x00读取按键和摇杆发送0x01 0x42开头的一串查询命令。返回的数据里第二字节固定是0x5A后面跟着按键状态和摇杆模拟量。理解了这条主从时序后面写代码就是顺水推舟的事。2. CubeMX初始化配置实战2.1 SPI1引脚与CubeMX参数设置用CubeMX创建STM32F103C8T6工程时我习惯把手柄接到SPI1这样PA5、PA6、PA7刚好是SCK、MISO、MOSI再拿一个普通GPIO做ATT片选。注意PS2接收器上DAT是回传数据必须接SPI的MISOCMD是主机命令输出必须接MOSI这两个接反是最常见的低级错误。SPI参数建议这样配配置项值说明ModeFull-Duplex Master发命令的同时收返回数据Hardware NSSDisable用PA4普通GPIO控制ATTData Size8 BitsPS2协议是字节单位First BitMSB First协议要求高位先出Prescaler25672MHz主频分频后约281kHzCPOLHigh空闲时钟为高电平CPHA2 Edge第二个边沿采样数据Prescaler这里要重点说。PS2手柄的时钟上限不高很多人上来照着默认的4分频或8分频配结果CLK跑到几MHz手柄直接不响应读回来的全是0xFF。F103的SPI1挂在APB2总线上72MHz主频256分频后是281.25kHz这是比较稳的速率。128分频的562kHz我也试过大部分手柄能工作但线稍微长一点或者接收器供电不稳就开始丢帧了。ATT引脚我通常选PA4CubeMX里把它设为GPIO_Output默认输出高电平。因为PS2手柄的片选是低电平有效平时不通信时ATT必须保持高这个初始电平一定要在CubeMX里设置好否则上电瞬间手柄可能进入异常状态。2.2 SPI通信细节与延时设置CubeMX生成的HAL代码里SPI初始化会被自动处理好但有几个细节必须手写补充。第一ATT拉低后不要立刻开始发命令建议先延时10微秒左右给手柄一个准备时间。第二每条命令发送后也要延时我实测10到20微秒是稳定区间太短容易丢数据太长会拖慢整个读取周期。提示PS2通信的延时不是越短越好。手柄内部单片机处理一条命令需要时间连续灌命令进去它会“反应不过来”。我调试时遇到过一种现象单条命令回读正常连续发两次就偶尔返回错位数据后来才发现是命令间隔太短。如果你用HAL_SPI_TransmitReceive收发单字节函数内部有超时机制但那个超时是针对SPI硬件忙状态的不管命令间隔。因此我建议在整个数据帧发送循环里显式加delay_us(10)不要偷懒。2.3 手柄初始化序列进入模拟模式PS2手柄上电后可能处于数字模式只有十字键和部分按键可用摇杆输出也不完整所以要先发送初始化序列让它进入模拟模式。初始化序列我之前写死成数组uint8_t config_cmd[9] {0x01, 0x43, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t analog_cmd[9] {0x01, 0x44, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00}; uint8_t exit_config_cmd[9] {0x01, 0x43, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};先发送config_cmd进入配置模式再发送analog_cmd开启模拟模式最后发送exit_config_cmd退出配置。整个初始化过程就是把这几个序列分别作为一帧数据发出每帧之间保持ATT为高电平状态。初始化完成后后续查询命令返回的ID就会从0x41变成0x73看到0x73就说明手柄已经在模拟模式了。3. HAL库驱动代码实现3.1 底层收发函数封装有了CubeMX生成的SPI句柄底层收发就很简单。我封装了一个发送一帧数据的函数核心逻辑就是拉低ATT、逐字节收发、中间加延时、最后拉高ATTvoid PS2_SendFrame(uint8_t *cmd, uint8_t *rx, uint8_t len) { HAL_GPIO_WritePin(ATT_GPIO_Port, ATT_Pin, GPIO_PIN_RESET); delay_us(10); for (uint8_t i 0; i len; i) { HAL_SPI_TransmitReceive(hspi1, cmd[i], rx[i], 1, 10); delay_us(10); } HAL_GPIO_WritePin(ATT_GPIO_Port, ATT_Pin, GPIO_PIN_SET); }这个函数是整篇代码的地基。delay_us(10)我建议用DWT或者定时器实现单纯嵌套循环的空转延时在优化级别不同时误差很大。CubeMX工程里如果开了HAL_Delay它基于SysTick最小单位是1毫秒太粗了不适合这里的微秒级延时。注意HAL_SPI_TransmitReceive每次调用都有实时性要求如果延时函数本身被中断频繁打断通信时序会有抖动。我在实际项目里是开了个1毫秒的时基再单独用DWT做微秒延时这样两个延时互不干扰。3.2 读取按键与摇杆数据帧读取手柄状态的查询帧固定发9个字节第一个字节是0x01第二个是0x42后面七个补0x00。返回的9个字节里rx[0]是命令回显rx[1]是手柄ID模拟模式下应为0x73rx[2]和rx[3]是按键状态rx[4]到rx[7]是四个摇杆模拟量顺序是右摇杆X、右摇杆Y、左摇杆X、左摇杆Y。按键状态是低电平有效也就是对应位为0表示按下。具体定义如下位rx[2]rx[3]Bit0SELECTL2Bit1L3R2Bit2R3L1Bit3STARTR1Bit4UP三角Bit5RIGHT圆圈Bit6DOWN叉Bit7LEFT方块判断按键时不能直接比较整个字节要用位与运算。比如判断圆形按键是否按下就检查(rx[3] 0x20) 0。我习惯先把按键状态定义成宏可读性会好很多#define PS2_PRESSED 0 #define PS2_KEY_SELECT (rx[2] 0x01) #define PS2_KEY_START (rx[2] 0x08) #define PS2_KEY_UP (rx[2] 0x10) #define PS2_KEY_RIGHT (rx[2] 0x20) #define PS2_KEY_DOWN (rx[2] 0x40) #define PS2_KEY_LEFT (rx[2] 0x80) #define PS2_KEY_TRIANGLE (rx[3] 0x10) #define PS2_KEY_CIRCLE (rx[3] 0x20) #define PS2_KEY_CROSS (rx[3] 0x40) #define PS2_KEY_SQUARE (rx[3] 0x80)整帧读取函数可以这样写void PS2_ReadData(void) { uint8_t cmd[9] {0x01, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t rx[9] {0}; PS2_SendFrame(cmd, rx, 9); if (rx[1] 0x73) // 模拟模式 { ps2_keys[0] rx[2]; ps2_keys[1] rx[3]; ps2_analog[0] rx[4]; // RX ps2_analog[1] rx[5]; // RY ps2_analog[2] rx[6]; // LX ps2_analog[3] rx[7]; // LY } }3.3 摇杆数据换算与方向映射摇杆模拟量是0到255静止时理论上在128附近。使用前要做一次简单换算把原始值变成有符号数方便控制逻辑使用int16_t ps2_joy_x (int16_t)ps2_analog[2] - 128; // LX int16_t ps2_joy_y (int16_t)ps2_analog[3] - 128; // LY这样摇杆居中时输出接近0前后推是正负范围。但注意不同手柄的几何中心和电位器误差会导致静止值不是正好128而且用久了电位器磨损中心点还会漂移。所以严格一点的做法是要做校准这个我在第4节单独讲。如果你的项目是控制电机速度通常还要把有符号数再映射到PWM占空比范围比如-100到100。这一步放在业务逻辑里做驱动层只负责把数据读出来、解析好、存进结构体职责拆分清楚后面做闭环或者改控制算法都方便。4. 摇杆滤波与校准实战4.1 噪声来源与死区处理摇杆数据读回来直接就能用吗能用但手感会很差。原因有两个一是摇杆电位器本身输出有微小抖动你手不动它数据也可能在中心值附近跳几个单位二是如果接收器供电纹波大或者SPI线缆受到电机干扰数据毛刺会更明显。这不只是PS2手柄的问题凡是读取模拟量传感器都会遇到我后来用HAL库模拟IIC读MT6701磁编码器角度数据同样有这个问题。最直观的解决办法是设置死区。摇杆微小偏移时把它当作零处理#define JOY_DEAD_ZONE 8 int16_t ApplyDeadZone(int16_t val) { if (val -JOY_DEAD_ZONE val JOY_DEAD_ZONE) return 0; return val; }死区值别设太大8到12就差不多了。设太大会导致摇杆在小幅度操控时没有反应玩精细控制时会觉得“手跟不上”。4.2 滑动平均滤波代码死区只能解决静止时的小抖动如果摇杆快速运动时偶尔冒出一个尖峰毛刺就需要做滤波。嵌入式里最常用的是滑动平均滤波原理是维护一个固定长度的窗口每来一个新数据就丢一个最旧的数据取窗口平均值#define FILTER_WINDOW 8 uint8_t filter_buf[FILTER_WINDOW] {0}; uint8_t filter_index 0; uint32_t filter_sum 0; uint8_t MovingAverageFilter(uint8_t new_val) { filter_sum - filter_buf[filter_index]; filter_buf[filter_index] new_val; filter_sum new_val; filter_index (filter_index 1) % FILTER_WINDOW; return (uint8_t)(filter_sum / FILTER_WINDOW); }窗口长度选8因为8是2的幂次除法可以用移位替代效率更高。窗口太长虽然更平滑但滞后严重摇杆推过去之后输出要慢半拍这对遥控项目是致命的。如果只做滤波、不做控制窗口可以适当加长到16。注意滑动平均对周期性噪声抑制效果好对突发性尖峰毛刺效果一般。如果发现数据偶尔跳变特别夸张建议在平均之前加一个“限幅”判断新数据与上一次有效值差超过阈值就直接丢弃或按阈值截断这是很多工程里实用的组合拳。4.3 中心点校准与量程映射滤波解决的是噪声解决不了偏差。手柄静止时摇杆读数不是128这是电位器物理误差和模数转换参考电压共同决定的。我在项目里做过一个简单的开机校准流程手柄静止时连续读取64次摇杆数据取平均值作为中心点存到全局变量里uint8_t joy_center[4] {128, 128, 128, 128}; void JoystickCalibrate(void) { uint32_t sum[4] {0, 0, 0, 0}; for (uint8_t i 0; i 64; i) { PS2_ReadData(); sum[0] ps2_analog[0]; sum[1] ps2_analog[1]; sum[2] ps2_analog[2]; sum[3] ps2_analog[3]; HAL_Delay(2); } for (uint8_t i 0; i 4; i) joy_center[i] (uint8_t)(sum[i] / 64); }之后使用时每个摇杆通道先减去中心点再乘一个映射系数折算到目标工程值。这样做的好处是即使手柄老化或者换了新手柄只需重新校准一次不需要改代码。顺带说一句我在用HAL库模拟IIC读取MT6701磁编码器时也用到了完全相同的校准思路。磁编码器读取的是机械角度安装时零位不可能完全对齐所以上电后先读一次当前角度存为偏移量之后每次读数都减去这个偏移再做角度卷绕处理。本质上和摇杆校准是同一件事先测偏置再补偿偏置。4.4 MT6701磁编码器的滤波校准方案对照如果把PS2摇杆换成MT6701磁编码器滤波和校准的套路可以原样迁移。MT6701通过IIC返回14位角度数据直接换算成0到360度的角度值。实际使用中我做了三步处理第一步上电静止时连续读32次角度取平均得到零位偏移。第二步运行时对角度增量做滑动平均或者一阶低通滤波抑制电机振动带来的角度抖动。第三步根据应用需求设置滞回区间防止角度在阈值边界来回跳变触发误动作。这三步和PS2摇杆的死区、滑动平均、中心校准一一对应可以说不管输入源是电位器还是磁编码器只要你读的是模拟量或类模拟量滤波加校准这套组合就是绕不开的基本功。5. 常见问题与排查技巧实录5.1 SPI通信失败读回全0xFF新手遇到最多的问题就是读回的数据全是0xFF或者所有字节都一样。排查顺序我建议这样来先确认接线DAT有没有接到SPI1的MISOCMD有没有接到SPI1的MOSI这两个接反必挂再确认共地手柄接收器和单片机板子必须共地否则信号根本没有参考电平最后检查SPI参数CPOL是不是High、CPHA是不是2 Edge分频是不是没有降下来。还有一个容易忽略的点就是ATT引脚初始电平。如果ATT悬空或者默认为低手柄会认为主机一直在通信导致后续所有时序都错乱。CubeMX里一定要把ATT配置为GPIO_Output并设置初始输出高。5.2 按键/摇杆数据异常返回值错位或偶发跳变初始化执行完了手柄ID也变成了0x73但按键读出来偶尔错乱比如按住十字键却触发圆圈键。这种情况大概率是命令间隔延时不够。把PS2_SendFrame里的delay_us(10)加大到20微秒再试一下。如果还是偶发错乱就要怀疑SPI线的干扰。PS2接收器的线材质量参差不齐而且电机、舵机这些感性负载一启动瞬间电流会把地电平抬起来。处理办法是给接收器供电加一个100到470微法的电解电容并放在靠近接收器插座的位置。我实测过这个电容对消除数据毛刺立竿见影。5.3 硬件连接与供电避坑PS2接收器的引脚定义容易看错不同厂家的丝印还不完全一样。常见的是1脚DAT、2脚CMD、3脚电源很多无振动版可以悬空、4脚GND、5脚VCC、6脚ATT、7脚CLK但务必以手边接收器说明书或者丝印为准。供电方面接收器最低支持3.3V但为了稳定性我一般接5V。问题是STM32F103C8T6的IO并非所有引脚都支持5V容忍如果你用的引脚是普通TTL电平引脚直接把5V信号灌进MCU有风险。稳妥做法是在DAT、CMD、CLK三条信号线上串联330欧姆左右的分压电阻或者确认你用的引脚是数据手册标注的5V容忍FT引脚。提示手柄接收器的信号线和单片机之间最好尽量短线长超过20厘米就比较容易出时序问题。如果项目必须把接收器放远优先降低SPI时钟到100kHz以下并加上第4节说的滤波能明显改善稳定性。踩过几次坑之后我现在做手柄通信项目的固定流程是CubeMX里先把SPI和ATT配好用0x42查询帧确认能读到0x5A再进模拟模式确认ID变成0x73最后才写按键映射和摇杆滤波。这套流程从零开始跑顺利的话一个下午就能全部搞定。等到实际装进小车或机械臂再根据现场供电和线长微调SPI速率、滤波窗口和死区大小——这三个参数从来不是照抄别人的是靠示波器和手感一起调出来的。本文还有配套的精品资源点击获取