汇编语言:内中断和中断处理程序
内中断
内中断的产生
CPU 具备在执行完当前指令后,响应内外特殊事件并暂停现行程序、转去处理该事件的能力。触发 CPU 打断原有执行流的特殊事件被统称为中断信息,它携带了要求 CPU 立即处理的请求和处理所需的参数,本质上是多个有序硬件操作事件的抽象。
其中,内中断是来自 CPU 内部的中断信号,是 CPU 在执行指令过程中觉得需要立刻停下来处理的一件事。8086 CPU 规定了 4 种内中断源,每种都配了一个一个字节的中断类型码,如下表所示:
| 中断源 | 类型码 | 触发场景 |
|---|---|---|
| 除法错误 | 0 | div / idiv 发生溢出 |
| 单步执行 | 1 | TF=1 时,每执行一条指令就中断 |
into |
4 | 溢出中断,OF=1 时触发 |
int n |
n | 程序员主动调用的软中断 |
其中int n 是唯一能主动控制的内中断,其他都是 CPU 自行产生的。
中断处理程序
CPU 收到中断信息后,需要对中断信息进行处理,编写的用来处理中断信息的程序被称为中断处理程序。实际上它就是一个被 CPU 临时叫去执行的函数,但它和普通子程序(call)有明显的区别:
| 对比 | call |
中断 |
|---|---|---|
| 发起方 | 程序员 | CPU |
| 返回指令 | ret/retf |
iret |
| 是否自动压栈 | 只压 IP / CS | 压 IP + CS + FLAG |
中断处理程序的任务是:保存现场 → 处理异常 → 恢复现场 → 返回,一般来说需要对不同的中断信息编写不同的处理程序。
中断向量表
中断向量表(IVT) 是 8086 在内存 0000:0000 ~ 0000:03FF 维护的一张表,表中有 256 个表项,每个表项 4 字节,共 1024 字节。每个表项的低字为偏移地址(IP),高字为段地址(CS)。8086 在硬件设计上规定 IVT 在 0 地址,无法进行修改。因为每项 4 字节,所以第 N 个表项对应的偏移地址需要乘 4。又因为偏移在前、段地址在后,因此对应内容的地址为:
| 内容 | 地址 |
|---|---|
| 偏移地址 | N × 4 |
| 段地址 | N × 4 + 2 |
当 CPU 收到中断类型码 N 时,首先计算表项地址 = n × 4,然后从 [n×4] 取 IP、从 [n×4+2] 取 CS,接着执行 CS:IP 的指令。例如给定如下内存,则 3 号中断对应的表项地址 = 3 × 4 = 12 = 000CH。从 0000:000C 开始取 4 字节,0000:000C: 16 00 对应 IP = 0016H、0000:000E: 9D 03 对应 CS = 039DH,最终得到入口地址为 039D:0016。
0000:0000 68 10 A7 00 8B 01 70 00 16 00 9D 03 8B 01 70 00
中断向量表要存在 0 地址的原因是:8086 复位后 CS:IP = FFFF:0000,指向 ROM BIOS。BIOS 启动后会初始化 0 地址处的 IVT,DOS 加载后会接管并修改 IVT(例如 int 21h)。把 IVT 放在 0 地址时硬件实现最为简单,类型码左移 2 位直接就是物理地址。
现代 CPU 早已抛弃固定设置 IVT 的方式改用 IDT 中断描述符表,但 8086 用一张表把中断号和函数入口解耦的事项仍然被延续使用。
中断过程
中断过程包括自动保存现场(FLAG/CS/IP)、强制关中断、跳转到中断处理程序几个主要步骤,8086 里该过程是硬件完成的,程序员无法对该过程进行干涉。只要中断被响应,以下步骤就会发生:
- 取中断类型码 N:来自
div溢出、单步、int n 等来源。 - 标志寄存器入栈:因为接下来需要修改 flag(主要是 TF、IF),需要先用
pushf备份寄存器状态。 - 清除 TF 和 IF:令
TF = 0防止单步中断嵌套,否则 CPU 每执行一条中断处理指令又会有单步中断,导致死循环;IF = 0以禁止可屏蔽外部中断,保证当前中断处理不被打扰。 push CS和push IP:保存被中断的下一条指令的地址,以便iret回来。- 根据 N 设置 CS:IP:
IP = (N × 4)、CS = (N × 4 + 2)。
中断处理程序
由于 CPU 随时都可能检测到中断信息,也就是说,因此中断处理程序需要一直存储在内存中。中断处理程序在形式上很像子程序,但有三点不同:
| 项目 | 子程序(call) | 中断处理程序 |
|---|---|---|
| 调用者 | 程序员 | CPU |
| 返回指令 | ret / retf |
iret |
| 栈中内容 | IP 或 CS:IP | FLAG + CS + IP |
中断处理程序的标准写法如下:
; 1. 保存寄存器状态
push ax
push bx
...
; 2. 处理中断,比如打印信息、修正错误
...
; 3. 恢复寄存器状态
pop bx
pop ax
; 4. 返回
iret
iret 指令(Interrupt Return)是中断/异常返回指令,用来从中断处理程序返回被中断的上下文,恢复之前的执行现场。执行 iret 可以视为中断过程的逆过程,CPU 按相反顺序把 CS 和 IP 值弹回寄存器,从而回到原来被打断的位置继续执行。形式上来说可以用如下几条指令表示:
pop IP
pop CS
popf
例如对于以下指令,执行后 CPU 检测到除法溢出。此时 CPU 没有继续执行下一条指令,触发 0 号中断。
mov ax, 1000h
mov bh, 1
div bh
接着从 0000:0000~0000:0003 取出 0 号中断向量,跳转到 DOS 的 0 号中断处理程序,在 DOS 上会显示 Divide overflow 然后返回 DOS。DOSBox 因为是模拟软件,其处理中断的行为不同,但是仍然可以看到跳转到中断处理程序的指令。

