汇编语言程序设计-6-中断及其应用

6. 中断及其应用

本篇笔记对应课程第六章(下图倾斜),章节划分和教材对应关系如下。


6.0 阶段导学

直接定址表:将数据/代码通过查表的方式排列出来,规定每个区域的作用。编写大型程序时经常用到。
【6.1 移位指令】
【6.2 操作显存数据】
【6.3 描述内存单元的标号】
【6.4 数据的直接定址表】
【6.5 代码的直接定址表】

内中断:介绍由CPU内部所触发的中断,并处理这些中断,如除法错误、调用INT指令等。
【6.6 中断及其处理】
【6.7 编制中断处理程序】
【6.8 单步中断】
【6.9 由int指令引发的中断】
【6.10 BIOS和DOS中断处理】

端口及外设控制:利用中断机制控制CPU的外设,比如显示器、打印机等。
【6.11 端口的读写】
【6.12 操作CMOS RAM芯片】
【6.13 外设连接与中断】
【6.14 PC机键盘的处理过程】
【6.15 定制键盘输入处理】
【6.16 改写中断例程的方法】
【6.17 用中断响应外设】
【6.18 应用:字符串的输入】
【6.19 磁盘读写】
【6.20 让计算机唱歌】

6.1 移位指令-shl/shr/sal/sar/rol/ror/rcl/rcr

图6-1 8种移位指令示意图
  • shl 寄存器,idata逻辑左移:低位补 0,高位进位到 CF。逻辑左移一位相当于乘以2,如 shl al,1
  • shr 寄存器,idata逻辑右移:高位补 0,低位进位到 CF。逻辑右移一位相当于除以2。
  • sal 寄存器,idata算术左移:低位补 0,高位进位到 CF。
  • sar 寄存器,idata算术右移:高位补 最高位,低位进位到 CF。
  • rol 寄存器,idata循环左移:高位进位到 CF 和最低位。
  • ror 寄存器,idata循环右移:低位进位到 CF 和最高位。
  • rcl 寄存器,idata带进位循环左移:将 CF 看作最高位,CF 进位到最低位。
  • rcr 寄存器,idata带进位循环右移:将 CF 看作最低位,最高位进位到 CF。

注:由于硬件电路设置,移动位数大于1时,idata必须用cl。比如 shl al,3不合法,而是要写成 mov cl,3shl al,cl合法。

6.2 操作显存数据-显存缓冲区

  8086CPU的显存地址空间有128K(A0000h~BFFFFh),其中最后的32K空间(B8000h~BFFFFh)是 80*25 彩色字符模式第0页的显示缓冲区。其中每个字符都需要2个字节,高位字节为字符的ASCII码,低位字节为预设的字符属性。我们可以通过直接修改显示缓冲区的数据,来更改屏幕显示内容,显然这是一种非常“直接”地操纵底层的数据显示方式。如下图所示:

图6-2 显示缓冲区的结构

【代码示例】显示4个字符,黑底绿字A、绿底黑字B、蓝底白字C、蓝底高亮D。

assume ds:datasg,cs:codesg

datasg segment
    db 41h,02h,42h,20h,43h,17h,44h,1fh
datasg ends

codesg segment
main:   ; 定义数据段
        mov ax,datasg
        mov ds,ax

        ; 搬移数据到显存缓冲区
        mov ax,0b800h
        mov es,ax   ; 显存缓冲区段地址
        mov si,0
        mov di,0
        mov cx,8  ; 开始循环
     s: mov al,ds:[si]
        mov es:[di],al
        inc si
        inc di
        loop s

        ; 程序退出
        mov ax,4c00h
        int 21h
codesg ends
end main

注:更新显示的第一行会被界面顶上去。所以建议先生成exe文件,然后重开一个DOS沙盒,直接挂载并运行该exe文件。

【代码示例】在屏幕的中间,白底蓝字,显示‘Welcome to masm!’。
注:屏幕大小为 80*25,于是第12行起始位置字节数为 160*12;该行中间位置为第 80 字节,再减去要显示字符长度的一半,就可以在中间显示字符。于是要显示字符起始字节为 160*12+(80-16)

assume ds:datasg,cs:codesg

datasg segment
    db 'Welcome to masm!'
datasg ends

codesg segment
main:   ; 定义数据段
        mov ax,datasg
        mov ds,ax

        ; 搬移数据到显存缓冲区
        mov ax,0b800h
        mov es,ax   ; 显存缓冲区段地址
        mov si,0
        mov di,160*12+80-16
        mov cx,16  ; 开始循环
     s: mov al,ds:[si]
        mov es:[di],al
        inc di
        mov al,71h   ; 白底蓝字
        mov es:[di],al
        inc si
        inc di
        loop s

        ; 程序退出
        mov ax,4c00h
        int 21h
codesg ends
end main

6.3 描述内存单元的标号

  标号不仅可以用来标记“指令”、“段”的起始地址,还可以用来标记“数据”,如下图所示有两种标记方式。其中,“数据标号”的使用可以使得代码更加简洁

图6-3 地址标号和数据标号
  1. 地址标号带冒号,只描述内存单元的地址,并且只能在“代码段”中使用,一般配合 offset使用。
  2. 数据标号不带冒号,同时描述内存单元的地址和单元长度,在“代码段”和“数据段”都能使用。

上右图中,a等价于code:0且长度为“字节”、b等价于code:8且长度为“字”,code为代码段名称,下面是一些指令的等价描述:

  • mov al,a[si]等价于 mov al,cs:0[3]——上右图
  • add b,ax等价于 add code:[8],ax——上右图
  • mov al,a[bx+si+3]等价于 mov al,code:0[bx+si+3]——体现索引
  • mov a,2等价于 mov byte ptr code:[0],2——体现长度为“字节”
  • mov b,2等价于 mov word ptr code:[8],2——体现长度为“字”
  • mov al,b——Error!长度不匹配

下面是一种数据标号的常见用法,就是将标号当作数据来定义,相当于存储了标号的地址,后续可以当作指针来使用。若定义存储长度为 dw,那么存储标号的偏移地址;若定义存储长度为 dd,那么低位存储标号的偏移地址、高位存储标号的段地址:

图6-4 将数据标号当作数据来定义

6.4 数据的直接定址表

  计算机很难进行三角函数的运算,于是便经常将常见结果编写成一张“表”,计算是直接查表即可,这就是“数据的直接定址表”。本质上来看,利用表,在两个数据集合之间建立一种映射关系,用查表的方法根据给出的数据得到其在另一集合中的对应数据。虽然会耗费一些内存,但优点是使得算法清晰和简洁、加快运算速度、使程序易于扩充。下面来看两个代码示例。

【代码示例1-数据标号作为数组首地址】给定一个byte,在屏幕中间以十六进制的白底黑字形式显示。
难点:以表的形式存储0~9、A~F的ASCII码,更加简单,索引就是大小。若直接使用判断分支结构,会非常麻烦。

assume cs:codesg,ss:stacksg

stacksg segment
    dw 16 dup (0)
stacksg ends

codesg segment
main:   mov ax,stacksg
        mov ss,ax
        mov sp,16       ; sp要指向栈底!!
        mov al,2bh      ; 给出要显示的byte
        call show_byte  ; 调用程序显示
        mov ax,4c00h
        int 21h

; 子程序:在屏幕中间显示一个字节
show_byte:
        jmp short show  ; 注意这是子程序的数据定义方式
        char_tb db '0123456789ABCDEF'  ; 数据标号!
show:   ; 用到寄存器压栈
        push ax
        push bx
        push cx
        push es
        ; 将高4位和低4位分开
        mov ah,al
        mov cl,4
        shr ah,cl         ;4->ah
        and al,00001111b  ;4->al
        ; 显示高4mov cx,0b800h  ; 显存的第一页段地址
        mov es,cx
        mov bl,ah  ; 注意索引必须为bx,而不能是ax
        mov bh,0
        mov ah,char_tb[bx]  ; 待显示数据
        mov es:[160*12+80-2],ah
        mov ah,70h          ; 白底黑字
        mov es:[160*12+80-1],ah
        ; 显示低4mov bl,al  ; 注意索引必须为bx,而不能是ax
        mov bh,0
        mov al,char_tb[bx]  ; 待显示数据
        mov es:[160*12+80],al
        mov al,70h          ; 白底黑字
        mov es:[160*12+80+1],al
        ; 寄存器出栈,子程序退出
        pop es
        pop cx
        pop bx
        pop ax
        ret
codesg ends
end main

【代码示例2-数字标号作为指针数组】编写程序,计算sin(x),x∈{0°,30°,60°,90°,120°,150°,180°},并在屏幕中间(位置不用太严谨)以白底黑字的形式显示计算结果。
提示:x的范围是30°的倍数,可以利用这一点当作数据的索引。
难点:直接定址表按字存储,字节单元则按字节检索位置,于是索引要乘以2!

