Skip to content

08_中断程序设计

基于 x86 实模式汇编。

一、 端口与 I/O 指令底层交互机制

CPU 无法直接控制外部硬件,必须通过端口 (Port) 与外设进行数据交换。端口是外设控制器(如键盘控制器、中断控制器)内部的寄存器,在独立的 I/O 地址空间中统一编址。

1. IN 指令与 OUT 指令规范

  • IN (Input): 从端口读取数据到 CPU。

    • 语法: IN AL, 端口号 或 IN AX, 端口号。

    • 示例: in al, 60h。从 60h 端口(键盘数据端口)读取当前按下的键码存入 AL 寄存器。

  • OUT (Output): 从 CPU 发送数据到端口。

    • 语法: OUT 端口号, AL 或 OUT 端口号, AX。

    • 示例: mov al, 20h 配合 out 20h, al。向 20h 端口(8259A 中断控制器的命令端口)发送 20h 这个特殊数据(End Of Interrupt, EOI 信号)。

二、 中断向量机制与代码段寻址策略

中断向量表固定在物理内存 00000H 处,占用 1024 字节。中断号 n 的向量(即处理程序的偏移地址 IP 和段地址 CS)保存在 0:[n*4] 的双字内存单元中。

1. old_8h 必须定义在 Code 段内

在编写硬件中断(如 INT 8H 时钟中断)接管程序时,原中断向量通常保存在变量 old_8h 中。该变量必须定义在代码段 (CS) 内,而不能定义在数据段 (DS) 内。

  • 原因: 硬件中断是异步发生的,发生时 CPU 可能正在执行任何进程的代码,此时的 DS 寄存器指向的是被中断进程的数据段,而非中断处理程序的数据段。

  • 寻址方式: 在中断处理函数内部,唯一绝对可靠的段寄存器是 CS。将变量放在代码段,使用 cs:old_8h 强制段前缀访问,能确保无论何时发生中断,都能精准找到原中断向量以实现恢复或中断衔接。

2. 自定义软件中断 (如 INT 80H)

通过直接修改中断向量表,可以将自定义函数绑定到空闲中断号上:

; 截取并替换 INT 80H
mov ax, 0
mov es, ax
mov bx, 80h*4
; 1. 保存原向量
mov ax, es:[bx]
mov cs:old_80h[0], ax   ; 存入CS段内的变量
mov dx, es:[bx+2]
mov cs:old_80h[2], dx
; 2. 写入新向量
mov word ptr es:[bx], offset int_80h
mov word ptr es:[bx+2], cs  ; 覆盖CS地址

三、 硬件中断接管与常驻内存 (TSR)

修改硬件中断(如 INT 8 时钟,INT 9 键盘)向量时,必须遵循严格的原子操作与硬件响应规范。

1. 中断修改的安全规范 (CLI / STI)

修改中断向量表时分为修改偏移量和修改段地址两步。如果在两步之间触发了该中断,CPU 将跳转到极其危险的错误地址。因此,必须使用 CLI 清除 IF 标志位(屏蔽硬件中断),修改完成后再 STI 恢复。

2. EOI 与 中断衔接 (Interrupt Chaining)

  • 直接返回 (发 EOI): 接管硬件中断后,若不调用原中断程序,必须手动向硬件发送 EOI 信号,否则中断控制器将被阻塞,不再发送后续同级或低级中断。

    mov al, 20h      ; 20h 为 EOI 命令
    out 20h, al      ; 发送给 20h 端口 (中断控制器主片)
    
  • 中断衔接 (推荐): 直接跳转到系统原中断处理程序,由原程序负责发送 EOI 并完成系统级更新(如更新系统时间)。

    jmp dword ptr cs:[old_8h]
    

3. 内存驻留退出 (INT 21H AH=31H)

如某锁血外挂程序。接管时钟中断并在后台周期性修改游戏内存数据,主程序不能用 4CH 退出,必须用 31H 驻留内存。(DOS是单线程的,不能同时运行外挂程序和游戏程序,要防止后续运行的游戏程序覆盖外挂程序原本占用的内存)。

  • AH=62H, INT 21H:获取当前程序的 PSP(程序段前缀)段地址,存储于 BX。

  • AH=31H, INT 21H:保留指定大小的内存(以 DX 为节,1 节=16字节)并退出。计算方法需包含代码段长度 + 100H(PSP大小),加上 0Fh 后右移 4 位,进行按节向上取整。

驻留内存的程序(外挂)在后台运行时,如何安全、准确地修改目标进程(如游戏)的内存?

  • PSP 段定位: 通过 INT 21H (AH=62H) 可以获取当前正在运行的任意进程的 PSP(程序段前缀)段地址,该地址是进程数据区的基址。

  • 特征码校验: 为了防止在非目标进程执行时引发内存越界或系统崩溃,必须先通过偏移地址读取特定内存(如 ds:[985h]),比对特征码(即二进制文件中已知的特定字节)。

  • 动态修改: 只有特征码匹配,确认当前是目标游戏进程时,才通过特定的偏移量(如 ds:[1809h])去锁定或修改内存数据(如生命值等)。

代码实现:

   mov ah, 62h
   int 21h             ; BX = 当前活动进程的 PSP 段址
   mov ds, bx
   cmp byte ptr ds:[985h], 48h  ; 校验特征码,确认是否为目标游戏
   jne skip
