概述
上一篇《BIOS中断服务》列举了系统已经提供的各种中断功能号,这些都是"调用别人写好的中断"。这篇讲反过来的事情——自己写一个中断处理程序,挂到中断向量表上,让系统在特定事件发生时执行你的代码。
这是 TSR、驻留程序、硬件驱动、热键工具的共同基础,在《DOS下TSR程序实现原理》中提到的定时器劫持,本质上就是这里要讲的技术在时间关键场景下的应用。
中断的两大类
写自定义中断程序之前,先要分清两类完全不同的中断:
| 类型 |
触发方式 |
例子 |
特点 |
| 软件中断 |
程序主动执行 INT n 指令 |
INT 21h(DOS服务)、INT 10h(BIOS显示)、INT 33h(鼠标) |
可预测,程序想调用才会触发 |
| 硬件中断 |
外部设备通过 8259 PIC 发信号 |
IRQ0(定时器)、IRQ1(键盘)、IRQ6(软盘) |
随时可能发生,不受当前代码控制 |
自定义中断程序可以针对这两类中的任意一种,但编写规则和风险完全不同——软件中断你可以设计自己的调用约定,硬件中断则必须遵守严格的硬件协议(尤其是 EOI 应答),否则会导致系统挂死。
第一部分:编写一个软件中断处理程序
1.1 用 interrupt 关键字
Turbo C 提供了 interrupt 关键字,编译器会自动生成正确的中断入口/出口代码(保存寄存器、用 IRET 而不是普通的 RET 返回):
1
2
3
4
5
6
7
8
|
#include <dos.h>
void interrupt my_service(union INTPACK far *regs) {
// regs 里可以读到调用者传入的 AX/BX/CX/DX 等寄存器值
if (regs->x.ax == 0x0001) {
regs->x.bx = 12345; // 通过修改寄存器返回结果
}
}
|
interrupt 关键字告诉编译器:
- 函数入口处自动保存所有通用寄存器(AX、BX、CX、DX、SI、DI、BP、DS、ES)
- 函数出口处自动恢复这些寄存器
- 用
IRET 指令返回,而不是普通函数的 RET(IRET 会同时恢复标志寄存器 FLAGS)
为什么普通函数不能直接用作中断处理程序? 因为 RET 指令只弹出返回地址,而中断发生时,CPU 除了压入返回地址,还会压入 FLAGS 寄存器。如果用 RET 返回,栈里剩下的 FLAGS 值不会被正确处理,会导致栈错位,程序立即崩溃。
1.2 挂接软件中断向量
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
|
#include <dos.h>
void interrupt (*old_int_60h)();
void interrupt my_int_60h(union INTPACK far *regs) {
switch (regs->x.ax) {
case 0:
printf("功能0被调用\n");
break;
case 1:
regs->x.bx = 0x1234; // 返回一个值
break;
default:
// 不认识的功能号,转发给原处理程序(如果有的话)
(*old_int_60h)();
}
}
int main() {
// INT 60h-66h 是 DOS 保留给用户程序自定义使用的中断号,不会跟系统冲突
old_int_60h = getvect(0x60);
setvect(0x60, my_int_60h);
printf("自定义中断已安装,用 INT 60h 调用\n");
// ...程序主体...
setvect(0x60, old_int_60h); // 退出前必须恢复
return 0;
}
|
中断号的选择很关键:DOS 明确保留了 INT 60h ~ 66h 给用户程序自由使用,不会与 BIOS、DOS 或第三方驱动冲突。自己设计的功能中断应该用这个区间,而不要覆盖已被占用的号(比如 INT 21h、INT 33h)。
1.3 从汇编侧调用自定义中断
1
2
3
|
mov ax, 1 ; 功能号1
int 0x60 ; 调用自定义中断
mov result, bx ; 读取返回值
|
在 C 里调用:
1
2
3
4
|
union REGS regs;
regs.x.ax = 1;
int86(0x60, ®s, ®s);
int result = regs.x.bx;
|
这跟调用 BIOS/DOS 中断的写法完全一样——因为从调用者的角度看,自定义中断和系统中断没有本质区别,都是"往某个中断号发起 INT 指令,用寄存器传参和取值"。
第二部分:编写一个硬件中断处理程序
硬件中断远比软件中断危险,因为它不受当前代码逻辑控制,可能在任意时刻打断正在执行的指令,还必须正确处理 8259 中断控制器的握手协议。
2.1 8259 可编程中断控制器(PIC)
PC 上有两片 8259(主片+从片,级联),负责把 15 个硬件中断请求(IRQ0-IRQ15)路由给 CPU:
| IRQ |
对应中断号 |
设备 |
| IRQ0 |
INT 08h |
系统定时器 |
| IRQ1 |
INT 09h |
键盘 |
| IRQ2 |
INT 0Ah |
级联(连接从片) |
| IRQ3 |
INT 0Bh |
COM2 串口 |
| IRQ4 |
INT 0Ch |
COM1 串口 |
| IRQ6 |
INT 0Eh |
软盘控制器 |
| IRQ7 |
INT 0Fh |
并口(打印机) |
PIC 的两个关键端口:
1
2
|
主片:命令端口 0x20,数据端口 0x21
从片:命令端口 0xA0,数据端口 0xA1
|
2.2 EOI(中断结束)应答的必要性
8259 PIC 在把一个 IRQ 报告给 CPU 后,会记录这个 IRQ 处于"正在处理"状态,直到收到 EOI(End Of Interrupt)信号才会解除这个状态。如果不发 EOI:
- 同一个 IRQ 不会再触发(因为 PIC 认为它还没处理完)
- 更严重:同级或较低优先级的 IRQ 会被永久阻塞(因为 8259 内部按优先级屏蔽)
1
2
3
4
5
6
7
8
9
|
#include <dos.h>
void interrupt my_irq_handler() {
// 处理硬件事件(读端口、更新状态等)
do_hardware_work();
// 必须发送 EOI,否则后续中断被阻塞
outportb(0x20, 0x20); // 0x20 = 非特定 EOI 命令
}
|
从片上的 IRQ(IRQ8-15)需要发两次 EOI——一次给从片,一次给主片(因为从片是通过主片的 IRQ2 级联的):
1
2
3
4
5
|
void interrupt my_irq10_handler() {
do_hardware_work();
outportb(0xA0, 0x20); // 先应答从片
outportb(0x20, 0x20); // 再应答主片
}
|
2.3 中断屏蔽寄存器(IMR)
每个 IRQ 都可以在数据端口(0x21/0xA1)单独开启或屏蔽:
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
|
void enable_irq(int irq) {
unsigned char mask;
if (irq < 8) {
mask = inportb(0x21);
mask &= ~(1 << irq); // 清零对应位=开启
outportb(0x21, mask);
} else {
mask = inportb(0xA1);
mask &= ~(1 << (irq - 8));
outportb(0xA1, mask);
}
}
void disable_irq(int irq) {
unsigned char mask;
if (irq < 8) {
mask = inportb(0x21);
mask |= (1 << irq); // 置1对应位=屏蔽
outportb(0x21, mask);
} else {
mask = inportb(0xA1);
mask |= (1 << (irq - 8));
outportb(0xA1, mask);
}
}
|
2.4 完整示例:自定义串口中断(IRQ4/COM1)
这是一个典型的硬件中断应用——用中断驱动方式接收串口数据,避免轮询浪费 CPU:
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
|
#include <dos.h>
#define COM1_BASE 0x3F8
#define COM1_IER (COM1_BASE + 1) // 中断使能寄存器
#define COM1_LSR (COM1_BASE + 5) // 线路状态寄存器
void interrupt (*old_com1_handler)();
volatile unsigned char rx_buffer[256];
volatile unsigned char rx_head = 0, rx_tail = 0;
void interrupt com1_isr() {
unsigned char data;
// 检查是否有数据到达
if (inportb(COM1_LSR) & 0x01) {
data = inportb(COM1_BASE); // 读取接收到的字节
rx_buffer[rx_head++] = data; // 存入环形缓冲区
}
outportb(0x20, 0x20); // EOI
}
void init_com1_interrupt() {
old_com1_handler = getvect(0x0C); // IRQ4 = INT 0Ch
setvect(0x0C, com1_isr);
outportb(COM1_IER, 0x01); // 使能"数据到达"中断
enable_irq(4); // 在 PIC 层面开启 IRQ4
}
void close_com1_interrupt() {
disable_irq(4);
outportb(COM1_IER, 0x00);
setvect(0x0C, old_com1_handler);
}
|
这个模式对线切割机之类的运动控制场景很有用——如果需要跟 PLC 或伺服驱动器做串口通信,中断驱动的接收比轮询更省 CPU,也更不容易丢字节(轮询的间隙里数据可能被覆盖,中断能保证每个字节到达时立刻被取走)。
第三部分:中断处理程序的通用注意事项
3.1 中断里不能做的事
跟《DOS下TSR程序实现原理》里讲的一致,中断处理程序(不管软件还是硬件触发)都有相同的限制:
- 不能调用 DOS 服务(INT 21h 不可重入)
- 不能用
malloc、printf 等依赖 DOS 的库函数
- 处理时间要尽可能短,尤其是硬件中断,长时间占用会让其他中断排队甚至丢失
- 涉及多字节共享变量时,要考虑主程序读到"写了一半"的数据(用
volatile 声明,必要时关中断保护)
3.2 中断链的正确姿势
不管是软件还是硬件中断,“链式调用"原处理程序的正确顺序是:
1
2
3
4
5
6
7
8
9
10
11
|
void interrupt my_handler() {
// 1. 先做自己的事
do_my_work();
// 2. 再决定是否转发给原处理程序
if (need_original_behavior) {
(*old_handler)(); // 原处理程序里会自己处理 EOI
} else {
outportb(0x20, 0x20); // 自己发 EOI(如果不转发)
}
}
|
千万不要同时发 EOI 又转发给原处理程序——原处理程序内部通常也会发 EOI,如果你之前已经发过一次,会导致 PIC 状态混乱。
3.3 退出前必须清理
自定义中断程序退出(尤其是常驻/TSR 场景)前,必须按相反顺序恢复所有修改过的状态:
1
2
3
|
disable_irq(4); // 1. 先关闭硬件触发
outportb(COM1_IER, 0x00); // 2. 关闭设备侧中断使能
setvect(0x0C, old_com1_handler); // 3. 最后恢复中断向量
|
如果顺序反过来(先恢复向量再关闭硬件),有极小概率在中间的时间窗口里,硬件触发了中断,但向量已经指向了别处(可能是垂悬指针),直接死机。
DJGPP 下的对应实现
32 位保护模式下写中断处理程序的机制不同——不能直接操作实模式中断向量表,需要通过 DPMI 服务。核心函数在 <dpmi.h> 里,《DJGPP下调用中断的方法》已经介绍过基本用法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
#include <dpmi.h>
#include <go32.h>
_go32_dpmi_seginfo old_handler, new_handler;
void my_protected_handler() {
// 保护模式下的处理逻辑
}
void install() {
_go32_dpmi_get_protected_mode_interrupt_vector(0x0C, &old_handler);
new_handler.pm_offset = (int)my_protected_handler;
_go32_dpmi_chain_protected_mode_interrupt_vector(0x0C, &new_handler);
}
|
保护模式下额外的坑:中断处理函数所在的内存页必须被锁定(_go32_dpmi_lock_code),否则硬件中断触发时如果该页刚好被换出,会触发缺页异常,而缺页异常处理本身可能又需要中断环境,造成死锁。这是从 16 位实模式迁移到 DJGPP 时最容易被忽略、也最难调试的问题——这也是为什么运动控制这类对中断时序极度敏感的场景,往往倾向于把关键的 ISR 部分保留在 16 位实模式下。
小结
自定义中断程序的核心要点:
- 软件中断(
INT n)用 INT 60h-66h 区间,自己设计调用约定,风险可控
- 硬件中断(IRQ)必须严格遵守 8259 PIC 的 EOI 协议,否则会阻塞后续中断
- 中断处理程序里避免调用 DOS 服务和标准库函数
- 退出时的清理顺序:先关硬件触发,再关设备中断使能,最后恢复向量
- DJGPP 下要额外处理内存锁定问题,避免缺页异常导致死锁
相关阅读