XV6

键盘种类很多,XV6 里使用的是 QEMU 仿真出来的 PS/2 键盘。XV6 只是简单地实现了键盘输入的基本功能,因此对键盘控制器的设置保持默认设置,主要是检测是否有按键并读入按键扫描码。

1. 工作原理

在经过初始化之后,键盘的主要作用就是扫描按键动作,如果有按键动作则将其扫描码发送到键盘控制器 8042 芯片上。每个字母、数字、符号的按键扫描码并不能通过公式简单推算出来,必须通过查表才能知道。

1.1 扫描码

每次按下一个键,将发送一个扫描码(make code);放开按键后,再发送一个扫描码(break code)。大多数 make code 是一个字节的,少部分两字节扫描码是 E0 开头的,还有一个四字节的扫描码(对应 pause 键)。大多数 break code 是将 F0 加上对应的 make code 而组成,扩展键则是 E0 + F0 再加上对应的 make code 组成。 下表是几个按键的 make code 和 break code。

Key Make Code Break Code
A 1C F0, 1C
5 2E F0, 2E
F10 09 F0, 09
Right Arrow E0, 74 E0, F0, 74
Right CTRL E0, 14 E0, F0, 14

对于一次按键 shift + g 动作,将由按下 shift(0x12)、按下 g(0x34)、释放 g(0xF0, 0x34)、 释放 shift(0xF0, 0x12)四个动作构成,即序列 0x12、0x34、0xF0、0x34、0xF0、0x12。如果持续按键,则会按一定的频率重复发送扫描码。如果有多个按键长时间按下,则会将重复发送最后一个按键的扫描码。

上述扫描码我们称为键盘扫描码集合 2(Keyboard Scan Codes: Set 2),键盘控制器 8042 接收到 Set 2 的扫描码后会转换成 Set 1 的扫描码, PC 驱动程序读到的扫描码是后者 Set 1 扫描码。

1.2 键盘控制器

幸运的是在 XV6 的代码中无需处理扫描码的细节信息,因为我们是和键盘控制器 8042 通 信,而 8042 芯片会帮忙接收和处理按键的扫描码。8042 芯片有 4 个寄存器,分别是:

  1. 输入缓冲寄存器,保存从键盘接收的 1 字节数据。
  2. 输出缓冲寄存器,写出到键盘的 1 字节数据。
  3. 状态寄存器,8 个状态位,占 1 字节。
  4. 控制寄存器,7 个标志位,占 1 字节,可读可写。

这些寄存器的 IO 端口地址请参见下表:

Port Read / Write Function
0x60 Read Read Input Buffer
0x60 Write Write Output Buffer
0x64 Read Read Status Register
0x64 Write Send Command to Port

其中对 0x64 的写操作可以发出 Read Command Byte 读操作,也可以发出 Write Command Byte 写操作。无论是往 0x64 里面写出 “读” 或 “写” 命令,都不会将数值写到哪个寄存器中,而是由 8042 芯片解释并执行,如果命令中有参数则通过 0x60 端口传送,反之如有返回数据也从 0x60 端口读回。

状态寄存器的 8 位标志定义如所示。

名称 说明
7 PERR Parity Error,0 正常,1 奇偶检验错
6 TO Receive Timeout, 0 正常,1 表示键盘对命令的响应超时
5 MOBF Transmit Timeout,0 表示正常,1 表示 15 ms 内未收到键盘时钟信号
4 INH Inhibit flag,0 表示进制与键盘通信,1 表示可以与键盘通信
3 A2 Address line A2,8042 内部使用,0 表示上次写入 0x60,1 表示上次写入的是 0x64
2 SYS System flag,0 表示上电启动中,1 表示收到 BAT(Basic Assurance Test)自检结果
1 IBF Input Buffer Full,1 表示满,可以读入数据
0 OBF Output Buffer Full ,1 表示满,不可写出数据

例如从状态寄存器读入 0x64=00010100b,则说明没有禁止键盘通信、8042 已经完成了 BAT(Basic Assurance Test) 自测。