注意,如果不把 TF 清零,当前指令执行完后会触发单步中断,进入单步中断处理程序。单步中断处理程序第一条指令执行完后又会触发单步中断,导致 CPU 在中断里死循环导致系统卡死。所以 CPU 在进入任何中断处理程序时,需要设置 TF=0 来避免这个问题。
编程处理 0 号中断
中断程序的编写逻辑
考虑当程序发生除法溢出(div 溢出)时,我们令 CPU 不执行 DOS 默认的 Divide overflow,而是跳转到自己写的中断处理程序 do0,在屏幕中间显示 overflow! 后返回 DOS。这个需求并不复杂,但是需要考虑以下 3 个问题:
do0代码的位置;- 如何让 CPU 找到并执行
do0; - 运行
do0如何返回。
如果像之前一样把 do0 放在代码段,在程序结束后这个代码段可能被 DOS 回收,此时如果再触发中断就不能正常运行了。从本质上说,do0 在程序执行时只是数据,不是中断处理程序。因为中断可能在任何时候发生,所以中断处理程序必须常驻内存,且地址固定。如此看来,内存选址需要满足如下条件:
| 条件 | 说明 |
|---|---|
| 必须常驻 | 不能放在用户程序段 |
| 必须固定 | 中断向量表是绝对地址 |
| 不能破坏系统 | 不能用已分配资源 |
| 大小合适 | do0 < 256 字节 |
王爽在书中给出的内存区域是 0000:0200~0000:02FF,这部分属于中断向量表区域(0000:0000~03FF),对应的是 32~255 号中断向量。这些中断在 DOS 下大多未使用,且操作系统和应用程序都不会占用,所以可以用于平时的实验。
一段代码是不是中断处理程序由中断向量表决定,不是由它写在哪里决定的。设置 0 号中断向量的本质是设置偏移地址为 0000:0000,设置段地址为 0000:0002,即可以看做如下指令:
mov word ptr ds:[0], 0200h ; IP
mov word ptr ds:[0+2], 0 ; CS
CPU 在发生除法溢出时就自动查 0 号表项,拿到 CS:IP = 0000:0200 后执行 do0,此时 0000:0200 处的代码才正式成为 0 号中断处理程序。do0 在不同阶段中的身份如下表总结:
| 阶段 | do0 的身份 |
|---|---|
| 编译时 | 一段数据 |
| 程序加载后 | 用户程序代码段里的数据 |
movsb 复制后 |
常驻内存的数据 |
| 设置中断向量后 | 0 号中断处理程序 |
以下是 do0 程序的框架,执行后的效果是:把 do0 当数据 movsb 到 0000:0200,设置中断向量,然后int 21h / 4c00h 返回 DOS。程序结束以后某刻发生除法溢出,CPU 自动跳到 0000:0200 执行 do0。
code segment
start:
; 安装 do0
; 设置中断向量
mov ax,4c00h
int 21h
do0:
; 显示字符串
mov ax,4c00h
int 21h
code ends
即do0 是事件驱动的,不是顺序执行的。综上所述,要使能我们自己写的 0 号中断程序,就需要把 do0 的代码复制到常驻内存、让 do0 能正确找到要显示的字符串、把 do0 的入口登记到中断向量表 0 号表项中。
安装程序
因为 0000:0200~0000:02FF 对应的中断向量(32~255 号)在 DOS 环境下通常为空,不会被系统或应用程序占用,因此将其选作安装位置。安装就是用 rep movsb 指令把 do0 到 do0end 之间的字节搬到 0000:0200 处。
assume cs:code
code segment
start:
mov ax, cs
mov ds, ax
mov si, offset do0 ; 源为代码段中的 do0
mov ax, 0
mov es, ax
mov di, 200h ; 目的为 0000:0200
mov cx, offset do0end - offset do0
cld
rep movsb ; 把 do0 代码复制到常驻区
; 设置 0 号中断向量
mov ax, 0
mov es, ax
mov word ptr es:[0], 200h ; 偏移地址
mov word ptr es:[2], 0 ; 段地址
; 测试代码,主动触发中断
mov ax, 1000h
mov bh, 1
div bh
mov ax, 4c00h
int 21h
……
……
程序用 ds:si 指向代码段里的 do0,es:di 指向 0000:0200,cx 用 offset do0end - offset do0 算出代码长度。注意“-”是编译器识别的运算符,它可以令编译器指令两个常数的减法。使用 cld + rep movsb 进行复制写入 0000:0000 和 0000:0002 两个字单元后,就完成了向量表登记。

