简介基于STM32F103C8T6微控制器与PS2手柄进行数据交互的完整工程代码适合嵌入式入门开发者以及需要快速实现手柄控制的项目人员。这份代码解决了从零配置SPI接口、解析PS2协议、读取按键与摇杆数据的常见问题同时展示了中断与DMA两种数据接收方式在实际工程中的应用。压缩包总计包含一百五十四个文件以C语言源文件、头文件、编译中间文件和工程配置文件为主其中源文件与头文件负责功能实现编译中间文件可辅助理解构建流程内附的CubeMX配置与HAL库驱动代码则方便直接导入开发环境并按需修改。整个压缩包体积约六点一五兆字节轻量易用。目前已有九百五十五人学习工程结构清晰从系统初始化到外设配置再到手柄数据解析各模块均有对应代码可作为同类外设驱动开发与通信协议学习的参考模板。1. 用STM32F103C8T6把PS2手柄变成遥控器从接线到读键全流程很多人第一次玩“stm32f103c8t6手柄PS2”这个组合时都会卡在同一步线接好了程序烧进去了串口打印出来的却全是 0xFF 或者乱跳的数值一度怀疑是不是买到了假手柄。其实 PS2 手柄的通信协议并不复杂核心就是 SPI 主机模式下的一次 9 字节握手通信读完一帧数据按键和摇杆的状态就全在手里了。这个项目适合两类人一是想让小车、机械臂、云台用手柄无线控制的硬件爱好者二是在校学生做课程设计时需要一个可靠、抗干扰的遥控输入方案。这篇文章我会把硬件接线、SPI 通信时序、按键/摇杆解析核心代码、常见踩坑点以及如何从“读到手柄数据”扩展到“驱动电机”全部拆开讲清楚保证你照着做能跑起来。2. 硬件架构与工程结构先搞清数据从哪里来、往哪里去2.1 PS2 手柄接收器的通信角色PS2 无线手柄的接收器从外面看是一个带排针的黑色小板上面引出 6 根线VCC、GND、DAT、CMD、CS、CLK。从 MCU 的角度看STM32F103C8T6 是 SPI 主机接收器是从机但和普通 SPI 从机不太一样的地方是主机每次给从机发送一个命令字节的同时从机也会在同一时钟沿把一字节数据放到 DAT 线上也就是说 MOSI 和 MISO 是同时工作的。PS2 接收器只认 3.3V 逻辑电平虽然很多教程说可以直接接 5V实际长期使用还是有风险的后面避坑章节我会细说。整个通信链路的架构可以这样理解手柄把按键和摇杆状态通过 2.4G 无线发给接收器接收器缓存这帧数据等待主机STM32主动来读取主机发一个 0x01 命令进入配置模式再发几个固定字节最后发 0x42 读取数据接收器会把 6 个字节的数据依次回传其中包括按键状态、摇杆模拟量和一个校验和。整个过程是主机完全主导的手柄不会主动上报数据这一点和很多蓝牙 HID 设备不同写代码时要记住你不问它不说。2.2 引脚分配与最小系统接线表STM32F103C8T6 这款芯片有 3 个 SPI 外设但这个项目一般只用 SPI1因为它的引脚刚好和下载器、调试串口不冲突。我习惯把硬件 SPI1 的引脚分配为功能引脚说明SCKPA5SPI1 时钟接接收器 CLKMOSIPA7主机输出接接收器 CMDMISOPA6主机输入接接收器 DATCSPA4手动控制的片选接接收器 CSVCC3.3V接收器供电不建议接 5VGNDGND共地必须接注意这里 CS 用的是普通 GPIO 而不是 SPI 的硬件 NSS原因是 PS2 的通信时序要求 CS 在整个 9 字节传输过程中保持低电平中途不能拉高用硬件 NSS 很难精确控制这个窗口所以项目里直接软件拉低、软件拉高虽然多写两行代码但时序上更干净。另外如果你用的是 5V 供电的 Arduino 版本接收器接到 STM32 前最好加一个电平转换或者串联电阻分压否则长期运行容易把 PA6、PA7 引脚烧出问题。2.3 工程目录结构与代码分层项目的源码结构是一个典型的裸机分层工程没有用操作系统所有代码按功能拆成几个文件方便二次开发。目录大致如下ps2_rc/ ├── Core │ ├── Inc │ │ ├── main.h │ │ ├── spi.h │ │ ├── gpio.h │ │ └── ps2.h │ └── Src │ ├── main.c │ ├── spi.c │ ├── gpio.c │ └── ps2.c ├── Drivers │ └── STM32F1xx_HAL_Driver └── MDK-ARMps2.c 是核心文件对外暴露三个接口PS2_Init 做引脚和 SPI 初始化PS2_ReadGamepad 读取一帧完整数据PS2_DebugPrint 把解析结果通过串口打印出来。main.c 里是一个 5ms 周期的主循环轮询逻辑调用 PS2_ReadGamepad 后根据返回的按键值执行对应动作。分层的作用是让硬件相关代码和业务逻辑解耦比如你把读取代码从 SPI1 换到 SPI2只需要改 ps2.c 里的引脚宏定义main.c 一行都不用动。3. PS2 通信协议与核心代码拆解一次完整的数据读取过程3.1 SPI 参数配置与初始化代码PS2 接收器的 SPI 时序比较挑剔要求时钟极性和相位都配置为模式 3也就是 CPOL 1、CPHA 1时钟空闲时为高电平数据在第二个边沿采样。用 STM32 的 HAL 库配置时要注意把 SPI 速度控制在 250kHz 左右太快会导致接收器跟不上太慢又影响读取频率。我一般把预分频设为 128在 72MHz 系统时钟下得到约 562kHz 的 SPI 时钟实测稳定。void PS2_Init(void) { GPIO_InitTypeDef gpio; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_SPI1_CLK_ENABLE(); // PA5 SCK, PA7 MOSI, PA6 MISO gpio.Pin GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode GPIO_MODE_AF_PP; gpio.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, gpio); // PA4 手动 CS gpio.Pin GPIO_PIN_4; gpio.Mode GPIO_MODE_OUTPUT_PP; HAL_GPIO_Init(GPIOA, gpio); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); hspi1.Instance SPI1; hspi1.Init.Mode SPI_MODE_MASTER; hspi1.Init.Direction SPI_DIRECTION_2LINES; hspi1.Init.DataSize SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity SPI_POLARITY_HIGH; // CPOL 1 hspi1.Init.CLKPhase SPI_CPHA_2EDGE; // CPHA 1 hspi1.Init.NSS SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler SPI_BAUDRATEPRESCALER_128; hspi1.Init.FirstBit SPI_FIRSTBIT_MSB; HAL_SPI_Init(hspi1); }这段代码里有几个参数是关键CLKPolarity 和 CLKPhase 必须是上面说的模式 3NSS 设置为软件管理BaudRatePrescaler 决定了通信速率。如果你的接收器通信不稳定优先检查这三项。GPIO 的复用推挽模式适用于 SCK 和 MOSIMISO 也需要配成 AF_PP这在 HAL 库里是合理的。另外 CS 初始化为高电平表示主机空闲时不选中从机。3.2 读取一帧数据的核心函数一次完整的 PS2 数据交换是 9 个字节主机依次发送 0x01、0x42、0x00、0x00、0x00、0x00、0x00、0x00、0x00每发送一个字节的同时从机回传一个字节。前两个回传字节是 0x43 和 0x5A用来校验握手是否正确第 4 个字节是按键状态第 5、6 个字节是左摇杆和右摇杆的 X、Y 模拟量。核心函数可以这样写uint8_t PS2_ReadGamepad(PS2_Data_t *data) { uint8_t send_cmd[9] {0x01, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t recv_buf[9] {0}; uint8_t i; HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); // CS 拉低 HAL_Delay(1); // 等待接收器准备 for (i 0; i 9; i) { HAL_SPI_TransmitReceive(hspi1, send_cmd[i], recv_buf[i], 1, 100); } HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // CS 拉高 // 握手校验前两个字节必须是 0x5A 和 0x5A 的取反 if (recv_buf[1] ! 0x5A) { >typedef struct { uint8_t connected; uint8_t btn_select; uint8_t btn_l3; uint8_t btn_r3; uint8_t btn_start; uint8_t btn_up; uint8_t btn_right; uint8_t btn_down; uint8_t btn_left; uint8_t lx; uint8_t ly; uint8_t rx; uint8_t ry; } PS2_Data_t;这个结构体的好处是你在业务逻辑里无论判断按键还是读取摇杆都直接访问成员变量不用关心底层寄存器。比如判断手柄是否按下“上”键只需要写 if (ps2.btn_up 0) 就行0 表示按下。摇杆的 lx、ly 对应左摇杆rx、ry 对应右摇杆如果手柄没有配置模拟模式这两个值会固定在 127 左右用摇杆之前需要按住 MODE 键切换到模拟模式这也是新手常踩的坑之一。按键映射规则方面PS2 的按键布局可以理解为十字方向键在上半字节SELECT、L3、R3、START 在下半字节手柄的 △、○、×、□ 和 L1、R1、L2、R2 在另一组数据里这部分需要开启模拟模式后通过扩展数据读取基础项目里一般只处理方向键和四个功能键扩展按键的读取代码我会在二次开发章节补充。4. 常见问题排查通信失败、数据错乱和电平不匹配的踩坑记录4.1 现象串口打印全是 0xFF偶尔跳出一个正常值这个现象在项目上线第一天最容易遇到。手柄和接收器都通电了但 MCU 读回来的数据几乎全是 0xFF。原因有两个方向一是 SPI 模式配置错了PS2 接收器要求模式 3如果你用的是 STM32CubeMX 默认配置经常是模式 0时钟相位对不上从机根本没在正确的边沿输出数据二是 CS 片选时序问题接收器要求通信期间 CS 一直为低如果代码里每次传完一个字节就把 CS 拉高接收器会认为通信中断后续字节全部返回高电平。解决方法是先确认 SPI 的 CPOL 和 CPHA 都配成 1再检查 PS2_ReadGamepad 里的 CS 控制逻辑保证 9 字节传输期间 CS 始终为低。4.2 现象手柄能连上但按键无响应或方向键错乱按键无响应的常见原因是手柄没开模拟模式。PS2 手柄默认处于数字模式这时十字键和四个功能键能读到但摇杆数据固定是 0x7F 左右且 L3、R3 按键不生效。解决方法是启动时发送一组配置命令把手柄切换到模拟模式然后读取数据里才会包含摇杆和扩展按键。方向键错乱的问题则是位序搞反了PS2 返回的按键字节是低 bit 对应 SELECT高 bit 对应 LEFT有些教程写的是反的你只需要对着协议定义重新排列 bit 就能修正。4.3 现象手柄开机时偶尔正常运行几分钟后通信卡死这种问题通常是 SPI 速率太高导致的。PS2 接收器的最高通信时钟大约是 500kHz你把 SPI 预分频设成 4 或者 8初始化时能通信但运行一会儿后从机内部状态机容易错乱表现为数据帧越来越不稳定直到完全卡死。解决方法是把 BaudRatePrescaler 设到 128 甚至 256确保时钟低于 500kHz。如果你用的是软件模拟 SPI要控制好每个半周期的延时常见做法是延时 5 微秒左右手搓时序时尤其要注意。4.4 现象接收器不识别 3.3V 供电必须接 5V 才能用市面上 PS2 接收器有两种一种是原装拆机芯片是 SPI 逻辑电平3.3V 可以稳定工作另一种是国产兼容版内部没有电平转换必须 5V 供电才能跑起来。但 STM32F103C8T6 的引脚是 5V 容忍的所以很多人直接让接收器接 5V、数据线直连 MCU 也能跑。如果你想长期稳定运行建议在 DAT、CMD、CS、CLK 四条数据线上加 1k 串联电阻或者用一个四路电平转换模块这样 MCU 引脚更安全。如果你发现接 5V 时通信偶尔失败优先判断是不是上拉电阻的问题。4.5 现象手柄数据偶尔跳变摇杆回中不在 127摇杆回中值不在 127 附近其实是正常现象PS2 的摇杆是电位器模拟量长时间使用后磨损会导致中心值偏移有的手柄回中值是 130有的偏到 110都属于硬件特性。解决方法是不要硬编码判断摇杆是否居中而是初始化时采样一次静止值作为校准基准之后每次读取都减去这个基准值再判断。数据跳变的问题则需要在主循环里加一个简单的软件滤波比如连续读 3 帧取中间值或者对相邻两帧的变化量做限制差值超过 50 就丢弃本次数据。5. 从读数据到控车PS2 手柄驱动小车电机的完整示例5.1 主循环逻辑轮询读取与动作执行读取到手柄数据之后最常见的落地场景就是控制两轮差速小车。main.c 里的主循环结构是每 10ms 调用一次 PS2_ReadGamepad如果返回值是 1 而且 connected 标志为真就根据按键和摇杆状态计算左右轮 PWM 占空比然后更新电机驱动。如果读取失败直接让小车停车避免手柄掉线后电机停不下来。while (1) { if (PS2_ReadGamepad(ps2_data)) { if (ps2_data.connected) { int16_t speed_l, speed_r; // 左摇杆 Y 轴控制前进后退X 轴控制转向 uint8_t turn ps2_data.lx; uint8_t fwd ps2_data.ly; int16_t base (int16_t)(fwd - 127) * 2; // -254 ~ 254 int16_t diff (int16_t)(turn - 127) * 1; // 转向权重 speed_l base diff; speed_r base - diff; // 限幅到 -255 ~ 255 speed_l (speed_l 255) ? 255 : (speed_l -255 ? -255 : speed_l); speed_r (speed_r 255) ? 255 : (speed_r -255 ? -255 : speed_r); Motor_SetSpeed(speed_l, speed_r); } } else { Motor_Stop(); } HAL_Delay(10); }这段逻辑用到了差速控制的经典算法把摇杆的 Y 轴值减去中心值得到基础速度X 轴值减去中心值得到转向差值左右轮速度分别是基础速度加减转向差值。摇杆值范围是 0 到 255减去 127 后范围是 -127 到 127乘以 2 是为了适配 8 位 PWM 的满量程。转向权重比前进权重小是为了让小车在高速时转向不会太激进。参数怎么调如果你觉得转向过猛把 diff 那行的权重从 1 改成 0.5 或者 0.7如果你想要原地转向模式可以增加一个按键判断按住 L1 时只输出 diff 不输出 base小车就能原地绕中心旋转。Motor_SetSpeed 函数内部负责把速度值映射到 PWM 通道和方向引脚速度为正时前进为负时反转这也是封装的好处main 里不用关心 GPIO 操作。5.2 电机驱动封装与正反转逻辑电机驱动部分我用的是 TB6612 双路电机驱动模块STM32 的 PA8、PA9 输出 PWM 控制左轮PA10、PA11 控制右轮方向引脚用 PB0、PB1、PB10、PB11。每路电机的控制是独立的两根方向线和一根 PWM 线方向线决定正反转PWM 决定速度。Motor_SetSpeed 的实现要考虑 PWM 的互补输出模式这里直接用定时器比较值控制占空比。void Motor_SetSpeed(int16_t left, int16_t right) { // 左轮 if (left 0) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET); // AIN1 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET); // AIN2 __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, left); } else { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_SET); __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, -left); } // 右轮 if (right 0) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_10, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_11, GPIO_PIN_RESET); __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_2, right); } else { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_10, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_11, GPIO_PIN_SET); __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_2, -right); } }PWM 频率选 1kHz 到 10kHz 都行TB6612 模块对频率不敏感但电机噪音和发热会随频率变化我一般用 10kHz 配合 72MHz 主频PWM 分辨率高且噪音小。注意 TIM1 是高级定时器如果用的是通用定时器 TIM2 或 TIM3代码里的通道定义要换成对应的宏。方向引脚的选择要确认驱动模块的输入逻辑TB6612 的 IN1 和 IN2 同时为高或同时为低时电机是刹车状态这在停车的时候比直接给零占空比更稳如果你发现小车滑行距离太长可以考虑停车时把方向引脚改成同高模式。5.3 按键功能映射表与调试技巧主循环里除了摇杆控速还需要处理离散按键比如 START 键切换启停SELECT 键切换速度档位× 键按一下紧急刹车L1、R1 键微调转向偏移。为了不让主循环里出现一大堆 if 判断项目里通常维护一个按键映射表把按键事件和回调函数关联起来按键功能说明START系统启停按下切换运行/待机SELECT速度档位在低速/中速/高速之间循环×急停立即停车并锁定 1 秒L1左微调转向偏移减 5R1右微调转向偏移加 5L3原地左转左轮正转右轮反转R3原地右转左轮反转右轮正转调试技巧方面我习惯在 main.c 里加一个串口调试任务把每次读取到的 ps2_data 全部打印出来包括 connected 状态、8 个按键的 0/1 值和 4 个摇杆的原始数值。这样在跑车之前先确认手柄数据是稳定的再让电机上电。串口波特率推荐 115200打印格式用逗号分隔方便在串口助手里画曲线观察摇杆漂移情况。6. 二次开发改造思路让项目从“能跑”进化到“好用”拿到这套代码后你可以从三个方向往下深入。第一个是摇杆数据的处理优化现在代码里直接用原始值减去 127但实际手柄中心值有偏差而且摇杆在接近边缘时灵敏度变化很大。我一般会在读取后加一个死区判断和线性映射把 0 到 255 的原始值映射到 -100 到 100死区设置为 ±10这样小车不会因为手柄轻微抖动而自己往前走。映射代码放在 ps2.c 里做一个独立的函数这样 main.c 里的控制逻辑不需要改动。第二个方向是扩展读取模式。PS2 手柄还有第二组按键数据包括 △、○、×、□、L1、L2、R1、R2这些键在基础协议里读不到需要通过 0x01 命令进入模拟模式后才能在手柄发送的数据里拿到扩展字节。改造方法是在 PS2_ReadGamepad 里把发送数组从 9 个字节扩展为 21 个字节然后在 recv_buf 的后续字节解析扩展按键这样手柄就能支持弓箭射击、视角切换、按键连发等玩法很多无人机遥控项目的按键方案就是这么做的。第三个方向是通信线程化和 DMA 优化。现在的主循环是 10ms 轮询一次读取期间 MCU 被 HAL_SPI_TransmitReceive 阻塞大约 2ms如果项目里还要处理屏幕刷新、超声波测距等任务这个阻塞很难接受。常见做法是把手柄读取放到一个定时器中断里读到的数据存入共享缓冲区主循环只读取缓冲区的最新值加一个 volatile 标志防止读写冲突。更进一步你可以用 SPI 的 DMA 模式配合空闲中断这样 CPU 可以在 DMA 搬运数据时处理其他任务读取频率可以提升到 100Hz 以上手柄延迟从 10ms 级降到 5ms 级操作手感会有明显改善。如果将来要上 RTOS这套代码也是标准的驱动层划分方式ps2.c 相当于设备驱动main.c 里的控制逻辑相当于应用任务两者之间用消息队列传递手柄状态即可。最后分享一个我的习惯每次改完手柄相关代码我都会先把手柄放桌上静止不动然后连续打印 1000 帧数据确认没有任何一帧握手失败再用手快速晃动摇杆观察是否有突变数据导致小车突然偏转。从那以后我每次跑小车前都强制走一遍这个检验流程省下的都是拆车时间。希望这篇拆解能帮你把这个项目顺利跑起来也欢迎你在跑通之后把手里的手柄玩出新花样。本文还有配套的精品资源点击获取