《汇编语言》第三版阅读笔记

Thou

第一章 基础知识

汇编课程研究重点放在如何利用硬件系统的编程结构和指令集有效灵活的控制系统进行工作。

1.1 机器语言

机器语言是机器指令的集合。电子计算机的机器指令是一列二进制数字。计算机将之转换为一列高低电平,以使计算机的电子器件受到驱动,进行运算。

每一种微处理器都有自己的机器指令集,也就是机器语言。

1.2 汇编语言的产生

机器语言难以辩别和记忆,基于此人们发明了汇编语言。

寄存器 简单的讲是CPU中(内部)可以存储数据的器件。
编译器 能够将汇编指令转换为机器指令的翻译程序。

1.3 汇编语言的组成

汇编语言主要由以下3类指令组成:

(1) 汇编指令:机器码的助记符,有对应的机器码。
(2) 伪指令:没有对应的机器码,由编译器执行,计算机并不执行。
(3) 其他符号:如 +、-、*、/等,由编译器识别,没有对应的机器码。

1.4 存储器

1.5 指令和数据

指令和数据是应用上的概念。在内存或磁盘上,指令和数据没有任何区别,都是二进制信息。

1.6 存储单元

计算机内的最小信息单位是bit,即一个二进制位。
计算机内的基本存储单元是Byte,即一个字节。一个字节等于8个二进制位。
1KB = 1024B, 1MB = 1024KB, 1GB = 1024MB, 1TB = 1024GB

1.7 CPU 对存储器的读写

CPU通过地址总线给出数据存储位置。
CPU通过控制总线给出数据存储方向。
CUP通过数据总线进行数据传输。

1.8 地址总线

一个CPU有N根地址线,则可以说这个CPU的地址总线宽度为N。这样的CPU最多可以寻找2的N次方个内存单元。

1.9 数据总线

数据总线的宽度决定了CPU和外界的数据传送速度。

1.10 控制总线

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

1.1~1.10 小结

(1) 汇编指令是机器指令的助记符,同机器指令一一对应。
(2) 每一种CPU都有自己的汇编指令集。
(3) CPU可以直接使用的信息在存储器中存放。
(4) 在存储器中指令和数据没有任何区别,都是二进制信息。
(5) 存储单元从零开始顺序编号。
(6) 一个存储单元可以存储8个bit,即8位二进制数。
(7) 1Byte = 8bit, 1KB = 1024B = 2^10B, 1MB = 1024KB = 2^20B, 1GB = 1024MB = 2^30B。2^10 = 1024, 2^16 = 65536。
(8) 每一个CPU芯片都有许多管脚,这些管脚和总线相连。也可以说,这些管脚引出总线。一个CPU可以引出3中总线的宽度标志了这个CPU的不同方面的性能:
地址总线的宽度决定了CPU的寻址能力;
数据总线的宽度决定了CPU与其他器件进行数据传送时一次数据传送量;
控制总线的宽度决定了CPU对系统中其他器件的控制能力。

1.11 内存地址空间(概述)

内存地址空间就是CPU可以通过地址总线寻址到的内存单元集合。

1.12 主板

1.13 接口卡

CPU通过总线向接口卡发送命令,接口卡根据CPU的命令控制外设工作。如:网卡、显卡、声卡等。

1.14 各类存储器芯片

RAM: 随机存储器,可读可写,但必须带电存储,断电后存储内容消失。
ROM: 只读存储器,只能读出,不能写入。断电后存储内容不消失。
BIOS: Basic Input/Output System,基本输入输出系统。BIOS是由主板和各类接口卡(如显卡、网卡等)厂商提供的软件系统。 可以通过它利用该硬件设备进行最基本的输入输出。在主板和某些接口卡上茶油存储相应BIOS的ROM。例如,主板上的ROM中存储着主板的BIOS(通常称为系统BIOS);显卡上的ROM存储着显卡的BIOS;如果网卡上装有ROM,那其中就可以存储网卡的BIOS。

1.15 内存地址空间

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


第二章 寄存器

一个典型的CPU由运算器、控制器、寄存器等器件构成:

  • 运算器进行信息处理;
  • 寄存器进行信息存储;
  • 控制器控制各种器件进行工作;
  • 内部总线连接各种器件,在它们之间进行数据的传送。

寄存器是CPU内部的存储器件。

8086CPU内部有14个寄存器,分别是:AX、BX、CX、DX、SI、DI、SP、BP、IP、CS、SS、DS、ES、PSW。这14个寄存器都是16位的。

2.1 通用寄存器

AX、BX、CX、DX这这四个寄存器通常用来存放一般性数据,被称为通用寄存器。

为了保证向前兼容,8086CPU的AX、BX、CX、DX这4个寄存器可以分为两个独立使用的8位寄存器来用:

  • AX可以分为AH和AL;
  • BX可以分为BH和BL;
  • CX可以分为CH和CL;
  • DX可以分为DH和DL。

2.2 字在寄存器中的存储

对于8086CPU来说,一个字由两个字节组成,这两个字节分别称之为高位字节和低位字节,并且存储于寄存器中的高8位和低8位。

2.3 几条汇编指令

mov ax, 001AH——转移指令
add ax, bx——求和指令

2.4 物理地址

所有内存单元构成的存储空间是一个一维线性空间,每一个内存单元在这个空间都有唯一的地址,我们将这个唯一的地址称为物理地址。

2.5 16位结构的CPU

8086CPU是16位结构的CPU,这也就是说,在8086内部,能够一次性处理、传输、暂时存储的信息的最大长度是16位的。内存单元的地址在送上地址总线之前,必须在CPU中处理、传输、暂时存放,对于16位CPU,能一次性处理、传输、暂时存储16位的地址。

但8086CPU有20根地址总线,那么16位的8086CPU是如何给出20位的地址总线的呢?

2.6 8086CPU给出物理地址的方法

8086CPU地址总线长度大于字长,导致程序物理地址无法一次性传递给CPU。为此,8086CPU采用一种在内部用两个16位地址合成的方法来形成一个20位的物理地址。

当8086CPU要读写内存时:
(1)CPU中的相关部件提供两个16位的地址,一个称为段地址,另一个称为偏移地址;
(2)段地址和偏移地址通过内部总线送入一个称为地址加法器的部件;
(3)地址加法器将两个16位地址合成为一个20位的物理地址;
(4)地址加法器通过内部总线将20位物理地址送入输入输出控制电路;
(5)输入输出控制电路将20位物理地址送上地址总线;
(6)20位物理地址被地址总线送到存储器。

地址加法器采用物理地址 = 段地址X16 + 偏移地址的方法用段地址和偏移地址合成物理地址。

2.7 “段地址X16+偏移地址=物理地址”的本质含义

“段地址X16+偏移地址=物理地址”的本质含义是:CPU在访问内存时,用一个基础地址(段地址X16)和一个相对基础地址的偏移地址相加,给出内存单元的物理地址。

举个例子:

假如说隔壁部门的同事张三来找你询问李四的工位?你发现李四的工位不好直接描述,既不是在角落也不是在中间。这时你发现李四旁边坐着的是经理,所以你告诉张三说李四就是经理左边第二位的那个人。这就是生活中使用“基础地址+偏移地址=物理地址”的例子。

还有比方说,大家描述学校水房的位置,一般会说在几号餐厅后面,或某宿舍楼旁边。

2.8 段的概念

CPU访问内存单元时,必须向内存提供内存单元的物理地址。8086CPU在内部用段地址和偏移地址移位相加的方法形成最终的物理地址。

CPU可以用不同的段地址和偏移地址形成同一个物理地址。

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

2.9 段寄存器*

8086CPU内部有四个段寄存器:CS、DS、SS、ES。用于存储指定内存单元的段地址。

2.10 CS和IP

8086PC机中,任意时刻,CPU将CS:IP指向的内容当作指令执行。其中CS为代码段寄存器,IP为指令指针寄存器。

8086CPU执行指令过程如下:

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

2.11 修改CS、IP的指令

能够改变CS、IP内容的指令被统称为转移指令。如jump指令。

2.12 代码段

我们可以将长度为N(N <= 64KB)的一组代码,存在一组地址连续、起始地址为16的倍数的内存单元中。这段地质连续的内存空间就称之为代码段。简单来说也就是存放代码的段。

2.9~2.12小结

