简介:面向STM32/MCU开发者的PS2手柄通讯手册,讲解单片机与手柄间的串行协议、引脚及按键解析,适合遥控机器人、平衡小车等嵌入式项目。文档为1个PDF,146KB,精简速查。详解接收器9个引脚(DI/DAT、DO/CMD、GND、VDD、CS/SEL、CLK、ACK)信号流向,梳理读取时序:拉低CS发0x01取ID(0x41绿灯、0x73红灯),再发0x42请求,手柄回0x5A后返9字节数据;Data[3]~[4]对应SELECT、START、方向键及□△○╳,Data[5]~[8]为摇杆模拟值,且区分红/绿灯模式差异。文末附STM32平衡车接线(DI→PB14、DO→PB15、CS→PB12、CLK→PB13)。已有857人学习下载,可参照协议时序快速完成PS2手柄的按键采集与摇杆读取。
1. PS2 手柄通讯手册及使用说明:一个被低估的嵌入式遥控方案
PS2 手柄通讯手册及使用说明,听起来像是给二十多年前的老游戏机写考古笔记,但实际上它在嵌入式领域一直没退场。一块几十块钱的单片机板子,配一只 PS2 手柄,就能拿到两个模拟摇杆、十四个按键和两组震动电机的完整输入输出,通讯协议是公开的串行时序,不需要蓝牙协议栈、不需要处理配对加密,这是很多开发者在做机器人遥控、舵机云台、电动滑板控制时第一版方案的首选。这篇笔记把 PS2 手柄从引脚到驱动完整拆开讲清楚,适合正在做单片机、嵌入式改造和遥控设备的工程师与爱好者;你会知道它靠什么通讯、数据怎么解析、哪些地方几乎每次都会翻车,以及怎么把它接到你自己的项目里。下面直接进入协议层。
2. 通讯协议拆开看:比特、字节与握手时序
2.1 为什么 PS2 手柄适合做嵌入式输入源
做遥控类产品的输入源,常见选择是蓝牙手柄、NRF24 自组遥控器和 PS2 手柄。蓝牙手柄方案要处理配对、HID 协议、电池管理和掉线重连,开发周期按周算;NRF24 自组方案要自己定义按键映射和摇杆量化,协议栈全自己写,调试时一个字节错位就够折腾。PS2 手柄的协议是“主机发起、从机应答”的单主机 SPI 模型,单片机是唯一的主设备,CLK、CMD、ATT 三条线都由单片机控制,只有 DAT 一根线回传,时钟节奏完全掌握在主机手里,不存在传统串口那种收发不同步的问题。
另一个优势是成本。PS2 手柄的保有量极大,二手市场上有线手柄和配套接收器都很便宜,而且协议文档早被社区整理得明明白白。接口是标准 9 针,线序固定,不需要自己画高频板和天线。对于遥控小车、机械臂、舵机云台这类产品,PS2 手柄往往比蓝牙方案更快进入可用状态,这也是它在 2025 年的今天仍然频繁出现在开源项目和课程设计里的原因。
2.2 9 字节数据帧的每一位都代表什么
通讯以“帧”为单位,一帧 9 个字节。主机把 ATT 拉低,代表“我要开始通讯了”,然后在 CLK 的驱动下连续发送 9 个字节;每发送一个字节的同时,从 DAT 线上读回一个字节。主机发出的 9 个字节格式固定:第 1 字节是启动命令 0x01,第 2 字节是地址 0x42,后面 7 个字节通常填充 0x00。手柄回传的 9 个字节才是有用的数据,对应关系如下:
| 回传字节索引 | 常见值 | 含义 |
|---|---|---|
| 0 | 0xFF | 应答起始标识 |
| 1 | 0x41 / 0x73 / 0x79 | 工作模式:数字模式 / 模拟模式 / 模拟模式 |
| 2 | 0x5A | 数据有效标识,读到 0x5A 才说明本次通讯成功 |
| 3 | 按键状态 1 | SELECT、L3、R3、START、上、右、下、左 |
| 4 | 按键状态 2 | L2、R2、L1、R1、三角、圆圈、叉、方块 |
| 5 | 0x00–0xFF | 左摇杆 X 轴,居中约 0x80 |
| 6 | 0x00–0xFF | 左摇杆 Y 轴,居中约 0x80 |
| 7 | 0x00–0xFF | 右摇杆 X 轴,居中约 0x80 |
| 8 | 0x00–0xFF | 右摇杆 Y 轴,居中约 0x80 |
注意按键状态里的位逻辑是反的:手柄内部使用上拉电路,按键没按下时对应位是 1,按下时被拉低变成 0。解析时必须取反,否则你会看到一个“所有键都在按着”的诡异状态。摇杆则没有方向区分,它是一个 0 到 255 的绝对位置值,中心约 128,向左上和左下是 0,向右下是 255。
2.3 250kHz 时序与硬件 SPI 的冲突点
PS2 手柄的通讯时序虽然长得像 SPI,但它跟单片机硬件 SPI 外设有两个明显冲突。第一,它的数据输出是半双工的,所有数据都从 DAT 一根线上走,而硬件 SPI 的 MOSI 和 MISO 是分开的,直接挂上会导致数据方向冲突。第二,它的位序和时钟极性没有统一标准,不同驱动库有 LSB 先行、也有 MSB 先行,硬件 SPI 的 CPOL/CPHA 组合并不能覆盖所有手柄型号。
我一般把 CLK 频率控制在 250kHz 左右,也就是每个时钟周期 4 微秒,高电平 2 微秒、低电平 2 微秒。频率再高,部分兼容手柄和无线接收器会丢位;频率太低,通讯速度会影响摇杆采样率。这里最可靠的做法不是依赖硬件 SPI,而是用 GPIO 软件模拟,代码可控、时序可调、出问题好排查。下一章先把硬件接好,我们把 FT 脚、ATT 脚这些容易接错的地方一次性讲清楚。
3. 硬件接线:从 9 针定义到电平适配
3.1 标准 9 针引脚定义与直连方式的坑
PS2 手柄或者无线接收器引出的是一个 9 针母头,线序是固定的,但很多人拿到实物后对着丝印看了半天,因为上面不会印“DAT”“CMD”这样的名称。标准定义如下:
| 引脚号 | 名称 | 方向 | 说明 |
|---|---|---|---|
| 1 | DAT | 手柄 → 主机 | 数据输出线,开漏输出 |
| 2 | CMD | 主机 → 手柄 | 命令输入线 |
| 3 | 9V / NC | — | 部分原装手柄是 9V 供电,一般悬空不用 |
| 4 | GND | — | 地线,和 9 脚连通 |
| 5 | VCC | — | 供电,5V,约 30–50mA |
| 6 | ATT | 主机 → 手柄 | 片选线,低电平有效 |
| 7 | CLK | 主机 → 手柄 | 时钟线 |
| 8 | NC | — | 空脚 |
| 9 | GND | — | 地线 |
有线手柄和无线接收器的线序相同,只是无线接收器在主机端多加了一次无线到有线的解码,时序上会有额外延迟。直连方式最常踩的坑是把 3 脚当成电源来供电,或者把 4 脚和 9 脚中的某个当成唯一的 GND,结果开工前就把手柄烧了。3 脚在原装手柄的设计里确实是 9V 输入,但现代驱动方案都用 5V 供电,3 脚保持悬空即可。4 脚和 9 脚是内部连通的,只接 4 脚也行,但建议两个都接,减少回路阻抗。
3.2 3.3V 与 5V 混接:DAT 开漏上拉不能省
这是 PS2 手柄项目里最容易翻车的硬件问题。手柄本身工作在 5V 逻辑电平,ATT、CMD、CLK 都是主机输出的高电平信号,如果主控是 3.3V 单片机,直接输出 3.3V 高电平,对手柄内部电路来说可能达不到输入高电平阈值,导致指令不被识别。反方向更麻烦:手柄的 DAT 是开漏输出,需要外部上拉电阻才能把数据拉高,你不加上拉,DAT 会一直维持浮空状态,读回来的数据不是 0xFF 就是乱码。
对 5V 单片机,DAT 线上加一个 10kΩ 电阻上拉到 5V,CMD、CLK、ATT 直接连过去就能工作。对 3.3V 单片机,常见做法是给 DAT 上拉到 5V,然后用两个电阻分压,例如 1kΩ 串联加 2kΩ 对地分压,把 5V 数据降到约 3.3V,再接 MCU 的输入引脚。CMD、CLK、ATT 这三个主机输出方向则要反过来,用 MOSFET 电平转换芯片把 3.3V 信号拉高到 5V,不能用电阻分压,因为这是单向驱动问题。
3.3 无线接收器与有线手柄的区别
有线手柄和无线接收器的通讯行为在协议层面完全一致,但无线接收器有两个额外特性要习惯。一是上电后它内部要初始化无线链路,最快也要几十毫秒,慢一点的兼容接收器要一百多毫秒,你必须在初始化代码里给足等待时间,否则第一次读帧全是 0xFF。二是无线传输会引入约 1–2 毫秒的随机抖动,CLK 频率超过 250kHz 后,连续读帧偶尔会出现某一帧数据整体错位。
我通常把初始化流程写成“上电延时 200ms → 尝试读一帧 → 校验 0x5A → 失败则每 50ms 重试一次,最多重试 5 次”。这样既兼容有线手柄的立即响应,也兼容无线接收器的慢启动。如果项目允许,建议先拿有线手柄调完协议,再换无线接收器验证时序余量,能省掉不少来回猜问题的时间。
4. 把协议跑起来:最小驱动代码与按键解析
4.1 最小驱动:软件模拟 SPI 的 read_frame 函数
接线确定后,驱动层的核心就是实现一个软件模拟的字节移位函数。下面这套是我在多个项目里反复用过的 C 代码骨架,位序按 LSB 先行实现,时钟用 250kHz。引脚定义按你自己的板子调整。
// ps2_driver.c // 引脚定义,按你自己的板子调整 #define PS2_CLK PC0 #define PS2_DAT PC1 // 输入,带上拉 #define PS2_CMD PC2 #define PS2_ATT PC3 // 发一个字节的同时读回一个字节 // 位序:LSB 先行,先发 bit0 static uint8_t ps2_shift(uint8_t data) { uint8_t recv = 0; for (uint8_t i = 0; i < 8; i++) { // 主机准备 CMD 数据 if (data & 0x01) { PS2_CMD_HIGH(); } else { PS2_CMD_LOW(); } // 时钟上升沿,主机输出有效 PS2_CLK_HIGH(); delay_us(2); // 2us,配合低电平 2us,约 250kHz // 时钟下降沿,从机输出有效,主机采样 DAT PS2_CLK_LOW(); delay_us(2); // 读取回传位,写入 recv if (PS2_DAT_READ()) { recv |= (0x80 >> i); // 注意这里把收到的位拼到高位 } data >>= 1; } return recv; } // 读取一帧,结果存入 rx[0..8] void ps2_read_frame(uint8_t rx[9]) { PS2_ATT_LOW(); // 拉低片选,启动通讯 // 第一字节:启动命令 rx[0] = ps2_shift(0x01); // 第二字节:读数据地址 0x42 rx[1] = ps2_shift(0x42); // 后续 7 个填充字节 for (uint8_t i = 0; i < 7; i++) { rx[i + 2] = ps2_shift(0x00); } PS2_ATT_HIGH(); // 拉高片选,结束通讯 }这段代码的核心逻辑是把 PS2 协议压缩成一个 8 次的循环,每次循环完成“主机发 1 位、从机回 1 位”的交换。ps2_shift 的参数 data 是主机要发送的字节,返回值 recv 是手柄同时回传的字节。rx 数组里最终的数据对应前面表格中的九字节结构,但要注意 rx[0] 通常是 0xFF,rx[2] 才是 0x5A 数据有效标识。如果你读到的 rx[2] 不是 0x5A,说明这次帧通讯失败,需要整帧重读。
参数方面,CLK 频率由 delay_us 决定,2 微秒对应 250kHz 左右,这是兼容原装手柄和第三方手柄的常用值。PS2_DAT 的读取时机放在 CLK 下降沿之后,目的是给从机足够的输出建立时间。如果你的主控跑得很快,可以把 delay_us 调到 1.5,但不要低于 1,否则绿色兼容手柄很容易出错。
4.2 按键与摇杆解析:从 raw 数据到可用的控制量
拿到 rx 数组后,下一步是把按键状态和摇杆值拆出来。这一步的独门细节是:按键按下的逻辑电平是 0,要反转;摇杆的原始值范围是 0–255,需要转为 -128 到 +127 才能和方向概念对应。
// ps2_parser.c // 按键按下判定:手柄上拉,按下为 0,未按为 1 static uint8_t ps2_key_pressed(uint8_t raw, uint8_t bit) { return (raw & (1 << bit)) == 0; } // 解析一帧,结果写入 struct void ps2_parse(const uint8_t rx[9], ps2_state_t *s) { // 第 3、4 字节是按键状态 s->select = ps2_key_pressed(rx[3], 0); s->l3 = ps2_key_pressed(rx[3], 1); s->r3 = ps2_key_pressed(rx[3], 2); s->start = ps2_key_pressed(rx[3], 3); s->up = ps2_key_pressed(rx[3], 4); s->right = ps2_key_pressed(rx[3], 5); s->down = ps2_key_pressed(rx[3], 6); s->left = ps2_key_pressed(rx[3], 7); s->l2 = ps2_key_pressed(rx[4], 0); s->r2 = ps2_key_pressed(rx[4], 1); s->l1 = ps2_key_pressed(rx[4], 2); s->r1 = ps2_key_pressed(rx[4], 3); s->triangle = ps2_key_pressed(rx[4], 4); s->circle = ps2_key_pressed(rx[4], 5); s->cross = ps2_key_pressed(rx[4], 6); s->square = ps2_key_pressed(rx[4], 7); // 摇杆:原始 0–255,转成 -128 到 +127 s->lx = (int8_t)(rx[5] - 128); s->ly = (int8_t)(rx[6] - 128); s->rx = (int8_t)(rx[7] - 128); s->ry = (int8_t)(rx[8] - 128); }解析函数里,rx[3] 对应十字键和 SELECT、START,rx[4] 对应肩键和四个动作键。注意这个顺序不是随意的,它是手柄协议固定的返回顺序,写错位会导致“按方块键结果输出三角键”的奇怪现象。ps2_key_pressed 把位值为 0 判定为按下,与前面表格里的逻辑一致。
摇杆转成有符号数之后,居中值从 128 变成 0,左和上为负,右和下为正。但要注意机械摇杆有个特点:它永远回不到精确的 128,会有几个 LSB 的漂移。直接拿这个数据去控制电机,会让设备在静止时出现缓慢漂移。实际项目里应该在解析后加一个死区判断,绝对值小于等于 4 的输入直接当作 0,后面进阶章会给出具体实现。
4.3 震动与配置模式:两条额外的命令序列
要开启震动,不能直接用 0x42 读地址。手柄出厂时默认处于数字模式,电机反馈是关闭的,必须先发送配置序列进入模拟模式,再单独设置电机强度。这是一段容易写错的代码,因为发送的字节数和顺序在不同手柄上有细微差异,但下面这套基础序列能兼容大多数原装手柄:
// 进入配置模式 void ps2_enter_config(void) { PS2_ATT_LOW(); ps2_shift(0x01); ps2_shift(0x43); // 0x43 表示“进入配置” ps2_shift(0x00); ps2_shift(0x01); ps2_shift(0x00); ps2_shift(0x00); ps2_shift(0x00); ps2_shift(0x00); ps2_shift(0x00); PS2_ATT_HIGH(); } // 开启震动映射 void ps2_enable_rumble(void) { ps2_enter_config(); PS2_ATT_LOW(); ps2_shift(0x01); ps2_shift(0x42); // 0x42 读地址覆盖为映射设置 ps2_shift(0x00); ps2_shift(0x00); ps2_shift(0x00); ps2_shift(0x00); ps2_shift(0x00); ps2_shift(0x00); ps2_shift(0x00); PS2_ATT_HIGH(); } // 设置震动强度,小马达 0–255,大马达 0–255 void ps2_set_rumble(uint8_t small, uint8_t large) { PS2_ATT_LOW(); ps2_shift(0x01); ps2_shift(0x42); ps2_shift(0x00); ps2_shift(0x00); ps2_shift(0x00); ps2_shift(small); // 第 6 字节:小马达设定 ps2_shift(large); // 第 7 字节:大马达设定 ps2_shift(0x00); ps2_shift(0x00); PS2_ATT_HIGH(); }震动命令的字节位置比较讲究,small 和 large 分别对应回传字节序列里的第 6、第 7 位,也就是主机在发送时填充的第 6、第 7 个字节。如果震动不生效,先确认手柄是不是第三方兼容型号,部分兼容手柄需要把 large 映射放到 small 之前,那是手柄自身的映射 bug,只能按实测调整。
配置模式还有一个隐含的坑:每一次 ps2_enter_config 都会让手柄暂离工作状态,频繁进入会导致手柄内部状态机错乱。稳妥的做法是在系统初始化时开启一次震动映射,之后只调 ps2_set_rumble,不再反复进入配置模式。
5. 避坑名单:实际项目里最常见的 4 个故障
5.1 读回数据全是 0xFF:DAT 上拉被遗漏
现象:rx 数组所有字节都是 0xFF,按键怎么按都没有反应,用万用表量 DAT 引脚永远是高电平。
原因:DAT 是开漏输出,手柄内部没有上拉能力,外部电路如果没接上拉电阻,引脚就处于浮空状态,主控读到的永远是默认高电平。
解决:在 DAT 到 VCC 之间接一个 10kΩ 上拉电阻。5V 系统直接上拉到 5V,3.3V 系统可以把 DAT 上拉到 5V 再通过分压进主控引脚。加上这个电阻后,按下一个键,对应位会被拉低,数据马上恢复正常。
5.2 用硬件 SPI 导致数据错位
现象:用 STM32 或 AVR 的硬件 SPI 外设连接手柄,读数据时有时正常,有时整体后移一位;换了时钟极性组合后,正常帧概率略有变化但没根治。
原因:PS2 协议的半双工 DAT 线和硬件 SPI 的 MISO 采样时刻对不齐,加上不同手柄的建立时间不一致,导致采样点落在边沿附近,数据被判断成前一位或后一位。
解决:放弃硬件 SPI 外设,使用 GPIO 软件模拟,采样点固定放在 CLK 下降沿之后 2 微秒。代码改完后,再观察连续读 1000 帧的数据,正常率应该接近 100%;如果还有单帧错位,优先检查 delay_us 的实际时间是否被中断函数拖长。
5.3 无线手柄上电后前 100ms 内读不到数据
现象:单片机上电执行初始化,第一个 ps2_read_frame 读回的帧里没有 0x5A,连续读都失败;但等程序运行一两秒后再按按键,一切正常。
原因:无线接收器上电后要先完成内部固件启动和无线链路同步,这个过程需要几十到两百毫秒。主机在此时发命令,接收器还没准备好应答。
解决:初始化时加一个 200ms 延时,再做失败重试。重试间隔取 50ms,连续失败 5 次后可以报错或重启接收器供电。注意不要用 while(1) 死等单一帧,无线链路的偶发丢帧是正常的,要允许重试。
5.4 第三方手柄的模式号和按键映射错乱
现象:读回的模式字节不是 0x41 或 0x73,而是 0x79、0xFF 或其它值;按键解析后,按下“上”键反而触发了“方块”键。
原因:第三方兼容手柄的映射表不遵循原装规范,尤其是按键位和模式标识位存在差异,这类手柄又叫它“黑色的盒子”,黑匣子行为只能靠实测。
解决:系统启动时先打印模式字节和几个按键的原始位,建立一个简单的映射学习流程。例如把模式号保存到一个配置数组里,以后读帧时先按模式号查映射表,查不到就按原装规则解析,然后用摇杆和少数几个通用按键先完成核心功能。不要试图在驱动层一次兼容所有第三方手柄,那是无底洞。
6. 进阶玩法:把 PS2 手柄变成通用遥控节点
基础协议跑通后,下一步是把手柄接入真实系统。我一般会在 ps2_parse 输出后立刻做三层处理:死区、映射、重发。死区解决摇杆回中漂移,映射解决“手柄的物理方向”和“执行器的控制方向”不一致,重发则是把数据打包成串口或蓝牙帧,让另一端的 MCU 直接使用。
死区实现很简单,前面解析出带符号摇杆值之后,做一个双向绝对值判断:
int8_t deadzone(int8_t v, int8_t dead) { if (v > -dead && v < dead) { return 0; } return v; } // 实际使用:int16_t speed = deadzone(s->ly, 4) * 2;死区选 4 还是 8,取决于手柄的磨损程度。旧手柄摇杆漂移明显,死区要放到 10 以上,但代价是低速控制时有一段“空行程”。我习惯把死区值和最大输出值做成宏,调试时用 OLED 屏幕实时显示摇杆原始值,这样不需要猜,直接把漂移量打印出来就能定死区参数。
映射层解决的是方向盘一致性问题。比如遥控小车,手柄竖直向上推代表前进,但电机安装方向可能导致转速值是反的,此时只需要把符号翻转。这种映射逻辑不要写在驱动里,写在应用层,方便不同项目复用同一个驱动文件。重发层把解析后的按键和摇杆值打包成固定帧格式,例如 3 字节帧:帧头、按键位、摇杆值,然后通过串口发出。另一端解析时也按这个格式,就能快速拼出一个远程控制链路。
我自己的习惯是,任何 PS2 手柄项目至少加一个 OLED 或串口调试输出,上电后滚动显示 rx[2] 的值、模式号和摇杆中心值。这样做的好处是能在一分钟内确认协议是否跑通、手柄型号是否兼容,而不是两眼一抹黑地盲调。手柄这种外设,玄学问题大多来自接线和上拉,剩下的时序问题靠延时和采样点就能解决,真正没法控制的只有第三方兼容手柄的映射表。希望这篇笔记能帮你在做遥控项目时少走几步弯路,把更多时间留给业务逻辑本身。
本文还有配套的精品资源,点击获取