汇编语言:外中断
外中断
接口芯片
CPU 并不直接从键盘、鼠标读取数据,也不直接向显示器写入像素。在 CPU 和外设之间以接口芯片作为中间层,如键盘控制器 8042、DMA 控制器、中断控制器 8259A。这些接口芯片内部有一组寄存器,CPU 把它们统一编址为端口。这些外设的端口数据流如下:
| 操作 | 数据流 |
|---|---|
| 输入 | 键盘 → 键盘控制器端口 → CPU 读取端口 |
| 输出 | CPU → 显卡端口 → 显示器 |
| 控制 | CPU → 控制端口 → 接口芯片 → 外设 |
因此,端口是 CPU 与外部硬件进行 I/O 通信的唯一桥梁,它让 CPU 无需了解键盘的扫描码协议或显示器的刷新时序,只需关心读写端口即可。
外中断的类型
外设随时可能产生输入,比如突然按下键盘,此时 CPU 通过中断机制得知外设的动作。当外设有事件需要处理时,接口芯片会向 CPU 发送一个外中断信息。CPU 在执行完当前指令后,检测到该信息,便会暂停当前任务,转去执行对应的外中断处理程序。
外中断分为两类,第一类为可屏蔽中断,这是绝大多数外设使用的中断类型,如键盘、鼠标、硬盘等。该类是可以被忽略的请求,CPU 是否响应它取决于标志寄存器中的 IF 位(Interrupt Flag,中断允许标志):
| IF 位 | 对可屏蔽中断的操作 |
|---|---|
| IF = 1 | CPU 允许响应可屏蔽中断。执行完当前指令后,立即转去执行中断处理程序。 |
| IF = 0 | CPU 屏蔽所有可屏蔽中断,对外设请求视而不见。 |
可屏蔽中断与内中断唯一区别在于中断类型码的获取方式,可屏蔽中断的类型码是通过数据总线从外部送入 CPU 的,而非由 CPU 内部产生。此外它们的中断过程几乎一致,都是标志寄存器和 CS:IP 入栈,然后根据 n×4 查中断向量表,设置新的 CS:IP 转去执行中断处理程序。
| 对比项 | 内中断(如 int 0、int n) |
可屏蔽外中断(如键盘) |
|---|---|---|
| 来源 | CPU 内部(指令、异常) | CPU 外部(接口芯片) |
| 类型码 | CPU 内部产生 | 数据总线送入 |
| IF 影响 | 中断开始时 IF=0 | 中断开始时 IF=0 |
| 响应条件 | 必然响应 | 取决于 IF 位 |
第二类是不可屏蔽中断(NMI),用于处理系统中必须响应的紧急事件,例如如电源掉电、内存校验错误等系统灾难场景。无论 IF 位是什么状态,CPU 必须立即响应。8086 中,不可屏蔽中断的类型码固定为 2,因此中断过程中无需从总线读取类型码,直接跳转到中断向量表 2 号表项:从中断向量表的 0000:0008 处读取 IP,从 0000:000A 处读取 CS,转去执行中断处理程序。
开/关中断指令
为了防止中断嵌套风暴,进入中断时要将 IF 置 0。如果 CPU 正在处理键盘中断,此时又来了一个新的键盘中断,若不屏蔽则 CPU 会不断嵌套处理中断,最终耗尽栈空间。因此,硬件在中断开始时自动将 IF 置 0,确保当前中断处理程序能安静地执行完毕。如果程序确实需要嵌套处理其他中断,可以在中断处理程序中用 sti 指令重新打开 IF。
| 指令 | 效果 |
|---|---|
sti |
Set Interrupt Flag,即开中断,运行后 IF = 1 |
cli |
Clear Interrupt Flag,即关中断,运行后 IF = 0 |
现代 x86 系统的外中断处理比 8086 复杂。传统的 8259A 中断控制器已被 APIC (高级可编程中断控制器) 取代,支持多核 CPU 的中断分发。中断向量表(IVT) 也演变为 IDT(中断描述符表),并引入了特权级检查。不过,其核心思想仍然是外设通过中断请求线(IRQ)向 CPU 发起请求,CPU 通过中断向量找到处理程序,并在处理前关闭中断以防止嵌套失控。
PC 机键盘的处理过程
键盘本质上是一组开关矩阵,内部有一个键盘控制器芯片(8048/8042 系列)持续扫描按键状态。当按下按键时将产生通码(Make Code),第 7 位为 0,例如 G 键的通码是 22h;松开按键时产生断码(Break Code),第 7 位为 1。断码 = 通码 + 80h,例如 G 键的断码是 A2h。
无论通码还是断码,最终都通过硬件总线送入60h 端口。扫描码只表示哪个键被操作,不表示输入了什么字符。G 和 g 的扫描码相同,区别取决于是否同时按下了 Shift。当 60h 端口收到扫描码后,键盘接口芯片会立即向 CPU 发出中断类型码为 9 的可屏蔽中断。此时如果 IF=1,CPU 执行完当前指令后,就会触发中断过程,转去执行 BIOS 预置的 int 9 中断例程。int 9 例程将完成三项任务:
- 读取扫描码:从 60h 端口读取刚刚产生的扫描码,这是所有后续处理的基础;
- 转换为系统可理解的状态:对扫描码进行转换,将信息存入相应的组件中;
- 硬件应答:向键盘控制器发送应答信号,允许其继续接收下一次按键。
其中对不不同的按键,int 9 例程的操作如下:
| 按键类型 | 操作 |
|---|---|
| 字符键 | 中断例程将扫描码翻译成对应的 ASCII 码,并将扫描码和 ASCII 码作为一个字存入 BIOS 键盘缓冲区。 |
| 控制键/切换键 | 例如 Ctrl、Shift、CapsLock 等,不生成 ASCII,而是更新内存中的键盘状态字节(0040:0017),用二进制位记录这些键的按下/释放状态。 |
BIOS 键盘缓冲区位于 0040:001E 开始的内存区,大小为 15 个字单元(32 字节)。每个字的高字节存扫描码,低字节存 ASCII 码。键盘缓冲区是一个环形队列,由 BIOS 维护,应用程序(如 DOS 的 int 21h/01h)可以从中读取输入。其中键盘状态字节位于 0040:0017,记录控制键的实时状态。状态位决定了字符键的最终翻译结果,例如当 CapsLock 位为 1 时,字母键的扫描码会被翻译成大写 ASCII。
| 位 | 含义 |
|---|---|
| 0 | 右 Shift |
| 1 | 左 Shift |
| 2 | Ctrl |
| 3 | Alt |
| 4 | ScrollLock |
| 5 | NumLock |
| 6 | CapsLock |
| 7 | Insert |
现代操作系统已不再依赖 BIOS 键盘缓冲区。Linux 和 Windows 都有键盘驱动,直接接管 8042 控制器或 USB 键盘的中断,将扫描码转换为键码(Keycode),再经输入子系统分发给应用程序。
编写 int 9 中断例程
从硬件角度看,键盘输入是一个由硬件驱动的过程,CPU 是被动响应的。键盘芯片扫描、产生扫描码、触发 9 号可屏蔽中断这三个步骤由硬件完成,程序员无法修改,因此唯一能进行干预的只有 int 9 中断例程。
字母序列显示
现在考虑一个程序,要求在屏幕中间依次显示 “a”~“z”,在显示的过程中,按下 Ese 键后改变显示的颜色。以下代码先将字符 'a' 存入 AH 寄存器,随后反复向文本显存第 12 行第 40 列写入 AH 中的字符并递增其 ASCII 值,直到 AH 超出 'z' 才停止循环。
assume cs:code
code segment
start:
mov ax, 0b800h
mov es, ax
mov ah, 'a'
s:
mov es:[160 * 12+40 * 2], ah
inc ah
cmp ah, 'z'
jna s
mov ax, 4c00h
int 21h
code ends
end start
这段代码逻辑上是正确的,但运行时将几乎看不到字母变化,只能看到最后一个字母 z。原因是 CPU 速度太快,一个字母刚显示马上就被下一个字母覆盖了,人眼的视觉暂留效应完全跟不上 CPU 的节奏。