(1) 段地址在8086CPU的段寄存器中存放。当8086CPU要访问内存时,由段寄存器提供内存单元的段地址。8086CPU有4个段寄存器,其中CS用来存放指令的段地址。
(2) CS存放指令的段地址,IP存放指令的偏移地址。8086机中,任意时刻,CPU将CS:IP指向的内容当做指令指向。
(3) 8086CPU工作过程:略
(4) 8086CPU提供转移指令修改CS、IP的内容。


第三章 寄存器(内存访问)

本章从内存访问的角度学习相关寄存器。

3.1 内存中字的存储

字单元:存放一个字型数据(16位)的内存单元,由两个地址连续的内存单元组成。高地址内存单元存放字型数据的高位字节,低地址单元存放字型数据的低位字节。

这种存储方式也被称为小端存储,Intel系列的处理器一般都是小端存储。

3.2 DS和[Address]

上一章我们学习了CS段寄存器,用于存放代码段段地址。这里我们再引入另外一个段寄存器DS,用于存放数据段段地址。

需要特别注意的是,8086CPU不支持将数据直接送入段寄存器。 包括所有的段寄存器CS、DS、SS、ES都不支持将数据从内存直接送入。内存中的数据必须先送入其他中间寄存器,然后在从中间寄存器送入段寄存器。(此处描述有误:栈操作”pop 段寄存器”实际上就是将数据从内存中直接送入段寄存器,此处应该更正为无法通过move指令将数据从内存中直接送入段寄存器)

“[address]”表示一个内存单元,中括号中的address表示内存单元的偏移地址。默认情况下,8686CPU取DS中的数据作为该内存单元的段地址。

3.3 字的传送

使用move指令一次可以传送一个字。move指令可以将数据从内存送入寄存器,也可以将数据从寄存器送入内存,也可以将数据从寄存器送入寄存器。但move指令不支持内存到内存的传送。

3.4 mov、add、sub指令

add指令和sub指令与mov指令用法类似,他们都有两个操作对象。这两个操作对象可以是如下格式:
寄存器, 数据
寄存器, 寄存器
寄存器, 内存单元
内存单元, 寄存器

有两点需要注意:
(1) mov、add、sub指令的两个操作对象不能同时为内存单元。
(2) 段寄存器只能接收mov指令传送数据,不可以进行算术运算。如 add ds, ax指令是违法的。(此处描述不够严谨,实际上段寄存器也可以接收来自操作栈的pop指令传递的数据)

3.5 数据段

数据段是一段长度为N(N <= 64KB)、地址连续、其实地址为16的倍数的内存单元。我们用段寄存器DS存放数据段的段地址。

3.1~3.5小结

(1) 字在内存中存储时,要用两个地址连续的内存单元来存放,字的低位字节存放在低地址单元中,高位字节存放在高地址单元中。
(2) 用mov指令访问内存单元,可以在mov指令中只给出单元的偏移地址,此时,段地址默认在DS寄存器中。
(3)[address]表示一个偏移地址为address的内存单元。
(4) 在内存和寄存器之间传送数据时,高地址单元和高8位寄存器、低地址单元和低8位寄存器相对应。
(5) mov、add、sub是具有两个操作对象的指令。jmp是具有一个操作对象的指令。
(6) 可以根据自己的推测,在debug中实验指令的新格式。

3.6 栈

栈就是一种先进后出的数据结构。LIFO(Last In First Out)。

3.7 CPU提供的栈机制

8086CPU对栈提供两个基本操作指令:PUSH(入栈)和POP(出栈)。 PUSH是将数据送入栈中,POP是将数据移出栈中。

前面我们已经学习了CS和DS两个段寄存器。并且知道CS:IP指向的内存单元被当做指令,DS:[address]指向的内存单元被当做数据。这里我们引入另外一个段寄存器SS,SS中保存的是栈顶元素的段地址,此外使用SP保存栈顶元素的偏移地址。故在任意时刻SS:SP都指向栈顶元素。

PUSH AX的操作详情:
(1)SP=SP-2,SS:SP指向当前栈顶前面的单元,以当前栈顶前面的单元为新的栈顶;
(2)将ax中的内容送入SS:SP指向的内存单元,SS:SP此时指向新的栈顶。

POP AX的操作详情:
(1)将SS:SP指向的内存单元处的数据送入ax中;
(2)SP=SP+2,SS:SP指向当前栈顶下面的单元,以当前栈顶下面的单元为新的栈顶。

3.8 栈顶超界问题

当栈满的时候进行PUSH操作或者栈空的时候使用POP操作,都将引发栈顶超界问题。

8086CPU并未对栈顶超界做任何处理,程序员在编程的时候应当避免使得栈顶超界的情况发生。

3.9 push、pop指令

push指令和pop指令支持如下形式:

1
2
3
4
5
6
7
push 寄存器
push 段寄存器
push 内存单元

pop 寄存器
pop 段寄存器
pop 内存单元

push、pop实际上就是一种内存传送指令,可以在寄存器和内存之间传送数据,与mov指令不同的是,push和pop指令访问的内存单元的地址不是在指令中给出的,而是由SS:SP指出的。同时,push和pop还要改变sp中的值。CPU执行mov指令仅需一步,CPU执行push和pop指令需要两步:传送数据和修改sp的值。

需要注意的是,push、pop等栈操作指令,修改的只是SP,也就是说,栈顶的变化范围最大为:0~FFFFH。

栈的综述
(1)8086CPU提供了栈操作机制,方案如下。
在SS、SP中存放栈顶的段地址和偏移地址;
提供入栈和出栈指令,它们根据SS:SP指示的地址,按照栈的方式访问内存单元。
(2)push指令的执行步骤:1、 SP=SP-2; 2、向SS:SP指向的字单元中送入数据。
(3)pop指令的执行步骤:1、从SS:SP指向的字单元中读取数据;2、SP=SP+2。
(4)任意时刻,SS:SP指向栈顶元素。
(5)8086CPU只记录栈顶,栈空间的大小我们要自己管理。
(6)用栈来暂存以后要恢复的寄存器的内容时,寄存器出栈的顺序要和入栈的顺序相反。
(7)push、pop实际上是一种内存传送指令,注意它们的灵活应用。
栈是一种非常重要的机制,一定要深入理解,灵活掌握。 (P67)

3.10 栈段

与代码段、数据段类似,我们在编程时,可以根据需要,将一组内存单元定义为一个段。我们可以将长度为N(N<=64KB)的一组地址连续、起始地址为16的倍数的内存单元,当做栈空间来用。只需要使用SS:SP指向它们。

一个栈最大为64KB,即偏移地址所能指向的最大范围。当一个大小为64KB的栈,其SP=0时则表示该栈为空或者栈满。

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

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

我们可以这样安排,但若要让CPU按照我们的安排来访问这些段,就要:

对于数据段,将它的段地址放在DS中,用mov、add、sub等访问内存单元的指令时,CPU就将我们定义的数据段中的内容当做数据来访问;

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

可见,不管我们如何安排,CPU将内存中的某段内容当做代码,是因为CS:IP指向了那里;CPU将某段内存当做栈,是因为SS:SP指向了那里。我们一定要清楚,什么是我们的安排,以及如何让CPU按我们的安排行事。要非常清楚CPU的工作原理,才能在控制CPU按照我们安排运行的时候做到游刃有余。

比如我们将10000H~1001FH安排为代码段,并在里面存储如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
mov ax, 1000H   
mov ss, ax
mov sp, 0020H ;初始化栈顶
mov ax, cs
mov ds, ax ;设置数据段段地址
mov ax, [0]
add ax, [2]
mov bx, [4]
add bx, [6]
push ax
push bx
pop ax
pop bx

设置CS=1000H,IP=0,这段代码将得到执行。可以看到,在这段代码中,我们又将10000H1001FH安排为栈段和数据段。10000H1001FH这段内存,即是代码段,又是栈段和数据段。

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

(p69)


第四章 第一个程序

4.1 一个源程序从写出到执行的过程

一个汇编语言程序从写出到最终执行主要经历三步:

第一步:编写汇编语言程序;
第二步:对源程序进行编译连接;
第三步:执行可执行文件中的程序。

对源程序进行编译连接生成可在操作系统中直接运行的可执行文件。可执行文件包含两部分内容。

  • 程序(从源程序中的汇编指令翻译过来的机器码)和数据(源程序中定义的数据)
  • 相关的描述信息(比如,程序有多大、要占用多少内存空间等)

