☰
STM32 GPIO模拟时序读取PS2手柄:握手、解析与校准
2026/10/28 20:44:13 网站建设 项目流程

简介:《PS2 手柄通讯手册及使用说明》面向单片机、嵌入式初学者以及需要为平衡车、机器人接入PS2手柄的开发者,系统梳理接收器引脚定义、串行通讯时序与按键数据解析方法,并交代自动配对、MODE切换等基础操作。压缩包内含1个PDF文件,约146KB,篇幅精炼,适合作为接线和调试时的随查手册。文档围绕DI/DAT、DO/CMD、CS/SEL、CLK等信号说明主机与手柄的交互流程,给出0x01命令、0x41/0x73手柄ID、0x42请求及0x5A应答等关键协议节点,提示数据在时钟下降沿同步传送,ACK应答脚在常规编程中可不接。数据意义对照表逐位解释SELECT、START、方向键、△○╳□、L/R键与摇杆模拟值,说明按键按下时对应位为0,区分红灯模拟摇杆、绿灯方向键两种模式以及L3、R3有效性的差异。硬件部分以STM32平衡车为例,列出PB14、PB15、PB12、PB13等连接方式,已有859人学习,便于快速对照接线、编写驱动并排查通讯问题。

1. 从一台积灰的 PS2 手柄说起:为什么单片机项目还在用它

手上有一批拆机 PS2 手柄,接收器还带着九根线的排针,扔了可惜,插上单片机又能干活,这大概是很多人翻出这份通讯手册的起点。PS2 手柄由手柄本体和接收器两部分组成,手柄只负责发按键,接收器负责把它翻译成两路串行信号:主机拉低片选、打一串时钟,就能拿到按键和摇杆的 8 位原始值。它便宜、带双摇杆、握着比几个轻触按键舒服,做平衡车、机械臂示教、遥控小车、桌面转台的控制输入都够用。适合谁?手上已经有 STM32 或 Arduino、会写 GPIO 翻转、想找一个不用装驱动、几根杜邦线就能通的输入设备的人。坑也明摆着:它不是标准 SPI,时序得自己怼,读完不回0x5A是常事。后面几章把接线、时序、解析和翻车点一次讲透。

2. 九根线只接了六根:接收器引脚、电平与上电顺序

先把硬件这部分钉死。软件调不通的项目里,有一半是线接错或者电平不对,剩下的一半才是时序。接收器是 2.54mm 间距的 9 针排针,丝印上标着DI/DAT、DO/CMD、NC、GND、VDD、CS/SEL、CLK、NC、ACK,其中两个NC和一个ACK在实际项目里基本不用管。

2.1 六个功能脚的走向与接法

引脚方向比引脚名字更容易记混:DI/DAT是手柄往主机送数据,DO/CMD是主机往手柄发命令,名字里的 DI/DO 是站在手柄那一侧命名的,第一次接很容易反。下表按接收器排针序号列出实际接法,右列对应项目里那台平衡车用的 STM32 引脚,换板子时只改这一列。

序号丝印信号方向作用STM32 连接
1DI/DAT手柄 → 主机8bit 串行数据,时钟下降沿同步PB14(输入上拉)
2DO/CMD主机 → 手柄8bit 串行命令,与 DAT 相对PB15(推挽输出)
3NC—空脚悬空
4GND—电源地GND
5VDD—接收器供电,3~5V3.3V 或 5V
6CS/SEL主机 → 手柄片选/触发,通讯期间保持低PB12(推挽输出)
7CLK主机 → 手柄时钟,由主机发出PB13(推挽输出)
8NC—空脚悬空
9ACK手柄 → 主机应答,编程中一般不用悬空

CS/SEL这根线有个细节手册里写得很清楚:通讯期间它必须一直保持低电平,如果 CS 提前拉高,大约 60 微秒后主机会去试另一个外设。换句话说,CS 不是每个字节拉一次,而是整帧拉一次,九帧数据发完才放开。这一点和标准 SPI 的片选行为一致,但很多人第一次写的时候按字节拉,读出来永远是0xFF。

