ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PS2键盘模拟程序:从PS/2协议到单片机固件实现的完整指南

PS2键盘模拟程序:从PS/2协议到单片机固件实现的完整指南

简介:面向单片机与嵌入式开发者,这份PS2键盘模拟程序源码演示了如何用单片机模拟PS/2键盘行为,适用于自动化测试、特殊输入设备等需要注入键盘信号的场景。资源共3个文件,包含1个C源文件与2个头文件,压缩包仅2KB,体量极小,便于快速阅读和移植到个人工程中。已有543人学习下载,折射出该协议模拟内容在嵌入式入门群体中的实用热度。源码完整实现了PS/2协议的关键环节,包括通信初始化、扫描码生成、数据包起始位/数据位/奇偶校验/停止位的构造,以及中断服务与基本错误处理逻辑;头文件还给出了104键扫描码映射表,读者可借助Keil、IAR等环境直接编译验证,或结合逻辑分析仪逐位调试,从而深入理解同步串行通信与单片机硬件接口设计。

1. PS2键盘模拟程序:让电脑以为你插了个真键盘

PS2键盘模拟程序,是用单片机在 DATA、CLK 两根线上按 PS/2 协议往外送按键帧,让主板 BIOS 和操作系统都认为接口上插着一个真实键盘。它最常用的场景有三类:老工控机只有 PS2 口、自动化产线要批量注入按键、以及想绕开 USB HID 的复杂协议栈做最小化输入方案。适合动手做它的人,是熟悉 GPIO 和中断的嵌入式开发者;如果你只写过 USB HID 代码,会觉得 PS2 像穿越回 1990 年代——没有设备描述符,没有枚举回调,只有两个引脚和一堆时序。这套方案最反直觉的一点是:按键逻辑从来不是难点,难点全在“怎么让主机在开机自检的那几秒内认出你”。

2. 先立住模型:PS2总线时序、电平与主控选型

2.1 两条线、11个位:PS2时序没有想象中神秘

PS/2 键盘只有两根信号线:CLK(时钟)和 DATA(数据),空闲时都被上拉到高电平。时钟频率标准是 10~16.7kHz,也就是说每个时钟周期约 60~100µs。读进电脑的数据每帧是 11 位:1 位起始位(低电平)+ 8 位数据(低位先发)+ 1 位奇校验位 + 1 位停止位(高电平)。主机在时钟上升沿采样 DATA,设备则要在时钟下降沿附近切换 DATA。理解了这个规则,后面所有代码都是围绕“什么时刻把数据线拉高/拉低”展开的。

键盘向主机传数据时,时序大致是:先检查 CLK 和 DATA 都是高电平,然后拉低 CLK 至少 100µs(这个动作叫抑制主机时钟),再把 DATA 拉低表示起始位,释放 CLK 产生第一个上升沿,之后对剩余 10 位重复“拉低 CLK → 切换 DATA → 释放 CLK”这一动作。主机在你释放 CLK 的上升沿读取 DATA。整个发送过程中,数据线电平必须在每个上升沿之前稳定下来,稳定时间至少 5µs。

我当年第一次照着网上的图写代码,以为只要把电平顺序做对就行,结果 BIOS 完全不理我。后来用逻辑分析仪抓线才发现,我忽略了发送前那段“等总线空闲”,数据刚上电就发,撞上了主机的初始化窗口。所以记住:PS2 不是“发了就行”,而是有一套明确的电平握手规矩。

2.2 电平与主控选型:为什么我劝你先用 5V 的板子

PS2 接口是开漏结构,外部上拉到 5V。主机端的收发器对高电平的阈值要求一般在 2.0V 以上,对低电平要求 0.8V 以下。如果你用 5V 供电的 ATmega328P 这类单片机,把 DATA 和 CLK 接两个数字引脚,配合 4.7kΩ 上拉电阻到 5V,逻辑电平是完美匹配的,这也是我推荐的最小硬件方案。

