简介面向单片机与嵌入式开发者这份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 —— 发送一帧给主机D2DATA, D3CLK #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起始位0bit1-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 启用与 ACKPS2 不是单行道——键盘不仅要发按键还要应答主机的命令。开机时主机通常会发 0xFF复位等键盘回 0xFAACK然后键盘发送 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 外部上拉是 5V3.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Ω 上拉到 5VCLK 与 DATA 线尽量短并保持双绞这是我在某跨平台系统的产测盒上验证过的稳定组合。最后养成一个习惯每次改动时序参数都用逻辑分析仪留一张波形图存档等哪天换了主板出问题拿图对比比凭记忆猜快得多。这套方案不值得追求完美协议实现够用、可复现、出问题能查才是长期投入它的理由。希望帮到你。本文还有配套的精品资源点击获取