do0 的编写
do0 被 CPU 当指令执行,因此它所在的内存里不能随便放数据。首先在 do0 入口执行 jmp short do0start,接着用 db "overflow!" 把字符串内嵌在代码后面。CPU 从 0000:0200 开始执行时将跳过重定位数据,从 do0start 开始执行处理逻辑。jmp 占 2 字节,所以 db 从 0202H 开始,字符串的偏移是 0202H。do0 执行时 CS 就是 0000H,于是用 mov si, 202h 就能取到字符串。
do0:
jmp short do0start
db "overflow!"
do0start:
mov ax, cs
mov ds, ax
mov si, 202h ; 字符串紧跟在 jmp 之后
mov ax, 0b800h
mov es, ax
mov di, 12*160 + 36*2 ; 第 12 行第 36 列
mov cx, 9
s:
mov al, [si]
mov ah, 00000100b ; 设置字体颜色为红色
mov es:[di], ax
inc si
add di, 2
loop s
mov ax, 4c00h
int 21h
do0end:
nop
code ends
end start
注意因为中断发生时 DS 是未知状态,这里不能用 data 段,只能用确定的 CS。显示时直接将字符串写入显存 B800:0000,第 12 行第 36 列的偏移是 12*160 + 36*2。严格来说中断处理程序应以 iret 返回被中断现场,但此处目标是回到 DOS,所以直接用 int 21h 退出。
设置中断向量
0 号表项在 0000:0000,低字存偏移、高字存段地址,安装程序的以下两条指令就是把 IP=0200H, CS=0000H 写进表项。
mov word ptr es:[0], 200h
mov word ptr es:[2], 0
运行后 0 号中断处理程序将属于 0000:0200 处的这段代码,若产生溢出则跳转到我们安装的中断处理程序 d0。