因此可以让 CPU 空转一段时间,降低其刷新速度。鉴于现代处理器的运算速度远高于 8086 原始主频,使用 16 位寄存器构建的循环最大迭代次数为 65535,延时过短无法满足需求。因此需要采用 DX 和 AX寄存器组合构成一个 32 位的迭代计数器,总循环次数设定为 10000000h(具体数值可根据宿主机器性能调整)。
修改后的代码如下,循环体中用 sub ax,1 和带借位减法 sbb dx,0 令 32 位减 1,再通过 cmp + jne 分别判断 AX 和 DX 是否为 0。只要有一个不为 0 就持续循环,直到 DX:AX 整体减到 0 才结束,本质上是执行了 \(2^{32} \times 1000h\) 次空耗 CPU 周期的延时循环。
assume cs:code
stack segment
db 128 dup (0)
stack ends
code segment
start:
mov ax, stack
mov ss, ax
mov sp, 128
mov ax, 0b800h
mov es, ax
mov ah, 'a'
s:
mov es:[160 * 12+40 * 2], ah
call delay
inc ah
cmp ah, 'z'
jna s
mov ax, 4c00h
int 21h
delay:
push ax
push dx
mov dx, 1000h
mov ax, 0
s1:
sub ax, 1
sbb dx, 0
cmp ax, 0
jne s1
cmp dx, 0
jne s1
pop dx
pop ax
ret
code ends
end start
运行该程序,每个字母显示后 CPU 都会空转循环,给人眼足够的时间看清当前字母再切换到下一个。

