汇编:外设连接与中断

一、外设连接基础(8086 体系)

1. 外设与 CPU 的连接方式

CPU 通过地址总线、数据总线、控制总线与外设交互,核心方式有两种:

  • 端口映射(I/O 映射):外设占用独立的 I/O 地址空间(8086 为 0000H~FFFFH),通过IN/OUT指令访问;
  • 内存映射:外设寄存器映射到内存地址,通过内存访问指令(MOV)操作。

8086 中主流采用端口映射,例如:

  • 输入指令:IN AL, 端口号(8 位端口)、IN AX, 端口号(16 位端口);
  • 输出指令:OUT 端口号, AL(8 位)、OUT 端口号, AX(16 位)。

2. 外设的编址方式

编址方式特点指令
独立编址(I/O 映射)地址空间与内存分离,专用指令IN/OUT
统一编址(内存映射)外设 = 内存单元,通用内存指令MOV

二、中断机制核心

中断是外设主动向 CPU 发起的 “请求”,让 CPU 暂停当前程序,优先处理外设任务(如键盘输入、串口数据接收)。

1. 中断分类(8086)

类型触发方式例子
内部中断(软中断)指令触发(如 INT n)或 CPU 异常除法错误(0 号)、INT 21H(DOS 功能调用)
外部中断(硬中断)外设硬件信号触发键盘中断(IRQ1)、定时器中断(IRQ0)

2. 中断向量表(IVT)

8086 在内存 00000H~003FFH(共 1KB)建立中断向量表,每个中断占 4 字节(2 字节偏移 + 2 字节段地址),对应中断号 0~255。

中断号 n 的向量地址n × 4(偏移)、n × 4 + 2(段地址)。

3. 外部中断处理流程

  1. 外设向 CPU 发送INTR(可屏蔽中断)或NMI(不可屏蔽中断)信号;
  2. CPU 响应INTR需满足:IF=1(开中断)、当前指令执行完毕;
  3. CPU 读取中断类型号 n;
  4. 保护现场:压栈 FLAGS、CS、IP;
  5. 关中断(IF=0),防止嵌套;
  6. 从 IVT 读取中断服务程序(ISR)的段地址和偏移(IP)=(N*4),CS=(n*4+2),跳转到 ISR;
  7. ISR 执行:处理外设请求、恢复现场;
  8. 开中断(STI),执行IRET(恢复 FLAGS、CS、IP),返回原程序。

8086CPU,PC机键盘的处理过程

8086CPU 对 PC 机键盘的处理是一个从硬件触发到软件解析的分层过程,核心依赖扫描码生成9 号硬件中断响应BIOS 中断处理缓冲区管理四大环节,最终将键盘输入转化为程序可识别的 ASCII 码或控制信号。

一、扫描码的生成与传输

键盘内部的扫描芯片会实时检测按键的按下 / 松开状态,生成扫描码(1 字节)并通过 I/O 端口60H传输给 8086CPU。

  • 通码:按键按下时生成,最高位为 0(如按下A键的通码是1EH)。
  • 断码:按键松开时生成,最高位为 1(如松开A键的断码是9EH,由通码1EH | 0x80得到)。
  • 扫描码通过键盘控制器写入 8086 的60H端口后,键盘会向 CPU 发送9 号可屏蔽中断请求,触发后续处理。

二、9 号硬件中断的响应与处理

8086CPU 通过中断向量表找到 9 号中断的服务程序入口,执行键盘中断处理逻辑,步骤如下:

  1. 中断响应条件:CPU 的中断标志位IF=1(开中断),否则忽略中断请求。
  2. 保存现场:CPU 自动保存当前CSIPFLAGS寄存器值到栈中,保护程序执行上下文。
  3. 读取扫描码:中断服务程序从60H端口读取扫描码,区分通码 / 断码。
  4. 键盘应答:向61H端口写入控制信号,告知键盘已接收扫描码(先置最高位为 1,再复位为 0)。
  5. 扫描码转 ASCII 码:通过 BIOS 内置的扫描码 - ASCII 码映射表(如scantab),将字符键的扫描码转换为 ASCII 码;功能键(如 Shift、Ctrl)则更新键盘状态字(存储在40:17H内存单元)。

