DSLBQ'S_BLOG

汇编语言

概述

汇编语言是直接在硬件之上工作的编程语言,首先要了解硬件系统的结构,才能有效的应用汇编语言对其编程。汇编的研究重点放在如何利用硬件系统的编程结构和指令集有效灵活的控制系统进行工作。

基础知识

  • 汇编指令是机器指的助记符,同机器指令一一对应(用汇编编译器转换)。
  • 每一种CPU都有自己的汇编指令集。
  • CPU可以直接使用的信息在存储器中存放。
  • 在存储器中指和数据没有任何区别,都是二进制信息。
  • 每一个CPU芯片都有许多管脚,这些管脚和总线相连。也可以说,这些管脚引出总线。一个CPU可以引出三种总线的宽度标志了这个CPU的不同方面的性能
    • 地址总线的宽度决定了CPU的寻址能力
    • 数据总线的宽度决定了CPU与其它器件进行数据传送时的一次数据传送量
    • 控制总线宽度决定了CPU对系统中其它器件的控制能力

CPU对存储器的读写

CPU要想进行数据的读写,必须和外部器件(标准的说法是芯片)进行三类信息的交互:

  • 存储单元的地址(地址信息)
  • 器件的选择,读或写命令(控制信息)
  • 读或写的数据(数据信息)

在计算机中专门有连接CPU和其他芯片的导线,通常称为总线。

  • 物理上:一根根导线的集合
  • 逻辑上划分为(存储器中的数字是指令还是数据根据总线进行区分,例如0x0011102可以是数据,也可以是指令):
    • 地址总线
    • 控制总线
    • 数据总线

CPU读写流程:

  1. 寻找要读写的地址
  2. 发出控制指令
  3. 读写数据

地址总线

CPU是通过地址总线来指定存储单元的。地址总线上能传送多少个不同的信息,CPU就可以对多少个存储单元进行寻址。我们常说的64位、32位指的就是CPU的地址总线宽度。

数据总线

CPU与内存或其它器件之间的数据传送是通过数据总线来进行的。数据总线的宽度决定了CPU和外界的数据传送速度。

控制总线

CPU对外部器件控制是通过控制总线来进行的。在这里控制总线是个总称,控制总线是一些不同控制线的集合。

有多少根控制总线,就意味着CPU提供了对外部器件的多少种控制。所以,控制总线的宽度决定了CPU对外部器件的控制能力。

前面所讲的内存读或写命令是由几根控制线综合发出的:

  • 其中有一根名为读信号输出控制线负责由CPU向外传送读信号,CPU向该控制线上输出低电平表示将要读取数据。
  • 有一根名为写信号输出控制线负责由CPU后外传送写信号。

内存地址空间

一个CPU的地址线宽度为10,那么可以寻址1024个(210)内存单元,这1024个可寻到的内存单元就构成这个CPU的内存地址空间。

存储器(网卡、显卡、内存)在物理上是独立的器件。但是它们在以下两点上相同:

  1. 都和CPU的总线相连。
  2. CPU对它们进行读或写的时候都通过控制线发出内存读写命令。

将各类存储器看作一个逻辑存储器:

  • 所有的物理存储器被看作一个由若干存储单元组成的逻辑存储器。
  • 每个物理存储器在这个逻辑存储器中占有一个地址段,即一段地址空间。
  • CPU在这段地址空间中读写数据,实际上就是在相对应的物理存储器中读写数据。

不同计算机系统的内存地址空间分配情况是不同的,下图为8086PC机的内存地址空间分配情况

最终运行程序的是CPU,我们用汇编编程的时候,必须要从CPU角度考虑问题。
对CPU来讲,系统中的所有存储器中的存储单元都处于一个统一的逻辑存储器中,它的容量受CPU寻址能力的限制。这个逻辑存储器即是我们所说的内存地址空间。

寄存器(CPU工作原理)

CPU概述

一个典型的CPU由运算器控制器寄存器等器件组成,这些器件靠内部总线相连。

区别:

  • 内部总线实现CPU内部各个器件之间的联系。
  • 外部总线实现CPU和主板上其它器件的联系。

寄存器概述

8086 CPU中寄存器总共为 14 个,且均为 16 位。即AX、BX、CX、DX、SP、BP、SI、DI、IP、PSW、CS、DS、SS、ES

寄存器类型

  • 通用寄存器
    • AX(Accumulator): 累加寄存器,也称之为累加器
  • BX(Base): 基地址寄存器
  • CX(Count):计数器寄存器
  • DX(Data):数据寄存器
  • 指针寄存器
    • SP(Stack Pointer):堆栈指针寄存器
    • BP(Base Pointer): 基指针寄存器
  • 址寄存器
    • SI(Source Index):源变址寄存器
    • Dl(Destination Index):目的变址寄存器
  • 控制寄存器
    • IP(Instruction Pointer): 指令指针寄存器
    • FLAG:标志寄存器
  • 段寄存器
    • CS(Code Segment): 代码段寄存器
    • DS(Data Segment):数据段寄存器
    • SS(Stack Segment):堆栈段寄存器
    • ES(Extra Segment):附加段寄存器

字在寄存器中的存储

一个字可以存在区一个16位寄存器中,这个字的高位字芊和低位字节自然就存在这个寄存器的高8位寄存器和低8位寄存器中。

物理地址

CPU访问内存单元时要给出内存单元的地址。所有的内存单元构成的存储空间是一个一维的线性空间。我们将这个唯一的地址称为物理地址。

CPU中的相关部件提供两个16位的地址,一个称为段地址,另一个称为偏移地址,段地址和偏移地址通过内部总线送入一个称为地址加法器的部件,地址加法器将两个16位地址合并成一个20位的地址。

地址加法器合成物理地址的方法:物理地址=段地址 X 16 + 偏移地址

“段地址 * 16″有一个更为常用的说法就是数据左移4位。(二进制位 )

一个数据的十六进制形式左移1位,相当于乘以16
一个数据的十进制形式左移1位,相当于乘以10
一个数据的X进制形式左移1位,相当于以X

段的概念

错误认识 :内存被划分成了一个一个的段,每一个段有一个段地址。
其实 :
内存并没有分段,段的划分来自于CPU,由于8086 CPU用 “(段地址X16) +偏移地址=物理地址”的方式给出内存单元的物理地址,使得我们可以用分段的方式来管理内存。

在编程时可以根据需要,将若干地址连续的内在单元看作一个段,用段地址 X 16定位段的起始地址(基础地址),用偏移地址定位段中的内存单元。

两点需要注意

  • 段地址 X 16 必然是16的倍数,所以一个段的起始地址也一定是16的倍数。
  • 偏移地址为16位,16位地址的寻址能力为64K,所以一个段的长度最大为64K。

“数据在21F60H内存单元中。”对于8086PC机的两种描述 :

  1. 数据存在内存2000:1F60单元中
  2. 数据存在内存的2000段中的IF60H单元中

可根据需要,将地址连续、起始地址为16的倍数的一组内存单元定义为一个段。

段寄存器

段寄存器就是提供段地址的。
8086 CPU有4个段寄存器 :CS、DS、SS、ES
当8086CPU要访问内存时,由这4个段寄存器提供内存单元的段地址。

CS和IP

CS和IP是8086CPU中最关键的寄存器,它们指示了CPU当前要读取指令的地址。
CS为代码段寄存器,IP为指令指针寄存器。

工作流程

  1. 从CS:IP指向内存地址读取指令,读取的指令进入指令缓冲器。
  2. IP = IP + 所读取指的长度,从而指向下一条指令。
  3. 执行指令。 转到步骤1,重复这个过程。

工作过程的简要概述

  • 在8086CPU加电启动或复位后(即CPU刚开始工作时)CS和IP被设置为CS=FFFFH,IP=O00OH。
  • 即在8086PC机刚启动时,CPU从内存FFFF0H单元中读取指令执行。
  • FFFF0H单元中的指合是8086PC机开机后执行的第一条指令。
  • 在任何时候,CPU将CS、IP中的内容当作指令的段地址和偏移地址,用它们合成指令的物理地址,到内存中读取指令码,执行。
  • 如果说,内存中的一段信息曾被CPU执行过的话,那么,它所在的内存单元必然被CS:IP指向过。

修改CS:IP的指令

  • 在CPU中,程序员能够用指令读写的部件只有寄存器,程序员可以通过改变寄存器中的内容实现对CPU的控制。
  • CPU从何处执行指今是由CS:IP中的内容决定的,程序员可以通过改变CS:IP中的内容来控制CPU执行目标指令。

代码段

  • 对于8086 PC机,在编程时,可以根据需要,将一组内存单元定义为一个段。
  • 可以将长度为N(N<=64KB)的一组代码,存在一组地址连续、起始地址为16的倍数的内存单元中,这段内存是用来存放代码的,从而定义了一个代码段

这段长度为10字节的字节的指令,存在从123B0H~123B9H的一组内存单元中,我们就可以认为,123B0H~123B9H这段内存单元是用来存放代码的,是一个代码段,它的段地址为123BH,长度为10字节。

如何使得代码段中的指令被执行呢?

将一段内存当作代码段,仅仅是我们在编程时的一种安排,CPU 并不会由于这种安排,就自动地将我们定义得代码段中的指令当作指令来执行。

CPU只认被CS:IP指向的内存单元中的内容为指令,所以要将CS:IP指向所定义的代码段中的第一条指令的首地址。

Windows调试工具

调用 cmd,输入debug

R:查看、改变CPU寄存器的内容
D:查看内存中的内容 -d 1000:0
E:改写内存中的内容 -e 1000:0 23 11 22 66
U:将内存中的机器指令翻译成汇编指令
T:执行一条机器指令
A:以汇编指令的格式在内存中写入一条机器指令(没有执行) -a 1386:10 mov ax 100...

寄存器(内存访问)

内存中字的存储

任何两个地址连续的内存单元,N号单元和N+1号单元,可以将它们看成两个内存单元,也可以看成一个地址为N的字单元中的高位字节单元和低位字节单元。

DS和[address]

  • CPU要读取一个内存单元的时候,必须先给出这个内存单元的地址
  • 在8086PC中,内存地址由段地址和偏移地址组成
  • 8086CPU中有一个DS寄存器,通常用来存放要访问的数据的段地址

例如:我们要读取10000H单元的内容可以用如下程序段进行:

mov bx, 1000H ;设置bx值为1000:0地址(会自动地址加法)
mov ds, bx
mov al, [0] ;偏移地址

上面三条指令将10000H (1000:0) 中的数据读到al中。

已知的mov指令可完成的三种传送功能

  • 将数据直接送入寄存器:mov ax, 2H
  • 将一个寄存器中的内容送入另一个寄存器中:mov bx, ax
  • 将一个内存单元中的内容送入一个寄存器:mov al, [0]
  • 将一个寄存器的内容送入内存单元中:mov [0], al
  • 将一个寄存器的内容送入段寄存器中:mov ds, ax

执行指令时,8086CPU自动取DS中的数据为内存单元的段地址。
如何用mov指令从10000H中读取数据?

  1. 10000H表示为1000:0 (段地址:偏移地址)
  2. 将段地址1000H放入ds
  3. 用mov al,[O]完成传送 (mov指令中的[]说明操作对象是一个内存单元,[]中的0说明这个内存单元的偏移地址是0,它的段地址默认放在ds中)

8086CPU不支持将数据直接送入DS段寄存器,这是硬件设计的问题

怎么样将al的数据从寄存器送入内存单元?

mov bx, 1000H
mov ds, bx
mov [0], al

字的传送

因为8086CPU是16位结构,有16根数据线,所以可以一次性传送16位的数据,也就是一次性传送一个字

mov bx, 1000H
mov ds, bx ;指定段地些
mov ax, [0] ;将1000:0处的字型数据送入ax,因为ax有16位,所以一次送传送一个字
mov [0], cx ;将cx中的16位数据送到内存1000:0处

数据段

对于8086PC机,我们可以根据需要将一组内存单元定义为一个段(可以是代码段、数据段等)。

我们可以将一组长度为N (N<=64K) 、地址连续、起始地址为16的倍数的内存单元当作专门存储数据的内存空间,从而定义了一个数据段。

比如我们用123B0H~12339H这段空间来存放数据

  • 段地址:1233H
  • 长度 :10字节

如何访问数据段中的数据?