中断接管与调用
接着实现在显示过程中按下 Esc 键时,改变字符颜色。新中断例程的功能主要包括以下 3 个步骤:
- 从 60h 端口读取键盘扫描码;
- 调用 BIOS 原 int 9 中断例程处理硬件细节,如扫描码翻译、状态字节更新;
- 判断读取的扫描码是否为 Esc 键的断码,若是则修改显存属性以改变颜色。
编写新的 int 9 例程需要先修改中断向量表,将 9 号表项指向自定义的代码。然而一旦修改完成,系统中断向量表指向的是新例程,原 BIOS 例程的入口便无法通过 int 9 指令直接调用。因此必须在修改向量表之前,将原例程的入口地址(段地址与偏移地址)备份至自定义数据区。我们将原例程的偏移地址被保存在 ds:[0],段地址保存在 ds:[2]。程序结束前,必须将这些备份值写回中断向量表,否则 DOS 将无法继续使用键盘功能。
由于无法直接通过 int 9 调用原例程,程序需要手动模拟 CPU 执行 int 指令的过程。int 指令的操作包括:取中断类型码、标志寄存器入栈、清除 IF 与 TF、CS 与 IP 入栈、跳转至中断向量表。本程序的中断类型码已无需获取,且清除 IF/TF 的工作在进入中断时已由硬件自动完成。因此过程将简化为两步:
- 标志寄存器入栈:使用
pushf指令保存当前标志寄存器状态。 - 远调用原例程:使用
call dword ptr ds:[0]指令,该指令的功能是将当前 CS 和 IP 入栈,并跳转至ds:[0]保存的入口地址。
读取扫描码后,新例程需判断是否为 Esc 键,该按键的通码为 01h、断码为 81h。对于按键释放动作需要判断 al 是否等于 81h,若是则修改显存属性。综上所述,编写的程序如下,首先备份原 int 9 向量,随后在关中断状态下安装新例程,接着执行主循环显示字母,最后恢复原向量并退出。程序利用中断向量表备份与恢复机制,通过 cli/sti 保护临界区,使用 pushf/call 模拟 int 指令调用 BIOS 原例程,并在新例程中检测 Esc 断码修改显存属性。
assume cs:code, ds:data, ss:stack
stack segment
db 128 dup (0)
stack ends
data segment
dw 0, 0 ; 保存原 int 9 中断向量
data ends
code segment
start:
; 初始化栈与数据段
mov ax, stack
mov ss, ax
mov sp, 128
mov ax, data
mov ds, ax
; 备份原 int 9 中断向量
mov ax, 0
mov es, ax
push es:[9*4]
pop ds:[0]
push es:[9*4+2]
pop ds:[2]
; 安装新的 int 9 中断例程(临界区保护)
cli
mov word ptr es:[9*4], offset int9
mov word ptr es:[9*4+2], cs
sti
; 主循环:显示 a~z
mov ax, 0b800h
mov es, ax
mov ah, 'a'
s:
mov es:[160*12+40*2], ah
call delay
inc ah
cmp ah, 'z'
jna s
; 恢复原 int 9 中断向量(临界区保护)
cli
push ds:[0]
pop es:[9*4]
push ds:[2]
pop es:[9*4+2]
sti
mov ax, 4c00h
int 21h
; 延时子程序:32 位空循环
delay:
push ax
push dx
mov dx, 1000h
mov ax, 0
s1:
sub ax, 1
sbb dx, 0
cmp ax, 0
jne s1
cmp dx, 0
jne s1
pop dx
pop ax
ret
; 新的 int 9 中断例程
int9:
push ax
push bx
push es
in al, 60h ; 读取扫描码
; 模拟 int 指令调用原 BIOS int 9 例程
pushf
call dword ptr ds:[0]
; 判断是否为 Esc 断码 (81h)
cmp al, 81h
jne int9ret
; 修改显示颜色
mov ax, 0b800h
mov es, ax
inc byte ptr es:[160*12+40*2+1]
int9ret:
pop es
pop bx
pop ax
iret
code ends
end start
程序运行时字母以肉眼可辨的速度依次显示,按下并释放 Esc 键时字符颜色随之改变,程序退出后键盘功能恢复正常。