assume cs:codesg,ss:stacksg

stacksg segment
    dw 16 dup (0)
stacksg ends

codesg segment
main:   mov ax,stacksg
        mov ss,ax
        mov sp,16
        mov ax,30  ; 输入要计算的角度
        call sin   ; 调用程序显示
        mov ax,4c00h
        int 21h

; 子程序:计算给定角度的sin值
sin:
        jmp short bn
        sin_tb dw sin0,sin30,sin60,sin90,sin120,sin150,sin180  ; 数据标号作为数据
        sin0   db '0',0
        sin30  db '0.5',0
        sin60  db '0.866',0
        sin90  db '1',0
        sin120 db '0.866',0
        sin150 db '0.5',0
        sin180 db '0',0
bn:  ; 用到寄存器压栈
        push ax
        push bx
        push cx
        push es
        push si
        push di
        ; 除以30计算索引-8位除法
        mov bl,30
        div bl  ;->al
        ; 不定长数据的显示
        mov cx,0b800h
        mov es,cx         ; 显示字节的段地址
        mov di,160*12+80  ; 显示字节的偏移地址
        mov bl,al
        mov bh,0
        shl bx,1  ; 注意sin_tb中按字存储偏移地址,但索引按字节,所以索引要乘以2!!!
        mov si,sin_tb[bx] ; 显示字节的源地址
s:      mov al,cs:[si]
        cmp al,0
        je ed  ; 如果ASCII码为0就退出
        mov es:[di],al
        inc di
        mov al,70h
        mov es:[di],al
        inc di
        inc si
        jmp s  ; 循环显示字符
        ; 寄存器出栈,子程序退出
ed:     pop di
        pop si
        pop es
        pop cx
        pop bx
        pop ax
        ret
codesg ends
end main

待改进:对角度值有效性的判断。

6.5 代码的直接定址表

  那如果想在子函数中调用子函数,也就是实现函数嵌套,也可以定义 代码的直接定址表。比如现在要实现一个子程序 screen_set,为显示输出提供如下功能,就可以将4个功能写成4个子程序,并将它们的入口地址存储在一个表中:

【代码示例】编写一个子程序,可以根据 ax 的预设值实现不同的功能:

  1. 清屏:ah=0。原理是将显存中偶地址的字符字节全部变成空格。
  2. 设置前景色:ah=1,al=颜色。原理是设置显存中所有的奇地址的属性字节的第0、1、2位。
  3. 设置背景色:ah=2,al=颜色。原理是设置显存中所有的奇地址的属性字节的第4、5、6位。
  4. 向上滚动一行:ah=3。原理是依次将第n+1行的内容复制到第n行处,最后一行置空。

提示:ah 传递功能号、al 传送颜色。注意观察 screen_set中的代码的直接定址表

assume cs:codesg,ss:stacksg

stacksg segment
    dw 16 dup (0)
stacksg ends

codesg segment
main:   mov ax,stacksg
        mov ss,ax
        mov sp,16
        mov ax,0000h     ; 清屏
        ; mov ax,0102h     ; 前景变绿
        ; mov ax,0204h     ; 背景变红
        ; mov ax,0300h     ; 向上挪一行
        call screen_set  ; 调用程序显示
        mov ax,4c00h
        int 21h

; 子程序:显示输出程序
screen_set:
        jmp short sc_be
        sub_tb dw sub1,sub2,sub3,sub4
sc_be:  ; 压栈
        push bx
        ; 调用相应的子程序
        cmp ah,3
        ja sc_ed  ; 功能号大于3无效
        mov bl,ah
        mov bh,0
        shl bx,1  ; 表按字存储,所以索引要乘以2!!!
        call word ptr sub_tb[bx]  ; 调用相应的子程序
sc_ed:  ; 出栈退出
        pop bx
        ret

; 子程序:实现功能1
sub1:   ; 设置显存中偶地址的字符字节全部变成空格
        ; 压栈
        push bx
        push cx
        push es
        ; 将b800h开始的80*25个字符全部置空
        mov bx,0b800h
        mov es,bx
        mov bx,0
        mov cx,2000  ; 清除2000个字符
s1_s:   mov byte ptr es:[bx],' '  ; 空格
        add bx,2
        loop s1_s
        ; 出栈退出
        pop es
        pop cx
        pop bx
        ret

; 子程序:实现功能2
sub2:   ; 设置显存中奇地址的属性字节的第012; 压栈
        push bx
        push cx
        push es
        ; 设置前景色->al
        mov bx,0b800h
        mov es,bx
        mov bx,1  ; 从属性字节开始
        mov cx,2000  ; 一共2000个字符
s2_s:   and byte ptr es:[bx],11111000b
        or es:[bx],al
        add bx,2
        loop s2_s
        ; 出栈退出
        pop es
        pop cx
        pop bx
        ret

; 子程序:实现功能3
sub3:   ; 设置显存中奇地址的属性字节的第456; 压栈
        push bx
        push cx
        push es
        ; 设置背景色->al
        mov bx,0b800h
        mov es,bx
        mov bx,1  ; 从属性字节开始
        mov cl,4
        shl al,cl ; 将颜色挪动到456mov cx,2000  ; 一共2000个字符
s3_s:   and byte ptr es:[bx],10001111b
        or es:[bx],al
        add bx,2
        loop s3_s
        ; 出栈退出
        pop es
        pop cx
        pop bx
        ret

; 子程序:实现功能4
sub4:   ; 依次将第n+1行的内容复制到第n行处,最后一行置空。
        ; 压栈
        push ax
        push cx
        push es
        push si
        push di
        ;80*25行,将后24行依次前挪一行
        mov si,0b800h
        mov ds,si
        mov es,si
        mov si,160  ; 下一行的索引
        mov di,0    ; 本行的索引
        cld         ; 串传送指令自增
        mov cx,24  ; 需要挪动24行
s4_s1:  push cx
        mov cx,80  ; 一行有80个字符
        rep movsw  ; 按字传送
        pop cx
        loop s4_s1
        ; 清空最后一行
        mov cx,80
        mov si,0
s4_s3:  mov byte ptr es:[160*24+si],' '
        add si,2
        loop s4_s3
        ; 出栈退出
        pop di
        pop si
        pop es
        pop cx
        pop ax
        ret

codesg ends
end main

注:子程序 screen_set也可以写成多个比较 cmp的形式,但是非常麻烦!
注:功能四也可以直接逐个搬移,但是使用串传送指令代码会非常便捷!

标号
带冒号的标号
不带冒号的标号
(直接定址表)
数据的直接定址表
代码的直接定址表
图6-5 标号分类总结

6.6 中断及其处理

图6-6 8086CPU的中断分类、中断向量表、中断执行流程

  “中断”指的是CPU接收到中断信息后,不再接着刚执行完的指令向下执行,而是转去执行中断处理程序。中断主要分为由CPU内部发生的事件而引起“内中断”,和由外部设备发生的事件引起的“外中断”,如上左图。中断程序的入口地址 CS:IP 统一存放在中断向量表中,根据中断信息代表的中断类型码,可以在中断向量表中找到中断程序对应的 CS:IP,并跳转执行。8086CPU的中断向量表固定为内存的前 1KB (0000:0000~0000:03FF),每个入口地址占用4个字节,所以8086CPU最大支持256个中断程序,如上中间图。整个中断执行过程由CPU的硬件自动完成。下面是常见的内中断、中断执行过程:

【常见的内中断中断信息及其“中断类型码”】

  1. 除法错误:中断类型码为 0。比如执行div指令中的除数为零。
  2. 单步执行:中断类型码为 1。
  3. 执行 into指令:溢出中断指令,中断类型码为 4。比如乘法mul指令结果溢出。
  4. 执行 int n指令:中断类型码为立即数 n 。

【中断执行过程】

  1. 从中断信息中取得“中断类型码”。
  2. 将标志寄存器入栈。因为中断过程中要改变标志寄存器的值,需要先行保护。
  3. 设置标志寄存器中的两位 TF=0、IF=0。表示已处理中断。
  4. CS的内容入栈。
  5. IP的内容入栈。
  6. 从“中断向量表”读取中断处理程序的入口地址,设置IP和CS。

最后看两个代码示例:

【代码示例1】简单认识21h中断的09h、4ch功能。
解析:21h中断的09h负责打印字符串信息。21h中断的4ch则是负责程序退出,并回到主程序。

assume ds:datasg,ss:stacksg,cs:codesg

datasg segment
    string db 13,10,'hello world!','$'
    ; 13,10是回车、换行;'$'表示字符串结束(中断程序的设定)
datasg ends

stacksg segment
    db 200h dup (0)
stacksg ends