ACK这根线在每个 8bit 数据的最后一个周期会变低,表示手柄收到了。大部分开源实现直接不接,靠回读 ID 和0x5A判断握手是否成功,够用。真要做鲁棒性,可以把它接到一个外部中断上做超时统计,但不是必需。

2.2 3.3V 还是 5V:一个容易烧 IO 的选择

接收器的 VDD 标称 3~5V,两边都能工作,但选哪个直接决定信号线上的高电平有多高。

用 3.3V 供电最省心:DAT、CMD、CS、CLK 全是 3.3V 逻辑,直接怼 STM32 的普通 IO,不需要任何电平转换。代价是手柄和接收器之间的无线链路在 3.3V 下距离会短一些,一般桌面范围内没问题。

用 5V 供电时,接收器工作更稳,但 DAT 输出高电平会接近 5V。这时候要查芯片数据手册里 PB14 有没有标 FT(5V 容忍)。标了 FT 的引脚可以直连,没标的必须处理:串一个 1kΩ 电阻再并一个 3.3V 稳压管做钳位,或者用电阻分压(比如 10kΩ + 20kΩ)把 5V 压到 3.3V。分压会拖慢边沿,时钟频率就得往下压,这是后面时序章节要配合改的参数。

提示:不确定引脚是否 5V 容忍时,一律按不容忍处理,串电阻的成本远低于换芯片。

电源去耦别省。手柄是无线设备,配对和按键瞬间的电流波动比静态大,接收器 VDD 到 GND 之间就近放一个 100μF 电解加一个 0.1μF 瓷片,能明显减少偶发的读帧失败。

2.3 上电、配对与红绿灯模式确认

接线顺序建议先 GND、再 VDD、最后信号线,避免带电插拔时信号线先于地线接通。上电后观察接收器指示灯:未配对成功时绿灯闪烁,手柄上的灯也闪;配对成功后两边都常亮,这时候才能开始通讯。手柄电池仓没电是最常见的假故障,灯闪但读不到数据,先换电池。

配对完成后按一下手柄上的MODE键切换发送模式,这个键决定了摇杆数据怎么解释:

指示灯返回 ID摇杆行为L3/R3适用场景
绿灯0x41摇杆退化为四个方向键,只有极限位置有效无效只需要方向控制,省解析
红灯0x73摇杆输出 0x00~0xFF 模拟值有效比例控制、云台、平衡车

很多人调摇杆没反应,不是代码错,是手柄停在绿灯模式。上电后先读一次 ID,把0x41和0x73分别对应到「数字模式」和「模拟模式」,后面解析逻辑就清晰了。

2.4 GPIO 初始化:为什么不用硬件 SPI

STM32 的硬件 SPI 理论上能凑出这个时序,但 PS2 的采样点、CS 保持时间、字节间不停时钟这几个要求和标准 SPI 的四种模式都对不齐,调起来比软件模拟还费劲。我一般直接用四个普通 IO 模拟,好处是时序可以随时用示波器对着改,出问题也好定位。

static void ps2_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); /* CS / CLK / CMD:推挽输出,空闲态全部为高 */ gpio.Pin = GPIO_PIN_12 | GPIO_PIN_13 | GPIO_PIN_15; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &gpio); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12 | GPIO_PIN_13 | GPIO_PIN_15, GPIO_PIN_SET); /* DAT:输入,必须上拉。接收器驱动能力弱,靠上拉兜底 */ gpio.Pin = GPIO_PIN_14; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOB, &gpio); }

三个输出脚初始为高是关键:CLK 空闲高、CS 空闲高、CMD 空闲高,接收器才认为总线处于空闲态。DAT 用内部上拉,因为接收器在没轮到它输出的时候是高阻,没有上拉会读到随机值,表现出来就是「数据偶尔对、偶尔全 1」。速度选 HIGH 是为了边沿陡一点,虽然软件模拟用不到几十兆,但推挽加高速档在长杜邦线上波形更干净。

3. 位操作怼时序:0x01、0x42、0x5A 三段握手怎么写