该程序执行后,屏幕第 12 行附近将显示 “overflow!” ,之后返回 DOS。

单步中断
TF 标志位
单步中断是 CPU 提供的每执行一条指令就暂停中断,主要用于实现调试,中断类型码为 1。当标志寄存器的 TF(Trap Flag,陷入标志) 被置为 1 时,CPU 在执行完当前指令后,会立即检测到这一状态并触发单步中断。其引发的中断过程与其他中断一致,取得类型码 1 后将标志寄存器入栈,将 TF 和 IF 清零,随后 CS 和 IP 入栈,最后从中断向量表 1 号表项中取出入口地址跳转执行。
单步中断是 Debug 的 T 命令实现单步执行程序的基础,用户键入 T 命令时 Debug 将 TF 置 1,CPU 执行完目标指令后便触发单步中断,转而执行 Debug 预先编写的中断处理程序。该程序负责把当前所有寄存器的状态显示在屏幕上,并等待用户输入下一条调试命令。
如果 CPU 在执行单步中断处理程序时 TF 仍然为 1,则在执行处理程序的第一条指令后又会触发新的单步中断,导致无限嵌套,永远无法执行完处理程序本身。为此,CPU 在进入任何中断处理程序之前,都会强制将 TF 清零,以确保中断处理程序能够执行,同时在处理程序返回后 TF 的状态会从栈中恢复,继续执行下一条用户指令时又重新进入单步模式。
在现代 x86 架构中,单步调试的功能被扩展为 EFLAGS 寄存器中的 TF 位,其工作原理与 8086 基本一致,但配合保护模式下的特权级检查,使得操作系统内核能够安全地调试用户态程序。
SS 寄存器的特殊情况
虽然 CPU 通常会在执行完当前指令后立即响应中断,但在某些情况下不这么做。其中最具代表性的就是在对 SS 寄存器进行写操作之后,CPU 不会立即响应中断。这样做的目的是为了保证栈结构的完整性,8086 架构中 SS:SP 指向当前栈顶,对它们的修改必须是一个原子操作。如果在执行完 mov ss, ax 设置栈段寄存器的指令后立即响应中断,CPU 会试图将标志寄存器、CS 和 IP 压入栈中。此时若 SP 尚未更新,SS:SP 将指向一个错误的内存位置,导致关键数据压入非法地址,进而引发系统崩溃。
为了解决这一问题,8086 CPU 规定在执行改变 SS 的指令后,即使有中断请求也会暂时屏蔽,直到下一条指令执行完毕。这要求程序员在初始化栈时,必须将设置 SS 和 SP 的指令连续存放。例如,正确的写法是将 mov ss, ax 和 mov sp, 0 紧挨着写,中间不能插入任何其他指令。这样就能保证 SS 和 SP 完成同步更新,避免了栈顶指针悬空。
这样就可以解释实验 2 中 mov ss, ax 指令执行后 T 命令“失效”的现象,Debug 的 T 命令依赖单步中断来显示寄存器状态,但在 mov ss, ax 执行后,CPU 屏蔽了包括单步中断在内的所有中断,导致 Debug 的中断处理程序无法立即执行。CPU 会直接执行后面的指令,该指令完成后中断屏蔽解除,单步中断才得以响应,此时才能看到寄存器的完整变化。

SS 寄存器的修改机制在现代 CPU 中依然保留,但在 64 位模式下,由于栈操作大多通过 RSP 寄存器完成,且操作系统通常不允许随意修改栈段。
参考资料
《汇编语言(第四版)》,王爽 编著,清华大学出版社

浙公网安备 33010602011771号