codesg segment
start:  ; 数据段初始化
        mov ax,datasg
        mov ds,ax
        ; 栈段初始化
        mov ax,stacksg
        mov ss,ax
        mov sp,200h
        ; 显示字符
        lea dx,string  ;string标号的地址传送给dx。mov是传送内容
        mov ah,9  ; 在屏幕上打印ds:[dx]开始的字符串,规定'$'结尾
        int 21h   ; 调用21h中断的9号功能
        ; 程序退出
        mov ax,4c00h
        int 21h  ; 调用21h中断的4ch号功能,退出程序回到DOS状态
codesg ends
end start

【代码示例2】观察系统的0号中断,也就是“除以0”所引发的中断。

注:DOS系统是微软开发的。中断后的第一条指令 FE38 被翻译成 ???,是因为微软没有公开对应的汇编语言,属于商业机密。

6.7 编制中断处理程序

  由于CPU随时都可能检测到中断信息,所以中断处理程序必须一直存储在内存某段空间之中,比如中断号为0的“除以0”中断程序地址固定为 CS:IP = F000:1060。下面我们演示如何编写中断处理程序,体会中断处理程序处理的技术问题:

【代码示例】编写“除以0”的中断处理程序,效果是显示红底黑字的’Err: div 0!'。
思路:

  1. 编写“装载程序”及“中断程序”。“装载程序”将“中断程序”放到中断向量表的最后256字节,并将其入口地址放到中断向量表的0号位置。
    注:放到中断向量表是保证程序装载到固定区域不被破坏,并且预估该程序大小不会超过256字节。
    注:中断程序存储到中断向量表,只是为了工程简便。正常应该向操作系统申请内存。
  2. 在debug模式中,运行“除以0”指令,观察中断执行效果。
assume ss:stacksg,cs:codesg

stacksg segment
    dw 200h dup(0)
stacksg ends

codesg segment
main:   ; 栈段寄存器初始化
        mov ax,stacksg
        mov ss,ax
        mov sp,200h
        ; 将子程序拷贝到固定区域
        ; call int0
        mov ax,cs
        mov ds,ax
        mov ax,0   
        mov es,ax
        mov si,offset int0
        mov di,0200h  ; 中断向量表的最后256字节
        mov cx,offset int0end - offset int0
        cld
        rep movsb
        ; 修改“除以0”的中断向量表
        mov ax,0
        mov es,ax
        mov word ptr es:[0],0200h
        mov word ptr es:[2],0
        ; 程序退出
        mov ax,4c00h
        int 21h

; 中断程序:输出字符串
int0:   jmp short i0_be
        db 'Err: div 0!'
i0_be:  ; 在屏幕上的最后一行显示字符串
        mov ax,cs
        mov ds,ax  ; 注意数据段地址就是cs
        mov si,0b800h
        mov es,si
        mov si,202h  ; 非常重要!!因为中断程序第一条jmp指令为两字节,所以字符串起始地址202h=200h+2h
        mov di,160*24
        mov cx,11  ;11个字符
i0_s:   mov al,ds:[si]
        mov es:[di],al
        inc di
        mov al,40h  ; 红底黑字
        mov es:[di],al
        inc si
        inc di
        loop i0_s
        ; 程序退出
        mov ax,4c00h
        int 21h
int0end:nop

codesg ends
end main

6.8 单步中断

图6-7 标志寄存器中与中断有关的标志位

与中断相关的标志位:

  • TF-陷阱标志(Trap flag):用于调试时的单步方式操作。当TF=1时,每条指令执行完后产生陷阱,由系统控制计算机;当TF=0时,CPU正常工作,不产生陷阱。
  • IF-中断标志(Interrupt flag):当IF=1时,允许CPU响应可屏蔽中断请求;当IF=0时,关闭中断。

  本小节通过以“单步中断”为例,介绍CPU如何通过监测标志位进入中断,上图给出了与中断相关的两个标志位。之前我们在Debug模式下使用 t命令完成单步中断。实际上,在使用 t命令时,Debug会将TF标志设为1,此时若CPU在执行完一条指令之后,如果检测到 TF==1,则产生单步中断(中断类型码为1),引发中断过程,执行中断处理程序。具体的单步中断执行过程如下:

  1. 取得中断类型码1。
  2. 标志寄存器入栈,TF、IF设置为0。保证中断处理程序顺利执行,防止其陷入不断循环的单步中断。
  3. CS、IP入栈。
  4. (IP)=(1*4),(CS)=(1*4+2)。也就是1号中断的 CS:IP,每个都是两字节。

回忆:0号中断是“除以0”、1号中断是“单步中断”。

  一般情况下,CPU执行完当前指令,若检测到中断标志位有效,就会引发中断过程;但是某些特殊情况,即使中断标志位有效,也不会引发中断。下面是中断不响应的特殊情况:

  • 设置 ss 后,下一个指令不响应中断。所以强烈推荐连续设置 ss、sp!!

注:“5.8节-代码示例1”中已经初见端倪,单步执行“mov ss,ax”是会一并将后续的“mov sp,16”执行,并不会停顿。

【代码示例】演示 ss、sp 的连续设置(推荐)和分开设置(编译不报错,但不推荐)。

; 连续设置ss:sp【推荐】
assume ss:stacksg,cs:codesg
stacksg segment
    dw 16 dup(0)
stacksg ends
codesg segment
main:   ; 定义栈段
        mov ax,stacksg
        mov ss,ax
        mov sp,4
        ; 程序退出
        mov ax,4c00h
        int 21h
codesg ends
end main
; 分开设置ss:sp【强烈不推荐】
assume ss:stacksg,cs:codesg
stacksg segment
    dw 16 dup(0)
stacksg ends
codesg segment
main:   ; 定义栈段
        mov ax,stacksg
        mov ss,ax
        mov ax,5
        mov sp,4
        ; 程序退出
        mov ax,4c00h
        int 21h
codesg ends
end main

6.9 由int指令引发的中断-int

int n
说明:n为中断类型码。
功能:引发对应的中断过程。
注意:调用 int时会自动进行 pushf(标志位压栈)、push CSpush IP这三步,所以自定义的中断例程退出时需要使用 iret将上述出栈,而不能使用简单的 ret

  本小节来介绍 int指令,其功能和 call指令相似,都是调用一段程序,只不过前者是调用中断函数。一般情况下,系统将一些具有一定功能的子程序,以中断处理程序的方式提供给应用程序调用。所以CPU执行 int n指令,就相当于引发一个 n号中断 的中断过程,执行过程如下:

  1. 取得中断类型码1。
  2. 标志寄存器入栈,TF、IF设置为0。
  3. CS、IP入栈。
  4. (IP)=(n*4),(CS)=(n*4+2)。也就是n号中断的 CS:IP,每个都是两字节。

注:中断号存储在ah寄存器中

编程时,可以用 int指令调用中断子程序,简称为“中断例程”。在“6.7节”中我们重写了0号中断例程,本小节我们新定义中断号 7ch,并编写对应的中断例程。

【代码示例1】写7ch的中断例程,求一个word型数据的平方。
参数:(ax)=要计算的word数据
返回值:dx, ax中存放结果的高、低16位
提示1:中断向量表大小为1024字节,预计中断例程不会超过256字节,所以可以将其直接安装在 0200h 处。
提示2:和“6.7节”相同,主程序的流程也是“装载中断程序 → 修改中断向量表 → 退出程序”
提示3:参数和返回值的要求符合 mul指令的定义,中断例程中只需要 mul指令即可完成计算。

装载中断例程:

assume ss:stacksg,cs:codesg

stacksg segment
    dw 200h dup(0)
stacksg ends

codesg segment
main:   ; 栈段寄存器初始化
        mov ax,stacksg
        mov ss,ax
        mov sp,200h
        
        ; 将子程序拷贝到固定区域
        mov ax,cs
        mov ds,ax
        mov si,offset square
        mov ax,0
        mov es,ax
        mov di,200h
        mov cx,offset square_end - offset square  ; 程序的字节数
        cld  ; si、di自增
        rep movsb  ; 将 ds:[si] 复制到 es:[di]

        ; 修改中断向量表中7ch位置存储的CS:IP
        mov ax,0
        mov es,ax
        mov word ptr es:[7ch*4],200h  ; 新的IP
        mov word ptr es:[7ch*4+2],0   ; 新的CS

        ; 程序退出
        mov ax,4c00h
        int 21h

; 中断程序:计算word数据的平方
square: mul ax
        iret  ; 中断函数专用返回指令
        ; ret只会弹出ip,段内返回
        ; iret会弹出ip、cs、标志位
square_end: nop  ; 空指令,只是标志一下中断程序的结束位置

codesg ends
end main

测试程序:

assume cs:codesg
codesg segment
main:   ; 开始测试
        mov ax,3456
        int 7ch
        ; 程序退出
        mov ax,4c00h
        int 21h
codesg ends
end main

【代码示例2】写7ch的中断例程,将字符串逐个转化为大写直到0。
参数:ds:si指向字符串的首地址。

装载中断例程:

assume cs:codesg
codesg segement
main:   ; 装载程序
        mov ax,cs
        mov ds,ax
        mov si,offset capital
        mov ax,0
        mov es,ax
        mov di,0200h
        mov cx,offset capital_end - offset capital  ; 程序的字节数
        cld  ; si、di自增
        rep movsb  ; 将 ds:[si] 复制到 es:[di]
        ; 修改中断向量表
        mov ax,0
        mov es,ax
        mov word ptr es:[7ch*4],200h
        mov word ptr es:[7ch*4+2],0
        ; 程序退出
        mov ax,4c00h
        int 21h

; 中断程序:将ds:[si]处的字符串逐个转化为大写直到0。
capital:        ; 压栈
                push cx
                push si
capital_start:  ; ds:[si]逐个转换成大写
                mov cl,ds:[si]
                mov ch,0
                jcxz capital_quit
                and bytr ptr [si],11011111b
                inc si
                jmp capital_st
capital_quit:   ; 中断程序结束
                pop si
                pop cx
                iret
capital_end: nop  ; 中断程序的结束位置

codesg ends
end main

测试程序:

assume ds:datasg,cs:codesg

datasg segment
        db 'conversion',0
datasg ends

codesg segement
main:   ; 开始测试
        mov ax,datasg
        mov ds:ax
        mov si,0
        int 7ch
        ; 程序退出
        mov ax,4c00h
        int 21h
codesg ends
end main

注:CSDN文章——“ret,retf,iret等的区别
注:实际测试,汇编语言源文件的文件名不能超过两个横杠“-”。比如“p6-9-int.asm”可以编译,但是“p6-9-1-int.asm”就不可以编译。

6.10 BIOS和DOS中断处理-10h/21h中断

图6-8 BIOS中断例程分类举例
图6-9 DOS中断例程分类举例

  BIOS(Basic Input Output System, 基本输入输出系统)是在系统板的ROM中存放着一套程序。一般固化在计算机主板上,是个人电脑启动时加载的第一个程序。在8086CPU中,BIOS内存地址固定从 FE000H 开始,容量为 8KB。使用BIOS功能调用,程序员不用了解硬件操作细节,直接使用指令设置参数,并中断调用BIOS例程,即可完成相关工作!显然这不仅方便程序员编程,同时也保证了代码简洁、易于移植。总的来说,BIOS中断例程就像是一个最底层的函数库,所有厂家都统一使用相同的中断号命名,而将中断例程进行硬件兼容的工作则由主板厂家完成。BIOS中的主要内容:

  1. 硬件系统的检测和初始化程序。
  2. 外部中断和内部中断的中断例程。
  3. 用于对硬件设备进行I/O操作的中断例程。
  4. 其他和硬件系统相关的中断例程。

注:更多介绍见“BIOS和DOS中断大全—中国科学技术大学”,另外我加上了目录

除了BIOS中断外,DOS系统本身也提供了非常多的DOS中断例程!只不过为了保证DOS系统在不同硬件的兼容性,和硬件设备相关的DOS中断例程,一般都调用更底层的BIOS的中断例程。如下图所示,用户可以直接调用DOS中断、BIOS中断、直接操作外设(端口调用,下一小节介绍)。总的来说,BIOS和DOS在所提供的中断例程中包含了许多子程序,这些子程序实现了程序员在编程的时常用到的功能。

图6-10 BIOS/DOS中断的关系

  最后介绍一下每次计算机开机时,BIOS和DOS中断例程的安装过程

  1. CPU一加电,初始化 (CS)=0FFFFH、(IP)=0,自动从 FFFF:0 单元开始执行程序。FFFF:0 处有一条转跳指令,CPU执行该指令后,转去执行BIOS中的硬件系统检测和初始化程序。
  2. 初始化程序将建立BIOS所支持的中断向量,即将BIOS提供的中断例程的入口地址登记在中断向量表中。
  3. 硬件系统检测和初始化完成后,调用 int 19h进行操作系统的引导。从此将计算机交由操作系统控制。
  4. DOS启动后,除完成其它工作外,还将它所提供的中断例程装入内存,并建立相应的中断向量。
图6-11 安装过程在8086地址空间中涉及到的位置

下面是代码示例:

【代码示例1】调用BIOS中断 int 10h,在屏幕的5行12列显示3个红底高亮闪烁绿色的’a’。
回忆:“6.2节”提到8086的显示缓冲区为25行80列,第0页地址为 B8000h~BFFFFh,每个像素点两字节(低字节ASCII码、高字节设置属性),需要自己计算显示位置。
现在:可以直接调用BIOS中断设置光标位置、显示相应字符。

  • 功能号(ah)=02h时,调用第10h中断例程的2号子程序,设置光标位置。
  • 功能号(ah)=09h时,调用第10h中断例程的9号子程序,在光标位置显示单个字符。
  • 功能号(ah)=4ch时,调用第21h中断例程的4ch号子程序,退出程序返回命令行,返回结果保存在al。
assume cs:codesg
codesg segment
main:   ; 设置光标位置
        mov ah,2    ; 功能号
        mov bh,0    ; 页的索引(第0页)
        mov dh,5    ; 行号,共25mov dl,12   ; 列号,共80int 10h
        ; 显示字符
        mov ah,9    ; 功能号
        mov al,'a'  ; 待显示字符
        mov bh,0    ; 页的索引(第0页)
        mov bl,0cah ; 字符属性(闪烁、红底、高亮、绿字)
        mov cx,3    ; 字符重复次数
        int 10h
        ; 程序退出
        mov ax,4c00h
        int 21h
codesg ends
end main

【代码示例2】调用DOS中断 int 21h,在屏幕的5行12列显示字符串“Welcome to masm!”。
提示:

  • 功能号(ah)=02h时,调用第10h中断例程的2号子程序,设置光标位置。
  • 功能号(ah)=09h时,调用第21h中断例程的09h号子程序,在光标位置显示ds:[dx]指向的字符串(以$结束)。
  • 功能号(ah)=4ch时,调用第21h中断例程的4ch号子程序,退出程序返回命令行,返回结果保存在al。
assume ds:datasg,cs:codesg
datasg segment
        db 'Welcome to masm!','$'
datasg ends
codesg segment
main:   ; 设置光标位置
        mov ah,2    ; 功能号
        mov bh,0    ; 页的索引(第0页)
        mov dh,5    ; 行号,共25mov dl,12   ; 列号,共80int 10h
        ; 显示字符串
        mov ax,datasg
        mov ds,ax
        mov dx,0    ; 此时ds:[dx]已指向datasg的首地址
        mov ah,9    ; 功能号
        int 21h
        ; 程序退出
        mov ax,4c00h
        int 21h
codesg ends
end main

6.11 端口的读写-in/out

in 寄存器名,端口地址
功能:CPU从端口读取数据。

out 端口地址,寄存器名
功能:: CPU往端口写入数据。

注:“寄存器名”用于存储数据,只能使用 ax(16位) 或 al
注:对0~255以内的端口进行读写,“端口地址”用 立即数给出;256~65535的“端口地址”则放在 dx中。

图6-12 可与CPU直接通信的器件分类、端口的地址空间

  上一小节提到,用户可以通过“端口”直接访问外设。各种芯片工作时,都有一些寄存器由CPU读写。于是从CPU的角度,将各寄存器当端口,并统一编址与各种设备通信。如上图所示,8086CPU有64K的端口地址空间,一个外设会对应几个地址。比如下面从计算机组成原理的角度,分析8086CPU在执行 in al,20h时与总线相关的操作:

【CPU与内存交互】

  1. CPU根据程序计数器,通过地址线发送地址信息给内存。
  2. CPU通过控制线发出端口读命令。
  3. 内存通过数据总线将指令 in al,20h发送给CPU。

【CPU与外设交互】

  1. CPU解析指令,并通过地址线将地址信息60h发出。
  2. CPU通过控制线发出端口读命令,选中端口所在的芯片,并通知要从中读取数据。
  3. 端口所在的芯片将60h端口中的数据通过数据总线送入CPU。

注:更详细的动态演示见“12端口的读写”——6:45~8:45。

图6-13 端口读写过程
表6-1 I/O端口分配(部分)
I/O地址分配说明I/O地址分配说明
00-1f8237A DMA控制器1170-177IDE硬盘控制器1
20-3f8259A可编程中断控制器11f0-1f7IDE硬盘控制器2
40-5f8253/8254可编程中断计数器278- 27f并行打印机端口2
60-6f8255A可编程外设接口电路2f8-2ff串行控制器2
70-71访问CMOS RAM/实时时钟RTC端口378-38f并行打印机端口1
80-9fDMA页面寄存器访问端口3b0-3bf单色MDA显示控制器
a0-bf8259可编程中断控制器23c0-3cf彩色CGA显示控制器
c0-df8237A DMA控制器23d0-3df彩色EGANGA显示控制器
f0-ff协处理器访问端口3f8-3ff串行控制器1

