任何一个通用的CPU都具备一种能力,可以在执行完当前正在执行的指令之后,检测到从CPU外部发送过来的或内部产生的一种特殊信息(中断信息),并且可以立即对所接收到的信息进行处理。

§ 第12章 内中断 - 图1

中断信息可以来自CPU内部和外部,这里讨论的是来自CPU内部的中断信息,即内中断。

12.1 内中断的产生

为什么需要内中断?

当CPU的内部有些事情发生的时候,会产生需要马上处理的中断信息呢?对于8086CPU,当CPU内部有下面的情况发生的时候,将会产生相关的中断信息。

§ 第12章 内中断 - 图2

现在不需要知道上述4种情况的具体含义,只需要知道针对这4种不同的情况进行不同的处理。CPU首先要知道,所接收到的中断信息的来源,所以中断信息中必须要包含识别来源的编码,8086CPU用中断类型码来标识中断信息的来源

§ 第12章 内中断 - 图3

针对上述4种中断源,8086CPU的中断类型码如下

  • 除法错误:0
  • 单步执行:1
  • 执行into指令:4
  • 执行int指令,该指令的格式为int n,指令中的n就是CPU的中断类型码

12.2 中断处理程序

CPU收到中断信息后,需要对中断信息进行处理,这部分是由我们编程决定的。用来处理中断信息的程序被称为中断处理程序,我们需要对不同的中断信息编写不同的处理程序。

CPU接收到中断信息并且转去执行该中断信息的处理程序,而CS:IP为程序的入口,所以我们必须要在CPU收到中断信息后确定其程序处理的入口。

我们上面提到了中断码,它是一个8位的数据,并且用来定位中断处理程序。那么如何利用中断码得到中断处理程序的段地址和偏移地址呢

12.3 中断向量表

CPU用8位的中断类型码通过中断向量表找到相应的中断处理程序的入口地址。那什么是中断向量表呢?中断向量表就是中断向量的列表。那么什么是中断向量呢?所谓中断向量,就是中断程序的入口地址

§ 第12章 内中断 - 图4

中断向量表在内存中存放,对于8086PC机,中断向量表放在内存地址0处。从内存0000:0000到0000:03FF的1024个单元中存放着中断向量表。中断向量表中存放的是中断处理程序的入口,即短地址:偏移地址,所以一个表项(中断向量)占2个字,高地址存放段地址,低地址存放偏移地址。

§ 第12章 内中断 - 图5

例子:

§ 第12章 内中断 - 图6

12.4 中断过程

从上面我们知道了利用中断码->中断向量表->中断处理程序入口这个过程,找到这个入口地址的最终目的是用它设置CS和IP,使CPU执行中断处理程序。而设置CS和IP这个过程,是由CPU的硬件自动完成的,CPU硬件完成这个工作的过程被称为中断过程

CPU在执行完成中断处理程序后,应该返回原来的执行点继续执行下面的命令。所以在中断过程中,在设置CS:IP之前,还要将原来的CS和IP的值保存起来。

§ 第12章 内中断 - 图7

针对上述图片进行详细的介绍,8086CPU在收到中断信息后所引发的中断过程。

  1. 从中断信息中取得中断类型码
  2. 标志寄存器中的值入栈pushf,因为在中断过程中要改变标志寄存器的值,所以先将其保存在栈中
  3. 设置标志寄存器的第8位TF和第9位IF的值为0(这一步的目的后面介绍)
  4. CS内容入栈
  5. IP内容入栈
  6. 从内存地址为 中断类型码*4中断类型码*4+2 的两个字单元中读取中断处理程序的入口地址设置IP和CS

CPU在收到中断信息后,如果处理该中断信息,就完成一个由硬件自动执行的中断过程。中断过程的主要任务就是用中断类型码在中断向量表中找到中断处理程序的入口地址,设置CS和IP

因为CPU还要回过头来继续执行被中断的程序,所以要在设置CS、IP之前,先将它们的值保存起来,CPU将它们保存在栈中。

