键盘输入实战:PS/2 8042 控制器 (Keyboard: PS/2 8042)


章节概述

键盘是计算机最基础的人机交互设备。本章深入剖析 PS/2 键盘控制器的硬件编程模型——8042 芯片的端口与寄存器、扫描码的接收与解码、中断驱动与轮询两种模式的实现,并给出一个完整的裸机键盘输入系统(键盘 ISR + VGA 回显),让你理解”按下键盘的瞬间,到底发生了什么”。本章的键盘 ISR 代码将直接对接 IDT 中断系统VGA 显存输出

核心理念:键盘输入的本质是硬件触发中断 → CPU 响应 → ISR 读取端口数据 → 软件解码扫描码 → 字符缓冲区。这一流程串联了端口 I/O、中断处理、数据缓冲区三大机制,是理解所有输入设备驱动的万能模板。


第一节:PS/2 8042 控制器架构

PS/2 接口由 Intel 8042 微控制器(或兼容芯片)管理,负责键盘和鼠标的通信。8042 通过两个 I/O 端口与 CPU 交互:

端口方向含义
0x60读/写数据端口:读键盘扫描码 / 写键盘命令
0x64状态端口:读取控制器状态寄存器
0x64命令端口:向 8042 控制器发送命令

状态端口 (0x64) 位定义:

Bit 7: 奇偶校验错误 (PARITY)
Bit 6: 接收超时 (TO)
Bit 5: 发送超时 (TO)
Bit 4: 键盘锁定 (INH)
Bit 3: 命令/数据 (CMD/DATA) — 1=命令, 0=数据
Bit 2: 系统标志 (SYS) — POST 完成后置 1
Bit 1: 输入缓冲区满 (IBF) — 1=CPU 写入前须等待此位为 0
Bit 0: 输出缓冲区满 (OBF) — 1=有数据可从 0x60 读取

最基本的键盘读取流程:

; 轮询读取键盘扫描码(NASM Intel 语法)
wait_key:
 in al, 0x64 ; 读取状态端口
 test al, 1 ; 检查 bit 0: 输出缓冲区是否满?
 jz wait_key ; 为 0 → 没有数据,继续等待
 in al, 0x60 ; 从数据端口读取扫描码
 ; AL 中即为扫描码
 ret

AT&T 语法对照:

/* AT&T 语法:轮询读取键盘 */
wait_key:
 inb $0x64, %al
 testb $1, %al
 jz wait_key
 inb $0x60, %al
 ret

向键盘发送命令(如设置 LED):

; 发送命令到键盘:先确保输入缓冲区空闲
send_kbd_cmd:
 in al, 0x64
 test al, 2 ; 检查 bit 1: 输入缓冲区满?
 jnz send_kbd_cmd ; 满了则等待
 mov al, 0xED ; 0xED = 设置 LED 命令
 out 0x60, al
 ret

8042 实际上是一个独立的微控制器。早期的 8042 固件还负责 A20 门控制和系统复位。现代芯片组中,8042 功能被集成到 Super I/O 或 PCH 中,但端口地址和寄存器布局保持不变以确保兼容性。

小节练习

判断题 1

PS/2 键盘的数据端口地址是 0x64。 ( )

  • 正确

  • 错误


第二节:扫描码体系——从按键到编码

2.1 扫描码基础

每次按键(按下或释放)都会产生一个 1 到多个字节的扫描码序列。PS/2 键盘默认使用扫描码集 1(兼容 AT 键盘)。

扫描码类型含义
Make Code键被按下时的编码(bit 7 = 0)
Break Code键被释放时的编码 = `Make Code
Extended Prefix0xE0 — 某些键的前缀(右 Ctrl、右 Alt、方向键等)

常用键的扫描码对照表:

; 扫描码 → ASCII 查找表(未按 shift 时)
keymap_lower:
 db 0, 0, '1', '2', '3', '4', '5', '6', '7', '8'
 db '9', '0', '-', '=', 0 ; Backspace
 db 0 ; Tab
 db 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'
 db '[', ']', 0 ; Enter
 db 0 ; Ctrl
 db 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'
 db ';', 0x27, '`', 0 ; Left Shift
 db '\', 'z', 'x', 'c', 'v', 'b', 'n', 'm'
 db ',', '.', '/', 0 ; Right Shift
 db '*', 0 ; Alt, Space

Shift 键按下时对应的扫描码:

  • Left Shift: Make=0x2A, Break=0xAA
  • Right Shift: Make=0x36, Break=0xB6
  • Left Ctrl: Make=0x1D, Break=0x9D
  • Left Alt: Make=0x38, Break=0xB8
  • Caps Lock: Make=0x3A, Break=0xBA(切换型——每次按下翻转状态)

