汇编语言 —— 内中断

任何一个通用的 CPU,比如 8086,都具备一种能力,可以在执行完当前正在执行的指令之后,检测到从 CPU 外部发送过来的或内部产生的一种特殊信息,并且可以立即对所接收到的信息进行处理。这种特殊的信息,我们可以称其为:中断信息。中断的意思是指,CPU 不再接着(刚执行完的指令)向下执行,而是转去处理这个特殊信息。

中断信息是对几个具有先后顺序的硬件操作所产生的事件的统一描述。“中断信息” 是要求 CPU 马上进行某种处理,并向所要进行的该种处理提供了必备的参数的通知信息。中断信息可以来自 CPU 的内部和外部,这里我们主要讨论来自于 CPU 内部的中断信息。


对于 8086CPU,当 CPU 内部有下面的情况发生的时候,将产生相应的中断信息:

  • 除法错误,比如,执行 div 指令产生的除法溢出;
  • 单步执行;
  • 执行 into 指令;
  • 执行 int 指令。

中断信息的来源称为中断源,上述的 4 种中断源,在 8086CPU 中的中断类型码如下:

  • 除法错误:0
  • 单步执行:1
  • 执行 into 指令:4
  • 执行 int 指令,该指令的格式为 int n,指令中的 n 为字节型立即数,是提供给 CPU 的中断类型码。

中断类型码为一个字节型数据,可以表示 256 种中断信息的来源。


CPU 在收到中断信息后,应该转去执行该中断信息的处理程序。若要 8086CPU 执行某处的程序,就要将 CS:IP 指向它的入口(即程序第一条指令的地址)。

可见首要的问题是,CPU 在收到中断信息后,如何根据中断信息确定其处理程序的入口。


CPU 用 8 位的中断类型码通过中断向量表找到相应的中断处理程序的入口地址。

中断向量表就是中断向量的列表。中断向量就是中断处理程序的入口地址。因此,中断向量表,就是中断处理程序入口地址的列表

CPU 用中断类型码,通过查找中断向量表,就可以得到中断处理程序的入口地址。

中断向量表在内存中存放,对于 8086PC 机,中断向量表指定放在内存地址 0 处。从内存 0000:0000 到 0000:03FF 的 1024 个单元中存放着中断向量表。

在中断向量表中,一个表项存放一个中断向量,也就是一个中断处理程序的入口地址。这个入口地址包括段地址和偏移地址,所以一个表项占两个字,高地址字存放段地址,低地址字存放偏移地址。


由上面的讲解可知,可以用中断类型码,在中断向量表中找到中断处理程序的入口。找到这个入口地址的最终目的是用它设置 CS 和 IP,使 CPU 执行中断处理程序。用中断类型码找到中断向量,并用它设置 CS 和 IP,这个工作是由 CPU 的硬件自动完成的。CPU 硬件完成这个工作的过程被称为中断过程。

CPU 收到中断信息后,要对中断信息进行处理,首先将引发中断过程。硬件在完成中断过程后,CS:IP 将指向中断处理程序的入口,CPU 开始执行中断处理程序。

技巧
有一个问题需要考虑,CPU 在执行完中断处理程序后,应该返回原来的执行点继续执行下面的指令。所以在中断过程中,在设置 CS:IP 之前,还要将原来的 CS 和 IP 的值保存起来。在使用 call 指令调用子程序时有同样的问题,子程序执行后还要返回到原来的执行点继续执行,所以,call 指令先保存当前 CS 和 IP 的值,然后再设置 CS 和 IP。

下面是 8086CPU 在收到中断信息后,所引发的中断过程:

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

CPU 在收到中断信息之后,如果处理该中断信息,就完成一个由硬件自动执行的中断过程(程序员无法改变这个过程中所要做的工作)。中断过程的主要任务就是用中断类型码在中断向量表中找到中断处理程序的入口地址,设置 CS 和 IP。完成后,CPU 还要回过头来继续执行被中断的程序,所以要在设置 CS、IP 之前,先将它们的值保存起来。可以看到 CPU 将它们保存在栈中。在中断过程中还要做的一个工作就是设置标志寄存器的 TF、IF 位(下一章中进行讨论)。因为在执行完中断处理程序后,需要恢复在进入中断处理程序之前的 CPU 现场(某一时刻,CPU 中各个寄存器的值)。所以应该在修改标记寄存器之前,将 它的值入栈保存。


中断处理程序的编写方法和子程序的比较相似,下面是常规的步骤:

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

iret 通常和硬件自动完成的中断过程配合使用。在中断过程中,寄存器入栈的顺序是标志寄存器、CS、IP,而 iret 的出栈顺序是 IP、CS、标志寄存器,刚好和其相对应,实现了用执行中断处理程序前的 CPU 现场恢复标志寄存器和 CS、IP 的工作。 iret 指令执行后,CPU 回到执行中断处理程序前的执行点继续执行程序。


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

系统对 0 号中断的处理
系统对 0 号中断的处理

可以看到,当 CPU 执行 div bh 时,发生了除法溢出错误,产生 0 号中断信息,从而引发中断过程,CPU 执行 0 号中断处理程序。我们从图中可以看出系统中的 0 号中断处理程序的功能:显示提示信息“Divide overflow” 后,返回到操作系统中。


编程:当发生除法溢出时,在屏幕中间显示 “overflow!”,返回 DOS。

