6. 中断及其应用
文章目录
- 6. 中断及其应用
- 6.0 阶段导学
- 6.1 移位指令-shl/shr/sal/sar/rol/ror/rcl/rcr
- 6.2 操作显存数据
- 6.3 描述内存单元的标号
- 6.4 数据的直接定址表
- 6.5 代码的直接定址表
- 6.6 中断及其处理
- 6.7 编制中断处理程序
- 6.8 单步中断
- 6.9 由int指令引发的中断-int
- 6.10 BIOS和DOS中断处理-10h/21h
- 6.11 端口的读写-in/out
- 6.12 操作CMOS RAM芯片
- 6.13 外设连接与中断-sti/cli
- 6.14 PC机键盘的处理过程
- 6.15 定制键盘输入处理
- 6.16 改写中断例程的方法
- 6.17 用中断响应外设
- 6.18 应用:字符串的输入
- 6.19 磁盘读写
- 6.20 让计算机唱歌
- 参考视频:烟台大学贺利坚老师的网课《汇编语言程序设计系列专题》,或者是B站《汇编语言程序设计 贺利坚主讲》,大家一起看比较热闹。
- 中文教材:《汇编语言-第3版-王爽》(课程使用)、《汇编语言-第4版-王爽》(最新版)。
- 老师的博客:《迂者-贺利坚的专栏-汇编语言》
- 检测点答案参考:《汇编语言》- 读书笔记 - 各章检测点归档
本篇笔记对应课程第六章(下图倾斜),章节划分和教材对应关系如下。
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
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,3
、shl al,cl
合法。
6.2 操作显存数据
8086CPU的显存地址空间有128K(A0000h~BFFFFh),其中最后的32K空间(B8000h~BFFFFh)是 80*25 彩色字符模式第0页的显示缓冲区。其中每个字符都需要2个字节,高位字节为字符的ASCII码,低位字节为预设的字符属性。我们可以通过直接修改显示缓冲区的数据,来更改屏幕显示内容,显然这是一种非常“直接”地操纵底层的数据显示方式。如下图所示:
【代码示例】显示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 描述内存单元的标号
标号不仅可以用来标记“指令”、“段”的起始地址,还可以用来标记“数据”,如下图所示有两种标记方式。其中,“数据标号”的使用可以使得代码更加简洁:
- 地址标号:带冒号,只描述内存单元的地址,并且只能在“代码段”中使用,一般配合
offset
使用。- 数据标号:不带冒号,同时描述内存单元的地址和单元长度,在“代码段”和“数据段”都能使用。
上右图中,
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 数据的直接定址表
计算机很难进行三角函数的运算,于是便经常将常见结果编写成一张“表”,计算是直接查表即可,这就是“数据的直接定址表”。本质上来看,利用表,在两个数据集合之间建立一种映射关系,用查表的方法根据给出的数据得到其在另一集合中的对应数据。虽然会耗费一些内存,但优点是使得算法清晰和简洁、加快运算速度、使程序易于扩充。下面来看两个代码示例。
【代码示例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 ; 显示高4位 mov 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 ; 显示低4位 mov 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 的预设值实现不同的功能:
- 清屏:ah=0。原理是将显存中偶地址的字符字节全部变成空格。
- 设置前景色:ah=1,al=颜色。原理是设置显存中所有的奇地址的属性字节的第0、1、2位。
- 设置背景色:ah=2,al=颜色。原理是设置显存中所有的奇地址的属性字节的第4、5、6位。
- 向上滚动一行: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: ; 设置显存中奇地址的属性字节的第0、1、2位 ; 压栈 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: ; 设置显存中奇地址的属性字节的第4、5、6位 ; 压栈 push bx push cx push es ; 设置背景色->al mov bx,0b800h mov es,bx mov bx,1 ; 从属性字节开始 mov cl,4 shl al,cl ; 将颜色挪动到4、5、6位 mov 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.6 中断及其处理
“中断”指的是CPU接收到中断信息后,不再接着刚执行完的指令向下执行,而是转去执行中断处理程序。中断主要分为由CPU内部发生的事件而引起“内中断”,和由外部设备发生的事件引起的“外中断”,如上左图。中断程序的入口地址 CS:IP 统一存放在中断向量表中,根据中断信息代表的中断类型码,可以在中断向量表中找到中断程序对应的 CS:IP,并跳转执行。8086CPU的中断向量表固定为内存的前 1KB (0000:0000~0000:03FF),每个入口地址占用4个字节,所以8086CPU最大支持256个中断程序,如上中间图。整个中断执行过程由CPU的硬件自动完成。下面是常见的内中断、中断执行过程:
【常见的内中断中断信息及其“中断类型码”】
- 除法错误:中断类型码为 0。比如执行div指令中的除数为零。
- 单步执行:中断类型码为 1。
- 执行
into
指令:溢出中断指令,中断类型码为 4。比如乘法mul指令结果溢出。- 执行
int n
指令:中断类型码为立即数 n 。【中断执行过程】
- 从中断信息中取得“中断类型码”。
- 将标志寄存器入栈。因为中断过程中要改变标志寄存器的值,需要先行保护。
- 设置标志寄存器中的两位 TF=0、IF=0。表示已处理中断。
- CS的内容入栈。
- IP的内容入栈。
- 从“中断向量表”读取中断处理程序的入口地址,设置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!'。
思路:
- 编写“装载程序”及“中断程序”。“装载程序”将“中断程序”放到中断向量表的最后256字节,并将其入口地址放到中断向量表的0号位置。
注:放到中断向量表是保证程序装载到固定区域不被破坏,并且预估该程序大小不会超过256字节。
注:中断程序存储到中断向量表,只是为了工程简便。正常应该向操作系统申请内存。- 在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 单步中断
与中断相关的标志位:
- 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。
- 标志寄存器入栈,TF、IF设置为0。保证中断处理程序顺利执行,防止其陷入不断循环的单步中断。
- CS、IP入栈。
- (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 CS
、push IP
这三步,所以自定义的中断例程退出时需要使用iret
将上述出栈,而不能使用简单的ret
。
本小节来介绍 int
指令,其功能和 call
指令相似,都是调用一段程序,只不过前者是调用中断函数。一般情况下,系统将一些具有一定功能的子程序,以中断处理程序的方式提供给应用程序调用。所以CPU执行 int n
指令,就相当于引发一个 n号中断 的中断过程,执行过程如下:
- 取得中断类型码1。
- 标志寄存器入栈,TF、IF设置为0。
- CS、IP入栈。
- (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
BIOS(Basic Input Output System, 基本输入输出系统)是在系统板的ROM中存放着一套程序。一般固化在计算机主板上,是个人电脑启动时加载的第一个程序。在8086CPU中,BIOS内存地址固定从 FE000H 开始,容量为 8KB。使用BIOS功能调用,程序员不用了解硬件操作细节,直接使用指令设置参数,并中断调用BIOS例程,即可完成相关工作!显然这不仅方便程序员编程,同时也保证了代码简洁、易于移植。总的来说,BIOS中断例程就像是一个最底层的函数库,所有厂家都统一使用相同的中断号命名,而将中断例程进行硬件兼容的工作则由主板厂家完成。BIOS中的主要内容:
- 硬件系统的检测和初始化程序。
- 外部中断和内部中断的中断例程。
- 用于对硬件设备进行I/O操作的中断例程。
- 其他和硬件系统相关的中断例程。
注:更多介绍见“BIOS和DOS中断大全—中国科学技术大学”,另外我加上了目录。
除了BIOS中断外,DOS系统本身也提供了非常多的DOS中断例程!只不过为了保证DOS系统在不同硬件的兼容性,和硬件设备相关的DOS中断例程,一般都调用更底层的BIOS的中断例程。如下图所示,用户可以直接调用DOS中断、BIOS中断、直接操作外设(端口调用,下一小节介绍)。总的来说,BIOS和DOS在所提供的中断例程中包含了许多子程序,这些子程序实现了程序员在编程的时常用到的功能。
最后介绍一下每次计算机开机时,BIOS和DOS中断例程的安装过程:
- CPU一加电,初始化 (CS)=0FFFFH、(IP)=0,自动从 FFFF:0 单元开始执行程序。FFFF:0 处有一条转跳指令,CPU执行该指令后,转去执行BIOS中的硬件系统检测和初始化程序。
- 初始化程序将建立BIOS所支持的中断向量,即将BIOS提供的中断例程的入口地址登记在中断向量表中。
- 硬件系统检测和初始化完成后,调用
int 19h
进行操作系统的引导。从此将计算机交由操作系统控制。- DOS启动后,除完成其它工作外,还将它所提供的中断例程装入内存,并建立相应的中断向量。
下面是代码示例:
【代码示例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 ; 行号,共25行 mov dl,12 ; 列号,共80列 int 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 ; 行号,共25行 mov dl,12 ; 列号,共80列 int 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
中。
上一小节提到,用户可以通过“端口”直接访问外设。各种芯片工作时,都有一些寄存器由CPU读写。于是从CPU的角度,将各寄存器当端口,并统一编址与各种设备通信。如上图所示,8086CPU有64K的端口地址空间,一个外设会对应几个地址。比如下面从计算机组成原理的角度,分析8086CPU在执行 in al,20h
时与总线相关的操作:
【CPU与内存交互】
- CPU根据程序计数器,通过地址线发送地址信息给内存。
- CPU通过控制线发出端口读命令。
- 内存通过数据总线将指令
in al,20h
发送给CPU。【CPU与外设交互】
- CPU解析指令,并通过地址线将地址信息60h发出。
- CPU通过控制线发出端口读命令,选中端口所在的芯片,并通知要从中读取数据。
- 端口所在的芯片将60h端口中的数据通过数据总线送入CPU。
注:更详细的动态演示见“12端口的读写”——6:45~8:45。
I/O地址 | 分配说明 | I/O地址 | 分配说明 |
---|---|---|---|
00-1f | 8237A DMA控制器1 | 170-177 | IDE硬盘控制器1 |
20-3f | 8259A可编程中断控制器1 | 1f0-1f7 | IDE硬盘控制器2 |
40-5f | 8253/8254可编程中断计数器 | 278- 27f | 并行打印机端口2 |
60-6f | 8255A可编程外设接口电路 | 2f8-2ff | 串行控制器2 |
70-71 | 访问CMOS RAM/实时时钟RTC端口 | 378-38f | 并行打印机端口1 |
80-9f | DMA页面寄存器访问端口 | 3b0-3bf | 单色MDA显示控制器 |
a0-bf | 8259可编程中断控制器2 | 3c0-3cf | 彩色CGA显示控制器 |
c0-df | 8237A DMA控制器2 | 3d0-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芯片
经常折腾系统的同学会发现,即使断网断电,重新开机后系统也能知道当前的时间。这是因为主板上有一个附带纽扣电池供电的 CMOS RAM 芯片,该芯片包含一个实时时钟、一个128 Byte的RAM存储器,实时时钟的时间、系统配置信息、相关的程序(用于开机时配置系统信息)都存储在这个RAM中。并且 CMOS RAM 芯片靠电池供电,所以关机后其内部的实时钟仍可正常工作, RAM 中的信息不丢失。CMOS RAM 内部有两个端口,CPU 通过这两个端口读写该芯片:
- 70h地址端口:存放要读写的CMOS RAM单元的相对地址(见下图)。
- 71h数据端口:存放从选定的单元中读取的数据,或要写入到其中的数据。
读取数据的步骤:将要读取的单元地址送入 70h 地址端口,再从数据端口 71h 读出数据。
时间格式信息如下图所示,年/月/日/时/分/秒 的相对地址如下,这6个信息长度均为1字节,高4位和低4位均使用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 ; 行号,共25行 mov dl,0 ; 列号,共80列 int 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。
- 可屏蔽中断:CPU可以不响应的外中断。当CPU检测到可屏蔽中断信息时,若IF=1,则CPU在执行完当前指令后响应中断,引发中断过程;若IF=0,则不响应该中断。几乎所有由外设引发的外中断都属于此类,比如键盘输入、打印机请求。
- 不可屏蔽中断:CPU必须响应的外中断。当CPU检测到不可屏蔽中断信息时,则在执行完当前指令后,立即响应,引发中断过程。一般是在系统中有必须处理的紧急情况发生时,用来通知CPU的中断信息,比如系统掉电。
注:8086CPU的不可屏蔽中断的中断类型码固定为2。
之前提到,CPU在执行指令过程中,可以检测到发送过来的“中断信息”,引发中断过程。这些中断信息分为“内中断”(见 6.6节)、“外中断”。“外中断”就是由外部设备发生的事件引起的中断。如上图所示,外中断分为两类,它们的中断过程如下:
【可屏蔽中断所引发的中断过程】
- 取中断类型码n。由于是外中断,所以中断类型码是通过数据总线送入CPU。
- 标志寄存器入栈,IF=0,TF=0。这里IF=0表示进入中断处理程序后,禁止其他的可屏蔽中断。
- CS 、IP 入栈。
- 更新(IP)=(n*4),(CS)=(n*4+2),由此转去执行中断处理程序。
【不可屏蔽中断的中断过程】
- 中断类型码固定为2,标志寄存器入栈,IF=0,TF=0。
- CS、IP入栈。
- 更新(IP)=(8),(CS)=(0AH),执行中断处理程序。
6.14 PC机键盘的处理过程
上一小节简单介绍了外中断分类,本小节以“键盘”为例,讲解如何处理外中断。首先来看看键盘输入的原理。键盘上的每一个键相当于一个开关,键盘中有一个芯片对每一个键的开关状态进行扫描,若有变化则产生相应的“扫描码”,然后送入主板上的相关接口芯片的寄存器中,该寄存器的端口地址为60H。“扫描码”的大小是一个字节,由于每个键有两种状态,显然最大支持128个键,而现在的键盘最大是108键位,所以完全够用。键盘芯片和端口通信的过程如下:
- 按下按键时,开关接通,芯片产生一个扫描码(该按键的通码)送入端口60H中。
- 松开按键后,开关断开,芯片再产生一个扫描码(该按键的断码)送入端口60H中。
注:通码+80H=断码,比如g键的 通码=0010_0010B=22H、断码=1010_0010B=a2H。
需要说明的是,键盘芯片输入给端口的扫描码都会保存到内存中的BIOS键盘缓冲区。BIOS键盘缓冲区是系统启动后,BIOS专用于存放 int 9
中断例程所接收的键盘输入的内存区,可以存储15个键盘输入(现代使用操作系统等方式存储字符,可以存储成千上万个字符)。一个键盘输入用一个 字单元(16bit) 存放,高位字节存放扫描码,低位字节存放对应的ASCII码,另外对于修饰键等其他键来说,在内存中有专门用于存放其状态的 “键盘状态字节(0040:0017H)”,每一位所表示的键位如下图:
最后下图给出了常见的键盘布局:
- 字符键:经常用到的输入按键。
- 功能键:F1~F12,不同的键盘有不同的功能定义,通常配合Fn使用。
- 专用键:除了“Start/Win菜单键”、“Esc”退出键,另外三个基本上没怎么用过。
- 修饰键:所谓“修饰(Modify)”就是本身没啥用,但是可以修改其他按键的功能。都是老熟人了。
- 导航键:基本上就用个“上下左右”。
- 数字小键盘:比较“长”的键盘上就会有,电赛啥的也会有一个专门的小键盘模块。
- 媒体按键:多出来的这4个键其实也看键盘厂家安排。
注:知乎“「科普」机械键盘配列(布局)”、英文维基百科“Keyboard layout”、稚晖君永远滴神“【自制】我做了一把模块化机械键盘!【软核】”。
最后以输入’a’为例,总结一下键盘输入的中断处理过程,其他外设也类似:
- 键盘输入。键盘芯片产生a键的通码 1eH,并送入主板上的端口60H中。
- 引发9号中断。主板60H处相关芯片会向CPU发起可屏蔽中断请求,中断类型码为9。CPU检测到该请求后,若IF=1,则响应中断,转去执行9号中断。
- 执行
int 9
外中断例程。a. 读出 60H 端口中的扫描码。
b. 若是字符键,将该扫描码、ASCII码送入内存中的BIOS键盘缓冲区。若是控制键,则将其写入内存中的存储状态字节。
c. 对键盘系统进行相关的控制,如向相关芯片发出应答信息。注:动态演示见视频“15PC机键盘的处理过程”——11:15~12:52。
6.15 定制键盘输入处理
上一小节介绍了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 用中断响应外设
硬件中断【
int 9h
】
- 触发源:“键盘输入”这个动作。
- 功能:从 60h 端口读出扫描码,并将其转化为相应的ASCII码或状态信息,存储在内存的指定空间(键盘缓冲区或状态字节)中。若超过键盘缓冲区的容量,会自动覆盖掉最开始的数据。
BIOS中断【
mov ah,0
、int 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,0
、int 16h
】的介绍可以看到,两者是一对相互配合的程序,int 9
向键盘缓冲区中写入,int 16h
从缓冲区中读出。只是它们写入和读出的时机不同,int 9
在有键按下的时候向键盘缓冲区中写入数据;而int 16h
是在应用程序对其进行调用的时候,将数据从键盘缓冲区中读出。使用时,可以根据需求调用不同的中断例程。于是总结一下,BIOS中断int 16h
中断例程0号功能的实现过程:
- 检测键盘缓冲区中是否有数据。
- 没有则继续做第1步。所以是阻塞等待!!
- 读取缓冲区第一个字单元中的键盘输入。
- 将读取的扫描码送入ah,ASCII 码送入al。
- 将己读取的键盘输入从缓冲区中删除。
注:动态演示见“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
从键盘缓冲区中读取键盘的输入,本小节进一步演示对于字符串的处理。并且进一步仿照中断功能号,对同一程序设定不同的功能。
【代码示例】设计一个最基本的字符串输入程序,需要具备下面的功能:
- 在输入的同时需要显示所有输入的字符串。
- 输入退格键,能够删除一个已经输入的字符。
- 在输入回车符后,字符串输入结束。
代码思路:显然需要定义一个“数组”存储字符串。字符串输入程序不断的调用
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 ; 显示行索引,共25行 mov dl,0 ; 显示列索引,共80列 call 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的低8位 mov 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 磁盘读写
6.14~6.18节一直在使用“键盘”作为演示,本小节介绍另一个外设——“磁盘”。首先来介绍一下磁盘的物理结构,如上图所示,磁盘本身分为不同的盘面,磁头是一组小磁头组成,每个盘面都由不同的小磁头进行读取(于是“磁头号”也就表示盘面)。另外,对于单个盘面来说,会按照同心圆分割成不同的环形磁道,每个磁道又可以切分成不同的小扇区。于是根据这个物理构造,我们便可以使用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 ; 0面 mov 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 ; 0面 mov 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 让计算机唱歌
终于到了本章的最后一节!本小节来做一个实际的应用——让计算机读取磁盘文件并发声。首先来看看计算机发声原理。在“6.11节”中的代码示例中,我们使用 端口61h(8255芯片) 的低两位控制扬声器开关、使用 端口42h(8253芯片) 来控制声音频率。具体原理如下图,8253芯片本质上就是一个“分频器”,通过对原始时钟1.19318MHz进行计数,产生不同频率的脉冲,这个计数器的阈值是1个字(16bit),通过两次 out 42h,...
设置。8255则是一个简单的开关,延时周期自定义,如下给出了两个芯片的配置代码:
注: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表示范围。于是便可以得到下面的乐谱时间:
; 新年好“数字化”乐谱
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]中存放声音的频率值。 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
注:这个程序就不放视频了,但是效果可以说是“呕哑嘲哳难为听”🤣。