written by PeterAlbus,Copyright © 2022 - SHOU 1951123 Hong Wu
写在前面:本学期这门课在课表上的名称叫《微机原理与接口技术》,冯老师为我们选的课本则是王爽老师的《汇编语言(第四版)》,开学的课上讲的也非常清楚,希望通过汇编语言=>理解微机原理与接口技术。实际效果倒是较为微妙。由于本笔记是根据课本编写,在反复研读了课本内容后,最终我还是决定把标题定为“汇编语言”
# 汇编语言
# 附录 寄存器
8086CPU的14个寄存器:AX,BX,CX,DX,SI,DI,SP,BP,IP,CS,SS,DS,ES,PSW
通用寄存器 :AX,BX,CX,DX
用来存放一般性的数据。可以存放16位数据。这四个寄存器也都可以拆分为两个可独立使用的8位寄存器
- CX 用于循环,loop指令在循环结束时会使CX-1,并使用CX判断是否继续执行
- BX可以出现在[]作寻址用
- AX和DX在div指令中会用于存放被除数
段寄存器:CS,DS,SS,ES
用来在CPU访问内存时提供段地址
- CS:程序段寄存器
- DS:数据段寄存器
- SS:栈段寄存器
指令指针寄存器:IP
存放下一条要执行的指令所在位置相对于CS的段内偏移
栈顶寄存器:SP
存放栈顶的段内偏移
不可拆分为两个八位寄存器的寄存器:SI,DI,BP
都可以用于寻址,BP与BX类似,SI或DI则可以与BP或BX组合,同时出现两个寄存器在[]中进行寻址
使用BX时,隐式的段地址为DS,使用BP时,隐式的段地址为SS
标志寄存器:PSW,书中简称为flag,有9个有效的标志位,具体含义在第11章
# 第一章 基础知识
# 1.1 机器语言
机器语言是机器指令的集合。
通过将一串二进制数字传递给计算机(过去,程序员使用打孔的方式编程),即一串由0和1组成的序列。
计算机读取机器码进行执行。
# 1.2 汇编语言的产生
由于机器码的晦涩难懂,难以记忆,产生了汇编语言。汇编语言把机器码用接近人类语言的方式表示,便于记忆。
例如想要把寄存器BX的内容送到AX中:
机器指令:1000100111011000
汇编指令:mov ax,bx3
程序员通过汇编指令编写源程序,通过编译器把汇编指令转换成机器码,交给计算机,由计算机最终执行。
# 1.3 汇编语言的组成
汇编语言由3类指令组成:
- 汇编指令:可以转换为对应的机器码
- 伪指令:没有对应的机器码,由编译器执行,辅助编译器进行编译(与编译器沟通的桥梁)
- 其他符号:如+,-,*,/等,由编译器识别
# 1.4 存储器
CPU负责控制计算机以及进行运算。CPU的工作需要两个内容:指令和数据。
指令和数据在存储器中存放。
汇编语言的核心就是通过将指令写入内存,让CPU从内存中读取指令,并通过这些指令控制CPU对于内存/寄存器中的数据进行计算。
# 1.5 指令和数据
在内存和磁盘上,指令和数据没有区别,都是一串二进制信息。
计算机在程序运行时,会把内存中的程序读取到寄存器内进行执行,此时这一串二进制信息会被解析为指令。
而计算机也可以把同样一段二进制信息当作简单的数据访问。
# 1.6 存储单元
存储器会被划分为若干个存储单元,从0开始编号。
一个微型计算机的存储单元可以存储8位二进制数,即8个bit(比特/位)。8个bit相当于1个Byte(字节)。
微型存储器的容量以字节为最小单位来计算。
# 1.7 CPU对存储器的读写
CPU在读取内存中的数据时,要和存储器芯片交互以下数据:
- CPU给出要操作的存储单元的地址信息——通过地址总线传输
- CPU给出要对存储单元进行怎么样的操作(读/写)——通过控制总线传输
- CPU给出要写入的数据或存储器芯片给出读出的数据——通过数据总线传输
地址总线、控制总线和数据总线就是总线根据传送信息的不同,从逻辑上分为的三个类别
# 1.8 地址总线
地址总线传递的信息数量决定了CPU能够对多少存储单元进行寻址。
一个CPU有n根地址线,则可以说这个CPU的地址总线的宽度为N,这样的CPU可以寻找2^N^个内存单元
# 1.9 数据总线
数据总线的宽度决定了CPU和外界传送数据的速度。8根数据总线一次可传送8位二进制数据(一个字节)。
8086cpu(汇编语言的学习基本都将基于本cpu)是16位cpu,有16根数据总线,一次可传送16位二进制数据。
# 1.11 内存地址空间 *
cpu将系统中的各类存储器看作一个逻辑存储器,通过总线能访问到这个逻辑存储器的各个地址空间。这个逻辑存储器就是汇编语言中要面对的整个内存地址空间。
# 第二章 寄存器
典型的CPU由运算器、控制器、寄存器组成。它们由CPU的内部总线相连(外部总线见第一章,访问内存地址空间)
对于汇编语言程序员,主要需要面对和操作的就是CPU的寄存器,在汇编的代码中,直接写寄存器的名称即可访问寄存器。
8086CPU有14个寄存器:AX,BX,CX,DX,SI,DI,SP,BP,IP,CS,SS,DS,ES,PSW
课本选择不对该14个寄存器进行一次性介绍。而本笔记作为学期末的整理,会将常用的寄存器功能和汇编程序指令整理在本文开头的附录中。
# 2.1 通用寄存器
8086CPU的所有寄存器都为16位,可以存放两个字节。
AX,BX,CX,DX是8086的四个通用寄存器,用于存放一般性的数据。
AX占用16个存储单元:0-15,
第0个存储单元存储最低位数据,第15个存储单元存储最高位数据。
为了和上一代的8位寄存器兼容,这四个寄存器也都可以拆分为两个8位寄存器。
例如AX可拆分为AH和AL。AH代表AX的高八位(high),AL代表AX的低八位(low)。
# 2.2 字在寄存器中的存储
8086CPU可以一次处理两种尺寸的数据,字节可以存放在8位寄存器中,一个字有两个字节(对于16位CPU来说),可以存储在一个16位寄存器中。
在学习完整个汇编语言后,回头看该章节意义如下:提示汇编中通过地址取出值,可能取出该地址后的一个字节,也可能取出该地址后的一个字 甚至两个字。明确指出的例子有jump dword ptr
和jump word ptr
的区别,隐含的则例如mov ax,ds:[0]
和mov ah,ds:[0]
从ds:[0]
取出的值位数是不一样的。
在讨论寄存器中的数据时,常常使用十六进制描述,因为十六进制和二进制为固定的4位对应一位,可以直观地看出数据的位数。为了区分不同的进制,十六进制数据后会加上H(HEX),二进制数据的后面会加上B(Binary),十进制数据后面则什么也不加。例如:20000,4E20H,0100111000100000B
# 2.3 几条汇编指令
该章节正式开始了汇编指令的学习。在写一条汇编指令或一个寄存器名称的时候不区分大小写。
本章节作为汇编语言的入门,主要学习了mov
和add
两条指令。
mov
可以将一个数据存入寄存器,也可以将另一个寄存器或是内存中的数据存入寄存器。
add
则可以对两个数据进行加法运算,其中第参数必须是寄存器,结果将会存储在该寄存器中。
使用举例(汇编语言以一行作为一条指令,结尾不需要分号,分号起到注释的作用):
mov ax,18 ;将数据18送入寄存器AX
mov ah,78 ;将数据78送入寄存器AH
add ax,8 ;将寄存器AX中的数值加上8
mov ax,bx ;将寄存器BX中的数据送入寄存器AX
add ax,bx ;将AX和BX相加,结果存储在AX中
对于上面的指令,在c语言中表示如下:
ax=18;
ah=78;
ax+=8;
ax=bx;
ax+=bx;
注意事项:
- 两个操作对象的位数应当是一致的,例如
mov ax,bh
是不合法的指令。 - 加法若产生进位,寄存器当中没有空间进行储存,将会丢失。
# 2.4 物理地址
上述的用例交给CPU处理的都是简单的数据或寄存器。如果要访问内存单元,则需要给出内存单元的地址。CPU需要通过存储器的地址总线将存储器的物理地址送入存储器。8086CPU如何形成这个物理地址将在接下来的部分讨论。
# 2.5 16位结构的CPU
由于8086为16位CPU,16位结构描述了CPU具有以下几个方面的特性。
- 运算器一次最多处理16位数据。
- 寄存器最大宽度为16位。
- 寄存器和运算器之间通路为16位。
因此8086对于地址数据也仅仅一次能传输16位。
# 2.6 8086CPU给出物理地址的方法
8086是16位CPU,但有20位的地址总线
为了发出20位的地址,8086CPU将两个16位地址合成一个20位的物理地址。
这两个16位地址分别是段地址和偏移地址。
地址加法器采用 物理地址=段地址*16+偏移地址 的方法合成物理地址。
# 2.7 “段地址*16+偏移地址=物理地址”的本质含义
CPU在访问内存时,用一个基础地址(段地址*16)加上一个相对于基础地址的偏移,给出了物理地址。
而段地址*16是在十进制下的表现。对于原理的解释,在十六进制下更加清晰。
对于十六进制:段地址*16即为段地址*10H,即段地址后增添一个0。
通过这种方法就可以用4位十六进制(16位二进制)表达更大范围(5位十六进制,20位二进制)的基础地址,而这样表达的精确度不够(基础地址只能是16的倍数),就使用偏移地址来进行修正。
# 2.8 段的概念
尽管有段地址的概念,不代表内存被划分成了一个个段,每个段对应一个固定的段地址。
段的划分是虚拟的,不同的段地址加上不同的偏移地址仍可以表示同一个内存单元。
同一个段地址,由于偏移地址有16位,最大可以寻找64KB的内存单元,因此虚拟上,一个段的最大长度为64KB。
# 2.9 段寄存器
8086CPU有4个段寄存器,用于在访问内存时提供4个段的段地址:CS,DS,SS,ES
# 2.10 CS和IP
CS和IP是8086CPU最重要的两个寄存器。它们只是了CPU当前要读取指令的地址。
CS为代码段寄存器,IP为指令指针寄存器。
任意时刻,当8086CPU需要执行一条指令时,会读取CS和IP中的值,通过地址加法器得出物理地址,然后通过该物理地址从CPU取出一条指令放入内存并执行,此时IP指向下一条指令。
8086CPU中,需要取出的指令长度可以通过读取到的前16个字节获取
# 2.11 修改CS、IP的指令
对于AX等通用寄存器中的值,可以使用mov
指令修改,但CS,IP是不能用该指令设置的。
能修改CS、IP的指令统称为转移指令,最简单的指令为jmp
jmp 2AE3:3 ;执行后CS=2AE3H,IP=0003H,注意,这种形式仅可在Debug中使用而编译器不识别
若仅仅想修改IP,也可用如下用法:
jmp ax ;用ax寄存器的值修改IP,含义上类似mov IP,ax
# 2.12 代码段
我们可以将长度为N(<64KB)的一段代码,存在一组地址连续,起始地址为16倍数的内存单元中。
而计算机并不知道这是一段代码段。
只要将CS:IP修改为指向这段内存单元的起始地址(第一条指令的首地址),CPU就会把这段数据当作指令进行处理。
对于汇编语言的学习,最佳还是进行编程和运行进行调试,这个过程需要使用DOSBox模拟8086CPU的环境,限于作者精力和篇幅,详细使用方式将不会在笔记中出现,可自行通过课本或搜索引擎进行学习。因此课本上debug相关的内容可能突兀地出现在接下来的笔记中,见谅。
# 第三章 寄存器(内存访问)
# 3.1 内存中字的存储
CPU中使用16位寄存器储存一个字,高八位存放高位字节,低八位存放低位字节。
如果用0、1两个内存单元存储数据20000(4E20H),4E将存储在编号为1的内存单元中,20将存储在20的内存单元中。
这两个单元可以看作一个起始地址为0的字单元。
# 3.2 DS和[address]
CPU要读写一个内存单元的时候,必须先给出这个内存单元的地址。8086CPU有一个DS寄存器,通常用来存放数据的段地址。当使用[address]时,将默认把ds作为段地址,address作为段内偏移。
例如想要读取10000H单元的内容,可以用如下的程序段进行:
mov bx,1000H
mov ds,bx
mov al,[0]
这里的[0]
说明想要送入al的值段内偏移为0,而段地址则自动从ds中取出。
为何将段地址1000H送入ds要先将其送入bx?
因为8086CPU不支持直接将数据传入段寄存器(这属于8086CPU硬件设计问题,不必深究)。
# 3.3 字的传送
mov指令可以进行字节型数据的传输,也可以进行字的传输。只要给出16位寄存器就可以进行16位数据的传送。
# 3.4 mov,add,sub指令
mov,add,sub指令都有两个操作对象,它们有以下几种格式:
mov 寄存器,数据
mov 寄存器,寄存器
mov 寄存器,内存单元
mov 内存单元,寄存器
mov 段寄存器,寄存器
mov 寄存器,段寄存器
mov 内存单元,段寄存器
mov 段寄存器,内存单元
add 寄存器,数据
add 寄存器,寄存器
add 寄存器,内存单元
add 内存单元,寄存器
sub 寄存器,数据
sub 寄存器,寄存器
sub 寄存器,内存单元
sub 内存单元,寄存器
# 3.5 数据段
对于8086CPU,可将一段连续的内存单元存放代码,作为代码段。
同样的,可以讲一段内存作为数据段,通过ds存储数据段的段地址。
# 3.6 栈
栈是一种具有特殊访问方式的存储空间,最后进入这个空间的数据,最先被取出。
# 3.7 CPU提供的栈机制
8086CPU提供相关的指令来以栈的方式访问内存空间。
最基本的两个是PUSH和POP。
pop ax
表示从栈顶取出数据送入ax。
push ax
表示将ax的数据送入栈中。
CPU通过SS和SP两个寄存器确定栈的位置。
任意时刻,SS:SP指向栈顶元素
push ax
执行时,由以下两步完成
- SP=SP-2,让SS:SP指向栈顶前面的单元
- ax中的内容送入SS:SP指向的内存单元处
pop ax
执行时的动作则相反
- 将SS:SP指向的内存单元处的字取出放入ax
- SP=SP+2
# 3.8 栈顶超界的问题
通过SS和SP仅仅能保证在出栈和入栈时能找到栈顶的位置。
当栈为空时使用pop或栈为满时使用push,可能会面临栈顶超界的问题。这是危险的,可能会覆盖栈外有效的数据。
8086CPU并没有寄存器记录栈的大小,在使用中需要程序员注意栈顶超界的问题。
# 3.9 push和pop指令
这两个指令可以是如下格式:
push 寄存器
pop 寄存器
push 段寄存器
pop 段寄存器
push 内存单元
pop 内存单元
例子:
将10000H~1000FH这段空间当作栈,初始为空,将AX、BX、DS中的数据入栈
mov ax,1000H
mov ss,ax
mov sp,0010H ;设置初始栈顶位置
push ax
push bx
push ds
加入将10000H-1000FH这段空间当作栈,数据从1000F开始填充,直到填满时栈顶为10000H,因此初始状态栈顶为0010H,当push数据时,sp=sp-2,栈顶变为1000E,写入两个字节的数据到1000E和1000F。
# 3.10 栈段
与代码段,数据段类似,我们可以将一组连续的内存单元(依旧是小于64kb,起始地址为16的倍数)当作栈空间使用,将SS:SP指向这个栈,从而定义了一个栈段。栈中没有元素时,SS:SP指向栈的最底部单元下面的单元。参考3.9末尾的分析。
# 第四章 第一个程序
在先前的章节中,仅仅讲述了一些简单的汇编指令和寄存器,对他们的测试可以通过DOSBox中的Debug进行。而本章节将会讲述编写一个完整的汇编语言程序,之后用编译和连接程序将他们编译连接成可执行文件的过程。
# 4.1 一个源程序从写出到执行的过程
使用文本编辑器编写汇编源程序
对源程序进行编译连接
执行可执行文件中的程序
这一步操作系统会按照可执行文件的描述信息,把可执行文件中的机器码加载入内存,并进行相关初始化(设置CS:IP)
# 4.2 源程序
一段简单的汇编语言源程序:
assume cs:codesg
codesg segment
mov ax,0123H
mov bx,0456H
add ax,bx
add ax,ax
mov ax,4c00H
int 21H
codesg ends
end
伪指令
源程序中包含汇编指令和伪指令。汇编指令有对应的机器码,最终会被编译为机器指令。伪指令则用来辅助编译器进行相关的编译工作。该源程序中出现了3种伪指令:
XXX segment XXX ends
segment和ends是一对成对使用的伪指令。功能是定义一个段,XXX位置填写标记段的段名。在定义段后,会有其他很多地方用到段,将在稍后介绍。
end
注意:end与ends并不是相同的指令。end用于标记一个汇编程序的结束。汇编源程序种必须有结束的标记。
assume
该伪指令含义为“假设”,用于把某一寄存器和定义的段关联。前方用segment...ends定义的段就在此处进行使用。在实例的源程序中,
assume cs:codesg
把定义的名为codesg
的段与cs寄存器关联,即设置段寄存器cs的值为codesg
段的段首地址。
笔记作者简评:assume指令常用于把程序中的代码段赋值给cs,另外常用的写法是
assume ds:datasg
和assume ss:stacksg
等,但在之后章节的学习中,ds的值往往在程序中仍需要重新进行一次赋值,通过assume指令设置的ds值在内存中观察,会和实际的ds值相差10h(这是由于PSP的缘故),而assume cs也未必可靠,如果代码段不是程序中的第一段,仍需要加一个start
和end start
的伪指令来标记程序的入口,因此,assume指令的意义到底如何,笔者仍在思考中。源程序中的程序
程序代码由编译器处理
标号
在伪指令中定义的标号,如
codesg
,最终将被编译、连接程序处理为一个段的段地址。程序的结构
对于汇编程序结构的总结:编写一个个段,代码段存放要执行的指令,数据段栈段存放数据,使用assume将它们和寄存器连接起来。最后指出程序在何处结束。
程序返回
我们的程序在编译后将最终在DOSBox的DOS系统中运行,DOS系统有一个程序,调用编译连接好的程序的可执行文件,将CPU交给这个程序。而这个程序运行完成后,也要把控制权交回给调用它运行的程序,这个过程称为返回。
程序返回的指令为:
mov ax,4c00H int 21H
返回的原理是调用编号为4c的中断,具体理由不必深究。只需了解这两条指令可以实现程序返回。
语法错误和逻辑错误
与其他高级语言类似,编译器在程序编译器可以发现语法错误,但逻辑错误只有在程序运行时才能被发现。
# 4.3-4.7 源程序的编辑、编译、连接以及exe可执行文件的执行
这几个章节主要内容为DOSBox中编译源程序以及执行文件的过程。
通过masm指令对于.asm文件进行编译,生成.obj文件。
通过link指令对.obj文件进行连接,生成exe文件。
最终在目录下输入exe文件名即可执行编写的程序。
# 4.8 谁将可执行文件中的程序装载
DOS系统中有一个command程序,我们在DOSBox中输入指令,都由这个程序读取、执行、输出结果。
当直接执行exe文件时,则是这个command程序把可执行文件加载到内存中。执行完毕后返回,CPU重新开始运行command。
# 4.9 程序执行过程的跟踪
想要观察程序的执行过程,可以使用Debug 1.exe
,这条指令会使用Debug将可执行文件加载入内存,但Debug仍然不放弃对CPU的控制权,你可以通过Debug的相关命令对程序的运行过程进行观察。
r指令可以查看寄存器的情况。
t指令可以单步执行(到了int,需要使用p指令)
d指令可以查看指定地址的内存
DOS系统EXE文件中程序的加载过程如下:
加载完毕后,ds存放着程序所在内存区的段地址(SA)。偏移为0。
这个内存区的前256个字节存放的则是PSP,DOS用来和程序通信。
因此,源代码中的程序开始,地址为SA+10H:0
# 第五章 [BX]和loop指令
[BX]和[0]类似,表示内存单元,偏移地址为0/BX中的数值。段地址一样默认在ds中
与课本相同,本笔记也将使用()来表示一个寄存器或一个内存单元中的内容,使用idata代表一个常量。
# 5.1 [BX]
使用举例
mov ax,[bx]
mov [bx],ax
BX中的数据作为一个偏移地址EA
第一条指令表示把DS:EA的数据送入AX,第二条指令表示把AX中的数据送入内存DS:EA。
# 5.2 Loop指令
loop指令格式为:loop 标号。
标号是在源代码中对一条指令的标记,标号代表一个地址,这个地址处有一条指令。
当CPU执行loop指令时,要进行两步操作
- (cx)=(cx)-1
- cx中的值不为0,就转至标号处,执行标号处的程序
因此我们常用loop指令来实现循环功能,cx中存放循环次数。
使用样例:
assume cs:code
code segment
mov ax,2
mov cx,11
s: add ax,ax
loop s
mov ax,4c00h
int 21h
code ends
end
该程序的目的是计算2^12
这里的s就代表一个地址,这个地址处有一条指令:add ax,ax
# 5.3 在Debug中跟踪还loop指令实现的循环程序
该段根据课本实际上级操作可以加深对loop的理解。
使用u 0B3D:0命令可以查看内存中的程序
使用g 0012命令可以让程序直接运行跳转到(IP)=0012h的位置
使用p命令可以一次将所有loop执行完,直到cx=0
# 5.4 Debug和汇编编译器masm对指令的不同处理
对于Debug,[0]将被视为一个偏移为0的内存单元中的数值,而对于masm,[0]将被视为一个常量。
因此在编写汇编源程序时,如果要将常量作为偏移地址,必须在之前显式的给出段地址,例如ds:[0]
# 5.5 loop和[bx]的联合应用
可通过loop的每次循环,更改bx的值(inc bx
),实现对内存单元的遍历。
本章节还提到,如果需要累加多个占1个内存单元的数据,但它们的和占用两个内存单元,可以先用字寄存器做中专,然后累加ax这种字寄存器。
# 5.6 段前缀
仅仅通过[]给出偏移地址时,段地址默认在ds中,而也可以显式的给出其他段寄存器来作为段地址,这在汇编语言中被称为段前缀。例如:cs:[bx]
# 5.7 一段安全的空间
使用汇编语言在DOS系统中操作内存时,有权利操纵那些存储着重要信息的内存,而不像windows,unix等系统对于重要的硬件有层层的保护。因此我们必须保证我们操作的内存没有其他程序使用。直接用汇编语言去操作真实的硬件,了解早已被层层系统软件掩盖的真相也正是汇编语言的魅力所在。
DOS和其他的合法程序一般不会使用0:200-0:2ff这段空间,以后我们需要直接向内存写入内容时,一般会使用这段空间。
# 5.8 段前缀的使用
想要将内存ffff:0~ffff:b
中的数据复制到0:200~0:20b
中
可以先把0:200~0:20b
看作0020:0~0020:b
,让两者的偏移地址相同
之后通过两个不同的段寄存器储存段地址,使用两个段前缀,就可以快速的在两个段间传递数据。
# 第六章 包含多个段的应用程序
0:200-0:2ff这段空间相对安全,而如果想要使用更多空间,需要合法的通过操作系统取得空间。
想要合法的取得空间,可以在加载程序的时候为程序分配,也可以在程序执行的过程中向系统申请。本课程中将仅学习第一种方法。
在先前的程序中,汇编程序都合法的获得了操作系统分配的代码段空间。如果想要在程序被加载的时候分配到空间,需要在源程序中进行说明。一般不同用途的空间(栈、数据),会通过不同的代码段来区分。
接下来的内容将先尝试不适用多个段,在同一个段中存放数据、代码、栈,之后尝试了解程序中使用多个段。
# 6.1 在代码段中使用数据
如何找到一串连续的内存单元存储数据?
规范的角度,我们应当让系统来为我们分配。我们可以在程序中定义数据,这些数据被编译连接后放入可执行文件中,可执行文件的程序被加载入内存时,这些被定义的数据也会被分配一段空间,加载到内存中。
例:
assume cs:code
code segment
dw 0123h,0456h,0789h,0abch,0defh,0fedh,0cbah,0987h
mov bx,0
mov ax,0
mov cx,8
s: add ax,cs:[bx]
add bx,2
loop s
mov ax,4c00h
int 21h
code ends
end
这里使用了dw,含义为define word,此处使用dw定义了8个字形数据,每个占用两个存储单元(16字节)
在程序执行过程中,因为这些数据放在程序段的开头,所以可以通过cs作为段地址,加上段内偏移找到这些数据。
但又因为这段数据放在了程序段的开头,这段数据也会被作为程序加载并尝试执行,这个过程会出错。为了解决这个问题,需要手动设置ip指向第一行代码,或者使用如下操作:
assume cs:code
code segment
dw 0123h,0456h,0789h,0abch,0defh,0fedh,0cbah,0987h
start:
mov bx,0
mov ax,0
mov cx,8
s: add ax,cs:[bx]
add bx,2
loop s
mov ax,4c00h
int 21h
code ends
end start
这里在程序的第一条代码前加上了标号start,同时在end处加上了start这个标号。
这样,end指令不仅可以向编译器告知程序的结束位置,也可以向编译器描述程序的入口,可执行文件运行时会根据入口修改CS:IP指向的位置。
# 6.2 在代码段中使用栈
要在代码段中使用栈,和使用数据类似,需要获得一段合法的内存空间,然后将SS:SP指向它。
想要获得合法的内存空间,依旧可以使用dw:
dw 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
这样定义16个字型数据,将取得16个字的内存空间,将栈顶指向这个数据段的后一个单元,就可以把这段数据当作栈使用。
# 6.3 将数据、代码、栈放入不同的段
将数据、代码、栈放到同一个段中的限制:
- 程序混乱
- 一个段的容量不能超过64KB(8086cpu的限制)
想要用多个段来存放数据、代码、栈,做法如下所示:
assume cs:code,ds:data,ss:stack
data segment
dw 0123h,0456h,0789h,0abch,0defh,0fedh,0cbah,0987h
data ends
stack segment
dw 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
stack ends
code segment
start:
mov ax,stack
mov ss,ax
mov sp,20h
mov ax,data
mov ds,ax
mov bx,0
mov cx,8
s: push [bx]
add bx,2
loop s
mov bx,0
mov cx,9
s0: pop [bx]
add bx,2
loop s0
mov ax,4c00h
int 21h
code ends
end start
定义多的段的方法:和代码段并无不同,只要每个段有不同的段名即可
如何引用段地址:我们可以注意到,想要使用data的段地址,直接通过
mov ax,data
就可以把data段的段地址送入AX。一个段中数据的段地址可以由段名代表,而段偏移则需要看它在段中的位置。注意点就是和常量一样,不可以直接通过段名将段地址送到段寄存器中,要用普通寄存器做中转。data这样的段名会被编译器解析成一个数值。
# 第七章 更灵活的定位内存地址的方法
之前访问内存地址,[]内往往是单一的寄存器或idata,实际上可以用更灵活的方式定位这些地址,方便编程的过程。如果理解困难,可以翻看课本,通过具体问题理解这些特性该怎么使用。本笔记将仅仅简单地介绍这些特性。
# 7.1 and和or指令
- and指令:逻辑与指令,两个操作数将按位与进行运算,结果存储在第一个操作数的位置
- or指令:逻辑或指令,两个操作数将按位进行或运算
# 7.2 关于ASCII码
ASCII码是一种编码方案,约定了用什么样的信息表示现实对象,例如用61H表示字符a。
当把这样的数据送到需要显示文字的地方,将会被解析为对应的字符。
# 7.3 以字符形式给出的数据
在汇编程序中,也可以以'.....'的形式以字符形式给出数据,存储在内存中时,它们将自动转换为ASCII码。
例如:
db 'unIX'
db 75H,6EH,49H,58H
这两行代码是等价的
Debug中使用d命令查看data时,也会以ASCII字符的形式显示出其中的内容。
# 7.4 大小写转换
由于大小写字母的ASCII码有如下规律:出了第五位外,大小写的其他各位都一样。因此要把数据置为大写,只要将第五位置为0,反之则能把数据置为大写。
这个操作可以用and和or指令完成。
and al,11011111可以将al内数据置为大写字母
or al,00100000可以将al内数据置为小写字母
# 7.5-7.10 不同的寻址方式
- [idata]使用一个常量来表示地址,可以直接定位一个内存单元
- [bx]用一个变量来表示内存地址,可以用于间接定位一个内存单元
- [bx+idata]使用了一个常量和一个变量,可以在一个起始地址的基础上定位内存单元(数组)
- [bx+si]用两个变量表示地址
- [bx+si+idata]用两个变量和一个常量表示地址
在实际程序的编写过程中,往往会有许多灵活的寻址需求,比如需要操作多个连续字符串里的每个字符等,灵活运用这些不同的寻址方式可以事半功倍。
简单总结可以说,在8086CPU中,可以出现在[]内用作寻址的寄存器有bx,si,di和之后会提到的bp,这四个寄存器可以单个出现,也可以从bx、bp中任选一个以及从si、di中任选一个,两两出现。此外[]内还可以出现idata。
# 第八章 数据处理的两个基本问题
本章节大致总结了计算机在数据处理过程中数据在哪,数据多长的问题。
# 8.1 bx,si,di和bp
如同第七章末尾内容,这四个寄存器可以用于寻址。
以下的用法都是合法的:
mov ax,[bx]
mov ax,[si]
mov ax,[di]
mov ax,[bp]
mov ax,[bx+si]
mov ax,[bx+di]
mov ax,[bp+si]
mov ax,[bp+di]
mov ax,[bp+si+idata]
mov ax,[bp+di+idata]
mov ax,[bx+si+idata]
mov ax,[bx+si+idata]
在[...]中若使用bp,则不同于使用其他时候的ds,隐式的段地址默认为ss
# 8.2 机器指令处理的数据在什么地方
# 8.3 汇编语言中数据位置的表达
- 立即数(idata),在汇编指令中直接给出
- 寄存器,在汇编指令中给出寄存器名
- 段地址(SA)和偏移地址(EA),用SA:[EA]的方式给出,SA可以不写使用默认的,EA的各种灵活寻址方式在上一章刚刚介绍
# 8.4 寻址方式
寻址方式的详细介绍已在第七章给出,这里给出书上总结的表格
# 8.5 指令要处理的数据有多长
对于8086的CPU,既可以处理byte(8位二进制,一个字节)尺寸的数据,也可以处理word(16位二进制,两个字节,一个字)尺寸的数据。进行的是字操作还是字节操作,汇编语言会以以下方式处理:
通过寄存器名指明要处理的数据尺寸
在没有寄存器名存在的情况下,可以用<操作符> ptr的形式,指定要访问的内存单元的长度,<操作符>可以是word或byte
mov word ptr ds:[0],1 mov byte ptr ds:[0],1
没有寄存器存在时,显式指明要访问的内存单元长度非常有必要。
有的指令则会默认访问的是字还是字节
例如push指令,只进行字操作
# 8.6 寻址方式的综合应用
教材通过一个样例的方式,传达了一种结构化寻址的思想。
例如一个结构化的数据包含了多个数据项,可以通过[bx+idata+si]
的方式访问结构体的数据。bx
定位整个结构体,idata
定位结构体的某个数据项,si
定位这个数据线的每个元素。
这种定位方式可以通过更加贴切的书写方式:
[bx].idata
和[bx].idata[si]
表示
例如:[bx].10h[si]
# 8.7 div指令
div指令用于进行除法运算。
div指令的格式为:
div 寄存器
div 内存单元
- 除数有8位和16位两种,存储在寄存器或内存单元中
- 如果除数有8位,被除数则有16位,存放在AX中,如果除数有16位,被除数则有32位,存放在AX和DX中
- 如果被除数为16位,则AL会存储除法操作的商,AH会存储除法的余数。如果被除数有32位,则会在AX中存储除法操作的商,DX存储除法操作的余数
# 8.8 伪指令dd
db用来定义字节数据,dw用来定义字型数据。
dd用于定义dword双字数据。
使用dd定义的数据会占用两个字。
# 8.8 dup
dup使用时和dw,db,dd等数据定义伪指令配合使用,用于进行数据的重复
例:
db 3 dup (0)
db 0,0,0
上面的两行代码是等价的,使用dup重复定义了3次0
db 3 dup (0,1,2)
db 0,1,2,0,1,2,0,1,2
上面的两行代码亦是等价的,使用dup相当于重复的进行了三次定义
# 第九章 转移指令的原理
在第二章2.11部分,提到过可以修改IP,或同时修改CS和IP的指令统称为转移指令。
概括地讲,转移指令用于控制CPU执行内存中某处的代码。
8086CPU的转移指令分为以下几类:
- 无条件转移指令
- 条件转移指令
- 循环指令
- 过程
- 中断
# 9.1 操作符offset
offset是由编译器处理的符号,它的功能是取得标号的偏移地址。例如下面的程序:
assume cs:codesg
codesg segment
start:mov ax,offset start ;相当于mov ax,0
s:mov ax,offset s ;相当于mov ax,3
codesg ends
end start
上面的代码通过offset操作符取得了标号start的偏移地址0和s的偏移地址3
# 9.2 jmp指令
jmp位无条件转移指令,可以只修改IP,也可以同时修改CS和IP
jmp指令要给出两种信息。
- 转移的目的地址
- 转移的距离(段间转移、段内短转移、段内近转移)
# 9.3 依据位移进行转移的jmp指令
jmp short 标号
该格式的jmp指令可以实现段内短转移。它可以使IP向前转移时最多越过128个字节,向后转移时最多越过127个字节。
指令中的标号则是代码段中的标号。使用举例如下:
assume cs:codesg
codesg segment
start:
mov ax,0
jmp short s
add ax,1
s:
inc ax
codesg ends
end start
这里的jmp short s可以实现将ip转移到标号s处,即cs:0008处的功能。但如果将这句汇编语言翻译成机器码,将会是EB03,机器码中并没有给出转移终点的段内偏移。这是因为CPU在执行jmp指令时不需要转移的目的地址,在机器码中储存的是转移的位移,例如上面的例程,转移指令代表将IP=IP+3,就能够跳转到s标号的位置。这个位移是由编译器计算出来的。
因此,jmp short 标号
的功能为实现:(IP)=(IP)+8位位移,由于位移是8位,因此范围为-128~127
与此功能相似,jmp near ptr 标号
功能为:(IP)=(IP)+16位位移,位移为16位因此范围位-32768~32767
以上两条指令的位移量都是由编译程序在编译时算出的。位移=标号处地址-jmp指令后第一个字节的地址。
# 9.4 转移的目的地址在指令中的jmp指令
以上的指令中jmp编译为机器指令后,都是根据相对位置进行转移。
jmp far ptr 标号
则可以实现段间转移,又称为远转移。
编译器在编译该指令后,会在机器码中出现标号处的段地址和偏移地址,用这些数据修改CS和IP。
# 9.5 转移地址在寄存器中的jmp指令
jmp 16位寄存器
该指令在2.11节已经进行过介绍
# 9.6 转移地址在内存中的jmp指令
以下两种格式的指令可以实现转移地址在内存中的jmp指令:
jmp word ptr 内存单元地址
该指令实现段内转移,从内存单元地址处开始存放着一个字,是转移的目的偏移地址,内存单元地址可以用第七章讲述的任意寻址方式给出
jmp dword ptr 内存单元地址
实现段间转移,从内存单元地址处开始存放着两个字,高地址处的字是转移的目的段地址,低地址处是转移的目的偏移地址。
# 9.7 jcxz指令
jcxz指令为有条件转移指令,所有的有条件转移指令都是短转移,根据相对位移进行转移
指令格式:jcxz 标号
该指令的功能为,如果cx==0,则执行jmp short 标号
# 9.8 loop指令
loop指令为循环指令,循环指令也是短转移指令。
我们先前已经多次使用loop指令,通过这些章节的学习,不难看出loop 标号
指令的功能为:
先将cx减一
之后执行
jcxz 标号
指令,即如果cx==0,则执行jmp short 标号
# 9.9 根据位移进行转移的意义
为何对IP的修改会有许多指令是根据相对位置进行修改?
在接下来的章节中,常常有内容会需要将一段程序装配到另一段内存中,如果使用短转移指令,程序复制到任意位置,在进行短转移指令时,都可以正确的找到在源代码中标号标记的位置,而不会出现错误。
# 9.10 编译器对转移位移超界的检测
如果转移范围超过了转移指令的限制(jmp short
超过了-127~128,jmp near ptr
超过了-32768~32767)
编译器会进行报错。
# 实验九 根据材料编程
该实验是我放入这个整理的笔记的第一个实验。
这个实验的主要作用为传达了一个知识点,要通过汇编在屏幕进行输出,也是通过操作内存。通过向现实缓冲区输入数据可以实现在显示器上显示字符。
这个实验值得一做,以加深对于汇编语言本质为内存操作的思想的理解。
# 第十章 CALL和RET指令
call和ret也都是转移指令。它们经常组合使用,共同用来实现子程序的设计。
# 10.1 ret和retf
ret指令用栈中的数据,修改IP的内容,从而实现近转移。
retf指令则用栈中的数据,修改CS和IP的内容,从而实现远转移。
如果用汇编语法来解释ret和retf指令:
;ret
pop IP
;retf
pop IP
pop CS
# 10.2 call指令
CPU执行call指令时有两步操作
- 将当前的IP或 当前的CS和IP压入栈中
- 转移
call指令不能实现短转移(jmp short
),除此之外和jmp
相同。
# 10.3 依据位移进行转移的call指令
call 标号
是call最基本的用法,相当于进行
push IP
jmp near ptr 标号
对应的机器指令中是目的位置的相对偏移
# 10.4 转移的目的地址在指令中的call指令
call far ptr 标号
实现的是段间转移,对应的机器指令中会有目的地址。
相当于进行
push CS
push IP
jmp far ptr 标号
# 10.5 转移地址在寄存器中的call指令
call 16位寄存器
相当于进行
push IP
jmp 16位寄存器
# 10.6 转移地址在内存中的call指令
call word ptr 内存单元地址
相当于进行
push IP jmp word ptr 内存单元地址
call dword ptr 内存单元地址
相当于进行
push CS push IP jmp dword ptr 内存单元地址
# 10.7 call和ret的配合使用
总结以下先前的章节:
call指令的作用是进行转移,如果转移过程中仅修改IP,就会把IP入栈,如果还会修改CS,就会把CS和IP同时入栈。
而ret的作用就是从栈中pop出一个数据,作为IP,跳转回这个位置。
retf则会从栈中pop出一个数据作为IP,再pop一个数据作为CS,跳转回这个位置。
而这两个指令配合使用,就可以实现子程序的机制。
通过call指令调用子程序,保存当前的CS和IP,在子程序的末尾使用ret或retf指令返回主程序。
样例:
assume cs:code
stack segment
db 8 dup (0)
db 8 dup (0)
stack ends
code segment
start:
mov ax,stack
mov ss,ax
mov sp,16
mov ax,1000
call s
mov ax,4c00h
int 21h
s:
ret
code ends
end start
使用call和ret的过程中需要注意的就是,如果在子程序中需要使用寄存器,一般也会通过栈的形式暂存原来的寄存器的值,在子程序执行ret指令时手动将其恢复。
# 10.8 mul指令
mul是乘法指令,使用mul做乘法的时候需要注意以下两点
- 两个相乘的数,要么都是8位,要么都是16位。8位乘法时,一个数默认放在AL中,另一个放在一个8位寄存器或内存字节单元中。16位乘法时,一个数放在AX中,另一个放在16位寄存器或内存单元中。
- 8位乘法的结果放在AX中,16位乘法的结果高位放在DX中,低位放在AX中
使用格式如下:
mul reg
mul 内存单元
# 10.9 模块化程序设计
call和ret指令共同支持了汇编语言的模块化设计。在编程过程中,模块化设计是非常常用的,我们可以用简便的方法,实现多个互相联系,功能独立的子程序来解决一个复杂的问题。
接下来的内容将探讨一些汇编的子程序设计会遇到的问题和解决思路
# 10.10 参数和结果的传递
子程序一般都要根据提供的参数来处理一定的事务。处理后将结果提供给调用者。
在高级语言中,这两项为参数和返回值,那么在汇编语言就会存在两个问题:
- 参数存储在什么地方
- 返回值存储在什么地方
显然,参数和返回值都可以用寄存器来存储。
汇编语言中往往人为选择一个寄存器来存放子程序的参数和返回值,这些内容在风格良好的程序中应当写明在子程序的注释上,如下:
;说明:计算N的三次方
;参数:(bx)=N
;结果:(dx:ax)=N^3
cube:
mov ax,bx
mul bx
mul bx
ret
# 10.11 批量数据的传递
对于先前的例子,参数比较少,可以使用寄存器传递,但如果参数更多,寄存器的数量不够。
对于批量的数据,可以把它们放到内存中,然后通过寄存器传递需要处理的数据的地址。
此外,除了用寄存器传递参数外,还有一种通用的方法是用栈来传递参数。
# 10.12 寄存器冲突的问题
在子程序中往往会使用到寄存器,而对寄存器的使用可能会破坏主程序中存储在寄存器的内容。
我们希望
- 在编写调用子程序的程序时不必关心子程序到底使用了哪些寄存器
- 编写子程序的时候不必关心调用者使用了哪些寄存器
- 不会发生寄存器冲突
因此解决方法为:在子程序的开始将子程序中所有用到的寄存器的内容保存起来,在子程序返回前恢复。这个过程一般用栈来方便的完成。
# 第十一章 标志寄存器
CPU内部有一种特殊的寄存器,具有以下三种作用
- 用来存储相关指令的某些执行结果
- 用来为CPU执行相关指令提供行为依据
- 用来控制CPU的相关工作方式
8086CPU中这样的寄存器被称为标志寄存器,其中存储的信息被称为状态字(PSW)
标志寄存器(以下简称flag)将是学习了解的最后一个寄存器,其中1、3、5、12、13、14、15位在8086CPU中并没有被使用,没有任何含义。
# 11.1 ZF标志
ZF是flag的第六位,为零标志位。用来记录相关指令执行后,结果是否为0,结果为0时ZF=1。
# 11.2 PF标志
flag的第二位是PF,奇偶标志位。记录相关指令执行后,结果所有bit位中1的个数是否为偶数。如果1的个数位偶数,pf=1,如果为奇数,pf=0。
# 11.3 SF标志
flag的第七位是SF,符号标志位,记录相关指令执行后,结果是否为负数,如果结果为负SF=1。
大多是运算指令对ZF、PF、SF有影响,例如add,sub,mul,div,inc,or,and等。他们执行后这三个寄存器比较全面的记录了指令的执行结果。
# 11.4 CF标志
flag的第0位是CF,进位标志位。在进行无符号数运算的时候,它记录了运算结果的最高有效位向更高位的进位值,或从更高位的借位值。
例如两个8位数据:98H+98H计算,将产生进位。这个进位值就会记录在CF上。
同样的,例如97H-98H时向更高位的借位也会记录在CF上。
mov al,97H
sub al,98H ;执行后(al)=FFH,CF=1
sub al,al ;执行后(al)=0,CF=0
# 11.5 OF标志
OF记录了有符号数计算结果是否发生了溢出,如果发生溢出,OF=1。
要注意CF对无符号运算有意义,OF对有符号运算有意义。它们之间没有关系。
# 11.6 adc指令
add是带进位的加法指令,它利用了CF位上记录的进位值。
adc ax,bx
实现的功能是(ax)=(ax)+(bx)+CF
adc指令是用来进行加法的第二步运算的,adc指令和add指令配合就可以对更大的数据进行加法运算。
例如计算两个32位数据,就可以先将低16位相加,然后将高十六位和进位值相加。
# 11.7 sbb指令
sbb是带借位减法指令
sbb ax,bx
相当于实现了(ax)=(ax)-(bx)-CF
功能使用和adc类似,想要快速理解这两个指令,可以参考竖式的运算。
# 11.8 cmp指令
cmp指令是是比较指令,相当于减法指令,只是不保存结果,cmp执行后将对标志寄存器产生影响。之后其他相关指令通过识别这些被影响的寄存器位来得知比较结果。
例如cmp ax,ax
就会做一个(ax)-(ax)的运算,但结果不会保存在ax中。之后影响flag的相关位,执行后:
zf=1,pf=1,sf=0,cf=0,of=0
通过寄存器的值可以看出比较的结果
# 11.9 检测比较结果的条件转移指令
jcxz是一个已经学习过的条件转移指令,通过检测cx中的数值决定是否转移。
而CPU还提供了其他条件转移指令。这些指令和cmp配合,通过查看cmp影响的标志寄存器决定是否转移。
# 11.10 DF标志和串传送指令
flag的第十位是DF,方向标志位。在串处理指令中,用于控制每次操作后si、di的增减。
df=0则每次操作后si和di递增,反之递减。
串传送指令举例
movsb
执行movsb后,相当于执行以下功能:
mov es:[di],byte ptr ds:[si] ;8086并不支持该指令,只是描述
如果df=0
inc si
inc di
如果df=1
dec si
dec di
此外还有传送一个字的串传送指令movsw
,区别就是会将ds:si指向的内存单元中的一个字都送入es:di,自然的,之后根据df标志,si和di会递增2或递减2。
# 11.11 pushf和popf
pushf可以将标志寄存器flag的值压入栈中。
popf则可以弹出数据,送入标志寄存器。
这两条指令为直接访问标志寄存器提供了一种方法。
# 11.12 标志寄存器在Debug中的表示
标志寄存器在Debug按照各个标志位单独表示。Debug中表示如下:
# 第十二章 内中断
任意一个通用的CPU,例如8086,都有能力在当前指令执行完毕之后,检测到从CPU外部发送过来的或内部产生的一种特殊信息。这种特殊信息可以称其为中断信息。
CPU不在接着刚执行完的指令向下执行,而是会转去处理这个特殊信息。
中断信息可以来自于CPU的内部和外部,这一章中将主要讨论来自于CPU内部的中断信息。
# 12.1 内中断的产生
在8086CPU中,当CPU内部有以下的情况发生的时候,将产生相应的中断信息。
- 除法错误,例如div指令产生的除法溢出
- 单步执行
- 执行into指令
- 执行int指令
中断信息的来源简称中断源。上述的4种中断源,在8086CPU中的终端类型码如下
- 除法错误:0
- 单步执行:1
- 执行into指令:4
- 执行int指令:该指令格式为int n,n为字节型立即数,就是提供给cpu的中断类型码。
# 12.2 中断处理程序
CPU收到中断信息后,需要对中断信息进行处理,如何处理则由编程决定。编写的用来处理中断信息的程序被称为中断处理程序。一般来说,要对不同的中断信息编写不同的处理程序。
根据CPU的设计,中断类型码的作用就是用来定位中断处理程序。
# 12.3 中断向量表
CPU用8位的中断类型码通过中断向量表找到相应的中断处理程序的入口地址。
中断向量表在内存中保存。其中存放着256个中断源所对应的中断处理程序入口。
对于8086CPU,内存0000:0000到0000:03FF的1024个单元中存放着中断向量表。8086CPU始终从此处读取中断向量表。
# 12.4 中断过程
8086CPU在收到中断信息后,将引发如下的中断过程:
- 从中断信息中取得中断类型码
- 标志寄存器的值入栈(因为中断过程需要改变标志寄存器的值,因此先将其保存在栈中)
- 设置标志寄存器第八位TF和第九位IF值为0,目的后面介绍
- CS内容入栈
- IP内容入栈
- 从内存地址为中断类型码*4和中断类型码*4+2的两个字单元中读取中断处理程序的入口地址,设置IP和CS
# 12.5 中断处理程序
中断处理程序必须一致储存在某段内存空间中,而中断处理程序的入口,即中断向量,必须储存在对应的中断向量表表项中。
中断处理程序的编写方法和子程序类似:
- 保存用到的寄存器
- 处理中断
- 恢复用到的寄存器
- 用iret指令返回
iret指令的功能用汇编语法描述为:
pop IP
pop CS
popf
iret通常和硬件自动完成的中断过程配合使用。
# 12.6 除法错误中断的处理
CPU在指定div等除法指令的时候,如果发生了除法溢出错误,将产生中断类型码为0的中断信息。
系统对引发的0号中断默认的中断处理程序功能为:显示提示信息“Divide overflow"后,回到操作系统中。
# 12.7-12.10 编程处理0号中断
这几个章节展示了一个过程,编写在屏幕上显示错误信息的程序,将这段程序放入内存中的一个位置,修改中断向量,以达到自定义中断处理程序来处理0号中断的效果,具体内容可以查看课本。这段内容可以加深对于中断处理过程的理解。
# 12.11 单步中断
CPU在执行完一条指令后,如果检测到标志寄存器的TF位为1,就会产生单步中断,引发中断类型码为1的中断过程。
Debug就是利用单步中断,在执行完一条指令后去停止。
这也是为何在中断过程中会有将TF置为0这个步骤。
# 12.12 响应中断的特殊情况
有些情况下,CPU在执行完当前指令后,即使发生中断,也不会响应。
举例:如果设置SS和设置SP的指令连续存放,在这之间CPU不会引发中断过程。因为这两条指令必须都执行才能保证指向正确的栈顶。
# 第十三章 int指令
使用int指令可以调用任何一个中断的处理程序。BIOS和DOS都有一些中断例程提供给程序员和应用程序,在需要时调用可以方便地实现一些功能。
# 第十七章 使用BIOS进行键盘输入和磁盘读写
- int 9 中断例程,处理键盘输入
- int 16h,读取键盘缓冲区