这一章的难度相对较低,但还是有很多细节没有讲清楚,或者这本身就是王爽老师写书的一种风格,即故意不讲清楚一些细节,而让读者自己去摸索,从而加深学习知识的印象。
比如本章的15.4 编写int 9 中断例程,所举的例子,“在屏幕中间依次显示”a"~"z",在显示的过程中,按下ESC键后,改变显示的颜色”,就有一些细节需要自己去摸索和理解。
先复习一下本章的知识要点:
一、接口芯片和端口
1、外设接口芯片的内部有若干寄存器,CPU将这些寄存器当作端口来访问(需要复习14章内容)。
2、外设的输入不直接送入内存和CPU,而是送入相关的接口芯片的端口中。
3、CPU向外设的输入也不是直接送入外设,而是先送入端口中,再由相关芯片送到外设。
4、CPU向外设输出的控制命令,是先送到相关芯片的端口中,然后再由相关的芯片根据命令对外设实施控制。
二、外中断信息
当CPU的内部有需要处理的事情发生,将产生中断信息,引发中断过程,这种中断信息来自CPU内部;而当外设的输入到达,相关外设的芯片向CPU发出中断信息,CPU可以检测到外设发送过来的中断信息,引发中断过程,处理外设的输入,这就是外部中断。
外部中断又分为两种:
1、可屏蔽中断
这是CPU可以不响应的外中断,CPU是否响应可屏蔽中断,取决于标志寄存器IF位的设置。
1)如果IF = 1,则CPU在执行完当前的指令后会响应可屏蔽中断。
2)如果IF = 0,则CPU不响应可屏蔽中断。
3)可屏蔽中断信息来自于CPU外部,中断类型码是通过数据总线送入CPU的。
而关于内部中断引发的过程,在第12章有这样的描述:
1)取中断类型码
2)标志寄存器入栈,IF=0,TF=0
3)CS、IP入栈
4)(IP)=(n*4),(CS)=(n*4+2)
而外部中断引发的过程,除在第1步的实现上有所不同外,基本上和内中断过程相同。而将IF设置为0的原因,就在于进入中断处理程序后,必须禁止其他的可屏蔽中断。
有了以上的知识点,我们可以画一个可屏蔽中断的流程图来帮助理解:
2、不可屏蔽中断
这种中断是CPU必须响应的外部中断,对于8086CPU,不可屏蔽中断的中断类型码固定为2,所以在中断过程中,不需要取中断类型码,其中断过程为:
1)标志寄存器入栈,IF=0,TF=0
2)CS、IP入栈
3)(IP)=(8),(CS)=(0AH)
不可屏蔽中断是在系统中有必须处理的紧急情况发生时用来通知CPU的中断信息。
三、PC机键盘对键盘的处理过程
1、键盘中有一个芯片对键盘上的每个键的开关状态进行扫描。
2、按下一个键时,产生一个叫做“通码”的扫描码,通码被送入主板上的相关接口芯片的寄存器中,该寄存器的端口地址为60h。
3、松开一个键时,产生一个叫做“断码”的扫描码,断码也被送入60h端口中。
4、扫描码长度为一个字节,通码的第7位为0,断码的第7位为1,即:断码= 通码+80h
表15.1是键盘上部分键的扫描码,只列出了通码,断码=通码+80h
四、9号中断的引发过程
1、当键盘的输入到达60h端口时,相关芯片会向CPU发出中断类型码为9的可屏蔽中断信息。
2、如果IF=1,则响应中断,引发中断过程,转去执行int 9中断例程。
3、BIOS提供了int 9中断例程,主要工作如下:
1)读出60h端口中的扫描码。
这一步,是通过in指令来完成,并且只能用al存放从端口中读入的数据,因为前面提到的,扫描码长度为一个字节(访问8位端口时用al)。关于int指令读取端口数据的操作,在原书中第266页有说明(al或ax来存放从端口读入的数据)。
2)如果是字符键的扫描码,将该扫描码和所对应的ASCII码送入内存中的BIOS键盘缓冲区。BIOS键盘缓冲区是系统启动后,BIOS用于存放int 9中断例程所接收的键盘输入的内存区,该内存区可以存储15个键盘输入。在BIOS键盘缓冲区中,一个键盘输入用一个字单元存放,高位字节存放扫描码,低位字节存放字符码(ASCII码)
3)如果是控制键(比如Ctrl)和切换键(比如CapLock)的扫描码,则将其转化为状态字节,写入内存中存储状态字节的单元。而在0040:17单元,则存储键盘状态字节,记录了控制键和切换键的状态,相关信息如下:
4)对键盘系统进行相关的控制,比如,向相关芯片发出应答信息。
以上流程如下图:
有了以上背景知识,就可以来深入理解本章15.4 编写int 9中断例程
这个例程的要求是:编程,在屏幕中间依次显示“a”~“z”,并可以让人看清。在显示的过程中,按下Esc 键后,改变显示的颜色。
先贴出这个程序第一个版本的源代码,然后逐步分析一些重点需要理解的地方:
====================
assume cs:code
stack segment
db 128 dup(0)
stack ends
data segment
dw 128 dup(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 21
delay:push ax
push dx
mov dx,1000h;循环10000000h次,读者可以根据自己机器的速度调整循环次数
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
====================
这个示例程序的主要设计思路是:
1、在屏幕中间依次显示“a”~“z”。
2、重新编写int 9中断例程,按照自己的意图(即按下Esc 键后,改变字符显示的颜色)来处理键盘的输入。
3、在重新编写的int 9中断例程中,调用BIOS原有的int 9中断例程。
关于第一步的基本原理,原书有详细讲述,本笔记不作阐述。而这一步最难理解的部分,就是以delay为标号的那几段延时代码:
====================
delay:push ax
push dx
mov dx,1000h;循环10000000h次,读者可以根据自己机器的速度调整循环次数
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
====================
问题来了:
1、在代码注释里,“循环10000000h次”是怎么算出来的?
2、在原书276页有一段话:因为现在CPU的速度都很快,所以循环的次数一定要大,用两个16位寄存器来存放32位的循环次数,其中对应的代码是:
sub ax,1
sbb dx,0
那么,通过sub和sbb指令,怎么就实现了32位的循环次数?而回答了第2个问题,第一个问题也就迎刃而解。但书中并没有详细解释。显然,这又是王爽老师挖的一个坑,得让我们自己去填。
我们先来看delay中的这段代码:
====================
mov dx,1000h
mov ax,0
s1:sub ax,1
sbb dx,0
cmp ax,0
jne s1
cmp dx,0
jne s1
====================
1、首先,dx被赋值为1000h,ax被赋值为0。在执行sub ax,1这行代码时,ax 由0减去1,得到FFFF,这是-1的16位(因为ax是16位的)补码,也即二进制1111111111111111;此时,由于0减1产生了借位,所以CF值为1。
2、所以,在第一层循环(sub ax,1至第一个jne s1)中,对ax的减法操作实际上就是FFFF+1 = 10000h次。
3、subb dx,0,根据原书第222页的说明,(dx)=(dx)-0-CF。使得在进行第一次第二层循环(sub ax,1至第二个jne s1)时, dx = 1000h - 0 - 1 = FFF。
4、在从第二次进行第一层循环(sub ax,1至第一个jne s1)开始到该循环结束,由于sub ax,1的操作是FFFF-1,没有产生借位,故CF值在执行这条指令之后为0,直到后续的第一层循环,ax再次为0之前CF值都为0。故在进行第二层循环时,dx = FFF- 0 -0 = FFF。计算结束后,由于dx不等于0,故又将回到第一层循环(sub ax,1至第一个jne s1),由此最终产生了10000h次的第一层循环。
5、在每次第一层循环(sub ax,1至第一个jne s1)结束后,ax在每次自减1的情况下又将归零,CF又在下一步的计算中(sub ax,1)产生借位,CF值又为1。然后将在第二层循环中将dx再次减1,比如:dx = FFF- 0 -1 = FFE
6、由于第一层循环与第二层循环的嵌套结合,对ax的操作循环了10000h次(FFFF+1),对dx的操作循环了1000h次,故总循环次数达到了10000X1000 = 10000000h次。
关于这个原理,可以用debug来观察一下ax与dx的变化信息。总结一下:
这种程序设计思想,就是结合sub和sbb指令,用两个16位寄存器AX、DX来存放32位的循环次数。用DX:AX表示循环次数,即高位是DX(第二层循环),低位是AX(第一层循环)。
而这么重要的细节,王爽老师居然一笔带过,没有20年以上的脑血栓真是无法理解这种设计思想。
接下来,看第二步和第三步:
2、重新编写int 9中断例程,按照自己的意图(即按下Esc 键后,改变字符显示的颜色)来处理键盘的输入。
3、在重新编写的int 9中断例程中,调用BIOS原有的int 9中断例程。
其程序设计的思想是:
1、用重新设计的int 9中断例程来代替BIOS原有的 int9中断例程,也就是需要增加改变字符显示的颜色的功能(这就有一点hacking的味道了)。
2、在重新设计的int 9中断例程中,调用BIOS原有的int 9中断例程,从而处理其他的硬件细节。也就是说,原有的int 9的中断例程的运作逻辑不能改变,否则将影响系统和其他程序的正常运行。
对于第一点,首先就要保存原来的int 9中断例程的入口地址,然后将新的int 9中断例程的地址注册到原int 9中断例程的入口中,最后在程序结束时,还要将原来的int 9中断例程的入口地址进行恢复。于是,这就有了以下几段代码:
====================
;保存原来的int 9中断例程的入口地址
push es:[9*4]
pop ds:[0]
push es:[9*4+2]
pop ds:[2];将原来的int 9中断例程的入口地址保存在ds:0、ds:2单元中
;将新的int 9中断例程的地址注册到原int 9中断例程的入口中
mov word ptr es:[9*4],offset int9
mov es:[9*4+2],cs ;在中断向量表中设置新的int 9中断例程的入口地址
…………;中间代码省略,将原来的int 9中断例程的入口地址进行恢复
push ds:[0]
pop es:[9*4]
push ds:[2]
pop es:[9*4+2];将中断向量表中int 9中断例程的入口恢复为原来的地址
====================
这几段代码很容易理解,大体上是通过压栈和出栈的方式,改变新旧int 9中断例程的入口注册。只是在将新的int 9中断例程的地址注册到原int 9中断例程的入口中的时候,用到了offset 来获得新的int 9的偏移地址,并且通过cs获得段地址,然后通过mov 指令将新地址的内容复制到es:[9*4]、es:[9*4+2]两个单元。
而重新设计的int 9中断例程,书中提到,是不能用 int 指令来调用的。
千哥的理解是,这可能和int指令的内部运作机制有关,有可能是绑定了原来的中断例程入口地址,现在已将地址注册进行了改变,用int命令调用中断例程的话会产生错误。当然,这只是猜想,毕竟有些隐藏的机制我们是不清楚的。
按照这个思路,就要用别的指令来对int指令进行模拟。根据对CPU执行int指令的分析,其原理分为五个步骤:
1、取中断类型码n
2、标志寄存器入栈
3、IF=0,TF=0
4、CS、IP入栈
5、(IP)=(n*4),(CS)=(n*4+2)
以上步骤,取中断类型码的目的,是为了定位中断例程的入口地址,而现在入口地址已经知道,就是改变后的ds:[0]、ds:[2]两个单元,所以用别的指令模拟int 指令时,不需要做第一步。也就是:
1、标志寄存器入栈
2、IF=0,TF=0
3、CS、IP入栈
4、(IP)=((ds)*16+0),(CS)=((ds)*16+2)
而改进后的步骤的第3、4步,其功能和call dword ptr ds:[0]的功能一样,也是:
1、CS、IP入栈
2、(IP)=((ds)*16+0),(CS)=((ds)*16+2)
这里又涉及对call指令的复习(原书10.6节内容)。
对于call dword ptr 内存单元地址
相当于进行:
push CS
push IP
jmp dword ptr 内存单元地址
由于有“dword”这个关键词 ,会将4个内存单元地址(每个单元1个字节),也就是2个字来保存转移的目的段地址和转移的目的偏移地址。
而“jmp dword ptr 内存单元地址”指令,就是从内存单元地址开始存放2个字,高地址处的字(16位)是转移的目的段地址,低地址处(16位)是转移的目的偏移地址,然后实施目的地址的跳转。
所以,模拟int指令的过程,就变为了:
1、标志寄存器入栈
2、IF=0,TF=0
3、call dword ptr ds:[0]
对于第二步,也就是IF=0,TF=0,用的是下面的指令来实现:
====================
pushf
pop bx
and bh,11111100b
push bx
popf
====================
意思是:先是将标志寄存器入栈,然后用pop弹出,赋值给bx,然后通过and,将bh的第7位和第8位设置为0,然后再将bx入栈,再用popf对标志寄存器重新设置,从而实现IF=0,TF=0。
需注意的是,这里的标志寄存器入栈(pushf),和前面的第一步,标志寄存器入栈不是一回事。这里的pushf,是为了改变bx这个中间变量的值。
所以,调用原有的int 9中断例程的完整代码就是:
====================
pushf
pushf
pop bx
and bh,11111100b
push bx
popf
call dword ptr ds:[0];对int 指令进行模拟,调用原来的int 9中断例程
====================
最后,进行对新的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
====================
一开始的push ax、 push bx 、push es,是为了保存原有的现场信息,这个不用多说。
接下来的in al,60h,就是从端口60h处读出键盘的输入信息。
然后就是调用原有的int 9中断例程,即从第1个pushf直到call dword ptr ds:[0]。这段代码也就模拟了原有int 9中断例程,从而实现不改变原有的int 9的中断例程运作逻辑。
也就是说,原来的int 9 该干什么就继续干什么,至于后面要加什么新的功能,由后面的代码说了算。
那后面的代码又干了什么事呢?就是在接收到ESC这个键的信号之后,让字符改变颜色。
====================
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
====================
前面提到,in al,60h,就是从端口60h处读出键盘的输入。这里用cmp 和jne指令进行比较,看看al是否为1,如果不为1(即不是ESC键),就跳转到int9ret标号的代码段,然后进行现场信息恢复,最后用iret指令,来对call指令进行返回。
如果al为1,就在0b800h显示缓冲区的[160*12+40*2+1],将字符属性值加1,从而改变颜色。
这里需要说明的是,call指令和iret指令是成对出现的,当用call指令完成对原有int 9中断例程的调用,并且处理完其他的硬件信息之后,必须用iret指令进行返回。
至此,15.4 编写int 9 中断例程的例子分析完毕。
千哥的体会是,魔鬼真的就是在细节之中。王爽老师写的书,有此内容就如同嚼毛牛肉干,一开始很难以下口,但慢慢咀嚼却又风味无穷。