12.5 中断处理程序和iret指令

由于CPU随时都可能检测到中断信息,所以CPU随时都可能执行中断处理程序,所以中断处理程序必须一直存储在内存某段空间之中。而中断处理程序的入口地址,即中断向量,必须存储在对应的中断向量表表项中。

§ 第12章 内中断 - 图8 中断处理程序的编写方法和子程序的比较相似,下面是常规的步骤:

  1. 保存用到的寄存器
  2. 处理中断
  3. 恢复用到的寄存器
  4. iret指令返回

§ 第12章 内中断 - 图9 iret指令的功能用汇编语法描述为

  1. pop IP
  2. pop CS
  3. popf

iret通常和硬件自动完成的中断过程配合使用。可以看到,在中断过程中,寄存器入栈的顺序是

  1. 标志寄存器
  2. CS和IP

iret的出栈顺序是

  1. IP和CS
  2. 标志寄存器

刚好和其相对应,实现了用执行中断处理程序前的CPU现场恢复标志寄存器和CS、IP的工作。iret指令执行后,CPU回到执行中断处理程序前的执行点继续执行程序。

12.6 出发错误中断的处理

下面通过对0号中断,即除法错误中断的处理来体会一下前面所讲内容。

当CPU执行div等除法指令的时候,如果产生了除法溢出错误,将产生中断类型码为0的中断信息,CPU将检测到这个信息,然后引发中断过程,转去执行0号中断所对应的中断处理程序。

看一下下面程序的执行结果,如下图所示

  1. mov ax,1000h
  2. mov bh,1
  3. div bh

§ 第12章 内中断 - 图10

可以看到,当CPU执行div bh时,发生了除法溢出错误,产生0号中断信息,从而引发中断过程,CPU执行0号中断处理程序。

12.7 编程处理0号中断

上述的默认的0号中断处理程序没有什么很有用的地方,现在重新编写一个0号中断处理程序,它的功能是在屏幕中间显示overflow!,然后返回到操作系统。

我们首先进行分析:

§ 第12章 内中断 - 图11 当发生除法溢出的时候,产生0号中断信息,从而引发中断过程。

此时,CPU将进行如下工作。

  1. 取得中断类型码0
  2. 标志寄存器入栈,TF, IF设置为0
  3. CS和IP寄存器入栈
  4. (IP)=(04), (CS)=(04+2)

§ 第12章 内中断 - 图12 可见,当中断0发生时,CPU将转去中断处理程序。

只要按如下步骤编写中断处理程序,当中断0发生时,即可显示 “overflow!”

  1. 相关处理
  2. 向显示缓冲区发送字符串”overflow!”
  3. 返回DOS

我们将这段程序称为:do0。

§ 第12章 内中断 - 图13 现在的问题是:do0应存放在内存中。因为除法溢出随时可能发生,CPU随时都可能将CS:IP指向do0的入口,执行程序。

那么do0应该放在哪里呢?

由于我们是在操作系统之上使用计算机,所有的硬件资源都在操作系统的管理之下,所以我们要想要得到一块内存区存放do0,应该向操作系统申请。

然而,为了考虑到不过多地讨论操作系统的内容,这里我们就不再申请操作系统的内存,而是找到一块不会被别的程序用到的内存区,将do0传送到其中。

前面讲到,内存0000:0000~0000:03FF,大小为1KB的空间是系统存放中断处理程序入口地址的中断向量表。8086支持256个中断,但是实际上,系统中要处理的中断事件远没有达到256个。所以在中断向量表中,有许多单元是空的。

根据以前的编程经验,我们可以估计出,do0的长度不可能超过256个字节。所以,我们可以将do0程序传送到内存0000:0200处。

§ 第12章 内中断 - 图14 将中断处理程序do0放到0000:0200后,若要使得除法溢出发生的时候,CPU转去执行do0,则必须要将do0的入口地址,即0000:0200登记在中断向量表的对应表项中。