三、BIOS 键盘缓冲区的管理

8086 将处理后的键盘数据存入BIOS 键盘缓冲区(位于40:1EH,共 15 个输入项,每项 2 字节):

  • 高位字节存储扫描码,低位字节存储 ASCII 码。
  • 缓冲区采用循环队列结构,通过bufpt1(读指针)和bufpt2(写指针)管理读写,满时丢弃新输入,空时等待按键。

四、应用程序读取键盘输入

程序通过BIOS 16H 中断DOS 21H 中断从缓冲区读取输入,常见方式:

  1. BIOS 16H 中断(00H 号功能):阻塞等待按键,返回时AL=ASCII码AH=扫描码

    asm

  • mov ah, 00H
    int 16H  ; AL=字符,AH=扫描码
    
  • DOS 21H 中断(07H/08H 号功能):读取字符但不回显,08H 还会检测 Ctrl+C。
  • 直接访问缓冲区:通过操作40:1EH的缓冲区指针,直接读取未处理的键盘数据。
assume cs:code  ; 声明cs寄存器关联code段
stack segment   ; 定义栈段,大小128字节
    db 128 dup(0)  ; 分配128字节空间并初始化为0
stack ends

code segment    ; 代码段开始
start:          ; 程序入口标签
    ; 初始化栈寄存器:ss指向stack段,sp指向栈顶(128)
    mov ax,stack
    mov ss,ax
    mov sp,128
    ; 定位显存:8086文本模式下,显存起始地址为0B800H(es指向显存段)
    mov ax,0B800H
    mov es,ax
    mov ah,'a'   ; 初始显示字符为'a'(ah暂存字符)

s:  ; 循环标签:显示字符并更新
    ; 显存偏移计算:160*12(第12行起始) + 40*2(第40列,每个字符占2字节)
    mov es:[160*12+40*2],ah  
    call delay   ; 调用延时函数,让字符显示可见
    inc ah       ; 字符自增(a→b→...→z)
    cmp ah,'z'   ; 比较是否到'z'
    jna s        ; 若未超过'z'(≤),跳回s继续循环
    mov ax,4c00h
    int 21h  ; DOS中断:程序正常退出(4c号功能)
delay:  ; 延时函数:通过空循环消耗CPU时间实现延时
    push ax  ; 保护现场:保存ax、dx(函数内修改的寄存器)
    push dx
    mov dx,10h  ; 延时计数器高位(dx=16)
    mov ax,0    ; 延时计数器低位(ax=0)
s1: ; 内层循环:ax-1,dx借位,直到dx:ax全0
    sub ax,1    ; ax = ax - 1
    sbb dx,0    ; dx = dx - 借位(sub ax,1若溢出则借位=1)
    cmp ax,0    ; 判断ax是否为0
    jne s1      ; 若ax≠0,继续s1
    cmp dx,0    ; 判断dx是否为0
    jne s1      ; 若dx≠0,继续s1
    pop dx      ; 恢复现场:还原dx、ax
    pop ax
    ret         ; 函数返回
code ends       ; 代码段结束
end start       ; 汇编结束,指定程序入口为start标签

关键理解delay函数:

1、JNA 指令详解
  • 助记符JNA label
  • 含义:如果无符号数比较结果为“不大于”(即小于或等于),则跳转。
  • 等价条件CF = 1 或 ZF = 1
    (CF:进位标志;ZF:零标志)
  • 适用场景:用于无符号整数的比较判断。

💡 JNA 常与 CMP 指令配合使用。例如:

CMP AL, BL
JNA target   ; 如果 AL ≤ BL(无符号),则跳转到 target
2、JNE指令详解

助记符(Mnemonic)

JNE


含义(Meaning)

“Jump if Not Equal” —— 如果不相等则跳转。

该指令根据零标志位(ZF, Zero Flag)的状态决定是否跳转:

  • 当 ZF = 0(即上一次比较或算术/逻辑操作的结果不为零)时,发生跳转。
  • 当 ZF = 1(结果为零)时,不跳转,顺序执行下一条指令。

