1. 项目概述与核心思路在嵌入式项目里尤其是那些需要复杂人机交互的玩意儿按键输入是个绕不开的话题。用一两个按键直接接在GPIO上简单省事。但当你需要几十个甚至上百个按键时——比如想做个自定义的控制面板、复古的游戏机、或者像我之前折腾的一个需要字母、数字和功能键的加密设备——把所有按键都一对一地接到单片机的引脚上就成了一场引脚资源的噩梦。就算你找到了一个引脚足够多的MCU布线也会变得一团糟成本飙升而且软件上管理这么多离散的输入也是个麻烦。这时候一个现成的、成熟的输入设备就成了最优解。键盘这个我们每天打交道的东西本质上就是一个高度集成、自带扫描逻辑的“巨型按键矩阵”。它内部已经解决了“如何用最少的线检测最多按键”的问题并通过一个标准化的接口把结果吐出来。PS/2接口作为在USB一统天下前PC的绝对主流其协议简单、稳定对单片机来说极其友好。它不需要复杂的USB协议栈只需要两根线数据Data和时钟Clock就能实现全键盘的输入捕获。这简直就是为资源有限的嵌入式系统量身定做的“外设扩展坞”。本文将手把手带你把一个标准的PS/2键盘接入到以STM32为代表的单片机系统中。我们不会停留在简单的“点灯”demo而是深入协议底层拆解时序解析扫描码并处理多键按压等实际场景。最终你将获得一个可以直接集成到你项目中的、健壮的键盘输入模块。无论你是做智能家居的中控、工业设备的调试面板还是复古硬件改造这套方案都能让你摆脱按键数量的束缚。2. PS/2协议深度解析不只是两根线那么简单很多人觉得PS/2就是简单的串口其实不然。它是一种同步、双向、半双工的串行通信协议。同步意味着通信双方要遵循同一个时钟节拍这个节拍时钟信号在键盘到主机的通信中完全由键盘产生。这是理解整个协议的第一步也是我们硬件连接和软件捕获的基础。2.1 电气连接与引脚定义一个PS/2接口这里指6针的mini-DIN通常使用4根线VCC (5V)为键盘提供电源。这是关键很多USB-only的键盘在5V下也无法模拟PS/2模式就是因为内部缺少PS/2控制器所需的5V逻辑电路。GND地线。Data数据线。双向开集电极或开漏结构常态被上拉电阻拉高。这意味着无论是键盘还是主机想发送逻辑‘0’时都需要主动将这条线拉低。Clock时钟线。同样是双向开集电极/开漏常态高。在键盘发送数据时时钟由键盘控制主机想发送命令时则需要先“夺取”时钟线的控制权。注意市面上有些USB-PS/2二合一键盘其USB接口的D和D-线在插入被动转接头后会被对应到PS/2的Data和Clock。但这种兼容性并非100%很多现代键盘已移除了PS/2控制器。所以为项目挑选键盘时一个老式的、带紫色/绿色PS/2圆口的键盘是最保险的选择。2.2 通信帧格式与时序当键盘有键被按下或释放时它会主动向主机发送一帧数据。每一帧包含11个位在时钟的下降沿被采样起始位 (Start Bit)总是逻辑‘0’。标志着数据帧的开始。8个数据位 (Data Bits)从最低有效位LSB开始发送。这就是我们常说的“扫描码Scan Code”。奇偶校验位 (Parity Bit)采用奇校验。即数据位校验位中‘1’的个数为奇数。这是一个简单的错误检测机制。停止位 (Stop Bit)总是逻辑‘1’。标志着数据帧的结束。时钟频率通常在10kHz到16.7kHz之间这意味着一个时钟周期在60到100微秒µs之间。数据线Data的变化必须发生在时钟线Clock为高电平期间并且在时钟下降沿到来之前和之后都需要有足够的稳定时间建立时间和保持时间。不过对于大多数现代单片机来说只要使用中断或输入捕获功能在时钟下降沿读取数据时序容限是绰绰有余的。下图清晰地展示了一帧数据的波形假设发送数据0x1C即二进制00011100LSB先发Clock _ - _ - _ - _ - _ - _ - _ - _ - _ - _ - _ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Data ---___---___---___---_______---___---___---_______---___---___---___---___---___--- | Start | D0 | D1 | D2 | D3 | D4 | D5 | D6 | D7 | Parity| Stop | Idle | (0) | (0)| (0)| (1)| (1)| (1)| (0)| (0)| (0)| (1) | (1) | (1)注_表示高电平-表示低电平---表示电平变化2.3 扫描码集与按键状态键盘按下“A”键它不会直接发送一个‘A’的ASCII码而是发送一个叫做“扫描码Make Code”的字节。松开时则发送“断开码Break Code”。不同的键盘可能使用不同的扫描码集最常见的是Scan Code Set 2。Set 2 通码按下例如A键的通码是0x1C。Set 2 断码释放由一个固定的前缀0xF0后跟该键的通码组成。所以A键的断码是0xF0, 0x1C。扩展按键像方向键、小键盘区的某些键它们的通码以0xE0为前缀。例如右方向键的通码是0xE0, 0x74其断码则是0xE0, 0xF0, 0x74。键盘会自动重复发送按下的通码这就是按键重复功能直到键被释放。我们的固件必须能处理这种重复避免将一次长按识别为多次按压。2.4 键盘防冲突机制这是实际使用中很容易踩坑的地方。键盘内部是一个矩阵当同时按下多个键时可能会产生“鬼影Ghosting”或无法识别的情况。无防冲突 (NKRO, N-Key Rollover)理论上可以同时识别所有按键。通常需要每个按键有独立的二极管成本高多见于高端游戏键盘。6键无冲 (6KRO)可以同时识别最多6个任意按键。这是目前大多数键盘的常见水平。2键无冲 (2KRO)只能保证同时按下的任意两个键都能被识别。如果按下三个键可能有一个无法识别。一些极低成本的键盘或薄膜键盘可能是这样。按键锁定 (Key Locking)对于Ctrl,Alt,Shift,CapsLock等修饰键键盘有内部逻辑处理其锁定状态并反映在扫描码流中。在你的项目规划阶段必须考虑用户可能的按键组合。如果需要复杂的和弦键如CtrlAltDel或快速多键输入选择一个支持至少6KRO的键盘是必要的。你可以通过一个简单的测试程序同时按下多个键并观察收到的扫描码流来验证你的键盘支持哪种防冲突。3. 硬件连接与单片机配置理论说完了开始动手。硬件连接的原则是安全第一兼容第二。3.1 电平匹配与保护老式PS/2键盘是5V TTL电平。而现代单片机如STM32F103的IO口工作电压通常是3.3V。虽然很多STM32的IO口标有“FT”5V Tolerant耐5V但这通常仅指在配置为浮空输入Floating Input或模拟输入时可以承受5V电压而不损坏。安全连接方案直接连接仅适用于5V容忍IO且不向键盘发送数据将PS/2的Data和Clock线分别连接到MCU的两个配置为浮空输入模式的FT引脚上。绝对不要在MCU端启用内部上拉电阻因为PS/2接口线路上已经有上拉电阻通常在键盘内部或主机端双重上拉可能影响电平。这种方式下MCU只做读取不控制线路。这是最简单、最常用的方案适用于绝大多数只需要接收按键信息的项目。// 在STM32CubeMX或代码中配置GPIO GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin PS2_CLK_Pin | PS2_DATA_Pin; GPIO_InitStruct.Mode GPIO_MODE_INPUT; // 输入模式 GPIO_InitStruct.Pull GPIO_NOPULL; // 关键禁用内部上下拉 HAL_GPIO_Init(GPIOx, GPIO_InitStruct);电平转换电路双向通信或非5V容忍MCU如果MCU需要向键盘发送命令如设置LED、重复速率或者MCU IO口不是5V容忍的必须使用电平转换。可以使用双N沟道MOSFET搭建双向电平转换电路或者使用专用的电平转换芯片如TXB0104。一个简单的、仅用于主机发送数据的方案是使用两个NPN三极管如2N2222或MOSFET分别控制Data和Clock线的下拉但这样只能单向控制电路稍复杂。电源确保能给键盘提供稳定的5V电源。可以从你的开发板的5V引脚取电或者使用外部5V电源模块。注意电流需求一个键盘通常需要100mA左右。3.2 以STM32为例的工程设置我们使用STM32CubeIDE进行配置目标是在时钟下降沿触发中断捕获数据。引脚配置打开CubeMX选择你的STM32型号。找到两个合适的FT引脚例如PA0, PA1分别设置为GPIO_Input模式。在NVIC Settings中使能这两个引脚的外部中断EXTI line0 and line1 interrupt。将中断触发边沿设置为Falling edge下降沿。因为数据在时钟下降沿是稳定的。系统配置配置好系统时钟SYSCLK使用内部或外部晶振均可。配置一个UART如USART1用于调试输出将扫描码打印到PC串口助手方便我们观察。波特率设为115200或9600。生成代码生成初始化代码后在工程中你需要编写中断服务函数ISR来处理Data和Clock线上的下降沿。4. 固件设计状态机解析协议在中断服务程序ISR里解析协议是最高效的方式。我们将实现一个有限状态机FSM根据Clock下降沿和当前的Data电平在不同的状态间转移逐步拼装出一帧完整的数据。4.1 状态机设计我们定义以下几个状态PS2_IDLE空闲状态。等待起始位Data线变低。PS2_START已检测到起始位。等待第一个时钟下降沿并验证Data是否为0。PS2_DATA正在接收8个数据位。每来一个时钟下降沿读取一位数据。PS2_PARITY接收奇偶校验位。PS2_STOP接收停止位。验证Data是否为1完成一帧接收。4.2 核心代码实现首先定义状态和数据结构typedef enum { PS2_STATE_IDLE, PS2_STATE_START, PS2_STATE_DATA, PS2_STATE_PARITY, PS2_STATE_STOP } ps2_state_t; typedef struct { ps2_state_t state; uint8_t data_byte; // 当前正在组装的数据字节 uint8_t bit_count; // 已接收的数据位数 uint8_t parity_count; // 用于计算奇偶校验 uint8_t shift_reg; // 位接收移位寄存器实践中可与data_byte合并 uint8_t rx_buffer[16]; // 接收缓冲区用于存储多字节扫描码序列 uint8_t rx_index; uint8_t new_data_flag; // 新数据就绪标志 } ps2_handler_t; volatile ps2_handler_t ps2; // 使用volatile因为它在ISR中被修改然后编写Clock引脚的外部中断服务函数。我们假设Clock连接到EXTI0Data引脚我们通过HAL_GPIO_ReadPin读取。// 在stm32f1xx_it.c中 void EXTI0_IRQHandler(void) { if(__HAL_GPIO_EXTI_GET_IT(PS2_CLK_Pin) ! RESET) { __HAL_GPIO_EXTI_CLEAR_IT(PS2_CLK_Pin); // 清除中断标志 uint8_t data_level HAL_GPIO_ReadPin(PS2_DATA_GPIO_Port, PS2_DATA_Pin); switch(ps2.state) { case PS2_STATE_IDLE: // 在空闲状态我们实际上不处理时钟中断而是等待Data线变低另一个中断 // 但一个健壮的设计也可以在这里加入超时复位逻辑 break; case PS2_STATE_START: // 在起始位状态第一个时钟下降沿到来 if (data_level 0) { // 验证确实是起始位低电平 ps2.state PS2_STATE_DATA; ps2.data_byte 0; ps2.bit_count 0; ps2.parity_count 0; ps2.shift_reg 0x01; // 从LSB开始接收 } else { // 错误在起始位状态Data不是低电平复位到空闲 ps2.state PS2_STATE_IDLE; } break; case PS2_STATE_DATA: // 接收数据位 if (data_level) { ps2.data_byte | ps2.shift_reg; // 如果数据位是1则置位 ps2.parity_count; } // 如果数据位是0则什么都不做该位已是0 ps2.shift_reg 1; // 移位准备接收下一位 ps2.bit_count; if (ps2.bit_count 8) { // 8个数据位接收完毕 ps2.state PS2_STATE_PARITY; } break; case PS2_STATE_PARITY: // 接收并检查奇偶校验位 if (data_level) { ps2.parity_count; } // 奇校验数据位校验位中1的个数应为奇数 if ((ps2.parity_count 0x01) 1) { ps2.state PS2_STATE_STOP; } else { // 奇偶校验错误 ps2.state PS2_STATE_IDLE; // 丢弃这一帧 } break; case PS2_STATE_STOP: // 接收停止位 if (data_level 1) { // 停止位正确一帧数据接收成功 // 将接收到的数据字节存入缓冲区 if (ps2.rx_index sizeof(ps2.rx_buffer)) { ps2.rx_buffer[ps2.rx_index] ps2.data_byte; } ps2.new_data_flag 1; // 设置新数据标志 } // 无论停止位对错一帧结束回到空闲状态等待下一个起始位 ps2.state PS2_STATE_IDLE; break; } } }关键点Data线的起始位检测需要另一个中断EXTI1。我们需要在Data线的下降沿中断中将状态从IDLE切换到START。void EXTI1_IRQHandler(void) { if(__HAL_GPIO_EXTI_GET_IT(PS2_DATA_Pin) ! RESET) { __HAL_GPIO_EXTI_CLEAR_IT(PS2_DATA_Pin); // 只有在空闲状态Data的下降沿才被认为是起始位 if (ps2.state PS2_STATE_IDLE) { ps2.state PS2_STATE_START; } // 在其他状态下出现Data下降沿可能是错误或主机发送数据这里简单复位 // 对于只接收的应用可以忽略或做错误处理 else { ps2.state PS2_STATE_IDLE; } } }4.3 主循环中的数据处理状态机在ISR中完成了比特位的组装在主循环中我们处理完整的字节序列将其翻译成有意义的按键事件。// 全局按键缓冲区记录当前按下的键假设最多8键 uint8_t pressed_keys[8] {0}; uint8_t num_pressed 0; void ProcessPS2Data(void) { if (ps2.new_data_flag) { ps2.new_data_flag 0; uint8_t byte ps2.rx_buffer[0]; // 先处理第一个字节 // 调试通过UART发送到PC char msg[32]; sprintf(msg, RX: 0x%02X\r\n, byte); HAL_UART_Transmit(huart1, (uint8_t*)msg, strlen(msg), HAL_MAX_DELAY); // 简单的扫描码集2解析不处理Pause等特殊键 static uint8_t extended 0; static uint8_t break_code 0; if (byte 0xE0) { extended 1; // 移除已处理的0xE0前缀准备处理下一个字节 memmove(ps2.rx_buffer[0], ps2.rx_buffer[1], --ps2.rx_index); return; } else if (byte 0xF0) { break_code 1; memmove(ps2.rx_buffer[0], ps2.rx_buffer[1], --ps2.rx_index); return; } // 此时byte是一个有效的键值通码或断码的键值部分 uint16_t key_code byte; if (extended) { key_code | 0xE000; // 用高字节标记扩展键例如 0xE01C extended 0; } if (break_code) { // 键释放事件 RemoveFromPressedKeys(key_code); // 从按下键列表中移除 break_code 0; } else { // 键按下事件注意可能是重复的通码 AddToPressedKeys(key_code); // 添加到按下键列表去重 } // 清除已处理的字节 memmove(ps2.rx_buffer[0], ps2.rx_buffer[1], --ps2.rx_index); } } // 在主循环中调用 while (1) { ProcessPS2Data(); // ... 其他任务 }AddToPressedKeys和RemoveFromPressedKeys函数需要实现去重逻辑以正确处理键盘自动重发的通码。5. 高级话题与实战优化基础功能跑通后我们需要让它在实际项目中更可靠、更好用。5.1 防抖与去重键盘的按键是机械开关存在抖动。虽然PS/2键盘控制器内部已经做了硬件防抖但我们的固件层面仍需处理通码重复的问题。去重在AddToPressedKeys函数中添加新键码前先遍历pressed_keys数组。如果发现相同的键码已经存在则忽略此次添加。这能有效过滤掉长按时键盘不断发送的重复通码。状态机超时复位通信可能被干扰。在主循环中加入一个超时检查如果状态机卡在非IDLE状态超过一定时间比如20ms就强制将其复位到IDLE状态等待新的起始位。if (ps2.state ! PS2_STATE_IDLE) { if (HAL_GetTick() - last_edge_time 20) { // 20ms超时 ps2.state PS2_STATE_IDLE; ps2.rx_index 0; // 可以在这里记录一个错误计数用于诊断 } } // 在每次成功进入STOP状态或每次时钟中断时更新last_edge_time HAL_GetTick();5.2 扫描码到应用层映射我们得到了形如0x1C或0xE01C的键码。你需要一个映射表将其转换为你的应用所需的“虚拟键值”或直接转换为ASCII字符注意这需要处理Shift、CapsLock等修饰键状态。typedef enum { KEY_A 0, KEY_B, KEY_C, // ... 其他键 KEY_UP, // 扩展键 KEY_DOWN, // ... } app_key_t; // 简化的映射表扫描码集2 const uint16_t scancode_to_appkey[] { [0x1C] KEY_A, [0x32] KEY_B, [0x21] KEY_C, // ... [0x75] KEY_UP, // 0xE0, 0x75 [0x72] KEY_DOWN, // 0xE0, 0x72 // ... };处理修饰键Shift,Ctrl,Alt时你需要维护它们的状态按下/释放然后在将主键如A映射为字符时查询这些修饰键的状态决定最终输出是大写‘A’、小写‘a’还是CtrlA的组合命令。5.3 向键盘发送命令可选虽然大多数项目只需要读键盘但有时你可能想控制键盘的NumLock、CapsLock、ScrollLock指示灯。这就需要主机向键盘发送命令。夺取总线控制权主机要发送时先将Clock线拉低至少100µs抑制键盘的时钟。发送请求主机释放Clock线并将Data线拉低起始位。键盘响应键盘检测到请求开始产生时钟信号。主机发送主机在时钟为低时改变Data线发送一个字节格式与键盘发送类似但多一个ACK位。键盘应答键盘在收到最后一个停止位后会在下一个时钟周期将Data线拉低作为应答ACK。实现这个功能需要将Data和Clock引脚配置为开漏输出模式并能够随时切换为输入模式以读取键盘的ACK。代码会复杂很多除非必要不建议在资源紧张的项目中实现。6. 常见问题与调试技巧收不到任何数据检查电源用万用表测量键盘接口的VCC和GND之间是否有稳定的5V。检查连接确认Data和Clock线没有接反接触良好。检查引脚配置确认MCU引脚配置为浮空输入GPIO_NOPULL且中断已正确使能并设置为下降沿触发。逻辑分析仪是神器用逻辑分析仪或示波器夹住Data和Clock线按下按键看是否有波形。如果没有可能是键盘不支持PS/2模式或已损坏。收到乱码或数据错误奇偶校验错误检查状态机中奇偶校验的逻辑是否正确。可能是时序问题尝试在时钟中断中稍微延迟一下再读取Data电平用__NOP()或短暂循环以确保数据稳定。电平问题如果MCU是3.3V且非FT引脚可能因电平不匹配导致数据错误。必须加电平转换电路。中断冲突确保PS/2中断的优先级设置合理不会被其他长时间的中断阻塞。多键按压识别不全这很可能是键盘本身的防冲突限制如2KRO。测试方法编写一个简单的程序将接收到的所有扫描码实时打印出来然后尝试同时按下多个键观察输出。如果某些组合下有的键没有通码就是键盘的硬件限制。你需要为项目选择一款支持6KRO或NKRO的键盘。按键反应迟钝或丢失中断服务程序太长优化你的ISR只做最必要的位采集和状态转移把数据处理如映射、存入缓冲区放到主循环。缓冲区溢出如果处理速度跟不上键盘发送速度尤其是在快速连击时会导致数据丢失。增大接收缓冲区rx_buffer的尺寸。主循环阻塞确保主循环中ProcessPS2Data()被频繁调用没有长时间阻塞的延时如HAL_Delay(1000)。使用非阻塞的编程模式。调试时充分利用UART打印。在每个关键阶段进入状态、收到完整字节、解析出按键事件都打印一条信息这是追踪程序流、定位问题最直观的方法。当一切稳定后再移除这些调试输出以优化代码大小和速度。将PS/2键盘集成到单片机项目是一个经典且极具性价比的输入扩展方案。它迫使你深入理解同步串行通信、状态机编程和中断处理这些技能在嵌入式开发中无处不在。从看懂波形图到写出稳定的状态机从点亮第一个按键到处理复杂的组合键这个过程本身就是一次扎实的工程训练。当你看到自己编写的代码能够流畅地将键盘输入转化为项目中的具体动作时那种成就感远非调用一个现成的库函数可比。