简介:面向单片机开发者的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 扫描码不是ASCII:Make/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 0x74,break是0xE0 0xF0 0x74,E0必须出现在前面。Windows键、菜单键、右Alt、右Ctrl也都是E0开头。Print Screen、Pause这类键的扫描码序列又长又怪,实战里我很少直接模拟,除非设备确实需要。
为什么主机要知道break码?因为主机自己维护一个“当前哪些键被按下”的状态表。收到make更新状态,收到break清除状态。如果你只发make不发break,主机认为这个键永远被按着,效果就是“按键粘住”。这在模拟快捷键时特别常见,后面避坑章会展开。
2.3 主机在开机时可能“问”你:识别、模式命令和响应表
PS/2键盘不是你发数据就完事,因为主机在开机阶段会主动发命令给键盘。如果你不响应,主板可能认为这个键盘没通电,或者干脆卡在初始化阶段长时间黑屏。我取了一个典型主板上电流程:键盘上电自检完成后主动发0xAA给主机,表示“BAT完成”。主机收到后往往发0xFF(reset),键盘要回0xFA(ACK),然后再发一次0xAA。之后主机可能发0xF4(启用)或0xF0(读取键盘ID),键盘需要回ACK和ID信息。
这里有一个常见的设计误区:很多教程只写“发送扫描码”函数,却不写接收主机命令的函数。结果在真实主板上,键盘发完0xAA后主机立刻reset,程序没有应答,主机永远等不到ACK,最后键盘名都没有。为了避免这个坑,哪怕只做最小实现,也需要一个最简单的“收到任何命令都回0xFA”的应答逻辑,并把特殊命令单独拆出来。
下面这张表是模拟程序至少要处理的命令:
| 主机命令 | 含义 | 键盘应答 |
|---|---|---|
| 0xFF | 复位 | 先0xFA,再发0xAA (BAT完成) |
| 0xF4 | 启用扫描 | 0xFA,然后进入正常发送模式 |
| 0xF5 | 禁用扫描 | 0xFA,进入禁用态,但可以继续响应命令 |
| 0xF0 | 读取键盘ID | 0xFA,然后0xAB 0x83(标准键盘) |
| 0xED | 设置指示灯 | 0xFA,等待一个LED状态字节 |
模拟实现里,最简单的“通用回复ACK”也能绕过大部分主板。但如果要做产品级固件,建议把每个命令的状态变化都记录下来,至少在日志上能看到主机叫你做过了什么。这个阶段的代码不需要很复杂,核心是吃透“主机发命令时,键盘必须停止主动发送并转入接收状态”。
3. 最小实现:用MCU的两个GPIO模拟PS/2键盘
3.1 硬件接线:上拉电阻、方向与供电的常见做法
硬件接线比想象中更讲究。PS/2插座上有4个有用引脚:DATA、CLK、GND、VCC(按6针Mini-DIN标准,通常是1=DATA、3=GND、4=VCC、5=CLK)。接反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 code,break码是“0xF0 + make code”,扩展键break码是“0xE0 + 0xF0 + make code”。
| 按键 | Set 2 make | 按键 | Set 2 make |
|---|---|---|---|
| A | 0x1C | B | 0x32 |
| C | 0x21 | D | 0x23 |
| E | 0x24 | F | 0x2B |
| G | 0x34 | H | 0x33 |
| I | 0x43 | J | 0x3B |
| K | 0x42 | L | 0x4B |
| M | 0x3A | N | 0x31 |
| O | 0x44 | P | 0x4D |
| Q | 0x15 | R | 0x2D |
| S | 0x1B | T | 0x2C |
| U | 0x3C | V | 0x2A |
| W | 0x1D | X | 0x22 |
| Y | 0x35 | Z | 0x1A |
| 0 | 0x45 | 1 | 0x16 |
| 2 | 0x1E | 3 | 0x26 |
| 4 | 0x25 | 5 | 0x2E |
| 6 | 0x36 | 7 | 0x3D |
| 8 | 0x3E | 9 | 0x46 |
| Enter | 0x5A | Space | 0x29 |
| Esc | 0x76 | Backspace | 0x66 |
| Tab | 0x0D | CapsLock | 0x58 |
功能键的make码值得单独记忆:F1=0x05、F2=0x06、F3=0x04、F4=0x0C、F5=0x03、F6=0x0B、F7=0x83、F8=0x0A、F9=0x01、F10=0x09、F11=0x78、F12=0x07。
这里的大写字母处理不能简单替换扫描码:主机不认“大写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 make | Set 2 break |
|---|---|---|
| Insert | 0xE0 0x70 | 0xE0 0xF0 0x70 |
| Delete | 0xE0 0x71 | 0xE0 0xF0 0x71 |
| Home | 0xE0 0x6C | 0xE0 0xF0 0x6C |
| End | 0xE0 0x69 | 0xE0 0xF0 0x69 |
| PageUp | 0xE0 0x7D | 0xE0 0xF0 0x7D |
| PageDown | 0xE0 0x7A | 0xE0 0xF0 0x7A |
| 右Control | 0xE0 0x14 | 0xE0 0xF0 0x14 |
| 右Alt | 0xE0 0x11 | 0xE0 0xF0 0x11 |
Print Screen和Pause是特例。Print Screen在Set 2下通常模拟为一串组合键扫描码,不同主板的处理也不一致,老实的做法是先往主机发“左Alt + Print Screen”之类组合事件,或者干脆把你需要的那一个键换成同功能的其他键。如果你做的KVM测试设备一定要覆盖Print Screen,我建议在逻辑分析仪上抓一次真键盘的输出,把那串原始序列完整存进EEPROM,而不是手抄文档。
4.3 组合键和宏:先发Make再发Break的顺序坑
组合键的正确发送顺序不是“按一个键,松开,再按下一个键”,而是先把所有需要按下的键都发送make,然后再逐个发送break。以Ctrl+Alt+Delete为例:必须依次发左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); } }调用这个函数模拟Ctrl+Alt+Delete:
const 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在前”写的,所以结构体里code=0x71, ext=1直接可用。但如果你从老教程里抄来“先发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,状态一直为“按下”,主机会按键盘自身的重复速率持续产生字符。很多宏脚本在发组合键时只发了所有make,break忘了循环释放。
解决:把每一次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,而是先延时2~3秒,并检测主机是否把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接反、奇校验写反这三个。希望帮到你。
本文还有配套的精品资源,点击获取