以前我们讨论的都是 CPU 对指令的执行。我们知道,CPU 在计算机系统中,除了能够执行指令,进行运算外,还应该对外部设备进行控制,接收它们的输入,向他们进行输出。也就是说,CPU 除了有运算能力外,还要有 I/O(Input/Output,输入/输出)能力。比如,我们按下键盘上的一个键,CPU 最终能够处理这个键。在使用文本编辑器时,按下 a 键后,我们可以看到屏幕上出现 “a”,是 CPU 将从键盘上输入的键所对应的字符送到显示器上的。
要及时处理外设的输入,需要解决两个问题:
- 外设的输入随时可以发生,CPU 如何得知?
- CPU 从何处得到外设的输入?
现在我们以键盘输入为例,讨论这个问题。
15.1 接口芯片和端口
14 章中讲过,PC 系统的接口卡和主板上,装有各种接口芯片。这些外设接口芯片内部有若干寄存器,CPU 将这些寄存器当作端口访问。
外设的输入不直接送入内存和 CPU,而是送入相关的接口芯片的端口中;CPU 向外设的输出也不是直接送入外设,而是先送入端口中,再由相关的芯片送到外设。CPU 还可以向外设输出控制命令,而这些控制命令也是先送到相关芯片的端口中,然后再由相关的芯片根据命令对外设实施控制。
CPU 通过端口和外部设备进行联系。
15.2 外中断信息
现在,我们知道了外设的输入被存放在端口中,可是外设的输入输出随时都有可能到达,CPU 如何及时地知道,并进行处理呢?更一般地讲,就是外设随时都可能发生需要 CPU 及时处理的事件,CPU 如何及时得知并进行处理?
CPU 提供中断机制来满足这种需要。前面说过,当 CPU 的内部有需要处理的事情发生的时候,将产生中断信息,引发中断过程。这种中断信息来自 CPU 的内部。
还有一种中断信息,来自 CPU 外部,当 CPU 外部有需要处理的事情发生的时候,比如说,外设的输入到达,相关芯片将向 CPU 发出相应的中断信息。CPU 在执行完当前指令后,可以检测到发送过来中断信息,引发中断过程,处理外设的输入。
在 PC 系统中,外中断源一共有以下两类。
可屏蔽中断
可屏蔽中断是 CPU 可以不响应的外中断。CPU 是否响应可屏蔽中断,要看标志寄存器的 IF 位的设置。当 CPU 检测到可屏蔽中断信息时,如果 IF=1,则 CPU 在执行完当前指令后响应中断,引发中断过程;如果 IF=0,则不响应可屏蔽中断。
我们回忆一下内中断所引发的中断过程:
- 取中断类型码 n;
- 标志寄存器入栈,IF=0,TF=0;
- CS、IP 入栈;
- (IP)=(n4),(CS)=(n4+2)
由此转去执行中断处理程序。
可屏蔽中断所引发的中断过程,除在第 1 步的实现上有所不同外,基本上和内中断的中断过程相同。因为可屏蔽中断信息来自 CPU 外部,中断类型码是通过数据总线送入 CPU 的;而内中断的终端类型码是在 CPU 内部产生的。
现在,我们可以解释中断过程中将 IF 设置为 0 的原因了。将 IF 设置为 0 的原因就是,在进入中断处理程序后,禁止其他的可屏蔽中断。
当然,如果在中断处理程序中需要处理可屏蔽中断,可以用指令将 IF 设置为1.8086 CPU 提供的设置 IF 的指令如下:
sti
:设置 IF = 1;cli
:设置 IF = 0。
不可屏蔽中断
不可屏蔽中断是 CPU 必须响应的中断。当 CPU 检测到不可屏蔽中断信息时,则在执行完当前指令后,立即响应,引发中断过程。
对于 8086CPU,不可屏蔽中断的中断类型码固定为 2,所以中断过程中,不需要取中断类型码。则不可屏蔽中断的中断过程为:
- 标志寄存器入栈,IF=0,TF=0;
- CS、IP 入栈;
- (IP)=(8),(CS)=(0AH)
几乎所有由外设引发的外中断,都是可屏蔽中断。当外设有需要处理的事件(比如说键盘输入)发生时,相关芯片向 CPU 发出可屏蔽中断信息。不可屏蔽中断是系统中有必须处理的紧急情况发生时用来通知 CPU 的中断信息。在我们的课程中,主要讨论可屏蔽中断。
15.3 PC机键盘的处理过程
下面我们看一下键盘输入的处理过程,以此来体会一下 PC 机处理外设输入的基本方法。
1.键盘输入
键盘上的每一个键相当于一个开关,键盘上有一个芯片对键盘上的每一个键的开关状态进行扫描。
按下一个键时,开关接通,该芯片就产生一个扫描码,扫描码说明了按下的键在键盘上的位置。扫描码被送入主板上的相关接口芯片的寄存器中,该寄存器的端口地址为 60h。
松开按下的键时,也产生一个扫描码。扫描码说明了松开的键在键盘上的位置,松开按键时产生的扫描码也被送入 60h 端口中。
一般将按下一个键时产生的扫描码被称为通码,松开一个键产生的扫描码被称为断码。扫描长度为一个字节,通码的第 7 位为 0,断码的第 7 位为1,即
比如,g 键的通码为 22h,断码为 a2h。
下表为键盘上部分键的扫描码,只列出通码。断码=通码+80h。
2.引发9号中断
键盘的输入到达 60h 端口时,相关的芯片就会向 CPU 发出中断类型码为 9 的可屏蔽中断信息。CPU 检测到该中断信息后,如果 IF=1,则响应中断,引发中断过程,转去执行 int 9
中断例程。
3.执行 int9 中断例程
BIOS 提供了 int 9
中断例程,用来进行基本的键盘输入处理,主要的工作如下:
读出 60h 端口中的扫描码;
对于不同的扫描码有不同的处理方式
- 如果是字符键的扫描码,将该扫描码和它所对应的字符码(即 ASCII 码)送入内存的 BIOS 键盘缓冲区;
- 如果是控制键(比如 Ctrl)和切换键(比如 CapsLock)的扫描码,则将其转变为状态字节(用二进制位记录控制键和切换键状态的字节)写入内存中存储状态字节的的单元
对键盘系统进行相关的控制,比如说,向相关芯片发出应答信息。
BIOS 键盘缓冲区是系统启动后,BIOS 用于存放 int 9
中断例程所接收的键盘输入的内存区。该内存区可以存储 15 个键盘输入,因为 int 9
中断例程除了接受扫描码以外,还要产生和扫描码所对应的字符码,所以在 BIOS 键盘缓冲区中,一个键盘输入用一个字单元存放,高位字节存放扫描码,低位字节存放字符码。
0040:17 单元存储键盘状态字节,该字节记录了控制键和切换键的状态。键盘状态字节各位记录的信息如下:
- 0:右 shift 状态,置 1 表示按下右 shift 键;
- 1:左 shift 状态,置 1 表示按下左 shift 键;
- 2:Ctrl 状态,置 1 表示按下 Ctrl 键;
- 3:Alt 状态,置 1 表示按下 Alt 键;
- 4:ScrollLock 状态,置 1 表示 Scroll 指示灯亮;
- 5:NumLock 状态,置 1 表示小键盘输入的是数字;
- 6:CapsLock 状态,置 1 表示输入大写字母;
- 7:Insert状态,置 1 表示处于删除状态。
15.4 编写 int 9 中断例程
从上面的内容中,可以看出键盘输入的处理过程:
上面的过程中,第 1、2、3 步都是由硬件系统完成的。我们能够改变的只有 int 9
中断处理程序。我们可以重写编写 int 9
中断例程,按照自己的意图来处理键盘的输入。但是,在课程中,我们不准备完整地编写一个键盘中断的处理程序,因为要设计一些硬件细节。
但是,我们却还要编写新的键盘中断处理程序,来进行一些特殊的工作,那么这些细节如何处理呢?这点比较简单,因为 BIOS 提供的 int 9
中断例程已经对这些硬件细节进行了处理。我们只要在自己编写的中断例程中调用 BIOS 的 int 9
中断例程就可以了
编程:在屏幕中间依次显示 “a” ~ “z”,可以让人看清。在显示的过程中,按下 Esc 键后,改变显示的颜色。
我们先来看一下如何依次显示 “a” ~ “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
在上面的程序的执行过程中,我们无法看清屏幕上的显示。因为一个字母刚显示到屏幕上,CPU 执行几条指令后,就又变成了另一个字母,字母之间切换到太快,无法看清。
应该在每显示一个字母后,延时一段时间,让人看清后,再显示下一个字母。那么如何延时呢?我们让 CPU 执行一段时间的空循环。因为现在 CPU 的速度非常快,所以循环的次数一定要大,用两个 16 位寄存器来存放 32 位的循环次数。如下:
mov dx,10h
mov ax,0
sub: sub ax,1
sbb dx,0
cmp ax,0
jne s
cmp dx,0
jne s
上面的程序,循环 100000h 次。我们可以将循环延时的程序段写为一个子程序。
现在,我们的程序如下:
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 ; 循环 10000000h 次,读者可以根据自己机器的速度调整循环次数
mov ax,0
s1: sub ax,1
sbb dx,0
cmp ax,0
jne s1
pop dx
pop ax
ret
code ends
end start
显示 “a” ~ “z”,并可以让人看清,这个任务已经实现。那么如何实现,按下 Esc 键后,改变显示的颜色呢?
键盘输入到达 60h 端口号,就会引发 9 号中断,CPU 则转去执行 int 9
中断例程。我们可以编写 int 9
号中断例程,功能如下:
- 从 60h 端口读入键盘的输入;
- 调用 BIOS 的
int 9
中断例程,处理其他硬件细节; - 判断是否为 Esc 的扫描码,如果是,改变显示的颜色后返回;如果不是则直接返回。
下面对这些功能一一进行分析。
下面对这些功能的实现一一分析。
从端口 60h 读出键盘的输入
in al,60h
调用 BIOS 的 int 9
中断例程
有一点要注意的是,我们写的中断处理程序要成为新的 int 9
中断例程,主程序必须要将中断向量表中的 **int 9**
中断例程的入口地址改为我们写的中断处理程序的入口地址。则在新的中断处理程序中调用原来的 int 9
中断例程时,中断向量表中的 int 9
中断例程的入口地址却不是原来的 int 9
中断例程的地址。所以不能使用 int
指令直接调用。
要在我们写的新中断例程中调用原来的中断例程,就必须在将中断向量表的中断例程的入口地址改为新地址之前,将原来的入口地址保存起来。这样,在需要调用的时候,我们才能找到原来的中断例程的入口。
对于我们现在的问题,假设将原来 int 9
中断例程的偏移地址和段地址保存在 ds:[0] 和 ds:[2] 单元中。那么,我们在需要调用原来的 int 9
中断例程的时候,就可以在 ds:[0]、ds:[2]单元中找到它的入口地址。
那么,有了入口地址后,如何进行调用呢?
当然不能使用指令 int 9
来调用。我们可以用别的指令来对 **int**
指令来进行一些模拟,从而实现对中断例程的调用。
我们来看,int
指令在执行的时候,CPU 进行下面的工作。
- 取中断类型码 n;
- 标志寄存器入栈;
- IF=0、TF=0;
- CS、IP入栈;
- (IP)=(n4),(CS)=(n4+2)。
取中断类型码是为了定位中断例程的入口地址,在我们的问题中,中断例程的入口地址已经知道。所以,我们用别的指令模拟 int
指令的时候,不需要做第 1 步。在假设要调用的中断例程的入口地址在 ds:0 和 ds:2 单元中的前提下,我们将 int
过程用下面几步模拟。
可以注意到第 3、4步和
call dword ptr ds:[0]
的功能一样,call dword ptr ds:[0]
的功能也是
- CS、IP入栈;
- (IP)=((ds)16+0),(CS)=((ds)16+2)。
所以,int
过程的模拟过程为
对于步骤1,标志寄存器入栈可以使用 pushf
实现;
对于步骤2,IF=0, TF=0
可以用下面的指令实现:
; 步骤2:IF=0, TF=0
pushf
pop ax
and ah,11111100b ; IF 和 TF 为标志寄存器的第9位和第8位
push ax
popf
则模拟 int
指令的调用功能,调用入口地址在 ds:0、ds:2 中的中断例程的程序为:
; 步骤1:标志寄存器入栈
pushf ; 标志寄存器入栈
; 步骤2:IF=0, TF=0
pushf
pop ax
and ah,11111100b
push ax
popf ; IF=0, TF=0
;步骤3
call dword ptr ds:[0] ; CS、IP 入栈; (IP)=((ds)*16+0), (CS)=((ds)*16+2)
如果是Esc 的扫描码,改变显示的颜色后返回
如何改变显示的颜色?
显示的位置是屏幕的中间,即第 12 行 40 列,显存中的偏移地址为:16012+402。所以字符的 ASCII 码要送入段地址 b800h,偏移地址 16012+402处。而段地址 b800h,偏移地址 16012+402+1 处是字符的属性,只要改变此处的数据就可以改变在段地址 b800h,偏移地址 16012+402 处显示的字符的颜色了。
该程序的最后一个问题是,要在程序返回前,将中断向量表中的 int 9
中断例程的入口地址恢复为原来的地址。否则在程序返回后,别的程序将无法使用键盘。
经过分析,完整的程序如下。
assume cs:code
stack segment
db 128 dup (0)
stack ends
data segment
dw 0,0
data ends
code segment
start: mov ax,stack
mov ss,ax
mov sp,128
mov ax,data
mov ds,ax
mov ax,0
mov es,ax
push es:[9*4]
pop ds:[0]
push es:[9*4+2]
pop ds:[2] ; 将原来的int 9中断例程的入口地址保存在ds:[0]、ds:[2]单元中
mov word ptr es:[9*4],offset int9
mov es:[9*4+2],cs ; 在中断向量表中设置新的int 9中断例程的入口地址
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,0
mov es,ax
push ds:[0]
pop es:[9*4]
push ds:[2]
pop es:[9*4+2] ; 将中断向量表int 9中断例程入口恢复为原来的地址
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
; --------------- 以下为新的int 9中断例程 ---------------
int9: push ax
push bx
push es
in al,60h
pushf
pushf
pop bx
and bh,11111100b
push bx
popf
call dword ptr ds:[0] ; 对int指令进行模拟,调用原来的int 9中断例程
cmp al,1
jne int9ret
mov ax,0b800h
mov es,ax
inc byte ptr es:[160*12+40*2+1] ; 将属性值加 1,改变颜色
int9ret: pop es
pop bx
pop ax
iret
code ends
end start
运行结果如下所示:
注意:本章中所有关于键盘的程序,因为要直接访问真实的硬件,则必须在 DOS 实模式下运行。在 Windows2000 的 DOS 方式下运行,会出现一些和硬件工作原理不符合的现象。
检测点 15.1
15.5 安装新的 int 9中断例程
下面,我们安装一个新的 int 9
中断例程,使得原 int 9
中断例程的功能得到扩展。
- 任务:安装一个新的
int 9
中断例程 - 功能:在 DOS 下,按 F1 键后改变当前屏幕的显示颜色,其他的键照常处理
我们进行一下分析
改变屏幕的显示颜色
改变从 B8000H 开始的 4000 个字节中的所有奇地址单元中的内容,当前屏幕的显示颜色即发生改变。程序如下
mov ax,0b800h
mov es,ax
mov bx,1
mov cx,2000
s: inc byte ptr es:[bx]
add bx,2
loop s
其他键照常处理
可以调用原 int 9
中断处理程序,来处理其他的键盘输入。
原 int 9
中断例程入口地址的保存
因为在编写的新 int 9
中断例程中要调用原 int 9
中断例程。所以,要保存原 int 9
中断例程的入口地址。保存在哪里?显然不能保存在安装程序中,因为安装程序返回地址将丢失。我们将地址保存在 0:200 单元处。
新 int 9
中断例程的安装
这个问题在前面已经详细讨论过。我们可将新的 int 9
中断例程安装在 0:204 处。
完整的程序如下:
assume cs:code
stack segment
db 128 dup (0)
stack ends
code segment
start: mov ax,stack
mov ss,ax
mov sp,128
push cs
pop ds
mov ax,0
mov es,ax
mov si,offset int9 ; 设置 ds:si 指向源地址
mov di,204h ; 设置 es:di 指向目的地址
mov cx,offset int9end-offset int9 ; 设置 cx 为传输长度
cld ; 设置传输方向为正
rep movsb
push es:[9*4]
pop es:[200h]
push es:[9*4+2]
pop es:[202h]
cli
mov word ptr es:[9*4],204h
mov word ptr es:[9*4+2],0
sti
mov ax,4c00h
int 21h
int9: push ax
push bx
push cx
push es
in al,60h
pushf
call dword ptr cs:[200h] ; 当此中断例程执行时(CS)=0
cmp al,3bh ; F1的扫描码为 3bh
jne int9ret
mov ax,0b800h
mov es,ax
mov bx,1
mov cx,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
这一章中,我们通对过键盘输入的处理,讲解了 CPU 对外设输入的通常处理方法。即:
- 外设的输入输出端口;
- 向 CPU 发送外中断(主要是可屏蔽中断)信息;
- CPU 检测到可屏蔽中断信息,如果 IF=1,则 CPU在执行完当前指令后响应中断,执行相应的中断例程;
- 可在中断例程中实现对外设输入的处理。
端口和中断机制,是 CPU 进行 I/O 的基础。