概述

上一篇《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 关键字告诉编译器:

  1. 函数入口处自动保存所有通用寄存器(AX、BX、CX、DX、SI、DI、BP、DS、ES)
  2. 函数出口处自动恢复这些寄存器
  3. IRET 指令返回,而不是普通函数的 RETIRET 会同时恢复标志寄存器 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, &regs, &regs);
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 不可重入)
  • 不能用 mallocprintf 等依赖 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 位实模式下。


小结

自定义中断程序的核心要点:

  1. 软件中断(INT n)用 INT 60h-66h 区间,自己设计调用约定,风险可控
  2. 硬件中断(IRQ)必须严格遵守 8259 PIC 的 EOI 协议,否则会阻塞后续中断
  3. 中断处理程序里避免调用 DOS 服务和标准库函数
  4. 退出时的清理顺序:先关硬件触发,再关设备中断使能,最后恢复向量
  5. DJGPP 下要额外处理内存锁定问题,避免缺页异常导致死锁

相关阅读