等价条件(Equivalent Conditions)

JNE 与以下指令完全等价(在8086中可互换使用):

  • JNZ(Jump if Not Zero)

两者在机器码层面是同一个指令,只是助记符不同,用于提高代码可读性。


适用场景(Typical Use Cases)

  • 循环控制
    在循环中判断计数器是否为零,若不为零则继续循环。

    mov cx, 5
    loop_start:
        ; 循环体
        dec cx
        jne loop_start    ; cx 减到 0 时 ZF=1,停止跳转
3、JB指令详解

助记符(Mnemonic)

JB


含义(Meaning)

JB 是 "Jump if Below" 的缩写,表示“如果低于则跳转”。
它用于无符号数比较后的条件跳转:当目的操作数(通常是被减数)小于源操作数(减数)时发生跳转。

该指令根据 CF(Carry Flag,进位标志) 判断是否跳转:

  • 如果 CF = 1,则跳转;
  • 如果 CF = 0,则不跳转。

注意:JB 与 JNAE、JC 是完全等价的指令,只是助记符不同,适用于不同语义场景。


等价条件(Equivalent Conditions / Synonyms)

  • JB label
  • JNAE label(Jump if Not Above or Equal)
  • JC label(Jump if Carry)

这三条指令在机器码层面是完全相同的,都检测 CF=1。


适用场景(Typical Use Cases)

  1. 无符号数比较后跳转

    CMP AX, BX    ; 比较 AX 和 BX(AX - BX)
    JB  less      ; 若 AX < BX(无符号),则跳转到 less

    此处“<”指无符号小于,例如 AX=5, BX=10 → 跳转;AX=0FFFFh, BX=1 → 也跳转(因为 65535 > 1 不成立?错!实际上 0FFFFh 是 65535,比 1 大,所以不跳转。但如果 AX=1, BX=0FFFFh,则 1 < 65535,会跳转。但注意:CMP AX, BX 执行的是 AX - BX,若 AX < BX(无符号),则借位 → CF=1 → JB 跳转。)

4、解读delay函数的延时原理

要彻底理解这段代码中delay函数的延时原理,需要从8086 寄存器结构减法指令的借位规则循环耗时逻辑三个维度拆解,以下是逐层深入的解析:

4.1、核心前提:8086 的寄存器与指令基础

  1. 寄存器范围:8086 是 16 位处理器,ax/dx都是 16 位通用寄存器,单独使用时最大表示0~FFFFH(65535);若将dx作为高位、ax作为低位,可拼接成一个32 位的虚拟寄存器dx:ax),表示范围0~FFFFFFFFH(4294967295)。
  2. 关键指令
    • sub ax, 1:对ax做减法(ax = ax - 1),若ax原本是 0,减 1 后会溢出(变成FFFFH),同时 CPU 的借位标志位(CF) 会被置 1(表示有借位);否则 CF=0。
    • sbb dx, 0:带借位减法(dx = dx - 0 - CF),即仅当 CF=1 时,dx才减 1,否则不变。
    • 标志位:CF 是 CPU 的状态标志,记录减法 / 加法的进位 / 借位,是实现多寄存器联动递减的核心。

4.2、delay 函数的执行流程(逐行拆解)

先回顾核心代码:

delay:
    push ax
    push dx
    mov dx,10h  ; dx = 16 (0010H)
    mov ax,0    ; ax = 0
s1:
    sub ax,1    ; ax自减1,触发CF变化
    sbb dx,0    ; dx根据CF自减(仅借位时减1)
    cmp ax,0    ; 判断ax是否为0
    jne s1      ; ax≠0 → 继续循环
    cmp dx,0    ; ax=0后,判断dx是否为0
    jne s1      ; dx≠0 → 继续循环
    pop dx
    pop ax
    ret

步骤 1:初始化计数器(dx:ax = 0010_0000H)

mov dx,10h + mov ax,0 相当于给 32 位虚拟寄存器dx:ax赋值为 10h * 65536 + 0 = 16*65536 = 1048576(十进制)。这个值是延时的 “总步数”—— 函数的核心是让dx:ax1048576递减到0,每一次递减对应一次s1循环,直到dx:ax全 0 时停止。

