内中断

⚠ 转载请注明出处:作者:ZobinHuang,更新日期:July 17 2021


知识共享许可协议

    本作品ZobinHuang 采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,在进行使用或分享前请查看权限要求。若发现侵权行为,会采取法律手段维护作者正当合法权益,谢谢配合。


目录

有特定需要的内容直接跳转到相关章节查看即可。

    Section 1. 内中断:介绍了 CPU 中断和内中断的概念

    Section 2. 中断背后的硬件原理:分析了 CPU 中断背后的原理
        2.1 基本思路:给出了 CPU 处理中断的基本思路
        2.2 中断跳转和恢复过程:分析了 CPU 在进行中断跳转和恢复时的过程

    Section 3. 中断处理程序 是一个代码段:给出了一个例子来理解中断处理程序的本质是一段代码 (废话)

    Section 4. 单步中断:分析了 1 号中断单步中断

    Section 5. CPU 也会忽略中断:列举了 CPU 忽略中断的一种场景

    Section 6. int 指令引发的中断:通过一个由 int 指令引发中断的例子来理解 int 和 iret 指令的用法

    Section 7. BIOS 和 DOS 提供的中断例程:阐述了 BIOS 和 DOS 操作系统提供的中断例程及其调用原理
        7.1 BIOS 和 操作系统提供的中断例程:介绍了 BIOS 和 操作系统提供中断例程的背景
        7.2 BIOS 和操作系统中断例程的初始化:分析了系统上电以后 BIOS 和操作系统注册中断的过程
        7.3 BIOS 中断实例:给出了一个 BIOS 中断的实例
        7.4 DOS 中断实例:给出了一个 DOS 操作系统中断的实例

1. 内中断

    中断指的是 CPU 在运行程序的过程中,收到来自某处的 中断信息,进而打断当前正在运行的程序,将 CS:IP 指向 中断处理程序 对中断进行处理,之后再返回先前正在执行的程序的过程。

    在本文中我们将主要介绍 内中断。内中断指的是由 CPU 内部自己执行的程序产生的中断。对于 8086 CPU 来说,中断源主要有下面四种:

  1. 除法错误 (e.g. 由 div 指令造成的除法溢出)
  2. 单步执行
  3. 执行 into 指令
  4. 执行 int 指令

    为了让 CPU 知道是由什么原因造成的内中断,每个中断都有一个 中断类型码,对于上面列举的四种类型的中断,它们的中断类型码分别为:

中断类型 中断类型码
除法错误 0
单步执行 1
执行 into 指令 4
执行 int 指令 int 指令的格式为 int n,n 为字节型立即数,n 指定了该条中断指令的中断类型码

2. 中断背后的硬件原理

2.1 基本思路

    CPU 发生中断时,它会拿到中断类型码,然后接下去的工作就是拿着中断类型码去寻找中断处理程序。在 8086 CPU 管理的内存中,0000:0000~0000:03FF 这块 1024 Bytes 的内存单元是用于存储 中断向量表 的。中断向量表的实质就是形成了 "中断类型码 -> 中断处理程序地址 (CS:IP值)" 的映射关系。每一条映射表项占据两个字型单元,高地址存放段地址 (CS),低地址存放段内偏移地址 (IP)。当中断发生时,CPU 通过中断向量表中相应位置存储的表项就能知道用于处理该中断的中断处理程序存储的位置,然后就能实现跳转和处理。

2.2 中断跳转和恢复过程

    在 CPU 收到中断信息后,它会引发下面的流程:

  1. 从 中断信息 中获取 中断类型码
  2. 将标志寄存器的值压入栈 (pushf)
  3. 设置标志寄存器的第 8 位的 TF 和第 9 位的 IF 的值为 0
  4. CS 的内容入栈
  5. IP 的内容入栈
  6. 从 中断向量表 中获取中断处理程序的地址,并进行跳转 (设置 CS 和 IP) [i.e. IP=n*4, CS=n*4+2,其中 n 为中断类型码]

    从上面的过程中我们可以看到,为了实现执行完中断处理程序返回原先正在执行的程序,必须对现有的 CPU 状态进行 现场保护,我们在上面保护了标志位寄存器、CS 和 IP 寄存器。对于其它寄存器的保护,我们将在 中断处理程序 中进行。 

    在 中断处理程序 执行完成后,中断服务程序会调用 iret 指令来恢复现场。iret 指令的作用就是恢复中断前被压栈的 CS, IP 和 标志位寄存器。即相当于汇编指令:

  1. pop IP
  2. pop CS
  3. popf

    在中断处理程序中,我们同样需要和普通的子程序一样,我们同样需要对在程序中使用到的寄存器进行 push 保护和 pop 恢复,这里不再赘述。

