汇编:外设连接与中断
一、外设连接基础(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. 外部中断处理流程
- 外设向 CPU 发送
INTR(可屏蔽中断)或NMI(不可屏蔽中断)信号; - CPU 响应
INTR需满足:IF=1(开中断)、当前指令执行完毕; - CPU 读取中断类型号 n;
- 保护现场:压栈 FLAGS、CS、IP;
- 关中断(IF=0),防止嵌套;
- 从 IVT 读取中断服务程序(ISR)的段地址和偏移(IP)=(N*4),CS=(n*4+2),跳转到 ISR;
- ISR 执行:处理外设请求、恢复现场;
- 开中断(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 号中断的服务程序入口,执行键盘中断处理逻辑,步骤如下:
- 中断响应条件:CPU 的中断标志位
IF=1(开中断),否则忽略中断请求。 - 保存现场:CPU 自动保存当前
CS、IP、FLAGS寄存器值到栈中,保护程序执行上下文。 - 读取扫描码:中断服务程序从
60H端口读取扫描码,区分通码 / 断码。 - 键盘应答:向
61H端口写入控制信号,告知键盘已接收扫描码(先置最高位为 1,再复位为 0)。 - 扫描码转 ASCII 码:通过 BIOS 内置的扫描码 - ASCII 码映射表(如
scantab),将字符键的扫描码转换为 ASCII 码;功能键(如 Shift、Ctrl)则更新键盘状态字(存储在40:17H内存单元)。
三、BIOS 键盘缓冲区的管理
8086 将处理后的键盘数据存入BIOS 键盘缓冲区(位于40:1EH,共 15 个输入项,每项 2 字节):
- 高位字节存储扫描码,低位字节存储 ASCII 码。
- 缓冲区采用循环队列结构,通过
bufpt1(读指针)和bufpt2(写指针)管理读写,满时丢弃新输入,空时等待按键。
四、应用程序读取键盘输入
程序通过BIOS 16H 中断或DOS 21H 中断从缓冲区读取输入,常见方式:
- 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 labelJNAE label(Jump if Not Above or Equal)JC label(Jump if Carry)
这三条指令在机器码层面是完全相同的,都检测 CF=1。
适用场景(Typical Use Cases)
-
无符号数比较后跳转
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 的寄存器与指令基础
- 寄存器范围:8086 是 16 位处理器,
ax/dx都是 16 位通用寄存器,单独使用时最大表示0~FFFFH(65535);若将dx作为高位、ax作为低位,可拼接成一个32 位的虚拟寄存器(dx:ax),表示范围0~FFFFFFFFH(4294967295)。 - 关键指令:
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:ax从1048576递减到0,每一次递减对应一次s1循环,直到dx:ax全 0 时停止。
步骤 2:s1 循环的递减逻辑(核心)
sub ax,1 + sbb dx,0 是实现 32 位计数器递减的关键,我们分两种场景分析:
| 场景 | sub ax,1 结果 | CF 标志 | sbb dx,0 结果 | dx:ax 整体变化 |
|---|---|---|---|---|
| ax ≠ 0 | ax = ax-1 | CF=0 | dx 不变 | dx:ax = dx:ax - 1 |
| ax = 0(需借位) | ax = FFFFH | CF=1 | dx = dx - 1 | dx:ax = dx:ax - 1(等价于:高位 dx 减 1,低位 ax 置为最大值 FFFFH) |
举个具体例子:
- 初始:
dx=10h, ax=0→dx: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=0且ax=0。
步骤 3:循环终止条件
cmp ax,0+jne s1:只要 ax≠0,就继续循环(此时 dx 还没开始减,因为 CF=0);- 当 ax=0 后,执行
cmp dx,0+jne s1:若 dx≠0,说明还没减完,跳回s1继续递减; - 只有当
dx=0且ax=0时,才会退出s1循环,执行pop和ret返回主程序。
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 位:
| 位位置 | 标志名 | 含义 |
|---|---|---|
| 0 | CF | 进位标志(Carry Flag) |
| 2 | PF | = 奇偶标志(Parity Flag) |
| 4 | AF | 辅助进位标志(Auxiliary Carry Flag) |
| 6 | ZF | 零标志(Zero Flag) |
| 7 | SF | 符号标志(Sign Flag) |
| 8 | TF | 陷阱标志(Trap Flag,单步调试) |
| 9 | IF | 中断允许标志(Interrupt Enable Flag) |
| 10 | DF | 方向标志(Direction Flag,用于字符串操作) |
| 11 | OF | 溢出标志(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 自动保存的 IP、CS、FLAGS 寄存器,回到被中断的程序继续执行。
它是中断处理例程(如自定义 int9)的 “收尾指令”,区别于普通子程序的 ret(仅恢复 IP),是中断例程能正确返回的关键。
1、iret 的执行流程(底层逻辑)
当硬件中断(如 int9)触发时,CPU 会自动完成 3 步压栈操作(保护现场):
plaintext
push FLAGS → 保存标志寄存器
push CS → 保存当前代码段地址
push IP → 保存当前指令偏移地址
随后跳转到中断例程执行。
而 iret 会逆向完成这 3 步(恢复现场):
pop IP→ 从栈中弹出中断前的IP,赋值给IP寄存器;pop CS→ 从栈中弹出中断前的CS,赋值给CS寄存器;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 在这里的作用拆解:
- 恢复原程序的执行流:当按下键盘触发 int9 中断时,CPU 会暂停 “显示 a-z 的循环代码”,跳转到自定义 int9 例程。执行
iret后,CPU 回到暂停的循环代码(如call delay或inc ah处),继续显示字符。 - 恢复标志寄存器(关键):中断例程中可能修改
FLAGS(如cli/sti改 IF 位,cmp改 ZF 位),iret会恢复中断前的FLAGS,确保原程序的标志位不受影响。 - 恢复中断允许状态:硬件中断触发时 CPU 会自动关中断(IF=0),
iret恢复FLAGS时会还原 IF 位(通常是 1,开中断),保证后续能正常响应新的中断。
4、iret 的执行前提(栈必须对齐)
iret 要求栈顶必须是 “中断时压栈的 FLAGS、CS、IP”,因此中断例程中:
- 手动压栈的寄存器(如
ax、bx、es)必须在iret前全部pop恢复; - 若栈中多 / 少压栈,
iret会弹出错误的值到IP/CS,导致程序跳转到非法地址崩溃。

浙公网安备 33010602011771号