将一段内存当作数据段,是我们在编程时的一种安排,我们可以在具体操作的时候,用ds存放数据段的段地址,再根据需要,用相关指令访问数据段中的具体单元。

  • 栈用来临时存放东西
  • 栈有两个基本的操作 : 入栈和出栈
    • 入栈:将一个新的元素放到栈顶
    • 出栈:从栈顶取出一个元素
  • 栈顶的元素总是最后入栈,需要出栈时,又最先被从栈中取出
  • 栈的操作规则: LIFO(Last In First out,后进先出)
  • 现今的CPU中都有栈的设计
  • 8086CPU提供相关的指令来以栈的方式访问内存空间
  • 这意味着,我们在基于8086CPU编程的时候,可以将一段内存当作栈来使用
  • 8086 CPU提供入栈和出栈指令:
    • PUSH(入栈)
    • POP(出栈)
  • push ax:将寄存器ax中的数据送入栈
  • pop ax:从栈顶取出数据送入ax
  • 8086 CPU的入栈和出栈操作都是以字为单位进行的
  • 字型数据用两个单元(字节)存放,高地址单元放高8位 ,低地址单元放低8位

CPU如何知道当前要执行的指令所在的位置

寄存器CS和IP中存放着当前指令的段地址和偏移地址。8086CPU中,有两个寄存器:

  • 栈段寄存器SS:存放栈顶的段地址
  • 栈指针寄存器SP:存放栈顶的偏移地址

任意时刻,SS:SP指向栈顶元素。

执行push和pop的时候,如何知道哪个单元是栈顶单元?

执行push ax时,CPU做了以下步骤:

  • SP=SP-2
  • 将ax中的容送入SS:SP指向的内存单元处,SS:SP此时指向新栈顶。

如果我们将10000H~1000FH这段空间当作栈,初始状态栈是空的,此时,SS=1000H,SP=?

SP = 0010H,指向栈空间最高地址单元的下一个单元

POP之后,内存中的数据还是存在的,只有等下一次数据push时才会被覆盖

栈顶超界的问题

SS和SP只记录了栈顶的地址,依靠SS和SP可以保证在入栈和出栈时找到栈顶。可是如何能够保证在入栈、出栈时,栈顶不会超出栈空间?

当栈满的时候再使用push指令入栈,栈空的时候再使用pop指令出栈,都将发生栈顶超界问题。栈顶超界是危险的,因为:我们既然将一段空间安排为栈,那么在栈空间之外的空间里很可能存放了具有其他用途的数据、代码等,这些数据、代码可能是我们自己的程序中的,也可能是别的程序中的。(毕竟一个计算机系统并不是只有我们自己的程序在运行)。

但是由于我们在入栈出栈时的不小心而将这些数据、代码意外地改写,将会引发一连串的错误。当然也有可能是故意的改写。

比如说在CPU中有记录栈顶上限和下限的寄存器,我们可以通过填写这些寄存器来指定栈空间的范围,然后,CPU 在执行push指令的时候靠检测栈顶上限寄存器,在执行pop指令的时候靠检测栈顶下限寄存器保证不会超界。实际情况 : 8086CPU中并没有这样的寄存器。

8086 CPU不保证对栈的操作不会超界。这就是说,8086 CPU只知道栈顶在何处(由SS:SP指示),而不知道读者安排的栈空间有多大。这点就好像,CPU只知道当前要执行的指令在何处(由CS:SP指示)而不知道读者要执行的指令有多少。

8086 CPU的工作机理,只考虑当前的情况:

  • 当前栈顶在何处
  • 当前要执行的指令是哪一条

我们在编程的时候要自己注意栈顶超界的问题,要根据可能用到的最大栈空间,来安排栈的大小,防止入栈的数据太多而导致的超界,执行出栈操作的时候也要注意,以防栈空的时候继续出栈而导致的超界。

push、pop指令

push和pop指会的格式

  • push内存单元:将一个内存单元处的字入栈(栈操作都是以字为单位)
  • pop内存单元:出栈,用一个内存字单元接收出栈的数据例如:
    • push [0]
    • pop [2]

指令执行时,CPU要知道内存单元的地址,可以在push、pop指令中给出内存单元的偏移地址,段地址在指令执行时,CPU从ds中取得。

练习

将10000H~1000FH这段空间当作栈,初始状态是空间,将AX、BX、DS中的数据入栈

mov ax, 1000H
mov ss, ax ;设置栈的段地址,SS=1000H,不能直接向段寄存器SS送入数据,所以用ax中转。
mov sp, 0010H ;设置栈顶的偏移地址,国为栈为空,所以SP=0010H。以上三条指令设置栈顶地址。编程中要自己注意栈的大小。
push ax
push bx
push ds ; push可以直接操作段寄存器

C语言Main函数调用其他函数对临时数据的操作

  • 将10000H~1000FH这段空间当作栈,初始状态是空的
  • Main函数中有AX,BX局部变量
  • 开始调用A函数前,将AX、BX中的数据入栈
  • 然后将AX、BX清零
  • 调用A函数
  • 返回A函数结果
  • 从栈中恢复AX、BX原来的内容

从上面的程序我们看到,用栈来暂存以后需要恢复的寄存器中的内容时,出栈的顺序要和入栈的顺序相反,因为最后入栈的寄存器的内容在栈顶,所以在恢复时,要最先出栈。

push和pop指令同mov指令不同,CPU执行mov指令只需一步操作,就是传送,而执行push、 pop指令却需要两步操作。
执行push时,先改变SP,后向SS:SP处传送。
执行pop时,先读取SS:SP处的数据,后改变SP。

栈段

  • 对于8086PC机,在编程时我们可以根据需要,将一组内存单元定义为一个段。
  • 我们可以将长度为N(N <=64K)的一组地址连续、起始地址为16的倍数的内存单元,当作栈来用,从而定义了一个栈段。
  • 比如我们将10010H~1001FH 这段长度为16字节的内存空间当作栈来用,以栈的方式进行访问。
  • 这段空间就可以成为栈段,段地址为1000H,大小为16字节。
  • 将一段内存当作栈段,仅仅是我们在编程时的一种安排,CPU并不会由于这种安排,就在执行push、pop 等栈操作指令时就自动地将我们定义的栈段当作栈空间来访问。
  • 如何使的如push、pop等栈操作指令访问我们定义的栈段呢?

如果我们将10000H~IFFFFH这段空间当作栈段,初始状态是空的,此时,SS=1000H,SP=?

我们将10000H~1FFFFH这段空间当作栈段,SS=1000H,栈空间大小为64KB,栈最底部的字单元地址为1000:FFFE(如果为空,指向下一个栈段)。任意时刻,SS:SP指向栈顶,当栈中只有一个元素的时候,SS=1000H,SP=FFFEH

栈为空时,就相当于栈中唯一的元素出栈,出栈后,SP=SP+2。SP原来为FFFEH,加2后SP=0,所以,当栈为空的时候,SS=1000H,SP=0。

换个角度看,任意时刻,SS:SP指向栈顶元素,当栈为空的时候,栈中没有元素,也就不存在栈顶元素,所以SS:SP只能指向栈的最底部单元下面的单元,该单元的偏移地址为栈最底部的字单元的偏移地址+2 ,栈最底部字单元的地址为1000:FFFE,所以栈空时,SP=0000H。

段的综述

我们可以将一段内存定义为一个段,用一个段地址指示段,用偏移地址访问段内的单元。这完全是我们自己的安排。

  • 我们可以用一个段存放数据,将它定义为“数据段”
  • 我们可以用一个段存放代码,将它定义为“代码段”
  • 我们可以用一个段当作栈,将它定义为“栈段”

我们可以这样安排,但若要让CPU按照我们的安排来访问这些段,就要对于数据段,将它的段地址放在 DS中,用mov、add、sub等访问内存单元的指令时,CPU就将我们定义的数据段中的内容当作数据段来访问

对于代码段,将它的段地址放在 CS中将段中第一条指令的偏移地址放在IP中,这样CPU就将执行我们定义的代码.
对于栈段,将它的段地址放在SS中,将栈顶单元的偏移地置放在SP 中,这样CPU在需要进行栈操作的时候,比如执push、pop指令等,就将我们定义的栈段当作栈空间来用

可见,不管我们如何安排,CPU将内存中的某段内存当作代码,是因为CS:IP指向了那里,CPU将某段内存当作栈,是因为SS:IP 指向了那里。

我们一定要清楚,什么是我们的安排,以及如何让CPU按我们的安排行事。要非常的清楚CPU的工作机理,才能在控制CPU来按照我们的安排运行的时候做到游刀有余。

一段内存,可以既是代码的存储空间又是数据的存储空间,还可以是栈空间,也可以什么也不是(也可以是漏洞点)。
关键在于CPU中寄存器的设置,即 :CS、IP、SS、SP、DS的指向。

汇编程序

编写过程

  • 编写(文本编辑器:notepad++,sublime等)
  • 编译连接:使用汇编语言编译程序(MASM.EXE)对源程序文件中的源程序进行编译,产生目标文件。再用连接程序(LINK.EXE)对目标文件进行连接,生成可在操作系统中直接运行的可执行文件。
  • 可执行文件中包含两部分内容:
    • 程序(从原程序中的汇编指令翻译过来的机器码)和数据(源程序中定义的数据)
    • 相关的描述信息(比如: 程序有多大、要占多少内存空间等)
  • 执行可执行程序:操作系统依照可执行文件中的描述信息,将可执行文件中的机器码和数据加载入内存,并进行相关的初始化(比如:设置CS:IP指向第一条要执行的指令),然后由CPU执行程序。

源程序

  • 汇编指令:有对应的机器码的指令,可以被编译为机器指令,最终为CPU所执行。
  • 伪指令:没有对应的机器码的指令,最终不被CPU所执行。伪指令是由编译器来执行的指令,编译器根据伪指令来进行相关的编译工作。
    • segment和ends的功能是定义一个段,segment说明一个段开始,ends说明一个段结束。
    • 一个段必须有一个名称来标识,使用格式为
      • 段名 segmen
      • 段名 ends
    • 一个汇编程序是由多个段组成的,这些段被用来存放代码、数据或当作栈空间来使用。
    • 一个有意义的汇编程序中至少要有一个段,这个段用来存放代码。没有代码段,就算有数据段也无法执行
    • End是一个汇编程序的结束标记,编译器在编译汇编程序的过程中,如果碰到了伪指令end,就结束对程序的编译
    • 如果程序写完了,要在结尾处加上伪指令End,否则,编译器在编译程序时,无法知道程序在何处结束。
    • 不要搞混了end和ends
    • assume:含义为“假设”
    • 它假设某一段寄存器和程序中的某一个用segment … ends 定义的段相关联。
    • 通过assume说明这种关联,在需要的情况下,编译程序可以将段寄存器和某一个具体的段相联系。

我们可以将源程序文件中的所有内容称为源程序,将源程序中最终由计算机执行处理的指令或数据,称为程序
程序最先以汇编指令的形式存在源程序中,经编译、连接后转变为机器码,存储在可执行文件中。

标号

  • 一个标号指代了一个地址。
  • codesg:放在segment的前面,作为一个段的名称,这个段的名称最终将被编译、连接程序处理为一个段的段地址。

DOS中的程序运行

DOS是一个单任务操作系统:

一个程序P2在可执行文件中,则必须有一个正在运行的程序P1,将P2从可执行文件中加载入内存后,将CPU的控制权交给P2,P2才能得以运行。P2开始运行后,P1暂停运行。
而当P2运行完毕后,应该将CPU的控制权交还给使它得以运行的程序P1,此后,P1继续运行。

程序返回

我们的程序最先以汇编指令的形式存在源程序中,经编译、连接后转变为机器码,存储在可执行文件中,那么,它怎样得到运行呢?一个程序结束后,将CPU的控制权交还给使它得以运行的程序,我们称这个过程为 : 程序返回。

程序如何返回?

mov ax, 4c00H
int 21H

这两条指令所实现的功能就是程序返回。

编译连接的作用

  • 当源程序很大时,可以将它分为多个源程序文件来编译,每个源程序编译成为目标文件后,再用连接程序将它们连接到一起,生成一个可执行文件。
  • 程序中调用了某个库文件中的子程序,需要将这个库文件和该程序生成的目标文件连接到一起,生成一个可执行文件。
  • 一个源程序编译后,得到了存有机器码的目标文件,目标文件中的有些内容还不能直接用来生成可执行文件,连接程序将这些内容处理为最终的可执行信息。所以,在只有一个源程序文件,而又不需要调用某个库中的子程序的情况下,也必须用连接程序对目标文件进行处理,生成可执行文件。
  • 对于连接的过程,可执行文件是我们想要得到的最终结果。

