简介面向电子技术、嵌入式系统开发者的PS/2键盘模拟程序源码可让单片机仿真PS/2键盘协议、向计算机发送标准键码序列适用于自制输入设备、自动化测试工装及特殊操作终端等场景。压缩包虽小但五脏俱全共3个文件、整体约2KB其中1个C源文件完整实现扫描码生成、数据包起始位/数据位/奇偶校验位/停止位封装以及中断服务函数2个头文件分别定义协议相关宏与104键扫描码映射表结构清晰便于初学者对照代码理解串行时序。目前该资源已有543人学习浏览读者能从这份精简工程中看到数据线冲突、时钟同步等异常情况的处理思路并快速上手PS/2协议模拟的编程方法是嵌入式方向提升底层驱动能力的不错范本。1. PS2键盘模拟程序老设备的按键注入方案到底在做些什么一台服役了十几年的老工控机上位机软件锁死在某个版本产线改造又不能碰原系统唯一的操作入口就是那个圆形的 PS/2 键盘口。想让它自动执行按键操作第一反应是买 USB 转 PS/2 转接器但实践中会发现这类转接器在 BIOS 自检阶段经常不被识别更别说按需发送组合键。于是直接用一个 MCU 在 GPIO 层面模拟 PS/2 键盘协议就成了把这台老设备接入自动化测试的最稳妥方案。这套 PS2 键盘模拟程序解决的就是这么一个问题它用软件按时序驱动时钟线和数据线把自己伪装成一个标准 PS/2 键盘让主板开机自检通过然后把按键扫描码按需注入到系统中。适合需要批量按键注入、老设备改造或产线自动化测试的嵌入式开发者拿到手主要改扫描码表和 IO 引脚就能落地跑起来。2. PS/2协议要点为什么软件模拟能骗过主机BIOS2.1 方案选型转接器、USB Host还是GPIO软模拟先聊一下选型。老设备只有 PS/2 键盘口想在外部注入按键表面上有三条路。第一条是买被动 USB 转 PS/2 转接器但这类转接器大多只支持 USB 键鼠芯片直接转换很多工业主板的 BIOS 只认原生 PS/2 设备转接器在自检阶段就掉链子。第二条是用 USB Host 板去读取 USB 键盘再转为 PS/2 信号输出硬件成本和软件复杂度都高而且延迟不可控。第三条也是最常见的做法用 MCU 的 GPIO 直接模拟 PS/2 总线的时钟和数据线按协议帧格式发送扫描码。这个方案成本低、时序可控、便于定制组合键缺点是 MCU 需要空闲去完成位级时序对代码结构有一定要求。模拟项目 X 当初用的就是 STM32 系列 MCUPB0 做时钟线PB1 做数据线软件实现整个键盘协议栈。如果你手上的 MCU 主频在 48MHz 以上跑这套完全没问题。很多开发者习惯先写命令响应再写按键发送我的习惯是把协议状态机先立起来后面所有按键操作都基于状态机分发排查问题路径最短。2.2 PS/2帧格式与关键时序参数PS/2 协议本质上是 11 位帧加奇偶校验的半双工串行协议。时钟频率允许在 10kHz 到 16.7kHz 之间常规设计取 12kHz 到 15kHz即时钟周期大约 60 到 80 微秒。空闲时时钟线和数据线都被外部上拉电阻拉高设备要发数据时先确报时钟线处于高电平说明主机没有在占用总线然后把数据线拉低作为起始位之后逐位产生时钟脉冲。帧格式比较固定1 位起始位低电平8 位数据位LSB 在前1 位奇校验位1 位停止位高电平。校验位要求数据位中 1 的数量加上校验位本身是奇数。比如数据 0x1C二进制为 00011100其中 1 的个数是 3那么校验位就是 0。如果个数是偶数校验位就是 1。主机在时钟上升沿之后的高电平窗口读取数据设备则必须在时钟低电平期间完成数据线的切换。这个约定决定了软件发送函数里必须先拉低时钟、再修改数据线、再拉高时钟的次序顺序反了就会出现主机采样到错误数据。关键时序参数大致如下表参数典型值说明时钟频率12 ~ 15 kHz周期约 66 ~ 83 微秒时钟高电平时间30 ~ 40 微秒主机采样窗口时钟低电平时间30 ~ 40 微秒数据线切换窗口主机钳制时钟大于 100 微秒表示主机要发送命令帧长度11 位1 起始 8 数据 1 校验 1 停止2.3 主机命令交互与状态机基线PS/2 并不只是键盘单方面发按键数据主板上电自检时会向键盘发送命令键盘必须正确应答否则 BIOS 直接报键盘错误。常见主机命令包括复位 0xFF读设备 ID 0xF2启用扫描 0xF4禁用扫描 0xF5。键盘正确响应 0xFF 的方式是先回 0xFA 应答再回 0xAA 表示自检通过响应 0xF2 则是先回 0xFA再回 0xAB 0x83 两组设备 ID。如果这些命令不回包老主板根本不认为键盘存在。常见做法是初始化后不急着发按键先进入一个命令等待状态只有收到 0xF4 后才真正允许发送扫描码。主机命令的交互时序相对复杂主机会先把时钟线拉低至少 100 微秒抢占总线再把数据线拉低请求发送之后释放时钟线由设备侧继续产生时钟脉冲逐位读入命令字节。因此软件实现里至少需要一个检测时钟线被主机拉低的机制以下降沿或轮询方式进入命令接收状态。我一般用外部中断检测时钟线的下降沿配合一个 1 毫秒软件定时器做超时兜底这样既快又避免中断里做太多事情。3. 模拟程序实现扫描码映射、发送函数与初始化流程3.1 Set 2扫描码表与按键映射设计PS/2 键盘默认使用 Set 2 扫描码与 PC 键盘常见的 Set 1 不同主机收到后自行转换。模拟程序里直接发 Set 2 扫描码就行。普通按键的扫描码是单字节例如字母 A 对应 0x1CB 对应 0x32C 对应 0x21回车键 0x5A空格键 0x29。数字键 1 到 9、0 分别对应 0x16、0x1E、0x26、0x25、0x2E、0x36、0x3D、0x3E、0x46、0x45。方向键属于扩展键需要在扫描码前加 0xE0 前缀比如上方向键是 0xE0 0x75。按键按下时发送 make 码松开时发送 break 码即先发 0xF0 再发该键的 make 码。映射表设计时建议把常用键位放在一个数组中方便批量注入和改动。// keymap.h常用按键的 Set 2 扫描码 #define KEY_A 0x1C #define KEY_B 0x32 #define KEY_C 0x21 #define KEY_ENTER 0x5A #define KEY_SPACE 0x29 #define KEY_ESC 0x76 #define KEY_UP 0x75 // 需要与 0xE0 前缀搭配 #define KEY_LEFT 0x6B #define KEY_RIGHT 0x74 #define KEY_DOWN 0x72逻辑说明这里只列出演示项目里会高频使用的按键实际项目里可以按键盘布局补齐整个映射表。扩展键与普通键的发送区别是是否先发 0xE0 前缀映射表设计时最好把扩展键单独标记避免后面误用。参数说明Set 2 扫描码与键盘上印的文字是两套体系调试时如果发送后屏幕出现不是预期的字符先查映射表不要怀疑协议层。字母 A 发 0x1C 会显示 a如果配合 Shift 键再发就显示 A组合键语义由接收端系统决定。3.2 GPIO初始化和11bit帧发送函数PS/2 总线电气特性要求 GPIO 必须配置为开漏输出并依靠外部上拉电阻保持高电平。如果误配成推挽输出硬件空闲时时钟线和数据线会被强行拉成一个固定电位主机检测不到总线空闲状态整个通信直接失败。初始化代码里还需要把两个引脚默认置为高电平输出释放总线。下面这段是常见 MCU 平台上的 GPIO 初始化写法适用范围很广。// ps2_gpio.c时钟线与数据线初始化 void ps2_gpio_init(void) { GPIO_InitTypeDef gpio {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); // 使能 GPIOB 时钟 gpio.Mode GPIO_MODE_OUTPUT_OD; // 开漏输出 gpio.Pull GPIO_NOPULL; // 外部已经有上拉电阻 gpio.Speed GPIO_SPEED_FREQ_LOW; gpio.Pin GPIO_PIN_0 | GPIO_PIN_1; // PB0CLK, PB1DATA HAL_GPIO_Init(GPIOB, gpio); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0 | GPIO_PIN_1, GPIO_PIN_SET); }逻辑说明开漏输出下GPIO 寄存器置 1 会让引脚处于高阻状态总线由外部上拉拉高置 0 则引脚拉低总线。CLK 和 DATA 都设成输出平时保持高阻态发送时再逐位切换。外部上拉电阻一般取 4.7k 到 10k接 3.3V 或 5V 均可但电平匹配要考虑后文避坑部分的细节。参数说明GPIO_SPEED_FREQ_LOW 足够PS/2 时钟频率只有十几 kHz不需要高速翻转低速模式反而能减少毛刺。如果开发板上没有外部上拉需要自行加两个 4.7k 电阻到对应电源轨。下面给出最核心的 11 位帧发送函数。这个函数把所有位动作统一处理起始位、数据位、校验位、停止位都按同一套时钟脉冲逻辑发送代码简洁且可读性好。// ps2_send.c发送一个完整的 11 位 PS/2 帧 int ps2_send_frame(uint8_t scancode) { uint8_t frame[11]; uint8_t parity 1; // 奇校验初始值 int i; // 1. 组装帧 frame[0] 0; // 起始位L for (i 0; i 8; i) { frame[i 1] (scancode i) 0x01; if (frame[i 1]) parity ^ 1; } frame[9] parity; // 校验位 frame[10] 1; // 停止位H // 2. 等总线空闲CLK高 for (i 0; i 200; i) { if (PS2_CLK_READ() 1) break; delay_us(10); } if (i 200) return -1; // 主机一直占用总线 // 3. 逐位发送 for (i 0; i 11; i) { PS2_CLK_LOW(); // 时钟先拉低 if (frame[i]) { PS2_DATA_HIGH(); // 数据线释放靠上拉为高 } else { PS2_DATA_LOW(); // 数据线拉低 } delay_us(20); // 等待数据线稳定 PS2_CLK_HIGH(); // 产生上升沿 delay_us(35); // 保持高电平让主机采样 } PS2_DATA_HIGH(); // 发送完毕释放总线 return 0; }逻辑说明组装帧时把 LSB 放在第一个数据位发送正好符合 PS/2 协议。校验位计算从 parity 初始值 1 开始每遇到数据位为 1 就翻转一次。如果所有数据位里 1 的个数是奇数最终 parity 为 0是偶数则 parity 为 1这样总 1 数保持奇数。发送前等待总线空闲是因为主机可能正钳制时钟准备发命令强行发送会撞车。逐位发送中每个位都先拉低时钟再修改数据线随后拉高时钟产生上升沿这个顺序和协议要求严格一致。参数说明延迟参数按 12kHz 时钟设计低电平 20 微秒、高电平 35 微秒总周期约 55 微秒。如果目标平台延迟误差大可以把两组延时都延长到 40 微秒仍然在协议许可范围内稳定性优先。3.3 make/break码发送与按键注入任务单个按键的发送需要区分按下和释放按下发 make 码释放发 break 码。PS/2 语义里 break 码由 0xF0 前缀加键位扫描码组成例如 A 键释放要依次发送 0xF0 和 0x1C。如果只发按下码不发释放码主机系统会认为这个键一直被按住开启自动重复。扩展键还要在 make 或 break 码前面加 0xE0 前缀。下面封装三个常用函数直接覆盖普通键和扩展键的按下、释放场景。// ps2_key.c按键发送封装 void ps2_key_down(uint8_t code) { ps2_send_frame(code); } void ps2_key_up(uint8_t code) { ps2_send_frame(0xF0); ps2_send_frame(code); } void ps2_key_down_ext(uint8_t code) { ps2_send_frame(0xE0); ps2_send_frame(code); } void ps2_key_up_ext(uint8_t code) { ps2_send_frame(0xE0); ps2_send_frame(0xF0); ps2_send_frame(code); }逻辑说明释放扩展键时先发 0xE0 再发 0xF0这个顺序不能反。0xE0 前缀表示后续扫描码属于扩展按键0xF0 是 break 前缀两者配合主机才能正确识别。普通键释放只需要 0xF0 加扫描码。参数说明code 参数传的是 Set 2 make 码非 ASCII 码。调用 ps2_key_up(KEY_A) 表示松开 A 键而不是发送大写字母 A 的某种编码这一点新手上手时最容易混淆。在实际应用里按键注入通常要保证按下-保持-释放的完整过程特别是组合键比如 CtrlAltDelete 重启老设备。以下是一个简易注入任务示例按顺序执行组合键序列。// task.c组合键注入示例 void inject_ctrl_alt_del(void) { ps2_key_down(0x14); // Ctrl 按下 ps2_key_down(0x11); // Alt 按下 ps2_key_down(0xE0); // Delete 是扩展键 ps2_key_down(0x71); // Delete make码 delay_ms(50); // 保持一段时间 ps2_key_up_ext(0x71); // Delete 释放 ps2_key_up(0x11); // Alt 释放 ps2_key_up(0x14); // Ctrl 释放 }逻辑说明组合键注入的关键是按住多个键时必须让系统感知到按键状态叠加全部按下后再统一释放。释放顺序一般与按下顺序相反防止某个键过早弹起触发系统误判。Ctrl 和 Alt 是普通键不做扩展前缀Delete 是扩展键所以释放用扩展版本。参数说明中间 delay 的时间决定了按键保持时长一般在 30 到 100 毫秒之间足够被系统识别。如果发送太快部分老系统可能存在消抖逻辑漏掉按键这时可以把延时加大到 100 毫秒。按键注入任务的实际调度方式取决于项目需求。常见做法是开一个串口或网络接口接收命令例如收到字符串 key a down 就执行 ps2_key_down(KEY_A)收到 key a up 就执行 ps2_key_up(KEY_A)。模拟程序的主体结构就是命令解析加按键发送两层协议栈与业务解耦后面扩展更多按键动作只需要维护扫描码表。4. 避坑指南PS2模拟的六个常见坑与排查4.1 上电握手失败导致BIOS不认键盘现象模拟键盘插上后主板自检直接报 Keyboard error or no keyboard present甚至卡在开机画面。原因BIOS 在上电后会向键盘发送 0xFF 复位命令键盘要依次回复 0xFA、0xAA然后 BIOS 再发 0xF2 读设备 ID键盘要回复 0xFA、0xAB、0x83。模拟程序如果上电后只发按键扫描码不处理主机命令就等于一个不存在的键盘。很多初版实现翻车在这一步因为协议栈只写了发送方向完全没有接收主机命令的机制。解决必须实现命令接收与应答流程。收到 0xFF 后回 0xFA、0xAA收到 0xF2 后回 0xFA、0xAB、0x83收到 0xF4 后置扫描使能标志此后才允许发送按键码。初始化阶段先用只响应命令的模式跑通 BIOS 自检再去接业务注入逻辑。4.2 发送期间与主机命令抢总线现象按键注入过程中偶发乱码或者某个按键被主机系统识别成了完全不同的键甚至触发系统关机命令这个最吓人。原因键盘发送期间如果主机需要发命令它会先拉低时钟线。我们的发送程序如果没检测到这个抢占继续把帧发下去时钟线被主机拉住导致数据位错乱。故障特征是偶发性强跟按键注入时序相关。解决发送前检查时钟线状态只是第一道防线发送过程中每一拍都要检测时钟线是否一直保持高电平。发现异常立即放弃当前帧并释放总线等待总线空闲后再重新发送。我一般每次发送前设置一个 500 微秒超时超时后返回错误码由上层任务决定是否重发避免死等。4.3 电平方向搞错GPIO配成推挽输出现象上电后量时钟线和数据线电压发现一个或两个引脚一直为低电平主机完全无法识别键盘。原因PS/2 总线是开漏加外部上拉结构空闲时为高。如果 GPIO 配置成推挽输出初始化时写高电平引脚和外部上拉同时作用倒不会立刻出问题但发送过程中推挽强行驱动低电平再释放的瞬间电流路径会变得异常。更常见的是初始化时写错了电平方向引脚被初始化输出低电平总线直接锁死在低。也有数据线和时钟线在硬件上接反的情况总线还能工作但完全不发正确数据。解决GPIO 统一配置为开漏输出初始化后置 1。用万用表量空闲电压时钟和数据都应该是高电平。如果量到低电平先检查初始化代码里默认电平再检查外部上拉是否焊好。数据线和时钟线的物理引脚顺序要以自己板子的原理图为准不要按颜色或接口形状想当然。4.4 按键释放码缺失导致系统卡键现象发送一次 Shift 键按下后主机一直处于大写状态或者某个方向键按下后系统菜单一直往下滚。原因释放按键时漏发了 0xF0 前缀或者把扩展键的释放顺序写反。PS/2 键盘按下和释放是明确区分的事件系统不会自己去判断键是否已松开。常见书写错误是普通键释放直接发 scan code没带 0xF0扩展键释放发成 0xF0 0xE0 0xcode顺序写反。解决统一走封装函数不要手动拼帧。检查代码里所有释放路径确认普通键释放是 0xF0 scan code扩展键释放是 0xE0 0xF0 scan code。组合键释放顺序从后往前不要一开始就松开修饰键。在状态机里维护一张按键状态表记录每个按键当前是否处于按下状态发送释放码前先查表能有效防止重复释放。4.5 5V与3.3V电平转换依赖侥幸现象某开发板用 3.3V MCU 直连台式机主板 PS/2 口短时间调试正常长时间运行后偶发按键丢失或乱码换一台电脑后问题更明显。原因PS/2 接口标准是 5V TTL 逻辑电平3.3V 开漏上拉到 3.3V 时高电平电压刚好在多数主板输入阈值附近。有些台式机主板能识别有些笔记本或工控板识别不了。开漏结构本身有一定兼容性但这属于电气上的侥幸设计量产或长期运行风险较高。解决如果 MCU 供电是 3.3V增加双向电平转换电路常见做法是用两个 N 沟道 MOSFET 交叉连接实现双向转换成本低效果好。如果板子供电是 5V直接外部上拉到 5V 也能兼容。总的原则是 PS/2 高电平以 5V 为参考设计不要靠 3.3V 的边际电压去赌兼容性。4.6 时钟频率偏差导致特定主板乱码现象在 A 主板上按键全部正常换到 B 主板后按键乱码或延迟明显。用示波器抓时钟频率发现实际时钟周期只有 40 微秒左右远超标准。原因PS/2 时钟允许范围是 10kHz 到 16.7kHz但有些老主板对时钟上限更敏感。软件延时函数如果用 for 循环空转在优化等级不同的编译器下周期差异可能达到一倍以上。代码里写了 35 微秒延时实际跑出来只有 15 微秒时钟就飙到 25kHz 以上主机采样窗口变小数据出错。解决用硬件定时器做微秒级时基或者直接把时钟周期拉长到 80 微秒以上留足余量。代码里延时参数宁可大一些也不要激进PS/2 是低速协议快 10 微秒不会带来任何体验提升慢 20 微秒也完全没影响。批量测试时至少在两台不同主板上验证过再固化参数。5. 进阶技巧用逻辑分析仪验证时序与降低发送延迟PS/2 模拟最怕的是看起来能跑但不知道什么时候翻车。所以我强烈建议你在 Debug 阶段用逻辑分析仪抓一次完整的复位握手和一次按键发送把帧解码打开看看协议栈发送出来的每一位是不是符合预期。逻辑分析仪不需要很贵采样率 24MHz 以上的入门型号就够用CLK 接一个通道DATA 接一个通道地线共地触发条件设成时钟线下降沿。抓到的波形里重点查看三件事帧起始位是否为低电平、数据位是否 LSB 在前、发送完毕后总线是否恢复到高电平。如果看到某一位数据在时钟高电平期间跳变说明发送函数在数据线切换时机上还没有完全遵守低电平时换数据的约定需要调整 CLK 拉低和 DATA 修改的顺序。调试时可以直接利用主机命令做辅助验证。上电后抓一次 0xFF 复位命令的完整交互能看到主机拉低 CLK 至少 100 微秒、然后拉低 DATA、再释放 CLK 的过程这是理解整个协议总线控制权切换的最好教材。从那以后我每次接 PS/2 模拟都强制自己先抓一次复位握手波形再去谈业务功能和按键映射。如果波形里看到主机命令没触发或者我们的应答字节多了一位按避坑章节里的排查顺序走一遍基本都能找到是状态机的问题还是电气参数的问题。关于降低发送延迟有一个实用技巧把帧发送放到一个独立的高优先级任务中用信号量触发。当上层任务需要连续发送多个按键动作时直接把按键序列放入一个队列由一个持续运行的发送任务逐个取出并执行中间不切换线程优先级这样就能避免在延时过程中被其他任务干扰。队列长度推荐至少 64 字节能覆盖常见的组合键序列。每次从队列取出一帧就调用 ps2_send_frame不等全部取完延迟最低。如果是裸机环境发送期间关闭全局中断发送完再打开也能达到同样效果但关闭中断的最长时间不要超过 2 毫秒否则影响其他外设响应。还有一个常见需求是做按键模拟的自动化测试发送频率本身不是瓶颈瓶颈往往在主机侧系统对键盘事件的消费能力。按键间隔至少保持 20 毫秒组合键之间保持 50 毫秒这样既能保证系统完整处理每个事件又不会显得慢。如果实测发现丢键把间隔翻倍再试如果翻倍后仍然丢键问题大概率在电气或状态机层面而不是发送速度。验证时序的最终标准不是代码看着对而是逻辑分析仪上每一位都对不同主板上都不出错。希望这些经验能帮你少走弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?