硬件通了之后,剩下的全是时序。PS2 手柄的协议本质上是一次固定长度的事务:主机拉低 CS,发一个起始字节,手柄回 ID;主机再发请求字节,手柄回0x5A表示数据准备好了;接着连续交换 6 个字节,拿到按键和摇杆。所有数据都是 8bit、低位在前(LSB first),在时钟下降沿同步。

3.1 一帧数据的字节序列

按手册里的对照表,把主机发什么、手柄回什么排成一条时间线,写代码时对着这张表逐字节填就不会乱:

顺序主机发(DO/CMD)手柄回(DI/DAT)含义
00x01idle起始命令,回读值丢弃
10x42ID(0x41或0x73)请求数据,同时拿到模式
20x000x5A手柄确认数据到来
30x00dataSELECT、L3、R3、START、UP、RIGHT、DOWN、LEFT
40x00dataL2、R2、L1、R1、△、○、╳、□
50x00dataPSS_RX(0x00 左,0xFF 右)
60x00dataPSS_RY(0x00 上,0xFF 下)
70x00dataPSS_LX(0x00 左,0xFF 右)
80x00dataPSS_LY(0x00 上,0xFF 下)

第 0 个字节的回读值是无效的,因为手柄还没进入状态,很多实现会读到0xFF或者0x00,直接丢掉。真正有判断价值的是第 1 字节的 ID 和第 2 字节的0x5A,这两个都对了,后面 6 个字节才可信。

3.2 收发一体的位函数

因为 DAT 和 CMD 是两根独立的线,发送和接收是同时发生的,所以不能先写一个字节再读一个字节,得在一个循环里同步完成。下面这个函数每调用一次交换 8 个 bit,返回值就是从 DAT 上采到的字节。

#define PS2_DELAY_US 4 /* 半周期 4us,对应约 125kHz */ static void ps2_delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000U); while ((DWT->CYCCNT - start) < ticks) { } } static uint8_t ps2_xfer(uint8_t out) { uint8_t in = 0; for (uint8_t i = 0; i < 8; i++) { /* 1. 先把本位摆到 CMD 上,低位在前 */ if (out & 0x01) PS2_CMD_HIGH(); else PS2_CMD_LOW(); out >>= 1; /* 2. 时钟拉低,从机在下降沿之后把数据摆到 DAT 上 */ PS2_CLK_LOW(); ps2_delay_us(PS2_DELAY_US); /* 3. 低电平中点采样 DAT,此时数据最稳定 */ in >>= 1; if (PS2_DAT_READ()) in |= 0x80; /* 4. 时钟拉高,完成一个 bit 周期 */ PS2_CLK_HIGH(); ps2_delay_us(PS2_DELAY_US); } return in; }

PS2_DELAY_US这个宏是整段代码里唯一需要根据硬件调的参数。取值 4 对应约 125kHz 时钟,杜邦线短、供电 3.3V 时很稳;线长超过 20cm 或者用了 5V 加分压,建议改成 8~10,把时钟降到 50~60kHz。降速只会让一帧的耗时从 600μs 涨到 1.2ms,对 50Hz 轮询完全没影响,但能救回很多「偶尔读不到」的现场。

延时用 DWT 周期计数器而不是HAL_Delay,是因为后者最小分辨率是 1ms,根本量不出 4μs。启用 DWT 只要在初始化里加三行:

CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;

采样点放在时钟低电平的中点,而不是上升沿,是因为接收器在下降沿之后才更新 DAT,边沿附近有建立时间,中点采样留的余量最大。这一条是示波器上看出来的血泪经验:采样点贴着边沿时,常温下能跑,温度一动手柄就开始丢帧。

3.3 组帧与握手判断

有了位函数,组帧就是按表填字节。CS 在整帧开始前拉低、结束后拉高,中间九个字节连续发完,中途不能放开。