汇编语言编程要用到 : 编辑器 (Edit) 、编译器 (masm)、连接器(link) 、调试工具 (debug) 等所有工具,而这些工具都是在操作系统之上运行的程序,所以学习的过程必须在操作系统的环境中进行。

可执行文件中的程序装入内存并运行的原理

在DOS中,可执行文件中的程序P1若要运行,必须有一个正在运行的程序P2,将P1从可执行文件中加载入内存,将CPU的控制权交给它,P1才能得以运行。
当P1运行完毕后,应该将CPU的控制权交还给使它得以运行的程序P2。

操作系统的外壳(Shell)

操作系统是由多个功能模块组成的庞大、复杂的软件系统。任何通用的操作系统,都要提供一个称为shell(外壳)的程序,用户(操作人员)使用这个程序来操作计算机系统工作
DOS中有一个程序command.com,这个程序在DOS中称为命令解释器,也就是DOS系统的shell

我们在DOS中直接执行exe程序时,是正在运行的command将exe程序加载入内存。
command设置CPU的CS:IP指向程序的第一条指令(即程序的入口),从而使程序得以运行。
程序运行结束后,返回到command中,CPU继续运行command。

DEBUG

为了观察程序的运行过程 ,我们可以使用Debug。
Debug可以将程序加载入内存,设置CS:IP指向程序的入口,但Debug并不放弃对CPU 的控制(中断),这样,我们就可以使用Debug的相关命令来单步执行程序查看每条指令指令的执行结果。

在DOS系统中.exe文件中的程序的加载过程

也就是CS寄存器的值比DS寄存器的值多10H的原因,数据从DS开始,代码从CS开始执行

总而言之:

  1. 程序加载后,ds中存放着程序所在内存区的段地址,这个内存区的偏移地址为0,则程序所在的内存区的地址为: ds:0
  2. 这个内存区的前256个字节中存放的是PSP,dos用来和程序进行通信。
  3. 从256字节处向后的空间存放的是程序。
  4. 所以,我们从ds中可以得到PSP的段地址SA,PSP的偏移地址为0,则物理地址为SAX16+0。
  5. 因为PSP占256 (100H) 字节,所以程序的物理地址是:
    SA * 16 + 0 + 256 = SA * 16 + 16 * 16 = (SA + 16) * 16 + 0

int 21执行后,显示“Program terminatednormally’,返回到Debug中。
表示程序正常结束(注意,要使用P命令执行int 21)。

-g 偏移地址:执行到偏移地址暂停
-p:跳到下一个未循环的位置

[BX]和loop指令

[BX]是什么?

和[0]有些类似,[0]表示内存单元,它的偏移地址是0。要完整的描述一个内存单元,需要两种信息:

  1. 内存单元的地址
  2. 内存单元的长度(类型)

我们用[0]表示一个内存单元时,0表示单元的偏移地址,段地址默认在ds中,单元的长度(类型)可以由具体指令中的其他操作对象(比如说寄存器)指出.如AX,AL。

[BX]同样也表示一个内存单元,它的偏移地址在BX中,比如下面的指令:

mov ax,[BX]
mov al,[BX]

不能直接给其它寄存器设置偏移地址(会默认为值),而需要通过[BX]转换

mov ax, [2] ; 假设ss:2的值为5555H,ax不会设置为5555H,而会设置为2

Loop指令

指令的格式是:loop 标号,CPU执行loop指令的时候,要进行两步操作:

  1. (cx) = (cx) – 1;
  2. 判断cx中的值,不为零则转至标号处执行程序,如果为零则向下执行。
assume cs:codesg

codesg segment

dslbq:  mov ax, 2
        mov cx, 11
     s: add ax,ax
        loop s       

        mov ax, 4c00H
        int 21H
codesg ends

end dslbq

在汇编语言中,标号代表一个地址,此代码中有一个标号s。它实际上标识了一个地址,这个地址处有多条指: add ax,ax

而在汇编源程序中,数据不能以字母开头,所以要在前面加0。

段前缀

指令“mov ax,[bx]”中,内存单元的偏移地址由bx给出,而段地址默认在ds中。我们可以在访问内存单元的指令中显式地给出内存单元的段地址所在的段寄存器。这些出现在访问内存单元的指令中,用于显式地指明内存单元的段地址的“ds:”、“cs:”、“ss:”或“es:”,在汇编语言中称为段前缀。

一段安全的空间

在8086模式中,随意向一段内存空间写入内容是很危险的 ,因为这段空间中可能存放着重要的系统数据或代码。

我们在不能确定一段内存空间中是否存放着重要的放据或代码的时候,不能随意向其中写入内容。
不要忘记,我们是在操作系统的环境中工作,操作系统管理所有的资源,也包括内存。

同样不能忘记,我们正在学习的是汇编语言,要通过它来获得底层的编程体验,理解计算机底层的基本工作机理。
所以我们尽量直接对硬件编程,而不去理会操作系统。

我们似乎面临一种选择,是在操作系统中安全、规矩地编程,还是自由、直接地用汇编语言去操作真实的硬件,了解那些早己被层层系统软件掩盖的真相?
在大部分的情况下,我们选择后者,除非我们就是在学习操作系统本身的内容。

我们在纯DOS方式(实模式)下,可以不理会DOS,直接用汇编语言去操作真实的硬件,因为运行在CPU实模式下的DOS,没有能力对硬件系统进行全面、严格地管理。

但在Windows XP\2000、UNIX这些运行于CPU保护模式下的操作系统中,不理会操作系统,用汇编语言去操作真实的硬件,是根本不可能的。
硬件已被这些操作系统利用CPU保护模式所提供的功能全面而严格地管理了

在一般的PC机中,DOS方式下,DOS和其他合法的程序一般都不会使用0:200~0:2FF( 0:200h~0:2FFh)的256 个字节的空间。所以,我们使用这段空间是安全的。

多个段

程序第一行中的 “dw”的含义是定义字型数据。dw即define word。假设我们使用dw定义了8个字型数据(数据之间以逗号分隔),它们所占的内存空间的大小为16个字节。程序中的指令就要对这8个数据进行累加,可这8个数据在哪里呢?由于它们在代码段中,程序在运行的时候CS中存放代码段的段地址,所以我们可以从CS中得到它们的段地址。

这8个数据的偏移地址是多少呢?因为用dw定义的数据处于代码段的最开始,所以偏移地址为0,这8 个数据就在代码段的偏移0、2、4、6、8、A、C、E处。程序运行时,它们的地址就是CS:0、CS:2、CS:4、CS:6、CS:8、CS:A、CS:C、CS:E。

我们在程序中用到了数据和栈,我们将数据、栈和代码都放到了一个段里面。我们在编程的时候要注意何处是数据,何处是栈,何处是代码。
这样做显然有两个问题:
(1)把它们放到一个段中使程序显得混乱;
(2)前面程序中处理的数据很少,用到的栈空间也小,加上没有多长的代码,放到一个段里面没有问题。

但如果数据、栈和代码需要的空间超过64KB,就不能放在一个段中(一个段的容量不能大于64 KB,是我们在学习中所用的8086模式的限制,并不是所有的处理器都这样)。
所以,我们应该考虑用多个段来存放数据、代码和栈。

我们用和定义代码段一样的方法来定义多个段,然后在这些段里面定义需要的数据,或通过定义数据来取得栈空间。

assume cs:code, ds:data, ss:stack

code segment

start:      mov ax, stack
             mov ss, ax
             mov sp, 16
             mov ax, data
             mov ds, ax
             push ds:[0]
             push ds:[2]
             pop ds:[2]
             pop ds:[0]
             
             mov ax, 4c00h
             int 21h
code ends

data segment
      dw    0123H, 0456H
data ends

stack segment
      dw    0, 0
stack ends     

end start

我们在源程序中为这三个段起了具有含义的名称,用来存放数据的段我们将其命名为“data”,用来放代码的段我们将其命名为“code”,用来作栈空间的命名为“stack”。
但CPU看的懂这些名称吗?答案是否,若要CPU按照我们的安排行事,就要用机器指令控制它,源程序中的汇编指令是CPU要执行的内容。

那么,CPU如何知道去执行它们?
我们在源程序的最后用“end start”说明了程序的入口,这个入口将被写入可执行文件的描述信息,可执行文件中的程序被加载入内存后,CPU的CS:IP被设置指向这个入口,从而开始执行程序中的第一条指令。
标号“start”在“code”段中,这样CPU就将code段中的内容当作指令来执行了。

我们在code段中,使用指令:

mov ax,stack
mov ss,ax
mov sp,16


设置ss指向stack,设置ss:sp指向stack:16, CPU 执行这些指令后,将把stack段当做栈空间来用。
CPU若要访问data段中的数据,则可用 ds 指向 data 段,用其他的寄存器(如:bx)来存放 data段中数据的偏移地址。

总之,CPU到底如何处理我们定义的段中的内容,是当作指令执行,当作数据访问,还是当作栈空间,完全是靠程序中具体的汇编指令,和汇编指令对CS:IP、SS:SP、DS等寄存器的设置来决定的。

定位内存地址

前面,我们用[0]、[bx]的方法,在访问内存的指令中,定位内存单元的地址。现在我们主要讲解一些更灵活的定位内存地址的方法和相关的编程方法。我们的讲解将通过具体的问题来进行:

首先我们介绍两条指令and和or,因为我们下面的例程中要用到它们。

and 指令

逻辑与指令,按位进行与运算。

如 mov al, 01100011B
and al, 00111011B
执行后:al = 00100011B

通过该指令可将操作对象的相应位设为0,其他位不变。

例如:
将al的第6位设为0:and al, 10111111B
将al的第7位设为0:and al, 01111111B
将al的第0位设为0:and al, 11111110B

or指令

逻辑或指令,按位进行或运算。

如 mov al, 01100011B
   and al, 00111011B
执行后:al = 01111011B

通过该指令可将操作对象的相应位设为1,其他位不变。

例如:
将al的第6位设为1:or al, 01000000B
将al的第7位设为1:or al, 10000000B
将al的第0位设为1:or al, 00000001B

[bx + idata]

我们可以用[bx]的方式来指明一个内存单元, 我们还可以用一种更为灵活的方式来指明内存单元:[bx+idata]表示一个内存单元,它的偏移地址为(bx)+idata(bx中的数值加上idata)。

我们看一下指令mov ax,[bx+200]的含义:
将一个内存单元的内容送入ax,这个内存单元的长度为2字节(字单元),存放一个字,偏移地址为bx中的数值加上200,段地址在ds中。
数学化的描述为: (ax)=((ds)*16+(bx)+200)

指令mov ax,[bx+200]也可以写成如下格式(常用):

  • mov ax,[200+bx]
  • mov ax,200[bx]
  • mov ax,[bx].200

用[bx+idata]的方式进行数组的处理

有了[bx+idata]这种表示内存单元的方式,我们就可以用更高级的结构来看待所要处理的数据。

SI和DI

SI和DI是8086CPU中和bx功能相近的寄存器,但是SI和DI不能够分成两个8 位寄存器来使用。

下面的三组指令实现了相同的功能:

mov bx,0
mov ax,[bx]

mov si,0
mov ax,[si]

mov di,0
mov ax,[di]

下面的三组指令也实现了相同的功能:

mov bx,0
mov ax,[bx+123]

mov si,0
mov ax,[si+123]

mov di,0
mov ax,[di+123]

我们用ds:si 指向要复制的源始字符串,用 ds:di 指向复制的目的空间,然后用一个循环来完成复制。

assume cs:codesg,ds:datasg
    datasg segment 
      db 'welcome to masm!'
      db '................'
    datasg ends

codesg segment
start: mov ax,datasg
         mov ds,ax
         mov si,0
         mov di,16
         mov cx,8
    s:  mov ax,[si]
         mov [di],ax
         add si,2
         add di,2
         loop s

         mov ax,4c00h
         int 21h
codesg ends
end start

注意,在程序中,我们用16位寄存器进行内存单元之间的数据传送,一次复制 2 个字节,一共循环8次。

我们可以利用[bx(si或di)+idata]的方式,来使程序变得简洁,程序如下:

codesg segment
start: mov ax,datasg
         mov ds,ax
         mov si,0
         mov cx,8
    s:  mov ax,0[si]
         mov 16[si],ax
         add si,2
         loop s
         mov ax,4c00h
         int 21h
codesg ends
end start

[bx+si]和[bx+di]

前面,我们用[bx(si或di)]和[bx(si或di)+idata] 的方式来指明一个内存单元,我们还可以用更灵活的方式:

[bx+si]
[bx+di]

[bx+si]表示一个内存单元,它的偏移地址为(bx)+(si)(即bx中的数值加上si中的数值)。

不同的寻址方式的灵活应用

将datasg段中每个单词的头一个字母改为大写字母

assume cs:codesg,ds:datasg
datasg segment
   db '1. file          '
   db '2. edit         '
   db '3. search     '
   db '4. view        '
   db '5. options    '
   db '6. help        '
datasg ends

codesg segment
 start:……
codesg ends
end start

datasg中的数据的存储结构,如图:

我们可以看到:在datasg中定义了6个字符串,每个长度为16字节。(注意,为了直观,每个字符串的后面都加上了空格符,以使它们的长度刚好为16字节)

我们需要进行6次循环,用一个变量R定位行,用常量3 定位列。处理的过程如下:
BX先存放第一行的地址 mov cx,6;因为总共有六行 s: 改变第BX行,第3列的字母为大写 改变BX的值是它指向下一行的地址 loop

我们用bx作变量,定位每行的起始地址,用3定位要修改的列,用[bx+idata]的方式来对目标单元进行寻址

assume cs:codesg,ds:datasg

datasg segment
	db '1. file         '
	db '2. edit         '
	db '3. search       '
	db '4. view         '
	db '5. options      '
	db '6. help         '
datasg ends

codesg segment
start: mov ax, datasg
       mov ds, ax
       mov bx, 0

       mov cx, 6
    s: mov al, [bx+3]     ;注意,单位是字节,所以是al
       and al, 11011111b  ;使第五位为零,这样呢,就确定了大写!
       mov [bx+3], al
       add bx, 16
       loop s

       mov ax, 4c00h
       int 21h
codesg ends
end start

一般来说,在需要暂存数据的时候,我们都应该使用栈,回忆一下,栈空间在内存中,采用相关的指令,如:push、pop等,可对其进行特殊的操作。

数据处理

我们知道,计算机是进行数据处理、运算的机器,那么有两个基本的问题就包含在其中:

  1. 处理的数据在什么地方?
  2. 要处理的数据有多长?

这两个问题,在机器指令中必须给以明确或隐含的说明,否则计算机就无法工作。

我们就要针对8086CPU对这两个基本问题进行讨论。虽然讨论是在8086CPU的基础上进行的,但是这两个基本问题却是普遍的,对任何一个处理器都存在。

在8086CPU 中,只有这4个寄存器(bx、bp、si、di)可以用在“[…]” 中来进行内存单元的寻址。
在“[…]” 中,这4个寄存器(bx、bp、si、di)可以单个出现,或只能以四种组合出现:
bx和si、bx和di、bp和si、bp和di

错误的用法

mov ax,[bx+bp]
mov ax,[si+di]

正确的用法:

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,[bx+si+idata]
 mov ax,[bx+di+idata]
 mov ax,[bp+si+idata]
 mov ax,[bp+di+idata]

只要在[…]中使用寄存器bp,而指令中没有显性的给出段地址,段地址就默认在ss中。比如:

mov ax,[bp]               含义: (ax)=((ss)*16+(bp))
mov ax,[bp+idata]      含义:(ax)=((ss)*16+(bp)+idata)
mov ax,[bp+si]           含义:(ax)=((ss)*16+(bp)+(si))
mov ax,[bp+si+idata]  含义:(ax)=((ss)*16+(bp)+(si)+idata)

绝大部分机器指令都是进行数据处理的指令,处理大致可分为三类:
读取、写入、运算
在机器指令这一层来讲,并不关心数据的值是多少,而关心指令执行前一刻,它将要处理的数据所在的位置。

指令在执行前,所要处理的数据可以在三个地方:
CPU内部、内存、端口

汇编语言中用三个概念来表达数据的位置。

  • 立即数(idata)
  • 寄存器
  • 段地址(SA)和偏移地址(EA)

立即数(idata)

对于直接包含在机器指令中的数据(执行前在cPu 的指令缓冲器中),在汇编语言中称为:立即数(idata ) ,在汇编指令中直接给出。例如:
mov ax,1
对应机器码:B80100
执行结果:(ax) = 1

寄存器

指令要处理的数据在寄存器中,在汇编指令中给出相应的寄存器名。例如:
mov ax,bx
对应机器码:89D8
执行结果:(ax) = (bx)

段地址(SA)和偏移地址(EA)

指令要处理的数据在内存中,在汇编指令中可用[X]的格式给出EA,SA在某个段寄存器中。

  • 存放段地址的寄存器可以是默认的。
  • 存放段地址的寄存器也可以显性的给出。

显性的给出存放段地址的寄存器

mov ax,ds:[bp]          ;含义:(ax)=((ds)*16+(bp)) ,默认未指定时,bp跟ss
mov ax,es:[bx]          ;含义:(ax)=((es)*16+(bx)),默认未指定时,bx跟ds
mov ax,ss:[bx+si]      ;含义:(ax)=((ss)*16+(bx)+(si))
mov ax,cs:[bx+si+8]  含义:(ax)=((cs)*16+(bx)+(si)+8)

当数据存放在内存中的时候,我们可以用多种方式来给定这个内存单元的偏移地址,这种定位内存单元的方法一般被称为寻址方式。

指令要处理的数据有多长?

8086CPU的指令,可以处理两种尺寸的数据,byte和word。所以在机器指令中要指明,指令进行的是字操作还是字节操作。

对于这个问题,汇编语言中用以下方法处理:

  • 通过寄存器名指明要处理的数据的尺寸,下面的指令中,寄存器指明了指令进行的是字操作:
    mov ax,1
    mov bx,ds:[0]
    mov ds,ax
    mov ds:[0],ax
    inc ax

    add ax,1000
  • 在没有寄存器名存在的情况下,用操作符X ptr指明内存单元的长度,X在汇编指令中可以为word或byte
    mov word ptr ds:[0],1
    inc word ptr [bx]
    inc word ptr ds:[0]
    add word ptr [bx],2
    mov byte ptr ds:[0],1
    inc byte ptr [bx]
    inc byte ptr ds:[0]
    add byte ptr [bx],2

    在没有寄存器参与的内存单元访问指令中,用word ptr或byte ptr显性地指明所要访问的内存单元的长度是很必要的。否则,CPU无法得知所要访问的单元是字单元,还是字节单元。
  • 有些指令默认了访问的是字单元还是字节单元,
    比如:push [1000H]就不用指明访问的是字单元还是字节单元,
    因为push指令只进行字操作

DIV指令

div是除法指令(division),使用div作除法的时候:

  • 除数:8位或16位,在寄存器或内存单元中
  • 被除数:(默认)放在AX 或 DX和AX中

当除数为8位,被除数应该为16位(AX),当除数为16位时,被除数应该为 32位(DX+AX)。

div指令格式:

div reg
div 内存单元

div指令示例:

div byte ptr ds:[0] ;含义为:(al) = (ax) / ((ds)*16+0)的商;(ah) = (ax) / ((ds)*16+0)的余数
div word ptr es:[0] ;含义为:(ax) = [(dx)*10000H+(ax)]/((ds)*16+0)的商;(dx) = [(dx)*10000H+(ax)]/((ds)*16+0)的余数

伪指令 dd

dd是用来定义dword (double word双字)型数据的。

dup

dup是一个操作符,在汇编语言中同db、dw、dd 等一样,也是由编译器识别处理的符号。
它是和db、dw、dd 等数据定义伪指令配合使用的,用来进行数据的重复。

dup示例

db 3 dup (0)
定义了3个字节,它们的值都是0,
相当于 db 0,0,0

db 3 dup (0,1,2)
定义了9个字节,它们是
0、1、2、0、1、2、0、1、2,
相当于 db 0,1,2,0,1,2,0,1,2

db 3 dup (‘abc’,’ABC’)
定义了18个字节,它们是
‘abcABCabcABCabcABC’,
相当于db ‘abcABCabcABCabcABC’

可见,dup的使用格式如下:

  • db 重复的次数 dup (重复的字节型数据)
  • dw 重复的次数 dup (重复的字型数据)
  • dd 重复的次数 dup (重复的双字数据)

dup是一个十分有用的操作符
比如我们要定义一个容量为 200 个字节的栈段,如果不用dup,则必须用这样的格式:

stack segment
dw 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
dw 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
dw 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
dw 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
dw 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
stack ends

有了dup就可以轻松解决。如下:

stack segment
db 200 dup (0)
stack ends

转移指令

8086CPU的转移指令分为以下几类:

  • 无条件转移指令 (如:jmp)
  • 条件转移指令
  • 循环指令(如:loop)
  • 过程
  • 中断

操作符offset

操作符offset在汇编语言中是由编译器处理的符号,它的功能是取得标号的偏移地址。
比如下面的程序:

assume cs:codesg
codeseg segment
start:mov ax,offset start ; 相当于 mov ax,0 
s:mov ax,offset s ; 相当于mov ax,3 
codesg ends 
end start

jmp指令

mp为无条件转移,可以只修改IP,也可以同时修改CS和IP。
jmp指令要给出两种信息:

  • 转移的目的地址
  • 转移的距离(段间转移、段内短转移,段内近转移)

jmp short 标号(转到标号处执行指令)
这种格式的 jmp 指令实现的是段内短转移,它对IP的修改范围为 -128~127,也就是说,它向前转移时可以最多越过128个字节,向后转移可以最多越过127个字节。

assume cs:codesg
codesg segment
  start:mov ax,0
          jmp short s
          add ax,1
       s:inc ax
codesg ends
end start

左面的程序执行后, ax中的值为 1 ,因为执行 jmp short s 后 ,越过了add ax,1 ,IP 指向了标号 s处的 inc ax。也就是说,程序只进行了一次ax加1操作。

汇编指令与机器码

汇编指令与机器码的对应示例

可以看到,在一般的汇编指令中,汇编指令中的idata(立即数),不论它是表示一个数据还是内存单元的偏移地址,都会在对应的机器指令中出现,因为CPU执行的是机器指令,它必须要处理这些数据或地址。

对照汇编源程序后我们可以看到,Debug 将 jmp short s 中的 s 表示为inc ax 指令的偏移地址 8 ,并将jmp short s 表示为 jmp 0008 ,表示转移到cs:0008处。
但是我们观察对应的机器码,却意外地发现了一些问题:

jmp 0008 ( Debug 中的表示)或jmp short s (汇编语言中的表示)所对应的机器码为EB 03,注意,这个机器码中竟不包含转移的目的地址。
这意味着,CPU 在执行EB 03的时候,并不知道转移目的地址。

这说明在机器指令中并不包含转移的目的地址。
如果机器指令中不包含目的地址的话,那么,也就是说 CPU不需要这个目的地址就可以实现对IP的修改。

jmp short s指令的读取和执行过程:

  1. (CS)=0BBDH,(IP)=0006,CS:IP指向EB 03(jmp short s的机器码);
  2. 读取指令码EB 03进入指令缓冲器;
  3. (IP)=(IP)+所读取指令的长度=(IP)+2=0008,CS:IP指向add ax,1;
  4. CPU指行指令缓冲器中的指令EB 03;
  5. 指令EB 03执行后,(IP)=000BH,CS:IP指向inc ax。

实际上,指令“jmp short 标号”的功能为(IP)=(IP)+8位位移。

  • 8位位移=“标号”处的地址-jmp指令后的第一个字节的地址;
  • short指明此处的位移为8位位移;
  • 8位位移的范围为-128~127,用补码表示
  • 8位位移由编译程序在编译时算出。

还有一种和指令“jmp short 标号”功能相近的指令格式:
jmp near ptr 标号
它实现的时段内近转移。
指令“jmp near ptr 标号”的功能为:
(IP)=(IP)+16位位移。

指令“jmp near ptr 标号”的说明:

  1. 16位位移=“标号”处的地址-jmp指令后的第一个字节的地址;
  2. near ptr指明此处的位移为16位位移,进行的是段内近转移;
  3. 16位位移的范围为
  4. -32769~32767,用补码表示;
  5. 16位位移由编译程序在编译时算出。