如果你手头只有 3.3V 的板子(比如常见的 32 位 MCU 开发板),要特别注意两点:第一,IO 必须支持 5V 容忍或开漏输出,否则会通过内部钳位二极管往 5V 上灌电流,长期会烧引脚;第二,3.3V 输出高电平在开漏上拉结构里会被外部 5V 上拉直接抬到接近 5V,所以输出高电平时不能推挽驱动,必须把引脚设成开漏或输入模式,让上拉电阻决定电平。常见做法是 GPIO 配成开漏输出,加上拉电阻到 5V。

选型时我的建议是:第一版不要上 32 位单片机,直接用 5V 的单片机最小系统或现成开发板,省掉电平转换的变量。等你把协议验证通过、逻辑分析仪上看到规整波形了,再移植到 3.3V 平台不迟。移植时把引脚方向切换、开漏配置、上拉电阻这三处分别改到位,通常不会出大问题。

3. 固件三件套:扫描码表、字节发送、按键管理

3.1 扫描码表:通码、断码与控制键状态

PS2 键盘发给主机的不是 ASCII,而是 Scan Code Set 2 的扫描码。每个按键有两个码:按下时发通码(make code),抬起时先发 0xF0 再发同一个通码(合起来叫断码,break code)。比如字母 A 的通码是 0x1C,那么按下发 0x1C,抬头发 0xF0 0x1C。方向键、功能键等扩展键还要加 E0 前缀(如右 Ctrl 通码是 0xE0 0x14),键盘模拟程序要实现完整映射,至少准备一张通码表。

控制键(Shift/Ctrl/Alt)不需要额外“锁定”状态,主机端自己会根据收到的是通码还是断码来维护状态。所以你的程序要做的是:按下 Shift 时发送 Shift 的通码(左 Shift 是 0x12),抬起时发送 0xF0 0x12;在这个期间按下字母 A,主机看到的是“Shift 被按住 + A 按下”,自然打出大写。不要自己合成 ASCII 再发,那是用错协议,主机收到的会是乱键。

我这里给出一个最小通码表,覆盖字母、数字和常用控制键,够你搭键盘矩阵用:

// scan_table.h —— 常用按键的 PS2 Scan Code Set 2 通码 uint8_t key_a = 0x1C; // A uint8_t key_b = 0x32; // B uint8_t key_c = 0x21; // C uint8_t key_0 = 0x45; // 数字 0 uint8_t key_1 = 0x16; // 数字 1 uint8_t key_enter = 0x5A; uint8_t key_esc = 0x76; uint8_t key_space = 0x29; uint8_t key_lshift = 0x12; uint8_t key_rshift = 0x59; uint8_t key_lctrl = 0x14; uint8_t key_lalt = 0x11;

逻辑说明:这是一组“有什么发什么”的映射表,不是完整 ASCII。你只需要保证每个物理按键能对应到唯一一个通码即可。参数说明里特别提醒:E0 前缀键(如方向键、小键盘区分键)在这张表里没覆盖,如果之后要支持方向键,需要额外维护“E0 发送”逻辑,否则发出去的码会被主机当成常规键。

3.2 发送一个字节:寄存器位操作与时钟参数

发送字节是整个程序的地基。很多人直接用库函数里的 digitalWrite 循环,发现波形抖动严重,原因在于每次调用有几十条指令的开销,位与位之间的间隔不稳定,主机在上升沿采样时容易采到跳变中的电平。正确做法是绕过库函数,直接操作端口寄存器,把电平切换时间压缩到 1~2 个时钟周期以内。

下面是一个可直接编译的字节发送函数,以 ATmega328P 的 D2/D3 引脚为例:

// ps2_send.c —— 发送一帧给主机(D2=DATA, D3=CLK) #define PS2_DATA_DDR DDRD #define PS2_DATA_PORT PORTD #define PS2_DATA_PIN PIND #define PS2_DATA_BIT (1 << PD2) #define PS2_CLK_DDR DDRD #define PS2_CLK_PORT PORTD #define PS2_CLK_PIN PIND #define PS2_CLK_BIT (1 << PD3) // 模拟开漏:高电平=输入模式(靠外部上拉),低电平=输出低 #define DATA_HIGH() { PS2_DATA_DDR &= ~PS2_DATA_BIT; } #define DATA_LOW() { PS2_DATA_DDR |= PS2_DATA_BIT; PS2_DATA_PORT &= ~PS2_DATA_BIT; } #define CLK_HIGH() { PS2_CLK_DDR &= ~PS2_CLK_BIT; } #define CLK_LOW() { PS2_CLK_DDR |= PS2_CLK_BIT; PS2_CLK_PORT &= ~PS2_CLK_BIT; } void ps2_send_byte(uint8_t data) { uint8_t parity = 1; // 奇校验:保证数据位+校验位中 1 的个数为奇数 for (uint8_t i = 0; i < 8; i++) { parity ^= (data >> i) & 1; } // 拼成 11 位帧:bit0=起始位0,bit1-8=数据,bit9=校验,bit10=停止位1 uint16_t frame = (1 << 10) | ((uint16_t)parity << 9) | ((uint16_t)data << 1); // 等待总线空闲(两边都是高),带超时保护 uint16_t timeout = 0; while ((!(PS2_CLK_PIN & PS2_CLK_BIT) || !(PS2_DATA_PIN & PS2_DATA_BIT)) && timeout < 2000) { timeout++; } // 发送前抑制:拉低 CLK 至少 100us,再拉低 DATA 表示起始位 CLK_LOW(); delayMicroseconds(100); DATA_LOW(); delayMicroseconds(30); CLK_HIGH(); delayMicroseconds(30); // 剩余 10 位:先拉低 CLK,切换 DATA,再释放 CLK 让主机采样 for (int8_t bit = 1; bit < 11; bit++) { CLK_LOW(); if (frame & (1 << bit)) { DATA_HIGH(); } else { DATA_LOW(); } delayMicroseconds(20); CLK_HIGH(); delayMicroseconds(35); } // 收尾:总线回到空闲高电平 delayMicroseconds(50); }

逻辑说明:发送前置的“拉低 CLK 100µs + 拉低 DATA 30µs + 释放 CLK 30µs”完成起始位握手,告诉主机“键盘要发数据了”;随后循环中每位的半周期约 20/35µs,一个完整时钟约 55µs,处于 10~16.7kHz 标准范围。如果你把 CLK 释放后的 35µs 改小到低于 20µs,主机采样建立时间不足,会出现随机丢键。参数说明:timeout 循环大约是 2ms 的等待窗口,超过就放弃发送,防止键盘被主机抑制通信时程序卡死;实际测试中主机偶尔会拉低 CLK 长达几百微秒,2ms 已足够。

3.3 按键按下与抬起:别漏掉 0xF0 断码

很多第一次写模拟键盘的人,会犯一个同样的错误:按下按键时只发通码,忘了发断码。结果就是主机的状态机认为这个键还按着,你会看到第一下正常、第二下开始系统认为是长按,游戏里还会出现角色不回头地跑。正确做法是每个键盘事件都成对发送:通码表示按下,0xF0+通码表示抬起。

如果你的某个按键是“点按”逻辑(按下立即抬起,模拟一次敲击),那也要先发通码,再立刻发断码。注意两个码之间要有至少几毫秒间隔(我测试下来 10ms 足够稳定),太短的话一些旧主板的键盘控制器内部 FIFO 会合并掉。下面这段展示一个按键状态机的核心判断:

// key_event.c —— 按键边沿检测,只在“按下/抬起”时发送 uint8_t key_prev[16]; // 每个键的上一状态 uint8_t key_deb[16]; // 消抖计数 void key_scan_row(uint8_t row, uint8_t col_pins[4]) { for (uint8_t col = 0; col < 4; col++) { uint8_t idx = row * 4 + col; uint8_t now = digitalRead(col_pins[col]) ? 0 : 1; if (now == key_deb[idx]) { if (now != key_prev[idx]) { uint8_t code = keymap[row][col]; if (now) { ps2_send_byte(code); // 按下:发通码 } else { ps2_send_byte(0xF0); // 抬起:发断码前缀 ps2_send_byte(code); } key_prev[idx] = now; } } else { key_deb[idx] = now; // 两次采样不一致,待稳定 } } }