typedef struct { uint8_t mode; /* 0x41 绿灯 / 0x73 红灯 */ uint8_t btn1, btn2; /* 按键原始字节,按下为 0 */ uint8_t rx, ry, lx, ly; /* 摇杆原始值 */ } ps2_raw_t; uint8_t ps2_read_frame(ps2_raw_t *pad) { uint8_t buf[9]; /* 总线回到空闲态,等接收器准备好 */ PS2_CS_HIGH(); PS2_CLK_HIGH(); PS2_CMD_HIGH(); ps2_delay_us(10); PS2_CS_LOW(); ps2_delay_us(10); /* CS 拉低后给接收器一点反应时间 */ buf[0] = ps2_xfer(0x01); /* 起始命令,回读值丢弃 */ buf[1] = ps2_xfer(0x42); /* 请求数据,回读 ID */ buf[2] = ps2_xfer(0x00); /* 回读应为 0x5A */ for (uint8_t i = 3; i < 9; i++) { buf[i] = ps2_xfer(0x00); } PS2_CS_HIGH(); ps2_delay_us(10); /* 两级握手校验,任一失败都认为本帧无效 */ if (buf[2] != 0x5A) return 0; if (buf[1] != 0x41 && buf[1] != 0x73) return 0; pad->mode = buf[1]; pad->btn1 = buf[3]; pad->btn2 = buf[4]; pad->rx = buf[5]; pad->ry = buf[6]; pad->lx = buf[7]; pad->ly = buf[8]; return 1; }

ps2_read_frame返回 0 不代表硬件坏了,只代表这一帧不可信。实际项目里我一般连续读三次,取第一次成功的结果,三次都失败才置错误标志。这样偶发的电磁干扰不会直接让控制逻辑跳变。

CS 拉低后的 10μs 延时不是可有可无。接收器内部有个状态机,CS 下降沿到它开始响应之间有延迟,省掉这段延时,第一个字节经常读成0xFF。这个值和PS2_DELAY_US一样,属于「示波器上看不出来、但少了就翻车」的参数。

4. 把 8 个 bit 拆成按键:数据帧解析与摇杆归一化

拿到 9 个字节只是原料,真正给控制逻辑用的是「哪个键按下了」和「摇杆偏了多少」。这一步的坑集中在两个地方:按键是反逻辑,摇杆的零点不在 0x80。

4.1 按键位是反逻辑:0 才算按下

手册里写得很直白:当有按键按下,对应位为 0,其他位为 1。比如 SELECT 被按下时,Data[3] = 11111110B。所以解析的第一步是取反,把「0 有效」翻译成「1 有效」,再映射成 16 位掩码,上层代码就只用关心if (btn & PS2_START)。

#define PS2_SELECT 0x0001 #define PS2_L3 0x0002 #define PS2_R3 0x0004 #define PS2_START 0x0008 #define PS2_UP 0x0010 #define PS2_RIGHT 0x0020 #define PS2_DOWN 0x0040 #define PS2_LEFT 0x0080 #define PS2_L2 0x0100 #define PS2_R2 0x0200 #define PS2_L1 0x0400 #define PS2_R1 0x0800 #define PS2_TRIANGLE 0x1000 #define PS2_CIRCLE 0x2000 #define PS2_CROSS 0x4000 #define PS2_SQUARE 0x8000 uint16_t ps2_buttons(const ps2_raw_t *p) { uint16_t b = 0; uint8_t lo = (uint8_t)~p->btn1; /* 取反:按下变 1 */ uint8_t hi = (uint8_t)~p->btn2; if (lo & 0x01) b |= PS2_SELECT; if (lo & 0x02) b |= PS2_L3; if (lo & 0x04) b |= PS2_R3; if (lo & 0x08) b |= PS2_START; if (lo & 0x10) b |= PS2_UP; if (lo & 0x20) b |= PS2_RIGHT; if (lo & 0x40) b |= PS2_DOWN; if (lo & 0x80) b |= PS2_LEFT; if (hi & 0x01) b |= PS2_L2; if (hi & 0x02) b |= PS2_R2; if (hi & 0x04) b |= PS2_L1; if (hi & 0x08) b |= PS2_R1; if (hi & 0x10) b |= PS2_TRIANGLE; if (hi & 0x20) b |= PS2_CIRCLE; if (hi & 0x40) b |= PS2_CROSS; if (hi & 0x80) b |= PS2_SQUARE; return b; }