步骤 2:s1 循环的递减逻辑(核心)

sub ax,1 + sbb dx,0 是实现 32 位计数器递减的关键,我们分两种场景分析:

场景sub ax,1 结果CF 标志sbb dx,0 结果dx:ax 整体变化
ax ≠ 0ax = ax-1CF=0dx 不变dx:ax = dx:ax - 1
ax = 0(需借位)ax = FFFFHCF=1dx = dx - 1dx:ax = dx:ax - 1(等价于:高位 dx 减 1,低位 ax 置为最大值 FFFFH)

举个具体例子:

  • 初始:dx=10h, ax=0dx:ax=100000H
  • 第 1 次循环:sub ax,1 → ax=0-1=FFFFH,CF=1;sbb dx,0 → dx=10h - 0 -1=0FH;此时dx:ax=0F_FFFFH(即100000H -1 = 0FFFFFH)。
  • 第 2 次循环:sub ax,1 → ax=FFFFH-1=FFFEH,CF=0;sbb dx,0 → dx=0FH(不变);此时dx:ax=0F_FFFEH(即0FFFFFH -1 = 0FFFEH)。
  • ...(重复此过程,直到 ax 从 FFFEH 递减到 0)
  • 当 ax 再次回到 0 时:sub ax,1 → ax=FFFFH,CF=1;sbb dx,0 → dx=0FH-1=0EH;此时dx:ax=0E_FFFFH

这个过程本质是:dx:ax作为一个整体,从100000H开始,每次减 1,直到dx=0ax=0

步骤 3:循环终止条件

  • cmp ax,0 + jne s1:只要 ax≠0,就继续循环(此时 dx 还没开始减,因为 CF=0);
  • 当 ax=0 后,执行cmp dx,0 + jne s1:若 dx≠0,说明还没减完,跳回s1继续递减;
  • 只有当dx=0ax=0时,才会退出s1循环,执行popret返回主程序。

4.3、延时的本质:消耗 CPU 时钟周期

8086 处理器的执行速度由时钟频率决定(比如早期 8086 是 4.77MHz,即每秒执行 477 万次时钟周期)。

每个指令的执行都需要固定的时钟周期:

  • sub ax,1:2 个时钟周期;
  • sbb dx,0:2 个时钟周期;
  • cmp ax,0:2 个时钟周期;
  • jne s1:若跳转成功,3 个时钟周期;否则 2 个。

s1循环的每一次迭代(递减 1)大约需要 2+2+2+3=9 个时钟周期(跳转成功的情况),而整个delay函数需要执行 1048576 次这样的迭代(因为 dx:ax 从 100000H 递减到 0),总耗时为:

总时钟周期 ≈ 1048576 * 9 = 9437184 个时钟周期

若 CPU 频率为 4.77MHz(4770000 次 / 秒),则延时时间≈ 9437184 / 4770000 ≈ 2 秒(实际会因指令流水线、内存访问略有偏差)。

运行结果如下:

a->b->c->.......x->y->z一直这样交替显示。


按下ESC键后改变显示的颜色

;在屏幕中间交替显示a,b,c,d,e,f......x,y,z
;按下ESC键后改变显示的颜色
assume cs:code
stack segment
	db 128 dup(0)
stack ends

data segment
	dw 0,0
data ends

code segment
	start:
	mov ax,stack
	mov ss,ax
	mov sp,128
	mov ax,data
	mov ds,ax		;将数据段首地址放入DS中
	
	;改中断例程入口地址
	mov ax,0
	mov es,ax
	push es:[9*4]
	pop ds:[0]
	push es:[9*4+2]
	pop ds:[2]		;目前DS[0]和DS[2]存放的是int9原始的中断指令CS:IP,共四个字节
	mov word ptr es:[9*4],offset int9		;自定义的中断程序
	mov es:[9*4+2],cs
	
	;显示字符a-z
	mov ax,0B800H
	mov es,ax
	mov ah,'a'