4.2 源程序

一段简单的汇编语言源程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

1. 伪指令

在汇编语言源程序中,包括两种指令,一种是汇编指令,一种是伪指令。汇编指令是有对应的机器码的指令,可以被编译为机器指令,最终为CPU所执行。而伪指令没有对应的机器指令,最终不被CPU所执行。伪指令是由编译器来执行的指令,编译器根据伪指令来进行相应的编译工作。

下面介绍上面程序中所出现的几个伪指令:

**(1) segment和ends **

segment和ends是一对成对使用的伪指令。功能是定义一个段。使用格式为:

1
2
3
4
段名 segment
.
.
段名 ends

一个汇编语言程序是由多个段组成的,这些段被用来存放代码(代码段)、数据(数据段)或当做栈空间(栈段)来使用。

**(2)end **

end是一个汇编语言的结束标记,编译器在编译汇编程序的过程中,如果碰到了伪指令end,就结束对源程序的编译。

**(3)assume **

这条伪指令的含义为“假设”。它假设某一段寄存器和程序中的某一个用segment…ends定义的段相关联。即将定义的段的段地址存放在段寄存器中。

2. 源程序中的“程序”

我们将源程序文件中的所有的内容称之为源程序,将源程序中最终由计算机执行、处理的指令或数据,称为程序。

3. 标号

汇编源程序中,除了汇编指令和伪指令外,还有一些标号,比如“codesg”。一个标号指代了一个地址。比如codesg在segment的前面,作为一个段的名称,这个段的名称最终将被编译、连接程序处理为一个段的段地址。

4. 程序的结构

源程序就是由一些段组成的。我们可以在这些段中存放代码、数据、或将某个段当做栈空间。

5. 程序返回

一个程序结束后,将CPU控制权交还给使它得以运行的程序,我们称这个过程为程序返回。 程序返回指令:

1
2
mov ax, 4c00H
int 21H

当前我们不必理解这两天语句的含义。只要记住使用这两条指令可以实现程序返回。

段结束、程序结束、程序返回的区别

目的 相关指令 指令性质 指令执行者
通知编译器一个段结束 段名 ends 伪指令 编译时,由编译器执行
通知编译器程序结束 end 伪指令 编译时,由编译器执行
程序返回 mov ax,4c00H int 21H 汇编指令 执行时,由于CPU执行

6. 语法错误和逻辑错误

一般来说,程序在编译时被编译器发现的错误是语法错误,如 mov ss, 1234 。

在程序编译后,在运行时发生的错误是逻辑错误,如除零操作。

4.3 编辑源程序

源程序文件以.asm作为后缀。

4.4 编译

我们使用微软的masm5.0汇编编译器进行编译,文件名为masm.exe。我们以c:\1.asm为例说明编译源程序的方法步骤。

(1)进入DOS方式,运行masm.exe。

1
2
3
...
...
Source filename [.ASM]: _

(2)输入要编译的源程序文件名,按enter键。

1
2
Source filename [.ASM]: c:\1.asm
Object filename [1.obj]:

输入要编译出的目标文件名,如果不输入则默认使用源程序名。

(3)确定了目标文件名称后,继续按Enter键(两次),忽略中间文件的生成。

最终完成对源程序的编译,生成目标文件。目标文件以.obj作为后缀。

4.5 连接

在对源程序进行编译生成目标文件后,我们需要对目标文件进行连接,从而得到可执行文件。

这里我们使用微软的Overlay Linker3.60连接器,文件名为link.exe。我们以4.4中生成的目标文件c:\masm\1.obj为例说明连接操作步骤。

(1)进入DOS方式,运行link.exe。

1
2
3
...
...
Object Modules [.OBJ]: _

(2)输入目标文件名,按enter键。

1
2
3
4
...
...
Object Modules [.OBJ]: 1.obj
Run File [1.EXE]: _

键入生成可执行文件的名称。如果不输入则默认使用源程序名。

(3)确定了可执行文件名后,按Enter键(两次),忽略镜像文件的生成,忽略库文件的连接。

经过以上三步后,最终会生成以.exe结尾的可执行文件。

连接的作用

(1)当源程序很大时,可以将它分为多个源程序文件来单独编译,然后将生成的目标文件连接在一起,节约程序编译时间。

(2)程序中调用了某个库文件的子程序,需要将这个库文件和该程序生成的目标文件连接在一起,生成一个可执行文件。

(3)一个源程序编译后,达到了存有机器码的目标文件,目标文件中的有些内容还不能直接用来生成可执行文件,连接程序将这些内容处理为最终的可执行信息。所以,在只有一个源程序文件,而又不需要调用某个库中的子程序的情况下,也必须用连接程序对目标文件进行处理,生成可执行
文件。

4.6 以简化的方式进行编译和连接

一键编译:

1
masm c:\1.asm;

一键连接:

1
link c:\1.obj;

4.7 1.exe的执行

进入dos环境,直接键入.exe可执行文件的文件名即可执行。

4.8 谁将可执行文件中的程序装载进入内存并使它运行

(1)在DOS中直接执行1.exe时,是正在运行的command,将1.exe中的程序加载入内存;

(2)command设置CPU的CS:IP指向程序的第一条指令(即程序入口),从而使程序得以运行。

(3)程序运行结束后,返回到command中,CPU继续运行command。

操作系统的外壳

操作系统是由多个功能模块组成的庞大、复杂的软件系统。任何通用的操作系统,都要提供一个称为shell(外壳)的程序,用户(操作人员)使用这个程序来操作计算机系统进行工作。

DOS中有一个程序command.exe,这个程序在DOS中称为命令解释器,也就是DOS系统的shell。

DOS启动时,先完成其他重要的初始化工作,然后运行command.exe,command.exe运行后,执行完其他的相关任务后,在屏幕上显示出由当前盘符和当前路径组成的提示符,比如:“c:\”或“c:\windows”等,然后等待用户的输入。

用户可以输入要执行的命令,比如,cd、dir、type等,这些命令由于command执行,command执行完这些命令后,再次显示当前盘符和当前路径组成的提示符,等待用户输入。

如果用户要执行一个程序,则输入该程序可执行文件的名称,command首先根据文件名找到可执行文件,然后将这个可执行文件中的程序加载入内存,设置CS:IP指向程序的入口。此后,command暂停运行,CPU运行程序。程序运行结束后,返回到command中,command再次显示由当前盘符和当前路径组成的提示符,等待用户输入。

在DOS中,command处理各种输入:命令或要执行的程序的文件名。我们就是通过command来进行工作的。

shell : 操作人员和OS之间的API。

汇编程序从写出到执行的过程

到此,完成了一个汇编程序从写出到执行的全部过程。我们经历了这样一个历程: 编程(Edit) → 1.asm → 编译(masm) → 1.obj → 连接(link) → 1.exe → 加载(command) → 内存中的程序 → 运行(CPU)

4.9 程序执行过程的跟踪

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

(1)找到一段起始地址为SA:0000(即起始地址的偏移地址为0)的容量足够的空闲内存区;

(2)在这段内存区的前256个字节中,创建一个称为程序段前缀(PSP)的数据区,DOS要利用PSP来和被加载程序进行通信;