Call和ret指令

想想程序之间的加载返回过程。call和ret 指令都是转移指令,它们都修改IP,或同时修改CS和IP。
它们经常被共同用来实现自程序的设计。我们讲解call和ret 指令的原理。

ret 和 retf

ret指令用栈中的数据,修改IP的内容,从而实现近转移。
CPU执行ret指令时,进行下面两步操作:

(1)(IP)=((ss)*16+(sp))
(2)(sp)=(sp)+2

retf指令用栈中的数据,修改CS和IP的内容,从而实现远转移;
CPU执行retf指令时,进行下面两步操作:

(1)(IP)=((ss)*16+(sp))
(2)(sp)=(sp)+2
(3)(CS)=((ss)*16+(sp))
(4)(sp)=(sp)+2

可以看出,如果我们用汇编语法来解释ret和retf指令,则:
CPU执行ret指令时,相当于进行:pop IP
CPU执行retf指令时,相当于进行:pop IP pop CS

call 指令

call指令经常跟ret指令配合使用,因此CPU执行call指令,进行两步操作:

  • 将当前的 IP 或 CS和IP 压入栈中
  • 转移(jmp)

call 指令不能实现短转移,除此之外,call指令实现转移的方法和 jmp 指令的原理相同。

依据位移进行转移的call指令

  • call 标号(将当前的 IP 压栈后,转到标号处执行指令)
  • CPU执行此种格式的call指令时,进行如下的操作:
    • (sp) = (sp) – 2
      ((ss)*16+(sp)) = (IP)
    • (IP) = (IP) + 16位位移
  • call 标号
    • 16位位移=“标号”处的地址-call指令后的第一个字节的地址;
    • 16位位移的范围为 -32768~32767,用补码表示;
    • 16位位移由编译程序在编译时算出。

从上面的描述中,可以看出,如果我们用汇编语法来解释此种格式的 call指令,则:CPU 执行指令“call 标号”时,相当于进行:

push IP
jmp near ptr 标号     

转移的目的地址在指令中的call指令

  • 前面讲解的call指令,其对应的机器指令中并没有转移的目的地址 ,而是相对于当前IP的转移位移。
  • 指令“call far ptr 标号”实现的是段间转移。
  • CPU执行“call far ptr 标号”这种格式的call指令时的操作:
(sp) = (sp) – 2
        ((ss) ×16+(sp)) = (CS)
        (sp) = (sp) – 2
        ((ss) ×16+(sp)) = (IP)
(CS) = 标号所在的段地址
    (IP) = 标号所在的偏移地址

从上面的描述中可以看出,如果我们用汇编语法来解释此种格式的 call 指令,则:
CPU 执行指令 “call far ptr 标号” 时,相当于进行:

push CS
push IP
jmp far ptr 标号

指令格式:call 16位寄存器

  • 功能:
    • (sp) = (sp) – 2
    • ((ss)*16+(sp)) = (IP)
    • (IP) = (16位寄存器)
  • 汇编语法解释此种格式的 call 指令,CPU执行call 16位reg时,相当于进行:
push IP 
jmp 16位寄存器

转移地址在内存中的call指令有两种格式:

  • call word ptr 内存单元地址
  • call dword ptr 内存单元地址

比如下面的指令:

 mov sp,10h
 mov ax,0123h
 mov ds:[0],ax
 call word ptr ds:[0] ;执行后,(IP)=0123H,(sp)=0EH

call dword ptr 内存单元地址

汇编语法解释:

push CS
push IP
jmp dword ptr 内存单元地址

比如,下面的指令:

mov sp,10h
mov ax,0123h
mov ds:[0],ax
mov word ptr ds:[2],0
call dword ptr ds:[0] ; 执行后,(CS)=0,(IP)=0123H,(sp)=0CH

mul 指令

因下面要用到,我们介绍一下mul指令,mul是乘法指令,使用 mul 做乘法的时候:

  • 相乘的两个数:要么都是8位,要么都是16位。
  • 8 位: AL中和 8位寄存器或内存字节单元中;
  • 16 位: AX中和 16 位寄存器或内存字单元中。

结果:

  • 8位:AX中
  • 16位:DX(高位)和AX(低位)中

格式如下:

  • mul reg
  • mul 内存单元

内存单元可以用不同的寻址方式给出,比如:

  • mul byte ptr ds:[0]
    含义为: (ax)=(al)((ds)16+0);
  • mul word ptr [bx+si+8]
    含义为:
    (ax)=(ax) * ((ds)16 + (bx) + (si) + 8)结果的低16位;
    (dx)=(ax) * ((ds)16 + (bx) + (si) + 8)结果的高16位;

例如:

  • 计算100*10
    100和10小于255,可以做8位乘法,程序如下:
mov al,100
mov bl,10
mul bl
结果: (ax)=1000(03E8H)
  • 计算100*10000
    100小于255,可10000大于255,所以必须做16位乘法,程序如下:
mov ax,100
mov bx,10000
mul bx ;结果: (ax)=4240H,(dx)=000FH
               ;(F4240H=1000000)

模块化程序设计

  • 从上面我们看到 ,call 与 ret 指令共同支持了汇编语言编程中的模块化设计。在实际编程中,程序的模块化是必不可少的。
  • 因为现实的问题比较复杂,对现实问题进行分析时,把它转化成为相互联系、不同层次的子问题,是必须的解决方法。
  • 而call和ret 指令对这种分析方法提供了程序实现上的支持。利用 call和ret指令,我们可以用简洁的方法,实现多个互相联系、功能独立的子程序来解决一个复杂的问题。
  • 子程序一般都要根据提供的参数处理一定的事务,处理后,将结果(返回值)提供给调用者。
  • 其实,我们讨论参数和返回值传递的问题,实际上就是在探讨,应该如何存储子程序需要的参数和产生的返回值。

标志寄存器

8086CPU的标志寄存器有16位,其中存储的信息通常被称为程序状态字(PSW)。
我们己经使用过8086CPU的ax、bx、cx、dx、si、di、bp、sp、ip、cs、ss、ds、es等13个寄存器了。
本章中的标志寄存器(以下简称为flag)是我们要学习的最后一个寄存器。

flag 和其他寄存器不一样,其他寄存器是用来存放数据的,都是整个寄存器具有一个含义。而flag寄存器是按位起作用的,也就是说,它的每一位都有专门的含义,记录特定的信息。

8086CPU的flag寄存器的结构:

flag的1、3、5、12、13、14、15位在8086CPU中没有使用,不具有任何含义。而0、2、4、6、7、8、9、10、11位都具有特殊的含义。

ZF标志

flag的第6位是ZF(Zero Flag),零标志位。它记录相关指令执行后:

  • 结果为0 ,ZF = 1
  • 结果不为0,ZF = 0

例如:

mov ax,1
sub ax,1

指令执行后,结果为0,则ZF = 1。

mov ax,2 
sub ax,1

指令执行后,结果为1,则ZF = 0。对于ZF的值,我们可以这样来看,ZF标记相关指令的计算结果是否为0,如果为0,则在ZF要记录下“是0”这样的肯定信息。
指令:

mov ax,1
and ax,0

执行后,结果为0,则ZF=1,表示“结果是0”。
指令:

mov ax,1
or ax,0

执行后,结果不为0,则ZF=0,表示“结果非0”。

注意:
在8086CPU的指令集中,有的指令的执行是影响标志寄存器的,比如:addsubmuldivincorand等,它们大都是运算指令(进行逻辑或算术运算);有的指令的执行对标志寄存器没有影响,比如:movpushpop等,它们大都是传送指令。我们在使用一条指令的时候,要注意这条指令的全部功能,其中包括,执行结果对标记寄存器的哪些标志位造成影响。

PF标志

flag的第2位是PF(Parity Flag):奇偶标志位。它记录指令执行后,结果的所有二进制位中1的个数:

  • 为偶数,PF = 1
  • 为奇数,PF = 0

示例
指令:

mov al,1
add al,10

执行后,结果为00001011B,其中有3(奇数)个1,则PF=0;
指令:

mov al,1
or al,10

执行后,结果为00000011B,其中有2(偶数)个1,则PF=1;

SF标志

flag的第7位是SF(Symbol Flag),符号标志位。
它记录指令执行后,

  • 结果为负,SF = 1
  • 结果为正,SF = 0

示例

mov al,10000001B
add al,1
结果: (al)=10000010B

有符号数与补码

  • 我们知道计算机中通常用补码来表示有符号数据。计算机中的一个数据可以看作是有符号数,也可以看成是无符号数。
  • 比如:
    • 00000001B ,可以看作为无符号数 1 ,或有符号数+1;
    • 10000001B ,可以看作为无符号数129,也可以看作有符号数-127。
  • 这也就是说,对于同一个二进制数据,计算机可以将它当作无符号数据来运算,也可以当作有符号数据来运算。
  • 我们可以将add指令进行的运算当作无符号数的运算,那么add指令相当于计算129+1,结果为130(10000010B);
  • 也可以将add指令进行的运算当作有符号数的运算,那么add指令相当于计算-127+1,结果为-126(10000010B)。
  • 不管我们如何看待,CPU 在执行add等指令的时候,就已经包含了两种含义,也将得到用同一种信息来记录的两种结果。
  • 关键在于我们的程序需要哪一种结果。
  • SF 标志,就是CPU对有符号数运算结果的一种记录 ,它记录数据的正负。在我们将数据当作有符号数来运算的时候,可以通过它来得知结果的正负。
  • 如果我们将数据当作无符号数来运算,SF的值则没有意义,虽然相关的指令影响了它的值。
  • 这也就是说,CPU在执行 add 等指令时,是必然要影响到SF标志位的值的。
  • 至于我们需不需要这种影响,那就看我们如何看待指令所进行的运算了。
    mov al,10000001B add al,1
    执行后,结果为10000010B,SF=1, 表示:如果指令进行的是有符号数运算,那么 结果为负;
  • 再例如:
    mov al,10000001B
    add al,01111111B
    执行后,结果为0,SF=0

    表示:如果指令进行的是有符号数运 算,那么结果为非负。
  • 某此指令将影响标志寄存器中的多个标志位,这些被影响的标记位比较全面地记录了指令的执行结果,为相关的处理提供了所需的依据。
  • 比如指令sub al,al执行后,ZF、PF、SF等标志位都要受到影响,它们分别为:1、1、0。

CF标志

  • flag的第0位是CF(Carry Flag),进位标志位。
  • 一般情况下,在进行无符号数运算的时候,它记录了运算结果的最高有效位向更高位的进位值,或从更高位的借位值。
  • 对于位数为N的无符号数来说,其对应的二进制信息的最高位,即第N-1位,的最高有效位,而假想存在的第N位,就是相对于最高有效位的更高位。

我们知道,当两个数据相加的时候,有可能产生从最高有效位向更高位的进位。

比如,两个8 位数据:98H+98H,将产生进位。由于这个进位值在8位数中无法保存,我们在前面的课程中,就只是简单地说这个进位值丢失了。

其实CPU在运算的时候,并不丢弃这个进位值,而是记录在一个特殊的寄存器的某一位上。 8086CPU 就用flag的CF位来记录这个进位值。

在Debug中,我们可以看到类似下面的信息:

比如,下面的指令:

mov al,98H
add al,al ;执行后: (al)=30H,CF=1,
          ;CF记录了最高有效位向更高位的进位值
add al,al ;执行后: (al)=30H,CF=0,
  • 另外一种情况,而当两个数据做减法的时候,有可能向更高位借位。
  • 比如,两个 8 位数据:97H-98H,将产生借位,借位后,相当于计算197H-98H。
  • 而flag的CF位也可以用来记录这个借位值。
  • 比如,两个 8 位数据:97H-98H,将产生借位,借位后,相当于计算197H-98H。
  • 而flag的CF位也可以用来记录这个借位值。

OF标志(Overflow Flag)

我们先来谈谈溢出的问题。在进行有符号数运算的时候,如结果超过了机器所能表示的范围称为溢出。那么,什么是机器所能表示的范围呢?比如:add al,3 ,那么对于 8 位的有符号数据,机器所能表示的范围就是-128~127。

如果运算结果超出了机器所能表达的范围,将产生溢出。
注意,这里所讲的溢出,只是对有符号数运算而言。(就像进位只是相对于无符号数而言!)