位序是从 Bit0 往 Bit7 排,和手册里的 SELECT、L3、R3、START 顺序严格对应。写反了不会报错,只会出现「按 START 触发了 SELECT」这种看着像玄学的现象。第一次接新批次手柄,建议逐个键按一遍打日志核对。

4.2 红灯和绿灯模式下摇杆的区别

同一个摇杆字节,在两种模式下含义完全不同,这是新手最容易绕晕的地方。

红灯模式(ID0x73)下,PSS_LX、PSS_LY、PSS_RX、PSS_RY输出 0x00~0xFF 的连续值,L3、R3 按下也有效。摇杆推到底,值会贴着 0x00 或 0xFF,中间位置大约在 0x80 附近,但每支手柄都不一样。

绿灯模式(ID0x41)下,摇杆模拟值无效,推到底时手柄会把它翻译成UP、RIGHT、DOWN、LEFT和△、○、╳、□这八个按键位,摇杆字节本身要么固定、要么无意义,L3、R3 也失效。做比例控制必须切到红灯模式,只做方向控制用绿灯模式反而省事,因为不用处理死区和漂移。

4.3 从 0x00~0xFF 到 -100~100

电阻式摇杆的零点有离散性,标称 0x80,实测常在 0x7A 到 0x86 之间。直接拿raw - 0x80做控制,松手时小车会自己慢慢走。正确做法是先减零点、再过死区、最后按两侧行程分别归一化,因为零点偏左时左右两边的最大行程不相等。

typedef struct { int16_t x; /* -100 ~ 100,右为正 */ int16_t y; /* -100 ~ 100,下为正 */ } ps2_axis_t; static int16_t axis_norm(uint8_t raw, uint8_t center, uint8_t dead) { int16_t d = (int16_t)raw - (int16_t)center; /* 死区:中心附近直接归零,抑制松手漂移 */ if (d > -(int16_t)dead && d < (int16_t)dead) return 0; if (d > 0) { int16_t span = 255 - center; /* 零点到上端的实际行程 */ if (span <= 0) span = 1; return (int16_t)((int32_t)d * 100 / span); } else { int16_t span = center; /* 零点到下端的实际行程 */ if (span <= 0) span = 1; return (int16_t)((int32_t)d * 100 / span); } }

center传校准得到的实际零点,dead一般取 6~10,对应 2%~4% 的行程。死区太大操作手感发木,太小松手会抖,8 是个不错的起点。两侧行程分开算的好处是推到极限一定能到 ±100,不会因为零点偏移导致一边只能到 92、另一边能到 100,做 PID 时输出不对称。

4.4 打包成一个对上层友好的结构体

把原始帧、按键掩码、归一化摇杆合成一个结构体,控制层就不用关心位操作了。

typedef struct { uint16_t buttons; ps2_axis_t left; ps2_axis_t right; uint8_t analog; /* 1 = 红灯模拟模式 */ uint32_t stamp_ms; /* 本帧时间戳,用于判超时 */ } ps2_state_t; void ps2_decode(const ps2_raw_t *raw, ps2_state_t *st) { st->buttons = ps2_buttons(raw); st->analog = (raw->mode == 0x73); if (st->analog) { st->right.x = axis_norm(raw->rx, center[0], 8); st->right.y = axis_norm(raw->ry, center[1], 8); st->left.x = axis_norm(raw->lx, center[2], 8); st->left.y = axis_norm(raw->ly, center[3], 8); } else { /* 绿灯模式摇杆无效,用方向键合成 ±100 */ st->left.x = (st->buttons & PS2_RIGHT) ? 100 : (st->buttons & PS2_LEFT) ? -100 : 0; st->left.y = (st->buttons & PS2_DOWN) ? 100 : (st->buttons & PS2_UP) ? -100 : 0; st->right.x = st->right.y = 0; } st->stamp_ms = HAL_GetTick(); }