当 8042 接收一个有效的扫描码后,转换成另一种扫描码存放在 0x60 端口,并设置 IBF 标志,发出 IRQ1 中断。8042 在接收到一个扫描码后,将停止键盘时钟禁止接收其他按键扫描码 ,直到输入寄存器清空。

IRQ1 中断对应 0x09 中断向量,驱动程序将从 0x60 读入按键扫描码,处理后写入键盘输入缓冲区。

1.3 键盘控制器命令

键盘控制命令需要写入到 0x64 端口,如果有参数则需要随后写入到 0x60 中。只有当 8042 的 OBF 标志清零后才能发送下一个命令或参数。命令包括:

  1. 0x20(Read Command Byte),返回命令字节。
  2. 0x60(Write Command Byte),将参数保存为命令字节(Command Byte)

命令字节的定义如下表:

名称 说明
7    
6 XLAT  
5 _EN2 Disable Mouse,0 表示使能鼠标 Auxillary PS/2 接口正常,1 表示禁止
4 _EN Inhibit flag,0 表示禁止与键盘通信,1 表示可以与键盘通信
3   Address line A2,8042 内部使用,0 表示上次写入 0x60,1 表示上次写入的是 0x64
2 SYS System flag,0 表示让POST 电路上电自测,1 表示收到 BAT 后的热启动
1 INT2 Mouse Input Buffer Full Interruption,0 表示禁止鼠标 Auxillary IBFvsdr,1 表示使能
0 INT Input Buffer Full Interruption,0 表示禁止 IBF 中断,1 表示使能 IBF 中断

1.4 发给 i8042 的命令

驱动对键盘控制器发送命令是通过写端口 64h 实现的,共有 12 条命令,分别为:

  1. 20h:准备读取 8042 芯片的 Command Byte;其行为是将当前 8042 Command Byte 的内容 放置于 Output Register 中,下一个从 60H 端口的读操作将会将其读取出来。
  2. 60h:准备写入 8042 芯片的 Command Byte;下一个通过 60h 写入的字节将会被放入 Command Byte。
  3. A4h:测试一下键盘密码是否被设置;测试结果放置在 Output Register,然后可以通过 60h 读取出来。测试结果可以有两种值:FAh=密码被设置;F1h=没有密码。
  4. A5h:设置键盘密码。其结果被按照顺序通过 60h 端口一个一个被放置在 Input Register 中。密码的最后是一个空字节(内容为 0)。 A6h 让密码生效。在发布这个命令之前,必须首先使用 A5h 命令设置密码。
  5. AAh:自检。诊断结果放置在 Output Register 中,可以通过 60h 读取。
  6. 55h=OK。
  7. ADh:禁止键盘接口。Command Byte 的 bit-4 被设置。当此命令被发布后,Keyboard 将被 禁止发送数据到 Output Register。
  8. AEh:打开键盘接口。Command Byte 的 bit-4 被清除。当此命令被发布后,Keyboard 将被 允许发送数据到 Output Register。
  9. C0h:准备读取 Input Port。Input Port 的内容被放置于 Output Register 中,随后可以通过 60h 端口读取。
  10. D0h:准备读取 Outport 端口。结果被放在 Output Register 中,随后通过 60h 端口读取出来。
  11. D1h:准备写 Output 端口。随后通过 60h 端口写入的字节,会被放置在 Output Port 中。
  12. D2h:准备写数据到 Output Register 中。随后通过 60h 写入到 Input Register 的字节会被放 入到 Output Register 中,此功能被用来模拟来自于 Keyboard 发送的数据。如果中断被允许, 则会触发一个中断。

1.5 发给 8048 的命令