因为除法溢出对应的中断类型码为0,它的中断处理程序的入口地址应该从0*4地质单元开始存放,段地址存放在0*4+2字单元中。也就是说段地址=0000:0002偏移地址=0000:0000

总结上面的分析,我们要做以下几件事情。

  1. 编写可以显示”overflow!”的中断处理程序:do0
  2. 将do0送入内存0000:0200处
  3. 将do0的入口地址0000:0200存储在中断向量表0号表项中。

§ 第12章 内中断 - 图15 程序12.1的框架如下

  1. assume cs:code
  2. code segment
  3. start:do0安装程序
  4. 设置中断向量表
  5. mov ax,4c00h
  6. int 21h
  7. do0:显示字符串"overflow"
  8. mov ax,4c00h
  9. int 21h
  10. code ends
  11. end start

可以看到,上面的程序分为两部分

  1. 安装do0,设置中断向量的程序
  2. do0

上述程序12.1执行时,do0的代码是不执行的,它只是作为do0安装程序所要传送的数据。上述程序12.1执行时,首先执行do0安装程序,将do0的代码复制到0:200处,然后设置中断向量表,将do0的入口地址,即偏移地址200H和段地址0,保存在0号表项中。这两部分工作完成后,程序就返回了。程序的目的就是在内存0:200处安装do0的代码,将0号中断处理程序的入口地址设置为0:200。do0的代码虽然在程序中,却不再程序执行的时候执行。它是在除法溢出发生的时候才得以执行的中断处理程序。

§ 第12章 内中断 - 图16

do0部分代码的最后两条指令是按照我们的编程要求,用来返回DOS的。现在,我们在反过来从CPU的角度来看一下,什么是中断处理程序?我们来看一下do0是如何变成0号中断的中断处理程序的?

  1. 程序12.1在执行时,被加载到内存中,此时do0的代码在程序12.1所在的内存空间中,它只是存放在程序12.1的代码段中的一段要被传送到其他单元中的数据,我们不是说它是0号中断的中断处理程序;
  2. 程序12.1中安装do0的代码执行完后,do0的代码被从程序12.1的代码段中复制到0:200处。此时,我们也不能说它是0号中断的中断处理程序,它只不过是存放在0:200处的一些数据
  3. 程序12.1中设置中断向量表的代码执行完后,在0号表项中填入了do0的入口地址0:200,此时0:200处的信息,即do0的代码,就变成了0号中断的中断处理程序。因为当除法溢出(0号中断)发生时,CPU将执行0:200处的代码。

回忆一下:

  • 我们如何让一个内存单元成为栈顶?将它的地址放在SS、SP中;
  • 我们如何让一个内存单元中的信息被CPU当作指令来执行?将它的地址放入CS、IP中;
  • 我们如何让一段程序成为N号中断的中断处理程序?将它的入口地址放入中断向量表的N号表项中。

下面的内容中,我们讨论每一部分程序的具体编写方法。

12.8 安装

可以使用movsb指令,将do0的代码送入0:200处,程序如下。

  1. assume cs:code
  2. code segment
  3. start:设置ds:di指向目的地址
  4. 设置ds:si指向源地址
  5. 设置cx为传输长度
  6. 设置传输方向为正
  7. rep movsb
  8. 设置中断向量表
  9. mov ax,4c00h
  10. int 21
  11. do0:显示字符串"overflow!"
  12. mov ax,4c00h
  13. int 21h
  14. code ends
  15. end start

我们来看一下,用rep movsb指令的时候要确定的信息。

  1. 传送的原始位置:段地址:code, 偏移地址:offset do0
  2. 传送的目的位置:0:200
  3. 传送的长度:do0部分代码的长度
  4. 传送的方向:正向