analog这个字段值得单独留出来,上层做模式切换时不用再回头查 ID。stamp_ms是给超时判断用的:超过 100ms 没更新,说明手柄关机或跑远了,控制逻辑应该回中位而不是保持上一帧的值,否则小车会带着最后一脚油门冲出去。

5. 灯闪、读到 0xFF、摇杆乱跳:五个高频翻车点排查

前面几章的代码照抄能跑通大部分手柄,但现场总有不顺的时候。下面五条是调试时出现频率最高的,每条按现象、原因、解决三段写,遇到问题按顺序对号入座。

5.1 读回全是 0xFF 或全 0x00

现象:串口打印出来九个字节不是0xFF就是0x00,ID 和0x5A一个都没有。

原因通常是三类:CS 根本没拉低,接收器没被选中;CLK 空闲电平不是高,接收器认为总线一直有时钟;DAT 没上拉,接收器高阻时读到的就是浮空值。还有一种是线和编号对反了,把 DI 和 DO 接串了。

解决:先拿万用表量 CS 在读数瞬间是不是真的到了 0V,再量 CLK 空闲时是不是 3.3V。DI 和 DO 一定按「DI 接输入、DO 接输出」接,别按名字直觉接。最后确认 DAT 那根线在 CubeMX 或初始化代码里配了上拉,外部再并一个 10kΩ 到 VDD 更保险。

5.2 静态能读,手一碰手柄就丢帧

现象:手柄放桌上不动,读帧成功率 100%;拿起来按几个键,成功率掉到七八成,甚至连续失败。

原因基本是时序余量不够,或者杜邦线太长、接触不良。软件模拟 SPI 对线材很敏感,20cm 以上的排线在 125kHz 下边沿已经明显变缓,采样点又贴着边沿,稍微一动就采错。供电不足也会这样,无线发射瞬间拉低 VDD,接收器复位。

解决:先把PS2_DELAY_US从 4 改到 8,把时钟降到 60kHz 左右;再把杜邦线换短、换粗,信号线尽量远离电机线;最后在接收器 VDD 附近补 100μF 电容。三招做完还不稳,就把接收器单独用 5V 供电,DAT 上加分压或串 1kΩ 电阻。

5.3 按键连发、按一下触发好几次

现象:按一次 START,日志里出现三到五次按下事件,做菜单时选项直接跳好几格。

原因是没有做软件消抖。手柄内部的导电胶加机械结构,按下和松开都有几毫秒的抖动,20ms 轮询一次的话,抖动正好落在两帧之间。

解决:在应用层加一个简单的状态机消抖,连续两帧读到相同状态才认为有效,或者记录上次变化时间,20ms 内的重复变化直接忽略。别在驱动层做长延时消抖,会拖慢整帧读取。

5.4 摇杆松手后数值不回中

现象:摇杆放开,PSS_LX稳定停在 0x76 或 0x8B,不是 0x80,映射成 -100~100 后是一个固定的小偏移。

原因是每支手柄的电位器中点都有离散性,用了几年之后碳膜磨损,零点偏移更大。拿标称 0x80 当零点,必然有残差。

解决:上电做一次零点校准。手柄放平不动,连续读 64 帧,四个轴各取平均值作为center[0..3],后面归一化都用这个实测零点。校准值可以存到 Flash 里,下次上电直接加载。校准期间别碰摇杆,否则零点会被带偏。

5.5 换了 5V 供电后读数飘忽甚至 IO 发烫

现象:改成 5V 供电后,ID 时而0x41时而0xFF,摸 PB14 附近的芯片明显发热。

原因是 DAT 输出 5V 高电平,打到了不耐 5V 的输入脚上,内部保护二极管长期导通。读数飘是因为保护电路把电平钳在 3.3V 加一个二极管压降,高低电平判据变模糊。

解决:立刻断电。查数据手册确认 PB14 是否标 FT,不是的话串 1kΩ 电阻并在引脚侧加 3.3V 钳位二极管,或者干脆换回 3.3V 供电。已经发烫的引脚大概率伤了,换一个 IO 重接,别抱侥幸。

6. 从阻塞到按拍轮询:50Hz 闸门、零点校准与一次可视化验证