s:	mov es:[160*12+40*2],ah
	call delay
	inc ah
	cmp ah,'z'
	jna s 
	mov ax,0
	mov es,ax
	
	;恢复原来的地址
	push ds:[0]
	pop es:[9*4]
	push ds:[2]
	pop es:[9*4+2]
	
	mov ax,4c00h
	int 21h
	
	;定义延时函数
delay:
	push ax
	push dx
	mov dx,10h
	mov ax,0
s1:	sub ax,1
	sbb dx,0
	cmp ax,0
	jne s1
	cmp dx,0
	jne s1
	pop dx
	pop ax
	ret
	
	;定义中断例程
int9:
	push ax
	push bx
	push es
	in al,60h	;读取键盘端口扫描码
	
	pushf
	pushf
	pop bx
	and bh,11111100B
	push bx
	popf
	call dword ptr ds:[0]	;调用保存在DS:[0]和DS:[2]中的原始int9的CS:IP指令
	
	;ESC扫描码1
	cmp al,1
	jne int9ret
	;改变颜色
	mov ax,0B800H
	mov es,ax
	inc byte ptr es:[160*12+40*2+1]
	
int9ret:
	pop es
	pop bx
	pop ax
	iret
	
code ends
end start

关键知识点总结:

一、pushf指令

在 Intel 8086 CPU 中,PUSHF(Push Flags)是一条用于将标志寄存器(FLAGS)的内容压入堆栈的指令。


1、作用
  • 将当前的 16 位 FLAGS 寄存器 的内容压入堆栈。
  • 堆栈指针 SP 减 2(因为 8086 是 16 位 CPU,压入一个字 = 2 字节)。
  • 常用于在中断处理、子程序调用前保存处理器状态,以便后续恢复(通常配合 POPF 使用)。

2、FLAGS 寄存器结构(8086)

8086 的 FLAGS 是 16 位寄存器,但只使用了其中 9 位:

位位置标志名含义
0CF进位标志(Carry Flag)
2PF= 奇偶标志(Parity Flag)
4AF辅助进位标志(Auxiliary Carry Flag)
6ZF零标志(Zero Flag)
7SF符号标志(Sign Flag)
8TF陷阱标志(Trap Flag,单步调试)
9IF中断允许标志(Interrupt Enable Flag)
10DF方向标志(Direction Flag,用于字符串操作)
11OF溢出标志(Overflow Flag)

其余位(1, 3, 5, 12–15)在 8086 中未定义或保留(通常为 0)。

二、为什么有两个pushf pushf?

在这段 int9 中断例程中,连续两次pushf为了安全地修改标志寄存器(FLAGS)的 IF/TF 位,核心目的是避免调用原 int9 中断时触发嵌套中断(栈溢出)。下面分步骤拆解这个设计的底层逻辑:

先明确核心目标

自定义 int9 例程中需要调用 “原 int9 中断例程” 来保留键盘原生功能,但直接调用会有风险:

  • int9 是硬件中断,CPU 响应时会自动置IF=0(关中断);
  • 而手动用call调用原 int9 时,IF可能仍为 1(开中断),若此时再次触发键盘中断,会递归调用 int9,导致栈溢出崩溃。

因此必须在调用原 int9 前,临时将 FLAGS 的 IF 位(中断允许位)和 TF 位(单步跟踪位)清零,调用后再恢复。但 FLAGS 是寄存器,无法直接修改,只能通过 “栈中转” 实现 —— 这就是两次pushf的核心原因。

两次 pushf 的逐行拆解

先回顾完整代码片段:

asm

pushf               ; 第1次pushf
pushf               ; 第2次pushf
pop bx              ; 把栈中FLAGS值弹出到bx
and bh,11111100B    ; 清空IF(位9)、TF(位8)
push bx             ; 把修改后的FLAGS压栈
popf                ; 恢复FLAGS(此时IF=0、TF=0)
call dword ptr ds:[0] ; 调用原int9

第一步:第 1 次pushf—— 保存 “原始 FLAGS”

pushf会把当前 CPU 的 FLAGS 寄存器值压入栈,这一步是为了后续恢复原 FLAGS 状态(但此时还没用到,先存起来)。