尽管现代操作系统已不再允许用户程序直接修改中断向量表,USB 键盘取代了 PS/2 接口的 8042 控制器,中断向量备份后挂钩、恢复依然是内核安全与驱动开发的核心概念,Windows 中的 IAT Hook、SSDT Hook 以及 Linux 中的 kprobe,本质上都是这种机制的复杂化与规范化延伸。
安装新的例程
现在考虑安装一个新的 int 9 中断例程,实现当按下 F1 键时改变当前屏幕的显示颜色,其他按键则维持原有行为不变。同样需要先将新的中断例程安装至系统预留的常驻内存区域,然后在修改中断向量表之前备份原 int 9 中断例程 的入口地址。在新例程中先调用原例程处理标准键盘逻辑,再判断当前按键是否为 F1,若是则执行屏幕变色操作。
在 80×25 彩色字符模式下,要改变整个屏幕的显示颜色需遍历显存中所有的属性字节并修改其值。由于属性字节位于偶地址字符字节之后,其偏移地址均为奇数(1, 3, 5...)。程序令 BX 寄存器初始化为 1,需要处理 25×80 个字符因此用 CX 计数 2000 次,每次循环递增 2。安装程序负责将 int9 例程从代码段复制到 0:204H 处,使用 rep movsb 完成内存复制。随后通过 cli/sti 保护临界区,将中断向量表中的 9 号表项修改为 0:204H。
实现的代码如下,在新例程内部调用原 int 9 例程时,使用的是 call dword ptr cs:[200h]。这是因为当中断发生时,CS 已指向 0:204H 所在的段(即 0000H),利用 cs: 段前缀可以直接访问之前保存在 200H 处的原向量,无需依赖额外的数据段寄存器。
assume cs:code
stack segment
db 128 dup (0)
stack ends
code segment
start:
; 初始化栈
mov ax, stack
mov ss, ax
mov sp, 128
; 设置源地址 (DS:SI) 和目的地址 (ES:DI)
push cs
pop ds
mov ax, 0
mov es, ax
mov si, offset int9
mov di, 204h
mov cx, offset int9end - offset int9
cld
rep movsb
; 备份原 int 9 向量至 0:200H
push es:[9*4]
pop es:[200h]
push es:[9*4+2]
pop es:[202h]
; 安装新 int 9 向量 ,同时临界区保护
cli
mov word ptr es:[9*4], 204h
mov word ptr es:[9*4+2], 0
sti
; 程序返回 DOS
mov ax, 4c00h
int 21h
; 新的 int 9 中断例程
int9:
push ax
push bx
push cx
push es
in al, 60h ; 读取键盘扫描码
; 使用 CS 段前缀调用原 BIOS int 9 例程
pushf
call dword ptr cs:[200h]
; 判断是否为 F1 键,扫描码 3BH
cmp al, 3bh
jne int9ret
; 遍历所有属性字节改变屏幕颜色
mov ax, 0b800h
mov es, ax
mov bx, 1 ; 属性字节从奇地址开始
mov cx, 2000 ; 25 * 80 = 2000 个字符
s:
inc byte ptr es:[bx]
add bx, 2
loop s
int9ret:
pop es
pop cx
pop bx
pop ax
iret
int9end:
nop
code ends
end start
程序驻留后,按 F1 键屏幕颜色将即时反转,同时不影响其他键盘输入功能。程序退出后,由于修改了中断向量表且未恢复,后续 DOS 会话将使用新的键盘处理逻辑。


