千哥读书笔记:汇编语言(王爽第四版)第15章15.4 编写int 9 中断例程
这一章的知识难度并不算高。然而,在这些细节中仍有许多内容未能深入讲解。或者这正是王爽老师著书立说的风格——即通过引导读者自行探索这些未详述的内容来加深印象。
例如本章的15.4节中实现中断例程时,在屏幕上逐一呈现字符a到z的顺序,并且当按下ESC键时会切换颜色以突出显示。
先复习一下本章的知识要点:
一、接口芯片和端口
外设接口芯片具有多个寄存器单元,在其设计架构中CPU将这些寄存器视为外部设备接口进行通信操作(需要复习14章相关内容)。
2、外设的输入不直接送入内存和CPU,而是送入相关的接口芯片的端口中。
外设无法直接接收CPU的输入信号,而这些信号经由端口传输后会通过相关芯片传递至外设
4、CPU输出控制命令的过程是这样的:它首先将该命令送达相关芯片的端口处;随后由这些芯片按照收到的命令去执行对外设的控制操作。
二、外中断信息
当CPU内部存在待处理任务时, 会产生相应的信号并触发相应的流程; 同时, 当外界设备的数据输入到来时, 相应的设备控制器会发送相应的信号到主控单元. CPU能够识别这些数据流的变化并进行相应的处理; 这种现象被定义为主控单元接收并处理这些信号变化的结果, 称为外部干扰.
外部中断又分为两种:
1、可屏蔽中断
外中断可能被CPU选择性地忽略;是否能够屏蔽这些中断则由标志寄存器IF位的状态决定;具体影响则受标志寄存器IF位设置的影响。
1)如果IF = 1,则CPU在执行完当前的指令后会响应可屏蔽中断。
2)如果IF = 0,则CPU不响应可屏蔽中断。
3)可屏蔽中断信息来自于CPU外部,中断类型码是通过数据总线送入CPU的。
而关于内部中断引发的过程,在第12章有这样的描述:
1)取中断类型码
2)标志寄存器入栈,IF=0,TF=0
3)CS、IP入栈
4)(IP)=(n4),(CS)=(n4+2)
外部中断引发的过程大体上与内中断过程一致。然而,在第一步的实现上存在差异。具体来说,在进入中断处理程序后,则必须阻止所有可屏蔽的中断以确保系统的稳定性
有了以上的知识点,我们可以画一个可屏蔽中断的流程图来帮助理解:

2、不可屏蔽中断
这种中断是CPU必须处理的一种外设引发的中断,在8086CPU中无法屏蔽的不可 Recovery 的中断类型码固定设置为2,在这种情况下,在相应的中断流程中不需要取中间断点处的类型码值;其中间断点处的操作流水线状态会发生相应的变化:Int_{INTERRUPTS}^{INTERRUPT}\text{的过程}。
1)标志寄存器入栈,IF=0,TF=0
2)CS、IP入栈
3)(IP)=(8),(CS)=(0AH)
无法忽略的中断是在系统中当必须处理的紧急情况发生时用来传递CPU的中断信息
三、PC机键盘对键盘的处理过程
1、键盘中有一个芯片对键盘上的每个键的开关状态进行扫描。
当按下键盘按钮时,在系统中会生成一个称为"通码"的扫描码,并通过相关接口芯片将该扫描码传输至其内部寄存器中。该寄存器的端口地址设置为60h。
3、松开一个键时,产生一个叫做“断码”的扫描码,断码也被送入60h端口中。
4、扫描码长度为一个字节,通码的第7位为0,断码的第7位为1,即:断码= 通码+80h


