ASM-x86汇编详解


汇编语言简介

学习汇编的常见问题

需要怎样的背景知识?

在学习本教程之前,至少使用过一种结构化高级语言进行编程,如 Java、C、Python 或 C++。需要了解如何使用 IF 语句、数组和函数来解决编程问题。

什么是汇编器和链接器?

汇编器(assembler)是一种工具程序,用于将汇编语言源程序转换为机器语言。链接器(linker)也是一种工具程序,它把汇编器生成的单个文件组合为一个可执行程序。还有一个相关的工具,称为调试器(debugger),使程序员可以在程序运行时,单步执行程序并检查寄存器和内存状态。

需要哪些硬件和软件?

一台运行 32 位或 64 位 Microsoft Windows 系统的计算机,并已安装了近期版本的 Microsoft Visual Studio。

MASM 能创建哪些类型的程序?

32 位保护模式(32-Bit Protected Mode):32 位保护模式程序运行于所有的 32 位和 64 位版本的 Microsoft Windows 系统。它们通常比实模式程序更容易编写和理解。从现在开始,将其简称为 32 位模式。

64 位模式(64-Bit Mode):64 位程序运行于所有的 64 位版本 Microsoft Windows 系统。

16 位实地址模式(16-Bit Real-Address Mode):16 位程序运行于 32 位版本 Windows 和嵌入式系统。 64 位 Windows 不支持这类程序。

汇编语言与机器语言有什么关系?

机器语言(machine language)是一种数字语言, 专门设计成能被计算机处理器(CPU)理解。所有 x86 处理器都理解共同的机器语言。

汇编语言(assembly language)包含用短助记符如 ADD、MOV、SUB 和 CALL 书写的语句。汇编语言与机器语言是一对一(one-to-one)的关系:每一条汇编语言指令对应一条机器语言指令。

C++ 和 Java 与汇编语言有什么关系?

高级语言如 Python、C++ 和 Java 与汇编语言和机器语言的关系是一对多(one-to-many)。比如,C++ 的一条语句就会扩展为多条汇编指令或机器指令。

大多数人无法阅读原始机器代码,因此,这里探讨的是与之最接近的汇编语言。例如,下面的 C++ 代码进行了两个算术操作,并将结果赋给一个变量。假设 X 和 Y 是 整数:

int Y;
int X = ( Y + 4 ) * 3;

与之等价的汇编语言程序如下所示。这种转换需要多条语句,因为每条汇编语句只对应一条机器指令:

mov eax,Y  ;Y 送入 EAX 寄存器
add eax,4  ;EAX 寄存器内容加 4
mov ebx,3  ;3 送入 EBX 寄存器
imul ebx   ;EAX 与 EBX 相乘
mov x,eax  ;EAX 的值送入 X

寄存器(register)是 CPU 中被命名的存储位置,用于保存操作的中间结果。这个例子的重点不是说明 C++ 与汇编语言哪个更好,而是展示它们的关系。

汇编语言可移植吗?

一种语言,如果它的源程序能够在各种各样的计算机系统中进行编译和运行,那么这种语言被称为是可移植的(portable)。

例如,一个 C++ 程序,除非需要特别引用某种操作系统的库函数,否则它就几乎可以在任何一台计算机上编译和运行。Java 语言的一大特点就是,其编译好的程序几乎能在所有计算机系统中运行。

汇编语言不是可移植的,因为它是为特定处理器系列设计的。目前广泛使用的有多种不同的汇编语言,每一种都基于一个处理器系列。

对于一些广为人知的处理器系列如 Motorola 68x00、x86、SUN Sparc、Vax 和 IBM-370,汇编语言指令会直接与该计算机体系结构相匹配,或者在执行时用一种被称为微代码解释器(microcode interpreter)的处理器内置程序来进行转换。

为什么要学习汇编语言?

如果对学习汇编语言还心存疑虑,考虑一下这些观点:

  • 如果是学习计算机工程,那么很可能会被要求写嵌入式(embedded)程序。嵌入式程序是指一些存放在专用设备中小容量存储器内的短程序,这些专用设备包括:电话、汽车燃油和点火系统、空调控制系统、安全系统、数据采集仪器、显卡、声卡、硬盘驱动器、调制解调器和打印机。由于汇编语言占用内存少,因此它是编写嵌入式程序的理想工具。

  • 处理仿真和硬件监控的实时应用程序要求精确定时和响应。高级语言不会让程序员对编译器生成的机器代码进行精确控制。汇编语言则允许程序员精确指定程序的可执行代码。

  • 电脑游戏要求软件在减少代码大小和加快执行速度方面进行高度优化。就针对一个目标系统编写能够充分利用其硬件特性的代码而言,游戏程序员都是专家。他们经常选择汇编语言作为工具,因为汇编语言允许直接访问计算机硬件,所以,为了提高速度可以对代码进行手工优化。

  • 汇编语言有助于形成对计算机硬件、操作系统和应用程序之间交互的全面理解。使用汇编语言,可以运用并检验从计算机体系结构和操作系统课程中获得的理论知识。

  • 一些高级语言对其数据表示进行了抽象,这使得它们在执行底层任务时显得有些不方便,如位控制。在这种情况下,程序员常常会调用使用汇编语言编写的子程序来完成他们的任务。

  • 硬件制造商为其销售的设备创建设备驱动程序。设备驱动程序(device driver)是一种程序,它把通用操作系统指令转换为对硬件细节的具体引用。比如,打印机制造商就为他们销售的每一种型号都创建了一种不同的 MS-Windows 设备驱动程序。通常,这些设备驱动程序包含了大量的汇编语言代码。

汇编语言有规则吗?

大多数汇编语言规则都是以目标处理器及其机器语言的物理局限性为基础的。比如,CPU 要求两个指令操作数的大小相同。与C++ 或 Java 相比,汇编语言的规则较少,因为,前者是用语法规则来减少意外的逻辑错误,而这是以限制底层数据访问为代价的。

汇编语言程序员可以很容易地绕过高级语言的限制性特征。例如,Java 就不允许访问特定的内存地址。程序员可以使用 JNI(Java Native Interface)类来调用 C 函数绕过这个限制,可结果程序不容易维护。

反之,汇编语言可以访问所有的内存地址。但这种自由的代价也很高:汇编语言程序员需要花费大量的时间进行调试。

汇编语言的应用(用途)

早期在编程时,大多数应用程序部分或全部用汇编语言编写。它们不得不适应小内存,并尽可能在慢速处理器上有效运行。随着内存容量越来越大,以及处理器速度急速提高,程序变得越来越复杂。

程序员也转向高级语言如 C语言、FORTRAN COBOL,这些语言具有很多结构化能力。最近,Python、C++、C# 和 Java 等面向对象语言已经能够编写含数百万行代码的复杂程序了。

很少能看到完全用汇编语言编写的大型应用程序,因为它们需要花费大量的时间进行编写和维护。不过,汇编语言可以用于优化应用程序的部分代码来提升速度,或用于访问计算机硬件。

下表比较了汇编语言和高级语言对各种应用类型的适应性。

应用类型 高级语言 汇编语言
商业或科学应用程序,为单一的中型或大型平台编写 规范结构使其易于组织和维护大量代码 最小规范结构,因此必须由具有不同程度经验的程序员来维护结构。这导致对已有代码的维护困难
硬件设备驱动程序 语言不一定提供对硬件的直接访问。 即使提供了,可能也需要难以控制的编码技术,这导致维护困难 对硬件的访问直接且简单。当程序较短且文档良好时易于维护
为多个平台(不同的操作系统)编写的商业或科学应用程序 通常可移植。在每个目标操作系统上, 源程序只做少量修改就能重新编译 需要为每个平台单独重新编写代码, 每个汇编器都使用不同的语法。维护困难
需要直接访问硬件的嵌入式系统和电脑游戏 可能生成很大的可执行文件,以至于超出设备的内存容量 理想,因为可执行代码小,运行速度快

C 和 C++ 语言具有一个独特的特性,能够在高级结构和底层细节之间进行平衡。直接访问硬件是可能的,但是完全不可移植。大多数 C 和 C++ 编译器都允许在其代码中嵌入汇编语句,以提供对硬件细节的访问。

虚拟机是什么?

虚拟机概念(virtual machine machine)是一种说明计算机硬件和软件关系的有效方法。

在安德鲁 · 塔嫩鲍姆(Andrew Tanenbaum)的书《结构化计算机组织》(Structured Computer Organization)中可以找到对这个模型广为人知的解释。要说明这个概念,先从计算机的最基本功能开始,即执行程序。

计算机通常可以执行用其原生机器语言编写的程序。这种语言中的每一条指令都简单到可以用相对少量的电子电路来执行。为了简便,称这种语言为 L0。

由于 L0 极其详细,并且只由数字组成,因此,程序员用其编写程序就非常困难。如果能够构造一种较易使用的新语言 L1,那么就可以用 L1 编写程序。有两种实现方法:

  • 解释(Interpretation):运行 L1 程序时,它的每一条指令都由一个用 L0 语言编写的程序进行译码和执行。L1 程序可以立即开始运行,但是在执行之前,必须对每条指令进行译码。

  • 翻译(Translation):由一个专门设计的 L0 程序将整个 L1 程序转换为 L0 程序。然后,得到的 L0 程序就可以直接在计算机硬件上执行。

虚拟机

与只使用语言描述相比,把每一层都想象成有一台假设的计算机或者虚拟机会更容易一些。通俗地说,虚拟机可以定义为一个软件程序,用来模拟一些其他的物理或虚拟计算机的功能。

虚拟机,将其称为 VM1,可以执行 L1 语言编写的指令。虚拟机 VM0 可以执行 L0 语言编写的指令:

每一个虚拟机既可以用硬件构成也可以用软件构成。程序员可以为虚拟机 VM1 编写程序,如果能把 VM1 当作真实计算机予以实现,那么,程序就能直接在这个硬件上执行。否则,用 VM1 写出的程序就被翻译 / 解释为 VM0 程序,并在机器 VM0 上执行。

机器 VM1 与 VM0 之间的差异不能太大,否则,翻译或解释花费的时间就会非常多。如果 VM1 语言对程序员来说还不够友好到足以用于应用程序的开发呢?

可以为此设计另一个更加易于理解的虚拟机 VM2。这个过程能够不断重复,直到虚拟机 VMn 足够支持功能强大、使用方便的语言。

Java 编程语言就是以虚拟机概念为基础的。Java 编译器把用 Java 语言编写的程序翻译为 Java 字节码(Java byte code)。

后者是一种低级语言,能够在运行时由 Java 虚拟机(JVM)程序快速执行。JVM 已经在许多不同的计算机系统上实现了,这使得 Java 程序相对而言独立于系统。

特定的机器

与实际机器和语言相对,用 Level 2 表示 VM2,Level 1 表示 VM1,如下图所示。计算机数字逻辑硬件表示为 Level 1 机器。其上是 Level 2,称为指令集架构(ISA, Instruction Set Architecture) 。通常,这是用户可以编程的第一个层次,尽管这种程序包含的是被称为机器语言的二进制数值。

指令集架构(Level 2)计算机芯片制造商在处理器内部设计一个指令集来实现基本操作,如传送、加法或乘法。这个指令集也被称为机器语言。每一个机器语言指令或者直接在机器硬件上执行,或者由嵌入到微处理器芯片的程序来执行,该程序被称为微程序。

汇编语言(Level 3)在 ISA 层,编程语言提供了一个翻译层,来实践大规模软件开发。汇编语言出现在 Level 3,使用短助记符,如 ADD、SUB 和 MOV,易于转换到 ISA 层。汇编语言程序在执行之前要全部翻译(汇编)为机器语言。

高级语言(Level 4)Level 4 是高级编程语言,如 C、C++ 和 Java。这些语言程序所包含的语句功能强大,并翻译为多条汇编语言指令。比如,查看 C++ 编译器生成的列表文件输出,就可以看到这样的翻译。汇编语言代码由编译器自动汇编为机器语言。

汇编语言的数据表示

汇编语言程序员处理的是物理级数据,因此他们必须善于检查内存和寄存器。通常,二进制数被用于描述计算机内存的内容;有时也使用十进制和十六进制数。所以必须熟练掌握数字格式,以便快速地进行数字的格式转换。

每一种数制格式或系统,都有一个基数(base),也就是可以分配给单一数字的最大符号数。下表给出了数制系统内可能的数字,这些系统是硬件和软件手册中最常使用的。

系统 基数 可能的数字
二进制 2 1
八进制 8 1234567
十进制 10 123456789
十六进制 16 0123456789ABCDEF

在表的最后一行,十六进制使用的是数字 0 到 9,然后字母 A 到 F 表示十进制数 10 到 15。在展示计算机内存的内容和机器级指令时,使用十六进制是相当常见的。

二进制(bit)整数

计算机以电子电荷集合的形式在内存中保存指令和数据。用数字来表示这些内容就需要系统能够适应开 / 关(on/off)或真 / 假(true/false)的概念。二进制数(binary number)用 2 个数字作基础,其中每一个二进制数字(称为位,bit)不是 0 就是 1。

位自右向左,从 0 开始顺序增量编号。左边的位称为最高有效位(Most Significant Bit, MSB)右边的位称为最低有效位(LSB, least significant bit)。一个 16 位的二进制数,其 MSB 和 LSB 如下图所示:

二进制整数可以是有符号的,也可以是无符号的。有符号整数又分为正数和负数,无符号整数默认为正数,零也被看作是正数。

在书写较大的二进制数时,有些人喜欢每 4 位或 8 位插入一个点号,以增加数字的易读性。比如,1101.1110.0011.1000.0000 和 11001010.10101100

无符号二进制整数

从 LSB 开始,无符号二进制整数中的每一个位代表的是 2 的加 1 次幂。下图展示的是对一个 8 位的二进制数来说,2 的幂是如何从右到左增加的:

下表列出了从 20 到 215 的十进制值。

无符号二进制整数到十进制数的转换

对于一个包含 n 个数字的无符号二进制整数来说,加权位记数法(weighted positional notation)提供了一种简便的方法来计算其十进制值:

D 表示一个二进制数字。比如,二进制数 00001001 就等于 9。计算该值时,剔除了数字等于 0 的位:

下图表示了同样的计算过程:

无符号十进制整数到二进制数的转换

将无符号十进制整数转换为二进制,方法是不断将这个整数除以 2,并将每个余数记录为一个二进制数字。下表展示的是十进制数 37 转换为二进制数的步骤。余数的数字,从第二行开始,分别表示的是二进制数字D0

D1、D2、D3、D4 和 D5:

将表中余数列的二进制位逆序连接(D5,D4,…),就得到了该整数的二进制值 100101。由于计算机总是按照 8 的倍数来组织二进制数字,因此在该二进制数的左边增加两个 0,形成 00100101。

提示:有多少位呢?设无符号十进制值为 n,其对应的二进制数的位数为 b,用一个简单的公式就可以计算出 b : b = (log2n) 的上限。比如,如果 n=17,则 log217 = 4.087 463,取其上限的最小整数 5。大多数计数器没有以 2 为底的对数运算,但是有些网页可以帮助实现这种计算。

二进制加法运算

两个二进制整数相加时,是位对位处理的,从最低的一对位(右边)开始,依序将每一对位进行加法运算。两个二进制数字相加,有四种结果,如下所示:

0 + 0 = 0 0 + 1 = 1
1 + 0 = 1 1 + 1 = 10

1 与 1 相加的结果是二进制的 10(等于十进制的 2)。多出来的数字向更高位产生一个进位。如下图所示,两个二进制数 0000 0100 和 0000 0111 相加:

从两个数的最低位(位 0)开始,计算 0+1,得到底行对应位上的 1。然后计算次低位(位 1)。在位 2 上,计算 1+1,结果是 0,并产生一个进位 1。然后计算位 3,0+0,还要加上位 2 的进位,结果是 1。

其余的位都是 0。上图右边是等价的十进制数值加法(4 + 7 = 11),可以用于验证左边的二进制加法。

有些情况下,最高有效位会产生进位。这时,预留存储区的大小就显得很重要。比如,如果计算 1111 1111 加 0000 0001,就会在最高有效位之外产生一个 1,而和数的低 8 位则为全 0。

如果和数的存储大小最少有 9 位,那么就可以将和数表示为 1 0000 0000。但是,如果和数只能保存 8 位,那么它就等于 0000 0000,也就是计算结果的低 8 位。

字节(byte)简介

在 x86 计算机中,所有数据存储的基本单位都是字节(byte),一个字节有 8 位。其他的存储单位还有字(word)(2 个字节),双字(doubleword)(4 个字节)和四字(quadword)(8 个字节)。

下图展示了每个存储单位所包含的位的个数:

下表列出了所有无符号整数可能的取值范围。

大的度量单位对内存和磁盘空间而言,还可以使用大的度量单位:

十六进制整数

大的二进制数读起来很麻烦,因此十六进制数字就提供了一种简便的方式来表示二进制数据。十六进制整数中的 1 个数字就表示了 4 位二进制位,两个十六进制数字就能表示一个字节。

一个十六进制数字表示的范围是十进制数 0 到 15,所以,用字母 A 到 F 来代表十进制数 10 到 15。

下表列出了每个 4 位二进制序列如何转换为十进制和十六进制数值。

二进制 十进制 十六进制 二进制 十进制 十六进制
0 0 0 1000 8 8
1 1 1 1001 9 9
10 2 2 1010 10 A
11 3 3 1011 11 B
100 4 4 1100 12 C
101 5 5 1101 13 D
110 6 6 1110 14 E
111 7 7 1111 15 F

下面的例子说明了二进制数 0001 0110 1010 0111 1001 0100 是如何与十六进制数 16A794 等价的。

无符号十六进制数到十进制的转换

十六进制数中,每一个数字位都代表了 16 的幂。这有助于计算一个十六进制整数的十进制值。假设用下标来对一个包含 4 个数字的十六进制数编号 D3D2D1D0。

下式计算了这个 整数的十进制值:

这个表达式可以推广到任意n位数的十六进制整数:

一般情况下,可以通过公式把基数为B的任何n位整数转换为十进制数:

下图演示了第二个数转换的计算过程:

下表列出了 16 的幂从160 到167 的十进制数值。

无符号十进制数到十六进制的转换

无符号十进制整数转换到十六进制数的过程是,把这个十进制数反复除以16,每次取余数作为一个十六进制数字。例如,下表列出了十进制数 422 转换为十六进制的步骤:

表中,余数列的数字按照最后一行到第一行的顺序,组合为十六进制的结果。因此本例中,十六进制结果就表示为1A6。同样的算法也适用于《二进制整数》一节中的二进制整数。如果要将十进制数转换为其他进制数,就在计算时把除数(16)换成相应的基数。

补码及进制转换

有符号二进制整数有正数和负数。在 x86 处理器中,MSB 表示的是符号位:0 表示正数,1 表示负数。下图展示了 8 位的正数和负数:

补码表示

负整数用补码(two`s-complement)表示时,使用的数学原理是:一个整数的补码是其加法逆元。(如果将一个数与其加法逆元相加,结果为 0。)

补码表示法对处理器设计者来说很有用,因为有了它就不需要用两套独立的电路来处理加法和减法。例如,如果表达式为 A-B,则处理器就可以很方便地将其转换为加法表达式:A+(-B)。

将一个二进制整数按位取反(求补)再加 1,就形成了它的补码。以 8 位二进制数 0000 0001 为例,求其补码为 1111 1111,过程如下所示:

初始值 1
第一步:按位取反 11111110
第二步:将上一步得到的结果加 1 11111110 +00000001
和值:补码表示 11111111

1111 1111 是 -1 的补码。补码操作是可逆的,因此,11111111 的补码就是 0000 0001。

十六进制数的补码

将一个十六进制整数按位取反并加 1,就生成了它的补码。一个简单的十六进制数字取反方法就是用 15 减去该数字。下面是一些十六进制数求补码的例子:

6A3D –> 95C2 + 1 –> 95C3

95C3 –> 6A3C + 1 –> 6A3D

有符号二进制数到十进制的转换

用下面的算法计算一个有符号二进制整数的十进制数值:

  • 如果最高位是 1,则该数是补码。再次对其求补,得到其正数值。然后把这个数值看作是一个无符号二进制整数,并求它的十进制数值。

  • 如果最高位是 0,就将其视为无符号二进制整数,并转换为十进制数。

例如,有符号二进制数 1111 0000 的最高有效位是 1,这意味着它是一个负数,首先要求它的补码,然后再将结果转换为十进制。过程如下所示:

初始值 11110000
第一步:按位取反 1111
第二步:将上一步得到的结果加 1 00001111 + 1
第三步:生成补码 10000
第四步:转换为十进制 16

由于初始值(1111 0000)是负数,因此其十进制数值为 -16。

有符号十进制数到二进制的转换

有符号十进制整数转换为二进制的步骤如下:

  • 把十进制整数的绝对值转换为二进制数。

  • 如果初始十进制数是负数,则在第 1 步的基础上,求该二进制数的补码。

比如,十进制数 -43 转换为二进制的过程为:

\1) 无符号数 43 的二进制表示为 0010 1011。

\2) 由于初始数值是负数,因此,求出 0010 1011 的补码 1101 0101 这就是十进制数 -43 的二进制表示。

有符号十进制数到十六进制的转换

有符号十进制整数转换为十六进制的步骤如下:

  • 把十进制整数的绝对值转换为十六进制数。

  • 如果初始十进制数是负数,则在第 1 步的基础上,求该十六进制数的补码。

有符号十六进制数到十进制的转换

有符号十六进制整数转换为十进制的步骤如下:

  • 如果十六进制整数是负数,求其补码,否则保持该数不变。

  • 把第 1 步得到的整数转换为十进制。如果初始值是负数,则在该十进制整数的前面加负号。

通过检查十六进制数的最高有效(最高)位,就可以知道该数是正数还是负数。如果最高位 ≥ 8,该数是负数;如果最高位 ≤ 7,该数是正数。比如,十六进制数 8A20 是负数,而 7FD9 是正数。

最大值和最小值

n 位有符号整数只用 n-1 来表示该数的范围。下表列出了有符号单字节、字、双字、四字和八字的最大值与最小值。

二进制减法运算

如果采用与十进制减法相同的方法,那么从一个较大的二进制数中减去一个较小的无符号二进制数就很容易了。示例如下:

01101 (十进制数 13)

- 00111 (十进制数 7)

-———

位 0 上的减法非常简单:

01101

- 00111

-———

     0

下一个位置上执行(0-1),要向左边的相邻位借1,其结果是从 2 中减去 1:

01001

- 00111

-———

     10

再下一位上,又要向左边的相邻位借一位,并从 2 中减去 1:

00011

- 00111

-———

   110

最后,最高两位都执行的是零减去零:

00011

- 00111

-———

00110 (十进制数 6)

执行二进制减法还有更简单的方法,即将被减去数的符号位取反,然后将两数相加。这个方法要求用一个额外的位来保存数的符号。

现在以刚才计算的(01101-00111)为例来试一下这个方法。首先,将 00111 按位取反 11000 加 1,得到 11001。然后,把两个二进制数值相加,并忽略最高位的进位:

01101 (+13)

11001 (-7)

-——

00110 (+6)

结果正是我们预期的 +6。

字符在计算机中是如何表示的?

如果计算机只存储二进制数据,那么它如何表示字符呢?计算机使用的是字符集,将字符映射为整数。早期,字符集只用 8 位表示。即使是现在,在字符模式(如 MS-DOS)下运行时,IBM 兼容微机使用的还是 ASCII(读为“askey”)字符集。

ASCII 是美国标准信息交换码(AmeTican Standard Code for Information Interchange)的首字母缩写。在 ASCII 中,每个字符都被分配了一个独一无二的 7 位整数。

由于 ASCII 只用字节中的低 7 位,因此最高位在不同计算机上被用于创建其专有字符集。比如,IBM 兼容微机就用数值 128〜255 来表示图形符号和希腊字符。

ANSI 字符集

美国国家标准协会(ANSI)定义了 8 位字符集来表示多达 256 个字符。前 128 个字符对应标准美国键盘上的字母和符号。后 128 个字符表示特殊字符,诸如国际字母表、重音符号、货币符号和分数。

Microsoft Windows 早期版本使用 ANSI 字符集。

Unicode 标准

当前,计算机必须能表示计算机软件中世界上各种各样的语言。因此,Unicode 被创建出来,用于提供一种定义文字和符号的通用方法。

Unicode 定义了数字代码(称为代码点(code point)),定义的对象为文字、符号以及所有主要语言中使用的标点符号,包括欧洲字母文字、中东的从右到左书写的文字和很多亚洲文字。代码点转换为可显示字符的格式有三种:

  • UTF-8 用于 HTML,与 ASCII 有相同的字节数值。

  • UTF-16 用于节约使用内存与高效访问字符相互平衡的环境中。比如,Microsoft Windows 近期版本使用了 UTF-16,其中的每个字符都有一个 16 位的编码。

  • UTF-32 用于不考虑空间,但需要固定宽度字符的环境中。每个字符都有一个 32 位的编码。

ASCII 字符串

有一个或多个字符的序列被称为字符串(string)。更具体地说,一个 ASCII 字符串是保存在内存中的,包含了 ASCII 代码的连续字节。比如,字符串“ABC123”的数字代码是 41h、42h、43h、31h、32h 和 33h。

以空字节结束(null-terminated)的字符串是指,在字符串的结尾处有一个为 0 的字节。C 和 C++ 语言使用的是以空字节结束的字符串,一些 Windows 操作系统函数也要求字符串使用这种格式。

使用 ASCII 表

下图中列出了在 Windows 控制台模式下运行时使用的 ASCII 码。

在查找字符的十六进制 ASCII 码时,先沿着表格最上面一行,再找到包含要转换字符的列即可。表格第二行是该十六进制数值的最高位;左起第二列是最低位。

例如,要查找字母 a 的 ASCII 码,先找到包含该字母的列,在这一列第二行中找到第一个十六进制数字 6。然后,找到包含 a 的行的左起第二列,其数字为 1。因此,a 的 ASCII 码是十六进制数 61。

下图用简单的形式说明了这个过程:

ASCII 控制字符

0〜31 的字符代码被称为 ASCII 控制字符。若程序用这些代码编写标准输出(比如 C++ 中),控制字符就会执行预先定义的动作。下表列出了该范围内最常用的字符。

ASCII码(十进制) 说明 ASCII码(十进制) 说明
8 回退符(向左移动一列) 12 换页符(移动到下一个打印页)
9 水平制表符(向前跳过 n 列) 13 回车符(移动到最左边的输出列)
10 换行符(移动到下一个输出行) 27 换码符

数字数据表示术语

用精确的术语描述内存中和显示屏上的数字及字符是非常重要的。比如,在内存中用单字节保存十进制数 65,形式为 0100 0001。调试程序可能会将该字节显示为“41”,这个数字的十六进制形式。

如果这个字节复制到显存中,则显示屏上可能显示字母“A”,因为在 ASCII 码中,0100 0001 代表的是字母 A。由于数字的解释可以依赖于它的上下文,因此,下面为每个数据表示类型分配一个特定的名称,以便将来的讨论更加清晰:

二进制整数是指,以其原始格式保存在内存中的整数,以备用于计算。二进制整数保存形式为 8 位的倍数(如 8、16、32 或 64)。

数字字符串是一串 ASCII 字符,例如“123”或“65”。这是一种简单的数字表示法,下表以十进制数 65 为例,列出了这种表示法能使用的各种形式。

格式 数值 格式 数值
二进制数字字符串 “01000001” 十六进制数字字符串 “41”
十进制数字字符串 “65” 八进制数字字符串 “101”

汇编语言布尔表达式(NOT、AND、OR)

布尔代数(boolean algebra)定义了一组操作,其值为真(true)或假(false)。它的发明者是十九世纪中叶的数学家乔治・布尔(George Boole)。

在数字计算机发明的早期,人们发现布尔代数可以用来描述数字电路的设计。同时,在计算机程序中,布尔表达式被用来表示逻辑操作。

一个布尔表达式(boolean expression)包括一个布尔运算符以及一个或多个操作数。每个布尔表达式都意味着一个为真或假的值。以下为运算符集合:

  • 非(NOT):标记为 ¬ 或 ~ 或 ‘

  • 与(AND):标记为^或 ·

  • 或(OR):标记为 ∨ 或 +

NOT 是一元运算符,其他运算符都是二元的。布尔表达式的操作数也可以是布尔表达式。示例如下:

达式 说明 表达式 说明
¬X NOT X ¬X∨Y (NOT X) OR Y
X^Y X AND Y ¬(X^Y) NOT (X AND Y)
X∨Y X OR Y X^¬Y X AND (NOT Y)

NOT

NOT 运算符将布尔值取反。用数学符号书写为 ¬X,其中,X 是一个变量(或表达式),其值为真(T)或假(F)。下表列出了对变量 X 进行 NOT 运算后所有可能的输岀。 左边为输入,右边(阴影部分)为输出:

X ¬X
F T
T F

真值表中,0 表示假,1 表示真。

AND

布尔运算符 AND 需要两个操作数,用符号表示为 X ^ Y。下表列出了对变量 X 和 Y 进行 AND 运算后,所有可能的输出(阴影部分):

X Y X^Y
F F F
F T F
T F F
T T T

当两个输入都是真时,输出才为真。这与 C++ 和 Java 的复合布尔表达式中的逻辑 AND 是相对应的。

汇编语言中 AND 运算符是按位操作的。如下例所示,X 中的每一位都与 Y 中的相应位进行 AND 运算:

X : 11111111

Y : 00011100

X ^ Y : 00011100

如下图所示,结果值 0001 1100 中的每一位表示的是 X 和 Y 相应位的 AND 运算结果。

OR

布尔运算符 OR 需要两个操作数,用符号表示为 X∨Y。下表列出了对变量 X 和 Y 进行 OR 运算后,所有可能的输出:

X Y X∨Y
F F F
F T T
T F T
T T T

当两个输入都是假时,输出才为假。这个真值表与 C++ 和 Java 的复合布尔表达式中的逻辑 OR 对应。

OR 运算符也是按位操作。在下例中,X 的每一位与 Y 的对应位进行 OR 运算,结果为 1111 1100:

X : 11101100

Y : 00011100

X∨Y : 11111100

如下图所示,每一位都独立进行 OR 运算,生成结果中的对应位。

运算符优先级

运算符优先级原则(operator precedence rule)用于指示在多运算符表达式中,先执行哪个运算。在包含多运算符的布尔表达式中,优先级是非常重要的。

如下表所示,NOT 运算符具有最高优先级,然后是 AND 和 OR 运算符。可以使用括号来强制指定表达式的求值顺序:

表达式 运算符顺序
¬X∨Y NOT,然后 OR
¬(X^Y) OR,然后 NOT
X∨(X^Y) AND,然后 OR

布尔函数真值表

布尔函数(Boolean function)接收布尔输入,生成布尔输出。所有布尔函数都可以构造一个真值表来展示全部可能的输入和输出。下面的这些真值表都表示包含两个输入变量 X 和 Y 的布尔函数。右侧的阴影部分是函数输出

示例 1:¬X∨Y

X ¬X Y ¬X∨Y
F T F T
F T T T
T F F F
T F T T

示例 2:X^¬Y

X Y ¬Y X^¬Y
F F T F
F T F F
T F T T
T T F F

示例3: (Y^S)∨(X^¬S)

X Y S Y^S ¬S X^¬S (Y^S)∨(X^¬S)
F F F F T F F
F T F F T F F
T F F F T T T
T T F F T T T
F F T F F F F
F T T T F F T
T F T F F F F
T T T T F F T

示例 3 的布尔函数描述了一个多路选择器(multiplexer),一种数字组件,利用一个选择位(S)在两个输出(X 和 Y)中选择一个。如果 S 为假,函数输出(Z)就和 X 相同;如果 S 为真,函数输出就和 Y 相同。下面是多路选择器的框图:

x86处理器架构

CPU处理器架构和工作原理浅析

基本微机设计

下图给出了假想机的基本设计。中央处理单元(CPU)是进行算术和逻辑操作的部件,包含了有限数量的存储位置——寄存器(register),一个高频时钟、一个控制单元和一个算术逻辑单元。

其中:

  • 时钟 (clock) 对 CPU 内部操作与系统其他组件进行同步。

  • 控制单元 (control unit, CU) 协调参与机器指令执行的步骤序列。

  • 算术逻辑单元 (arithmetic logic unit, ALU) 执行算术运算,如加法和减法,以及逻辑运算,如 AND(与)、OR(或)和 NOT(非)。

CPU 通过主板上 CPU 插座的引脚与计算机其他部分相连。大部分引脚连接的是数据总线、控制总线和地址总线。

内存存储单元 (memory storage unit) 用于在程序运行时保存指令与数据。它接受来自 CPU 的数据请求,将数据从随机存储器 (RAM) 传输到 CPU,并从 CPU 传输到内存。

由于所有的数据处理都在 CPU 内进行,因此保存在内存中的程序在执行前需要被复制到 CPU 中。程序指令在复制到 CPU 时,可以一次复制一条,也可以一次复制多条。

总线 (bus) 是一组并行线,用于将数据从计算机一个部分传送到另一个部分。一个计算机系统通常包含四类总线:数据类、I/O 类、控制类和地址类。

数据总线 (data bus) 在 CPU 和内存之间传输指令和数据。I/O 总线在 CPU 和系统输入 / 输出设备之间传输数据。控制总线 (control bus) 用二进制信号对所有连接在系统总线上设备的行为进行同步。当前执行指令在 CPU 和内存之间传输数据时,地址总线 (address bus) 用于保持指令和数据的地址。

时钟与 CPU 和系统总线相关的每一个操作都是由一个恒定速率的内部时钟脉冲来进行同步。机器指令的基本时间单位是机器周期 (machine cycle) 或时钟周期 (clock cycle)。

一个时钟周期的时长是一个完整时钟脉冲所需要的时间。下图中,一个时钟周期被描绘为两个相邻下降沿之间的时间:

时钟周期持续时间用时钟速度的倒数来计算,而时钟速度则用每秒振荡数来衡量。例如,一个每秒振荡 10 亿次 (1GHz) 的时钟,其时钟周期为 10 亿分之 1 秒 (1 纳秒 )。

执行一条机器指令最少需要 1 个时钟周期,有几个需要的时钟则超过了 50 个(比如 8088 处理器中的乘法指令)。由于在 CPU、系统总线和内存电路之间存在速度差异,因此,需要访问内存的指令常常需要空时钟周期,也被称为等待状态 (wait states)。

指令执行周期

一条机器指令不会神奇地一下就执行完成。CPU 在执行一条机器指令时,需要经过一系列预先定义好的步骤,这些步骤被称为指令执行周期 (instruction execution cycle)。

假设现在指令指针寄存器中已经有了想要执行指令的地址,下面就是执行步骤:

\1) CPU 从被称为指令队列 (instruction queue) 的内存区域取得指令,之后立即增加指令指针的值。

\2) CPU 对指令的二进制位模式进行译码。这种位模式可能会表示该指令有操作数(输入值)。

\3) 如果有操作数,CPU 就从寄存器和内存中取得操作数。有时,这步还包括了地址计算。

\4) 使用步骤 3 得到的操作数,CPU 执行该指令。同时更新部分状态标志位,如零标志 (Zero)、进位标志 (Carry) 和溢出标志 (Overflow)。

\5) 如果输出操作数也是该指令的一部分,则 CPU 还需要存放其执行结果。

通常将上述听起来很复杂的过程简化为三个步骤:取指 (Fetch)、译码 (Decode) 和执行 (Execute)。操作数 (operand) 是指操作过程中输入或输出的值。例如,表达式 Z=X+Y 有两个输入操作数 (X 和 Y),—个输岀操作数 (Z)。

下图是一个典型 CPU 中的数据流框图。该图表现了在指令执行周期中相互交互部件之间的关系。在从内存读取程序指令之前,将其地址放到地址总线上。然后,内存控制器将所需代码送到数据总线上,存入代码高速缓存 (code cache)。指令指针的值决定下一条将要执行的指令。指令由指令译码器分析,并产生相应的数值信号送往控制单元,其协调 ALU 和浮点单元。虽然图中没有画出控制总线,但是其上传输的信号用系统时钟协调不同 CPU 部件之间的数据传输。

读取内存

作为一个常见现象,计算机从内存读取数据比从内部寄存器读取速度要慢很多。这是因为从内存读取一个值,需要经过下述步骤:

  • 将想要读取的值的地址放到地址总线上。

  • 设置处理器 RD(读取)引脚(改变 RD 的值)。

  • 等待一个时钟周期给存储器芯片进行响应。

  • 将数据从数据总线复制到目标操作数。

上述每一步常常只需要一个时钟周期,时钟周期是基于处理器内固定速率时钟节拍的一种时间测量方法。计算机的 CPU 通常是用其时钟速率来描述。例如,速率为 1.2GHz 意味着时钟节拍或振荡为每秒 12 亿次。

因此,考虑到每个时钟周期仅为 1/1 200 000 000 秒,4 个时钟周期也是非常快的。但是,与 CPU 寄存器相比,这个速度还是慢了,因为访问寄存器一般只需要 1 个时钟周期。

幸运的是,CPU 设计者很早之前就已经指出,因为绝大多数程序都需要访问变量,计算机内存成为了速度瓶颈。他们想出了一个聪明的方法来减少读写内存的时间一一将大部分近期使用过的指令和数据存放在高速存储器 cache 中。

其思想是,程序更可能希望反复访问相同的内存和指令,因此,cache 保存这些值就能使它们能被快速访问到。此外,当 CPU 开始执行一个程序时,它会预先将后续(比如)一千条指令加载到 cache 中,这个行为是基于这样一种假设,即这些指令很快就会被用到。

如果这种情况重复发生在一个代码块中,则 cache 中就会有相同的指令。当处理器能够在 cache 存储器中发现想要的数据,则称为 cache 命中 (cache hit)。反之,如果 CPU 在 cache 中没有找到数据,则称为 cache 未命中 (cache miss)。

x86 系列中的 cache 存储器有两种类型:一级 cache(或主 cache)位于 CPU 上;二级 cache (或次 cache)速度略慢,通过高速数据总线与 CPU 相连。这两种 cache 以最佳方式一 起工作。

还有一个原因使得 cache 存储器比传统 RAM 速度快,cache 存储器是由一种被称为静态 RAM (static RAM) 的特殊存储器芯片构成的。这种芯片比较贵,但是不需要为了保持其内容进行不断地刷新。另一方面,传统存储器,即动态 RAM (dynamic RAM),就需要持续刷新。它速度慢一些,但是价格更便宜。

加载并执行程序

在程序执行之前,需要用一种工具程序将其加载到内存,这种工具程序称为程序加载器 (program loader)。加载后,操作系统必须将 CPU 向程序的入口,即程序开始执行的地址。以下步骤是对这一过程的详细分解。

\1) 操作系统(OS)在当前磁盘目录下搜索程序的文件名。如果找不到,则在预定目录列表(称为路径(path))下搜索文件名。当 OS 无法检索到文件名时,它会发出一个出错信息。

\2) 如果程序文件被找到,OS 就访问磁盘目录中的程序文件基本信息,包括文件大小,及其在磁盘驱动器上的物理位置。

\3) OS 确定内存中下一个可使用的位置,将程序文件加载到内存。为该程序分配内存块,并将程序大小和位置信息加入表中(有时称为描述符表(descriptor table))。另外,OS 可能调整程序内指针的值,使得它们包括程序数据地址。

\4) OS 开始执行程序的第一条机器指令(程序入口)。当程序开始执行后,就成为一个进程(process)。OS 为这个进程分配一个标识号(进程 ID),用于在执行期间对其进行追踪。

\5) 进程自动运行。OS 的工作是追踪进程的执行,并响应系统资源的请求。这些资源包括内存、磁盘文件和输入输出设备等。

\6) 进程结束后,就会从内存中移除。

不论使用哪个版本的 Microsoft Windows,按下 Ctrl-Alt-Delete 组合键,可以选择任务管理器(task manager)选项。在任务管理器窗口可以查看应用程序和进程列表。

应用程序列表中列出了当前正在运行的完整程序名称,比如,Windows 浏览器,或者 Microsoft Visual C++。如果选择进程列表,则会看见一长串进程名。其中的每个进程都是一个独立于其他进程的,并处于运行中的小程序。

可以连续追踪每个进程使用的 CPU 时间和内存容量。在某些情况下,选定一个进程名称后,按下 Delete 键就可以关闭该进程。

32位x86处理器架构

操作模式

x86 处理器有三个主要的操作模式:保护模式、实地址模式和系统管理模式;以及一个子模式:虚拟 8086 (virtual-8086) 模式,这是保护模式的特殊情况。以下是对这些模式的简介:

1) 保护模式 (Protected Mode)

保护模式是处理器的原生状态,在这种模式下,所有的指令和特性都是可用的。分配给程序的独立内存区域被称为段,而处理器会阻止程序使用自身段范围之外的内存。

2) 虚拟 8086 模式 (Virtual-8086 Mode)

保护模式下,处理器可以在一个安全环境中,直接执行实地址模式软件,如 MS-DOS 程序。换句话说,如果一个程序崩溃了或是试图向系统内存区域写数据,都不会影响到同一时间内执行的其他程序。现代操作系统可以同时执行多个独立的虚拟 8086 会话。

3) 实地址模式 (Real-Address Mode)

实地址模式实现的是早期 Intel 处理器的编程环境,但是增加了一些其他的特性,如切换到其他模式的功能。当程序需要直接访问系统内存和硬件设备时,这种模式就很有用。

4) 系统管理模式 (System Management Mode)

系统管理模式 (SMM) 向操作系统提供了实现诸如电源管理和系统安全等功能的机制。这些功能通常是由计算机制造商实现的,他们为了一个特定的系统设置而定制处理器。

基本执行环境

1) 地址空间

在 32 位保护模式下,一个任务或程序最大可以寻址 4GB 的线性地址空间。从 P6 处理器开始,一种被称为扩展物理寻址 (extended physical addressing) 的技术使得可以被寻址的物理内存空间增加到 64GB。

与之相反,实地址模式程序只能寻址 1MB 空间。如果处理器在保护模式下运行多个虚拟 8086 程序,则每个程序只能拥有自己的 1MB 内存空间。

2) 基本程序执行寄存器

寄存器是直接位于 CPU 内的高速存储位置,其设计访问速度远高于传统存储器。例如,当一个循环处理为了速度进行优化时,其循环计数会保留在寄存器中而不是变量中。

下图展示的是基本程序执行寄存器(basic program execution registers)。8 个通用寄存器,6 个段寄存器,一个处理器状态标志寄存器(EFLAGS),和一 个指令指针寄存器(EIP)。

通用寄存器

通用寄存器主要用于算术运算和数据传输。如下图所示,EAX 寄存器的低 16 位在使用时可以用 AX 表示。

一些寄存器的组成部分可以处理 8 位的值。例如,AX 寄存器的高 8 位被称为 AH,而低 8 位被称为 AL。同样的重叠关系也存在于 EAX、EBX、ECX 和 EDX 寄存器中:

32 位 16 位 8 位(高) 8 位(低)
EAX AX AH AL
EBX BX BH BL
ECX CX CH CL
EDX DX DH DL

其他通用寄存器只能用 32 位或 16 位名称来访问,如下表所示:

32 位 16 位 32 位 16 位
ESI SI EBP BP
EDI DI ESP SP
特殊用法

某些通用寄存器有特殊用法:

  • 乘除指令默认使用EAX。它常常被称为扩展累加器(extended accumulator)寄存器。

  • CPU 默认使用 ECX 为循环计数器。

  • ESP 用于寻址堆栈(一种系统内存结构)数据。它极少用于一般算术运算和数据传输,通常被称为扩展堆栈指针(extended stack pointer)寄存器。

  • ESI 和 EDI 用于高速存储器传输指令,有时也被称为扩展源变址(extended source index)寄存器和扩展目的变址(extended destination index)寄存器。

  • 高级语言通过 EBP 来引用堆栈中的函数参数和局部变量。除了高级编程,它不用于一般算术运算和数据传输。它常常被称为扩展帧指针(extended frame pointer)寄存器。

段寄存器

实地址模式中,16 位段寄存器表示的是预先分配的内存区域的基址,这个内存区域称为段。保护模式中,段寄存器中存放的是段描述符表指针。一些段中存放程序指令(代码),其他段存放变量(数据),还有一个堆栈段存放的是局部函数变量和函数参数。

指令指针

指令指针(EIP)寄存器中包含下一条将要执行指令的地址。某些机器指令能控制 EIP,使得程序分支转向到一个新位置。

EFLAGS 寄存器

EFLAGS (或 Flags)寄存器包含了独立的二进制位,用于控制 CPU 的操作,或是反映一些 CPU 操作的结果。有些指令可以测试和控制这些单独的处理器标志位。

设置标志位时,该标识位 =1;清除(或重置)标识位时,该标志位 =0。

控制标志位

控制标志位控制 CPU 的操作。例如,它们能使得 CPU 每执行一条指令后进入中断;在侦测到算术运算溢出时中断执行;进入虚拟 8086 模式,以及进入保护模式。

程序能够通过设置 EFLAGS 寄存器中的单独位来控制 CPU 的操作,比如,方向标志位和中断标志位。

状态标志位

状态标志位反映了 CPU 执行的算术和逻辑操作的结果。其中包括:溢出位、符号位、零标志位、辅助进位标志位、奇偶校验位和进位标志位。下述说明中,标志位的缩写紧跟在标志位名称之后:

  • 进位标志位(CF),与目标位置相比,无符号算术运算结果太大时,设置该标志位。

  • 溢出标志位(OF),与目标位置相比,有符号算术运算结果太大或太小时,设置该标志位。

  • 符号标志位(SF),算术或逻辑操作产生负结果时,设置该标志位。

  • 零标志位(ZF),算术或逻辑操作产生的结果为零时,设置该标志位。

  • 辅助进位标志位(AC),算术操作在 8 位操作数中产生了位 3 向位 4 的进位时,设置该标志位。

  • 奇偶校验标志位(PF),结果的最低有效字节包含偶数个 1 时,设置该标志位,否则,清除该标志位。一般情况下,如果数据有可能被修改或损坏时,该标志位用于进行 错误检测。

3) MMX 寄存器

在实现高级多媒体和通信应用时,MMX 技术提高了 Intel 处理器的性能。8 个 64 位 MMX 寄存器支持称为 SIMD(单指令,多数据,Single-Instruction,Multiple-Data)的特殊指令。

顾名思义,MMX 指令对 MMX 寄存器中的数据值进行并行操作。虽然,它们看上去是独立的寄存器,但是 MMX 寄存器名实际上是浮点单元中使用的同样寄存器的别名。

4) XMM 寄存器

x86 结构还包括了 8 个 128 位 XMM 寄存器,它们被用于 SIMD 流扩展指令集。

浮点单元

浮点单元(FPU, floating-point unit)执行高速浮点算术运算。之前为了这个目的,需要一个独立的协处理器芯片。从 Intel486 处理器开始,FPU 已经集成到主处理器芯片上。

FPU 中有 8 个浮点数据寄存器,分别命名为 ST(0),ST(1),ST(2),ST(3),ST(4), ST(5), ST (6)和 ST(7)。其他控制寄存器和指针寄存器如下图所示。

x86 内存管理

x86 处理器按照前面讨论的基本操作模式来管理内存。保护模式是最可靠、最强大的,但是它对应用程序直接访问系统硬件有着严格的限制。

在实地址模式中,只能寻址 1MB 内存,地址从 00000H 到 FFFFFH。处理器一次只能运行一个程序,但是可以暂时中断程序来处理来自外围设备的请求(称为中断(interrupt))。

应用程序被允许访问内存的任何位置,包括那些直接与系统硬件相关的地址。MS-DOS 操作系统在实地址模式下运行,Windows 95 和 98 能够引导进入这种模式。

在保护模式中,处理器可以同时运行多个程序,它为每个进程(运行中的程序)分配总共 4GB 的内存。每个程序都分配有自己的保留内存区域,程序之间禁止意外访问其他程序的代码和数据。MS-Windows 和 Linux 运行在保护模式下。

在虚拟 8086 模式中,计算机运行在保护模式下,通过创建一个带有 1MB 地址空间的虚拟 8086 机器来模拟运行于实地址模式的 80x86 计算机。例如,在 Windows NT 和 2000 下,当打开一个命令窗口时,就创建了一个虚拟 8086 机器。同一时间可以运行多个这样的窗口,并且窗口之间都是受到保护的。

在 Windows NT,2000 和 XP 系统中,某些需要直接使用计算机硬件的 MS-DOS 程序不能运行在虚拟 8086 模式下。

64位x86-64处理器架构

处理器包括 Intel 64 和 AMD64 处理器系列。指令集是已讨论的 x86 指令集的 64 位扩展。以下为一些基本特征:

\1) 向后兼容 x86 指令集。

\2) 地址长度为 64 位,虚拟地址空间为 2 64 字节。按照当前芯片的实现情况,只能使用地址的低 48 位。

\3) 可以使用 64 位通用寄存器,允许指令具有 64 位整数操作数。

\4) 比 x86 多了 8 个通用寄存器。

\5) 物理地址为 48 位,支持高达 256TB 的 RAM。

另一方面,当处理器运行于本机 64 位模式时,是不支持 16 位实模式或虚拟 8086 模式的。(在传统模式(legacy mode)下,还是支持 16 位编程,但是在 Microsoft Windows 64 位版本中不可用。)

注意尽管 x86-64 指的是指令集,但是也可以将其看作是处理器类型。学习汇编语言时,没有必要考虑支持 x86-64 的处理器之间的硬件实现差异。

第一个使用 x86-64 的 Intel 处理器是 Xeon,之后还有许多其他的处理器,包括 Core i5 和 Core i7。AMD 处理器中使用 x86-64 的例子有 Opteron 和 Athlon 64。

另一个为人所知的 64 位 Intel 架构是 IA-64,后来被称为 Itanium。 IA-64 指令集与 x86 和 x86-64 完全不同,Itanium 处理器通常用于高性能数据库和网络服务器。

64 位操作模式

Intel 64 架构引入了一个新模式,称为 IA-32e。从技术上看,这个模式包含两个子模式:兼容模式(compatibility mode)和 64 位模式(64-bit mode)。不过它们常常被看做是模式而不是子模式,因此,先来了解这两个模式。

1) 兼容模式

在兼容模式下,现有的 16 位与 32 位应用程序通常不用进行重新编译就可以运行。但是,16 位 Windows(Win16)和 DOS 应用程序不能运行在 64 位 Microsoft Windows 下。

与早期 Windows 版本不同,64 位 Windows 没有虚拟 DOS 机器子系统来利用处理器的功能切换到虚拟 8086 模式。

2) 64 位模式

在 64 位模式下,处理器执行的是使用 64 位线性地址空间的应用程序。这是 64 位 Microsoft Windows 的原生模式,该模式能使用 64 位指令操作数。

基本 64 位执行环境

64 位模式下,虽然处理器现在只能支持 48 位的地址,但是理论上,地址最大为 64 位。从寄存器来看,64 位模式与 32 位最主要的区别如下所示:

  • 16 个 64 位通用寄存器(32 位模式只有 8 个通用寄存器)

  • 8 个 80 位浮点寄存器

  • 1 个 64 位状态标志寄存器 RFLAGS (只使用低 32 位)

  • 1 个 64 位指令指针寄存器 RIP

32 位标志寄存器和指令指针寄存器分别称为 EFLAGS 和 EIP。此外,还有一些 x86 处理器用于多媒体处理的特殊寄存器:

  • 8 个 64 位 MMX 寄存器

  • 16 个 128 位 XMM 寄存器(32 位模式只有 8 个 XMM 寄存器)

通用寄存器

64 位模式下,操作数的默认大小是 32 位,并且有 8 个通用寄存器。但是,给每条指令加上 REX(寄存器扩展)前缀后,操作数可以达到 64 位,可用通用寄存器的数量也增加到 16 个:32 位模式下的寄存器,再加上 8 个有标号的寄存器,R8 到 R15。下表给出了 REX 前缀下可用的寄存器。

操作数大小 可用寄存器
8 位 AL、BL、CL、DL、DIL、SIL、BPL、SPL、R8L、R9L、R10L、R11L、R12L、R13L、R14L、R15L
16 位 AX、BX、CX、DX、DI、SI、BP、SP、R8W、R9W、R10W、R11W、R12W、R13W、R14W、R15W
32 位 EAX、EBX、ECX、EDX、EDI、ESI、EBP、ESP、R8D、R9D、R10D、R11D、R12D、R13D、R14D、R15D
64 位 RAX、RBX、RCX、RDX、RDI、RSI、RBP、RSP、R8、R9、R10、R11、R12、R13、R14、R15

还有一些需要记住的细节:

  • 64 位模式下,单条指令不能同时访问寄存器高字节,如 AH、BH、CH 和 DH,以及新字节寄存器的低字节(如 DIL)。

  • 64 位模式下,32 位 EFLAGS 寄存器由 64 位 RFLAGS 寄存器取代。这两个寄存器共享低 32 位,而 RFLAGS 的高 32 位是不使用的。

  • 32 位模式和 64 位模式具有相同的状态标志。

x86计算机组件

主板

主板是微型计算机的心脏,它是一个平面电路板,其上集成了 CPU、支持处理器(芯片组(chipset))、主存、输入输出接口、电源接口和扩展插槽。

各种组件通过总线即一组直接蚀刻在主板上的导线,进行互连。目前 PC 市场上有几十种主板,它们在扩展功能、集成部件和速度方面存在着差异。但是,下述组件一般都会岀现在主板上:

  • CPU 插座。根据其支持的处理器类型,插座具有不同的形状和尺寸。

  • 存储器插槽(SIMM 或 DIMM),用于直接插入小型内存条。

  • BIOS (基本输入输出系统,basic input-output system)计算机芯片,保存系统软件。

  • CMOS RAM,用一个小型纽扣电池为其持续供电。

  • 大容量插槽设备接口,如硬盘和 CD-ROMS。

  • 外部设备的 USB 接口。

  • 键盘和鼠标接口。

  • PCI 总线接口,用于声卡、显卡、数据采集卡和其他输入输出设备。

以下是可选组件:

  • 集成声音处理器。

  • 并行和串行设备接口。

  • 集成网卡。

  • 用于高速显卡的 AGP 总线接口。

典型系统中还有一些重要的支持处理器:

  • 浮点单元(FPU),处理浮点数和扩展整数运算。

  • 8284/82C84 时钟发生器,简称时钟,按照恒定速率振荡。时钟发生器同步 CPU 和计算机的其他部分。

  • 8259A 可编程中断控制器(PIC, Programmable Interrupt Controller),处理来自硬件设备的外部中断请求,包括键盘、系统时钟和磁盘驱动器。这些设备能中断 CPU,并使其立即响应它们的请求。

  • 8253 可编程间隔定时器 / 计数器(Programmable Interval Timer/Counter),每秒中断系统 18.2 次,更新系统日期和时钟,并控制扬声器。它还负责不断刷新内存,因为 RAM 存储器芯片保持其内容的时间只有几毫秒。

  • 8255 可编程并行端口(Programmable Parallel Port),使用 IEEE 并行端口将数据输入和输出计算机。该端口通常用于打印机,但是也可以用于其他输入输出设备。

1) PCI 和 PCI Express 总线架构

PCI(外部设备互联,Peripheral Component Interconnect)总线为 CPU 和其他系统设备提供了连接桥,这些设备包括硬盘驱动器、内存、显卡、声卡和网卡。

最近,PCI Express 总线在设备、内存和处理器之间提供了双向串行连接。如同网络一样,它用独立的“通道”传送数据包。该总线得到显卡的广泛支持,能以较高速度传输数据。

2) 主板芯片组

主板芯片组(motherlboard chipset)是一组处理器芯片的集合,这些芯片被设计为在特定类型主板上一起工作。

各种芯片组具有增强处理能力、多媒体功能或减少功耗等特性。以 Intel P965 Express 芯片组为例,该芯片组与 Intel Core2 Duo 或 Pentium D 处理器一起,用于桌面系统

Intel P965 具有下述特性:

  • Intel 高速内存访问 (Fast Memory Access) 使用了最新内存控制中心 (MCH)。它可以 800MHz 时钟速度来访问双通道 DDR2 存储器。

  • I/O 控制中心 (Intel ICH8/R/DH) 使用 Intel 矩阵存储技术 (MST) 来支持多个串行 ATA 设备 ( 磁盘驱动器 ) 。

  • 支持多个 USB 端口,多个 PCI Express 插槽,联网和 Intel 静音系统技术。

  • 高清晰音频芯片提供了数字声音功能。

如下图所示,主板厂商以特定芯片为中心来制造产品。例如,Asus 公司使用 P965 芯片组的 P5B-E P965 主板。

内存

基于 Intel 的系统使用的是几种基础类型内存:只读存储器(ROM)、可擦除可编程只读存储器(EPROM)、动态随机访问存储器(DRAM)、静态 RAM (SRAM)、图像随机存储器(VRAM),和互补金属氧化物半导体(CMOS)RAM:

  • ROM 永久烧录在芯片上,并且不能擦除。

  • EPROM 能用紫外线缓慢擦除,并且重新编程。

  • DRAM,即通常的内存,在程序运行时保存程序和数据的部件。该部件价格便宜,但是每毫秒需要进行刷新,以避免丢失其内容。有些系统使用的是 ECC(错误检查和纠正)存储器。

  • SRAM 主要用于价格高、速度快的 cache 存储器。它不需要刷新,CPU 的 cache 存储器就是由 SRAM 构成的。

  • VRAM 保存视频数据。VRAM 是双端口的,它允许一个端口持续刷新显示器,同时另一个端口将数据写到显示器。

  • CMOS RAM 在系统主板上,保存系统设置信息。它由电池供电,因此当计算机电源关闭后,CMOS RAM 中的内容仍能保留。

计算机I/O输入输出系统

由于计算机游戏与内存和 I/O 有着非常密切的关系,因此,它们推动计算机达到其最大性能。善于游戏编程的程序员通常很了解视频和音频硬件,并会优化代码的硬件特性。

I/O 访问层次

应用程序通常从键盘和磁盘文件读取输入,而将输出写到显示器和文件中。完成 I/O 不需要直接访问硬件——相反,可以调用操作系统的函数。

与《虚拟机》一节中描述的虚拟机相似,I/O 也有不同的访问层次,主要有以下三个:

1) 高级语言函数

高级编程语言,如 C++ 或 Java,包含了执行输入输出的函数。由于这些函数要在各种不同的计算机系统中工作,并不依赖于任何一个操作系统,因此,这些函数具有可移植性。

2) 操作系统

程序员能够从被称为 API(应用程序编程接口,Application Programming Interface)的库中调用操作系统函数。操作系统提供高级操作,比如,向文件写入字符串,从键盘读取字符串,和分配内存块。

3) BIOS

基本输入输出系统是一组能够直接与硬件设备通信的低级子程序集合。BIOS 由计算机制造商安装并定制,以适应机器硬件。操作系统通常与 BIOS 通信。

设备驱动程序

设备驱动程序允许操作系统与硬件设备和系统 BIOS 直接通信。例如,设备驱动程序可能接收来自 OS 的请求来读取一些数据,而满足该请求的方法是,通过执行设备固件中的代码,用设备特有的方式来读取数据。

设备驱动程序有两种安装方法:一种是在特定硬件设备连接到系统之前,或者设备已连接并且识别之后。对于后一种方法,OS 识别设备名称和签名,然后在计算机上定位并安装设备驱动软件。

现在,通过展示应用程序在屏幕上显示字符串的过程,来了解 I/O 层次结构如下图所示。

该过程包含以下步骤:

  • 应用程序调用 HLL 库函数,将字符串写入标准输出。

  • 库函数(第 3 层)调用操作系统函数,传递一个字符串指针。

  • 操作系统函数(第 2 层)用循环的方法调用 BIOS 子程序,向其传递每个字符的 ASCII 码和颜色。操作系统调用另一个 BIOS 子程序,将光标移动到屏幕的下一个位置上。

  • BIOS 子程序(第 1 层)接收一个字符,将其映射到一个特定的系统字体,并把该字符发送到与视频控制卡相连的硬件端口。

  • 视频控制卡(第 0 层)为视频显示产生定时硬件信号,来控制光栅扫描并显示像素。

多层次编程

汇编语言程序在输入输出编程领域有着强大的能力和灵活性。它们可以从以下访问层次进行选择 (如下图所示):

  • 第 3 层:调用库函数来执行通用文本 I/O 和基于文件的 I/O。

  • 第 2 层:调用操作系统函数来执行通用文本 I/O 和基于文件的 I/O。如果 OS 使用了图形用户界面,它就能用与设备无关的方式来显示图形。

  • 第 1 层:调用 BIOS 函数来控制设备具体特性,如颜色、图形、声音、键盘输入和底层磁盘 I/O。

  • 第 0 层:从硬件端口发送和接收数据,对特定设备拥有绝对控制权。这个方式没有广泛用于各种硬件设备,因此不具可移植性。不同设备通常使用不同硬件端口,因此,程序代码必须根据每个设备的特定类型来进行定制。

如何进行权衡?控制与可移植性是最重要的。第 2 层(OS)工作在任何一个运行同样操作系统的计算机上。如果 I/O 设备缺少某些功能,那么 OS 将尽可能接近预期结果。第 2 层速度并不特别快,因为每个 I/O 调用在执行前,都必须经过好几个层次。

第 1 层(BIOS)在具有标准 BIOS 的所有系统上工作,但是在这些系统上不会产生同样的结果。例如,两台计算机可能会有不同分辨率的视频显示功能。在第 1 层上的程序员需要编写代码来检测用户的硬件设置,并调整输出格式来与之匹配。第 1 层的速度比第 2 层快,因为它与硬件之间只隔了一个层次。

第 0 层(硬件)与通用设备一起工作,如串行端口;或是与由知名厂商生产的特殊 I/O 设备一起工作。这个层次上的程序必须扩展它们的编码逻辑来处理 I/O 设备的变化。实模式的游戏程序就是最好的例子,因为它们常常需要取得计算机的控制权。第 0 层的程序执行速度与硬件一样快。

举个例子,假设要用音频控制设备来播放一个 WAV 文件。在 OS 层上,不需要了解已安装设备的类型,也不用关心设备卡的非标准特性。

在 BIOS 上,要查询声卡(通过其已安装的设备驱动软件),找出它是否属于某一类具有已知功能的声卡。在硬件层上,需要对 特定模式声卡的程序进行微调,以利用每个声卡的特性。

通用操作系统极少允许应用程序直接访问系统硬件,因为这样做会使得它几乎无法同时运行多个程序。相反,硬件只能由设备驱动程序按照严格控制的方式进行访问。

另一方面,专业设备的小型操作系统则常常直接与硬件相连。这样做是为了减少操作系统代码占用的内存空间,并且这些操作系统几乎总是一次只运行单个程序。Microsoft 最后一个允许程序直接访问硬件的操作系统是 MS-DOS,它一次只能运行一个程序。

汇编语言基础

第一个汇编语言程序

汇编语言以隐晦难懂而著名,它是一种几乎提供了全部信息的语言。程序员可以看到正在发生的所有事情,甚至包括 CPU 中的寄存器和标志!

但是,在拥有这种能力的同时,程序员必须负责处理数据表示的细节和指令的格式。程序员工作在一个具有大量详细信息的层次。现在以一个简单的汇编语言程序为例,来了解其工作过程。

程序执行两个数相加,并将结果保存在寄存器中。程序名称为 AddTwo:

main PROC
    mov eax, 5               ;将数字 5 送入 eax 寄存器
    add eax, 6               ;eax 寄存器加 6

    INVOKE ExitProcess, 0    ;程序结束
main ENDP

现在按照一次一行代码的方法来仔细查看这段程序:

  • 第 1 行开始 main 程序(主程序),即程序的入口;

  • 第 2 行将数字 5 送入 eax 寄存器;

  • 第 3 行把 6 加到 EAX 的值上,得到新值 11;

  • 第 5 行调用 Windows 服务(也被称为函数)ExitProcess 停止程序,并将控制权交还给操作系统;

  • 第 6 行是主程序结束的标记。

大家可能已经注意到了程序中包含的注释,它总是用分号开头。程序的顶部省略了一些声明,稍后会予以说明,不过从本质上说,这是一个可以用的程序。

它不会将全部信息显示在屏幕上,但是借助工具程序调试器的运行,程序员可以按一次一行代码的方式执行程序, 并查看寄存器的值。

添加一个变量

现在让这个程序变得有趣些,将加法运算的结果保存在变量 sum 中。要实现这一点,需要增加一些标记,或声明,用来标识程序的代码和数据区:

.data                          ;此为数据区
sum DWORD 0                    ;定义名为sum的变量
.code                          ;此为代码区
main PROC
    mov eax,5                  ;将数字5送入而eax寄存器
    add eax,6                  ;eax寄存器加6
    mox sum,eax
    INVOKE ExitProcess,0       ;结束程序
main ENDP

变量 sum 在第 2 行进行了声明,其大小为 32 位,使用了关键字 DWORD。汇编语言中有很多这样的大小关键字,其作用或多或少与数据类型一样。

但是与程序员可能熟悉的类型相比它们没有那么具体,比如 int、double、float 等等。这些关键字只限制大小,并不检查变量中存放的内容。记住,程序员拥有完全控制权。

顺便说一下,那些被 .code 和 .data 伪指令标记的代码和数据区,被称为段。即,程序有代码段和数据段。

汇编语言常量

常量(constant)是程序中使用的一个确定数值,在汇编阶段就可以确定,直接编码于指令代码中,不是保存在存储器中可变的变量,因为是编码在指令中的量,和指令一起存储了,所以不用单独开辟主存空间,所以也就没法动态改变它了,这也正是高级语言常量无法修改的原因。

整数常量

整数常量(integer literal)(又称为整型常量(integer constant))由一个可选前置符号、一个或多个数字,以及一个指明其基数的可选基数字符构成:

[{+|-}] digits [radix]

提示:本教程使用 Microsoft 语法符号。方括号内的元素是可选的;大括号内的元素用 | 符号分隔,且必须要选择其中一个元素;斜体字标识的是有明确定义或 说明的元素。

由此,比如 26 就是一个有效的整数常量。它没有基数,所以假设其是十进制形式。如果想要表示十六进制数 26,就将其写为 26h。同样,数字 1101 可以被看做是十进制值,除非在其末尾添加“b”,使其成为 1101b (二进制)。下表列出了可能的基数值:

h 十六进制 r 编码实数
q/o 八进制 t 十进制(备用)
d 十进制 y 二进制(备用)
b 二进制

下面这些整数常量声明了各种基数。每行都有注释:

26         ;十进制
26d        ;十进制
11010011b  ;二进制
42q        ;八进制
42o        ;八进制
1Ah        ;十六进制
0A3h       ;十六进制

以字母开头的十六进制数必须加个前置 0,以防汇编器将其解释为标识符。

整型常量表达式

整型常量表达式 (constant integer expression) 是一种算术表达式,它包含了整数常量和算术运算符。每个表达式的计算结果必须是一个整数,并可用 32 位 (从 0 到 FFFFFFFFh) 来存放。

下表列出了算术运算符,并按照从高 (1) 到低 (4) 的顺序给出了它们的优先级。对整型常量表达式而言很重要的是,要意识到它们只在汇编时计算。这里将它们简称为 整数表达式。

运算符 名称 优先级
() 圆括号 1
+,- 一元加、减 2
*, / 乘、除 3
MOD 取模 3
+, - 加、减 4

运算符优先级 (operator precedence) 是指,当一个表达式包含两个或多个运算符时,这些操作的执行顺序。下面是一些表达式和它们的执行顺序:

4 + 5 * 2       ;乘法,加法
12 - 1 MOD 5    ;取模,减法
-5 + 2          ;一元减法,加法
(4 + 2)  *  6   ;加法,乘法

下面给出了一些有效表达式和它们的值:

提示:在表达式中使用圆括号来表明操作顺序,那么就不用去死记运算符优先级。

实数常量

实数常量(real number literal)(又称为浮点数常量(floating-point literal))用于表示十进制实数和编码(十六进制)实数。十进制实数包含一个可选符号,其后跟随一个整数,一个十进制小数点,一个可选的表示小数部分的整数,和一个可选的指数:

[sign]integer.[integer] [exponent]

符号和指数的格式如下:

sign               {+,-}
exponent       E[{+,-}]integer

下面是一些有效的十进制实数:

2.
+3.0
-44.2E+05
26.E5
至少需要一个数字和一个十进制小数点。

编码实数(encoded real)表示的是十六进制实数,用 IEEE 浮点数格式表示短实数。比如,十进制数 +1.0 用二进制表示为:

0011 1111 1000 0000 0000 0000 0000 0000

在汇编语言中,同样的值可以编码为短实数:

3F800000r

字符常量

字符常量 (character literal) 是指,用单引号或双引号包含的一个字符。汇编器在内存中保存的是该字符二进制 ASCII 码的数值。例如:

'A'
"d"

表明字符常量在内部保存为整数,使用的是 ASCII 编码序列。因此,当编写字符常量“A”时,它在内存中存放的形式为数字 65 ( 或 41h)。

字符串常量

字符串常量 (string literal) 是用单引号或双引号包含的一个字符 ( 含空格符 ) 序列:

'ABC'
'X'
"Good night, Gracie"
'40961

嵌套引号也是被允许的,使用方法如下例所示:

"This isn't a test"
'Say "Good night," Gracie'

和字符常量以整数形式存放一样,字符串常量在内存中的保存形式为整数字节数值序列。例如,字符串常量“ABCD”就包含四个字节 41h、42h、43h、44h。

汇编语言保留字

保留字(reserved words)有特殊意义并且只能在其正确的上下文中使用。默认情况下,保留字是没有大小写之分的。比如,MOV 与 mov、Mov 是相同的。

保留字有不同的类型:

  • 指令助记符,如 MOV、ADD 和 MUL。

  • 寄存器名称。

  • 伪指令,告诉汇编器如何汇编程序。

  • 属性,提供变量和操作数的大小与使用信息。例如 BYTE 和 WORD。

  • 运算符,在常量表达式中使用。

  • 预定义符号,比如 @data,它在汇编时返回常量的整数值。

下表是常用的保留字列表。

$ PARITY? DWORD STDCALL
? PASCAL FAR SWORD
@B QWORD FAR16 SYSCALL
@F REAL4 FORTRAN TBYTE
ADDR REAL8 FWORD VARARG
BASIC REAL10 NEAR WORD
BYTE SBYTE NEAR16 ZERO?
C SDORD OVERFLOW?
CARRY? SIGN?

汇编语言标识符及其命名规则

标识符(identifier)是由程序员选择的名称,它用于标识变量、常数、子程序和代码标签。

标识符的形成有一些规则:

  • 可以包含 1 到 247 个字符。

  • 不区分大小写。

  • 第一个字符必须为字母 (A—Z, a—z) A 下划线 (_)、@、? 或 $。其后的字符也可以是数字。

  • 标识符不能与汇编器保留字相同。

提示:可以在运行汇编器时,添加 -Cp 命令行切换项来使得所有关键字和标识符变成大小写敏感。

通常,在高级编程语言代码中,标识符使用描述性名称是一个好主意。尽管汇编语言指令短且隐晦,但没有理由使得标识符也要变得难以理解。

下面是一些命名良好的名称:

lineCount    firstValue    index    line_count
myFile     xCoord      main    x_Coord

下面的名称合法,但是不可取:

_lineCount    $first    @myFile

一般情况下,应避免用符号 @ 和下划线作为第一个字符,因为它们既用于汇编器,也用于高级语言编译器。

汇编语言伪指令

伪指令 (directive) 是嵌入源代码中的命令,由汇编器识别和执行。伪指令不在运行时执行,但是它们可以定义变量、宏和子程序;为内存段分配名称,执行许多其他与汇编器相关的日常任务。

默认情况下,伪指令不区分大小写。例如,.data,.DATA 和 .Data 是相同的。

下面的例子有助于说明伪指令和指令的区别。DWORD 伪指令告诉汇编器在程序中为一个双字变量保留空间。另一方面,MOV 指令在运行时执行,将 myVar 的内容复制到 EAX 寄存器中:

myVar DWORD 26
mov eax,myVar

尽管 Intel 处理器所有的汇编器使用相同的指令集,但是通常它们有着不同的伪指令。比如,Microsoft 汇编器的 REPT 伪指令对其他一些汇编器就是无法识别的。

定义段

汇编器伪指令的一个重要功能是定义程序区段,也称为段 (segment)。程序中的段具有不同的作用。如下面的例子,一个段可以用于定义变量,并用 .DATA 伪指令进行标识:

.data

.CODE 伪指令标识的程序区段包含了可执行的指令:

.code

.STACK 伪指令标识的程序区段定义了运行时堆栈,并设置了其大小:

.stack 100h

汇编语言指令详解

指令(instruction)是一种语句,它在程序汇编编译时变得可执行。汇编器将指令翻译为机器语言字节,并且在运行时由 CPU 加载和执行。

一条指令有四个组成部分:

  • 标号(可选)

  • 指令助记符(必需)

  • 操作数(通常是必需的)

  • 注释(可选)

不同部分的位置安排如下所示:

[label: ] mnemonic [operands] [;comment]

现在分别了解每个部分,先从标号字段开始。

1) 标号

标号(label)是一种标识符,是指令和数据的位置标记。标号位于指令的前端,表示指令的地址。同样,标号也位于变量的前端,表示变量的地址。标号有两种类型:数据标号和代码标号。

数据标号标识变量的位置,它提供了一种方便的手段在代码中引用该变量。比如,下面定义了一个名为 count 的变量:

count DWORD 100

汇编器为每个标号分配一个数字地址。可以在一个标号后面定义多个数据项。在下面的例子中,array 定义了第一个数字(1024)的位置,其他数字在内存中的位置紧随其后:

array DWORD 1024, 2048
      DWORD 4096, 8192

程序代码区(指令所在区段)的标号必须用冒号(:)结束。代码标号用作跳转和循环指令的目标。例如,下面的 JMP 指令创建一个循环,将程序控制传递给标号 target 标识的位置:

target:
        mov ax,bx
        ...
        jmp target

代码标号可以与指令在同一行上,也可以自己独立一行:

L1: mov ax, bx
L2 :

标号命名规则要求,只要每个标号在其封闭子程序页中是唯一的,那么就可以多次使用相同的标号。

2) 指令助记符

指令助记符(instruction mnemonic)是标记一条指令的短单词。在英语中,助记符是帮助记忆的方法。相似地,汇编语言指令助记符,如 mov, add 和 sub,给出了指令执行操作类型的线索。下面是一些指令助记符的例子:

助记符 说明 助记符 说明
MOV 传送(分配)数值 MUL 两个数值相乘
ADD 两个数值相加 JMP 跳转到一个新位置
SUB 从一个数值中减去另一个数值 CALL 调用一个子程序

3) 操作数

操作数是指令输入输出的数值。汇编语言指令操作数的个数范围是 0〜3 个,每个操作数可以是寄存器、内存操作数、整数表达式和输入输岀端口。

生成内存操作数有不同的方法,比如,使用变量名、带方括号的寄存器等。变量名暗示了变量地址,并指示计算机使用给定地址的内存内容。下表列出了一些操作数示例:

示例 操作数类型 示例 操作数类型
96 整数常量 eax 寄存器
2+4 整数表达式 count 内存

现在来考虑一些包含不同个数操作数的汇编语言指令示例。比如,STC 指令没有操作数:

stc                    ;进位标志位置 1

INC 指令有一个操作数:

inc eax                ;EAX 加 1

MOV 指令有两个操作数:

mov count, ebx         ;将 EBX 传送给变量 count

操作数有固有顺序。当指令有多个操作数时,通常第一个操作数被称为目的操作数,第二个操作数被称为源操作数(source operand)。

一般情况下,目的操作数的内容由指令修改。比如,在 mov 指令中,数据就是从源操作数复制到目的操作数。

IMUL 指令有三个操作数,第一个是目的操作数,第二个和第三个是进行乘法的源操作数:

imul eax,ebx,5

在上例中,EBX 与 5 相乘,结果存放在 EAX 寄存器中。

4) 注释

注释是程序编写者与阅读者交流程序设计信息的重要途径。程序清单的开始部分通常包含如下信息:

  • 程序目标的说明

  • 程序创建者或修改者的名单

  • 程序创建和修改的日期

  • 程序实现技术的说明

注释有两种指定方法:

  • 单行注释,用分号(;)开始。汇编器将忽略在同一行上分号之后的所有字符。

  • 块注释,用 COMMENT 伪指令和一个用户定义的符号开始。汇编器将忽略其后所有的文本行,直到相同的用户定义符号出现为止。

示例如下:

COMMENT !
        This line is a comment.
        This line is also a comment.
!

其他符号也可以使用,只要该符号不出现在注释行中:

COMMENT &
        This line is a comment.
        This line is also a comment.
&

当然,程序员应该在整个程序中提供注释,尤其是代码意图不太明显的地方。

5) NOP(空操作)指令

最安全(也是最无用)的指令是 NOP(空操作)。它在程序空间中占有一个字节,但是不做任何操作。它有时被编译器和汇编器用于将代码对齐到有效的地址边界。

在下面的例子中,第一条指令 MOV 生成了 3 字节的机器代码。NOP 指令就把第三条指令的地址对齐到双字边界(4 的偶数倍):

00000000   66   8B  C3  mov ax,bx
00000003   90           nop           ;对齐下条指令
00000004   8B   D1      mov edx,ecx

x86 处理器被设计为从双字的偶数倍地址处加载代码和数据,这使得加载速度更快。

汇编语言整数加减法示例

之前给出的 AddTwo 程序,并添加必要的声明使其成为完全能运行的程序。

; AddTwo.asm -两个 32 位整数相加

.386
.model flat,stdcall
.stack 4096
ExitProcess PROTO, dwExitCode:DWORD

.code
main PROC
mov  eax,5  ;将数字5送入eax寄存器
add  eax,6  ;eax寄存器加6

INVOKE ExitProcess,0
main ENDP
END main

第 3 行是 .386 伪指令,它表示这是一个 32 位程序,能访问 32 位寄存器和地址。第 4 行选择了程序的内存模式(flat),并确定了子程序的调用规范(称为 stdcall)。其原因是 32 位 Windows 服务要求使用 stdcall 规范。第 5 行为运行时堆栈保留了 4096 字节的存储空间,每个程序都必须有。

第 6 行声明了 ExitProcess 函数的原型,它是一个标准的 Windows 服务。原型包含了函数名、PROTO 关键字、一个逗号,以及一个输入参数列表。ExitProcess 的输入参数名称为 dwExitCode。

可以将其看作为给 Windows 操作系统的返回值,返回值为零,则表示程序执行成功;而任何其他的整数值都表示了一个错误代码。因此,程序员可以将自己的汇编程序看作是被操作系统调用的子程序或过程。当程序准备结束时,它就调用 ExitProcess,并向操作系统返回一个整数以表示该程序运行良好。

大家可能会好奇,为什么操作系统想要知道程序是否成功完成。理由如下:与按序执行一些程序相比,系统管理员常常会创建脚本文件。在脚本文件中的每一个点上,系统管理员都需要知道刚执行的程序是否失败,这样就可以在必要时退出该脚本。

脚本通常如下例所示,其中,ErrorLevel1 表示前一步的过程返回码大于或等于 1 :

call program_1
if ErrorLevel 1 goto FailedLabel
call program_2
if ErrorLevel 1 goto FailedLabel
:SuccessLabel.
Echo Great, everything worked!

现在回到 AddTwo 程序清单。第 15 行用 end 伪指令来标记汇编的最后一行,同时它也标识了程序的入口(main)。标号 main 在第 9 行进行了声明,它标记了程序开始执行的地址。

汇编伪指令回顾

现在回顾一些在示例程序中使用过的最重要的汇编伪指令。

首先是 .MODEL 伪指令,它告诉汇编程序用的是哪一种存储模式:

.model flat,stdcall

32 位程序总是使用平面(flat)存储模式,它与处理器的保护模式相关联。关键字 stdcall 在调用程序时告诉汇编器,怎样管理运行时堆栈。

然后是 .STACK 伪指令,它告诉汇编器应该为程序运行时堆栈保留多少内存字节:

.stack 4096

数值 4096 可能比将要用的字节数多,但是对处理器的内存管理而言,它正好对应了一个内存页的大小。所有的现代程序在调用子程序时都会用到堆栈。首先,用来保存传递的参数;其次,用来保存调用函数的代码的地址。

函数调用结束后,CPU 利用这个地址返回到函数被调用的程序点。此外,运行时堆栈还可以保存局部变量,也就是,在函数内定义的变量。

.CODE 伪指令标记一个程序代码区的起点,代码区包含了可执行指令。通常,.CODE 的下一行声明程序的入口,按照惯例,一般会是一个名为 main 的过程。程序的入口是指程序要执行的第一条指令的位置。用下面两行来传递这个信息:

.code
main PROC

ENDP 伪指令标记一个过程的结束。如果程序有名为 main 的过程,则 endp 就必须使用同样的名称:

main ENDP

最后,END 伪指令标记一个程序的结束,并要引用程序入口:

END main

如果在 END 伪指令后面还有更多代码行,它们都会被汇编程序忽略。程序员可以在这里放各种内容一一程序注释,代码副本等等,都无关紧要。

运行和调试 AddTwo 程序

使用 Visual Studio 可以很方便地编辑、构建和运行汇编语言程序。下面的步骤,按照 Visual Studio 2012,说明了怎样打开示例项目,并创建 AddTwo 程序:

\1) 启动计算机上安装的最新版本的 Visual Studio。

\2) 打开 Visual Studio 中 Solution Explorer 窗口。它应该已经是可见的,但是程序员也可以在 View 菜单中选择 Solution Explorer 使其可见。

\3) 在 Solution Explorer 窗口右键点击项目名称,在文本菜单中选择 Add,再在弹出菜单中选择 New Item。

\4) 在 Add New File 对话窗口中(如下图所示 ) ,将文件命名为 AddTwo.asm,填写 Location 项为该文件选择一个合适的磁盘文件夹。

\5) 单击 Add 按钮保存文件。

\6) 键入程序源代码,如下所示。这里大写关键字不是必需的:

; AddTwo.asm - adds two 32-bit integers.
.386
.model flat,stdcall
.stack 4096
ExitProcess PROTO,dwExitCode:DWORD
.code
main PROC
mov eax, 5
add eax, 6
INVOKE ExitProcess,0
main ENDP
END main

\7) 在 Project 菜单中选择 Build Project,查看 Visual Studio 工作区底部的错误消息。这被称为错误列表窗口。注意,当没有错误时,窗口底部的状态栏会显示 Build succeeded。

调试演示

下面将展示 AddTwo 程序的一个示例调试会话。演示使用的是 Visual Studio 2012,不过,自 2008 年起的任何版本的 Visual Studio 都可以使用。

运行调试程序的一个方法是在 Debug 菜单中选择 Step Over。按照 Visual Studio 的配置,F10 功能键或 Shift+F8 组合键将执行 Step Over 命令。

开始调试会话的另一种方法是在程序语句上设置断点,方法是在代码窗口左侧灰色垂直条中直接单击。断点处由一个红色大圆点标识出来。然后就可以从 Debug 菜单中选择 Start Debugging 开始运行程序。

如果试图在非执行代码行设置断点,那么在运行程序时,Visual Studio 会直接将断点前移到下一条可执行代码行。

当调试器被激活时,Visual Studio 窗口底部的状态栏变为橙色。当调试器停止并返回编辑模式时,状态栏变为蓝色。可视提示是有用的,因为在调试器运行时,程序员无法对程序进行编辑或保存。

自定义调试接口

在调试时可以自定义调试接口。例如,如果想要显示 CPU 寄存器,实现方法是,在 Debug 菜单中选择 Windows,然后再选择 Registerso, 其中 Registers 窗口可见,同时还关闭了一些不重要的窗口。EAX 数值显示为 0000000B,是十进制数 11 的十六进制表示。

Registers 窗口中,EFL 寄存器包含了所有的状态标志位(零标志、进位标志、溢出标志等)。如果在 Registers 窗口中 右键单击,并在弹出菜单中选择Flags,则窗口将显示单个的标志位值。

Registers 窗口的一个重要特点是,在单步执行程序时,任何寄存器,只要当前指令修改了它的数值,就会变为红色。尽管无法在打印页面(它只有黑白两色)上表示出来,这种红色高亮确实显示给程序员,使之了解其程序是怎样影响寄存器的。

在 Visual Studio 中运行一个汇编语言程序时,它是在控制台窗口中启动的。这个窗口与从 Windows 的 Start 菜单运行名为 cmd.exe 程序的窗口是相同的。或者,还可以打开项目 Debug\Bin 文件夹中的命令提示符,直接从命令行运行应用程序。如果采用的是这个方法,程序员就只能看见程序的输出,其中包括了写入控制台窗口的文本。查找具有相同名称的可执行文件作为 Visual Studio 项目。

汇编器以及汇编流程

用汇编语言编写的源程序不能直接在其目标计算机上执行,必须通过翻译或汇编将其转换为可执行代码。实际上,汇编器与编译器 (compiler) 很相似,编译器是一类程序,用于将 C++ 或 Java 程序翻译为可执行代码。

汇编器生成包含机器语言的文件,称为目标文件 (object file)。这个文件还没有准备好执行,它还需传递给一个被称为链接器 (linker) 的程序,从而生成可执行文件 (executable file)。这个文件就准备好在操作系统命令提示符下执行。

汇编-链接-执行周期

下面总结了编辑、汇编、链接和执行汇编语言程序的过程。下面详细说明每一个步骤。

  • 步骤1:编程者用文本编辑器 (text editor) 创建一个 ASCII 文本文件,称之为源文件。

  • 步骤2:汇编器读取源文件,并生成目标文件,即对程序的机器语言翻译。或者,它也会生成列表文件。只要出现任何错误,编程者就必须返回步骤 1,修改程序。

  • 步骤3:链接器读取并检查目标文件,以便发现该程序是否包含了任何对链接库中过程的调用。链接器从链接库中复制任何被请求的过程,将它们与目标文件组合,以生成可执行文件。

  • 步骤4:操作系统加载程序将可执行文件读入内存,并使 CPU 分支到该程序起始地址,然后程序开始执行。

列表文件

列表文件 (listing file) 包括了程序源文件的副本,再加上行号、每条指令的数字地址、每条指令的机器代码字节(十六进制)以及符号表。符号表中包含了程序中所有标识符的名称、段和相关信息。

高级程序员有时会利用列表文件来获得程序的详细信息。下面的代码展示了 AddTwo 程序的部分列表文件,现在进一步查看这个文件。1〜7 行没有可执行代码,因此它们原封不动地从源文件中直接复制过来。第 9 行表示代码段开始的地址为 0000 0000(在 32 位程序中,地址显示为 8 个十六进制数字)。这个地址是相对于程序内存占用起点而言 的,但是,当程序加载到内存中时,这个地址就会转换为绝对内存地址。此时,该程序就会从这个地址开始,比如 0004 0000h。

; AddTwo.asm - adds two 32-bit integers.
; Chapter 3 example

.386
.model flat,stdcall
.stack 4096

ExitProcess PROTO,dwExitCode:DWORD
    00000000                            .code
    00000000                            main PROC
    00000000 B8 00000005                    mov eax, 5
    00000005 83 C0 06                       add eax,6

                                            invoke ExitProcess,0
    00000008 6A 00                          push        +000000000h
    0000000A E8 00000000 E                  call        ExitProcess
    0000000F                            main ENDP
                                        END main

第 10 行和第 11 行也显示了相同的开始地址 0000 0000,原因是:第一条可执行语句是 MOV 指令,它在第 11 行。请注意第 11 行中,在地址和源代码之间出现了几个十六进制字节,这些字节(B8 0000 0005)代表的是机器代码指令(B8 ),而该指令分配给 EAX 的就是 32 位常数值(0000 0005):

00000000 B8 00000005 mov eax, 5

数值 B8 也被称为操作代码(或简称为操作码),因为它表示了特定的机器指令,将一个 32 位整数送入 eax 寄存器。

第 12 行也是一条可执行指令,起始偏移量为 0000 0005。这个偏移量是指从程序起始地址开始 5 个字节的距离。

第 14 行有 invoke 伪指令。注意第 15 行和 16 行是如何插入到这段代码中的,插入代码的原因是,INVOKE 伪指令使得汇编器生成 PUSH 和 CALL 语句,它们就显示在第 15 行和 16 行。

代码中展示的示例列表文件说明了机器指令是怎样以整数值序列的形式加载到内存的,在这里用十六进制表示:B8、0000 0005、83、C0、06、6A、00、EB、0000 0000。每个数中包含的数字个数暗示了位的个数:2 个数字就是 8 位,4 个数字就是 16 位,8 个数字就是 32 位,以此类推。所以,本例机器指令长正好是 15 个字节(2 个 4 字节值和 7 个 1 字节值)。

当程序员想要确认汇编器是否按照自己的程序生成了正确的机器代码字节时,列表文件就是最好的资源。如果是刚开始学习机器代码指令是如何生成的,列表文件也是一个很好的教学工具。

若想告诉 Visual Studio 生成列表文件,则在打开项目时按下述步骤操作:在 Project 菜单中选择 Properties,在 Configuration Properties 下,选择 Microsoft Macro Assemblero 然后选择 Listing File。在对话框中,设置 Generate Preprocessed Source Listing 为 Yes,设置 List All Available Information 为 Yes。

汇编语言数据类型以及数据定义详解

汇编器识别一组基本的内部数据类型(intrinsic data type),按照数据大小(字节、字、双字等等)、是否有符号、是整数还是实数来描述其类型。这些类型有相当程度的重叠,例如,DWORD 类型(32 位,无符号整数)就可以和 SDWORD 类型(32 位,有符号整数)相互交换。

可能有人会说,程序员用 SDWORD 告诉读程序的人,这个值是有符号的,但是,对于汇编器来说这不是强制性的。汇编器只评估操作数的大小。因此,举例来说,程序员只能将 32 位整数指定为 DWORD、SDWORD 或者 REAL4 类型。

下表给出了全部内部数据类型的列表,有些表项中的 IEEE 符号指的是 IEEE 计算机学会出版的标准实数格式。

类型 用法
BYTE 8 位无符号整数,B 代表字节
SBYTE 8 位有符号整数,S 代表有符号
WORD 16 位无符号整数
SWORD 16 位有符号整数
DWORD 32 位无符号整数,D 代表双(字)
SDWORD 32 位有符号整数,SD 代表有符号双(字)
FWORD 48 位整数(保护模式中的远指针)
QWORD 64 位整数,Q 代表四(字)
TBYTE 80 位(10 字节)整数,T 代表 10 字节
REAL4 32 位(4 字节)IEEE 短实数
REAL8 64 位(8 字节)IEEE 长实数
REAL10 80 位(10 字节)IEEE 扩展实数

数据定义语句

数据定义语句(data definition statement)在内存中为变量留岀存储空间,并赋予一个可选的名字。数据定义语句根据内部数据类型(上表)定义变量。

数据定义语法如下所示:[name] directive initializer [,initializer]...

下面是数据定义语句的一个例子:count DWORD 12345

其中:

  • 名字:分配给变量的可选名字必须遵守标识符规范。

  • 伪指令:数据定义语句中的伪指令可以是 BYTE、WORD、DWORD、SBTYE、SWORD 或其他在上表中列出的类型。此外,它还可以是传统数据定义伪指令,如下表所示。

伪指令 用法 伪指令 用法
DB 8位整数 DQ 64 位整数或实数
DW 16 位整数 DT 定义 80 位(10 字节)整数
DD 32 位整数或实数

数据定义中至少要有一个初始值,即使该值为 0。其他初始值,如果有的话,用逗号分隔。对整数数据类型而言,初始值(initializer)是整数常量或是与变量类型,如 BYTE 或 WORD 相匹配的整数表达式。

如果程序员希望不对变量进行初始化(随机分配数值),可以用符号 ? 作为初始值。所有初始值,不论其格式,都由汇编器转换为二进制数据。 初始值 0011 0010b、32h 和 50d 都具有相同的二进制数值。

向 AddTwo 程序添加一个变量

现在创建一个新版本,并称为 AddTwoSum。这个版本引入了变量 sum,它出现在完整的程序清单中:

;AddTowSum.asm

.386
.model flat,stdcall
.stack 4096
ExitProcess PROTO, dwExitCode:DWORD

.data
sum DWORD 0

.code
main PROC
    mov eax,5
    add eax,6
    mov sum,eax

    INVOKE ExitProcess,0
main ENDP
END main

可以在第 13 行设置断点,每次执行一行,在调试器中单步执行该程序。执行完第 15 行后,将鼠标悬停在变量 sum 上,查看其值。或者打开一个 Watch 窗口,打开过程如下:在 Debug 菜单中选择 Windows(在调试会话中),选择 Watch,并在四个可用选项(Watch1,Watch2,Watch3 或 Watch4)中选择一个。然后,用鼠标高亮显示 sum 变量,将其拖拉到 Watch 窗口中。下图展示了一个例子,其中用大箭头指出了执行第 15 行后,sum 的当前值。

定义 BYTE 和 SBYTE 数据

BYTE(定义字节)和 SBYTE(定义有符号字节)为一个或多个无符号或有符号数值分配存储空间。每个初始值在存储时,都必须是 8 位的。例如:

value1 BYTE  'A'    ;字符常量
value2 BYTE  0      ;最小无符号字节
value3 BYTE  255    ;最大无符号字节
value4 SBYTE -128   ;最小有符号字节
value5 SBYTE +127   ;最大有符号字节

问号(?)初始值使得变量未初始化,这意味着在运行时分配数值到该变量:

value6 BYTE ?

可选名字是一个标号,标识从变量包含段的开始到该变量的偏移量。比如,如果 value1 在数据段偏移量为 0000 处,并在内存中占一个字节,则 value2 就自动处于偏移量为 0001 处:

value1 BYTE 10h
value2 BYTE 20h

DB 伪指令也可以定义有符号或无符号的 8 位变量:

val1 DB 255  ;无符号字节
val2 DB -128 ;有符号字节

1) 多初始值

如果同一个数据定义中使用了多个初始值,那么它的标号只指出第一个初始值的偏移量。在下面的例子中,假设 list 的偏移量为 0000。那么,数值 10 的偏移量就为 0000, 20 的偏移量为 0001,30 的偏移量为 0002,40 的偏移量为 0003:

list BYTE 10,20,30,40

并不是所有的数据定义都要用标号。比如,在 list 后面继续添加字节数组,就可以在下一行定义它们:

list BYTE 10,20,30,40
     BYTE 50,60,70,80
     BYTE 81,82,83,84

在单个数据定义中,其初始值可以使用不同的基数。字符和字符串常量也可以自由组合。在下面的例子中,list1 和 list2 有相同的内容:

list1 BYTE 10, 32, 41h, 00100010b
list2 BYTE 0Ah, 20h, 'A', 22h

2) 定义字符串

定义一个字符串,要用单引号或双引号将其括起来。最常见的字符串类型是用一个空字节(值为0)作为结束标记,称为以空字节结束的字符串,很多编程语言中都使用这种类型的字符串:

greeting1 BYTE "Good afternoon",0
greeting2 BYTE 'Good night',0

每个字符占一个字节的存储空间。对于字节数值必须用逗号分隔的规则而言,字符串是一个例外。如果没有这种例外,greeting1 就会被定义为:

greeting1 BYTE 'G', 'o', 'o', 'd'....etc.

这就显得很冗长。一个字符串可以分为多行,并且不用为每一行都添加标号:

greeting1 BYTE "Welcome to the Encryption Demo program "
          BYTE "created by Kip Irvine.",0dh, 0ah
          BYTE "If you wish to modify this program, please "
          BYTE "send me a copy.",0dh,0ah,0

十六进制代码 0Dh 和 0Ah 也被称为 CR/LF (回车换行符)或行结束字符。在编写标准输出时,它们将光标移动到当前行的下一行的左侧。

行连续字符(\)把两个源代码行连接成一条语句,它必须是一行的最后一个字符。下面的语句是等价的:

greeting1 BYTE "Welcome to the Encryption Demo program "

greeting1 \
BYTE "Welcome to the Encryption Demo program "

3) DUP 操作符

DUP 操作符使用一个整数表达式作为计数器,为多个数据项分配存储空间。在为字符串或数组分配存储空间时,这个操作符非常有用,它可以使用初始化或非初始化数据:

BYTE 20 DUP ( 0 )      ;20 个字节,值都为 0
BYTE 20 DUP ( ? )      ;20 个字节,非初始化
BYTE 4 DUP ( "STACK" ) ; 20 个字节:

定义 WORD 和 SWORD 数据

WORD(定义字)和 SWORD(定义有符号字)伪指令为一个或多个 16 位整数分配存储空间:

word1 WORD 65535    ;最大无符号数
word2 SWORD -32768  ;最小有符号数
word3 WORD ?        ;未初始化,无符号

也可以使用传统的 DW 伪指令:

val1 DW 65535   ;无符号
val2 DW -32768  ;有符号

16 位字数组通过列举元素或使用 DUP 操作符来创建字数组。下面的数组包含了一组数值:

myList WORD 1,2,3,4,5

DUP 操作符提供了一种方便的方法来声明数组:

array WORD 5 DUP (?) ; 5 个数值,未初始化

定义 DWORD 和 SDWORD 数据

DWORD(定义双字)和 SDWORD(定义有符号双字)伪指令为一个或多个 32 位整数分配存储空间:

val1 DWORD 12345678h    ;无符号
val2 SDWORD -2147483648 ;有符号
val3 DWORD 20 DUP (?)   ;无符号数组

传统的 DD 伪指令也可以用来定义双字数据:

val1 DD 12345678h ;无符号
val2 DD -2147483648 ;有符号

DWORD 还可以用于声明一种变量,这种变量包含的是另一个变量的 32 位偏移量。如下所示,pVal 包含的就是 val3 的偏移量:

pVal DWORD val3

32 位双字数组

现在定义一个双字数组,并显式初始化它的每 一个值:

myList DWORD 1,2,3,4,5

定义 QWORD 数据

QWORD(定义四字)伪指令为 64 位(8 字节)数值分配存储空间:

quad1 QWORD 1234567812345678h

传统的 DQ 伪指令也可以用来定义四字数据:

quad1 DQ 1234567812345678h

定义压缩 BCD(TBYTE)数据

Intel 把一个压缩的二进制编码的十进制(BCD, Binary Coded Decimal)整数存放在一个 10 字节的包中。每个字节(除了最高字节之外)包含两个十进制数字。在低 9 个存储字节中,每半个字节都存放了一个十进制数字。最高字节中,最高位表示该数的符号位。如果最高字节为 80h,该数就是负数;如果最高字节为 00h,该数就是正数。整数的范围是 -999 999 999 999 999 999 到 +999 999 999 999 999 999。

示例下表列出了正、负十进制数 1234 的十六进制存储字节,排列顺序从最低有效字节到最高有效字节:

十进制数值 存储字节
1234 34 12 00 00 00 00 00 00 00 00
-1234 34 12 00 00 00 00 00 00 00 80

MASM 使用 TBYTE 伪指令来定义压缩 BCD 变量。常数初始值必须是十六进制的,因为,汇编器不会自动将十进制初始值转换为 BCD 码。下面的两个例子展示了十进制 数 -1234 有效和无效的表达方式:

intVal TBYTE 800000000000001234h ;有效
intVal TBYTE -1234               ;无效

第二个例子无效的原因是 MASM 将常数编码为二进制整数,而不是压缩 BCD 整数。

如果想要把一个实数编码为压缩 BCD 码,可以先用 FLD 指令将该实数加载到浮点寄存器堆栈,再用 FBSTP 指令将其转换为压缩 BCD 码,该指令会把数值舍入到最接近的整数:

.data
posVal REAL8 1.5
bcdVal TBYTE ?
.code
fid posVal ;加载到浮点堆栈
fbstp bcdVal ;向上舍入到 2,压缩 BCD 码值

如果 posVal 等于 1.5,结果 BCD 值就是 2。

定义浮点类型

REAL4 定义 4 字节单精度浮点变量。REAL8 定义 8 字节双精度数值,REAL10 定义 10 字节扩展精度数值。每个伪指令都需要一个或多个实常数初始值:

rVal1 REAL4 -1.2
rVal2 REAL8 3.2E-260
rVal3 REAL10 4.6E+4096
ShortArray REAL4 20 DUP(0.0)

下表描述了标准实类型的最少有效数字个数和近似范围:

DD、DQ 和 DT 伪指令也可以定义实数:

rVal1 DD -1.2      ;短实数
rVal2 DQ 3.2E-260  ;长实数
rVal3 DT 4.6E+4096 ;扩展精度实数

MASM 汇编器包含了诸如 wal4 和 real8 的数据类型,这些类型表明数值是实数。更准确地说,这些数值是浮点数,其精度和范围都是有限的。从数学的角度来看,实数的精度和大小是无限的。

变量加法程序

到目前为止,本节的示例程序实现了存储在寄存器中的整数加法。现在已经对如何定义数据有了一些了解,那么可以对同样的程序进行修改,使之实现三个整数变量相加,并将和数存放到第四个变量中。

;AddTowSum.asm

.386
.model flat,stdcall
.stack 4096
ExitProcess PROTO, dwExitCode:DWORD

.data
firstval DWORD 20002000h
secondval DWORD 11111111h
thirdval DWORD 22222222h
sum DWORD 0

.code
main PROC
    mov eax,firstval
    add eax,secondval
    add eax,thirdval
    mov sum,eax

    INVOKE ExitProcess,0
main ENDP
END main

注意,已经用非零数值对三个变量进行了初始化(9〜11 行)。16〜18 行进行变量相加。x86 指令集不允许将一个变量直接与另一个变量相加,但是允许一个变量与一个寄存器相加。这就是为什么 16〜17 行用 EAX 作累加器的原因:

mov eax,firstval
add eax,secondval

第 17 行之后,EAX 中包含了 firstval 和 secondval 之和。接着,第 18 行把 thirdval 加到 EAX 中的和数上:

add eax,thirdval

最后,在第 19 行,和数被复制到名称为 sum 的变量中:

mov sum,eax

作为练习,鼓励大家在调试会话中运行本程序,并在每条指令执行后检查每个寄存器。最终和数应为十六进制的 53335333。

在调试会话过程中,如果想要变量显示为十六进制,则按下述步骤操作:鼠标在变量或寄存器上悬停 1 秒,直到一个灰色矩形框出现在鼠标下。右键点击该矩形框,在弹出菜单中选择 Hexadecimal Display。

小端顺序

x86 处理器在内存中按小端(little-endian)顺序(低到高)存放和检索数据。最低有效字节存放在分配给该数据的第一个内存地址中,剩余字节存放在随后的连续内存位置中。考虑一个双字 12345678h。如果将其存放在偏移量为 0000 的位置,则 78h 存放在第一个字节,56h 存放在第二个字节,余下的字节存放地址偏移量为 0002 和 0003。

其他有些计算机系统采用的是大端顺序(高到低)。 12345678h 从偏移量 0000 开始的大端顺序存放。

声明未初始化数据

.DATA ? 伪指令声明未初始化数据。当定义大量未初始化数据时,.DATA ? 伪指令减少了编译程序的大小。例如,下述代码是有效声明:

.data
smallArray DWORD 10 DUP (0) ;40 个字节
.data?
bigArray DWORD 5000 DUP ( ? ) ;20 000 个字节,未初始化

而另一方面,下述代码生成的编译程序将会多岀 20 000 个字节:

.data
smallArray DWORD 10 DUP ( 0 )  ; 40 个字节
bigArray DWORD 5000 DUP ( ? )  ; 20 000 个字节

代码与数据混合汇编器允许在程序中进行代码和数据的来回切换。比如,想要声明一个变量,使其只能在程序的局部区域中使用。下述示例在两个代码语句之间插入了一个名为 temp 的变量:

.code
mov eax,ebx
.data
temp DWORD ?
.code
mov temp,eax

尽管 temp 声明的出现打断了可执行指令流,MASM 还是会把 temp 放在数据段中,并与保持编译的代码段分隔开。然而同时,混用 .code 和 .data 伪指令会使得程序变得难以阅读。

汇编语言等号=伪指令

等号伪指令(equal-sign directive)把一个符号名称与一个整数表达式连接起来,其语法如下:

name = expression

通常,表达式是一个 32 位的整数值。当程序进行汇编时,在汇编器预处理阶段,所有出现的 name 都会被替换为 expression。假设下面的语句出现在一个源代码文件开始的位置:

COUNT = 500

然后,假设在其后 10 行的位置有如下语句:

mov eax, COUNT

那么,当汇编文件时,MASM 将扫描这个源文件,并生成相应的代码行:

mov eax, 500

为什么使用符号?

程序员可以完全跳过 COUNT 符号,简化为直接用常量 500 来编写 MOV 指令,但是经验表明,如果使用符号将会让程序更加容易阅读和维护。

设想,如果 COUNT 在整个程序中出现多次,那么,在之后的时间里,程序员就能方便地重新定义它的值:

COUNT = 600

假如再次对该源文件进行汇编,则所有的 COUNT 都将会被自动替换为 600。

当前地址计数器

最重要的符号之一被称为当前地址计数器(current location counter),表示为 $。例如,下面的语句声明了一个变量 selfPtr,并将其初始化为该变量的偏移量:

selfPtr DWORD $

键盘定义

程序通常定义符号来识别常用的数字键盘代码。比如,27 是 Esc 键的 ASCII 码:

Esc_key = 27

在该程序的后面,如果语句使用这个符号而不是整数常量,那么它会具有更强的自描述性。

使用

mov al,Esc_key ;好的编程风格

而非

mov al,27     ;不好的编程风格

使用DUP操作符

《数据定义》一节说明了怎样使用 DUP 操作符来存储数组和字符串。为了简化程序的维护,DUP 使用的计数器应该是符号计数器。

在下例中,如果已经定义了 COUNT,那么它就可以用于下面的数据定义中:

array dword COUNT DUP(0)

重定义

用“=”定义的符号,在同一程序内可以被重新定义。下例展示了当 COUNT 改变数值后,汇编器如何计算它的值:

COUNT = 5
mov al,COUNT ; AL = 5
COUNT = 10
mov al,COUNT ; AL = 10
COUNT = 100
mov al,COUNT ; AL = 100

符号值的改变,例如 COUNT,不会影响语句在运行时的执行顺序。相反,在汇编器预处理阶段,符号会根据汇编器对源代码处理的顺序来改变数值。

汇编语言计算数组和字符串长度

在使用数组时,通常会想要知道它的大小。下例使用常量 ListSize 来声明 list 的大小:

list BYTE 10,20,30,40
ListSize = 4

显式声明数组的大小会导致编程错误,尤其是如果后续还会插入或删除数组元素。声明数组大小更好的方法是,让汇编器来计算这个值。

$ 运算符(当前地址计数器)返回当前程序语句的偏移量。在下例中,从当前地址计数器($)中减去 list 的偏移量,计算得到 ListSize:

list BYTE 10,20,30,40
ListSize = ($ - list)

ListSize 必须紧跟在 list 的后面。下面的例子中,计算得到的 ListSize 值(24)就过大,原因是 var2 使用的存储空间,影响了当前地址计数器与 list 偏移量之间的距离:

list BYTE 10,20,30,40
var2 BYTE 20 DUP(?)
ListSize = ($ - list)

不要手动计算字符串的长度,让汇编器完成这个工作:

myString BYTE "This is a long string, containing"
               BYTE "any number of characters"
myString_len = ($ - myString)

字数组和双字数组

当要计算元素数量的数组中包含的不是字节时,就应该用数组总的大小(按字节计)除以单个元素的大小。比如,在下例中,由于数组中的每个字要占 2 个字节(16 位),因此,地址范围应该除以 2:

list WORD 1000h,2000h,3000h,4000h
ListSize = ($ - list) / 2

同样,双字数组中每个元素长 4 个字节,因此,其总长度除以 4 才能产生数组元素的个数:

list DWORD l0000000h,20000000h,30000000h,40000000h
ListSize = ($ -list) / 4

汇编语言EQU伪指令

EQU 伪指令把一个符号名称与一个整数表达式或一个任意文本连接起来,它有 3 种格式:

name EQU expression
name EQU symbol
name EQU <text>

第一种格式中,expression 必须是一个有效整数表达式。第二种格式中,symbol 是一个已存在的符号名称,已经用 = 或 EQU 定义过了。第三种格式中,任何文本都可以岀现在<…>内。当汇编器在程序后面遇到 name 时,它就用整数值或文本来代替符号。

在定义非整数值时,EQU 非常有用。比如,可以使用 EQU 定义实数常量:

PI EQU <3.1416>

【示例 1】下面的例子将一个符号与一个字符串连接起来,然后用该符号定义一个变量:

pressKey EQU <"Press any key to continue...", 0>
.data
prompt BYTE pressKey

【示例 2】假设想定义一个符号来计算一个 10 x 10 整数矩阵的元素个数。现在用两种不同的方法来进行符号定义,一种用整数表达式,一种用文本。然后把两个符号都用于数据定义:

matrix1 EQU 10 * 10
matrix2 EQU <10 * 10>
.data
M1 WORD matrix1
M2 WORD matrix2

汇编器将为 M1 和 M2 生成不同的数据定义。计算 matrix1 中的整数表达式,并将其赋给M1。而 matrix2 中的文本则直接复制到 M2 的数据定义中:

M1 WORD 100
M2 WORD 10 * 10

与 = 伪指令不同,在同一源代码文件中,用 EQU 定义的符号不能被重新定义。这个限制可以防止现有符号在无意中被赋予新值。

汇编语言TEXTEQU伪指令

TEXTEQU 伪指令,类似于 EQU,创建了文本宏(text macro)。它有 3 种格式:第一种为名称分配的是文本;第二种分配的是已有文本宏的内容;第三种分配的是整数常量表达式:

name TEXTEQU <text>
name TEXTEQU textmacro
name TEXTEQU %constExpr

例如,变量 prompt1 使用了文本宏 continueMsg:

continueMsg TEXTEQU <"Do you wish to continue (Y/N)?">
.data
prompt1 BYTE continueMsg

文本宏可以相互构建。如下例所示,count 被赋值了一个整数表达式,其中包含 rowSize。然后,符号 move 被定义为 mov。最后,用 move 和 count 创建 setupAL:

rowSize = 5
count TEXTEQU %(rowSize * 2)
move TEXTEQU <mov>
setupAL TEXTEQU <move al,count>

因此,语句

setupAL

就会被汇编为

mov al,10

用 TEXTEQU 定义的符号随时可以被重新定义。

汇编语言64位编程

MD 和 Intel 64 位处理器的出现增加了对 64 位编程的兴趣。MASM 支持 64 位代码,所有的 Visual Studio 2012 版本(最终版、高级版和专业版)以及桌面系统的 Visual Studio 2012 Express 都会同步安装 64 位版本的汇编器。

现在借助《数据定义》一节中给出的 AddTwoSum 程序,将其改为 64 位编程:

;AddTowSum_64.asm

ExitProcess PROTO

.data
sum DWORD 0

.code
main PROC
    mov eax,5
    add eax,6
    mov sum,eax

mov eax,0
    call ExitProcess
main ENDP
END

上述程序与之前给出的 32 位版本不同之处如下所示:

\1) 32 位 AddTwoSum 程序中使用了下列三行代码,而 64 位版本中则没有:

.386
.model flat,stdcall
.stack 4096

\2) 64 位程序中,使用 PROTO 关键字的语句不带参数,如第 3 行代码所示:

ExitProcess PROTO

32 位版本代码如下:

ExitProcess PROTO,dwExitCode:DWORD

\3) 14〜15 行使用了两条指令(mov 和 call)来结束程序。32 位版本则只使用了一条 INVOKE 语句实现同样的功能。64 位 MASM 不支持 INVOKE 伪指令。

\4) 在第 17 行,END 伪指令没有指定程序入口点,而 32 位程序则指定了。

使用 64 位寄存器

在某些应用中,可能需要实现超过 32 位的整数的算术运算。在这种情况下,可以使用 64 位寄存器和变量。例如,下述步骤让示例程序能使用 64 位数值:

  • 在第 6 行,定义 sum 变量时,把 DWORD 修改为 QWORD。

  • 在 10〜12 行,把 EAX 替换为其 64 位版本 RAX。

下面是修改后的 6〜12 行:

sum DWORD 0
.code
main PROC
   mov rax,5
   add rax,6
   mov sum,rax

编写 32 位还是 64 位汇编程序,很大程度上是个人喜好的问题。但是,需要记住:64 位 MASM 11.0 (Visual Studio 2012 附带的)不支持 INVOKE 伪指令。同时,为了运行 64 位程序,必须使用 64 位Windows。

汇编语言数据相关的运算符、指令和算术运算

汇编语言操作数类型

x86 的指令格式为:

[label:] mnemonic [operands][ ;comment ]

指令包含的操作数个数可以是:0 个,1 个,2 个或 3 个。这里,为了清晰起见,省略掉标号和注释:

mnemonic
mnemonic [destination]
mnemonic [destination] , [source]
mnemonic [destination] , [source-1] , [source-2]

操作数有 3 种基本类型:

  • 立即数——用数字文本表达式

  • 寄存器操作数——使用 CPU 内已命名的寄存器

  • 内存操作数——引用内存位置

下表说明了标准操作数类型,它使用了简单的操作数符号(32 位模式下),这些符号来自 Intel 手册并进行了改编。本教程将用这些符号来描述每条指令的语法。

操作数 说明
reg8 8 位通用寄存器:AH、AL、BH、BL、CH、CL、DH、DL
reg16 16 位通用寄存器:AX、BX、CX、DX、SI、DI、SP、BP
reg32 32 位通用寄存器:EAX、EBX、ECX、EDX、ESI、EDI、ESP、EBP
reg 通用寄存器
sreg 16 位段寄存器:CS、DS、SS、ES、FS、GS
imm 8 位、16 位或 32 位立即数
imm8 8 位立即数,字节型数值
imm16 16 位立即数,字类型数值
imm32 32 位立即数,双字型数值
reg/mem8 8 位操作数,可以是 8 位通用寄存器或内存字节
reg/mem16 16 位立即数,可以是 16 位通用寄存器或内存字
reg/mem32 32 位立即数,可以是 32 位通用寄存器或内存双字
mem 8位、16 位或 32 位内存操作数

直接内存操作数

变量名引用的是数据段内的偏移量。例如,如下变量 varl 的声明表示,该变量的大小类型为字节,值为十六进制的10:

.data
var1 BYTE 10h

可以编写指令,通过内存操作数的地址来解析(查找)这些操作数。假设 var1 的地址偏移量为 10400h。如下指令将该变量的值复制到 AL 寄存器中:

mov al var1

指令会被汇编为下面的机器指令:

A0 00010400

这条机器指令的第一个字节是操作代码(即操作码(opcode))。剩余部分是 var1 的 32 位十六进制地址。虽然编程时有可能只使用数字地址,但是如同 var1 一样的符号标号会让使用内存更加容易。

另一种表示法。一些程序员更喜欢使用下面这种直接操作数的表达方式,因为,括号意味着解析操作:

mov al, [var1]

MASM 允许这种表示法,因此只要愿意就可以在程序中使用。由于多数程序(包括 Microsoft 的程序)印刷时都没有用括号,所以,本书只在出现算术表达式时才使用这种带括号的表示法:

mov al,[var1 + 5]

汇编语言MOV指令:将源操作数复制到目的操作数

MOV 指令将源操作数复制到目的操作数。作为数据传送(data transfer)指令,它几乎用在所有程序中。在它的基本格式中,第一个操作数是目的操作数,第二个操作数是源操作数:

MOV destination,source

其中,目的操作数的内容会发生改变,而源操作数不会改变。这种数据从右到左的移动与 C++ 或 Java 中的赋值语句相似:

dest = source;

在几乎所有的汇编语言指令中,左边的操作数是目标操作数,而右边的操作数是源操作数。只要按照如下原则,MOV 指令使用操作数是非常灵活的。

  • 两个操作数必须是同样的大小。

  • 两个操作数不能同时为内存操作数。

  • 指令指针寄存器(IP、EIP 或 RIP)不能作为目标操作数。

下面是 MOV 指令的标准格式:

MOV reg, reg
MOV mem, reg
MOV reg, mem
MOV mem, imm
MOV reg, imm

内存到内存

单条 MOV 指令不能用于直接将数据从一个内存位置传送到另一个内存位置。相反,在将源操作数的值赋给内存操作数之前,必须先将该数值传送给一个寄存器:

.data
var1 WORD ?
var2 WORD ?
.code
mov ax,var1
mov var2,ax

提示:在将整型常数复制到一个变量或寄存器时,必须考虑该常量需要的最少字节数。

覆盖值

下述代码示例演示了怎样通过使用不同大小的数据来修改同一个 32 位寄存器。当 oneWord 字传送到 AX 时,它就覆盖了 AL 中已有的值。当 oneDword 传送到 EAX 时,它就覆盖了 AX 的值。最后,当 0 被传送到 AX 时,它就覆盖了 EAX 的低半部分。

.data
oneByte BYTE 78h
oneWord WORD 1234h
oneDword DWORD 12345678h
.code
mov eax,0                                 ;EAX=OOOOOOOOh
mov al,oneByte                            ;EAX=00000078h
mov ax,oneWord                            ;EAX=00001234h
mov eax,oneDword                          ;EAX=12345678h
mov ax, 0                                 ;EAX=12340000h

汇编语言MOVZX和MOVSX指令

尽管 MOV 指令不能直接将较小的操作数复制到较大的操作数中,但是程序员可以想办法解决这个问题。假设要将 count(无符号,16 位)传送到 ECX(32 位),可以先将 ECX 设置为 0,然后将 count 传送到 CX:

.data
count WORD 1
.code
mov ecx,0
mov cx,count

如果对一个有符号整数 -16 进行同样的操作会发生什么呢?

.data
signedVal SWORD -16      ; FFF0h (-16)
.code
mov ecx,0
mov cx,signedVal         ; ECX = 0000FFF0h(+ 65,52 0)

ECX 中的值(+65 520)与 -16 完全不同。但是,如果先将 ECX 设置为 FFFFFFFFh,然后再把 signedVal 复制到 CX,那么最后的值就是完全正确的:

mov ecx,0FFFFFFFFh
mov cx,signedVal    ;ECX = FFFFFFF0h(-16)

本例的有效结果是用源操作数的最高位(1)来填充目的操作数 ECX 的高 16 位,这种技术称为符号扩展(sign extension)。当然,不能总是假设源操作数的最高位是 1。幸运的是,Intel 的工程师在设计指令集时已经预见到了这个问题,因此,设置了 MOVZX 和 MOVSX 指令来分别处理无符号整数和有符号整数。

MOVZX 指令

MOVZX 指令(进行全零扩展并传送)将源操作数复制到目的操作数,并把目的操作数 0 扩展到 16 位或 32 位。这条指令只用于无符号整数,有三种不同的形式:

MOVZX reg32,reg/mem8
MOVZX reg32,reg/mem16
MOVZX reg16,reg/mem8

在三种形式中,第一个操作数(寄存器)是目的操作数,第二个操作数是源操作数。注意,源操作数不能是常数。下例将二进制数 1000 1111 进行全零扩展并传送到 AX:

.data
byteVal BYTE 10001111b
.code
movzx ax,byteVal ;AX = 0000000010001111b

下图展示了如何将源操作数进行全零扩展,并送入 16 位目的操作数。

下面例子的操作数是各种大小的寄存器:

mov bx, 0A69Bh
movzx eax, bx     ;EAX = 0000A69Bh
movzx edx, bl     ;EDX = 0000009Bh
movzx cx, bl     ;CX = 009Bh

下面例子的源操作数是内存操作数,执行结果是一样的:

.data
byte1 BYTE  9Bh
word1 WORD 0A69Bh
.code
movzx eax, word1 ;EAX = 0000A69Bh
movzx edx, byte1 ;EDX = 0000009Bh
movzx ex, byte1     ;CX = 009Bh

MOVSX 指令

MOVSX 指令(进行符号扩展并传送)将源操作数内容复制到目的操作数,并把目的操作数符号扩展到 16 位或 32 位。这条指令只用于有符号整数,有三种不同的形式:

MOVSX reg32, reg/mem8
MOVSX reg32, reg/mem16
MOVSX reg16, reg/mem8

操作数进行符号扩展时,在目的操作数的全部扩展位上重复(复制)长度较小操作数的最高位。下面的例子是将二进制数 1000 1111b 进行符号扩展并传送到 AX:

.data
byteVal BYTE 10001111b
.code
movsx ax,byteVal      ;AX = 1111111110001111b

如下图所示,复制最低 8 位,同时,将源操作数的最高位复制到目的操作数高 8 位的每一位上。

如果一个十六进制常数的最大有效数字大于 7,那么它的最高位等于 1。如下例所示,传送到 BX 的十六进制数值为 A69B,因此,数字“A”就意味着最高位是 1。(A69B 前面的 0 是一种方便的表示法,用于防止汇编器将常数误认为标识符。)

汇编语言LAHF和SAHF指令

LAHF(加载状态标志位到 AH)指令将 EFLAGS 寄存器的低字节复制到 AH。被复制的标志位包括:符号标志位、零标志位、辅助进位标志位、奇偶标志位和进位标志位。使用这条指令,可以方便地把标志位副本保管在变量中:

.data
saveflags BYTE ?
.code
lahf                      ;将标志位加载到 AH
mov saveflags, ah         ;用变量保存这些标志位

SAHF(保存 AH 内容到状态标志位)指令将 AH 内容复制到 EFLAGS(或 RFLAGS)寄存器低字节。例如,可以检索之前保存到变量中的标志位数值:

mov ah, saveflags  ;加载被保存标志位到 AH
sahf                        ;复制到 FLAGS 寄存器

汇编语言XCHG指令:交换两个操作数内容

XCHG(交换数据)指令交换两个操作数内容。该指令有三种形式:

XCHG reg, reg
XCHG reg, mem
XCHG mem, reg

除了 XCHG 指令不使用立即数作操作数之外,XCHG 指令操作数的要求与《MOV指令》一节中介绍的 MOV 指令操作数要求是一样的。

在数组排序应用中,XCHG 指令提供了一种简单的方法来交换两个数组元素。下面是几个使用 XCHG 指令的例子。

xchg ax,bx      ;交换 16 位寄存器内容
xchg ah,al      ;交换 8 位寄存器内容
xchg var1,bx    ;交换 16 位内存操作数与 BX 寄存器内容
xchg eax,ebx    ;交换 32 位寄存器内容

如果要交换两个内存操作数,则用寄存器作为临时容器,把 MOV 指令与 XCHG 指令一起使用:

mov ax,val1
xchg ax,val2
mov val1,ax

汇编语言直接偏移量操作数

变量名加上一个位移就形成了一个直接 - 偏移量操作数。这样可以访问那些没有显式标记的内存位置。假设现有一个字节数组 arrayB:

arrayB BYTE 10h,20h,30h,40h,50h

用该数组作为 MOV 指令的源操作数,则自动传送数组的第一个字节:

mov al,arrayB         ;AL = 10h

通过在 arrayB 偏移量上加 1 就可以访问该数组的第二个字节:

mov al,[arrayB+1]      ;AL = 20h

如果加 2 就可以访问该数组的第三个字节:

mov al,[arrayB+2]      ;AL = 30h

形如 arrayB+1 一样的表达式通过在变量偏移量上加常数来形成所谓的有效地址。有效地址外面的括号表明,通过解析这个表达式就可以得到该内存地址指示的内容。汇编器并不要求在地址表达式之外加括号,但为了清晰明了,建议使用括号。

MASM 没有内置的有效地址范围检查。在下面的例子中,假设数组 arrayB 有 5 个字节,而指令访问的是该数组范围之外的一个内存字节。其结果是一种难以发现的逻辑错误,因此,在检查数组引用时要非常小心:

mov al, [arrayB+20]              ; AL = ??

字和双字数组

在 16 位的字数组中,每个数组元素的偏移量比前一个多 2 个字节。这就是为什么在下面的例子中,数组 ArrayW 加 2 才能指向该数组的第二个元素:

.data
arrayW WORD 100h,200h,300h
.code
mov ax, arrayW                               ;AX = 100h
mov ax,[arrayW+2]                            ;AX = 200h

同样,如果是双字数组,则第一个元素偏移量加 4 才能指向第二个元素:

.data
arrayD DWORD l0000h,20000h
.code
mov eax, arrayD                            ;EAX = 10000h
mov eax,[arrayD+4]                         ;EAX = 20000h

汇编语言数据传送示例

该程序中包含了到此介绍的所有指令,包括:MOV、XCHG、MOVSX 和 MOVZX,展示了字节、字和双字是如何受到它们的影响。同时,程序中还包括了一些直接 - 偏移量操作数。

;数据传送示例
.386
.model flat,stdcall
.stack 4096
ExitProcess PROTO,dwExitCode:DWORD
.data
val1 WORD 1000h
val2 WORD 2000h
arrayB BYTE 10h,20h,30h,40h,50h
arrayW WORD 100h,200h,300h
arrayD DWORD 10000h,20000h
.code
main PROC
;演示 MOVZX 指令
    mov bx,0A69Bh
    movzx eax,bx        ;EAX = 0000A69Bh
    movzx edx,bl        ;EDX = 0000009Bh
    movzx cx,bl         ;CX     = 009Bh
;演示 MOVSX 指令
    mov bx,0A69Bh
    movsx eax,bx        ;EAX = FFFFA69Bh
    movsx edx,bl        ;EDX = FFFFFF9Bh
    mov bl,7Bh
    movsx cx,bl         ;CX = 007Bh
;内存-内存的交换
    mov ax,val1         ;AX = 1000h
    xchg ax val2        ;AX = 2000h,val2 = 1000h
    mov val1,ax         ;val1 = 2000h
;直接-偏移量寻址(字节数组)
    mov al,arrayB        ;AL = 10h
    mov al,[arrayB+1]    ;AL = 20h
    mov al,[arrayB+2]    ;AL = 30h
;直接-偏移量寻址(字数组)
    mov ax,arrayW        ;AX = 100h
    mov ax,[arrayW+2]    ;AX = 200h
;直接-偏移量寻址(双字数组)
    mov eax,arrayD        ;EAX = 10000h
    mov eax,[arrayD+4]    ;EAX = 20000h
    mov eax,[arrayD+4]    ;EAX = 20000h
    INVOKE ExitProcess,0
main ENDP
END main

该程序不会产生屏幕输出,但是可以用调试器(debugger)运行。

在 Visual Studio 调试器中显示 CPU 标志位

在调试期间显示 CPU 状态标志位时,在 Debug 菜单中选择 Windows 子菜单,再选择 Register。在 Register 窗口,右键选择下拉列表中的 Flags。要想查看这些菜单选项,必须调试程序。下表是 Register 窗口中用到的标志位符号:

标志名称 溢岀 方向 中断 符号 辅助进位 奇偶 进位
符号 OV UP EI PL ZR AC PE CY

每个标志位有两个值:0(清除)或 1(置位)。示例如下:

OV = 0    UP = 0     EI = 1
PL = 0   ZR = 1   AC = 0
PE = 1   CY = 0   

调试程序期间,当逐步执行代码时,指令只要修改了标志位的值,则标志位就会显示为红色。这样就可以通过单步执行来了解指令是如何影响标志位的,并可以密切关注这些标志位值的变化。

汇编语言加法和减法详解

先从最简单、最有效的指令开始:INC(增加)和 DEC(减少)指令,即加 1 和减 1。然后是能提供更多操作的 ADD、SUB 和 NEG(非)指令。最后,将讨论算术运算指令如何影响 CPU 状态标志位(进位位、符号位、零标志位等)。请记住,汇编语言的细节很重要。

INC 和 DEC 指令

INC(增加)和DEC(减少)指令分别表示寄存器或内存操作数加 1 和减 1。语法如下所示:

INC reg/mem
DEC reg/mem

下面是一些例子:

.data
myWord WORD 1000h
.code
inc myWord                         ; myWord = 1001h
mov bx,myWord
dec bx                             ; BX = 1000h

根据目标操作数的值,溢岀标志位、符号标志位、零标志位、辅助进位标志位、进位标志位和奇偶标志位会发生变化。INC 和 DEC 指令不会影响进位标志位(这还真让人吃惊)。

ADD 指令

ADD 指令将长度相同的源操作数和目的操作数进行相加操作。语法如下:

ADD dest,source

在操作中,源操作数不能改变,相加之和存放在目的操作数中。该指令可以使用的操作数与 MOV 指令相同。下面是两个 32 位整数相加的短代码示例:

.data
var1 DWORD 10000h
var2 DWORD 20000h
.code
mov eax,var1    ; EAX = 10000h
add eax,var2    ; EAX = 30000h

标志位:进位标志位、零标志位、符号标志位、溢出标志位、辅助进位标志位和奇偶标 志位根据存入目标操作数的数值进行变化。

SUB 指令

SUB 指令从目的操作数中减去源操作数。该指令对操作数的要求与 ADD 和 MOV 指令相同。指令语法如下:

SUB dest, source

下面是两个 32 位整数相减的短代码示例:

.data
var1 DWORD 30000h
var2 DWORD 10000h
.code
mov eax,var1         ;EAX = 30000h
sub eax,var2         ;EAX = 20000h

标志位:进位标志位、零标志位、符号标志位、溢出标志位、辅助进位标志位和奇偶标 志位根据存入目标操作数的数值进行变化。

NEG 指令

NEG(非)指令通过把操作数转换为其二进制补码,将操作数的符号取反。下述操作数可以用于该指令:

NEG reg
NEG mem

提示:将目标操作数按位取反再加 1,就可以得到这个数的二进制补码。

标志位:进位标志位、零标志位、符号标志位、溢出标志位、辅助进位标志位和奇偶标志位根据存入目标操作数的数值进行变化。

执行算术表达式

使用 ADD、SUB 和 NEG 指令,就有办法来执行汇编语言中的算术表达式,包括加法、减法和取反。换句话说,当有下述表达式时,就可以模拟 C++ 编译器的行为:

Rval = -Xval + (Yval - Zval);

现在来看看,使用如下有符号 32 位变量,汇编语言是如何执行上述表达式的。

Rval SDWORD ?
Xval SDWORD 26
Yval SDWORD 30
Zval SDWORD 40

转换表达式时,先计算每个项,最后再将所有项结合起来。首先,对 Xval 的副本进行取反,并存入寄存器:

; first term: -Xval
mov eax,Xval
neg eax                            ; EAX = -26

然后,将 Yval 复制到寄存器中,再减去 Zval:

; second term: (Yval - Zval)
mov ebx,Yval
sub ebx,Zval                    ; EBX = -10

最后,将两个项(EAX 和 EBX 的内容)相加:

; add the terms and store:
add eax,ebx
mov Rval,eax                    ; -36

加减法影响的标志位

执行算术运算指令时,常常想要了解结果。它是负数、正数还是零?对目的操作数来说,它是太大,还是太小?这些问题的答案有助于发现计算错误,否则可能会导致程序的错误行为。

检查算术运算结果使用的是 CPU 状态标志位的值,同时,这些值还可以触发条件分支指令,即基本的程序逻辑工具。下面是对状态标志位的简要概述:

  • 进位标志位意味着无符号整数溢出。比如,如果指令目的操作数为 8 位,而指令产生的结果大于二进制的 1111 1111,那么进位标志位置 1。

  • 溢出标志位意味着有符号整数溢出。比如,指令目的操作数为 16 位,但其产生的负数结果小于十进制的 -32 768,那么溢出标志位置 1。

  • 零标志位意味着操作结果为 0。比如,如果两个值相等的操作数相减,则零标志位置 1。

  • 符号标志位意味着操作产生的结果为负数。如果目的操作数的最高有效位(MSE)置 1,则符号标志位置 1。

  • 奇偶标志位是指,在一条算术或布尔运算指令执行后,立即判断目的操作数最低有效字节中 1 的个数是否为偶数。

  • 辅助进位标志位置 1,意味着目的操作数最低有效字节中位 3 有进位。

要在调试时显示 CPU 状态标志位,打开 Register 窗口,右键点击该窗口,并选择 Flags。

1) 无符号数运算:零标志位、进位标志位和辅助进位标志位

当算术运算结果等于 0 时,零标志位置 1。下面的例子展示了执行 SUB、INC 和 DEC 指令后,目的寄存器和零标志位的状态:

mov ecx,1
sub ecx,1                          ;ECX = 0, ZF = 1
mov eax,0FFFFFFFFh
inc    eax                         ;EAX =    0,    ZF    =    1
inc    eax                         ;EAX =    1,    ZF    =    0
dec    eax                         ;EAX =    0,    ZF    =    1

加法和进位标志位,如果将加法和减法分开考虑,那么进位标志位的操作是最容易解释的。两个无符号整数相加时,进位标志位是目的操作数最高有效位进位的副本。直观地说,如果和数超过了目的操作数的存储大小,就可以认为 CF = 1。在下面的例子里,ADD 指令将进位标志位置 1,原因是,相加的和数(100h)超过了 AL 的大小:

mov al,0FFh
add al,1              ; AL = 00, CF = 1

下图演示了在 0FFh 上加 1 时,操作数的位是如何变化的。AL 最高有效位的进位复制到进位 标志位。

另一方面,如果 AX 的值为 00FFh,则对其进行加 1 操作后,和数不会超过 16 位,那么进位标志位清 0:

mov ax,00FFh
add ax, 1           ; AX = 0100h, CF = 0

但是,如果 AX 的值为 FFFFh,则对其进行加 1 操作后,AX 的高位就会产生进位:

mov ax,0FFFFh
add ax, 1           ; AX = 0000, CF = 1

减法和进位标志位,从较小的无符号整数中减去较大的无符号整数时,减法操作就会将进位标志位置 1。下图说明了,操作数为 8 位时,计算(1-2)会出现什么情况。下面是相应的汇编代码:

mov al, 1
sub al, 2            ; AL = FFh, CF = 1

提示:INC 和 DEC 指令不会影响进位标志位。在非零操作数上应用 NEG 指令总是会将进位标志位置 1。

辅助进位标志位,辅助进位(AC)标志位意味着目的操作数位 3 有进位或借位。它主要用于二进制编码的十进制数(BCD)运算,也可以用于其他环境。现在,假设计算(1+0Fh),和数在位 4 上为 1,这是位 3 的进位:

mov al,0Fh
add al, 1           ; AC = 1

计算过程如下:

   00001111
+  00000001
--------------
   00010000

奇偶标志位,目的操作数最低有效字节中 1 的个数为偶数时,奇偶(PF)标志位置 1。下例中,ADD 和 SUB 指令修改了 AL 的奇偶性:

mov al,10001100b
add al,00000010b    ; AL = 10001110, PF = 1
sub al,10000000b    ; AL = 00001110, PF = 0

执行了 ADD 指令后,AL 的值为 1000 1110 (4 个 0, 4 个 1), PF=1。执行了 SUB 指令后,AL 的值包含了奇数个 1,因此奇偶标志位等于 0。

2) 有符号数运算:符号标志位和溢出标志位

符号标志位,有符号数算术操作结果为负数,则符号标志位置 1。下面的例子展示的是小数(4)减去大数(5):

mov eax, 4
sub eax,5        ; EAX = -1, SP = 1

从机器的角度来看,符号标志位是目的操作数高位的副本。下面的例子表示产生了负数结果后,BL 中的十六进制的值:

mov bl,1        ; BL = 01h
sub bl,2        ; BL = FFh(-1), SF = 1

溢出标志位,有符号数算术操作结果与目的操作数相比,如果发生上溢或下溢,则溢出标志位置 1。例如,8 位有符号整数的最大值为 +127,再加 1 就会溢出:

mov al,+127
add al, 1       ; OF = 1

同样,最小的负数为-128,再减1就发生下溢。如果目的操作数不能容纳一个有效算 术运算结果,那么溢出标志位置 1:

mov al,-128
sub al,1        ;OF = 1

加法测试,两数相加时,有个很简单的方法可以判断是否发生溢出。溢出发生的情况有:

  • 两个正数相加,结果为负数

  • 两个负数相加,结果为正数

如果两个加数的符号相反,则不会发生溢出。

硬件如何检测溢出,加法或减法操作后,CPU 用一种有趣的机制来检测溢出标志位的状态。计算结果的最高有效位产生的进位与结果的最高位进行 异或操作,异或的结果存入溢岀标志位。如下图所示,两个 8 位二进制数 1000 0000 和 1111 1110 相加,产生进位 CF=1,和数最高位(位 7)= 0,即 1 XOR 0=1,则 OF=1。

NEG 指令,如果 NEG 指令的目的操作数不能正确存储,则该结果是无效的。例如, AL 中存放的是 -128,对其求反,正确的结果为 +128,但是这个值无法存入 AL。则溢出标志位置 1 就表示 AL 中存放的是一个无效的结果:

mov al,-128         ;AL = 10000000b
neg al              ;AL = 10000000b, OF = 1

反之,如果对 +127 求反,结果是有效的,则溢出标志位清 0:

mov al,+127         ;AL = 01111111b
neg al              ;AL = 10000001b, OF = 0

CPU 如何知道一个算术运算是有符号的还是无符号的?答案看上去似乎有点愚蠢:它不知道!在算术运算之后,不论标志位是否与之相关,CPU 都会根据一组布尔规则来设置所有的状态标志位。程序员要根据执行操作的类型,来决定哪些标志位需要分析,哪些可以忽略。

示例程序(AddSubTest)

AddSubTest 程序利用 ADD、SUB、INC、DEC 和 NEG 指令执行各种算术运算表达式,并展示了相关状态标志位是如何受到影响的:

;加法和减法   (AddSubTest.asm)
.386
.model flat,stdcall
.stack 4096
ExitProcess proto,dwExitCode:dword
.data
Rval    SDWORD ?
Xval    SDWORD 26
Yval    SDWORD 30
Zval    SDWORD 40
.code
main PROC
    ;INC和DEC
    mov    ax,1000h
    inc    ax        ;1001h
    dec    ax        ;1000h
    ;表达式:Rval=-Xval+(Yval-Zval)
    mov    eax,Xval
    neg    eax        ;-26
    mov    ebx,Yval
    sub    ebx,Zval   ;-10
    add    eax,ebx
    mov    Rval,eax;36
    ;零标志位示例
    mov    cx,1
    sub    cx,1       ;ZF = 1
    mov    ax,0FFFFh
    inc    ax         ;ZF = 1
    ;符号标志位示例
    mov    cx,0
    sub    cx,1       ;SF = 1
    mov    ax,7FFFh
    add    ax,2       ;SF = 1
    ;进位标志位示例
    mov    al,0FFh
    add    al,1       ;CF = 1,AL = 00
    ;溢出标志位示例
    mov    al,+127
    add    al,1       ;OF = 1
    mov    al,-128
    sub    al,1       ;OF = 1
    INVOKE ExitProcess,0
main ENDP
END main

汇编语言OFFSET运算符:返回数据标号的偏移量

OFFSET 运算符返回数据标号的偏移量。这个偏移量按字节计算,表示的是该数据标号距离数据段起始地址的距离。如下图所示为数据段内名为 myByte 的变量。

OFFSET 示例

在下面的例子中,将用到如下三种类型的变量:

.data
bVal BYTE ?
wVal WORD ?
dVal DWORD ?
dVal2 DWORD ?

假设 bVal 在偏移量为 0040 4000(十六进制)的位置,则 OFFSET 运算符返回值如下:

mov esi,OFFSET bVal             ; ESI = 00404000h
mov esi,OFFSET wVal             ; ESI = 00404001h
mov esi,OFFSET dVal             ; ESI = 00404003h
mov esi,OFFSET dVal2            ; ESI = 00404007h

OFFSET 也可以应用于直接 - 偏移量操作数。设 myArray 包含 5 个 16 位的字。下面的 MOV 指令首先得到 myArray 的偏移量,然后加 4,再将形成的结果地址直接传送给 ESI。因此,现在可以说 ESI 指向数组中的第 3 个整数。

.data
myArray WORD 1,2,3,4,5
.code
mov esi,OFFSET myArray + 4

还可以用一个变量的偏移量来初始化另一个双字变量,从而有效地创建一个指针。如下例所示,pArray 就指向 bigArray 的起始地址:

.data
bigArray DWORD 500 DUP (?)
pArray DWORD bigArray

下面的指令把该指针的值加载到 ESI 中,因此,这个 ESI 寄存器就可以指向数组的起始地址:

mov esi,pArray

汇编语言ALIGN伪指令:对齐一个变量

ALIGN 伪指令将一个变量对齐到字节边界、字边界、双字边界或段落边界。

语法如下:

ALIGN bound

Bound 可取值有:1、2、4、8、16。当取值为 1 时,则下一个变量对齐于 1 字节边界(默认情况)。当取值为 2 时,则下一个变量对齐于偶数地址。当取值为 4 时,则下一个变量地址为 4 的倍数。当取值为 16 时,则下一个变量地址为 16 的倍数,即一个段落的边界。

为了满足对齐要求,汇编器会在变量前插入一个或多个空字节。为什么要对齐数据?因为,对于存储于偶地址和奇地址的数据来说,CPU 处理偶地址数据的速度要快得多。

下述例子中,bVal 处于任意位置,但其偏移量为 0040 4000。在 wVal 之前插入 ALIGN 2 伪指令,这使得 wVal 对齐于偶地址偏移量:

bVal BYTE ?           ;00404000h
ALIGN 2 
wVal WORD ?           ;00404002h
bVal2 BYTE ?          ;00404004h
ALIGN 4 
dVal DWORD ?          ;00404008h
dVal2 DWORD ?         ;0040400Ch

请注意,dVal 的偏移量原本是 0040 4005,但是 ALIGN 4 伪指令使它的偏移量成为 0040 4008。

汇编语言PTR运算符:重写操作数的大小类型

PTR 运算符可以用来重写一个已经被声明过的操作数的大小类型。只要试图用不同于汇编器设定的大小属性来访问操作数,那么这个运算符就是必需的。

例如,假设想要将一个双字变量 myDouble 的低 16 位传送给 AX 由于操作数大小不匹配,因此,汇编器不会允许这种操作:

.data
myDouble DWORD 12345678h
.code
mov ax,myDouble

但是,使用 WORD PTR 运算符就能将低位字(5678h)送入 AX:

mov ax,WORD PTR myDouble

为什么送入 AX 的不是 1234h ?因为,x86 处理器采用的是小端存储格式,即低位字节存放于变量的起始地址。如下图所示,用三种方式表示 myDouble 的内存布局:第一列是一个双字,第二列是两个字(5678h、1234h),第三列是四个字节(78h、56h、34h、12h)。

不论该变量是如何定义的,都可以用三种方法中的任何一种来访问内存。比如,如果 myDouble 的偏移量为 0000,则以这个偏移量为首地址存放的 16 位值是 5678h。同时也可以检索到 1234h,其字地址为 myDouble+2,指令如下:

mov ax,WORD PTR [myDouble+2]     ; 1234h

同样,用 BYTE PTR 运算符能够把 myDouble 的单个字节传送到 BL:

mov b1,BYTE PTR myDouble       ; 78h

注意,PTR 必须与一个标准汇编数据类型一起使用,这些类型包括:BYTE、SEYTE、WORD、SWORD、DWORD、SDWORD、FWORD、QWORD 或 TBYTE。

将较小的值送入较大的目的操作数

程序可能需要将两个较小的值送入一个较大的目的操作数。如下例所示,第一个字复制到 EAX 的低半部分,第二个字复制到高半部分。而 DWORD PTR 运算符能实现这种操作:

.data
wordList WORD 5678h,1234h
.code
mov eax, DWORD PTR wordList      ; EAX = 12345678h

汇编语言TYPE运算符:返回变量的大小

TYPE 运算符返回变量单个元素的大小,这个大小是以字节为单位计算的。比如,TYPE 为字节,返回值是 1;TYPE 为字,返回值是 2;TYPE 为双字,返回值是 4;TYPE 为四字,返回值是 8。示例如下:

.data
var1 BYTE ?
var2 WORD ?
var3 DWORD ?
var4 QWORD ?

下表是每个 TYPE 表达式的值。

表达式 表达式
TYPE var1 1 TYPE var3 4
TYPE var2 2 TYPE var4 8

汇编语言LENGTHOF运算符:计算数组中元素的个数

LENGTHOF 运算符计算数组中元素的个数,元素个数是由数组标号同一行出现的数值来定义的。示例如下:

.data 
byte1 BYTE  10,20,30
array1 WORD  30 DUP (?),0,0
array2 WORD 5 DUP(3 DUP(?))
array3 DWORD 1,2,3,4
digitStr  BYTE "12345678",0

如果数组定义中出现了嵌套的 DUP 运算符,那么 LENGTHOF 返回的是两个数值的乘积。下表列出了每个 LENGTHOF 表达式返回的数值。

表达式 表达式
LENGTHOF byte1 3 LENGTHOF array3 4
LENGTHOF array1 30+2 LENGTHOF digitStr 9
LENGTHOF array2 5*3

如果数组定义占据了多个程序行,那么 LENGTHOF 只针对第一行定义的数据。比如有如下数据,则 LENGTHOF myArray 返回值为 5 :

myArray BYTE 10,20,30,40,50
        BYTE 60,70,80,90,100

另外,也可以在第一行结尾处用逗号,并在下一行继续进行数组初始化。若有如下数据定义, LENGTHOF myArray 返回值为 10:

myArray BYTE 10,20,30,40,50,
             60,70,80,90,100

汇编语言LABEL伪指令

LABEL 伪指令可以插入一个标号,并定义它的大小属性,但是不为这个标号分配存储空间。LABEL 中可以使用所有的标准大小属性,如 BYTE、WORD、DWORD、QWORD 或 TBYTE。

LABEL 常见的用法是,为数据段中定义的下一个变量提供不同的名称和大小属性。如下例所示,在变量 val32 前定义了一个变量,名称为 val16 属性为 WORD:

.data
val16 LABEL WORD
val32 DWORD 12345678h
.code
mov ax,val16          ; AX = 5678h
mov dx,[val16+2]      ; DX = 1234h

val16 与 val32 共享同一个内存位置。LABEL 伪指令自身不分配内存。

有时需要用两个较小的整数组成一个较大的整数,如下例所示,两个 16 位变量组成一个 32 位变量并加载到 EAX 中:

.data
LongValue LABEL DWORD
val1 WORD 5678h
val2 WORD 1234h
.code
mov eax,LongValue         ; EAX = 12345678h

汇编语言间接寻址

直接寻址很少用于数组处理,因为,用常数偏移量来寻址多个数组元素时,直接寻址不实用。反之,会用寄存器作为指针(称为间接寻址)并控制该寄存器的值。如果一个操作数使用的是间接寻址,就称之为间接操作数。

间接操作数

保护模式

任何一个 32 位通用寄存器(EAX、EBX、ECX、EDX、ESI、EDI、EBP 和 ESP)加上括号就能构成一个间接操作数。

寄存器中存放的是数据的地址。示例如下,ESI 存放的是 byteVal 的偏移量,MOV 指令使用间接操作数作为源操作数,解析 ESI 中的偏移量,并将一个字节送入 AL:

.data
byteVal BYTE 10h
.code
mov esi,OFFSET byteVal
mov al,[esi]                              ; AL = 10h

如果目的操作数也是间接操作数,那么新值将存入由寄存器提供地址的内存位置。在下面的例子中,BL 寄存器的内容复制到 ESI 寻址的内存地址中:

mov [esi],bl

PTR 与间接操作数一起使用

一个操作数的大小可能无法从指令中直接看出来。下面的指令会导致汇编器产生“operand must have size(操作数必须有大小)”的错误信息:

inc [esi]    ;错误:operand must have size

汇编器不知道 ESI 指针的类型是字节、字、双字,还是其他的类型。而 PTR 运算符则可以确定操作数的大小类型:

inc BYTE PTR [esi]

数组

间接操作数是步进遍历数组的理想工具。下例中,arrayB 有 3 个字节,随着 ESI 不断加 1,它就能顺序指向每一个字节:

.data
arrayB BYTE 10h,20h,30h
.code
mov esi,OFFSET arrayB
mov al [esi]                        ;AL = lOh
inc esi
mov al, [esi]                        ;AL = 20h
inc esi
mov al, [esi]                        ;AL = 30h

如果数组是 16 位整数类型,则 ESI 加 2 就可以顺序寻址每个数组元素:

.data
arrayW WORD 1000h,2000h,3000h
.code
mov esi,OFFSET arrayW
mov ax,[esi]                         ; AX = 1000h
add esi, 2
mov ax,[esi]                         ; AX = 2000h
add esi, 2
mov axz [esi]                        ; AX = 3000h

假设 arrayW 的偏移量为 10200h,下图展示的是 ESI 初始值相对数组数据的位置。

示例:32 位整数相加下面的代码示例实现的是 3 个双字相加。由于双字是 4 个字节的,因此,ESI 要加 4 才能顺序指向每个数组数值:

.data
arrayD DWORD 10000h,20000h,30000h
.code
mov esi,OFFSET arrayD
mov eax, [esi]                  ;(第一个数)
add esi, 4
add eax, [esi]                   ;(第二个数)
add esi, 4
add eax, [esi]                   ;(第三个数)

假设 arrayD 的偏移量为 10200h。下图展示的是 ESI 初始值相对数组数据的位置:

变址操作数

变址操作数是指,在寄存器上加上常数产生一个有效地址。每个 32 位通用寄存器都可以用作变址寄存器。MASM 可以用不同的符号来表示变址操作数(括号是表示符号的一部分):

constant [reg]
[constant + reg]

第一种形式是变量名加上寄存器。变量名由汇编器转换为常数,代表的是该变量的偏移量。下面给岀的是两种符号形式的例子:

arrayB[esi] [arrayB + esi]
arrayD[ebx] [arrayD + ebx]

变址操作数非常适合于数组处理。在访问第一个数组元素之前,变址寄存器需要初始化为 0:

.data
arrayB BYTE 10h,20h,30h
.code
mov esi, 0
mov al, arrayB[esi]                ; AL = 10h

最后一条语句将 ESI 和 arrayB 的偏移量相加,表达式 [arrayB+ESI] 产生的地址被解析,并将相应内存字节的内容复制到AL。

增加位移量变址寻址的第二种形式是寄存器加上常数偏移量。变址寄存器保存数组或结构的基址,常数标识各个数组元素的偏移量。下例展示了在一个 16 位字数组中如何使用这种形式:

.data
arrayW WORD 1000h,2000h,3000h
.code
mov esi,OFFSET arrayW
mov ax, [esi]                   ;AX = 1000h
mov ax, [esi+2]                 ;AX = 2000h
mov ax, [esi+4]                 ;AX = 3000h

使用 16 位寄存器

在实地址模式中,一般用 16 位寄存器作为变址操作数。在这种情况下,能被使用的寄存器只有 SI、DI、BX 和 BP:

mov al,arrayB[si]
mov ax,arrayW[di]
mov eax,arrayD[bx]

如果有间接操作数,则要避免使用 BP 寄存器,除非是寻址堆栈数据。

变址操作数中的比例因子

在计算偏移量时,变址操作数必须考虑每个数组元素的大小。比如下例中的双字数组,下标(3 )要乘以 4(一个双字的大小)才能生成内容为 400h 的数组元素的偏移量:

.data
arrayD DWORD 100h, 200h, 300h, 400h
.code
mov esi , 3 * TYPE arrayD                            ; arrayD [ 3 ]的偏移量
mov eax,arrayD[esi]                                  ; EAX = 400h

Intel 设计师希望能让编译器编写者的常用操作更容易,因此,他们提供了一种计算偏移量的方法,即使用比例因子。比例因子是数组元素的大小(字 = 2,双字 =4,四字 =8)。现在对刚才的例子进行修改,将数组下标(3)送入 ESI,然后 ESI 乘以双字的比例因子(4):

.data
arrayD DWORD 1,2,3,4
.code 
mov esi, 3                               ;下标
mov eax,arrayD[esi*4]                    ;EAX = 4

TYPE 运算符能让变址更加灵活,它可以让 arrayD 在以后重新定义为别的类型:

mov esi, 3                         ;下标 
mov eax,arrayD[esi*TYPE arrayD]    ;EAX = 4

指针

如果一个变量包含另一个变量的地址,则该变量称为指针。指针是控制数组和数据结构的重要工具,因为,它包含的地址在运行时是可以修改的。比如,可以使用系统调用来分配(保留)一个内存块,再把这个块的地址保存在一个变量中。

指针的大小受处理器当前模式(32位或64位)的影响。下例为 32 位的代码,ptrB 包含了 arrayB 的偏移量:

.data
arrayB byte 10h,20h,30h,40h
ptrB dword arrayB

还可以用 OFFSET 运算符来定义 ptrB,从而使得这种关系更加明确:

ptrB dword OFFSET arrayB

32 位模式程序使用的是近指针,因此,它们保存在双字变量中。这里有两个例子:ptrB 包含 arrayB 的偏移量,ptrW 包含 arrayW 的偏移量:

arrayB BYTE 10h,20h,30h,40h
arrayW WORD 1000h,2000h,3000h
ptrB    DWORD arrayB
ptrW    DWORD arrayW

同样,也还可以用 OFFSET 运算符使这种关系更加明确:

ptrB DWORD OFFSET arrayB
ptrW DWORD OFFSET arrayW

高级语言刻意隐藏了指针的物理细节,这是因为机器结构不同,指针的实现也有差异。汇编语言中,由于面对的是单一实现,因此是在物理层上检查和使用指针。这样有助于消除围绕着指针的一些神秘感。

使用 TYPEDEF 运算符

TYPEDEF 运算符可以创建用户定义类型,这些类型包含了定义变量时内置类型的所有状态。它是创建指针变量的理想工具。比如,下面声明创建的一个新数据类型 PBYTE 就是一个字节指针:

PBYTE TYPEDEF PTR BYTE

这个声明通常放在靠近程序开始的地方,在数据段之前。然后,变量就可以用 PBYTE 来定义:

.data
arrayB BYTE 10h,20h,30h,40h
ptr1 PBYTE ?                              ;未初始化
ptr2 PBYTE arrayB                     ;指向一个数组

示例程序:Pointers

下面的程序(pointers.asm)用 TYPEDEF 创建了 3 个指针类型(PBYTE、PWORD、PDWORD)。此外,程序还创建了几个指针,分配了一些数组偏移量,并解析了这些指针:

TITLE Pointers            (Pointers.asm)
.386
.model flat,stdcall
.stack 4096
ExitProcess proto,dwExitCode:dword
;创建用户定义类型
PBYTE TYPEDEF PTR BYTE                ;字节指针
PWORD TYPEDEF PTR WORD                ;字指针
PDWORD TYPEDEF PTR DWORD            ;双字指针
.data
arrayB BYTE 10h,20h,30h
arrayW WORD 1,2,3
arrayD DWORD 4,5,6
;创建几个指针变量
ptr1 PBYTE arrayB
ptr2 PWORD arrayW
ptr3 PDWORD arrayD
.code
main PROC
;使用指针访问数据
    mov esi,ptr1
    mov al,[esi]                    ;10h
    mov esi,ptr2
    mov ax,[esi]                    ;1
    mov esi,ptr3
    mov eax,[esi]                    ;4
    invoke ExitProcess,0
main ENDP
END main

汇编语言JMP和LOOP(转移)指令

默认情况下,CPU 是顺序加载并执行程序。但是,当前指令有可能是有条件的,也就是说,它按照 CPU 状态标志(零标志、符号标志、进位标志等)的值,把控制转向程序中的新位置。

汇编语言程序使用条件指令来实现如 IF 语句的高级语句与循环。每条条件指令都包含了一个可能的转向不同内存地址的转移(跳转)。控制转移,或分支,是一种改变语句执行顺序的方法,它有两种基本类型:

  • 无条件转移:无论什么情况都会转移到新地址。新地址加载到指令指针寄存器,使得程序在新地址进行执行。JMP 指令实现这种转移。

  • 条件转移:满足某种条件,则程序出现分支。各种条件转移指令还可以组合起来,形成条件逻辑结构。CPU 基于 ECX 和标志寄存器的内容来解释真 / 假条件。

JMP 指令

JMP 指令无条件跳转到目标地址,该地址用代码标号来标识,并被汇编器转换为偏移 量。语法如下所示:

JMP destination

当 CPU 执行一个无条件转移时,目标地址的偏移量被送入指令指针寄存器,从而导致迈从新地址开始继续执行。

JMP 指令提供了一种简单的方法来创建循环,即跳转到循环开始时的标号:

top:
    .
    .
    jmp top     ;不断地循环

JMP 是无条件的,因此循环会无休止地进行下去,除非找到其他方法退岀循环。

LOOP 指令

LOOP 指令,正式称为按照 ECX 计数器循环,将程序块重复特定次数。ECX 自动成为计数器,每循环一次计数值减 1。语法如下所示:

LOOP destination

循环目标必须距离当前地址计数器 -128 到 +127 字节范围内。LOOP 指令的执行有两个步骤:

  • 第一步,ECX 减 1。

  • 第二步,将 ECX 与 0 比较。

如果 ECX 不等于 0,则跳转到由目标给出的标号。否则,如果 ECX 等于 0,则不发生跳转,并将控制传递到循环后面的指令。

实地址模式中,CX 是 LOOP 指令的默认循环计数器。同时,LOOPD 指令使用 ECX 为循环计数器,LOOPW 指令使用 CX 为循环计数器。

下面的例子中,每次循环是将 AX 加 1。当循环结束时,AX=5, ECX=0:

    mov ax,0
    mov ecx,5
L1:
    inc ax
    loop L1

一个常见的编程错误是,在循环开始之前,无意间将 ECX 初始化为 0。如果执行了这个操作,LOOP 指令将 ECX 减 1 后,其值就为 FFFFFFFFh,那么循环次数就变成了 4 294 967 296!如果计数器是 CX (实地址模式下),那么循环次数就为 65 536。

有时,可能会创建一个太大的循环,以至于超过了 LOOP 指令允许的相对跳转范围。下面给出是 MASM 产生的一条错误信息,其原因就是 LOOP 指令的跳转目标太远了:

error A2075: jump destination too far : by 14 byte(s)

基本上,在一个循环中不用显式的修改 ECX,否则,LOOP 指令可能无法正常工作。下例中,每次循环 ECX 加 1。这样 ECX 的值永远不能到 0,因此循环也永远不会停止:

top:
    .
    .
    inc ecx
    loop top

如果需要在循环中修改 ECX,可以在循环开始时,将 ECX 的值保存在变量中,再在 LOOP 指令之前恢复被保存的计数值:

.data
count DWORD ?
.code
    mov ecx, 100        ;设置循环计数值
top:
    mov count, ecx      ;保存计数值
    .
    mov ecx, 20         ;修改 ECX
    .
    mov ecx, count      ;恢复计数值
    loop top
循环嵌套

当在一个循环中再创建一个循环时,就必须特别考虑外层循环的计数器 ECX,可以将它保存在一个变量中:

.data
count DWORD ?
.code
    mov ecx, 100    ;设置外层循环计数值
L1:
    mov count, ecx  ;保存外层循环计数值
    mov ecx, 20     ;设置内层循环计数值
L2 :
    loop L2         ;重复内层循环
    mov ecx, count  ;恢复外层循环计数值
    loop L1         ;重复外层循环

提示:作为一般规则,多于两重的循环嵌套难以编写。如果使用的算法需要多重循环,则将一些内层循环用子程序来实现。

在 Visual Studio 调试器中显示数组

在调试期间,如果想要显示数组的内容,步骤如下:选择 Debug 菜单 -> 选择 Windows -> 选择 Memory -> 选择Memory 1。则出现内存窗口,可以用鼠标拖动并停靠在 Visual Studio 工作区的任何一边。还可以右键点击该窗口的标题栏,表明要这个窗口浮动在编辑窗口之上。

在内存窗口上端的 Address 栏里, 键入 & 符号和数组名称,然后点击 Enter。比如,&myArray 就是一个有效的地址表达式。内存窗口将显示从这个数组地址开始的内存块,如下图所示。

如果数组的值是双字,可以在内存窗口中,点击右键并在弹出菜单里选择 4-byte integer。还有不同的格式可供选择,包括 Hexadecimal Display,Signed Display(有符号显示),和 Unsigned Display(无符号显示)。下图显示了所有的选项。

整数数组求和

在刚开始编程时,几乎没有任务比计算数组元素总和更常见了。汇编语言实现数组求和步骤如下:

  • 指定一个寄存器作变址操作数,存放数组地址。

  • 循环计数器初始化为数组的长度。

  • 指定一个寄存器存放累积和数,并赋值为0。

  • 创建标号来标记循环开始的地方。

  • 在循环体内,将和数与一个数组元素相加。

  • 指向下一个数组元素。

  • 用LOOP指令重复循环。

步骤 1 到步骤 3 可以按照任何顺序执行。下面的短程序实现对一个 16 位整数数组求和。

; 数组求和(SumArray. asm)
.386
.model flat,stdcall
.stack 4096
ExitProcess proto,dwExitCode:dword
.data
intarray DWORD 10000h,20000h,30000h,40000h
.code
main PROC
    mov edi, OFFSET intarray   ; 1: EDI=intarray 地址
    mov ecx, LENGTHOF intarray ; 2 :循环计数器初始化
    mov    eax,0               ; 3:    sum=0
L1:                            ; 4:标记循环开始的地方
    add    eax,    [edi]       ; 5:加一个整数
    add edi, TYPE intarray     ; 6:指向下一个元素
    loop    L1                 ; 7:重复,直到 ECX=0
    invoke ExitProcess, 0
main ENDP
END main

复制字符串

程序常常要将大块数据从一个位置复制到另一个位置。这些数据可能是数组或字符串,但是它们可以包括任何类型的对象。

现在看看在汇编语言中如何实现这种操作,用循环来复制一个字符串,而字符串表示为带有一个空终止值的字节数组。变址寻址很适合于这种操作,因为可以用同一个变址寄存器来引用两个字符串。目标字符串必须有足够的空间来接收 被复制的字符,包括最后的空字节:

;复制字符串    (CopyStr.asm)
.386
.model flat,stdcall
.stack 4096
ExitProcess proto,dwExitCode:dword
.data
source BYTE "This is the source string", 0
target BYTE SIZEOF source DUP(0)
.code
main PROC
    mov    esi, 0                      ;变址寄存器
    mov ecx, SIZEOF source             ;循环计数器
L1:                                   ;从源字符串获取一个字符
    mov    al, source [esi]            ;保存到目标字符串
    mov target [esi] , al              ;指向下一个字符
    inc esi                            ;重复,直到整个字符串完成
    loop L1
    invoke ExitProcess,0
main ENDP
END main

MOV 指令不能同时有两个内存操作数,所以,每个源字符串字符送入 AL,然后再从 AL 送入目标字符串。

汇编语言64位MOV指令

64 位模式下的 MOV 指令与 32 位模式下的有很多共同点,只有几点区别,现在讨论一下。立即操作数(常数)可以是 8 位、16 位、32 位或 64 位。下面为一个 64 位示例:

mov rax, 0ABCDEF0AFFFFFFFFh ; 64 位立即操作数

当一个 32 位常数送入 64 位寄存器时,目标操作数的高 32 位(位 32—位 63)被清除(等于 0):

mov rax, 0FFFFFFFFh ;rax = 00000000FFFFFFFF

向 64 位寄存器送入 16 位或 8 位常数,其高位也要清零:

mov rax, 06666h  ;清位 16—位 63
mov rax, 055h      ;清位 8—位 63

如果将内存操作数送入 64 位寄存器,则结果是确定的。比如,传送一个 32 位内存操作数到 EAX(RAX 寄存器的低半部分),就会清除 RAX 的高 32 位:

.data
myDword DWORD 80000000h
.code
mov rax,0FFFFFFFFFFFFFFFFh
mov eax,myDword                     ; RAX = 0000000080000000

但是,如果是将 8 位或 16 位内存操作数送入 RAX 的低位,那么,目标寄存器的高位不受影响:

.data
myByte BYTE 55h
myWord WORD 6666h
.code
mov ax,myWord                ;位 16—位 63 不受影响
mov al, myByte                  ;位 8—位 63 不受影响

MOVSXD 指令(符号扩展传送)允许源操作数为 32 位寄存器或内存操作数。下面的指令使得 RAX 的值为 FFFFFFFFFFFFFFFFh:

mov ebx, 0FFFFFFFFh
movsxd rax,ebx

OFFSET 运算符产生 64 位地址,必须用 64 位寄存器或变量来保存。下例中使用的是 RSI 寄存器:

.data
myArray WORD 10,20,30,40
.code
mov rsi,OFFSET myArray
64 位模式中,LOOP 指令用 RCX 作为循环计数器。

有了这些基本概念,就可以编写许多 64 位模式程序了。大多数情况下,如果一直使用 64 位整数变量或 64 位寄存器,那么编程比较容易。ASCII 码字符串是一种特殊情况,因为它们总是包含字节。一般在处理时,采用间接或变址寻址。

汇编语言64位加法和减法

如同 32 位模式下一样,ADD、SUB、INC 和 DEC 指令在 64 位模式下,也会影响 CPU 状态标志位。在下面的例子中,RAX 寄存器存放一个 32 位数,执行加 1,每一位都向左产生一个进位,因此,在位 32 生成 1:

mov rax, 0FFFFFFFFh ;低 32 位是全 1
add rax,1           ; RAX = 100000000h

需要时刻留意操作数的大小,当操作数只使用部分寄存器时,要注意寄存器的其他部分是没有被修改的。如下例所示,AX 中的 16 位总和翻转为全 0,但是不影响 RAX 的高位。这是因为该操作只使用 16 位寄存器(AX 和 BX):

mov rax,0FFFFh        ; RAX = 000000000000FFFF
mov bx, 1
add ax,bx             ; RAX = 0000000000000000

同样,在下面的例子中,由于 AL 中的进位不会进入 RAX 的其他位,所以执行 ADD 指令后,RAX 等于 0:

mov rax,0FFh         ; RAX = 00000000000000FF
mov bl, 1
add al,bl            ; RAX = 0000000000000000

减法也使用相同的原则。在下面的代码段中,EAX 内容为 0,对其进行减 1 操作,将会使得 RAX 低32位变为 -1(FFFFFFFFh)。同样,AX 内容为 0,对其进行减 1 操作,使得 RAX 低 16 位等于 -1(FFFFh)。

mov rax,0               ; RAX = 0000000000000000
mov ebx, 1
sub eax,ebx             ; RAX = 00000000FFFFFFFF
mov rax,0               ; RAX = 0000000000000000
mov bx,1
sub ax,bx               ; RAX = 000000000000FFFF

当指令包含间接操作数时,必须使用 64 位通用寄存器。记住,一定要使用 PTR 运算符来明确目标操作数的大小。下面是一些包含了 64 位目标操作数的例子:

dec BYTE PTR [rdi]              ;8 位目标操作数
inc WORD PTR [rbx]              ;16 位目标操作数
inc QWORD PTR [rsi]             ;64 位目标操作数

64 位模式下,可以对间接操作数使用比例因子,就像在 32 位模式下一样。如下例所示,如果处理的是 64 位整数数组,比例因子就是 8:

.data
array QWORD 1,2,3,4
.code
mov esi, 3                   ;下标
mov rax,array[rsi*8]         ; RAX = 4

64 位模式的指针变量包含的是 64 位偏移量。在下面的例子中,ptrB 变量包含了数组 B 的偏移量:

.data
arrayB BYTE 10h, 20h, 30h, 40h
ptrB QWORD arrayB

或者,还可以用 OFFSET 运算符来定义 ptrB,使得这个关系更加明确:

ptrB QWORD OFFSET arrayB

汇编语言过程

汇编语言堆栈简介

如下图所示,如果把 10 个盘子垒起来,其结果就称为堆栈。虽然有可能从这个堆栈的中间移出一个盘子,但是,更普遍的是从顶端移除。新的盘子可以叠加到堆栈顶部,但不能加在底部或中部。

堆栈数据结构(stack data structure)的原理与盘子堆栈相同:新值添加到栈顶,删除值也在栈顶移除。通常,对各种编程应用来说,堆栈都是有用的结构,并且它们也容易用面向对象的编程方法来实现。

如果大家已经学习过使用数据结构的编程课程,那么就应该已经用过堆栈抽象数据类型(stack abstract data type)。

堆栈也被称为 LIFO 结构(后进先出,Last-In First-Out),,其原因是,最后进入堆栈的值也是第一个出堆栈的值。

汇编语言运行时堆栈(内存数组)

运行时堆栈是内存数组,CPU 用 ESP(扩展堆栈指针,extended stack pointer)寄存器对其进行直接管理,该寄存器被称为堆栈指针寄存器(stack pointer register)。

32位模式下,ESP 寄存器存放的是堆栈中某个位置的 32 位偏移量。ESP 基本上不会直接被程序员控制,反之,它是用 CALL、RET、PUSH 和 POP 等指令间接进行修改。

ESP 总是指向添加,或压入(pushed)到栈顶的最后一个数值。为了便于说明,假设现有一个堆栈,内含一个数值。如下图所示,ESP 的内容是十六进制数 0000 1000,即刚压入堆栈数值(0000 0006)的偏移量。在图中,当堆栈指针数值减少时,栈顶也随之下移。

上图中,每个堆栈位置都是32位长,这 是32位模式下运行程序的情形。

运行时堆栈工作于系统层,处理子程序调用。堆栈 ADT 是编程结构,通常用高级编程语言编写,如 C++ 或 Java。它用于实现基于后进先出操作的算法。

入栈操作

32 位入栈操作把栈顶指针减 4,再将数值复制到栈顶指针指向的堆栈位置。下图展示了把 0000 00A5 压入堆栈的结果,堆栈中已经有一个数值(0000 0006)。注意,ESP 寄存器总是指向最后压入堆栈的数据项。

上图中显示的堆栈顺序与之前示例给出的盘堆栈顺序相反,这是因为运行时堆栈在内存中是向下生长的,即从高地址向低地址扩展。入栈之前, ESP=0000 1000h;入栈之后,ESP=0000 0FFCh。下图显示了同一个堆栈总共压入 4 个整数之后的情况。

出栈操作

出栈操作从堆栈删除数据。数值弹出堆栈后,栈顶指针增加(按堆栈元素大小),指向堆栈中下一个最高位置。下图展示了数值 0000 0002 弹出前后的堆栈情况。

ESP 之下的堆栈域在逻辑上是空白的,当前程序下一次执行任何数值入栈操作指令都可以覆盖这个区域。

堆栈应用

运行时堆栈在程序中有一些重要用途:

  • 当寄存器用于多个目的时,堆栈可以作为寄存器的一个方便的临时保存区。在寄存器被修改后,还可以恢复其初始值。

  • 执行 CALL 指令时,CPU 在堆栈中保存当前过程的返回地址。

  • 调用过程时,输入数值也被称为参数,通过将其压入堆栈实现参数传递。

  • 堆栈也为过程局部变量提供了临时存储区域。

汇编语言PUSH和POP指令(压栈和出栈)

汇编里把一段内存空间定义为一个栈,栈总是先进后出,栈的最大空间为 64K。由于 “栈” 是由高到低使用的,所以新压入的数据的位置更低,ESP 中的指针将一直指向这个新位置,所以 ESP 中的地址数据是动态的。

PUSH 指令

PUSH 指令首先减少 ESP 的值,再将源操作数复制到堆栈。操作数是 16 位的,则 ESP 减 2,操作数是 32 位的,则 ESP 减 4。PUSH 指令有 3 种格式:

PUSH reg/mem16
PUSH reg/mem32
PUSH inm32

POP指令

POP 指令首先把 ESP 指向的堆栈元素内容复制到一个 16 位或 32 位目的操作数中,再增加 ESP 的值。如果操作数是 16 位的,ESP 加 2,如果操作数是 32 位的,ESP 加 4:

POP reg/mem16
POP reg/mem32

PUSHFD 和 POPFD 指令

PUSHFD 指令把 32 位 EFLAGS 寄存器内容压入堆栈,而 POPFD 指令则把栈顶单元内容弹出到 EFLAGS 寄存器:

pushfd
popfd

不能用 MOV 指令把标识寄存器内容复制给一个变量,因此,PUSHFD 可能就是保存标志位的最佳途径。有些时候保存标志寄存器的副本是非常有用的,这样之后就可以恢复标志寄存器原来的值。通常会用 PUSHFD 和 POPFD 封闭一段代码:

pushfd          ;保存标志寄存器
;
;任意语句序列
;
popfd          ;恢复标志寄存器

当用这种方式使用入栈和出栈指令时,必须确保程序的执行路径不会跳过 POPFD 指令。当程序随着时间不断修改时,很难记住所有入栈和出栈指令的位置。因此,精确的文档就显得至关重要!

一种不容易出错的保存和恢复标识寄存器的方法是:将它们压入堆栈后,立即弹出给一个变量:

.data
saveFlags DWORD ?
.code
pushfd                    ;标识寄存器内容入栈
pop saveFLags             ;复制给一个变量

下述语句从同一个变量中恢复标识寄存器内容:

push saveFlags            ;被保存的标识入栈
popfd                     ;复制给标识寄存器

PUSHAD,PUSHA,POPAD 和 POPA

PUSHAD 指令按照 EAX、ECX、EDX、EBX、ESP(执行 PUSHAD 之前的值)、EBP、ESI 和 EDI 的顺序,将所有 32 位通用寄存器压入堆栈。

POPAD 指令按照相反顺序将同样的寄存器弹出堆栈。与之相似,PUSHA 指令按序(AX、CX、DX、BX、SP、BP、SI 和 DI)将 16 位通用寄存器压入堆栈。

POPA 指令按照相反顺序将同样的寄存器弹出堆栈。在 16 位模式下,只能使用 PUSHA 和 POPA 指令。

如果编写的过程会修改 32 位寄存器的值,则在过程开始时使用 PUSHAD 指令,在结束时使用 POPAD 指令,以此保存和恢复寄存器的内容。示例如下列代码段所示:

MySub PROC
    pushad                 ;保存通用寄存器的内容
    .
    .
    mov eax,...
    mov edx,...
    mov ecx,...
    .
    .
    popad                   ;恢复通用寄存器的内容
    ret
MySub ENDP

必须要指岀,上述示例有一个重要的例外:过程用一个或多个寄存器来返回结果时,不应使用 PUSHA 和 PUSHAD。假设下述 ReadValue 过程用 EAX 返回一个整数;调用 POPAD 将会覆盖 EAX 中的返回值:

ReadValue PROC
    pushad                    ;保存通用寄存器的内容
    .
    .
    mov eax rreturn_value
    .
    .
    popad                    ;覆盖 EAX !
    ret
ReadValue ENDP

示例:字符串反转

现在查看名为 RevStr 的程序:在一个字符串上循环,将每个字符压入堆栈,再把这些字符从堆栈中弹出(相反顺序),并保存回同一个字符串变量。由于堆栈是 LIFO(后进先出)结构,字符串中的字母顺序就发生了翻转:

;字符串翻转(Revstr.asm)
.386
.model flat,stdcall
.stack 4096
ExitProcess PROTO,dwExitCode:DWORD
.data
aName BYTE "Abraham Lincoln",0
nameSize = ($-aName)-1
.code
main PROC
;将名字压入堆栈
    mov ecx,nameSize
    mov esi,0
L1:    movzx eax,aName[esi]        ;获取字符
    push eax                       ;压入堆栈
    inc esi
    loop L1
;将名字逆序弹出堆栈
;并存入aName数组
    mov ecx,nameSize
    mov esi,0
L2:    pop eax                        ;获取字符
    mov aName[esi],al                 ;存入字符串
    inc esi
    loop L2
    INVOKE ExitProcess,0
main ENDP
END main

汇编语言PROC和ENDP伪指令:定义一个过程

在汇编语言中,通常用术语过程(procedure)来指代子程序。在其他语言中,子程序也被称为方法或函数。

就面向对象编程而言,单个类中的函数或方法大致相当于封装在一个汇编语言模块中的过程和数据集合。汇编语言出现的时间远早于面向对象编程,因此它不具备面向对象编程中的形式化结构。汇编程序员必须在程序中实现自己的形式化结构。

定义过程

过程可以非正式地定义为:由返回语句结束的已命名的语句块。过程用 PROC 和 ENDP 伪指令来定义,并且必须为其分配一个名字(有效标识符)。到目前为止,所有编写的程序都包含了一个名为 main 的过程,例如:

main PROC
.
.
main ENDP

当在程序启动过程之外创建一个过程时,就用 RET 指令来结束它。RET 强制 CPU 返回到该过程被调用的位置:

sample PROC
   .
   .
   ret
sample ENDP

过程中的标号

默认情况下,标号只在其被定义的过程中可见。这个规则常常影响到跳转和循环指令。在下面的例子中,名为 Destination 的标号必须与 JMP 指令位于同一个过程中:

jmp Destination

解决这个限制的方法是定义全局标号,即在名字后面加双冒号 (::)。

Destination::

就程序设计而言,跳转或循环到当前过程之外不是个好主意。过程用自动方式返回并调整运行时堆栈。如果直接跳出一个过程,则运行时堆栈很容易被损坏。

示例:三个整数求和

现在创建一个名为 SumOf 的过程计算三个 32 位整数之和。假设在过程调用之前,整数已经分配给 EAX、EBX 和 ECX。过程用 EAX 返回和数:

SumOf PROC
    add eax,ebx
    add eax,ecx
    ret
SumOf ENDP

过程说明

要培养的一个好习惯是为程序添加清晰可读的说明。下面是对放在每个过程开头的信息的一些建议:

  • 对过程实现的所有任务的描述。

  • 输入参数及其用法的列表,并将其命名为 Receives ( 接收 )。如果输入参数对其数值有特殊要求,也要在这里列岀来。

  • 对过程返回的所有数值的描述,并将其命名为 Returns ( 返回 )。

  • 所有特殊要求的列表,这些要求被称为先决条件 (preconditions),必须在过程被调用之前满足。列表命名为 Requires。例如,对一个画图形线条的过程来说,一个有用的先决条件是该视频显示适配器必须已经处于图形模式。

上述选择的描述性标号,如 ReceivesReturns 和 Requires,不是绝对的;其他有用的名字也常常被使用。

有了这些思想,现在对 SumOf 过程添加合适的说明:

;-------------------------------------------------------
; sumof
; 计算 3 个 32 位整数之和并返回和数。
; 接收:EAX、EBX和ECX为3个整数,可能是有符号数,也可能是无符号数。
; 返回:EAX=和数
;------------------------------------------------------
SumOf PROC
    add eax,ebx
    add eax,ecx
    ret
SumOf ENDP

用高级语言,如 C 和 C++,编写的函数,通常用 AL 返回 8 位的值,用 AX 返回 16 位的值,用 EAX 返回 32 位的值。

汇编语言CALL和RET指令:调用一个过程

CALL 指令调用一个过程,指挥处理器从新的内存地址开始执行。过程使用 RET(从过程返回)指令将处理器转回到该过程被调用的程序点上。

从物理上来说,CALL 指令将其返回地址压入堆栈,再把被调用过程的地址复制到指令指针寄存器。当过程准备返回时,它的 RET 指令从堆栈把返回地址弹回到指令指针寄存器。32 位模式下,CPU 执行的指令由 EIP(指令指针寄存器)在内存中指岀。16 位模式下,由 IP 指出指令。

调用和返回示例

假设在 main 过程中,CALL 指令位于偏移量为 0000 0020 处。通常,这条指令需要 5 个字节的机器码,因此,下一条语句(本例中为一条 MOV 指令)就位于偏移量为 0000 0025 处:

   main PROC
00000020 call MySub
00000025 mov eax,ebx

然后,假设 MySub 过程中第一条可执行指令位于偏移量 0000 0040 处:

  MySub PROC
00000040 mov eaxz edx
   .
   .
   ret
  MySub ENDP

当 CALL 指令执行时如下图所示,调用之后的地址(0000 0025)被压入堆栈,MySub 的地址加载到 EIP。

执行 MySub 中的全部指令直到 RET 指令。当执行 RET 指令时,ESP 指向的堆栈数值被弹岀到 EIP(如下图所示,步骤 1)。在步骤 2 中,ESP 的数值增加,从而指向堆栈中的前一个值(步骤 2)。

汇编语言过程调用嵌套

被调用过程在返回之前又调用了另一个过程时,就发生了过程调用嵌套。假设 main 调用了过程 Sub1。当 Sub1 执行时,它调用了过程 Sub2。当 Sub2 执行时,它调用了过程 Sub3。步骤如下图所示。

当执行 Sub3 末尾的 RET 指令时,将 stack[ESP](堆栈段首地址 +ESP 给出的偏移量)中的数值弹出到指令指针寄存器中,这使得执行转回到调用 Sub3 后面的指令。下图显示的是执行从 Sub3 返回操作之前的堆栈:

返回之后,ESP 指向栈顶下一个元素。当 Sub2 末尾的 RET 指令将要执行时,堆栈如下所示:

最后,执行 Sub1 的返回,stack[ESP] 的内容弹出到指令指针寄存器,继续在 main 中执行:

显然,堆栈证明了它很适合于保存信息,包括过程调用嵌套。一般说来,堆栈结构用于程序需要按照特定顺序返回的情况。

向过程传递寄存器参数

如果编写的过程要执行一些标准操作,如整数数组求和,那么,在过程中包含对特定变量名的引用就不是一个好主意。如果这样做了,该过程就只能作用于一个数组。更好的方法是向过程传递数组的偏移量以及指定数组元素个数的整数。这些内容被称为参数(或输入参数)。在汇编语言中,经常用通用寄存器来传递参数。

在《PROC和ENDP伪指令》一节中创建了一个简单的过程 SumOf,计算 EAX、EBX 和 ECX 中的整数之和。在 main 调用 SumOf 之前,将数值分配给 EAX、EBX 和 ECX:

.data
theSum DWORD ?
.code
main PROC
mov eax, 10000h          ;参数
mov ebx, 20000h          ;参数
mov ecx, 30000h          ;参数
call Sumof               ;EAX=(EAX+EEX+ECX)
mov theSum,eax           ;保存和数

在 CALL 语句之后,选择了将 EAX 中的和数复制给一个变量。

汇编语言示例:整数数组求和

现在创建一个过程 ArraySum,从一个调用程序接收两个参数:一个指向 32 位整数数组的指针,以及一个数组元素个数的计数器。该过程计算和数,并用 EAX 返回数组之和:

;------------------------------------
;ArraySum
;计算32位整数数组元素之和
;接收:ESI = 数组偏移量
;      ECX = 数组元素的个数
;返回:EAX = 数组元素之和
;-------------------------------------
ArraySum PROC
    push esi                ;保存ESI和ECX
    push ecx
    mov eax,0               ;设置和数为0
L1:    add eax,[esi]       ;将每个整数与和数相加
    add esi,TYPE DWORD      ;指向下一个整数
    loop L1                 ;按照数组大小重复

    pop ecx                 ;恢复ECX和ESI
    pop esi               
    ret                     ;和数在EAX中
ArraySum ENDP

这个过程没有特别指定数组名称和大小,它可以用于任何需要计算32位整数数组之和的程序。只要有可能,编程者也应该编写具有灵活性和适应性的程序。

测试 ArraySum 过程

下面的程序通过传递一个 32 位整数数组的偏移量和长度来测试 ArraySum 过程。调用 ArraySum 之后,程序将过程的返回值保存在变量 theSum 中。

;测试ArraySum过程
.386
.model flat,stdcall
.stack 4096
ExitProcess PROTO,dwExitCode:DWORD
.data
array DWORD 10000h,20000h,30000h,40000h,50000h
theSum DWORD ?
.code
main PROC
    mov esi,OFFSET array          ;ESI指向数组
    mov ecx,LENGTHOF array        ;ECX = 数组计算器
    call ArraySum                 ;计算和数
    mov theSum,eax                ;用EAX返回和数
    INVOKE ExitProcess,0
main ENDP
;------------------------------------
;ArraySum
;计算32位整数数组元素之和
;接收:ESI = 数组偏移量
;       ECX = 数组元素的个数
;返回:EAX = 数组元素之和
;-------------------------------------
ArraySum PROC
    push esi                 ;保存ESI和ECX
    push ecx
    mov eax,0                ;设置和数为0
L1:    add eax,[esi]        ;将每个整数与和数相加
    add esi,TYPE DWORD      ;指向下一个整数
    loop L1                 ;按照数组大小重复

    pop ecx                 ;恢复ECX和ESI
    pop esi               
    ret                     ;和数在EAX中
ArraySum ENDP
END  main

汇编语言USES运算符:保存和恢复寄存器

USES 运算符

USES 运算符与 PROC 伪指令一起使用,让程序员列出在该过程中修改的所有寄存器名。USES 告诉汇编器做两件事情:第一,在过程开始时生成 PUSH 指令,将寄存器保存到堆栈;第二,在过程结束时生成 POP 指令,从堆栈恢复寄存器的值。

USES 运算符紧跟在 PROC 之后,其后是位于同一行上的寄存器列表,表项之间用空格符或制表符(不是逗号)分隔。

在 ArraySum 过程使用 PUSH 和 POP 指令来保存和恢复 ESI 和 ECX。 USES 运算符能够更加容易地实现同样的功能:

ArraySum PROC USES esi ecx
  mov eax, 0                   ;置和数为0
L1:
  add eax,[esi]                ;将每个整数与和数相加
  add esi, TYPE DWORD          ;指向下个整数
  loop L1                      ;按照数组大小重复
  ret                          ;和数在 EAX 中
ArraySum ENDP

汇编器生成的相应代码展示了使用 USES 的效果:

ArraySum PROC
  push esi
  push ecx
  mov eax, 0                      ;置和数为0
L1:
  add eax, [esi]                  ;将每个整数与和数相加
  add esi, TYPE DWORD             ;指向下一个整数
  loop L1                         ;按照数组大小重复
  pop ecx
  pop esi
  ret
ArraySum ENDP

调试提示:使用 Microsoft Visual Studio 调试器可以查看由 MASM 高级运算符和伪指令生成的隐藏机器指令。在调试窗口中右键点击,选择 Go To Disassembly。该窗口显示程序源代码,以及由汇编器生成的隐藏机器指令。

当过程利用寄存器(通常用 EAX)返回数值时,保存使用寄存器的惯例就岀现了一个重要的例外。在这种情况下,返回寄存器不能被压入和弹出堆栈。例如下述 SumOf 过程把 EAX 压入、弹出堆栈,就会丢失过程的返回值:

SumOf PROC                             ;三个整数之和
  push eax                             ;保存EAX
  add eax, ebx
  add eax, ecx                         ;计算EAX、EBX和ECX之和
  pop eax                              ;和数丢失!
  ret
SumOf ENDP

汇编语言链接库简介

背景知识

链接库是一种文件,包含了已经汇编为机器代码的过程(子程序)。链接库开始时是一个或多个源文件,这些文件再被汇编为目标文件。目标文件插入到一个特殊格式文件,该文件由链接器工具识别。

假设一个程序调用过程 WriteString 在控制台窗口显示一个字符串。该程序源代码必须包含 PROTO 伪指令来标识 WriteString 过程:

WriteString proto

之后,CALL 指令执行 WriteString:

call WriteString

当程序进行汇编时,汇编器将不指定 CALL 指令的目标地址,它知道这个地址将由链接器指定。链接器在链接库中寻找 WriteString,并把库中适当的机器指令复制到程序的可执行文件中。同时,它把 WriteString 的地址插入到 CALL 指令。

如果被调用过程不在链接库中,链接器就发出错误信息,且不会生成可执行文件。

链接命令选项

链接器工具把一个程序的目标文件与一个或多个目标文件以及链接库组合在一起。比如,下述命令就将 hello.obj 与 irvine32.lib 和 kernel32.lib 库链接起来:

link hello.obj irvine32.lib kernel32.lib

32位程序链接

kernel32.lib 文件是 Microsoft Windows 平台软件开发工具(Software Development Kit)的一部分,它包含了 kernel32.dll 文件中系统函数的链接信息。kernel32.dll 文件是 MS-Windows 的一个基本组成部分,被称为动态链接库(dynamic link library)。它含有的可执行函数实现基于字符的输入输出。

下图展示了为什么 kernel32.lib 是通向 kernel32.dll 的桥梁。

汇编语言Irvine32链接库

即使是在那个时代,如果想在控制台上显示一个整数,也需要编写一个相当复杂的程序,将整数的内部二进制表示转换为可以在屏幕上显示的 ASCII 字符序列。这个过程被称为 WriteInt,下面是其抽象为伪代码的逻辑:

初始化:

let n equal the binary value
let buffer be an array of char[size]

算法:

i = size -1                      ;缓冲区最后一个位置
repeat
  r = n mod 10                   ;余数
  n = n / 10                     ;整数除法
  digit = r OR 30h               ;将工转换为 ASCII 数字
  bufferf[i--] = digit           ;保存到缓冲区
until n = 0
if n is negative
  buffer[i] = "-"                ;插入负号
while i > 0
  print buffer[i]
  i++

注意,数字是按照逆序生成,插入缓冲区,从后往前移动。然后,数字按照正序写到控制台。虽然这段代码简单到足以用 C/C++ 实现,但是如果是在汇编语言中,它还需要一些高级技巧。

专业程序员通常更愿意自己建立库,这是一种很好的学习经验。在 Windows 的 32 位模式下,输入输出库必须能直接调用操作系统的内容。这个学习曲线相当陡峭,对编程初学者提出了一些挑战。因此,Irvine32 链接库被设计成给初学者提供简单的输入输岀接口。

随着学习的推进,我们将能获得自己创建库的知识和技术。只要成为库的创建者,就能自由地修改和重用库。

下表列出了 Irvine32 链接库的全部过程。

过程 说明
CloseFile 关闭之前已经打开的磁盘文件
Clrscr 清除控制台窗口,并将光标置于左上角
CreateOutputFile 为输出模式下的写操作创建一个新的磁盘文件
Crlf 在控制台窗口中写一个行结束的序列
Delay 程序执行暂停指定的 n 毫秒
DumpMem 以十六进制形式,在控制台窗口写一个内存块
DumpRegs 以十六进制形式显示 EAX、EEX、ECX、EDX、ESI、EDI、EBP、ESP、EFLAGS 和 EIP 寄存器。也显示最常见的 CPU 状态标志位
GetCommandTail 复制程序命名行参数(称为命令尾)到一个字节数组
GetDateTime 从系统获取当前日期和时间
GetMaxXY 返回控制台窗口缓冲器的行数和列数
GetMseconds 返回从午夜开始经过的毫秒数
GetTextColor 返回当前控制台窗口的前景色和背景色
Gotoxy 将光标定位到控制台窗口内指定的位置
IsDigit 如果 AL 寄存器中包含了十进制数字(0-9)的 ASCII 码,则零标志位置 1
MsgBox 显示一个弹出消息框
MsgBoxAsk 在弹出消息框中显示 yes/no 问题
OpenlnputFile 打开一个已有磁盘文件进行输入操作
ParseDecimal32 将一个无符号十进制整数字符串转换为 32 位二进制数
Parselnteger32 将一个有符号十进制整数字符串转换为 32 位二进制数
Random32 在 0〜FFFFFFFFh 范围内,生成一个 32 位的伪随机整数
Randomize 用一个值作为随机数生成器的种子
RandomRange 在特定范围内生成一个伪随机整数
ReadChar 等待从键盘输入一个字符,并返回该字符
ReadDec 从键盘读取一个无符号 32 位十进制整数,用回车符结束
ReadFromFile 将一个输入磁盘文件读入缓冲区
ReadHex 从键盘读取一个 32 位十六进制整数,用回车符结束
Readlnt 从键盘读取一个有符号 32 位十进制整数,用回车符结束
ReadKey 无需等待输入即从键盘输入缓冲区读取一个字符
ReadString 从键盘读取一个字符串,用回车符结束
SetTextColor 设置控制台输出字符的前景色和背景色
Str_compare 比较两个字符串
Str_copy 将源字符串复制到目的字符串
Str_length 用 EAX 返回字符串长度
Str_trim 从字符串删除不需要的字符
Str_ucase 将字符串转换为大写字母
WaitMsg 显示信息并等待按键操作
WriteBin 用 ASCII 二进制格式,向控制台窗口写一个无符号 32 位整数
WriteBinB 用字节、字或双字格式向控制台窗口写一个二进制整数
WriteChar 在控制台窗口写一个字符
WriteDec 用十进制格式,向控制台窗口写一个无符号 32 位整数
WriteHex 用十六进制格式,向控制台窗口写一个 32 位整数
WriteHexB 用十六进制格式,向控制台窗口写一个字节、字或双字整数
Writelnt 用十进制格式,向控制台窗口写一个有符号 32 位整数
WriteStackFrame 向控制台窗口写当前过程的堆栈帧
WriteStackFrameName 向控制台窗口写当前过程的名称和堆栈帧
WriteString 向控制台窗口写一个以空字符结束的字符串
WriteToFile 将缓冲区内容写入一个输出文件
WriteWindowsMsg 显示一个字符串,包含 MS-Windows 最近一次产生的错误

CloseFile

CloseFile 过程关闭之前已经创建或打开的文件(参见 CreateOutputFile 和 OpenlnputFile)。该文件用一个 32 位整数的句柄来标识,句柄由 EAX 传递。如果文件成功关闭,EAX 中的返回值就是非零的。示例如下:

mov eax,fileHandle
call CloseFile

Clrscr

Clrscr 过程清除控制台窗口。该过程通常在程序开始和结束时被调用。如果在其他时间调用这个过程,就需要先调用 WaitMsg 来暂停程序,这样就可以让用户在屏幕被清除之前,阅读屏幕上的信息。调用示例如下:

call WaitMsg       ; "Press any key..."
call Clrscr

CreateOutputFile

CreateOutputFile 过程创建并打开一个新的磁盘文件,进行写操作。调用该过程时,将文件名的偏移量送入 EDX。过程返回后,如果文件创建成功则 EAX 将包含一个有效文件句柄(32 位整数),否则,EAX 将等于 INVALID_HANDLE_VALUE(一个预定义的常数)。调用示例如下:

.data
filename BYTE "newfile.txt",0
.code
mov edx,OFFSET filename
call CreateOutputFile

下面的伪代码描述的是调用 CreateOutputFile 之后,可能会出现的结果:

if EAX = INVALID_HANDLE_VALUE
    the file was not created successfully
else
    EAX = handle for the open file
endif

Crlf

Crlf 过程将光标定位在控制台窗口下一行的开始位置。它写的字符串包含了 ASCII 字符代码 ODh 和 OAh。调用示例如下:

call Crlf

Delay

Delay 过程按照特定毫秒数暂停程序。在调用 Delay 之前,将预定时间间隔送入 EAXO 调用示例如下:

mov eax,1000              ;1 秒
call Delay

DumpMen

DumpMen 过程在控制台窗口中用十六进制的形式显示一段内存区域。ESI 中存放的是内存区域首地址;ECX 中存放的是单元个数;EBX 中存放的是单元大小(1 = 字节,2 = 字,4 = 双字)。下述调用示例用十六进制形式显示了包含 11 个双字的数组:

.data
array DWORD 1,2,3,4,5,6,7,8,9,0Ah,0Bh
.code
main PROC
        mov esi,OFFSET array                   ;首地址偏移量
        mov ecx, LENGTHOF array                ;单元个数
        mov ebx,TYPE array                     ;双字格式
        call DumpMen

产生的输出如下所示:

00000001   00000002   00000003   00000004   00000005   00000006
00000007  00000008  00000009  0000000A   0000000B

DumpRegs

DumpRegs 过程用十六进制形式显示 EAX、EBX、ECX、EDX、ESI、EDI、EBP、ESP、EIP 和 EFL(EFLAGS)的内容,以及进位标志位、符号标志位、零标志位、溢出标志位、辅助进位标志位和奇偶标志位的值。调用示例如下:

call DumpRegs

示例输出如下所示:

EAX=00000613   EBX=00000000   ECX=000000FF   EDX=00000000
ESI=00000000   EDI=00000100   EBP=0000091E   ESP=000000F6
EIP=00401026   EFL=00000286   CF=0   SF=1   ZF=0   OF=0   AF=0   PF=1

EIP 显示的数值是调用 DumpRegs 的下一条指令的偏移量。DumpRegs 在调试程序时很有用,因为它显示了 CPU 快照。该过程没有输入参数和返回值。

GetCommandTail

GetCommandTail 过程将程序命令行复制到一个空字节结束的字符串。如果命令行是空,则进位标志位置 1 ;否则进位标志位清零。该过程的作用在于能让程序用户通过命令行传递参数。假设有一程序 Encrypt.exe 读取输入文件 filel.txt,并产生输出文件 file2.txt。程序运行时,用户可以通过命令行传递这两个文件名:

Encrypt filel.txt file2.txt

当 Encrypt 程序启动时,它可以调用 GetCommandTail,检索这两个文件名。调用 GetCommandTail 时,EDX 必须包含一个数组的偏移量,该数组至少要有 129 个字节。调用示例如下:

.data
cmdTail BYTE 129 DUP ( 0 )          ;空缓冲区
.code
mov edx,OFFSET cmdTail
call GetCommandTail                 ;填充缓冲区

在 Visual Studio 中运行应用程序时,有一种方法可以传递命令行参数。在 Project 菜单中,选择 Properties。在 Property Pages 窗口,展开 Configuration Properties 选项,选择 Debugging。然后,在右边 Command Arguments 面板的编辑行中输入程序的命令参数。

GetMaxXY

GetMaxXY 过程获取控制台窗口缓冲区的大小。如果控制台窗口缓冲区大于可视窗口尺寸,则自动显示滚动条。GetMaxXY 没有输入参数。当过程返回时,DX 寄存器包含了缓冲区的列数,AX 寄存器包含了缓冲区的行数。每个数值的可能范围都不超过 255,这也许会小于实际窗口缓冲区的大小。调用示例如下:

.data
rows BYTE ?
cols BYTE ?
.code
call GetMaxXY
mov rows, al
mov cols,dl

GetMseconds

GetMseconds 过程获取主机从午夜开始经过的毫秒数,并用 EAX 返回该值。在计算事件间隔时间时,这个过程是非常有用的。过程不需要输入参数。

下面的例子调用了 GetMseconds,并保存了返回值。执行循环之后,代码第二次调用 GetMseconds,并将两次返回的时间值相减,结果就是执行循环的大致时间:

.data
startTime DWORD ?
.code
call GetMseconds
mov startTime,eax
LI :
;(loop body)
loop LI
call GetMseconds
sub eax, startTime      ;EAX = 循环时间,按毫秒计

GetTextColor

GetTextColor 过程获取控制台窗口当前的前景色和背景色,它没有输入参数。返回时,AL 中的高四位是背景色,低四位是前景色。调用示例如下:

.data
color byte ?
.code
call GetTextColor
mov color,AL

Gotoxy

Gotoxy 过程将光标定位到控制台窗口的指定位置。默认情况下,控制台窗口的X轴范围为 0〜79,Y 轴范围为 0〜24。调用 Gotoxy 时,将 Y 轴(行数)传递到 DH 寄存器,X 轴(列数)传递到 DL 寄存器。调用示例如下:

mov dh, 10              ;第 10 行
mov dl, 20              ;第 20 列
call Gotoxy             ;定位光标
用户可能会修改控制台窗口大小,因此可以调用 GetMaxXY 获取当前窗口的行列数。

IsDigit

IsDigit 过程确定 AL 中的数值是否是一个有效十进制数的 ASCII 码。过程被调用时,将一个 ASCII 字符传递到 AL。如果 AL 包含的是一个有效十进制数,则过程将零标志位置 1;否则,清除零标志位。调用示例如下:

mov AL,somechar
call IsDigit

MsgBox

MsgBox 过程显示一个带选择项的图形界面弹出消息框。(当程序运行于控制台窗口时有效。)过程用 EDX 传递一个字符串的偏移量,该字符串将显示在消息框中。还可以用 EBX 传递消息框标题字符串的偏移量,如果标题为空,则 EBX 为 0。调用示例如下:

.data
caption BYTE "Dialog Title", 0
HelloMsg BYTE "This is a pop-up message box.", 0dh, 0ah
         BYTE "Click OK to continue...", 0
.code
mov ebx,OFFSET caption
mov edx,OFFSET HelloMsg
call MsgBox

MsgBoxAsk

MsgBoxAsk 过程显示带有 Yes 和 No 按钮的图形弹岀消息框。(当程序运行于控制台窗口时有效。)过程用 EDX 传递问题字符串的偏移量,该问题字符串将显示在消息框中。还可以用 EBX 传递消息框标题字符串的偏移量,如果标题为空,则 EBX 为 0。

MsgBoxAsk 用 EAX 中的返回值表示用户选择的是哪个按钮,返回值有两个选择,都是预先定义的 Windows 常数:IDYES (值为 6)或 IDNO(值为 7)。调用示例如下:

.data
caption BYTE "Survey Completed",0
question BYTE "Thank you for completing the survey."
BYTE 0dh,0ah
BYTE "Would you like to receive the results?",0
.code
mov ebx,OFFSET caption
mov edx,OFFSET question
call MsgBoxAsk                    ;查看 EAX 中的返回值

OpenlnputFile

OpenlnputFile 过程打开一个已存在的文件进行输入。过程用 EDX 传递文件名的偏移量。当从过程返回时,如果文件成功打开,则 EAX 就包含有效的文件句柄。 否则,EAX 等于 INVALID_HANDLE_VALUE(一个预定义的常数)。

调用示例如下:

.data
filename BYTE "myfile.txt",0
.code
mov edx,OFFSET filename
call OpenlnputFile

下述伪代码显示了调用 OpenlnputFile 后可能的结果:

if EAX = INVALID_HANDLE_VALUE
    the file was not opened successfully
else
    EAX = handle for the open file
endif

ParseDecimal32

ParseDecimal32 过程将一个无符号十进制整数字符串转换为 32 位二进制数。非数字符号之前所有的有效数字都要转,前导空格要忽略。过程用 EDX 传递字符 串的偏移量,用 ECX 传递字符串的长度,用 EAX 返回二进制数值。

调用示例如下:

.data
buffer BYTE "8193"
bufSize = ($ - buffer)
.code
mov edx,OFFSET buffer
mov ecx, bufSize
call Pars eDecimal32     ;返回 EAX
  • 如果整数为空,则 EAX=0 且 CF=1

  • 如果整数只有空格,则 EAX=0 且 CF=1

  • 如果整数大于(2³²-1),则 EAX=0 且 CF=1

  • 否则,EAX 为转换后的数,且 CF=0

参阅 ReadDec 过程的说明,详细了解进位标志位是如何受到影响的。

Parselnteger32

Parselnteger32 过程将一个有符号十进制整数字符串转换为32位二进制数。字符串开始到第一个非数字符号之间所有的有效数字都要转,前导空格要忽略。过程用 EDX 传递字符串的偏移量,用 ECX 传递字符串的长度,用 EAX 返回二进制数值。调用示例如下:

.data
buffer byte ,'-8193"
bufSize = ($ - buffer)
.code
mov edx,OFFSET buffer
mov ecx,bufSize
call Parselnteger32            ;返回 EAX

字符串可能包含一个前导加号或减号,但其后只能跟十进制数字。如果数值不能表示为 32 位有符号整数(范围:-2 147 483 648 到 +2 147 483 647),则溢出标志位置 1,且在控制 台显示一个错误信息。

Random32

Random32 过程生成一个 32 位随机整数并用 EAX 返回该数。当被反复调用时,Random32 就会生成一个模拟的随机数序列,这些数由一个简单的函数产生,该函数有一个输入称为种子(seed)。

函数利用公式里的种子生成一个随机数值,并且每次都使用前次生成的随机数作为种子,来生成后续随机数。下述代码段展示了一个调用 Random32 的例子:

.data
randVal DWORD ?
.code
call Random32
mov randVal, eax

Randomize

Randomize 过程对 Random32 和 RandomRange 过程的第一个种子进行初始化。种子等于一天中的时间,精度为 1/100 秒。每当调用 Random32 和 RandomRaiige 的程序运行时,生成的随机数序列都不相同。而 Randomize 程只需要在程序开头调用一次。 下面的例子生成了 10 个随机整数:

call Randomize
mov ecx,10
L1: call Random32
;在此使用或显示 EAX 中的随机数
loop L1

RandomRange

RandomRange 过程在范围 0〜n-1 内生成一个随机整数,其中 n 是用 EAX 寄存器传递的输入参数。生成的随机数也用 EAX 返回。下面的例子在 0 到 4999 之间生成一个随机整数,并将其放在变量 randVal 中。

.data
randVal DWORD ?
.code
mov eax,5000
call RandomRange
mov randVal, eax

ReadChar

ReadChar 过程从键盘读取一个字符,并用 AL 寄存器返回,字符不在控制台窗口中回显。调用示例如下:

.data
char BYTE ?
.code
call ReadChar
mov char,al

如果用户按下的是扩展键,如功能键、方向键、Ins 键或 Del 键,则过程就把 AL 清零,而 AH 包含的是键盘扫描码。EAX 的高字节没有使用。下述伪代码描述了调用 ReadChar 之后可能产生的结果:

if an extended key was pressed
    AL = 0
    AH = keyboard scan code
else
    AL = ASCII key value
endif

ReadDec

ReadDec 过程从键盘读取一个 32 位无符号十进制整数,并用 EAX 返回该值,前导空格要忽略。返回值为遇到第一个非数字字符之前的所有有效数字。比如,如果用户输入123AEC,则 EAX 中的返回值为 123。下面是一个调用示例:

.data
intVal DWORD ?
.code
call ReadDec
mov intVal,eax

ReadDec 会影响进位标志位:

  • 如果整数为空,则 EAX=0 且 CF=1

  • 如果整数只有空格,则 EAX=0 且 CF=1

  • 如果整数大于(2³²-1),则 EAX=0 且 CF=1

  • 否则,EAX 为转换后的数,且 CF=0

ReadFromFile

ReadFromFile 过程读取存储缓冲区中的一个输入磁盘文件。当调用 ReadFromFile 时,用 EAX 传递打开文件的句柄,用 EDX 传递缓冲区的偏移量,用 ECX 传递读取的最大字节数。

ReadFromFile 返回时要查看进位标志位的值:如果 CF 清零,则 EAX 包含了从文件中读取的字节数;如果 CF 置 1,则 EAX 包含了数字系统错误代码。调用 WriteWindowsMsg 程就可以获得该错误的文本。在下面的例子中,从文件读取的 5000 个字节复制到了缓冲区变量:

.data
BUFFER_SIZE = 5000
buffer BYTE BUFFER_SIZE DUP(?)
bytesRead DWORD ?
.code
mov edx,OFFSET buffer         ;指向缓冲区
mov ecx,BUFFER_SIZE           ;读取的最大字节数
call ReadFromFile             ; 读文件 }

如果此时进位标志位清零,则可以执行如下指令:

mov bytesRead, eax            ;实际读取的字节数

但是,如果此时进位标志位置 1,就可以调用 WriteWindowsMsg 过程,显示错误代码以及该应用最近产生错误的说明:

call WriteWindowsMsg

ReadHex

ReadHex 过程从键盘读取一个 32 位十六进制整数,并用 EAX 返回相应的二进制数。对无效字符不进行任何错误检查。字母 A 到 F 的大小写都可以使用。最多能够输入 8 个数字(超出的字符将被忽略),前导空格将被忽略。调用示例如下:

.data
hexVal DWORD ?
.code
call ReadHex
mov hexVal,eax

Readlnt

Readlnt 过程从键盘读取一个 32 位有符号整数,并用 EAX 返回该值。用户可以键入前置加号或减号,而其后跟的只能是数字。

Readlnt 设置溢出标志位,如果输入数值无法表示为 32 位有符号数(范围:-2 147 483 648 至 +2 147 483 647),则显示一个错误信息。返回值包括所有的有效数字,直到遇见第一个非数字字符。例如,如果用户输入 +123ABC,则返回值为 +123。调用示例如下:

.data
intVal SDWORD ?
.code
call Readlnt
mov intVal,eax

ReadKey

ReadKey 过程执行无等待键盘检查。换句话说,它检查键盘输入缓冲区以查看用户是否有按键操作。如果没有发现键盘数据,则零标志位置 1。如果 ReadKey 发现有按键,则清除零标志位,且向 AL 送入 0 或 ASCII 码。若 AL 为 0,表示用户可能按下了一个特殊键(功能键、方向键等)。

AH 寄存器为虚拟扫描码,DX 为虚拟键码,EBX 为键盘标志位。下述伪代码说明了调用 ReadKey 时的各种结果:

if no_keyboard_data then
    ZF = 1
else
    ZF = 0
    if AL = 0 then
        extended key was pressed, and AH = scan code, DX = virtual
           key code, and EBX = keyboard flag bits
    else
        AL = the key's ASCII code
    endif
endif

当调用 ReadKey 时,EAX 和 EDX 的高 16 位会被覆盖。

ReadString

ReadString 过程从键盘读取一个字符串,直到用户键入回车键。过程用 EDX 传递缓冲区的偏移量,用 ECX 传递用户能键入的最大字符数加 1(保留给终止空字节),用 EAX 返回用户键入的字符数。示例调用如下:

.data
buffer BYTE 21 DUP(0)          ;输入缓冲区
byteCount DWORD ?              ;定义计数器
.code
mov edx,OFFSET buffer           ;指向缓冲区
mov ecxz SIZEOF buffer          ;定义最大字符数
call ReadString                 ;输入字符串
mov byteCount, eax              ;字符数

ReadString 在内存中字符串的末尾自动插入一个 null 终止符。用户输入“ABCDEFG”后,buffer 中前 8 个字节的十六进制形式和 ASCII 形式如下所示:

41 42 43 44 45 46 47 00 ABCDEFG

变量 byteCoun t等于 7。

SetTextColor

SetTextColor 过程(仅在 Irvine32 链接库中)设置输出文本的前景色和背景色。调用 SetTextColor 时,给 EAX 分配一个颜色属性。下列预定义的颜色常数都可以用于前景色和背景色:

black = 0 red = 4 gray = 8 lightRed = 12
blue = 1 magenta = 5 lightBlue = 9 light Magenta = 13
green = 2 brown = 6 light Green = 10 yellow = 14
cyan = 3 lightGray = 7 lightCyan = 11 white = 15

颜色常量在 Irvine32.inc 文件中进行定义。要获得完整的颜色字节数值,就将背景色乘以 16 再加上前景色。例如,下述常量表示在蓝色背景上输出黄色字符:

yellow + (blue * 16)

下列语句设置为蓝色背景上输出白色字符:

mov eax,white + (blue * 16)     ; 蓝底白字
call SetTextColor

另一种表示颜色常量的方法是使用 SHL 运算符,将背景色左移 4 位再加上前景色。

yellow + (blue SHL 4)

位移是在汇编时执行的,因此它只能用常数作操作数。

Str_length

Str_length 过程返回空字节结束的字符串的长度。过程用 EDX 传递字符串的偏移量,用 EAX 返回字符串的长度。调用示例如下:

.data
buffer BYTE "abcde",0
bufLength DWORD ?
.code
mov edx, OFFSET buffer          ;指向字符串
call Str_length                 ;EAX=5
mov bufLength, eax              ;保存长度

WaitMsg

WaitMsg 过程显示“Press any key to continue…”消息,并等待用户按键。当用户想在数据滚动和消失之前暂停屏幕显示时,这个过程就很有用。过程没有输入参数。 调用示例如下:

call WaitMsg

WriteBin

WriteBin 过程以 ASCII 二进制格式向控制台窗口输出一个整数。过程用 EAX 传递该整数。为了便于阅读,二进制位以四位一组的形式进行显示。调用示例如下:

mov eax,12346AF9h
call WriteBin

示例代码显示如下:

0001 0010 0011 0100 0110 1010 1111 1001

WriteBinB

WriteBinB 过程以 ASCII 二进制格式向控制台窗口输出一个 32 位整数。过程用 EAX 寄存器传递该整数,用 EDX 表示以字节为单位的显示大小(1、2,或 4)。为了便于阅读,二进制位以四位一组的形式进行显示。调用示例如下:

mov eax,00001234h
mov ebx,TYPE WORD           ; 两个字节
call WriteBinB              ; 显示 0001 0010 0011 0100

WriteChar

WriteChar 过程向控制台窗口写一个字符。过程用 AL 传递字符(或其 ASCII 码)。调用示例如下:

mov al, 'A'
call WriteChar           ;显示:"A"

WriteDec

WriteDec 过程以十进制格式向控制台窗口输出一个 32 位无符号整数,且没有前置 0。过程用 EAX 寄存器传递该整数。调用示例如下:

mov eax,295
call WriteDec            ;显示:"295"

WriteHex

WriteHex 过程以 8 位十六进制格式向控制台窗口输出一个 32 位无符号整数,如果需要,应插入前置 0。过程用 EAX 传递整数。调用示例如下:

mov eax,7FFFh
call WriteHex           ;显示:"00007FFF"

WriteHexB

WriteHexB 过程以十六进制格式向控制台窗口输岀一个 32 位无符号整数,如果需要,应插入前置 0。过程用 EAX 传递整数,用 EBX 表示显示格式的字节数(1、2,或 4)。调用示例如下:

mov eax, 7FFFh
mov ebx, TYPE WORD        ;两个字节
call WriteHexB            ;显示:"7FFF"

Writelnt

Writelnt 过程以十进制向控制台窗口输岀一个 32 位有符号整数,有前置符号,但没有前置 0。过程用 EAX 传递整数。调用示例如下:

mov eax, 216543
call Writelnt             ;显示:"+216543"

WriteString

WriteString 过程向操作台窗口输出一个空字节结束的字符串。过程用 EDX 传递字符串的偏移量。调用示例如下:

.data
prompt BYTE "Enter your name: ",0
.code
mov edx,OFFSET prompt
call WriteString

WriteToFile

WriteToFile 过程向一个输出文件写入缓冲区内容。过程用 EAX 传递有效的文件句柄,用 EDX 传递缓冲区偏移量,用 ECX 传递写入的字节数。当过程返回时,如果 EAX 大于 0,则其包含的是写入的字节数;否则,发生错误。下述代码调用了 WriteToFile:

BUFFER_SIZE = 5000
.data
fileHandle DWORD ?
buffer BYTE BUFFER_SIZE DUP(?)
.code
mov eax, fileHandle
mov edx, OFFSET buffer
mov ecx, BUFFER SIZE
call WriteToFile

下面的伪代码说明了调用 WriteToFile 之后对 EAX 返回值的处理:

if EAX = 0 then
    error occurred when writing to file
    call WriteWindowsMessage to see the error
else
    EAX = number of bytes written to the file
endif

WriteWindowsMsg

WriteWindowsMsg 过程向控制台窗口输出应用程序在调用系统函数时最近产生的错误信息。调用示例如下:

call WriteWindowsMsg

下面的例子展示了一个消息字符串:

Error 2: The system cannot find the file specified.

汇编语言Irvine64链接库

一个能支持 64 位编程的最小链接库,其中包含了如下过程:

  • Crlf:向控制台写一个行结束的序列。

  • Random64:在0〜2⁶⁴-1 内,生成一个 64 位的伪随机整数。随机数值用 RAX 寄存器返回。

  • Randomize:用一个值作为随机数生成器的种子。

  • Readlnt64:从键盘读取一个 64 位有符号整数,用回车符结束。数值用 RAX 寄存器返回。

  • ReadString:从键盘读取一个字符串,用回车符结束。过程用 RDX 传递输入缓冲器偏移量;用 RCX 传递用户可输入的最大字符数加 1(用于 unll 结束符字节)。返回值(用 RAX)为用户实际输入的字符数。

  • Str_compare:比较两个字符串。过程将源串指针传递给 RSI,将目的串指针传递给 RDIO 用与 CMP(比较)指令一样的方式设置零标志位和进位标志位。

  • Str_copy:将一个源串复制到目标指针指定的位置。源串偏移量传递给 RSI,目标偏移量传递给 RDI。

  • Strjength:用 RAX 寄存器返回一个空字节结束的字符串的长度。过程用 RCX 传递字符串的偏移量。

  • Writelnt64:将 RAX 寄存器中的内容显示为 64 位有符号十进制数,并加上前置加号或减号。过程没有返回值。

  • WriteHex64:将 RAX 寄存器中的内容显示为 64 位十六进制数。过程没有返回值。

  • WriteHexB:将 RAX 寄存器中的内容显示为 1 字节、2 字节、4 字节或 8 字节的十六进制数。将显示的大小(1、2、4 或 8) 传递给 RBX 寄存器。过程没有返回值。

  • WriteString:显示一个空字节结束的 ASCII 字符串。将字符串的 64 位偏移量传递给 RDX。过程没有返回值。

尽管这个库比 32 位链接库小很多,它还是包含了许多重要工具能使得程序更具互动性。随着学习的深入,大家可以用自己的代码来扩展这个链接库。Irvine64 链接库会保留 RBX、RBP、RDI、RSI、R12、R13、R14 和 R15 寄存器的值,反之,RAX、RCX、RDX、R8、R9、R10 和 R11 寄存器的值则不会保留。

调用 64 位子程序

如果想要调用自己编写的子程序,或是 Irvine64 链接库中的子程序,则程序员需要做的就是将输入参数送入寄存器,并执行 CALL 指令。比如:

mov rax,12345678h
call WriteHex64

还有一件小事也需要完成,即程序员要在自己程序的顶部用 PROTO 伪指令指定所有在本程序之外同时又将会被调用的过程:

ExitProcess PROTO         ;位于 Windows API
WriteHex64 PROTO          ;位于 Irvine64 链接库

x64 调用规范

Microsoft 在 64 位程序中使用统一模式来传递参数并调用过程,称为 Microsoft x64 调用规范。该规范由 C/C++ 编译器和 Windows 应用编程接口(API)使用。

程序员只有在调用 Windows API 的函数或用 C/C++ 编写的函数时,才会使用这个调用规范。该调用规范的一些基本特性如下所示:

\1) CALL 指令将 RSP(堆栈指针)寄存器减 8,因为地址是 64 位的。

\2) 前四个参数依序存入 RCX、RDX、R8 和 R9 寄存器,并传递给过程。如果只有一个参数,则将其放入 RCX。如果还有第二个参数,则将其放入 RDX,以此类推。其他参数,按照从左到右的顺序压入堆栈。

\3) 调用者的责任还包括在运行时堆栈分配至少 32 字节的影子空间(shadow space),这样,被调用的过程就可以选择将寄存器参数保存在这个区域中。

\4) 在调用子程序时,堆栈指针(RSP)必须进行 16 字节边界对齐(16 的倍数)。CALL 指令把 8 字节的返回值压入堆栈,因此,除了已经减去的影子空间的 32 之外,调用程序还必须从堆栈指针中减去 8。后面的示例将显示如何实现这些操作。

提示:调用 Irvine64 链接库中的子程序时,不需使用 Microsoft x64 调用规范;只在调用 Windows API 函数时使用它。

调用过程示例

现在编写一段小程序,使用 Microsoft x64 调用规范来调用子程序 AddFour。这个子程序将四个参数寄存器(RCX、RDX、R8 和 R9)的内容相加,并将和数保存到 RAX。

由于过程通常使用 RAX 返回结果,因此,当从子程序返回时,调用程序也期望返回值在这个寄存器中。这样就可以说这个子程序是一个函数,因为,它接收了四个输入并(确切地说)产生了一个输出。

;在64模式下调用子程序
ExitProcess PROTO
WriteInt64 PROTO          ;Irvine64链接库
Crlf PROTO                ;Irvine64链接库
.code
main PROC
    sub    rsp,8            ;对准堆栈指针
    sub    rsp,20h          ;为影子参数保留32个字节
    mov    rcx,1            ;依序传递参数
    mov    rdx,2
    mov    r8,3
    mov    r9,4
    call AddFour            ;在RAX中查找返回值
    call WriteInt64         ;显示数字
    call Crlf               ;输出回车换行符
    mov    ecx,0
    call ExitProcess
main ENDP
AddFour PROC
    mov rax,rcx
    add    rax,rdx
    add    rax,r8
    add    rax,r9            ;和数保存在RAX中
    ret
AddFour ENDP
END

现在来看看本例中的其他细节:第 10 行将堆栈指针对齐到 16 字节的偶数边界。为什么要这样做?在 OS 调用主程序之前,假设堆栈指针是对齐 16 字节边界的。然后,当 OS 调用主程序时,CALL 指令将 8 字节的返回地址压入堆栈。将堆栈指针再减去 8,使其减少成一个 16 的倍数。

可以在 Visual Studio 调试器中运行该程序,并查看 RSP 寄存器(堆栈指针)改变数值。通过这个方法,能够看到用图形方式在下图中展示的十六进制数值。

上图只展示了每个地址的低 32 位,因为高 32 位为全零:

\1) 执行第 10 行前,RSP=01AFE48。这表示在 OS 调用本程序之前,RSP 等于 01AFE50。( CALL 指令使得堆栈指针减 8。)

\2) 执行第 10 行后,RSP=01AFE40,表示堆栈正好对齐到 16 字节边界。

\3) 执行第 11 行后,RSP=01AFE20,表示 32 个字节的影子空间位置从 01AFE20 到 01AFE3F。

\4) 在 AddFour 过程中,RSP=01AFE18,表示调用者的返回地址已经压入堆栈。

\5) 从 AddFour 返回后,RSP 再一次等于 01AFE20,与调用 AddFour 之前的值相同。

与调用 ExitProcess 来结束程序相比,本程序选择的是执行 RET 指令,这将返回到启动本程序的过程。但是,这也就要求能将堆栈指针恢复到其在 main 程开始执行时的位置。下面的代码行能替代 CallProc_64 程序的第 20 和 21 行:

add rsp,28         ;恢复堆栈指针
mov ecx,0          ;过程返回码
ret                ;返回 OS

提示:要使用 Irvine64 链接库,将 Irvine64.obj 文件添加到用户的 Visual Studio 项目中。Visual Studio 中的操作步骤如下:在 Solution Explorer 窗口中右键点击项目名称,选择 Add,选择 Existing Item,再选择 Irvine64.obj 文件名。

汇编语言条件判断

汇编语言布尔和比较指令简介

首先了解按位指令,这里使用的技术也可以用于操作硬件设备控制位,实现通信协议以及加密数据,这里只列举了几种应用。Intel 指令集包含了 AND、OR、XOR 和 NOT 指令,它们能直接在二进制位上实现布尔操作,如下表所示。此外,TEST 指令是一种非破坏性的 AND 操作。

操作 说明
AND 源操作数和目的操作数进行逻辑与操作
OR 源操作数和目的操作数进行逻辑或操作
XOR 源操作数和目的操作数进行逻辑异或操作
NOT 对目标操作数进行逻辑非操作
TEST 源操作数和目的操作数进行逻辑与操作,并适当地设置 CPU 标志位

布尔指令影响零标志位、进位标志位、符号标志位、溢出标志位和奇偶标志位。下面简单回顾一下这些标志位的含义:

  • 操作结果等于 0 时,零标志位置 1。

  • 操作使得目标操作数的最高位有进位时,进位标志位置 1。

  • 符号标志位是目标操作数高位的副本,如果标志位置 1,表示是负数;标志位清 0,表示是正数。(假设 0 为正。)

  • 指令产生的结果超出了有符号目的操作数范围时,溢出标志位置 1。

  • 指令使得目标操作数低字节中有偶数个 1 时,奇偶标志位置 1。

汇编语言AND指令:对两个操作数进行逻辑(按位)与操作

AND 指令在两个操作数的对应位之间进行(按位)逻辑与(AND)操作,并将结果存放在目标操作数中:

AND destination,source

下列是被允许的操作数组合,但是立即操作数不能超过 32 位:

AND reg, reg
AND reg, mem
AND reg, imm
AND mem, reg
AND mem, imm

操作数可以是 8 位、16 位、32 位和 64 位,但是两个操作数必须是同样大小。两个操作数的每一对对应位都遵循如下操作原则:如果两个位都是 1,则结果位等于 1;否则结果位等于 0。

下表展示了两个输入位 X 和 Y,第三列是表达式 X^Y 的值:

X Y X^Y
0 0 0
0 1 0
1 0 0
1 1 1

AND 指令可以清除一个操作数中的 1 个位或多个位,同时又不影响其他位。这个技术就称为位屏蔽,就像在粉刷房子时,用遮盖胶带把不用粉刷的地方(如窗户)盖起来。

例如,假设要将一个控制字节从 AL 寄存器复制到硬件设备。并且当控制字节的位 0 和位 3 等于 0 时,该设备复位。那么,如果想要在不修改 AL 其他位的条件下,复位设备,可以用下面的指令:

and AL, 11110110b            ;清除位 0 和位 3 ,其他位不变

如,设 AL 初始化为二进制数 1010 1110,将其与 1111 0110 进行 AND 操作后,AL 等于 1010 0110:

mov al,10101110b
and al, 11110110b   ;AL 中的结果 = 1010 0110

标志位

AND 指令总是清除溢出和进位标志位,并根据目标操作数的值来修改符号标志位、零标志位和奇偶标志位。比如,下面指令的结果存放在 EAX 寄存器,假设其值为 0。在这种情况下,零标志位就会置 1:

and eax,1Fh

将字符转换为大写

AND 指令提供了一种简单的方法将字符从小写转换为大写。如果对比大写 A 和小写 a 的 ASCII 码,就会发现只有位 5 不同:

0 1 1 0 0 0 0 1 = 61h ('a')
0 1 0 0 0 0 0 1 = 41h ('A')

其他的字母字符也是同样的关系。把任何一个字符与二进制数 1101 1111 进行 AND,则除位 5 外的所有位都保持不变,而位 5 清 0。下例中,数组中所有字符都转换为大写:

.data
array BYTE 50 DUP(?)
.code
    mov ecx,LENGTHOF array
    mov esi,OFFSET array
L1: and BYTE PTR [esi], 11011111b       ;清除位 5
    inc esi
    loop L1

汇编语言OR指令:对两个操作数进行逻辑(按位)或操作

OR 指令在两个操作数的对应位之间进行(按位)逻辑或(OR)操作,并将结果存放在目标操作数中:

OR destination, source

OR 指令操作数组合与 AND 指令相同:

OR reg,reg
OR reg,mem
OR reg, imm
OR mem,reg
OR mem,imm

操作数可以是 8 位、16 位、32 位和 64 位,但是两个操作数必须是同样大小。对两个操作数的每一对对应位而言,只要有一个输入位是 1,则输出位就是 1。下面的真值表展示了布尔运算 x∨y:

X Y X∨Y
0 0 0
0 1 1
1 0 1
1 1 1

当需要在不影响其他位的情况下,将操作数中的 1 个位或多个位置为 1 时,OR 指令就非常有用了。比如,计算机与伺服电机相连,通过将控制字节的位 2 置 1 来启动电机。假设该控制字节存放在 AL 寄存器中,每一个位都含有重要信息,那么,下面的指令就只设置了位 2:

or AL, 00000100b ;位 2 置 1,其他位不变

如果 AL 初始化为二进制数 1110 0011,把它与 0000 0100 进行 OR 操作,其结果等于 1110 0111:

mov al,11100011b
or al, 00000100b       ;AL 中的结果 =1110 0111

标志位

OR 指令总是清除进位和溢出标志位,并根据目标操作数的值来修改符号标志位、零标志位和奇偶标志位。比如,可以将一个数与它自身(或 0)进行 OR 运算,来获取该数值的某些信息:

or al,al

下表给出了零标志位和符号标志位对 AL 内容的说明:

零标志位 符号标志位 AL 中的值
清0 清0 大于0
置1 清0 等于0
清0 置1 小于0

汇编语言位向量(位映射)

有些应用控制的对象是从一个有限全集中选出来的一组项目。就像公司里的雇员,或者气象监测站的环境读数。在这些情景中,二进制位可以代表集合成员。

与 Java HashSet 用指针或引用指向容器内对象不同,应用可以用位向量(或位映射)把一个二进制数中的位映射为数组中的对象。

如下例所示,二进制数的位从左边 0 号开始,到右边 31 号为止,该数表示了数组元素 0、1、2 和 31 是名为 SetX 的集合成员:

SetX = 10000000 00000000 00000000 00000111

(为了提供可读性,字节已经分开。)通过在特定位置与 1 进行 AND 运算,就可以方便地检测出该位是否为集合成员:

mov eax,SetX
and eax, 10000b  ;元素[4]是 SetX 的成员吗?

如果本例中的 AND 指令清除了零标志位,那么就可以知道元素[4]是 SetX 的成员。

1) 补集

补集可以用 NOT 指令生成,NOT 指令将所有位都取反。因此,可以用下面的指令生成上例中 SetX 的补集,并存放在 EAX 中:

mov eax,SetX
not eax                 ;Setx的补集

2) 交集

AND 指令可以生成位向量来表示两个集合的交集。下面的代码生成集合 SetX 和 SetY 的交集,并将其存放在 EAX 中:

mov eax,SetX
and eax,SetY

SetX 和 SetY 交集生成过程如下所示:

        1000000000000000000000000000111 (SetX)
AND     1000001010100000000011101100011 (SetY)
-------------------------------------------------------------
        1000000000000000000000000000011 (交集)

很难想象还有更快捷的方法生成交集。对于更大的集合来说,它所需要的位超过了单个寄存器的容量,因此,需要用循环来实现所有位的 AND 运算。

3) 并集

OR 指令生成位图表示两个集合的并集。下面的代码产生集合 SetX 和 SetY 的并集,并将其存放在 EAX 中:

mov eax,SetX
or eax,SetY

OR 指令生成 SetX 和 SetY 并集的过程如下所示:

         1000000000000000000000000000111 (SetX)
OR       1000001010100000000011101100011 (SetY)
-------------------------------------------------------------
         1000001010100000000011101100111 (并集)

汇编语言XOR指令:对两个操作数进行逻辑(按位)异或操作

XOR 指令在两个操作数的对应位之间进行(按位)逻辑异或(XOR)操作,并将结果存放在目标操作数中:

XOR destination, source

XOR 指令操作数组合和大小与 AND 指令及 OR 指令相同。两个操作数的每一对对应位都应用如下操作原则:如果两个位的值相同(同为 0 或同为 1),则结果位等于 0;否则结果位等于 1。

下表描述的是布尔运算 X㊉y:

x y x㊉y
0 0 0
0 1 1
1 0 1
1 1 0

与 0 异或值保持不变,与 1 异或则被触发(求补)。对相同操作数进行两次 XOR 运算,则结果逆转为其本身。如下表所示,位 x 与位 y 进行了两次异或,结果逆转为 x 的初始值:

x y x㊉y (x㊉y)㊉y
0 0 0 0
0 1 1 0
1 0 1 1
1 1 0 1

异或运算这种“可逆的”属性使其成为简单对称加密的理想工具。

标志位

XOR 指令总是清除溢岀和进位标志位,并根据目标操作数的值来修改符号标志位、零标志位和奇偶标志位。

检查奇偶标志

奇偶检查是在一个二进制数上实现的功能,计算该数中 1 的个数;如果计算结果为偶数,则说该数是偶校验;如果结果为奇数,则该数为奇校验。

x86 处理器中,当按位操作或算术操作的目标操作数最低字节为偶校验时,奇偶标志位置 1。反之,如果操作数为奇校验,则奇偶标志位清 0。一个既能检查数的奇偶性,又不会修改其数值的有效方法是,将该数与 0 进行异或运算:

mov al,10110101b       ;5 个 1,奇校验
xor al, 0                              ;奇偶标志位清 0 (奇)
mov al, 11001100b       ;4 个 1,偶校验
xor al, 0                              ;奇偶标志位置 1(偶)

Visual Studio 用 PE=1 表示偶校验,PE=0 表示奇校验。

16 位奇偶性

对 16 位整数来说,可以通过将其高字节和低字节进行异或运算来检测数的奇偶性:

mov ax,64Clh   ;0110 0100 1100 0001
xor ah, al           ;奇偶标志位置1 (偶)

将每个寄存器中的置 1 位(等于 1 的位)想象为一个 8 位集合中的成员。XOR 指令把两个集合交集中的成员清 0,并形成了其余位的并集。这个并集的奇偶性与整个 16 位整数的奇偶性相同。

那么 32 位数值呢?如果将数值的字节进行编号,从 B₀ 到 B₃ 那么计算奇偶性的表达式为:B₀ XOR B₁ XOR B₂ XOR B₃。

汇编语言NOT(反码)指令:翻转操作数的所有位

NOT 指令触发(翻转)操作数中的所有位。其结果被称为反码。该指令允许的操作数类型如下所示:

NOT reg
NOT mem

例如,F0h 的反码是 0Fh:

mov al,11110000b
not al             ;AL = 00001111b

提示:NOT 指令不影响标志位。

汇编语言TEST指令:对两个操作数进行逻辑(按位)与操作

TEST 指令在两个操作数的对应位之间进行 AND 操作,并根据运算结果设置符号标志位、零标志位和奇偶标志位。

TEST 指令与《AND指令》中介绍的 AND 指令唯一不同的地方是,TEST 指令不修改目标操作数。TEST 指令允许的操作数组合与 AND 指令相同。在发现操作数中单个位是否置位时,TEST 指令非常有用。

示例:多位测试

TEST 指令同时能够检查几个位。假设想要知道 AL 寄存器的位 0 和位 3 是否置 1,可以使用如下指令:

test al, 00001001b ;测试位 0 和位 3

(本例中的 0000 1001 称为位掩码。)从下面的数据集例子中,可以推断只有当所有测试位都清 0 时,零标志位才置 1:

0 0 1 0 0 1 0 1   <- 输入值
0 0 0 0 1 0 0 1   <- 测试值
0 0 0 0 0 0 0 1   <- 结果:ZF=0

0 0 1 0 0 1 0 0   <- 输入值
0 0 0 0 1 0 0 1   <- 测试值
0 0 0 0 0 0 0 0   <- 结果:ZF=1

标志位

TEST 指令总是清除溢出和进位标志位,其修改符号标志位、零标志位和奇偶标志位的方法与 AND 指令相同。

汇编语言CMP(比较)指令:比较整数

了解了所有按位操作指令后,现在来讨论逻辑(布尔)表达式中的指令。最常见的布尔表达式涉及一些比较操作,下面的伪码片段展示了这种情况:

if A > B ...
while X > 0 and X < 200  ...
if check_for_error(N) = true

x86 汇编语言用 CMP 指令比较整数。字符代码也是整数,因此可以用 CMP 指令。

CMP(比较)指令执行从目的操作数中减去源操作数的隐含减法操作,并且不修改任何操作数:

CMP destination,source

标志位

当实际的减法发生时,CMP 指令按照计算结果修改溢出、符号、零、进位、辅助进位和奇偶标志位。

如果比较的是两个无符号数,则零标志位和进位标志位表示的两个操作数之间的关系如右表所示:

CMP结果 ZF CF
目的操作数 < 源操作数 0 1
目的操作数 > 源操作数 0 0
目的操作数 = 源操作数 1 0

如果比较的是两个有符号数,则符号标志位、零标志位和溢出标志位表示的两个操作数之间的关系如右表所示:

CMP结果 标志位
目的操作数 < 源操作数 SF ≠ OF
目的操作数 > 源操作数 SF=OF
目的操作数 = 源操作数 ZF=1

CMP 指令是创建条件逻辑结构的重要工具。当在条件跳转指令中使用 CMP 时,汇编语言的执行结果就和 IF 语句一样。

下面用三段代码来说明标志位是如何受到 CMP 影响的。设 AX=5,并与 10 进行比较,则进位标志位将置 1,原因是(5-10)需要借位:

mov ax, 5
cmp ax,10    ; ZF = 0 and CF = 1

1000 与 1000 比较会将零标志位置 1,因为目标操作数减去源操作数等于 0:

mov ax,1000
mov cx,1000
cmp cx, ax       ;ZF = 1 and CF = 0
105 与 0 进行比较会清除零和进位标志位,因为(105-0)的结果是一个非零的正整数。
mov si,105
cmp si, 0       ; ZF = 0 and CF = 0

汇编语言置位和清除单个CPU标志位

怎样能方便地置位和清除零标志位、符号标志位、进位标志位和溢出标志位?有几种方法,其中的一些需要修改目标操作数。要将零标志位置 1,就把操作数与 0 进行 TEST 或 AND 操作;要将零标志位清零,就把操作数与 1 进行 OR 操作:

test al, 0          ;零标志位置 1
and al, 0          ;零标志位置 1
or al, 1       ;零标志位清零

TEST 指令不修改目的操作数,而 AND 指令则会修改目的操作数。若要符号标志位置 1,将操作数的最高位和 1 进行 OR 操作;若要清除符号标志位,则将操作数最高位和 0 进行 AND 操作:

or al, 80h     ;符号标志位置 1
and al, 7Fh    ;符号标志位清零

若要进位标志位置 1,用 STC 指令;清除进位标志位,用 CLC 指令:

stc          ;进位标志位置 1
clc          ;进位标志位清零

若要溢出标志位置 1,就把两个正数相加使之产生负的和数;若要清除溢出标志位,则将操作数和 0 进行 OR 操作:

mov al,7Fh      ; AL = +127
inc al        ; AL = 80h (-128), OF=1
or eax, 0          ; 溢出标志位清零

汇编语言64位模式下的布尔指令

大多数情况下,64 位模式中的 64 位指令与 32 位模式中的操作是一样的。比如,如果源操作数是常数,长度小于 32 位,而目的操作数是一个 64 位寄存器或内存操作数,那么,目的操作数中所有的位都会受到影响:

.data
allones QWORD 0FFFFFFFFFFFFFFFFh
.code
    mov rax,allones                  ;RAX = FFFFFFFFFFFFFFFF
    and rax,80h                      ;RAX = 0000000000000080
    mov rax,allones                  ;RAX = FFFFFFFFFFFFFFFF
    and rax,8080h                    ;RAX = 0000000000008080
    mov rax,allones                  ;RAX = FFFFFFFFFFFFFFFF
    and rax,808080h                  ;RAX = 0000000000808080

但是,如果源操作数是 32 位常数或寄存器,那么目的操作数中,只有低 32 位会受到影响。如下例所示,只有 RAX 的低 32 位被修改了:

mov rax,allones                ;RAX = FFFFFFFFFFFFFFFF
and rax,80808080h              ;RAX = FFFFFFFF80808080

当目的操作数是内存操作数时,得到的结果是一样的。显然,32 位操作数是一个特殊的情况,需要与其他大小操作数的情况分开考虑。

汇编语言条件跳转简介

x86 指令集中没有明确的高级逻辑结构,但是可以通过比较和跳转的组合来实现它们。

执行一个条件语句需要两个步骤:

  • 第一步,用 CMP、AND 或 SUB 操作来修改 CPU 状态标志位;

  • 第二步,用条件跳转指令来测试标志位,并产生一个到新地址的分支。

下面是一些例子。

【示例 1】本例中的 CMP 指令把 EAX 的值与 0 进行比较,如果该指令将零标志位置 1,则 JZ(为零跳转)指令就跳转到标号 L1:

       cmp eax, 0
       jz L1                  ;如果 ZF=1 则跳转
       .
       .
L1:

【示例 2】本例中的 AND 指令对 DL 寄存器进行按位与操作,并影响零标志位。如果零标志位清零,则 JNZ(非零跳转)指令跳转:

       and dl,10110000b
       jnz L2                        ;如果 ZF=0 则跳转
       .
       .
L2 :

Jcond 指令

当状态标志条件为真时,条件跳转指令就分支到目标标号。否则,当标志位条件为假时,立即执行条件跳转后面的指令。语法如下所示:

Jcond destination

cond 是指确定一个或多个标志位状态的标志位条件。下面是基于进位和零标志位的例子:

JC 进位跳转(进位标志位置 1)
JNC 无进位跳转(进位标志位清零)
JZ 为零跳转(零标志位置 1)
JNZ 非零跳转(零标志位清零)

CPU 状态标志位最常见的设置方法是通过算术运算、比较和布尔运算指令。条件跳转指令评估标志位状态,利用它们来决定是否发生跳转。

用 CMP 指令 假设当 EAX=5 时,跳转到标号 L1。在下面的例子中,如果 EAX=5,CMP 指令就将零标志位置 1;之后,由于零标志位为 1,JE 指令就跳转到 L1:

cmp eax,5
je L1                 ;如果相等则跳转

JE 指令总是按照零标志位的值进行跳转。如果 EAX 不等于 5,CMP 就会清除零标志位,那么,JE 指令将不跳转。

下例中,由于 AX 小于 6,所以 JL 指令跳转到标号 L1:

mov ax, 5
cmp ax, 6
jl L1                ;小于则跳转

下例中,由于 AX 大于4,所以发生跳转:

mov ax,5
cmp ax,4
jg L1             ;大于则跳转

汇编语言条件跳转指令汇总

x86 指令集包含大量的条件跳转指令。它们能比较有符号和无符号整数,并根据单个 CPU 标志位的值来执行操作。条件跳转指令可以分为四个类型:

  • 基于特定标志位的值跳转

  • 基于两数是否相等,或是否等于(E)CX 的值跳转

  • 基于无符号操作数的比较跳转

  • 基于有符号操作数的比较跳转

下表展示了基于零标志位、进位标志位、溢出标志位、奇偶标志位和符号标志位的跳转。

助记符 说明 标志位/寄存器 助记符 说明 标志位/寄存器
JZ 为零跳转 ZF=1 JNO 无溢出跳转 OF=0
JNZ 非零跳转 ZF=0 JS 有符号跳转 SF=1
JC 进位跳转 CF=1 JNS 无符号跳转 SF=0
JNC 无进位跳转 CF=0 JP 偶校验跳转 PF=1
JO 溢出跳转 OF=1 JNP 奇校验跳转 PF=0

1) 相等性的比较

下表列出了基于相等性评估的跳转指令。有些情况下,进行比较的是两个操作数;其他情况下,则是基于 CX、ECX 或 RCX 的值进行跳转。表中符号 leftOp 和 rightOp 分别指的是 CMP 指令中的左(目的)操作数和右(源)操 作数:

助记符 说明
JE 相等跳转 (leftOp=rightOp)
JNE 不相等跳转 (leftOp M rightOp)
JCXZ CX=0 跳转
JECXZ ECX=0 跳转
JRCXZ RCX=0 跳转(64 位模式)
CMP leftOp,rightOp

操作数名字反映了代数中关系运算符的操作数顺序。比如,表达式 X< Y 中,X 被称为 leftOp,Y 被称为 rightOp。

尽管 JE 指令相当于 JZ(为零跳转),JNE 指令相当于 JNZ(非零跳转),但是,最好是选择最能表明编程意图的助记符(JE 或 JZ),以便说明是比较两个操作数还是检查特定的状态标志位。

下述示例使用了 JE、JNE、JCXZ 和 JECXZ 指令。仔细阅读注释,以保证理解为什么条件跳转得以实现(或不实现)。

示例 1:

mov edx, 0A523h
cmp edx, 0A523h
jne L5            ;不发生跳转
je L1                        ;跳转

示例 2:

mov bx,1234h
sub bx,1234h
jne L5            ;不发生跳转
je L1                        ;跳转

示例 3:

mov ex, 0FFFFh
inc ex
jexz L2                     ;跳转

示例4:

xor ecx,ecx
jeexz L2                  ;跳转

2) 无符号数比较

基于无符号数比较的跳转如下表所示。操作数的名称反映了表达式中操作数的顺序(比如 leftOp < rightOp)。下表中的跳转仅在比较无符号数值时才有意义。有符号操作数使用不同的跳转指令。

助记符 说明 助记符 说明
JA 大于跳转(若 leftOp > rightOp) JB 小于跳转(若 leftOp < rightOp)
JNBE 不小于或等于跳转(与 JA 相同) JNAE 不大于或等于跳转(与 JB 相同)
JAE 大于或等于跳转(若 leftOp ≥ rightOp) JBE 小于或等于跳转(若 leftOp ≤ rightOp)
JNB 不小于跳转(与 JAE 相同) JNA 不大于跳转(与 JBE 相同)

3) 有符号数比较

下表列岀了基于有符号数比较的跳转。下面的指令序列展示了两个有符号数值的比较:

助记符 说明 助记符 说明
JG 大于跳转(若 leftOp > rightOp) JL 小于跳转(若 leftOp < rightOp)
JNLE 不小于或等于跳转(与 JG 相同) JNGE 不大于或等于跳转(与 JL 相同)
JGE 大于或等于跳转(若 leftOp ≥ rightOp) JLE 小于或等于跳转(若 leftOp ≤ rightOp)
JNL 不小于跳转(与 JGE 相同) JNG 不大于跳转(与 JLE 相同)
mov al, +127       ;十六进制数值 7Fh
cmp al, -128             ;十六进制数值 80h
ja Is Above        ;不跳转,因为 7Fh < 80h
jg IsGreater        ;跳转,因为 +127 > -128

由于无符号数 7Fh 小于无符号数 80h,因此,为无符号数比较而设计的 JA 指令不发生跳转。另一方面,由于 +127 大于 -128,因此,为有符号数比较而设计的 JG 指令发生跳转。

对下面的代码示例,阅读注释,以保证理解为什么跳转得以实现(或不实现):

示例 1:

mov edx,-1
cmp edx, 0
jnl L5          ;不发生跳转(-1 ≥ 0 为假)
jnle L5                 ;不发生跳转(-1 > 0 为假)
jl L1                     ;跳转(-1 < 0 为真)

示例 2:

mov bx,+ 32
cmp bx,-35
jng L5         ;不发生跳转( + 32 ≤ -35 为假)
jnge L5        ;不发生跳转( + 32 < -35 为假)
jge L1                ;跳转( + 32 ≥ -35 为真)

示例 3:

mov ecx, 0
cmp ecx, 0
jg L5                    ;不发生跳转(0 > 0 为假)
jnl L1          ;跳转(0 ≥ 0 为真)

示例 4:

mov ecx, 0
cmp ecx, 0
jl L5                     ;不发生跳转(0 < 0 为假)
jng L1                  ;跳转(0 ≤ 0 为真)

汇编语言条件跳转应用及示例

条件跳转指令常常用这些状态标志位来决定是否将控制转向代码标号。例如,假设有一个名为 status 的 8 位内存操作数,它包含了与计算机连接的一个外设的状态信息。如果该操作数的位 5 等于 1,表示外设离线,则下面的指令就跳转到标号:

mov al, status
test al, 00100000b         ;测试位 5
jnz DeviceOffline

如果位 0、1 或 4 中任一位置 1,则下面的语句跳转到标号:

mov al, status
test al, 00010011b         ;测试位 0、1、4
jnz InputDataByte

如果是位 2、3 和 7 都置 1 使得跳转发生,则还需要 AND 和 CMP 指令:

mov al, status
and al,10001100b            ;屏蔽位 2、3 和 7
cmp al, 10001100b          ;所有位都置 1 ?
je ResetMachine        ;是:跳转

两个数中的较大数

下面的代码比较了 EAX 和 EBX 中的两个无符号整数,并且把其中较大的数送入 EDX:

   mov   edx, eax        ;假设EAX存放较大的数
   cmp eax, ebx                  ;若 EAX ≥ EBX
   jae L1                              ;跳转到 L1
   mov   edx, ebx        ;否则,将 EBX 的值送入 EDX
L1:                                      ;EDX 中存放的是较大的数

三个数中的最小数

下面的代码比较了分别存放于三个变量 VI、V2 和 V3 的无符号 16 位数值,并且把其中最小的数送入AX:
.data
V1 WORD ?
V2 WORD ?
V3 WORD ?
.code
       mov   ax, V1   ;假设 V1 是最小值
       cmp   ax, V2   ;如果 AX ≤ V2
       jbe   L1       ;跳转到 L1
       mov   ax, V2   ;否则,将 V2 送入 AX
LI:    cmp   ax, V3   ;如果 AX ≤ V3
       jbe L2        ;跳转到L2
       mov   ax, V3    ;否则,将V3送入AX
L2 :

循环直到按下按键

下面的 32 位代码会持续循环,直到用户按下任意一个标准的字母数字键。如果输入缓冲区中当前没有按键,那么 Irvine32 库中的 ReadKey 函数就会将零标 志位置1:

.data
char BYTE ?
.code
L1: mov eax, 10         
       call Delay             ;创建 10 毫秒的延迟;
       call ReadKey    ;检查按键
       jz L1           ;如果没有按键则循环
       mov char, AL    ;保存字符)

上述代码在循环中插入了一个 10 毫秒的延迟,以便 MS-Windows 有时间处理事件消息。如果省略这个延迟,那么按键可能被忽略。

【示例 1】:顺序搜索数组

常见的编程任务是在数组中搜索满足某些条件的数值。例如,下述程序就是在一个 16 位数组中寻找第一个非零数值。如果找到,则显示该数值;否则,就显示一条信息,以说明没有发现非零数值:

;扫描数组    (ArrayScan.asm)
;扫描数组寻找第一个非零数值
INCLUDE Irvine32.inc
.data
intArray SWORD 0,0,0,0,1,20,35,-12,66,4,0
;intArray SWORD 1,0,0,0            ;候补测试数据
;intArray SWORD 0,0,0,0            ;候补测试数据
;intArray SWORD 0,0,0,1            ;候补测试数据
noneMsg BYTE "A non-zero value was not found",0
.code
main PROC
    mov ebx,OFFSET intArray        ;指向数组
    mov ecx,LENGTHOF intArray      ;循环计数器
L1: cmp WORD PTR [ebx],0           ;将数值与0比较
    jnz found                      ;寻找数值
    add ebx,2                      ;指向下一个元素
    loop L1                        ;继续循环
    jmp    notFound                ;没有发现非零数值
found:
    movsx eax,WORD PTR[ebx]        ;送人EAX并进行符号扩展
    call WriteInt
    jmp quit
notFound:
    mov edx,OFFSET noneMsg         ;显示“没有发现”消息
    call WriteString
quit:
    call Crlf
    exit
main ENDP
END main
本程序包含了可以替换的测试数据,它们已经被注释出来。取消这些注释行,就可 以用不同的数据配置来测试程序。

【示例 2】:简单字符串加密

XOR 指令有一个有趣的属性。如果一个整数 X 与 Y 进行 XOR,其结果再次与 Y 进行 XOR,则最后的结果就是 X:

( ( X ㊉ Y ) ㊉ Y) = X

XOR 的可逆性为简单数据加密提供了一种方便的途径:明文消息转换成加密字符串,这个加密字符串被称为密文,加密方法是将该消息与被称为密钥的第三个字符串按位进行 XOR 操作。预期的查看者可以用密钥解密密文,从而生成原始的明文。

下面将演示一个使用对称加密的简单程序,即用同一个密钥既实现加密又实现解密的过程。运行时,下述步骤依序发生:

\1) 用户输入明文。

\2) 程序使用单字符密钥对明文加密,产生密文并显示在屏幕上。

\3) 程序解密密文,产生初始明文并显示出来。

程序清单完整的程序清单如下所示:

;加密程序    (Encrypt.asm)
INCLUDE Irvine32.inc
KEY = 239                    ;1-255之间的任一值
BUFMAX = 128                 ;缓冲区的最大容量
.data
sPrompt BYTE "Enter the plain text:",0
sEncrypt BYTE "Cipher text",0
sDecrypt BYTE "Decrypted:",0
buffer BYTE BUFMAX+1 DUP(0)
bufSize DWORD ?
.code
main PROC
    call InputTheString        ;输入明文
    call TranslateBuffer       ;加密缓冲区
    mov edx,OFFSET sEncrypt    ;显示加密消息
    call DisplayMessage
    call TranslateBuffer       ;解密缓冲区
    mov edx,OFFSET sDecrypt    ;显示解密消息
    call DisplayMessage
    exit
main ENDP
;-----------------------------------------
InputTheString PROC
;
;提示用户输入一个纯文本字符串
;保存字符串和它的长度
;接收:无
;返回:无
;-----------------------------------------
    pushad                     ;保存32位寄存器
    mov edx,OFFSET sPrompt     ;显示提示
    call WriteString
    mov ecx,BUFMAX             ;字符计数器最大值
    mov edx,OFFSET buffer      ;指向缓冲区
    call ReadString            ;输入字符串
    mov bufSize,eax            ;保存长度
    call Crlf
    popad
    ret
InputTheString ENDP
;-----------------------------------------
DisplayMessage PROC
;
;显示加密或解密消息
;接收:EDX指向消息
;返回:无
;-----------------------------------------
    pushad
    call WriteString
    mov edx,OFFSET buffer        ;显示缓冲区
    call WriteString
    call Crlf
    call Crlf
    popad
    ret
DisplayMessage ENDP
;-----------------------------------------
TranslateBuffer PROC
;
;字符串的每个字节都与密钥字节进行异或
;实现转换
;接收:无
;返回:无
;-----------------------------------------
    pushad
    mov ecx,bufSize                ;循环计数器
    mov esi,0                      ;缓冲区索引初始值赋0
L1:
    xor buffer[esi],KEY            ;转换一个字节
    inc esi                        ;指向下一个字节
    loop L1
    popad
    ret
TranslateBuffer ENDP
END main

汇编语言LOOPZ(为零跳转)和LOOPE(相等跳转)指令

LOOPZ(为零跳转)指令的工作和 LOOP 指令相同,只是有一个附加条件:为零控制转向目的标号,零标志位必须置 1。指令语法如下:

LOOPZ destination

LOOPE(相等跳转)指令相当于 LOOPZ 它们有相同的操作码。这两条指令执行如下任务:

ECX = ECX - 1
if ECX > 0 and ZF = 1, jump to destination

否则,不发生跳转,并将控制传递到下一条指令。LOOPZ 和 LOOPE 不影响任何状态标志位。32 位模式下,ECX 是循环计数器;64 位模式下,RCX 是循环计数器。

汇编语言LOOPNZ(非零跳转)和LOOPNE(不等跳转)指令

LOOPNZ(非零跳转)指令与 LOOPZ 相对应。当 ECX 中无符号数值大于零(减 1 操作之后)且零标志位等于零时,继续循环。指令语法如下:

LOOPNZ destination

LOOPNE(不等跳转)指令相当于 LOOPNZ 它们有相同的操作码。这两条指令执行如 下任务:

ECX = ECX - 1
if ECX > 0 and ZF = 0, jump to destination

否则,不发生跳转,并将控制传递到下一条指令。

【示例】扫描数组中的每一个数,直到发现一个非负数(符号位为 0)为止。注意,在执行 ADD 指令前要把标志位压入堆栈,因为 ADD 有可能修改标志位。然后在执行 LOOPNZ 指令之前,用 POPFD 恢复这些标志位:

.data
array  SWORD  -3,-6,-1,-10,10,30,40,4
sentinel SWORD  0
.code
main PROC
mov esi,OFFSET array
mov ecx,LENGTHOF array
next:
test WORD PTR [esi],8000h    ; 测试符号位
pushfd                       ; 标志位入栈
add  esi,TYPE array          ; 移动到下一个位置
popfd                        ; 标志位出栈
loopnz next                  ; 继续循环
jnz  quit                    ; 没有发现非负数
sub  esi,TYPE array          ; ESI 指向数值
quit:

如果找到一个非负数,ESI 会指向该数值。如果没有找到一个正数,则只有当 ECX=0 时才终止循环。在这种情况下,JNZ 指令跳转到标号 quit,同时 ESI 指向标记值(0),其在内存中的位置正好紧接着该数组。

使用汇编语言实现IF语句

IF 结构包含一个布尔表达式,其后有两个语句列表:一个是当表达式为真时执行,另一个是当表达式为假时执行:

if( boolean-expression )
   statement-list-1
else
   statement-list-2

结构中的 else 部分是可选的。在汇编语言中,则是用多个步骤来实现这种结构的。首先,对布尔表达式求值,这样一来某个 CPU 状态标志位会受到影响。然后,根据相关 CPU 状态标志位的值,构建一系列跳转把控制传递给两个语句列表。

【示例 1】下面的 C++ 代码中,如果 op1 等于 op2,则执行两条赋值语句:

if( op1 = op2 )
{
    X = 1;
    Y = 2;
}

在汇编语言中,这种 IF 语句转换为条件跳转和 CMP 指令。由于 op1 和 op2 都是内存操作数(变量),因此,在执行 CMP 之前,要将其中的一个操作数送入寄存器。

下面实现 IF 语句的程序是高效的,当逻辑表达式为真时,它允许代码“通过”直达两条期望被执行的 MOV 指令:

        mov eax, op1
        cmp eax,op2                  ; op1 == op2?
        jne L1                       ; 否:跳过后续指令
        mov X, 1                     ; 是:X, Y 赋值
        mov Y, 2
L1:

如果用 JE 来实现 == 运算符,生成的代码就没有那么紧凑了(6 条指令,而非 5 条指令):

        mov    eax, op1
        cmp    eax,op2              ; op1 == op2?
        je    L1                    ; 是:跳转到 L1
        jmp    L2                   ; 否:跳过赋值语句
LI:    mov X, 1                    ; X, Y 赋值
        mov    Y, 2
L2 :

从上面的例子可以看出,相同的条件结构在汇编语言中有多种实现方法。上面给出 的编译代码示例只代表一种假想的编译器可能产生的结果。

【示例 2】NTFS 文件存储系统中,磁盘簇的大小取决于磁盘卷的总容量。如下面的伪代码所示,如果卷大小(用变量 terrabytes 存放)不超过 16TB,则簇大小设置为 4096。否则, 簇大小设置为 8192:

clusterSize = 8192;
if terrabytes < 16
   clusterSize = 4096;

用汇编语言实现该伪代码:

        mov clusterSize, 8192                ;假设较大的磁盘簇
        cmp terrabytes, 16                   ;小于 16TB?
        jae next
        mov clusterSize, 4096                ;切换到较小的磁盘簇
next:

【示例 3】下面的伪代码有两个分支:

if op1 > op2
   call Routine1
else
   call Routine2
end if

用汇编语言翻译这段伪代码,设 op1 和 op2 是有符号双字变量。对这两个变量比较时,其中一个必须送入寄存器:

        mov eax, op1                    ; opl送入寄存器
        cmp    eax, op2                 ; opl > op2?
        jg    A1                        ; 是:调用 Routine1
        call    Routine2                ; 否:调用 Routine2
        jmp    A2    ;退出工F语句
A1:     call Routine1
A2:

白盒测试

复杂条件语句可能有多个执行路径,这使得它们难以进行调试检查(查看代码)。程序员经常使用的技术称为白盒测试,用来验证子程序的输入和相应的输出。

白盒测试需要源代码,并对输入变量进行不同的赋值。对每个输入组合,要手动跟踪源代码,验证其执行路径和子程序产生的输出。下面,通过嵌套 IF 语句的汇编程序来看看这个测试过程:

if op1 == op2
    if X > Y
        call Routine1
    else
        call Routine2
    end if
else
    call Routine3
end if

下面是可能的汇编语言翻译,加上了参考行号。程序改变了初始条件(op1 == op2),并立即跳转到 ELSE 部分。剩下要翻译的内容是内层 IF-ELSE 语句:

        mov    eax, op1
        cmp eax, op2                             ;op1 == op2?
        jne    L2                                ;否:调用 Routine3
; 处理内层 IF-ELSE 语句。
        mov    eax, X
        cmp    eax, Y                             ; X > Y?
        jg    L1                                  ; 是:调用 Routine1
        call    Routine2                          ; 否:调用 Routine2
        jmp    L3                                 ; 退出
L1:     call Routine1                             ; 调用 Routine1
        jmp    L3                                 ; 退出
L2:     call    Routine3
L3:

下表给出了示例代码的白盒测试结果。前四列对 op1、op2、X 和 Y 进行测试赋值。第 5 列和第 6 列对生成的执行路径进行了验证。

op1 op2 X Y 执行行序列 调用
10 20 30 40 1, 2, 3, 11, 12 Rountine3
10 20 40 30 1, 2, 3, 11, 12 Rountine3
10 10 30 40 1, 2, 3, 4, 5, 6, 7, 8, 12 Rountine2
10 10 40 30 1, 2, 3, 4, 5, 6, 9, 10, 12 Rountine1

使用汇编语言实现逻辑表达式

逻辑 AND 运算符

汇编语言很容易实现包含 AND 运算符的复合布尔表达式。考虑下面的伪代码,假设其中进行比较的是无符号整数:

if (a1 > b1) AND (b1 > c1)
   X = 1
end if

短路求值

下面的例子是短路求值的简单实现,如果第一个表达式为假,则不需计算第二个表达式。高级语言的规范如下:

        cmp a1,b1                  ;第一个表达式…
        ja L1
        jmp next
L1:     cmp b1, c1                 ;第二个表达式…
        ja L2
        jmp next
L2:   mov X, 1                     ;全为真:将 X 置 1
next:

如果把第一条 JA 指令替换为 JBE,就可以把代码减少到 5 条:

        cmp    a1,b1                  ; 第一个表达式…
        jbe next                      ; 如果假,则退出
        cmp    b1,c1                  ; 第二个表达式…
        jbe next                      ; 如果假,则退出
        mov    X, 1                   ; 全为真
next:

若第一个 JBE 不执行,CPU 可以直接执行第二个 CMP 指令,这样就能够减少 29% 的代码量(指令数从 7 条减少到 5 条)。

逻辑 OR 运算符

当复合表达式包含的子表达式是用 OR 运算符连接的,那么只要一个子表达式为真,则整个复合表达式就为真。以如下伪代码为例:

if (a1 > b1) OR (b1 > c1)
   X = 1

在下面的实现过程中,如果第一个表达式为真,则代码分支到 L1;否则代码直接执行第二个 CMP 指令。第二个表达式翻转了 > 运算符,并使用了 JBE 指令:

        cmp a1, b1                  ; 1:比较 AL 和 BL
        ja L1                       ; 如果真,跳过第二个表达式
        cmp b1, c1                  ; 2:比较 BL 和 CL
        jbe next                    ; 假:跳过下一条语句
L1:     mov X, 1                    ; 真:将 x 置 1
next:

对于一个给定的复合表达式而言,汇编语句有多种实现方法。

使用汇编语言实现WHILE循环

WHILE 循环在执行语句块之前先进行条件测试。只要循环条件一直为真,那么语句块就不断重复。下面是用 C++ 编写的循环:

while( val1 < val2 )
{
   val1++;
   val2 --;
}

用汇编语言实现这个结构时,可以很方便地改变循环条件,当条件为真时,跳转到 endwhile。假设 val1 和 val2 都是变量,那么在循环开始之前必须将其中的一个变量送入寄存器,并且还要在最后恢复该变量的值:

        mov eax, val1                  ; 把变量复制到 EAX
beginwhile:
        cmp eax, val2                  ; 如果非 val1 < val2
        jnl     endwhile               ; 退出循环
        inc    eax                     ; val1++;
        dec    val2                    ; val2--;
        jmp    beginwhile              ; 重复循环
endwhile:
        mov    val1, eax                ;保存 val1 的新值

在循环内部,EAX 是 val1 的代理(替代品),对 val1 的引用必须要通过 EAX。JNL 的使用意味着 val1 和 val2 是有符号整数。

【示例】循环内的 IF 语句嵌套

高级语言尤其善于表示嵌套的控制结构。如下 C++ 代码所示,在一个 WHILE 循环中有嵌套 IF 语句。它计算所有大于 sample 值的数组元素之和:

int array[] = {10,60,20,33,72,89,45,65,72,18};
int sample  =  50;
int ArraySize = sizeof array / sizeof sample;
int index = 0;
int sum  =  0;
while( index < ArraySize )
{
    if( array[index] > sample )
    {
        sum += array[index];
    }
    index++;
}

在用汇编语言编写该循环之前,用下图的流程图来说明其逻辑。为了简化转换过程,并通过减少内存访问次数来加速执行,图中用寄存器来代替变量:EDX-sample, EAX=sum, ESI=index, ECX=ArraySize ( 常数 )。标号名称也已经添加到逻辑框上。

汇编代码

从流程图生成汇编代码最简单的方法就是为每个流程框编写单独的代码。注意流程图标签和下面源代码使用标签之间的直接关系:

array DWORD 10,60,20,33,72,89,45,65,72,18
ArraySize = ($ - Array) / TYPE array
.code
main PROC
    mov    eax,0                           ; 求和
    mov    edx,sample
    mov    esi,0                           ; 索引
    mov    ecx,ArraySize
L1: cmp    esi,ecx                         ; 如果 esi < ecx
    jl    L2
    jmp    L5
L2: cmp    array[esi*4], edx               ; 如果array[esi] > edx
    jg    L3
    jmp    L4
L3: add    eax,array[esi*4]
L4: inc    esi
    jmp    L1
L5: mov    sum,eax

汇编语言表驱动选择

表驱动选择是用查表来代替多路选择结构的一种方法。使用这种方法,需要新建一个表,表中包含查询值和标号或过程的偏移量,然后必须用循环来检索这个表。当有大量比较操作时,这个方法最有效。

例如,下面是一个表的一部分,该表包含单字符查询值,以及过程的地址:

.data
CaseTable BYTE   'A'          ;查询值
       DWORD Process_A   ;过程地址
       BYTE 'B'
       DWORD Process_B
       (etc.)

假设 Process_A、Process_B、Process_C 和 Process_D 的地址分别是 120h、130h、140h 和 150h。上表在内存中的存放如下图所示。

示例程序

用户从键盘输入一个字符。通过循环,该字符与表的每个表项进行比较。第一个匹配的查询值将会产生一个调用,调用对象是紧接在该查询值后面的过程偏移量。每个过程加载到 EDX 的偏移量都代表了一个不同的字符串,它将在循环中显示:

; 过程偏移量表          (ProcTble.asm)
; 本程序包含了过程偏移量表格
; 使用这个表执行间接过程调用
INCLUDE Irvine32.inc
.data
CaseTable  BYTE   'A'                  ; 查询值
           DWORD   Process_A           ; 过程地址
           BYTE   'B'
           DWORD   Process_B
           BYTE   'C'
           DWORD   Process_C
           BYTE   'D'
           DWORD   Process_D
NumberOfEntries = 4
prompt BYTE "Press capital A,B,C,or D: ",0
;为每个过程定义一个单独的消息字串
msgA BYTE "Process_A",0
msgB BYTE "Process_B",0
msgC BYTE "Process_C",0
msgD BYTE "Process_D",0
.code
main PROC
    mov  edx,OFFSET prompt              ; 请求用户输入
    call WriteString
    call ReadChar                       ; 读取字符到AL
    mov  ebx,OFFSET CaseTable           ; 设 EBX 为表指针
    mov  ecx,NumberOfEntries            ; 循环计数器
L1:
    cmp  al,[ebx]                       ; 出现匹配项?
    jne  L2                             ; 否: 继续
    call NEAR PTR [ebx + 1]             ; 是: 调用过程
;这个 CALL 指令调用过程,其地址保存在 EBX+1 指向的内存位置中,像这样的间接调用需要使用 NEAR PTR 运算符
    call WriteString                    ; 显示消息
    call Crlf
    jmp  L3                             ; 推出搜索
    add  ebx,5                          ; 指向下一个表项
    loop L1                             ; 重复直到 ECX = 0
L3:
    exit
main ENDP
;下面的每个过程向EDX加载不同字符串的偏移量
Process_A PROC
    mov  edx,OFFSET msgA
    ret
Process_A ENDP
Process_B PROC
    mov  edx,OFFSET msgB
    ret
Process_B ENDP
Process_C PROC
    mov  edx,OFFSET msgC
    ret
Process_C ENDP
Process_D PROC
    mov  edx,OFFSET msgD
    ret
Process_D ENDP
END main

表驱动选择有一些初始化开销,但是它能减少编写的代码总量。一个表就可以处理大量的比较,并且与一长串的比较、跳转和 CALL 指令序列相比,它更加容易修改。甚至在运行时,表还可以重新配置。

有限状态机(FSM)与汇编语言[附带实例]

有限状态机(FSM)是一个根据输入改变状态的机器或程序。用图表示 FSM 相当简明, 下图中的矩形(或圆形)称为节点,节点之间带箭头的线段称为边(或弧)。

上图给出了一个简单的例子。每个节点代表一个程序状态,每个边代表从一个状态到另一个状态的转换。一个节点被指定为初始状态,在图中用一个输入箭头指出。其余的状态可以用数字或字母来标示。一个或多个状态可以指定为终止状态,用粗框矩形表示。终止状态表示程序无出错的结束状态。

FSM 是一种被称为有向图的更一般结构的特例。有向图就是一组节点,它们用具有特定方向的边进行连接。

验证输入字符串

读取输入流的程序往往要通过执行一定量的错误检查来验证它们的输入。比如,编程语言编译器可以用 FSM 来扫描程序,将文字和符号转换为记号(通常是指关键字、算法运算符和标识符)。

用 FSM 来验证输入字符串时,常常是按字符进行读取。每一个字符都用图中的一条边(转换)来表示。FSM 有两种方法检测非法输入序列:

  • 下一个输入字符没有对应到当前状态的任何一个转换。

  • 输入已经终止,但是当前状态是非终止状态。

字符串示例现在根据下面两条原则来验证一个输入字符串:

  • 该字符串必须以字母“x”开始,以字母“z”结束。

  • 第一个和最后一个字符之间可以有零个或多个字母,但其范围必须是 {a,….,y}。

下图的 FSM 显示了上述语法。每一个转换都是由特定类型的输入来标识。比如,仅当从输入流中读取字母 x 时,才能完成状态 A 到状态 B 的转换。输入任何非“z”的字母,都会使得状态 B 转换为其自身。而仅当从输入流中读取字母 z 时,才会发生状态 B 到状态 C 的转换。

如果输入流已经结束,而程序只出现了状态 A 和状态 B,那么就生成出错条件,因为只有状态 C 才能标记终止状态。下述输入字符串能被该 FSM 认可:

xaabcdefgz
xz
xyyqqrrstuvz

验证有符号整数

下图表示的是 FSM 解析一个有符号整数。输入包括一个可选的前置符号,其后跟一串数字。图中没有对数字个数进行限制。

有限状态机很容易转换为汇编代码。图中的每个状态(A、B、C…)代表了一段有标号的程序。每个标号执行的操作如下:

\1) 调用输入程序读入下一个输入字符。

\2) 如果是终止状态,则检查用户是否按下 Enter 键来结束输入。

\3) 一个或多个比较指令检查从状态发岀的所有可能的转换。每个比较指令后面跟一个条件跳转指令。

比如,在状态 A,如下代码读取下一个输入字符并检查到状态 B 的可能的转换:

StateA:
        Cal1 Getnext                          ;读取下一个字符,并送入 AL
        cmp    al, '+'                        ;前置+ ?
        je    StateB                          ;到状态 b
        cmp    al, '-'                        ;前置 - ?
        je    StateB                          ;到状态 B
        call    IsDigit                       ;如果 AL 包含数字,则 ZF = 1
        jz    StateC                          ;到状态 C
        call    DisplayErrorMsg               ;发现非法输入
        jmp Quit

下面来更详细地检查这段代码。首先,代码调用 Getnext,从控制台输入读取下一个字符,送入 AL 寄存器。接着检查前置 + 或 -,先将 AL 的值与符号“+”进行比较,如果匹配,就发生到标号 StateB 的跳转:

StateA:
        call Getnext                         ;读取下一个字符,并送入 al
        cmp al, ' + '                        ;前置 + ?
        je StateB                            ;到状态 B

现在,再次查看上图,发现只有输入 + 或 - 时,才发生状态 A 到状态 B 的转换。所以,代码还需检查减号:

cmp al, '-'                                  ;前置 - ?
je StateB                                    ;到状态 B

如果无法发生到状态 B 的转换,就可以检查 AL 寄存器中是否为数字,这可以导致到状态 C 的转换。调用 IsDigit 子程序,当 AL 包含数字时,零标志位置 1:

call IsDigit                                 ;如果AL包含数字,贝U ZF=1
jz StateC                                    ;到状态 C

最后,状态 A 没有其他可能的转换。如果发现 AL 中的字符既不是前置符号,又不是数字,程序就会调用 DisplayErrorMsg (在控制台上显示一条错误消息)子程序,并跳转到标号 Quit 处:

call DisplayErrorMsg                         ;发现非法输入
jmp Quit

标号 Quit 标识程序的出口,位于主程序的结尾:

Quit:
    call Crlf
    exit
main ENDP

完整的有限状态机程序

如下程序实现上图所示的有符号整数 FSM:

; 有限状态机              (Finite.asm)
INCLUDE Irvine32.inc
ENTER_KEY = 13
.data
InvalidInputMsg BYTE "Invalid input",13,10,0
.code
main PROC
    call Clrscr
StateA:
    call    Getnext               ; 读取下一个字符,并送入AL
    cmp    al,'+'                 ; 前置+ ?
    je    StateB                  ; 到状态 B
    cmp    al,'-'                 ; 前置 - ?
    je    StateB                  ; 到状态 B
    call    IsDigit               ; 如果 AL 包含数字 ,则 ZF = 1
    jz    StateC                  ; 到状态 C
    call    DisplayErrorMsg       ; 发现非法输入
    jmp    Quit
StateB:
    call    Getnext               ; 读取下一个字符,并送入AL
    call    IsDigit               ; 如果AL包含数字,则 ZF = 1
    jz    StateC
    call    DisplayErrorMsg       ; 发现非法输入
    jmp    Quit
StateC:
    call    Getnext               ; 读取下一个字符,并送入AL
    call    IsDigit               ; 如果AL包含数字,则 ZF = 1
    jz    StateC
    cmp    al,ENTER_KEY           ; 按下Enter键?
    je    Quit                    ; 是:Quit
    call    DisplayErrorMsg       ; 否: 发现非法输入
    jmp    Quit
Quit:
    call    Crlf
    exit
main ENDP
;-----------------------------------------------
Getnext PROC
;
; 从标准输入读取一个字符
; 接收: 无
; 返回: 字符保存在AL中
;-----------------------------------------------
     call ReadChar            ; 从键盘输入
     call WriteChar           ; 显示在屏幕上
     ret
Getnext ENDP
;-----------------------------------------------
DisplayErrorMsg PROC
;
; 显示一个错误消息以表示
; 输入流中包含非法输入
; 接收: 无.
; 返回: 无
;-----------------------------------------------
     push  edx
     mov      edx,OFFSET InvalidInputMsg
     call  WriteString
     pop      edx
     ret
DisplayErrorMsg ENDP
END main

IsDigit子程序

有限状态机示例程序调用 IsDigit 子程序,该子程序属于本教程的链接库。现在来看看 IsDigit 的源程序,程序把 AL 寄存器作为输入,其返回值设置零标志位:

;----------------------------------------------------
IsDigit PROC
;
;确定 AL 中的字符是否为有效的十进制数字。
;接收:AL= 字符
;返回:若 AL 为有效的十进制字符,ZF=1;否则,ZF=0
;---------------------------------------------------
        cmp    al,'0'
        jb    ID1                                 ;跳转发生,ZF=0
        cmp    al, '9'
        ja    ID1                                 ;跳转发生,ZF = 0
        test    ax, 0                             ;设置 ZF=1
ID1: ret
IsDigit ENDP

在查看 IsDigit 的代码之前,先回顾十进制数字的十六进制 ASCII 码,如下表所示。由于这些值是连续的,因此,只需要检查第一个和最后一个值:

字符 ‘0’ ‘1’ ‘2’ ‘3’ ‘4’ ‘5’ ‘6’ ‘7’ ‘8’ ‘9’
ASCII 码(十六进制) 30 31 32 33 34 35 36 37 38 39

IsDigit 子程序中,开始的两条指令将 AL 寄存器中字符的值与数字 0 的 ASCII 码进行比较。如果字符的 ASCII 码小于 0 的 ASCII 码,程序跳转到标号 ID1:

cmp al, '0'
jb ID1                       ;跳转发生,ZF=0

但是有人可能会问了,如果 JB 将控制传递给标号 ID1,那么,怎么知道零标志位的状态呢?答案就在 CMP 指令的执行方式里——它执行一个隐含的减法操作,从 AL 寄存器的字符中减去 0 的 ASCII 码(30h)。如果 AL 中的值较小,那么进位标志位置 1,零标志位清除(你可能想用调试器来单步执行这段代码来验证这个事实)。JB 指令的目的是,当 CF=1 且 ZF=0 时,将控制传递给一个标号。

接下来,IsDigit 子程序代码把 AL 与数字 9 的 ASCII 码进行比较。如果 AL 的值较大,代码跳转到同一个标号:

cmp al, '9'
ja ID1                ;跳转发生,ZF=0

如果 AL 中字符的 ASCII 码大于数字 9 的 ASCII 码(39h),清除进位标志位和零标志位。这也正好是使得 JA 指令将控制传递到目的标号的标志位组合。

如果没有跳转发生(JA 或 JE),又假设 AL 中的字符确实是一个数字,则插入一条指令确保将零标志位置 1。将 0 与任何数值进行 test 操作,就意味着执行一次隐含的与全 0 的 AND 运算。其结果必然为 0:

test ax, 0         ; 置 ZF=1

前面 IsDigit 中的 JA 和 JB 指令跳转到了 TEST 指令后面的标号。所以,如果发生跳转,零标志位将清零。下面再次给出完整的过程:

Isdigit PROC
    cmp al,'0'
    jb ID1             ;若跳转发生,则 ZF=0
    cmp al,'9'
    ja ID1             ;若跳转发生,则 ZF=0
    test ax,0          ;置 zf=1
ID1: ret
Isdigit ENDP

在实时或高性能应用中,程序员常常利用硬件特性的优势,来对其代码进行充分优化。IsDigit 过程就是这种方法的例子,它利用 JB、JA 和 TEST 对标志的设置,实际上返回的是一个布尔结果。

汇编语言条件控制流伪指令

32 位模式下,MASM 包含了一些高级条件控制流伪指令(conditional control flow directives),这有助于简化编写条件语句。遗憾的是,这些伪指令不能用于 64 位模式。

对程序进行汇编之前,汇编器执行的是预处理步骤。在这个步骤中,汇编器要识别伪指令,如:.CODE、.DATA,以及一些用于条件控制流的伪指令。下表列出了这些伪指令。

伪指令 说明
.BREAK 生成代码终止 .WHILE 或 .REPEAT 块
.CONTINUE 生成代码跳转到 .WHILE 或 .REPEAT 块的顶端
.ELSE 当 .IF 条件不满足时,开始执行的语句块
.ELSEIF condition 生成代码测试 condition,并执行其后的语句,直到碰到一个 .ENDIF 或另一个 .ELSEIF 伪指令
.ENDIF 终止 .IF、.ELSE 或 .ELSEIF 伪指令后面的语句块
.ENDW 终止 .WHILE 伪指令后面的语句块
.IF condition 如果 condition 为真,则生成代码执行语句块
.REPEAT 生成代码重复执行语句块,直到条件为真
.UNTIL condition 生成代码重复执行 .REPEAT 和 .UNTIL 伪指令之间的语句块,直到 condition 为真
.UNTILCXZ 生成代码重复执行 .REPEAT 和 .UNTILCXZ 伪指令之间的语句块,直到 CX 为零
.WHILE condition 当 condition 为真时,生成代码执行 .WHILE 和 .ENDW 伪指令之间的语句块

汇编语言.IF、.ELSE、.ELSEIF、.ENDIF伪指令

.IF、.ELSE、.ELSEIF 和 .ENDIF 伪指令使得程序员易于对多分支逻辑进行编码。它们让汇编器在后台生成 CMP 和条件跳转指令,这些指令显示在输出列表文件中。语法如下所示:

.IF conditionl
   statements
[.ELSEIF condition2
   statements ]
[.ELSE
   statements ]
.ENDIF

方括号表示 .ELSEIF 和 .ELSE 是可选的,而 .IF 和 .ENDIF 则是必需的。condition(条件)是布尔表达式,使用与 C++ 和 Java 相同的运算符 ( 比如:<、>、== 和 !=)。表达式在运行时计算。下面的例子给出了一些有效的条件,使用的是 32 位寄存器和变量:

eax > 10000h
val1 <= 100
val2 == eax
val3 != ebx

下面的例子给出的是复合条件:

(eax > 0) && (eax > 10000h)
(val1 <= 100) || (val2 <= 100)
(val2 != ebx) && !CARRY?

下表列出了所有的关系和逻辑运算符。

运算符 说明
expr1 == expr2 若 expr1 等于 expr2,则返回“真”
expr1 != expr2 若 expr1 不等于 expr2,则返回“真”
expr1 > expr2 若 expr1 大于 expr2,则返回”真”
expr1 ≥ expr2 若 expr1 大于等于 expr2,则返回“真”
expr1 < expr2 若 expr1 小于 expr2,则返回“真”
expr1 ≤ expr2 若 expr1 小于等于 expr2,则返回“真”
!expr1 若 expr 为假,则返回“真”
expr1expr2 对 expr1 和 expr2 执行逻辑 AND 运算
expr1 || expr2 对 1xprl 和 expr2 执行逻辑 OR 运算
expr1 & expr2 对 expr1 和 expr2 执行按位 AND 运算
CARR1? 若进位标志位置 11则返回“真”
OVERFLOW ? 若溢出标志位置 1,则返回“真”
PARITY ? 若奇偶标志位置 1,则返回“真”
SIGN ? 若符号标志位置 1,则返回“真”
ZERO ? 若零标志位置 1,则返回“真”

在使用 MASM 条件伪指令之前,一定要彻底了解怎样用纯汇编语言实现条件分支指令。此外,在包含条件伪指令的程序汇编时,要查看列表文件以确认 MASM 生成的代码确实是编程者所需要的。

生成 ASM 代码

当使用如 .IF 和 .ELSE 一样的高级伪指令时,汇编器将为程序员编写代码。例如,编写一条 .IF 伪指令来比较 EAX 与变量 val1:

mov eax,6
.IF eax > val1
    mov result,1
.ENDIF

假设 val1 和 result 是 32 位无符号整数,当汇编器读到前述代码时,就将它们扩展为下述汇编语言指令,用 Visual Studio 调试器运行程序时可以查看这些指令,操作为:右键点击, 选择 Go To Disassembly。

    mov eax,6
    cmp eax,val1
    jbe @C0001            ;无符号数比较跳转
    mov result, 1
@C0001:

标号名 @C0001 由汇编器创建,这样可以确保同一个过程中的所有标号都具有唯一性。

要控制 MASM 生成代码是否显示在源列表文件中,可以在 Visual Studio 中配置 Project 的属性。步骤如下:在 Project 菜单中,选择 Project Properties,选择 Microsoft Macro Assembler,选择 Listing File,再设置 Enable Assembly Generated Code Listing 为 Yes。

有符号数和无符号数的比较

当使用 .IF 伪指令来比较数值时,必须认识到 MASM 是如何生成条件跳转的。如果比较包含了一个无符号变量,则在生成代码中插入一条无符号条件跳转指令。如下还是前面的例子,比较 EAX 和无符号双字变量 val1:

.data
val1 DWORD 5
result DWORD ?
.code
    mov eax,6
    .IF eax > val1
        mov result,1
    .ENDIF

汇编器用 JBE(无符号跳转)指令对其进行扩展:

mov eax,6
cmp eax,val1
    jbe @C0001             ;无符号比较跳转
    mov result,1
@C0001:

1) 有符号数比较

如果 .IF 伪指令比较的是有符号变量,则在生成代码中插入一条有符号条件跳转指令。例如,val2 为有符号双字:

.data
val2 SDWORD -1
result DWORD ?
.code
    mov eax,6
    .IF eax > val2
        mov result,1
    .ENDIF

因此,汇编器用 JLE 指令生成代码,即基于有符号比较的跳转:

    mov eax,6
    cmp eax,val2
    jle @C0001               ;有符号比较跳转
    mov result,1
@C0001:

2) 寄存器比较

那么,现在可能会有一个问题:如果是两个寄存器进行比较,情况又是怎样的?显然,汇编器无法确定寄存器中的数值是有符号的还是无符号的:

mov eax,6
mov ebx,val2
.IF eax > ebx
    mov result,1
.ENDIF

下面生成的代码表示汇编器将其默认为无符号数比较(注意使用的是 JBE 指令):

    mov eax, 6
    mov ebx,val2
    cmp eax, ebx
    jbe @C0001
    mov result,1
@C0001:

复合表达式

很多复合布尔表达式使用逻辑 OR 和 AND 运算符。用 .IF 伪指令时,符号 || 表示的是逻辑 OR 运算符:

.IF expression1 || expression2
   statements
.ENDIF

同样,符号 && 表示的是逻辑 AND 运算符:

.IF expression1 && expression2
   statements
.ENDIF

下面的程序示例中将使用逻辑 OR 运算符。

1) SetCursorPosition 示例

下例给出的 SetCursorPosition 过程,根据两个输入参数 DH 和 DL,执行范围检查。Y 坐标(DH)范围必须为 0〜24。X 坐标(DL)范围必须为 0〜79。不论发现哪个坐标超出范围,都显示一条错误消息:

SetCursorPosition PROC
; 设置光标位置
; 接收: DL = X坐标, DH = Y坐标
; 检查 DL 和 DH 的范围
; 返回:无
;------------------------------------------------
.data
BadXCoordMsg BYTE "X-Coordinate out of range!",0Dh,0Ah,0
BadYCoordMsg BYTE "Y-Coordinate out of range!",0Dh,0Ah,0
.code
    .IF (DL < 0) || (DL > 79)
       mov  edx,OFFSET BadXCoordMsg
       call WriteString
       jmp  quit
    .ENDIF
    .IF (DH < 0) || (DH > 24)
       mov  edx,OFFSET BadYCoordMsg
       call WriteString
       jmp  quit
    .ENDIF
    call Gotoxy
quit:
    ret
SetCursorPosition ENDP

MASM 对 SetCursorPosition 进行预处理时,生成代码如下:

.code
;.IF (dl < 0) || (dl > 79)
    cmp dl, OOOh
    jb @C0002
    cmp dl, 04Fh
    jbe @C0001
@C0002:
    mov edx,OFFSET BadXCoordMsg
    call WriteString
    jmp quit
;.ENDIF
@C0001:
;.IF (dh < 0) || (dh > 24)
    cmp dh, OOOh
    jb @COOO5
    cmp    dh, 018h
    jbe @C0004
@COOO5:
    mov edx,OFFSET BadYCoordMsg
    call WriteString
    jmp quit
;.ENDIF
@C0004:
    call Gotoxy
quit:
    ret
2) 大学注册示例

假设有一个大学生想要进行课程注册。现在用两个条件来决定该生是否能注册:第一个条件是学生的平均成绩,范围为 0〜400,其中 400 是可能的最高成绩;第二个条件是学生期望获得的学分。可以使用多分支结构,包括 .IF、.ELSEIF 和 .ENDIF。示例如下。

.data
TRUE = 1
FALSE = 0
gradeAverage  WORD 275    ; 要检查的数值
credits       WORD 12     ; 要检查的数值
OkToRegister  BYTE ?
.code
main PROC
    mov OkToRegister,FALSE
    .IF gradeAverage > 350
       mov OkToRegister,TRUE
    .ELSEIF (gradeAverage > 250) && (credits <= 16)
       mov OkToRegister,TRUE
    .ELSEIF (credits <= 12)
       mov OkToRegister,TRUE
    .ENDIF

汇编器生成的相应代码如下所示,用 Microsoft Visual Studio 调试器的 Dissassembly 窗口可以查看该表。(为了便于阅读,已经对其进行了一些整理。)

    mov byte ptr OkToRegister,FALSE
    cmp word ptr gradeAverage,350
    jbe @C0006
    mov byte ptr OkToRegister,TRUE
    jmp @C0008
@C0006:
    cmp word ptr gradeAverage,250
    jbe @C0009
    cmp word ptr credits,16
    ja  @COOO9
    mov byte ptr OkToRegister,TRUE
    jmp @C0008
@C0009:
    cmp word ptr credits,12
    ja  @C0008
    mov byte ptr OkToRegister,TRUE
@COOO8:

汇编程序时,如果使用 /Sg 命令行就可以在源列表文件中显示 MASM 生成代码。被定义常量的大小(如当前代码示例中的 TRUE 和 FALSE)为 32 位。所以,把一个常量送入 BYTE 类型地址时,MASM 会插入 BYTE PTR 运算符。

汇编语言用.REPEAT和.WHILE伪指令实现循环

除了用 CMP 和条件跳转指令外,.REPEAT 和 .WHILE 伪指令还提供了另一种方法来编写循环。它们可以使用之前由《.IF伪指令》一节中关系和逻辑运算符表所列出的条件表达式。

.REPEAT 伪指令执行循环体,然后测试 .UNTIL 伪指令后面的运行时条件:

.REPEAT
   statements
.UNTIL condition
.WHILE 伪指令在执行循环体之前测试条件:
.WHILE condition
   statements
.ENDW

示例:下述语句使用 .WHILE 伪指令显示数值 1 到 10。循环之前,计数器寄存器 (EAX) 被初始化为 0。之后,循环体内的第一条语句将 EAX 加 1。当 EAX 等于 10 时,.WHILE 伪指令将分支到循环体外。

mov eax,0
.WHILE eax < 10
    inc eax
    call WriteDec
    call Crlf
.ENDW

下述语句使用 .REPEAT 伪指令显示数值 1 到 10:

mov eax,0
.REPEAT
    inc eax
    call WriteDec
    call Crlf
.UNTIL eax == 10

【示例】:含 IF 语句的循环

《使用汇编语言实现WHILE循环》一节中展示了如何编写汇编语言代码来实现 WHILE 循环嵌套 IF 语句。伪代码如下:

while( op1 < op2 )
{
    op1++;
    if( op1 == op3 )
        X = 2;
    else
        X = 3;
}

下面用 .WHILE 和 .IF 伪指令实现这段伪代码。由于 op1、op2 和 op3 是变量,为了避免任何指令出现两个内存操作数,它们被送入寄存器:

.data
X DWORD 0
op1 DWORD 2     ;被检测的数据
op2 DWORD 4     ;被检测的数据
op3 DWORD 5     ;被检测的数据
.code
    mov eax, op1
    mov ebx, op2
    mov ecx, op3
    .WHILE eax < ebx
        inc eax
        .IF eax == ecx
            mov X,2
        .ELSE
            mov X,3
        .ENDIF
    .ENDW

汇编语言整数运算

汇编语言移位和循环移位指令简介

移位指令与前面介绍的按位操作指令一起形成了汇编语言最显著的特点之一。位移动 (bit shifting) 意味着在操作数内向左或向右移动。

x86 处理器在这方面提供了相当丰富的指令集如下表所示,这些指令都会影响溢出标志位和进位标志位。

SHL 左移 ROR 循环右移
SHR 右移 RCL 带进位的循环左移
SAL 算术左移 RCR 带进位的循环右移
SAR 算术右移 SHLD 双精度左移
ROL 循环左移 SHRD 双精度右移

逻辑移位和算术移位

移动操作数的位有两种方法。第一种是逻辑移位 (logic shift),空出来的位用 0 填充。如下图所示,一个字节的数据向右移动一位。也就是说,每一位都被移动到其旁边的低位上。注意,位 7 被填充为 0:

下图所示为二进制数 1100 1111 逻辑右移一位,得到 OllOOlll。最低位移入进位标志位:

另一种移位的方法是算术移位 (arithmetic shift),空出来的位用原数据的符号位填充:

例如,二进制数 1100 1111,符号位为 1。算术右移一位后,得到 1110 0111:

汇编语言SHL(左移)指令:将操作数逻辑左移一位

SHL(左移)指令使目的操作数逻辑左移一位,最低位用 0 填充。最高位移入进位标志位,而进位标志位中原来的数值被丢弃:

若将 1100 1111 左移 1 位,该数就变为 1001 1110:

SHL 的第一个操作数是目的操作数,第二个操作数是移位次数:

SHL destination,count

该指令可用的操作数类型如下所示:

SHL reg, imm8
SHL mem, imm8
SHL reg, CL
SHL mem, CL

x86 处理器允许 imm8 为 0〜255 中的任何整数。另外,CL 寄存器包含的是移位计数。上述格式同样适用于 SHR、SAL、SAR、ROR、ROL、RCR 和 RCL 指令。

【示例】下列指令中,BL 左移一位。最高位复制到进位标志位,最低位填充 0:

mov b1, 8Fh         ; BL = 10001111b
shl bl, 1         ; CF = 1, BL = 00011110b

当一个数多次进行左移时,进位标志位保存的是最后移岀最高有效位(MSB)的数值。下例中,位 7 没有留在进位标志位中,因为,它被位 6(0)替换了:

mov al, 10000000b
shl al, 2          ; CF = 0, AL = 00000000b

同样,当一个数多次进行右移时,进位标志位保存的是最后移出最低有效位(LSB)的数值。

位元乘法

数值进行左移(向 MSB 移动)即执行了位元乘法(Bitwise Multiplication)。例如,SHL 可以通过 2 的幕进行乘法运算。任何操作数左移 n 位,即将该数乘以 2n。现将整数 5 左移一位则得到 5 x 2¹ = 10:

mov dl, 5           ; 移动前:00000101 = 5
shl dl, 1       ; 移动后:00001010 = 10

若二进制数 0000 1010(十进制数 10)左移两位,其结果与 10 乘以 2² 相同:

mov dl, 10          ;移动前:00001010
shl dl, 2        ;移动后:00101000

汇编语言SHR(右移)指令:将操作数逻辑右移一位

SHR(右移)指令使目的操作数逻辑右移一位,最高位用 0 填充。最低位复制到进位标志位,而进位标志位中原来的数值被丢弃:

SHR 与 SHL 的指令格式相同。在下面的例子中,AL 中的最低位 0 被复制到进位标志位,而 AL 中的最高位用 0 填充:

mov al, 0D0h    ; AL = 11010000b
shr al, 1       ; AL = 01101000b, CF = 0

在多位移操作中,最后一个移出位 0(LSB)的数值进入进位标志位:

mov al, 00000010b
shr al, 2          ; AL = 00000000b, CF = 1

位元除法

数值进行右移(向 LSB 移动)即执行了位元除法(Bitwise Division)。将一个无符号数右移 n 位,即将该数除以 2n。下述语句将 32 除以 2¹,结果为 16:

mov dl, 32          ;移动前:00100000 = 32
shr dl, 1        ;移动后:00010000 = 16

下例实现的是 64 除以 2³:

mov al, 01000000b    ;AL = 64
shr al, 3             ;除以 8, AL = 00001000b

用移位的方法实现有符号数除法可以使用 SAR 指令,因为该指令会保留操作数的符号位。

汇编语言SAL(算术左移)和SAR(算术右移)指令:将操作数左/右移一位

SAL(算术左移)指令的操作与SHL 指令一样。每次移动时,SAL 都将目的操作数中的每一位移动到下一个最高位上。最低位用 0 填充;最高位移入进位标志位,该标志位原来的值被丢弃:

如,二进制数 1100 1111 算术左移一位,得到 1001 1110:

SAR(算术右移)指令将目的操作数进行算术右移:

SAL 与 SAR 指令的操作数类型与 SHL 和 SHR 指令完全相同。移位可以重复执行,其次数由第二个操作数给出的计数器决定:

SAR destination, count

下面的例子展示了 SAR 是如何复制符号位的。执行指令前 AL 的符号位为负,执行指令后该位移动到右边的位上:

mov al, 0F0h ; AL = 11110000b (-16)
sar al, 1    ; AL = 11111000b (-8) , CF = 0

有符号数除法

使用 SAR 指令,就可以将有符号操作数除以 2 的幂。下例执行的是 -128 除以2³,商为 -16:

mov dl, -128 ; DL = 10000000b
sar dl, 3    ; DL = 11110000b

AX 符号扩展到 EAX

设 AX 中为有符号数,现将其符号位扩展到 EAX。首先把 EAX 左移 16 位,再将其算术右移 16 位:

mov ax, -128 ; EAX = ????FF80h
shl eax, 16    ; EAX = FF800000h
sar eax, 16    ; EAX = FFFFFF80h

汇编语言ROL(循环左移)指令:将操作数所有位都向左移

以循环方式来移位即为位元循环(Bitwise Rotation)。一些操作中,从数的一端移出的位立即复制到该数的另一端。还有一种类型则是把进位标志位当作移动位的中间点。

ROL(循环左移)指令把所有位都向左移。最高位复制到进位标志位和最低位。该指令格式与 SHL 指令相同:

位循环不会丢弃位。从数的一端循环出去的位会出现在该数的另一端。在下例中,请注意最高位是如何复制到进位标志位和位 0 的:

mov al,40h       ; AL = 01000000b
rol al,1        ; AL = 10000000b, CF = 0
rol al,1        ; AL = 00000001b, CF = 1
rol alz1       ; AL = 00000010b, CF = 0

循环多次

当循环计数值大于 1 时,进位标志位保存的是最后循环移出 MSB 的位:

mov al,00100000b
rol al,3          ; CF = 1, AL = 00000001b

位组交换

利用 ROL 可以交换一个字节的高四位(位 4〜7)和低四位(位 0〜3)。例如,26h 向任何方向循环移动 4 位就变为 62h:

mov al, 26h
rol al, 4         ; AL = 62h

当多字节整数以四位为单位进行循环移位时,其效果相当于一次向右或向左移动一个十六进制位。例如,将 6A4Bh 反复循环左移四位,最后就会回到初始值:

mov ax, 6A4Bh
rol ax, 4       ; AX = A4B6h
rol ax, 4       ; AX = 4B6Ah
rol ax, 4       ; AX = B6A4h
rol ax, 4       ; AX = 6A4Bh

汇编语言ROR(循环右移)指令:将操作数所有位都向右移

ROR(循环右移)指令把所有位都向右移,最低位复制到进位标志位和最高位。该指令格式与 SHL 指令相同:

在下例中,请注意最低位是如何复制到进位标志位和结果的最高位的:

mov al, 0lh         ; AL = 00000001b
ror al, 1        ; AL = 10000000b, CF = 1
ror al, 1        ; AL = 01000000b, CF = 0

循环多次

当循环计数值大于 1 时,进位标志位保存的是最后循环移出 LSB 的位:

mov al, 00000100b
ror al, 3       ; AL = 10000000b, CF = 1

汇编语言RCL(带进位循环左移)和RCR(带进位循环右移)指令

RCL(带进位循环左移)指令把每一位都向左移,进位标志位复制到 LSB,而 MSB 复制到进位标志位:

如果把进位标志位当作操作数最高位的附加位,那么 RCL 就成了循环左移操作。下面的例子中,CLC 指令清除进位标志位。第一条 RCL 指令将 BL 最高位移入进位标志位,其他位都向左移一位。第二条 RCL 指令将进位标志位移入最低位,其他位都向左移一位:

clc               ; CF = 0
mov bl, 88h       ; CF,BL = 0 1000100Ob
rcl bl, 1           ; CF,BL = 1 00010000b
rcl b1, 1          ; CF,BL = 0 00100001b

从进位标志位恢复位

RCL 可以恢复之前移入进位标志位的位。下面的例子把 testval 的最低位移入进位标志位,并对其进行检查。如果 testval 的最低位为 1,则程序跳转;如果最低位为 0,则用 RCL 将该数恢复为初始值:

.data
testval BYTE 01101010b
.code
shr testval, 1         ; 将lsb移入进位标志位
jc exit           ; 如果该标志位置 1,则退出
rcl testval, 1      ; 否则恢复该数原值

RCR 指令

RCR(带进位循环右移)指令把每一位都向右移,进位标志位复制到 MSB,而 LSB 复制到进位标志位:

从上图来看,RCL 指令将该整数转化成了一个 9 位值,进位标志位位于 LSB 的右边。下面的示例代码用 STC 将进位标志位置 1,然后,对 AH 寄存器执行一次带进位循环右移操作:

stc              ; CF = 1
mov ah, 10h            ; AH, CF = 00010000 1
rcr ah, 1          ; AH, CF = 10001000 0

有符号数溢出

如果有符号数循环移动一位生成的结果超过了目的操作数的有符号数范围,则溢出标志位置 1。换句话说,即该数的符号位取反。下例中,8 位寄存器中的正数(+127)循环左移后变为负数(-2):

mov al, +127         ; AL = 01111111b
rol al, 1          ; OF = 1, AL = 11111110b

同样,-128 向右移动一位,溢出标志位置 1。AL 中的结果(+64)符号位与原数相反:

mov al, -128      ; AL = 10000000b
shr al, 1         ; OF = 1, AL = 01000000b

如果循环移动次数大于 1,则溢出标志位无定义。

汇编语言SHLD(双精度左移)和SHRD(双精度右移)指令

SHLD(双精度左移)指令将目的操作数向左移动指定位数。移动形成的空位由源操作数的高位填充。源操作数不变,但是符号标志位、零标志位、辅助进位标志位、奇偶标志位和进位标志位会受影响:

SHLD dest, source, count

下图展示的是 SHLD 执行移动一位的过程。源操作数的最高位复制到目的操作数的最低位上。目的操作数的所有位都向左移动:

SHRD(双精度右移)指令将目的操作数向右移动指定位数。移动形成的空位由源操作数的低位填充:

SHRD dest, source, count

下图展示的是 SHRD 执行移动一位的过程:

下面的指令格式既可以应用于 SHLD 也可以应用于 SHRD。目标操作数可以是寄存器或内存操作数;源操作数必须是寄存器;移位次数可以是 CL 寄存器或者 8 位立即数:

SHLD regl6, regl6, CL/imm8
SHLD meml6, regl6, CL/imm8
SHLD reg32, reg32, CL/imm8
SHLD mem32, reg32, CL/imm8

【示例 1】下述语句将 wval 左移 4 位,并把 AX 的高 4 位插入 wval 的低 4 位:

.data
wval WORD 9BA6h
.code
mov ax, 0AC36h
shld wval, ax, 4             ; wval = BA6Ah

数据移动过程如下图所示:

【示例 2】下例中,AX 右移 4 位,DX 的低 4 位移入 AX 的高 4 位:

mov ax, 234Bh
mov dx,7654h
shrd ax, dx, 4

为了在屏幕上重定位图像而必须将位元组左右移动时,可以用 SHLD 和 SHRD 来处理位映射图像。另一种可能的应用是数据加密,如果加密算法中包含位的移动的话。最后,对于很长的整数来说,这两条指令还可以用于快速执行其乘除法。

下面的代码示例展示了用 SHRD 如何将一个双字数组右移 4 位:

.data
array DWORD 648B2165h, 8C943A29h, 6DFA4B86h, 91F76C04h, 8BAF9857h
.code
    mov bl, 4                            ;移位次数
    mov esi, OFFSET array                ;数组的偏移量
    mov ecx, (LENGTHOF array) - 1        ;数组元素个数
L1: push ecx                             ;保存循环计数
    mov eax, [esi + TYPE DWORD]
    mov cl, bl                            ;移动次数
    shrd [esi], eax, cl                   ;EAX [ESI] 的高位
    add esi, TYPE DWORD                   ;指向下一对双字
    pop ecx                               ;恢复循环计数
    loop L1
    shr DWORD PTR [esi], 4                ;最后一个双字进行移位

汇编语言移位和循环移位的应用

当程序需要将一个数的位从一部分移动到另一部分时,汇编语言是非常合适的工具。有时,把数的位元子集移动到位 0,便于分离这些位的值。本节将展示一些易于实现的常见移位和循环移位的应用。

多个双字的移位

对于已经被分割为字节、字或双字数组的扩展精度整数可以进行移位操作。在此之前,必须知道该数组元素是如何存放的。保存整数的常见方法之一被称为小端顺序 (little-endian order)。

其工作方式如下:将数组的最低字节存放到它的起始地址,然后,从该字节开始依序把高字节存放到下一个顺序的内存地址中。除了可以将数组作为字节序列存放外,还可以将其作为字序列和双字序列存放。如果是后两种形式,则字节和字节之间仍然是小端顺序,因为 x86 机器是按照小端顺序存放字和双字的。

下面的步骤说明了怎样将一个字节数组右移一位。

步骤 1):把位于 [ESI+2] 的最高字节右移一位,其最低位自动复制到进位标志位。

步骤 2):把 [ESI+1] 循环右移一位,即用进位标志位填充最高位,而将最低位移入进位标志位:

步骤 3) :把 [ESI] 循环右移一位,即用进位标志位填充最高位,而将最低位移入进位标志位:

步骤 3 完成后,所有的位都向右移动了一位:

实现的是上述 3 个步骤,代码如下:

.data
ArraySize = 3
array BYTE ArraySize DUP(99h)          ; 每个半字节的值都是 1001
.code
main PROC
    mov esi,0
    shr array[esi+2],1                 ; 高字节
    rcr array[esi+1],1                 ; 中间字节,包括进位标志位
    rcr array[esi],1                   ; 低字节,包括进位标志位

虽然这个例子只有 3 个字节进行了移位,但是它能很容易被修改成执行字数组或双字数组的移位操作。利用循环,可以对任意大小的数组进行移位操作。

二进制乘法

有时程序员会压榨出任何可以获得的性能优势,他们会使用移位而非 MUL 指令来实现整数乘法。当乘数是 2 的幂时,SHL 指令执行的是无符号数乘法。

一个无符号数左移 n 位就是将其乘以 2n。其他任何乘数都可以表示为 2 的幂之和。例如,若将 EAX 中的无符号数乘以 36,则可以将 36 写为 25+22,再使用乘法 分配律:

EAX * 36 = EAX * (2⁵ + 2²)
        = EAX * (32 + 4)
        = (EAX * 32) + (EAX * 4)

下图展示了乘法 123*36 得到结果 4428 的过程:

请注意这里有个有趣的现象,乘数 (36) 的位 2 和位 5 都为 1,而整数 2 和 5 又是需要移位的次数。利用这个现象,下面的代码片段使用 SHL 和 ADD 指令实现了 123 乘以 36:

mov eax, 123
mov ebx, eax
shl eax, 5                ; 乘以 2⁵
shl ebx, 2                ; 乘以 2²
add eax, ebx            ; 乘积相力口

显示二进制位

将二进制整数转换为 ASCII 码的位串,并显示出来是一种常见的编程任务。SHL 指令适用于这个要求,因为每次操作数左移时,它都会把操作数的最高位复制到进位标志位。下面的 BinToAsc 过程是该功能一个简单的实现:

;---------------------------------------------------------
BinToAsc PROC
;
; 将 32 位二进制整数转换为 ASCII 码的二进制形式。
; 接收:EAX = 二进制整数,EST 为缓冲区指针
; 返回:包含 ASCII 码二进制数字的缓冲区
;---------------------------------------------------------
    push    ecx
    push    esi

    mov    ecx,32                    ; EAX 中的位数
L1:    shl    eax,1                  ; 最高位移入进位标志位
    mov    BYTE PTR [esi],'0'        ; 选择0作为默认数字
    jnc    L2                        ; 如果进位标志位为0,则跳转到L2
    mov    BYTE PTR [esi],'1'        ; 否则将1送入缓冲区
L2:    inc    esi                    ; 指向下一个缓冲区位置
    loop    L1                       ; 下一位进行左移
    pop    esi
    pop    ecx
    ret
BinToAsc ENDP

提取文件日期字段

当存储空间非常宝贵的时候,系统软件常常将多个数据字段打包为一个整数。要获得这些数据,应用程序就需要提取被称为位串(bit string)的位序列。例如,在实地址模式下,MS-DOS 函数 57h 用 DX 返回文件的日期戳。

(日期戳显示的是该文件最后被修改的日期。)其中,位 0〜 位 4 表示的是 1〜31 内的日期;位 5〜 位 8 表示的是月份;位 9〜 位 15 表示的是年份。如果一个文件最后被修改的日期是 1999 年 3 月 10 日,则 DX 寄存器中该文件的日期戳就如下图所示(年份以 1980 为基点):

要提取一个位串,就把这些位移到寄存器的低位部分,再清除掉其他无关的位。下面的代码示例从一个日期戳中提取日期字段,方法是:复制 DL,然后屏蔽与该字段无关的位:

mov al, dl            ; 复制 DL
and al, 00011111b        ; 清除位 5 〜 位 7
mov day, al          ; 结果存入变量 day

要提取月份字段,就把位 5〜 位 8 移到 AL 的低位部分,再清除其他无关位,最后把 AL 复制到变量中:

mov ax, dx           ;复制 DX
shr ax, 5             ;右移5位
and al, 00001111b        ;清除位 4 〜位 7
mov month, al        ;结果存入变量month
年份字段(位 9〜 位 15)完全包含在 DH 寄存器中,将其复制到 AL,再右移 1 位:
mov al, dh                ;复制 DH
shr al, 1           ;右移1位
mov ah, 0         ;将 AH 清零
add ax, 1980            ;年份基点为1980
mov year, ax            ;结果存入变量year

汇编语言MUL指令:无符号数乘法

32 位模式下,MUL(无符号数乘法)指令有三种类型:

  • 第一种执行 8 位操作数与 AL 寄存器的乘法;

  • 第二种执行 16 位操作数与 AX 寄存器的乘法;

  • 第三种执行 32 位操作数与 EAX 寄存器的乘法。

乘数和被乘数的大小必须保持一致,乘积的大小则是它们的一倍。这三种类型都可以使用寄存器和内存操作数,但不能使用立即数:

MUL reg/mem8
MUL reg/meml6
MUL reg/mem32

MUL 指令中的单操作数是乘数。下表按照乘数的大小,列出了默认的被乘数和乘积。由于目的操作数是被乘数和乘数大小的两倍,因此不会发生溢岀。

被乘数 乘数 乘积
AL reg/mem8 AX
AX reg/mem16 DX:AX
EAX reg/mem32 EDX:EAX

如果乘积的高半部分不为零,则 MUL 会把进位标志位和溢出标志位置 1。因为进位标志位常常用于无符号数的算术运算,在此我们也主要说明这种情况。例如,当 AX 乘以一个 16 位操作数时,乘积存放在 DX 和 AX 寄存器对中。其中,乘积的高 16 位存放在 DX,低 16 位存放在 AX。如果 DX 不等于零,则进位标志位置 1,这就意味着隐含的目的操作数的低半部分容纳不了整个乘积。

有个很好的理由要求在执行 MUL 后检查进位标志位,即,确认忽略乘积的高半部分是否安全。

MUL 示例

下述语句实现 AL 乘以 BL,乘积存放在 AX 中。由于 AH(乘积的高半部分)等于零,因此进位标志位被清除(CF=0):

mov al, 5h
mov bl, 10h
mul bl                   ; AX = 0050h, CF = 0

下图展示了寄存器内容的变化:

下述语句实现 16 位值 2000h 乘以 0100h。由于乘积的高半部分(存放于 DX)不等于零,因此进位标志位被置 1:

.data
val1 WORD 2000h
val2 WORD 0l00h
.code
mov ax, val1           ; AX = 2000h
mul val2               ; DX:AX = 00200000h, CF = 1

下述语句实现 12345h 乘以 1000h,产生的 64 位乘积存放在 EDX 和 EAX 寄存器对中。EDX 中存放的乘积高半部分为零,因此进位标志位被清除:

mov eax, 12345h
mov ebx, 1000h
mul ebx                  ; EDX:EAX = 0000000012345000h, CF = 0

下图展示了寄存器内容的变化:

在 64 位模式下使用 MUL

64 位模式下,MUL 指令可以使用 64 位操作数。一个 64 位寄存器或内存操作数与 RAX 相乘,产生的 128 位乘积存放到 RDX:RAX 寄存器中。下例中,RAX 乘以 2,就是将 RAX 中的每一位都左移一位。RAX 的最高位溢出到 RDX 寄存器,使得 RDX 的值为 0000 0000 0000 0001h:

mov rax, 0FFFF0000FFFF0000h
mov rbx, 2
mul rbx                    ; RDX:RAX = 0000000000000001FFFE0001FFFE0000

下面的例子中,RAX 乘以一个 64 位内存操作数。该寄存器的值乘以 16,因此,其中的每个十六进制数字都左移一位(一次移动 4 个二进制位就相当于乘以 16)。

.data
multiplier QWORD 10h
.code
mov rax, OAABBBBCCCCDDDDh
mul multiplier       ; RDX:RAX = 00000000000000000AABBBBCCCCDDDDOh

汇编语言IMUL指令:有符号数乘法

IMUL(有符号数乘法)指令执行有符号整数乘法。与 MUL 指令不同,IMUL 会保留乘 积的符号,实现的方法是,将乘积低半部分的最高位符号扩展到高半部分。

x86 指令集支持三种格式的 IMUL 指令:单操作数、双操作数和三操作数。单操作数格式中,乘数和被乘数大小相同,而乘积的大小是它们的两倍。

单操作数格式

单操作数格式将乘积存放在 AX、DX:AX 或 EDX:EAX 中:

IMUL reg/mem8   ; AX = AL * reg/mem8
IMUL reg/meml6   ; DX:AX = AX * reg/meml6
IMUL reg/mem32   ; EDX:EAX = EAX * reg/mem32

和 MUL 指令一样,其乘积的存储大小使得溢出不会发生。同时,如果乘积的高半部分不是其低半部分的符号扩展,则进位标志位和溢出标志位置 1。利用这个特点可以决定是否忽略乘积的高半部分。

双操作数格式(32位模式)

32 位模式中的双操作数 IMUL 指令把乘积存放在第一个操作数中,这个操作数必须是寄存器。第二个操作数(乘数)可以是寄存器、内存操作数和立 即数。16位格式如下所示:

IMUL regl6, reg/meml6
IMUL regl6, imm8
IMUL regl6, imml6

32 位操作数类型如下所示,乘数可以是 32 位寄存器、32 位内存操作数或立即数(8 位 或 32 位):

IMUL reg32, reg/mem32
IMUL reg32, inun8
IMUL reg32, imm32

双操作数格式会按照目的操作数的大小来截取乘积。如果被丢弃的是有效位,则溢出标志位和进位标志位置 1。因此,在执行了有两个操作数的 IMUL 操作后,必须检查这些标志位中的一个。

三操作数格式

32 位模式下的三操作数格式将乘积保存在第一个操作数中。第二个操作数可以是 16 位寄存器或内存操作数,它与第三个操作数相乘,该操作数是一个8位或16 位立即数:

IMUL regl6, reg/meml6,imm8
IMUL regl6, reg/meml6, iirrnl6

而 32 位寄存器或内存操作数可以与 8 位或 32 位立即数相乘:

IMUL reg32, reg/mem32, imm8
IMUL reg32, reg/mem32, imm32

IMUL 执行时,若乘积有效位被丢弃,则溢出标志位和进位标志位置 1。因此,在执行了有三个操作数的 IMUL 操作后,必须检查这些标志位中的一个。

在 64 位模式下执行 IMUL

在 64 位模式下,IMUL 指令可以使用 64 位操作数。在单操作数格式中,64 位寄存器或内存操作数与 RAX 相乘,产生一个 128 位且符号扩展的乘积存放到 RDX:RAX 寄存器中。在下面的例子中,RBX 与 RAX 相乘,产生 128 位的乘积 -16。

mov rax, -4
mov rbx, 4
imul rbx         ; RDX = 0FFFFFFFFFFFFFFFFh, RAX = -16

也就是说,十进制数 -16 在 RAX 中表示为十六进制 FFFF FFFF FFF0,而 RDX 只包含 TRAX 的高位扩展,即它的符号位。

三操作数格式也可以用于 64 位模式。如下例所示,被乘数 (-16) 乘以 4,生成 RAX 中的乘积 -64:

.data
multiplicand QWORD -16
.code
imul rax, multiplicand, 4       ; RAX = FFFFFFFFFFFFFFC0 (-64)

无符号乘法

由于有符号数和无符号数乘积的低半部分是相同的,因此双操作数和三操作数的 IMUL 指令也可以用于无符号乘法。但是这种做法也有一点不便的地方:进位标志位和溢出标志位将无法表示乘积的高半部分是否为零。

IMUL 示例

下述指令执行 48 乘以 4,乘积 +192 保存在 AX 中。虽然乘积是正确的,但是 AH 不是 AL 的符号扩展,因此溢出标志位置 1:

mov al,48
mov bl, 4
imul bl            ; AX = 00C0h, OF = 1

下述指令执行 -4 乘以 4,乘积 -16 保存在 AX 中。AH 是 AL 的符号扩展,因此溢出标志位清零:

mov al, -4
mov bl, 4
imul bl            ; AX = FFF0h, OF = 0

下述指令执行 48 乘以 4,乘积 +192 保存在 DX:AX 中。DX 是 AX 的符号扩展,因此溢出标志位清零:

mov ax, 48
mov bx, 4
imul bx            ; DX:AX = 000000C0h, OF = 0

下述指令执行 32 位有符号乘法 (4 823 424*-423),乘积 -2 040 308 352 保存在 EDX:EAX 中。溢出标志位清零,因为 EDX 是 EAX 的符号扩展:

mov eax, +4823424
mov ebx, -423
imul ebx        ; EDX:EAX = FFFFFFFF86635D80h, OF = 0

下述指令展示了双操作数格式:

.data
word1 SWORD 4
dword1 SDWORD 4
.code
mov ax, -16            ; AX = -16
mov bx, 2              ; BX = 2
imul bx, ax            ; BX = -32
imul bx, 2             ; BX = -64
imul bx, word1         ; BX = -256
mov eax, -16           ; EAX = -16
mov ebx, 2             ; EBX = 2
imul ebx, eax          ; EBX = -32
imul ebx, 2            ; EBX = -64
imul ebx, dword1       ; EBX = -256

双操作数和三操作数 IMUL 指令的目的操作数大小与乘数大小相同。因此,有可能发生有符号溢出。执行这些类型的 IMUL 指令后,总要检查溢岀标志位。下面的双操作数指令展示了有符号溢出,因为 -64000 不适合 16 位目的操作数:

mov ax, -32000
imul ax, 2           ; OF = 1

下面的指令展示的是三操作数格式,包括了有符号溢出的例子:

.data
word1 SWORD 4
dword1 SDWORD 4
.code
imul bx, word1, -16             ; BX = word1 * -16
imul ebx, dword1, -16           ; EBX = dword1 * -16
imul ebx, dword1, -2000000000   ; 有符号溢出!

汇编语言GetMseconds:测量程序执行时间

通常,程序员发现用测量执行时间的方法来比较一段代码与另一段代码执行的性能是很有用的。Microsoft Windows API 为此提供了必要的工具,lrvine32 库中的 GetMseconds 过程可使其变得更加方便使用。该过程获取系统自午夜过后经过的毫秒数。

在下面的代码示例中,首先调用 GetMseconds,这样就可以记录系统开始时间。然后调用想要测量其执行时间的过程 (FirstProcedureToTest)。最后,再次调用 GetMseconds,计算开始时间和当前毫秒数的差值:

.data
startTime DWORD ?
procTime1 DWORD ?
procTime2 DWORD ?
.code
call GetMseconds    ;获得开始时间
mov startTime, eax
.
call FirstProcedureToTest
.
call GetMseconds     ;获得结束时间
sub eax, startTime   ;计算执行花费的时间
mov procTime1, eax   ;保存执行花费的时间

当然,两次调用 GetMseconds 会消耗一点执行时间。但是在衡量两个代码实现的性能时间之比时,这点开销是微不足道的。现在,调用另一个被测试的过程,并保存其执行时间 (procTime2):

call GetMseconds                ;获得开始时间
mov startTime, eax
.
call SecondProcedureToTest
.
call GetMseconds                ;获得结束时间
sub eax, startTime              ;计算执行花费的时间
mov procTime2, eax              ;保存执行花费的时间

则 procTime1 和 procTime2 的比值就可以表示这两个过程的相对性能。

MUL、IMUL 与移位的比较

对老的 x86 处理器来说,用移位操作实现乘法和用 MUL、IMUL 指令实现乘法之间有着明显的性能差异。可以用 GetMseconds 程比较这两种类型乘法的执行时间。下面的两个过程重复执行乘法,用常量 LOOP_COUNT 决定重复的次数:

mult_by_shifting PROC
;
;用 SHL 执行 EAX 乘以 36,执行次数为LOOP_COUNT
    mov ecx, LOOP_COUNT
L1: push eax                    ;保存原始 EAX
    mov ebx, eax
    shl eax, 5
    shl ebx, 2
    add eax, ebx
    pop eax                      ;恢复 EAX
    loop LI
    ret
mult_by_shifting ENDP
mult_by_MUL PROC
;
;用MUL执行EAX乘以36,执行次数为LOOP_COUNT
    mov ecx, LOOP_COUNT
LI: push eax                    ;保存原始 EAX
    mov ebx, 36
    mul ebx
    pop eax                      ;恢复 EAX
    loop L1
    ret
mult_by_MUL ENDP

下述代码调用 multi_by_shifting,并显示计时结果。

.data
LOOP_COUNT = 0FFFFFFFFh
.data
intval DWORD 5
startTime DWORD ?
.code
main PROC
    call    GetMseconds          ; 获取开始时间
    mov    startTime,eax
    mov    eax,intval            ; 开始乘法
    call    mult_by_shifting
    call    GetMseconds          ; 获取结束时间
    sub    eax,startTime
    call    WriteDec             ; 显示乘法执行花费的时间

用同样的方法调用 mult_by_MUL,在传统的 4GHz 奔腾 4 处理器上运行的结果为:SHL 方法执行时间是 6.078 秒,MUL 方法执行时间是 20.718 秒。也就是说,使用 MUL 指令速度会慢 2.41 倍。

但是,在近期的处理器上运行同样的程序,调用两个函数的时间是完全一样的。这个例子说明,Intel 在近期的处理器上已经设法大大地优化了 MUL 和 IMUL 指令。

汇编语言DIV指令:无符号除法

32 位模式下,DIV(无符号除法)指令执行 8 位、16 位和 32 位无符号数除法。其中,单寄存器或内存操作数是除数。格式如下:

DIV reg/mem8
DIV reg/meml6
DIV reg/mem32

下表给出了被除数、除数、商和余数之间的关系:

被除数 除数 余数
AX reg/mem8 AL AH
DX:AX reg/mem16 AX DX
EDX:EAX reg/mem32 EAX EDX

64 位模式下,DIV 指令用 RDX:RAX 作被除数,用 64 位寄存器和内存操作数作除数, 商存放到 RAX,余数存放在 RDX 中。

DIV 示例

下述指令执行 8 位无符号除法 (83h/2),生成的商为 41h,余数为 1:

mov ax, 0083h   ; 被除数
mov bl, 2        ; 除数
div bl           ; AL = 41h, AH = Olh

下图展示了寄存器内容的变化:

下述指令执行 16 位无符号除法 (8003h/100h),生成的商为 80h,余数为 3。DX 包含的是被除数的高位部分,因此在执行 DIV 指令之前,必须将其清零:

mov dx, 0         ; 清除被除数高16位
mov ax, 8003h     ; 被除数的低16位
mov ex, 100h      ; 除数
div ex            ; AX = 0080h, DX = 0003h

下图展示了寄存器内容的变化:

下述指令执行 32 位无符号除法,其除数为内存操作数:

.data
dividend QWORD 0000000800300020h
divisor DWORD 00000100h
.code
mov edx, DWORD PTR dividend + 4  ; 高双字
mov eax, DWORD PTR dividend      ; 低双字
div divisor                      ; EAX = 08003000h, EDX = 00000020h

下图展示了寄存器内容的变化:

下面的 64 位除法生成的商 (0108 0000 0000 3330h) 在 RAX 中,余数 (0000 0000 0000 0020h) 在 RDX 中:

.data
dividend_hi QWORD 0000000000000108h
dividend_lo QWORD 0000000033300020h
divisor QWORD OOOOOOOOOOOlOOOOh
.code
mov rdx, dividend_hi
mov rax, dividend_lo
div divisor                ; RAX = 0108000000003330
                           ; RDX = 0000000000000020

请注意,由于被 64k 除,被除数中的每个十六进制数字是如何右移 4 位的。(若被 16 除,则每个数字只需右移一位。)

汇编语言IDICV指令:有符号数除法

有符号除法几乎与无符号除法相同,只有一个重要的区别:在执行除法之前,必须对被除数进行符号扩展。

符号扩展是指将一个数的最高位复制到包含该数的变量或寄存器的所有高位中。为了说明为何有此必要,让我们先不这么做。下面的代码使用 MOV 把 -101 赋给 AX,即 DX:AX 的低半部分:

.data
wordVal SWORD -101      ; 009Bh
.code
mov dx, 0
mov ax, wordVal         ; DX:AX = 0000009Bh (+155
mov bx, 2               ; BX 是除数
idiv bx                 ; DX:AX除以BX (有符号操作)

可惜的是,DX:AX 中的 009Bh 并不等于 -101,它等于 +155。因此,除法产生的商为 +77,这不是所期望的结果。而解决该问题的正确方法是使用 CWD( 字转双字 ) 指令,在进行除法之前在 DX:AX 中对 AX 进行符号扩展:

.data
wordVal SWORD -101      ; 009Bh
.code
mov dx, 0
mov ax, wordVal          ; DX:AX = 0000009Bh (+155)
cwd                      ; DX:AX = FFFFFF9Bh (-101 )
mov bx, 2
idiv bx

x86 指令集有几种符号扩展指令。首先了解这些指令,然后再将其应用到有符号除法指令 IDIV 中。

符号扩展指令(CBW、CWD、CDQ)

Intel 提供了三种符号扩展指令:CBW、CWD 和 CDQ。CBW(字节转字)指令将 AL 的符号位扩展到 AH,保留了数据的符号。如下例所示,9Bh(AL 中)和 FF9Bh (AX 中)都等于十进制的 -101:

.data
byteVal SBYTE -101     ; 9Bh
.code
mov al, byteVal        ; AL = 9Bh
cbw                    ; AX = FF9Bh

CWD(字转双字)指令将 AX 的符号位扩展到 DX:

.data
wordVal SWORD -101     ; FF9Bh
.code
mov ax, wordVal         ; AX = FF9Bh
cwd                     ; DX:AX = FFFFFF9Bh

CDQ(双字转四字)指令将 EAX 的符号位扩展到 EDX:

.data
dwordVal SDWORD -101    ; FFFFFF9Bh
.code
mov eax, dwordVal
Cdq                     ; EDX:EAX = FFFFFFFFFFFFFF9Bh

IDIV 指令

IDIV(有符号除法)指令执行有符号整数除法,其操作数与 DIV 指令相同。执行 8 位除法之前,被除数(AX)必须完成符号扩展。余数的符号总是与被除数相同。

【示例 1】下述指令实现 -48 除以 5。IDIV 执行后,AL 中的商为 -9,AH 中的余数为 -3:

.data
byteVal SBYTE -48       ;D0 十六进制
.code
mov al, byteVal         ;被除数的低字节
cbw                     ;AL扩展到AH
mov bl,+5               ;除数
idiv bl                 ;AL = -9, AH = -3

下图展示了 AL 是如何通过 CBW 指令符号扩展为 AX 的:

为了理解被除数的符号扩展为什么这么重要,现在在不进行符号扩展的前提下重复之前的例子。下面的代码将 AH 初始化为 0,这样它就有了确定值,然后没有用 CBW 指令转换被除数就直接进行了除法:

.data
byteVal SBYTE -48       ;D0 十六进制
.code
mov ah, 0               ;被除数高字节
mov al, byteVal         ;被除数低字节
mov bl, +5              ;除数
idiv bl                 ;AL = 41z AH = 3

执行除法之前,AX=00D0h ( 十进制数 208)。 IDIV 把这个数除以 5,生成的商为十进制数 41,余数为3。这显然不是正确答案。

【示例 2】16 位除法要求 AX 符号扩展到 DX。下例执行 -5000 除以 256:

.data
wordVal SWORD -5000
.code
mov ax, wordVal          ;被除数的低字
cwd                      ;AX扩展到DX
mov bx, +256             ;除数
idiv bx                  ;商 AX=-19,余数 DX=-13 6

【示例 3】32 位除法要求 EAX 符号扩展到 EDX。下例执行 50 000 除以 -256:

.data
dwordVal SDWORD +50000
.code
mov eax, dwordVal         ;被除数的低双字
cdq                       ;EAX 扩展至q EDX
mov ebx, -256             ;除数
idiv ebx                 ;商 EAX=-195,余数 EDX=+80

执行 DIV 和 IDIV 后,所有算术运算状态标志位的值都不确定。

除法溢出

如果除法操作数生成的商不适合目的操作数,则产生除法溢出 (divide overflow)。这将导致处理器异常并暂停执行当前程序。例如,下面的指令就产生了除法溢出,因为它的商 (100h) 对 8 位的 AL 目标寄存器来说太大了:

mov ax,1000h
mov bl,10h
div bl          ; AL无法容纳100h

运行这段代码时,Visual Studio 就会产生如下所示的结果错误。如果试图运行除以零的代码,也会显示相同的对话框。

Unhandled exception at 0x00401016 in Project.exe:0xC0000095:Integer overflow.

对此有个建议:使用 32 位除数和 64 位被除数来减少出现除法溢出条件的可能性。如下面的代码所示,除数为 EBX,被除数在 EDX 和 EAX 组成的 64 位寄存器对中:

mov eax,1000h
cdq
mov ebx,10h
div ebx              ; EAX = 00000100h

要预防除以零的操作,则在进行除法之前检查除数:

mov ax, dividend
mov bl, divisor
cmp bl, 0              ;检查除数
je NoDivideZero        ;为零?显不错误
div bl                 ;不为零:继续
.
.
NoDivideZero:         ;显示 "Attmpt to divide by zero"

使用汇编语言实现算术表达式[实例]

有两种简单的方法可以查看 C++ 编译器生成的汇编代码:

  • 一种方法是用 Visual Studio 调试时,在调试窗口中右键点击,选择 Go to Disassembly。

  • 一种方法是,在 Project 菜单中选择 Properties,生成一个列表文件。在 Configuration Properties,选择 Microsoft Macro Assembler,再选择 Listing Fileo 在对话窗口中,将 Generate Preprocessed Source Listing 设置为 Yes,List All Available Information 也设置为 Yes。

【示例 1】使用 32 位无符号整数,用汇编语言实现下述 C++ 语句:

var4 = (var1 + var2) * var3;

这个问题很简单,因为可以从左到右来处理 (先加法再乘法)。执行了第二条指令后,EAX 存放的是 val1 与 var2 之和。第三条指令中,EAX 乘以 var3,乘积存放在 EAX 中:

mov eax, var1
add eax, var2
mul var3                   ; EAX = EAX * var3
jc tooBig                  ;无符号溢出?
mov var4, eax
jmp next
tooBig:                    ;显示错误消息

如果 MUL 指令产生的乘积大于 32 位,则 JC 指令跳转到有标号指令来处理错误。

【示例 2】使用 32 位无符号整数实现下述 C++ 语句:

var4 = (var1 * 5) / (var2 - 3);

本例有两个用括号括起来的子表达式。左边的子表达式可以分配给 EDX:EAX,因此不必检查溢出。右边的子表达式分配给 EBX,最后用除法完成整个表达式:

mov eax, var1             ;左边的子表达式
mov ebx, 5
mul ebx                   ;EDX:EAX=乘积
mov ebx, var2             ;右边的子表达式
sub ebx, 3
div ebx                   ;最后的除法
mov var4, eax

【示例 3】使用 32 位有符号整数实现下述 C++ 语句:

var4 = (varl * -5) / (-var2 % var3);

与之前的例子相比,这个例子需要一些技巧。可以先从右边的表达式开始,并将其保存在 EBX 中。由于操作数是有符号的,因此必须将被除数符号扩展到 EDX,再使用 IDIV 指令:

mov eax,var2         ;开始计算右边的表达式
neg eax
cdq                  ;符号扩展被除数
idiv var3            ;EDX = 余数
mov ebx,edx          ;EBX = 右边表达式的结果

第二步,计算左边的表达式,并将乘积保存在 EDX:EAX 中:

mov eax, -5          ;开始计算左边表达式
imul var1            ;EDX:EAX=左边表达式的结果

最后,左边表达式结果 (EDX:EAX) 除以右边表达式结果 (EBX):

idiv ebx             ;最后计算除法
mov var4,eax         ;商

汇编语言ADC指令:带进位加法

ADC(带进位加法)指令将源操作数和进位标志位的值都与目的操作数相加。该指令格式与 ADD 指令一样,且操作数大小必须相同:

ADC reg, reg
ADC mem, reg
ADC reg, mem
ADC mem, imm
ADC reg, imm

例如,下述指令实现两个 8 位整数相加 (FFh+FFh),产生的 16 位和数存入 DL:AL,其值为 01FEh:

mov dl, 0
mov al, 0FFh
add al, 0FFh    ; AL = FEh
adc dl, 0       ; DL/AL = OlFEh

下图展示了这两个数相加过程中的数据活动。首先,FFh 与 AL 相加,生成 FEh 存入 AL 寄存器,并将进位标志位置 1。然后,将 0 和进位标志位与 DL 寄存器相加:

同样,下述指令实现两个 32 位整数相加 (FFFF FFFFh+ FFFF FFFFh),产生的 64 位和数存入 EDX:EAX,其值为:0000 0001 FFFF FFFEh:

mov edx, 0
mov eax, 0FFFFFFFFh
add eax, 0FFFFFFFFh
adc edx, 0

扩展加法示例

接下来将说明过程 Extended_Add 实现两个大小相同的扩展整数的加法。利用循环,该过程将两个扩展整数当作并行数组实现加法操作。数组中每对数值相加时,都要包括前一次循环迭代执行的加法所产生的进位标志位。实现过程时,假设整数存储在字节数组中,不过 本例很容易就能修改为双字数组的加法。

该过程接收两个指针,存入 ESI 和 EDI,分别指向参与加法的两个整数。EBX 寄存器指向缓冲区,用于存放和数,该缓冲区的前提条件是必须比两个加数大一个字节。此外,过程还用 ECX 接收最长加数的长度。

两个加数都需要按小端顺序存放,即其最低字节存放在该数组的起始地址。过程代码如下所示,添加了代码行编号便于进行详细讨论:

;------------------------------------------
Extended_Add PROC
; 计算两个以字节数组存放的扩展整数之和。
; 接收:ESI和EDI为两个加数的指针
;      EBX 为和数变量指针,
;      ECX为
; 相加的字节数。
; 和数存储区必须比输入的操作数多一个字节。
; 返回:无
;------------------------------------------
    pushad
    clc                         ;清除进位标志位

L1: mov    al, [esi]            ;取第一个数
    adc    al, [edi]            ;与第二个数相加
    pushfd                      ;保存进位标志位
    mov    [ebx], al            ;保存部分和
    add    esi, 1               ;三个指针都加1
    add    edi, 1
    add    ebx, 1
    popfd                      ;恢复进位标志位
    loop    L1                 ;重复循环

    mov byte ptr [ebx], 0      ;清除和数高字节
    adc byte ptr [ebx], 0  
    popad                      ;加上其他的进位
    ret
Extended_Add ENDP

当第14行和第15行将两个数组的最低字节相加时,加法运算可能会将进位标志位置 1。因此,第16行将进位标志位压入堆栈进行保存就很重要,因为在循环重复时会用到进位 标志位。第17行保存了和数的第一个字节,第18〜20行将三个指针(两个操作数,一个和数)都加 1。第 21 行恢复进位标志位,第 22 行将循环返回到第 14 行。

(LOOP 指令不会修改 CPU 的状态标志位。)再次循环时,第 17 行进行的是第二对字节的加法,其中包括进位标志位的值。因此,如果第一次循环过程产生了进位,则第二次循环就要包括该进位。按照这种方式循环,直到所有的字节都完成了加法。然后,最后的第 24 行和第 25 行检查操作数最高字节相加是否产生进位,若产生了进位,就将该值加到和数多岀来的那个字节中。

下面的代码示例调用 Extended_Add,并向其传递两个 8 字节的整数。要注意为和数多分配一个字节:

.data
op1 BYTE 34h,12h,98h,74h,06h,0A4h,0B2h,0A2h
op2 BYTE 02h,45h,23h,00h,00h,87h,10h,80h
sum BYTE 9 dup(0)     ; = 0122C32B0674BB5736h
.code
main PROC
    mov    esi,OFFSET op1        ; 第一个操作数
    mov    edi,OFFSET op2        ; 第二个操作数
    mov    ebx,OFFSET sum        ; 和数
    mov    ecx,LENGTHOF op1      ; 字节数
    call    Extended_Add
; 显示和数

    mov  esi,OFFSET sum
    mov  ecx,LENGTHOF sum
    call    Display_Sum
    call Crlf

上述程序的输出如下所示,加法产生了一个进位:

0122C32B0674BB5736

过程 Display_Sum 按照正确的顺序显示和数,即从最高字节开始依次显示到最低字节:

Display_Sum PROC
    pushad
    ; 指向左后一个数组
    add esi,ecx
    sub esi,TYPE BYTE
    mov ebx,TYPE BYTE

L1:    mov  al,[esi]            ; 取一个数组字节
    call WriteHexB              ; 显示该字节
    sub  esi,TYPE BYTE          ; 指向前一个字节
    loop L1
    popad
    ret
Display_Sum ENDP

汇编语言SBB指令:带借位减法

SBB(带借位减法)指令从目的操作数中减去源操作数和进位标志位的值。

下面的示例代码用 32 位操作数实现 64 位减法,EDX:EAX 的值为 0000 0007 0000 0001h,从该值中减去 2。低 32 位先执行减法,并设置进位标志位,然后高 32 位再进行包括进位标志位的减法:

汇编语言ASCII和非压缩十进制运算

假设程序需要用户输入两个数,并将它们相加。若用户输入 3402 和 1256,则程序输出如下所示:

输入第一个数: 3402

输入第二个数: 1256

和数: 4658

有两种方法可以计算并显示和数:

\1) 将两个操作数都转换为二进制,进行二进制加法,再将和数从二进制转换为 ASCII 数字串。

\2) 直接进行数字串的加法,按序相加每对 ASCII 数字(2+6、0+5、4+2、3+1)。和数为 ASCII 数字串,因此可以直接显示在屏幕上。

第二种方法需要在执行每对 ASCII 数字相加后,用特殊指令来调整和数。有四类指令用于处理 ASCII 加法、减法、乘法和除法,如下所示:

AAA (执行加法后进行 ASCII 调整) AAM (执行乘法后进行 ASCII 调整)
AAS (执行减法后进行 ASCII 调整) AAD (执行除法前进行 ASCII 调整)

ASCII 十进制数和非压缩十进制数

非压缩十进制整数的高 4 位总是为零,而 ASCII 十进制数的高 4 位则应该等于 0011b。在任何情况下,这两种类型的每个数字都占用一个字节。

下面的例子展示了 3402 用这两种类型存放的格式:

尽管 ASCII 运算执行速度比二进制运算要慢很多,但是它有两个明显的优点:

\1) 不必在执行运算之前转换串格式。

\2) 使用假设的十进制小数点,使得实数操作不会出现浮点运算的舍入误差的危险。

ASCII 加减法运行操作数为 ASCII 格式或非压缩十进制格式,但是乘除法只能使用非压缩十进制数。

汇编语言AAA指令:调整ADD或ADC指令的二进制运算结果

在 32 位模式下,AAA ( 加法后的 ASCII 调整 ) 指令调整 ADD 或 ADC 指令的二进制运算结果。设两个 ASCII 数字相加,其二进制结果存放在 AL 中,则 AAA 将 AL 转换为两个非压缩十进制数字存入 AH 和 AL。一旦成为非压缩格式,通过将 AH 和 AL 与 30h 进 OR 运算,很容易就能把它们转换为 ASCII 码。

下例展示了如何用 AAA 指令正确地实现 ASCII 数字 8 加 2。在执行加法之前,必须把 AH 清零,否则它将影响 AAA 执行的结果。最后一条指令将 AH 和 AL 转换为 ASCII 数字:

mov ah, 0
mov al, '8'                     ; AX = 0038h
add al, '2'                     ; AX = 006Ah
aaa                             ; AX = 0100h (结果进行 ASCII 调整)
or ax, 3030h                    ; AX = 3130h ='10' (转换为 ASCH 码)

使用 AAA 实现多字节加法

现在来查看一个过程,其功能为实现包含了隐含小数点的 ASCII 十进制数值相加。由于每次数字相加的进位标志位都要传递到更高位,因此,过程的实现要比想象的更复杂一些。下面的伪代码中,acc 代表的是一个 8 位的累加寄存器:

esi (index) = length of first_number - 1
edi (index) = length of first_number
ecx = length of first_number
set carry value to 0
Loop
   acc = first_number[esi]
   add previous carry to acc
   save carry in carry1
   acc += second_number[esi]
   OR the carry with carry1
   sum[edi] = acc
   dec edi
Until ecx == 0
Store last carry digit in sum

进位值必须总是被转换为 ASCII 码。将进位值与第一个操作数相加时,就需要用 AAA 来调整结果。程序清单如下:

; ASCII Addition                      (ASCII_add.asm)
; 对有隐含固定小数点的串执行 ASCII 运算。
INCLUDE Irvine32.inc
DECIMAL_OFFSET = 5                            ; 距离右侧的偏移量
.data
decimal_one BYTE "100123456789765"            ; 1001234567.89765
decimal_two BYTE "900402076502015"            ; 9004020765.02015
sum BYTE (SIZEOF decimal_one + 1) DUP(0),0
.code
main PROC
; 从最后一个数字开始
    mov    esi,SIZEOF decimal_one - 1
    mov    edi,SIZEOF decimal_one
    mov    ecx,SIZEOF decimal_one
    mov    bh,0                       ; 进位值清零
L1:    mov    ah,0                    ; 执行加法前清除AH
    mov    al,decimal_one[esi]        ; 取第一个数字
    add    al,bh                      ; 加上之前的进位值
    aaa                               ; 调整和数 (AH = 进位值)
    mov    bh,ah                      ; 将进位保存到 carry1
    or    bh,30h                      ; 将其转化为 ASCII 码
    add    al,decimal_two[esi]        ; 加第二个数字
    aaa                               ; 调整和数 (AH = 进位值)
    or    bh,ah                       ; 将进位值 carry1 进行 OR 运算
    or    bh,30h                      ; 将其转换为 ASCII 码
    or    al,30h                      ; 将 AL 转换为 ASCII 码
    mov    sum[edi],al                ; 将 AL 保存到 sum
    dec    esi                        ; 后退一个数字
    dec    edi
    loop    L1
    mov    sum[edi],bh                ; 保存最后的进位值
; 显示和数字符串
    mov    edx,OFFSET sum
    call    WriteString
    call    Crlf

    exit
main ENDP
END main

程序输出如下所示,和数没有显示十进制小数点:1000 5255 3329 1780

汇编语言AAS指令:减法后的ASXII调整

32 位模式下,AAS(减法后的 ASCII 调整)指令紧随 SUB 或 SBB 指令之后,这两条指令执行两个非压缩十进制数的减法,并将结果保存到 AL 中。AAS 指令将 AL 转换为 ASCII 码的数字形式。

只有减法结果为负时,调整才是必需的。比如,下面的语句实现 ASCII 码 数字 8 减去 9:

.data
val1 BYTE '8'
val2 BYTE '9'
.code
mov ah, 0
mov al,val1            ; AX = 0038h
sub al,val2            ; AX = OOFFh
aas                    ; AX = FF09h
pushf                  ; 保存进位标志位
or al,30h              ; AX = FF39h
popf                   ; 恢复进位标志位

执行 SUB 指令后,AX 等于 00FFh。AAS 指令将 AL 转换为 09h,AH 减 1 等于 FFh,并且把进位标志位置 1。

汇编语言AAM(乘法后的ASCII调整)和AAD(除法之前的ASCII调整)指令

32 位模式下,MUL 执行非压缩十进制乘法,AAM(乘法后的 ASCII 调整)指令转换由其产生的二进制乘积。乘法只能使用非压缩十进制数。

下面的例子实现 5 乘以 6,并调整 AX 中的结果。调整后,AX=0300h,非压缩十进制表示为 30:

.data
AscVal BYTE 05h, 06h
.code
mov bl, ascVal      ;第一个操作数
mov al, [ascVal+1]  ;第二个操作数
mul bl              ;AX = 001Eh
aam                 ;AX = 0300h

同样在 32 位模式下,AAD(除法之前的 ASCII 调整)指令将 AX 中的非压缩十进制被除数转换为二进制,为执行 DIV 指令做准备。

下面的例子把非压缩 0307h 转换为二进制数,然后除以 5。DIV 指令在 AL 中生成商 07h,在 AH 中生成余数 02h:

.data
quotient BYTE ?
remainder BYTE ?
.code
mov ax, 0307h         ; 被除数
aad                   ; AX = 0025h
mov bl, 5             ; 除数
div bl                ; AX = 0207h
mov quotient,al
mov remainder,ah

汇编语言压缩十进制运算简介

压缩十进制数的每个字节存放两个十进制数字,每个数字用 4 位表示。如果数字个数为奇数,则最高的半字节用零填充。存储大小可变:

bcd1 QWORD 2345673928737285h       ;十进制数 2 345 673 928 737 285
bcd2 DWORD 12345678h            ;十进制数 12 345 678
bcd3 DWORD 08723654h            ;十进制数 8 723 654
bcd4 WORD 9345h                 ;十进制数 9345
bcd5 WORD 0237h                 ;十进制数 237
bcd6 BYTE 34h                     ;十进制数 34

压缩十进制存储至少有两个优势:

\1) 数据几乎可以包含任何个数的有效数字。这使得以很高的精度执行计算成为可能。

\2) 实现压缩十进制数与 ASCII 码之间的相互转换相对简单。

DAA(加法后的十进制调整)和 DAS(减法后的十进制调整)这两条指令调整压缩十进制数加减法的结果。可惜的是,目前还没有与乘除法有关的相似指令。在这些情况下,相乘或相除的数必须是非压缩的,执行后再压缩。

汇编语言DAA指令:加法后的十进制调整

32 位模式下,ADD 或 ADC 指令在 AL 中生成二进制和数,DAA(加法后的十进制调整)指令将和数转换为压缩十进制格式。比如,下述指令执行压缩十进制数 35 加 48。二进制和数(7Dh)被调整为 83h,即 35 和 48 的压缩十进制和数。

mov al, 35h
add al, 48h       ; AL = 7Dh
daa          ; AL = 83h (调整后的结果)

【示例】下面的程序执行两个 16 位压缩十进制整数加法,并将和数保存在一个压缩双字中。加法要求和数变量的存储大小比操作数多一个数字:

; 压缩十进制示例    (AddPacked.asm)
; 演示压缩十进制加法。
INCLUDE Irvine32.inc
.data
packed_1 WORD 4536h
packed_2 WORD 7207h
sum DWORD ?
.code
main PROC
; 初始化和数与索引
    mov    sum,0
    mov    esi,0

; 低字节相加
    mov    al,BYTE PTR packed_1[esi]
    add    al,BYTE PTR packed_2[esi]
    daa
    mov    BYTE PTR sum[esi],al

; 高字节相加,包括进位标志位
    inc    esi
    mov    al,BYTE PTR packed_1[esi]
    adc    al,BYTE PTR packed_2[esi]
    daa
    mov    BYTE PTR sum[esi],al
; 若还有进位,则加上该进位值
    inc    esi
    mov    al,0
    adc    al,0
    mov    BYTE PTR sum[esi],al

; 用十六进制显示和数
    mov    eax,sum
    call    WriteHex
    call    Crlf
    exit
main ENDP
END main

汇编语言DAS指令:减法后的十进制调整

32 位模式下,SUB 或 SBB 指令在 AL 中生成二进制结果,DAS(减法后的十进制调整)指令将其转换为压缩十进制格式。

具体调整规则如下:

  • 如果 AL 的低四位大于 9,或 AF=1,那么,AL=AL-06H,并置 AF=1;

  • 如果 AL 的高四位大于 9,或 CF=1,那么,AL=AL-60H,并置 CF=1;

  • 如果以上两点都不成立,则清除标志位 AF 和 CF。

经过调整后,AL 的值仍是压缩型 BCD 码,即:二个压缩型 BCD 码相减,并进行调整后,得到的结果还是压缩型 BCD 码。

比如,下面的语句计算压缩十进制数 85 减 48,并调整结果:

mov bl,48h
mov al,85h
sub al,bl   ; AL = 3Dh
das         ; AL = 37h (调整后的结果)

DAS 的内部逻辑请参阅 Intel 指令集参考手册。

汇编语言高级过程

汇编语言堆栈帧简介

子程序接收的是寄存器参数。比如在 Irvine32 链接库中就是如此。接下来将展示子程序如何用堆栈接收参数。

在 32 位模式下,堆栈参数总是由 Windows API 函数使用。然而在 64 位模式下,Windows 函数可以同时接收寄存器参数和堆栈参数。

堆栈帧 (stack frame)( 或活动记录 (activation Tecord)) 是一块堆栈保留区域,用于存放被传递的实际参数、子程序的返回值、局部变量以及被保存的寄存器。

堆栈帧的创建步骤如下所示:

\1) 被传递的实际参数。如果有,则压入堆栈。

\2) 当子程序被调用时,使该子程序的返回值压入堆栈。

\3) 子程序开始执行时,EEP 被压入堆栈。

\4) 设置 EBP 等于 ESP。从这时开始,EBP 就变成了该子程序所有参数的引用基址。

\5) 如果有局部变量,修改 ESP 以便在堆栈中为这些变量预留空间。

\6) 如果需要保存寄存器,就将它们压入堆栈。

程序内存模式和对参数传递规则的选择直接影响到堆栈帧的结构。

学习用堆栈传递参数有个好理由:几乎所有的高级语言都会用到它们。比如,如果想要在 32 位 Windows 应用程序接口 (API) 中调用函数,就必须用堆栈传递参数。而 64 位程序可以使用另一种不同的参数传递规则。

汇编语言寄存器参数的缺点

多年以来,Microsoft 在 32 位程序中包含了一种参数传递规则,称为 fastcall。如同这个名字所暗示的,只需简单地在调用子程序之前把参数送入寄存器,就可以将运行效率提高一些。相反,如果把参数压入堆栈,则执行速度就要更慢一点。

典型用于参数的寄存器包括 EAX、EBX、ECX 和 EDX,少数情况下,还会使用 EDI 和 ESI。可惜的是,这些寄存器在用于存放数据的同时,还用于存放循环计数值以及参与计算的操作数。

因此,在过程调用之前,任何存放参数的寄存器须首先入栈,然后向其分配过程参数,在过程返回后再恢复其原始值。例如,如下代码从 Irvine32 链接库中调用了 DumpMem:

push ebx               ;保存寄存器值
push ecx
push esi
mov esi,OFFSET array   ;初始 OFFSET
mov ecx,LENGTHOF array ;大小,按元素个数计
mov ebx, TYPE array    ;双字格式
call DumpMem           ;显示内存
pop esi                ;恢复寄存器值
pop ecx
pop ebx

这些额外的入栈和出栈操作不仅会让代码混乱,还有可能消除性能优势,而这些优势正是通过使用寄存器参数所期望获得的!此外,程序员还要非常仔细地将 PUSH 与相应的 POP 进行匹配,即使代码存在着多个执行路径。

例如,在下面的代码中,第 8 行的 EAX 如果等于 1,那么过程在第 17 行就无法返回其调用者,原因就是有三个寄存器的值留在运行时堆栈里。

push ebx                   ;保存寄存器值
push ecx
push esi
mov esi, OFFSET array      ;初始 OFFSET
mov ecx, LENGTHOF array    ;大小,按元素个数计
mov ebx, TYPE array        ;双字格式
call DumpMem               ;显示内存
cmp eax, 1                 ;设置错误标志?
je error_exit              ;设置标志后退出
pop esi                    ;恢复寄存器值
pop ecx
pop ebx
ret
error_exit:
mov edx, offset error_msg
ret
不得不说,像这样的错误是不容易发现的,除非是花了相当多的时间来检查代码。

堆栈参数提供了一种不同于寄存器参数的灵活方法:只需要在调用子程序之前,将参数压入堆栈即可。比如,如果 DumpMem 使用了堆栈参数,就可以通过如下代码对其进行调用:

push TYPE array
push LENGTHOF array
push OFFSET array
call DumpMem

子程序调用时,有两种常见类型的参数会入栈:

  • 值参数(变量和常量的值)

  • 引用参数(变量的地址)

值传递

当一个参数通过数值传递时,该值的副本会被压入堆栈。假设调用一个名为 AddTwo 的子程序,向其传递两个 32 位整数:

.data
val1 DWORD 5
val2 DWORD 6
.code
push val2
push val1
call AddTwo

执行 CALL 指令前,堆栈如下图所示:

用 C++ 编写相同的功能调用则为

int sum = AddTwo(val1, val2);

观察发现参数入栈的顺序是相反的,这是 C 和 C++ 语言的规范。

引用传递

通过引用来传递的参数包含的是对象的地址(偏移量)。下面的语句调用了 Swap,并传递了两个引用参数:

push OFFSET val2
push OFFSET val1
call Swap

调用 Swap 之前,堆栈如下图所示:

在 C/C++ 中,同样的函数调用将传递 val1 和 val2 参数的地址:

Swap(&vail, &val2);

传递数组

高级语言总是通过引用向子程序传递数组。也就是说,它们把数组的地址压入堆栈。然后,子程序从堆栈获得该地址,并用其访问数组。

不愿意用值来传递数组的原因是显而易见的,因为这样就会要求将每个数组元素分别压入堆栈。这种操作不仅速度很慢,而且会耗尽宝贵的堆栈空间。

下面的语句用正确的方法向子程序 ArrayFill 传递了数组的偏移量:

.data
array DWORD 50 DUP(?)
.code
push OFFSET array
call ArrayFill

汇编语言访问堆栈参数详解

高级语言有多种方式来对函数调用的参数进行初始化和访问。以 C 和 C++ 语言为例,它们以保存 EBP 寄存器并使该寄存器指向栈顶的语句为开始 (prologue)。

然后,根据实际情况,它们可以把某些寄存器入栈,以便在函数返回时恢复这些寄存器的值。在函数结尾 (epilogue) 部分,恢复 EBP 寄存器,并用 RET 指令返回调用者。

AddTwo示例

下面是用 C 编写的 AddTwo 函数,它接收了两个值传递的整数,然后返回这两个数之和:

int AddTwo( int x, int y )
{
    return x + y;
}

现在用汇编语言实现同样的功能。在函数开始的时候,AddTwo 将 EBP 入栈,以保存其当前值:

AddTwo PROC
   push ebp

接下来,EBP 的值被设置为等于 ESP,这样 EBP 就成为 AddTwo 堆栈帧的基址指针:

AddTwo PROC
   push ebp
   mov ebp,esp

执行了上面两条指令后,堆栈帧的内容如下图所示。而形如 AddTwo(5, 6) 的函数调用会先把第一个参数入栈,再把第二个参数入栈:

AddTwo 在其他寄存器入栈时,不用通过 EEP 来修改堆栈参数的偏移量。数值会改变的是 ESP,而 EBP 则不会。

基址-偏移量寻址

可以使用基址-偏移量寻址 (base-offset addressing) 方式来访问堆栈参数。其中,EBP 是基址寄存器,偏移量是常数。通常,EAX 为 32 位返回值。AddTwo 的实现如下所示,参数相加后,EAX 返回它们的和数:

AddTwo PROC
    push ebp
    mov ebp, esp              ;堆栈帧的基址
    mov eax, [ebp + 12]       ;第二个参数
    add eax, [ebp + 8]        ;第一个参数 pop ebp
    ret
AddTwo ENDP

显式的堆栈参数

若堆栈参数的引用表达式形如 [ebp+8],则称它们为显式的堆栈参数 (explicit stack parameters)。这个名称的含义是:汇编代码显式地说明了参数的偏移量是一个常数。有些程序员定义符号常量来表示显式的堆栈参数,以使其代码更具易读性:

y_param EQU [ebp + 12]
x_param EQU [ebp + 8]
AddTwo PROC
    push ebp
    mov ebp,esp
    mov eax,y_param
    add eax,x_param
    pop ebp
    ret
AddTwo ENDP

清除堆栈

子程序返回时,必须将参数从堆栈中删除。否则将导致内存泄露,堆栈就会被破坏。例如,设如下语句在 main 中调用 AddTwo:

push 6
push 5
call AddTwo

假设 AddTwo 有两个参数留着堆栈中,下图所示为调用返回后的堆栈:

main 部分试图忽略这个问题,并希望程序能正常结束。但是,如果循环调用 AddTwo,堆栈就会溢出。因为每次调用都会占用 12 字节的堆栈空间——每个参数需要 4 个字节,再加 4 个字节留给 CALL 指令的返回地址。如果在 main 中调用 Example1,而它又要调用 AddTwo 就会导致更加严重的问题:

main PROC
    call Example1
    exit
main ENDP
Example1 PROC
    push 6
    push 5
    call AddTwo
    ret                   ;堆栈被破坏了!
Example1 ENDP

当 Example1 的 RET 指令将要执行时,ESP 指向整数 5 而不是能将其带回 main 的返回地址:

RET 指令把整数 5 加载到指令指针寄存器,尝试将控制转移到内存地址为 5 的位置。假设这个地址在程序代码边界之外,那么处理器将给出运行时异常,通知 OS 终止程序。

常用32位编程调用规范简介

C 语言发布的 C 调用规范,该语言用于 Unix 和 Windows。然后是 STDCALL 调用规范,它描述了调用 Windows API 函数的协议。这两种规范都很重要,因为在 C 和 C++ 程序中会调用汇编函数, 同时汇编语言程序也会调用大量的 Windows API 函数。

C 调用规范

C 调用规范用于 C 和 C++ 语言。子程序的参数按逆序入栈,因此,C 程序在调用如下函数时,先将 B 入栈,再将 A 入栈:

AddTwo(A, B)

C 调用规范用一种简单的方法解决了清除运行时堆栈的问题:程序调用子程序时,在 CALL 指令的后面紧跟一条语句使堆栈指针(ESP)加上一个数,该数的值即为子程序参数所占堆栈空间的总和。下面的例子在执行 CALL 指令之前,将两个参数(5 和 6)入栈:

Example1 PROC
    push 6
    push 5
    call AddTwo
    add esp, 8        ;从堆栈移除参数
    ret
Example1 ENDP

因此,用 C/C++ 编写的程序在从子程序返回后,总是能把参数从堆栈中删除。

STDCALL 调用规范

另一种从堆栈删除参数的常用方法是使用名为 STDCALL 的规范。如下所示的 AddTwo 过程给 RET 指令添加了一个整数参数,这使得程序在返回到调用过程时,ESP 会加上数值 8。这个添加的整数必须与被调用过程参数占用的堆栈空间字节数相等:

AddTwo PROC
    push ebp
    mov ebp,esp                   ;堆栈帧基址
    mov eax, [ebp + 12 ]       ;第二个参数
    add eax, [ebp + 8 ]        ;第一个参数
    pop ebp
ret 8                           ;清除堆栈
AddTwo ENDP

要说明的是,STDCALL 与 C 相似,参数是按逆序入栈的。通过在 RET 指令中添加参数,STDCALL 不仅减少了子程序调用产生的代码量(减少了一条指令),还保证了调用程序永远不会忘记清除堆栈。

另一方面,C 调用规范则允许子程序声明不同数量的参数,主调程序可以决定传递多少个参数。C 语言的 printf 函数就是一个例子,它的参数数量取决于初始字符串参数中的格式说明符的个数:

int x = 5;
float y = 3.2;
char z = 'Z';
printf("Printing values: %d, %f, %c", xz y, z);

C 编译器按逆序将参数入栈,被调用的函数负责确定要传递的实际参数的个数,然后依次访问参数。这种函数实现没有像给 RET 指令添加一个常数那样简便的方法来清除堆栈,因此,这个责任就留给了主调程序。

调用 32 位 Windows API 函数时,Irvine32 链接库使用的是 STDCALL 调用规范。Irvine64 链接库使用的是 x64 调用规范。

保存和恢复寄存器

通常,子程序在修改寄存器之前要将它们的当前值保存到堆栈。这是一个很好的做法,因为可以在子程序返回之前恢复寄存器的原始值。理想情况下,相关寄存器入栈应在设置 EBP 等于 ESP 之后,在为局部变量保留空间之前。这有利于避免修改当前堆栈参数的偏移量。

例如,假设如下过程 MySub 有一个堆栈参数。在 EBP 被设置为堆栈帧基址后,ECX 和 EDX 入栈,然后堆栈参数加载到 EAX:

MySub PROC
    push ebp                ;保存基址指针
    mov ebp,esp             ;堆栈帧基址
    push ecx
    push edx                ;保存 EDX
    mov eax,[ebp+8]         ;取堆栈参数
    .
    .
    pop    edx               ;恢复被保存的寄存器
    pop ecx
    pop    ebp               ;恢复基址指针
    ret                      ;清除堆栈
MySub ENDP

EBP 被初始化后,在整个过程期间它的值将保持不变。ECX 和 EDX 的入栈不会影响到已入栈参数与 EBP 之间的位移量,因为堆栈的增长位于 EBP 的下方,如下图所示。

汇编语言局部变量应用

高级语言中,在单一子程序内新建、使用和撤销的变量被称为局部变量 (local variable)。局部变量创建于运行时堆栈,通常位于基址指针 (EBP) 之下。

尽管不能在汇编时给它们分配默认值,但是能在运行时初始化它们。可以使用与 C 和 C++ 相同的方法在汇编语言中新建局部变量。

【示例】下面的 C++ 函数声明了局部变量 X 和 Y:

void MySub()
{
    int X = 10;
    int Y = 20;
}

如果这段代码被编译为机器语言,就能看出局部变量是如何分配的。每个堆栈项都默认为 32 位,因此,每个变量的存储大小都要向上取整保存为 4 的倍数。两个局部变量一共要保留 8 个字节:

变量 字节数 堆栈偏移量
X 4 EBP-4
Y 4 EBP-8

MySub 函数(在调试器中)的反汇编展示了 C++ 程序如何创建局部变量,以及如何从堆栈中删除它们。该例使用了 C 调用规则:

MySub PROC
    push ebp
    mov ebp, esp
    sub esp, 8                    ;创建局部变量
    mov DWORD PTR [ebp-4],10      ; X
    mov DWORD PTR [ebp-8],20      ; Y
    mov esp, ebp                  ;从堆栈中删除局部变量
    pop ebp
    ret
MySub ENDP

局部变量初始化后,函数的堆栈帧如下图所示。

在结束前,函数通过将 EBP 的值赋给堆栈指针完成对其的重置,该操作的效果是把局部变量从堆栈中删除:

mov esp, ebp        ;从堆栈中删除局部变量

如果省略这一步,那么 POP EBP 指令将会把 EBP 设置为 20,而 RET 指令就会分支到内存地址 10 的位置,从而导致程序因出现处理器异常而终止。下面的 MySub 代码就是这种情况:

MySub PROC
    push ebp
    mov ebp, esp
    sub esp, 8                      ; 创建局部变量
    mov DWORD PTR [ebp-4], 10    ; X
    mov DWORD PTR [ebp-8], 20    ; Y
    pop ebp
    ret                             ; 返回到无效地址!
MySub ENDP

为了使程序更加易读,可以为每个局部变量的偏移量定义一个符号,然后在代码中使用这些符号:

X_local EQU DWORD PTR [ebp-4]
Y_local EQU DWORD PTR [ebp-8]
MySub PROC
    push ebp
    mov ebp, esp
    sub esp, 8              ; 为局部变量保留空间
    mov X_local, 10         ; X
    mov Y_local, 20         ; Y
    mov esp, ebp            ;从堆栈中删除局部变量
    pop ebp
    rst
MySub ENDP

汇编语言引用参数简介

引用参数通常是由过程用基址-偏移量寻址(从 EBP)方式进行访问。由于每个引用参数都是一个指针,因此,常常作为一个间接操作数放在寄存器中。例如,假设堆栈地址 [ebp+12] 存放了一个数组指针,则下述语句就把该指针复制到 ESP 中:

mov esi, [ebp+12 ]  ;指向数组

【示例】下面将要展示的 ArrayFill 过程用 16 位整数的伪随机序列来填充数组。它接收两个参数:数组指针和数组长度,第一个为引用传递,第二个为值传递。调用示例如下:

.data
count = 100
array WORD count DUP(?)
.code
main PROC
    push OFFSET array
    push COUNT
    call ArrayFill

在 ArrayFill 中,下面的代码为其开始部分,对堆栈帧指针(EBP)进行初始化:

ArrayFill PROC   
    push ebp
    mov ebp,esp

现在,堆栈帧中包含了数组偏移量、数组长度(count)、返回地址以及被保存的 EBP:

ArrayFill 保存了通用寄存器,检索参数并填充数组:

ArrayFill PROC   
    push ebp
    mov ebp,esp
    pushad                ; 保存寄存器
    mov esi,              ; 数组偏移量
    mov ecx,[ebp+8]       ; 数组长度
    cmp ecx,0             ; ECX == 0?
    je L2                 ; 是: 跳过循环

L1:
    mov eax,10000h        ; 随机范围 0 - FFFFh
    call RandomRange      ; 从链接库生成随机数
    mov [esi],ax          ; 在数组中插入值
    add esi,TYPE WORD     ; 指向下一个元素
    loop L1
L2:    popad                ; 恢复寄存器
    pop ebp
    ret 8                   ; 清除堆栈
ArrayFill ENDP

汇编语言LEA指令:返回间接操作数的地址

LEA 指令返回间接操作数的地址。由于间接操作数中包含一个或多个寄存器,因此会在运行时计算这些操作数的偏移量。为了演示如何使用 LEA,现在来看下面的 C++ 程序,该程序声明了一个局部数组 myString,并引用它来分配数组值:

void makeArray()
{
    char myString[30];
    for ( int i = 0; i < 30; i++ )
        myString[i] = '*';
}

与之等效的汇编代码在堆栈中为 myString 分配空间,并将地址(间接操作数)赋给 ESI。虽然数组只有 30 个字节,但是 ESP 还是递减了 32 以对齐双字边界。注意如何使用 LEA 把数组地址分配给 ESI:

makeArray PROC
    push ebp
    mov ebp,esp
    sub esp, 32            ;myString 位于 EBP-30 的位置
    lea esi, [ebp-30]      ;加载 myString 的地址
    mov ecx, 30            ;循环计数器
LI: mov BYTE PTR [esi]     ;填充一个位置
    inc esi                ;指向下一个元素
    loop LI                ;循环,直到 ECX=0
    add esp, 32            ;删除数组(恢复ESP)
    pop ebp
    ret
makeArray ENDP

不能用 OFFSET 获得堆栈参数的地址,因为 OFFSET 只适用于编译时已知的地址。下面的语句无法汇编:

mov esi,OFFSET [ebp-30 ]   ;错误

汇编语言ENTER和LEAVE指令:创建和结束堆栈帧

ENTER 指令为被调用过程自动创建堆栈帧。它为局部变量保留堆栈空间,把 EBP 入栈。具体来说,它执行三个操作:

  • 把 EBP 入栈 (push ebp)

  • 把 EBP 设置为堆栈帧的基址 (mov ebp, esp)

  • 为局部变量保留空间 (sub esp, numbytes)

ENTER 有两个操作数:第一个是常数,定义为局部变量保存的堆栈空间字节数;第二个定义了过程的词法嵌套级。

ENTER numbytes, nestinglevel

这两个操作数都是立即数。Numbytes 总是向上舍入为 4 的倍数,以便 ESP 对齐双字边界。Nestinglevel 确定了从主调过程堆栈帧复制到当前帧的堆栈帧指针的个数。在示例程序中,nestinglevel 总是为 0。

【示例 1】下面的例子声明了一个没有局部变量的过程:

MySub PROC
   enter 0,0

它与如下指令等效:

MySub PROC
   push ebp
   mov ebp, esp

【示例 2】ENTER 指令为局部变量保留了 8 个字节的堆栈空间:

MySub PROC
   enter 8,0

它与如下指令等效:

MySub PROC
   push ebp
   mov ebp,esp
   sub esp,8

下图为执行 ENTER 指令前后的堆栈示意图。

如果要使用 ENTER 指令,那么强烈建议在同一个过程的结尾处同时使用 LEAVE 指令。否则,为局部变量保留的堆栈空间就可能无法释放。这将会导致 RET 指令从堆栈中弹出错误的返回地址。

LEAVE 指令

LEAVE 指令结束一个过程的堆栈帧。它反转了之前的 ENTER 指令操作:恢复了过程被调用时 ESP 和 EBP 的值。再次以 MySub 过程为例,现在可以编码如下:

MySub PROC
   enter 8,0
   .
   .
   leave
   ret
MySub ENDP

下面是与之等效的指令序列,其功能是在堆栈中保存和删除 8 个字节的局部变量:

MySub PROC
   push ebp
   mov ebp, esp
   sub esp, 8
   .
   .
   mov esp, ebp
   pop ebp
   ret
MySub ENDP

汇编语言LOCAL伪指令:声明一个或多个变量名

不难想象,Microsoft 创建 LOCAL 伪指令是作为 ENTER 指令的高级替补。LOCAL 声明一个或多个变量名,并定义其大小属性。(另一方面,ENTER 则只为局部变量保留一块未命名的堆栈空间。)如果要使用 LOCAL 伪指令,它必须紧跟在 PROC 伪指令的后面。

其语法如下所示:

LOCAL varlist

varlist 是变量定义列表,用逗号分隔表项,可选为跨越多行。每个变量定义采用如下格式:

label:type

其中,标号可以为任意有效标识符,类型既可以是标准类型(WORD、DWORD 等),也可以是用户定义类型。

【示例】MySub 过程包含一个局部变量 var1,其类型为 BYTE:

MySub PROC
LOCAL var1:BYTE

BubbleSort 过程包含一个双字局部变量 temp 和一个类型为 BYTE 的 SwapFlag 变量:

BubbleSort PROC
LOCAL temp:DWORD, SwapFlag:BYTE

Merge 过程包含一个类型为 PTR WORD 的局部变量 pArray,它是一个 16 位整数的指针:

Merge PROC
LOCAL pArray:PTR WORD

局部变量 TempArray 是一个数组,包含 10 个双字。请注意用方括号显示数组大小:

LOCAL TempArray[10]:DWORD

MASM 代码生成

使用 LOCAL 伪指令时,查看 MASM 生成代码是有好处的。下面的过程 Example1 有一个双字局部变量:

Example1 PROC
    LOCAL temp:DWORD
    mov eax,temp
    ret
Example1 ENDP

MASM 为 Example1 生成如下代码,展示了 ESP 怎样减去 4,以便为双字变量预留空间:

push ebp
mov ebp, esp
add esp, OFFFFFFFCh     ;ESP 加 -4
mov eax, [ebp-4]
leave
ret

Example1 的堆栈帧示意图如下所示:

汇编语言Microsoft x64调用规范简介

Microsoft 遵循固定模式实现 64 位编程中的参数传递和子程序调用,该模式被称为 Microsoft x64 调用规范(Microsoft x64 calling convention)。它既用于 C 和 C++ 编译器,也用于 Windows API库。

只有在要么调用 Windows 函数,要么调用 C 和 C++ 函数时,才需要使用这个调用规范。它的特点和要求如下所示:

\1) 由于地址长为 64 位,因此 CALL 指令把 RSP(堆栈指针)寄存器的值减去8。

\2) 第一批传递给子程序的四个参数依次存放于寄存器 RCX、RDX、R8 和 R9。因此,如果只传递一个参数,它就会被放入 RCX。如果还有第二个参数,它就会被放入 RDX,以此类推。其他参数按照从左到右的顺序入栈。

\3) 长度不足 64 位的参数不进行零扩展,因此,其高位的值是不确定的。

\4) 如果返回值的长度小于或等于 64 位,那么它必须放在 RAX 寄存器中。

\5) 主调者要负责在堆栈中分配至少 32 字节的影子空间,以便被调用的子程序可以选择将寄存器保存在这个区域中。

\6) 调用子程序时,堆栈指针(RSP)必须对齐 16 字节边界。CALL 指令将 8 字节的返回地址压入堆栈,因此,主调程序除了把堆栈指针减去 32 以便存放寄存器参数之外,还要减去8。

\7) 被调用子程序执行结束后,主调程序需负责从运行时堆栈中移除所有的参数和影子空间。

\8) 大于 64 位的返回值存放于运行时堆栈,由 RCX 指出其位置。

\9) 寄存器 RAX、RCX、RDX、R8、R9、R10 和 R11 常常被子程序修改,因此,如果主调程序想要保存它们的值,就应在调用子程序之前将它们入栈,之后再从堆栈弹出。

\10) 寄存器 RBX、RBP、RDI、RSI、R12、R13、R14 和 R15 的值必须由子程序保存。

汇编语言递归及应用详解

递归子程序(recursive subrountine)是指直接或间接调用自身的子程序。递归,调用递归子程序的做法,在处理具有重复模式的数据结构时,它是一个强大的工具。例如链表和各种类型的连接图,这些情况下,程序都需要追踪其路径。

无限递归

子程序对自身的调用是递归中最显而易见的类型。例如,下面的程序包含一个名为 Endless 的过程,它不间断地重复调用自身:

;无限递归 (Endless, asm)
INCLUDE Irvine32.inc
.data
endlessStr BYTE "This recursion never stops",0
.code
main PROC
    call Endless
    exit
main ENDP
Endless PROC
    mov edx,OFFSET endlessStr
    call WriteString
    call Endless
    ret                           ;从不执行
Endless ENDP
END main

当然,这个例子没有任何实用价值。每次过程调用自身时,它会占用 4 字节的堆栈空间让 CALL 指令将返回地址入栈。RET 指令永远不会被执行,仅当堆栈溢出时,程序终止。

递归求和

实用的递归子程序总是包含终止条件。当终止条件为真时,随着程序执行所有挂起的 RET 指令,堆栈展开。举例说明,考虑一个名为 CalcSum 的递归过程,执行整数 1 到 n 的加法,其中 n 是通过 ECX 传递的输入参数。CalcSum 用 EAX 返回和数:

;整数求和   (RecursiveSum. asm)
INCLUDE Irvine32.inc
.code
main PROC
    mov  ecx,5          ; 计数值 = 5
    mov  eax,0          ; 保存和数
    call CalcSum        ; 计算和数
L1:    call WriteDec    ; 显示 EAX
    call Crlf           ; 换行
    exit
main ENDP
;--------------------------------------------------------
CalcSum PROC
; 计算整数列表的和数
; 接收: ECX = 计数值
; 返回: EAX = 和数
;--------------------------------------------------------
    cmp  ecx,0         ; 检查计数值
    jz   L2            ; 若为零则推出
    add  eax,ecx       ; 否则,与和数相加
    dec  ecx           ; 计数值递减
    call CalcSum       ; 递归调用
L2:    ret
CalcSum ENDP
END Main

CalcSum 的开始两行检查计数值,若 ECX=0 则退出该过程,代码就跳过了后续的递归调用。当第一次执行 RET 指令时,它返回到前一次对 CalcSum 的调用,而这个调用再返回到它的前一次调用,依序前推。

下表给出了 CALL 指令压入堆栈的返回地址(用标号表示),以及与之相应的 ECX(计数值)和 EAX(和数)的值。

入栈的返回地址 ECX的值 EAX的值 入栈的返回地址 ECX的值 EAX的值
L1 5 0 L2 2 12
L2 4 5 L2 1 14
L2 3 9 L2 0 15

即使是一个简单的递归过程也会使用大量的堆栈空间。每次过程调用发生时最少占用 4 字节的堆栈空间,因为要把返回地址保存到堆栈。

计算阶乘

递归子程序经常用堆栈参数来保存临时数据。当递归调用展开时,保存在堆栈中的数据就有用了。下面要查看的例子是计算整数 n 的阶乘。阶乘算法计算 n!,其中 n 是无符号整数。第一次调用 factorial 函数时,参数 n 就是初始数字。下面给出的是用 C/C++/Java 语法编写的代码:

int function factorial(int n)
{
    if(n == 0)
        return 1;
    else
        return n * factorial(n-1);
}

假设给定任意 n,即可计算 n-1 的阶乘。这样就可以不断减少 n,直到它等于 0 为止。根据定义,0!=l。而回溯到原始表达式 n! 的过程,就会累积每次的乘积。比如计算 5! 的递归算法如下图所示,左列为算法递进过程,右列为算法回溯过程。

【示例】下面的汇编语言程序包含了过程 Factorial,递归计算阶乘。通过堆栈把 n(0〜12 之间的无符号整数 ) 传递给过程 Factorial,返回值在 EAX 中。由于 EAX 是 32 位寄存器,因此,它能容纳的最大阶乘为 12!(479 001 600 )。

; 计算阶乘 (Fact.asm)
INCLUDE Irvine32.inc
.code
main PROC
    push 5                ; 计算 5!
    call Factorial        ; 计算阶乘 (eax)
    call WriteDec         ; 显示结果
    call Crlf
    exit
main ENDP
Factorial PROC
    push ebp
    mov  ebp,esp
    mov  eax,[ebp+8]       ; 获取 n
    cmp  eax,0             ; n < 0?
    ja   L1                ; 是: 继续
    mov  eax,1             ; 否: 返回0!的值 1
    jmp  L2                ; 并返回主调程序
L1:    dec  eax
    push eax               ; Factorial(n-1)
    call Factorial
; 每次递归调用返回时
; 都要执行下面的指令
ReturnFact:
    mov  ebx,[ebp+8]       ; 获取 n
    mul  ebx               ; EDX:EAX = EAX*EBX
L2:    pop  ebp            ; 返回 EAX
    ret  4                 ; 清除堆栈
Factorial ENDP
END main

现在通过跟踪初始值 N=3 的调用过程,来更加详细地查看 Factorial。按照其说明中的记录,Factorial 用 EAX 寄存器返回结果:

push 3
call Factorial          ; EAX = 3!

Factorial 过程接收一个堆栈参数 N 为初始值,以决定计算哪个数的阶乘。主调程序的返回地址由 CALL 指令自动入栈。Factorial 的第一个操作是把 EBP 入栈,以便保存主调程序堆栈的基址指针:

Factorial PROC
   push ebp

之后,它必须把 EBP 设置为当前堆栈帧的起始地址:

mov ebp resp

现在,EBP 和 ESP 都指向栈顶,运行时堆栈的堆栈帧如下图所示。其中包含了参数 N、主调程序的返回地址和被保存的 EBP 值:

由上图可知,要从堆栈中取出 N 并加载到 EAX,代码需要把 EBP 加 8 后进行基址-偏移量寻址:

mov eax, [ebp+8]     ; 获取 n

然后,代码检查基本情况 (base case),即停止递归的条件。如果 N (EAX 当前值 ) 等于 0,函数返回 1,也就是 0! 的定义值。

cmp   eax,0    ; n>0   ?
ja   L1         ; 是:继续
mov   eax, 1    ; 否:返回o   !的结果1
jmp   L2       ; 并返回主调程序

由于当前 EAX 的值为 3,Factorial 将递归调用自身。首先,它从 N 中减去 1,并把新值入栈。该值作为参数传递给新调用的 Factorial:

L1: dec eax
   push eax        ; Factorial(n - 1)
   call Factorial

现在,执行转向 Factorial 的第一行,计算数值为 N 的新值:

Factorial PROC
   push ebp
   mov ebp,esp

运行时堆栈又包含了第二个堆栈帧,其中 N 等于 2:

现在 N 的值为 2,将其加载到 EAX,并与 0 比较:

mov eax, [ebp+8]     ;当前 N=2
cmp   eax, 0             ; N 与 0 比较
ja   L1            ;仍然大于0
mov   eax, 1             ;不执行
jmp   L2          ;不执行

N 大于 0,因此,继续执行标号 L1。

大家可能已经注意到之前的 EAX,即第一次调用时分配给 Factorial 的值,被新值覆盖了。这说明了一个重要的事实:在过程进行递归调用时,应该小心注意哪些寄存器会被修改。如果需要保存这些寄存器的值,就需要在递归调用之前将其入栈,并在调用返回之后将其弹出堆栈。幸运的是,对 Factorial 过程而言,在递归调用之间保存 EAX 并不是必要的。

执行 L1 时,将会用递归过程调用来计算 N-1 的阶乘。代码将 EAX 减 1,并将结果入栈,再调用 Factorial:
L1: dec   eax           ; N = 1
   push eax       ; Factorial(1)
   call Factorial

现在,第三次进入 Factorial,堆栈中也有了三个活动的堆栈帧:

Factorial 程将 N 与 0 比较,发现 N 大于 0,则再次调用 Factorial,此时 N=0。当最后一次进入 Factorial 过程时,运行时堆栈出现了第四个堆栈帧:

在 N=0 时调用 Factorial,情况变得有趣了。下面的语句产生了到标号 L2 的分支。由于 0! =1,因此数值 1 赋给 EAX,而 EAX 必须包含 Factorial 的返回值:

mov eax,[ebp+8]     ; EAX = 0
cmp eax,0          ; n < 0?
ja L1               ; 是: 继续
mov eax,1          ; 否: 返回0!的值 1
jmp L2             ; 并返回主调程序
标号 L2 的语句如下,它使得 Factorial 返回到前一次的调用:
L2: pop ebp           ; 返回 EAX
   ret 4          ; 清除堆栈

此时,如下图所示,最近的帧已经不在运行时堆栈中,且 EAX 的值为 1(零的阶乘)。

下面的代码行是 Factorial 调用的返回点。它们获取 N 的当前值(保存于堆栈 EBP+8 的位置),将其与 EAX(Factorial 调用的返回值)相乘。那么,EAX 中的乘积就是 Factorial 本次迭代的返回值:

ReturnFact:
   mov ebx,[ebp+8]      ; 获取 n
   mul ebx           ; EDX:EAX = EAX*EBX

L2:   pop ebp               ; 返回 EAX
   ret 4              ; 清除堆栈
Factorial ENDP

(EDX 中的乘积高位部分为全 0,可以忽略。)由此,上述代码行第一次得以执行,EAX 保存了表达式 1 x 1 的乘积。随着 RET 指令的执行,又一个堆栈帧从堆栈中删除:

再次执行 CALL 指令后面的语句,将 N(现在等于 2)与 EAX 的值(等于 1)相乘:

ReturnFact:
   mov ebx,[ebp+8]      ; 获取 n
   mul ebx           ; EDX:EAX = EAX*EBX

L2:   pop ebp               ; 返回 EAX
   ret 4             ; 清除堆栈
Factorial ENDP

EAX 中的乘积现在等于 2,RET 指令又从堆栈中移除一个堆栈帧:

现在,最后一次执行 CALL 指令后面的语句,将 N(等于 3)与 EAX 的值(等于 2)相乘:

ReturnFact:
   mov ebx,[ebp+8]      ; 获取 n
   mul ebx           ; EDX:EAX = EAX*EBX

L2:   pop ebp               ; 返回 EAX
   ret 4              ; 清除堆栈
Factorial ENDP

EAX 的返回值为 6,是 3! 的计算结果,也是第一次调用 Factorial 时希望进行的计算。当 RET 指令执行时,最后一个堆栈帧也从堆栈中移除。

汇编语言INVOKE伪指令:将参数入栈并调用过程

INVOKE 伪指令,只用于 32 位模式,将参数入栈(按照 MODEL 伪指令的语言说明符所指定的顺序)并调用过程。INVOKE 是 CALL 指令一个方便的替代品,因为,它用一行代码就能传递多个参数。常见语法如下:

INVOKE procedureName [, argumentList]

ArgumentList 是可选项,它用逗号分隔传递给过程的参数。例如,执行若干 PUSH 指令后调用 DumpArray 过程,使用 CALL 指令的形式如下:

push TYPE array
push LENGTHOF array
push OFFSET array
call DumpArray

使用等效的 INVOKE 则将代码减少为一行,列表中的参数逆序排列(假设遵循 STDCALL 规范):

INVOKE DumpArray, OFFSET array, LENGTHOF array, TYPE array

INVOKE 对参数数量几乎没有限制,每个参数也可以独立成行。下面的 INVOKE 语句包含了有用的注释:

INVOKE DumpArray,     ;显示数组
OFFSET array,          ;指向数组
LENGTHOF array,             ;数组长度
TYPE array             ;数组元素的大小类型
参数类型如下表所示。
类型 例子 类型 例子
立即数 10, 3000h, Offset mylist, TYPE array 寄存器 eax, bl, edi
整数表达式 (10*20), COUNT ADDR name ADDR myList
变量 myLIst, array, my Word, myDword OFFSET name OFFSET myList
地址表达式 [myList+2], [ebx+esi]

覆盖 EAX 和 EDX

如果向过程传递的参数小于 32 位,那么在将参数入栈之前,INVOKE 为了扩展参数常常会使得汇编器覆盖 EAX 和 EDX 的内容。有两种方法可以避免这种情况:

  • 其一,传递给 INVOKE 的参数总是 32 位的;

  • 其二,在过程调用之前保存 EAX 和 EDX,在过程调用之后再恢复它们的值。

汇编语言ADDR运算符:传递指针参数

ADDR 运算符同样可用于 32 位模式,在使用 INVOKE 调用过程时,它可以传递指针参数。比如,下面的 INVOKE 语句给 FillArray 过程传递了 myArray 的地址:

INVOKE FillArray, ADDR myArray

传递给 ADDR 的参数必须是汇编时常数。下面的例子就是错误的:

INVOKE mySub, ADDR [ebp+12 ]     ;错误

ADDR 运算符只能与 INVOKE 一起使用,下面的例子也是错误的:

mov esi, ADDR myArray             ;错误

【示例】下例中的 INVOKE 伪指令调用 Swap,并向其传递了一个双字数组前两个元素的地址:

.data
Array DWORD 20 DUP(?)
.code
...
INVOKE Swap,
  ADDR Array,
  ADDR [Array+4]

假设使用 STDCALL 规范,那么汇编器生成的相应代码如下所示:

push OFFSET Array+4
push OFFSET Array
call Swap

汇编语言PROC伪指令:过程定义

32 位模式中,PROC 伪指令基本语法如下所示:label PROC [attributes] [USES reglist], parameter_list

Label 是按照《LABEL伪指令》中说明的标识符规则、由用户定义的标号。Attributes 是指下述任一内容:

[distance] [langtype] [visibility] [prologuearg]

下表对这些属性进行了说明。

属性 说明
distance NEAR 或 FAR。指定汇编器生成的 RET 指令(RET 或 RETF)类型
langtype 指定调用规范(参数传递规范),如 C、PASCAL 或 STDCALL。能覆盖由 .MODEL 伪指令指定的语言
visibility 指明本过程对其他模块的可见性。选项包括 PRIVATE、PUBLIC (默认项)和 EXPORT。若可见性为 EXPORT,则链接器把过程名放入分段可执行文件的导出表。EXPORT 也使之具有了 PUBLIC 可见性
prologuearg 指定会影响开始和结尾代码生成的参数

参数列表

PROC 伪指令允许在声明过程时,添加上用逗号分隔的参数名列表。代码实现可以用名称来引用参数,而不是计算堆栈偏移量,如 [ebp+8]:

label PROC [attributes] [USES reglist],
   parameter_1,
   parameter_2,
   ...
   parameter_n

如果参数列表与 PROC 在同一行,则 PROC 后面的逗号可以省略:

label PROC [attributes], parameter_1, parameter_2, ..., parameter_n

每个参数的语法如下:

paramName: type

ParamName 是分配给参数的任意名称,其范围只限于当前过程(称为局部作用域(local scope))。同样的参数名可以用于多个过程,但却不能作为全局变量或代码标号的名称。

Type 可以在这些类型中选择:BYTE、SBYTE、WORD、SWORD、DWORD、SDWORD、FWORD、QWORD 或 TBYTE。此外,type 还可以是限定类型(qualified type),如指向现有类型的指针。

下面是限定类型的例子:

PTR BYTE     PTR SBYTE
PTR WORD   PTR SWORD
PTR DWORD   PTR SDWORD
PTR QWORD   PTR TBYTE

虽然可以在这些表达式中添加 NEAR 和 FAR 属性,但它们只与更加专用的应用程序相关。限定类型还能够用 TYPEDEF 和 STRUCT 伪指令创建。

【示例 1】AddTwo 过程接收两个双字数值,用 EAX 返回它们的和数:

AddTwo PROC,
    val1:DWORD,
    val2:DWORD
    mov eax,val1
    add eax,val2
    ret
AddTwo ENDP

AddTwo 汇编时,MASM 生成的汇编代码显示了参数名是如何被转换为 EBP 偏移量的。由于使用的是 STDCALL,因此 RET 指令附加了一个常量操作数:

AddTwo PROC
    push ebp
    mov ebp, esp
    mov eax, dword ptr [ebp+8]
    add eax, dword ptr [ebp+OCh]
    leave
    ret 8
AddTwo ENDP

用指令 ENTERO, 0 来代替下面的语句,AddTwo 过程也一样正确:

push ebp
mov ebp,esp

【示例 2】FillArray 过程接收一个字节数组的指针:

FillArray PROC,
   pArray:PTR BYTE
   ...
FillArray ENDP

【示例 3】Swap 过程接收两个双字指针:

Swap PROC,
    pValX:PTR DWORD,
    pValY:PTR DWORD
Swap ENDP

【示例 4】Read_File 过程接收一个字节指针 pBuffer,有一个局部双字变量 fileHandle,并把两个寄存器保存入栈(EAX 和 EBX):

Read_File PROC USES eax ebx,
   pBuffer:PTR BYTE
   LOCAL fileHandle:DWORD

   mov esi,pBuffer
   mov fileHandle,eax
   ...
   ret
Read_File ENDP

MASM 为 Read_File 生成的代码显示了在 EAX 和 EBX 入栈(由 USES 子句指定)前,如何为局部变量(fileHandle)预留堆栈空间:

Read_File PROC
    push ebp
    mov ebp,esp
    add esp, OFFFFFFFCh          ;创建 fileHandle
    push eax                     ;保存 EAX
    push ebx                     ;保存 EBX
    mov esi, dword ptr [ebp+8]   ; pBuffer
    mov dword ptr [ebp-4],eax    ; fileHandle
    pop ebx
    pop eax
    leave
    ret 4
Read_File ENDP

注意:尽管 Microsoft 没有采用这种方法,但 Read_File 生成代码的开始部分还可以是这样的:

Read_File PROC
   enter 4,0
   push eax
   (etc.)

ENTER 指令首先保存 EBP,再将它设置为堆栈指针的值,并为局部变量保留空间。

由 PROC 修改的 RET 指令

当 PROC 有一个或多个参数时,STDCALL 是默认调用规范。假设 PROC 有 n 个参数,MASM 将生成如下入口和出口代码:

push ebp
mov ebp,esp
...
leave
ret (n*4)

RET 指令中的常数是参数个数乘以 4 ( 因为每个参数都是一个双字 )。若使用了 INCLUDE Irvine32.inc,则 STDCALL 是默认规范,它是所有 Windows API 函数调用使用的调用规范。

指定参数传递协议

一个程序可以调用 Irvme32 链接库过程,反之,也可以包含能被 C++ 程序调用的过程。为了提供这样的灵活性,PROC 伪指令的属性域允许程序指定传递参数的语言规范,并且能覆盖 .MODEL 伪指令指定的默认语言规范。

下例声明的过程采用了 C 调用规范:

Examplel PROC C,
   parm1:DWORD, parm2:DWORD

若用 INVOKE 执行 Examplel,汇编器将生成符合 C 调用规范的代码。同样,如果用 STDCALL 声明 Examplel,INVOKE 的生成代码也会符合这个语言规范:

Examplel PROC STDCALL,
   parm1:DWORD, parm2:DWORD

汇编语言PROTO伪指令:指定程序的外部过程

64 模式中,PROTO 伪指令指定程序的外部过程,示例如下:

ExitProcess PROTO
.code
mov ecx, 0
call ExitProcess

然而在 32 位模式中,PROTO 是一个更有用的工具,因为它可以包含过程参数列表。可以说,PROTO 伪指令为现有过程创建了原型 (prototype)。原型声明了过程的名称和参数列表。它还允许在定义过程之前对其进行调用,并验证参数的数量和类型是否与过程的定义相匹配。

MASM 要求 INVOKE 调用的每个过程都有原型。PROTO 必须在 INVOKE 之前首先岀现。换句话说,这些伪指令的标准顺序为:

MySub PROTO       ;过程原型
.
INVOKE MySub      ;过程调用
.
MySub PROC     ;过程实现
..
MySub ENDP

还有一种情况也是可能的:过程实现可以出现在程序的前面,先于调用该过程的 INVOKE 语句。在这种情况下,PROC 就是它自己的原型:

MySub PROC          ;过程定义
..
MySub ENDP
.
INVOKE MySub      ;过程调用

假设已经编写了一个特定的过程,创建其原型也是很容易的,即复制 PROC 语句并做如下修改:

  • 将关键字 PROC 改为 PROTO。

  • 如有 USES 运算符,则把该运算符连同其寄存器列表一起删除。

比如,假设已经创建了 ArraySum 过程:

ArraySum PROC USES esi ecx,
    ptrArray:PTR DWORD,        ;指向数组
    szArray:DWORD              ;数组大小
;省略其余代码行……
ArraySum ENDP

下面是与之对应的 PROTO 声明:

ArraySum PROTO,
   ptrArray:PTR DWORD,     ;指向数组
   szArray:DWORD          ;数组大小

PROTO 伪指令可以覆盖 .MODEL 伪指令中的参数传递协议。但它必须与过程的 PROC 声明一致:

Example1 PROTO C,
   parm1:DWORD, parm2:DWORD

汇编时参数检查

PROTO 伪指令帮助汇编器比较过程调用和过程定义的参数列表。但是这个错误检查没有如 C 和 C++ 语言中那样重要。相反,MASM 检查参数正确的数量,并在某些情况下,匹配实际参数和形式参数的类型。比如,假设 Sub1 的原型声明如下:

Sub1 PROTO, p1:BYTE, p2:WORD, p3:PTR BYTE

现在定义变量:

.data
byte_1 BYTE 10h
.word_1 WORD 2000h
word_2 WORD 3000h
dword_1 DWORD 12345678h

那么,下面是 Sub1 的一个有效调用:

INVOKE Sub1, byte_1, word_1, ADDR byte_1

MASM 为这个 INVOKE 生成的代码显示了参数按逆序压入堆栈:

push 404000h                  ;指向 byte_1 的指针
sub   esp, 2                    ;在栈项填充两个字节
push word ptr ds:[00404001h]     ;word_1 的值
mov   al, byte ptr ds:[00404000h]  ;byte_1 的值
push eax
call 00401071

EAX 被覆盖,sub esp,2 指令填充接下来的两个堆栈单元,以扩展到 32 位。

MASM 会检测的错误

如果实际参数超过了形式参数声明的大小,MASM 就会产生一个错误:

INVOKE Sub1, word_1, word_2, ADDR byte_1     ;参数 1 错误

如果调用 Sub1 时参数个数太少或太多,则 MASM 会产生错误:

INVOKE Sub1, byte_1, word_2               ;错误:参数个数太少
INVOKE Sub1, byte_1,                      ;错误:参数个数太多
   word_2, ADDR byte_1, word_2

MASM 不会检测的错误

如果实际参数的类型小于形式参数的声明,那么 MASM 不会检测出错误:

INVOKE Sub1, byte_1, byte_1, ADDR byte_1

相反,MASM 会把实际参数扩展为形式参数声明的类型大小。下面是 INVOKE 示例生成的代码,其中第二个实际参数 (byte_1) 入栈之前,在 EAX 中进行了扩展:

push 404000h                  ;byte_1 的地址
mov al,byte ptr ds:[00404000h]        ;byte_1
movzx eax,al                   ;在 EAX 中扩展
push eax                      ;入栈
mov al,byte ptr ds:[00404000h]    ;byte_1 的值
push eax                      ;入栈
call 00401071                  ;调用 Sub1

如果在想要传递指针时传递了一个双字,则不会检测出任何错误。当子程序试图把这个堆栈参数用作指针时,这种情况通常会导致一个运行时错误:

INVOKE Sub1, byte_1, word_2, dword_1 ;无错误检出

ArraySum 示例

过程用寄存器传递参数,现在,可以用 PROC 伪指令来声明堆栈参数:

ArraySum PROC USES esi ecx,
    ptrArray:PTR DWORD,                  ;指向数组
    szArray:DWORD                        ;数组大小
    mov esi, ptrArray                    ;数组地址
    mov ecx, szArray                     ;数组大小
    mov eax, 0                           ;和数清零
    cmp ecx, 0                           ;数组长度=0?
    je L2                                ;是:退出
L1: add eax, [esi]                       ;将每个整数加到和数中
    add esi, 4                           ;指向下一个整数
    loop L1                              ;按数组大小重复
L2: ret                                  ;和数保存在EAX中
ArraySum ENDP

INVOKE 语句调用 ArraySum,传递数组地址和元素个数:

.data
array DWORD 10000h, 20000h, 30000h, 40000h, 50000h
theSum DWORD ?
.code
main PROC
    INVOKE ArraySum,
        AD DR array,                     ;数组地址
        LENGTHOF array                   ;元素个数
    mov theSum, eax                      ;保存和数

汇编语言过程参数简介

过程参数一般按照数据在主调程序和被调用过程之间传递的方向来分类:

1) 输入类

输入参数是指从主调程序传递给过程的数据。被调用过程不会被要求修改相应的参数变量,即使修改了,其范围也只能局限在自身过程中。

2) 输出类

当主调程序向过程传递变量地址,就会产生输岀参数。过程用地址来定位变量,并为其分配数据。比如,Win32 控制台库中的 ReadConsole 函数,其功能为从键盘读入一个字符串。用户键入的字符由 ReadConsole 保存到缓冲区中,而主调程序传递的就是这个字符串缓冲区的指针:

.data

buffer BYTE 80 DUP(?)

inputHandle DWORD ?

.code

INVOKE ReadConsole, inputHandle, ADDR buffer,

   (etc.)

3) 输入输出类

输入输出参数与输出参数相同,只有一个例外:被调用过程预期参数引用的变量中会包含一些数据,并且能通过指针来修改这些变量。

【示例】下面的例子实现两个 32 位整数的交换。Swap 过程有两个输入输出参数 pValX 和 pValY,它们是交换数据的地址:

; Swap 过程示例   (Swap.asm)
INCLUDE Irvine32.inc
Swap PROTO, pValX:PTR DWORD, pValY:PTR DWORD
.data
Array DWORD 10000h,20000h
.code
main PROC
    ; 显示交换前的数组
    mov  esi,OFFSET Array
    mov  ecx,2                     ; 计数值 = 2
    mov  ebx,TYPE Array
    call DumpMem                   ; 显示数组
    INVOKE Swap,ADDR Array, ADDR [Array+4]
    ; 显示交换后的数组
    call DumpMem
    exit
main ENDP
;-------------------------------------------------------
Swap PROC USES eax esi edi,
    pValX:PTR DWORD,              ; 第一个整数的指针
    pValY:PTR DWORD               ; 第二个整数的指针
; 交换两个32位整数的值
; 返回: 无
;-------------------------------------------------------
    mov esi,pValX                 ; 获得指针
    mov edi,pValY
    mov eax,[esi]                 ; 取第一个整数
    xchg eax,[edi]                ; 与第二个数交换
    mov [esi],eax                 ; PROC 在这里生成 RET 8
    ret
Swap ENDP
END main

Swap 程的两个参数 pValX 和 pValY 都是输入输出参数。它们的当前值要输入到过程,而它们的新值也会从过程输出。由于使用的 PROC 带有参数,汇编器把 Swap 过程末尾的 RET 指令改为 RET 8(假设调用规范是 STDCALL)。

调试提示

这里提醒编程者要注意的一些常见错误是汇编语言在传递过程参数时会遇到的,希望编程者永远不要犯这些错误。

1) 参数大小不匹配

数组地址以其元素的大小为基础。比如,一个双字数组第二个元素的地址就是其起始地址加 4。假设调用 Swap 过程,并传递 DoubleArray 前两个元素的指针。如果错误地把第二个元素的地址计算为 DoubleArray+1,那么调用 Swap 后,DoubleArray 中的十六进制结果值也不正确:

.data
DoubleArray DWORD 10000h,20000h
.code
INVOKE Swap, ADDR [DoubleArray+0], ADDR [DoubleArray+1]
2) 传递错误类型的指针

在使用 INVOKE 时,要记住汇编器不会验证传递给过程的指针类型。例如,Swap 过程期望接收到两个双字指针,假若不小心传递的是指向字节的指针:

.data
ByteArray BYTE 10h,20h,30h,40h,50h,60h,70h,80h
.code
INVOKE Swap, ADDR [ByteArray+0], ADDR [ByteArray+1]

程序可以汇编运行,但是当 ESI 和 EDI 解引用时,就会交换两个 32 位数值。

3) 传递立即数

如果过程有一个引用参数,就不要向其传递立即数参数。考虑下面的过程,它只有一个引用参数:

Sub2 PROC, dataPtr:PTR WORD
    mov esi, dataPtr         ;获得地址
    mov WORD PTR [esi], 0    ;解引用,分配零
    ret
Sub2 ENDP

汇编下面的 INVOKE 语句将导致一个运行时错误。Sub2 过程接收 1000h 作为指针的值,并解引用到内存地址 1000h:

INVOKE Sub2, 1000h

上例很可能会导致一般性保护故障,因为内存地址1000h不大可能在该程序的数据段中。

汇编语言WriteStackFrame过程:显示当前过程堆栈帧的内容

Irvine32 链接库有个很有用的过程 WriteStackFrame,用于显示当前过程堆栈帧的内容,其中包括过程的堆栈参数、返回地址、局部变量和被保存的寄存器。

该过程由太平洋路德大学 (Pacific Lutheran University) 的詹姆斯·布林克 (James Brink) 教授慷慨提供,原型如下:

WriteStackFrame PROTO,
   numParam:DWORD,        ;传递参数的数量
   numLocalVal: DWORD,      ;双字局部变量的数量
   numSavedReg: DWORD        ;被保存寄存器的数量

下面的代码节选自WriteStackFrame的演示程序:

main PROC
    mov eax, 0EAEAEAEAh
    mov ebx, 0EBEBEBEBh
    INVOKE myProc, 1111h, 2222h ;传递两个整数参数
    exit
main ENDP
myProc PROC USES eax ebx,
    x: DWORD, y: DWORD
    LOCAL a:DWORD, b:DWORD
    PARAMS = 2
    LOCALS = 2
    SAVED_REGS = 2
    mov a, 0AAAAh
    mov b, 0BBBBh
    INVOKE WriteStackFrame, PARAMS, LOCALS, SAVED_REGS

该调用生成的输岀如下所示:

还有一个过程名为 WriteStackFrameName,增加了一个参数,保存拥有该堆栈帧的过程名:

WriteStackFrameName PROTO,
   numParam:DWORD,                ;传递参数的数量
   numLocalVal: DWORD,       ;双字局部变量的数量
   numSavedReg: DWORD,         ;被保存寄存器的数量
   procName:PTR BYTE        ;空字节结束的字符串

Irvine32 链接库的源代码保存在安装目录的 \Examples\Lib32 子目录下,文件名为 Irvine32.asm。Irvine32 链接库安装文件下载(https://pan.baidu.com/s/1yQBqLbViAgs8mCImheCe6g 提取码:6kvk)获取。

汇编语言多模块程序简述

大型源文件难于管理且汇编速度慢,可以把单个文件拆分为多个子文件,但是,对其中任何子文件的修改仍需对所有的文件进行整体汇编。更好的方法是把一个程序按模块(module)(汇编单位)分割。每个模块可以单独汇编,因此,对一个模块源代码的修改就只需要重汇编这个模块。

链接器将所有汇编好的模块(OEJ 文件)组合为一个可执行文件的速度是相当快的,链接大量目标模块比汇编同样数量的源代码文件花费的时间要少得多。

新建多模块程序有两种常用方法:

  • 其一是传统方法,使用 EXTERN 伪指令,基本上它在不同的 x86 汇编器之间都可以进行移植。

  • 其二是使用 Microsoft 的高级伪指令 INVOKE 和 PROTO,这能够简化过程调用,并隐藏一些底层细节。

隐藏和导出过程名

默认情况下,MASM 使所有的过程都是 public 属性,即允许它们能被同一程序中任何其他模块调用。使用限定词 PRIVATE 可以覆盖这个属性:

mySub PROC PRIVATE

使过程为 private 属性,可以利用封装原则将过程隐藏在模块中,如果其他模块有相同过程名,就还需避免潜在的重名冲突。

OPTION PROC:PRIVAT E 伪指令

在源模块中隐藏过程的另一个方法是,把 OPTION PROC:PRIVATE 伪指令放在文件顶部。则所有的过程都默认为 private,然后用 PUBLIC 伪指令指明那些希望其可见的过程:

OPTION PROC:PRIVATE
PUBLIC mySub

PUBLIC 伪指令用逗号分隔过程名:

PUBLIC sub1, sub2, sub3

或者,也可以单独指定过程为 public 属性:

mySub PROC PUBLIC
.
mySub ENDP

如果程序的启动模块使用了 OPTION PROC:PRIVATE,那么就应该将它(通常为 main)指定为 PUBLIC,否则操作系统加载器无法发现该启动模块。比如:

main PROC PUBLIC

汇编语言EXTERN伪指令:调用外部过程

调用当前模块之外的过程时使用EXTERN伪指令,它确定过程名和堆栈帧大小。下面的示例程序调用了 sub1,它在一个外部模块中:

INCLUDE Irvine32.inc
EXTERN sub1@0:PROC
.code
main PROC
    call subl@0
    exit
main ENDP
END main

当汇编器在源文件中发现一个缺失的过程时(由 CALL 指令指定),默认情况下它会产生错误消息。但是,EXTERN 伪指令告诉汇编器为该过程新建一个空地址。在链接器生成程序的可执行文件时再来确定这个空地址。

过程名的后缀 @n 确定了已声明参数占用的堆栈空间总量。如果使用的是基本 PROC 伪指令,没有声明参数,那么 EXTERN 中的每个过程名后缀都为 @0。若用扩展 PROC 伪指令声明一个过程,则每个参数占用 4 字节。假设现在声明的 AddTwo 带有两个双字参数:

AddTwo PROC,
   val1:DWORD,
   val2:DWORD
   ...
AddTwo ENDP
则相应的 EXTERN 伪指令为 EXTERN AddTwo@8 : PROC。或者,也可以用 PROTO 伪指令来代替 EXTERN:
AddTwo PROTO,
   val1:DWORD,
   val2:DWORD

汇编语言跨模块使用变量和标号

默认情况下,变量和符号对其包含模块是私有的(private)。可以使用 PUBLIC 伪指令输出指定过程名,如下所示:

PUBLIC count, SYM1
SYM1 = 10
.data
count DWORD 0

访问外部变量和符号

使用 EXTERN 伪指令可以访问在外部过程中定义的变量和符号:

EXTERN name:type

对符号(由 EQU 和 = 定义)而言,type 应为 ABS。对变量而言,type 是数据定义属性,如 BYTE、WORD、DWORD 和 SDWORD,可以包含 PTR。例子如下:

EXTERN one:WORD, two:SDWORD, three:PTR BYTE, four:ABS

使用带 EXTERNDEF 的 INCLUDE 文件

MASM 中一个很有用的伪指令 EXTERNDEF 可以代替 PUBLIC 和 EXTERN。它可以放在文本文件中,并用 INCLUDE 伪指令复制到每个程序模块。比如,现在用如下声明定义文件 vars.inc:

;var s.inc
EXTERNDEF count:DWORD, SYM1:ABS

接着,新建名为 sub1.asm 的源文件,其中包含了 count 和 SYM1,以及一条用于把 vars.inc 复制到编译流中的 INCLUDE 语句。

;sub1.asm
.386
.model flat,STDCALL
INCLUDE vars.inc
SYM1 = 10
.data
count DWORD 0
END

因为不是程序启动模块,因此 END 伪指令省略了程序入口标号,并且不用声明运行时堆栈。

现在再新建一个启动模块 main.asm,其中包含 vars.inc,并使用了 count 和 SYM1:

; main.asm
.386
.model flat,stdcall
.stack 4096
ExitProcess proto, dwExitCode:dword
INCLUDE vars.inc
.code
main PROC
    mov count,2000h
    mov eax,SYM1
    INVOKE ExitProcess,0
main ENDP
END main

汇编语言用Extern伪指令新建模块

PromptForIntegers

jprompt.asm 是 PromptForIntegers 过程的源代码文件。它显示提示要求用户输入三个整数,调用 Readlnt 获取数值,并将它们插入数组:

;提示整数输入请求    (_prompt.asm)
INCLUDE Irvine32.inc
.code
;--------------------------------
PromptForIntegers PROC
;提示用户为数组输入整数,并用
;用户输入填充该数组。
;接收:
;    ptrPrompt:PTR BYTE    ;提示信息字符串
;    ptrArray:PTR DWORD    ;数组指针
;    arraySize:DWORD       ;数组大小
;返回:无
;--------------------------------
arraySize EQU [ebp+16]
ptrArray EQU [ebp+12]
ptrPrompt EQU [ebp+8]
    enter 0,0
    pushad                     ;保存全部寄存器
    mov ecx,arraySize
    cmp    ecx,0               ;数据大小WO?
    jle    L2                  ;是:退出
    mov edx,ptrPrompt          ;提示信息的地址
    mov esi,ptrArray
L1: call WriteString           ;显示字符串
    call ReadInt               ;将整数读入EAX
    call Crlf                  ;换行
    mov [esi],eax              ;保存入数组
    add esi,4                  ;下一个整数
    loop L1
L2: popad                      ;恢复全部寄存器
    leave 
    ret 12                     ;恢复堆栈
PromptForIntegers ENDP
END

ArraySum

_arraysum.asm 模块为 ArraySum 过程,计算数组元素之和,并用 EAX 返回计算结果:

;ArraySumit程    (_arrysum.asm)
INCLUDE Irvine32.inc
.code
;----------------------------------------------   
ArraySum PROC
;计算32位整数数组之和。
;接收:
;    ptrArray    ;数组指针
;    arraySize    ;数组大小(DWROD)
;返回:EAX = 和数
;----------------------------------------------   
ptrArray EQU [ebp+8]
arraySize EQU [ebp+12]
    enter 0,0
    push ecx                      ;EAX 不入栈
    push esi

    mov    eax, 0                 ;和数清零
    mov    esi, ptrArray
    mov    ecx,arraySize
    cmp    ecx, 0                 ;数组大小WO?
    jle    L2                     ;是:退出
L1: add    eax, [esi]             ;将每个整数加到和数中
    add    esi,4                  ;指向下一个整数
    loop L1                       ;按数组大小重复
L2: pop esi
    pop ecx                       ;用EAX返回和数
    leave
    ret    8                      ;恢复堆栈
ArraySum ENDP
END

DisplaySum

_display.asm 模块为 DisplaySum 过程,显示标号和和数的结果:

;DisplaySum 过程    (_display.asm)
INCLUDE Irvine32.inc
.code
;-----------------------------------------
DisplaySum PROC
;在控制台显示和数。
;接收:
;    ptrPrompt      ;提示字符串的偏移量
;    theSum         ;数组和数(DWROD)
;返回:无
;-----------------------------------------
theSum EQU [ebp+12]
ptrPrompt EQU [ebp+8]
    enter 0,0
    push eax
    push edx
    mov edx,ptrPrompt                           ;提示字符串的指针
    call WriteString
    mov eax,theSum
    call Writelnt                               ;显示 EAX
    call Crlf
    pop edx
    pop eax
    leave
    ret 8                                        ;恢复堆栈
DisplaySum ENDP
END

Startup 模块

Sum_main.asm 模块为启动过程 (main)。其中的 EXTERN 伪指令指定了三个外部过程。为了使源代码更加友好,用 EQU 伪指令再次定义了过程名:

ArraySum          EQU ArraySum@0
PromptForIntegers EQU PromptForIntegers@0
DisplaySum        EQU DisplaySum@0

每次过程调用之前,用注释说明了参数顺序。该过程使用 STDCALL 参数传递规范:

;整数求和过程(Sum_main.asm)
;多模块示例
;本程序由用户输入多个整数,
;将它们存入数组,计算数组之和,
;并显示和数。
INCLUDE Irvine32.inc
EXTERN PromptForIntegers@0:PROC
EXTERN ArraySum@0:PROC, DisplaySum@0:PROC
;为方便起见,重新定义外部符号
ArraySum          EQU ArraySum@0
PromptForIntegers EQU PromptForIntegers@0
DisplaySum        EQU DisplaySum@0
;修改 Count 来改变数组大小:
Count = 3
.data
prompt1 BYTE "Enter a signed integer: ",0
prompt2 BYTE "The sum of the integers is: ",0
array DWORD Count DUP(?)
sum DWORD ?
.code
main PROC
    call Clrscr
;PromptForIntegers (addr prompt1, addr array, Count)
    push Count
    push OFFSET array
    push OFFSET prompt1
    call PromptForIntegers
;sum = ArraySum(addr array, Count)
    push Count
    push OFFSET array
    call ArraySum
    mov sum,eax
;DisplaySum(addr prompt2, sum)
    push sum
    push OFFSET prompt2
    call DisplaySum
    call Crlf
    exit
main ENDP
END main

汇编语言用INVOKE和PROTO新建模块

32 位模式中,可以用 Microsoft 的 INVOKE、PROTO 和扩展 PROC 伪指令新建多模块程序。与更加传统的 CALL 和 EXTERN 相比,它们的主要优势在于:能够将 INVOKE 传递的参数列表与 PROC 声明的相应列表进行匹配。

现在用 INVOKE、PROTO 和高级 PROC 伪指令重新编写 ArraySum。为每个外部过程创建含有 PROTO 伪指令的头文件是很好的开始。每个模块都会包含这个文件 ( 用 INCLUDE 伪指令) 且不会增加任何代码量或运行时开销。

如果一个模块不调用特定过程,汇编器就会忽略相应的 PROTO 伪指令。

sum.inc 头文件本程序的 sum.inc 头文件如下所示:

; (sum.inc)
INCLUDE Irvine32.inc
PromptForIntegers PROTO,
    ptrPrompt:PTR BYTE,        ; 提示字符串
    ptrArray:PTR DWORD,        ; 数组指针
    arraySize:DWORD            ; 数组大小
ArraySum PROTO,
    ptrArray:PTR DWORD,        ; 数组指针
    arraySize:DWORD            ; 数组大小
DisplaySum PROTO,
    ptrPrompt:PTR BYTE,        ; 提示字符串
    theSum:DWORD               ; 数组之和

_prompt 模块

_prompt.asm 文件用 PROC 伪指令为 PromptForIntegers 过程声明参数,用 INCLUDE 将 sum.inc 复制到本文件:

; 提示整数输入请求          (_prompt.asm)
INCLUDE sum.inc        ; 获得过程原型
.code
;-----------------------------------------------------
PromptForIntegers PROC,
  ptrPrompt:PTR BYTE,        ; 提示字符串
  ptrArray:PTR DWORD,        ; 数组指针
  arraySize:DWORD            ; 数组大小
;
; 提示用户输入数组元素值,并用用户输入
; 填充数组
; 返回:无
;-----------------------------------------------------
    pushad                 ; 保存所有寄存器

    mov  ecx,arraySize
    cmp  ecx,0             ; 数组大小 <= 0?
    jle  L2                ; 是: 退出
    mov  edx,ptrPrompt     ; 提示信息的地址
    mov  esi,ptrArray
L1:    call WriteString    ; 显示字符串
    call ReadInt           ; 把整数读入EAX
    call Crlf              ; 换行
    mov  [esi],eax         ; 保存入数组
    add  esi,4             ; 下一个整数
    loop L1
L2:    popad               ; 恢复所有寄存器
    ret
PromptForIntegers ENDP
END
与前面的 PromptForIntegers 版本比较,语句 enter 0,0 和 leave 不见了,这是因为当 MASM 遇到 PROC 伪指令及其声明的参数时,会自动生成这两条语句。同样,RET 指令也不需要自带常数参数了,PROC 会处理好。

_arraysum 模块

接下来,_arraysum.asm 文件包含了 ArraySum 过程:

; ArraySum 过程                 (_arrysum.asm)
INCLUDE sum.inc
.code
;-----------------------------------------------------
ArraySum PROC,
    ptrArray:PTR DWORD,    ; 数组指针
    arraySize:DWORD        ; 数组大小
;
; 计算 32 位整数数组之和
; 返回:  EAX = 和数
;-----------------------------------------------------
    push ecx              ; EAX 不入栈
    push esi
    mov  eax,0            ; 和数清零
    mov  esi,ptrArray
    mov  ecx,arraySize
    cmp  ecx,0            ; 数组大小 <= 0?
    jle  L2               ; 是: 退出
L1:    add  eax,[esi]     ; 将每个整数加到和数中
    add  esi,4            ; 指向下一个整数
    loop L1               ; 按数组大小重复
L2:    pop esi
    pop ecx               ; 用 EAX 返回和数
    ret
ArraySum ENDP
END

_display 模块

_display.asm 文件包含了 DisplaySum 过程:

; DisplaySum 过程        (_display.asm)
INCLUDE Sum.inc
.code
;-----------------------------------------------------
DisplaySum PROC,
    ptrPrompt:PTR BYTE,    ; 提示字符串
    theSum:DWORD           ; 数组之和
;
; 控制台显示和数
; 返回:无
;-----------------------------------------------------
    push    eax
    push    edx
    mov    edx,ptrPrompt        ; 提示信息的指针
    call    WriteString
    mov    eax,theSum
    call    WriteInt            ; 显示 EAX
    call    Crlf
    pop    edx
    pop    eax
    ret
DisplaySum ENDP
END

Sum_main 模块

Sum_main.asm ( 启动模块 ) 包含主程序并调用所有其他的过程。它使用 INCLUDE 从 sum.inc 复制过程原型:

; 整数求和程序         (Sum_main.asm)
INCLUDE sum.inc
Count = 3
.data
prompt1 BYTE  "Enter a signed integer: ",0
prompt2 BYTE  "The sum of the integers is: ",0
array   DWORD  Count DUP(?)
sum     DWORD  ?
.code
main PROC
    call Clrscr
    INVOKE PromptForIntegers, ADDR prompt1, ADDR array, Count
    INVOKE ArraySum, ADDR array, Count
    mov    sum,eax
    INVOKE DisplaySum, ADDR prompt2, sum
    call Crlf
    exit
main ENDP
END main

小结 本节与上一节《用Extern伪指令新建模块》展示了在 32 位模式中新建多模块程序的两种方法:

  • 第一种使用 的是更传统的EXTERN伪指令;

  • 第二种使用的是INVOKE. PROTO和PROC的高级功能。

后一种中的伪指令简化了很多细节,并为 Windows API 函数调用进行了优化。此外,它们还隐藏了一些细节,因此,编程者可能更愿意使用显式的堆栈参数和 CALL 及 EXTERN 伪指令。

汇编语言使用USES运算符注意事项

在《USES运算符》一节中列出了在过程开始保存、结尾恢复的寄存器名。汇编器自动为每个列出的寄存器生成相应的 PUSH 和 POP 指令。

但是必须注意的是:如果过程用常数偏移量访问其堆栈参数,比如 [ebp+8],那么声明该过程时不能使用 USES 运算符。现在举例说明其原因。下面的 MySub1 过程用 USES 运算符保存和恢复 ECX 和 EDX:

MySub1 PROC USES ecx edx
   ret
MySub1 ENDP

当 MASM 汇编 MySub1 时,生成代码如下:

push ecx
push edx
pop edx
pop ecx
ret

假设在使用 USES 的同时还使用了堆栈参数,如 MySub2 过程所示,该参数预期保存的堆栈地址为 EBP+8:

MySub2 PROC USES ecx edx
    push ebp                 ;保存基址指针
    mov ebp, esp             ;堆栈帧基址
    mov eax, [ebp+8]         ;取堆栈参数
    pop ebp                  ;恢复基址指针
    ret 4                    ;清除堆栈
MySub2 ENDP

则 MASM 为 MySub2 生成的相应代码如下:

push ecx
push edx
push ebp
mov ebp,esp
mov eax, dword ptr [ebp+8]   ;错误地址!
pop ebp
pop edx
pop ecx
ret 4

由于汇编器在过程开头插入了 ECX 和 EDX 的 PUSH 指令,使得堆栈参数的偏移量发生变化,从而导致结果错误。

下图说明了为什么堆栈参数现在必须以[EBP+16]来引用。USES 在保存 EBP 之前修改了堆栈,破坏了子程序常用的标准开始代码。

提示:前面介绍了 PROC 伪指令声明堆栈参数的高级语法。在那种情况下,USES 运算符不会带来问题。

汇编语言向堆栈传递8位和16位参数

32 位模式中,向过程传递堆栈参数时,最好是压入 32 位操作数。虽然也可以将 16 位操作数入栈,但是这样会使得 EBP 不能对齐双字边界,从而可能导致出现页面失效、降低运行时性能。因此,在入栈之前,要把操作数扩展为 32 位。下面的 Uppercase 过程接收一个字符参数,并用 AL 返回其大写字母:

Uppercase PROC
    push ebp
    mov ebp, esp
    mov    al, [esp+8 ]          ;AL=字符
    cmp    al, 'a'               ;小于'a' ?
    jb L1                        ;是:什么都不做
    cmp    al, 'z'               ;大于'z' ?
    ja L1                        ;是:什么都不做
    sub    al, 32                ;否:转换字符
L1:
    pop ebp
    ret 4                        ;清除堆栈
Uppercase ENDP

当向 Uppercase 传递一个字母字符时,PUSH 指令自动将其扩展为 32 位:

push 'x'
call Uppercase

如果传递的是字符变量就需要更小心一些,因为 PUSH 指令不允许操作数为 8 位:

.data
charVal BYTE 'x'
.code
push charVal                 ;语法错误!
call Uppercase

相反,要用 MOVZX 把字符扩展到 EAX:

movzx eax,charVal             ;扩展并传送
push eax
call Uppercase

16 位参数示例

假设现在想向之前给出的 AddTwo 过程传递两个 16 位整数。由于该过程期望的数值为 32 位,所以下面的调用会发生错误:

.data
word1 WORD 1234h
word2 WORD 4111h
.code
push word1
push word2
call AddTwo                    ;错误!

因此,可以在每个参数入栈之前进行全零扩展。下面的代码将会正确调用 AddTwo:

movzx eax,word1
push eax
movzx eax,word2
push eax
call AddTwo                    ; EAX 为和数

一个过程的主调者必须保证它传递的参数与过程期望的参数是一致的。对堆栈参数而言,参数的顺序和大小都很重要!

汇编语言32位模式下传递64位参数

32 位模式中,通过堆栈向子程序传递 64 位参数时,先将参数的高位双字入栈,再将其低位双字入栈。这样就使得整数在堆栈中是按照小端顺序(低字节在低地址)存放的,因而子程序容易检索到这些数值,如同下面的 WriteHex64 过程操作一样。该过程用十六进制显示 64 位整数:

WriteHex64 PROC
    push ebp
    mov ebp, esp
    mov eax, [ebp+12]     ;高位双字
    call WriteHex
    mov eax, [ebp+8]      ;低位双字
    call WriteHex
    pop ebp
    ret 8
WriteHex64 ENDP

WriteHex64 的调用示例如下,它先把 longVal 的高半部分入栈,再把 longVal 的低半部分入栈:

.data
longVal QWORD 1234567800ABCDEFh
.code
push DWORD PTR longVal + 4            ;高位双字
push DWORD PTR longVal                ;低位双字
call WriteHex64

下图显示的是在 EBP 入栈,并把 ESP 复制给 EBP 之后,WriteHex64 的堆栈帧示意图。

汇编语言非双字局部变量

在声明不同大小的局部变量时,LOCAL 伪指令的操作会变得很有趣。每个变量都按照其大小来分配空间:8 位的变量分配给下一个可用的字节,16 位的变量分配给下一个偶地址(字对齐),32 位变量分配给下一个双字对齐的地址。

现在来看几个例子。首先,Example 过程含有一个局部变量 var1,类型为 BYTE:

Example1 PROC
    LOCAL var1:byte
    mov al,var1       ;[EBP-1]
    ret
Example1 ENDP

由于 32 位模式中,堆栈偏移量默认为 32 位,因此,var1 可能被认为会存放于 EBP-4 的位置。实际上,如下图所示,MASM 将 EBP 减去 4,但是却把 var1 存放在 EBP-1,其下面的三个字节并未使用(用 nu 标记,表示没有使用)。图中,每个方块表示一个字节。

过程 Example2 含一个双字局部变量和一个字节局部变量:

Example2 PROC
   local temp:dword, SwapFlag:BYTE
   ...
   ret
Example2 ENDP

汇编器为 Example2 生成的代码如下所示。ADD 指令将 ESP 加 -8,在 ESP 和 EBP 之间为这两个局部变量预留了空间:

push ebp
mov ebp, esp
add esp,0FFFFFFF8h     ; ESP+(-8)
mov eax,[ebp-4]        ; temp
mov bl,[ebp-5]         ; SwapFlag
leave
ret

虽然 SwapFlag 只是一个字节变量,但是 ESP 还是会下移到堆栈中下一个双字的位置。下图以字节为单位详细展示了堆栈的情况:SwapFlag 确切的位置以及位于其下方的三个没有使用的空间(用 nu 标记)。图中,每个方块表示一个字节。

如果要创建超过几百字节的数组作为局部变量,那么一定要确保为运行时堆栈预留足够的空间。此时可以使用 STACK 伪指令。比如,在 Irvine32 链接库中,要预留 4096 个字节的堆栈空间:

.stack 4096

对嵌套调用来说,不论程序执行到哪一步,运行时堆栈都必须大到能够容纳下全部的活跃局部变量。比如在下面的代码中,Sub1 调用 Sub2,Sub2 调用 Sub3,每个过程都有一个局部数组变量:

Sub1 PROC
local array1 [50]:dword       ; 200 字节
callSub2
...
ret
Sub1 ENDP

Sub2 PROC
local array2 [80]:word        ; 160 字节
callSub3
...
ret
Sub2 ENDP

Sub3 PROC
local array3 [300]:dword      ; 1200 字节
...
ret
Sub3 ENDP

当程序进入 Sub3 时,运行时堆栈中有来自 Sub1、Sub2 和 Sub3 的全部局部变量。那么堆栈总共需要:1560 个字节保存局部变量,加上两个过程的返回地址(8 字节),还要加上在过程中入栈的所有寄存器占用的空间。若过程为递归调用,则堆栈空间大约为其局部变量与参数总的大小乘以预计的递归次数。

Java虚拟机(JVM)工作原理

JVM 是基于堆栈机器的首选示例。JVM 用堆栈实现数据传送、算术运算、比较和分支操作,而不是用寄存器来保存操作数(如同 x86 一样)。

Java 虚拟机

Java 虚拟机(JVM)是执行已编译 Java 字节码的软件。它是 Java 平台的重要组成部分,包括程序、规范、库和数据结构,让它们协同工作。Java 字节码是指编译好的 Java 程序中使用的机器语言的名字。

JVM 执行的编译程序包含了 Java 字节码。每个 Java 源程序都必须编译为 Java 字节码(形式为 .class 文件)后才能执行。包含 Java 字节码的程序可以在任何安装了 Java 运行时软件的计算机系统上执行。

例如,一个 Java 源文件名为 Account.java,编译为文件 Account.class。这个类文件是该类中每个方法的字节码流。JVM 可能选择实时编译(just-in-time compilation)技术把类字节码编译为计算机的本机机器语言。

正在执行的 Java 方法有自己的堆栈帧存放局部变量、操作数栈、输入参数、返回地址和返回值。操作数区实际位于堆栈顶端,因此,压入这个区域的数值可以作为算术和逻辑运算的操作数,以及传递给类方法的参数。

在局部变量被算术运算指令或比较指令使用之前,它们必须被压入堆栈帧的操作数区域。通常把这个区域称为操作数栈(operand stack)。

Java 字节码中,每条指令包含 1 字节的操作码、零个或多个操作数。操作码可以用 Java 反汇编工具显示名字,如 iload、istore、imul 和 goto。每个堆栈项为 4 字节(32 位)。

查看反汇编字节码

Java 开发工具包(JDK)中的工具 javap.exe 可以显示 java.class 文件的字节码,这个操作被称为文件的反汇编。命令行语法如下所示:

javap -c classname

比如,若类文件名为 Account.class,则相应的 javap 命令行为:

javap -c Account

安装 Java 开发工具包后,可以在 \bin 文件夹下找到 javap.exe 工具。

指令集

1) 基本数据类型

JVM 可以识别 7 种基本数据类型,如下表所示。和 x86 整数一样,所有有符号整数都是二进制补码形式。但它们是按照大端顺序存放的,即高位字节位于每个整数的起始地址(x86 的整数按小端顺序存放)。

数据类型 所占字节 格式 数据类型 所占字节 格式
char 2 Unicode 字符 long 8 有符号整数
byte 1 有符号整数 float 4 IEEE 单精度实数
short 2 有符号整数 double 8 IEEE 双精度实数
int 4 有符号整数

2) 比较指令

比较指令从操作数栈的顶端弹出两个操作数,对它们进行比较,再把比较结果压入堆栈。现在假设操作数入栈顺序如下所示:

下表给出了比较 op1 和 op2 之后压入堆栈的数值:

op1 和 op2 比较的结果 压入操作数栈的数值
op1 > op2 1
op1 = op2 0
op1 < op2 -1

dcmp 指令比较双字,fcmp 指令比较浮点数。

3) 分支指令

分支指令可以分为有条件分支和无条件分支。Java 字节码中无条件分支的例子是 goto 和 jsr。

goto 指令无条件分支到一个标号:

goto label

jsr 指令调用用标号定义的子程序。其语法如下:

jsr label

条件分支指令通常检测从操作数栈顶弹出的数值。根据该值,指令决定是否分支到给定标号。比如,ifle 指令就是当弹出数值小于等于 0 时跳转到标号。其语法如下:

ifle label

同样,ifgt 指令就是当弹出数值大于等于 0 时跳转到标号。其语法如下:

ifgt label

Java 反汇编示例

为了帮助理解 Java 字节码是如何工作的,本节将给出用 Java 编写的一些短代码例子。在这些例子中,请注意不同版本 Java 的字节码清单细节会存在些许差异。

【示例 1】两个整数相加

下面的 Java 源代码行实现两个整数相加,并将和数保存在第三个变量中:

int A = 3;
int B = 2;
int sum = 0;
sum = A + B;

该 Java 代码的反汇编如下:

iconst_3
istore_0
iconst_2
istore_l
iconst_0
istore_2
iload_0
iload_l
iadd
istore_2

每个编号行表示一条 Java 字节码指令的字节偏移量。本例中,可以发现每条指令都只占一个字节,因为指令偏移量的编号是连续的。

尽管字节码反汇编一般不包括注释,这里还是会将注释添加上去。虽然局部变量在运行时堆栈中有专门的保留区域,但是指令在执行算术运算和数据传送时还会使用另一个堆栈,即操作数栈。为了避免在这两个堆栈间产生混淆,将用索引值来指代变量位置,如 0、1、2 等。

现在来仔细分析刚才的字节码。开始的两条指令将一个常数值压入操作数栈,并把同一个值弹出到位置为 0 的局部变量:

iconst_3 //常数(3)压入操作数栈
istore_0 //弹出到局部变量0

接下来的四行将其他两个常数压入操作数栈,并把它们弹岀到位置分别为 1 和 2 的局部变量:

iconst_2 //常数(2)压入操作数栈
istore_1 //弹出到局部变量1
iconst_0 //常数(0)压入操作数栈
istore_2 //弹出到局部变量2

由于已经知道了该生成字节码的 Java 源代码,因此,很明显下表列出的是三个变量的位置索引:

位置索引 变量名
0 A
1 B
2 sum

接下来,为了实现加法,必须将两个操作数压入操作数栈。指令 iload_0 将变量 A 入栈,指令 iload_1 对变量 B 进行相同的操作:

iload_0 // (A 入栈)
iload_1 // (B 入栈)

现在操作数栈包含两个数:

这里并不关心这些例子的实际机器表示,因此上图中的运行时堆栈是向上生长的。每个堆栈示意图中的最大值即为栈顶。

指令 iadd 将栈顶的两个数相加,并把和数压入堆栈:

iadd

操作数栈现在包含的是 A、B 的和数:

指令 istore_2 将栈顶内容弹出到位置为 2 的变量,其变量名为 sum:

istore_2

操作数栈现在为空。

【示例 2】两个 Double 类型数据相加

下面的 Java 代码片段实现两个 double 类型的变量相加,并将和数保存到 sum。它执行的操作与两个整数相加示例相同,因此这里主要关注的是整数处理与 double 处理的差异:

double A = 3.1;
double B = 2;
double sum = A + B;

本例的反汇编字节码如下所示,用 javap 实用程序可以在右边插入注释:

ldc2_w #20; // double 3.Id
dstore_0
ldc2_w #22; // double 2.Od
dstore_2
dload_0
dload_2
dadd
dstore_4

下面对这个代码进行分步讨论。偏移量为 0 的指令 ldc2_w 把一个浮点常数(3.1)从常数池压入操作数栈。ldc2 指令总是用两个字节作为常数池区域的索引:

ldc2_w #20; // double 3.ld

偏移量为 3 的 dstore 指令从堆栈弹出一个 double 数,送入位置为 0 的局部变量。该指令起始偏移量(3)反映出第一条指令占用的字节数(操作码加上两字节索引):

dstore_0 //保存到 A

同样,接下来偏移量为 4 和 7 的两条指令对变量 B 进行初始化:

ldc2_w #22; // double 2.Od
dstore_2 // 保存到 B

指令 dload_0 和 dload_2 把局部变量入栈,其索引指的是 64 位位置(两个变量栈项),因为双字数值要占用 8 个字节:

dload_0
dload_2
接下来的指令(dadd)将栈顶的两个 double 值相加,并把和数入栈:
dadd

最后,指令 dstore_4 把栈顶内容弹出到位置为 4 的局部变量:

dstore_4

JVM 条件分支

了解 JVM 怎样处理条件分支是理解 Java 字节码的重要一环。比较操作总是从堆栈栈顶弹出两个数据,对它们进行比较后,再把结果数值入栈。条件分支指令常常跟在比较操作的后面,利用栈顶数值决定是否分支到目标标号。比如,下面的 Java 代码包含一个简单的 IF 语句,它将两个数值中的一个分配给一个布尔变量:

double A = 3.0;
boolean result = false;
if( A > 2.0 )
result = false;
else
result = true;

该 Java 代码对应的反汇编如下所示:

ldc2_w #26; // double 3.Od
dstore_0 // 弹出到 A
iconst_0 // false = 0
istore_2 //保存到 result
dload_0
ldc2_w #22; // double 2.0d
dcmpl
ifle 19     //如果 A ≤ 2.0,转到 19
iconst_0 // false
istore_2 // result = false
goto 21     //跳过后面两条语句
iconst_l // true
istore_2 // result = true

开始的两条指令将 3.0 从常数池复制到运行时堆栈,再把它从堆栈弹岀到变量 A:

ldc2_w #26; // double 3.0d
dstore_0 // 弹出至A

接下来的两条指令将布尔值 false (等于 0)从常量区复制到堆栈,再把它弹出到变量 result:

iconst_0 // false = 0
istore_2 // 保存到 result

A 的值(位置 0)压入操作数栈,数值 2.0 紧跟其后入栈:

dload_0    //A 入栈
ldc2_w #22; // double 2.0d

操作数栈现在有两个数值:

指令 dcmpl 将两个 double 数弹出堆栈进行比较。由于栈顶的数值(2.0)小于它下面的数值(3.0),因此整数 1 被压入堆栈。

dcmpl

如果从堆栈弹出的数值小于等于 0,则指令 ifle 就分支到给定的偏移量:

ifle 19  //如果 stack.pop() <= 0,转到 19

这里要回顾一下之前给出的 Java 源代码示例,若 A>2.0,其分配的值为 false:

if( A > 2.0 )
    result = false;
else
    result = true;

如果 A <= 2.0,Java 字节码就把 IF 语句转向偏移量为 19 的语句,为 result 分配数值 true。与此同时,如果不发生到偏移量 19 的分支,则由下面几条指令把 false 赋给 result:

iconst_0   // false
istore_2   // result = false
goto 21    //跳过后面两条指令

偏移量 16 的指令 goto 跳过后面两行代码,它们的作用是给 result 分配 true:

iconst_l // true
istore_2 // result = true

Java 虚拟机的指令集与 x86 处理器系列的指令集有很大的不同。它采用面向堆栈的方法实现计算、比较和分支,与 x86 指令经常使用寄存器和内存操作数形成了鲜明的对比。

虽然字节码的符号反汇编不如 x86 汇编语言简单,但是,编译器生成字节码也是相当容易的。每个操作都是原子的,这就意味着它只执行一个操作。

若 JVM 使用的是实时编译器,则 Java 字节码只要在执行前转换为本地机器语言即可。就这方面来说,Java 字节码与基于精简指令集(RISC)模型的机器语言有很多共同点。

汇编语言字符串和数组

汇编语言字符串基本指令简介

x86 指令集有五组指令用于处理字节、字和双字数组。虽然它们被称为字符串原语 (string primitives),但它们并不局限于字符数组。32 位模式中,下表中的每条指令都隐含使用 ESI、EDI,或是同时使用这两个寄存器来寻址内存。

指令 说明
MOVSB、MOVSW、MOVSD 传送字符串数据:将 ESI 寻址的内存数据复制到 EDI 寻址的内存位置
CMPSB、CMPSW、CMPSD 比较字符串:比较分别由 ESI 和 EDI 寻址的内存数据
SCASB、SCASW、SCASD 扫描字符串:比较累加器 (AL、AX 或 EAX) 与 EDI 寻址的内存数据
STOSB、STOSW、STOSD 保存字符串数据:将累加器内容保存到 EDI 寻址的内存位置
LODSB、LODSW、LODSD 从字符串加载到累加器:将 ESI 寻址的内存数据加载到累加器

根据指令数据大小,对累加器的引用隐含使用 AL、AX 或 EAX。字符串原语能高效执行,因为它们会自动重复并增加数组索引。

使用重复前缀

就其自身而言,字符串基本指令只能处理一个或一对内存数值。如果加上重复前缀,指令就可以用 ECX 作计数器重复执行。重复前缀使得单条指令能够处理整个数组。下面为可用的重复前缀:

REP ECX > 0 时重复
REPZ、REPE 零标志位置 1 且 ECX > 0 时重复
REPNZ、REPNE 零标志位清零且 ECX > 0 时重复

【示例】复制字符串:下面的例子中,MOVSB 从 string1 传送 10 个字节到 string2。重复前缀在执行 MOVSB 指令之前,首先测试 ECX 是否大于 0。若 ECX=0,MOVSB 指令被忽略,控制传递到程序的下一行代码;若 ECX>0,则 ECX 减 1 并重复执行 MOVSB 指令:

cld                       ;清除方向标志位
mov esi, OFFSET string1           ; ESI 指向源串
mov edi, OFFSET string2      ; EDI 执行目的串
mov ecx, 10                ;计数器赋值为10
rep movsb                 ;传送io个字节

重复 MOVSB 指令时,ESI 和 EDI 自动增加,这个操作由 CPU 的方向标志位控制。

方向标志位

根据方向标志位的状态,字符串基本青令增加或减少 ESI 和 EDI 如下表所示。可以用 CLD 和 STD 指令显式修改方向标志位:

CLD ;方向标志位清零(正向)
STD ;方向标志位置 1(反向)
方向标志位的值 对ESI和EDI的影响 地址顺序
0 增加 低到高
1 减少 高到低

在执行字符串基本指令之前,若忘记设置方向标志位会产生大麻烦,因为 ESI 和 EDI 寄存器可能无法按预期增加或减少。

汇编语言MOVSB、MOVSW和MOVSD指令:将数据到EDI指向的内存

MOVSB、MOVSW 和 MOVSD 指令将数据从 ESI 指向的内存位置复制到 EDI 指向的内存位置。(根据方向标志位的值)这两个寄存器自动地增加或减少:

MOVSB 传送(复制)字节
MOVSW 传送(复制)字
MOVSD 传送(复制)双字

MOVSB、MOVSW 和 MOVSD 可以使用重复前缀。方向标志位决定 ESI 和 EDI 是否增加或减少。增加 / 减少的量如下表所示:

指令 ESI 和 EDI 增加或减少的数值
MOVSB 1
MOVSW 2
MOVSD 4

【示例】复制双字数组,假设现在想从 source 复制 20 个双字整数到 target。数组复制完成后,ESI 和 EDI 将分别指向两个数组范围之外的一个位置(即超出 4 字节):

.data
source DWORD 20 DUP(OFFFFFFFFh)
target DWORD 20 DUP(?)
.code
cld                     ;方向为正向
mov ecx,LENGTHOF source ;设置 REP 计数器
mov esi,OFFSET source   ;ES工指向 source
mov edi,OFFSET target   ;ED工指向 target
rep novsd               ;复制双

汇编语言CMPSB、CMPSW和CMPSD指令:比较两个操作数

CMPSB、CMPSW 和 CMPSD 指令比较 ESI 指向的内存操作数与 EDI 指向的内存操作数:

CMPSB 比较字节
CMPSW 比较字
CMPSD 比较双字

CMPSB、CMPSW 和 CMPSD 可以使用重复前缀。方向标志位决定 ESI 和 EDI 的增加或减少。

【示例】比较双字,假设现在想用 CMPSD 比较两个双字。下例中,source 的值小于 target,因此 JA 指令不会跳转到标号 L1。

.data
source DWORD 1234h
target DWORD 5678h
.code
mov esi,OFFSET source
mov edi,OFFSET target
cmpsd                 ;比较双字
ja L1                 ;若 source > target 则跳转

比较多个双字时,清除方向标志位(正向),ECX 初始化为计数器,并给 CMPSD 添加重复前缀:

mov esi,OFFSET source
mov edi,OFFSET target
cld                       ;方向为正向
mov ecx,LENGTHOF source   ;设置重复计数器
repe cmpsd                ;相等则重复

REPE 前缀重复比较操作,并自动增加 ESI 和 EDI,直到 ECX 等于 0,或者发现了一对不相等的双字。

汇编语言SCASB、SCASW和SCASD指令:在字符串或数组中寻找一个值

SCASB、SCASW 和 SCASD 指令分别将 AL/AX/EAX 中的值与 EDI 寻址的一个字节 / 字 / 双字进行比较。这些指令可用于在字符串或数组中寻找一个数值。结合 REPE(或 REPZ)前缀,当 ECX > 0 且 AL/AX/EAX 的值等于内存中每个连续的值时,不断扫描字符串或数组。

REPNE 前缀也能实现扫描,直到 AL/AX/EAX 与某个内存数值相等或者 ECX = 0。

扫描是否有匹配字符下面的例子扫描字符串 alpha,在其中寻找字符 F。如果发现该字符,则 EDI 指向匹配字符后面的一个位置。如果未发现匹配字符,则 JNZ 执行退出:

.data
alpha BYTE "ABCDEFGH",0
.code
mov edi,OFFSET alpha        ;ED工指向字符串
mov al, 'F'                 ;检索字符F
mov ecx,LENGTHOF alpha      ;设置检索计数器
cld                         ;方向为正向
repne seasb                 ;不相等则重复
jnz quit                    ;若未发现字符则退出
dec edi                     ;发现字符:EDI 减 1

循环之后添加了 JNZ 以测试由于 ECX=0 且没有找到 AL 中的字符而结束循环的可能性。

汇编语言STOSB、STOSW和STOSD指令:把AL/AX/EAX的内容存储到EDI指向的内存单元中

STOSB、STOSW 和 STOSD 指令分别将 AL/AX/EAX 的内容存入由 EDI 中偏移量指向的内存位置。EDI 根据方向标志位的状态递增或递减。

与 REP 前缀组合使用时,这些指令实现用同一个值填充字符串或数组的全部元素。例如,下面的代码就把 string1 中的每一个字节都初始化为 OFFh:

.data
Count = 100
string1 BYTE Count DUP(?)
.code
mov al, OFFh              ;要保存的数值
mov edi,OFFSET string1       ;ED:[指向目标字符串
mov ecx,Count              ;字符计数器
cld                          ;方向为正向
rep stosb                  ;用 AL 的内容实现填充

汇编语言LODSB、LODSW和LODSD指令:加载一个字节或字

LODSB、LODSW 和 LODSD 指令分别从 ESI 指向的内存地址加载一个字节或一个字到 AL/AX/EAX。ESI 按照方向标志位的状态递增或递减。

LODS 很少与 REP 前缀一起使用,原因是,加载到累加器的新值会覆盖其原来的内容。相对而言,LODS常常被用于加载单个 数值。在后面的例子中,LODSB代替了如下两条指令(假设方向标志位清零):

mov al, [esi]   ;将字节送入AL
inc esi   ;指向下一个字节

【示例】数组乘法,下面的程序把一个双字数组中的每个元素都乘以同一个常数。程序同时 使用了 LODSD 和 STOSD:

;数组乘法    (Mult.asm)
;本程序将一个32位整数数组中的每个元素都乘以一个常数。
INCLUDE Irvine32.inc
.data
array DWORD 1,2,3,4,5,6,7,8,9,10    ;测试数据
mug" 0W0RD -10
.code
main PROC
    cld                             ;方向为正向
    mov esi,OFFSET array            ;源数组索引
    itqv edi,esi                    ;目标数组索引
    mov ecx,LENGTHOF array          ;循环计数器
L1: lodsd                           ;将 [ESI] 加载到 EAX
mul multiplier                      ;与常数相乘
stosd                               ;将 EAX 保存到[EDI]
loop L1
exit
main ENDP
END main

汇编语言Irvine32字符串过程详解

Irvine32 链接库中的几个过程来处理空字节结束的字符串。这些过程与标准 C 库中的函数有着明显的相似性:

;将源串复制到目的串。
Str_copy PROTO,
   source:PTR BYTE,
   target:PTR BYTE
;用 EAX 返回串长度(包括零字节)。
Str_length PROTO,
   pString:PTR BYTE
;比较字符串 1 和字符串 2。
;并用与 CMP 指令相同的方法设置零标志位和进位标志位。
Str_compare PROTO,
   string1:PTR BYTE,
   string2:PTR BYTE
;从字符串尾部去掉特定的字符。
;第二个参数为要去除的字符。
Str_trim PROTO,
   pString:PTR BYTE,
   char:BYTE
;将字符串转换为大写。
Str_ucase PROTO,
   pString:PTR BYTE

Str_compare 过程

Str_compare 过程比较两个字符串,其调用格式如下:

INVOKE Str_compare, ADDR string1, ADDR string2

它从第一个字节开始按正序比较字符串。这种比较是区分大小写的,因为字母的大写和小写 ASCII 码不相同。该过程没有返回值,若参数为 string1 和 string2,则进位标志位和零标志位的含义如下表所示。

关系 进位标志位 零标志位 为真则分支(指令)
string1 < string2 1 0 JB
string1 = string2 0 1 JE
string1 > string2 0 0 JA

回顾《CMP指令》一节中 CMP 指令如何设置进位标志位和零标志位。下面给出了 Str_compare 过程的代码清单。

;--------------------------------------
Str_compare PROC USES eax edx esi edi,
    string1:PTR BYTE,
    string2:PTR BYTE
;比较两个字符串。
;无返回值,但是零标志位和进位标志位受到的影响与 CMP 指令相同。
;--------------------------------------
    mov esi, string1
    mov edi, string2
L1: mov al, [esi]
    mov dl, [edi]
    cmp al, 0            ; string1 结束?
    jne L2               ; 否
    cmp dl, 0            ; 是:string2 结束?
    jne L2               ; 否
    jmp L3               ; 是,退出且ZF=1
L2: inc esi              ; 指向下一个字符
    inc edi              ; 字符相等?
    cmp al,dl            ; 是:继续循环
    je L1
L3: ret                  ; 否:退出并设置标志位
Str_compare ENDP

实现 Str_compare 时也可以使用 CMPSB 指令,但是这条指令要求知道较长字符串的长度,这样就需要调用 Str_length 程两次。

本例中,在同一个循环内检测两个字符串的零结束符显得更加容易。CMPSB 在处理长度已知的大型字符串或数组时最有效。

Str_length 过程

Str_length 过程用 EAX 返回一个字符串的长度。调用该过程时,要传递字符串的偏移地址。例如:

INVOKE Str_length, ADDR myString

过程实现如下:

Str_length PROC USES edi,
    pString:PTR BYTE       ;指向字符串
    mov edi, pString       ;字符计数器
    mov eax, 0             ;字符结束?
L1: cmp BYTE PTR[edi],0
    je L2                  ;是:退出
    inc edi                ;否:指向下一个字符
    inc eax                ;计数器加1
    jmp L1
L2: ret
Str_length ENDP

Str_copy 过程

Str_copy 过程把一个空字节结束的字符串从源地址复制到目的地址。调用该过程之前,要确保目标操作数能够容纳被复制的字符串。Str_copy 的调用语法如下:

INVOKE Str_copy, ADDR source, ADDR target

过程无返回值。下面是其实现:

;--------------------------------------
Str_copy PROC USES eax ecx esi edi,
    source:PTR BYTE,       ; source string
    target:PTR BYTE        ; target string
;将字符串从源串复制到目的串。
;要求:目标串必须有足够空间容纳从源复制来的串。
;--------------------------------------
    INVOKE Str_length, source      ;EAX = 源串长度
    mov ecx, eax                   ;重复计数器
    inc    ecx                     ;由于有零字节,计数器加 1
    mov esi, source
    mov edi, target
    cld                            ;方向为正向
    rep    movsb                   ;复制字符串
    ret
Str_copy ENDP

Str_trim 过程

Str_trim 程从空字节结束字符串中移除所有与选定的尾部字符匹配的字符。其调用语法如下:

INVOKE Str_trim, ADDR string, char_to_trim

这个过程的逻辑很有意思,因为程序需要检查多种可能的情况(以下用 # 作为尾部字符):

\1) 字符串为空。

\2) 字符串一个或多个尾部字符的前面有其他字符,如“Hello#”。

\3) 字符串只含有一个字符,且为尾部字符,如“#”。

\4) 字符串不含尾部字符,如“Hello”或“H”。

\5) 字符串在一个或多个尾部字符后面跟随有一个或多个非尾部字符,如“#H”或“##Hello”

使用 Str_trim 过程可以删除字符串尾部的全部空格(或者任何重复的字符)。从字符串中去掉字符的最简单的方法是,在想要移除的字符前面插入一个空字节。空字节后面的任何字符都会变得无意义。

下表列出了一些有用的测试例子。在所有例子中都假设从字符串中删除的是 # 字符,表中给出了期望的输出。

输入字符串 预期修改后的字符串
“Hello##” “Hello”
“#” “”(空字符串)
“Hello” “Hello”
“H” “H”
“#H” “#H”

现在来看看测试 Str_trim 程的代码。INVOKE 语句向 Str_trim 传递字符串地址:

.data
string_1 BYTE "Hello##",0
.code
INVOKE Str_trim,ADDR string_1,'#'
INVOKE ShowString,ADDR string_1

ShowString 过程用方括号显示了被裁剪后的字符串,这里未给出其代码。过程输出示例如下:[Hello]

下面给出了 Str_trim 的实现,它在想要保留的最后一个字符后面插入了一个空字节。空字节后面的任何字符一般都会被字符串处理函数所忽略。

;-------------------------------------
;Str_trim
;从字符串末尾删除所有与给定分隔符匹配的字符。
;返回:无
;-------------------------------------
Str_trim PROC USES eax ecx edi,
    pString:PTR BYTE,                ;指向字符串
    char: BYTE                       ;要移必的字符
    mov edi,pString                  ;准备调用 Str_length
    INVOKE Str_length,edi            ;用 EAX 返回鬆
    cmp eax,0                        ;长度是否为零?
    je L3                            ;是:立刻退出
    mov ecx, eax                     ;否:ECX = 字符串长度
    dec eax
    add edi,eax                      ;指向最后一个字符
L1: mov al, [edi]                    ;取一个字符
    cmp al,char                      ;是否为分隔符?
    jne L2                           ;否:插入空字节
    cec edi                          ;是:继续后退一个字符
    loop L1                          ;直到字符串的第一个字符
L2: mov BYTE PTR [edi+1 ],0          ;插入一个空字节
L3:    ret
Str_trim ENDP

详细说明

现在仔细研究一下 Str_trim。该算法从字符串最后一个字符开始,反向进行串扫描,以寻找第一个非分隔符字符。当找到这样的字符后,就在该字符后面的位置上插入一个空字节:

ecx = length(str)
if length (str) > 0 then
    edi = length - 1
    do while ecx > 0
        if str[edi] ≠ delimiter then
            str[edi+1] = null
            break
        else
            edi = edi - 1
        end if
        ecx = ecx - 1
    end do

下面逐行查看代码实现。首先,pString 为待裁剪字符串的地址。程序需要知道该字符串的长度,Str_length 过程用 EDI 寄存器接收其输入参数:

mov edi,pString       ;准备调用 Str_length
INVOKE Str_length,edi   ;过程返回值在 Eax 中

Str_length 过程用 EAX 寄存器返回字符串长度,所以,后面的代码行将它与零进行比较,如果字符串为空,则跳过后续代码:

cmp eax,0   ;字符串长度等于零吗?
je L3       ;是:立刻退出

在继续后面的程序之前,先假设该字符串不为空。ECX 为循环计数器,因此要将字符串长度赋给它。由于希望 EDI 指向字符串最后一个字符,因此把 EAX(包含字符串长度)减 1 后再加到 EDI 上:

mov ecx,eax         ;否:ECX =字符串长度
dec eax
add edi,eax      ;指向最后一个字符

现在 EDI 指向的是最后一个字符,将该字符复制到 AL 寄存器,并与分隔符比较:

L1: mov al, [edi]   ;取字符
   cmp al, char   ;是分隔符吗?

如果该字符不是分隔符,则退出循环,并用标号为 L2 的语句插入一个空字节:

jne L2          ;否:插入空字节

否则,如果发现了分隔符,则继续循环,逆向搜索字符串。实现的方法为:将 EDI 后退一个字符,再重复循环:

dec edi         ;是:继续后退
loop L1        ;直到字符串的第一个字符

如果整个字符串都由分隔符组成,则循环计数器将减到零,并继续执行 loop 指令下面的代码行,即标号为 L2 的代码,在字符串中插入一个空字节:

L2: mov BYTE PTR [edi+1], 0     ;插入空字节

假如程序控制到达这里的原因是循环计数减为零,那么,EDI 就会指向字符串第一个字符之前的位置。因此需要用表达式 [edi+1] 来指向第一个字符。

在两种情况下,程序会执行标号 L2:

  • 其一,在字符串中发现了非分隔符字符;

  • 其二, 循环计数减为零。

标号 L2 后面是标号为 L3 的 RET 指令,用来结束整个过程:

L3:   ret
Str_trim ENDP

Str_ucase 过程

Str_ucase 过程把一个字符串全部转换为大写字母,无返回值。调用过程时,要向其传 递字符串的偏移量:

INVOKE Str_ucase, ADDR myString

过程实现如下:

;---------------------------------
;Str_ucase
;将空字节结束的字符串转换为大写字母。
;返回:无
;---------------------------------
Str_ucase PROC USES eax esi,
pString:PTR BYTE
    mov esi,pString
L1:
    mov al, [esi]           ;取字符
    cmp al, 0               ;字符串是否结束?
    je L3                   ;是:退出
    cnp al, 'a'             ;小于"a" ?
    jb L2
    cnp al, 'z'             ;大于"z" ?
    ja L2
    and BYTE PTR [esi], 11011111b ;转换字符
L2: inc esi                 ;下一个字符
    jmp L1
L3: ret
Str_ucase ENDP

字符串演示程序

下面的 32 位程序演示了对 Irivne32 链接库中 Str_trim、Str_ucase、Str_compare 和 Str_length 过程的调用:

; String Library Demo    (StringDemo.asm)
; 该程序演示了链接库中字符串处理过程
INCLUDE Irvine32.inc
.data
string_1 BYTE "abcde////",0
string_2 BYTE "ABCDE",0
msg0     BYTE "string_1 in upper case: ",0
msg1     BYTE "string1 and string2 are equal",0
msg2     BYTE "string_1 is less than string_2",0
msg3     BYTE "string_2 is less than string_1",0
msg4     BYTE "Length of string_2 is ",0
msg5     BYTE "string_1 after trimming: ",0
.code
main PROC
    call trim_string
    call upper_case
    call compare_strings
    call print_length
    exit
main ENDP
trim_string PROC
; 从 string_1 删除尾部字符
    INVOKE Str_trim, ADDR string_1,'/'
    mov        edx,OFFSET msg5
    call    WriteString
    mov        edx,OFFSET string_1
    call    WriteString
    call    Crlf
    ret
trim_string ENDP
upper_case PROC
; 将 string_1 转换为大写字母
    mov        edx,OFFSET msg0
    call    WriteString
    INVOKE  Str_ucase, ADDR string_1
    mov        edx,OFFSET string_1
    call    WriteString
    call    Crlf
    ret
upper_case ENDP
compare_strings PROC
; 比较 string_1 和 string_2.
    INVOKE Str_compare, ADDR string_1, ADDR string_2
    .IF ZERO?
    mov    edx,OFFSET msg1
    .ELSEIF CARRY?
    mov    edx,OFFSET msg2     ; string 1 小于...
    .ELSE
    mov    edx,OFFSET msg3     ; string 2 小于...
    .ENDIF
    call    WriteString
    call    Crlf
    ret
compare_strings  ENDP
print_length PROC
; 显示 string_2 的长度
    mov        edx,OFFSET msg4
    call    WriteString
    INVOKE  Str_length, ADDR string_2
    call    WriteDec
    call    Crlf
    ret
print_length ENDP
END main

调用 Str_trim 过程从 string_1 删除尾部字符,调用 Str_ucase 过程将字符串转换为大写字母。

String Library Demo 程序的输出如下所示:

汇编语言Irivne64字符串过程详解

下面将一些比较重要的字符串处理过程从 Irvine32 链接库转换为 64 位模式。变化非常简单,删除堆栈参数,并将所有的 32 位寄存器都替换为 64 位寄存器。

下表列出了这些字符串过程、过程说明及其输入输出。

Str_compare 比较两个字符串 输入参数:RSI 为源串指针,RDI 为目的串指针 返回值:若源串 < 目的串,则进位标志位 CF=1;若源串 = 目的串,则零标志位 ZF=1;若源串 > 目的串,则 CF=0 且 ZF=0
Str_copy 将源串复制到目的指针指向的位置 输入参数:RSI 为源串指针,RDI 指向被复制串将要存储的位置
Str_length 返回空字节结束字符串的长度 输入参数:RCX 为字符串指针 返回值:RAX 为该字符串的长度

Str_compare 过程中,RSI 和 RDI 是输入参数的合理选择,因为字符串比较循环会用到它们。使用这两个寄存器参数能在过程开始时避免将输入参数复制到 RSI 和 RDI 寄存器中:

;------------------------------------
;Str_compare
;比较两个字符串
;接收:RSI 为源串指针
;     RDT 为目的串指针
;返回:若字符串相等,ZF 置 1
;      若源串 < 目的串,CF 置 1
;------------------------------------
Str_compare PROC USES rax rdx rsi rdi
L1: mov al,[rsi]
    mov dl,[rdi]
    cmp al, 0            ; string1 结束?
    jne L2               ; 否
    cmp dl, 0            ;是:string2 结束?
    jne L2               ;否
    jmp L3               ;是:退出且 ZF=1
L2: inc rsi              ;指向下一个字符
    inc rdi
    cmp al,dl            ;字符相等?
    je L1                ;是:继续循环
                         ;否:退出并设置标志位
L3: ret
Str_compare ENDP

注意,PROC 伪指令用 USES 关键字列出了所有需要在过程开始时入栈、在过程时返回出栈的寄存器。

Str_copy 过程用 RSI 和 RDI 接收字符串指针:

;-------------------------------------
;Str_copy
;复制字符串
;接收:RSI 为源串指针
;     RDI 为目的串指针
;返回:无
;-------------------------------------
Str_copy PROC USES rax rex rsi rdi
    mov rex,rsi               ;获得源串长度
    call Str_length           ;RAX 返回长度
    mov rex,rax               ;循环计数器
    inc rex                   ;有空字节,加 1
    cld                       ;方向为正向
    rep movsb                 ;复制字符串
    ret
Str_copy ENDP

Str_length 过程用 RCX 接收字符串指针,然后循环扫描该字符串直到发现空字节。字符串长度用 RAX 返回:

;-------------------------------------
;Str_length
;计算辜符串长度
;接收:RCX 指向字符串
;返回:RAX 为字符串长度
;-------------------------------------
Str_length PROC USES rdi
    mov rdi,rex           ;获得指针
    mov eax,0             ;字符计数
L1:
    cmp BYTE PTR [rdi],0  ;字符串结束?
    je L2                 ;是:退出
    inc rdi               ;否:指向下一个字符
    inc rax               ;计数器加 1
    jmp L1
L2: ret                   ;RAX 返回计数值
Str_length ENDP

一个简单的测试程序

下面的测试程序调用了 64 位的 Str_length、Str_copy 和Str_compare 过程。虽然程序中没有显示字符串的语句,但是建议在 Visual Studio 凋试器中运行,这样就可以查看内存窗口、寄存器和标志位。

; 测试 Irvine64 字符串程序
Str_compare        proto
Str_length        proto
Str_copy        proto
ExitProcess     proto
.data
source byte "AABCDEFGAABCDFG",0      ; 大小为 15
target byte 20 dup(0)
.code
main proc
    mov   rax,offset source
    call  Str_length                ; 用 RAX 返回长度
    mov   rsi,offset source
    mov   rdi,offset target
    call  str_copy
; 由于刚刚才复制了字符串,因此它们应该相等

    call  str_compare                ; ZF = 1, 字符串相等
; 修改目的串的第一个字符,再比较两个字符串
; compare them again:
    mov   target,'B'
    call  str_compare                ; CF = 1, 源串 < 目的串

    mov   ecx,0
    call  ExitProcess
main ENDP

汇编语言二维数组简介

在汇编语言程序员看来,二维数组是一位数组的高级抽象。高级语言有两种方法在内存中存放数组的行和列:行主序和列主序,如下图所示。

使用行主序(最常用)时,第一行存放在内存块开始的位置,第一行最后一个元素后面紧跟的是第二行的第一个元素。使用列主序时,第一列的元素存放在内存块开始的位置,第一列最后一个元素后面紧跟的是第二列的第一个元素。

用汇编语言实现二维数组时,可以选择其中的任意一种顺序。这里使用的是行主序。如果是为高级语言编写汇编子程序,那么应该使用高级语言文档中指定的顺序。

x86 指令集有两种操作数类型:基址-变址和基址-变址-位移量,这两种类型都适用于数组。下面将对它们进行研究并通过例子来说明如 何有效地使用它们。

基址-变址操作数

基址-变址操作数将两个寄存器(称为基址和变址)相加,生成一个偏移地址:

[base + index]

其中的方括号是必需的。32 位模式下,任一 32 位通用寄存器都可以用作基址和变址寄存器。(通常情况下避免使用 EBP,除非进行堆栈寻址。)下面的例子是 32 位模式中基址和变址操作数的各种组合:

.data
array WORD 1000h,2000h,3000h
.code
mov ebx,OFFSET array
mov esi, 2
mov ax,[ebx+esi]    ; AX = 2000h
mov edi, OFFSET array
mov ecx,4
mov ax,[edi+ecx]    ; AX = 3000h
mov ebp,OFFSET array
mov esi, 0
mov ax,[ebp+esi]    ; AX = l000h

二维数组

按行访问一个二维数组时,行偏移量放在基址寄存器中,列偏移量放在变址寄存器中。例如,下表给出的数组为 3 行 5 列:

tableB BYTE 10h, 20h, 30h, 40h, 50h
Rowsize = ($ - tableB)
    BYTE 60h, 70h, 80h, 90h, 0A0h
    BYTE 0B0h, 0C0h, 0D0h, 0E0h, 0F0h

该表为行主序,汇编器计算的常数 Rowsize 是表中每行的字节数。如果想用行列坐标定位表中的某个表项,则假设坐标基点为 0,那么,位于行 1 列 2 的表项为 80h。

将 EBX 设置为该表的偏移量,加上(Rowsizerow_index),计算出行偏移量,将 ESI 设置为列索引:

row_index = 1
column_index = 2
mov ebx, OFFSET tableB            ; 表偏移量
add ebx, RowSize * row_index      ; 行偏移量
mov esi, column_index
mov al,[ebx + esi]                ; AL = 80h

假设该数组位置的偏移量为 0150h,则其有效地址表示为 EBX+ESI,计算得 0157h。下图展示了如何通过 EBX 加上 ESI 生成 tableB[1, 2] 字节的偏移量。如果有效地址指向该程序数据区之外,那么就会产生一个运行时错误。

1) 计算数组行之和

基于变址的寻址简化了二维数组的很多操作。比如,用户可能想要计算一个整数矩阵中一行的和。下面的 32 位 calc_row_sum 程序就计算了一个 8 位整数矩阵中被选中行的和数:

;------------------------------------------------------------
; calc_row_sum
; 计算字节矩阵中一行的和数
; 接收: EBX = 表偏移量, EAX = 行索引
;       ECX = 按字节计的行大小
; 返回:  EAX 为和数
;------------------------------------------------------------
calc_row_sum PROC uses ebx ecx edx esi
    mul     ecx              ; 行索引 * 行大小
    add     ebx,eax          ; 行偏移量
    mov     eax,0            ; 累加器
    mov     esi,0            ; 列索引
L1:    movzx edx,BYTE PTR[ebx + esi]        ; 取一个字节
    add     eax,edx                         ; 与累加器相加
    inc     esi                             ; 行中的下一个字节
    loop L1
    ret
calc_row_sum ENDP

BYTE PTR 是必需的,用于声明 MOVZX 指令中操作数的类型。

2) 比例因子

如果是为字数组编写代码,则需要将变址操作数乘以比例因子 2。下面的例子定位行 1 列 2 的元素值:

tablew WORD 10h, 20h, 30h, 40h, 50h
RowsizeW = ($ - tableW)
    WORD 60h, 70h, 80h, 90h, 0A0h
    WORD 0B0h, 0C0h, 0D0h, 0E0h, 0F0h
.code
row_index = 1
column_index = 2
mov ebx,OFFSET tableW             ;表偏移量
add ebx,RowSizeW * row_index      ;行偏移量
mov esi, column_index
mov ax,[ebx + esi*TYPE tableW]    ;AX = 0080h

本例的比例因子 (TYPE tableW) 等于 2。同样,如果数组类型为双字,则比例因子为 4:

tableD DWORD 10h, 20h, . . .etc.
.code
mov eax,[ebx + esi*TYPE tableD]

基址-变址-偏移量操作数

基址-变址-偏移量操作数用一个偏移量、一个基址寄存器、一个变址寄存器和一个可选的比例因子来生成有效地址。格式如下:

[base + index + displacement]
displacement[base + index]

Displacement ( 偏移量 ) 可以是变量名或常量表达式。32 位模式下,任一 32 位通用寄存器都可以用作基址和变址寄存器。基址-变址-偏移量操作数非常适于处理二维数组。偏移量可以作为数组名,基址操作数为行偏移量,变址操作数为列偏移量。

双字数组示例

下面的二维数组包含了 3 行 5 列的双字:

tableD DWORD 10h, 20h, 30h, 40h, 50h
Rowsize = ($ - tableD)
DWORD 60h, 70h, 80h, 90h, 0A0h
DWORD 0B0h, 0C0h, 0D0h, 0E0h, 0F0h

Rowsize 等于 20 (14h) 。假设坐标基点为 0,那么位于行 1 列 2 的表项为 80h。为了访问到这个表项,将 EBX 设置为行索引,ESI 设置为列索引:

mov ebx, Rowsize             ;行索弓|
mov esi, 2             ;列索引
mov eax, tableD[ebx + esi*TYPE tableD]

设 tableD 开始于偏移量 0150h 处,下图展示了 EBX 和 ESI 相对于该数组的位置。偏移量为十六进制。

64 位模式下的基址-变址操作数

64 位模式中,若用寄存器索引操作数则必须为 64 位寄存器。基址-变址操作数和基址-变址-偏移量操作数都可以使用。

下面是一段小程序,它用 get_tableVal 过程在 64 位整数的二维数组中定位一个数值。如果将其与前面的 32 位代码进行比较,会发现 ESI 被替换为 RSI,EAX 和 EBX 也成了 RAX 和 RBX。

;64 位模式下的二维数组 (TwoDimArrays.asm)
Crlf        proto
WriteInt64  proto
ExitProcess proto
.data
table QWORD 1,2,3,4,5
RowSize = ($ - table)
      QWORD 6,7,8,9,10
      QWORD 11,12,13,14,15
.code
main proc
; 基址-变址-偏移量操作数
    mov    rax,1                    ; 行索引基点为0
    mov    rsi,4                    ; 列索引基点为0
    call    get_tableVal            ; RAX中为返回值
    call    WriteInt64              ; 显示返回值
    call    Crlf
    mov   ecx,0           
    call  ExitProcess               ; 程序结束
main endp
;------------------------------------------------------
; get_tableVal
; 返回四字二维数组中给定行列值的元素
; 接收: RAX = 行数, RSI = 列数
; 返回: RAX中的数值
;------------------------------------------------------
get_tableVal proc uses rbx
    mov    rbx,RowSize
    mul    rbx                ; 乘积(低) = RAX
    mov    rax,table[rax + rsi*TYPE table]
    ret
get_tableVal endp
end

汇编语言冒泡排序简述

冒泡排序从位置 0 和 1 开始,对比数组的两个数值。如果比较结果为逆序,就交换这两个数。下图展示了对一个整数数组进行一次遍历的过程。

一次冒泡过程之后,数组仍没有按序排列,但此时最高索引位置上是最大数。外层循环则开始对该数组再一次遍历。经过 1 次遍历后,数组就会按序排列。

冒泡排序对小型数组效果很好,但对较大的数组而言,它的效率就十分低下。计算机科学家在衡量算法的相对效率时,常常使用一种被称为 “时间复杂度”(big-O)的概念来描述随着处理对象数量的增加,平均运行时间是如何增加的。

冒泡排序是 O (n²) 算法,这就意味着,它的平均运行时间随着数组元素 (n) 个数的平方增加。比如,假设 1000 个元素排序需要 0.1 秒。当元素个数增加 10 倍时,该数组排序所需要的时间就会增加 10² (100) 倍。

下表列出了不同数组大小需要的排序时间,假设 1000 个数组元素排序花费 0.1 秒:

数组大小 时间(秒) 数组大小 时间(秒)
1 000 0.1 100 000 1000
10 000 10 1 000 000 100 000 (27.78 小时)

对于一百万个整数来说,冒泡排序谈不上有效率,因为它完成任务的时间太长了!但是对于几百个整数,它的效率是足够的。

用类似于汇编语言的伪代码为冒泡排序编写的简化代码是有用的。代码用 N 表示数组大小,cx1 表示外循环计数器,cx2 表示内循环计数器:

cx1 = N - 1
while( cxl > 0 )
{
    esi = addr (array)
    cx2 = cx1
    while ( cx2 > 0 )
    {
        if(array[esi] > array[esi+4])
            exchange(array[esi], array[esi+4])
        add esi,4
        dec cx2
    }
    dec cxl
}

如保存和恢复外循环计数器等的机械问题被刻意忽略了。注意内循环计数 (cx2) 是基于外循环计数 (cx1) 当前值的,每次遍历数组时它都依次递减。

根据伪代码能够很容易生成与之对应的汇编程序,并将它表示为带参数和局部变量的过程:

;----------------------------------------
;BubbleSort
;使用冒泡算法,将一个 32 位有符号整数数组按升序进行排列。
;接收:数组指针,数组大小
;返回:无
;----------------------------------------
BubbleSort PROC USES eax ecx esi,
    pArray:PTR DWORD,           ;数组指针
    Count: DWORD                ;数组大小
    mov ecx,Count
    dec ecx                     ;计数值减1
L1: push ecx                    ;保存外循环计数值
    mov esi,pArray              ;指向第一个数值
L2: mov eax, [esi]              ;取数组元素值
    cmp [esi+4],eax             ;比较两个数值
    jg L3                       ;如果[ESI]<=[ESI + 4],不交换
    xchg eax, [esi+4]           ;交换两数
    mov [esi],eax
L3: add esi,4                   ;两个指针都向前移动一个元素
    loop L2                     ;内循环
    pop    ecx                  ;恢复外循环计数值
    loop L1                     ;若计数值不等于0,则继续外循环
L4: ret
BubbleSort ENDP

汇编语言对半查找(二分查找)简述

数组查找是日常编程中最常见的一类操作。对小型数组 (1000 个元素或更少 ) 而言,顺序查找(sequential search) 是很容易的,从数组开始的位置顺序检查每一个元素,直到发现匹配的元素为止。对任意 n 个元素的数组,顺序查找平均需要比较 n/2 次。如果查找的是小型数组,则执行时间也很少。但是,如果查找的数组包含一百万个元素就需要相当多的处理时间了。

对半查找 (binary search) 算法用于从大型数组中查找一个数值是非常有效的。但是它有一个重要的前提:数组必须是按升序或降序排列。下面的算法假设数组元素是升序:

开始查找前,请求用户输入一个整数,将其命名为 searchVal

\1) 被查找数组的范围用下标行 first 和 last 来表示。如果 first > last,则退出查找,也就是说没有找到匹配项。

\2) 计算位于数组 first 和 last 下标之间的中点。

\3) 将 searchVal 与数组中点进行比较:

  • 如果数值相等,将中点送入 EAX,并从过程返回。该返回值表示在数组中发现了匹配值。

  • 否则,如果 searchVal 大于中点值,则将 first 重新设置为中点后一位元素的位置。

  • 或者,如果 searchVal 小于中点值,则将 last 重新设置为中点前一位元素的位置。

\4) 返回步骤 1

对半查找效率高的原因是它采用了分而治之的策略。每次循环迭代中,数值范围都被对半分为成两部分。通常它被描述为 O (log n) 算法,即,当数组元素增加 n 倍时,平均查找时间仅增加 log₂n 倍。

为了帮助了解对半查找效率有多高,下表列出了数组大小相同时,顺序查找和对半查找需要执行的最大比较次数。表中的数据代表的是最坏的情况一一在实际应用 中,经过更少次的比较就可能找到匹配数值。

数组大小 顺序查找 对半查找
64 64 6
1 024 1 024 10
65 536 65 536 17
1 048 576 1 048 576 21
4 294 967 296 4 294 967 296 33

下面是用 C++ 语言实现的对半查找功能,用于有符号整数数组:

int BinSearch( int values[], const int searchVal, int count )
{
    int first = 0;
    int last = count - 1;
    while( first <= last )
    {
        int mid = (last + first) / 2;
        if( values[mid] < searchVal )
            first = mid + 1;
        else if( values[mid] > searchVal )
            last = mid - 1;
        else
            return mid;    // 成功
    }
    return -1;    // 未找至U
}

该 C++ 代码示例的汇编语言程序清单如下所示:

;-------------------------------------------------------------
; Binary Search procedure
INCLUDE Irvine32.inc
.code
BinarySearch PROC USES ebx edx esi edi,
    pArray:PTR DWORD,          ; 数组指针
    Count:DWORD,               ; 数组大学
    searchVal:DWORD            ; 给定查找数值
LOCAL first:DWORD,             ; first 的位置
    last:DWORD,                ; last 的位置
    mid:DWORD                  ; 中点
; 接收: 数组指针、数组大小、给定查找数值
; 返回: 若发现匹配项则EAX=该匹配元素在数组中的位置 否则 EAX = -1
;-------------------------------------------------------------
    mov     first,0              ; first = 0
    mov     eax,Count            ; last = (count - 1)
    dec     eax
    mov     last,eax
    mov     edi,searchVal        ; EDI = searchVal
    mov     ebx,pArray           ; EBX 为数组指针
L1: ; while first <= last
    mov     eax,first
    cmp     eax,last
    jg     L5                    ; 退出查找
; mid = (last + first) / 2
    mov     eax,last
    add     eax,first
    shr     eax,1
    mov     mid,eax
; EDX = values[mid]
    mov     esi,mid
    shl     esi,2                ; 将 mid 值乘 4
    mov     edx,[ebx+esi]        ; EDX = values[mid]
; if ( EDX < searchval(EDI) )
;    first = mid + 1;
    cmp     edx,edi
    jge     L2
    mov     eax,mid                ; first = mid + 1
    inc     eax
    mov     first,eax
    jmp     L4
; else if( EDX > searchVal(EDI) )
;    last = mid - 1;
L2:    cmp     edx,edi
    jle     L3
    mov     eax,mid                ; last = mid - 1
    dec     eax
    mov     last,eax
    jmp     L4
; else return mid
L3:    mov     eax,mid                  ; 发现数值
    jmp     L9                          ; 返回 (mid)
L4:    jmp     L1                       ; 继续循环
L5:    mov     eax,-1                   ; 查找失败
L9:    ret
BinarySearch ENDP
END

Java如何字符串处理及常用方法

【示例】:寻址子串,下面的 Java 代码定义了一个字符串变量,其中包含了一个雇员 ID 和该雇员的姓氏。然后,调用 substring 方法将账号送入第二个字符串变量:

String empInfo = "10034Smith";
String id = empInfo.substring(0,5);

对该 Java 代码反汇编,其字节码显示如下:

ldc #32; //字符串 10034Smith
astore_0
aload_0
iconst_0
iconst_5
invokevirtual #34; // Method java/lang/String.substring
astore_1

现在分步研究这段代码,并加上自己的注释。ldc 指令把一个对字符串文本的引用从常量池加载到操作数栈。接着,astore_0 指令从运行时堆栈弹出该字符串引用,并把它保存到局部变量 empInfo 中,其在局部变量区域中的索引为 0:

ldc #32; //加载文本字符串:10034Smith
astore_0 //保存到 empInfo (索引 0)

接下来,aload_0 指令把对 empInfo 的引用压入操作数栈:

aload_0 //加载 empInfo 到堆栈

然后,在调用 substring 方法之前,它的两个参数(0 和 5)必须压入操作数栈。该操作由指令 iconst_0 和 iconst_5 完成:

iconst_0
iconst_5

invokevirtual 指令调用 substring 方法,它的引用 ID 号为 34:

invokevirtual #34; // Method java/lang/String.substring

substring 方法将参数弹出堆栈,创建新字符串,并将该字符串的引用压入操作数栈。其后的 astore_1 指令把这个字符串保存到局部变量区域内索引 1 的位置,也就是变量 id 所在的位置:astore_1

汇编语言结构和宏

汇编语言STRUCT和ENDS伪指令:定义结构

定义结构使用的是 STRUCT 和 ENDS 伪指令。在结构内,定义字段的语法与一般的变量定义是相同的。结构对其包含字段的数量几乎没有任何限制:

name STRUCT
   field-declarations
name ENDS

字段初始值若结构字段有初始值,那么在创建结构变量时就要进行赋值。字段初始值可以使用各种类型:

  • 无定义:运算符?使字段初始值为无定义。

  • 字符串文本:用引号括起的字符串。

  • 整数:整数常数和整数表达式。

  • 数组:DUP 运算符可以初始化数组元素。

下面的 Employee 结构描述了雇员信息,其包含字段有 ID 号、姓氏、服务年限,以及薪酬历史信息数组。结构定义如下所示,定义必须在声明 Employee 变量之前:

Employee STRUCT
    IdNum BYTE "000000000"
    LastName BYTE 30 DUP(0)
    Years WORD 0
    SalaryHistory DWORD 0,0,0,0
Employee ENDS

该结构内存保存形式的线性表示如下:

对齐结构字段

为了获得最好的内存 I/O 性能,结构成员应按其数据类型进行地址对齐。否则,CPU 将会花更多时间访问成员。例如,一个双字成员应对齐到双字边界。下表列岀了 Microsoft C 和 C++ 编译器,以及 Win32 API 函数的对齐方式。汇编语言中的 ALIGN 伪指令会使其后的字段或变量按地址对齐:

ALIGN datatype
成员类型 对齐方式 成员类型 对齐方式
BYTE, SBYTE 对齐到 8 位(字节)边界 REAL4 对齐到 32 位(双字)边界
WORD, SWORD 对齐到 16 位(字)边界 REAL8 对齐到 64 位(四字)边界
DWORD, SDWORD 对齐到 32 位(双字)边界 structure 所有成员的最大对齐要求
QWORD 对齐到 64 位(四字)边界 union 第一个成员的对齐要求

比如,下面的例子就把 myVar 对齐到双字边界:

.data
ALIGN DWORD
myVar DWORD ?

现在正确地定义 Employee 结构,利用 ALIGN 将 Years 按字(WORD)边界对齐,SalaryHistory 按双字(DWORD)边界对齐。注释为字段大小:

Employee STRUCT
    IdNum BYTE "000000000"              ; 9
    LastName BYTE 30 DUP(0)             ; 30
    ALIGN WORD                          ; 加 1 字节
    Years WORD 0                        ; 2
    ALIGN DWORD                         ; 加 2 字节
    SalaryHistory DWORD 0,0,0,0         ; 16
Employee ENDS                           ;共 60 字节

汇编语言声明结构变量

结构变量可以被声明,并能选择为是否用特定值进行初始化。语法如下,其中 structureType 已经用 STRUCT 伪指令定义过了:

identifier structureType <initializer-list>

identifier 的命名规则与 MASM 中其他变量的规则相同。initializer-list 为可选项,但是如果选择使用,则该项就是一个用逗号分隔的汇编时常数列表,需要与特定结构字段的数据类型相匹配:

initializer [, initializer] ...

空括号 <> 使结构包含的是结构定义的默认字段值。此外,还可以在选定字段中插入新值。结构字段中的插入值顺序为从左到右,与结构声明中字段的顺序一致。这两种方法的示例如下,使用的结构是 COORD 和 Employee:

.data
point1 COORD <5,10>             ; X = 5, Y = 10
point2 COORD <20>               ; X = 20, Y = ?
point3 COORD <>                 ; X = ?, Y = ?
worker Employee <>              ; 默认初始值

可以只覆盖选定字段的初始值。下面的声明只覆盖了 Employee 结构的 IdNum 字段,而其他字段仍为默认值:

person1 Employee <"555223333">

还有一种形式是使用大括号 {…} 而不是尖括号:

person2 Employee {"555223333"}

若字符串字段初始值的长度少于字段的定义,则多出的位置用空格填充。空字节不会自动插到字符串字段的尾部。通过插入逗号作为位置标记可以跳过结构字段。例如,下面的语句就跳过了 IdNum 字段,初始化了 LastName 字段:

person3 Employee <, "dJones">

数组字段使用 DUP 运算符来初始化某些或全部数组元素。如果初始值比字段位数少,则多出的位置用零填充。下面的语句只初始化了前两个 SalaryHistory 的值,而其他的值则为 0:

person4 Employee <, , ,2 DUP(20000)>

DUP 运算符能够用于定义结构数组,如下所示,AllPoints 中每个元素的 X 和 Y 字段都被初始化为 0:

NumPoints = 3
AllPoints COORD NumPoints DUP(<0,0>)

对齐结构变量

为了最好的处理器性能,结构变量在内存中的位置要与其最大结构成员的边界对齐。Employee 结构包含双字 (DWORD) 字段,因此,下面的定义使用了双字对齐:

.data
ALIGN DWORD
person Employee <>

汇编语言TYPE和SIZEOF运算符:引用结构变量和结构名称

使用 TYPE 和 SIZEOF 运算符可以引用结构变量和结构名称。例如,现在回到之前的 Employee 结构:

Employee STRUCT
    IdNum BYTE "000000000"      ; 9
    LastName BYTE 30 DUP(0)     ; 30
    ALIGN WORD                  ; 加 1 字节
    Years WORD 0                ; 2
    ALIGN DWORD                 ; 加 2 字节
    SalaryHistory DWORD 0,0,0,0 ; 16
Employee ENDS                   ; 共 60 字节

给定数据定义:

.data
worker Employee <>

则下列所有表达式返回的值都相同:

TYPE Employee        ; 60
SIZEOF Employee           ; 60
SIZEOF worker        ; 60

TYPE 运算符返回的是标识符存储类型(BYTE、WORD、DWORD 等)的字节数。LENGTHOF 运算符返回的是数组元素的个数。SIZEOF 运算符则为 LENGTHOF 与 TYPE 的乘积。

1) 引用成员

引用已命名的结构成员时,需要用结构变量作为限定符。以 Employee 结构为例,在汇编时能生成下述常量表达式:

TYPE Employee.SalaryHistory            ; 4
LENGTHOF Employee.SalaryHistory             ; 4
SIZEOF Employee.SalaryHistory           ; 16
TYPE Employee.Years                   ; 2

以下为对 worker(一个 Employee)的运行时引用:

.data
worker Employee <>
.code
mov dx,worker.Years
mov worker.SalaryHistory, 20000              ;第一个工资
mov [worker.SalaryHistory+4 ], 30000         ;第二个工资

使用 OFFSET 运算符能获得结构变量中一个字段的地址:

mov edx,OFFSET worker.LastName

2) 间接和变址操作数

间接操作数用寄存器(如 ESI)对结构成员寻址。间接寻址具有灵活性,尤其是在向过程传递结构地址或者使用结构数组的情况下。引用间接操作数时需要 PTR 运算符:

mov esi,OFFSET worker
mov ax,(Employee PTR [esi]).Years

下面的语句不能汇编,原因是 Years 自身不能表明它所属的结构:

mov ax, [esi].Years   ;无效

变址操作数

用变址操作数可以访问结构数组。假设 department 是一个包含 5 个 Employee 对象的数组。下述语句访问的是索引位置为 1 的雇员的 Years 字段:

.data
department Employee 5 DUP(<>)
.code
mov esi, TYPE Employee              ; 索引 = 1
mov department[esi].Years, 4

数组循环

带间接或变址寻址的循环可以用于处理结构数组。下面的程序 (AllPoints.asm)为 AllPoints 数组分配坐标:

; 数组循环       (AllPoints.asm)
INCLUDE Irvine32.inc
NumPoints = 3
.data
ALIGN WORD
AllPoints COORD NumPoints DUP(<0,0>)
.code
main PROC
    mov edi,0                    ; 数组索引
    mov ecx,NumPoints            ; 循环计数器
    mov ax,1                     ; 起始 X, Y 的值
L1:    mov (COORD PTR AllPoints[edi]).X,ax
    mov (COORD PTR AllPoints[edi]).Y,ax
    add edi,TYPE COORD
    inc ax
    loop L1
    exit
main ENDP
END main
3) 对齐的结构成员的性能

之前已经断言,处理器访问正确对齐的结构成员时效率更高。那么,非对齐字段会对性能产生多大影响呢?现在使用本章介绍的 Employee 结构的两种不同版本,进行一个简单的测试。测试将对第一个版本进行重命名,以便两种版本能在同一个程序中使用:

EmployeeBad STRUCT   
    Idnum    BYTE "000000000"
    Lastname BYTE 30 DUP(0)
    Years    WORD 0
    SalaryHistory DWORD 0,0,0,0
EmployeeBad ENDS               
Employee STRUCT   
    Idnum    BYTE "000000000"
    Lastname BYTE 30 DUP(0)
    ALIGN    WORD
    Years    WORD 0
    ALIGN    DWORD
    SalaryHistory DWORD 0,0,0,0
Employee ENDS

下面的代码首先获取系统时间,再执行循环以访问结构字段,最后计算执行花费的时 间。变量 emp 可以声明为 Employee 对象或者 EmployeeBad 对象:

.data
ALIGN DWORD
startTime DWORD ?                ; 对齐 startTime
emp EmployeeBad <>               ; 或: EmployeeBad
.code
    call    GetMSeconds          ; 获取系统时间
    mov    startTime,eax

    mov    ecx,0FFFFFFFFh        ; 循环计数器
L1:    mov    emp.Years,5
    mov    emp.SalaryHistory,35000
    loop    L1
    call    GetMSeconds        ; 获取开始时间
    sub    eax,startTime
    call    WriteDec           ; 显示执行花费的时间

在这个简单的测试程序中,使用正确对齐的 Employee 结构的执行时间为 6141 毫秒,而使用 EmployeeBad 结构的执行时间为 6203 毫秒。两者相差不大 (62 毫秒),可能是因为处理器的内存 cache 将对齐问题最小化了。

汇编语言实例:显示系统时间

MS-Windows 提供了设置屏幕光标位置和获取系统时间的控制台函数。要使用这些函数,先为两个预先定义的结构 COORD 和 SYSTEMTIME 创建实例:

COORD STRUCT
    X WORD ?
    Y WORD ?
COORD ENDS
SYSTEMTIME STRUCT
    wYear WORD ?
    wMonth WORD ?
    wDayOfWeek WORD ?
    wDay WORD ?
    wHour WORD ?
    wMinute WORD ?
    wSecond WORD ?
    wMilliseconds WORD ?
SYSTEMTIME ENDS

这两个结构都在 SmallWin.inc 中进行了定义,这个文件位于汇编器的 INCLUDE 目录下,并且由 Irvine32.inc 引用。首先获取系统时间(调整本地时间),调用 MS-Windows 的 GetLocalTime 函数,并向其传递 SYSTEMTIME 结构的地址:

.data
sysTime SYSTEMTIME <>
.code
INVOKE GetLocalTime, ADDR sysTime

接着,从 SYSTEMTIME 结构检索相应的数值:

movzx eax,sysTime.wYear
call WriteDec

当 Win32 程序产生屏幕输出时,它要调用 MS-Windows GetStdHandle 函数来检索标准控制台输出句柄(一个整数):

.data
consoleHandle DWORD ?
.code
INVOKE GetStdHandle, STD_OUTPUT_HANDLE
mov consoleHandle,eax

设置光标位置要调用 MS-Windows SetConsoleCursorPosition 函数,并向其传递控制台输岀句柄,以及包含 X、Y 字符坐标的 COORD 结构变量:

.data
XYPos COORD <10,5>
.code
INVOKE SetConsoleCursorPosition, consoleHandle, XYPos

程序清单

下面的程序检索系统时间,并将其显示在指定的屏幕位置。该程序只在保护模式下运行:

; 结构    (ShowTime.asm)
INCLUDE Irvine32.inc
.data
sysTime SYSTEMTIME <>
XYPos   COORD <10,5>
consoleHandle DWORD ?
colonStr BYTE ":",0
.code
main PROC
; 获取 Win32 控制台的标准输出句柄
    INVOKE GetStdHandle, STD_OUTPUT_HANDLE
    mov consoleHandle,eax
; 设置光标位置并获取系统时间
    INVOKE SetConsoleCursorPosition, consoleHandle, XYPos
    INVOKE GetLocalTime,ADDR sysTime
; 显示系统时间 (hh:mm:ss).
    movzx eax,sysTime.wHour          ; 小时
    call  WriteDec
    mov   edx,offset colonStr        ; ":"
    call  WriteString
    movzx eax,sysTime.wMinute        ; 分钟
    call  WriteDec
    call  WriteString                ; ":"
    movzx eax,sysTime.wSecond        ; 秒
    call  WriteDec
    call Crlf
    exit
main ENDP
END main

SmallWin.inc(自动包含在 Irvine32.inc 中)中的上述程序采用如下定义:

STD_OUTPUT_HANDLE EQU -11
SYSTEMTIME STRUCT ...
COORD STRUCT ...
GetStdHandle PROTO,
   nStdHandle:DWORD
GetLocalTime PROTO,
   lpSystemTime:PTR SYSTEMTIME
SetConsoleCursorPosition PROTO,
   nStdHandle:DWORD,
   coords:COORD

下面是示例程序输出,执行时间为下午 12:16:

12:16:35
Press any key to continue...

汇编语言结构嵌套简述

结构还可以包含其他结构的实例。例如,Rectangle 可以用其左上角和右下角来定义,而它们都是 COORD 结构:

Rectangle STRUCT
   UpperLeft COORD <>
   LowerRight COORD <>
Rectangle ENDS

Rectangle 变量可以被声明为不覆盖或者覆盖单个 COORD 字段。各种表达形式如下所示:

rect1 Rectangle < >
rect2 Rectangle { }
rect3 Rectangle { {10,10}, {50,20} }
rect4 Rectangle < <10,10>, <50,20> >

下面是对其一个结构字段的直接引用:

mov rect1.UpperLeft.X, 10

也可以用间接操作数访问结构字段。下例用 ESI 指向结构,并把 10 送人该结构左上角的 Y 坐标:

mov esi,OFFSET rect1
mov (Rectangle PTR [esi]).UpperLeft.Y, 10

OFFSET 运算符能返回单个结构字段的指针,包括嵌套字段:

mov edi,OFFSET rect2.LowerRight
mov (COORD PTR [edi]).X, 50
mov edi,OFFSET rect2.LowerRight.X
mov WORD PTR [edi], 50

示例:醉汉行走

现在来看一个使用结构的小程序将会有所帮助。下面完成一个“醉汉行走”练习,用程序模拟一个不太清醒的教授从计算机科学假期聚会回家的路线。利用随机数生成器,选择该教授每一步行走的方向。假设教授处于一个虚构的网格中心,其中的每个方格代表的是北、南、东、西方向上的一步。现在按照随机路径通过网格,如下图所示。

本程序将使用 COORD 结构追踪这个人行走路径上的每一步,它们被保存在一个 COORD 对象数组中。

WalkMax == 50
DrunkardWalk STRUCT
   path COORD WalkMax DUP(<0, 0>)
   pathsUsed WORD 0
DrunkardWalk ENDS

Walkmax 是一个常数,决定在模拟中教授能够行走的总步数。pathsUsed 字段表示在程序循环结束后,一共行走了多少步。教授每走一步,其位置就被记录在 COORD 对象中,并插入 path 数组下一个可用的位置。程序将在屏幕上显示这些坐标。

以下是完整的程序清单, 需在 32 位模式下运行:

; 醉汉行走    (Walk. asm)
; 醉汉行走程序。教授的起点坐标为(25,25),并在周围徘徊
INCLUDE Irvine32.inc
WalkMax = 50
StartX = 25
StartY = 25
DrunkardWalk STRUCT
    path COORD WalkMax DUP(<0,0>)
    pathsUsed WORD 0
DrunkardWalk ENDS
DisplayPosition PROTO currX:WORD, currY:WORD
.data
aWalk DrunkardWalk <>
.code
main PROC
    mov esi,OFFSET aWalk
    call TakeDrunkenWalk
    exit
main ENDP
;-------------------------------------------------------
TakeDrunkenWalk PROC
    LOCAL currX:WORD, currY:WORD
;
; 向随机方向行走(北, 南, 东, 西)
; 接收: ESI 为 DrunkardWalk 结构的指针
; 返回:  结构初始化为随机数
;-------------------------------------------------------
    pushad
; 用 OFFSET 运算符获取 path,COORD 对象数组的地址,并将其复制到 EDI.
    mov edi,esi
    add edi,OFFSET DrunkardWalk.path
    mov ecx,WalkMax            ; 循环计数器
    mov currX,StartX           ; 当前 X 的位置
    mov currY,StartY           ; 当前 Y 的位置
Again:
    ; 把当前位置插入数组
    mov ax,currX
    mov (COORD PTR [edi]).X,ax
    mov ax,currY
    mov (COORD PTR [edi]).Y,ax
    INVOKE DisplayPosition, currX, currY
    mov      eax,4      ; 选择一个方向 (0-3)
    call  RandomRange
    .IF eax == 0        ; 北
      dec currY
    .ELSEIF eax == 1    ; 南
      inc currY
    .ELSEIF eax == 2    ; 西
      dec currX
    .ELSE               ; 东 (EAX = 3)
      inc currX
    .ENDIF
    add    edi,TYPE COORD    ; 指向下一个 COORD
    loop    Again
Finish:
    mov (DrunkardWalk PTR [esi]).pathsUsed, WalkMax
    popad
    ret
TakeDrunkenWalk ENDP
;-------------------------------------------------------
DisplayPosition PROC currX:WORD, currY:WORD
; 显示当前 X 和 Y 的位置
;-------------------------------------------------------
.data
commaStr BYTE ",",0
.code
    pushad
    movzx eax,currX                ; 当前 X 的位置
    call     WriteDec
    mov     edx,OFFSET commaStr    ; "," 字符串
    call     WriteString
    movzx eax,currY                ; 当前 Y 的位置
    call     WriteDec
    call     Crlf
    popad
    ret
DisplayPosition ENDP
END main

现在进一步查看 TakeDrunkenWalk 过程。过程接收指向 DrunkardWalk 结构的指针 (ESI),利用 OFFSET 运算符计算 path 数组的偏移量,并将其复制到 EDI:

mov edi,esi
add edi,OFFSET DrunkardWalk.path

教授初始位置的 X 和 Y 值 (StartX 和 StartY) 都被设置为 25,位于 50 x 50 虚拟网格的中点。循环计数器也进行了初始化:

mov ecx, WalkMax ;循环计数器
mov currX, StartX  ;当前 X 的位置
mov currY, StartY  ;当前 Y 的位置

循环开始时,对 path 数组的第一项进行初始化:

Again:
   ; 把当前位置插入数组
   mov ax,currX
   mov (COORD PTR [edi]).X,ax
   mov ax,currY
   mov (COORD PTR [edi]).Y,ax

路径结束时,在 pathsUsed 字段插入一个计数值,表示总共走了多少步:

Finish:
   mov (DrunkardWalk PTR [esi]).pathsUsed, WalkMax

在当前的程序中,pathsUsed 总是等于 WalkMaX。不过,若在行走过程中发现障碍,如湖泊或建筑物,情况就会发生变化,循环将会在达到 WalkMax 之前结束。

汇编语言联合 (union) 的声明和使用

结构中的每个字段都有相对于结构第一个字节的偏移量,而联合 (union) 中所有的字段则都起始于同一个偏移量。一个联合的存储大小即为其最大字段的长度。如果不是结构的组成部分,那么需要用 UNION 和 ENDS 伪指令来定义联合:

unionname UNION
   union-fields
unionname ENDS

如果联合嵌套在结构内,其语法会有一点不同:

structname STRUCT
   structure-fields
   UNION unionname
       union-fields
   ENDS
structname ENDS

除了其每个字段都只有一个初始值之外,联合字段声明的规则与结构的规则相同。例如,Integer 联合对同一个数据声明了 3 种不同的大小属性,并将所有的字段都初始化为 0:

Integei; UNION
   D DWORD 0
   W WORD 0
   B BYTE 0
Integer ENDS

一致性

如果使用初始值,那么它们必须为相同的数值。假设 Integer 声明了 3 个不同的初始值:

Integer UNION
   D DWORD 1
   W WORD 5
   B BYTE 8
Integer ENDS

同时还假设声明了一个 Integer 变量 mylnt 使用默认初始值:

.data
mylnt Integer <>

结果发现,myInt.D、myInt.W 和 myInt.B 都等于 1。字段 W 和 B 中声明的初始值会被汇编器忽略。

结构包含联合

在结构声明中使用联合的名称,就可以使联合嵌套在这个结构中。方法如同下面在 Fileinfo 结构中声明 FilelD 字段一样:

Fileinfo STRUCT
   FilelD Integer <>
   FileName BYTE 64 DUP(?)
Fileinfo ENDS

还可以直接在结构中定义联合,方法如同下面定义 FilelD 字段一样:

Fileinfo STRUCT
   UNION FilelD
       D DWORD ?
       W WORD ?
       B BYTE ?
   ENDS
   FileName BYTE 64 DUP(?)
Fileinfo ENDS

声明和使用联合变量

联合变量的声明和初始化方法与结构变量相同,只除了一个重要的差异:不允许初始值多于一个。下面是 Integer 类型变量的例子:

val1 Integer <12345678h>
val2 Integer <100h>
val3 Integer <>

在可执行指令中使用联合变量时,必须给出字段的一个名称。下面的例子把寄存器的值赋给了 Integer 联合字段。注意其可以使用不同操作数大小的灵活性:

mov val3.B, al
mov val3.W, ax
mov val3.D, eax

联合还可以包含结构。有些 MS-Windows 控制台输入函数会使用如下 INPUT_RECORD 结构,它包含了一个名为 Event 的联合,这个联合对几个预定义的结构类型进行选择。EventType 字段表示联合中出现的是哪种 record。每一种结构都有不同的布局和大小,但是一次只能使用一种:

INPUT_RECORD STRUCT
   EventType WORD ?
   ALIGN DWORD
   UNION Event
       KEY_EVENT_RECORD <>
       MOUSE_EVENT_RECORD <>
       WINDOW_BUFFER_SIZE_RECORD <>
       MENU_EVENT_RECORD <>
       FOCUS_EVENT_RECORD <>
   ENDS
INPUT_RECORD ENDS

Win32 API

在命名结构时,常常使用单词 RECORD。KEY_EVENT_RECORD 结构的定义如下所示:

KEY_EVENT_RECORD STRUCT
   bKeyDown DWORD ?
   wRepeatCount WORD ?
   wVirtualKeyCode WORD ? wVirtualScanCode WORD ?
   UNION uChar
       UnicodeChar WORD ?
       AsciiChar BYTE ?
   ENDS
   dwControlKeyState DWORD ?
KEY_EVENT_RECORD ENDS

汇编语言宏过程(macro procedure)简述

宏过程 (macro procedure) 是一个命名的汇编语句块。一旦定义好了,它就可以在程序中多次被调用。在调用宏过程时,其代码的副本将被直接插入到程序中该宏被调用的位置。

这种自动插入代码也被称为内联展开(inline expansion)。尽管从技术上来说没有 CALL 指令,但是按照惯例仍然说调用 (calling) 宏过程。

提示:Microsoft 汇编程序手册中的术语宏过程是指无返回值的宏。还有一种宏函数 (macro function) 则有返回值。在程序员中,单词宏 (macro) 通常被理解为宏过程。在下面的讲解中将使用宏这个简短的称呼。

位置宏定义一般出现在程序源代码开始的位置,或者是放在独立文件中,再用 INCLUDE 伪指令复制到程序里。

宏在汇编器预处理 (preprocessing) 阶段进行扩展。在这个阶段中,预处理程序读取宏定义并扫描程序剩余的源代码。每到宏被调用的位置,汇编器就将宏的源代码复制插入到程序中。

汇编器在调用宏之前,必须先找到宏定义。如果程序定义了宏但却没有调用它,那么在编译好的程序中不会出现宏代码。

在下例中,宏 PrintX 调用了 Irvine32 链接库的 WriteChar 过程。这个定义通常会被放置在数据段之前:

PrintX MACRO
   mov al,'X'
   call WriteChar
ENDM

接着,在代码段中调用这个宏:

.code
PrintX

当预处理程序扫描这个程序并发现对 PrintX 的调用后,它就用如下语句替换宏调用:

mov al, 'X'
call WriteChar

这里发生的是文本替换。虽然宏有点不灵活,但后面很快就会展示如何向宏传递实参,使它们变得更有用。

汇编语言MACRO和ENDM伪指令:定义宏

定义一个宏使用的是 MACRO 和 ENDM 伪指令,其语法如下所示:

macroname MACRO parameter-1, parameter-2...
   statement-list
ENDM

关于缩进没有硬性规定,但是还是建议对 macroname 和 ENDM 之间的语句进行缩进。 同时,还希望在宏名上使用前缀 m,形成易识别的名称,如 mPutChar,mWriteString 和 mGotoxy。

除非宏被调用,否则 MACRO 和 ENDM 伪指令之间的语句不会被汇编。宏定义中还可以有多个形参,参数之间用逗号隔开。

参数

宏形参 (macro parameter) 是需传递给调用者的文本实参的命名占位符。实参实际上可能是整数、变量名或其他值,但是预处理程序把它们都当做文本。

形参不包含类型信息,因此,预处理程序不会检查实参类型来看它们是否正确。如果发生类型不匹配,它将会在宏展开之后,被汇编器捕获。

mPutChar 示例

下面宏 mPutChar 接收一个名为 char 的输入形参,通过调用本教程链接库的 WriteChar 将其显示在控制台:

mPutchar MACRO char
    push eax
    mov al,char
    call WriteChar
    pop eax
ENDM

汇编语言宏的调用简述

调用宏的方法是把宏名插入到程序中,后面可能跟有宏的实参。宏调用语法如下:

macroname argument-1, argument-2,

Macroname 必须是源代码中在此之前被定义宏的名称。每个实参都是文本值,用以替换宏的一个形参。实参的顺序要与形参一致,但是两者的数量不须相同。如果传递的实参数太 多,则汇编器会发出警告。如果传递给宏的实参数太少,则未填充的形参保持为空。

调用 mPutChar

上一节《MACRO和ENDM伪指令》中定义了宏 mPutChar。调用 mPutChar 时,可以传递任何字符或 ASCII 码。下面的语句调用了 mPutChar,并向其传递了字母 “A”:

mPutchar 'A'

汇编器的预处理程序将这条语句展开为下述代码,以列表文件的形式展开如下:

1 push eax
1 mov al,'A'
1 call WriteChar
1 pop eax

左侧的 1 表示宏展开的层次,如果在宏的内部又调用了其他的宏,那么该值将会增加。下面的循环显示了字母表中前 20 个字母:

   mov al,'A'
   mov ecx,20
L1:
   mPutchar al           ;宏调用
   inc al
   loop L1

该循环由预处理程序在下面的代码中展开(源列表文件中可见),其中,宏调用在其展开的前面:

   mov al,'A'
   mov ecx,20
L1:
   mPutchar al   ;调用宏
   1 push eax
   1 mov al,al
   1 call WriteChar
   1 pop eax
   inc al
   loop L1

提示:与过程相比,宏执行起来更快,其原因是过程的 CALL 和 RET 指令需要额外的开销。但是,使用宏也有缺点:重复使用大型宏会增加程序的大小,因为,每次调用宏都会在程序中插入宏代码的一个新副本。

调试宏

调试使用了宏的程序相当具有挑战性。程序汇编之后,检查其列表文件(扩展名为 .LST) 以确保每个宏都按照程序员的要求展开。然后,在Visual Studio 调试器中启动该程序,在调试窗口点击右键,从弹出菜单中选择Go to Disassemblyo每个宏调用的后面都紧 跟其生成代码。示例如下:

mWriteAt 15,10,"Hi there"
    push edx
    mov dh, 0Ah
    mov dl, 0Fh
    call _Gotoxy@0 (401551h)
    pop edx
    push edx
    mov edx,offset ??0000 (405004h)
    call _WriteString@0 (401D64h)
pop edx

由于 Irvine32 链接库使用的是 STDCALL 调用规范,因此函数名用下划线 (_) 开始。

汇编语言宏的特性

1) 规定形参

利用 REQ 限定符,可以指定必需的宏形参。如果被调用的宏没有实参与规定形参相匹配,那么汇编器将显示出错消息。如果一个宏有多个规定形参,则每个形参都要使用 REQ 限定符。

下面是宏 mPutChar,形参 char 是必需的:

mPutchar MACRO char:REQ
    push eax
    mov al,char
    call WriteChar
    pop eax
ENDM

2) 宏注释

宏定义中的注释行一般都出现在每次宏展开的时候。如果希望忽略宏展开时的注释,就在它们的前面添加双分号 (;;)。示例如下:

mPutchar MACRO char:REQ
    push eax             ;; 提示:char 必须包含 8 个比特
    mov al, char
    call WriteChar
    pop eax
ENDM

3) ECHO 伪指令

在程序汇编时,ECHO 伪指令写一个字符串到标准输出。下面的 mPutChar 在汇编时会显示消息“Expanding the mPutChar macro” :

mPutchar MACRO char:REQ
    ECHO Expanding the mPutchar macro
    push eax
    mov al,char
    call WriteChar
    pop eax
ENDM

Visual Studio 2012 的控制台窗口不会捕捉 ECHO 伪指令的输出,除非在编写程序时将其设置为生成详细输出。设置方法如下:从 Tool 菜单选择 Options,选择 Projects and Solutions,选择 Build and Run,再从 MSBuild project build output verbosity 下拉列表中选择 Detailed。或者打开一个命令提示符并汇编程序。

首先,执行如下命令,调整 Visual Studio 当前版本的路径:

"C:\Program Files\Microsoft Visual Studio 11.0\VC\bin\vcvars32"

然后,键入如下指令,其中 filename.asm 是程序的源代码文件名:

ml.exe /c /I "c:\Irvine" filename.asm

4) LOCAL 伪指令

宏定义中常常包含了标号,并会在其代码中对这些标号进行自引用。例如,下面的宏 makeString 声明了一个变量 string,且将其初始化为字符数组:

makestring MACRO text
    .data
    string BYTE text,0
ENDM

假设两次调用宏:

makeString "Hello"
makeString "Goodbye"

由于汇编器不允许两个标号有相同的名字,因此结果出现错误:

makeString "Hello"
1 .data
1 string BYTE "Hello",0
 makeString "Goodbye"
1 .data
1 string BYTE "Goodbye",0     ;错误!

使用 LOCAL

为了避免标号重命名带来的问题,可以对一个宏定义内的标号使用 LOCAL 伪指令。若标号被标记为 LOCAL,那么每次进行宏展开时,预处理程序就把标号名转换为唯一的标识符。下面是使用了 LOCAL 的宏 makeString:

makeString MACRO text
    LOCAL string
    .data
    string BYTE text,0
ENDM

假设和前面一样,也是两次调用宏,预处理程序生成的代码会将每个string替换成唯一 的标识符:

makeString "Hello"
1 .data
1 ??0000 BYTE "Hello",0
 makeString "Goodbye"
1 .data
1 ??0001 BYTE "Goodbye",0

汇编器生成的标号名使用了 ??nnnn 的形式,其中 nnnn 是具有唯一性的整数。local 伪指令还可以用于宏内的代码标号。

5) 包含代码和数据的宏

宏通常既包含代码又包含数据。例如,下面的宏mWrite在控制台显示文本字符串:

mWrite MACRO text
    LOCAL string            ;;local号
    .data                   ;;定义字符串
    string BYTE text,0
    .code
    push edx
    mov edx, OFFSET string
    call WriteString
    pop edx
ENDM

下面的语句两次调用宏,并向其传递不同的字符串文本:

mWrite "Please enter your first name"
mWrite "Please enter your last name"

汇编器对这两条语句进行展开时,每个字符串都被赋予了唯一的标号,且MOV指令也 作了相应的调整:

mWrite "Please enter your first name"
1 .data
1 ??0000 BYTE "Please enter your first name",0
1 .code
1 push edx
1 mov edx, OFFSET ??0000
1 call WriteString
1 pop edx
 mWrite "Please enter your last name"
1 .data
1 ??0001 BYTE "Please enter your last name", 0
1 .code
1 push edx
1 mov edx, OFFSET ??0001
1 call Writestring
1 pop edx

6) 宏嵌套

被其他宏调用的宏称为被嵌套的宏 (nested macro)。当汇编器的预处理程序遇到对被嵌套宏的调用时,它会就地展开该宏。传递给主调宏的形参也将直接传递给它的被嵌套宏。

提示:使用模块方法创建宏。保持它们的简短性,以便将它们组合到更复杂的宏内。这样有助于减少程序中的复制代码量。

【示例】下面的宏 mWritein 写一个字符串文本到控制台,并添加换行符。它调用宏 mWrite 和 Crlf 过程:

mWriteln MACRO text
    mWrite text
    call Crlf
ENDM

形参 text 被直接传递给 mWrite。假设用下述语句调用 mWriteln:

mWriteln "My Sample Macro Program"

在结果代码展开,语句旁边的嵌套级数(2)表示被调用的是一个嵌套宏:

mWriteln "My Sample Macro Program"
2  .data
2  ??0002 BYTE "My Sample Macro Program",0
2  .code
2  push edx
2  mov   edx,OFFSET ??0002
2  call WriteString
2  pop   edx
1  call Crlf

汇编语言Macro宏库详解

示例程序包含了一个小而实用的 32 位链接库,只需要在程序的 INCLUDE 后面添加如下代码行就可以使用该链接库:

INCLUDE Macros.inc

有些宏封装在了 Irvine32 链接库的过程中,这样传递参数就更加容易。其他宏则提供新的功能。下表详细介绍了每个宏。

宏名 形式参数 说明
mDump varName, useLabel 用变量名和默认属性显示一个变量
mDumpMem abbress, itemCount, componentsize 显示内存区域
mGotoxy X,Y 将光标位置设置在控制台窗口缓冲区
mReadString varName 从键盘读取一个字符串
mShow itsName, format 用各种格式显示一个变量或寄存器
mShowRegister itsName, regValue 显示32位寄存器名,并用十六进制显示其内容
mWrite text 向控制台窗口输出一个字符串文本
mWriteSpace count 向控制台窗口输出一个或多个空格
mWriteString buffer 向控制台窗口输岀一个字符串变量的内容

1) mDumpMem

宏 mDumpMem 在控制台窗口显示一个内存区域。向其传递的第一个实参为包含待显示内存偏移量的常数、寄存器或者变量,第二个实参应为待显示内存中存储对象的数量,第三个实参为每个存储对象的大小。

宏在调用mDumpMem库过程时,分别将这三个实参分配给 ESI、ECX 和 EBX。现假设有一数据定义如下:

.data
array DWORD 1000h, 2000h, 3000h, 4000h

下面的语句按照默认属性显示数组:

mDumpMem OFFSET array, LENGTHOF array, TYPE array

输出为:

Dump of offset 00405004
------------------------------
00001000 00002000 00003000 00004000

下面的语句则将同一个数组显示为字节序列:

mDumpMem OFFSET array, SIZEOF array, TYPE BYTE

输出为:

Dump of offset 00405004
------------------------------
00 10 00 00 00 20 00 00 00 30 00 00 00 40 00 00

下面的代码把三个数值压入堆栈,并设置好 EBX、ECX 和 ESI,然后调用 mDumpMem 显示堆栈:

mov eax,0AAAAAAAAh
push eax
mov eax,0BBBBBBBBh
push eax
mov eax,OCCCCCCCCh
push eax
mov ebx,1
mov ecx,2
mov esi,3
mDumpMem esp, 8, TYPE DWORD

显示出来的结果堆栈区域表明,宏已经先把 EBX、ECX 和 ESI 压入了堆栈。这些数值之后是在调用 mDumpMem 之前入栈的 3 个整数:

Dump of offset 0012FFAC
------------------------------
00000003 00000002 00000001 CCCCCCCC BBBBBBBB AAAAAAAA 7C816D4F
0000001A

实现宏代码清单如下:

mDumpMem MACRO address:REQ, itemCount:REQ, componentsize:REQ
;用 DumpMem 过程显示一个内存区域。
;接收:内存偏移量、显示对象的数量,以及每个存储对象的大小。
;避免用 EBX、ECX 和 ESI 传递实参。
    push ebx
    push ecx
    push esi
    mov esi, address
    mov ecx, itemCount
    mov ebx, componentSize
    call DumpMem
    pop esi
    pop ecx
    pop ebx
ENDM
2) mDump

宏 mDump 用十六进制显示一个变量的地址和内容。传递给它的参数有:变量名和(可选的)一个字符以表明在该变量之后应显示的标号。显示格式自动与变量的大小属性(BYTE、WORD 或 DWORD)匹配。

下面的例子展示了对 mDump 的两次调用::

.data
diskSize DWORD 12345h
.code
mDump diskSize      ; no label
mDump diskSize,Y       ; show label

代码执行后,产生的输出如下所示:

Dump of cffset 00405000
------------------------
00012345
Variable name: diskSize
Dump of offset 00405000
------------------------
00012345

下面是宏 mDump 的代码清单,它反过来又调用了 mDumpMem。代码用一个新的伪指令 IFNE (若不为空)来发现主调者是否向第二个形参传递了实参:

;-----------------------------------------------
mDump MACRO varName:REQ, useLabel
;用其已知属性显示一个变量。
;接收:varName为变量名。
;如果 useLabel 不为空,则显示变量名。
;-----------------------------------------------
    call Crlf
    IFNB <useLabel>
        mWrite "Variable name: &varName"
    ENDIF
    mDumpMem OFFSET varName, LENGTHOF varName, TYPE varName
ENDM

&varName 中的符号 & 是替换操作符,它允许将 varName 形参的值插入到字符串文本中。

3) mGotoxy

宏 mGotoxy 把光标定位在控制台窗口缓冲区内指定的行列上。可以向其传递 8 位立即数、内存操作数和寄存器值:

mGotoxy   10,20                 ;立即数
mGotoxy   row, col             ;内存操作数
mGotoxy   ch,cl                 ;寄存器值

实现 下面是宏的源代码清单:

;-----------------------------
mGotoxy MACRO X:REQ, Y:REQ
;设置光标在控制台窗口的位置。
;接收:X和Y坐标(类型为BYTE)。避免用DH和DL传递实参。
;-----------------------------
    push edx
    mov dh,Y
    mov dl,X
    call Gotoxy
    pop edx
ENDM

若宏的实参是寄存器,它们有时可能会与宏内使用的寄存器发生冲突。比如,调用 mGotoxy 时用了 DH 和 DL,那么就不会生成正确的代码。为了说明原因,现在来查看上述参数被替换后展开的代码:

1 push edx
2 mov dhr dl    ;;行
3 mov dl,dh   ;;列
4 call Gotoxy
5 pop edx

假设 DL 传递的是 Y 值,DH 传递的是 X 值,代码行 2 会在代码行 3 有机会把列值复制 到DL之前就替换了 DH的原值。

提示:只要有可能,宏定义应该用注释说明哪些寄存器不能用作实参。

4) mReadString

宏 mReadSrting 从键盘读取一个字符串,并将其存储在缓冲区。在这个宏的内部封装了一个对 ReadString 库过程的调用。需向其传递缓冲区名:

.data
firstName BYTE 30 DUP(?)
.code
mReadString firstName

下面是宏的源代码:

;-----------------------------------------   
mReadString MACRO varName:REQ
;从标准输入读到缓冲区。
;接收:缓冲区名。避免用 ECX 和 EDX 传递实参。
;-----------------------------------------   
    push ecx
    push edx
    mov edx,OFFSET varName
    mov ecx,SIZEOF varName
    call Readstring
    pop edx
    pop ecx
ENDM
5) mShow

宏 mShow 按照主调者选择的格式显示任何寄存器或变量的名字和内容。传递给它的是寄存器名,其后可选择性地加上一个字母序列,以表明期望的格式。字母选择如下:H = 十六进制,D = 无符号十进制,I 二有符号十进制,B 二二进制,N = 换行。

可以组合多种输出格式,还可以指定多个换行。默认格式为“HIN”。mShow 是一种有用的辅助调试工具,经常被 DumpRegs 库过程使用。可以把mShow当作调试工具,显示重要寄存器或变量的值。

【示例】下面的语句将 AX 寄存器的值显示为十六进制、有符号十进制、无符号十进制和二进制:

mov ax, 4096
mShow AX       ;默认选项:HTN
mShow AX,DBN   ;无符号十进制,二进制,换行

输出如下:

AX = 1000h +4096d
AX = 4096d 0001 0000 0000 0000b

【示例】下面的语句在同一行上,用无符号十进制格式显示 AX, BX, CX 和 DX:

;插入测试数值,显示4个寄存器:
mov ax, 1
mov bx, 2
mov cx, 3
mov dxz 4
mShow AX, D
mShow BX, D
mShow CX,D
mShow DX, DN

相应输出如下:AX = Id BX = 2d CX = 3d DX = 4d

【示例】下面的代码调用 mShow,用无符号十进制格式显示 mydword 的内容,并换行:

.data
mydword. DWORD ?
.code
mS how mydword,DN

实现 mShow的实现代码太长不便在这里给岀,不过可以在本书安装文件夹(C : \Irvine)内的Macros.inc文件中找到完整代码。在编写mShow时,需要注意在寄存器被宏 自身的内部语句修改之前显示其当前值。

6) mShowRegister

宏 mShowRegister 显示单个 32 位寄存器的名称,并用十六进制格式显示其内容。传递给它的是希望被显示的寄存器名,其后紧跟寄存器本身。下面的宏调用指定了被显示的名称为 EBX:

mShowRegister EBX, ebx

产生的输出如下:

EBX=7FFD9000

下面的调用使用尖括号把标号括起来,其原因是标号内有一个空格:

mShowRegister <Stack Pointer>, esp

产生输出如下:

Stack Pointer=0012FFC0

实现宏的源代码如下:

;------------------------------------
mShowRegister MACRO regName, regValue
LOCAL tempStr
;显示寄存器名和内容。
;接收:寄存器名,寄存器值
;------------------------------------
    .data
    tempStr BYTE " &regName=",0
    .code
    push eax
    ;显示寄存器名
    push edx
    mov edx,OFFSET tempStr
    call WriteString
    pop edx
    ;显示寄存器内容
    mov eax,regValue
    call WriteHex
    pop eax
ENDM

7) mWriteSpace

宏 mWriteSpace 向控制台窗口输出一个或多个空格。可以选择性地向其传递一个整数形参,以指定空格数 ( 默认为一个 )。例如,下面的语句写了 5 个空格:

mWriteSpace 5

实现mWriteSpace的源代码如下:

;-------------------------------------------
mWriteSpace MACRO count:=<1>
;向控制台窗口输出一个或多个空格。
;接收:一个整数以指定空格数。
;默认个数为l。
;-------------------------------------------
LOCAL spaces
.data
spaces BYTE count DUP('    '),0
.code
    push edx
    mov edx,OFFSET spaces
    call WriteString
    pop edx
ENDM

8) mWriteString

宏 mWriteSrting 向控制台窗口输出一个字符串变量的内容。从宏的内部来看,它通过在同一语句行上传递字符串变量名简化了对 WriteString的调用。例如:

.data
str1 BYTE "Please enter your name: ", 0
.code
mWriteString str1

mWriteString 的实现如下,它将 EDX 保存到堆栈,然后把字符串偏移量赋给 EDX,在过程调用后,再从堆栈恢复 EDX 的值:

;------------------------------
mWriteString MACRO buffer:REQ
;向标准输出写一个字符串变量。
;接收:字符串变量名。
;------------------------------
    push edx
    mov    edx,OFFSET buffer
    call WriteString
    pop edx
ENDM

汇编语言实例:封装器

现在创建一个简短的程序 Wraps.asm 来展示之前已介绍的作为过程封装器的宏。由于每个宏都隐含了大量繁琐的参数传递,因此程序出奇得紧凑。假设这里所有的宏当前都在 Macros.inc 文件内:

; 过程封装器宏        (Wraps.asm)
; 本程序演示宏作为库过程的封装器。
; 内容: mGotoxy, mWrite, mWriteString, mReadString, 和 mDumpMem.
INCLUDE Irvine32.inc
INCLUDE Macros.inc            ; 宏定义
.data
array DWORD 1,2,3,4,5,6,7,8
firstName BYTE 31 DUP(?)
lastName  BYTE 31 DUP(?)
.code
main PROC
    mGotoxy 0,0
    mWrite <"Sample Macro Program",0dh,0ah>
; 输入用户名
    mGotoxy 0,5
    mWrite "Please enter your first name: "
    mReadString firstName
    call Crlf
    mWrite "Please enter your last name: "
    mReadString lastName
    call Crlf
; 显示用户名
    mWrite "Your name is "
    mWriteString firstName
    mWriteSpace
    mWriteString lastName
    call Crlf
; 显示整数数组
    mDumpMem OFFSET array,LENGTHOF array, TYPE array
    exit
main ENDP
END main

程序输出 程序输出的示例如下:

汇编语言条件汇编伪指令简述

很多不同的条件汇编伪指令都可以和宏一起使用,这使得宏更加灵活。条件汇编伪指令常用语法如下所示:

IF condition
  statements
[ELSE
  statements]
ENDIF

下表列出了更多常用的条件汇编伪指令。若说明为该伪指令允许汇编,就意味着所有的后续语句都将被汇编,直到遇到下一个 ELSE 或 ENDIF 伪指令。必须强调的是,表中列出的伪指令是在汇编时而不是运行时计算。

伪指令 说明
IF expression 若 expression 为真(非零)则允许汇编。可能的关系运算符为 LT、GT、EQ、NE、LE 和 GE
IFB 若 argument 为空则允许汇编。实参名必须用尖括号(<>)括起来
IFNB 若 argument 为非空则允许汇编。实参名必须用尖括号(<>)括起来
IFIDN, 若两个实参相等(相同)则允许汇编。采用区分大小写的比较
IFIDNI, 若两个实参相等(相同)则允许汇编。采用不区分大小写的比较
IFDIF, 若两个实参不相等则允许汇编。采用区分大小写的比较
IFDIFI, 若两个实参不相等则允许汇编。采用不区分大小写的比较
IFDIF name 若 name 已定义则允许汇编
IFNDEF name 若 name 还未定义则允许汇编
ENDIF 结束用一个条件汇编伪指令开始的代码块
ELSE 若条件为真,则终止汇编之前的语句。若条件为假,ELSE 汇编语句直到遇到下一个 ENDIF
ELSEIF expression 若之前条件伪指令指定的条件为假,而当前表达式为真,则汇编全部语句直到出现 ENDIF
EXITM 立即退出宏,阻止所有后续宏语句的展开

汇编语言IFB和IFNB伪指令:检查缺失的参数

宏能够检查其参数是否为空。通常,宏若接收到空参数,则预处理程序在进行宏展开时会导致出现无效指令。例如,如果调用宏 mWriteString 却又不传递实参,那么宏展开在把字符串偏移量传递给 EDX 时,就会出现无效指令。

汇编器生成的如下语句检测出缺失的操作数,并产生了一个错误消息:

mWriteString
1 push edx
1 mov edx,OFFSET
Macro2.asm(18) : error A2081: missing operand after unary operator
1 call WriteString
1 pop edx

为了防止由于操作数缺失而导致的错误,可以使用 IFB (if blank) 伪指令,若宏实参为空,则该伪指令返回值为真。

还可以使用 IFNB (if not blank) 运算符,若宏实参不为空,则其返回值为真。现在编写 mWriteString 的另一个版本,使其可以在汇编时显示错误消息:

mWriteString MACRO string
    IFB <string>
        ECHO -------------------------------------------
        ECHO * Error: parameter missing in mWriteString
        ECHO *  (no code generated)
        ECHO -------------------------------------------
    EXITM
    ENDIF
    push edx
    mov edx,OFFSET string
    call WriteString
    pop edx
ENDM

程序汇编时,ECHO 伪指令向控制台写一个消息。EXITM 伪指令告诉预处理程序退岀宏,不再展开更多宏语句。汇编的程序有缺失参数时,其屏幕输出如下所示:

汇编语言宏默认值设定及布尔表达式简述

宏可以有默认参数初始值。如果调用宏出现了宏参数缺失,那么就可以使用默认参数。其语法如下:

paramname := < argument >

运算符前后的空格是可选的。比如,宏 mWriteln 提供含有一个空格的字符串作为其默认参数。如果对其进行无参数调用,它仍然会打印一个空格并换行:

mWriteln MACRO text:=<" ">
  mWrite text
  call Crlf
ENDM

若把空字符串 (“ “) 作为默认参数,那么汇编器会产生错误,因此必须在引号之间至少插入一个空格。

布尔表达式

汇编器允许在包含 IF 和其他条件伪指令的常量布尔表达式中使用下列关系运算符:

LT 小于
GT 大于
EQ 等于
NE 不等于
LE 小于等于
GE 大于等于

汇编语言IF、ELSE和DENDIF伪指令

IF 伪指令的后面必须跟一个常量布尔表达式。该表达式可以包含整数常量、符号常量或者常量宏实参,但不能包含寄存器或变量名。仅适用于 IF 和 ENDIF 的语法格式如下:

IF expression
   statement-list
ENDIF

另一种格式则适用于 IF、ELSE 和 ENDIF:

IF expression
   statement-list
ELSE
   statement-list
ENDIF

【示例】宏 mGotoxyConst 利用 LT 和 GT 运算符对传递给宏的参数进行范围检查。实参 X 和 Y 必须为常数。还有一个常数符号 ERRS 对发现的错误进行计数。根据 X 的值,可以将 ERRS 设置为 1。根据 Y 的值,可以将 ERRS 加 1。最后,如果 ERRS 大于零,EXITM 伪指令退岀宏:

;-------------------------------------
mGotoxyConst MACRO X:REQ, Y:REQ
;
;将光标位置设置在 X 列 Y 行。
;要求 X 和 Y 的坐标为常量表达式
;其范围为 0 ≤ X < 80, 0 ≤ Y < 25。
;-------------------------------------
    LOCAL ERRS               ;;门局部常量
    ERRS = 0
    IF (X LT 0) OR (X GT 79)
        ECHO Warning: First argument to mGotoxy (X) is out of range.
        ECHO ******************************************************
        ERRS = 1
    ENDIF
    IF (Y LT 0) OR (Y GT 24)
        ECHO Warning: Second argument to mGotoxy (Y) is out of range.
        ECHO ******************************************************
        ERRS = ERRS + 1
    ENDIF
    IF ERRS GT 0                 ;;若发现错误,
        EXITM                    ;;退出宏
    ENCIF
    push edx
    mov dh,Y
    mov dl,X
    call Gotoxy
    pop edx
ENDM

汇编语言IFIDN和IFIDNI伪指令:对两个参数进行比较

IFIDNI 伪指令在两个符号(包括宏参数名)之间进行不区分大小写的比较,如果它们相等,则返回真。IFIDN 伪指令执行的是区分大小写的比较。

如果想要确认宏主调者使用的寄存器参数不会与宏内使用的寄存器发生冲突,那么可以使用这两个伪指令中的前者。IFIDNI 的语法如下:

IFIDNI <symbol>, <symbol>
   statements
ENDIF

IFIDN 的语法与之相同。例如下面的宏 mReadBuf,其第二个参数不能用 EDX,因为当 buffer 的偏移量被送入 EDX 时,原来的值就会被覆盖。

在如下修改过的宏代码中,如果这个条件不满足,就会显示一条警告消息:

;-------------------------------------
mReadBuf MACRO bufferPtr, maxChars
;将键盘输入读到缓冲区。
;接收:缓冲区偏移量,最多可输入字符的数量。第二个参数不能用 edx 或EDX。
;-------------------------------------
    IFIDNI <maxChars>,<EDX>
        ECHO Warning: Second argument to mReadBuf cannot be EDX
        ECHO **************************************************
        EXITM
    ENDIF
    push ecx
    push edx
    mov edx,bufferPtr
    mov ecx,maxChars
    call Readstring
    pop edx
    pop ecx
ENDM

下面的语句将会导致宏产生警告消息,因为 EDX 是其第二个参数:

mReadBuf OFFSET buffer,edx

汇编语言实例:矩阵行求和

前面已经展示了如何计算字节矩阵中单个行的总和。尽管这个解决方案有些冗长,现在还是要看看能否用宏来简化任务。首先,我们来回顾一下 calc_row_sum 过程:

;------------------------------------------------------------
; calc_row_sum
; 计算字节矩阵中一行的和数
; 接收: EBX = 表偏移量, EAX = 行索引
;       ECX = 按字节计的行大小
; 返回:  EAX 为和数
;------------------------------------------------------------
calc_row_sum PROC uses ebx ecx edx esi
    mul     ecx                             ; 行索引 * 行大小
    add     ebx,eax                         ; 行偏移量
    mov     eax,0                           ; 累加器
    mov     esi,0                           ; 列索引
L1:    movzx edx,BYTE PTR[ebx + esi]        ; 取一个字节
    add     eax,edx                         ; 与累加器相加
    inc     esi                             ; 行中的下一个字节
    loop L1
    ret
calc_row_sum ENDP

从把 PROC 改为 MACRO 开始,删除 RET 指令,把 ENDP 改为 ENDM。由于没有宏与 USES 伪指令功能相当,因此插入 PUSH 和 POP 指令:

mCalc_row_sum MACRO
    push ebx              ;保存被修改的寄存器
    push ecx
    push esi
    mul ecx               ;行索引x行大小
    add ebx,eax           ;行偏移量
    mov eax,0             ;累加器
    mov esi,0             ;列索引
L1: movzx edx,BYTE PTR[ebx + esi]
    add eax, edx          ;取一个字节
    inc esi               ;与累加器相加
    loop L1               ;行内的下一个字节
    pop esi               ;恢复被修改的寄存器
    pop ecx
    pop ebx
ENDM

接着,用宏参数代替寄存器参数,并对宏内寄存器进行初始化:

mCalc_row_sum MACRO index, arrayoffset, rowSize
    push ebx                       ;保存被修改的寄存器
    push ecx
    push esi
;设置需要的寄存器
    mov eax,index
    mov ebx,arrayOffset
    mov ecx,rowSize
    mul ecx                        ;行索引x行大小
    add ebx,eax                    ;行偏移量
    mov eax,0                      ;累加器
    mov esi,0                      ;列索引
L1: movzx edx, BYTE PTR[ebx + esi] ;取一个字节
    add eax, edx                   ;与累力口器相力口
    inc esi                        ;行内的下一个字节
    loop L1
    pop esi                        ;恢复被修改的寄存器
    pop ecx
    pop ebx
ENDM

然后,添加一个参数 eltType 指定数组类型 (BYTE、WORD 或 DWORD):

mCalc_row_sum MACRO index, arrayOffset, rowSize, eltType

复制到 ECX 的参数 rowSize 现在表示的是每行的字节数。如果要用其作为循环计数器,那么它就必须转换为每行的元素 (element) 个数。

因此,若为 16 位数组,就将 ECX 除以 2 ;若为双字数组,就将 ECX 除以 4。实现上述操作的快捷方式为:eltType 除以 2,把商作为移位计数器,再将 ECX 右移:

shr ecx,(TYPE eltType/2)         ; byte=0, word=1, dword=2

TYPE eltType 就成为 MOVZX 指令中基址-变址操作数的比例因子:

movzx edx,eltType PTR[ebx + esi*(TYPE eltType)]

若 MOVZX 右操作数为双字,那么指令不会汇编。所以,当 eltType 为 DWORD 时,需要用 IFIDNI 运算符另外编写一条 MOV 指令:

IFIDNI <eltType>,<DWORD>
   mov edx,eltType PTR[ebx + esi*(TYPE eltType)]
ELSE
   movzx edx, eltType PTR[ebx + esi*(TYPE eltType)]
ENDIF

最后必须结束宏,记住要把标号 L1 指定为 LOCAL:

;-----------------------------------------------------
mCa1c_row_sum MACRO index, arrayOffset, rowSize, eltType
;计算二维数组中一行的和数。
;接收:行索引、数组偏移量、每行的字节数、数组类型 (BYTE、WORD、或 DWORD)。
;返回:EAX= 和数。
;-----------------------------------------------------
LOCAL L1
    push ebx                    ;保存被修改的寄存器
    push ecx
    push esi
;设置需要的寄存器
    mov eax, index
    mov ebx, arrayOffset
    mov ecx, rowSize
;计算行偏移量
    mul ecx                      ;行索引x行大小
    add ebx, eax                 ;行偏移量
;初始化循环计数器
    shr ecx,(TYPE eltType/2)     ;byte=0, word=1, dword=2
;初始化累加器和列索引
    mov eax,0                    ;累加器
    mov esi,0                    ;列索引
L1:
    IFIDNI <eltType>, <DWORD>
        mov edx,eltType PTR[ebx + esi*(TYPE eltType)]
    ELSE
        movzx edx,eltType PTR[ebx + esi*(TYPE eltType)]
    ENDIF
    add eax,edx                  ;与累加器相加
    inc esi
    loop L1
    pop esi                      ;恢复被修改的寄存器
    pop ecx
    pop ebx
ENDM

下面用字节数组、字数组和双字数组对宏进行示例调用:

.data
tableB BYTE 10h, 20h, 30h, 40h, 50h
RowSizeB = ($ - tableB)
    BYTE 60h, 70h, 80h, 90h, 0A0h
    BYTE 0B0h, 0C0h, 0D0h, 0E0h, 0F0h
tableW WORD 10h, 20h, 30h, 40h, 50h
RowSizeW = ($ - tableW)
    WORD 60h, 70h, 80h, 90h, 0A0h
    WORD 0B0h, 0C0h, 0D0h, 0E0h, 0F0h
tableD DWORD 10h, 20h, 30h, 40h, 50h
RowSizeD = ($ - tableD)
    DWORD 60h, 70h, 80h, 90h, 0A0h
    DWORD 0B0h, 0C0h, 0D0h, 0E0h, 0F0h
index DWORD ?
.code
mCalc_row_sum index, OFFSET tableB, RowSizeB, BYTE
mCalc_row_sum index, OFFSET tableW, RowSizeW, WORD
mCalc_row_sum index, OFFSET tableD, RowSizeD, DWORD

汇编语言替换(&)、文本(<>)、字符(!)、展开(%)运算符简述

下述四个汇编运算符使得宏更加灵活:

& 替换运算符
<> 文字文本运算符
! 文字字符运算符
% 展开运算符

替换运算符(&)

替换运算符(&)解析对宏参数名的有歧义的引用。宏 mShowRegister 显示了一个 32 位寄存器的名称和十六进制的内容。示例调用如下:

.code
mShowRegister ECX

下面是调用 mShowRegister 产生的示例输出:

ECX=00000101

在宏内可以定义包含寄存器名的字符串变量:

mShowRegister MACRO regName
.data
tempStr BYTE "regName=",0

但是预处理程序会认为 regName 是字符串文本的一部分,因此,不会将其替换为传递给宏的实参值。相反,如果添加了 & 运算符,它就会强制预处理程序在字符串文本中插入宏实参 ( 如 ECX)。下面展示的是如何定义 tempStr:

mShowRegister MACRO regName
.data
tempStr BYTE "&regName=",0

展开运算符(%)

展开运算符(%)展开文本宏并将常量表达式转换为文本形式。有几种方法实现该功能。若使用的是 TEXTEQU,% 运算符就计算常量表达式,再把结果转换为整数。

在下面的例子中,% 运算符计算表达式 (5+count),并返回整数 15 ( 以文本形式 ):

count = 10
sumVal TEXTEQU %(5 + count)           ;="15"

如果宏请求的实参是整数常量,% 运算符就能使程序具有传递一个整数表达式的灵活性。计算这个表达式得到结果值,然后将这个值传递给宏。例如,调用 mGotoxyConst 时,计算表达式的结果分别为 50 和 7:

mGotoxyConst %(5 * 10), %(3 + 4)

预处理程序将产生如下语句:

1 push edx
1 mov dh,7
1 mov dl,50
1 call Gotoxy
1 pop edx

% 在一行的首位

当展开运算符 (%) 是一行源代码的第一个字符时,它指示预处理程序展开该行上的所有文本宏和宏函数。比如,假设想在汇编时将数组大小显示在屏幕上。下面的尝试不会产生期望的结果:

.data
array DWORD 1,2,3,4,5,6,7,8
.code
ECHO The array contains (SIZEOF array) bytes
ECHO The array contains %(SIZEOF array) bytes

屏幕输出没什么用:

The array contains (SIZEOF array) bytes
The array contains %(SIZEOF array) bytes

反之,如果用 TEXTEQU 编写包含 (SIZEOF array) 的文本宏,那么该宏就可以展开为之后的代码行:

TempStr TEXTEQU %(SIZEOF array)
%  ECHO The array contains TempStr bytes

产生的输出如下所示:

The array contains 32 bytes

显示行号

下面的宏 Mul32 将它前两个实参相乘,乘积由第三个实参返回。其形参可以是寄存器、内存操作数和立即数 ( 乘积除外 ):

Mul32 MACRO op1, op2, product
    IFIDNI <op2>,<EAX>%
        LINENUM TEXTEQU %(@LINE)
        ECHO ----------------------------------------------
%       ECHO * Error on line LINENUM: EAX cannot be the second
        ECHO * argument when invoking the MUL32 macro.
        ECHO ----------------------------------------------
    EXITM
    ENDIF
    push eax
    mov eax,op1
    mul op2
    mov product,eax
    pop eax
ENDM

Mul32 要检查的一个重要要求是:EAX 不能作为第二个实参。这个宏有趣的地方是,它显示的是其调用者的行号,这样更加易于追踪并解决问题。首先定义文本宏 LINENUM,它引用的 @LINE 是一个预先定义的汇编运算符,其功能为返回当前源代码行的编号:

LINENUM TEXTEQU % ((@LINE)

接着,在含有 ECHO 语句的代码行第一列上的展开运算符 (%) 使得 LINENUM 被展开:

%  ECHO * Error on line LINENUM: EAX cannot be the second

假设如下宏调用发生在程序的 40 行:

MUL32 val1, eax,val3

那么,汇编时将显示如下信息:

文字文本运算符(<>)

文字文本(literal-text)运算符(<>)把一个或多个字符和符号组合成一个文字文本,以防止预处理程序把列表中的成员解释为独立的参数。

在字符串含有特殊字符时该运算符非常有用,比如逗号、百分号(%)、和号(&)以及分号(;),这些符号既可以被解释为分隔符,又可以被解释为其他的运算符。例如,之前给岀的宏 mWrite 接收一个字符串文本作为其唯一的实参。如果传递的字符串如下所示,预处理程序就会将其解释为3个独立的实参:

mWrite "Line three", 0dh, 0ah

第一个逗号后面的文本会被丢弃,因为宏只需要一个实参。然而,如果用文字文本运算 符将字符串括起来,那么预处理程序就会把尖括号内所有的文本当作一个宏实参:

mWrite <"Line three", 0dh, 0ah>

文字字符运算符(!)

构造文字字符(literal-character)运算符(!)的目的与文字文本运算符的几乎完全一样:强制预处理程序把预先定义的运算符当作普通的字符。在下面的 TEXTEQU 定义中,运算符 ! 可以防止符号 > 被当作文本分隔符:

BadYValue TEXTEQU <Warning: Y-coordinate is !> 24>

警告信息示例

下面的例子有助于说明运算符 %、& 和 ! 是如何工作的。假设已经定义了符号 BadYValue。现在创建一个宏 ShowWarning,接收一个用引号括起来的文本实参,并将其传递给宏 mWrite。注意替换(&)运算符的用法:

ShowWarning MACRO message
   mWrite "&message"
ENDM

然后调用 ShowWarning,把表达式 %BadYValue 传递给它。% 运算符计算(解析) BadYValue,并生成与之等价的字符串:

.code
ShowWarning %BadYValue

正如所期望的,程序运行并显示警告信息:Warning: Y-coordinate is > 24

汇编语言宏函数

宏函数与宏过程有相似的地方,它也为汇编语言语句列表分配一个名称。不同的地方在于,宏函数通过 EXITM 伪指令总是返回一个常量(整数或字符串)。如下例所示,如果给定符号已定义则宏 IsDefined 返回真(-1);否则返回假(0):

IsDefined MACRO symbol
    IFDEF symbol
        EXITM <-l>     ;; 真
    ELSE
        EXITM <0>      ;; 假
    ENDIF
ENDM

EXITM (退出宏)伪指令终止了所有后续的宏展开。

调用宏函数

调用宏函数时,它的实参列表必须用括号括起来。比如,调用宏 IsDefined 并传递 RealMode (一个可能已定义也可能还未定义的符号名):

IF IsDefined(RealMode)
   mov ax, @data
   mov ds, ax
ENDIF

如果在汇编过程中,汇编器在此之前已经遇到过对 RealMode 的定义,那么它就会汇编这两条指令:

mov ax,@data
mov ds,ax

同样的 IF 伪指令可以被放在名为 Startup 的宏内:

Startup MACRO
    IF IsDefined(RealMode)
        mov ax,@data
        mov ds,ax
    ENDIF
ENDM

像 IsDefined 这样的宏可以用于设计多种内存模式的程序。比如,可以用它来决定使用哪种头文件:

IF IsDefined(RealMode)
    INCLUDE Irvine16.inc
ELSE
    INCLUDE Irvine32.inc
ENDIF

定义 RealMode 符号

剩下的任务就只是找到定义 RealMode 符号的方法。方法之一是把下面的代码行放在程序开始的位置:

RealMode = 1

或者,汇编器命令行也有选项来定义符号,即,使用 -D。下面的 ML 命令行定义了 RealMode 符号并为其赋值 1:

ML -c -DRealMode=l myProg.asm

而保护模式程序中相应的 ML 命令就没有定义 RealMode 符号:

ML -c myProg.asm

HelioNew 程序

下面的程序 (HelloNew.asm) 使用刚才介绍的宏,在屏幕上显示了一条消息:

; 宏函数    (HelloNew.asm)
INCLUDE Macros.inc
IF IsDefined( RealMode )
    INCLUDE Irvine16.inc
ELSE
    INCLUDE Irvine32.inc
ENDIF
.code
main PROC
    Startup
    mWrite <"This program can be assembled to run ",0dh,0ah>
    mWrite <"in both Real mode and Protected mode.",0dh,0ah>
    exit
main ENDP
END main

16 位实模式程序运行于模拟的 MS-DOS 环境中,使用的是 Irvine16.inc 头文件和 Irvine16 链接库。

汇编语言定使用WHILE、REPEAT、FOR 和 FORC伪指令定义重复语句块

MASM 有许多循环伪指令用于生成重复的语句块:WHILE、REPEAT、FOR 和 FORC。与 LOOP 指令不同,这些伪指令只在汇编时起作用,并使用常量值作为循环条件和计数器:

  • WHILE 伪指令根据一个布尔表达式来重复语句块。

  • REPEAT 伪指令根据计数器的值来重复语句块。

  • FOR 伪指令通过遍历符号列表来重复语句块。

  • FORC 伪指令通过遍历字符串来重复语句块。

WHILE 伪指令

WHILE 伪指令重复一个语句块,直到特定的常量表达式为真。其语法如下:

WHILE constExpression
   statements
ENDM

下面的代码展示了如何在 1 到 F000 0000h 之间生成斐波那契 (Fibonacci) 数,作为汇编时常数序列:

.data
val1 = 1
val2 = 1
DWORD val1                   ;前两个值
DWORD val2
val3 = val1 + val2
WHILE val3 LT 0F0000000h
    DWORD val3
    val1 = val2
    val2 = val3
    val3 = val1 + val2
ENDM

REEPEAT 伪指令

在汇编时,REPEAT 伪指令将一个语句块重复固定次数。其语法如下:

REPEAT constExpression
   statements
ENDM

constExpression 是一个无符号整数常量表达式,用于确定重复次数。

在创建数组时,REPEAT 的用法与 DUP 类似。在下面的例子中,WeatherReadings 结构含有一个地点字符串和一个包含了降雨量与湿度读数的数组:

WEEKS_PER_YEAR = 52
    WeatherReadings STRUCT
    location BYTE 50 DUP(0)
    REPEAT WEEKS_PER_YEAR
        LOCAL rainfall, humidity
        rainfall DWORD ?
        humidity DWORD ?
    ENDM
WeatherReadings ENDS

由于汇编时循环会对降雨量和湿度重定义,使用 LOCAL 伪指令可以避免因其导致的错误。

FOR 伪指令

FOR 伪指令通过迭代用逗号分隔的符号列表来重复一个语句块。列表中的每个符号都会引发循环的一次迭代过程。其语法如下:

FOR parameter,<arg1,arg2,arg3,...>
   statements
ENDM

第一次循环迭代时,parameter 取 arg1 的值,第二次循环迭代时,parameter 取 arg2 的值; 以此类推,直到列表的最后一个实参。

【示例】现在创建一个学生注册的场景,其中,COURSE 结构含有课程编号和学分值;SEMESTER 结构包含一个有 6 门课程的数组和一个计数器 NumCourses:

COURSE STRUCT
    Number BYTE 9 DUP(?)
    Credits BYTE ?
COURSE ENDS
;semester 含有一个课程数组。
SEMESTER STRUCT
    Courses COURSE 6 DUP(<>)
    NumCourses WORD ?
SEMESTER ENDS

使用 FOR 循环可以定义 4 个 SEMESTER 对象,每一个对象都从由尖括号括起的符号列表中选择一个不同的名称:

.data
   FOR semName,<Fall2013, Spring2014, Summer2014, Fall2014>
   semName SEMESTER <>
ENDM
如果查看列表文件就会发现如下变量:
.data
Fall2013 SEMESTER <>
Spring2014 SEMESTER <>
Summer2014 SEMESTER <>
Fall2014 SEMESTER <>

FORC 伪指令

FORC 伪指令通过迭代字符串来重复一个语句块。字符串中的每个字符都会引发循环的一次迭代过程。其语法如下:

FORC parameter, <string>
   statements
ENDM

第一次循环迭代时,parameter 等于字符串的第一个字符,第二次循环迭代时,parameter 等于字符串的第二个字符;以此类推,直到最后一个字符。

下面的例子创建了一个字符查找表,其中包含了一些非字母字符。注意,< 和 > 的前面必须有文字字符(!)运算符,以防它们违反FORC伪指令的语法:

Delimiters LABEL BYTE
FORC code, <@#$%^&*!<!>>
   BYTE "&code"
ENDM

生成的数据表如下所示,可以在列表文件中查看:

00000000 40 1 BYTE "@"
00000001 23 1 BYTE "#"
00000002 24 1 BYTE "$"
00000003 25 1 BYTE "%"
00000004 5E 1 BYTE "^"
00000005 26 1 BYTE "&"
00000006 2A 1 BYTE "*"
00000007 3C 1 BYTE "<"
00000008 3E 1 BYTE ">"

示例:链表

结合结构声明与 REPEAT 伪指令以指示汇编器创建一个链表的数据结构是相当简单的。链表中的每个节点都含有一个数据域和一个链接域:

在数据域中,一个或多个变量可以保存每个节点所特有的数据。在链接域中,一个指针包含了链表下一个节点的地址。最后一个节点的链接域通常是一个空指针。现在编写程序创建并显示一个简单链表。首先,程序定义一个节点,其中含有一个整数(数据)和一个指向下一个节点的指针:

ListNode STRUCT
   NodeData DWORD ?   ;节点的数据
   NextPtr DWORD ?       ;指向下一个节点的指针
ListNode ENDS

接着 REPEAT 伪指令创建了 ListNode 对象的多个实例。为了便于测试,NodeData 域含有一个整数常量,其范围为 1〜15,在循环内部,计数器加 1 并将值插入到 ListNode 域:

TotalNodeCount = 15
NULL = 0
Counter = 0
.data
LinkedList LABEL PTR ListNode
REPEAT TotalNodeCount
    Counter = Counter + 1
    ListNode <Counter, ($ + Counter * SIZEOF ListNode)>
ENDM

表达式 ($+Counter*SIZEOF ListNode) 告诉汇编器把计数值与 ListNode 的大小相乘,并将乘积与当前地址计数器相加。结果值插入结构内的 NextPtr 域。[注意一个有趣的现象:位置计数器的值 ($) 固定在表的第一节点上。]该表用尾节点 (tail node) 来标记末尾,其 NextPtr 域为空 (0):

ListNode <0,0>

当程序遍历该表时,它用下面的语句检索 NextPtr 域,并将其与 NULL 比较,以检查是否为表的末尾:

mov eax,(ListNode PTR [esi]).NextPtr
cmp eax,NULL

程序清单

完整的程序清单如下所示。在 main 中,一个循环遍历链表并显示全部的节点值。与使用固定计数值控制循环相比,程序检查是否为尾节点的空指针,若是则停止循环:

; 创建一个链表            (List.asm)
INCLUDE Irvine32.inc
ListNode STRUCT
  NodeData DWORD ?
  NextPtr  DWORD ?
ListNode ENDS
TotalNodeCount = 15
NULL = 0
Counter = 0
.data
LinkedList LABEL PTR ListNode
REPT TotalNodeCount
    Counter = Counter + 1
    ListNode <Counter, ($ + Counter * SIZEOF ListNode)>
ENDM
ListNode <0,0>    ; tail node
.code
main PROC
    mov  esi,OFFSET LinkedList
; 显示 NodeData 域的值
NextNode:
    ; 检查是否为尾节点
    mov  eax,(ListNode PTR [esi]).NextPtr
    cmp  eax,NULL
    je   quit
    ; 显示节点数据
    mov  eax,(ListNode PTR [esi]).NodeData
    call WriteDec
    call Crlf
    ; 获取下一个节点的指针
    mov  esi,(ListNode PTR [esi]).NextPtr
    jmp  NextNode
quit:
    exit
main ENDP
END main

汇编语言MS-Windows编程

汇编语言MS-Windows编程简述

一个 Windows 应用程序开始的时候,要么创建一个控制台窗口,要么创建一个图形化窗口。本教程的项目文件一直把如下选项与 LINK 命令一起使用。它告诉链接器创建一个基于控制台的应用程序:

/SUBSYSTEM:CONSOLE

控制台程序的外观和操作就像 MS-DOS 窗口,它的一些改进部分将在后面进行介绍。控制台有一个输入缓冲区以及一个或多个屏幕缓冲区:

1) 输入缓冲区(input buffer):

包含一组输入记录(input records),其中的每个记录都是一个输入事件的数据。输入事件的例子包括键盘输入、鼠标点击,以及用户调整控制台窗口大小。

2) 屏幕缓冲区(screen buffer):

是字符与颜色数据的二维数组,它会影响控制台窗口文本的外观。

Win32 API 参考信息

函数

在接下来的讲解中将介绍 Win32 API 函数的子集并给岀一些简单的例子。由于篇幅的限制,将不会涉及很多细节。如果想了解更多信息,请访问 Microsoft MSDN 网站(地址为:www.msdn.microsoft.com)。在搜索函数或标识符时,把 Filtered by 参数设置为 Platform SDK。

常数

阅读 Win32 API 函数的文档时,常常会见到常量名,如 TIME_ZONE_ID_UNKNOWN。少数情况下,这些常量已经在 SmallWin.inc 中定义过。例如,头文件 WinNT.h 就定义了 TIME_ZONE_ID_UNKNOWN 及相关常量:

#define TIME_ZONE_ID_UNKNOWN 0
#define TIME_ZONE_ID_STANDARD 1
#define TIME_ZONE_ID_DAYLIGHT 2

利用这个信息,可以将下述语句添加到 SmallWin.h 或者用户自己的头文件中:

TIME_ZONE_ID_UNKNOWN = 0
TIME_ZONE_ID_STANDARD = 1
TIME_ZONE_ID_DAYLIGHT = 2

字符集和 Windows API 函数

调用 Win32 API 函数时会使用两类字符集:8 位的 ASCII/ANSI 字符集和 16 位的 Unicode 字符集(所有近期的 Windows 版本中都有)。

Win32 函数可以处理的文本通常有两种版本:一种以字母 A 结尾(8 位 ANSI 字符),另一种以 W 结尾(宽字符集,包括了 Unicode)。WriteConsole 即为其中之一:

WriteConsoleA

WriteConsoleW

Windows95 和 98 不支持以 W 结尾的函数名。另一方面,在所有近期的 Windows 版本中,Unicode 都是原生字符集。例如调用名为 WriteConsoleA 的函数,则操作系统将把字符从 ANSI 转换为 Unicode,再调用 WriteConsoleW。

在 Microsoft MSDN 链接库的函数文件中,如 WriteConsole,尾字符 A 和 W 都被省略了。

WriteConsole EQU

这个定义使得程序能按 WriteConsole 的通用名对其进行调用。

高级别和低级别访问

控制台访问有两个级别,这就能够在简单控制和完全控制之间进行权衡:

  • 高级别控制台函数从控制台输入缓冲区读取字符流,并将字符数据写入控制台的屏幕缓冲区。输入和输出都可以重定向到文本文件。

  • 低级别控制台函数检索键盘和鼠标事件,以及用户与控制台窗口交互 ( 拖曳、调整大小等 ) 的详细信息。这些函数还允许对窗口大小、位置以及文本颜色进行详细控制。

Windows 数据类型

Win32 函数使用 C/C++ 程序员的函数声明进行记录。在这些声明中,所有函数参数类型要么基于标准 C 类型,要么基于 MS-Windows 预定义类型 (下表中列出了部分类型 ) 之一。

MS-Windows 类型 MASM类型 说明
BOOL, BOOLEAN DWORD 布尔值 (TRUE 或 FALSE)
BYTE BYTE 8 位无符号整数
CHAR BYTE 8 位 Windows ANSI 字符
COLORREF DWORD 作为颜色值的 32 位数值
DWORD DWORD 32 位无符号整数
HANDLE DWORD 对象句柄
HFILE DWORD 用 OpenFile 打开的文件的句柄
INT SDWORD 32 位有符号整数
LONG SDWORD 32 位有符号整数
LPARAM DWORD 消息参数,由窗口过程和回调函数使用
LPCSTR PTR BYTE 32 位指针,指向由 8 位 Windows (ANSI)字符组成的空字节结束的字符串常量
LPCVOID DWORD 指向任何类型的常量
LPSTR PTR BYTE 32 位指针,指向由 8 位 Windows (ANSI) 字符组成的空字节结束的字符串
LPCTSTR PTR WORD 32 位指针,指向对 Unicode 和双字节字符集可移植的字符串常量
LPTSTR PTR WORD 32 位指针,指向对 Unicode 和双字节字符集可移植的字符串
LPVOID DWORD 32 位指针,指向未指定类
LRESULT DWORD 窗口过程和回调函数返回的 32 位数值
SIZE_T DWORD 一个指针可以指向的最大字节数
UNIT DWORD 32 位无符号整数
WNDPROC DWORD 32 位指针,指向窗口过程
WORD WORD 16 位无符号整数
WPARAM DWORD 作为参数传递给窗口过程或回调函数的 32 位数值

区分数据值和指向值的指针是很重要的。以字母 LP 开头的类型名是长指针 (long pointer),指向其他对象。

SmallWin.inc 头文件

SmallWin.inc 是一个头文件,其中包含了 Win32 API 编程的常量定义、等价文本以及函数原型。通过本教程一直使用的 Irvine32.inc,SmallWin.inc 被自动包含在程序中。

大多数常量都可以在用于 C 和 C++ 编程的头文件 Windows.h 中找到。与它的名字不同,SmallWin.inc 文件相当大, 因此这里只展示其突出部分:

DO_NOT_SHARE = 0
NULL = 0
TRUE = 1
FALSE = 0
;Win32 控制台句柄
STD_INPUT_HANDLE EQU -10
STD_OUTPUT_HANDLE EQU -11
STD_ERROR_HANDLE EQU -12

类型 HANDLE 是 DWORD 的代名词,能帮助函数原型与 Microsoft Win32 文档更加一致:

HANDLE TEXTEQU <DWORD>

SmallWin.inc 也包括用于 Win32 调用的结构定义。下面给出了两个结构定义:

COORD STRUCT
   X WORD ?
   Y WORD ?
COORD ENDS

SYSTEMTIME STRUCT
   wYear WORD ?
   wMonth WORD ?
   wDayOfWeek WORD ?
   wDay WORD ?
   wHour WORD ?
   wMinute WORD ?
   wSecond WORD ?
   wMilliseconds WORD ?
SYSTEMTIME ENDS

控制台句柄

几乎所有的 Win32 控制台函数都要求向其传递一个句柄作为第一个实参。句柄 (handle) 是 一个 32 位无符号整数,用于唯一标识一个对象,例如一个位图、画笔或任何输入/输岀设备:STD_INPUT_HANDLE standard input
STD_OUTPUT_HANDLE standard output
STD_ERROR_HANDLE standard error output

上述句柄中的后两个用于写控制台活跃屏幕缓冲区。

GetStdHandle 函数返回一个控制台流的句柄:输入、输出或错误输出。基于控制台的程序中所有的输入/输出操作都需要句柄。函数原型如下:

GetStdHandle PROTO,
   nStdHandle:HANDLE       ;句柄类型

nStdHandle 可以是 STD_INPUT_HANDLE、STD_OUTPUT_HANDLE 或者 STD_ERROR_ HANDLE。函数用 EAX 返回句柄,且应将它复制给变量保存。下面是一个调用示例:

.data
inputHandle HANDLE ?
.code
INVOKE GetStdHandle, STD_INPUT_HANDLE
mov inputHandle;eax

汇编语言Win32控制台函数简述

下表为所有 Win32 控制台函数的一览表。在 www.msdn.microsoft.com 上可以找到 MSDN 库中每个函数的完整描述。

提示:Win32 API 函数不保存 EAX、EBX、ECX 和 EDX,因此程序员需自己完成这些寄存器的入栈和出栈操作。

函数 描述
AllocConsole 为调用进程分配一个新控制台
CreateConsoleScreenBuffer 创建控制台屏幕缓冲区
ExitProcess 结束进程及其所有线程
FillConsoleOutputAttribute 为指定数量的字符单元格设置文本和背景颜色属性
FillConsoleOutputCharacter 按指定次数将一个字符写入屏幕缓冲区
FlushConsoleInputBuffer 刷新控制台输入缓冲区
FreeConsole 将主调进程与其控制台分离
GenerateConsoleCtrlEvent 向控制台进程组发送指定信号,这些进程组共享与主调进程关联的控制台
GetConsoleCP 获取与主调进程关联的控制台使用的输入代码页
GetConsoleCursorInfo 获取指定控制台屏幕缓冲区光标大小和可见性的信息
GetConsoleMode 获取控制台输入缓冲区的当前输入模式或控制台屏幕缓冲区的当前输出模式
GetConsoleOutputCP 获取与主调进程关联的控制台使用的输出代码页
GetConsoleScreenBufferInfo 获取指定控制台屏幕缓冲区信息
GetConsoleTitle 获取当前控制台窗口的标题栏字符串
GetConsoleWindow 获取与主调进程关联的控制台使用的窗口句柄
GetLargestConsoleWindowSize 获取控制台窗口最大可能的大小
GetNumberOfConsoleInputEvents 获取控制台输入缓冲区中未读输入记录的个数
GetNumberOfConsoleMouseButtons 获取当前控制台使用的鼠标按钮数
GetStdHandle 获取标准输入、标准输出或标准错误设备的句柄
HandlerRoutine 与 SetConsoleCtrlHandler 函数一起使用的应用程序定义的函数
PeekConsoleInput 从指定控制台输入缓冲区读取数据,且不从缓冲区删除该数据
ReadConsole 从控制台输入缓冲区读取并删除输入字符
ReadConsoleInput 从控制台输入缓冲区读取并删除该数据
ReadConsoleOutput 从控制台屏幕缓冲区的矩形字符单元格区域读取字符和颜色属性数据
ReadConsoleOutputAttribute 从控制台屏幕缓冲区的连续单元格复制指定数量的前景和背景颜色属性
ReadConsoleOutputCharacter 从控制台屏幕缓冲区的连续单元格复制若干字符
ScrollConsoleScreenBuffer 移动屏幕缓冲区内的一个数据块
SetConsoleActiveScreenBuffer 设置指定屏幕缓冲区为当前显示的控制台屏幕缓冲区
SetConsoleCP 设置主调过程的控制台输入代码页
SetConsoleCtrlHandler 为主调过程从处理函数列表中添加或删除应用程序定义的 HandlerRoutine
SetConsoleCursorInfo 设置指定控制台屏幕缓冲区光标的大小和可见度
SetConsoleCursorPosition 设置光标在指定控制台屏幕缓冲区中的位置
SetConsoleMode 设置控制台输入缓冲区的输入模式或者控制台屏幕缓冲区的输出模式
SetConsoleOntputCP 设置主调过程的控制台输出代码页
SetConsoleScreenBufferSize 修改指定控制台屏幕缓冲区的大小
SetConsoleTextAttribute 设置写入屏幕缓冲区的字符的前景(文本)和背景颜色属性
SetConsoleTitle 为当前控制台窗口设置标题栏字符串
SetConsoleWindowInfo 设置控制台屏幕缓冲区窗口当前的大小和位置
SetStdHandle 设置标准输入、输出和标准错误设备的句柄.
WriteConsole 向由当前光标位置标识开始的控制台屏幕缓冲区写一个字符串
WriteConsoleInput 直接向控制台输入缓冲区写数据
WriteConsoleOutput 向控制台屏幕缓冲区内指定字符单元格的矩形块写字符和颜色属性数据
WriteConsoleOutputAttribute 向控制台屏幕缓冲区的连续单元格复制一组前景和背景颜色属性
WriteConsoleOutputCharacter 向控制台屏幕缓冲区的连续单元格复制一组字符

汇编语言MessageBoxA函数:显示消息框

Win32 应用程序生成输岀的一个最简单的方法就是调用 MessageBoxA 函数:

MessageBoxA PROTO,
   hWnd:DWORD,                  ;窗口句柄(可以为空)
   lpText:PTR BYTE,         ;字符串,对话框内
   lpCaption:PTR BYTE,          ;字符串,对话框标题
   uType:DWORD          ;内容和行为

基于控制台的应用程序可以将 hWnd 设置为空,表示该消息框没有相关的包含窗口或父窗口。lpText 参数是指向空字节结束字符串的指针,该字符串将出现在消息框内。lpCaption 参数指向作为对话框标题的空字节结束字符串。uType 参数指定对话框的内容和行为。

内容和行为

uType 参数包含的位图整数组合了三种选项:显示按钮、图标和默认按钮选择。几种可能的按钮组合如下:

  • MB_OK

  • MB_OKCANCEL

  • MB_YESNO

  • MB_YESNOCANCEL

  • MB_RETRYCANCEL

  • MB_ABORTRETRYIGNORE

  • MB_CANCELTRYCONTINUE

默认按钮

可以选择按钮作为用户点击 Enter 键时的自动选项。选项包括 MB_DEFBUTTON1(默认)、MB_DEFBUTTON2、MB_DEFBUTTON3 和 MB_DEFBUTTON4。按钮从左到右,从 1 开始编号。

图标

有四个图标可用。有时多个常数会产生相同的图标:

  • 停止符:MB_ICONSTOP. MB_ICONHAND 或 MB_ICONERROR

  • 问号(?):MB_ICONQUESTION

  • 信息符(i):MB_ICONINFORMATION、MB_ICONASTERISK

  • 感叹号(!):MB_ICONEXCLAMATION、MB_ICONWARNING

返回值

如果 MessageBoxA 失败,则返回零;否则,它将返回一个整数以表示用户在关闭对话框时点击的按钮。选项包括 IDABORT、IDCANCEL、IDCONTINUE、IDIGNORE、IDNO、IDOK、IDRETRY、IDTRYAGAIN,以及 IDYES。

Smallwin.inc 将 MessageBoxA 重定义为 MessageBox,这个名字看上去具有更强的用户友好性。

如果想要消息框窗口浮动于桌面所有其他窗口之上,就在传递的最后一个参数(uType 参数)值上添加 MB_SYSTEMMODAL 选项。

1) 演示程序

下面将通过一个小程序来演示函数 MessageBoxA 的一些功能。第一个函数调用显示一条警告信息:

第二个函数调用显示一个问号图标以及 Yes/No 按钮。如果用户选择 Yes 按钮,则程序利用返回值选择一个操作:

第三个函数调用显示一个信息图标以及三个按钮:

第四个函数调用显示一个停止图标和一个 OK 按钮:

2) 程序清单

MessageBoxA 演示程序的完整清单如下所示。函数 MessageBoxA 重命名为函数 MessageBox,这样就可以使用更加简单的函数名:

; 演示 MessageBoxA
INCLUDE Irvine32.inc
.data
captionW        BYTE "Warning",0
warningMsg    BYTE "The current operation may take years "
                BYTE "to complete.",0
captionQ        BYTE "Question",0
questionMsg    BYTE "A matching user account was not found."
                BYTE 0dh,0ah,"Do you wish to continue?",0   
captionC        BYTE "Information",0
infoMsg        BYTE "Select Yes to save a backup file "
                BYTE "before continuing,",0dh,0ah
                BYTE "or click Cancel to stop the operation",0
captionH        BYTE "Cannot View User List",0
haltMsg        BYTE "This operation not supported by your "
                BYTE "user account.",0               
.code
main PROC
; 显示感叹号图标和 OK 按钮
    INVOKE MessageBox, NULL, ADDR warningMsg,
        ADDR captionW,
        MB_OK + MB_ICONEXCLAMATION
; 显示问号图标和 Yes/No 按钮
    INVOKE MessageBox, NULL, ADDR questionMsg,
        ADDR captionQ, MB_YESNO + MB_ICONQUESTION

    ; 解释用户点击的按钮  
    cmp    eax,IDYES        ; YES button clicked?
; 显示信息图标和 Yes/No/Cancel 按钮
    INVOKE MessageBox, NULL, ADDR infoMsg,
      ADDR captionC, MB_YESNOCANCEL + MB_ICONINFORMATION \
          + MB_DEFBUTTON2
; 显示停止图标和 OK 按钮
    INVOKE MessageBox, NULL, ADDR haltMsg,
        ADDR captionH,
        MB_OK + MB_ICONSTOP
    exit
main ENDP
END main 

汇编语言ReadConsole函数:读取文本输入并将其送入缓冲区

函数 ReadConsole 为读取文本输入并将其送入缓冲区提供了便捷的方法。其原型如下所示:

ReadConsole PROTO,
   hConsoleInput: HANDLE z            ;输入句柄
   lpBuffer:PTR BYTE,                  ;缓冲区指针
   nNumberOfCharsToRead:DWORD,        ;读取的字符数
   lpNumberOfCharsRead:PTR DWORD,    ;指向读取字节数的指针
   lpReserved:DWORD                 ;未使用

hConsoleInput 是函数 GetStdHandle 返回的可用控制台输入句柄。参数 lpBuffer 是字符数组的偏移量。nNumberOfCharsToRead 是一个 32 位整数,指明读取的最大字符数。lpNumberOfCharsRead 是一个允许函数填充的双字指针,当函数返回时,字符数的计数值将被放入缓冲区。最后一个参数未使用,因此传递的值为 0。

在调用 ReadConsole 时,输入缓冲区还要包含两个额外的字节用来保存行结束字符。如果希望输入缓冲区里是空字节结束字符串,则用空字节来代替内容为 ODh 的字节。Irvine32.lib 的过程 ReadString 就是这样操作的。

注意:Win32 API 函数不会保存 EAX、EBX、ECX 和 EDX 寄存器。

【示例】要读取用户输入的字符,就调用 GetStdHandle 来获得控制台标准输入句柄,再使用该句柄调用 ReadConsoleo 下面的 ReadConsole 程序演示了这个方法。

提示:Win32 API 调用与 Irvine32 链接库兼容,因此在调用 Win32 函数的同时还可以调用 DumpRegs

; 从控制台读取    (ReadConsole.asm)
INCLUDE Irvine32.inc
BufSize = 80
.data
buffer BYTE BufSize DUP(?),0,0
stdInHandle HANDLE ?
bytesRead   DWORD ?
.code
main PROC
    ; 获取标准输入句柄
    INVOKE GetStdHandle, STD_INPUT_HANDLE
    mov    stdInHandle,eax
    ; 等待用户输入
    INVOKE ReadConsole, stdInHandle, ADDR buffer,
      BufSize, ADDR bytesRead, 0
    ; 显示缓冲区
    mov    esi,OFFSET buffer
    mov    ecx,bytesRead
    mov    ebx,TYPE buffer
    call    DumpMem
    exit
main ENDP
END main

如果用户输入 “abcdefg”,程序将生成如下输出。缓冲区会插入 9 个字节:“abcdefg” 再加上 ODh 和 OAh,即用户按下 Enter 键时产生的行结束字符。bytesRead 等于 9:

汇编语言GetLastError和FormatMessage函数:获取错误信息

若 Windows API 函数返回了错误值 ( 如 NULL),则可以调用 API 函数 GetLastError 来获取该错误的更多信息。该函数用 EAX 返回 32 位整数错误码:

.data
messageId DWORD ?
.code
call GetLastError
mov messageId,eax

MS-Windows 有大量的错误码,因此,程序员可能希望得到一个消息字符串来对错误进行说明。要想达到这个目的,就调用函数 FormatMessage:

FormatMessage PROTO,     ;格式化消息
   dwFlags:DWORD,        ;格式化选项
   lpSource:DWORD,        ;消息定义的位置
   dwMsgID:DWORD,       ;消息标识符
   dwLanguageID:DWORD,   ;语言标识符
   lpBuffer:PTR BYTE,        ;缓冲区接收字符串指针
   nSize:DWORD,           ;缓冲区大小
   va_list: DWORD          ;参数列表指针

该函数的参数有点复杂,程序员需要阅读 SDK 文档来了解全部信息。下面简要列出了最常用的参数值。除了 lpBuffer 是输出参数外,其他都是输入参数:

1) dwFlags

保存格式化选项的双字整数,包括如何解释参数 lpSource。它规定怎样处理换行,以及格式化输出行的最大宽度。建议值为 FORMAT_MESSAGE_ALLOCATE_BUFFER 和 FORMAT_MESSAGE_FROM_SYSTEM。

2) lpSource

消息定义位置的指针。若按照建议值设置 dwFlags,则 lpSource 设置为 NULL(0)。

3) dwMsgID

调用 GetLastError 后返回的双字整数。

4) dwLanguageID

语言标识符。若将其设置为 0,则消息为语言无关,否则将对应于用户的默认语言环境。

5) lpBuffer( 输出参数 )

接收空字节结束消息字符串的缓冲区指针。如果使用了 FORMAT_MESSAGE_ALLOCATE_BUFFER 选项,则会自动分配缓冲区。

6) nSize

用于指定一个缓冲区来保存消息字符串。如果 dwFlags 使用了上述建议选项,则该参数可以设置为 0。

7) va_list

数组指针,该数组包含了可以插入到格式化消息的值。由于没有格式化错误消息,这个参数可以为 NULL(0)。

FormatMessage 的示例调用如下:

.data
messageId DWORD ?
pErrorMsg DWORD ?            ;指向错误消息
.code
call GetLastError
mov messageId,eax
INVOKE FormatMessage, FORMAT_MESSAGE_ALLOCATE_BUFFER + \
    FORMAT_MESSAGE_FROM_SYSTEM, NULL, messagelD, 0,
    ADDR pErrorMsg, 0, NULL

调用 FormatMessage 后,再调用 LocalFree 来释放由 FormatMessage 分配的存储空间:

INVOKE LocalFree, pErrorMsg

WriteWindowsMsg

Irvine32 链接库有如下 WriteWindowsMsg 程,它封装了消息处理的细节:

;----------------------------------------------------
WriteWindowsMsg PROC USES eax edx
;
; 显示包含 MS-Windows 最新生成的错误字符串
; 接收: 无
; 返回: 无
;----------------------------------------------------
.data
WriteWindowsMsg_1 BYTE "Error ",0
WriteWindowsMsg_2 BYTE ": ",0
pErrorMsg DWORD ?              ; 指向错误消息
messageId DWORD ?
.code
    call    GetLastError
    mov    messageId,eax
; 显示错误号
    mov    edx,OFFSET WriteWindowsMsg_1
    call    WriteString
    call    WriteDec    ; show error number
    mov    edx,OFFSET WriteWindowsMsg_2
    call    WriteString
; 获取相应的消息字符串
    INVOKE FormatMessage, FORMAT_MESSAGE_ALLOCATE_BUFFER + \
      FORMAT_MESSAGE_FROM_SYSTEM, NULL, messageID, NULL,
      ADDR pErrorMsg, NULL, NULL
; 显示 MS-Windows 生成的错误消息
    mov    edx,pErrorMsg
    call    WriteString
; 释放错误消息字符串的空间
    INVOKE LocalFree, pErrorMsg
    ret
WriteWindowsMsg ENDP

汇编语言单字符输入简述

控制台模式下的单字符输入有些复杂。MS-Windows 为当前安装的键盘提供了驱动器。当一个键被按下时,一个 8 位的扫描码 (scan code) 就被传递到计算机的键盘端口。当这个键被释放时,就会传递第二个扫描码。

MS-Windows 利用设备驱动程序将扫描码转换为 16 位的虚拟键码 (virtual-key code),即 MS-Windows 定义的用于标识按键用途的与设备无关数值。MS-Windows 生成含有扫描码、虚拟键码和其他信息的消息。这个消息放在 MS-Windows 消息队列中,并最终进入当前执行程序线程(由控制台输入句柄标识)。

如果想要进一步了解键盘输入过程,请参阅 Platform SDK 文档中的 About Keyboard Input 主题。虚拟键常数列表位于本教程 \Examples\chll 目录下的 VirtualKeys.inc 文件中。

Irvine32 键盘过程 Irvine32 链接库由两个相关过程:

  • ReadChar:等待键盘输入一个 ASCII 字符,并用 AL 返回该字符。

  • ReadKey:过程执行无等待键盘检查。如果控制台输入缓冲区中没有等待的按键,则零标志位置 1。如果发现有按键,则零标志位清零且 AL 等于零或 ASCII 码。EAX 和 EDX 的高 16 位被覆盖。

如果 ReadKey 过程中的 AL 等于 0,那么用户可能按下了特殊键(功能键、光标箭头等)。AH 寄存器为键盘扫描码。DX 为虚拟键码,EBX 为键盘控制键状态信息。

下表为控制键值列表。调用 ReadKey 之后,可以用 TEST 指令检查各种键值。

含义 含义
CAPSLOCK_ON CAPSLOCK 指示灯亮 RIGHT_ALT_PRESSED 右 ALT 键被按下
ENHANCED_KEY 被按下增强的 RIGHT_CTRL_PRESSED 右 CTRL 键被按下
LEFT_ALT_PRESSED 该键是左 ALT 键 SCROLLLOCL_ON SCROLLLOCK 指示灯亮
LEFT_CTRL_PRESSED 左 CTRL 键被按下 SHIFT_PRESSED SHIFT 键被按下
NUMLOCK_ON NUMLOCK 指示灯亮

ReadKey 测试程序

下面是 ReadKey 测试程序:等待一个按键,然后报告按下的是否为 CapsLock 键。程序应考虑延迟因素,以便在调用 ReadKey 时留出时间让 MS-Windows 处理其消息循环:

; 测试 ReadKey    ( TestReadkey. asm)
INCLUDE Irvine32.inc
INCLUDE Macros.inc
.code
main PROC
L1: mov    eax,10             ; 消息处理带来的延迟
    call    Delay
    call    ReadKey           ; 等待按键
    jz    L1
    test    ebx,CAPSLOCK_ON   
    jz    L2
    mWrite <"CapsLock is ON",0dh,0ah>
    jmp    L3
L2:    mWrite <"CapsLock is OFF",0dh,0ah>
L3:    exit
main ENDP
END main

汇编语言GetKeyState函数:获得键盘状态

通过测试单个键盘按键可以发现当前按下的是哪个键。方法是调用 API 函数 GetKeyState。

GetKeyState PROTO, nVirtKey:DWORD

向该函数传递如下表所示的虚拟键值。测试程序必须按照同一个表来测试 EAX 里面的返回值。

按键 虚拟键符号 EAX 中被测试的位
NumLock VK_NUMLOCK 0
Scroll Lock VK_SCROLL 0
Left Shift VK_LSHIFT 15
Right Shift VK_tRSHIFT 15
Left Ctrl VK_LCONTROL 15
Right Ctrl VK_RCONTROL 15
Left Menu VK_LMENU 15
Right Menu VK_RMENU 15

下面的测试程序通过检查 NumLock 键和左 Shift 键的状态来演示 GetKeyState 函数:

; 键盘切换键    (Keybd.asm)
INCLUDE Irvine32.inc
INCLUDE Macros.inc
; 如果当前触发了切换键 (CapsLock, NumLock, ScrollLock),
; 则 GetKeyState 将 EAX 的位 0 置 1
; 如果当前按下了特殊键,则将 EAX 的最高位置 1
.code
main PROC
    INVOKE GetKeyState, VK_NUMLOCK
    test al,1
    .IF !Zero?
      mWrite <"The NumLock key is ON",0dh,0ah>
    .ENDIF
    INVOKE GetKeyState, VK_LSHIFT
    call DumpRegs
    test eax,80000000h
    .IF !Zero?
      mWrite <"The Left Shift key is currently DOWN",0dh,0ah>
    .ENDIF
    exit
main ENDP
END main

汇编语言WriteConsole和WriteConsoleOutputCharacter函数:控制台输出

有些 Win32 控制台函数使用的是预定义的数据结构,包括 COORD 和 SMALL_RECT。COORD 结构包含的是控制台屏幕缓冲区内字符单元格的坐标。坐标原点(0, 0)位于左上角单元格:

COORD STRUCT
   X WORD ?
   Y WORD ?
COORD ENDS

SMALL_RECT 结构包含的是矩形的左上角和右下角,它指定控制台窗口中的屏幕缓冲区字符单元格区域:

SMALL_RECT STRUCT
   Left WORD ?
   Top WORD ?
   Right WORD ?
   Bottom WORD ?
SMALL_RECT ENDS

WriteConsole 函数

函数 WriteConsole 在控制台窗口的当前光标所在位置写一个字符串,并将光标留着字符串末尾右边的字符位置上。它按照标准 ASCII 控制字符操作,比如制表符、回车和换行。

字符串不一定以空字节结束。函数原型如下:

WriteConsole PROTO,
   hConsoleOutput:HANDLE,
   lpBuffer:PTR BYTE,
   nNumberOfCharsToWrite:DWORD,
   lpNumberOfCharsWritten:PTR DWORD,
   lpReserved:DWORD

hConsoleOutput 是控制台输出流句柄;lpBuffer 是输出字符数组的指针;nNumberOfCharsToWrite 是数组长度;lpNumberOfCharsWritten 是函数返回时实际输出字符数量的整数指针。最后一个参数未使用,因此将其设置为 0。

示例程序:Console1

下面的程序通过向控制台窗口写字符串演示了函数 GetStdHandle、ExitProcess 和 WriteConsole:

; Win32 控制台示例 #1    (Consolel.asm)
; 本程序调用如下 Win32 控制台函数:
; GetStdHandle, ExitProcess, WriteConsole
INCLUDE Irvine32.inc
.data
endl EQU <0dh,0ah>            ; 行结尾
message LABEL BYTE
    BYTE "This program is a simple demonstration of "
    BYTE "console mode output, using the GetStdHandle "
    BYTE "and WriteConsole functions.", endl
messageSize DWORD ($-message)
consoleHandle HANDLE 0     ; 标准输出设备句柄
bytesWritten  DWORD ?      ; 输出字节数
.code
main PROC
  ; 获得控制台输出句柄
    INVOKE GetStdHandle, STD_OUTPUT_HANDLE
    mov consoleHandle,eax
  ; 向控制台写一个字符串
    INVOKE WriteConsole,
      consoleHandle,          ; 控制台输出句柄
      ADDR message,           ; 字符串指针
      messageSize,            ; 字符长度
      ADDR bytesWritten,      ; 返回输出字节数
      0                       ; 未使用
    INVOKE ExitProcess,0
main ENDP
END main

程序生成输出如下所示:

WriteConsoleOutputCharacter 函数

函数 WriteConsoleOutputCharacter 从指定位置开始,向控制台屏幕缓冲区的连续单元格内复制一组字符。原型如下:

WriteConsoleOutputCharacter PROTO,
   hConsoleOutput:HANDLE,             ;控制台输出句柄
   lpCharacter :PTR BYTE,                ;缓冲区指针
   nLength: DWORD,                   ;缓冲区大小
   dwWriteCoord: COORD,               ;第一个单元格的坐标
   lpNumberOfCharsWritten: PTR DWORD  ;输出计数器

如果文本长度超过了一行,字符就会输岀到下一行。屏幕缓冲区的属性值不会改变。如果函数不能写字符,则返回零。ASCII 码,如制表符、回车和换行,会被忽略。

汇编语言CreateFile函数:创建新文件或者打开已有文件

函数 CreateFile 可以创建一个新文件或者打开一个已有文件。如果调用成功,函数返回打开文件的句柄;否则,返回特殊常数 INVALID_HANDLE_VALUEO 原型如下:

CreateFile PROTO,                ;创建新文件
   lpFilename:PTR BYTE,            ;文件名指针
   dwDesiredAccess:DWORD,       ;访问模式
   dwShareMode:DWORD,         ;共享模式
   lpSecurityAttributes:DWORD,     ;安全属性指针
   dwCreationDisposition:DWORD,   ;文件创建选项
   dwFlagsAndAttributes:DWORD,   ;文件属性
   hTemplateFile:DWORD          ;文件模板句柄

下表对参数进行了说明。如果函数调用失败则返回值为零。

参数 说明
lpFileName 指向一个空字节结束字符串,该串为部分或全部合格的文件名(drive:\path\filename)
dwDesiredAccess 指定文件访问方式(读或写)
dwShareMode 控制多个程序对打开文件的访问能力
lpSecurityAttributes 指向安全结构,该结构控制安全权限
dwCreationDisposition 指定文件存在或不存在时的操作
dwFlagsAndAttributes 包含位标志指定文件属性,如存档、加密、隐藏、普通、系统和临时
hTemplateFile 包含一个可选的文件模板句柄,该文件为已创建的文件提供文件属性和扩展属性;如果不使用该参数,就将其设置为 0

dwDesiredAccess

参数 dwDesiredAccess 允许指定对文件进行读访问、写访问、读/写访问,或者设备查询访问。可以从下表列出的值中选择,也可以从表中未列出的更多特定标志值选择。

含义
0 为对象指定设备查询访问。应用程序可以查询设备属性而无需访问设备,也可以检查文件是否存在
GENERIC_READ 为对象指定读访问。可以从文件中读取数据,文件指针可以移动。与 GENERIC_WRITE 一起使用为读/写访问
GENERIC_WRITE 对对象指定写访问。可以向文件中写入数据,文件指针可以移动。与 GENERIC_READ 一起使用为读/写访问

dwCreationDisposition

参数 dwCreationDisposition 指定当文件存在或不存在时应采取怎样的操作。可从下表中选择一个值。

含义
CREATE_NEW 创建一个新文件。要求将参数 dwDesiredAccess 设置为 GENERIC_WRITE。如果文件已经存在,则函数调用失败
CREATE_ALWAYS 创建一个新文件。如果文件已存在,则函数会覆盖原文件,清除现有属性,并合并文件 属性与预定义的常数 FILE_ATTRIBUTES_ARCHIVE 中属性参数指定的标志。要求将参数 dwDesiredAccess 设置为 GENERIC WRITE
OPEN_EXISTING 打开文件。如果文件不存在,则函数调用失败。可用于读取和/或写入文件
OPEN_ALWAYS 如果文件存在,则打开文件。如果不存在,则函数创建文件,就好像CreateDisposition 的值为 CREATE NEW
TRUNCATE_EXISTING 打开文件。一旦打开,文件将被截断,使其大小为零。要求将参数 dwDesiredAccess 设置为 GENERIC_WRITE。如果文件不存在,则函数调用失败

下表列出了参数 dwFlagsAndAttributes 比较常用的值。(完整列表请在 Microsoft 在线文档中搜索CreateFiko)允许任意属性组合,除了 FILE_ATTRIBUTE_NORMAL 会被其他 所有属性覆盖。这些值能映射为 2 的幂,因此可以用汇编时 OR 运算符或 + 运算符将它们组 合为一个参数:

FILE_ATTRIBUTE_HIDDEN OR FILE_ATTRIBUTE_READONLY
FILE_ATTRIBUTE_HIDDEN + FILE_ATTRIBUTE_READONLY
属性 含义
FILE_ATTRIBUTE_ARCHIVE 文件存档。应用程序使用这个属性标记文件以便备份或移动
FILE_ATTRIBUTE_HIDDEN 文件隐藏。不包含在普通目录列表中
FILE_ATTRIBUTE_NORMAL 文件没有其他属性设置。该属性只在单独使用时有效
FILE_ATTRIBUTE_READONLY 文件只读。应用程序可以读文件但不能写或删除文件
FILE_ATTRIBUTE_TEMPORARY 文件被用于临时存储

【示例】下面的例子仅具说明性,展示了如何创建和打开文件。请参阅在线从 Microsoft文 档,了解 CreateFile 更多可用选项:

打开并读取(输入)已存在文件:

INVOKE CreateFile,
    ADDR filename,            ;文件名指针
    GENERIC_READ,             ;读文件
    DO_NOT_SHARE,             ;共享模式
    NULL,                     ;安全属性指针
    OPEN_EXISTING,            ;打开已存在文件
    FILE_ATTRIBUTE_NORMALA    ;普通文件属性
    0                         ;未使用

打开并写入(输出)已存在文件。文件打开后,可以通过写入覆盖当前数据,或者将文件指针移到末尾,向文件添加新数据(参见11.1.6节的SetFilePointer):

INVOKE CreateFile,
    ADDR filename,
    GENERIC_WRITEZ,      ;写文件
    DO_NOT_SHARE,
    NULL,
    OPEN_EXISTIN,       ;文件必须存在
    FILE_ATTRIBUTE_NORMAL,
    0

创建有普通属性的新文件,并删除所有已存在的同名文件:

INVOKE CreateFile,
    ADDR filename,
    GENERIC_WRITE,       ;写文件
    DO _NOT_SHARE,
    NULL,
    CREATE_ALWAYS,       ;覆盖已存在的文件
    FILE_ATTRIBUTE_NORMAL,
    0

若文件不存在,则创建文件;否则打开并输出现有文件:

INVOKE CreateFile,
    ADDR filename,
    GENERIC_WRITE,         ;写文件
    DO_NOT_SHARE,
    NULL,
    CREATE_NEW,            ;不删除已存在文件
    FILE_ATTRIBUTE_NORMAL,
    0

汇编语言CloseHandle函数:关闭一个打开的对象句柄

函数 CloseHandle 关闭一个打开的对象句柄。其原型如下:

CloseHandle PROTO,
  hObject: HANDLE ;对象句柄

可以用 CloseHandle 关闭当前打开的文件句柄。如果函数调用失败,则返回值为零。

汇编语言ReadFile函数:从输入文件中读取文本

函数 ReadFile 从输入文件中读取文本。其原型如下:

ReadFile PROTO,
   hFile:HANDLE,                      ;输入句柄
   lpBuffer:PTR BYTE,                   ;缓冲区指针
   nNumberOfBytesToRead:DWORD,          ;读取的字节数
   lpNumberOfBytesRead:PTR DWORD,      ;实际读出的 字节数
   lpOverlapped:PTR DWORD            ;异步信息指针

其中:

  • hFile 是由 CreateFile 返回的打开文件的句柄;

  • lpBuffer 指向的缓冲区接收从该文件读取的数据;

  • nNumberOfBytesToRead 定义从该文件读取的最大字节数;

  • lpNumberOfBytesRead 指向的整数为函数返回时实际读取的字节数;

  • lpOverlapped 应被设置为 NULL(0)。若函数调用失败,则返回值为零。

如果对同一个打开文件的句柄进行多次调用,那么 ReadFile 就会记住最后一次读取的位置,并从这个位置开始读。换句话说,函数有一个内部指针指向文件内的当前位置。

ReadFile 还可以运行在异步模式下,这意味着调用程序不用等到读操作完成。

汇编语言WriteFile函数:向文件写入数据

函数 WriteFile 用输出句柄向文件写入数据。句柄可以是屏幕缓冲区句柄,也可以是分配给文本文件的句柄。函数从文件内部位置指针所指向的位置开始写数据。

写操作完成后,文件位置指针按照实际写入的字节数进行调整。函数原型如下:

WriteFile PROTO,
   hFile:HANDLE,                      ;输出句柄
   lpBuffer:PTR BYTE,                   ;缓冲区指针
   nNumberOfBytesToWrite:DWORD,      ;缓冲区大小
   lpNumberOfBytesWritten:PTR DWORD,  ;写入字节数
   lpOverlapped:PTR DWORD            ;异步信息指针

其中:

  • hFile 是已打开文件的句柄;

  • lpBuffer 指向的缓冲区包含了写入到文件的数据;

  • nNumberOfBytesToWrite 指定向文件写入多少字节;

  • lpNumberOfBytesWritten 指向的整数为函数执行后实际写入的字节数;

  • 若为同步操作,则 lpOverlapped 应被设置为 NULL。若函数调用失败,则返回值为零。

汇编语言SetFilePointer函数:移动打开文件的位置指针

函数 SetFilePointer 移动打开文件的位置指针。该函数可以用于向文件添加数据,或是执行随机访问记录处理:

SetFilePointer PROTO,
   hFile:HANDLE,                     ;文件句柄
   lpDistanceToMove:SDWORD,         ;指针移动 字节数
   lpDistanceToMoveHigh:PTR SDWORD,  ;指针移动字节数,高双字
   dwMoveMethod:DWORD            ;起点

若函数调用失败,则返回值为零。dwMoveMode 指定文件指针移动的起点,选择项为 3 个预定义符号:FILE_BEGIN、FILE_CURRENT 和 FILE_END。

移动距离本身为 64 位有符号整数值,分为两个部分:

  • lpDistanceToMove:低 32 位

  • lpDistanceToMoveHigh:含有高 32 位的变量指针

如果 lpDistanceToMoveHigh 为空,则只用 lpDistanceToMove 的值来移动文件指针。例如,下面的代码准备添加到一个文件末尾:

INVOKE SetFilePointer,
   fileHandle,       ;文件句柄
   0,           ;距离低32位
   0,           ;距离高32位
   FILE_END     ;移动模式

汇编语言Irvine32链接库文件I/O(输入/输出)

Irvine32 库中包含了一些简化的文件 I/O 过程。这些过程已经封装到本章描述的 Win32 API 函数中。

下面的源代码就给岀了 CreateOutputFile、OpenFile、WriteToFile、ReadFromFile 和 CloseFile:

;------------------------------------------------------
CreateOutputFile PROC
;
; 创建一个新文件并以输出模式打开
; 接收: EDX 指向文件名
; 返回: 如果文件创建成功, EAX 包含一个有效的文件句柄。 
; 否则,EAX 等于 INVALID_HANDLE_VALUE
;------------------------------------------------------
    INVOKE CreateFile,
      edx, GENERIC_WRITE, DO_NOT_SHARE, NULL,
      CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, 0
    ret
CreateOutputFile ENDP
;-------------------------------------------------------
OpenFile PROC
;打开一个新的文本文件进行输入。
;接收:EDX 指向文件名。
;返回:如果文件打开成功,EAX 包含一个有效的文件
;句柄。否则,EAX 等于 INVALID_HANDLE_VALUE。
;-------------------------------------------------------
    INVOKE CreateFilez
        edx, GENERIC_READ, DO_NOT_SHARE, NULL,
        OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0
    ret
OpenFile ENDP
;--------------------------------------------------------
WriteToFile PROC
;
; 将缓冲区内容写入一个输出文件
; 接收: EAX = 文件句柄, EDX = 缓冲区偏移量,
;    ECX = 写入字节数
; 返回: EAX = 实际写入文件的字节数
; 如果 EAX 返回的值小于 ECX 中的参数, 则可能发生错误
;--------------------------------------------------------
.data
WriteToFile_1 DWORD ?        ; 已写入字节数
.code
    INVOKE WriteFile,        ; 向文件写缓冲区
        eax,                 ; 文件句柄
        edx,                 ; 缓冲区指针
        ecx,                 ; 写入字节数
        ADDR WriteToFile_1,  ; 已写入字节数
        0                    ; 覆盖执行标志
    mov    eax,WriteToFile_1 ; 返回值
    ret
WriteToFile ENDP
;--------------------------------------------------------
ReadFromFile PROC
; 将一个输入文件读入缓冲区
; 接收: EAX = 文件句柄, EDX = 缓冲区偏移量,
;    ECX = 读字节数
; 返回: 如果 CF=0,EAX = 已读字节数
; 如果 CF=1,则EAX包含Win32 API 函数 GetLastError 返回的系统错误码
;--------------------------------------------------------
.data
ReadFromFile_1 DWORD ?            ; 已读字节数
.code
    INVOKE ReadFile,
        eax,                      ; 文件句柄
        edx,                      ; 缓冲区指针
        ecx,                      ; 读取的最大字节数
        ADDR ReadFromFile_1,      ; 已读字节数
        0                         ; 覆盖执行标志
    mov    eax,ReadFromFile_1
    ret
ReadFromFile ENDP
;--------------------------------------------------------
CloseFile PROC
; 使用句柄为标识符关闭一个文件
; 接收: EAX = 文件句柄
; 返回: EAX = 非 0,如果文件被成功关闭
;--------------------------------------------------------
    INVOKE CloseHandle, eax
    ret
CloseFile ENDP

汇编语言实例:文件I/O(输入/输出)过程

下面通过两个实例程序来演示文件I/O(输入/输出)的过程。

1) CreatFile 程序示例

下面的程序用输岀模式创建一个文件,要求用户输入一些文本,将这些文本写到输出文件,并报告已写入的字节数,然后关闭文件。在试图创建文件后,程序要进行错误检查:

; 创建一个文件    (CreateFile.asm)
INCLUDE Irvine32.inc 
BUFFER_SIZE = 501
.data
buffer BYTE BUFFER_SIZE DUP(?)
filename     BYTE "output.txt",0
fileHandle   HANDLE ?
stringLength DWORD ?
bytesWritten DWORD ?
str1 BYTE "Cannot create file",0dh,0ah,0
str2 BYTE "Bytes written to file [output.txt]: ",0
str3 BYTE "Enter up to 500 characters and press "
     BYTE "[Enter]: ",0dh,0ah,0
.code
main PROC
; 创建一个新文本文件
    mov    edx,OFFSET filename
    call    CreateOutputFile
    mov    fileHandle,eax
; 错误检查
    cmp    eax, INVALID_HANDLE_VALUE      ; 发现错误?
    jne    file_ok                        ; 否: 跳过
    mov    edx,OFFSET str1                ; 显示错误
    call    WriteString
    jmp    quit
file_ok:
; 提示用户输入字符串
    mov    edx,OFFSET str3                 ; "Enter up to ...."
    call    WriteString
    mov    ecx,BUFFER_SIZE                 ; 输入字符串
    mov    edx,OFFSET buffer
    call    ReadString
    mov    stringLength,eax                ; 计算输入字符数
; 将缓冲区写入输出文件
    mov    eax,fileHandle
    mov    edx,OFFSET buffer
    mov    ecx,stringLength
    call    WriteToFile
    mov    bytesWritten,eax                ; 保存返回值
    call    CloseFile

; 显示返回值
    mov    edx,OFFSET str2                 ; "Bytes written"
    call    WriteString
    mov    eax,bytesWritten
    call    WriteDec
    call    Crlf
quit:
    exit
main ENDP
END main

2) ReacIFile 程序示例

下面的程序打开一个文件进行输入,将文件内容读入缓冲区,并显示该缓冲区。所有过程都从 Irvine32 链接库调用:

; 读文件      (ReadFile.asm)
; 使用 Irvine32.lib 的过程打开,读取并显示一个文本文件
INCLUDE Irvine32.inc
INCLUDE macros.inc
BUFFER_SIZE = 5000
.data
buffer BYTE BUFFER_SIZE DUP(?)
filename    BYTE 80 DUP(0)
fileHandle  HANDLE ?
.code
main PROC
; 用户输入文件名
    mWrite "Enter an input filename: "
    mov    edx,OFFSET filename
    mov    ecx,SIZEOF filename
    call    ReadString
; 打开文件进行输入
    mov    edx,OFFSET filename
    call    OpenInputFile
    mov    fileHandle,eax
; 错误检查
    cmp    eax,INVALID_HANDLE_VALUE           ; 错误打开文件?
    jne    file_ok                            ; 否: 跳过
    mWrite <"Cannot open file",0dh,0ah>
    jmp    quit                               ; 退出
file_ok:
; 将文件读入缓冲区
    mov    edx,OFFSET buffer
    mov    ecx,BUFFER_SIZE
    call    ReadFromFile
    jnc    check_buffer_size                ; 错误读取?
    mWrite "Error reading file. "           ; 是: 显示错误消息
    call    WriteWindowsMsg
    jmp    close_file

check_buffer_size:
    cmp    eax,BUFFER_SIZE                    ; 缓冲区足够大?
    jb    buf_size_ok                         ; 是
    mWrite <"Error: Buffer too small for the file",0dh,0ah>
    jmp    quit                               ; 退出

buf_size_ok:   
    mov    buffer[eax],0                    ; 插入空结束符
    mWrite "File size: "
    call    WriteDec                        ; 显示文件大小
    call    Crlf
; 显示缓冲区
    mWrite <"Buffer:",0dh,0ah,0dh,0ah>
    mov    edx,OFFSET buffer                ; 显示缓冲区
    call    WriteString
    call    Crlf
close_file:
    mov    eax,fileHandle
    call    CloseFile
quit:
    exit
main ENDP
END main

如果文件不能打开,则程序报告错误:

如果程序不能从文件读取,则报告错误。比如,假设有一个错误为在读文件时使用了不正确的文件句柄:

缓冲区可能太小,无法容纳文件:

汇编语言控制台窗口操作

Win32 API 提供了对控制台窗口及其缓冲区相当大的控制权。下图显示了屏幕缓冲区可以大于控制台窗口当前显示的行数。控制台窗口就像是一个“视窗”,显示部分缓冲区。

下列函数影响的是控制台窗口及其相对于屏幕缓冲区的位置:

  • SetConsoleWindowInfo:设置控制台窗口相对于屏幕缓冲区的大小和位置。

  • GetConsoleScreenBufferInfo:返回(还包括其他一些信息)控制台窗口相对于屏幕缓冲区的矩形坐标。

  • SetConsoleCursorPosition:将光标设置在屏幕缓冲区内的任何位置;如果区域不可见,则移动控制台窗口直到光标可见。

  • ScrollConsoleScreenBuffer:移动屏幕缓冲区中的一些或全部文本,本函数会影响控制台窗口显示的文本。

1) SetConsoleTitle

函数 SetConsoleTitle 可以改变控制台窗口的标题。示例如下:

.data
.titleStr BYTE "Console title", 0
.code
INVOKE SetConsoleTitle, ADDR titleStr

2) GetConsoleScreenBufferInfo

函数 GetConsoleScreenBufferInfo 返回控制台窗口的当前状态信息。它有两个参数:控制台屏幕的句柄和指向该函数填充的结构的指针:

GetConsoleScreenBufferInfo PROTO,
   hConsoleOutput:HANDLE,
   lpConsoleScreenBufferInfo:PTR CONSOLE_SCREEN_BUFFER_INFO

CONSOLE_SCREEN_BUFFER_INFO 结构如下:

CONSOLE_SCREEN_BUFFER_INFO STRUCT
    dwSize COORD <>
    dwCursorPosition COORD <>
    wAttributes WORD ?
    srWindow SMALL_RECT <>
    dwMaximumWindowSize COORD <>
CONSOLE_SCREEN_BUFFER_INFO ENDS

dwSize 按字符行列数返回屏幕缓冲区大小。dwCursorPosition 返回光标的位置。这两个字段都是 COORD 结构。

wAttributes 返回字符的前景色和背景色,字符由诸如 WriteConsole 和 WriteFile 等函数写到控制台。srWindow 返回控制台窗口相对于屏幕缓冲区的坐标。

dwMaximumWindowSize 以当前屏幕缓冲区的大小、字体和视频显示大小为基础,返回控制台窗口的最大尺寸。函数示例调用如下所示:

.data
consoleInfo CONSOLE_SCREEN_BUFFER_INFO <>
outHandle HANDLE ?
.code
INVOKE GetConsoleScreenBufferInfo, outHandle,
   ADDR consoleInfo

3) SetConsoleWindowInfo 函数

函数 SetConsoleWindowInfo 可以设置控制台窗口相对于其屏幕缓冲区的大小和位置。函数原型如下:

SetConsoleWindowInfo PROTO,
   hConsoleOutput:HANDLE,         ;屏幕缓冲区句柄
   bAbsolute:DWORD,               ;坐标类型
   lpConsoleWindow:PTR SMALL_RECT ;矩形窗口指针

bAbsolute 说明如何使用结构中由 lpConsoleWindow 指出的坐标。如果 bAbsolute 为真,则坐标定义控制台窗口新的左上角和右下角。如果 bAbsolute 为假,则坐标与当前窗口坐标相加。

下面的程序向屏幕缓冲区写 50 行文本。然后重定义控制台窗口的大小和位置,有效地向后滚动文本。该程序使用了函数 SetConsoleWindowInfo:

; 滚动控制台窗口    (Scroll.asm)
INCLUDE Irvine32.inc
.data
message BYTE ":  This line of text was written "
        BYTE "to the screen buffer",0dh,0ah
messageSize DWORD ($-message)
outHandle     HANDLE 0                     ; 标准输出句柄
bytesWritten  DWORD ?                      ; 已写入字节数
lineNum DWORD 0
windowRect    SMALL_RECT <0,0,60,11>       ; 上,下,左,右
.code
main PROC
    INVOKE GetStdHandle, STD_OUTPUT_HANDLE
    mov outHandle,eax
.REPEAT
      mov    eax,lineNum
      call    WriteDec                     ; 显示每行编号
    INVOKE WriteConsole,
      outHandle,                           ; 控制台输出句柄
      ADDR message,                        ; 字符串指针
      messageSize,                         ; 字符串长度
      ADDR bytesWritten,                   ; 返回已写字节数
      0                                    ; 未使用
      inc  lineNum                         ; 下一行编号
.UNTIL lineNum > 50
; 调整控制台窗口相对于屏幕缓冲区的大小和位置
    INVOKE SetConsoleWindowInfo,
      outHandle,
      TRUE,
      ADDR windowRect
    call    Readchar                      ; 等待按键
    call    Clrscr                        ; 清除屏幕缓冲区
    call    Readchar                      ; 等待第二次按键
    INVOKE ExitProcess,0
main ENDP
END main

最好能直接从 MS-Windows Exlporer 中,或者直接以命令行形式运行程序,而不使用集成的编辑环境。否则,编辑器可能会影响控制台窗口的行为和外观。在程序结束时需要两次按键:第一次清除屏幕缓冲区,第二次结束程序。

4) SetConsoleScreenBufferSize 函数

函数 SetConsoleScreenBufferSize 可以将屏幕缓冲区设置为 X 列 * Y 行。其原型如下:

SetConsoleScreenBufferSize PROTO,
   hConsoleOutput:HANDLE,                ;屏幕缓冲区句柄
   dwSize:COORD                  ;新屏幕缓冲区大小

汇编语言控制台光标设置函数简述

Win32 API 提供了函数用于设置控制台应用光标的大小、可见度和屏幕位置。与这些函数相关的重要数据结构是 CONSOLE_CURSOR_INFO,其中包含了控制台光标的大小和可见度信息:

CONSOLE_CURSOR_INFO STRUCT
   dwSize DWORD ?
   bVisible DWORD ?
CONSOLE_CURSOR_INFO ENDS

dwSize 为光标填充的字符单元格的百分比(从 1 到 100)。如果光标可见,则 bVisible 等于 TRUE(1)。

1) GetConsoleCursorInfo 函数

函数 GetConsoleCursorInfo 返回控制台光标的大小和可见度。需向其传递指向结构 CONSOLE_CURSOR_INFO 的指针:

GetConsoleCursorInfo PROTO,
   hConsoleOutput:HANDLE,
   lpConsoleCursorInfo:PTR CONSOLE_CURSOR_INFO

默认情况下,光标大小为 25,这表示光标占据了 25% 的字符单元格。

2) SetConsoleCursorInfo 函数

函数 SetConsoleCursorInfo 设置光标的大小和可见度。需向其传递指向结构 CONSOLE_CURSOR_INFO 的指针:

SetConsoleCursorInfo PROTO,
   hConsoleOutput:HANDLE,
   lpConsoleCursorInfo:PTR CONSOLE_CURSOR_INFO

3) SetConsoleCursorPosition

函数 SetConsoleCursorPosition 设置光标的 X、Y 位置。向其传递一个 COORD 结构和控制台输岀句柄:

SetConsoleCursorPosition PROTO,
   hConsoleOutput:DWORD,   ;输入模式句柄
   dwCursorPosition:COORD   ;屏幕 X、Y 坐标

汇编语言SetConsoleTextAttribute和WriteConsoleOutputAttribute函数:控制文本颜色

控制台窗口中的文本颜色有两种控制方法。

  • 通过调用 SetConsoleTextAttribute 来改变当前文本颜色,这种方法会影响控制台中所有后续输出文本。

  • 调用 WriteConsoleOutputAttribute 来设置指定单元格的属性。函数 GetConsoleScreenBufferlnfo 返回当前屏幕的颜色以及其他控制台信息。

1) SetConsoleTextAttribute 函数

函数 SetConsoleTextAttribute 可以设置控制台窗口所有后续输出文本的前景色和背景色。原型如下:

SetConsoleTextAttribute PROTO,
   hConsoleOutput:HANDLE,          ;控制台输出句柄
   wAttributes : WORD           ;颜色属性

颜色值保存在 wAttributes 参数的低字节中。

2) WriteConsoleOutputAttribute 函数

函数 WriteConsoleOutputAttribute 从指定位置开始,向控制台屏幕缓冲区的连续单元格复制一组属性值。原型如下:

WriteConsoleOutputAttribute PROTO,
   hConsoleOutput:DWORD,                ;输出句柄
   lpAttribute:PTR WORD,                  ;写属性
   nLength:DWORD,                      ;单元格数
   dwWriteCoord :COORD,                 ;第一个单元格坐标
   lpNumberOfAttrsWritten:PTR DWORD          ;输出计数

其中:

  • lpAttribute 指向属性数组,其中每个字节的低字节都包含了颜色值;

  • nLength 为数组长度;

  • dwWriteCoord 为接收属性的开始屏幕单元格;

  • lpNumberOfAttrsWritten 指向一个变量,其中保存的是已写单元格的数量。

3) 示例:写文本颜色

为了演示颜色和属性的用法,程序 WriteColors.asm 创建了一个字符数组和一个属性数组, 属性数组中的每个元素都对应一个字符。程序调用 WriteConsoleOutputAttribute 将属性复制到屏幕缓冲区,调用 WriteConsoleOutputCharacter 将字符复制到相同的屏幕缓冲区单元格:

; 写文本颜色      (WriteColors.asm)
INCLUDE Irvine32.inc
.data
outHandle    HANDLE ?
cellsWritten DWORD ?
xyPos COORD <10,2>
; 字符编号数组
buffer BYTE 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
       BYTE 16,17,18,19.20
BufSize DWORD ($ - buffer)
; 属性数组
attributes WORD 0Fh,0Eh,0Dh,0Ch,0Bh,0Ah,9,8,7,6
           WORD 5,4,3,2,1,0F0h,0E0h,0D0h,0C0h,0B0h
.code
main PROC
; 获取控制台标准输出句柄
    INVOKE GetStdHandle,STD_OUTPUT_HANDLE
    mov outHandle,eax
; 设置相邻单元格颜色
INVOKE WriteConsoleOutputAttribute,
      outHandle, ADDR attributes,
      BufSize, xyPos,
      ADDR cellsWritten
; 写 1 到 20 号字符
    INVOKE WriteConsoleOutputCharacter,
      outHandle, ADDR buffer, BufSize,
      xyPos, ADDR cellsWritten
    INVOKE ExitProcess,0
main ENDP
END main

下图是程序输岀的快照,其中 1 到 20 号显示为图形字符。虽然印刷页面为灰度显示,但每个字符都是不同的颜色。

汇编语言Win32时间与日期函数

Win32 API 有相当多的时间和日期函数可供选择。最常见的是,用户想要用这些函数来获得和设置当前日期与时间。这里只能讨论这些函数的一小部分,不过在 Platform SDK 文档中可以查阅到下表列出的 Win32 函数。

函数 说明
CompareFileTime 比较两个 64 位的文件时间
DosDateTimeToFileTime 把 MS-DOS 日期和时间值转换为一个 64 位的文件时间
FileTimeToDosDateTime 把 64 位文件时间转换为 MS-DOS 日期和时间值
FileTimeToLocalFileTime 把 UTC(通用协调时间)文件时间转换为本地文件时间
FileTimeToSystemTime 把 64 位文件时间转换为系统时间格式
GetFileTime 检索文件创建、最后访问和最后修改的日期与时间
GetLocalTime 检索当前本地日期和时间
GetSystemTime 以 UTC 格式检索当前系统日期和时间
GetSystemTimeAdjustment 决定系统是否对其日历钟进行周期性时间调整
GetSystemTimeAsFileTime 以 UTC 格式检索当前系统日期和时间
GetTickCount 检索自系统启动后经过的毫秒数
GetTimeZoneInformation 检索当前时区参数
LocalFileTimeToFileTime 把本地文件时间转换为基于 UTC 的文件时间
SetFileTime 设置文件创建、最后访问和最后修改的日期与时间
SetLocalTime 设置当前本地时间与日期
SetSystemTime 设置当前系统时间与日期
SetSystemTimeAdjustment 启用或禁用对系统日历钟进行周期性时间调整
SetTimeZoneInformation 设置当前时区参数
SystemTimeToFileTime 把系统时间转换为文件时间
SystemTimeToTzSpecificLocalTime 把 UTC 时间转换为指定时区对应的本地时间

SYSTEMTIME 结构

SYSTEMTIME 结构由 Windows API 的日期和时间函数使用:

SYSTEMTIME STRUCT
   wYear WORD ?          ;年(4 个数子)
   wMonth WORD ?        ;月(1 ~ 12)
   wDayOfWeek WORD ?   ;星期(0 ~ 6)
   wDay WORD ?          ;日(1 ~ 31)
   wHour WORD ?         ;小时(0 ~ 23)
   wMinute WORD ?            ;分钟(0 ~ 59)
   wSecond WORD ?            ;秒(0 ~ 59)
   wMilliseconds WORD ?   ;毫秒(0 ~ 999)
SYSTEMTIME ENDS

字段 wDayOfWeek 的值依序为星期天 = 0,星期一 = 1,以此类推。wMilliseconds 中的值不确定,因为系统可以与时钟源同步周期性地刷新时间。

GetLocalTime 和 SetLocalTime

函数 GetLocalTime 根据系统时钟返回日期和当前时间。时间要调整为本地时区。调用该函数时,需向其传递一个指针指向 SYSTEMTIME 结构:

GetLocalTime PROTO,
   lpSystemTime:PTR SYSTEMTIME

函数 GetLocalTime 调用示例如下:

.data
sysTime SYSTEMTIME <>
.code
INVOKE GetLocalTime, ADDR sysTime

函数 SetLocalTime 设置系统的本地日期和时间。调用时,需向其传递一个指针指向包含了期望日期和时间的 SYSTEMTIME 结构:

SetLocalTime PROTO,
   lpSystemTime:PTR SYSTEMTIME

如果函数执行成功,则返回非零整数;如果失败,则返回零。

GetTickCount 函数

函数 GetTickCount 返回从系统启动起经过的毫秒数:

GetTickCount PROTO              ; EAX 为返回值

由于返回值为一个双字,因此当系统连续运行 49.7 天后,时间将会回绕归零。可以使用这个函数监视循环经过的时间,并在达到时间限制时终止循环。

下面的程序 Timer.asm 计算两次调用 GetTickCount 之间的时间间隔。程序尝试确定计时器没有回绕(超过 49.7 天)。相似的代码可以用于各种程序:

;计算经过的时间    (Timer.asm)
;用Win32函数GetTickCount演示一个简单的秒表计时器。
INCLUDE Irvine32.inc
INCLUDE macros.inc
.data
startTime DWORD ?
.code
main PROC
    INVOKE GetTickCount         ; 获取开始时间计数
    mov    startTime,eax        ; 保存开始时间计数
; Create a useless calculation loop.
    mov    ecx,10000100h
L1:    imul    ebx
    imul    ebx
    imul    ebx
    loop    L1
    INVOKE GetTickCount         ; 获得新的时间计数
    cmp    eax,startTime        ; 比开始时间计数小
    jb    error                 ; 时间回绕

    sub    eax,startTime        ; 计算时间间隔
    call    WriteDec            ; 显示时间间隔
    mWrite <" milliseconds have elapsed",0dh,0ah>
    jmp    quit
error:
    mWrite "Error: GetTickCount invalid--system has "
    mWrite <"been active for more than 49.7 days",0dh,0ah>
quit:
    exit
main ENDP
END main

Sleep 函数

有些时候程序需要暂停或延迟一小段时间。虽然可以通过构造一个计算循环或忙循环来保持处理器工作,但是不同的处理器会使得执行时间不同。另外,忙循环还不必要地占用了处理器,这会降低在同一时间执行程序的速度。

Win32 函数 Sleep 按照指定毫秒数暂停当前执行的线程:

Sleep PROTO,
   dwMilliseconds:DWORD

由于本教程中汇编语言程序是单线程的,因此假设一个线程就等同于一个程序。当线程休眠时,它不会消耗处理器时间。

GetDateTime 过程

Irvine32 链接库中的过程 GetDateTime 以 100 纳秒为间隔,返回从 1601 年 1 月 1 日起经过的时间间隔数。这看起来有点奇怪,因为那个时候计算机还是未知的。对任何事件,Microsoft 都用这个值来跟踪文件日期和时间。

如果想要为日期计算准备系统日期/时间值,Win32 SDK 建议采用如下步骤:

  • 调用函数,如 GetLocalTime,填充 SYSTEMTIME 结构。

  • 调用函数 SystemTimeToFileTime,将 SYSTEMTIME 结构转换为 FILETIME 结构。

  • 将得到的 FILETIME 结构复制到 64 位的四字。

FILETIME 结构把 64 位四字分割为两个双字:

FILETIME STRUCT
   loDateTime DWORD ?
   hiDateTime DWORD ?
FILETIME ENDS

下面的 GetDateTime 过程接收一个指针,指向 64 位四字变量。它用 Win32 FILETIME 格式将当前日期和时间保存到变量中:

;--------------------------------------------------
GetDateTime PROC,
    pDateTime:PTR QWORD
    LOCAL sysTime:SYSTEMTIME, flTime:FILETIME
;
; 以64位整数形式 ( 按 Win32 FILETIME 格式 ) 获得并保存当前本地时间/日期
;--------------------------------------------------
; 获得系统本地时间
    INVOKE GetLocalTime,
      ADDR sysTime
; SYSTEMTIME 转换为 FILETIME.
    INVOKE SystemTimeToFileTime,
      ADDR sysTime,
      ADDR flTime
; 把 FILETIME 复制到一个64位整数
    mov esi,pDateTime
    mov eax,flTime.loDateTime
    mov DWORD PTR [esi],eax
    mov eax,flTime.hiDateTime
    mov DWORD PTR [esi+4],eax
    ret
GetDateTime ENDP

汇编语言64位Windows API使用简述

任何对 Windows API 的 32 位调用都可以重新编写为 64 位调用。只需要记住几个关键 点就可以:

\1) 输入与输出句柄是 64 位的。

\2) 调用系统函数前,主调程序必须保留至少 32 字节的影子空间,其方法是将堆栈指针(RSP)寄存器减去 32。这使得系统函数能利用这个空间保存 RCX、RDX、R8 和 R9 寄存器的临时副本。

\3) 调用系统函数时,RSP 需对齐 16 字节地址边界(基本上,任何十六进制地址的最低位数字都是 0)。幸运的是,Win64 API 似乎没有强制执行这条规则,而且在应用程序中对堆栈对齐进行精确控制往往是比较困难的。

\4) 系统调用返回后,主调方必须回复 RSP 的初始值,方法是加上在函数调用前减去的数值。如果是在子程序中调用 Win64 API,那么这一点非常重要,因为在执行 RET 指令时,ESP 最终须指向子程序的返回地址。

\5) 整数参数利用 64 位寄存器传递。

\6) 不允许使用 INVOKE。取而代之,前 4 个参数要按照从左到右的顺序,依次放入这 4 个寄存器:RCX、RDX、R8 和 R9。其他参数则压入运行时堆栈。

\7) 系统函数用 RAX 存放返回的 64 位整数值。

下面的代码行演示了如何从 Irvine64 链接库中调用 64 位 GetStdHandle 函数:

.data
STD_OUTPUT_HANDLE EQU -11
consoleOutHandle QWORD ?
.code
sub rsp, 40                  ;预留影子空间 & 对齐 RSP
mov rex,STD_OUTPUT_HANDLE
call GetstdHandle   -
mov consoleOutHandle,rax
add rsp,40

一旦控制台输出句柄被初始化,可以用后面的代码来演示如何调用 64 位 WriteConsoleA 函数。

这里有 5 个参数:RCX(控制台句柄)、RDX(字符串指针)、R8(字符串长度)、 R9(byteWritten 变量指针),以及最后一个虚拟零参数,它位于 RSP 上面的第 5 个堆栈位置。

WriteString proc uses rex rdx r8 r9
    sub rsp, (5*8)            ;为 5 个参数预留空间
    movr cx,rdx
    call Str_length           ;用 EAX 返回字符串长度
    mov rcx,consoleOutHandle
    mov rdx, rdx              ;字符串指针
    mov r8, rax               ;字符串长度
    lea r9,bytesWritten
    mov qword ptr [rsp + 4 * SIZEOF QWORD], 0 ; 总是 0
    call WriteConsoleA
    add rsp,(5*8)             ;恢复 RSP
    ret
WriteString ENDP

汇编语言如何编写图形化的Windows应用程序

本节将展示如何为 32 位 Microsoft Windows 编写简单的图形化应用程序。该程序创建并显示一个主窗口,显示消息框,并响应鼠标事件。本节内容为简介性质,如果希望了解更多信息,请参阅 Platform SDK 文档

下表列出了编写该程序时需要的各种链接库和头文件。

文件名 说明
WinApp.asm 程序源代码
GraphWin.asm 头文件,包含程序要使用的结构、常量和函数原型
kernel32.lib 前面使用的 MS-Windows API 链接库
user32.lib 其他 MS-Windows API 函数

/SUBSYSTEM:WINDOWS 代替了之前章节中使用的 /SUBSYSTEM:CONSOLE。程序从 kernel32.lib 和 user32.lib 这两个标准 MS-Windows 链接库中调用函数。

主窗口

本程序显示一个全屏主窗口。为了让窗口适合本书页面,这里缩小了它的尺寸

必要的结构

结构 POINT 以像素为单位,定义屏幕上一个点的 X 坐标和 Y 坐标。它可以用于定位图形对象、窗口和鼠标点击:

POINT STRUCT
   ptX DWORD ?
   ptY DWORD ?
POINT ENDS

结构 RECT 定义矩形边界。成员 left 为矩形左边的 X 坐标,成员 top为矩形上边的 Y 坐标。成员 right 和 bottom 保存矩形类似的值:

RECT STRUCT
   left DWORD ?
   top DWORD ?
   right DWORD ?
   bottom DWORD ?
RECT ENDS

结构 MSGStruct 定义 MS-Windows 需要的数据:

MSGStruct STRUCT
   msgWnd DWORD ?
   msgMessage DWORD ?
   msgWparam DWORD ?
   msgLparam DWORD ?
   msgTime   DWORD ?
   msgPt POINT <>
MSGStruct ENDS

结构 WNDCLASS 定义窗口类。程序中的每个窗口都必须属于一个类,并且每个程序都必须为其主窗口定义一个窗口类。在主窗口可以显示之前,这个类必须要注册到操作系统:

WNDCLASS STRUC
   style DWORD ?                ;窗口样式选项
   lpfnWndProc DWORD ?                ; winProc 函数指针
   cbClsExtra DWORD ?           ;共享内存
   cbWndExtra DWORD ?          ;附加字节数
   hlnstance DWORD ?            ;当前程序句柄
   hlcon DWORD ?               ;图标句柄
   hCursor DWORD ?             ;光标句柄
   hbrBackground DWORD ?       ;背景刷句柄
   IpszMenuName DWORD ?       ;菜单名指针
   IpszClassName DWORD ?       ; WinCZLass 名指针
WNDCLASS ENDS

下面对上述参数进行简单小结:

  • style 是不同样式选项的集合,比如 WS_CAPTION 和 WS_BORDER,用于控制窗口外观和行为。

  • lpfnWndProc 是指向函数的指针,该函数接收并处理由用户触发的事件消息。

  • cbClsExtra 指向一个类中所有窗口使用的共享内存。可以为空。

  • cbWndExtra 指定分配给后面窗口实例的附加字节数。

  • hInstance 为当前程序实例的句柄。

  • hIcon 和 hCursor 分别为当前程序中图标资源和光标资源的句柄。

  • hbrBackground 为背景(颜色)刷的句柄。

  • lpszMenuName 指向一个菜单名。

  • lpszClassName 指向一个空字节结束的字符串,该字符串中包含了窗口的类名称。

汇编语言MessageBox函数:显示一个简单的消息框

对程序而言,显示文本最简单的方法是将文本放入弹出消息框中,并等待用户点击按钮。Win32 API 链接库的 MessageBox 函数能显示一个简单的消息框。其函数原型如下:

MessageBox PROTO,
hWnd:DWORD,
lpText:PTR BYTE,
lpCaption:PTR BYTE,
uType:DWORD

其中:

  • hWnd 是当前窗口的句柄。

  • lpText 指向一个空字节结束的字符串,该字符串将在消息框中显示。

  • lpCaption 指向一个空字节结束的字符串,该字符串将在消息框的标题栏中显示。

  • style 是一个整数,用于描述对话框的图标(可选)和按钮(必选)。

按钮由常数标识,如 MB_OK 和 MB_YESNO。图标也由常数标识,如 MB_ICONQUESTION。

显示消息框时, 可以同时添加图标常数和按钮常数:

INVOKE MessageBox, hWnd, ADDR QuestionText,
  ADDR QuestionTitle, MB_OK + MB_ICONQUESTION

汇编语言WinMain过程:应用程序的启动过程

每个 Windows 应用程序都需要一个启动过程,通常将其命名为 WinMain,该过程负责下述任务:

  • 得到当前程序的句柄。

  • 加载程序的图标和光标。

  • 注册程序的主窗口类,并标识处理该窗口事件消息的过程。

  • 创建主窗口。

  • 显示并更新主窗口。

  • 开始接收和发送消息的循环,直到用户关闭应用程序窗口。

WinMain 包含一个名为 GetMessage 的消息处理循环,从程序的消息队列中取出下一条可用消息。如果 GetMessage 取出的消息是 WM_QUIT,则返回零,即通知 WinMain 暂停程序。

对于其他消息,WinMain 将它们传递给 DispatchMessage 函数,该函数再将消息传递给程序的 WinProc 过程。若想进一步了解消息,请查阅 Platform SDK 文档的 Windows Messages。

汇编语言WinProc过程:接收并处理所有与窗口有关的事件消息

WinProc 程接收并处理所有与窗口有关的事件消息。这些事件绝大多数是由用户通过点击和拖动鼠标、按下键盘按键等操作发起的。这个过程的工作就是解码每个消息,如果消息得以识别,则在应用程序中执行与该消息相关的任务。

过程声明如下:

WinProc PROC,
hWnd: DWORD,     ;窗口句柄
localMsg: DWORD,  ;消息 ID
wParam:DWORD,    ;参数 1 (可变)
lParam: DWORD     ;参数 2 (可变)

根据具体的消息 ID,第三个和第四个参数的内容可变。比如,若点击鼠标,那么 lParam 就为点击位置的 X 坐标和 Y 坐标。在后面的示例程序中,WinProc 过程处理了三种特定的消息:

  • WM_LBUTTONDOWN,用户按下鼠标左键时产生该消息

  • WM_CREATE,表示刚刚创建主窗口

  • WM_CLOSE,表示将要关闭应用程序主窗口

比如,下面的代码行通过调用 MessageBox 向用户显示一个弹出消息框来处理 WM_LBUTTONDOWN:

.IF eax == WM_LBUTTONDOWN
INVOKE MessageBox, hWnd, ADDR PopupText,
    ADDR PopupTitle, MB_OK
jmp WinProcExit

用户所见的结果消息如下图所示。其他不希望被处理的消息都会被传递给 DefWindow-Proc(MS-Windows 默认的消息处理程序)。

汇编语言ErrorHandler过程:获取错误信息

过程 ErrorHandler 是可选的,如果在注册和创建程序主窗口的过程中系统报错,则调用该过程。

比如,如果成功注册程序主窗口,则函数 RegisterClass 返回非零值。但是,如果该函数返回值为零,那么就调用 ErrorHandler( 显示一条消息 ) 并退出程序:

INVOKE RegisterClass, ADDR MainWin
.IF eax == 0
   call ErrorHandler
   jmp Exit_Program
.ENDIF

过程 ErrorHandler 需要执行几个重要任务:

  • 调用 GetLastError 取得系统错误号。

  • 调用 FormatMessage 取得合适的系统格式化的错误消息字符串。

  • 调用 MessageBox 显示包含错误消息字符串的弹出消息框。

  • 调用 LocalFree 释放错误消息字符串使用的内存空间。

汇编语言实例:Windows图形化程序

下面通过实例来演示一下如何通过汇编语言来创建 Windows 图形化程序。不要担心这个程序的长度,其中大部分的代码在任何 MS-Windows 应用程序中都是一样的:

; Windows 应用程序           (WinApp.asm)
; 本程序显示一个可调大小的应用程序窗口和几个弹出消息框
.386
INCLUDE Irvine32.inc
INCLUDE GraphWin.inc
;==================== DATA =======================
.data
AppLoadMsgTitle BYTE "Application Loaded",0
AppLoadMsgText  BYTE "This window displays when the WM_CREATE "
                BYTE "message is received",0
PopupTitle BYTE "Popup Window",0
PopupText  BYTE "This window was activated by a "
           BYTE "WM_LBUTTONDOWN message",0
GreetTitle BYTE "Main Window Active",0
GreetText  BYTE "This window is shown immediately after "
           BYTE "CreateWindow and UpdateWindow are called.",0
CloseMsg   BYTE "WM_CLOSE message received",0
ErrorTitle  BYTE "Error",0
WindowName  BYTE "ASM Windows App",0
className   BYTE "ASMWin",0
; 定义应用程序的窗口类结构
MainWin WNDCLASS <NULL,WinProc,NULL,NULL,NULL,NULL,NULL, \
    COLOR_WINDOW,NULL,className>
msg          MSGStruct <>
winRect   RECT <>
hMainWnd  DWORD ?
hInstance DWORD ?
;=================== CODE =========================
.code
WinMain PROC
; 获得当前过程的句柄
    INVOKE GetModuleHandle, NULL
    mov hInstance, eax
    mov MainWin.hInstance, eax
; 加载程序的图标和光标
    INVOKE LoadIcon, NULL, IDI_APPLICATION
    mov MainWin.hIcon, eax
    INVOKE LoadCursor, NULL, IDC_ARROW
    mov MainWin.hCursor, eax
; 注册窗口类
    INVOKE RegisterClass, ADDR MainWin
    .IF eax == 0
      call ErrorHandler
      jmp Exit_Program
    .ENDIF
; 创建应用程序的主窗口
    INVOKE CreateWindowEx, 0, ADDR className,
      ADDR WindowName,MAIN_WINDOW_STYLE,
      CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,
      CW_USEDEFAULT,NULL,NULL,hInstance,NULL
    mov hMainWnd,eax
; 若 CreateWindowEx 失败,则显示消息并退出
    .IF eax == 0
      call ErrorHandler
      jmp  Exit_Program
    .ENDIF
; 保存窗口句柄,显示并绘制窗口
    INVOKE ShowWindow, hMainWnd, SW_SHOW
    INVOKE UpdateWindow, hMainWnd
; 显示欢迎消息
    INVOKE MessageBox, hMainWnd, ADDR GreetText,
      ADDR GreetTitle, MB_OK
; 启动程序的连续消息处理循环
Message_Loop:
    ; 从队列中取出下一条消息
    INVOKE GetMessage, ADDR msg, NULL,NULL,NULL
    ; 若没有其他消息则退出
    .IF eax == 0
      jmp Exit_Program
    .ENDIF
    ; 将消息传递给程序的 WinProc
    INVOKE DispatchMessage, ADDR msg
    jmp Message_Loop
Exit_Program:
      INVOKE ExitProcess,0
WinMain ENDP
;-----------------------------------------------------
WinProc PROC,
    hWnd:DWORD, localMsg:DWORD, wParam:DWORD, lParam:DWORD
; 应用程序的消息处理过程,处理应用程序特定的消息。
; 其他所有消息则传递给默认的 windows 消息处理过程
;-----------------------------------------------------
    mov eax, localMsg
    .IF eax == WM_LBUTTONDOWN          ; 鼠标按钮?
      INVOKE MessageBox, hWnd, ADDR PopupText,
        ADDR PopupTitle, MB_OK
      jmp WinProcExit
    .ELSEIF eax == WM_CREATE           ; 创建窗口?
      INVOKE MessageBox, hWnd, ADDR AppLoadMsgText,
        ADDR AppLoadMsgTitle, MB_OK
      jmp WinProcExit
    .ELSEIF eax == WM_CLOSE            ; 关闭窗口?
      INVOKE MessageBox, hWnd, ADDR CloseMsg,
        ADDR WindowName, MB_OK
      INVOKE PostQuitMessage,0
      jmp WinProcExit
    .ELSE                              ; 其他消息?
      INVOKE DefWindowProc, hWnd, localMsg, wParam, lParam
      jmp WinProcExit
    .ENDIF
WinProcExit:
    ret
WinProc ENDP
;---------------------------------------------------
ErrorHandler PROC
; 显示合适的系统错误消息
;---------------------------------------------------
.data
pErrorMsg  DWORD ?         ; 错误消息指针
messageID  DWORD ?
.code
    INVOKE GetLastError    ; 用EAX返回消息ID
    mov messageID,eax
    ; 获取相应的消息字符串
    INVOKE FormatMessage, FORMAT_MESSAGE_ALLOCATE_BUFFER + \
      FORMAT_MESSAGE_FROM_SYSTEM,NULL,messageID,NULL,
      ADDR pErrorMsg,NULL,NULL
    ; 显示错误消息
    INVOKE MessageBox,NULL, pErrorMsg, ADDR ErrorTitle,
      MB_ICONERROR+MB_OK
    ; 释放错误消息字符串
    INVOKE LocalFree, pErrorMsg
    ret
ErrorHandler ENDP
END WinMain

运行程序

第一次加载程序时,显示如下消息框:

当用户点击 OK 来关闭 Application Loaded 消息框时,则显示另一个消息框:

当用户关闭 Main Window Active 消息框时,就会显示程序的主窗口 :

当用户在主窗口的任何位置点击鼠标时,显示如下消息框:

当用户关闭该消息框,并点击主窗口右上角上的 X 时,那么在窗口关闭之前将显示如下消息框:

当用户关闭了这个消息框后,则程序结束。

汇编语言动态内存分配

动态内存分配 (dynamic memory allocation),又被称为堆分配 (heap allocation),是编程语言使用的一种技术,用于在创建对象、数组和其他结构时预留内存。比如在 Java 语言中,下面的语句就会为 String 对象保留内存:

String str = new String("abcde");

同样的,在 C++ 中,对变量使用大小属性就可以为一个整数数组分配空间:

int size;
cin >> size;    //用户输入大小
int array[] = new int[size];

C、C++ 和 Java 都有内置运行时堆管理器来处理程序请求的存储分配和释放。程序启动时,堆管理器常常从操作系统中分配一大块内存,并为存储块指针创建空闲列表 (free list)。

当接收到一个分配请求时,堆管理器就把适当大小的内存块标识为已预留,并返回指向该块的指针。之后,当接收到对同一个块的删除请求时,堆就释放该内存块,并将其返回到空闲列表。每次接收到新的分配请求,堆管理器就会扫描空闲列表,寻找第一个可用的、且容量足够大的内存块来响应请求。

汇编语言程序有两种方法进行动态分配:

  • 方法一:通过系统调用从操作系统获得内存块。

  • 方法二:实现自己的堆管理器来服务更小的对象提出的请求。

利用下表中的几个 Win32 API 函数就可以从 Windows 中请求多个不同大小的内存块。表中所有的函数都会覆盖通用寄存器,因此程序可能想要创建封装过程来实现重要寄存器的入栈和出栈操作。

函数 描述
GetProcessHeap 用 EAX 返回程序现存堆区域的 32 位整数句柄。如果函数成功,则 EAX 中的返回值为堆句柄。 如果函数失败,则 EAX 中的返回值为 NULL
HeapAlloc 从堆中分配内存块。如果成功,EAX 中的返回值就为内存块的地址。如果失败,则 EAX 中的返 回值为 NULL
HeapCreate 创建新堆,并使其对调用程序可用。如果函数成功,则 EAX 中的返回值为新创建堆的句柄。如果失败,则 EAX 的返回值为 NULL
HeapDestroy 销毁指定堆对象,并使其句柄无效。如果函数成功,则 EAX 中的返回值为非零
HeapFree 释放之前从堆中分配的内存块,该堆由其地址和堆句柄进行标识。如果内存块释放成功,则返回值为非零
HeapReAlloc 对堆中内存块进行再分配和调整大小。如果函数成功,则返回值为指向再分配内存块的指针。如果函数失败,且没有指定 HEAP GENERATE EXCEPTIONS,则返回值为 NULL
HeapSize 返回之前通过调用 HeapAlloc 或 HeapReAlloc 分配的内存块的大小。如果函数成功,则 EAX 包含被分配内存块的字节数。如果函数失败,则返回值为 SIZE_T-1 ( SIZE_T 等于指针能指向的最大字节数 )

GetProcessHeap

如果使用的是当前程序的默认堆,那么 GetProcessHeap 就足够了。这个函数没有参数,EAX 中的返回值就是堆句柄:

GetProcessHeap PROTO

示例调用:

.data
hHeap HANDLE ?
.code
INVOKE GetProcessHeap
.IF eax == NULL           ;不能获取句柄
    jmp quit
.ELSE
    mov hHeap,eax         ;句柄 ok
.ENDIF

HeapCreate

HeapCreate 能为当前程序创建一个新的私有堆:

HeapCreate PROTO,
   flOptions:DWORD,          ;堆分配选项
   dwInitialSize:DWORD,        ;按字节初始化堆大小
   dwMaximumSize:DWORD       ;最大堆字节数

flOptions 设置为 NULL。dwInitialSize 设置为初始堆字节数,其值的上限为下一页的边界。如果 HeapAlloc 的调用超过了初始堆大小,那么堆最大可以扩展到 dwMaximumSize 参数中指定的大小(上限为下一页的边界)。调用后,EAX 中的返回值为空就表示堆未创建成 功。HeapCreate 的调用示例如下:

HEAP_START = 2000000 ; 2 MB
HEAP_MAX = 400000000 ; 400 MB
.data
hHeap HANDLE ?       ; 堆句柄
.code
INVOKE HeapCreate, 0, HEAP_START, HEAP_MAX
.IF eax == NULL      ; 堆未创建
    call WriteWindowsMsg ; 显示错误消息
    jmp quit
.ELSE
    mov hHeap,eax    ; 句柄 OK
.ENDIF

HeapDestroy

HeapDeatroy 销毁一个已存在的私有堆(由 HeapCreate 创建)。需向其传递堆句柄:

HeapDestroy PROTO,
   hHeap:DWORD         ;堆句柄

如果堆销毁失败,则 EAX 等于 NULL。下面为示例调用,其中使用了 WriteWindowsMsg 过程:

.data
hHeap HANDLE ?                ;堆句柄
.code
INVOKE HeapDestroy, hHeap
.IF eax == NULL
    call WriteWindowsMsg      ;显示错误消息
.ENDIF

HeapAlloc

HeapAlloc 从已存在堆中分配一个内存块:

HeapAlloc PROTO,
   hHeap:HANDLE,    ;现有堆内存块的句柄
   dwFlags :DWORD,   ;堆分配控制标志
   dwBytes:DWORD   ;分配的字节数

需传递下述参数:

  • hHeap:32 位堆句柄,该堆由 GetProcessHeap 或 HeapCreate 初始化。

  • dwFlags:一个双字,包含了一个或多个标志值。可以选择将其设置为 HEAP_ZERO_MEMORY,即设置内存块为全零。

  • dwBytes:一个双字,表示堆分配的字节数。

如果 HeapAlloc 成功,则 EAX 包含指向新存储区的指针;如果失败,则 EAX 中的返回值为 NULL。下面的代码用 hHeap 标识一个堆,从该堆中分配了一个 1000 字节的数组,并将数组初始化为全零:

.data
hHeap HANDLE ?    ;堆句柄
pArray DWORD ?    ;数组指针
.code
INVOKE HeapAlloc, hHeap, HEAP_ZERO_MEMORY, 1000
.IF eax == NULL
    mWrite "HeapAlloc failed"
    jmp quit
.ELSE
    mov pArray,eax
.ENDIF

HeapFree

函数 HeapFree 释放之前从堆中分配的一个内存块,该堆由其地址和堆句柄标识:

HeapFree PROTO,
   hHeap:HANDLE,
   dwFlags:DWORD,
   lpMem:DWORD

第一个参数是包含该内存块的堆的句柄。第二个参数通常为零,第三个参数是指向将被释放内存块的指针。如果内存块释放成功,则返回值非零。如果该块不能被释放,则函数返回零。

示例调用如下:

INVOKE HeapFree, hHeap, 0, pArray

Error Handling

若在调用 HeapCreate、HeapDestroy 或 GetProcessHeap 时遇到错误,可以通过调用 API 函数 GetLastError 来获得详细信息。还可以调用 Irvine32 链接库的函数 WriteWindowsMsg。

HeapCreate 调用示例如下:

INVOKE HeapCreate, 0, HEAP_START, HEAP_MAX
.IF eax == NULL                    ;失败?
    call WriteWindowsMsg           ;显示错误信息
.ELSE
    mov    hHeap,eax               ;成功
.ENDIF

反之,函数 HeapAlloc 在失败时不会设置系统错误码,因此也就无法调用 GetLastError 或 WriteWindowsMsg。

汇编语言实例:动态内存分配

下面的示例程序使用动态内存分配创建并填充了一个 1000 字节的数组:

; 堆测试 #1        (Heaptest1.asm)
INCLUDE Irvine32.inc
; 使用动态内存分配,本程序分配并填充一个字节数据
.data
ARRAY_SIZE = 1000
FILL_VAL EQU 0FFh
hHeap   DWORD ?        ; 程序堆句柄
pArray  DWORD ?        ; 内存块指针
newHeap DWORD ?        ; 新堆句柄
str1 BYTE "Heap size is: ",0
.code
main PROC
    INVOKE GetProcessHeap          ; 获取程序堆句柄
    .IF eax == NULL                ; 如果失败,显示消息
    call    WriteWindowsMsg
    jmp    quit
    .ELSE
    mov    hHeap,eax                ; 成功
    .ENDIF
    call    allocate_array
    jnc    arrayOk                  ; 失败 (CF = 1)?
    call    WriteWindowsMsg
    call    Crlf
    jmp    quit
arrayOk:                            ; 成功填充数组
    call    fill_array
    call    display_array
    call    Crlf
    ; 释放数组
    INVOKE HeapFree, hHeap, 0, pArray

quit:
    exit
main ENDP
;--------------------------------------------------------
allocate_array PROC USES eax
;
; 动态分配数组空间
; 接收: EAX = 程序堆句柄
; 返回: 如果内存分配成功,则 CF = 0
;--------------------------------------------------------
    INVOKE HeapAlloc, hHeap, HEAP_ZERO_MEMORY, ARRAY_SIZE

    .IF eax == NULL
       stc                    ; 返回 CF = 1
    .ELSE
       mov  pArray,eax        ; 保存指针
       clc                    ; 返回 CF = 0
    .ENDIF
    ret
allocate_array ENDP
;--------------------------------------------------------
fill_array PROC USES ecx edx esi
;
; 用一个字符填充整个数组
; 接收: 无
; 返回: 无
;--------------------------------------------------------
    mov    ecx,ARRAY_SIZE             ; 循环计数器
    mov    esi,pArray                 ; 指向数组
L1:    mov    BYTE PTR [esi],FILL_VAL ; 填充每个字节
    inc    esi                        ; 下一个位置
    loop    L1
    ret
fill_array ENDP
;--------------------------------------------------------
display_array PROC USES eax ebx ecx esi
;
; 显示数组
; 接收: 无
; 返回: 无
;--------------------------------------------------------
    mov    ecx,ARRAY_SIZE     ; 循环计数器
    mov    esi,pArray         ; 指向数组

L1:    mov    al,[esi]        ; 取出一个字节
    mov    ebx,TYPE BYTE
    call    WriteHexB         ; 显示该字节
    inc    esi                ; 下一个位置
    loop    L1
    ret
display_array ENDP
END main

下面的示例采用动态内存分配重复分配大块内存,直到超过堆大小。

; 堆测试 #2      (Heaptest2.asm)
INCLUDE Irvine32.inc
.data
HEAP_START =   2000000    ;   2 MB
HEAP_MAX  =  400000000    ; 400 MB
BLOCK_SIZE =    500000    ;  0.5 MB
hHeap DWORD ?             ; 堆句柄
pData DWORD ?             ; 块指针
str1 BYTE 0dh,0ah,"Memory allocation failed",0dh,0ah,0
.code
main PROC
    INVOKE HeapCreate, 0,HEAP_START, HEAP_MAX
    .IF eax == NULL          ; 失败?
    call    WriteWindowsMsg
    call    Crlf
    jmp    quit
    .ELSE
    mov    hHeap,eax          ; 成功
    .ENDIF
    mov    ecx,2000           ; 循环计数器
L1:    call allocate_block    ; 分配一个块
    .IF Carry?                ; 失败?
    mov    edx,OFFSET str1    ; 显示消息
    call    WriteString
    jmp    quit
    .ELSE                     ; 否: 打印一个点来显示进度
    mov    al,'.'
    call    WriteChar
    .ENDIF

    ;call free_block          ; 允许/禁止本行
    loop    L1

quit:
    INVOKE HeapDestroy, hHeap      ; 销毁堆
    .IF eax == NULL                ; 失败?
    call    WriteWindowsMsg        ; 是: 错误消息
    call    Crlf
    .ENDIF
    exit
main ENDP
allocate_block PROC USES ecx
    INVOKE HeapAlloc, hHeap, HEAP_ZERO_MEMORY, BLOCK_SIZE

    .IF eax == NULL
       stc                        ; 返回 CF = 1
    .ELSE
       mov  pData,eax             ; 保存指针
       clc                        ; 返回 CF = 0
    .ENDIF
    ret
allocate_block ENDP
free_block PROC USES ecx
    INVOKE HeapFree, hHeap, 0, pData
    ret
free_block ENDP
END main

汇编语言x86存储管理简述

本节将对 Windows 32 位存储管理进行简要说明,展示它是如何使用 x86 处理器直接内置功能的。重点关注的是存储管理的两个主要方面:

  • 将逻辑地址转换为线性地址

  • 将线性地址转换为物理地址 ( 分页 )

下面先简单回顾一下第2章《x86处理器架构》介绍过的一些 x86 存储管理术语:

  • 多任务处理 (multitasking) 允许多个程序(或任务)同时运行。处理器在所有运行程序中划分其时间。

  • 段 (segments) 是可变大小的内存区,用于让程序存放代码或数据。

  • 分段 (segmentation) 提供了分隔内存段的方法。它允许多个程序同时运行又不会相互干扰。

  • 段描述符 (segment descriptor) 是一个 64 位的值,用于标识和描述一个内存段。它包含的信息有段基址、访问权限、段限长、类型和用法。

现在再增加两个新术语:

  • 段选择符 (segment selector) 是保存在段寄存器 (CS、DS、SS、ES、FS 或 GS) 中的一个 16 位数值。

  • 逻辑地址 (logical address) 就是段选择符加上一个 32 位的偏移量。

汇编语言线性地址简述

在上一节《x86存储管理》中提到了线性地址,接下来为大家简单介绍一下线性地址。

逻辑地址转换为线性地址

多任务操作系统允许几个程序(任务)同时在内存中运行。每个程序都有自己唯一的数据区。假设现有 3 个程序,每个程序都有一个变量的偏移地址为 200h,那么,怎样区分这 3 个变量而不进行共享?

x86 解决这个问题的方法是,用一步或两步处理过程将每个变量的偏移量转换为唯一的内存地址。

第一步,将段值加上变量偏移量形成线性地址 (linear address)。这个线性地址可能就是该变量的物理地址。但是像 MS-Windows 和 Linux 这样的操作系统采用了分页 (paging) 功能,它使得程序能使用比可用物理空间更大的线性空间。这种情况下,就必需采用第二步页转换 (page translation),将线性地址转换为物理地址。

首先了解一下处理器如何用段和选择符来确定变量的线性地址。每个段选择符都指向一个段描述符(位于描述符表中),其中包含了该内存段的基地址。如下图所示,逻辑地址中的 32 位偏移量加上段基址就形成了 32 位的线性地址。

线性地址是一个 32 位整数,其范围为 0FFFFFFFFh,它表示一个内存位置。如果禁止分页功能,那么线性地址也就是目标数据的物 理地址。

分页

分页是 x86 处理器的一个重要功能,它使得计算机能运行在其他情况下无法装入内存的一组程序。处理器初始只将部分程序加载到内存,而程序的其他部分仍然留在硬盘上。

程序使用的内存被分割成若干小区域,称为页 (page),通常一页大小为 4KB。当每个程序运行时,处理器会选择内存中不活跃的页面替换出去,而将立即会被请求的页加载到内存。

操作系统通过维护一个页目录 (page directory) 和一组页表 (page table) 来持续跟踪当前内存中所有程序使用的页面。当程序试图访问线性地址空间内的一个地址时,处理器会自动将线性地址转换为物理地址。这个过程被称为页转换 (page translation)。

如果被请求页当前不在内存中,则处理器中断程序并产生一个页故障 (page fault)。操作系统将被请求页从硬盘复制到内存,然后程序继续执行。从应用程序的角度看,页故障和页转换都是自动发生的。

使用 Microsoft Windows 工具任务管理器(task manager)就可以查看物理内存和虚拟内存的区别。如下图所示计算机的物理内存为 256MB。任务管理器的 Commit Charge 框内为当前可用的虚拟内存总量。虚拟内存的限制为 633MB,大大高于计算机的物理内存。

描述符表

段描述符可以在两种表内找到:全局描述符表(global description table)和局部描述符表(local description table)。

全局描述符表(GDT)开机过程中,当操作系统将处理器切换到保护模式时,会创建唯——张 GDT,其基址保存在 GDTR(全局描述符表寄存器)中。表中的表项(称为段描述符)指向段。操作系统可以选择将所有程序使用的段保存在 GDT 中。

局部描述符表(LDT)在多任务操作系统中,每个任务或程序通常都分配有自己的段描述符表,称为 LDT。LDTR 寄存器保存的是程序 LDT 的地址。每个段描述符都包含了段在线性地址空间内的基地址。

一般,段与段之间是相互区分的。如下图所示,图中有三个不同的逻辑地址,这些地址选择了 LDT 中三个不同的表项。这里,假设禁止分页,因此, 线性地址空间也是物理地址空间。

段描述符详细信息

除了段基址,段描述符还包含了位映射字段来说明段限长和段类型。只读类型段的一个例子就是代码段。如果程序试图修改只读段,则会产生处理器故障。

段描述符可以包含保护等级,以便保护操作系统数据不被应用程序访问。下面是对每个描述符字段的说明:

1) 基址

一个 32 位整数,定义段在 4GB 线性地址空间中的起始地址。

2) 特权级

每个段都可以分配一个特权级,特权级范围从 0 到 3,其中 0 级为最高级,一般用于操作系统核心代码。如果特权级数值高的程序试图访问特权级数值低的段,则发生处理器故障。

3) 段类型

说明段的类型并指定段的访问类型以及段生长的方向(向上或向下)。数据(包括堆栈)段可以是可读类型或读/写类型,其生长方向可以是向上的也可以是向下的。代码段可以是只执行类型或执行/只读类型。

4) 段存在标志

这一位说明该段当前是否在物理内存中。

5) 粒度标志

确定对段限长字段的解释。如果该位清零,则段限长以字节为单位。如果该 位置 1,则段限长的解释单位为 4096 字节。

6) 段限长:

这个 20 位的整数指定段大小。按照粒度标志,这个字段有两种解释:

  • 该段有多少字节,范围为 1〜1MB。

  • 该段包含多少个 4096 字节,允许段大小的范围为 4KB〜4GB。

汇编语言页转换:线性地址转换位物理地址

若允许分页,则处理器必须将 32 位线性地址转换为 32 位物理地址。这个过程会用到 3 种结构:

  • 页目录:一个数组,最多可包含 1024 个 32 位页目录项。

  • 页表:一个数组,最多可包含 1024 个 32 位页表项。

  • 页:4KB 或 4MB 的地址空间。

为了简化下面的叙述,假设页面大小为 4KB:

线性地址分为三个字段:页目录表项指针、页表项指针和页内偏移量。控制寄存器(CR3)保存了页目录的起始地址。如下图所示,处理器在进行线性地址到物理地址的转换时,采用如下步骤:

\1) 线性地址引用线性地址空间中的一个位置。

\2) 线性地址中 10 位的目录字段是页目录项的索引。页目录项包含了页表的基址。

\3) 线性地址中 10 位的页表字段是页表的索引,该页表由页目录项指定。索引到的页表项包含了物理内存中页面的基址。

\4) 线性地址中 12 位的偏移量字段与页面基址相加,生成的恰好是操作数的物理地址。

操作系统可以选择让所有的运行程序和任务使用一个页目录,或者选择让每个任务使用一个页目录,还可以选择为两者的组合。

Windows 虚拟机管理器

现在对 IA-32 如何管理内存已经有了总体了解,那么看看 Windows 如何处理内存管理可能也会令人感兴趣。

虚拟机管理器(VMM)是 Windows 内核中的 32 位保护模式操作系统。它创建、运行、监视和终止虚拟机。它管理内存、进程、中断和异常。它与虚拟设备(virtual device)一起工作,使得它们能拦截中断和故障,以此来控制对硬件和已安装软件的访问。

VMM 和虚拟设备运行在特权级为 0 的单一 32 位平坦模式地址空间中。系统创建两个全局描述符表项(段描述符),一个是代码段的,一个是数据段的。段固定在线性地址 0。VMM 提供多线程和抢先多任务处理。通过共享运行应用程序的虚拟机之间的 CPU 时间,它可以同时运行多个应用程序。

在上面的文字中,可以将虚拟机解释为 Intel 中的过程或任务。它包含了程序代码、支撑软件、内存和寄存器。每个虚拟机都被分配了自己的地址空间、I/O 端口空间、中断向量表和局部描述符表。运行于虚拟 8086 模式的应用程序特权级为 3。Windows 中保护模式程序的特权级为 0 和 3。

浮点数处理与指令编码

汇编语言IEEE二进制浮点数表示

x86 处理器使用的三种浮点数二进制存储格式都是由 IEEE 标准 754-1985。二进制浮点数运算 (Standard 754-1985 for Binary Floating-Point Arithmetic) 所指定。

下表列出了它们的特点。

单精度 32 位:1 位符号位,8 位阶码,23 位为有效数字的小数部分。大致的规格化范围:2-126 〜2127 。也被称为短实数 (short real)
双精度 64 位:1 位符号位,11 位阶码,52 位为有效数字的小数部分。大致的规格化范围:2-1022 〜21023 。也被称为长实数 (longreal)
扩展双精度 80 位:1 位符号位,15 位阶码,1 位为整数部分,63 位为有效数字的小数部分。大致的规格化范围:2-16382〜216383。也被称为扩展实数 (extended real)

由于三种格式比较相似,因此本节将重点关注单精度格式,如下图所示。32 位数值的最高有效位(MSB) 在最左边。标注为小数 (fraction) 的字段表示的是有效数字的小数部分。如同预想的一样,各个字节按照小端顺序(最低有效位 (LSB) 在起始地址上)存放在内存中

1) 符号位

如果符号位为 1,则该数为负;如果符号位为 0,则该数为正。零被认为是正数。

2) 有效数字

在浮点数表达式 m*be 中,m 称为有效数字或尾数;b 为基数;e 为阶码。浮点数的有效数字(或尾数)由小数点左右的十进制数字构成。同样的概念也可以扩展到浮点数的小数部分。例如,十进制数 123.154 可以表示为下面的累加和形式:

小数点左边数字的阶码都为正,右边数字的阶码都为负。

二进制浮点数也可以使用加权位计数法。浮点数十进制数值 11.1011 表示为:

小数点右边的数字还有一种表达方式,即将它们列为分数之和,其中分母为 2 的幂。上例的和为 11/16 ( 或 0.6875):

.1011 = 1/2+0/4+1/8+1/16=11/16

生成的小数部分非常直观。十进制分子 (11) 表示的就是二进制位组合 1011。如果小数点右边的有效位个数为 e 则十进制分母就为 2e :上例中,e=4,则有 2e=16。下表列出了更多的例子,来展示将二进制浮点数转换为以 10 为基数的分数。

二进制浮点数 基数为 10 的分数 二进制浮点数 基数为 10 的分数
11.11 3 3/4 0.00101 5/32
101.0011 5 3/16 1.011 1 3/8
1101.100101 13 37/64 1E-23 1/8388608

表中最后一项为 23 位规格化有效数字可以保存的最小分数。为便于参考,下表列出了二进制浮点数及其等价的十进制分数和十进制数值。

二进制 十进制分数 十进制数值 二进制 十进制分数 十进制数值
0.1 1月2日 0.5 0.0001 1月16日 0.0625
0.01 1月4日 0.25 0.00001 1/32 0.03125
0.001 1月8日 0.125

3) 有效数字的精度

用有限位数表示的任何浮点数格式都无法表示完整连续的实数。例如,假设一个简单的浮点数格式有 5 位有效数字,那么将无法表示范围在 1.1111〜10.000 之间的二进制数。比如,二进制数 1.11111 就需要更精确的有效数字。将这个思想扩展到 IEEE 双精度格式,就会发现其 53 位有效数字无法表示需要 54 位或更多位的二进制数值。

汇编语言阶码简介

单精度数用 8 位无符号整数存放阶码,引入的偏差为 127,因此必须在数的实际阶码上再加 127。考虑二进制数值 1.101 x 25 :将实际阶码 (5) 加上 127 后,形成的偏移码 (132) 保存到数据表示形式中。

下表给出了阶码的有符号十进制、偏移十进制,以及最后一列的无符号二进制。

阶码(E) 偏移码(E+127) 二进制 阶码(E) 偏移码(E+127) 二进制
5 132 10000100 127 254 11111110
0 127 1111111 -126 1 1
-10 117 1110101 -1 126 1111110

偏移码总是正数,范围为 1〜254。如前所述,实际阶码的范围为 -126〜+127。这个经过选择的范围,使得最小可能阶码的倒数也不会发生溢出。

汇编语言规格化二进制浮点数

大多数二进制浮点数都以规格化格式 (normalized form) 存放,以便将有效数字的精度最大化。给定任意二进制浮点数,都可以进行规格化,方法是将二进制小数点移位,直到小数点左边只有一个“1”。

阶码表示的是二进制小数点向左(正阶码)或向右(负阶码)移动的位数。示例如下:

反规格化数

规格化操作的逆操作是将二进制浮点数反规格化 (denormalize) ( 或非规格化 (unnormalize))。移动二进制小数点,直到阶码为 0。如果阶码为正数 n,则将二进制小数点右移 n 位;如果阶码为负数 n,则将二进制小数点左移 n 位,并在需要位置填充刖导数 0。

实数编码

一旦符号位、阶码和有效数字字段完成规格化和编码后,生成一个完整的二进制 IEEE 段实数就很容易了。首先将设置符号位,然后是阶码字段,最后是有效数字的小数部分。例如,下面表示的是二进制

  • 符号位:0

  • 阶码:01111111

  • 小数部分:10100000000000000000000

偏移码 (01111111) 是十进制数 127 的二进制形式。所有规格化有效数字在二进制小数点的左边都有个 1,因此,不需要对这一位进行显式编码。更多的例子参见下表。

二进制数值 偏移阶码 符号、阶码、小数部分
-1.11 127 1 01111111 11000000000000000000000
1101.101 130 0 10000010 10110100000000000000000
-0.00101 124 1 0111110001000000000000000000000
100111 132 0 10000100 00111000000000000000000
0.00000011 120 001111000 10101100000000000000000

IEEE 规范包含了多种实数和非数字编码。

  • 正零和负零

  • 非规格化有限数

  • 规格化有限数

  • 正无穷和负无穷

  • 非数字 (NaN,即不是一个数字 (Not a Number))

  • 不定

不定数被浮点单元 (FPU) 用于响应一些无效的浮点操作。

规格化和非规格化

规格化有限数 (nonnalized finite numbers) 是指所有非零有限值,这些数能被编码为零到无穷之间的规格化实数。尽管看上去全部有限非零浮点数都应被规格化,但是若数值接近于零,则无法规格化。

当阶码范围造成的限制使得 FPU 不能将二进制小数点移动到规格化位置时,就会发生这种情况。假设 FPU 计算结果为

其阶码太小,无法用单精度数形式存放。此时产生一个下溢异常,数值则每次将二进制小数点左移一位逐步进行非规格化,直到阶码达到有效范围:

在这个例子中,移动二进制小数点导致有效数字损失了精度。

正无穷和负无穷

正无穷 (+∞) 表示最大正实数,负无穷 (-∞) 表示最大负实数。无穷可以与其他数值比较:-∞ 小于 +∞,-∞ 小于任意有限数,+∞ 大于任意有限数。任一无穷都可以表示浮点溢出条件。运算结果不能规格化的原因是,结果的阶码太大而无法用有效阶码位数来表示。

NaN

NaN 是不表示任何有效实数的位模式。x86 有两种 NaN:quiet NaN 能够通过大多数算术运算来传递,而不会引起异常。signaling NaN 则被用于产生一个浮点无效操作异常。

编译器可以用 signaling NaN 填充未初始化数组,那么,任何试图在这个数组上执行的运算都会引发异常。quiet NaN 可以用于存在调试期间生成的诊断信息。程序可根据需要自由地在 NaN 中编入任何信息。FPU 不会尝试在 NaN 上执行操作。Intel 手册有一组规则确定了以这两种 NaN 为操作数的指令结果。

特定编码

在浮点运算中,常常会出现一些特定的数值编码,如下表所示。字母 x 表示的位,其值可以为 1,也可以为 0。 QNaN 是 quiet NaN, SNaN 是 signaling NaN。

数值 符号、阶码、有效数字
Positive zero 0 00000000 00000000000000000000000
Negative zero 1 00000000 00000000000000000000000
Positive infinity 0 11111111 00000000000000000000000
Negative infinity 1 11111111 00000000000000000000000
QNaN x 11111111 1xxxxxxxxxxxxxxxxxxxxxx
SNaN x 11111111 0xxxxxxxxxxxxxxxxxxxxxx

汇编语言十进制小数转换为二进制实数

当十进制小数可以表示为形如 (1/2+1/4+1/8+…) 的分数之和时,发现与之对应的二进制实数就非常容易了。如下表所示,左列中的大多数分数不容易转换为二进制。不过,可以将它们写成第二列的形式。

很多实数,如 1/10(0.1)或 1/100(0.01),不能表示为有限位的二进制数,它们只能近似地表示为一组以 2 的幂为分母的分数之和。想想看,像 $39.95 这样的货币值受到了怎样的影响!

使用二进制长除法

当十进制数比较小的时候,将十进制分数转换为二进制的一个简单方法就是:先将分子与分母转换为二进制,再执行长除。例如,十进制数 0.5 表示为分数就是 5/10,那么十进制 5 等于二进制 0101,十进制 10 等于二进制 1010。执行了长除之后,商为二进制数 0.1:

当被除数减去除数 1010 的结果为 0 时,除法完成。因此,十进制分数 5/10 等于二进制数 0.1。这种方法被称为二进制长除法(binary long division method)。

下面用二进制长除法将十进制数 0.2(2/10)转换为二进制数。首先,用二进制 10 除以二进制 1010(十进制 10):

第一个足够大到能上商的数是 10000。从 10000 减去 1010 后,余数为 110。添加一个 0 后,形成新的被除数 1100。从 1100 减去 1010 后,余数为 10。添加三个 0 后,形成新的被除数 10000。

这个数与第一个被除数相同。从这里开始,商的位序列出现重复(0011…),由此可知,不会得到确定的商,所以,0.2 也不能表示为有限位的数。其单精度编码的有效数字为 10011001100110011001100。

单精度数转换为十进制

IEEE 单精度数转换为十进制时,建议步骤如下:

\1) 若 MSB 为 1,该数为负;否则,该数为正。

\2) 其后 8 位为阶码。从中减去二进制值 01111111(十进制数 127),生成无偏差阶码。将无偏差阶码转换为十进制。

\3) 其后 23 位表示有效数字。添加“1.”,后面紧跟有效数字位,尾随零可以忽略。用形成的有效数字、第一步得到的符号和第二步计算出来的阶码,就构成一个二进制浮点数。

\4) 对第三步生成的二进制数进行非规格化。(按照阶码的值移动二进制小数点。如果阶码为正,则右移;如果阶码为负,则左移。)

\5) 利用加权位计数法,从左到右,将二进制浮点数转换为 2 的幂之和,形成十进制数。

【示例】IEEE(0 10000010 01011000000000000000000)转换为十进制

\1) 该数为正数。

\2) 无偏差阶码的二进制值为 00000011,十进制值为 3。

\3) 将符号、阶码和有效数字组合起来即得该二进制数为 +1.01011 x2³。

\4) 非规格化二进制数为 +1010.11。

\5) 则该数的十进制值为 +10 3/4,或 +10.75。

汇编语言FPU寄存器栈(register stack)

FPU 不使用通用寄存器 (EAX、EBX 等等)。反之,它有自己的一组寄存器,称为寄存器栈 (register stack)。数值从内存加载到寄存器栈,然后执行计算,再将堆栈数值保存到内存。

FPU 指令用后缀 (postfix) 形式计算算术表达式,这和惠普计算器的方法大致相同。比如,现有一个中缀表达式 (infix expression):(5*6)+4,其后缀表达式为:

5 6 * 4 +

中缀表达式 (A+B)*C 要用括号来覆盖默认的优先级规则(乘法在加法之前)。与之等效的后缀表达式则不需要括号:

表达式堆栈

在计算后缀表达式的过程中,用堆栈来保存中间结果。下图展示了计算后缀表达式 56*4- 所需的步骤。堆栈条目被标记为 ST(0) 和 ST(1),其中 ST(0) 表示堆栈指针通常所指位置。

中缀表达式转换为后缀表达式的常见方法在互联网以及计算机科学入门读物中都可以查阅到,此处不再赘述。下表给岀了一些等价表达式。

中缀 后缀 中缀 后缀
A+B AB+ (A+B)*(C+D) AB+CD+*
(A-B)/D AB-D/ ((A+B)/C)*(E—F) AB+C/EF-*

FPU 数据寄存器

FPU 有 8 个独立的、可寻址的 80 位数据寄存器 R0〜R7,如下图所示,这些寄存器合称为寄存器栈。FPU 状态字中名为 TOP 的一个 3 位字段给出了当前处于栈顶的寄存器编号。例如,在下图中,TOP 等于二进制数 011,这表示栈顶为 R3。在编写浮点指令时,这个位置也称为 ST(0)(或简写为 ST)。最后一个寄存器为 ST(7)。

如同所想的一样,入栈(push)操作(也称为加载)将 TOP 减 1,并把操作数复制到标识为 ST(0) 的寄存器中。如果在入栈之前,TOP 等于 0,那么 TOP 就回绕到寄存器 R7。

出栈(pop)操作(也称为保存)把 ST(0) 的数据复制到操作数,再将TOP加1。如果在出栈之前,TOP 等于 7,则 TOP 就回绕到寄存器 R0。

如果加载到堆栈的数值覆盖了寄存器栈内原有的数据,就会产生一个浮点异常(floating-point exception)。下图展示了数据 1.0 和 2.0 入栈后的堆栈情况。

尽管理解 FPU 如何用一组有限数量的寄存器实现堆栈很有意思,但这里只需关注 ST(n),其中 ST(0) 总是表示栈顶。从这里开始,引用栈寄存器时将使用 ST(0),ST(1),以此类推。指令操作数不能直接引用寄存器编号。

寄存器中浮点数使用的是 IEEE 10 字节扩展实数格式(也被称为临时实数(temporary real))。当 FPU 把算术运算结果存入内存时,它会把结果转换成如下格式之一:整数、长整

数、单精度(短实数)、双精度(长实数),或者压缩二进制编码的十进制数(BCD)。

专用寄存器

FPU 有 6 个专用(special-purpose)寄存器,如下图所示:

  • 操作码寄存器:保存最后执行的非控制指令的操作码。

  • 控制寄存器:执行运算时,控制精度以及 FPU 使用的舍入方法。还可以用这个寄存器来屏蔽(隐藏)单个浮点异常。

  • 状态寄存器:包含栈顶指针、条件码和异常警告。

  • 标识寄存器:指明 FPU 数据寄存器栈内每个寄存器的内容。其中,每个寄存器都用两位来表示该寄存器包含的是一个有效数、零、特殊数值 (NaN、无穷、非规格化,或不支持的格式 ),还是为空。

  • 最后指令指针寄存器:保存指向最后执行的非控制指令的指针。

  • 最后数据(操作数)指针寄存器:保存指向数据操作数的指针,如果存在,那么该数被最后执行的指令所使用。

操作系统使用这些专用寄存器在任务切换时保存状态信息。

汇编语言FPU舍入:计算浮点数的精确结果

FPU 尝试从浮点计算中产生非常精确的结果。但是,在很多情况下这是不可能的,因为目标操作数可能无法精确表示计算结果。比如,假设现有一特定存储格式只允许 3 个小数位。那么,该格式可以保存形如 1.011 或 1.101 的数值,而不能保存形如 1.0101 的数值。

若计算的精确结果为 +1.0111 (十进制数 1.4375),那么,既可以通过加 0.0001 向上舍入该数,也可以通过减 0.0001 向下舍入:

(a) 1.0111 -> 1.100

(b) 1.0111 -> 1.011

若精确结果是负数,那么加 -0.0001 会使舍入结果更接近 -∞。而减去 -0.0001 会使舍入结果更接近 0 和 +8:

(a) -1.0111 -> -1.100

(b) -1.0111 -> -1.011

FPU 可以在四种舍入方法中进行选择:

\1) 舍入到最接近的偶数 (round to nearest even):舍入结果最接近无限精确的结果。如果有两个值近似程度相同,则取偶数值 (LSB=0)。

\2) 向 -∞ 舍入 (round down to -∞ ):舍入结果小于或等于无限精确结果。

\3) 向 +∞ 舍入 (round down to +∞ ):舍入结果大于或等于无限精确结果。

\4) 向 0 舍入 (round toward zero):也被称为截断法,舍入结果的绝对值小于或等于无限精确结果。

FPU 控制字

FPU 控制字用两位指明使用的舍入方法,这两位被称为 RC 字段,字段数值(二进制)如下:

  • 00:舍入到最接近的偶数(默认)。

  • 01:向负无穷舍入。

  • 10:向正无穷舍入。

  • 11:向 0 舍入(截断)。

舍入到最接近的偶数是默认选择,它被认为是最精确的,也最适合大多数应用程序。下表以二进制数 +1.0111 为例,展示了四种舍入方法。

方法 精确结果 舍入结果 方法 精确结果 舍入结果
舍入到最接近的偶数 1.0111 1.1 向 +∞ 舍入 1.0111 1.1
向 -∞ 舍入 1.0111 1.011 向 0 舍入 1.0111 1.011

同样,下表展示了二进制数 -1.0111 的舍入结果。

汇编语言浮点数异常与常用指令集

每个程序都可能出错,而 FPU 就需要处理这些结果。因而,它要识别并检测 6 种类型的异常条件:

  • 无效操作(#I)

  • 除零(#Z)

  • 非规格化操作数(#D)

  • 数字上溢(#O)

  • 数字下溢(#U)

  • 模糊精度(#P)

前三个(#I、#Z 和 #D)在全部运算操作发生前进行检测,后三个(#O、#U 和 #P)则在操作发生后检测。

每种异常都有对应的标志位和屏蔽位。当检测到浮点异常时,处理器将与之匹配的标志位置 1。每个被处理器标记的异常都有两种可能的操作:

  • 如果相应的屏蔽位置 1,那么处理器自动处理异常并继续执行程序。

  • 如果相应的屏蔽位清 0,那么处理器将调用软件异常处理程序。

大多数程序普遍都可以接受处理器的屏蔽(自动)响应。如果应用程序需要特殊响应,那么可以使用自定义异常处理程序。一条指令能触发多个异常,因此处理器要持续保存自上一次异常清零后所发生的全部异常。完成一系列计算后,可以检测是否发生了异常。

浮点数指令集

FPU 指令集有些复杂,因此这里只对其功能进行概述,并用具体例子给出编译器通常会生成的代码。此外,大家还将看到如何通过改变舍入模式来控制 FPU。指令集包括如下基本指令类型:

  • 数据传送

  • 基本算术运算

  • 比较

  • 超越函数

  • 常数加载(仅对专门预定义的常数)

  • x87 FPU 控制

  • x87 FPU 和 SIMD 状态管理

浮点指令名用字母 F 开头,以区别于 CPU 指令。指令助记符的第二个字母(通常为 B 或 I)指明如何解释内存操作数:B 表示 BCD 操作数,I 表示二进制整数操作数。

如果这两个字母都没有使用,则内存操作数将被认为是实数。比如,FBLD 操作对象为 BCD 数值, FILD 操作对象为整数,而 FLD 操作对象为实数。

操作数

浮点指令可以包含零操作数、单操作数和双操作数。如果是双操作数,那么其中一个必然为浮点寄存器。指令中没有立即操作数,但是某些预定义常数(如 0.0,π 和 log210)可以加载到堆栈。

通用寄存器 EAX、EBX、ECX 和 EDX 不能作为操作数。(唯一的例外是 FSTSW,它将 FPU 状态字保存在 AX 中。)不允许内存-内存操作。

整数操作数从内存(不是从 CPU 寄存器)加载到 FPU,并自动转换为浮点格式。同样,将浮点数保存到整数内存操作数时,该数值也会被自动截断或舍入为整数。

初始化(FINIT)

FINIT 指令对 FPU 进行初始化。将 FPU 控制字设置为 037Fh,即屏蔽(隐藏)了所有浮点异常;舍入模式设置为最近偶数,计算精度设置为 64 位。建议在程序开始时调用 FINIT, 这样就可以了解处理器的起始状态。

浮点数据类型

现在快速回顾一下 MASM 支持的浮点数据类型(QWORD、TBYTE、REAL4、REAL8 和 REAL10),如下表所示。

类型 用法
QWORD 64 位整数
TBYTE 80 位(10 字节)整数
REAL4 32 位(4 字节)IEEE 短实数
REAL8 64 位(8 字节)IEEE 长实数
REAL10 80 位(10 字节)IEEE 扩展实数

在定义 FPU 指令 的内存操作数时,将会使用到这些类型。例如,加载一个浮点变量到 FPU 堆栈,这个变量可以定义为 REAL4,REAL8 或 REAL10:

.data
bigVal REAL10 1.212342342234234243E+864
.code
fld bigVal             ;加载变量到堆栈

加载浮点数值(FLD)

FLD(加载浮点数值)指令将浮点操作数复制到 FPU 堆栈栈顶(称为 ST(0))。操作数可以是 32 位、64 位、80 位的内存操作数(REAL4、REAL8、REAL10)或另一个 FPU 寄存器:

FLD m32fp
FLD m64fp
FLD m80fp
FLD ST(i)

内存操作数类型 FLD 支持的内存操作数类型与 MOV 指令一样。示例如下:

.data
array REAL8 10 DUP (?)
.code
fid array                         ;直接寻址
fid [array+16 ]                    ;直接偏移
fid REAL8 PTR[esi]                 ;间接寻址
fid array[esi]                      ;变址寻址
fid array[esi*8]                    ;带比例因子的变址
fid array[esi*TYPE array]             ;带比例因子的变址
fid REAL8 PTR[ebx+esi]             ;基址-变址
fid array[ebx+esi]                  ;基址-变址-偏移量
fid array[ebx+esi*TYPE array]         ;带比例因子的基址-变址-偏移量

【示例】下面的例子加载两个直接操作数到 FPU 堆栈:

.data
dblOne REAL8 234.56
dblTwo REAL8 10.1
.code
fid dblOne           ; ST(0) = dblOne
fid dblTwo           ; ST(0) = dblTwo, ST(1) = dblOne

每条指令执行后的堆栈情况如下图所示:

执行第二条 FLD 时,TOP 减 1,这使得之前标记为 ST(0) 的堆栈元素变为了 ST(1)。

FILD

FILD(加载整数)指令将 16 位、32 位或 64 位有符号整数源操作数转换为双精度浮点数,并加载到 ST(0)。源操作数符号保留。FILD 支持的内存操作数类型与 MOV 指令一致(间接、变址、基址-变址等)。

加载常数

下面的指令将特定常数加载到堆栈。这些指令没有操作数:

  • FLD1 指令将 1.0 压入寄存器堆栈。

  • FLDL2T 指令将 log210 压入寄存器堆栈。

  • FLDL2E 指令将 log2e 压入寄存器堆栈。

  • FLDPI 指令将 π 压入寄存器堆栈。

  • FLDLG2 指令将 log102 压入寄存器堆栈。

  • FLDLN2 指令将 loge2压入寄存器堆栈。

  • FLDZ(加载零)指令将 0.0 压入 FPU 堆栈。

保存浮点数值(FST, FSTP)

FST(保存浮点数值)指令将浮点操作数从 FPU 栈顶复制到内存。FST 支持的内存操作数类型与 FLD 一致。操作数可以为 32 位、64 位、80 位内存操作数(REAL4、REAL8、 REAL10)或另一个 FPU 寄存器:

FST m32fp FST m80fp
FST m64fp FST ST(i)

FST 不是弹出堆栈。下面的指令将 ST(0) 保存到内存。假设 ST(0) 等于 10.1,ST(1) 等于 234.56:

fst dblThree  ; 10.1
fst dblFour    ; 10.1

直观地说,代码段期望 dblFour 等于 234.56。但是第一条 FST 指令把 10.1 留在 ST(0) 中。如果代码段的意图是把 ST(1) 复制到 dblFour,那么就要用 FSTP 指令。

FSTP

FSTP(保存浮点值并将其出栈)指令将 ST(0) 的值复制到内存并将 ST(0) 弹出堆栈。假设执行下述指令前 ST(0) 等于 10.1,ST(1) 等于 234.56:

fstp dblThree ; 10.1
fstp dblFour ; 234.56

指令执行后,这两个数值会从堆栈中逻辑移除。从物理上看,每次执行 FSTP,TOP 指针都会减 1,修改 ST(0) 的位置。

FIST(保存整数)指令将 ST(0) 的值转换为有符号整数,并把结果保存到目标操作数。保存的值可以为字或双字。FIST 支持的内存操作数类型与 FST 一致。

汇编语言浮点数算术运算指令

下表列出了基本算术运算操作。所有算术运算指令支持的内存操作数类型与 FLD (加载)和 FST(保存)一致,因此,操作数可以是间接操作数、变址操作数和基址-变址操作数等等。

FCHS 修改符号
FADD 源操作数与目的操作数相加
FSUB 从目的操作数中减去源操作数
FSUBR 从源操作数中减去目的操作数
FMUL 源操作数与目的操作数相乘
FDIV 目的操作数除以源操作数
FDIVR 源操作数除以目的操作数

FCHS 和 FABS

FCHS( 修改符号 ) 指令将 ST(0) 中浮点数值的符号取反。FABS ( 绝对值 ) 指令清除 ST(0) 中数值的符号,以得到它的绝对值。这两条指令都没有操作数:

FCHS
FABS

FADD、FADDP、FIADD

FADD(加法)指令格式如下,其中,m32fp 是 REAL4 内存操作数,m64fp 即是 REAL8 内存操作数,i 是寄存器编号:

FADD
FADD m32fp
FADD m64fp
FADD ST(0), ST(i)
FADD ST(i) , ST(0)

无操作数

如果 FADD 没有操作数,则 ST(0)与 ST(1)相加,结果暂存在 ST(l)。然后 ST(0) 弹出堆栈,把加法结果保留在栈顶。假设堆栈已经包含了两个数值,下图展示了 FADD 的操作:

寄存器操作数

从同样的栈开始,如下所示将 ST(0) 加到 ST(1):

内存操作数

如果使用的是内存操作数,FADD 将操作数与 ST(0) 相加。示例如下:

fadd mySingle      ; ST(0) += mySingle
fadd REAL8 PTR[esi]  ; ST(0) += [esi]

FADDP

FADDP(相加并出栈)指令先执行加法操作,再将 ST(0) 弹出堆栈。MASM 支持如下格式:

FADDP ST(i),ST(0)

下图演示了 FADDP 的操作过程:

FIADD

FIADD(整数加法)指令先将源操作数转换为扩展双精度浮点数,再与 ST(0) 相加。指令语法如下:

FIADD ml6int
FIADD m32int

示例:

.data
myInteger DWORD 1
.code
fiadd myInteger         ; ST(0) += myInteger

FSUB、FSUBP、FISUB

FSUB 指令从目的操作数中减去源操作数,并把结果保存在目的操作数中。目的操作数总是一个 FPU 寄存器,源操作数可以是 FPU 寄存器或者内存操作数。该指令操作数类型与 FADD 指令一致:

FSUB
FSUB m32fp
FSUB m64fp
FSUB ST(0), ST(i)
FSUB ST(i), ST(0)

FSUB 的操作与 FADD 相似,只不过它进行的是减法而不是加法。比如,无参数 FSUB 实现 ST(1) - ST(0),结果暂存于 ST(1)。然后 ST(0) 弹出堆栈,将减法结果留在栈顶。若 FSUB 使用内存操作数,则从 ST(0) 中减去内存操作数,且不再弹出堆栈。

fsub mySingle      ; ST(0) -= mySingle
fsub array[edi*8]  ; ST(0) -= array[edi*8]

FSUBP

FSUBP(相减并出栈)指令先执行减法,再将 ST(0) 弹出堆栈。MASM 支持如下格式:

FSUBP ST(i),ST(0)

FISUB

FISUB(整数减法)指令先把源操作数转换为扩展双精度浮点数,再从 ST(0) 中减去该操作数:

FISUB m16int
FISUB m32int

FMUL、FMULP、FIMUL

FMUL 指令将源操作数与目的操作数相乘,乘积保存在目的操作数中。目的操作数总是一个 FPU 寄存器,源操作数可以为寄存器或者内存操作数。其语法与 FADD 和 FSUB 相同:

FMUL
FMUL m32fp
FMUL m64fp
FMUL ST(0), ST(i)
FMUL ST(i), ST(0)

除了执行的是乘法而不是加法外,FMUL 的操作与 FADD 相同。比如,无参数 FMUL 将 ST(O) 与 ST(1) 相乘,乘积暂存于 ST(1)。然后 ST(0) 弹出堆栈,将乘积留在栈顶。同样,使用内存操作数的 FMUL 则将内存操作数与 ST(0) 相乘:

fmul mySingle   ; ST(0) *= mySingle

FMULP

FMULP(相乘并出栈)指令先执行乘法,再将 ST(0) 弹出堆栈。MASM 支持如下格式:

FMULP ST(i),ST(O)

FIMUL 与 FIADD 相同,只是它执行的是乘法而不是加法:

FIMUL ml6int
FIMUL m32int

FDIV、FDIVP、FIDIV

FDIV 指令执行目的操作数除以源操作数,被除数保存在目的操作数中。目的操作数总是一个寄存器,源操作数可以为寄存器或者内存操作数。其语法与 FADD 和 FSUB 相同:

FDIV
FDIV m32fp
FDIV m64fp
FDIV ST(O), ST(i)
FDIV ST(i), ST(O)

除了执行的是除法而不是加法外,FDIV 的操作与 FADD 相同。比如,无参数 FDIV 执行 ST(1) 除以 ST(0)。然后 ST(0) 弹出堆栈,将被除数留在栈顶。使用内存操作数的 FDIV 将 ST(0) 除以内存操作数。下面的代码将 dblOne 除以 dblTwo,并将商保存到 dblQuot:

.data
dblOne REAL8 1234.56
dblTwo REAL8 10.0
dblQuot REAL8 ?
.code
fid dblOne      ; 加载到 ST (0)
fdiv dblTwo     ; ST(0) 除以 dblTwo
fstp dblQuot      ; 将 ST(0) 保存到 dblQuot

若源操作数为 0,则产生除零异常。若源操作数等于正、负无穷,零或 NaN,则使用一些特殊情况。

FIDIV

FIDIV 指令先将整数源操作数转换为扩展双精度浮点数,再执行与 ST(0) 的除法。其语法如下:

FIDIV ml6int
FIDIV m32int

汇编语言FCOM指令:比较浮点数值

浮点数不能使用 CMP 指令进行比较,因为后者是通过整数减法来执行比较的。取而代之,必须使用 FCOM 指令。

执行 FCOM 指令后,还需要采取特殊步骤,然后再使用逻辑 IF 语句中的条件跳转指令(JA、JB、JE 等)。由于所有的浮点数都为隐含的有符号数,因此,FCOM 执行的是有符号比较。

FCOM、FCOMP、FCOMPP

FCOM(比较浮点数)指令将其源操作数与 ST(0) 进行比较。源操作数可以为内存操作数或 FPU 寄存器。

FCOMP 指令的操作数类型和执行的操作与 FCOM 指令相同,但是它要将 ST(0) 弹岀堆栈。FCOMPP 指令与 FCOMP 相同,但是它有两次出栈操作。

条件码

FPU 条件码标识有 3 个,C3、C2 和 C0,用以说明浮点数比较的结果,如下表所示。由于 C3、C2 和 C0 的功能分别与零标志位 (ZF)、奇偶标志位 (PF) 和进位标志位 (CF) 相同,因此表中列标题给出了与之等价的 CPU 状态标识。

条件 C3(零标志位) C2(奇偶标志位) C0(进位标志位) 使用的条件跳转指令
ST(0) > SPC 0 0 0 JA.JNBE
ST(0) < SPC 0 0 1 JB.JNAE
ST(0) = SPC 1 0 0 JE.JZ
无序 1 1 1 (无)

提示:如果出现无效算术运算操作数异常(无效操作数),且该异常被屏蔽,则 C3、C2 和 C0 按照标记为“无序”的行来设置。

在比较了两个数值并设置了 FPU 条件码之后,遇到的主要挑战就是怎样根据条件分支到相应标号。这包括了两个步骤:

  • 用 FNSTSW 指令把 FPU 状态字送入 AX。

  • 用 SAHF 指令把 AH 复制到 EFLAGS 寄存器。

条件码送入 EFLAGS 之后,就可以根据 ZF、PF 和 CF 进行条件跳转。上表列出了每种标志位组合所对应的条件跳转。根据该表还可以推出其他跳转:如果 CF=0,则可以使用 JAE 指令引发控制转移;如果 CF=1 或 ZF=1,则可使用 JBE 指令引发控制转移;如果 ZF=0,则可使用 JNE 指令。

【示例】现有如下 C++ 代码段:

double X = 1.2;
double Y = 3.0;
int N = 0;
if( X < Y )
N = 1;

与之等效的汇编语言代码如下:

.data
X REAL8 1.2
Y REAL8 3.0
N DWORD 0
.code
if( X < Y )
   ; N = 1
   fid X       ; ST(0) = X
   fcomp Y    ;比较 ST (0)和 Y
   fnstsw ax   ;状态字送入AX
   sahf       ;AH 复制至!) EFLAGS
   jnb L1      ;X不小于Y?跳过
   mov Nz1      ; N = 1
L1:

P6 处理器的改进

对上面的例子需要说明一点的是浮点数比较的运行时开销大于整数比较。考虑到这一点,Intel P6 系列引入了 FCOMI 指令。该指令比较浮点数值,并直接设置 ZF、PF 和 CF。P6 系列以 Pentium Pro 和 Pentium II 处理器为起点。) FCOMI 的语法如下:

FCOMI 指令代替了之前代码段中的三条指令,但是增加了一条 FLD 指令。FCOMI 指令不使用内存操作数。

相等比较

几乎所有的编程入门教材都会警告读者不要进行浮点数相等的比较,其原因是在计算

过程中出现的舍入误差。现在通过计算表达式 (sqrt(2.0)*sqrt(2.0)) -2.0 来对这个问题进行说明。从数学上看,这个表达式应 该等于0,但计算结果却相差甚远(约等于 4.4408921E-016)。 使用如下数据,下表列出了每一步计算后FPU堆栈的情况:

vail REAL8 2.0
指令 FPU堆栈
fidvall ST(0) : +2.0000000E+000
fsqrt ST(0) : +1.4142135E+000
fmul ST(0), ST(0) ST(0) : +2.0000000E+000
fsub vail ST(0) : +4.4408921E-016

比较两个浮点数 n 和 y 的正确方法是取它们差值的绝对值|x-y|,再将其与用户定义的误差值 epsilon 进行比较。汇编语言代码如下,其中,epsilon 为两数差值允许的最大值,不 大于该值则认为这两个浮点数相等:

.data
epsilon REAL8 1.0E-12
val2 REAL8 0.0           ;比较的数值
val3 REAL8 1.01E —13         ;认为等于^&丄2
.code
;如果 (val2 == val3 ),显示"Values are equal".
fid epsilon
fid val2
fsu val3
fabs
fcomi ST(0)ZST(1)
ja skip
mWrite <"Values are equal",Odh,0ah>
skip:

下表跟踪程序执行过程,显示了前四条指令执行后的堆栈情况。

指令 FPU堆栈 指令 FPU堆栈
fid epsilon ST(0): +1.0000000E-012 ST(1): +1.0000000E-012
fid val2 ST(0): +0.0000000E+000 fabs ST(0): +1.0010000E-013
ST(1): +1.0000000E-012 ST(1): +1.0000000E-012
fsub val3 ST(0): -1.0010000E-013 fcomi ST(0), ST(1) ST(0)<ST(1), so CF=1, ZF=0

如果将 val3 重新定义为大于 epsilon,它就不会等于 val2:

val3 REAL8 1.001E-12 ;不相等·

汇编语言读写浮点数值

链接库有两个浮点数输入输出过程,如下所示:

  • ReadFloat:从键盘读取一个浮点数,并将其压入浮点堆栈。

  • WriteFloat:将 ST(0) 中的浮点数以阶码形式写到控制台窗口。

ReadFloat 接收各种形式的浮点数,示例如下:

35
+35.
-3.5
.35
3.5E5
3.5E005
-3.5E+5
3.5E-4
+3.5E-4

ShowFPUStack 另一个有用的过程,能够显示 FPU 堆栈。调用该过程不需要参数:

call ShowFPUStack

【示例】下面的示例程序把两个浮点数压入 FPU 堆栈并显示,再由用户输入两个数,将它们相乘并显示乘积:

; 32位浮点数 I/O 测试      (floatTest32.asm)
INCLUDE Irvine32.inc
INCLUDE macros.inc
.data
first  REAL8 123.456
second REAL8 10.0
third  REAL8 ?
.code
main PROC
    finit                    ; 初始化 FPU
; 两个浮点数入栈,并显示 FPU 堆栈.
    fld    first
    fld    second
    call    ShowFPUStack
; 输入两个浮点数,并显示它们的乘机
    mWrite "Please enter a real number: "
    call    ReadFloat

    mWrite "Please enter a real number: "
    call    ReadFloat

    fmul    ST(0),ST(1)            ; 相乘

    mWrite "Their product is: "
    call    WriteFloat
    call    Crlf
    exit
main ENDP
END main

示例输入/输出(用户输入显示为粗体)如下:

汇编语言FWAIT(WAIT)指令:异常同步

整数 (CPU) 和 FPU 是相互独立的单元,因此,在执行整数和系统指令的同时可以执行浮点指令。这个功能被称为并行性 (concurrency),当发生未屏蔽的浮点异常时,它可能是个潜在的问题。反之,已屏蔽异常则不成问题,因为,FPU 总是可以完成当前操作并保存结果。

发生未屏蔽异常时,中断当前的浮点指令,FPU 发异常事件信号。当下一条浮点指令或 FWAIT(WAIT) 指令将要执行时,FPU 检查待处理的异常。如果发现有这样的异常,FPU 就调用浮点异常处理程序(子程序)。

如果引发异常的浮点指令后面跟的是整数或系统指令,情况又会是怎样的呢?很遗憾,指令不会检查待处理异常,它们会立即执行。假设第一条指令将其输出送入一个内存操作数,而第二条指令又要修改同一个内存操作数,那么异常处理程序就不能正确执行。示例如下:

.data
intVal DWORD 25
.code
fild intVal ;将整数加载到 ST(0)
inc intVal ;整数加 1

设置 WAIT 和 FWAIT 指令是为了在执行下一条指令之前,强制处理器检查待处理且未屏蔽的浮点异常。这两条指令中的任一条都可以解决这种潜在的同步问题,直到异常处理程序结束,才执行 INC 指令。

fild intVal ;将整数加载到 ST(0)
fwait     ;等待待处理异常
inc intVal ;整数加 1

下面将用几个简短的例子来演示浮点算术运算指令。一个很好的学习方法是用 C++ 编写表达式,编译后,再检查由编译器生成的代码。

表达式

现在编写代码,计算表达式 valD=-valA+(valB*valC)。下面给出一种可能的循序渐进的方法:将 valA 加载到堆栈,并取其负数;将 valB 加载到 ST(0),则 valA 成为 ST(1);将 ST(0) 和 valC 相乘,乘积保存在 ST(0) 中;将 ST(1) 与 ST(0) 相加,和数保存到 valD:

.data
valA REAL8 1.5
valB REAL8 2.5
valC REAL8 3.0
valD REAL8 ?; +6.0
.code
fld valA    ; ST(0) = valA
fchs        ;修改 ST(0) 的符号
fld valB    ; 将 valB 加载到 ST(0)
fmul valC   ; ST(0) *= valC
fadd        ; ST(0) += ST(1)
fstp valD   ; 将 ST(0) 保存到 valD

数组求和

下面的代码计算并显示一个双精度实数数组之和:

ARRAY_SIZE = 20
.data
sngArray REAL8 ARRAY_SIZE DUP(?)
.code
    mov    esi, 0            ;数组索引
    fldz                     ; 0.0 入栈
    mov ecx,ARRAY_SIZE
L1: fld    sngArray[esi]     ;将内存操作数加载到ST(0)
    fadd                     ; ST(0) 加 ST(1),出栈
    add esi,TYPE REAL8       ;移至!I 下一个元素
    loop L1
    call WriteFloat          ;显示 ST(0) 中的和数

平方根之和

FSQRT 指令对 ST(0) 中的数值求平方根,并将结果送回 ST(0)。下面的代码计算了两个数的平方根之和:

.data
valA REAL8 25.0
valB REAL8 36.0
.code
fid valA        ; valA 入栈
fsqrt           ; ST(0) = sqrt(valA)
fid valB        ; valB 入栈
fsqrt           ; ST(0) = sqrt(valB)
fadd            ; ST (0)+ST(1)

数组点积

下面的代码计算了表达式 (airay[0]airay[l]) + (array[2]array[3])。该计算有时也被称为点积 (dot product)。

.data
array REAL4 6.0, 2.0, 4.5, 3.2

下表列出了每条指令执行后,FPU 堆栈的情况。输入数据如下:

指令 FPU堆栈 指令 FPU堆栈
fld array ST(0):+6.0000000E+000 fmul [array+12] ST(0):+1.4400000E+001
fmul [array+4] ST(0):+1.2000000E+001 ST(1):+1.2000000E+001
fld [array+8] ST(0):+4.5000000E+000 fadd ST(0):+2.6400000E+001
ST(1):+1.2000000E+001

汇编语言混合模式运算简述

应用程序通常执行的是包含了整数与实数的混合模式运算。整数运算指令,如 ADD 和 MUL,不能操作实数,因此只能选择用浮点指令。Intel指令集提供指令将整数转换为实数,并将数值加载到浮点堆栈。

【示例 1】下面的 C++ 代码将一个整数与一个双精度数相加,并把和数保存为双精度数。C++ 在执行加法前,把整数自动转换为实数:

int N = 20;
double X = 3.5;
double Z = N + X;

与之等效的汇编代码如下:

.data
N SDWORD 20
X REAL8 3.5
Z REAL8 ?
.code
fild n     ;整数加载到ST(0)
fadd X      ;将内存操作数与ST(0)相加
fstp z       ;将ST(0)保存到内存操作数

【示例 2】下面的 C++ 程序把 N 转换为双精度数后,计算一个实数表达式,再将结果保存为整数变量:

int N = 20;
double X = 3.5;
int Z = (int)(N + X);

Visual C++ 生成的代码先调用转换函数 (ftol),再把截断的结果保存到 Z。如果在表达式的汇编代码中使用 FIST,那么就可以避免函数调用,不过Z (默认) 会向上舍入为 24:

fild N  ;整数加载到ST(0)
fadd X ;将内存操作数与ST(0)相加
fist Z  ;将ST(0)保存为整型内存操作数

修改舍入模式

FPU 控制字的 RC 字段指定使用的舍入类型。可以先用 FSTCW 把控制字保存为一个变量,再修改 RC 字段(位 10 和 11),最后用 FLDCW 指令把这个变量加载回控制字:

fstew ctrlWord             ;保存控制字
or ctrlWord, 110000000000b ;设置眈=截断
fldcw ctrlWord             ;加载控制字

之后采用截断执行计算,生成结果为 Z=23:

fild N  ;整数加载到ST(0)
fadd X ;将内存整数与ST(0)相加
fist Z   ;将ST(0)保存为整型内存操作数

或者,把舍入模式重新设置为默认选项(舍入到最接近的偶数):

fstcw ctrlWord               ;保存控制字
and ctrlWord, 001111111111b   ;重置舍入模式为默认
fldcw ctrlWord               ;加载控制字 

汇编语言异常的屏蔽与未屏蔽简述

默认情况下,异常是被屏蔽的,因此,当出现浮点异常时,处理器分配一个默认值为结果,并继续平稳地工作。例如,一个浮点数除以 0 生成结果为无穷,但不会中断程序:

.data
val1 DWORD 1
val2 REAL8 0.0
.code
fild val1    ;整数加载到ST(0)
fdiv val2      ;ST(0) =正无穷

如果 FPU 控制字没有屏蔽异常,那么处理器就会试着执行合适的异常处理程序。清除 FPU 控制字中的相应位就可以实现异常的未屏蔽操作,如下表所示。

说明 说明
0 无效操作异常屏蔽位 5 精度异常屏蔽位
1 非规格化操作数异常屏蔽位 8〜9 精度控制位
2 除零异常屏蔽位 10〜11 舍入控制位
3 上溢异常屏蔽位 12 无穷控制位
4 下溢异常屏蔽位

假设不想屏蔽除零异常, 则需要如下步骤:

\1) 将 FPU 控制字保存到 16 位变量。

\2) 清除位 2(除零标志位)。

\3) 将变量加载回控制字。

下面的代码实现了浮点异常的未屏蔽操作:

.data
ctrlWord WORD ?
.code
fstcw ctrlWord                   ;获取控制字
and ctrlWord, 1111111111111011b   ;不屏蔽除零异常
fldcw ctrlWord                   ;结果加载回 FPU

现在,如果执行除零代码,那么就会产生一个未屏蔽异常:

fild val1
fdiv val2 ;除零
fst val2

只要 FST 指令开始执行,MS-Windows 就会显示错误信息。

屏蔽异常

要屏蔽一个异常,就把 FPU 控制字中的相应位置 1。下面的代码屏蔽了除零异常:

.data
ctrlWord WORD ?
.code
fstcw ctrlWord    ;获取控制字
or ctrlWord, 100b ;屏蔽除零异常
fldcw ctrlWord    ;结果力口载回 FPU

汇编语言x86指令编码简述

若要完全理解汇编语言操作码和操作数,就需要花些时间了解汇编指令翻译成机器语言的方法。由于 Intel 指令集使用了丰富多样的指令和寻址模式,因此这个问题相当复杂。

Intel 8086 处理器是第一个使用复杂指令集计算机(Complex Instruction Set Computer, CISC)设计的处理器。这种指令集中包含了各种各样的内存寻址、移位、算术运算、数据传送和逻辑操作

与 RISC(精简指令集计算机,Reduced Instruction Set Computer)指令相比,Intel 指令在编码和解码方面有些复杂。

指令编码(encode)是指将汇编语言指令及其操作数转换为机器码。指令解码(decode)是指将机器指令转换为汇编语言。对 Intel 指令编码和解码的逐步解释至少将有助于唤起对 MASM 作者们辛苦工作的理解和欣赏。

指令格式

一般的 x86 机器指令格式,如下图所示。包含了一个指令前缀字节、操作码、Mod R/M 字节、伸缩索引字节(SIB)、地址位移和立即数。

指令按小端顺序存放,因此前缀字节位于指令的起始地址。每条指令都有一个操作码,而其他字段则是可选的。少数指令包含了全部字段,平均来看,绝大多数指令都有 2 个或 3 个字节。

下面是对指令字段的简介:

\1) 指令前缀覆盖默认操作数大小。

\2) 操作码(操作代码)指定指令的特定变体。比如,按照使用的参数类型,指令 ADD 有 9 种不同的操作码。

\3) Mod R/M 字段指定寻址模式和操作数。符号 “R/M” 代表的是寄存器和模式。下表列出了 Mod 字段。

Mod 位移
0 DISP=0,位移低半部分和高半部分都无定义(除非r/m = 110)
1 DISP= 位移低半部分符号扩展到 16 位,位移高半部分无定义
10 DISP= 位移高半部分和低半部分都有效
11 R/M 字段包含的是寄存器编号

下表给出了当 Mod=10b 时 16 位应用程序的 R/M 字段。

R/M 有效地址 R/M 有效地址
0 [BX+SIJ+D16 100 [SI]+D16
1 [BX+DI]+D16 101 [DI]+D16
10 [BP+SI]+D16 110 [BP]+D16
11 [BP+DIJ+D16 111 [BX]+D16

\4) 伸缩索引字节(scale index byte, SIB)用于计算数组索引偏移量。

\5) 地址位移字段保存了操作数的偏移量,在基址-偏移量或基址-变址-偏移量寻址模式中,该字段还可以与基址或变址寄存器相加。

\6) 立即数字段保存了常量操作数。

汇编语言单字节指令与立即操作数简述

没有操作数或只有一个隐含操作数的指令是最简单的指令。这种指令只需要操作码字段,字段值由处理器的指令集预先确定。下表列出了几个常见的单字节指令。

指令 操作码 指令 操作码
AAA 37 LODSB AC
AAS 3F XLAT D7
CBW 98 INC DX 42

在这些指令中,INC DX 指令好像是不应该岀现的,它出现的原因是:指令集的设计者决定为某些常用指令提供独特的操作码。其结果是,为了代码量和执行速度要对寄存器增量操作进行优化。

立即数送寄存器

立即操作数(常数)按照小端顺序(起始地址为最低字节)添加到指令。首先关注的是立即数送寄存器指令,暂不考虑内存寻址的复杂性。将一个立即字送寄存器的 MOV 指令的编码格式为:B8+rw dw,其中操作码字节的值为 B8+rw,表示将一个寄存器编号(0〜7)与 B8 相加;dw 为立即字操作数,低字节在低地址。

下表列出了操作码使用的寄存器编号。

寄存器 编号 寄存器 编号
AX/A1 0 SP/AH 4
CX/CL 1 BP/CH 5
DX/DL 2 SI/DH 6
BX/BL 3 DI/BH 7

下面例子中出现的所有数值都为十六进制。

【示例 1】PUSH CX 机器指令为 51。编码步骤如下:

\1) 带一个 16 位寄存器操作数的 PUSH 指令编码为 50。

\2) CX的寄存器编码为1,因此1+50得到操作码为51。

【示例 2】MOV AX, 1 机器指令为 B8 01 00(十六进制)。编码过程如下:

\1) 立即数送 16 位寄存器的操作码为 B8

\2) AX 的寄存器编号为 0,将 0 加上 B8(参见上表所示)。

\3) 立即操作数(0001)按小端顺序添加到指令(01, 00 )。

【示例 3】MOV BX, 1234h 机器指令为 BB 34 12。编码过程如下:

\1) 立即数送 16 位寄存器的操作码为 B8。

\2) BX 的寄存器编号为 3,将 3 加上 B8 得到操作码 BB。

\3) 立即操作数字节为 34 12。

从实践的角度出发,建议手动汇编一些 MOV 立即数指令来提高能力,然后通过 MASM 的源列表文件中的生成代码来检查汇编结果。

汇编语言寄存器模式指令简述

在使用寄存器操作数的指令中,ModR/M 字节用一个 3 位的标识符来表示寄存器操作数。下表列岀了寄存器的位编码。操作码字段的位 0 用于选择 8 位或 16 位寄存器:1 表示 16 位寄存器,0 表示 8 位寄存器。

R/M 寄存器 R/M 寄存器
0 AX or AL 100 SP or AH
1 CX or CL 101 BP or CH
10 DX or DL 110 SI or DH
11 BX or BL 111 DI or BH

比如,MOV AX, BX 的机器码为 89 D8。寄存器送其他操作数的 16 位 MOV 指令的 Intel 编码为 89/r,其中 /r 表示操作码后面带一个 Mod R/M 字节。

Mod R/M 字节有三个字段(mod. reg 和 r/m)。例如,若 Mod R/M 的值为 D8,则它包含如下字段

mod reg r/m
11 11 0
  • 位 6〜7 是 mod 字段,指定寻址模式。mod 字段为 11 表示 r/m 字段包含的是一个寄存器编号。

  • 位 3〜5 是 eg 字段,指定源操作数。在本例中,BX 就是编号为 011 的寄存器。

  • 位 0〜2 是 r/m 字段,指定目的操作数。本例中,AX 是编号为 000 的寄存器。

下表列出了更多使用 8 位和 16 位寄存器操作数的例子。

指令 操作码 mod reg r/m
mov ax, dx 8B 11 0 10
mov al, dl 8A 11 0 10
mov ex, dx 8B 11 1 10
mov cl, dl 8A 11 1 10

汇编语言处理器操作数大小前缀作用及意义

现在将注意力转回到 x86 处理器(IA-32)的指令编码。有些指令以操作数大小前缀开始,覆盖了其修改指令的默认段属性。问题是,为什么有指令前缀?在编写 8088/8086 指令集时,几乎所有 256 个可能的操作码都用于处理带有 8 位和 16 位操作数的指令。

当 Intel 开发 32 位处理器时,就需要想办法发明新的操作码来处理 32 位操作数,而同时还要保持与之前处理器的兼容性。对于面向 16 位处理器的程序,所有使用 32 位操作数的指令都添加一个前缀字节。

对于面向 32 位处理器的程序,默认为 32 位操作数,因此所有使用 16 位操作数的指令添加一个前缀字节。8 位操作数不需要前缀。

【示例】16 位操作数,现在对 MOV 指令进行汇编,以此为例来看看在 16 位模式下前缀字节是如何起作用的。.286 伪指令指明编译代码的目标处理器,确保不使用 32 位寄存器。下面的每条 MOV 指令都给岀了其指令编码:

.model small
.286
.stack 100h
.code
main PROC
    mov ax, dx    ; 8B C2
    mov al, dl    ; 8A C2

现在对 32 位处理器汇编相同的指令,使用 .386 伪指令,默认操作数为 32 位。指令将包括 16 位和 32 位操作数。第一条 MOV 指令(EAX、EDX)使用的是 32 位操作数,因此不需要前缀。第二条 MOV(AX、DX)指令由于使用的是 16 位操作数,因此需要操作数大小前缀(66):

.model small
.386
.stack 100h
.code
main PROC
    mov    eax,edx ; 8B C2
    mov    ax,dx   ; 66 8B C2
    mov    al,dl   ; 8A C2

汇编语言内存模式指令简述

如果 Mod R/M 字节只用于标识寄存器操作数,那么 Intel 指令编码就会相对简单。实际上,Intel 汇编语言有着各种各样的内存寻址模式,这就使得 Mod R/M 字节编码相当复杂。(指令集的复杂性是 RISC 设计支持者常见的批评理由。)

Mod R/M 字节正好可以指定 256 个不同组合的操作数。下表列岀了 Mod 00 时的 Mod R/M 字节(十六进制)。

字节 AL CL DL BL AH CH DH BH
AX CX DX BX SP BP SI DI
寄存器ID 0 1 10 11 100 101 110 111
Mod R/M Mod R/M 值 有效地址
0 0 0 8 10 18 20 28 30 38 [BX+SI]
1 1 9 11 19 21 29 31 39 [BX+DI]
10 2 0A 12 1A 22 2A 32 3A [BP+SI]
11 3 0B 13 1B 23 2B 33 3B [BP+DI]
100 4 0C 14 1C 24 2C 34 3C [SI]
101 5 0D 15 1D 25 2D 35 3D [DI]
110 6 0E 16 1E 26 2E 36 3E 16 位偏移量
111 7 0F 17 1F 27 2F 37 3F [BX]

Mod R/M 字节编码的作用如下:Mod 列中的两位指定寻址模式的集合。比如,Mod 00 有 8 种可能的 R/M 数值(000b〜111b),有效地址列给岀了这些数值标识的操作数类型。

假设想要编码 MOV AX, [Si], Mod 位为 00b, R/M 位为 100b。从《x86指令编码》一节中的 16 位 RM 表中可知 AX 的寄存器编号为 000b,因此完整的 Mod R/M 字节为 00 000 100b 或 04h:

mod reg r/m
0 0 100

十六进制字节 04 在上表(Mod R/M)的 AX 列第 5 行。

MOV [SI], AL 的 Mod R/M 字节还是一样的(04h),因为寄存器 AL 的编号也是 000。现在对指令 MOV [SI], AL 进行编码。8 位寄存器的传送操作码为 88。Mod R/M 字节为 04h,则机器码为 88 04。

MOV 指令示例

下表列出了 8 位和 16 位 MOV 指令所有的指令格式和操作码。

操作码 指令 说明 操作码 指令 说明
88/r MOV eb, rb 字节寄存器送 EA 字节操作数 8E/2 MOV SS, rw 字寄存器送 SS
89/r MOV ew, rw 字寄存器送 EA 字操作数 8E/3 MOV DS, mw 内存字送 DS
8A/r MOV rb, eb EA 字节操作数送字节寄存器 8E/3 MOV DS, rw 字寄存器送 DS
8B/r MOV rw, ew EA 字操作数送字寄存器 A0 dw MOV AL, xb 字节变量(偏移量为 dw)送 AL
8C/0 MOV ew, ES ES 送 EA 字操作数 A1 dw MOV AX, xw 字变量(偏移量为 dw)送 AX
8C/1 MOV ew, CS CS 送 EA 字操作数 A2 dw MOV xb, AL AL 送字节变量(偏移量为 dw)
8C/2 MOV ew, SS SS 送 EA 字操作数 A3 dw MOV xw, AX AX 送字寄存器(偏移量为 dw)
8C/3 MOV ew, DS DS 送 EA 字操作数 B0+rb db MOV rb, db 字节立即数送字节寄存器
8E/0 MOV ES, mw 内存字送 ES B8+rw dw MOV rw, dw 字立即数送字寄存器
8E/0 MOV ES, rw 字寄存器送 ES C6 /0 db MOV eb, db 字节立即数送 EA 字节操作数
8E/2 MOV SS, mw 内存字送 SS C7 /0 dw MOV ew, dw 字立即数送 EA 字操作数

下面两表给出了上表中缩写符号的补充信息。手动汇编 MOV 指令时可以用这些表作为参考。

/n: 操作码后面跟一个 Mod R/M 字节,该字节后面可能再跟立即数和偏移量字段。数字 n( 0〜7 )为 Mod R/ M 字节中 reg 字段的值
/r: 操作码后面跟一个 Mod R/M 字节,该字节后面可能再跟立即数和偏移量字段
db: 操作码和 Mod R/M 字节后面跟一个字节立即操作数
dw: 操作码和 Mod R/M 字节后面跟一个字立即操作数
+rb: 8 位寄存器的编号(0〜7 ),与前面的十六进制字节一起构成 8 位操作码
+rw: 16 位寄存器的编号(0〜7 ),与前面的十六进制字节一起构成 8 位操作码
db -128〜+127 之间的有符号数。若操作数为字类型,则该数值进行符号扩展
dw 指令操作数为字类型的立即数
eb 字节类型操作数,可以是寄存器也可以是内存操作数
ew 字类型操作数,可以是寄存器也可以是内存操作数
rb 用数值(0〜7 )标识的 8 位寄存器
rw 用数值(0〜7 )标识的 16 位寄存器
xb 无基址或变址寄存器的简单字节内存变量
xw 无基址或变址寄存器的简单字内存变量

下表列出了更多的 MOV 指令,这些指令能手动汇编,且可以与表中的机器代码比较。假设 myWord 的起始地址偏移量为 0102h。

指令 机器码 寻址模式
mov ax, my Word A1 02 01 直接(为 AX 优化)
mov my Word,bx 89 IE 02 01 直接
mov[di],bx 89 ID 变址
mov[bx+2],ax 89 47 02 基址 - 偏移量
mov[bx+si],ax 89 00 基址 - 变址
mov word prt [bx+di+2], 1234h C7 41 02 34 12 基址 - 变址 - 偏移量

高级语言接口

高级语言调用汇编语言的接口规范

从高级语言中调用汇编过程时,需要解决一些常见的问题。

首先,一种语言使用的命名规范(naming convention)是指与变量和过程命名相关的规则和特性。比如,一个需要回答的重要问题是:汇编器或编译器会修改目标文件中的标识符名称吗?如果是,如何修改?

其次,段名称必须与高级语言使用的名称兼容。

第三,程序使用的内存模式(微模式、小描述、紧凑模式、中模式、大模式、巨模式,或平坦模式)决定了段大小(16 或 32 位),以及调用或引用是近(同一段内)还是远(不同段之间)。

调用规范

调用规范(calling convention)是指调用过程的底层细节。下面列出了需要考虑的细节信息:

  • 调用过程需要保存哪些寄存器

  • 传递参数的方法:用寄存器、用堆栈、共享内存,或者其他方法

  • 主调程序调用过程时,参数传递的顺序

  • 参数传递方法是传值还是传引用

  • 过程调用后,如何恢复堆栈指针

  • 函数如何向主调程序返回结果

命名规范与外部标识符

当从其他语言程序中调用汇编过程时,外部标识符必须与命名规范(命名规则)兼容。外部标识符(external identifier)是放在模块目标文件中的名称,链接器使得这些名称能够被其他程序模块使用。链接器解析对外部标识符的引用,但是仅适用于命名规范一致的情况。

例如,假设 C 程序 Main.c 调用外部过程 ArraySum。如下图所示,C 编译器自动保留大小写,并为外部名称添加前导下划线,将其修改为 _ArraySum:

Array.asm 模块用汇编语言编写,由于其 .MODEL 伪指令使用的选项为 Pascal 语言,因此输出 ArraySum 过程的名称就是 ARRAYSUM。由于两个输出的名称不同,因此链接器无法生成可执行程序。

早期编程语言,如 COBOL 和 PASCAL,其编译器一般将标识符全部转换为大写字母。近期的语言,如 C、C++ 和 Java 则保留了标识符的大小写。

此外,支持函数重载的语言(如 C++)还使用名称修饰 (name decoration) 的技术为函数名添加更多字符。比如,若函数名为 MySub (int n, double b),则其输出可能为 MySub#int#double。

在汇编语言模块中,通过 .MODEL 伪指令选择语言说明符来控制大小写。

段名称

汇编语言过程与高级语言程序链接时,段名称必须是兼容的。本章使用 Microsoft 简化段伪指令 .CODE、.STACK 和 .DATA,它们与 Microsoft C++ 编译器生成的段名称兼容。

内存模式

主调程序与被调过程使用的内存模式必须相同。比如,实地址模式下可选择小模式、中模式、紧凑模式、大模式和巨模式。保护模式下必须使用平坦模式。

汇编语言.MODEL伪指令:确定程序的特性

16 位和 32 位模式中,MASM 使用 .MODEL 伪指令确定若干重要的程序特性:内存模式类型、过程命名模式以及参数传递规则。若汇编代码被其他编程语言程序调用,那么后两者就尤其重要。

.MODEL 伪指令的语法如下:

.MODEL memorymodel [,modeloptions]

MemoryModel

下表列出了 memorymodel 字段可选择的模式。除了平坦模式之外,其他所有模式都可以用于 16 位实地址编程。

模式 说明
微模式 一个既包含代码又包含数据的段。文件扩展名为 .com 的程序使用该模式
小模式 一个代码段和一个数据段。默认情况下,所有代码和数据都为近属性
中模式 多个代码段,一个数据段
紧凑模式 一个代码段,多个数据段
大模式 多个代码段和数据段
巨模式 与大模式相同,但是各个数据项可以大于单个段
平坦模式 保护模式。代码与数据使用 32 位偏移量。所有的数据和代码(包括系统资源)都在一个 32 位段内

32 位程序使用平坦内存模式,其偏移量为 32 位,代码和数据最大可达 4GB。比如,Irvine32.inc 文件包含了如下 .MODEL 伪指令:

.model flat, STDCALL

ModelOptions

.MODEL 伪指令中的 ModelOptions 字段可以包含一个语言说明符和一个栈距离。语言说明符指定过程与公共符号的调用和命名规范。栈距离可以是 NEARSTACK(默认值)或者 FARSTACK。

1) 语言说明符

伪指令 .MODEL 有几种不同的可选语言说明符,其中的一些很少使用(比如 BASIC、FORTRAN 和 PASCAL)。反之,C 和 STDCALL 则十分常见。结合平坦内存模式,示例如下:

.model flat, C
.model flat, STDCALL

语言说明符 STDCALL 用于 Windows 系统函数调用。本章在链接汇编代码和 C 与 C++ 程序时,使用 C 语言说明符。

2) STDCALL

STDCALL 语言说明符将子程序参数按逆序(从后往前)压入堆栈。为了便于说明,首先用高级语言编写如下函数调用:

AddTwo(5, 6);

若 STDCALL 被选为语言说明符,则等效的汇编语言代码如下:

push 6
push 5
call AddTwo

另一个重要的考虑是,过程调用后如何从堆栈中移除参数。STDCALL 要求在 RET 指令中带一个常量操作数。返回地址从堆栈中弹出后,该常数为 RET 执行与 ESP 相加的数值:

AddTwo PROC
  push ebp
  mov ebp,esp
  mov eax, [ebp + 12]   ;第二个参数
  add eax, [ebp + 8]    ;第一个参数
  pod ebp
ret 8                 ;清除堆栈
AddTwo ENDPP

堆栈指针加上 8 后,就指回了主调程序参数入栈之前指针的位置。

最后,STDCALL 通过将输出(公共)过程名保存为如下格式来修改这些名称:

_name@nn

前导下划线添加到过程名,@ 符号后面的整数指定了过程参数的字节数(向上舍入到 4 的倍数)。例如,假设过程 AddTwo 带有两个双字参数,那么汇编器传递给链接器的名称就为 _AddTwo@8。

Microsoft 链接器是区分大小写的,因此 _MYSUB@8 和 _MySub@8 是两个不同的名称。要查看 OBJ 文件中所有的过程名,使用 Visual Studio 中的 DUMPBIN 工具,选项为 /SYMBOLS。

3) C 说明符

和 STDCALL 一样,C 语言说明符也要求将过程参数按从后往前的顺序压入堆栈。对于过程调用后从堆栈中移除参数的问题,C 语言说明符将这个责任留给了主调方。在主调程序中,ESP 与一个常数相加,将其再次设置为参数入栈之前的位置:

push 6      ;第二个参数
push 5      ;第一个参数
call AddTwo
add esp,8      ;清除堆栈

C 语言说明符在外部过程名的前面添加前导下划线。示例如下:

_AddTwo

查看C语言/C++编译器生成的汇编语言代码

长久以来,C 和 C++ 编译器都会生成汇编语言源代码,但是程序员通常看不到。这是因为,汇编语言代码只是产生可执行文件过程的一个中间步骤。幸运的是,大多数编译器都可以应要求生成汇编语言源代码文件。 例如,下表列出了 Visual Studio 控制汇编源代码输出的命令行选项。

命令行 列表文件内容
/FA 仅汇编文件
/FAc 汇编文件与机器码
/FAs 汇编文件与源代码
/FAcs 汇编文件、机器码和源代码

检查编译器生成的代码文件有助于理解底层信息,比如堆栈帧结构、循环和逻辑编码,并且还有可能找到低级编程错误。另一个好处是更加便于发现不同编译器生成代码的差异。

现在来看看 C++ 编译器生成优化代码的一种方法。由于是第一个例子,因此先编写一个简单的 C 方法 Array Sum,并在 Visual Studio 2012 中进行编译,其设置如下:

  • Optimization=Disabled ( 使用调试器时需要 )

  • Favor Size or Speed=Favor fast code

  • Assembler Output=Assembly With Source Code

下面是用 ANSI C 编写的 arraysum 源代码:

int arraySum( int array[], int count )
{
    int i;
    int sum = 0;
    for(i = 0; i < count; i++)
        sum += array[i];
    return sum;
}

现在来查看由编译器生成的 arraysum 的汇编代码,如下所示。

_sum$ = -8        ; size = 4
_i$ = -4          ; size = 4
_array$ = 8       ; size = 4
_count$ = 12      ; size = 4
_arraySum PROC    ; COMDAT
;4    : {
    push ebp
    mov    ebp, esp
    sub    esp, 72    ; 00000048H
    push ebx
    push esi
    push edi
;5    : int i;
;6    : int sum = 0;
    mov DWORD PTR _sum$[ebp], 0
;7    :
;8    : for(i =    0; i < count; i++)
    mov DWORD PTR _i$[ebp], 0
    jmp SHORT $LN3@arraySum
$LN2@arraySum:
    mov eax, DWORD PTR _i$[ebp]
    add eax, 1
    mov DWORD PTR _i$[ebp], eax
$LN3@arraySum:
    mov eax, DWORD PTR _i$[ebp]
    cmp eax, DWORD PTR _count$[ebp]
    jge SHORT $LN1@arraySum
;9    : sum += array[i];
    mov eax, DWORD PTR _i$[ebp]
    mov ecx, DWORD PTR _array$[ebp]
    mov edx, DWORDPTR _sum$[ebp]
    add edx, DWORD PTR [ecx+eax*4]
    mov DWORD PTR _sum$[ebp], edx
    jmp SHORT $LN2@arraySum
$LNl@arraySum:
;10    :
;11    : return sum;
    mov eax, DWORD PTR _sum$[ebp]
;12    : }
    pop edi
    pop esi
    pop ebx
    mov esp, ebp
    pop ebp
    ret 0
_arraySum ENDP

1〜4 行定义了两个局部变量 (sum 和 i) 的负数偏移量,以及输入参数 array 和 count 的正数偏移量:

_sum$ = -8       ; size = 4
_i$ = -4       ; size = 4
_array$ = 8    ; size = 4
_count$ = 12   ; size = 4

9〜10 行设置 ESP 为帧指针:

push ebp
mov ebp,esp

之后,11〜14 行从 ESP 中减去 72,为局部变量预留堆栈空间。同时,把将会被函数修改的三个寄存器保存到堆栈。

sub esp, 72
push ebx
push esi
push edi

19 行把局部变量 sum 定位到堆栈帧,并将其初始化为 0。由于符号 _sum$ 定义为数值 -8,因此它就位于当前 EBP 下面 8 个字节的位置:

mov DWORD PTR _sum$[ebp],0

24 和 25 行将变量 i 初始化为 0,再转移到 30 行,跳过后面循环计数器递增的语句:

mov DWORD PTR _i$[ebp], 0
jmp SHORT $LN3@arraySum

26〜29 行标记循环开端以及循环计数器递增的位置。从 C 源代码来看,递增操作 (i++) 是在循环末尾执行,但是编译器却将这部分代码移到了循环顶部:

$LN2@arraySum:
   mov eax, DWORD PTR _i$[ebp]
   add eax, 1
   mov DWORD PTR _i$[ebp], eax

30〜33 行比较变量 i 和 count,如果 i 大于或等于 count,则跳岀循环:

$LN3@arraySum:
   mov eax, DWORD PTR _i$[ebp]
   cmp eax, DWORD PTR _count$[ebp]
   jge SHORT $LN1@arraySum

37〜41 行计算表达式 sum+=array[i]。Array[i] 复制到 ECX,sum 复制到 EDX,执行加法运算后,EDX 的内容再复制回 sum:

mov eax, DWORD PTR _i$[ebp]
mov ecx, DWORD PTR _array$[ebp]   ; array [i]
mov edx, DWORD PTR _sum$[ebp]     ; sum
add edx, DWORD PTR [ecx+eax*4]
mov DWORD PTR _sum$[ebp], edx

42 行将控制转回循环顶部:

jmp SHORT $LN2@arraySum

43 行的标号正好位于循环之外,该位置便于作为循环结束时进行跳转的目标地址:

$LN1@arraySum:

48 行将变量 sum 送入 EAX,准备返回主调程序。52〜56 行恢复之前被保存的寄存器,其中,ESP 必须指向主调程序在堆栈中的返回地址。

mov eax, DWORD PTR _sum$[ebp]

;   12 : }

pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
ret 0
_arraySum ENDP

可以写出比上例更快的代码,这种想法不无道理。上例中的代码是为了进行交互式调试,因此为了可读性而牺牲了速度。如果针对确定目标编译同样的程序,并选择完全优化,那么结果代码的执行速度将会非常快,但同时,程序对人类而言基本上是无法阅读和理解的。

调试器设置

用 Visual Studio 调试 C 和 C++ 程序时,若想查看汇编语言源代码,就在 Tools 菜单中选择 Options 以显示如下图的对话框窗口,再选择箭头所指的选项。上述设置要在启动调试器之前完成。接着,在调试会话开始后,右键点击源代码窗口,从弹出菜单中选择 Go to Disassembly。

本章目标是熟悉由 C 和 C++ 编译器产生的最直接和简单的代码生成例子。此外,认识到编译器有多种方法生成代码也是很重要的。比如,它们可以将代码优化为尽可能少的机器代码字节。或者,可以尝试生成尽可能快的代码,即使要用大量机器代码字节来输出结果 ( 常见的情况 )。

最后,编译器还可以在代码量和速度的优化间进行折中。为速度进行优化的代码可能包含更多指令,其原因是,为了追求更快的执行速度会展开循环。机器代码还可以拆分为两部分以便利用双核处理器,这些处理器能同时执行两条并行代码。

Visual C++ __asm伪指令:C语言/C++内嵌汇编语言代码

内嵌汇编代码 (inline assembly code) 是指直接插入高级语言程序中的汇编源代码。大多数 C 和 C++ 编译器都支持这一功能。

本节将展示如何在运行于 32 位保护模式,并采用平坦内存模式的 Microsoft Visual C++ 中编写内嵌汇编代码。其他高级语言编译器也支持内嵌汇编代码,但其语法会发生变化。

内嵌汇编代码是把汇编代码编写为外部模块的一种直接替换方式。编写内嵌代码最突岀的优点就是简单性,因为不用考虑外部链接,命名以及参数传递协议等问题。

但使用内嵌汇编代码最大的缺点是缺少兼容性。高级语言程序针对不同目的平台进行编译时,这就成了一个问题。比如,在 Intel Pentium 处理器上运行的内嵌汇编代码就不能在 RISC 处理器上运行。

一定程度上,在程序源代码中插入条件定义可以解决这个问题,插入的定义针对不同目标系统可以启用函数的不同版本。不过,容易看出,维护仍然是个问题。另一方面,外部汇编过程的链接库容易被为不同目标机器设计的相似链接库所代替。

__asm 伪指令

在 Visual C++ 中,伪指令 __asm 可以放在一条语句之前,也可以放在一个汇编语句块(称为 asm 块)之前。语法如下:

__asm statement

__asm {
  statement-1
  statement-2
  ....
  statement-n
}

提示:在“asm”的前面有两个下划线。

注释

注释可以放在 asm 块内任何语句的后面,使用汇编语法或 C/C++ 语法。Visual C++ 手册建议不要使用汇编风格的注释,以防与 C 宏混淆,因为 C 宏会在单个逻辑行上进行扩展。下面是可用注释的例子:

mov esi,buf ; initialize index register
mov esi,buf // initialize index register
mov esi,buf /* initialize index register */

特点

编写内嵌汇编代码时允许:

  • 使用 x86 指令集内的大多数指令。

  • 使用寄存器名作为操作数。

  • 通过名字引用函数参数。

  • 引用在 asm 块之外定义的代码标号和变量。(这点很重要,因为局部函数变量必须在 asm 块的外面定义。)

  • 使用包含在汇编风格或 C 风格基数表示法中的数字常数。比如,0A26h 和 0xA26 是等价的,且都能使用。

  • 在语句中使用 PTR 运算符,比如 inc BYTE PTR[esi]。

  • 使用 EVEN 和 ALIGN 伪指令。

限制

编写内嵌汇编代码时不允许:

  • 使用数据定义伪指令,如 DB(BYTE)和 DW(WORD)。

  • 使用汇编运算符(除了 PTR 之外)。

  • 使用 STRUCT、RECORD, WIDTH 和 MASK。

  • 使用宏伪指令,包括 MACRO、REPT、IRC、IRP 和 ENDM,以及宏运算符(<>、!、&、% 和 .TYPE)。

  • 通过名字引用段。(但是,可以用段寄存器名作为操作数。)

寄存器值

不能在一个 asm 块开始的时候对寄存器值进行任何假设。寄存器有可能被 asm 块前面的执行代码修改。Microsoft Visual C++ 的关键字 __fastcall 会使编译器用寄存器来传递参数,为了避免寄存器冲突,不要同时使用 __fastcall 和 __asm。

一般情况下,可以在内嵌代码中修改 EAX、EBX、ECX 和 EDX,因为编译器并不期望在语句之间保留这些寄存器值。但是,如果修改的寄存器太多,那么编译器就无法对同一过程中的 C++ 代码进行完全优化,因为优化要用到寄存器。

虽然不能使用 OFFSET 运算符,但是用 LEA 指令也可以得到变量的偏移地址。比如,下面的指令将 buffer 的偏移地址送入 ESI:

lea esi,buffer

长度、类型和大小

内嵌汇编代码还可以使用 LENGTH、SIZE 和 TYPE 运算符。LENGTH 运算符返回数组内元素的个数。按照不同的对象,TYPE 运算符返回值如下:

  • 对 C 或 C++ 类型以及标量变量,返回其字节数。

  • 对结构,返回其字节数。

  • 对数组,返回其单个元素的大小。

SIZE 运算符返回 LENGTH*TYPE 的值。下面的程序片段演示了内嵌汇编程序对各种 C++ 类型的返回值。

Microsoft Visual C++ 内嵌汇编程序不支持 SIZEOF 和 LENGHTOF 运算符。

使用 LENGTH、TYPE 和 SIZE 运算符

下面程序包含的内嵌汇编代码使用 LENGTH、TYPE 和 SIZE 运算符对 C++ 变量求值。每个表达式的返回值都在同一行的注释中给出:

struct Package {
  long originZip;        // 4
  long destinationZip;   // 4
  float shippingPrice;   // 4
};
   char myChar;
   bool myBool;
   short myShort;
   int  myInt;
   long myLong;
   float myFloat;
   double myDouble;
   Package myPackage;
   long double myLongDouble;
   long myLongArray[10];
__asm {
   mov  eax,myPackage.destinationZip;
   mov  eax,LENGTH myInt;         // 1
   mov  eax,LENGTH myLongArray;   // 10
   mov  eax,TYPE myChar;          // 1
   mov  eax,TYPE myBool;          // 1
   mov  eax,TYPE myShort;         // 2
   mov  eax,TYPE myInt;           // 4
   mov  eax,TYPE myLong;          // 4
   mov  eax,TYPE myFloat;         // 4
   mov  eax,TYPE myDouble;        // 8
   mov  eax,TYPE myPackage;       // 12
   mov  eax,TYPE myLongDouble;    // 8
   mov  eax,TYPE myLongArray;     // 4
   mov  eax,SIZE myLong;          // 4
   mov  eax,SIZE myPackage;       // 12
   mov  eax,SIZE myLongArray;     // 40
}

C语言/C++内嵌汇编代码实例:文件加密

现在查看的简短程序实现如下操作:读取一个文件,对其进行加密,再将其输出到另一个文件。函数 TranslateBuffer 用一个 __asm 块定义语句,在一个字符数组内进行循环,并把每个字符与预定义值进行 XOR 运算。

内嵌语言可以使用函数形参、局部变量和代码标号。由于本例是由 Microsoft Visual C++ 编译的 Win32 控制台应用,因此其无符号整数类型为 32 位:

void TranslateBuffer(char * buf,
    unsigned count, unsigned char eChar)
{
    __asm {
        mov esi, buf
        mov ecx, count
        mov al, eChar
    L1:
        xor [esi],al
        inc esi
        loop L1
    }    // asm
}

C++ 模块

C++ 启动程序从命令行读取输入和输出文件名。在循环内调用 TranslateBuffer 从文件读取数据块,加密,再将转换后的缓冲区写入新文件:

// ENCODE.CPP    复制并加密文件。
#include <iostream>
#include <fstream>
#include "translat.h"
using namespace std;
int main( int argcount, char * args[] )
{ 
    // 从命令行读取输入和输出文件
    if( argcount < 3 ) {
        cout << "Usage: encode infile outfile" << endl;
        return -1;
    }
    const int BUFSIZE = 2000;
    char buffer[BUFSIZE];
    unsigned int count;            // 字符计算
    unsigned char encryptCode;
    cout << "Encryption code [0-255]? ";
    cin >> encryptCode;
    ifstream infile( args[1], ios::binary );
    ofstream outfile( args[2], ios::binary );
    cout << "Reading " << args[1] << " and creating "
        << args[2] << endl;
    while (!infile.eof() )
    {
        infile.read(buffer, BUFSIZE );
        count = infile.gcount();
        TranslateBuffer(buffer, count, encryptCode);
        outfile.write(buffer, count);
    }
    return 0;
}

用命令提示符运行该程序,并传递输入和输岀文件名是最容易的。比如,下面的命令行读取 infile.txt,生成 encoded.txt:

encode infile.txt encoded.txt

头文件

头文件 translat.h 包含了 TranslateBuffer 的一个函数原型:void TranslateBuffer(char * buf, unsigned count, unsigned char eChar);

过程调用的开销

如果在调试器调试程序时查看 Disassembly 窗口,那么,看到函数调用和返回究竟有多少开销是很有趣的。下面的语句将三个实参送入堆栈,并调用 TranslateBuffer。在 Visual C++ 的 Disassembly 窗口,激活 Show Source Code 和 Show Symbol Names 选项:

; TranslateBuffer(buffer, count, encryptCode)
mov al,byte ptr [encryptCode]
push eax
mov ecx,dword ptr [count]
push ecx
lea edx,[buffer]
push edx
call TranslateBuffer (4159BFh)
add esp, 0Ch

下面的代码对 TranslateBuffer 进行反汇编。编译器自动插入了一些语句用于设置 EBP,以及保存标准寄存器集合,集合内的寄存器不论是否真的会被过程修改,总是被保存。

push ebp
mov ebp, esp
sub esp,40h
push ebx
push esi
push edi
;内嵌代码从这里开始。
mov esi,dword ptr [buf]
mov ecx,dword ptr [count]
mov al,byte ptr [eChar]
L1:
    xor byte ptr [esi],al
    inc esi
    loop L1 (41D762h)
;内嵌代码结束。
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret

若关闭了调试器 Disassembly 窗口的 Display Symbol Names 选项,则将参数送入寄存器的三条语句如下:

mov esi,dword ptr [ebp+8]
mov ecx,dword ptr [ebp+0Ch]
mov al,byte ptr [ebp+10h]

编译器按要求生成 Debug 目标代码,这是非优化代码,适合于交互式调试。如果选择 Release 目标代码,那么编译器生成的代码就会更加有效(但易读性更差)。

忽略过程调用

本节前面给出的 TranslateBuffer 中有 6 条内嵌指令,其执行总共需要 8 条指令。

如果函数被调用几千次,那么其执行时间就比较可观了。为了消除这种开销,把内嵌代码插入调用 TranslateBuffer 的循环,得到更为有效的程序:

while (!infile.eof() )
  {
    infile.read(buffer, BUFSIZE );
    count = infile.gcount();
    __asm {
       lea esi,buffer
       mov ecx,count
       mov al, encryptCode
    L1:
       xor [esi],al
       inc  esi
       Loop L1
   } // asm

    outfile.write(buffer, count);
  }

C语言/C++调用汇编语言函数

为设备驱动器和嵌入式系统编码的程序员常常需要把 C/C++ 模块与用汇编语言编写的专门代码集成起来。汇编语言特别适合于直接硬件访问、位映射,以及对寄存器和 CPU 状态标识进行底层访问。

整个应用程序都用汇编语言编写是很乏味的,比较有用的方法是,用 C/C++ 编写主程序,而那些不太好用 C 编写的代码则用汇编语言。现在来讨论一下从 32 位 C/ C++ 程序调用汇编程序的一些标准要求。

C/C++ 程序从右到左传递参数,与参数列表的顺序一致。函数返回后,主调程序负责将堆栈恢复到调用前的状态。这可以采用两种方法:一种是将堆栈指针加上一个数值,该值等于参数大小;还有一种是从堆栈中弹出足够多的数。

在汇编源代码中,需要在 .MODEL 伪指令中指定 C 调用规范,并为外部 C/C++ 程序调用的每个过程创建原型。示例如下:

.586
.model flat,C
IndexOf PROTO,
   srchVal:DWORD, arrayPtr:PTR DWORD, count:DWORD

函数声明

在 C 程序中,声明外部汇编过程时要使用 extern 限定符。比如,下面的语句声明了 IndexOf:

extern long IndexOf(long n, long array[], unsigned count);

如果过程会被 C++ 程序调用,就要添加“C”限定符,以防止 C++ 的名称修饰:

extern "C" long IndexOf(long n, long array[], unsigned count);

名称修饰 (name decoration) 是一种标准 C++ 编译技术,通过添加字符来修改函数名,添加的字符指明了每个函数参数的确切类型。任何支持函数重载(多个函数有相同的函数名、不同的参数列表)的语言都需要这个技术。

从汇编语言程序员的角度来看,名称修饰存在的问题是:C++ 编译器让链接器去找的是修饰过的名称,而不是生成可执行文件时的原始名称。

IndexOf 示例

现在新建一个简单汇编函数,对数组实现线性搜索,找到与样本整数匹配的第一个实例。如果搜索成功,则返回匹配元素的索引位置;否则,返回 -1。该函数将被 C++ 程序调用。在 C++ 中,编写程序段如下:

long IndexOf(long searchVal, long array[], unsigned count)
{
    for(unsigned i = 0; i < count; i++) {
        if(array[i] == searchVal )
            return i;
    }
    return -1;
}

参数包括:希望被找到的数值、一个数组指针,以及数组大小。

用汇编语言编写该函数显然是很容易的。编写好的汇编代码放入自己的源代码文件 IndexOf.asm。这个文件将被编译为目标代码文件 IndexOf.obj。使用 Visual Studio 实现主调 C++ 程序与汇编模块的编译和链接。C++ 项目将用 Win32 控制台作为其输出类型,虽然也没有理由不让它成为图形应用程序。

下面为 IndexOf 模块的源代码清单。

;IndexOf 函数    (IndexOf . asm)
.586
.model flat,C
IndexOf PROTO,
    srchVal:DWORD, arrayPtr:PTR DWORD, count:DWORD
.code
;-------------------------------------------
IndexOf PROC USES ecx esi edi,
    srchVal:DWORD, arrayPtr:PTR DWORD, count:DWORD
;
;对 32 位整数数组执行线性搜索,
;寻找指定数值。如果发现匹配数值,
;用EAX返回该数值的索引位置;
;否则,EAX 返回 -1。
;-------------------------------------------
    NOT_FOUND = -1
    mov    eax, srchVal      ;搜索数值
    mov    ecx, count        ;数组大小
    mov    esi, arrayPtr     ;数组指针
    mov    edi, 0            ;索引
L1:cmp [esi+edi*4],eax
    je found
    inc edi
    loop L1
notFound:
    mov eax,NOT_FOUND
    jmp short exit
found:
    mov eax,edi
exit:
    ret
IndexOf ENDP
END

首先,注意到用于测试循环的汇编代码 25〜28 行,虽然代码量小,但是高效。对要执行很多次的循环,应试图使其循环体内的指令条数尽可能少:

L1: cmp [esi+edi*4],eax
   je found
   inc edi
   loop L1

如果找到匹配项,程序跳转到 34 行,将 EDI 复制到 EAX,该寄存器用于存放函数返回值。在搜索期间,EDI 为当前索引位置。

found:
   mov eax,edi

如果没有找到匹配项,则把 -1 赋值给 EAX 并返回:

notFound:
   mov eax,NOT_FOUND
   jmp short exit

下面为主调 C++ 程序清单。

#include <iostream>
#include <time.h>
#include "indexof.h"
using namespace std;
int main()  {
    // 用伪随机数填充数组
    const unsigned ARRAY_SIZE = 100000;
    const unsigned LOOP_SIZE = 100000;
    char* boolstr[] = {"false","true"};
    long array[ARRAY_SIZE];
    for(unsigned i = 0; i < ARRAY_SIZE; i++)
     array[i] = rand();
    long searchVal;
    time_t startTime, endTime;
    cout << "Enter an integer value to find: ";
    cin >> searchVal;
    cout << "Please wait...\n";
    // 测试汇编函数
    time( &startTime );
    long count = 0;
    for( unsigned n = 0; n < LOOP_SIZE; n++)
         count = IndexOf( searchVal, array, ARRAY_SIZE );
    bool found = count != -1;
    time( &endTime );
    cout << "Elapsed ASM time: " << long(endTime - startTime)
          << " seconds. Found = " << boolstr[found] << endl;
    return 0;
}

首先,用伪随机数值对数组进行初始化:

long array [ARRAY_SIZE];
for(unsigned i = 0; i < ARRAY_SIZE; i++)
   array[i] = rand();

18〜19 行提示用户输入在数组中搜索的数值:

cout << "Enter an integer value to find:";
cin >> searchVal;
23 行调用 C 链接库的 time 函数(在 time.h 中),把从 1970 年 1 月 1 日起已经过的秒数保存到变量 startTime:
time(&startTime);

26 和 27 行按照 LOOP_SIZE 的值 (100 000),反复执行相同的搜索:

for(unsigned n = 0; n < LOOP_SIZE; n++)
   count = IndexOf(searchVal, array, ARRAY_SIZE);

由于数组大小也约为 100 000,因此执行步骤的总数可以多达 100 000 x 100 000,或 100 亿。31〜33 行再次检查时间,并显示循环运行所耗的秒数:

time(&endTime);
cout << "Elapsed ASM time: " << long(endTime - startTime)
    << "seconds. Found = " << boolstr[found] << endl;

在高速计算机上测试时,循环执行时间为 6 秒。对 100 亿次迭代而言,这个时间不算多,每秒约有 16.7 亿次迭代。重要的是,需要意识到程序重复过程调用的开销(参数入栈,执行 CALL 和 RET 指令)也是 100 000 次。过程调用导致了相当多的额外处理。

汇编语言调用C语言/C++函数

可以编写汇编程序来调用 C 和 C++ 函数。这样做的理由至少有两个:

  • C 和 C++ 有丰富的输入-输出库,因此输入-输出有更大的灵活性。处理浮点数时,这是相当有用的。

  • 两种语言都有丰富的数学库。

调用标准 C 库(或 C++ 库)函数时,必须从 C 或 C++ 的 main() 过程启动程序,以便运行库初始化代码。

1) 函数原型

汇编语言代码调用的 C++ 函数,必须用“C”和关键字 extern 定义。其基本语法如下:

extern "C" returnType funcName(paramlist)
{...}

示例如下:

extern "C" int askForlnteger()
{
  cout << "Please enter an integer:";
  //...
}

与其修改每个函数定义,把多个函数原型放在一个块内显得更容易。然后,还可以省略单个函数实现的 extern 和“C”:

extern "C" {
  int askForlnteger();
  int showInt(int value, unsigned outwidth);
  //etc.
}

2) 汇编语言模块

如果汇编语言模块调用 Irvine32 链接库过程,就要使用如下 .MODEL 伪指令:

.model flat, STDCALL

虽然 STDCALL 与 Win32 API 兼容,但是它与 C 程序的调用规范不匹配。因此,在声明由汇编模块调用的外部 C 或 C++ 函数时,必须给 PROTO 伪指令加上 C 限定符:

INCLUDE Irvine32.inc
askForlnteger PROTO C
showInt PROTO C, value:SDWORD, outWidth:DWORD

C 限定符是必要的,因为链接器必须把函数名与 C++ 模块输出的参数列表匹配起来。此外,使用了 C 调用规范,汇编器必须生成正确的代码以便在函数调用后清除堆栈。

C++ 程序调用的汇编过程也必须使用 C 限定符,这样汇编器使用的命名规则将能被链接器识别。比如,下面的 SetTextColor 过程有一个双字参数:

SetTextOutColor PROC C,
color:DWORD
...
SetTextOutColor ENDP

最后,如果汇编代码调用其他汇编过程,C 调用规范要求在每个过程调用后,把参数从堆栈中移除。

如果汇编代码不调用 Irvine32 过程,就可以在 .MODEL 伪指令中使用 C 调用规范:

;(do not INCLUDE Irvine32.inc)
.586
.model flat,C
此时不再需要为 PROTO 和 PROC 伪指令添加 C 限定符:
askForInteger PROTO
showInt PROTO, value:SDWORD, outWidth:DWORD
SetTextOutColor PROC,
  color:DWORD
  ...
SetTextOutColor ENDP
3) 函数返回值

C++ 语言规范没有提及代码实现细节,因此没有规定标准方法让 C 和 C++ 函数返回数值。当编写的汇编代码调用这些语言的函数时,要检查编译器文件以便了解它们的函数是如何返回数值的。

下面列出了一些可能的情况,但并非全部:

  • 整数用单个寄存器或寄存器组返回。

  • 主调程序可以在堆栈中为函数返回值预留空间。函数在返回前,可以将返回值存入堆栈。

  • 函数返回前,浮点数值通常被压入处理器的浮点数堆栈。

下面列出了 Microsoft Visual C++ 函数怎样返回数值:

  • bool 和 char 值用 AL 返回。

  • short int 值用 AX 返回。

  • int 和 long int 值用 EAX 返回。

  • 指针用 EAX 返回。

  • float、double 和 long double 值分别以 4 字节、8 字节和 10 字节数值压入浮点堆栈。

汇编语言调用C语言/C++实例:乘法表

现在编写一个简单的应用程序,提示用户输入整数,通过移位的方式将其与 2 的幕 (2¹〜2ⁿ) 相乘,并用填充前导空格的形式再次显示每个乘积。输入-输出使用 C++。汇编模块将调用 3 个 C++ 编写的函数。程序将由 C++ 模块启动。

汇编语言模块

汇编模块包含一个函数 DisplayTable。它调用 C++ 函数 askForInteger 从用户输入一个整数。它还使用循环结构把整数 intVal 重复左移,并调用 showInt 进行显示。

; C++ 调用ASM函数.
INCLUDE Irvine32.inc
;外部C++函数
askForInteger PROTO C
showInt PROTO C, value:SDWORD, outWidth:DWORD
OUT_WIDTH = 8
ENDING_POWER = 10
.data
intVal DWORD ?
.code
;---------------------------------------------
SetTextOutColor PROC C,
    color:DWORD
;
; 设置文本颜色,并清除控制台窗口
; 调用 Irvine32 库函数
;---------------------------------------------
    mov    eax,color
    call    SetTextColor
    call    Clrscr
    ret
SetTextOutColor ENDP
;---------------------------------------------
DisplayTable PROC C
;
; 输入一个整数 n 并显示范围为 n * 2^1 ~ n * 2^10的乘法表
;----------------------------------------------
    INVOKE askForInteger                 ; 调用 C++ 函数
    mov    intVal,eax                    ; 保存整数
    mov    ecx,ENDING_POWER              ; 循环计数器
L1:    push ecx                          ; 保存循环计数器
    shl  intVal,1                        ; 乘以 2
    INVOKE showInt,intVal,OUT_WIDTH
    call    Crlf
    pop    ecx                           ; 恢复循环计数器
    loop    L1
    ret
DisplayTable ENDP
END
在 DisplayTable 过程中,必须在调用 showInt 和 newLine 之前将 ECX 入栈,并在调用后将 ECX 出栈,这是因为 Visual C++ 函数不会保存和恢复通用寄存器。函数 askForInteger 用 EAX 寄存器返回结果。

DisplayTable 在调用 C++ 函数时不一定要用 INVOKE。PUSH 和 CALL 指令也能得到同样的结果。对 showInt 的调用如下所示:

push OUT_WIDTH ;最后一个参数首先入栈
push intVal
call showInt       ;调用函数
add esp,8        ;清除堆栈

必须遵守 C 语言调用规范,其参数按照逆序入栈,且主调方负责在调用后从堆栈移除实参。

C++ 测试程序

下面查看启动程序的 C++ 模块。其入口为 main(),保证执行所需 C++ 语言的初始化代码。它包含了外部汇编过程和三个输岀函数的原型:

// main.cpp
// 演示C++程序和外部汇编模块的函数调用
#include <iostream>
#include <iomanip>
using namespace std;
extern "C" {
    // 外部 ASM 过程:
    void DisplayTable();
    void SetTextOutColor( unsigned color );
    // 局部 C++ 函数:
    int askForInteger();
    void showInt( int value, int width );
}
// 程序入口
int main()
{
    SetTextOutColor( 0x1E );       // 蓝底黄字
    DisplayTable();                // 调用 ASM 过程
    return 0;
}
// 提示用户输入一个整数
int askForInteger()
{
    int n;
    cout << "Enter an integer between 1 and 90,000: ";
    cin >> n;
    return n;
}
// 按特定宽度显示一个有符号整数
void showInt( int value, int width )
{
    cout << setw(width) << value;
}

生成项目

将 C++ 和汇编模块添加到 Visual Studio 项目,并在 Project 菜单中选择 Build Solution。

程序输出

当用户输入为 90 000 时,乘法表程序产生的输出如下

Visual Studio 项目属性

如果使用 Visual Studio 生成集成了 C++ 和汇编代码的程序,并且调用 Irvine32 链接库,就需要修改某些项目设置。以 Multiplication_Table 程序为例。

在 Project 菜单中选择 Properties,在窗口左边的 Configuration Properties 条目下,选择 Linker。在右边面板的 Additional Library Directories 条目中输入 c:\Irvine。

示例如下图所示。点击OK关闭 Project Property Pages 窗口。现在 Visual Studio 就可以找到 Irvine32 链接库了。

汇编语言调用C语言/C++库函数

C 语言有标准函数集合,被称为标准 C 库 (Standard C Library)。同样的函数还可以用于 C++ 程序,因此,也可用于与 C 和 C++ 程序连接的汇编模块。

汇编模块调用 C 函数时,就必须包含函数的原型。一般通过访问 C++ 编译器的帮助系统可以找到 C 函数原型。程序调用 C 函数时,需要先将 C 函数原型转换为汇编语言原型。

printf 函数

下面是 printf 函数的 C/C++ 语言原型,第一个参数为字符指针,其后跟了一组可变数量的参数:

int printf(
   const char * format [, argument]...
);

C/C++ 编译器的帮助库可以查阅到 printf 函数文档。汇编语言中与之等效的函数原型将 char* 改为 PTR BYTE,将可变长度参数列表的类型改为 VARARG:

printf PROTO C, pString:PTR BYTE, args:VARARG

另一个有用的函数是 scanf,用于从标准输入(键盘)接收字符、数字和字符串,并将输入数值分配给变量:

scanf PROTO C, format:PTR BYTE, args:VARARG

用 printf 函数显示格式化实数

编写汇编函数格式化并显示浮点数不是一件容易的事。与其由程序员自行编码,还不如利用 C 库的 printf 函数。需要创建 C 或 C++ 的启动模块,并将其与汇编代码链接

下面给出了用 Visual C++.NET 创建这种程序的过程:

\1) 用 Visual C++ 创建一个 Win32 控制台程序。创建文件 main.cpp,并插入函数 main,该函数调用了 asmMain:

extern "C" void asmMain();
int main()
{
   asmMain();
   return 0;
}

\2) 在 main.cpp 所在的文件夹中,创建一个汇编模块 asmMain.asm。该模块包含过程 asmMain,并声明使用 C 调用规范:

; asmMain.asm
.386
.model flat,stdcall
.stack 2000
.code
asmMain PROC C
   ret
asmMain ENDP
END

\3) 汇编 asmMain.asm(但不进行链接),生成 asmMain.obj。

\4) 将 asmMain.obj 添加到 C++ 项目。

\5) 构建并运行项目。如果修改了 asmMain.asm,则在运行前,需要再一次汇编和构建项目。

一旦程序正确建立,就可以向 asmMain.asm 添加代码来调用 C/C++ 函数。

显示双精度数值

下面是 asmMain 中的汇编代码,它通过调用 printf 输岀了一个类型为 REAL8 的数值:

.data
double1 REAL8 1234567.890123
formatStr BYTE "%.3f", 0dh, 0ah, 0
.code
INVOKE printf, ADDR formatStr, double1

相应的输出如下:

1234567.890

这里,传递给 printf 的格式化字符串与 C++ 中的略有不同:不是插入转义字符,如 \n,而是必须插入 ASCII 字符(0dh, 0ah)。

传递给 printf 的浮点参数应声明为 REAL8 类型。不过传递的数值也可能是 REAL4 类型,这需要相当的编程技巧。若想了解 C++ 编译器是如何工作的,可以声明一个 float 类型的变量,并将其传递给 printf。编译程序,并用调试器跟踪该程序的反汇编代码。

多参数

printf 函数接收可变数量的参数,因此很容易在一次函数调用中对两个数进行格式化并显示它们:

TAB = 9
.data
formatTwo BYTE "%.2f",TAB,"%.3f",0dh,0ah,0
val1 REAL8 456.789
val2 REAL8 864.231
.code
INVOKE printf, ADDR formatTwo, val1, val2

相应的输岀如下:

456.79 864.231

用 scanf 函数输入实数

调用 scanf 可以从用户输入浮点数。SmallWin.inc(包括在 Irvine32.inc 内)定义的函数原型如下所示:

scanf PROTO C,
   format:PTR BYTE, args:VARARG

传递给它的参数包括:格式化字符串的偏移量,一个或多个 REAL4、REAL8 类型变量的偏移量(这些变量存放了用户输入的数值)。调用示例如下:

.data
strSingle BYTE "%f", 0
strDouble BYTE "%lf",0
single1 REAL4 ?
double1 REAL8 ?
.code
INVOKE scanf, ADDR strSingle, ADDR single1
INVOKE scanf, ADDR strDouble, ADDR double1

必须从 C 或 C++ 启动程序中调用汇编语言代码。

C/C++调用汇编语言实例:目录表程序

现在编写一个简短的程序,清除屏幕,显示当前磁盘目录,并请求用户输入文件名。程序员可能希望扩展该程序,以打开并显示被选中文件。

C++ 根模块

C++ 模块只有一个对 asm_main 的调用,因此可以将其称为根模块 (stub module):

// main.cpp
//根模块:启动汇编程序
extern "C" void asm_main() ; // asm 启动过程
void main()
{
  asm_main();
}

ASM 模块

汇编语言模块包括了函数原型、若干字符串和一个 fileName 变量。模块两次调用 system 函数,向其传递“cls”和“dir”命令。然后调用 printf,显示请求文件名的提示行,再调用 scanf,使用户输入文件名。

程序不调用 Irvine32 库中的任何函数,因此可以将 .MODEL 伪指令设置为 C 语言规范:

; 从 C++ 启动的 ASM 程序 (asmMain.asm)
.586
.MODEL flat,C
; 标准 C 库函数
system PROTO, pCommand:PTR BYTE
printf PROTO, pString:PTR BYTE, args:VARARG
scanf  PROTO, pFormat:PTR BYTE,pBuffer:PTR BYTE, args:VARARG
fopen  PROTO, mode:PTR BYTE, filename:PTR BYTE
fclose PROTO, pFile:DWORD
BUFFER_SIZE = 5000
.data
str1 BYTE "cls",0
str2 BYTE "dir/w",0
str3 BYTE "Enter the name of a file: ",0
str4 BYTE "%s",0
str5 BYTE "cannot open file",0dh,0ah,0
str6 BYTE "The file has been opened and closed",0dh,0ah,0
modeStr BYTE "r",0
fileName BYTE 60 DUP(0)
pBuf  DWORD ?
pFile DWORD ?
.code
asm_main PROC
    ; 清除屏幕,显示磁盘目录
    INVOKE system,ADDR str1
    INVOKE system,ADDR str2

    ; 清除文件名
    INVOKE printf,ADDR str3
    INVOKE scanf, ADDR str4, ADDR fileName
    ; 尝试打开文件
    INVOKE fopen, ADDR fileName, ADDR modeStr
    mov pFile,eax
    .IF eax == 0                ; 不能打开文件
      INVOKE printf,ADDR str5
      jmp quit
    .ELSE
      INVOKE printf,ADDR str6
    .ENDIF
    ; 关闭文件
    INVOKE fclose, pFile
quit:
    ret                         ; 返回 C++ 主程序
asm_main ENDP
END

函数 scanf 需要两个参数:第一个是格式化字符串(“%s”)的指针,第二个是输入字符串变量(fileName)的指针。因为互联网上有丰富的文档,因此这里不再浪费时间来解释标准 C 函数。


文章作者: 杰克成
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 杰克成 !
评论
  目录