【代码示例】控制扬声器外设以 08h 的频率持续响两倍的 0ffffh 个CPU周期。
原理1:如下图,设备控制寄存器 61H 的低两位控制扬声器开关,低两位都设置为1,即可使扬声器发声。
原理2:端口地址 42h 通过设置中断计数器,来实现控制声音频率。

注:具体的控制原理见最后一节“6.20-让计算机唱歌”。

assume cs:codesg
codesg segment
main:   ; 设置外设寄存器
        mov al,08h
        out 42h,al      ; 设置声音频率
        in al,61h       ; 读取61h的原始值
        mov ah,al       ; 将61h的原始值保存在ah中
        or al,3
        out 61h,al      ; 设置61h的低两位全为1
        ; 延迟一段时间
        mov cx,0ffffh  ; 循环65535个周期
      delay:
        nop
        nop  ; 每个周期都执行两个空操作
        loop delay
        ; 关闭外设端口
        mov al,ah
        out 61h,al      ; 将61h恢复原始值
        ; 程序退出
        mov ax,4c00h
        int 21h
codesg ends
end main

6.12 操作CMOS RAM芯片-70h/71h端口

图6-14 主板上的时钟芯片和端口地址

  经常折腾系统的同学会发现,即使断网断电,重新开机后系统也能知道当前的时间。这是因为主板上有一个附带纽扣电池供电的 CMOS RAM 芯片,该芯片包含一个实时时钟、一个128 Byte的RAM存储器,实时时钟的时间、系统配置信息、相关的程序(用于开机时配置系统信息)都存储在这个RAM中。并且 CMOS RAM 芯片靠电池供电,所以关机后其内部的实时钟仍可正常工作, RAM 中的信息不丢失。CMOS RAM 内部有两个端口,CPU 通过这两个端口读写该芯片:

  • 70h地址端口:存放要读写的CMOS RAM单元的相对地址(见下图)。
  • 71h数据端口:存放从选定的单元中读取的数据,或要写入到其中的数据。

读取数据的步骤:将要读取的单元地址送入 70h 地址端口,再从数据端口 71h 读出数据。

时间格式信息如下图所示,年/月/日/时/分/秒 的相对地址如下,这6个信息长度均为1字节,高4位和低4位均使用BCD码存储十进制数据。

图6-15 CMOS RAM中存储的时间信息格式(BCD码)

【代码示例】根据上述时间信息格式,在最后一行显示当前的“年-月-日 时:分:秒”。

assume ds:datasg,cs:codesg

datasg segment
        db '00-00-00 00:00:00','$'
datasg ends

codesg segment
main:   ;mov al,9
        out 70h,al
        in al,71h
        mov bx,0
        call info_store
        ;mov al,8
        out 70h,al
        in al,71h
        mov bx,1
        call info_store
        ;mov al,7
        out 70h,al
        in al,71h
        mov bx,2
        call info_store
        ;mov al,4
        out 70h,al
        in al,71h
        mov bx,3
        call info_store
        ;mov al,2
        out 70h,al
        in al,71h
        mov bx,4
        call info_store
        ;mov al,0
        out 70h,al
        in al,71h
        mov bx,5
        call info_store
        ; 显示时间信息
        call display
        ; 程序退出
        mov ax,4c00h
        int 21h

; 子程序:将al中的数据转换成两个ASCII码,并存储到datasg的对应位置
; 输入:
;       al:数据
;       bx:位置,范围0~5,表示是哪个时间信息
info_store:
        ; 入栈
        push ax
        push cx
        push bx
        push es
        ; 数据分离
        mov ah,al
        mov cl,4
        shr ah,cl
        and al,0fh
        ; 转换成ASCII码
        add ah,30h
        add al,30h
        ; 存储到datasg
        mov cx,datasg
        mov es,cx
        mov cx,bx
        shl bx,1
        add bx,cx
        mov es:[bx],ah
        mov es:[bx+1],al
        ; 出栈退出
        pop es
        pop bx
        pop cx
        pop ax
        ret

; 子程序:显示datasg中的字符串信息
display:
        ; 入栈
        push ax
        push bx
        push dx
        push ds
        ; 设置光标位置
        mov ah,2    ; 功能号
        mov bh,0    ; 页的索引(第0页)
        mov dh,24   ; 行号,共25mov dl,0    ; 列号,共80int 10h
        ; 显示字符串
        mov ax,datasg
        mov ds,ax
        mov dx,0    ; 此时ds:[dx]已指向datasg的首地址
        mov ah,9    ; 功能号
        int 21h
        ; 出栈退出
        pop ds
        pop dx
        pop bx
        pop ax
        ret
codesg ends
end main

6.13 外设连接与中断-sti/cli

sti
功能:用于设置标志寄存器中的 IF=1。

cli
功能:用于设置标志寄存器中的 IF=0。

外中断
可屏蔽中断
不可屏蔽中断
图6-16 外中断分类
  • 可屏蔽中断:CPU可以不响应的外中断。当CPU检测到可屏蔽中断信息时,若IF=1,则CPU在执行完当前指令后响应中断,引发中断过程;若IF=0,则不响应该中断。几乎所有由外设引发的外中断都属于此类,比如键盘输入、打印机请求。
  • 不可屏蔽中断:CPU必须响应的外中断。当CPU检测到不可屏蔽中断信息时,则在执行完当前指令后,立即响应,引发中断过程。一般是在系统中有必须处理的紧急情况发生时,用来通知CPU的中断信息,比如系统掉电。

注:8086CPU的不可屏蔽中断的中断类型码固定为2

  之前提到,CPU在执行指令过程中,可以检测到发送过来的“中断信息”,引发中断过程。这些中断信息分为“内中断”(见 6.6节)、“外中断”。“外中断”就是由外部设备发生的事件引起的中断。如上图所示,外中断分为两类,它们的中断过程如下:

【可屏蔽中断所引发的中断过程】

  1. 取中断类型码n。由于是外中断,所以中断类型码是通过数据总线送入CPU。
  2. 标志寄存器入栈,IF=0,TF=0。这里IF=0表示进入中断处理程序后,禁止其他的可屏蔽中断。
  3. CS 、IP 入栈。
  4. 更新(IP)=(n*4),(CS)=(n*4+2),由此转去执行中断处理程序。

【不可屏蔽中断的中断过程】

  1. 中断类型码固定为2,标志寄存器入栈,IF=0,TF=0。
  2. CS、IP入栈。
  3. 更新(IP)=(8),(CS)=(0AH),执行中断处理程序。

6.14 PC机键盘的处理过程-60h端口

  上一小节简单介绍了外中断分类,本小节以“键盘”为例,讲解如何处理外中断。首先来看看键盘输入的原理。键盘上的每一个键相当于一个开关,键盘中有一个芯片对每一个键的开关状态进行扫描,若有变化则产生相应的“扫描码”,然后送入主板上的相关接口芯片的寄存器中,该寄存器的端口地址为60H“扫描码”的大小是一个字节,由于每个键有两种状态,显然最大支持128个键,而现在的键盘最大是108键位,所以完全够用。键盘芯片和端口通信的过程如下:

  • 按下按键时,开关接通,芯片产生一个扫描码(该按键的通码)送入端口60H中。
  • 松开按键后,开关断开,芯片再产生一个扫描码(该按键的断码)送入端口60H中。

注:通码+80H=断码,比如g键的 通码=0010_0010B=22H、断码=1010_0010B=a2H。

表6-2 键盘上每个键的扫描码(通码)

需要说明的是,键盘芯片输入给端口的扫描码都会保存到内存中的BIOS键盘缓冲区。BIOS键盘缓冲区是系统启动后,BIOS专用于存放 int 9中断例程所接收的键盘输入的内存区,可以存储15个键盘输入(现代使用操作系统等方式存储字符,可以存储成千上万个字符)。一个键盘输入用一个 字单元(16bit) 存放,高位字节存放扫描码,低位字节存放对应的ASCII码,另外对于修饰键等其他键来说,在内存中有专门用于存放其状态的 “键盘状态字节(0040:0017H)”,每一位所表示的键位如下图:

图6-17 BIOS键盘缓冲区存储格式、键盘状态字节

最后下图给出了常见的键盘布局:

图6-18 主流键盘布局(分类属于个人意见)
  • 字符键:经常用到的输入按键。
  • 功能键:F1~F12,不同的键盘有不同的功能定义,通常配合Fn使用。
  • 专用键:除了“Start/Win菜单键”、“Esc”退出键,另外三个基本上没怎么用过。
  • 修饰键:所谓“修饰(Modify)”就是本身没啥用,但是可以修改其他按键的功能。都是老熟人了。
  • 导航键:基本上就用个“上下左右”。
  • 数字小键盘:比较“长”的键盘上就会有,电赛啥的也会有一个专门的小键盘模块。
  • 媒体按键:多出来的这4个键其实也看键盘厂家安排。

