☰
用MCU模拟PS/2键盘:从协议时序到扫描码组合键的完整实现
2026/10/14 10:35:06 网站建设 项目流程

简介:面向单片机开发者的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读取键盘ID0xFA,然后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
A0x1CB0x32
C0x21D0x23
E0x24F0x2B
G0x34H0x33
I0x43J0x3B
K0x42L0x4B
M0x3AN0x31
O0x44P0x4D
Q0x15R0x2D
S0x1BT0x2C
U0x3CV0x2A
W0x1DX0x22
Y0x35Z0x1A
00x4510x16
20x1E30x26
40x2550x2E
60x3670x3D
80x3E90x46
Enter0x5ASpace0x29
Esc0x76Backspace0x66
Tab0x0DCapsLock0x58

功能键的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 makeSet 2 break
Insert0xE0 0x700xE0 0xF0 0x70
Delete0xE0 0x710xE0 0xF0 0x71
Home0xE0 0x6C0xE0 0xF0 0x6C
End0xE0 0x690xE0 0xF0 0x69
PageUp0xE0 0x7D0xE0 0xF0 0x7D
PageDown0xE0 0x7A0xE0 0xF0 0x7A
右Control0xE0 0x140xE0 0xF0 0x14
右Alt0xE0 0x110xE0 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接反、奇校验写反这三个。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询