上述的代码应该如下所示

  1. assume cs:code
  2. code segment
  3. start: mov ax,cs
  4. mov ds,ax
  5. mov si,offset do0 ; 设置ds:si指向源地址
  6. mov ax,0
  7. mov es,ax
  8. mov di,200h ; 设置es:di指向目的地址
  9. mov cx,do0部分代码的长度 ; 设置cx为传输长度
  10. cld ; 设置传输方向为正
  11. rep movsb
  12. 设置中断向量表
  13. mov ax,4c00h
  14. int 21h
  15. do0: 显示字符串"overflow!"
  16. mov ax,4c00h
  17. int 21h
  18. code ends
  19. end start

问题是,我们如何知道do0代码的长度?最简单的方法是,计算一下do0中所有的指令码的字节数。但是只要do0的内容发生了改变,我们都要重新计算它的长度,这样太麻烦了。

可以利用编译器来计算do0的长度,具体做法如下。

  1. assume cs:code
  2. code segment
  3. start: mov ax,cs
  4. mov ds,ax
  5. mov si,offset do0 ; 设置ds:si指向源地址
  6. mov ax,0
  7. mov es,ax
  8. mov di,200h ; 设置es:di指向目的地址
  9. mov cx,offset do0end-offset do0 ; 设置cx为传输长度
  10. cld
  11. rep movsb
  12. 设置中断向量表
  13. mov ax,4c00h
  14. int 21h
  15. do0: 显示字符串"overflow!"
  16. mov ax,4c00h
  17. int 21h
  18. do0end: nop
  19. code ends
  20. end start

现在来看一下上述代码中的mov cx,offset do0end-offset do0是如何计算代码的长度的?

-是编译器识别的运算符号,编译器可以用它来进行两个常数的减法。

汇编编译器也可以处理表达式

通过上述的表达式,可以知道是通过代码的起始和终止位置得到的。

  1. mov ax,8-4 ; 就相当于mov ax,4
  1. mov ax,(5+3)*5/10 ; 相当于mov ax,4

下面编写do0程序

12.9 do0程序

do0程序的主要任务是显示字符串,程序如下。

  1. do0: 设置ds:si指向字符串
  2. mov ax,0b800h
  3. mov es,ax
  4. mov di,12*160+36*2 ; 设置es:di指向显存空间的中间位置
  5. mov cx,9 ; 设置cx为字符串长度
  6. s: mov al,[si]
  7. mov es:[di],al
  8. inc si
  9. add di,2
  10. loop s
  11. mov ax,4c00h
  12. int 21h
  13. do0end:nop

程序写好了,要把显示的字符串放在哪里呢?我们看下面的程序。

§ 第12章 内中断 - 图17 程序12.2

  1. assume cs:code
  2. data segment
  3. db "overflow!"
  4. data ends
  5. code segment
  6. start: mov ax,cs
  7. mov ds,ax
  8. mov si,offset do0 ; 设置ds:si指向源地址
  9. mov ax,0
  10. mov es,ax
  11. mov di,200h ; 设置es:di指向目的地址
  12. mov cx,offset do0end-offset do0 ; 设置cx为传输长度
  13. cld
  14. rep movsb
  15. 设置中断向量表
  16. mov ax,4c00h
  17. int 21h
  18. do0: mov ax,data
  19. mov ds,ax
  20. mov si,0 ; 设置ds:si指向字符串
  21. mov ax,0b800h
  22. mov es,ax
  23. mov di,12*160+36*2 ; 设置es:di指向显存空间的中间位置
  24. mov cx,9
  25. s: mov al,[si]
  26. mov es:[di],al
  27. inc si
  28. add di,2
  29. loop s
  30. mov ax,4c00h
  31. int 21h
  32. do0end: nop
  33. code ends
  34. end start

上面的程序,看似合理,可实际上却大错特错。注意,”overflow!”在程序12.2的data段中。程序12.2执行完后返回,它所占用的内存空间被系统释放,而在其中存放的”overflow”很可能被别的信息覆盖。

而do0程序被放到了0:200处,随时都会因发生了除法溢出而被CPU执行,很难保证do0程序从原来程序12.2所处的空间中取得的是要显示的字符串”overflow!”。

