汇编语言的艺术第二版-全-
汇编语言的艺术第二版(全)
原文:
zh.annas-archive.org/md5/8a73b01d4b56b1eea85b223d23fdcac1译者:飞龙
第一章. 汇编语言的“你好,世界!”

本章是一个“快速入门”章节,旨在让你尽快开始编写基本的汇编语言程序。本章内容包括:
-
介绍了 HLA(高级汇编语言)程序的基本语法
-
介绍 Intel CPU 架构
-
提供一些数据声明、机器指令和高级控制语句
-
描述了一些你可以在 HLA 标准库中调用的实用程序
-
向你展示如何编写一些简单的汇编语言程序
本章结束时,你应该理解 HLA 程序的基本语法,并且应该了解学习后续章节中新的汇编语言特性所需的前置条件。
1.1 HLA 程序的结构
一个典型的 HLA 程序的形式如图 1-1 所示。

图 1-1. 基本 HLA 程序
上述模板中的pgmID是一个用户定义的程序标识符。你必须为你的程序选择一个合适的描述性名称。特别是,pgmID对于任何实际程序来说都是一个糟糕的选择。如果你是在编写课程作业,导师可能会为你提供主程序的名称。如果你是在编写自己的 HLA 程序,你将需要为你的项目选择一个合适的名称。
HLA 中的标识符与大多数高级语言中的标识符非常相似。HLA 标识符可以以下划线或字母字符开头,后面可以跟零个或多个字母数字字符或下划线字符。HLA 的标识符是大小写不敏感的。这意味着标识符是区分大小写的,因为你必须在程序中始终准确地拼写标识符(包括大小写)。然而,与像 C/C++等区分大小写的语言不同,你不能在程序中声明两个仅因字母大小写不同而名称不同的标识符。
人们常写的传统第一个程序,由 Kernighan 和 Ritchie 的《C 程序设计语言》推广,是“你好,世界!”程序。这个程序是学习新语言的一个极好的具体例子。示例 1-1 展示了 HLA 的helloWorld程序。
示例 1-1. helloWorld程序
program helloWorld;
#include( "stdlib.hhf" );
begin helloWorld;
stdout.put( "Hello, World of Assembly Language", nl );
end helloWorld;
程序中的#include语句告诉 HLA 编译器包含来自stdlib.hhf(标准库,HLA 头文件)的一组声明。此文件包含了程序使用的stdout.put代码声明。
stdout.put 语句是 HLA 语言中的打印语句。你用它将数据写入标准输出设备(通常是控制台)。对于熟悉高级语言中 I/O 语句的人来说,这个语句显然是用来打印短语 Hello, World of Assembly Language 的。该语句末尾的 nl 是一个常量,也定义在 stdlib.hhf 中,表示换行序列。
请注意,分号跟在 program、begin、stdout.put 和 end 语句后面。严格来说,#include 语句后并不跟分号。可以创建包含错误的 include 文件,如果在 #include 语句后加分号,会导致错误,因此你可能要养成不在此处加分号的习惯。
#include 是你第一次接触 HLA 声明的地方。#include 本身并不是声明,但它告诉 HLA 编译器将文件 stdlib.hhf 替代 #include 指令,从而在你的程序中插入几个声明。你编写的大多数 HLA 程序都需要包含一个或多个 HLA 标准库头文件(stdlib.hhf 实际上将所有标准库定义包含进你的程序中)。
编译此程序将生成一个 console 应用程序。在命令窗口中运行此程序会打印指定的字符串,然后控制权返回到命令行解释器(在 Unix 术语中称为 shell)。
HLA 是一种自由格式的语言。因此,如果将语句拆分成多行有助于提高程序的可读性,你可以这样做。例如,你可以像下面这样编写 helloWorld 程序中的 stdout.put 语句:
stdout.put
(
"Hello, World of Assembly Language",
nl
);
你会在本书的示例代码中看到另一个结构,HLA 会自动连接它在源文件中发现的任何相邻的字符串常量。因此,上面的语句也等同于
stdout.put
(
"Hello, "
"World of Assembly Language",
nl
);
事实上,nl(换行符)实际上不过是一个字符串常量,因此(从技术上讲)nl 和前一个字符串之间的逗号并不是必需的。你通常会看到上述写法是
stdout.put( "Hello, World of Assembly Language" nl );
注意字符串常量和 nl 之间没有逗号;这在 HLA 中是合法的,尽管它只适用于某些常量;通常情况下,你不能省略逗号。第四章 详细解释了这一点。这里提到这一点是因为你可能会在正式解释之前,看到示例代码使用了这个“技巧”。
1.2 运行你的第一个 HLA 程序
“Hello, world!” 程序的整个目的在于提供一个简单的示例,通过它,学习新编程语言的人可以弄清楚如何使用所需的工具来编译和运行该语言的程序。的确,1.1 HLA 程序的构成中的helloWorld 程序有助于演示一个简单的 HLA 程序的格式和语法,但像 helloWorld 这样的程序的真正目的是学习如何从头到尾创建和运行一个程序。尽管上一节介绍了 HLA 程序的布局,但并未讨论如何编辑、编译和运行该程序。本节将简要介绍这些细节。
所有需要编译和运行 HLA 程序的软件可以在 randallhyde.com/ 或 webster.cs.ucr.edu/ 找到。在快速导航面板中选择 High Level Assembly,然后从该页面选择下载 HLA 链接。HLA 目前可用于 Windows、Mac OS X、Linux 和 FreeBSD。下载适合你系统的 HLA 软件版本。在 HLA 下载页面,你还可以下载与本书相关的所有软件。如果 HLA 下载包中没有包含这些内容,你可能需要下载 HLA 参考手册和 HLA 标准库参考手册,连同 HLA 和本书的软件一起下载。本书并未描述整个 HLA 语言,也没有描述整个 HLA 标准库。在学习使用 HLA 的汇编语言时,最好随时准备好这些参考手册。
本节不会描述如何安装和设置 HLA 系统,因为这些说明会随时间而变化。每个操作系统的 HLA 下载页面都会描述如何安装和使用 HLA。请查阅这些说明以获得准确的安装程序。
创建、编译和运行 HLA 程序的过程与在任何计算机语言中创建、编译或运行程序的过程非常相似。首先,由于 HLA 不是一个集成开发环境(IDE),不能在同一个程序中进行编辑、编译、测试、调试和运行应用程序,你将使用文本编辑器来创建和编辑 HLA 程序。^([1])
Windows、Mac OS X、Linux 和 FreeBSD 提供了多种文本编辑器选项。你甚至可以使用其他 IDE 附带的文本编辑器来创建和编辑 HLA 程序(例如在 Visual C++、Borland 的 Delphi、Apple 的 Xcode 以及类似语言中找到的编辑器)。唯一的限制是 HLA 期望的是 ASCII 文本文件,因此你使用的编辑器必须能够处理并保存文本文件。在 Windows 下,你始终可以使用记事本来创建 HLA 程序。如果你在 Linux 或 FreeBSD 下工作,你可以使用 joe、vi 或 emacs。在 Mac OS X 下,你可以使用 XCode、Text Wrangler 或你偏好的其他编辑器。
HLA 编译器^([2])是一个传统的命令行编译器,这意味着你需要从 Windows 的命令行提示符或 Linux/FreeBSD/Mac OS X 的Shell中运行它。为此,你需要在命令行提示符或 Shell 窗口中输入如下命令:
hla hw.hla
此命令告诉 HLA 将hw.hla(helloWorld)程序编译成可执行文件。如果没有错误,你可以通过在命令提示符窗口(Windows)中输入以下命令来运行生成的程序:
hw
或者进入 Shell 解释器窗口(Linux/FreeBSD/Mac OS X):
./hw
如果你在编译和运行程序时遇到问题,请参阅 HLA 下载页面上的安装说明。这些说明详细描述了如何安装、设置和使用 HLA。
^([1]) HIDE(HLA 集成开发环境)是一个适用于 Windows 用户的 IDE。有关下载 HIDE 的详细信息,请参阅高级汇编语言(HLA)网页。
^([2]) 传统上,程序员总是将汇编语言的翻译器称为汇编器,而不是编译器。然而,由于 HLA 具有高级特性,因此将 HLA 称为编译器而不是汇编器更为恰当。
1.3 一些基本的 HLA 数据声明
HLA 提供了各种常量、类型和数据声明语句。后续章节将更详细地讲解声明部分,但了解如何在 HLA 程序中声明一些简单的变量是很重要的。
HLA 预定义了几种不同的有符号整数类型,包括 int8、int16 和 int32,分别对应 8 位(1 字节)有符号整数、16 位(2 字节)有符号整数和 32 位(4 字节)有符号整数。^([3])典型的变量声明发生在 HLA 的静态变量部分。一组典型的变量声明形式如图 1-2 所示。

图 1-2. 静态变量声明
熟悉 Pascal 语言的人应该能轻松理解这种声明语法。此示例演示了如何声明三个独立的整数:i8、i16 和 i32。当然,在实际程序中,你应该使用更具描述性的变量名。像 i8 和 i32 这样的名称虽然描述了对象的类型,但没有描述其用途。变量名应当描述对象的用途。
在静态声明部分,你还可以为变量赋初始值,操作系统将在程序加载到内存时将该初始值赋给变量。图 1-3 提供了相关的语法。

图 1-3. 静态变量初始化
重要的是要意识到,在赋值运算符(:=)后面的表达式必须是常量表达式。你不能在静态变量声明中为其他变量赋值。
熟悉其他高级语言(尤其是 Pascal)的人应注意,你每个语句只能声明一个变量。也就是说,HLA 不允许在一个声明中使用逗号分隔的变量名列表,后跟冒号和类型标识符。每个变量声明由一个标识符、一个冒号、一个类型标识符和一个分号组成。
示例 1-2 提供了一个简单的 HLA 程序,演示了在 HLA 程序中使用变量。
示例 1-2. 变量声明与使用
Program DemoVars;
#include( "stdlib.hhf" )
static
InitDemo: int32 := 5;
NotInitialized: int32;
begin DemoVars;
// Display the value of the pre-initialized variable:
stdout.put( "InitDemo's value is ", InitDemo, nl );
// Input an integer value from the user and display that value:
stdout.put( "Enter an integer value: " );
stdin.get( NotInitialized );
stdout.put( "You entered: ", NotInitialized, nl );
end DemoVars;
除了静态变量声明外,此示例还引入了三个新概念。首先,stdout.put语句允许多个参数。如果你指定一个整数值,stdout.put会将该值转换为字符串表示并输出。
在示例 1-2 中引入的第二个新特性是stdin.get语句。此语句从标准输入设备(通常是键盘)读取一个值,将该值转换为整数,并将整数值存储到NotInitialized变量中。最后,示例 1-2 还介绍了(其中一种形式的)HLA 注释的语法。HLA 编译器会忽略从//序列到当前行末尾的所有文本。(熟悉 Java、C++和 Delphi 的人应该能识别这些注释。)
^([3]) 对于不熟悉这些术语的人,关于比特和字节的讨论将在第二章中出现。
1.4 布尔值
HLA 和 HLA 标准库对布尔对象提供了有限的支持。你可以声明布尔变量,使用布尔字面常量,在布尔表达式中使用布尔变量,并且可以打印布尔变量的值。
布尔字面常量由两个预定义标识符 true 和 false 组成。HLA 内部用数值 1 表示真,0 表示假。大多数程序将 0 视为假,将其他任何值视为真,因此 HLA 对真和假的表示应该足够。
要声明一个布尔变量,你使用 boolean 数据类型。HLA 使用一个字节(它能分配的最小内存)来表示布尔值。以下示例演示了一些典型的声明:
static
BoolVar: boolean;
HasClass: boolean := false;
IsClear: boolean := true;
正如这个示例所演示的,你可以在需要时初始化布尔变量。
因为布尔变量是字节对象,你可以使用任何直接操作 8 位值的指令来操作它们。此外,只要确保布尔变量只包含 0 和 1(分别表示假和真),你就可以使用 80x86 的 and、or、xor 和 not 指令来操作这些布尔值(这些指令在第二章中有介绍)。
你可以通过调用 stdout.put 例程来打印布尔值。例如:
stdout.put( BoolVar )
这个例程会根据布尔参数的值打印 true 或 false(0 为假,其他值为真)。请注意,HLA 标准库不允许通过 stdin.get 读取布尔值。
1.5 字符值
HLA 允许你使用 char 数据类型声明 1 字节的 ASCII 字符对象。你可以通过将字符值用一对撇号括起来来初始化字符变量。以下示例演示了如何在 HLA 中声明和初始化字符变量:
static
c: char;
LetterA: char := 'A';
你可以使用 stdout.put 例程打印字符变量,也可以通过调用 stdin.get 过程来读取字符变量。
1.6 英特尔 80x86 CPU 系列简介
到目前为止,你已经看过几个实际可以编译和运行的 HLA 程序。然而,到目前为止,程序中出现的所有语句都只是数据声明或对 HLA 标准库例程的调用。还没有涉及任何真正的汇编语言。在我们进一步学习一些真正的汇编语言之前,有必要先绕个弯;除非你了解英特尔 80x86 CPU 系列的基本结构,否则机器指令几乎没有意义。
Intel CPU 家族通常被归类为 冯·诺依曼架构机器。冯·诺依曼计算机系统包含三个主要组成部分:中央处理单元(CPU)、内存 和 输入/输出(I/O)设备。这三部分通过 系统总线(由地址总线、数据总线和控制总线组成)互联。图 1-4 显示了这种关系。
CPU 通过将一个数值放置在地址总线上,来与内存和 I/O 设备进行通信,从而选择一个内存位置或 I/O 设备端口位置,每个位置都有一个唯一的二进制数值 地址。然后,CPU、内存和 I/O 设备通过将数据放置在数据总线上相互传递数据。控制总线包含决定数据传输方向(到/从内存以及到/从 I/O 设备)的信号。

图 1-4. 冯·诺依曼计算机系统框图
80x86 CPU 寄存器可以分为四类:通用寄存器、特殊用途应用程序可访问的寄存器、段寄存器和特殊用途内核模式寄存器。由于段寄存器在现代 32 位操作系统(如 Windows、Mac OS X、FreeBSD 和 Linux)中使用较少,并且本书的内容主要针对为 32 位操作系统编写的程序,因此不需要过多讨论段寄存器。特殊用途的内核模式寄存器是用于编写操作系统、调试器及其他系统级工具的。这类软件构建超出了本书的范围。
80x86(Intel 家族)CPU 提供了多个通用寄存器供应用程序使用。其中包括八个 32 位寄存器,名称分别为:EAX、EBX、ECX、EDX、ESI、EDI、EBP 和 ESP。
每个名称前的 E 前缀表示 扩展。这个前缀将 32 位寄存器与八个 16 位寄存器区分开来,后者的名称分别为:AX、BX、CX、DX、SI、DI、BP 和 SP。
最后,80x86 CPU 提供了八个 8 位寄存器,名称分别为:AL、AH、BL、BH、CL、CH、DL 和 DH。
不幸的是,这些并不是完全独立的寄存器。也就是说,80x86 并没有提供 24 个独立的寄存器。相反,80x86 使用 32 位寄存器覆盖 16 位寄存器,并且使用 16 位寄存器覆盖 8 位寄存器。图 1-5 展示了这种关系。
关于通用寄存器,最重要的一点是它们不是独立的。修改一个寄存器可能会修改最多三个其他寄存器。例如,修改 EAX 寄存器可能会影响 AL、AH 和 AX 寄存器。这个事实在这里必须特别强调。初学汇编语言的程序员常犯的一个错误是寄存器值被破坏,因为程序员没有完全理解 图 1-5 通用寄存器") 中显示的关系。

图 1-5. 80x86 (Intel CPU) 通用寄存器
EFLAGS 寄存器是一个 32 位寄存器,封装了多个单比特布尔值(真/假)。EFLAGS 寄存器中的大部分位要么是为内核模式(操作系统)功能保留的,要么对于应用程序员来说并不重要。其中特别有意义的八个比特(或称为标志)是应用程序员编写汇编语言程序时需要关注的。这些标志包括溢出、方向、禁止中断、符号、零、辅助进位、奇偶和进位标志。图 1-6 展示了这些标志在 EFLAGS 寄存器低 16 位中的布局。

图 1-6. EFLAGS 寄存器的布局(EFLAGS 寄存器的低 16 位)
在八个对应用程序员有意义的标志中,尤其有四个标志是极为重要的:溢出、进位、符号和零标志。我们统称这四个标志为条件码。图 1-5 这些标志的状态可以让你测试之前计算的结果。例如,在比较两个值之后,条件码标志会告诉你第一个值是否小于、等于或大于第二个值。
对于刚学习汇编语言的人来说,有一个重要的事实可能会让人感到惊讶,那就是几乎所有在 80x86 CPU 上的计算都涉及寄存器。例如,为了将两个变量相加并将结果存储到第三个变量中,你必须将其中一个变量加载到寄存器中,将第二个操作数加到寄存器中的值,然后将寄存器的值存储到目标变量中。寄存器几乎在每个计算中都充当中介。因此,寄存器在 80x86 汇编语言程序中非常重要。
另一个需要注意的事情是,尽管寄存器被称为“通用寄存器”,但你不应推断出可以将任何寄存器用于任何目的。所有 80x86 寄存器都有自己的特殊用途,这限制了它们在某些上下文中的使用。例如,SP/ESP 寄存器对有一个非常特殊的用途,实际上阻止你将其用于其他任何事情(它是栈指针)。同样,BP/EBP 寄存器也有一个特殊的用途,这限制了它作为通用寄存器的有效性。目前,你应该避免在通用计算中使用 ESP 和 EBP 寄存器;另外,请记住,剩余的寄存器在程序中并不是完全可以互换的。
^([4]) 应用程序无法修改中断标志,但我们将在第二章中查看该标志;因此,这里会讨论该标志。
^([5]) 从技术上讲,奇偶标志也是一种状态码,但在本文中我们不会使用该标志。
1.7 内存子系统
一台运行现代 32 位操作系统的典型 80x86 处理器可以访问最多 2³²个不同的内存位置,即超过 40 亿字节。几年前,4GB 内存看起来就像是无限;然而,现代计算机已经超过了这一限制。尽管如此,由于 80x86 架构在使用像 Windows、Mac OS X、FreeBSD 或 Linux 等 32 位操作系统时支持最大 4GB 的地址空间,以下讨论将假设 4GB 的限制。
当然,你首先应该问的问题是,“什么是内存位置?”80x86 支持字节可寻址内存。因此,基本的内存单元是字节,足以存储单个字符或一个(非常)小的整数值(我们将在第二章中详细讨论)。
把内存想象成一个线性字节数组。第一个字节的地址是 0,最后一个字节的地址是 2³²−1。对于 80x86 处理器,以下伪 Pascal 数组声明是内存的一个良好近似:
Memory: array [0..4294967295] of byte;
C/C++和 Java 用户可能更喜欢以下语法:
byte Memory[4294967296];
要执行等同于 Pascal 语句Memory [125] := 0;的操作,CPU 将值 0 放到数据总线上,将地址 125 放到地址总线上,并激活写线(这通常涉及将该线路设置为 0),如图 1-7 所示。

图 1-7. 内存写入操作
要执行等同于CPU := Memory [125];的操作,CPU 会将地址 125 放到地址总线上,激活读线(因为 CPU 正在从内存读取数据),然后从数据总线读取结果数据(见图 1-8)。

图 1-8. 内存读取操作
这个讨论仅适用于访问内存中的单个字节。那么当处理器访问字或双字时会发生什么呢?由于内存是由字节数组构成的,那么我们如何处理大于单字节的值呢?很简单——为了存储更大的值,80x86 使用一系列连续的内存位置。图 1-9 展示了 80x86 如何在内存中存储字节、字(2 字节)和双字(4 字节)。每个这些对象的内存地址是每个对象第一个字节的地址(即最低地址)。
现代的 80x86 处理器并不直接连接到内存。相反,CPU 上有一个特殊的内存缓冲区,称为 缓存(发音为“cash”),它充当 CPU 和主内存之间的高速中介。尽管缓存自动为你处理了这些细节,但你应该知道的一个事实是,如果对象的地址是该对象大小的偶数倍,访问内存中的数据对象有时会更加高效。因此,最好将 4 字节的对象(双字)对齐到 4 的倍数地址。同样,将 2 字节的对象对齐到偶数地址是最有效的。你可以在任何地址高效访问单字节对象。你将在 3.4 HLA 数据对齐支持中了解如何设置内存对象的对齐方式。

图 1-9. 内存中的字节、字和双字存储
在讨论内存对象之前,理解内存与 HLA 变量之间的对应关系非常重要。使用像 HLA 这样的汇编器/编译器的一个好处是,你不需要担心数字内存地址。你所需要做的就是在 HLA 中声明一个变量,HLA 会自动将该变量与一组唯一的内存地址关联起来。例如,如果你有以下的声明部分:
static
i8 :int8;
i16 :int16;
i32 :int32;
HLA 会在内存中找到一些未使用的 8 位字节,并将其与 i8 变量关联;它会找到一对连续的未使用字节,并将 i16 与它们关联;最后,HLA 会找到 4 个连续的未使用字节,并将 i32 的值与这 4 个字节(32 位)关联。你将始终通过这些变量的名称来引用它们。通常,你不需要关心它们的数字地址。不过,你应该知道 HLA 在幕后为你完成了这些操作。
1.8 一些基本的机器指令
80x86 CPU 系列提供了从一百多条到几千条不同的机器指令,具体数量取决于你如何定义机器指令。即便是在指令数量较少的一端(大于 100),似乎也有太多指令需要在短时间内学习。幸运的是,你不需要知道所有的机器指令。事实上,大多数汇编语言程序可能只使用大约 30 条不同的机器指令。^([6]) 确实,你完全可以用几条机器指令编写几个有意义的程序。本节的目的是提供少量机器指令,让你能够立即开始编写简单的 HLA 汇编语言程序。
毋庸置疑,mov 指令是最常用的汇编语言语句。在一个典型的程序中,25% 到 40% 的指令都是 mov 指令。正如其名称所示,这条指令将数据从一个位置移动到另一个位置。^([7]) 该指令的 HLA 语法是:
mov( *`source_operand`*, *`destination_operand`* );
source_operand 可以是寄存器、内存变量或常量。destination_operand 可以是寄存器或内存变量。严格来说,80x86 指令集不允许两个操作数都为内存变量。然而,HLA 会自动将两个字或双字内存操作数的 mov 指令转换为一对指令,从一个位置复制数据到另一个位置。在像 Pascal 或 C/C++ 这样的高级语言中,mov 指令大致相当于以下赋值语句:
*`destination_operand`* = *`source_operand`* ;
或许 mov 指令操作数的主要限制是它们必须具有相同的大小。也就是说,你可以在一对字节(8 位)、字(16 位)或双字(32 位)对象之间移动数据;但是,你不能混合操作数的大小。表 1-1 列出了所有 mov 指令的合法组合。
你应该仔细研究这张表,因为大多数通用 80x86 指令都使用这种语法。
表 1-1. 合法的 80x86 mov 指令操作数
| 源 | 目标 |
|---|---|
| Reg[8]^([a]) | Reg[8] |
| Reg[8] | Mem[8] |
| Mem[8] | Reg[8] |
| 常量^([b]) | Reg[8] |
| 常量 | Mem[8] |
| Reg[16] | Reg[16] |
| Reg[16] | Mem[16] |
| Mem[16] | Reg[16] |
| 常量 | Reg[16] |
| 常量 | Mem[16] |
| Reg[32] | Reg[32] |
| Reg[32] | Mem[32] |
| Mem[32] | Reg[32] |
| 常量 | Reg[32] |
| 常量 | Mem[32] |
|
^([a]) 后缀表示寄存器或内存位置的大小。
^([b]) 常量必须足够小,以适应指定的目标操作数。
|
80x86 的add和sub指令允许你进行加法和减法操作。它们的语法与mov指令几乎完全相同:
add( *`source_operand`*, *`destination_operand`* );
sub( *`source_operand`*, *`destination_operand`* );
add和sub操作数的格式与mov指令完全相同。^([8]) add指令执行以下操作:
*`destination_operand`* = *`destination_operand`* + *`source_operand`* ;
*`destination_operand`* += *`source_operand`*; // For those who prefer C syntax.
sub指令执行计算:
*`destination_operand`* = *`destination_operand`* - *`source_operand`* ;
*`destination_operand`* -= *`source_operand`* ; // For C fans.
仅凭这三条指令,再加上下一节讨论的 HLA 控制结构,你实际上就可以编写一些复杂的程序。示例 1-3 提供了一个 HLA 程序示例,展示了这三条指令。
示例 1-3. mov、add和sub指令演示
program DemoMOVaddSUB;
#include( "stdlib.hhf" )
static
i8: int8 := −8;
i16: int16 := −16;
i32: int32 := −32;
begin DemoMOVaddSUB;
// First, print the initial values
// of our variables.
stdout.put
(
nl,
"Initialized values: i8=", i8,
", i16=", i16,
", i32=", i32,
nl
);
// Compute the absolute value of the
// three different variables and
// print the result.
// Note: Because all the numbers are
// negative, we have to negate them.
// Using only the mov, add, and sub
// instructions, we can negate a value
// by subtracting it from zero.
mov( 0, al ); // Compute i8 := -i8;
sub( i8, al );
mov( al, i8 );
mov( 0, ax ); // Compute i16 := -i16;
sub( i16, ax );
mov( ax, i16 );
mov( 0, eax ); // Compute i32 := -i32;
sub( i32, eax );
mov( eax, i32 );
// Display the absolute values:
stdout.put
(
nl,
"After negation: i8=", i8,
", i16=", i16,
", i32=", i32,
nl
);
// Demonstrate add and constant-to-memory
// operations:
add( 32323200, i32 );
stdout.put( nl, "After add: i32=", i32, nl );
end DemoMOVaddSUB;
^([6]) 不同的程序可能会使用不同的 30 条指令,但很少有程序会使用超过 30 条不同的指令。
^([7]) 从技术上讲,mov实际上是将数据从一个位置复制到另一个位置。它并不会销毁源操作数中的原始数据。也许这个指令应该叫做copy会更好。可惜现在已经太晚了,无法更改它了。
^([8]) 但请记住,add和sub不支持内存到内存的操作。
1.9 一些基本的 HLA 控制结构
mov、add和sub指令虽然很有用,但它们不足以让你编写有意义的程序。在你能够编写复杂程序之前,你还需要具备做决策和创建循环的能力,HLA 提供了几种高级控制结构,这些控制结构与高级语言中的控制结构非常相似,包括if..then..elseif..else..endif、while..endwhile、repeat..until等。通过学习这些指令,你将能够准备好编写一些真正的程序。
在讨论这些高级控制结构之前,首先需要指出的是,这些并不是实际的 80x86 汇编语言指令。HLA 会将这些指令编译成一系列一个或多个真实的汇编语言指令。在第七章中,你将学习 HLA 如何编译这些指令,并且你将学习如何编写不使用这些指令的纯汇编语言代码。不过,在你学到这些之前,还有很多内容需要学习,因此我们现在将继续使用这些高级语言指令。
另一个需要提到的重要事实是,HLA 的高级控制结构并不像它们最初看起来的那么高级。HLA 高级控制结构的目的是让你尽可能快速地开始编写汇编语言程序,而不是让你完全避免使用汇编语言。你很快会发现,这些语句有一些严重的限制,并且你很快会超出它们的能力。这是故意的。一旦你对 HLA 的高级控制结构有了足够的了解,并且决定你需要比它们提供的更多的功能,那么是时候转向学习这些语句背后的真正 80x86 指令了。
不要让 HLA 中出现的类似高级语言的语句让你感到困惑。许多人在了解这些语句在 HLA 语言中出现后,错误地得出结论,认为 HLA 只是某种特殊的高级语言,而不是一种真正的汇编语言。这不是真的。HLA 是一种完整的低级汇编语言。HLA 支持与其他 80x86 汇编器相同的所有机器指令。不同之处在于,HLA 有一些额外的语句,允许你做一些比其他 80x86 汇编器更多的事情。一旦你使用 HLA 学会了 80x86 汇编语言,你可以选择忽略所有这些额外的(高级)语句,只编写低级的 80x86 汇编语言代码,如果这是你的需求。
以下章节假设你至少熟悉一种高级语言。它们从这个角度介绍 HLA 控制语句,而不去解释如何在程序中实际使用这些语句来完成任务。本文假设的一个前提条件是,你已经知道如何在高级语言中使用这些通用控制语句;你将以相同的方式在 HLA 程序中使用它们。
1.9.1 HLA 语句中的布尔表达式
一些 HLA 语句需要布尔(真或假)表达式来控制它们的执行。例如,if、while 和 repeat..until 语句。对于这些布尔表达式的语法,代表了 HLA 高级控制结构的最大限制。这是你对高级语言的熟悉度会对你不利的一个地方——你会想要使用在高级语言中常用的复杂表达式,而 HLA 只支持一些基本形式。
HLA 布尔表达式的形式如下:^([9])
flag_specification
!flag_specification
register
!register
Boolean_variable
!Boolean_variable
mem_reg relop mem_reg_const
register in LowConst..HiConst
register not in LowConst..HiConst
flag_specification 可能是表 1-2 中描述的符号之一。
表 1-2. flag_specification 符号
| 符号 | 含义 | 解释 |
|---|---|---|
@c |
进位 | 如果进位设置(1),则为真;如果进位清除(0),则为假。 |
@nc |
无进位 | 如果进位清除(0),则为真;如果进位设置(1),则为假。 |
@z |
零 | 如果零标志设置,则为真;如果零标志未设置,则为假。 |
@nz |
非零 | 如果零标志未设置,则为真;如果零标志设置,则为假。 |
@o |
溢出 | 如果溢出标志设置,则为真;如果溢出标志未设置,则为假。 |
@no |
无溢出 | 如果溢出标志未设置,则为真;如果溢出标志设置,则为假。 |
@s |
有符号 | 如果符号标志设置,则为真;如果符号标志未设置,则为假。 |
@ns |
无符号 | 如果符号标志未设置,则为真;如果符号标志设置,则为假。 |
在布尔表达式中使用标志值是相对高级的内容。你将在下一章看到如何使用这些布尔表达式操作数。
寄存器操作数可以是任何 8 位、16 位或 32 位通用寄存器。如果寄存器的值为零,则表达式计算为假;如果寄存器的值为非零,则计算为真。
如果你将布尔变量指定为表达式,程序会测试它是否为零(假)或非零(真)。由于 HLA 使用零和一分别表示假和真,因此该测试直观易懂。注意,HLA 要求这样的变量类型为boolean。HLA 会拒绝其他数据类型。如果你想测试某个其他类型是否为零或非零,则需要使用接下来讨论的一般布尔表达式。
HLA 布尔表达式的最一般形式有两个操作数和一个关系运算符。表 1-3 列出了合法的组合。
表 1-3. 合法布尔表达式
| 左操作数 | 关系运算符 | 右操作数 |
|---|---|---|
| 内存变量或寄存器 | = 或 ==<> 或 !=<<=>>= | 变量、寄存器或常量 |
请注意,两个操作数不能都是内存操作数。实际上,如果你将右操作数视为源操作数,将左操作数视为目标操作数,那么这两个操作数必须与add和sub允许的操作数相同。
同样像add和sub指令一样,两个操作数必须是相同大小的。也就是说,它们必须都是字节操作数,必须都是字操作数,或者必须都是双字操作数。如果右操作数是常量,它的值必须在与左操作数兼容的范围内。
还有一个问题:如果左操作数是一个寄存器,而右操作数是一个正数常量或另一个寄存器,HLA 会使用无符号比较。下一章将讨论这个问题的后果;目前,请不要将寄存器中的负值与常量或另一个寄存器进行比较。你可能得不到直观的结果。
in 和 not in 运算符允许你测试一个寄存器,查看它是否在指定的范围内。例如,表达式 eax in 2000..2099 如果 EAX 寄存器中的值在 2000 到 2099 之间(包含 2000 和 2099),则返回真。not in(两个单词)运算符检查寄存器中的值是否在指定范围之外。例如,al not in 'a'..'z' 如果 AL 寄存器中的字符不是小写字母字符,则返回真。
以下是 HLA 中合法布尔表达式的一些示例:
@c
Bool_var
al
esi
eax < ebx
ebx > 5
i32 < −2
i8 > 128
al < i8
eax in 1..100
ch not in 'a'..'z'
1.9.2 HLA 的 if..then..elseif..else..endif 语句
HLA if 语句使用的语法如 图 1-10 所示。

图 1-10. HLA if 语句语法
出现在 if 语句中的表达式必须符合前一节所述的形式。如果布尔表达式为真,then 之后的代码将执行;否则,控制权将转移到语句中的下一个 elseif 或 else 子句。
因为 elseif 和 else 子句是可选的,所以一个 if 语句可以只包含一个 if..then 子句,后面跟着一系列语句和一个结束的 endif 子句。以下是这样的一个语句:
if( eax = 0 ) then
stdout.put( "error: NULL value", nl );
endif;
如果在程序执行过程中,表达式为真,则 then 和 endif 之间的代码将执行。如果表达式为假,则程序会跳过 then 和 endif 之间的代码。
另一种常见的 if 语句形式只有一个 else 子句。以下是一个带有可选 else 子句的 if 语句示例:
if( eax = 0 ) then
stdout.put( "error: NULL pointer encountered", nl );
else
stdout.put( "Pointer is valid", nl );
endif;
如果表达式为真,则 then 和 else 之间的代码将执行;否则,else 和 endif 之间的代码将执行。
通过将 elseif 子句集成到 if 语句中,你可以创建复杂的决策逻辑。例如,如果 CH 寄存器包含一个字符值,你可以使用如下代码从菜单中选择项目:
if( ch = 'a' ) then
stdout.put( "You selected the 'a' menu item", nl );
elseif( ch = 'b' ) then
stdout.put( "You selected the 'b' menu item", nl );
elseif( ch = 'c' ) then
stdout.put( "You selected the 'c' menu item", nl );
else
stdout.put( "Error: illegal menu item selection", nl );
endif;
尽管这个简单的例子没有展示,但 HLA 并不要求在一系列 elseif 子句的末尾一定要有 else 子句。然而,在做多路决策时,提供一个 else 子句总是一个好主意,以防出现错误。即使你认为 else 子句不可能执行,也要记住,未来对代码的修改可能会使这个假设失效,因此在代码中包含错误报告语句是个好习惯。
1.9.3 布尔表达式中的合取、析取与否定
在前面各节的运算符列表中,有一些明显遗漏的运算符:合取(逻辑 and)、析取(逻辑 or)和否定(逻辑 not)。本节描述它们在布尔表达式中的使用(讨论必须等到描述完 if 语句之后,才能展示实际的例子)。
HLA 使用&&运算符来表示运行时布尔表达式中的逻辑与。这是一个二元(两个操作数)运算符,两个操作数必须是合法的运行时布尔表达式。如果两个操作数都为真,则此运算符的结果为真。例如:
if( eax > 0 && ch = 'a' ) then
mov( eax, ebx );
mov( ' ', ch );
endif;
上面两个mov语句仅在 EAX 大于零且CH 等于字符a时执行。如果这两个条件中的任何一个为假,程序执行将跳过这些mov指令。
请注意,&&运算符两侧的表达式可以是任何合法的布尔表达式;这些表达式不一定要使用关系运算符进行比较。例如,以下所有的表达式都是合法的:
@z && al in 5..10
al in 'a'..'z' && ebx
boolVar && !eax
HLA 在编译&&运算符时使用短路求值。如果最左边的操作数为假,则 HLA 生成的代码不会再评估第二个操作数(因为此时整个表达式必定为假)。因此,在上面的最后一个表达式中,如果boolVar为假,则代码不会检查 EAX 是否为零。
请注意,像eax < 10 && ebx <> eax这样的表达式本身就是一个合法的布尔表达式,因此可以作为&&运算符的左或右操作数。因此,以下类似的表达式是完全合法的:
eax < 0 && ebx <> eax && !ecx
&&运算符是左结合的,因此 HLA 生成的代码会从左到右评估上述表达式。如果 EAX 小于零,CPU 将不会测试剩余的表达式。类似地,如果 EAX 不小于零但 EBX 等于 EAX,这段代码将不会评估第三个表达式,因为无论 ECX 的值如何,整个表达式都为假。
HLA 使用||运算符来表示析取(逻辑或)在运行时布尔表达式中的应用。和&&运算符一样,这个运算符也期望两个合法的运行时布尔表达式作为操作数。如果任一(或两个)操作数为真,则此运算符的结果为真。与&&运算符一样,析取运算符也使用短路求值。如果左侧操作数为真,则 HLA 生成的代码不会再测试第二个操作数的值。相反,代码将跳转到处理布尔表达式为真时的相应位置。以下是一些使用||运算符的合法表达式示例:
@z || al = 10
al in 'a'..'z' || ebx
!boolVar || eax
和&&运算符一样,析取运算符是左结合的,因此在同一个表达式中可以出现多个||运算符。如果出现这种情况,HLA 生成的代码将从左到右计算表达式。例如:
eax < 0 || ebx <> eax || !ecx
上述代码在 EAX 小于零、EBX 不等于 EAX 或 ECX 为零时评估为真。注意,如果第一个比较为真,代码不会检查其他条件。同样,如果第一个比较为假且第二个为真,代码不会再检查 ECX 是否为零。仅当前两个比较为假时,才会检查 ECX 是否等于零。
如果合取运算符和析取运算符出现在同一表达式中,则 && 运算符优先于 || 运算符。考虑以下表达式:
eax < 0 || ebx <> eax && !ecx
HLA 生成的机器代码将此表达式评估为:
eax < 0 || (ebx <> eax && !ecx)
如果 EAX 小于零,则 HLA 生成的代码不会检查表达式的其余部分,整个表达式会被判定为真。但是,如果 EAX 不小于零,则以下两个条件必须都为真,整个表达式才会为真。
如果需要调整运算符的优先级,HLA 允许你使用括号来围绕涉及 && 和 || 的子表达式。考虑以下表达式:
(eax < 0 || ebx <> eax) && !ecx
要使此表达式评估为真,ECX 必须为零,并且 EAX 必须小于零或 EBX 必须不等于 EAX。与没有括号时该表达式的结果相比,可以看到区别。
HLA 使用 ! 运算符表示逻辑否定。然而,! 运算符只能作为寄存器或布尔变量的前缀;不能将其用作更大表达式的一部分(例如,!eax < 0)。要对现有的布尔表达式进行逻辑否定,必须将该表达式用括号括起来,并将 ! 运算符放在括号前。例如:
!( eax < 0 )
如果 EAX 不小于零,则此表达式为真。
逻辑运算符not主要用于围绕涉及合取和析取运算符的复杂表达式。尽管它偶尔对像上面那样的简短表达式有用,但通常直接表达逻辑会更简单(且更具可读性),而不是用逻辑not运算符将其复杂化。
注意,HLA 还提供了 | 和 & 运算符,但它们与 || 和 && 不同,含义完全不同。有关这些(编译时)运算符的更多细节,请参阅 HLA 参考手册。
1.9.4 while..endwhile 语句
while 语句使用的基本语法如图 1-11 所示。

图 1-11. HLA while 语句语法
该语句计算布尔表达式。如果为假,控制会立即转移到endwhile子句后面的第一条语句。如果表达式的值为真,则 CPU 执行循环体。循环体执行后,控制返回到循环顶部,此时while语句会重新测试循环控制表达式。这个过程会一直重复,直到表达式计算为假。
请注意,while循环与其高级语言对应物一样,在循环顶部测试是否终止。因此,循环体中的语句有可能不会执行(如果在代码首次执行while语句时,表达式为假)。还请注意,while循环体中的语句必须在某个时刻修改布尔表达式的值,否则将会导致无限循环。
这是一个 HLA 的while循环示例:
mov( 0, i );
while( i < 10 ) do
stdout.put( "i=", i, nl );
add( 1, i );
endwhile;
1.9.5 for..endfor 语句
HLA 的for循环具有以下通用形式:
for( *`Initial_Stmt`*; *`Termination_Expression`*; *`Post_Body_Statement`* ) do
<< Loop body >>
endfor;
这等同于以下while语句:
*`Initial_Stmt`*;
while( *`Termination_Expression`* ) do
<< Loop body >>
*`Post_Body_Statement`*;
endwhile;
Initial_Stmt可以是任何单个 HLA/80x86 指令。通常该语句会将寄存器或内存位置(循环计数器)初始化为零或其他初始值。Termination_Expression是一个 HLA 布尔表达式(与while允许的格式相同)。该表达式决定循环体是否执行。Post_Body_Statement在循环底部执行(如上所示的while示例)。这是一条单一的 HLA 语句,通常像add这样的指令会修改循环控制变量的值。
以下是一个完整的示例:
for( mov( 0, i ); i < 10; add(1, i )) do
stdout.put( "i=", i, nl );
endfor;
上述内容重写为while循环后变为:
mov( 0, i );
while( i < 10 ) do
stdout.put( "i=", i, nl );
add( 1, i );
endwhile;
1.9.6 repeat..until 语句
HLA 的repeat..until语句使用图 1-12 所示的语法。C/C++/C#和 Java 用户应注意,repeat..until语句与do..while语句非常相似。

图 1-12. HLA repeat..until语句语法
HLA 的repeat..until语句在循环底部测试循环是否终止。因此,循环体中的语句总是至少执行一次。当遇到until子句时,程序将评估表达式,并在表达式为假时重复循环(即,当假时重复)。如果表达式计算为真,控制会转移到until子句后面的第一条语句。
以下简单示例演示了repeat..until语句:
mov( 10, ecx );
repeat
stdout.put( "ecx = ", ecx, nl );
sub( 1, ecx );
until( ecx = 0 );
如果循环体总是至少执行一次,那么通常使用repeat..until循环比使用while循环更高效。
1.9.7 break 和 breakif 语句
break 和 breakif 语句提供了提前退出循环的功能。图 1-13 显示了这两个语句的语法。

图 1-13. HLA break 和 breakif 语法
break 语句退出直接包含 break 的循环。breakif 语句评估布尔表达式,并在表达式为真时退出包含的循环。
注意,break 和 breakif 语句不允许你跳出多个嵌套循环。HLA 提供了其他语句来实现这一功能,包括 begin..end 块和 exit/exitif 语句。请参阅 HLA 参考手册了解更多细节。HLA 还提供了 continue/continueif 配对语句,允许你重复循环体。同样,请参考 HLA 参考手册以获取更多信息。
1.9.8 forever..endfor 语句
图 1-14 显示了 forever 语句的语法。

图 1-14. HLA forever 循环语法
该语句创建一个无限循环。你还可以将 break 和 breakif 语句与 forever..endfor 一起使用,在循环中间进行循环终止条件的测试。实际上,这可能是此循环最常见的使用方式,以下示例演示了这一点:
forever
stdout.put( "Enter an integer less than 10: ");
stdin.get( i );
breakif( i < 10 );
stdout.put( "The value needs to be less than 10!", nl );
endfor;
1.9.9 try..exception..endtry 语句
HLA try..exception..endtry 语句提供了非常强大的异常处理功能。此语句的语法如图 1-15 所示。

图 1-15. HLA try..exception..endtry 语句语法
try..endtry语句在执行过程中保护一组语句。如果try子句和第一个exception子句之间的语句(即保护块)顺利执行,控制会立即转移到endtry之后的第一条语句。如果发生错误(异常),程序会在异常发生的地方中断控制(即程序会抛出一个异常)。每个异常都有一个与之关联的无符号整数常量,称为异常 ID。HLA 标准库中的excepts.hhf头文件预定义了几个异常 ID,尽管你可以根据需要创建新的异常 ID。当异常发生时,系统会将异常 ID 与保护代码之后每个异常子句中的值进行比较。如果当前的异常 ID 与某个异常值匹配,控制会继续执行该异常后面紧接着的语句块。在异常处理代码执行完毕后,控制会转移到endtry之后的第一条语句。
如果发生异常且没有活动的try..endtry语句,或者活动的try..endtry语句无法处理特定的异常,程序将以错误消息终止。
以下代码片段演示了如何使用try..endtry语句来保护程序免受错误用户输入的影响:
repeat
mov( false, GoodInteger ); // Note: GoodInteger must be a boolean var.
try
stdout.put( "Enter an integer: " );
stdin.get( i );
mov( true, GoodInteger );
exception( ex.ConversionError );
stdout.put( "Illegal numeric value, please re-enter", nl );
exception( ex.ValueOutOfRange );
stdout.put( "Value is out of range, please re-enter", nl );
endtry;
until( GoodInteger );
repeat..until循环会在输入过程中出现错误时重复执行该代码。如果由于错误输入而发生异常,控制将转移到异常处理部分,查看是否发生了转换错误(例如,数字中的非法字符)或数字溢出。如果发生这些异常,则会打印相应的消息,控制跳出try..endtry语句,repeat..until循环将会重复,因为代码没有将GoodInteger设置为 true。如果发生了其他异常(代码中未处理的异常),程序将以指定的错误消息终止。^([10])
表 1-4 列出了在编写本文时,excepts.hhf头文件中提供的异常。请参见随 HLA 提供的excepts.hhf头文件,以获取最新的异常列表。
表 1-4. 在excepts.hhf中提供的异常
| 异常 | 描述 |
|---|---|
ex.StringOverflow |
尝试将一个过大的字符串存入字符串变量。 |
ex.StringIndexError |
尝试访问字符串中不存在的字符。 |
ex.StringOverlap |
尝试将一个字符串复制到自身。 |
ex.StringMetaData |
损坏的字符串值。 |
ex.StringAlignment |
尝试将字符串存储在未对齐的地址上。 |
ex.StringUnderflow |
尝试从字符串中提取“负”字符。 |
ex.IllegalStringOperation |
字符串数据上不允许进行该操作。 |
ex.ValueOutOfRange |
值对于当前操作来说过大。 |
ex.IllegalChar |
操作遇到的字符代码,其 ASCII 码不在 0..127 的范围内。 |
ex.TooManyCmdLnParms |
命令行包含过多的程序参数。 |
ex.BadObjPtr |
类对象的指针无效。 |
ex.InvalidAlignment |
参数没有在正确的内存地址对齐。 |
ex.InvalidArgument |
函数调用(通常是操作系统 API 调用)包含无效的参数值。 |
ex.BufferOverflow |
缓冲区或 blob 对象超出了声明的大小。 |
ex.BufferUnderflow |
尝试从 blob 或缓冲区中检索不存在的数据。 |
ex.IllegalSize |
参数的数据大小不正确。 |
ex.ConversionError |
字符串转数字时包含非法(非数字)字符。 |
ex.BadFileHandle |
程序尝试使用无效的文件句柄值访问文件。 |
ex.FileNotFound |
程序尝试访问一个不存在的文件。 |
ex.FileOpenFailure |
操作系统无法打开文件(文件未找到)。 |
ex.FileCloseError |
操作系统无法关闭文件。 |
ex.FileWriteError |
写入数据到文件时发生错误。 |
ex.FileReadError |
从文件读取数据时发生错误。 |
ex.FileSeekError |
尝试定位到文件中不存在的位置。 |
ex.DiskFullError |
尝试写入数据到已满的磁盘。 |
ex.AccessDenied |
用户没有足够的权限访问文件数据。 |
ex.EndOfFile |
程序尝试读取文件末尾之后的数据。 |
ex.CannotCreateDir |
尝试创建目录失败。 |
ex.CannotRemoveDir |
尝试删除目录失败。 |
ex.CannotRemoveFile |
尝试删除文件失败。 |
ex.CDFailed |
尝试切换到新目录失败。 |
ex.CannotRenameFile |
尝试重命名文件失败。 |
ex.MemoryAllocationFailure |
系统内存不足,无法满足分配请求。 |
ex.MemoryFreeFailure |
无法释放指定的内存块(内存管理系统损坏)。 |
ex.MemoryAllocationCorruption |
内存管理系统损坏。 |
ex.AttemptToFreeNULL |
调用者尝试释放一个 NULL 指针。 |
ex.AttemptToDerefNULL |
程序尝试通过 NULL 指针间接访问数据。 |
ex.BlockAlreadyFree |
调用者尝试释放一个已经被释放的内存块。 |
ex.CannotFreeMemory |
释放内存操作失败。 |
ex.PointerNotInHeap |
调用者尝试释放一个未在堆上分配的内存块。 |
ex.WidthTooBig |
数字转字符串时格式宽度过大。 |
ex.FractionTooBig |
浮点转字符串时,格式中小数部分的大小过大。 |
ex.ArrayShapeViolation |
尝试在两个维度不匹配的数组上执行操作。 |
ex.ArrayBounds |
尝试访问数组元素,但索引超出了范围。 |
ex.InvalidDate |
尝试对非法日期进行日期操作。 |
ex.InvalidDateFormat |
从字符串到日期的转换包含非法字符。 |
ex.TimeOverflow |
时间算术运算中的溢出。 |
ex.InvalidTime |
尝试对非法时间进行时间操作。 |
ex.InvalidTimeFormat |
从字符串到时间的转换包含非法字符。 |
ex.SocketError |
网络通信失败。 |
ex.ThreadError |
通用线程(多任务)错误。 |
ex.AssertionFailed |
assert语句遇到失败的断言。 |
ex.ExecutedAbstract |
尝试执行抽象类方法。 |
ex.AccessViolation |
尝试访问非法内存位置。 |
ex.InPageError |
操作系统内存访问错误。 |
ex.NoMemory |
操作系统内存失败。 |
ex.InvalidHandle |
向操作系统 API 调用传递了错误的句柄。 |
ex.ControlC |
在系统控制台上按下了ctrl-C(此功能取决于操作系统)。 |
ex.Breakpoint |
程序执行了断点指令(INT 3)。 |
ex.SingleStep |
程序在追踪标志设置的情况下运行。 |
ex.PrivInstr |
程序尝试执行仅限内核的指令。 |
ex.IllegalInstr |
程序尝试执行非法机器指令。 |
ex.BoundInstr |
使用“越界”值执行了界限指令。 |
ex.IntoInstr |
在溢出标志设置的情况下执行 Into 指令。 |
ex.DivideError |
程序尝试除以零或其他除法错误。 |
ex.fDenormal |
浮点异常(请参阅第六章)。 |
ex.fDivByZero |
浮点异常(请参阅第六章)。 |
ex.fInexactResult |
浮点异常(请参阅第六章)。 |
ex.fInvalidOperation |
浮点异常(请参阅第六章)。 |
ex.fOverflow |
浮点异常(请参阅第六章)。 |
ex.fStackCheck |
浮点异常(请参阅第六章)。 |
ex.fUnderflow |
浮点异常(请参阅第六章)。 |
ex.InvalidHandle |
操作系统报告某个操作的无效句柄。 |
这些异常大多数发生在超出本章范围的情况中。它们出现在这里是为了完整性。有关这些异常的更多细节,请参阅 HLA 参考手册、HLA 标准库文档以及 HLA 标准库源代码。ex.ConversionError、ex.ValueOutOfRange和ex.StringOverflow是您最常使用的异常。
我们将在 1.11 关于 try..endtry 的更多细节中重新讨论try..endtry语句。首先,然而我们需要覆盖更多的内容。
^([9]) 还有一些额外的形式我们将在第六章中讨论。
^([10]) 一位经验丰富的程序员可能会想知道,为什么这段代码使用布尔变量,而不是breakif语句来退出repeat..until循环。背后有一些技术原因,你将在 1.11 关于 try..endtry 的更多细节中了解到这些原因。
1.10 HLA 标准库简介
HLA 比标准汇编语言更容易学习和使用的原因有两个。第一个原因是 HLA 为声明和控制结构提供了高级语法。这利用了你对高级语言的知识,使你能更高效地学习汇编语言。方程的另一半是 HLA 标准库。HLA 标准库提供了许多常见的、易于使用的汇编语言例程,你可以调用这些例程,而不需要自己编写这些代码(更重要的是,不需要学习如何编写这些代码)。这消除了很多人在学习汇编语言时遇到的一个大障碍:编写基本语句所需的复杂 I/O 和支持代码。在没有标准化的汇编语言库之前,新手汇编程序员往往需要相当长的学习时间,才能做到打印一个字符串到显示器。而有了 HLA 标准库,这一障碍就被移除了,你可以专注于学习汇编语言的概念,而不是学习特定操作系统的底层 I/O 细节。
丰富的库例程只是 HLA 支持的一部分。毕竟,汇编语言库已经存在相当长的时间了。^([11]) HLA 的标准库通过为这些例程提供一个高级语言接口来补充 HLA。实际上,HLA 语言本身最初就是专门设计用来创建一套高级的库例程的。这个高级接口,加上库中许多例程的高级特性,带来了令人惊讶的强大功能,并且易于使用。
HLA 标准库由几个按类别组织的模块组成。表 1-5 列出了许多可用的模块。^([12])
表 1-5. HLA 标准库模块
| 名称 | 描述 |
|---|---|
args |
命令行参数解析支持例程。 |
arrays |
数组声明和操作。 |
bits |
位操作函数。 |
blobs |
二进制大对象——对大块二进制数据的操作。 |
bsd |
FreeBSD 的操作系统 API 调用(仅限 HLA FreeBSD 版本)。 |
chars |
对字符数据的操作。 |
console |
可移植的控制台(文本屏幕)操作(光标移动、屏幕清除等)。 |
conv |
字符串与其他值之间的各种转换。 |
coroutines |
支持协程(“协作式多任务”)。 |
cset |
字符集函数。 |
DateTime |
日历、日期和时间功能。 |
env |
访问操作系统环境变量。 |
excepts |
异常处理例程。 |
fileclass |
面向对象的文件输入输出。 |
fileio |
文件输入和输出例程。 |
filesys |
访问操作系统文件系统。 |
hla |
特殊的 HLA 常量和其他值。 |
Linux |
Linux 系统调用(仅限 HLA Linux 版本)。 |
lists |
HLA 类,用于操作链表。 |
mac |
Mac OS X 的操作系统 API 调用(仅限 HLA Mac OS X 版本)。 |
math |
扩展精度算术、超越函数和其他数学函数。 |
memmap |
内存映射文件操作。 |
memory |
内存分配、释放和支持代码。 |
patterns |
HLA 模式匹配库。 |
random |
伪随机数生成器和支持代码。 |
sockets |
一组网络通信函数和类。 |
stderr |
提供用户输出和其他几个支持函数。 |
stdin |
用户输入例程。 |
stdio |
支持 stderr、stdin 和 stdout 的模块。 |
stdout |
提供用户输出和其他几个支持例程。 |
strings |
HLA 强大的字符串库。 |
tables |
表(关联数组)支持例程。 |
threads |
支持多线程应用程序和进程同步。 |
timers |
支持应用程序中的定时事件。 |
win32 |
用于 Windows 调用的常量(仅限 HLA Windows 版本)。 |
x86 |
特定于 80x86 CPU 的常量和其他项。 |
后续部分将更详细地解释这些模块。本节将集中讨论最重要的例程(至少对于初学者 HLA 程序员来说),即stdio库。
1.10.1 stdio 模块中的预定义常量
也许首先要介绍的是stdio模块为你定义的一些常见常量。请考虑以下(典型)示例:
stdout.put( "Hello World", nl );
nl出现在此语句的末尾,代表换行。nl标识符不是 HLA 的保留关键字,也与stdout.put语句无关。相反,它只是一个预定义常量,对应包含标准换行符序列的字符串(在 Windows 下是回车/换行对,在 Linux、FreeBSD 和 Mac OS X 下只是换行符)。
除了nl常量外,HLA 标准 I/O 库模块还定义了几个其他有用的字符常量,具体见表 1-6。
表 1-6. HLA 标准 I/O 库定义的字符常量
| 字符 | 定义 |
|---|---|
stdio.bell |
ASCII 铃声字符;打印时会发出蜂鸣声 |
stdio.bs |
ASCII 退格符字符 |
stdio.tab |
ASCII 制表符字符 |
stdio.lf |
ASCII 换行符字符 |
stdio.cr |
ASCII 回车符字符 |
除了nl外,这些字符出现在stdio命名空间中^([13])(因此需要使用stdio前缀)。将这些 ASCII 常量放置在stdio命名空间内有助于避免与您自己变量的命名冲突。nl名称不出现在命名空间中,因为您会非常频繁地使用它,键入stdio.nl会很快变得麻烦。
1.10.2 标准输入和标准输出
许多 HLA I/O 例程有一个stdin或stdout前缀。从技术上讲,这意味着标准库在一个命名空间中定义了这些名称。实际上,这个前缀提示了输入来源(标准输入设备)或输出目标(标准输出设备)。默认情况下,标准输入设备是系统键盘。同样,默认标准输出设备是控制台显示。因此,一般来说,带有stdin或stdout前缀的语句将在控制台设备上读写数据。
当你从命令行窗口(或 shell)运行程序时,可以选择重定向标准输入和/或标准输出设备。形式为>outfile的命令行参数将标准输出设备重定向到指定的文件(outfile)。形式为<infile的命令行参数将标准输入重定向,使得输入数据来自指定的输入文件(infile)。以下示例展示了如何在命令窗口运行名为testpgm的程序时使用这些参数:^([14])
testpgm <input.data
testpgm >output.txt
testpgm <in.txt >output.txt
1.10.3 stdout.newln例程
stdout.newln过程将换行符序列输出到标准输出设备。这在功能上等同于执行stdout.put( nl );。调用stdout.newln有时更加方便。例如:
stdout.newln();
1.10.4 stdout.putiX例程
stdout.puti8、stdout.puti16和stdout.puti32库例程将一个单一参数(分别为一个字节、两个字节或四个字节)作为有符号整数值输出。该参数可以是常量、寄存器或内存变量,只要实际参数的大小与形式参数的大小相同。
这些例程将其指定参数的值打印到标准输出设备。这些例程将使用尽可能少的打印位置打印该值。如果数字是负数,这些例程将打印一个前导负号。以下是调用这些例程的一些示例:
stdout.puti8( 123 );
stdout.puti16( dx );
stdout.puti32( i32Var );
1.10.5 stdout.putiXSize例程
stdout.puti8Size、stdout.puti16Size和stdout.puti32Size例程将有符号整数值输出到标准输出,类似于stdout.putiX例程。不同的是,这些例程提供了更多的输出控制;它们允许您指定输出时值所需的(最小)打印位置数。这些例程还允许您指定填充字符,如果打印字段大于显示值所需的最小空间时使用。调用这些例程需要以下参数:
stdout.puti8Size( *`Value8`*, *`width`*, *`padchar`* );
stdout.puti16Size( *`Value16`*, *`width`*, *`padchar`* );
stdout.puti32Size( *`Value32`*, *`width`*, *`padchar`* );
Value*参数可以是常量、寄存器或指定大小的内存位置。width参数可以是一个在−256 和+256 之间的有符号整数常量;该参数可以是常量、寄存器(32 位)或内存位置(32 位)。padchar参数应为单字符值。
与stdout.putiX例程类似,这些例程将指定的值作为有符号整数常量打印到标准输出设备。不同的是,这些例程允许您指定值的字段宽度。字段宽度是这些例程在打印值时将使用的最小打印位置数。width参数指定最小字段宽度。如果数字需要更多的打印位置(例如,如果您尝试以 2 的字段宽度打印1234),则这些例程将打印足够的字符以正确显示该值。另一方面,如果width参数大于显示该值所需的字符位置数,则这些例程将打印一些额外的填充字符,以确保输出至少有width个字符位置。如果width值为负,则数字在打印字段中左对齐;如果width值为正,则数字在打印字段中右对齐。
如果width参数的绝对值大于最小打印位置数,那么这些stdout.putiXSize例程将在数字前后打印填充字符。padchar参数指定这些例程将打印的字符。大多数情况下,您会将空格指定为填充字符;对于特殊情况,您可能会指定其他字符。请记住,padchar参数是一个字符值;在 HLA 中,字符常量用撇号括起来,而不是引号。您还可以指定一个 8 位寄存器作为该参数。
示例 1-4 提供了一个简短的 HLA 程序,演示了如何使用stdout.puti32Size例程以表格形式显示一系列值。
示例 1-4. 使用 stdio.Puti32Size 的表格输出演示
program NumsInColumns;
#include( "stdlib.hhf" )
var
i32: int32;
ColCnt: int8;
begin NumsInColumns;
mov( 96, i32 );
mov( 0, ColCnt );
while( i32 > 0 ) do
if( ColCnt = 8 ) then
stdout.newln();
mov( 0, ColCnt );
endif;
stdout.puti32Size( i32, 5, ' ' );
sub( 1, i32 );
add( 1, ColCnt );
endwhile;
stdout.newln();
end NumsInColumns;
1.10.6 stdout.put 例程
stdout.put例程^([15])是标准输出库模块中最灵活的输出例程之一。它将大多数其他输出例程合并为一个易于使用的过程。
stdout.put例程的通用格式如下:
stdout.put( *`list_of_values_to_output`* );
stdout.put参数列表由一个或多个常量、寄存器或内存变量组成,每个项之间用逗号分隔。该例程显示与每个参数相关联的值。由于我们在本章中已经多次使用该例程,因此你已经看到了许多该例程基本形式的示例。值得指出的是,该例程有一些在本章示例中未体现的附加功能。特别是,每个参数可以采用以下两种形式之一:
*`value`*
*`value`*:*`width`*
value可以是任何合法的常量、寄存器或内存变量对象。在本章中,你已看到字符串常量和内存变量出现在stdout.put的参数列表中。这些参数对应于上面提到的第一种形式。上述第二种参数形式允许你指定最小字段宽度,类似于stdout.putiXSize例程。^([16]) 示例 1-5 的程序产生与示例 1-4 相同的输出;然而,示例 1-5 使用的是stdout.put而非stdout.puti32Size。
示例 1-5. stdout.put 字段宽度指定的演示
program NumsInColumns2;
#include( "stdlib.hhf" )
var
i32: int32;
ColCnt: int8;
begin NumsInColumns2;
mov( 96, i32 );
mov( 0, ColCnt );
while( i32 > 0 ) do
if( ColCnt = 8 ) then
stdout.newln();
mov( 0, ColCnt );
endif;
stdout.put( i32:5 );
sub( 1, i32 );
add( 1, ColCnt );
endwhile;
stdout.put( nl );
end NumsInColumns2;
stdout.put例程的功能远不止本节描述的这些属性。本文将根据需要介绍这些附加功能。
1.10.7 stdin.getc 例程
stdin.getc例程从标准输入设备的输入缓冲区读取下一个可用字符。^([17]) 它将该字符返回到 CPU 的 AL 寄存器中。示例 1-6 例程演示")中的程序演示了该例程的一个简单用法。
示例 1-6. stdin.getc() 例程演示
program charInput;
#include( "stdlib.hhf" )
var
counter: int32;
begin charInput;
// The following repeats as long as the user
// confirms the repetition.
repeat
// Print out 14 values.
mov( 14, counter );
while( counter > 0 ) do
stdout.put( counter:3 );
sub( 1, counter );
endwhile;
// Wait until the user enters 'y' or 'n'.
stdout.put( nl, nl, "Do you wish to see it again? (y/n):" );
forever
stdin.readLn();
stdin.getc();
breakif( al = 'n' );
breakif( al = 'y' );
stdout.put( "Error, please enter only 'y' or 'n': " );
endfor;
stdout.newln();
until( al = 'n' );
end charInput;
该程序使用stdin.ReadLn例程强制从用户输入一个新的行。关于stdin.ReadLn的描述见 1.10.9 stdin.readLn和stdin.flushInput例程。
1.10.8 stdin.getiX 例程
stdin.geti8、stdin.geti16和stdin.geti32例程分别从标准输入设备读取 8 位、16 位和 32 位有符号整数值。这些例程将它们的值返回到 AL、AX 或 EAX 寄存器中。它们提供了在 HLA 中从用户读取有符号整数值的标准机制。
像stdin.getc例程一样,这些例程从标准输入缓冲区读取一串字符。它们首先跳过任何空白字符(空格、制表符等),然后将接下来的十进制数字流(可选的前导负号)转换为相应的整数。如果输入序列不是有效的整数字符串,或者用户输入的值太大,无法适应指定的整数大小,这些例程会引发一个异常(你可以通过try..endtry语句来捕获该异常)。请注意,stdin.geti8读取的值必须在−128 到+127 之间;stdin.geti16读取的值必须在−32,768 到+32,767 之间;stdin.geti32读取的值必须在−2,147,483,648 到+2,147,483,647 之间。
在示例 1-7 中的示例程序演示了这些例程的使用。
示例 1-7. stdin.getiX 示例代码
program intInput;
#include( "stdlib.hhf" )
var
i8: int8;
i16: int16;
i32: int32;
begin intInput;
// Read integers of varying sizes from the user:
stdout.put( "Enter a small integer between −128 and +127: " );
stdin.geti8();
mov( al, i8 );
stdout.put( "Enter a small integer between −32768 and +32767: " );
stdin.geti16();
mov( ax, i16 );
stdout.put( "Enter an integer between +/− 2 billion: " );
stdin.geti32();
mov( eax, i32 );
// Display the input values.
stdout.put
(
nl,
"Here are the numbers you entered:", nl, nl,
"Eight-bit integer: ", i8:12, nl,
"16-bit integer: ", i16:12, nl,
"32-bit integer: ", i32:12, nl
);
end intInput;
你应该编译并运行这个程序,然后测试当你输入超出范围的值或输入非法字符时会发生什么。
1.10.9 stdin.readLn和stdin.flushInput例程
每当你调用像stdin.getc或stdin.geti32这样的输入例程时,程序不一定会在那个时刻从用户那里读取值。相反,HLA 标准库会通过从用户那里读取整行文本来缓冲输入。调用输入例程时,会从这个输入缓冲区中获取数据,直到缓冲区为空。虽然这种缓冲机制高效且方便,但有时也可能会让人感到困惑。考虑以下代码示例:
stdout.put( "Enter a small integer between −128 and +127: " );
stdin.geti8();
mov( al, i8 );
stdout.put( "Enter a small integer between −32768 and +32767: " );
stdin.geti16();
mov( ax, i16 );
直观上,你会期望程序打印出第一个提示信息,等待用户输入,然后打印第二个提示信息,等待第二次用户输入。然而,实际情况并非如此。例如,如果你运行这段代码(来自上一节的示例程序),并在第一个提示符处输入文本123 456,程序不会在第二个提示符处等待额外的用户输入。相反,它会在执行stdin.geti16调用时,从输入缓冲区读取第二个整数(456)。
通常,stdin例程仅在输入缓冲区为空时从用户读取文本。只要输入缓冲区包含额外的字符,输入例程将尝试从缓冲区中读取数据。你可以通过编写以下代码序列来利用这种行为:
stdout.put( "Enter two integer values: " );
stdin.geti32();
mov( eax, intval );
stdin.geti32();
mov( eax, AnotherIntVal );
该序列允许用户在同一行输入两个值(由一个或多个空白字符分隔),从而节省屏幕空间。因此,输入缓冲区的行为有时是可取的。输入例程的缓冲行为在其他时候可能是反直觉的。
幸运的是,HLA 标准库提供了两个例程,stdin.readLn和stdin.flushInput,它们允许你控制标准输入缓冲区。stdin.readLn例程丢弃输入缓冲区中的所有内容,并立即要求用户输入新的一行文本。stdin.flushInput例程只是丢弃缓冲区中的所有内容。下一次输入例程执行时,系统将要求用户输入新的一行数据。你通常会在某个标准输入例程之前立即调用stdin.readLn;在调用标准输入例程之后,通常会立即调用stdin.flushInput。
注意
如果你正在调用stdin.readLn,并且发现你需要输入两次数据,这通常表明你应该调用stdin.flushInput而不是stdin.readLn。通常,你应该始终能够调用stdin.flushInput来刷新输入缓冲区,并在下一次输入调用时读取新的一行数据。stdin.readLn例程很少需要使用,因此除非你真的需要立即强制输入新的一行文本,否则应该使用stdin.flushInput。
1.10.10 stdin.get例程
stdin.get例程将许多标准输入例程合并为一个调用,就像stdout.put将所有输出例程合并为一个调用一样。实际上,stdin.get比stdout.put更易于使用,因为该例程的唯一参数是变量名列表。
让我们重写上一节给出的示例:
stdout.put( "Enter two integer values: " );
stdin.geti32();
mov( eax, intval );
stdin.geti32();
mov( eax, AnotherIntVal );
使用stdin.get例程,我们可以将此代码重写为:
stdout.put( "Enter two integer values: " );
stdin.get( intval, AnotherIntVal );
如你所见,stdin.get例程更方便使用。
请注意,stdin.get将输入值直接存储到你在参数列表中指定的内存变量中;除非你明确指定了寄存器作为参数,否则它不会将值返回到寄存器中。stdin.get的所有参数必须是变量或寄存器。
^([11]) 例如,请参阅针对 80x86 汇编语言程序员的 UCR 标准库。
^([12]) 由于 HLA 标准库正在扩展,这个列表可能已经过时。请参阅 HLA 文档以获取当前的标准库模块列表。
^([13]) 命名空间是第五章的主题。
^([14]) 对于 Linux、FreeBSD 和 Mac OS X 用户,根据系统的配置,可能需要在程序名称前加上 ./ 才能执行程序(例如,./testpgm <input.data>)。
^([15]) stdout.put 实际上是一个宏,而不是一个过程。两者之间的区别超出了本章的范围。第九章 描述了它们的区别。
^([16]) 请注意,使用 stdout.put 例程时无法指定填充字符;填充字符默认为空格字符。如果需要使用不同的填充字符,可以调用 stdout.putiXSize 例程。
^([17]) 缓冲区 只是一个表示数组的 fancy 术语。
1.11 关于 try..endtry 的附加细节
如你所记得,try..endtry 语句将一组语句包围起来,以捕捉在执行这些语句过程中发生的任何异常。系统通过以下三种方式之一引发异常:通过硬件故障(如除零错误)、通过操作系统生成的异常,或通过执行 HLA raise 语句。你可以编写异常处理程序,使用 exception 子句来拦截特定的异常。示例 1-8 中的程序提供了使用此语句的典型示例。
示例 1-8. try..endtry 示例
program testBadInput;
#include( "stdlib.hhf" )
static
u: int32;
begin testBadInput;
try
stdout.put( "Enter a signed integer:" );
stdin.get( u );
stdout.put( "You entered: ", u, nl );
exception( ex.ConversionError )
stdout.put( "Your input contained illegal characters" nl );
exception( ex.ValueOutOfRange )
stdout.put( "The value was too large" nl );
endtry;
end testBadInput;
HLA 将 try 子句与第一个 exception 子句之间的语句称为 保护 语句。如果在保护语句内发生异常,程序将扫描每个异常并将当前异常的值与每个 exception 子句后括号中的值进行比较。^([18]) 这个异常值只是一个 32 位值。因此,每个 exception 子句后括号中的值必须是一个 32 位值。HLA excepts.hhf 头文件预定义了几个异常常量。尽管这将是一个极其严重的风格违规,但你仍然可以用数字值来替代上述两个 exception 子句中的值。
1.11.1 嵌套 try..endtry 语句
如果程序扫描完 try..endtry 语句中的所有 exception 子句并且没有找到与当前异常值匹配的子句,则程序会在 动态嵌套 的 try..endtry 块的 exception 子句中继续查找,以尝试找到合适的异常处理程序。例如,考虑 示例 1-9 中的代码。
示例 1-9. 嵌套 try..endtry 语句
program testBadInput2;
#include( "stdlib.hhf" )
static
u: int32;
begin testBadInput2;
try
try
stdout.put( "Enter a signed integer: " );
stdin.get( u );
stdout.put( "You entered: ", u, nl );
exception( ex.ConversionError )
stdout.put( "Your input contained illegal characters" nl );
endtry;
stdout.put( "Input did not fail due to a value out of range" nl );
exception( ex.ValueOutOfRange )
stdout.put( "The value was too large" nl );
endtry;
end testBadInput2;
在示例 1-9 中,一个try语句嵌套在另一个try语句内。在执行stdin.get语句时,如果用户输入一个大于四十亿并且有些变化的值,则stdin.get会引发ex.ValueOutOfRange异常。当 HLA 运行时系统接收到此异常时,它首先会在引发异常的语句周围的try..endtry语句中的所有异常子句中进行搜索(在上面的示例中就是嵌套的try..endtry)。如果 HLA 运行时系统未能找到ex.ValueOutOfRange的异常处理程序,它会检查当前的try..endtry是否嵌套在另一个try..endtry内(正如示例 1-9 中的情况)。如果是这样,HLA 运行时系统会在外层try..endtry语句中搜索适当的异常子句。在示例 1-9 中,程序在try..endtry块中找到了合适的异常处理程序,因此控制权转移到exception( ex.ValueOutOfRange )子句后的语句。
离开try..endtry块后,HLA 运行时系统不再认为该块是活动的,并且在程序引发异常时不会在其异常列表中进行搜索。^([19]) 这样可以让你在程序的其他部分以不同的方式处理相同的异常。
如果两个try..endtry语句处理相同的异常,并且其中一个try..endtry块嵌套在另一个try..endtry语句的保护区段内,并且程序在执行最内层的try..endtry序列时引发异常,则 HLA 会直接将控制权转移到最内层try..endtry块提供的异常处理程序。HLA 不会自动将控制权转移到外层try..endtry序列提供的异常处理程序。
在之前的示例中(示例 1-9),第二个 try..endtry 语句是静态地嵌套在外部的 try..endtry 语句内部的。^([20]) 正如之前没有评论提到的,如果最近激活的 try..endtry 语句没有处理特定的异常,程序将通过任何动态嵌套的 try..endtry 块的 exception 子句进行查找。动态嵌套不要求嵌套的 try..endtry 块物理上出现在外部的 try..endtry 语句中。相反,控制权可以从外部 try..endtry 保护块内部转移到程序中的其他位置。在该位置执行的 try..endtry 语句会动态地将两个 try 语句嵌套在一起。虽然有很多方法可以动态嵌套代码,但有一种方法你可能已经通过高级语言的经验熟悉:过程调用。在第五章中,当你学习如何在汇编语言中编写过程(函数)时,应该记住,任何在 try..endtry 保护部分内对过程的调用,都可能在该过程中执行 try..endtry 时创建一个动态嵌套的 try..endtry。
1.11.2 try..endtry 语句中的未保护子句
每当程序执行 try 子句时,它会保存当前的异常环境,并设置系统,在发生异常时将控制权转移到该 try..endtry 语句中的 exception 子句。如果程序成功完成了 try..endtry 保护块的执行,程序将恢复原始的异常环境,控制权转移到 endtry 子句之后的第一条语句。恢复执行环境的这个最后步骤非常重要。如果程序跳过这一步,任何未来的异常都会将控制权转移到这个 try..endtry 语句,即使程序已经离开了 try..endtry 块。示例 1-10 展示了这个问题。
示例 1-10. 不当退出 try..endtry 语句
program testBadInput3;
#include( "stdlib.hhf" )
static
input: int32;
begin testBadInput3;
// This forever loop repeats until the user enters
// a good integer and the break statement below
// exits the loop.
forever
try
stdout.put( "Enter an integer value: " );
stdin.get( input );
stdout.put( "The first input value was: ", input, nl );
break;
exception( ex.ValueOutOfRange )
stdout.put( "The value was too large, re-enter." nl );
exception( ex.ConversionError )
stdout.put( "The input contained illegal characters, re-enter." nl );
endtry;
endfor;
// Note that the following code is outside the loop and there
// is no try..endtry statement protecting this code.
stdout.put( "Enter another number: " );
stdin.get( input );
stdout.put( "The new number is: ", input, nl );
end testBadInput3;
本示例试图通过将一个循环放置在 try..endtry 语句周围来创建一个强健的输入系统,并在 stdin.get 例程由于输入数据错误而抛出异常时,强制用户重新输入数据。虽然这是一个不错的想法,但该实现有一个重大问题:break 语句会立即退出 forever..endfor 循环,而没有先恢复异常环境。因此,当程序执行第二个 stdin.get 语句时,在程序底部,HLA 的异常处理代码仍然认为它在 try..endtry 块内部。如果发生异常,HLA 会将控制转回到 try..endtry 语句,寻找适当的异常处理程序。假设异常是 ex.ValueOutOfRange 或 ex.ConversionError,则示例 1-10 中的程序将打印一个适当的错误信息并迫使用户重新输入第一个值。这显然不是理想的做法。
将控制转交给错误的 try..endtry 异常处理程序只是问题的一部分。另一个大问题与 HLA 保存和恢复异常环境的方式有关:具体来说,HLA 将旧的执行环境信息保存在一个特殊的内存区域中,称为 堆栈。如果在不恢复异常环境的情况下退出 try..endtry,这将导致旧的执行环境信息仍然保留在堆栈上,而这部分额外的数据可能会导致程序故障。
尽管这个讨论很清楚地表明,程序不应该以 示例 1-10 中的方式退出 try..endtry 语句,但如果你能够在 try..endtry 块周围使用一个循环,强制重新输入错误数据,就像这个程序尝试做的那样,那就更好了。为了支持这种做法,HLA 的 try..endtry 语句提供了一个 unprotected 区域。请参考示例 1-11 中的代码。
示例 1-11. try..endtry 未保护区域
program testBadInput4;
#include( "stdlib.hhf" )
static
input: int32;
begin testBadInput4;
// This forever loop repeats until the user enters
// a good integer and the break statement below
// exits the loop. Note that the break statement
// appears in an unprotected section of the try..endtry
// statement.
forever
try
stdout.put( "Enter an integer value: " );
stdin.get( input );
stdout.put( "The first input value was: ", input, nl );
unprotected
break;
exception( ex.ValueOutOfRange )
stdout.put( "The value was too large, re-enter." nl );
exception( ex.ConversionError )
stdout.put( "The input contained illegal characters, re-enter." nl );
endtry;
endfor;
// Note that the following code is outside the loop and there
// is no try..endtry statement protecting this code.
stdout.put( "Enter another number: " );
stdin.get( input );
stdout.put( "The new number is: ", input, nl );
end testBadInput4;
每当try..endtry语句遇到unprotected子句时,它会立即恢复异常环境。正如其名称所示,unprotected部分中的语句执行不再受到该try..endtry块的保护(然而,注意,任何动态嵌套的try..endtry语句仍然是活动的;unprotected只会关闭包含unprotected子句的try..endtry语句的异常处理)。因为示例 1-11 中的break语句出现在unprotected部分内,它可以安全地将控制转移出try..endtry块,而不需要“执行”endtry,因为程序已经恢复了之前的异常环境。
注意,unprotected关键字必须紧跟在try..endtry语句中的protected块之后出现。也就是说,它必须位于所有exception关键字之前。
如果在执行try..endtry序列时发生异常,HLA 会自动恢复执行环境。因此,你可以在exception子句中执行break语句(或任何其他会将控制转移出try..endtry块的指令)。
因为程序在遇到unprotected块或exception块时会恢复异常环境,所以在这些区域内发生的异常会立即将控制转移到之前的(动态嵌套的)活动try..endtry序列。如果没有嵌套的try..endtry序列,程序将以适当的错误信息终止。
1.11.3 try..endtry语句中的anyexception子句
在典型的情况下,你将使用try..endtry语句,并配合一组exception子句来处理在try..endtry序列的保护区域内可能发生的所有异常。通常,确保try..endtry语句处理所有可能的异常是很重要的,以防程序由于未处理的异常而提前终止。如果你已经编写了保护区内的所有代码,你会知道它可能抛出的异常,因此可以处理所有可能的异常。然而,如果你调用的是一个库函数(尤其是第三方库函数)、进行操作系统 API 调用,或者执行一些你无法控制的代码,那么你可能无法预测这段代码可能抛出的所有异常(尤其是在考虑过去、现在和未来的版本时)。如果这段代码抛出一个你没有exception子句处理的异常,这可能会导致你的程序失败。幸运的是,HLA 的try..endtry语句提供了anyexception子句,它会自动捕获所有现有的exception子句没有处理的异常。
anyexception子句类似于exception子句,不同之处在于它不需要异常编号参数(因为它处理任何异常)。如果anyexception子句出现在包含其他exception部分的try..endtry语句中,anyexception部分必须是try..endtry语句中的最后一个异常处理程序。anyexception部分可以是try..endtry语句中的唯一异常处理程序。
如果一个未处理的异常将控制转移到anyexception部分,EAX 寄存器将包含异常编号。你在anyexception块中的代码可以测试这个值,以确定异常的原因。
1.11.4 寄存器与 try..endtry 语句
try..endtry语句在你进入try..endtry语句时会保存几个字节的数据。离开try..endtry块时(或触发unprotected子句时),程序会恢复异常环境。只要没有发生异常,try..endtry语句在进入或退出时不会影响任何寄存器的值。然而,如果在执行受保护语句期间发生异常,则这一说法不成立。
进入exception子句时,EAX 寄存器包含异常编号,但所有其他通用寄存器的值都是未定义的。由于操作系统可能因硬件错误而引发了异常(因此,可能已修改了寄存器的值),你甚至不能假设通用寄存器在异常发生时包含它们所包含的任何值。HLA 为异常生成的底层代码在不同版本的编译器中可能会有所变化,当然在不同操作系统之间也会有所不同,因此,在异常处理程序中实验性地确定寄存器包含什么值并依赖于这些值从来不是一个好主意。
由于进入异常处理程序可能会破坏寄存器的值,因此你必须确保在endtry子句之后的代码中,如果假设寄存器包含某些特定值(即在受保护部分中设置的值或在执行try..endtry语句之前设置的值),要重新加载重要的寄存器。否则,这将引入一些严重的缺陷到你的程序中(这些缺陷可能是间歇性的并且难以检测,因为异常很少发生,且可能不会总是破坏某个特定寄存器中的值)。以下代码片段提供了这个问题及其解决方案的典型示例:
static
sum: int32;
.
.
.
mov( 0, sum );
for( mov( 0, ebx ); ebx < 8; inc( ebx )) do
push( ebx ); // Must preserve ebx in case there is an exception.
forever
try
stdin.geti32();
unprotected break;
exception( ex.ConversionError )
stdout.put( "Illegal input, please re-enter value: " );
endtry;
endfor;
pop( ebx ); // Restore ebx's value.
add( ebx, eax );
add( eax, sum );
endfor;
由于 HLA 的异常处理机制会干扰寄存器,并且异常处理是一个相对低效的过程,因此您绝对不应将 try..endtry 语句作为通用控制结构使用(例如,通过引发一个整数异常值并使用异常处理块作为要处理的案例,来模拟 switch/case 语句)。这样做会对程序的性能产生非常负面的影响,并可能引入细微的缺陷,因为异常会打乱寄存器的内容。
为了正确操作,try..endtry语句假设您仅使用 EBP 寄存器来指向激活记录(第五章 讨论了激活记录)。默认情况下,HLA 程序会自动使用 EBP 来完成此目的;只要您不修改 EBP 中的值,程序将自动使用 EBP 来维护当前激活记录的指针。如果您尝试将 EBP 寄存器作为通用寄存器来保存值并进行算术计算,HLA 的异常处理功能将无法正常工作(以及其他可能的问题)。因此,您绝对不应将 EBP 寄存器用作通用寄存器。当然,这同样适用于 ESP 寄存器。
^([18]) 请注意,HLA 会将此值加载到 EAX 寄存器中。因此,当进入 exception 子句时,EAX 中将包含异常编号。
^([19]) 当然,除非程序通过循环或其他控制结构重新进入 try..endtry 块。
^([20]) 静态嵌套意味着一个语句在源代码中物理地嵌套在另一个语句内。当我们说一个语句嵌套在另一个语句内时,通常意味着该语句是静态地嵌套在另一个语句内的。
1.12 高级汇编语言与低级汇编语言
在本章结束之前,重要的是要提醒你,本章出现的任何控制语句都不是“真实”的汇编语言。80x86 CPU 不支持像if、while、repeat、for、break、breakif和try这样的机器指令。每当 HLA 遇到这些语句时,它会将它们编译成一个或多个真实的机器指令,这些指令执行的操作与您使用的高级语句相同。虽然这些语句使用起来很方便,并且在许多情况下,它们与 HLA 将其转换为的低级机器指令序列一样高效,但不要忘记,它们并不是真正的机器指令。
本文的目的是教你低级汇编语言编程;这些高级控制结构只是实现这一目标的一种手段。请记住,学习 HLA 高级控制结构可以让你在教育过程中早早利用你对高级语言的了解,这样你就不必一次性学完所有关于汇编语言的内容。通过使用你已经熟悉的高级控制结构,本文可以将对通常用于控制流的实际机器指令的讨论推迟到很后面。通过这种方式,本文可以控制呈现的材料量,因此,希望你会发现学习汇编语言变得更加愉快。然而,你必须始终记住,这些高级控制语句只是一个帮助你学习汇编语言的教学工具。虽然一旦掌握了真正的控制流语句,你可以在汇编程序中自由使用它们,但如果你想学习汇编语言编程,你确实必须学习低级控制语句。既然你大概是因为这个目的在阅读本书,就不要让高级控制结构成为你的依赖。当你到达学习如何真正编写低级控制语句的阶段时,要全身心地去使用它们(仅限于低级语句)。随着你在低级控制语句方面的经验积累,了解它们的优缺点,你将能更好地判断在给定的应用中是使用高级代码序列还是低级代码序列更合适。然而,直到你获得相当的低级控制结构经验之前,你无法做出明智的决策。记住,除非你掌握了低级语句,否则你无法称自己为一个真正的汇编语言程序员。
需要记住的另一点是,HLA 标准库函数并不是汇编语言的一部分。它们只是一些为你预先编写的方便函数。虽然调用这些函数没有问题,但请始终记住,它们并不是机器指令,且这些例程本身并没有什么特别之处;随着你编写汇编语言代码经验的积累,你可以编写自己版本的这些例程(甚至写得更高效)。
如果你学习汇编语言是因为你想写出尽可能高效的程序(无论是最快还是最小的代码),你需要理解,如果你使用高级控制语句并且频繁调用 HLA 标准库,你将无法完全实现这一目标。HLA 的代码生成器和 HLA 标准库并不是极其低效的,但编写高效汇编程序的唯一真正方式是用汇编语言思考。HLA 的高级控制语句以及 HLA 标准库中的许多例程非常棒,因为它们让你避免用汇编语言思考。虽然这在你刚开始学习汇编语言时很有用,但如果你的最终目标是写出高效的代码,那么你必须学会用汇编语言思考。这篇文章将帮助你达到这个目标(并且会因为使用了 HLA 的高级特性而更迅速地做到这一点),但不要忘了,你的最终目标是放弃这些高级特性,转而使用低级编程。
1.13 更多信息
本章已经涵盖了很多内容!虽然你仍然有很多关于汇编语言编程需要学习,但本章结合你对高级语言的知识,提供了足够的信息,帮助你开始编写真正的汇编语言程序。
尽管本章涵盖了许多不同的主题,但主要的三个关注点是 80x86 CPU 架构、简单 HLA 程序的语法和 HLA 标准库。有关更多相关主题的信息,请参考本书的(完整版)电子版、HLA 参考手册和 HLA 标准库手册。所有这三者都可以在 www.artofasm.com/ 和 webster.cs.ucr.edu/ 查阅。
第二章 数据表示

许多初学者在学习汇编语言时遇到的一个主要障碍是二进制和十六进制计数系统的常见使用。尽管十六进制数字有些奇怪,但它们的优点远大于缺点。理解二进制和十六进制计数系统非常重要,因为它们的使用简化了其他主题的讨论,包括位操作、有符号数表示、字符编码和打包数据。
本章讨论几个重要概念,包括:
-
二进制和十六进制计数系统
-
二进制数据组织(比特、半字节、字节、字、双字)
-
有符号和无符号计数系统
-
对二进制值进行算术、逻辑、移位和旋转操作
-
位域和打包数据
这是基础内容,本书其余部分将基于你对这些概念的理解。如果你已经在其他课程或学习中接触过这些术语,至少应该浏览一遍这些内容再继续下一章。如果你对这些内容不熟悉,或者仅有模糊了解,你应该在继续之前仔细学习它。本章的所有内容都很重要! 不要跳过任何内容。
2.1 计数系统
大多数现代计算机系统并不使用十进制(基数 10)系统来表示数值。相反,它们通常使用二进制或二的补码计数系统。
2.1.1 十进制系统回顾
你已经使用十进制计数系统很长时间了,以至于可能不再注意它。当你看到像123这样的数字时,你不会去思考数字 123 的具体数值,而是会在脑海中生成一个关于这个数值代表多少个物品的形象。然而,实际上,数字 123 表示:
| 110² + 210¹ + 3*10⁰ |
|---|
或者
| 100 + 20 + 3 |
|---|
在十进制位置计数系统中,小数点左侧的每个数字表示一个值,该值为 0 到 9 之间的数字乘以逐渐增加的 10 的幂次。小数点右侧的数字表示一个值,该值为 0 到 9 之间的数字乘以逐渐增加的负 10 的幂次。例如,值 123.456 表示:
| 110² + 210¹ + 310⁰ + 410^(−1) + 510^(−2) + 610^(−3) |
|---|
或者
| 100 + 20 + 3 + 0.4 + 0.05 + 0.006 |
|---|
2.1.2 二进制计数系统
大多数现代计算机系统使用二进制逻辑进行操作。计算机使用两个电压级别(通常为 0v 和+2.4..5v)表示值。两个这样的电平可以准确表示两个独特的值。这些值可以是任何两个不同的值,但通常它们表示 0 和 1。这两个值恰巧对应于二进制计数系统中的两个数字。
二进制计数系统与十进制计数系统类似,只有两个例外:二进制只允许数字 0 和 1(而不是 0..9),而且二进制使用 2 的幂而不是 10 的幂。因此,将二进制数转换为十进制非常容易。对于二进制字符串中的每个1,将2^n加上去,其中n是二进制数字的基于零的位置。例如,二进制值 11001010[2]表示:
| 12⁷ + 12⁶ + 02⁵ + 02⁴ + 12³ + 02² + 12¹ + 02⁰ |
|---|
| = |
| 128 + 64 + 8 + 2 |
| = |
| 202[10] |
将十进制转换为二进制稍微困难一些。你必须找出那些 2 的幂,当它们加在一起时,能得出十进制结果。
转换十进制为二进制的一种简单方法是奇偶-除以二算法。该算法使用以下步骤:
-
如果数字是偶数,输出 0。如果数字是奇数,输出 1。
-
将数字除以 2 并舍去任何小数部分或余数。
-
如果商为 0,算法完成。
-
如果商不是 0 并且是奇数,则在当前字符串前插入 1;如果数字是偶数,则在二进制字符串前加 0。
-
返回第 2 步并重复。
二进制数虽然在高级语言中不太重要,但在汇编语言程序中随处可见。所以你应该对它们有一定的熟悉度。
2.1.3 二进制格式
在最纯粹的意义上,每个二进制数包含无限多个数字(或位,即二进制数字的简称)。例如,我们可以通过以下任何一种方式表示数字 5:
| 101 00000101 0000000000101 ...000000000000101 |
|---|
可以有任意数量的前导零位出现在二进制数之前,而不会改变其值。
我们将采用忽略值中任何前导零的惯例。例如,101[2]表示数字 5,但由于 80x86 通常以 8 位为一组工作,因此我们会发现将所有二进制数扩展到 4 位或 8 位的倍数要容易得多。因此,按照这一惯例,我们会将数字 5 表示为 0101[2]或 00000101[2]。
在美国,大多数人将每三个数字用逗号分开,以便更容易阅读较大的数字。例如,1,023,435,208 比 1023435208 更容易阅读和理解。我们将在本文中采用类似的惯例,使用下划线分隔每四个位的二进制数字。例如,我们将把二进制值 1010111110110010 写为 1010_1111_1011_0010。
我们将按以下方式编号每一位:
-
二进制数中最右边的位是位位置 0。
-
每一位向左递增一个连续的位编号。
8 位二进制值使用位 0..7:
X[7] X[6] X[5] X[4] X[3] X[2] X[1] X[0] 16 位二进制值使用位位置 0..15:
X[15] X[14] X[13] X[12] X[11] X[10] X[9] X[8] X[7] X[6] X[5] X[4] X[3] X[2] X[1] X[0] 32 位二进制值使用位位置 0..31,以此类推。
位 0 是低位(L.O.)位(有些人称之为最低有效位)。最左边的位称为高位(H.O.)位(或最高有效位)。我们将按位号来称呼中间的位。
2.2 十六进制计数系统
不幸的是,二进制数很冗长。表示值 202[10]需要 8 个二进制数字。十进制版本只需要 3 个十进制数字,因此比二进制表示的数字更紧凑。这一点在设计二进制计算机系统的工程师心中早已显现出来。当处理大数值时,二进制数很快变得笨拙。不幸的是,计算机“思考”时使用的是二进制,因此大多数时候使用二进制计数系统是方便的。虽然我们可以在十进制和二进制之间转换,但转换并不是一项简单的任务。十六进制(基数 16)计数系统解决了二进制系统固有的许多问题。十六进制数字具备我们所需的两个特点:它们非常紧凑,且转换为二进制及反向转换都很简单。因此,大多数工程师使用十六进制计数系统。
由于十六进制数字的基数是 16,十六进制小数点左边的每个十六进制数字表示某个值乘以 16 的连续幂。例如,数字 1234[16]等于:
| 116³ + 216² + 316¹ + 416⁰ |
|---|
或者
| 4096 + 512 + 48 + 4 = 4660[10] |
|---|
每个十六进制数字可以表示介于 0 和 15[10]之间的 16 个值。由于只有 10 个十进制数字,我们需要发明 6 个附加数字来表示 10[10]到 15[10]之间的值。我们不为这些数字创造新符号,而是使用字母 A 到 F。以下都是有效的十六进制数字示例:
| 1234[16] DEAD[16] BEEF[16] 0AFB[16] FEED[16] DEAF[16] |
|---|
因为我们经常需要将十六进制数输入计算机系统,所以我们需要一种不同的机制来表示十六进制数。毕竟,在大多数计算机系统中,你不能输入下标来表示相关值的基数。我们将采用以下约定:
-
所有十六进制值都以$字符开头;例如,$123A4。
-
所有二进制值都以百分号(%)开头。
-
十进制数字没有前缀字符。
-
如果上下文已经能明确基数,本书可能会省略前导的$或%字符。
下面是一些有效的十六进制数字示例:
$1234 $DEAD $BEEF $AFB $FEED $DEAF
如你所见,十六进制数字简洁且易于阅读。此外,你可以轻松地在十六进制和二进制之间进行转换。请参阅表 2-1。此表提供了将任何十六进制数转换为二进制数或反之所需的所有信息。
表 2-1. 二进制/十六进制转换
| 二进制 | 十六进制 |
|---|---|
| %0000 | $0 |
| %0001 | $1 |
| %0010 | $2 |
| %0011 | $3 |
| %0100 | $4 |
| %0101 | $5 |
| %0110 | $6 |
| %0111 | $7 |
| %1000 | $8 |
| %1001 | $9 |
| %1010 | $A |
| %1011 | $B |
| %1100 | $C |
| %1101 | $D |
| %1110 | $E |
| %1111 | $F |
要将十六进制数字转换为二进制数字,只需将每个十六进制数字对应的 4 位二进制数替换即可。例如,要将$ABCD 转换为二进制值,只需根据表 2-1 将每个十六进制数字转换,如下所示:
| A | B | C | D | 十六进制 |
|---|---|---|---|---|
| 1010 | 1011 | 1100 | 1101 | 二进制 |
将二进制数转换为十六进制格式几乎同样简单。第一步是用零填充二进制数,确保数字中的位数是 4 的倍数。例如,给定二进制数 1011001010,第一步是向数字左侧添加 2 个二进制位,使其包含 12 位。转换后的二进制值为 001011001010。下一步是将二进制值分成 4 位一组,例如,0010_1100_1010。最后,在表 2-1 中查找这些二进制值,并替换为相应的十六进制数字,即$2CA。对比一下十进制和二进制或十进制和十六进制之间的转换难度!
因为在十六进制和二进制之间转换是你需要反复执行的操作,所以你应该花几分钟记住转换表。即使你有一个可以为你进行转换的计算器,你会发现手动转换在二进制和十六进制之间的转换要快得多且更方便。
2.3 数据组织
在纯数学中,一个值的表示可能需要任意数量的位数。而计算机通常使用特定数量的位。常见的位数集合有单个位、4 位一组(称为nibble)、8 位一组(byte)、16 位一组(word)、32 位一组(double word或dword)、64 位一组(quad word或qword)、128 位一组(long word或lword)等。位数不是任意的,这些特定的值有其合理的原因。本节将描述在 Intel 80x86 芯片上常用的位组。
2.3.1 位
二进制计算机上的最小数据单位是单个比特。通过一个比特,你可以表示任何两种不同的项目。举例来说,包括 0 或 1,true 或 false,开或关,男或女,对或错。然而,你并不局限于表示二进制数据类型(即那些只有两种不同值的对象)。你可以用一个比特表示数字 723 和 1,245,或者可能是值 6,254 和 5。你也可以用一个比特表示红色和蓝色。你甚至可以用一个比特表示两个不相关的对象。例如,你可以用一个比特表示红色和数字 3,256。你可以用一个比特表示任何两个不同的值。然而,你只能用一个比特表示两种不同的值。
更加混淆的是,不同的比特可以表示不同的东西。例如,你可以用一个比特表示值 0 和 1,而另一个比特可以表示值 true 和 false。你怎么通过观察这些比特来判断它们的意义呢?答案显然是,你不能。但这恰恰说明了计算机数据结构的核心思想:数据是你定义的那样。如果你用一个比特表示布尔值(true/false),那么这个比特(按照你的定义)就代表了 true 或 false。为了让比特具有实际意义,你必须保持一致性。如果在程序的某一部分你用一个比特表示 true 或 false,那么你就不应该在后面的部分用它来表示红色或蓝色。
因为大多数你需要建模的项目需要超过两种不同的值,单比特值并不是你最常使用的数据类型。然而,由于其他一切都是由比特组组成的,因此比特在你的程序中将发挥重要作用。当然,也有几种数据类型需要两个不同的值,所以看起来比特本身是很重要的。然而,你很快就会发现,单个比特难以操作,因此我们通常会使用其他数据类型来表示两种状态值。
2.3.2 Nibbles
一个nibble是 4 个比特的集合。它本身并不是一个特别有趣的数据结构,除非考虑到两个事实:二进制编码十进制(BCD)数字^([21])和十六进制数字。表示一个 BCD 或十六进制数字需要 4 个比特。通过一个 nibble,我们可以表示最多 16 个不同的值,因为 4 个比特的字符串有 16 种唯一的组合:
0000
0001
0010
0011
0100
0101
0110
0111
1000
1001
1010
1011
1100
1101
1110
1111
对于十六进制数字,值 0、1、2、3、4、5、6、7、8、9、A、B、C、D、E 和 F 用 4 个比特表示。BCD 使用 10 个不同的数字(0、1、2、3、4、5、6、7、8、9),也需要 4 个比特(因为用 3 个比特只能表示 8 个不同的值,剩下的 6 个值用 4 个比特表示,但在 BCD 表示法中这些额外的值从未被使用)。事实上,任何 16 个不同的值都可以通过 nibble 表示,虽然十六进制和 BCD 数字是我们可以用单个 nibble 表示的主要项目。
2.3.3 字节
毫无疑问,80x86 微处理器使用的最重要数据结构是字节,它由 8 个位组成。80x86 的主内存和 I/O 地址都是字节地址。这意味着,80x86 程序能够单独访问的最小项是 8 位值。要访问更小的数据,必须读取包含数据的字节并去除不需要的位。字节中的位通常从 0 到 7 进行编号,如图 2-1 所示。

图 2-1. 位编号
位 0 是低位或最低有效位,位 7 是高位或最高有效位。我们将其他所有位按其编号来引用。
请注意,一个字节还恰好包含两个半字节(参见图 2-2)。

图 2-2. 一个字节中的两个半字节
位 0 到 3 组成低位半字节,位 4 到 7 组成高位半字节。由于一个字节恰好包含两个半字节,因此字节值需要两个十六进制数字。
由于一个字节包含 8 个位,它可以表示 2⁸(256)个不同的值。通常,我们使用字节表示范围为 0 到 255 的数字值、有符号数范围为−128 到+127(参见 2.8 有符号和无符号数字)、ASCII/IBM 字符代码以及其他需要不超过 256 个不同值的特殊数据类型。许多数据类型的项数少于 256,因此 8 位通常足够。
由于 80x86 是字节寻址机器,因此操作整个字节比操作单个位或半字节更高效。因此,大多数程序员使用整个字节来表示需要不超过 256 个项的数据类型,即使使用少于 8 位也足够。例如,我们通常通过 00000001[2]和 00000000[2]分别表示布尔值 true 和 false。
字节最重要的用途可能是保存字符值。键盘输入的字符、显示在屏幕上的字符以及打印在打印机上的字符都有数值。为了与外部世界进行通信,个人电脑通常使用ASCII 字符集的变种。ASCII 字符集中定义了 128 个代码。
由于字节是 80x86 内存空间中最小的存储单元,因此字节也恰好是你可以在 HLA 程序中创建的最小变量。如你在上一章中看到的,你可以使用int8数据类型声明一个 8 位有符号整数变量。由于int8对象是有符号的,因此你可以使用int8变量表示范围为−128 到+127 的值。你应该只将有符号值存储到int8变量中;如果你想创建一个任意字节变量,则应该使用byte数据类型,如下所示:
static
byteVar: byte;
byte数据类型是一个部分未类型化的数据类型。与byte对象关联的唯一类型信息是它的大小(1 字节)。你可以将任何 8 位值(小的有符号整数、小的无符号整数、字符等)存储到字节变量中。你需要自行跟踪你存储在字节变量中的对象类型。
2.3.4 字
一个字是由 16 个位组成的。我们将字中的位从 0 编号到 15,如图 2-3 所示。与字节一样,位 0 是最低有效位。对于字来说,位 15 是最高有效位。在引用字中的其他位时,我们将使用它们的位位置编号。

图 2-3. 字中的位数
请注意,一个字包含恰好 2 个字节。位 0..7 构成低字节,位 8..15 构成高字节(见图 2-4)。

图 2-4. 字中的两个字节
当然,一个字还可以进一步分解为四个半字,如图 2-5 所示。半字 0 是字中的最低有效半字,半字 3 是字中的最高有效半字。我们将其他两个半字分别称为nibble 1和nibble 2。

图 2-5. 字中的半字
通过 16 个位,你可以表示 2¹⁶(65,536)种不同的值。这些值可以是范围 0..65,535 内的值,或者通常的情况是有符号值−32,768..+32,767,或者是任何其他不超过 65,536 个值的数据类型。字的三大主要用途是短的有符号整数值、短的无符号整数值以及 Unicode 字符。
字可以表示 0..65,535 或−32,768..32,767 范围内的整数值。无符号数值由字中的位所对应的二进制值表示。有符号数值使用二的补码形式表示数值(参见 2.8 有符号和无符号数)。作为 Unicode 字符,字可以表示最多 65,536 个不同的字符,允许计算机程序使用非罗马字符集。Unicode 是一种国际标准,类似于 ASCII,它允许计算机处理非罗马字符,例如亚洲字符、希腊字母和俄语字符。
与字节类似,你也可以在 HLA 程序中创建字变量。当然,在上一章中你已经看到如何使用int16数据类型创建 16 位有符号整数变量。要创建一个任意的字变量,只需使用word数据类型,如下所示:
static
w: word;
2.3.5 双字
双字正如其名,是一对字。因此,双字量是 32 位长,如图 2-6 所示。

图 2-6. 双字中的比特数
自然,这个双字可以分为高位字和低位字、四个不同的字节,或者八个不同的半字节(见图 2-7)。
双字(dwords)可以表示各种不同的事物。你常用双字表示的一个项目是 32 位整数值(允许无符号数在 0..4,294,967,295 范围内,或有符号数在−2,147,483,648..2,147,483,647 范围内)。32 位浮点值也可以适应双字。双字对象的另一个常见用途是存储指针值。

图 2-7. 双字中的字节、字和四位字节
在第一章中,你学会了如何使用int32数据类型创建 32 位有符号整数变量。你还可以使用dword数据类型创建任意双字变量,如以下示例所示:
static
d: dword;
2.3.6 四字和长字
显然,我们可以继续定义越来越大的字大小。然而,80x86 只支持某些特定的原生大小,因此继续定义更大对象的术语没有太大意义。虽然字节、字和双字是你在 80x86 程序中最常见的大小,但四字(64 位)值也很重要,因为某些浮点数据类型需要 64 位。同样,现代 80x86 处理器的 SSE/MMX 指令集可以操作 64 位值。从类似的角度看,长字(128 位)值也很重要,因为后期 80x86 处理器的 SSE 指令集能够操作 128 位值。HLA 允许使用qword和lword类型声明 64 位和 128 位值,如下所示:
static
q :qword;
l :lword;
请注意,你还可以使用像以下这样的 HLA 声明来定义 64 位和 128 位的整数值:
static
i64 :int64;
i128 :int128;
然而,你不能直接使用标准指令如mov、add和sub来操作 64 位和 128 位整数对象,因为标准 80x86 整数寄存器每次只能处理 32 位。在第八章中,你将看到如何操作这些扩展精度值。
^([21]) 二进制编码十进制是一种数值方案,用于通过每个十进制数字 4 位来表示十进制数。
2.4 二进制和十六进制数的算术运算
我们可以对二进制和十六进制数字执行几种运算。例如,我们可以加法、减法、乘法、除法,以及执行其他算术运算。虽然你不必成为这一方面的专家,但在紧急情况下,你应该能够手动使用纸和笔完成这些运算。虽然刚才说过你应该能够手动执行这些算术运算,正确的做法是拥有一款可以自动完成这些运算的计算器。市场上有几款这样的计算器;以下是一些十六进制计算器制造商的列表(2010 年):
-
卡西欧(Casio)
-
惠普(Hewlett-Packard)
-
夏普(Sharp)
-
德州仪器(Texas Instruments)
这个列表并非详尽无遗。其他计算器制造商可能也生产这些设备。惠普(Hewlett-Packard)的设备无疑是其中最好的。然而,它们比其他品牌更贵。夏普和卡西欧生产的设备价格远低于五十美元。如果你打算进行任何汇编语言编程,拥有其中一款计算器是必不可少的。
为了理解为什么你应该花钱买一台计算器,考虑以下这个算术问题:
$9
+ $1
----
你可能会想写出答案\(10 作为这个问题的解答。但那是错误的!正确答案是 10,即\)A,而不是 16,即$10。类似的问题也出现在以下的减法问题中:
$10
- $1
----
你可能会想回答\(9,尽管正确答案是\)F。记住,这个问题问的是:“16 和 1 之间的差是多少?”答案当然是 15,即$F。
即使这两个问题不困扰你,在紧张情况下,你的大脑会在你考虑其他事情时自动转换回十进制,从而得出错误的结果。故事的寓意是——如果你必须手动使用十六进制数字进行算术计算,一定要慢慢来,注意细节。或者,你可以将数字转换为十进制,进行十进制运算后再转换回十六进制。
2.5 数字与表示的说明
很多人会混淆数字及其表示形式。初学汇编语言的学生常问一个问题:“我有一个在 EAX 寄存器中的二进制数;我怎么将它转换为 EAX 寄存器中的十六进制数?”答案是,“你不能。”尽管可以提出有力的论据,认为内存或寄存器中的数字是以二进制表示的,但最好将内存或寄存器中的值视为抽象的数值。像 128、$80 或%1000_0000 这样的符号串并不是不同的数字;它们只是“128”这个抽象量的不同表示形式。在计算机内部,无论表示方式如何,数字就是数字;只有在你以人类可读的形式输入或输出值时,表示方式才重要。
人类可读的数字量形式始终是字符字符串。为了以人类可读的形式打印值 128,必须将数字值 128 转换为由字符 1、2、8 组成的三字符序列。这将提供数字量的十进制表示形式。如果你愿意,也可以将数字值 128 转换为三字符序列$80\。它是相同的数字,但我们将其转换为不同的字符序列,因为(假设)我们希望以十六进制表示该数字,而不是十进制。同样,如果我们希望以二进制形式查看该数字,则必须将该数字值转换为包含一个 1 后跟七个 0 的字符串。
默认情况下,使用stdout.put例程时,HLA 会以十六进制计数法显示所有byte、word、dword、qword和lword变量。同样,HLA 的stdout.put例程也会以十六进制形式显示所有寄存器的值。考虑示例 2-1 中的程序,该程序将作为十进制数字输入的值转换为其十六进制等价值。
示例 2-1. 十进制到十六进制转换程序
program ConvertToHex;
#include( "stdlib.hhf" )
static
value: int32;
begin ConvertToHex;
stdout.put( "Input a decimal value:" );
stdin.get( value );
mov( value, eax );
stdout.put( "The value ", value, " converted to hex is $", eax, nl );
end ConvertToHex;
类似地,寄存器和byte、word、dword、qword、lword类型变量的默认输入基数也是十六进制。在示例 2-2 中的程序与示例 2-1 中的程序相反;它输入一个十六进制值,并将其输出为十进制。
示例 2-2. 十六进制到十进制转换程序
program ConvertToDecimal;
#include( "stdlib.hhf" )
static
value: int32;
begin ConvertToDecimal;
stdout.put( "Input a hexadecimal value: " );
stdin.get( ebx );
mov( ebx, value );
stdout.put( "The value $", ebx, " converted to decimal is ", value, nl );
end ConvertToDecimal;
仅仅因为 HLA 的 stdout.put 例程选择了十进制作为 int8、int16 和 int32 变量的默认输出基数,并不意味着这些变量保存的是十进制数。请记住,内存和寄存器保存的是数值,而不是十六进制或十进制值。stdout.put 例程将这些数值转换为字符串并打印出结果。选择十六进制还是十进制输出是 HLA 语言中的设计选择,仅此而已。你可以很容易地修改 HLA,使其将寄存器和 byte、word、dword、qword 或 lword 变量输出为十进制值,而不是十六进制值。如果你需要将寄存器或 byte、word、dword 变量的值以十进制显示,只需调用其中一个 putiX 例程。stdout.puti8 例程会将其参数作为 8 位有符号整数输出。任何 8 位参数都能工作。所以你可以将一个 8 位寄存器、int8 变量或 byte 变量作为参数传递给 stdout.puti8,结果将始终是十进制输出。stdout.puti16 和 stdout.puti32 例程为 16 位和 32 位对象提供相同的功能。示例 2-3 程序演示了使用仅包含 EBX 寄存器的十进制转换程序(即不使用变量 iValue),与 示例 2-2 一起展示。
示例 2-3. 无变量的十六进制到十进制转换器
program ConvertToDecimal2;
#include( "stdlib.hhf" )
begin ConvertToDecimal2;
stdout.put( "Input a hexadecimal value: " );
stdin.get( ebx );
stdout.put( "The value $", ebx, " converted to decimal is " );
stdout.puti32( ebx );
stdout.newln();
end ConvertToDecimal2;
注意,HLA 的 stdin.get 例程对于输入使用的默认基数与 stdout.put 例程用于输出时使用的默认基数相同。也就是说,如果你尝试读取一个 int8、int16 或 int32 变量,默认输入基数是十进制。如果你尝试读取一个寄存器或 byte、word、dword、qword 或 lword 变量,默认输入基数是十六进制。如果你希望在读取寄存器或 byte、word、dword、qword 或 lword 变量时将默认输入基数更改为十进制,则可以使用 stdin.geti8、stdin.geti16、stdin.geti32、stdin.geti64 或 stdin.geti128。
如果你想反向操作,也就是将 int8、int16、int32、int64 或 int128 变量作为十六进制值输入或输出,可以调用 stdout.puth8、stdout.puth16、stdout.puth32、stdout.puth64、stdout.puth128、stdin.geth8、stdin.geth16、stdin.geth32、stdin.geth64 或 stdin.geth128 函数。stdout.puth8、stdout.puth16、stdout.puth32、stdout.puth64 和 stdout.puth128 函数将 8 位、16 位、32 位、64 位或 128 位对象以十六进制值写入。stdin.geth8、stdin.geth16、stdin.geth32、stdin.geth64 和 stdin.geth128 函数分别读取 8 位、16 位、32 位、64 位和 128 位的值;它们将结果返回到 AL、AX 或 EAX 寄存器(或者对于 64 位和 128 位值返回到参数位置)。示例 2-4 中的程序展示了这些函数的一些使用方法:
示例 2-4. 演示 stdin.geth32 和 stdout.puth32
program HexIO;
#include( "stdlib.hhf" )
static
i32: int32;
begin HexIO;
stdout.put( "Enter a hexadecimal value: " );
stdin.geth32();
mov( eax, i32 );
stdout.put( "The value you entered was $" );
stdout.puth32( i32 );
stdout.newln();
end HexIO;
2.6 位级逻辑运算
我们将进行四种主要的逻辑运算,使用十六进制和二进制数字:and、or、xor(异或)和 not。与算术运算不同,进行这些运算时不需要十六进制计算器。手动操作通常比使用电子设备计算要简单。逻辑 and 运算是一个二元^([22]) 运算(意味着它接受恰好两个操作数)。这些操作数是单独的二进制位。and 运算为:
0 and 0 = 0
0 and 1 = 0
1 and 0 = 0
1 and 1 = 1
一种紧凑的方式来表示逻辑 and 运算是使用真值表。真值表的形式如 表 2-2 所示。
表 2-2. and 真值表
and |
0 | 1 |
|---|---|---|
| 0 | 0 | 0 |
| 1 | 0 | 1 |
这就像你在学校遇到的乘法表。左列的值对应 and 运算的左操作数。顶部行的值对应 and 运算的右操作数。位于行和列交点处的值(对于特定输入值对)是这两个值进行逻辑 and 运算后的结果。
在英语中,逻辑and运算是,“如果第一个操作数为 1 且第二个操作数为 1,则结果为 1;否则结果为 0。”我们也可以这样表达,“如果任一操作数或两个操作数都是 0,则结果为 0。”
关于逻辑and操作,有一个重要的事实需要注意,那就是你可以用它来强制得到一个 0 的结果。如果一个操作数为 0,则结果始终为 0,不管另一个操作数的值是什么。例如,在上面的真值表中,标记为 0 输入的行只有 0,标记为 0 的列结果也全是 0。相反,如果一个操作数为 1,则结果完全等于第二个操作数的值。and操作的这些结果非常重要,特别是当我们想强制将比特设置为 0 时。我们将在下一节中进一步探讨逻辑and操作的这些应用。
逻辑or操作也是一种二元操作。它的定义是:
0 or 0 = 0
0 or 1 = 1
1 or 0 = 1
1 or 1 = 1
or操作的真值表呈现于表 2-3 中。
表 2-3. or 真值表
or |
0 | 1 |
|---|---|---|
| 0 | 0 | 1 |
| 1 | 1 | 1 |
口语中,逻辑or操作是,“如果第一个操作数或第二个操作数(或两者)为 1,则结果为 1;否则结果为 0。”这也被称为包含或操作。
如果逻辑or操作的一个操作数为 1,则结果始终为 1,不管第二个操作数的值是什么。如果一个操作数为 0,则结果始终是第二个操作数的值。像逻辑and操作一样,这是逻辑or操作的一个重要副作用,实际上非常有用。
请注意,这种形式的包含逻辑or操作与标准英语中的含义是不同的。考虑句子“我要去商店或者我要去公园。”这样的表述意味着说话者要么去商店,要么去公园,而不是同时去两个地方。因此,英语中的逻辑or略有不同于包含或操作;实际上,这就是异或操作的定义。
逻辑xor(异或)操作也是一种二元操作。它的定义如下:
0 xor 0 = 0
0 xor 1 = 1
1 xor 0 = 1
1 xor 1 = 0
xor操作的真值表呈现于表 2-4 中。
表 2-4. xor 真值表
xor |
0 | 1 |
|---|---|---|
| 0 | 0 | 1 |
| 1 | 1 | 0 |
在英语中,逻辑xor操作是,“如果第一个操作数或第二个操作数(但不是两者),为 1,则结果为 1;否则结果为 0。”请注意,异或操作比逻辑or操作更接近英语中or这个词的含义。
如果逻辑异或操作的一个操作数为 1,则结果始终是另一个操作数的反转;即,如果一个操作数为 1,而另一个操作数为 1,则结果为 0;如果另一个操作数为 0,则结果为 1。如果第一个操作数为 0,则结果完全等于第二个操作数的值。这个特性让你可以在比特串中选择性地反转比特。
逻辑not操作是单目操作(意味着它只接受一个操作数):
not 0 = 1
not 1 = 0
not操作的真值表见于表 2-5。
表 2-5. not 真值表
not |
0 | 1 |
|---|---|---|
| 1 | 0 |
^([22]) 许多文本称之为二进制运算。术语二元表示相同的意思,并避免了与二进制计数系统的混淆。
2.7 二进制数和位串的逻辑运算
上一节定义了单比特操作数的逻辑函数。由于 80x86 使用的是 8、16 或 32 位的字长,我们需要扩展这些函数的定义,以处理超过 2 位的情况。80x86 上的逻辑函数是逐位(或按位)操作的。给定两个值,这些函数对第 0 位执行操作,生成结果的第 0 位。它们对输入值的第 1 位执行操作,生成结果的第 1 位,以此类推。例如,如果你想计算以下两个 8 位数的逻辑and,你将对每一列独立地执行逻辑and操作:
%1011_0101
%1110_1110
----------
%1010_0100
你也可以将这种逐位计算应用于其他逻辑函数。
因为我们已经定义了基于二进制值的逻辑运算,所以你会发现对二进制值进行逻辑运算比其他表示形式更容易。因此,如果你想对两个十六进制数执行逻辑运算,应该先将它们转换为二进制。这适用于大多数基本的二进制逻辑运算(如and、or、xor等)。
使用逻辑and/or操作将比特强制为 0 或 1 的能力,以及使用逻辑xor操作反转比特的能力,在处理比特串(如二进制数)时非常重要。这些操作使你能够选择性地操作比特串中的某些比特,而不影响其他比特。例如,如果你有一个 8 位的二进制值X,并且想要保证第 4..7 位为 0,你可以将值X与二进制值%0000_1111 进行逻辑and运算。这个逐位逻辑and操作会将高 4 位强制为 0,而将X的低 4 位保持不变。同样,你可以通过将X与%0000_0001 进行逻辑or运算,将X的低位强制为 1,并通过将X与%0000_0100 进行逻辑异或(exclusive-or)运算来反转X的第 2 位。以这种方式使用逻辑and、or和xor操作来处理比特串被称为掩码比特串。我们使用掩码这个术语,因为我们可以使用特定的值(and用 1,or/xor用 0)来掩盖或掩入某些比特,从而在将比特强制为 0、1 或其反值时控制操作。
80x86 CPU 支持四条指令,将这些按位逻辑操作应用于操作数。这些指令是and、or、xor和not。and、or和xor指令与add和sub指令使用相同的语法:
and( *`source`*, *`dest`* );
or( *`source`*, *`dest`* );
xor( *`source`*, *`dest`* );
这些操作数与add操作数具有相同的限制。具体来说,source操作数必须是常数、内存或寄存器操作数,dest操作数必须是内存或寄存器操作数。此外,操作数必须具有相同的大小,并且不能同时是内存操作数。这些指令通过以下等式计算明显的按位逻辑操作:
*`dest`* = *`dest operator source`*
80x86 逻辑not指令,由于只有一个操作数,因此使用稍微不同的语法。该指令的形式如下:
not( *`dest`* );
该指令计算出以下结果:
*`dest`* = not( *`dest`* )
dest操作数必须是寄存器或内存操作数。此指令会反转指定目标操作数中的所有位。
程序在示例 2-5 中从用户输入两个十六进制值,并计算它们的逻辑and、or、xor和not:
示例 2-5. and、or、xor和not示例
program LogicalOp;
#include( "stdlib.hhf" )
begin LogicalOp;
stdout.put( "Input left operand: " );
stdin.get( eax );
stdout.put( "Input right operand: " );
stdin.get( ebx );
mov( eax, ecx );
and( ebx, ecx );
stdout.put( "$", eax, " and $", ebx, " = $", ecx, nl );
mov( eax, ecx );
or( ebx, ecx );
stdout.put( "$", eax, " or $", ebx, " = $", ecx, nl );
mov( eax, ecx );
xor( ebx, ecx );
stdout.put( "$", eax, " xor $", ebx, " = $", ecx, nl );
mov( eax, ecx );
not( ecx );
stdout.put( "not $", eax, " = $", ecx, nl );
mov( ebx, ecx );
not( ecx );
stdout.put( "not $", ebx, " = $", ecx, nl );
end LogicalOp;
2.8 带符号数和无符号数
到目前为止,我们将二进制数视为无符号值。二进制数...00000 表示 0,...00001 表示 1,...00010 表示 2,依此类推,直到无穷大。那么负数呢?带符号值在前面的章节中已经提到过,我们提到了二进制补码系统,但我们还没有讨论如何使用二进制系统表示负数。现在是时候描述二进制补码系统了。
为了使用二进制编号系统表示带符号数字,我们必须对数字施加限制:它们必须具有有限且固定的位数。为了简便起见,我们将大幅限制位数,限制为 8、16、32、64、128 或其他一些较小的位数。
使用固定的位数,我们只能表示有限数量的对象。例如,使用 8 位,我们只能表示 256 个不同的值。负值是独立的对象,就像正数和 0 一样;因此,我们必须使用 256 个不同的 8 位值中的一部分来表示负数。换句话说,我们必须使用一些位组合来表示负数。为了公平起见,我们将把一半的可能组合分配给负值,另一半分配给正值和 0。因此,我们可以用一个 8 位字节表示负值−128..−1 和非负值 0..127。使用 16 位字,我们可以表示值范围−32,768..+32,767。使用 32 位双字,我们可以表示值范围−2,147,483,648..+2,147,483,647。一般来说,使用n位时,我们可以表示带符号值范围为−2(*n*−1)到+2(n−1)−1。
好的,我们可以表示负数。我们究竟该怎么做呢?实际上有许多方法,但 80x86 微处理器使用的是二补码表示法,因此研究这种方法是有意义的。在二补码系统中,一个数的最高有效位(H.O. bit)是一个符号位。如果最高有效位是 0,则该数为正数;如果最高有效位是 1,则该数为负数。以下是一些例子。
对于 16 位数:
$8000 is negative because the H.O. bit is 1.
$100 is positive because the H.O. bit is 0.
$7FFF is positive.
$FFFF is negative.
$FFF ($0FFF) is positive.
如果最高有效位是 0,则该数为正数,并使用标准二进制格式。如果最高有效位是 1,则该数为负数,并使用二补码形式。将正数转换为负数的二补码形式,你可以使用以下算法:
-
反转数值中的所有位;也就是说,应用逻辑
not运算。 -
将 1 加到反转后的结果,并忽略任何从最高有效位溢出的部分。
例如,计算−5 的 8 位等价数:
%0000_0101 5 (in binary). %1111_1010 Invert all the bits. %1111_1011 Add 1 to obtain result.如果我们对−5 进行二补码操作,我们会得到原始值 %0000_0101,正如我们所期望的那样:
%1111_1011 Two's complement for −5. %0000_0100 Invert all the bits. %0000_0101 Add 1 to obtain result (+5).以下例子提供了一些正负 16 位有符号数值:
$7FFF: +32767, the largest 16-bit positive number. $8000: −32768, the smallest 16-bit negative number. $4000: +16384.要将上面的数字转换为其负数(即取反),请执行以下操作:
$7FFF: %0111_1111_1111_1111 +32,767 %1000_0000_0000_0000 Invert all the bits (8000h) %1000_0000_0000_0001 Add 1 (8001h or −32,767) 4000h: %0100_0000_0000_0000 16,384 %1011_1111_1111_1111 Invert all the bits ($BFFF) %1100_0000_0000_0000 Add 1 ($C000 or −16,384) $8000: %1000_0000_0000_0000 −32,768 %0111_1111_1111_1111 Invert all the bits ($7FFF) %1000_0000_0000_0000 Add one (8000h or −32,768)
$8000 反转后变为 $7FFF。加 1 后,我们得到 $8000!等等,发生了什么?−(−32,768)是−32,768 吗?当然不是。但+32,768 的值无法用 16 位有符号数表示,所以我们无法取反最小的负值。
为什么要使用如此复杂的编号系统?为什么不将最高有效位作为符号标志,将数值的正数等价物存储在剩余的位中呢?(顺便说一下,这被称为一补码编号系统。)答案在于硬件。事实证明,取反值是唯一繁琐的操作。使用二补码系统时,大多数其他操作和二进制系统一样简单。例如,假设你要执行加法 5 + (−5)。结果是 0。考虑一下当我们在二补码系统中将这两个数相加时发生了什么:
% 0000_0101
% 1111_1011
------------
%1_0000_0000
我们最终会在第九位产生进位,其他所有位都为 0。事实上,如果我们忽略从最高有效位产生的进位,使用二补码编号系统时,两个符号数的加法总是能得到正确的结果。这意味着我们可以使用相同的硬件进行有符号和无符号的加法与减法,而其他编号系统则不能做到这一点。
通常,你不需要手动执行二补码操作。80x86 微处理器提供了一条指令,neg(取反),可以为你执行这个操作。此外,十六进制计算器通过按下切换符号键(+/− 或 CHS)来执行这个操作。不过,手动计算二补码很简单,你应该知道怎么做。
请记住,由一组二进制位表示的数据完全取决于上下文。8 位二进制值%1100_0000 可以表示一个字符,也可以表示无符号十进制值 192,或者表示有符号十进制值−64。作为程序员,你有责任定义数据的格式,然后一致地使用这些数据。
80x86 的取反指令neg与not指令使用相同的语法;即,它接受一个目标操作数:
neg( *`dest`* );
该指令计算 dest = -dest; 操作数与not指令相同的限制(它必须是一个内存位置或寄存器)。neg指令作用于字节、字(word)和双字(dword)大小的对象。由于这是一个有符号整数操作,所以只对有符号整数值进行操作才有意义。示例 2-6 中的程序通过使用neg指令演示了二进制补码操作:
示例 2-6. twosComplement 示例
program twosComplement;
#include( "stdlib.hhf" )
static
PosValue: int8;
NegValue: int8;
begin twosComplement;
stdout.put( "Enter an integer between 0 and 127: " );
stdin.get( PosValue );
stdout.put( nl, "Value in hexadecimal: $" );
stdout.puth8( PosValue );
mov( PosValue, al );
not( al );
stdout.put( nl, "Invert all the bits: $", al, nl );
add( 1, al );
stdout.put( "Add one: $", al, nl );
mov( al, NegValue );
stdout.put( "Result in decimal: ", NegValue, nl );
stdout.put
(
nl,
"Now do the same thing with the NEG instruction: ",
nl
);
mov( PosValue, al );
neg( al );
mov( al, NegValue );
stdout.put( "Hex result = $", al, nl );
stdout.put( "Decimal result = ", NegValue, nl );
end twosComplement;
如你所见,使用int8、int16、int32、int64和int128数据类型来为有符号整数变量预留存储空间。你也看到过像stdout.puti8和stdin.geti32这样的例程,它们用于读取和写入有符号整数值。由于本节已经明确指出,你必须在程序中区分有符号和无符号计算,你可能会问自己:“如何声明和使用无符号整数变量?”
问题的第一部分,“如何声明无符号整数变量”,最容易回答。你只需在声明变量时使用uns8、uns16、uns32、uns64和uns128数据类型。例如:
static
u8: uns8;
u16: uns16;
u32: uns32;
u64: uns64;
u128: uns128;
至于如何使用这些无符号变量,HLA 标准库提供了一组补充的输入/输出例程,用于读取和显示无符号变量。你可以猜到,这些例程包括stdout.putu8、stdout.putu16、stdout.putu32、stdout.putu64、stdout.putu128、stdout.putu8Size、stdout.putu16Size、stdout.putu32Size、stdout.putu64Size、stdout.putu128Size、stdin.getu8、stdin.getu16、stdin.getu32、stdin.getu64和stdin.getu128。你可以像使用有符号整数对应例程一样使用这些例程,只不过你可以使用这些例程访问无符号值的全部范围。示例 2-7 的源代码演示了无符号 I/O,并展示了如果你在同一计算中混合有符号和无符号操作会发生什么。
示例 2-7. 无符号 I/O
program UnsExample;
#include( "stdlib.hhf" )
static
UnsValue: uns16;
begin UnsExample;
stdout.put( "Enter an integer between 32,768 and 65,535: " );
stdin.getu16();
mov( ax, UnsValue );
stdout.put
(
"You entered ",
UnsValue,
". If you treat this as a signed integer, it is "
);
stdout.puti16( UnsValue );
stdout.newln();
end UnsExample;
2.9 符号扩展、零扩展、收缩和饱和
因为补码格式的整数具有固定的长度,所以会出现一个小问题。如果需要将一个 8 位补码值转换为 16 位会发生什么?这个问题及其反问题(将 16 位值转换为 8 位)可以通过符号扩展和收缩操作来完成。
考虑值 −64。这个数的 8 位补码值是\(C0。该数的 16 位等效值是\)FFC0。现在考虑值 +64。该值的 8 位和 16 位版本分别为$40 和\(0040。8 位和 16 位数值之间的区别可以通过以下规则描述:“如果数值是负数,16 位数值的高字节包含\)FF;如果数值是正数,16 位数值的高字节是 0。”
要将一个有符号值从某些位数扩展到更大的位数很容易;只需将符号位复制到新格式中的所有附加位。例如,要将 8 位数符号扩展为 16 位数,只需将 8 位数的第 7 位复制到 16 位数的第 8 到 15 位。要将 16 位数符号扩展为双字,只需将 16 位数的第 15 位复制到双字的第 16 到 31 位。
在操作不同长度的有符号值时,你必须使用符号扩展。通常你需要将一个字节量与一个字量相加。在进行操作之前,必须先将字节量符号扩展为字。其他操作(特别是乘法和除法)可能需要扩展到 32 位:
Sign Extension:
8 Bits 16 Bits 32 Bits
$80 $FF80 $FFFF_FF80
$28 $0028 $0000_0028
$9A $FF9A $FFFF_FF9A
$7F $007F $0000_007F
$1020 $0000_1020
$8086 $FFFF_8086
要将一个无符号值扩展为更大的值,你必须进行零扩展。零扩展非常简单——只需将 0 存储到更大操作数的高字节中。例如,要将 8 位值$82 零扩展到 16 位,你只需在高字节加上 0,得到$0082。
Zero Extension:
8 Bits 16 Bits 32 Bits
$80 $0080 $0000_0080
$28 $0028 $0000_0028
$9A $009A $0000_009A
$7F $007F $0000_007F
$1020 $0000_1020
$8086 $0000_8086
80x86 提供了几条指令,允许你将一个较小的数字符号扩展或零扩展到较大的数字。表 2-6 列出了能够符号扩展 AL、AX 或 EAX 寄存器的一组指令。
表 2-6. 扩展 AL, AX, 和 EAX 的指令
| 指令 | 说明 |
|---|---|
cbw(); |
通过符号扩展将 AL 中的字节转换为 AX 中的字。 |
cwd(); |
通过符号扩展将 AX 中的字转换为 DX:AX 中的双字。 |
cdq(); |
通过符号扩展将 EAX 中的双字转换为 EDX:EAX 中的四字。 |
cwde(); |
通过符号扩展将 AX 中的字转换为 EAX 中的双字。 |
请注意,cwd(将字转换为双字)指令并不会将 AX 中的字进行符号扩展到 EAX 中的双字。相反,它将符号扩展的高字节部分存储到 DX 寄存器中(符号 DX:AX 表示你有一个双字值,其中 DX 包含高 16 位,AX 包含低 16 位)。如果你希望将 AX 的符号扩展放入 EAX 中,应该使用cwde(将字转换为双字,扩展)指令。
上述四条指令是不同寻常的,因为这是你见过的第一组没有任何操作数的指令。这些指令的操作数是由指令本身隐含的。
在接下来的几章中,你将发现这些指令的重要性以及为什么cwd和cdq指令涉及 DX 和 EDX 寄存器。然而,对于简单的符号扩展操作,这些指令有一些主要缺点——你无法指定源操作数和目标操作数,并且操作数必须是寄存器。
对于一般的符号扩展操作,80x86 提供了mov指令的扩展——movsx(带符号扩展的移动指令),它在复制数据的同时进行符号扩展。movsx指令的语法与mov指令非常相似:
movsx( *`source`*, *`dest`* );
这条指令与mov指令的最大语法区别在于目标操作数必须比源操作数大。也就是说,如果源操作数是字节,目标操作数必须是字或双字。类似地,如果源操作数是字,目标操作数必须是双字。另一个区别是目标操作数必须是寄存器;而源操作数则可以是内存位置。^([23]) movsx指令不允许常量操作数。
要将一个值进行零扩展,可以使用movzx指令。它的语法和限制与movsx指令相同。零扩展某些 8 位寄存器(AL、BL、CL 和 DL)到它们对应的 16 位寄存器,可以通过将互补的高字节寄存器(AH、BH、CH 或 DH)加载为 0 来轻松实现,而无需使用movzx指令。显然,要将 AX 零扩展到 DX:AX 或 EAX 零扩展到 EDX:EAX,你需要做的就是将 DX 或 EDX 加载为 0。^([24])
示例 2-8 中的示例程序演示了符号扩展指令的使用。
示例 2-8. 符号扩展指令
program signExtension;
#include( "stdlib.hhf" )
static
i8: int8;
i16: int16;
i32: int32;
begin signExtension;
stdout.put( "Enter a small negative number: " );
stdin.get( i8 );
stdout.put( nl, "Sign extension using CBW and CWDE:", nl, nl );
mov( i8, al );
stdout.put( "You entered ", i8, " ($", al, ")", nl );
cbw();
mov( ax, i16 );
stdout.put( "16-bit sign extension: ", i16, " ($", ax, ")", nl );
cwde();
mov( eax, i32 );
stdout.put( "32-bit sign extension: ", i32, " ($", eax, ")", nl );
stdout.put( nl, "Sign extension using MOVSX:", nl, nl );
movsx( i8, ax );
mov( ax, i16 );
stdout.put( "16-bit sign extension: ", i16, " ($", ax, ")", nl );
movsx( i8, eax );
mov( eax, i32 );
stdout.put( "32-bit sign extension: ", i32, " ($", eax, ")", nl );
end signExtension;
符号收缩,将具有一定数量位的值转换为具有较少位的相同值,是稍微麻烦一点的操作。符号扩展从不失败。给定一个 m 位的有符号值,你总是可以将其通过符号扩展转换为 n 位数(其中 n > m)。不幸的是,给定一个 n 位数,如果 m < n,你并不总能将其转换为 m 位数。例如,考虑值 −448。作为一个 16 位有符号数,它的十六进制表示为 $FE40。不幸的是,这个数字的大小对于一个 8 位值来说太大了,因此你无法将其符号收缩为 8 位。这是一个在转换过程中发生溢出的例子。
要正确地对合同值进行签名,你必须查看要丢弃的 H.O. 字节。H.O. 字节必须全都包含 0 或 $FF。如果你遇到其他值,则无法在不发生溢出的情况下进行收缩。最后,结果值的 H.O. 位必须与从数字中移除的每一个位匹配。以下是一些示例(16 位到 8 位):
$FF80 can be sign contracted to $80.
$0040 can be sign contracted to $40.
$FE40 cannot be sign contracted to 8 bits.
$0100 cannot be sign contracted to 8 bits.
另一种减少整数大小的方法是饱和。饱和在需要将较大的对象转换为较小的对象,并且你愿意接受可能的精度损失时非常有用。通过饱和转换值时,如果较大的值不超出较小对象的范围,你只需将较大的值复制到较小的对象中。如果较大的值超出了较小值的范围,那么你裁剪该值,将其设置为较小对象范围内的最大(或最小)值。
例如,当将一个 16 位有符号整数转换为 8 位有符号整数时,如果 16 位值在 −128..+127 的范围内,你只需将 16 位对象的 L.O. 字节复制到 8 位对象中。如果 16 位有符号值大于 +127,那么你将值裁剪为 +127,并将 +127 存储到 8 位对象中。同样,如果该值小于 −128,你将最终的 8 位对象裁剪为 −128。饱和操作在将 32 位值裁剪为较小值时同样有效。如果较大的值超出了较小值的范围,那么你只需将较小值设置为可以用较小值表示的最接近溢出值。
显然,如果较大的值超出了较小值的范围,那么在转换过程中就会出现精度损失。虽然将值裁剪到较小对象所限制的范围并不是理想的做法,但有时这是可以接受的,因为另一种选择是引发异常或拒绝计算。对于许多应用场景,如音频或视频处理,裁剪后的结果仍然是可识别的,因此这是一个合理的转换方式。
^([23]) 这并不会成为太大的限制,因为符号扩展几乎总是发生在必须在寄存器中执行的算术操作之前。
^([24]) 零扩展到 DX:AX 或 EDX:EAX 和 CWD、CDQ 指令同样重要,正如你最终会看到的那样。
2.10 移位与旋转
另一个应用于比特串的逻辑操作是移位和旋转操作。这两类操作可以进一步细分为左移、左旋、右移和右旋。这些操作非常有用。
左移操作将比特串中的每个位向左移动一位位置(图 2-8 提供了一个 8 位移位的示例)。

图 2-8. 左移操作
位 0 移动到位位置 1,位位置 1 中的先前值移动到位位置 2,以此类推。当然,随之而来的两个问题是:“位 0 中放入什么?”以及“最高位去哪里了?”我们将一个 0 移入位 0,先前的最高位值将成为此次操作的进位。
80x86 提供了一个左移指令shl,可以执行此有用操作。shl指令的语法为:
shl( *`count`*, *`dest`* );
count 操作数可以是 CL 或一个常数,范围是 0..n,其中 n 是目标操作数中比特位数减去 1(例如,8 位操作数时 n = 7,16 位操作数时 n = 15,32 位操作数时 n = 31)。dest 操作数是一个典型的目标操作数,可以是内存位置或寄存器。
当count 操作数为常数 1 时,shl 指令执行如图 2-9 所示的操作。

图 2-9. 左移操作
在图 2-9 中,C 代表进位标志。也就是说,从操作数中移出的最高位会进入进位标志。因此,你可以在执行 shl(1, *dest*); 指令后,通过立即测试进位标志来检测溢出(例如,使用 if( @c ) then... 或 if( @nc ) then...)。
英特尔的文献指出,如果移位计数是 1 以外的值,则进位标志的状态未定义。通常,进位标志包含从目标操作数中移出的最后一位,但英特尔似乎没有保证这一点。
注意,左移值等同于将其乘以基数。例如,将一个十进制数左移一位(在数字右侧加上一个 0)实际上是将其乘以 10(基数):
1234 shl 1 = 12340
(shl 1 表示将一个数字位置向左移。)
由于二进制数字的基数是 2,左移操作相当于乘以 2。如果你将二进制值左移两次,你相当于将其乘以 2 两次(即乘以 4)。如果你将二进制值左移三次,你相当于将其乘以 8(222)。一般来说,如果你将一个值左移n次,你就将该值乘以 2^(n)。
右移操作的工作方式相同,只是我们将数据移向相反的方向。对于字节值,第 7 位移入第 6 位,第 6 位移入第 5 位,第 5 位移入第 4 位,依此类推。在右移过程中,我们将一个 0 移入第 7 位,第 0 位将作为操作的进位输出(见图 2-10)。

图 2-10. 右移操作
正如你可能预料的那样,80x86 提供了一个shr指令,可以将目标操作数的位向右移。其语法与shl指令相同,当然,区别在于你指定的是shr而不是shl:
shr( *`count`*, *`dest`* );
该指令将一个 0 移入目标操作数的最高有效位(H.O.),并将其他位向右移一个位置(即,从高位数字移到低位数字)。最后,位 0 被移入进位标志。如果你指定移位次数为 1,shr指令会执行图 2-11 中所示的操作。

图 2-11. 右移操作
再次提醒,Intel 的文档建议,移位超过 1 位时,进位会处于未定义状态。
由于左移相当于乘以 2,因此右移大致可以看作是除以 2(或者一般而言,除以该数字的基数)。如果你执行n次右移,那么你将该数字除以 2^(n)。
关于除法,右移操作有一个问题:右移仅相当于无符号除以 2。例如,如果你将无符号表示的 254($FE)右移一位,你得到 127(\(7F),这正是你期望的结果。然而,如果你将-2 的二进制表示(\)FE)右移一位,你得到 127($7F),这是不正确的。这个问题发生是因为我们将一个 0 移入了第 7 位。如果第 7 位之前是 1,那么我们将其从负数变成了正数。在除以 2 时这样做并不好。
为了将右移用作除法运算符,我们必须定义第三种移位操作:算术右移。^([25]) 算术右移的工作原理与普通右移操作(逻辑右移)相同,唯一的例外是:算术右移操作不会将 0 移入高位,而是将高位的值保留在其自身中;也就是说,在移位操作过程中,它不会修改高位,如图 2-12 所示。

图 2-12. 算术右移操作
算术右移通常会产生你预期的结果。例如,如果对 −2 (\(FE) 执行算术右移操作,结果是 −1 (\)FF)。然而,有一点需要牢记关于算术右移:该操作总是将数字舍入到 小于或等于实际结果 的最接近整数。基于高级编程语言的经验和整数截断的标准规则,大多数人认为这意味着除法总是向 0 截断。但事实并非如此。例如,如果对 −1 ($FF) 执行算术右移操作,结果是 −1,而不是 0。因为 −1 小于 0,算术右移操作会舍入到 −1。这并不是算术右移操作的错误;它只是使用了与整数除法不同(但有效)的定义。
80x86 提供了一个算术右移指令,sar(算术右移)。该指令的语法几乎与 shl 和 shr 相同。其语法如下:
sar( *`count`*, *`dest`* );
通常关于计数和目标操作数的限制适用。如果计数为 1,则该指令的操作如图 2-13` 操作")所示。

图 2-13. sar( 1, dest ) 操作
再次提醒,Intel 的文档建议,如果移位超过 1 位,进位将处于未定义状态。
另一个有用的操作是 左旋转 和 右旋转。这些操作与左移和右移操作类似,但有一个主要区别:从一端移出的位会被旋转回另一端。图 2-14 展示了这些操作。

图 2-14. 左旋转和右旋转操作
80x86 提供了 rol(左旋转)和 ror(右旋转)指令,对其操作数执行这些基本操作。这两个指令的语法与移位指令类似:
rol( *`count`*, *`dest`* );
ror( *`count`*, *`dest`* );
再次强调,这些指令在移位计数为 1 时提供特殊行为。在这种情况下,这两个指令还会将从目标操作数中移出的位复制到进位标志中,正如图 2-15 操作")和图 2-16 操作")所示。

图 2-15. rol( 1, dest ) 操作
请注意,英特尔的文档建议,超过 1 位的旋转操作会使进位处于未定义状态。

图 2-16. ror( 1, dest ) 操作
对于旋转操作,通常更方便将输出位通过进位进行旋转,并将之前的进位值重新输入到移位操作的输入位中。80x86 的 rcl(进位左旋转)和 rcr(进位右旋转)指令为你实现了这一点。这些指令使用以下语法:
rcl( *`count`*, *`dest`* );
rcr( *`count`*, *`dest`* );
与其他移位和旋转指令一样,count 操作数要么是常数,要么是 CL 寄存器,dest 操作数则是内存位置或寄存器。count 操作数的值必须小于 dest 操作数的位数。对于移位计数值为 1 的情况,这两个指令执行的旋转操作如图 2-17 和 rcr( 1, dest ) 操作")所示。

图 2-17. rcl( 1, dest ) 和 rcr( 1, dest ) 操作
再次提醒,英特尔的文档建议,超过 1 位的旋转操作会使进位处于未定义状态。
^([25]) 不需要算术左移。标准的左移操作适用于有符号和无符号数值,前提是没有溢出发生。
2.11 位域和打包数据
尽管 80x86 在处理byte、word和dword数据类型时效率最高,但有时你需要处理一些比 8、16 或 32 位更复杂的数据类型。例如,考虑一下格式为 04/02/01 的日期。表示这个日期需要三个数值:月份、日期和年份。月份的值当然是 1 到 12 之间的数值。因此,表示月份至少需要 4 位(二进制最大值为 16)。日期的范围是 1 到 31,因此表示日期需要 5 位(二进制最大值为 32)。假设年份在 0 到 99 之间,表示年份需要 7 位(最多可以表示 128 种不同的值)。4 + 5 + 7 = 16 位,或者 2 字节。换句话说,我们可以将日期数据打包成 2 字节,而不是使用 3 个字节(分别为月份、日期和年份各分配一个字节)。这样每存储一个日期就能节省 1 个字节的内存,如果需要存储大量日期,这将是一个显著的节省。位可以按图 2-18 中的方式进行排列。

图 2-18. 短打包日期格式(2 字节)
MMMM 代表组成月份值的 4 位,DDDDD 代表组成日期的 5 位,YYYYYYY 代表组成年份的 7 位。每个数据项的位集合称为位字段。例如,2001 年 4 月 2 日将表示为$4101:
0100 00010 0000001 = %0100_0001_0000_0001 or $4101
4 2 01
尽管打包的值节省空间(即在内存使用上非常高效),但它们在计算上是低效的(慢!)。原因是什么?因为需要额外的指令来解包那些打包在各个位字段中的数据。这些额外的指令需要额外的时间来执行(并且需要额外的字节来存储这些指令);因此,必须仔细考虑打包的数据字段是否能为你节省任何东西。示例 2-9 中的示例程序展示了打包和解包这种 16 位日期格式所需的工作量。
示例 2-9. 打包和解包日期数据
program dateDemo;
#include( "stdlib.hhf" )
static
day: uns8;
month: uns8;
year: uns8;
packedDate: word;
begin dateDemo;
stdout.put( "Enter the current month, day, and year: " );
stdin.get( month, day, year );
// Pack the data into the following bits:
//
// 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
// m m m m d d d d d y y y y y y y
mov( 0, ax );
mov( ax, packedDate ); // Just in case there is an error.
if( month > 12 ) then
stdout.put( "Month value is too large", nl );
elseif( month = 0 ) then
stdout.put( "Month value must be in the range 1..12", nl );
elseif( day > 31 ) then
stdout.put( "Day value is too large", nl );
elseif( day = 0 ) then
stdout.put( "Day value must be in the range 1..31", nl );
elseif( year > 99 ) then
stdout.put( "Year value must be in the range 0..99", nl );
else
mov( month, al );
shl( 5, ax );
or( day, al );
shl( 7, ax );
or( year, al );
mov( ax, packedDate );
endif;
// Okay, display the packed value:
stdout.put( "Packed data = $", packedDate, nl );
// Unpack the date:
mov( packedDate, ax );
and( $7f, al ); // Retrieve the year value.
mov( al, year );
mov( packedDate, ax ); // Retrieve the day value.
shr( 7, ax );
and( %1_1111, al );
mov( al, day );
mov( packedDate, ax ); // Retrieve the month value.
rol( 4, ax );
and( %1111, al );
mov( al, month );
stdout.put( "The date is ", month, "/", day, "/", year, nl );
end dateDemo;
当然,经过了千年虫(Y2K)问题,你知道使用一个限制为 100 年(甚至 127 年)的日期格式在现在看来是非常愚蠢的。如果你担心你的软件在 100 年后仍在运行,也许使用一个 3 字节的日期格式而不是 2 字节格式会更明智。不过,正如你将在数组章节中看到的,通常你应该尽量创建长度为偶数次幂的 2 的数据对象(1 字节、2 字节、4 字节、8 字节等),否则你会遭遇性能损失。因此,可能明智的做法是直接使用 4 字节,并将这些数据打包到一个双字变量中。图 2-19 展示了一个可能的 4 字节日期数据组织方式。

图 2-19. 长紧凑日期格式(4 字节)
在这种长格式的紧凑日期格式中,我们做了一些更改,不仅仅是简单地扩展与年份相关的位数。首先,因为在 32 位双字变量中有额外的位,所以该格式为月份和日期字段分配了额外的位。由于这两个字段现在各自包含 8 个位,它们可以很容易地作为字节对象从双字中提取。这会留下更少的位给年份,但 65,536 年可能已经足够;你可以假设,除非你的软件仍然在 63,000 年后使用,否则它不太可能在这个日期格式停止工作时仍然被使用。
当然,你可以争辩说,这不再是一个紧凑日期格式。毕竟,我们需要三个数值,其中两个正好可以各自占用 1 个字节,而另一个则可能至少需要 2 个字节。因为这个“紧凑”日期格式与非紧凑版本消耗相同的 4 个字节,那么这个格式到底有什么特别之处呢?好吧,你会注意到,在这个长格式的紧凑日期格式与出现在图 2-18 中的短日期格式之间,另一个区别是,这个长日期格式重新排列了位,使得Year字段位于高字节位,Month字段位于中间字节位,而Day字段位于低字节位。这一点非常重要,因为它使得你可以非常方便地比较两个日期,查看一个日期是否小于、等于或大于另一个日期。请看以下代码:
mov( *`Date1`*, eax ); // Assume *`Date1`* and *`Date2`* are dword variables
if( eax > *`Date2`* ) then // using the Long Packed Date format.
<< Do something if *`Date1`* > *`Date2`* >>
endif;
如果你将不同的日期字段保存在单独的变量中,或者以不同的方式组织字段,你就无法像这样轻松地比较Date1和Date2。因此,这个例子展示了另一个打包数据的理由,即使你没有意识到任何空间节省——它可以使某些计算变得更加方便,甚至更高效(与通常打包数据时会发生的情况相反)。
实际的打包数据类型例子比比皆是。你可以将八个布尔值打包成一个字节,将两个 BCD 数字打包成一个字节,等等。当然,经典的打包数据示例是 EFLAGS 寄存器(参见图 2-20)。该寄存器将九个重要的布尔对象(以及七个重要的系统标志)打包成一个 16 位寄存器。你通常需要访问这些标志中的许多。因此,80x86 指令集提供了多种方法来操作 EFLAGS 寄存器中的各个位。当然,你可以使用 HLA 伪布尔变量(如@c、@nc、@z和@nz)在if语句或其他布尔表达式中测试许多条件代码标志。
除了条件代码外,80x86 还提供了直接影响某些标志的指令(表 2-7)。
表 2-7. 影响某些标志的指令
| 指令 | 说明 |
|---|---|
cld(); |
清除(设置为 0)方向标志。 |
std(); |
设置(为 1)方向标志。 |
cli(); |
清除中断禁止标志。 |
sti(); |
设置中断禁止标志。 |
clc(); |
清除进位标志。 |
stc(); |
设置进位标志。 |
cmc(); |
互补(反转)进位标志。 |
sahf(); |
将 AH 寄存器存储到 EFLAGS 寄存器的低 8 位。 |
lahf(); |
从 EFLAGS 寄存器的低 8 位加载 AH。 |
还有其他影响 EFLAGS 寄存器的指令;然而,这些指令演示了如何访问 EFLAGS 寄存器中的几个打包布尔值。特别是,lahf和sahf指令提供了一种方便的方法,可以将 EFLAGS 寄存器的低 8 位作为一个 8 位字节(而不是作为八个单独的 1 位值)进行访问。有关 EFLAGS 寄存器的布局,请参见图 2-20。

图 2-20. EFLAGS 寄存器作为打包布尔数据
lahf(将 AH 加载为 EFLAGS 寄存器的低 8 位)和sahf(将 AH 存储到 EFLAGS 寄存器的低字节)使用以下语法:
lahf();
sahf();
2.12 浮点运算简介
整数运算无法表示小数值。因此,现代 CPU 支持一种实数运算的近似方法:浮点运算。浮点运算的一个大问题是,它不遵循代数的标准规则。然而,许多程序员在使用浮点运算时仍然应用正常的代数规则。这是许多程序中的缺陷来源。本节的主要目标之一是描述浮点运算的局限性,以便你能正确使用它。
正常的代数规则只适用于无限精度运算。考虑简单的语句x := x + 1,其中x是整数。在任何现代计算机上,只要没有发生溢出,这个语句遵循正常的代数规则。也就是说,这个语句只对某些x值有效(minint <= x < maxint)。大多数程序员对此没有问题,因为他们充分意识到程序中的整数并不遵循标准的代数规则(例如,5/2 不等于 2.5)。
整数不遵循代数标准规则,因为计算机用有限数量的位来表示它们。你无法表示大于最大整数或小于最小整数的任何整数值。浮点值也有同样的问题,而且问题更严重。毕竟,整数是实数的一个子集。因此,浮点值必须表示同样的无限整数集合。然而,在任何两个整数值之间都有无限多个实数值,所以这个问题变得更加严重。因此,除了需要将值限制在最大和最小范围之间,你也无法表示这两个范围之间的所有值。
为了表示实数,大多数浮点数格式采用科学记数法,并使用一定数量的位来表示尾数,以及更少数量的位来表示指数。最终结果是浮点数只能表示具有特定数量有效数字的数值。这对浮点运算的运作有很大影响。为了更清楚地看到有限精度运算的影响,我们将在示例中采用简化的十进制浮点格式。我们的浮点格式将提供一个具有三位有效数字的尾数和一个两位数字的十进制指数。尾数和指数都是带符号的值,如图 2-21 所示。

图 2-21. 浮点数格式
当对两个科学记数法表示的数字进行加减时,我们必须调整这两个值,使它们的指数相同。例如,当加 1.23e1 和 4.56e0 时,我们必须调整这两个值的指数,使它们相同。一个方法是将 4.56e0 转换为 0.456e1,然后再进行加法。这会得到 1.686e1。遗憾的是,结果无法容纳三位有效数字,因此我们必须对结果进行四舍五入或截断到三位有效数字。四舍五入通常能得到最准确的结果,因此我们将结果四舍五入得到 1.69e1。正如你所看到的,精度(我们在计算中保留的数字或位数)影响了结果的准确性(计算的正确性)。
在上面的例子中,我们能够四舍五入结果,因为在计算过程中我们保留了四位有效数字。如果我们的浮点计算在计算过程中只能保留三位有效数字,我们就必须截断较小数字的最后一位,得到 1.68e1,这是一个更不准确的值。为了提高浮点计算的准确性,有必要在计算过程中添加额外的数字。计算过程中可用的额外数字被称为保护数字(对于二进制格式而言,称为保护位)。它们在长链计算过程中能大大提高准确性。
在单次计算中,准确性损失通常不足以引起担忧,除非你对计算的准确性非常关注。然而,如果你计算的值是多个浮点运算结果的序列,那么误差可能会累积,并极大地影响计算本身。例如,假设我们要将 1.23e3 加到 1.00e0。通过在加法前调整这两个数的指数,使其相同,得到 1.23e3 + 0.001e3。即使经过四舍五入,这两个值的和仍然是 1.23e3。这对你来说可能完全合理;毕竟,我们只能保留三位有效数字,因此加上一个小值不应该影响结果。然而,假设我们将 1.00e0 加到 1.23e3 十次。第一次将 1.00e0 加到 1.23e3 时,得到 1.23e3。同样,在第二次、第三次、第四次……直到第十次加法时,我们都得到 1.23e3。另一方面,如果我们将 1.00e0 加到自己身上 10 次,再将结果(1.00e1)加到 1.23e3,我们会得到不同的结果,1.24e3。这是有限精度运算中需要注意的一个重要点:
运算顺序会影响结果的准确性。
如果相对大小(即指数)在加减浮点值时接近,你将得到更准确的结果。如果进行的是包含加法和减法的连锁计算,应该尽量将值适当地分组。
| 加法和减法的另一个问题是可能会导致虚假精度。考虑计算 1.23e0 − 1.22e0\。这会产生 0.01e0\。虽然这在数学上等同于 1.00e − 2,但后者的形式表明最后两位数字是精确为 0 的。不幸的是,我们目前只有一个有效数字。实际上,一些浮点单元(FPU)软件包可能会在低位插入随机数字(或位)。这引出了关于有限精度算术的第二条重要规则: |
|---|
在减去两个符号相同的数字或加两个符号不同的数字时,结果的准确性可能低于浮点格式所能提供的精度。
乘法和除法不像加法和减法那样存在相同的问题,因为你不需要在运算前调整指数;你所需要做的只是加上指数并乘以尾数(或减去指数并除以尾数)。就其本身而言,乘法和除法不会产生特别差的结果。然而,它们倾向于放大已经存在的任何误差。例如,如果你将 1.23e0 乘以 2,而你应该将 1.24e0 乘以 2,结果会更加不准确。这引出了在进行有限精度算术运算时的第三条重要规则:
在进行涉及加法、减法、乘法和除法的计算链时,尽量先执行乘法和除法操作。
通常,通过应用正常的代数变换,你可以调整计算顺序,使得乘法和除法运算优先进行。例如,假设你想计算 x * ( y + z )。通常你会先将 y 和 z 相加,然后将它们的和乘以 x。然而,如果你将 x * ( y + z ) 转换为 x * y + x * z 并先执行乘法运算,你会获得更高的准确性。^([26])
| 乘法和除法并非没有问题。当乘以两个非常大或非常小的数字时,很可能会发生溢出或下溢。同样的情况也会出现在将一个小数字除以一个大数字,或将一个大数字除以一个小数字时。这引出了你在乘法或除法时应遵循的第四条规则: |
|---|
在进行乘法和除法时,尽量安排乘法顺序,使得大数和小数相乘;同样,尽量将具有相同相对大小的数字进行除法运算。
比较浮动点数值是非常危险的。鉴于任何计算中(包括将输入字符串转换为浮动点值)都可能存在不准确性,你绝不应该比较两个浮动点值是否相等。在二进制浮动点格式中,不同的计算可能产生相同的(数学)结果,但它们的最低有效位可能不同。例如,1.31e0 + 1.69e0 应该得到 3.00e0。同样,1.50e0 + 1.50e0 应该得到 3.00e0。然而,如果你比较 (1.31e0 + 1.69e0) 和 (1.50e0 + 1.50e0),你可能会发现这两个和并不相等。只有当两个操作数的所有位(或数字)完全相同,等式测试才会成功。因为在两次不同的浮动点计算结果应该相同的情况下,这一点不一定成立,所以直接进行相等测试可能不起作用。
| 测试浮动点数值是否相等的标准方法是确定你在比较中允许多少误差(或容差),并检查一个值是否在另一个值加减某个小误差值的范围内。通常的做法是使用类似如下的测试: |
|---|
if *`Value1`* >= (*`Value2`*-*`error`*) and *`Value1`* <= (*`Value2`*+*`error`*) then ...
| 处理同样比较的另一种常见方式是使用类似以下形式的语句 |
|---|
if abs(*`Value1`*-*`Value2`*) <= *`error`* then ...
在选择error的值时必须小心。这个值应该略大于你计算中可能出现的最大误差值。具体的值取决于你使用的浮动点格式,但稍后会详细说明。这里是我们在本节中给出的最终规则: |
|---|
比较两个浮动点数值时,总是比较一个值,看它是否在由另一个值加减某个小误差值的范围内。
使用浮动点值时,可能会遇到许多其他小问题。本文仅能指出一些主要问题,并提醒你不能像对待实际算术运算那样处理浮动点算术——有限精度算术中的不准确性可能会让你陷入麻烦,如果你不小心的话。一本关于数值分析的好书,甚至是科学计算的书,可以帮助你填补本文无法涉及的细节。如果你打算使用浮动点算术,无论是什么语言,你应该花时间研究有限精度算术对计算结果的影响。
HLA 的if语句不支持涉及浮动点操作数的布尔表达式。因此,你不能在程序中使用像if( x < 3.141) then...这样的语句。第六章会教你如何进行浮动点比较。 |
|---|
2.12.1 IEEE 浮动点格式
当英特尔计划为其新的 8086 微处理器引入浮点单元时,它足够聪明地意识到,设计芯片的电气工程师和固态物理学家可能并不是选择最佳浮点格式的最佳人选。因此,英特尔聘请了最好的数值分析师来为其 8087 浮点处理单元设计浮点格式。这个人随后又聘请了两位领域专家,三人(Kahn、Coonan 和 Stone)共同设计了英特尔的浮点格式。他们设计的 KCS 浮点标准如此成功,以至于 IEEE 组织采纳了这一格式作为 IEEE 浮点格式。^([27])
为了处理各种性能和精度要求,英特尔实际上引入了三种浮点格式:单精度、双精度和扩展精度。单精度和双精度格式对应于 C 语言中的 float 和 double 类型,或者 FORTRAN 中的 real 和 double-precision 类型。英特尔计划使用扩展精度来处理长链式计算。扩展精度包含 16 个额外的位,这些位可以作为保护位,在将计算结果四舍五入到双精度值时使用。
单精度格式使用补码 24 位尾数和8 位过量 127 指数。尾数通常表示从 1.0 到接近 2.0 之间的一个值。尾数的高位(H.O.位)始终假定为 1,并表示二进制点左边的一个值。剩余的 23 个位则出现在二进制点的右侧。因此,尾数表示的值为
1.mmmmmmm mmmmmmmm mmmmmmmm
mmmm字符代表尾数的 23 位。请记住,我们在这里处理的是二进制数。因此,二进制点右侧的每个位置表示一个值(0 或 1)乘以 2 的连续负次幂。隐含的 1 位总是乘以 2⁰,也就是 1。这就是为什么尾数始终大于或等于 1 的原因。即使其他尾数位都是 0,隐含的 1 位也总是给我们带来值 1^([29])。当然,即使我们在二进制点后有几乎无限多个 1 位,它们仍然加起来不会达到 2。这就是为什么尾数可以表示从 1 到接近 2 之间的值。
尽管 1 和 2 之间存在无限多个值,但我们只能表示其中的 800 万个值,因为我们使用了 23 位尾数(第 24 位始终为 1)。这就是浮点运算不准确的原因——在涉及单精度浮点值的计算时,我们只能使用 23 位的精度。
尾数使用的是一补码格式,而不是二补码。这意味着尾数的 24 位值只是一个无符号二进制数,符号位决定该值是正数还是负数。一补码数有一个不寻常的属性,那就是 0 有两种表示方式(符号位可以是设置的或清除的)。通常,这对于设计浮点软件或硬件系统的人来说是重要的。我们假设值 0 总是符号位清除的。
为了表示 1.0 到接近 2.0 范围之外的值,浮点格式的指数部分发挥了作用。浮点格式将 2 的指数次方与尾数相乘,得到最终结果。指数为 8 位,并以超 127格式存储。在超 127 格式中,指数 2⁰由值 127($7F)表示。因此,要将一个指数转换为超 127 格式,只需将 127 加到该指数值上。使用超 127 格式使得浮点数值比较更加容易。单精度浮点格式的形式如图 2-22 所示。

图 2-22. 单精度(32 位)浮点格式
使用 24 位尾数,你将得到大约 6 ½位的精度(½位精度意味着前六位数字可以在 0 到 9 的范围内,但第七位只能在 0 到x的范围内,其中x < 9,并且通常接近 5)。使用一个 8 位的超 127 指数,单精度浮点数的动态范围大约为 2 ± 128,或者约为 10 ± 38。
尽管单精度浮点数在许多应用中非常适用,但其动态范围有限,不适用于许多金融、科学及其他应用。此外,在长时间的计算链中,单精度格式的有限精度可能会引入严重的误差。
双精度格式有助于克服单精度浮点数的问题。使用双倍空间,双精度格式具有一个 11 位的超 1023 指数和一个 53 位的尾数(带有一个隐含的高阶位 1)以及一个符号位。这提供了大约 10^(±308)的动态范围和 14 ½位的精度,足以满足大多数应用需求。双精度浮点数的形式如图 2-23 所示。

图 2-23. 64 位双精度浮点格式
为了帮助确保在涉及双精度浮点数的长链计算过程中精度的准确性,Intel 设计了扩展精度格式。扩展精度格式使用 80 位。额外的 16 位中,有 12 位附加到尾数,4 位附加到指数的末尾。与单精度和双精度值不同,扩展精度格式的尾数没有隐含的 H.O. 位(该位始终为 1)。因此,扩展精度格式提供了一个 64 位的尾数、一个 15 位的超常指数(excess-16383)和一个 1 位的符号位。扩展精度浮点值的格式如 图 2-24 所示。

图 2-24. 80 位扩展精度浮点格式
在浮点运算单元(FPU)中,所有的计算都使用扩展精度格式进行。每当你加载一个单精度或双精度值时,FPU 会自动将其转换为扩展精度值。同样,当你将一个单精度或双精度值存储到内存时,FPU 会在存储之前自动将该值向下舍入到适当的大小。通过始终使用扩展精度格式,Intel 确保有大量的保护位,以确保计算的准确性。
为了在计算过程中保持最大的精度,大多数计算都使用标准化值。标准化浮点值是指其 H.O. 尾数位为 1 的值。几乎任何非标准化值都可以被标准化;将尾数位向左移动,并递减指数,直到尾数的 H.O. 位变为 1。记住,指数是二进制指数。每次递增指数时,浮点值都会乘以 2。同样,每次递减指数时,浮点值都会除以 2。因此,将尾数向左移动一位也会将浮点值乘以 2;同样,将尾数向右移动则会将浮点值除以 2。因此,将尾数向左移动一位并递减指数完全不会改变浮点数的值。
保持浮点数的标准化是有益的,因为它保持了计算的最大精度位数。如果尾数的 H.O. 位全为 0,则尾数的有效位数就会减少。由此,涉及标准化值的浮点计算会更精确。
有两个重要的情况,在这些情况下浮点数无法归一化。零是其中一种特殊情况。显然它无法归一化,因为 0 的浮点表示法在尾数中没有 1 位。然而,这不是问题,因为我们可以仅用一个比特精确表示值 0。
第二种情况是当尾数中有一些高阶位是 0,而偏置指数也为 0 时(并且我们不能减少它来归一化尾数)。为了不禁止某些小值,这些小值的尾数高阶位和偏置指数都是 0(这是最小的偏置指数),IEEE 标准允许使用特殊的 非规格化 值来表示这些较小的值。^([30]) 尽管使用非规格化值可以使 IEEE 浮点运算产生比发生下溢时更好的结果,但请记住,非规格化值提供的精度比特较少。
2.12.2 HLA 对浮点值的支持
HLA 提供了多种数据类型和库例程,以支持在汇编语言程序中使用浮点数据。这些包括内置类型来声明浮点变量,以及提供浮点输入、输出和转换的例程。
讨论 HLA 的浮点功能时,最好的起点可能是描述浮点字面常量。HLA 浮点常量允许以下语法:
-
一个可选的
+或−符号,表示尾数的符号(如果没有此符号,HLA 默认认为尾数为正) -
后跟一个或多个小数位数字
-
可选地跟随小数点和一个或多个小数位数字
-
可选地跟随
e或E,可选地跟随符号(+或−)和一个或多个小数位数字
请注意,必须存在小数点或 e/E,以区分该值与整数或无符号字面常量。以下是一些合法的浮点字面常量示例:
1.234 3.75e2 −1.0 1.1e-1 1e+4 0.1 −123.456e+789 +25e0
请注意,浮点字面常量不能以小数点开头;它必须以十进制数字开头,因此你必须使用 0.1 来表示 .1。
HLA 还允许在浮点字面常量中的任意两个连续十进制数字之间插入下划线字符(_)。你可以使用下划线字符代替逗号(或其他语言特定的分隔符)来帮助使大浮点数字更易于阅读。以下是一些示例:
1_234_837.25 1_000.00 789_934.99 9_999.99
要声明一个浮动点变量,可以使用real32、real64或real80数据类型。与它们的整数和无符号兄弟类似,这些数据类型声明末尾的数字指定了每种类型二进制表示中使用的位数。因此,使用real32声明单精度浮动点值,使用real64声明双精度浮动点值,使用real80声明扩展精度浮动点值。除了需要使用这些类型声明浮动点变量而不是整数外,它们的使用与int8、int16、int32等几乎完全相同。以下示例展示了这些声明及其语法:
static
fltVar1: real32;
fltVar1a: real32 := 2.7;
pi: real32 := 3.14159;
DblVar: real64;
DblVar2: real64 := 1.23456789e+10;
XPVar: real80;
XPVar2: real80 := −1.0e-104;
要以 ASCII 格式输出一个浮动点变量,可以使用stdout.putr32、stdout.putr64或stdout.putr80函数。这些程序会以十进制表示一个数字,即一串数字、可选的小数点以及结尾的一串数字。除了名称不同之外,这三个函数的调用方式完全相同。以下是每个函数的调用和参数:
stdout.putr80( r:real80; width:uns32; decpts:uns32 );
stdout.putr64( r:real64; width:uns32; decpts:uns32 );
stdout.putr32( r:real32; width:uns32; decpts:uns32 );
这些程序的第一个参数是您希望打印的浮动点值。此参数的大小必须与程序名称相匹配(例如,在调用stdout.putr80程序时,r参数必须是 80 位的扩展精度浮动点变量)。第二个参数指定输出文本的字段宽度;即当程序显示数字时,数字所需的打印位置数。请注意,这个宽度必须包括数字的符号和小数点的打印位置。第三个参数指定小数点后打印位置的数量。例如:
stdout.putr32( pi, 10, 4 );
显示值
_ _ _ _ 3.1416
(下划线表示此示例中的前导空格)。
当然,如果数字非常大或非常小,您会希望使用科学计数法而不是十进制表示来输出浮动点数字。HLA 标准库中的stdout.pute32、stdout.pute64和stdout.pute80函数提供了这个功能。这些函数使用以下程序原型:
stdout.pute80( r:real80; width:uns32 );
stdout.pute64( r:real64; width:uns32 );
stdout.pute32( r:real32; width:uns32 );
与十进制输出函数不同,这些科学计数法输出函数不需要第三个参数来指定显示小数点后要显示的位数。width参数间接地指定了这个值,因为除了一个尾数数字,所有尾数数字总是出现在小数点的右边。这些函数将它们的值以十进制表示输出,类似如下:
1.23456789e+10 −1.0e-104 1e+2
你还可以使用 HLA 标准库的stdout.put例程来输出浮动点值。如果你在stdout.put参数列表中指定了浮动点变量的名称,stdout.put代码将使用科学计数法输出该值。实际的字段宽度会根据浮动点变量的大小有所不同(stdout.put例程尽力输出尽可能多的有效数字)。以下是一个示例:
stdout.put( "XPVar2 = ", XPVar2 );
如果你指定了字段宽度,使用冒号后跟一个带符号的整数值,那么stdout.put例程将使用相应的stdout.puteXX例程来显示该值。也就是说,数字仍然以科学计数法显示,但你可以控制输出值的字段宽度。像整数和无符号值的字段宽度一样,正数字段宽度会将数字右对齐,而负数字段宽度则将数字左对齐。
下面是一个使用 10 个打印位置打印XPVar2变量的示例:
stdout.put( "XPVar2 = ", XPVar2:10 );
如果你希望使用stdout.put以十进制表示法打印浮动点值,你需要使用以下语法:
*`Variable_Name`* : *`Width`* : *`DecPts`*
请注意,DecPts字段必须是一个非负整数值。
当stdout.put包含这种形式的参数时,它会调用相应的stdout.putrXX例程来显示指定的浮动点值。例如,考虑以下调用:
stdout.put( "Pi = ", pi:5:3 );
对应的输出是:
3.142
HLA 标准库提供了几个其他有用的例程,你可以在输出浮动点值时使用。有关这些例程的更多信息,请参阅 HLA 标准库参考手册。
HLA 标准库提供了多个例程,允许你以多种格式显示浮动点值。相比之下,HLA 标准库仅提供两个例程来支持浮动点输入:stdin.getf()和stdin.get()。stdin.getf()例程需要使用 80x86 FPU 堆栈,这是一个本章没有涉及的硬件组件。因此,我们将在第六章中推迟对stdin.getf()例程的讨论。由于stdin.get()例程提供了stdin.getf()例程的所有功能,这个推迟不会成为问题。
你已经看过stdin.get()例程的语法;它的参数列表仅包含变量名称的列表。stdin.get()函数会为每个出现在参数列表中的变量读取相应的值。如果你指定了一个浮动点变量的名称,stdin.get()例程会自动从用户处读取一个浮动点值,并将结果存储到指定的变量中。以下示例演示了该例程的使用:
stdout.put( "Input a double-precision floating-point value: " );
stdin.get( DblVar );
警告
本节讨论了如何声明浮点变量,以及如何输入和输出它们。并没有讨论算术运算。浮点算术与整数算术不同;你不能使用 80x86 的add和sub指令对浮点值进行操作。浮点算术将在第六章中进行讨论。
^([26]) 当然,缺点是你现在必须进行两次乘法运算,而不是一次,所以结果可能会更慢。
^([27]) 对某些特殊操作的处理方式做了一些小改动,但位表示基本保持不变。
^([28]) 二进制点与小数点相同,只不过它出现在二进制数中,而不是十进制数中。
^([29]) 实际上,这不一定是对的。IEEE 浮点格式支持非规格化值,其中 H.O.位不是 0。然而,我们在讨论中将忽略非规格化值。
^([30]) 另一种做法是将值下溢到 0。
2.13 二进制编码十进制表示
尽管整数和浮点格式涵盖了大多数平均程序的数字需求,但在某些特殊情况下,其他数字表示方法会更方便。在本节中,我们将讨论二进制编码十进制格式,因为 80x86 CPU 为这种数据表示提供了一小部分硬件支持。
BCD 值是一个字节序列,每个字节表示一个 0..9 范围内的值。当然,你也可以使用一个字节表示 0..15 范围内的值;然而,BCD 格式只使用了 16 个可能值中的 10 个来表示每个字节。
BCD 值中的每个字节表示一个单独的十进制数字。因此,使用一个字节(即两个数字),我们可以表示包含两个十进制数字的值,或者表示 0..99 范围内的值(见图 2-25)。使用一个字(word),我们可以表示包含四个十进制数字的值,或者表示 0..9,999 范围内的值。同样,使用一个双字(double word),我们可以表示最多包含八个十进制数字的值(因为一个双字值有八个字节)。

图 2-25. 内存中的 CD 数据表示
如你所见,BCD 存储并不是特别高效。例如,一个 8 位 BCD 变量可以表示 0..99 范围内的值,而同样的 8 位,当存储二进制值时,能够表示 0..255 范围内的值。同样,一个 16 位的二进制值可以表示 0..65,535 范围内的值,而一个 16 位的 BCD 值只能表示其中大约六分之一的值(0..9,999)。低效的存储并不是唯一的问题。BCD 计算往往比二进制计算更慢。
到目前为止,你可能在想,为什么有人会使用 BCD 格式。BCD 格式确实有两个优点:它可以非常容易地在内部数字表示和字符串表示之间转换 BCD 值;此外,使用 BCD 在硬件中(例如,通过旋转按钮或拨轮)编码多位十进制值也非常简单。基于这两个原因,你可能会看到在嵌入式系统中(如烤面包机、闹钟和核反应堆)使用 BCD,但在通用计算机软件中却很少见。
几十年前,人们错误地认为涉及 BCD(或十进制)算术的计算比二进制计算更准确。因此,他们常常使用基于十进制的算术进行重要的计算,如涉及美元和分(或其他货币单位)的计算。虽然某些计算在 BCD 中可能会得到更精确的结果,但这个说法并不普遍成立。事实上,对于大多数计算(即使是固定点十进制算术的计算),二进制表示更为准确。因此,大多数现代计算机程序都以二进制形式表示所有值。例如,Intel 80x86 浮点单元支持一对加载和存储 BCD 值的指令。然而,内部上,FPU 将这些 BCD 值转换为二进制并在二进制中执行所有计算。它仅将 BCD 作为外部数据格式(即外部于 FPU)使用。这通常能产生更准确的结果,并且比拥有一个支持十进制算术的独立协处理器需要的硅片要少得多。
2.14 字符
也许个人计算机上最重要的数据类型是字符数据类型。字符一词指的是一个人类或机器可读的符号,通常是一个非数字实体。一般而言,字符指的是任何你通常可以在键盘上输入(包括一些可能需要多次按键才能产生的符号)或在视频显示器上显示的符号。许多初学者常常混淆字符和字母字符这两个术语。这两个术语并不相同。标点符号、数字、空格、制表符、回车符(Enter)、其他控制字符以及其他特殊符号也是字符。当本文使用字符一词时,它指的是这些字符中的任何一个,而不仅仅是字母字符。当本文指的是字母字符时,它会使用“字母字符”、“大写字母字符”或“小写字母字符”等表述。
初学者在第一次接触字符数据类型时,另一个常见的问题是区分数字字符和数字。字符1与值 1 是不同的。计算机(通常)对数字字符(0、1、...、9)和数字值(0 到 9)使用两种不同的内部表示。你必须小心不要把这两者混淆。
大多数计算机系统使用 1 字节或 2 字节的序列以二进制形式编码各种字符。Windows、Mac OS X、FreeBSD 和 Linux 无疑都属于这一类,使用 ASCII 或 Unicode 编码表示字符。本节将讨论 ASCII 字符集和 HLA 提供的字符声明功能。
2.14.1 ASCII 字符编码
ASCII(美国信息交换标准代码)字符集将 128 个文本字符映射到无符号整数值 0..127($0..$7F)。当然,计算机内部使用二进制数字表示一切,因此计算机使用二进制值来表示非数字实体(如字符)并不令人惊讶。虽然字符到数值的具体映射是任意的且不重要,但使用标准化的代码进行映射是很重要的,因为你需要与其他程序和外设通信,并且你需要与这些程序和设备使用相同的“语言”。这就是 ASCII 代码发挥作用的地方;它是一个几乎所有人都达成一致的标准化代码。因此,如果你使用 ASCII 代码 65 来表示字符 'A',那么你知道,当你将数据传输到外设(如打印机)时,某个外设会正确地将此值解释为字符 'A'。
你不应该认为 ASCII 是计算机系统中唯一使用的字符集。IBM 在许多大型计算机系统上使用 EBCDIC 字符集系列。另一种常用的字符集是 Unicode 字符集。Unicode 是对 ASCII 字符集的扩展,它使用 16 位而不是 7 位来表示字符。这使得字符集可以包含 65,536 个不同的字符,从而将世界不同语言中的大多数符号纳入一个统一的字符集中。
因为 ASCII 字符集只提供 128 个不同的字符,而一个字节可以表示 256 个不同的值,所以就产生了一个有趣的问题:“我们应该如何处理可以存储在字节中的 128..255 的值?”一种答案是忽略这些额外的值。这将是本文的主要处理方式。另一种可能性是扩展 ASCII 字符集,向其中添加额外的 128 个字符。当然,除非你能够让每个人都同意这些扩展,否则这将违背拥有标准化字符集的初衷。这是一个艰难的任务。
当 IBM 首次推出 IBM-PC 时,它定义了这 128 个额外的字符代码,以包含各种非英语字母字符、一些绘图字符、一些数学符号以及其他一些特殊字符。因为 IBM 的 PC 是今天我们所称的 PC 的基础,所以这一字符集已经成为所有兼容 IBM-PC 的机器上的伪标准。即使是在现代机器上,这些机器并不兼容 IBM-PC,也不能运行早期的 PC 软件,IBM 扩展字符集仍然存在。然而,需要注意的是,这个 PC 字符集(ASCII 字符集的扩展)并不是通用的。大多数打印机在使用本地字体时不会打印扩展字符,许多程序(特别是在非英语国家)也不使用那些字符来表示 8 位值中的前 128 个代码。由于这些原因,本文将主要使用标准的 128 字符 ASCII 字符集。
尽管它是一个标准,但仅仅使用标准的 ASCII 字符编码你的数据并不能保证在不同系统之间的兼容性。虽然在一台机器上字符 'A' 很可能在另一台机器上也是 'A',但在控制字符的使用上,各机器之间几乎没有标准化。实际上,在 32 个控制代码加上删除符号中,只有四个控制代码被广泛支持——退格(BS)、制表符、回车(CR)和换行(LF)。更糟糕的是,不同的机器通常以不同的方式使用这些控制代码。行结束是一个特别麻烦的例子。Windows、MS-DOS、CP/M 和其他系统通过两个字符的序列 CR/LF 来标记行结束。旧款 Apple Macintosh 计算机(Mac OS 9 及更早版本)和许多其他系统则通过单一的 CR 字符来标记行结束。Linux、Mac OS X、FreeBSD 和其他 Unix 系统则通过单一的 LF 字符标记行结束。不用说,在这些系统之间交换简单的文本文件时,可能会让人感到非常沮丧。即使你在这些系统中的所有文件中使用标准的 ASCII 字符,你在交换文件时仍然需要进行数据转换。幸运的是,这种转换相对简单。
尽管存在一些重大缺陷,ASCII 数据仍然是计算机系统和程序之间数据交换的标准。大多数程序都可以接受 ASCII 数据;同样,大多数程序也能生成 ASCII 数据。由于你在汇编语言中将处理 ASCII 字符,建议你研究字符集的布局,并记住一些关键的 ASCII 代码(例如,字符 '0'、'A'、'a' 等的代码)。
ASCII 字符集被分为四个 32 字符的组。前 32 个字符,ASCII 码 0..$1F(31),形成一个特殊的非打印字符集,称为控制字符。我们称它们为控制字符,因为它们执行各种打印机/显示控制操作,而不是显示符号。例子包括回车符,它将光标定位到当前行的左侧;^([31])换行符,它将光标向下移动一行;以及退格符,它将光标向左移动一个位置。不幸的是,不同的控制字符在不同的输出设备上执行不同的操作。输出设备之间的标准化程度非常低。要准确了解控制字符如何影响特定设备,你需要查阅其手册。
第二组 32 个 ASCII 字符代码包含各种标点符号、特殊字符和数字。该组中最显著的字符包括空格符(ASCII 码$20)和数字(ASCII 码$30..$39)。
第三组 32 个 ASCII 字符包含大写字母字符。字符'A'..'Z'的 ASCII 码范围是$41..$5A(65..90)。由于只有 26 个不同的字母字符,其余 6 个代码则包含各种特殊符号。
第四组,也是最后一组 32 个 ASCII 字符代码,表示小写字母符号、5 个附加的特殊符号和另一个控制字符(删除)。注意,小写字母符号使用 ASCII 码$61..$7A。如果你将大小写字母的代码转换为二进制,你会注意到大写符号和其对应的小写符号只有一个位不同。例如,考虑图 2-26 中显示的字符代码'E'和'e'。图 2-26。

图 2-26. E 和 e 的 ASCII 码
这两个代码唯一不同的地方是在第 5 位。大写字母的第 5 位总是 0;小写字母的第 5 位总是 1。你可以利用这一点快速地进行大小写转换。如果你有一个大写字母,可以通过将第 5 位设置为 1 将其转换为小写字母。如果你有一个小写字母,并且希望将其强制转换为大写字母,你可以通过将第 5 位设置为 0 来实现。你可以通过简单地反转第 5 位来在大写字母和小写字母之间切换。
实际上,第 5 位和第 6 位决定了你所在的 ASCII 字符集中的四个组中的哪一个,正如表 2-8 所示。
表 2-8. ASCII 组
| 第 6 位 | 第 5 位 | 组 |
|---|---|---|
| 0 | 0 | 控制字符 |
| 0 | 1 | 数字和标点符号 |
| 1 | 0 | 大写字母和特殊符号 |
| 1 | 1 | 小写字母和特殊字符 |
比如,你可以通过将第 5 和第 6 位设置为 0,将任何大写或小写字母(或相应的特殊字符)转换为其等效的控制字符。
试想一下,表 2-9 中出现的数字字符的 ASCII 代码。
表 2-9:数字字符的 ASCII 代码
| 字符 | 十进制 | 十六进制 |
|---|---|---|
| 0 | 48 | $30 |
| 1 | 49 | $31 |
| 2 | 50 | $32 |
| 3 | 51 | $33 |
| 4 | 52 | $34 |
| 5 | 53 | $35 |
| 6 | 54 | $36 |
| 7 | 55 | $37 |
| 8 | 56 | $38 |
| 9 | 57 | $39 |
这些 ASCII 代码的十进制表示并不非常直观。然而,这些 ASCII 代码的十六进制表示揭示了一个非常重要的内容——ASCII 代码的低位字节(L.O. nibble)是所表示数字的二进制等效值。通过去除(即设置为 0)数字字符的高位字节,你可以将该字符代码转换为对应的二进制表示。相反,你也可以通过简单地将高位字节设置为 3,将 0..9 范围内的二进制值转换为其 ASCII 字符表示。请注意,你可以使用逻辑and操作将高位字节强制设置为 0;同样,你也可以使用逻辑or操作将高位字节强制设置为%0011(3)。
请注意,你不能通过仅仅去除字符串中每个数字的高位(H.O.)部分,将数字字符字符串转换为其等效的二进制表示。例如,按这种方式转换 123($31 $32 $33)会得到 3 个字节:$010203;而 123 的正确值是$7B。将一串数字转换为整数比这更复杂;上面的转换方法仅适用于单个数字。
2.14.2 HLA 对 ASCII 字符的支持
虽然你可以轻松地将字符值存储在byte变量中,并在程序中使用相应的数字等效 ASCII 代码作为字符字面量,但这种麻烦其实是没有必要的。HLA 提供了对字符变量和字面量的支持,方便你在汇编语言程序中使用。
在 HLA 中,字符字面量常量有两种形式:一个被撇号包围的单个字符,或者一个井号(#)后跟一个在 0..127 范围内的数字常量(表示该字符的 ASCII 代码)。以下是一些示例:
'A' #65 #$41 #%0100_0001
请注意,这些例子都表示相同的字符('A'),因为'A'的 ASCII 代码是 65。
除一个例外外,字面量字符常量中只能包含单个字符。这个例外是撇号字符本身。如果你想创建一个撇号字面量常量,需要连续四个撇号(即在两个撇号中间重复一个撇号):
''''
井号运算符(#)必须放在一个合法的 HLA 数值常量之前(无论是十进制、十六进制还是二进制,如上述示例所示)。特别地,井号并不是一个通用的字符转换函数;它不能放在寄存器或变量名之前,只能放在常量之前。
一般来说,你应该始终使用字符文字常量的撇号形式来表示图形字符(即那些可打印或可显示的字符)。对于控制字符(即那些不可见或打印时有奇怪效果的字符),或者对于可能无法在源代码中正确显示或打印的扩展 ASCII 字符,应使用井号形式。
请注意程序中字符文字常量和字符串文字常量之间的区别。字符串是由零个或多个字符组成,并用引号括起来;字符则用撇号括起来。
特别重要的是要意识到
'A' ≠ "A"
字符常量'A'和包含单个字符A的字符串有两种完全不同的内部表示。如果你尝试在 HLA 期望字符常量的地方使用包含单个字符的字符串,HLA 会报告一个错误。字符串和字符串常量的内容见第四章。
在 HLA 程序中声明字符变量时,你使用char数据类型。例如,以下声明演示了如何声明一个名为UserInput的变量:
static
UserInput: char;
这个声明预留了 1 字节的存储空间,你可以用来存储任何字符值(包括 8 位扩展 ASCII 字符)。你还可以像以下示例所示那样初始化字符变量:
static
TheCharA: char := 'A';
ExtendedChar: char := #128;
因为字符变量是 8 位对象,所以你可以使用 8 位寄存器来操作它们。你可以将字符变量移动到 8 位寄存器中,并可以将 8 位寄存器的值存储到字符变量中。
HLA 标准库提供了一些例程,可以用于字符输入输出和操作;其中包括stdout.putc、stdout.putcSize、stdout.put、stdin.getc和stdin.get。
stdout.putc例程使用以下调用顺序:
stdout.putc( *`charvar`* );
这个过程将传递给它的单字符参数作为字符输出到标准输出设备。该参数可以是任何char常量或变量,或者是byte变量或寄存器。^([32])
stdout.putcSize例程提供了输出宽度控制,用于显示字符变量。该过程的调用顺序如下:
stdout.putcSize( *`charvar`*, *`widthInt32`*, *`fillchar`* );
该例程使用至少 widthInt32 打印位置打印指定的字符(参数 c)。^([33]) 如果 widthInt32 的绝对值大于 1,则 stdout.putcSize 将打印 fillchar 字符作为填充。如果 widthInt32 的值为正,则 stdout.putcSize 会将字符右对齐打印在打印字段中;如果 widthInt32 为负,则 stdout.putcSize 会将字符左对齐打印在打印字段中。由于字符输出通常是左对齐的,通常这个调用的 widthInt32 值会是负数。空格字符是最常见的 fillchar 值。
你也可以使用通用的 stdout.put 例程打印字符值。如果字符变量出现在 stdout.put 的参数列表中,则 stdout.put 会自动将其作为字符值打印。例如:
stdout.put( "Character c = '", c, "'", nl );
你可以使用 stdin.getc 和 stdin.get 例程从标准输入读取字符。stdin.getc 例程没有任何参数。它从标准输入缓冲区读取一个字符,并将该字符返回在 AL 寄存器中。你可以将字符值存储或在 AL 寄存器中进行其他操作。示例 2-10 中的程序从用户读取一个字符,如果它是小写字母,则将其转换为大写字母,然后显示该字符。
示例 2-10. 字符输入示例
program charInputDemo;
#include( "stdlib.hhf" )
begin charInputDemo;
stdout.put( "Enter a character: " );
stdin.getc();
if( al >= 'a' ) then
if( al <= 'z' ) then
and( $5f, al );
endif;
endif;
stdout.put
(
"The character you entered, possibly ", nl,
"converted to uppercase, was '"
);
stdout.putc( al );
stdout.put( "'", nl );
end charInputDemo;
你还可以使用通用的 stdin.get 例程从用户处读取字符变量。如果 stdin.get 的参数是字符变量,则 stdin.get 例程会从用户读取一个字符,并将字符值存储到指定的变量中。示例 2-11 是使用 stdin.get 例程重写的 示例 2-10。
示例 2-11. stdin.get 字符输入示例
program charInputDemo2;
#include( "stdlib.hhf" )
static
c:char;
begin charInputDemo2;
stdout.put( "Enter a character: " );
stdin.get(c);
if( c >= 'a' ) then
if( c <= 'z' ) then
and( $5f, c );
endif;
endif;
stdout.put
(
"The character you entered, possibly ", nl,
"converted to uppercase, was '",
c,
"'", nl
);
end charInputDemo2;
正如你在上一章中所回忆的,HLA 标准库会缓冲其输入。每当你使用 stdin.getc 或 stdin.get 从标准输入读取字符时,库例程会从缓冲区读取下一个可用的字符;如果缓冲区为空,程序会从用户读取一行新的文本,并返回该行的第一个字符。如果你希望程序在读取字符变量时保证从用户那里读取到一行新文本,你应该在尝试读取字符之前调用 stdin.flushInput 例程。这样会刷新当前的输入缓冲区,并强制在下一个输入中读取一行新文本(可能是 stdin.getc 或 stdin.get 调用)。
行尾是一个有问题的地方。不同的操作系统在输出和输入时处理行尾的方式不同。在控制台设备上,按下回车键表示行尾;然而,当从文件读取数据时,你会得到一个行尾序列,它是换行符或者回车符/换行符对(在 Windows 下)或仅仅是换行符(在 Linux/Mac OS X/FreeBSD 下)。为了帮助解决这个问题,HLA 标准库提供了一个“行尾”函数。这个过程在 AL 寄存器中返回 true (1),如果当前的所有输入字符已经耗尽;否则返回 false (0)。示例 2-12 中的示例程序演示了 stdin.eoln 函数。
示例 2-12. 使用 stdin.eoln 检测行尾
program eolnDemo;
#include( "stdlib.hhf" )
begin eolnDemo;
stdout.put( "Enter a short line of text: " );
stdin.flushInput();
repeat
stdin.getc();
stdout.putc( al );
stdout.put( "=$", al, nl );
until( stdin.eoln() );
end eolnDemo;
HLA 语言和 HLA 标准库提供了许多其他过程以及对字符对象的额外支持。第四章和第十一章以及 HLA 参考文档详细描述了如何使用这些功能。
^([31]) 历史上,回车指的是打字机上的纸架。回车指的是将纸架完全移动到右边,这样下一个输入的字符就会出现在纸的左边。
^([32]) 如果你指定了一个字节变量或字节大小的寄存器作为参数,stdout.putc 例程将输出该变量或寄存器中显示的 ASCII 码对应的字符。
^([33]) 只有在你指定 0 为宽度时,stdout.putcSize 才会使用比你指定更多的打印位置;此时此例程将使用一个打印位置。
2.15 Unicode 字符集
尽管 ASCII 字符集无疑是计算机中最流行的字符表示形式,但它并不是唯一的格式。例如,IBM 在许多大型主机和小型计算机系列上使用 EBCDIC 码。由于 EBCDIC 主要出现在 IBM 的大型机上,并且你在个人计算机系统中很少遇到它,我们在本文中将不考虑这个字符集。另一个在小型计算机系统(以及大型系统)中日益流行的字符表示法是 Unicode 字符集。Unicode 克服了 ASCII 两个最大的限制:有限的字符空间(即,8 位字节中最多 128/256 个字符)和缺乏国际化(美国以外的)字符。
Unicode 使用 16 位字来表示单个字符。因此,Unicode 支持多达 65,536 个不同的字符编码。这显然比我们用 8 位字节表示的 256 种可能的编码要先进得多。Unicode 是向上兼容 ASCII 的。具体来说,如果 Unicode 字符的高 9 位包含 0,则低 7 位表示与具有相同字符编码的 ASCII 字符相同的字符。如果高 9 位包含非零值,则该字符表示其他值。如果你想知道为什么需要这么多不同的字符编码,只需注意某些亚洲字符集包含 4,096 个字符(至少它们的 Unicode 子集包含)。
本文将坚持使用 ASCII 字符集,除了偶尔提到 Unicode 外。最终,由于许多新操作系统内部使用 Unicode(并根据需要转换为 ASCII),本文可能需要取消 ASCII 的讨论,转而使用 Unicode。不幸的是,许多字符串算法对 Unicode 的适应性不如对 ASCII 方便(特别是字符集函数),因此我们将尽可能长时间地坚持使用 ASCII。
2.16 更多信息
本书的电子版(在 Webster 网站上 webster.cs.ucr.edu/ 或 artofasm.com/)包含一些关于数据表示的额外信息,可能对您有所帮助。关于数据表示的一般信息,您可以考虑阅读我的书 Write Great Code, Volume 1(No Starch Press, 2004 年出版),或者一本关于数据结构和算法的教科书(任何书店都有售)。
第三章 内存访问与组织

第一章和第二章展示了如何在汇编语言程序中声明和访问简单变量。本章将全面解释 80x86 内存访问。你将学习如何高效地组织变量声明,以加速对数据的访问。本章将教授你 80x86 栈及如何在栈上操作数据。最后,本章将讲解动态内存分配和 堆。
本章讨论了几个重要的概念,包括:
-
80x86 内存寻址模式
-
索引寻址和缩放索引寻址模式
-
内存组织
-
程序分配内存
-
数据类型强制转换
-
80x86 栈
-
动态内存分配
本章将教你如何高效利用计算机的内存资源。
3.1 80x86 寻址模式
80x86 处理器允许你以多种不同方式访问内存。到目前为止,你只见过一种访问变量的方式,即所谓的 仅位移 寻址模式。在本节中,你将看到一些额外的方式,如何通过 80x86 内存寻址模式 来访问内存。80x86 内存寻址模式提供了灵活的内存访问方式,使你能够轻松访问变量、数组、记录、指针和其他复杂数据类型。掌握 80x86 寻址模式是掌握 80x86 汇编语言的第一步。
当英特尔设计最初的 8086 处理器时,它为处理器提供了灵活但有限的内存寻址模式。英特尔在推出 80386 微处理器时添加了几种新的寻址模式。然而,在 Windows、Mac OS X、FreeBSD 和 Linux 等 32 位环境中,这些早期的寻址模式并不是很有用;实际上,HLA 甚至不支持使用这些旧的、仅限 16 位的寻址模式。幸运的是,任何可以通过旧的寻址模式完成的事情,都可以通过新的寻址模式来实现。因此,在为当今高性能操作系统编写代码时,你不需要浪费时间学习旧的 16 位寻址模式。但请记住,如果你打算在 MS-DOS 或其他 16 位操作系统下工作,你仍然需要研究这些旧的寻址模式(有关详细信息,请参阅本书的 16 位版本:webster.cs.ucr.edu/)。
3.1.1 80x86 寄存器寻址模式
大多数 80x86 指令可以在 80x86 的通用寄存器集中操作。通过指定寄存器的名称作为操作数,你可以访问该寄存器的内容。考虑 80x86 的 mov(移动)指令:
mov( *`source`*, *`destination`* );
这条指令将数据从源操作数复制到目标操作数。8 位、16 位和 32 位寄存器当然是该指令有效的操作数。唯一的限制是两个操作数必须具有相同的大小。现在让我们来看一些实际的 80x86 mov指令:
mov( bx, ax ); // Copies the value from bx into ax
mov( al, dl ); // Copies the value from al into dl
mov( edx, esi ); // Copies the value from edx into esi
mov( bp, sp ); // Copies the value from bp into sp
mov( cl, dh ); // Copies the value from cl into dh
mov( ax, ax ); // Yes, this is legal!
寄存器是存储变量的最佳位置。使用寄存器的指令比访问内存的指令更简短、更快速。当然,大多数计算至少需要一个寄存器操作数,因此寄存器寻址模式在 80x86 汇编代码中非常流行。
3.1.2 80x86 32 位内存寻址模式
80x86 提供了数百种不同的内存访问方式。刚开始看起来可能很多,但幸运的是,大多数寻址模式只是彼此简单的变体,因此它们非常容易学习。而且你确实应该学会它们!良好的汇编语言编程的关键是正确使用内存寻址模式。
80x86 系列提供的寻址模式包括仅位移、基址、位移加基址、基址加索引和位移加基址加索引。这五种形式的变体提供了 80x86 上的所有不同寻址模式。看,从数百种变成了五种。其实并不那么复杂!
3.1.2.1 仅位移寻址模式
最常见的寻址模式,也是最容易理解的,是仅位移(或直接)寻址模式。仅位移寻址模式由一个 32 位常量组成,指定目标位置的地址。假设变量j是一个出现在地址$8088 的int8变量,指令mov( j, al );将 AL 寄存器加载为位于内存地址$8088 的字节的副本。同样,如果int8变量k位于内存地址$1234,则指令mov( dl, k );将 DL 寄存器中的值存储到内存地址$1234(参见图 3-1)。

图 3-1。仅位移(直接)寻址模式
仅位移寻址模式非常适合访问简单的标量变量。之所以称其为仅位移寻址模式,是因为一个 32 位常量(位移)跟随在mov操作码后面存储在内存中。在 80x86 处理器中,这个位移是从内存起始地址(即地址 0)开始的偏移量。本章中的示例通常访问内存中的字节。然而,别忘了,你也可以通过指定其第一个字节的地址来访问 80x86 处理器上的字和双字(参见图 3-2)。

图 3-2. 使用仅位移寻址模式访问字或双字
3.1.2.2 寄存器间接寻址模式
80x86 CPU 让你通过寄存器间接寻址模式间接访问内存。术语间接意味着操作数不是实际的地址,而是操作数的值指定要使用的内存地址。在寄存器间接寻址模式的情况下,寄存器中保存的值是要访问的内存位置的地址。例如,指令mov( eax, [ebx] );告诉 CPU 将 EAX 的值存储到地址在 EBX 中的位置(EBX 周围的方括号告诉 HLA 使用寄存器间接寻址模式)。
在 80x86 上有八种这种寻址模式。以下指令是这八种形式的示例:
mov( [eax], al );
mov( [ebx], al );
mov( [ecx], al );
mov( [edx], al );
mov( [edi], al );
mov( [esi], al );
mov( [ebp], al );
mov( [esp], al );
这八种寻址模式通过方括号中的寄存器(EAX、EBX、ECX、EDX、EDI、ESI、EBP 或 ESP)找到的偏移量引用内存位置。
请注意,寄存器间接寻址模式要求使用 32 位寄存器。使用间接寻址模式时,不能指定 16 位或 8 位寄存器。^([34]) 从技术上讲,你可以将一个 32 位寄存器加载任意数值,并使用寄存器间接寻址模式间接访问该位置:
mov( $1234_5678, ebx );
mov( [ebx], al ); // Attempts to access location $1234_5678.
不幸的是(或者幸运的是,这取决于你如何看待它),这可能会导致操作系统生成保护错误,因为并非总是合法访问任意内存位置。事实证明,有更好的方法将某些对象的地址加载到寄存器中;你稍后会看到如何操作。
寄存器间接寻址模式有许多用途。你可以用它们来访问由指针引用的数据,也可以用它们来遍历数组数据,通常,你可以在程序运行时需要修改变量地址时使用它们。
寄存器间接寻址模式提供了一个匿名变量的例子。当使用寄存器间接寻址模式时,你是通过变量的数值内存地址(例如,加载到寄存器中的值)而不是变量的名称来引用变量的值——因此有了匿名变量这一说法。
HLA 提供了一个简单的操作符,你可以用它获取一个static变量的地址,并将该地址放入一个 32 位寄存器中。这就是&(地址操作符)操作符(注意,这与 C/C++ 中的地址操作符符号相同)。以下示例将变量 j 的地址加载到 EBX 中,然后使用寄存器间接寻址模式将 EAX 的当前值存储到 j 中:
mov( &j, ebx ); // Load address of j into ebx.
mov( eax, [ebx] ); // Store eax into j.
当然,直接将 EAX 的值存储到j中比使用两条指令间接完成这项操作要简单。然而,你可以很容易地想象出一个代码序列,在该序列中,程序在执行mov( eax, [ebx]);语句之前,会将多个不同的地址之一加载到 EBX 中,从而根据程序的执行路径将 EAX 存储到多个不同的位置。
警告
&(取地址)操作符不像 C/C++中的&操作符那样是通用的取地址操作符。你只能将此操作符应用于静态变量。^([35]) 你不能将它应用于通用的地址表达式或其他类型的变量。在 3.13 获取内存对象的地址中,你将了解加载有效地址指令,它为获取内存中某个变量的地址提供了通用的解决方案。
3.1.2.3 索引寻址模式
索引寻址模式使用以下语法:
mov( *`VarName`*[ eax ], al );
mov( *`VarName`*[ ebx ], al );
mov( *`VarName`*[ ecx ], al );
mov( *`VarName`*[ edx ], al );
mov( *`VarName`*[ edi ], al );
mov( *`VarName`*[ esi ], al );
mov( *`VarName`*[ ebp ], al );
mov( *`VarName`*[ esp ], al );
VarName 是你程序中某个变量的名称。
索引寻址模式通过将变量的地址加到出现在方括号内的 32 位寄存器的值上来计算有效地址^([36])。它们的和即为指令访问的实际内存地址。因此,如果VarName位于内存地址$1100,而 EBX 中包含 8,那么mov(VarName[ ebx ], al);会将地址$1108 处的字节加载到 AL 寄存器中(见图 3-3)。

图 3-3. 索引寻址模式
索引寻址模式对于访问数组元素非常方便。你将在第四章中看到如何使用这些寻址模式来完成这一任务。
3.1.2.4 索引寻址模式的变体
索引寻址模式有两种重要的语法变体。两种形式生成相同的基本机器指令,但它们的语法暗示了这些变体的其他用途。
第一种变体使用以下语法:
mov( [ ebx + *`constant`* ], al );
mov( [ ebx - *`constant`* ], al );
这些示例只使用了 EBX 寄存器。然而,你可以使用任何其他 32 位通用寄存器来代替 EBX。这种形式通过将 EBX 中的值加到指定常量上,或者将指定常量从 EBX 中减去来计算其有效地址(见图 3-4 和图 3-5)。

图 3-4. 使用寄存器加常量的索引寻址模式

图 3-5. 使用寄存器减去常数的索引寻址模式
这种寻址模式的特定变体在 32 位寄存器包含多字节对象的基地址时非常有用,且你希望访问该位置之前或之后某个字节的内存位置。这种寻址模式的一个重要用途是在你拥有记录数据的指针时访问记录(或结构)的字段。对于访问过程中的自动(局部)变量,这种寻址模式也非常有价值(详见第五章)。
索引寻址模式的第二种变体实际上是前两种形式的结合体。这个版本的语法如下:
mov( *`VarName`*[ ebx + *`constant`* ], al );
mov( *`VarName`*[ ebx - *`constant`* ], al );
再次说明,这个示例仅使用了 EBX 寄存器。在这两个示例中,你可以用任何 32 位通用寄存器替代 EBX。这个特定的形式在汇编语言程序中访问记录(结构)数组元素时非常有用(更多内容见第四章)。
这些指令通过将constant值从VarName的地址中加或减去,然后将 EBX 中的值加到这个结果上来计算它们的有效地址。请注意,是 HLA,而不是 CPU,计算VarName地址与constant的和或差。上述实际的机器指令包含一个常数值,在运行时将这个常数值加到 EBX 中的值上。因为 HLA 将常数替换为VarName,它可以将以下形式的指令简化为
mov( *`VarName`*[ ebx + *`constant`*], al );
变成如下形式的指令
mov( *`constant1`*[ ebx + *`constant2`*], al );
由于这些寻址模式的工作方式,它在语义上等同于
mov( [ebx + (*`constant1`* + *`constant2`*)], al );
HLA 将在编译时将这两个常数相加,有效地产生以下指令:
mov( [ebx + *`constant_sum`*], al );
当然,减法本身并没有什么特别的。你可以通过简单地取 32 位常数的二补码,再将该补码值相加(而不是减去原始值)轻松地将涉及减法的寻址模式转换为加法。
3.1.2.5 缩放索引寻址模式
缩放索引寻址模式与索引寻址模式类似,但有两个不同之处:(1) 缩放索引寻址模式允许你结合两个寄存器加一个位移量,(2) 缩放索引寻址模式允许你将索引寄存器乘以 1、2、4 或 8 的缩放因子。这些寻址模式的语法是
*`VarName`*[ *`IndexReg32`***`scale`* ]
*`VarName`*[ *`IndexReg32`***`scale`* + *`displacement`* ]
*`VarName`*[ *`IndexReg32`***`scale`* - *`displacement`* ]
[ *`BaseReg32`* + *`IndexReg32`***`scale`* ]
[ *`BaseReg32`* + *`IndexReg32`***`scale`* + *`displacement`* ]
[ *`BaseReg32`* + *`IndexReg32`***`scale`* - *`displacement`* ]
*`VarName`*[ *`BaseReg32`* + *`IndexReg32`***`scale`* ]
*`VarName`*[ *`BaseReg32`* + *`IndexReg32`***`scale`* + *`displacement`* ]
*`VarName`*[ *`BaseReg32`* + *`IndexReg32`***`scale`* - *`displacement`* ]
在这些示例中,BaseReg32表示任何通用 32 位寄存器,IndexReg32表示除 ESP 外的任何通用 32 位寄存器,scale必须是常数 1、2、4 或 8 之一。
缩放索引寻址模式和索引寻址模式的主要区别在于包含了IndexReg32*scale组件。这些模式通过将新寄存器的值乘以指定的缩放因子后加到基地址中,从而计算有效地址(有关以 EBX 作为基寄存器,ESI 作为索引寄存器的示例,请参见图 3-6)。

图 3-6. 缩放索引寻址模式
在图 3-6 中,假设 EBX 包含$100,ESI 包含$20,VarName位于内存中的基地址$2000;则以下指令
mov( *`VarName`*[ ebx + esi*4 + 4 ], al );
将地址$2184($100 + $20*4 + 4)处的字节移入 AL 寄存器。
缩放索引寻址模式适用于访问每个元素为 2、4 或 8 字节的数组元素。当你有指向数组开始位置的指针时,这些寻址模式也非常有用。
3.1.2.6 寻址模式总结
说实话,你刚刚学会了几百种寻址模式!其实并不难,对吧?如果你在想这些模式是从哪里来的,值得注意的是,寄存器间接寻址模式并不是单一的寻址模式,而是八种不同的寻址模式(涉及到八个不同的寄存器)。寄存器、常数大小和其他因素的组合,会增加系统中可能的寻址模式的数量。实际上,你只需要记住大约二十几种形式,就能应付了。实际上,在任何给定的程序中,你会使用不到一半的可用寻址模式(而且许多寻址模式你可能永远都不会用到)。因此,学习所有这些寻址模式其实比看起来要容易得多。
^([34]) 事实上,80x86 确实支持涉及某些 16 位寄存器的寻址模式,如前所述。然而,HLA 不支持这些模式,并且在 32 位操作系统下它们并不实用。
^([35]) 这里的static表示一个static、readonly或storage对象。
^([36]) 有效地址是指令在完成所有地址计算后将要访问的内存中的最终地址。
3.2 运行时内存组织
类似 Mac OS X、FreeBSD、Linux 或 Windows 的操作系统倾向于将不同类型的数据放入内存的不同段(或区域)。尽管通过运行链接器并指定不同的参数,可以重新配置内存的布局,但默认情况下,Windows 使用图 3-7 中显示的内存组织加载 HLA 程序(Linux、Mac OS X 和 FreeBSD 类似,尽管它们对一些段进行了重新安排)。

图 3-7. HLA 典型的运行时内存组织
操作系统保留了最低的内存地址。通常,你的应用程序无法访问这些低地址处的数据(或执行指令)。操作系统保留这些空间的一个原因是帮助捕捉 NULL 指针引用。如果你尝试访问内存位置 0,操作系统将生成一个通用保护错误,这意味着你访问了一个没有有效数据的内存位置。因为程序员通常将指针初始化为 NULL(0)以表示指针未指向任何位置,因此访问位置 0 通常意味着程序员犯了错误,没有正确初始化指针为合法的(非 NULL)值。
内存映射中的其余六个区域保存与你的程序相关的不同类型的数据。这些内存段包括stack段、heap段、code段、readonly段、static段和storage段。每个内存段对应你可以在 HLA 程序中创建的某种类型的数据。下面将详细讨论每个段。
3.2.1 代码段
code段包含出现在 HLA 程序中的机器指令。HLA 将你编写的每条机器指令翻译成一个或多个字节值的序列。在程序执行期间,CPU 将这些字节值解释为机器指令。
默认情况下,当 HLA 链接你的程序时,它会告诉系统你的程序可以在代码段中执行指令,并且你可以从代码段读取数据。特别注意,你不能向代码段写入数据。如果你尝试将数据存储到代码段中,操作系统将生成一个通用保护错误。
记住,机器指令不过是数据字节。从理论上讲,您可以编写一个程序,将数据值存储到内存中,然后将控制权转移到它刚写入的数据上,从而产生一个在执行时自己写自己的程序。这种可能性激发了人们对 人工智能 程序的浪漫幻想,这些程序能够自我修改以产生期望的结果。但在现实生活中,效果要少得多,远没有那么光彩。通常,自我修改的程序非常难以调试,因为指令在程序员的背后不断变化。由于大多数现代操作系统使得编写自我修改的程序变得非常困难,因此我们在本文中不会进一步讨论它们。
HLA 会自动将与机器代码关联的数据存储到代码区。除了机器指令之外,您还可以通过使用以下伪操作码将数据存储到代码区:^([37])
byte |
int8 |
|---|---|
word |
int16 |
dword |
in32 |
uns8 |
boolean |
uns16 |
char |
uns32 |
以下的 byte 语句展示了每个伪操作码的语法:
byte *`comma_separated_list_of_byte_constants`* ;
以下是一些示例:
boolean true;
char 'A';
byte 0, 1, 2;
byte "Hello", 0
word 0, 2;
int8 −5;
uns32 356789, 0;
如果伪操作码后面的值列表中出现多个值,HLA 会将每个连续的值发送到代码流中。因此,上面的第一个 byte 语句会向代码流发送 3 个字节,值分别为 0、1 和 2。如果字符串出现在 byte 语句中,HLA 会为字符串中的每个字符发送 1 个字节的数据。因此,上面的第二个 byte 语句会发送 6 个字节:字符 H、e、l、l 和 o,然后是一个 0 字节。
请记住,除非特别小心以防止数据执行,否则 CPU 会尝试将您发送到代码流中的数据当作机器指令来处理。例如,如果您写下以下内容:
mov( 0, ax );
byte 0,1,2,3;
add( bx, cx );
执行 mov 指令后,您的程序会尝试将 0、1、2 和 3 字节值作为机器指令执行。除非您知道某个指令序列的机器码,否则将这样的数据值直接插入代码中通常会导致程序崩溃。通常,当您在程序中插入这样的数据时,您会执行一些代码,将控制权转移到数据所在位置。
3.2.2 静态区
static 区通常是您声明变量的地方。尽管 static 区在语法上看起来像是程序或过程的一部分,但请记住,HLA 会将所有静态变量移动到内存中的 static 区。因此,HLA 不会将您在 static 区声明的变量夹在 code 区的过程之间。
除了声明静态变量,你还可以将数据列表嵌入到static声明区。你可以使用与将数据嵌入到code区相同的技巧,将数据嵌入到static区:你使用byte、word、dword、uns32等伪操作码。考虑以下示例:
static
b: byte := 0;
byte 1,2,3;
u: uns32 := 1;
uns32 5,2,10;
c: char;
char 'a', 'b', 'c', 'd', 'e', 'f';
bn: boolean;
boolean true;
HLA 使用这些伪操作码写入static内存段的数据,会在前面变量之后写入该段。例如,字节值1、2和3会在b的0字节之后被写入static区。由于这些值没有与标签关联,你无法在程序中直接访问这些值。你可以使用索引寻址模式来访问这些额外的值(示例见第四章)。
在上述示例中,请注意c和bn变量没有(显式)初始值。然而,如果你没有提供初始值,HLA 会将static区中的变量初始化为全 0 位,因此 HLA 将 NUL 字符(ASCII 码 0)赋给c作为其初始值。同样,HLA 将false作为bn的初始值。特别需要注意的是,即使你没有为变量分配初始值,static区中的变量声明仍然会占用内存。
3.2.3 只读数据区
readonly数据区包含常量、表格和程序在执行过程中无法修改的其他数据。你通过在readonly声明区中声明它们来创建只读对象。readonly区与static区非常相似,主要有三个区别:
-
readonly区以保留字readonly开头,而不是static。 -
readonly区中的所有声明通常都有初始化值。 -
系统不允许你在程序运行时将数据存储到
readonly对象中。
这是一个示例:
readonly
pi: real32 := 3.14159;
e: real32 := 2.71;
MaxU16: uns16 := 65_535;
MaxI16: int16 := 32_767;
所有readonly对象声明必须有初始化值,因为你无法在程序控制下初始化该值。^([38]) 就所有实际目的而言,你可以将readonly对象视为常量。然而,这些常量占用内存,除了不能写入数据到readonly对象外,它们的行为类似于static变量。因为它们的行为像static对象一样,你不能在常量允许的任何地方使用readonly对象;特别是,readonly对象是内存对象,因此你不能将一个readonly对象(你视为常量)和其他内存对象作为操作数传递给指令。
与static区一样,你可以使用byte、word、dword等数据声明将数据值嵌入到readonly区。例如:
readonly
roArray: byte := 0;
byte 1, 2, 3, 4, 5;
qwVal: qword := 1;
qword 0;
3.2.4 存储区
readonly部分要求你初始化所有声明的对象。static部分让你选择性地初始化对象(或者将它们保持未初始化状态,在这种情况下,它们的默认初始值为 0)。storage部分完成了初始化的覆盖:你用它来声明在程序开始运行时始终未初始化的变量。storage部分以storage保留字开始,并包含没有初始化器的变量声明。以下是一个示例:
storage
UninitUns32: uns32;
i: int32;
character: char;
b: byte;
Linux、FreeBSD、Mac OS X 和 Windows 在将程序加载到内存时会将所有存储对象初始化为 0。然而,依赖这种隐式初始化可能不是一个好主意。如果你需要一个初始化为 0 的对象,可以在static部分声明它,并显式地将其设置为 0。
你在storage部分声明的变量可能会减少程序可执行文件中的磁盘空间占用。这是因为 HLA 会将readonly和static对象的初始值写入可执行文件,但它可能会使用紧凑的表示方式来存储你在storage部分声明的未初始化变量;但请注意,这种行为依赖于操作系统和对象模块格式。
因为storage部分不允许初始化值,所以你不能在storage部分使用byte、word、dword等伪操作码来放置没有标签的值。
3.2.5 @nostorage属性
@nostorage属性让你在静态数据声明部分(即static、readonly和storage)声明变量,而不实际为变量分配内存。@nostorage选项告诉 HLA 将当前地址分配给声明部分中的变量,但不为对象分配任何存储空间。该变量将与变量声明部分中下一个出现的对象共享相同的内存地址。以下是@nostorage选项的语法:
*`variableName`*: *`varType`*; @nostorage;
注意,你在类型名称后跟上@nostorage;,而不是一些初始值或仅仅是分号。以下代码序列提供了在readonly部分中使用@nostorage选项的示例:
readonly
abcd: dword; nostorage;
byte 'a', 'b', 'c', 'd';
在这个例子中,abcd是一个双字,其中最低字节包含 97('a'),字节 1 包含 98('b'),字节 2 包含 99('c'),最高字节包含 100('d')。HLA 不会为abcd变量分配存储空间,因此 HLA 会将内存中以下 4 个字节(由byte指令分配)与abcd关联。
注意,@nostorage属性仅在static、storage和readonly部分(所谓的静态声明部分)合法。HLA 不允许在接下来会介绍的var部分中使用它。
3.2.6 var部分
HLA 提供了另一种变量声明部分,即 var 部分,你可以用来创建 自动 变量。当程序单元(即主程序或过程)开始执行时,程序将为自动变量分配存储;当该程序单元返回给调用者时,程序会释放自动变量的存储。当然,任何在主程序中声明的自动变量与所有 static、readonly 和 storage 对象具有相同的 生命周期 ^([39]),因此 var 部分的自动分配功能在主程序中是无用的。通常,你应该只在过程(见 第五章)中使用自动对象。HLA 允许在主程序的声明部分使用它们作为一种概括。
因为你在 var 部分声明的变量是在运行时创建的,所以 HLA 不允许在此部分声明的变量上使用初始化器。因此,var 部分的语法与 storage 部分几乎相同;两者之间唯一的实际区别是使用了 var 保留字,而不是 storage 保留字。^([40]) 以下示例演示了这一点:
var
vInt: int32;
vChar: char;
HLA 在 var 部分声明的变量分配到 stack 内存部分。HLA 不会将 var 对象分配到固定位置;相反,它会将这些变量分配到与当前程序单元关联的激活记录中。第五章更详细地讨论了激活记录;目前,重要的是要意识到 HLA 程序使用 EBP 寄存器作为指向当前激活记录的指针。因此,每当你访问 var 对象时,HLA 会自动将变量名替换为 [EBP±位移]。位移是对象在激活记录中的偏移量。这意味着你不能使用完整的缩放索引寻址模式(基址寄存器加缩放索引寄存器)来访问 var 对象,因为 var 对象已经使用 EBP 寄存器作为基址寄存器。虽然你不会经常直接使用这两种寄存器寻址模式,但 var 部分的这一限制是避免在主程序中使用 var 部分的一个重要理由。
3.2.7 程序中声明部分的组织
static、readonly、storage 和 var 部分可以在 program 头部和相关 begin 之间出现零次或多次。在程序的这两个点之间,声明部分可以按任意顺序出现,以下示例演示了这一点:
program demoDeclarations;
static
i_static: int32;
var
i_auto: int32;
storage
i_uninit: int32;
readonly
i_readonly: int32 := 5;
static
j: uns32;
var
k: char;
readonly
i2: uns8 := 9;
storage
c: char;
storage
d: dword;
begin demoDeclarations;
<< Code goes here. >>
end demoDeclarations;
除了演示各个部分可以以任意顺序出现之外,本节还展示了在程序中给定的声明部分可能出现多次。当多个相同类型的声明部分(例如,上述的三个storage部分)出现在程序的声明部分时,HLA 会将它们合并成一个组。
^([37]) 这不是完整的列表。HLA 通常允许你使用任何标量数据类型名称作为语句,在代码部分保留存储空间。你将在第四章中了解更多可用的数据类型。
^([38]) 其中有一个例外,你将在第五章中看到。
^([39]) 变量的生命周期是从首次分配内存到该变量的内存被释放的时间段。
^([40]) 事实上,还有一些其他的小差异,但我们在本文中不会涉及这些差异。有关更多细节,请参阅 HLA 语言参考手册。
3.3 HLA 如何为变量分配内存
正如你所见,80x86 CPU 不会处理像I、Profits和LineCnt这样的变量名。CPU 严格处理可以放置在地址总线上的数字地址,例如$1234_5678、$0400_1000 和$8000_CC00。而 HLA 则不会强制你通过地址来引用变量对象(这很好,因为名称更容易记住)。这很好,但它确实掩盖了真正发生的情况。在本节中,我们将了解 HLA 如何将数字地址与变量关联起来,这样你就能理解(并欣赏)在你不知情的情况下发生的过程。
再看看图 3-7。如你所见,各个内存部分通常是彼此相邻的。因此,如果某一内存部分的大小发生变化,所有后续部分的起始地址也会受到影响。例如,如果你向程序中添加了一些机器指令,增大了code部分的大小,那么这可能会影响static部分在内存中的起始地址,从而改变所有静态变量的地址。通过数字地址来追踪变量(而不是通过它们的名称)已经足够困难;想象一下,如果地址在你添加或删除机器指令时不断变化,会变得多么糟糕!幸运的是,你不需要追踪变量的地址;HLA 会为你完成这个记账工作。
HLA 为每个静态声明区段(static、readonly和storage)关联了一个当前的位置计数器。这些位置计数器最初的值为 0,每当你在某个静态区段中声明一个变量时,HLA 会将该区段位置计数器的当前值与变量关联;同时,HLA 还会将该位置计数器的值增加声明对象的大小。举个例子,假设以下是程序中唯一的static声明区段:
static
b :byte; // Location counter = 0, size = 1
w :word; // Location counter = 1, size = 2
d :dword; // Location counter = 3, size = 4
q :qword; // Location counter = 7, size = 8
l :lword; // Location counter = 15, size = 16
// Location counter is now 31.
当然,这些变量的运行时地址并不是位置计数器的值。首先,HLA 会将static内存区段的基地址加到这些位置计数器的每个值上(我们称之为位移或偏移量)。其次,可能还有其他静态对象存在于你链接到程序中的模块中(例如来自 HLA 标准库的对象),或者在同一源文件中可能存在额外的static区段,链接器必须将这些static区段合并在一起。因此,这些偏移量可能对这些变量在内存中的最终地址几乎没有影响。尽管如此,有一个重要的事实依然成立:HLA 会将你在单个static声明区段中声明的变量分配到连续的内存位置。也就是说,给定上述声明,w将在内存中紧跟b之后,d将在内存中紧跟w之后,q将在内存中紧跟d之后,以此类推。通常情况下,假设系统以这种方式分配变量并不是良好的编码风格,但有时这么做是方便的。
请注意,HLA 会将你在readonly、static和storage区段中声明的内存对象分配到完全不同的内存区域。因此,你不能假设以下三个内存对象会出现在相邻的内存位置(实际上,它们可能不会相邻):
static
b :byte;
readonly
w :word := $1234;
storage
d :dword;
事实上,HLA 甚至不能保证你在不同的static(或其他)区段中声明的变量会在内存中相邻,即使你的代码中这些声明之间没有任何东西(例如,你不能假设b、w和d在以下声明中是相邻的内存位置,也不能假设它们不会在内存中相邻):
static
b :byte;
static
w :word := $1234;
static
d :dword;
如果你的代码要求这些变量占用相邻的内存位置,你必须将它们声明在同一个static区段中。
请注意,HLA 处理你在var区段中声明的变量与在static区段中声明的变量略有不同。我们将在第五章中讨论如何为var对象分配偏移量。
3.4 HLA 对数据对齐的支持
为了编写快速的程序,你需要确保在内存中正确对齐数据对象。正确的对齐意味着对象的起始地址是某个大小的倍数,通常如果对象的大小是 2 的幂(最大为 16 字节),那么它的起始地址应该是该对象大小的倍数。对于大于 16 字节的对象,将其对齐到 8 字节或 16 字节的地址边界通常就足够了。对于小于 16 字节的对象,将其对齐到大于对象大小的下一个 2 的幂次方地址通常是可以的。访问没有对齐到合适地址的数据可能会需要额外的时间;因此,如果你想确保程序尽可能快速运行,你应该根据数据对象的大小来对齐它们。
当你为不同大小的对象分配存储空间时,如果它们被分配在相邻的内存位置,数据就会变得未对齐。例如,如果你声明了一个字节变量,它将占用 1 个字节的存储空间,而在该声明部分中你声明的下一个变量将位于该字节对象的地址加 1 的位置。如果字节变量的地址恰好是偶数地址,那么紧随其后的变量将从一个奇数地址开始。如果这个变量是一个字或双字对象,那么它的起始地址将不是最优的。在本节中,我们将探讨确保变量根据对象的大小在适当起始地址对齐的方法。
请考虑以下 HLA 变量声明:
static
dw: dword;
b: byte;
w: word;
dw2: dword;
w2: word;
b2: byte;
dw3: dword;
程序中的第一个static声明(在 Windows、Mac OS X、FreeBSD、Linux 和大多数 32 位操作系统下运行)将变量放置在一个偶数倍的 4,096 字节地址上。无论哪个变量最先出现在static声明中,都能保证它会对齐到一个合理的地址。每个后续变量都将分配到一个地址,该地址是前面所有变量的大小之和加上该static段的起始地址。因此,假设 HLA 在前面示例中分配变量的起始地址为4096,HLA 将把它们分配到以下地址:
// Start Adrs Length
dw: dword; // 4096 4
b: byte; // 4100 1
w: word; // 4101 2
dw2: dword; // 4103 4
w2: word; // 4107 2
b2: byte; // 4109 1
dw3: dword; // 4110 4
除了第一个变量(它对齐在 4KB 边界上)和字节变量(其对齐不重要)外,所有这些变量都没有对齐。w、w2和dw2变量从奇数地址开始,而dw3变量则对齐在一个不是 4 的倍数的偶数地址上。
保证变量正确对齐的一个简单方法是:在声明中先写双字变量,其次是字变量,最后是字节变量,如下所示:
static
dw: dword;
dw2: dword;
dw3: dword;
w: word;
w2: word;
b: byte;
b2: byte;
这种组织方式在内存中生成以下地址:
// Start Adrs Length
dw: dword; // 4096 4
dw2: dword; // 4100 4
dw3: dword; // 4104 4
w: word; // 4108 2
w2: word; // 4110 2
b: byte; // 4112 1
b2: byte; // 4113 1
如你所见,这些变量都在合理的地址上进行了对齐。
不幸的是,通常很难按照这种方式安排你的变量。虽然有许多技术原因使得这种对齐变得不可能,但不这样做的一个好的实际原因是,这样做不能让你按逻辑功能组织变量声明(也就是说,你可能希望将相关的变量放在一起,而不管它们的大小)。
为了解决这个问题,HLA 提供了 align 指令。align 指令使用以下语法:
align( *`integer_constant`* );
整数常量必须是以下小的无符号整数值之一:1、2、4、8 或 16。如果 HLA 在 static 部分遇到 align 指令,它将把下一个变量对齐到一个指定对齐常量的偶数倍地址。前面的例子可以用 align 指令重写,如下所示:
static
align( 4 );
dw: dword;
b: byte;
align( 2 );
w: word;
align( 4 );
dw2: dword;
w2: word;
b2: byte;
align( 4 );
dw3: dword;
如果你在想 align 指令是如何工作的,其实非常简单。如果 HLA 确定当前地址(位置计数器的值)不是指定值的偶数倍,HLA 会在前一个变量声明后静默地插入额外的填充字节,直到 static 部分的当前地址成为指定值的偶数倍。这会使你的程序稍微变大(增加几字节),以换取更快速的数据访问。鉴于使用此功能时,程序只会增加几字节,这可能是一个值得的权衡。
一般来说,如果你想要尽可能快速的访问,你应该选择一个等于你想对齐的对象大小的对齐值。也就是说,你应该用 align(2); 语句将字对齐到偶数边界,用 align(4); 将双字对齐到 4 字节边界,用 align(8); 将四字对齐到 8 字节边界,依此类推。如果对象的大小不是 2 的幂,则将其对齐到下一个较大的 2 的幂(最大为 16 字节)。不过,请注意,只有 real80(和 tbyte)类型的对象需要对齐到 8 字节边界。
请注意,数据对齐并非总是必要的。现代 80x86 CPU 的缓存架构实际上可以处理大多数未对齐的数据。因此,你应该仅在需要快速访问的变量上使用对齐指令。这是一个合理的空间/速度权衡。
3.5 地址表达式
本章早些时候提到,寻址模式有几种通用的形式,包括以下几种:
*`VarName`*[ *`Reg32`* ]
*`VarName`*[ *`Reg32`* + *`offset`* ]
*`VarName`*[ *`RegNotESP32`***`scale`* ]
*`VarName`*[ *`Reg32`* + *`RegNotESP32`***`scale`* ]
*`VarName`*[ *`RegNotESP32`***`scale`* + *`offset`* ]
*`VarName`*[ *`Reg32`* + *`RegNotESP32`***`scale`* + *`offset`* ]
另一种合法的形式,实际上并不是一种新的寻址模式,而只是位移寻址模式的扩展,是:
*`VarName`*[ *`offset`* ]
后者的示例通过将方括号内的常量偏移量与变量的地址相加来计算其有效地址。例如,指令mov(Address[3], al);将 AL 寄存器加载为内存中位于Address对象之后 3 个字节的字节(见图 3-8)。
始终记住,这些示例中的offset值必须是常量。如果Index是一个int32变量,则Variable[Index]不是合法的地址表达式。如果您希望指定一个在运行时变化的索引,则必须使用某种索引寻址或缩放索引寻址模式。
另一个需要记住的重要事项是,Address[offset]中的偏移量是字节地址。尽管这种语法类似于 C/C++或 Pascal 等高级语言中的数组索引,但除非Address是字节数组,否则这并不会正确地对数组对象进行索引。

图 3-8. 使用地址表达式访问超出变量的数据
本书将地址表达式视为任何合法的 80x86 寻址模式,其中包括位移(即变量名)或偏移量。除了上述形式外,以下也是地址表达式:
[ *`Reg32`* + *`offset`* ]
[ *`Reg32`* + *`RegNotESP32`***`scale`* + *`offset`* ]
本书将不把以下内容视为地址表达式,因为它们不涉及位移或偏移量成分:
[ *`Reg32`* ]
[ *`Reg32`* + *`RegNotESP32`*
**`scale`* ]
地址表达式是特别的,因为包含地址表达式的指令总是将位移常量编码为机器指令的一部分。也就是说,机器指令包含一些位(通常是 8 位或 32 位),这些位保存一个数字常量。该常量是位移(即变量的地址或偏移量)加上偏移量的和。请注意,HLA 会自动为您将这两个值相加(如果在寻址模式中使用−而非+操作符,它会自动减去偏移量)。
到目前为止,所有寻址模式示例中的偏移量始终是一个数字常量。然而,HLA 还允许在任何合法的偏移量位置使用常量表达式。常量表达式由一个或多个常量项组成,这些常量项通过加法、减法、乘法、除法、取余以及其他各种运算符进行运算。然而,大多数地址表达式只涉及加法、减法、乘法,有时还会涉及除法。考虑以下示例:
mov( X[ 2*4+1 ], al );
该指令将地址X+9处的字节移动到 AL 寄存器中。
地址表达式的值总是在编译时计算,而不是在程序运行时计算。当 HLA 遇到上面的指令时,它会立即计算 2 * 4 + 1,并将此结果加到内存中 X 的基地址。HLA 将这个单一的和(X 的基地址加 9)作为指令的一部分进行编码;HLA 不会在运行时为你发出额外的指令来计算这个和(这是好的,因为这样做效率更高)。由于 HLA 在编译时计算地址表达式的值,因此表达式的所有组件必须是常量,因为 HLA 在编译程序时无法知道变量的运行时值。
地址表达式在访问内存中变量之外的数据时非常有用,特别是当你在 static 或 readonly 段中使用 byte、word、dword 等语句将附加字节添加到数据声明后时。例如,考虑 示例 3-1 中的程序。
示例 3-1. 地址表达式演示
program adrsExpressions;
#include( "stdlib.hhf" )
static
i: int8; @nostorage;
byte 0, 1, 2, 3;
begin adrsExpressions;
stdout.put
(
"i[0]=", i[0], nl,
"i[1]=", i[1], nl,
"i[2]=", i[2], nl,
"i[3]=", i[3], nl
);
end adrsExpressions;
示例 3-1 中的程序将显示四个值 0、1、2 和 3,仿佛它们是数组元素。这是因为 i 地址处的值是 0(该程序使用 @nostorage 选项声明 i,因此 i 是 static 段中下一个对象的地址,恰好是作为 byte 语句一部分出现的值 0)。地址表达式 i[1] 告诉 HLA 获取位于 i 地址加 1 的字节。这个值是 1,因为该程序中的 byte 语句在值 0 后立即将值 1 写入 static 段。同样,对于 i[2] 和 i[3],该程序显示值 2 和 3。
3.6 类型强制转换
虽然 HLA 在类型检查方面相当宽松,但 HLA 确保你为指令指定了合适的操作数大小。例如,考虑以下(错误的)程序:
program hasErrors;
static
i8: int8;
i16: int16;
i32: int32;
begin hasErrors;
mov( i8, eax );
mov( i16, al );
mov( i32, ax );
end hasErrors;
HLA 会为这三个 mov 指令生成错误。这是因为操作数大小不兼容。第一条指令试图将一个字节移动到 EAX,第二条指令试图将一个字移动到 AL,第三条指令试图将一个双字移动到 AX。当然,mov 指令要求两个操作数的大小相同。
虽然这是 HLA 的一个好特性,^([41]),但有时它会成为障碍。考虑以下代码片段:
static
byte_values: byte; @nostorage;
byte 0, 1;
...
mov( byte_values, ax );
在这个例子中,假设程序员确实希望将byte_values地址开始的字加载到 AX 寄存器中,因为她希望通过单条指令将 0 加载到 AL 中,将 1 加载到 AH 中(注意 0 存储在低字节,1 存储在高字节)。HLA 会拒绝,声称发生了类型不匹配错误(因为byte_values是一个字节对象,而 AX 是一个字对象)。程序员可以将其拆分为两条指令,一条将字节值加载到 AL,另一条将字节值加载到 AH。不幸的是,这样的拆分会使程序效率稍微降低(这可能是最初使用单条mov指令的原因)。如果我们能告诉 HLA 我们知道自己在做什么,并且希望将byte_values变量视为word对象,那就太好了。HLA 的类型强制转换功能提供了这种能力。
类型强制转换^([42])是告诉 HLA 你希望将一个对象视为显式类型的过程,而不管它的实际类型是什么。要强制转换变量的类型,使用以下语法:
(type *`newTypeName addressExpression`*)
newTypeName项是你希望与addressExpression指定的内存位置关联的新类型。你可以在任何合法的内存地址上使用这个强制转换操作符。为了修正前面的例子,以便 HLA 不再抱怨类型不匹配,你可以使用以下语句:
mov( (type word *`byte_values`*), ax );
该指令告诉 HLA 从内存中byte_values地址开始加载一个字到 AX 寄存器中。假设byte_values仍然包含其初始值,这条指令将把 0 加载到 AL 中,将 1 加载到 AH 中。
当你指定一个匿名变量作为直接修改内存的指令的操作数时(例如,neg,shl,not等),需要使用类型强制转换。考虑以下语句:
not( [ebx] );
HLA 将在此指令上生成错误,因为它无法确定内存操作数的大小。该指令未提供足够的信息来确定程序是否应该反转由 EBX 指向的字节的位,EBX 指向的字的位,或者 EBX 指向的双字的位。你必须使用类型强制转换来显式指定这些类型的指令中的匿名引用的大小:
not( (type byte [ebx]) );
not( (type dword [ebx]) );
警告
除非你完全了解自己在做什么并且充分理解它对程序的影响,否则不要使用类型强制转换操作符。初学汇编语言的程序员经常使用类型强制转换作为工具来安抚编译器,避免因类型不匹配而报错,而不解决根本问题。
考虑以下语句(其中byteVar是一个 8 位变量):
mov( eax, (type dword *`byteVar`*) );
如果没有类型强制操作符,HLA 会抱怨这个指令,因为它试图将一个 32 位寄存器存储到一个 8 位的内存位置中。一个初学编程的程序员,想让程序编译通过,可能会采取捷径,使用类型强制操作符,如该指令所示;这肯定会让编译器安静下来——它不再抱怨类型不匹配——因此初学者会感到高兴。然而,程序依然不正确;唯一的区别是 HLA 不再警告你错误。类型强制操作符并没有解决试图将 32 位值存储到 8 位内存位置的问题——它只是允许指令将 32 位值从 8 位变量指定的地址开始存储。程序仍然存储了 4 个字节,覆盖了内存中紧跟在byteVar后面的 3 个字节。这常常会产生意想不到的结果,包括程序中变量的“幽灵修改”。^([43]) 另一种较少见的情况是程序因一般保护错误而中止。如果byteVar后面的 3 个字节没有分配到实际内存中,或者这些字节恰好位于内存中的只读段,则可能会发生这种情况。关于类型强制操作符需要记住的一个重要点是:如果你不能准确描述该操作符的效果,请不要使用它。
还要记住,类型强制操作符不会对内存中的数据进行任何转换。它只是告诉编译器将内存中的位视为另一种类型。它不会自动将 8 位值扩展为 32 位,也不会将整数转换为浮点值。它只是告诉编译器将内存操作数的位模式视为不同的类型。
^([41]) 毕竟,如果两个操作数的大小不同,这通常表示程序中存在错误。
^([42]) 这在某些语言中也称为类型转换。
^([43]) 如果在这个例子中有一个变量紧跟在byteVar后面,那么mov指令肯定会覆盖该变量的值,无论你是否希望发生这种情况。
3.7 寄存器类型强制
你还可以使用类型强制操作符将寄存器转换为特定类型。默认情况下,8 位寄存器是byte类型,16 位寄存器是word类型,32 位寄存器是dword类型。通过类型强制,你可以将寄存器转换为另一种类型,只要新类型的大小与寄存器的大小一致。这是一个重要的限制,使用类型强制应用于内存变量时并不存在这个限制。
大多数时候,你不需要将寄存器强制转换为不同的类型。作为byte、word和dword对象,寄存器已经与所有 1 字节、2 字节和 4 字节对象兼容。然而,在一些情况下,寄存器类型强制转换是有用的,甚至是必需的。两个例子包括 HLA 高级语言语句中的布尔表达式(例如if和while)以及寄存器 I/O 操作,在stdout.put和stdin.get(及相关语句)中。
在布尔表达式中,HLA 始终将byte、word和dword对象视为无符号值。因此,在没有类型强制转换的情况下,以下if语句总是求值为假(因为没有无符号值小于 0):
if( eax < 0 ) then
stdout.put( "EAX is negative!", nl );
endif;
你可以通过将 EAX 强制转换为int32类型来克服这个限制:
if( (type int32 eax) < 0 ) then
stdout.put( "EAX is negative!", nl );
endif;
类似地,HLA 标准库的stdout.put例程总是将byte、word和dword值作为十六进制数输出。因此,如果你尝试打印一个寄存器,stdout.put例程会将其打印为十六进制值。如果你想以其他类型打印该值,可以使用寄存器类型强制转换来实现:
stdout.put( "AL printed as a char = '", (type char al), "'", nl );
对于stdin.get例程也是如此。除非你将其类型强制转换为byte、word或dword以外的类型,否则它总是会读取寄存器的十六进制值。
3.8 堆栈段和 push 与 pop 指令
本章提到,所有在var段中声明的变量最终都会进入stack内存段。然而,var对象并不是stack内存段中唯一的内容;你的程序以多种不同的方式在stack段中操作数据。本节描述了堆栈,并介绍了操作stack段数据的push和pop指令。
内存中的stack段是 80x86 维护堆栈的地方。堆栈是一个动态数据结构,依据程序的需求增长和收缩。堆栈还存储关于程序的重要信息,包括局部变量、子例程信息和临时数据。
80x86 通过 ESP(堆栈指针)寄存器控制其堆栈。当程序开始执行时,操作系统将 ESP 初始化为stack内存段中最后一个内存位置的地址。数据通过“压入”(pushing)数据到堆栈和“弹出”(popping)数据从堆栈中来写入stack内存段。
3.8.1 基本的 push 指令
考虑 80x86 push指令的语法:
push( *`reg16`* );
push( *`reg32`* );
push( *`memory16`* );
push( *`memory32`* );
pushw( *`constant`* );
pushd( *`constant`* );
这六种形式允许你压入word或dword寄存器、内存位置和常量。你应该特别注意,不能将byte值压入堆栈。
push指令执行以下操作:
ESP := ESP - *`Size_of_Register_or_Memory_Operand`* (2 or 4)
[ESP] := *`Operand's_Value`*
pushw和pushd操作数分别始终是 2 字节和 4 字节常量。
假设 ESP 的值为 $00FF_FFE8,那么指令 push( eax ); 会将 ESP 设置为 $00FF_FFE4,并将 EAX 的当前值存储到内存位置 $00FF_FFE4,如 图 3-9; 操作前的栈段") 和 图 3-10; 操作后的栈段") 所示。

图 3-9. push( eax ); 操作前的栈段

图 3-10. push( eax ); 操作后的栈段
请注意,push( eax ); 指令不会影响 EAX 寄存器的值。
虽然 80x86 支持 16 位的 push 操作,但它们主要用于如 MS-DOS 这样的 16 位环境。为了获得最佳性能,栈指针的值应始终是 4 的偶数倍;实际上,如果 ESP 的值不是 4 的倍数,您的程序可能在 32 位操作系统下出现故障。将小于 4 字节的数据推入栈的唯一实际原因是通过两个连续的 16 位 push 操作来构建一个双字。
3.8.2 基本 pop 指令
要检索您推入栈的数据,可以使用 pop 指令。基本的 pop 指令允许以下几种形式。
pop( *`reg16`* );
pop( *`reg32`* );
pop( *`memory16`* );
pop( *`memory32`* );
与 push 指令一样,pop 指令仅支持 16 位和 32 位操作数;您不能从栈中弹出一个 8 位的值。与 push 指令相同,您应该避免弹出 16 位值(除非连续做两个 16 位弹出),因为 16 位弹出可能会导致 ESP 寄存器中包含一个不是 4 的偶数倍的值。push 和 pop 之间的一个主要区别是,您不能弹出常量值(这很有意义,因为 push 的操作数是源操作数,而 pop 的操作数是目的操作数)。
正式来说,pop 指令的作用如下:
*`Operand`* := [ESP]
ESP := ESP + *`Size_of_Operand`* (2 or 4)
如您所见,pop 操作是 push 操作的逆操作。请注意,pop 指令在调整 ESP 中的值之前,会从内存位置 [ESP] 复制数据。有关此操作的详细信息,请参见 图 3-11; 操作之前的内存") 和 图 3-12; 指令执行后的内存")。

图 3-11. pop( eax ); 操作之前的内存

图 3-12. pop( eax ); 指令执行后的内存
请注意,从栈中弹出的值仍然存在于内存中。弹出一个值并不会擦除内存中的该值,它只是调整栈指针,使其指向被弹出值上方的下一个值。然而,您绝不能尝试访问已从栈中弹出的值。下次将数据推送到栈中时,弹出的值将被覆盖。因为不仅仅是您的代码在使用栈(例如,操作系统和子程序也使用栈),所以在您将数据从栈中弹出后,不能依赖数据仍然保存在栈内存中。
3.8.3 使用 push 和 pop 指令保护寄存器
push 和 pop 指令最常见的用途可能是保存寄存器值以便进行中间计算。80x86 架构的一个问题是,它提供的通用寄存器非常少。由于寄存器是保存临时值的最佳位置,并且寄存器还需要用于各种寻址模式,因此在编写执行复杂计算的代码时,寄存器很容易用完。当发生这种情况时,push 和 pop 指令可以为您提供帮助。
请考虑以下程序框架:
<< Some sequence of instructions that use the eax register >>
<< Some sequence of instructions that need to use eax, for a
different purpose than the above instructions >>
<< Some sequence of instructions that need the original value in eax >>
push 和 pop 指令非常适合这种情况。通过在中间指令序列之前插入一个 push 指令,并在中间指令序列之后插入一个 pop 指令,您可以在这些计算中保护 EAX 中的值:
<< Some sequence of instructions that use the eax register >>
push( eax );
<< Some sequence of instructions that need to use eax, for a
different purpose than the above instructions >>
pop( eax );
<< Some sequence of instructions that need the original value in eax >>
上面的push指令将第一组指令计算出的数据复制到栈中。现在,中间的指令序列可以根据需要使用 EAX 寄存器。中间指令序列完成后,pop指令会恢复 EAX 中的值,以便最后一组指令可以使用 EAX 中的原始值。
3.9 栈是一个 LIFO 数据结构
您可以将多个值推送到栈中,而无需先将先前的值弹出栈。然而,栈是一个后进先出(LIFO)数据结构,因此在推送和弹出多个值时必须小心。例如,假设您想在一些指令块之间保护 EAX 和 EBX 寄存器的值;以下代码演示了处理这种情况的明显方法:
push( eax );
push( ebx );
<< Code that uses eax and ebx goes here. >>
pop( eax );
pop( ebx );
不幸的是,这段代码无法正常工作!图 3-13 到图 3-16 展示了问题所在。因为这段代码首先推送 EAX,然后推送 EBX,所以栈指针指向栈中 EBX 的值。当执行 pop( eax ); 指令时,它将原本在 EBX 中的值从栈中移除并放入 EAX 中!同样,pop( ebx ); 指令将原本在 EAX 中的值弹出并放入 EBX 寄存器。最终结果是,这段代码通过以与推送顺序相同的顺序弹出寄存器的值,从而交换了寄存器中的值。

图 3-13. 推入 EAX 后的堆栈

图 3-14. 推入 EBX 后的堆栈

图 3-15. 弹出 EAX 后的堆栈

图 3-16. 弹出 EBX 后的堆栈
为了纠正这个问题,你必须注意到堆栈是一个后进先出(LIFO)数据结构,所以你必须先弹出最后推入堆栈的东西。因此,你必须始终遵循以下格言:
始终以相反的顺序弹出你推入的值。
对前面代码的修正是:
push( eax );
push( ebx );
<< Code that uses eax and ebx goes here. >>
pop( ebx );
pop( eax );
另一个重要的格言是:
始终弹出与推入相同数量的字节。
这通常意味着推入和弹出的次数必须完全一致。如果你弹出次数太少,会导致数据留在堆栈上,可能会干扰正在运行的程序。如果你弹出次数太多,会不小心移除之前推入的数据,常常会导致灾难性的后果。
上述格言的推论是:“在循环中推入和弹出数据时要小心。”通常很容易将推入操作放在循环中,而将弹出操作放在循环外(或反之),这样会导致堆栈不一致。记住,重要的是 push 和 pop 指令的执行,而不是程序中出现的 push 和 pop 指令的数量。在运行时,程序执行的 push 指令的数量(和顺序)必须与 pop 指令的数量(和反顺序)相匹配。 |
|---|
3.9.1 其他推入和弹出指令
80x86 除了基本的 push/pop 指令外,还提供了几条额外的 push 和 pop 指令。这些指令包括以下几条:
pusha |
popa |
|---|---|
pushad |
popad |
pushf |
popf |
pushfd |
popfd |
pusha 指令将所有通用 16 位寄存器推入堆栈。此指令主要用于旧的 16 位操作系统,如 MS-DOS。一般来说,你很少需要使用此指令。pusha 指令按以下顺序将寄存器推入堆栈:
ax
cx
dx
bx
sp
bp
si
di
pushad 指令将所有 32 位(双字)寄存器推入堆栈。它按以下顺序将寄存器推入堆栈:
eax
ecx
edx
ebx
esp
ebp
esi
edi
因为 pusha 和 pushad 指令本身会修改 SP/ESP 寄存器,你可能会想,为什么 Intel 会把这个寄存器也推入堆栈。可能在硬件层面,直接推入 SP/ESP 比特殊处理它更容易。无论如何,这些指令确实会推入 SP 或 ESP,所以不用太担心——这没什么你能做的。
popa 和 popad 指令提供了与pusha和pushad指令对应的“弹出所有”操作。这将以适当的顺序弹出pusha或pushad推送的寄存器(也就是说,popa和popad将通过以与pusha或pushad推送它们时相反的顺序弹出它们,正确恢复寄存器的值)。
尽管pusha/popa和pushad/popad序列简短且方便,但它们实际上比相应的push/pop指令序列更慢,特别是当你考虑到你很少需要推送大部分寄存器,更不用说所有寄存器了时,情况就更加明显了。^([44]) 所以,如果你追求最大速度,应该仔细考虑是否使用pusha(d)/popa(d)指令。
pushf、pushfd、popf 和 popfd 指令用于推送和弹出 EFLAGS 寄存器。这些指令允许你在执行一系列指令时保留条件码和其他标志设置。不幸的是,除非你费很大劲,否则很难保留单独的标志。在使用pushf(d)和popf(d)指令时,这是一个“全有或全无”的问题——当你推送它们时,你会保留所有标志;当你弹出它们时,你会恢复所有标志。
像pushad和popad指令一样,你应该使用pushfd和popfd指令来推送完整的 32 位 EFLAGS 寄存器版本。尽管你推送和弹出的额外 16 位在编写应用程序时基本被忽略,但你仍然希望通过仅推送和弹出双字来保持栈的对齐。
3.9.2 在不弹出数据的情况下从栈中移除数据
偶尔你可能会发现自己将一些不再需要的数据推入栈中。尽管你可以将这些数据弹出到未使用的寄存器或内存位置,但有一个更简单的方法可以从栈中移除不需要的数据——只需调整 ESP 寄存器中的值,跳过栈中的不需要数据。
考虑以下困境:
push( eax );
push( ebx );
<< Some code that winds up computing some values we want to keep
into eax and ebx >>
if( *`Calculation_was_performed`* ) then
// Whoops, we don't want to pop eax and ebx!
// What to do here?
else
// No calculation, so restore eax, ebx.
pop( ebx );
pop( eax );
endif;
在if语句的then部分,这段代码希望移除 EAX 和 EBX 的旧值,同时不影响任何其他寄存器或内存位置。我们如何做到这一点?
因为 ESP 寄存器包含栈顶项目的内存地址,我们可以通过将该项目的大小加到 ESP 寄存器中来移除栈顶的项目。在上面的例子中,我们想从栈顶移除两个双字项目。我们可以通过将 8 加到栈指针来轻松完成这一操作(有关详细信息,请参见图 3-17 之前")和图 3-18 之后"))。
push( eax );
push( ebx );
<< Some code that winds up computing some values we want to keep
into eax and ebx >>
if( *`Calculation_was_performed`* ) then
add( 8, ESP ); // Remove unneeded eax/ebx values from the stack.
else
// No calculation, so restore eax, ebx.
pop( ebx );
pop( eax );
endif;

图 3-17. 从栈中移除数据,add( 8, esp );之前

图 3-18. 从栈中移除数据,add( 8, esp );之后
实际上,这段代码是在不移动数据的情况下将数据从栈中弹出。还要注意,这段代码比两条虚拟pop指令更快,因为它可以通过单个add指令从栈中移除任意数量的字节。
警告
记得保持栈在双字边界上对齐。因此,在从栈中移除数据时,应该始终向 ESP 添加一个 4 的倍数常量。
^([44]) 例如,你很少需要使用pushad/popad指令序列来推送和弹出 ESP 寄存器。
3.10 访问已推入栈但尚未弹出的数据
偶尔,你会将数据推入栈中,之后你可能需要获取该数据的副本,或者你希望在不弹出数据的情况下修改数据的值(也就是说,你希望稍后再从栈中弹出该数据)。80x86 的[reg32 + offset]寻址模式为此提供了机制。
考虑以下两个指令执行后的栈情况(见图 3-19):
push( eax );
push( ebx );

图 3-19. 将 EAX 和 EBX 推入栈后的栈情况
如果你想访问原始的 EBX 值而不从栈中移除它,你可以通过作弊,将值弹出后再立即将其推回栈中。然而,假设你希望访问 EAX 的旧值或者栈上更远的其他值。弹出所有中间值并再推回栈是最好的情况,而最坏的情况是完全不可能做到的。然而,正如你从图 3-19 中看到的那样,栈中每个推入的值都在内存中与 ESP 寄存器有一定的偏移。因此,我们可以使用[ESP + offset]寻址模式来直接访问我们感兴趣的值。在上面的例子中,你可以使用单条指令通过其原始值重新加载 EAX。
mov( [esp+4], eax );
这段代码将从内存地址 ESP+4 开始的 4 个字节复制到 EAX 寄存器。这个值恰好是之前推入栈中的 EAX 的值。你可以使用相同的技术来访问你推入栈的其他数据值。
警告
别忘了,ESP 中的值偏移量在每次压入或弹出数据时都会发生变化。滥用此特性可能会导致代码难以修改;如果你在代码中广泛使用此特性,将使得在第一次将数据压入堆栈和决定再次访问该数据时,难以在两者之间压入和弹出其他数据项,使用 [ESP + offset] 内存寻址模式。
前一节提到了如何通过向 ESP 寄存器添加常数来从堆栈中移除数据。那段代码示例可能会更安全地改写成这样:
push( eax );
push( ebx );
<< Some code that winds up computing some values we want to keep
into eax and ebx >>
if( *`Calculation_was_performed`* ) then
<< Overwrite saved values on stack with new eax/ebx values
(so the pops that follow won't change the values in eax/ebx). >>
mov( eax, [esp+4] );
mov( ebx, [esp] );
endif;
pop( ebx );
pop( eax );
在此代码序列中,计算结果被存储在堆栈上已保存的值之上。稍后,当程序弹出这些值时,它将这些计算结果加载到 EAX 和 EBX 寄存器中。
3.11 动态内存分配与堆段
尽管静态变量和自动变量可能是简单程序所需的全部内容,但更复杂的程序需要能够在程序控制下动态地分配和释放存储空间(在运行时)。在 C 语言中,你会使用 malloc 和 free 函数来实现这一点。C++ 提供了 new 和 delete 运算符。Pascal 使用 new 和 dispose。其他语言也提供了类似的功能。这些内存分配例程有几个共同点:它们允许程序员指定要分配的字节数,它们返回指向新分配存储空间的指针,并且它们提供了一种机制,可以将存储空间返回给系统,以便系统在未来的分配调用中重新利用这些空间。正如你可能猜到的,HLA 也在 HLA 标准库中提供了一组处理内存分配和释放的例程。
HLA 标准库中的 mem.alloc 和 mem.free 例程分别处理内存分配和释放操作。mem.alloc 例程使用以下调用顺序:
mem.alloc( *`Number_of_Bytes_Requested`* );
唯一的参数是一个 dword 值,指定你需要的存储字节数。此过程在内存的 heap 段中分配存储空间。HLA 的 mem.alloc 函数会在 heap 段中找到一个未使用的内存块,并将该块标记为“正在使用中”,这样未来对 mem.alloc 的调用就不会再次分配相同的存储空间。在将该块标记为“正在使用中”后,mem.alloc 例程将返回指向该存储空间第一个字节的指针,该指针存储在 EAX 寄存器中。
对于许多对象,你将知道表示该对象所需的字节数。例如,如果你希望为一个 uns32 变量分配存储空间,可以使用以下调用来调用 mem.alloc 例程:
mem.alloc( 4 );
虽然你可以像本示例所示指定一个字面常量,但在为特定数据类型分配存储时,通常不建议这么做。相反,应该使用 HLA 内置的编译时函数^([45]) @size 来计算某个数据类型的大小。@size 函数使用以下语法:
@size( *`variable_or_type_name`* )
@size 函数返回一个无符号整数常量,表示其参数的字节大小。因此,你应该将之前对 mem.alloc 的调用重写为如下形式:
mem.alloc( @size( uns32 ));
该调用将正确分配足够的存储空间来存储指定的对象,无论其类型如何。虽然一个 uns32 对象所需的字节数不太可能改变,但对于其他数据类型来说,这不一定成立;因此,在这些调用中,你应该始终使用 @size 而不是字面常量。
从 mem.alloc 例程返回后,EAX 寄存器包含你请求的存储地址(参见 图 3-20)。

图 3-20. 调用 mem.alloc 返回一个指针到 EAX 寄存器。
要访问 mem.alloc 分配的存储,你必须使用寄存器间接寻址模式。以下代码序列演示了如何将值 1234 赋给 mem.alloc 创建的 uns32 变量:
mem.alloc( @size( uns32 ));
mov( 1234, (type uns32 [eax]));
注意使用了 type 强制转换运算符。在本示例中,这是必要的,因为匿名变量没有与之关联的类型,并且常量 1234 可能是 word 或 dword 类型。type 强制转换运算符消除了歧义。
mem.alloc 例程可能并不总是成功。如果堆段中没有一个足够大的连续空闲内存块来满足请求,那么 mem.alloc 例程将引发一个 ex.MemoryAllocationFailure 异常。如果你没有提供一个 try..exception..endtry 处理程序来处理这种情况,内存分配失败将导致程序停止。由于大多数程序不会使用 mem.alloc 分配大量动态存储,因此这种异常很少发生。然而,你绝不能假设内存分配总是能无错误地完成。
当你完成使用 mem.alloc 在堆上分配的值后,可以通过调用 mem.free 过程来释放存储(即标记为“不再使用”)。mem.free 例程需要一个参数,这个参数必须是之前调用 mem.alloc 返回的地址(且你尚未释放过)。以下代码片段演示了 mem.alloc/mem.free 配对的性质:
mem.alloc( @size( uns32));
<< Use the storage pointed at by eax. >>
<< Note: This code must not modify eax. >>
mem.free( eax );
这段代码展示了一个非常重要的要点:为了正确释放mem.alloc分配的存储,你必须保存mem.alloc返回的值。如果你需要将 EAX 用于其他目的,有几种方法可以做到这一点;你可以使用push和pop指令将指针值保存在堆栈中,或者你可以将 EAX 的值保存在一个变量中,直到需要释放它。
你释放的存储可以被未来的mem.alloc调用重用。当你需要时分配存储,然后在完成后释放该存储供其他用途,这样可以提高程序的内存效率。通过在使用完存储后释放它,程序可以将该存储用于其他目的,从而使程序在内存使用上比静态分配单个对象的存储时更加高效。
使用指针时可能会出现几个问题。你应该意识到一些初学者在使用像mem.alloc和mem.free这样的动态存储分配例程时常犯的错误:
-
错误 1:在释放存储后继续访问它。一旦通过调用
mem.free将存储返回给系统,就不应再访问该存储。这样做可能会导致保护故障,甚至更糟,可能会破坏程序中的其他数据而不提示错误。 -
错误 2:调用
mem.free两次以释放一个存储块。这样做可能会不小心释放一些你不打算释放的其他存储,或者更糟糕的是,可能会破坏系统的内存管理表。
第四章讨论了你在处理动态分配存储时通常会遇到的一些其他问题。
本节至今的示例都为单个无符号 32 位对象分配了存储。显然,你可以通过调用mem.alloc来为任何数据类型分配存储,只需将该对象的大小作为mem.alloc的参数即可。在调用mem.alloc时,还可以为内存中的一系列连续对象分配存储。例如,以下代码将为一系列八个字符分配存储:
mem.alloc( @size( char ) * 8 );
注意使用常量表达式来计算一个八字符序列所需的字节数。因为@size(char)始终返回一个常量值(在此情况下为 1),所以编译器可以在不生成额外机器指令的情况下计算表达式@size(char) * 8的值。
对mem.alloc的调用总是会在连续的内存位置分配多个字节的存储。因此,前面的mem.alloc调用生成的序列如图 3-21 所示。

图 3-21. 使用mem.alloc分配一组八个字符对象
要访问这些额外的字符值,你需要使用从基地址偏移量(在mem.alloc返回时存储在 EAX 寄存器中)进行访问。例如,mov( ch, [eax + 2] );将字符 CH 存储到mem.alloc分配的第三个字节中。你还可以使用类似[eax + ebx]的寻址方式,在程序控制下逐步访问每个已分配的对象。例如,以下代码将会把 128 字节块中的所有字符设置为 NUL 字符(#0):
mem.alloc( 128 );
for( mov( 0, ebx ); ebx < 128; add( 1, ebx ) ) do
mov( 0, (type byte [eax+ebx]) );
endfor;
第四章讨论复合数据结构(包括数组),并描述了处理内存块的其他方法。
你应该注意到,调用mem.alloc实际上分配的内存比你请求的要多。首先,内存分配请求通常是某个最小大小(通常是 4 到 16 之间的 2 的幂次,尽管这取决于操作系统)。此外,mem.alloc请求每次分配还需要额外几字节的开销(通常约 16 到 32 字节),用于跟踪已分配和空闲的内存块。因此,使用单独的mem.alloc调用来分配大量小对象并不高效。每次分配的开销可能大于你实际使用的存储空间。通常,你会使用mem.alloc来为数组或大型记录(结构体)分配存储空间,而不是为小对象分配内存。
^([45]) 编译时函数是在程序编译过程中由 HLA 评估的函数,而不是在运行时评估的。
3.12 inc和dec指令
如前一节中的示例所示——事实上,正如迄今为止多个示例所表明的——对寄存器或内存位置加 1 或减 1 是非常常见的操作。实际上,这些操作如此常见,以至于英特尔的工程师专门设计了一对指令来执行这些特定操作:inc(递增)和dec(递减)指令。
inc和dec指令使用以下语法:
inc( *`mem/reg`* );
dec( *`mem/reg`* );
单个操作数可以是任何合法的 8 位、16 位或 32 位寄存器或内存操作数。inc指令会将指定的操作数加 1,而dec指令会将指定的操作数减 1。
这两条指令比对应的add或sub指令稍微短一些(也就是说,它们的编码使用了更少的字节)。这两条指令与对应的add或sub指令之间还有一个小小的区别:它们不会影响进位标志。
作为inc指令的一个例子,考虑前一节中的例子,重新编码为使用inc而不是add:
mem.alloc( 128 );
for( mov( 0, ebx ); ebx < 128; inc( ebx ) ) do
mov( 0, (type byte [eax+ebx]) );
endfor;
3.13 获取内存对象的地址
3.1.2.2 寄存器间接寻址模式讨论了如何使用取地址运算符&来获取静态变量的地址。^([46]) 不幸的是,您不能使用取地址运算符来获取自动变量(即您在var部分声明的变量)的地址,也不能用它计算匿名变量的地址,甚至不能用它来获取使用索引或缩放索引寻址模式的内存引用的地址(即使静态变量是地址表达式的一部分)。您只能使用取地址运算符来获取简单静态对象的地址。通常,您还需要获取其他内存对象的地址;幸运的是,80x86 提供了加载有效地址指令lea,可以实现这一功能。
lea指令使用以下语法:
lea( *`reg32`*, *`Memory_operand`* );
第一个操作数必须是一个 32 位寄存器;第二个操作数可以是任何合法的内存引用,使用任何有效的内存寻址模式。该指令会将指定内存位置的地址加载到寄存器中。该指令不会以任何方式访问或修改内存操作数的值。
一旦您将内存位置的有效地址加载到 32 位通用寄存器中,您可以使用寄存器间接、索引或缩放索引寻址模式来访问指定内存地址的数据。考虑以下代码片段:
static
b:byte; @nostorage;
byte 7, 0, 6, 1, 5, 2, 4, 3;
.
.
.
lea( ebx, b );
for( mov( 0, ecx ); ecx < 8; inc( ecx )) do
stdout.put( "[ebx+ecx] = ", (type byte [ebx+ecx]), nl );
endfor;
这段代码逐个处理紧跟着b标签后的 8 个字节,并打印它们的值。注意使用了[ebx+ecx]寻址模式。EBX 寄存器保存列表的基地址(即列表中第一个项目的地址),而 ECX 寄存器包含列表中的字节索引。
^([46]) 静态变量是指在程序的static、readonly或storage部分声明的变量。
3.14 更多信息
一本旧版的 16 位《汇编语言程序设计艺术》可以在webster.cs.ucr.edu/找到。在该书中,您将找到关于 80x86 的 16 位寻址模式和分段的信息。有关 HLA 标准库mem.alloc和mem.free函数的更多信息,请查阅 HLA 标准库参考手册,该手册也可以在 Webster 上找到,网址是webster.cs.ucr.edu/或artofasm.com/。当然,Intel 的 x86 文档(可以在www.intel.com/找到)提供了关于 80x86 寻址模式和机器指令编码的完整信息。
第四章 常量、变量和数据类型

第二章讨论了内存中数据的基本格式。第三章介绍了计算机系统如何在物理上组织这些数据。本章通过将 数据表示 与其实际物理表示连接起来,完成了讨论。正如标题所示,本章主要涉及三个主题:常量、变量和数据结构。本章并不假设你已经接受过正式的数据结构课程,尽管这样的经验会有所帮助。
本章讨论如何声明和使用常量、标量变量、整数、数据类型、指针、数组、记录/结构体、联合体和命名空间。在进入下一章之前,必须掌握这些内容。特别是声明和访问数组似乎给初学汇编语言的程序员带来了很多问题。然而,本文的其余部分依赖于你对这些数据结构及其内存表示的理解。不要抱有以后慢慢学到这些内容的期望,试图跳过这些材料会让你感到困惑。你马上就会用到这些内容,试图与后续的内容一起学习只会增加困惑。
4.1 一些额外的指令:intmul、bound、into
本章介绍了数组和其他需要扩展你对 80x86 指令集知识的概念。特别是,你需要学习如何将两个值相乘;因此我们首先要介绍的是 intmul(整数乘法)指令。另一个在访问数组时常见的任务是检查数组索引是否在范围内。80x86 的 bound 指令提供了一种方便的方式,检查寄存器的值是否在某个范围内。最后,into(溢出时中断)指令提供了一种快速检查有符号算术溢出的方法。虽然在数组(或其他数据类型)访问中并不一定需要 into,但它的功能与 bound 非常相似,因此在这一点上介绍它。
intmul 指令有以下几种形式:
// The following compute *`destreg`* = *`destreg`* * *`constant`*
intmul( *`constant, destreg16`* );
intmul( *`constant, destreg32`* );
// The following compute *`dest`* = *`src`* * *`constant`*
intmul( *`constant`*, *`srcreg16`*, *`destreg16`* );
intmul( *`constant`*, *`srcmem16`*, *`destreg16`* );
intmul( *`constant`*, *`srcreg32`*, *`destreg32`* );
intmul( *`constant`*, *`srcmem32`*, *`destreg32`* );
// The following compute *`dest`* = *`src`* * *`constant`*
intmul( *`srcreg16`*, *`destreg16`* );
intmul( *`srcmem16`*, *`destreg16`* );
intmul( *`srcreg32`*, *`destreg32`* );
intmul( *`srcmem32`*, *`destreg32`* );
请注意,intmul 指令的语法与 add 和 sub 指令不同。特别是,目标操作数必须是寄存器(add 和 sub 都允许内存操作数作为目标)。还要注意,当第一个操作数是常量时,intmul 允许三个操作数。另一个重要的区别是,intmul 指令仅允许 16 位和 32 位操作数;它不支持 8 位操作数的乘法。
intmul 计算指定操作数的乘积并将结果存储到目标寄存器中。如果发生溢出(这总是一个有符号溢出,因为 intmul 仅乘以有符号整数值),那么此指令会设置进位标志和溢出标志。intmul 会使其他条件码标志未定义(例如,执行 intmul 后你不能有意义地检查符号标志或零标志)。
bound 指令检查 16 位或 32 位寄存器,查看它是否在两个值之间。如果该值超出此范围,程序会引发异常并中止。此指令特别适用于检查数组索引是否在给定范围内。bound 指令有以下几种形式:
bound( *`reg16`*, *`LBconstant`*, *`UBconstant`* );
bound( *`reg32`*, *`LBconstant`*, *`UBconstant`* );
bound( *`reg16`*, *`Mem16`*[2] );
bound( *`reg32`*, *`Mem32`*[2] );
bound 指令将其寄存器操作数与无符号下界值和无符号上界值进行比较,以确保寄存器的值在范围内:
*`lower_bound`* <= *`register`* <= *`upper_bound`*
带有三个操作数的 bound 指令将寄存器与第二和第三个参数(分别是下界和上界)进行比较。^([47]) 带有两个操作数的 bound 指令将寄存器与以下一个范围进行比较:
*`Mem16`*[0] <= *`register16`* <= *`Mem16`*[2]
*`Mem32`*[0] <= *`register32`* <= *`Mem32`*[4]
如果指定的寄存器不在给定范围内,则 80x86 会引发异常。你可以使用 HLA 的 try..endtry 异常处理语句捕获此异常。excepts.hhf 头文件为此目的定义了一个异常 ex.BoundInstr。示例 4-1 程序展示了如何使用 bound 指令检查某些用户输入。
示例 4-1. bound 指令的演示
program BoundDemo;
#include( "stdlib.hhf" );
static
InputValue:int32;
GoodInput:boolean;
begin BoundDemo;
// Repeat until the user enters a good value:
repeat
// Assume the user enters a bad value.
mov( false, GoodInput );
// Catch bad numeric input via the try..endtry statement.
try
stdout.put( "Enter an integer between 1 and 10: " );
stdin.flushInput();
stdin.geti32();
mov( eax, InputValue );
// Use the BOUND instruction to verify that the
// value is in the range 1..10.
bound( eax, 1, 10 );
// If we get to this point, the value was in the
// range 1..10, so set the boolean GoodInput
// flag to true so we can exit the loop.
mov( true, GoodInput );
// Handle inputs that are not legal integers.
exception( ex.ConversionError )
stdout.put( "Illegal numeric format, re-enter", nl );
// Handle integer inputs that don't fit into an int32.
exception( ex.ValueOutOfRange )
stdout.put( "Value is *way* too big, re-enter", nl );
// Handle values outside the range 1..10 (BOUND instruction).
exception( ex.BoundInstr )
stdout.put
(
"Value was ",
InputValue,
", it must be between 1 and 10, re-enter",
nl
);
endtry;
until( GoodInput );
stdout.put( "The value you entered, ", InputValue, " is valid.", nl );
end BoundDemo;
into 指令与 bound 指令类似,也会在某些条件下生成异常。具体而言,into 会在溢出标志被设置时生成异常。通常,你会在有符号算术操作(例如 intmul)之后立即使用 into 来检查是否发生溢出。如果溢出标志未设置,系统会忽略 into;但是,如果溢出标志被设置,则 into 指令会引发 ex.IntoInstr 异常。示例 4-2 程序演示了如何使用 into 指令。
示例 4-2. into 指令的演示
program INTOdemo;
#include( "stdlib.hhf" );
static
LOperand:int8;
ResultOp:int8;
begin INTOdemo;
// The following try..endtry checks for bad numeric
// input and handles the integer overflow check:
try
// Get the first of two operands:
stdout.put( "Enter a small integer value (-128..+127):" );
stdin.geti8();
mov( al, LOperand );
// Get the second operand:
stdout.put( "Enter a second small integer value (-128..+127):" );
stdin.geti8();
// Produce their sum and check for overflow:
add( LOperand, al );
into();
// Display the sum:
stdout.put( "The eight-bit sum is ", (type int8 al), nl );
// Handle bad input here:
exception( ex.ConversionError )
stdout.put( "You entered illegal characters in the number", nl );
// Handle values that don't fit in a byte here:
exception( ex.ValueOutOfRange )
stdout.put( "The value must be in the range −128..+127", nl );
// Handle integer overflow here:
exception( ex.IntoInstr )
stdout.put
(
"The sum of the two values is outside the range −128..+127",
nl
);
endtry;
end INTOdemo;
^([47]) 这种形式并不是一个真正的 80x86 指令。HLA 会通过创建两个初始化为指定常量的 readonly 内存变量,将这种形式的 bound 指令转换为双操作数形式。
4.2 HLA 常量和数值声明
HLA 的 const 和 val 部分允许你声明符号常量。const 部分允许你声明在编译和运行时其值都恒定不变的标识符;val 部分允许你声明在编译时值可以变化,但在运行时值不变的符号常量(也就是说,相同的名称可以在源代码的不同地方有不同的值,但在程序运行时,val 符号的值在特定的程序点不能改变)。
const 部分出现在程序中的位置与 static、readonly、storage 和 var 部分相同。它以 const 保留字开始,语法几乎与 readonly 部分相同;也就是说,const 部分包含一个标识符列表,后跟类型和常量表达式。以下示例将给你一个关于 const 部分的基本概念:
const
pi: real32 := 3.14159;
MaxIndex: uns32 := 15;
Delimiter: char := '/';
BitMask: byte := $F0;
DebugActive: boolean := true;
一旦以这种方式声明了这些常量,你可以在任何符号常量合法的地方使用这些符号标识符。这些常量被称为显式常量。显式常量是常量的符号表示,允许你在程序中的任何地方将字面值替换为符号。与 readonly 变量进行对比;readonly 变量当然是常量值,因为在运行时你不能改变这些值。然而,readonly 变量有一个内存位置,操作系统,而不是 HLA 编译器,强制执行只读属性。尽管在程序运行时,它肯定会导致程序崩溃,但写出像 mov( eax, ReadOnlyVar ); 这样的指令是完全合法的。另一方面,写 mov( eax, MaxIndex );(使用上面的声明)和写 mov( eax, 15 ); 是一样不合法的。实际上,这两个语句是等效的,因为编译器在遇到这个显式常量时,会用 15 替换 MaxIndex。
如果常量的类型完全没有歧义,那么你可以仅指定名称和常量值来声明常量,省略类型说明。在之前的示例中,pi、Delimiter、MaxIndex 和 DebugActive 常量可以使用以下声明:
const
pi := 3.14159; // Default type is real80.
MaxIndex := 15; // Default type is uns32.
Delimiter := '/'; // Default type is char.
DebugActive := true; // Default type is boolean.
具有整数字面常量的符号常量,如果常量为零或正数,则始终使用最小的无符号类型;如果值为负数,则使用最小的整数类型(int8、int16 等)。
常量声明非常适合定义可能在程序修改过程中发生变化的“魔法”数字。示例 4-3 中的程序展示了如何使用常量来参数化程序中的“魔法”值。在这个具体案例中,程序定义了清单常量来指定为测试分配的内存量、(错误的)对齐方式,以及循环和数据重复的次数。该程序展示了在数据访问未对齐时性能的下降。如果程序运行太快或太慢,可以调整 MainRepetitions 常量。
示例 4-3. 使用 const 定义重写的数据对齐程序
program ConstDemo;
#include( "stdlib.hhf" );
const
MemToAllocate := 4_000_000;
NumDWords := MemToAllocate div 4;
MisalignBy := 62;
MainRepetitions := 10000;
DataRepetitions := 999_900;
CacheLineSize := 16;
begin ConstDemo;
//console.cls();
stdout.put
(
"Memory Alignment Exercise",nl,
nl,
"Using a watch (preferably a stopwatch), time the execution of", nl
"the following code to determine how many seconds it takes to", nl
"execute.", nl
nl
"Press Enter to begin timing the code:"
);
// Allocate enough dynamic memory to ensure that it does not
// all fit inside the cache. Note: The machine had better have
// at least 4 megabytes mem.free or virtual memory will kick in
// and invalidate the timing.
mem.alloc( MemToAllocate );
// Zero out the memory (this loop really exists just to
// ensure that all memory is mapped in by the OS).
mov( NumDWords, ecx );
repeat
dec( ecx );
mov( 0, (type dword [eax+ecx*4]));
until( !ecx ); // Repeat until ecx = 0.
// Okay, wait for the user to press the Enter key.
stdin.readLn();
// Note: As processors get faster and faster, you may
// want to increase the size of the following constant.
// Execution time for this loop should be approximately
// 10-30 seconds.
mov( MainRepetitions, edx );
add( MisalignBy, eax ); // Force misalignment of data.
repeat
mov( DataRepetitions, ecx );
align( CacheLineSize );
repeat
sub( 4, ecx );
mov( [eax+ecx*4], ebx );
mov( [eax+ecx*4], ebx );
mov( [eax+ecx*4], ebx );
mov( [eax+ecx*4], ebx );
until( !ecx );
dec( edx );
until( !edx ); // Repeat until eax is zero.
stdout.put( stdio.bell, "Stop timing and record time spent", nl, nl );
// Okay, time the aligned access.
stdout.put
(
"Press Enter again to begin timing access to aligned variable:"
);
stdin.readLn();
// Note: If you change the constant above, be sure to change
// this one, too!
mov( MainRepetitions, edx );
sub( MisalignBy, eax ); // Realign the data.
repeat
mov( DataRepetitions, ecx );
align( CacheLineSize );
repeat
sub( 4, ecx );
mov( [eax+ecx*4], ebx );
mov( [eax+ecx*4], ebx );
mov( [eax+ecx*4], ebx );
mov( [eax+ecx*4], ebx );
until( !ecx );
dec( edx );
until( !edx ); // Repeat until eax is zero.
stdout.put( stdio.bell, "Stop timing and record time spent", nl, nl );
mem.free( eax );
end ConstDemo;
4.2.1 常量类型
清单常量可以是任何 HLA 基本类型,以及本章讨论的一些复合类型。第一章, 第二章, 和 第三章 讨论了大多数基本类型;基本类型包括以下几种:^([48])
-
boolean常量(true 或 false) -
uns8常量(0..255) -
uns16常量(0..65,535) -
uns32常量(0..4,294,967,295) -
int8常量(−128..+127) -
int16常量(−32,768..+32,767) -
int32常量(−2,147,483,648..+2,147,483,647) -
char常量(任何 ASCII 字符,字符代码范围为 0..255) -
byte常量(任何 8 位值,包括整数、布尔值和字符) -
word常量(任何 16 位值) -
dword常量(任何 32 位值) -
real32常量(浮点值) -
real64常量(浮点值) -
real80常量(浮点值)
除了上面出现的常量类型外,const 部分还支持六种额外的常量类型:
-
string常量 -
text常量 -
枚举常量值
-
数组常量
-
记录/联合常量
-
字符集常量
这些数据类型是本章的主题,关于它们的大部分讨论稍后会出现。然而,字符串和文本常量足够重要,值得早期讨论这些常量类型。
4.2.2 字符串和字符字面常量
HLA 和大多数编程语言一样,区分字符序列(字符串)和单个字符。这一区别在类型声明和字面字符与字符串常量的语法中都存在。到目前为止,本书尚未对字符和字符串字面常量做出精细区分;现在是时候进行区分了。
字符串字面常量由零个或多个字符组成,这些字符被 ASCII 引号字符包围。以下是合法的字面字符串常量的示例:
"This is a string" // String with 16 characters.
"" // Zero length string.
"a" // String with a single character.
"123" // String of length 3.
长度为 1 的字符串与字符常量并不相同。HLA 对字符和字符串值使用了两种完全不同的内部表示方式。因此,"a" 不是一个字符;它是一个字符串,只不过恰好包含一个字符。
字符字面量常量有几种形式,但最常见的形式是由一个字符包围在 ASCII 撇号字符中:
'2' // Character constant equivalent to ASCII code $32.
'a' // Character constant for lowercase 'A'.
如本节前面所述,"a" 和 'a' 并不等价。
那些熟悉 C、C++ 或 Java 的人可能会认出这些字面常量形式,因为它们与 C/C++/Java 中的字符和字符串常量相似。事实上,到目前为止,本文已经默认假设你对 C/C++ 有一定的了解,因为到目前为止的示例中使用了字符和字符串常量,而没有明确地对它们进行定义。
C/C++ 字符串和 HLA 字符串之间的另一个相似之处是自动连接程序中的相邻字面量字符串常量。例如,HLA 会将两个字符串常量连接起来
"First part of string, " "second part of string"
形成一个单一的字符串常量
"First part of string, second part of string"
然而,除了这些相似之处,HLA 字符串和 C/C++ 字符串还是有所不同。例如,C/C++ 字符串允许你使用转义字符序列来指定特殊字符值,该序列由反斜杠字符后跟一个或多个特殊字符组成;而 HLA 不使用这种转义字符机制。然而,HLA 提供了其他几种方法将特殊字符插入字符串或字符常量中。
因为 HLA 不允许在字面量字符串和字符常量中使用转义字符序列,你可能首先会问:“如何在字符串常量中嵌入引号字符,在字符常量中嵌入撇号字符?”为了解决这个问题,HLA 使用与 Pascal 和许多其他语言相同的技术:在字符串常量中插入两个引号以表示单个引号,或者在字符常量中插入两个撇号以表示单个撇号字符。例如:
"He wrote a "" Hello World"" program as an example."
上述内容等价于:
He wrote a "Hello World" program as an example.
正如第一章所指出的,要创建一个单一的撇号字符常量,你需要在一对撇号中放入两个相邻的撇号:
''''
HLA 提供了其他一些功能,消除了对转义字符的需求。除了将两个相邻的字符串常量连接成一个更长的字符串常量外,HLA 还可以将任何相邻的字符常量和字符串常量的组合连接成一个单一的字符串常量:
'1' '2' '3' // Equivalent to "123"
"He wrote a " '"' "Hello World" '"' " program as an example."
请注意,前面示例中的两个He wrote字符串在 HLA 中是完全相同的。
HLA 提供了第二种指定字符常量的方法,可以处理所有其他 C/C++ 转义字符序列:ASCII 码字面量字符常量。这个字面量字符常量的形式使用以下语法:
#integer_constant
该形式创建一个字符常量,其值为由integer_constant指定的 ASCII 码。数字常量可以是十进制、十六进制或二进制值。例如:
#13 #$d #%1101 // All three are the same
// character, a carriage return.
因为你可以将字符字面量与字符串连接,而#constant形式是一个字符字面量,所以下列字符串都是合法的:
"Hello World" #13 #10 // #13 #10 is the Windows newline sequence
// (carriage return followed by line feed).
"Error: Bad Value" #7 // #7 is the bell character.
"He wrote a " #$22 "Hello World" #$22 " program as an example."
因为$22 是引号字符的 ASCII 码,所以这个最后的示例是He wrote字符串字面量的第三种形式。
4.2.3 const部分中的字符串和文本常量
const部分中的字符串和文本常量使用以下声明语法:
const
AStringConst: string := "123";
ATextConst: text := "123";
除了这两个常量的数据类型外,它们的声明是相同的。然而,它们在 HLA 程序中的行为却完全不同。
每当 HLA 在程序中遇到符号字符串常量时,它会用字符串字面量常量替换字符串名称。所以像stdout.put( AStringConst );这样的语句会将字符串123打印到显示屏上。这里没有什么意外。
每当 HLA 在程序中遇到符号文本常量时,它会用该字符串的文本内容(而不是字符串字面量常量)替换标识符。也就是说,HLA 会将定界引号之间的字符替换为符号文本常量。因此,考虑以下语句,根据上述声明,它是完全合法的:
mov( ATextConst, al ); // Equivalent to mov( 123, al );
注意,在这个示例中,将AStringConst替换为ATextConst是非法的:
mov( AStringConst, al ); // Equivalent to mov( "123", al );
这个后续示例是非法的,因为你不能将字符串字面量常量移入 AL 寄存器。
每当 HLA 在你的程序中遇到符号文本常量时,它会立即将文本常量的字符串值替换为该文本常量,并继续编译,就像你在程序中写入的是文本常量的值而不是符号标识符一样。如果你经常在程序中输入某些文本序列,这可以节省一些输入工作,并使你的程序更具可读性。例如,考虑在 HLA stdio.hhf库头文件中找到的nl(换行)文本常量声明:
const
nl: text := "#$d #$a"; // Windows version.
const
nl: text := " """" #$a"; // Linux, FreeBSD, and Mac OS X version.
每当 HLA 遇到符号nl时,它会立即将字符串"#$d #$a"的值替换为nl标识符。当 HLA 看到#$d(回车)字符常量后跟#$a(换行)字符常量时,它会将这两个字符连接成一个字符串,形成 Windows 换行序列(回车后跟换行)。考虑以下两个语句:
stdout.put( "Hello World", nl );
stdout.put( "Hello World" nl );
(请注意,上述第二个语句没有用逗号分隔字符串字面量和nl符号。)在第一个示例中,HLA 生成代码打印字符串Hello World,然后生成一些额外的代码打印换行符序列。在第二个示例中,HLA 将nl符号扩展如下:
stdout.put( "Hello World" #$d #$a );
现在 HLA 看到一个字符串字面常量(Hello World),后面跟着两个字符常量。它将这三者连接起来,形成一个单一的字符串,并通过一个调用来打印这个字符串。因此,省略字符串字面常量和 nl 符号之间的逗号可以生成略微更高效的代码。请记住,这仅适用于字符串字面常量。你不能通过这种技巧连接字符串变量,或将字符串变量与字符串字面常量连接起来。
Linux、FreeBSD 和 Mac OS X 用户应注意,Unix 的行尾序列仅是一个单一的换行符。因此,在这些操作系统中,nl 的声明略有不同(以确保 nl 始终展开为字符串常量,而不是字符常量)。
在常量部分,如果只指定常量标识符和字符串常量(即未提供类型),HLA 默认类型为 string。如果你想声明一个 text 常量,必须显式地提供类型。
const
AStrConst := "String Constant";
ATextConst: text := "mov( 0, eax );";
4.2.4 常量表达式
到目前为止,本章给人的印象是符号常量定义由标识符、可选类型和字面常量组成。实际上,HLA 常量声明可以比这复杂得多,因为 HLA 允许将常量表达式(而不仅仅是字面常量)赋值给符号常量。通用常量声明有以下两种形式:
*`Identifier`* : *`typeName`* := *`constant_expression`* ;
*`Identifier`* := *`constant_expression`* ;
常量表达式采用你在 C/C++ 和 Pascal 等高级语言中常见的形式。它们可以包含字面常量值、先前声明的符号常量和各种算术运算符。表 4-1 列出了常量表达式中可能的一些运算。
常量表达式运算符遵循标准的优先级规则;如果需要,你可以使用括号来覆盖优先级。有关确切的优先级关系,请参见 HLA 参考手册 webster.cs.ucr.edu/ 或 artofasm.com/。一般来说,如果优先级不明显,可以使用括号明确指出计算顺序。虽然 HLA 提供了比这些更多的运算符,但上述运算符是你最常用的;HLA 文档提供了常量表达式运算符的完整列表。
表 4-1. 常量表达式中允许的运算
| 算术运算符 |
|---|
−(一元取反) |
* |
div |
mod |
/ |
+ |
− |
| 比较运算符 |
| --- |
=, == |
<>, != |
< |
<= |
> |
>= |
| 逻辑运算符^([a]) |
| --- |
& |
| |
^ |
! |
|
^([a]) 提示给 C/C++和 Java 用户:HLA 的常量表达式使用完整的布尔求值,而不是短路布尔求值。因此,HLA 的常量表达式与 C/C++/Java 表达式的行为不同。
|
| 按位逻辑运算符 |
|---|
& |
| |
^ |
! |
| 字符串运算符 |
| --- |
'+' |
如果常量表达式中出现标识符,则该标识符必须是你在程序中的const或val部分之前定义的常量标识符。你不能在常量表达式中使用变量标识符,因为在 HLA 求值常量表达式时,变量的值在编译时并未定义。另外,请不要混淆编译时操作和运行时操作:
// Constant expression, computed while HLA is compiling your program:
const
x := 5;
y := 6;
Sum := x + y;
// Runtime calculation, computed while your program is running, long after
// HLA has compiled it:
mov( x, al );
add( y, al );
HLA 在编译期间直接解释常量表达式的值。它不会发出任何机器指令来计算上面常量表达式中的x + y。相反,它会直接计算这两个常量值的和。从那时起,HLA 将常量Sum与值 11 关联,就像程序中包含了语句Sum := 11;而不是Sum := x + y;一样。另一方面,HLA 不会在 AL 中预计算指令mov和add的值;它忠实地发出这两个指令的目标代码,80x86 将在程序运行时(编译完成后某个时刻)计算它们的和。
一般来说,常量表达式在汇编语言程序中不会非常复杂。通常,你只是对两个整数值进行加法、减法或乘法。例如,以下 const 区段定义了一组具有连续值的常量:
const
TapeDAT := 0;
Tape8mm := TapeDAT + 1;
TapeQIC80 := Tape8mm + 1;
TapeTravan := TapeQIC80 + 1;
TapeDLT := TapeTravan + 1;
上述常量具有以下值:TapeDAT=0、Tape8mm=1、TapeQIC80=2、TapeTravan=3 和 TapeDLT=4。
4.2.5 HLA 程序中的多个 const 区段及其顺序
尽管 const 区段必须出现在 HLA 程序的声明区段中(例如,在程序 pgmname; 头部和相应的 begin pgmname; 语句之间),但它不必出现在声明区段中的其他任何位置之前或之后。事实上,和变量声明区段一样,你可以在声明区段中放置多个 const 区段。HLA 常量声明的唯一限制是,必须在程序中使用任何常量符号之前先声明该符号。
一些 C/C++ 程序员,例如,更习惯于按如下方式编写常量声明(因为这更接近 C/C++ 声明常量的语法):
const TapeDAT := 0;
const Tape8mm := TapeDAT + 1;
const TapeQIC80 := Tape8mm + 1;
const TapeTravan := TapeQIC80 + 1;
const TapeDLT := TapeTravan + 1;
在程序中,const 区段的位置似乎是程序员个人的问题。除了要求在使用常量之前定义所有常量外,你可以随意将 const 声明区段插入到声明区段的任何位置。
4.2.6 HLA val 区段
你不能更改在 const 区段中定义的常量的值。虽然这似乎是完全合理的(毕竟常量应该是常量),但我们可以通过不同的方式来定义 "常量" 这个术语,而 const 对象遵循的是一个特定定义的规则。HLA 的 val 区段允许你定义遵循略有不同规则的常量对象。本节讨论了 val 区段及 val 常量与 const 常量之间的区别。
"const 属性" 可以在两个不同的时刻存在:当 HLA 正在编译程序时,以及程序执行时(此时 HLA 不再运行)。所有合理的常量定义都要求常量的值在程序运行时保持不变。至于常量的值是否可以在编译时发生变化,这是一个单独的问题。HLA const 对象和 HLA val 对象的区别在于,常量的值是否可以在编译时发生变化。
一旦你在 const 区段中定义了常量,该常量的值从此以后将不可更改,无论是在运行时还是在 HLA 编译程序时。因此,像 mov( SymbolicCONST, eax ); 这样的指令始终将相同的值移动到 EAX 寄存器中,无论该指令在 HLA 主程序中出现的位置如何。一旦你在 const 区段中定义了符号 SymbolicCONST,从那时起该符号的值就始终不变。
HLA 的val部分允许你声明符号常量,就像const部分一样。然而,HLA 的val常量可以在程序的整个源代码中更改其值。以下 HLA 声明是完全合法的:
val InitialValue := 0;
const SomeVal := InitialValue + 1; // = 1
const AnotherVal := InitialValue + 2; // = 2
val InitialValue := 100;
const ALargerVal := InitialValue; // = 100
const LargeValTwo := InitialValue*2; // = 200
所有出现在const部分的符号都使用符号值InitialValue作为定义的一部分。然而,请注意,InitialValue在这段代码序列中的不同位置有不同的值;在代码序列的开始,InitialValue的值为 0,而在后面它的值为 100。
请记住,在运行时,val对象不是变量;它仍然是一个常量,HLA 会用val标识符当前的值替代该标识符。^([49]) 像mov( 25, InitialValue );这样的语句和mov( 25, 0 );或mov( 25, 100 );一样不合法。
4.2.7 在程序中的任意位置修改val对象
如果你在声明部分声明了所有的val对象,看起来你就无法在程序的begin和end语句之间更改val对象的值。毕竟,val部分必须出现在程序的声明部分,而声明部分在begin语句之前就结束了。在第九章中,你将学到,大多数val对象的修改发生在begin和end语句之间;因此,HLA 必须提供某种方式来在声明部分之外更改val对象的值。实现这一点的机制是?操作符。HLA 不仅允许你在声明部分之外更改val对象的值,而且还允许你几乎在程序的任何地方更改val对象的值。在 HLA 程序中,凡是允许空格的位置,你都可以插入如下形式的语句
? *`ValIdentifier`* := *`constant_expression`*;
这意味着你可以编写一个像示例 4-4 中出现的简短程序。
示例 4-4:使用?操作符演示val重新定义
program VALdemo;
#include( "stdlib.hhf" )
val
NotSoConstant := 0;
begin VALdemo;
mov( NotSoConstant, eax );
stdout.put( "EAX = ", (type uns32 eax ), nl );
?NotSoConstant := 10;
mov( NotSoConstant, eax );
stdout.put( "EAX = ", (type uns32 eax ), nl );
?NotSoConstant := 20;
mov( NotSoConstant, eax );
stdout.put( "EAX = ", (type uns32 eax ), nl );
?NotSoConstant := 30;
mov( NotSoConstant, eax );
stdout.put( "EAX = ", (type uns32 eax ), nl );
end VALdemo;
^([48]) 这不是一个完整的列表,HLA 还支持 64 位和 128 位数据类型。我们将在第八章中讨论这些类型。
^([49]) 在此上下文中,当前指的是回顾源代码时最后一次赋值给val对象的值。
4.3 HLA 类型部分
假设你就是不喜欢 HLA 用来声明byte、word、dword、real和其他变量的名称。假设你更喜欢 Pascal 的命名约定,或者可能是 C 的命名约定。你希望使用诸如integer、float、double等术语。如果 HLA 像 Pascal 一样,你可以在程序的type部分重新定义这些名称。如果是 C,你可以使用#define或typedef语句来完成这项任务。好吧,HLA 和 Pascal 一样,也有自己的type语句,它也允许你创建这些名称的别名。以下示例演示了如何在 HLA 程序中设置一些与 C/C++/Pascal 兼容的名称:
type
integer: int32;
float: real32;
double: real64;
colors: byte;
现在你可以用更有意义的语句声明你的变量,例如:
static
i: integer;
x: float;
HouseColor: colors;
如果你使用 Ada、C/C++或 FORTRAN(或其他任何语言),你可以选择你更习惯的类型名称。当然,这并不会改变 80x86 或 HLA 对这些变量的反应,但它确实让你创建的程序更易于阅读和理解,因为类型名称更能反映实际的底层类型。给 C/C++程序员的一个警告:不要太兴奋,去定义一个int数据类型。不幸的是,int是 80x86 的机器指令(中断),因此它在 HLA 中是一个保留字。
type部分不仅仅用于创建类型同构(即,为现有类型指定新名称)。以下部分演示了你可以在type部分执行的许多操作。
4.4 enum与 HLA 枚举数据类型
在之前讨论常量和常量表达式的部分,你看到了以下示例:
const TapeDAT := 0;
const Tape8mm := TapeDAT + 1;
const TapeQIC80 := Tape8mm + 1;
const TapeTravan := TapeQIC80 + 1;
const TapeDLT := TapeTravan + 1;
这个示例演示了如何使用常量表达式来开发一组包含独特、连续值的常量。然而,这种方法存在一些问题。首先,它涉及大量的输入(以及在复查该程序时额外的阅读工作)。其次,在创建长列表的独特常量时,很容易犯错,可能会重复使用或跳过某些值。HLA 的enum类型提供了一种更好的方法来创建具有独特值的常量列表。
enum是 HLA 的一种类型声明,它允许你将一系列名称与新类型关联。HLA 会将一个独特的值与每个名称关联(即,它对列表进行枚举)。enum关键字通常出现在type部分,你可以按照以下方式使用它:
type
*`enumTypeID`*: enum { *`comma_separated_list_of_names`* };
符号enumTypeID成为一个新类型,其值由名称列表指定。作为一个具体的例子,考虑数据类型TapeDrives及其对应的变量声明类型TapeDrives:
type
TapeDrives: enum{ TapeDAT, Tape8mm, TapeQIC80, TapeTravan, TapeDLT};
static
BackupUnit: TapeDrives := TapeDAT;
.
.
.
mov( BackupUnit, al );
if( al = Tape8mm ) then
...
endif;
// etc.
默认情况下,HLA 为枚举数据类型保留 1 字节的存储空间。因此,BackupUnit 变量将消耗 1 字节的内存,你通常会使用一个 8 位寄存器来访问它。^([50]) 至于常量,HLA 将从 0 开始依次为每个枚举标识符分配 uns8 常量值。在 TapeDrives 示例中,磁带驱动器标识符的值为 TapeDAT=0、Tape8mm=1、TapeQIC80=2、TapeTravan=3 和 TapeDLT=4。你可以像在 const 部分定义这些常量并赋值一样,直接使用这些常量。
^([50]) HLA 提供了一种机制,你可以通过它指定枚举数据类型消耗 2 或 4 字节的内存。有关更多细节,请参见 HLA 文档。
4.5 指针数据类型
你可能已经在 Pascal、C 或 Ada 等编程语言中亲身体验过指针,而且现在可能有些担心。几乎每个人第一次接触指针时都会有糟糕的体验。别怕!指针在汇编语言中的处理实际上比在高级语言中要简单。此外,你在使用指针时遇到的许多问题,可能与指针本身无关,而是你尝试用它们实现的链表和树形数据结构的问题。另一方面,指针在汇编语言中的用途非常广泛,且与链表、树和其他复杂的数据结构无关。实际上,像数组和记录这样简单的数据结构也常常涉及使用指针。所以,如果你对指针有根深蒂固的恐惧,忘掉你对它们的所有认知吧。你将会发现,指针其实是很棒的。
可能最好的入门方式是从指针的定义开始。指针究竟是什么呢?不幸的是,高级语言像 Pascal 常常将指针的简单性隐藏在抽象的墙后。这种额外的复杂性(顺便说一下,这是有充分理由的)往往会让程序员感到害怕,因为他们不理解发生了什么。
如果你害怕指针,那就暂时忽略它们,先使用数组。考虑下面这个 Pascal 数组声明:
M: array [0..1023] of integer;
即使你不知道 Pascal,理解这个概念也非常简单。M 是一个包含 1,024 个整数的数组,索引从 M[0] 到 M[1023]。这些数组元素中的每一个都可以保存一个整数值,并且每个值都与其他元素的值无关。换句话说,这个数组为你提供了 1,024 个不同的整数变量,每个变量都通过数字(数组索引)而非名称来访问。
如果你遇到一个程序,其中有语句 M[0]:=100;,你大概不需要多想就知道这条语句在做什么。它正在把值 100 存储到数组 M 的第一个元素中。现在考虑以下两条语句:
i := 0; (* Assume "i" is an integer variable. *)
M [i] := 100;
你应该不太犹豫就同意,这两条语句执行的操作和M[0]:=100;是一样的。实际上,你可能会同意,你可以使用任何范围在 0 到 1,023 之间的整数表达式作为这个数组的索引。以下语句依然执行与我们对索引 0 的单一赋值相同的操作:
i := 5; (* Assume all variables are integers.*)
j := 10;
k := 50;
m [i*j-k] := 100;
“好了,那么重点是什么?”你可能在想。“任何生成 0 到 1,023 范围内整数的东西都是合法的,那又怎么样?”好吧,那以下的情况怎么样:
M [1] := 0;
M [ M [1] ] := 100;
哇!这一点可能需要一点时间消化。不过,如果你慢慢来,它是有道理的,你会发现这两条指令实际上执行的是你一直在做的相同操作。第一条语句将 0 存储到数组元素M[1]中。第二条语句取出M[1]的值,这是一个整数,因此你可以将其用作数组M的索引,并使用该值(0)来控制它存储 100 的地方。
如果你愿意接受上述内容作为合理的,或许有些奇怪,但依然可用的情况,那么你就不会对指针有任何问题了。因为 M[1] 就是一个指针! 其实不完全是,但如果你将M看作“内存”,并将这个数组视为整个内存,那么这就是指针的确切定义。指针仅仅是一个内存位置,其值是某个其他内存位置的地址(或者说索引,如果你喜欢这样称呼的话)。在汇编语言程序中,指针的声明和使用非常简单,你甚至不必担心数组索引之类的问题。
4.5.1 在汇编语言中使用指针
HLA 指针是一个 32 位的值,可能包含某个其他变量的地址。如果你有一个dword类型的变量p,它的值是$1000_0000,那么p“指向”内存位置$1000_0000。要访问p所指向的dword,你可以使用类似以下的代码:
mov( p, ebx ); // Load ebx with the value of pointer p.
mov( [ebx], eax ); // Fetch the data that p points at.
通过将p的值加载到 EBX 中,这段代码将$1000_0000 的值加载到 EBX 中(假设p包含$1000_0000,因此指向内存位置$1000_0000)。上面第二条指令将 EAX 寄存器加载为从 EBX 中偏移量所指向的位置开始的dword。因为 EBX 现在包含$1000_0000,所以这将从$1000_0000 到$1000_0003 的位置加载 EAX。
为什么不直接使用像mov( mem, eax )这样的指令从位置$1000_0000 加载 EAX?(假设mem位于地址$1000_0000)其实有很多原因。不过最主要的原因是,这条mov指令总是从位置mem加载 EAX。你无法更改它加载 EAX 的地址。然而,前面的指令总是从p所指向的位置加载 EAX。这在程序控制下非常容易改变。实际上,简单的指令mov( &mem2, p );会使得上面那两条指令在下次执行时,从mem2加载 EAX。考虑以下指令序列:
mov( &i, p ); // Assume all variables are STATIC variables.
.
.
.
if( *`some_expression`* ) then
mov( &j, p ); // Assume the code above skips this instruction
. // and you get to the next instruction by
. // jumping to this point from somewhere else.
.
endif;
mov( p, ebx ); // Assume both of the above code paths wind up
mov( [ebx], eax ); // down here.
这个简单示例演示了程序的两条执行路径。第一条路径将变量 p 加载为变量 i 的地址。第二条路径通过代码将 p 加载为变量 j 的地址。这两条执行路径在最后两条 mov 指令处汇合,根据执行路径的不同,将 i 或 j 加载到 EAX 寄存器中。在许多方面,这就像在高级语言(如 Pascal)中的参数。执行相同的指令时,取决于哪个地址(i 或 j)最终存储在 p 中,程序访问不同的变量。
4.5.2 在 HLA 中声明指针
由于指针是 32 位长,您可以简单地使用 dword 类型为指针分配存储空间。但是,有一种更好的方法:HLA 提供了 pointer to 短语专门用于声明指针变量。考虑以下示例:
static
b: byte;
d: dword;
pByteVar: pointer to byte := &b;
pDWordVar: pointer to dword := &d;
这个示例演示了在 HLA 中初始化和声明指针变量是可能的。请注意,您只能使用取地址符号(address-of operator)获取静态变量(static、readonly 和 storage 对象)的地址,因此只能使用静态对象的地址来初始化指针变量。
您还可以在 HLA 程序的 type 部分定义自己的指针类型。例如,如果您经常使用指向字符的指针,您可能希望使用如下所示的 type 声明。
type
ptrChar: pointer to char;
static
cString: ptrChar;
4.5.3 指针常量与指针常量表达式
HLA 允许两种字面指针常量形式:取地址符号后跟静态变量的名称,或者常量 NULL。除了这两种字面指针常量外,HLA 还支持简单的指针常量表达式。
NULL 指针是常量 0。零是一个非法地址,如果您在现代操作系统下尝试访问它,会引发异常。程序通常用 NULL 初始化指针,以表示指针显式地未初始化为有效地址。
除了简单的地址字面量和值 0 之外,HLA 还允许在任何指针常量合法的地方使用非常简单的常量表达式。指针常量表达式有以下三种形式之一:
&*`StaticVarName`* [ *`PureConstantExpression`* ]
&*`StaticVarName`* + *`PureConstantExpression`*
&*`StaticVarName`* - *`PureConstantExpression`*
PureConstantExpression 术语指的是不涉及任何指针常量的数字常量表达式。这种类型的表达式生成一个内存地址,该地址是 StaticVarName 变量在内存中指定字节数之前或之后(分别为 − 或 +)。请注意,上面两种形式在语义上是等效的;它们都返回一个指针常量,其地址是静态变量和常量表达式的总和。
由于你可以创建指针常量表达式,发现 HLA 允许你在const部分定义指针常量并不令人惊讶。程序在示例 4-5 中演示了如何实现这一点。
示例 4-5. HLA 程序中的指针常量表达式
program PtrConstDemo;
#include( "stdlib.hhf" );
static
b: byte := 0;
byte 1, 2, 3, 4, 5, 6, 7;
const
pb := &b + 1;
begin PtrConstDemo;
mov( pb, ebx );
mov( [ebx], al );
stdout.put( "Value at address pb = $", al, nl );
end PtrConstDemo;
执行时,该程序打印出位于内存中b之后的字节的值(该字节的值为$01)。
4.5.4 指针变量与动态内存分配
指针变量是存储 HLA 标准库mem.alloc函数返回结果的理想地方。mem.alloc函数返回分配的存储空间的地址,存储在 EAX 寄存器中;因此,你可以在调用mem.alloc后,立即使用单条mov指令将地址直接存储到指针变量中:
type
bytePtr: pointer to byte;
var
bPtr: bytePtr;
.
.
.
mem.alloc( 1024 ); // Allocate a block of 1,024 bytes.
mov( eax, bPtr ); // Store address of block in bPtr.
.
.
.
mem.free( bPtr ); // Free the allocated block when done using it.
.
.
.
4.5.5 常见指针问题
程序员在使用指针时常常会遇到五个常见的问题。这些错误中的一些会导致程序立即停止,并显示诊断信息;其他问题则更加隐蔽,可能导致程序结果不正确,但不会报告错误,或者仅影响程序的性能,而不显示错误。以下是这五个问题:
-
使用未初始化的指针
-
使用包含非法值(例如,
NULL)的指针 -
在该存储空间被释放后,继续使用通过
mem.alloc分配的存储空间 -
在程序使用完存储空间后未调用
mem.free释放该存储空间 -
使用错误的数据类型访问间接数据
上述第一个问题是使用指针变量时,尚未为指针分配有效的内存地址。初学者通常没有意识到,声明一个指针变量只是为指针本身保留存储空间;它并不为指针引用的数据保留存储空间。示例 4-6 中的简短程序演示了这个问题。
示例 4-6. 未初始化指针演示
// Program to demonstrate use of
// an uninitialized pointer. Note
// that this program should terminate
// with a Memory Access Violation exception.
program UninitPtrDemo;
#include( "stdlib.hhf" );
static
// Note: By default, variables in the
// static section are initialized with
// zero (NULL) hence the following
// is actually initialized with NULL,
// but that will still cause our program
// to fail because we haven't initialized
// the pointer with a valid memory address.
Uninitialized: pointer to byte;
begin UninitPtrDemo;
mov( Uninitialized, ebx );
mov( [ebx], al );
stdout.put( "Value at address Uninitialized: = $", al, nl );
end UninitPtrDemo;
尽管你在static部分声明的变量在技术上是初始化的,但静态初始化仍然未能为该程序中的指针赋予有效地址(它们被初始化为0,即NULL)。
当然,在 80x86 中并没有真正的未初始化变量。你真正拥有的是已经显式赋予初始值的变量,以及那些恰好继承了在分配变量存储空间时内存中所存储的位模式的变量。大多数情况下,这些在内存中残留的垃圾位模式并不对应有效的内存地址。试图解引用这样的指针(即访问它所指向的内存中的数据)通常会引发内存访问违规异常。
然而,有时候,内存中的随机位恰好可能对应一个有效的内存位置,你可以访问。在这种情况下,CPU 会访问指定的内存位置,而不会终止程序。虽然对于一个天真的程序员来说,这种情况似乎比停止程序要好,但实际上这是更糟糕的,因为你的缺陷程序会继续运行,而不会提醒你发生了问题。如果你通过一个未初始化的指针存储数据,你很可能会覆盖内存中其他重要变量的值。这个缺陷可能会在你的程序中产生一些非常难以定位的问题。
程序员在使用指针时的第二个问题是将无效的地址值存储到指针中。上述第一个问题实际上是第二个问题的一个特例(内存中的垃圾位提供了无效地址,而不是由于计算错误由你自己产生的)。其影响是相同的;如果你尝试解引用一个包含无效地址的指针,要么会触发内存访问违规(Memory Access Violation)异常,要么会访问到一个意外的内存位置。
上述列出的第三个问题也被称为悬空指针问题。要理解这个问题,请考虑以下代码片段:
mem.alloc( 256 ); // Allocate some storage.
mov( eax, ptr ); // Save address away in a pointer variable.
.
. // Code that uses the pointer variable ptr.
.
mem.free( ptr ); // Free the storage associated with ptr.
.
. // Code that does not change the value in ptr.
.
mov( ptr, ebx );
mov( al, [ebx] );
在这个示例中,你会注意到程序分配了 256 字节的存储空间,并将该存储空间的地址保存在ptr变量中。然后代码使用这一块 256 字节的内存一段时间,释放了存储空间,将其交还给系统以供其他用途。请注意,调用mem.free并不会以任何方式改变ptr的值;ptr仍然指向之前通过mem.alloc分配的内存块。实际上,mem.free并不会改变这块内存中的任何数据,因此在从mem.free返回后,ptr仍然指向代码存入该内存块的数据。然而,需要注意的是,调用mem.free是告诉系统程序不再需要这块 256 字节的内存,系统可以将这块内存用于其他用途。mem.free函数并不能强制保证你将永远不会再访问这些数据;你只是承诺不会这么做。当然,上述代码片段违反了这个承诺;正如你在上面的最后两条指令中看到的,程序获取了ptr中的值并访问了它所指向的内存数据。
悬挂指针的最大问题是,您可以在很大一部分时间里不出问题地使用它们。只要系统没有重用您已释放的存储,使用悬挂指针对程序没有不良影响。然而,每次调用 mem.alloc 时,系统可能决定重用先前通过 mem.free 释放的内存。当发生这种情况时,任何试图解引用悬挂指针的操作可能会导致一些意外后果。问题的范围从读取已被覆盖的数据(由数据存储的合法新用途覆盖),到覆盖新数据,再到(最糟糕的情况)覆盖系统堆管理指针(这样做可能会导致程序崩溃)。解决方案很明确:一旦释放与指针相关联的存储,就永远不要再使用该指针值。
所有问题中,第四个问题(未释放分配的存储)对程序正常运行的影响可能最小。以下代码片段演示了这个问题:
mem.alloc( 256 );
mov( eax, ptr );
. // Code that uses the data where ptr is pointing.
. // This code does not free up the storage
. // associated with ptr.
mem.alloc( 512 );
mov( eax, ptr );
// At this point, there is no way to reference the original
// block of 256 bytes pointed at by ptr.
在这个示例中,程序分配了 256 字节的存储,并通过 ptr 变量引用该存储。稍后,程序又分配了另一个内存块,并将 ptr 中的值覆盖为这个新内存块的地址。请注意,ptr 中的原始值丢失了。由于程序不再拥有这个地址值,因此无法调用 mem.free 来释放存储以供后续使用。结果,这块内存不再对程序可用。虽然使 256 字节的内存对程序不可访问看起来不算大问题,但请想象这段代码在一个不断重复的循环中。每次执行循环时,程序都会丢失另外 256 字节的内存。在足够多次的循环迭代后,程序将耗尽堆上可用的内存。这个问题通常被称为 内存泄漏,因为其效果就像是内存数据在程序执行过程中从计算机中“泄漏”出去(导致可用存储空间越来越少)。
内存泄漏的危害远小于悬挂指针。实际上,内存泄漏只有两个问题:一是堆空间耗尽的危险(最终可能导致程序中止,尽管这种情况很少发生),二是由于虚拟内存页面交换导致的性能问题。尽管如此,您应该养成在使用完所有存储后立即释放它们的习惯。当您的程序退出时,操作系统会回收所有存储,包括因内存泄漏而丢失的数据。因此,通过泄漏丢失的内存仅对您的程序丢失,而非整个系统。
指针的最后一个问题是缺乏类型安全的访问。这可能是因为 HLA 无法且不强制执行指针类型检查。例如,考虑 示例 4-7 中的程序。
示例 4-7. 类型不安全的指针访问示例
// Program to demonstrate use of
// lack of type checking in pointer
// accesses.
program BadTypePtrDemo;
#include("stdlib.hhf" );
static
ptr: pointer to char;
cnt: uns32;
begin BadTypePtrDemo;
// Allocate sufficient characters
// to hold a line of text input
// by the user:
mem.alloc( 256 );
mov( eax, ptr );
// Okay, read the text a character
// at a time by the user:
stdout.put( "Enter a line of text: " );
stdin.flushInput();
mov( 0, cnt );
mov( ptr, ebx );
repeat
stdin.getc(); // Read a character from the user.
mov( al, [ebx] ); // Store the character away.
inc( cnt ); // Bump up count of characters.
inc( ebx ); // Point at next position in memory.
until( stdin.eoln());
// Okay, we've read a line of text from the user,
// now display the data:
mov( ptr, ebx );
for( mov( cnt, ecx ); ecx > 0; dec( ecx )) do
mov( [ebx], eax );
stdout.put( "Current value is $", eax, nl );
inc( ebx );
endfor;
mem.free( ptr );
end BadTypePtrDemo;
这个程序从用户输入数据作为字符值,然后将数据显示为双字节的十六进制值。虽然汇编语言的一个强大特性是它允许你随意忽略数据类型,并且自动将数据强制转换而无需任何努力,但这种强大也是一把双刃剑。如果你犯了错误,使用错误的数据类型访问间接数据,HLA 和 80x86 可能无法捕捉到这个错误,你的程序可能会产生不准确的结果。因此,在你的程序中使用指针和间接寻址时,必须小心,确保数据类型的一致性。
4.6 复合数据类型
复合数据类型,也叫做聚合数据类型,是由其他(通常是标量)数据类型构建而成的。本章将介绍几个重要的复合数据类型——字符字符串、字符集、数组、记录和联合体。字符串就是一个很好的复合数据类型的例子;它是由一系列单独的字符和一些其他数据构成的数据结构。
4.7 字符字符串
在整数值之后,字符字符串可能是现代程序中使用的最常见的数据类型。80x86 确实支持一些字符串指令,但这些指令实际上是为了块内存操作而设计的,而不是针对字符字符串的特定实现。因此,本节将主要集中在 HLA 字符字符串的定义,并讨论 HLA 标准库中提供的字符串处理例程。
一般来说,字符字符串是一系列 ASCII 字符,具有两个主要属性:长度和一些字符数据。不同的语言使用不同的数据结构来表示字符串。为了更好地理解 HLA 字符串设计背后的思路,看看两种由不同高级语言推广的字符串表示方法可能是很有帮助的。
毫无疑问,零终止字符串可能是目前使用最广泛的字符串表示形式,因为这是 C、C++、C#、Java 以及其他语言的本地字符串格式。零终止字符串由一系列零个或多个 ASCII 字符组成,并以一个 0 字节结尾。例如,在 C/C++ 中,字符串 "abc" 需要 4 个字节:三个字符 'a'、'b' 和 'c',后跟一个 0 字节。如你将很快看到的,HLA 字符串与零终止字符串向上兼容,但在此之前,你应该注意,在 HLA 中创建零终止字符串非常简单。最简单的方法是在 static 区段中使用如下代码:
static
zeroTerminatedString: char; @nostorage;
byte "This is the zero-terminated string", 0;
记住,当使用 @nostorage 选项时,HLA 不为变量保留任何空间,因此 zeroTerminatedString 变量在内存中的地址对应于以下 byte 指令中的第一个字符。每当字符字符串出现在 byte 指令中时,如同这里所示,HLA 会将字符串中的每个字符依次输出到连续的内存位置。字符串末尾的 0 值终止了这个字符串。
HLA 支持 zstring 数据类型。然而,这些对象是双字指针,包含一个指向 zstring 的地址,而不是零终止字符串本身。以下是 zstring 声明(和静态初始化)的示例:
static
zeroTerminatedString: char; @nostorage;
byte "This is the zero-terminated string", 0;
zstrVar: zstring := &zeroTerminatedString;
零终止字符串有两个主要特点:它们非常容易实现,并且字符串长度没有限制。另一方面,零终止字符串也有一些缺点。首先,虽然通常不重要,零终止字符串不能包含 NUL 字符(其 ASCII 码为 0)。通常这不是问题,但偶尔会带来麻烦。零终止字符串的第二个问题是,许多操作相对低效。例如,为了计算零终止字符串的长度,你必须扫描整个字符串,寻找那个 0 字节(即计算字符直到遇到 0)。下面的程序片段演示了如何计算上述字符串的长度:
mov( &zeroTerminatedString, ebx );
mov( 0, eax );
while( (type byte [ebx+eax]) <> 0 ) do
inc( eax );
endwhile;
// String length is now in eax.
如你从这段代码中看到的,计算字符串长度所需的时间与字符串的长度成正比;随着字符串变长,计算其长度所需的时间也会增加。
第二种字符串格式,长度前缀字符串,克服了零终止字符串的一些问题。长度前缀字符串在像 Pascal 这样的语言中很常见;它们通常由一个长度字节和零个或多个字符值组成。第一个字节指定字符串的长度,接下来的字节(直到指定的长度)是字符数据。在长度前缀方案中,字符串abc将由 4 个字节组成,分别是 $03(字符串长度)后跟 a、b 和 c。你可以在 HLA 中使用如下代码创建长度前缀字符串:
static
lengthPrefixedString:char; @nostorage;
byte 3, "abc";
提前计算字符数并将其插入到字节声明中,如同这里所做的那样,可能看起来是一项麻烦的工作。幸运的是,有一些方法可以让 HLA 自动计算字符串的长度。
长度前缀字符串解决了与零终止字符串相关的两个主要问题。可以在长度前缀字符串中包含 NUL 字符,并且那些在零终止字符串上相对低效的操作(例如,计算字符串长度)在使用长度前缀字符串时更高效。然而,长度前缀字符串也有它们自己的缺点。主要的缺点是它们的长度最大限制为 255 个字符(假设使用 1 字节长度前缀)。
HLA 使用一种扩展的字符串方案,该方案与零终止字符串和长度前缀字符串向上兼容。HLA 字符串享有零终止字符串和长度前缀字符串的优点,而没有它们的缺点。事实上,HLA 字符串相对于这些其他格式的唯一缺点是,它比零终止或长度前缀字符串消耗了几个额外的字节(HLA 字符串的开销为 9 到 12 字节,而零终止或长度前缀字符串的开销为 1 字节,开销是指实际字符之外所需的字节数)。
HLA 字符串值由四个组件组成。第一个元素是一个双字值,指定字符串可以容纳的最大字符数。第二个元素是一个双字值,指定字符串的当前长度。第三个组件是字符串中的字符序列。最后一个组件是一个零终止字节。你可以使用以下代码在 static 区段创建一个兼容 HLA 的字符串:^([51])
static
align(4);
dword 11;
dword 11;
TheString: char; @nostorage;
byte "Hello there";
byte 0;
请注意,HLA 字符串关联的地址是第一个字符的地址,而不是最大值或当前长度值。
“那么当前字符串长度和最大字符串长度有什么区别呢?”你可能会想。在一个字面量字符串中,它们通常是相同的。然而,当你在运行时为字符串变量分配存储时,通常会指定字符串可以容纳的最大字符数。当你将实际的字符串数据存储到字符串中时,存储的字符数必须小于或等于这个最大值。如果你尝试超过这个最大长度,HLA 标准库的字符串例程将抛出异常(这是 C/C++ 和 Pascal 格式无法做到的)。
HLA 字符串末尾的终止 0 字节使得你可以将 HLA 字符串视为一个零终止字符串,如果这样做更高效或更方便。例如,大多数对 Windows、Mac OS X、FreeBSD 和 Linux 的调用都需要零终止字符串作为它们的字符串参数。在 HLA 字符串末尾放置一个 0 确保与操作系统以及使用零终止字符串的其他库模块的兼容性。
^([51]) 事实上,HLA 字符串在内存中的存放位置有一些限制。本文不涉及这些问题。有关更多详细信息,请参见 HLA 文档。
4.8 HLA 字符串
如前一节所述,HLA 字符串由四个组件组成:最大长度、当前字符串长度、字符数据和零终止字节。然而,HLA 从不要求你手动创建字符串数据来发出这些组件。HLA 足够智能,当它看到字符串常量时,会自动为你构建这些数据。因此,如果你使用以下字符串常量,请理解在某个地方 HLA 会在内存中为你创建这个四组件字符串:
stdout.put( "This gets converted to a four-component string by HLA" );
HLA 实际上并不直接处理前一部分描述的字符串数据。相反,当 HLA 看到一个字符串对象时,它总是处理指向该对象的指针,而不是直接处理对象。毫无疑问,这是关于 HLA 字符串最重要的事实,也是初学者在处理 HLA 字符串时遇到问题的最大源头:字符串是指针! 一个字符串变量占用的空间正好是 4 个字节,与指针相同(因为它就是一个指针!)。说完这些,我们来看一下 HLA 中一个简单的字符串变量声明:
static
StrVariable: string;
因为字符串变量是指针,所以你必须在使用它之前初始化它。你可以通过三种常见方式来初始化一个合法字符串地址的字符串变量:使用静态初始化器,使用 str.alloc 例程,或者调用其他初始化字符串或返回字符串指针的 HLA 标准库函数。
在一个允许初始化变量(static 和 readonly)的静态声明部分,你可以使用标准初始化语法来初始化一个字符串变量。例如:
static
InitializedString: string := "This is my string";
注意,这并不会用字符串数据初始化字符串变量。相反,HLA 会在一个特殊的、隐藏的内存段中创建字符串数据结构(参见 4.7 字符串),并将 InitializedString 变量初始化为该字符串第一个字符的地址(This 中的 T)。记住,字符串是指针! HLA 编译器将实际的字符串数据放置在只读内存段中。因此,您无法在运行时修改此字符串字面量的字符。然而,由于字符串变量(记住,它是一个指针)位于 static 部分,因此你可以更改字符串变量,使其指向不同的字符串数据。
因为字符串变量是指针,你可以将字符串变量的值加载到一个 32 位寄存器中。指针本身指向字符串的第一个字符位置。你可以在这个地址前的双字 4 个字节中找到当前字符串的长度,并且可以在这个地址前的双字 8 个字节中找到字符串的最大长度。示例 4-8 中的程序演示了一种访问这些数据的方法。^([52])
示例 4-8:访问字符串的长度和最大长度字段
// Program to demonstrate accessing Length and Maxlength fields of a string.
program StrDemo;
#include( "stdlib.hhf" );
static
theString:string := "String of length 19";
begin StrDemo;
mov( theString, ebx ); // Get pointer to the string.
mov( [ebx-4], eax ); // Get current length.
mov( [ebx-8], ecx ); // Get maximum length.
stdout.put
(
"theString = '", theString, "'", nl,
"length( theString )= ", (type uns32 eax ), nl,
"maxLength( theString )= ", (type uns32 ecx ), nl
);
end StrDemo;
在访问字符串变量的各个字段时,像 Example 4-8 中那样使用固定的数字偏移量是不明智的。未来,HLA 字符串的定义可能会稍作更改,特别是最大长度和长度字段的偏移量可能会发生变化。访问字符串数据的更安全方法是通过使用str.strRec数据类型来强制转换你的字符串指针。str.strRec数据类型是一个record数据类型(参见 4.25 Records),它为string数据类型中长度和最大长度字段的偏移量定义了符号名称。如果将来 HLA 版本中长度和最大长度字段的偏移量发生变化,那么str.strRec中的定义也会随之变化。因此,如果使用str.strRec,那么重新编译程序会自动对程序进行必要的更改。
要正确使用str.strRec数据类型,首先必须将字符串指针加载到一个 32 位寄存器中;例如,mov( SomeString, ebx );。一旦字符串数据的指针被加载到寄存器中,就可以使用 HLA 构造 (type str.strRec [ebx]) 将该寄存器强制转换为str.strRec数据类型。最后,要访问长度或最大长度字段,可以分别使用(type str.strRec [ebx]).length 或(type str.strRec [ebx]).maxlen。虽然这涉及更多的输入(相比于使用简单的偏移量,如−4 或−8),但这些形式比直接使用数字偏移量更加描述性且更安全。示例程序 Example 4-9 通过使用str.strRec数据类型,修正了 Example 4-8 中的例子。
示例 4-9。正确访问字符串的length和maxlen字段
// Program to demonstrate accessing length and maxlen fields of a string
program LenMaxlenDemo;
#include( "stdlib.hhf" );
static
theString:string := "String of length 19";
begin LenMaxlenDemo;
mov( theString, ebx ); // Get pointer to the string.
mov( (type str.strRec [ebx]).length, eax ); // Get current length.
mov( (type str.strRec [ebx]).maxlen, ecx ); // Get maximum length.
stdout.put
(
"theString = ", theString, "'", nl,
"length( theString )= ", (type uns32 eax ), nl,
"maxLength( theString )= ", (type uns32 ecx ), nl
);
end LenMaxlenDemo;
在 HLA 中操作字符串的第二种方法是分配堆内存来存储字符串数据。由于字符串不能直接使用mem.alloc返回的指针(字符串操作访问的是地址前 8 个字节),因此不应使用mem.alloc为字符串数据分配内存。幸运的是,HLA 标准库的内存模块提供了一个专门为字符串数据分配存储的内存分配例程:str.alloc。与mem.alloc类似,str.alloc也期望一个双字参数。这个值指定字符串中允许的最大字符数。str.alloc例程将分配指定数量的字节内存,再加上 9 到 13 个额外字节来存储额外的字符串信息。^([53])
str.alloc例程将为字符串分配存储空间,将最大长度初始化为作为str.alloc参数传入的值,将当前长度初始化为 0,并在字符串的第一个字符位置存储一个零终止字节。之后,str.alloc会将零终止字节的地址(即第一个字符元素的地址)返回到 EAX 寄存器中。
一旦为字符串分配了存储空间,你可以调用 HLA 标准库中的各种字符串处理例程来操作该字符串。下一部分将详细讨论一些 HLA 字符串例程;本节为了举例,介绍了几个与字符串相关的例程。第一个例程是stdin.gets( strvar );。这个例程从用户处读取一个字符串,并将字符串数据存储到由字符串参数(此例中为strvar)指向的字符串存储区。如果用户试图输入超过字符串最大允许长度的字符,stdin.gets会引发ex.StringOverflow异常。示例 4-10 中的程序演示了如何使用str.alloc。
示例 4-10。读取用户输入的字符串
// Program to demonstrate str.alloc and stdin.gets
program strallocDemo;
#include( "stdlib.hhf" );
static
theString:string;
begin strallocDemo;
str.alloc( 16 ); // Allocate storage for the string and store
mov( eax, theString ); // the pointer into the string variable.
// Prompt the user and read the string from the user:
stdout.put( "Enter a line of text (16 chars, max): " );
stdin.flushInput();
stdin.gets( theString );
// Echo the string back to the user:
stdout.put( "The string you entered was: ", theString, nl );
end strallocDemo;
如果你仔细观察,你会发现上面程序有一个小缺陷。它通过调用str.alloc为字符串分配存储空间,但它从未释放所分配的存储空间。即使程序在最后一次使用字符串变量后立即退出,操作系统会回收该存储空间,但显式地释放你分配的存储空间始终是个好习惯。这样做可以帮助你养成释放已分配存储的习惯(避免在重要时刻忘记释放);此外,程序总是有增长的可能,一个在当前版本中看似无害的小缺陷,可能会在明天的版本中变成致命的缺陷。
要释放通过str.alloc分配的存储空间,必须调用str.free例程,并将字符串指针作为唯一参数传入。示例 4-11 中的程序是示例 4-10 的修正版本,修正了这个缺陷。
示例 4-11。修正后的程序,从用户读取字符串
// Program to demonstrate str.alloc, str.free, and stdin.gets
program strfreeDemo;
#include( "stdlib.hhf" );
static
theString:string;
begin strfreeDemo;
str.alloc( 16 ); // Allocate storage for the string and store
mov( eax, theString ); // the pointer into the string variable.
// Prompt the user and read the string from the user:
stdout.put( "Enter a line of text (16 chars, max): " );
stdin.flushInput();
stdin.gets( theString );
// Echo the string back to the user:
stdout.put( "The string you entered was: ", theString, nl );
// Free up the storage allocated by str.alloc:
str.free( theString );
end strfreeDemo;
在查看这个修正后的程序时,请注意,stdin.gets例程期望你传递一个指向已分配字符串对象的字符串参数。无疑,初学 HLA 的程序员常犯的一个错误就是调用stdin.gets并传递一个未初始化的字符串变量。虽然这可能已经听得很烦了,但请记住,字符串是指针! 就像指针一样,如果你没有用有效的地址初始化字符串,那么当你尝试操作这个字符串对象时,程序很可能会崩溃。上面的程序通过调用str.alloc和随后的mov指令来初始化字符串指针。如果你在程序中使用字符串变量,你必须确保在向字符串对象写入数据之前为字符串数据分配存储空间。
为字符串分配存储空间是一个非常常见的操作,因此许多 HLA 标准库例程会自动为你分配存储空间。通常,这些例程的名称中会带有a_前缀。例如,stdin.a_gets将调用str.alloc和stdin.gets合并成同一个例程。这个例程没有任何参数,它会从用户处读取一行文本,分配一个字符串对象来保存输入数据,然后将指向字符串的指针返回到 EAX 寄存器中。示例 4-12 展示了一个改编版,它结合了示例 4-10 和示例 4-11,并使用了stdin.a_gets。
示例 4-12. 使用stdin.a_gets从用户读取字符串
// Program to demonstrate str.free and stdin.a_gets
program strfreeDemo2;
#include( "stdlib.hhf" );
static
theString:string;
begin strfreeDemo2;
// Prompt the user and read the string from the user:
stdout.put( "Enter a line of text: " );
stdin.flushInput();
stdin.a_gets();
mov( eax, theString );
// Echo the string back to the user:
stdout.put( "The string you entered was: ", theString, nl );
// Free up the storage allocated by stdin.a_gets:
str.free( theString );
end strfreeDemo2;
请注意,与之前一样,你仍然需要通过调用str.free例程来释放stdin.a_gets分配的存储空间。这个例程与之前的两个例程的一个重要区别是,HLA 会自动为从用户读取的字符串分配刚好足够的空间。在之前的程序中,调用str.alloc仅分配 16 字节的空间。如果用户输入超过 16 个字符,程序会抛出异常并退出。如果用户输入少于 16 个字符,则字符串末尾的空间会被浪费。而stdin.a_gets例程则始终为从用户读取的字符串分配最小所需的空间。由于它会分配存储空间,因此溢出的可能性很小。^([54])
^([52]) 请注意,这种方案不推荐使用。如果你需要从字符串中提取长度信息,请使用 HLA 字符串库提供的例程来实现。
^([53]) str.alloc可能会为开销数据分配超过 9 字节的内存,因为分配给 HLA 字符串的内存必须始终是双字对齐的,并且数据结构的总长度必须是 4 的倍数。
^([54]) 事实上,stdin.a_gets分配的最大字符数是有限制的。通常这个范围在 1,024 字节到 4,096 字节之间。具体值请参考 HLA 标准库源代码和操作系统文档。
4.9 访问字符串中的字符
从字符串中提取单个字符是一个非常常见的任务。它非常简单,以至于 HLA 没有提供任何特定的过程或语言语法来实现这一点——你只需使用机器指令即可完成。只要你有一个指向字符串数据的指针,简单的索引寻址模式就可以完成其余的工作。
当然,最重要的是要记住,字符串是指针。因此,你不能直接对字符串变量应用索引寻址模式来提取字符串中的字符。也就是说,如果s是一个字符串变量,那么mov( s[ebx], al );并不会获取字符串s中 EBX 位置的字符并将其放入 AL 寄存器中。记住,s只是一个指针变量;像s[ebx]这样的寻址模式只是会从s的地址开始,按偏移量 EBX 获取内存中的字节(见图 4-1)。

图 4-1. 错误地从字符串变量进行索引
在图 4-1 中,假设 EBX 的值为 3,s[ebx]并不会访问字符串s中的第四个字符;相反,它会获取指向字符串数据的指针的第四个字节。这很可能不是你想要的。图 4-2 展示了在假设 EBX 包含s的值时,如何从字符串中提取字符的必要操作。

图 4-2. 正确地从字符串变量的值中进行索引
在图 4-2 中,EBX 包含字符串s的值。s的值是指向内存中实际字符串数据的指针。因此,当你将s的值加载到 EBX 中时,EBX 将指向字符串的第一个字符。以下代码演示了如何以这种方式访问字符串s的第四个字符:
mov( s, ebx ); // Get pointer to string data into ebx.
mov( [ebx+3], al ); // Fetch the fourth character of the string.
如果你想加载字符串中位于变量而非固定偏移量处的字符,可以使用 80x86 的缩放索引寻址模式来获取该字符。例如,如果一个uns32变量index包含了字符串中的目标偏移量,你可以使用以下代码访问s[index]处的字符:
mov( s, ebx ); // Get address of string data into ebx.
mov( index, ecx ); // Get desired offset into string.
mov( [ebx+ecx], al ); // Get the desired character into al.
上面的代码只有一个问题——它没有检查偏移量index处的字符是否真实存在。如果index大于字符串当前的长度,那么这段代码将从内存中获取一个垃圾字节。除非你能事先确定index总是小于字符串的长度,否则像这样的代码是很危险的。一个更好的解决方案是在尝试访问字符之前,先检查索引是否在字符串当前的长度范围内。以下代码提供了实现此操作的一种方法:
mov( s, ebx );
mov( index, ecx );
if( ecx < (type str.strRec [ebx]).length ) then
mov( [ebx+ecx], al );
else
<< Code that handles out-of-bounds string index >>
endif;
在此if语句的else部分,你可以采取纠正措施,打印错误信息,或者引发异常。如果你想明确引发异常,可以使用 HLA 的raise语句来实现。raise语句的语法是
raise( *`integer_constant`* );
raise( *`reg32`* );
integer_constant或 32 位寄存器的值必须是一个异常编号。通常,这是excepts.hhf头文件中的一个预定义常量。当字符串索引大于字符串长度时,应该引发的适当异常是ex.StringIndexError。以下代码演示了如果字符串索引越界时引发此异常:
mov( s, ebx );
mov( index, ecx );
if( ecx < (type str.strRec [ebx]).length ) then
mov( [ebx+ecx], al );
else
raise( ex.StringIndexError );
endif;
4.10 HLA 字符串模块与其他字符串相关例程
尽管 HLA 为字符串数据提供了强大的定义,但 HLA 字符串功能背后的真正强大之处在于 HLA 标准库,而不是 HLA 字符串数据的定义。HLA 提供了数百个字符串处理例程,远超标准高级语言(如 C/C++、Java 或 Pascal)中的功能;事实上,HLA 的字符串处理能力堪比字符串处理语言(如 Icon 或 SNOBOL4)。本章讨论了 HLA 标准库提供的几种字符串函数。
你可能需要的最基本字符串操作就是将一个字符串赋值给另一个字符串。HLA 中有三种不同的方法来赋值字符串:通过引用赋值、通过复制字符串赋值和通过重复字符串赋值。在这三种方法中,通过引用赋值是最快且最简单的。如果你有两个字符串,并希望将一个字符串赋给另一个字符串,一种简单且快速的方法是复制字符串指针。以下代码片段演示了这一点:
static
string1: string := "Some String Data";
string2: string;
.
.
.
mov( string1, eax );
mov( eax, string2 );
.
.
.
通过引用进行字符串赋值非常高效,因为无论字符串长度如何,它只涉及执行两条简单的mov指令。如果在赋值操作之后你不再修改字符串数据,引用赋值效果很好。不过,请记住,两个字符串变量(在上述例子中为string1和string2)最终指向相同的数据。因此,如果你修改了一个字符串变量指向的数据,你也会修改第二个字符串对象所指向的数据,因为这两个对象指向的是相同的数据。示例 4-13 提供了一个演示该问题的程序。
示例 4-13. 通过复制指针进行字符串赋值的问题
// Program to demonstrate the problem with string assignment by reference
program strRefAssignDemo;
#include( "stdlib.hhf" );
static
string1: string;
string2: string;
begin strRefAssignDemo;
// Get a value into string1.
forever
stdout.put( "Enter a string with at least three characters: " );
stdin.a_gets();
mov( eax, string1 );
breakif( (type str.strRec [eax]).length >= 3 );
stdout.put( "Please enter a string with at least three chars:" nl );
endfor;
stdout.put( "You entered: '", string1, "'" nl );
// Do the string assignment by copying the pointer.
mov( string1, ebx );
mov( ebx, string2 );
stdout.put( "String1= '", string1, "'" nl );
stdout.put( ""String2= '", string2, "'" nl );
// Okay, modify the data in string1 by overwriting
// the first three characters of the string (note that
// a string pointer always points at the first character
// position in the string and we know we've got at least
// three characters here).
mov( 'a', (type char [ebx]) );
mov( 'b', (type char [ebx+1]) );
mov( 'c', (type char [ebx+2]) );
// Okay, demonstrate the problem with assignment via
// pointer copy.
stdout.put
(
"After assigning 'abc' to the first three characters in string1:"
nl
nl
);
stdout.put( "String1= '", string1, "'" nl );
stdout.put( "String2= '", string2, "'" nl );
str.free( string1 ); // Don't free string2 as well!
end strRefAssignDemo;
因为在这个例子中string1和string2都指向相同的字符串数据,所以对一个字符串所做的任何修改都会反映到另一个字符串上。虽然这种情况有时是可以接受的,但大多数程序员期望赋值操作会生成字符串的不同副本;也就是说,他们期望字符串赋值的语义能够生成两个独立的字符串数据副本。
使用引用复制(这个术语意味着复制一个指针)时,必须记住一个重要的点,那就是你创建了字符串数据的别名。术语别名意味着你有两个不同的名称指向内存中的同一个对象(例如,在上述程序中,string1和string2是指向同一字符串数据的两个不同名称)。当你阅读一个程序时,合理的期望是不同的变量指向不同的内存对象。别名违反了这一规则,从而使得程序更难阅读和理解,因为你必须记住别名并不指向内存中不同的对象。如果不记住这一点,可能会导致程序中的微妙错误。例如,在上述例子中,你必须记住string1和string2是别名,以避免在程序结束时释放这两个对象。更糟糕的是,你必须记住string1和string2是别名,这样你就不会在释放string1后继续使用string2,因为此时string2将成为一个悬空引用。
因为通过引用复制会使你的程序更难以阅读,并增加可能引入微妙缺陷的风险,你可能会想知道为什么有人会使用引用复制。原因有两个:首先,引用复制非常高效;它只涉及执行两条mov指令。其次,一些算法实际上依赖于引用复制的语义。然而,在使用这种技术之前,你应该仔细考虑复制字符串指针是否是你程序中字符串赋值的合适方式。
将一个字符串赋值给另一个字符串的第二种方式是复制字符串数据。HLA 标准库的 str.cpy 例程提供了这个功能。调用 str.cpy 过程时使用以下调用语法:^([55])
str.cpy( *`source_string`*, *`destination_string`* );
源字符串和目标字符串必须是字符串变量(指针)或包含字符串数据在内存中地址的 32 位寄存器。
str.cpy 例程首先检查目标字符串的最大长度字段,确保它至少与源字符串当前的长度一样大。如果不是,str.cpy 将引发 ex.StringOverflow 异常。如果目标字符串的最大长度足够大,str.cpy 就会将源字符串的字符串长度、字符和零终止字节从源字符串复制到目标字符串。当这个过程完成时,两个字符串指向相同的数据,但它们在内存中并不指向相同的数据^([56])。示例 4-14 中的程序是使用 str.cpy 而不是通过引用复制的方式,对 示例 4-13 中示例的重做。
示例 4-14. 使用 str.cpy 复制字符串
// Program to demonstrate string assignment using str.cpy
program strcpyDemo;
#include( "stdlib.hhf" );
static
string1: string;
string2: string;
begin strcpyDemo;
// Allocate storage for string2:
str.alloc( 64 );
mov( eax, string2 );
// Get a value into string1.
forever
stdout.put( "Enter a string with at least three characters: " );
stdin.a_gets();
mov( eax, string1 );
breakif( (type str.strRec [eax]).length >= 3 );
stdout.put( "Please enter a string with at least three chars:" nl );
endfor;
// Do the string assignment via str.cpy.
str.cpy( string1, string2 );
stdout.put( "String1= '", string1, "'" nl );
stdout.put( "String2= '", string2, "'" nl );
// Okay, modify the data in string1 by overwriting
// the first three characters of the string (note that
// a string pointer always points at the first character
// position in the string and we know we've got at least
// three characters here).
mov( string1, ebx );
mov( 'a', (type char [ebx]) );
mov( 'b', (type char [ebx+1]) );
mov( 'c', (type char [ebx+2]) );
// Okay, demonstrate that we have two different strings
// because we used str.cpy to copy the data:
stdout.put
(
"After assigning 'abc' to the first three characters in string1:"
nl
nl
);
stdout.put( "String1= '", string1, "'" nl );
stdout.put( "String2= '", string2, "'" nl );
// Note that we have to free the data associated with both
// strings because they are not aliases of one another.
str.free( string1 );
str.free( string2 );
end strcpyDemo;
有两件重要的事情需要注意,关于 示例 4-14 中的程序。首先,注意这个程序开始时会为 string2 分配存储空间。请记住,str.cpy 例程不会为目标字符串分配存储空间;它假定目标字符串已经分配了存储空间。请注意,str.cpy 不会初始化 string2;它只会将数据复制到 string2 当前指向的位置。程序有责任在调用 str.cpy 之前分配足够的内存来初始化该字符串。第二件需要注意的事情是,程序在退出之前调用 str.free 来释放 string1 和 string2 的存储空间。
在调用 str.cpy 之前为字符串变量分配存储空间是非常常见的,以至于 HLA 标准库提供了一个分配并复制字符串的例程:str.a_cpy。这个例程使用以下调用语法:
str.a_cpy( *`source_string`* );
请注意,str.cpy 没有目标字符串。该例程查看源字符串的长度,分配足够的存储空间,复制字符串,然后返回一个指向新字符串的指针,存储在 EAX 寄存器中。示例 4-15 中的程序演示了如何使用 str.a_cpy 过程完成与 示例 4-14 中相同的操作。
示例 4-15. 使用 str.a_cpy 复制字符串
// Program to demonstrate string assignment using str.a_cpy
program stra_cpyDemo;
#include( "stdlib.hhf" );
static
string1: string;
string2: string;
begin stra_cpyDemo;
// Get a value into string1.
forever
stdout.put( "Enter a string with at least three characters: " );
stdin.a_gets();
mov( eax, string1 );
breakif( (type str.strRec [eax]).length >= 3 );
stdout.put( "Please enter a string with at least three chars:" nl );
endfor;
// Do the string assignment via str.a_cpy.
str.a_cpy( string1 );
mov( eax, string2 );
stdout.put( "String1= '", string1, "'" nl );
stdout.put( "String2= '", string2, "'" nl );
// Okay, modify the data in string1 by overwriting
// the first three characters of the string (note that
// a string pointer always points at the first character
// position in the string and we know we've got at least
// three characters here).
mov( string1, ebx );
mov( 'a', (type char [ebx]) );
mov( 'b', (type char [ebx+1]) );
mov( 'c', (type char [ebx+2]) );
// Okay, demonstrate that we have two different strings
// because we used str.cpy to copy the data:
stdout.put
(
"After assigning 'abc' to the first three characters in string1:"
nl
nl
);
stdout.put( "String1= '", string1, "'" nl );
stdout.put( "String2= '", string2, "'" nl );
// Note that we have to free the data associated with both
// strings because they are not aliases of one another.
str.free( string1 );
str.free( string2 );
end stra_cpyDemo;
警告
每当你使用引用传递或 str.a_cpy 来分配字符串时,别忘了在完全处理完字符串数据后释放与该字符串相关的存储。如果你没有其他指向先前字符串数据的指针,未能这样做可能会导致内存泄漏。
获取字符字符串的长度非常常见,以至于 HLA 标准库提供了一个专门用于此目的的 str.length 例程。当然,你也可以通过使用 str.strRec 数据类型直接访问长度字段来获取长度,但由于这种机制需要输入大量的代码,频繁使用会比较繁琐。str.length 例程提供了一种更紧凑、更方便的方式来获取长度信息。你可以使用以下两种格式之一调用 str.length:
str.length( *`Reg32`* );
str.length( *`string_variable`* );
这个例程将当前字符串的长度返回在 EAX 寄存器中。
另一个有用的字符串例程对是 str.cat 和 str.a_cat 过程。它们使用以下语法:
str.cat( *`srcRStr`*, *`destLStr`* );
str.a_cat( *`srcLStr`*, *`srcRStr`* );
这两个例程将两个字符串连接在一起(即通过将两个字符串连接创建一个新字符串)。str.cat 过程将源字符串连接到目标字符串的末尾。在实际进行连接之前,str.cat 会检查目标字符串是否足够大,能够容纳连接结果,如果目标字符串的最大长度太小,它会抛出 ex.StringOverflow 异常。
str.a_cat 例程顾名思义,在进行连接操作之前,会为结果字符串分配存储空间。这个例程将为连接结果分配足够的存储空间,然后将 srcLStr 复制到分配的存储空间中,接着将 srcRStr 指向的字符串数据附加到这个新字符串的末尾,最后它会在 EAX 寄存器中返回指向新字符串的指针。
警告
注意一个潜在的混淆来源。str.cat 过程将其第一个操作数连接到第二个操作数的末尾。因此,str.cat 遵循许多 HLA 语句中常见的标准 (src, dest) 操作数格式。而 str.a_cat 例程则有两个源操作数,而不是一个源操作数和一个目标操作数。str.a_cat 例程以直观的从左到右的方式连接这两个操作数,这与 str.cat 正好相反。使用这两个例程时,请记住这一点。
示例 4-16 演示了如何使用 str.cat 和 str.a_cat 例程。
示例 4-16. 演示 str.cat 和 str.a_cat 例程
// Program to demonstrate str.cat and str.a_cat
program strcatDemo;
#include( "stdlib.hhf" );
static
UserName: string;
Hello: string;
a_Hello: string;
begin strcatDemo;
// Allocate storage for the concatenated result:
str.alloc( 1024 );
mov( eax, Hello );
// Get some user input to use in this example:
stdout.put( "Enter your name: " );
stdin.flushInput();
stdin.a_gets();
mov( eax, UserName );
// Use str.cat to combine the two strings:
str.cpy( "Hello ", Hello );
str.cat( UserName, Hello );
// Use str.a_cat to combine the string strings:
str.a_cat( "Hello ", UserName );
mov( eax, a_Hello );
stdout.put( "Concatenated string #1 is '", Hello, "'" nl );
stdout.put( "Concatenated string #2 is '", a_Hello, "'" nl );
str.free( UserName );
str.free( a_Hello );
str.free( Hello );
end strcatDemo;
str.insert 和 str.a_insert 例程与字符串连接过程类似。然而,str.insert 和 str.a_insert 例程允许你将一个字符串插入到另一个字符串的任何位置,而不仅仅是字符串的末尾。这两个例程的调用顺序如下:
str.insert( *`src`*, *`dest`*, *`index`* );
str.a_insert( *`src`*, *`dest`*, *`index`* );
这两个函数将源字符串(src)插入到目标字符串(dest)的指定字符位置index处。str.insert函数将源字符串直接插入目标字符串;如果目标字符串的长度不足以容纳两个字符串,str.insert会引发ex.StringOverflow异常。str.a_insert函数首先为新字符串分配内存,将目标字符串(src)复制到新字符串中,然后在指定的偏移位置插入源字符串(dest);str.a_insert返回新字符串的指针,并存储在 EAX 寄存器中。
字符串的索引是从零开始的。这意味着,如果你在str.insert或str.a_insert中提供值 0 作为索引,那么这些函数会在目标字符串的第一个字符之前插入源字符串。同样地,如果index等于字符串的长度,那么这些函数会直接将源字符串连接到目标字符串的末尾。
警告
如果index大于字符串的长度,str.insert和str.a_insert过程不会引发异常;相反,它们会简单地将源字符串附加到目标字符串的末尾。
str.delete和str.a_delete函数允许你从字符串中删除字符。它们使用以下调用方式:
str.delete( *`strng`*, *`StartIndex`*, *`Length`* );
str.a_delete( *`strng`*, *`StartIndex`*, *`Length`* );
两个函数都会从字符串strng的字符位置StartIndex开始删除Length个字符。两者的区别在于,str.delete直接从strng中删除字符,而str.a_delete首先分配内存并复制strng,然后从新字符串中删除字符(不改变strng)。str.a_delete函数会将新字符串的指针返回在 EAX 寄存器中。
str.delete和str.a_delete函数对你传入的StartIndex和Length的值非常宽容。如果StartIndex大于字符串的当前长度,这两个函数不会从字符串中删除任何字符。如果StartIndex小于字符串的当前长度,但StartIndex+Length大于字符串的长度,那么这两个函数会删除从StartIndex到字符串末尾的所有字符。
另一个非常常见的字符串操作是需要将字符串的一部分复制到另一个字符串,而不影响源字符串。str.substr和str.a_substr函数提供了这个功能。这些函数使用以下语法:
str.substr( *`src`*, *`dest`*, *`StartIndex`*, *`Length`* );
str.a_substr( *`src`*, *`StartIndex`*, *`Length`* );
str.substr例程将从src字符串的StartIndex位置开始,复制Length个字符到dest字符串中。目标字符串必须有足够的存储空间来容纳新字符串,否则str.substr将抛出ex.StringOverflow异常。如果StartIndex的值大于字符串的长度,则str.substr将抛出ex.StringIndexError异常。如果StartIndex+Length大于源字符串的长度,但StartIndex小于字符串的长度,那么str.substr将仅提取从StartIndex到字符串末尾的字符。
str.a_substr过程的行为几乎与str.substr完全相同,唯一不同的是它会在堆上分配存储空间用于目标字符串。str.a_substr处理异常的方式与str.substr相同,唯一不同的是它永远不会抛出字符串溢出异常,因为这种情况永远不会发生。^([57]) 你现在可能已经猜到了,str.a_substr会返回一个指向新分配字符串的指针,该指针存储在 EAX 寄存器中。
当你开始处理字符串数据一段时间后,比较两个字符串的需求通常会不可避免地出现。第一次尝试使用标准 HLA 关系运算符进行字符串比较时,虽然会编译通过,但不一定会产生预期的结果:
mov( s1, eax );
if( eax = s2 ) then
<< Code to execute if the strings are equal >>
else
<< Code to execute if the strings are not equal >>
endif;
记住,字符串是指针。这段代码比较两个指针,看看它们是否相等。如果它们相等,显然这两个字符串是相等的(因为s1和s2指向的是完全相同的字符串数据)。然而,两个指针不同并不一定意味着这两个字符串不相等。s1和s2可能包含不同的值(即它们指向内存中的不同地址),但这两个地址上的字符串数据可能是相同的。大多数程序员期望当两个字符串的数据相同时,字符串比较为 true。显然,指针比较并不能提供这种类型的比较。为了解决这个问题,HLA 标准库提供了一组字符串比较例程,这些例程会比较字符串数据,而不仅仅是它们的指针。使用这些例程时,调用方式如下:
str.eq( *`src1`*, *`src2`* );
str.ne( *`src1`*, *`src2`* );
str.lt( *`src1`*, *`src2`* );
str.le( *`src1`*, *`src2`* );
str.gt( *`src1`*, *`src2`* );
str.ge( *`src1`*, *`src2`* );
这些例程将src1字符串与src2字符串进行比较,并根据比较结果在 EAX 寄存器中返回 true(1)或 false(0)。例如,str.eq( s1, s2); 如果s1等于s2,则会在 EAX 中返回 true。HLA 提供了一个小扩展,允许你在if语句中使用字符串比较例程。^([58]) 以下代码演示了如何在if语句中使用一些比较例程:
stdout.put( "Enter a single word: " );
stdin.a_gets();
if( str.eq( eax, "Hello" )) then
stdout.put( "You entered 'Hello'", nl );
endif;
str.free( eax );
请注意,用户在此示例中输入的字符串必须完全匹配 Hello,包括字符串开头的大写字母 H。在处理用户输入时,最好在字符串比较时忽略字母的大小写,因为不同的用户对于何时按下键盘上的 SHIFT 键有不同的理解。一种简单的解决方案是使用 HLA 的不区分大小写的字符串比较函数。这些例程比较两个字符串,忽略字母的大小写差异。这些例程使用以下调用顺序:
str.ieq( *`src1`*, *`src2`* );
str.ine( *`src1`*, *`src2`* );
str.ilt( *`src1`*, *`src2`* );
str.ile( *`src1`*, *`src2`* );
str.igt( *`src1`*, *`src2`* );
str.ige( *`src1`*, *`src2`* );
除了将大写字符与其小写等效字符视为相同外,这些例程的行为与前述例程完全相同,根据比较结果在 EAX 中返回 true 或 false。
与大多数高级语言一样,HLA 使用字典顺序来比较字符串。这意味着两个字符串只有在它们的长度相同且两个字符串中对应的字符完全相同的情况下才相等。在小于或大于的比较中,字典顺序对应于单词在字典中的排列方式。也就是说,a 小于 b 小于 c,以此类推。实际上,HLA 使用字符的 ASCII 数字代码来比较字符串,因此如果你不确定 a 是否小于句点,可以查阅 ASCII 字符表(顺便提一下,在 ASCII 字符集里,a 大于句点,以防你有这个疑问)。
如果两个字符串长度不同,字典顺序只有在两个字符串完全匹配到较短字符串的长度时才考虑长度。如果是这种情况,那么较长的字符串大于较短的字符串(反之亦然,较短的字符串小于较长的字符串)。然而,请注意,如果两个字符串中的字符完全不匹配,那么 HLA 的字符串比较例程会忽略字符串的长度;例如,z 总是大于 aaaaa,即使它较短。
str.eq 例程检查两个字符串是否相等。然而,有时你可能想知道一个字符串是否包含另一个字符串。例如,你可能想知道某个字符串是否包含子字符串 north 或 south,以决定在游戏中采取某个行动。HLA 的 str.index 例程允许你检查一个字符串是否作为子字符串包含在另一个字符串中。str.index 例程使用以下调用顺序:
str.index( *`StrToSearch`*, *`SubstrToSearchFor`* );
该函数在 EAX 中返回 StrToSearch 中 SubstrToSearchFor 出现的位置偏移量。如果 SubstrToSearchFor 不存在于 StrToSearch 中,则该例程在 EAX 中返回 −1。请注意,str.index 会进行区分大小写的搜索。因此,字符串必须完全匹配。没有可以使用的不区分大小写的 str.index 变体。^([59])
HLA strings 模块除了本节中列出的那些例程外,还包含数百个例程。由于空间限制和前提知识的要求,这里无法展示所有这些函数;然而,这并不意味着剩余的字符串函数不重要。你应该一定要查看 HLA 标准库文档,了解更多关于强大的 HLA 字符串库例程的知识。
^([55]) 警告 C/C++ 用户:请注意,操作数的顺序与 C 标准库的 strcpy 函数相反。
^([56]) 当然,除非两个字符串指针最初包含相同的地址,在这种情况下 str.cpy 会将字符串数据复制到自身。
^([57]) 从技术上讲,str.a_substr 就像所有调用 mem.alloc 来分配存储空间的例程一样,可能会抛出 ex.MemoryAllocationFailure 异常,但这种情况发生的可能性非常小。
^([58]) 这个扩展实际上比本节描述的要更通用。第七章 完整地解释了它。
^([59]) 然而,HLA 确实提供了一些例程,可以将字符串中的所有字符转换为某种大小写。因此,你可以复制字符串,将两个副本中的所有字符都转换为小写,然后使用这些转换后的字符串进行搜索。这将达到相同的效果。
4.11 内存中的转换
HLA 标准库的 string 模块包含数十个用于在字符串和其他数据格式之间转换的例程。尽管在本节中介绍这些函数还为时过早,但如果不至少讨论其中一个可用的函数——str.put 例程,那将有些不妥。这个例程封装了许多其他字符串转换函数的功能,因此如果你学会了如何使用这个,你将能够使用大部分其他例程的功能。
你以类似于 stdout.put 例程的方式使用 str.put。唯一的区别是,str.put 例程将数据“写入”一个字符串,而不是标准输出设备。调用 str.put 的语法如下:
str.put( *`destString`*, *`values_to_convert`* );
这是调用 str.put 的一个示例:
str.put( *`destString`*, "I =", i:4, " J= ", j, " s=", s );
警告
通常,你不会像打印字符串到标准输出设备时那样在字符串末尾加上换行符序列。
destString 参数在 str.put 参数列表的开头必须是一个字符串变量,并且它必须已经分配了存储空间。如果 str.put 尝试将超过允许字符数的内容存储到 destString 参数中,那么此函数将抛出 ex.StringOverflow 异常。
大多数情况下,你不知道 str.put 会生成的字符串长度。在这种情况下,你应该为一个非常大的字符串分配存储空间,远大于你预期的大小,并将这个字符串对象作为 str.put 调用的第一个参数。这将防止异常导致程序崩溃。通常,如果你预期生成大约一行屏幕文字,那么应该为目标字符串分配至少 256 个字符。如果你要生成更长的字符串,应该为字符串分配至少 1,024 个字符(如果你要生成非常大的字符串,可以分配更多)。
这是一个示例:
static
s: string;
.
.
.
str.alloc( 256 );
mov( eax, s );
.
.
.
str.put( s, "R: ", r:16:4, " strval: '", strval:-10, "'" );
你可以使用 str.put 例程将任何数据转换为字符串,然后使用 stdout.put 打印出来。你会发现这个例程对于常见的值到字符串的转换非常有用。
4.12 字符集
字符集是另一种复合数据类型,类似于字符串,是基于字符数据类型构建的。字符集是一个数学集合,最重要的属性是成员资格。也就是说,字符要么是集合的成员,要么不是集合的成员。序列的概念(例如,字符是否排在另一个字符之前,如同在字符串中)不适用于字符集。此外,成员资格是一种二元关系;字符要么在集合中,要么不在集合中;你不能在字符集中有多个相同字符。对字符集可以进行各种操作,包括数学集合运算,如并集、交集、差集和成员测试。
HLA 实现了一种受限形式的字符集,允许字符集成员是任何 128 个标准 ASCII 字符(即 HLA 的字符集功能不支持扩展字符编码范围 128..255)。尽管有此限制,HLA 的字符集功能仍然非常强大,并且在编写处理字符串数据的程序时非常方便。以下部分将描述 HLA 字符集功能的实现和使用,以便你可以在自己的程序中利用字符集。
4.13 HLA 中的字符集实现
在汇编语言程序中表示字符集有多种方式。HLA 使用一个包含 128 个布尔值的数组来实现字符集。每个布尔值决定相应字符是否是字符集的成员;也就是说,布尔值为真表示相应字符是字符集的成员,而布尔值为假表示该字符不是字符集的成员。为了节省内存,HLA 为字符集中的每个字符分配一个位,因此 HLA 字符集只消耗 16 字节的内存,因为 16 字节包含 128 位。这个包含 128 位的数组在内存中的组织方式如 图 4-3 所示。

图 4-3. 字符集对象的位布局
字节 0 的位 0 对应于 ASCII 代码 0(NUL 字符)。如果该位为 1,则字符集包含 NUL 字符;如果该位为假,则字符集不包含 NUL 字符。同样,字节 1 的位 0(128 位数组中的第九位)对应退格字符(ASCII 代码为 8)。字节 8 的位 1 对应 ASCII 代码 65,大写字母 A。如果 A 是字符集的当前成员,则位 65 将为 1;如果 A 不是字符集的成员,则该位为 0。
尽管存在其他实现字符集的方法,但使用这种位向量实现可以非常容易地实现集合操作,如并集、交集、差集比较和成员测试。
HLA 支持使用 cset 数据类型的字符集变量。要声明一个字符集变量,可以使用如下声明:
static
*`CharSetVar`*: cset;
该声明将预留 16 字节的存储空间,用于保存表示 ASCII 字符集的 128 位。
虽然可以使用如 and、or、xor 等指令操作字符集中的位,80x86 指令集包括几条用于测试、设置、重置和补码的指令,非常适合操作字符集。例如,bt(位测试)指令会将内存中的一个位复制到进位标志中。bt 指令支持以下语法形式。
bt( *`BitNumber`*, *`BitsToTest`* );
bt( *`reg16`*, *`reg16`* );
bt( *`reg32`*, *`reg32`* );
bt( *`constant`*, *`reg16`* );
bt( *`constant`*, *`reg32`* );
bt( *`reg16`*, *`mem16`* );
bt( *`reg32`*, *`mem32`* ); // HLA treats cset objects as dwords within bt.
bt( *`constant`*, *`mem16`* );
bt( *`constant`*, *`mem32`* ); // HLA treats cset objects as dwords within bt.
第一个操作数包含一个位数,第二个操作数指定一个寄存器或内存位置,其位应该复制到进位标志中。如果第二个操作数是一个寄存器,则第一个操作数必须包含一个在 0..n−1 范围内的值,其中 n 是第二个操作数的位数。如果第一个操作数是常量且第二个操作数是内存位置,则常量必须在 0..255 范围内。以下是这些指令的一些示例:
bt( 7, ax ); // Copies bit 7 of ax into the carry flag (CF).
mov( 20, eax );
bt( eax, ebx ); // Copies bit 20 of ebx into CF.
// Copies bit 0 of the byte at CharSetVar+3 into CF.
bt( 24, CharSetVar );
// Copies bit 4 of the byte at DWmem+2 into CF.
bt( eax, DWmem);
bt 指令对于测试集合成员资格非常有用。例如,要检查字符 A 是否是字符集的成员,可以使用如下代码序列:
bt( 'A', CharSetVar );
if( @c ) then
<< Do something if 'A' is a member of the set. >>
endif;
bts(位测试并设置)、btr(位测试并重置)和btc(位测试并取反)指令在操作字符集变量时也非常有用。像bt指令一样,这些指令将指定的位复制到进位标志中;在复制指定的位后,这些指令将设置(bts)、重置/清除(btr)或取反/翻转(btc)该位。因此,你可以使用bts指令通过集合并集将字符添加到字符集中(也就是说,如果字符不在集合中,它将被添加到集合中;否则集合不受影响)。你可以使用btr指令通过集合交集将字符从字符集中移除(也就是说,它只有在字符原本在集合中时才会移除字符;否则对集合没有影响)。btc指令让你可以将字符添加到集合中,如果字符原本不在集合中;如果字符已经在集合中,它将从集合中移除(也就是说,它会切换字符在集合中的成员身份)。
4.14 HLA 字符集常量与字符集表达式
HLA 支持字面字符集常量。这些cset常量使得在编译时初始化cset变量变得更加容易,并且允许你将字符集常量轻松地作为过程参数传递。HLA 字符集常量的形式如下:
{ *`Comma_separated_list_of_characters_and_character_ranges`* }
以下是一个简单的字符集示例,包含所有数字字符:
{ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }
当指定具有多个连续值的字符集字面量时,HLA 允许你简洁地仅使用范围的起始值和结束值来指定这些值,如下所示:
{ '0'..'9' }
你可以在同一字符集常量中结合字符和各种范围。例如,以下字符集常量包含所有的字母数字字符:
{ '0'..'9', 'a'..'z', 'A'..'Z' }
你可以在const和val部分使用这些cset字面常量作为初始化器。以下示例演示了如何使用上面的字符集创建符号常量AlphaNumeric:
const
AlphaNumeric: cset := {'0'..'9', 'a'..'z', 'A'..'Z' };
在上述声明之后,你可以在任何合法的字符集字面量使用的地方使用标识符AlphaNumeric。
你还可以将字符集字面量(当然也包括字符集符号常量)用作static或readonly变量的初始化字段。以下代码片段演示了这一点:
static
Alphabetic: cset := { 'a'..'z', 'A'..'Z' };
任何可以使用字符集字面常量的地方,也可以使用字符集常量表达式。表 4-2 展示了 HLA 在字符集常量表达式中支持的操作符。
表 4-2. HLA 字符集操作符
| 操作符 | 描述 |
|---|---|
CSetConst1 + CSetConst2 |
计算两个集合的并集。集合并集是包含任一集合中所有字符的集合。 |
CSetConst1 * CSetConst2 |
计算两个集合的交集。交集是出现在两个操作数集合中的所有字符的集合。 |
CSetConst1 - CSetConst2 |
计算两个集合的差集。差集是出现在第一个集合中但不出现在第二个集合中的字符集合。 |
-CSetConst |
计算集合的补集。补集是所有不在该集合中的字符的集合。 |
请注意,这些运算符仅生成编译时结果。也就是说,上面的表达式是由编译器在编译期间计算的;它们不会生成任何机器代码。如果你想在程序运行时对两个不同的字符集执行这些操作,HLA 标准库提供了可以调用的例程来实现你想要的结果。HLA 还提供了其他编译时字符集运算符。
4.15 HLA 标准库中的字符集支持
HLA 标准库提供了几个你可能会觉得有用的字符集例程。字符集支持例程分为四类:标准字符集函数、字符集测试、字符集转换和字符集输入/输出。本节描述了这些 HLA 标准库中的例程。
首先,让我们考虑一下帮助你构建字符集的标准库例程。这些例程包括cs.empty、cs.cpy、cs.charToCset、cs.unionChar、cs.removeChar、cs.rangeChar、cs.strToCset和cs.unionStr。这些过程允许你在运行时使用字符和字符串对象构建字符集。
cs.empty过程通过将字符集中的所有位设置为 0 来初始化一个字符集变量为空集。该过程调用使用以下语法(CSvar是一个字符集变量):
cs.empty( *`CSvar`* );
cs.cpy过程将一个字符集复制到另一个字符集,替换目标字符集之前的任何数据。cs.cpy的语法如下:
cs.cpy( *`srcCsetValue`*, *`destCsetVar`* );
cs.cpy源字符集可以是一个字符集常量或一个字符集变量。目标字符集必须是一个字符集变量。
cs.unionChar过程将一个字符添加到字符集中。它使用以下调用格式:
cs.unionChar( *`CharVar`*, *`CSvar`* );
这个调用将第一个参数,一个字符,通过集合并运算添加到集合中。请注意,你可以使用bts指令来实现相同的结果;然而,cs.unionChar调用通常更方便。字符值必须在#0..#127 的范围内。
cs.charToCset函数创建一个单例集合(包含一个字符的集合)。该函数的调用格式为:
cs.charToCset( *`CharValue`*, *`CSvar`* );
第一个操作数,即字符值CharValue,可以是一个 8 位寄存器、一个常量,或一个存储在#0..#127 范围内的字符变量。第二个操作数(CSvar)必须是一个字符集变量。该函数将目标字符集清零,然后将指定的字符并入字符集中。
cs.removeChar 过程允许你从字符集中移除一个字符,而不影响集合中的其他字符。此函数的语法与 cs.charToCset 相同,参数也具有相同的属性。调用顺序如下:
cs.removeChar( *`CharValue`*, *`CSvar`* );
请注意,如果字符本来不在 CSVar 集合中,cs.removeChar 将不会影响集合。此函数大致对应 btr 指令。
cs.rangeChar 构造一个字符集,包含你传递的两个字符之间的所有字符。此函数将这些两个字符范围之外的所有位设置为 0。调用顺序如下:
cs.rangeChar( *`LowerBoundChar`*, *`UpperBoundChar`*, *`CSVar`* );
LowerBoundChar 和 UpperBoundChar 参数可以是常量、寄存器或字符变量。LowerBoundChar 和 UpperBoundChar 中的值必须在 #0..#127 范围内。CSVar,目标字符集,必须是一个 cset 变量。
cs.strToCset 过程创建一个新的字符集,其中包含字符串中所有字符的并集。该过程首先将目标字符集设置为空集,然后依次将字符串中的字符并入集合,直到所有字符都被处理完。调用顺序如下:
cs.strToCset( *`StringValue`*, *`CSVar`* );
从技术上讲,StringValue 参数可以是一个字符串常量,也可以是一个字符串变量;然而,以这种方式调用 cs.strToCset 没有任何意义,因为 cs.cpy 是一种更高效的方式,用常量字符集来初始化字符集。像往常一样,目标字符集必须是一个 cset 变量。通常,你会使用这个函数根据用户输入的字符串创建一个字符集。
cs.unionStr 过程会将字符串中的字符添加到现有字符集中。像 cs.strToCset 一样,你通常会使用此函数根据用户输入的字符串将字符联合成一个集合。调用顺序如下:
cs.unionStr( *`StringValue`*, *`CSVar`* );
标准集合操作包括并集、交集和集合差集。HLA 标准库例程 cs.setunion、cs.intersection 和 cs.difference 分别提供这些操作^([60])。这些例程都使用相同的调用顺序:
cs.setunion( *`srcCset`*, *`destCset`* );
cs.intersection( *`srcCset`*, *`destCset`* );
cs.difference( *`srcCset`*, *`destCset`* );
第一个参数可以是字符集常量或字符集变量。第二个参数必须是字符集变量。这些过程计算 destCset := destCset op srcCset,其中 op 表示集合并集、交集或差集,具体取决于函数调用。
第三类字符集例程以各种方式测试字符集。它们通常返回一个布尔值,表示测试结果。HLA 字符集例程中的此类包括 cs.IsEmpty、cs.member、cs.subset、cs.psubset、cs.superset、cs.psuperset、cs.eq 和 cs.ne。
cs.IsEmpty函数用于测试一个字符集是否为空集。该函数将在 EAX 寄存器中返回 true 或 false。此函数使用的调用顺序如下:
cs.IsEmpty( *`CSetValue`* );
单个参数可以是常量或字符集变量,尽管将字符集常量传递给此过程没有多大意义(因为你在编译时就知道该集合是否为空)。
cs.member函数用于测试某个字符值是否是集合的成员。如果字符是集合的成员,该函数将在 EAX 寄存器中返回 true。请注意,你可以使用bt指令来测试相同的条件。然而,如果字符参数不是常量,cs.member函数可能会更方便使用。cs.member的调用顺序如下:
cs.member( *`CharValue`*, *`CsetValue`* );
第一个参数是一个 8 位寄存器、字符变量或常量。第二个参数可以是字符集常量或字符集变量。两个参数都是常量的情况不太常见。
cs.subset、cs.psubset(真子集)、cs.superset、cs.psuperset(真超集)函数让你检查一个字符集是否是另一个字符集的子集或超集。这四个例程的调用顺序几乎相同;它们的调用顺序如下:
cs.subset( *`CsetValue1`*, *`CsetValue2`* );
cs.psubset( *`CsetValue1`*, *`CsetValue2`* );
cs.superset( *`CsetValue1`*, *`CsetValue2`* );
cs.psuperset( *`CsetValue1`*, *`CsetValue2`* );
这些例程将第一个参数与第二个参数进行比较,并根据结果在 EAX 寄存器中返回 true 或 false。如果第一个字符集的所有成员都包含在第二个字符集中,则一个集合是另一个集合的子集。如果第二个(右边)字符集还包含在第一个(左边)字符集中不存在的字符,则它是一个真子集。同样,如果一个字符集包含第二个字符集中的所有字符(并可能包含更多字符),则它是另一个字符集的超集。真超集包含第二个集合中没有的额外字符。参数可以是字符集变量或字符集常量;然而,如果两个参数都是字符集常量就不太常见了(因为你可以在编译时确定这一点,没必要在运行时调用函数来计算)。
cs.eq和cs.ne函数用于检查两个集合是否相等或不相等。这些函数根据集合比较结果在 EAX 中返回 true 或 false。调用顺序与上面的子集/超集函数相同:
cs.eq( *`CsetValue1`*, *`CsetValue2`* );
cs.ne( *`CsetValue1`*, *`CsetValue2`* );
请注意,没有测试小于、小于等于、大于或大于等于的函数。子集和真子集函数分别等同于小于等于和小于;同样,超集和真超集函数分别等同于大于等于和大于。
cs.extract例程从字符集中移除一个任意字符,并将该字符返回到 EAX 寄存器中。^([61]) 调用顺序如下:
cs.extract( *`CsetVar`* );
唯一的参数必须是一个字符集变量。请注意,这个函数会通过从字符集中删除某些字符来修改字符集变量。如果在调用之前字符集为空,这个函数会在 EAX 中返回 $FFFF_FFFF(−1)。
除了在 cset.hhf(字符集)库模块中找到的例程外,字符串和标准输出模块还提供了允许或需要字符集参数的函数。例如,如果你将一个字符集值作为参数传递给 stdout.put,那么 stdout.put 例程将打印字符集中当前的字符。有关字符集处理过程的更多细节,请参阅 HLA 标准库文档。
^([60]) 使用了 cs.setunion 而不是 cs.union,因为 union 是 HLA 的保留字。
^([61]) 这个例程返回 AL 中的字符,并将 EAX 的高 3 字节清零。
4.16 在你的 HLA 程序中使用字符集
字符集在你的程序中有很多不同的用途。例如,字符集的一个常见用途是验证用户输入。本节还将介绍字符集的其他几个应用,帮助你开始思考如何在程序中使用它们。
请考虑以下简短的代码段,它从用户那里获取一个是/否类型的回答:
static
answer: char;
.
.
.
repeat
.
.
.
stdout.put( "Would you like to play again? " );
stdin.FlushInput();
stdin.get( answer );
until( answer = 'n' );
这个代码序列的一个主要问题是,它只有在用户输入小写的 n 时才会停止。如果用户输入除了 n 以外的任何字符(包括大写的 N),程序会把它当作肯定的回答,并返回到 repeat..until 循环的开头。更好的解决方案是在 until 子句之前验证用户输入,以确保用户仅输入了 n、N、y 或 Y。以下代码序列将实现这一点:
repeat
.
.
.
repeat
stdout.put( "Would you like to play again? " );
stdin.FlushInput();
stdin.get( answer );
until( cs.member( answer, { 'n', 'N', 'Y', 'y' } );
if( answer = 'N' ) then
mov( 'n', answer );
endif;
until( answer = 'n' );
4.17 数组
与字符串一起,数组可能是最常用的复合数据类型。然而,大多数初学者程序员并不理解数组的内部操作以及它们相关的效率权衡。令人惊讶的是,许多初学者(甚至是高级程序员!)在学习如何在机器层面处理数组后,才会从完全不同的角度看待数组。
抽象来说,数组是一种聚合数据类型,其成员(元素)都是相同的类型。从数组中选择一个成员是通过一个整数索引来完成的。^([62]) 不同的索引选择数组中的独特元素。本节假设整数索引是连续的(尽管这并非必须)。也就是说,如果数字 x 是数组的有效索引,而 y 也是有效索引,并且 x < y,那么所有满足 x < i < y 的 i 都是有效的索引。
每当你对数组应用索引操作符时,结果是该索引选择的具体数组元素。例如,A[i] 选择数组 A 中的第 i 个元素。请注意,没有正式要求元素 i 在内存中必须靠近元素 i+1。只要 A[i] 总是指向相同的内存位置,并且 A[i+1] 总是指向其对应的位置(且两者不同),那么数组的定义就满足要求。
在本节中,我们假设数组元素在内存中占据连续的位置。一个包含五个元素的数组将在内存中呈现为 图 4-4 所示。

图 4-4. 数组在内存中的布局
数组的 基地址 是数组中第一个元素的地址,并且始终位于内存的最低位置。第二个数组元素紧跟第一个元素之后存储在内存中,第三个元素跟在第二个元素后面,以此类推。请注意,索引不一定要求从 0 开始。它们可以从任何数字开始,只要是连续的即可。然而,为了讨论的方便,本书将所有索引从 0 开始。
要访问数组中的一个元素,你需要一个将数组索引转换为索引元素地址的函数。对于一维数组,这个函数非常简单。它是:
*`Element_Address`* = *`Base_Address`* + ((*`Index`* - *`Initial_Index`*) * *`Element_Size`*)
其中 Initial_Index 是数组中第一个索引的值(如果是 0 可以忽略),Element_Size 是数组元素的大小,以字节为单位。
^([62]) 或者它可能是某个底层表示为整数的值,例如字符、枚举类型和布尔类型。
4.18 在 HLA 程序中声明数组
在你可以访问数组的元素之前,你需要为该数组分配存储空间。幸运的是,数组声明基于你已经看到的声明。要为数组分配 n 个元素,你可以在变量声明部分使用如下声明:
*`ArrayName`*: *`basetype`*[n];
ArrayName 是数组变量的名称,basetype 是该数组元素的类型。这个声明为数组分配了存储空间。要获取数组的基地址,只需使用 ArrayName。
[n] 后缀告诉 HLA 将对象复制 n 次。现在让我们看一些具体示例。
static
CharArray: char[128]; // Character array with elements 0..127.
ByteArray: byte[10]; // Array of bytes with elements 0..9.
PtrArray: dword[4]; // Array of double words with elements 0..3.
这些示例都为未初始化的数组分配了存储空间。你也可以指定使用如下声明在 static 和 readonly 部分初始化数组元素:
RealArray: real32[8] := [ 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 ];
IntegerAry: int32[8] := [ 1, 1, 1, 1, 1, 1, 1, 1 ];
这两个定义都创建了包含八个元素的数组。第一个定义将每个 4 字节的实数值初始化为 1.0,第二个声明将每个 int32 元素初始化为 1。请注意,方括号内常量的数量必须与数组的大小完全匹配。
如果你希望数组的每个元素都具有相同的值,那么这种初始化机制是可以的。如果你想要初始化每个元素为(可能是)不同的值呢?不用担心,只需在上面示例中的方括号内指定一组不同的值:
RealArray: real32[8] := [ 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0 ];
IntegerAry: int32[8] := [ 1, 2, 3, 4, 5, 6, 7, 8 ];
4.19 HLA 数组常量
上一节的最后几个例子展示了 HLA 数组常量的使用。HLA 数组常量不过是由一对括号包围的一组值。以下都是合法的数组常量:
[ 1, 2, 3, 4 ]
[ 2.0, 3.14159, 1.0, 0.5 ]
[ 'a', 'b', 'c', 'd' ]
[ "Hello", "world", "of", "assembly" ]
(注意,最后这个数组常量包含了四个双字指针,指向内存中其他地方出现的四个 HLA 字符串。)
如同你在上一节中所看到的,你可以在static和readonly部分使用数组常量来为数组变量提供初始值。数组常量中的项数必须与变量声明中的数组元素数量完全匹配。同样,数组常量中每个元素的类型必须与数组变量声明的基础类型相匹配。
使用数组常量初始化小型数组非常方便。当然,如果你的数组有几千个元素,输入这些值会很繁琐。大多数以这种方式初始化的数组不超过几百个元素,通常远少于 100 个元素。使用数组常量来初始化这种变量是合理的。然而,到了某个时候,使用这种方式初始化数组会变得太过繁琐且容易出错。你可能不想手动用数组常量初始化一个有 1000 个不同元素的数组。然而,如果你想用相同的值初始化数组的所有元素,HLA 确实提供了一种特殊的数组常量语法来做到这一点。考虑以下声明:
BigArray: uns32[ 1000 ] := 1000 dup [ 1 ];
这个声明创建了一个包含 1000 个元素的整数数组,并将每个元素初始化为 1。1000 dup [ 1 ]表达式告诉 HLA 通过重复单一值[ 1 ]一千次来创建一个数组常量。你甚至可以使用dup运算符来重复一系列值(而不是单一值),如下例所示:
SixteenInts: int32[16] := 4 dup [1,2,3,4];
这个例子通过四次复制序列1,2,3,4来初始化SixteenInts,从而得到总共 16 个不同的整数(即 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4)。
当查看 4.22 多维数组时,你将看到使用dup运算符的更多可能性。
4.20 访问一维数组的元素
要访问一个基于零的数组元素,你可以使用简化公式
*`Element_Address`* = *`Base_Address`* + *`index`* * Element_Size
对于 Base_Address 项,你可以使用数组的名称(因为 HLA 将数组第一个元素的地址与该数组的名称关联)。Element_Size 项是每个数组元素的字节数。如果该对象是字节数组,Element_Size 字段为 1(结果是一个非常简单的计算)。如果数组的每个元素是一个字(或其他 2 字节类型),则 Element_Size 为 2,依此类推。要访问上一节中的 SixteenInts 数组的一个元素,你可以使用以下公式(大小为 4,因为每个元素是一个 int32 对象):
*`Element_Address`* = *`SixteenInts`* + *`index`**4
80x86 代码中等价于语句 eax := SixteenInts[index] 的代码是:
mov( index, ebx );
shl( 2, ebx ); // Sneaky way to compute 4*ebx
mov( SixteenInts[ ebx ], eax );
这里有两点需要注意。首先,这段代码使用了 shl 指令,而不是 intmul 指令来计算 4*index。选择 shl 的主要原因是它更高效。事实证明,shl 在许多处理器上比 intmul 快得多。
关于这条指令序列需要注意的第二点是,它并没有显式地计算基地址加上索引乘以 4 的和。而是依赖于索引寻址模式隐式地计算这个和。指令 mov( SixteenInts[ ebx ], eax ); 从 SixteenInts + ebx 位置加载 EAX,也就是基地址加上 index*4(因为 EBX 中包含 index*4)。当然,你本可以使用
lea( eax, SixteenInts );
mov( index, ebx );
shl( 2, ebx ); // Sneaky way to compute 4*ebx
add( eax, ebx ); // Compute base address plus index*4
mov( [ebx], eax );
代替之前的指令序列,为什么要使用五条指令,而三条指令就能完成相同的任务呢?这是一个很好的例子,说明为什么你需要深入了解你的寻址模式。选择正确的寻址模式可以减少程序的大小,从而加快程序的运行速度。
当然,既然我们在讨论效率提升,值得指出的是,80x86 的缩放索引寻址模式让你能够自动将索引乘以 1、2、4 或 8。因为当前的示例将索引乘以 4,所以我们可以通过使用缩放索引寻址模式进一步简化代码:
mov( index, ebx );
mov( SixteenInts[ ebx*4 ], eax );
但需要注意的是,如果你需要将索引乘以其他常数,而不是 1、2、4 或 8,那么你不能使用缩放索引寻址模式。同样,如果你需要将索引乘以某个不是 2 的幂的元素大小,你将不能使用 shl 指令来将索引乘以元素大小;相反,你需要使用 intmul 或其他指令序列来完成乘法运算。
80x86 上的索引寻址模式非常适合访问一维数组的元素。事实上,它的语法甚至暗示着数组访问。需要记住的重要一点是,你必须记得将索引乘以元素的大小。如果忘记这样做,会导致不正确的结果。
4.21 排序一个值的数组
几乎每本教科书在介绍数组时都会给出一个排序的例子。因为你可能已经在高级语言中看到过如何进行排序,所以看看在 HLA 中如何实现排序可能会更具启发性。本节中的示例代码将使用气泡排序的变种,这对于短列表数据和几乎已排序的列表非常有效,但对于其他情况几乎毫无用处。^([63])
const
NumElements := 16;
static
DataToSort: uns32[ NumElements ] :=
[
1, 2, 16, 14,
3, 9, 4, 10,
5, 7, 15, 12,
8, 6, 11, 13
];
NoSwap: boolean;
.
.
.
// Bubble sort for the DataToSort array:
repeat
mov( true, NoSwap );
for( mov( 0, ebx ); ebx <= NumElements-2; inc( ebx )) do
mov( DataToSort[ ebx*4], eax );
if( eax > DataToSort[ ebx*4 + 4] ) then
mov( DataToSort[ ebx*4 + 4 ], ecx );
mov( ecx, DataToSort[ ebx*4 ] );
mov( eax, DataToSort[ ebx*4 + 4 ] ); // Note: eax contains
mov( false, NoSwap ); // DataToSort[ ebx*4 ]
endif;
endfor;
until( NoSwap );
气泡排序通过比较数组中相邻的元素来工作。这个代码片段中有一个有趣的地方,那就是它如何比较相邻的元素。你会注意到,if 语句将 EAX(它包含 DataToSort[ebx*4])与 DataToSort[ebx*4 + 4]进行比较。因为该数组的每个元素是 4 字节(uns32),所以索引 [ebx*4 + 4] 引用的是 [ebx*4] 之后的下一个元素。
像气泡排序算法一样,如果最内层的循环在没有交换任何数据的情况下完成,则该算法终止。如果数据已经是预排序的,那么气泡排序非常高效,仅对数据进行一次遍历。不幸的是,如果数据没有排序(最坏的情况是数据按逆序排列),那么这个算法非常低效。实际上,虽然可以修改上面的代码,使其在平均情况下运行速度提高约两倍,但对于这么一个效率低下的算法,这些优化是徒劳的。然而,气泡排序非常容易实现和理解(这也是为什么入门教材仍然在示例中使用它)。
^([63]) 不要担心,您将在第五章看到一些更好的排序算法。
4.22 多维数组
80x86 硬件可以轻松处理一维数组。不幸的是,没有一种神奇的寻址模式可以让你轻松访问多维数组的元素。要做到这一点需要一些工作和多条指令。
在讨论如何声明或访问多维数组之前,最好先弄清楚如何在内存中实现它们。第一个问题是弄清楚如何将一个多维对象存储到一维内存空间中。
假设你有一个 Pascal 数组,形式为 A:array[0..3,0..3] of char;。这个数组包含 16 个字节,组织为四行四列的字符。你需要将这个数组中的 16 个字节与主内存中的 16 个连续字节对应起来。图 4-5 展示了一种实现方式。

图 4-5. 将 4x4 数组映射到顺序内存位置
实际的映射并不重要,只要满足两个条件:(1) 每个元素都映射到唯一的内存位置(即,数组中的任何两个条目都不占用相同的内存位置),(2) 映射是一致的。也就是说,数组中的某个给定元素始终映射到相同的内存位置。因此,你真正需要的是一个带有两个输入参数(行和列)的函数,它可以产生一个指向 16 个内存位置的线性数组的偏移量。
现在,任何满足上述约束的函数都可以正常工作。事实上,只要映射一致,你甚至可以随意选择一个映射。然而,真正需要的是一个在运行时高效计算并且适用于任何大小数组(不仅仅是 4x4 或二维数组)的映射。虽然有很多可能的函数符合这个要求,但有两个特别的函数是大多数程序员和高级语言使用的:行主序排列 和 列主序排列。
4.22.1 行主序排列
行主序排列将相邻的元素按行从左到右排列,再按列向下排列,并分配到连续的内存位置。这个映射在图 4-6 中得到了展示。

图 4-6. 行主序数组元素排列
行主序排列是大多数高级编程语言采用的方法。这种方法在机器语言中非常容易实现和使用。你从第一行(行 0)开始,然后将第二行连接到其末尾。接着将第三行连接到列表的末尾,然后是第四行,依此类推(参见图 4-7)。

图 4-7. 4×4 数组的行主序排列的另一种视图
将索引值列表转换为偏移量的实际函数是对计算一维数组元素地址公式的轻微修改。计算二维行主序排列数组偏移量的公式如下:
*`Element_Address`* = *`Base_Address`* + (*`colindex`* * *`row_size`* + *`rowindex`*) *
*`Element_Size`*
像往常一样,Base_Address 是数组第一个元素的地址(在本例中是A[0][0]),Element_Size 是数组单个元素的大小,以字节为单位。colindex 是最左边的索引,rowindex 是数组中最右边的索引。row_size 是数组中一行的元素数量(在本例中为四,因为每行有四个元素)。假设Element_Size 为 1,以下公式计算从基地址开始的偏移量:
Column Row Offset
Index Index into Array
0 0 0
0 1 1
0 2 2
0 3 3
1 0 4
1 1 5
1 2 6
1 3 7
2 0 8
2 1 9
2 2 10
2 3 11
3 0 12
3 1 13
3 2 14
3 3 15
对于三维数组,计算内存偏移量的公式如下:
*`Address`* = *`Base`* + ((*`depthindex`***`col_size`*+*`colindex`*) * *`row_size`* + *`rowindex`*)
* *`Element_Size`*
col_size 是列中的元素数,row_size 是行中的元素数。在 C/C++ 中,如果你声明数组为 type A[i] [j] [k];,那么 row_size 等于 k,col_size 等于 j。
对于在 C/C++ 中声明的四维数组 type A[i] [j] [k] [m];,计算数组元素地址的公式是:
*`Address`* =
*`Base`* + (((*`LeftIndex`***`depth_size`*+*`depthindex`*)**`col_size`*+*`colindex`*) * *`row_size`*
+ *`rowindex`*) * *`Element_Size`*
depth_size 等于 j,col_size 等于 k,row_size 等于 m。LeftIndex 表示最左侧索引的值。
到目前为止,你可能已经开始看到一种模式。确实存在一个通用公式,可以计算任何维度的数组在内存中的偏移量;然而,你很少会使用超过四个维度的数组。
另一种方便的思考行主序数组的方法是将其视为数组的数组。考虑以下单维 Pascal 数组定义:
A: array [0..3] of *`sometype`*;
假设 sometype 是类型 sometype = array [0..3] of char;。
A 是一个一维数组。它的单独元素恰好是数组,但暂时可以忽略这一点。计算一维数组元素地址的公式是:
*`Element_Address`* = *`Base`* + *`Index`* * *`Element_Size`*
在这种情况下,Element_Size 恰好为 4,因为 A 的每个元素都是一个包含四个字符的数组。那么这个公式计算的是什么呢?它计算的是这个 4x4 字符数组中每一行的基地址(参见 图 4-8)。

图 4-8. 查看 4x4 数组作为数组的数组
当然,一旦计算出一行的基地址,你可以重新应用一维公式来获取特定元素的地址。虽然这不会影响计算,但处理几个一维计算可能比处理复杂的多维数组计算要容易一些。
考虑一个定义为 A:array [0..3] [0..3] [0..3] [0..3] [0..3] of char; 的 Pascal 数组。你可以将这个五维数组视为一个数组的数组。一段 HLA 代码提供了这样的定义:
type
OneD: char[4];
TwoD: OneD[4];
ThreeD: TwoD[4];
FourD: ThreeD [4];
var
A : FourD [4];
OneD 的大小是 4 字节。因为 TwoD 包含四个 OneD 数组,所以它的大小是 16 字节。同样,ThreeD 是四个 TwoD,因此它的大小是 64 字节。最后,FourD 是四个 ThreeD,所以它的大小是 256 字节。为了计算 A [b, c, d, e, f] 的地址,你可以使用以下步骤:
-
通过公式计算
A [b]的地址,公式为Base+ b *size。其中 size 为 256 字节。将此结果作为下一步计算中的新基地址。 -
通过公式
Base+ c *size计算A [b, c]的地址,其中Base是上一步得到的值,size为 64。将结果作为下一步计算中的新基地址。 -
通过
Base+ d *size计算A[b, c, d]的基地址,Base来自前一步的计算,size为 16。将该结果作为下一个计算的基地址。 -
使用公式
Base+ e *size计算A[b, c, d, e]的地址,其中Base来自前一步的计算,size为 4。将此值作为下一步计算的基地址。 -
最后,使用公式
Base+ f *size计算A[b, c, d, e, f]的地址,其中Base来自之前的计算,size为 1(显然,你可以忽略这一最后的乘法)。此时你得到的结果就是所需元素的地址。
你在汇编语言中不会找到高维数组的一个主要原因是,汇编语言强调与这种访问方式相关的低效性。像 A[b, c, d, e, f] 这样的内容很容易就会被输入到 Pascal 程序中,而你可能没意识到编译器在处理这些代码时的方式。汇编语言程序员可不会这么轻率——他们清楚地知道,当你使用高维数组时,会遇到的一团糟。事实上,优秀的汇编语言程序员会尽量避免使用二维数组,并且在不得已使用二维数组时,往往会采取一些技巧来访问该数组中的数据。
4.22.2 列主序排列
列主序排列是高阶语言中常用来计算数组元素地址的另一种方法。FORTRAN 和各种 BASIC 方言(例如,早期版本的 Microsoft BASIC)使用这种方法。
在行主序排列中,最右侧的索引随着你在连续的内存位置中移动而增长最快。在列主序排列中,最左侧的索引增长得最快。从图示上来看,列主序排列的数组结构如图 Figure 4-9 所示。

图 4-9。列主序数组元素排列
使用列主序排列时,计算数组元素地址的公式与行主序排列时非常相似。你只需要在计算中反转索引和尺寸:
For a two-dimension column-major array:
*`Element_Address`* = *`Base_Address`* + (*`rowindex`* * *`col_size`* + *`colindex`*) *
*`Element_Size`*
For a three-dimension column-major array:
*`Address`* = *`Base`* + ((*`rowindex`* * *`col_size`*+*`colindex`*) * *`depth_size`* +
*`depthindex`*) *
*`Element_Size`*
For a four-dimension column-major array:
*`Address`* =
*`Base`* + (((*`rowindex`* * *`col_size`* + *`colindex`*)**`depth_size`* + *`depthindex`*) *
*`Left_size`* + *`Leftindex`*) * *`Element_Size`*
4.23 为多维数组分配存储空间
如果你有一个 m x n 的数组,它将包含 m * n 个元素,并且需要 m * n * Element_Size 字节的存储空间。为了为数组分配存储空间,你必须预留这块内存。像往常一样,有几种不同的方式来完成这个任务。幸运的是,HLA 的数组声明语法与高阶语言的数组声明语法非常相似,因此 C/C++、Java、BASIC 和 Pascal 程序员都会觉得得心应手。要在 HLA 中声明一个多维数组,你可以使用类似以下的声明:
*`ArrayName`*: *`elementType`* [ *`comma_separated_list_of_dimension_bounds`* ];
例如,这里是一个 4x4 字符数组的声明:
GameGrid: char[ 4, 4 ];
这里是另一个示例,展示如何声明一个三维字符串数组:
NameItems: string[ 2, 3, 3 ];
请记住,字符串对象实际上是指针,因此此数组声明为 18 个双字指针(2 * 3 * 3 = 18)保留了存储空间。
就像单维数组一样,你可以通过在声明后跟上赋值操作符和数组常量来初始化数组的每个元素。数组常量会忽略维度信息;唯一重要的是数组常量中的元素数量与实际数组中的元素数量相对应。以下示例显示了带有初始化器的 GameGrid 声明:
GameGrid: char[ 4, 4 ] :=
[
'a', 'b', 'c', 'd',
'e', 'f', 'g', 'h',
'i', 'j', 'k', 'l',
'm', 'n', 'o', 'p'
];
请注意,HLA 会忽略此声明中出现的缩进和额外的空白字符(例如换行符)。此布局是为了增强可读性(这是一个始终不错的想法)。HLA 并不会将这四行视为数组中的数据行,而是人类这样做,这也是为什么以这种方式书写数据很好的原因。唯一重要的是数组常量中有 16(4 * 4)个字符。你可能会同意,这比
GameGrid: char[ 4,4 ] :=
[ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
'n', 'o', 'p' ];
当然,如果你有一个很大的数组,或者一个非常大的行数数组,或者一个多维数组,那么最终可能无法获得可读性高的代码。这时,精心编写的注释就派上用场了。
至于单维数组,你可以使用 dup 操作符来将大数组的每个元素初始化为相同的值。以下示例初始化了一个 256x64 的字节数组,使每个字节包含值 $FF:
StateValue: byte[ 256, 64 ] := 256*64 dup [$ff];
注意使用常量表达式来计算数组元素的数量,而不是简单地使用常量 16,384(256 * 64)。使用常量表达式更加清晰地表明这段代码正在初始化一个 256x64 元素的数组,而不是简单的文字常量 16,384。
另一个可以用来提高程序可读性的 HLA 技巧是使用 嵌套数组常量。以下是一个 HLA 嵌套数组常量的示例:
[ [0, 1, 2], [3, 4], [10, 11, 12, 13] ]
每当 HLA 遇到嵌套在另一个数组常量中的数组常量时,它会简单地移除围绕嵌套数组常量的括号,并将整个常量视为单一数组常量。例如,HLA 会将这个嵌套数组常量转换为如下所示:
[ 0, 1, 2, 3, 4, 10, 11, 12, 13 ]
你可以利用这一点来帮助使你的程序更具可读性。对于多维数组常量,你可以将每一行常量用方括号括起来,以表示每一行的数据是分组的,并与其他行分开。考虑下面的 GameGrid 数组声明,它与之前的 GameGrid 声明在 HLA 眼中是相同的:
GameGrid: char[ 4, 4 ] :=
[
[ 'a', 'b', 'c', 'd' ],
[ 'e', 'f', 'g', 'h' ],
[ 'i', 'j', 'k', 'l' ],
[ 'm', 'n', 'o', 'p' ]
];
这个声明更清楚地表明该数组常量是一个 4x4 的数组,而不仅仅是一个 16 元素的一维数组,因为它的元素无法都放在一行源代码中。像这样的细微美学改进正是将普通程序员与优秀程序员区分开的因素。
4.24 在汇编语言中访问多维数组元素
好的,你已经看过了计算多维数组元素地址的公式。现在是时候看看如何使用汇编语言访问这些数组的元素了。
mov、shl和intmul指令能够快速处理计算多维数组偏移量的各种方程式。我们先来看一个二维数组的例子。
static
i: int32;
j: int32;
TwoD: int32[ 4, 8 ];
.
.
.
// To perform the operation TwoD[i,j] := 5; you'd use code like the following.
// Note that the array index computation is (i*8 + j)*4.
mov( i, ebx );
shl( 3, ebx ); // Multiply by 8 (shl by 3 is a multiply by 8).
add( j, ebx );
mov( 5, TwoD[ ebx*4 ] );
请注意,这段代码不需要在 80x86 上使用双寄存器寻址模式。虽然像TwoD[ebx][esi]这样的寻址模式看起来应该是访问二维数组的自然方式,但这并不是该寻址模式的目的。
现在考虑第二个示例,它使用了一个三维数组:
static
i: int32;
j: int32;
k: int32;
ThreeD: int32[ 3, 4, 5 ];
.
.
.
// To perform the operation ThreeD[i,j,k] := esi; you'd use the following code
// that computes ((i*4 + j)*5 + k )*4 as the address of ThreeD[i,j,k].
mov( i, ebx );
shl( 2, ebx ); // Four elements per column.
add( j, ebx );
intmul( 5, ebx ); // Five elements per row.
add( k, ebx );
mov( esi, ThreeD[ ebx*4 ] );
请注意,这段代码使用了intmul指令将 EBX 中的值乘以 5。记住,shl指令只能将寄存器的值乘以 2 的幂。虽然也有方法将寄存器中的值乘以非 2 的幂常数,但intmul指令更加方便。^([64])
^([64]) 关于常数乘法的完整讨论(除了 2 的幂)出现在第四章。
4.25 记录
另一个主要的复合数据结构是 Pascal 记录或 C/C++/C#结构。^([65]) Pascal 术语可能更好,因为它避免了与更通用的术语数据结构混淆。由于 HLA 使用术语记录,我们在这里也采用这个术语。
而数组是同质的,其元素都是相同类型的,记录中的元素可以具有不同的类型。数组允许你通过整数索引选择特定元素。对于记录,你必须通过名称选择一个元素(称为字段)。
记录的全部目的是让你将不同的、尽管在逻辑上相关的数据封装到一个单一的包中。Pascal 记录的学生声明是一个典型示例:
student =
record
Name: string[64];
Major: integer;
SSN: string[11];
Midterm1: integer;
Midterm2: integer;
Final: integer;
Homework: integer;
Projects: integer;
end;
大多数 Pascal 编译器将记录中的每个字段分配到连续的内存位置。这意味着 Pascal 将为名称保留前 65 个字节,^([66])接下来的 2 个字节保存主代码,接下来的 12 个字节保存社会安全号码,依此类推。
在 HLA 中,你也可以使用record/endrecord声明创建记录类型。你可以通过以下方式在 HLA 中编码上述记录:
type
student: record
Name: char[65];
Major: int16;
SSN: char[12];
Midterm1: int16;
Midterm2: int16;
Final: int16;
Homework: int16;
Projects: int16;
endrecord;
正如你所看到的,HLA 声明与 Pascal 声明非常相似。请注意,为了忠实于 Pascal 声明,示例中使用了字符数组而不是字符串来表示Name和SSN(美国社会安全号码)字段。在实际的 HLA 记录声明中,你可能会为至少是名称字段使用字符串类型(记住,字符串变量实际上是一个 4 字节指针)。
记录中的字段名必须是唯一的。也就是说,同一个名字不能在同一个记录中出现两次或更多次。然而,所有字段名对于该记录是局部的。因此,你可以在程序的其他地方或不同的记录中重复使用这些字段名。
record/endrecord声明可以出现在变量声明部分(例如static或var)或type声明部分。在之前的示例中,Student声明出现在type部分,因此这并没有为Student变量分配任何存储空间。相反,你需要显式声明一个Student类型的变量。以下示例演示了如何做到这一点:
var
John: Student;
这分配了 81 字节的存储,并在内存中按图 4-10 所示的方式布局。

图 4-10. 学生数据结构在内存中的存储
如果标签John对应于该记录的基地址,那么Name字段的偏移量为John+0,Major字段的偏移量为John+65,SSN字段的偏移量为John+67,依此类推。
要访问结构中的元素,你需要知道从结构的起始位置到目标字段的偏移量。例如,变量John中的Major字段相对于John的基地址的偏移量是 65。因此,你可以使用以下指令将 AX 中的值存储到该字段中:
mov( ax, (type word John[65]) );
不幸的是,记住记录中所有字段的偏移量违背了使用记录的初衷。毕竟,如果你必须处理这些数字偏移量,为什么不直接使用字节数组而不是记录呢?
幸运的是,HLA 让你可以通过与 C/C++/C#和 Pascal 相同的机制来引用记录中的字段名:点操作符。为了将 AX 存储到Major字段,你可以使用mov( ax, John.Major );,而不是之前的指令。这种方式更加易读,且使用起来显然更为简便。
请注意,使用点操作符并不会引入新的寻址模式。指令mov( ax, John.Major );依然使用的是仅位移寻址模式。HLA 只是将John的基地址与Major字段的偏移量(65)相加,以得到实际的位移量,并将其编码到指令中。
像任何类型声明一样,HLA 要求所有记录类型声明在你使用之前出现在程序中。然而,你不必在type部分定义所有记录来创建记录变量。你可以直接在变量声明部分使用record/endrecord声明。如果程序中只需要一个特定记录对象的实例,这种方式非常方便。以下示例演示了这一点:
storage
OriginPoint: record
x: uns8;
y: uns8;
z: uns8;
endrecord;
^([65]) 它在其他语言中也有一些不同的名称,但大多数人至少认识其中一个名称。
^([66]) 字符串需要额外的一个字节,除了字符串中的所有字符外,还用于编码长度。
4.26 记录常量
HLA 让你定义记录常量。事实上,HLA 支持符号(显式)记录常量和字面值记录常量。记录常量作为静态记录变量的初始化器非常有用。当使用 HLA 编译时语言时,它们也作为编译时数据结构非常有用(有关 HLA 编译时语言的更多细节,请参阅 HLA 参考手册)。本节将讨论如何创建记录常量。
字面值记录常量的形式如下:
*`RecordTypeName`*:[ *`List_of_comma_separated_constants`* ]
RecordTypeName 是你在 HLA type 部分定义的记录数据类型的名称,必须在使用常量之前定义。
出现在括号中的常量列表是指定记录中每个字段的数据。列表中的第一个项对应记录的第一个字段,第二个项对应记录的第二个字段,以此类推。此列表中每个常量的数据类型必须与其各自字段的类型匹配。以下示例演示了如何使用字面值记录常量来初始化记录变量:
type
point: record
x:int32;
y:int32;
z:int32;
endrecord;
static
Vector: point := point:[ 1, −2, 3 ];
该声明将 Vector.x 初始化为 1,Vector.y 初始化为 −2,Vector.z 初始化为 3。
你也可以通过在程序的 const 或 val 部分声明记录对象来创建显式记录常量。你可以像访问记录变量的字段一样访问这些符号记录常量的字段,使用点操作符。由于该对象是常量,你可以在任何该字段类型合法的常量位置指定记录常量的字段。你还可以将符号记录常量作为变量初始化器。以下示例演示了这一点:
type
point: record
x:int32;
y:int32;
z:int32;
endrecord;
const
PointInSpace: point := point:[ 1, 2, 3 ];
static
Vector: point := PointInSpace;
XCoord: int32 := PointInSpace.x;
.
.
.
stdout.put( "Y Coordinate is ", PointInSpace.y, nl );
.
.
.
4.27 记录的数组
创建记录数组是一个完全合理的操作。为此,你只需要创建一个记录类型,然后使用标准的数组声明语法。以下示例演示了如何做到这一点:
type
*`recElement`*:
record
<< Fields for this record >>
endrecord;
.
.
.
static
*`recArray`*: *`recElement`*[4];
要访问该数组的元素,你可以使用标准的数组索引技巧。由于 recArray 是一个一维数组,你需要使用公式 baseAddress + index*@size( recElement ) 来计算该数组元素的地址。例如,要访问 recArray 的一个元素,你可以使用如下代码:
// Access element i of *`recArray`*:
intmul( @size( *`recElement`* ), i, ebx ); // ebx := i*@size( *`recElement`* )
mov( *`recArray`*.*`someField`*[ebx], eax );
请注意,索引规范紧随整个变量名之后;记住,这是汇编语言,而不是高级语言(在高级语言中,你可能会使用 recArray[i].someField)。
自然,你也可以创建多维记录数组。你将使用行主序或列主序的函数来计算该记录中某个元素的地址。唯一真正变化的地方(与数组的讨论相比)是每个元素的大小是记录对象的大小。
static
rec2D: recElement[ 4, 6 ];
.
.
.
// Access element [i,j] of rec2D and load *`someField`* into eax:
intmul( 6, i, ebx );
add( j, ebx );
intmul( @size( *`recElement`* ), ebx );
mov( rec2D.*`someField`*[ ebx ], eax );
4.28 数组/记录作为记录字段
记录可以包含其他记录或数组作为字段。考虑以下定义:
type
Pixel:
record
Pt: point;
color: dword;
endrecord;
上面的定义定义了一个带有 32 位颜色组件的单一点。在初始化Pixel类型的对象时,第一个初始化器对应于Pt字段,而不是 x 坐标 字段。以下定义是错误的:
static
ThisPt: Pixel := Pixel:[ 5, 10 ]; // Syntactically incorrect!
第一个字段(5)的值不是point类型的对象。因此,汇编程序在遇到这条语句时会生成一个错误。HLA 允许你使用如下声明来初始化Pixel的字段:
static
ThisPt: Pixel := Pixel:[ point:[ 1, 2, 3 ], 10 ];
ThatPt: Pixel := Pixel:[ point:[ 0, 0, 0 ], 5 ];
访问Pixel字段非常简单。就像在高级语言中一样,你使用一个句点来引用Pt字段,再用一个句点访问point的x、y和z字段:
stdout.put( "ThisPt.Pt.x = ", ThisPt.Pt.x, nl );
stdout.put( "ThisPt.Pt.y = ", ThisPt.Pt.y, nl );
stdout.put( "ThisPt.Pt.z = ", ThisPt.Pt.z, nl );
.
.
.
mov( eax, ThisPt.Color );
你还可以将数组声明为记录字段。以下记录创建了一个数据类型,能够表示一个有八个点的对象(例如,一个立方体):
type
Object8:
record
Pts: point[8];
Color: dword;
endrecord;
这个记录为八个不同的点分配存储空间。访问Pts数组中的一个元素要求你知道point类型对象的大小(记住,你必须将数组索引乘以单个元素的大小,在这个特定的例子中是 12)。假设,例如,你有一个类型为Object8的变量Cube。你可以如下访问Pts数组中的元素:
// Cube.Pts[i].x := 0;
mov( i, ebx );
intmul( 12, ebx );
mov( 0, Cube.Pts.x[ebx] );
所有这一切的一个不幸之处在于,你必须知道Pts数组中每个元素的大小。幸运的是,你可以使用@size重写上面的代码,如下所示:
// Cube.Pts[i].x := 0;
mov( i, ebx );
intmul( @size( point ), ebx );
mov( 0, Cube.Pts.x[ebx] );
注意,在这个示例中,索引规格([ebx])跟随整个对象名称,即使数组是Pts,而不是x。请记住,[ebx]规格是一个索引寻址模式,而不是数组索引。索引总是跟随整个名称,因此你不会像在 C/C++ 或 Pascal 等高级语言中那样将它们附加到数组组件上。这会产生正确的结果,因为加法是可交换的,点运算符(以及索引运算符)对应加法。特别是,表达式Cube.Pts.x[ebx]告诉 HLA 计算Cube(对象的基址)加上Pts字段的偏移,再加上x字段的偏移,再加上 EBX 的值。从技术上讲,我们实际上是在计算offset(Cube) + offset(Pts) + EBX + offset(x),但我们可以重新排列这个顺序,因为加法是可交换的。
你还可以在记录中定义二维数组。访问此类数组的元素与访问任何其他二维数组没有不同,唯一的区别是你必须将数组的字段名称指定为数组的基地址。例如:
type
RecW2DArray:
record
intField: int32;
aField: int32[4,5];
.
.
.
endrecord;
static
recVar: RecW2DArray;
.
.
.
// Access element [i,j] of the aField field using row-major ordering:
mov( i, ebx );
intmul( 5, ebx );
add( j, ebx );
mov( recVar.aField[ ebx*4 ], eax );
.
.
.
上面的代码使用标准的行优先计算法来索引一个 4x5 的双字数组。这个示例与独立数组访问的唯一区别是基址是recVar.aField。
嵌套记录定义有两种常见方式。正如本节所述,你可以在type部分创建一个记录类型,然后将该类型名称作为记录中某个字段的数据类型(例如,上面Pixel数据类型中的Pt:point字段)。也可以在另一个记录内直接声明一个记录,而不为该记录创建单独的数据类型;以下示例演示了这一点:
type
NestedRecs:
record
iField: int32;
sField: string;
rField:
record
i:int32;
u:uns32;
endrecord;
cField:char;
endrecord;
通常,创建一个单独的类型比直接将记录嵌入到其他记录中更好,但嵌套记录是完全合法的。
如果你有一个记录数组,并且其中一个字段是数组类型,你必须独立地计算每个数组的索引,然后将这些索引的和作为最终的索引。以下示例演示了如何做到这一点:
type
recType:
record
arrayField: dword[4,5];
<< Other fields >>
endrecord;
static
aryOfRecs: recType[3,3];
.
.
.
// Access aryOfRecs[i,j].arrayField[k,l]:
intmul( 5, i, ebx ); // Computes index into aryOfRecs
add( j, ebx ); // as (i*5 +j)*@size( recType ).
intmul( @size( recType ), ebx );
intmul( 3, k, eax ); // Computes index into aryOfRecs
add( l, eax ); // as (k*3 + j) (*4 handled later).
mov( aryOfRecs.arrayField[ ebx + eax*4 ], eax );
请注意,使用基址加缩放索引寻址模式来简化此操作。
4.29 在记录中对齐字段
为了在程序中获得最佳性能,或者确保 HLA 的记录正确映射到某些高级语言中的记录或结构,你通常需要能够控制记录中字段的对齐。例如,你可能希望确保一个双字节字段的偏移量是 4 的偶数倍。你可以使用align指令来实现这一点。以下示例展示了如何将某些字段对齐到重要的边界:
type
PaddedRecord:
record
c: char;
align(4);
d: dword;
b: boolean;
align(2);
w: word;
endrecord;
每当 HLA 在记录声明中遇到align指令时,它会自动调整后续字段的偏移量,以确保它是align指令指定值的偶数倍。如果需要,它会通过增加该字段的偏移量来实现这一点。在上面的示例中,字段的偏移量将如下:c:0、d:4、b:8、w:10。注意,HLA 在c和d之间插入了 3 个字节的填充,在b和w之间插入了 1 个字节的填充。不用说,你永远不应假设这些填充字节存在。如果你想使用这些额外的字节,那么你必须为它们声明字段。
请注意,在记录声明中指定对齐并不能保证字段在内存中会对齐到指定的边界;它只是确保字段的偏移量是你指定的值的倍数。如果一个PaddedRecord类型的变量在内存中从一个奇数地址开始,那么d字段也将从一个奇数地址开始(因为任何奇数地址加 4 仍然是奇数地址)。如果你想确保字段在内存中对齐到合适的边界,你还必须在该记录类型的变量声明之前使用align指令。例如:
static
.
.
.
align(4);
PRvar: PaddedRecord;
align操作数的值应是一个偶数,并且可以被记录类型中最大的align表达式整除(在本例中,4 是最大值,且已能被 2 整除)。
如果你想确保记录的大小是某个值的倍数,那么只需在记录声明的最后加上一个 align 指令。HLA 会在记录的末尾填充适当数量的字节,以使其大小符合要求。以下示例演示了如何确保记录的大小是 4 字节的倍数:
type
PaddedRec:
record
<< Some field declarations >>
align(4);
endrecord;
HLA 提供了一些额外的对齐指令,用于记录类型,让你可以轻松控制记录内所有字段的对齐方式以及字段在记录中的起始偏移量。如果你对更多信息感兴趣,请查阅 HLA 参考手册。
4.30 指向记录的指针
在执行过程中,你的程序可能会通过指针间接引用记录对象。当你使用指针访问结构体的字段时,你必须将 80x86 的一个 32 位寄存器加载为所需记录的地址。假设你有以下变量声明(假设是前面部分提到的 Object8 结构):
static
Cube: Object8;
CubePtr: pointer to Object8 := &Cube;
CubePtr 包含(即指向)Cube 对象的地址。要访问 Cube 对象的 Color 字段,你可以使用类似 mov( Cube.Color, eax ) 的指令。通过指针访问字段时,你首先需要将对象的地址加载到如 EBX 这样的 32 位寄存器中。指令 mov( CubePtr, ebx ); 就能完成这一步。完成后,你可以使用 [ebx+offset] 寻址模式来访问 Cube 对象的字段。唯一的问题是,“如何指定要访问哪个字段?”请简要考虑以下 错误的 代码:
mov( CubePtr, ebx );
mov( [ebx].Color, eax ); // This does not work!
因为字段名是结构体内部的局部名称,且可能在两个或多个结构体中重用同一个字段名,HLA 如何确定 Color 表示的偏移量呢?当直接访问结构体成员时(例如,mov( Cube.Color, eax );),没有歧义,因为 Cube 有一个特定的类型,汇编器可以进行检查。另一方面,[ebx] 可以指向 任何东西。特别是,它可以指向任何包含 Color 字段的结构体。因此,汇编器不能单独决定 Color 符号使用哪个偏移量。
HLA 通过要求你显式地提供类型来解决这个模糊性问题。为此,你必须将 [ebx] 强制转换为 Cube 类型。一旦完成这一步,你就可以使用正常的点操作符符号来访问 Color 字段:
mov( CubePtr, ebx );
mov( (type Cube [ebx]).Color, eax );
如果你有指向记录的指针,并且该记录的某个字段是数组,访问该字段元素最简单的方法是使用基址加索引的寻址模式。为此,你只需将指针的值加载到一个寄存器中,并在第二个寄存器中计算数组的索引。然后,你将这两个寄存器组合在地址表达式中。在上面的例子中,Pts 字段是一个包含八个 point 对象的数组。要访问 Cube.Pts 字段的第 i 个元素的 x 字段,你可以使用如下代码:
mov( CubePtr, ebx );
intmul( @size( point ), i, esi ); // Compute index into point array.
mov( (type Object8 [ebx]).Pts.x[ esi*4 ], eax );
如果你在程序中频繁使用指向特定记录类型的指针,像(type Object8 [ebx])这样的强制类型转换操作符很快就会变得很麻烦。减少强制转换 EBX 输入量的一种方法是使用text常量。考虑以下语句:
const
O8ptr: text := "(type Object8 [ebx])";
在程序开始时使用此语句,你可以用O8ptr代替类型强制转换操作符,HLA 会自动替换为适当的文本。使用像上面这样的文本常量,前面的例子变得更具可读性和可写性:
mov( CubePtr, ebx );
intmul( @size( point ), i, esi ); // Compute index into point array.
mov( O8Ptr.Pts.x[ esi*4 ], eax );
4.31 联合体
记录定义根据字段的大小为记录中的每个字段分配不同的偏移量。这种行为与在var或static段中分配内存偏移量非常相似。HLA 提供了第二种结构声明类型——union,它不会为每个对象分配不同的地址;相反,union声明中的每个字段具有相同的偏移量——0。以下示例演示了union声明的语法:
type
*`unionType`*:
union
<< Fields (syntactically identical to record declarations) >>
endunion;
你访问union的字段的方式与访问记录的字段完全相同:使用点符号和字段名称。以下是一个union类型声明及其union类型变量的具体示例:
type
numeric:
union
i: int32;
u: uns32;
r: real64;
endunion;
.
.
.
static
number: numeric;
.
.
.
mov( 55, number.u );
.
.
.
mov( −5, number.i );
.
.
.
stdout.put( "Real value = ", number.r, nl );
关于union对象需要注意的重要一点是,union的所有字段在结构中具有相同的偏移量。在上面的例子中,number.u、number.i和number.r字段的偏移量都是相同的:0。因此,union的字段在内存中是重叠的;这与 80x86 的 8 位、16 位和 32 位寄存器之间的重叠方式非常相似。通常,你一次只能访问union的一个字段;也就是说,你不能同时操作一个特定union变量的多个字段,因为写入一个字段会覆盖其他字段。在上面的例子中,任何对number.u的修改都会改变number.i和number.r。
程序员通常出于两种不同的原因使用联合体:节省内存或创建别名。节省内存是这种数据结构的主要用途。为了了解这如何运作,我们可以将上面的numeric union与相应的记录类型进行比较。
type
numericRec:
record
i: int32;
u: uns32;
r: real64;
endrecord;
如果你声明一个类型为numericRec的变量,比如n,你可以像访问numeric类型的变量一样访问其字段,分别为n.i、n.u和n.r。两者的区别在于,numericRec变量为记录的每个字段分配了独立的存储空间,而numeric(联合体)对象则为所有字段分配了相同的存储空间。因此,@size(numericRec)的大小为 16,因为记录包含了两个双字字段和一个四字字段(real64)。而@size(numeric)的大小为 8,这是因为所有union的字段都占用了相同的内存位置,union对象的大小是该对象中最大字段的大小(参见图 4-11)。

图 4-11. union与record变量的布局
除了节省内存外,程序员通常使用联合体来创建代码中的别名。你可能还记得,别名是同一内存对象的不同名称。别名通常会导致程序中的混淆,因此你应该谨慎使用;然而,有时使用别名会非常方便。例如,在程序的某些部分,你可能需要不断使用类型转换来通过不同类型引用一个对象。虽然你可以使用 HLA 的text常量来简化这一过程,但另一种方法是使用一个union变量,其字段表示你希望用于该对象的不同类型。以下是一个示例代码:
type
CharOrUns:
union
c:char;
u:uns32;
endrecord;
static
v:CharOrUns;
通过如上声明,你可以通过访问v.u来操作一个uns32对象。如果某个时候,你需要将这个uns32变量的低字节当作字符来处理,你可以通过简单地访问v.c变量来实现,例如,
mov( eax, v.u );
stdout.put( "v, as a character, is '", v.c, "'" nl );
在 HLA 程序中,你可以像使用记录一样使用联合体。特别是,union声明可以作为记录中的字段,record声明可以作为联合体中的字段,数组声明可以出现在联合体内部,你还可以创建联合体数组,等等。
4.32 匿名联合体
在record声明中,你可以放置一个union声明,而无需为union对象指定字段名。以下示例演示了这种语法:
type
HasAnonUnion:
record
r:real64;
union
u:uns32;
i:int32;
endunion;
s:string;
endrecord;
static
v: HasAnonUnion;
每当匿名联合体出现在record中时,你可以像直接访问record字段一样访问union的字段。例如,在上面的例子中,你可以分别使用v.u和v.i的语法来访问v的u和i字段。u和i字段在记录中的偏移量相同(为 8,因为它们位于一个real64对象之后)。v的字段相对于v的基地址具有以下偏移量:
v.r 0
v.u 8
v.i 8
v.s 12
@size(v)的大小为 16,因为u和i字段只占用 4 个字节。
HLA 还允许在联合体中使用匿名记录。有关更多详细信息,请参阅 HLA 文档,尽管语法和用法与记录中的匿名联合体相同。
4.33 变体类型
联合体在程序中的一个重要用途是创建变体类型。一个变体变量可以在程序运行时动态改变其类型。一个变体对象可以在程序中的某一时刻是一个整数,之后在程序的另一个部分切换为字符串,稍后又变为实数值。许多高级语言(VHLL)系统使用动态类型系统(即变体对象)来减少程序的整体复杂性;实际上,许多 VHLL 的支持者坚持认为,使用动态类型系统是你能用少量代码编写复杂程序的原因之一。当然,如果你能够在 VHLL 中创建变体对象,那么在汇编语言中当然也能做到。在本节中,我们将看看如何使用union结构来创建变体类型。
在程序执行的任何时刻,变体对象都有一个特定的类型,但在程序控制下,变量可以切换到不同的类型。因此,当程序处理变体对象时,必须使用if语句或switch语句(或类似的语句)根据对象的当前类型执行不同的指令。高级语言会透明地做到这一点。在汇编语言中,你必须提供代码来测试类型。为了实现这一点,变体类型需要比对象的值更多的附加信息。具体而言,变体对象需要一个字段来指定对象的当前类型。这个字段(通常称为tag字段)是一个枚举类型或整数,用于指定对象在任何给定时刻的类型。以下代码演示了如何创建变体类型:
type
VariantType:
record
tag:uns32; // 0-uns32, 1-int32, 2-real64
union
u:uns32;
i:int32;
r:real64;
endunion;
endrecord;
static
v:VariantType;
程序将测试v.tag字段以确定v对象的当前类型。根据此测试,程序将操作v.i、v.u或v.r字段。
当然,在操作变体对象时,程序的代码必须不断测试tag字段,并针对uns32、int32或real64值执行一系列独立的指令。如果你经常使用变体字段,那么编写程序来处理这些操作是非常有意义的(例如,vadd、vsub、vmul和vdiv)。
4.34 命名空间
记录(record)和联合体(union)的一个非常好的特点是,字段名是局部的,仅在给定的record或union声明中有效。也就是说,你可以在不同的记录或联合体中重复使用字段名。这是 HLA 的一个重要特点,因为它有助于避免命名空间污染。命名空间污染发生在你用尽程序中的所有“好”名字时,你不得不开始为对象创建不具描述性的名称,因为你已经把最合适的名称用在了其他东西上。我们使用命名空间一词来描述 HLA 如何将名称与特定对象关联。record的字段名有一个命名空间,该命名空间仅限于该记录类型的对象。HLA 提供了这一命名空间机制的推广,允许你创建任意命名空间。这些命名空间对象可以让你保护常量、类型、变量和其他对象的名称,使它们的名称不会干扰程序中其他声明的命名。
一个 HLA 的namespace部分封装了一组通用声明,方式与record封装一组变量声明类似。namespace声明的形式如下:
namespace *`name`*;
<< declarations >>
end *`name`*;
name标识符为namespace提供了名称。end语句后的标识符必须与namespace后的标识符完全匹配。请注意,namespace声明部分本身就是一个单独的部分。它不需要出现在type或var部分中。namespace可以出现在任何一个 HLA 声明部分合法的位置。一个程序可以包含任意数量的namespace声明;实际上,命名空间标识符甚至不需要唯一,正如你很快会看到的那样。
在namespace和end语句之间出现的声明都是标准的 HLA 声明部分,除了你不能在namespace声明中嵌套其他namespace声明。不过,你可以在namespace内放置const、val、type、static、readonly和storage部分。^([67]) 以下代码提供了 HLA 程序中典型的namespace声明示例:
namespace myNames;
type
integer: int32;
static
i:integer;
j:uns32;
const
pi:real64 := 3.14159;
end myNames;
要访问命名空间的字段,你使用与记录和联合体相同的点符号。例如,要在命名空间外访问myNames的字段,你可以使用以下标识符:
myNames.integer A type declaration equivalent to int32
myNames.i An integer variable (int32)
myNames.j An uns32 variable
myNames.pi A real64 constant
这个示例还展示了一个关于namespace声明的重要点:在一个命名空间内,你可以引用同一命名空间声明中的其他标识符,而无需使用点符号。例如,上面的i字段使用了来自myNames命名空间的integer类型,而不需要使用mynames.前缀。
从上面的示例中看不出的一点是,namespace声明每次打开命名空间时都会创建一个干净的符号表。HLA 在namespace声明中识别的唯一外部符号是预定义的类型标识符(例如,int32、uns32和char)。HLA 在处理namespace声明时不会识别你在命名空间外部声明的任何符号。这会导致一个问题:如果你希望在声明命名空间内部的其他符号时使用命名空间外部的符号。例如,假设integer类型在myNames外部被定义如下:
type
integer: int32;
namespace myNames;
static
i:integer;
j:uns32;
const
pi:real64 := 3.14159;
end myNames;
如果你尝试编译这段代码,HLA 会抱怨符号integer未定义。显然integer在这个程序中已定义,但 HLA 在创建命名空间时会隐藏所有外部符号,这样你就可以在命名空间内重用(并重新定义)这些符号。当然,如果你真的想在那个命名空间内使用在myNames外部定义的名称,这并不会有什么帮助。HLA 提供了一个解决这个问题的方案:@global:运算符。如果在namespace声明部分中,你用@global:前缀加上一个名称,那么 HLA 将使用该名称的全局定义,而不是局部定义(如果局部定义存在)。为了修复上面示例中的问题,你可以使用以下代码:
type
integer: int32;
namespace myNames;
static
i:@global:integer;
j:uns32;
const
pi:real64 := 3.14159;
end myNames;
使用@global:前缀时,即使在myNames命名空间中出现了不同的integer声明,i变量的类型仍将是int32。
你不能嵌套namespace声明。从逻辑上看,这似乎没有必要,因此在 HLA 语言中省略了这一功能。
你可以在同一个程序中有多个使用相同命名空间标识符的namespace声明。例如:
namespace ns;
<< Declaration group #1 >>
end ns;
.
.
.
namespace ns;
<< Declaration group #2 >>
end ns;
当 HLA 遇到一个已定义标识符的第二个namespace声明时,它会将第二组声明附加到为第一组创建的符号列表的末尾。因此,处理完这两个namespace声明后,ns命名空间将包含你在两个namespace块中声明的所有符号。
命名空间最常见的用途可能是在库模块中。如果你创建一组库例程供多个项目使用或分发给他人,你需要小心选择函数和其他对象的名称。如果你使用像get和put这样的常见名称,当你的名称与用户的名称发生冲突时,他们会抱怨。一个简单的解决方案是将所有代码放在一个namespace块中。这样,你唯一需要担心的名称就是namespace标识符本身。这是唯一可能与其他用户的标识符发生冲突的名称。虽然这种情况有可能发生,但如果你不使用命名空间,并且你的库模块将数十个,甚至数百个新名称引入全局命名空间,冲突发生的概率会大大增加。^([68]) HLA 标准库提供了许多命名空间使用的好例子。HLA 标准库定义了多个命名空间,如stdout、stdin、str、cs和chars。你可以通过类似stdout.put、stdin.get、cs.intersection、str.eq和chars.toUpper这样的名称来引用这些命名空间中的函数。HLA 标准库中命名空间的使用防止了与你自己的程序中的类似名称发生冲突。
^([67]) 过程声明,作为第五章的主题,也是namespace声明部分中的合法内容。
^([68]) 全局命名空间是你程序的全局部分。
4.35 汇编语言中的动态数组
本章所描述的数组的一个问题是它们的大小是静态的。也就是说,所有示例中的元素数量是在编写程序时选择的;而不是在程序运行时(即动态地)选择的。遗憾的是,有时你在编写程序时根本不知道一个数组需要多大;只能在程序运行时确定数组的大小。本节描述了如何动态分配数组的存储空间,以便你可以在运行时设置数组的大小。
为一维数组分配存储空间并访问该数组的元素,在运行时几乎是一个微不足道的任务。你所需要做的就是调用 HLA 标准库中的mem.alloc例程,并指定数组的大小(以字节为单位)。mem.alloc将返回一个指向新数组基地址的指针,该指针存储在 EAX 寄存器中。通常,你会将这个地址保存在一个指针变量中,并在所有后续的数组访问中使用这个地址作为数组的基地址。
要访问一维动态数组的元素,你通常需要将基地址加载到一个寄存器中,并在第二个寄存器中计算索引。然后,你可以使用基址索引寻址模式来访问该数组的元素。这与访问静态分配数组元素的工作量没有太大区别。以下代码片段演示了如何分配和访问一维动态数组的元素。
static
ArySize: uns32;
BaseAdrs: pointer to uns32;
.
.
.
stdout.put( "How many elements do you want in your array? " );
stdin.getu32();
mov( eax, ArySize ); // Save away the upper bounds on this array.
shl( 2, eax ); // Multiply eax by 4 to compute the number of bytes.
mem.alloc( eax ); // Allocate storage for the array.
mov( eax, BaseAdrs ); // Save away the base address of the new array.
.
.
.
// Zero out each element of the array:
mov( BaseAdrs, ebx );
mov( 0, eax );
for( mov(0, esi); esi < ArySize; inc( esi )) do
mov( eax, [ebx + esi*4 ]);
endfor;
为多维数组动态分配存储是相当直接的。多维数组中的元素数量是所有维度值的乘积;例如,一个 4x5 的数组有 20 个元素。所以如果你从用户那里获得每个维度的边界,你需要做的就是计算所有这些边界值的乘积,并将结果乘以单个元素的大小。这将计算出数组的总字节数,这是mem.alloc期望的值。
访问多维数组的元素稍微有点复杂。问题在于你需要保留维度信息(即每个维度的边界),因为在计算行主序(或列主序)索引时,这些值是必需的。^([69]) 常见的解决方案是将这些边界存储在一个静态数组中(通常在编译时你就知道维度数,因此可以为这个维度边界的数组分配静态存储)。这个动态数组边界数组被称为哑向量。下面的代码片段展示了如何使用一个简单的哑向量来为一个二维动态数组分配存储。
var
ArrayPtr: pointer to uns32;
ArrayDims: uns32[2]; // The dope vector
.
.
.
// Get the array bounds from the user:
stdout.put( "Enter the bounds for dimension #1: " );
stdin.get( ArrayDims[0] );
stdout.put( "Enter the bounds for dimension #2: " );
stdin.get( ArrayDims[1*4] );
// Allocate storage for the array:
mov( ArrayDims[0], eax );
intmul( ArrayDims[1*4], eax );
shl( 2, eax ); // Multiply by 4 because each element is 4 bytes.
mem.alloc( eax ); // Allocate storage for the array and
mov( eax, ArrayPtr ); // save away the pointer to the array.
// Initialize the array:
mov( 0, edx );
mov( ArrayPtr, edi );
for( mov( 0, ebx ); ebx < ArrayDims[0]; inc( ebx )) do
for( mov( 0, ecx ); ecx < ArrayDims[1*4]; inc( ecx )) do
// Compute the index into the array
// as esi := ( ebx * ArrayDims[1*4] + ecx ) * 4
// (Note that the final multiplication by 4 is
// handled by the scaled indexed addressing mode below.)
mov( ebx, esi );
intmul( ArrayDims[1*4], esi );
add( ecx, esi );
// Initialize the current array element with edx.
mov( edx, [edi+esi*4] );
inc( edx );
endfor;
endfor;
^([69]) 从技术上讲,你不需要最左边维度边界的值来计算数组中的索引;然而,如果你想使用bound指令(或其他方法)检查索引边界,你需要在运行时保留这个值。
4.36 更多信息
在本书的电子版中,你可以在webster.cs.ucr.edu/或www.artofasm.com/找到关于数据类型的额外信息。HLA 标准库文档描述了 HLA 数组包,提供了对动态分配(以及静态分配)数组的支持,数组索引及许多其他数组选项。你应当参考 HLA 标准库文档以获取更多关于该数组包的详细信息。关于内存中数据结构表示的更多信息,你可以考虑阅读我的书《写出伟大的代码,第 1 卷》(No Starch Press,2004)。关于数据类型的深入讨论,你应当查阅有关数据结构与算法的教材。
第五章 过程和单元

在过程式编程语言中,代码的基本单元是过程。过程是一组计算某个值或执行某些操作(例如打印或读取字符值)的指令。本章将讨论 HLA 如何实现过程。它首先讨论 HLA 的高层语法,用于过程声明和调用,但也描述了过程在机器级别的低层实现。此时,你应该已经对汇编语言编程感到熟悉,因此是时候开始呈现“纯”汇编语言,而不是继续依赖 HLA 的高层语法作为拐杖。
5.1 过程
大多数过程式编程语言通过调用/返回机制实现过程。也就是说,一些代码调用一个过程,过程执行其操作,然后返回给调用者。调用和返回指令提供了 80x86 的过程调用机制。调用代码通过 call 指令调用一个过程,而过程通过 ret 指令返回给调用者。例如,以下 80x86 指令调用 HLA 标准库的 stdout.newln 例程:^([70])
call stdout.newln;
stdout.newln 过程将换行符序列打印到控制台设备,并将控制权返回给紧接在 call stdout.newln; 指令后的指令。
唉,HLA 标准库并没有提供你可能需要的所有例程。大多数时候,你必须编写自己的过程。为此,你将使用 HLA 的过程声明功能。一个基本的 HLA 过程声明如下所示:
procedure *`ProcName`*;
<< Local declarations >>
begin *`ProcName`*;
<< Procedure statements >>
end *`ProcName`*;
过程声明出现在程序的声明部分。也就是说,在你可以放置 static、const、type 或其他声明部分的任何地方,你都可以放置过程声明。在上面的语法示例中,ProcName 代表你希望定义的过程名称。这可以是任何有效且唯一的 HLA 标识符。无论哪个标识符跟在 procedure 保留字后面,它也必须跟在过程中的 begin 和 end 保留字后面。正如你可能注意到的,过程声明看起来和 HLA 程序非常相似。事实上,唯一的区别(到目前为止)是使用了 procedure 保留字,而不是 program 保留字。
下面是一个具体的 HLA 过程声明示例。此过程在进入过程时将零值存储到 EBX 所指向的 256 个双字中:
procedure zeroBytes;
begin zeroBytes;
mov( 0, eax );
mov( 256, ecx );
repeat
mov( eax, [ebx] );
add( 4, ebx );
dec( ecx );
until( @z ); // That is, until ecx=0.
end zeroBytes;
你可以使用 80x86 的call指令来调用这个过程。当程序执行到end zeroBytes;语句时,过程会返回给调用它的人,并开始执行call指令之后的第一条指令。示例 5-1 中的程序提供了调用zeroBytes例程的一个示例。
示例 5-1. 简单过程的示例
program zeroBytesDemo;
#include( "stdlib.hhf" )
procedure zeroBytes;
begin zeroBytes;
mov( 0, eax );
mov( 256, ecx );
repeat
mov( eax, [ebx] ); // Zero out current dword.
add( 4, ebx ); // Point ebx at next dword.
dec( ecx ); // Count off 256 dwords.
until( ecx = 0 ); // Repeat for 256 dwords.
end zeroBytes;
static
dwArray: dword[256];
begin zeroBytesDemo;
lea( ebx, dwArray );
call zeroBytes;
end zeroBytesDemo;
正如你可能已经注意到的,当调用 HLA 标准库过程时,你不必使用call指令来调用 HLA 过程。HLA 标准库过程和你自己写的过程没有什么特别之处。虽然正式的 80x86 调用过程机制是使用call指令,但 HLA 提供了一个高级扩展,允许你通过简单地指定过程名称后跟一对空括号来调用过程。^([71]) 例如,以下任一语句都将调用 HLA 标准库中的stdout.newln过程:
call stdout.newln;
stdout.newln();
同样,以下任一语句都将调用示例 5-1 中的zeroBytes过程:
call zeroBytes;
zeroBytes();
调用机制的选择完全取决于你。然而,大多数人发现高级语法更易于阅读。
^([70]) 通常,你会使用高级语法newln()来调用newln,但是call指令也可以正常工作。
^([71]) 这假设过程没有任何参数。
5.2 保存机器的状态
查看示例 5-2 中的程序。这段代码试图打印 20 行,每行 40 个空格和一个星号。不幸的是,存在一个微妙的 bug,导致了无限循环。主程序使用repeat..until循环调用PrintSpaces 20 次。PrintSpaces使用 ECX 来计数它打印的 40 个空格。PrintSpaces返回时,ECX 的值为 0。然后主程序打印一个星号和一个换行符,递减 ECX,并重复此过程,因为 ECX 不为 0(此时它始终包含$FFFF_FFFF)。
这里的问题是PrintSpaces子例程没有保存 ECX 寄存器。保存寄存器意味着在进入子例程时保存它,并在离开前恢复它。如果PrintSpaces子例程保存了 ECX 寄存器的内容,示例 5-2 中的程序将能够正常运行。
示例 5-2. 程序存在意外的无限循环
program nonWorkingProgram;
#include( "stdlib.hhf" );
procedure PrintSpaces;
begin PrintSpaces;
mov( 40, ecx );
repeat
mov( ' ', al );
stdout.putc( al ); // Print 1 of 40 spaces.
dec( ecx ); // Count off 40 spaces.
until( ecx = 0 );
end PrintSpaces;
begin nonWorkingProgram;
mov( 20, ecx );
repeat
PrintSpaces();
stdout.put( '*', nl );
dec( ecx );
until( ecx = 0 );
end nonWorkingProgram;
你可以使用 80x86 的push和pop指令来保存寄存器值,同时在需要时使用这些寄存器做其他操作。考虑以下PrintSpaces的代码:
procedure PrintSpaces;
begin PrintSpaces;
push( eax );
push( ecx );
mov( 40, ecx );
repeat
mov( ' ', al );
stdout.putc( al ); // Print 1 of 40 spaces.
dec( ecx ); // Count off 40 spaces.
until( ecx = 0 );
pop( ecx );
pop( eax );
end PrintSpaces;
注意,PrintSpaces保存并恢复了 EAX 和 ECX(因为这个过程修改了这些寄存器)。还要注意,这段代码按照反向顺序从堆栈中弹出寄存器。堆栈的先进后出操作要求了这种顺序。
可以由调用者(包含 call 指令的代码)或被调用者(子程序)来负责保存寄存器。在上面的示例中,被调用者负责保存寄存器。示例 5-3 中的示例展示了如果调用者保存寄存器,代码可能是什么样子:
示例 5-3. 调用者寄存器保存示范
program callerPreservation;
#include( "stdlib.hhf" );
procedure PrintSpaces;
begin PrintSpaces;
mov( 40, ecx );
repeat
mov( ' ', al );
stdout.putc( al ); // Print 1 of 40 spaces.
dec( ecx ); // Count off 40 spaces.
until( ecx = 0 );
end PrintSpaces;
begin callerPreservation;
mov( 20, ecx );
repeat
push( eax );
push( ecx );
PrintSpaces();
pop( ecx );
pop( eax );
stdout.put( '*', nl );
dec( ecx );
until( ecx = 0 );
end callerPreservation;
被调用者保存寄存器有两个优点:节省空间和易于维护。如果被调用者(过程)保存了所有受影响的寄存器,那么只有一份 push 和 pop 指令,即过程本身包含的那一份。如果调用者保存寄存器中的值,程序需要在每次调用时都使用一组 push 和 pop 指令。这不仅使程序变得更长,而且也更难维护。记住每次调用过程时需要 push 和 pop 哪些寄存器并非易事。
另一方面,如果子程序保存它所修改的所有寄存器,它可能会不必要地保存某些寄存器。在上面的示例中,代码不需要保存 EAX。尽管PrintSpaces修改了 AL,但这不会影响程序的操作。如果调用者负责保存寄存器,那么它就不需要保存不关心的寄存器(参见示例 5-4 中的程序))。
示例 5-4. 证明调用者保存不需要保存所有寄存器
program callerPreservation2;
#include( "stdlib.hhf" );
procedure PrintSpaces;
begin PrintSpaces;
mov( 40, ecx );
repeat
mov( ' ', al );
stdout.putc( al ); // Print 1 of 40 spaces.
dec( ecx ); // Count off 40 spaces.
until( ecx = 0 );
end PrintSpaces;
begin callerPreservation2;
mov( 10, ecx );
repeat
push( ecx );
PrintSpaces();
pop( ecx );
stdout.put( '*', nl );
dec( ecx );
until( ecx = 0 );
mov( 5, ebx );
while( ebx > 0 ) do
PrintSpaces();
stdout.put( ebx, nl );
dec( ebx );
endwhile;
mov( 110, ecx );
for( mov( 0, eax ); eax < 7; inc( eax )) do
PrintSpaces();
stdout.put( eax, " ", ecx, nl );
dec( ecx );
endfor;
end callerPreservation2;
示例 5-4 中的这个示例提供了三种不同的情况。第一个循环(repeat..until)只保存 ECX 寄存器。修改 AL 寄存器不会影响此循环的操作。在第一个循环之后,这段代码在 while 循环中再次调用 PrintSpaces。然而,这段代码并没有保存 EAX 或 ECX,因为它不关心 PrintSpaces 是否更改了它们。
让调用者保存寄存器的一个大问题是你的程序可能会随着时间的推移而发生变化。你可能会修改调用代码或过程,使用更多的寄存器。当然,这些变化可能会改变你需要保存的寄存器集。更糟糕的是,如果修改发生在子程序本身,你将需要找到每一个对该例程的调用,并验证子程序没有更改调用代码所使用的任何寄存器。
保留寄存器并不是保留环境的全部。你还可以推送和弹出子程序可能改变的变量和其他值。因为 80x86 允许你推送和弹出内存位置,所以你也可以轻松地保留这些值。
5.3 提前从过程返回
HLA 的exit和exitif语句允许你从一个过程返回,而无需落入过程中的相应end语句。这些语句的行为很像循环中的break和breakif语句,不同之处在于它们将控制转移到过程的底部,而不是跳出当前的循环。这些语句在许多情况下非常有用。
这两个语句的语法如下:
exit *`procedurename`*;
exitif( *`boolean_expression`* ) *`procedurename`*;
procedurename操作数是你希望退出的过程的名称。如果你指定了主程序的名称,exit和exitif语句将终止程序执行(即使你当前在过程内部而不是主程序体内)。
exit语句立即将控制转移出指定的过程或程序。条件exitif语句首先测试布尔表达式,并在结果为真时退出。它的语义等同于以下内容:
if( *`boolean_expression`* ) then
exit *`procedurename`*;
endif;
尽管exit和exitif语句在许多情况下非常有用,但在使用它们时应谨慎。如果一个简单的if语句可以让你跳过过程中的其余代码,那就使用if语句。包含大量exit和exitif语句的过程比没有这些语句的过程更难阅读、理解和维护(毕竟,exit和exitif语句实际上不过是goto语句,而你可能已经听说过关于goto的问题)。当你必须在一系列嵌套控制结构中从一个过程返回时,exit和exitif语句非常方便,而且用if..endif包围过程中的剩余代码是不切实际的。
5.4 局部变量
HLA 过程,就像大多数高级语言中的过程和函数一样,允许你声明局部变量。局部变量通常只在过程内部可访问;调用过程的代码无法访问它们。局部变量声明与在主程序中的变量声明相同,唯一的区别是你在过程的声明部分声明变量,而不是在主程序的声明部分声明。实际上,你可以在过程的声明部分声明任何在主程序声明部分合法的东西,包括常量、类型,甚至其他过程。^([72]) 在这一部分,我们将重点讨论局部变量。
局部变量具有两个重要属性,将它们与主程序中的变量(即全局变量)区分开来:词法作用域和生命周期。词法作用域,或简称作用域,决定了标识符在程序中的可用位置。生命周期决定了变量何时具有与之关联的内存并能够存储数据。因为这两个概念区分了局部变量和全局变量,所以花时间讨论它们是明智的。
讨论局部变量的作用域和生命周期时,也许最好的起点是从全局变量的作用域和生命周期开始——即你在主程序中声明的那些变量。到目前为止,你需要遵循的关于变量声明的唯一规则是“你必须声明程序中使用的所有变量。” HLA 声明部分相对于程序语句的位置自动执行了另一个主要规则,即“你必须在第一次使用变量之前声明所有变量。” 随着过程的引入,现在可以违反这个规则,因为(1)过程可以访问全局变量,且(2)过程声明可以出现在声明部分的任何位置,甚至在一些变量声明之前。示例 5-5 中的程序展示了这种源代码组织。
示例 5-5. 全局作用域演示
program demoGlobalScope;
#include( "stdlib.hhf" );
static
AccessibleInProc: char;
procedure aProc;
begin aProc;
mov( 'a', AccessibleInProc );
end aProc;
static
InaccessibleInProc: char;
begin demoGlobalScope;
mov( 'b', InaccessibleInProc );
aProc();
stdout.put
(
"AccessibleInProc = '", AccessibleInProc, "'" nl
"InaccessibleInProc = '", InaccessibleInProc, "'" nl
);
end demoGlobalScope;
本示例演示了只要在过程之前声明全局变量,过程就可以访问主程序中的全局变量。在这个例子中,aProc 过程无法访问 InaccessibleInProc 变量,因为它的声明出现在过程声明之后。然而,aProc 可以引用 AccessibleInProc,因为它的声明出现在 aProc 过程之前。
过程可以以与主程序访问 static、storage 或 readonly 对象相同的方式访问这些变量——通过引用名称。尽管过程可以访问全局 var 对象,但需要不同的语法,并且在你理解额外语法的目的之前,还需要学些额外的内容(有关更多细节,请查阅 HLA 参考手册)。
访问全局对象既方便又简单。不幸的是,正如你在学习高级语言编程时可能已经了解到的那样,访问全局对象会使你的程序变得更难阅读、理解和维护。像大多数入门级编程教材一样,本书不鼓励在过程内使用全局变量。在某些情况下,在过程内访问全局变量可能是解决某个问题的最佳方案。然而,这种(合法的)访问通常只发生在涉及多线程执行或其他复杂系统的高级程序中。由于你目前不太可能编写这样的代码,因此也不太可能绝对需要在你的过程中访问全局变量,所以在这样做之前你应该仔细考虑自己的选择。^([73])
在你的过程内声明局部变量非常简单;你使用与主程序相同的声明部分:static、readonly、storage和var。在这些部分声明的变量的声明部分和访问规则与主程序中的规则和语法相同。示例 5-6 中的示例代码演示了局部变量的声明。
示例 5-6。过程中的局部变量示例
program demoLocalVars;
#include( "stdlib.hhf" );
// Simple procedure that displays 0..9 using
// a local variable as a loop control variable.
procedure CntTo10;
var
i: int32;
begin CntTo10;
for( mov( 0, i ); i < 10; inc( i )) do
stdout.put( "i=" , i, nl );
endfor;
end CntTo10;
begin demoLocalVars;
CntTo10();
end demoLocalVars;
过程中的局部变量仅在该过程内可访问。^([74]) 因此,过程CntTo10中的变量i在主程序中是不可访问的。
对于局部变量,HLA 放宽了标识符在程序中必须唯一的规则。在 HLA 程序中,所有标识符必须在给定的作用域内是唯一的。因此,所有全局名称必须彼此唯一。类似地,给定过程中的所有局部变量必须具有唯一名称,但仅对于该过程中的其他局部符号而言。特别地,局部名称可以与全局名称相同。当这种情况发生时,HLA 会创建两个独立的变量。在过程的作用域内,任何对该公共名称的引用都会访问局部变量;在该过程之外,任何对该公共名称的引用都会引用全局标识符。尽管生成的代码质量可能存在疑问,但完全合法的是,两个或更多不同过程中的局部名称与全局标识符MyVar相同。每个过程都有自己的局部变体,与主程序中的MyVar是独立的。示例 5-7 提供了一个展示这一特性的 HLA 程序示例。
示例 5-7。局部变量不需要全局唯一的名称。
program demoLocalVars2;
#include( "stdlib.hhf" );
static
i: uns32 := 10;
j: uns32 := 20;
// The following procedure declares i and j
// as local variables, so it does not have access
// to the global variables by the same name.
procedure First;
var
i:int32;
j:uns32;
begin First;
mov( 10, j );
for( mov( 0, i ); i < 10; inc( i )) do
stdout.put( "i=", i," j=", j, nl );
dec( j );
endfor;
end First;
// This procedure declares only an i variable.
// It cannot access the value of the global i
// variable but it can access the value of the
// global j object because it does not provide
// a local variant of j.
procedure Second;
var
i:uns32;
begin Second;
mov( 10, j );
for( mov( 0, i ); i < 10; inc( i )) do
stdout.put( "i=", i," j=", j, nl );
dec( j );
endfor;
end Second;
begin demoLocalVars2;
First();
Second();
// Because the calls to First and Second have not
// modified variable i, the following statement
// should print "i=10". However, because the Second
// procedure manipulated global variable j, this
// code will print "j=0" rather than "j=20".
stdout.put( "i=", i, " j=", j, nl );
end demoLocalVars2;
在过程内重用全局名称有利有弊。一方面,可能会导致混淆。如果你将 ProfitsThisYear 用作全局符号,并在一个过程内重用该名称,阅读代码的人可能会认为该过程引用的是全局符号,而非局部符号。另一方面,像 i、j 和 k 这样的简单名称几乎没有实际意义(几乎每个人都预期它们用作循环控制变量或其他局部用途),因此将这些名称重用为局部对象可能是个好主意。从软件工程的角度来看,最好保持所有具有非常特定含义的变量名称(如 ProfitsThisYear)在程序中唯一。而那些具有模糊意义的通用名称(如 index 和 counter 以及像 i、j 或 k 这样的名称)可能可以作为全局变量重用。
关于 HLA 程序中标识符作用域的最后一点需要说明:即使变量在不同的过程(procedure)中有相同的名称,它们也是独立的。例如,在示例 5-7 中,First 和 Second 过程中的局部变量共享相同的名称(i)。然而,First 中的 i 和 Second 中的 i 是完全不同的变量。
区分局部变量和全局变量的第二个主要特性是生命周期。变量的生命周期从程序首次为变量分配存储空间开始,到程序为该变量释放存储空间为止。请注意,生命周期是一个动态特性(在运行时控制),而作用域是一个静态特性(在编译时控制)。特别地,如果程序反复分配然后释放该变量的存储空间,变量实际上可以拥有多个生命周期。
全局变量始终有一个单一的生命周期,从主程序首次开始执行的时刻到主程序终止时为止。同样,所有静态对象也有一个单一的生命周期,贯穿程序的执行(记住,静态对象是你在 static、readonly 或 storage 部分声明的对象)。即使在过程内也是如此。因此,局部静态对象的生命周期和全局静态对象的生命周期没有区别。然而,你在 var 部分声明的变量则是另一回事。HLA 的 var 对象使用 自动存储分配。自动存储分配意味着过程在进入过程时自动为局部变量分配存储空间。类似地,程序在过程返回给调用者时会释放自动对象的存储空间。因此,自动对象的生命周期是从过程中的第一条语句执行开始,到它返回给调用者的时刻。
也许关于自动变量最重要的一点是,你不能指望它们在调用过程之间保持其值。一旦过程返回到调用者,自动变量的存储就会丢失,因此值也会丢失。因此,你必须始终假设局部 var 对象在进入过程时是未初始化的,即使你知道之前已经调用过该过程,并且之前的过程调用已初始化了该变量。上一次调用存储到该变量中的任何值在过程返回给调用者时都会丢失。如果你需要在调用过程之间保持变量的值,你应该使用静态变量声明类型之一。
鉴于自动变量无法在过程调用之间保持其值,你可能会想知道为什么要使用它们。然而,自动变量有一些静态变量所没有的优点。静态变量的最大缺点是,即使引用它们的(唯一)过程未运行,它们仍然会消耗内存。另一方面,自动变量仅在其关联的过程执行时消耗存储空间。返回时,该过程会将其分配的任何自动存储归还给系统,以供其他过程重用。你将在本章稍后看到一些关于自动变量的额外优势。
^([72]) 严格来说,这不完全正确。你不能在过程内声明外部对象。外部对象的相关内容可以参考 5.24 单元和 external 指令。
^([73]) 请注意,反对访问全局变量的这个论点不适用于其他全局符号。访问程序中的全局常量、类型、过程和其他对象是完全合理的。
^([74]) 严格来说,这并不完全正确。不过,访问非局部的 var 对象超出了本书的讨论范围。请参阅 HLA 文档了解更多详细信息。
5.5 其他局部和全局符号类型
如前节所述,HLA 程序允许你在主程序的声明部分声明常量、值、类型以及几乎所有合法的内容。作用域的相同规则适用于这些标识符。因此,你可以在局部声明中重用常量名、过程名、类型名等。
引用全局常量、值和类型不会像引用全局变量那样带来软件工程问题。引用全局变量的问题在于,一个过程可能以一种不明显的方式改变全局变量的值。这会使程序变得更难阅读、理解和维护,因为仅通过查看过程调用,你通常无法判断一个过程是否在修改内存。常量、值、类型和其他非变量对象没有这个问题,因为它们在运行时不能被更改。因此,避免全局对象几乎成为必须遵循的规则,这对非变量对象不适用。
既然说了访问全局常量、类型等是可以的,那么也值得指出的是,如果程序仅在某个过程内引用这些对象,你应该在该过程内局部声明这些对象。这样做会使你的程序稍微容易阅读,因为阅读代码的人就不需要到处寻找符号的定义。
5.6 参数
尽管许多过程完全自包含,但大多数过程都需要一些输入数据,并将一些数据返回给调用者。参数是你传递给过程并从中返回的值。在纯汇编语言中,传递参数可能是一项繁琐的任务。幸运的是,HLA 提供了类似高级语言的语法来声明过程和涉及参数的过程调用。本节介绍了 HLA 的高级参数语法。后续章节将介绍在纯汇编代码中传递参数的低级机制。
讨论参数时,首先要考虑的是 如何 将它们传递给过程。如果你熟悉 Pascal 或 C/C++,你可能见过两种传递参数的方法:按值传递和按引用传递。HLA 当然支持这两种参数传递机制。然而,HLA 还支持按值/结果传递、按结果传递、按名称传递和懒惰求值传递。当然,HLA 是汇编语言,因此你可以使用任何你能想到的方案来传递参数(至少是 CPU 上能够实现的任何方案)。不过,HLA 提供了用于按值、引用、值/结果、结果、名称和懒惰求值传递的特殊高级语法。
因为按值/结果传递、结果、名称和惰性求值是比较高级的内容,本书不会讨论这些参数传递机制。如果你有兴趣了解更多关于这些参数传递方式的信息,请参阅 HLA 参考手册或查看本书的电子版,网址为webster.cs.ucr.edu/ 或 www.artofasm.com/。
你在处理参数时还需要考虑一个问题,那就是将参数传递到哪里。有许多不同的地方可以传递参数;在本节中,我们将把过程参数传递到栈上。你不需要过多关注细节,因为 HLA 会为你抽象掉这些;不过,记住,过程调用和过程参数都涉及栈的使用。因此,任何在过程调用之前推送到栈上的内容,在进入过程时将不再位于栈顶。
5.6.1 按值传递
按值传递的参数就是如此——调用者将一个值传递给过程。按值传递的参数是输入参数。也就是说,你可以将它们传递给过程,但过程不能通过它们返回值。给定 HLA 过程调用
CallProc(I);
如果你按值传递I,那么CallProc不会改变I的值,无论CallProc内部发生了什么。
由于你必须将数据的副本传递给过程,因此你应该仅将小对象(如字节、字和双字)按值传递。按值传递大型数组和记录非常低效(因为你必须创建并传递对象的副本给过程)。
HLA 像 Pascal 和 C/C++一样,按值传递参数,除非你另行指定。以下是一个典型的函数,它有一个按值传递的单一参数。
procedure PrintNSpaces( N:uns32 );
begin PrintNSpaces;
push( ecx );
mov( N, ecx );
repeat
stdout.put( ' ' ); // Print 1 of N spaces.
dec( ecx ); // Count off N spaces.
until( ecx = 0 );
pop( ecx );
end PrintNSpaces;
在PrintNSpaces中,参数N被称为形式参数。在过程体内任何出现N的地方,程序都引用调用者通过N传递的值。
PrintNSpaces的调用顺序可以是以下任意一种:
PrintNSpaces( *`constant`* );
PrintNSpaces( *`reg32`* );
PrintNSpaces( *`uns32_variable`* );
以下是一些具体的调用PrintNSpaces的例子:
PrintNSpaces( 40 );
PrintNSpaces( eax );
PrintNSpaces( SpacesToPrint );
在调用PrintNSpaces时传递的参数被称为实际参数。在上面的例子中,40、eax和SpacesToPrint就是实际参数。
请注意,按值传递的参数行为与在var部分声明的局部变量完全相同,唯一的例外是过程的调用者在传递控制给过程之前初始化这些局部变量。
HLA 使用位置参数符号,就像大多数高级语言一样。因此,如果需要传递多个参数,HLA 会通过参数列表中的位置将实际参数与形式参数关联起来。以下是一个简单的PrintNChars过程,它有两个参数:
procedure PrintNChars( N:uns32; c:char );
begin PrintNChars;
push( ecx );
mov( N, ecx );
repeat
stdout.put( c ); // Print 1 of N characters.
dec( ecx ); // Count off N characters.
until( ecx = 0 );
pop( ecx );
end PrintNChars;
以下是对 PrintNChars 程序的调用,它将打印 20 个星号字符:
PrintNChars( 20, '*' );
请注意,HLA 使用分号来分隔过程声明中的形式参数,使用逗号来分隔过程调用中的实际参数(Pascal 程序员应该对这种表示法很熟悉)。还要注意,每个 HLA 形式参数声明采用以下格式:
*`parameter_identifier`* : *`type_identifier`*
特别注意,参数类型必须是标识符。以下的声明都是非法的,因为数据类型不是单一标识符:
PtrVar: pointer to uns32
ArrayVar: uns32[10]
recordVar: record i:int32; u:uns32; endrecord
DynArray: array.dArray( uns32, 2 )
然而,不要以为你不能将指针、数组、记录或动态数组变量作为参数传递。诀窍是在 type 部分为这些类型中的每一种声明一个数据类型。然后,你可以在参数声明中使用单一标识符作为类型。以下代码片段演示了如何使用上述四种数据类型来做到这一点:
type
uPtr: pointer to uns32;
uArray10: uns32[10];
recType: record i:int32; u:uns32; endrecord
dType: array.dArray( uns32, 2 );
procedure FancyParms
(
PtrVar: uPtr;
ArrayVar: uArray10;
recordVar:recType;
DynArray: dType
);
begin FancyParms;
.
.
.
end FancyParms;
默认情况下,HLA 假定你打算按值传递参数。HLA 还允许你通过在形式参数声明前加上 val 关键字,明确指出某个参数是按值传递的。下面是一个 PrintNSpaces 程序的版本,明确表示 N 是一个按值传递的参数:
procedure PrintNSpaces( val N:uns32 );
begin PrintNSpaces;
push( ecx );
mov( N, ecx );
repeat
stdout.put( ' ' ); // Print 1 of N spaces.
dec( ecx ); // Count off N spaces.
until( ecx = 0 );
pop( ecx );
end PrintNSpaces;
如果在同一程序声明中有多个参数使用不同的传递机制,明确指出某个参数是按值传递的参数是一个好主意。
当你按值传递参数并使用 HLA 高级语言语法调用过程时,HLA 会自动生成代码,将实际参数的值复制一份,并将该数据复制到该参数的本地存储中(即形式参数)。对于小对象,按值传递可能是最有效的传递方式。然而,对于大对象,HLA 必须生成代码,将实际参数的每一个字节都复制到形式参数中。对于大型数组和记录,这可能是一个非常昂贵的操作。^([75]) 除非你有特定的语义要求,必须按值传递大型数组或记录,否则你应该使用按引用传递或其他参数传递机制来处理数组和记录。
当将参数传递给一个过程时,HLA 会检查每个实际参数的类型,并将该类型与相应的形式参数进行比较。如果类型不匹配,HLA 会检查实际参数或形式参数是否为字节、字(word)或双字(double-word)对象,并且另一个参数的长度分别为 1、2 或 4 字节。如果实际参数不满足这些条件,HLA 会报告参数类型不匹配的错误。如果出于某种原因,你需要以与程序要求的类型不同的类型传递参数,可以使用 HLA 类型强制转换操作符来覆盖实际参数的类型。
5.6.2 按引用传递
要通过引用传递参数,你必须传递一个变量的地址,而不是它的值。换句话说,你必须传递一个指向数据的指针。该过程必须取消引用该指针才能访问数据。当你需要修改实际参数或在过程之间传递大型数据结构时,通过引用传递参数非常有用。
要声明一个按引用传递的参数,你必须在形式参数声明前加上var关键字。以下代码片段演示了这一点:
procedure UsePassByReference( var PBRvar: int32 );
begin UsePassByReference;
.
.
.
end UsePassByReference;
调用带有按引用传递参数的过程使用的语法与按值传递相同,只是参数必须是一个内存位置;它不能是常量或寄存器。此外,内存位置的类型必须与形式参数的类型完全匹配。以下是对上述过程的合法调用(假设i32是一个int32变量):
UsePassByReference( i32 );
UsePassByReference( (type int32 [ebx] ) );
以下是所有非法的UsePassbyReference调用(假设charVar是char类型):
UsePassByReference( 40 ); // Constants are illegal.
UsePassByReference( EAX ); // Bare registers are illegal.
UsePassByReference( charVar ); // Actual parameter type must match
// the formal parameter type.
与高级语言 Pascal 和 C++不同,HLA 并没有完全隐藏你传递的是指针而不是值这一事实。在过程调用中,HLA 会自动计算一个变量的地址并将该地址传递给过程。然而,在过程内部,你不能像处理值参数那样处理变量(如同在大多数高级语言中)。相反,你需要将该参数视为一个包含指向指定数据的指针的双字变量。你必须显式地取消引用这个指针以访问参数的值。示例 5-8 中的示例提供了一个简单的演示。
示例 5-8:访问按引用传递的参数
program PassByRefDemo;
#include( "stdlib.hhf" );
var
i: int32;
j: int32;
procedure pbr( var a:int32; var b:int32 );
const
aa: text := "(type int32 [ebx])";
bb: text := "(type int32 [ebx])";
begin pbr;
push( eax );
push( ebx ); // Need to use ebx to dereference a and b.
// a = −1;
mov( a, ebx ); // Get ptr to the "a" variable.
mov( −1, aa ); // Store −1 into the "a" parameter.
// b = −2;
mov( b, ebx ); // Get ptr to the "b" variable.
mov( −2, bb ); // Store −2 into the "b" parameter.
// Print the sum of a+b.
// Note that ebx currently contains a pointer to "b".
mov( bb, eax );
mov( a, ebx ); // Get ptr to "a" variable.
add( aa, eax );
stdout.put( "a+b=", (type int32 eax), nl );
end pbr;
begin PassByRefDemo;
// Give i and j some initial values so
// we can see that pass by reference will
// overwrite these values.
mov( 50, i );
mov( 25, j );
// Call pbr passing i and j by reference
pbr( i, j );
// Display the results returned by pbr.
stdout.put
(
"i= ", i, nl,
"j= ", j, nl
);
end PassByRefDemo;
在某些罕见的情况下,通过引用传递参数可能会产生一些奇怪的结果。考虑示例 5-8 中的pbr过程。如果你修改主程序中的调用为pbr(i,i)而不是pbr(i,j),程序将输出以下非直观的结果:
a+b=−4
i= −2;
j= 25;
这段代码显示a+b=−4而不是预期的a+b=−3,是因为pbr(i,i);调用将相同的实际参数传递给a和b。结果,a和b的引用参数都包含指向相同内存位置的指针——即变量i的位置。在这种情况下,a和b是相互的别名。因此,当代码将−2 存储在b指向的位置时,它会覆盖之前存储在a指向位置的−1。当程序获取a和b指向的值并计算它们的和时,a和b都指向相同的值,即−2。将−2 + −2 相加,得到程序显示的−4 结果。这种非直观的行为在程序中遇到别名时是可能的。将同一个变量作为两个不同的引用参数传递,可能并不常见。但如果一个过程引用了全局变量,并且你通过引用将这个全局变量传递给该过程,你也可以创建一个别名(这是另一个你应该避免在过程中引用全局变量的好例子)。
通过引用传递通常比通过值传递效率低。你必须在每次访问时取消引用所有通过引用传递的参数;这比直接使用值要慢,因为通常至少需要两条指令。然而,在传递大型数据结构时,通过引用传递更快,因为你不需要在调用过程之前复制整个数据结构。当然,你可能需要使用指针来访问这个大型数据结构(例如数组)的元素,因此,当你通过引用传递大型数组时,效率几乎没有损失。
^([75]) 给 C/C++程序员的提示:HLA 不会自动按引用传递数组。如果你将数组类型指定为形式参数,HLA 将在调用相关过程时生成代码,复制数组的每一个字节。
5.7 函数和函数结果
函数是返回某些结果给调用者的过程。在汇编语言中,过程和函数之间的语法差异很小,这就是为什么 HLA 没有为函数提供特定声明的原因。尽管汇编过程和函数在语法上几乎没有区别,但在语义上是有一些不同的。也就是说,尽管你可以在 HLA 中以相同的方式声明它们,但它们的使用方式是不同的。
过程是一系列机器指令,用来完成某项任务。执行过程的最终结果是完成该任务。而函数则执行一系列机器指令,专门用于计算某个值并返回给调用者。当然,函数也可以执行一些活动,过程也能计算一些值,但主要的区别在于,函数的目的是返回某个计算结果;而过程没有这个要求。
一个好例子是stdout.puti32过程。这个过程只需要一个int32类型的参数。这个过程的目的是将该整数值的十进制转换结果打印到标准输出设备上。需要注意的是,stdout.puti32不会返回任何调用程序可以使用的值。
一个函数的好例子是cs.member函数。这个函数需要两个参数:第一个是字符值,第二个是字符集值。如果字符是指定字符集的成员,它会在 EAX 中返回真(1)。如果字符参数不是字符集的成员,则返回假。
从逻辑上讲,cs.member返回一个可用值给调用代码(在 EAX 中),而stdout.puti32则没有返回,这很好地说明了函数和过程之间的主要区别。所以,通常情况下,一个过程通过你明确决定在返回时返回一个值而成为函数。声明和使用一个函数并不需要特别的语法,你依然像写过程一样编写代码。
5.7.1 返回函数结果
80x86 的寄存器是返回函数结果最常见的地方。HLA 标准库中的cs.member例程就是一个很好的函数例子,它将值返回到 CPU 的某个寄存器中。它在 EAX 寄存器中返回真(1)或假(0)。根据约定,程序员通常会在 AL、AX 和 EAX 寄存器中返回 8 位、16 位和 32 位(非实数)结果。这是大多数高级语言返回这些类型结果的地方。
当然,AL/AX/EAX 寄存器并没有什么特别神圣的地方。如果用其他寄存器返回函数结果更方便,也是可以的。然而,如果没有充分的理由不使用 AL/AX/EAX 寄存器,那么你应该遵循这个约定。这样做将帮助他人更好地理解你的代码,因为他们通常会假设你的函数在 AL/AX/EAX 寄存器中返回较小的结果。
如果你需要返回一个大于 32 位的函数结果,显然你必须将其返回到 EAX 以外的地方(因为 EAX 只能容纳 32 位的值)。对于稍大于 32 位的值(例如 64 位,或者甚至可能是 128 位),你可以将结果拆分成多个部分,并将这些部分分别返回到两个或更多的寄存器中。常见的做法是将 64 位的值通过 EDX:EAX 寄存器对返回(例如,HLA 标准库中的stdin.geti64函数会在 EDX:EAX 寄存器对中返回一个 64 位整数)。
如果你需要将一个大对象作为函数结果返回,比如一个包含 1,000 个元素的数组,显然你不可能将函数结果返回到寄存器中。有两种常见的方法来处理大型函数返回结果:要么将返回值作为引用参数传递,要么在堆上分配存储空间(使用mem.alloc)来存储该对象,并将其指针返回到 32 位寄存器中。当然,如果你返回了一个指向堆上分配的存储的指针,调用程序必须在完成后释放这块存储。
5.7.2 HLA 中的指令组合
几个 HLA 标准库函数允许你将它们作为其他指令的操作数来调用。例如,考虑以下代码片段:
if( cs.member( al, {'a'..'z'}) ) then
.
.
.
endif;
正如你的高级语言经验(和 HLA 经验)应该能暗示的那样,这段代码调用了cs.member函数来检查 AL 中的字符是否是小写字母。如果cs.member函数返回 true,那么这段代码将执行if语句的then部分;然而,如果cs.member返回 false,这段代码将跳过if..then体。这里没有什么特别之处,唯一需要注意的是,HLA 不支持在if语句中将函数调用作为布尔表达式(可以回顾一下第一章,查看完整的可用表达式集)。那么,这段程序是如何编译并运行的,并得出直观的结果呢?
下一节将介绍如何让 HLA 知道你希望在布尔表达式中使用函数调用。然而,要理解这一点,你需要先了解 HLA 中的指令组合。
指令组合允许你将一个指令作为另一个指令的操作数。例如,考虑mov指令。它有两个操作数:一个源操作数和一个目标操作数。指令组合允许你将一个有效的 80x86 机器指令替换为任一(或两个)操作数。以下是一个简单的例子:
mov( mov( 0, eax ), ebx );
当然,直接的问题是,“这是什么意思?”要理解发生了什么,你首先需要意识到,大多数指令在编译时都会“返回”一个值给编译器。对于大多数指令,它们“返回”的值是它们的目标操作数。因此,mov( 0, eax ); 在编译过程中返回字符串 eax 给编译器,因为 EAX 是目标操作数。大多数情况下,特别是当一条指令单独出现在一行时,编译器会忽略返回的字符串结果。然而,当你将指令作为某些操作数的替代时,HLA 会使用这个字符串结果;具体来说,HLA 将这个字符串作为操作数代替原来的指令。因此,上面的 mov 指令等价于以下两条指令序列:
mov( 0, eax ); // HLA compiles interior instructions first.
mov( eax, ebx ); // HLA substituted "eax" for "mov( 0, eax )"
在处理组合指令(即作为操作数包含其他指令的指令序列)时,HLA 总是以“从左到右然后深度优先(内向外)”的方式工作。为了理解这一点,请考虑以下指令:
add( sub( mov( i, eax ), mov( j, ebx )), mov( k, ecx ));
要解释这里发生的事情,从源操作数开始。它包含以下内容:
sub( mov( i, eax ), mov( j, ebx ))
该指令的源操作数是 mov( i, eax ),并且这条指令没有任何组合,因此 HLA 发出此指令并返回其目标操作数(eax),作为 sub 指令的源操作数。这实际上给我们带来了以下结果:
sub( eax, mov( j, ebx ))
现在,HLA 编译了作为目标操作数出现的指令(mov( j, ebx )),并返回其目标操作数(ebx),以替代 sub 指令中的 mov。这将产生以下结果:
sub( eax, ebx )
这是一个完整的指令,没有组合,HLA 可以编译它。因此,它编译了这条指令,并将其目标操作数(ebx)作为字符串结果返回,替代原始 add 指令中的 sub。所以,原始的 add 指令现在变成了:
add( ebx, mov( k, ecx ));
HLA 接下来编译了目标操作数中出现的 mov 指令。它将其目标操作数作为字符串返回,HLA 用该字符串替代 mov,最终生成简单的指令:
add( ebx, ecx );
因此,原始 add 指令的编译结果是以下指令序列:
mov( i, eax );
mov( j, ebx );
sub( eax, ebx );
mov( k, ecx );
add( ebx, ecx );
哇!从原始指令来看,很难轻易看出这个序列是结果。正如这个例子所示,过度使用指令组合可能会导致程序几乎无法阅读。你在编写程序时应非常小心使用指令组合。除少数例外,编写组合指令序列会使你的程序更难以理解。
请注意,过度使用指令组合可能会使程序中的错误难以解读。请看以下 HLA 语句:
add( mov( eax, i ), mov( ebx, j ) );
这个指令组合生成了以下 80x86 指令序列:
mov( eax, i );
mov( ebx, j );
add( i, j );
当然,编译器会抱怨你试图将一个内存位置加到另一个内存位置上。然而,指令组合有效地掩盖了这一事实,使得理解错误信息的原因变得困难。这个故事的寓意是:除非真的能使程序更易读,否则避免使用指令组合。本节中的几个示例演示了不应该如何使用指令组合。
在两个主要的领域中,使用指令组合可以帮助使程序更易读。第一个是 HLA 的高级语言控制结构。另一个是在过程参数中。尽管指令组合在这两种情况下(以及可能的其他几种情况)是有用的,但这并不意味着你可以使用像之前示例中的add指令那样复杂的指令。相反,大多数时候,你将使用单个指令或函数调用来替代高级语言布尔表达式中的单个操作数,或在过程/函数参数中使用。
既然我们在讨论这个话题,那么过程调用作为 HLA 在指令组合中替代调用的字符串返回的到底是什么呢?此外,像if..endif这样的语句返回什么?没有目标操作数的指令又返回什么呢?嗯,函数返回结果是下一节的内容,你将在几分钟内阅读到它。至于所有其他语句和指令,你应该查阅 HLA 参考手册。手册列出了每条指令及其返回值。返回值是 HLA 在指令作为另一个指令的操作数时替代指令的字符串。请注意,许多 HLA 语句和指令默认返回空字符串作为返回值(过程调用也是如此)。如果指令返回空字符串作为其组合值,那么如果你试图将其作为另一条指令的操作数使用,HLA 会报告错误。例如,if..then..endif语句返回空字符串作为返回值,因此你不能将if..then..endif嵌套在另一条指令中。
5.7.3 HLA 过程中的@returns选项
HLA 过程声明允许一个特殊选项,指定当过程调用作为另一条指令的操作数时使用的字符串:@returns选项。带有@returns选项的过程声明语法如下:
procedure *`ProcName`* ( *`optional_parameters`* ); @returns( *`string_constant`* );
<< Local declarations >>
begin *`ProcName`*;
<< Procedure statements >>
end *`ProcName`*;
如果没有@returns选项,HLA 会将空字符串赋值给过程的@returns值。这实际上使得将该过程调用作为另一个指令操作数成为非法。
@returns 选项要求一个被括号包围的单字符串表达式。如果这个字符串常量出现在另一个指令的操作数中,HLA 会将其替换为过程调用。通常,这个字符串常量是一个寄存器名称;不过,任何作为指令操作数合法的文本也可以使用。例如,你可以指定内存地址或常量。为了清晰起见,你应该始终在 @returns 参数中指定函数返回值的位置。
举个例子,考虑下面的布尔函数,它会在 EAX 寄存器中返回真或假,前提是单字符参数是字母字符:^([77])
procedure IsAlphabeticChar( c:char ); @returns( "EAX" );
begin IsAlphabeticChar;
// Note that cs.member returns true/false in eax.
cs.member( c, {'a'..'z', 'A'..'Z'} );
end IsAlphabeticChar;
一旦你在这个过程声明的末尾加上 @returns 选项,就可以合法地将对 IsAlphabeticChar 的调用用作其他 HLA 语句和指令的操作数:
mov( IsAlphabeticChar( al ), ebx );
.
.
.
if( IsAlphabeticChar( ch ) ) then
.
.
.
endif;
上面的最后一个例子展示了通过 @returns 选项,你可以在各种 HLA 语句的布尔表达式字段中嵌入对自定义函数的调用。请注意,以上代码等同于:
IsAlphabeticChar( ch );
if( eax ) then
.
.
.
endif;
并不是所有的 HLA 高级语言语句都会在语句前展开组合指令。例如,考虑下面的 while 语句:
while( IsAlphabeticChar( ch ) ) do
.
.
.
endwhile;
这段代码并不会展开成以下内容:
IsAlphabeticChar( ch );
while( eax ) do
.
.
.
endwhile;
相反,IsAlphabeticChar 的调用会在 while 的布尔表达式内展开,这样程序会在每次循环迭代时调用该函数。
输入 @returns 参数时需要小心。HLA 在编译过程声明时不会检查字符串参数的语法(除了验证它是一个字符串常量)。HLA 只会在用 @returns 字符串替换函数调用时检查语法。因此,如果你在前面的示例中为 IsAlphabeticChar 指定了 eaz 而不是 eax 作为 @returns 参数,HLA 直到你实际使用 IsAlphabeticChar 作为操作数时才会报告错误。到那时,HLA 会抱怨非法的操作数,但通过查看 IsAlphabeticChar 的调用是无法明确知道问题所在的。因此,要特别小心不要在 @returns 字符串中引入排版错误;稍后发现这些错误可能会非常困难。
^([76]) 在第六章中,你会看到大多数程序员返回实际的结果。
^([77]) 在你实际使用这个函数到自己的程序中之前,注意 HLA 标准库提供了 char.isAlpha 函数来进行此测试。更多详情请参阅 HLA 文档。
5.8 递归
递归 是指过程调用自身。例如,下面就是一个递归过程:
procedure Recursive;
begin Recursive;
Recursive();
end Recursive;
当然,CPU 永远不会从这个过程返回。进入Recursive时,这个过程会立即再次调用自己,控制永远不会传递到过程的结束部分。在这个特定情况下,失控的递归会导致无限循环。^([78])
类似于循环结构,递归需要一个终止条件来防止无限递归。Recursive可以用终止条件重写,如下所示:
procedure Recursive;
begin Recursive;
dec( eax );
if( @nz ) then
Recursive();
endif;
end Recursive;
对该例程的修改使得Recursive根据 EAX 寄存器中出现的次数自我调用。在每次调用时,Recursive 会将 EAX 寄存器减 1,然后再次调用自己。最终,Recursive 将 EAX 减至 0,并从每次调用中返回,直到它返回到最初的调用者。
迄今为止,递归并没有真正的必要。毕竟,你可以高效地像这样编写这个过程:
procedure Recursive;
begin Recursive;
repeat
dec( eax );
until( @z );
end Recursive;
这两个示例都会根据传入 EAX 寄存器的次数重复执行程序的主体。^([79]) 事实证明,只有少数递归算法无法以迭代方式实现。然而,许多递归实现的算法比它们的迭代版本更高效,而且大多数情况下,算法的递归形式更容易理解。
快速排序算法可能是最著名的通常以递归形式出现的算法。该算法的 HLA 实现出现在示例 5-9 中。
示例 5-9. 递归快速排序程序
program QSDemo;
#include( "stdlib.hhf" );
type
ArrayType: uns32[ 10 ];
static
theArray: ArrayType := [1,10,2,9,3,8,4,7,5,6];
procedure quicksort( var a:ArrayType; Low:int32; High:int32 );
const
i: text := "(type int32 edi)";
j: text := "(type int32 esi)";
Middle: text := "(type uns32 edx)";
ary: text := "[ebx]";
begin quicksort;
push( eax );
push( ebx );
push( ecx );
push( edx );
push( esi );
push( edi );
mov( a, ebx ); // Load BASE address of "a" into ebx.
mov( Low, edi); // i := Low;
mov( High, esi ); // j := High;
// Compute a pivotal element by selecting the
// physical middle element of the array.
mov( i, eax );
add( j, eax );
shr( 1, eax );
mov( ary[eax*4], Middle ); // Put middle value in edx.
// Repeat until the edi and esi indexes cross one
// another (edi works from the start towards the end
// of the array, esi works from the end towards the
// start of the array).
repeat
// Scan from the start of the array forward
// looking for the first element greater or equal
// to the middle element).
while( Middle > ary[i*4] ) do
inc( i );
endwhile;
// Scan from the end of the array backwards looking
// for the first element that is less than or equal
// to the middle element.
while( Middle < ary[j*4] ) do
dec( j );
endwhile;
// If we've stopped before the two pointers have
// passed over one another, then we've got two
// elements that are out of order with respect
// to the middle element, so swap these two elements.
if( i <= j ) then
mov( ary[i*4], eax );
mov( ary[j*4], ecx );
mov( eax, ary[j*4] );
mov( ecx, ary[i*4] );
inc( i );
dec( j );
endif;
until( i > j );
// We have just placed all elements in the array in
// their correct positions with respect to the middle
// element of the array. So all elements at indexes
// greater than the middle element are also numerically
// greater than this element. Likewise, elements at
// indexes less than the middle (pivotal) element are
// now less than that element. Unfortunately, the
// two halves of the array on either side of the pivotal
// element are not yet sorted. Call quicksort recursively
// to sort these two halves if they have more than one
// element in them (if they have zero or one elements, then
// they are already sorted).
if( Low < j ) then
quicksort( a, Low, j );
endif;
if( i < High ) then
quicksort( a, i, High );
endif;
pop( edi );
pop( esi );
pop( edx );
pop( ecx );
pop( ebx );
pop( eax );
end quicksort;
begin QSDemo;
stdout.put( "Data before sorting: " nl );
for( mov( 0, ebx ); ebx < 10; inc( ebx )) do
stdout.put( theArray[ebx*4]:5 );
endfor;
stdout.newln();
quicksort( theArray, 0, 9 );
stdout.put( "Data after sorting: " nl );
for( mov( 0, ebx ); ebx < 10; inc( ebx )) do
stdout.put( theArray[ebx*4]:5 );
endfor;
stdout.newln();
end QSDemo;
注意,这个快速排序过程使用寄存器来存储所有非参数的局部变量。还要注意快速排序如何使用text常量定义为寄存器提供更具可读性的名称。这种技巧常常能使算法更易于阅读;然而,使用这种技巧时,必须小心不要忘记这些寄存器已经被使用。
^([78]) 嗯,实际上并不是无限的。栈会溢出,Windows、Mac OS X、FreeBSD 或 Linux 会在那时抛出异常。
^([79]) 后者版本会大大加快速度,因为它没有call/ret指令的开销。
5.9 前向过程
通常情况下,HLA 要求在程序中第一次使用符号之前声明所有符号。^([80]) 因此,你必须在第一次调用之前定义所有过程。之所以不总是可行,有两个原因:互递归(两个过程互相调用)和源代码组织(你可能希望将过程放在首次调用它的代码之后)。幸运的是,HLA 允许你使用前向过程定义来声明过程原型。前向声明使你能够在实际提供过程代码之前定义该过程。
前向过程声明是一种常见的过程声明,它使用保留字forward代替过程的声明部分和主体。以下是上一节中快速排序过程的前向声明:
procedure quicksort( var a:ArrayType; Low:int32; High:int32 ); forward;
在 HLA 程序中,前向声明是向编译器保证,实际的过程声明会在源代码的后续部分以与前向声明完全相同的形式出现。^([81]) 前向声明必须有相同的参数,它们必须以相同的方式传递,并且它们的类型必须与过程中的正式参数类型一致。
互递归的过程(即过程A调用过程B,而过程B又调用过程A)至少需要一个前向声明,因为你只能在另一个之前声明过程A或B中的一个。然而,实际上,互递归(无论是直接递归还是间接递归)并不常见,因此你很少会为了这个目的使用前向声明。
在没有互递归的情况下,总是可以组织你的源代码,使得每个过程声明出现在其第一次调用之前。然而,什么是可能的,什么是期望的,是两回事。你可能希望将一组相关的过程放在源代码的开头,而将另一组过程放在源代码的末尾。这种逻辑分组按功能而非调用进行,可能会使你的程序更容易阅读和理解。然而,这种组织方式也可能导致代码在声明之前尝试调用一个过程。没关系;只需使用前向过程定义来解决这个问题。
前向定义和实际过程声明之间的一个主要区别与过程选项有关。有些选项,如@returns,可能只出现在前向声明中(如果有forward声明)。其他选项可能只出现在实际的过程声明中(我们还没有涉及其他过程选项,所以暂时不用担心它们)。如果你的过程需要@returns选项,@returns选项必须出现在forward保留字之前。例如:
procedure IsItReady( valueToTest: dword ); @returns( "eax" ); forward;
@returns选项不得在源代码中的实际过程声明中再次出现。
^([80]) 这个规则有一些小例外,但对于过程调用来说,这一规则是确实成立的。
^([81]) 事实上,exactly是一个太强的词。稍后你会看到一些例外情况。
5.10 HLA v2.0 过程声明
HLA v2.0 及更高版本支持一种类似于常量、类型和变量声明的替代过程声明语法。尽管本书倾向于使用原始的过程声明语法(HLA v2.0 及更高版本仍然支持该语法),但你将在现实世界中的代码示例中看到新语法;因此,本节简要讨论了新过程声明语法。
新的 HLA v2.0 过程声明语法使用 proc 关键字开始一个过程声明部分(类似于 var 或 static 开始一个变量声明部分)。在 proc 部分内,过程声明有以下几种形式:
*`procname`*:procedure( *`parameters`* );
begin *`procname`*;
<< body >>
end *`procname`*;
*`procname`*:procedure( *`parameters`* ) {*`options`*};
begin *`procname`*;
<< body >>
end *`procname`*;
*`procname`*:procedure( *`parameters`* ); *`external`*;
*`procname`*:procedure( *`parameters`* ) { *`options`* }; *`external`*;
有关此替代过程声明语法的更多详细信息,请参阅 HLA v2.0(或更高版本)参考手册。只需知道它的存在,以防你在阅读从其他来源获得的示例 HLA 代码时遇到它。
5.11 低级过程和 call 指令
80x86 的 call 指令做两件事。首先,它将紧跟在 call 指令之后的指令地址压入堆栈;然后,它将控制权转移到指定过程的地址。call 指令压入堆栈的值称为返回地址。当过程想要返回调用者并继续执行紧跟在 call 指令之后的第一条语句时,过程只需从堆栈中弹出返回地址,并间接跳转到该地址。大多数过程通过执行 ret(返回)指令来返回调用者。ret 指令将返回地址从堆栈中弹出,并将控制权间接转移到它从堆栈中弹出的地址。
默认情况下,HLA 编译器会自动在你编写的每个 HLA 过程的末尾添加一条 ret 指令(以及其他几条指令)。这就是为什么到目前为止你无需显式使用 ret 指令的原因。要禁用 HLA 过程中的默认代码生成,请在声明过程时指定以下选项:
procedure *`ProcName`*; @noframe; @nodisplay;
begin *`ProcName`*;
.
.
.
end *`ProcName`*;
@noframe 和 @nodisplay 子句是过程选项的例子。HLA 过程支持几个这样的选项,包括 @returns、@noframe、@nodisplay 和 @noalignstack。你将在 5.14 节看到 @noalignstack 和其他几个过程选项的用途。这些过程选项可以按照任意顺序出现在过程名称(及其参数,如果有的话)之后。注意,@noframe 和 @nodisplay(以及 @noalignstack)只能出现在实际的过程声明中。你不能在前向声明中指定这些选项。
@noframe 选项告诉 HLA 编译器,你不希望编译器为过程自动生成入口和退出代码。这告诉 HLA 不要自动生成 ret 指令(以及其他几条指令)。
@nodisplay 选项告诉 HLA 不需要在过程的局部变量区域为 显示 分配存储空间。显示是一种机制,用于访问过程中的非局部 var 对象。因此,只有在程序中嵌套过程时,才需要显示。本书不会讨论显示或嵌套过程;有关显示和嵌套过程的更多细节,请参见电子版的相关章节,网址为 www.artofasm.com/ 或 webster.cs.ucr.edu/,或者查阅 HLA 参考手册。在此之前,你可以安全地在所有过程上指定 @nodisplay 选项。事实上,对于本章到目前为止出现的所有过程,指定 @nodisplay 选项是很有意义的,因为这些过程并不实际使用显示。使用 @nodisplay 选项的过程比未指定此选项的过程要稍微快一点,且稍微短一点。
以下是最小化过程的示例:
procedure minimal; @nodisplay; @noframe; @noalignstack;
begin minimal;
ret();
end minimal;
如果你使用 call 指令调用此过程,minimal 将简单地从栈中弹出返回地址并返回给调用者。你应该注意,在指定 @noframe 过程选项时,ret 指令是绝对必要的。^([82]) 如果在过程内未放置 ret 指令,程序在遇到 end minimal; 语句时将不会返回给调用者。相反,程序会直接跳转到内存中跟随过程的任何代码。示例程序 Example 5-10 演示了这个问题。
示例 5-10. 缺少 ret 指令在过程中的影响
program missingRET;
#include( "stdlib.hhf" );
// This first procedure has the @noframe
// option but does not have a ret instruction.
procedure firstProc; @noframe; @nodisplay;
begin firstProc;
stdout.put( "Inside firstProc" nl );
end firstProc;
// Because the procedure above does not have a
// ret instruction, it will "fall through" to
// the following instruction. Note that there
// is no call to this procedure anywhere in
// this program.
procedure secondProc; @noframe; @nodisplay;
begin secondProc;
stdout.put( "Inside secondProc" nl );
ret();
end secondProc;
begin missingRET;
// Call the procedure that doesn't have
// a ret instruction.
call firstProc;
end missingRET;
尽管在某些罕见情况下,这种行为可能是期望的,但在大多数程序中,它通常代表着一个缺陷。因此,如果指定了 @noframe 选项,务必记得使用 ret 指令显式地从过程返回。
^([82]) 严格来说,这并不完全正确。但在过程体内,某种机制必须从栈中弹出返回地址并跳转到返回地址。
5.12 过程与栈
由于过程使用栈来保存返回地址,因此在过程内推送和弹出数据时必须小心。考虑以下简单(且有缺陷的)过程:
procedure MessedUp; @noframe; @nodisplay;
begin MessedUp;
push( eax );
ret();
end MessedUp;
当程序遇到 ret 指令时,80x86 栈的状态如图 5-1 所示。

图 5-1. MessedUp 过程中的 ret 前栈内容
ret 指令并不知道栈顶的值不是有效地址。它只是弹出栈顶的值,并跳转到该位置。在这个示例中,栈顶包含保存的 EAX 值。因为 EAX 很不可能包含正确的返回地址(实际上,EAX 正确的几率大约是四十亿分之一),这个程序很可能会崩溃或表现出其他未定义的行为。因此,在向栈中推送数据时,必须小心,确保在从过程返回之前妥善地弹出这些数据。
注意
如果在编写过程时没有指定 @noframe 选项,HLA 会在过程开始时自动生成代码,将一些数据推送到栈上。因此,除非你完全理解发生了什么并且已经妥善处理了 HLA 推送到栈上的数据,否则不应在没有 @noframe 选项的过程内部执行裸 ret 指令。这样做会尝试返回到由这些数据指定的位置(而不是返回地址),而不是正确地返回到调用者。在没有 @noframe 选项的过程里,应使用 exit 或 exitif 语句来从过程返回。
在执行 ret 语句之前从栈中弹出多余的数据,也会在程序中引发严重问题。考虑以下有缺陷的过程:
procedure messedUpToo; @noframe; @nodisplay;
begin messedUpToo;
pop( eax );
ret();
end messedUpToo;
当程序执行到该过程中的 ret 指令时,80x86 的栈大致如下所示,参见图 5-2。

图 5-2:messedUpToo 中 ret 执行前的栈内容
再次说明,ret 指令盲目地从栈顶弹出任何数据,并尝试返回到该地址。与前一个示例不同,前一个示例中栈顶很不可能包含有效的返回地址(因为它包含的是 EAX 的值),而在这个示例中,栈顶有小概率确实包含返回地址。然而,这个地址并不是 messedUpToo 过程的正确返回地址;相反,它将是调用 messedUpToo 过程的那个过程的返回地址。为了理解这段代码的影响,请参考示例 5-11 中的程序。
示例 5-11:从栈中弹出过多数据的影响
program extraPop;
#include( "stdlib.hhf" );
// Note that the following procedure pops
// excess data off the stack (in this case,
// it pops messedUpToo's return address).
procedure messedUpToo; @noframe; @nodisplay;
begin messedUpToo;
stdout.put( "Entered messedUpToo" nl );
pop( eax );
ret();
end messedUpToo;
procedure callsMU2; @noframe; @nodisplay;
begin callsMU2;
stdout.put( "calling messedUpToo" nl );
messedUpToo();
// Because messedUpToo pops extra data
// off the stack, the following code
// never executes (because the data popped
// off the stack is the return address that
// points at the following code).
stdout.put( "Returned from messedUpToo" nl );
ret();
end callsMU2;
begin extraPop;
stdout.put( "Calling callsMU2" nl );
callsMU2();
stdout.put( "Returned from callsMU2" nl );
end extraPop;
因为有效的返回地址位于堆栈的顶部,你可能认为这个程序会正常工作(正确)。然而,注意到当从 messedUpToo 过程返回时,这段代码直接返回到主程序,而不是返回到 callsMU2 过程中的正确返回地址。因此,callsMU2 过程中的所有代码在调用 messedUpToo 之后不会执行。在阅读源代码时,可能很难理解为什么那些语句没有执行,因为它们紧跟在调用 messedUpToo 过程之后。除非你非常仔细地查看,否则不会很明显,程序正在从堆栈中弹出一个额外的返回地址,因此没有返回到 callsMU2,而是直接返回到调用 callsMU2 的地方。当然,在这个示例中,问题很容易看出来(因为这个示例正是为了演示这个问题)。然而,在真实的程序中,确定一个过程是否不小心从堆栈中弹出了太多数据可能会更加困难。因此,你应该始终小心在过程中的数据推入和弹出操作。你应当始终验证程序中的推入操作与对应的弹出操作之间是否存在一对一的关系。
5.13 激活记录
每当你调用一个过程时,程序会为该过程调用关联某些信息。返回地址就是程序为特定过程调用维护的一类信息。参数和自动局部变量(即在 var 部分声明的变量)是程序为每个过程调用维护的其他信息的例子。激活记录是我们用来描述程序为特定过程调用关联的这些信息的术语。^([83])
激活记录是这个数据结构的恰当名称。程序在调用(激活)过程时创建一个激活记录,并且该结构中的数据按记录的方式组织。激活记录(与标准记录相比)唯一不同的地方可能就是记录的基地址位于数据结构的中间,因此你必须在记录的正负偏移量处访问记录的字段。
激活记录的构建始于调用过程的代码。调用者将参数数据(如果有的话)推入堆栈。然后,call 指令的执行将返回地址推入堆栈。此时,激活记录的构建将在过程内部继续。过程将寄存器和其他重要的状态信息推入堆栈,然后在激活记录中为局部变量腾出空间。过程还必须更新 EBP 寄存器,使其指向激活记录的基址。
要查看典型的激活记录是什么样的,可以考虑以下 HLA 程序声明:
procedure ARDemo( i:uns32; j:int32; k:dword ); @nodisplay;
var
a:int32;
r:real32;
c:char;
b:boolean;
w:word;
begin ARDemo;
.
.
.
end ARDemo;
每当 HLA 程序调用 ARDemo 过程时,它首先会将参数的数据压入栈中。调用代码会按照参数列表中从左到右的顺序将参数压入栈中。因此,调用代码首先压入 i 参数的值,然后压入 j 参数的值,最后压入 k 参数的数据。在压入参数后,程序调用 ARDemo 过程。进入 ARDemo 过程时,栈中包含这四个项,排列如图 5-3 所示。
ARDemo 中的前几条指令(注意它没有使用 @noframe 选项)将当前的 EBP 值压入栈中,然后将 ESP 的值复制到 EBP 中。接下来,代码将栈指针在内存中向下移动,为局部变量腾出空间。这会产生如图 5-4 所示的栈组织。

图 5-3. 进入 ARDemo 时的栈组织

图 5-4. ARDemo 的激活记录
要访问激活记录中的对象,必须使用从 EBP 寄存器到目标对象的偏移量。你当前最关注的两个项目是参数和局部变量。你可以通过从 EBP 寄存器的正偏移量访问参数;你可以通过从 EBP 寄存器的负偏移量访问局部变量,如图 5-5 所示。
英特尔专门将 EBP(扩展基指针)寄存器保留用于作为激活记录基址的指针。这就是为什么你不应该将 EBP 寄存器用于一般计算的原因。如果你随意改变 EBP 寄存器中的值,你将无法访问当前过程的参数和局部变量。

图 5-5. ARDemo 激活记录中的对象偏移量
^([83]) 栈帧是许多人用来描述激活记录的另一个术语。
5.14 标准入口序列
过程的调用者负责将参数压入堆栈。自然,call 指令会将返回地址压入堆栈。构建其余的激活记录是过程的责任。你可以通过以下“标准入口序列”代码来实现这一点:
push( ebp ); // Save a copy of the old ebp value.
mov( esp, ebp ); // Get pointer to base of activation record into ebp.
sub( *`NumVars`*, esp ); // Allocate storage for local variables.
如果该过程没有任何局部变量,则上述第三条指令 sub( NumVars, esp ); 是不必要的。 NumVars 代表过程所需的局部变量的 字节数。这是一个常量,应该是 4 的倍数(以确保 ESP 寄存器保持在双字对齐边界)。如果过程中的局部变量字节数不是 4 的倍数,则在从 ESP 中减去此常量之前,应该将值四舍五入到下一个更高的 4 的倍数。这样做会稍微增加过程为局部变量分配的存储空间,但不会对过程的操作产生其他影响。
警告
如果 NumVars 常量不是 4 的倍数,从 ESP 中减去该值(假设 ESP 存储的是一个双字对齐的指针)几乎可以确保所有后续的堆栈访问都会发生堆栈对齐错误,因为程序几乎总是会推送和弹出双字值。这将对程序的性能产生非常负面的影响。更糟的是,如果堆栈在进入操作系统时没有双字对齐,许多操作系统 API 调用将会失败。因此,必须始终确保局部变量分配值是 4 的倍数。
由于堆栈对齐的问题,默认情况下,HLA 编译器还会在标准入口序列中发出第四条指令。HLA 编译器实际上会为之前定义的 ARDemo 程序发出如下标准入口序列:
push( ebp );
mov( esp, ebp );
sub( 12, esp ); // Make room for ARDemo's local variables.
and( $FFFF_FFFC, esp ); // Force dword stack alignment.
该序列末尾的 and 指令强制将堆栈对齐到 4 字节边界(如果 ESP 的值不是 4 的倍数,它会将堆栈指针的值减少 1、2 或 3)。尽管 ARDemo 的入口代码正确地从 ESP 中减去了 12 以为局部变量分配空间(12 是 4 的倍数,并且是局部变量的字节数),但这仅在 ESP 在进入过程时已是双字对齐时,才能保持 ESP 的双字对齐。如果调用者修改了堆栈并将 ESP 设置为非 4 的倍数的值,从 ESP 中减去 12 将导致 ESP 保持一个未对齐的值。然而,上述序列中的 and 指令可以确保无论 ESP 进入过程时的值如何,ESP 都会保持双字对齐。如果 ESP 没有双字对齐,这条指令所需的少量字节和 CPU 周期将会带来巨大的收益。
尽管在标准入口序列中执行and指令总是安全的,但它可能并非必要。如果你始终确保 ESP 包含一个双字对齐的值,那么上述标准入口序列中的and指令就没有必要。因此,如果你指定了@noframe过程选项,就不必将该指令包含在入口序列中。
如果你没有指定@noframe选项(也就是说,允许 HLA 为你发出构建标准入口序列的指令),你仍然可以告诉 HLA 在你确定堆栈每次调用过程时都会是双字对齐的情况下,不发出额外的and指令。为此,可以使用@noalignstack过程选项。例如:
procedure NASDemo( i:uns32; j:int32; k:dword ); @noalignstack;
var
LocalVar:int32;
begin NASDemo;
.
.
.
end NASDemo;
HLA 为上述过程发出了以下入口序列:
push( ebp );
mov( esp, ebp );
sub( 4, esp );
5.15 标准退出序列
在过程返回给调用者之前,它需要清理激活记录。尽管可以在过程和过程调用者之间共享清理任务,但 Intel 在指令集中包含了一些功能,使得过程能够高效地自行处理所有清理工作。因此,标准的 HLA 过程和过程调用假设,过程负责在返回调用者时清理激活记录(包括参数)。
如果一个过程没有任何参数,则退出序列非常简单。它只需要三条指令:
mov( ebp, esp ); // Deallocate locals and clean up stack.
pop( ebp ); // Restore pointer to caller's activation record.
ret(); // Return to the caller.
如果过程有一些参数,则需要对标准退出序列进行稍微修改,以便从堆栈中移除参数数据。具有参数的过程使用以下标准退出序列:
mov( ebp, esp ); // Deallocate locals and clean up stack.
pop( ebp ); // Restore pointer to caller's activation record.
ret( *`ParmBytes`* ); // Return to the caller and pop the parameters.
ret指令的ParmBytes操作数是一个常量,指定返回指令弹出返回地址后,从堆栈中移除的参数数据字节数。例如,前面章节中的ARDemo示例代码有三个双字参数。因此,标准退出序列将采用以下形式:
mov( ebp, esp );
pop( ebp );
ret( 12 );
如果你使用 HLA 语法声明了参数(也就是说,参数列表紧跟在过程声明之后),则 HLA 会在过程内自动创建一个本地常量_parms_,其值等于该过程中的参数字节数。因此,代替自己计算参数字节数,你可以使用以下标准退出序列,适用于任何具有参数的过程:
mov( ebp, esp );
pop( ebp );
ret( _parms_ );
请注意,如果你没有为ret指令指定字节常量操作数,80x86 在返回时不会从栈中弹出参数。那些参数仍然会保留在栈上,当你执行call调用后的第一条指令时,它们仍然存在于栈中。同样,如果你指定的值太小,一些参数在从过程返回时仍然会留在栈上。如果你指定的ret操作数过大,ret指令实际上会从栈中弹出一些调用者的数据,通常会导致灾难性的后果。
如果你希望从一个没有@noframe选项的过程提前返回,并且你不特别想使用exit或exitif语句,你必须执行标准的退出序列以返回到调用者。一个简单的ret指令是不够的,因为局部变量和旧的 EBP 值可能仍然保留在栈顶。
5.16 自动(局部)变量的低级实现
你的程序通过从激活记录基地址(EBP)使用负偏移量来访问过程中的局部变量。考虑以下 HLA 过程(虽然它实际上除了演示局部变量的使用外并没有做什么):
procedure LocalVars; @nodisplay;
var
a:int32;
b:int32;
begin LocalVars;
mov( 0, a );
mov( a, eax );
mov( eax, b );
end LocalVars;
LocalVars的激活记录出现在图 5-6 中。

图 5-6. LocalVars过程的激活记录
HLA 编译器为这个过程的主体生成的代码大致等同于以下内容:^([84])
mov( 0, (type dword [ebp-4]));
mov( [ebp-4], eax );
mov( eax, [ebp-8] );
你实际上可以将这些语句自己输入到过程中并使其工作。当然,使用像[ebp-4]和[ebp-8]这样的内存引用,而不是a或b,会使你的程序非常难以阅读和理解。因此,你应该始终声明并使用 HLA 符号名称,而不是 EBP 的偏移量。
这个LocalVars过程的标准入口序列将是:^([85])
push( ebp );
mov( esp, ebp );
sub( 8, esp );
这段代码从栈指针中减去 8,因为这个过程有 8 个字节的局部变量(两个双字对象)。不幸的是,随着局部变量数量的增加,特别是当这些变量具有不同类型时,计算局部变量的字节数变得相当繁琐。幸运的是,对于那些希望自己编写标准入口序列的人,HLA 会自动为你计算这个值,并创建一个常量_vars_,指定局部变量的字节数。^([86]) 因此,如果你打算自己编写标准入口序列,应该在为局部变量分配存储时使用sub指令中的_vars_常量:
push( ebp );
mov( esp, ebp );
sub( _vars_, esp );
现在你已经了解了汇编语言如何为局部变量分配和回收存储空间,理解为什么自动(var)变量在两次调用相同过程之间不保持其值就容易多了。因为与这些自动变量相关的内存位于栈上,当一个过程返回给调用者时,调用者可以将其他数据压入栈中,从而覆盖栈上之前存储的值。此外,调用其他过程(它们有自己的局部变量)也可能会清除栈上的值。而且,在重新进入过程时,过程的局部变量可能会对应于不同的物理内存位置;因此,局部变量的值将不再处于它们正确的位置。
自动存储的一个大优势是,它能高效地在多个过程之间共享固定的内存池。例如,如果你依次调用三个过程,像这样:
ProcA();
ProcB();
ProcC();
第一个过程(上面代码中的 ProcA)在栈上分配它的局部变量。返回时,ProcA 释放该栈存储空间。进入 ProcB 时,程序为 ProcB 的局部变量分配存储空间,使用的是刚被 ProcA 释放的相同内存位置。同样,当 ProcB 返回并且程序调用 ProcC 时,ProcC 使用的也是 ProcB 最近释放的栈空间。这种内存重用高效利用了系统资源,也是使用自动(var)变量的最大优势。
^([84]) 这忽略了与标准入口和退出序列相关的代码。
^([85]) 这段代码假设在入口时 ESP 已经按双字对齐,因此and( $FFFF_FFFC, esp );指令是多余的。
^([86]) HLA 甚至将这个常量向上取整到下一个 4 的倍数,这样你就不必担心栈对齐问题。
5.17 低级参数实现
之前,在讨论 HLA 的高级参数传递机制时,曾有几个关于参数的问题。以下是一些重要的问题:
-
数据从哪里来?
-
你使用什么机制来传递和返回数据?
-
你传递了多少数据?
在这一部分,我们将重新审视两种最常见的参数传递机制:按值传递和按引用传递。我们将讨论三个常见的参数传递位置:寄存器、栈以及代码流。参数数据的大小直接影响数据的传递方式和传递位置。以下几节将详细讨论这些问题。
5.17.1 在寄存器中传递参数
在第 5.6 节中已讲解了如何将参数传递给过程,接下来要讨论的是在哪里传递参数。你传递参数的位置取决于这些参数的大小和数量。如果你要传递少量字节的参数,那么寄存器是传递参数的绝佳选择。如果你要传递单个参数到过程,你应该使用以下寄存器来传递对应的数据类型。
Data Size Pass in this Register
Byte: al
Word: ax
Double Word: eax
Quad Word: edx:eax
这不是一条硬性规定。如果你觉得在 SI 或 BX 寄存器中传递 16 位值更方便,那就这么做。然而,大多数程序员还是会使用上述寄存器来传递参数。
如果你要通过 80x86 的寄存器传递多个参数,应该按照以下顺序使用寄存器:
First Last
eax, edx, ecx, esi, edi, ebx
一般来说,你应该避免使用 EBP 寄存器。如果你需要超过六个双字,也许应该将值传递到其他地方。这种优先级选择并非完全任意。许多高级语言会尝试在 EAX、EDX 和 ECX 寄存器中传递参数(通常是按这个顺序)。此外,Intel ABI(应用二进制接口)允许高级语言过程使用 EAX、EDX 和 ECX,而不需要保留它们的值。因此,这三个寄存器是传递参数的理想位置,因为很多代码假设它们的值会在过程调用之间发生变化。
作为一个例子,考虑以下strfill( s,c )过程,它将字符c(通过值传递,存储在 AL 寄存器中)复制到s(通过引用传递,存储在 EDI 寄存器中)中的每个字符位置,直到遇到零终止字节:
// strfill- Overwrites the data in a string with a character.
//
// EDI- Pointer to zero-terminated string (e.g., an HLA string)
// AL- Character to store into the string
procedure strfill; @nodisplay;
begin strfill;
push( edi ); // Preserve this because it will be modified.
while( (type char [edi] ) <> #0 ) do
mov( al, [edi] );
inc( edi );
endwhile;
pop( edi );
end strfill;
在调用strfill过程之前,你需要将字符串数据的地址加载到 EDI 寄存器中,并将字符值加载到 AL 寄存器中。以下代码片段演示了一个典型的strfill调用。
mov( s, edi ); // Get ptr to string data into edi (assumes s:string).
mov( ' ', al );
strfill();
别忘了,HLA 字符串变量是指针。这个例子假设s是一个 HLA 字符串变量,因此它包含指向一个零终止字符串的指针。因此,mov( s, edi );指令将零终止字符串的地址加载到 EDI 寄存器中(因此这段代码将字符串数据的地址传递给strfill,即按引用传递字符串)。
通过寄存器传递参数的一种方法是在调用之前将寄存器加载上适当的值,然后在过程内引用这些寄存器。这是汇编语言程序中传递寄存器参数的传统机制。HLA(高级汇编语言)比传统汇编语言更高层次,提供了一种正式的参数声明语法,让你告诉 HLA 你正在通过通用寄存器传递某些参数。这种声明语法如下:
*`parmName`*: *`parmType`* in *`reg`*
其中,parmName 是参数的名称,parmType 是对象的类型,而 reg 是 80x86 的通用 8 位、16 位或 32 位寄存器之一。参数类型的大小必须与寄存器的大小相等,否则 HLA 会报告错误。以下是一个具体的示例:
procedure HasRegParms( count: uns32 in ecx; charVal:char in al );
这种语法的一个优点是,你可以像调用任何其他程序一样,使用高级语法调用具有寄存器参数的过程。例如:
HasRegParms( ecx, bl );
如果你为实际参数指定了与正式参数相同的寄存器,HLA 不会生成额外的代码;它会假定该参数的值已经在相应的寄存器中。例如,在上面的调用中,第一个实际参数是 ECX 中的值;由于过程的声明指定第一个参数在 ECX 中,因此 HLA 不会生成任何代码。另一方面,第二个实际参数在 BL 中,但过程将期望该参数值在 AL 中。因此,HLA 会在调用该过程之前发出 mov( bl, al ); 指令,以便该值在进入过程时已经放入正确的寄存器。
你还可以通过寄存器按引用传递参数。考虑以下声明:
procedure HasRefRegParm( var myPtr:uns32 in edi );
调用此过程总是需要某个内存操作数作为实际参数。HLA 会生成代码,将该内存对象的地址加载到参数的寄存器中(此处为 EDI)。请注意,在传递引用参数时,寄存器必须是 32 位通用寄存器,因为地址是 32 位长的。以下是调用 HasRefRegParm 的示例:
HasRefRegParm( x );
HLA 会发出 mov( &x, edi); 或 lea( edi, x); 指令,在 call 指令之前将 x 的地址加载到 EDI 寄存器中。^([87])
如果你将匿名内存对象(例如 [edi] 或 [ecx])作为参数传递给 HasRefRegParm,并且内存引用使用了你为参数声明的相同寄存器(即 [edi]),则 HLA 不会生成任何代码。如果你使用除 EDI 以外的寄存器(例如 [ecx])指定间接寻址模式,它将使用简单的 mov 指令将实际地址复制到 EDI 中。如果你使用像 [edi+ecx*4+2] 这样的更复杂的寻址模式,它将使用 lea 指令计算匿名内存操作数的有效地址。
在程序的代码中,HLA 为那些映射其名称到适当寄存器的寄存器参数创建文本等式。在HasRegParms示例中,每当你引用count参数时,HLA 会用ecx替代count。同样,HLA 会在整个程序体内用al替代charVal。由于这些名称是寄存器的别名,你应该始终记住,不能独立使用 ECX 和 AL。最好在每次使用这些参数时,旁边加上注释,提醒读者count等价于 ECX,charVal等价于 AL。
5.17.2 在代码流中传递参数
另一个可以传递参数的地方是在call指令后立即的代码流中。考虑以下print例程,它将字面字符串常量打印到标准输出设备:
call print;
byte "This parameter is in the code stream.",0;
通常,子程序会将控制权立即返回给紧跟在call指令后的第一条指令。如果这里发生这种情况,80x86 将尝试将“这。。。。”的 ASCII 代码解释为一条指令。这将产生不良后果。幸运的是,你可以在从子程序返回时跳过这个字符串。
那么,如何访问这些参数呢?很简单。栈上的返回地址指向它们。考虑一下在示例 5-12 中出现的print实现。
示例 5-12。打印过程实现(使用代码流参数)
program printDemo;
#include( "stdlib.hhf" );
// print-
//
// This procedure writes the literal string
// immediately following the call to the
// standard output device. The literal string
// must be a sequence of characters ending with
// a zero byte (i.e., a C string, not an HLA
// string).
procedure print; @noframe; @nodisplay;
const
// RtnAdrs is the offset of this procedure's
// return address in the activation record.
RtnAdrs:text := "(type dword [ebp+4])";
begin print;
// Build the activation record (note the
// @noframe option above).
push( ebp );
mov( esp, ebp );
// Preserve the registers this function uses.
push( eax );
push( ebx );
// Copy the return address into the ebx
// register. Because the return address points
// at the start of the string to print, this
// instruction loads ebx with the address of
// the string to print.
mov( RtnAdrs, ebx );
// Until we encounter a zero byte, print the
// characters in the string.
forever
mov( [ebx], al ); // Get the next character.
breakif( !al ); // Quit if it's zero.
stdout.putc( al ); // Print it.
inc( ebx ); // Move on to the next char.
endfor;
// Skip past the zero byte and store the resulting
// address over the top of the return address so
// we'll return to the location that is one byte
// beyond the zero-terminating byte of the string.
inc( ebx );
mov( ebx, RtnAdrs );
// Restore eax and ebx.
pop( ebx );
pop( eax );
// Clean up the activation record and return.
pop( ebp );
ret();
end print;
begin printDemo;
// Simple test of the print procedure
call print;
byte "Hello World!", 13, 10, 0 ;
end printDemo;
除了展示如何在代码流中传递参数外,print例程还展示了另一个概念:可变长度参数。call后的字符串可以是任何实际长度。零终止字节标志着参数列表的结束。有两种简单的方法来处理可变长度参数:要么使用某个特殊的终止值(比如 0),要么传递一个特殊的长度值,告诉子程序你传递了多少个参数。这两种方法各有优缺点。使用特殊值来终止参数列表要求你选择一个在列表中永远不会出现的值。例如,print使用 0 作为终止值,因此它不能打印 NUL 字符(其 ASCII 码为 0)。有时候这并不是限制。指定一个特殊长度参数是另一种可以用来传递可变长度参数列表的机制。虽然这不需要任何特殊的代码,也不会限制可以传递给子程序的值的范围,但设置长度参数并维护由此产生的代码可能会变得非常麻烦。^([88])
尽管通过代码流传递参数带来了方便,但这种方式也有一些缺点。首先,如果你没有提供子程序所需的确切数量的参数,子程序会感到困惑。考虑 print 示例。它打印一个以零字节终止的字符字符串,然后将控制权返回到零字节后的第一条指令。如果你遗漏了零字节,print 例程会高兴地将接下来的操作码字节当作 ASCII 字符打印,直到它找到一个零字节。因为零字节通常出现在指令的中间,print 例程可能会将控制权返回到其他指令的中间部分。这很可能会导致机器崩溃。插入额外的 0 字节,这种情况比你想象的更常见,也是程序员在使用 print 例程时遇到的另一个问题。在这种情况下,print 例程会在遇到第一个零字节时返回,并试图将后续的 ASCII 字符当作机器代码执行。同样,这通常会导致机器崩溃。这些就是为什么 HLA 的 stdout.put 代码 不会 在代码流中传递其参数的一些原因。尽管如此,代码流仍然是传递那些值不变的参数的高效方式。
5.17.3 通过栈传递参数
大多数高级语言使用栈来传递参数,因为这种方法相当高效。默认情况下,HLA 也将参数通过栈传递。虽然通过栈传递参数的效率略低于通过寄存器传递参数,但寄存器的数量非常有限,你只能通过寄存器传递少量的值或引用参数。另一方面,栈允许你传递大量的参数数据而不出现困难。这就是大多数程序将参数通过栈传递的主要原因。
HLA 通常会使用高级过程调用语法将你指定的参数压入栈中。例如,假设你按以下方式定义了前面提到的 strfill:
procedure strfill( s:string; chr:char );
形式为 strfill( s, ' ' ); 的调用将把 s(它是一个地址)和一个空格字符压入 80x86 栈中。当你以这种方式指定调用 strfill 时,HLA 会自动为你压入参数,因此你不需要自己将它们压入栈中。当然,如果你选择手动操作,HLA 会允许你在调用之前手动将参数压入栈中。
要手动将参数压入栈中,请在调用子程序之前立即将它们压入栈中。然后,子程序从栈内存中读取这些数据并适当处理它。考虑以下 HLA 子程序调用:
CallProc(i,j,k);
HLA 将参数按照它们在参数列表中出现的顺序压入栈中。^([89]) 因此,HLA 为这个子程序调用生成的 80x86 代码(假设你是按值传递参数)如下所示:
push( i );
push( j );
push( k );
call CallProc;
进入CallProc时,80x86 的栈看起来像图 5-7 中所示的那样。

图 5-7. 进入CallProc时的栈布局
你可以通过从栈中移除数据来访问传递在栈上的参数,正如以下代码片段所示:
// Note: To extract parameters off the stack by popping, it is very important
// to specify both the @nodisplay and @noframe procedure options.
static
RtnAdrs: dword;
p1Parm: dword;
p2Parm: dword;
p3Parm: dword;
procedure CallProc( p1:dword; p2:dword; p3:dword ); @nodisplay; @noframe;
begin CallProc;
pop( RtnAdrs );
pop( p3Parm );
pop( p2Parm );
pop( p1Parm );
push( RtnAdrs );
.
.
.
ret();
end CallProc;
正如你从这段代码中看到的,它首先将返回地址从栈中弹出并存入RtnAdrs变量;然后它按逆序弹出p1、p2和p3参数的值;最后,它将返回地址推回栈中(以便ret指令能够正确执行)。在CallProc过程内,你可以访问p1Parm、p2Parm和p3Parm变量来使用p1、p2和p3参数值。
然而,有一种更好的方式来访问过程参数。如果你的过程包含标准的入口和退出序列,那么你可以通过索引 EBP 寄存器直接访问激活记录中的参数值。考虑使用以下声明的CallProc激活记录布局:
procedure CallProc( p1:dword; p2:dword; p3:dword ); @nodisplay; @noframe;
begin CallProc;
push( ebp ); // This is the standard entry sequence.
mov( esp, ebp ); // Get base address of A.R. into ebp.
.
.
.
看一下在CallProc中执行mov( esp, ebp );之后的栈布局。如果你已经将三个双字参数压入栈中,它应该看起来像图 5-8 中所示的那样。

图 5-8. 执行标准入口序列后的CallProc激活记录
现在,你可以通过从 EBP 寄存器索引来访问参数:
mov( [ebp+16], eax ); // Accesses the first parameter.
mov( [ebp+12], ebx ); // Accesses the second parameter.
mov( [ebp+8], ecx ); // Accesses the third parameter.
当然,和局部变量一样,你实际上并不会以这种方式访问参数。你可以使用正式的参数名称(p1、p2和p3),HLA 将替换为适当的[ebp+位移量]内存地址。即使你不应该实际使用类似[ebp+12]的地址表达式来访问参数,但理解它们与过程中的参数之间的关系是很重要的。
激活记录中经常出现的其他项目是你的过程保留的寄存器值。在过程中的最合理的位置来保存寄存器是标准入口序列之后的代码中。在标准的 HLA 过程中(即没有指定@noframe选项的过程),这意味着保存寄存器的代码应该出现在过程体的最前面。同样,恢复这些寄存器值的代码应该出现在end语句之前。^([90])
5.17.3.1 栈中访问值参数
访问按值传递的参数与访问本地var对象没有区别。只要你在正式参数列表中声明了该参数,并且过程在进入程序时执行标准入口序列,那么你只需指定参数的名称即可引用该参数的值。示例 5-13 提供了一个示例程序,程序中的过程访问了主程序按值传递给它的参数。
示例 5-13. 值参数的演示
program AccessingValueParameters;
#include( "stdlib.hhf" )
procedure ValueParm( theParameter: uns32 ); @nodisplay;
begin ValueParm;
mov( theParameter, eax );
add( 2, eax );
stdout.put
(
"theParameter + 2 = ",
(type uns32 eax),
nl
);
end ValueParm;
begin AccessingValueParameters;
ValueParm( 10 );
ValueParm( 135 );
end AccessingValueParameters;
尽管你可以通过匿名地址[EBP+8]在代码中访问theParameter的值,但实际上没有任何理由这么做。如果你使用 HLA 高级语言语法声明参数列表,你可以通过在过程内指定参数的名称来访问其值。
5.17.3.2 将值参数传递到栈上
如示例 5-13 所示,将值参数传递给过程非常简单。只需像调用高级语言函数时那样,在实际参数列表中指定值即可。实际上,情况比这稍微复杂一点。如果你传递的是常量、寄存器或变量值,传递值参数很简单。如果需要传递某个表达式的结果,情况会稍微复杂一些。本节将介绍将参数按值传递给过程的不同方法。
当然,你不必使用 HLA 高级语法将值参数传递给过程。你也可以手动将这些值压入栈中。因为在许多情况下,手动传递参数更方便或更高效,所以描述如何做到这一点是一个不错的起点。
如本章前面所述,在将参数传递到栈上时,你需要按照正式参数列表中的顺序(从左到右)将对象压栈。在按值传递参数时,你应将实际参数的值压入栈中。示例 5-14 演示了如何做到这一点。
示例 5-14. 手动将参数传递到栈上
program ManuallyPassingValueParameters;
#include( "stdlib.hhf" )
procedure ThreeValueParms( p1:uns32; p2:uns32; p3:uns32 ); @nodisplay;
begin ThreeValueParms;
mov( p1, eax );
add( p2, eax );
add( p3, eax );
stdout.put
(
"p1 + p2 + p3 = ",
(type uns32 eax),
nl
);
end ThreeValueParms;
static
SecondParmValue:uns32 := 25;
begin ManuallyPassingValueParameters;
pushd( 10 ); // Value associated with p1
pushd( SecondParmValue); // Value associated with p2
pushd( 15 ); // Value associated with p3
call ThreeValueParms;
end ManuallyPassingValueParameters;
注意,如果你像本示例那样手动将参数压入栈中,你必须使用call指令来调用过程。如果你尝试使用ThreeValueParms();这种形式的过程调用,那么 HLA 会因参数列表不匹配而报错。HLA 无法意识到你已经手动将参数压入栈中(就 HLA 而言,这些压栈操作似乎是为了保存其他数据)。
通常,如果实际参数是常量、寄存器值或变量,手动将参数推送到栈上没有太大必要。HLA 的高级语法会为你处理大部分这类参数。然而,有几种情况,HLA 的高级语法无法工作。第一个例子是将算术表达式的结果作为值参数传递。因为在 HLA 中没有运行时算术表达式,你必须手动计算表达式的结果并将该值传递给过程。有两种可能的方式来实现这一点:计算表达式的结果并手动将该结果推送到栈上,或者将表达式的结果计算到寄存器中,并将寄存器作为参数传递给过程。示例 5-15 中的程序演示了这两种机制。
示例 5-15. 将某些算术表达式的结果作为参数传递
program PassingExpressions;
#include( "stdlib.hhf" )
procedure ExprParm( exprValue:uns32 ); @nodisplay;
begin ExprParm;
stdout.put( "exprValue = ", exprValue, nl );
end ExprParm;
static
Operand1: uns32 := 5;
Operand2: uns32 := 20;
begin PassingExpressions;
// ExprParm( Operand1 + Operand2 );
//
// Method one: Compute the sum and manually
// push the sum onto the stack.
mov( Operand1, eax );
add( Operand2, eax );
push( eax );
call ExprParm;
// Method two: Compute the sum in a register and
// pass the register using the HLA high-level
// language syntax.
mov( Operand1, eax );
add( Operand2, eax );
ExprParm( eax );
end PassingExpressions;
到目前为止,本节中的例子做了一个重要假设:你传递的参数是一个双字值。如果你传递的参数不是 4 字节对象,则调用顺序会有所变化。因为 HLA 在传递不是 4 字节长的对象时可能生成相对低效的代码,所以如果你想要生成最快的代码,手动传递这类对象是个好主意。
HLA 要求所有值参数的长度必须是 4 字节的倍数。^([91]) 如果你传递的对象少于 4 字节,HLA 要求你填充参数数据,添加额外的字节,确保你始终传递至少 4 字节长的对象。对于大于 4 字节的参数,必须确保你传递的参数值是 4 字节的倍数,必要时,在对象的高位端添加额外的字节来填充。
考虑以下过程原型:
procedure OneByteParm( b:byte );
该过程的激活记录在图 5-9 中显示。

图 5-9. OneByteParm 激活记录
如你所见,栈上与 b 参数相关联的有 4 字节,但其中只有 4 字节中的 1 个字节包含有效数据(低位字节)。其余的 3 个字节仅为填充,过程应忽略这些字节。特别地,你永远不应假设这些额外的字节包含 0 或其他一致的值。根据你传递的参数类型,HLA 的自动代码生成可能会或可能不会将 0 字节作为额外数据推送到栈上。
当将一个字节参数传递给过程时,HLA 会自动生成代码,将 4 个字节推送到堆栈上。由于 HLA 的参数传递机制保证不会干扰任何寄存器或其他值,因此 HLA 有时会生成比实际需要的更多的代码来传递字节参数。例如,如果你决定将 AL 寄存器作为字节参数传递,HLA 将生成将 EAX 寄存器推送到堆栈上的代码。这个单一的推送指令是一种非常高效的方式来将 AL 作为 4 字节参数对象传递。另一方面,如果你决定将 AH 寄存器作为字节参数传递,推送 EAX 就不奏效了,因为这会将 AH 中的值留在激活记录中偏移量为 EBP+9 的位置,如图 5-9 所示。不幸的是,该过程期望该值位于 EBP+8 的偏移量位置,因此仅仅推送 EAX 是无法完成任务的。如果你传递的是 AH、BH、CH 或 DH 作为字节参数,HLA 将生成类似以下的代码:
sub( 4, esp ); // Make room for the parameter on the stack.
mov( ah, [esp] ); // Store ah into the L.O. byte of the parameter.
如你所见,将 H 寄存器之一作为字节参数传递的效率不如将 L 寄存器之一传递。因此,如果可能的话,传递 8 位寄存器作为参数时,应该尽量使用 L 寄存器。^([92]) 顺便提一下,关于效率问题,即使你手动传递参数,也几乎无法做什么。
如果你决定传递的字节参数是一个变量而不是寄存器,HLA 将生成明显更差的代码。例如,假设你如下调用OneByteParm:
OneByteParm( *`uns8Var`* );
对于这个调用,HLA 将生成类似以下的代码来推送这个单字节参数:
push( eax );
push( eax );
mov( *`uns8Var`*, al );
mov( al, [esp+4] );
pop( eax );
如你所见,要将单个字节传递到堆栈上,这需要大量的代码!HLA 生成这么多代码是因为(1)它保证不会干扰任何寄存器,并且(2)它不知道uns8Var是否是分配内存中的最后一个变量。如果你不必强制执行这两个约束条件,那么你可以生成更好的代码。
如果你有一个空闲的 32 位寄存器(尤其是 EAX、EBX、ECX 或 EDX 中的一个),那么你可以只用两条指令将字节参数推送到堆栈上。将字节值移动(或带符号/零扩展移动)到寄存器中,然后将该寄存器推送到堆栈上。对于当前的OneByteParm调用,如果 EAX 可用,调用序列将如下所示:
mov( *`uns8Var`*, al );
push( eax );
call OneByteParm;
如果只有 ESI 或 EDI 可用,你可以使用如下代码:
movzx( *`uns8Var`*, esi );
push( esi );
call OneByteParm;
你可以使用的另一种技巧是将字节变量强制转换为双字对象,然后仅使用单条push指令传递参数。例如:
push( (type dword *`uns8Var`*));
call OneByteParm;
最后这个例子非常高效。注意,它会将紧跟在uns8Var后面的任意值的前 3 个字节作为填充字节推送。HLA 不会使用这种技术,因为使用这种方案有(非常微小的)可能性会导致程序失败。如果结果是uns8Var对象是内存中某个页面的最后一个字节,而下一个内存页面是不可读的,那么push指令将会引发内存访问异常。为了安全起见,HLA 编译器没有使用这种方案。然而,如果你始终确保以这种方式传递的实际参数不是你在static区域声明的最后一个变量,那么你就可以使用这种技术的代码。因为字节对象几乎不可能出现在堆栈上最后一个可访问地址,所以使用这种技术对var对象来说可能是安全的。
在栈上传递字参数时,你还必须确保包含填充字节,以便每个参数消耗的是 4 字节的倍数。你可以使用我们传递字节时所使用的相同技巧,当然,传递的是两个有效字节数据,而不是一个。例如,你可以使用以下两种方案中的任何一种来传递一个字对象w给OneWordParm过程:
mov( w, ax );
push( eax );
call OneWordParm;
push( (type dword w) );
call OneWordParm;
当通过栈传递大型对象的值时(例如记录和数组),你不需要确保对象的每个元素或字段消耗的是 4 字节的倍数;你只需要确保整个数据结构在栈上消耗的是 4 字节的倍数。例如,如果你有一个包含十个 3 字节元素的数组,则整个数组将需要 2 字节的填充(10 * 3 是 30 字节,不是 4 的倍数,但 10 * 3 + 2 是 32,能被 4 整除)。HLA 在通过值传递大型数据对象到过程时做得相当好。对于更大的对象,除非你有一些特殊要求,否则你应该使用 HLA 的高级语言过程调用语法。当然,如果你想要高效操作,你应该尽量避免通过值传递大型数据结构。
默认情况下,HLA 保证在发出传递参数给过程的代码时,不会干扰任何寄存器的值。有时候,这个保证并不是必须的。例如,如果你在 EAX 中返回一个函数结果,并且你没有在 EAX 中传递参数给过程,那么实际上没有理由在进入过程时保留 EAX 的值。与其生成如下的疯狂代码来传递一个字节参数,
push( eax );
push( eax );
mov( *`uns8Var`*, al );
mov( al, [esp+4] );
pop( eax );
如果 HLA 知道它可以像这样使用 EAX(或其他寄存器),它就能生成更好的代码。
mov( *`uns8Var`*, al );
push( eax );
你可以使用@use过程选项来告诉 HLA,如果这样做可以提高生成的代码质量,它可以修改寄存器的值。当传递参数时,这个选项的语法是:
@use *`reg32`*;
reg32操作数可以是 EAX、EBX、ECX、EDX、ESI 或 EDI。如果该寄存器是 EAX、EBX、ECX 或 EDX 之一,你将获得最佳结果。需要注意的是,这里不能指定 EBP 或 ESP(因为过程已经使用了这些寄存器)。
@use过程选项告诉 HLA,指定为操作数的寄存器的值可以被修改。因此,如果 HLA 通过不保留该寄存器的值能够生成更好的代码,它将这样做。例如,当为之前给出的OneByteParm过程提供@use eax;选项时,HLA 将仅生成上述的两条指令,而不是保留 EAX 的五条指令序列。
在指定@use过程选项时,必须小心。特别是,你不应该在与@use选项中指定的寄存器相同的寄存器中传递任何参数(因为如果这样做,HLA 可能会不小心扰乱参数的值)。同样,你必须确保过程能够修改该寄存器的值。如上所述,当过程将函数结果返回到 EAX 时,EAX 是@use寄存器的最佳选择(因为显然,调用者不会期望过程保留 EAX)。
如果你的过程有forward或external声明(见 5.24 单元和 external 指令),那么@use选项只能出现在forward或external定义中,而不能出现在实际的过程声明中。如果没有这样的过程原型出现,那么你必须将@use选项附加到过程声明中。
这是一个示例:
procedure OneByteParm( b:byte ); @nodisplay; @use EAX;
begin OneByteParm;
<< Do something with b. >>
end OneByteParm;
.
.
.
static
byteVar:byte;
.
.
.
OneByteParm( byteVar );
对OneByteParm的调用生成以下指令:
mov( *`uns8Var`*, al );
push( eax );
call OneByteParm;
5.17.3.3 在堆栈上访问引用参数
因为 HLA 传递的是引用参数的地址,所以在过程内访问引用参数比访问值参数稍微复杂一些,因为你必须对引用参数的指针进行解引用。不幸的是,HLA 的高级语法在过程声明和调用中并没有(也不能)为你抽象掉这一细节。你需要自己手动解引用这些指针。本节将回顾如何操作。
在示例 5-16 中,RefParm过程有一个通过引用传递的参数。通过引用传递的参数始终是指向由参数声明指定类型的对象的指针。因此,theParameter实际上是一个pointer to uns32类型的对象,而不是一个uns32值。为了访问与theParameter关联的值,这段代码必须将该双字地址加载到 32 位寄存器中,并通过间接方式访问数据。示例 5-16 中的mov( theParameter, eax );指令将此指针取出,存入 EAX 寄存器,然后RefParm过程使用[eax]寻址模式访问theParameter的实际值。
示例 5-16. 访问引用参数
program AccessingReferenceParameters;
#include( "stdlib.hhf" )
procedure RefParm( var theParameter: uns32 ); @nodisplay;
begin RefParm;
// Add 2 directly to the parameter passed by
// reference to this procedure.
mov( theParameter, eax );
add( 2, (type uns32 [eax]) );
// Fetch the value of the reference parameter
// and print its value.
mov( [eax], eax );
stdout.put
(
"theParameter now equals ",
(type uns32 eax),
nl
);
end RefParm;
static
p1: uns32 := 10;
p2: uns32 := 15;
begin AccessingReferenceParameters;
RefParm( p1 );
RefParm( p2 );
stdout.put( "On return, p1=", p1, " and p2=", p2, nl );
end AccessingReferenceParameters;
因为这个过程访问了实际参数的数据,所以将 2 加到这些数据会影响从主程序传递到RefParm过程的变量的值。当然,这并不令人惊讶,因为这是按引用传递参数的标准语义。
正如你所看到的,访问(小的)通过引用传递的参数比访问值参数稍微低效一些,因为你需要额外的指令将地址加载到 32 位指针寄存器中(更不用说你还需要为此目的预留一个 32 位寄存器)。如果频繁访问引用参数,这些额外的指令会开始累计,降低程序的效率。此外,很容易忘记取消引用引用参数,并在计算中使用值的地址(尤其是在将双字参数,例如上面示例中的uns32参数,传递给过程时)。因此,除非你确实需要影响实际参数的值,否则应该使用按值传递将小对象传递给过程。
传递大型对象,如数组和记录,是使用引用参数变得高效的地方。当按值传递这些对象时,调用代码必须创建实际参数的副本;如果实际参数是一个大型对象,复制过程可能非常低效。因为计算大型对象的地址和计算小型标量对象的地址一样高效,因此按引用传递大型对象时不会有效率损失。在过程内,你仍然必须取消引用指针以访问对象,但与复制大型对象的成本相比,由间接访问造成的效率损失是微不足道的。示例 5-17 中的程序演示了如何使用按引用传递初始化记录数组。
示例 5-17. 按引用传递记录数组
program accessingRefArrayParameters;
#include( "stdlib.hhf" )
const
NumElements := 64;
type
Pt: record
x:uns8;
y:uns8;
endrecord;
Pts: Pt[NumElements];
procedure RefArrayParm( var ptArray: Pts ); @nodisplay;
begin RefArrayParm;
push( eax );
push( ecx );
push( edx );
mov( ptArray, edx ); // Get address of parameter into edx.
for( mov( 0, ecx ); ecx < NumElements; inc( ecx )) do
// For each element of the array, set the x field
// to (ecx div 8) and set the y field to (ecx mod 8).
mov( cl, al );
shr( 3, al ); // ecx div 8.
mov( al, (type Pt [edx+ecx*2]).x );
mov( cl, al );
and( %111, al ); // ecx mod 8.
mov( al, (type Pt [edx+ecx*2]).y );
endfor;
pop( edx );
pop( ecx );
pop( eax );
end RefArrayParm;
static
MyPts: Pts;
begin accessingRefArrayParameters;
// Initialize the elements of the array.
RefArrayParm( MyPts );
// Display the elements of the array.
for( mov( 0, ebx ); ebx < NumElements; inc( ebx )) do
stdout.put
(
"RefArrayParm[",
(type uns32 ebx):2,
"].x=",
MyPts.x[ ebx*2 ],
" RefArrayParm[",
(type uns32 ebx):2,
"].y=",
MyPts.y[ ebx*2 ],
nl
);
endfor;
end accessingRefArrayParameters;
从这个例子中可以看出,通过引用传递大对象是相对高效的。除了在整个 RefArrayParm 过程中占用 EDX 寄存器,并且通过一条指令将 EDX 加载为引用参数的地址外,RefArrayParm 过程所需的指令并不会比通过值传递相同参数时的指令多。
5.17.3.4 通过栈传递引用参数
HLA 的高层次语法通常使得传递引用参数变得非常简单。你所需要做的只是指定你希望传递的实际参数的名称,HLA 会自动生成代码来计算指定实际参数的地址,并将该地址推入栈中。然而,像 HLA 为值参数生成的代码一样,HLA 为通过栈传递实际参数地址所生成的代码可能不是最有效的。因此,如果你想编写更快的代码,可能需要手动编写代码来将引用参数传递给过程。本节将讨论如何做到这一点。
每当你将静态对象作为引用参数传递时,HLA 会生成非常高效的代码来将该参数的地址传递给过程。举个例子,考虑以下代码片段:
procedure HasRefParm( var d:dword );
.
.
.
static
FourBytes:dword;
var
v: dword[2];
.
.
.
HasRefParm( FourBytes );
.
.
.
对于对 HasRefParm 过程的调用,HLA 会生成以下指令序列:
pushd( &FourBytes );
call HasRefParm;
如果你通过栈传递引用参数,你实际上不太可能做得比这更好。因此,如果你通过栈传递静态对象作为引用参数,HLA 会生成相当不错的代码,你应该坚持使用高层次的语法来调用过程。
不幸的是,当将自动 (var) 对象或索引变量作为引用参数传递时,HLA 需要在运行时计算对象的地址。这可能需要使用 lea 指令。不幸的是,lea 指令需要一个 32 位寄存器,而 HLA 承诺在为你自动生成代码时不会干扰任何寄存器中的值。^([93])因此,HLA 需要保留它在通过 lea 计算地址时所使用的寄存器中的值,以便通过引用传递参数。下面的示例展示了 HLA 实际生成的代码:
// Call to the HasRefParm procedure:
HasRefParm( v[ebx*4] );
// HLA actually emits the following code for the above call:
push( eax );
push( eax );
lea( eax, v[ebx*4] );
mov( eax, [esp+4] );
pop( eax );
call HasRefParm;
如你所见,这段代码相当长,尤其是当你有一个 32 位寄存器并且不需要保留该寄存器的值时。考虑到 EAX 的可用性,下面是一个更好的代码序列。
lea( eax, v[ebx*4] );
push( eax );
call HasRefParm;
记住,当通过引用传递实际参数时,你必须计算该对象的地址,并将地址压入栈中。对于简单的静态对象,你可以使用取地址符号(&)轻松计算对象的地址并将其压入栈中;然而,对于索引对象和自动对象,你可能需要使用lea指令来计算对象的地址。以下是一些示例,展示了如何使用前面示例中的HasRefParm过程来实现这一点:
static
i: int32;
Ary: int32[16];
iptr: pointer to int32 := &i;
var
v: int32;
AV: int32[10];
vptr: pointer to int32;
.
.
.
lea( eax, v );
mov( eax, vptr );
.
.
.
// HasRefParm( i );
push( &i ); // Simple static object, so just use &.
call HasRefParm;
// HasRefParm( Ary[ebx] ); // Pass element of Ary by reference.
lea( eax, Ary[ ebx*4 ]); // Must use lea for indexed addresses.
push( eax );
call HasRefParm;
// HasRefParm( *iptr ); -- Pass object pointed at by iptr
push( iptr ); // Pass address (iptr's value) on stack.
call HasRefParm;
// HasRefParm( v );
lea( eax, v ); // Must use lea to compute the address
push( eax ); // of automatic vars passed on stack.
call HasRefParm;
// HasRefParm( AV[ esi ] ); -- Pass element of AV by reference.
lea( eax, AV[ esi*4] ); // Must use lea to compute address of the
push( eax ); // desired element.
call HasRefParm;
// HasRefParm( *vptr ); -- Pass address held by vptr...
push( vptr ); // Just pass vptr's value as the specified
call HasRefParm; // address.
如果你有一个额外的寄存器可供使用,你可以告诉 HLA 在计算引用参数的地址时使用该寄存器(而不生成保留该寄存器值的代码)。@use选项将告诉 HLA 可以使用指定的寄存器而无需保留其值。如在值参数部分所述,该过程选项的语法为:
@use *`reg32`*;
其中,reg32可以是任何一个 EAX、EBX、ECX、EDX、ESI 或 EDI。因为引用参数总是传递 32 位值,所以在 HLA 看来,这些寄存器都是等效的(与值参数不同,值参数可能更偏好 EAX、EBX、ECX 或 EDX 寄存器)。如果过程没有在 EAX 寄存器中传递参数且过程返回值在 EAX 寄存器中,最好的选择是 EAX;否则,任何当前未使用的寄存器也可以正常工作。
使用@use eax选项时,HLA 会生成前面示例中更简短的代码。它不会生成所有额外的指令来保留 EAX 的值。这使得代码更加高效,尤其是在通过引用传递多个参数或多次调用带有引用参数的过程时。
5.17.3.5 通过值或引用传递形式参数作为实际参数
前面两节的示例展示了如何通过值或引用将静态和自动变量作为参数传递给过程。还有一种情况这些示例没有正确处理:即在一个过程中的形式参数作为实际参数传递给另一个过程的情况。下面这个简单示例展示了通过值传递和通过引用传递参数时可能发生的不同情况:
procedure p1( val v:dword; var r:dword );
begin p1;
.
.
.
end p1;
procedure p2( val v2:dword; var r2:dword );
begin p2;
p1( v2, r2 ); // (1) First call to p1
p1( r2, v2 ); // (2) Second call to p1
end p2;
在上面的标记为(1)的语句中,过程p2调用过程p1并将它的两个形式参数作为参数传递给p1。请注意,这段代码将两个过程的第一个参数以值传递,而将两个过程的第二个参数以引用传递。因此,在(1)语句中,程序将v2参数以值传递给p2,并再以值传递给p1;同样,程序将r2以引用传递,并以引用传递其值给p1。
因为p2的调用者以值传递v2,且p2又将此参数以值传递给p1,所以代码所需做的仅是复制v2的值并将其传递给p1。实现这一点的代码仅需一条push指令。例如:
push( v2 );
<< Code to handle r2 >>
call p1;
正如你所看到的,这段代码与按值传递自动变量是相同的。实际上,传递值参数给另一个过程所需编写的代码与传递本地自动变量给另一个过程所需的代码是相同的。
在上面的语句 (1) 中传递 r2 需要更多的思考。你不能像传递值参数或自动变量时那样,使用 lea 指令获取 r2 的地址。当将 r2 传递给 p1 时,编写此代码的作者可能期望 r 形式参数包含传递给 p2 的变量地址。用通俗的语言来说,这意味着 p2 必须将 r2 实际参数的地址传递给 p1。因为 r2 参数是一个双字值,包含对应实际参数的地址,这意味着代码必须将 r2 的双字值传递给 p1。上面语句 (1) 的完整代码如下:
push( v2 ); // Pass the value passed in through v2 to p1.
push( r2 ); // Pass the address passed in through r2 to p1.
call p1;
在这个示例中需要注意的一个重要点是,将正式引用参数(r2)作为实际引用参数(r)传递,并不涉及获取正式参数(r2)的地址。p2 的调用者已经完成了这一步;p2 只是将该地址传递给 p1。
在上面的示例中第二次调用 p1(2),代码交换了实际参数,使得对 p1 的调用按值传递 r2,按引用传递 v2。具体来说,p1 期望 p2 传递给它与 r2 关联的双字对象的值;同样,它期望 p2 传递给它与 v2 关联的值的地址。
为了传递与 r2 关联的对象的值,你的代码必须解引用与 r2 关联的指针,并直接传递值。以下是 HLA 自动生成的代码,用于在语句 (2) 中将 r2 作为第一个参数传递给 p1:
sub( 4, esp ); // Make room on stack for parameter.
push( eax ); // Preserve eax's value.
mov( r2, eax ); // Get address-of object passed in to p2.
mov( [eax], eax ); // Dereference to get the value of this object.
mov( eax, [esp+4]); // Put value-of parameter into its location on stack.
pop( eax ); // Restore original eax value.
如常,HLA 生成的代码比实际所需的稍多,因为它不会破坏 EAX 寄存器中的值(你可以使用 @use 程序选项告诉 HLA 可以使用 EAX 的值,从而减少生成的代码)。如果有可用的寄存器,你可以在此序列中编写更高效的代码。如果 EAX 未被使用,你可以将其精简为以下形式:
mov( r2, eax ); // Get the pointer to the actual object.
pushd( [eax] ); // Push the value of the object onto the stack.
因为你可以像对待本地(自动)变量一样处理值参数,所以你使用相同的代码将 v2 按引用传递给 p1,就像将 p2 中的本地变量传递给 p1 一样。具体来说,你使用 lea 指令来计算 v2 中值的地址。HLA 自动生成的用于语句 (2) 的代码会保留所有寄存器,并且呈现以下形式(与按引用传递自动变量相同):
push( eax ); // Make room for the parameter.
push( eax ); // Preserve eax's value.
lea( eax, v2 ); // Compute address of v2's value.
mov( eax, [esp+4]); // Store away address as parameter value.
pop( eax ); // Restore eax's value.
当然,如果你有一个可用的寄存器,你可以改进这段代码。以下是与上面语句 (2) 对应的完整代码:
mov( r2, eax ); // Get the pointer to the actual object.
pushd( [eax] ); // Push the value of the object onto the stack.
lea( eax, v2 ); // Push the address of v2 onto the stack.
push( eax );
call p1;
5.17.3.6 HLA 混合参数传递功能
像控制结构一样,HLA 提供了一种方便且易于阅读的高级语言语法来进行过程调用。然而,这种高级语言语法有时效率较低,可能无法提供你所需要的功能(例如,你不能像在高级语言中那样指定一个算术表达式作为值参数)。HLA 通过允许你编写低级(“纯”)汇编语言代码来帮助你克服这些限制。不幸的是,低级代码比使用高级语法的过程调用更难以阅读和维护。此外,HLA 可能为某些参数生成完美的代码,而只有一个或两个参数会出现问题。幸运的是,HLA 为过程调用提供了一种混合语法,允许你根据实际参数的需要在合适的地方使用高级和低级语法。这使你可以在合适的地方使用高级语法,然后切换到纯汇编语言来传递那些 HLA 的高级语法无法高效处理的特殊参数(如果能处理的话)。
在实际参数列表中(使用高级语言语法),如果 HLA 遇到#{后跟一系列语句和一个关闭的}#,HLA 会用大括号中的指令替代它通常会为该参数生成的代码。例如,考虑以下代码片段:
procedure HybridCall( i:uns32; j:uns32 );
begin HybridCall;
.
.
.
end HybridCall;
.
.
.
// Equivalent to HybridCall( 5, i+j );
HybridCall
(
5,
#{
mov( i, eax );
add( j, eax );
push( eax );
}#
);
上面对HybridCall的调用等同于以下“纯”汇编语言代码。
pushd( 5 );
mov( i, eax );
add( j, eax );
push( eax );
call HybridCall;
作为第二个示例,考虑上一节中的示例:
procedure p2( val v2:dword; var r2:dword );
begin p2;
p1( v2, r2 ); // (1) First call to p1
p1( r2, v2 ); // (2) Second call to p1
end p2;
HLA 为这个示例中对p1的第二次调用生成了极其平庸的代码。如果在这个过程调用的上下文中效率很重要,并且你有一个空闲的寄存器可用,那么你可能希望将代码重写如下:^([94])
procedure p2( val v2:dword; var r2:dword );
begin p2;
p1( v2, r2 ); // (1) First call to p1
p1 // (2) Second call to p1
( // This code assumes eax is free.
#{
mov( r2, eax );
pushd( [eax] );
}#,
#{
lea( eax, v2 );
push( eax );
}#
);
end p2;
请注意,指定@use reg;选项告诉 HLA,寄存器在调用过程时始终可用。如果有一种情况要求过程调用必须保留指定的寄存器,那么你不能使用@use选项来生成更优的代码。然而,你可以根据具体情况使用混合参数传递机制,以提高那些特定调用的性能。
5.17.3.7 混合寄存器和基于栈的参数
你可以在同一个高级过程声明中混合使用寄存器参数和标准(基于栈的)参数。例如:
procedure HasBothRegAndStack( var dest:dword in edi; count:un32 );
在构建激活记录时,HLA 会忽略你通过寄存器传递的参数,只处理你通过栈传递的参数。因此,调用HasBothRegAndStack过程时,只有一个参数会被压入栈中(count)。dest参数会通过 EDI 寄存器传递。当该过程返回调用者时,它只会从栈中移除 4 字节的参数数据。
注意,当你在寄存器中传递参数时,应避免在@use过程选项中指定相同的寄存器。在上面的示例中,HLA 可能根本不会为dest参数生成任何代码(因为该值已经在 EDI 中)。如果你指定了@use edi;,并且 HLA 决定可以修改 EDI 的值,这将销毁 EDI 中的参数值;在这个特定示例中(因为 HLA 从不使用寄存器传递像count这样的双字值参数),这实际上不会发生,但请牢记这个问题。
^([87]) 指令的选择取决于x是否是静态变量(静态对象使用mov,其他对象使用lea)。
^([88]) 如果参数列表频繁变化,尤其如此。
^([89]) 当然,这假设你没有指示 HLA 做其他操作。你可以告诉 HLA 反转栈上参数的顺序。有关更多详情,请参阅电子版。
^([90]) 注意,如果你使用exit语句退出过程,必须复制代码以弹出寄存器值,并将此代码放置在exit语句之前。这是维护上的噩梦,也是为什么程序中应该只有一个退出点的一个很好的理由。
^([91]) 这仅适用于使用 HLA 高级语言语法声明和访问过程中的参数。当然,如果你手动将参数压栈并使用类似[ebp+8]的寻址模式在过程内访问参数,则可以传递任意大小的对象。当然,请记住,大多数操作系统期望栈是按双字对齐的,因此你压入的参数应该是 4 字节的倍数。
^([92]) 更好的是,如果你自己编写过程,直接通过寄存器传递参数。
^([93]) 这并不完全正确。你将在第十二章看到例外。此外,使用@use过程选项告诉 HLA 可以修改寄存器中的值。
^([94]) 当然,你也可以使用@use eax;过程选项在这个示例中实现相同的效果。
5.18 过程指针
80x86 的call指令支持三种基本形式:直接调用(通过过程名)、通过 32 位通用寄存器的间接调用以及通过双字指针变量的间接调用。call指令支持以下(低级)语法:
call Procname; // Direct call to procedure Procname (or Stmt label).
call( Reg32 ); // Indirect call to procedure whose address appears
// in the Reg32 general-purpose 32-bit register.
call( dwordVar ); // Indirect call to the procedure whose address
// appears in the dwordVar double word variable.
我们在本章中一直使用的第一种形式,因此这里无需多谈。第二种形式,即寄存器间接调用,调用由指定的 32 位寄存器存储的过程地址。过程的地址是该过程内要执行的第一条指令的字节地址。请记住,在冯·诺依曼架构的机器(如 80x86)上,系统将机器指令与其他数据一起存储在内存中。CPU 在执行指令之前会从内存中获取指令操作码。当你执行寄存器间接 call 指令时,80x86 首先将返回地址压入栈中,然后从寄存器值指定的地址开始提取下一个操作码字节(指令)。
上述调用指令的第三种形式从内存中的双字变量中提取某个过程第一条指令的地址。虽然这条指令表明调用使用的是仅位移寻址模式,但你应该意识到,这里任何合法的内存寻址模式都是合法的;例如,call( procPtrTable[ebx*4] ); 完全合法;这条语句从双字数组(procPtrTable)中获取双字,并调用该双字中包含的地址对应的过程。
HLA 将过程名称视为静态对象。因此,你可以通过使用取地址符号(&)操作符结合过程名称,或使用 lea 指令来计算过程的地址。例如,&Procname 是 Procname 过程的第一条指令的地址。因此,以下三种代码序列都会调用 Procname 过程:
call Procname;
.
.
.
mov( &Procname, eax );
call( eax );
.
.
.
lea( eax, Procname );
call( eax );
由于过程的地址适合存储在一个 32 位对象中,你可以将该地址存储到双字变量中;实际上,你可以通过类似以下的代码使用过程的地址来初始化一个双字变量:
procedure p;
begin p;
end p;
.
.
.
static
ptrToP: dword := &p;
.
.
.
call( ptrToP ); // Calls the p procedure if ptrToP has not changed.
由于过程指针在汇编语言程序中使用频繁,HLA 提供了声明过程指针变量以及通过这些指针变量间接调用过程的特殊语法。要在 HLA 程序中声明过程指针,你可以使用以下形式的变量声明:
static
*`procPtr`*: procedure;
请注意,这种语法使用 procedure 作为数据类型。它位于变量名称之后,并跟一个冒号,位于变量声明部分中的其中一节(static、readonly、storage 或 var)。这为 procPtr 变量预留了恰好 4 字节的存储空间。要调用由 procPtr 存储的过程地址,你可以使用以下两种形式之一:
call( *`procPtr`* ); // Low-level syntax
*`procPtr`*(); // High-level language syntax
请注意,间接过程调用的高级语法与直接过程调用的高级语法相同。HLA 可以根据标识符的类型判断是使用直接调用还是间接调用。如果你指定了变量名,HLA 会假设需要使用间接调用;如果你指定了过程名,HLA 则会使用直接调用。
像所有指针对象一样,除非你已经使用适当的地址初始化该变量,否则不应尝试通过指针变量间接调用过程。有两种方式可以初始化过程指针变量:static和readonly对象允许初始化器,或者你可以计算一个例程的地址(作为一个 32 位值),并在运行时将该 32 位地址直接存储到过程指针中。以下代码片段演示了两种初始化过程指针的方法:
static
ProcPointer: procedure := &p; // Initialize ProcPointer with
// the address of p.
.
.
.
ProcPointer(); // First invocation calls p.
mov( &q, ProcPointer ); // Reload ProcPointer with the address of q.
.
.
.
ProcPointer(); // This invocation calls the q procedure.
过程指针变量声明也允许声明参数。要声明一个带参数的过程指针,你必须使用如下声明:
static
p:procedure( i:int32; c:char );
这个声明表明 p 是一个 32 位指针,包含一个需要两个参数的过程的地址。如果需要,你还可以通过使用静态初始化器来初始化变量 p 为某个过程的地址。例如:
static
p:procedure( i:int32; c:char ) := &*`SomeProcedure`*;
请注意,SomeProcedure 必须是一个其参数列表与 p 的参数列表完全匹配的过程(即两个值参数,第一个是int32类型,第二个是char类型)。要间接调用这个过程,你可以使用以下任一序列:
push( *`Value_for_i`* );
push( *`Value_for_c`* );
call( p );
或
p( *`Value_for_i`*, *`Value_for_c`* );
高级语言语法具有与直接过程调用的高级语法相同的特性和限制。唯一的区别是调用序列结束时,HLA 发出的实际call指令。
尽管本节中的所有示例都使用了static变量声明,但不要认为你只能在static或其他变量声明部分声明简单的过程指针。你还可以在type部分声明过程指针类型,或者将过程指针声明为record或union的字段。假设你在type部分为过程指针创建了一个类型名称,你甚至可以创建过程指针的数组。以下代码片段展示了其中的一些可能性:
type
pptr: procedure;
prec: record
p:pptr;
<< Other fields >>
endrecord;
static
p1:pptr;
p2:pptr[2]
p3:prec;
.
.
.
p1();
p2[ebx*4]();
p3.p();
使用过程指针时,需要牢记一件非常重要的事,那就是 HLA 并不会(也不能)对你赋给过程指针变量的指针值进行严格的类型检查。特别是,如果指针变量的声明和你赋给指针变量的过程的地址之间的参数列表不匹配,那么当你尝试通过指针使用高级语法间接调用不匹配的过程时,程序可能会崩溃。就像低级“纯”过程调用一样,你有责任确保在调用之前,堆栈上有正确数量和类型的参数。
5.19 过程参数
过程指针在参数列表中的一个非常有价值的用途是选择多个过程中的一个进行调用,方法是传递某个过程的地址。因此,HLA 允许你将过程指针声明为参数。
过程参数声明没有什么特别的。它看起来与过程变量声明完全相同,只不过它出现在参数列表中,而不是变量声明部分。以下是一些典型的过程原型,展示了如何声明这样的参数:
procedure p1( procparm: procedure ); forward;
procedure p2( procparm: procedure( i:int32 ) ); forward;
procedure p3( val procparm: procedure ); forward;
上面的最后一个例子与第一个是相同的。不过,它指出了你通常通过值传递过程参数。这可能看起来有些反直觉,因为过程指针是地址,你需要传递一个地址作为实际参数;然而,通过引用传递过程参数是完全不同的意思。考虑以下(合法的!)声明:
procedure p4( var procPtr:procedure ); forward;
这个声明告诉 HLA,你正在通过引用将一个变量传递给p4。HLA 期望的地址必须是一个过程指针变量的地址,而不是一个过程的地址。
当通过值传递过程指针时,你可以指定一个过程变量(其值会被 HLA 传递给实际的过程)或者一个过程指针常量。过程指针常量由取地址运算符(&)紧接着一个过程名称组成。传递过程常量可能是传递过程参数的最方便方法。例如,以下对Plot例程的调用可能会绘制从−2 到+2 的函数。
Plot( &sineFunc );
Plot( &cosFunc );
Plot( &tanFunc );
请注意,你不能仅仅通过指定过程的名称来将过程作为参数传递。也就是说,Plot( sineFunc )是行不通的。仅仅指定过程名称不起作用,因为 HLA 会尝试直接调用你指定的过程(记住,参数列表中的过程名称会调用指令组合)。如果你没有在参数/过程的名称后指定参数列表——或者至少是一个空的圆括号——HLA 会生成一个语法错误信息。故事的寓意是:不要忘记在过程参数常量名称前加上取地址运算符(&)。
5.20 无类型引用参数
有时,您可能希望编写一个过程,通过引用传递一个通用内存对象,而不考虑该内存对象的类型。一个经典的例子是一个将某些数据结构清零的过程。这样的过程可能有以下原型:
procedure ZeroMem( var mem:byte; count:uns32 );
该过程将从第一个参数指定的地址开始,清零count字节。这个过程原型的问题在于,如果您尝试将除字节对象以外的任何内容作为第一个参数传递,HLA 会报错。当然,您可以使用类型强制来克服这个问题,如下所示,但如果您多次调用此过程并且涉及许多不同的数据类型,那么以下的强制转换操作就显得非常繁琐:
ZeroMem( (type byte MyDataObject), @size( MyDataObject ));
当然,您始终可以使用混合参数传递或手动将参数推送到堆栈中,但这些解决方案比使用类型强制操作更繁琐。幸运的是,HLA 提供了一个方便的解决方案:无类型引用参数。
无类型引用参数就是那种—按引用传递的参数,HLA 不会去比较实际参数的类型和形式参数的类型。使用无类型引用参数时,上述ZeroMem调用的形式如下:
ZeroMem( MyDataObject, @size( MyDataObject ));
MyDataObject可以是任何类型,并且多次调用ZeroMem时,可以传递不同类型的对象,而 HLA 不会提出异议。
要声明一个无类型引用参数,您使用常规语法来指定参数,唯一不同的是使用保留字var代替参数的类型。这个var关键字告诉 HLA,任何变量对象都可以作为该参数。请注意,您必须通过引用传递无类型引用参数,因此var关键字也必须出现在参数声明之前。以下是使用无类型引用参数的ZeroMem过程的正确声明:
procedure ZeroMem( var mem:var; count:uns32 );
使用这个声明,HLA 会计算您作为实际参数传递给ZeroMem的任何内存对象的地址,并将其传递到堆栈中。
5.21 管理大型程序
大多数汇编语言源文件并不是独立的程序。通常,您会调用各种标准库或其他例程,这些例程并没有在您的主程序中定义。例如,您可能已经注意到,80x86 并没有提供像read、write或put这样的机器指令来进行 I/O 操作。当然,您可以编写自己的过程来完成这些操作。不幸的是,编写这些例程是一个复杂的任务,而初学汇编语言的程序员还没有准备好处理这样的任务。这就是 HLA 标准库的作用所在。它是一组您可以调用的过程,用于执行像stdout.put这样的简单 I/O 操作。
HLA 标准库包含了成千上万行源代码。试想一下,如果你不得不将这成千上万行的代码合并到你的简单程序中,编程会有多困难!再想象一下,如果每次写程序都必须编译这些成千上万行的代码,编译速度会有多慢。幸运的是,你不需要这么做。
对于小型程序,使用单个源文件是完全可以的。对于大型程序,这样做会变得非常繁琐(考虑上面提到的例子:你必须将整个 HLA 标准库包含到每个程序中)。此外,一旦你调试并测试了代码的一个大部分,当你对程序的其他部分进行小修改时,继续重新编译那部分已经调试通过的代码就是浪费时间。以 HLA 标准库为例,即使是在一台快速的机器上,编译也需要几分钟时间。试想一下,如果你在一台快速的 PC 上进行编译,而你只改动了代码的一行,居然需要等待 20 或 30 分钟!
对于高级语言来说,解决方案是 分离编译。首先,你将大型源文件拆分为可管理的块。然后,将这些独立的文件编译成目标代码模块。最后,你将目标模块链接在一起,形成一个完整的程序。如果你需要对其中一个模块做小修改,只需要重新编译那个模块;无需重新编译整个程序。
HLA 标准库正是这样工作的。标准库已经编译好并准备使用。你只需调用标准库中的例程,并使用 链接器 程序将你的代码与标准库链接起来。这在开发使用标准库代码的程序时节省了大量时间。当然,你也可以轻松地创建自己的目标模块,并将它们与你的代码链接在一起。你甚至可以向标准库中添加新的例程,以便在你编写未来程序时使用这些例程。
“大规模编程”是软件工程师用来描述处理大型软件项目开发的过程、方法论和工具的术语。虽然每个人对于什么是“大规模”有不同的看法,但分离编译是支持“大规模编程”的常见技术之一。以下部分将描述 HLA 提供的分离编译工具,以及如何有效地在你的程序中使用这些工具。
5.22 #include 指令
#include 指令在源文件中遇到时,会将程序的输入从当前文件切换到 #include 指令参数列表中指定的文件。这使得你可以构建包含常量、类型、源代码以及其他 HLA 项目的文本文件,并将这些文件包含到多个独立程序的编译过程中。#include 指令的语法是:
#include( "*`Filename`*" )
文件名 必须是一个有效的文件名。HLA 会在 #include 指令处将指定的文件合并到编译中。请注意,你可以在所包含的文件中嵌套 #include 语句。也就是说,一个文件在汇编时被包含到另一个文件中时,可能还会包含第三个文件。事实上,您在大多数示例程序中看到的 stdlib.hhf 头文件,实际上不过是一堆 #include 语句(参见 示例 5-18 查看原始的 stdlib.hhf 源代码;请注意,这个文件今天已经有了很大的不同,但概念依然相同)。
示例 5-18. 原始的 stdlib.hhf 头文件
#include( "hla.hhf" )
#include( "x86.hhf" )
#include( "misctypes.hhf" )
#include( "hll.hhf" )
#include( "excepts.hhf" )
#include( "memory.hhf" )
#include( "args.hhf" )
#include( "conv.hhf" )
#include( "strings.hhf" )
#include( "cset.hhf" )
#include( "patterns.hhf" )
#include( "tables.hhf" )
#include( "arrays.hhf" )
#include( "chars.hhf" )
#include( "math.hhf" )
#include( "rand.hhf" )
#include( "stdio.hhf" )
#include( "stdin.hhf" )
#include( "stdout.hhf" )
通过在源代码中包含 stdlib.hhf,你自动包含了所有 HLA 库模块。在编译时间和生成的代码大小方面,通常只包含程序中实际需要的 #include 语句会更高效。然而,包含 stdlib.hhf 是非常方便的,而且在本文中占用的空间更少,这也是为什么本文中的大多数程序都使用 stdlib.hhf 的原因。
请注意,#include 指令后不需要分号。如果在 #include 后加上分号,该分号将成为源文件的一部分,并且会在编译时作为第一个字符出现在包含的源代码之后。HLA 通常允许在程序的各个部分有多余的分号,因此你有时会看到以分号结尾的 #include 语句。然而,总的来说,你不应养成在 #include 语句后加分号的习惯,因为在某些情况下,这可能会导致语法错误。
单独使用 #include 指令并不会提供独立编译。你可以使用 #include 指令将一个大型源文件分割成多个模块,然后在编译时将这些模块连接在一起。以下示例将在程序编译时包含 printf.hla 和 putc.hla 文件:
#include( "printf.hla" )
#include( "putc.hla" )
现在,你的程序 将 受益于这种方法带来的模块化。然而,可惜的是,你并不会节省任何开发时间。#include 指令在编译时将源文件插入到 #include 位置,完全就像你自己输入了那些代码一样。HLA 仍然需要编译这些代码,这会花费时间。如果你以这种方式包含所有标准库例程的文件,编译时间将会永远延长。
通常,你不应该像上面那样使用 #include 指令来包含源代码。^([95]) 相反,你应该使用 #include 指令将常见的常量、类型、外部过程声明以及其他类似的项目插入到程序中。通常,汇编语言包含文件不包含任何机器代码(宏除外;有关详细信息,请参见第九章)。以这种方式使用 #include 文件的目的,在你了解外部声明如何工作后会更加清晰。
^([95]) 这没什么问题,唯一的问题是它没有利用分离编译的优势。
5.23 忽略重复的 #include 操作
当你开始开发复杂的模块和库时,你最终会发现一个大问题:某些头文件需要包含其他头文件(例如,stdlib.hhf 头文件包含了所有其他标准库头文件)。嗯,这实际上不是个大问题,但当一个头文件包含另一个头文件,而那个第二个头文件又包含第三个,第三个头文件又包含第四个,直到最后一个头文件又包含第一个头文件时,问题就来了。现在,这可就是个大问题了。
一个头文件间接包含自身有两个问题。首先,这会在编译器中创建一个无限循环。编译器会毫不犹豫地不断重复包含这些文件,直到内存耗尽或发生其他错误。显然,这不是好事。第二个问题(通常发生在第一个问题之前)是,当 HLA 第二次包含头文件时,它开始抱怨重复的符号定义。毕竟,第一次读取头文件时,它会处理该文件中的所有声明;第二次读取时,它将这些符号视为重复符号。
HLA 提供了一种特殊的包含指令,消除了这个问题:#includeonce。你可以像使用 #include 指令一样使用这个指令。例如:
#includeonce( "myHeaderFile.hhf" )
如果 myHeaderFile.hhf 直接或间接地包含自身(使用 #includeonce 指令),那么 HLA 会忽略新的包含请求。然而,值得注意的是,如果你使用的是 #include 指令,而不是 #includeonce,HLA 将第二次包含该文件。这是为了防止你真的需要两次包含一个头文件的情况。
底线是:你应该始终使用 #includeonce 指令来包含你自己创建的头文件。事实上,你应该养成习惯,总是使用 #includeonce,即使是对于别人创建的头文件(HLA 标准库已经有防止递归包含的机制,所以你不必担心在标准库头文件中使用 #includeonce)。
还有一种技术可以防止递归包含——使用条件编译。第九章,关于宏和 HLA 编译时语言的章节,讨论了这种选项。
5.24 单元与外部指令
从技术上讲,#include指令为你提供了创建模块化程序所需的所有设施。你可以创建几个模块,每个模块包含一些特定的例程,并根据需要使用#include将这些模块包含到你的汇编语言程序中。然而,HLA 提供了一种更好的方式:外部和公共符号。
#include机制的一个主要问题是,一旦调试了一个例程,将其包含到编译中仍然浪费时间,因为每次组装主程序时,HLA 都必须重新编译无错误的代码。一个更好的解决方案是,预先汇编调试过的模块,并将目标代码模块链接在一起。这就是external指令允许你做的事情。
要使用external设施,必须至少创建两个源文件。一个文件包含第二个文件使用的一组变量和过程。第二个文件使用这些变量和过程,但不知道它们是如何实现的。唯一的问题是,如果创建了两个独立的 HLA 程序,链接器在尝试将它们组合时会混淆。因为这两个 HLA 程序都有各自的主程序。当操作系统将程序加载到内存中时,应该运行哪个主程序?为了解决这个问题,HLA 使用一种不同类型的编译模块——unit,来编译没有主程序的程序。HLA unit的语法实际上比 HLA 程序的语法更简单,格式如下:
unit *`unitname`*;
<< declarations >>
end *`unitname`*;
除了var部分外,任何可以放入 HLA program声明部分的内容,都可以放入 HLA unit的声明部分。请注意,unit没有begin子句,并且单元中没有程序语句;^([96]) 单元只包含声明。
除了单元不包含主程序部分外,单元和程序之间还有一个区别。单元不能有var部分。这是因为var部分声明的是自动变量,这些变量是主程序源代码的局部变量。由于单元没有与之关联的“主程序”,因此var部分是非法的。^([97])
为了演示,考虑示例 5-19 和示例 5-20 中的两个模块。
示例 5-19。简单 HLA 单元的示例
unit Number1;
static
Var1: uns32;
Var2: uns32;
procedure Add1and2;
begin Add1and2;
push( eax );
mov( Var2, eax );
add( eax, Var1 );
end Add1and2;
end Number1;
示例 5-20。引用外部对象的主程序
program main;
#include( "stdlib.hhf" );
begin main;
mov( 2, Var2 );
mov( 3, Var1 );
Add1and2();
stdout.put( "Var1=", Var1, nl );
end main;
主程序引用了 Var1、Var2 和 Add1and2,然而这些符号对于该程序来说是外部的(它们出现在 Number1 单元中)。如果你尝试按原样编译主程序,HLA 会提示这三个符号未定义。
因此,你必须使用 external 选项将它们声明为外部声明。外部过程声明与前向声明类似,唯一的区别是你使用保留字 external 而不是 forward。要声明外部静态变量,只需在这些变量的声明后加上保留字 external。示例 5-21 中的程序是对 示例 5-20 中程序的修改,包含了外部声明。
示例 5-21. 修改后的主程序与外部声明
program main;
#include( "stdlib.hhf" );
procedure Add1and2; external;
static
Var1: uns32; external;
Var2: uns32; external;
begin main;
mov( 2, Var2 );
mov( 3, Var1 );
Add1and2();
stdout.put( "Var1=", Var1, nl );
end main;
如果你尝试使用典型的 HLA 编译命令 HLA main2.hla 编译这个第二版的 main 程序,你可能会感到有些失望。这个程序实际上会编译通过,不会报错。然而,当 HLA 尝试链接这段代码时,它会报告符号 Var1、Var2 和 Add1and2 未定义。这是因为你没有将相关单元与主程序一起编译和链接。在你尝试后并发现它仍然无法正常工作之前,你需要知道,默认情况下,单元中的所有符号对于该单元来说都是 私有 的。这意味着这些符号在该单元之外的代码中无法访问,除非你显式声明这些符号为 公共 符号。要将符号声明为公共符号,只需在单元中的实际符号声明之前为这些符号添加外部声明。如果外部声明出现在与符号实际声明同一源文件中,HLA 会认为该符号在外部需要使用,并将其视为公共(而非私有)符号。示例 5-22 中的单元是对 Number1 单元的修正,正确声明了外部对象。
示例 5-22. 正确的 Number1 单元与外部声明
unit Number1;
static
Var1: uns32; external;
Var2: uns32; external;
procedure Add1and2; external;
static
Var1: uns32;
Var2: uns32;
procedure Add1and2;
begin Add1and2;
push( eax );
mov( Var2, eax );
add( eax, Var1 );
end Add1and2;
end Number1;
可能会觉得在 示例 5-21 和 示例 5-22 中对这些符号进行两次声明有些冗余,但你很快会发现,实际上你不会以这种方式编写代码。
如果你尝试使用典型的 HLA 语句编译 main 程序或 Number1 单元,也就是,
HLA main2.hla
HLA unit2.hla
你会很快发现,链接器仍然返回错误。它在编译main2.hla时返回错误,因为你还没有告诉 HLA 将与unit2.hla关联的目标代码链接进来。同样,如果你试图单独编译unit2.hla,链接器会报错,因为它找不到主程序。解决这个问题的简单方法是将这两个模块一起编译,使用以下单个命令:
HLA main2.hla unit2.hla
此命令将正确地编译两个模块并将它们的目标代码链接在一起。
不幸的是,上述命令破坏了独立编译的一个主要优势。当你执行该命令时,它会先编译main2和unit2,然后再将它们链接在一起。记住,独立编译的一个主要原因是减少大型项目的编译时间。虽然上述命令很方便,但它并没有达到这个目标。
要独立编译这两个模块,必须分别在它们上运行 HLA。当然,你在之前已经看到,尝试独立编译这些模块会产生链接器错误。为了解决这个问题,你需要先编译模块,但不进行链接。-c(仅编译)HLA 命令行选项可以实现这一点。要在不运行链接器的情况下编译这两个源文件,你可以使用以下命令:
HLA -c main2.hla
HLA -c unit2.hla
这将生成两个目标代码文件,main2.obj和unit2.obj,你可以将它们链接在一起生成一个单一的可执行文件。你可以直接运行链接器程序,但更简单的方法是使用 HLA 编译器将目标模块链接在一起:
HLA main2.obj unit2.obj
在 Windows 下,此命令会生成一个名为main2.exe的可执行文件;在 Linux、Mac OS X 和 FreeBSD 下,此命令会生成一个名为main2的文件。你也可以输入以下命令,编译主程序并将其与之前编译的unit2目标模块链接:
HLA main2.hla unit2.obj
通常,HLA 会查看 HLA 命令后面的文件名后缀。如果文件名没有后缀,HLA 默认认为它是.HLA文件。如果文件名有后缀,HLA 将按以下方式处理该文件:
-
如果文件后缀是.HLA,HLA 将使用 HLA 编译器编译该文件。
-
如果文件后缀是.ASM,HLA 将使用 MASM(或在 Windows 下的其他默认汇编器,如 FASM、NASM 或 TASM)或 Gas(Linux/Mac OS X/FreeBSD)来汇编该文件。
-
如果文件后缀是.OBJ或.LIB(Windows),或.o或.a(Linux/Mac OS X/FreeBSD),HLA 将链接该模块与其余编译部分。
5.24.1 外部指令的行为
每当你使用external指令声明符号时,请记住几个关于external对象的限制:
-
一个源文件中只能出现一个
external对象声明。也就是说,你不能将相同的符号两次声明为external对象。 -
只有
procedure、static、readonly和storage变量对象可以是外部的。var、type、const和参数对象不能是外部的。 -
external对象必须出现在全局声明级别。你不能在过程或其他嵌套结构中声明external对象。^([99]) -
external对象会将其名称发布为全局。因此,你必须小心选择external对象的名称,确保它们与其他符号不冲突。
最后一点尤其需要注意。HLA 通过链接器将你的模块链接在一起。在这个过程中,每一步都可能因为你选择的外部名称而给你带来问题。
考虑以下 HLA 外部/公共声明:
static
extObj: uns32; external;
extObj: uns32;
localObject: uns32;
当你编译包含这些声明的程序时,HLA 会自动为localObject变量生成一个“混合”的名称,这个名称通常不会与系统全局外部符号发生冲突。^([100])然而,每当你声明外部符号时,HLA 会默认使用对象的名称作为外部名称。如果你不小心使用了某个全局名称作为变量名,这可能会引发问题。
为了解决外部名称冲突的问题,HLA 支持一种额外的语法,使你能够明确指定外部名称。以下示例演示了这种扩展语法:
static
c: char; external( "var_c" );
c: char;
如果你在external关键字后面跟着一个由括号括起来的字符串常量,HLA 将在你的 HLA 源代码中继续使用声明的名称(例如示例中的c)作为标识符。在外部(即汇编代码中),HLA 每次引用c时会用名称var_c来替代。这一特性帮助你避免在 HLA 程序中误用汇编语言的保留字或其他全局符号的问题。
你还应该注意到,external选项的这一特性允许你创建别名。例如,你可能希望在一个模块中将某个对象称为StudentCount,而在另一个模块中将该对象称为PersonCount(你可能这么做是因为你有一个处理人数统计的通用库模块,而你想在一个只处理学生的程序中使用这个对象)。使用如下声明可以让你做到这一点:
static
StudentCount: uns32; external( "PersonCount" );
当然,你已经看到了在开始创建别名时可能遇到的一些问题。所以,你应该在程序中谨慎使用这个功能。或许更合理的使用方式是简化某些操作系统 API。例如,Win32 API 为某些过程调用使用了一些非常长的名称。你可以使用external指令提供一个比操作系统指定的标准名称更具意义的名称。
5.24.2 HLA 中的头文件
HLA 使用相同的 external 声明来定义公共符号和外部符号的技巧,可能看起来有些违反直觉。为什么不为公共符号使用 public 保留字,为外部定义使用 external 关键字呢?虽然 HLA 的外部声明看起来有些违反直觉,但它们是建立在数十年与 C/C++ 编程语言的经验基础上的,C/C++ 语言也使用类似的方法来处理公共和外部符号。^([101]) 结合 头文件,HLA 的外部声明使得大程序的维护变得轻松。
external 指令(与分别使用 public 和 external 指令相比)的一个重要优点是,它可以让你在源文件中最大限度地减少重复工作。例如,假设你想创建一个模块,其中包含一些支持例程和变量,供多个不同的程序使用(例如,HLA 标准库)。除了共享一些例程和变量之外,假设你还希望共享常量、类型和其他项。
#include 文件机制提供了一个完美的解决方案。你只需创建一个 #include 文件,包含常量、宏和 external 定义,然后在实现例程的模块以及使用这些例程的模块中包含该文件(见 图 5-10)。

图 5-10. 在 HLA 程序中使用头文件
一个典型的头文件只包含 const、val、type、static、readonly、storage 和过程原型(以及我们尚未看到的一些其他内容,如宏)。static、readonly 和 storage 部分中的对象,以及所有过程声明,始终是 external 对象。特别是,你不应该在头文件中放置任何 var 对象,也不应该在头文件中放置任何非外部变量或过程体。如果你这样做,HLA 会在包含该头文件的不同源文件中创建这些对象的重复副本。这不仅会使你的程序变得更大,还会在某些情况下导致程序失败。例如,通常你会把一个变量放在头文件中,以便在多个模块之间共享该变量的值。然而,如果你没有在头文件中将该符号声明为外部符号,而只是放置了一个标准的变量声明,那么每个包含该源文件的模块都会得到自己独立的变量——模块之间将无法共享同一个变量。
如果你创建一个标准头文件,包含const、val和type声明以及外部对象,你应该始终确保在所有需要这些定义的模块的声明部分中包含该文件。通常,HLA 程序会在program或unit头文件后的前几行中包含所有的头文件。
本文采用 HLA 标准库的约定,使用.hhf作为 HLA 头文件的后缀(hhf 代表 HLA 头文件)。
^([96]) 当然,单元可以包含过程,这些过程可能有语句,但该单元本身并不包含任何可执行指令。
^([97]) 单元中的过程可以有自己的var部分,但过程的声明部分与单元的声明部分是分开的。
^([98]) 如果你想显式指定输出文件的名称,HLA 提供了一个命令行选项来实现这一点。你可以通过输入命令HLA -?来获取所有合法命令行选项的菜单。
^([99]) 有一些例外情况,但你不能在全局级别以外声明外部过程或变量。
^([100]) 通常,HLA 会将localObject变成类似001A_localObject的名称。这是一个合法的 MASM 标识符,但在使用 MASM 编译程序时,它不太可能与其他全局符号发生冲突。
^([101]) 事实上,C/C++ 稍有不同。一个模块中的所有全局符号默认假定为公共的,除非明确声明为私有。HLA 的方法(通过external强制声明公共项目)稍微更安全一些。
5.25 命名空间污染
创建包含多个不同模块的库时,一个问题是命名空间污染。一个典型的库模块会有一个与之关联的#include文件,该文件提供了库中所有例程、常量、变量和其他符号的外部定义。每当你想使用库中的某些例程或其他对象时,你通常会在项目中#include该库的头文件。随着库的增大,并且你在头文件中添加声明,库中标识符的名称很可能会与当前项目中你想使用的名称发生冲突。这被称为命名空间污染:库的头文件用你通常不需要的名称污染了命名空间,以便你可以轻松访问库中你实际使用的少数几个例程。大多数情况下,这些名称不会造成任何问题——除非你也想将这些名称用于自己的目的。
HLA 要求你在全局(program/unit)级别声明所有外部符号。因此,你不能在过程内部包含包含外部声明的头文件。这样,外部库符号和你在过程内局部声明的符号之间就不会发生命名冲突;冲突只会发生在外部符号和你的全局符号之间。虽然这是避免在程序中使用全局符号的一个好理由,但事实是大多数汇编语言程序中的符号将具有全局作用域。因此,另一个解决方案是必要的。
HLA 的解决方案是将大多数库名称放入namespace声明部分。namespace声明将所有声明封装起来,并仅在全局级别暴露一个名称(即namespace标识符)。你可以通过使用熟悉的点符号来访问namespace中的名称(参见 4.34 Namespaces 中的命名空间讨论)。这将多个几十个或上百个名称的命名空间污染效果减少到仅一个名称。
当然,使用namespace声明的一个缺点是,你必须输入更长的名称才能引用该命名空间中的特定标识符(即,你必须输入namespace标识符、一个句点,然后是你希望使用的特定标识符)。对于一些你经常使用的标识符,你可以选择将这些标识符放在任何namespace声明之外。例如,HLA 标准库并未在命名空间中定义符号nl。但是,你希望在库中尽量减少这种声明,以避免与自己程序中的名称发生冲突。通常,你可以选择一个namespace标识符来补充你的例程名称。例如,HLA 标准库的字符串复制例程命名借鉴了等效的 C 标准库函数strcpy。HLA 版本为str.cpy。实际的函数名是cpy;它恰好是str namespace的成员,因此完整名称为str.cpy,与对应的 C 函数非常相似。HLA 标准库中包含了多个遵循这一命名约定的示例。arg.c和arg.v函数就是另一对此类标识符(对应于 C 语言中的标识符argc和argv)。
在头文件中使用namespace与在program或unit中使用namespace没有什么不同,尽管通常不会在namespace中放置实际的过程体。以下是一个包含namespace声明的典型头文件示例:
// myHeader.hhf -
//
// Routines supported in the myLibrary.lib file
namespace myLib;
procedure func1; external;
procedure func2; external;
procedure func3; external;
end myLib;
通常,你会将每个函数(func1..func3)编译成单独的单元(因此每个函数都有自己的目标文件,并且在一个函数中进行链接不会链接到所有其他函数)。以下是其中一个函数的unit声明示例:
unit func1Unit;
#includeonce( "myHeader.hhf" )
procedure myLib.func1;
begin func1;
<< Code for func1 >>
end func1;
end func1Unit;
你应该注意到这个单元中的两个重要事项。首先,你并没有把实际的func1过程代码放在namespace声明块内。通过使用标识符myLib.func1作为过程的名称,HLA 自动识别到这个过程声明属于一个命名空间。第二点需要注意的是,在过程的begin和end语句后,你并没有再用myLib.作为func1的前缀。HLA 会自动将begin和end标识符与procedure声明关联起来,因此它知道这些标识符是myLib命名空间的一部分,而且你不需要再次输入整个名称。
重要提示:当你在命名空间内声明外部名称时,正如之前在func1Unit中所做的那样,HLA 只使用函数名(在这个例子中是func1)作为外部名称。这会导致外部命名空间中出现命名空间污染的问题。例如,如果你有两个不同的命名空间myLib和yourLib,并且它们都定义了func1过程,那么如果你尝试同时使用这两个库模块中的函数,链接器会因为func1的重复定义而报错。解决这个问题有一个简单的变通方法:使用external指令的扩展形式,明确为namespace声明中的所有外部标识符提供外部名称。例如,你可以通过以下简单的修改来解决这个问题,修改myHeader.hhf文件:
// myHeader.hhf -
//
// Routines supported in the myLibrary.lib file
namespace myLib;
procedure func1; external( "myLib_func1" );
procedure func2; external( "myLib_func2" );
procedure func3; external( "myLib_func3" );
end myLib;
这个例子展示了你应该采用的一个优秀规范:当从命名空间导出名称时,始终提供一个明确的外部名称,并通过将namespace标识符与下划线以及对象的内部名称连接起来构造这个名称。
使用namespace声明并不能完全消除命名空间污染的问题(毕竟,命名空间标识符仍然是一个全局对象,任何包含了stdlib.hhf并尝试定义cs变量的人都能证明这一点),但namespace声明在消除这个问题上已经相当接近了。因此,在创建自己的库时,尽量在实际可行的地方使用namespace。
5.26 获取更多信息
本书的电子版可以在www.artofasm.com/ 或 webster.cs.ucr.edu/找到,包含了一整本关于高级和中级过程的内容。本章中的信息取自电子版中的入门和中级章节。尽管本章涉及的内容涵盖了汇编程序员通常使用的 99%的材料,但其中还有一些关于过程和参数的附加信息,你可能会感兴趣。特别是,电子版涵盖了额外的参数传递机制(按值/结果传递、按结果传递、按名称传递和按惰性求值传递),并详细介绍了你可以传递参数的地方。该电子版还涵盖了迭代器、thunks 以及其他高级过程类型。你还应该查看 HLA 文档,了解更多关于 HLA 的过程功能的细节。最后,一本好的编译器构造教材将详细介绍关于运行时支持过程的更多细节。
本章仅讨论了 32 位近过程(适用于如 Windows、Mac OS X、FreeBSD 和 Linux 等操作系统)。有关 16 位代码中的过程(包括近过程和远过程)的信息,请查看本书的 16 位版,该版也可以在 webster.cs.ucr.edu/ 或 www.artofasm.com/ 找到。
HLA 支持嵌套过程的能力;也就是说,你可以在某个过程的声明部分声明另一个过程,并使用显示和静态链接来访问封闭过程中的自动变量。HLA 还支持高级的参数指针功能。本书没有讨论这些特性,因为它们有点高级,而且很少有汇编语言程序员在其程序中利用这些功能。然而,这些功能在某些情况下非常有用。一旦你对过程和汇编语言编程有了足够的了解,你应该阅读 HLA 文档中关于嵌套过程的相关内容,以及在电子版书中的中级和高级过程章节,这些内容可以在 webster.cs.ucr.edu/ 或 www.artofasm.com/ 找到。
最后,HLA 生成的代码示例在传递参数时使用高级语法是不完整的。随着时间的推移,HLA 已经改进了其在栈上传递参数时生成的代码质量。如果你希望查看 HLA 为某个特定参数调用序列生成的代码类型,你应该向 HLA 提供 -sourcemode、-h 和 -s 命令行参数,并查看 HLA 输出的相应汇编语言文件(该文件将是一个伪 HLA 源文件,展示 HLA 生成的低级代码)。
第六章 算术

本章讨论在汇编语言中的算术运算。到本章结束时,你应该能够将高级语言(如 Pascal 和 C/C++)中的算术表达式和赋值语句翻译为 80x86 汇编语言。
6.1 80x86 整数算术指令
在描述如何在汇编语言中编码算术表达式之前,最好先讨论 80x86 指令集中剩余的算术指令。前几章已经介绍了大部分算术和逻辑指令,因此本节将介绍你所需要的少数剩余指令。
6.1.1 mul和imul指令
乘法指令为你提供了 80x86 指令集中的另一种不规则性。像add、sub等许多指令都支持两个操作数,就像mov指令一样。不幸的是,80x86 的操作码字节没有足够的位数来支持所有指令,因此 80x86 将mul(无符号乘法)和imul(有符号整数乘法)指令视为单操作数指令,就像inc、dec和neg指令一样。
当然,乘法是一个双操作数的函数。为了绕过这一点,80x86 总是假设累加器(AL、AX 或 EAX)是目标操作数。这一不规则性使得在 80x86 上使用乘法比其他指令稍微困难,因为一个操作数必须在累加器中。英特尔采用了这种非正交的方法,因为他们认为程序员使用乘法的频率远低于add和sub等指令。
mul和imul指令的另一个问题是,你不能使用这些指令将累加器与常量相乘。英特尔很快发现了支持常量乘法的需求,并添加了intmul指令来解决这个问题。然而,你必须意识到,基本的mul和imul指令不支持与intmul一样的所有操作数范围。
乘法指令有两种形式:无符号乘法(mul)和有符号乘法(imul)。与加法和减法不同,你需要为有符号和无符号操作分别使用不同的指令。
乘法指令有以下几种形式:
无符号乘法:
mul( *`reg8`* ); // returns "ax"
mul( *`reg16`* ); // returns "dx:ax"
mul( *`reg32`* ); // returns "edx:eax"
mul( *`mem8`* ); // returns "ax"
mul( *`mem16`* ); // returns "dx:ax"
mul( *`mem32`* ); // returns "edx:eax"
有符号(整数)乘法:
imul( *`reg8`* ); // returns "ax"
imul( *`reg16`* ); // returns "dx:ax"
imul( *`reg32`* ); // returns "edx:eax"
imul( *`mem8`* ); // returns "ax"
imul( *`mem16`* ); // returns "dx:ax"
imul( *`mem32`* ); // returns "edx:eax"
上面返回的值是这些指令在 HLA 中用于指令组合的字符串。(i)mul,适用于所有 80x86 处理器,用于乘法 8 位、16 位或 32 位操作数。
在乘以两个n位值时,结果可能需要多达 2 * n位。因此,如果操作数是 8 位的,结果可能需要 16 位。同样,16 位操作数会产生 32 位结果,32 位操作数则需要 64 位来存储结果。
(i)mul 指令,使用 8 位操作数时,会将 AL 与操作数相乘,并将 16 位乘积保存在 AX 中。因此
mul( *`operand8`* );
或者
imul( *`operand8`* );
计算
ax := al * *`operand8`*
* 表示 mul 的无符号乘法和 imul 的有符号乘法。
如果你指定 16 位操作数,则 mul 和 imul 计算如下:
dx:ax := ax * *`operand16`*
* 的含义与上述相同,dx:ax 表示 DX 包含 32 位结果的高字(H.O.),AX 包含 32 位结果的低字(L.O.)。如果你在疑惑为何英特尔没有将 32 位结果放入 EAX 中,请注意,英特尔在最早期的 80x86 处理器中引入了 mul 和 imul 指令,在 80386 CPU 引入 32 位寄存器之前。
如果你指定 32 位操作数,则 mul 和 imul 计算如下:
edx:eax := eax * *`operand32`*
* 的含义与上述相同,edx:eax 表示 EDX 包含 64 位结果的高字(H.O.),EAX 包含 64 位结果的低字(L.O.)。
如果 8×8 位、16×16 位或 32×32 位的乘积需要超过 8、16 或 32 位(分别),则 mul 和 imul 指令会设置进位标志和溢出标志。mul 和 imul 会扰乱符号标志和零标志。
注意
特别需要注意的是,在执行这两条指令后,符号标志和零标志不包含有意义的值。
为了减少使用 mul 和 imul 指令时的一些语法不规范,HLA 提供了一种扩展语法,允许使用以下两操作数形式:
无符号乘法:
mul( *`reg8`*, al );
mul( *`reg16`*, ax );
mul( *`reg32`*, eax );
mul( *`mem8`*, al );
mul( *`mem16`*, ax );
mul( *`mem32`*, eax );
mul( *`constant8`*, al );
mul( *`constant16`*, ax );
mul( *`constant32`*, eax );
有符号(整数)乘法:
imul( *`reg8`*, al );
imul( *`reg16`*, ax );
imul( *`reg32`*, eax );
imul( *`mem8`*, al );
imul( *`mem16`*, ax );
imul( *`mem32`*, eax );
imul( *`constant8`*, al );
imul( *`constant16`*, ax );
imul( *`constant32`*, eax );
两操作数形式让你可以指定(低字)目标寄存器作为第二操作数。通过指定目标寄存器,你可以使程序更易读。请注意,尽管 HLA 允许在这里使用两个操作数,但你不能指定任意寄存器。目标操作数必须始终是 AL、AX 或 EAX,具体取决于源操作数。
HLA 提供了一种形式,可以让你指定常量。80x86 实际上不支持带有常量操作数的 mul 或 imul 指令。HLA 会将你指定的常量存储在内存的只读段中,并用该值初始化该变量。然后 HLA 将指令转换为 (i)mul(memory);指令。请注意,当你指定常量作为源操作数时,指令需要两个操作数(因为 HLA 使用第二个操作数来确定乘法是 8 位、16 位还是 32 位)。
当你学习扩展精度算术时,你会经常使用 mul 和 imul 指令,详见 第八章。不过,除非你在进行多精度运算,否则你可能更倾向于用 intmul 指令来替代 mul 或 imul,因为它更为通用。然而,intmul 并不能完全替代这两条指令。除了操作数的数量外,intmul 和 mul/imul 指令之间还有几个差异。以下规则专门适用于 intmul 指令:
-
并没有可用的 8×8 位
intmul指令。 -
intmul指令不会产生 2×n 位的结果。也就是说,16×16 位的乘法会产生 16 位的结果。同样,32×32 位的乘法会产生 32 位的结果。如果结果不能适配目标寄存器,这些指令会设置进位标志和溢出标志。
6.1.2 div 和 idiv 指令
80x86 除法指令执行 64/32 位除法、32/16 位除法或 16/8 位除法。这些指令有以下几种形式:
div( *`reg8`* ); // returns "al"
div( *`reg16`* ); // returns "ax"
div( *`reg32`* ); // returns "eax"
div( *`reg8`*, ax ); // returns "al"
div( *`reg16`*, dx:ax ); // returns "ax"
div( *`reg32`*, edx:eax ); // returns "eax"
div( *`mem8`* ); // returns "al"
div( *`mem16`* ); // returns "ax"
div( *`mem32`* ); // returns "eax"
div( *`mem8`*, ax ); // returns "al"
div( *`mem16`*, dx:ax ); // returns "ax"
div( *`mem32`*, edx:eax ); // returns "eax"
div( *`constant8`*, ax ); // returns "al"
div( *`constant16`*, dx:ax ); // returns "ax"
div( *`constant32`*, edx:eax ); // returns "eax"
idiv( *`reg8`* ); // returns "al"
idiv( *`reg16`* ); // returns "ax"
idiv( *`reg32`* ); // returns "eax"
idiv( *`reg8`*, ax ); // returns "al"
idiv( *`reg16`*, dx:ax ); // returns "ax"
idiv( *`reg32`*, edx:eax ); // returns "eax"
idiv( *`mem8`* ); // returns "al"
idiv( *`mem16`* ); // returns "ax"
idiv( *`mem32`* ); // returns "eax"
idiv( *`mem8`*, ax ); // returns "al"
idiv( *`mem16`*, dx:ax ); // returns "ax"
idiv( *`mem32`*, edx:eax ); // returns "eax"
idiv( *`constant8`*, ax ); // returns "al"
idiv( *`constant16`*, dx:ax ); // returns "ax"
idiv( *`constant32`*, edx:eax ); // returns "eax"
div 指令是无符号除法操作。如果操作数是 8 位操作数,div 将 AX 寄存器除以该操作数,商存储在 AL 寄存器中,余数(模)存储在 AH 寄存器中。如果操作数是 16 位数,则 div 指令将 dx:ax 中的 32 位数除以操作数,商存储在 AX 中,余数存储在 DX 中。对于 32 位操作数,div 将 edx:eax 中的 64 位值除以操作数,商存储在 EAX 中,余数存储在 EDX 中。
像 mul 和 imul 一样,HLA 提供了特殊语法,允许使用常量操作数,即使底层机器指令实际上不支持它们。有关这些扩展的更多信息,请参阅之前列出的 div 指令。
idiv 指令计算有符号商和余数。idiv 指令的语法与 div 相同(除了使用了 idiv 作为助记符),尽管为 idiv 创建有符号操作数可能需要在执行 idiv 之前使用不同的指令序列,而不是 div。
在 80x86 架构上,你不能简单地将一个无符号 8 位值除以另一个。如果除数是一个 8 位值,分子必须是一个 16 位值。如果你需要将一个无符号 8 位值除以另一个,你必须将分子扩展为 16 位。你可以通过将分子加载到 AL 寄存器中,然后将 0 移入 AH 寄存器来实现这一点。然后,你可以将 AX 除以除数操作数,得到正确的结果。在执行 div 之前没有将 AL 扩展为 0 可能导致 80x86 产生错误的结果! 当你需要除以两个 16 位无符号值时,你必须将包含分子的 AX 寄存器零扩展到 DX 寄存器。为此,只需将 0 加载到 DX 寄存器中。如果你需要将一个 32 位值除以另一个,你必须在除法操作之前将 EAX 寄存器零扩展到 EDX(通过将 0 加载到 EDX)。
在处理有符号整数值时,你需要在执行 idiv 之前,将 AL 扩展到 AX,AX 扩展到 DX,或 EAX 扩展到 EDX。为此,可以使用 cbw、cwd、cdq 或 movsx 指令。如果高位字节、字或双字中没有包含有效位,则必须在执行 idiv 操作之前对累加器(AL/AX/EAX)中的值进行符号扩展。未能执行此操作可能会导致错误的结果。
还有一个 80x86 除法指令的问题:你可能会遇到致命错误。首先,当然,你可以尝试将一个值除以 0。另一个问题是,商可能太大,无法适应 EAX、AX 或 AL 寄存器。例如,16/8 位除法 $8000/2 产生商 $4000,余数为 0。$4000 无法适应 8 位。如果发生这种情况,或者你尝试除以 0,80x86 将生成 ex.DivisionError 异常或整数溢出错误(ex.IntoInstr)。这通常意味着你的程序会显示适当的对话框并中止。如果发生这种情况,可能是你在执行除法操作之前没有对分子进行符号扩展或零扩展。由于此错误可能导致程序崩溃,因此在使用除法时应非常小心选择值。当然,你可以使用 try..endtry 块与 ex.DivisionError 和 ex.IntoInstr 来捕获程序中的这个问题。
80x86 在执行除法操作后,会使进位标志、溢出标志、符号标志和零标志未定义。因此,你不能通过检查标志位来检测除法操作后的问题。
80x86 并没有提供一个独立的指令来计算一个数除以另一个数的余数。div 和 idiv 指令在计算商的同时也会自动计算余数。然而,HLA 提供了 mod 和 imod 指令的助记符(指令)。这些特殊的 HLA 指令编译成与 div 和 idiv 相同的代码。唯一的区别是返回值的位置不同(因为这些指令将余数返回到与商不同的位置)。HLA 支持的 mod 和 imod 指令如下:
mod( *`reg8`* ); // returns "ah"
mod( *`reg16`* ); // returns "dx"
mod( *`reg32`* ); // returns "edx"
mod( *`reg8`*, ax ); // returns "ah"
mod( *`reg16`*, dx:ax ); // returns "dx"
mod( *`reg32`*, edx:eax ); // returns "edx"
mod( *`mem8`* ); // returns "ah"
mod( *`mem16`* ); // returns "dx"
mod( *`mem32`* ); // returns "edx"
mod( *`mem8`*, ax ); // returns "ah"
mod( *`mem16`*, dx:ax ); // returns "dx"
mod( *`mem32`*, edx:eax ); // returns "edx"
mod( *`constant8`*, ax ); // returns "ah"
mod( *`constant16`*, dx:ax ); // returns "dx"
mod( *`constant32`*, edx:eax ); // returns "edx"
imod( *`reg8`* ); // returns "ah"
imod( *`reg16`* ); // returns "dx"
imod( *`reg32`* ); // returns "edx"
imod( *`reg8`*, ax ); // returns "ah"
imod( *`reg16`*, dx:ax ); // returns "dx"
imod( *`reg32`*, edx:eax ); // returns "edx"
imod( *`mem8`* ); // returns "ah"
imod( *`mem16`* ); // returns "dx"
imod( *`mem32`* ); // returns "edx"
imod( *`mem8`*, ax ); // returns "ah"
imod( *`mem16`*, dx:ax ); // returns "dx"
imod( *`mem32`*, edx:eax ); // returns "edx"
imod( *`constant8`*, ax ); // returns "ah"
imod( *`constant16`*, dx:ax ); // returns "dx"
imod( *`constant32`*, edx:eax ); // returns "edx"
6.1.3 cmp 指令
cmp(比较)指令与 sub 指令基本相同,唯一的语义区别是:它不会保留计算出的差值,而只是设置标志寄存器中的条件码位。cmp 指令的语法与 sub 指令相似(虽然操作数的顺序被调整过,以便更易理解);其通用形式如下:
cmp( *`LeftOperand`*, *`RightOperand`* );
该指令计算 LeftOperand - RightOperand(注意与 sub 的区别)。具体形式如下:
cmp( *`reg`*, *`reg`* ); // Registers must be the same size.
cmp( *`reg`*, *`mem`* ); // Sizes must match.
cmp( *`reg`*, *`constant`* );
cmp( *`mem`*, *`constant`* );
cmp 指令根据减法操作 (LeftOperand - RightOperand) 的结果更新 80x86 的标志位。80x86 会以适当的方式设置标志位,以便我们可以将此指令理解为“将 LeftOperand 与 RightOperand 进行比较。”你可以通过检查标志寄存器中的相关标志位,使用条件设置指令(见 6.1.4 setcc 指令)或条件跳转指令(见 第七章)来测试比较的结果。
在探索 cmp 指令时,最好的起点可能是查看 cmp 指令如何具体影响标志位。考虑以下 cmp 指令:
cmp( ax, bx );
该指令执行 AX - BX 的计算,并根据计算结果设置标志位。标志位设置如下(另见 表 6-1):
Z
只有当 AX = BX 时,零标志才会被设置。这是 AX - BX 结果为零的唯一情况。因此,你可以通过零标志来测试相等或不相等。
S
如果结果为负数,则设置符号标志为 1。乍一看,你可能认为当 AX 小于 BX 时会设置该标志,但实际上并非总是如此。如果 AX = \(7FFF,BX = −1 (\)FFFF),那么从 BX 减去 AX 的结果是$8000,负数(因此符号标志会被设置)。所以对于有符号比较来说,符号标志并没有提供正确的状态。对于无符号操作数,考虑 AX = \(FFFF 和 BX = 1。AX 大于 BX,但它们的差值是\)FFFE,仍然是负数。事实证明,符号标志和溢出标志可以一起用于比较两个有符号值。
O
如果 AX 和 BX 的差值产生了溢出或下溢,则在cmp操作后设置溢出标志。如上所述,符号标志和溢出标志在进行有符号比较时都被使用。
C
如果从 AX 中减去 BX 需要借位,则在cmp操作后设置进位标志。这仅在 AX 小于 BX 且 AX 和 BX 都是无符号值时发生。
由于cmp指令以这种方式设置标志,你可以通过以下标志来测试两个操作数的比较:
cmp( *`Left`*, *`Right`* );
表 6-1. cmp后的条件码设置
| 无符号操作数 | 有符号操作数 |
|---|---|
| Z: 相等/不相等 | Z: 相等/不相等 |
| C: 左边 < 右边 (C = 1) 左边 >= 右边 (C = 0) | C: 无意义 |
| S: 无意义 | S: 请参阅本节讨论 |
| O: 无意义 | O: 请参阅本节讨论 |
对于有符号比较,S(符号)和 O(溢出)标志一起有以下含义:
-
如果[(S = 0)且(O = 1)]或[(S = 1)且(O = 0)],则在有符号比较中左边 < 右边。
-
如果[(S = 0)且(O = 0)]或[(S = 1)且(O = 1)],则在有符号比较中左边 >= 右边。
请注意,当左操作数小于右操作数时,(S xor O)为 1。相反,当左操作数大于或等于右操作数时,(S xor O)为 0。
为了理解这些标志为什么以这种方式设置,请考虑以下示例:
Left minus Right S O
------ ------ - -
$FFFF (-1) - $FFFE (-2) 0 0
$8000 - $0001 0 1
$FFFE (-2) - $FFFF (-1) 1 0
$7FFF (32767) - $FFFF (-1) 1 1
请记住,cmp操作实际上是减法;因此,上面的第一个例子计算(−1)-(−2),结果是(+1)。结果为正且没有发生溢出,因此 S 和 O 标志均为 0。因为(S xor O)为 0,所以Left大于或等于Right。
在第二个例子中,cmp指令将计算(−32,768)-(+1),结果是(−32,769)。由于 16 位有符号整数无法表示该值,因此该值会环绕到$7FFF(+32,767)并设置溢出标志。结果是正数(至少作为 16 位值),所以 CPU 会清除符号标志。这里(S xor O)为 1,因此Left小于Right。
在上面的第三个例子中,cmp计算(−2)-(−1),结果是(−1)。没有发生溢出,因此 O 标志为 0,结果为负数,因此符号标志为 1。因为(S xor O)为 1,所以Left小于Right。
在第四个(也是最后一个)示例中,cmp计算(+32,767)-(−1)。这产生了(+32,768),设置了溢出标志。此外,值会环绕回$8000(−32,768),因此符号标志也被设置。因为(S xor O)等于 0,所以Left大于或等于Right。
你可以在cmp指令后使用 HLA 高级控制语句和布尔标志表达式(例如@c,@nc,@z,@nz,@o,@no,@s,@ns等)测试标志。表 6-2 列出了 HLA 支持的布尔表达式,允许你在比较指令后检查各种条件。
表 6-2. HLA 条件码布尔表达式
| HLA 语法 | 条件 | 注释 |
|---|---|---|
@c |
进位设置 | 如果第一个操作数小于第二个操作数(无符号),则进位标志设置。与@b和@nae相同条件。 |
@nc |
进位清除(无进位) | 如果第一个操作数大于或等于第二个操作数(使用无符号比较),则进位标志清除。与@nb和@ae相同条件。 |
@z |
零标志设置 | 如果第一个操作数等于第二个操作数,则零标志设置。与@e相同条件。 |
@nz |
零标志清除(无零) | 如果第一个操作数不等于第二个操作数,则零标志清除。与@ne相同条件。 |
@o |
溢出标志设置 | 如果比较操作导致符号算术溢出,则此标志设置。 |
@no |
溢出标志清除(无溢出) | 如果在比较操作过程中没有发生符号算术溢出,则溢出标志清除。 |
@s |
符号标志设置 | 如果比较(减法)结果为负,则符号标志被设置。 |
@ns |
符号标志清除(无符号) | 如果比较操作产生非负(零或正)结果,则符号标志清除。 |
@a |
大于(无符号大于) | @a条件检查进位和零标志,查看@c = 0 且@z = 0。此条件存在于第一个(无符号)操作数大于第二个(无符号)操作数时。这与@nbe相同。 |
@na |
不大于 | @na条件检查进位标志是否被设置(@c)或零标志是否被设置(@z)。这等同于无符号的“非大于”条件。请注意,这个条件与@be相同。 |
@ae |
大于或等于(无符号大于等于) | 如果第一个操作数使用无符号比较大于或等于第二个操作数,则@ae条件为真。这等同于@nb和@nc条件。 |
@nae |
非大于或等于 | 如果第一个操作数使用无符号比较不大于或等于第二个操作数,则@nae条件为真。这等同于@b和@c条件。 |
@b |
小于(无符号小于) | 如果第一个操作数使用无符号比较小于第二个操作数,则@b条件为真。这相当于@nae和@c条件。 |
@nb |
不小于 | 如果第一个操作数使用无符号比较不小于第二个操作数,则此条件为真。此条件等同于@nc和@ae条件。 |
@be |
小于或等于(无符号小于或等于) | 如果第一个操作数使用无符号比较小于或等于第二个操作数,则@be条件为真。此条件等同于@na。 |
@nbe |
不小于或等于 | 如果第一个操作数使用无符号比较不小于或等于第二个操作数,则@be条件为真。此条件等同于@a。 |
@g |
大于(有符号大于) | 如果第一个操作数使用有符号比较大于第二个操作数,则@g条件为真。这相当于@nle条件。 |
@ng |
不大于 | 如果第一个操作数使用有符号比较不大于第二个操作数,则@ng条件为真。这相当于@le条件。 |
@ge |
大于或等于(有符号大于或等于) | 如果第一个操作数使用有符号比较大于或等于第二个操作数,则@ge条件为真。这相当于@nl条件。 |
@nge |
不大于或等于 | 如果第一个操作数使用有符号比较不大于或等于第二个操作数,则@nge条件为真。这相当于@l条件。 |
@l |
小于(有符号小于) | 如果第一个操作数使用有符号比较小于第二个操作数,则@l条件为真。这相当于@nge条件。 |
@nl |
不小于 | 如果第一个操作数使用有符号比较不小于第二个操作数,则@ng条件为真。这相当于@ge条件。 |
@le |
小于或等于(有符号) | 如果第一个操作数使用有符号比较小于或等于第二个操作数,则@le条件为真。这相当于@ng条件。 |
@nle |
不小于或等于 | 如果第一个操作数使用有符号比较不小于或等于第二个操作数,则@nle条件为真。这相当于@g条件。 |
@e |
等于(有符号或无符号) | 如果第一个操作数等于第二个操作数,则此条件为真。@e条件等同于@z条件。 |
@ne |
不等于(有符号或无符号) | 如果第一个操作数不等于第二个操作数,则@ne为真。此条件等同于@nz。 |
你可以在if语句、while语句或任何其他允许布尔表达式的 HLA 高级控制语句中使用出现在表 6-2 中的布尔条件。在执行完cmp指令后,通常会在if语句中使用这些条件。例如:
cmp( eax, ebx );
if( @e ) then
<< Do something if eax = ebx. >>
endif;
请注意,上面的示例等同于以下内容:
if( eax = ebx ) then
<< Do something if eax = ebx. >>
endif;
6.1.4 set*cc* 指令
条件设置(或setcc)指令根据标志寄存器中的值,将一个字节的操作数(寄存器或内存)设置为 0 或 1。setcc指令的一般格式如下:
set*`cc`*( *`reg8`* );
set*`cc`*( *`mem8`* );
setcc表示一个助记符,出现在表 6-3、表 6-4 和表 6-5 中。这些指令在条件为假时将 0 存入相应的操作数,如果条件为真,则将 1 存入 8 位操作数中。
表 6-3。setcc 指令测试标志
| 指令 | 描述 | 条件 | 注释 |
|---|---|---|---|
setc |
如果进位,则设置 | 进位 = 1 | 与setb、setnae相同 |
setnc |
如果没有进位,则设置 | 进位 = 0 | 与setnb、setae相同 |
setz |
如果为零,则设置 | 零 = 1 | 与sete相同 |
setnz |
如果不为零,则设置 | 零 = 0 | 与setne相同 |
sets |
如果为符号,则设置 | 符号 = 1 | |
setns |
如果没有符号,则设置 | 符号 = 0 | |
seto |
如果溢出,则设置 | 溢出 = 1 | |
setno |
如果没有溢出,则设置 | 溢出 = 0 | |
setp |
如果为奇偶性,则设置 | 奇偶性 = 1 | 与setpe相同 |
setpe |
如果为偶数奇偶性,则设置 | 奇偶性 = 1 | 与setp相同 |
setnp |
如果没有奇偶性,则设置 | 奇偶性 = 0 | 与setpo相同 |
setpo |
如果奇偶性为奇数,则设置 | 奇偶性 = 0 | 与setnp相同 |
上面的setcc指令仅仅测试标志,而没有附加其他含义。例如,你可以在执行移位、旋转、位测试或算术操作后使用setc来检查进位标志。你可能注意到上面有setp、setpe和setnp指令。它们检查奇偶性标志。这些指令在此列出以完整性为目的,但本文不会花太多时间讨论奇偶性标志(它的使用有些过时)。
cmp指令与 setcc 指令协同工作。在执行cmp操作后,处理器的标志会提供关于操作数相对值的信息。它们可以帮助你判断一个操作数是否小于、等于或大于另一个操作数。
两组额外的 setcc 指令在 cmp 操作后非常有用。第一组处理无符号比较的结果;第二组处理有符号比较的结果。
表 6-4. setcc 无符号比较指令
| 指令 | 描述 | 条件 | 注释 |
|---|---|---|---|
seta |
如果高于(>)则设置 | Carry = 0,Zero = 0 | 与setnbe相同 |
setnbe |
如果不低于或等于(不 <=)则设置 | Carry = 0,Zero = 0 | 与seta相同 |
setae |
如果高于或等于(>=)则设置 | Carry = 0 | 与setnc、setnb相同 |
setnb |
如果不低于(不 <)则设置 | Carry = 0 | 与setnc、setae相同 |
setb |
如果低于(<)则设置 | Carry = 1 | 与setc、setna相同 |
setnae |
如果不高于或等于(不 >=)则设置 | Carry = 1 | 与setc、setb相同 |
setbe |
如果低于或等于(<=)则设置 | Carry = 1 或 Zero = 1 | 与setna相同 |
setna |
如果不高于(不 >)则设置 | Carry = 1 或 Zero = 1 | 与setbe相同 |
sete |
如果相等则设置(=) | Zero = 1 | 与setz相同 |
setne |
如果不相等则设置(¦) | Zero = 0 | 与setnz相同 |
表 6-5 列出了相应的有符号比较。
表 6-5. setcc 有符号比较指令
| 指令 | 描述 | 条件 | 注释 |
|---|---|---|---|
setg |
如果大于(>)则设置 | Sign = Overflow 且 Zero = 0 | 与setnle相同 |
setnle |
如果不小于或等于(不 <=)则设置 | Sign = Overflow 或 Zero = 0 | 与setg相同 |
setge |
如果大于或等于(>=)则设置 | Sign = Overflow | 与setnl相同 |
setnl |
如果不小于(不 <)则设置 | Sign = Overflow | 与setge相同 |
setl |
如果小于(<)则设置 | Sign ¦ Overflow | 与setnge相同 |
setnge |
如果不大于或等于(不 >=)则设置 | Sign ¦ Overflow | 与setl相同 |
setl |
如果小于或等于(<=)则设置 | Sign ¦ Overflow 或 Zero = 1 | 与setng相同 |
setng |
如果不大于(不 >)则设置 | Sign ¦ Overflow 或 Zero = 1 | 与setle相同 |
sete |
如果相等则设置(=) | Zero = 1 | 与setz相同 |
setne |
如果不相等则设置(¦) | Zero = 0 | 与setnz相同 |
注意 setcc 指令与可能出现在布尔指令中的 HLA 标志条件之间的对应关系。
setcc 指令特别有价值,因为它们可以将比较结果转换为布尔值(假/真或 0/1)。这在将高级语言如 Pascal 或 C/C++ 转换为汇编语言时尤其重要。以下示例展示了如何以这种方式使用这些指令:
// bool := a <= b
mov( a, eax );
cmp( eax, b );
setle( bool ); // bool is a boolean or byte variable.
因为 setcc 指令总是产生 0 或 1,你可以使用 and 和 or 指令与这些结果一起计算复杂的布尔值:
// bool := ((a <= b) and (d = e))
mov( a, eax );
cmp( eax, b );
setle( bl );
mov( d, eax );
cmp( eax, e );
sete( bh );
and( bl, bh );
mov( bh, bool );
6.1.5 测试指令
80x86 的 test 指令就像 cmp 指令之于 sub 指令。也就是说,test 指令计算其两个操作数的逻辑 and,并根据结果设置条件码标志;然而,它不会将逻辑 and 的结果存储回目标操作数。test 指令的语法类似于 and:
test( *`operand1`*, *`operand2`* );
test 指令在逻辑 and 运算结果为 0 时会设置零标志。如果结果的高位比特为 1,则会设置符号标志。test 指令始终清除进位标志和溢出标志。
test 指令的主要用途是检查单个比特是否为 0 或 1。考虑指令 test( 1, al);。这条指令将 AL 与值 1 进行逻辑 and 运算;如果 AL 的第 0 位为 0,则结果为 0(设置零标志),因为常数 1 中的其他比特都是 0。相反,如果 AL 的第 1 位为 1,则结果不为 0,因此 test 会清除零标志。因此,你可以在这条 test 指令之后测试零标志,查看第 0 位是 0 还是 1(例如,使用 setz 或 setnz 指令)。
test 指令还可以检查指定的比特集中的所有比特是否为 0。指令 test( $F, al); 仅在 AL 的最低 4 位都为 0 时,才会设置零标志。
test 指令的一个非常重要的用途是检查一个寄存器是否包含 0。指令 test( reg, reg );,其中两个操作数是相同的寄存器,将该寄存器与自身做逻辑 and 运算。如果寄存器的值为 0,则结果为 0,CPU 将设置零标志。但是,如果寄存器包含非零值,将该值与自身做逻辑 and 运算将得到相同的非零值,CPU 会清除零标志。因此,你可以在执行此指令后立即检查零标志(例如,使用 setz 或 setnz 指令,或者使用 @z 和 @nz 布尔条件)来判断寄存器是否为 0。以下是一些示例:
test( eax, eax );
setz( bl ); // bl is set to 1 if eax contains 0.
.
.
.
test( bx, bx );
if( @nz ) then
<< Do something if bx <> 0\. >>
endif;
6.2 算术表达式
对于第一次接触汇编语言的初学者来说,最大的震惊可能是缺乏熟悉的算术表达式。在大多数高级语言中,算术表达式看起来与它们的代数等价物类似。例如:
x := y * z;
在汇编语言中,你需要几个语句来完成相同的任务:
mov( y, eax );
intmul( z, eax );
mov( eax, x );
显然,高级语言版本要输入、阅读和理解起来都要容易得多。这个事实,胜过其他任何原因,导致了人们远离汇编语言。尽管涉及很多输入,但将一个算术表达式转换成汇编语言并不难。通过分步解决问题,就像你手动解题一样,你可以轻松地将任何算术表达式分解为等效的汇编语言指令。通过学习如何将这种表达式分三步转换成汇编语言,你会发现这个任务几乎没有难度。
6.2.1 简单赋值
转换为汇编语言最简单的表达式是简单赋值。简单赋值将一个单一的值复制到变量中,具有两种形式之一:
*`variable`* := *`constant`*
或者
*`var1`* := *`var2`*
将第一种形式转换为汇编语言很简单——只需使用汇编语言指令:
mov( *`constant`*, *`variable`* );
这个mov指令将常量复制到变量中。
上述第二个赋值稍微复杂一些,因为 80x86 并没有提供内存到内存的mov指令。因此,要将一个内存变量复制到另一个内存变量,必须通过寄存器传递数据。根据惯例(并出于轻微的效率考虑),大多数程序员倾向于使用 AL/AX/EAX 作为这个目的。例如:
*`var1`* := *`var2`*;
变为
mov( *`var2`*, eax );
mov( eax, *`var1`* );
当然,这是假设var1和var2是 32 位变量。如果它们是 8 位变量,使用 AL;如果是 16 位变量,使用 AX。
当然,如果你已经用 AL、AX 或 EAX 做了其他事情,使用其他寄存器也可以。无论如何,通常会使用寄存器将一个内存位置转移到另一个内存位置。
6.2.2 简单表达式
下一层复杂性是一个简单表达式。一个简单的表达式具有以下形式:
*`var1`* := *`term1 op term2`*;
var1是一个变量,term1和term2是变量或常量,op是某个算术运算符(加法、减法、乘法等等)。大多数表达式都采用这种形式。由此看来,80x86 架构是为这种类型的表达式优化的,这一点应该不足为奇。
这种类型表达式的典型转换形式如下:
mov( *`term1`*, eax );
*`op`*( *`term2`*, eax );
mov( eax, *`var1`* )
op是与指定操作相对应的助记符(例如,+是add,−是sub,等等)。
请注意,简单的表达式var1 := const1 op const2;可以通过编译时表达式和单个mov指令轻松处理。例如,要计算var1 := 5+3;,只需使用单条指令mov( 5+3, var1 );。
有一些不一致之处需要注意。当处理 80x86 上的(i)mul、(i)div 和(i)mod 指令时,你必须使用 AL/AX/EAX 和 DX/EDX 寄存器。你不能像其他操作那样使用任意寄存器。此外,如果你正在执行除法操作,并且是在将一个 16/32 位数字除以另一个时,别忘了使用符号扩展指令。最后,别忘了某些指令可能会引发溢出。你可能需要在算术操作之后检查是否发生溢出(或下溢)。
这里是一些常见简单表达式的例子:
x := y + z;
mov( y, eax );
add( z, eax );
mov( eax, x );
x := y - z;
mov( y, eax );
sub( z, eax );
mov( eax, x );
x := y * z; {unsigned}
mov( y, eax );
mul( z, eax ); // Don't forget this wipes out edx.
mov( eax, x );
x := y * z; {signed}
mov( y, eax );
intmul( z, eax ); // Does not affect edx!
mov( eax, x );
x := y div z; {unsigned div}
mov( y, eax );
mov( 0, edx ); // Zero extend eax into edx.
div( z, edx:eax );
mov( eax, x );
x := y idiv z; {signed div}
mov( y, eax );
cdq(); // Sign extend eax into edx.
idiv( z, edx:eax );
mov( eax, z );
x := y mod z; {unsigned remainder}
mov( y, eax );
mov( 0, edx ); // Zero extend eax into edx.
mod( z, edx:eax );
mov( edx, x ); // Note that remainder is in edx.
x := y imod z; {signed remainder}
mov( y, eax );
cdq(); // Sign extend eax into edx.
imod( z, edx:eax );
mov( edx, x ); // Remainder is in edx.
某些一元运算也可以作为简单表达式,导致一般规则中的额外不一致性。一个好的例子是一元运算是取反。在高级语言中,取反有两种可能的形式:
*`var`* := -*`var`*
或者
*`var1`* := -*`var2`*
请注意,var := -constant 实际上是一个简单的赋值,而不是简单表达式。你可以将负常量作为操作数传递给 mov 指令:
mov( −14, *`var`* );
要处理 var1 = -var1;,使用以下单条汇编语言语句:
// *`var1`* = -*`var1`*;
neg( *`var1`* );
如果涉及两个不同的变量,则使用以下方式。
// *`var1`* = -*`var2`*;
mov( *`var2`*, eax );
neg( eax );
mov( eax, *`var1`* );
6.2.3 复杂表达式
复杂表达式是指涉及多个项和一个运算符的任何算术表达式。这类表达式通常出现在用高级语言编写的程序中。复杂表达式可能包含括号,用于覆盖运算符优先级,函数调用,数组访问等等。虽然许多复杂表达式转换为汇编语言相对直接,但其他转换则需要一些努力。本节概述了将这类表达式转换的规则。
一个容易转换为汇编语言的复杂表达式是涉及三个项和两个运算符的表达式。例如:
w := w - y - z;
显然,直接将这个语句转换为汇编语言将需要两个 sub 指令。然而,即使是像这样的简单表达式,转换也不是小事。实际上,从上面的语句转换到汇编语言有两种方法:
mov( w, eax );
sub( y, eax );
sub( z, eax );
mov( eax, w );
和
mov( y, eax );
sub( z, eax );
sub( eax, w );
第二种转换方法由于较短,看起来更好。然而,它会产生一个错误的结果(假设原语句采用类似 Pascal 的语义)。结合性是问题所在。上述第二个表达式计算的是 w := w - (y - z),这与 w := (w - y) - z 不相同。我们如何在子表达式周围放置括号会影响结果。请注意,如果你更关注简洁的形式,可以使用以下序列:
mov( y, eax );
add( z, eax );
sub( eax, w );
这会计算 w := w - (y + z)。这等同于 w := (w - y) - z。
优先级是另一个问题。考虑这个 Pascal 表达式:
x := w * y + z;
再次,我们可以通过两种方式来计算这个表达式:
x := (w * y) + z;
或者
x := w * (y + z);
到现在,你可能会觉得这段文字有点疯狂。大家都知道,正确的求值方式是第二种形式。然而,你认为那样是错误的。比如 APL 编程语言,它是从右到左进行表达式求值的,并且不会让某个运算符优先于另一个运算符。到底哪种方式是“正确”的,完全取决于你如何在你的算术系统中定义优先级。
大多数高级语言使用一组固定的优先级规则来描述涉及两个或多个不同运算符的表达式中的求值顺序。这类编程语言通常会在加法和减法之前计算乘法和除法。那些支持指数运算的语言(例如 FORTRAN 和 BASIC)通常会在乘法和除法之前计算指数运算。这些规则是直观的,因为几乎每个人在上高中之前就学过它们。考虑以下表达式
x *`op1`* y *`op2`* z
如果op1的优先级高于op2,那么这将计算为(x op1 y) op2 z;否则,如果op2的优先级高于op1,则将计算为x op1 (y op2 z)。根据涉及的运算符和操作数,这两种计算可能会产生不同的结果。当将这种形式的表达式转换为汇编语言时,必须确保首先计算优先级最高的子表达式。以下示例演示了这一技术:
// w := x + y * z;
mov( x, ebx );
mov( y, eax ); // Must compute y * z first because "*"
intmul( z, eax ); // has higher precedence than "+".
add( ebx, eax );
mov( eax, w );
如果在表达式中出现的两个运算符具有相同的优先级,那么你需要使用结合性规则来确定求值顺序。大多数运算符是左结合的,意味着它们从左到右进行求值。加法、减法、乘法和除法都是左结合的。一个右结合运算符则从右到左进行求值。例如,FORTRAN 和 BASIC 中的指数运算符就是一个典型的右结合运算符:
2²³ is equal to 2^(2³) *`not`* (2²)³
优先级和结合性规则决定了求值顺序。间接地,这些规则告诉你在表达式中放置括号的位置,以确定求值顺序。当然,你可以始终使用括号来覆盖默认的优先级和结合性。然而,最重要的是,你的汇编代码必须在正确的顺序中完成某些操作,才能正确计算给定表达式的值。以下示例演示了这一原则:
// w := x - y - z
mov( x, eax ); // All the same operator, so we need
sub( y, eax ); // to evaluate from left to right
sub( z, eax ); // because they all have the same
mov( eax, w ); // precedence and are left associative.
// w := x + y * z
mov( y, eax ); // Must compute y * z first because
intmul( z, eax ); // multiplication has a higher
add( x, eax ); // precedence than addition.
mov( eax, w );
// w := x / y - z
mov( x, eax ); // Here we need to compute division
cdq(); // first because it has the highest
idiv( y, edx:eax ); // precedence.
sub( z, eax );
mov( eax, w );
// w := x * y * z
mov( y, eax ); // Addition and multiplication are
intmul( z, eax ); // commutative; therefore the order
intmul( x, eax ); // of evaluation does not matter.
mov( eax, w );
对于结合性规则,有一个例外。如果一个表达式涉及乘法和除法,通常最好先进行乘法。例如,给定一个类似以下形式的表达式
w := x / y * z // Note: This is (x * z) / y, not x / (y * z).
通常,最好先计算x * z,然后将结果除以y,而不是先将x除以y,再将商乘以z。这种方法更好的原因有两个。首先,记住imul指令总是生成一个 64 位的结果(假设操作数是 32 位)。通过先进行乘法,你会自动将乘积符号扩展到 EDX 寄存器中,这样就不需要在除法前对 EAX 进行符号扩展。第二个理由是先进行乘法可以提高计算的精确度。记住,(整数)除法往往会产生不准确的结果。例如,如果你计算 5/2,你会得到值 2,而不是 2.5。计算(5 / 2) * 3 得到 6。但如果你计算(5 * 3) / 2,你会得到值 7,这更接近真实的商(7.5)。因此,如果你遇到以下形式的表达式:
w := x / y * z;
你通常可以将其转换为以下汇编代码:
mov( x, eax );
imul( z, eax ); // Note the use of imul, not intmul!
idiv( y, edx:eax );
mov( eax, w );
当然,如果你正在编码的算法依赖于除法操作的截断效应,你就不能使用这种技巧来改善算法。这个故事的寓意是:在将任何表达式转换为汇编语言之前,一定要完全理解它的含义。显然,如果语义要求你必须先进行除法操作,那就按照要求做。
考虑以下的 Pascal 语句:
w := x - y * x;
这与之前的例子类似,只不过它使用了减法而不是加法。由于减法不是交换律的,你不能先计算y * x然后从这个结果中减去x。这会稍微增加转换的复杂度。你需要先将x加载到寄存器中,计算y与x的乘积,并将它们的乘积保存在另一个寄存器中,然后从x中减去这个乘积。例如:
mov( x, ebx );
mov( y, eax );
intmul( x, eax );
sub( eax, ebx );
mov( ebx, w );
这是一个简单的例子,演示了在表达式中使用临时变量的必要性。这段代码使用了 EBX 寄存器暂时保存x的副本,直到计算出y和x的乘积。随着表达式复杂度的增加,临时变量的需求也会增加。考虑以下的 Pascal 语句:
w := (a + b) * (y + z);
根据代数求值的正常规则,你首先计算括号内的子表达式(即具有最高优先级的两个子表达式),并将它们的值暂存。当你计算出两个子表达式的值后,你可以计算它们的和。处理像这样的复杂表达式的一种方式是将其简化为一系列简单的表达式,这些简单表达式的结果最终存储在临时变量中。例如,你可以将上面的单一表达式转换为以下的序列:
*`temp1`* := a + b;
*`temp2`* := y + z;
w := *`temp1`* * *`temp2`*;
因为将简单的表达式转换为汇编语言相对容易,现在你可以轻松地将之前复杂的表达式转换为汇编代码。代码如下:
mov( a, eax );
add( b, eax );
mov( eax, *`temp1`* );
mov( y, eax );
add( z, eax );
mov( eax, *`temp2`* );
mov( *`temp1`*, eax );
intmul( *`temp2`*, eax );
mov( eax, w );
当然,这段代码极为低效,并且要求你在数据段中声明几个临时变量。然而,通过尽量将临时变量保存在 80x86 寄存器中,这段代码是非常容易优化的。使用 80x86 寄存器来存储临时结果后,这段代码变成了:
mov( a, eax );
add( b, eax );
mov( y, ebx );
add( z, ebx );
intmul( ebx, eax );
mov( eax, w );
这是另一个例子:
x := (y + z) * (a - b) / 10;
这可以转换为四个简单的表达式:
*`temp1`* := (y + z)
*`temp2`* := (a - b)
*`temp1`* := *`temp1`* * *`temp2`*
X := *`temp1`* / 10
你可以将这四个简单的表达式转换为以下汇编语言语句:
mov( y, eax ); // Compute eax = y + z
add( z, eax );
mov( a, ebx ); // Compute ebx = a - b
sub( b, ebx );
imul( ebx, eax ); // This also sign extends eax into edx.
idiv( 10, edx:eax );
mov( eax, x );
最重要的是,你应该尽量将临时值保存在寄存器中。记住,访问 80x86 寄存器比访问内存位置高效得多。只有在寄存器用完的情况下,才使用内存位置来保存临时变量。
最终,将复杂的表达式转换为汇编语言与手动求解表达式没什么不同。你不是在每个计算阶段实际计算结果,而是编写计算结果的汇编代码。因为你可能被教导一次只计算一个操作,这意味着手动计算是在复杂表达式中处理“简单表达式”。当然,将这些简单表达式转换成汇编语言是相当简单的。因此,任何能够手动求解复杂表达式的人,都可以按照简单表达式的规则将其转换为汇编语言。
6.2.4 交换律运算符
如果 op 表示某个运算符,且该运算符是 交换律 的话,那么以下关系总是成立:
(A *`op`* B) = (B *`op`* A)
如你在上一节中看到的,交换律运算符很有用,因为其操作数的顺序无关紧要,这使得你可以重新排列计算步骤,通常可以使计算更简单或更高效。通常,重新排列计算步骤能让你使用更少的临时变量。每当你在表达式中遇到交换律运算符时,应该始终检查是否有更好的顺序能提高代码的大小或速度。表 6-6 和 表 6-7 列出了你在高级语言中通常遇到的交换律和非交换律运算符。
表 6-6. 一些常见的交换律二元运算符
| Pascal | C/C++ | 描述 |
|---|---|---|
+ |
+ |
加法 |
* |
* |
乘法 |
and |
&& 或 & |
逻辑与与按位与 |
or |
|| 或 | |
逻辑或与按位或 |
xor |
^ |
(逻辑或)按位异或 |
= |
== |
等式 |
<> |
!= |
不等式 |
表 6-7. 一些常见的非交换律二元运算符
| Pascal | C/C++ | 描述 |
|---|---|---|
- |
- |
减法 |
/ 或 div |
/ |
除法 |
mod |
% |
取模或余数 |
< |
< |
小于 |
<= |
<= |
小于或等于 |
> |
> |
大于 |
>= |
>= |
大于或等于 |
6.3 逻辑(布尔)表达式
考虑下面一个来自 Pascal 程序的表达式:
b := ((x = y) and (a <= c)) or ((z - a) <> 5);
b是一个布尔变量,其他变量都是整数。
我们如何在汇编语言中表示布尔变量?尽管表示一个布尔值只需要一个位,但大多数汇编语言程序员会为此分配一个完整的字节或字(因此,HLA 也为布尔变量分配了一个完整的字节)。有了一个字节,我们可以使用 256 个可能的值来表示两个布尔值:真和假。那么我们用哪两个值(或哪两组值)来表示这些布尔值呢?由于机器的架构,测试像零或非零、正数或负数这样的条件比测试两个特定布尔值要容易得多。大多数程序员(事实上,一些编程语言,如 C 语言)选择用 0 表示假,其他任何值表示真。也有人更倾向于用 1 和 0(分别表示真和假)来表示布尔值,并且不允许其他值。还有些人选择将所有 1 位($FFFF_FFFF, $FFFF 或 $FF)表示真,0 表示假。你也可以用正值表示真,负值表示假。这些机制各有其优缺点。
仅使用 0 和 1 来表示假和真有两个非常大的优点:(1)setcc 指令会产生这些结果,因此这个方案与这些指令兼容;(2)80x86 的逻辑指令(and、or、xor,以及在较小程度上,not)对这些值的操作完全符合预期。也就是说,如果你有两个布尔变量A和B,那么以下指令会对这两个变量执行基本的逻辑操作:
// c = a AND b;
mov( a, al );
and( b, al );
mov( al, c );
// c = a OR b;
mov( a, al );
or( b, al );
mov( al, c );
// c = a XOR b;
mov( a, al );
xor( b, al );
mov( al, c );
// b = NOT a;
mov( a, al ); // Note that the NOT instruction does not
not( al ); // properly compute al = NOT al by itself.
and( 1, al ); // I.e., (NOT 0) does not equal one. The AND
mov( al, b ); // instruction corrects this problem.
mov( a, al ); // Another way to do b = NOT a;
xor( 1, al ); // Inverts bit 0.
mov( al, b );
注意,如上所述,not指令不能正确地计算逻辑非运算。0 的按位not是\(FF,而 1 的按位`not`是\)FE。两者的结果都不是 0 或 1。然而,通过将结果与 1 进行and操作,你可以得到正确的结果。注意,你可以通过使用xor( 1, ax );指令更高效地实现not操作,因为它只影响最低有效位(L.O. bit)。
事实证明,使用 0 表示假,其他任何值表示真有许多微妙的优势。具体来说,真或假的测试通常在执行任何逻辑指令时是隐式的。然而,这种机制有一个很大的缺点:你无法使用 80x86 的and、or、xor和not指令来实现同名的布尔操作。考虑两个值\(55 和\)AA。它们都是非零的,因此它们都表示真值。然而,如果你使用 80x86 的and指令对\(55 和\)AA 进行逻辑与操作,结果是 0。真and真应该得到真,而不是假。尽管你可以处理这种情况,但通常需要额外的几条指令,并且在计算布尔操作时效率较低。
使用非零值表示真,0 表示假,是一种算术逻辑系统。使用 0 和 1 这两个不同值来表示假和真的是一种布尔逻辑系统,或者简称布尔系统。你可以根据需要选择任何一个系统。再考虑一下布尔表达式
b := ((x = y) and (a <= d)) or ((z - a) <> 5);
从这个表达式得到的简单表达式可能是:
mov( x, eax );
cmp( y, eax );
sete( al ); // al := x = y;
mov( a, ebx );
cmp( ebx, d );
setle( bl ); // bl := a <= d;
and( al, bl ); // bl := (x = y) and (a <= d);
mov( z, eax );
sub( a, eax );
cmp( eax, 5 );
setne( al );
or( bl, al ); // al := ((x = y) and (a <= d)) or ((z - a) <> 5);
mov( al, b );
在处理布尔表达式时,别忘了你可能可以通过简化这些布尔表达式来优化你的代码。你可以使用代数变换来帮助简化表达式的复杂性。在控制结构章节中,你还将看到如何使用控制流来计算布尔结果。与本节中的例子所教的完全布尔运算相比,这通常要高效得多。
6.4 机器和算术成语
成语是一种特有的表达方式。几种算术操作和 80x86 指令都有其特有之处,在编写汇编语言代码时,你可以利用这些特性。有些人称使用机器和算术成语为“技巧性编程”,并认为在编写优良程序时应当避免。然而,虽然为了避免单纯的技巧使用是明智的,但许多机器和算术成语是广为人知且在汇编语言程序中常见的。它们中的一些只是技巧而已,但有相当一部分则是简单的“行业技巧”。这篇文本甚至无法开始列举当前常用的所有成语,它们太多了,且列表不断变化。尽管如此,还是有一些非常重要的成语你将经常看到,因此讨论这些是很有意义的。
6.4.1 不使用 mul、imul 或 intmul 进行乘法运算
在乘以常数时,你有时可以通过使用移位、加法和减法来代替乘法指令,从而编写更快的代码。
记住,shl指令计算的结果与将指定操作数乘以 2 相同。将操作数左移两位乘以 4。将操作数左移三位乘以 8。通常,将操作数左移n位将其乘以 2 的n次方。您可以使用一系列移位和加法或移位和减法将任意值乘以某个常数。例如,要将 AX 寄存器乘以 10,您只需将其乘以 8,然后加上两倍的原始值。也就是说,10 * ax = 8 * ax + 2 * ax。执行此操作的代码如下:
shl( 1, ax ); // Multiply ax by two.
mov( ax, bx); // Save 2*ax for later.
shl( 2, ax ); // Multiply ax by eight (*4 really,
// but ax contains *2).
add( bx, ax ); // Add in ax*2 to ax*8 to get ax*10.
许多 x86 处理器可以通过使用shl比使用mul指令快得多来更快地将 AX 寄存器(或几乎任何寄存器)乘以各种常数值。这可能难以置信,因为仅需一条指令即可计算此乘积:
intmul( 10, ax );
但是,如果您查看指令时序,上面的移位加法示例在许多 80x86 系列处理器上需要的时钟周期比mul指令少。当然,代码稍微大一些(多出几个字节),但性能改进通常是值得的。
您还可以使用移位结合减法来执行乘法操作。考虑以下乘以 7 的乘法运算:
mov( eax, ebx ); // Save eax * 1
shl( 3, eax ); // eax = eax * 8
sub( ebx, eax ); // eax * 8 - eax * 1 is eax * 7
初学汇编语言程序员常犯的一个错误是减去或加上 1 或 2,而不是eax * 1或eax * 2。以下内容不计算eax * 7:
shl( 3, eax );
sub( 1, eax );
它计算的是(8 * eax) - 1,完全不同的东西(当然,如果 EAX = 1 除外)。在使用移位、加法和减法执行乘法操作时要注意此陷阱。
您还可以使用lea指令来计算某些乘积。诀窍是使用缩放索引寻址模式。以下示例演示了一些简单的情况:
lea( eax, [ecx][ecx] ); // eax := ecx * 2
lea( eax, [eax][eax*2] ); // eax := eax * 3
lea( eax, [eax*4] ); // eax := eax * 4
lea( eax, [ebx][ebx*4] ); // eax := ebx * 5
lea( eax, [eax*8] ); // eax := eax * 8
lea( eax, [edx][edx*8] ); // eax := edx * 9
6.4.2 无需使用 div 或 idiv 进行除法
就像shl指令对模拟乘以 2 的幂非常有用一样,shr和sar指令可以模拟除以 2 的幂。不幸的是,您不能轻松地使用移位、加法和减法来执行任意常数的除法。因此,请记住,此技巧仅在除以 2 的幂时才有用。此外,请不要忘记,sar指令向负无穷方向舍入,而不是向 0 舍入;这与idiv指令的操作方式不同(它向 0 舍入)。
另一种进行除法的方法是使用乘法指令。通过乘以其倒数,您可以除以某个值。由于乘法指令比除法指令快,乘以倒数通常比除法快。
现在你可能会想,“当我们处理的值都是整数时,怎么通过倒数来进行乘法运算?”当然,答案是我们必须作弊来实现这一点。如果你想乘以 1/10,事先将 1/10 加载到 80x86 整数寄存器中是不可能的。然而,我们可以将 1/10 乘以 10,进行乘法运算,然后将结果除以 10 得到最终结果。当然,这并不会带来任何好处;实际上,这样做反而会更糟,因为你现在不仅要做乘以 10 的运算,还要做除以 10 的运算。然而,假设你将 1/10 乘以 65,536(6,553),进行乘法运算,然后再除以 65,536,这仍然能完成正确的操作,实际上,如果你正确地设置问题,除法操作是免费的。考虑以下将 AX 除以 10 的代码:
mov( 6554, dx ); // 6,554 = round( 65,536/10 )
mul( dx, ax );
这段代码将 AX/10 的结果保存在 DX 寄存器中。
要理解这一点,考虑当你将 AX 乘以 65,536($1_0000)时会发生什么。这实际上将 AX 移动到 DX 并将 AX 设置为 0(乘以$1_0000 相当于左移 16 位)。将 AX 乘以 6,554(65,536 除以 10)将 AX 除以 10 的结果存储在 DX 寄存器中。因为mul比div更快,所以这种技术比使用除法稍微快一点。
当你需要除以常数时,乘以倒数的方式非常有效。你甚至可以用它来除以一个变量,但计算倒数的开销只有在你进行多次相同值的除法时才会得到回报。
6.4.3 使用与实现模 N 计数器
如果你想实现一个计数器变量,使其计数直到 2^(n) - 1,然后重置为 0,只需使用以下代码:
inc( CounterVar );
and( nBits, CounterVar );
其中 nBits 是一个二进制值,包含* n *个右对齐的 1 位。例如,要创建一个在 0 和 15 之间循环的计数器(2⁴ - 1),你可以使用以下代码:
inc( CounterVar );
and( %00001111, CounterVar );
6.5 浮点运算
当 8086 CPU 在 1970 年代末期首次亮相时,半导体技术还没有发展到 Intel 可以直接在 8086 CPU 上放置浮点指令的地步。因此,Intel 设计了一种方案,使用第二颗芯片来执行浮点运算——即浮点单元(FPU)。^([102]) 随着 Intel Pentium 芯片的发布,半导体技术已经发展到 FPU 完全集成到 80x86 CPU 中的程度。因此,几乎所有现代的 80x86 CPU 设备都完全支持在 CPU 上直接进行浮点运算。
6.5.1 FPU 寄存器
80x86 FPU 为 80x86 添加了 13 个寄存器:八个浮点数据寄存器,一个控制寄存器,一个状态寄存器,一个标签寄存器,一个指令指针和一个数据指针。数据寄存器类似于 80x86 的通用寄存器集,因为所有浮点计算都在这些寄存器中进行。控制寄存器包含位,用于决定 FPU 如何处理某些退化情况,如不准确计算的舍入;它还包含控制精度等的位。状态寄存器类似于 80x86 的标志寄存器;它包含条件码位和描述 FPU 状态的其他几个浮点标志。标签寄存器包含几组位,用于确定每个浮点数据寄存器中值的状态。指令指针和数据指针寄存器包含关于上次执行的浮点指令的某些状态信息。我们在此不讨论最后三个寄存器;有关详细信息,请参阅英特尔文档。
6.5.1.1 FPU 数据寄存器
FPU 提供了八个 80 位的数据寄存器,组织成一个堆栈。这与 80x86 CPU 上通用寄存器的组织方式有显著不同。HLA 将这些寄存器称为 ST0、ST1、……ST7。
FPU 寄存器集与 80x86 寄存器集之间最大的区别是堆栈结构。在 80x86 CPU 上,AX 寄存器始终是 AX 寄存器,无论发生什么。然而,在 FPU 上,寄存器集是一个包含 8 个元素的堆栈,存储的是 80 位浮点值(见图 6-1))。

图 6-1. FPU 浮点寄存器堆栈
ST0 表示堆栈顶部的项,ST1 表示堆栈中的下一项,以此类推。许多浮点指令会在堆栈上推入和弹出项;因此,在将某个项推入堆栈后,ST1 将表示 ST0 的先前内容。习惯于寄存器编号会变化这一事实需要一些思考和练习,但这并不难克服。
6.5.1.2 FPU 控制寄存器
当英特尔设计 80x87(以及本质上是 IEEE 浮点标准)时,浮点硬件没有标准化。不同的(大型机和小型机)计算机制造商都有不同且不兼容的浮点格式。不幸的是,许多应用程序已经根据这些不同浮点格式的特殊性进行了编写。英特尔希望设计一个能够与大多数现有软件兼容的 FPU(请记住,英特尔开始设计 8087 时,IBM-PC 还差三到四年才问世,所以英特尔无法依赖那个“山一样”的 PC 软件来让其芯片流行)。不幸的是,这些较老浮点格式中的许多特性是互不兼容的。例如,在某些浮点系统中,当精度不足时会发生舍入;而在其他系统中则会发生截断。有些应用程序可以与一种浮点系统兼容,但与另一种不兼容。英特尔希望尽可能多的应用程序能够在尽可能少的修改下与其 80x87 FPU 兼容,因此它添加了一个特殊寄存器,即 FPU 控制寄存器,让用户可以选择 FPU 的几种操作模式之一。
80x87 控制寄存器包含 16 位,组织方式如图 6-2 所示。

图 6-2. FPU 控制寄存器
FPU 控制寄存器的第 10 位和第 11 位提供了舍入控制,具体取决于表 6-8 中显示的值。
表 6-8. 舍入控制
| 第 10 位和第 11 位 | 功能 |
|---|---|
| 00 | 舍入到最近的或偶数 |
| 01 | 向下舍入 |
| 10 | 向上舍入 |
| 11 | 截断 |
00 设置是默认设置。FPU 会将大于最小有效位一半的值舍入到上面,低于最小有效位一半的值则舍入到下面。如果最小有效位下面的值恰好是最小有效位的一半,则 FPU 会将该值舍入到最接近的、最小有效位为 0 的值。对于长字符串的计算,这提供了一种合理的、自动的方式来保持最大的精度。
向上舍入和向下舍入选项适用于那些在计算过程中需要跟踪精度的计算。通过将舍入控制设置为向下舍入并执行操作,然后重复执行该操作,将舍入控制设置为向上舍入,你可以确定真实结果将落入的最小和最大范围之间。
截断选项强制所有计算在过程中截断任何多余的位。如果精度对你来说很重要,你很少会使用此选项。然而,如果你正在将旧软件移植到 FPU 上,可能会使用此选项来帮助软件移植。此选项在将浮点值转换为整数时非常有用。因为大多数软件期望浮点到整数的转换会截断结果,你需要使用截断/舍入模式来实现这一点。
控制寄存器的第 8 位和第 9 位指定计算过程中的精度。此功能提供了与旧软件兼容的能力,以符合 IEEE 754 标准的要求。精度控制位使用表 6-9 中的值。
表 6-9. 尾数精度控制位
| 位 8 和 9 | 精度控制 |
|---|---|
| 00 | 24 位 |
| 01 | 保留 |
| 10 | 53 位 |
| 11 | 64 位 |
一些 CPU 在处理精度为 53 位(即 64 位浮点格式)的浮点值时,可能比处理 64 位(即 80 位浮点格式)更快。有关详细信息,请参见特定处理器的文档。通常,CPU 默认将这些位设置为 %11,以选择 64 位尾数精度。
位 0..5 是异常屏蔽位。这些位类似于 80x86 的标志寄存器中的中断使能位。如果这些位为 1,则相应的条件会被 FPU 忽略。然而,如果任一位为 0,并且相应条件发生,则 FPU 会立即生成中断,程序可以处理这种退化条件(通常这会引发 HLA 异常;异常值见 excepts.hhf 头文件)。
位 0 对应无效操作错误。这通常是由于编程错误引起的。引发无效操作异常(ex.fInvalidOperation)的情况包括将超过八个项压入栈中,或者尝试从空栈中弹出项,取负数的平方根,或加载非空寄存器。
位 1 屏蔽非规范化中断,每当你尝试操作非规范化的值时都会触发。非规范化异常发生在你将任意扩展精度的值加载到 FPU 或者处理非常小的超出 FPU 能力范围的数值时。通常情况下,你可能不会启用此异常。如果启用了此异常且 FPU 生成了此中断,则 HLA 运行时系统会触发 ex.fDenormal 异常。
位 2 屏蔽零除异常。如果此位为 0,当你尝试将非零值除以 0 时,FPU 会生成中断。如果你不启用零除异常,FPU 会在执行零除时产生 NaN(不是一个数)。通过将 0 编程到此位,启用此异常可能是个好主意。请注意,如果你的程序生成此中断,HLA 运行时系统会引发ex.fDivByZero异常。
位 3 屏蔽溢出异常。如果计算发生溢出,或者你试图将一个太大的值存入目标操作数(例如,将一个大的扩展精度值存入单精度变量),FPU 将引发溢出异常。如果你启用此异常并且 FPU 生成此中断,HLA 运行时系统会引发ex.fOverflow异常。
位 4,如果设置,会屏蔽下溢异常。下溢发生在结果太小,无法适应目标操作数时。像溢出一样,这个异常可以在你将一个小的扩展精度值存入一个较小的变量(单精度或双精度)时发生,或者当计算结果对扩展精度来说太小时。如果你启用此异常并且 FPU 生成此中断,HLA 运行时系统会引发ex.fUnderflow异常。
位 5 控制是否可以发生精度异常。精度异常发生在 FPU 产生不精确的结果时,通常是内部四舍五入操作的结果。虽然许多操作会产生精确的结果,但更多操作不会。例如,将 1 除以 10 会产生不精确的结果。因此,这个位通常为 1,因为不精确的结果非常常见。如果你启用此异常并且 FPU 生成此中断,HLA 运行时系统会引发ex.InexactResult异常。
控制寄存器中的位 6..7 和 12..15 目前是未定义的,并保留用于未来使用(位 7 和 12 在旧的 FPU 上有效,但现在不再使用)。
FPU 提供了两条指令,fldcw(加载控制字)和 fstcw(存储控制字),让你可以加载和存储控制寄存器的内容。这些指令的单操作数必须是一个 16 位内存位置。fldcw 指令从指定的内存位置加载控制寄存器。fstcw 将控制寄存器存储到指定的内存位置。这些指令的语法是:
fldcw( *`mem16`* );
fstcw( *`mem16`* );
这是一些示例代码,设置四舍五入控制为“截断结果”,并将四舍五入精度设置为 24 位:
static
fcw16: word;
.
.
.
fstcw( fcw16 );
mov( fcw16, ax );
and( $f0ff, ax ); // Clears bits 8-11.
or( $0c00, ax ); // Rounding control=%11, Precision = %00.
mov( ax, fcw16 );
fldcw( fcw16 );
6.5.1.3 FPU 状态寄存器
FPU 状态寄存器提供了读取时刻 FPU 的状态。fstsw 指令将 16 位浮点状态寄存器存储到一个字变量中。状态寄存器是一个 16 位寄存器;其布局见图 6-3。

图 6-3. FPU 状态寄存器
位 0 到位 5 是异常标志。这些位的顺序与控制寄存器中的异常掩码相同。如果存在相应的条件,则该位被设置。这些位独立于控制寄存器中的异常掩码设置。FPU 无论对应的掩码设置如何,都会设置和清除这些位。
位 6 表示堆栈故障。堆栈故障发生在堆栈溢出或下溢时。当该位被设置时,C[1] 状态码位确定是堆栈溢出(C[1] = 1)还是堆栈下溢(C[1] = 0)。
如果设置了任何错误条件位,状态寄存器的第 7 位将被设置。它是位 0 到位 5 的逻辑 或 运算结果。程序可以测试这个位,以快速判断是否存在错误条件。
位 8、9、10 和 14 是协处理器状态码位。不同的指令设置状态码位,如 表 6-10") 和 表 6-11") 所示。
表 6-10. FPU 状态码位 (X = "不关心")
| 指令 | 状态码位 | 条件 |
|---|---|---|
| C[3] | C[2] | |
| --- | --- | --- |
fcom``fcomp``fcompp``ficom``ficomp |
0011 | 0001 |
ftst |
0011 | 0001 |
fxam |
0000111100001 | 001100110011X |
fucom``fucomp``fucompp |
0011 | 0001 |
表 6-11. 状态码解释 (X = "不关心")
| 指令 | 状态码位 |
|---|---|
| C[0] | |
| --- | --- |
fcom, fcomp, fcmpp, ftst, fucom, fucomp, fucompp, ficom, ficomp |
比较结果。请参见前表。 |
fxam |
请参见前表。 |
fprem, fprem1 |
余数的第 2 位 |
fist, fbstp, frndint, fst, fstp, fadd, fmul, fdiv, fdivr, fsub, fsubr, fscale, fsqrt, fpatan, f2xm1, fyl2x, fyl2xp1 |
未定义 |
fptan, fsin, fcos, fsincos |
未定义 |
fchs, fabs, fxch, fincstp, fdecstp, ``constant``加载, fxtract, fld, fild, fbld, fstp (80 位) |
未定义 |
fldenv, fstor |
从内存操作数恢复。 |
fldcw, fstenv, fstcw, fstsw, fclex |
未定义 |
finit, fsave |
清除为零。 |
FPU 状态寄存器的位 11–13 提供了栈顶的寄存器编号。在计算过程中,FPU 将程序员提供的逻辑寄存器编号与这三个位进行加法(模-8 运算),以确定运行时的物理寄存器编号。
状态寄存器的位 15 是忙碌位。当 FPU 忙碌时,此位被置为 1。这一位是 FPU 曾作为独立芯片时的历史遗留物;大多数程序几乎没有理由访问此位。
6.5.2 FPU 数据类型
FPU 支持七种不同的数据类型:三种整数类型,一种打包十进制类型,三种浮点类型。整数类型支持 64 位整数,尽管通常使用 CPU 的整数单元进行 64 位算术运算更为高效(参见第八章)。当然,使用标准整数寄存器进行 16 位和 32 位整数算术运算更为快速。打包十进制类型提供一个 17 位的有符号十进制(BCD)整数。BCD 格式的主要用途是进行字符串与浮点值之间的转换。其余三种数据类型是 32 位、64 位和 80 位的浮点数据类型。80x87 数据类型出现在图 6-4、图 6-5 和图 6-6 中。

图 6-4. FPU 浮点格式

图 6-5. FPU 整数格式

图 6-6. FPU 打包十进制格式
FPU 通常以归一化格式存储值。当浮点数被归一化时,尾数的最高有效位总是 1。在 32 位和 64 位浮点格式中,FPU 实际上并不存储这一位;FPU 总是假设它为 1。因此,32 位和 64 位浮点数总是归一化的。在扩展精度 80 位浮点格式中,FPU 不假设尾数的最高有效位为 1;尾数的最高有效位作为位串的一部分出现。
归一化值在给定位数下提供最大的精度。然而,有大量的非归一化值是我们无法使用 80 位格式表示的。这些值非常接近 0,表示那些尾数最高有效位(H.O. bit)不是 0 的值。FPU 支持一种特殊的 80 位形式,称为非归一化值。非归一化值允许 FPU 编码它无法通过归一化值编码的非常小的值,但非归一化值的精度比归一化值低。因此,在计算中使用非归一化值可能会引入一些轻微的不准确性。当然,这总比将非归一化值下溢为 0(这可能会使计算更加不准确)要好,但你必须记住,如果你处理的是非常小的值,可能会在计算中丢失一些精度。请注意,FPU 状态寄存器包含一个位,你可以用来检测 FPU 在计算中何时使用非归一化值。
6.5.3 FPU 指令集
FPU 在 80x86 指令集中添加了许多指令。我们可以将这些指令分类为数据传输指令、转换指令、算术指令、比较指令、常量指令、超越指令和其他指令。以下章节将描述这些类别中的每一条指令。
6.5.4 FPU 数据传输指令
数据传输指令在内部 FPU 寄存器和内存之间传输数据。该类别的指令有fld、fst、fstp和fxch。fld指令总是将操作数压入浮点栈。fstp指令总是在存储栈顶元素(TOS)后弹出栈顶元素。其余指令不会影响栈中的元素个数。
6.5.4.1 fld 指令
fld指令将一个 32 位、64 位或 80 位浮点值加载到栈中。此指令在将值压入浮点栈之前,会将 32 位和 64 位操作数转换为 80 位扩展精度值。
fld 指令首先递减 TOS 指针(状态寄存器的第 11–13 位),然后将 80 位值存储到新 TOS 指针指定的物理寄存器中。如果 FLD 指令的源操作数是一个浮点数据寄存器,sti,那么 FPU 用于加载操作的实际寄存器是递减 TOS 指针之前的寄存器。因此,fld( st0 ); 会复制堆栈顶部的值。
如果发生堆栈溢出,fld 指令会设置堆栈故障位。如果您加载的是 80 位的非正规值,它会设置非正规异常位。如果您尝试将空的浮点寄存器加载到堆栈顶部(或执行其他无效操作),它会设置无效操作位。
下面是一些示例:
fld( st1 );
fld( *`real32_variable`* );
fld( *`real64_variable`* );
fld( *`real80_variable`* );
fld( (type real64 [ebx]) );
fld( *`real_constant`* );
请注意,没有直接将 32 位整数寄存器加载到浮点堆栈中的方法,即使该寄存器包含 real32 值。为了实现这一点,您必须首先将整数寄存器存储到内存位置;然后您可以使用 fld 指令将该内存位置压入 FPU 堆栈。例如:
mov( eax, *`tempReal32`* ); // Save real32 value in eax to memory.
fld( *`tempReal32`* ); // Push that real value onto the FPU stack.
请注意,通过 fld 加载常量实际上是 HLA 扩展。FPU 不支持这种指令类型。HLA 会在常量段创建一个 real80 对象,并使用该内存对象的地址作为 fld 的实际操作数。
6.5.4.2 fst 和 fstp 指令
fst 和 fstp 指令将浮点堆栈顶部的值复制到另一个浮点寄存器或 32 位、64 位或 80 位的内存变量中。当将数据复制到 32 位或 64 位内存变量时,FPU 会根据 FPU 控制寄存器中的舍入控制位,将堆栈顶部的 80 位扩展精度值舍入到较小的格式。
fstp 指令在将值移动到目标位置时会将其从堆栈顶部弹出。它通过在访问 ST0 中的数据后递增状态寄存器中的 TOS 指针来完成这一操作。如果目标操作数是一个浮点寄存器,FPU 会在弹出堆栈顶部数据之前,将值存储到指定的寄存器号中。
执行 fstp( st0 ); 指令有效地将堆栈顶部的数据弹出,但没有数据传输。以下是一些示例:
fst( *`real32_variable`* );
fst( *`real64_variable`* );
fst( *`realArray`*[ ebx*8 ] );
fst( st2 );
fstp( st1 );
上面的最后一个示例有效地弹出了 ST1,同时将 ST0 保留在堆栈顶部。
fst 和 fstp 指令将在发生栈下溢时设置栈异常标志位(即尝试从空的寄存器栈存储值)。如果在存储操作过程中发生精度丢失,它们将设置精度位(例如,当将一个 80 位扩展精度值存储到 32 位或 64 位内存变量时,部分位会在转换过程中丢失)。当将一个 80 位的值存储到 32 位或 64 位内存变量时,如果该值太小而无法适配目标操作数,它们将设置下溢异常位。同样,如果栈顶的值太大,无法适配到 32 位或 64 位内存变量,它们会设置溢出异常位。fst 和 fstp 指令在你试图将一个非规范值存储到 80 位寄存器或变量时,会设置非规范标志位^([103)]。如果发生无效操作(例如存储到空寄存器中),它们会设置无效操作标志位。最后,如果在存储操作过程中发生舍入,它们会设置 C[1] 条件位(这种情况仅在存储到 32 位或 64 位内存变量时发生,并且需要将尾数舍入以适应目标)。
注意
由于与指令编码相关的 FPU 指令集中的特殊性,不能使用 fst 指令将数据存储到 real80 内存变量中。但是,你可以使用 fstp 指令存储 80 位数据。
6.5.4.3 fxch 指令
fxch 指令交换栈顶的值与其他 FPU 寄存器中的值。该指令有两种形式:一种是操作数为单个 FPU 寄存器,另一种是不带操作数。第一种形式交换栈顶与指定寄存器的值;第二种形式的 fxch 会将栈顶与 ST1 交换。
许多 FPU 指令,例如 fsqrt,仅对寄存器栈的栈顶进行操作。如果你想对栈顶以外的值执行这样的操作,可以使用 fxch 指令交换该寄存器与栈顶寄存器(TOS),执行所需的操作,然后再使用 fxch 指令将栈顶寄存器与原寄存器交换。以下示例演示了如何对 ST2 取平方根:
fxch( st2 );
fsqrt();
fxch( st2 );
fxch 指令在栈为空时设置栈异常位。如果你指定一个空寄存器作为操作数,它会设置无效操作位。该指令始终清除 C[1] 条件码位。
6.5.5 转换
FPU 对 80 位实数进行所有算术操作。从某种意义上说,fld 和 fst/fstp 指令是转换指令,因为它们在 80 位实数格式与 32 位和 64 位内存格式之间自动转换。然而,我们会将它们简单归类为数据移动操作,而不是转换操作,因为它们是将实数值从内存中移入或移出。FPU 提供了其他六条指令,在移动数据时可以在整数或二进制编码十进制(BCD)格式之间转换。这些指令包括 fild、fist、fistp、fisttp、fbld 和 fbstp。
6.5.5.1 fild 指令
fild(整数加载)指令将 16 位、32 位或 64 位的补码整数转换为 80 位扩展精度格式,并将结果推送到堆栈中。此指令总是期望一个操作数。该操作数必须是字、双字或四字整数变量的地址。你不能指定 80x86 的 16 位或 32 位通用寄存器。如果你想将 80x86 通用寄存器的值推送到 FPU 堆栈中,必须先将其存储到内存变量中,然后使用 fild 指令将该内存变量推送到堆栈。
如果在推送转换后的值时发生堆栈溢出,fild 指令会设置堆栈异常位并相应地设置 C[1]。来看一些示例:
fild( *`word_variable`* );
fild( *`dword_val`*[ ecx*4 ] );
fild( *`qword_variable`* );
fild( (type int64 [ebx]) );
6.5.5.2 fist、fistp 和 fisttp 指令
fist、fistp 和 fisttp 指令将堆栈顶部的 80 位扩展精度变量转换为 16 位、32 位或 64 位整数,并将结果存储到由单一操作数指定的内存变量中。fist 和 fistp 指令根据 FPU 控制寄存器中的舍入设置(第 10 位和第 11 位)将堆栈顶部的值转换为整数。fisttp 指令始终使用截断模式进行转换。与 fild 指令类似,fist、fistp 和 fisttp 指令不允许你指定 80x86 的 16 位或 32 位通用寄存器作为目标操作数。
fist 指令将堆栈顶部的值转换为整数,并存储结果;它不会对浮点寄存器堆栈产生其他影响。fistp 和 fisttp 指令在存储转换后的值后,会将值从浮点寄存器堆栈中弹出。
如果浮点寄存器堆栈为空,这些指令会设置堆栈异常位(这也会清除 C[1])。如果发生四舍五入(即,如果 ST0 中的值有任何小数部分),它们会设置精度(不精确操作)和 C[1] 位。如果结果太小(即小于 1 但大于 0,或小于 0 但大于 −1),这些指令会设置下溢异常位。以下是一些示例:
fist( *`word_var`*[ ebx*2 ] );
fist( *`qword_var`* );
fisttp( *`dword_var`* );
fistp( *`dword_var`* );
别忘了,fist 和 fistp 指令使用舍入控制设置来决定它们如何在存储操作期间将浮点数据转换为整数。默认情况下,舍入控制通常设置为“舍入”模式;然而,大多数程序员希望 fist/fistp 在转换时截断小数部分。如果你希望 fist/fistp 在将浮点值转换为整数时截断,你需要在浮点控制寄存器中适当地设置舍入控制位(或者使用 fisttp 指令,不论舍入控制位如何,都将结果截断)。这是一个例子:
static
fcw16: word;
fcw16_2: word;
IntResult: int32;
.
.
.
fstcw( fcw16 );
mov( fcw16, ax );
or( $0c00, ax ); // Rounding control=%11 (truncate).
mov( ax, fcw16_2 ); // Store into memory and reload the ctrl word.
fldcw( fcw16_2 );
fistp( IntResult ); // Truncate ST0 and store as int32 object.
fldcw( fcw16 ); // Restore original rounding control.
6.5.5.3 fbld 和 fbstp 指令
fbld 和 fbstp 指令加载和存储 80 位 BCD 值。fbld 指令将 BCD 值转换为其 80 位扩展精度等效值,并将结果压入堆栈。fbstp 指令弹出堆栈顶部的扩展精度实数值,将其转换为 80 位 BCD 值(根据浮点控制寄存器中的位进行舍入),并将转换后的结果存储在目标内存操作数指定的地址中。注意,fbst 指令并不存在。
fbld 指令在堆栈溢出时设置堆栈异常位和 C[1]。如果尝试加载无效的 BCD 值,它会设置无效操作位。fbstp 指令在堆栈下溢(堆栈为空)时设置堆栈异常位并清除 C[1]。它在与 fist 和 fistp 相同的条件下设置下溢标志。看看这些例子:
// Assuming fewer than 8 items on the stack, the following
// code sequence is equivalent to an fbst instruction:
fld( st0 );
fbstp( *`tbyte_var`* );
// The following example easily converts an 80-bit BCD value to
// a 64-bit integer:
fbld( *`tbyte_var`* );
fist( *`qword_var`* );
这两条指令特别适用于在字符串格式和浮点格式之间转换。有关更多细节,请参见 HLA 标准库中的浮点到字符串和字符串到浮点转换例程。
6.5.6 算术指令
算术指令构成了 FPU 指令集中的一小部分,但却是重要的子集。这些指令大致分为两类:一类作用于实数值,另一类作用于实数和整数值。
6.5.6.1 fadd 和 faddp 指令
这两条指令有以下几种形式:
fadd()
faddp()
fadd( st0, st*`i`* );
fadd( st*`i`*, st0 );
faddp( st0, st*`i`* );
fadd( *`mem_32_64`* );
fadd( *`real_constant`* );
fadd 指令没有操作数时,将 ST0 中的值加到 ST1 中的值,并将结果存储到 ST1 中。faddp 指令(没有操作数)弹出堆栈顶部的两个值,将它们相加,并将它们的和重新压入堆栈。
fadd 指令的接下来的两种形式,具有两个 FPU 寄存器操作数,行为类似于 80x86 的 add 指令。它们将源寄存器操作数中的值加到目标寄存器操作数中的值。注意,其中一个寄存器操作数必须是 ST0。
faddp 指令带有两个操作数,它将 ST0(必须始终是源操作数)加到目标操作数中,然后弹出 ST0。目标操作数必须是其他 FPU 寄存器之一。
上面最后的形式,带内存操作数的 fadd,将一个 32 位或 64 位浮点变量加到 ST0 中的值。这条指令在执行加法之前会将 32 位或 64 位操作数转换为 80 位扩展精度值。请注意,这条指令不允许 80 位内存操作数。
这些指令可以根据需要触发堆栈、精度、下溢、上溢、非标准化和非法操作异常。如果发生堆栈故障异常,C[1] 表示堆栈溢出或下溢。
类似于 fld( real_constant ),fadd( real_constant ) 指令是 HLA 扩展。注意,它创建一个 64 位变量来保存常数值,并发出 fadd( mem64 ) 指令,指定它在常数段中创建的只读对象。
6.5.6.2 fsub、fsubp、fsubr 和 fsurpb 指令
这四条指令采取以下形式:
fsub()
fsubp()
fsubr()
fsubrp()
fsub( st0, st*`i`* )
fsub( st*`i`*, st0 );
fsubp( st0, st*`i`* );
fsub( *`mem_32_64`* );
fsub( *`real_constant`* );
fsubr( st0, st*`i`* )
fsubr( st*`i`*, st0 );
fsubrp( st0, st*`i`* );
fsubr( *`mem_32_64`* );
fsubr( *`real_constant`* );
fsub 指令在没有操作数的情况下将 ST0 从 ST1 中减去,并将结果保留在 ST1 中。没有操作数时,fsubp 指令从寄存器堆栈中弹出 ST0 和 ST1,计算 st1 - st0,然后将差值推回堆栈。fsubr 和 fsubrp 指令(反向减法)几乎以相同的方式工作,唯一的区别是它们计算 st0 - st1。
当有两个寄存器操作数(source,destination)时,fsub 指令计算 destination := destination - source。这两个寄存器之一必须是 ST0。对于有两个寄存器作为操作数的 fsubp,它也计算 destination := destination - source,并在计算差值后将 ST0 从堆栈中弹出。对于 fsubp 指令,源操作数必须是 ST0。
当有两个寄存器操作数时,fsubr 和 fsubrp 指令的工作方式与 fsub 和 fsubp 类似,唯一的区别是它们计算 destination := source - destination。
fsub( mem ) 和 fsubr( mem ) 指令接受 32 位或 64 位内存操作数。它们将内存操作数转换为 80 位扩展精度值,并将其从 ST0 中减去(fsub),或者将 ST0 从该值中减去(fsubr),并将结果存储回 ST0 中。
这些指令可以根据需要触发堆栈、精度、下溢、上溢、非标准化和非法操作异常。如果发生堆栈故障异常,C[1] 表示堆栈溢出或下溢。
注意
具有真实常数作为操作数的指令不是严格的 FPU 指令。这些是 HLA 提供的扩展。HLA 生成一个常数段内存对象,并用常数的值初始化它。
6.5.6.3 fmul 和 fmulp 指令
fmul 和 fmulp 指令用于相乘两个浮点值。这些指令允许以下形式:
fmul()
fmulp()
fmul( st*`i`*, st0 );
fmul( st0, st*`i`* );
fmul( *`mem_32_64`* );
fmul( *`real_constant`* );
fmulp( st0, st*`i`* );
如果没有操作数,fmul 将计算 st0 * st1 并将乘积存储到 ST1 中。没有操作数的 fmulp 指令会弹出 ST0 和 ST1,将这两个值相乘,并将乘积重新压入栈中。带有两个寄存器操作数的 fmul 指令会计算 destination := destination * source。其中一个寄存器(源寄存器或目标寄存器)必须是 ST0。
fmulp( st0, sti ) 指令计算 sti := sti * st0,然后弹出 ST0。该指令在弹出 ST0 之前使用 STi 的值。fmul( mem ) 指令需要一个 32 位或 64 位的内存操作数。它将指定的内存变量转换为 80 位扩展精度值,然后将 ST0 与该值相乘。
这些指令可以根据情况引发栈、精度、下溢、上溢、非正规、以及非法操作异常。如果在计算过程中发生了四舍五入,这些指令会设置 C[1] 条件码位。如果发生栈故障异常,C[1] 表示栈溢出或下溢。
注释
操作数为实常数的指令不是严格意义上的 FPU 指令。它是由 HLA 提供的扩展(有关详细信息,请参阅 6.5.6.2 fsub, fsubp, fsubr 和 fsurpb 指令 末尾的注释)。
6.5.6.4 fdiv, fdivp, fdivr 和 fdivrp 指令
这四条指令支持以下形式:
fdiv()
fdivp()
fdivr()
fdivrp()
fdiv( st*`i`*, st0 );
fdiv( st0, st*`i`* );
fdivp( st0, st*`i`* );
fdivr( st*`i`*, st0 );
fdivr( st0, st*`i`* );
fdivrp( st0, st*`i`* );
fdiv( *`mem_32_64`* );
fdivr( *`mem_32_64`* );
fdiv( *`real_constant`* );
fdivr( *`real_constant`* );
如果没有操作数,fdivp 指令会弹出 ST0 和 ST1,计算 st1/st0,并将结果重新压入栈中。没有操作数的 fdiv 指令会计算 st1 := st1/st0。fdivr 和 fdivrp 指令与 fdiv 和 fdivp 的工作方式相似,只是它们计算的是 st0/st1,而不是 st1/st0。
如果有两个寄存器操作数,这些指令会计算以下商:
fdiv( st*`i`*, st0 ); // st0 := st0/st*`i`*
fdiv( st0, st*`i`* ); // st*`i`* := st*`i`*/st0
fdivp( st0, st*`i`* ); // st*`i`* := st*`i`*/st0 then pop st0
fdivr( st0, st*`i`* ); // st0 := st0/st*`i`*
fdivrp( st0, st*`i`* ); // st*`i`* := st0/st*`i`* then pop st0
fdivp 和 fdivrp 指令在执行除法操作后也会弹出 ST0。这两个指令中的 i 的值在弹出 ST0 之前就已计算完毕。
这些指令可以根据情况引发栈、精度、下溢、上溢、非正规、零除以及非法操作异常。如果在计算过程中发生了四舍五入,这些指令会设置 C[1] 条件码位。如果发生栈故障异常,C[1] 表示栈溢出或下溢。
请注意,操作数为实常数的指令不是严格意义上的 FPU 指令。这些是由 HLA 提供的扩展。
6.5.6.5 fsqrt 指令
fsqrt 例程不允许任何操作数。它计算栈顶值(TOS)的平方根,并用该结果替换 ST0。TOS 上的值必须为 0 或正数;否则,fsqrt 会生成非法操作异常。
此指令可以根据需要触发堆栈、精度、非规范化和无效操作异常。如果在计算过程中发生舍入,fsqrt 会设置 C[1] 条件码位。如果发生堆栈故障异常,C[1] 表示堆栈溢出或下溢。
以下是一个示例:
// Compute z := sqrt(x**2 + y**2);
fld( x ); // Load x.
fld( st0 ); // Duplicate x on TOS.
fmulp(); // Compute x**2.
fld( y ); // Load y.
fld( st0 ); // Duplicate y.
fmul(); // Compute y**2.
faddp(); // Compute x**2 + y**2.
fsqrt(); // Compute sqrt( x**2 + y**2 ).
fstp( z ); // Store result away into z.
6.5.6.6 fprem 和 fprem1 指令
fprem 和 fprem1 指令计算 部分余数。英特尔在 IEEE 最终确定浮点标准之前设计了 fprem 指令。在 IEEE 浮点标准的最终草案中,fprem 的定义与英特尔原始设计略有不同。不幸的是,英特尔需要与现有使用 fprem 指令的软件保持兼容,因此设计了一个新版本来处理 IEEE 部分余数操作,即 fprem1。在新的软件中,您应始终使用 fprem1;因此,我们这里只讨论 fprem1,尽管您以相同的方式使用 fprem。
fprem1 计算 st0/st1 的 部分 余数。如果 ST0 和 ST1 的指数差小于 64,fprem1 可以在一次操作中计算出精确的余数。否则,您将需要执行两次或更多次 fprem1 来获取正确的余数值。C[2] 条件码位决定了计算何时完成。请注意,fprem1 并不会将两个操作数从堆栈中弹出;它将部分余数保留在 ST0 中,原始除数保留在 ST1 中,以防您需要计算另一个部分积来完成结果。
fprem1 指令如果堆栈顶部没有两个值,则会设置堆栈异常标志。如果结果太小,它会设置下溢和非规范化异常位。如果 TOS 上的值不适合该操作,它会设置无效操作位。如果部分余数操作未完成,它会设置 C[2] 条件码位。最后,它会将 C[3]、C[1] 和 C[0] 分别加载为商的第 0、1 和 2 位。
以下是一个示例:
// Compute z := x mod y
fld( y );
fld( x );
repeat
fprem1();
fstsw( ax ); // Get condition code bits into ax.
and( 1, ah ); // See if C2 is set.
until( @z ); // Repeat until C2 is clear.
fstp( z ); // Store away the remainder.
fstp( st0 ); // Pop old y value.
6.5.6.7 frndint 指令
frndint 指令使用控制寄存器中指定的舍入算法,将堆栈顶部(TOS)值舍入到最接近的整数。
如果 TOS 上没有值,此指令将设置堆栈异常标志(此时它还会清除 C[1])。如果出现精度丧失,它会设置精度和非规范化异常位。如果 TOS 上的值不是有效数字,它会设置无效操作标志。请注意,TOS 上的结果仍然是浮点值;它只是没有小数部分。
6.5.6.8 fabs 指令
fabs 通过清除 ST0 的尾数符号位来计算 ST0 的绝对值。如果堆栈为空,它会设置堆栈异常位和无效操作位。
以下是一个示例:
// Compute x := sqrt(abs(x));
fld( x );
fabs();
fsqrt();
fstp( x );
6.5.6.9 fchs 指令
fchs 通过反转 ST0 值的尾数符号位(即这是浮点数取反指令)来改变 ST0 值的符号。如果堆栈为空,它将设置堆栈异常位和无效操作位。
看这个例子:
// Compute x := -x if x is positive, x := x if x is negative.
// That is, force x to be a negative value.
fld( x );
fabs();
fchs();
fstp( x );
6.5.7 比较指令
FPU 提供了几条指令用于比较实数值。fcom、fcomp 和 fcompp 指令比较堆栈顶端的两个值,并相应地设置条件码。ftst 指令将堆栈顶端的值与 0 进行比较。
通常,大多数程序在比较后立即测试条件码位。不幸的是,没有 FPU 指令可以测试 FPU 条件码。相反,你需要使用 fstsw 指令将浮点状态寄存器复制到 AX 寄存器;然后可以使用 sahf 指令将 AH 寄存器复制到 80x86 的条件码位。完成此操作后,你可以测试标准的 80x86 标志以检查某些条件。此技巧将 C[0] 复制到进位标志,将 C[2] 复制到奇偶标志,将 C[3] 复制到零标志。sahf 指令不会将 C[1] 复制到任何 80x86 标志位。
由于 sahf 指令不会将任何 FPU 状态位复制到符号标志或溢出标志,因此不能使用带符号比较指令。相反,在测试浮点比较结果时,应使用无符号操作(例如 seta、setb)。是的,这些指令通常测试无符号值,而浮点数是带符号值。然而,仍然应使用无符号操作;fstsw 和 sahf 指令将 80x86 标志寄存器设置为仿佛你已经使用 cmp 指令比较了无符号值。
Pentium II 及其兼容的处理器提供了一组额外的浮点比较指令,直接影响 80x86 的条件码标志。这些指令避免了使用 fstsw 和 sahf 将 FPU 状态复制到 80x86 条件码的过程。这些指令包括 fcomi 和 fcomip。使用它们的方法与 fcom 和 fcomp 指令相同,当然,不需要手动将状态位复制到 FLAGS 寄存器。
6.5.7.1 fcom、fcomp 和 fcompp 指令
fcom、fcomp 和 fcompp 指令将 ST0 与指定的操作数进行比较,并根据比较结果设置相应的 FPU 状态码位。这些指令的合法形式如下:
fcom()
fcomp()
fcompp()
fcom( st*`i`* )
fcomp( st*`i`* )
fcom( *`mem_32_64`* )
fcomp( *`mem_32_64`* )
fcom( *`real_constant`* )
fcomp( *`real_constant`* )
在没有操作数的情况下,fcom、fcomp 和 fcompp 将 ST0 与 ST1 进行比较,并相应地设置 FPU 标志。此外,fcomp 会将 ST0 弹出堆栈,而 fcompp 会将 ST0 和 ST1 都从堆栈中弹出。
在单寄存器操作数的情况下,fcom 和 fcomp 将 ST0 与指定的寄存器进行比较。fcomp 在比较后也会弹出 ST0。
对于 32 位或 64 位内存操作数,fcom和fcomp指令将内存变量转换为 80 位扩展精度值,然后将 ST0 与此值进行比较,并相应地设置条件码位。fcomp在比较后还会弹出 ST0。
如果两个操作数不可比较(例如,NaN),这些指令会设置 C[2](它最终会影响到奇偶标志)。如果在比较中可能出现非法的浮点值,应该在检查期望条件之前,先检查奇偶标志以确认是否有错误(例如,使用 HLA 的@p和@np条件,或者使用setp/setnp指令)。
如果寄存器栈顶部没有两个项,这些指令会设置堆栈故障位。如果操作数之一或两者是非正规化数,它们会设置非正规化异常位。如果操作数之一或两者是安静的NaN,它们会设置无效操作标志。这些指令总是清除 C[1]条件码。
注意,具有实数常量作为操作数的指令并不是真正的 FPU 指令。这些是 HLA 提供的扩展。当 HLA 遇到这样的指令时,它会在常量段创建一个real64只读变量,并将此变量初始化为指定的常量。然后,HLA 将指令翻译为指定real64内存操作数的指令。
注意
由于精度差异(64 位与 80 位),如果在浮点指令中使用常量操作数,你可能不会得到与预期相符的精确结果。
让我们来看一个浮点比较的示例:
fcompp();
fstsw( ax );
sahf();
setb( al ); // al = true if st1 < st0.
.
.
.
注意,你不能在 HLA 运行时的布尔表达式中比较浮点值(例如,在if语句中)。然而,在浮点比较之后,你可以在这些语句中测试条件,就像上面的序列一样。例如:
fcompp();
fstsw( ax );
sahf();
if( @b ) then
<< Code that executes if st1 < st0 >>
endif;
6.5.7.2 fcomi和fcomip指令
fcomi和fcomip指令将 ST0 与指定的操作数进行比较,并根据比较结果设置相应的 EFLAG 条件码位。你可以像使用fcom和fcomp一样使用这些指令,只是执行这些指令后,你可以直接测试 CPU 的标志位,而无需先将 FPU 状态位移动到 EFLAGS 寄存器。这些指令的合法形式如下:
fcomi()
fcomip()
fcomi( st*`i`* )
fcomip( st*`i`* )
fcomi( *`mem_32_64`* )
fcomip( *`mem_32_64`* )
fcomi( *`real_constant`* )
fcomip( *`real_constant`* )
6.5.7.3 ftst 指令
ftst指令将 ST0 中的值与 0.0 进行比较。它的行为就像fcom指令,如果 ST1 中包含 0.0。注意,这个指令不会区分−0.0 和+0.0。如果 ST0 中的值是这两个值之一,ftst将设置 C[3]来表示相等。这个指令不会弹出 ST0。
这是一个示例:
ftst();
fstsw( ax );
sahf();
sete( al ); // Set al to 1 if TOS = 0.0
6.5.8 常量指令
FPU 提供了几个指令,允许你将常用的常数加载到 FPU 的寄存器堆栈中。如果发生堆栈溢出,这些指令会设置堆栈故障、无效操作和 C[1] 标志;否则,它们不会影响 FPU 标志。此类别中的特定指令包括以下内容:
fldz() // Pushes +0.0.
fld1() // Pushes +1.0.
fldpi() // Pushes pi.
fldl2t() // Pushes log2(10).
fldl2e() // Pushes log2(e).
fldlg2() // Pushes log10(2).
fldln2() // Pushes ln(2).
6.5.9 超越函数指令
FPU 提供了八个超越函数(对数和三角函数)指令,用于计算正弦、余弦、部分正切、部分反正切、2x - 1、y * log2 和 y * log2。通过使用各种代数恒等式,可以方便地使用这些指令计算其他常见的超越函数。
6.5.9.1 f2xm1 指令
f2xm1 计算 2^(ST0) - 1。ST0 中的值必须在 −1.0 到 ST0 到 +1.0 的范围内。如果 ST0 超出此范围,f2xm1 会生成未定义的结果,但不会引发异常。计算得到的值会替换 ST0 中的值。
这是一个示例,使用恒等式 10^(x) = 2^(x log2(10)) 来计算 10^(x*)。这仅对 x 在一个小范围内有效,避免 ST0 超出前述有效范围。
fld( x );
fldl2t();
fmul();
f2xm1();
fld1();
fadd();
请注意,f2xm1 计算的是 2x - 1,这就是为什么上述代码在计算结束时要加 1.0。
6.5.9.2 fsin、fcos 和 fsincos 指令
这些指令将栈顶的值弹出,计算正弦、余弦或两者,然后将结果推回栈中。fsincos 指令首先推送原操作数的正弦值,然后推送余弦值;因此,它将 cos(ST0) 保存在 ST0 中,并将 sin(ST0) 保存在 ST1 中。
这些指令假定 ST0 指定的是弧度角度,并且该角度必须在 −2⁶³ < ST0 < +2⁶³ 的范围内。如果原操作数超出此范围,这些指令会设置 C[2] 标志,并保持 ST0 不变。你可以使用 fprem1 指令,将除数设为 2π,来将操作数限制在合理的范围内。
这些指令会根据计算结果设置堆栈故障/C[1]、精度、下溢、非标准化和无效操作标志。
6.5.9.3 fptan 指令
fptan 计算 ST0 的正切并将该值推送到栈上,然后将 1.0 推送到栈上。与 fsin 和 fcos 指令类似,ST0 的值必须为弧度,并且在 −2⁶³ < ST0 < +2⁶³ 的范围内。如果值超出此范围,fptan 会设置 C[2] 标志,表示转换未发生。与 fsin、fcos 和 fsincos 指令一样,你可以使用 fprem1 指令,使用 2π 作为除数,将操作数限制在合理范围内。
如果参数无效(即为零或 π 弧度,导致除以 0),则结果是未定义的,并且该指令不会引发异常。fptan 会根据操作要求设置堆栈故障、精度、下溢、非标准化、无效操作、C[2] 和 C[1] 位。
6.5.9.4 fpatan 指令
此指令期望栈顶有两个值。它将它们弹出并计算 ST0 = tan^(−1)(ST1/ST0)。
结果值是栈上比值的反正切,单位为弧度。如果你有一个值需要计算其正切,可以使用 fld1 创建适当的比值,然后执行 fpatan 指令。
此指令会影响栈故障/C[1]、精度、下溢、非正规化和无效操作位,如果计算过程中出现问题。如果必须对结果进行舍入,它会设置 C[1] 条件码位。
6.5.9.5 fyl2x 指令
此指令期望 FPU 栈中有两个操作数:y 位于 ST1,x 位于 ST0。此函数计算 ST0 = ST1 * log2。
此指令没有操作数(对指令本身而言)。此指令使用以下语法:
fyl2x();
注意,此指令计算的是以 2 为底的对数。当然,通过乘以适当的常数,计算其他底数的对数是非常简单的。
6.5.9.6 fyl2xp1 指令
此指令期望 FPU 栈中有两个操作数:y 位于 ST1,x 位于 ST0。此函数计算 ST0 = ST1 * log2。
此指令的语法如下:
fyl2xp1();
否则,此指令与 fyl2x 相同。
6.5.10 杂项指令
FPU 包含若干额外的指令,用于控制 FPU、同步操作,并让你测试或设置各种状态位。这些指令包括 finit/fninit、fldcw、fstcw、fclex/fnclex 和 fstsw。
6.5.10.1 finit 和 fninit 指令
finit 指令初始化 FPU 以确保正确操作。您的应用程序应该在执行其他任何 FPU 指令之前执行此指令。此指令将控制寄存器初始化为 $37F,状态寄存器初始化为 0,标签字初始化为 $FFFF。其他寄存器不受影响。
这里有一些示例:
finit();
fninit();
finit 和 fninit 的区别在于,finit 在初始化 FPU 之前会检查是否有任何待处理的浮点异常;而 fninit 不会。
6.5.10.2 fldcw 和 fstcw 指令
fldcw 和 fstcw 指令需要一个 16 位内存操作数:
fldcw( *`mem16`* );
fstcw( *`mem16`* );
这两个指令分别从内存位置加载控制寄存器(fldcw)或将控制字存储到 16 位内存位置(fstcw)。
当使用 fldcw 指令开启某个异常时,如果在启用该异常时相应的异常标志已设置,FPU 会在 CPU 执行下一个指令之前生成一个即时中断。因此,在更改 FPU 异常使能位之前,应该使用 fclex 指令清除任何待处理的中断。
6.5.10.3 fclex 和 fnclex 指令
fclex 和 fnclex 指令清除所有异常位、栈故障位以及 FPU 状态寄存器中的忙碌标志。
这里有一些示例:
fclex();
fnclex();
这些指令之间的区别与 finit 和 fninit 之间的区别相同。
6.5.10.4 fstsw 和 fnstsw 指令
这些指令将 FPU 状态寄存器存储到一个 16 位内存位置或 AX 寄存器中。
fstsw( ax );
fnstsw( ax );
fstsw( *`mem16`* );
fnstsw( *`mem16`* );
这些指令不同寻常,因为它们可以将 FPU 值复制到其中一个 80x86 通用寄存器(具体是 AX)。当然,允许将状态寄存器传输到 AX 的整个目的,是为了让 CPU 能够轻松通过 sahf 指令测试条件码寄存器。fstsw 和 fnstsw 之间的区别与 fclex 和 fnclex 之间的区别相同。
6.5.11 整数操作
FPU 提供了特殊指令,结合了整数到扩展精度转换以及各种算术和比较操作。以下是这些指令:
fiadd( *`int_16_32`* );
fisub( *`int_16_32`* );
fisubr( *`int_16_32`* );
fimul( *`int_16_32`* );
fidiv( *`int_16_32`* );
fidivr( *`int_16_32`* );
ficom( *`int_16_32`* );
ficomp( *`int_16_32`* );
这些指令将它们的 16 位或 32 位整数操作数转换为 80 位扩展精度浮点值,然后将该值作为指定操作的源操作数。这些指令使用 ST0 作为目标操作数。
^([102]) 英特尔还将该设备称为数字数据处理器(NDP)、数字处理器扩展(NPX)以及数学协处理器。
^([103]) 将一个非标准化值存储到 32 位或 64 位内存变量中将始终设置下溢异常位。
6.6 将浮点表达式转换为汇编语言
由于 FPU 寄存器组织与 80x86 整数寄存器集不同,涉及浮点操作数的算术表达式翻译与整数表达式的翻译技巧有所不同。因此,花一些时间讨论如何手动将浮点表达式翻译为汇编语言是很有意义的。
从某个方面来说,将浮点表达式翻译成汇编语言其实更容易。英特尔 FPU 的堆栈架构简化了将算术表达式翻译为汇编语言的过程。如果你曾使用过惠普计算器,你会发现自己很容易上手 FPU,因为像惠普计算器一样,FPU 使用后缀表示法(也叫做逆波兰表示法,或 RPN)进行算术操作。一旦习惯了使用后缀表示法,翻译表达式实际上更方便,因为你无需担心分配临时变量——它们总是会出现在 FPU 堆栈上。
后缀表示法与标准的 中缀表示法 相对,将操作数放在运算符之前。以下示例展示了中缀表示法和对应的后缀表示法的一些简单例子:
infix notation postfix notation
5 + 6 5 6 +
7 − 2 7 2 −
x * y x y *
a / b a b /
像 5 6 + 这样的后缀表达式表示:“将 5 推送到堆栈,将 6 推送到堆栈,然后从堆栈顶部弹出值(6),并将其加到新的堆栈顶部。”听起来熟悉吗?这正是 fld 和 fadd 指令所做的。事实上,你可以使用以下代码来计算这个:
fld( 5.0 );
fld( 6.0 );
fadd(); // 11.0 is now on the top of the FPU stack.
如你所见,后缀表示法是一种方便的符号,因为它非常容易将此代码转换为 FPU 指令。
后缀表示法的一个优点是它不需要括号。以下示例演示了几个稍微复杂的中缀到后缀的转换:
infix notation postfix notation
(x + y) * 2 x y + 2 *
x * 2 − (a + b) x 2 * a b + −
(a + b) * (c + d) a b + c d + *
后缀表达式x y + 2 *的意思是:“先推送x,然后推送y;接下来,将栈中的这两个值相加(生成x + y)。接着,推送 2,并将栈中的两个值(2 和x + y)相乘,得到2 * (x + y)。”同样,我们可以直接将这些后缀表达式转换为汇编语言。以下代码展示了上述每个表达式的转换:
// x y + 2 *
fld( x );
fld( y );
fadd();
fld( 2.0 );
fmul();
// x 2 * a b + −
fld( x );
fld( 2.0 );
fmul();
fld( a );
fld( b );
fadd();
fsub();
// a b + c d + *
fld( a );
fld( b );
fadd();
fld( c );
fld( d );
fadd();
fmul();
6.6.1 将算术表达式转换为后缀表示法
由于将算术表达式转换为汇编语言涉及后缀表示法(RPN),将算术表达式转换为后缀表示法似乎是我们讨论浮动点表达式转换的一个不错的起点。本节将集中讨论后缀转换。
对于简单的表达式,涉及两个操作数和一个单一操作符的转换是很简单的。只需将操作符从中缀位置移到后缀位置(即,将操作符从两个操作数之间移到第二个操作数之后)。例如,5 + 6变为5 6 +。除了将操作数分开以避免混淆(即,它是 5 和 6 还是 56?),将简单的中缀表达式转换为后缀表示法是直接的。
对于复杂表达式,思路是将简单的子表达式转换为后缀表示法,然后将每个转换后的子表达式视为剩余表达式中的一个单独操作数。以下讨论围绕已完成的转换,并使用方括号使得很容易看出哪些文本需要作为单一操作数进行转换。
至于整数表达式转换,最好的做法是从最内层的括号子表达式开始,然后向外处理,考虑优先级、结合性以及其他括号子表达式。作为一个具体的示例,考虑以下表达式:
x = ((y - z) * a) - ( a + b * c ) / 3.14159
一个可能的首次转换是将子表达式(y - z)转换为后缀表示法:
x = ([y z -] * a) - ( a + b * c ) / 3.14159
方括号包围着转换后的后缀代码,以将其与中缀代码区分开。这些方括号仅用于使部分转换更易于阅读。请记住,在转换过程中,我们将方括号内的文本视为单一的操作数。因此,您应将[y z -]视为一个单独的变量名或常量。
下一步是将子表达式([y z -] * a )转换为后缀形式。结果如下:
x = [y z - a *] - ( a + b * c ) / 3.14159
接下来,我们处理括号表达式(a + b * c)。由于乘法的优先级高于加法,我们首先转换b * c:
x = [y z - a *] - ( a + [b c *]) / 3.14159
在转换b * c后,我们完成了括号表达式:
x = [y z - a *] - [a b c * +] / 3.14159
这只剩下两个中缀运算符:减法和除法。因为除法优先级更高,我们首先转换除法:
x = [y z - a *] - [a b c * + 3.14159 /]
最后,我们通过处理最后一个中缀运算(减法)将整个表达式转换为后缀表示法:
x = [y z - a *] [a b c * + 3.14159 /] -
去掉方括号,得到真正的后缀表示法,得到如下后缀表达式:
x = y z - a * a b c * + 3.14159 / -
以下步骤展示了另一个中缀到后缀的转换过程:
a = (x * y - z + t) / 2.0
-
在括号内进行操作。由于乘法具有最高优先级,先转换乘法:
a = ( [x y *] - z + t) / 2.0 -
仍然在括号内工作,我们注意到加法和减法具有相同的优先级,因此我们依赖结合性来决定下一步该做什么。这些运算符是左结合的,因此我们必须按照从左到右的顺序翻译表达式。这意味着首先翻译减法运算符:
a = ( [x y * z -] + t) / 2.0 -
现在翻译括号内的加法运算符。因为这完成了括号内的运算符,我们可以去掉括号:
a = [x y * z - t +] / 2.0 -
转换最后的中缀运算符(除法)。得到如下结果:
a = [x y * z - t + 2.0 / ] -
去掉方括号,完成:
a = x y * z - t + 2.0 /
6.6.2 后缀表示法转换为汇编语言
一旦你将算术表达式转换为后缀表示法,完成到汇编语言的转换就变得很容易。你所要做的就是每次遇到操作数时发出 fld 指令,每次遇到运算符时发出相应的算术指令。本节使用上一节的完整示例来展示这个过程是多么简单。
x = y z - a * a b c * + 3.14159 / -
-
将
y转换为fld(y)。 -
将
z转换为fld(z)。 -
将
-转换为fsub()。 -
将
a转换为fld(a)。 -
将
*转换为fmul()。 -
按照从左到右的顺序继续,生成以下表达式的代码:
fld( y ); fld( z ); fsub(); fld( a ); fmul(); fld( a ); fld( b ); fld( c ); fmul(); fadd(); fldpi(); // Loads pi (3.14159) fdiv(); fsub(); fstp( x ); // Store result away into x.这是上一节中第二个示例的翻译:
a = x y * z - t + 2.0 / fld( x ); fld( y ); fmul(); fld( z ); fsub(); fld( t ); fadd(); fld( 2.0 ); fdiv(); fstp( a ); // Store result away into a.
正如你所看到的,一旦你将中缀表示法转换为后缀表示法,翻译过程就相当简单了。还要注意,与整数表达式转换不同,浮点表达式不需要显式的临时变量。事实证明,FPU 栈为你提供了临时变量。^([104]) 基于这些原因,浮点表达式转换为汇编语言实际上比整数表达式更简单。
^([104]) 当然,这假设你的计算并不复杂到超出了 FPU 栈的八元素限制。
6.7 HLA 标准库对浮点算术的支持
第二章简要提到了stdin.getf函数。该讨论中遗漏的是,stdin.getf函数从标准输入读取浮点值并返回。现在你已经了解了 80x86 的浮点扩展,接下来可以完成对该标准库函数的讨论。stdin.getf函数从标准输入读取一串字符,将这些字符转换为一个 80 位的浮点数,并将结果放置在 FPU 堆栈上(在 ST0 寄存器中)。
HLA 标准库还提供了math.hhf模块,其中包括多个 FPU 不直接支持的数学函数,以及对 FPU 部分支持的各种函数(如正弦和余弦)的支持。math.hhf模块提供的一些函数包括acos、acot、acsc、asec、asin、cot、csc、sec、2^x、10^x、y^x、e^x、log和ln。有关这些函数和 HLA 标准库支持的其他数学函数的更多信息,请参阅 HLA 标准库文档。
6.8 更多信息
Intel/AMD 处理器手册全面描述了每个整数和浮点算术指令的操作,包括这些指令如何影响 EFLAGS 和 FPU 状态寄存器中的条件码位和其他标志的详细描述。为了编写最优的汇编语言代码,你需要深入了解算术指令如何影响执行环境,因此花时间研究 Intel/AMD 手册是一个不错的选择。
HLA 标准库提供了大量的浮点函数,这些函数没有单独的机器指令。HLA 标准库还提供了像math.sin和math.cos这样的函数,克服了本机机器指令的局限性。有关更多细节,请参见 HLA 标准库参考手册。此外,HLA 标准库也以源代码形式提供,因此你可以查看这些数学函数的实现,获得更多浮点编码的示例。
第八章讨论了高精度整数算术。有关处理大于 32 位大小的整数操作数的详细信息,请参见该章节。
80x86 SSE 指令集出现在后期的 CPU 型号中,支持使用 SSE 寄存器集进行浮点运算。有关 SSE 浮点指令集的详细信息,请参考webster.cs.ucr.edu/或 Intel/AMD 的文档。
第七章 低级控制结构

本章讨论“纯”汇编语言控制语句。在你声称自己是汇编语言程序员之前,你需要掌握这些低级控制结构。完成本章后,你应该能够停止使用 HLA 的高级控制语句,并使用低级 80x86 机器指令来合成这些语句。
本章最后部分讨论了混合控制结构,它结合了 HLA 的高级控制语句和 80x86 控制指令的特点。这些控制结构将低级控制语句的力量和效率与高级控制语句的可读性结合起来。高级汇编程序员可能希望使用这些混合语句,在不牺牲效率的前提下提高程序的可读性。
7.1 低级控制结构
直到现在,你在程序中看到和使用的大多数控制结构都类似于高级语言(如 Pascal、C++和 Ada)中的控制结构。虽然这些控制结构使得学习汇编语言变得容易,但它们并不是实际的汇编语言语句。相反,HLA 编译器将这些控制结构转换为一系列“纯”机器指令,从而实现与高级控制结构相同的结果。本文通过使用高级控制结构,让你在不需要一次性学习所有内容的情况下学习汇编语言。然而,现在是时候放下这些高级控制结构,学习如何使用低级控制结构编写真正的汇编语言程序了。
7.2 语句标签
汇编语言的低级控制结构广泛使用标签来标识源代码中的位置。低级控制结构通常会在程序的两个点之间转移控制。你通常通过语句标签来指定这样的转移目标。语句标签由一个有效(唯一的)HLA 标识符和一个冒号组成。例如:
aLabel:
当然,对于过程、变量和常量标识符,你应该尽量选择描述性和有意义的名称作为标签。上面示例中的标识符aLabel几乎没有描述性或意义。
语句标签有一个重要的特点,使其区别于 HLA 中大多数其他标识符:你不需要在使用标签之前声明它。这一点很重要,因为低级控制结构通常需要将控制转移到代码中的某个位置;因此,当你引用标签时,它可能尚未被定义。
你可以对标签执行三种操作:通过跳转(goto)指令转移控制权到标签,使用 call 指令调用标签,并获取标签的地址。对于标签,你几乎不能做其他操作(当然,你也不会希望对标签做太多其他操作,所以这并不算限制)。示例 7-1 演示了在程序中获取标签地址并打印地址的两种方法(使用 lea 指令和使用 & 地址操作符):
示例 7-1. 在程序中显示语句标签的地址
program labelDemo;
#include( "stdlib.hhf" );
begin labelDemo;
lbl1:
lea( ebx, lbl1 );
mov( &lbl2, eax );
stdout.put( "&lbl1=$", ebx, " &lbl2=", eax, nl );
lbl2:
end labelDemo;
HLA 还允许你使用语句标签的地址初始化双字变量。然而,变量声明中的初始化部分对标签有一些限制。最重要的限制是,你必须在与变量声明相同的词法级别上定义语句标签。也就是说,如果你在主程序中引用变量声明的初始化器中的语句标签,则该语句标签也必须位于主程序中。相反,如果你在局部变量声明中获取语句标签的地址,则该符号必须出现在与局部变量相同的过程内。示例 7-2 演示了语句标签在变量初始化中的使用:
示例 7-2. 使用语句标签的地址初始化 dword 变量
program labelArrays;
#include( "stdlib.hhf" );
static
labels:dword[2] := [ &lbl1, &lbl2 ];
procedure hasLabels;
static
stmtLbls: dword[2] := [ &label1, &label2 ];
begin hasLabels;
label1:
stdout.put
(
"stmtLbls[0]= $", stmtLbls[0], nl,
"stmtLbls[1]= $", stmtLbls[4], nl
);
label2:
end hasLabels;
begin labelArrays;
hasLabels();
lbl1:
stdout.put( "labels[0]= $", labels[0], " labels[1]=", labels[4], nl );
lbl2:
end labelArrays;
有时,你需要引用当前过程外的标签。由于这种需求相对较少,本文不会描述所有细节。如果你需要做这种操作,请参考 HLA 文档了解更多细节。
7.3 无条件转移控制(jmp)
jmp(跳转)指令无条件地将控制权转移到程序中的另一个位置。该指令有三种形式:直接跳转和两种间接跳转。这些指令的形式如下:
jmp *`label`*;
jmp( *`reg32`* );
jmp( *`mem32`* );
第一条指令是一个直接跳转。对于直接跳转,你通常通过语句标签来指定目标地址。该标签要么与可执行机器指令在同一行,要么单独出现在可执行机器指令前的一行。直接跳转完全等同于高级语言中的 goto 语句。^([105])
下面是一个例子:
<< statements >>
jmp laterInPgm;
.
.
.
laterInPgm:
<< statements >>
前面提到的第二种 jmp 指令形式——jmp( reg32 );——是寄存器间接跳转指令。该指令将控制转移到指定的 32 位通用寄存器中出现的指令地址。要使用这种形式的 jmp 指令,必须在执行 jmp 之前将 32 位寄存器加载为某个机器指令的地址。你可以使用此指令通过在程序的不同位置将寄存器加载为某个标签的地址,并在一个公共点使用单一的间接跳转来实现 状态机。示例 7-3 演示了如何以这种方式使用 jmp 指令。
示例 7-3. 使用寄存器间接 jmp 指令
program regIndJmp;
#include( "stdlib.hhf" );
static
i:int32;
begin regIndJmp;
// Read an integer from the user and set ebx to
// denote the success or failure of the input.
try
stdout.put( "Enter an integer value between 1 and 10: " );
stdin.get( i );
mov( i, eax );
if( eax in 1..10 ) then
mov( &GoodInput, ebx );
else
mov( &valRange, ebx );
endif;
exception( ex.ConversionError )
mov( &convError, ebx );
exception( ex.ValueOutOfRange )
mov( &valRange, ebx );
endtry;
// Okay, transfer control to the appropriate
// section of the program that deals with
// the input.
jmp( ebx );
valRange:
stdout.put( "You entered a value outside the range 1..10" nl );
jmp Done;
convError:
stdout.put( "Your input contained illegal characters" nl );
jmp Done;
GoodInput:
stdout.put( "You entered the value ", i, nl );
Done:
end regIndJmp;
前面提到的第三种 jmp 指令形式是内存间接 jmp。这种形式的 jmp 指令从内存位置获取双字值并跳转到该地址。这类似于寄存器间接 jmp,只不过地址出现在内存位置,而不是寄存器中。示例 7-4 演示了这种 jmp 指令形式的一个相当简单的用法。
示例 7-4. 使用内存间接 jmp 指令
program memIndJmp;
#include( "stdlib.hhf" );
static
LabelPtr:dword := &stmtLabel;
begin memIndJmp;
stdout.put( "Before the JMP instruction" nl );
jmp( LabelPtr );
stdout.put( "This should not execute" nl );
stmtLabel:
stdout.put( "After the LabelPtr label in the program" nl );
end memIndJmp;
警告
与 HLA 高级控制结构不同,低级的jmp指令可能会给你带来很多麻烦。特别是,如果你没有用有效指令的地址初始化一个寄存器,并且通过该寄存器间接跳转,那么结果是未定义的(虽然这通常会导致一般保护故障)。类似地,如果你没有用合法指令的地址初始化一个双字变量,通过该内存位置间接跳转可能会导致程序崩溃。
^([105]) 与高级语言不同,在高级语言中,教师通常会禁止使用 goto 语句,而你会发现,在汇编语言中使用 jmp 指令是必不可少的。
7.4 条件跳转指令
尽管 jmp 指令提供了控制转移,但在做出决策时使用它并不方便,例如你需要实现 if 和 while 语句的场景。80x86 的条件跳转指令可以处理这一任务。
条件跳转会测试一个或多个 CPU 标志,查看它们是否匹配某个特定的模式。如果标志设置匹配条件,条件跳转指令会将控制转移到目标位置。如果匹配失败,CPU 会忽略条件跳转,并继续执行紧接着条件跳转之后的指令。一些条件跳转指令仅仅测试符号、进位、溢出和零标志的设置。例如,在执行 shl 指令后,你可以测试进位标志,以确定 shl 是否将一个 1 从操作数的高位移出。同样,执行 test 指令后,你可以测试零标志来检查结果是否为 0。然而,大多数时候,你可能会在执行 cmp 指令后执行条件跳转。cmp 指令会设置标志,从而让你可以测试小于、大于、相等等情况。
条件 jmp 指令的形式如下:
j*`cc label`*;
cc 在 jcc 中表示你必须替换为某个字符序列,指定要测试的条件类型。这些字符与 setcc 指令使用的字符相同。例如,js 表示如果符号标志被设置则 跳转。一个典型的 js 指令是:
js ValueIsNegative;
在这个例子中,如果符号标志当前被设置,js 指令将控制转移到 ValueIsNegative 标签;如果符号标志未设置,控制则跳转到 js 指令之后的下一条指令。
与无条件的 jmp 指令不同,条件跳转指令不提供间接形式。它们只允许跳转到程序中的一个标签。
注意
英特尔的文档为许多条件跳转指令定义了各种同义词或指令别名。
表 7-1, 表 7-2, 和 表 7-3 列出了特定指令的所有别名。这些表还列出了相反的跳转。你将很快看到相反跳转的用途。
表 7-1. jcc 指令,测试标志
| 指令 | 描述 | 条件 | 别名 | 相反 |
|---|---|---|---|---|
jc |
如果进位则跳转 | 进位 = 1 | jb, jnae |
jnc |
jnc |
如果没有进位则跳转 | 进位 = 0 | jnb, jae |
jc |
jz |
如果为零则跳转 | 零 = 1 | je |
jnz |
jnz |
如果不为零则跳转 | 零 = 0 | jne |
jz |
js |
如果符号则跳转 | 符号 = 1 | jns |
|
jns |
如果没有符号则跳转 | 符号 = 0 | js |
|
jo |
如果溢出则跳转 | 溢出 = 1 | jno |
|
jno |
如果没有溢出则跳转 | 溢出 = 0 | jo |
|
jp |
如果奇偶校验则跳转 | 奇偶校验 = 1 | jpe |
jnp |
jpe |
如果奇偶校验为偶 | 奇偶校验 = 1 | jp |
jpo |
jnp |
如果没有奇偶校验跳转 | 奇偶校验 = 0 | jpo |
jp |
jpo |
如果奇偶校验为奇数跳转 | 奇偶校验 = 0 | jnp |
jpe |
| 表 7-2. 用于无符号比较的jcc指令
| 指令 | 描述 | 条件 | 别名 | 相反 |
|---|---|---|---|---|
ja |
如果大于跳转 (>) |
进位 = 0, 零 = 0 | jnbe |
jna |
jnbe |
如果不小于或等于跳转 (not <=) |
进位 = 0, 零 = 0 | ja |
jbe |
jae |
如果大于或等于跳转 (>=) |
进位 = 0 | jnc, jnb |
jnae |
jnb |
如果不小于跳转 (not <) |
进位 = 0 | jnc, jae |
jb |
jb |
如果小于跳转 (<) |
进位 = 1 | jc, jnae |
jnb |
jnae |
如果不大于或等于跳转 (not >=) |
进位 = 1 | jc, jb |
jae |
jbe |
如果小于或等于跳转 (<=) |
进位 = 1 或零 = 1 | jna |
jnbe |
jna |
如果不大于跳转 (not >) |
进位 = 1 或零 = 1 | jbe |
ja |
je |
如果相等跳转 (=) |
零 = 1 | jz |
jne |
jne |
如果不相等跳转 (¦) |
零 = 0 | jnz |
je |
| 表 7-3. 用于有符号比较的jcc指令
| 指令 | 描述 | 条件 | 别名 | 相反 |
|---|---|---|---|---|
jg |
如果大于跳转 (>) |
符号 = 溢出或零 = 0 | jnle |
jng |
jnle |
如果不小于或等于跳转 (not <=) |
符号 = 溢出或零 = 0 | jg |
jle |
jge |
如果大于或等于跳转 (>=) |
符号 = 溢出 | jnl |
jge |
jnl |
如果不小于跳转 (not <) |
符号 = 溢出 | jge |
jl |
jl |
如果小于跳转 (<) |
符号 <> 溢出 | jnge |
jnl |
jnge |
如果不大于或等于跳转 (not >=) |
符号 <> 溢出 | jl |
jge |
jle |
如果小于或等于跳转 (<=) |
符号 <> 溢出或零 = 1 | jng |
jnle |
jng |
如果不大于跳转 (not >) |
符号 <> 溢出或零 = 1 | jle |
jg |
je |
如果相等跳转 (=) |
零 = 1 | jz |
jne |
jne |
如果不相等跳转 (¦) |
零 = 0 | jnz |
je |
| 关于“相反”列,有必要做一个简短的说明。在许多情况下,你需要能够生成特定分支指令的相反指令(后面会有示例)。除了两个例外外,一个非常简单的规则可以完全描述如何生成相反分支:
-
| 如果
jcc指令的第二个字母不是n,则在j后插入n。例如,je变为jne,jl变为jnl。 -
| 如果
jcc指令的第二个字母是n,则从指令中删除该n。例如,jng变为jg,jne变为je。
| 这两个例外是jpe(如果奇偶校验为偶数跳转)和jpo(如果奇偶校验为奇数跳转)。这两个例外几乎不会引起问题,因为(1)你几乎不需要测试奇偶校验标志,且(2)你可以使用别名jp和jnp作为jpe和jpo的同义词。“N/No N”规则适用于jp和jnp。
虽然你知道jge是jl的反操作,但养成使用jnl而不是jge作为jl的对立跳转指令的习惯。在关键情况下,容易误认为“更大是小的对立面”,并错误地替换为jg。你可以通过始终使用“有/没有 N”规则来避免这种混淆。
80x86 条件跳转指令使你能够根据某个条件将程序流分成两条路径。假设你想在 BX 等于 CX 时递增 AX 寄存器。你可以通过以下代码实现这一目标:
cmp( bx, cx );
jne SkipStmts;
inc( ax );
SkipStmts:
诀窍是使用对立分支来跳过你希望在条件为真时执行的指令。始终使用之前给出的“对立分支(有/没有 N)”规则来选择对立分支。
你还可以使用条件跳转指令来合成循环。例如,以下代码序列从用户那里读取一系列字符,并将每个字符存储在数组的连续元素中,直到用户按下回车键(换行符):
mov( 0, edi );
RdLnLoop:
stdin.getc(); // Read a character into the al register.
mov( al, Input[ edi ] ); // Store away the character.
inc( edi ); // Move on to the next character.
cmp( al, stdio.cr ); // See if the user pressed Enter.
jne RdLnLoop;
像setcc指令一样,条件跳转指令分为两大类:一种是测试特定处理器标志的指令(例如jz,jc,jno),另一种是测试某些条件(小于、大于等)。在测试条件时,条件跳转指令几乎总是跟随cmp指令。cmp指令设置标志,以便你可以使用ja,jae,jb,jbe,je或jne指令来测试无符号小于、小于等于、等于、不等于、大于或大于等于。与此同时,cmp指令也设置标志,以便你可以使用jl,jle,je,jne,jg和jge指令进行带符号的比较。
条件跳转指令只测试 80x86 标志;它们不会影响任何标志。
7.5 "中级" 控制结构:jt 和 jf
HLA 提供了两条特殊的条件跳转指令:jt(如果为真则跳转)和jf(如果为假则跳转)。这些指令的语法如下:
jt( *`boolean_expression`* ) *`target_label`*;
jf( *`boolean_expression`* ) *`target_label`*;
boolean_expression 是标准的 HLA 布尔表达式,允许在if..endif和其他 HLA 高级语言语句中使用。这些指令计算布尔表达式,如果表达式为真,则跳转到指定标签(jt),如果为假,则跳转到指定标签(jf)。
这些不是实际的 80x86 指令。HLA 将它们编译成一个或多个 80x86 机器指令,以实现相同的结果。通常,你不应在主代码中使用这两条指令;它们相较于使用if..endif语句没有什么好处,并且它们的可读性不比它们编译成的纯汇编语言序列更强。HLA 提供这些“中级”指令,以便你可以使用宏创建自己的高级控制结构(更多详情请参见第九章和 HLA 参考手册)。
7.6 在汇编语言中实现常见的控制结构
由于本章的主要目标是教你如何使用低级机器指令实现决策、循环和其他控制结构,因此明智之举是向你展示如何使用纯汇编语言实现这些高级语句。以下章节提供了这些信息。
7.7 决策介绍
在其最基本的形式中,决策 是代码中的某种分支,根据某些条件在两个可能的执行路径之间切换。通常(尽管并非总是如此),条件指令序列是通过条件跳转指令实现的。条件指令对应于 HLA 中的 if..then..endif 语句:
if( *`expression`* ) then
<< statements >>
endif;
与往常一样,汇编语言在处理条件语句时提供了更多的灵活性。考虑以下 C/C++ 语句:
if( (( x < y ) && ( z > t )) || ( a != b ) )
stmt1;
将此语句转为汇编语言的“暴力”方法可能会产生以下代码:
mov( x, eax );
cmp( eax, y );
setl( bl ); // Store x<y in bl.
mov( z, eax );
cmp( eax, t );
setg( bh ); // Store z>t in bh.
and( bh, bl ); // Put (x<y) && (z>t) into bl.
mov( a, eax );
cmp( eax, b );
setne( bh ); // Store a != b into bh.
or( bh, bl ); // Put (x<y) && (z>t) || (a!=b) into bl
je SkipStmt1; // Branch if result is false.
<< Code for Stmt1 goes here. >>
SkipStmt1:
正如你所看到的,仅仅处理上面示例中的表达式就需要相当多的条件语句。这大致对应于(等效的)C/C++ 语句:
bl = x < y;
bh = z > t;
bl = bl && bh;
bh = a != b;
bl = bl || bh;
if( bl )
<< Stmt1 >>;
现在将其与以下“改进版”代码进行比较:
mov( a, eax );
cmp( eax, b );
jne DoStmt;
mov( x, eax );
cmp( eax, y );
jnl SkipStmt;
mov( z, eax );
cmp( eax, t );
jng SkipStmt;
DoStmt:
<< Place code for Stmt1 here. >>
SkipStmt:
从上述代码序列中应该能看出两点:首先,在 C/C++(或其他高级语言)中,一个单一的条件语句可能需要在汇编语言中使用多个条件跳转;其次,条件序列中的复杂表达式的组织方式可能会影响代码的效率。因此,在处理汇编语言中的条件序列时,应小心谨慎。
条件语句可以分为三大类:if 语句、switch/case 语句和间接跳转。以下章节描述了这些程序结构,如何使用它们,以及如何用汇编语言编写它们。
7.7.1 if..then..else 序列
最常见的条件语句是 if..then..endif 和 if..then..else..endif 语句。这两种语句的形式如 图 7-1 所示。

图 7-1. if..then..else..endif 和 if..then..endif 语句流程
if..then..endif 语句只是 if..then..else..endif 语句的一种特殊情况(其 else 块为空)。因此,我们将只考虑更通用的 if..then..else..endif 形式。80x86 汇编语言中 if..then..else..endif 语句的基本实现大致如下所示:
<< Sequence of statements to test some condition >>
j*`cc`* ElseCode;
<< Sequence of statements corresponding to the THEN block >>
jmp EndOfIf;
ElseCode:
<< Sequence of statements corresponding to the ELSE block >>
EndOfIf:
请注意,jcc 表示某种条件跳转指令。例如,要转换 C/C++ 语句
if( a == b )
c = d;
else
b = b + 1;
要转为汇编语言,你可以使用以下 80x86 代码:
mov( a, eax );
cmp( eax, b );
jne ElsePart;
mov( d, c );
jmp EndOfIf;
ElseBlk:
inc( b );
EndOfIf:
对于像 ( a == b ) 这样的简单表达式,生成一个正确的 if..then..else..endif 语句几乎是微不足道的。若表达式变得更加复杂,代码的复杂度也会随之增加。考虑之前提到的 C/C++ if 语句:
if( (( x > y ) && ( z < t )) || ( a != b ) )
c = d;
在处理像这样的复杂 if 语句时,如果将 if 语句分解成三个不同的 if 语句,你会发现转换任务更容易,示例如下:
if( a != b ) c = d;
else if( x > y)
if( z < t )
c = d;
该转换源自以下 C/C++ 等价物:
if( *`expr1`* && *`expr2`* ) *`stmt`*;
等价于
if( *`expr1`* ) if( *`expr2`* ) *`stmt`*;
和
if( *`expr1`* || *`expr2`* ) *`stmt`*;
等价于
if( *`expr1`* ) *`stmt`*;
else if( *`expr2`* ) *`stmt`*;
在汇编语言中,前面的 if 语句变成
// if( (( x > y ) && ( z < t )) || ( a != b ) )
// c = d;
mov( a, eax );
cmp( eax, b );
jne DoIF;
mov( x, eax );
cmp( eax, y );
jng EndOfIF;
mov( z, eax );
cmp( eax, t );
jnl EndOfIf;
DoIf:
mov( d, eax );
mov( eax, c );
EndOfIf:
如你所见,测试一个条件可能比 else 和 then 块中的语句更加复杂。尽管似乎有些矛盾的是,测试一个条件可能比对条件结果进行操作更加费力,但这确实时常发生。因此,你应该做好接受这一点的准备。
在汇编语言中,复杂的条件语句最头疼的问题可能是当你写完代码后,试图理解自己做了什么。高级语言相较于汇编语言的一个大优势是,表达式更易于阅读和理解。高级语言的版本(更)具自文档性,而汇编语言则往往掩盖了代码的真正含义。因此,编写良好的注释是汇编语言实现 if..then..else..endif 语句的一个必要组成部分。上述示例的优雅实现如下:
// if ((x > y) && (z < t)) or (a != b) c = d;
// Implemented as:
// if (a != b) then goto DoIf;
mov( a, eax );
cmp( eax, b );
jne DoIf;
// if not (x > t) then goto EndOfIf;
mov( x, eax );
cmp( eax, y );
jng EndOfIf;
// if not (z < t) then goto EndOfIf;
mov( z, eax );
cmp( eax, t );
jnl EndOfIf;
// then block:
DoIf:
mov( d, eax );
mov( eax, c );
// End of if statement
EndOfIf:
诚然,对于如此简单的示例来说,这看起来有些过于复杂。以下内容可能就足够了:
// if ( (( x > y ) && ( z < t )) || ( a != b ) ) c = d;
// Test the boolean expression:
mov( a, eax );
cmp( eax, b );
jne DoIf;
mov( x, eax );
cmp( eax, y );
jng EndOfIf;
mov( z, eax );
cmp( eax, t );
jnl EndOfIf;
// then block:
DoIf:
mov( d, eax );
mov( eax, c );
// End of if statement
EndOfIf:
然而,随着 if 语句变得越来越复杂,注释的密度(和质量)变得越来越重要。
7.7.2 将 HLA if 语句转换成纯汇编语言
将 HLA if 语句转换成纯汇编语言非常容易。HLA if 语句所支持的布尔表达式是特别选择的,以便可以展开成几条简单的机器指令。以下段落讨论了将每个支持的布尔表达式转换为纯机器代码的过程。
if( flag_specification ) then stmts endif;
这种形式,或许是最容易转换的 HLA if 语句。为了在特定标志位被设置(或清除)时立即执行 then 关键字后的代码,你只需要在标志位清除(设置)时跳过代码。这仅需要一条条件跳转指令来实现,以下示例展示了这一过程:
// if( @c ) then inc( eax ); endif;
jnc SkipTheInc;
inc( eax );
SkipTheInc:
// if( @ns ) then neg( eax ); endif;
js SkipTheNeg;
neg( eax );
SkipTheNeg:
if( register ) then stmts endif;
这种形式使用 test 指令来检查指定的寄存器是否为 0。如果寄存器中包含 0(假),那么程序会通过 jz 指令跳过 then 子句后的语句。将此语句转换为汇编语言需要 test 指令和 jz 指令,以下示例展示了这一过程:
// if( eax ) then mov( false, eax ); endif;
test( eax, eax );
jz DontSetFalse;
mov( false, eax );
DontSetFalse:
// if( al ) then mov( bl, cl ); endif;
test( al, al );
jz noMove;
mov( bl, cl );
noMove:
if( !register ) then stmts endif;
这种形式的if语句使用test指令检查指定的寄存器是否为 0。如果寄存器不为 0(即为真),程序则使用jnz指令跳过then子句后的语句。将此语句转换为汇编语言时,需使用test指令和jnz指令,方法与前面的示例相同。
if( boolean_variable ) then stmts endif;
这种形式的if语句将布尔变量与 0(假)进行比较,如果变量为假,则跳过后面的语句。HLA 通过使用cmp指令将布尔变量与 0 进行比较,然后使用jz(je)指令在变量为假时跳过语句。以下示例展示了转换过程:
// if( bool ) then mov( 0, al ); endif;
cmp( bool, false );
je SkipZeroAL;
mov( 0, al );
SkipZeroAL:
if( !boolean_variable ) then stmts endif;
这种形式的if语句将布尔变量与 0(假)进行比较,如果变量为真(即与前一个示例相反的条件),则跳过后面的语句。HLA 通过使用cmp指令将布尔变量与 0 进行比较,然后使用jnz(jne)指令在变量为真时跳过语句。以下示例展示了转换过程:
// if( !bool ) then mov( 0, al ); endif;
cmp( bool, false );
jne SkipZeroAL;
mov( 0, al );
SkipZeroAL:
if( mem_reg relop mem_reg_const ) then stmts endif;
HLA 将这种形式的if语句转换为cmp指令和一个条件跳转,跳过由relop运算符指定的相反条件下的语句。表 7-4 列出了操作符和条件跳转指令之间的对应关系。
表 7-4. if语句条件跳转指令
| 关系运算 | 如果两个操作数都是无符号的,条件跳转指令 | 如果任一操作数为有符号的,条件跳转指令 |
|---|---|---|
= 或 == |
jne |
jne |
<> 或 != |
je |
je |
< |
jnb |
jnl |
<= |
jnbe |
jnle |
> |
jna |
jng |
>= |
jnae |
jnge |
以下是几个使用涉及关系运算符的表达式转换成纯汇编语言的if语句示例:
// if( al == ch ) then inc( cl ); endif;
cmp( al, ch );
jne SkipIncCL;
inc( cl );
SkipIncCL:
// if( ch >= 'a' ) then and( $5f, ch ); endif;
cmp( ch, 'a' );
jnae NotLowerCase
and( $5f, ch );
NotLowerCase:
// if( (type int32 eax ) < −5 ) then mov( −5, eax ); endif;
cmp( eax, −5 );
jnl DontClipEAX;
mov( −5, eax );
DontClipEAX:
// if( si <> di ) then inc( si ); endif;
cmp( si, di );
je DontIncSI;
inc( si );
DontIncSI:
if( reg/mem 在 LowConst..HiConst 范围内 ) then stmts endif;
HLA 将此if语句转换为一对cmp指令和一对条件跳转指令。它将寄存器或内存位置与较小的常量进行比较,如果小于(有符号)或低于(无符号)该常量,则跳过then子句后的语句。如果寄存器或内存位置的值大于或等于LowConst,则代码执行第二对cmp和条件跳转指令,将寄存器或内存位置与较高的常量进行比较。如果值大于(高于)该常量,则条件跳转指令跳过then子句中的语句。
这是一个示例:
// if( eax in 1000..125_000 ) then sub( 1000, eax ); endif;
cmp( eax, 1000 );
jb DontSub1000;
cmp( eax, 125_000 );
ja DontSub1000;
sub( 1000, eax );
DontSub1000:
// if( i32 in −5..5 ) then add( 5, i32 ); endif;
cmp( i32, −5 );
jl NoAdd5;
cmp( i32, 5 );
jg NoAdd5;
add(5, i32 );
NoAdd5:
if( reg/mem 不在 LowConst..HiConst 范围内 ) then stmts endif;
这种形式的 HLA if 语句测试一个寄存器或内存位置,查看其值是否在指定范围之外。该实现与前面的代码非常相似,只是如果值小于 LowConst 或大于 HiConst,则跳转到 then 子句;如果值在两个常数指定的范围内,则跳过 then 子句中的代码。以下示例演示了如何进行此转换:
// if( eax not in 1000..125_000 ) then add( 1000, eax ); endif;
cmp( eax, 1000 );
jb Add1000;
cmp( eax, 125_000 );
jbe SkipAdd1000;
Add1000:
add( 1000, eax );
SkipAdd1000:
// if( i32 not in −5..5 ) then mov( 0, i32 ); endif;
cmp( i32, −5 );
jl Zeroi32;
cmp( i32, 5 );
jle SkipZero;
Zeroi32:
mov( 0, i32 );
SkipZero:
7.7.3 使用完整布尔运算实现复杂的 if 语句
许多布尔表达式涉及合取(and)或析取(or)操作。本节描述了如何将布尔表达式转换为汇编语言。对于涉及合取和析取的复杂布尔表达式,有两种不同的转换方法:使用完整布尔运算或使用短路布尔运算。本节讨论完整布尔运算,下一节讨论短路布尔运算。
通过完整布尔运算进行转换几乎与将算术表达式转换为汇编语言相同。事实上,前一章关于算术的内容涵盖了这个转换过程。值得注意的唯一一点是,你不需要将结果存储在某个变量中;一旦表达式的计算完成,你只需检查是否得到 false(0)或 true(1 或非零)结果,并根据布尔表达式的指示采取相应的操作。正如你在前面章节的示例中看到的,你通常可以利用最后的逻辑指令(and / or)在结果为 false 时设置零标志,在结果为 true 时清除零标志。这让你避免了显式测试结果。考虑以下 if 语句及其使用完整布尔运算转换为汇编语言的过程:
// if( (( x < y ) && ( z > t )) || ( a != b ) )
// << Stmt1 >>;
mov( x, eax );
cmp( eax, y );
setl( bl ); // Store x<y in bl.
mov( z, eax );
cmp( eax, t );
setg( bh ); // Store z>t in bh.
and( bh, bl ); // Put (x<y) && (z>t) into bl.
mov( a, eax );
cmp( eax, b );
setne( bh ); // Store a != b into bh.
or( bh, bl ); // Put (x<y) && (z>t) || (a != b) into bl.
je SkipStmt1; // Branch if result is false.
<< Code for Stmt1 goes here. >>
SkipStmt1:
这段代码计算 BL 寄存器中的布尔结果,然后在计算结束时测试这个值,看看它是 true 还是 false。如果结果为 false,则该序列跳过与 Stmt1 相关的代码。这个例子中需要注意的重点是,程序将执行每一条计算布尔结果的指令(直到 je 指令)。
7.7.4 短路布尔运算
如果你愿意多花一点力气,通常可以通过使用短路布尔求值将布尔表达式转换成更短且更快速的汇编语言指令序列。短路布尔求值通过执行部分指令来判断表达式是“真”还是“假”,从而避免执行计算完整表达式所需的所有指令。出于这个原因,以及短路布尔求值不需要使用任何临时寄存器的事实,HLA 在将复杂布尔表达式翻译成汇编语言时使用了短路求值。
考虑表达式a && b。一旦我们确定a为假,就不需要再评估b,因为无论b的值是什么,表达式都不可能为真。如果a和b是子表达式而非简单变量,短路布尔求值所带来的节省就更为明显。举个具体的例子,考虑上一节中的子表达式((x<y) && (z>t))。一旦你确定x不小于y,就不需要再检查z是否大于t,因为无论z和t的值如何,整个表达式都会为假。以下代码片段展示了如何对这个表达式实现短路布尔求值:
// if( (x<y) && (z>t) ) then ...
mov( x, eax );
cmp( eax, y );
jnl TestFails;
mov( z, eax );
cmp( eax, t );
jng TestFails;
<< Code for THEN clause of IF statement >>
TestFails:
注意一旦代码确定x不小于y,它会跳过进一步的测试。当然,如果x小于y,程序就必须测试z是否大于t;如果不是,程序会跳过then语句。只有当程序满足两个条件时,代码才会执行then语句。
对于逻辑“或”操作,技巧类似。如果第一个子表达式评估为真,那么就不需要再测试第二个操作数。无论第二个操作数的值是什么,整个表达式仍然为真。以下示例展示了如何使用短路求值与析取(or)操作:
// if( ch < 'A' || ch > 'Z' )
// then stdout.put( "Not an uppercase char" );
// endif;
cmp( ch, 'A' );
jb ItsNotUC
cmp( ch, 'Z' );
jna ItWasUC;
ItsNotUC:
stdout.put( "Not an uppercase char" );
ItWasUC:
由于连接词和析取词运算符是可交换的,因此如果更方便的话,你可以先计算左边或右边的操作数。^([106]) 作为本节的最后一个例子,考虑上一节中的完整布尔表达式:
// if( (( x < y ) && ( z > t )) || ( a != b ) ) << Stmt1 >>;
mov( a, eax );
cmp( eax, b );
jne DoStmt1;
mov( x, eax );
cmp( eax, y );
jnl SkipStmt1;
mov( z, eax );
cmp( eax, t );
jng SkipStmt1;
DoStmt1:
<< Code for Stmt1 goes here. >>
SkipStmt1:
注意这个示例中的代码选择先评估a != b,然后再评估剩余的子表达式。这是汇编语言程序员常用的技巧,用来编写更高效的代码。
7.7.5 短路与完整布尔求值
在使用完全布尔评估时,该表达式序列中的每个语句都会执行;另一方面,短路布尔评估可能不需要执行与布尔表达式相关的每个语句。正如您在前两节中所看到的,基于短路评估的代码通常更简短且更快。因此,似乎短路评估是将复杂布尔表达式转换为汇编语言时的首选技巧。
不幸的是,有时短路布尔评估可能不会产生正确的结果。在表达式中存在副作用时,短路布尔评估会产生与完全布尔评估不同的结果。考虑以下 C/C++示例:
if( ( x == y ) && ( ++z != 0 )) << Stmt >>;
使用完全布尔评估,您可能会生成如下代码:
mov( x, eax ); // See if x == y.
cmp( eax, y );
sete( bl );
inc( z ); // ++z
cmp( z, 0 ); // See if incremented z is 0.
setne( bh );
and( bh, bl ); // Test x == y && ++z != 0.
jz SkipStmt;
<< Code for Stmt goes here. >>
SkipStmt:
使用短路布尔评估,您可能会生成如下代码:
mov( x, eax ); // See if x == y.
cmp( eax, y );
jne SkipStmt;
inc( z ); // ++z
cmp( z, 0 ); // See if incremented z is 0.
je SkipStmt;
<< Code for Stmt goes here. >>
SkipStmt:
请注意这两个转换之间一个非常微妙但重要的区别:如果x等于y,那么上面第一版本仍然会递增 z并在执行与Stmt相关的代码之前将其与 0 进行比较;另一方面,短路版本如果x等于y,则跳过递增z的代码。因此,如果x等于y,这两段代码的行为是不同的。没有哪种实现特别错误;根据具体情况,您可能希望或者不希望在x等于y时递增z。然而,重要的是要意识到这两种方案会产生不同的结果,因此,如果z的变化对程序有影响,您可以选择合适的实现。
许多程序利用短路布尔评估,并依赖于程序可能不会评估表达式中的某些组件这一事实。以下的 C/C++代码片段演示了可能最常见的需要短路布尔评估的例子:
if( Ptr != NULL && *Ptr == 'a' ) << Stmt >>;
如果结果是Ptr为NULL,那么表达式为假,并且无需计算表达式的其余部分(因此,使用短路布尔评估的代码将不会计算该表达式的其余部分)。这个语句依赖于短路布尔评估的语义才能正确操作。如果 C/C++使用完全布尔评估,并且变量Ptr包含NULL,那么表达式的后半部分将尝试解引用一个NULL指针(这通常会导致大多数程序崩溃)。考虑使用完全布尔评估和短路布尔评估翻译此语句:
// Complete boolean evaluation:
mov( Ptr, eax );
test( eax, eax ); // Check to see if eax is 0 (NULL is 0).
setne( bl );
mov( [eax], al ); // Get *Ptr into al.
cmp( al, 'a' );
sete( bh );
and( bh, bl );
jz SkipStmt;
<< Code for Stmt goes here. >>
SkipStmt:
请注意,在这个例子中,如果Ptr包含NULL(0),则程序将通过mov( [eax], al );指令尝试访问内存位置 0 的数据。在大多数操作系统下,这将导致内存访问故障(一般保护故障)。
现在考虑短路布尔转换:
// Short-circuit boolean evaluation
mov( Ptr, eax ); // See if Ptr contains NULL (0) and
test( eax, eax ); // immediately skip past Stmt if this
jz SkipStmt; // is the case.
mov( [eax], al ); // If we get to this point, Ptr contains
cmp( al, 'a' ); // a non-NULL value, so see if it points
jne SkipStmt; // at the character 'a'.
<< Code for Stmt goes here. >>
SkipStmt:
如您在此示例中所见,解引用NULL指针的问题不存在。如果Ptr包含NULL,那么这段代码会跳过尝试访问Ptr所包含的内存地址的语句。
7.7.6 汇编语言中if语句的高效实现
在汇编语言中高效编码if语句需要比仅仅选择短路求值而不是完整布尔求值更多的思考。为了在汇编语言中编写执行速度尽可能快的代码,你必须仔细分析情况,并适当地生成代码。以下段落提供了一些建议,你可以将其应用到你的程序中,以提高性能。
7.7.6.1 了解你的数据!
程序员常犯的一个错误是假设数据是随机的。实际上,数据很少是随机的,如果你知道程序常用的值类型,你可以利用这一知识编写更好的代码。看看如何做,考虑以下 C/C++语句:
if(( a == b ) && ( c < d )) ++i;
由于 C/C++使用短路求值,这段代码会首先检查a是否等于b。如果是,它将检查c是否小于d。如果你预计a大多数时候等于b,但不预计c大多数时候小于d,这条语句的执行速度将比预期的要慢。考虑以下 HLA 实现的这段代码:
mov( a, eax );
cmp( eax, b );
jne DontIncI;
mov( c, eax );
cmp( eax, d );
jnl DontIncI;
inc( i );
DontIncI:
如你在这段代码中所见,如果a大多数时候等于b且c大多数时候不小于d,你将几乎每次都必须执行所有六条指令,以确定表达式的结果是错误的。现在,考虑一下利用这个知识,并且利用&&运算符交换律的事实对上述 C/C++语句的实现:
mov( c, eax );
cmp( eax, d );
jnl DontIncI;
mov( a, eax );
cmp( eax, b );
jne DontIncI;
inc( i );
DontIncI:
在这个例子中,代码首先检查c是否小于d。如果大多数情况下c小于d,那么这段代码会确定它只需执行三条指令(与前一个例子中的六条指令相比),然后跳到标签DontIncI。在汇编语言中,这一点比在高级语言中更为明显;这也是汇编程序通常比其高级语言对应程序更快的主要原因之一:优化在汇编语言中比在高级语言中更为直观。当然,关键是要理解你的数据行为,这样你才能做出像上面那样的智能决策。
7.7.6.2 重新排列表达式
即使你的数据是随机的(或者你无法确定输入值如何影响你的决策),重新排列表达式中的项可能仍然会带来一些好处。一些计算比其他计算花费的时间要长得多。例如,div指令比简单的cmp指令要慢得多。因此,如果你有如下的语句,你可能希望重新排列表达式,使得cmp优先执行:
if( (x % 10 = 0 ) && (x != y ) ++x;
转换为汇编代码后,这个if语句变成了:
mov( x, eax ); // Compute X % 10.
cdq(); // Must sign extend eax -> edx:eax.
imod( 10, edx:eax ); // Remember, remainder goes into edx.
test( edx, edx ); // See if edx is 0.
jnz SkipIf;
mov( x, eax );
cmp( eax, y );
je SkipIf;
inc( x );
SkipIf:
imod指令非常昂贵(在这个例子中通常比大多数其他指令慢 50 到 100 倍)。除非余数为 0 的可能性比x等于y的可能性大 50 到 100 倍,否则最好先进行比较,然后再进行余数计算:
mov( x, eax );
cmp( eax, y );
je SkipIf;
mov( x, eax ); // Compute X % 10.
cdq(); // Must sign extend eax -> edx:eax.
imod( 10, edx:eax ); // Remember, remainder goes into edx.
test( edx, edx ); // See if edx is 0.
jnz SkipIf;
inc( x );
SkipIf:
当然,为了以这种方式重新排列表达式,代码不能假定使用短路求值语义(因为&&和||运算符在必须先计算一个子表达式然后计算另一个子表达式时不具有交换律)。
7.7.6.3 重构你的代码
尽管结构化编程技术有很多优点,但编写结构化代码也有一些缺点。具体而言,结构化代码有时比非结构化代码效率低。大多数情况下这是可以接受的,因为非结构化代码难以阅读和维护;通常可以牺牲一些性能以换取可维护的代码。然而,在某些情况下,你可能需要尽可能地提升性能。在这些罕见的情况下,你可能会选择为了获得额外的性能而牺牲代码的可读性。
一个经典的做法是使用代码移动将程序中很少使用的代码移到大部分时间执行的代码之外。例如,考虑以下伪 C/C++ 语句:
if( See_If_an_Error_Has_Occurred )
{
<< Statements to execute if no error >>
}
else
{
<< Error handling statements >>
}
在正常的代码中,人们并不期望错误经常发生。因此,你通常会期望上述if语句的then部分执行的次数远远超过else分支。上面的代码可以转换为以下汇编代码:
cmp( See_If_an_Error_Has_Occurred, true );
je HandleTheError;
<< Statements to execute if no error >>
jmp EndOfIF;
HandleTheError:
<< Error handling statements >>
EndOfIf:
请注意,如果表达式为假,此代码会继续执行正常的语句,然后跳过错误处理语句。控制从程序的一个点转移到另一个点的指令(例如jmp指令)通常速度较慢。执行顺序一致的一组指令要比在程序中到处跳转快得多。不幸的是,上面的代码不允许这样做。解决这个问题的一种方法是将代码的else分支移动到程序的其他位置。也就是说,你可以将代码重写为以下形式:
cmp( See_If_an_Error_Has_Occurred, true );
je HandleTheError;
<< Statements to execute if no error >>
EndOfIf:
在程序的某个其他位置(通常在jmp指令之后),你会插入以下代码:
HandleTheError:
<< Error handling statements >>
jmp EndOfIf;
请注意,程序的长度没有任何变化。你从原序列中移除的jmp最终会出现在else分支的末尾。然而,由于else分支很少执行,将jmp指令从频繁执行的then分支移到else分支中是一个重大的性能优化,因为then分支只使用直线代码。这种技术在许多时间关键的代码段中效果惊人。
写非结构化代码和写结构化代码是有区别的。非结构化代码一开始就以非结构化方式编写。它通常难以阅读、难以维护,并且经常包含缺陷。另一方面,非结构化代码从结构化代码开始,您做出有意识的决定来消除结构,以获得小幅度的性能提升。通常,在将其非结构化之前,您已经测试了结构化形式的代码。因此,非结构化代码通常比非结构化代码更易于处理。
7.7.6.4 计算而非分支
在许多 80x86 系列处理器中,分支(跳转)与许多其他指令相比非常昂贵。因此,有时在序列中执行更多的指令比执行涉及分支的较少指令更好。例如,考虑简单的赋值语句eax = abs( eax );。不幸的是,80x86 指令集中没有计算整数绝对值的指令。处理这个问题的明显方法是使用以下指令序列:
test( eax, eax );
jns ItsPositive;
neg( eax );
ItsPositive:
然而,正如您在此示例中明显看到的,它使用条件跳转来跳过neg指令(如果 EAX 为负则会创建一个正数值)。现在考虑以下也能完成任务的序列:
// Set edx to $FFFF_FFFF if eax is negative, $0000_0000 if eax is
// 0 or positive:
cdq();
// If eax was negative, the following code inverts all the bits in eax;
// otherwise it has no effect on eax.
xor( edx, eax );
// If eax was negative, the following code adds 1 to eax; otherwise
// it doesn't modify eax's value.
and( 1, edx ); // edx = 0 or 1 (1 if eax was negative).
add( edx, eax );
如果 EAX 在序列之前是负数,则此代码将反转 EAX 中的所有位,然后向 EAX 添加 1;也就是说,它否定了 EAX 中的值。如果 EAX 为 0 或正数,则此代码不会更改 EAX 中的值。
请注意,此序列需要四条指令,而不是前面示例中所需的三条指令。但是,由于此序列中没有控制传输指令,因此在 80x86 系列的许多 CPU 上可能执行速度更快。
7.7.7 switch/case 语句
HLA switch语句的形式如下:
switch( *`reg32`* )
case( *`const1`* )
<< Stmts1: code to execute if *`reg32`* equals *`const1`* >>
case( *`const2`* )
<< Stmts2: code to execute if *`reg32`* equals *`const2`* >>
.
.
.
case( *`constn`* )
<< Stmtsn: code to execute if *`reg32`* equals *`constn`* >>
default // Note that the default section is optional.
<< Stmts_default: code to execute if *`reg32`*
does not equal any of the case values >>
endswitch;
当此语句执行时,它检查寄存器的值是否与常量const1..constn匹配。如果找到匹配项,则执行相应的语句。HLA 对switch语句有一些限制。首先,HLA switch语句只允许 32 位寄存器作为switch表达式。其次,在case子句中的所有常量必须是唯一的。这些限制的原因将很快明了。
大多数入门编程教材通过将switch/case语句解释为一系列if..then..elseif..else..endif语句来介绍它。他们可能会声称以下两段 HLA 代码是等效的:
switch( eax )
case(0) stdout.put("i=0");
case(1) stdout.put("i=1");
case(2) stdout.put("i=2");
endswitch;
if( eax = 0 ) then
stdout.put("i=0")
elseif( eax = 1 ) then
stdout.put("i=1")
elseif( eax = 2 ) then
stdout.put("i=2");
endif;
尽管从语义上讲,这两段代码可能是相同的,但它们的实现通常是不同的。if..then..elseif..else..endif 链会对序列中的每个条件语句进行比较,而 switch 语句通常使用间接跳转,通过一次计算将控制转移到多个语句中的任何一个。考虑上述给出的两个示例;它们可以通过以下代码用汇编语言编写:
// if..then..else..endif form:
mov( i, eax );
test( eax, eax ); // Check for 0.
jnz Not0;
stdout.put( "i=0" );
jmp EndCase;
Not0:
cmp( eax, 1 );
jne Not1;
stdou.put( "i=1" );
jmp EndCase;
Not1:
cmp( eax, 2 );
jne EndCase;
stdout.put( "i=2" );
EndCase:
// Indirect Jump Version
readonly
JmpTbl:dword[3] := [ &Stmt0, &Stmt1, &Stmt2 ];
.
.
.
mov( i, eax );
jmp( JmpTbl[ eax*4 ] );
Stmt0:
stdout.put( "i=0" );
jmp EndCase;
Stmt1:
stdout.put( "I=1" );
jmp EndCase;
Stmt2:
stdout.put( "I=2" );
EndCase:
if..then..elseif..else..endif 版本的实现非常明显,几乎不需要什么解释。然而,间接跳转版本可能对你来说相当神秘,因此让我们考虑一下这个特定的 switch 语句实现是如何工作的。
请记住,jmp 指令有三种常见形式。标准的无条件 jmp 指令,如前面的 jmp EndCase 指令,会直接将控制转移到 jmp 操作数指定的语句标签。第二种 jmp 指令形式是 jmp( reg32 );,它将控制转移到由 32 位寄存器中找到的地址指定的内存位置。第三种 jmp 指令形式是前面示例中使用的,它将控制转移到由双字内存位置内容指定的指令。正如这个示例清楚地展示的那样,那个内存位置可以使用任何寻址方式。你不局限于仅使用位移寻址方式。现在让我们仔细考虑一下 switch 语句第二种实现方式是如何工作的。
首先,switch 语句要求你创建一个指针数组,每个元素包含代码中某个语句标签的地址(这些标签必须附加到每个 switch 语句中的执行语句序列)。在上面的示例中,JmpTbl 数组就起到了这个作用。请注意,这段代码用语句标签 Stmt0、Stmt1 和 Stmt2 的地址初始化了 JmpTbl。程序将这个数组放置在 readonly 区段中,因为程序在执行过程中不应改变这些值。
警告
每当你用一组语句标签的地址初始化数组时,就像这个示例中一样,你声明数组的部分(例如此例中的 readonly)必须与包含语句标签的同一个过程在一起。^([107])
在执行此代码序列时,程序将i的值加载到 EAX 寄存器中。然后程序使用该值作为JmpTbl数组的索引,并将控制权转移到指定位置的 4 字节地址。例如,如果 EAX 中包含 0,jmp( JmpTbl[eax*4] );指令将获取地址JmpTbl+0 ( eax*4=0 )处的双字数据。因为表中的第一个双字包含Stmt0的地址,所以jmp指令将控制权转移到Stmt0标签后的第一条指令。同样,如果i(因此 EAX)包含 1,那么间接jmp指令将获取表中偏移量为 4 的双字,并将控制权转移到Stmt1标签后的第一条指令(因为Stmt1的地址出现在表的偏移量 4 处)。最后,如果i/EAX 包含 2,那么这段代码将控制权转移到JmpTbl表中偏移量为 8 处的Stmt2标签后的语句。
你应该注意,当你添加更多(连续的)case时,跳转表实现比if/elseif形式更高效(无论在空间还是速度方面)。除非是简单的情况,switch语句几乎总是更快,而且通常差距较大。只要case值是连续的,switch语句版本通常也更小。
如果需要包含不连续的case标签,或者你不能确保switch值不会超出范围,会发生什么情况?对于 HLA 的switch语句,出现这种情况时会将控制转移到endswitch语句后的第一条语句(或者转移到default语句,如果switch中存在default)。然而,在上面的例子中并没有发生这种情况。如果变量i不包含 0、1 或 2,执行上面的代码将产生未定义的结果。例如,如果在执行上面代码时,i的值为 5,那么间接jmp指令将获取JmpTbl中偏移量为 20(5 * 4)处的双字,并将控制权转移到该地址。不幸的是,JmpTbl中没有六个条目;因此,程序将最终获取JmpTbl后第三个双字的值,并将其作为目标地址。这通常会导致程序崩溃,或者将控制转移到一个意外的位置。
解决方案是在间接jmp指令之前放置几条指令,以验证switch选择值是否在合理范围内。在之前的例子中,我们可能希望在执行jmp指令之前验证i的值是否在 0..2 的范围内。如果i的值超出此范围,程序应该直接跳转到endcase标签(这对应于跳到endswitch语句后的第一条语句)。以下代码实现了这一修改:
readonly
JmpTbl:dword[3] := [ &Stmt0, &Stmt1, &Stmt2 ];
.
.
.
mov( i, eax );
cmp( eax, 2 ); // Verify that i is in the range
ja EndCase; // 0..2 before the indirect jmp.
jmp( JmpTbl[ eax*4 ] );
Stmt0:
stdout.put( "i=0" );
jmp EndCase;
Stmt1:
stdout.put( "i=1" );
jmp EndCase;
Stmt2:
stdout.put( "i=2" );
EndCase:
尽管上面的例子处理了选择值超出 0..2 范围的问题,但它仍然存在几个严重的限制:
-
各个
case必须从值 0 开始。也就是说,在这个示例中,最小的case常量必须是 0。 -
case值必须是连续的。
解决第一个问题很简单,且你可以通过两步来处理。首先,必须将case选择值与下限和上限进行比较,以确定该case值是否合法。例如:
// SWITCH statement specifying cases 5, 6, and 7:
// WARNING: This code does *NOT* work. Keep reading to find out why.
mov( i, eax );
cmp( eax, 5 );
jb EndCase
cmp( eax, 7 ); // Verify that i is in the range
ja EndCase; // 5..7 before the indirect jmp.
jmp( JmpTbl[ eax*4 ] );
Stmt5:
stdout.put( "i=5" );
jmp EndCase;
Stmt6:
stdout.put( "i=6" );
jmp EndCase;
Stmt7:
stdout.put( "i=7" );
EndCase:
如你所见,这段代码增加了一对额外的指令cmp和jb,用于测试选择值是否在 5 到 7 的范围内。如果不在范围内,控制会跳转到EndCase标签;否则,控制通过间接的jmp指令转移。遗憾的是,正如评论所指出的,这段代码存在问题。考虑如果变量i包含值 5 时会发生什么:代码会验证 5 是否在 5 到 7 的范围内,然后它会取偏移量为 20 的 dword(5*@size(dword))并跳转到该地址。然而,和之前一样,这会加载超出表格边界的 4 个字节,并不会将控制转移到已定义的位置。一种解决方案是在执行jmp指令之前,从 EAX 中减去最小的case选择值,如下面的示例所示。
// SWITCH statement specifying cases 5, 6, and 7:
// WARNING: There is a better way to do this. Keep reading.
readonly
JmpTbl:dword[3] := [ &Stmt5, &Stmt6, &Stmt7 ];
.
.
.
mov( i, eax );
cmp( eax, 5 );
jb EndCase
cmp( eax, 7 ); // Verify that i is in the range
ja EndCase; // 5..7 before the indirect jmp.
sub( 5, eax ); // 5->0, 6->1, 7->2.
jmp( JmpTbl[ eax*4 ] );
Stmt5:
stdout.put( "i=5" );
jmp EndCase;
Stmt6:
stdout.put( "i=6" );
jmp EndCase;
Stmt7:
stdout.put( "i=7" );
EndCase:
通过从 EAX 中的值减去 5,这段代码强制 EAX 的值为 0、1 或 2,紧接着是jmp指令。因此,case选择值为 5 时跳转到Stmt5,case选择值为 6 时跳转到Stmt6,case选择值为 7 时跳转到Stmt7。
有一种巧妙的方法可以改进上述代码。你可以通过将这个减法合并到jmp指令的地址表达式中,从而消除sub指令。考虑下面的代码,它实现了这一点:
// SWITCH statement specifying cases 5, 6, and 7:
readonly
JmpTbl:dword[3] := [ &Stmt5, &Stmt6, &Stmt7 ];
.
.
.
mov( i, eax );
cmp( eax, 5 );
jb EndCase
cmp( eax, 7 ); // Verify that i is in the range
ja EndCase; // 5..7 before the indirect jmp.
jmp( JmpTbl[ eax*4 - 5*@size(dword)] );
Stmt5:
stdout.put( "i=5" );
jmp EndCase;
Stmt6:
stdout.put( "i=6" );
jmp EndCase;
Stmt7:
stdout.put( "i=7" );
EndCase:
HLA switch语句提供了一个default子句,如果case选择值与任何case值不匹配,则执行该子句。例如:
switch( ebx )
case( 5 ) stdout.put( "ebx=5" );
case( 6 ) stdout.put( "ebx=6" );
case( 7 ) stdout.put( "ebx=7" );
default
stdout.put( "ebx does not equal 5, 6, or 7" );
endswitch;
在纯汇编语言中实现default子句的等效功能非常简单。只需在代码开头的jb和ja指令中使用不同的目标标签。以下示例实现了一个类似于上面那个的 HLA switch语句:
// SWITCH statement specifying cases 5, 6, and 7 with a DEFAULT clause:
readonly
JmpTbl:dword[3] := [ &Stmt5, &Stmt6, &Stmt7 ];
.
.
.
mov( i, eax );
cmp( eax, 5 );
jb DefaultCase;
cmp( eax, 7 ); // Verify that i is in the range
ja DefaultCase; // 5..7 before the indirect jmp.
jmp( JmpTbl[ eax*4 - 5*@size(dword)] );
Stmt5:
stdout.put( "i=5" );
jmp EndCase;
Stmt6:
stdout.put( "i=6" );
jmp EndCase;
Stmt7:
stdout.put( "i=7" );
jmp EndCase;
DefaultCase:
stdout.put( "i does not equal 5, 6, or 7" );
EndCase:
之前提到的第二个限制,即case值需要是连续的,可以通过在跳转表中插入额外的条目来轻松处理。考虑以下 HLA switch语句:
switch( ebx )
case( 1 ) stdout.put( "ebx = 1" );
case( 2 ) stdout.put( "ebx = 2" );
case( 4 ) stdout.put( "ebx = 4" );
case( 8 ) stdout.put( "ebx = 8" );
default
stdout.put( "ebx is not 1, 2, 4, or 8" );
endswitch;
最小的switch值是 1,最大值是 8。因此,在间接jmp指令之前的代码需要将 EBX 中的值与 1 和 8 进行比较。如果该值在 1 和 8 之间,仍然可能 EBX 不包含一个合法的case选择值。然而,因为jmp指令使用case选择表索引到双字表,所以表必须包含八个双字条目。为了处理介于 1 和 8 之间但不是case选择值的值,只需将default子句的语句标签(或者如果没有default子句,则是指定endswitch后第一条指令的标签)放入每个没有对应case子句的跳转表条目中。以下代码演示了这一技术:
readonly
JmpTbl2: dword :=
[
&Case1, &Case2, &dfltCase, &Case4,
&dfltCase, &dfltCase, &dfltCase, &Case8
];
.
.
.
cmp( ebx, 1 );
jb dfltCase;
cmp( ebx, 8 );
ja dfltCase;
jmp( JmpTbl2[ ebx*4 - 1*@size(dword) ] );
Case1:
stdout.put( "ebx = 1" );
jmp EndOfSwitch;
Case2:
stdout.put( "ebx = 2" );
jmp EndOfSwitch;
Case4:
stdout.put( "ebx = 4" );
jmp EndOfSwitch;
Case8:
stdout.put( "ebx = 8" );
jmp EndOfSwitch;
dfltCase:
stdout.put( "ebx is not 1, 2, 4, or 8" );
EndOfSwitch:
这种switch语句的实现存在一个问题。如果case值包含不连续的条目且间隔较大,跳转表可能会变得非常大。以下switch语句会生成一个极其庞大的代码文件:
switch( ebx )
case( 1 ) << Stmt1 >>;
case( 100 ) << Stmt2 >>;
case( 1_000 ) << Stmt3 >>;
case( 10_000 ) << Stmt4 >>;
default << Stmt5 >>;
endswitch;
在这种情况下,如果你用一系列的if语句来实现switch语句,而不是使用间接跳转语句,那么你的程序将会更小。然而,要记住一点——跳转表的大小通常不会影响程序的执行速度。如果跳转表包含两个条目或两千个条目,switch语句将在恒定的时间内执行多重分支。if语句的实现则要求随着每个case标签的出现,所需的时间按线性方式增加。
使用汇编语言而不是像 Pascal 或 C/C++这样的高级语言最大的优势之一就是你可以选择语句(如switch)的实际实现方式。在某些情况下,你可以将switch语句实现为一系列的if..then..elseif语句,或者实现为一个跳转表,或者两者结合使用:
switch( eax )
case( 0 ) << Stmt0 >>;
case( 1 ) << Stmt1 >>;
case( 2 ) << Stmt2 >>;
case( 100 ) << Stmt3 >>;
default << Stmt4 >>;
endswitch;
这可能变成
cmp( eax, 100 );
je DoStmt3;
cmp( eax, 2 );
ja TheDefaultCase;
jmp( JmpTbl[ eax*4 ]);
...
当然,HLA 支持以下代码高级控制结构:
if( ebx = 100 ) then
<< Stmt3 >>;
else
switch( eax )
case(0) << Stmt0 >>;
case(1) << Stmt1 >>;
case(2) << Stmt2 >>;
Otherwise << Stmt4 >>;
endswitch;
endif;
但这往往会破坏程序的可读性。另一方面,汇编语言代码中用于测试 100 的额外代码不会对程序的可读性产生不利影响(也许是因为它本身就已经很难读了)。因此,大多数人会添加额外的代码,以提高程序的效率。
C/C++的switch语句与 HLA 的switch语句非常相似。唯一的主要语义区别是:程序员必须在每个case子句中显式地放置一个break语句,将控制转移到switch语句之外的第一条语句。这个break对应于上述汇编代码中每个case序列末尾的jmp指令。如果没有相应的break,C/C++会将控制转移到下一个case的代码中。这相当于在case序列末尾省略jmp指令:
switch (i)
{
case 0: << Stmt1 >>;
case 1: << Stmt2 >>;
case 2: << Stmt3 >>;
break;
case 3: << Stmt4 >>;
break;
default: << Stmt5 >>;
}
这段代码转换成了以下 80x86 代码:
readonly
JmpTbl: dword[4] := [ &case0, &case1, &case2, &case3 ];
.
.
.
mov( i, ebx );
cmp( ebx, 3 );
ja DefaultCase;
jmp( JmpTbl[ ebx*4 ]);
case0:
Stmt1;
case1:
Stmt2;
case2:
Stmt3;
jmp EndCase; // Emitted for the break stmt.
case3:
Stmt4;
jmp EndCase; // Emitted for the break stmt.
DefaultCase:
Stmt5;
EndCase:
^([106]) 然而,要注意某些表达式依赖于最左边的子表达式以某种方式求值,以便最右边的子表达式有效;例如,C/C++ 中常见的测试是 if( x != NULL && x->y )...
^([107]) 如果 switch 语句出现在你的主程序中,你必须在主程序的声明部分声明数组。
7.8 状态机和间接跳转
另一种在汇编语言程序中常见的控制结构是 状态机。状态机使用 状态变量 来控制程序流程。FORTRAN 编程语言通过赋值的 goto 语句提供了这一功能。某些 C 语言的变种(例如,GNU 的 GCC 编译器)也提供了类似的功能。在汇编语言中,间接跳转可以实现状态机。
那么,什么是状态机呢?用最基本的话来说,它是一段通过进入和离开某些“状态”来跟踪其执行历史的代码。对于本章的目的,我们假设状态机是一个能够(以某种方式)记住其执行历史(其 状态)并根据该历史执行代码段的程序。
从某种意义上讲,所有程序都是状态机。CPU 寄存器和内存中的值构成了该机器的状态。然而,我们将采用更为受限的视角。实际上,在大多数情况下,只有一个变量(或 EIP 寄存器中的值)会表示当前状态。
现在让我们考虑一个具体的例子。假设你有一个过程,第一次调用时执行一个操作,第二次调用时执行不同的操作,第三次调用时执行另一个操作,然后在第四次调用时执行一个新的操作。在第四次调用之后,它会按顺序重复这四个不同的操作。例如,假设你希望过程在第一次时将 EAX 和 EBX 相加,第二次时进行减法,第三次时进行乘法,第四次时进行除法。你可以通过以下方式实现这个过程:
procedure StateMachine;
static
State:byte := 0;
begin StateMachine;
cmp( State, 0 );
jne TryState1;
// State 0: Add ebx to eax and switch to State 1:
add( ebx, eax );
inc( State );
exit StateMachine;
TryState1:
cmp( State, 1 );
jne TryState2;
// State 1: Subtract ebx from eax and switch to State 2:
sub( ebx, eax );
inc( State ); // State 1 becomes State 2.
exit StateMachine;
TryState2:
cmp( State, 2 );
jne MustBeState3;
// If this is State 2, multiply ebx by eax and switch to State 3:
intmul( ebx, eax );
inc( State ); // State 2 becomes State 3.
exit StateMachine;
// If it isn't one of the above states, we must be in State 3,
// so divide eax by ebx and switch back to State 0.
MustBeState3:
push( edx ); // Preserve this 'cause it gets whacked by div.
xor( edx, edx ); // Zero extend eax into edx.
div( ebx, edx:eax);
pop( edx ); // Restore edx's value preserved above.
mov( 0, State ); // Reset the state back to 0.
end StateMachine;
从技术上讲,这个过程并不是状态机。实际上,正是变量 State 和 cmp/jne 指令构成了状态机。
这段代码没有什么特别之处。它不过是通过 if..then..elseif 结构实现的 switch 语句。这个过程唯一不同之处在于它记住了它被调用的次数^([108]),并根据调用的次数表现出不同的行为。虽然这是一个正确的状态机实现,但它的效率并不高。聪明的读者当然会认识到,使用实际的 switch 语句而非 if..then..elseif 实现,可以使这段代码运行得更快。然而,还有一个更好的解决方案。
在汇编语言中,状态机的一种常见实现方式是使用间接跳转。我们可以将状态变量加载为代码执行入口时的地址,而不是让状态变量包含像 0、1、2 或 3 这样的值。通过简单地跳转到该地址,状态机可以省略选择合适代码片段所需的测试。考虑以下使用间接跳转的实现:
procedure StateMachine;
static
State:dword := &State0;
begin StateMachine;
jmp( State );
// State 0: Add ebx to eax and switch to State 1:
State0:
add( ebx, eax );
mov( &State1, State );
exit StateMachine;
State1:
// State 1: Subtract ebx from eax and switch to State 2:
sub( ebx, eax );
mov( &State2, State ); // State 1 becomes State 2.
exit StateMachine;
State2:
// If this is State 2, multiply ebx by eax and switch to State 3:
intmul( ebx, eax );
mov( &State3, State ); // State 2 becomes State 3.
exit StateMachine;
// State 3: Divide eax by ebx and switch back to State 0.
State3:
push( edx ); // Preserve this 'cause it gets whacked by div.
xor( edx, edx ); // Zero extend eax into edx.
div( ebx, edx:eax);
pop( edx ); // Restore edx's value preserved above.
mov( &State0, State ); // Reset the state back to 0.
end StateMachine;
StateMachine过程开始时的jmp指令将控制转移到State变量所指向的位置。第一次调用StateMachine时,它指向State0标签。此后,每个代码子段都将State变量设置为指向相应的后续代码。
^([108]) 实际上,它记住了被调用的次数,并取模 4。
7.9 意大利面代码
汇编语言的一个主要问题是,它需要多个语句才能实现一个高级语言中用单个语句封装的简单思想。汇编语言程序员常常会注意到,通过跳转到程序结构的中间某处,可以节省几个字节或时钟周期。经过几次这样的观察(并做出相应修改),代码中就包含了一系列跳转进出代码的操作。如果你画出每个跳转到其目标位置的线,最终得到的代码列表就像有人把一碗意大利面撒在你的代码上一样,因此这个术语叫做意大利面代码。
意大利面代码有一个主要的缺点——阅读这样的程序并弄清楚它的功能非常困难(即使勉强能读懂)。大多数程序起初是“结构化”的,但在追求效率的过程中往往变成意大利面代码。遗憾的是,意大利面代码很少高效。由于很难准确弄清楚程序在做什么,因此很难判断是否可以使用更好的算法来改进系统。因此,意大利面代码可能最终比结构化代码更低效。
虽然在程序中写出一些意大利面代码可能会提高效率,但这应该始终是最后的手段,前提是你已经尝试过所有其他方法,并且仍然没有达到所需的效果。编写程序时,始终从简单的if和switch语句开始。当一切正常并且已经理解时,再开始通过jmp指令组合代码段。当然,除非收益足够大,否则你绝不应该破坏代码的结构。
在结构化编程圈子里有一句名言:“在 goto 之后,指针是编程语言中最危险的元素。”另一句类似的名言是:“指针对数据结构的作用,就像 goto 对控制结构的作用。”换句话说,要避免过度使用指针。如果 goto 和指针都不好,那么间接跳转肯定是最糟糕的构造,因为它涉及到 goto 和指针!不过,说正经的,间接跳转指令应该避免随意使用。它的使用往往会使程序更难以阅读。毕竟,间接跳转(理论上)可以将控制转移到程序中的任何位置。想象一下,如果你不知道指针包含什么,而你遇到了一个使用该指针的间接跳转,那么要跟踪程序的执行流程会有多困难。因此,在使用间接跳转指令时,应该始终小心谨慎。
7.10 循环
循环是典型程序的最后一种基本控制结构(顺序、决策和循环)。像汇编语言中的许多其他结构一样,你会发现自己在从未想过使用循环的地方使用循环。大多数高级语言都有隐藏的隐式循环结构。例如,考虑 BASIC 语句 if A$ = B$ then 100。此 if 语句比较两个字符串,如果它们相等,则跳转到语句 100。在汇编语言中,你需要编写一个循环来比较 A$ 中的每个字符与 `B$ 中对应的字符,然后仅当所有字符匹配时,才跳转到语句 100。在 BASIC 中,程序中看不见任何循环。汇编语言要求编写一个循环来比较字符串中的每个字符。^([109]) 这只是一个小例子,展示了循环如何似乎无处不在。
程序循环由三个部分组成:一个可选的初始化部分,一个可选的循环终止测试,以及循环体。你组合这些部分的顺序会显著影响循环的操作。这些部分的三种排列方式在程序中非常常见。由于它们的频繁出现,这些循环结构在高级语言中有了特定的名称:while 循环、repeat..until 循环(在 C/C++ 中是 do..while),以及无限循环(例如,在 HLA 中是 forever..endfor)。
7.10.1 while 循环
最通用的循环是 while 循环。在 HLA 的高级语法中,它采用以下形式:
while( *`expression`* ) do *`statements`* endwhile;
关于 while 循环,有两点需要注意。首先,终止测试出现在循环的开始处。其次,由于终止测试的位置,循环体可能永远不会执行,如果布尔表达式始终为假。
请考虑以下 HLA 的while循环:
mov( 0, i );
while( i < 100 ) do
inc( i );
endwhile;
mov( 0, i );指令是该循环的初始化代码。i是循环控制变量,因为它控制着循环体的执行。i < 100是循环的终止条件。也就是说,只要i小于 100,循环就不会终止。单个指令inc( i );是每次循环迭代时执行的循环体。
请注意,HLA 的while循环可以通过if和jmp语句轻松合成。例如,你可以将之前的 HLA while循环替换为以下 HLA 代码:
mov( 0, i );
WhileLp:
if( i < 100 ) then
inc( i );
jmp WhileLp;
endif;
更一般地,你可以像下面这样构造任何while循环:
<< Optional initialization code >>
UniqueLabel:
if( *`not_termination_condition`* ) then
<< Loop body >>
jmp UniqueLabel;
endif;
因此,你可以使用本章前面介绍的技术,将if语句转换为汇编语言,并添加一个jmp指令来生成while循环。我们在这一节中看到的例子可以转换为以下纯 80x86 汇编代码:^([110])
mov( 0, i );
WhileLp:
cmp( i, 100 );
jnl WhileDone;
inc( i );
jmp WhileLp;
WhileDone:
7.10.2 repeat..until 循环
repeat..until(do..while)循环在循环的末尾测试终止条件,而不是在开始时。在 HLA 的高级语法中,repeat..until循环采用以下形式:
<< Optional initialization code >>
repeat
<< Loop body >>
until( *`termination_condition`* );
该序列先执行初始化代码,然后执行循环体,最后测试某个条件来判断循环是否应该重复。如果布尔表达式为假,循环会重复;否则循环终止。你应该注意repeat..until循环的两点:一是终止测试出现在循环的末尾,二是由于这一点,循环体总是至少执行一次。
与while循环类似,repeat..until循环可以通过if语句和jmp指令来合成。你可以使用以下方式:
<< Initialization code >>
*`SomeUniqueLabel`*:
<< Loop body >>
if( *`not_the_termination_condition`* ) then jmp *`SomeUniqueLabel`*; endif;
根据前面章节介绍的内容,你可以轻松地在汇编语言中合成repeat..until循环。以下是一个简单的例子:
repeat
stdout.put( "Enter a number greater than 100: " );
stdin.get( i );
until( i > 100 );
// This translates to the following if/jmp code:
RepeatLabel:
stdout.put( "Enter a number greater than 100: " );
stdin.get( i );
if( i <= 100 ) then jmp RepeatLabel; endif;
// It also translates into the following "pure" assembly code:
RepeatLabel:
stdout.put( "Enter a number greater than 100: " );
stdin.get( i );
cmp( i, 100 );
jng RepeatLabel;
7.10.3 forever..endfor 循环
如果while循环在循环开始时测试终止条件,而repeat..until循环在循环结束时检查终止条件,那么唯一可以测试终止的地方就是循环的中间。HLA 高级forever..endfor循环结合break和breakif语句,提供了这种能力。forever..endfor循环采用以下形式:
forever
<< Loop body >>
endfor;
请注意,forever..endfor构造没有显式的终止条件。除非另有规定,forever..endfor构造会形成一个无限循环。通常使用breakif语句来处理循环的终止。考虑以下使用forever..endfor构造的 HLA 代码:
forever
stdin.get( *`character`* );
breakif( *`character`* = '.' );
stdout.put( *`character`* );
endfor;
将forever循环转换为纯汇编语言很简单。你需要的只是一个标签和一个jmp指令。这个例子中的breakif语句实际上不过是一个if语句和一个jmp指令。上述代码的纯汇编语言版本看起来大概是这样的:
foreverLabel:
stdin.get( *`character`* );
cmp( *`character`*, '.' );
je ForIsDone;
stdout.put( *`character`* );
jmp foreverLabel;
ForIsDone:
7.10.4 for 循环
for 循环是 while 循环的一种特殊形式,它会重复执行循环体指定次数。在 HLA 中,for 循环的形式如下:
for( *`Initialization_Stmt`*; *`Termination_Expression`*; *`inc_Stmt`* ) do
<< statements >>
endfor;
这完全等效于以下代码:
*`Initialization_Stmt`*;
while( *`Termination_Expression`* ) do
<< statements >>
*`inc_Stmt`*;
endwhile;
传统上,程序使用 for 循环来处理按顺序访问的数组和其他对象。通常,首先通过初始化语句初始化循环控制变量,然后使用该循环控制变量作为数组(或其他数据类型)的索引。例如:
for( mov( 0, esi ); esi < 7; inc( esi )) do
stdout.put( "Array Element = ", SomeArray[ esi*4 ], nl );
endfor;
要将其转换为纯汇编语言,首先将 for 循环转换为等效的 while 循环:
mov( 0, esi );
while( esi < 7 ) do
stdout.put( "Array Element = ", SomeArray[ esi*4 ], nl );
inc( esi );
endwhile;
现在,使用 while 循环部分中的技巧,将代码翻译成纯汇编语言:
mov( 0, esi );
WhileLp:
cmp( esi, 7 );
jnl EndWhileLp;
stdout.put( "Array Element = ", SomeArray[ esi*4 ], nl );
inc( esi );
jmp WhileLp;
EndWhileLp:
7.10.5 break 和 continue 语句
HLA 的 break 和 continue 语句都被翻译为单个 jmp 指令。break 指令会退出立即包含 break 语句的循环;continue 语句会重新启动立即包含 continue 语句的循环。
将 break 语句转换为纯汇编语言非常简单。只需发出一个 jmp 指令,将控制权转移到循环的 endxxxx(或 until)子句之后的第一条语句,以退出循环。可以通过在关联的 endxxxx 子句后面放置一个标签并跳转到该标签来实现这一点。以下代码片段展示了这种技术在不同循环中的应用。
// Breaking out of a FOREVER loop:
forever
<< stmts >>
// break;
jmp BreakFromForever;
<< stmts >>
endfor;
BreakFromForever:
// Breaking out of a FOR loop;
for( *`initStmt`*; *`expr`*; *`incStmt`* ) do
<< stmts >>
// break;
jmp BrkFromFor;
<< stmts >>
endfor;
BrkFromFor:
// Breaking out of a WHILE loop:
while( *`expr`* ) do
<< stmts >>
// break;
jmp BrkFromWhile;
<< stmts >>
endwhile;
BrkFromWhile:
// Breaking out of a REPEAT..UNTIL loop:
repeat
<< stmts >>
// 20break;
jmp BrkFromRpt;
<< stmts >>
until( *`expr`* );
BrkFromRpt:
continue 语句比 break 语句稍微复杂一些。实现仍然是单个 jmp 指令;但是,目标标签并不是每个不同的循环都指向相同的位置。图 7-2, 图 7-3, 图 7-4, 和 图 7-5 显示了 continue 语句在每个 HLA 循环中如何转移控制。

图 7-2. continue 目标用于 forever 循环

图 7-3. continue 目标和 while 循环

图 7-4. continue 目标和 for 循环

图 7-5. continue 目标和 repeat..until 循环
以下代码片段演示了如何将continue语句转换为每种循环类型的适当jmp指令。
forever..continue..endfor
// Conversion of forever loop with continue
// to pure assembly:
forever
<< stmts >>
continue;
<< stmts >>
endfor;
// Converted code:
foreverLbl:
<< stmts >>
// continue;
jmp foreverLbl;
<< stmts >>
jmp foreverLbl;
while..continue..endwhile
// Conversion of while loop with continue
// into pure assembly:
while( *`expr`* ) do
<< stmts >>
continue;
<< stmts >>
endwhile;
// Converted code:
whlLabel:
<< Code to evaluate *`expr`* >>
j*`cc`* EndOfWhile; // Skip loop on *`expr`* failure.
<< stmts >>
// continue;
jmp whlLabel; // Jump to start of loop on continue.
<< stmts >>
jmp whlLabel; // Repeat the code.
EndOfwhile:
for..continue..endfor
// Conversion for a for loop with continue
// into pure assembly:
for( *`initStmt`*; *`expr`*; *`incStmt`* ) do
<< stmts >>
continue;
<< stmts >>
endfor;
// Converted code:
*`initStmt`*
ForLpLbl:
<< Code to evaluate *`expr`* >>
j*`cc`* EndOfFor; // Branch if expression fails.
<< stmts >>
// continue;
jmp ContFor; // Branch to *`incStmt`* on continue.
<< stmts >>
ContFor:
*`incStmt`*
jmp ForLpLbl;
EndOfFor:
repeat..continue..until
repeat
<< stmts >>
continue;
<< stmts >>
until( *`expr`* );
// Converted code:
RptLpLbl:
<< stmts >>
// continue;
jmp ContRpt; // Continue branches to loop termination test.
<< stmts >>
ContRpt:
<< Code to test *`expr`* >>
j*`cc`* RptLpLbl; // Jumps if expression evaluates false.
7.10.6 寄存器使用与循环
考虑到 80x86 比内存位置更有效地访问寄存器,寄存器是放置循环控制变量的理想位置(特别是对于小循环)。然而,在循环中使用寄存器会遇到一些问题。使用寄存器作为循环控制变量的主要问题是寄存器是有限资源。以下代码由于尝试重用已经在使用的寄存器(CX)而无法正常工作:
mov( 8, cx );
loop1:
mov( 4, cx );
loop2:
<< stmts >>
dec( cx );
jnz loop2;
dec( cx );
jnz loop1;
这里的目的是创建一组嵌套的循环,即一个循环嵌套在另一个循环中。内层循环(loop2)应在外层循环(loop1)执行八次的过程中重复四次。不幸的是,两个循环都使用了相同的寄存器作为循环控制变量。因此,这将形成一个无限循环,因为在第一次循环结束时,CX 的值会变为 0。由于在遇到第二个dec指令时,CX 总是为 0,控制会一直跳转到loop1标签(因为递减 0 会产生非零结果)。解决方案是保存并恢复 CX 寄存器,或者在外层循环中使用不同的寄存器替代 CX:
mov( 8, cx );
loop1:
push( cx );
mov( 4, cx );
loop2:
<< stmts >>
dec( cx );
jnz loop2;
pop( cx );
dec( cx );
jnz loop1;
或者
mov( 8, dx );
loop1:
mov( 4, cx );
loop2:
<< stmts >>
dec( cx );
jnz loop2;
dec( dx );
jnz loop1;
寄存器损坏是汇编语言程序中循环出现错误的主要来源之一,因此要时刻注意这个问题。
^([109]) 当然,HLA 标准库提供了str.eq例程,用于比较字符串,从而有效地隐藏了循环,即使在汇编语言程序中也是如此。
^([110]) 请注意,HLA 实际上会将大多数while语句转换为与本节所示不同的 80x86 代码。差异的原因出现在 7.11 性能优化中,当我们探索如何编写更高效的循环代码时。
7.11 性能优化
80x86 微处理器以惊人的速度执行指令序列。因此,你很少会遇到不包含循环的慢程序。因为循环是程序性能问题的主要来源,它们是你在尝试加速软件时需要重点关注的地方。虽然关于如何编写高效程序的论文超出了本章的范围,但在设计程序中的循环时,有一些事情你需要注意。这些都是为了从循环中去除不必要的指令,以减少执行一次循环所需的时间。
7.11.1 将终止条件移到循环末尾
请考虑以下为前三种类型的循环展示的流程图:
repeat..until loop:
Initialization code
Loop body
Test for termination
Code following the loop
while loop:
Initialization code
Loop termination test
Loop body
Jump back to test
Code following the loop
forever..endfor loop:
Initialization code
Loop body part one
Loop termination test
Loop body part two
Jump back to Loop body part one
Code following the loop
如你所见,repeat..until循环是其中最简单的一种。这在这些循环的汇编语言实现中得到了体现。考虑以下语义完全相同的repeat..until和while循环:
// Example involving a WHILE loop:
mov( edi, esi );
sub( 20, esi );
while( esi <= edi ) do
<< stmts >>
inc( esi );
endwhile;
// Conversion of the code above into pure assembly language:
mov( edi, esi );
sub( 20, esi );
whlLbl:
cmp( esi, edi );
jnle EndOfWhile;
<< stmts >>
inc( esi );
<< stmts >>
jmp whlLbl;
EndOfWhile:
// Example involving a REPEAT..UNTIL loop:
mov( edi, esi );
sub( 20, esi );
repeat
<< stmts >>
inc( esi );
until( esi > edi );
// Conversion of the REPEAT..UNTIL loop into pure assembly:
rptLabel:
<< stmts >>
inc( esi );
cmp( esi, edi );
jng rptLabel;
正如通过仔细研究转换为纯汇编语言所看到的,在循环末尾测试终止条件使我们能够从循环中移除一个jmp指令。如果这个循环嵌套在其他循环中,这可能会很重要。在前面的例子中,执行循环体至少一次没有问题。根据循环的定义,你可以轻松看到该循环将被执行恰好 20 次。这表明转换为repeat..until循环是微不足道的,并且总是可能的。不幸的是,事情并不总是如此简单。考虑以下 HLA 代码:
while( esi <= edi ) do
<< stmts >>
inc( esi );
endwhile;
在这个特定的例子中,我们根本不知道 ESI 在进入循环时包含什么内容。因此,我们不能假设循环体至少会执行一次。因此,我们必须在执行循环体之前测试循环是否终止。测试可以放在循环的末尾,并包括一个jmp指令:
jmp WhlTest;
TopOfLoop:
<< stmts >>
inc( esi );
WhlTest:
cmp( esi, edi );
jle TopOfLoop;
尽管代码的长度与原始的while循环一样,jmp指令只执行一次,而不是每次循环时都执行。请注意,通过这种微小的效率提升,换来了可读性的小损失。上述第二段代码比原始实现更接近“意大利面条代码”。这往往是为了小幅性能提升所付出的代价。因此,你应该仔细分析代码,以确保性能提升是值得的,且不会失去清晰度。通常,汇编语言程序员为了可疑的性能提升而牺牲可读性,编写出难以理解的程序。
顺便提一下,HLA 将其高级的while语句转换为一系列指令,这些指令使用本节所描述的技术,在循环的底部测试循环终止条件。
7.11.2 反向执行循环
由于 80x86 标志的特点,从某个数字递减到(或递增到)0 的循环比从 0 执行到其他值的循环更高效。比较以下 HLA 的for循环及其生成的代码:
for( mov( 1, j ); j <= 8; inc( j ) ) do
<< stmts >>
endfor;
// Conversion to pure assembly (as well as using a REPEAT..UNTIL form):
mov( 1, j );
ForLp:
<< stmts >>
inc( j );
cmp( j, 8 );
jnge ForLp;
现在考虑另一个循环,它也有八次迭代,但它的循环控制变量是从 8 递减到 1,而不是从 1 递增到 8:
mov( 8, j );
LoopLbl:
<< stmts >>
dec( j );
jnz LoopLbl;
请注意,通过从 8 递减到 1 运行循环,我们节省了每次循环时的比较操作。
不幸的是,你不能强制所有循环都向后运行。然而,稍加努力并做一些调整,你应该能够编写许多for循环,使它们反向运行。在每次循环迭代中节省cmp指令的执行时间,可能会导致更快的代码。
上面的示例效果很好,因为循环从 8 执行到 1。当循环控制变量变为 0 时,循环终止。如果你需要在循环控制变量变为 0 时执行循环会发生什么呢?例如,假设上面的循环需要从 7 执行到 0。只要上限是正数,你可以用 jns 指令替换之前代码中的 jnz 指令:
mov( 7, j );
LoopLbl:
<< stmts >>
dec( j );
jns LoopLbl;
这个循环将重复八次,j 依次取值 7..0。当它将 0 减少到 -1 时,会设置符号标志,循环终止。
请记住,一些值看起来是正数,但实际上是负数。如果循环控制变量是字节,那么在二进制补码系统中,范围在 128..255 之间的值是负数。因此,初始化循环控制变量为范围 129..255 之间的任何 8 位值(或者当然是 0)会在一次执行后终止循环。如果不小心,这可能会导致问题。
7.11.3 循环不变计算
循环不变计算是指在循环中出现的、始终返回相同结果的计算。你不必在循环内执行这样的计算。你可以在循环外进行计算,然后在循环内引用计算结果。以下 HLA 代码展示了一个不变计算的示例:
for( mov( 0, eax ); eax < n; inc( eax )) do
mov( eax, edx );
add( j, edx );
sub( 2, edx );
add( edx, k );
endfor;
因为 j 在整个循环执行过程中都不变,所以子表达式 j-2 可以在循环外计算:
mov( j, ecx );
sub( 2, ecx );
for( mov( 0, eax ); eax < n; inc( eax )) do
mov( eax, edx );
add( ecx, edx );
add( edx, k );
endfor;
虽然通过将子表达式 j-2 移出循环外计算,我们消除了一个指令,但该计算仍然有一个不变成分。注意,这个不变成分在循环中执行 n 次;这意味着我们可以将之前的代码转换为如下:
mov( j, ecx );
sub( 2, ecx );
intmul( n, ecx ); // Compute n*(j-2) and add this into k outside
add( ecx, k ); // the loop.
for( mov( 0, eax ); eax < n; inc( eax )) do
add( eax, k );
endfor;
如你所见,我们已将循环体从四条指令减少到一条。当然,如果你真的想提高这个特定循环的效率,你可以完全不使用循环来计算结果(有一个与上述迭代计算对应的公式)。不过,这个简单的示例展示了如何从循环中消除循环不变计算。
7.11.4 展开循环
对于小型循环,即那些循环体只有几条语句的情况,处理循环所需的开销可能会占总处理时间的一个显著比例。例如,看看以下 Pascal 代码及其相关的 80x86 汇编语言代码:
for i := 3 downto 0 do A[i] := 0;
mov( 3, i );
LoopLbl:
mov( i, ebx );
mov( 0, A[ ebx*4 ] );
dec( i );
jns LoopLbl;
每次循环重复时会执行四条指令。只有一条指令在执行所需的操作(将 0 移动到 A 的一个元素中)。其余的三条指令则控制循环。因此,要执行逻辑上需要 4 次的操作,总共需要 16 条指令。
虽然我们可以基于目前提供的信息对这个循环做出许多改进,但请仔细考虑一下这个循环到底在做什么——它正在将四个 0 存储到A[0]到A[3]。一个更高效的方法是使用四条mov指令来完成相同的任务。例如,如果A是一个双字数组,那么下面的代码比上面的代码初始化A要快得多:
mov( 0, A[0] );
mov( 0, A[4] );
mov( 0, A[8] );
mov( 0, A[12] );
虽然这是一个简单的例子,但它展示了循环展开(也称为循环展开)的好处。如果这个简单的循环出现在一组嵌套循环中,4:1 的指令减少可能会使该部分程序的性能翻倍。
当然,并不是所有的循环都能展开。执行可变次数的循环很难展开,因为很少有方法可以在汇编时确定循环的迭代次数。因此,展开循环是一个最适用于已知迭代次数的循环的过程(并且这个次数在汇编时是已知的)。
即使你重复执行一个固定次数的循环,这也未必是循环展开的好选择。当控制循环(以及处理其他开销操作)的指令数量占循环总指令数的显著比例时,循环展开能够显著提升性能。如果前面的循环体包含 36 条指令(不包括 4 条开销指令),那么性能提升最多也不过是 10%(相比于现在的 300–400%提升)。因此,展开一个循环的成本,即必须插入程序中的所有额外代码,随着循环体变大或迭代次数增多,很快就会达到收益递减的临界点。此外,将这些代码插入程序也会变得非常麻烦。因此,循环展开是一个最佳应用于小型循环的技术。
注意,超标量的 80x86 处理器(Pentium 及之后的版本)具有分支预测硬件,并使用其他技术来提高性能。在这些系统上进行循环展开实际上可能会使代码变慢,因为这些处理器被优化为执行短小的循环。
7.11.5 归纳变量
考虑以下循环:
for i := 0 to 255 do csetVar[i] := {};
这里程序正在初始化一个字符集数组的每个元素为空集。实现这一目标的直接代码如下:
mov( 0, i );
FLp:
// Compute the index into the array (note that each element
// of a CSET array contains 16 bytes).
mov( i, ebx );
shl( 4, ebx );
// Set this element to the empty set (all 0 bits).
mov( 0, csetVar[ ebx ] );
mov( 0, csetVar[ ebx+4 ] );
mov( 0, csetVar[ ebx+8 ] );
mov( 0, csetVar[ ebx+12 ] );
inc( i );
cmp( i, 256 );
jb FLp;
尽管解开这段代码仍然会提高性能,但要完成这项任务需要 1,024 条指令,对于除最关键的时间应用外,数量过多。然而,你可以通过使用归纳变量来减少循环体的执行时间。归纳变量是指其值完全依赖于其他变量的值。在上面的示例中,数组 csetVar 中的索引跟踪循环控制变量(它始终等于循环控制变量的值乘以 16)。由于 i 在循环中没有其他用途,因此在 i 上执行计算没有意义。为什么不直接操作数组索引值呢?下面的代码演示了这一技巧:
mov( 0, ebx );
FLp:
mov( 0, csetVar[ ebx ]);
mov( 0, csetVar[ ebx+4 ] );
mov( 0, csetVar[ ebx+8 ] );
mov( 0, csetVar[ ebx+12 ] );
add( 16, ebx );
cmp( ebx, 256*16 );
jb FLp;
在此示例中发生的归纳操作是在每次循环迭代时通过将循环控制变量(出于效率原因已移入 EBX)增加 16 来进行,而不是增加 1。通过将循环控制变量乘以 16(以及最终的循环终止常量值),代码可以消除在每次循环迭代时将循环控制变量乘以 16 的操作(即,这使得我们可以去除先前代码中的 shl 指令)。此外,由于这段代码不再引用原始的循环控制变量(i),代码可以将循环控制变量严格保持在 EBX 寄存器中。
7.12 HLA 中的混合控制结构
HLA 高级语言控制结构有一些缺点:(1)它们不是纯粹的汇编语言指令,(2)复杂的布尔表达式仅支持短路求值,(3)它们通常会将低效的编码实践引入到一种大多数人仅在需要编写高性能代码时使用的语言中。另一方面,虽然 80x86 低级控制结构允许你编写高效的代码,但生成的代码非常难以阅读和维护。HLA 提供了一组混合控制结构,允许你使用纯汇编语言语句来求值布尔表达式,同时使用高级控制结构来划定由布尔表达式控制的语句。结果是,代码比纯汇编语言更具可读性,而效率损失不大。
HLA 提供了混合形式的if..elseif..else..endif、while..endwhile、repeat..until、breakif、exitif和continueif语句(即涉及布尔表达式的语句)。例如,混合形式的 if 语句如下所示:
if( #{ *`instructions`* }# ) then *`statements`* endif;
注意 #{ 和 }# 操作符的使用,它们将一系列指令包围在该语句中。这就是混合控制结构与标准高级语言控制结构的区别所在。其余的混合控制结构的形式如下:
while( #{ *`statements`* }# ) *`statements`* endwhile;
repeat *`statements`* until( #{ *`statements`* }# );
breakif( #{ *`statements`* }# );
exitif( #{ *`statements`* }# );
continueif( #{ *`statements`* }# );
大括号中的语句替代了 HLA 高级控制结构中的普通布尔表达式。这些语句之所以特殊,是因为 HLA 在其上下文中定义了两个伪标签,true和false。HLA 将标签true与通常会执行的代码关联起来,该代码会在布尔表达式存在且其结果为真时执行。同样,HLA 将标签false与在这些语句中布尔表达式评估为假时会执行的代码关联起来。作为一个简单的例子,请考虑以下两个(等效的)if语句:
if( eax < ebx ) then inc( eax ); endif;
if
( #{
cmp( eax, ebx );
jnb false;
}# ) then
inc( eax );
endif;
在这个后续示例中,jnb将控制转移到false标签,如果 EAX 不小于 EBX,则会跳过inc指令。请注意,如果 EAX 小于 EBX,则控制会继续执行inc指令。这大致等价于以下纯汇编代码:
cmp( eax, ebx );
jnb falseLabel;
inc( eax );
falseLabel:
作为一个稍微复杂一点的例子,请考虑以下语句:
if( eax >= j && eax <= k ) then sub( j, eax ); endif;
以下混合型if语句实现了上述功能:
if
( #{
cmp( eax, j );
jnae false;
cmp( eax, k );
jnae false;
}# ) then
sub( j, eax );
endif;
作为混合型if语句的最后一个例子,请考虑以下内容:
// if( ((eax > ebx) && (eax < ecx)) || (eax = edx)) then
// mov( ebx, eax );
// endif;
if
( #{
cmp( eax, edx );
je true;
cmp( eax, ebx );
jng false;
cmp( eax, ecx );
jnb false;
}# ) then
mov( ebx, eax );
endif;
由于这些例子相当简单,它们并没有真正展示使用混合语句而非纯汇编代码时,代码可读性提升的程度。然而,有一点你应该注意到的是,使用混合语句可以消除在代码中插入标签的需求。这可以使你的程序更易于阅读和理解。
对于if语句,true标签对应语句的then部分;false标签对应elseif、else或endif部分(即紧随then部分之后的部分)。对于while循环,true标签对应循环体,而false标签则附加到紧随endwhile之后的第一条语句。对于repeat..until语句,true标签附加到until子句之后的代码,而false标签附加到循环体的第一条语句。breakif、exitif和continueif语句将false标签与这些语句之后的第一条语句关联,将true标签与通常与break、exit或continue语句相关联的代码关联。
7.13 获取更多信息
HLA 包含了一些本章未描述的其他高级控制结构。例如,try..endtry块和foreach语句。没有在本章讨论这些语句,因为它们是高级控制结构,它们的实现太复杂,无法在本文的早期阶段进行描述。有关其实现的更多信息,请参阅电子版 www.artofasm.com/(或 webster.cs.ucr.edu/)或 HLA 参考手册。
第八章 高级算术

本章讨论了汇编语言特别适合的那些算术操作。它涵盖了四个主要主题:扩展精度算术、不同大小操作数的算术、十进制算术以及通过表查找进行计算。
到目前为止,本章涉及的最广泛的主题是多精度算术。到本章结束时,你将知道如何对任何大小的整数操作数进行算术和逻辑运算。如果你需要处理超出±20 亿范围的整数值(或无符号值超过 40 亿),不用担心;本章将向你展示如何完成这项工作。
不同大小的操作数也会带来一些特殊的问题。例如,你可能想要将一个 64 位无符号整数与一个 128 位有符号整数相加。本章讨论如何将这两个操作数转换为兼容的格式。
本章还讨论了使用 80x86 BCD(十进制编码二进制)指令和 FPU(浮点单元)进行十进制算术。这使你能够在那些绝对需要十进制操作的少数应用中使用十进制算术。
最后,本章通过讨论如何使用表查找加速复杂计算来结束。
8.1 多精度操作
汇编语言相较于高级语言的一个大优势是,它不限制整数操作的大小。例如,标准 C 编程语言定义了三种不同的整数大小:short int、int和long int。^([111]) 在 PC 上,这些通常是 16 位和 32 位整数。尽管 80x86 机器指令限制你使用单一指令处理 8 位、16 位或 32 位整数,但你总是可以使用多条指令处理任何大小的整数。如果你想将 256 位整数相加,没问题;在汇编语言中做到这一点相对容易。接下来的部分将描述如何将各种算术和逻辑操作从 16 位或 32 位扩展到任何你想要的位数。
8.1.1 HLA 标准库对扩展精度操作的支持
虽然了解如何自己进行扩展精度算术非常重要,但你应该注意,HLA 标准库提供了一整套 64 位和 128 位算术和逻辑函数,你可以使用这些函数。这些例程是通用的,且非常方便使用。本节简要介绍了 HLA 标准库对扩展精度算术的支持。
如前几章所述,HLA 编译器支持几种不同的 64 位和 128 位数据类型。这些扩展的数据类型包括:
-
uns64: 64 位无符号整数 -
int64: 64 位有符号整数 -
qword: 64 位无类型值 -
uns128: 128 位无符号整数 -
int128: 128 位有符号整数 -
lword: 128 位无类型值
HLA 还提供了一个tbyte类型,但我们在此不予考虑(请参见 8.2 操作不同大小的操作数)。
HLA 完全支持 64 位和 128 位字面常量以及常量运算。这使您可以使用标准的十进制、十六进制或二进制表示法初始化 64 位和 128 位的静态对象。例如:
static
u128 :uns128 := 123456789012345678901233567890;
i64 :int64 := −12345678901234567890;
lw :lword := $1234_5678_90ab_cdef_0000_ffff;
为了便于操作 64 位和 128 位的值,HLA 标准库的math.hhf模块提供了一组处理大多数标准算术和逻辑操作的函数。您可以像使用 32 位算术和逻辑指令一样使用这些函数。例如,考虑math.addq(qword)和math.addl(lword)函数:
math.addq( *`left64`*, *`right64`*, *`dest64`* );
math.addl( *`left128`*, *`right128`*, *`dest128`* );
这些函数计算以下内容:
*`dest64`* := *`left64`* + *`right64`*; // *`dest64`*, *`left64`*, and *`right64`*
// must be 8-byte operands
*`dest128`* := *`left128`* + *`right128`*; // *`dest128`*, *`left128`*, and *`right128`*
// must be 16-byte operands
这些函数设置 80x86 标志的方式与执行 add 指令后的预期结果相同。具体来说,这些函数会在(完整的)结果为 0 时设置零标志,如果有进位,则设置进位标志,如果出现符号溢出,则设置溢出标志,如果结果的高位字节(H.O.)包含 1,则设置符号标志。
其余的大多数算术和逻辑例程使用与 math.addq 和 math.addl 相同的调用序列。简要介绍如下:
math.andq( *`left64`*, *`right64`*, *`dest64`* );
math.andl( *`left128`*, *`right128`*, *`dest128`* );
math.divq( *`left64`*, *`right64`*, *`dest64`* );
math.divl( *`left128`*, *`right128`*, *`dest128`* );
math.idivq( *`left64`*, *`right64`*, *`dest64`* );
math.idivl( *`left128`*, *`right128`*, *`dest128`* );
math.modq( *`left64`*, *`right64`*, *`dest64`* );
math.modl( *`left128`*, *`right128`*, *`dest128`* );
math.imodq( *`left64`*, *`right64`*, *`dest64`* );
math.imodl( *`left128`*, *`right128`*, *`dest128`* );
math.mulq( *`left64`*, *`right64`*, *`dest64`* );
math.mull( *`left128`*, *`right128`*, *`dest128`* );
math.imulq( *`left64`*, *`right64`*, *`dest64`* );
math.imull( *`left128`*, *`right128`*, *`dest128`* );
math.orq( *`left64`*, *`right64`*, *`dest64`* );
math.orl( *`left128`*, *`right128`*, *`dest128`* );
math.subq( *`left64`*, *`right64`*, *`dest64`* );
math.subl( *`left128`*, *`right128`*, *`dest128`* );
math.xorq( *`left64`*, *`right64`*, *`dest64`* );
math.xorl( *`left128`*, *`right128`*, *`dest128`* );
这些函数设置标志的方式与相应的 32 位机器指令相同,并且在除法和余数(取模)函数的情况下,会触发相同的异常。请注意,乘法函数不会产生扩展精度的结果。目标值与源操作数的大小相同。如果结果无法适应目标操作数,这些函数会设置溢出和进位标志。所有这些函数计算以下内容:
*`dest64`* := *`left64 op right64`*;
*`dest128`* := *`left128 op right128`*;
其中 op 表示特定的操作。
除了这些函数,HLA 标准库的数学模块还提供了一些额外的函数,其语法与math.addq和math.addl略有不同。这些函数包括math.negq、math.negl、math.notq、math.notl、math.shlq、math.shll、math.shrq和math.shrl。请注意,没有旋转或算术右移函数。然而,您很快会发现,使用标准指令可以轻松合成这些操作。以下是这些附加函数的原型:
math.negq( source:qword; var dest:qword );
math.negl( source:lword; var dest:lword );
math.notq( source:qword; var dest:qword );
math.notl( source:lword; var dest:lword );
math.shlq( count:uns32; source:qword; var dest:qword );
math.shll( count:uns32; source:lword; var dest:lword );
math.shrq( count:uns32; source:qword; var dest:qword );
math.shrl( count:uns32; source:lword; var dest:lword );
再次强调,所有这些函数设置标志的方式与相应的机器指令在支持 64 位或 128 位操作数时所设置的标志完全相同。
HLA 标准库还提供了完整的 64 位和 128 位值的输入/输出和转换例程。例如,你可以使用 stdout.put 显示 64 位和 128 位的值,也可以使用 stdin.get 读取这些值,HLA 转换模块中还有一组例程可以在这些值和它们的字符串表示之间进行转换。一般来说,任何你可以对 32 位值执行的操作,也可以对 64 位或 128 位值执行。详细信息请参见 HLA 标准库文档。
8.1.2 多精度加法操作
80x86 add 指令用于将两个 8 位、16 位或 32 位的数字相加。在执行完 add 指令后,如果和的高位有溢出,80x86 的进位标志会被设置。你可以使用这些信息进行多精度加法操作。考虑你手动进行多位数(多精度)加法操作的方法:
Step 1: Add the least significant digits together:
289 289
+456 produces +456
---- ----
5 with carry 1.
Step 2: Add the next significant digits plus the carry:
1 (previous carry)
289 289
+456 produces +456
---- ----
5 45 with carry 1.
Step 3: Add the most significant digits plus the carry:
1 (previous carry)
289 289
+456 produces +456
---- ----
45 745
80x86 以相同的方式处理扩展精度算术,只不过它不是一次加一个数字,而是一次加一个字节、一个字或一个双字。考虑图 8-1 中的三个双字(96 位)加法操作。

图 8-1. 将两个 96 位对象相加
正如你从这张图中看到的,核心思想是将一个较大的操作分解成一系列更小的操作。由于 x86 处理器家族每次最多只能加 32 位数字,所以操作必须分块进行,每块最多 32 位。因此,第一步是将两个 L.O. 双字加在一起,就像你在手动算法中将两个 L.O. 位加在一起一样。这项操作没有什么特别的;你可以使用 add 指令来完成。
第二步是将两个 96 位值中的第二对双字加在一起。注意,在步骤 2 中,计算还必须加上前一次加法的进位(如果有的话)。如果 L.O. 加法有进位,add 指令会将进位标志设置为 1;相反,如果 L.O. 加法没有进位,之前的 add 指令会清除进位标志。因此,在第二次加法中,我们实际上需要计算两个双字的和,再加上第一次指令的进位。幸运的是,x86 CPU 提供了一条可以完成此操作的指令:adc(带进位加法)指令。adc 指令与 add 指令使用相同的语法,执行的操作几乎相同:
adc( *`source`*, *`dest`* ); // *`dest`* := *`dest`* + *`source`* + C
如你所见,add和adc指令之间的唯一区别是,adc指令在源操作数和目标操作数相加的同时,还会将进位标志的值加进来。它还会像add指令一样设置标志(包括在发生无符号溢出时设置进位标志)。这正是我们需要将 96 位和数的中间两个双字加在一起的方式。
在图 8-1 的第 3 步中,算法将 96 位值的高位双字相加。这个加法操作还必须将中间两个双字相加的进位考虑进去;因此,这里也需要使用adc指令。总的来说,add指令将低位双字相加。adc(带进位加法)指令将其他双字对相加。在扩展精度加法序列结束时,进位标志指示无符号溢出(如果设置),溢出标志指示有符号溢出,符号标志指示结果的符号。零标志在扩展精度加法结束时没有实际意义(它仅表示两个高位双字的和为 0,并不表示整个结果为 0)。如果你想查看如何检查扩展精度零结果,请参阅 HLA 标准库中math.addq或math.addl函数的源代码。
例如,假设你有两个 64 位的值希望相加,它们定义如下:
static
X: qword;
Y: qword;
假设你还想将和存储在第三个变量Z中,该变量也是qword。以下 80x86 代码将完成此任务:
mov( (type dword X), eax ); // Add together the L.O. 32 bits
add( (type dword Y), eax ); // of the numbers and store the
mov( eax, (type dword Z) ); // result into the L.O. dword of Z.
mov( (type dword X[4]), eax ); // Add together (with carry) the
adc( (type dword Y[4]), eax ); // H.O. 32 bits and store the result
mov( eax, (type dword Z[4]) ); // into the H.O. dword of Z.
记住,这些变量是qword对象。因此,编译器不会接受mov( X, eax );这种形式的指令,因为该指令会尝试将 64 位的值加载到 32 位寄存器中。此代码使用强制转换操作符将符号X、Y和Z强制转换为 32 位。前三条指令将X和Y的低位双字相加,并将结果存储在Z的低位双字中。最后三条指令将X和Y的高位双字相加,并加上低位字的进位,将结果存储在Z的高位双字中。记住,形式为X[4]的地址表达式访问的是 64 位实体的高位双字。这是因为 x86 内存空间按字节寻址,4 个连续字节组成一个双字。
你可以通过使用adc指令加上高位值,将其扩展到任意位数。例如,若要将两个 128 位值相加,你可以使用如下代码:
type
tBig: dword[4]; // Storage for four dwords is 128 bits.
static
BigVal1: tBig;
BigVal2: tBig;
BigVal3: tBig;
.
.
.
mov( BigVal1[0], eax ); // Note there is no need for (type dword BigValx)
add( BigVal2[0], eax ); // because the base type of BitValx is dword.
mov( eax, BigVal3[0] );
mov( BigVal1[4], eax );
adc( BigVal2[4], eax );
mov( eax, BigVal3[4] );
mov( BigVal1[8], eax );
adc( BigVal2[8], eax );
mov( eax, BigVal3[8] );
mov( BigVal1[12], eax );
adc( BigVal2[12], eax );
mov( eax, BigVal3[12] );
8.1.3 多精度减法操作
80x86 执行多字节减法,就像它执行加法一样,方式与手动操作相同,只不过它一次减去的是整个字节、字或双字,而不是十进制数字。这个机制与add操作的机制类似。你在低阶字节/字/双字上使用sub指令,而在高阶值上使用sub(带借位)指令。
以下示例演示了使用 80x86 的 32 位寄存器进行的 64 位减法:
static
Left: qword;
Right: qword;
Diff: qword;
.
.
.
mov( (type dword Left), eax );
sub( (type dword Right), eax );
mov( eax, (type dword Diff) );
mov( (type dword Left[4]), eax );
sbb( (type dword Right[4]), eax );
mov( (type dword Diff[4]), eax );
以下示例演示了一个 128 位减法:
type
tBig: dword[4]; // Storage for four dwords is 128 bits.
static
BigVal1: tBig;
BigVal2: tBig;
BigVal3: tBig;
.
.
.
// Compute BigVal3 := BigVal1 - BigVal2
mov( BigVal1[0], eax ); // Note there is no need for (type dword BigValx)
sub( BigVal2[0], eax ); // because the base type of BitValx is dword.
mov( eax, BigVal3[0] );
mov( BigVal1[4], eax );
sbb( BigVal2[4], eax );
mov( eax, BigVal3[4] );
mov( BigVal1[8], eax );
sbb( BigVal2[8], eax );
mov( eax, BigVal3[8] );
mov( BigVal1[12], eax );
sbb( BigVal2[12], eax );
mov( eax, BigVal3[12] );
8.1.4 扩展精度比较
不幸的是,没有“带借位比较”指令可以用于执行扩展精度比较。由于cmp和sub指令执行相同的操作,至少就标志位而言,你可能会猜测可以使用sub指令来合成扩展精度比较;然而,这种方法并不总是有效。幸运的是,存在更好的解决方案。
考虑两个无符号值$2157 和$1293。这两个值的低阶字节不影响比较结果。只需比较高阶字节,$21 与$12,就能判断第一个值大于第二个值。事实上,只有在高阶字节相等时,你才需要查看这两个字节。在所有其他情况下,比较高阶字节就能告诉你所有你需要了解的值。当然,这对于任意字节数都是成立的,不仅仅是 2 个字节。以下代码通过先比较高阶双字,再在高阶双字相等时才比较低阶双字,来比较两个有符号的 64 位整数:
// This sequence transfers control to location "IsGreater" if
// QwordValue > QwordValue2\. It transfers control to "IsLess" if
// QwordValue < QwordValue2\. It falls through to the instruction
// following this sequence if QwordValue = QwordValue2\. To test for
// inequality, change the "IsGreater" and "IsLess" operands to "NotEqual"
// in this code.
mov( (type dword QWordValue[4]), eax ); // Get H.O. dword.
cmp( eax, (type dword QWordValue2[4]));
jg IsGreater;
jl IsLess;
mov( (type dword QWordValue[0]), eax ); // If H.O. dwords were equal,
cmp( eax, (type dword QWordValue2[0])); // then we must compare the
jg IsGreater; // L.O. dwords.
jl IsLess;
// Fall through to this point if the two values were equal.
要比较无符号值,只需将jg和jl指令分别替换为ja和jb。
你可以轻松地从前面的序列中合成任何可能的比较。以下示例展示了如何做到这一点。这些示例演示了有符号比较;如果你需要无符号比较,只需将jg、jge、jl、jle分别替换为ja、jae、jb、jbe。每个示例假设以下声明:
static
QW1: qword;
QW2: qword;
const
QW1d: text := "(type dword QW1)";
QW2d: text := "(type dword QW2)";
以下代码实现了一个 64 位测试,检查QW1 < QW2(有符号)。如果QW1 < QW2,控制流转移到IsLess标签。如果不成立,控制流将继续到下一条语句。
mov( QW1d[4], eax ); // Get H.O. dword.
cmp( eax, QW2d[4] );
jg NotLess;
jl IsLess;
mov( QW1d[0], eax ); // Fall through to here if the H.O. dwords are equal.
cmp( eax, QW2d[0] );
jl IsLess;
NotLess:
这是一个 64 位测试,检查QW1 <= QW2(有符号)。如果条件成立,这段代码会跳转到IsLessEq。
mov( QW1d[4], eax ); // Get H.O. dword.
cmp( eax, QW2d[4] );
jg NotLessEQ;
jl IsLessEQ;
mov( QW1d[0], eax ); // Fall through to here if the H.O. dwords are equal.
cmp( eax, QW2d[0] );
jle IsLessEQ;
NotLessEQ:
这是一个 64 位测试,检查QW1 > QW2(有符号)。如果条件成立,它会跳转到IsGtr。
mov( QW1d[4], eax ); // Get H.O. dword.
cmp( eax, QW2d[4] );
jg IsGtr;
jl NotGtr;
mov( QW1d[0], eax ); // Fall through to here if the H.O. dwords are equal.
cmp( eax, QW2d[0] );
jg IsGtr;
NotGtr:
以下是一个 64 位测试,检查QW1 >= QW2(有符号)。如果条件成立,这段代码会跳转到标签IsGtrEQ。
mov( QW1d[4], eax ); // Get H.O. dword.
cmp( eax, QW2d[4] );
jg IsGtrEQ;
jl NotGtrEQ;
mov( QW1d[0], eax ); // Fall through to here if the H.O. dwords are equal.
cmp( eax, QW2d[0] );
jge IsGtrEQ;
NotGtrEQ:
这是一个 64 位测试,检查QW1 = QW2(有符号或无符号)。如果QW1 = QW2,这段代码会跳转到IsEqual标签。如果不相等,则继续执行下一条指令。
mov( QW1d[4], eax ); // Get H.O. dword.
cmp( eax, QW2d[4] );
jne NotEqual;
mov( QW1d[0], eax ); // Fall through to here if the H.O. dwords are equal.
cmp( eax, QW2d[0] );
je IsEqual;
NotEqual:
以下是一个 64 位测试,用于检查 QW1 <> QW2(有符号或无符号)。如果 QW1 <> QW2,这段代码会跳转到标签 NotEqual。如果它们相等,则会继续执行下一条指令。
mov( QW1d[4], eax ); // Get H.O. dword.
cmp( eax, QW2d[4] );
jne IsNotEqual;
mov( QW1d[0], eax ); // Fall through to here if the H.O. dwords are equal.
cmp( eax, QW2d[0] );
jne IsNotEqual;
// Fall through to this point if they are equal.
如果需要进行扩展精度比较,则无法直接使用 HLA 高级控制结构。然而,你可以使用 HLA 混合控制结构,并将适当的比较嵌入到布尔表达式中。这样做可能会使代码更易于阅读。例如,下面的if..then..else..endif语句使用 64 位扩展精度无符号比较来检查 QW1 > QW2:
if
( #{
mov( QW1d[4], eax );
cmp( eax, QW2d[4] );
jg true;
mov( QW1d[0], eax );
cmp( eax, QW2d[0] );
jng false;
}# ) then
<< Code to execute if QW1 > QW2 >>
else
<< Code to execute if QW1 <= QW2 >>
endif;
如果需要比较大于 64 位的对象,可以很容易地将上面给出的 64 位操作数的代码进行泛化。总是从对象的高阶双字开始比较,并一直向下比较到对象的低阶双字,只要相应的双字相等。以下示例比较两个 128 位值,以检查第一个值是否小于或等于(无符号)第二个值:
static
Big1: uns128;
Big2: uns128;
.
.
.
if
( #{
mov( Big1[12], eax );
cmp( eax, Big2[12] );
jb true;
ja false;
mov( Big1[8], eax );
cmp( eax, Big2[8] );
jb true;
ja false;
mov( Big1[4], eax );
cmp( eax, Big2[4] );
jb true;
ja false;
mov( Big1[0], eax );
cmp( eax, Big2[0] );
jnbe false;
}# ) then
<< Code to execute if Big1 <= Big2 >>
else
<< Code to execute if Big1 > Big2 >>
endif;
8.1.5 扩展精度乘法
尽管 8×8 位、16×16 位或 32×32 位的乘法通常足够,但有时你可能需要乘以更大的值。你将使用 x86 单操作数的 mul 和 imul 指令进行扩展精度乘法操作。
毫不奇怪(考虑到我们如何通过 adc 和 sbb 实现扩展精度加法),你使用相同的技术在 80x86 上执行扩展精度乘法,就像手动乘两个值时所采用的方法一样。考虑一种简化形式的手工多位数乘法方式:
1) Multiply the first two 2) Multiply 5*2:
digits together (5*3):
123 123
45 45
--- ---
15 15
10
3) Multiply 5*1: 4) Multiply 4*3:
123 123
45 45
--- ---
15 15
10 10
5 5
12
5) Multiply 4*2: 6) Multiply 4*1:
123 123
45 45
--- ---
15 15
10 10
5 5
12 12
8 8
4
7) Add all the partial products together:
123
45
---
15
10
5
12
8
4
------
5535
80x86 以相同的方式执行扩展精度乘法,唯一不同的是它操作的是字节、字和双字,而不是数字。图 8-2 展示了这种工作方式。

图 8-2. 扩展精度乘法
在进行扩展精度乘法时,可能最重要的是记住,必须同时执行多重精度加法。将所有部分积相加需要进行几次加法运算,最终产生结果。示例 8-1 展示了在 32 位处理器上乘以两个 64 位值的正确方法。
示例 8-1. 扩展精度乘法
program testMUL64;
#include( "stdlib.hhf" )
procedure MUL64( Multiplier:qword; Multiplicand:qword; var Product:lword );
const
mp: text := "(type dword Multiplier)";
mc: text := "(type dword Multiplicand)";
prd:text := "(type dword [edi])";
begin MUL64;
mov( Product, edi );
// Multiply the L.O. dword of Multiplier times Multiplicand.
mov( mp, eax );
mul( mc, eax ); // Multiply L.O. dwords.
mov( eax, prd ); // Save L.O. dword of product.
mov( edx, ecx ); // Save H.O. dword of partial product result.
mov( mp, eax );
mul( mc[4], eax ); // Multiply mp(L.O.) * mc(H.O.)
add( ecx, eax ); // Add to the partial product.
adc( 0, edx ); // Don't forget the carry!
mov( eax, ebx ); // Save partial product for now.
mov( edx, ecx );
// Multiply the H.O. word of Multiplier with Multiplicand.
mov( mp[4], eax ); // Get H.O. dword of Multiplier.
mul( mc, eax ); // Multiply by L.O. word of Multiplicand.
add( ebx, eax ); // Add to the partial product.
mov( eax, prd[4] ); // Save the partial product.
adc( edx, ecx ); // Add in the carry!
mov( mp[4], eax ); // Multiply the two H.O. dwords together.
mul( mc[4], eax );
add( ecx, eax ); // Add in partial product.
adc( 0, edx ); // Don't forget the carry!
mov( eax, prd[8] ); // Save the partial product.
mov( edx, prd[12] );
end MUL64;
static
op1: qword;
op2: qword;
rslt: lword;
begin testMUL64;
// Initialize the qword values (note that static objects
// are initialized with 0 bits).
mov( 1234, (type dword op1 ));
mov( 5678, (type dword op2 ));
MUL64( op1, op2, rslt );
// The following only prints the L.O. qword, but
// we know the H.O. qword is 0 so this is okay.
stdout.put( "rslt=" );
stdout.putu64( (type qword rslt));
end testMUL64;
你需要记住的一件事是,这段代码仅适用于无符号操作数。要将两个有符号值相乘,必须在乘法之前注意操作数的符号,取两个操作数的绝对值,进行无符号乘法,然后根据原操作数的符号调整结果积的符号。有符号操作数的乘法留给读者自己做(或者你可以查看 HLA 标准库中的源代码)。
示例 8-1 中的例子相当直接,因为可以将部分积保存在不同的寄存器中。如果你需要将更大的值相乘,就需要将部分积保存在临时(内存)变量中。除此之外,示例 8-1 使用的算法可以推广到任意数量的双字。
8.1.6 扩展精度除法
你不能通过div和idiv指令合成一个通用的n位/m位除法操作。扩展精度除法需要一系列的移位和减法指令,过程非常复杂。然而,除去一般的操作,将n位数除以 32 位数是可以通过div指令轻松合成的。本节介绍了两种扩展精度除法的方法。
在我们描述如何执行多精度除法操作之前,你需要注意,尽管某些操作看起来可以用单一的div或idiv指令进行计算,但仍然需要扩展精度除法。将一个 64 位数除以一个 32 位数很简单,只要结果商可以适应 32 位。div和idiv指令会直接处理这个问题。然而,如果商不能适应 32 位,那么你必须将这个问题作为扩展精度除法来处理。这里的技巧是,将被除数的高字(H.O.)双字(无符号扩展或符号扩展)除以除数,然后用余数和被除数的低字(L.O.)再次进行相同操作。以下的步骤展示了这一过程。
static
dividend: dword[2] := [$1234, 4]; // = $4_0000_1234.
divisor: dword := 2; // dividend/divisor = $2_0000_091A
quotient: dword[2];
remainder:dword;
.
.
.
mov( divisor, ebx );
mov( dividend[4], eax );
xor( edx, edx ); // Zero extend for unsigned division.
div( ebx, edx:eax );
mov( eax, quotient[4] ); // Save H.O. dword of the quotient (2).
mov( dividend[0], eax ); // Note that this code does *NOT* zero extend
div( ebx, edx:eax ); // eax into edx before this div instr.
mov( eax, quotient[0] ); // Save L.O. dword of the quotient ($91a).
mov( edx, remainder ); // Save away the remainder.
由于除以 1 是完全合法的,因此结果商可能需要与被除数一样多的位数。这就是为什么在这个例子中,quotient变量和dividend变量的大小相同(64 位)(注意使用两个双字数组而非qword类型;这样可以避免代码中将操作数强制转换为双字)。无论被除数和除数操作数的大小如何,余数的大小总是不会超过除法操作的大小(此例中为 32 位)。因此,例子中的remainder变量仅为一个双字。
在分析这段代码如何工作之前,让我们简要了解一下为什么单一的 64/32 除法对于这个特定的例子不起作用,尽管div指令确实能够计算 64/32 除法的结果。假设 x86 能够执行这种操作的天真方法大致如下:
// This code does *NOT* work!
mov( dividend[0], eax ); // Get dividend into edx:eax
mov( dividend[4], edx );
div( divisor, edx:eax ); // Divide edx:eax by divisor.
虽然这段代码在语法上是正确的并且能够编译,但是如果你尝试运行这段代码,它将引发一个ex.DivideError^([112])异常。原因是商必须能够适配 32 位,因为商的值是$2_0000_091A,它无法适配 EAX 寄存器,因此会引发异常。
现在我们再看一下之前那段能够正确计算 64/32 商的代码。该代码首先计算dividend[4]与除数的 32/32 商。这个除法的商(2)成为最终商的高位双字。这个除法的余数(0)成为除法操作第二部分的 EDX 扩展。代码的第二部分将edx:dividend[0]除以除数,以产生商的低位双字和除法的余数。注意,代码在第二个div指令之前没有将 EAX 零扩展到 EDX。EDX 已经包含有效位,这段代码不能打乱这些位。
上面的 64/32 除法操作实际上只是通用除法操作的一种特殊情况,它允许你将一个任意大小的值除以一个 32 位的除数。为了实现这一点,你首先将被除数的高位双字移入 EAX 寄存器,并将其零扩展到 EDX 寄存器。接着,你将这个值除以除数。然后,在不修改 EDX 的情况下,存储部分商,将 EAX 加载为被除数的下一个低位双字,并用除数除以它。你重复这个操作,直到处理完被除数中的所有双字。这时,EDX 寄存器将包含余数。示例 8-2 中的程序演示了如何将一个 128 位的数除以一个 32 位的除数,得到一个 128 位的商和一个 32 位的余数。
示例 8-2. 无符号 128/32 位扩展精度除法
program testDiv128;
#include( "stdlib.hhf" )
procedure div128
(
Dividend: lword;
Divisor: dword;
var QuotAdrs: lword;
var Remainder: dword
); @nodisplay;
const
Quotient: text := "(type dword [edi])";
begin div128;
push( eax );
push( edx );
push( edi );
mov( QuotAdrs, edi ); // Pointer to quotient storage.
mov( (type dword Dividend[12]), eax ); // Begin division with the H.O. dword.
xor( edx, edx ); // Zero extend into edx.
div( Divisor, edx:eax ); // Divide H.O. dword.
mov( eax, Quotient[12] ); // Store away H.O. dword of quotient.
mov( (type dword Dividend[8]), eax ); // Get dword #2 from the dividend.
div( Divisor, edx:eax ); // Continue the division.
mov( eax, Quotient[8] ); // Store away dword #2 of the quotient.
mov( (type dword Dividend[4]), eax ); // Get dword #1 from the dividend.
div( Divisor, edx:eax ); // Continue the division.
mov( eax, Quotient[4] ); // Store away dword #1 of the quotient.
mov( (type dword Dividend[0]), eax ); // Get the L.O. dword of the
// dividend.
div( Divisor, edx:eax ); // Finish the division.
mov( eax, Quotient[0] ); // Store away the L.O. dword of the quotient.
mov( Remainder, edi ); // Get the pointer to the remainder's value.
mov( edx, [edi] ); // Store away the remainder value.
pop( edi );
pop( edx );
pop( eax );
end div128;
static
op1: lword := $8888_8888_6666_6666_4444_4444_2222_2221;
op2: dword := 2;
quo: lword;
rmndr: dword;
begin testDiv128;
div128( op1, op2, quo, rmndr );
stdout.put
(
nl
nl
"After the division: " nl
nl
"Quotient = $",
quo[12], "_",
quo[8], "_",
quo[4], "_",
quo[0], nl
"Remainder = ", (type uns32 rmndr )
);
end testDiv128;
你可以通过简单地向指令序列中添加额外的mov/div/mov指令来扩展这段代码,支持任意位数的操作。像上一节所介绍的扩展精度乘法一样,这种扩展精度除法算法仅适用于无符号操作数。如果你需要除以两个有符号数,你必须注意它们的符号,取其绝对值,进行无符号除法,然后根据操作数的符号设置结果的符号。
如果你需要使用大于 32 位的除数,你将不得不使用移位和减法策略来实现除法。不幸的是,这种算法非常慢。在本节中,我们将开发两种能在任意位数上进行除法的算法。第一种算法较慢,但更容易理解;第二种算法要快得多(在平均情况下)。
就像乘法一样,理解计算机如何进行除法的最佳方法是研究你如何被教导手工进行长除法。考虑操作 3,456/12,以及你手动执行此操作时所采取的步骤,如 图 8-3 所示。

图 8-3. 手动逐位除法操作
这个算法在二进制下实际上更容易,因为在每一步中,你不需要猜测 12 能除尽余数多少次,也不需要将 12 乘以你的猜测来获得需要减去的数。在二进制算法的每一步中,除数对余数的除法结果恰好是零次或一次。例如,考虑将 27 (11011) 除以 3 (11),如 图 8-4 所示。
有一种新颖的方法来实现这个二进制除法算法,它可以同时计算商和余数。该算法如下:
Quotient := Dividend;
Remainder := 0;
for i := 1 to NumberBits do
Remainder:Quotient := Remainder:Quotient SHL 1;
if Remainder >= Divisor then
Remainder := Remainder - Divisor;
Quotient := Quotient + 1;
endif
endfor

图 8-4. 二进制手动除法
NumberBits 是 Remainder、Quotient、Divisor 和 Dividend 变量的位数。注意,Quotient := Quotient + 1; 语句将 Quotient 的最低有效位设置为 1,因为该算法之前将 Quotient 左移了 1 位。示例 8-3 中的程序实现了这个算法。
示例 8-3. 扩展精度除法
program testDiv128b;
#include( "stdlib.hhf" )
// div128-
//
// This procedure does a general 128/128 division operation using the
// following algorithm (all variables are assumed to be 128-bit objects):
//
// Quotient := Dividend;
// Remainder := 0;
// for i := 1 to NumberBits do
//
// Remainder:Quotient := Remainder:Quotient SHL 1;
// if Remainder >= Divisor then
//
// Remainder := Remainder - Divisor;
// Quotient := Quotient + 1;
//
// endif
// endfor
//
procedure div128
(
Dividend: lword;
Divisor: lword;
var QuotAdrs: lword;
var RmndrAdrs: lword
); @nodisplay;
const
Quotient: text := "Dividend"; // Use the Dividend as the Quotient.
var
Remainder: lword;
begin div128;
push( eax );
push( ecx );
push( edi );
mov( 0, eax ); // Set the remainder to 0.
mov( eax, (type dword Remainder[0]) );
mov( eax, (type dword Remainder[4]) );
mov( eax, (type dword Remainder[8]) );
mov( eax, (type dword Remainder[12]));
mov( 128, ecx ); // Count off 128 bits in ecx.
repeat
// Compute Remainder:Quotient := Remainder:Quotient SHL 1:
shl( 1, (type dword Dividend[0]) ); // See Section 8.1.12 to see
rcl( 1, (type dword Dividend[4]) ); // how this code shifts 256
rcl( 1, (type dword Dividend[8]) ); // bits to the left by 1 bit.
rcl( 1, (type dword Dividend[12]));
rcl( 1, (type dword Remainder[0]) );
rcl( 1, (type dword Remainder[4]) );
rcl( 1, (type dword Remainder[8]) );
rcl( 1, (type dword Remainder[12]));
// Do a 128-bit comparison to see if the remainder
// is greater than or equal to the divisor.
if
( #{
mov( (type dword Remainder[12]), eax );
cmp( eax, (type dword Divisor[12]) );
ja true;
jb false;
mov( (type dword Remainder[8]), eax );
cmp( eax, (type dword Divisor[8]) );
ja true;
jb false;
mov( (type dword Remainder[4]), eax );
cmp( eax, (type dword Divisor[4]) );
ja true;
jb false;
mov( (type dword Remainder[0]), eax );
cmp( eax, (type dword Divisor[0]) );
jb false;
}# ) then
// Remainder := Remainder - Divisor
mov( (type dword Divisor[0]), eax );
sub( eax, (type dword Remainder[0]) );
mov( (type dword Divisor[4]), eax );
sbb( eax, (type dword Remainder[4]) );
mov( (type dword Divisor[8]), eax );
sbb( eax, (type dword Remainder[8]) );
mov( (type dword Divisor[12]), eax );
sbb( eax, (type dword Remainder[12]) );
// Quotient := Quotient + 1;
add( 1, (type dword Quotient[0]) );
adc( 0, (type dword Quotient[4]) );
adc( 0, (type dword Quotient[8]) );
adc( 0, (type dword Quotient[12]) );
endif;
dec( ecx );
until( @z );
// Okay, copy the quotient (left in the Dividend variable)
// and the remainder to their return locations.
mov( QuotAdrs, edi );
mov( (type dword Quotient[0]), eax );
mov( eax, [edi] );
mov( (type dword Quotient[4]), eax );
mov( eax, [edi+4] );
mov( (type dword Quotient[8]), eax );
mov( eax, [edi+8] );
mov( (type dword Quotient[12]), eax );
mov( eax, [edi+12] );
mov( RmndrAdrs, edi );
mov( (type dword Remainder[0]), eax );
mov( eax, [edi] );
mov( (type dword Remainder[4]), eax );
mov( eax, [edi+4] );
mov( (type dword Remainder[8]), eax );
mov( eax, [edi+8] );
mov( (type dword Remainder[12]), eax );
mov( eax, [edi+12] );
pop( edi );
pop( ecx );
pop( eax );
end div128;
// Some simple code to test out the division operation:
static
op1: lword := $8888_8888_6666_6666_4444_4444_2222_2221;
op2: lword := 2;
quo: lword;
rmndr: lword;
begin testDiv128b;
div128( op1, op2, quo, rmndr );
stdout.put
(
nl
nl
"After the division: " nl
nl
"Quotient = $",
(type dword quo[12]), "_",
(type dword quo[8]), "_",
(type dword quo[4]), "_",
(type dword quo[0]), nl
"Remainder = ", (type uns32 rmndr )
);
end testDiv128b;
这段代码看起来很简单,但存在一些问题:它没有检查除以 0 的情况(如果你尝试除以 0,它会产生值 $FFFF_FFFF_FFFF_FFFF),它仅处理无符号值,而且非常慢。处理除以 0 的情况非常简单;只需在运行此代码之前检查除数是否为 0,并在除数为 0 时返回适当的错误代码(或引发 ex.DivisionError 异常)。处理有符号值与之前的除法算法相同:注意符号,取操作数的绝对值,进行无符号除法,然后修正符号。然而,这个算法的性能远远不如理想。它的性能大约比 80x86 上的 div/idiv 指令慢一个数量级甚至两个,而这些指令本身就是 CPU 上最慢的指令之一。
有一种技术可以显著提升除法性能:检查除数变量是否只使用 32 位。通常,尽管除数是一个 128 位变量,但其值本身完全可以适应 32 位(即,Divisor的高双字是 0)。在这种特殊情况下,这种情况发生得非常频繁,你可以使用div指令,它要快得多。算法稍微复杂一些,因为你必须首先比较高双字是否为 0,但通常它的执行速度更快,并且能够除以任意两个值的对。
8.1.7 扩展精度取反操作
虽然有多种方法可以对扩展精度值进行取反,但对于较小的值(96 位或更小),最简便的方法是结合使用neg和sbb指令。这种技术利用了neg指令将其操作数从 0 中减去的事实。特别是,它设置了与sub指令相同的标志,如果你从 0 中减去目标值的话。这段代码的形式如下(假设你要取反 EDX:EAX 中的 64 位值):
neg( edx );
neg( eax );
sbb( 0, edx );
如果取反操作的低字(L.O. word)有借位,sbb指令会将 EDX 递减(除非 EAX 为 0,借位操作总是会发生)。
将此操作扩展到更多字节、字或双字非常简单;你只需从要取反对象的高字节内存位置开始,向低字节方向工作。以下代码计算一个 128 位的取反操作。
static
Value: dword[4];
.
.
.
neg( Value[12] ); // Negate the H.O. double word.
neg( Value[8] ); // Neg previous dword in memory.
sbb( 0, Value[12] ); // Adjust H.O. dword.
neg( Value[4] ); // Negate the second dword in the object.
sbb( 0, Value[8] ); // Adjust third dword in object.
sbb( 0, Value[12] ); // Adjust the H.O. dword.
neg( Value ); // Negate the L.O. dword.
sbb( 0, Value[4] ); // Adjust second dword in object.
sbb( 0, Value[8] ); // Adjust third dword in object.
sbb( 0, Value[12] ); // Adjust the H.O. dword.
不幸的是,这段代码往往变得非常庞大且缓慢,因为你需要在每次取反操作后将进位传播到所有高字(H.O. words)。对于较大的值,取反的一个更简单方法是直接将该值从 0 中减去:
static
Value: dword[5]; // 160-bit value.
.
.
.
mov( 0, eax );
sub( Value, eax );
mov( eax, Value );
mov( 0, eax );
sbb( Value[4], eax );
mov( eax, Value[4] );
mov( 0, eax );
sbb( Value[8], eax );
mov( eax, Value[8] );
mov( 0, eax );
sbb( Value[12], eax );
mov( eax, Value[12] );
mov( 0, eax );
sbb( Value[16], eax );
mov( eax, Value[16] );
8.1.8 扩展精度和操作
执行n字节and操作非常简单:只需将两个操作数中对应的字节进行and操作,保存结果。例如,要执行一个所有操作数为 64 位长的and操作,可以使用以下代码:
mov( (type dword *`source1`*), eax );
and( (type dword *`source2`*), eax );
mov( eax, (type dword *`dest`*) );
mov( (type dword *`source1`*[4]), eax );
and( (type dword *`source2`*[4]), eax );
mov( eax, (type dword *`dest`*[4]) );
这个技术可以轻松扩展到任意数量的字;你只需要在操作数中按位逻辑and相应的字节、字或双字即可。注意,这个序列会根据最后一次and操作的结果设置标志。如果最后进行的是高双字(H.O. double words)的and操作,这将正确设置除了零标志外的所有标志。如果你需要在此序列之后检查零标志,你需要将得到的两个双字按位逻辑or(或其他方式比较它们是否为 0)。
8.1.9 扩展精度或操作
多字节逻辑or操作与多字节and操作的执行方式相同。你只需将两个操作数中对应的字节进行or操作。例如,要对两个 96 位值进行逻辑or操作,可以使用以下代码:
mov( (type dword *`source1`*), eax );
or( (type dword *`source2`*), eax );
mov( eax, (type dword *`dest`*) );
mov( (type dword *`source1`*[4]), eax );
or( (type dword *`source2`*[4]), eax );
mov( eax, (type dword *`dest`*[4]) );
mov( (type dword *`source1`*[8]), eax );
or( (type dword *`source2`*[8]), eax );
mov( eax, (type dword *`dest`*[8]) );
与前面的例子一样,这样做不能正确设置整个操作的零标志。如果你在执行多精度的or后需要测试零标志,必须将所有结果的双字进行逻辑or运算。
8.1.10 扩展精度的xor操作
扩展精度的xor操作与and/or操作相同——只需对两个操作数的相应字节执行xor,即可得到扩展精度的结果。以下代码序列对两个 64 位操作数进行xor运算,并将结果存储到 64 位变量中:
mov( (type dword *`source1`*), eax );
xor( (type dword *`source2`*), eax );
mov( eax, (type dword *`dest`*) );
mov( (type dword *`source1`*[4]), eax );
xor( (type dword *`source2`*[4]), eax );
mov( eax, (type dword *`dest`*[4]) );
前两个部分提到的零标志的评论也适用于这里。
8.1.11 扩展精度的非操作
not指令会反转指定操作数中的所有位。扩展精度的not操作通过对所有受影响的操作数执行not指令来完成。例如,要对(EDX:EAX)中的 64 位值执行not操作,你只需执行以下指令:
not( eax );
not( edx );
请记住,如果你执行两次not指令,最终会得到original 值。还要注意,与所有 1(\(FF、\)FFFF 或$FFFF_FFFF)进行异或运算,与执行not指令的效果相同。
8.1.12 扩展精度移位操作
扩展精度的移位操作需要一个移位指令和一个旋转指令。考虑如何使用 32 位操作实现 64 位的shl(参见图 8-5):
-
必须将 0 移到位 0。
-
位 0 到 30 被移到下一个更高的位。
-
位 31 被移到位 32。
-
位 32 到 62 必须移到下一个更高的位。
-
位 63 被移到进位标志。

图 8-5. 64 位左移操作
你可以使用的两条指令来实现 64 位移位是shl和rcl。例如,要将(EDX:EAX)中的 64 位数值左移一位,可以使用以下指令:
shl( 1, eax );
rcl( 1, eax );
请注意,使用这种技术,你一次只能移位一个扩展精度的值。不能使用 CL 寄存器将扩展精度操作数移位多个位。也不能使用这种技术指定大于 1 的常数值。
要理解这一指令序列是如何工作的,可以考虑每条指令的操作。shl指令将 0 移到 64 位操作数的位 0,并将位 31 移到进位标志。然后,rcl指令将进位标志移到位 32,再将位 63 移到进位标志。结果正是我们想要的。
要对超过 64 位的操作数进行左移操作,只需使用额外的rcl指令。扩展精度左移操作总是从最低有效双字开始,每个随后的rcl指令都作用于下一个更高有效双字。例如,要对一个 96 位的内存位置进行左移操作,可以使用以下指令:
shl( 1, (type dword *`Operand`*[0]) );
rcl( 1, (type dword *`Operand`*[4]) );
rcl( 1, (type dword *`Operand`*[8]) );
如果你需要将数据移位 2 位或更多位,你可以重复上述序列所需的次数(对于固定次数的移位),或者将指令放入循环中,重复执行若干次。例如,以下代码将 96 位的Operand值向左移位,移位的位数由 ECX 指定:
ShiftLoop:
shl( 1, (type dword *`Operand`*[0]) );
rcl( 1, (type dword *`Operand`*[4]) );
rcl( 1, (type dword *`Operand`*[8]) );
dec( ecx );
jnz ShiftLoop;
实现shr和sar的方式类似,只不过你必须从操作数的高位字开始,然后逐步移到低位字:
// Extended-precision SAR:
sar( 1, (type dword *`Operand`*[8]) );
rcr( 1, (type dword *`Operand`*[4]) );
rcr( 1, (type dword *`Operand`*[0]) );
// Double-precision SHR:
shr( 1, (type dword *`Operand`*[8]) );
rcr( 1, (type dword *`Operand`*[4]) );
rcr( 1, (type dword *`Operand`*[0]) );
这里描述的扩展精度移位操作与它们的 8/16/32 位对应操作有一个主要的区别——扩展精度移位设置标志位的方式不同于单精度操作。这是因为旋转指令对标志位的影响与移位指令不同。幸运的是,进位标志是你在移位操作后最常测试的标志,而扩展精度移位操作(即旋转指令)会正确地设置该标志。
shld和shrd指令允许你高效地实现多个比特位的多精度移位。这些指令的语法如下:
shld( *`constant`*, *`Operand1`*, *`Operand2`* );
shld( cl, *`Operand1`*, *`Operand2`* );
shrd( *`constant`*, *`Operand1`*, *`Operand2`* );
shrd( cl, *`Operand1`*, *`Operand2`* );
shld指令的工作方式如图 8-6 所示。

图 8-6. shld操作
Operand1必须是 16 位或 32 位寄存器。Operand2可以是寄存器或内存位置。两个操作数的大小必须相同。立即数操作数可以是 0 到n−1 之间的值,其中n是两个操作数的位数;此操作数指定移位的位数。
shld指令将Operand2中的位向左移。高位的位移入进位标志,而Operand1的高位则移入Operand2的低位。请注意,该指令不会修改Operand1的值;它在移位过程中使用Operand1的临时副本。立即数操作数指定移位的位数。如果计数为n,则shld将第n−1 位移入进位标志。它还将Operand1的高位n位移入Operand2的低位n位。shld指令按如下方式设置标志位:
-
如果移位计数为 0,
shld指令不会影响任何标志位。 -
进位标志包含从
Operand2的高位移出的最后一位。 -
如果移位计数为 1,溢出标志将包含 1,如果
Operand2的符号位在移位过程中发生变化。如果计数不为 1,则溢出标志未定义。 -
如果移位操作产生结果为 0,零标志将为 1。
-
符号标志将包含结果的高位(H.O.)位。
shrd指令与shld类似,当然,它是将位向右移而不是向左移。为了更清晰地了解shrd指令,请参考图 8-7。

图 8-7. shrd操作
shrd指令会按如下方式设置标志位:
-
如果移位计数为 0,
shrd指令不会影响任何标志位。 -
进位标志包含从
Operand2的低位(L.O.)移出最后一位。 -
如果移位计数为 1,溢出标志将在
Operand2的高位(H.O.)位发生变化时为 1。如果计数不为 1,则溢出标志未定义。 -
如果移位操作产生结果为 0,零标志将为 1。
-
符号标志将包含结果的高位(H.O.)位。
请考虑以下代码序列:
static
ShiftMe: dword[3] := [ $1234, $5678, $9012 ];
.
.
.
mov( ShiftMe[4], eax )
shld( 6, eax, ShiftMe[8] );
mov( ShiftMe[0], eax );
shld( 6, eax, ShiftMe[4] );
shl( 6, ShiftMe[0] );
上面的第一个shld指令将ShiftMe[4]中的位移到ShiftMe[8]中,而不会影响ShiftMe[4]中的值。第二个shld指令将ShiftMe中的位移到ShiftMe[4]。最后,shl指令将低位双字(L.O.)按适当的数量进行移位。关于这段代码,有两点需要注意。首先,与其他扩展精度左移操作不同,这一序列从高位双字(H.O.)向低位双字(L.O.)进行操作。其次,进位标志不包含来自高位移位操作的进位。如果你需要保留此时的进位标志,你需要在第一个shld指令后推送标志,并在shl指令后弹出标志。
你可以使用shrd指令进行扩展精度的右移操作。它的工作方式几乎与上面的代码序列相同,不同之处在于你是从低位双字(L.O.)向高位双字(H.O.)进行操作。这个解答留给读者作为练习。
8.1.13 扩展精度旋转操作
rcl和rcr操作的扩展方式几乎与shl和shr相同。例如,要执行 96 位的rcl和rcr操作,可以使用以下指令:
rcl( 1, (type dword *`Operand`*[0]) );
rcl( 1, (type dword *`Operand`*[4]) );
rcl( 1, (type dword *`Operand`*[8]) );
rcr( 1, (type dword *`Operand`*[8]) );
rcr( 1, (type dword *`Operand`*[4]) );
rcr( 1, (type dword *`Operand`*[0]) );
这段代码与扩展精度移位操作的代码唯一的区别是,第一个指令是rcl或rcr,而不是shl或shr指令。
执行扩展精度的rol或ror操作并不像想象的那么简单。你可以使用bt、shld和shrd指令来实现扩展精度的rol或ror指令。以下代码展示了如何使用shld指令执行扩展精度的rol操作:
// Compute rol( 4, edx:eax );
mov( edx, ebx );
shld, 4, eax, edx );
shld( 4, ebx, eax );
bt( 0, eax ); // Set carry flag, if desired.
扩展精度的ror指令类似;只需要记住,你首先在对象的低位(L.O.)进行操作,最后在高位(H.O.)进行操作。
8.1.14 扩展精度输入/输出
一旦你可以进行扩展精度运算,下一个问题就是如何将这些扩展精度值引入程序并如何向用户显示它们的值。HLA 的标准库提供了处理 8、16、32、64 或 128 位长度值的无符号十进制、带符号十进制和十六进制输入/输出例程。因此,只要你处理的值大小不超过 128 位,你就可以使用标准库的代码。如果需要输入或输出大于 128 位的值,你需要编写自己的例程来处理这些操作。本节讨论了编写此类例程所需的策略。
本节中的示例专门处理 128 位值。这些算法是完全通用的,适用于任何位数(事实上,本节中的 128 位算法实际上与 HLA 标准库用于 128 位值的算法没有太大区别)。当然,如果你需要一组 128 位无符号输入/输出例程,你可以直接使用标准库的代码。如果需要处理更大的值,对以下代码进行简单的修改就足够了。
以下各节使用一组常见的 128 位数据类型,以避免在每条指令中强制转换lword/uns128/int128值。以下是这些数据类型:
type
h128 :dword[4];
u128 :dword[4];
i128 :dword[4];
8.1.14.1 扩展精度十六进制输出
扩展精度十六进制输出非常简单。你只需要使用调用stdout.puth32例程,从高位双字到低位双字输出扩展精度值的每个双字组件。以下过程正是这样做的,用来输出一个lword值:
procedure puth128( b128: h128 ); @nodisplay;
begin puth128;
stdout.puth32( b128[12] );
stdout.puth32( b128[8] );
stdout.puth32( b128[4] );
stdout.puth32( b128[0] );
end puth128;
当然,HLA 标准库提供了一个stdout.puth128过程,直接写入lword值,因此你可以在输出更大值(例如 256 位值)时多次调用stdout.puth128。事实证明,HLA stdlib.puth128过程的实现与上述的puth128非常相似。
8.1.14.2 扩展精度无符号十进制输出
十进制输出比十六进制输出稍微复杂一些,因为二进制数的高位会影响十进制表示中的低位数字(这对于十六进制值来说并不成立,这也是十六进制输出如此简单的原因)。因此,我们需要通过一次提取一个十进制数字来创建二进制数的十进制表示。
对于无符号十进制输出,最常见的解决方案是反复将值除以 10,直到结果变为 0。第一次除法后的余数是 0 到 9 之间的一个值,该值对应于十进制数的低位数字。连续的除以 10(及其相应的余数)提取数字的各个位。
解决该问题的迭代方法通常为字符字符串分配足够大的存储空间来容纳整个数字。然后,代码在循环中逐个提取十进制数字,并将它们依次放入字符串中。在转换过程结束时,例程按相反顺序打印字符串中的字符(记住,除法算法先提取最低位数字,最后提取最高位数字,这与需要打印的顺序相反)。
在本节中,我们采用递归解法,因为它稍显优雅。递归解法通过将值除以 10 并将余数保存在局部变量中开始。如果商不为 0,程序会递归调用自身,先打印所有前导数字。递归调用返回后(此时所有前导数字已打印),递归算法会打印与余数关联的数字以完成操作。以下是打印十进制值 789 时操作的工作方式:
-
将 789 除以 10。商为 78,余数为 9。
-
将余数(9)保存在局部变量中,并递归调用该例程,传入商作为参数。
-
[递归入口 1] 将 78 除以 10。商为 7,余数为 8。
-
将余数(8)保存在局部变量中,并递归调用该例程,传入商作为参数。
-
[递归入口 2] 将 7 除以 10。商为 0,余数为 7。
-
将余数(7)保存在局部变量中。由于商为 0,不需要递归调用该例程。
-
输出保存在局部变量中的余数值(7)。返回给调用者(递归入口 1)。
-
返回递归入口 1 输出保存在递归入口 1(8)中的局部变量的余数值。返回给调用者(原始调用过程)。
-
[原始调用] 输出保存在局部变量中的余数值(9)。返回给输出例程的原始调用者。
整个算法中唯一需要扩展精度计算的操作是“除以 10”语句。其他的操作都简单直接。幸运的是,我们的算法是将一个扩展精度值除以一个轻松适应双字的值,因此我们可以使用快速(且简单的)扩展精度除法算法,该算法使用 div 指令。示例 8-4 中实现了一个利用该技术的 128 位十进制输出例程。
示例 8-4。128 位扩展精度十进制输出例程
program out128;
#include( "stdlib.hhf" );
// 128-bit unsigned integer data type:
type
u128: dword[4];
// DivideBy10-
//
// Divides "divisor" by 10 using fast
// extended-precision division algorithm
// that employs the div instruction.
//
// Returns quotient in "quotient".
// Returns remainder in eax.
// Trashes ebx, edx, and edi.
procedure DivideBy10( dividend:u128; var quotient:u128 ); @nodisplay;
begin DivideBy10;
mov( quotient, edi );
xor( edx, edx );
mov( dividend[12], eax );
mov( 10, ebx );
div( ebx, edx:eax );
mov( eax, [edi+12] );
mov( dividend[8], eax );
div( ebx, edx:eax );
mov( eax, [edi+8] );
mov( dividend[4], eax );
div( ebx, edx:eax );
mov( eax, [edi+4] );
mov( dividend[0], eax );
div( ebx, edx:eax );
mov( eax, [edi+0] );
mov( edx, eax );
end DivideBy10;
// Recursive version of putu128.
// A separate "shell" procedure calls this so that
// this code does not have to preserve all the registers
// it uses (and DivideBy10 uses) on each recursive call.
procedure recursivePutu128( b128:u128 ); @nodisplay;
var
remainder: byte;
begin recursivePutu128;
// Divide by 10 and get the remainder (the char to print).
DivideBy10( b128, b128 );
mov( al, remainder ); // Save away the remainder (0..9).
// If the quotient (left in b128) is not 0, recursively
// call this routine to print the H.O. digits.
mov( b128[0], eax ); // If we logically OR all the dwords
or( b128[4], eax ); // together, the result is 0 if and
or( b128[8], eax ); // only if the entire number is 0.
or( b128[12], eax );
if( @nz ) then
recursivePutu128( b128 );
endif;
// Okay, now print the current digit.
mov( remainder, al );
or( '0', al ); // Converts 0..9 -> '0'..'9'.
stdout.putc( al );
end recursivePutu128;
// Nonrecursive shell to the above routine so we don't bother
// saving all the registers on each recursive call.
procedure putu128( b128:u128 ); @nodisplay;
begin putu128;
push( eax );
push( ebx );
push( edx );
push( edi );
recursivePutu128( b128 );
pop( edi );
pop( edx );
pop( ebx );
pop( eax );
end putu128;
// Code to test the routines above:
static
b0: u128 := [0, 0, 0, 0]; // decimal = 0
b1: u128 := [1234567890, 0, 0, 0]; // decimal = 1234567890
b2: u128 := [$8000_0000, 0, 0, 0]; // decimal = 2147483648
b3: u128 := [0, 1, 0, 0 ]; // decimal = 4294967296
// Largest uns128 value
// (decimal=340,282,366,920,938,463,463,374,607,431,768,211,455):
b4: u128 := [$FFFF_FFFF, $FFFF_FFFF, $FFFF_FFFF, $FFFF_FFFF ];
begin out128;
stdout.put( "b0 = " );
putu128( b0 );
stdout.newln();
stdout.put( "b1 = " );
putu128( b1 );
stdout.newln();
stdout.put( "b2 = " );
putu128( b2 );
stdout.newln();
stdout.put( "b3 = " );
putu128( b3 );
stdout.newln();
stdout.put( "b4 = " );
putu128( b4 );
stdout.newln();
end out128;
8.1.14.3 扩展精度有符号十进制输出
一旦你有了扩展精度的无符号十进制输出例程,编写扩展精度的有符号十进制输出例程就非常简单了。基本算法的形式如下:
-
检查数字的符号。
-
如果是正数,调用无符号输出例程打印它。如果数字是负数,打印负号。然后将数字取反并调用无符号输出例程打印它。
要检查扩展精度整数的符号,当然,你只需测试该数字的最高有效位(H.O.位)。要取反一个大值,最好的解决方法可能是将该值从 0 中减去。下面是一个快速版本的puti128,它使用了前一部分中的putu128例程:
procedure puti128( i128: u128 ); @nodisplay;
begin puti128;
if( (type int32 i128[12]) < 0 ) then
stdout.put( '-' );
// Extended-precision Negation:
push( eax );
mov( 0, eax );
sub( i128[0], eax );
mov( eax, i128[0] );
mov( 0, eax );
sbb( i128[4], eax );
mov( eax, i128[4] );
mov( 0, eax );
sbb( i128[8], eax );
mov( eax, i128[8] );
mov( 0, eax );
sbb( i128[12], eax );
mov( eax, i128[12] );
pop( eax );
endif;
putu128( i128 );
end puti128;
8.1.14.4 扩展精度格式化输出
前两部分的代码使用最少的打印位置打印有符号和无符号整数。为了创建格式化良好的值表,你将需要等同于puti128Size或putu128Size例程的内容。一旦你拥有了这些例程的“未格式化”版本,实现格式化版本就非常容易了。
第一步是编写i128Size和u128Size例程,这些例程计算显示该值所需的最小数字位数。实现这一算法的方法与数值输出例程非常相似。事实上,唯一的区别是在进入例程时初始化一个计数器为 0(例如,非递归的外壳例程),然后在每次递归调用时递增该计数器,而不是输出一个数字。(如果数字为负,别忘了在i128Size中递增计数器;你必须考虑输出负号。)计算完成后,这些例程应返回操作数在 EAX 寄存器中的大小。
一旦你拥有了i128Size和u128Size例程,编写格式化输出例程就很容易了。在初次进入puti128Size或putu128Size时,这些例程会调用相应的size例程来确定显示数字所需的打印位置数量。如果size例程返回的值大于最小大小参数的绝对值(传递给puti128Size或putu128Size),那么你只需调用put例程打印该值;无需其他格式化操作。如果参数大小的绝对值大于i128Size或u128Size返回的值,则程序必须计算这两个值之间的差异,并在打印数字之前(如果参数大小值为正)或打印数字之后(如果参数大小值为负)打印相应数量的空格(或其他填充字符)。这两个例程的实际实现留给读者自己去完成(或者直接查看 HLA 标准库中的stdout.putiSize128和stdout.putuSize128源代码)。
HLA 标准库通过执行一系列连续的扩展精度比较来实现i128Size和u128Size,以确定数值中的数字个数。有兴趣的读者可以查看这些例程的源代码,以及stdout.puti128和stdout.putu128过程的源代码(这些源代码可以在 Webster 网站上找到:webster.cs.ucr.edu/ 或 www.artofasm.com/)。
8.1.14.5 扩展精度输入例程
扩展精度输出例程和扩展精度输入例程之间有几个根本性的区别。首先,数字输出通常不会发生错误;^([113])而数字输入则必须处理非常现实的输入错误可能性,例如非法字符和数字溢出。此外,HLA 的标准库和运行时系统鼓励采用稍微不同的输入转换方法。本节讨论了那些将输入转换与输出转换区分开来的问题。
输入和输出转换之间最大的区别之一,可能就是输出转换不被括起来。也就是说,当将一个数字值转换为字符串以进行输出时,输出例程并不关心输出字符串前面的字符,也不关心输出流中数字值后面的字符。数字输出例程将数据转换为字符串,并打印该字符串,而不考虑上下文(即,数字值的字符串表示之前和之后的字符)。而数字输入例程不能如此轻率;数字字符串周围的上下文信息非常重要。
一个典型的数字输入操作包括从用户读取一串字符,然后将这串字符转换为内部的数字表示。例如,像stdin.get(i32)这样的语句通常会从用户那里读取一行文本,并将该行文本开头的数字序列转换为一个 32 位有符号整数(假设i32是一个int32对象)。然而需要注意的是,stdin.get例程会跳过字符串中某些可能出现在实际数字字符之前的字符。例如,stdin.get会自动跳过字符串中的任何前导空格。同样,输入字符串可能包含数字输入结束后的额外数据(例如,有可能从同一输入行中读取两个整数值),因此输入转换例程必须以某种方式确定数字数据在输入流中的结束位置。幸运的是,HLA 提供了一个简单的机制,让你轻松地确定输入数据的开始和结束:Delimiters字符集。
Delimiters 字符集是 HLA 标准库内部的一个变量,包含可以出现在合法数值之前或之后的合法字符集。默认情况下,该字符集包括字符串结尾标记(一个 0 字节)、制表符、换行符、回车符、空格、逗号、冒号和分号。因此,HLA 的数值输入例程会自动忽略输入中的任何该字符集中的字符,这些字符出现在数值字符串之前。同样,该字符集中的字符也可以合法地跟在数值字符串后面(相反,如果任何非分隔符字符跟在数值字符串后面,HLA 将抛出 ex.ConversionError 异常)。
Delimiters 字符集是 HLA 标准库中的一个私有变量。虽然你不能直接访问这个对象,但 HLA 标准库提供了两个访问函数,conv.setDelimiters 和 conv.getDelimiters,允许你访问和修改该字符集的值。这两个函数的原型如下(可以在 conv.hhf 头文件中找到):
procedure conv.setDelimiters( Delims:cset );
procedure conv.getDelimiters( var Delims:cset );
conv.setDelimiters 程序将 Delims 参数的值复制到内部的 Delimiters 字符集。因此,你可以使用此程序更改字符集,如果你想为数值输入使用不同的分隔符集。conv.getDelimiters 调用将返回一个你作为参数传递给 conv.getDelimiters 程序的变量中的 Delimiters 字符集的副本。我们将使用 conv.getDelimiters 返回的值来确定数值输入的结束标志,编写我们自己的扩展精度数值输入例程。
当从用户读取一个数值时,第一步是获取 Delimiters 字符集的副本。第二步是读取并丢弃用户输入的字符,只要这些字符属于 Delimiters 字符集中的成员。一旦找到一个不属于 Delimiters 集合的字符,输入例程必须检查该字符并验证它是否是合法的数字字符。如果不是,并且该字符的值超出了范围 $00..$7F,则程序应该抛出 ex.IllegalChar 异常;如果该字符不是合法的数字字符,则应该抛出 ex.ConversionError 异常。一旦例程遇到一个数字字符,它应该继续读取字符,只要这些字符是有效的数字字符;在读取字符时,转换例程应将其转换为数值数据的内部表示。如果在转换过程中发生溢出,程序应抛出 ex.ValueOutOfRange 异常。
数值转换应该在程序遇到字符串末尾的第一个分隔符字符时结束。然而,非常重要的是,程序不能消耗掉结束字符串的分隔符字符。也就是说,下面的做法是错误的:
static
Delimiters: cset;
.
.
.
conv.getDelimiters( Delimiters );
// Skip over leading delimiters in the string:
while( stdin.getc() in Delimiters ) do /* getc did the work */ endwhile;
while( al in '0'..'9') do
// Convert character in al to numeric representation and
// accumulate result...
stdin.getc();
endwhile;
if( al not in Delimiters ) then
raise( ex.ConversionError );
endif;
第一个while循环读取一串分隔符字符。当第一个while循环结束时,AL 中的字符不再是分隔符字符。第二个while循环处理一串十进制数字。首先,它检查前一个while循环中读取的字符,看看它是否是十进制数字;如果是,它会处理该数字并读取下一个字符。这个过程一直持续,直到调用stdin.getc(位于循环底部)读取到一个非数字字符。第二个while循环结束后,程序会检查最后一个读取的字符,以确保它是一个合法的分隔符字符,用于数值输入。
这个算法的问题在于它会消耗掉数字字符串后面的分隔符字符。例如,冒号符号在默认的Delimiters字符集中是一个合法的分隔符。如果用户输入123:456并执行上面的代码,这段代码会正确地将123转换为数值 123。然而,从输入流中读取的下一个字符将是字符 4,而不是冒号字符(:)。虽然在某些情况下这是可以接受的,但大多数程序员期望数字输入例程只消耗前导分隔符字符和数字字符。他们不希望输入例程消耗任何后续的分隔符字符(例如,许多程序会读取下一个字符,并期望看到冒号作为输入,如果输入字符串是123:456)。因为stdin.getc会消耗一个输入字符,而且没有办法将字符重新放回输入流,所以需要一种不会消耗字符的方式来读取用户输入的字符。^([114])
HLA 标准库通过提供 stdin.peekc 函数来提供帮助。像 stdin.getc 一样,stdin.peekc 例程从 HLA 的内部缓冲区读取下一个输入字符。stdin.peekc 和 stdin.getc 之间有两个主要区别。首先,如果当前输入行为空(或者你已经读取了输入行中的所有文本),stdin.peekc 不会强制用户输入新的一行文本。相反,stdin.peekc 会简单地返回 0 到 AL 寄存器,表示输入行中没有更多的字符。因为 #0(NUL 字符)通常是合法的数字值分隔符字符,并且行尾肯定是合法的数字输入终止方式,所以这种行为相当合适。stdin.getc 和 stdin.peekc 的第二个区别是 stdin.peekc 不会消耗从输入缓冲区读取的字符。如果你连续调用 stdin.peekc 多次,它将始终返回相同的字符;同样,如果在 stdin.peekc 之后立即调用 stdin.getc,stdin.getc 通常会返回与 stdin.peekc 返回的字符相同的字符(唯一的例外是行尾条件)。因此,虽然我们在使用 stdin.getc 读取字符后不能将字符放回输入流,但我们可以查看输入流中的下一个字符,并根据该字符的值调整我们的逻辑。下面是一个修正后的版本:
static
Delimiters: cset;
.
.
.
conv.getDelimiters( Delimiters );
// Skip over leading delimiters in the string:
while( stdin.peekc() in Delimiters ) do
// If at the end of the input buffer, we must explicitly read a
// new line of text from the user. stdin.peekc does not do this
// for us.
if( al = #0 ) then
stdin.ReadLn();
else
stdin.getc(); // Remove delimiter from the input stream.
endif;
endwhile;
while( stdin.peekc in '0'..'9') do
stdin.getc(); // Remove the input character from the input stream.
// Convert character in al to numeric representation and
// accumulate result...
endwhile;
if( al not in Delimiters ) then
raise( ex.ConversionError );
endif;
注意,第二个 while 中对 stdin.peekc 的调用不会在表达式为假时消耗分隔符字符。因此,分隔符字符将在算法完成后作为下一个字符被读取。
对于数字输入,唯一需要补充的评论是指出 HLA 标准库的输入例程允许在数字字符串中插入任意下划线。输入例程会忽略这些下划线字符。这使得用户可以输入类似 FFFF_F012 和 1_023_596 的字符串,这比 FFFFF012 和 1023596 更易读。在数字输入例程中允许下划线(或你选择的任何其他符号)是非常简单的;只需像下面这样修改第二个 while 循环:
while( stdin.peekc in {'0'..'9', '_'}) do
stdin.getc(); // Read the character from the input stream.
// Ignore underscores while processing numeric input.
if( al <> '_' ) then
// Convert character in al to numeric representation and
// accumulate result...
endif;
endwhile;
8.1.14.6 扩展精度十六进制输入
和数字输出一样,十六进制输入是最简单的数字输入例程。十六进制字符串到数字的基本转换算法如下:
-
初始化扩展精度值为 0。
-
对于每个有效的十六进制数字输入字符,执行以下操作:
-
将十六进制字符转换为 0 到 15 范围内的值(\(0..\)F)。
-
如果扩展精度值的高 4 位非零,则抛出异常。
-
将当前的扩展精度值乘以 16(即左移 4 位)。
-
将转换后的十六进制数字值加到累加器中。
-
检查最后一个输入字符,以确保它是一个有效的分隔符。如果不是,则抛出异常。
-
示例 8-5 中的程序实现了一个用于 128 位值的扩展精度十六进制输入例程。
示例 8-5. 扩展精度十六进制输入
program Xin128;
#include( "stdlib.hhf" );
// 128-bit unsigned integer data type:
type
b128: dword[4];
procedure getb128( var inValue:b128 ); @nodisplay;
const
HexChars := {'0'..'9', 'a'..'f', 'A'..'F', '_'};
var
Delimiters: cset;
LocalValue: b128;
begin getb128;
push( eax );
push( ebx );
// Get a copy of the HLA standard numeric input delimiters:
conv.getDelimiters( Delimiters );
// Initialize the numeric input value to 0:
xor( eax, eax );
mov( eax, LocalValue[0] );
mov( eax, LocalValue[4] );
mov( eax, LocalValue[8] );
mov( eax, LocalValue[12] );
// By default, #0 is a member of the HLA Delimiters
// character set. However, someone may have called
// conv.setDelimiters and removed this character
// from the internal Delimiters character set. This
// algorithm depends upon #0 being in the Delimiters
// character set, so let's add that character in
// at this point just to be sure.
cs.unionChar( #0, Delimiters );
// If we're at the end of the current input
// line (or the program has yet to read any input),
// for the input of an actual character.
if( stdin.peekc() = #0 ) then
stdin.readLn();
endif;
// Skip the delimiters found on input. This code is
// somewhat convoluted because stdin.peekc does not
// force the input of a new line of text if the current
// input buffer is empty. We have to force that input
// ourselves in the event the input buffer is empty.
while( stdin.peekc() in Delimiters ) do
// If we're at the end of the line, read a new line
// of text from the user; otherwise, remove the
// delimiter character from the input stream.
if( al = #0 ) then
stdin.readLn(); // Force a new input line.
else
stdin.getc(); // Remove the delimiter from the input buffer.
endif;
endwhile;
// Read the hexadecimal input characters and convert
// them to the internal representation:
while( stdin.peekc() in HexChars ) do
// Actually read the character to remove it from the
// input buffer.
stdin.getc();
// Ignore underscores, process everything else.
if( al <> '_' ) then
if( al in '0'..'9' ) then
and( $f, al ); // '0'..'9' -> 0..9
else
and( $f, al ); // 'a'/'A'..'f'/'F' -> 1..6
add( 9, al ); // 1..6 -> 10..15
endif;
// Conversion algorithm is the following:
//
// (1) LocalValue := LocalValue * 16.
// (2) LocalValue := LocalValue + al
//
// Note that "* 16" is easily accomplished by
// shifting LocalValue to the left 4 bits.
//
// Overflow occurs if the H.O. 4 bits of LocalValue
// contain a nonzero value prior to this operation.
// First, check for overflow:
test( $F0, (type byte LocalValue[15]));
if( @nz ) then
raise( ex.ValueOutOfRange );
endif;
// Now multiply LocalValue by 16 and add in
// the current hexadecimal digit (in eax).
mov( LocalValue[8], ebx );
shld( 4, ebx, LocalValue[12] );
mov( LocalValue[4], ebx );
shld( 4, ebx, LocalValue[8] );
mov( LocalValue[0], ebx );
shld( 4, ebx, LocalValue[4] );
shl( 4, ebx );
add( eax, ebx );
mov( ebx, LocalValue[0] );
endif;
endwhile;
// Okay, we've encountered a non-hexadecimal character.
// Let's make sure it's a valid delimiter character.
// Raise the ex.ConversionError exception if it's invalid.
if( al not in Delimiters ) then
raise( ex.ConversionError );
endif;
// Okay, this conversion has been a success. Let's store
// away the converted value into the output parameter.
mov( inValue, ebx );
mov( LocalValue[0], eax );
mov( eax, [ebx] );
mov( LocalValue[4], eax );
mov( eax, [ebx+4] );
mov( LocalValue[8], eax );
mov( eax, [ebx+8] );
mov( LocalValue[12], eax );
mov( eax, [ebx+12] );
pop( ebx );
pop( eax );
end getb128;
// Code to test the routines above:
static
b1:b128;
begin Xin128;
stdout.put( "Input a 128-bit hexadecimal value: " );
getb128( b1 );
stdout.put
(
"The value is: $",
b1[12], '_',
b1[8], '_',
b1[4], '_',
b1[0],
nl
);
end Xin128;
将此代码扩展到处理大于 128 位的对象非常简单。只需要三个更改:你必须在 getb128 例程的开头将整个对象置为零;在检查溢出时(test( $F, (type byte LocalValue[15]) ); 指令),你必须测试你正在处理的新对象的高 4 位;你必须修改乘法代码,该代码通过 shld 将 LocalValue 乘以 16,使其乘以你的对象 16(即,将其向左移动 4 位)。
8.1.14.7 扩展精度无符号十进制输入
扩展精度无符号十进制输入的算法与十六进制输入几乎相同。实际上,唯一的区别(除了只接受十进制数字之外)是你将扩展精度值乘以 10 而不是 16,针对每个输入字符(一般来说,任何进制的算法都是相同的;只是将累积值乘以输入的基数)。示例 8-6 中的代码展示了如何编写一个 128 位无符号十进制输入例程。
示例 8-6. 扩展精度无符号十进制输入
program Uin128;
#include( "stdlib.hhf" );
// 128-bit unsigned integer data type:
type
u128: dword[4];
procedure getu128( var inValue:u128 ); @nodisplay;
var
Delimiters: cset;
LocalValue: u128;
PartialSum: u128;
begin getu128;
push( eax );
push( ebx );
push( ecx );
push( edx );
// Get a copy of the HLA standard numeric input delimiters:
conv.getDelimiters( Delimiters );
// Initialize the numeric input value to 0:
xor( eax, eax );
mov( eax, LocalValue[0] );
mov( eax, LocalValue[4] );
mov( eax, LocalValue[8] );
mov( eax, LocalValue[12] );
// By default, #0 is a member of the HLA Delimiters
// character set. However, someone may have called
// conv.setDelimiters and removed this character
// from the internal Delimiters character set. This
// algorithm depends upon #0 being in the Delimiters
// character set, so let's add that character in
// at this point just to be sure.
cs.unionChar( #0, Delimiters );
// If we're at the end of the current input
// line (or the program has yet to read any input),
// wait for the input of an actual character.
if( stdin.peekc() = #0 ) then
stdin.readLn();
endif;
// Skip the delimiters found on input. This code is
// somewhat convoluted because stdin.peekc does not
// force the input of a new line of text if the current
// input buffer is empty. We have to force that input
// ourselves in the event the input buffer is empty.
while( stdin.peekc() in Delimiters ) do
// If we're at the end of the line, read a new line
// of text from the user; otherwise, remove the
// delimiter character from the input stream.
if( al = #0 ) then
stdin.readLn(); // Force a new input line.
else
stdin.getc(); // Remove the delimiter from the input buffer.
endif;
endwhile;
// Read the decimal input characters and convert
// them to the internal representation:
while( stdin.peekc() in '0'..'9' ) do
// Actually read the character to remove it from the
// input buffer.
stdin.getc();
// Ignore underscores, process everything else.
if( al <> '_' ) then
and( $f, al ); // '0'..'9' -> 0..9
mov( eax, PartialSum[0] ); // Save to add in later.
// Conversion algorithm is the following:
//
// (1) LocalValue := LocalValue * 10.
// (2) LocalValue := LocalValue + al
//
// First, multiply LocalValue by 10:
mov( 10, eax );
mul( LocalValue[0], eax );
mov( eax, LocalValue[0] );
mov( edx, PartialSum[4] );
mov( 10, eax );
mul( LocalValue[4], eax );
mov( eax, LocalValue[4] );
mov( edx, PartialSum[8] );
mov( 10, eax );
mul( LocalValue[8], eax );
mov( eax, LocalValue[8] );
mov( edx, PartialSum[12] );
mov( 10, eax );
mul( LocalValue[12], eax );
mov( eax, LocalValue[12] );
// Check for overflow. This occurs if edx
// contains a nonzero value.
if( edx /* <> 0 */ ) then
raise( ex.ValueOutOfRange );
endif;
// Add in the partial sums (including the
// most recently converted character).
mov( PartialSum[0], eax );
add( eax, LocalValue[0] );
mov( PartialSum[4], eax );
adc( eax, LocalValue[4] );
mov( PartialSum[8], eax );
adc( eax, LocalValue[8] );
mov( PartialSum[12], eax );
adc( eax, LocalValue[12] );
// Another check for overflow. If there
// was a carry out of the extended-precision
// addition above, we've got overflow.
if( @c ) then
raise( ex.ValueOutOfRange );
endif;
endif;
endwhile;
// Okay, we've encountered a non-decimal character.
// Let's make sure it's a valid delimiter character.
// Raise the ex.ConversionError exception if it's invalid.
if( al not in Delimiters ) then
raise( ex.ConversionError );
endif;
// Okay, this conversion has been a success. Let's store
// away the converted value into the output parameter.
mov( inValue, ebx );
mov( LocalValue[0], eax );
mov( eax, [ebx] );
mov( LocalValue[4], eax );
mov( eax, [ebx+4] );
mov( LocalValue[8], eax );
mov( eax, [ebx+8] );
mov( LocalValue[12], eax );
mov( eax, [ebx+12] );
pop( edx );
pop( ecx );
pop( ebx );
pop( eax );
end getu128;
// Code to test the routines above:
static
b1:u128;
begin Uin128;
stdout.put( "Input a 128-bit decimal value: " );
getu128( b1 );
stdout.put
(
"The value is: $",
b1[12], '_',
b1[8], '_',
b1[4], '_',
b1[0],
nl
);
end Uin128;
与十六进制输入类似,将此十进制输入扩展到超过 128 位的数量也非常简单。你只需要修改将 LocalValue 变量置为零的代码和将 LocalValue 乘以 10 的代码(溢出检查已在此代码中完成,因此这段代码中只有两个位置需要修改)。
8.1.14.8 扩展精度有符号十进制输入
一旦你有了一个无符号十进制输入例程,编写一个有符号十进制输入例程就变得很容易。以下算法描述了如何实现这一点:
-
在输入流的开头消耗任何分隔符字符。
-
如果下一个输入字符是负号,消耗该字符并设置一个标志,表示该数字为负数。
-
调用无符号十进制输入例程将字符串的其余部分转换为整数。
-
检查返回结果,确保其高位(H.O.)位清除。如果结果的高位(H.O.)位被设置,则抛出
ex.ValueOutOfRange异常。 -
如果代码在步骤 2 中遇到了负号,则取结果的负值。
实际代码留给读者作为编程练习(或者参见 HLA 标准库中的转换例程,查看具体示例)。
^([111]) 更新的 C 标准还提供了 long long int 类型,通常是一个 64 位的整数。
^([112]) Windows 可能会将此转换为 ex.IntoInstr 异常。
^([113]) 从技术上讲,这并不完全正确。设备错误(例如磁盘已满)是可能发生的。虽然这种可能性非常低,我们可以有效地忽略这种可能性。
^([114]) HLA 标准库例程实际上会将输入行缓冲在一个字符串中,并从字符串中处理字符。这使得在寻找分隔符以结束输入值时,可以轻松地“预读”一个字符。你的代码也可以这样做;然而,本章中的代码采用了不同的方法。
8.2 对不同大小操作数的运算
有时你可能需要对一对不同大小的操作数进行运算。例如,你可能需要将一个字和一个双字加在一起,或者从一个字值中减去一个字节值。解决方法很简单:只需将较小的操作数扩展到较大操作数的大小,然后对两个相同大小的操作数进行运算。对于有符号操作数,你将较小的操作数符号扩展到与较大操作数相同的大小;对于无符号值,你将较小的操作数零扩展。这适用于任何操作,尽管以下示例展示了加法操作。
为了将较小的操作数扩展到较大操作数的大小,可以使用符号扩展或零扩展操作(取决于你是加法有符号值还是无符号值)。一旦将较小的值扩展到较大的大小,运算就可以继续进行。考虑以下将字节值加到字值上的代码:
static
var1: byte;
var2: word;
.
.
.
// Unsigned addition:
movzx( var1, ax );
add( var2, ax );
// Signed addition:
movsx( var1, ax );
add( var2, ax );
在这两种情况下,字节变量被加载到 AL 寄存器中,扩展到 16 位,然后加到字操作数上。如果你能选择操作的顺序(例如,将 8 位值加到 16 位值上),这段代码会非常有效。有时候,你无法指定操作的顺序。也许 16 位值已经在 AX 寄存器中,你想加一个 8 位值。对于无符号加法,可以使用以下代码:
mov( var2, ax ); // Load 16-bit value into ax.
. // Do some other operations leaving
. // a 16-bit quantity in ax.
add( var1, al ); // Add in the 8-bit value.
adc( 0, ah ); // Add carry into the H.O. word.
这个示例中的第一个add指令将var1中的字节加到累加器中低字节的值上。上面的adc指令将低字节相加的进位加到累加器的高字节中。你必须确保这个adc指令存在。如果省略它,可能得不到正确的结果。
将 8 位有符号操作数加到 16 位有符号值上稍微复杂一些。不幸的是,你不能将立即数(如上所示)加到 AX 的高字节。这是因为高字节扩展字节可以是\(00 或\)FF。如果有可用的寄存器,最好的做法是如下:
mov( ax, bx ); // bx is the available register.
movsx( var1, ax );
add( bx, ax );
如果没有可用的额外寄存器,你可以尝试以下代码:
push( ax ); // Save word value.
movsx( var1, ax ); // Sign extend 8-bit operand to 16 bits.
add( [esp], ax ); // Add in previous word value.
add( 2, esp ); // Pop junk from stack.
另一种选择是将 16 位值从累加器存储到内存位置,然后像之前一样继续操作:
mov( ax, temp );
movsx( var1, ax );
add( temp, ax );
上述所有例子都将字节值加到了字值上。通过将较小的操作数通过零扩展或符号扩展到较大操作数的大小,您可以轻松地将任何两个不同大小的变量相加。
最后的一个例子,考虑将一个 8 位带符号值加到一个四字(64 位)值上:
static
QVal:qword;
BVal:int8;
.
.
.
movsx( BVal, eax );
cdq();
add( (type dword QVal), eax );
adc( (type dword QVal[4]), edx );
8.3 十进制算术
80x86 CPU 使用二进制数字系统作为其本地内部表示。二进制数字系统是当今计算机系统中最常见的数字系统。然而,在早期,确实有一些计算机系统是基于十进制(基数 10)数字系统,而不是二进制数字系统。因此,它们的算术系统是基于十进制的,而不是二进制的。这类计算机系统在面向商业/商业系统的系统中曾非常流行。^([115]) 尽管系统设计师已经发现,二进制算术在一般计算中几乎总是优于十进制算术,但仍然存在这样的误解:十进制算术在货币计算方面比二进制算术更好。因此,许多软件系统仍然在其计算中指定使用十进制算术(更不用说,很多遗留代码的算法只有在使用十进制算术时才稳定)。因此,尽管十进制算术通常不如二进制算术,但对十进制算术的需求依然存在。
当然,80x86 并不是一个十进制计算机;因此,我们必须采用一些技巧来使用本地二进制格式表示十进制数。最常见的技巧,甚至是大多数所谓的十进制计算机所采用的技巧,是使用二进制编码的十进制(BCD)表示法。BCD 表示法使用 4 位来表示 10 个可能的十进制数字(见 表 8-1)。这 4 位的二进制值等于对应的十进制值,范围是 0 到 9。当然,使用 4 位实际上可以表示 16 个不同的值;BCD 格式忽略了其余六种位组合。
由于每个 BCD 数字需要 4 位,因此我们可以用一个字节表示一个 2 位的 BCD 值。这意味着我们可以用一个字节表示范围在 0 到 99 之间的十进制值(如果将值当作无符号二进制数来处理,则范围是 0 到 255)。显然,用 BCD 表示相同的值需要比用二进制表示更多的内存。例如,使用 32 位值时,您可以表示范围为 0 到 99,999,999(八位有效数字)的 BCD 值。然而,使用二进制表示时,您可以表示范围为 0 到 4,294,967,295(超过九位有效数字)的值。
BCD 格式不仅在二进制计算机上浪费内存(因为它使用更多的位来表示给定的整数值),而且十进制算术也较慢。由于这些原因,除非某个特定应用强烈要求,否则你应避免使用十进制算术。
二进制编码的十进制表示法相比二进制表示法有一个很大的优势:它非常简单地将十进制数字的字符串表示转换为 BCD 表示。这一特性在处理分数值时特别有用,因为定点和浮点二进制表示无法精确表示许多常用的 0 到 1 之间的值(例如,1/10)。因此,BCD 运算在从 BCD 设备读取数据、进行简单的算术操作(例如,单次加法)后,再将 BCD 值写入其他设备时,可以提高效率。
表 8-1. 二进制编码十进制(BCD)表示法
| BCD 表示 | 十进制等价 |
|---|---|
| 0000 | 0 |
| 0001 | 1 |
| 0010 | 2 |
| 0011 | 3 |
| 0100 | 4 |
| 0101 | 5 |
| 0110 | 6 |
| 0111 | 7 |
| 1000 | 8 |
| 1001 | 9 |
| 1010 | 非法 |
| 1011 | 非法 |
| 1100 | 非法 |
| 1101 | 非法 |
| 1110 | 非法 |
| 1111 | 非法 |
8.3.1 字面 BCD 常量
HLA 并不提供,也不需要,特殊的字面 BCD 常量。因为 BCD 只是十六进制表示的一种特殊形式,它不允许使用\(A..\)F 的值,所以你可以轻松地使用 HLA 的十六进制表示法来创建 BCD 常量。当然,你必须注意不要在 BCD 常量中包含 A..F 符号,因为它们是非法的 BCD 值。举个例子,考虑以下mov指令,它将 BCD 值 99 复制到 AL 寄存器中:
mov( $99, al );
需要记住的重要一点是,你不能使用 HLA 字面十进制常量来表示 BCD 值。也就是说,mov( 95, al );并不会将 95 的 BCD 表示加载到 AL 寄存器中。相反,它会将$5F 加载到 AL 中,而这就是一个非法的 BCD 值。你尝试用非法 BCD 值进行任何计算都会得到垃圾结果。请始终记住,尽管这似乎违反直觉,但你应该使用十六进制字面常量来表示字面 BCD 值。
8.3.2 80x86 的 daa 和 das 指令
80x86 的整数单元并不直接支持 BCD 算术。相反,80x86 要求你使用二进制算术进行计算,并使用一些辅助指令将二进制结果转换为 BCD。为了支持每字节两个数字的压缩 BCD 加法和减法,80x86 提供了两个指令:加法后的十进制调整(daa)和减法后的十进制调整(das)。你需要在执行完add/adc或sub/sbb指令后立即执行这两个指令,以修正 AL 寄存器中的二进制结果。
为了将一对两位数(即单字节)BCD 值相加,你需要使用以下序列:
mov( bcd_1, al ); // Assume that bcd_1 and bcd_2 both contain
add( bcd_2, al ); // valid BCD values.
daa();
上述前两条指令通过标准二进制算术将两个字节的值相加。这可能不会产生正确的 BCD 结果。例如,如果bcd_1包含$9 而bcd_2包含\(1,那么上述前两条指令将产生二进制和\)A,而不是正确的 BCD 结果$10。daa指令会纠正这个无效的结果。它检查是否有低位 BCD 数字的进位,如果有溢出,就通过加 6 来调整值。调整完低位溢出后,daa指令会对高位 BCD 数字重复这个过程。如果高位 BCD 数字发生了(十进制)进位,daa会设置进位标志。
daa指令仅对 AL 寄存器操作。如果尝试将值加到 AX、EAX 或任何其他寄存器,它将不会正确调整十进制加法。特别需要注意的是,daa限制你每次只能加两个十进制数字(一字节)。这意味着,在计算十进制和时,你必须将 80x86 视为 8 位处理器,每次只能加 8 位。如果你希望将超过两个数字相加,必须将其视为多精度运算。例如,要将四个十进制数字相加(使用daa),你必须执行如下的操作序列:
// Assume "bcd_1:byte[2];", "bcd_2:byte[2];", and "bcd_3:byte[2];"
mov( bcd_1[0], al );
add( bcd_2[0], al );
daa();
mov( al, bcd_3[0] );
mov( bcd_1[1], al );
adc( bcd_2[1], al );
daa();
mov( al, bcd_3[1], al );
// Carry is set at this point if there was unsigned overflow.
因为两个字的二进制加法(产生一个字的结果)只需要三条指令,所以可以看出十进制算术是昂贵的。^([116])
das(减法后调整十进制)指令会在执行二进制sub或sbb指令后调整十进制结果。使用它的方式与使用daa指令相同。以下是一些例子:
// Two-digit (1-byte) decimal subtraction:
mov( bcd_1, al ); // Assume that bcd_1 and bcd_2 both contain
sub( bcd_2, al ); // valid BCD values.
das();
// Four-digit (2-byte) decimal subtraction.
// Assume "bcd_1:byte[2];", "bcd_2:byte[2];", and "bcd_3:byte[2];"
mov( bcd_1[0], al );
sub( bcd_2[0], al );
das();
mov( al, bcd_3[0] );
mov( bcd_1[1], al );
sbb( bcd_2[1], al );
das();
mov( al, bcd_3[1], al );
// Carry is set at this point if there was unsigned overflow.
不幸的是,80x86 仅支持使用daa和das指令对压缩 BCD 值进行加法和减法。它不支持乘法、除法或任何其他算术运算。由于使用这些指令进行十进制算术的功能如此有限,因此你很少会看到程序使用这些指令。
8.3.3 80x86 的 aaa、aas、aam 和 aad 指令
除了压缩十进制指令(daa和das)外,80x86 CPU 还支持四个非压缩十进制调整指令。非压缩十进制数每个 8 位字节仅存储一个数字。如你所见,这种数据表示方式会浪费相当多的内存。然而,非压缩十进制调整指令支持乘法和除法操作,因此它们稍微更有用一些。
指令助记符aaa、aas、aam和aad分别代表“ASCII 加法、减法、乘法和除法调整”(即“ASCII adjust for Addition, Subtraction, Multiplication, and Division”)。尽管它们的名字如此,这些指令并不处理 ASCII 字符。相反,它们支持 AL 寄存器中的非打包十进制值,其中低 4 位包含十进制数字,高 4 位包含 0。需要注意的是,你可以通过将 AL 与$0F 进行and操作,轻松地将 ASCII 十进制数字字符转换为非打包十进制数。
aaa指令用于调整两个非打包十进制数字相加后的二进制结果。如果这两个值相加超过了 10,aaa会将 10 从 AL 中减去,并将 AH 加 1(同时设置进位标志)。aaa假设你加在一起的两个值是合法的非打包十进制值。除了aaa一次只处理一个十进制数字(而不是两个)外,你使用它的方式与使用daa指令的方式相同。当然,如果你需要将一串十进制数字相加,使用非打包十进制算术将需要更多的操作次数,从而需要更多的执行时间。
使用aas指令的方式与使用das指令的方式相同,当然,它是作用于非打包十进制值,而不是打包十进制值。与aaa一样,aas在加相同数量的十进制数字时,将需要两倍的操作次数。如果你在想为什么有人会使用aaa或aas指令,请记住,非打包格式支持乘法和除法,而打包格式则不支持。由于数据的打包和解包通常比逐个数字操作数据要昂贵,所以如果你需要处理非打包数据(因为需要乘法和除法),aaa和aas指令将更高效。
aam指令修改 AX 寄存器中的结果,以在使用mul指令将两个非打包十进制数字相乘后,生成正确的非打包十进制结果。因为你得到的最大乘积是 81(9 * 9 是两个单一数字的最大乘积),结果将适合存储在 AL 寄存器中。aam通过将结果除以 10 来解包二进制结果,将商(高位数字)存放在 AH 寄存器中,余数(低位数字)存放在 AL 寄存器中。需要注意的是,aam将商和余数存放在不同的寄存器中,而标准的 8 位div操作则不会这样。
从技术上讲,进行 BCD 乘法操作时,你不一定需要使用aam指令。aam指令只是将 AL 寄存器中的值除以 10,并将商和余数分别存储在 AH 和 AL 中。如果你需要执行这个特定操作,可以使用aam指令(事实上,现在大多数程序中,aam的用途几乎仅限于此)。
如果你需要使用mul和aam将两个以上的解包十进制数字相乘,你需要设计一种多精度乘法,使用本章前面介绍的手动算法。由于这需要大量的工作,本节将不会介绍该算法。如果你需要多精度十进制乘法,请参阅 8.3.4 使用 FPU 的打包十进制算术;它提供了一个更好的解决方案。
aad 指令,正如你可能预期的那样,调整解包十进制除法的值。这个指令的不同之处在于,你必须在执行 div 操作之前先执行它。它假定 AL 包含一个两位值的最低有效数字,而 AH 包含一个两位解包十进制值的最高有效数字。它将这两个数字转换为二进制,以便标准的 div 指令能够产生正确的解包十进制结果。像 aam 一样,这条指令几乎对它的预期用途没什么作用,因为扩展精度操作(例如,超过一位或两位数字的除法)极为低效。然而,这条指令本身实际上非常有用。它计算 AX = AH * 10 + AL(假设 AH 和 AL 包含单个十进制数字)。你可以使用这条指令将包含值的 ASCII 表示的两字符字符串(范围为 0..99)转换为二进制值。例如:
mov( '9', al );
mov( '9', ah ); // "99" is in ah:al.
and( $0F0F, ax ); // Convert from ASCII to unpacked decimal.
aad(); // After this, ax contains 99.
十进制和 ASCII 调整指令提供的十进制算术实现非常差。为了更好地支持 80x86 系统上的十进制算术,Intel 将十进制操作集成到 FPU 中。下一节将讨论如何使用 FPU 来实现这一目的。然而,即使有 FPU 支持,十进制算术依然效率低下,且精度不如二进制算术。因此,在将其集成到程序中之前,你应该仔细考虑是否真的需要使用十进制算术。
8.3.4 使用 FPU 的打包十进制算术
为了提高依赖十进制算术的应用程序性能,Intel 将十进制算术直接集成到 FPU 中。与前面章节中的打包和解包十进制格式不同,FPU 可以轻松支持最多 18 位十进制精度的值,且全部以 FPU 的速度进行运算。此外,FPU 的所有算术功能(例如,超越运算)都可以使用,除了加法、减法、乘法和除法之外。假设你能够接受只有 18 位精度和一些其他限制,FPU 上的十进制算术是你在程序中必须使用十进制算术时的正确选择。
使用 FPU 时,必须注意的第一个事实是它并不真正支持十进制算术。相反,FPU 提供了两条指令,fbld和fbstp,用于在将数据传输进出 FPU 时,在打包十进制和二进制浮点格式之间进行转换。fbld(浮点/BCD 加载)指令在将 BCD 值转换为 IEEE 二进制浮点格式后,将一个 80 位的打包 BCD 值加载到 FPU 栈的顶部。同样,fbstp(浮点/BCD 存储并弹出)指令将浮点值从栈顶弹出,转换为打包 BCD 值,并将该 BCD 值存储到目标内存位置。
一旦你将一个打包的 BCD 值加载到 FPU 中,它就不再是 BCD 格式了,而仅仅是一个浮点值。这就对 FPU 作为十进制整数处理器的使用提出了第一个限制:计算是通过二进制算术来进行的。如果你有一个完全依赖于十进制算术的算法,使用 FPU 来实现可能会导致失败。^([117])
第二个限制是 FPU 仅支持一种 BCD 数据类型:一个 10 字节的 18 位打包十进制值。它不支持更小或更大的值。由于 18 位通常已足够,且内存便宜,这并不是一个大限制。
第三个需要考虑的问题是,打包 BCD 和浮点格式之间的转换不是一个廉价的操作。fbld和fbstp指令的速度可能相当慢(例如,比fld和fstp慢两个数量级以上)。因此,如果你只是进行简单的加法或减法,这些指令会很耗时;转换的成本远超过了使用daa和das指令按字节逐一加法的时间(然而,乘法和除法在 FPU 上会更快)。
你可能会想,为什么 FPU 的打包十进制格式只支持 18 位数字。毕竟,使用 10 个字节应该能表示 20 位 BCD 数字。实际上,FPU 的打包十进制格式使用前 9 个字节来存储打包 BCD 值,采用标准的打包十进制格式(第一个字节包含两个低位数字,第九个字节包含两个高位数字)。第十个字节的高位存储符号位,FPU 会忽略第十个字节中的剩余位。如果你在想,为什么 Intel 没有再压缩进一个数字(即,使用第十个字节的低 4 位来支持 19 位精度),请记住,这么做会导致一些 BCD 值,FPU 无法在原生浮点格式中准确表示。因此,你只能使用 18 位的限制。
FPU 使用反码表示法来表示负的 BCD 值。也就是说,符号位如果数字是负数,则包含 1,如果数字是正数或 0,则包含 0(类似于二进制反码格式,对于 0 有两种不同的表示)。
HLA 的 tbyte 类型是你用来定义打包 BCD 变量的标准数据类型。fbld 和 fbstp 指令需要一个 tbyte 操作数(你可以用十六进制/BCD 值来初始化它)。
因为 FPU 将打包的十进制值转换为内部浮点格式,所以你可以在同一个计算中混合使用打包十进制、浮点和(二进制)整数格式。 示例 8-7 中的程序演示了你如何实现这一点。
示例 8-7. 混合模式 FPU 算术
program MixedArithmetic;
#include( "stdlib.hhf" )
static
tb: tbyte := $654321;
begin MixedArithmetic;
fbld( tb );
fmul( 2.0 );
fiadd( 1 );
fbstp( tb );
stdout.put( "bcd value is " );
stdout.puth80( tb );
stdout.newln();
end MixedArithmetic;
FPU 将打包十进制值视为整数值。因此,如果你的计算产生了小数结果,fbstp 指令将根据当前的 FPU 舍入模式四舍五入结果。如果你需要处理小数值,你需要坚持使用浮点结果。
^([115]) 事实上,直到 1960 年代中期 IBM 360 发布之前,大多数科学计算机系统都是基于二进制的,而大多数商业/业务系统则是基于十进制的。IBM 推出了其系统\360,作为一个适用于商业和科学应用的单一用途解决方案。实际上,型号名称(360)来源于罗盘上的 360 度,以此暗示系统\360 适用于“罗盘上所有的方向”(即商业和科学)。
^([116]) 你也很快会发现,以这种方式进行十进制运算是很少见的。所以几乎无关紧要。
^([117]) 这样的算法的一个例子可能是通过将数字左移一位来乘以 10。然而,这种操作在 FPU 内部是不可能的,因此在 FPU 内部行为不当的算法实际上是非常罕见的。
8.4 表格
对不同的程序员来说,表格 这个术语有不同的含义。对于大多数汇编语言程序员来说,表格不过是一个初始化了某些数据的数组。汇编语言程序员通常使用表格来计算复杂或其他较慢的函数。许多高级语言(例如 SNOBOL4 和 Icon)直接支持 table 数据类型。这些语言中的表格本质上是关联数组,其元素可以通过非整数索引访问(例如,浮点数、字符串 或任何其他数据类型)。HLA 提供了一个 table 模块,让你可以使用字符串作为索引来访问数组。然而,在本章中,我们将采用汇编语言程序员对表格的理解。
表格是一个包含初始化值的数组,这些值在程序执行过程中不会改变。在汇编语言中,你可以使用表格来实现多种用途:计算函数、控制程序流程,或简单地查找数据。通常,表格提供了一种快速的机制来执行某些操作,代价是程序中的一些空间(额外的空间存放了表格数据)。在接下来的章节中,我们将探讨表格在汇编语言程序中的一些可能用途。
请注意,由于表格通常包含在程序执行过程中不会改变的初始化数据,readonly 区段是放置表格对象的一个好地方。
8.4.1 通过表格查找进行函数计算
在汇编语言中,表格可以实现各种功能。在像 Pascal 这样的高级语言中,创建一个计算某个值的公式很容易。看似简单的高级语言算术表达式,可能相当于大量的 80x86 汇编语言代码,因此可能计算起来非常昂贵。汇编语言程序员通常会预先计算许多值,并使用表格查找这些值来加速程序的运行。这种方法的优点是更简单,而且通常也更高效。考虑以下 Pascal 语句:
if (*`character`* >= 'a') and (*`character`* <= 'z') then *`character`* :=
chr(ord(*`character`*) - 32);
这个 Pascal if 语句将 character 变量的值从小写字母转换为大写字母,前提是 character 在 a..z 范围内。实现相同功能的 HLA 代码如下:
mov( *`character`*, al );
if( al in 'a'..'z' ) then
and( $5f, al ); // Same as sub( 32, al ) in this code.
endif;
mov( al, *`character`* );
请注意,HLA 的高级 if 语句在这个特定的例子中会转换成四条机器指令。因此,这段代码总共需要七条机器指令。
如果你把这段代码嵌套在一个循环中,除非使用表格查找,否则很难减少这段代码的大小。然而,使用表格查找可以将这段指令序列减少到仅四条指令:
mov( *`character`*, al );
lea( ebx, CnvrtLower );
xlat
mov( al, *`character`* );
你可能在想这段代码是如何工作的,并且在问,“这个新的指令 xlat 是什么?”xlat,或者叫转换指令,执行以下操作:
mov( [ebx+al*1], al );
也就是说,它使用 AL 寄存器的当前值作为索引,索引到存储在 EBX 中的数组的基地址。它会在该索引位置获取数组中的字节,并将该字节复制到 AL 寄存器中。英特尔将这个指令称为 translate,因为程序员通常使用它通过查找表将字符从一种形式转换为另一种形式。我们就是以这种方式来使用它的。
在之前的例子中,CnvrtLower是一个 256 字节的表,包含从 0 到$60 的值,索引范围是 0 到$60,$41 到$5A 的值位于索引$61 到$7A,\(7B 到\)FF 的值位于索引$7Bh 到 0FF。因此,如果 AL 寄存器包含$0 到$60 范围内的值,xlat指令会返回$0 到$60 的值,实际上不改变 AL 的值。然而,如果 AL 包含$61 到$7A 范围内的值(即 a..z 的 ASCII 码),那么xlat指令会用$41 到$5A 范围内的值替换 AL 中的值。$41 到$5A 恰好是 A..Z 的 ASCII 码。因此,如果 AL 原本包含一个小写字符($61..$7A),xlat指令会将 AL 中的值替换为$41..$5A 范围内的对应值,实际上将原来的小写字符($61..$7A)转换为大写字符($41..$5A)。表格中的其他条目,比如$0 到$60 中的条目,仅仅包含其特定元素在表中的索引。因此,如果 AL 最初包含\(7A 到\)FF 范围内的值,xlat指令将返回相应的表条目,该条目也包含\(7A 到\)FF 的值。
随着函数复杂性的增加,表查找方法的性能优势显著增加。虽然你几乎不会使用查找表来将小写字母转换为大写字母,但考虑一下如果你想通过计算交换大小写会发生什么:
mov( *`character`*, al );
if( al in 'a'..'z' ) then
and( $5f, al );
elseif( al in 'A'..'Z' ) then
or( $20, al );
endif;
mov( al, *`character`* ):
if和elseif语句分别生成 4 条和 5 条实际的机器指令,因此这段代码等价于 13 条实际的机器指令。
计算这个相同函数的表查找代码是:
mov( *`character`*, al );
lea( ebx, SwapUL );
xlat();
mov( al, *`character`* );
正如你所看到的,当使用表查找计算函数时,只有表格发生变化;代码保持不变。
表查找方法有一个主要问题——通过表查找计算的函数具有有限的定义域。函数的定义域是它可以接受的可能输入值(参数)的集合。例如,上述的大小写转换函数的定义域是 256 个字符的 ASCII 字符集。
像SIN或COS这样的函数接受实数集合作为可能的输入值。显然,SIN和COS的定义域远大于大小写转换函数。如果你打算通过表查找进行计算,你必须将函数的定义域限制为一个较小的集合。这是因为函数定义域中的每个元素都需要在查找表中有一个条目。如果一个函数的定义域是实数集,利用表查找来实现该函数会变得非常不实际。
大多数查找表都很小,通常只有 10 到 256 个条目。查找表很少超过 1,000 个条目。大多数程序员没有耐心去创建(并验证其正确性)一个包含 1,000 个条目的表。
基于查找表的函数的另一个限制是,函数的定义域中的元素必须相对连续。表查找会取函数的输入值,使用该输入值作为索引查找表,并返回该位置的值。如果你只传递给函数 0、100、1,000 和 10,000 这些值,那么它似乎是实现表查找的理想候选;它的定义域仅包含四个元素。然而,由于输入值的值域,查找表实际上需要 10,001 个不同的元素。因此,不能通过表查找有效地创建这样的函数。在本节关于表的内容中,我们将假设函数的定义域是一个相对连续的值集。
你可以通过表查找实现的最佳函数是那些其定义域和值域始终在 0..255 范围内(或该范围的某个子集)。你可以通过 80x86 的 xlat 指令高效地实现这些函数。前面介绍的大小写转换例程就是这种函数的良好示例。任何属于这一类的函数(其定义域和值域取值范围为 0..255)都可以使用同样的两条指令计算:lea( table, ebx ); 和 xlat();。唯一变化的只是查找表。
一旦函数的值域或定义域超出了 0..255,你就不能(方便地)使用 xlat 指令来计算函数值。需要考虑三种情况:
-
定义域超出了 0..255,但值域在 0..255 之间。
-
定义域在 0..255 内,但值域超出了 0..255。
-
函数的定义域和值域取值范围超出了 0..255。
我们将分别考虑这些情况。
如果一个函数的定义域超出了 0..255,但其值域在这个范围内,我们的查找表将需要超过 256 个条目,但我们可以用一个字节表示每个条目。因此,查找表可以是一个字节数组。除那些可以使用 xlat 指令进行查找的情况外,属于这一类的函数是最有效率的。以下是 Pascal 函数调用:
B := Func(X);
其中 Func 是
function Func(X:dword):byte;
它可以很容易地转换为以下 HLA 代码:
mov( X, ebx );
mov( FuncTable[ ebx ], al );
mov( al, B );
这段代码将函数参数加载到 ebx 中,使用该值(范围为 0..??)作为索引查找 FuncTable 表,获取该位置的字节,并将结果存储到 B 中。显然,表必须包含每个可能的 X 值的有效条目。例如,假设你想将视频屏幕上范围为 0..1,999 的光标位置(80×25 视频显示器上有 2,000 个字符位置)映射到屏幕上的 X 或 Y 坐标。你可以通过函数轻松计算 X 坐标:
X := Posn mod 80
并且 Y 坐标与公式
Y := Posn div 80
(其中 Posn 是屏幕上的光标位置)。这可以通过 80x86 代码轻松计算:
mov( Posn, ax );
div( 80, ax );
// X is now in ah, Y is now in al
然而,在 80x86 上,div 指令非常慢。如果你需要为每个写入屏幕的字符进行此计算,那么将严重降低视频显示代码的速度。以下代码通过表格查找实现这两个函数,可能会显著提升代码的性能:
movzx( Posn, ebx ); // Use a plain mov instr if Posn is
mov( YCoord[ebx], al ); // uns32 rather than an uns16 value.
mov( XCoord[ebx], ah );
如果一个函数的域在 0..255 之间,但其范围超出了这个集合,则查找表将包含 256 或更少的条目,但每个条目将需要 2 个或更多字节。如果函数的范围和域都超出了 0..255,则每个条目将需要 2 个或更多字节,且表格将包含超过 256 个条目。
回忆一下数组章节中,索引单维数组的公式(table 是其特殊情况)是:
Address := Base + index * size
如果函数的范围中的元素需要 2 字节,那么在索引表之前,必须将索引值乘以 2。同样,如果每个条目需要 3、4 或更多字节,那么在作为索引使用之前,必须将索引值乘以每个表项的大小。例如,假设你有一个函数 F(x),其定义如下(伪 Pascal 声明):
function F(x:dword):word;
你可以使用以下 80x86 代码轻松创建这个函数(当然,前提是有一个名为 F 的适当表格):
mov( X, ebx );
mov( F[ebx*2], ax );
任何域小且大部分连续的函数都是通过表格查找进行计算的好候选。在某些情况下,非连续域也是可以接受的,只要可以将该域转化为一组适当的值。这样的操作称为条件化,并将在下一节中讨论。
8.4.2 域条件化
域条件化是指通过对函数域中的一组值进行调整,使其更适合作为该函数的输入。考虑以下函数:

这表示(计算机)函数 sin(x) 等同于(数学)函数 sin x,其中

正如我们所知,正弦是一个循环函数,可以接受任何实数输入。然而,用于计算正弦的公式仅接受这组值中的一小部分。
这个范围限制并不会带来实际问题;通过简单地计算 sin(X mod (2*pi)),我们就能计算任意输入值的正弦值。修改输入值以便能够轻松计算函数值的过程称为输入条件化。在上面的例子中,我们计算了 X mod 2*pi 并将结果作为 sin 函数的输入。这样可以将 X 截断到 sin 所需的域,而不会影响结果。我们同样可以将输入条件化应用于表格查找。事实上,将索引进行缩放以处理字长条目就是一种输入条件化。考虑以下 Pascal 函数:
function val(x:word):word; begin
case x of
0: val := 1;
1: val := 1;
2: val := 4;
3: val := 27;
4: val := 256;
otherwise val := 0;
end;
end;
这个函数计算x在 0 到 4 范围内的某个值,如果x超出这个范围,则返回 0。由于x可以取 65,536 个不同的值(作为 16 位字),创建一个包含 65,536 个单词的表格,其中只有前五个条目非零,似乎非常浪费。然而,如果我们使用输入条件处理,仍然可以通过表格查找来计算这个函数。以下汇编语言代码展示了这一原理:
mov( 0, ax ); // ax = 0, assume x > 4.
movzx( x, ebx ); // Note that H.O. bits of ebx must be 0!
if( bx <= 4 ) then
mov( val[ ebx*2 ], ax );
endif;
这段代码检查x是否超出了 0 到 4 的范围。如果是,它手动将AX设置为 0;否则,它通过val表查找函数值。通过输入条件处理,你可以实现许多通过表格查找本来无法实现的函数。
8.4.3 生成表格
使用表格查找的一个大问题是首先创建表格。如果表格中有大量条目,这个问题尤为突出。确定要放入表格中的数据,然后费力地输入数据,最后检查数据以确保其有效性是一个非常耗时且枯燥的过程。对于许多表格,这个过程是无法避免的。而对于其他表格,有一种更好的方法——使用计算机为你生成表格。一个示例可能是最好的描述方式。考虑以下对正弦函数的修改:

这表示x是 0 到 359 之间的整数,r必须是一个整数。计算机可以通过以下代码轻松计算这一点:
movzx( x, ebx );
mov( Sines[ ebx*2], eax ); // Get sin(X) * 1000
imul( r, eax ); // Note that this extends eax into edx.
idiv( 1000, edx:eax ); // Compute (r*(sin(X)*1000)) / 1000
注意,整数的乘法和除法不是可交换的。你不能因为乘以 1,000 和除以 1,000 看起来互相抵消而省略这两步。此外,这段代码必须严格按照这个顺序来计算函数。我们需要的仅仅是一个包含 360 个不同值的表格,这些值对应于角度(以度为单位)的正弦乘以 1,000。将这样的表格输入到包含这些值的汇编语言程序中是非常枯燥的,而且你很可能在输入和验证这些数据时犯几个错误。然而,你可以让程序为你生成这个表格。可以参考示例 8-8 来理解这个过程。
示例 8-8。一个生成正弦表格的 HLA 程序
program GenerateSines;
#include( "stdlib.hhf" );
var
outFile: dword;
angle: int32;
r: int32;
readonly
RoundMode: uns16 := $23f;
begin GenerateSines;
// Open the file:
mov( fileio.openNew( "sines.hla" ), outFile );
// Emit the initial part of the declaration to the output file:
fileio.put
(
outFile,
stdio.tab,
"sines: int32[360] := " nl,
stdio.tab, stdio.tab, stdio.tab, "[" nl );
// Enable rounding control (round to the nearest integer).
fldcw( RoundMode );
// Emit the sines table:
for( mov( 0, angle); angle < 359; inc( angle )) do
// Convert angle in degrees to an angle in radians using
// radians := angle * 2.0 * pi / 360.0;
fild( angle );
fld( 2.0 );
fmulp();
fldpi();
fmulp();
fld( 360.0 );
fdivp();
// Okay, compute the sine of st0.
fsin();
// Multiply by 1000 and store the rounded result into
// the integer variable r.
fld( 1000.0 );
fmulp();
fistp( r );
// Write out the integers eight per line to the source file.
// Note: If (angle AND %111) is 0, then angle is evenly
// divisible by 8 and we should output a newline first.
test( %111, angle );
if( @z ) then
fileio.put
(
outFile,
nl,
stdio.tab,
stdio.tab,
stdio.tab,
stdio.tab,
r:5,
','
);
else
fileio.put( outFile, r:5, ',' );
endif;
endfor;
// Output sine(359) as a special case (no comma following it).
// Note: This value was computed manually with a calculator.
fileio.put
(
outFile,
" −17",
nl,
stdio.tab,
stdio.tab,
stdio.tab,
"];",
nl
);
fileio.close( outFile );
end GenerateSines;
上述程序产生了以下输出(为了简洁起见进行了截断):
sines: int32[360] :=
[
0, 17, 35, 52, 70, 87, 105, 122,
139, 156, 174, 191, 208, 225, 242, 259,
276, 292, 309, 326, 342, 358, 375, 391,
407, 423, 438, 454, 469, 485, 500, 515,
530, 545, 559, 574, 588, 602, 616, 629,
643, 656, 669, 682, 695, 707, 719, 731,
.
.
.
−643, −629, −616, −602, −588, −574, −559, −545,
−530, −515, −500, −485, −469, −454, −438, −423,
−407, −391, −375, −358, −342, −326, −309, −292,
−276, −259, −242, −225, −208, −191, −174, −156,
−139, −122, −105, −87, −70, −52, −35, −17
];
很明显,编写生成此数据的 HLA 程序要比手动输入(和验证)这些数据容易得多。当然,您甚至不必用 HLA 编写表生成程序。如果愿意,您可能会发现在 Pascal/Delphi、C/C++ 或其他高级语言中编写程序更容易。因为该程序只会执行一次,所以表生成程序的性能不是问题。如果在高级语言中编写表生成程序更容易,请务必这样做。还请注意,HLA 具有内置解释器,允许您轻松创建表格,而无需使用外部程序。有关详细信息,请参阅 第九章。
运行表生成程序后,唯一剩下的任务就是从文件(例如 sines.hla)中复制并粘贴表格到实际使用表格的程序中。
8.4.4 表查找性能
在个人计算机早期,表查找是进行高性能计算的首选方式。然而,随着新 CPU 速度远远超过存储器速度,查找表的优势正在减弱。如今,CPU 的速度往往比主存储器快 10 到 100 倍。因此,使用表查找可能不比使用机器指令进行相同计算更快。因此,值得简要讨论表查找在何时提供显著优势。
尽管 CPU 比主存储器快得多,但芯片上的 CPU 缓存存储子系统以接近 CPU 速度运行。因此,如果您的表驻留在 CPU 的缓存存储器中,则表查找可以是一种成本效益较高的方法。这意味着要获得良好的性能,使用小表格(因为缓存存储器上只有有限空间)并使用频繁引用其条目的表格(以便这些表格保留在缓存中)。有关缓存存储器操作及如何优化缓存存储器使用的详细信息,请参阅《编写优秀代码,第 1 卷》(No Starch Press)或《汇编语言的艺术》的电子版,网址分别为 webster.cs.ucr.edu/ 或 www.artofasm.com/。
8.5 获取更多信息
HLA 标准库参考手册包含大量关于 HLA 标准库扩展精度算术功能的信息。您还需要查看几个 HLA 标准库例程的源代码,了解如何执行各种扩展精度操作(在计算完成后正确设置标志)。HLA 标准库源代码还涵盖了本章未提及的扩展精度 I/O 操作。
唐纳德·克努斯的计算机程序设计的艺术,第二卷:半数值算法包含了很多关于十进制算术和扩展精度算术的有用信息,尽管该文本是通用的,并没有描述如何在 x86 汇编语言中实现这些内容。
第九章. 宏与 HLA 编译时语言

本章讨论了 HLA 编译时语言。讨论的内容包括 HLA 编译时语言中最重要的组成部分之一——宏。许多人通过汇编器的宏处理能力来判断其强大程度。如果你恰好是这些人之一,那么在阅读完本章之后,你可能会同意 HLA 是地球上最强大的汇编器之一,因为 HLA 拥有任何计算机语言处理系统中最强大的宏处理功能之一。
9.1 编译时语言(CTL)简介
HLA 实际上是将两种语言合并到一个程序中。运行时语言是你在前面所有章节中阅读过的标准 80x86/HLA 汇编语言。这被称为运行时语言,因为你编写的程序在你运行可执行文件时执行。HLA 包含了第二种语言的解释器——HLA 编译时语言(CTL),它在 HLA 编译程序时执行程序。CTL 程序的源代码嵌入在 HLA 汇编语言源文件中;也就是说,HLA 源文件包含了 HLA CTL 和运行时程序的指令。HLA 在编译过程中执行 CTL 程序。HLA 完成编译后,CTL 程序终止;CTL 应用程序不是 HLA 生成的运行时可执行文件的一部分,尽管 CTL 应用程序可以为你写部分运行时程序,实际上,CTL 的主要目的就是这个(见图 9-1)。

图 9-1. 编译时执行与运行时执行
在同一个编译器中内置两个独立的语言可能会让人感到困惑。也许你甚至在质疑,为什么需要编译时语言。为了理解编译时语言的好处,请考虑以下你现在应该非常熟悉的陈述:
stdout.put("i32=",i32," strVar=",strVar," charVar=",charVar,nl);
这条陈述既不是 HLA 语言中的一条语句,也不是对某个 HLA 标准库过程的调用。实际上,stdout.put是 HLA 标准库提供的 CTL 应用程序中的一条语句。stdout.put "应用程序"处理参数列表,并生成对其他标准库过程的调用;它根据当前处理的参数类型选择要调用的过程。例如,上述stdout.put "应用程序"将向运行时可执行文件输出以下语句:
stdout.puts( "i32=" );
stdout.puti32( i32 );
stdout.puts( " strVar=" );
stdout.puts( strVar );
stdout.puts( " charVar=" );
stdout.putc( charVar );
stdout.newln();
显然,stdout.put 语句比 stdout.put 为其参数列表发出的语句序列更容易阅读和编写。这是 HLA 编程语言更强大的功能之一:能够修改语言以简化常见的编程任务。按顺序打印不同数据对象是一个常见任务;stdout.put “应用”极大简化了这一过程。
HLA 标准库中充满了许多 HLA CTL 示例。除了标准库的使用,HLA CTL 在处理“一次性”应用程序时也非常擅长。一个经典的例子是填充查找表的数据。第八章提到,使用 HLA CTL 构建查找表是可行的。不仅如此,使用 HLA CTL 构建这些表往往比其他方法省时省力。
尽管 CTL 本身相对低效,通常不用于编写最终用户应用程序,但它最大化了你时间的使用。通过学习如何使用 HLA CTL 并正确应用它,你可以像开发高级语言应用程序一样快速开发汇编语言应用程序(甚至更快,因为 HLA 的 CTL 允许你创建非常高级语言的构造)。
9.2 #print 和 #error 语句
你可能还记得,第一章以大多数人在学习新语言时编写的典型第一个程序——“Hello, world!”程序开始。当讨论本书的第二种语言时,呈现这个程序是非常合适的。示例 9-1 提供了用 HLA 编译时语言编写的基本“Hello, world!”程序。
示例 9-1. CTL “Hello, world!”程序
program ctlHelloWorld;
begin ctlHelloWorld;
#print( "Hello, World of HLA/CTL" )
end ctlHelloWorld;
该程序中的唯一 CTL 语句是 #print 语句。其余的行仅仅是为了让编译器正常工作(尽管我们可以通过使用 unit 声明而非 program 声明,将开销减少到两行)。
#print 语句在编译 HLA 程序时会显示其参数列表的文本表示。因此,如果你使用命令 hla ctlHW.hla 编译上面的程序,HLA 编译器将立即打印出以下文本:
Hello, World of HLA/CTL
请注意,在 HLA 源文件中,以下两个语句之间有很大的区别:
#print( "Hello World" )
stdout.puts( "Hello World" nl );
第一个语句在编译过程中打印出 Hello World(并添加一个换行符)。这个第一个语句对可执行程序没有任何影响。第二行则不影响编译过程(除了向可执行文件发出代码)。然而,当你运行可执行文件时,第二个语句会打印出字符串 Hello World,后跟一个换行符。
HLA/CTL #print 语句使用以下基本语法:
#print( *`list_of_comma_separated_constants`* )
注意,分号并不会终止此语句。分号终止的是运行时语句;通常不会终止编译时语句(有一个例外,稍后你会看到)。
#print语句必须至少有一个操作数;如果参数列表中有多个操作数,你必须用逗号分隔每个操作数(就像stdout.put一样)。如果某个操作数不是字符串常量,HLA 会将该常量转换为相应的字符串表示并打印该字符串。下面是一个示例:
#print( "A string Constant ", 45, ' ', 54.9, ' ', true )
你可以指定命名的符号常量和常量表达式。然而,所有的#print操作数必须是常量(无论是字面常量还是你在const或val部分定义的常量),这些常量必须在你使用它们的#print语句之前定义。例如:
const
pi := 3.14159;
charConst := 'c';
#print( "PI = ", pi, " CharVal=", charConst )
HLA 的#print语句对于调试 CTL 程序特别有价值。这个语句还用于显示编译进度,展示编译过程中发生的假设和默认操作。除了显示与#print参数列表相关的文本外,#print语句对程序的编译没有其他影响。
#error语句允许一个单一的字符串常量操作数。和#print一样,这个语句会在编译时将字符串显示到控制台。然而,#error语句将该字符串视为错误信息,并作为 HLA 错误诊断的一部分显示该字符串。此外,#error语句会增加错误计数,这将导致 HLA 在处理完当前源文件后停止编译(不会汇编或链接)。通常,当你的 CTL 代码发现某些问题,无法生成有效代码时,你会使用#error语句在编译期间显示错误信息。例如:
#error( "Statement must have exactly one operand" )
和#print语句一样,#error语句也不以分号结尾。虽然#error只允许一个单一的字符串操作数,但通过使用编译时字符串连接操作符和几个 HLA 内建的编译时函数,你可以很容易地打印其他值。你将在本章稍后部分了解这些内容。
9.3 编译时常量和变量
就像运行时语言一样,编译时语言也支持常量和变量。你可以像在运行时语言中一样在const部分声明编译时常量。在val部分声明编译时变量。你在val部分声明的对象对运行时语言来说是常量,但记住,你可以在整个源文件中改变你在val部分声明的对象的值。因此,称之为“编译时变量”。有关详细信息,请参见第四章。
CTL 赋值语句(?)计算赋值运算符(:=)右侧常量表达式的值,并将结果存储到赋值运算符左侧紧接的 val 对象名中。^([118]) 这个示例代码可以出现在 HLA 源文件的任何位置,而不仅仅是程序的 val 部分。
?ConstToPrint := 25;
#print( "ConstToPrint = ", ConstToPrint )
?ConstToPrint := ConstToPrint + 5;
#print( "Now ConstToPrint = ", ConstToPrint )
^([118]) 如果赋值运算符左侧的标识符未定义,HLA 将自动在当前作用域级别声明该对象。
9.4 编译时表达式和运算符
HLA CTL 支持在 CTL 赋值语句中使用常量表达式。与运行时语言不同(在运行时,你需要将代数符号转换为一系列机器指令),HLA CTL 允许使用熟悉的表达式语法进行完整的算术运算。这使得 HLA CTL 在编译时表达式中具有相当大的能力,尤其是当与下一节讨论的内置编译时函数结合使用时。
表 9-1 和 表 9-2 列出了 HLA CTL 在编译时表达式中支持的运算符。
表 9-1. 编译时运算符
| 运算符 | 操作数类型^([a]) | 描述 |
|---|---|---|
-(一元运算符) |
numeric | 对特定的数值(int, uns, real)进行取负运算。 |
| cset | 返回指定字符集的补集。 | |
!(一元运算符) |
integer | 反转操作数中的所有位(按位 not)。 |
| boolean | 操作数的布尔 not。 |
|
* |
numericL * numericR | 计算两个操作数的乘积。 |
| csetL * csetR | 计算两个集合的交集。 | |
div |
integerL divintegerR | 计算两个整数(int/uns/dword)操作数的整数商。 |
mod |
integerL modintegerR | 计算两个整数(int/uns/dword)操作数的除法余数。 |
/ |
numericL / numericR | 计算两个数值操作数的实数商。即使两个操作数都是整数,也返回实数结果。 |
<< |
integerL << integerR | 将 integerL 操作数向左移动由 integerR 操作数指定的位数。 |
>> |
integerL >> integerR | 将 integerL 操作数向右移动由 integerR 操作数指定的位数。 |
+ |
numericL + numericR | 将两个数值操作数相加。 |
| csetL + csetR | 计算两个集合的并集。 | |
| strL + strR | 连接两个字符串。 | |
- |
numericL numericR | 计算 numericL 和 numericR 之间的差。 |
| csetL - csetR | 计算 csetL - csetR 的集合差。 | |
= 或 == |
numericL = numericR | 如果两个操作数具有相同的值,则返回真。 |
| csetL = csetR | 如果两个集合相等,则返回真。 | |
| strL = strR | 如果两个字符串/字符相等,则返回真。 | |
| typeL = typeR | 如果两个值相等,则返回 true。它们必须是相同类型。 | |
<> 或 != |
typeL <> typeR(与 != 相同) | 如果两个(兼容的)操作数不相等(数值、字符集或字符串),则返回 false。 |
< |
numericL < numericR | 如果 numericL 小于 numericR,则返回 true。 |
| csetL < csetR | 如果 csetL 是 csetR 的适当子集,则返回 true。 | |
| strL < strR | 如果 strL 小于 strR,则返回 true。 | |
| booleanL < booleanR | 如果左操作数小于右操作数,则返回 true(注意:false < true)。 | |
| enumL < enumR | 如果 enumL 出现在与 enumR 相同的枚举列表中,并且 enumL 出现得更早,则返回 true。 | |
<= |
与 < 相同 | 如果左操作数小于或等于右操作数,则返回 true。对于字符集,意味着左操作数是右操作数的子集。 |
> |
与 < 相同 | 如果左操作数大于右操作数,则返回 true。对于字符集,意味着左操作数是右操作数的一个适当超集。 |
>= |
与 <= 相同 | 如果左操作数大于或等于右操作数,则返回 true。对于字符集,意味着左操作数是右操作数的超集。 |
& |
integerL & integerR | 计算两个操作数的按位 and。 |
| booleanL & booleanR | 计算两个操作数的逻辑 and。 |
|
| |
integerL | integerR | 计算两个操作数的按位 or。 |
| booleanL | booleanR | 计算两个操作数的逻辑 or。 |
|
^ |
integerL ^ integerR | 计算两个操作数的按位 xor。 |
| booleanL ^ booleanR | 计算两个操作数的逻辑 xor。注意,这等同于 booleanL <> booleanR。 |
|
in |
charL 在 csetR 中 | 如果 charL 是 csetR 的成员,则返回 true。 |
|
^([a]) 类型 numeric 是 {intXX, unsXX, byte, word, dword 和 realXX} 值。类型 cset 是字符集操作数。类型 integer 是 {intXX, unsXX, byte, word, dword}。类型 str 是任何字符串或字符值。类型表示任意 HLA 类型。其他类型指定一个显式的 HLA 数据类型。 |
|
表 9-2. 运算符优先级和结合性 |
| 结合性 | 优先级(从高到低) | 运算符 |
|---|---|---|
| 从右到左 | 6 | !(一元操作符) |
-(一元操作符) |
||
| 从左到右 | 5 | * |
div |
||
mod |
||
/ |
||
>> |
||
<< |
||
| 从左到右 | 4 | + |
- |
||
| 从左到右 | 3 | = 或 == |
<> 或 != |
||
< |
||
<= |
||
> |
||
>= |
||
| 从左到右 | 2 | & |
| |
||
^ |
||
| 非结合性 | 1 | in |
当然,你可以通过在表达式中使用括号来覆盖运算符的默认优先级和结合性。 |
9.5 编译时函数 |
HLA 提供了丰富的编译时函数供你使用。这些函数在编译期间计算值,方式与高级语言函数在运行时计算值类似。HLA 的编译时语言包括各种数值、字符串和符号表函数,帮助你编写复杂的编译时程序。
大多数内建的编译时函数名称以特殊符号@开头,名称类似于@sin或@length。使用这些特殊标识符可以避免与程序中可能使用的常见名称(例如length)发生冲突。剩余的编译时函数(那些不以@开头的)通常是数据转换函数,使用类型名称如int8和real64。你甚至可以通过宏来创建自己的编译时函数(宏的使用在 9.8 宏(编译时过程)")中讨论)。
HLA 根据操作类型将编译时函数组织成不同的类别。例如,有一些函数将常量从一种形式转换为另一种形式(例如字符串到整数的转换),还有许多有用的字符串函数,HLA 提供了一整套编译时数值函数。
HLA 编译时函数的完整列表过于庞大,无法在此呈现。相反,关于每个编译时对象和函数的完整描述可以在 HLA 参考手册中找到(可在webster.cs.ucr.edu/或www.artofasm.com/查阅);本节将突出介绍一些函数,以展示它们的使用方法。本章后续章节以及未来章节将广泛使用各种编译时函数。
也许理解编译时函数最重要的概念是,它们在你的汇编语言代码(即运行时程序)中等同于常量。例如,编译时函数调用@sin(3.1415265358979328)大致相当于在程序中指定 0.0。^([119])像@sin(x)这样的函数调用,只有在x是一个在函数调用点之前已声明的常量时才是合法的。特别是,x不能是运行时变量或其他在运行时而非编译时存在的对象。因为 HLA 将编译时函数调用替换为其常量结果,你可能会问为什么还要使用编译时函数。毕竟,在程序中输入0.0可能比输入@sin(3.1415265358979328)更方便。然而,编译时函数对于生成查找表和其他可能在你改变程序中的const值时发生变化的数学结果非常有用。9.9 编写编译时“程序”将进一步探讨这个想法。
9.5.1 类型转换编译时函数
最常用的编译时函数可能是类型转换函数。这些函数接受一个类型的单一参数,并将该信息转换为指定的类型。这些函数使用多个 HLA 内建数据类型名称作为函数名。该类别中的函数包括:
-
boolean -
int8、int16、int32、int64和int128 -
uns8、uns16、uns32、uns64和uns128 -
byte、word、dword、qword和lword(它们实际上等同于uns8、uns16、uns32、uns64和uns128) -
real32、real64和real80 -
char -
string -
cset -
text
这些函数接受一个常量表达式参数,并且如果合理的话,将该表达式的值转换为指定类型的值。例如,以下函数调用返回值−128,因为它将字符串常量转换为对应的整数值:
int8( "-128" )
某些转换是没有意义的,或者有相关的限制。例如,boolean函数会接受一个字符串参数,但该字符串必须是“true”或“false”,否则函数会生成编译时错误。同样,数字转换函数(例如,int8)允许一个字符串操作数,但该字符串操作数必须表示一个合法的数字值。某些转换(例如,带有字符集参数的int8)根本没有意义,并且始终是非法的。
这个类别中最有用的函数之一是string函数。该函数接受几乎所有常量表达式类型,并生成一个表示参数数据的字符串。例如,调用string(128)会生成字符串128作为返回结果。当你有一个值需要在 HLA 中作为字符串使用时,这个函数非常方便。例如,#error编译时语句只允许一个字符串操作数。你可以使用string函数和字符串连接运算符(+)轻松绕过这个限制。例如:
#error( "theValue (" + string( theValue ) + ") is out of range" )
请注意,这些类型函数实际上执行的是转换。这意味着这些函数返回的位模式可能与传递的参数的位模式有很大不同。例如,考虑以下对real32函数的调用:
real32( $3F80_0000 )
现在事实证明,$3F80_0000 是real32值 1.0 的十六进制等价物。然而,前面的函数调用并不会返回 1.0;相反,它试图将整数值$3F80_0000(1,065,353,216)转换为real32值,但失败了,因为该值太大,无法使用real32对象精确表示。与此相对比,以下常量函数:
char( 65 )
这个 CTL 函数调用返回字符 A(因为 65 是 A 的 ASCII 码)。请注意,char 函数只是简单地使用你传递给它的整数参数的位模式作为 ASCII 码,而 real32 函数试图将整数参数转换为浮点值。尽管这两个函数的语义差别很大,但归根结底,它们倾向于执行直观的操作,即使这牺牲了一定的统一性。
然而,有时你可能不希望这些函数做“直观”的操作。例如,你可能希望 real32 函数仅仅将你传入的位模式作为 real32 值来处理。为了处理这种情况,HLA 提供了第二组类型函数,它们只是类型名称前加上 @ 前缀,并将参数视为最终类型的位模式。因此,如果你真的希望从 $3F80_0000 生成 1.0,你可以使用以下函数调用:
@real32( $3F80_0000 )
一般来说,这种类型强制转换在编译时语言中是比较高级的,所以你可能不会经常使用它。然而,当它需要时,能够使用它还是很方便的。
9.5.2 数值编译时函数
本类函数在编译时执行标准的数学操作。这些函数非常适合生成查找表,并通过在程序开始时重新计算已定义常量的函数来“参数化”源代码。本类函数包括以下内容:
|
@abs(n)
| 数值参数的绝对值 |
|---|
|
@ceil(r), @floor(r)
| 提取浮点值的整数部分 |
|---|
|
@sin(r),@cos(r),@tan(r)
| 标准三角函数 |
|---|
|
@exp(r),@log(r),@log10(r)
| 标准对数/指数函数 |
|---|
|
@min(list),@max(list)
| 从值列表中返回最小/最大值 |
|---|
|
@random,@randomize
返回伪随机的 int32 值 |
|---|
|
@sqrt(n)
| 计算数值参数的平方根(实数结果) |
|---|
详情请参见 HLA 参考手册 webster.cs.ucr.edu/ 或 www.artofasm.com/。
9.5.3 字符分类编译时函数
本组函数均返回布尔结果。它们测试一个字符(或字符串中的所有字符),查看其是否属于某一类字符。该类别的函数包括以下内容:
-
@isAlpha(c),@isAlphanum(c) -
@isDigit(c),@isxDigit(c) -
@isLower(c),@isUpper(c) -
@isSpace(c)
除了这些字符分类函数,HLA 语言还提供了一组模式匹配函数,你也可以用来分类字符和字符串数据。有关这些例程的讨论,请参见 HLA 参考手册。
9.5.4 编译时字符串函数
本类别中的函数操作字符串参数。大多数返回字符串结果,尽管有一些(例如 @length 和 @index)返回整数结果。这些函数不会直接影响它们参数的值;相反,它们返回一个合适的结果,如果你愿意,可以将其赋值回参数。
-
@delete,@insert -
@index,@rindex -
@length -
@lowercase,@uppercase -
@strbrk,@strspan -
@strset -
@substr,@tokenize,@trim
关于这些函数、它们的参数以及类型的具体细节,请参见 HLA 参考手册。请注意,这些是 HLA 标准库中许多字符串函数的编译时等效函数。
@length 函数值得特别讨论,因为它可能是这个类别中最常用的函数。它返回一个 uns32 常量,指定其字符串参数中包含的字符数。语法如下:
@length( *`string_expression`* )
其中 string_expression 代表任何编译时字符串表达式。如前所述,这个函数返回指定表达式的字符长度。
9.5.5 编译时符号信息
在编译过程中,HLA 维护一个内部数据库,称为 符号表。符号表包含了关于你在程序中的某个点之前定义的所有标识符的许多有用信息。为了生成机器代码输出,HLA 需要查询这个数据库以确定如何处理某些符号。在你的编译时程序中,通常需要查询符号表来决定如何处理代码中的标识符或表达式。HLA 编译时符号信息函数负责执行这一任务。
许多编译时符号信息函数超出了本书的范围。本章将介绍其中的一些函数。有关编译时符号表函数的完整列表,请参见 HLA 参考手册。本章将讨论的函数包括以下内容:
-
@size -
@defined -
@typeName -
@elements -
@elementSize
毫无疑问,@size 函数可能是这一组中最重要的函数。事实上,前面的章节已经使用过这个函数。@size 函数需要一个单一的 HLA 标识符或常量表达式作为参数。它返回该对象(或表达式)数据类型的字节大小。如果你提供一个标识符,它可以是常量、类型或变量标识符。正如你在前面的章节中看到的,这个函数在通过 mem.alloc 分配存储和为数组分配存储时非常有用。
这个组中另一个非常有用的函数是 @defined 函数。这个函数接受一个单一的 HLA 标识符作为参数。例如:
@defined( *`MyIdentifier`* )
这个函数在程序的某个点上返回 true,如果标识符在该点已定义;否则返回 false。
@typeName 函数返回一个字符串,指定你作为参数提供的标识符或表达式的类型名称。例如,如果 i32 是一个 int32 对象,那么 @typeName( i32 ) 将返回字符串 int32。这个函数对于测试你在编译时程序中处理的对象类型非常有用。
@elements 函数需要一个数组标识符或表达式。它返回数组元素的总数作为函数结果。需要注意的是,对于多维数组,这个函数返回所有数组维度的乘积。^([120])
@elementSize 函数返回你作为参数传递的数组元素的大小(以字节为单位)。这个函数对于计算数组的索引非常有价值(也就是说,这个函数计算数组索引计算中的 element_size 组件;更多细节请参见第四章)。
9.5.6 杂项编译时函数
HLA 编译时语言包含一些不属于上述类别的额外函数。一些比较有用的杂项函数包括以下内容:
-
@odd -
@lineNumber -
@text
@odd 函数接受一个序数值(即非实数的数字或字符)作为参数,如果该值是奇数,则返回 true,如果是偶数,则返回 false。@lineNumber 函数不需要参数;它返回源文件中的当前行号。这个函数对于调试编译时(和运行时!)程序非常有用。
@text 函数可能是这个组中最有用的函数。它需要一个单字符串参数,并将该字符串在 @text 函数调用处扩展为文本。这个函数与编译时字符串处理函数结合使用时非常有用。你可以使用字符串操作函数构建指令(或指令的一部分),然后通过 @text 函数将该字符串转换为程序源代码。以下是这个函数在操作中的一个简单示例:
?id1:string := "eax";
?id2:string := "i32";
@text( "mov( " + id1 + ", " + id2 + ");" )
上述序列编译为
mov( eax, i32 );
9.5.7 编译时文本对象的类型转换
一旦你在程序中创建了一个文本常量,就很难对该对象进行操作。以下示例展示了程序员希望在程序中更改文本符号定义的场景:
val
t:text := "stdout.put";
.
.
.
?t:text := "fileio.put";
这个示例中的基本思路是,符号 t 在代码的前半部分展开为 stdout.put,而在程序的后半部分则展开为 fileio.put。不幸的是,这个简单的示例不会生效。问题在于 HLA 会在几乎任何地方扩展文本符号。包括在 ? 语句中的 t。因此,之前的代码扩展成了以下(不正确的)文本:
val
t:text := "stdout.put";
.
.
.
?stdout.put:text := "fileio.put";
HLA 不知道如何处理这个 ? 语句,所以它会生成语法错误。
有时你可能不希望 HLA 展开文本对象。你的代码可能需要处理文本对象中持有的字符串数据。HLA 提供了几种方法来处理这两个问题:
-
@string(identifier) -
@toString:identifier
对于@string( identifier ),HLA 返回与文本对象关联的字符串常量。换句话说,这个运算符允许你在表达式中将文本对象当作字符串常量来处理。
不幸的是,@string函数将文本对象转换为字符串常量,而不是字符串标识符。因此,你不能像这样写
?@string(t) := "Hello"
这不起作用,因为@string(t)将其自身替换为与文本对象t关联的字符串常量。根据之前对t的赋值,这个语句展开为
?"stdout.put" := "Hello";
这个语句仍然是非法的。
在这种情况下,@toString:identifier运算符来帮忙。@toString:运算符要求一个文本对象作为关联标识符。它将这个文本对象转换为一个字符串对象(仍然保持相同的字符串数据),然后返回该标识符。因为标识符现在是一个字符串对象,你可以给它赋值(并将其类型更改为其他类型,例如text,如果需要的话)。因此,为了实现原始目标,你可以使用如下代码:
val
t:text := "stdout.put";
.
.
.
?@toString:t : text := "fileio.put";
^([119]) 实际上,因为在这个例子中,@sin的参数并不完全是 pi,所以你会得到一个小的正数而不是零作为函数结果,但理论上应该得到零。
^([120]) 有一个@dim函数,返回一个数组,指定多维数组每个维度的边界。如果你对这个函数感兴趣,可以查看webster.cs.ucr.edu/或www.artofasm.com/的文档获取更多详情。
9.6 条件编译(编译时决策)
HLA 的编译时语言提供了一个#if语句,可以让你在编译时做出决策。#if语句有两个主要目的:传统用途是支持条件编译(或条件汇编),根据程序中各种符号或常量值的状态在编译过程中包含或排除代码。该语句的第二个用途是支持 HLA 编译时语言中的标准 if 语句决策过程。本节讨论了#if语句的这两种用途。
HLA 编译时#if语句的最简单形式使用以下语法:
#if( *`constant_boolean_expression`* )
<< text >>
#endif
请注意,#endif语句后面不应放置分号。如果在#endif后加上分号,它将成为源代码的一部分,这等同于在程序中的下一个项之前插入该分号。
在编译时,HLA 会评估 #if 后括号中的表达式。这个表达式必须是常量表达式,并且其类型必须是布尔类型。如果表达式求值为 true,HLA 会继续处理源文件中的文本,仿佛 #if 语句不存在一样。然而,如果表达式求值为 false,HLA 会将 #if 和相应的 #endif 之间的所有文本视为注释(即忽略这些文本),如图 Figure 9-2 所示。

图 9-2. HLA 编译时 #if 语句的操作
请记住,HLA 的常量表达式支持完整的表达式语法,就像在 C 或 Pascal 等高级语言中找到的那样。#if 表达式的语法不限于 HLA if 语句中允许的表达式语法。因此,编写如下复杂的表达式是完全合理的:
#if( @length( someStrConst ) < 10*i & ( (MaxItems*2 + 2) < 100 | MinItems-5 < 10 ))
<< text >>
#endif
还需要记住,编译时表达式中的标识符必须是 const 或 val 标识符,或是 HLA 编译时函数调用(并带有适当的参数)。特别需要注意的是,HLA 在编译时评估这些表达式,因此它们不能包含运行时变量。^([121]) HLA 的编译时语言使用完整的布尔评估,因此表达式中出现的任何副作用可能会产生不期望的结果。
HLA 的 #if 语句支持可选的 #elseif 和 #else 子句,这些子句按直观的方式进行操作。#if 语句的完整语法如下所示:
#if( *`constant_boolean_expression_1`* )
<< text >>
#elseif( *`constant_boolean_expression_2`* )
<< text >>
#else
<< text >>
#endif
如果第一个布尔表达式求值为 true,那么 HLA 会处理直到 #elseif 子句的文本。然后,它会跳过所有文本(即将其视为注释),直到遇到 #endif 子句。HLA 会继续按正常方式处理 #endif 子句之后的文本。
如果上面的第一个布尔表达式求值为 false,那么 HLA 会跳过所有文本,直到遇到 #elseif、#else 或 #endif 子句。如果遇到 #elseif 子句(如上所示),HLA 会评估与该子句相关联的布尔表达式。如果它求值为 true,HLA 会处理 #elseif 和 #else 子句之间的文本(如果没有 #else 子句,则处理到 #endif 子句)。如果在处理这些文本时,HLA 遇到另一个 #elseif 或像上面那样的 #else 子句,HLA 会忽略所有后续文本,直到找到相应的 #endif。
如果前面示例中的第一个和第二个布尔表达式都为假,HLA 会跳过它们相关的文本并开始处理#else子句中的文本。如你所见,一旦理解了 HLA 如何“执行”这些语句的主体,#if语句的行为就变得相对直观;#if语句会根据布尔表达式的状态来处理文本或将其视为注释。当然,你可以通过包含零个或多个#elseif子句,并可选择性地提供#else子句,来创建几乎无限种不同的#if语句序列。由于这种构造与 HLA 的if..then..elseif..else..endif语句完全相同,因此此处无需进一步详细说明。
条件编译的一个非常传统的应用是开发可以轻松配置为多个不同环境的软件。例如,fcomip指令使得浮点比较变得非常容易,但该指令仅在 Pentium Pro 及更高版本的处理器上可用。如果你希望在支持此指令的处理器上使用它,并在较旧的处理器上回退到标准的浮点比较,你通常需要编写两个版本的程序——一个使用fcomip指令,另一个使用传统的浮点比较序列。不幸的是,维护两个不同的源文件(一个针对较新的处理器,一个针对较旧的处理器)非常困难。大多数工程师更倾向于使用条件编译将不同的序列嵌入同一个源文件中。以下示例演示了如何实现这一点:
const
// Set true to use FCOMIxx instrs.
PentProOrLater: boolean := false;
.
.
.
#if( PentProOrLater )
fcomip(); // Compare st1 to st0 and set flags.
#else
fcomp(); // Compare st1 to st0.
fstsw( ax ); // Move the FPU condition code bits
sahf(); // into the flags register.
#endif
如当前编写的代码片段所示,它会在#else子句中编译三条指令序列,并忽略#if和#else子句之间的代码(因为常量PentProOrLater为假)。通过将PentProOrLater的值更改为真,你可以告诉 HLA 编译单个fcomip指令,而不是三条指令的序列。当然,你可以在程序中的其他#if语句中使用PentProOrLater常量,以控制 HLA 如何编译你的代码。
请注意,条件编译并不能让你创建一个能在所有处理器上高效运行的可执行文件。使用这种技术时,你仍然需要创建两个可执行程序(一个用于 Pentium Pro 及更高版本的处理器,一个用于较早版本的处理器),通过编译源文件两次:第一次编译时必须将PentProOrLater常量设置为假;第二次编译时必须将该常量设置为真。尽管你需要创建两个独立的可执行文件,但只需维护一个源文件。
如果你熟悉其他语言中的条件编译,例如 C/C++语言,你可能会想知道 HLA 是否支持类似 C 语言中的#ifdef语句。答案是否定的,HLA 不支持。但你可以使用 HLA 的编译时函数@defined轻松测试符号是否已在源文件中定义。考虑以下对前面代码的修改,使用了这种技巧:
const
// Note: Uncomment the following line if you are compiling this
// code for a Pentium Pro or later CPU.
// PentProOrLater :=0; // Value and type are irrelevant.
.
.
.
#if( @defined( PentProOrLater ) )
fcomip(); // Compare st1 to st0 and set flags.
#else
fcomp(); // Compare st1 to st0.
fstsw( ax ); // Move the FPU condition code bits
sahf(); // into the flags register.
#endif
条件编译的另一个常见用途是将调试和测试代码引入程序中。许多 HLA 程序员使用的典型调试技巧是在代码中的关键位置插入“print”语句;这使得他们能够在代码中跟踪并显示在不同检查点的关键值。然而,这种技巧的一个大问题是,在项目完成之前,他们必须删除调试代码。软件的客户(或学生的导师)可能不希望在程序生成的报告中看到调试输出。因此,使用这种技巧的程序员往往会暂时插入代码,然后在运行程序并确定问题所在后将代码移除。使用这种技巧至少有两个问题:
-
程序员常常忘记删除一些调试语句,这会导致最终程序中出现缺陷。
-
在移除调试语句后,这些程序员常常发现他们需要该语句来调试稍后出现的不同问题。因此,他们不断地插入和移除相同的语句。
条件编译可以提供解决这个问题的方法。通过定义一个符号(例如debug)来控制程序中的调试输出,你可以通过简单地修改一行源代码,轻松地激活或停用所有调试输出。以下代码片段演示了这一点:
const
// Set to true to activate debug output.
debug: boolean := false;
.
.
.
#if( debug )
stdout.put( "At line ", @lineNumber, " i=", i, nl );
#endif
只要你像前面那样,用#if语句将所有调试输出语句包裹起来,就不必担心调试输出会意外出现在最终的应用程序中。通过将debug符号设置为false,你可以自动禁用所有此类输出。同样,你也不必在调试语句完成其即时功能后将其从程序中移除。通过使用条件编译,你可以将这些语句保留在代码中,因为它们很容易被停用。以后,如果你决定在编译过程中需要查看相同的调试信息,你不必重新输入调试语句;只需通过将debug符号设置为true来重新启用它。
尽管程序配置和调试控制是条件编译的两种更常见、传统的用途,但不要忘记,#if 语句提供了 HLA 编译时语言中的基本条件语句。你将像在 HLA 或其他语言中使用 if 语句一样,在编译时程序中使用 #if 语句。本书后续部分将提供许多关于如何在这方面使用 #if 语句的示例。
^([121]) 当然,除非作为某些 HLA 编译时函数的参数,如 @size 或 @typeName。
9.7 重复编译(编译时循环)
HLA 的 #while..#endwhile 和 #for..#endfor 语句提供了编译时循环结构。#while 语句告诉 HLA 在编译期间反复处理相同的语句序列。这对于构建数据表以及为编译时程序提供传统的循环结构非常有用。尽管你不会像使用 #if 语句那样频繁使用 #while 语句,但当你编写高级 HLA 程序时,这个编译时控制结构非常重要。
#while 语句的语法如下:
#while( *`constant_boolean_expression`* )
<< text >>
#endwhile
当 HLA 在编译时遇到 #while 语句时,它将评估常量布尔表达式。如果表达式的值为假,HLA 将跳过 #while 和 #endwhile 语句之间的文本(此行为类似于 #if 语句在表达式为假时的行为)。如果表达式的值为真,HLA 将处理 #while 和 #endwhile 语句之间的内容,然后“跳回”源文件中的 #while 语句开始处,并重复这一过程,如 图 9-3 所示。

图 9-3. HLA 编译时 #while 语句操作
为了理解这个过程是如何工作的,请参阅 示例 9-2 中的程序。
示例 9-2. #while..#endwhile 演示
program ctWhile;
#include( "stdlib.hhf" )
static
ary: uns32[5] := [ 2, 3, 5, 8, 13 ];
begin ctWhile;
?i := 0;
#while( i < 5 )
stdout.put( "array[ ", i, " ] = ", ary[i*4], nl );
?i := i + 1;
#endwhile
end ctWhile;
正如你可能猜到的,来自该程序的输出如下:
array[ 0 ] = 2
array[ 1 ] = 3
array[ 2 ] = 4
array[ 3 ] = 5
array[ 4 ] = 13
不太明显的是,这个程序是如何生成输出的。请记住,#while..#endwhile 结构是一个编译时语言特性,而不是运行时控制结构。因此,之前的 #while 循环在 编译 时重复执行五次。在每次循环重复时,HLA 编译器都会处理 #while 和 #endwhile 语句之间的内容。因此,前面的程序实际上等同于 示例 9-3 中显示的代码。
示例 9-3. 与示例 9-2 中的代码等效的程序
program ctWhile;
#include( "stdlib.hhf" )
static
ary: uns32[5] := [ 2, 3, 5, 8, 13 ];
begin ctWhile;
stdout.put( "array[ ", 0, " ] = ", ary[0*4], nl );
stdout.put( "array[ ", 1, " ] = ", ary[1*4], nl );
stdout.put( "array[ ", 2, " ] = ", ary[2*4], nl );
stdout.put( "array[ ", 3, " ] = ", ary[3*4], nl );
stdout.put( "array[ ", 4, " ] = ", ary[4*4], nl );
end ctWhile;
如此示例所示,#while语句非常方便用于构建重复代码序列。这对于展开循环尤其宝贵。
HLA 提供了三种形式的#for..#endfor循环。这三种循环具有以下通用形式:
示例 9-4. HLA #for循环
#for( *`valObject`* := *`startExpr`* to *`endExpr`* )
.
.
#endfor
#for( *`valObject`* := *`startExpr`* downto *`endExpr`* )
.
.
.
#endfor
#for( *`valObject`* in *`composite_expr`* )
.
.
.
#endfor
正如其名称所示,valObject必须是你在val声明中定义的对象。
对于上述两种形式的#for循环,startExpr和endExpr组件可以是任何返回整数值的 HLA 常量表达式。这两种#for循环中的第一个语义上等同于以下#while代码:
?*`valObject`* := *`startExpr`*;
#while( *`valObject`* <= *`endExpr`* )
.
.
.
?*`valObject`* := *`valObject`* + 1;
#endwhile
这三种#for循环中的第二种语义上等同于#while循环:
?*`valObject`* := *`startExpr`*;
#while( *`valObject`* >= *`endExpr`* )
.
.
.
?*`valObject`* := *`valObject`* - 1;
#endwhile
这三种#for循环中的第三种(使用in关键字的循环)对于处理某些复合数据类型中的单个项特别有用。该循环会针对你为composite_expr指定的复合值中的每个元素、字段、字符等重复一次。这可以是数组、字符串、记录或字符集表达式。对于数组,该#for循环会针对数组的每个元素重复一次,并且在每次迭代时,循环控制变量包含当前元素的值。例如,以下编译时循环会显示值 1、10、100 和 1,000:
#for( i in [1, 10, 100, 1000])
#print( i )
#endfor
如果composite_expr常量是字符串常量,#for循环会针对字符串中的每个字符重复一次,并将循环控制变量的值设置为当前字符。如果composite_expr常量表达式是记录常量,则循环会针对记录的每个字段重复一次,并且在每次迭代时,循环控制变量将采用当前字段的类型和值。如果composite_expr表达式是字符集,则循环会针对集合中的每个字符重复一次,并将循环控制变量赋值为该字符。
#for循环实际上比#while循环更有用,因为你遇到的大多数编译时循环会重复固定次数(例如,处理固定数量的数组元素、宏参数等)。
9.8 宏(编译时过程)
宏是语言处理器在编译期间用其他文本替换的对象。宏是替换长而重复的文本序列为较短文本序列的极好工具。除了宏在传统作用下的功能(例如 C/C++中的#define),HLA 的宏还充当编译时语言过程或函数的等效体。因此,宏在 HLA 的编译时语言中非常重要——就像其他高级语言中的函数和过程一样重要。
虽然宏并不新鲜,但 HLA 对宏的实现远超大多数其他编程语言(无论是高级语言还是低级语言)的宏处理能力。以下各节将探讨 HLA 的宏处理功能以及宏与其他 HLA CTL 控制结构之间的关系。
9.8.1 标准宏
HLA 支持一个简单直接的宏功能,允许你以类似声明过程的方式定义宏。一个典型的简单宏声明形式如下:
#macro *`macroname`*;
<< Macro body >>
#endmacro
尽管宏和过程声明相似,但从这个例子中可以明显看出两者之间有几个直接的区别。首先,当然,宏声明使用保留字#macro而不是procedure。其次,你不会以begin *macroname*;语句开始宏的主体。最后,你会注意到宏以#endmacro语句结束,而不是end macroname;。以下代码是一个宏声明的具体示例:
#macro neg64;
neg( edx );
neg( eax );
sbb( 0, edx );
#endmacro
执行此宏的代码将计算 EDX:EAX 中的 64 位值的二补数(详见 8.1.7 扩展精度 neg 操作的描述)。
要执行与neg64相关的代码,只需在你想执行这些指令的地方指定宏的名称。例如:
mov( (type dword i64), eax );
mov( (type dword i64[4]), edx );
neg64;
请注意,你不需要像调用过程那样,在宏的名称后跟一对空括号(稍后这个原因会变得很清楚)。
除了neg64调用后没有括号外,^([122])这看起来就像一个过程调用。你可以使用以下过程声明来实现这个简单的宏:
procedure neg64p;
begin neg64p;
neg( edx );
neg( eax );
sbb( 0, edx );
end neg64p;
请注意,以下两条语句都会对 EDX:EAX 中的值取反:
neg64; neg64p();
这两者之间的区别(宏调用与过程调用)在于,宏会将其文本内联展开,而过程调用会发出一个调用,去调用文本中其它地方的相应过程。也就是说,HLA 会将neg64;调用直接替换为以下文本:
neg( edx );
neg( eax );
sbb( 0, edx );
另一方面,HLA 用单一的调用指令替代了过程调用neg64p();:
call neg64p;
假设你在程序中已经定义了neg64p过程。
你应该根据效率来决定使用宏还是过程调用。宏比过程调用稍微快一些,因为你不需要执行call和相应的ret指令。另一方面,使用宏可能会使你的程序变大,因为每次调用宏时,宏的正文文本会被展开。过程调用则跳转到过程正文的单一实例。因此,如果宏的正文很大,并且你在程序中多次调用该宏,它会使最终的可执行文件变得更大。此外,如果宏的正文执行的指令超过几个简单的指令,call/ret序列的开销对总体执行时间几乎没有影响,因此执行时间的节省几乎可以忽略不计。另一方面,如果过程的正文非常短(像上面提到的neg64示例),你会发现宏实现更快,并且不会显著增加程序的大小。一个好的经验法则是:
注意
使用宏处理短小且时间敏感的程序单元。使用过程处理更长的代码块,且在执行时间不那么关键时使用过程。
宏相对于过程还有许多其他缺点。宏不能有局部(自动)变量,宏参数与过程参数的工作方式不同,宏不支持(运行时)递归,而且宏比过程更难调试(仅举几项缺点)。因此,除非在性能至关重要的情况下,否则你不应将宏作为过程的替代品。
9.8.2 宏参数
与过程类似,宏允许你定义参数,使你可以在每次调用宏时提供不同的数据。这使得你可以编写通用的宏,宏的行为可以根据你提供的参数而有所不同。通过在编译时处理这些宏参数,你可以编写非常复杂的宏。
宏参数声明语法非常简单。在宏声明中,你只需在括号内提供参数名称的列表:
#macro neg64( reg32HO, reg32LO );
neg( reg32HO );
neg( reg32LO );
sbb( 0, reg32HO );
#endmacro;
请注意,宏参数不像过程参数那样与数据类型相关联。这是因为 HLA 宏通常是text类型对象。
当你调用宏时,你只需像调用过程一样提供实际的参数:
neg64( edx, eax );
请注意,要求参数的宏调用期望你将参数列表包含在括号内。
9.8.2.1 标准宏参数展开
正如前一节所解释的,HLA 会自动将text类型与宏参数关联。这意味着,在宏展开过程中,HLA 会在每次出现正式参数名称的地方,替换为你提供的实际参数。所谓的“通过文本替换传递”与“按值传递”或“按引用传递”的语义有所不同,因此在此探讨这些差异是有价值的。
考虑以下宏调用,使用上一节中的 neg64 宏:
neg64( edx, eax );
neg64( ebx, ecx );
这两个调用会扩展为以下代码:
// neg64(edx, eax );
neg( edx );
neg( eax );
sbb( 0, edx );
// neg64( ebx, ecx );
neg( ebx );
neg( ecx );
sbb( 0, ebx );
请注意,宏调用并不会创建参数的局部副本(就像“按值传递”那样),也不会将实际参数的地址传递给宏。相反,neg64( edx, eax ); 这种形式的宏调用相当于以下内容:
?reg32HO: text := "edx";
?reg32LO: text := "eax";
neg( reg32HO );
neg( reg32LO );
sbb( 0, reg32HO );
当然,文本对象会立即展开其字符串值,在线扩展 neg64( edx, eax ); 的前一个扩展。
请注意,宏参数不限于内存、寄存器或常量操作数,就像指令或过程操作数一样。只要其扩展在使用正式参数的地方是合法的,任何文本都可以。类似地,正式参数可以出现在宏体中的任何位置,而不仅仅是在内存、寄存器或常量操作数合法的地方。考虑以下宏声明和示例调用:
#macro chkError( instr, jump, target );
instr;
jump target;
#endmacro;
chkError( cmp( eax, 0 ), jnl, RangeError ); // Example 1
...
chkError( test( 1, bl ), jnz, ParityError ); // Example 2
// Example 1 expands to
cmp( eax, 0 );
jnl RangeError;
// Example 2 expands to
test( 1, bl );
jnz ParityError;
一般来说,HLA 假定所有逗号之间的文本构成一个单一的宏参数。如果 HLA 遇到任何左括号、左大括号或左中括号符号,它会包括所有文本,直到遇到相应的闭合符号,忽略括号符号内可能出现的任何逗号。这就是为什么上面的 chkError 调用将 cmp( eax, 0 ) 和 test( 1, bl ) 视为单个参数,而不是一对参数。当然,HLA 不会将字符串常量中的逗号(和括号符号)视为实际参数的结束。所以以下宏和调用是完全合法的:
#macro print( strToPrint );
stdout.out( strToPrint );
#endmacro;
.
.
.
print( "Hello, world!" );
HLA 将字符串 Hello, world! 视为单个参数,因为逗号出现在一个字面字符串常量内,就像你的直觉所建议的那样。
如果你不熟悉其他语言中的文本宏参数扩展,应该注意到,当 HLA 扩展你的实际宏参数时,可能会遇到一些问题。考虑以下宏声明和调用:
#macro Echo2nTimes( n, theStr );
#for( echoCnt := 1 to n*2 )
#print( theStr )
#endfor
#endmacro;
.
.
.
Echo2nTimes( 3+1, "Hello" );
这个例子在编译时会显示 Hello 五次,而不是你直觉上可能期待的八次。这是因为上面的 #for 语句扩展为:
#for( echoCnt := 1 to 3+1*2 )
n 的实际参数是 3+1;因为 HLA 会将此文本直接替换为 n,所以你会得到一个错误的文本扩展。当然,在编译时,HLA 会将 3+1*2 计算为值 5,而不是值 8(如果 HLA 是通过值传递而不是文本替换传递这个参数,你将得到值 8)。
传递可能包含编译时表达式的数值参数时,解决此问题的常见方法是将宏中的正式参数括起来;例如,你可以将上面的宏重写如下:
#macro Echo2nTimes( n, theStr );
#for( echoCnt := 1 to (n)*2 )
#print( theStr )
#endfor
#endmacro;
之前的调用将扩展为以下代码:
#for( echoCnt := 1 to (3+1)*2 )
#print( theStr )
#endfor
这个版本的宏会产生直观的结果。
如果实际参数的数量与形式参数的数量不匹配,HLA 会在编译期间生成诊断消息。与过程一样,实际参数的数量必须与形式参数的数量一致。如果你想要有可选的宏参数,请继续阅读。
9.8.2.2 参数个数可变的宏
你可能已经注意到,一些 HLA 宏不需要固定数量的参数。例如,HLA 标准库中的 stdout.put 宏允许一个或多个实际参数。HLA 使用一种特殊的数组语法来告诉编译器,你希望在宏参数列表中允许一个可变数量的参数。如果你在形式参数列表中的最后一个宏参数后加上 [ ],那么 HLA 将允许用零个或多个实际参数代替该形式参数。例如:
#macro varParms( varying[] );
<< Macro body >>
#endmacro;
.
.
.
varParms( 1 );
varParms( 1, 2 );
varParms( 1, 2, 3 );
varParms();
特别注意最后一次调用。如果一个宏有任何形式参数,在宏调用后,你必须为宏列表提供圆括号。即使你给一个具有变化参数列表的宏提供零个实际参数,这也是成立的。请记住,没有参数的宏与具有变化参数列表但没有实际参数的宏之间的这个重要区别。
当 HLA 遇到一个带有 [ ] 后缀的形式宏参数时(该参数必须是形式参数列表中的最后一个参数),HLA 会创建一个常量字符串数组,并用宏调用中剩余实际参数关联的文本初始化该数组。你可以使用 @elements 编译时函数来确定分配给该数组的实际参数数量。例如,@elements( varying ) 将返回一个值,0 或更大,指定与该参数关联的总参数数量。以下对 varParms 的声明演示了如何使用这一点:
#macro varParms( varying[] );
#for( vpCnt := 0 to @elements( varying ) - 1 )
#print( varying[ vpCnt ] )
#endfor
#endmacro;
.
.
.
varParms( 1 ); // Prints "1" during compilation.
varParms( 1, 2 ); // Prints "1" and "2" on separate lines.
varParms( 1, 2, 3 ); // Prints "1", "2", and "3" on separate lines.
varParms(); // Doesn't print anything.
由于 HLA 不允许 text 对象的数组,变化参数必须是一个字符串数组。不幸的是,这意味着你必须将变化参数与标准宏参数区分开来。如果你希望变化的字符串数组中的某个元素在宏体内展开为文本,你可以始终使用 @text 函数来实现这一点。相反,如果你希望使用一个非变化的形式参数作为字符串对象,你可以始终使用 @string (name) 函数。以下示例演示了这一点:
#macro ReqAndOpt( Required, optional[] );
?@text( optional[0] ) := @string( ReqAndOpt );
#print( @text( optional[0] ))
#endmacro;
.
.
.
ReqAndOpt( i, j );
// The macro invocation above expands to
?@text( "j" ) := @string( i );
#print( "j" )
// The above further expands to
j := "i";
#print( j )
// The above simply prints "i" during compilation.
当然,在像上面这样的宏中,最好先验证是否至少有两个参数,然后再尝试引用 optional 参数的零元素。你可以如下简单实现这一点:
#macro ReqAndOpt( Required, optional[] );
#if( @elements( optional ) > 0 )
?@text( optional[0] ) := @string( ReqAndOpt );
#print( @text( optional[0] ))
#else
#error( "ReqAndOpt must have at least two parameters" )
#endif
#endmacro;
9.8.2.3 必需与可选宏参数
如前一节所述,HLA 要求每个非变化的正式宏参数必须有一个实际参数。如果没有变化的宏参数(最多只能有一个),那么实际参数的数量必须完全匹配正式参数的数量。如果存在变化的正式参数,那么必须至少有与非变化(或必需)正式宏参数一样多的实际宏参数。如果有一个单一的变化实际参数,那么宏调用可以有零个或多个实际参数。
调用一个没有参数的宏和调用一个带有单个、变化参数且没有实际参数的宏之间有一个很大的区别:带有变化参数列表的宏后面必须有一对空的括号,而调用没有任何参数的宏时不允许这样做。如果你希望编写一个没有任何参数的宏,但又想让宏调用后跟着 ( ),以使其与没有参数的过程调用语法匹配,你可以利用这个事实。考虑以下宏:
#macro neg64( JustForTheParens[] );
#if( @elements( JustForTheParens ) = 0 )
neg( edx );
neg( eax );
sbb( 0, edx );
#else
#error( "Unexpected operand(s)" )
#endif
#endmacro;
前面的宏要求调用形式为neg64();,以使用与过程调用相同的语法。如果你希望无参数宏调用的语法与无参数过程调用的语法匹配,这个特性非常有用。如果将来某个时候你需要将宏转换为过程(或者反过来),这么做也不失为一个好主意。
9.8.3 宏中的局部符号
考虑以下宏声明:
macro JZC( target );
jnz NotTarget;
jc target;
NotTarget:
endmacro;
这个宏的目的是模拟一个指令,当零标志被设置and进位标志被设置时,跳转到指定的目标位置。相反,如果零标志或进位标志之一被清除,则此宏将控制转移到宏调用后面的指令。
这个宏有一个严重的问题。考虑一下如果在程序中多次使用这个宏会发生什么:
JZC( *`Dest1`* );
.
.
.
JZC( *`Dest2`* );
.
.
.
前面的宏调用展开为以下代码:
jnz NotTarget;
jc *`Dest1`*;
NotTarget:
.
.
.
jnz NotTarget;
jc *`Dest2`*;
NotTarget:
.
.
.
这两个宏调用展开的问题是它们在宏展开时都会发出相同的标签NotTarget。当 HLA 处理这段代码时,它会抱怨重复的符号定义。因此,在宏内部定义符号时必须小心,因为该宏的多次调用可能会导致该符号的多重定义。
HLA 解决这个问题的方法是允许在宏内使用局部符号。局部宏符号是特定宏调用的唯一标识符。例如,如果NotTarget在前面的JZC宏调用中是一个局部符号,程序会正常编译,因为 HLA 将每个NotTarget实例视为一个唯一的符号。
HLA 并不会自动使内部宏符号定义仅限于该宏^([123])。相反,你必须显式地告诉 HLA 哪些符号必须是局部的。你可以在宏声明中使用以下通用语法来实现这一点:
#macro *`macroname`*( *`optional_parameters`* ):*`optional_list_of_local_names`* ;
<< Macro body >>
#endmacro;
局部名称列表是由一个或多个 HLA 标识符组成的序列,这些标识符通过逗号分隔。每当 HLA 在特定的宏调用中遇到这个名称时,它会自动为该标识符替换为一个唯一的名称。对于每个宏调用,HLA 会为局部符号替换为一个不同的名称。
你可以通过以下宏代码修正 JZC 宏的问题:
#macro JZC( target ):NotTarget;
jnz NotTarget;
jc target;
NotTarget:
#endmacro;
现在,每当 HLA 处理这个宏时,它将自动为每个 NotTarget 的出现关联一个唯一符号。这将防止如果你没有将 NotTarget 声明为局部符号时发生重复符号错误。
HLA 通过在宏调用中出现局部符号的地方替换为类似 _nnnn_(其中 nnnn 是一个四位十六进制数字)的符号来实现局部符号。例如,形如 JZC( SomeLabel ); 的宏调用可能展开为:
jnz _010A_;
jc *`SomeLabel`*;
_010A_:
对于每个在宏扩展中出现的局部符号,HLA 将通过简单地为每个新局部符号递增数字值来生成一个唯一的临时标识符。只要你不显式地创建形如 _nnnn_Text_(其中 nnnn 是一个十六进制值)的标签,就不会在你的程序中发生冲突。HLA 明确保留所有以单个下划线开头和结尾的符号供其私用(并供 HLA 标准库使用)。只要你遵守这个限制,HLA 局部符号生成和你自己程序中的标签之间就不会发生冲突,因为所有 HLA 生成的符号都以单个下划线开头和结尾。
HLA 通过有效地将局部符号转换为文本常量来实现局部符号,这个文本常量扩展为 HLA 为局部标签生成的唯一符号。也就是说,HLA 实际上将局部符号声明当作以下示例所示的那样处理:
#macro JZC( target );
?NotTarget:text := "_010A_*`Text`*_";
jnz NotTarget;
jc target;
NotTarget:
#endmacro;
每当 HLA 扩展这个宏时,它将用 _010A_Text_ 来替换扩展过程中遇到的每个 NotTarget。这个类比并不完美,因为在这个例子中,文本符号 NotTarget 在宏扩展后仍然可访问,而在宏内定义局部符号时则不是这种情况。但这给你一个关于 HLA 如何实现局部符号的概念。
9.8.4 宏作为编译时过程
尽管程序员通常使用宏来扩展为一系列机器指令,但宏体中绝对没有要求必须包含任何可执行指令。实际上,许多宏仅包含编译时语言语句(例如,#if、#while、#for、?赋值语句等)。通过仅在宏体中放置编译时语言语句,您可以有效地使用宏编写编译时的过程和函数。
以下unique宏是一个很好的示例,它是一个返回字符串结果的编译时函数。考虑以下宏的定义:
#macro unique:theSym;
@string(theSym)
#endmacro;
每当您的代码引用此宏时,HLA 会将宏调用替换为文本@string(theSym),这当然会展开为类似于_021F_Text_这样的字符串。因此,您可以将这个宏视为一个返回字符串结果的编译时函数。
注意不要把函数类比推得太远。请记住,宏总是在调用点展开为其宏体文本。某些扩展可能在程序的任何任意位置都是不合法的。幸运的是,大多数编译时语句在程序中任何合法的空白位置都是合法的。因此,宏的行为与您在编译时程序执行过程中对函数或过程的预期行为一致。
当然,过程和函数之间的唯一区别是,函数返回某个显式值,而过程仅执行某些活动。对于编译时函数的返回值,没有特殊的语法来指定。如上面的示例所示,只需将希望返回的值作为宏体中的一条语句指定即可。另一方面,编译时过程将不包含任何非编译时语言语句,这些语句在宏调用时会展开成某种数据。
9.8.5 使用宏模拟函数重载
C++语言支持一个巧妙的特性,称为函数重载。函数重载允许您编写多个具有相同名称的不同函数或过程。这些函数的区别在于其参数的类型或参数的数量。如果一个过程声明与其他同名函数的参数数量不同,或者其参数类型与其他同名函数不同,那么它就是 C++中的独特声明。HLA 并不直接支持过程重载,但您可以使用宏来实现相同的结果。本节将解释如何使用 HLA 的宏和编译时语言来实现函数/过程重载。
程序重载的一个好处是可以减少你需要记住的标准库例程的数量。例如,HLA 标准库提供了五个不同的“puti”例程来输出整数值:stdout.puti128、stdout.puti64、stdout.puti32、stdout.puti16 和 stdout.puti8。这些不同的例程,正如它们的名称所示,根据整数参数的大小输出整数值。在 C++语言(或其他支持过程/函数重载的语言)中,设计输入例程的工程师可能会选择将它们都命名为stdout.puti,并让编译器根据操作数的大小选择合适的例程。^([124]) 在示例 9-5 中,宏演示了如何在 HLA 中使用编译时语言来确定参数操作数的大小。
示例 9-5. 基于操作数大小的简单过程重载
// Puti.hla
//
// This program demonstrates procedure overloading via macros.
//
// It defines a "puti" macro that calls stdout.puti8, stdout.puti16,
// stdout.puti32, or stdout.puti64, depending on the size of
// the operand.
program putiDemo;
#include( "stdlib.hhf" )
// puti-
//
// Automatically decides whether we have a 64-, 32-, 16-, or 8-bit
// operand and calls the appropriate stdout.putiX routine to
// output this value.
#macro puti( operand );
// If we have an 8-byte operand, call puti64:
#if( @size( operand ) = 8 )
stdout.puti64( operand );
// If we have a 5-byte operand, call puti32:
#elseif( @size( operand ) = 4 )
stdout.puti32( operand );
// If we have a 2-byte operand, call puti16:
#elseif( @size( operand ) = 2 )
stdout.puti16( operand );
// If we have a 1-byte operand, call puti8:
#elseif( @size( operand ) = 1 )
stdout.puti8( operand );
// If it's not an 8-, 4-, 2-, or 1-byte operand,
// then print an error message:
#else
#error( "Expected a 64-, 32-, 16-, or 8-bit operand" )
#endif
#endmacro;
// Some sample variable declarations so we can test the macro above:
static
i8: int8 := −8;
i16: int16 := −16;
i32: int32 := −32;
i64: qword;
begin putiDemo;
// Initialize i64 because we can't do this in the static section.
mov( −64, (type dword i64 ));
mov( $FFFF_FFFF, (type dword i64[4]));
// Demo the puti macro:
puti( i8 ); stdout.newln();
puti( i16 ); stdout.newln();
puti( i32 ); stdout.newln();
puti( i64 ); stdout.newln();
end putiDemo;
上面的例子只是通过测试操作数的大小来确定使用哪个输出例程。你还可以使用其他 HLA 编译时函数,如@typename,进行更复杂的处理。考虑示例 9-6 中的程序,该程序演示了一个宏,它根据操作数的类型重载stdout.puti32、stdout.putu32和stdout.putd。
示例 9-6. 基于操作数类型的过程重载
// put32.hla
//
// This program demonstrates procedure overloading via macros.
//
// It defines a put32 macro that calls stdout.puti32, stdout.putu32,
// or stdout.putdw depending on the type of the operand.
program put32Demo;
#include( "stdlib.hhf" )
// put32-
//
// Automatically decides whether we have an int32, uns32, or dword
// operand and calls the appropriate stdout.putX routine to
// output this value.
#macro put32( operand );
// If we have an int32 operand, call puti32:
#if( @typename( operand ) = "int32" )
stdout.puti32( operand );
// If we have an uns32 operand, call putu32:
#elseif( @typename( operand ) = "uns32" )
stdout.putu32( operand );
// If we have a dword operand, call puth32:
#elseif( @typename( operand ) = "dword" )
stdout.puth32( operand );
// If it's not a 32-bit integer value, report an error:
#else
#error( "Expected an int32, uns32, or dword operand" )
#endif
#endmacro;
// Some sample variable declarations so we can test the macro above:
static
i32: int32 := −32;
u32: uns32 := 32;
d32: dword := $32;
begin put32Demo;
// Demo the put32 macro:
put32( d32 ); stdout.newln();
put32( u32 ); stdout.newln();
put32( i32 ); stdout.newln();
end put32Demo;
你可以轻松扩展这个宏,以输出 8 位和 16 位的操作数以及 32 位的操作数。这个作为练习留给读者。
实际参数的数量是解决调用哪个重载过程的另一种方式。如果你指定了一个可变数量的宏参数(使用[ ]语法;详见 9.8.2.2 可变参数宏),你可以使用@elements编译时函数来确定到底有多少个参数,并调用适当的例程。示例 9-7 中的示例使用了这个技巧来确定是否应该调用stdout.puti32或stdout.puti32Size。
示例 9-7. 使用参数数量解决重载过程
// puti32.hla
//
// This program demonstrates procedure overloading via macros.
//
// It defines a puti32 macro that calls
// stdout.puti32 or stdout.puti32size
// depending on the number of parameters present.
program puti32Demo;
#include( "stdlib.hhf" )
// puti32-
//
// Automatically decides whether we have an int32, uns32, or dword
// operand and calls the appropriate stdout.putX routine to
// output this value.
#macro puti32( operand[] );
// If we have a single operand, call stdout.puti32:
#if( @elements( operand ) = 1 )
stdout.puti32( @text(operand[0]) );
// If we have two operands, call stdout.puti32size and
// supply a default value of ' ' for the padding character:
#elseif( @elements( operand ) = 2 )
stdout.puti32Size
(
@text(operand[0]),
@text(operand[1]),
' '
);
// If we have three parameters, then pass all three of them
// along to puti32size:
#elseif( @elements( operand ) = 3 )
stdout.puti32Size
(
@text(operand[0]),
@text(operand[1]),
@text(operand[2])
);
// If we don't have one, two, or three operands, report an error:
#else
#error( "Expected one, two, or three operands" )
#endif
#endmacro;
// A sample variable declaration so we can test the macro above:
Static
i32: int32 := −32;
begin puti32Demo;
// Demo the put32 macro:
puti32( i32 ); stdout.newln();
puti32( i32, 5 ); stdout.newln();
puti32( i32, 5, '*' ); stdout.newln();
end puti32Demo;
到目前为止的所有示例都提供了标准库例程的过程重载(特别是整数输出例程)。当然,你并不限于在 HLA 标准库中进行过程重载,你也可以创建自己的重载过程。你需要做的就是编写一组具有唯一名称的过程,然后使用一个宏来根据宏的参数决定实际调用哪个例程。与其调用各个例程,不如调用公共宏,让它决定实际调用哪个过程。
^([122]) 为了区分宏和过程,本书将在描述宏的使用时使用调用(invocation)一词,而描述过程的使用时则使用调用(call)一词。
^([123]) 有时你实际上希望这些符号是全局的。
^([124]) 顺便说一句,HLA 标准库也这样做。尽管它没有提供 stdout.puti,但它提供了 stdout.put,该例程会根据参数的类型选择适当的输出例程。这比 puti 例程更灵活。
9.9 编写编译时“程序”
HLA 编译时语言提供了一个强大的功能,可以在 HLA 编译汇编语言程序时编写执行的“程序”。虽然使用 HLA 编译时语言编写一些通用程序是可能的,但 HLA 编译时语言的真正目的是允许你编写短小的程序,这些程序用于编写其他程序。特别地,HLA 编译时语言的主要目的是自动化生成大型或复杂的汇编语言序列。以下小节提供了一些简单的编译时程序示例。
9.9.1 在编译时构建数据表
本书之前建议你可以编写程序为你的汇编语言程序生成大型、复杂的查找表(见 8.4.3 生成表格的讨论)。第八章提供了 HLA 中的示例,但也建议编写一个独立程序并非必要。这是对的;你可以仅使用 HLA 编译时语言功能生成大多数所需的查找表。事实上,填充表格条目是 HLA 编译时语言的主要用途之一。本节将介绍如何在编译过程中使用 HLA 编译时语言构建数据表。
在 8.4.3 生成表格中,你看到过一个 HLA 程序的示例,该程序生成一个包含三角正弦函数查找表的文本文件。该表包含 360 个条目,表中的索引指定一个角度(以度为单位)。表中的每个int32条目包含值 sin(angle)1,000,其中angle*等于表中的索引。8.4.3 生成表格建议运行该程序,然后将程序生成的文本输出包含到实际使用该表的程序中。你可以通过使用编译时语言避免大部分这项工作。示例 9-8 中的 HLA 程序包含了一段简短的编译时代码,直接构造了这个正弦表。
示例 9-8。使用编译时语言生成正弦查找表
// demoSines.hla
//
// This program demonstrates how to create a lookup table
// of sine values using the HLA compile-time language.
program demoSines;
#include( "stdlib.hhf" )
const
pi :real80 := 3.1415926535897;
readonly
sines: int32[ 360 ] :=
[
// The following compile-time program generates
// 359 entries (out of 360). For each entry
// it computes the sine of the index into the
// table and multiplies this result by 1000
// in order to get a reasonable integer value.
?angle := 0;
#while( angle < 359 )
// Note: HLA's @sin function expects angles
// in radians. radians = degrees*pi/180.
// The int32 function truncates its result,
// so this function adds 1/2 as a weak attempt
// to round the value up.
int32( @sin( angle * pi / 180.0 ) * 1000 + 0.5 ),
?angle := angle + 1;
#endwhile
// Here's the 360th entry in the table. This code
// handles the last entry specially because a comma
// does not follow this entry in the table.
int32( @sin( 359 * pi / 180.0 ) * 1000 + 0.5 )
];
begin demoSines;
// Simple demo program that displays all the values in the table:
for( mov( 0, ebx); ebx<360; inc( ebx )) do
mov( sines[ ebx*4 ], eax );
stdout.put
(
"sin( ",
(type uns32 ebx ),
" )*1000 = ",
(type int32 eax ),
nl
);
endfor;
end demoSines;
编译时语言的另一个常见用途是为xlat指令在运行时生成 ASCII 字符查找表。常见的例子包括用于字母大小写转换的查找表。示例 9-9 中的程序展示了如何构造大写转换表和小写转换表。^([125]) 注意这里使用宏作为编译时过程来简化表生成代码的复杂度:
示例 9-9。使用编译时语言生成大小写转换表
// demoCase.hla
//
// This program demonstrates how to create a lookup table
// of alphabetic case conversion values using the HLA
// compile-time language.
program demoCase;
#include( "stdlib.hhf" )
const
// emitCharRange
//
// This macro emits a set of character entries
// for an array of characters. It emits a list
// of values (with a comma suffix on each value)
// from the starting value up to, but not including,
// the ending value.
#macro emitCharRange( start, last ): index;
?index:uns8 := start;
#while( index < last )
char( index ),
?index := index + 1;
#endwhile
#endmacro;
readonly
// toUC:
// The entries in this table contain the value of the index
// into the table except for indices #$61..#$7A (those entries
// whose indices are the ASCII codes for the lowercase
// characters). Those particular table entries contain the
// codes for the corresponding uppercase alphabetic characters.
// If you use an ASCII character as an index into this table and
// fetch the specified byte at that location, you will effectively
// translate lowercase characters to uppercase characters and
// leave all other characters unaffected.
toUC: char[ 256 ] :=
[
// The following compile-time program generates
// 255 entries (out of 256). For each entry
// it computes toupper( *`index`* ) where *`index`* is
// the character whose ASCII code is an index
// into the table.
emitCharRange( 0, uns8('a') )
// Okay, we've generated all the entries up to
// the start of the lowercase characters. Output
// uppercase characters in place of the lowercase
// characters here.
emitCharRange( uns8('A'), uns8('Z') + 1 )
// Okay, emit the nonalphabetic characters
// through to byte code #$FE:
emitCharRange( uns8('z') + 1, $FF )
// Here's the last entry in the table. This code
// handles the last entry specially because a comma
// does not follow this entry in the table.
#$FF
];
// The following table is very similar to the one above.
// You would use this one, however, to translate uppercase
// characters to lowercase while leaving everything else alone.
// See the comments in the previous table for more details.
TOlc: char[ 256 ] :=
[
emitCharRange( 0, uns8('A') )
emitCharRange( uns8('a'), uns8('z') + 1 )
emitCharRange( uns8('Z') + 1, $FF )
#$FF
];
begin demoCase;
for( mov( uns32( ' ' ), eax ); eax <= $FF; inc( eax )) do
mov( toUC[ eax ], bl );
mov( TOlc[ eax ], bh );
stdout.put
(
"toupper( '",
(type char al),
"' ) = '",
(type char bl),
"' tolower( '",
(type char al),
"' ) = '",
(type char bh),
"'",
nl
);
endfor;
end demoCase;
这个例子中需要注意的一点是,emitCharRange宏调用后并没有跟随分号。宏调用不需要闭合的分号。通常情况下,往宏调用的末尾加一个分号是合法的,因为 HLA 通常对于代码中多余的分号是比较宽容的。然而,在这个例子中,多余的分号是非法的,因为它们会出现在TOlc和toUC表的相邻条目之间。请记住,宏调用不需要分号,尤其是在将宏调用用作编译时过程时。
9.9.2 展开循环
在低级控制结构章节中,本文指出你可以通过展开循环来提高某些汇编语言程序的性能。展开循环的问题之一是,你可能需要做很多额外的输入,尤其是当循环迭代次数很多时。幸运的是,HLA 的编译时语言功能,特别是#while和#for循环,能够提供帮助。只需稍加输入并加上一份循环体,你就可以根据需要展开循环多次。
如果你只是想将相同的代码序列重复执行若干次,展开代码尤其简单。你只需将一个 HLA #for..#endfor 循环包裹在序列周围,并计数 val 对象指定的次数。例如,如果你想打印Hello World 10 次,可以按如下方式编码:
#for( count := 1 to 10 )
stdout.put( "Hello World", nl );
#endfor
尽管上面的代码看起来和你在程序中编写的 HLA for 循环非常相似,但请记住其根本区别:前面的代码只是由 10 个直接的 stdout.put 调用组成。如果你用 HLA for 循环来编码,这里只会有一个 stdout.put 调用,而会有很多额外的逻辑来循环回去并执行该调用 10 次。
如果循环中的任何指令引用了循环控制变量的值或其他随着每次迭代而变化的值,展开循环将变得稍微复杂一些。一个典型的例子是将整数数组元素置零的循环:
mov( 0, eax );
for( mov( 0, ebx ); ebx < 20; inc( ebx )) do
mov( eax, array[ ebx*4 ] );
endfor;
在这个代码片段中,循环使用了循环控制变量(在 EBX 中)的值来索引数组。简单地将 mov( eax, array[ ebx*4 ]); 复制 20 次并不是展开这个循环的正确方式。你必须将一个合适的常量索引(范围为 0..76,即对应的循环索引,乘以 4)代替示例中的 ebx*4。正确展开该循环应该生成以下代码序列:
mov( eax, array[ 0*4 ] );
mov( eax, array[ 1*4 ] );
mov( eax, array[ 2*4 ] );
mov( eax, array[ 3*4 ] );
mov( eax, array[ 4*4 ] );
mov( eax, array[ 5*4 ] );
mov( eax, array[ 6*4 ] );
mov( eax, array[ 7*4 ] );
mov( eax, array[ 8*4 ] );
mov( eax, array[ 9*4 ] );
mov( eax, array[ 10*4 ] );
mov( eax, array[ 11*4 ] );
mov( eax, array[ 12*4 ] );
mov( eax, array[ 13*4 ] );
mov( eax, array[ 14*4 ] );
mov( eax, array[ 15*4 ] );
mov( eax, array[ 16*4 ] );
mov( eax, array[ 17*4 ] );
mov( eax, array[ 18*4 ] );
mov( eax, array[ 19*4 ] );
你可以使用以下编译时代码序列轻松实现这一点:
#for( iteration := 0 to 19 )
mov( eax, array[ iteration*4 ] );
#endfor
如果循环中的语句利用了循环控制变量的值,那么只有在这些值在编译时已知的情况下,才有可能展开这样的循环。当用户输入(或其他运行时信息)控制循环迭代次数时,无法展开循环。
^([125]) 请注意,在现代处理器上,使用查找表可能不是将字母大小写转换的最有效方式。不过,这只是一个使用编译时语言填充表格的示例。即使代码不是最优的,原理依然是正确的。
9.10 在不同源文件中使用宏
与过程不同,宏在内存中的某个地址没有固定的代码片段。因此,你不能创建外部宏并将其与程序中的其他模块链接。然而,分享宏与不同源文件之间非常容易:只需将你希望重用的宏放在一个头文件中,并使用 #include 指令包含该文件。你可以通过这个简单的方法将宏提供给你选择的任何源文件。
9.11 更多信息
尽管本章花费了相当多的时间描述 HLA 的宏支持和编译时语言特性,事实上,本章几乎没有描述 HLA 的所有可能性。实际上,本章声称 HLA 的宏功能比其他汇编器提供的要强大得多;然而,本章并没有充分展现 HLA 的宏能力。如果你曾经使用过具有良好宏功能的语言,你可能会想:“这有什么大不了的?”其实,真正复杂的内容超出了本章的范围。如果你有兴趣了解更多 HLA 强大的宏功能,请查阅 HLA 参考手册以及《汇编语言的艺术》的电子版,网址是webster.cs.ucr.edu/或www.artofasm.com/。你会发现,实际上可以使用 HLA 的宏功能创建你自己的高级语言。然而,本章并没有假设读者具备进行这类编程的先决知识(至少目前还没有!),因此本章将这部分内容留待在网站上找到的相关资料中讨论。
第十章 位操作

操作内存中的位或许是汇编语言最著名的特性之一。确实,人们宣称 C 语言是一种中级语言而不是高级语言的原因之一,是因为 C 语言提供了大量的位操作符。即便有了这如此广泛的位操作功能,C 语言提供的位操作集合仍不如汇编语言完整。
本章讨论如何使用 80x86 汇编语言操作内存和寄存器中的位字符串。它从回顾到目前为止涉及的位操作指令开始,并介绍了一些新的指令。本章回顾了在内存中打包和解包位字符串的信息,因为这是许多位操作的基础。最后,本章讨论了几个以位为中心的算法及其在汇编语言中的实现。
10.1 什么是位数据?
在描述如何操作位之前,最好先明确一下本书中位数据的具体含义。大多数读者可能会认为位操作程序是修改内存中的单个位。虽然这样的程序无疑是位操作程序,但我们不会将定义仅限于这些程序。就我们的目的而言,位操作是指处理由非连续位字符串或长度不是 8 位的倍数的位数据类型。通常,这样的位对象不会表示数值整数,尽管我们并不会对我们的位字符串做出这一限制。
位字符串是一个连续的由一个或多个比特组成的序列。请注意,位字符串不必从任何特定位置开始或结束。例如,位字符串可以从内存中一个字节的第 7 个位开始,继续到下一个字节的第 6 个位。同样,位字符串可以从 EAX 的第 30 个位开始,消耗 EAX 的上 2 个位,然后从 EBX 的第 0 个位到第 17 个位继续。在内存中,位必须是物理连续的(即,位编号总是递增,除非跨越字节边界,而在字节边界时,内存地址增加 1 字节)。在寄存器中,如果位字符串跨越了寄存器边界,应用程序定义继续的寄存器,但位字符串总是从第二个寄存器的第 0 个位开始继续。
位集合是一个位的集合,这些位不一定是连续的,位于某个较大的数据结构内。例如,某个双字中的位 0..3、7、12、24 和 31 构成一个位集合。通常,我们会将位集合限制为某个合理大小的容器对象(封装位集合的数据结构),但该定义并没有特别限制其大小。通常,我们会处理大小不超过 32 或 64 位的位集合,尽管这个限制是完全人为设定的。请注意,位串是位集合的特例。
位串是一个所有位值相同的位序列。零串是一个只包含零的位串,一串是一个只包含一的位串。第一个置位是指在位串中第一个包含 1 的位的位置,也就是可能在零串后的第一个 1 位。第一个清除位也有类似的定义。最后一个置位是指在位串中最后一个包含 1 的位位置;位串的其余部分形成一个不间断的零串。最后一个清除位也有类似的定义。
位偏移是指从某个边界位置(通常是字节边界)到指定位的位数。如第二章所述,我们从边界位置的 0 开始编号每一位。
掩码是一个位的序列,我们将用它来操作另一个值中的某些位。例如,位串%0000_1111_0000,当与and指令一起使用时,可以屏蔽掉(清除)所有位,只留下位 4 到 7 的位。类似地,如果你使用相同的值与or指令一起使用,它可以将目标操作数中的位 4 到 7 强制为 1。术语掩码来源于这些位串与and指令的结合使用;在这种情况下,1 和 0 的位像遮蔽胶带一样使用;它们通过某些位而不改变这些位,同时遮蔽(清除)其他位。
了解了这些定义后,我们已经准备好开始操作一些位了!
10.2 操作位的指令
位操作通常包括六个活动:设置位、清除位、反转位、测试和比较位、从位串中提取位、以及将位插入位串。到目前为止,你应该已经熟悉了我们用来执行这些操作的大部分指令;这些指令的介绍可以追溯到本书最早的章节。然而,回顾一下旧的指令并介绍我们尚未考虑的几条位操作指令,仍然是值得的。
最基本的位操作指令是and、or、xor、not、test以及移位和旋转指令。事实上,在最早的 80x86 处理器上,这些是唯一可用于位操作的指令。以下段落回顾了这些指令,重点介绍了如何使用它们来操作内存或寄存器中的位。
and指令提供了从某些位序列中剥离不需要的位的功能,将不需要的位替换为零。此指令特别适用于隔离与其他无关数据(或者至少是与位字符串或位集无关的数据)合并的位字符串或位集。例如,假设一个位字符串占用了 EAX 寄存器的 12 到 24 位;我们可以通过使用以下指令将 EAX 中的所有其他位清零,从而隔离这个位字符串:
and( %1_1111_1111_1111_0000_0000_0000, eax );
大多数程序使用and指令来清除不属于所需位字符串的位。从理论上讲,你可以使用or指令将所有不需要的位掩码设置为 1 而不是 0,但如果不需要的位位置包含 0,那么后续的比较和操作通常会更容易(见图 10-1)。

图 10-1. 使用and指令隔离位字符串
一旦你清除了位集中不需要的位,你通常可以直接对位集进行操作。例如,要检查 EAX 中 12 到 24 位的位字符串是否包含$12F3,你可以使用以下代码:
and( %1_1111_1111_1111_0000_0000_0000, eax );
cmp( eax, %1_0010_1111_0011_0000_0000_0000 );
这是另一种解决方案,使用常量表达式,稍微容易理解一些:
and( %1_1111_1111_1111_0000_0000_0000, eax );
cmp( eax, $12F3 << 12 ); // "<<12" shifts $12F3 to the left 12 bits.
然而,大多数时候,你会希望(或者需要)在执行任何操作之前,将位字符串对齐到 EAX 的位 0。当然,在你掩码之后,你可以使用shr指令来正确对齐该值,如下所示:
and( %1_1111_1111_1111_0000_0000_0000, eax );
shr( 12, eax );
cmp( eax, $12F3 );
<< Other operations that require the bit string at bit #0 >>
现在,位字符串已经对齐到位 0,与该值一起使用的常量和其他值更容易处理。
你也可以使用or指令来掩码不需要的位。然而,or指令并不能让你清除位,它允许你将位设置为 1。在某些情况下,可能需要将位集周围的所有位设置为 1;然而,大多数软件更容易编写的是将周围的位清除,而不是设置它们。
or指令对于将一组位插入到另一个位字符串中特别有用。为了做到这一点,你需要经过几个步骤:
-
清除源操作数中围绕位集的所有位。
-
清除目标操作数中你希望插入位集的所有位。
-
将位集和目标操作数使用
or指令结合。
例如,假设你希望将 EAX 的位 0..12 中的值插入到 EBX 的位 12..24 中,而不影响 EBX 中的其他位。你将首先从 EAX 中去除位 13 及以上的位;然后从 EBX 中去除位 12..24 的位。接下来,你将位移 EAX 中的位,使得位串占据 EAX 的位 12..24。最后,你将 EAX 中的值通过or操作插入到 EBX 中(参见图 10-2),如下所示:
and( $1FFF, eax ); // Strip all but bits 0..12 from eax.
and( $FE00_0FFF, ebx ); // Clear bits 12..24 in ebx.
shl( 12, eax ); // Move bits 0..12 to 12..24 in eax.
or( eax, ebx ); // Merge the bits into ebx.

图 10-2. 将 EAX 的位 0..12 插入 EBX 的位 12..24
在这个图中,期望的位(AAAAAAAAAAAAA)形成了一个位串。然而,即使你正在操作一个非连续的位集合,这个算法仍然能够正常工作。你只需创建一个适当的位掩码,用于and操作,并在适当的位置填充 1 即可。
在使用位掩码时,像前面几个例子那样使用字面数值常量是非常不好的编程风格。你应该始终在 HLA 的const(或val)部分为你的位掩码创建符号常量。结合一些常量表达式,你可以编写出更易读和更易维护的代码。当前的示例代码更恰当的写法如下:
const
StartPosn := 12;
BitMask: dword := $1FFF << StartPosn; // Mask occupies bits 12..24.
.
.
.
shl( StartPosn, eax ); // Move into position.
and( BitMask, eax ); // Strip all but bits 12..24 from eax.
and( !BitMask, ebx ); // Clear bits 12..24 in ebx.
or( eax, ebx ); // Merge the bits into ebx.
注意使用编译时的not运算符(!)来反转位掩码,以便清除 EBX 中代码插入 EAX 中的位的位位置。这样就不需要在程序中创建另一个常量,并且每次修改BitMask常量时都需要更新它。程序中维护两个相互依赖的符号并不是一个好习惯。
当然,除了将一个位集合与另一个合并外,or指令也非常适用于强制将位设置为 1。在源操作数中将不同的位设置为 1 后,你可以通过使用or指令将目标操作数中的相应位强制设置为 1。
xor指令允许你反转位集合中的选定位。虽然反转位不像设置或清除位那么常见,但xor指令在位操作程序中经常出现。当然,如果你想反转某个目标操作数中的所有位,not指令可能比xor指令更合适;然而,要反转选定的位而不影响其他位时,xor是更合适的选择。
xor 操作的一个有趣事实是,它让你能够以几乎任何可想象的方式操作已知数据。例如,如果你知道某个字段包含 %1010,你可以通过与 %1010 进行 xor 操作将该字段强制为 0。同样,你也可以通过与 %0101 进行 xor 操作将其强制为 %1111。虽然这看起来可能是一种浪费,因为你可以很容易地通过 and/or 将这个 4 位字符串强制为 0 或全部为 1,但 xor 指令有两个优点:(1)你不仅仅局限于将字段强制为全零或全一;你实际上可以通过 xor 将这些位设置为 16 种有效组合中的任何一种;(2)如果你需要同时操作目标操作数中的其他位,and/or 可能无法满足你的需求。例如,假设你知道一个字段包含 %1010,你想将其强制为 0,另一个字段包含 %1000,你希望将该字段加 1(即将字段设置为 %1001)。你无法通过单个 and 或 or 指令完成这两个操作,但你可以通过单个 xor 指令做到;只需将第一个字段与 %1010 进行 xor,将第二个字段与 %0001 进行 xor。然而,请记住,这个技巧仅在你知道目标操作数中某个位的当前值时才有效。当然,在你调整包含已知值的位字段时,你还可以同时翻转其他字段中的位。
除了在某些目标操作数中设置、清除和翻转位外,and、or 和 xor 指令还会影响标志寄存器中的各种条件码。这些指令会按以下方式影响标志:
-
这些指令始终会清除进位标志和溢出标志。
-
如果结果的最高有效位是 1,这些指令会设置符号标志;否则,它们会清除符号标志。也就是说,这些指令将结果的最高有效位复制到符号标志中。
-
如果结果为 0,这些指令会设置/清除零标志。
-
如果目标操作数的低字节中有偶数个已设置的位,这些指令会设置奇偶标志;如果目标操作数的低字节中有奇数个 1 位,这些指令会清除奇偶标志。
首先要注意的是,这些指令始终会清除进位标志和溢出标志。这意味着你不能期望系统在执行这些指令后保留这两个标志的状态。许多汇编语言程序中一个非常常见的错误是假设这些指令不会影响进位标志。很多人会执行一个设置/清除进位标志的指令,然后执行 and/or/xor 指令,再尝试测试之前指令的进位标志状态。这显然是行不通的。
这些指令的一个有趣的方面是,它们将结果的最高有效位(H.O.位)复制到符号标志中。这意味着你可以通过测试符号标志(使用sets/setns或js/jns指令,或者在布尔表达式中使用@s/@ns标志)轻松测试结果的 H.O.位的设置。因此,许多汇编语言程序员常常将一个重要的布尔变量放置在某个操作数的 H.O.位中,这样他们就可以在进行逻辑操作后,通过符号标志轻松测试该位的状态。
在这篇文章中,我们没有太多讨论奇偶标志。我们不会深入讨论这个标志及其用途,因为该标志的主要用途已经被硬件所接管。^([126]) 然而,因为这是关于位操作的章节,而奇偶计算是一个位操作,所以现在简要讨论奇偶标志似乎是合适的。
奇偶是一个非常简单的错误检测方案,最早由电报和其他串行通信协议使用。其原理是计算字符中设置位的数量,并在传输中添加一个额外的位,以指示该字符包含偶数或奇数个设置位。接收端也会计算位并验证额外的“奇偶”位是否表示传输成功。我们此时不会深入探讨这种错误检查方案的信息理论方面,只是指出奇偶标志的目的是帮助计算这个额外位的值。
80x86 的and、or和xor指令会在其操作数的最低有效字节(L.O.字节)中包含偶数个设置位时设置奇偶位。这里有一个重要的事实需要重申:奇偶标志仅反映目标操作数的 L.O.字节中设置位的数量;它不包括字、双字或其他大小操作数中的 H.O.字节。指令集仅使用 L.O.字节来计算奇偶性,因为使用奇偶性的通信程序通常是面向字符的传输系统(如果一次传输超过 8 位,通常有更好的错误检查方案)。
零标志的设置是and/or/xor指令产生的更重要的结果之一。实际上,程序在and指令之后如此频繁地引用这个标志,以至于英特尔添加了一个单独的指令test,其主要目的是对两个结果进行逻辑and操作并设置标志,而不会对任何指令操作数产生其他影响。
在执行 and 或 test 指令后,零标志有三种主要用途:(1)检查操作数中的某一特定位是否为 1,(2)检查位集中的多个位中是否有至少一个为 1,以及(3)检查操作数是否为 0。使用(1)实际上是(2)的特例,其中位集仅包含一个位。我们将在接下来的段落中探讨这些用途。
and 指令的一个常见用途,也是 80x86 指令集中包含 test 指令的初衷,是测试给定操作数中某一特定位是否被置为 1。要执行这种类型的测试,通常会将包含一个单独置位的常数值与你想要测试的操作数进行 and/test 操作。这样可以清除第二个操作数中的所有其他位,如果该操作数在该位置上是 0,则在 test 下的该位置会留 0。使用 1 进行 and 操作时,如果原来该位置是 1,则会留下 1。由于结果中的其他位都是 0,因此如果该特定位是 0,整个结果将为 0;如果该位置是 1,整个结果则为非零值。80x86 通过零标志(Z = 1 表示该位为 0;Z = 0 表示该位为 1)反映这一状态。以下指令序列演示了如何测试 EAX 寄存器的第 4 位是否被置位:
test( %1_0000, eax ); // Check bit #4 to see if it is 0/1.
if( @nz ) then
<< Do this if the bit is set. >>
else
<< Do this if the bit is clear. >>
endif;
你也可以使用 and/test 指令来检查多个位中是否有至少一个位被置为 1。只需提供一个常数,其中你想测试的位置上为 1,其余位置为 0。将此值与待测操作数进行 and 操作,如果待测操作数中的一个或多个位包含 1,则会产生非零值。以下示例测试 EAX 中第 1 位、第 2 位、第 4 位和第 7 位是否为 1:
test( %1001_0110, eax );
if( @nz ) then // At least one of the bits is set.
<< Do whatever needs to be done if one of the bits is set. >>
endif;
请注意,你不能仅使用单个 and 或 test 指令来检查位集中的所有相应位是否都为 1。要实现这一点,必须首先屏蔽掉不在位集中的位,然后将结果与掩码本身进行比较。如果结果等于掩码,则位集中的所有位都包含 1。必须使用 and 指令来执行此操作,因为 test 指令不会屏蔽任何位。以下示例检查位集(bitMask)中的所有位是否都为 1:
and( bitMask, eax );
cmp( eax, bitMask );
if( @e ) then
// All the bit positions in eax corresponding to the set
// bits in bitMask are equal to 1 if we get here.
<< Do whatever needs to be done if the bits match. >>
endif;
当然,一旦我们在其中使用了 cmp 指令,就不再需要检查位集中的所有位是否都为 1。我们可以通过将适当的值作为操作数传递给 cmp 指令,来检查任意组合的值。
请注意,test/and指令只有在 EAX(或其他目标操作数)中的所有位在常量操作数的相应位置为 1 时,才会设置零标志。这表明另一种检查位集中是否全为 1 的方法:在使用and或test指令之前,将 EAX 中的值取反。然后,如果零标志被设置,你就知道(原始的)位集中全为 1。例如:
not( eax );
test( bitMask, eax );
if( @z ) then
// At this point, eax contained all ones in the bit positions
// occupied by ones in the bitMask constant.
<< Do whatever needs to be done at this point. >>
endif;
之前的段落都表明bitMask(源操作数)是一个常量。这只是为了举例说明。事实上,如果你愿意,你可以在这里使用变量或其他寄存器。在执行上面例子中的test、and或cmp指令之前,简单地将该变量或寄存器加载上适当的位掩码。
我们已经看到的另一组可以用来操作位的指令是位测试指令。这些指令包括bt(位测试)、bts(位测试并设置)、btc(位测试并取反)和btr(位测试并重置)。我们曾用这些指令操作 HLA 字符集变量中的位;同样,我们也可以使用它们来操作一般的位。这些btx指令允许以下语法形式:
bt*`x`*( *`BitNumber`*, *`BitsToTest`* );
bt*`x`*( *`reg16`*, *`reg16`* );
bt*`x`*( *`reg32`*, *`reg32`* );
bt*`x`*( *`constant`*, *`reg16`* );
bt*`x`*( *`constant`*, *`reg32`* );
bt*`x`*( *`reg16`*, *`mem16`* );
bt*`x`*( *`reg32`*, *`mem32`* );
bt*`x`*( *`constant`*, *`mem16`* );
bt*`x`*( *`constant`*, *`mem32`* );
btx指令的第一个操作数是一个位号,指定要检查第二个操作数中的哪一位。如果第二个操作数是寄存器,那么第一个操作数必须包含一个介于 0 和寄存器大小(以位为单位)减 1 之间的值;因为 80x86 架构的最大寄存器是 32 位,所以该值的最大值为 31(对于 32 位寄存器)。如果第二个操作数是内存位置,那么位计数不限制在 0..31 的范围内。如果第一个操作数是常量,它可以是 0..255 范围内的任何 8 位值。如果第一个操作数是寄存器,则没有限制。
bt指令将指定的位从第二个操作数复制到进位标志中。例如,bt( 8, eax );指令将 EAX 寄存器的第 8 位复制到进位标志中。你可以在此指令之后测试进位标志,以确定 EAX 中的第 8 位是设置为 1 还是清零。
bts、btc和btr指令在测试位的同时,也会操作它们所测试的位。这些指令可能会较慢(取决于你使用的处理器),如果性能是你的主要关注点,并且你使用的是较老的 CPU,应该避免使用它们。如果性能(与便捷性相对)是一个问题,你应该始终尝试两种不同的算法——一种使用这些指令,另一种使用and/or指令——并测量它们的性能差异;然后选择最优的方案。
移位和旋转指令是另一类可以用于操作和测试位的指令。这些指令将高位(左移/旋转)或低位(右移/旋转)位移动到进位标志中。因此,在执行这些指令之后,你可以测试进位标志,以确定操作数的高位或低位的原始设置。移位和旋转指令对于对齐位串以及打包和解包数据非常有价值。第二章中有几个示例,且本章前面的部分也使用移位指令进行此类操作。
^([126]) 使用奇偶校验进行错误检查的串行通信芯片和其他通信硬件通常会在硬件中计算奇偶校验;你不需要使用软件来完成这个任务。
10.3 进位标志作为位累加器
btx、移位和旋转指令会根据操作和选定的位设置或清除进位标志。因为这些指令将其“位结果”放入进位标志中,所以通常可以将进位标志视为一个 1 位的寄存器或累加器来进行位操作。在本节中,我们将探讨一些使用进位标志中位结果的操作。
有用的指令是那些将进位标志作为输入值的指令。以下是这类指令的一些示例:
-
adc、sbb -
rcl,rcr -
cmc(虽然clc和stc不使用进位作为输入,我们也一并提到它们。) -
jc、jnc -
setc、setnc
adc和sbb指令会将操作数与进位标志一起加或减。因此,如果你已经计算出一些位结果并存入进位标志中,你可以使用这些指令将结果纳入加法或减法操作中。
要将位结果合并到进位标志中,通常使用旋转通过进位指令(rcl和rcr)。这些指令将进位标志移动到其目标操作数的低位(L.O.)或高位(H.O.)中。这些指令对于将一组位结果打包成字节、字或双字值非常有用。
cmc(补码进位)指令可以轻松地反转某些位操作的结果。你也可以使用clc和stc指令,在进行一系列涉及进位标志的位操作之前初始化进位标志。
用于测试进位标志的指令在计算后,如果进位标志中有位结果时,会非常常见。jc、jnc、setc和setnc指令在这种情况下非常有用。你也可以在布尔表达式中使用 HLA 的@c和@nc操作数来测试进位标志中的结果。
如果你有一系列比特计算,并希望测试这些计算是否产生特定的 1 比特结果,最简单的方法是清空一个寄存器或内存位置,并使用rcl或rcr指令将每个结果移入该位置。比特操作完成后,你可以将存储结果的寄存器或内存位置与常数值进行比较。如果你想测试涉及合取与析取(即涉及and和or的结果字符串)的结果序列,那么你可以使用setc和setnc指令将寄存器设置为 0 或 1,然后使用and/or指令合并结果。
10.4 打包与解包比特字符串
常见的比特操作是将比特字符串插入操作数或从操作数中提取比特字符串。第二章提供了打包和解包此类数据的简单示例;现在是时候正式描述如何执行此操作了。
对于我们的目的,我们将假设正在处理比特字符串——即一串连续的比特。在 10.11 提取比特字符串中,我们将探讨如何提取和插入比特集合。我们做的另一个简化假设是,比特字符串完全适配于一个字节、字或双字操作数。跨越对象边界的大型比特字符串需要额外的处理;关于跨双字边界的比特字符串的讨论将在本节后面出现。
比特字符串有两个我们在打包和解包时必须考虑的属性:起始比特位置和长度。起始比特位置是该字符串在较大操作数中最低有效比特的位置。长度是操作数中的比特数。为了将数据插入(打包)到目标操作数中,首先需要有一个适当长度的比特字符串,该字符串右对齐(即从比特位置 0 开始),并被零扩展至 8、16 或 32 位。任务是将这些数据插入到另一个宽度为 8、16 或 32 位的操作数中,从适当的起始位置开始。目标比特位置的值没有任何保证。
前两步(可以按任意顺序发生)是清除目标操作数中相应的比特,并将(比特字符串的副本)移位,使最低有效比特开始于适当的比特位置。第三步是将移位后的结果与目标操作数进行or操作。这样就将比特字符串插入到了目标操作数中(参见图 10-3)。

图 10-3. 将比特字符串插入目标操作数
只需要三条指令就能将已知长度的位串插入目标操作数。以下三条指令演示了如何在图 10-3 中处理插入操作。这些指令假设源操作数在 BX 中,目标操作数在 AX 中:
shl( 5, bx );
and( %111111000011111, ax );
or( bx, ax );
如果在编写程序时无法知道长度和起始位置(即你必须在运行时计算它们),那么位串插入就会稍微困难一些。然而,通过使用查找表,这仍然是一个可以轻松完成的操作。假设我们有两个 8 位值:用于插入字段的起始位置和一个非零的 8 位长度值。还假设源操作数在 EBX 中,目标操作数在 EAX 中。将一个操作数插入到另一个操作数中的代码可以采用以下形式:
readonly
// The index into the following table specifies the length
// of the bit string at each position:
MaskByLen: dword[ 33 ] :=
[
0, $1, $3, $7, $f, $1f, $3f, $7f,
$ff, $1ff, $3ff, $7ff, $fff, $1fff, $3fff, $7fff, $ffff,
$1_ffff, $3_ffff, $7_ffff, $f_ffff,
$1f_ffff, $3f_ffff, $7f_ffff, $ff_ffff,
$1ff_ffff, $3ff_ffff, $7ff_ffff, $fff_ffff,
$1fff_ffff, $3fff_ffff, $7fff_ffff, $ffff_ffff
];
.
.
.
movzx( *`Length`*, edx );
mov( MaskByLen[ edx*4 ], edx );
mov( *`StartingPosition`*, cl );
shl( cl, edx );
not( edx );
shl( cl, ebx );
and( edx, eax );
or( ebx, eax );
MaskByLen表中的每个条目包含由表的索引指定的 1 位的数量。使用Length值作为索引从该表中获取一个值,该值具有与Length值相同数量的 1 位。上面的代码获取了一个适当的掩码,将其向左移动,以便该组 1 位的最低有效位与我们想要插入数据的字段的起始位置对齐,然后反转掩码并使用反转后的值清除目标操作数中适当的位。
从一个较大的操作数中提取位串与将位串插入某个较大的操作数一样简单。你所需要做的就是屏蔽掉不需要的位,然后将结果移动,直到位串的最低有效位(L.O. bit)位于目标操作数的第 0 位。例如,要从 EBX 中提取从第 5 位开始的 4 位字段并将结果保存在 EAX 中,你可以使用以下代码:
mov( ebx, eax ); // Copy data to destination.
and( %1_1110_0000, eax ); // Strip unwanted bits.
shr( 5, eax ); // Right justify to bit position 0.
如果在编写程序时,你不知道位串的长度和起始位置,你仍然可以提取所需的位串。代码与插入操作非常相似(尽管稍微简单一些)。假设你拥有我们在插入位串时使用的Length和StartingPosition值,你可以使用以下代码提取相应的位串(假设源操作数为 EBX,目标操作数为 EAX):
movzx( *`Length`*, edx );
mov( MaskByLen[ edx*4 ], edx );
mov( *`StartingPosition`*, cl );
mov( ebx, eax );
shr( cl, eax );
and( edx, eax );
到目前为止的示例都假设比特串完全出现在一个双字(或更小)对象中。如果比特串的长度小于或等于 32 位,这种情况总是成立。然而,如果比特串的长度加上其在对象中起始位置(模 8 运算)大于 32,那么比特串就会跨越对象中的双字边界。提取这样的比特串需要最多三个操作:一个操作提取比特串的起始部分(直到第一个双字边界),一个操作复制整个双字(假设比特串很长,跨越多个双字),以及最后一个操作,复制位于比特串末尾的最后一个双字中的剩余比特。这个操作的具体实现留给读者作为练习。
10.5 合并比特集与分配比特串
插入和提取比特集与插入和提取比特串的不同之处在于,如果你插入的比特集(或提取出的结果比特集)与主对象中的比特集形状相同,那么操作就没什么不同。比特集的“形状”是指比特集内比特位的分布,而忽略比特集的起始比特位置。因此,包含比特位 0、4、5、6 和 7 的比特集,其形状与包含比特位 12、16、17、18 和 19 的比特集相同,因为这些比特的分布是一样的。插入或提取这个比特集的代码几乎与上一节相同,唯一的区别是你使用的掩码值。例如,要将这个以 EAX 寄存器的比特位 0 开始的比特集插入到 EBX 寄存器的比特位 12 的位置,你可以使用以下代码:
and( !%1111_0001_0000_0000_0000, ebx );// Mask out destination bits.
shl( 12, eax ); // Move source bits into position.
or( eax, ebx ); // Merge the bit set into ebx.
但是,假设你在 EAX 寄存器的比特位置 0 到 4 之间有 5 个位,且你想将它们合并到 EBX 寄存器中的比特位 12、16、17、18 和 19 上。你必须以某种方式在将值通过逻辑or操作合并到 EBX 之前,分配 EAX 中的比特位。考虑到这个特定的比特集只有两段 1 比特,过程会相对简化。以下代码以一种有点“狡猾”的方式实现了这一点:
and( !%1111_0001_0000_0000_0000, ebx );
shl( 3, eax ); // Spread out the bits: 1-4 goes to 4-7 and 0 to 3.
btr( 3, eax ); // Bit 3->carry and then clear bit 3.
rcl( 12, eax ); // Shift in carry and put bits into final position.
or( eax, ebx ); // Merge the bit set into ebx.
这个使用btr(位测试与重置)指令的技巧效果很好,因为我们在原始源操作数中只有 1 个比特位位置错误。可惜,如果比特位相对彼此的位置完全错乱,那么这个方案可能就不太有效了。我们稍后会看到一个更通用的解决方案。
提取这个比特集并将比特位“合并”成一个比特串并不容易。然而,我们依然可以利用一些巧妙的技巧。考虑以下代码,它从 EBX 中提取比特集,并将结果放入 EAX 的比特位 0 到 4 中:
mov( ebx, eax );
and( %1111_0001_0000_0000_0000, eax ); // Strip unwanted bits.
shr( 5, eax ); // Put bit 12 into bit 7, etc.
shr( 3, ah ); // Move bits 11..14 to 8..11.
shr( 7, eax ); // Move down to bit 0.
这段代码将(原始的)第 12 位移到第 7 位位置,即 AL 的高位。同时,它将第 16 到第 19 位移到第 11 到第 14 位(即 AH 的第 3 到第 6 位)。然后,代码将 AH 中第 3 到第 6 位向下移到第 0 位。这将高位的位集合放置在 AL 中剩余的位旁边。最后,代码将所有位向下移到第 0 位。这不是一种通用的解决方案,但它展示了如果仔细思考,这种问题可以用一种巧妙的方法来解决。
上面提到的合并和分配算法的问题在于它们不是通用的。它们仅适用于特定的位集合。通常,特定的解决方案会提供最有效的解决方案。一个通用的解决方案(也许是让你指定一个掩码,然后代码根据掩码分配或合并位)将会更加复杂。以下代码演示了如何根据位掩码中的值分配位:
// eax- Originally contains some value into which we
// insert bits from ebx.
// ebx- L.O. bits contain the values to insert into eax.
// edx- Bitmap with ones indicating the bit positions in eax to insert.
// cl- Scratchpad register.
mov( 32, cl ); // Count number of bits we rotate.
jmp DistLoop;
CopyToEAX:rcr( 1, ebx ); // Don't use SHR here, must preserve Z-flag.
rcr( 1, eax );
jz Done;
DistLoop: dec( cl );
shr( 1, edx );
jc CopyToEAX;
ror( 1, eax ); // Keep current bit in eax.
jnz DistLoop;
Done: ror( cl, eax ); // Reposition remaining bits.
在上面的代码中,如果我们用 %1100_1001 加载 EDX,那么这段代码将会把 EDX 中的第 0 到第 3 位复制到 EAX 中的第 0、3、6 和 7 位。注意到有一个短路测试,它检查 EDX 中的值是否已经用尽(通过检查 EDX 中是否有 0)。注意,旋转指令不会影响零标志位,而移位指令会。因此,上面的 shr 指令在没有更多位可以分配时(当 EDX 变为 0 时)将设置零标志。
合并位的通用算法比分配算法稍微高效一些。以下是将通过 EDX 中的位掩码从 EBX 中提取位,并将结果保留在 EAX 中的代码:
// eax- Destination register.
// ebx- Source register.
// edx- Bitmap with ones representing bits to copy to eax.
// ebx and edx are not preserved.
sub( eax, eax ); // Clear destination register.
jmp ShiftLoop;
ShiftInEAX:
rcl( 1, ebx ); // Up here we need to copy a bit from
rcl( 1, eax ); // ebx to eax.
ShiftLoop:
shl( 1, edx ); // Check mask to see if we need to copy a bit.
jc ShiftInEAX; // If carry set, go copy the bit.
rcl( 1, ebx ); // Current bit is uninteresting, skip it.
jnz ShiftLoop; // Repeat as long as there are bits in edx.
这个序列利用了移位和旋转指令的一个巧妙特性:移位指令会影响零标志,而旋转指令则不会。因此,shl( 1, edx ); 指令在 EDX 变为 0(移位后)时会设置零标志。如果进位标志也被设置,代码将会额外通过循环一次,将一个位移入 EAX,但下次代码将 EDX 向左移 1 位时,EDX 依然是 0,因此进位标志会被清除。在这次迭代中,代码会跳出循环。
另一种合并位的方法是通过查表。通过一次抓取一个字节的数据(这样你的表格不会变得太大),你可以使用该字节的值作为查找表的索引,将所有位合并到第 0 位。最后,你可以将每个字节低位的位合并在一起。在某些情况下,这可能会产生一个更高效的合并算法。具体实现留给读者。
10.6 位串的打包数组
尽管创建元素大小为整数字节数的数组要高效得多,但完全可以创建元素大小不是 8 位倍数的数组。缺点是,计算数组元素的“地址”并操作该数组元素需要额外的工作。在本节中,我们将看几个示例,展示如何在元素大小为任意位数的数组中打包和解包数组元素。
在继续之前,值得讨论一下为什么你会想要使用位对象数组。答案很简单:空间。如果一个对象仅消耗 3 个位,使用数据打包的方式,你可以在相同的空间内存放 2.67 倍数量的元素,而不是为每个对象分配一个完整的字节。对于非常大的数组,这可以节省大量空间。当然,这种空间节省的代价是速度:你需要执行额外的指令来打包和解包数据,从而减慢了数据访问速度。
计算在一大块位中定位数组元素的位偏移量与标准数组访问几乎相同;其公式为:
*`Element_Address_in_bits`* =
*`Base_address_in_bits`* + *`index`* * *`element_size_in_bits`*
一旦你计算出元素的位地址,你需要将其转换为字节地址(因为在访问内存时必须使用字节地址),并提取指定的元素。由于数组元素的基地址(几乎)总是从字节边界开始,我们可以使用以下公式来简化这个任务:
*`Byte_of_1st_bit`* =
*`Base_Address`* + (*`index`* * *`element_size_in_bits`* )/8
*`Offset_to_1st_bit`* =
(*`index`* * *`element_size_in_bits`*) % 8 (note "%" = MOD)
例如,假设我们有一个包含 200 个 3 位对象的数组,声明如下:
static
AO3Bobjects: byte[ int32((200*3)/8 + 2) ]; // "+2" handles
// truncation.
上面维度中的常量表达式为足够容纳 600 位(200 个元素,每个元素 3 个位)预留了空间。如注释所示,该表达式在末尾添加了 2 个额外的字节,以确保我们不会丢失任何奇数位(在本示例中不会发生这种情况,因为 600 可以被 8 整除,但通常不能依赖这一点;通常添加一个额外字节不会造成问题),同时允许我们在数组的末尾访问 1 个字节(当向数组存储数据时)。
现在假设你想访问该数组的第i个 3 位元素。你可以使用以下代码提取这些位:
// Extract the ith group of 3 bits in AO3Bobjects
// and leave this value in eax.
sub( ecx, ecx ); // Put i/8 remainder here.
mov( i, eax ); // Get the index into the array.
lea( eax, [eax+eax*2] ); // eax := eax * 3 (3 bits/element).
shrd( 3, eax, ecx ); // eax/8 -> eax and eax mod 8 -> ecx
// (H.O. bits).
shr( 3, eax ); // Remember, shrd doesn't modify eax.
rol( 3, ecx ); // Put remainder into L.O. 3
// bits of ecx.
// Okay, fetch the word containing the 3 bits we want to
// extract. We have to fetch a word because the last bit or two
// could wind up crossing the byte boundary (i.e., bit offset 6
// and 7 in the byte).
mov( (type word AO3Bobjects[eax]), ax );
shr( cl, ax ); // Move bits down to bit 0.
and( %111, eax ); // Remove the other bits.
向数组插入一个元素要稍微复杂一些。除了计算数组元素的基地址和位偏移量外,还必须创建一个掩码,以清除目标位置的位,供插入新数据使用。以下代码将 EAX 的低 3 位插入到AO3Bobjects数组的i元素中。
// Insert the L.O. 3 bits of ax into the ith element
// of AO3Bobjects:
readonly
Masks:
word[8] :=
[
!%0111, !%0011_1000,
!%0001_1100_0000, !%1110,
!%0111_0000, !%0011_1000_0000,
!%0001_1100, !%1110_0000
];
.
.
.
mov( i, ebx ); // Get the index into the array.
mov( ebx, ecx ); // Use L.O. 3 bits as index
and( %111, ecx ); // into Masks table.
mov( Masks[ecx*2], dx ); // Get bit mask.
// Convert index into the array into a bit index.
// To do this, multiply the index by 3:
lea( ebx, [ebx+ebx*2]);
// Divide by 8 to get the byte index into ebx
// and the bit index (the remainder) into ecx:
shrd( 3,ebx, ecx );
shr( 3, ebx );
rol( 3, ecx );
// Grab the bits and clear those we're inserting.
and( (type word AO3Bobjects[ ebx ]), dx );
// Put our 3 bits in their proper location.
shl( cl, ax );
// Merge bits into destination.
or( ax, dx );
// Store back into memory.
mov( dx, (type word AO3Bobjects[ ebx ]) );
注意使用查找表来生成所需的掩码,以清除数组中适当位置的位。该数组的每个元素都包含全 1,除了我们需要清除的给定位偏移位置的三个零(注意使用!操作符来反转表中的常量)。
10.7 查找位
一个非常常见的位操作是定位一段位的结束。这个操作的一个特殊情况是定位 16 位或 32 位值中第一个(或最后一个)设置或清除的位。在本节中,我们将探讨实现这一目标的方法。
在描述如何搜索给定值的第一个或最后一个位之前,也许先讨论一下在这个上下文中第一个和最后一个的确切含义是明智的。术语第一个设置位是指从位 0 开始扫描,直到扫描到包含 1 的高位。第一个清除位有类似的定义。最后一个设置位是指从高位开始扫描,直到扫描到包含 1 的位 0。最后一个清除位也有类似的定义。
扫描第一个或最后一个位的一个明显方法是使用移位指令在循环中,并计算在将 1(或 0)移入进位标志之前的迭代次数。迭代次数指定了位的位置。下面是一些示例代码,它检查 EAX 中第一个设置的位,并将该位位置返回到 ECX 中:
mov( −32, ecx ); // Count off the bit positions in ecx.
TstLp: shr( 1, eax ); // Check to see if current bit
// position contains a 1.
jc Done; // Exit loop if it does.
inc( ecx ); // Bump up our bit counter by 1.
jnz TstLp; // Exit if we execute this loop 32 times.
Done: add( 32, cl ); // Adjust loop counter so it holds
// the bit position.
// At this point, ecx contains the bit position of the first set bit.
// ecx contains 32 if eax originally contained 0 (no set bits).
这段代码唯一的复杂之处在于它将循环计数器从−32 运行到 0,而不是从 32 倒数到 0。这样一来,一旦循环终止,计算位位置就稍微容易些。
这个特定循环的缺点是它比较昂贵。这个循环最多会重复 32 次,具体取决于 EAX 中的原始值。如果你检查的值在 EAX 的低位中有很多零,这段代码就会运行得比较慢。
搜索第一个(或最后一个)设置位是一个非常常见的操作,因此英特尔在 80386 处理器上专门增加了一些指令来加速这个过程。这些指令是bsf(位扫描向前)和bsr(位扫描向后)。它们的语法如下:
bsr( *`source`*, *`destReg`* );
bsf( *`source`*, *`destReg`* );
源操作数和目标操作数必须具有相同的大小,并且它们必须都是 16 位或 32 位对象。目标操作数必须是寄存器。源操作数可以是寄存器或内存位置。
bsf指令从源操作数的第 0 位开始扫描第一个设置位。bsr指令通过从高位向低位扫描来扫描源操作数中的最后一个设置位。如果这些指令在源操作数中找到了一个已设置的位,它们将清除零标志并将该位位置存入目标寄存器。如果源寄存器包含 0(即没有设置的位),这些指令将设置零标志,并将目标寄存器的值置为不确定。请注意,你应该在这些指令执行后立即测试零标志,以验证目标寄存器的值。下面是一个示例:
mov( *`SomeValue`*, ebx ); // Value whose bits we want to check.
bsf( ebx, eax ); // Put position of first set bit in eax.
jz *`NoBitsSet`*; // Branch if *`SomeValue`* contains 0.
mov( eax, *`FirstBit`* ); // Save location of first set bit.
.
.
.
你以相同的方式使用bsr指令,不同之处在于它计算操作数中最后一个设置位的位位置(即,它从高位向低位扫描时找到的第一个设置位)。
80x86 CPU 没有提供定位第一个包含 0 的比特的指令。然而,你可以通过首先反转源操作数(如果必须保留源操作数的值,可以反转其副本),然后搜索第一个 1 比特来轻松扫描 0 比特;这对应于原始操作数值中的第一个 0 比特。
bsf和bsr指令是非常复杂的 80x86 指令。因此,这些指令可能比其他指令慢。实际上,在某些情况下,使用离散指令定位第一个已设置的比特可能更快。然而,由于这些指令的执行时间因 CPU 而异,因此你应该在将它们用于时间关键型代码之前测试这些指令的性能。
请注意,bsf和bsr指令不会影响源操作数。一个常见的操作是提取你在某个操作数中找到的第一个(或最后一个)已设置的比特。也就是说,你可能希望在找到比特后将其清除。如果源操作数是一个寄存器(或者你可以轻松地将其移入一个寄存器),那么你可以在找到比特后使用btr(或btc)指令清除该比特。下面是实现这一结果的代码:
bsf( eax, ecx ); // Locate first set bit in eax.
if( @nz ) then // If we found a bit, clear it.
btr( ecx, eax ); // Clear the bit we just found.
endif;
在此序列的末尾,零标志指示我们是否找到一个比特(请注意,btr不会影响零标志)。或者,你可以在上面的if语句中添加一个else部分,处理源操作数(EAX)在此指令序列开始时为 0 的情况。
因为bsf和bsr指令仅支持 16 位和 32 位操作数,所以你需要稍微不同的方法来计算 8 位操作数的第一个比特位置。有几种合理的方式。首先,当然,你通常可以将 8 位操作数零扩展到 16 位或 32 位,然后在这个操作数上使用bsf或bsr指令。另一种选择是创建一个查找表,每个表项包含作为索引的值中比特的数量;然后你可以使用xlat指令来“计算”值中的第一个比特位置(注意,你需要将值为 0 的情况作为特殊情况处理)。另一种解决方案是使用本节开始时出现的移位算法;对于 8 位操作数,这并不是一个完全低效的解决方案。
bsf和bsr指令的一个有趣应用是用它们来填充一个字符集,将该集中的所有字符按从最低值到最高值的顺序排列。例如,假设一个字符集包含值{'A', 'M', 'a'..'n', 'z'};如果我们填补这个字符集中的空白,就会得到{'A'..'z'}这些值。为了计算这个新集,我们可以使用bsf来确定集中的第一个字符的 ASCII 码,再使用bsr来确定集中的最后一个字符的 ASCII 码。完成这一步后,我们可以将这两个 ASCII 码传递给 HLA 标准库的cs.rangeChar函数来计算新的字符集。
你还可以使用bsf和bsr指令来确定位段的大小,前提是操作数中有一个连续的位段。只需定位位段中的第一个和最后一个位(如上所述),然后计算这两个值的差(加 1)。当然,这种方案仅在第一个和最后一个设置位之间没有中间零时有效。
10.8 计数位
前一节的最后一个示例展示了一个非常通用问题的特定案例:计数位。不幸的是,那个示例有一个严重的限制:它仅计数源操作数中出现的一个连续的 1 位段。本节讨论了该问题的更通用解决方案。
几乎每周都会有人在某个互联网新闻组上问如何计算寄存器操作数中的位数。这是一个常见的请求,无疑是因为许多汇编语言课程的讲师将这个任务作为项目布置给学生,以此来教授移位和旋转指令。无疑,讲师们期望的解决方案大致如下:
// BitCount1:
//
// Counts the bits in the eax register, returning the count in ebx.
mov( 32, cl ); // Count the 32 bits in eax.
sub( ebx, ebx ); // Accumulate the count here.
CntLoop: shr( 1, eax ); // Shift next bit out of eax and into Carry.
adc( 0, bl ); // Add the carry into the ebx register.
dec( cl ); // Repeat 32 times.
jnz CntLoop;
这里值得注意的“技巧”是,这段代码使用了adc指令将进位标志的值加到 BL 寄存器中。因为计数将小于 32,结果将很适合存储在 BL 中。
不管代码是否复杂,这个指令序列的速度并不特别快。通过简单分析就能看出,上面的循环总是执行 32 次,因此该代码序列执行了 130 条指令(每次迭代 4 条指令,再加 2 条额外指令)。你可能会问是否有更高效的解决方案;答案是肯定的。下面的代码,来自 AMD Athlon 优化指南,提供了一个更快的解决方案(请参阅注释以了解算法描述):
// bitCount
//
// Counts the number of "1" bits in a dword value.
// This function returns the dword count value in eax.
procedure bitCount( BitsToCnt:dword ); @nodisplay;
const
EveryOtherBit := $5555_5555;
EveryAlternatePair := $3333_3333;
EvenNibbles := $0f0f_0f0f;
begin bitCount;
push( edx );
mov( BitsToCnt, eax );
mov( eax, edx );
// Compute sum of each pair of bits
// in eax. The algorithm treats
// each pair of bits in eax as a
// 2-bit number and calculates the
// number of bits as follows (description
// is for bits 0 and 1, it generalizes
// to each pair):
//
// edx = Bit1 Bit0
// eax = 0 Bit1
//
// edx-eax = 00 if both bits were 0.
// 01 if Bit0=1 and Bit1=0.
// 01 if Bit0=0 and Bit1=1.
// 10 if Bit0=1 and Bit1=1.
//
// Note that the result is left in edx.
shr( 1, eax );
and( EveryOtherBit, eax );
sub( eax, edx );
// Now sum up the groups of 2 bits to
// produces sums of 4 bits. This works
// as follows:
//
// edx = bits 2,3, 6,7, 10,11, 14,15, ..., 30,31
// in bit positions 0,1, 4,5, ..., 28,29 with
// zeros in the other positions.
//
// eax = bits 0,1, 4,5, 8,9, ... 28,29 with zeros
// in the other positions.
//
// edx+eax produces the sums of these pairs of bits.
// The sums consume bits 0,1,2, 4,5,6, 8,9,10, ... 28,29,30
// in eax with the remaining bits all containing 0.
mov( edx, eax );
shr( 2, edx );
and( EveryAlternatePair, eax );
and( EveryAlternatePair, edx );
add( edx, eax );
// Now compute the sums of the even and odd nibbles in the
// number. Because bits 3, 7, 11, etc. in eax all contain
// 0 from the above calculation, we don't need to AND
// anything first, just shift and add the two values.
// This computes the sum of the bits in the 4 bytes
// as four separate values in eax (al contains number of
// bits in original al, ah contains number of bits in
// original ah, etc.)
mov( eax, edx );
shr( 4, eax );
add( edx, eax );
and( EvenNibbles, eax );
// Now for the tricky part.
// We want to compute the sum of the 4 bytes
// and return the result in eax. The following
// multiplication achieves this. It works
// as follows:
// (1) the $01 component leaves bits 24..31
// in bits 24..31.
//
// (2) the $100 component adds bits 17..23
// into bits 24..31.
//
// (3) the $1_0000 component adds bits 8..15
// into bits 24..31.
//
// (4) the $1000_0000 component adds bits 0..7
// into bits 24..31.
//
// Bits 0..23 are filled with garbage, but bits
// 24..31 contain the actual sum of the bits
// in eax's original value. The shr instruction
// moves this value into bits 0..7 and zeros
// out the H.O. bits of eax.
intmul( $0101_0101, eax );
shr( 24, eax );
pop( edx );
end bitCount;
10.9 反转位串
另一个常见的编程项目,通常由讲师布置,并且本身是一个有用的函数,是一个反转操作数中位的程序。也就是说,它将最低有效位(L.O. bit)与最高有效位(H.O. bit)交换,位 1 与次高有效位交换,以此类推。讲师可能期望的典型解决方案如下:
// Reverse the 32-bits in eax, leaving the result in ebx:
mov( 32, cl );
RvsLoop: shr( 1, eax ); // Move current bit in eax to
// the carry flag.
rcl( 1, ebx ); // Shift the bit back into
// ebx, backwards.
dec( cl );
jnz RvsLoop;
与前面的示例一样,这段代码的缺点是它重复执行 32 次循环,总共需要 129 条指令。通过展开循环,你可以将指令数减少到 64 条,但这仍然算是比较昂贵的。
和往常一样,优化问题的最佳解决方案往往是选择更好的算法,而不是试图通过选择更快的指令来调整代码以加速某些操作。然而,在操作位时,一点小聪明可以带来很大效果。例如,在上一节中,我们通过替换一个更复杂的算法,成功地加速了字符串中位的计数,替代了简单的“移位计数”算法。在上面的示例中,我们再次面临一个非常简单的算法,它的循环会针对每个数字的 1 个位重复执行。问题是:“我们能否发现一个不需要执行 129 条指令来反转 32 位寄存器中的位的算法?”答案是可以的,诀窍是尽可能并行地处理工作。
假设我们想做的只是交换一个 32 位值中的偶数位和奇数位。我们可以使用以下代码轻松地交换 EAX 寄存器中的偶数位和奇数位:
mov( eax, edx ); // Make a copy of the odd bits.
shr( 1, eax ); // Move even bits to the odd positions.
and( $5555_5555, edx ); // Isolate the odd bits.
and( $5555_5555, eax ); // Isolate the even bits.
shl( 1, edx ); // Move odd bits to even positions.
or( edx, eax ); // Merge the bits and complete the swap.
当然,交换偶数位和奇数位虽然有点有趣,但并没有解决我们更大的问题——反转数字中的所有位。不过,它确实将我们带到了一定的进展。例如,如果在执行上述代码序列之后你交换了相邻的位对,你就成功地交换了 32 位值中所有半字节的位。交换相邻位对的方式与上述类似,代码如下:
mov( eax, edx ); // Make a copy of the odd-numbered bit pairs.
shr( 2, eax ); // Move the even bit pairs to the odd position.
and( $3333_3333, edx ); // Isolate the odd pairs.
and( $3333_3333, eax ); // Isolate the even pairs.
shl( 2, edx ); // Move the odd pairs to the even positions.
or( edx, eax ); // Merge the bits and complete the swap.
完成上述序列后,你可以交换 32 位寄存器中的相邻半字节。再一次,唯一的区别是位掩码和移位的长度。代码如下:
mov( eax, edx ); // Make a copy of the odd-numbered nibbles.
shr( 4, eax ); // Move the even nibbles to the odd position.
and( $0f0f_0f0f, edx ); // Isolate the odd nibbles.
and( $0f0f_0f0f, eax ); // Isolate the even nibbles.
shl( 4, edx ); // Move the odd pairs to the even positions.
or( edx, eax ); // Merge the bits and complete the swap.
你可能已经看到这个模式,并且可以推断出,在接下来的两个步骤中,你需要交换这个对象中的字节和字。你可以像上面那样使用代码,但有一种更好的方法:使用bswap指令。bswap(字节交换)指令的语法如下:
bswap( *`reg32`* );
该指令交换了指定 32 位寄存器中的字节 0 和 3,并交换了字节 1 和 2。该指令的主要用途是将数据在所谓的小端和大端数据格式之间进行转换。^([127]) 虽然在这里你并没有专门使用这条指令来做这个,但bswap指令确实按照你反转位时所需的方式交换了 32 位对象中的字节和字。你可以简单地在上述指令后面使用bswap( eax );指令来完成任务,而不必插入额外的 12 条指令来交换字节和字。最终代码序列如下:
mov( eax, edx ); // Make a copy of the odd bits in the data.
shr( 1, eax ); // Move the even bits to the odd positions.
and( $5555_5555, edx ); // Isolate the odd bits.
and( $5555_5555, eax ); // Isolate the even bits.
shl( 1, edx ); // Move the odd bits to the even positions.
or( edx, eax ); // Merge the bits and complete the swap.
mov( eax, edx ); // Make a copy of the odd numbered bit pairs.
shr( 2, eax ); // Move the even bit pairs to the odd position.
and( $3333_3333, edx ); // Isolate the odd pairs.
and( $3333_3333, eax ); // Isolate the even pairs.
shl( 2, edx ); // Move the odd pairs to the even positions.
or( edx, eax ); // Merge the bits and complete the swap.
mov( eax, edx ); // Make a copy of the odd numbered nibbles.
shr( 4, eax ); // Move the even nibbles to the odd position.
and( $0f0f_0f0f, edx ); // Isolate the odd nibbles.
and( $0f0f_0f0f, eax ); // Isolate the even nibbles.
shl( 4, edx ); // Move the odd pairs to the even positions.
or( edx, eax ); // Merge the bits and complete the swap.
bswap( eax ); // Swap the bytes and words.
该算法只需要 19 条指令,而且比前面提到的位移循环执行得更快。当然,这个序列确实消耗了稍多的内存。如果你想节省内存而不是时钟周期,那么循环可能是一个更好的解决方案。
^([127]) 在小端系统中,即原生的 80x86 格式,对象的低字节(L.O. byte)出现在内存中的最低地址。而在大端系统中,许多 RISC 处理器使用这种系统,对象的高字节(H.O. byte)出现在内存中的最低地址。bswap指令用于在这两种数据格式之间进行转换。
10.10 合并位串
另一个常见的位串操作是通过合并或交错来自两个不同来源的位来生成一个单一的位串。以下示例代码序列通过交替合并两个 16 位位串的位,创建一个 32 位的位串:
// Merge two 16-bit strings into a single 32-bit string.
// ax - Source for even numbered bits.
// bx - Source for odd numbered bits.
// cl - Scratch register.
// edx- Destination register.
mov( 16, cl );
MergeLp: shrd( 1, eax, edx ); // Shift a bit from eax into edx.
shrd( 1, ebx, edx ); // Shift a bit from ebx into edx.
dec( cl );
jne MergeLp;
这个特定的例子将两个 16 位值合并,交替它们的位在结果值中。为了更快地实现这段代码,展开循环可能是最佳选择,因为这可以消除一半的指令。
通过稍微修改一下,我们也可以将四个 8 位值合并在一起,或者使用其他位序列生成结果。例如,以下代码将 EAX 中的位 0..5 复制,然后将 EBX 中的位 0..4 复制,然后将 EAX 中的位 6..11 复制,再将 EBX 中的位 5..15 复制,最后将 EAX 中的位 12..15 复制:
shrd( 6, eax, edx );
shrd( 5, ebx, edx );
shrd( 6, eax, edx );
shrd( 11, ebx, edx );
shrd( 4, eax, edx );
10.11 提取位串
当然,我们也可以轻松实现合并两个位流的逆操作;也就是说,我们可以从位串中提取并分发位到多个目标。以下代码将 EAX 中的 32 位值并将交替位分配到 BX 和 DX 寄存器:
mov( 16, cl ); // Count the loop iterations.
ExtractLp: shr( 1, eax ); // Extract even bits to (e)bx.
rcr( 1, ebx );
shr( 1, eax ); // Extract odd bits to (e)dx.
rcr( 1, edx );
dec( cl ); // Repeat 16 times.
jnz ExtractLp;
shr( 16, ebx ); // Need to move the results from the H.O.
shr( 16, edx ); // bytes of ebx/edx to the L.O. bytes.
该序列执行了 99 条指令。这虽然不算太糟,但我们或许能通过使用一个并行提取位的算法来做得更好。采用我们用来反转寄存器中位的技术,我们可以想出以下算法,将所有偶数位移到 EAX 的低字(L.O. word),将所有奇数位移到 EAX 的高字(H.O. word)。
// Swap bits at positions (1,2), (5,6), (9,10), (13,14), (17,18),
// (21,22), (25,26), and (29, 30).
mov( eax, edx );
and( $9999_9999, eax ); // Mask out the bits we'll keep for now.
mov( edx, ecx );
shr( 1, edx ); // Move 1st bits in tuple above to the
and( $2222_2222, ecx ); // correct position and mask out the
and( $2222_2222, edx ); // unneeded bits.
shl( 1, ecx ); // Move 2nd bits in tuples above.
or( edx, ecx ); // Merge all the bits back together.
or( ecx, eax );
// Swap bit pairs at positions ((2,3), (4,5)),
// ((10,11), (12,13)), etc.
mov( eax, edx );
and( $c3c3_c3c3, eax ); // The bits we'll leave alone.
mov( edx, ecx );
shr( 2, edx );
and( $0c0c_0c0c, ecx );
and( $0c0c_0c0c, edx );
shl( 2, ecx );
or( edx, ecx );
or( ecx, eax );
// Swap nibbles at nibble positions (1,2), (5,6), (9,10), etc.
mov( eax, edx );
and( $f00f_f00f, eax );
mov( edx, ecx );
shr(4, edx );
and( $0f0f_0f0f, ecx );
and( $0f0f_0f0f, ecx );
shl( 4, ecx );
or( edx, ecx );
or( ecx, eax );
// Swap bits at positions 1 and 2.
ror( 8, eax );
xchg( al, ah );
rol( 8, eax );
该序列需要 30 条指令。乍一看,它似乎是一个赢家,因为原始的循环执行了 64 条指令。然而,这段代码并不像看起来那么好。毕竟,如果我们愿意写这么多代码,为什么不将上面的循环展开 16 次呢?那样的序列只需要 64 条指令。所以,之前的算法在指令数量上可能没有太大优势。至于哪个序列更快,嗯,你得实际测量一下才能得出结论。不过,shrd指令并不是在所有处理器上都特别快,其他序列中的指令也是如此。这个例子在这里展示并不是要给你一个更好的算法,而是为了证明编写复杂代码并不总能带来巨大的性能提升。
提取其他位组合的任务留给读者作为练习。
10.12 搜索位模式
另一个可能需要的与位相关的操作是能够在位字符串中搜索特定的位模式。例如,你可能希望从某个特定位置开始,在位字符串中找到第一次出现的%1011 的位索引。在本节中,我们将探讨一些简单的算法来完成这个任务。
要搜索特定的位模式,我们需要知道四个事项:(1)要搜索的模式(pattern),(2)要搜索的模式的长度,(3)我们要搜索的位字符串(source),以及(4)要搜索的位字符串的长度。搜索的基本思路是基于模式的长度创建一个掩码,并用该值对源字符串的副本进行掩码处理。然后,我们可以直接将模式与掩码后的源字符串进行比较。如果它们相等,搜索结束;如果不相等,则递增位位置计数器,将源字符串右移一位,并重试。这个操作将重复length(source) - length(pattern)次。如果在这些尝试后仍未找到位模式,则算法失败(因为我们已经耗尽了源操作数中所有可能与模式长度匹配的位)。以下是一个简单的算法,用于在 EBX 寄存器中搜索一个 4 位模式:
mov( 28, cl ); // 28 attempts because 32−4 = 28
// (len(src) - len(pat)).
mov( %1111, ch ); // Mask for the comparison.
mov( *`pattern`*, al ); // Pattern to search for.
and( ch, al ); // Mask unnecessary bits in al.
mov( *`source`*, ebx ); // Get the source value.
ScanLp: mov( bl, dl ); // Copy the L.O. 4 bits of ebx
and( ch, dl ); // Mask unwanted bits.
cmp( dl, al ); // See if we match the pattern.
jz Matched;
dec( cl ); // Repeat the specified number of times.
shl( 1, ebx );
jnz ScanLp;
// Do whatever needs to be done if we failed to match the bit string.
jmp Done;
Matched:
// If we get to this point, we matched the bit string. We can compute
// the position in the original source as 28-cl.
Done:
位字符串扫描是字符串匹配的特殊情况。字符串匹配是计算机科学中研究得较为深入的问题,许多用于字符串匹配的算法同样适用于位字符串匹配。这类算法超出了本章的范围,但为了让你预览其工作原理,你可以计算模式和当前源位之间的一些函数(如xor或sub),并使用结果作为查找表的索引,来确定可以跳过多少位。这类算法允许你跳过多个位,而不是像前面的算法那样每次扫描循环只右移一次。
10.13 HLA 标准库位模块
HLA 标准库提供了bits.hhf模块,该模块提供了多个与位操作相关的函数,包括本章所研究的许多算法的内置函数。本节将介绍 HLA 标准库中一些可用的函数。
procedure bits.cnt( b:dword ); @returns( "eax" );
该过程返回b参数中 1 位的数量。它将结果存储在 EAX 寄存器中。要统计参数值中 0 位的数量,可以在将参数传递给bits.cnt之前,先对参数的值取反。如果你想统计 16 位操作数中的位数,只需在调用此函数之前将其零扩展到 32 位。以下是几个示例:
// Compute the number of bits in a 16-bit register:
pushw( 0 );
push( ax );
call bits.cnt;
// If you prefer to use a higher-level syntax, try the following:
bits.cnt( #{ pushw(0); push(ax); }# );
// Compute the number of bits in a 16-bit memory location:
pushw( 0 );
push( *`mem16`* );
call bits.cnt;
如果你想计算一个 8 位操作数的位数,可能更快的方法是编写一个简单的循环,旋转源操作数中的所有位并将进位加到累积和中。当然,如果性能不是问题,你可以将字节零扩展到 32 位并调用 bits.cnt 过程。
procedure bits.distribute( source:dword; mask:dword; dest:dword );
@returns( "eax" );
该函数提取 source 的最低 n 位,其中 n 是 mask 中 1 位的数量,并将这些位插入到 dest 中,位置由 mask 中的 1 位指定(即,与本章前面出现的分发算法相同)。该函数不会更改 dest 中对应于 mask 中零的位。该函数不会影响实际的 dest 参数值;它将在 EAX 寄存器中返回新值。
procedure bits.coalesce( source:dword; mask:dword );
@returns( "eax" );
该函数是 bits.distribute 的反向操作。它提取 source 中所有在 mask 中对应位置为 1 的位。该函数将这些位合并(右对齐)到结果的最低有效位位置,并将结果返回在 EAX 寄存器中。
procedure bits.extract( var d:dword );
@returns( "eax" ); // Really a macro.
该函数从第 0 位开始搜索 d 中的第一个设置位,并在 EAX 寄存器中返回该位的索引;此时,函数还会清除零标志。该函数还会清除该操作数中的该位。如果 d 为 0,则该函数返回零标志设置,并且 EAX 将包含 -1。
请注意,HLA 实际上是将此功能实现为宏,而不是过程。这意味着你可以将任何双字操作数作为参数(内存或寄存器操作数)。然而,如果将 EAX 作为参数传递,结果将是未定义的(因为此函数计算的是 EAX 中的位数)。
procedure bits.reverse32( d:dword ); @returns( "eax" );
procedure bits.reverse16( w:word ); @returns( "ax" );
procedure bits.reverse8( b:byte ); @returns( "al" );
这三种例程返回其参数值,并在累加器寄存器(AL/AX/EAX)中反转其位。根据数据大小调用适合的例程。
procedure bits.merge32( even:dword; odd:dword ); @returns( "edx:eax" );
procedure bits.merge16( even:word; odd:word ); @returns( "eax" );
procedure bits.merge8( even:byte; odd:byte ); @returns( "ax" );
这些例程将两个位流合并,生成一个其大小为两个参数合并后的值。even 参数的位占据结果中的偶数位,odd 参数的位占据结果中的奇数位。请注意,这些函数根据字节、字和双字参数值返回 16、32 或 64 位。
procedure bits.nibbles32( d:dword ); @returns( "edx:eax" );
procedure bits.nibbles16( w:word ); @returns( "eax" );
procedure bits.nibbles8( b:byte ); @returns( "ax" );
这些例程从参数中提取每个半字节,并将这些半字节放入单独的字节中。bits.nibbles8 函数从 b 参数中提取两个半字节,并将最低有效半字节放入 AL,将最高有效半字节放入 AH。bits.nibbles16 函数提取 w 中的四个半字节,并将它们放入 EAX 的四个字节中。你可以使用 bswap 或旋转指令访问 EAX 中高字的半字节。bits.nibbles32 函数从 EAX 中提取八个半字节,并将它们分布到 EDX:EAX 中的 8 个字节。半字节 0 会出现在 AL 中,半字节 7 会出现在 EDX 的最高字节中。同样,你可以使用 bswap 或旋转指令访问 EAX 和 EDX 的上半部分字节。
10.14 更多信息
《汇编语言艺术》的电子版可在webster.cs.ucr.edu/和www.artofasm.com/找到,里面包含了一些在开发位操作算法时可能会用到的附加信息。特别是关于数字设计的章节讨论了布尔代数,这是你在处理位操作时必须掌握的一个重要内容。HLA 标准库参考手册包含了更多关于 HLA 标准库位操作例程的信息。有关这些函数的更多信息,请查阅网站上的文档。正如在位计数章节中所提到的,AMD Athlon 优化指南中包含了一些用于位运算的有用算法。最后,为了了解更多关于位搜索算法的内容,你应该阅读一本数据结构与算法的教材,并深入学习其中的字符串匹配算法部分。
第十一章 字符串指令

字符串是存储在连续内存位置中的一组值。字符串通常是字节、字或(在 80386 及更高处理器上)双字的数组。80x86 微处理器家族支持几种专门设计用来处理字符串的指令。本章将探讨这些字符串指令的一些用途。
80x86 CPU 可以处理三种类型的字符串:字节字符串、字字符串和双字字符串。它们可以移动字符串、比较字符串、在字符串中查找特定值、将字符串初始化为固定值,并执行其他字符串基础操作。80x86 的字符串指令对于操作数组、表格和记录也非常有用。你可以使用字符串指令轻松地分配或比较这些数据结构。使用字符串指令可以显著加速数组操作代码。
11.1 80x86 字符串指令
80x86 系列的所有成员都支持五种不同的字符串指令:movsx、cmpsx、scasx、lodsx 和 stosx。^([128])(x = b、w 或 d,分别表示字节、字或双字;在一般讨论这些字符串指令时,通常会省略 x 后缀。)这些是构建其他大多数字符串操作的基础指令。如何使用这五条指令是接下来各节的主题。
For MOVS:
movsb();
movsw();
movsd();
For CMPS:
cmpsb();
cmpsw();
cmpsd();
For SCAS:
scasb();
scasw();
scasd();
For STOS:
stosb();
stosw();
stosd();
For LODS:
lodsb();
lodsw();
lodsd();
11.1.1 字符串指令的操作方式
字符串指令作用于内存的块(连续线性数组)。例如,movs 指令将一串字节从一个内存位置移动到另一个位置。cmps 指令比较两个内存块。scas 指令扫描内存块以查找特定值。这些字符串指令通常需要三个操作数:目标块地址、源块地址和(可选的)元素计数。例如,在使用 movs 指令复制字符串时,你需要源地址、目标地址和计数(要移动的字符串元素数量)。
与其他操作内存的指令不同,字符串指令没有显式操作数。字符串指令的操作数如下:
-
ESI(源索引)寄存器
-
EDI(目标索引)寄存器
-
ECX(计数)寄存器
-
AL/AX/EAX 寄存器
-
FLAGS 寄存器中的方向标志
例如,movs(移动字符串)指令的一个变体将 ECX 个元素从由 ESI 指定的源地址复制到由 EDI 指定的目标地址。同样,cmps 指令将 ESI 指向的字符串(长度为 ECX)与 EDI 指向的字符串进行比较。
并不是所有的字符串指令都有源操作数和目的操作数(只有 movs 和 cmps 支持)。例如,scas 指令(扫描字符串)会将累加器中的值(AL、AX 或 EAX)与内存中的值进行比较。
11.1.2 rep/repe/repz 和 repnz/repne 前缀
字符串指令本身并不会对数据字符串进行操作。例如,movs 指令只会复制一个字节、字或双字。当执行 movs 指令时,它会忽略 ECX 寄存器中的值。重复前缀告诉 80x86 执行多字节的字符串操作。重复前缀的语法如下:
For MOVS:
rep.movsb();
rep.movsw();
rep.movsd();
For CMPS:
repe.cmpsb(); // Note: repz is a synonym for repe.
repe.cmpsw();
repe.cmpsd();
repne.cmpsb(); // Note: repnz is a synonym for repne.
repne.cmpsw();
repne.cmpsd();
For SCAS:
repe.scasb(); // Note: repz is a synonym for repe.
repe.scasw();
repe.scasd();
repne.scasb(); // Note: repnz is a synonym for repne.
repne.scasw();
repne.scasd();
For STOS:
rep.stosb();
rep.stosw();
rep.stosd();
通常情况下,你不会将重复前缀与 lods 指令一起使用。
当在字符串指令前加上重复前缀时,字符串指令会重复执行 ECX 次操作。^([129]) 没有重复前缀时,指令仅作用于单一元素(字节、字或双字)。
你可以使用重复前缀通过单一指令处理整个字符串。你也可以在没有重复前缀的情况下使用字符串指令作为字符串的基本操作,进而合成更强大的字符串操作。
11.1.3 方向标志
除了 ESI、EDI、ECX 和 AL/AX/EAX 寄存器之外,还有一个寄存器控制 80x86 字符串指令的操作——EFLAG 寄存器。具体来说,标志寄存器中的 方向标志 控制 CPU 如何处理字符串。
如果方向标志被清除,CPU 在操作每个字符串元素后会递增 ESI 和 EDI。例如,执行 movs 会将 ESI 处的字节、字或双字移动到 EDI,然后将 ESI 和 EDI 分别递增 1、2 或 4。当在此指令前加上 rep 前缀时,CPU 会根据 ECX 中指定的元素数量递增 ESI 和 EDI。执行完后,ESI 和 EDI 寄存器会指向字符串之外的第一个元素。
如果方向标志被设置,80x86 在处理每个字符串元素后会递减 ESI 和 EDI(同样,ECX 指定字符串元素的数量)。在重复的字符串操作后,如果方向标志被设置,ESI 和 EDI 寄存器将指向字符串之前的第一个字节、字或双字。
你可以使用 cld(清除方向标志)和 std(设置方向标志)指令来更改方向标志的值。当在过程内使用这些指令时,请记住它们会修改机器状态。因此,你可能需要在执行该过程时保存方向标志。以下示例展示了你可能遇到的问题。
procedure Str2; @nodisplay;
begin Str2;
std();
<< Do some string operations. >>
.
.
.
end Str2;
.
.
.
cld();
<< Do some operations. >>
Str2();
<< Do some string operations requiring D=0\. >>
这段代码不会正常工作。调用代码假设 Str2 返回后方向标志已经清除,但实际上并非如此。因此,在调用 Str2 后执行的字符串操作将无法正常工作。
解决这个问题有几种方法。第一个,也是最明显的方法,是在执行一个或多个字符串指令的序列之前,总是插入cld或std指令。这确保了你的代码中的方向标志始终被正确设置。另一种选择是使用pushfd和popfd指令来保存和恢复方向标志。使用这两种技巧,以上代码可以变成以下示例。
在字符串指令之前始终发出cld或std指令:
procedure Str2; @nodisplay;
begin Str2;
std();
<< Do some string operations. >>
.
.
.
end Str2;
.
.
.
cld();
<< Do some operations. >>
Str2();
cld();
<< Do some string operations requiring D=0\. >>
保存和恢复标志寄存器:
procedure Str2; @nodisplay;
begin Str2;
pushfd();
std();
<< Do some string operations. >>
.
.
.
popfd();
end Str2;
.
.
cld();
<< Do some operations. >>
Str2();
<< Do some string operations requiring D=0\. >>
如果你使用pushfd和popfd指令来保存和恢复标志寄存器,请记住,你是在保存和恢复所有的标志。这会让返回其他标志位的信息变得有些困难。例如,如果你使用pushfd和popfd来保留方向标志,那么在程序中返回进位标志的错误条件就需要一些额外的工作。
第三种解决方案是始终确保方向标志在特定需要设置它的指令序列之外是清除的。例如,许多库函数和一些操作系统总是假设在调用它们时,方向标志是清除的。例如,大多数标准 C 库函数就是这样工作的。你可以遵循这一约定,始终假设方向标志已被清除,并在需要使用std的序列后立即确保将其清除。
11.1.4 movs指令
movs指令使用以下语法:
movsb()
movsw()
movsd()
rep.movsb()
rep.movsw()
rep.movsd()
movsb(移动字符串,字节)指令从地址 ESI 取出一个字节并存储到地址 EDI,然后将 ESI 和 EDI 寄存器递增或递减 1。如果存在rep前缀,CPU 会检查 ECX,看看它是否为 0。如果不是,CPU 就会将字节从 ESI 移动到 EDI,并将 ECX 寄存器递减。这个过程会重复,直到 ECX 变为 0。如果 ECX 在初次执行时就为 0,movs指令将不会复制任何数据字节。
movsw(移动字符串,字)指令从地址 ESI 取出一个字并存储到地址 EDI,然后将 ESI 和 EDI 分别递增或递减 2。如果存在rep前缀,则 CPU 会重复这个过程 ECX 次。
movsd指令在双字数据上以类似方式工作。每次数据移动后,它会将 ESI 和 EDI 递增或递减 4。
当你使用rep前缀时,movsb指令会移动你在 ECX 寄存器中指定的字节数。以下代码段将 384 个字节从CharArray1复制到CharArray2:
CharArray1: byte[ 384 ];
CharArray2: byte[ 384 ];
.
.
.
cld();
lea( esi, CharArray1 );
lea( edi, CharArray2 );
mov( 384, ecx );
rep.movsb();
如果你将movsw替换为movsb,那么前面的代码将移动 384 个字(768 字节),而不是 384 个字节:
WordArray1: word[ 384 ];
WordArray2: word[ 384 ];
.
.
.
cld();
lea( esi, WordArray1 );
lea( edi, WordArray2 );
mov( 384, ecx );
rep.movsw();
记住,ECX 寄存器包含的是元素计数,而不是字节计数。使用movsw指令时,CPU 会移动 ECX 寄存器指定数量的字。同样,movsd指令移动的是 ECX 寄存器指定的双字数,而不是字节数。
如果在执行 movsb/movsw/movsd 指令之前设置了方向标志,CPU 会在移动每个字符串元素后递减 ESI 和 EDI 寄存器。这意味着,在执行 movsb、movsw 或 movsd 指令之前,ESI 和 EDI 寄存器必须指向它们各自字符串的最后一个元素。例如:
CharArray1: byte[ 384 ];
CharArray2: byte[ 384 ];
.
.
.
cld();
lea( esi, CharArray1[383] );
lea( edi, CharArray2[383] );
mov( 384, ecx );
rep.movsb();
尽管在某些情况下,从尾到头处理字符串是有用的(参见 11.1.5 cmps 指令 中的描述),但通常你会按正向方向处理字符串,因为这样更直接。有一类字符串操作,必须能够支持双向处理:当源和目标块重叠时,移动字符串。考虑以下代码中会发生什么:
CharArray1: byte;
CharArray2: byte[ 384 ];
.
.
.
cld();
lea( esi, CharArray1 );
lea( edi, CharArray2 );
mov( 384, ecx );
rep.movsb();
这一指令序列将 CharArray1 和 CharArray2 视为一对 384 字节的字符串。然而,CharArray1 数组中的最后 383 个字节与 CharArray2 数组中的前 383 个字节重叠。我们来逐字节跟踪这段代码的操作。
当 CPU 执行 movsb 指令时,它会将 ESI 指向的字节(CharArray1)复制到 EDI 指向的字节(CharArray2)。然后,ESI 和 EDI 会递增,ECX 会减少 1,并重复这个过程。此时,ESI 寄存器指向 CharArray1+1(即 CharArray2 的地址),而 EDI 寄存器指向 CharArray2+1。movsb 指令将 ESI 指向的字节复制到 EDI 指向的字节。然而,这个字节原本是从 CharArray1 位置复制过来的。因此,movsb 指令将原本位于 CharArray1 位置的值同时复制到 CharArray2 和 CharArray2+1。再次,CPU 递增 ESI 和 EDI,递减 ECX,并重复这个操作。此时,movsb 指令将来自 CharArray1+2(即 CharArray2+1)的位置的字节复制到 CharArray2+2 的位置。但这依然是原本出现在 CharArray1 位置的值。每次循环的重复都会将 CharArray1[0] 中的下一个元素复制到 CharArray2 数组中下一个可用的位置。从图示来看,大致如下所示:图 11-1。
最终结果是,movsb 指令会在整个字符串中复制 X。movsb 指令将源操作数复制到内存位置,这个内存位置将成为下一次移动操作的源操作数,从而导致复制的发生。

图 11-1. 在两个重叠数组之间复制数据(正向方向)
如果你确实想在两个数组重叠的情况下将一个数组移动到另一个数组中,你应该从两个字符串的末尾开始,将源字符串的每个元素移动到目标字符串中,正如图 11-2 所示。
设置方向标志,并将 ESI 和 EDI 指向字符串的末尾,将允许你在两个字符串重叠且源字符串位于目标字符串较低地址处时(正确地)将一个字符串移动到另一个字符串。如果两个字符串重叠,且源字符串位于目标字符串的较高地址处,那么清除方向标志并将 ESI 和 EDI 指向两个字符串的开始处。
如果两个字符串没有重叠,那么你可以使用任一技术将字符串移动到内存中。通常,清除方向标志进行操作是最简单的,因此最有意义。

图 11-2. 使用向后复制复制重叠数组中的数据
你不应该使用movsx指令来用单一字节、字或双字值填充数组。另一个字符串指令,stos,在这方面要更合适。然而,对于元素为 1、2 或 4 字节的数组,你可以使用movs指令将整个数组初始化为第一个元素的内容。
movs指令在复制双字时有时比复制字节或字更高效。在某些系统中,使用movsb复制一个字节所需的时间通常与使用movsd复制一个双字所需的时间相同。因此,如果你要将大量字节从一个数组移动到另一个数组,如果你能使用movsd指令而不是movsb指令,复制操作会更快。如果你要移动的字节数是 4 的偶数倍,这就是一个微不足道的变化;只需将要复制的字节数除以 4,将此值加载到 ECX 中,然后使用movsb指令。如果字节数不能被 4 整除,则可以使用movsd指令复制数组中除了最后 1、2 或 3 字节之外的所有字节(即,将字节计数除以 4 后的余数)。例如,如果你想高效地移动 4,099 个字节,你可以通过以下指令序列来完成。
lea( esi, Source );
lea( edi, Destination );
mov( 1024, ecx ); // Copy 1024 dwords = 4096 bytes.
rep.movsd();
movsw(); // Copy bytes 4097 and 4098.
movsb(); // Copy the last byte.
使用此技术复制数据时,从不需要超过三个movsx指令,因为你可以通过不超过两个movsb和movsw指令复制 1、2 或 3 字节。如果两个数组在双字边界上对齐,上述方案效率最高。如果没有对齐,你可能需要将movsb或movsw指令(或两者)移到movsd之前,这样movsd指令就可以与双字对齐的数据一起工作。
如果你在程序执行之前无法知道你正在复制的块的大小,你仍然可以使用类似以下代码来提高字节块移动的性能:
lea( esi, Source );
lea( edi, Dest );
mov( Length, ecx );
shr( 2, ecx ); // Divide by 4.
if( @nz ) then // Only execute movsd if 4 or more bytes.
rep.movsd(); // Copy the dwords.
endif;
mov( Length, ecx );
and( %11, ecx ); // Compute (Length mod 4).
if( @nz ) then // Only execute movsb if #bytes/4 <> 0.
rep.movsb(); // Copy the remaining 1, 2, or 3 bytes.
endif;
在许多计算机系统中,movsd 指令提供了一种复制大量数据从一个位置到另一个位置的最快方法。虽然在某些 CPU 上,可能有更快的方式来复制数据,但最终内存总线的性能才是限制因素,而 CPU 通常比内存总线要快得多。因此,除非你有一个特殊的系统,否则编写复杂的代码来优化内存到内存的传输可能是浪费时间。另外请注意,英特尔在后续处理器上改善了 movsx 指令的性能,使得 movsb 在复制相同字节数时几乎和 movsw 及 movsd 一样高效。因此,在后期的 80x86 处理器上,直接使用 movsb 复制指定字节数可能比执行上述所有复杂的操作更高效。最重要的一点是:如果你对块移动的速度有要求,尝试几种不同的方式,并选择最快的方法(或者,如果它们的速度相同,选择最简单的方法,这种情况可能性很大)。
11.1.5 cmps 指令
cmps 指令用于比较两个字符串。CPU 将 EDI 引用的字符串与 ESI 指向的字符串进行比较。ECX 包含两个字符串的长度(当使用 repe 或 repne 前缀时)。与 movs 指令类似,HLA 允许此指令的几种不同形式:
cmpsb();
cmpsw();
cmpsd();
repe.cmpsb();
repe.cmpsw();
repe.cmpsd();
repne.cmpsb();
repne.cmpsw();
repne.cmpsd();
与 movs 指令类似,你在 ESI 和 EDI 寄存器中指定实际的操作数地址。
如果没有重复前缀,cmps 指令会将 EDI 位置的值与 ESI 位置的值进行相减,并更新标志位。除了更新标志位之外,CPU 不会使用这次相减得到的差值。比较两个位置后,cmps 会根据需要将 ESI 和 EDI 寄存器分别加 1、2 或 4(分别对应 cmpsb/cmpsw/cmpsd)。如果方向标志清除,cmps 会增加 ESI 和 EDI 寄存器的值,否则会减少它们。
当然,使用 cmps 指令来比较内存中的单个字节、字或双字时,你不会真正发挥它的强大功能。当你用它来比较整个字符串时,这条指令才会发挥真正的优势。使用 cmps,你可以比较字符串中的连续元素,直到找到匹配项或直到连续元素不匹配。
为了比较两个字符串是否相等或不相等,你必须比较字符串中的对应元素,直到它们不匹配。考虑以下字符串:
"String1"
"String1"
确定这两个字符串是否相等的唯一方法是将第一个字符串中的每个字符与第二个字符串中对应的字符进行比较。毕竟,第二个字符串可能是String2,而这显然与String1不相等。一旦你遇到目标字符串中的一个字符,它与源字符串中的对应字符不相等,比较就可以停止。你不需要再比较两个字符串中的其他字符。
repe前缀完成了此操作。它将比较字符串中的连续元素,只要它们相等且 ECX 大于 0。我们可以使用以下 80x86 汇编语言代码来比较上述两个字符串:
cld();
mov( AdrsString1, esi );
mov( AdrsString2, edi );
mov( 7, ecx );
repe.cmpsb();
在执行cmpsb指令之后,你可以使用标准的(无符号)条件跳转指令来测试标志位。这样,你可以检查相等、不相等、小于、大于等情况。
字符字符串通常使用字典顺序进行比较。在字典顺序中,字符串的最低有效元素权重最大。这与标准整数比较直接对立,在整数比较中,数字的最高有效部分权重最大。此外,只有当两个字符串在短字符串的长度范围内完全相同时,字符串的长度才会影响比较。例如,Zebra小于Zebras,因为它是两个字符串中较短的一个;然而,尽管Zebra较短,它仍然大于AAAAAAAAAAH!。字典顺序比较会逐个比较对应的字符,直到遇到不匹配的字符或到达较短字符串的末尾。如果一对对应的字符不匹配,则此算法根据该单个字符比较两个字符串。如果两个字符串在较短字符串的长度范围内匹配,我们必须比较它们的长度。只有当两个字符串的长度相等,并且两个字符串中的每一对对应字符都相同时,两个字符串才相等。字典顺序就是你从小到大习惯的标准字母排序。
对于字符字符串,请按以下方式使用cmps指令:
-
在比较字符串之前,必须清除方向标志。
-
使用
cmpsb指令按字节逐个比较字符串。即使字符串包含偶数个字符,也不能使用cmpsw或cmpsd指令。它们不会按照字典顺序比较字符串。 -
你必须将 ECX 寄存器加载为较短字符串的长度。
-
使用
repe前缀。 -
ESI 和 EDI 寄存器必须指向你想要比较的两个字符串中的第一个字符。
在执行cmps指令之后,如果两个字符串相等,它们的长度必须进行比较,以完成比较。以下代码比较了一对字符字符串:
mov( AdrsStr1, esi );
mov( AdrsStr2, edi );
mov( LengthSrc, ecx );
if( ecx > LengthDest ) then // Put the length of the
// shorter string in ecx.
mov( LengthDest, ecx );
endif;
repe.cmpsb();
if( @z ) then // If equal to the length of the
// shorter string, cmp lengths.
mov( LengthSrc, ecx );
cmp( ecx, LengthDest );
endif;
如果你使用字节来存储字符串的长度,你应该适当地调整这段代码(即,使用movzx指令将长度加载到 ECX 寄存器)。HLA 字符串使用双字来存储当前的长度值,因此在使用 HLA 字符串时并不会遇到这个问题。
你还可以使用cmps指令来比较多字整型值(即扩展精度的整型值)。由于字符串比较需要大量的准备工作,因此对于长度小于六或八个双字的整型值,使用这种方法并不实际,但对于大整型值,它是一个极好的比较方式。与字符字符串不同,我们不能使用字典序比较整数字符串。在比较字符串时,我们从最不重要的字节开始比较到最重要的字节。而在比较整数时,我们必须从最重要的字节(或字/双字)开始比较,直到最不重要的字节、字或双字。因此,要比较两个 32 字节(256 位)的整型值,可以在 80x86 上使用以下代码:
std();
lea( esi, SourceInteger[28] );
lea( edi, DestInteger[28] );
mov( 8, ecx );
rep.cmpsd();
这段代码从整型值的最重要的双字开始比较,直到最不重要的双字。cmpsd指令在两个值不相等或 ECX 递减为 0 时结束(表示两个值相等)。再次强调,标志提供了比较的结果。
repne前缀将指示cmps指令比较连续的字符串元素,只要它们不匹配。执行此指令后,80x86 标志几乎没有用。要么 ECX 寄存器为 0(表示两个字符串完全不同),要么它包含在两个字符串中比较的元素数量,直到找到匹配项为止。虽然这种形式的cmps指令在比较字符串时不太有用,但它对于在字节、字或双字数组中定位第一对匹配项很有用。一般来说,你很少会在cmps中使用repne前缀。
使用cmps指令时需要记住的一件事是:ECX 寄存器中的值决定了要处理的元素数量,而不是字节数。因此,当使用cmpsw时,ECX 指定要比较的字数。同样,对于cmpsd,ECX 包含要处理的双字数。
11.1.6 scas指令
cmps指令用于比较两个字符串。你不能使用它来查找字符串中的特定元素。例如,你不能使用cmps指令快速扫描另一个字符串中的 0。你可以使用scas(扫描字符串)指令来完成这个任务。
与movs和cmps指令不同,scas指令只需要一个目标字符串(由 EDI 指向),而不是源字符串和目标字符串。源操作数是 AL 寄存器中的值(scasb)、AX 寄存器中的值(scasw)或 EAX 寄存器中的值(scasd)。scas指令将累加器中的值(AL、AX 或 EAX)与 EDI 指向的值进行比较,然后根据比较结果将 EDI 增加或减少 1、2 或 4。CPU 根据比较结果设置标志。虽然这种操作偶尔会有用,但当使用repe和repne前缀时,scas指令要更加有用。
使用repe前缀(等值时重复),scas扫描字符串,寻找第一个不匹配累加器中值的元素。使用repne前缀(不等时重复),scas扫描字符串,寻找第一个与累加器中值匹配的字符串元素。
你可能会想,“为什么这些前缀做的完全是它们应该做的反方向?”前面的段落并没有完全正确地描述scas指令的操作。使用repe前缀与scas指令时,80x86 会在累加器中的值等于字符串操作数时扫描字符串。这相当于在字符串中搜索第一个不匹配累加器中值的元素。使用scas指令与repne前缀时,CPU 会在累加器中的值不等于字符串操作数时扫描字符串。显然,这种方式是在寻找字符串中第一个与累加器寄存器中的值匹配的值。scas指令有以下几种形式:
scasb()
scasw()
scasd()
repe.scasb()
repe.scasw()
repe.scasd()
repne.scasb()
repne.scasw()
repne.scasd()
像cmps和movs指令一样,ECX 寄存器中的值指定了要处理的元素数,而不是字节数,特别是在使用重复前缀时。
11.1.7 stos指令
stos指令将累加器中的值存储到由 EDI 指定的位置。存储完值后,CPU 根据方向标志的状态增加或减少 EDI。尽管stos指令有许多用途,但它的主要用途是将数组和字符串初始化为常量值。例如,如果你有一个 256 字节的数组并且想用零清空它,可以使用以下代码:
cld();
lea( edi, DestArray );
mov( 64, ecx ); // 64 double words = 256 bytes.
xor( eax, eax ); // Zero out eax.
rep.stosd();
这段代码写入的是 64 个双字,而不是 256 个字节,因为单次stosd操作比四次stosb操作更快。
stos指令有六种形式,它们是:
stosb();
stosw();
stosd();
rep.stosb();
rep.stosw();
rep.stosd();
stosb指令将 AL 寄存器中的值存储到指定的内存位置,stosw指令将 AX 寄存器中的值存储到指定的内存位置,stosd指令将 EAX 寄存器中的值存储到指定的内存位置。
请记住,stos指令仅用于将字节、字或双字数组初始化为常量值。如果你需要初始化一个包含不同值的数组,不能使用stos指令。
11.1.8 lods指令
lods指令在所有字符串指令中是独特的。你可能永远不会在此指令中使用重复前缀。lods指令将 ESI 指针指向的字节、字或双字复制到 AL、AX 或 EAX 寄存器中,然后将 ESI 寄存器递增或递减 1、2 或 4。通过重复前缀来重复此指令几乎没有任何意义,因为每次lods指令重复时,累加器寄存器都会被覆盖。在重复操作结束时,累加器将包含最后一个从内存读取的值。
代替重复使用rep前缀,使用lods指令从内存中提取字节(lodsb)、字(lodsw)或双字(lodsd)进行进一步处理。通过使用lods和stos指令,你可以合成强大的字符串操作。
和stos指令一样,lods指令有六种形式:
lodsb();
lodsw();
lodsd();
rep.lodsb();
rep.lodsw();
rep.lodsd();
如前所述,你几乎不会使用rep前缀与这些指令一起使用。^([130]) 80x86 会根据方向标志以及你使用lodsb、lodsw或lodsd指令的不同,分别以 1、2 或 4 的步长递增或递减 ESI 寄存器。
11.1.9 基于 lods 和 stos 构建复杂字符串函数
80x86 只支持五条不同的字符串指令:movs、cmps、scas、lods和stos。^([131]) 这些当然不是你永远会使用的唯一字符串操作。然而,你可以利用lods和stos指令轻松生成任何你想要的特定字符串操作。例如,假设你想要一个字符串操作,将字符串中的所有大写字母转换为小写字母。你可以使用以下代码:
mov( StringAddress, esi ); // Load string address into esi.
mov( esi, edi ); // Also point edi here.
mov( (type str.strRec [esi]).length, ecx );
repeat
lodsb(); // Get the next character in the string.
if( al in 'A'..'Z' ) then
or( $20, al ); // Convert uppercase to lowercase.
endif;
stosb(); // Store converted char into string.
dec( ecx );
until( @z ); // Zero flag is set when ecx is 0.
因为lods和stos指令使用累加器作为中介位置,你可以利用任何累加器操作来快速操作字符串元素。
^([128]) 80x86 处理器支持两条额外的字符串指令,ins和outs,它们分别从输入端口输入字符串数据或将字符串数据输出到输出端口。我们不会考虑这些指令,因为它们是特权指令,你不能在标准 32 位操作系统应用程序中执行它们。
^([129]) 除了cmps指令,它最多重复 ECX 寄存器中指定的次数。
^([130]) 它们之所以出现在这里,仅仅是因为它们是允许的。它们并不是很有用,但确实被允许。此类指令的唯一用途大概是“触碰”缓存中的项,以便它们被预加载到缓存中。然而,还有更好的方法可以实现这一点。
^([131]) 不包括ins和outs,我们在这里忽略它们。
11.2 80x86 字符串指令的性能
在早期的 80x86 处理器中,字符串指令提供了操作字符串和数据块的最有效方式。然而,这些指令并不是 Intel 的 RISC Core 指令集的一部分,因此,它们可能比使用离散指令执行相同操作要慢。Intel 在后来的处理器上优化了 movs 指令,使其尽可能快速运行,但其他字符串指令可能会相对较慢。像往常一样,建议使用不同的算法(包括和不包括字符串指令的版本)来实现性能关键的算法,并比较它们的性能,以确定使用哪种解决方案。
请记住,字符串指令的执行速度相对于其他指令会根据所使用的处理器而有所不同。因此,最好在你期望代码运行的处理器上进行实验。请注意,在大多数处理器上,movs 指令比对应的离散指令要快。Intel 努力优化 movs,因为许多对性能敏感的代码都在使用它。
尽管字符串指令可能比离散指令更慢,但毫无疑问,字符串指令通常比实现相同结果的离散代码更加紧凑。
11.3 更多信息
HLA 标准库包含数百个你可能会觉得有用的字符串和模式匹配函数。所有这些都以源代码形式出现在 www.artofasm.com/ 或 webster.cs.ucr.edu/ 上;如果你想查看一些字符串指令的实际例子,应该查阅一些源代码。还要注意,HLA 标准库中的某些例程使用离散指令来实现某些高性能算法。你可能会想查看这些代码作为此类代码的例子。本书的 16 位版本(出现在网站上)讨论了使用 80x86 字符串指令实现多个字符字符串函数的问题。查阅该版本以获得更多示例(这些示例由于字符串指令的性能问题未出现在这里)。最后,关于字符串函数的更多一般信息,请查阅 HLA 标准库参考手册,它解释了 HLA 标准库中字符串和模式匹配函数的操作。
第十二章 类和对象

许多现代高级语言支持类和对象的概念。C++(C 语言的面向对象版本)、Java 和 Delphi(Pascal 的面向对象版本)就是很好的例子。当然,这些高级语言编译器将源代码转换成低级机器代码,所以应该很明显,机器代码中存在某种机制来实现类和对象。
尽管在机器代码中实现类和对象一直是可能的,但大多数汇编器在编写面向对象的汇编语言程序时提供的支持较差。HLA 不会受到这个缺点的影响,因为它为编写面向对象的汇编语言程序提供了良好的支持。本章将讨论面向对象编程(OOP)的基本原则以及 HLA 如何支持 OOP。
12.1 一般原则
在讨论 OOP 背后的机制之前,最好先退一步,探讨一下使用 OOP 的好处(尤其是在汇编语言程序中)。大多数描述 OOP 好处的书籍会使用一些流行词汇,如代码重用、抽象数据类型、提高开发效率等。尽管这些特性都很不错,是编程范式的优点,但一个好的软件工程师会质疑在“提高开发效率”是重要目标的环境中使用汇编语言。毕竟,使用高级语言(即使不是 OOP 方式)比在汇编语言中使用对象可能更能获得更好的效率。如果 OOP 的声称特性似乎不适用于汇编语言编程,那么为什么还要在汇编中使用 OOP 呢?本节将探讨一些这些原因。
你首先需要意识到的是,使用汇编语言并不会否定上述的 OOP 好处。汇编语言中的 OOP 确实促进了代码重用。它提供了一种实现抽象数据类型的好方法,而且它能够提高汇编语言中的开发效率。换句话说,如果你坚定地想使用汇编语言,那么使用 OOP 是有好处的。
为了理解面向对象编程(OOP)的主要好处之一,可以考虑全局变量的概念。大多数编程书籍强烈建议不要在程序中使用全局变量(本书也是如此)。通过全局变量进行跨过程通信是危险的,因为在大型程序中,很难追踪到所有可能修改给定全局对象的地方。更糟的是,在进行增强时,意外地重新使用全局对象来做原本不打算做的事情是非常容易的;这往往会导致系统出现缺陷。
尽管全局变量存在众所周知的问题,全局对象的语义(延长的生命周期和来自不同过程的可访问性)在各种情况下绝对是必要的。对象通过让程序员确定对象的生命周期^([132])以及允许从不同过程访问数据字段来解决这个问题。在许多方面,对象比简单的全局变量有几个优势,因为对象可以控制对它们的数据字段的访问(使得过程无意中访问数据变得困难),而且你还可以创建对象的多个实例,允许程序的不同部分使用自己独特的“全局”对象而不受其他部分的干扰。
当然,对象还具有许多其他宝贵的特性。关于对象和面向对象编程的好处,可以写下几卷书来讨论;这一章节无法全面展示这个主题。本章以在 HLA/汇编程序中使用对象为目标。然而,如果你对面向对象编程是新手或者希望获取更多关于面向对象范式的信息,你应该参考其他关于这一主题的文献。
类和对象的一个重要用途是创建抽象数据类型(ADT)。抽象数据类型是数据对象的集合,以及操作这些数据的函数(我们称之为方法)。在纯粹的抽象数据类型中,ADT 的方法是唯一能够访问 ADT 数据字段的代码;外部代码只能通过函数调用来获取或设置数据字段的值(这些是 ADT 的访问器方法)。在现实生活中,出于效率的原因,大多数支持 ADT 的语言允许外部代码至少有限地访问 ADT 的数据字段。
汇编语言并不是大多数人与 ADT 关联的语言。尽管如此,HLA 提供了几个功能来允许创建基本的 ADT。虽然有些人可能会认为 HLA 的功能不如像 C++或 Java 这样的语言完整,但要记住,这些差异存在是因为 HLA 是一种汇编语言。
真正的 ADT 应该支持信息隐藏。这意味着 ADT 不允许 ADT 的用户访问内部数据结构和操作这些结构的例程。实质上,信息隐藏将 ADT 的访问限制为 ADT 的访问器方法。当然,汇编语言提供的限制非常少。如果你坚决要直接访问一个对象,HLA 几乎无法阻止你这样做。然而,HLA 提供了一些设施,可以提供一种有限形式的信息隐藏。结合你的一些注意,你将能够在你的程序中享受信息隐藏的许多好处。
HLA 提供的主要支持信息隐藏的功能包括单独编译、可链接模块和#include/#includeonce指令。为了我们的目的,抽象数据类型定义将由两个部分组成:接口部分和实现部分。
接口部分包含必须对应用程序可见的定义。通常,它不应包含任何允许应用程序违反信息隐藏原则的特定信息,但考虑到汇编语言的性质,这通常是不可能的。尽管如此,你应尽量仅在接口部分公开绝对必要的信息。
实现部分包含实际实现 ADT 的代码、数据结构等。虽然实现部分中出现的一些方法和数据类型可能是公共的(由于出现在接口部分),但许多子程序、数据项等将是实现代码私有的。实现部分是你隐藏所有细节的地方,避免暴露给应用程序。
如果你希望在未来某个时间修改抽象数据类型,你只需要更改接口和实现部分。除非你删除一些应用程序正在使用的以前可见的对象,否则完全不需要修改应用程序。
尽管你可以将接口和实现部分直接放入应用程序中,但这不会促进信息隐藏或可维护性,特别是当你需要在多个不同的应用程序中包含这段代码时。最好的方法是将实现部分放入一个包含文件中,任何需要的应用程序可以使用 HLA 的#include指令读取该文件,并将实现部分放入一个单独的模块中,与你的应用程序链接。
包含文件将包含external指令、任何必要的宏以及你希望公开的其他定义。通常,它不会包含 80x86 代码,除非在某些宏中。应用程序如果需要使用 ADT,它会包含这个文件。
包含实现部分的单独汇编文件将包含所有的过程、函数、数据对象等,用于实际实现抽象数据类型(ADT)。那些你希望公开的名称应该出现在接口包含文件中,并具有external属性。你还应该在实现文件中包含接口包含文件,这样就不需要维护两组external指令。
使用过程作为数据访问方法的一个问题是,许多访问方法特别简单(例如,仅一个 mov 指令),而调用和返回指令的开销对于这种简单的操作来说是昂贵的。例如,假设你有一个抽象数据类型(ADT),其数据对象是一个结构,但你不希望将字段名称暴露给应用程序,并且你真的不希望允许应用程序直接访问数据结构的字段(因为数据结构可能会在未来发生变化)。处理这种情况的常见方法是提供一个 GetField 方法,该方法返回所需字段的值。然而,如上所述,这可能非常慢。对于简单的访问方法,替代方案是使用宏来生成访问所需字段的代码。尽管直接访问数据对象的代码会出现在应用程序中(通过宏展开),但如果你在接口部分更改宏,重新编译将自动更新它。
尽管完全可以仅通过单独编译和可能的记录来创建抽象数据类型,但 HLA 提供了更好的解决方案:类。继续阅读,了解 HLA 对类和对象的支持以及如何使用它们来创建抽象数据类型。
^([132]) 生命周期指的是系统为对象分配内存的时间。
12.2 HLA 中的类
从根本上说,类 是一种记录声明,它允许定义非数据字段(例如,过程、常量和宏)。将其他对象包含在类定义中极大地扩展了类的功能。例如,通过类现在可以轻松定义抽象数据类型(ADT),因为类可以包含数据和对数据进行操作的方法(过程)。
在 HLA 中创建抽象数据类型的主要方式是声明一个类数据类型。HLA 中的类总是出现在 type 部分,并使用以下语法:
classname : class
<< Class declaration section >>
endclass;
类声明部分与过程的局部声明部分非常相似,它允许声明const、val、var、storage、readonly、static 和 proc 变量。类还允许你定义宏,并指定过程、迭代器、^([133]) 以及 方法 原型(方法声明仅在类中合法)。这一列表中显著缺少的是类型声明部分。你不能在类中声明新类型。
方法 是一种特殊类型的过程,只出现在类内部。稍后你将看到过程和方法之间的区别;现在你可以将它们视为相同。除了关于类初始化和使用指向类的指针的几个微妙细节外,它们的语义是相同的。^([134]) 通常,如果你不确定在类中是使用过程还是方法,最安全的选择是使用方法。
你不需要将过程/迭代器/方法代码放在类中。相反,你只需提供这些例程的原型。一个例程原型由procedure、iterator或method保留字、例程名称、任何参数以及一些可选的过程属性(@use、@returns和external)组成。实际的例程定义(例程的主体及其所需的任何局部声明)出现在类外部。
以下示例演示了一个典型的类声明,出现在type部分:
TYPE
TypicalClass: class
Const
TCconst := 5;
Val
TCval := 6;
var
TCvar : uns32; // Private field used only by TCproc.
static
TCstatic : int32;
procedure TCproc( u:uns32 ); @returns( "eax" );
iterator TCiter( i:int32 ); external;
method TCmethod( c:char );
endclass;
如你所见,类与 HLA 中的记录非常相似。事实上,你可以把记录看作是只允许var声明的类。HLA 实现类的方式与记录非常相似,因为它将顺序数据字段分配到顺序的内存位置。事实上,除了一个小例外之外,record声明和只有var声明部分的class声明之间几乎没有区别。稍后你将看到 HLA 是如何实现类的,但现在你可以假设 HLA 的实现方式与记录相同,这样你就不会偏离主题太远。
你可以像访问记录的字段一样访问TCvar和TCstatic字段(如上面的类)。你也可以以类似的方式访问const和val字段。如果一个TypicalClass类型的变量名为obj,你可以如下访问obj的字段:
mov ( obj.TCconst, eax );
mov( obj.TCval, ebx );
add( obj.TCvar, eax );
add( obj.TCstatic, ebx );
obj.TCproc( 20 ); // Calls the TCproc procedure in TypicalClass.
etc.
如果一个应用程序包含上述类声明,它可以使用TypicalClass类型创建变量,并使用提到的方法执行操作。不幸的是,应用程序也可以肆意访问该 ADT 的字段。例如,如果程序创建了一个TypicalClass类型的变量MyClass,则它可以轻松执行像mov( MyClass.TCvar, eax );这样的指令,即使该字段可能是实现部分的私有字段。不幸的是,如果你打算允许应用程序声明一个TypicalClass类型的变量,字段名称就必须是可见的。虽然我们可以通过一些技巧来隐藏 HLA 类定义中的私有字段,但最好的解决方案是彻底注释私有字段,并在访问该类的字段时保持一定的克制。具体来说,这意味着你使用 HLA 的类创建的 ADT 不能是“纯”ADT,因为 HLA 允许直接访问数据字段。然而,只要有一些自律,你可以通过简单地选择不在类的方法、过程和迭代器之外访问这些字段来模拟一个纯 ADT。
类中的原型实际上是前向声明。像普通的前向声明一样,你在类中定义的所有过程、迭代器和方法必须在代码的后面有实际实现。或者,你可以在类中的过程、迭代器或方法声明的末尾附加external选项,以通知 HLA 实际的代码出现在一个单独的模块中。通常,类声明出现在头文件中,并表示 ADT 的接口部分。过程、迭代器和方法的主体出现在实现部分,通常是一个单独的源文件,你将其单独编译并与使用该类的模块进行链接。
以下是一个示例类过程实现的例子:
procedure TypicalClass.TCproc( u:uns32 ); @nodisplay;
<< Local declarations for this procedure >>
begin TCproc;
<< Code to implement whatever this procedure does >>
end TCProc;
标准过程声明和类过程声明之间有几个区别。首先,也是最明显的,过程名称包括类名(例如,TypicalClass.TCproc)。这将类过程定义与一个仅恰好叫做TCproc的常规过程区分开来。然而请注意,你不需要在过程的begin和end子句中重复类名(这与在 HLA 命名空间中定义的过程类似)。
类过程和非类过程之间的第二个区别不那么明显。某些过程属性(@use、external、@returns、@cdecl、@pascal和@stdcall)仅在类中的原型声明中合法,而其他属性(@noframe、@nodisplay、@noalignstack和@align)仅在过程定义中合法,而不能出现在类中。幸运的是,如果你将选项放在错误的位置,HLA 会提供有用的错误消息,因此你不需要记住这个规则。
如果类例程的原型没有external选项,则包含类声明的编译单元(即程序或单元)必须同时包含该例程的定义,否则 HLA 会在编译结束时生成错误。对于小型的本地类(即当你将类声明和例程定义嵌入到同一个编译单元中时),惯例是将类的过程、迭代器和方法定义放在类声明之后不久的源文件中。对于较大的系统(即在单独编译类的例程时),惯例是将类声明单独放在头文件中,将所有过程、迭代器和方法定义放在一个单独的 HLA 单元中并单独编译。
^([133]) 本文不讨论迭代器。有关这种类型的函数,请参阅 HLA 参考手册。
^([134]) 然而,请注意,过程与方法之间的差异对于面向对象编程范式至关重要,因此 HLA 的类定义中包括了方法。
12.3 对象
记住,类定义只是一个类型。因此,当你声明一个类类型时,你并没有创建一个可以操作其字段的变量。一个 对象 是一个 实例;也就是说,对象是一个类型为某个类的变量。你声明对象(即类变量)与声明其他变量的方式相同:在 var、static 或 storage 部分。^([135]) 这里是一个示例对象声明对:
var
T1: TypicalClass;
T2: TypicalClass;
对于给定的类对象,HLA 为类声明的 var 部分中出现的每个变量分配存储空间。如果你有两个 TypicalClass 类型的对象 T1 和 T2,那么 T1.TCvar 是唯一的,T2.TCvar 也是唯一的。这是直观的结果(类似于 record 声明);你在类中定义的大多数数据字段会出现在类的 var 声明部分。
静态数据对象(例如,你在类声明的 static 或 storage 部分声明的对象)在该类的对象中不是唯一的;也就是说,HLA 只分配一个静态变量,所有该类的变量共享该变量。例如,考虑以下(部分)类声明和对象声明:
type
sc: class
var
i:int32;
static
s:int32;
.
.
.
endclass;
var
s1: sc;
s2: sc;
在这个例子中,s1.i 和 s2.i 是不同的变量。然而,s1.s 和 s2.s 是彼此的别名。因此,像 mov(5, s1.s); 这样的指令也会将 5 存储到 s2.s 中。通常,你使用静态类变量来保持关于整个类的信息,而使用类 var 对象来保持关于特定对象的信息。由于追踪类信息相对较少见,因此你可能会将大多数类数据字段声明在 var 部分。
你还可以创建类的动态实例,并通过指针引用这些动态对象。事实上,这可能是对象存储和访问中最常见的形式。以下代码演示了如何创建指向对象的指针,以及如何动态分配对象的存储:
var
pSC: pointer to sc;
.
.
.
mem.alloc( @size( sc ) );
mov( eax, pSC );
.
.
.
mov( pSC, ebx );
mov( (type sc [ebx]).i, eax );
请注意使用类型转换将 EBX 中的指针强制转换为 sc 类型。
^([135]) 从技术上讲,你也可以在 readonly 部分声明一个对象,但 HLA 不允许你定义类常量,因此在 readonly 部分声明类对象的实用性不大。
12.4 继承
继承是面向对象编程中最基本的概念之一。基本思想是一个类继承或复制某个类的所有字段,然后可能会扩展新数据类型的字段数量。例如,假设你创建了一个描述平面(二维)空间中的点的数据类型 point。该 point 类可能如下所示:
type
point: class
var
x:int32;
y:int32;
method distance;
endclass;
假设你想在 3D 空间中创建一个 point,而不是在 2D 空间中。你可以像下面这样轻松地构建一个数据类型:
type
point3D: class inherits( point )
var
z:int32;
endclass;
class 声明中的 inherits 选项告诉 HLA 将 point 的字段插入到类的开头。在这种情况下,point3D 继承了 point 的字段。HLA 总是将继承的字段放在类对象的开头。稍后你会明白为什么这样做。如果你有一个 point3D 的实例,叫做 P3,那么以下 80x86 指令都是合法的:
mov( P3.x, eax );
add( P3.y, eax );
mov( eax, P3.z );
P3.distance();
请注意,在此示例中调用的 p3.distance 方法调用的是 point.distance 方法。除非你真的想这么做(参见下一节的详细信息),否则你不必为 point3D 类编写一个单独的 distance 方法。就像 x 和 y 字段一样,point3D 对象继承了 point 的方法。
12.5 重写
重写 是将继承类中现有的方法替换为更适合新类的方法的过程。在前一节中出现的 point 和 point3D 示例中,distance 方法(推测)计算的是从原点到指定点的距离。对于一个位于二维平面上的点,你可以使用以下函数来计算距离:
| d = √ x² + y² |
|---|
然而,3D 空间中一个点的距离是通过这个公式给出的:
| d = √ x² + y² + z² |
|---|
很明显,如果你对一个 point3D 对象调用 point 的 distance 函数,你将得到一个错误的结果。然而,在前一节中,你看到 P3 对象调用了从 point 类继承来的 distance 函数。因此,这将产生一个不正确的结果。
在这种情况下,point3D 数据类型必须用一个计算正确值的方法重写 distance 方法。你不能仅仅通过添加 distance 方法原型来重新定义 point3D 类:
type
point3D: class inherits( point )
var
z:int32;
method distance; // This doesn't work!
endclass;
上面 distance 方法声明的问题在于,point3D 已经有一个 distance 方法——它是从 point 类继承过来的。HLA 会抱怨,因为它不喜欢在一个类中有两个同名的方法。
为了解决这个问题,我们需要某种机制,能够重写 point.distance 的声明,并将其替换为 point3D.distance 的声明。为此,你在方法声明之前使用 override 关键字:
type
point3D: class inherits( point )
var
z:int32;
override method distance; // This will work!
endclass;
override 前缀告诉 HLA 忽略 point3D 从 point 类继承了一个名为 distance 的方法。现在,任何通过 point3D 对象调用 distance 方法都会调用 point3D.distance 方法,而不是 point.distance。当然,一旦你使用 override 前缀重写了一个方法,你必须在代码的实现部分提供该方法。例如:
method point3D.distance; @nodisplay;
<< Local declarations for the distance function >>
begin distance;
<< Code to implement the distance function >>
end distance;
12.6 虚方法与静态过程
稍早些时候,本章建议你可以将类方法和类过程视为相同。事实上,这两者之间有一些重大区别(毕竟,如果它们相同,为什么还要有方法呢?)。实际上,方法和过程之间的区别在你想开发面向对象程序时至关重要。方法提供了支持真正多态所需的第二个特性:虚拟过程调用。^([136]) 虚拟过程调用只不过是间接过程调用(使用与对象关联的指针)的一个花哨名称。虚拟过程的关键好处是,当使用指向通用对象的指针时,系统会自动调用正确的方法。
考虑以下使用前面章节中point类的声明:
var
P2: point;
P: pointer to point;
根据上述声明,以下汇编语句都是合法的:
mov( P2.x, eax );
mov( P2.y, ecx );
P2.distance(); // Calls point3D.distance.
lea( ebx, P2 ); // Store address of P2 into P.
mov( ebx, P );
P.distance(); // Calls point.distance.
请注意,HLA 允许你通过指向对象的指针而不是通过对象变量直接调用方法。这是 HLA 中对象的一个关键特性,也是实现虚拟方法调用的关键。
多态和继承背后的魔力在于对象指针是通用的。通常,当你的程序通过指针间接引用数据时,指针的值应该是与该指针相关联的基础数据类型的某个值的地址。例如,如果你有一个指向 16 位无符号整数的指针,通常你不会用这个指针访问一个 32 位有符号整数值。类似地,如果你有一个指向某个记录的指针,通常你不会将该指针强制转换为其他记录类型并访问该类型的字段。^([137]) 然而,对于指向类对象的指针,我们可以稍微放宽这一限制。指向对象的指针可以合法地包含该对象类型的地址或包含该类型字段的任何继承对象的地址。考虑以下使用前面示例中的point和point3D类型的声明:
var
P2: point;
P3: point3D;
p: pointer to point;
.
.
.
lea( ebx, P2 );
mov( ebx, p );
p.distance(); // Calls the point.distance method.
.
.
.
lea( ebx, P3 );
mov( ebx, p ); // Yes, this is semantically legal.
p.distance(); // Surprise, this calls point3D.distance.
因为p是指向point对象的指针,所以直觉上看,p.distance应该调用point.distance方法。然而,方法是多态的。如果你有一个指向某个对象的指针,并且调用与该对象关联的方法,系统会调用与该对象关联的实际(重写的)方法,而不是与指针的类类型专门关联的方法。
类过程与方法在重写过程中行为有所不同。当通过对象指针间接调用类过程时,系统将始终调用与底层类相关联的过程。因此,如果在之前的示例中distance是一个过程而非方法,那么p.distance()的调用将始终调用point.distance,即使p指向的是point3D对象。12.9 构造函数和对象初始化解释了为什么方法和过程是不同的。
^([136]) 多态性字面意思是“多面性”。在面向对象编程中,多态性意味着相同的方法名称,例如distance,可以指代多个不同的方法。
^([137]) 当然,汇编语言程序员经常违反这种规则。暂时假设我们遵守规则,仅通过与指针相关的数据类型访问数据。
12.7 编写类方法和过程
对于类定义中出现的每个类过程和方法原型,程序中必须有一个对应的过程或方法(为了简洁起见,本节将使用例程来指代过程或方法)。如果原型中没有包含external选项,则代码必须出现在与类声明相同的编译单元中。如果原型包含external选项,那么代码可以出现在相同的编译单元中,或者是不同的编译单元中(只要将生成的目标文件与包含类声明的代码链接起来)。像外部(非类)过程一样,如果未提供代码,链接器将在你尝试创建可执行文件时报错。为了减少以下示例的大小,它们将把例程定义在与类声明相同的源文件中。
HLA 类例程必须始终在编译单元中的类声明之后。如果你在一个单独的单元中编译你的例程,类声明仍然必须出现在类例程实现之前(通常通过#include文件)。如果在定义像point.distance这样的例程时尚未定义类,HLA 将无法知道point是一个类,因此也不知道如何处理该例程的定义。
请考虑以下point2D类的声明:
type
point2D: class
const
UnitDistance: real32 := 1.0;
var
x: real32;
y: real32;
static
LastDistance: real32;
method distance
(
fromX: real32;
fromY: real32
); @returns( "st0" );
procedure InitLastDistance;
endclass;
该类的distance函数应计算对象的点到(fromX,fromY)的距离。以下公式描述了该计算:

编写distance方法的第一次尝试可能会产生如下代码:
method point2D.distance( fromX:real32; fromY:real32 ); @nodisplay;
begin distance;
fld( x ); // Note: this doesn't work!
fld( fromX ); // Compute (x-fromX)
fsubp();
fld( st0 ); // Duplicate value on TOS.
fmulp(); // Compute square of difference.
fld( y ); // This doesn't work either.
fld( fromY ); // Compute (y-fromY)
fsubp();
fld( st0 ); // Compute the square of the difference.
fmulp();
faddp();
fsqrt();
end distance;
这段代码看起来应该能够正常工作,对于熟悉面向对象编程语言(如 C++或 Delphi)的人来说。然而,正如注释所指出的,推送x和y变量到 FPU 堆栈的指令不起作用;HLA 不会自动在类的例程中定义与类的数据字段相关的符号。
要学习如何在类的例程中访问类的数据字段,我们需要稍微回顾一下,并讨论一些关于 HLA 类的非常重要的实现细节。为此,请考虑以下变量声明:
var
Origin: point2D;
PtInSpace: point2D;
记住,每当你创建像Origin和PtInSpace这样的两个对象时,HLA 会为这两个对象的x和y数据字段保留存储空间。然而,内存中只有一个point2D.distance方法的副本。因此,如果你调用Origin.distance和PtInSpace.distance,系统会为这两个方法调用调用相同的例程。一旦进入该方法,我们不得不想知道像fld( x );这样的指令会做什么。它如何将x与Origin.x或PtInSpace.x关联起来?更糟糕的是,这段代码如何区分数据字段x和全局对象x?在 HLA 中,答案是,它不会。你不能仅仅像使用普通变量一样通过名字来指定类例程中的数据字段名称。
为了在类的例程中区分Origin.x和PtInSpace.x,HLA 会自动在调用类例程时传递一个指向对象数据字段的指针。因此,你可以通过这个指针间接引用数据字段。HLA 将此对象指针传递给 ESI 寄存器。这是 HLA 生成的代码会在你不注意的情况下修改 80x86 寄存器的少数几个地方之一:每当你调用一个类例程时,HLA 会自动加载 ESI 寄存器,指向对象的地址。显然,你不能指望 ESI 的值在类例程调用之间被保留,也不能通过 ESI 寄存器向类例程传递参数(尽管指定@use esi;来允许 HLA 在设置其他参数时使用 ESI 寄存器是完全合理的)。对于类方法(但不是过程),HLA 还会将 EDI 寄存器加载为类的虚拟方法表的地址。虽然虚拟方法表地址不像对象地址那样重要,但请记住,HLA 生成的代码在你调用类方法或迭代器时会覆盖 EDI 寄存器中的任何值。同样,"EDI"是类方法的@use操作数的一个好选择,因为无论如何 HLA 都会清除 EDI 中的值。
当进入类例程时,ESI 包含一个指向与类相关的(非静态)数据字段的指针。因此,要访问像x和y这样的字段(在我们的point2D示例中),你可以使用如下的地址表达式:
(type point2D [esi]).x
因为你使用 ESI 作为对象数据字段的基地址,所以最好不要在类例程中更改 ESI 的值(或者,至少在你必须使用 ESI 进行其他操作的代码段中保留 ESI 的值)。请注意,在方法内部你不必保留 EDI(除非出于某种原因需要访问虚拟方法表,这种情况不太可能)。
在类的例程中访问数据对象的字段是非常常见的操作,HLA 为此提供了简写符号,用于将 ESI 转换为指向类对象的指针:this。在 HLA 中的类内,保留字this会自动扩展为类似(type classname [esi])的字符串,当然会将适当的类名替换为classname。使用this关键字后,我们可以(正确地)将前面的 distance 方法重写如下:
method point2D.distance( fromX:real32; fromY:real32 ); @nodisplay;
begin distance;
fld( this.x );
fld( fromX ); // Compute (x-fromX).
fsubp();
fld( st0 ); // Duplicate value on TOS.
fmulp(); // Compute square of difference.
fld( this.y );
fld( fromY ); // Compute (y-fromY).
fsubp();
fld( st0 ); // Compute the square of the difference.
fmulp();
faddp();
fsqrt();
end distance;
别忘了,调用类例程会清除 ESI 寄存器中的值。这一点从例程调用的语法中并不明显。特别是在从另一个类例程内部调用某个类例程时,很容易忘记这一点;记住,如果你这样做,内部调用会清除 ESI 中的值,并且从该调用返回时,ESI 将不再指向原始对象。在这种情况下,始终推送和弹出 ESI(或以其他方式保留 ESI 的值)。例如:
.
.
.
fld( this.x ); // esi points at current object.
.
.
.
push( esi ); // Preserve esi across this method call.
*`SomeObject`*.*`SomeMethod`*();
pop( esi );
.
.
.
lea( ebx, this.x ); // esi points at original object here.
this关键字提供了对你在类的var部分声明的类变量的访问。你也可以使用this来调用与当前对象相关的其他类例程。例如:
this.distance( 5.0, 6.0 );
要访问类常量和static数据字段,通常不使用this指针。HLA 将常量和静态数据字段与整个类关联,而不是与特定对象关联(就像类中的static字段一样)。要访问这些类成员,请使用类名代替对象名。例如,要访问point2d类中的UnitDistance常量,可以使用如下语句:
fld( point2D.UnitDistance );
另一个例子是,如果你想在每次计算距离时更新point2D类中的LastDistance字段,可以将point2D.distance方法重写如下:
method point2D.distance( fromX:real32; fromY:real32 ); @nodisplay;
begin distance;
fld( this.x );
fld( fromX ); // Compute (x-fromX).
fsubp();
fld( st0 ); // Duplicate value on TOS.
fmulp(); // Compute square of difference.
fld( this.y );
fld( fromY ); // Compute (y-fromY).
fsubp();
fld( st0 ); // Compute the square of the difference.
fmulp();
faddp();
fsqrt();
fst( point2D.LastDistance ); // Update shared (STATIC) field.
end distance;
下一节将解释为什么在引用常量和静态对象时使用类名,而在访问var对象时使用this。
类方法也是静态对象,因此可以通过指定类名而不是对象名来调用类方法;例如,以下两种写法都是合法的:
Origin.InitLastDistance();
point2D.InitLastDistance();
然而,这两个类过程调用之间有一个微妙的区别。上面的第一个调用会在实际调用 InitLastDistance 过程之前,先将 origin 对象的地址加载到 ESI 寄存器中。第二个调用则是直接调用类过程,而不引用任何对象;因此,HLA 不知道要加载哪个对象地址到 ESI 寄存器中。在这种情况下,HLA 会在调用 InitLastDistance 过程之前,将 NULL(0)加载到 ESI 中。由于可以以这种方式调用类过程,通常最好在你的类过程内部检查 ESI 的值,以验证 HLA 是否包含有效的对象地址。检查 ESI 中的值是确定使用哪种调用机制的好方法。12.9 构造函数和对象初始化讨论了构造函数和对象初始化;接下来,你将看到静态过程和直接调用这些过程(而不是通过对象调用)的好处。
12.8 对象实现
在像 C++ 或 Delphi 这样的高级面向对象语言中,完全有可能掌握对象的使用,而不真正理解机器是如何实现这些对象的。学习汇编语言编程的原因之一是为了完全理解低级实现细节,这样你可以在使用诸如对象之类的编程构造时做出有根据的决策。此外,汇编语言允许你在非常低级别操作数据结构,了解 HLA 如何实现对象可以帮助你创建某些算法,而没有对对象实现的详细了解是无法实现这些算法的。因此,本节及其相应的小节将解释你需要了解的低级实现细节,以便编写面向对象的 HLA 程序。
HLA 实现对象的方式与记录类型非常相似。特别是,HLA 按顺序为类中的所有 var 对象分配存储空间,就像记录类型一样。实际上,如果一个类只包含 var 数据字段,那么该类的内存表示与对应的 record 声明几乎完全相同。考虑从第四章中提取的 student 记录声明和相应的类(参见图 12-1 和图 12-2,分别)。
type
student: record
Name: char[65];
Major: int16;
SSN: char[12];
Midterm1: int16;
Midterm2: int16;
Final: int16;
Homework: int16;
Projects: int16;
endrecord;
student2: class
var
Name: char[65];
Major: int16;
SSN: char[12];
Midterm1: int16;
Midterm2: int16;
Final: int16;
Homework: int16;
Projects: int16;
endclass;

图 12-1. student 记录在内存中的实现

图 12-2. student 类在内存中的实现
如果你仔细观察图 12-1 和图 12-2,你会发现类和记录实现之间的唯一区别是类对象开头包含了VMT(虚拟方法表)指针字段。这个字段在类中始终存在,包含了指向类虚拟方法表的地址,而虚拟方法表又包含了类的所有方法和迭代器的地址。顺便说一下,VMT字段即使类中没有任何方法或迭代器,仍然存在。
正如前面各节所指出的,HLA 不会为对象中的 static 对象分配存储空间。相反,HLA 为每个 static 数据字段分配一个单一实例,所有对象共享该实例。举个例子,考虑以下类和对象声明:
type
tHasStatic: class
var
i:int32;
j:int32;
r:real32;
static
c:char[2];
b:byte;
endclass;
var
hs1: tHasStatic;
hs2: tHasStatic;
图 12-3 展示了这两个对象在内存中的存储分配情况。

图 12-3. 带有 static 数据字段的对象分配
当然,const、val 和 #macro 对象在运行时没有内存要求,因此 HLA 不会为这些字段分配存储空间。像 static 数据字段一样,你可以使用类名或对象名来访问 const、val 和 #macro 字段。因此,即使 tHasStatic 包含这些类型的字段,其内存组织结构仍然与图 12-3 中显示的相同。
除了虚拟方法表(VMT)指针的存在外,方法和过程的存在对对象的存储分配没有影响。当然,与这些例程相关的机器指令确实会出现在内存的某个位置。所以在某种意义上,例程的代码与 static 数据字段非常相似,因为所有对象共享一个例程的单一实例。
12.8.1 虚拟方法表
当 HLA 调用类过程时,它会直接使用 call 指令调用该过程,就像任何正常的过程调用一样。方法则完全不同。系统中的每个对象都携带指向虚拟方法表的指针,虚拟方法表是指向该对象类中所有方法和迭代器的指针数组(见图 12-4)。

图 12-4. 虚拟方法表组织结构
你在类中声明的每个迭代器或方法在虚方法表中都有一个对应的条目。这个双字条目包含了该迭代器或方法的第一条指令的地址。调用类方法或迭代器比调用类过程稍微复杂一些(它需要额外的一条指令并且使用 EDI 寄存器)。下面是一个典型的方法调用序列:
mov( *`ObjectAdrs`*, ESI ); // All class routines do this.
mov( [esi], edi ); // Get the address of the VMT into edi
call( (type dword [edi+n])); // "n" is the offset of the method's
// entry in the VMT.
对于一个给定的类,内存中只有一份虚方法表。这是一个静态对象,因此所有该类类型的对象共享同一份虚方法表。这是合理的,因为同一类类型的所有对象拥有完全相同的方法和迭代器(参见图 12-5)。
尽管 HLA 在遇到类中的方法和迭代器时会构建VMT记录结构,HLA 并不会自动为你创建虚方法表。你必须在程序中显式声明这个表。为此,你需要在程序的static或readonly声明部分中包括如下语句。例如:
readonly
VMT( *`classname`* );

图 12-5. 所有相同类类型的对象共享同一份 VMT。
因为虚方法表中的地址在程序执行期间不应该发生变化,所以readonly部分可能是声明虚方法表的最佳选择。毫无疑问,修改虚方法表中的指针通常是一个非常糟糕的主意。因此,将VMT放入static部分通常不是一个好主意。
如上所示的声明定义了变量classname._VMT_。在 12.9 构造函数与对象初始化中,你将看到在初始化对象变量时需要这个名称。类声明自动将classname._VMT_符号定义为外部静态变量。上面的声明只是为这个外部符号提供了实际的定义。
VMT的声明使用了一种有些奇怪的语法,因为你实际上并不是在声明一个新的符号;你只是为一个你之前通过定义类隐式声明的符号提供数据。也就是说,类声明定义了静态表变量classname._VMT_;你在VMT声明中所做的,仅仅是告诉 HLA 生成该表的实际数据。如果出于某种原因,你想用除了classname._VMT_以外的名称来引用这个表,HLA 确实允许你在上面的声明前添加一个变量名。例如:
readonly
myVMT: VMT( *`classname`* );
在此声明中,myVMT是classname._VMT_的别名。一般来说,你应该避免在程序中使用别名,因为它们使程序更难阅读和理解。因此,你不太可能需要使用这种类型的声明。
与任何其他全局静态变量一样,程序中对于给定类的虚拟方法表应该只有一个实例。最佳的VMT声明位置是在与该类的方法、迭代器和过程代码相同的源文件中(假设它们都出现在同一文件中)。这样,每当你链接该类的例程时,你就会自动链接虚拟方法表。
12.8.2 具有继承的对象表示
到目前为止,关于类对象实现的讨论忽略了继承的可能性。继承仅通过添加类声明中未明确声明的字段,影响对象的内存表示。
将从基类继承的字段添加到另一个类时必须小心。记住,继承自基类字段的类的一个重要属性是,你可以使用指向基类的指针来访问基类的继承字段,即使该指针指向的是其他类的地址(该类继承了基类的字段)。例如,考虑以下类:
type
tBaseClass: class
var
i:uns32;
j:uns32;
r:real32;
method mBase;
endclass;
tChildClassA: class inherits( tBaseClass )
var
c:char;
b:boolean;
w:word;
method mA;
endclass;
tChildClassB: class inherits( tBaseClass )
var
d:dword;
c:char;
a:byte[3];
endclass;
因为 tChildClassA和 tChildClassB都继承了 tBaseClass的字段,这两个子类包含了i、j和r字段以及它们各自的特定字段。此外,每当你有一个基本类型为 tBaseClass的指针变量时,合法地将该指针加载为任何 tBaseClass子类的地址是合理的;因此,将该指针加载为 tChildClassA或 tChildClassB变量的地址是完全合理的。例如:
var
B1: tBaseClass;
CA: tChildClassA;
CB: tChildClassB;
ptr: pointer to tBaseClass;
.
.
.
lea( ebx, B1 );
mov( ebx, ptr );
<< Use ptr >>
.
.
.
lea( eax, CA );
mov( ebx, ptr );
<< Use ptr >>
.
.
.
lea( eax, CB );
mov( eax, ptr );
<< Use ptr >>
因为ptr指向的是tBaseClass类型的对象,你可以合法地(从语义上讲)访问ptr所指向对象的i、j和r字段。但访问tChildClassA或tChildClassB对象的c、b、w或d字段是不合法的,因为在任何时刻,程序可能无法确切知道ptr引用的是哪种类型的对象。
为了使继承正常工作,i、j和r字段在所有子类中的偏移量必须与在 tBaseClass中相同。这样,即使 EBX 指向的是tChildClassA或tChildClassB类型的对象,指令mov((type tBaseClass [ebx]).i, eax)仍能正确访问i字段。图 12-6 显示了子类和基类的布局。
注意,即使两个子类中的新字段名称相同,它们也没有关系(例如,两个子类中的 c 字段并不位于相同的偏移量)。虽然两个子类共享它们从共同的基类继承的字段,但它们添加的任何新字段都是独立且唯一的。如果两个不同类中的字段共享相同的偏移量,那也只是巧合,前提是这些字段不是从共同的基类继承的。

图 12-6. 基类和子类对象在内存中的布局
所有类(即使它们彼此之间没有关系)都会在对象的偏移量 0 处放置指向虚拟方法表的指针。程序中每个类都有一个唯一的虚拟方法表;即使是继承了某个基类字段的类,它们的虚拟方法表通常也与基类的表不同。图 12-7 展示了 tBaseClass、tChildClassA 和 tChildClassB 类型的对象如何指向它们各自的虚拟方法表。

图 12-7. 从对象中引用虚拟方法表
虚拟方法表不过是一个指向与类相关的各种方法和迭代器的指针数组。类中第一个方法或迭代器的地址位于偏移量 0,第二个方法的地址位于偏移量 4,依此类推。你可以使用 @offset 函数来确定给定迭代器或方法的偏移值。如果你想直接调用一个方法(使用 80x86 语法,而不是 HLA 的高级语法),可以使用类似以下的代码:
var
sc: tBaseClass;
.
.
.
lea( esi, sc ); // Get the address of the object (& VMT).
mov( [esi], edi ); // Put address of VMT into edi.
call( (type dword [edi+@offset( tBaseClass.mBase )] );
当然,如果方法有任何参数,你必须在执行上述代码之前将它们推入栈中。直接调用方法时,别忘了必须用对象的地址加载 ESI。方法中的任何字段引用可能都依赖于 ESI 包含该地址。选择使用 EDI 来保存 VMT 地址几乎是任意的。除非你在做一些复杂的操作(比如使用 EDI 获取运行时类型信息),否则可以在这里使用任何你喜欢的寄存器。作为一般规则,当模拟类方法调用时,应该使用 EDI,因为这是 HLA 使用的约定,大多数程序员会预期这种用法。
每当子类从某个基类继承字段时,子类的虚拟方法表也会继承基类表中的条目。例如,类tBaseClass的虚拟方法表只包含一个条目——指向方法tBaseClass.mBase的指针。类tChildClassA的虚拟方法表包含两个条目:指向tBaseClass.mBase和tChildClassA.mA的指针。由于tChildClassB没有定义任何新方法或迭代器,tChildClassB的虚拟方法表只包含一个条目,即指向tBaseClass.mBase方法的指针。请注意,tChildClassB的虚拟方法表与tBaseClass的表是相同的。然而,HLA 会生成两个不同的虚拟方法表。这是一个至关重要的事实,我们稍后会用到它。图 12-8 显示了这些虚拟方法表之间的关系。

图 12-8. 继承类的虚拟方法表
尽管虚拟方法表指针总是出现在对象的偏移量 0 处(因此,如果 ESI 指向一个对象,你可以使用地址表达式 [ESI] 来访问指针),HLA 实际上会在符号表中插入一个符号,这样你就可以象征性地引用虚拟方法表指针。符号_pVMT_(指向虚拟方法表的指针)提供了这个能力。因此,更具可读性的一种访问指针的方式(如前面的代码示例所示)是:
lea( esi, sc );
mov( (type tBaseClass [esi])._pVMT_, edi );
call( (type dword [edi+@offset( tBaseClass.mBase )] );
如果你需要直接访问虚拟方法表,有几种方法可以做到这一点。每当你声明一个类对象时,HLA 会自动在该类中包含一个名为_VMT_的字段。_VMT_是一个静态的双字对象数组。因此,你可以通过类似 classname._VMT_ 的标识符来引用虚拟方法表。通常,你不应该直接访问虚拟方法表,但正如你将很快看到的,确实有一些合理的理由需要知道此对象在内存中的地址。
12.9 构造函数和对象初始化
如果你试图提前写一个使用对象的程序,你可能已经发现,每当你尝试运行它时,程序会莫名其妙地崩溃。到目前为止,我们已经涵盖了本章的大量内容,但你仍然缺少一个关键的信息——如何在使用之前正确初始化对象。本节将为你解开最后一个谜题,并允许你开始编写使用类的程序。
考虑以下对象声明和代码片段:
var
bc: tBaseClass;
.
.
.
bc.mBase();
请记住,你在 var 部分声明的变量在运行时是未初始化的。因此,当包含这些语句的程序执行到 bc.mBase 时,它会执行你已经多次看到的三条语句:
lea( esi, bc);
mov( [esi], edi );
call( (type dword [edi+@offset( tBaseClass.mBase )] );
这个序列的问题在于它将一个未定义的值加载到 EDI 中,假设你没有先前初始化 bc 对象。因为 EDI 包含一个垃圾值,尝试在地址 [EDI+@offset(tBaseClass.mBase)] 调用一个子程序,很可能会导致系统崩溃。因此,在使用对象之前,你必须初始化 _pVMT_ 字段,将该对象的虚拟方法表地址赋值给它。实现这一点的一个简单方法是通过以下语句:
mov( &tBaseClass._VMT_, bc._pVMT_ );
始终记住,在使用对象之前,务必初始化该对象的虚拟方法表指针。
尽管你必须初始化所有使用的对象的虚拟方法表指针,但这可能不是你需要初始化的对象中的唯一字段。每个特定的类可能有其特定的应用程序初始化。虽然初始化可能因类而异,但你需要对每个你使用的特定类的对象执行相同的初始化。如果你创建了一个类的多个对象,那么创建一个程序来为你执行这个初始化可能是个好主意。这是一个非常常见的操作,以至于面向对象的程序员给这些初始化过程起了个特殊的名字:构造函数。
一些面向对象的语言(例如 C++)使用特殊语法来声明构造函数。其他语言(例如 Delphi)则直接使用现有的过程声明来定义构造函数。使用特殊语法的一个优点是语言可以知道何时定义构造函数,并能自动生成代码,在你声明对象时调用该构造函数。像 Delphi 这样的语言要求你显式地调用构造函数;这可能是一个小小的不便,并且可能成为程序缺陷的来源。HLA 不使用特殊语法来声明构造函数:你通过标准的类过程来定义构造函数。因此,你需要在程序中显式地调用构造函数;然而,你将在 12.11 HLA 的 initialize 和 finalize 字符串中看到一种自动化此操作的简单方法。
也许你必须记住的最重要的事实是,构造函数必须是类过程。你不能将构造函数定义为方法。原因非常简单:构造函数的一个任务是初始化指向虚拟方法表的指针,而你不能在初始化 VMT 指针之前调用类方法或迭代器。因为类过程不使用虚拟方法表,所以你可以在初始化对象的 VMT 指针之前调用类过程。
按惯例,HLA 程序员使用 create 作为类构造函数的名称。虽然没有要求你必须使用这个名称,但这样做可以使你的程序更易于阅读,并且其他程序员也能更轻松地理解。
如你所记得,你可以通过对象引用或类引用调用类过程。例如,如果 clsProc 是 tClass 类的类过程,而 Obj 是 tClass 类型的对象,那么以下两个类过程调用都是合法的。
tClass.clsProc();
Obj.clsProc();
这两个调用之间有很大区别。第一个调用时,clsProc 的 ESI 中包含 0(NULL),而第二个调用会在调用之前将 Obj 的地址加载到 ESI 中。我们可以利用这一点来确定方法内部的具体调用机制。
12.9.1 构造函数中的动态对象分配
事实证明,大多数程序都使用 mem.alloc 动态分配对象,并通过指针间接引用这些对象。这增加了初始化过程中的一步——为对象分配存储。构造函数是分配存储的最佳位置。因为你可能不需要动态分配所有对象,所以你需要两种类型的构造函数:一种是分配存储并初始化对象,另一种则是初始化已经有存储的对象。
另一种构造函数的惯例是将这两种构造函数合并为一个,并通过 ESI 中的值来区分构造函数调用的类型。在进入类的 create 过程时,程序检查 ESI 中的值,看看它是否包含 NULL(0)。如果是,构造函数会调用 mem.alloc 来为对象分配存储并将指针返回到 ESI。如果进入过程时 ESI 不包含 NULL,那么构造函数就认为 ESI 指向一个有效的对象,并跳过内存分配的语句。最起码,构造函数会初始化指向虚拟方法表的指针;因此,最简单的构造函数看起来像下面这样:
procedure tBaseClass.create; @nodisplay;
begin create;
if( ESI = 0 ) then
push( eax ); // mem.alloc returns its result here, so save it.
mem.alloc( @size( tBaseClass ));
mov( eax, esi ); // Put pointer into esi.
pop( eax );
endif;
// Initialize the pointer to the VMT:
// Remember, "this" is shorthand for "(type tBaseClass [esi])".
mov( &tBaseClass._VMT_, this._pVMT_ );
// Other class initialization would go here.
end create;
在编写像上面这样的构造函数后,你可以根据对象存储是否已经分配来选择合适的调用机制。对于预分配的对象(比如在 var、static 或 storage 部分声明的对象^([138]),或那些你通过 mem.alloc 预先分配存储的对象),你只需将对象的地址加载到 ESI 中并调用构造函数。对于你声明为变量的对象,这很简单;只需调用合适的 create 构造函数:
var
bc0: tBaseClass;
bcp: pointer to tBaseClass;
.
.
.
bc0.create(); // Initializes preallocated bc0 object.
.
.
.
// Allocate storage for bcp object.
mem.alloc( @size( tBaseClass ));
mov( eax, bcp );
.
.
.
bcp.create(); // Initializes preallocated bcp object.
请注意,虽然bcp是一个指向tBaseClass对象的指针,但create方法并不会自动为此对象分配存储空间。程序已经在之前分配了存储空间。因此,当程序调用bcp.create时,它会加载 ESI,其中包含在bcp内的地址;因为这不是 NULL,所以tBaseClass.create过程不会为新对象分配存储空间。顺便说一句,对bcp.create的调用会发出以下一系列机器指令:
mov( bcp, esi );
call tBaseClass.create;
到目前为止,关于类过程调用的代码示例总是以lea指令开始。这是因为到目前为止的所有示例都使用了对象变量,而不是对象变量的指针。请记住,类过程(方法)调用会将对象的地址传递给 ESI 寄存器。对于对象变量,HLA 会发出lea指令以获取此地址。然而,对于对象的指针,实际的对象地址是指针变量的值;因此,为了将对象的地址加载到 ESI 中,HLA 会发出一个mov指令,将指针的值复制到 ESI 寄存器中。
在前面的示例中,程序在调用对象构造函数之前预先分配了对象的存储空间。虽然有多种预分配对象存储空间的原因(例如,创建对象的动态数组),但您可以通过调用标准的create过程(例如,如果 ESI 包含NULL,则分配对象存储空间的过程)来实现大多数简单的对象分配,就像上面的示例一样。以下示例演示了这一点:
var
bcp2: pointer to tBaseClass;
.
.
.
tBaseClass.create(); // Calls create with esi=NULL.
mov( esi, bcp2 ); // Save pointer to new class object in bcp2.
请记住,对于tBaseClass.create构造函数的调用会将新对象的指针存储在 ESI 寄存器中。调用者有责任将此函数返回的指针保存到适当的指针变量中;构造函数不会自动完成此操作。同样,当应用程序结束使用对象时,调用者有责任释放与该对象关联的存储空间(请参阅 12.10 析构函数中对析构函数的讨论)。
12.9.2 构造函数与继承
继承自基类的派生(子)类构造函数代表了一种特殊情况。每个类必须有自己的构造函数,但需要调用基类的构造函数。本节解释了这一点的原因以及如何实现。
派生类会继承其基类的create过程。但是,在派生类中必须重写此过程,因为派生类可能需要比基类更多的存储空间,因此可能需要使用不同的调用来为动态对象分配存储空间。因此,派生类不重写create过程是非常不寻常的。
然而,重写基类的create过程本身有一些问题。当你重写基类的create过程时,你就承担了初始化(整个)对象的全部责任,包括基类所需的所有初始化。至少,这涉及将重复的代码放入重写的过程,以处理通常由基类构造函数完成的初始化。除了使程序变大(通过重复基类构造函数中已经存在的代码)外,这还违反了信息隐藏原则,因为派生类必须了解基类中的所有字段(包括那些逻辑上属于基类的私有字段)。我们在这里需要的是能够从派生类的构造函数中调用基类的构造函数,并让这个调用来完成基类字段的底层初始化。幸运的是,这在 HLA 中是一个容易实现的操作。
考虑以下类声明(这些做法比较繁琐):
type
tBase: class
var
i:uns32;
j:int32;
procedure create(); @returns( "esi" );
endclass;
tDerived: class inherits( tBase );
var
r: real64;
override procedure create(); @returns( "esi" );
endclass;
procedure tBase.create; @nodisplay;
begin create;
if( esi = 0 ) then
push( eax );
mov( mem.alloc( @size( tBase )), esi );
pop( eax );
endif;
mov( &tBase._VMT_, this._pVMT_ );
mov( 0, this.i );
mov( −1, this.j );
end create;
procedure tDerived.create; @nodisplay;
begin create;
if( esi = 0 ) then
push( eax );
mov( mem.alloc( @size( tDerived )), esi );
pop( eax );
endif;
// Initialize the VMT pointer for this object:
mov( &tDerived._VMT_, this._pVMT_ );
// Initialize the "r" field of this particular object:
fldz();
fstp( this.r );
// Duplicate the initialization required by tBase.create:
mov( 0, this.i );
mov( −1, this.j );
end create;
让我们仔细看看上面的tDerived.create过程。像常规构造函数一样,它首先检查 ESI,并在 ESI 为 NULL 时分配新对象的存储空间。注意,tDerived对象的大小包括继承字段所需的大小,因此这为 tDerived对象中的所有字段正确地分配了必要的存储空间。
接下来,tDerived.create过程初始化对象的VMT指针字段。记住,每个类都有自己的虚方法表,特别是派生类不会使用基类的虚方法表。因此,这个构造函数必须使用tDerived 虚方法表的地址来初始化_pVMT_字段。
在初始化虚方法表指针后,tDerived构造函数将r字段的值初始化为 0.0(记住,fldz会将 0 加载到 FPU 堆栈中)。这就完成了tDerived特有的初始化。
tDerived.create中的其余指令是问题所在。这些语句重复了tBase.create过程中的一些代码。代码重复的问题在你决定修改这些字段的初始值时变得明显;如果你在派生类中重复了初始化代码,你需要在多个create过程中修改初始化代码。然而,这通常会导致派生类create过程中的缺陷,尤其是当这些派生类出现在与基类不同的源文件中时。
将基类初始化隐藏在派生类构造函数中的另一个问题是违反了信息隐藏原则。基类的一些字段可能是逻辑上私有的。尽管 HLA 并不显式支持类中的公有和私有字段概念(比如 C++那样),但是严谨的程序员仍然会将字段划分为私有或公有,并且仅在属于该类的类例程中使用私有字段。在派生类中初始化这些私有字段是这些程序员不能接受的。这样做会使得日后更改基类的定义和实现变得非常困难。
幸运的是,HLA 提供了一个简单的机制来在派生类的构造函数中调用继承的构造函数。你只需使用类名语法调用基类构造函数;例如,你可以直接从 tDerived.create中调用tBase.create。通过调用基类构造函数,你的派生类构造函数可以在不关心基类的确切实现(或初始值)的情况下初始化基类字段。
不幸的是,每个(传统的)构造函数都有两种初始化方式会影响你调用基类构造函数的方式:所有传统的构造函数都会在 ESI 为 0 时为类分配内存,所有传统的构造函数都会初始化VMT指针。幸运的是,处理这两个问题非常简单。
基类对象所需的内存通常比你从基类派生出的类对象所需的内存要少(因为派生类通常会增加更多的字段)。因此,当你从派生类的构造函数内部调用基类构造函数时,你不能允许基类构造函数分配存储空间。你可以通过在派生类构造函数内检查 ESI 并在调用基类构造函数之前分配所需的存储空间来轻松解决这个问题。
第二个问题是VMT指针的初始化。当你调用基类的构造函数时,它会用基类虚拟方法表的地址初始化VMT指针。然而,派生类对象的_pVMT_字段必须指向派生类的虚拟方法表。调用基类构造函数将始终使用错误的指针初始化_pVMT_字段。为了正确地将适当的值初始化到_pVMT_字段,派生类构造函数必须在调用基类构造函数之后将派生类虚拟方法表的地址存储到_pVMT_字段中(这样它会覆盖基类构造函数写入的值)。
tDerived.create构造函数,重写为调用tBase.create构造函数,如下所示:
procedure tDerived.create; @nodisplay;
begin create;
if( esi = 0 ) then
push( eax );
mov( mem.alloc( @size( tDerived )), esi );
pop( eax );
endif;
// Call the base class constructor to do any initialization
// needed by the base class. Note that this call must follow
// the object allocation code above (so esi will always contain
// a pointer to an object at this point and tBase.create will
// never allocate storage).
(type tBase [esi]).create();
// Initialize the VMT pointer for this object. This code
// must always follow the call to the base class constructor
// because the base class constructor also initializes this
// field and we don't want the initial value supplied by
// tBase.create.
mov( &tDerived._VMT_, this._pVMT_ );
// Initialize the "r" field of this particular object:
fldz();
fstp( this.r );
end create;
此解决方案解决了所有关于派生类构造函数的上述问题。请注意,调用基类构造函数使用的语法是(type tBase [esi]).create();而不是tBase.create();。直接调用tBase.create的问题在于它会将NULL加载到 ESI 中,并覆盖在tDerived.create中分配的存储的指针。上述方案在调用tBase.create时使用了 ESI 中现有的值。
12.9.3 构造函数参数和过程重载
到目前为止,所有的构造函数示例都没有任何参数。然而,构造函数没有任何特殊之处,不妨碍使用参数。构造函数是过程,因此您可以指定任意数量和任意类型的参数。您可以使用这些参数值来初始化特定字段或控制构造函数如何初始化字段。当然,您可以将构造函数参数用于任何您在任何其他过程中使用参数的目的。事实上,唯一需要关注的问题就是在有派生类时使用参数的情况。本节就是处理这些问题的。
派生类构造函数中参数的第一个,可能也是最重要的问题实际上适用于所有被重写的过程和方法:重写的例程的参数列表必须完全匹配基类中相应例程的参数列表。事实上,HLA 甚至不允许你违反这个规则,因为override例程的原型不允许参数列表声明:它们自动继承基例程的参数列表。因此,你不能在一个类的构造函数原型中使用特殊的参数列表,而在基类或派生类中出现的构造函数中使用不同的参数列表。有时候如果情况不是这样会更好,但 HLA 不支持这种做法是有一些合理和逻辑上的原因的。^([139])
HLA 支持特殊的overloads声明,允许您使用单个标识符调用多个不同的过程、方法或迭代器(参数类型的数量决定调用哪个函数)。例如,这允许您为给定类(或派生类)创建多个构造函数,并使用匹配该构造函数的参数列表调用所需的构造函数。感兴趣的读者应查阅 HLA 文档中有关过程章节的详细信息以了解overloads声明。
^([138]) 通常不会在readonly部分声明对象,因为无法初始化它们。
^([139]) 调用虚拟方法和迭代器将是一个真正的问题,因为你无法确切知道一个指针引用的是哪个例程。因此,你无法知道正确的参数列表。虽然过程的问题并不像构造函数那样严重,但如果基类或派生类允许重写具有不同参数列表的过程,可能会有一些微妙的问题出现在你的代码中。
12.10 析构函数
析构函数是一个类例程,在程序使用完对象后负责清理该对象。与构造函数类似,HLA 没有提供用于创建析构函数的特殊语法,也不会自动调用析构函数。与构造函数不同,析构函数通常是方法而不是过程(因为虚拟析构函数是有意义的,而虚拟构造函数则没有)。
一个典型的析构函数可能会关闭对象打开的任何文件,释放在使用该对象过程中分配的内存,最后,如果对象是动态创建的,还会释放对象本身。析构函数还会处理对象在停止存在之前所需的任何其他清理工作。
按照惯例,大多数 HLA 程序员将他们的析构函数命名为destroy。大多数析构函数通常有的代码就是释放与对象相关联的存储空间。以下析构函数演示了如何做到这一点:
procedure tBase.destroy; @nodisplay;
begin destroy;
push( eax ); // isInHeap uses this.
// Place any other cleanup code here.
// The code to free dynamic objects should always appear last
// in the destructor.
/*************/
// The following code assumes that esi still contains the address
// of the object.
if( mem.isInHeap( esi )) then
free( esi);
endif;
pop( eax );
end destroy;
HLA 标准库例程mem.isInHeap如果其参数是mem.alloc返回的地址,则返回 true。因此,如果程序最初通过调用mem.alloc为对象分配了存储空间,则此代码会自动释放与对象相关联的存储空间。显然,从此方法调用返回后,如果你动态分配了对象,ESI 将不再指向内存中一个合法的对象。请注意,如果对象不是你之前通过调用mem.alloc分配的,这段代码将不会影响 ESI 中的值,也不会修改对象。
12.11 HLA 的_initialize_和_finalize_字符串
尽管 HLA 不会自动调用与你的类相关的构造函数和析构函数,但 HLA 提供了一种机制,你可以强制 HLA 自动发出这些调用:通过使用_initialize_和_finalize_这两个编译时字符串变量(即val常量),HLA 会在每个过程的自动声明中使用它们。
每当你编写一个过程、迭代器或方法时,HLA 会在该例程中自动声明多个本地符号。其中两个符号是_initialize_和_finalize_。HLA 会如下声明这些符号:
val
_initialize_: string := "";
_finalize_: string := "";
HLA 会在例程主体的最开始位置发出_initialize_字符串,即在例程的begin子句之后。^([140]) 同样,HLA 会在例程主体的最后发出_finalize_字符串,紧接着end子句之前。这与以下内容类似:
procedure *`SomeProc`*;
<< declarations >>
begin *`SomeProc`*;
@text( _initialize_ );
<< Procedure body >>
@text( _finalize_ );
end *`SomeProc`*;
由于 _initialize_ 和 _finalize_ 最初包含空字符串,除非你在 begin 子句之前显式修改 _initialize_ 的值,或者在过程的 end 子句之前修改 _finalize_,否则这些扩展对 HLA 生成的代码没有影响。所以,如果你修改这些字符串对象之一以包含机器指令,HLA 将会在过程的开始或结束时编译该指令。以下示例演示了如何使用这种技术:
procedure *`SomeProc`*;
?_initialize_ := "mov( 0, eax );";
?_finalize_ := "stdout.put( eax );";
begin *`SomeProc`*;
// HLA emits "mov( 0, eax );" here in response to the _initialize_
// string constant.
add( 5, eax );
// HLA emits "stdout.put( eax );" here.
end *`SomeProc`*;
当然,这些示例并不会为你节省太多时间。与其将包含这些语句的字符串分配给 _initialize_ 和 _finalize_ 编译时变量,不如直接在过程的开始和结束时输入实际的语句。然而,如果我们能够自动化将某些字符串分配给这些变量,这样我们就不需要在每个过程里显式地分配它们,那么这个功能可能会变得有用。稍后你将看到我们如何自动化将值分配给 _initialize_ 和 _finalize_ 字符串。暂时考虑这样一种情况:我们将构造函数的名称加载到 _initialize_ 字符串中,并将析构函数的名称加载到 _finalize_ 字符串中。这样,例程将“自动”调用该特定对象的构造函数和析构函数。
上一个示例有一个小问题。如果我们能够自动化地将某些值分配给 _initialize_ 或 _finalize_,那么如果这些变量已经包含一些值会发生什么呢?例如,假设我们在一个例程中使用了两个对象,第一个对象将其构造函数的名称加载到 _initialize_ 字符串中;当第二个对象尝试做同样的事情时会发生什么?解决方案很简单:不要直接将任何字符串分配给 _initialize_ 或 _finalize_ 编译时变量;相反,总是将你的字符串连接到这些变量中现有字符串的末尾。以下是对上述示例的修改,演示了如何做到这一点:
procedure *`SomeProc`*;
?_initialize_ := _initialize_ + "mov( 0, eax );";
?_finalize_ := _finalize_ + "stdout.put( eax );";
begin *`SomeProc`*;
// HLA emits "mov( 0, eax );" here in response to the _initialize_
// string constant.
add( 5, eax );
// HLA emits "stdout.put( eax );" here.
end *`SomeProc`*;
当你将值赋给_initialize_和_finalize_字符串时,HLA 保证_initialize_序列会在进入例程时执行。遗憾的是,_finalize_字符串在退出时并没有相同的保证。HLA 仅在例程的末尾发出_finalize_字符串的代码,在清理激活记录并返回代码之前。遗憾的是,“从例程的末尾退出”并不是你从该例程返回的唯一方式。你也可以通过执行ret指令在代码的中间某个地方显式返回。由于 HLA 仅在例程的末尾发出_finalize_字符串,因此通过这种方式从例程返回会绕过_finalize_代码。遗憾的是,除了手动发出_finalize_代码外,你别无他法。^([141]) 幸运的是,这种退出例程的机制完全由你掌控。如果你从不通过除“从例程末尾退出”以外的方式退出例程,那么你就无需担心这个问题(请注意,如果你确实想在代码的中间某处返回,你可以使用exit控制结构将控制转移到例程的末尾)。
另一种不幸的是你无法控制的提前退出例程的方式是通过引发异常。你的例程可能会调用其他例程(例如,标准库中的某个例程),该例程会引发异常并立即将控制转移到调用你例程的地方。幸运的是,你可以通过在你的过程里放置try..endtry块来轻松捕获并处理异常。以下是一个演示的示例:
procedure *`SomeProc`*;
<< Declarations that modify _initialize_ and _finalize_ >>
begin *`SomeProc`*;
<< HLA emits the code for the _initialize_ string here. >>
try // Catch any exceptions that occur:
<< Procedure body goes here. >>
anyexception
push( eax ); // Save the exception #.
@text( _finalize_ ); // Execute the _finalize_ code here.
pop( eax ); // Restore the exception #.
raise( eax ); // Reraise the exception.
endtry;
<< HLA automatically emits the _finalize_ code here. >>
end *`SomeProc`*;
尽管前面的代码处理了与_finalize_相关的一些问题,但它并不能处理所有可能的情况。你应该始终留意程序是否可能在未执行_finalize_字符串中的代码的情况下意外退出某个例程。如果遇到这种情况,你应当明确地扩展_finalize_。
有一个重要的地方你可能会遇到异常问题:在常规为_initialize_字符串生成的代码中。如果你修改_initialize_字符串,使它包含一个构造函数调用,并且该构造函数的执行引发了异常,这可能会导致该常规提前退出,而没有执行相应的_finalize_代码。你可以把try..endtry语句直接嵌入到_initialize_和_finalize_字符串中,但这种方法存在几个问题,其中一个最严重的问题是你调用的第一个构造函数可能引发异常,将控制权转交给异常处理程序,该程序会调用该常规中所有对象的析构函数(包括那些你还未调用构造函数的对象)。尽管没有一个单一的解决方案可以处理所有问题,但最好的方法可能是在每个构造函数调用中加入try..endtry块,前提是该构造函数可能引发某些可以处理的异常(即,不需要立即终止程序的异常)。
到目前为止,关于_initialize_和_finalize_的讨论并没有涉及一个重要问题:为什么要使用这个功能来实现“自动”调用构造函数和析构函数,因为它显然涉及比直接调用构造函数和析构函数更多的工作?显然,必须有一种方法可以自动化分配_initialize_和_finalize_字符串,否则这一部分就不会存在。实现这一点的方法是使用宏来定义类类型。所以,现在是时候看看另一个使这种活动自动化的 HLA 特性:forward关键字。
你已经看到如何使用forward保留字来创建过程原型(参见 5.9 Forward Procedures 的讨论);事实上,你还可以声明前向的const、val、type和变量声明。这些声明的语法如下:
*`ForwardSymbolName`*: forward( *`undefinedID`* );
这个声明完全等同于以下内容:
?*`undefinedID`*: text := "*`ForwardSymbolName`*";
特别注意,这个扩展并没有真正定义符号ForwardSymbolName。它只是将该符号转换为一个字符串,并将这个字符串赋值给指定的text对象 undefinedID。
现在你可能在想,像上面这样怎么能等同于前向声明呢?事实上,它并不是。不过,前向声明允许你创建宏,通过延迟对象类型的实际声明,直到代码中的某个后续位置,从而模拟类型名称。考虑下面的例子:
type
myClass: class
var
i:int32;
procedure create; @returns( "esi" );
procedure destroy;
endclass;
#macro _myClass: varID;
forward( varID );
?_initialize_ := _initialize_ + @string:varID + ".create(); ";
?_finalize_ := _finalize_ + @string:varID + ".destroy(); ";
varID: myClass
#endmacro;
注意,这一点非常重要,在这个宏的末尾,varID: myClass声明后并没有分号。稍后你会明白为什么没有这个分号。
如果你的程序中有上述类和宏声明,你现在可以声明类型为 _myClass 的变量,这些变量会在常规进入和退出时自动调用构造函数和析构函数。要了解具体如何操作,请查看以下过程外壳:
procedure HasmyClassObject;
var
mco: _myClass;
begin HasmyClassObject;
<< Do stuff with mco here. >>
end HasmyClassObject;
因为 _myClass 是一个宏,上面的过程在编译时会扩展为以下文本:
procedure HasmyClassObject;
var
mco: // Expansion of the _myClass macro:
forward( _0103_ ); // _0103_ symbol is an HLA-supplied text
// symbol that expands to "mco".
?_initialize_ := _initialize_ + "mco" + ".create(); ";
?_finalize_ := _finalize_ + "mco" + ".destroy(); ";
mco: myClass;
begin HasmyClassObject;
mco.create(); // Expansion of the _initialize_ string.
<< Do stuff with mco here. >>
mco.destroy(); // Expansion of the _finalize_ string.
end HasmyClassObject;
你可能会注意到,在上面的例子中,mco: myClass 声明后面出现了一个分号。这个分号实际上并不是宏的一部分,而是原始代码中的 mco: _myClass; 声明后面的分号。
如果你想创建一个对象数组,你可以合法地将该数组声明如下:
var
mcoArray: _myClass[10];
由于 _myClass 宏中的最后一条语句没有以分号结尾,所以上面的声明会扩展为类似以下的(几乎正确的)代码:
mcoArray: // Expansion of the _myClass macro:
forward( _0103_ ); // _0103_ symbol is an HLA-supplied text
// symbol that expands to "mcoArray".
?_initialize_ := _initialize_ + "mcoArray" + ".create(); ";
?_finalize_ := _finalize_ + "mcoArray" + ".Destroy(); ";
mcoArray: myClass[10];
这个扩展的唯一问题是它仅对数组的第一个对象调用构造函数。解决这个问题有几种方法;一种方法是在 _initialize_ 和 _finalize_ 的末尾附加一个宏名称,而不是构造函数名称。这个宏将检查对象的名称(例如本例中的 mcoArray),以确定它是否是一个数组。如果是,宏可以扩展为一个循环,依次调用数组中每个元素的构造函数。
解决这个问题的另一个方法是使用宏参数来指定 myClass 数组的维度。这种方案比上面的方案更容易实现,但它的缺点是需要为对象数组声明使用不同的语法(你必须使用圆括号而不是方括号来表示数组维度)。
forward 指令非常强大,可以让你实现各种技巧。然而,也有一些问题需要注意。首先,由于 HLA 会透明地生成 _initialize_ 和 _finalize_ 代码,如果这些字符串中的代码出现错误,你可能会很容易感到困惑。如果你开始收到与常规 begin 或 end 语句相关的错误消息,你可能需要查看该常规中的 _initialize_ 和 _finalize_ 字符串。最好的防御措施是始终在这些字符串后追加非常简单的语句,这样可以减少出现错误的可能性。
从根本上说,HLA 不支持自动调用构造函数和析构函数。本节介绍了几种技巧,尝试自动化调用这些常规。然而,这种自动化并不完美,实际上,之前提到的 _finalize_ 字符串问题限制了这种方法的适用性。本节介绍的机制可能适用于简单的类和程序。一条建议值得遵循:如果你的代码复杂或对正确性要求很高,最好手动显式地调用构造函数和析构函数。
^([140])如果例程自动发出代码来构建激活记录,HLA 会在构建激活记录的代码之后发出_initialize_的文本。
^([141])注意,你可以使用语句@text( _finalize_ );手动发出_finalize_代码。
12.12 抽象方法
抽象基类是一个仅存在于为其派生类提供一组公共字段的类。你永远不会声明类型为抽象基类的变量;你总是使用派生类中的一个。抽象基类的目的是为创建其他类提供模板,仅此而已。事实证明,标准基类和抽象基类之间的语法唯一的区别就是至少有一个抽象方法声明。抽象方法是一种特殊的方法,在抽象基类中没有实际的实现。任何调用该方法的尝试都会引发异常。如果你想知道抽象方法有什么好处,继续阅读。
假设你想创建一组类来保存数字值。一类可以表示无符号整数,另一类可以表示有符号整数,第三类可以实现 BCD 值,第四类可以支持real64值。虽然你可以创建四个独立的类,它们相互之间不依赖,但这样做错失了让这组类更方便使用的机会。要理解为什么,考虑以下可能的类声明:
type
uint: class
var
TheValue: dword;
method put;
<< Other methods for this class >>
endclass;
sint: class
var
TheValue: dword;
method put;
<< Other methods for this class >>
endclass;
r64: class
var
TheValue: real64;
method put;
<< Other methods for this class >>
endclass;
这些类的实现并不不合理。它们有用于数据的字段,并且有一个put方法(可以推测,该方法将数据写入标准输出设备)。它们可能还有其他方法和过程来实现对数据的各种操作。然而,这些类存在两个问题,一个是小问题,一个是大问题,两个问题都发生在这些类没有从公共基类继承任何字段的情况下。
第一个问题,相对较小,是你必须在这些类中重复声明几个公共字段。例如,put方法的声明出现在每个类中。^([142])这种重复的工作导致程序更难维护,因为它没有鼓励你为公共函数使用一个共同的名称,因为在每个类中使用不同的名称很容易。
这种方法的一个更大问题是它不具有通用性。也就是说,你不能创建指向“数字”对象的通用指针,并对该值执行加法、减法和输出等操作(无论底层的数字表示如何)。
我们可以通过将之前的类声明转换为一组派生类来轻松解决这两个问题。以下代码演示了一种简单的方法:
type
numeric: class
method put;
<< Other common methods shared by all the classes >>
endclass;
uint: class inherits( numeric )
var
TheValue: dword;
override method put;
<< Other methods for this class >>
endclass;
sint: class inherits( numeric )
var
TheValue: dword;
override method put;
<< Other methods for this class >>
endclass;
r64: class inherits( numeric )
var
TheValue: real64;
override method put;
<< Other methods for this class >>
endclass;
该方案解决了这两个问题。首先,通过继承 put 方法于 numeric 类,这段代码鼓励派生类始终使用 put 这个名称,从而使得程序更易于维护。其次,由于这个示例使用了派生类,因此可以创建指向 numeric 类型的指针,并将该指针加载到 uint、sint 或 r64 对象的地址中。该指针可以调用 numeric 类中的方法执行加法、减法或数字输出等功能。因此,使用此指针的应用程序不需要知道具体的数据类型,只需以通用方式处理数值。
这个方案的一个问题是,可能声明并使用 numeric 类型的变量。不幸的是,这种 numeric 变量无法表示任何类型的数字(请注意,numeric 字段的实际数据存储实际上出现在派生类中)。更糟的是,由于你在 numeric 类中声明了 put 方法,实际上你必须编写代码来实现这个方法,即使你根本不应该调用它;实际的实现应该仅发生在派生类中。虽然你可以编写一个虚拟方法来打印错误消息(或者更好的是,抛出异常),但实际上不应该需要编写像这样的“虚拟”过程。幸运的是,不需要这样做——如果你使用 abstract 方法的话。
abstract 关键字,当它跟在方法声明后面时,告诉 HLA 你不会为该类提供方法的实现。相反,所有派生类有责任为抽象方法提供具体实现。如果你尝试直接调用一个抽象方法,HLA 会抛出异常。以下是将 put 转为抽象方法的 numeric 类的修改:
type
numeric: class
method put; abstract;
<< Other common methods shared by all the classes >>
endclass;
抽象基类是指至少有一个抽象方法的类。请注意,你不需要将所有方法都声明为抽象方法,在抽象基类中声明一些标准方法(当然,提供它们的实现)是完全合法的。
抽象方法声明提供了一种机制,通过该机制,基类可以指定一些派生类必须实现的通用方法。从理论上讲,所有派生类必须提供所有抽象方法的具体实现,否则这些派生类本身也将是抽象基类。实际上,可以稍微打破规则,将抽象方法用于略有不同的目的。
稍早前,你已经阅读到,不应该创建类型为抽象基类的变量。如果你尝试执行一个抽象方法,程序会立即抛出异常,抱怨这是一个非法的方法调用。实际上,实际上你是可以声明抽象基类类型的变量,并且如果不调用该类中的任何抽象方法,程序也不会出问题。
^([142]) 顺便提一下,TheValue 不是一个常见字段,因为这个字段在 r64 类中有不同的类型。
12.13 运行时类型信息
在使用对象变量时(与指向对象的指针不同),该对象的类型是显而易见的:它是变量声明的类型。因此,在编译时和运行时,程序都知道对象的类型。使用指向对象的指针时,一般情况下无法确定指针引用的对象类型。然而,在运行时,可以确定对象的实际类型。本节讨论如何检测底层对象的类型以及如何使用这些信息。
如果你有一个指向对象的指针,并且该指针的类型是某个基类类型,那么在运行时该指针可能指向基类对象或任何派生类对象。在编译时,无法确定某一时刻对象的确切类型。为了理解这一点,考虑以下简短示例:
*`ReturnSomeObject`*(); // Returns a pointer to some class in esi.
mov( esi, *`ptrToObject`* );
例程 ReturnSomeObject 返回指向 ESI 中某个对象的指针。这可能是某个基类对象的地址,也可能是某个派生类对象的地址。在编译时,程序无法知道该函数返回的对象类型。例如,ReturnSomeObject 可能会询问用户返回什么值,因此直到程序实际运行并且用户做出选择后,确切的类型才能确定。
在一个设计完善的程序中,可能不需要知道一个通用对象的实际类型。毕竟,面向对象编程和继承的整个目的就是制作通用程序,能够与许多不同类型的对象一起工作,而无需对程序做出重大修改。然而,在现实世界中,程序可能没有完美的设计,有时知道指针引用的对象的确切类型会很有帮助。运行时类型信息(RTTI)使你能够在运行时确定对象的类型,即使你是通过指向该对象基类的指针来引用该对象的。
也许你需要的最基本的 RTTI 操作是能够判断一个指针是否包含某种特定对象类型的地址。许多面向对象语言(例如 Delphi)提供了一个is运算符来实现这一功能。is是一个布尔运算符,当其左操作数(一个指针)指向一个类型与右操作数(必须是类型标识符)匹配的对象时,它返回 true。典型的语法通常如下所示:
*`ObjectPointerOrVar`* is *`ClassType`*
如果变量属于指定的类,则该运算符返回 true;否则返回 false。以下是该运算符的典型使用示例(在 Delphi 语言中):
if( *`ptrToNumeric`* is uint ) then begin
.
.
.
end;
在 HLA 中实现这个功能实际上非常简单。如你所记得,每个类都会有自己的虚方法表。每当你创建一个对象时,必须将指向虚方法表的指针初始化为该类虚方法表的地址。因此,给定类类型的所有对象的VMT指针字段都包含相同的指针值,而这个指针值与其他所有类的VMT指针字段不同。我们可以利用这一点来判断一个对象是否是某个特定类型。下面的代码演示了如何在 HLA 中实现上述 Delphi 语句:
mov( *`ptrToNumeric`*, esi );
if( (type uint [esi])._pVMT_ = &uint._VMT_ ) then
.
.
.
endif;
这个if语句只是将对象的_pVMT_字段(指向虚方法表的指针)与所需类的虚方法表地址进行比较。如果它们相等,则ptrToNumeric变量指向类型为uint的对象。
在类方法或迭代器的主体内,有一种稍微简单的方法来判断对象是否属于某个特定类。记住,在进入方法或迭代器时,EDI 寄存器包含虚方法表的地址。因此,假设你没有修改 EDI 的值,你可以通过使用如下的if语句轻松测试方法或迭代器是否属于某个特定类类型:
if( edi = &uint._VMT_ ) then
.
.
.
endif;
然而,请记住,EDI 只会在你调用类方法时包含虚方法表的指针。在调用类过程时,情况并非如此。
12.14 调用基类方法
在构造函数部分,你看到可以在派生类的重写过程内调用祖先类的过程。要做到这一点,你只需使用调用(type classname [esi]).procedureName( parameters );来调用该过程。有时,你可能希望对类的方法和过程进行相同的操作(即,让重写的方法调用相应的基类方法,以执行一些你不想在派生类方法中重复的计算)。不幸的是,HLA 不允许你像调用过程那样直接调用方法。你需要使用间接机制来实现这一点;具体来说,你必须使用基类虚方法表中的地址来调用该方法。本节描述了如何做到这一点。
每当你的程序调用一个方法时,它是通过间接方式实现的,使用虚拟方法表中该方法所属类的地址。虚拟方法表不过是一个 32 位指针数组,每个条目包含该类某个方法的地址。所以,要调用一个方法,你所需要的只是这个数组的索引(或者更准确地说,是数组中的偏移量)来获取你希望调用的方法的地址。HLA 编译时函数@offset可以帮忙:它将返回你提供的方法名称在虚拟方法表中的偏移量。结合call指令,你可以轻松调用任何与类相关的方法。下面是如何操作的示例:
type
*`myCls`*: class
.
.
.
method m;
.
.
.
endclass;
.
.
.
call( *`myCls`*._VMT_[ @offset( *`myCls`*.m )]);
上面的call指令调用的是虚拟方法表中指定条目处的myCls的方法地址。@offset函数调用返回的是myCls.m在虚拟方法表中的偏移量(即索引乘以 4)。因此,这段代码通过使用虚拟方法表中m的方法条目,间接调用了m方法。
使用这种方式调用方法有一个主要的缺点:你无法使用高层次的过程/方法调用语法。相反,你必须使用低级的call指令。在上面的示例中,这并不是什么大问题,因为m过程没有任何参数。如果它有参数,你就必须手动将这些参数推入栈中。幸运的是,你通常不需要从派生类调用祖先类的方法,所以在实际程序中,这个问题不会太大。
12.15 更多信息
HLA 参考手册可以在webster.cs.ucr.edu/或www.artofasm.com/找到,里面有关于 HLA 类实现的更多信息。查阅这份文档可以了解更多底层实现特性。本章并没有真正试图教授面向对象编程范式。欲了解更多关于该主题的细节,请参阅关于面向对象设计的通用教材。
第十三章:| 附录 A. ASCII 字符集 |
| 二进制 | 十六进制 | 十进制 | 字符 |
|---|---|---|---|
| 0000_0000 | 00 | 0 | NUL |
| 0000_0001 | 01 | 1 | ctrl A |
| 0000_0010 | 02 | 2 | ctrl B |
| 0000_0011 | 03 | 3 | ctrl C |
| 0000_0100 | 04 | 4 | ctrl D |
| 0000_0101 | 05 | 5 | ctrl E |
| 0000_0110 | 06 | 6 | ctrl F |
| 0000_0111 | 07 | 7 | 响铃 |
| 0000_1000 | 08 | 8 | backspace |
| 0000_1001 | 09 | 9 | tab |
| 0000_1010 | 0A | 10 | 换行 |
| 0000_1011 | 0B | 11 | ctrl K |
| 0000_1100 | 0C | 12 | 表单进纸 |
| 0000_1101 | 0D | 13 | return |
| 0000_1110 | 0E | 14 | ctrl N |
| 0000_1111 | 0F | 15 | ctrl O |
| 0001_0000 | 10 | 16 | ctrl P |
| 0001_0001 | 11 | 17 | ctrl Q |
| 0001_0010 | 12 | 18 | ctrl R |
| 0001_0011 | 13 | 19 | ctrl S |
| 0001_0100 | 14 | 20 | ctrl T |
| 0001_0101 | 15 | 21 | ctrl U |
| 0001_0110 | 16 | 22 | ctrl V |
| 0001_0111 | 17 | 23 | ctrl W |
| 0001_1000 | 18 | 24 | ctrl X |
| 0001_1001 | 19 | 25 | ctrl Y |
| 0001_1010 | 1A | 26 | ctrl Z |
| 0001_1011 | 1B | 27 | ESC |
| 0001_1100 | 1C | 28 | ctrl \ |
| 0001_1101 | 1D | 29 | ctrl ] |
| 0001_1110 | 1E | 30 | ctrl ^ |
| 0001_1111 | 1F | 31 | ctrl _ |
| 0010_0000 | 20 | 32 | 空格 |
| 0010_0001 | 21 | 33 | ! |
| 0010_0010 | 22 | 34 | " |
| 0010_0011 | 23 | 35 | # |
| 0010_0100 | 24 | 36 | $ |
| 0010_0101 | 25 | 37 | % |
| 0010_0110 | 26 | 38 | & |
| 0010_0111 | 27 | 39 | ' |
| 0010_1000 | 28 | 40 | ( |
| 0010_1001 | 29 | 41 | ) |
| 0010_1010 | 2A | 42 | * |
| 0010_1011 | 2B | 43 | + |
| 0010_1100 | 2C | 44 | , |
| 0010_1101 | 2D | 45 | - |
| 0010_1110 | 2E | 46 | . |
| 0010_1111 | 2F | 47 | / |
| 0011_0000 | 30 | 48 | 0 |
| 0011_0001 | 31 | 49 | 1 |
| 0011_0010 | 32 | 50 | 2 |
| 0011_0011 | 33 | 51 | 3 |
| 0011_0100 | 34 | 52 | 4 |
| 0011_0101 | 35 | 53 | 5 |
| 0011_0110 | 36 | 54 | 6 |
| 0011_0111 | 37 | 55 | 7 |
| 0011_1000 | 38 | 56 | 8 |
| 0011_1001 | 39 | 57 | 9 |
| 0011_1010 | 3A | 58 | : |
| 0011_1011 | 3B | 59 | ; |
| 0011_1100 | 3C | 60 | < |
| 0011_1101 | 3D | 61 | = |
| 0011_1110 | 3E | 62 | > |
| 0011_1111 | 3F | 63 | ? |
| 0100_0000 | 40 | 64 | @ |
| 0100_0001 | 41 | 65 | A |
| 0100_0010 | 42 | 66 | B |
| 0100_0011 | 43 | 67 | C |
| 0100_0100 | 44 | 68 | D |
| 0100_0101 | 45 | 69 | E |
| 0100_0110 | 46 | 70 | F |
| 0100_0111 | 47 | 71 | G |
| 0100_1000 | 48 | 72 | H |
| 0100_1001 | 49 | 73 | I |
| 0100_1010 | 4A | 74 | J |
| 0100_1011 | 4B | 75 | K |
| 0100_1100 | 4C | 76 | L |
| 0100_1101 | 4D | 77 | M |
| 0100_1110 | 4E | 78 | N |
| 0100_1111 | 4F | 79 | O |
| 0101_0000 | 50 | 80 | P |
| 0101_0001 | 51 | 81 | Q |
| 0101_0010 | 52 | 82 | R |
| 0101_0011 | 53 | 83 | S |
| 0101_0100 | 54 | 84 | T |
| 0101_0101 | 55 | 85 | U |
| 0101_0110 | 56 | 86 | V |
| 0101_0111 | 57 | 87 | W |
| 0101_1000 | 58 | 88 | X |
| 0101_1001 | 59 | 89 | Y |
| 0101_1010 | 5A | 90 | Z |
| 0101_1011 | 5B | 91 | [ |
| 0101_1100 | 5C | 92 | \ |
| 0101_1101 | 5D | 93 | ] |
| 0101_1110 | 5E | 94 | ^ |
| 0101_1111 | 5F | 95 | _ |
| 0110_0000 | 60 | 96 | ` |
| 0110_0001 | 61 | 97 | a |
| 0110_0010 | 62 | 98 | b |
| 0110_0011 | 63 | 99 | c |
| 0110_0100 | 64 | 100 | d |
| 0110_0101 | 65 | 101 | e |
| 0110_0110 | 66 | 102 | f |
| 0110_0111 | 67 | 103 | g |
| 0110_1000 | 68 | 104 | h |
| 0110_1001 | 69 | 105 | i |
| 0110_1010 | 6A | 106 | j |
| 0110_1011 | 6B | 107 | k |
| 0110_1100 | 6C | 108 | l |
| 0110_1101 | 6D | 109 | m |
| 0110_1110 | 6E | 110 | n |
| 0110_1111 | 6F | 111 | o |
| 0111_0000 | 70 | 112 | p |
| 0111_0001 | 71 | 113 | q |
| 0111_0010 | 72 | 114 | r |
| 0111_0011 | 73 | 115 | s |
| 0111_0100 | 74 | 116 | t |
| 0111_0101 | 75 | 117 | u |
| 0111_0110 | 76 | 118 | v |
| 0111_0111 | 77 | 119 | w |
| 0111_1000 | 78 | 120 | x |
| 0111_1001 | 79 | 121 | y |
| 0111_1010 | 7A | 122 | z |
| 0111_1011 | 7B | 123 | { |
| 0111_1100 | 7C | 124 | | |
| 0111_1101 | 7D | 125 | } |
| 0111_1110 | 7E | 126 | ~ |
| 0111_1111 | 7F | 127 |


浙公网安备 33010602011771号