2.2 扫描码解码逻辑

; 扫描码解码函数 — 将 AL 中的扫描码转换为 ASCII
; 维护全局变量 shift_pressed 和 caps_locked
; 返回: AL = ASCII 字符(或 0 表示不可打印键)
 
decode_scancode:
 cmp al, 0x2A ; Left Shift make?
 je set_shift
 cmp al, 0x36 ; Right Shift make?
 je set_shift
 cmp al, 0xAA ; Left Shift break?
 je clear_shift
 cmp al, 0xB6 ; Right Shift break?
 je clear_shift
 cmp al, 0x3A ; Caps Lock?
 je toggle_caps
 
 test al, 0x80 ; 检查 bit 7: 是否是 break code?
 jnz .done ; 是释放键,忽略
 cmp al, 58 ; 扫描码越界?
 jae .done
 
 movzx ebx, al
 push eax
 ; 选择大小写查找表
 mov al, [shift_pressed]
 or al, [caps_locked]
 and al, 1 ; 如果 shift XOR caps 为 1 → 大写
 jz .lower
 mov al, [keymap_upper + ebx]
 jmp .output
.lower:
 mov al, [keymap_lower + ebx]
.output:
 ; AL 现在是 ASCII 字符
.done:
 ret
 
set_shift:
 mov byte [shift_pressed], 1
 ret
clear_shift:
 mov byte [shift_pressed], 0
 ret
toggle_caps:
 xor byte [caps_locked], 1
 ret

扫描码集 2 和 3 在现代 PS/2 键盘中也存在,但 8042 硬件会自动将其转换为扫描码集 1 再给 CPU。因此内核层面始终看到的是扫描码集 1。

小节练习


第三节:中断驱动模式——IRQ1 键盘中断

3.1 PIC 8259 的关键端口

键盘连接到主 PIC(8259)的 IRQ1(中断向量号 33,因为被偏移到 0x21 领域)。IRQ1 仅当按键被按下时触发(break code 也会触发,用于检测按键释放)。

PIC 寄存器:

  • 主 PIC 数据: 0x21 (IMR—中断屏蔽寄存器)
  • PIC 命令: 0x20 (EOI—中断结束命令)

3.2 完整的键盘中断驱动示例

以下是一个可运行于 QEMU 裸机环境的完整键盘输入系统:中断驱动 + VGA 回显:

; kbdint.asm — 完整的键盘中断驱动 + VGA 回显
; 编译: nasm -f bin kbdint.asm -o kbdint.bin
; 运行: qemu-system-x86_64 -drive format=raw,file=kbdint.bin
 
[org 0x7C00]
[bits 16]
 
 jmp start
 
; ─── 变量 ───
shift db 0
caps db 0
buf_pos dw 0 ; VGA 光标位置 (字偏移)
 
; ─── 扫描码 → ASCII 查找表 ───
keymap_l:
 db 0,0,'1','2','3','4','5','6','7','8','9','0','-','=',0
 db 0,'q','w','e','r','t','y','u','i','o','p','[',']',0
 db 0,'a','s','d','f','g','h','j','k','l',';',0x27,'`'
 db 0,'\','z','x','c','v','b','n','m',',','.','/',0
 db '*',0,' '
keymap_u:
 db 0,0,'!','@','#','$','%','^','&','*','(',')','_','+',0
 db 0,'Q','W','E','R','T','Y','U','I','O','P','{','}',0
 db 0,'A','S','D','F','G','H','J','K','L',':','"','~'
 db 0,'|','Z','X','C','V','B','N','M','<','>','?',0
 db '*',0,' '
 
; ─── 中断处理 ───
kbd_isr:
 pusha
 in al, 0x60 ; 读扫描码
 
 ; ── Shift 处理 ──
 cmp al, 0x2A
 je .shift_down
 cmp al, 0x36
 je .shift_down
 cmp al, 0xAA
 je .shift_up
 cmp al, 0xB6
 je .shift_up
 
 ; ── Caps Lock ──
 cmp al, 0x3A
 je .toggle_caps
 
 ; ── 忽略 break code ──
 test al, 0x80
 jnz .done_isr
 
 ; ── 扫描码 → ASCII ──
 cmp al, 58
 jae .backspace ; 特殊键
 movzx bx, al
 push ax
 mov al, [shift]
 or al, [caps]
 and al, 1
 jz .use_lower
 mov al, [keymap_u + bx]
 jmp .print_char
.use_lower:
 mov al, [keymap_l + bx]
.print_char:
 cmp al, 0
 je .done_isr
 call putchar
 jmp .done_isr
 
.backspace:
 cmp al, 0x0E ; Backspace 扫描码
 jne .done_isr
 mov al, 0x08
 call putchar
 