(1) 当发生除法溢出的时候,产生 0 号中断信息,从而引发中断过程。

此时,CPU 将进行以下工作:

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

(2) 当中断 0 发生时,CPU 将转去执行中断处理程序。

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

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

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

(3) do0 应存放在内存中的什么地方?

我们只需找到一块别的程序不会用到的内存区,将 do0 传送到其中即可。

一般情况下,从 0000:0200 至 0000:02FF 的 256 个字节的空间所对应的中断向量表项都是空的,操作系统和其他应用程序都不占用。

我们可以将 do0 传送到内存 0000:0200 处。

(4) 将 do0 的入口地址,即 0000:0200 登记在中断向量表的对应表项中。

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

综上,我们需要做以下事情:

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

安装 do0 中断处理程序,就是将 do0 的代码送入 0:200 处,可以使用 movsb 指令完成安装。程序如下:

text

assume cs:code

code segment

    start:
            mov ax, cs
            mov ds, ax
            mov si, offset do0                  ; 设置 ds:si 指向源地址

            mov ax, 0
            mov es, ax
            mov di, 200h                        ; 设置 es:di 指向目的地址 0:200

            mov cx, offset do0end-offset do0    ; 设置 cx 为传输长度

            cld                                 ; 设置传输方向为正

            rep movsb

            ; TODO 设置中断向量表

            mov ax, 4c00h
            int 21h

      do0:
            ; TODO 显示字符串“overflow!”
            mov ax, 4c00h
            int 21h

   do0end:
            nop

code ends

end start
警告
此处的代码仅仅是将代码 do0 传入 0:200 处,并未执行 do0!

此处我们利用编译器来计算 do0 的长度,offset do0end-offset do0,“_” 是编译器识别的运算符号,编译器可以用它来进行两个常数的减法。

汇编编译器可以处理表达式。比如,指令:mov ax,(5+3)*5/10,被编译器处理为指令:mov ax, 4

do0 程序的主要任务是显示字符串,这里特别需要注意字符串“overflow!”存放的位置,程序如下:

text

assume cs:code

code segment

    start:
            mov ax, cs
            mov ds, ax
            mov si, offset do0                  ; 设置 ds:si 指向源地址

            mov ax, 0
            mov es, ax
            mov di, 200h                        ; 设置 es:di 指向目的地址

            mov cx, offset do0end-offset do0    ; 设置 cx 为传输长度

            cld                                 ; 设置传输方向为正

            rep movsb

            ; TODO 设置中断向量表

            mov ax, 4c00h
            int 21h

      do0:
            jmp short do0start
            db "overflow!"

 do0start:
            mov ax, cs
            mov ds, ax
            mov si, 202h                        ; 设置 ds:si 指向字符串

            mov ax, 0b800h
            mov es, ax
            mov di, 12*160+36*2                 ; 设置 es:di 指向显存空间的中间位置

            mov cx, 9                           ; 设置 cx 为字符串长度
        s:
            mov al, [si]
            mov es:[di], al
            inc si
            add di, 2
            loop s                              ; 循环将字符串写入显存

            mov ax, 4c00h
            int 21h

   do0end:
            nop

code ends

end start

此处将“overflow!”存放到 do0 程序中,而不是存放到 data 段中,是因为如果存放到 data 段中,在执行完 start 代码后返回,它锁占用的内存空间会被系统释放,其中存放的“overflow!”信息也可能被别的信息覆盖,所以需要将字符串存放在一段不会被覆盖的空间中。此处的代码会随着代码段 do0 的转移,将字符串存入 0:202h 地址处(因为 0:200h 处的指令为 jmp short do0start,这条指令占两个字节,所以“overflow!”的偏移地址为 202h)。

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

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

text

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

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

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

如果 TF=1,则执行一条指令后,CPU 就要转去执行 1 号中断处理程序。CPU 为什么要提供这样的功能呢?

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

在使用 Debug 的 t 命令的时候,Debug 如何能让 CPU 在执行一条指令后,就显示各个寄存器的状态?

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

当 TF=1 时,CPU 在执行完一条指令后将引发单步中断,转去执行中断处理程序。注意,中断处理程序也是由一条条指令组成的,如果在执行中断处理程序之前,TF=1,则 CPU 在执行完中断处理程序的第一条指令后,又要产生单步中断,则又要转去执行单步中断的中断处理程序,在执行完中断处理程序的第一条指令后,又要产生单步中断,则又要转去执行单步中断的中断处理程序······

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


一般情况下,CPU 在执行完当前指令后,如果检测到中断信息,就响应中断,引发中断过程。可是,在有些情况下,CPU 在执行完当前指令后,即便是发生中断,也不会响应。

在执行完向 ss 寄存器传送数据的指令后,即便是发生中断,CPU 也不会响应。这样做的主要原因是,ss:sp 联合指向栈顶,而对它们的设置应该连续完成。如果在执行完设置 ss 的指令后,CPU 响应中断,引发中断过程,要在栈中压入标志寄存器、CS 和 IP 的值。而 ss 改变,sp 并未改变,ss:sp 指向的不是正确的栈顶,将引起错误。所以 CPU 在执行完设置 ss 的指令后,不响应中断。这给连续设置 ss 和 sp 指向正确的栈顶提供了一个时机。我们应该利用这个特性,将设置 ss 和 sp 的指令连续存放,使得设置 sp 的指令紧接着设置 ss 的指令执行,而在此之间,CPU 不会引发中断过程。