如果在进行有符号数运算时发生溢出,那么运算的结果将不正确。
就上面的两个例子来说:

mov al,98
add al,99 

add指令运算的结果是(al)=0C5H ,因为进行的是有符号数运算,所以 al中存储的是有符号数,而0C5H是有符号数-59的补码。

如果我们用add 指令进行的是有符号数运算,则98+99=-59这样的结果让人无法接受。
造成这种情况的原因,就是实际的结果 197,作为一个有符号数,在 8 位寄存器al中存放不下。
由于在进行有符号数运算时,可能发生溢出而造成结果的错误。所以CPU需要对指令执行后是否产生溢出进行记录。因此有了OF

记住,一定要注意CF和OF的区别:

  • CF是对无符号数运算有意义的标志位;
  • 而OF是对有符号数运算有意义的标志位。

对于无符号数运算,CPU用CF位来记录是否产生了进位;对于有符号数运算,CPU 用 OF 位来记录是否产生了溢出,当然,还要用SF位来记录结果的符号。

对于有无符号,计算机是分不清楚状况的,因此他必须两种都记载着,要怎么用,看的是你当他是什么!
例如:

mov al, 98d
add al, 99d

对于无符号数运算,98+99没有进位,CF=0;
对于有符号数运算,98+99发生溢出,OF=1。

adc指令

adc是带进位加法指令 ,它利用了CF位上记录的进位值。

  • 格式: adc 操作对象1,操作对象2
  • 功能:操作对象1=操作对象1+操作对象2+CF
  • 比如:adc ax,bx 实现的功能是:(ax)=(ax)+(bx)+CF

adc指令示例(一)

mov ax,2 
mov bx,1 
sub bx,ax 
adc ax,l

执行后,(ax)=4。
adc执行时,相当于计算: (ax)+1+CF=2+1+1=4。

adc指令示例(二)

mov ax,1
add ax,ax
adc ax,3

执行后,(ax)=5。
adc执行时,相当于计算: (ax)+3+CF=2+3+0=5。

adc指令示例(三)

mov al,98H 
add al,al 
adc al,3

执行后,(ax)=34H。
adc执行时,相当于计算: (ax)+3+CF=30H+3+1=34H。

在执行 adc 指令的时候加上的 CF 的值的含义,由 adc指令前面的指令决定的,也就是说,关键在于所加上的CF值是被什么指令设置的。
显然,如果CF 的值是被sub指令设置的,那么它的含义就是借位值;如果是被add指令设置的,那么它的含义就是进位值。

sbb指令

sbb(sub with brow)是带借位减法指令,它利用了CF位上记录的借位值。
格式:sbb 操作对象1,操作对象2
功能:
操作对象1=操作对象1–操作对象2–CF
比如:sbb ax,bx
实现功能: (ax) = (ax) – (bx) – CF

利用sbb指令我们可以对任意大的数据进行减法运算。
计算003E100OH–00202000H,结果放在ax,bx中,程序如下:

mov bx,1000H
mov ax,003EH
sub bx,2000H
sbb ax,0020H

sbb和adc是基于同样的思想设计的两条指令,在应用思路上和adc类似。在这里,我们就不再进行过多的讨论。通过学习这两条指令,我们可以进一步领会一下标志寄存器CF位的作用和意义。

cmp指令

cmp 是比较指令,功能相当于减法指令,只是不保存结果。cmp 指令执行后,将对标志寄存器产生影响。其他相关指令通过识别这些被影响的标志寄存器位来得知比较结果。

cmp指令
格式:cmp 操作对象1,操作对象2
功能:计算操作对象1–操作对象2 但并不保存结果,仅仅根据计算结果对标志寄存器进行设置

比如:cmp ax,ax
做(ax)–(ax)的运算,结果为0,但并不在ax中保存,仅影响flag的相关各位。
指令执行后:

ZF=1,
PF=1,
SF=0,
CF=0,
OF=0。

下面的指令:

mov ax,8
mov bx,3
cmp ax,bx

执行后:

(ax) = 8
ZF=0,
PF=1,
SF=0,
CF=0,
OF=0。

其实,我们通过cmp 指令执行后,相关标志位的值就可以看出比较的结果。
例如:cmp ax,bx

现在我们可以看出比较指令的设计思路
即:通过做减法运算,影响标志寄存器,标志寄存器的相关位记录了比较的结果。
反过来看上面的例子 cmp ax,ax

同 add、sub 指令一样,CPU 在执行cmp指令的时候,也包含两种含义:

  • 进行无符号数运算和进行有符号数运算。
  • 所以利用cmp指令可以对无符号数进行比较,也可以对有符号数进行比较。

下面我们再来看一下如果用cmp来进行有符号数比较时,我们要注意哪些标志位!我们以cmp ah,bh为例进行说明:

如果(ah)=(bh) 则(ah)-(bh)=0,所以:ZF=1;
如果(ah)≠(bh) 则(ah)-(bh) ≠0,所以:ZF=0;

所以,我们根据cmp指令执行后ZF的值,就可以知道两个数据是否相等。

我们继续看,如果(ah)<(bh)则可能发生什么情况呢?对于有符号数运算,在 (ah)<(bh) 情况下,(ah)-(bh)显然可能引起SF=1,即结果为负。

比如:
(ah) = 1,(bh) = 2:
则(ah)-(bh)=0FFH,0FFH 为 -1 的补码,因为结果为负,所以SF=1。

(ah)=0FEH,(bx)=0FFH:
则(ah)-(bh)=(-2)-(-1)=0FFH,因为结果为负,所以SF=1。

通过上面的例子,我们是不是可以得到这样的结论:cmp 操作对象1,操作对象2 指令执行后,SF=1,就说明操作对象1<操作对象2?
当然不是。我们再看下面这个例子。

(ah)=22H,(bh)=0A0H:
则(ah)-(bh)=34-(-96)=130=82H,82H是 -126的补码,所以SF=1。
这里虽然SF=1,但是并不能说明(ah)<(bh),因为显然34>-96。

两个有符号数A 和B 相减,得到的是负数,那么可以肯定A<B,这个思路没有错误;关键在于我们根据什么来断定得到的是一个负数。CPU将 cmp 指令得到的结果记录在flag的相关标志位中。

我们可以根据指令执行后,相关标志位的值来判断比较的结果。
单纯地考察SF 的值不可能知道结果的正负。因为SF 记录的只是可以在计算机中存放的相应位数的结果的正负。比如add ah, al执行后,SF记录的是ah中的8位二进制信息所表示的数据的正负。

所得到的相应结果的正负,并不能说明,运算所应该得到的结果的正负。这是因为在运算的过程中可能发生溢出。如果有这样的情况发生,那么,SF的值就不能说明任何问题。

如果没有溢出发生的话,那么,实际结果的正负和逻辑上真正结果的正负就一致了。所以,我们应该在考察SF(得知实际结果的正负)的同时考察OF(得知有没有溢出),就可以得知逻辑上真正结果的正负,同时就可以知道比较的结果。下面,我们以cmp ah,bh为例,总结一下CPU执行cmp指令后,SF和OF的值是如何来说明比较的结果的。

(1)如果SF=1,而OF=0
OF=0,说明没有溢出,逻辑上真正结果的正负=实际结果的正负;因SF=1,实际结果为负,所以逻辑上真正的结果为负,所以(ah)<(bh)。

(2)如果SF=1,而OF=1
OF=1 ,说明有溢出,逻辑上真正结果的正负≠实际结果的正负;
简单分析一下,就可以看出,如果因为溢出导致了实际结果为负,那么逻辑上真正的结果必然为正。这样,SF=1,OF = 1 ,说明了(ah)>(bh)。

(3)如果SF=0,而OF=1
OF=1 ,说明有溢出,逻辑上真正结果的正负≠实际结果的正负;
简单分析一下,就可以看出,如果因为溢出导致了实际结果为正,那么逻辑上真正的结果必然为负。这样,SF=0,OF = 1 ,说明了(ah)<(bh)。

(4)如果SF=0,而OF=0
OF=0,说明没有溢出,逻辑上真正结果的正负=实际结果的正负;
因SF=0,实际结果非负,所以逻辑上真正的结果必然非负。所以(ah)≥(bh)。

上面,我们深入讨论了cmp指令在进行有符号数和无符号数比较时,对flag 相关标志位的影响,和CPU如何通过相关的标志位来表示比较的结果。在学习中,要注意领会8086CPU这种工作机制的设计思想。实际上,这种设计思想对于各种处理机来说是普遍的。

下面的内容中我们将学习一些根据cmp指令的比较结果(即,cmp指令执行后,相关标志位的值)进行工作的指令。
它们检测的是哪些标志位呢?
就是被cmp指令影响的那些,表示比较结果的标志位。
这些条件转移指令通常都和cmp相配合使用,就好像 call 和 ret 指令通常相配合使用一样。

因为 cmp 指令可以同时进行两种比较,无符号数比较和有符号数比较,所以根据 cmp 指令的比较结果进行转移的指令也分为两种,即:
根据无符号数的比较结果进行转移的条件转移指令,它们检测ZF、CF的值;
和根据有符号数的比较结果进行转移的条件转移指令,它们检测 SF、OF和 ZF的值。

这些指令比较常用,它们都很好记忆,它们的第一个字母都是j,表示jump;后面的:

  • e:表示equal;
  • ne:表示not equal;
  • b:表示below;
  • nb:表示not below;
  • a:表示above;
  • na:表示not above。

注意观察一下它们所检测的标志位,都是cmp指令进行无符号数比较时候,记录比较结果的标志位。
比如je,检测 ZF位,当 ZF=1的时候进行转移,如果在 je 前面使用了 cmp 指令,那么je对ZF的检测,实际上就是间接地检测cmp的比较结果是否为两数相等。

DF标志和串传送指令

flag的第10位是DF(Direction Flag),方向标志位。在串处理指令中,控制每次操作后si,di的增减。
DF = 0:每次操作后si,di递增;
DF = 1:每次操作后si,di递减。