.done_isr:
 mov al, 0x20
 out 0x20, al ; EOI 给主 PIC
 popa
 iret
 
.shift_down:
 mov byte [shift], 1
 jmp .done_isr
.shift_up:
 mov byte [shift], 0
 jmp .done_isr
.toggle_caps:
 xor byte [caps], 1
 jmp .done_isr
 
; ── VGA 输出 ──
putchar:
 push es
 push di
 mov di, 0xB800
 mov es, di
 mov di, [buf_pos]
 cmp al, 0x08 ; Backspace
 jne .not_bs
 test di, di
 jz .done_out
 sub di, 2
 mov word [es:di], 0x0F20 ; 空格覆盖
 mov [buf_pos], di
 jmp .done_out
.not_bs:
 mov ah, 0x0F ; 黑底白字
 stosw
 mov [buf_pos], di
.done_out:
 pop di
 pop es
 ret
 
; ── 初始化 IDT ──
start:
 xor ax, ax
 mov ds, ax
 mov es, ax
 mov ss, ax
 mov sp, 0x7C00
 
 ; 设置 IVT 中断向量 33 (IRQ1 = INT 0x21)
 ; 实模式下:IVT[0x21] = CS:IP
 mov word [0x21*4], kbd_isr ; offset
 mov word [0x21*4+2], 0 ; segment (我们代码段是 0)
 
 ; 初始化 PIC — 重新映射中断到 0x20-0x2F
 ; ICW1: 初始化 + 级联模式
 mov al, 0x11
 out 0x20, al ; 主 PIC ICW1
 out 0xA0, al ; 从 PIC ICW1 (虽然键盘在从片 IRQ2, 但这里键盘 IRQ1 在主片)
 ; ICW2: 向量偏移
 mov al, 0x20
 out 0x21, al ; 主 PIC 中断向量起始 0x20
 mov al, 0x28
 out 0xA1, al ; 从 PIC 中断向量起始 0x28
 ; ICW3: 主从关系
 mov al, 0x04
 out 0x21, al ; 主 PIC: IRQ2 连接从片 (bit 2)
 mov al, 0x02
 out 0xA1, al ; 从 PIC: cascaded
 ; ICW4: 8086 模式
 mov al, 0x01
 out 0x21, al
 out 0xA1, al
 ; 屏蔽 IRQ2 (键盘在 IRQ1)
 mov al, 0xFD ; 只开放 IRQ1 (bit 1 = 0)
 out 0x21, al
 
 sti
 
 ; 等待中断 — 主循环休眠
.loop:
 hlt
 jmp .loop
 
; ── 启动签名 ──
times 510-($-$$) db 0
dw 0xAA55

此代码直接在 QEMU 中运行后,键盘输入会即时显示在 VGA 屏幕上。其中断系统、PIC 编程细节请参阅 中断与 IDT

小节练习


第四节:轮询模式——无需中断的键盘读取

对于更简单的场景(如没有 IDT 初始化的早期引导阶段),可以使用轮询模式读取键盘。轮询模式简单可靠,缺点是无法同时做其他事情。

; pollkbd.asm — 轮询读取键盘并在 VGA 显示
[org 0x7C00]
[bits 16]
 
start:
 xor ax, ax
 mov ds, ax
 mov ss, ax
 mov sp, 0x7C00
 
 ; 清屏
 mov ax, 0xB800
 mov es, ax
 xor di, di
 mov cx, 80*25
 mov ax, 0x0F20 ; 空格 + 白属性
 rep stosw
 xor di, di
 
.scan_loop:
 in al, 0x64 ; 读状态
 test al, 1 ; 有数据?
 jz .scan_loop
 in al, 0x60 ; 读扫描码
 
 ; 只处理 make code (bit 7 = 0)
 test al, 0x80
 jnz .scan_loop
 cmp al, 58
 jae .scan_loop
 
 ; 查表转 ASCII
 movzx bx, al
 mov al, [keymap + bx]
 cmp al, 0
 je .scan_loop
 
 ; 输出到 VGA
 mov ah, 0x0F
 stosw
 cmp di, 80*25*2
 jb .scan_loop
 xor di, di ; 滚屏回顶
 jmp .scan_loop
 
keymap:
 db 0,0,'1','2','3','4','5','6','7','8','9','0','-','=',0
 db 0,'q','w','e','r','t','y','u','i','o','p','[',']',0
 db 0,'a','s','d','f','g','h','j','k','l',';',0x27,'`'
 db 0,'\','z','x','c','v','b','n','m',',','.','/',0
 db '*',0,' '
 
times 510-($-$$) db 0
dw 0xAA55

轮询 vs 中断的对比:

特性轮询模式中断模式
实现复杂度极简需要 IDT + PIC
CPU 利用率100% 空转等待HLT 休眠
延迟取决于轮询频率即时响应
适合场景引导加载器、调试操作系统内核

轮询模式实际上很少用于正式内核。在实际内核中,ISR 收到扫描码后会放入环形缓冲区(ring buffer),阅读器(如 getchar())再从缓冲区读出,这样 ISR 快速返回,不阻塞中断。

小节练习

判断题 1

轮询模式下 CPU 可以同时执行其他任务。 ( )

  • 正确

  • 错误


第五节:A20 门——键盘控制器的隐藏功能

5.1 什么是 A20 门

A20(Address line 20)是 CPU 地址总线的第 21 根线(从 A0 开始数)。在 8086 时代,只有 20 根地址线(A0-A19),寻址范围 1MB。1MB 以上的地址会回绕到低地址(如 0xFFFF:0x0010 = 0x100000 被截断为 0x00000)。历史程序依赖此行为。

进入保护模式后,32 位 CPU 需要访问 1MB 以上的内存。A20 门是一个遗留开关:

  • A20 关闭(默认):bit 20 始终为 0 → 只有 1MB 可寻址
  • A20 开启:bit 20 正常 → 可访问 1MB 以上

5.2 通过键盘控制器开启 A20

; 通过 8042 开启 A20 门
enable_A20:
 call .wait_input
 mov al, 0xAD ; 禁用键盘
 out 0x64, al
 call .wait_input
 mov al, 0xD0 ; 读控制器输出端口
 out 0x64, al
 call .wait_output
 in al, 0x60
 push ax ; 保存原始值
 call .wait_input
 mov al, 0xD1 ; 写控制器输出端口
 out 0x64, al
 call .wait_input
 pop ax
 or al, 2 ; 置位 bit 1 (A20 门)
 out 0x60, al
 call .wait_input
 mov al, 0xAE ; 启用键盘
 out 0x64, al
 ret
 
.wait_input:
 in al, 0x64
 test al, 2 ; 输入缓冲区满?
 jnz .wait_input
 ret
 
.wait_output:
 in al, 0x64
 test al, 1 ; 输出缓冲区满?
 jz .wait_output
 ret

现代系统通常使用 Fast A20(通过 System Control Port A, 0x92)或 BIOS 的 int 0x15, AX=0x2401 来开启 A20,而不是对 8042 进行繁琐的读写。但理解键盘控制器的这一角色,是理解 x86 历史兼容性设计的绝佳案例。

小节练习


章节测试

一、判断题(正确选 ,错误选 )

判断题 1

out 0x64, al 总是向键盘发送数据。 ( )

  • 正确

  • 错误

判断题 2

键盘 break code 不会触发 IRQ1 中断。 ( )

  • 正确

  • 错误

判断题 3

8042 状态寄存器的 bit 1 (IBF) 为 1 时,可以向 0x600x64 写入数据。 ( )

  • 正确

  • 错误

判断题 4

所有键盘按键都产生相同长度的扫描码。 ( )

  • 正确

  • 错误

判断题 5

A20 门是通过 8042 输出端口的 bit 1 来控制的。 ( )

  • 正确

  • 错误


动手练习题

练习题 1:轮询键盘 + 串口输出

难度: 简单

利用 串口 UART 的串口初始化代码,写一个纯轮询键盘程序:按下键盘 → 读取扫描码 → 解码为 ASCII → 通过串口发送。在 QEMU 中用 -serial stdio 运行。要求区分 make/break,仅 make code 可见。

练习题 2:中断驱动键盘 + 环形缓冲区

难度: 简单

基于本章第三节的中断驱动示例,实现一个 256 字节的环形缓冲区:

  • ISR 将扫描码写入缓冲区尾指针
  • 主循环 HLT 醒来后从缓冲区读字符并显示
  • 正确处理缓冲区满和空的情况
  • 添加 Enter 键处理(换行)

练习题 3:Shift+Caps 完整大小写支持

难度: 简单

完善大小写处理逻辑:

  • Shift 按下时大写字母 + 符号切换(数字键产生符号)
  • Caps Lock 切换:按下后,字母键倒转大小写;再次按下恢复
  • 在 VGA 屏幕角落显示 Caps Lock 状态(大写指示)
  • 使用 QEMU GDB 单步调试 ISR 中的扫描码解码逻辑

练习题 4:扫描码原始数据记录器

难度: 简单

写一个 Linux 内核模块(参考 内核索引)或裸机程序,将接收到的每个扫描码的原始字节以十六进制形式输出到屏幕/日志中。测试特殊键(方向键、功能键 F1-F12、Pause 等)并记录它们产生的多字节序列。

练习

本章实践性强,请用动手练习题自检(扫描码解码、环形缓冲区、大小写处理)。