3. 中断处理程序 是一个代码段

    总结一下,我们在 中断向量表 中存储的 中断程序地址(CS:IP) 是指向一个代码段的,这样当 CS:IP 切换到这块区域的时候,才能够执行代码。这段代码一直在内存的一段不会被其它应用程序使用的空间中存放,以防随时发生中断可以被调用运行。

    那当我们想在 中断处理程序 中去使用数据和栈的时候,该怎么办呢?答案就是在代码段中声明数据和栈。思考下面的例子:原始的 0 号中断处理程序会在屏幕上打印 "Divide overflow ",我们现在将其替换为显示 "overflow! "。思路很简单,就是编写好新的中断处理程序,然后修改 中断向量表 中 0 号中断指向的位置就可以了。我们在下面的程序中展示了新的 0 号中断处理程序 和 中断程序的安装代码。注意到我们把新的 中断处理程序安装在了系统不会使用的 0000:0200~02FF 这段空间中,实际上这段空间是用于存放 中断向量的,由于系统并没有那么多的中断向量,因此我们可以暂时使用这段没被使用的内存,但是注意到这样的做法在实际中是不允许的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
assume cs:code

code segment
start: # 程序安装
# (1) 设置 ds:[si] 指向新的中断处理程序的位置
mov ax, cs
mov ds, ax
mov si, offset do0

# (2) 设置 es:[di] 指向中断处理程序的安装位置
mov ax, 0
mov es, ax
mov di, 200h

# (3) 搬运新的中断处理程序
mov cx, offset do0end - offset do0
cld
rep movsb

# (4) 设置中断向量表
mov ax, 0
mov es, ax
mov word ptr es:[0*4], 200h
mov word ptr es:[0*4+2], 0

mov ax, 4c00h
int 21h

# 新的中断处理程序
#===============================================
do0: # 声明数据
jmp short do0start
db "overflow! "

do0start: # 设置 ds:[si] 指向要显示的数据的位置
mov ax, cs
mov ds, ax
mov si, 202h # 这里使用了绝对的物理位置,因为我们已经知道程序要被装入到哪部份内存中去

# 设置 es:[di] 指向显存
mov ax, 0B800h
mov es, ax
mov di, 12*160+36*2

# 将要显示的数据搬运至显存
mov cx, 10
show: mov al, ds:[si]
mov es:[di], al
inc si
add di, 2
loop show

mov ax, 4c00h
int 21h

do0end: nop
#===============================================

code ends

end start

    拷贝过后,我们就发现我们在 0000:0200~02FF 处的内存中放入了如下的指令,初始缺失的空间是用于存放我们在代码段中声明的数据 (i.e. "overflow! ") 和一条 jmp 指令 (i.e. "jmp short do0start"),这里没有做展示。

4. 单步中断

    8086 CPU 的 1 号中断类型码告示了单步中断。单步中断是 8086 CPU 为单步跟踪程序的执行过程提供的实现机制。当 CPU 执行完一条指令之后,如果发现标志寄存器的 TF 位为 1,则引发单步中断,会转去执行 1 号中断处理程序,即引发的中断处理过程如下:

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

    思考我们在 DOSbox 中的 Debug 程序,它的 -t 参数提供了单步执行程序指令的方法。其具体的实现方法如下:

    Debug 在使用 -t 执行某一条指令前,会将 TF 标志位设置为 1,CPU 在执行完该条指令后,会触发单步中断,因此会执行单步中断的 中断处理程序:将所有寄存器中的内容显示在屏幕上,并且等待输入下一条指令。注意到这份中断处理程序是由 Debug 程序提供的。

    同时我们也能理解,为什么中断在转移过程中要把标志寄存器的 TF 位给置 0。如果没有设置为 0 的话,那么进入 1 号中断处理程序后依然会导致单步中断,这样无限循环下去,自然系统将会崩溃了。

5. CPU 也会忽略中断

    在某些情况下,CPU 是不会去相应中断的,这里列举一个例子

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

    当我们使用如上代码设置栈位置的时候,注意到我们设置 SS 寄存器的指令和设置 SP 寄存器的指令是紧邻着的,这样一来,当我们执行完 "mov ss, ax" 指令后,如果在这个之后发生了中断,CPU 是不会响应的,而是会继续向下执行,把 SP 命令执行完。这是因为 SS 寄存器和 SP 寄存器的设置应该被保证为一个原子性的操作,才能够使得栈顶指针是正确的。另外,中断发生的时候是需要将标志寄存器和 CS:IP 寄存器进行压栈操作的,如果栈顶指针错误,那么压栈的操作也会有问题,这样一来系统会发生崩溃。因此,我们在写代码的时候应该利用 CPU 的这样的一种机制,将设置 SS 和 SP 的指令紧邻着写,来保证程序的正确。