game_is_running:
   mov byte ptr ds:[1809h], 0Bh ; 确认无误,锁定生命值

4. 时钟中断的典型应用:延时控制

由于 INT 8H 时钟中断每秒固定触发约 18.2 次,程序可以通过接管该中断来实现不依赖 CPU 频率的绝对精准延时。

  • 实现机制: 在数据段或代码段定义一个全局变量(如 ticks)。在主程序中将其设置为目标延迟次数(如延迟 1 秒设为 18)。

  • 中断处理逻辑: 自定义的中断函数 int_8h 每次执行时,检查 ticks,若大于 0 则将其减 1。

  • 主程序阻塞等待: 主程序通过 cmp [ticks], 0 并在不为 0 时循环等待(jne wait_a_while),从而实现精准的异步延时,且不影响 CPU 响应其他底层硬件中断。

代码示例:

delay_1s:
   mov [ticks], 18       ; 设置计数器为 18 次 (~1秒)
wait_a_while:
   cmp [ticks], 0        ; 每次中断发生后,ticks 会被 int_8h 减 1
   jne wait_a_while      ; 只要不为 0 就死循环等待
   ret

四、 键盘底层中断设计 (INT 9H) 详解

键盘通过 60h 端口直接向 CPU 传送硬件扫描码,每次按键状态变化都会触发 INT 9H。

1. 扫描码的按键状态编码 (Make/Break Code)

键盘每次按键会产生两个编码:按下时产生通码 (Make Code),松开时产生断码 (Break Code)。

  • 按下 (最高位为 0): 例如 A 键按下时为 1Eh (即二进制 0001 1110)。

  • 抬起 (最高位为 1): 断码 = 通码 + 80h。例如 A 键抬起时为 9Eh (即二进制 1001 1110)。

2. 键盘编码上限与前缀扩充码

由于 1 字节 8 位最多表示 256 个状态,扣除一半用于断码,原生扫描码最多只能表示 128 个物理按键 (2^8 / 2 = 128)。

随着键盘按键增多(如引入多媒体键、右侧 Alt/Ctrl 等),128 个编码不够用。为此,键盘硬件允许某些键共享基础编码,但使用前缀扩充码 (Prefix Code)进行区分。

  • 扩充码占多个字节,最常见的前缀是 E0h 和 E1h。

  • 处理逻辑: 当 IN AL, 60h 读到 E0h 时,表示这是一个扩充键序列的开始,下一个触发 INT 9 传来的才是真实键码。例如,右 Alt 键按下会触发两次中断,先后发送 E0h 和 38h(左 Alt 仅发送 38h)。

3. 扫描码的可视化输出策略

在接管 INT 9H 后,获取的 AL 寄存器内容是原始的二进制扫描码,无法直接通过 INT 21H 打印,必须手动将其拆分为高 4 位和低 4 位,映射到 ASCII 字符集。

  • XLAT 查表指令: 汇编中使用 XLAT 指令(查表翻译)是最高效的做法。预先定义好 16 进制字符表 hex_tbl db '0123456789ABCDEF'。

  • 位运算拆解:

    • 将 AL 右移 4 位(shr al, 4)得到高位,作为偏移量加到 BX(基址表),利用 XLAT 转换为对应的 ASCII 码。

    • 将原 AL 与 0Fh 做按位与(and al, 0Fh)获取低位,再次查表。

  • 底层视频中断调用: 拼接好字符后,通过 INT 10H (AH=0EH) 将其输出到屏幕。

五、 单步调试中断 (INT 1H) 机制分析

INT 1H 是调试器(如 Debug、OllyDbg 等)实现代码单步步过的基石,由硬件的 TF (Trap Flag,陷阱标志位) 直接驱动。

1. 触发逻辑与插入模式

当标志寄存器 FL 中的 TF = 1 时,CPU 进入单步模式。

  • 插入模式机制: 若在某条指令执行前 TF=1,那么在该条指令执行后,CPU 硬件会强制自动插入一条 INT 1 中断调用。

  • 防止递归防线: CPU 进入 INT 1H 处理程序内部时,硬件会自动将 IF 和 TF 标志位清零。这非常关键,否则 INT 1H 处理程序内的第一条指令执行后又会触发 INT 1H,导致无限递归堆栈溢出。中断结束执行 IRET 弹出原标志寄存器时,自动恢复 TF=1 状态。

2. 调试器的接管套路

调试器会修改中断向量表 0:[4],将 INT 1 指向调试器内部的控制函数。

  • 开启单步: 由于无法直接修改 TF 位,必须通过堆栈周转修改。

    pushf
    pop ax
    or ax, 100h  ; 100h = 0000 0001 0000 0000B, 即将第 8 位 TF 置 1
    push ax
    popf         ; 从此处起 TF=1
    
  • 应用场景(如指令动态加解密): 通过 INT 1H 在每条指令执行后触发中断,中断处理函数通过提取堆栈中保存的 CS:IP(指向下一条即将执行的指令),去动态修改下一条指令的机器码,使其恢复成正常指令并执行,执行完后再将其破坏。这是一种极强的软件保护(反汇编/反调试)手段。