(3)从这段内存区的256字节处开始(在PSP后面),将程序装入,程序的地址被设为SA+10H:0;(空闲内存区从SA:0开始,0~255字节为PSP,从256字节处开始存放程序,为了更好地区分PSP和程序,DOS一般将它们划分到不同的段中,所以有了这样的地址安排:
空闲内存区:SA:00
PSP区:SA:0
程序区:SA+10:0
注意:PSP和程序区虽然物理地址连续,却有着不同的段地址

(4)将该内存区的段地址存取ds中,初始化其它相关寄存器后,设置CS:IP指向程序入口。

程序加载进内存后,cx中存放的是程序的长度,ds存放着程序所在内存区的段地址,cs存放可执行程序的段地址,ip存放着可执行程序的偏移地址。

Debug常用命令

我们使用Debug对程序的执行过程进行跟踪。
用T命令单步执行程序的每一条执行。
用P命令执行程序结束语句int 21。
用Q命令退出debug。


第五章 [BX]和loop指令

1. [bx]和内存单元的描述

[bx]表示一个内存单元,该内存单元的段地址位于ds中,偏移地址位于bx中。
该内存单元的完整地址为: ds*16 + bx。

2. loop

循环指令。指令格式为:loop 标号
该指令分两步执行。

第一步,计算 cx = cx -1
第二步,判断cx中的值,不为零则跳转至标号出执行程序,如果为零则向下执行。

3. 我们定义的描述性的符号:“()”

“()”表示一个内存单元或寄存器的内容。也即是存储器中存储的值。

“()”中的元素可以有3中类型:寄存器名、段寄存器名、内存单元的物理地址(一个20位数据)。

4. 约定符号idata表示常量

在以后的学习中我们约定idata表示一个常量。

5.1 [BX]

[bx]表示一个内存单元。

mov ax, [bx] 代码的含义:将ds:bx所指向内存单元的内容放入ax寄存器中。即:(ax)=((ds*16)+(bx))

mov [bx], ax 代码的含义:将ax中的内容放入ds:bx所指向的内存单元中。即:((ds*16)+(bx))=(ax)

5.2 Loop指令

首先loop指令的格式是:loop 标号。该指令分两步执行:

第一步, 计算cx = cx - 1;
第二步,判断cx中的值,不为零则跳转至标号处执行程序,如果为零则向下执行。

一般使用loop指令实现循环功能。格式如下:

1
2
3
    mov cx, n
s: add ax, ax
loop s

以上代码会循环执行n次。(n >= 0)

5.3 在Debug中跟踪用loop指令实现的循环程序

使用Debug调试程序时,有几条经常用到的指令。

T指令,单步执行指令。

g指令, 跳至断点,从当前IP执行至指定IP处。”g 0012”表示程序由当前位置执行至DS:0012处。

p指令,循环执行指令,p指令用于执行完当前次数。

5.4 Debug和汇编编译器masm对指令的不同处理

Debug和汇编编译器masm对形如“mov ax, [0]”这类指令的处理是不同的。debug将”[0]”看做是一个内存单元,该内存单元的地址是 ds*6 + 0。而编译器直接将“[0]”看做立即数0。因此有如下约定。

(1)在汇编源程序中,如果指令访问一个内存单元,则在指令中必须用”[…]”来表示内存单元,如果在“[…]”里用一个常量idata直接给出内存单元的偏移地址,就要在“[]”的前面显式的给出段地址所在的段寄存器。

(2)如果在“[]”里面用寄存器,比如bx,间接给出内存单元的偏移地址,则段地址默认在ds中。当然,也可以显式的给出段地址所在的段寄存器。

以上两点概括来说就是,如果内存单元的偏移地址使用立即数给出,则必须显式指明其段地址所在的段寄存器。

5.5 loop和[bx]的联合应用

通过loop和[bx]联合应用实现对连续内存单元的操作实例:

1
2
3
4
5
6
7
8
    ...
mov bx, 0
mov cx, 50

s: mov ax, [bx]
inc bx
loop s
...

以上代码通过循环实现了对内存单元DS:0000H~DS:0032H内容的操作。

5.6段前缀

如果内存单元的偏移地址由bx给出,如“mov ax, [bx]”,则段地址默认位于ds中。我们也可以在访问内存单元的指令中显式的给出内存单元段地址所在的段寄存器。比如:

(1)mov ax, ds:[bx]
(2)mov ax, cs:[bx]
(3)mov ax, ss:[bx]
(4)mov ax, cs:[bx]
(5)mov ax, ss:[0]
(6)mov ax, cs:[0]

这些出现在访问内存单元的指令中,用于显式的指明内存单元的段地址的“ds:”、“cs:”、“ss:”、“es:”,在汇编语言中称为段前缀

5.7 一段安全的空间

(1)我们需要直接向一段内存汇总写入内容;

(2)这段内存空间不应当存放系统或其他程序的数据或代码,否则写入操作很可能引发错误;

(3)DOS方式下,一般情况,0:200~0:2ff空间中没有系统或其他程序的数据或代码;

(4)以后,我们需要直接向一段内存中写入内容时,就使用0:200~0:2ff这段空间。

5.8 段前缀的使用

当需要操作的内存空间跨段时,显式的使用段前缀给出内存单元的段地址,可以避免在循环中对ds的重复设置。

也即是说一个内存单元的段地址不仅仅可以由ds给出,也可以通过cs、ss、es给出。


第六章 包含多个段的程序

程序取得所需空间的方法有两种,一是在加载程序的时候为程序分配,再就是程序在执行的过程向系统分配。在本课程中,我们只讨论第一种方法。

6.1 在代码段中使用数据

下面一段代码用于计算8个数据的累加和,结果放在ax寄存器中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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

分析这段代码,我们使用dw定义了8个字型数据,并且使用 “end 标号”的形式指明了程序的入口。

6.2 在代码段中使用栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
assume cs:codesg

codesg segment

dw 0123h,0456h,0789h,0abch,0defh,0fedh,0cbah,0987h
dw 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0

start: mov ax, cs
mov ss, ax
mov sp, 30h

mov bx, 0
mov cx, 8
s: push cs:[bx]
add bx, 2
loop s

mov bx, 0
mov cx, 8
s0: pop cs:[bx]
add bx, 2
loop s0

mov ax, 4c00h
int 21h

codesg ends
end start

6.3 将数据、代码、栈放入不同的段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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, 8
s0: pop [bx]
add bx, 2
loop s0

mov ax, 4c00h
int 21h

code ends

end start

下面对以上代码进行说明。

(1)定义多个段的方法

定义数据段、栈段与定义代码段的方法没有区别,只是对于不同的段,要有不同的段名。

(2)对段地址的引用

在程序中,段名就相当于一个标号,它代表了段地址。例如程序中“data”段中的数据“0abch”的地址就是:data:6。要将它送入bx中,代码如下:

1
2
3
mov ax, data
mov ds, ax
mov bx, ds:[6]

(3)“代码段”、“数据段”、“栈段”完全是我们的安排

我们通过“end 标号”的形式来声明程序的入口地址,这个入口信息被写入可执行文件中的描述信息中。可执行文件中的程序被加载入内存后,CPU的CS:IP就会被设置指向这个入口。

我们通过如下代码来指定程序的栈段:

1
2
3
mov ax, stack
mov ss, ax
mov sp, 20h

通过如下代码来指定数据段:

1
2
mov ax, data
mov ds, ax

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


第七章 更灵活的定位内存地址的方法

本章主要讲不同的寻址方式。

7.1 and和or指令

and表示逻辑与。or表示逻辑或。

7.2 关于ASCII码

ASCII码:American Standard Code for Information Interchange,美国信息交换标准代码。用8位(一个字节)二进制数表示一个字符。起初定义了128个字符,后来扩展至256个。

当我们再键盘上按下字母a键,屏幕上显示a字母,这其中经历了哪些过程?

  1. a被ASCII编码为数字61H存储在指定内存空间内。
  2. 文本编辑器软件从内存中取出61H,将其送入显卡显存中。
  3. 显卡根据ASCII编码将61H反译为字母a,同时显卡驱动显示器,将字母a的图像画在屏幕上。

通过以上3步,我们就看到了字母a被显示在屏幕上。

7.3 以字符形式给出的数据

在汇编程序中,使用引号‘’括起来的内容被识别为字符,编译器将把它转换为对应的ASCII码。

7.4 大小写转换的问题

在ASCII码中,小写字母的对应范围为:61H - 7AH。大写字母的对应范围为:41H - 5AH。可见同一个字母的大写形式的ASCII码比小写形式的ASCII码小20H。

仔细观察大小写字母所对应的ASCII吗二进制形式,可以发现如下规律:大写字母从右数第6位(从1开始计算)全为0,小写字母从右数第6位全为1。

综上我们可以总结出大小写转换的两种方式:

字母大小写转换方式1:

  1. 大写字母加上20h可转换为小写字母。
  2. 小写字母减去20h可转换为大写字母。

字母大小写转换方式2:

  1. 字母转大写:逻辑与11011111B。
  2. 字母转小写:逻辑或00100000B。

7.5 [bx+idata]

这是一种”变量+常量”的寻址方式。

[bx+idata]表示一个内存单元,它的偏移地址为(bx)+idata。
指令:

1
mov ax, [bx+200]

表示将一个内存单元的内容送入ax,这个内存单元的长度为2个字节(字单元),存放一个字,偏移地址为bx中的数值加上200,段地址在ds中。

该指令的常用格式有:

1
2
3
mov ax, [bx+200]
mov ax, 200[bx]
mov ax, [bx].200

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

我们可以将地址连续的多个数据当做数组处理。例如定义如下数据:

1
2
3
4
datasg segment
db 'BaSiC'
db 'MinIX'
datasg ends

我们可以把如上两个字符串当做两个数组,一个数组下标从0开始,一个数组下标从5开始。在程序中使用[bx+0]和[bx+5]的方式定位两个字符串的首地址。从而可以在一个循环当中同时处理两组数据。

回忆我们在高级语言中用到的数组取值方式(如c或java):a[index]。可以看出这就是汇编语言中[bx+idata]形式的变种。a与idata相对应,是一常量,表示了数组的首地址。而下标index与bx对应,是一变量,表示数组下标。

7.7 SI和DI

SI是Source Index的缩写。DI是Destination Index的缩写。它俩的功能与bx相近,但SI和DI不能够分成两个8位寄存器来使用。下面三组指令实现了相同的功能:

1
2
3
4
5
6
7
8
9
10
11
;(1)
mov bx, 0
mov ax, [bx]

;(2)
mov si, 0
mov ax, [si]

;(3)
mov di, 0
mov ax, [di]

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

1
2
3
4
5
6
7
8
9
10
11
;(1)
mov bx, 0
mov ax, [bx+123]

;(2)
mov si, 0
mov ax, [si+123]

;(3)
mov di, 0
mov ax, [di+123]

7.8 [bx+si]和[bx+di]

这是一种“变量+变量”的寻址方式。

[bx+si]和[bx+di]含义相似,都是表示一个内存单元。该内存单元的段地址位于ds中,偏移地址为bx的值加上si的值(或bx的值加上di的值)。

该指令的常用格式有:

1
2
mov ax, [bx+si]
mov ax, [bx][si]

7.9 [bx+si+idata]和[bx+di+idata]

这是一种”变量+变量+常量”的寻址方式。

常用指令格式:

1
2
3
4
5
mov ax, [bx+200+si]
mov ax, [200+bx+si]
mov ax, 200[bx][si]
mov ax, [bx].200[si]
mov ax, [bx][si].200

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

总结一下前面讲到的几种定位内存地址的方法(寻址方式):

(1)[idata]用一个常量来表示地址,可用于直接定位一个内存单元;
(2)[bx]用一个变量来表示内存地址,可用于间接定位一个内存单元;
(3)[bx+idata]用一个变量和常量表示地址,可在一个起始地址的基础上用变量间接定位一个内存单元;
(4)[bx+si]用两个变量表示地址;
(5)[bx+si+idata]用两个变量和一个常量表示地址。

下一章中,我们将对寻址方式的问题进行更深入的探讨,之所以如此重视这个问题,是因为寻址方式的适当应用,使我们可以以更合理的结构来看待所要处理的数据。而为所要处理的看似杂乱的数据设计一种清晰的数据结构是程序设计的一个关键问题(个人认为这段话说的很有道理,特记录于此)


第八章 数据处理的两个基本问题

本章旨在进一步加强对不同寻址方式的理解及运用。

计算机是进行数据处理、运算的机器,这其中包含两个基本的问题:

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

携带着这两个问题,我们开启第八章的学习之路。

8.1 bx、si、di和bp

首先看下这四个寄存器的含义:

bx, Base,Pointer to base addresss (data)。一般用于存储数据段的基址(首地址)。
si,Source Index,Source string/index pointer。一般用于存储源数组数据索引(下标)。
di,Destination Index,estination string/index pointer。一般用于存储目标数组数据索引(下标)。
bp,Base Pointer,Pointer to base address (stack)。一般用于存储栈的基址。

然后在使用过程中有几处需要注意的地方:

(1)在8086CPU中,只有这4个寄存器可以用在“[….]”中来进行内存单元的寻址。其他寄存器是不可以的,例如“mov bx, [ax]”就是错误的用法。

(2)在[…]中,这四个寄存器可以单个出现,或只能以4种组合出现:bx和si、bx和di、bp和si、bp和di。为了方便记忆可以将si和di看做一组,将bx和bp看做一组。组间可以自由组合,组内不能组合。(脑补为人类不可以近亲繁殖。)

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

8.2 机器指令处理的数据在什么地方

这是我们在开头抛出的两个问题中的第一个。

在指令执行前,所要处理的数据可以在3个地方:CPU内部、内存、端口(端口暂时不用知道是什么东西)。

我们知道了存储数据的部件,但如果具体找到这些部件存储的数据位置?下一节将解答我们的疑问。

8.3 汇编语言中数据位置的表达

在汇编语言中如何表达数据的位置?

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

(1)立即数

对于直接包含在机器指令中的数据(执行前在CPU的指令缓冲器中),汇编语言中称为立即数(idata),在汇编指令中直接给出。
例如:

1
2
mov ax, 1
or bx, 00100000B

(2)寄存器

指令要处理的数据在寄存器中,在汇编指令中给出相应的寄存器名。例如:

1
2
mov ax, bx
push bx

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

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

存放段地址的寄存器可以是默认的,也可以显性给出。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
;段寄存器默认存储在DS中
mov ax, [0]
mov ax, [bx]
mov ax, [di]
...

;段寄存器默认存储在SS中
mov ax, [bp]
mov ax, [bp+si]
mov ax, [bp+di]
...

;段寄存器显性给出
mov ax, ds:[bp]
mov ax, es:[3]
mov ax, ss:[bx+si]
mov ax, cs:[bx+si+8]

8.4 寻址方式

这一节我们总结一下所学到过的寻址方式。列表如下:

|寻址方式| 含义 | 名称 | 常用格式举例|备注|
|——–|—-|—|—|
|[idata]|EA=idata; SA=(ds)| 直接寻址|[idata]|偏移地址=立即数|
|[bx] |EA=(bx); SA=(ds)| 寄存器间接寻址|[bx]|偏移地址=变量|
|[bx+idata]| EA=(bx)+idata; SA=(ds)|寄存器相对寻址| 用于结构体:[bx].idata; 用于数组:idata[si],idata[di]; 用于二维数组:[bx][idata] |偏移地址=变量+立即数|
|[bx+si]|EA=(bx)+(si); SA=(ds)|基址变址寻址|用于二维数组:[bx][si]|偏移地址=变量+变量|
|[bx+si+idata]|EA=(bx)+(si)+idata; SA=(ds)|相对基址变址寻址| 用于表格(结构)中的数组项:[bx].idata[si]; 用于二维数组idata[bx][si]|偏移地址=变量+变量+立即数|

注意在8.1节指出的特殊情况,只要在[…]中使用寄存器bp,而指令中没有显性的给出段地址,段地址就默认在ss中。

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

这是我们在开头抛出的两个问题中的第二个。

8086CPU的指令,可以处理两种尺寸的数据,byte和word。所以在机器指令中要指明,指令进行的是字操作还是字节操作。对于这个问题,汇编语言中用以下方法处理。

(1)通过寄存器名指定要处理的数据的尺寸。如果寄存器名是字型寄存器(如ax、bx等),则说明指令进行的是字操作。如果寄存器名是字节型寄存器(如al、ah、bl等),则说明指令进行的是字节操作。

(2)在没有寄存器名存在的情况下,用操作符X ptr指明内存单元的长度,X在汇编指令中可以为word或byte。这种情形适用于没有寄存器参与的内存单元访问指令中。例如:

1
2
3
4
5
6
7
;下面的指令,用word ptr指明了指令中访问的内存单元是一个字单元
mov word ptr ds:[0], 1
inc word ptr [bx]

;下面的指令,用byte ptr指明了指令访问的内存单元是一个字节单元
mov byte ptr ds:[0], 1
inc byte ptr [bx]

(3)其他方法。有些指令默认了访问的是字单元还是字节单元,比如,push[1000H]就不用指明访问的是字单元还是字节单元,因为push指令只会进行字操作。

8.6 寻址方式的综合应用

8086CPU提供的如[bx+si+idata]的寻址方式为结构化数据的处理提供了方便。使得我们可以在编程的时候,从结构化的角度去看待所要处理的数据。正常情况下,一个结构化的数据包含了多个数据项,而数据项的类型又不相同,有的是字型数据,有的是字节型数据,有的是数组(字符串)。一般来说,我们可以用[bx+idata+si]的方式来访问结构体中的数据。用bx定位整个结构体,用idata定位结构体中的某一个数据项,用si定位数组项中的元素。为此,汇编语言提供了更为贴切的书写方式,如[bx].idata、[bx].idata[si]。

8.7 div指令

div是除法指令。在使用的过程中应注意以下问题:

(1)除数:有8位和16位两种,在一个reg(寄存器)或内存单元中。

(2)被除数:默认放在AX或DX和AX中,如果除数为8位,被除数则为16位,默认在AX中存放;如果除数为16位,被除数则为32位,在DX和AX中存放,DX存放高16位,AX存放低16位。

(3)结果:如果除数为8位,则AL存储除法操作的商,AH存储除法操作的余数;如果除数为16位,则AX存储除法操作的商,DX存储除法操作的余数。

div使用格式如下:

div reg
div 内存单元

8.8 伪指令dd

dd用来定义dword(双字)型数据。

8.9 dup

dup(duplication的缩写)用来重复开辟内存空间。

dup指令要和db、dw、dd等数据定义伪指令配合使用,使用格式如下:

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

例如,如下代码表示定义了9个字节:

1
db 3 dup (0,1,2)

第九章 转移指令的原理

本章主要讲如何控制CPU执行指令的顺序。

可以修改IP,或同时修改CS和IP的指令统称为转移指令。概括的降,转移指令就是可以控制CPU执行内存中某处代码的指令。

8086CPU的转移指令有以下几类。

  • 只修改IP时,称为段内转移,比如:jum ax。
  • 同时修改CS和IP时,称为段间转移,比如:jmp 1000:0。

由于转移指令对IP的修改范围不同,段内转移又分为:短转移和近转移。

  • 短转移IP的修改范围为-128~127。
  • 近转移IP的修改范围为-32768~32767。

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

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

这些转移指令的前提条件可能不同,但转移的基本原理是相同的。

9.1 操作符 offset

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

9.2 jmp 指令

jmp为无条件转移指令,可以只修改IP,也可以同时修改CS和IP。

jmp指令要给出两种信息:

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

下面几节将对jmp指令进行详细的介绍。

9.3 依据位移进行转移的jmp指令

jmp short 标号

实现的是段内短转移,执行后: (IP) = (IP)+ 8位位移。

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

jmp near ptr 标号

实现的是段内近转移,执行后:(IP) = (IP) + 16位位移。

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

9.4 转移的目的地址在指令中的jmp指令

jmp far ptr 标号

实现的是段间转移,又称为远转移。功能如下:
(CS)=标号所在段的段地址;(IP)=标号所在段中的偏移地址。

far ptr指明了指令用标号的段地址和偏移地址修改CS和IP。

该指令与上节学习的段内转移明显不同的是:
段内转移机器指令携带的是位移,段间转移机器指令携带的是目的地址。

9.5 转移地址在寄存器中的jmp指令

jmp 16位的reg

该指令实现的功能为:(IP)= (16位的reg)

9.6 转移地址在内存中的jmp指令

jmp word ptr 内存单元地址(段内转移)

功能:从内存单元地址处开始存放一个字,是转移的目的偏移地址。

内存单元地址可以用之前学过的任一寻址方式给出。

jmp dword ptr 内存单元地址(段间转移)

功能:从内存单元地址处开始存放着两个字,高地址处的字是转移的目的段地址,低地址处是转移的目的的偏移地址。

9.7 jcxz 指令

jcxz 标号

功能:如果(cx)=0,则转移到标号处执行。如果(cx)≠ 0,则程序继续向下执行。

jcxz指令为有条件转递指令,所有的有条件转移指令都是短转移,在对应的机器码中包含转移的位移,而不是目的地址。对IP的修改范围都为:-128~127。

9.8 loop指令

loop 标号

功能:(cx)=(cx)-1,如果(cx)≠ 0,则转移到标号处执行。

9.9 根据位移进行转移的意义

方便了程序段在内存中的浮动装配。

9.10 编译器对转移位移超界的检测

根据位移进行转移的指令,它们的转移范围受到转移位移的限制,如果在源程序中出现了转移范围超界的问题,在编译的时候,编译器将报错。


第十章 CALL和RET指令

call和ret都是转移指令,它们都修改IP,或同时修改CS和IP。它们经常被共同用来实现子程序的设计。

10.1 ret 和 retf

这个两个指令可以理解为高级语言中的return关键字,表示程序返回。

ret 用栈中的数据,修改IP的内容,从而实现近转移;
retf指令用栈中的数据,修改CS和IP的内容,从而实现远转移。

CPU执行ret指令时,进行下面两步操作:

(1)(IP) = ((SS)*16+(SP))
(2)(sp) = (sp)+2

以上步骤相当于进行:

pop IP

CPU执行retf指令时,进行下面4步操作:

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

以上步骤相当于进行:

pop IP
pop CS

10.2 call 指令

call指令可以理解为高级语言中的方法(函数)调用功能。

CPU指令call指令时,进行两步操作:

(1)将当前的IP或CS和IP压入栈中。 (保存现场)
(2)转移。

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

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

指令格式:

call 标号

CPU执行该指令时相当于进行:

push IP
jmp near ptr 标号

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

指令格式:

call far ptr 标号

CPU执行该指令时相当于进行:

push CS
push IP
jmp far ptr 标号

该指令编译的机器指令中包含了转移的目的地址。包括段地址CS的值及偏移地址IP的值。

10.5 转移地址在寄存器中的call指令

指令格式:

call 16位reg

CPU执行该指令时相当于进行:

push IP
jmp 16位reg

10.6 转移地址在内存中的call指令

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

(1)第一种指令格式:

call word ptr 内存单元地址

CPU执行该指令时相当于进行:

push IP
jmp word ptr 内存单元地址

(2)第二种指令格式:

call dword ptr 内存单元地址

CPU执行该指令时相当于进行:

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

10.7 call和ret的配合使用

call和ret的配合使用可以用来实现子程序的机制。call指令在转去执行子程序之前,会将当前指令下一条指令的位置保持在栈中,当子程序执行ret或retf指令后,会用栈中的数据设置ip或cs和ip的值,从而转到call指令后面的代码处继续执行。

10.8 mul指令

(1)两个相乘的数:练歌相乘的数,要么都是8位,要么都是16位。如果是8位,一个默认放在AL中,另一个放在8位reg或内存字节单元中;如果是16位,一个默认放在AX中,另一个放在16位reg或内存字单元中。

(2)结果:如果是8位乘法,结果默认放在AX中;如果是16位乘法,结果高位默认在DX中存放,低位在AX中存放。

10.9 模块化程序设计

现实问题比较复杂,对现实问题进行分析时,把它转化为相互联系、不同层次的子问题,是必须的解决方法。

在高级语言中的函数或者方法就是这种思想的体现。在汇编语言中我们将高级语言中的方法或函数称之为子程序。

10.10 参数和结果传递的问题

当我们设计子程序时面临两个问题:

(1)参数存放的位置?
(2)计算结果存放的位置?

实际上,我们可以将参数及结果存放于任何可以存储数据的地方。一般情况下,我们可以将参数存储在寄存器中,也可以存储在普通内存单元中。更一般的做法我们将其存储在栈中进行传递。

10.11 寄存器冲突的问题

寄存器数量是有限的,子程序中使用的寄存器,很可能在主程序中也要使用,造成了寄存器使用上的冲突。解决这个问题的简捷方法是,在子程序的开始将子程序中所有用到的寄存器中的内容都保存起来,在子程序返回前再恢复。 可以用栈来保存寄存器中的内容。

栈是临时保存数据的一个比较理想的数据结构。


第十一章 标志寄存器

标志寄存器(Flag Register)是我们8086CPU14个寄存器中最为复杂的一个。其他13个寄存器一般用于存放数据,整个寄存器具有一个含义。而flag寄存器是按位起作用的。

这一章中我们主要学习CF、PF、ZF、SF、OF、DF等标记位,以及其相关部分指令。

11.1 ZF标志

Zero Flag,零标记位。用于记录相关指令执行后,其结果是否为0。如果结果为0,则ZF=1,如果结果非0,则ZF=0。

需要特别注意的是:

在8086的指令集中,有的指令的执行是影响标志寄存器的,比如,add、sub、mul、div、inc、or、and等,它们大都是运算指令(进行逻辑或算术运算);有的指令的执行对标志寄存器没有影响,比如mov、push、pop等,它们大都是传送指令。

11.2 PF标志

Parity Flag,奇偶标记位。它用于记录相关指令执行后,其结果的所有bit位中1的个数是否为偶数。如果1的个数为偶数,则pf=1,如果为奇数,则pf=0。

11.3 SF标志

Sign Flag,符号标记位。它用于记录相关指令执行后,其结果是否为负。如果结果为负,则SF=1,如果结果非负,则SF=0。

计算机中通常用补码来表示有符号数,补码在形式上与普通的无符号二进制数据并无差异。也即是说,给定的一个二进制数,我们既可以把它当做有符号数的补码形式,也可以当做一个无符号数。对于计算机来说,无论是无符号数还是有符号数的补码形式,在计算方式上并无差异(补码的符号位同样参与运算)。

SF标志,就是CPU对有符号数运算结果的一种记录,它记录数据的正负。在我们将数据当做有符号数来运算的时候,可以通过它来得知结果的正负。如果我们将数据当做无符号数来运算,SF的值则没有意义,虽然相关指令影响了它的值。

11.4 CF标志

Carry Flag,进位标志位。一般情况下,在进行无符号数运算的时候,它记录了运算结果的最高有效位向更高位的进位值,或从更高位的借位值。

11.5 OF标志

Overflow Flag,溢出标志位。在进行有符号数运算的时候,如果计算结果超出了机器所能表示的范围则发生溢出,此时OF=1。否则,OF=0。

注意区分CF和OF的区别:CF是对无符号数运算有意义的标志位,OF是对有符号数运算有意义的标志位。

11.6 adc指令

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

指令格式:

adc 操作对象1,操作对象2

功能:操作对象1=操作对象1+操作对象2+CF

比如指令 adc ax,bx 实现的功能是:(ax)= (ax)+ (bx)+ CF

既然我们已经有了add指令,那为什么还要设计adc指令呢?

设想一下,之前我们使用add指令做加法运算的时候,相加结果都是16位以内,如果和大于16位就会产生误差。adc指令目的就是对任意大的数据进行加法运算。自习观察加法运算可以得到如下规律:

任意大的加法运算都可以分解为多步进行,低位相加,高位相加再加上低位相加产生的进位值,直至所有位都相加完毕。

使用adc指令结合上述规律就可以实现对任意大的数据进行加法运算。

11.7 sbb指令

sbb是带借位减法指令,它利用了CF位上记录的错位值。

指令格式:

sbb 操作对象1,操作对象2

功能:操作对象1=操作对象1-操作对象2-CF。

sbb指令和adc指令是基于同样的思想设计的两条指令,在应用思路上和adc指令类似。

11.8 cmp指令

cmp是比较指令,cmp的功能相当于减法指令,只是不保存结果。cmp指令执行后,将对标志寄存器产生影响。

指令格式:

cmp 操作对象1,操作对象2

功能:计算操作对象 1 - 操作对象 2 但并不保存结果,仅仅根据计算结果对标志寄存器进行设置。

利用 cmp ax, bx 指令对两个无符号数ax和bx进行比较,如果执行后:

zf = 1,说明 (ax) = (bx)
zf = 0,说明 (ax) ≠ (bx)
cf = 1,说明 (ax) < (bx)
cf = 0,说明 (ax) ≥ (bx)
cf = 0 并且 zf = 0,说明 (ax) > (bx)
cf = 1 或 zf = 1,说明 (ax) ≤ (bx)

利用 cmp ah,bh 指令对两个有符号数ah和bh进行比较,由于有符号数的比较较为复杂,主要是考虑到溢出的特殊情景,我们分类讨论:

(1) 如果 sf = 1 并且 of = 0

of = 0 说明没有溢出,并且 sf = 1 说明逻辑上真正的结果为负数。所以 (ah) < (bh)。

(2) 如果 sf = 1 并且 of = 1

of = 1 说明存在溢出,针对补码求和来说,如果结果非0并且产生溢出,正确的逻辑结果符号与实际的结果符号必然相反。 sf = 1 说明实际结果为负,那么正确的逻辑结果应该为正。所以 (ah) > (bh)。

(3) 如果 sf = 0 并且 of = 1

of = 1 说明存在溢出,针对补码求和来说,如果结果非0并且产生溢出,正确的逻辑结果符号与实际的结果符号必然相反。 sf = 0说明实际运算结果必然不小于0,因为存在溢出所以实际运算结果必不等于0,所以实际运算结果必然大于0,进而推导出正确的逻辑运算结果必然小于0。所以 (ah) < (bh)。

(4) 如果 sf = 0 并且 of = 0

of = 0 说明没有溢出,并且 sf = 0,说明逻辑上真正的结果为非负数。所以 (ah) ≥ (bh)。

(5) 如果 zf = 1

这种情形比较简单。此时 (ah) = (bh)。

11.9 检测比较结果的条件转移指令

“转移”指的是它能够修改IP,而“条件”指的是它可以根据某种条件,决定是否修改IP。比如,jcxz就是一个条件转移指令,它可以检测cx中的数值,如果 (cx) = 0,就修改IP,否则什么也不做。所有条件转移指令的位移都是 [-128, 127](即它们都是短转移)。

jcxz是根据寄存器cx的值来判断是否转移,除此之外还存在其他条件转移指令,大多数条件转移指令都检测标志寄存器相关标志位,根据检测的结果来决定是否修改IP。

下表列出了常用的根据无符号数的比较结果进行转移的条件转移指令:

指令 含义 检测的相关标志位 备注
je 等于则转移 zf = 1 e 表示 equal
jne 不等于则转移 zf = 0 ne 表示 not eauql
jb 低于则转移 cf = 1 b 表示 below
jnb 不低于则转移 cf = 0 nb 表示 not blow
ja 高于则转移 cf = 0 且 zf = 0 a 表示 above
jna 不高于则转移 cf = 1 或 zf = 1 na 表示 not above

注意,条件转移指令通常与cmp指令配合使用。

11.10 DF标志和串传送指令

Direction Flag,方向标志位。在串传送指令中,控制每次操作后 si、di 的增减。

df = 0 ,每次操作后 si、di 递增;
df = 1 ,每次操作后 si、di 递减。

下面,我们学习几个常见的串传送指令。(写到这里,突然想吃羊肉串了~~)

movsb指令

格式:

movsb

功能:将ds:si指向的内存单元中的字节送入es:di中,并根据标志寄存器df的值,将si和di递增或递减。
movsw指令

与 movsb 指令类似,只不过 movsw指令传送的是一个字单元。

rep 指令

本人将其翻译为重复指令(repetition)。movsb 和 movsw 进行的是串传送操作中的一个步骤,一般来说,movsb 和 movsw 都配合 rep 配合使用,格式如下:

rep movsb

功能:根据cx的值来决定是否重复执行 movsb操作。使用汇编语法来描述就是>

s: movsb
loop s

cld 指令和 std 指令

cld 指令:将标志寄存器的 df 位置0;
std 指令:将标志寄存器的 df 位置1。

为了方便记忆,可以将 cld 理解为 clear direction 的缩写,将 std 理解为 set direction 的缩写。

11.11 pushf 和 popf

pushf 的功能是将标志寄存器的值压栈,而 popf 是从栈中弹出数据,送入标志寄存器中。

pushf 和 popf 为直接访问标志寄存器提供了一种方法。

11.12 标志寄存器在Debug中的表示

在Debug中,我们使用r命令查看寄存器详情,第二行最后几个双字符字母即是标志寄存器中各标志位的值。


第十二章 内中断

什么是中断?如果你学习过高级编程语言,可以将中断理解为异常的特殊处理过程,就像Java里面的Exception。

任何一个通用的CPU,都具备一种能力,可以在执行完当前正在执行的指令之后,检测从CPU外部发送过来的或内部产生的一种特殊信息,并且可以立即对所收到的信息进行处理。这种特殊信息,我们可以称其为:中断信息。中断的意思是指,CPU不在接着(刚执行完的指令)向下执行,而是转去处理这个特殊信息。

中断信息可以来自CPU内部和外部,这一章,我们主要讨论来自CPU内部的中断信息,我们称之为内中断。

12.1 内中断的产生

8086CPU使用单元字节大小的数字来标识中断类型。

CPU内部可能产生多种多样的中断,那么应该如何来标识是哪种中断呢,或者说我们如何确定中断源?
8086CPU用称为中断类型码的数据来标识中断信息的来源。中断类型码为一个字节型数据,可以表示256种中断类型。以后,我们将产生中断信息的事件,即中断信息的来源,称之为中断源

12.2 中断处理程序

处理中断信息的程序被称为中断处理程序

12.3 中断向量表

中断发生后,CPU要根据中断类型码去执行对应的中断处理程序?但如何根据8位的中断类型码得到中断处理程序的地址呢?

实际上,8086CPU用8位的中断类型码通过中断向量表找到相应的中断处理程序的入口地址。中断向量表就是中断处理程序入口地址的列表,列表的下标索引(从0开始)即是中断类型码的值。中断向量表实际上是中断类型码与中断处理程序入口地址之间的一种映射关系。可以理解为高级编程语言中的Map集合。

8086CPU中断向量表指定放在内存0处。每个表项占用4个字节,高位字存放段地址,低位字存放偏移地址。

12.4 中断过程

用中断类型码找到中断向量,并用它设置CS和IP的值,这个工作是由CPU的硬件自动完成的。CPU硬件完成这个工作的过程被称为中断过程。中断过程完成后,CPU就会开始执行中断处理程序。中断过程可以理解为中断环境的初始化。那么在CPU进行中断过程中需要准备哪些工作呢?概括来说,主要进行以下六步准备工作:

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

12.5 中断处理程序和iret指令

中断处理程序必须一直存储在指定内存中,以应对随时可能发生的中断事件。

中断处理程序的编写方法和子程序比较相似,下面是常规步骤:

(1)保存用到的寄存器;
(2)处理中断;
(3)恢复用到的寄存器;
(4)用ret指令返回。

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

1
2
3
pop IP
pop CS
popf

在中断过程中,注意标志寄存器入栈和出栈的次序。入栈顺序是标志寄存器、CS、IP,出栈顺序与此相反。

12.6 除法错误中断的处理

除法错误将引发0号中断。至于为何是0号中断,我估摸着除法中断时人们最容易想到也最容易遇到的中断了吧。

12.7 编程处理0号中断

我们的需求是重新编写一个0号中断处理程序,它的功能是在屏幕中间显示“overflow!”,然后返回到操作系统。

为了满足以上需求,需要做一下几件事情:

(1)编写可以显示“overflow”的中断处理程序:do0;
(2)将do0送入内存0000:0200处;
(3)将do0的入口地址0000:0200存储在中断向量表0号表项中。

程序的框架如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
assume cs:code

code segment

start: do0安装程序
设置中断向量表
mov ax, 4c00h
int 21h

do0: 显示字符串"overflow"
mov ax, 4c00h
int 21h

code ends

end start

下面摘抄书中比较精辟的一段总结:

我们如何让一个内存单元成为栈顶?将它的地址放入SS、SP中;
我们如何让一个内存单元中的信息被CPU当做指令来执行?将它的地址放入CS、IP中;
我们如何让一个内存单元成为要处理的数据?将它的段地址放在DS中;(书中无这句话,个人根据理解补充)
那么,我们如何让一段程序成为 N 号中断的中断处理程序呢?将它的入口地址放入中断向量表的 N 好表项中。

12.8 安装

所谓安装就是将中断处理程序(do0)送到指定内存处。

我们可以使用movsb指令,将do0的代码送入0:200处。复习一下movsb的用法:movsb是串传送指令,其功能是将ds:si指向的内存单元中的字节送入es:di中,并根据标志寄存器df的值,将si和di递增或递减。movsb指令往往与rep指令配合使用来实现批量字符串的传送。

安装程序的框架如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
assume cs:code
code segment

start: 设置es:di指向目的地址
设置ds:si指向源地址
设置cx为传输长度
设置传输方向为正
rep movsb

设置中断向量表

mov ax, 4c00h
int 21h

do0: 显示字符串"overflow!"
mov ax, 4c00h
int 21h

code ends
end start

以上步骤的难点在于如何确认中断处理程序do0的长度?最笨的方法是计算do0中每句代码的长度,然后累加,但这样做太麻烦了,不仅要知道每行代码所占的字节数,代码稍有改动那就令人抓狂。书中作者给出一个非常简便的计算方式,利用编译器来帮助我们计算do0的长度。之前我们学过offset指令,他的功能是取得标号的偏移地址,我们在do0后面在添加一个标号do0end,使用 offset do0end - offset do0 即可计算出do0的长度。

解决了字符传送以及确认do0长度这两个拦路虎后,我们就可以看一下较为完整的安装程序代码了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
assume cs:code
code segment
start:
mov ax, cs
mov ds, ax
mov si, offset do0 ;设置ds:si指向源地址
mov ax, 0
mov es, ax
mov di, 0200h ;设置es:di指向目标地址

mov cx, offset do0end - offset do0 ;设置cx为传输长度
cld ;设置传输方向为正
rep movsb ;传输开始

设置中断向量表

mov ax, 4c00H
int 21H

do0: 显示字符串"overflow!"
mov ax, 4c00h
int 21h
code ends

end start

这里补充一点,像”+”、”-“、”*“、”/“、”offset”这类指令都是伪指令,并不是标准的汇编指令,它是由编译器识别并由编译器翻译为对应的汇编指令。

12.9 do0

do0程序即是我们的0号中断处理程序。其主要目的是显示字符串”overflow!”。

主要程序代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
do0:    jum short do0start
db 'overflow!'

do0start: mov ax, cs
mov ds, ax
mov si, 202h

mov ax, 0b800h
mov es, ax
mov di, 12*160 + 36*2

mov cx, 9
s:mov al, [si]
mov es:[di], al
inc si
add di, 2
loop s

mov ax, 4c00h
int 21h

do0end: nop

这部分代码需要注意的地方是,我们在子程序do0开始处定义了字符串”overflow!”,但它并不是可以执行的代码,所以在”overflow!”之前加上一条jmp指令,转移到正式的do0程序。

12.10 设置中断向量

设置中断向量,也即是将中断处理程序do0在内存中的入口地址存放在中断向量表0号表项中。0号表项的地址为0:0,其中0:0字单元存放中断处理程序入口地址的偏移地址,0:2字单元存放中断处理程序入口地址的段地址。程序如下:

1
2
3
4
mov ax, 0
mov es, ax
mov word ptr es:[0*4], 200h
mov word ptr es:[0*4+2], 0

12.11 单步中断

基本上,在CPU执行完一条指令之后,如果检测到标志寄存器的TF位为1,则产生单步中断,引发中断过程。单步中断的中断类型码为1。在一开始我们说CPU在执行中断处理程序之前要先将标志寄存器TF位置0,这就是为了防止CPU在执行1号类型中断(单步中断)时无限递归执行中断。

CPU提供单步中断功能的出发点是,为单步跟踪程序的执行过程,提供了实现机制。

12.12 响应中断的特殊情况

一般情况下,CPU在执行完当前指令后,如果检测到中断信息,就响应中断,引发中断过程。可是,在有些情况下,CPU执行完当前指令后,即便是发生中断,也不会响应。例如针对ss修改执行后,下一条指令(一般是修改sp)也会紧接着执行,中间即使发生中断,CPU也不会去响应。这样做的主要原因是,ss:sp联合指向栈顶,而对它们的设置应该连续完成。如果在执行完设置ss的指令后,CPU响应中断,引发中断过程,要在栈中压入标志寄存器、CS和IP的值,而ss改变,sp并未改变,ss:sp指向的不是正确的栈顶,将引起错误。

这种理念在高级编程语言中的具体体现是“原子操作”,即一组操作要么不执行,要么就一次执行完毕,不会存在中间状态。

  • 标题: 《汇编语言》第三版阅读笔记
  • 作者: Thou
  • 创建于 : 2022-10-11 03:05:13
  • 更新于 : 2024-12-19 15:27:27
  • 链接: https://blog.txgde.space/2022/10/11/《汇编语言》第三版阅读笔记/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论
目录
《汇编语言》第三版阅读笔记