6. int 指令引发的中断

    int n 指令用于引发中断类型码为 n 的中断,引发的中断可以是系统自带的中断,也可以是用户的自定义中断。

    我们看下面的代码,在屏幕上显示了 80 个 '!' 符号,其中程序基于 int 指令和 iret 指令,使用中断的方式实现了 loop 的功能。其主要的思路是,在中断发生时,主程序会将当前的 CS:IP 值压入栈中暂存,然后我们在中断中可以通过修改压入栈中的 IP 值,来实现 iret 返回后返回到主程序 loop 的开始的位置 (在 CX 大于 0 的情况下)。具体代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 主程序
code segment
start: mov ax, 0B800H
mov es, ax
mov di, 160*12

mov bx, offset s - offset se # 使用 bx 寄存器来传递 se 处 和 s 处的位移,用于在中断中对栈中的 IP 值进行修改

mov cx, 80

s: mov byte ptr es:[di], '!'
add di, 2
int 7ch
se: nop

mov ax, 4C00H
int 21h
code ends

end start

    7ch 号中断的处理程序如下所示:

1
2
3
4
5
6
7
8
# 7ch 号中断程序
lp: push bp
mov bp, sp # 由于要修改栈中数据,因此初始化 bp 寄存器
dec cx
jcxz lpret
add [bp+2], bx # 修改栈中暂存的 IP 值,使得 iret 返回的时候返回到 loop 开始的位置
lpret: pop bp
iret

7. BIOS 和 DOS 提供的中断例程

7.1 BIOS 和 操作系统提供的中断例程

    在主板的 ROM 上,存放着一套程序,这套程序有一个令人熟知的名字:BIOS (Basic Input/Output System),这套程序包括了:

  1. 硬件系统的检测和初始化程序
  2. 外部中断和内部中断的中断例程
  3. 用于对硬件设备进行 I/O 操作的中断例程
  4. 其它和硬件系统相关的中断例程

    另外,操作系统也提供了一些中断例程。在我们本套文章展示的例子中,我们使用的都是 DOS 系统提供给我们的中断编程资源。

    BIOS 和 操作系统提供的中断例程中,包含了很多的子程序,它们实现了程序员在编程的时候需要用到的很多的功能。因此程序员在编程的时候可以使用 int 指令来直接调用 BIOS 和操作系统提供的中断例程。、并且,在与硬件设备相关的 DOS 中断例程中,一般都调用了 BIOS 的中断例程。

7.2 BIOS 和操作系统中断例程的初始化

    正如我们在 中断处理程序 是一个代码段 中看到的那样,我们要使用一个中断处理程序,必须将中断处理程序安装在内存中某个安全的地方,并且在中断向量表中进行注册。这对于 BIOS 和操作系统提供的中断例程来说,也是一样的。因此,我们现在来关注一下,系统上点之后发生了什么事情:

  1. 开机后,CPU 一加电,初始化 CS = 0FFFFH,IP = 0,自动从 FFFF:0 单元开始执行程序。FFFF:0 处有一条跳转指令,CPU 执行该指令后,转去执行 BIOS 中的硬件系统检测和初始化程序 (i.e. 主板 ROM 上的程序)。
  2. 初始化程序将建立 BIOS 所支持的 中断向量,即将 BIOS 提供的中断例程的入口地址登记在 中断向量表 中。对于 BIOS 所提供的中断例程,只需将它们的入口地址登记在 中断向量表 中即可,因为 中断处理程序 是固化在 ROM 中的,不需要进行安装操作。
  3. 硬件系统检测和初始化完成之后,调用 int 19h 进行操作系统的引导,从此将计算机交由操作系统来控制
  4. 操作系统启动后,除完成其他工作以外,还将它所提供的中断例程安装到内存,并且建立起相应的 中断向量

7.3 BIOS 中断实例

    一个供程序员调用的中断例程中通常包括了多个子程序,中断例程内部使用传递进来的参数来决定执行哪一个子程序。对于 DOS 和 BIOS 来说,它们都是使用 AH 寄存器来存储调用的子程序的编号。

    int 10h 是 BIOS 提供的一个与屏幕输出有关的中断例程,里面包括了多个和屏幕输出相关的子程序,我们在下面来看一个例子:

1
2
3
4
5
6
7
8
9
10
# 选择第 10h 号中断下编号为 2 的子程序,用于设置光标位置
mov ah, 2

# 相关参数设置
mov bh, 0 # 第 0 页
mov dh, 5 # dh 中放行号
mov dl, 12 # dl 中放列号

# 调用 BIOS 10h 中断
int 10h

7.4 DOS 中断实例

    我们在之前的程序中多次用到下面的语句用以返回:

1
2
mov ax, 4C00H # AH: 4CH (子程序编号),AL: 00 (返回值)
int 21h

    到这里读者可能就可以理解,21H 是操作系统提供的一个中断例程。我们这么写的目的是:"调用 21H 号中断例程的 4CH 号子程序,实现程序返回的功能,其中使用 AL 寄存器存储程序的返回值"。