注:知乎“「科普」机械键盘配列(布局)”、英文维基百科“Keyboard layout”、稚晖君永远滴神“【自制】我做了一把模块化机械键盘!【软核】”。

最后以输入’a’为例,总结一下键盘输入的中断处理过程,其他外设也类似:

  1. 键盘输入。键盘芯片产生a键的通码 1eH,并送入主板上的端口60H中。
  2. 引发9号中断。主板60H处相关芯片会向CPU发起可屏蔽中断请求,中断类型码为9。CPU检测到该请求后,若IF=1,则响应中断,转去执行9号中断。
  3. 执行int 9外中断例程。

a. 读出 60H 端口中的扫描码。
b. 若是字符键,将该扫描码、ASCII码送入内存中的BIOS键盘缓冲区。若是控制键,则将其写入内存中的存储状态字节。
c. 对键盘系统进行相关的控制,如向相关芯片发出应答信息。

注:动态演示见视频“15PC机键盘的处理过程”——11:15~12:52。

6.15 定制键盘输入处理-9h中断

上一小节介绍了CPU处理键盘输入的原理,本小节就在正常执行原有按键中断例程的基础上,添加一些自定义的功能。

【代码示例】在程序执行的下一行中间(24行40列)依次显示 ‘a’~‘z’,并可以让人看清。在显示的过程中,按下Esc键后,改变显示的字体属性。
提示:“让人看清”的意思是,字符切换的速度太快,需要加入延迟。
提示:尽可能忽略硬件处理细节,充分利用BIOS提供的int 9中断例程对这些硬件细节进行处理。也就是说,在原有的int 9中断例程的基础上套一个壳,先执行原有的中断例程,再检测若为Esc按键就改变字体颜色。

提示:此时需要将中断向量表中9号中断更改为自定义程序的地址,并将原有的地址保存到 ds:[0]ds:[2]方便在自定义程序中调用,最后别忘了恢复。

assume ss:stacksg,ds:datasg,cs:codesg

stacksg segment
    db 128 dup(0)
stacksg ends

datasg segment
    dw 0,0  ; 存放原有的int9的中断向量地址
datasg ends

codesg segment
main:   ; 初始化栈段
        mov ax,stacksg
        mov ss,ax
        mov sp,128  ; 注意sp一定要指向栈底!!!!
        ; 初始化数据段
        mov ax,datasg
        mov ds,ax

        ; 修改中断向量表中的int9地址
        mov ax,0
        mov es,ax
        push es:[9*4]
        pop ds:[0]
        push es:[9*4 + 2]
        pop ds:[2]
        mov word ptr es:[9*4],offset my_int9
        mov es:[9*4 + 2],cs

        ; 循环显示a~z
        ; 注:这里若使用10h中断的第9号程序显示字符,每次都会重新设置字符属性。
        ;     但由于Esc会改编字符属性,为了保证演示效果,这是手动设置显存缓冲区。
        mov ax,0b800h
        mov es,ax
        mov al,'a'
  m_s:  mov es:[160*24+40*2],al    ; 待显示字符
        call delay  ; 延迟一段时间,保证人眼看得清
        inc al
        cmp al,'z'
        jna m_s

        ; 恢复int9中断向量地址
        mov ax,0
        mov es,ax  ; 注意要将es恢复回来!!!
        push ds:[0]
        pop es:[9*4]
        push ds:[2]
        pop es:[9*4 + 2]

        ; 程序退出
        mov ax,4c00h
        int 21h

; 子函数:在调用原有int9中断例程基础上,添加自定义功能
my_int9:    ; 入栈
            push ax
            push bx
            push es
            in al,60h  ; 调用程序前,先读取一下扫描码!!
            ; 完成原有的执行中断前流程
            ; 1.标志寄存器入栈
            pushf
            ; 2.IF=0,TF=0
            pushf
            pop bx
            and bh,11111100b
            push bx
            popf
            ; 3.CS、IP入栈,并将CS:IP修改成中断向量
            call dword ptr ds:[0]

            ; 若按下Esc键,则字体属性+1
            cmp al,01h  ; Esc的通码为01h
            jne my_int9_ret
            mov ax,0b800h
            mov es,ax
            inc byte ptr es:[160*24+40*2+1]

  my_int9_ret:
            ; 出栈退出
            pop es
            pop bx
            pop ax
            iret

; 子函数:延迟 dx:ax 周期
delay: ; 压栈
        push ax
        push dx
        ; 延迟 dx:ax 个周期
        mov dx,0005h
        mov ax,0000h
  d_s:  sub ax,1
        sbb dx,0
        cmp ax,0
        jne d_s
        cmp dx,0
        jne d_s
        ; 出栈退出
        pop dx
        pop ax
        ret
codesg ends
end main

注:即使功能并没有实现完毕,子函数也必须有 ret/iret等表示函数返回的指令,否则执行 offset 函数名将会导致程序卡死。
编程感想:sp要初始为栈底!!另外恢复中断向量表时要注意先将 es恢复回来!!

6.16 改写中断例程的方法

上一小节演示了如何增加int9功能,本小节更进一步,将自定义的功能常驻到内存中。

【代码示例】安装一个新的int 9中断例程。功能是在DOS下,按F1键后改变当前屏幕显示的背景颜色,其他的键照常处理。
提示:原有的int9中断向量地址放到 0200h 处,自定义的中断例程则放到 0:204h 处。

assume ss:stacksg,cs:codesg

stacksg segment
    db 128 dup(0)
stacksg ends

codesg segment
main:   ; 初始化栈段
        mov ax,stacksg
        mov ss,ax
        mov sp,128  ; 注意sp一定要指向栈底!!!!
        
        ; 存放原有的int9中断向量地址
        mov ax,0
        mov es,ax
        push es:[9*4]
        pop es:[200h]
        push es:[9*4+2]
        pop es:[202h]

        ; 装载中断例程
        push cs
        pop ds
        mov si,offset my_int9
        mov di,204h
        mov cx,offset my_int9_end - offset my_int9
        cld
        rep movsb ; ds:[si]->es:[di]

        ; 更新int9的中断向量地址
        cli  ; 更新过程不允许被其他中断打断
        mov word ptr es:[9*4],204h
        mov word ptr es:[9*4+2],0
        sti

        ; 程序退出
        mov ax,4c00h
        int 21h

; 中断例程:新增F1键改变屏幕背景颜色
my_int9:    ; 入栈
            push ax
            push bx
            push cx
            push es
            pushf
            
            ; 读取扫描码
            in al,60h

            ; 调用原来的中断例程
            call dword ptr cs:[200h]  ; 注:此时(CS)=0
            
            ; 若为F1键,则背景颜色+1
            cmp al,3bh
            jne my_int9_ret
            mov ax,0b800h
            mov es,ax
            mov bx,1
            ;;;;设置颜色;;;;
            mov al,es:[bx]
            mov cl,4
            shr al,cl
            inc al
            mov cl,5
            shl al,cl
            shr al,1  ; 注意先左移5位再右移1位,就直接将最高位也置0;;;;;;;;;;;;;;;;
            mov ah,es:[bx]
            and ah,10001111b
            or ah,al
            mov cx,25*80
    my_s:   mov es:[bx],ah
            add bx,2
            loop my_s

            ; 出栈退出
my_int9_ret:pop es
            pop cx
            pop bx
            pop ax
            iret
my_int9_end: nop


codesg ends
end main

6.17 用中断响应外设-9h/16h中断

图6-19 与键盘操作有关的中断

硬件中断【int 9h

  • 触发源:“键盘输入”这个动作。
  • 功能:从 60h 端口读出扫描码,并将其转化为相应的ASCII码或状态信息,存储在内存的指定空间(键盘缓冲区或状态字节)中。若超过键盘缓冲区的容量,会自动覆盖掉最开始的数据。

BIOS中断【mov ah,0int 16h

  • 触发源:需要用户在程序中调用,调用后会进入阻塞的等待触发状态,有“键盘输入”就完成触发。
  • 功能:从键盘缓冲区中读取一个键盘输入存储在 ax 中,并且将其从缓冲区中删除。其中,(ah)=扫描码,(al)=ASCII码。

