5 分钟阅读 次阅读

目前市面上的资料,大多数是对PS/2键盘的通信过程做了介绍,但是PS/2和Windows在开机阶段是怎么握手上的,却没有详细地介绍,本文弥补了这一点。

参考网站

1 通信概述

PS/2 协议使用两根线 CLK(时钟线)和 DAT(数据线),与 IIC 协议有一定相似性,是一种串行通信协议。PS/2 设备主要包括键盘和鼠标,本文重点介绍键盘,但键盘和鼠标在通信协议上大致相同。

在 PS/2 通信中,主机和设备之间可以进行双向数据传输,即主机可以向设备发送数据,设备也可以向主机发送数据。CLK 和 DAT 在空闲状态下都为高电平,只有当它们处于高电平时,才能够开始数据的发送,否则会出现通信抑制的情况,此时不能发送数据。

在设备主动发送数据时,设备会先拉低 DAT 线,再拉低 CLK 线,然后由设备产生时钟信号来发送数据;而主机主动发送数据时,主机会先拉低 CLK 线,再拉低 DAT 线,之后由设备产生时钟信号,主机进行数据发送。

对于数据的读取时机,设备主动发送的数据在 CLK 的下降沿被读取,主机主动发送的数据在 CLK 的上升沿被读取。并且,一般不会在边沿产生时立刻开始读取或发送数据,而是在边沿产生后延时一段时间再进行操作,以确保数据的稳定。具体来说,设备主动发送时,在 CLK 低电平时,DAT 线上的数据是稳定的;而主机主动发送时,在 CLK 高电平时,DAT 线上的数据是稳定的。

2 时序详解

PS/2 协议的数据传输时序包括 1 个起始位、8 个数据位、1 个校验位、1 个停止位和 1 个应答位,具体如下:

  • 起始位:低电平
  • 数据位:LSB,先发送低位
  • 校验位:奇校验
  • 停止位:高电平
  • 应答位:低电平,设备接收到主机数据后才需要应答,主机接收到设备数据无需应答

2.1 主机主动发送

alt text 发送一个字节数据过程:

  1. CLK 先拉低,延时,然后拉低 DAT
  2. 释放 DAT
  3. 等待设备

设备主动发送

alt text 发送一个字节数据过程:

  1. 等待 CLK 为高电平后,检查数据线是否为高电平,如果是,继续下面的操作
  2. 延时 40us,发送起始位 0
  3. 由低位开始发送 8 位的数据位
  4. 发送奇校验位
  5. 发送停止位

发送一个位的过程:(保证在拉低 CLK 的时候 DAT 已经准备好了)

  1. DAT 准备好数据位,延时 10us
  2. 拉低 CLK,延时 20us
  3. 释放 CLK,延时 10us
  4. 检查 CLK 是否为高(空闲状态),如果是,则返回正确

3 硬件连接

在硬件连接方面,对于主机(例如 x86 设备),一般通过一个 SIO 芯片提供两个引脚(CLK 和 DAT)来进行 PS/2 通信。这两个引脚的资源在 BIOS 中需要被配置为 PS/2 模式,这样在开机初始化时系统才会尝试和设备进行握手操作。同时,CLK 和 DAT 线应进行外部上拉处理。如果使用单片机模拟键盘,接入 CLK 和 DAT 的引脚要配置为开漏模式。

4 通信过程

4.1 初始化

PS/2 设备和主机之间需要进行握手操作,只有握手成功后,设备才能正常使用。需要注意的是,PS/2 设备不支持热拔插。

以 Windows 10 主机接入 PS/2 键盘,开机初始化的过程为例: 主机会先尝试告知设备其要发送数据,具体操作是先下拉 CLK 线进行通讯抑制,然后下拉 DAT 线表示要发送数据,接着释放 CLK 线,等待设备产生 CLK 线上的时钟信号。主机根据设备产生的时钟信号进行数据发送,通常发送一个字节的信息,例如 0xEE。设备接收到主机发送的这个字节后,需要进行应答。应答的数据一般是 0xFA,但存在一些例外情况:如果接收到的信息是 0xEE,则需要应答 0xEE(EE 表示 echo);如果接收到 0xFF,则需应答 0xFA,并在 500ms 之内设备发送 0xAA(FF 是复位命令,该命令的正确应答对于键盘能否初始化成功至关重要)。