格式1: movsb(move stream byte
功能:(以字节为单位传送)
(1) ((es)×16 + (di)) = ((ds) ×16 + (si))
(2) 如果DF = 0则: (si) = (si) + 1
(di) = (di) + 1
如果DF = 1则: (si) = (si) – 1
(di) = (di) – 1

movsb 的功能是将 ds:si 指向的内存单元中的字节送入 es:di中,然后根据标志寄存器DF位的值,将 si和di递增或递减。当然,也可以传送一个字: movsw
格式2:movswmove stream word
功能:(以字为单位传送)将 ds:si指向的内存字单元中word送入es:di中,然后根据标志寄存器DF位的值,将si和di递增2或递减2。

movsb和movsw进行的是串传送操作中的一个步骤,一般来说,movsb 和 movsw 都和rep配合使用,格式如下: rep movsb
rep的作用是根据cx的值,重复执行后面的串传送指令。
由于每执行一次movsb指令si和di都会递增或递减指向后一个单元或前个单元,则rep movsb就可以循环实现(cx)个字符的传送。

由于flag的DF位决定着串传送指令执行后,si和di改变的方向,所以CPU应该提供相应的指令来对DF位进行设置,从而使程序员能够决定传送的方向。8086CPU提供下而两条指令对DF位进行设置:

  • cld(clear direction)指令:将标志寄存器的DF位置0
  • std(set direction)指令:将标志寄存器的DF位置1

pushf 和 popf

  • pushf :将标志寄存器的值压栈;
  • popf :从栈中弹出数据,送入标志寄存 器中。
  • pushf 和 popf,为直接访问标志寄存器提供了一种方法。

内中断

简介

中断是CPU处理外部突发事件的一个重要技术。它能使CPU在运行过程中对外部事件发出的中断请求及时地进行处理,处理完成后又立即返回断点,继续进行CPU原来的工作。引起中断的原因或者说发出中断请求的来源叫做中断源。根据中断源的不同,可以把中断分为硬件中断和软件中断两大类,而硬件中断又可以分为外部中断和内部中断两类。

  • 外部中断一般是指由计算机外设发出的中断请求,如:键盘中断、打印机中断、定时器中断等。外部中断是可以屏蔽的中断,也就是说,利用中断控制器可以屏蔽这些外部设备 的中断请求。
  • 内部中断是指因硬件出错(如突然掉电、奇偶校验错等)或运算出错(除数为零、运算 溢出、单步中断等)所引起的中断。内部中断是不可屏蔽的中断。
  • 软件中断其实并不是真正的中断,它们只是可被调用执行的一般程序以及DOS的系统功能调用(INT 21H)等都是软件中断。

CPU为了处理并发的中断请求,规定了中断的优先权,中断优先权由高到低的顺序是:

  1. 除法错、溢出中断、软件中断
  2. 不可屏蔽中断
  3. 可屏蔽中断
  4. 单步中断

中断处理程序

CPU的设计者必须在中断信息和其处理程序的入口地址之间建立某种联系,使得CPU根据中断信息可以找到要执行的处理程序。
我们知道,中断信息中包含有标识中断源的类型码。根据CPU的设计,中断类型码的作用就是用来定位中断处理程序。

比如CPU 根据中断类型码 4,就可以找到4号中断的处理程序。可随之而来的问题是,若要定位中断处理程序,需要知道它的段地址和偏移地址,而如何根据 8位的中断类型码(8086中断类型码为一个字节)得到中断处理程序的段地址和偏移地址呢? 这就要引入“中断向量表”。

中断向量表

CPU用 8 位的中断类型码通过中断向量表找到相应的中断处理程序的入口地址。那么什么是中断向量表呢?中断向量表就是中断向量的列表。

中断向量表在内存中保存,其中存放着 256个中断源所对应的中断处理程序的入口,如右图所示:

中断向量表在内存中存放,对于8086PC机,中断向量表指定放在内存地址0处。从内存0000:0000到0000:03FF的1024(段地址:偏移地址各占2个字节,256 * 4 = 1024)个单元中存放着中断向量表。

中断过程

从上面的讲解中,我们知道,可以用中断类型码,在中断向量表中找到中断处理程序的入口。找到这个入口地址的最终目的是用它设置CS和IP,使CPU执行中断处理程序。用中断类型码找到中断向量,并用它设置CS和IP,这个工作是由CPU的硬件自动完成的。
CPU 硬件完成这个工作的过程被称为中断过程。

8086CPU的中断过程:

  1. (从中断信息中)取得中断类型码;
  2. 标志寄存器的值入栈(保护标志位);
  3. 设置标志寄存器的第8位TF 和第9位IF的 值为0;(这一步的目的后面将介绍)
  4. CS的内容入栈;
  5. IP的内容入栈;
  6. 从内存地址为中断类型码*4 和中断类型 码 *4+2 的两个字单元中读取中断处理 程序的入口地址设置IP和CS。

可以看到CPU将CS、IP保存在栈中。我们注意到,在中断过程中还要做的一个工作就是设置标志寄存器的TF、IF位。

我们更简洁的描述中断过程,如下:

  1. 取得中断类型码N;
  2. pushf
  3. TF = 0,IF = 0
  4. push CS
  5. push IP
  6. (IP) = (N4),(CS) = (N4+2)

在最后一步完成后,CPU 开始执行由程序员编写的中断处理程序。

由于CPU随时都可能检测到中断信息,也就是说,CPU 随时都可能执行中断处理程序,所以中断处理程序必须一直存储在内存某段空间之中。
而中断处理程序的入口地址,即中断向量,必须存储在对应的中断向量表表项中。
中断处理程序的编写方法和子程序的比较相似,下面是常规的步骤:

  1. 保存用到的寄存器。
  2. 处理中断。
  3. 恢复用到的寄存器。
  4. 用 iret 指令返回。

iret指令的功能用汇编语法描述为:

pop IP
pop CS
popf

iret通常和硬件自动完成的中断过程配合使用。可以看到,在中断过程中,寄存器入栈的顺序是标志寄存器、CS、IP ,而iret的出栈顺序是 IP、CS、标志寄存器,刚好和其对应,实现了用执行中断处理程序前的CPU现场恢复标志寄存器和CS、IP的工作。iret指令执行后,CPU回到执行中断处理程序前的执行点继续执行程序。

除法错误中断的处理

当CPU执行div等除法指令的时候,如果发生了除法溢出错误,将产生中断类型码为 0 的中断信息,CPU将检测到这个信息,然后引发中断过程,转去执行 0 号中断所对应的中断处理程序。

单步中断

CPU为什么要提供这样的功能呢?我们在使用Debug的T命令的时候,有没有想过这样的问题,Debug如何能让CPU在执行一条指令后,就显示各个寄存器的状态?

假想:如果CPU不提供其他功能的话,就按正常方式工作,只要CPU一加电,它就从预设的地址开始一直执行下去……不可控制!可是,我们在Debug中看到的情况却是,Debug可以控制CPU执行被加载程序中的一条指令,然后让它停下来,显示寄存器的状态。Debug有特殊的能力吗?

我们只能说Debug利用了CPU提供的一种功能。只有CPU提供了在执行一条指令后就转去做其他事情的功能,Debug或是其他的程序才能利用CPU提供的这种功能做出我们使用T命令时的效果。好了,我们先来谈谈CPU是如何实现单步中断机制,然后再来简要地考虑一下Debug是如何利用CPU所提供的单步中断的功能的。

CPU在执行完一条指令之后,如果检测到标志寄存器的TF(Trap Flag )位为1,则产生单步中断,引发中断过程。
单步中断的中断类型码为1,则它所引发的中断过程如下:

  1. 取得中断类型码1;
  2. 标志寄存器入栈,TF、IF设置为0;
  3. CS、IP入栈;
  4. (IP)=(14),(CS)=(14+2)。

如上所述,如果TF=1,则执行一条指令后,CPU就要转去执行1号中断处理程序。同样的道理,Debug提供了单步中断的中断处理程序,功能为显示所有寄存器中的内容后等待输入命令。

在使用 T 命令执行指令时,Debug 将TF设置为 1,使得CPU在工作于单步中断方式下,则在CPU执行完这条指令后就引发单步中断,执行单步中断的中断处理程序,所有寄存器中的内容被显示在屏幕上,并且等待输入命令。总之,当TF=1时,CPU在执行完一条指令后将引发单步中断,转去执行中断处理程序。执行完中断处理程序后,又返回原来的位置继续。

我们再来看一下中断过程

  1. 取得中断类型码N;
  2. 标志寄存器入栈,TF=0、IF=0;
  3. CS、IP入栈;
  4. (IP) = (N4),(CS) = (N4+2)

最后,CPU提供单步中断功能的原因就是,为单步跟踪的执行过程,提供了实现机制。

响应中断的特殊情况

一般情况下,CPU在执行完当前指令后,如果检测到中断信息,就响应中断,引发中断过程。可是,在有些情况下,CPU 在执行完当前指令后,即便是发生中断,也不会响应。对于这些情况,我们不一一列举,大家结合实际运用多加体会,这里我们举一种比较典型的情况来进行说明。

例如,在执行完向 ss寄存器传送数据的指令后,即便检测到中断信号,CPU 也不会响应。这样做的主要原因是,ss:sp联合指向栈顶,而对它们的设置应该连续完成。因为,如果在执行完设置ss的指令后,CPU响应中断,引发中断过程,要在栈中压入标志寄存器、CS和IP的值。而ss改变,sp并未改变,ss:sp指向的不是正确的栈顶,将引起错误。

所以CPU在执行完设置ss的指令后,不响应中断。这给连续设置 ss和sp,指向正确的栈顶提供了一个时机。即,我们应该利用这个特性,将设置ss和sp的指令连续存放,使得设置sp的指令紧接着设置ss的指令执行,而在此之间,CPU不会引发中断过程。

比如,我们要将栈顶设为1000:0,

;应该
 mov ax,1000h
 mov ss,ax
 mov sp,0 
 mov ax,0

;而不应该
 mov ax,1000h
 mov ss,ax
 mov ax,0
 mov sp,0

int 指令

简介

int格式: int n,n为中断类型码。它的功能是引发中断过程。

int 指令

CPU 执行int n指令,相当于引发一个 n号中断的中断过程,执行过程如下:

  1. 取中断类型码n;
  2. 标志寄存器入栈,IF = 0,TF = 0;
  3. CS、IP入栈;
  4. (IP) = (n4),(CS) = (n4+2)。

从此处转去执行n号中断的中断处理程序。可以在程序中使用int指令调用任何一个中断的中断处理程序。

程序是没有做除法,但是在结尾使用了int 0指令。CPU执行int 0指令时,将引发中断过程,执行 0号中断处理程序,而系统设置的 0号中断处理程序的功能是显示“Divide overflow”,然后返回到系统。可见,int 指令的最终功能和call指令相似,都是调用一段程序。

因此,一般情况下,系统将一些具有一定功能的子程序,以中断处理程序的方式提供给应用程序调用。我们在编程的时候,可以用int指令调用这些子程序。当然,也可以自己编写一些中断处理程序供别人使用。以后,我们可以将中断处理程序简称为中断例程。

BIOS和DOS中断例程的安装过程

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

端口

CPU可以直接读写3 个地方的数据:

  1. CPU 内部的寄存器
  2. 内存单元
  3. 端口

端口的读写

对端口的读写不能用mov、push、pop等内存读写指令。端口的读写指令只有两条:in 和 out分别用于从端口读取数据和往端口写入数据。我们看一下CPU 执行内存访问指令和端口访问指令时候,总线上的信息:

  • 访问内存
  • 访问端口

访问内存

mov ax,ds:[8];,假设执行前 (ds)=0,执行时,与总线相关的操作:

  1. CPU通过地址线将地址信息8发出;
  2. CPU通过控制线发出内存读命令,选中存储器芯片,并通知它,将要从中读取数据;
  3. 存储器将 8号单元中的数据通过数据线送入CPU。

访问端口

in al,60h;从60h号端口读入一个字节,执行时与总线相关的操作:

  • CPU通过地址线将地址信息60h发出;
  • CPU通过控制线发出端口读命令,选中端口所在的芯片,并通知它,将要从中读取数据;
  • 端口所在的芯片将60h端口中的数据通过数据线送入CPU。

端口的读写

对0~255以内的端口进行读写:

in al,20h 	;从20h端口读入一个字节
out 20h,al 	;往20h端口写入一个字节

对256~65535的端口进行读写时,端口号放在dx中:

mov dx,3f8h 	;将端口号3f8送入dx
in al,dx 	;从3f8h端口读入一个字节
out dx,al 	;向3f8h端口写入一个字节

CMOS RAM 芯片

PC机中有一个CMOS RAM芯片,其有如下特征:

  1. 包含一个实时钟和一个有128个存储单元的RAM存储器。(早期的计算机为64个字节)
  2. 该芯片靠电池供电。因此,关机后其内部的实时钟仍可正常工作, RAM 中的信息不丢失。
  3. 128 个字节的 RAM 中,内部实时钟占用 0~0dh单元来保存时间信息,其余大部分分单元用于保存系统配置信息,供系统启动时BIOS程序读取。BIOS也提供了相关的程序,使我们可以在开机的时候配置CMOS RAM 中的系统信息。
  4. 该芯片内部有两个端口,端口地址为70h和71h。CPU 通过这两个端口读写CMOS RAM。
  5. 70h为地址端口,存放要访问的CMOS RAM单元的地址;71h为数据端口,存放从选定的CMOS RAM 单元中读取的数据,或要写入到其中的数据。

可见,CPU对CMOS RAM的读写分两步进行。比如:读CMOS RAM的2号单元:

  1. 将2送入端口70h
  2. 从71h读出2号单元的内容

shl和shr指令

shl和shr 是逻辑移位指令,shl逻辑左移指令,功能为:

  1. 将一个寄存器或内存单元中的数据向左移位;
  2. 将最后移出的一位写入CF中;
  3. 最低位用0补充。

例如有如下指令:

mov al,01001000b
shl al,1 ;将al中的数据左移一位

执行后(al)=10010000b,CF=0。

如果移动位数大于1时,必须将移动位数放在cl中,比如指令:

mov al,01010001b
mov cl,3
shl al,cl

执行后(al)=10001000b,那请问,CF的值是多少?因为最后移出一位是0,所以CF=0。

可以看出,将X逻辑左移一位,相当于执行X=X*2。比如:

shr逻辑右移指令,它和shl所进行的操作刚好相反:

  • 将一个寄存器或内存单元中的数据向右移位;
  • 将最后移出的一位写入CF中;
  • 最高位用0补充。

可以看出,将X逻辑右移一位,相当于执行X=X/2。

CMOS RAM中存储的时间信息

在CMOS RAM中,存放着当前时间:

  • 秒:00H
  • 分:02H
  • 时:04H
  • 日:07H
  • 月:08H
  • 年:09H

这6个信息的长度长度都为1个字节。

这些数据以BCD码的方式存放:

数码:     0      1       2      3      4      5      6       7      8      9      
BCD码:   0000   0001    0010   0011   0100   0101   0110    0111   1000   1001

例如:数值26,用BCD码表示为: 0010 0110

可见,一个字节可表示两个BCD码。则CMOS RAM存储时间信息的单元中,存储了用两个 BCD码表示的两位十进制数,高 4 位的BCD码表示十位,低4 位的BCD 码表示个位。
比如:00010100b表示14。

外中断

以前我们讨论的都是CPU对指令的执行。我们知道,CPU 在计算机系统中,除了能够执行指令,进行运算以外,还应该能够对外部设备进行控制,接收它们的输入,向它们进行输出。也就是说,CPU 除了有运算能力外,还要有 I/O( Input/Output ,输入/输出)能力。

接口芯片和端口

在PC 系统的接口卡和主板上,装有各种接口芯片。这些外设接口芯片的内部有若干寄存器,CPU 将这些寄存器当作端口来访问。外设的输入不直接送入内存和CPU ,而是送入相关的接口芯片的端口中;CPU 向外设的输出也不是直接送入外设,而是先送入端口中,再由相关的芯片送到外设。

CPU 还可以向外设输出控制命令,而这些控制命令也是先送到相关芯片的端口中,然后再由相关的芯片根据命令对外设实施控制。可见,CPU 通过端口和外部设备进行联系。CPU 在执行完当前指令后,可以检测到发送过来的中断信息,引发中断过程,处理外设的输入。

外中断信息

在PC 系统中,外中断源一共有两类:

  1. 可屏蔽中断
  2. 不可屏蔽中断

可屏蔽中断是CPU 可以不响应的外中断。CPU 是否响应可屏蔽中断,要看标志寄存器的IF(Interrupt Enable Flag) 位的设置。当CPU 检测到可屏蔽中断信息时:

  • 如果IF=1,则CPU 在执行完当前指令后响应中断,引发中断过程;
  • 如果IF=0,则不响应可屏蔽中断。

我们回忆一下内中断所引发的中断过程:

  1. 取中断类型码n;
  2. 标志寄存器入栈,IF=0,TF=0;
  3. CS 、IP 入栈;
  4. (IP)=(n4),(CS)=(n4+2)
  5. 由此转去执行中断处理程序。

可屏蔽中断所引发的中断过程 ,除在第一步的实现上有所不同外,基本上和内中断的中断过程相同。因为可屏蔽中断信息来自于CPU外部,中断类型码是通过数据总线送入CPU 的;而内中断的中断类型码是在CPU内部产生的。

现在,我们可以解释中断过程中将IF置为0的原因了。将IF置0的原因就是,在进入中断处理程序后,禁止其他的可屏蔽中断。当然,如果在中断处理程序中需要处理可屏蔽中断,可以用指令将IF 置1 。8086CPU 提供的设置IF的指令如下:

  • sti,用于设置IF=1;
  • cli,用于设置IF=0。

不可屏蔽中断是CPU 必须响应的外中断。当CPU 检测到不可屏蔽中断信息时,则在执行完当前指令后,立即响应,引发中断过程。对于8086CPU 不可屏蔽中断的中断类型码固定为2。所以中断过程中,不需要取中断类型码。

不可屏蔽中断的中断过程:

  1. 标志寄存器入栈,IF=0,TF=0
  2. CS、IP入栈
  3. (IP)=(8),(CS)=(0AH)

几乎所有由外设引发的外中断,都是可屏蔽中断。当外设有需要处理的事件(比如说键盘输入)发生时,相关芯片向CPU 发出可屏蔽中断信息。不可屏蔽中断是在系统中有必须处理的紧急情况发生时用来通知CPU 的中断信息。在我们的课程中,主要讨论可屏蔽中断。

PC机键盘的处理过程

下面我们看一下键盘输入的处理过程,并以此来体会一下PC 机处理外设输入的基本方法。

  1. 键盘输入
  2. 引发9号中断
  3. 执行int 9中断例程

键盘上的每一个键相当于一个开关,键盘中有一个芯片对键盘上的每一个键的开关状态进行扫描。按下一个键时,开关接通,该芯片就产生一个扫描码,扫描码说明了按下的键在键盘上的位置。扫描码被送入主板上的相关接口芯片的寄存器中,该寄存器的端口地址为60H 。松开按下的键时,也产生一个扫描码,扫描码说明了松开的键在键盘上的位置。松开按键时产生的扫描码也被送入60H 端口中。

一般将按下一个键时产生的扫描码称为通码,松开一个键产生的扫描码称为断码。扫描码长度为一个字节,通码的第 7 位为 0 ,断码的第7位为1,即:断码 = 通码+80H,比如:g键的通码为22H,断码为a2H。

键盘上部分键的扫描码

BIOS 提供了int 9中断例程,用来进行基本的键盘输入处理,主要的工作如下:

  1. 读出60H 端口中的扫描码;
  2. 如果是字符键的扫描码,将该扫描码和它所对应的字符码( 即 ASCII码)送入内存中的 BIOS 键盘缓冲区;键盘的输入到达60H 端口时,相关的芯片就会向CPU 发出中断类型码为 9 的可屏蔽中断信息。CPU检测到该中断信息后,如果IF=1,则响应中断,引发中断过程,转去执行int 9中断例程。如果是控制键(比如 Ctrl )和切换键(比如 CapsLock)的扫描码,则将其转变为状态字节( 用二进制位记录控制键和切换键状态的字节 )写入内存中存储状态字节的单元。
  3. 对键盘系统进行相关的控制,比如说,向相关芯片发出应答信息。BIOS键盘缓冲区是系统启动后,BIOS用于存放int 9 中断例程所接收的键盘输入的内存区。
    该内存区可以存储 15 个键盘输入,因为 int 9 中断例程除了接收扫描码外,还要产生和扫描码对应的字符码,所以在BIOS键盘缓冲区中,一个键盘输入用一个字单元存放,高位字节存放扫描码,低位字节存放字符码。
    0040:17 单元存储键盘状态字节,该字节记录了控制键和切换键的状态。键盘状态字节各位记录的信息如下:

直接定址表

前面我们一直在代码段中使用标号来标记指令、数据、段的起始地址。比如:下面的程序将code 段中的a 标号处的8个数据累加,结果存储到b标号处的字中。

assume cs:code
code segment
         a : db 1,2,3,4,5,6,7,8
         b : dw 0
start :mov si,offset a
         mov bx,offset b
         mov cx,8
    s : mov al,cs:[si]
         mov ah,0
         add cs:[bx],ax
         inc si
         loop s
         mov ax,4c00h
         int 21h
code ends
end start

程序中,code、a、b、start、s都是标号。这些标号仅仅表示了内存单元的地址。但是,我们还可以使用一种标号,这种标号不但表示内存单元的地址,还表示了内存单元的长度,即表示在此标号处的单元,是一个字节单元,还是字单元,还是双字单元。上面的程序我们还可以写成这样:

assume cs:code
code segment
          a db 1,2,3,4,5,6,7,8
          b dw 0
start :  mov si,0
          mov cx,8
    s :   mov al,a[si]
          mov ah,0
          add b,ax
          inc si
          loop s
          mov ax,4c00h
          int 21h
code ends
end start

我们在code 段中使用的标号a、b后面没有“:” ,因此它们是可以同时描述内存地址和单元长度的标号。标号a,描述了地址code:0,和从这个地址开始,以后的内存单元都是字节单元;而标号b描述了地址code:8,和从这个地址开始,以后的内存单元都是字单元。

因为这种标号包含了对单元长度的描述,所以,在指令中,它可以代表一个段中的内存单元。

比如,对于程序中的b dw 0。
指令:mov ax,b 相当于:mov ax,cs:[8]
指令:mov b,2 相当于:mov word ptr cs:[8],2
指令:inc b 相当于:inc word ptr cs:[8]

在这些指令中,标号b 代表了一个内存单元,地址为code:8 ,长度为2 字节。

下面的指令会引起编译错误:

mov al,b
为什么?因为b代表的内存单元是字单元,而al 是8 位寄存器。因此,如果我们将程序中的指令:add b,ax ,写为add b,al,将出现同样的编译错误。

对于程序中的a db 1,2,3,4,5,6,7,8
则有—>>>
指令:mov al,a [si]
相当于:mov al,cs:0[si]
指令:mov al,a[3]
相当于:mov al,cs:0[3]
指令:mov al,a[bx+si+3]
相当于:mov al,cs:0[bx+si+3]

可见,使用这种包含单元长度的标号, 可以使我们以简洁的形式访问内存中的数据。以后,我们将这种标号称为数据标号。它标记了存储数据的单元的地址和长度。它不同于仅仅表示地址的地址标号。

一般来说,我们不会在代码段中定义数据,而是将数据定义到其他段中。在其他段中,我们也可以使用数据标号来描述存储数据的单元的地址和长度。注意:在后面加有“:”的地址标号,只能在代码段中使用,不能在其他段中使用。下边程序将data 段中 a标号处的 8 个数据累加,结果存储到 b标号处的字中。

assume cs:code,ds:data
data segment          
          a db 1,2,3,4,5,6,7,8
          b dw 0
data ends
code segment
start:  mov ax,data
          mov ds,ax
          mov si,0
          mov cx,8
s:       mov al,a[si]
          mov ah,0
          add b,ax
          inc si
          loop s
          mov ax,4c00h
          int 21h
code ends
end start

注意,如果想在代码段中,直接用数据标号访问数据,则需要用伪指令assume 将标号所在的段和一个段寄存器联系起来。否则编译器在编译的时候,无法确定标号的段地址在哪一个寄存器中。当然,这种联系是编译器需要的,但绝对不是说,我们因为编译器的工作需要,用 assume 指令将段寄存器和某个段相联系,段寄存器中就会真的存放该段的地址。

比如:在上面的程序中,我们要在代码段code中用 data段中的数据标号 a、b 访问数据,则必须用 assume 将一个寄存器和data 段相联。

在程序中,我们用 ds寄存器和 data 段相联,则编译器对相关指令的编译如下:
指令:mov al,a[si]
编译为:mov al,[si+0]
指令:add b,ax
编译为:add [8],ax

因为这些实际编译出的指令,都默认所访问单元的段地址在ds中,而实际要访问的段为data,所以,若要访问正确,在这些指令执行前,ds 中必须为 data 段的段地址。
则,我们在程序中使用指令: mov ax,data mov ds,ax
设置ds指向data段。

我们可以将标号当作数据来定义,此时,编译器将标号所表示的地址当作数据的值。
比如: data segment
a db 1,2,3,4,5,6,7,8
b dw 0
c dw a,b
data ends
数据标号c处存储的两个字型数据为标号a、b 的偏移地址。

相当于:

data segment
a db 1,2,3,4,5,6,7,8
b dw 0
c dw offset a, offset b
data ends

再比如:

data segment
a db 1,2,3,4,5,6,7,8
b dw 0
c dd a,b
data ends

数据标号c处存储的两个双字型数据为标号a的偏移地址和段地址、标号b 的偏移地址和段地址。相当于:

data segment
a db 1,2,3,4,5,6,7,8
b dw 0
c dw offset a, seg a, offset b, seg b
data ends

seg操作符,功能为取得某一标号的段地址。

使用BIOS进行键盘输入和磁盘读写

大多数有用的程序都需要处理用户的输入,键盘输入是最基本的输入。程序和数据通常需要长期存储,磁盘是最常用的存储设备。BIOS 为这两种外设的I/O提供了最基本的中断例程,在本章中,我们对它们的应用和相关的问题进行讨论。

《琢石成器:Windows环境下32位汇编语言程序设计》

我们已经讲过,键盘输入将引发9 号中断,BIOS 提供了int 9 中断例程。CPU 在9 号中断发生后,执行int 9中断例程,从60h 端口读出扫描码,并将其转化为相应的ASCII 码或状态信息,存储在内存的指定空间(键盘缓冲区或状态字节)中。所以,一般的键盘输入,在CPU 执行完int 9 中断例程后,都放到了键盘缓冲区中。

键盘缓冲区中有16 个字单元,可以存储15个按键的扫描码和对应的入ASCII 码。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注