8042 可以向键盘设备发送命令,例如可以将键盘上的 LED 等点亮等。共有 10 条命令,分别为:

  1. EDh:设置 LED。Keyboard 收到此命令后,一个 LED 设置会话开始。Keyboard 首先回复一 个 ACK(FAh),然后等待从 60h 端口写入的 LED 设置字节,如果等到一个,则再次回复一个 ACK,然后根据此字节设置 LED。然后接着等待。直到等到一个非 LED 设置字节(高位被设 置),此时 LED 设置会话结束。
  2. EEh:诊断 Echo。此命令纯粹为了检测 Keyboard 是否正常,如果正常,当 Keyboard 收到此 命令后,将会回复一个 EEh 字节。
  3. F0h:选择 Scan code set。Keyboard 系统共可能有 3 个 Scan code set。当 Keyboard 收到此 命令后,将回复一个 ACK,然后等待一个来自于 60h 端口的 Scan code set 代码。系统必须在此 命令之后发送给 Keyboard 一个 Scan code set 代码。当 Keyboard 收到此代码后,将再次回复一 个 ACK,然后将 Scan code set 设置为收到的 Scan code set 代码所要求的。
  4. F2h:读取 Keyboard ID。由于 8042 芯片后不仅仅能够接 Keyboard。此命令是为了读取 8042 后所接的设备 ID。设备 ID 为 2 个字节,Keyboard ID 为 83ABh。当键盘收到此命令后,会首先 回复一个 ACK,然后,将 2 字节的 Keyboard ID 一个一个回复回去。
  5. F3h:设置 Typematic Rate/Delay。当 Keyboard 收到此命令后,将回复一个 ACK。然后等待 来自于 60h 的设置字节。一旦收到,将回复一个 ACK,然后将 Keyboard Rate/Delay 设置为相 应的值。
  6. F4h:清理键盘的 Output Buffer。一旦 Keyboard 收到此命令,将会将 Output buffer 清空, 然后回复一个 ACK。然后继续接受 Keyboard 的击键。
  7. F5h:设置默认状态(w/Disable)。一旦 Keyboard 收到此命令,将会将 Keyboard 完全初始化 成默认状态。之前所有对它的设置都将失效——Output buffer 被清空,Typematic Rate/Delay 被设置成默认值。然后回复一个 ACK,接着等待下一个命令。需要注意的是,这个命令被执行 后,键盘的击键接受是禁止的。如果想让键盘接受击键输入,必须 Enable Keyboard。
  8. F6h:设置默认状态。和 F5 命令唯一不同的是,当此命令被执行之后,键盘的击键接收是 允许的。
  9. FEh Resend:如果 Keyboard 收到此命令,则必须将刚才发送到 8042 Output Register 中的 数据重新发送一遍。当系统检测到一个来自于 Keyboard 的错误之后,可以使用自命令让 Keyboard 重新发送刚才发送的字节。
  10. FFh Reset Keyboard:如果 Keyboard 收到此命令,则首先回复一个 ACK,然后启动自身的 Reset 程序,并进行自身基本正确性检测(BAT-Basic Assurance Test)。等这一切结束之后,将 返回给系统一个单字节的结束码(AAh=Success, FCh=Failed),并将键盘的 Scan code set 设置 为 2。

1.6 读到的数据

00h/FFh 当击键或释放键时检测到错误时,则在 Output Bufer 后放入此字节,如果 Output Buffer 已满,则会将 Output Buffer 的最后一个字节替代为此字节。使用 Scan code set 1 时使用 00h,Scan code 2 和 Scan Code 3 使用 FFh。

AAh BAT 完成代码。如果键盘检测成功,则会将此字节发送到 8042 Output Register 中。

EEh Echo 响应。Keyboard 使用 EEh 响应从 60h 发来的 Echo 请求。

F0h 在 Scan code set 2 和 Scan code set 3 中,被用作 Break Code 的前缀。

FAh ACK。当 Keyboard 任何时候收到一个来自于 60h 端口的合法命令或合法数据之后,都 回复一个 FAh。

FCh BAT 失败代码。如果键盘检测失败,则会将此字节发送到 8042 Output Register 中。