表15.1是键盘上部分键的扫描码,只列出了通码,断码=通码+80h
四、9号中断的引发过程
当键盘输入触发60h端口时,相应的芯片将被发送带有中断类型码9的可屏蔽中断信息
2、如果IF=1,则响应中断,引发中断过程,转去执行int 9中断例程。
3、BIOS提供了int 9中断例程,主要工作如下:
1)读出60h端口中的扫描码。
此操作借助in指令实现,并且仅能将数据存于al寄存器中。鉴于此之前所述,扫描码长度为一个字节(当访问8位端口时使用al)。针对如何从端口中获取数据,在原书中第266页有详细说明(该处可选使用al或ax寄存器来存储来自端部的数据)。
如果涉及字符键的扫描代码,则会将其与相应的ASCII值一起存入系统启动时生成的一个特定区域中。这个区域名为BIOS键盘缓存,在首次开机时由BIOS程序接收并暂存用户的输入信号。这个缓存空间最多可容纳15种不同的按键反馈信息。其中每个按键状态会被分配到一个单独的存储单元中——即单个汉字单元——其中高位部分保存的是按键触发的具体编码信息而低位部分则记录相应的字符编码(如ASCII值)。
- 当遇到需要处理的控制键(如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:[16012+402],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:[16012+402+1] ;将属性值加1,改变颜色
int9ret: pop es
pop bx
pop ax
iret
code ends
end start
====================
这个示例程序的主要设计思路是:
1、在屏幕中间依次显示“a”~“z”。
在进行对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次”是怎么算出来的?
在《原书》第276页中存在一段文字描述:鉴于现代CPU运行速度极快,在实际应用中为了提高程序效率必须确保循环次数足够大,并且可以通过使用两个16位寄存器来存储所需的32位循环计数。其中具体的实现代码如下:
sub ax,1
sbb dx,0
那么如何利用sub和sbb指令实现了32位循环次数的问题?解决了第二个问题则第一个自然迎刃而解但书中并未给出详细的说明或解答显而易见地看过去似乎如此在王爽老师的著作中似乎设下一个让人需要自行探索的陷阱
我们先来看delay中的这段代码:
====================
mov dx,1000h
mov ax,0
s1:sub ax,1
sbb dx,0
cmp ax,0
jne s1
cmp dx,0
jne s1
====================
首先,在汇编语言中,变量dx被赋值为1000h(十六进制),变量ax被赋值为0。当执行sub ax, 1这条指令时,在完成这一操作后ax会从0变为FFFF(即-1的补码表示)。这一操作导致flags寄存器中的CF位被设置为1。
因此,在最外层循环(sub ax, 1 至第一个jne s1)中,对ax执行的减法操作实际上等于十六进制中的FFFF加一等于十进制的一万零一十六。
3、执行减法操作于dx寄存器,并参照原书第222页的说明计算其值为(dx)减去CF的结果。这导致在执行第一次第二层循环时(从sub ax,1开始至第二个jne s1条件满足),dx的结果等于1000h减去零再减一得到FFh。
在执行第二次第一层循环(sub ax,1至第一个jne s1)时至该循环结束期间,在每次执行sub ax,1指令的过程中均未发生无借位事件因而导致CF寄存器在此操作后被置零直至后续的第一层循环开始前ax寄存器再次清零之前其CF值始终保持为零状态。因此在进入第二层循环时dx等于FFFF减去两个零的结果即FFFF-0-0=FFFF。运行完毕后由于dx不等于零系统将返回至第一层循环(sub ax,1至第一个jne s1)进行操作由此最终导致了第一次第一层循环共计被调用了一千次的情况发生。
5、每当执行第一层循环时(通过sub ax,1指令直至满足jne s1条件),ax值在每次自减操作后会归零。在此阶段的计算过程中(执行sub ax,1指令时),CF位会设置借位标志。随后,在第二层循环中对dx进行减一操作。例如,在第二次循环中进行一次减一操作后得到的结果为:dx = FFF - 0 - 1 = FFE。
6、因为第一层循环与第二层循环相互嵌套导致,在每次对ax进行反复操作FFFF+1次的同时,在每次对dx也进行反复操作万次。这样一来,在整个运算过程中总共进行了(FFFF+1) × 万 = 十万零百千万次运算。
关于这个原理,可以用debug来观察一下ax与dx的变化信息。总结一下:
该程序设计方法主要利用了子指令机制以及多级计数策略。其核心原理在于通过结合sub与sbb指令,在内存中预留两个16位寄存器空间来承载一个完整的32位数值信息。具体来说,在内存布局中采用高位优先的方式进行数据存储:其中高位由一个16位寄存器持有(代表第二层数值),而低位则由另一个16位寄存器持有(代表第一层数值)。
说到如此关键的细节时
接下来,看第二步和第三步:
重新配置int 9中断例程以根据个人设置,在按动Esc键时调整字符显示颜色。
3、在重新编写的int 9中断例程中,调用BIOS原有的int 9中断例程。
其程序设计的思想是:
用重新设计过的int_9中断例程替代BIOS当前存在的int_9中断例程,在功能上将实现字符颜色的更改(带有一定的技术难度)。
在重新设计后的int 9中断例程中调用BIOS原存的int 9中断例程以承担其余的硬件细节即不得更改其运行逻辑否则将导致系统及其他程序出现故障
对于第一条内容来说,在开始之前首先要将原有的int16_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中断例程入口地址时,我们采用了offset指令计算新的int 9段的偏移地址,并结合cs寄令获取相应的段地址信息。最后使用mov指令将新内存区域的内容复制至es:[94]和es:[94+2]位置。
而重新设计的int 9中断例程,书中提到,是不能用 int 指令来调用的。
千哥的观点认为,在int指令内部运行机制上可能存在关联,并推测可能是固定了原有的中断例程入口地址,并已将其注册进行了更改。如果使用int指令来调用该中断程序可能会导致错误。当然这只是一种推测而已
基于这个思路,需要用另一组指令来模拟int指令的行为。经过对CPU执行int指令的行为进行分析研究, 其原理主要包含五个方面的基本步骤。
1、取中断类型码n
2、标志寄存器入栈
3、IF=0,TF=0
4、CS、IP入栈
5、(IP)=(n4),(CS)=(n4+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个字来存储目标段地址和偏移量信息。
而'jumpt dword ptr base'指令的作用是从目标内存起始点存储两个字,并将它们分别存放在高位存储的位置和低位存储的位置。其中,在高位存储的位置(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
====================
首先将标志寄存器压入栈中接着从栈顶弹出并赋值给bx寄存器之后使用and操作使得bh寄存器的第7和8位被置零随后再次压入bx寄存器到栈中并再次从栈顶弹出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:[16012+402+1] ;将属性值加1,改变颜色
int9ret: pop es
pop bx
pop ax
iret
====================
在最初的时候进行push ax、push bx、push es的操作,其目的是为了记录当前系统的运行状态.这一操作的重要性无需多言.
接下来的in al,60h,就是从端口60h处读出键盘的输入信息。
接着调用现有的int 9中断功能模块。具体实现过程是从第一个pushf指令开始到call dword ptr ds:[0]指令结束。从而实现了原有中断机制的工作流程
换句话说,在原来的情况下(即int为9时),程序会按照既定流程运行而不做额外修改;至于后续的功能扩展,则由后续的代码决定。
具体做了什么?当按下ESC这个键时,会使得字符的颜色发生变化。
====================
cmp al,1
jne int9ret
mov ax,0b800h
mov es,ax
inc byte ptr es:[16012+402+1] ;将属性值加1,改变颜色
int9ret: pop es
pop bx
pop ax
iret
====================
之前提到,在 al, 60h 处捕获键盘输入。这里通过使用 cmp 和 jne 指令来进行比较操作:首先检查 al 是否等于 1 ;如果结果不等于 1 (即非 ESC 键),则会跳转至 int9ret 标签所指代的代码段,并完成相关操作;最后通过 iret 指令实现对 call 指令的返回。
如果al为1,就在0b800h显示缓冲区的[16012+402+1],将字符属性值加1,从而改变颜色。
这里需要说明的是,在完成原有int 9中断例程的调用以及处理其他硬件信息后,在完成原有int 9中断例程的调用以及处理其他硬件信息后, 调用指令与返回指令通常是配对使用的。在完成原有int 9中断例程的调用以及处理其他硬件信息后, 必须通过返回指令来退出。
至此,15.4 编写int 9 中断例程的例子分析完毕。
千哥的感受是说:魔鬼就在细微之处。王爽老师的著作里这类内容读起来起初令人不适应,在反复推敲的过程中却又别有一番趣味。
