简介:《PS2手柄通讯手册及使用说明》是一份面向嵌入式开发与单片机学习者的技术文档,适合需要将PS2手柄接入STM32等主控、完成按键与摇杆数据采集的读者。文档围绕手柄与接收器的自动配对、红灯/绿灯模式差异展开,系统说明接收器各引脚功能及DI/DAT、DO/CMD、CS/SEL、CLK等信号的流向与电平要求,并给出主机拉低CS、发送0x01、读取ID、以0x42请求数据、接收0x5A应答的通讯流程。数据意义对照表逐字节解释按键位与左右摇杆模拟量范围,附带的STM32平衡车接线示例可辅助快速搭建硬件连接。资源包为1个PDF文件,约146KB,单文件便于随查随用;目前已有859人学习,适合用来对照编写驱动、核验时序并排查通讯故障。
1. PS2 手柄接到单片机:9 根线里藏着什么
把一只 PS2 手柄拆到单片机上用,难点从来不在接线,而在那只接收器只肯按固定节奏说话:CS 拉低、发0x01、等 ID、发0x42、等0x5A,九轮时钟换回一帧数据,错半个边沿读回来就是一片0xFF。这份《PS2 手柄通讯手册及使用说明》把引脚定义、握手命令、9 字节数据帧的位含义全部展开写了一遍,还给出了接收器到 STM32 平衡车的实际接法(DI→PB14、DO→PB15、CS→PB12、CLK→PB13)。它适合手上有一堆闲置手柄、想拿摇杆当廉价二维输入设备的嵌入式开发者,也适合刚接触软件 SPI、需要一份能对着示波器波形核对的时序参照的人。下面按引脚、时序、接线、代码、踩坑的顺序,把它落到一个真能跑的工程里。
2. 引脚与时序:9 个时钟边沿里读出一帧数据
2.1 九根引脚的责任边界
手册给的是标准 2.54mm 间距的 9 针排针,序号从 1 到 9 依次是DI/DAT、DO/CMD、NC、GND、VDD、CS/SEL、CLK、NC、ACK。这套命名方式同时给了两个名字,斜杠前是接收器侧的名字,斜杠后是主机侧的视角,看代码时容易搞混,先明确一遍方向:
| 序号 | 标识 | 方向 | 说明 |
|---|---|---|---|
| 1 | DI/DAT | 手柄 → 主机 | 8bit 串行数据,下降沿同步 |
| 2 | DO/CMD | 主机 → 手柄 | 8bit 串行数据,下降沿同步 |
| 3 | NC | — | 空脚 |
| 4 | GND | — | 电源地,必须与主控共地 |
| 5 | VDD | — | 接收器供电,3~5V |
| 6 | CS/SEL | 主机 → 手柄 | 片选/触发,通讯期间保持低电平 |
| 7 | CLK | 主机 → 手柄 | 时钟,由主机产生 |
| 8 | NC | — | 空脚 |
| 9 | ACK | 手柄 → 主机 | 应答,每 8bit 末周期拉低,编程时不用 |
DI和DO是两条独立的单向线,不是一根双向线,所以它本质上是全双工:你发一个字节的同时,手柄也在回一个字节。很多人第一次接的时候只看DI,把DO当成可以不接的输出,结果发不出命令,永远等不到0x5A。
ACK这一根手册写得很清楚——"在编程时未使用 ACK 端口"。它存在的意义是给原主机做外设存在性探测:如果 CS 没被拉低,大约 60 微秒后主机会去试下一个外设。自己做板子时这根脚悬空就行,别为了"接全"把它拉到某个 IO 上再写一堆无用逻辑。
CS/SEL是整条链路的开关。它在通讯期间必须一直是低电平,一帧 9 个字节发完才能松开。中间被拉高一次,手柄侧的状态机会复位,回来的数据从第几个字节开始错位就全看运气了,这是后面排查章节里出现频率最高的一类问题。
2.2 位级时序:为什么必须在下降沿采
手册对DI/DAT的描述是"同步传送于时钟的下降沿,信号的读取在时钟由高到低的变化过程中完成"。这句话翻译成 SPI 术语就是:时钟空闲时为高电平(CPOL=1),数据在第二个边沿也就是下降沿被采样(CPHA=1),合起来是 SPI 模式 3。数据位在上升沿之前就已经摆好,下降沿时双方同时锁存,谁也不用等谁。
按位来看,一个字节的交换过程是这样的:主机先把DO摆成本位的电平(低位在前,LSB first),然后拉低CLK,此时手柄把DI上的本位数据推出来,主机在CLK为低的这段时间里读DI,再拉高CLK,进入下一位。八个来回换一个字节,第九个字节结束时拉高CS,一次读帧收工。
时钟频率手册没给上限,这很容易让人误以为可以随便快。实际经验是:纯软件位敲时,半周期延时给到 5 微秒(约 100kHz)几乎不会翻车;压到 1 微秒以内,杜邦线稍微长一点就开始丢位。用硬件 SPI 时,把预分频设到能出 100~250kHz 就够,摇杆是人手推的,采样率 50Hz 已经完全够用,没必要为了"看起来快"把时钟拉到 1MHz 以上去赌时序余量。
| 时序参数 | 保守取值 | 说明 |
|---|---|---|
| CLK 半周期 | 5 μs | 对应约 100kHz,位敲首选 |
| CS 拉低到首字节 | ≥10 μs | 给接收器唤醒时间 |
| 帧间隔 | ≥20 μs | 两帧之间必须拉高 CS |
| 单帧耗时 | 约 0.8 ms | 9 字节 × 8 位 × 10 μs |
| 采样率上限 | 约 200 Hz | 50Hz 已足够 |
电磁环境差、线又长的时候,把半周期拉到 10 微秒更稳。先跑通再提速,这个顺序别反。
2.3 九字节帧结构:谁在第几轮说话
一次完整的读帧是 9 轮交换,每轮主机发一个字节、手柄回一个字节。手册那张"数据意义对照表"用的行号是 0 到 8,正好对应 9 轮:
| 轮次 | 主机发(DO) | 手柄回(DI) | 含义 |
|---|---|---|---|
| 0 | 0x01 | idle | 起始命令,手柄此时还在等 |
| 1 | 0x42 | ID | 手柄回0x41(绿灯)或0x73(红灯) |
| 2 | idle | 0x5A | 手柄声明"数据来了" |
| 3 | idle | data | SELECT、L3、R3、START、UP、RIGHT、DOWN、LEFT |
| 4 | idle | data | L2、R2、L1、R1、△、○、╳、□ |
| 5 | idle | data | PSS_RX,0x00=左、0xFF=右 |
| 6 | idle | data | PSS_RY,0x00=上、0xFF=下 |
| 7 | idle | data | PSS_LX,0x00=左、0xFF=右 |
| 8 | idle | data | PSS_LY,0x00=上、0xFF=下 |
所谓 idle,主机侧通常补0xFF,手柄侧在第 0 轮也回0xFF。这个值不是随便挑的,0xFF在按键语义里表示"全部松开",所以哪怕时序有轻微错位,读到的也是一个安全值而不是乱码。
第 1 轮返回的 ID 是整个协议的分叉点。0x41表示模拟绿灯模式,0x73表示模拟红灯模式。手册原文把0x41记作"模拟绿灯"、0x73记作"模拟红灯",两种都是模拟模式,区别只在于摇杆输出的是模拟量还是被映射成方向键。
第 3、4 字节是两个按键字节,注意按下时对应位为0、松开为1,也就是低电平有效。手册给的例子很直观:SELECT按下时Data[3] = 11111110B,只有最低位被拉成 0。
第 5 到第 8 字节是四个轴的模拟量,0x00到0xFF,中心位置在0x80附近。红灯模式下这四个字节是真实的比例值,左右推到极限才到0x00和0xFF;绿灯模式下摇杆被数字化,推到极限时直接变成第 3、4 字节里的 UP/RIGHT/DOWN/LEFT 和 △/○/╳/□,四个轴字节基本不动。这一点决定了你的代码要不要区分模式做两套解析。
3. 接线与供电:3~5V 边界上的三个决定
3.1 接收器到 STM32 的引脚映射
手册给的那张连接表是照着 STM32 平衡车项目写的,可以直接抄,只要按自己的 PCB 改 GPIO 号:
| 接收器引脚 | 序号 | 功能 | STM32 引脚 |
|---|---|---|---|
| DI/DAT | 1 | 数据入 | PB14 |
| DO/CMD | 2 | 数据出 | PB15 |
| NC | 3 | 空 | 不接 |
| GND | 4 | 地 | GND |
| VDD | 5 | 电源 | VCC |
| CS/SEL | 6 | 片选 | PB12 |
| CLK | 7 | 时钟 | PB13 |
| NC | 8 | 空 | 不接 |
| ACK | 9 | 应答 | 悬空 |
这四根信号线PB12~PB15不是随便排的,它们同属 GPIOB 且相邻,用寄存器整组读写时省事,也方便同时挂逻辑分析仪。选脚的时候顺手把这点考虑进去,调试阶段会舒服很多。
PB14配成上拉输入或浮空输入都行,但外部再并一个 10k 上拉到 VDD 更保险;PB15、PB12、PB13配成推挽输出,速度选低速或中速即可,反正也就 100kHz 上下。上电初始化时先把CS和CLK都拉高,让总线处于空闲态,再开始第一次握手。
3.2 供电选 3.3V 还是 5V
手册写 VDD 是 3~5V,这句话听着宽松,实际是两个不同的坑。
接 3.3V 时,接收器输出高电平大约 3.0~3.3V,和 STM32 的 IO 电平完全匹配,DI直连PB14没有任何风险,这是最省事的方案。手柄本体是电池供电,接收器只是转发,3.3V 完全带得动。
接 5V 时,接收器输出的高电平会接近 5V 直接怼到PB14上。STM32 的部分 IO 带 5V 容忍(数据手册里标 FT 的那些),但PB14在多数型号上并不是 FT 脚,长期倒灌会通过内部 ESD 二极管给 3.3V 轨充电,轻则读数飘,重则把引脚打死。要用 5V 供电就得做电平转换:两个电阻分压(10k + 20k)能应付 100kHz 这种低速场景,专用电平转换芯片更干净。
我一般的做法是统一 3.3V 供电,省掉一整块转换电路。如果接收器在 3.3V 下绿灯闪得厉害、配对不稳,再考虑单独给它一路 5V 并加分压。判断依据很简单:上电后接收器绿灯能稳定常亮,就说明供电没问题。
3.3 上拉、走线与上电检查
信号完整性在这个场景里比想象中重要。100kHz 的时钟边沿并不算陡,但杜邦线的分布电容能把边沿拉得很缓,接收器那边看到的就是一个"斜坡",采样点稍微偏一点就采错。血泪经验是:线长控制在 20cm 以内,超过 30cm 就开始出现隔几帧丢一次的玄学故障。
DI这根线上拉不能省。接收器侧的DI在空闲期间是高阻状态,没有上拉时电平悬空,读到的第一轮数据很容易是随机的0x00或者0xFF。10k 上拉到 VDD 就够,别用 1k,那样会把手柄侧的驱动能力拖垮。
上电顺序也有讲究。接收器和手柄都通电、手柄开关打开后才会自动配对,未配对时两边绿灯都在闪,配对成功后两边常亮。所以固件里最好给一段 500ms 的启动延时,等配对完成再开始握手,否则一开始几次读帧必然失败,白白触发一堆错误计数。
| 检查项 | 合格标准 | 不合格表现 |
|---|---|---|
| 共地 | 接收器 GND 与主控 GND 直连 | 读数随机漂移 |
| VDD 电压 | 3.3V ±5% | 绿灯闪烁不停、配对失败 |
| 线长 | ≤20cm | 隔几帧丢一次 |
| DI 上拉 | 10k 到 VDD | 首字节读到0x00 |
| CS 空闲态 | 高电平 | 手柄状态机反复复位 |
4. 固件落地:软件 SPI 位敲与 9 字节帧解析
4.1 位敲一个字节
先把最底层的一个字节交换写出来,整个协议都建在这十几个行上。下面的写法针对 STM32F1 系列,用 HAL 的 GPIO 接口,换成别的平台只要把宏改掉即可。
/* ps2_port.h —— 把引脚操作收敛到宏里,换平台只改这一处 */ #ifndef PS2_PORT_H #define PS2_PORT_H #include "stm32f1xx_hal.h" #define PS2_CS_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET) #define PS2_CS_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET) #define PS2_CLK_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_13, GPIO_PIN_RESET) #define PS2_CLK_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_13, GPIO_PIN_SET) #define PS2_CMD_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_15, GPIO_PIN_RESET) #define PS2_CMD_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_15, GPIO_PIN_SET) /* DI 是输入,带上拉,读到 1 表示该位为 1 */ #define PS2_DAT_READ() HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_14) #endif/* ps2.c —— 一次全双工字节交换 */ #include "ps2_port.h" /* 半周期延时。5us 对应约 100kHz,先用保守值跑通再谈提速 */ static inline void ps2_delay_us(uint32_t us) { /* 用 DWT 或简单循环都行,这里示意,实际按主频标定 */ for (volatile uint32_t i = 0; i < us * 8; i++) { __NOP(); } } /* * 发一个字节,同时收一个字节。 * CLK 空闲为高,每个位:拉低 CLK -> 摆好 CMD -> 读 DAT -> 拉高 CLK。 * 数据在下降沿被双方锁存,对应 SPI 模式 3(CPOL=1, CPHA=1)。 */ static uint8_t ps2_xfer(uint8_t cmd) { uint8_t rx = 0; for (int i = 0; i < 8; i++) { PS2_CLK_LOW(); /* 下降沿:双方锁存 */ if (cmd & 0x01) PS2_CMD_HIGH(); /* LSB first */ else PS2_CMD_LOW(); cmd >>= 1; ps2_delay_us(5); /* 给接收器推出 DI 的时间 */ if (PS2_DAT_READ()) rx |= (uint8_t)(1u << i); PS2_CLK_HIGH(); /* 上升沿:准备下一位 */ ps2_delay_us(5); } return rx; }几个点需要单独说明。第一,cmd >>= 1而不是<<= 1,因为协议是低位在前,这一点和标准 SPI 的 MSB first 相反,配硬件 SPI 时要记得把LSBFirst打开。第二,PS2_CLK_LOW()放在摆数据之前,是为了让数据在下降沿那一刻已经稳定,而不是边沿和电平同时变化。第三,延时函数的标定很重要,us * 8只是示意,实际要按主频调整,标错了整套时序就全歪,最好用示波器量一下CLK的实际周期。
4.2 组一帧并做两处校验
有了字节交换,组帧就是按表 2.3 的顺序发九个字节。关键是两个校验点:ID 必须是0x41或0x73,第三轮必须是0x5A。这两处任何一个不成立,说明链路根本没通,后面的数据一个字节都不能信。
/* ps2.h */ #ifndef PS2_H #define PS2_H #include <stdint.h> typedef struct { uint8_t mode; /* 0x41=绿灯(数字) 0x73=红灯(模拟) */ uint8_t btn1; /* 已取反,1 表示按下 */ uint8_t btn2; /* 已取反,1 表示按下 */ int16_t rx, ry, lx, ly; /* 已去偏置,范围约 -128 ~ +127 */ } ps2_frame_t; void ps2_init(void); int ps2_poll(ps2_frame_t *out); /* 0=成功,负数=错误码 */ void ps2_enter_analog(void); #endif/* 读一帧。返回 0 表示成功,-1 表示 ID 不对,-2 表示没收到 0x5A */ int ps2_poll(ps2_frame_t *out) { uint8_t buf[9]; PS2_CS_LOW(); ps2_delay_us(10); /* CS 拉低后必须等一下 */ buf[0] = ps2_xfer(0x01); /* 起始命令,手柄回 idle */ buf[1] = ps2_xfer(0x42); /* 请求数据,手柄回 ID */ buf[2] = ps2_xfer(0xFF); /* 手柄回 0x5A */ for (int i = 3; i < 9; i++) { buf[i] = ps2_xfer(0xFF); /* 6 个数据字节:按键 2 + 摇杆 4 */ } PS2_CS_HIGH(); ps2_delay_us(20); /* 帧间隔,别紧接着发下一帧 */ if (buf[1] != 0x41 && buf[1] != 0x73) return -1; if (buf[2] != 0x5A) return -2; out->mode = buf[1]; out->btn1 = (uint8_t)~buf[3]; /* 低有效,取反成"1=按下" */ out->btn2 = (uint8_t)~buf[4]; /* 摇杆原始值 0x00~0xFF,减去中心 0x80,得到有符号偏移 */ out->rx = (int16_t)buf[5] - 0x80; out->ry = (int16_t)buf[6] - 0x80; out->lx = (int16_t)buf[7] - 0x80; out->ly = (int16_t)buf[8] - 0x80; return 0; }buf[0]收回来的是 idle(0xFF),一般不参与判断;真要判断,就检查它是不是0xFF,因为按键全松开时它就是0xFF,可以作为链路健康的弱信号。buf[3]、buf[4]取反这一步特别容易漏,漏了之后的现象是"松开按键时全亮、按下全灭",第一次见会愣住。
把摇杆减去0x80得到有符号偏移,好处是后面做死区和滤波都是围绕 0 计算,控制回路里不用再记中心值。注意buf是uint8_t,buf[5] - 0x80会先做整型提升,结果是int,不会溢出成负数乱码,这一点 C 语言的整型提升帮了忙。
4.3 按键与摇杆解码
两个按键字节的位定义是手册明确给出的,最好写成一张表放在注释里,半年后回来看代码不至于重新翻手册:
| 位 | btn1(第 3 字节) | btn2(第 4 字节) |
|---|---|---|
| Bit0 | SELECT | L2 |
| Bit1 | L3 | R2 |
| Bit2 | R3 | L1 |
| Bit3 | START | R1 |
| Bit4 | UP | △ |
| Bit5 | RIGHT | ○ |
| Bit6 | DOWN | ╳ |
| Bit7 | LEFT | □ |
/* 按键枚举与读取,位上已经是"1=按下" */ enum { PS2_SELECT = 1u << 0, PS2_L3 = 1u << 1, PS2_R3 = 1u << 2, PS2_START = 1u << 3, PS2_UP = 1u << 4, PS2_RIGHT = 1u << 5, PS2_DOWN = 1u << 6, PS2_LEFT = 1u << 7 }; static inline int ps2_key_down(const ps2_frame_t *f, uint8_t mask) { return (f->btn1 & mask) != 0; } /* 绿灯模式下,摇杆被映射成方向键,硬件上没有独立的方向键 */ static inline int ps2_hat_up(const ps2_frame_t *f) { return ps2_key_down(f, PS2_UP) || (f->mode == 0x73 && f->ry < -64); }这里的-64是 128 的一半,也就是摇杆过半才认定为方向输入,避免临界点抖动。这个阈值在实机上要按手柄磨损情况调,老手柄中心漂移大,阈值就得放宽。
4.4 红灯绿灯:MODE 键与配置帧
手册给的方式很简单:配对成功后按手柄上的MODE键切换,红灯表示摇杆输出模拟值,绿灯表示摇杆映射成上面四个按键、只有四个极限方向有效。红灯模式下L3、R3(摇杆按下去的那两个键)也有效;绿灯模式下这两个键无效。
这意味着你的固件必须能容忍运行中途模式被切换。最稳妥的写法是每帧都看buf[1],把mode存进结构体,解码逻辑按模式分支——红灯模式用四个模拟轴,绿灯模式用方向位。别在初始化时读一次模式就当成常量,用户按一下MODE你的程序就全错。
另一种常见做法是不依赖手动按键,上电后主动发一串配置帧把接收器强制切进红灯模式。这套帧结构是 9 个字节一组,格式和普通读帧一样,只是命令字不同:
/* 常见做法:主动切到模拟红灯模式。命令字固定,参数在第 4 个字节。 * 手册正文只写了按 MODE 键切换,这套配置帧属于社区通行做法, * 不同批次接收器兼容性不一致,跑不通就退回手动按 MODE。 */ static void ps2_send_cmd(uint8_t cmd, uint8_t param) { PS2_CS_LOW(); ps2_delay_us(10); ps2_xfer(0x01); ps2_xfer(cmd); ps2_xfer(0x00); ps2_xfer(param); ps2_xfer(0x00); for (int i = 0; i < 4; i++) ps2_xfer(0x00); PS2_CS_HIGH(); ps2_delay_us(20); } void ps2_enter_analog(void) { ps2_send_cmd(0x43, 0x01); /* 进入配置 */ ps2_send_cmd(0x44, 0x01); /* 0x01=红灯模拟,0x00=绿灯数字 */ ps2_send_cmd(0x4D, 0x00); /* 振动电机归零 */ ps2_send_cmd(0x43, 0x00); /* 退出配置 */ }跑完这四步再读一帧,buf[1]应该稳定是0x73。如果还是0x41或者读出来是-1,说明这只接收器不吃这套配置帧,老老实实回到按键方案,别在这里耗。我见过的一个批次就是配置帧全部返回0xFF,但手动按MODE一切正常,判断依据就是"手动能切、程序不能切",结论是固件兼容性问题而不是硬件坏了。
5. 避坑与排查:读回全是 0xFF 的五个原因
5.1 五条踩坑记录
第一条:整帧全是0xFF,一个有效字节都没有。
现象是ps2_poll每次返回 -1,把buf打出来九个字节清一色0xFF,示波器上CLK有波形、DO也有波形,唯独DI是一条直线。原因基本是两个:DI没接或者接错脚,或者CS根本没拉低。手册写得很明白,CS在通讯期间必须处于低电平,CS不低手柄就认为主机没在跟它说话,DI一直保持高阻,上拉电阻把它拉成0xFF。解决方式是把CS用示波器两通道同时看,确认它在第一轮时钟开始之前就已经落下,而且整帧期间没有抬起过。如果CS波形正确但DI还是不动,那就是引脚映射错了,拿万用表对着接收器 1 脚量到PB14的通断。
第二条:ID 一会儿0x41一会儿0x73,程序行为跟着乱跳。
现象是静止不动放几分钟,mode在两个值之间随机变,摇杆数据也跟着时有时无。原因有两个方向:一是手柄被碰了MODE键,二是供电不稳导致接收器重新配对,重新上电后模式回到默认值。手册里写得很清楚,未配对时两边绿灯闪烁,配对成功后常亮,所以先看指示灯——如果绿灯在闪,说明是电源问题,不是协议问题。解决方式是给接收器单独加一颗 100μF 电容稳住电源,并且在固件里每帧判断mode,模式一变就重置滤波器状态,别让旧数据污染新模式的解算。
第三条:按键永远是"全部松开",怎么按都没反应。
现象是btn1、btn2读出来恒等于 0。原因是忘了取反。手册明确写了"当有按键按下,对应位为 0,其他位为 1",11111110B表示SELECT按下,这是低有效。如果不取反,全松开时读到0xFF,取用的时候按"非零即按下"判断就会变成"一直按下";反过来如果用"等于 1 才按下",就变成"永远不响应"。解决方式是统一在ps2_poll里做一次~,对外只暴露"1=按下"的语义,全工程不出现第二次取反。
第四条:摇杆推到极限才有反应,中间一段完全没输出。
现象是lx、ly在中间区域纹丝不动,推到快到底才突然跳到 ±120。原因是手柄处在绿灯模式,手册写明这个模式下"左右摇杆模拟值为无效,推到极限时对应发送 UP、RIGHT、DOWN、LEFT",四个轴字节本来就不输出比例值。判断依据是看mode是不是0x41。解决方式是切到红灯模式,或者干脆按绿灯模式的语义去读方向位,别指望在绿灯模式下拿到连续的模拟量。
第五条:运行几分钟后开始隔几帧丢一次,重启又好。
现象是刚上电很稳,跑一阵子开始出现 -2(收不到0x5A),频率随时间上升。原因是时钟太快加上线太长,边沿变缓后采样点漂移,温度一上来就更明显;也可能是CS拉高拉低之间没有留够帧间隔,手柄状态机还没复位就被打断。解决方式是把半周期延时从 5μs 加到 10μs 先验证,如果能稳住就说明是时序余量问题,然后再逐步往回压,找到临界点后留一倍余量。帧间隔给到 20μs 以上,别贪那点采样率。
5.2 一套从波形到代码的排查顺序
出问题时的顺序我一般是这样的,从物理层往上走,不跳步:
- 看灯。两边绿灯常亮=配对正常,闪烁=供电或配对问题,这一步能排掉一半的"故障"。
- 量电压。
VDD对GND是不是 3.3V,波动大不大。 - 看
CS。整帧期间是不是一直低,拉低到第一个CLK下降沿之间有没有 10μs 的间隔。 - 看
CLK。周期对不对,有没有毛刺,占空比是不是接近 50%。 - 看
DI。有没有跟着CLK变化,变化沿是不是在CLK下降沿之后。 - 最后才看代码。前面五步都正常,问题一定在解析逻辑,先把
buf九个字节全部打出来,对照表 2.3 逐字节核。
这个顺序的价值在于:前三步不需要改一行代码,五分钟内就能把硬件问题摘干净,避免在软件里瞎改半天最后发现是杜邦线松了。
6. 进阶:摇杆死区、滤波与失联判定
拿到原始值只是开始,真正把它用到控制回路里还有三件事要做。
第一件是死区。手柄用久了中心会漂,0x80只是理论中心,实测经常是0x83或者0x7C。做法是上电时静置采样 64 帧,把四个轴的平均值存成center[4],之后每次减掉它,再套一个死区:
/* 死区处理:绝对值小于 deadzone 的一律归零,避免静止时抖动 */ static int16_t apply_deadzone(int16_t v, int16_t center, int16_t deadzone) { int16_t d = v - center; if (d > -deadzone && d < deadzone) return 0; /* 超出死区的部分做线性拉伸,保证 0 附近连续 */ if (d > 0) return d - deadzone; else return d + deadzone; }deadzone取 6~10 比较合适,太小会抖,太大会让微操失效。老手柄磨损严重的话,这个值可能要放到 15,代价是精细操作变钝,得按实际手感权衡。
第二件是滤波。一阶低通就够,y = y + (x - y) / 4,系数取 4 相当于时间常数约 4 帧,在 50Hz 采样下响应延迟 80ms 左右,人手感觉不出来,但能把单帧跳变压掉大半。别用滑动平均,窗口一大延迟就上来了,平衡车这种要实时性的场景会明显发飘。
第三件是失联判定,这个最容易被忽略。连续 10 帧ps2_poll返回负值就置一个link_lost标志,控制回路读到这个标志立刻把目标速度归零并进入阻尼模式。手柄电池没电、线被扯掉、接收器掉电,这几种情况在读帧层面长得一模一样,唯一的区别只是恢复时间,所以统一按失联处理最安全。恢复条件也简单:连续 3 帧成功就清标志。
| 处理环节 | 参数 | 作用 |
|---|---|---|
| 中心校准 | 上电静置 64 帧均值 | 消除个体偏差 |
| 死区 | 6~10(原始 LSB) | 抑制静止抖动 |
| 一阶滤波 | 系数 1/4 | 平滑单帧跳变 |
| 失联判定 | 连续 10 帧失败 | 触发安全停机 |
| 恢复判定 | 连续 3 帧成功 | 清除失联标志 |
有个细节值得单独提:校准必须在进入红灯模式之后做。绿灯模式下四个轴字节不输出比例值,这时候采出来的"中心"是无效数据,拿它去算死区会让整个控制回路偏掉。
最后说个我自己的习惯。这套协议最坑的地方不是代码难写,而是它没有校验和、没有应答重传,链路一坏就只能靠两个魔数(ID 和0x5A)兜底,属于典型的黑匣子式协议。所以从那以后,我每次接新批次的手柄,都会先把九字节原始帧打到串口上,用半分钟时间空手推一遍摇杆、按一轮所有键,确认每个字节的变化规律和手册对得上,再动上层逻辑。这一步花不了几分钟,但能省掉后面几小时的"到底是硬件坏还是代码错"的自我怀疑。希望帮到你。
本文还有配套的精品资源,点击获取