简介:这是一份基于STM32CubeMX与HAL库的PS/2解码完整工程,面向嵌入式开发者,解决键盘鼠标PS/2协议数据解析需求,适用于需要快速上手串行通信与中断处理的STM32学习场景。压缩包共176个文件,包含62个HAL库头文件、24个C源码文件及定时器/串口/SPI驱动实现,还有工程配置与编译输出文件,整体约5.07MB,结构完整便于直接参考。已有969人学习下载。工程展示了从CubeMX初始化GPIO与上拉配置、编写时钟下降沿检测与奇偶校验逻辑,再到按键事件与鼠标移动帧解析的完整流程;借助HAL库高级API,开发者可专注于协议解码与应用逻辑,避免直接操作底层寄存器。通过学习可掌握PS/2通信时序、HAL库框架及CubeMX生成代码的组织方式,为后续类似外设驱动开发提供可复用范例,也可作为课程设计参考。
1. STM32 做 PS2 解码,卡点从来不在协议,而在时序
用 CUBEMX + HAL 库做 PS2 解码工程,第一反应往往是“接两根线读数据就行”,实际跑起来才发现,键盘没有任何响应、串口打出来全是 0xFF、鼠标一动就死机。这些现象背后不是代码逻辑错,而是对 PS2 协议里的时钟边沿、开漏结构和设备侧时序没有做对。PS2 接口虽然是老设备时代的产物,但在工业键盘、收银机、嵌入式人机交互里仍然常见,很多带 PS2 接口的工业设备不会因为 USB 普及而消失。这个工程的核心价值,是把 CUBEMX 图形化配置和 HAL 库事件回调结合起来,用一套稳定的状态机完成字节接收和命令发送。
这篇文章面向两类人:一类是刚接触 PS2 解码,想把键盘或鼠标接进自己的板子,需要一份能直接抄的工程思路;另一类是已经跑通基础接收,但经常遇到乱码、奇偶校验失败、命令超时,想知道边界在哪、怎么排查。我会从协议帧结构讲起,再给完整配置步骤和代码,最后集中写踩坑记录。整个过程不依赖厂商私有库,只靠 CUBEMX 生成的 GPIO 和外部中断,加上 HAL 库的标准回调,就能在 F103、F4 或 G0 系列上移植。
2. PS2 协议帧与解码路径:先搞懂时钟和数据的关系
2.1 一帧到底长什么样:起始位、8 位数据、奇偶校验、停止位
PS2 协议是双向同步串行,时钟线 CLK 和数据线 DATA 都是开漏结构,默认被上拉到高电平。设备侧(键盘或鼠标)负责产生时钟,主机侧(STM32)只是读取时钟沿和数据电平。每个字节的传输以起始位开始,先拉低 DATA,然后设备拉低 CLK 表示数据有效,并在后续的每个时钟下降沿发送一位数据。完整的帧结构是:1 位起始位(低电平),8 位数据位(LSB first),1 位奇校验位,1 位停止位(高电平)。一帧总共 11 位,时钟频率大概在 10kHz 到 16.7kHz 之间,早期键盘大约每秒传输 10 到 20 字节。
这里需要特别注意奇偶校验的计算方式。PS2 用的奇校验要求数据位和校验位加起来 1 的个数为奇数。也就是说,先数 8 个数据位里有多少个 1,如果 1 的个数是偶数,校验位就置 1;如果是奇数,校验位就置 0。这个规则容易和串口常见的偶校验搞混,一旦校验逻辑写反,解出来的字节就会莫名奇偶错误。我在做解码时一般把起始位丢掉,只保留中间的 8 位数据,再用查表法或逐位异或计算奇偶。
主机接收时的核心工作不是“读数据”,而是“读时钟下降沿”。因为数据在时钟下降沿时已经稳定,正确做法是在 STM32 上给 CLK 配一个下降沿触发的外部中断,在中断服务函数里立即读取 DATA 引脚电平,然后移位进一个缓冲区。千万不要用轮询方式去等时钟沿,因为 PS2 时钟不是主机产生的,轮询会丢失沿,尤其在系统里跑着其他中断时。
2.2 两种解码路径:外部中断 + GPIO 读取,还是定时器输入捕获
我见过不少方案用定时器输入捕获来测 CLK 脉宽,通过计算高低电平持续时间还原数据位。这种思路可以工作,但对定时器配置的要求更高,而且需要处理边沿抖动和噪声,代码复杂度明显增加。对于 PS2 这种 10-16kHz 的低速信号,外部中断加 GPIO 读取已经是绝大多数从业者的首选。STM32 的 GPIO 翻转速度足够快,外部中断响应时间在微秒级,PS2 时钟高电平持续时间大约 30-50us,完全来得及。
选择外部中断路径的另一个原因是 CUBEMX 配置简单。CLK 引脚配成下降沿外部中断,DATA 引脚配成输入模式,然后在 HAL_GPIO_EXTI_Callback 里读一次 DATA 电平,记录到一个 8 位变量里。这样中断回调里只做“移位 + 计数 + 判断”的动作,不做复杂计算,避免在中断里耽误太久。相比之下,定时器输入捕获还需要额外配置捕获通道和极性,如果只是单纯解码,性价比不高。
但定时器路径并非没用武之地。当你的系统里已经有多个中断源,而且 PS2 接线较长导致信号边沿有抖动时,用定时器捕获可以给每个沿打时间戳,再配合软件滤波。我一般只在 PS2 线缆超过 1.5 米或者现场有电机干扰时才考虑这个方案,普通桌面环境下外部中断足够。后面的配置和代码都以外部中断路径为准。
3. 用 CUBEMX 配置最小硬件资源:两个 GPIO 加一个中断回调
3.1 引脚规划和开漏模式:CLK 与 DATA 的电气细节
PS2 的 CLK 和 DATA 都是开漏结构,设备内部已经有上拉电阻,所以 STM32 侧不能配成推挽输出,否则会拉低电平或者产生总线冲突。CUBEMX 里把 CLK 和 DATA 都配成输入模式,典型配置是 GPIO_INPUT,外加内部上拉。实际接入时,为了防止意外短路,我通常还会在 CLK 和 DATA 线上各串一个 1kΩ 电阻,这个电阻不参与逻辑,纯粹是保护作用。如果板上已经有人体静电防护器件,那串不串都行。
具体引脚选择上,CLK 必须连接到支持外部中断的引脚。F103 系列里 PA0-PA15、PB0-PB15、PC0-PC15 都可以做外部中断,但每个引脚编号对应同一路 EXTI 线,比如 PA0 和 PB0 都使用 EXTI0,同一个 EXTI 线只能分配一个引脚。CUBEMX 会自动检查冲突,如果键盘的 CLK 接到 PA0,鼠标的 CLK 又接到 PB0,就配置不过去。所以二合一方案通常把键盘和鼠标放在不同的 EXTI 线上,比如 PA0 和 PA1。DATA 引脚没有中断要求,普通输入即可,但建议也选在同一个 GPIO 端口内,方便代码里用 GPIOA 这类端口统一访问。
在 CUBEMX 的 GPIO 配置界面里,CLK 引脚要选择 External Interrupt Mode with Falling edge trigger detection,然后在上拉/下拉里选择 Pull-up。DATA 引脚选择 Input mode,同样上拉。额外需要打开 NVIC 里的 EXTI line 中断,优先级建议设低一点,比如 2 或 3,避免和系统滴答、串口中断抢优先级。PS2 解码中断里只做移位和计数,理论上优先级低一些也不丢数据,只要不在中断里调用 HAL_Delay 就行。
3.2 生成工程前必须调整的两个参数:NVIC 优先级和代码生成选项
CUBEMX 生成工程时,有两个容易被忽略的选项会直接影响后续调试。第一个是 NVIC 优先级分组,通常选择优先级分组 2(两位抢占优先级 + 两位子优先级),这样外设中断之间可以区分抢占关系。第二个是外设初始化代码的生成方式,在 Project Manager 里的 Code Generator 标签下,建议选择 “Generate peripheral initialization as a pair of .c/.h files per peripheral”,这样每个外设的初始化和回调函数分文件管理,后期定位问题不会在一大堆注释里找代码。
还有一个经验:如果使用 HAL 库的外部中断回调,必须在 main.c 里重写 HAL_GPIO_EXTI_Callback。这个回调是弱函数,不需要在头文件里声明,直接在 main.c 函数末尾添加即可。CUBEMX 不会自动生成这个函数的框架,但 HAL 库会在 stm32f1xx_hal_gpio.c 里定义默认的 Weak 版本,你重写之后会链接到你的版本。只要函数签名一致,就能正确响应中断。我习惯在 HAL_GPIO_EXTI_Callback 里先判断 GPIO_Pin,然后调用自己写的解码函数,这样代码结构清楚。
生成工程后,先不要写任何业务代码,直接用一段日志打印 CLK 引脚的电平变化,确认硬件连接正确。做法是在主循环里轮询 HAL_GPIO_ReadPin,把 CLK 电平通过串口打印出来。如果键盘没插电,CLK 应该一直是高电平;插上电之后按任意键,CLK 会短暂变成低电平。如果这一步都看不到变化,说明接线或者供电有问题,后面调解码就是空中楼阁。
4. 解码代码落地:接收状态机、命令发送与最小测试工程
4.1 接收状态机:在 EXTI 回调里组装一个字节
PS2 接收最好是状态机,而不是每个时钟沿都进同一个分支判断。简单说,一帧有 11 位:起始位、8 位数据、校验位、停止位。我们只在 CLK 下降沿触发中断,所以每个沿对应一位。状态可以用一个位索引变量表示,从 0 到 10。第一个沿收到的是起始位,应该为低;第 2 到第 9 个沿收到的是数据位;第 10 个沿是校验位;第 11 个沿是停止位,应该为高。停止位之后,一帧结束,把组装好的字节和校验状态交给上层。
下面是一个最小实现,用 uint8_t 存储位索引和数据缓冲区。因为 PS2 数据是 LSB first,所以每收到一个数据位就把它放到字节的最高位,或者用左移累计。这样最终得到的字节顺序是对的。
#define PS2_CLK_PIN GPIO_PIN_0 #define PS2_CLK_PORT GPIOA #define PS2_DATA_PIN GPIO_PIN_1 #define PS2_DATA_PORT GPIOA static uint8_t ps2_bit_index = 0; // 当前收到的位索引,从0到10 static uint8_t ps2_data_shift = 0; // 数据位左移缓存 static uint8_t ps2_parity_bit = 0; // 收到的校验位 static volatile uint8_t ps2_rx_data = 0; static volatile uint8_t ps2_rx_complete = 0; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == PS2_CLK_PIN) { uint8_t data_level = HAL_GPIO_ReadPin(PS2_DATA_PORT, PS2_DATA_PIN); if (ps2_bit_index == 0) { // 起始位,应该为低电平 if (data_level != 0) { // 不是低电平,说明协议错位,复位状态机 ps2_bit_index = 0; return; } ps2_bit_index = 1; } else if (ps2_bit_index >= 1 && ps2_bit_index <= 8) { // 数据位,LSB first ps2_data_shift >>= 1; if (data_level) { ps2_data_shift |= 0x80; } ps2_bit_index++; } else if (ps2_bit_index == 9) { ps2_parity_bit = data_level; ps2_bit_index++; } else if (ps2_bit_index == 10) { // 停止位,应该为高电平 if (data_level == 1) { ps2_rx_data = ps2_data_shift; ps2_rx_complete = 1; } // 无论停止位正确与否,都复位状态机 ps2_bit_index = 0; ps2_data_shift = 0; } } }这段代码的逻辑说明:位索引 0 处理起始位,索引 1-8 处理数据位,索引 9 处理校验位,索引 10 处理停止位。数据位采用先右移再置最高位的方式,因为 LSB 先来,第一个收到的是 bit0,右移 7 次后 bit0 会到最低位,但这里右移加置最高位的组合等效于把收到的位依次放到从高位到低位,最终完成位反转。如果你想更直观,可以用一个数组存放 8 个数据位,逐个赋值,最后再组装成字节。
这个实现有几个参数需要关注。第一个是数据位索引范围,如果你把 ps2_bit_index 初始为 0,但在计数到 1 之前还未处理任何位,那中断回调逻辑需要保证每个沿只增加一次索引。第二个是奇偶校验未实际使用,只是保存起来,真正判断可以在主循环里做。第三个是 ps2_rx_complete 需要主循环清零,否则会重复处理同一包数据。实际工程中我一般还会加上一个超时机制,如果收了一半就停住了,状态机会一直卡在中间,下次收到起始位时会因为索引不为 0 而错位。超时可以用一个定时器周期检查 ps2_bit_index,超过 2ms 没有新沿就强制复位。
4.2 发送命令给 PS2 设备:从主机发一个字节的关键时序
PS2 设备也需要接收命令,比如键盘 LED 状态、鼠标滚轮模式。主机发送时,要先拉低 CLK 至少 100us,再拉低 DATA,然后释放 CLK,让设备接管时钟并产生一个低电平表示准备接收。之后主机在 CLK 下降沿时把对应的数据位写到 DATA 上。具体流程为:主机把 CLK 拉低,等待 100us;把 DATA 拉低,释放 CLK;设备会在 CLK 上产生一个低电平,然后开始产生时钟信号;主机在第一个下降沿之前把 DATA 准备好,后面的每个下降沿都更新 DATA 电平。注意发送时,CLK 和 DATA 的方向需要切换成输出模式,用 HAL_GPIO_Init 重新配置引脚。
发送一个字节的函数如下(去掉具体的引脚映射细节,用宏代替):
void PS2_SendByte(uint8_t byte) { // 1. 主机拉低 CLK,至少保持 100us HAL_GPIO_WritePin(PS2_CLK_PORT, PS2_CLK_PIN, GPIO_PIN_RESET); HAL_Delay(1); // 1ms 远大于 100us,确保设备检测到请求 // 2. 主机拉低 DATA,表示要发送数据 HAL_GPIO_WritePin(PS2_DATA_PORT, PS2_DATA_PIN, GPIO_PIN_RESET); // 3. 释放 CLK,设备开始产生时钟 HAL_GPIO_WritePin(PS2_CLK_PORT, PS2_CLK_PIN, GPIO_PIN_SET); // 4. 等待设备把 DATA 拉低(设备响应主机请求) uint32_t timeout = 1000; while (HAL_GPIO_ReadPin(PS2_DATA_PORT, PS2_DATA_PIN) != GPIO_PIN_RESET) { if (--timeout == 0) return; // 空等 } // 5. 此时 DATA 已经被设备拉低,设备准备接收 // 起始位:主机拉低 DATA HAL_GPIO_WritePin(PS2_DATA_PORT, PS2_DATA_PIN, GPIO_PIN_RESET); // 等待设备的第一个时钟下降沿 // 这个等待必须在真实项目中用中断或带超时,这里简化 // 主机在下降沿写数据位 for (int i = 0; i < 8; i++) { // 等待 CLK 低电平(下降沿) while (HAL_GPIO_ReadPin(PS2_CLK_PORT, PS2_CLK_PIN) != GPIO_PIN_RESET); // 写入数据位 if (byte & (1 << i)) HAL_GPIO_WritePin(PS2_DATA_PORT, PS2_DATA_PIN, GPIO_PIN_SET); else HAL_GPIO_WritePin(PS2_DATA_PORT, PS2_DATA_PIN, GPIO_PIN_RESET); // 等待 CLK 回到高电平 while (HAL_GPIO_ReadPin(PS2_CLK_PORT, PS2_CLK_PIN) != GPIO_PIN_SET); } // 8 位之后是校验位,这里略过,实际应用需要按奇校验补全 // 最后释放 DATA,等待设备产生停止位 HAL_GPIO_WritePin(PS2_DATA_PORT, PS2_DATA_PIN, GPIO_PIN_SET); }上面的代码是原型示例,重点说明发送时序。实际使用时,等待 CLK 沿必须带超时,否则一旦设备没有响应,主循环会卡死。HAL_Delay(1) 虽然简单,但如果工程里有多个任务,会阻塞系统。更好的办法是用一个定时器或者让出任务调度。另外,主机发送时不能和接收共用同一个 EXTI 中断,因为 CLK 被主机拉低时,外部中断也会触发。通常做法是发送前关闭 CLK 的外部中断,发送完成后重新打开,避免中断回调误认为收到数据。
还有一个细节:发送过程中等待 CLK 下降沿,用 while 循环读引脚,这在裸机环境可以接受,但如果有更高优先级中断打断,等待时间会变长,可能导致位出错。所以发送代码建议放在一个足够短的中断临界区里,或至少关掉部分可以被忽略的中断。
4.3 主循环里怎么把这些拼起来:轮询标志加超时复位
接收状态机只负责把字节从协议里解出来,主循环负责消费。典型做法是在 main.c 的 while(1) 里检查 ps2_rx_complete,如果置位,就读取 ps2_rx_data,然后清零标志,再对数据做解析。例如键盘扫描码,如果收到 0xE0 开头的扩展字节,需要组合成多字节序列。这个解析属于应用层,不应该放进中断。
为了验证接收链路,可以先做一个回环测试:用一根杜邦线把某开发板的 PA0 接到另一块板子的 PA1,对方发送一串固定字节,观察接收是否准确。对于 PS2 设备,最直接的测试是读取键盘的设备 ID:开机后发送 0xF2 命令,正常情况下键盘回复 0xFA(ACK)再回复 0xAB 或 0xAB + 0x41。如果串口能打印出来,说明接收和发送都通了。
主循环里还要做状态机复位,防止半包数据卡住。最简单的方法是用 HAL_GetTick() 记录最后一次收到沿的时间,每次中断里更新该时间戳。主循环检查如果当前时间减去最后时间超过 2ms,就把 ps2_bit_index 清零,同时丢弃未完成的半包。因为 PS2 一个字节的传输时间大约 70-80us,2ms 已经足够宽松,正常一帧无论如何都不会超过这个时间。
while (1) { if (ps2_rx_complete) { ps2_rx_complete = 0; uint8_t data = ps2_rx_data; // 在这里解析 data,例如按键扫描码 char buf[32]; snprintf(buf, sizeof(buf), "RX: 0x%02X, parity: %d\r\n", data, ps2_parity_bit); HAL_UART_Transmit(&huart1, (uint8_t*)buf, strlen(buf), 100); } // 超时复位 if (HAL_GetTick() - last_ps2_tick > 2) { // 注意:临界区保护,避免中断里读 bit_index 不一致 __disable_irq(); ps2_bit_index = 0; ps2_data_shift = 0; __enable_irq(); } }最后这个最小工程已经能完成“收到一个字节并打印”的目标。你可以先插上 PS2 键盘,上电后按一下键,串口应该能看到 0xE0、0xXX 之类的扫描码。如果看不到,大概率是引脚配置反向,或者中断根本没有触发。
5. PS2 解码避坑:5 个让人翻车的常见问题
5.1 现象:串口只能收到 0xFF 或者全 1
原因几乎都是 DATA 引脚没有正确上拉,或者读取的端口/引脚号写错。PS2 设备在不传输时 DATA 保持高电平,如果 STM32 内部上拉未开启,引脚电平可能被外部环境噪声拉低,导致每个沿都读到 0,组装出来是 0x00;反过来,如果引脚悬空读到高,就是 0xFF。
解决:先在 CUBEMX 里确认 DATA 和 CLK 都开启了 Pull-up,再用万用表量插头引脚,CLK 和 DATA 空闲电压应该在 4.5V 到 5V 之间。注意有些开发板自带 PS2 插座,插座内部已有上拉,此时再开 STM32 内部上拉问题不大,但更干净的做法是关掉内部上拉,别叠加并联 10kΩ 上拉导致电平无法拉低。
5.2 现象:键盘初始化失败,发送 0xF2 后收不到 ACK
原因通常是发送时序里 CLK 拉低时间不够,或者等待设备响应超时写得太短。有些老键盘对请求脉冲要求更严格,100us 是最小值,建议放宽到 500us-1ms。另一个隐蔽问题是:主机发送前把 CLK 拉低,但设备端已经有数据要发,这时会发生总线冲突。
解决:发送前一定要让设备处于空闲状态。通常做法是主机拉低 CLK 后,同时也要监听 DATA。如果 DATA 此时被设备拉低,说明设备正在向主机发送数据,主机应该释放 CLK,先接收完设备发来的字节再发命令。我在实际项目中加了一个简单的仲裁:如果发送准备阶段读到 DATA 为低,就放弃本次发送,等 1ms 重试。
5.3 现象:扩展键(方向键、小键盘)扫描码错乱
原因:PS2 键盘对扩展键会先发送 0xE0 前缀,再接一个扫描码。如果你只是按字节处理,看到 0xE0 就直接忽略,后面那个键的扫描码就可能被当成普通键,导致组合逻辑错误。比如右方向键的扫描码通常发送 0xE0 0x74,单独收到 0x74 会被误认为数字键盘的某个键。
解决:在解析层加一个前缀状态变量。收到 0xE0 时,置一个标志,等下一个字节;收到下一个字节时,用扩展键表映射,然后清标志。还需要处理 0xE0 后可能跟的重复码(0xE0 0x12 0xE0 0x70 是按住右 Ctrl 时的重复输出),这需要更完善的按键状态机,但对大多数应用来说,先把 0xE0 前缀正确配对就够了。
5.4 现象:调试时用 HAL_Delay 打印数据,结果字节丢得厉害
原因:在中断回调里调用打印函数或 HAL_Delay,阻塞时间过长,超过了 PS2 时钟半周期。PS2 时钟约 16kHz,半周期约 30us,HAL_Delay(1) 会产生 1ms 的阻塞,这段时间里整个字节已经传完了,后续的时钟沿全部错过。
解决:中断回调里绝不能调用 HAL_Delay 或串口打印。正确做法是把收到的字节放到一个环形缓冲区,主循环再去打印。如果必须在中断里做快速响应,也只能用寄存器级的操作,比如直接把数据写到一个 GPIO 翻转,让逻辑分析仪抓波形。
5.5 现象:奇偶校验总是错误,但去掉校验判断后数据其实是对的
原因:你把校验计算写成了偶校验,或者把校验位当成了数据位。PS2 是奇校验,校验位和 8 位数据里 1 的个数之和必须为奇数。如果收到的字节本身没问题,但按偶校验判断,就会误报错误。
解决:先验证计算逻辑。比如收到 0x00,数据位里 1 的个数是 0,校验位就应该是 1,因为 0+1=1 是奇数。如果收到的校验位是 0,那一定是协议错位。常见错误是把起始位也算进去了,导致偏移一位。我写了一个快速校验函数:先对数据字节逐位计数,加上校验位,然后取最低位看是不是 1,不是 1 就报错。注意不要把停止位算进去。
static int PS2_CheckParity(uint8_t data, uint8_t parity) { uint8_t ones = 0; for (int i = 0; i < 8; i++) { if (data & (1 << i)) ones++; } ones += (parity & 1); return (ones & 1); // 奇数返回1,正确 }这个函数在主循环里调用,如果返回 0 就丢弃当前包。但要注意,停止位如果不是高电平,说明帧错位,同样应该丢弃。很多情况下奇偶校验错误和停止位错误是同一个原因:状态机没复位,把上一帧的停止位当成了下一帧的起始位。
6. 验证与进阶:用逻辑分析仪校准时序,再把解码层做成独立模块
买一台几百兆采样率的逻辑分析仪,是调试 PS2 性价比最高的工具。把 CLK 和 DATA 两根线接到逻辑分析仪,设置下降沿触发,抓取按键瞬间的波形。你会看到 11 个清晰的时钟脉冲,数据在时钟下降沿附近跳变。用光标测量起始位宽度、数据位电平、停止位宽度,和协议帧一一对照,就能看出主机发送时序是不是把 CLK 拉得太短,或者接收方是不是在错误的边沿采样。如果发现数据位在时钟下降沿前后不稳定,多半是上拉电阻太弱或者线束过长,可以换短一点的线。
进阶方向是把解码层和应用层彻底分离。上面的代码把全局变量放在 main.c 里,只适合验证,不适合复用。我一般会建一个 ps2.c / ps2.h,对外提供 PS2_Init、PS2_ReadByte、PS2_SendByte 三个接口。中断回调由 HAL 层调用,内部使用一个 16 字节的环形缓冲记录接收到的原始字节。应用层只需要调用 PS2_ReadByte 就能拿到数据,不需要关心协议细节。这样在切换到另一个型号的 MCU 时,只需要改 .c 文件里的底层引脚映射,接口保持不变。
具体做法是在 ps2.c 里放一个结构体,保存状态机变量和环形缓冲索引。PS2_Init 会把传入的 GPIO 引脚配置好,但 CUBEMX 里通常已经完成初始化,所以这个函数只用来复位状态机。PS2_RxCallback 是一个需要暴露给 HAL 层的函数,在 HAL_GPIO_EXTI_Callback 里调用。为了更通用,可以用一个函数指针处理断帧超时,但最简单还是用 HAL_GetTick。
最后强调一个习惯:每次修改协议相关代码后,先用逻辑分析仪保存一份正常波形作为基准。下次改动如果出现乱码,把新波形和基准对比,就能快速定位是电平问题还是时序问题。PS2 解码不难,但它的坑很隐蔽,几乎都是因为把简单的事情想复杂了。我早期在这个工程上翻过车,浪费了整整一个晚上查为什么键盘一直发出 0xFF,最后发现是 DATA 引脚内部上拉没开。希望帮到你。
本文还有配套的精品资源,点击获取