代码能读通之后,真正决定项目稳不稳的是「多久读一次」和「读到的数据怎么验证」。我见过把ps2_read_frame塞进 1ms 定时中断的实现,一次事务 600μs,中断里堵了一半时间,串口和电机 PWM 全乱。正确做法是把读取放到主循环或者一个低频任务里,用时间戳做闸门。

6.1 用时间戳做 20ms 闸门

static uint32_t last_poll = 0; static uint8_t lost_cnt = 0; ps2_state_t g_pad; void ps2_task(void) { uint32_t now = HAL_GetTick(); if (now - last_poll < 20) return; /* 50Hz 足够,别更快 */ last_poll = now; ps2_raw_t raw; if (ps2_read_frame(&raw)) { lost_cnt = 0; ps2_decode(&raw, &g_pad); } else { if (++lost_cnt >= 5) { /* 连续 5 帧失败才判掉线 */ g_pad.buttons = 0; g_pad.left.x = g_pad.left.y = 0; g_pad.right.x = g_pad.right.y = 0; g_pad.analog = 0; } } }

闸门间隔 20ms 对应 50Hz,人手操作根本不需要更高频率,反而给其他任务留出 CPU。lost_cnt连续 5 次才判掉线,是为了过滤偶发干扰;一旦判掉线就把所有输出清零,比保持上一帧安全得多。这个函数放在while(1)里轮询,或者挂到一个 10ms 的软件定时器里都行,别放中断。

6.2 上电零点校准的实现

校准放在初始化之后、进入主循环之前,要求手柄已配对、摇杆静止。

uint8_t center[4] = {0x80, 0x80, 0x80, 0x80}; void ps2_calibrate(void) { uint32_t sum[4] = {0}; uint16_t n = 0; ps2_raw_t raw; while (n < 64) { if (ps2_read_frame(&raw) && raw.mode == 0x73) { sum[0] += raw.rx; sum[1] += raw.ry; sum[2] += raw.lx; sum[3] += raw.ly; n++; } HAL_Delay(5); } for (uint8_t i = 0; i < 4; i++) { uint32_t avg = sum[i] / 64; /* 限幅,防止校准期间有人碰摇杆把零点带飞 */ if (avg < 0x60) avg = 0x60; if (avg > 0xA0) avg = 0xA0; center[i] = (uint8_t)avg; } }

64 次平均能把随机噪声压下去,限幅到 0x60~0xA0 是后悔药:校准期间如果有人手搭在摇杆上,平均值会跑到 0x30 或者 0xD0,归一化时一侧行程直接变成负数。限幅之后最坏情况只是手感偏一点,不会算出离谱的值。

6.3 用串口做一次可视化验证

调通之后别急着接执行机构,先花十分钟做一次全键位验证。把ps2_task的输出按下面这张表逐项打串口,对一遍再往下走。

验证项操作方法期望结果
模式识别按 MODE 键切换ID 在0x41和0x73之间切换
按键位序依次单按 SELECT、START、△、○、╳、□掩码只有对应位为 1,无串位
摇杆方向左右摇杆分别推四个方向对应轴正负正确,另一轴接近 0
行程满量摇杆推到底保持归一化值到 ±100,不截断在 80 多
回中松手静置 3 秒四轴稳定为 0,无缓慢漂移
掉线保护关掉手柄电源100ms 内所有输出归零

这张表我每次换手柄批次都会重跑一遍。看着啰嗦,但能提前发现批次差异,比如某批手柄的按键位序和手册不一致,或者绿灯模式下摇杆字节不是固定值。

6.4 一个我坚持了很多年的习惯

从那以后我每次上手一支新拆机手柄,都强制走一遍「读 ID、打全键、推满行程、关电看归零」这四步,不管它看起来多正常。这套流程花不了五分钟,但能挡掉后面调试控制逻辑时一半的怀疑:数据源是干净的,问题一定在别处。PS2 手柄这套协议老归老,时序摸清之后极其稳定,真正难的是第一次把 CS、CLK、DAT 三根线的脾气对上。希望帮到你。

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

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

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

立即咨询