4.2 常态

在设备和主机握手成功之后,进入常态。在常态下,设备可以向主机发送键码,告知主机用户按下了键盘的哪个键,同时主机也可以向设备发送数据进行一些设置操作。

5 bios 是否配置 或 引脚是否正常判断

将正常的 PS/2 键盘接入系统,正常情况下键盘应该能够正常使用,且在设备管理器中的键盘一项可以看到 PS/2 键盘。可以使用示波器监视 CLK 和 DAT 线,在系统开机过程中,主机应该有尝试发送数据的动作(即先拉低 CLK 线然后拉低 DAT 线),如果没有这个动作,则可能是 BIOS 没有正确配置或者 SIO 芯片存在虚焊等问题。

6 初始化成功判断

如果握手没有成功,在进入系统后,CLK 线可能会被 SIO 主动拉低,从而抑制通信。如果握手成功了,在设备管理器中(以 Windows 系统为例)的键盘选项中,可以看到一个 PS/2 的设备。

7 键码

目前使用的 PS/2 键盘大多默认采用第 2 套扫描码集(可参考https://wiki.osdev.org/PS/2_Keyboard)。PS/2 键盘的键码有通码和断码之分,按下按键时发送通码,松开按键时发送断码。

例如,要模拟 Win 键的触发,使用正常的 PS/2 键盘进行测试时可以发现,按下 Win 键必须在松开后才会弹出开始菜单,这意味着首先要模拟 Win 键的通码发送,然后再模拟断码发送。通过查表可以得到,需要先发送 0xE0,0x1F,然后发送 0xE0,0xF0,0x1F。 键码 需要先发送 0xE0,0x1F

8 代码

  • 下面代码是 STC8 模拟 PS/2 键盘的示例,包括开机初始化键盘的握手,以及键码的发送

注意 delay 函数延时的准确性,如果不准确会造成一些 bug,这方面和编译优化选项也有关系,最好使用示波器实际校准下。 KDAT 和 KCLK 为 STC8 连接到 PS/2 接口的管脚。

8.1 读取单个位

unsigned char ReadBit() {
    Delay_us(20);
    KCLK = 0;        // 设备拉低时钟线
    Delay_us(40);
    KCLK = 1;        // 释放时钟线
    Delay_us(20);
    return KDAT;     // 返回数据线状态
}

8.2 发送单个位

char SendBit(unsigned char b) {
    KDAT = b;          // 准备数据位
    Delay_us(20);
    KCLK = 0;            // 拉低时钟线
    Delay_us(40);
    KCLK = 1;            // 释放时钟线
    Delay_us(20);
    return KCLK ? 0 : -1;// 检测时钟抑制
}

8.3 计算校验位

unsigned char CalculateParity(unsigned char dat) {
    unsigned char parity = 1, i;
    for(i=0; i<8; i++) parity ^= (dat >> i) & 1;
    return parity;
}

8.4 发送应答位

void SendAck() {
    Delay_us(15);
    KDAT = 0;        // 拉低数据线
    Delay_us(5);
    KCLK = 0;        // 拉低时钟线
    Delay_us(40);
    KCLK = 1;        // 释放时钟线
    Delay_us(5);
    KDAT = 1;        // 释放数据线
}

8.5 读取一个字节

char RecvByte() {
    unsigned char b, parity, stop;
    unsigned char tmp = 0;
    unsigned char i;

    // 等待时钟线高电平(主机释放时钟)
    // while(!KCLK);
    unsigned int wait_start = systick;
    while(!KCLK) {
        if (systick - wait_start > 5) {   // 等待5ms超时
            return -1; // 超时退出
        }
    }
    Delay_us(300);
    // if(!KCLK) return -1;

    // 检查起始位(数据线必须为低)
    if(KDAT != 0) return -1;

    // 读取8位数据
    for(i=0; i<8; i++) {
        b = ReadBit();
        if(!KCLK) return -2;    // 检测时钟抑制
        tmp |= (b << i);
    }

    // 读取校验位
    parity = ReadBit();

    // 读取停止位
    stop = ReadBit();

    // 处理停止位错误
    if(stop != 1) {
        while(!(stop = ReadBit())); // 持续读取直到高电平
        return -3;
    }

    // 发送应答信号
    SendAck();

    // // 校验数据完整性
    // if(CalculateParity(tmp) != parity) return -4;

    // 最终延时
    Delay_us(45);

    recvData = tmp; // 将接收到的数据存储到全局变量

    // *data = tmp;
    return 0;
}

8.6 发送一个字节

char SendByte(unsigned char dat) {
	unsigned char i;
    // 等待时钟线高电平
    // while(KCLK == 0);
    unsigned int wait_start = systick;
    while(!KCLK) {
        if (systick - wait_start > 5) {   // 等待5ms超时
            return -1; // 超时退出
        }
    }
    Delay_us(40);
    if(KCLK == 0) return -1; // 检测时钟线状态

    // 检查数据线状态
    if(KDAT == 0) return -1;

    // 发送起始位
    Delay_us(5);
    if(SendBit(0)) return -1;

    // 发送数据位
    for(i=0; i<8; i++) {
        if(SendBit((dat >> i) & 1)) return -1;
    }

    // 发送校验位
    if(SendBit(CalculateParity(dat))) return -1;

    // 发送停止位
    if(SendBit(1)) return -1;

    Delay_us(50);
    return 0;
}

8.7 接收数据与应答(可用于初始化的握手)

bit last_KDAT = 1;         // 上次KDAT状态
unsigned char recvData = 0; // 接收数据存储变量

void main(){
    last_KDAT = KDAT;

    while(1){
        // 接受主机发送的数据
        if(KDAT==0 && last_KDAT==1){//KDAT被拉低,主机可能要发送数据
            if(RecvByte()==0){
                SendByte(0xFA); // 发送确认

                if(recvData == 0xFF){
                    Delay_ms(360);
                    SendByte(0xAA); // 发送复位确认
                }
            }
        }
        last_KDAT = KDAT;
    }
}

8.8 键码的发送

  • 以发送 win 键为例
  • 通码和断码之间延时可适当拉长
void SendWinKey() {
    SendByte(0xE0);  // 扩展标记
    Delay_ms(3);
    SendByte(0x1F);  // 左Win键通码

    Delay_ms(3);

    SendByte(0xE0);
    Delay_ms(3);
    SendByte(0xF0);
    Delay_ms(3);
    SendByte(0x1F);
}

9 bug 说明

9.1 bug1——没有等待主机进入数据发送模式

  • 具体描述: 在主机主动发送数据时,主机先拉低 CLK 线,再拉低 DAT 线,然后释放 CLK 线示意设备可以产生时钟信号,设备产生时钟信号后主机发送数据。但从主机释放 CLK 线后,如果设备立刻产生时钟信号,主机可能来不及转换到数据发送模式,从而导致通信出现问题。

  • 解决方式: 设备检测到主机释放 CLK 后,等待 300us,再检测起始位,然后进行数据的读取(数据读取时设备发送时钟)。

  • 参考 patch:

diff --git a/main.c b/main.c
index 9954251..a4ac5b7 100644
--- a/main.c
+++ b/main.c
@@ -269,6 +269,7 @@ char RecvByte() {
// 接收完整数据包
char RecvByte() {
    unsigned char b, parity, stop;
    unsigned char tmp = 0;
    unsigned char i;

    // 等待时钟线高电平(主机释放时钟)
    while(!KCLK);
+   Delay_us(300);

    // 检查起始位(数据线必须为低)
    if(KDAT != 0) return -1;

    // 读取8位数据
    for(i=0; i<8; i++) {
        b = ReadBit();
        if(!KCLK) return -2;    // 检测时钟抑制
        tmp |= (b << i);
    }

    // 读取校验位
    parity = ReadBit();

9.2 bug2——握手超时导致主机死机

  • 具体描述: 在 S5 关机或者 S4 休眠唤醒后,系统没有出现死机或者卡顿现象,而在 S3 睡眠唤醒后,出现了死机和卡顿情况。经排查,发现该问题与 PS/2 设备有关。原因是设备在接收到主机发送的 FF 复位信号后,虽然回复了 FA 和 AA,但是 AA 距离 FF 的时间超过了 500ms。PS/2 协议规定,设备在接收到主机发送的 FF 复位信号后,应该在 500ms 内回复 AA。推测是主机在发送 FF 后,收到了设备的 FA,但是由于 AA 超时,主机没有接收到。虽然按常理即使超时也不应该导致卡顿,但可能存在 PS/2 相关中断的 bug。

  • 解决方式: 设备接收到主机发的 FF 复位命令后,及时回复,并且保证在 500ms 之内回复 AA

  • 参考 patch:

diff --git a/main.c b/main.c
index 5fea6f4..1eea803 100644
--- a/main.c
+++ b/main.c
@@ -402,7 +402,7 @@ void main()
                 SendByte(0xFA); // 发送确认

                 if(recvData == 0xFF){
-                    Delay_ms(500);
+                    Delay_ms(360);
                     SendByte(0xAA); // 发送复位确认
                 }
             }

9.3 bug3——没有超时检测机制导致的死循环问题

  • 具体描述: 设备检测到 DAT 线被拉低,意味着主机准备接受数据,所以设备要等待主机释放时钟线。但有时候主机没有释放时钟线,单纯使用 while 循环会导致程序一直在等待时钟线的释放,从而进入死循环。

  • 解决方式:加入超时机制

  • 参考 patch:

// while(!KCLK);
unsigned int wait_start = systick;
while(!KCLK) {
    if (systick - wait_start > 5) {   // 等待5ms超时
        return -1; // 超时退出
    }
}

10 实际波形分析

以下波形为真实的 PS/2 键盘的波形。

10.1 S3 睡眠唤醒

  • 主机发送 FF -> 键盘应答 FA
  • 360ms 后
  • 键盘主动发送 AA
  • 主机发送 F3 -> 键盘应答 FA
  • 主机发送 10 -> 键盘应答 FA
  • 主机发送 ED -> 键盘应答 FA
  • 主机发送 01 -> 键盘应答 FA
  • 主机发送 ED -> 键盘应答 FA
  • 主机发送 02 -> 键盘应答 FA alt text alt text alt text alt text alt text alt text alt text alt text alt text alt text alt text alt text alt text alt text alt text alt text alt text alt text

10.2 S4 休眠唤醒和 S5 相同

  • 主机发送 ED -> 键盘应答 FA
  • 主机发送 02 -> 键盘应答 FA
  • 5S 后
  • 主机发送 FF -> 键盘应答 FA
  • 360ms 后
  • 键盘主动发送 AA
  • 主机发送 F3 -> 键盘应答 FA
  • 主机发送 80 -> 键盘应答 FA
  • 主机发送 ED -> 键盘应答 FA
  • 主机发送 80 -> 键盘应答 FA
  • 主机发送 ED -> 键盘应答 FA
  • 主机发送 未知 -> 键盘应答 FA
  • 主机发送 F3 -> 键盘应答 FA
  • 主机发送 04 -> 键盘应答 FA
  • 主机发送 F3 -> 键盘应答 FA
  • 主机发送 10 -> 键盘应答 FA
  • 主机发送 F3 -> 键盘应答 FA
  • 主机发送 20 -> 键盘应答 FA
  • 主机发送 ED -> 键盘应答 FA
  • 主机发送 01 -> 键盘应答 FA

10.3 win7 下发送 Windows 键的键码

alt text alt text alt text alt text alt text alt text alt text alt text alt text

留下评论