实验 15
实验要求是安装一个新的 int 9 中断例程以扩展键盘处理功能,当按下 A 键且不松开时屏幕保持原样;松开 A 键立即令全屏显示字符 “A”。其他按键的处理逻辑则交由 BIOS 原 int 9 例程负责,不受影响。思路仍然是先将自定义的中断例程安装到系统预留的常驻内存区(0:204H),随后备份原中断向量,最后修改中断向量表完成接管。断码 = 通码 + 80H,A 键的通码为 1EH,因此其断码为 9EH。
安装程序首先初始化栈,随后利用 rep movsb 将 int9 代码段复制到 0:204H 处。接着程序将原 int 9 例程的入口地址,即 ES:[94] 和 ES:[94+2] 备份到 0:200H 和 0:202H 处。最后在 cli/sti 保护下的临界区中,将新的中断向量指向 0:204H。新例程首先保存寄存器的的状态,随后从 60H 端口读取扫描码。通过 pushf 和 call dword ptr cs:[200h] 模拟 int 指令,调用 BIOS 原 int 9 例程,确保键盘的 Shift、Ctrl 等控制键状态能被正确更新,且其他按键的扫描码能被系统正常处理。
接着比较 AL 中的扫描码与 A 键的断码 9EH,如果不相等则直接跳转到恢复现场并返回。若确认是 A 键松开,程序将 ES 指向显存段 B800H,BX 初始化为 0(字符字节的起始偏移),CX 设置为 2000(屏幕上字符的总数)。在循环中,程序将字符 “A”(ASCII 码 41H)写入当前 BX 指向的显存位置,随后 BX 增加 2 以指向下一个字符位置,直至循环结束。
assume cs:code
stack segment
db 128 dup (0)
stack ends
code segment
start:
; 初始化栈
mov ax, stack
mov ss, ax
mov sp, 128
; 设置源地址 (DS:SI) 和目的地址 (ES:DI)
push cs
pop ds
mov ax, 0
mov es, ax
mov si, offset int9
mov di, 204h
mov cx, offset int9end - offset int9
cld
rep movsb
; 备份原 int 9 向量至 0:200H
push es:[9*4]
pop es:[200h]
push es:[9*4+2]
pop es:[202h]
; 安装新 int 9 向量 (临界区保护)
cli
mov word ptr es:[9*4], 204h
mov word ptr es:[9*4+2], 0
sti
; 程序返回 DOS
mov ax, 4c00h
int 21h
; 新的 int 9 中断例程
int9:
push ax
push bx
push cx
push es
in al, 60h ; 读取键盘扫描码
; 调用原 BIOS int 9 例程 (使用 CS 段前缀)
pushf
call dword ptr cs:[200h]
; 判断是否为 A 键的断码 (9EH)
cmp al, 9eh
jne int9ret
; 遍历所有字符字节显示“A”
mov ax, 0b800h
mov es, ax
mov bx, 0 ; 字符字节从偶地址开始
mov cx, 2000 ; 25 * 80 = 2000 个字符
s:
mov byte ptr es:[bx], 041H
add bx, 2
loop s
int9ret:
pop es
pop cx
pop bx
pop ax
iret
int9end:
nop
code ends
end start
程序加载运行后,键盘输入行为基本不变。当按下 A 键时,屏幕无特殊反应;一旦松开 A 键,屏幕上的所有字符位置会瞬间被字符 “A” 填满。由于程序退出时未恢复中断向量表,后续 DOS 会话中,松开 A 键依然会触发全屏显示效果。

参考资料
《汇编语言(第四版)》,王爽 编著,清华大学出版社

浙公网安备 33010602011771号