FEh Resend。当 Keyboard 任何时候收到一个来自于 60h 端口的非法命令或非法数据之后, 或者数据的奇偶交验错误,都回复一个 FEh,要求系统重新发送相关命令或数据。

83ABh 当键盘收到一个来自于 60h 的 F2h 命令之后,会依次回复 83h,ABh。83AB 是键 盘的 ID。

Scan code 除了上述那些特殊字节以外,剩下的都是 Scan code。

1.7 端口操作

端口的读写操作,驱动中使用函数 READ_PORT_UCHAR 进行读操作,READ_PORT_UCHAR 中使用 CPU 读端口指令 in。驱动中使用函数 WRITE_PORT_UCHAR 进行写操作, WRITE_PORT_UCHAR 中使用 CPU 写端口指令 out

读取状态寄存器

读取状态寄存器的方法,对 64h 端口进行读操作。

读数据

需要读取的数据有,i8042 从 i8048 得到的按键的扫描码,i8042 命令的 ACK,i8042 从 i8048 得到的 i8048 命令的 ACK,需要命令重发的 RESEND,一些需要返回结果的命令得到的结果。

当有数据需要被驱动读走的时候,数据被放入输出缓冲器,同时将状态寄存器的 bit0 (OUTPUT_BUFFER_FULL)置 1,引发键盘中断(键盘中断的 IRQ 为 1)。由于键盘中断,引起 由键盘驱动提供的键盘中断服务例程被执行。在键盘中断服务例程中,驱动会从 i8042 读走数 据。一旦数据读取完成,状态寄存器的 bit0 会被清 0。

读数据的方法,首先,读取状态寄存器,判断 bit0,状态寄存器 bit0 为 1,说明输出缓冲 器中有数据。保证状态寄存器 bit0 为 1,然后对 60h 端口进行读操作,读取数据。

这里我们要谈一点很有用的题外话,前面提到的 IRQ,是 Interrupt Request line,中断请 求线,是一个硬件线,它和中断向量是不同的。中断向量是用来在中断描述符表(IDT)中查找中 断服务例程的那个序号。键盘的 IRQ 是 1,键盘中断服务例程对应的中断向量可不是 1。这点 要弄清楚。

向 i8042 发命令

当命令被发往 i8042 的时候,命令被放入输入缓冲器,同时引起状态寄存器的 Bit1 置 1, 表示输入缓冲器满,同时引起状态寄存器的 Bit2 置 1,表示写入输入缓冲器的是一个命令。

向 i8042 发命令的方法,首先,读取状态寄存器,判断 bit1,状态寄存器 bit1 为 0,说明 输入缓冲器为空,可以写入。保证状态寄存器 bit1 为 0,然后对 64h 端口进行写操作,写入命 令。

间接向 i8048 发命令

向 i8042 发这些命令,i8042 会转发 i8048,命令被放入输入缓冲器,同时引起状态寄存器 的 Bit1 置 1,表示输入缓冲器满,同时引起状态寄存器的 Bit2 置 1,表示写入输入缓冲器的 是一个命令。这里我们要注意,向 i8048 发命令,是通过写 60h 端口,而后面发命令的参数, 也是写 60h 端口。i8042 如何判断输入缓冲器中的内容是命令还是参数呢,我们在后面发命令 的参数中一起讨论。

向 i8048 发命令的方法,首先,读取状态寄存器,判断 bit1,状态寄存器 bit1 为 0,说明输 入缓冲器为空,可以写入。保证状态寄存器 bit1 为 0,然后对 60h 端口进行写操作,写入命令。

发命令的参数