注:键盘缓冲区为环形队列,一共有16个字单元,可以存储15个按键的扫描码和对应的入ASCII码。

  如上图所示,与键盘操作有关的中断包括 硬件中断int 9h、BIOS中断int 16h、DOS中断int 21h,这三者中越往后越靠近顶层、功能也更加丰富。前面三个小节演示了如何使用 硬件中断int 9处理键盘输入,本节开始演示如何使用 BIOS中断int 16h处理。从上面对于【int 9h】【mov ah,0int 16h】的介绍可以看到,两者是一对相互配合的程序,int 9向键盘缓冲区中写入,int 16h从缓冲区中读出。只是它们写入和读出的时机不同,int 9在有键按下的时候向键盘缓冲区中写入数据;而int 16h是在应用程序对其进行调用的时候,将数据从键盘缓冲区中读出。使用时,可以根据需求调用不同的中断例程。于是总结一下,BIOS中断int 16h中断例程0号功能的实现过程:

  1. 检测键盘缓冲区中是否有数据。
  2. 没有则继续做第1步。所以是阻塞等待!!
  3. 读取缓冲区第一个字单元中的键盘输入。
  4. 将读取的扫描码送入ah,ASCII 码送入al。
  5. 将己读取的键盘输入从缓冲区中删除。

注:动态演示见“18用中断响应外设”——8:10~12:30~14:45。

最后是一个代码示例。

【代码示例】接收用户的键盘输入,若:

输入“r”,将屏幕上的所有字符设置为红色。
输入“g”,将屏幕上的所有字符设置为绿色。
输入“b”,将屏幕上的所有字符设置为蓝色。

assume cs:codesg
codesg segment
main:     ; 调用int 16h,等待键盘输入
          mov ah,0
          int 16h
          ; 注:(ah)=扫描码,(al)=ASCII码。

          ; 根据输入确定颜色
          mov ah,1  ; 颜色存储在ah中
          cmp al,'r'
          je red
          cmp al,'g'
          je green
          cmp al,'b'
          je blue
          jmp main_ret  ; 输入其他字符直接退出
  red:    shl ah,1
  green:  shl ah,1  ; 注意这里很巧妙!!红色会直接左移两次!!
          
          ; 将屏幕上所有字符颜色都进行更改
  blue:   mov bx,0b800h
          mov es,bx
          mov bx,1
          mov cx,25*80
  s:      and byte ptr es:[bx],11111000b
          or es:[bx],ah
          add bx,2
          loop s

          ; 程序退出
main_ret: mov ax,4c00h
          int 21h
codesg ends
end main

为了显示按键字符,每次我都多按了一次。

6.18 应用:字符串的输入

上一小节简单演示了调用 int 16h从键盘缓冲区中读取键盘的输入,本小节进一步演示对于字符串的处理。并且进一步仿照中断功能号,对同一程序设定不同的功能。

【代码示例】设计一个最基本的字符串输入程序,需要具备下面的功能:

  1. 在输入的同时需要显示所有输入的字符串。
  2. 输入退格键,能够删除一个已经输入的字符。
  3. 在输入回车符后,字符串输入结束。

代码思路:显然需要定义一个“数组”存储字符串。字符串输入程序不断的调用 int 16h扫描按键,其子程序中则设定 ah 来执行“尾部插入”、“尾部删除”、“显示”这些不同的功能。实际上字符串存储在“数据段”中,下面给出字符串输入程序的子程序参数说明:

  • (ah)=功能号,0表示存储,1表示删除,2表示显示;
  • 0号功能:(al)=入栈字符ASCII码。
  • 1号功能:删除后,(al)=返回的字符ASCII码。
  • 2号功能:(dh)、(dl)=字符串在屏幕上显示的行、列位置;ds:[si] 指向字符串的存储空间,字符串以0为结尾符。
assume ss:stacksg,ds:datasg,cs:codesg

stacksg segment
    db 256 dup(0)
stacksg ends

datasg segment
    db 128 dup(0)  ; 存储字符串,假设最大128字节
datasg ends

codesg segment
main:   ; 初始化栈段
        mov ax,stacksg
        mov ss,ax
        mov sp,256
        ; 初始化数据段
        mov ax,datasg
        mov ds,ax
        ; 字符串输入程序
        mov si,0  ; 字符串存储初始位置
        mov dh,24  ; 显示行索引,共25mov dl,0   ; 显示列索引,共80call getString
        ; 程序退出
        mov ax,4c00h
        int 21h

; 字符串输入程序
getString:
        ; 入栈
        push ax

        ; 调整光标位置
        mov ah,2    ; 功能号
        mov bh,0    ; 页的索引(第0页)
        ; dx已经在主程序中设置好了
        int 10h

  getString_start:
        ; 检测按键是什么
        mov ax,0
        int 16h
        cmp al,20h  ; ASCII码>=20h就是字符
        jnb keyChar
        cmp ah,0eh  ; 扫描码是BackSpace
        je keyBackSpace
        cmp ah,1ch  ; 扫描码是Enter
        je keyEnter
        jmp getString_start  ; 都不是就重新扫描按键

  keyChar:
        ; 输入新的字符
        mov ah,0  ; 插入字符
        call charArray
        mov ah,2  ; 显示字符
        call charArray
        jmp getString_start
  keyBackSpace:
        ; 删除最后的字符
        mov ah,1  ; 删除字符
        call charArray
        mov ah,2  ; 显示字符
        call charArray
        jmp getString_start
  keyEnter:
        ; 结束输入
        mov ah,2  ; 显示字符
        call charArray

        ; 出栈退出
        pop ax
        ret

; 子程序:对字符串进行具体的操作
charArray:
        jmp short charArray_start
        charOption dw char_ins,char_del,char_dip ; 三种基本操作的地址
        top dw 0  ; 栈顶

  charArray_start:
        ; 入栈
        push ax
        push bx
        push dx
        push di
        push es

        ; 根据ah跳转到不同的功能
        cmp ah,2
        ja charArray_ret  ; ah>2直接退出
        mov bl,ah
        mov bh,0
        add bx,bx
        jmp word ptr charOption[bx]

  char_ins:
        ; 插入一个新字符
        mov bx,top
        mov ds:[si][bx],al
        inc top
        jmp charArray_ret

  char_del:
        ; 删除最后一个字符
        cmp top,0
        je charArray_ret  ; 若为空字符串直接返回
        mov bx,top
        mov al,ds:[si][bx]
        mov byte ptr ds:[si][bx],0
        dec top  ; top-1
        jmp charArray_ret

  char_dip:
        ; 显示所有字符,直到top位置
        ; 计算字符位置:di=160*dh+dl*2
        push dx  ; 存储一下显示位置,调整坐标用到
        mov ax,0b800h
        mov es,ax
        mov al,160
        mov ah,0
        mul dh
        mov di,ax
        add dl,dl
        mov dh,0
        add di,dx
        ; 显示字符串
        mov bx,0
    dip_next:  ; 下一轮判断
        cmp bx,top
        jne dip_cur  ; 不是最后一个字符就继续显示
        mov byte ptr es:[di],' '  ; 将最后一位的后一位显示清空,在删除字符时起作用
        ;;;;;;;;;;;调整光标位置;;;;;;;;;;
        pop dx      ; 原始的显示位置(dh行号)
        mov ax,top
        add dl,al    ; dl列号,也就是top的低8mov ah,2    ; 功能号
        mov bh,0    ; 页的索引(第0页)
        int 10h
        ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
        jmp charArray_ret  ; 退出程序
    dip_cur:  ; 显示当前字符
        mov al,ds:[si][bx]
        mov es:[di],al
        inc bx
        add di,2
        jmp dip_next

  charArray_ret:
        ; 出栈退出
        pop es
        pop di
        pop dx
        pop bx
        pop ax
        ret

codesg ends
end main
  • 卡了很久的bug:居然是子程序中,最开始的push和pop顺序完全一致(应该是相反),导致数据错乱。😅
  • 程序亮点:光标会跟随移动,并且可以在最顶层的主程序中,任意修改显示位置。

6.19 磁盘读写-13h中断

图6-20 磁盘的物理结构

  6.14~6.18节一直在使用“键盘”作为演示,本小节介绍另一个外设——“磁盘”。首先来介绍一下磁盘的物理结构,如上图所示,磁盘本身分为不同的盘面,磁头是一组小磁头组成,每个盘面都由不同的小磁头进行读取(于是“磁头号”也就表示盘面)。另外,对于单个盘面来说,会按照同心圆分割成不同的环形磁道,每个磁道又可以切分成不同的小扇区。于是根据这个物理构造,我们便可以使用BIOS中断 int 13h来操作磁盘,并给出读写操作的入口参数和返回参数:

表6-3 BIOS提供的磁盘直接服务——int 13h

【读操作】
入口参数:

  • (ah)=2(2表示读扇区)
  • (al)=读取的扇区数
  • (ch)=磁道号,(cl)=扇区号
  • (dh)=磁头号(对于软盘即面号,一个面用一个磁头来读写)
  • (dl)=驱动器号:软驱从0开始(0-软驱A,1-软驱B),硬盘从80h开始(80h-硬盘C,81h-硬盘D…)。
  • es:bx:指向接收从扇区读入数据的内存区。

返回参数:

  • 操作成功:(ah)=0,(al)=读入的扇区数
  • 操作失败:(ah)=出错代码

【代码示例1】读取C盘0面0道1扇区的内容到内存单元0:200。

