键盘输入实战: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 中即为扫描码
retAT&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
ret8042 实际上是一个独立的微控制器。早期的 8042 固件还负责 A20 门控制和系统复位。现代芯片组中,8042 功能被集成到 Super I/O 或 PCH 中,但端口地址和寄存器布局保持不变以确保兼容性。
小节练习
判断题 1
PS/2 键盘的数据端口地址是
0x64。 ( )
正确
错误
点击查看答案 解析: 数据端口是
0x60,0x64是状态/命令端口。in al, 0x60读数据,in al, 0x64读状态。答案: 错误
第二节:扫描码体系——从按键到编码
2.1 扫描码基础
每次按键(按下或释放)都会产生一个 1 到多个字节的扫描码序列。PS/2 键盘默认使用扫描码集 1(兼容 AT 键盘)。
| 扫描码类型 | 含义 |
|---|---|
| Make Code | 键被按下时的编码(bit 7 = 0) |
| Break Code | 键被释放时的编码 = `Make Code |
| Extended Prefix | 0xE0 — 某些键的前缀(右 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, SpaceShift 键按下时对应的扫描码:
- 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 可以同时执行其他任务。 ( )
正确
错误
点击查看答案 解析: 轮询模式下 CPU 反复检查
0x64端口,无法执行其他任务。HLT+中断模式可以在无键按下时休眠 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总是向键盘发送数据。 ( )
正确
错误
点击查看答案 解析:
0x64是命令端口,向其写入的是给 8042 控制器 的命令(如读/写输出端口、自检等),而非直接发给键盘的数据。键盘数据通过0x60发送。答案: 错误
判断题 2
键盘 break code 不会触发 IRQ1 中断。 ( )
正确
错误
点击查看答案 解析: 按键释放时也会触发 IRQ1 中断,break code 同样通过扫描码传回。ISR 需要根据
test al, 0x80区分 make/break。答案: 错误
判断题 3
8042 状态寄存器的 bit 1 (IBF) 为 1 时,可以向
0x60或0x64写入数据。 ( )
正确
错误
点击查看答案 解析: IBF=1 表示输入缓冲区已满。CPU 必须等待 IBF=0 才能安全写入数据或命令。强制写入会导致数据丢失。
答案: 错误
判断题 4
所有键盘按键都产生相同长度的扫描码。 ( )
正确
错误
点击查看答案 解析: 大多数按键为 1 字节,但扩展键(如方向键、Pause/Break、Print Screen 等)可能产生 2-8 字节的多字节扫描码序列。
答案: 错误
判断题 5
A20 门是通过 8042 输出端口的 bit 1 来控制的。 ( )
正确
错误
点击查看答案 解析: 8042 的输出端口 bit 1 控制 A20 门。
0xD0读输出端口 → 置位 bit 1 →0xD1写回。答案: 正确
动手练习题
练习题 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 等)并记录它们产生的多字节序列。
练习
本章实践性强,请用动手练习题自检(扫描码解码、环形缓冲区、大小写处理)。