注:这一步是 “预留保护”,确保调用完原 int9 后,FLAGS 能回到进入自定义 int9 时的初始状态(不过原代码中省略了 “恢复第 1 次 pushf 的 FLAGS”,但不影响核心逻辑,因为 iret 会自动恢复 FLAGS)。

第二步:第 2 次pushf—— 获取 FLAGS 副本用于修改

第二次pushf将同一 FLAGS 值再次压栈,目的是:

  • 得到一个 FLAGS 的 “可修改副本”(栈中的值);
  • 后续通过pop bx将副本读到 bx 寄存器,才能用指令修改 IF/TF 位(FLAGS 寄存器无法直接用and等指令操作)。

三、iret的作用

iret 指令的核心作用

iret(Interrupt Return)是 8086 汇编中专用于中断返回的指令,核心功能是:恢复中断发生时 CPU 自动保存的 IPCSFLAGS 寄存器,回到被中断的程序继续执行

它是中断处理例程(如自定义 int9)的 “收尾指令”,区别于普通子程序的 ret(仅恢复 IP),是中断例程能正确返回的关键。

1、iret 的执行流程(底层逻辑)

当硬件中断(如 int9)触发时,CPU 会自动完成 3 步压栈操作(保护现场):

plaintext

push FLAGS  → 保存标志寄存器
push CS     → 保存当前代码段地址
push IP     → 保存当前指令偏移地址

随后跳转到中断例程执行。

iret 会逆向完成这 3 步(恢复现场):

  1. pop IP → 从栈中弹出中断前的 IP,赋值给 IP 寄存器;
  2. pop CS → 从栈中弹出中断前的 CS,赋值给 CS 寄存器;
  3. pop FLAGS → 从栈中弹出中断前的 FLAGS,赋值给标志寄存器;

执行完这三步后,CPU 就回到了 “中断发生前的下一条指令”,继续执行原程序。

2、iret 与普通 ret 的核心区别

指令恢复内容适用场景栈操作次数
ret仅恢复 IP(偏移地址)普通子程序(近调用)1 次 pop
retf恢复 IP + CS跨段子程序(远调用)2 次 pop
iret恢复 IP + CS + FLAGS所有中断处理例程3 次 pop

⚠️ 关键:中断例程必须用 iret,不能用 ret/retf—— 否则会遗漏 FLAGS 的恢复,导致原程序的标志寄存器(如 IF、ZF 等)被破坏,程序逻辑混乱甚至崩溃。

3、在自定义 int9 例程中,iret 的具体作用

结合之前的 int9 例程代码:

asm

int9:
    push ax/bx/es  ; 手动保护通用寄存器
    in al,60h
    ; ... 调用原int9、检测ESC键、修改颜色 ...
int9ret:
    pop es/bx/ax   ; 手动恢复通用寄存器
    iret           ; 中断返回核心指令

iret 在这里的作用拆解:

  1. 恢复原程序的执行流:当按下键盘触发 int9 中断时,CPU 会暂停 “显示 a-z 的循环代码”,跳转到自定义 int9 例程。执行 iret 后,CPU 回到暂停的循环代码(如 call delayinc ah 处),继续显示字符。
  2. 恢复标志寄存器(关键):中断例程中可能修改 FLAGS(如 cli/sti 改 IF 位,cmp 改 ZF 位),iret 会恢复中断前的 FLAGS,确保原程序的标志位不受影响。
  3. 恢复中断允许状态:硬件中断触发时 CPU 会自动关中断(IF=0),iret 恢复 FLAGS 时会还原 IF 位(通常是 1,开中断),保证后续能正常响应新的中断。

4、iret 的执行前提(栈必须对齐)

iret 要求栈顶必须是 “中断时压栈的 FLAGSCSIP”,因此中断例程中:

  • 手动压栈的寄存器(如 axbxes)必须在 iret 前全部 pop 恢复;
  • 若栈中多 / 少压栈,iret 会弹出错误的值到 IP/CS,导致程序跳转到非法地址崩溃。

posted @ 2025-12-10 23:12  chenlight  阅读(5)  评论(0)    收藏  举报  来源