简介面向单片机开发者的PS/2键盘模拟程序资源包演示如何让单片机模拟PS/2键盘协议主动向计算机发送按键扫描码实现单片机控制电脑操作的目标适用于嵌入式输入设备、自动化测试台架及特殊人机交互场景。压缩包内含3个文件以1个C源文件与2个头文件为主源码负责数据包构建与发送头文件分别提供扫描码映射表和底层引脚操作定义整体体积仅2KB代码高度精简方便直接阅读和移植。目前已有543人学习下载。通过研读源码可以厘清PS/2协议中起始位、数据位、奇偶校验位和停止位的完整封装过程理解104键键盘扫描码按下与释放两种状态以及单片机如何借助中断服务程序响应按键并处理时钟同步、数据线冲突等异常对提升嵌入式编程和硬件接口调试能力有明显价值。1. PS2键盘模拟程序一台只有PS/2口的设备如何用程序“假装”能打字我曾经给一台老式工控机做过自动注入脚本的测试台那台机器没有USB口只有一个PS/2键盘接口。手头没有原装键盘又不能一直人工按机器人于是我用一块MCU接了根线写了段PS2键盘模拟程序让主板以为对面坐着一个永远不会手抖的操作员。这个程序并不难但它和“发几个字节到串口”完全不是一个难度级别PS/2是双向协议有开漏总线、奇校验、扩展扫描码还有主机在开机时发来的一堆“盘问”。如果你只想去网上抄一些扫描码表大概率会卡在主机不认键盘、按键粘住、甚至开机阶段就黑屏报错。这篇笔记从协议讲起落到可复现的GPIO实现、键位映射、组合键宏最后给排查清单和进阶验证方法。适合产线自动化、硬件测试、老设备改造的从业者。2. 动手前先弄懂PS/2协议时序、扫描码和主机握手2.1 从一根时钟线和一根数据线看主机键盘怎么“对话”PS/2接口的物理引脚里真正参与通信的只有两根线CLK和DATA剩下是VCC和GND。这两根线都是开漏结构主板侧有上拉电阻到5V空闲状态下CLK和DATA都是高电平。开漏意味着任何一端都可以主动拉低但不会主动拉高所以总线天然避开了“两个设备同时输出高电平”的短路问题。标准PS/2通信里键盘是“设备端”设备负责产生时钟。主机发送数据时会把CLK拉低一段时间来“抑制”设备之后通过时序交换方向。对我们写模拟程序来说最核心的是掌握“设备主动发送一帧数据”的格式一个起始位0、8个数据位LSB在前、1个奇校验位、1个停止位1。这11个bit会呈现在CLK和DATA两根线的时间关系上逻辑分析仪必须同时抓两个通道否则看不出完整报文。真正容易混淆的点在于CLK在每个bit传输时都要经历一次下降沿和上升沿。设备在CLK为低时改变DATA电平CLK上升沿时被主机采样下一个bit再拉低CLK。所以写代码时不能只操作DATA还要严格翻转CLK。如果CLK保持高电平数据线乱变主机认为总线状态非法会直接丢弃整帧。这里建议先把逻辑分析仪接好观察真实键盘上电初始化时的波形再动手写发送函数。2.2 扫描码不是ASCIIMake/break与扩展键的前世今生PS/2键盘上报给主机的不是ASCII码而是“扫描码”。现代键盘主流是Scancode Set 2按下键发送make code松开键发送break code。常见做法里把Set 2里的数据转换成主机BIOS需要的Set 1是主板芯片组的事键盘本身只按Set 2工作。所以模拟程序也要发Set 2扫描码除非你明确知道目标主机需要什么。普通键的make码是单字节例如字母A的Set 2 make code是0x1C。松开A时发送break码0xF0 0x1C。注意这两字节的顺序不能反过来一旦反过来主机可能会误解为“先按了F0这个键再按下A”。扩展键则更麻烦比如右方向键的make是0xE0 0x74break是0xE0 0xF0 0x74E0必须出现在前面。Windows键、菜单键、右Alt、右Ctrl也都是E0开头。Print Screen、Pause这类键的扫描码序列又长又怪实战里我很少直接模拟除非设备确实需要。为什么主机要知道break码因为主机自己维护一个“当前哪些键被按下”的状态表。收到make更新状态收到break清除状态。如果你只发make不发break主机认为这个键永远被按着效果就是“按键粘住”。这在模拟快捷键时特别常见后面避坑章会展开。2.3 主机在开机时可能“问”你识别、模式命令和响应表PS/2键盘不是你发数据就完事因为主机在开机阶段会主动发命令给键盘。如果你不响应主板可能认为这个键盘没通电或者干脆卡在初始化阶段长时间黑屏。我取了一个典型主板上电流程键盘上电自检完成后主动发0xAA给主机表示“BAT完成”。主机收到后往往发0xFFreset键盘要回0xFAACK然后再发一次0xAA。之后主机可能发0xF4启用或0xF0读取键盘ID键盘需要回ACK和ID信息。这里有一个常见的设计误区很多教程只写“发送扫描码”函数却不写接收主机命令的函数。结果在真实主板上键盘发完0xAA后主机立刻reset程序没有应答主机永远等不到ACK最后键盘名都没有。为了避免这个坑哪怕只做最小实现也需要一个最简单的“收到任何命令都回0xFA”的应答逻辑并把特殊命令单独拆出来。下面这张表是模拟程序至少要处理的命令主机命令含义键盘应答0xFF复位先0xFA再发0xAA (BAT完成)0xF4启用扫描0xFA然后进入正常发送模式0xF5禁用扫描0xFA进入禁用态但可以继续响应命令0xF0读取键盘ID0xFA然后0xAB 0x83标准键盘0xED设置指示灯0xFA等待一个LED状态字节模拟实现里最简单的“通用回复ACK”也能绕过大部分主板。但如果要做产品级固件建议把每个命令的状态变化都记录下来至少在日志上能看到主机叫你做过了什么。这个阶段的代码不需要很复杂核心是吃透“主机发命令时键盘必须停止主动发送并转入接收状态”。3. 最小实现用MCU的两个GPIO模拟PS/2键盘3.1 硬件接线上拉电阻、方向与供电的常见做法硬件接线比想象中更讲究。PS/2插座上有4个有用引脚DATA、CLK、GND、VCC按6针Mini-DIN标准通常是1DATA、3GND、4VCC、5CLK。接反CLK和DATA非常常见上电后主机不认键盘逻辑分析仪抓不到任何正常帧。建议接线前先用万用表测出VCC和GND再识别DATA和CLK。我这里用某块Cortex-M核心板举例它只有3.3V IO。PS/2主机侧上拉到5V所以IO口必须耐5V并且工作在开漏模式。如果MCU的GPIO结构无法直接耐5V常见做法是加一颗N沟道MOSFET做电平转换DATA和CLK各接一个源极接MCU侧漏极接主机5V上拉侧。不要直接用一个普通推挽IO去接因为主机侧也可能拉低总线推挽输出高会和主机抢总线之前有人因此烧了单片机引脚。还有一个很容易忽略的细节PS/2主机输出的5V通常能提供100mA左右的电流够一块低功耗MCU跑但不够给模块、液晶屏供电。如果整个系统里传感器比较多最好单独供电只跟主机共地避免主机电源被拉垮。真实项目中我总会在DATA和CLK线上各加1kΩ串联电阻可以防呆也减少线缆干扰。3.2 第一个能跑的程序初始化与时钟同步先写占位用的GPIO读写接口让你能快速换成自己平台的驱动。下面这段代码把两个引脚初始化为开漏输出初始输出高电平然后给一个上电稳定延时。/* 占位函数改用你平台的GPIO驱动 */ static void gpio_write(uint8_t pin, int level); static int gpio_read(uint8_t pin); #define PIN_CLK 0 #define PIN_DATA 1 static void ps2_pin_init(void) { gpio_write(PIN_CLK, 1); /* 开漏输出高等待上拉 */ gpio_write(PIN_DATA, 1); /* 设置开漏模式具体看平台寄存器 */ delay_ms(50); /* 上电稳定避免主机误检 */ }时钟同步是发送的关键。在PS/2设备端我们必须先确认主机没有拉低CLK来抑制通信。如果主机正想发送命令它会拉低CLK并保持几十微秒这时你贸然发数据就会造成总线冲突。我习惯在每次发送前加一个“等待CLK恢复高电平”的死循环最多等1ms超时直接返回失败。static int ps2_wait_clock_high(void) { uint16_t timeout 1000; /* 1ms左右 */ while (timeout--) { if (gpio_read(PIN_CLK)) return 0; } return -1; /* 主机一直拉低禁止发送 */ }真正发送一个字节的函数要严格处理11个bit。数据在CLK低电平时改变CLK高电平时保持不变主机从上升沿采样。下面的实现里我设置了15μs的bit延时实际单片机主频不同需要调整太快主机采样不到太慢主机会认为异常并重新同步。static int ps2_send_byte(uint8_t byte) { uint8_t i; uint8_t ones 0; if (ps2_wait_clock_high() ! 0) return -1; /* 起始位拉低CLK再拉低DATA */ gpio_write(PIN_CLK, 0); gpio_write(PIN_DATA, 0); delay_us(15); gpio_write(PIN_CLK, 1); delay_us(5); /* 8个数据位LSB first */ for (i 0; i 8; i) { uint8_t bit (byte i) 1; if (bit) ones; gpio_write(PIN_CLK, 0); gpio_write(PIN_DATA, bit); delay_us(15); gpio_write(PIN_CLK, 1); delay_us(5); } /* 奇校验位让总1个数为奇数 */ gpio_write(PIN_CLK, 0); gpio_write(PIN_DATA, (ones 1) ? 0 : 1); delay_us(15); gpio_write(PIN_CLK, 1); delay_us(5); /* 停止位高 */ gpio_write(PIN_CLK, 0); gpio_write(PIN_DATA, 1); delay_us(15); gpio_write(PIN_CLK, 1); delay_us(5); /* 释放数据线 */ gpio_write(PIN_DATA, 1); return 0; }这里要特别说下奇校验的计算PS/2要求数据位和校验位加起来“1”的个数是奇数。如果8个数据位已经有奇数个1校验位就是0如果偶数个1校验位就是1。代码里用(ones 1) ? 0 : 1表达。许多初版程序这里写反主机收不到任何一帧但逻辑分析仪看着波形又好像没问题。3.3 发送一个按键从Make到Break的完整流程有了send_byte按键事件就成了扫描码的组合。普通键按下时发make松开时先发0xF0再发make。扩展键需要在前面补0xE0。我习惯把“发完整事件”封装成一个函数避免每次重复写break逻辑。typedef enum { KEY_DOWN 0, KEY_UP 1, KEY_EXT 2 } key_flag_t; static int ps2_send_scancode(uint8_t code, uint8_t ext, uint8_t is_break) { if (ext) { if (ps2_send_byte(0xE0) ! 0) return -1; } if (!is_break) { if (ps2_send_byte(code) ! 0) return -1; } else { if (ps2_send_byte(0xF0) ! 0) return -1; if (ps2_send_byte(code) ! 0) return -1; } return 0; }组合到main里就可以实现最简单的“每隔500ms打一个A键”。但这个minimal demo不能直接用在真实主板上因为它没有应答主机命令。我通常用它配合逻辑分析仪抓PS/2帧验证发送时序对不对要真正让主机识别需要接进一个接收函数见第6章。int main(void) { ps2_pin_init(); delay_ms(100); /* 简化调试先发BAT完成码让主机知道键盘存在 */ ps2_send_byte(0xAA); delay_ms(50); while (1) { ps2_send_scancode(0x1C, 0, KEY_DOWN); /* A key make */ delay_ms(20); ps2_send_scancode(0x1C, 0, KEY_UP); /* A key break */ delay_ms(500); } }参数说明make和break之间的延时建议不要小于10ms。如果小于10ms有些主机内部的按键消抖逻辑会把连续两帧合并成一次按键抖动导致输出不稳定。我一般设20ms起步测试稳定后再往下压。break之后到下一次make之间至少留50ms给主机处理扫描码状态表足够的时间。4. 扫描码表、键位映射和组合键设计4.1 常用按键的扫描码表字母、数字、功能键自己每个键去查协议文档很浪费时间我直接把常用的Set 2扫描码整理成了一组表放在固件里当常量用。注意这里是make codebreak码是“0xF0 make code”扩展键break码是“0xE0 0xF0 make code”。按键Set 2 make按键Set 2 makeA0x1CB0x32C0x21D0x23E0x24F0x2BG0x34H0x33I0x43J0x3BK0x42L0x4BM0x3AN0x31O0x44P0x4DQ0x15R0x2DS0x1BT0x2CU0x3CV0x2AW0x1DX0x22Y0x35Z0x1A00x4510x1620x1E30x2640x2550x2E60x3670x3D80x3E90x46Enter0x5ASpace0x29Esc0x76Backspace0x66Tab0x0DCapsLock0x58功能键的make码值得单独记忆F10x05、F20x06、F30x04、F40x0C、F50x03、F60x0B、F70x83、F80x0A、F90x01、F100x09、F110x78、F120x07。这里的大写字母处理不能简单替换扫描码主机不认“大写A”这个键它只认“左Shift A”两个按键的组合。所以在大写锁定没开时要向主机发Shift的make接着发A的make再发A的break和Shift的break。这一点很多第一次做模拟程序的人会踩后面组合键小节会专门设计。4.2 扩展键方向键、Print Screen怎么处理扩展键的麻烦在于绝对不能在E0后面省字节。方向键上、下、左、右的make分别是上E0 75下E0 72左E0 6B右E0 74。它们的break是E0 F0 75等不是F0 E0 75。发送函数里我已经把E0和F0的顺序固定好了但如果你要手写一定注意这个细节。Insert、Delete、Home、End、PageUp、PageDown也都是扩展键具体如下按键Set 2 makeSet 2 breakInsert0xE0 0x700xE0 0xF0 0x70Delete0xE0 0x710xE0 0xF0 0x71Home0xE0 0x6C0xE0 0xF0 0x6CEnd0xE0 0x690xE0 0xF0 0x69PageUp0xE0 0x7D0xE0 0xF0 0x7DPageDown0xE0 0x7A0xE0 0xF0 0x7A右Control0xE0 0x140xE0 0xF0 0x14右Alt0xE0 0x110xE0 0xF0 0x11Print Screen和Pause是特例。Print Screen在Set 2下通常模拟为一串组合键扫描码不同主板的处理也不一致老实的做法是先往主机发“左Alt Print Screen”之类组合事件或者干脆把你需要的那一个键换成同功能的其他键。如果你做的KVM测试设备一定要覆盖Print Screen我建议在逻辑分析仪上抓一次真键盘的输出把那串原始序列完整存进EEPROM而不是手抄文档。4.3 组合键和宏先发Make再发Break的顺序坑组合键的正确发送顺序不是“按一个键松开再按下一个键”而是先把所有需要按下的键都发送make然后再逐个发送break。以CtrlAltDelete为例必须依次发左Ctrl make、左Alt make、Delete make然后再发三个键的break。如果中途混入break主机看到的就只是单个快捷键或者更糟触发一个键盘缓冲区里的孤立按键。/* 用结构体表达一个按键事件ext表示是否E0前缀 */ typedef struct { uint8_t code; uint8_t ext; } ps2_key_t; /* 发送一组按键全部make结束后再全部break */ static void ps2_send_chord(const ps2_key_t *keys, uint8_t count) { uint8_t i; for (i 0; i count; i) { ps2_send_scancode(keys[i].code, keys[i].ext, KEY_DOWN); delay_ms(2); } for (i 0; i count; i) { ps2_send_scancode(keys[i].code, keys[i].ext, KEY_UP); delay_ms(2); } }调用这个函数模拟CtrlAltDeleteconst ps2_key_t ctrl_alt_del[] { { 0x14, 0 }, /* 左Ctrl */ { 0x11, 0 }, /* 左Alt */ { 0x71, 1 } /* Delete扩展键 */ }; ps2_send_chord(ctrl_alt_del, 3);这里有一个藏在结构体里的坑Delete是扩展键break时我也是先发E0再发F0而上一小节里明确说了扩展键break是E0 F0 xx。因为ps2_send_scancode的终实现就是按“E0在前”写的所以结构体里code0x71, ext1直接可用。但如果你从老教程里抄来“先发F0再发E0”的版本改起来会很痛苦。宏脚本也同理录制宏时不要把处理过的ASCII序列发出去一定要按扫描码序列存。5. 避坑与排查数据线没信号、主机不认键盘、按键“粘住”怎么办5.1 现象一主机开机后键盘灯不亮逻辑分析仪看不到时钟现象程序烧进去上电后只看到DATA和CLK都是高电平逻辑分析仪没有任何帧主板报错“键盘错误”。原因最常见是CLK和DATA接反或者MCU引脚不是开漏模式。还有一个容易被忽视的问题PS/2主机的CLK和DATA内部上拉电阻约10kΩ如果外部再并一个强上拉总线电平抬太高设备端拉低时可能拉不动。解决先用万用表量主机侧空闲电压正常应该看到3.5V以上。然后把MCU的两个引脚都设为开漏输出高再手动把DATA拉低几次观察主机侧是否出现低电平。如果拉不高检查核心板的IO是否真的支持开漏并耐5V如果拉低了但逻辑分析仪还是没波形就把CLK和DATA互换试试。5.2 现象二发普通键正常Stable组合键失效现象单发字母A、数字1都好使但只要一按带E0前缀的键比如Delete或方向键主机就没反应。原因E0前缀和后续扫描码之间延时太短。我遇到过把两个字节连续无间隔发送结果主机把E0和后面的字节当成一帧超长数据直接丢弃。另一个原因是break码没发E0比如发Delete的break时漏了E0主机认为是一个未知键。解决给E0和后续字节之间至少5μs间隔我这个实现里发送每个字节本身就会占用几十微秒所以没问题。如果还失效检查发送顺序是否为“E0-F0-code”而不是“F0-E0-code”。另外扩展键的make和break之间也要留出至少几十毫秒太快的组合键有些主板不识别。5.3 现象三按键卡死——忘了发Break现象按下一次后主机一直收到这个键的重复触发屏幕上连续跳出同一个字符直到手动发一遍break代码才停。原因PS/2主机内部保存着“按键状态表”。你没发break状态一直为“按下”主机会按键盘自身的重复速率持续产生字符。很多宏脚本在发组合键时只发了所有makebreak忘了循环释放。解决把每一次make调用都对应一个break调用。我通常用状态机包装维护一个bool变量表示键当前是否按下每次执行发送按键事件时先判断是否重复按下如果重复则先发上次的break再发新的make。组合键场景则用第4章已经写好的ps2_send_chord从结构上保证所有键最终都会被释放。5.4 现象四与主机双向通信时的总线冲突现象上电后前几帧成功但主机开始发送命令后我们的程序就再也不能发数据逻辑分析仪看到DATA线出现两条叠加的毛刺。原因设备在主机发送命令时还在主动发扫描码两个开漏输出同时抢占DATA线。PS/2主机发送命令前会把CLK拉低并保持你的程序没检测这个状态就继续发最终总线紊乱。解决所有发送函数第一件事就是ps2_wait_clock_high()。只要CLK不是高电平说明主机正在使用总线或者正想给我们发命令此时应该停止发送并等待。更稳的做法是收到主机任意命令后进入“接收态”清空发送缓冲直到主机下发启用命令再恢复键盘扫描。这个在第6章会结合接收函数一起做。5.5 现象五主机上电时间很晚程序已经发完了0xAA现象用独立电源给MCU供电MCU上电瞬间就按我们的逻辑发了0xAA但主板电源晚了几秒才稳定主板根本没收到这个BAT完成码导致键盘不识别。原因PS/2键盘上电必须在主机电源之后或者至少要等主板上电稳定再发0xAA。很多工控机主板电源时序很慢设备侧如果抢先完成BAT主机不会重收。解决程序里不要一上电就发0xAA而是先延时23秒并检测主机是否把CLK拉低来请求通信。如果检测到CLK被拉低说明主机已经就绪再发送0xAA。真实项目里我还会增加一个“按任意键唤醒”的检测逻辑上电后如果主机一直不拉低CLK则每隔一秒尝试发一次0xAA直到主机响应为止。6. 进阶自检回环、多键无冲与休眠唤醒的实现技巧6.1 自检回环先让模拟程序能“回读”主机命令最小实现里没有接收函数这在真实主板上一多半无法稳定工作。我建议你至少写出一个极简的ps2_read_byte()只做两件事检测CLK下降沿然后按11位帧格式收下数据。主机发命令时CLK会被设备接管并产生时钟实际上主机先拉低CLK然后释放由我们设备端产生时钟。也就是说接收命令时我们依然要输出CLK只是数据方向反了。很多现成代码是这样做的等待CLK低拉低CLK读DATA起始位再产生时钟驱动主机。对不熟悉协议的人来说全双工回环很难一次写对。我常用的折中方案是放弃完整接收只靠“检测CLK低电平”来暂停发送。主机发reset时程序虽然不解析但主机一般会重复发直到ACK。最终我建议参考成熟PS/2库但保留自己的发送函数因为发送方向出错概率小排查也更直观。实测通过后再慢慢完善接收。6.2 多键无冲的极限与实现取舍PS/2理论上没有“无冲”上限因为主机靠扫描码状态表识别。但在模拟多个键时必须维护好状态表否则发送顺序错乱后主机状态和真实键盘不一致。我一般做一个位图数组每个键对应一个bit记录当前是按下还是抬起。每次需要改变键状态时先查位图如果状态没变就不用发送如果变了先发送对应的make或break再更新位图。多键无冲的真正瓶颈在于MCU的GPIO刷新速度和主机端口的采样能力。一次发送11个bit约200μs同时按下6个键就是1.2ms对主机来说完全能接受。最怕的是你在脚本里用send_chord的同时又去操作UART、SPI导致发送函数被反复打断。真实项目里我建议把PS/2发送放在一个独占的定时器中断上下文或者用DMA配合GPIO翻转避免在高优先级中断里发送过程中被改写电平。6.3 休眠唤醒和收尾习惯老式主板进入休眠后会发0xF5让键盘停止扫描等用户按任意键后发0xF4恢复。模拟程序如果不处理这两条命令就会在休眠唤醒后“失联”。我一般把“进入禁用态”设计成标志位收到0xF5后不再发送扫描码但保留GPIO唤醒检测收到0xF4后再开始正常发送。我自己的收尾习惯是每次改完时序代码先跑一遍ps2_send_byte自检回环把回读到的字节和发送的原始字节对比再按第3章的最小demo让主机识别最后才加扫描码表。这些年踩过的坑里八成是电源时序、CLK/DATA接反、奇校验写反这三个。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?