因为do0程序随时可能被执行,而它要用到字符串”overflow”,所以该字符串也应该存放在一段不会被覆盖的空间中。正确的程序如下。

§ 第12章 内中断 - 图18 程序12.3

  1. assume cs:code
  2. code segment
  3. start: mov ax,cs
  4. mov ds,ax
  5. mov si,offset do0 ; 设置ds:si指向源地址
  6. mov ax,0
  7. mov es,ax
  8. mov di,200h ; 设置es:di指向目的地址
  9. mov cx,offset do0end-offset do0 ; 设置cx为传输长度
  10. cld
  11. rep movsb
  12. 设置中断向量表
  13. mov ax,4c00h
  14. int 21h
  15. do0: jmp short do0start
  16. db "overflow!"
  17. do0start: mov ax,cs
  18. mov ds,ax
  19. mov si,202h ; 设置ds:si指向字符串
  20. mov ax,0b800h
  21. mov es,ax
  22. mov di,12*160+36*2 ; 设置es:di指向显存空间的中间位置
  23. mov cx,9 ; 设置cx为字符串长度
  24. s: mov al,[si]
  25. mov es:[di],al
  26. inc si
  27. add di,2
  28. loop s
  29. mov ax,4c00h
  30. int 21h
  31. do0end: nop
  32. code ends
  33. end start

在程序12.3中,将”overflow!”放到do0程序中,程序12.3执行时,将标号do0到do0end之间的内容发送到0000:0200处。

注意!因为在db0程序开始处的”overflow!”不是可以执行的代码,所以在”overflow!”之前加上一条jmp指令,转移到正式的do0程序。当除法溢出发生时,CPU执行0:200处的jmp指令,跳过后面的字符串,转到正式的do0程序执行。

do0程序执行过程中必须要找到”overflow!”,那么它在哪里呢?首先来看段地址,”overflow!”和do0的代码处于同一段中,而除法溢出发生时,CS中必然存放do0的段地址,也就是”overflow”的段地址;再来看偏移地址,0:200处的指令为jmp short do0start,这条指令占两个字节,所以”overflow!”的偏移地址为202h。

12.10 设置中断向量

下面,将do0的入口地址0:200,写入中断向量表的0号表项中,使do0成为0号中断的中断处理程序。

0号表项的地址为0:0,其中0:0字单元存放偏移地址,0:2字单元存放段地址。程序如下

  1. mov ax,0
  2. mov es,ax
  3. mov word ptr es:[0*4],200h
  4. mov word ptr es:[0*4+2],0

12.11 单步中断

基本上,CPU在执行完一条指令以后,如果检测到标志寄存器的TF位为1,则产生单步中断,引发中断过程。单步中断的中断类型码为1,则它引发的中断过程如下:

  1. 取得中断类型码1
  2. 标志寄存器入栈,TF、IF设置为0
  3. CS和IP入栈
  4. § 第12章 内中断 - 图19%3D(14)%2C%20(CS)%3D(I4%2B2)#card=math&code=%28IP%29%3D%281%2A4%29%2C%20%28CS%29%3D%28I%2A4%2B2%29&id=N7omG)

§ 第12章 内中断 - 图20 如上所述,如果TF=1,则执行一条指令后,CPU就要转去执行1号中断处理程序。CPU为什么要提供这样的功能呢?

我们在使用Debug的t命令的时候,有没有想过这样的问题,Debug是如何能让CPU在执行一条指令后,就显示各个寄存器的状态?

我们知道,CPU在执行程序的时候是从CS:IP指向的某个地址开始,自动向下读取指令执行。也就是说,如果CPU不提供其他功能的话,就按这种方式工作,只要CPU一加电,它就从预设的地址开始一直执行下去,不可能有任何程序能控制它在执行完一条指令后停止,去做别的事情。可是,我们在Debug中看到的情况却是,Debug可以控制CPU执行被加载程序中的一条指令,然后让它停下来,显示寄存器的状态。