逻辑说明:这里的“消抖计数”并不是累计计时,而是记录当前采样值,下一轮只有连续两次一致才认为事件真实发生,等效约 6ms 消抖(3ms 扫描周期)。参数说明:ps2_send_byte 是阻塞发送,一个键码约耗时 600µs,在 3ms 扫描周期内完全来得及;如果未来按键多了、同时按下多个键,就要考虑把发送移进中断,后面第六节再展开。

4. 把程序跑起来:矩阵键盘接入与主机命令应答

4.1 4x4 矩阵扫描与映射发送

有了发送函数和通码表,剩下就是让按键物理上能触发这些码。最常见做法是用 4x4 矩阵键盘,8 个 IO 控制 16 个键。扫描时逐行拉低,读列引脚电平,低电平表示该列有键按下。

// matrix.c —— 4x4 矩阵扫描主逻辑 #define ROWS 4 #define COLS 4 const uint8_t row_pins[ROWS] = {4, 5, 6, 7}; const uint8_t col_pins[COLS] = {8, 9, 10, 11}; uint8_t keymap[ROWS][COLS] = { {0x1C, 0x32, 0x21, 0x23}, // A B C D {0x24, 0x2B, 0x34, 0x33}, // E F G H {0x43, 0x3B, 0x42, 0x4B}, // I J K L {0x15, 0x2D, 0x2C, 0x3C}, // Q R T U }; void scan_matrix(void) { for (uint8_t r = 0; r < ROWS; r++) { pinMode(row_pins[r], OUTPUT); digitalWrite(row_pins[r], LOW); delayMicroseconds(5); // 等待行线电平稳定 for (uint8_t c = 0; c < COLS; c++) { uint8_t idx = r * 4 + c; uint8_t now = digitalRead(col_pins[c]) ? 0 : 1; // 低有效 if (now == key_deb[idx]) { if (now != key_prev[idx]) { if (now) { ps2_send_byte(keymap[r][c]); } else { ps2_send_byte(0xF0); ps2_send_byte(keymap[r][c]); } key_prev[idx] = now; } } else { key_deb[idx] = now; } } pinMode(row_pins[r], INPUT_PULLUP); // 恢复高阻,避免影响下一行 } }

逻辑说明:逐行拉低、读列,这是最标准的矩阵扫描。代码里的 key_prev/key_deb 沿用了上一节的状态机,保证同一个键只在状态翻转时发码。参数说明里的 5µs 延时不可省:行线拉到低电平后,电容放电和引脚内部上拉撤销都需要时间,尤其行线走线长时,稳定时间要放宽到 10µs。这个矩阵扫描有个限制:它没有做二极管防串扰,如果同时按下同一行的两个键,会出现列线被另一个行拉低导致的“假按下”。对模拟键盘这种场景,最可靠的兜底是在每个按键的列线串 1N4148 二极管,或者接受“不允许多键同按”的使用假设。

4.2 主机命令应答:0xFF 复位、0xF4 启用与 ACK

PS2 不是单行道——键盘不仅要发按键,还要应答主机的命令。开机时,主机通常会发 0xFF(复位),等键盘回 0xFA(ACK),然后键盘发送 0xAA(自检通过)。如果主机发 0xF4(启用扫描)而你只回 ACK、不启用矩阵扫描,那键盘就永远不干活。这些命令都要在“主机拉低 CLK、释放 CLK、键盘接管时钟”的流程里接收。

接收主机命令比发送复杂在一点:CLK 的驱动权会交接。主机先拉低 CLK 至少 100µs,再拉低 DATA,然后释放 CLK;此时键盘要接管时钟,自己产生脉冲并采样 DATA。下面是我用的简化接收实现,放在主循环里轮询:

// host_cmd.c —— 检测并响应主机命令 void host_cmd_poll(void) { static uint8_t low_ticks = 0; if (!(PS2_CLK_PIN & PS2_CLK_BIT)) { if (++low_ticks > 10) { // 持续约 1ms 低电平,判定主机请求 uint8_t cmd = ps2_receive_byte(); // 接管时钟,读 11 位帧 low_ticks = 0; handle_host_cmd(cmd); } } else { low_ticks = 0; } } void handle_host_cmd(uint8_t cmd) { switch (cmd) { case 0xFF: // 复位命令 ps2_send_byte(0xFA); // ACK delay(10); ps2_send_byte(0xAA); // 自检通过 scan_enabled = 1; break; case 0xF4: // 启用扫描 ps2_send_byte(0xFA); scan_enabled = 1; break; case 0xF5: // 禁止扫描 ps2_send_byte(0xFA); scan_enabled = 0; break; default: ps2_send_byte(0xFA); // 未知命令一律回 ACK,别让主机超时 break; } }

逻辑说明:low_ticks 累计的是主循环每次执行的采样结果,如果你的主循环周期是 100µs,那么累计 10 次就代表 CLK 被拉低约 1ms,足够排除普通干扰。handle_host_cmd 里对命令的处理最关键的是“别超时”——主机在发命令后会等待 ACK,如果等不到就会判定键盘不存在或通信异常。所以未知命令也回 ACK,是我验证过的兼容性技巧。

这里有一个很容易忽略的坑:0xAA(自检通过)到底什么时候发。标准键盘是上电自检通过后主动发送 0xAA;但我们的单片机启动要比 BIOS 慢,如果上电就发 0xAA,可能撞上 BIOS 还没初始化 PS2 控制器的窗口。可靠做法是:先等主机发 0xFF、回 ACK 后再发 0xAA。对多数主板而言,它开机必发 0xFF;万一你的主板不发,可以在上电 1 秒后主动发一次 0xAA 作为兜底。

5. 上电不识别、按键卡死:五个高频坑与排查清单

5.1 坑一:冷启动不识别,热插拔没反应

现象:每次开机进 BIOS 都提示 keyboard error 或者直接默认无键盘;插拔 PS2 接口后系统完全没反应。原因:PS2 协议没有热插拔枚举机制,主机的键盘控制器只在开机自检那几百毫秒里枚举挂在接口上的设备。解决:确保单片机上电到“能应答命令”的准备时间小于 BIOS 发出 0xFF 的时间。实际操作中,不要在固件初始化里做太长的延时,尤其不要在前面放 bootloader 等待;矩阵扫描的引脚初始化要放在 PS2 初始化之后。

5.2 坑二:3.3V 板子强接 5V PS2 口,烧引脚或电平不识别

现象:用 3.3V 单片机直接接 PS2 线,有的板子几分钟后引脚发热,有的板子能发帧但主机就是丢。原因:PS2 外部上拉是 5V,3.3V 推挽输出高电平会被钳位,或者内部保护二极管导通产生灌电流。解决:所有接 PS2 的引脚必须配置为开漏模式,外部加 4.7kΩ 上拉到 5V;确保该引脚支持 5V 容忍。如果用的是不支持 5V 容忍的引脚,中间加电平转换芯片,不要指望电阻分压,因为逆止阶段需要双向传输。

5.3 坑三:奇偶校验算错,主机静默丢帧

现象:按键大部分时候正常,偶尔丢一个,而且毫无规律;用 BIOS 自带键盘检测工具能看到“键盘错误”提示。原因:PS2 是奇校验,数据位里 1 的个数为偶数时校验位必须是 1,不少照着抄的代码把校验位写成固定 0 或固定 1,导致帧错误。解决:用逻辑分析仪抓一帧,数一下 1 的个数;或者在上面的 ps2_send_byte 里单独验证:把数据位 8 个 bit 逐个异或,得到 1 则说明 1 的个数是奇数,校验位应为 0。我之前就翻车过,校验位写反后 BIOS 依然认键盘,但进系统后键位乱跳,排查了两天才发现是这里。

