简介STM32F103C8T6与PS2手柄交互的完整工程代码面向嵌入式初学者及需要实现手柄控制的开发者可解决PS2协议解析、SPI接口配置与按键摇杆数据读取等常见问题。资源基于STM32CubeMX构建完整展示了PS2异步串行通信的时序实现包括时钟线与数据线双向通信、8位数据加奇偶校验的帧格式同时覆盖SPI1/SPI2总线选择、时钟频率设置、GPIO引脚映射、主从模式切换以及中断和DMA两种数据接收方式帮助理解如何实时获取并解码手柄状态。代码内部还包含错误检测与重试机制可应对噪声或硬件问题导致的通信异常固件按初始化函数、中断服务程序与主循环清晰分层方便二次开发。压缩包共154个文件以48个.h头文件、21个.c源文件、21个.crf及编译生成的.o/.axf/.hex等为主另附.uvprojx工程文件和.ioc配置可直接在Keil或STM32CubeIDE中打开使用整个资源包仅6.15MB目录结构清晰便于按模块学习。目前已有955人学习下载适合希望系统掌握STM32 SPI通信及PS2手柄编程的读者参考研究。1. 用一块蓝板读PS2手柄别把时间花在时序玄学上先说结论这份 stm32f103c8t6 手柄 PS2 代码解决的不是“怎么让手柄通电”而是“怎么在 STM32F103C8T6 上稳定读出手柄按键和摇杆值”。如果你做过遥控小车或机械臂大概率踩过这类坑手柄明明没坏串口打出来的却全是 0xFF要么按键抖动得像接触不良最后发现是 CLK 时序不对。这份代码把 PS2 手柄读取封装成几个独立函数改一下引脚就能用。适合两类人一类是想快速把手柄接到自己板子上做原型验证的开发者另一类是点灯已经熟练、想搞清楚同步串行通信怎么配合 GPIO 实现的入门读者。读完这篇你能自己把无级调速小车、舵机云台这类外设控制逻辑接上去。2. 硬件与协议基础四根信号线一张接线表2.1 先搞懂 PS2 手柄的四线接口PS2 手柄虽然是一根六芯插座但真正参与通信的只有四根DATA、COMMAND、CLK、GND第五根 VCC 给手柄供电第六根 9V 是给振动马达用的做机器人底盘时通常不接。从主机的角度看DATA 是手柄返回数据的线输入COMMAND 是主机往下发命令的线输出CLK 由主机产生GND 共地。这个接口本质上是一种单向时钟、双向数据的同步串行协议和 SPI 很像但又不完全是标准 SPI。主机在拉低 CS 片选后每一个 CLK 上升沿手柄会从 DATA 线上返回 1 个 bit同时主机从 COMMAND 线上发送 1 个 bit。也就是说命令发送和数据读取是同一个时钟沿完成的和“先发完命令再读数据”的理解完全不一样。我早年就在这里翻过车写代码时先把整个命令发完再拉时钟去读结果读回来的永远是废数据。这里有一个反直觉的点协议是高位先出而且按键数据是“反逻辑”按下对应的位是 0松开是 1和平时读按键的“高电平有效”相反。所以解析 PS2 数据的核心不是读寄存器而是做位取反。2.2 引脚分配与接线表这套代码默认用 PA4~PA7刚好是 SPI1 的引脚位置既方便 GPIO 模拟也方便以后想切成硬件 SPI 时不用换线。引脚分配表如下手柄信号方向相对主机STM32 引脚GPIO 模式COMMANDDO主机输出PA7推挽输出DATADI主机输入PA6浮空输入带上拉CLK主机输出PA5推挽输出CS主机输出PA4推挽输出VCC供电3.3V-GND地GND-选 PA4~PA7 还有一个实际原因避开 PA13、PA14、PA15、PB3、PB4 这几个默认被调试接口占用的引脚。很多人把手柄接到 PB3 上发现怎么都点不亮不是代码问题是引脚默认被占用需要先重映射。所以新开板子建议直接照这套默认引脚接线省得开局就踩坑。需要注意的是手柄 DATA 线是开漏输出单片机内部上拉大约是 40kΩ在长线和大电流情况下容易把波形拉歪。我一般在板上再外挂一个 10kΩ 上拉到 3.3V确保高电平干净。尤其当你用面包板加十几厘米杜邦线时这一步能少很多事。2.3 为什么我用 GPIO 模拟而不是硬件 SPI理论上 STM32F103C8T6 的 SPI1 可以直接拿来跑 PS2 协议但实际用过的开发者都知道这里面有个坑PS2 手柄对时钟频率要求比较低而硬件 SPI 在标准配置下分频最低也有 2MHz手柄在几百 kHz 时才最稳定。更重要的是PS2 的传输方向判断依赖 CS 时序和命令字节硬件 SPI 在双向收发模式下把你发的内容和收到的内容混在一起解析起来非常别扭。GPIO 模拟的优势是把每个时钟沿都暴露出来想降频就降频想观察就用示波器量出问题好排查。至于性能19 字节的数据帧也就一百多次时钟翻转对 72MHz 的 F103 来说连零头都算不上完全不存在瓶颈。我一般的做法是GPIO 模拟做底层字节收发函数上层按键解析做成独立模块以后切别的单片机只改 PS2_Command 一个函数。3. 核心代码拆解从初始化到按键值的完整链路3.1 GPIO 配置与引脚宏定义先看底层配置。代码默认跑在标准外设库上如果你用 HAL 库把 GPIO_Init 换成 MX_GPIO_Init 同样适用。#define PS2_CS_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_4) #define PS2_CS_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_4) #define PS2_CLK_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_5) #define PS2_CLK_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_5) #define PS2_DO_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_7) #define PS2_DO_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_7) #define PS2_DI_READ() GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6) void PS2_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* PA4、PA5、PA7 都是主机输出50MHz 档位 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); /* PA6 是主机输入内部上拉复位默认高电平 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); /* 初始状态CS 高、CLK 高总线空闲 */ PS2_CS_HIGH(); PS2_CLK_HIGH(); }这段配置里有一个容易忽略的点初始化之后必须先把 CS 拉高、CLK 拉高。因为 PS2 手柄在开机后如果看到 CS 一直是低会认为总线忙进入异常状态后面再发命令也不响应。养成“先拉高再使用”的习惯能少踩一个很隐蔽的坑。GPIO_Speed 选 50MHz 不是因为要高速翻转而是让信号沿更陡。PS2 的时钟本身只有几百 kHz沿太缓会让手柄在临界电平上误判。输入模式用 IPU 而不是浮空输入是因为手柄 DATA 线是开漏输出内部上拉能保证没有数据传输时读到稳定的高电平。3.2 PS2_Command每个时钟沿同时收发一位整个 PS2 驱动的核心就是这一个函数其它都是围绕它在转。它的职责是在 CS 有效期间发送一个字节同时接收一个字节。uint8_t PS2_Command(uint8_t command) { uint8_t i; uint8_t response 0; PS2_CLK_LOW(); PS2_CS_LOW(); for (i 0; i 8; i) { PS2_CLK_LOW(); if (command 0x80) PS2_DO_HIGH(); else PS2_DO_LOW(); delay_us(2); PS2_CLK_HIGH(); delay_us(2); response 1; if (PS2_DI_READ()) response | 0x01; command 1; } PS2_CS_HIGH(); return response; }这段代码的时序逻辑是先把 CLK 拉低然后在低电平期间把要发的位放到 DO 线上等电平稳定后拉高 CLK手柄在上升沿锁存数据。也就是说在同一个上升沿主机发出的 bit 被手柄拿走手柄返回的 bit 也被主机读到。理解了这个“同时收发”再看 PS2 协议就不会觉得别扭。后面那个 response 的移位和 PS2_DI_READ()是实现同时收发的关键。它把读到的位放到 response 的最低位再通过循环里的左移逐步拼成一个完整字节。用示波器量的话波形上 DO 和 DATA 的变化正好发生在 CLK 的低电平段这是判断代码是否正确最直观的方式。delay_us(2) 对应约 250kHz 的时钟频率实测对原装和副厂手柄都友好。如果改成 delay_us(0)时钟频率冲到几百 kHz 以上按键会出现莫名串键。另外注意整个循环里 response 不需要额外清零因为 response 1 已经把旧位推走了。提示delay_us 建议用定时器或者 DWT 实现不要用普通空循环因为编译优化等级不同空循环延时会差好几倍。3.3 PS2_ReadData0x42 命令与 19 字节数据帧有了字节收发函数读手柄就变成了一个简单的协议流程。PS2 手柄上电默认是红灯模拟模式读摇杆前必须发 0x01 进入配置模式再发 0x42 读取数据。void PS2_ReadData(uint8_t *buf) { uint8_t i; /* 进入配置模式唤醒手柄 */ PS2_Command(0x01); delay_ms(2); /* 发送数据读取命令 */ PS2_Command(0x42); /* 连续读 19 个字节 */ for (i 0; i 19; i) { buf[i] PS2_Command(0x00); } }这里有一个很多初学者会卡住的问题为什么读数据的时候又要发 0x00因为 PS2 是“一发一收”主机想拿下一个字节就必须为这个字节提供时钟发 0x00 纯粹是为了凑满时钟周期内容是什么无所谓真正有意义的返回值。19 字节的数据帧结构大致如下字节位置含义0~1握手字节正常都是 0x5A2~3按键状态按下为 04~5左摇杆 X、Y6~7右摇杆 X、Y8~17压力感应等扩展数据18校验字节部分手柄为 0x00所以这个流程要做的校验很简单如果 buf[0] 和 buf[1] 不是 0x5A这次读到的数据直接丢弃不要拿去控制电机。我实际测试过部分副厂手柄返回帧是 18 字节而不是 19如果读出来的数据错位试着把 19 改成 18再对照按键表看 buf[2] 是否合理就能知道帧长度对不对。3.4 按键位映射与摇杆解析按键数据的位映射是整个项目里最绕的部分。PS2 手柄按键按下时对应位是 0松开是 1所以直接 if (buf[2] 0x10) 判断 up 是不可靠的必须先按位取反。typedef struct { uint8_t select; uint8_t l3; uint8_t r3; uint8_t start; uint8_t up; uint8_t right; uint8_t down; uint8_t left; uint8_t l2; uint8_t r2; uint8_t l1; uint8_t r1; uint8_t triangle; uint8_t circle; uint8_t cross; uint8_t square; uint8_t lx; uint8_t ly; uint8_t rx; uint8_t ry; } PS2_Data; PS2_Data PS2_Analyze(uint8_t *buf) { PS2_Data d; d.select !(buf[2] 0x01); d.l3 !(buf[2] 0x02); d.r3 !(buf[2] 0x04); d.start !(buf[2] 0x08); d.up !(buf[2] 0x10); d.right !(buf[2] 0x20); d.down !(buf[2] 0x40); d.left !(buf[2] 0x80); d.l2 !(buf[3] 0x01); d.r2 !(buf[3] 0x02); d.l1 !(buf[3] 0x04); d.r1 !(buf[3] 0x08); d.triangle !(buf[3] 0x10); d.circle !(buf[3] 0x20); d.cross !(buf[3] 0x40); d.square !(buf[3] 0x80); d.lx buf[4]; d.ly buf[5]; d.rx buf[6]; d.ry buf[7]; return d; }buf[2] 对应十字键和 SELECT、L3、R3、STARTbuf[3] 对应 L1、L2、R1、R2 和四色键。十字键和四色键在物理位置上是分开的但在数据帧里恰好分成两个字节记这个规律比背十六进制省事。所有 !(x mask) 的操作等价于“该位为 1 则松开为 0 则按下”。摇杆数值需要单独强调lx、ly、rx、ry 的范围是 0x00 到 0xFF静止时大约在 0x7F 到 0x80 附近。但某些副厂手柄的静止值会偏到 0x6F所以做控制时不要写死一个中心值我的做法是上电后先读 50 次取平均作为零点否则小车会自己在原地打转。4. 数据可靠性校验、中值滤波与串口调试4.1 握手字节与异或校验PS2 协议本身没有复杂的 CRC网上常见的手柄库通常只检查前两个 0x5A但实际调试中发现只检查握手字节不够。杜邦线一长个别 bit 翻转是家常便饭摇杆值会瞬间跳到极值然后马上又恢复。我习惯在外层再做一道异或校验把命令 0x42 之后读到的第 2 到第 18 个字节全部做 XOR和最后一个字节比较一致才认为帧有效。uint8_t PS2_CheckFrame(uint8_t *buf) { uint8_t i; uint8_t xor_sum 0; if (buf[0] ! 0x5A || buf[1] ! 0x5A) return 0; for (i 2; i 18; i) { xor_sum ^ buf[i]; } if (xor_sum ! buf[18]) return 0; return 1; }这个校验函数要放在 PS2_Analyze 之前调用校验不通过就保持上一次的合法数据直接 return。这样即使总线偶尔抖动也不会出现电机猛转一下的情况代价只是响应延迟一帧对手柄控制场景完全可接受。有一点需要说明副厂手柄的帧结构五花八门有些手柄第 19 字节根本不是校验位。如果发现校验总是失败先把 XOR 对比去掉只保留 0x5A 握手检查把功能跑通再考虑加防御。校验逻辑应该是“可插拔”的不要和协议解析耦合太深。4.2 摇杆采样与中值滤波摇杆的值本质上是两个电位器分压天生会抖尤其是在电池供电的小车上电源纹波会叠加在采样结果上。虽然 PS2 手柄内部已经做了模数转换但通过长线传过来的数字量仍然会有 ±3 左右的跳动。对这种缓变物理量我最常用的是三次采样取中值比均值滤波更能扛突发干扰。uint8_t mid_value(uint8_t a, uint8_t b, uint8_t c) { if (a b) { uint8_t t a; a b; b t; } if (b c) { uint8_t t b; b c; c t; } if (a b) { uint8_t t a; a b; b t; } return b; } void PS2_ReadFiltered(PS2_Data *result) { uint8_t buf[19]; uint8_t samples[3]; uint8_t i; for (i 0; i 3; i) { PS2_ReadData(buf); samples[i] buf[4]; // 以左摇杆 X 为例 } result-lx mid_value(samples[0], samples[1], samples[2]); }这个函数里做的是全交换排序虽然写得笨但在 8 位单片机上比调用标准库的排序快得多而且代码一眼能看懂。三次采样时间开销大约是几十毫秒手柄这种操控频率完全够用哪怕只采样一次也行滤波主要防的是偶发毛刺。按键不需要做滤波因为按键是数字量真正需要防抖的反而是逻辑层。比如按住十字键不放时每次轮询 up 都等于 1不做边沿检测就会得到“按一次方向键小车转三圈”的效果。解决方法是保存上一次按键状态只在“上次为 0 且本次为 1”时触发一次动作。4.3 串口打印数据帧定位假数据排查问题时不要直接看解析后的结构体那会把问题隐藏在逻辑层。我一般把 19 个原始字节用串口打出来一眼就能看出问题出在物理层、时序层还是解析层。void PS2_PrintRaw(uint8_t *buf) { uint8_t i; for (i 0; i 19; i) { printf(%02X , buf[i]); } printf(\r\n); }正常情况下串口助手里应该看到 5A 5A 开头的帧。如果第一字节是 FF 或者 00大概率是 CS 没拉低或 DO 线接错如果前两字节不是 5A 但后面数据有规律变化可能是帧长度不对如果数据看起来正常但某个 bit 偶尔翻转那就要检查上拉电阻和电源。串口波特率我习惯用 115200USB 转 TTL 模块在这个速率下兼容性最稳。printf 重定向到串口 1 的代码在标准库工程里很常规重写 fputc 让它往 USART1 的 DR 寄存器写数据就行。注意打印频率不要太快主循环里每隔 100ms 打一帧就够看打太密反而看不清波形变化。5. 常见问题排查五个把数据读错的坑5.1 数据全 0xFF按键完全无效现象串口打印出来每个字节都是 0xFF手柄怎么按都没反应。原因有两类一是接线错位DATA 和 COMMAND 接反了二是 CS 在发送过程中被拉高过。GPIO 模拟时如果在 PS2_Command 内部重复拉高再拉低 CS手柄会认为这次传输中断之后返回的全部字节都是 0xFF。解决先万用表量一下手柄插头的引脚定义确认 COMMAND 接 PA7、DATA 接 PA6代码里保证 CS 只在 PS2_Command 的开头拉低函数返回前才拉高中途不要动。这条排查顺序能解决掉一半的 0xFF 问题。5.2 按键串键按上键输出“上左”现象按下某个方向键读到的却是相邻两个按键同时有效。原因CLK 频率太高手柄内部分频跟不上导致移位寄存器里数据错位。另外COMMAND 线的电平在时钟上升沿还没稳定也会造成误锁存。解决把 PS2_Command 里的 delay_us(2) 改成 delay_us(5)让时钟周期降到 200kHz 以下。注意这个改动要同时影响发送和接收两个半周期只改发送侧会导致波形占空比不对称串键会变成固定方向的偏移。5.3 摇杆值不停跳动静止时也有波动现象手柄放在桌上不动摇杆数值在中心位置附近来回跳范围达到 ±10。原因手柄供电电压不稳。PS2 手柄对电压敏感供电毛刺会直接进入摇杆的采样基准造成数值抖动和代码里的时序没有关系。解决在 VCC 和 GND 之间并联一个 100μF 电解电容加一个 104 瓷片电容放在手柄插座旁边。软件上把中值滤波的采样次数从 3 次改成 5 次能再压掉一半抖动。电容要离插座近不要用长线引过去否则等效电感会让滤波效果打折。5.4 手柄放一段时间后第一次读就超时现象手柄五分钟不动再按任何键都没反应重新上电又好了。原因PS2 手柄有休眠机制进入低功耗后不再响应 0x42 命令需要先发 0x01 唤醒。这是协议特性不是硬件坏了。解决每次读数据前都先执行一遍 PS2_Command(0x01)然后延时 2ms 再发 0x42。这套唤醒序列放在 PS2_ReadData 里外部应用层不用感知。注意唤醒命令不能省略哪怕你上一条刚读完 2ms 也要发休眠判断在手柄内部不看主机状态。5.5 杜邦线一长就偶发错帧现象用手捏住线或者线靠近电机摇杆数值突然跳到极值松手又恢复。原因DATA 是开漏输出长线等效电容大边沿变缓手柄拉到低电平时单片机还没采到同时电机产生的电磁干扰会耦合进线缆造成信号毛刺。解决尽量把线缩短到 15cm 以内DATA 线上拉电阻从 10kΩ 换成 4.7kΩ增强下拉能力。电磁干扰重的场景下把信号线换成双绞线并套磁环比在代码里加滤波器更治本。这个坑属于物理层问题代码怎么滤波都救不回来。6. 二次开发把按键映射成小车方向盘6.1 按键转向 PWM 输出拿到解析好的 PS2_Data第一次改造成做什么最有成就感我选的是两个按键控制 PWM 方向。用定时器 2 的 CH1 输出 PWM占空比由按键决定。void motor_control(PS2_Data *d) { if (d-up) { TIM_SetCompare1(TIM2, 800); // 前进占空比 80% } else if (d-down) { TIM_SetCompare1(TIM2, 200); // 后退占空比 20% } else { TIM_SetCompare1(TIM2, 500); // 停止占空比 50% } }这里有个小技巧用占空比 50% 表示零速比占空比 0% 更好。很多电机驱动板把 0% 占空比当成禁用信号得先解锁才能动。改 PWM 参数时注意 TIM_SetCompare1 的输入范围匹配你的 ARR 值否则占空比会非线性。6.2 摇杆无级变速与死区方向按键是开关量摇杆才是模拟量。把摇杆映射成 PWM 占空比关键在于做死区处理不然手柄静止时中心点的微小偏移会让电机发出嗡嗡声。int map_joy_to_pwm(uint8_t joy) { int value (int)joy - 128; if (value -20 value 20) return 500; // 死区回中 value value * 400 / 128 500; // 500~900 正转500~100 反转 return value; }这段映射把摇杆 -128~127 的区间线性放大到 PWM 比较值上。死区 20 这个数值是实测下来比较合适的手柄静止时摆动不超过 ±10留一倍余量。如果电机响应太贼把死区加大到 30手感会更柔代价是起步会肉一点。6.3 组合键与状态机当你同时用到按键和摇杆自然会遇到一个需求想让同一个摇杆在不同模式下控制不同东西。比如按一下 R1 进入调速模式再按一下 R1 退回方向模式。这时候直接 if 到底会很乱我一般引入一个简单的状态变量。uint8_t mode 0; // 0: 转向模式, 1: 巡航模式 void handle_mode_switch(PS2_Data *d) { static uint8_t last_r1 0; if (d-r1 !last_r1) { mode !mode; } last_r1 d-r1; }这段代码只做了一件事用边沿检测实现按键“按一次切换一次”而不是“按住一直切换”。static 变量保存上一次的 R1 状态callback 里只判断上升沿这是嵌入式里处理按键事件的通用做法。这套状态机思路同样适用于长按、双击、组合键比如 L1R1 同时按下进入校准模式本质都是先找到边沿再查当前模式。从那以后我每次接到一个新的手柄型号或者换了不同封装的接收板都强制先跑一遍 19 字节全打印确认握手和校验都正常再去写业务逻辑。这个习惯帮我排掉了至少一半“看起来像玄学”的时序坑。希望帮到你。本文还有配套的精品资源点击获取