某些命令需要参数,我们在发送命令之后,发送它的参数,参数被放入输入缓冲器,同时 引起状态寄存器的 Bit1 置 1,表示输入缓冲器满。这里我们要注意,向 i8048 发命令,是通过 写 60h 端口,发命令的参数,也是写 60h 端口。i8042 如何判断输入缓冲器中的内容是命令还 是参数呢。i8042 是这样判断的,如果当前状态寄存器的 Bit3 为 1,表示之前已经写入了一个 命令,那么现在通过写 60h 端口放入输入缓冲器中的内容,就被当做之前命令的参数,并且 引起状态寄存器的 Bit3 置 0。如果当前状态寄存器的 Bit3 为 0,表示之前没有写入命令,那 么现在通过写 60h 端口放入输入缓冲器中的内容,就被当做一个间接发往 i8048 的命令,并且 引起状态寄存器的 Bit3 置 1。

向 i8048 发参数的方法,首先,读取状态寄存器,判断 bit1,状态寄存器 bit1 为 0,说明输 入缓冲器为空,可以写入。保证状态寄存器 bit1 为 0,然后对 60h 端口进行写操作,写入参数。

2. 相关代码

2.1 变量定义

变量定义均在 kbd.h

normalmap[256] 对应直接按键。

shiftmap[256] 对应 shift 组合键

ctlmap[256] 对应 CTRL 组合按键。

这三个表都是以键盘扫描码集合 1(Keyboard Scan Codes: Set 1)转换为按键的 ASCII 码的。例如, Set 1 中按键 1 的 make 码是 0x02,break 码是 0x82(0x02 + 0x80); 按键 A 的 make 码是 0x1E,break 码是 0x9E(0x1E + 0x80)。

扫描码到 ASCII 转换的码表有三个,normalmap[256]shfitmap[256]ctlmap[256],分别 对应于单键动作、SHIFT 组合按键、CTRL 组合按键的 ASCII 码。

CAPSLOCK、NUMLOCK 和 SCROLLLOCK 三个按键每按一次状态反转一次,因此使用 toggledmap[] 码表进行转换,结果记录到 shift 变量中。

这些转换表都是根据 set 1 扫描码而产生的,set 1 扫描码的示例如下图:

Key Make Break Code
A 1E 9E
1 02 82
L SHIFT 2A AA
R SHIFT 36 B6
L ALT 38 B8
R ALT E0, 38 E0, B8
L CTRL 1D 9D
R CTRL E0, 1D E0, 9D

例如按键 1 的 set 1 扫描码是 0x02,因此 normalmap[02]=’1’,同理有按键 A 对应normalmap[0x1E]=’a’。 也就说,将 set 1 扫描码作为数组下标,就能从 normalmap[] 中读取到相应按键的 ASCII 码值。

2. 功能实现

功能实现均在 kdb.c

kdb.c 中就只有两个函数,一个是键盘中断服务函数 kdintr(),另一个就是读取按键的 kbdgetc()

kbdgetc() 函数读取的按键扫描码保存在 data 中,SHIFT / CTRL 等按键状态保存在 shift 静态变量中,根据情况不同需要做不同处理:

  1. 如果是 E0 转义扫描码,则通过 shift |= E0ESC 记录,返回 0 值。
  2. 如果是释放按键的操作,且上一次是 E0ESC 则扫描码不变,否则 data & 0x7F 取扫描码的低 7 位(相当于转换成 make 码)。清除 shift 对应的 SHIFT、CTL、ALT 位,清除 shift 中的 E0ESC 标志。
  3. 如果上一次是 E0 转义扫描码,则扫描码修正为 data |= 0x80,然后清除 shift 中的 E0ESC 标志。

从上面可以看出 E0 扫描码仅能使 shift 的 E0ESC 位有效一次,然后会被清除。

如果通过 togglecode[] 码表发现有 CAPSLOCK、NUMLOCK 和 SCROLLLOCK 按键,则进行变换。

通过 shiftcode[] 码表检查和记录 shiftcrtlalt 的按键情况。

将处理后的扫描码经过 charcode[][] 表转换成 ASCII 码。

最后如果 CAPSLOCK 标记有效且 ASCII 字符是英文字符,则进行大小写转换,a~z 转换成 A~Z,反之如果扫描码转换后是 A~Z(例如 shift+’a’ ~ shift+’z’)则转换成 a~z