☰
PS2手柄接STM32单片机:软件SPI时序与9字节帧解析
2026/10/28 3:59:08 网站建设 项目流程

简介:《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。这套命名方式同时给了两个名字,斜杠前是接收器侧的名字,斜杠后是主机侧的视角,看代码时容易搞混,先明确一遍方向:

序号标识方向说明
1DI/DAT手柄 → 主机8bit 串行数据,下降沿同步
2DO/CMD主机 → 手柄8bit 串行数据,下降沿同步
3NC—空脚
4GND—电源地,必须与主控共地
5VDD—接收器供电,3~5V
6CS/SEL主机 → 手柄片选/触发,通讯期间保持低电平
7CLK主机 → 手柄时钟,由主机产生
8NC—空脚
9ACK手柄 → 主机应答,每 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 ms9 字节 × 8 位 × 10 μs
采样率上限约 200 Hz50Hz 已足够

电磁环境差、线又长的时候,把半周期拉到 10 微秒更稳。先跑通再提速,这个顺序别反。

2.3 九字节帧结构:谁在第几轮说话

一次完整的读帧是 9 轮交换,每轮主机发一个字节、手柄回一个字节。手册那张"数据意义对照表"用的行号是 0 到 8,正好对应 9 轮:

轮次主机发(DO)手柄回(DI)含义
00x01idle起始命令,手柄此时还在等
10x42ID手柄回0x41(绿灯)或0x73(红灯)
2idle0x5A手柄声明"数据来了"
3idledataSELECT、L3、R3、START、UP、RIGHT、DOWN、LEFT
4idledataL2、R2、L1、R1、△、○、╳、□
5idledataPSS_RX,0x00=左、0xFF=右
6idledataPSS_RY,0x00=上、0xFF=下
7idledataPSS_LX,0x00=左、0xFF=右
8idledataPSS_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/DAT1数据入PB14
DO/CMD2数据出PB15
NC3空不接
GND4地GND
VDD5电源VCC
CS/SEL6片选PB12
CLK7时钟PB13
NC8空不接
ACK9应答悬空

这四根信号线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 字节)
Bit0SELECTL2
Bit1L3R2
Bit2R3L1
Bit3STARTR1
Bit4UP△
Bit5RIGHT○
Bit6DOWN╳
Bit7LEFT□
/* 按键枚举与读取,位上已经是"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 一套从波形到代码的排查顺序

出问题时的顺序我一般是这样的,从物理层往上走,不跳步:

  1. 看灯。两边绿灯常亮=配对正常,闪烁=供电或配对问题,这一步能排掉一半的"故障"。
  2. 量电压。VDD对GND是不是 3.3V,波动大不大。
  3. 看CS。整帧期间是不是一直低,拉低到第一个CLK下降沿之间有没有 10μs 的间隔。
  4. 看CLK。周期对不对,有没有毛刺,占空比是不是接近 50%。
  5. 看DI。有没有跟着CLK变化,变化沿是不是在CLK下降沿之后。
  6. 最后才看代码。前面五步都正常,问题一定在解析逻辑,先把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)兜底,属于典型的黑匣子式协议。所以从那以后,我每次接新批次的手柄,都会先把九字节原始帧打到串口上,用半分钟时间空手推一遍摇杆、按一轮所有键,确认每个字节的变化规律和手册对得上,再动上层逻辑。这一步花不了几分钟,但能省掉后面几小时的"到底是硬件坏还是代码错"的自我怀疑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询