mov ax,0
mov es,ax
mov bx,200h ; 读入0:200h
mov al,1    ; 1个扇区
mov ch,0    ; 0磁道
mov cl,1    ; 1扇区
mov dh,0    ; 0mov dl,80h  ; C盘
mov ah,2    ; 读扇区
int 13h

【写操作】
入口参数:

  • (ah)=3(3表示写扇区)
  • (al)=写入的扇区数
  • (ch)=磁道号,(cl)=扇区号
  • (dh)=磁头号(对于软盘即面号)
  • (dl)=驱动器号:软驱从0开始(0-软驱A,1-软驱B),硬盘从80h开始(80h-硬盘C,81h:硬盘D…)。
    es:bx:指向将写入磁盘的数据。

返回参数:

  • 操作成功:(ah)=0,(al)=写入的扇区数
  • 操作失败:(ah)=出错代码

【代码示例2】将0:200中的内容写入C盘0面0道1扇区。

mov ax,0
mov es,ax
mov bx,200h ;0:200h
mov al,1    ;1个扇区
mov ch,0    ; 0磁道
mov cl,1    ; 1扇区
mov dh,0    ; 0mov dl,80h  ; C盘
mov ah,3    ; 3号写入功能
int 13h

当然,BIOS中断例程偏向底层,更高一层的DOS中断 int 21h则支持对于磁盘文件进行操作,下面简单列出:

  • 目录控制功能(Directory-Control Function)

39H—创建目录 3AH—删除目录 3BH—设置当前目录 47H—读取当前目录

  • 磁盘管理功能(Disk-Management Function)

0DH—磁盘复位 2EH—设置校验标志 0EH—选择磁盘 36H—读取驱动器分配信息
19H—读取当前驱动器 54H—读取校验标志 1BH,1CH—读取驱动器数据

  • 文件操作功能(File Operation Function)
  • 文件操作功能(FCB, 分块操作)(File Operation Function)
  • 记录操作功能(Record Function)
  • 记录操作功能(FCB)(Record Function)

注:DOS中断的文件操作功能甚至比C语言还要丰富。
注:手册见“BIOS和DOS中断大全—中国科学技术大学”,另外我加上了目录

6.20 让计算机唱歌-61h/42h端口

  终于到了本章的最后一节!本小节来做一个实际的应用——让计算机读取磁盘文件并发声。首先来看看计算机发声原理。在“6.11节”中的代码示例中,我们使用 端口61h(8255芯片) 的低两位控制扬声器开关、使用 端口42h(8253芯片) 来控制声音频率。具体原理如下图,8253芯片本质上就是一个“分频器”,通过对原始时钟1.19318MHz进行计数,产生不同频率的脉冲,这个计数器的阈值是1个字(16bit),通过两次 out 42h,...设置。8255则是一个简单的开关,延时周期自定义,如下给出了两个芯片的配置代码:

图6-21 扬声器控制电路

注:8086CPU的标准钟频率是1MHz。但8255是软件控制,延时并不精准但也够用。

; 8253 芯片(定时/计数器)的设置
mov al,0b6h         ; 8253初始化
out 43h,al          ; 43H是8253芯片控制口的端口地址
mov dx,12h
mov ax,34dch        ; [dx,ax]是被除数,12_34dcH=1,193,180D(Hz)
div word ptr [si]   ; 计算分频值,赋给ax, [si]中存放声音的频率值。
out 42h,al          ; 先送低8位到计数器,42h是8253芯片通道2的端口地址
mov al,ah
out 42h,al          ; 后送高8位计数器
; 设置8255芯片(并行I/O),控制扬声器的开/in al,61h   ; 读取8255 B端口原值
mov ah,al   ; 保存原值
or al,3     ; 使低两位置1,以便打开开关
out 61h,al  ; 开扬声器, 发声
;;;;;;;;;;;;;;;;;;;;;;;;;;;;
delay...    ; 自定义延时周期
;;;;;;;;;;;;;;;;;;;;;;;;;;;;
mov al,ah
out 61h,al  ; 恢复扬声器端口原值

现在我们了解了如何控制计算机发声的频率和时长,那又该如何翻译乐谱呢?如下直接给出音符和频率的对应关系,直接按图索骥即可。至于发声时长,一般设置四分音符的持续时间为500ms,而下图中“新年好”是三四拍,表示一个小节(两个竖杠之间)有3个四分音符,于是单个小节是1.5s。于是我们便可以假设延迟的基本单位是10ms,根据CPU=1MHz推算出基本单位为 1000 个周期,没有超过65535表示范围。于是便可以得到下面的乐谱时间:

图6-22 翻译“新年好”乐谱--音符和频率的对应关系
; 新年好“数字化”乐谱
  datasg segment
  mus_freq  dw 262,262,262,196
            dw 330,330,330,262
            dw 262,330,392,392
            dw 349,330,294
            dw 294,330,349,349
            dw 330,294,330,262
            dw 262,330,294,196
            dw 247,294,262,-1  ; 为了指明结束位置,最后添加一个-1
  mus_time  dw 3 dup(25,25,50,50),25,25,100
            dw 3 dup(25,25,50,50),25,25,100
datasg ends

将上面的理论结合一下,就可以完成计算机播放音乐的功能。

【代码示例】让计算机播放“新年好”。
提示:调用关系是主程序–调用–>播放音乐子程序–调用–>播放单个音符子程序。

assume ss:stacksg,ds:datasg,cs:codesg

stacksg segment
    db 256 dup(0)
stacksg ends

; “新年好”数字化乐谱
datasg segment
      mus_freq    dw 262,262,262,196
                  dw 330,330,330,262
                  dw 262,330,392,392
                  dw 349,330,294
                  dw 294,330,349,349
                  dw 330,294,330,262
                  dw 262,330,294,196
                  dw 247,294,262,-1  ; 为了指明结束位置,最后添加一个-1
      mus_time    dw 3 dup(25,25,50,50),25,25,100
                  dw 3 dup(25,25,50,50),25,25,100
datasg ends

codesg segment
main: ; 初始化栈段
      mov ax,stacksg
      mov ss,ax
      mov sp,256
      ; 初始化数据段
      mov ax,datasg
      mov ds,ax

      ; 播放音乐
      lea si,mus_freq  ; 频率的偏移地址
      lea di,mus_time  ; 延迟的偏移地址
      call play_music

      ; 程序退出
      mov ax,4c00h
      int 21h

; 子程序:播放音乐
; 传递参数:ds:[si]指向乐符频率的首地址、ds:[di]指向乐符延迟的首地址
play_music:
      ; 入栈
      push dx
      push si
      push di

  play_begin:
      ; 演奏音乐
      mov dx,ds:[si]
      cmp dx,-1
      je play_end
      call single_sound
      add si,2
      add di,2
      jmp play_begin

  play_end:
      ; 出栈退出
      pop dx
      pop si
      pop di
      ret


; 子程序:演奏单个乐符
; 传递参数:ds:[si]指向乐符频率的首地址、ds:[di]指向乐符延迟的首地址
single_sound:
      ; 入栈
      push ax
      push dx
      push cx
      ; 设置频率(8253芯片)
      mov al,0b6h         ; 8253初始化
      out 43h,al          ; 43H是8253芯片控制口的端口地址
      mov dx,12h
      mov ax,34dch        ; [dx,ax]是被除数,12_34dcH=1,193,180D(Hz)
      div word ptr ds:[si]; 计算分频值,赋给ax,ds:[si]中存放声音的频率值。
      ; mov cl,3
      ; shr ax,cl           ; 洒水车音效!!
      out 42h,al          ; 先送低8位到计数器,42h是8253芯片通道2的端口地址
      mov al,ah
      out 42h,al          ; 后送高8位计数器

      ; 设置延迟(8255芯片)
      in al,61h   ; 读取8255 B端口原值
      mov ah,al   ; 保存原值
      or al,3     ; 使低两位置1,以便打开开关
      out 61h,al  ; 开扬声器, 发声
      ;;;;;;;;;;;;;;;;;;;;;;;;;;;;
      mov cx,ds:[di]
  single_sound_loops:
      call delay_10ms  ; 自定义延时周期
      loop single_sound_loops
      ;;;;;;;;;;;;;;;;;;;;;;;;;;;;
      mov al,ah
      out 61h,al  ; 恢复扬声器端口原值

      ; 出栈退出
      pop cx
      pop dx
      pop ax
      ret

; 子程序:延迟10ms(10000个周期)
delay_10ms: push cx
            mov cx,10000
  delay_s:  nop
            loop delay_s
            pop cx
            ret

codesg ends
end main

注:这个程序就不放视频了,但是效果可以说是“呕哑嘲哳难为听”🤣。
另:将所有音符的频率提升4倍(右移两位,见代码78行),就是洒水车的效果了。😁

  • 29
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

虎慕

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值