5.4 坑四:发送函数没有超时保护,主机抑制时死循环

现象:键盘插着时一切正常,拔掉后程序卡死,再插回去没有任何输出;串口打印发现代码停在发送函数里不走了。原因:PS2 主机在不想接收数据时会拉低 CLK 做抑制,如果你在总线被抑制时调用发送函数,又没有超时,while 循环永远等不到总线空闲。解决:给等待总线空闲加上超时退出(上面的 2000 次循环就是一种做法),超时后直接返回,不要阻塞主循环;同时把这次按键事件做标记,等下次扫描周期再补发或者干脆丢弃。

5.5 坑五:漏发断码造成系统“卡键”

现象:第一次按 A 出 A,第二次按 A 直接变成了“长按 A”,屏幕上冒出一串 A。原因:第一次按下发过通码 0x1C,但抬起事件漏了 0xF0 0x1C,主机认为 A 从未抬起。解决:在矩阵扫描的状态机里,只有“按下沿”和“抬起沿”两个触发点发送,其他轮次什么都不发;如果某个键只是点按语义,也要立刻补发断码。这个坑我建议你在最初做硬件测试时就按“按住 1 秒再松开”的方式验证,不要只做快速敲击,否则很容易漏过去。

注意:以上五个坑没有先后顺序,实际项目里通常是组合出现。我的排查习惯是:先抓波形确认电平与校验位,再查上电时序,最后才怀疑扫描逻辑,能省掉大半无效调试时间。

6. 读信号而不是猜结果:逻辑分析仪验证与参数微调

6.1 用第二块 MCU 当简易逻辑分析仪

如果你手头没有逻辑分析仪,也可以用第二块单片机抓 PS2 时钟的宽度,验证时序是否落在标准范围。核心思路是给 CLK 引脚挂 pin change 中断,在中断里用 micros() 记录相邻沿的时间差:

// verify_mcu.c —— 第二块 MCU 测量 PS2_CLK 半周期 volatile uint32_t last_edge = 0; volatile uint16_t last_half = 0; void clk_edge_isr() { uint32_t now = micros(); last_half = (uint16_t)(now - last_edge); last_edge = now; }

逻辑说明:每次电平沿产生中断,记录与上一次沿的间隔,这个间隔就是时钟半周期。正常值应在 25~50µs,如果看到间隔忽大忽小超过 ±10µs,说明发送端用了阻塞式延时导致抖动。参数说明:把这段代码下载到另一块 5V 单片机上,CLK 接外部中断引脚,D2/D3 都行,采样精度在 4µs 以内,足够判断时序毛刺;想要看完整帧,还是建议买一个支持 PS2 解码的逻辑分析仪,能直接标出每帧的起始位、数据、校验位和停止位,省掉人工数电平的时间。

6.2 参数微调:让苛刻的主板也认你

时序参数不是死板的,不同主板 BIOS 的窗口容忍度有差异。我测试过的经验值是:发送时钟周期 70µs 是通用值,但如果某台机器上识别慢或者进系统后偶发丢键,把周期调到 60µs(半周期各 30µs)往往就好了;低于 50µs 就会翻车,出现第一个键丢失或主机直接报告键盘错误。数据线电平建立时间不要低于 10µs,否则主机采样点落在电平跳变沿上。

上拉电阻也是一个变量。内部上拉(一般在 20~50kΩ)虽然能工作,但沿不够陡,长线场景下容易引入噪声。外接 4.7kΩ 上拉到 5V,CLK 与 DATA 线尽量短并保持双绞,这是我在某跨平台系统的产测盒上验证过的稳定组合。最后养成一个习惯:每次改动时序参数,都用逻辑分析仪留一张波形图存档,等哪天换了主板出问题,拿图对比比凭记忆猜快得多。这套方案不值得追求完美协议实现,够用、可复现、出问题能查,才是长期投入它的理由。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进