§ 第12章 内中断 - 图21 Debug有特殊的能力吗?我们只能说Debug利用了CPU提供的一种功能。只有CPU提供了在执行一条指令后就转去做其他事情的功能,Debug或是其他的程序才能利用CPU提供的这种功能做出我们使用T命令时的效果

现在我们简要地考虑一下Debug是如何利用CPU所提供的单步中断的功能的。首先,Debug提供了单步中断的中断处理程序,功能为显示所有寄存器中的内容后等待输入命令。然后,在使用t命令执行指令时,Debug将TF设置为1,使得CPU工作于单步中断方式下,则在CPU执行完这条指令后就引发单步中断,执行单步中断的中断处理程序,所有寄存器的内容被显示在屏幕上,并且等待输入命令。

§ 第12章 内中断 - 图22 那么,接下来的问题是,当TF=1时,CPU在执行完一条指令后将引发单步中断,转去执行中断处理程序。注意,中断处理程序也是由一条条指令组成的,如果在执行中断处理程序之前,TF=1,则CPU在执行完中断处理程序之前,又要产生单步中断,则又要转去单步中断的中断处理程序,在执行完中断处理程序的第一条指令后,又要产生单步中断,则又要转去执行单步中断的中断处理程序。

看来,上面的过程将陷入一个永远不能结束的循环,CPU永远执行单步中断处理程序的第一条指令。

CPU当然不能让这种情况发生,解决的办法就是,在进行中断处理程序之前,设置TF=0。从而避免CPU在执行中断处理程序的时候发生单步中断。这就是为什么在中断过程中有TF=0这个步骤,我们再来看一下这个中断过程。

  1. 取得中断类型码N
  2. 标志寄存器入栈,TF=0、IF=0
  3. CS和IP入栈
  4. § 第12章 内中断 - 图23%3D(14)%2C%20(CS)%3D(I4%2B2)#card=math&code=%28IP%29%3D%281%2A4%29%2C%20%28CS%29%3D%28I%2A4%2B2%29&id=g7Elz)

最后,PCU提供单布中断功能的原因就是,为单步跟踪程序的执行过程,提供了实现机制。

12.12 响应中断的特殊情况

一般情况下,CPU在执行完当前指令后,如果检测到中断信息,就响应中断,引发中断过程。可是,在有些情况下,CPU在执行完当前指令后,即便是发生中断,也不会响应。对于这些情况,我们拿其中的一种情况来说明。

在执行完向 SS 寄存器传送数据的指令后,即便是发生中断,CPU也不会响应。这样做的主要原因是,ss:sp 联合指向栈顶,而对它们的设置应该连续完成。如果在执行完设置 SS的指令后,CPU响应中断,引发中断过程,要在栈中压入标志寄存器、CS和IP的值。而SS改变,SP并未改变,SS:SP指向的不是正确的栈顶,将引起错误。

所以CPU在执行完设置SS的指令后,不响应中断。这给连续设置SS和SP指向正确的栈顶提供了一个时机。即,我们应该利用这个特性,将设置SS和SP的指令连续存放,使得设置SP的指令紧接着设置SS的指令执行,而在此之间,CPU不会引发中断过程。比如,我们要将栈顶设为1000:0,应该:

  1. mov ax,1000h
  2. mov ss,ax
  3. mov sp,0

而不应该

  1. mov ax,1000h
  2. mov ss,ax
  3. mov ax,0
  4. mov sp,0

Debug利用单步中断来实现 t 命令的功能,也就是说,用 t 命令执行一条指令后,CPU响应单步中断,执行Debug设置好的处理程序,才能在屏幕上显示寄存器的状态,并等待命令的输入。

而在mov ss,ax指令执行后,CPU根本就不响应任何中断,其中也包括单步中断,所以Debug设置好的用来显示寄存器状态和等待输入命令的中断处理程序根本没有得到执行,所以我们看不到预期的结果。CPU接着向下执行后面的指令mov sp,10h,然后响应单步中断,我们才看到正常的结果。