资讯动态

STM32 CUBEMX+HAL库实现PS2键盘鼠标解码工程详解

发布时间:2026/10/15 11:10:48 来源:尧图企业网站定制
简介这是一套基于STM32与CubeMXHAL库开发的PS/2解码完整工程面向需要掌握键盘/鼠标通信协议的嵌入式开发者。工程以STM32F1系列为主展示了从CubeMX图形化配置时钟、GPIO、DMA到HAL库编写解码逻辑的完整链路覆盖PS/2协议中的数据线、时钟线采样、起始位/停止位判断与奇偶校验等关键环节适合学习外设驱动和串行通信的入门及进阶者。压缩包共176个文件包括62个h头文件与24个c源码文件另有uvprojx、ioc、mxproject等工程配置以及axf、hex、crf等编译中间产物便于直接查看或重新构建工程整体大小约5.07MB。目前已有969人学习。解包后可按目录定位到初始化代码、HAL库封装和主逻辑既能直接参考PS/2解码实现也能迁移其中的CubeMXHAL项目搭建思路结合资源内对同步串行帧格式的解析可加深对MSB优先、奇偶校验等机制的理解对后续开发其他串行外设驱动很有帮助。1. PS2解码为什么值得正经做一个CUBEMXHAL库工程很多人在做设备兼容时会遇到这样一个需求手头有一个还在服役的老式键盘或鼠标只留下PS2接口而主控板是STM32。把PS2解码加进去看起来只是数脉冲但如果直接在HAL库的GPIO中断回调里数高低电平十个工程有五个会在奇偶校验和边沿抖动上翻车。PS2协议虽然老但它的时序是双向的设备发数据时设备控制时钟主机发命令时主机要先拉低时钟申请总线这个角色切换在HAL库里没有现成封装只能自己维护一套状态机。CUBEMX能帮你把引脚、中断和时钟树一次配好但真正的解码逻辑必须落到代码里。这套工程方案适合这些人用STM32做键鼠输入的开发者、要在工业面板或某个跨平台系统里兼容老外设的人以及想弄懂PS2时序却不希望靠玄学调参的新手。它会先讲清时序再给可复现的初始化步骤和解码代码最后把几个常见的坑摊开讲。2. PS2协议帧结构、采样点与两套通信规则2.1 一帧11位起始位、数据位、奇校验、停止位PS2协议的核心是一帧11位无论键盘还是鼠标无论数据方向帧结构都一样。空闲状态时时钟线和数据线同时为高设备要发送数据时先把数据线拉低作为起始位然后时钟线产生下降沿之后每一个时钟周期对应一位数据。主机要做的事情就是在合适的采样点把数据线电平读进来凑满11位后做校验和解析。一帧的位分布按发送顺序展开是这样位序用途电平约定第1位起始位必须为低电平0第2~9位数据位8位LSB在前第10位奇偶校验位保证数据位校验位中1的个数为奇数第11位停止位必须为高电平1关键点是LSB在前。比如发送扫描码0x1C二进制是00011100线上实际发送顺序是0、0、1、1、1、0、0、0与直觉相反。很多工程第一次解析出来数值翻倍或者高低字节错位就是因为没做位序翻转。关于采样点这里有一个血泪经验数据线在时钟下降沿附近变化在上升沿时稳定。所以不要在下降沿中断里立刻读数据线那样读到的是上一个位的旧值或者正处于跳变中的不稳定电平。正确做法是把时钟线的上升沿作为采样点。2.2 设备发送与主机发送角色不同时序完全不同PS2最容易被低估的地方在这里。设备向主机发送时时钟完全由设备产生主机只是被动采样这时候主机侧的中断配置成上升沿触发即可。但主机要向设备发送命令时主机必须先拉低时钟线至少100微秒申请总线使用权然后由主机控制时钟向设备逐位写入数据。设备发送数据和主机发送数据的差异可以看这张表项目设备发送主机发送时钟谁产生设备主机申请后由设备接管产生时钟起始位数据线先拉低主机拉低数据线采样点主机在上升沿采样设备在上升沿采样主机在低电平期间准备好数据结束信号停止位后释放总线停止位后设备拉低数据线应答ACK如果只做了设备解码没做主机发送那键盘的指示灯控制、鼠标滚轮模式切换全都没法实现。很多教程只讲单向解码导致用户做到一半发现无法向设备发命令这是常见的认知断层。一个完整的PS2工程必须把两套时序都覆盖。2.3 解码策略选型为什么选外部中断状态机解码方案有三条路线。第一条是轮询主循环里不停读时钟线电平检测边沿。这条路线在裸机下会被其他中断频繁打断PS2时钟约10到40kHz位周期最短只有几十微秒一旦被打断超过半个位周期采样点就偏了。第二条是定时器输入捕获用TIM的输入捕获通道直接捕获时钟边沿硬件完成时间戳记录精度很高但占用一个高级定时器且数据位提取仍需额外逻辑。第三条是外部中断软件状态机这也是我在这套工程里采用的做法。外部中断状态机的优势在于GPIO上升沿和下降沿都能触发中断里只做最轻量的操作——记录边沿间隔、采样数据线、累加位计数不做任何延时和打印。这样即使系统其他地方响应慢一点只要EXTI中断优先级最高位采样就不会丢。对比下来轮询适合时间片极短的RTOS任务捕获适合对时序强依赖的场景而中断状态机是通用性最好、CUBEMX里配置最简单的一种。3. CUBEMX初始化引脚分配、EXTI与NVIC优先级3.1 CUBEMX外设配置时钟线、数据线与调试串口以常用的F103系列为例我在CUBEMX里给PS2分配PC6作为时钟线、PC7作为数据线。这两个引脚都是FT引脚能容忍5V电平这点很重要。PS2设备可能是5V逻辑的老外设虽然信号是开漏输出、高电平由上拉电阻决定但引脚本身能容忍5V输入会更稳妥。在CUBEMX的Pinout视图里把PC6配置成GPIO_EXTI6PC7配置成GPIO_Input模式都选Pull-up。时钟线开上升沿和下降沿触发因为状态机同时需要下降沿标记起始和数据线电平变化。数据线不需要外部中断它只是被读取的对象。功能引脚CUBEMX配置说明PS2时钟PC6EXTI6上升/下降沿上拉边沿触发中断PS2数据PC7GPIO_Input上拉只读数据线调试串口PA9/PA10USART1115200 8N1打印解码结果时基SysTick默认1msHAL_Delay和超时判断快速计数TIM3内部时钟计数频率1MHz边沿间隔过滤时钟树方面外部高速时钟配72MHz主频APB1分频后的TIM3时钟也要确认好因为后面要用TIM3的计数器来做微秒级时间戳。CUBEMX生成的代码里TIM3默认是1ms中断模式这里要改成不使能中断、只保留计数功能在Parameter Settings里把Prescaler和Period配成让计数频率为1MHz。3.2 NVIC优先级EXTI最高定时器其次PS2解码的成败很大程度在NVIC配置。时钟边沿中断必须抢占最高优先级否则系统里任何一个中断抖动都会导致边沿丢失。在CUBEMX的NVIC设置页里把EXTI6的抢占优先级设为0TIM3设为1USART1设为2。SysTick保持默认即可。中断源抢占优先级用途EXTI60PS2时钟边沿不可丢TIM31微秒时间戳与超时判断USART12调试打印SysTick默认HAL_Delay这里有一个容易踩的坑不要在定时器中断里去做PS2的边沿轮询也不要让某个外设中断的优先级和EXTI相同。HAL库的中断回调都是统一入口如果两个同优先级中断同时到达NVIC会按硬件编号顺序执行EXTI6不一定能保证立刻响应极端情况下位采样就错位了。3.3 生成代码后的初始化顺序与总线自检CUBEMX生成代码后main函数里的初始化顺序是固定的HAL_Init、时钟配置、GPIO配置、外设配置。PS2的初始化顺序有一个额外要求设备上电后可能立即开始发送数据如果主机侧的EXTI还没使能第一帧就会丢失。常见做法是在PS2初始化函数里做一次总线复位。先把时钟线和数据线都配置成开漏输出拉低时钟线保持100微秒以上再释放时钟线让设备重新同步。然后延时200毫秒等待设备上电稳定再主动丢弃接收缓冲里的残留数据。这一段逻辑直接放在初始化函数末尾void ps2_init(void) { GPIO_InitTypeDef ini {0}; // 时钟线开漏输出用于总线复位 ini.Pin PS2_CLK_PIN; ini.Mode GPIO_MODE_OUTPUT_OD; ini.Pull GPIO_PULLUP; ini.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(PS2_CLK_PORT, ini); // 数据线开漏输出 ini.Pin PS2_DATA_PIN; HAL_GPIO_Init(PS2_DATA_PORT, ini); // 拉低时钟线100us以上请求设备复位 HAL_GPIO_WritePin(PS2_CLK_PORT, PS2_CLK_PIN, GPIO_PIN_RESET); delay_us(150); HAL_GPIO_WritePin(PS2_CLK_PORT, PS2_CLK_PIN, GPIO_PIN_SET); // 恢复接收模式CLK为EXTI上升/下降沿DAT为输入上拉 ps2_line_mode(0, 0); // 等待设备重新上电应答丢弃不稳定数据 HAL_Delay(200); ps2_rx_active 0; ps2_rx_done 0; }这段代码的逻辑是先把两条线都变成开漏输出主动拉低时钟线触发设备复位再恢复成接收模式。ps2_line_mode是方向切换函数后面的章节会给出完整实现。初始化完成后不要立刻收数据先等200毫秒让键盘或鼠标完成自检否则收到的可能是设备上电自检的0xAA字节混入正常按键流里会干扰解析。4. 解码代码实现边沿采集、键盘扫描码与鼠标数据包4.1 外部中断回调去毛刺、置起始、采数据解码核心在HAL_GPIO_EXTI_Callback里。这段代码的全部逻辑都在中断上下文执行所以要保证每条语句都是微秒级操作不能调用HAL_Delay、不能做串口打印、不能处理复杂协议解析。#define PS2_CLK_PORT GPIOC #define PS2_CLK_PIN GPIO_PIN_6 #define PS2_DATA_PORT GPIOC #define PS2_DATA_PIN GPIO_PIN_7 volatile uint8_t ps2_rx_active; // 1正在接收一帧 volatile uint8_t ps2_rx_done; // 1完整帧已接收待处理 volatile uint16_t ps2_rx_buf; // 收满11位bit0为起始位 volatile uint8_t ps2_rx_bit_count;// 已采样的上升沿个数 void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { uint32_t now; uint32_t interval; if (GPIO_Pin ! PS2_CLK_PIN) { return; } // 利用TIM3的1MHz计数器计算边沿间隔滤掉毛刺 now __HAL_TIM_GET_COUNTER(htim3); static uint32_t last_edge; interval now - last_edge; last_edge now; // 正常位周期最小也在30us以上间隔小于20us视为抖动 if (interval 20) { return; } if (HAL_GPIO_ReadPin(PS2_CLK_PORT, PS2_CLK_PIN) GPIO_PIN_SET) { // 上升沿数据线稳定采样并累加 if (ps2_rx_active) { uint8_t bit HAL_GPIO_ReadPin(PS2_DATA_PORT, PS2_DATA_PIN); ps2_rx_buf | ((uint16_t)bit ps2_rx_bit_count); ps2_rx_bit_count; if (ps2_rx_bit_count 11) { // 已收满起始位8数据位校验位停止位 ps2_rx_active 0; ps2_rx_done 1; ps2_rx_bit_count 0; } } } else { // 下降沿标志一帧开始不读数据线 if (!ps2_rx_active !ps2_rx_done) { ps2_rx_active 1; ps2_rx_bit_count 0; ps2_rx_buf 0; } } }代码里的帧位分布需要说清楚。按采样先后顺序bit0是起始位bit1到bit8是数据位LSB在bit1bit9是奇校验位bit10是停止位。解析时取出数据用(ps2_rx_buf 1) 0xFF。去毛刺的20微秒阈值不是拍脑袋定的。PS2规范允许时钟频率最低10kHz对应位周期100微秒半周期50微秒即使高速模式40kHz半周期也有约25微秒。阈值设在20微秒既不会滤掉正常边沿又能干掉大多数线缆感应噪声。有一个细节容易忽略ps2_rx_done置1后如果主循环没有及时处理下一个下降沿到来时会因为ps2_rx_done为1而不会开启新帧。所以主循环处理完一帧后必须把ps2_rx_done清0。这个标志位的生命周期要约定好中断只负责置1主循环负责清0。4.2 键盘解码通码、断码与重复帧处理键盘发送的是扫描码不是ASCII码。扫描码分通码和断码按下按键发送通码松开时发送0xF0加通码。比如A键的通码是0x1C松开时发送0xF0、0x1C两个字节。还有扩展按键比如方向键和编辑键会先发送0xE0前缀。主循环里的帧处理逻辑如下void ps2_keyboard_process(uint16_t frame) { uint8_t data; uint8_t parity; uint8_t stop_bit; uint8_t ones 0; int i; // 拆帧 data (frame 1) 0xFF; parity (frame 9) 0x01; stop_bit (frame 10) 0x01; // 丢弃起始位不为0、停止位不为1 if ((frame 0x01) ! 0 || stop_bit ! 1) { return; } // 奇校验数据位校验位中1的总数必须为奇数 for (i 0; i 8; i) { if (data (1 i)) ones; } if (((ones parity) 0x01) ! 1) { return; } // 进入扫描码状态机 if (data 0xE0) { ps2_ext_flag 1; return; } if (data 0xF0) { ps2_release_flag 1; return; } // 查表得到键值送入上层事件队列 ps2_key_event(ps2_keyboard_map(data), ps2_release_flag); ps2_ext_flag 0; ps2_release_flag 0; }校验逻辑里起始位和停止位是硬条件开场白就过滤掉一半的错帧。奇校验的实现要点是统计8个数据位中1的个数加上校验位后总数必须是奇数。这里最容易算反后面避坑章节会专门展开。扫描码到键值的映射表在工程里是一个常量数组展示其中一小段uint8_t ps2_keyboard_map(uint8_t scancode) { switch (scancode) { case 0x1C: return A; case 0x1B: return S; case 0x23: return D; case 0x2B: return F; case 0x34: return G; case 0x33: return H; case 0x29: return ; case 0x5A: return \r; default: return 0; } }完整映射表覆盖全部按键包括Shift、Ctrl、方向键和数字键盘工程里按需补齐。重复帧是另一个容易被忽略的点键盘在长按时会周期性重发通码间隔约100毫秒。如果上层逻辑需要对重复按键去抖可以在解析时记录每个键的最后事件时间戳相同通码在500毫秒内再次到达就视为重复帧不生成新事件。4.3 鼠标解码三字节数据包与增量位移鼠标和键盘的帧格式一样但逻辑上每三帧组成一个完整报告。第一字节是标志位第二字节是X方向位移增量第三字节是Y方向位移增量。位移是带符号的8位值用二进制补码表示范围是-128到127。typedef struct { uint8_t buttons; // bit0左键 bit1右键 bit2中键 int8_t x; // X增量右正左负 int8_t y; // Y增量注意屏幕方向映射 } ps2_mouse_report_t; void ps2_mouse_parse(uint8_t *buf) { ps2_mouse_report_t report; // bit3必须为1这是数据包的标识位 if ((buf[0] 0x08) 0) { return; } // bit7和bit6分别是X和Y溢出溢出时丢弃当帧位移 if ((buf[0] 0xC0) ! 0) { return; } report.buttons buf[0] 0x07; report.x (int8_t)buf[1]; report.y (int8_t)buf[2]; // 鼠标坐标Y轴正方向是向上应用层做屏幕映射时注意翻转 ps2_mouse_handle(report); }这里的字节序不需要翻转因为三字节是按顺序接收的第一帧是标志字节第二帧是X第三帧是Y。接收端要做的事是把三帧拼成一个报告。鼠标还有一个滚轮扩展协议启用后每个报告多一字节滚轮增量这个需要主机主动发送命令切换模式放在第6章里说。5. PS2解码避坑清单5个让工程翻车的细节5.1 中断回调里调用HAL_Delay时钟边沿被吞现象键盘偶尔能解析出正确按键但多数按键错乱尤其是快速打字时丢键严重。原因在HAL_GPIO_EXTI_Callback里加入了延时或串口打印。PS2时钟位周期只有几十微秒而HAL_Delay依赖SysTick中断至少阻塞1毫秒足够丢失好几个边沿。串口打印如果阻塞发送同样致命。解决中断回调里只做状态机操作所有耗时的解析、打印全部放到主循环。如果确实需要调试信息用标志位通知主循环打印或者用DMA串口发送。5.2 奇校验算反每一帧都被丢弃现象接收中断一直在触发帧也收满了11位但解析函数里校验始终失败数据无一通过。原因对奇校验的理解偏差。奇校验要求数据位中1的个数加上校验位中1的个数等于奇数。数据位里1的个数是奇数时校验位必须是0这样总和才是奇数数据位里1的个数是偶数时校验位必须是1。很多实现写反把校验位设置成和数据的奇偶性相同导致校验永远报错。解决按前面解析代码里的方式实现统计8个数据位中1的个数加上校验位后取最低位结果为1才算通过。单独写一个函数验证用0x00、0xFF、0x1C几个已知扫描码逐个跑一遍确认校验位逻辑正确后再接入解码流程。5.3 时钟毛刺误启帧状态机陷入错乱现象键盘闲置时也会产生错误帧有时一个按键触发两三个事件用示波器看时钟线有短脉冲毛刺。原因线缆过长或布局靠近电机、继电器等干扰源GPIO边沿中断对毛刺非常敏感。一个毛刺下降沿会被当成起始位之后的采样数据错乱整个帧被污染。解决代码层面做最小脉冲宽度过滤也就是4.1节里的20微秒阈值判断。硬件层面在时钟线和数据线上各加一个RC低通滤波器电阻选1kΩ、电容选100pF左右能吸收大部分高频毛刺。两个手段同时做不要只靠软件。5.4 上电第一帧丢失或收到0xAA现象设备刚供电时前几个按键无响应之后恢复正常或者复位后收到的第一个字节固定是0xAA。原因键盘鼠标上电后会自动执行自检自检完成后发送0xAA表示正常。如果主机在设备自检完成前就已进入正常接收状态会把0xAA当成普通扫描码处理。设备自检期间时钟和数据线的状态也不稳定容易产生半帧。解决初始化时按3.3节的流程主动复位设备并延时200毫秒然后清空接收缓冲区和状态机标志。把0xAA、0xFA这类设备应答码和普通扫描码在状态机里分开处理应答码只走握手逻辑不进按键映射。5.5 3.3V供电板子接5V键鼠时好时坏现象同样的解码代码在一套板子上稳定运行换一个供电设计后就频繁丢帧、错帧。原因PS2设备多数是5V供电信号线是开漏结构。如果主机侧上拉电阻接的是3.3V且阻值偏大信号上升沿会变缓时钟边沿的触发点在抖动区间里反复横跳采样位置不稳定。部分老设备的输入阈值也需要更高电平才能可靠识别。解决时钟线和数据线外接2.2kΩ上拉电阻到3.3V如果设备仍然不稳定改用5V上拉并在STM32的FT引脚侧加串联电阻限制电流。选型时优先选FT引脚像PC6和PC7这类本身兼容5V的引脚能少很多麻烦。6. 进阶双缓冲、滚轮模式与键盘LED命令6.1 双缓冲帧管理解码不丢帧如果接收中断速度很快而主循环处理不过来会出现上一帧还没处理完、下一帧已经把ps2_rx_buf覆盖的情况。常见做法是用双缓冲中断写一个缓冲主循环读另一个缓冲处理完后交换。实现上可以简化把ps2_rx_done当作可重入标志主循环检测到置位后立刻把ps2_rx_buf拷贝到本地变量再清标志。这个拷贝操作只要几个指令周期就能避免覆盖。if (ps2_rx_done) { uint16_t frame ps2_rx_buf; ps2_rx_done 0; // 先清标志再处理避免中断又写同一帧 ps2_keyboard_process(frame); }先拷贝、再清标志、最后处理这个顺序是刻进肌肉记忆的。反过来会丢数据。6.2 主到设备命令切换滚轮模式与键盘LED向设备发送命令要走完整的主机发送时序。常见做法是写一个ps2_send_byte函数发送前把时钟线和数据线都切换成开漏输出发送完恢复成接收。滚轮鼠标的启用方式是依次写入采样率200、100、80设备收到后进入滚轮模式。键盘LED控制的命令是0xED后面跟一个LED状态字节。编写这个函数时留意开漏输出的切换顺序先把输出寄存器置高再切模式防止引脚瞬间输出低电平被设备误读为起始位。void ps2_send_byte(uint8_t data) { uint8_t i, ones 0, parity; for (i 0; i 8; i) { if (data (1 i)) ones; } parity (ones % 2) ? 0 : 1; ps2_line_mode(1, 1); // CLK和DAT都切成开漏输出 HAL_GPIO_WritePin(PS2_CLK_PORT, PS2_CLK_PIN, GPIO_PIN_RESET); delay_us(120); // 拉低时钟申请总线 HAL_GPIO_WritePin(PS2_DATA_PORT, PS2_DATA_PIN, GPIO_PIN_RESET); delay_us(20); // 起始位 for (i 0; i 8; i) { // 每个位等待时钟低电平设置数据线等待时钟高电平 while (HAL_GPIO_ReadPin(PS2_CLK_PORT, PS2_CLK_PIN) GPIO_PIN_SET); HAL_GPIO_WritePin(PS2_DATA_PORT, PS2_DATA_PIN, (data (1 i)) ? GPIO_PIN_SET : GPIO_PIN_RESET); while (HAL_GPIO_ReadPin(PS2_CLK_PORT, PS2_CLK_PIN) GPIO_PIN_RESET); } // 校验位 while (HAL_GPIO_ReadPin(PS2_CLK_PORT, PS2_CLK_PIN) GPIO_PIN_SET); HAL_GPIO_WritePin(PS2_DATA_PORT, PS2_DATA_PIN, parity ? GPIO_PIN_SET : GPIO_PIN_RESET); while (HAL_GPIO_ReadPin(PS2_CLK_PORT, PS2_CLK_PIN) GPIO_PIN_RESET); // 停止位释放数据线 HAL_GPIO_WritePin(PS2_DATA_PORT, PS2_DATA_PIN, GPIO_PIN_SET); delay_us(20); ps2_line_mode(0, 0); // 切回接收模式 }这套发送逻辑我在实际调试中确认过起始位后的第一个等待时钟低电平对应设备接管时钟后的第一个脉冲主机在这个低电平期间设置D0上升沿时设备采样。停止位释放数据线后设备会拉低应答这里没有再读ACK工程够用严格场景可以补一个等待设备拉低数据线的循环。6.3 用串口打印和逻辑分析仪做验收验收时先把原始帧以十六进制打印出来对照协议逐一核对位序和校验。我用串口助手连上USART1在解析函数里临时加一行打印帧值和校验结果。全部字节符合预期后再关掉调试打印跑通链路。逻辑分析仪抓时钟和数据线确认位宽度、起始位和停止位的位置这一步能快速定位毛刺和采样点偏移。回头总结一条教训做PS2解码先接逻辑分析仪看波形再动手写代码能省掉一半的扯皮时间。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