卓越代码之道第一卷第二版-全-

卓越代码之道第一卷第二版(全)

原文:zh.annas-archive.org/md5/b3d4d5c13d27345d71708ed6b29d55b6

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:编写伟大代码所需了解的知识**

Image

写出伟大代码(WGC)系列将教你如何编写你可以自豪的代码;那些会让其他程序员印象深刻、让客户满意、并受用户欢迎的代码;那些客户、你的老板等不会介意支付高价购买的代码。通常,【WGC】(gloss01.xhtml#gloss01_262) 系列中的书籍将讨论如何编写能达到传奇地位的软件,赢得其他程序员的敬畏与钦佩。

1.1 写出伟大代码系列

写出伟大代码,第 1 卷:理解机器(以下简称【WGC1】(gloss01.xhtml#gloss01_263))是【WGC】(gloss01.xhtml#gloss01_262) 系列中的第一本书。编写伟大代码需要知识、经验和技能的结合,而程序员通常只有在经过多年的错误与发现后,才能获得这些。这个系列的目的是与新手和有经验的程序员分享几几十年的观察和经验。我希望这些书能够帮助减少学习“吃苦头”的时间和挫折。

本书,【WGC1】(gloss01.xhtml#gloss01_263),填补了在典型计算机科学或工程课程中常常被忽略的低层次细节。这些细节是许多问题解决方案的基础,没有这些信息,你无法编写高效的代码。虽然我尽力使每本书独立成章,【WGC1】(gloss01.xhtml#gloss01_263) 可能被视为后续系列卷的先决条件。

写出伟大代码,第 2 卷:低级思维,高级编程【WGC2】(gloss01.xhtml#gloss01_264))立即应用本书中的知识。【WGC2】(gloss01.xhtml#gloss01_264) 将教你如何分析用高级语言编写的代码,以确定编译器为其生成的机器代码的质量。优化编译器并不总是生成最好的机器代码——你在源代码文件中选择的语句和数据结构会对编译器输出的效率产生很大影响。【WGC2】(gloss01.xhtml#gloss01_264) 将教你如何编写高效的代码,而无需使用汇编语言。

伟大代码的属性不仅仅是效率,系列中的第三本书,写出伟大代码,第 3 卷:工程化软件【WGC3】(gloss01.xhtml#gloss01_265)),将涵盖其中一些内容。【WGC3】(gloss01.xhtml#gloss01_265) 将讨论软件开发的隐喻、开发方法论、开发人员类型、系统文档以及统一建模语言(UML)。【WGC3】(gloss01.xhtml#gloss01_265) 为个人软件工程奠定了基础。

优秀的代码始于优秀的设计。《编写伟大代码,第 4 卷:设计伟大代码》 (WGC4) 将描述分析和设计的过程(包括结构化和面向对象设计)。* WGC4 * 将教你如何将初步概念转化为工作中的软件系统设计。

《编写伟大代码,第 5 卷:伟大的编码》 (WGC5) 将教你如何创建其他人可以轻松阅读和维护的源代码,以及如何在没有许多软件工程书籍所讨论的“繁琐工作”负担的情况下,提高生产力。

优秀的代码有效。因此,我不能不包括一本关于测试、调试和质量保证的书。很少有程序员能够正确地测试他们的代码。这通常不是因为他们觉得测试无聊或不值得做,而是因为他们不知道如何测试他们的程序、消除缺陷并确保代码质量。为了帮助克服这个问题,《编写伟大代码,第 6 卷:测试、调试和质量保证》 (WGC6) 将描述如何高效地测试你的应用程序,而不需要那些工程师通常与此任务相关的繁琐工作。

1.2 本书涵盖的内容

为了编写优秀的代码,你需要知道如何编写高效的代码,而要编写高效的代码,你必须理解计算机系统如何执行程序以及编程语言中的抽象如何映射到机器的低级硬件能力。

过去,学习伟大的编码技术需要学习汇编语言。虽然这不是一种不好的方法,但它有些过度。学习汇编语言涉及学习两个相关的主题:机器组织和汇编语言编程。学习汇编语言的真正好处来自于机器组织部分。因此,本书仅关注机器组织部分,让你在不需要学习汇编语言的情况下,能够编写出优秀的代码。

机器组织是计算机体系结构的一个子集,涉及低级数据类型、内部 CPU 组织、内存组织和访问、低级机器操作、大容量存储组织、外设以及计算机如何与外部世界通信。本书专注于计算机体系结构和机器组织中那些对程序员可见或有助于理解系统架构师选择特定系统设计原因的部分。学习机器组织以及本书的目标,并不是让你设计自己的 CPU 或计算机系统,而是让你能够高效利用现有的计算机设计。接下来,我们将快速浏览一下我们将要涵盖的具体主题。

第二章、4 章和 5 章处理了计算机数据表示的基础——计算机如何表示有符号和无符号整数值、字符、字符串、字符集、实数值、分数值以及其他数值和非数值量。如果没有扎实地掌握计算机如何在内部表示这些不同的数据类型,理解为什么某些操作效率低下将会非常困难。

第三章讨论了二进制算术和大多数现代计算机系统使用的位操作。它还提供了若干见解,教你如何通过以通常在初学编程课程中不教授的方式使用算术和逻辑操作来编写更好的代码。学习这些标准的“技巧”是成为一名优秀程序员的过程之一。

第六章介绍了内存,讨论了计算机如何访问内存,并描述了内存性能的特点。本章还涵盖了各种机器码【寻址模式】(gloss01.xhtml#gloss01_8),这些模式是 CPU 用来访问内存中不同类型数据结构的。在现代应用中,性能不佳往往是因为程序员未意识到内存访问的后果,从而在程序中制造了瓶颈。第六章解决了许多这些问题。

第七章回到数据类型和表示,讨论了复合数据类型和内存对象:指针、数组、记录、结构和联合。程序员往往使用大型复合数据结构,而没有考虑到这样做的内存和性能影响。这些高级复合数据类型的低级描述将清楚地说明它们的固有成本,让你能够明智而节制地使用它们。

第八章讨论了布尔逻辑和数字设计。本章提供了理解 CPU 及其他计算机系统组件设计所需的数学和逻辑背景。特别地,本章讨论了如何优化布尔表达式,例如常见高级编程语言中的ifwhile语句。

延续第八章的硬件讨论,第九章讨论了 CPU 架构。如果你想写出高效的代码,基本理解 CPU 的设计和运行原理是必不可少的。通过以符合 CPU 执行方式的方式编写代码,你将能够在使用更少系统资源的情况下获得更好的性能。

第十章讨论了 CPU 指令集架构。机器指令是任何 CPU 执行的基本单元,程序执行的持续时间直接由 CPU 必须处理的机器指令的数量和类型决定。学习计算机架构师如何设计机器指令可以为你提供有价值的见解,帮助理解为什么某些操作比其他操作执行得更慢。一旦你理解了机器指令的局限性以及 CPU 如何解读这些指令,你就可以利用这些信息将普通的代码序列转化为优秀的代码。

第十一章回到了内存的话题,介绍了内存架构和组织。对于任何希望编写快速代码的人来说,这一章尤其重要。本章描述了内存层次结构,以及如何最大化缓存和其他高速内存组件的使用。你将学习到抖动问题以及如何避免在应用程序中发生低效的内存访问。

第十二章至第十五章描述了计算机系统如何与外部世界进行通信。许多外围设备(输入/输出设备)的操作速度远低于 CPU 和内存。你可以编写出最快执行的指令序列,但你的应用程序仍然运行缓慢,因为你没有理解系统中 I/O 设备的局限性。这四章讨论了通用 I/O 端口、系统总线、缓冲、握手、轮询和中断。它们还解释了如何高效使用许多流行的 PC 外围设备,包括键盘、并行(打印机)端口、串行端口、磁盘驱动器、磁带驱动器、闪存、SCSI、IDE/ATA、USB 和声卡。

1.3 本书假设的前提

本书是基于一些关于你先前知识的假设而写的。如果你的技能与以下内容相符,你将从本书中获得最大收益:

  • 你应该在至少一种现代编程语言中具有合理的能力。这包括 C/C++、C#、Java、Swift、Python、Pascal/Delphi(对象 Pascal)、BASIC 和汇编语言,以及像 Ada、Modula-2 和 FORTRAN 等语言。

  • 给定一个小问题描述,你应该能够通过设计和实现软件解决方案来解决该问题。一门典型的大学或学院的学期或季度课程(或几个月的自学经验)应该为你提供足够的背景知识来阅读本书。

与此同时,本书并不特定于某种语言;其概念超越了你所使用的编程语言。此外,本书并不假设你使用或了解任何特定的语言。为了让示例更易于理解,编程示例轮流使用几种不同的语言。本书详细解释了示例代码的操作方式,即使你不熟悉特定的编程语言,通过阅读附带的描述,你也能理解它的操作。

本书在不同的示例中使用了以下语言和编译器:

  • C/C++:GCC,微软的 Visual C++

  • Pascal:Embarcadero 的 Delphi,Free Pascal

  • 汇编语言:微软的 MASM、HLA(高级汇编语言)、Gas(Gnu 汇编器;在 PowerPC 和 ARM 上使用)

  • Swift 5(苹果)

  • Java(v6 或更高版本)

  • BASIC:微软的 Visual Basic

通常,示例会以多种语言出现,因此如果你不理解某种语言的语法,通常可以忽略特定示例。

1.4 伟大代码的特征

不同的程序员对伟大代码有不同的定义,因此不可能提供一个能满足所有人需求的全面定义。然而,几乎每个人都会同意,伟大的代码:

  • 高效使用 CPU(也就是说,它很快)

  • 高效使用内存(也就是说,它很小)

  • 高效使用系统资源

  • 容易阅读和维护

  • 遵循一致的风格指南

  • 使用遵循已建立的软件工程规范的明确设计

  • 容易扩展

  • 经过充分测试且稳健(也就是说,它能正常工作)

  • 有良好的文档记录

我们可以轻松地向这个列表中添加几十个条目。例如,一些程序员可能认为,伟大的代码必须是可移植的,必须遵循特定的编程风格指南,或者必须使用某种语言编写(或者不能使用某种语言编写)。一些人可能认为伟大的代码必须尽可能简单地编写,而另一些人则认为它必须迅速编写。还有一些人可能认为,伟大的代码是在按时并且不超预算的情况下创建的。

本书使用的定义如下:

伟大的代码是通过使用一套一致且优先级明确的良好软件特性来编写的软件。特别是,伟大的代码遵循一套规则,指导程序员在将算法实现为源代码时做出决策。

两个不同的程序不必遵循相同的规则(也就是说,它们不需要具备相同的特性)才能被认为是伟大的。在某些环境下,优先考虑的是编写可在不同 CPU 和操作系统之间移植的代码。而在另一些环境下,效率(速度)可能是首要目标,移植性则不那么重要。根据另一个环境的规则,这两个程序都无法被认为是伟大的,但只要软件始终遵循为该特定程序制定的指南,就可以认为它是伟大代码的一个例子。

1.5 本书的环境

尽管本书呈现的是通用信息,但部分讨论必然会涉及到特定系统的内容。由于 Intel 架构的 PC 目前仍是使用最广泛的,因此本书在讨论特定系统依赖的概念时将使用该平台。

本书中的大多数具体示例在现代的 Intel 架构(包括 AMD)CPU 上运行,操作系统包括 macOS、Windows 或 Linux,并且具有合理数量的 RAM 和现代 PC 通常配备的其他系统外设。本书尽量使用标准库接口与操作系统(OS)交互,只有在替代方案会导致写出“不太优秀”的代码时,才会进行操作系统特定的调用。虽然软件可能不完全适用,但这些概念将适用于 Android、Chrome、iOS、Mac、Unix 系统、嵌入式系统,甚至大型机,尽管你可能需要研究如何将概念应用到你的平台上。

1.6 额外提示

没有一本书可以完全覆盖你写出优秀代码所需要知道的所有内容。因此,本书集中在与机器组织最相关的领域,为那些希望编写最优代码的人提供 90%的解决方案。要获得最后的 10%,你将需要额外的帮助。以下是一些建议:

学习汇编语言。至少精通一门汇编语言将填补你仅通过学习机器组织无法获得的许多细节。除非你计划在你的软件系统中使用汇编语言,否则不必在你为其编写软件的平台上学习它。你最好的选择可能是在 PC 上学习 80x86 汇编语言,因为有许多优秀的软件工具可以帮助你学习 Intel 架构汇编语言(例如 HLA),这些工具在其他平台上并不存在。这里学习汇编语言的目的不是编写汇编代码,而是学习汇编的思维方式。如果你了解 80x86 汇编语言,你将对其他 CPU(如 ARM 或 IA-64 系列)如何工作有一个很好的了解。

学习高级计算机架构。机器组织是计算机架构的一个子集,但由于篇幅限制,本书未能全面覆盖这两者。虽然你可能不需要了解如何设计自己的 CPU,但学习计算机架构可能会教你一些这里没有涉及的内容。

1.7 更多信息

亨内西,约翰·L 和大卫·A·帕特森。《计算机架构:量化方法》(第 5 版)。马萨诸塞州沃尔瑟姆:摩根·考夫曼,2012 年。

海德,兰道尔。《汇编语言的艺术》(第 2 版)。旧金山:No Starch Press,2010 年。

第二章:数字表示**

Image

高级语言屏蔽了程序员处理底层数字表示的痛苦。然而,编写优秀的代码需要理解计算机是如何表示数字的,所以本章的重点就是这个。一旦你理解了内部数字表示,你会发现许多算法的高效实现方法,并避免常见编程实践中可能出现的陷阱。

2.1 什么是数字?

在教授汇编语言编程多年后,我发现大多数人并不理解数字与数字表示之间的根本区别。大多数情况下,这种混淆是无害的。然而,许多算法依赖于我们用来操作数字的内部和外部表示,以确保其正确高效地运行。如果你不理解数字的抽象概念和该数字的表示之间的区别,你将很难理解、使用或创建这样的算法。

一个数字是一个无形的、抽象的概念。它是我们用来表示数量的智力工具。假设我告诉你一本书有一百页。你可以触摸这些页面——它们是有形的。你甚至可以数一数这些页面,以验证它们是否有一百页。然而,“一百”只是我应用于这本书的一个抽象概念,用来描述它的大小。

需要认识到的重要一点是,下面的并不是一百:

100

这不过是纸上墨水形成的一些特定线条和曲线(称为字形)。你可能会把这组符号认作一百的表示,但这并不是实际的 100 值。它只是页面上的三个符号。它甚至不是一百的唯一表示——考虑以下这些,它们都是 100 值的不同表示:

100 十进制表示
C 罗马数字表示
64[16] 十六进制(基 16)表示
1100100[2] 二进制(基 2)表示
144[8] 八进制(基 8)表示
一百 英文表示

一个数字的表示(通常)是一些符号的序列。例如,数字一百的常见表示“100”,实际上是由三个数字组成的序列:数字 1 后跟数字 0,再后跟第二个数字 0。每个数字都有其特定的意义,但我们本可以用“64”这个序列来表示一百。即使是组成这个 100 表示的单个数字也不是数字。它们是数字符号,是我们用来表示数字的工具,但它们本身不是数字。

现在你可能会想,为什么你应该关心像“100”这样的符号序列是实际的数字一百,还是仅仅是它的表示。原因是你在计算机程序中会遇到几种看起来像数字(也就是说,它们看起来像“100”)的符号序列,而你不想将它们与实际的数值混淆。相反,计算机可能使用许多不同的表示方式来表示数字一百,重要的是你要意识到它们是等效的。

2.2 数字系统

数字系统是一种我们用来表示数值的机制。今天,大多数人使用十进制(或基数 10)数字系统,大多数计算机系统使用二进制(或基数 2)数字系统。两者之间的混淆可能会导致不良的编码习惯。

阿拉伯人发明了我们今天常用的十进制数字系统(这就是为什么 10 个十进制数字被称为【阿拉伯数字】(gloss01.xhtml#gloss01_13))。十进制系统使用【位置记数法】(gloss01.xhtml#gloss01_197)来用一小组不同的符号表示值。位置记数法不仅赋予符号本身意义,还赋予符号在符号序列中的位置意义——这一方案远优于其他非位置表示法。为了理解位置系统和非位置系统之间的区别,可以参考图 2-1 中的记数划线表示法,表示数字 25。

image

图 2-1:记数划线表示法的 25

记数划线表示法使用一系列n个符号来表示值n。为了让数值更易于阅读,大多数人将记数符号按五个一组排列,如图 2-1 所示。记数划线数字系统的优点在于它对于计数物体非常方便。然而,这种符号表示方法体积庞大,且进行算术运算时很困难。记数划线表示法的最大问题是它占用的物理空间。表示值n需要的空间与n成正比。因此,对于较大的n值,这种表示法就变得不可使用了。

2.2.1 十进制位置数字系统

十进制位置数制使用阿拉伯数字的字符串表示数字,通常包括一个小数点,用于分隔数字的整数部分和小数部分。数字在字符串中的位置影响其含义:小数点左边的每个数字表示一个 0 到 9 之间的值,并乘以递增的 10 的幂(见 图 2-2)。序列中小数点左边紧邻的符号表示 0 到 9 之间的值。如果有至少两个数字,小数点左边第二个符号表示 0 到 9 乘以 10 的值,依此类推。小数点右边的值则逐渐减小。

image

图 2-2:位置数制

数字序列 123.45 表示:

(1 × 10²)+(2 × 10¹)+(3 × 10⁰)+(4 × 10^(–1))+(5 × 10^(–2))

或:

100 + 20 + 3 + 0.4 + 0.05

为了理解十进制位置数制的强大功能,可以考虑与记数划线系统相比:

  • 它可以在三分之一的空间内表示值 10。

  • 它可以在大约 3% 的空间内表示值 100。

  • 它可以在大约 0.3% 的空间内表示值 1,000。

随着数字变大,差距会变得更加显著。由于其紧凑且易于识别的符号,位置数制非常流行。

2.2.2 基数(进制)值

人类发展了十进制数制,因为它与他们手上的手指(“数字”)数量相对应。然而,十进制并不是唯一可能的进制数制;事实上,对于大多数基于计算机的应用,十进制甚至不是最佳的数制。所以,让我们来看看如何在其他数制中表示数值。

十进制位置数制使用 10 的幂和 10 个独特的符号表示每个数字位置。由于十进制数使用 10 的幂,我们称它们为“十进制”数字。通过替换一组不同的数字符号,并将这些符号乘以某个除 10 以外的基数的幂,我们可以设计出另一种数制。基数,或 基数,是我们为每个小数点左边的数字位置提高到递增幂的值(注意,十进制点仅适用于十进制数字)。

例如,我们可以使用八个符号(0-7)和连续的 8 的幂,创建一个基数为 8(八进制)的数制。考虑八进制数 123[8](下标表示进制,采用标准数学符号),其等价于十进制数 83[10]:

1 × 8² + 2 × 8¹ + 3 × 8⁰

或:

64 + 16 + 3

要创建一个基数为 n 的数字系统,你需要 n 个唯一的数字。最小的进制是 2(对于这种方案)。对于 2 到 10 之间的进制,约定使用阿拉伯数字 0 到 n - 1(对于基数为 n 的系统)。对于大于 10 的进制,约定使用字母数字 azAZ(忽略大小写)表示大于 9 的数字。该方案支持从基数 2 到 36 的数字系统(10 个数字字符和 26 个字母字符)。对于大于 10 的阿拉伯数字和 26 个字母字符之外的符号,目前没有统一的约定。在本书中,我们将处理基数为 2、基数为 8 和基数为 16 的值,因为基数 2(二进制)是大多数计算机使用的原生表示,基数 8 曾在旧计算机系统中流行,而基数 16 比基数 2 更紧凑。你会发现许多程序使用这三种进制,因此了解它们非常重要。

2.2.3 二进制数字系统

既然你在读这本书,那么很可能你已经熟悉了基数为 2 的二进制数字系统;不过,快速回顾一下也是有必要的。二进制数字系统的工作原理与十进制数字系统相似,只是二进制只使用 0 和 1(而不是 0 到 9),并且使用 2 的幂(而不是 10 的幂)。

为什么要担心二进制呢?毕竟,几乎所有的计算机语言都允许程序员使用十进制表示法(自动将十进制表示转换为内部的二进制表示)。尽管有这个功能,但大多数现代计算机系统在与 I/O 设备通信时使用二进制,且它们的算术电路处理的是二进制数据。许多算法依赖于二进制表示来确保正确运行。因此,为了编写优秀的代码,你需要完全理解二进制表示法。

2.2.3.1 十进制与二进制之间的转换

为了理解计算机为你做了什么,学习如何手动将十进制和二进制表示法进行转换是很有用的。

要将二进制值转换为十进制,需将 2^(i)加到每个二进制字符串中为 1 的位置,其中 i 是二进制位的零基位置。例如,二进制值 11001010[2]表示:

1 × 2⁷ + 1 × 2⁶ + 0 × 2⁵ + 0 × 2⁴ + 1 × 2³ + 0 × 2² + 1 × 2¹ + 0 × 2⁰

或者:

128 + 64 + 8 + 2

或者:

202[10]

将十进制转换为二进制几乎一样简单。这里有一个算法可以将十进制表示转换为对应的二进制表示:

  1. 如果数字是偶数,则输出 0。如果数字是奇数,则输出 1。

  2. 将数字除以 2,丢弃任何小数部分或余数。

  3. 如果商为 0,算法完成。

  4. 如果商不为 0 且数字为奇数,则在当前字符串前插入 1。如果商不为 0 且数字为偶数,则在二进制字符串前加上 0。

  5. 返回步骤 2 并重复。

这个例子将 202 转换为二进制:

  1. 202 是偶数,因此输出 0 并除以 2(101):0

  2. 101 是奇数,因此输出 1 并除以 2(50):10

  3. 50 是偶数,因此输出 0 并除以 2(25):010

  4. 25 是奇数,因此输出 1 并除以 2(12):1010

  5. 12 是偶数,因此输出 0 并除以 2(6):01010

  6. 6 是偶数,因此输出 0 并除以 2(3):001010

  7. 3 是奇数,因此输出 1 并除以 2(1):1001010

  8. 1 是奇数,因此输出 2 并除以 2(0):11001010

  9. 结果是 0,因此算法完成,得出 11001010。

2.2.3.2 使二进制数字更易读

如你从等价表示 202[10]和 11001010[2]中可以看出,二进制表示法不如十进制表示法紧凑。我们需要找到一种方法,让二进制数字中的每一位,或*(位),更加简洁并易于阅读。

在美国,大多数人会用逗号将每三位数字分开,以便更容易阅读较大的数字。例如,1,023,435,208 比 1023435208 更容易阅读和理解。本书将采用类似的约定来表示二进制数字;每 4 个位的二进制数会用下划线分开。例如,二进制值 1010111110110010[2]将写作 1010_1111_1011_0010[2]。

2.2.3.3 在编程语言中表示二进制值

到目前为止,本章已经使用了数学家采用的下标符号来表示二进制值(缺少下标表示十进制)。然而,下标符号通常不被程序文本编辑器或编程语言编译器识别,因此我们需要一些其他方法来在标准的 ASCII 文本文件中表示不同的进制。

一般来说,只有汇编语言编译器(“汇编器”)允许在程序中使用字面二进制常量。^(1) 由于汇编器的种类繁多,在汇编语言程序中表示字面二进制常量的方式也各不相同。本书使用 MASM 和 HLA 作为示例,因此采用它们的表示约定是有意义的。

MASM 将二进制值表示为一串二进制数字(01),并以bB结尾。在 MASM 源文件中,9 的二进制表示为1001b

HLA 使用百分号符号(%)作为二进制值的前缀。为了使二进制数字更易读,HLA 还允许在二进制字符串中插入下划线,像这样:

%11_1011_0010_1101

2.2.4 十六进制数制

如前所述,二进制数字表示方式较为冗长。十六进制表示法具有两个重要优点:它非常紧凑,并且在二进制和十六进制之间转换很容易。因此,软件工程师通常使用十六进制表示法而不是二进制,以使程序更具可读性。

由于十六进制表示法是以 16 为基数的,每一位数字左边的十六进制点代表某个值乘以 16 的连续幂。例如,数字 1234[16]等于:

1 × 16³ + 2 × 16² + 3 × 16¹ + 4 × 16⁰

或:

4096 + 512 + 48 + 4

或:

4660[10]

十六进制表示法使用字母AF表示它所需的额外六个数字(超过 10 个标准十进制数字,0–9)。以下是所有有效十六进制数字的示例:

234[16] DEAD[16] BEEF[16] 0AFB[16] FEED[16] DEAF[16]

2.2.4.1 在编程语言中表示十六进制值

十六进制表示法的一个问题是很难区分像“DEAD”这样的十六进制值与标准程序标识符。因此,大多数编程语言使用特殊的前缀或后缀字符来表示十六进制值。以下是几种流行语言中如何指定字面十六进制常量:

  • C、C++、C#、Java、Swift 及其他 C 派生编程语言使用前缀0x。你可以使用字符序列0xdead来表示十六进制值 DEAD[16]。

  • MASM 汇编器使用hH后缀。由于这并不能完全消除某些标识符与字面十六进制常量之间的歧义(例如,“deadh”对 MASM 仍然看起来像一个标识符),它还要求十六进制值以数字字符开头。因此,你需要在值的开头添加0(因为数字表示的前缀0不会改变值),得到0deadh,这就明确表示 DEAD[16]。

  • Visual Basic 使用&H&h前缀。继续当前的示例,你需要使用&Hdead来表示 Visual Basic 中的 DEAD[16]。

  • Pascal(Delphi)使用前缀$。因此,你可以使用$dead来表示 Delphi/Free Pascal 中的当前示例。

  • HLA 也使用前缀$。与二进制数一样,它还允许你在十六进制数字中插入下划线,以便更容易阅读(例如,$FDEC_A012)。

一般来说,本书将使用 HLA/Delphi/Free Pascal 格式,除非是在针对其他编程语言的示例中。由于本书中有若干 C/C++示例,因此你会经常看到 C/C++表示法。

2.2.4.2 在十六进制和二进制表示之间转换

十六进制表示法流行的另一个原因是它便于在二进制和十六进制表示之间进行转换。通过记住表 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 转换为二进制形式 %1010_1011_1100_1101,根据 表 2-1 中的值转换每个十六进制数字:

A B C D 十六进制
1010 1011 1100 1101 二进制

将数字的二进制表示转换为十六进制几乎同样简单。首先,将二进制数用 0 补齐,确保它的位数是 4 的倍数。例如,给定二进制数 1011001010,将两个 0 位加到数字的左侧,使其变为 12 位而不改变其值:001011001010。接下来,将二进制值分成 4 位一组:0010_1100_1010。最后,在 表 2-1 中查找这些二进制值,并替换为相应的十六进制数字:$2CA。正如你所见,这比将十进制与二进制或十进制与十六进制之间的转换要简单得多。

2.2.5 八进制计数系统

八进制(基数 8)表示法在早期计算机系统中很常见,因此你可能会偶尔看到它的使用。八进制对于 12 位和 36 位计算机系统(或任何位数是 3 的倍数的系统)很有用,但对于位数是 2 的幂(如 8 位、16 位、32 位和 64 位计算机系统)的计算机系统并不特别合适。然而,一些编程语言允许你以八进制表示法指定数字值,你仍然可以找到一些使用它的旧版 Unix 应用程序。

2.2.5.1 编程语言中的八进制表示

C 编程语言(以及 C++ 和 Java 等衍生语言)、MASM、Swift 和 Visual Basic 支持八进制表示。你应该了解它们使用的八进制表示法,以防在这些语言编写的程序中遇到这种情况。

  • 在 C 中,你可以通过在数字字符串前加上 0(零)来指定八进制。例如,0123 等于十进制值 83[10],并且绝对不是十进制值 123[10]。

  • MASM 使用 Qq 后缀。(微软/英特尔可能选择 Q,因为它看起来像字母 O,但不容易与零混淆。)

  • Swift 使用 0o 前缀。例如,0o14 表示十进制值 12[10]。

  • Visual Basic 使用前缀 &O(是字母 O,不是零)。例如,你会用 &O123 来表示十进制值 83[10]。

2.2.5.2 二进制与八进制表示之间的转换

二进制与八进制之间的转换类似于二进制与十六进制之间的转换,只是这次是按 3 位一组,而不是 4 位一组。查看 表 2-2,它列出了二进制与八进制的等价表示。

表 2-2: 二进制/八进制转换表

二进制 八进制
%000 0
%001 1
%010 2
%011 3
%100 4
%101 5
%110 6
%111 7

要将八进制转换为二进制,请将数字中的每个八进制数字替换为表 2-2 中对应的 3 位二进制位。例如,当你将123q转换为二进制值时,最终结果是%0_0101_0011

1 2 3
001 010 011

要将二进制数字转换为八进制,你需要将二进制字符串分成 3 位一组(根据需要用 0 填充),然后将每组三位替换为表 2-2 中的对应八进制数字。

要将八进制值转换为十六进制表示法,先将八进制数字转换为二进制,然后再将二进制值转换为十六进制。

2.3 数字/字符串转换

在本节中,我们将探讨从字符串到数字形式的转换以及反向转换。由于大多数编程语言(或其库)会自动执行这些转换,初学者往往不知道这些转换的发生。例如,考虑在各种语言中将字符串转换为数字形式是多么容易:

cin >> i;                      // C++

readln( i );                   // Pascal

let j = Int(readLine() ?? "")! // Swift

input i                        // BASIC

stdin.get( i );                // HLA

在这些语句中,变量i可以保存一个整数值。然而,用户从控制台输入的是一串字符。编程语言的运行时库负责将这串字符转换为 CPU 所需的内部二进制形式。请注意,Swift 只允许你从标准输入读取一个字符串;你必须显式地使用Int()构造函数/类型转换函数将该字符串转换为整数。

不幸的是,如果你不了解这些语句的成本,你将无法意识到它们在性能关键时如何影响你的程序。理解转换算法中涉及的底层工作非常重要,这样你就不会轻率地使用这些语句。

注意

为了简便起见,我们将讨论无符号整数值,并忽略非法字符和数值溢出的可能性。因此,以下算法稍微低估了实际涉及的工作。

使用此算法将一串十进制数字转换为整数值:

  1. 初始化一个值为0的变量;这个变量将保存最终的值。

  2. 如果字符串中没有更多的数字,则算法完成,变量保存了数字值。

  3. 从字符串中提取下一个数字(从左到右),并将其从 ASCII 转换为整数。

  4. 将变量乘以 10,然后加上步骤 3 中提取的数字。

  5. 返回步骤 2 并重复。

将整数值转换为字符字符串需要更多的努力:

  1. 初始化一个空字符串。

  2. 如果整数值为 0,输出0,并且算法完成。

  3. 将当前的整数值除以 10,计算余数和商。

  4. 将余数(始终在 0..9 的范围内^(2))转换为字符,并将该字符插入字符串的开头。

  5. 如果商不为 0,则将其作为新的值,并重复步骤 3 至步骤 5。

  6. 输出字符串中的字符。

这些算法的细节并不重要。重要的是,每个输出字符执行一次这些步骤,且除法运算非常缓慢。因此,像以下这样的简单语句可以隐藏程序员相当多的工作:

printf( "%d", i );    // C

cout << i;            // C++

print i               // BASIC

write( i );           // Pascal

print( i )            // Swift

stdout.put( i );      // HLA

要编写出色的代码,你不必完全避免使用数字/字符串转换;然而,一名出色的程序员会小心只在必要时使用它们。

记住,这些算法仅适用于无符号整数。带符号整数需要稍微更多的处理(尽管额外的工作几乎可以忽略不计)。然而,浮点值在字符串与数值之间的转换要困难得多,因此在编写涉及浮点运算的代码时,务必记住这一点。

2.4 内部数值表示

大多数现代计算机系统使用内部二进制格式来表示值和其他对象。然而,大多数系统只能有效地表示特定大小的二进制值。为了编写出色的代码,你需要确保你的程序使用计算机能够高效表示的数据对象。本节将描述计算机如何物理表示值,以便你能相应地设计程序。

2.4.1 比特

在二进制计算机中,数据的最小单位是单个比特。因为一个比特只能表示两个不同的值(通常是01),你可能会认为它无法发挥太大作用。但实际上,使用单个比特可以表示无数种两项组合。以下是一些例子(我创建的任意二进制编码):

  • 零(0)或一(1

  • 假(0)或真(1

  • 关闭(0)或开启(1

  • 男性(0)或女性(1

  • 错误(0)或正确(1

你不仅限于表示二进制数据类型(即那些只有两种不同值的对象)。你也可以使用单个比特来表示任何两个不同的项:

  • 数字 723(0)和 1,245(1

  • 红色(0)和蓝色(1

你甚至可以使用单个比特表示两个不相关的对象。例如,你可以使用比特值0表示红色,使用比特值1表示数字 3,256。你可以用单个比特表示任何两种不同的值——但仅仅两种不同的值。因此,单个比特对于大多数计算需求是不足够的。为了克服单个比特的局限性,我们通过多个比特的序列创建【比特串】(gloss01.xhtml#gloss01_32)

2.4.2 位串

通过将比特组合成一个序列,我们可以形成与其他数值表示方式(如十六进制和八进制)等价的二进制表示。大多数计算机系统不允许你组合任意数量的比特,因此你必须使用固定长度的比特串。

半字节是由 4 位组成的集合。大多数计算机系统在内存中无法高效访问半字节。特别地,恰好需要 1 个半字节来表示一个十六进制数字。

字节是 8 位,是许多 CPU 上最小的可寻址数据项;即 CPU 可以高效地从内存中以 8 位为一组来检索数据。正因如此,许多语言支持的最小数据类型消耗 1 个字节的内存(无论该数据类型实际需要多少位)。

因为字节是大多数计算机中最小的存储单位,并且许多语言使用字节表示需要少于 8 位的对象,我们需要某种方式表示字节中的单个位。为了描述字节中的位,我们将使用位编号。如图 2-3 所示,位 0 是低位(LO),或最不重要的位,位 7 是高位(HO),或最重要的位。其余位将通过它们的编号来表示。

image

图 2-3:字节中的位编号

的定义因 CPU 而异:它可能是 16 位、32 位或 64 位的对象。本书采用 80x86 术语,并将一个字定义为 16 位的集合。与字节一样,我们将为一个字使用位编号,从位 0(LO 位)开始,逐步到位 15(HO 位)(参见图 2-4)。

image

图 2-4:一个字中的位编号

请注意,一个字包含正好 2 个字节。位 0 到 7 组成 LO 字节,位 8 到 15 组成 HO 字节(参见图 2-5)。

image

图 2-5:一个字中的 2 个字节

双字(或dword)的含义正如其名称所示——一对字。因此,双字的长度是 32 位,如图 2-6 所示。

image

图 2-6:双字中的位布局

图 2-7 显示一个双字由 2 个字或 4 个字节组成。

image

图 2-7:双字中的字节和字

如前所述,大多数 CPU 高效地处理大小不超过某一限制的对象(现代系统上通常为 32 位或 64 位)。这并不意味着你不能处理更大的对象,只是这样做的效率较低。你通常不会看到程序处理超过大约 128 位或 256 位的数字对象。一些编程语言提供 64 位整数,且大多数语言支持 64 位浮点值,因此对于这些数据类型,我们将使用四字(quad word)这个术语。最后,我们将使用长字(long word)来描述 128 位的值;尽管今天很少有语言支持它们,^(3),但这给了我们一定的扩展空间。

我们可以将四字长(quad word)分解为 2 个双字(double word),4 个字(word),8 个字节(byte)或 16 个半字节(nibble)。同样,我们也可以将长字(long word)分解为 2 个四字长,4 个双字,8 个字或 16 个字节。

英特尔 80x86 平台还支持一种 80 位类型,英特尔称之为 tbyte(即“十字节”对象的缩写)。80x86 CPU 系列使用 tbyte 变量来保存扩展精度的浮点值和某些二进制编码十进制(BCD)值。

一般来说,使用 n 位串,您可以表示最多 2^(n) 个不同的值。表 2-3 显示了使用半字节、字节、字、双字、四字和长字可以表示的可能对象数量。

表 2-3: 可由位串表示的值的数量

位串大小(以位为单位) 可能的组合数量(2^(n))
4 16
8 256
16 65,536
32 4,294,967,296
64 18,446,744,073,709,551,616
128 340,282,366,920,938,463,463,374,607,431,768,211,456

2.5 有符号与无符号数

二进制数字 0…00000^(4) 表示 0;0…00001 表示 1;0…00010 表示 2;依此类推直到无穷大。那么负数呢?为了表示有符号的值,大多数计算机系统使用 二进制补码 数字系统。有符号数的表示方式对它们施加了一些基本的限制,因此理解有符号数和无符号数在计算机系统中的不同表示方式非常重要,以便有效地使用它们。

使用 n 位,我们只能表示 2^(n) 个不同的对象。由于负数本身也是一个对象,我们需要将这 2^(n) 个组合分配给负值和非负值。例如,一个字节可以表示从 -128 到 -1 的负值,以及从 0 到 127 的非负值。使用 16 位字,可以表示从 -32,768 到 +32,767 的有符号值。使用 32 位双字,可以表示从 -2,147,483,648 到 +2,147,483,647 的值。一般来说,使用 n 位可以表示从 -2^(n–)¹ 到 +2^(n–)¹ – 1 范围内的有符号值。

二进制补码系统使用最高位(HO 位)作为 符号位。如果最高位为 0,则该数字为非负数,并使用通常的二进制编码;如果最高位为 1,则该数字为负数,并使用二进制补码编码。以下是一些使用 16 位数字的例子:

  • $8000%1000_0000_0000_0000)是负数,因为最高位(HO 位)为 1

  • $100%0000_0001_0000_0000)是非负数,因为最高位(HO 位)为 0

  • $7FFF%0111_1111_1111_1111)是非负数。

  • $FFFF%1111_1111_1111_1111)是负数。

  • $FFF%0000_1111_1111_1111)是非负数。

要取反一个数字,可以使用二进制补码操作,方法如下:

  1. 反转数字中的所有位;也就是说,将所有 0 改为 1,将所有 1 改为 0

  2. 1加到反转后的结果(忽略任何溢出)。

如果结果是负数(其 HO 位被设置),那么这就是非负值的二进制补码形式。

例如,以下是计算十进制值 –5 的 8 位等价数的步骤:

  1. %0000_0101 5(二进制)。

  2. %1111_1010 反转所有位。

  3. %1111_1011 加 1 得到 –5(二进制补码形式)。

如果我们取 –5 并取反,结果是 5(%0000_0101),正如我们预期的那样:

  1. %1111_1011 –5 的二进制补码。

  2. %0000_0100 反转所有位。

  3. %0000_0101 加 1 得到 5(二进制)。

让我们来看一些 16 位示例及其反转。

首先,取反 32,767($7FFF):

  1. %0111_1111_1111_1111 +32,767,最大的 16 位正数。

  2. %1000_0000_0000_0000 反转所有位(8000h)。

  3. %1000_0000_0000_0001 加 1(8001h,或 –32,767)。

现在取反 16,384($4000):

  1. %0100_0000_0000_0000 16,384。

  2. %1011_1111_1111_1111 反转所有位($BFFF)。

  3. %1100_0000_0000_0000 加 1($C000 或 –16,384)。

现在取反 –32,768($8000):

  1. %1000_0000_0000_0000 –32,768,最小的 16 位负数。

  2. %0111_1111_1111_1111 反转所有位($7FFF)。

  3. %1000_0000_0000_0000 加 1($8000 或 –32,768)。

$8000 反转后变为 $7FFF,加 1 后我们得到 $8000!等等,发生了什么:–(–32,768) 就是 –32,768?当然不是。然而,16 位二进制补码系统无法表示 +32,768。一般来说,你不能取二进制补码系统中最小负值的反。

2.6 二进制数的有用性质

以下是一些你可能在程序中用到的二进制值性质:

  • 如果二进制(整数)值的第 0 位包含 1,则该数是奇数;如果该位包含 0,则该数是偶数。

  • 如果二进制数的低 n 位都包含 0,那么该数可以被 2^(n) 整除。

  • 如果二进制值的第 n 位是 1,而其他所有位都是 0,那么该数等于 2^(n)。

  • 如果二进制值从第 0 位到第 n-1 位都包含 1,而其他所有位都是 0,那么该值等于 2^(n) – 1。

  • 将一个数的所有位向左移动一位,会将该二进制值乘以 2。

  • 将无符号二进制数的所有位右移一位,实际上是将该数除以 2(这不适用于有符号整数值)。奇数会向下舍入。

  • 两个 n 位二进制值相乘可能需要多达 2 × n 位来存储结果。

  • 加法或减法两个 n 位二进制值从不需要超过 n + 1 位来存储结果。

  • 反转二进制数中的所有位(即将所有 0 改为 1,反之亦然)与取反(改变符号)该值并从结果中减去 1 是相同的。

  • 递增(加 1)给定位数的最大无符号二进制值总是产生 0

  • 递减(减去 1)0 总是会生成一个给定位数的最大无符号二进制值。

  • 一个n位的值提供 2^(n)种唯一的位组合。

  • 2^(n)–1 的值包含n个位,每个位都包含值1

记住从 2⁰到 2¹⁶的所有 2 的幂是个好主意(见表 2-4),因为这些值在程序中经常出现。

表 2-4: 2 的幂

n 2n
0 1
1 2
2 4
3 8
4 16
5 32
6 64
7 128
8 256
9 512
10 1,024
11 2,048
12 4,096
13 8,192
14 16,384
15 32,768
16 65,536

2.7 符号扩展、零扩展与收缩

使用补码系统时,单一负值的表示方式会根据表示大小的不同而有所不同。8 位的符号值必须转换才能用于涉及 16 位数的表达式。这种转换及其逆操作——将 16 位数转换为 8 位数——分别是符号扩展收缩操作。

考虑值-64。这个数的 8 位补码值是$C0,16 位等效值是$FFC0。显然,这些不是相同的位模式。现在考虑值+64。这个数的 8 位和 16 位版本分别是$40$0040。我们扩展负值的方式与扩展非负值的方式不同。

要进行符号扩展,将符号位复制到新格式中的额外高位。例如,将一个 8 位数扩展到 16 位时,将 8 位数的第 7 位复制到 16 位数的第 8 到 15 位。将一个 16 位数扩展到双字时,将第 15 位复制到双字的第 16 到 31 位。

在将一个字节数量加到一个字数量时,需要先将字节扩展到 16 位,再加上这两个数。其他操作可能需要扩展到 32 位。

表 2-5 提供了几个符号扩展的示例。

表 2-5: 符号扩展示例

8 位 16 位 32 位 二进制(补码)
$80 | $FF80 $FFFF_FF80 %1111_1111_1111_1111_1111_1111_1000_0000
$28 | $0028 $0000_0028 %0000_0000_0000_0000_0000_0000_0010_1000
$9A | $FF9A $FFFF_FF9A %1111_1111_1111_1111_1111_1111_1001_1010
$7F | $007F $0000_007F %0000_0000_0000_0000_0000_0000_0111_1111
n/a $1020 | $0000_1020 %0000_0000_0000_0000_0001_0000_0010_0000
n/a $8086 | $FFFF_8086 %1111_1111_1111_1111_1000_0000_1000_0110

零扩展将小的无符号值转换为更大的无符号值。零扩展非常简单——只需将0存储在更大操作数的高位字节中。例如,将 8 位值$82零扩展到 16 位时,将高位字节插入0,得到$0082

更多示例请见表 2-6。

表 2-6: 零扩展示例

8 位 16 位 32 位 二进制
$80 | $0080 $0000_0080 %0000_0000_0000_0000_0000_0000_1000_0000
$28 | $0028 $0000_0028 %0000_0000_0000_0000_0000_0000_0010_1000
$9A | $009A $0000_009A %0000_0000_0000_0000_0000_0000_1001_1010
$7F | $007F $0000_007F %0000_0000_0000_0000_0000_0000_0111_1111
不适用 $1020 | $0000_1020 %0000_0000_0000_0000_0001_0000_0010_0000
不适用 $8086 | $0000_8086 %0000_0000_0000_0000_1000_0000_1000_0110

许多高级语言编译器会自动处理符号扩展和零扩展。下面的 C 语言示例演示了这一过程:

signed char sbyte;   // Chars in C are byte values.

short int sword;     // Short integers in C are *usually* 16-bit values.

long int sdword;     // Long integers in C are *usually* 32-bit values.

 . . .

sword = sbyte;       // Automatically sign-extends the 8-bit value to 16 bits.

sdword = sbyte;      // Automatically sign-extends the 8-bit value to 32 bits.

sdword = sword;      // Automatically sign-extends the 16-bit value to 32 bits.

一些语言(如 Ada 或 Swift)要求从较小的大小显式转换为较大的大小。请查阅特定语言的参考手册,查看是否需要这样做。要求显式转换的语言的优点是,编译器永远不会在你不知情的情况下做任何事情。如果你没有进行转换,编译器会发出诊断信息。

关于符号扩展和零扩展,重要的是要意识到它们并非总是免费的。将较小的整数赋值给较大的整数可能需要更多的机器指令(执行时间更长),而不是在两个相同大小的整数变量之间移动数据。因此,在同一个算术表达式或赋值语句中混合不同大小的变量时,应小心。

符号扩展——将一个具有某些位数的值转换为具有较少位数的相同值——稍微麻烦一些。例如,考虑值 –448。作为一个 16 位的十六进制数,它的表示是 $FE40。这个数的大小太大,无法适应 8 位,因此你无法将其符号扩展到 8 位。

为了正确地将一个值符号扩展到另一个值,你必须查看你想要丢弃的高位字节。首先,所有高位字节必须包含 0$FF。其次,结果值的高位字节必须与你从数字中移除的每一位匹配。以下是将 16 位值转换为 8 位值的一些示例(包括几个失败的例子):

  • $FF80%1111_1111_1000_0000)可以符号扩展为 $80%1000_0000)。

  • $0040%0000_0000_0100_0000)可以符号扩展为 $40%0100_0000)。

  • $FE40%1111_1110_0100_0000)不能被符号扩展到 8 位。

  • $0100%0000_0001_0000_0000)不能符号扩展到 8 位。

一些高级语言,如 C,通常会将表达式的低位部分存储到一个较小的变量中,丢弃高位部分——最多,C 编译器可能会给出有关可能发生精度丢失的警告。你通常可以让编译器静默,但它仍然不会检查无效值。通常,你可以使用类似以下代码来符号扩展 C 中的值:

signed char sbyte;    // Chars in C are byte values.

short int sword;      // Short integers in C are *usually* 16-bit values.

long int sdword;      // Long integers in C are *usually* 32-bit values.

 . . .

sbyte = (signed char) sword;

sbyte = (signed char) sdword;

sword = (short int) sdword;

在 C 中唯一安全的解决方案是在尝试将值存入较小的变量之前,先将表达式的结果与上限和下限值进行比较。以下是加入这些检查后的代码:

if( sword >= -128 && sword <= 127 )

{

    sbyte = (signed char) sword;

}

else

{

    // Report appropriate error.

}

// Another way, using assertions:

assert( sword >= -128 && sword <= 127 )

sbyte = (signed char) sword;

assert( sdword >= -32768 && sdword <= 32767 )

sword = (short int) sdword;

这段代码显得相当复杂。在 C/C++ 中,你可能希望将其转换为宏(#define)或函数,这样代码会更加易读。

一些高级语言(例如 Free Pascal 和 Delphi)会自动进行符号收缩值转换,然后检查该值以确保它适合目标操作数。^(5) 这样的语言如果发生范围违反,会抛出某种异常(或停止程序)。要采取纠正措施,你需要编写一些异常处理代码,或者使用类似于 C 示例中给出的 if 语句序列。

2.8 饱和

你还可以通过 饱和 来减小整数值的大小,当你愿意接受可能的精度损失时,这非常有用。要通过饱和转换一个值,你需要将较大对象的低位(LO bits)复制到较小的对象中。如果较大的值超出了较小对象的范围,那么你就 裁剪 较大的值,将较小对象的值设置为该范围内的最大(或最小)值。

例如,在将一个 16 位有符号整数转换为 8 位有符号整数时,如果 16 位值的范围在 -128 到 +127 之间,你只需将低字节(LO byte)复制到 8 位对象中。如果 16 位有符号值大于 +127,那么你就裁剪该值至 +127,并将 +127 存储到 8 位对象中。同样地,如果该值小于 -128,你就将最终的 8 位对象裁剪至 -128。饱和操作在将 32 位值裁剪为较小值时,效果相同。

如果较大的值超出了较小值的范围,那么在转换过程中会丢失精度。虽然裁剪值并不是最理想的选择,但有时它比抛出异常或拒绝计算更好。对于许多应用程序,比如音频或视频,即使裁剪后的结果,最终用户依然能够识别,因此这是一种合理的转换方案。

许多 CPU 支持其特殊的“多媒体扩展”指令集中的饱和运算——例如,英特尔 80x86 处理器系列的 MMX/SSE/AVX 指令扩展。大多数 CPU 的标准指令集以及大多数高级语言并未直接支持饱和运算,但这种技术并不难实现。请看以下使用饱和技术将 32 位整数转换为 16 位整数的 Free Pascal/Delphi 代码:

var

    li  :longint;

    si  :smallint;

        . . .

    if( li > 32767 ) then

        si := 32767;

    else if( li < -32768 ) then

        si := -32768;

    else 

        si := li;

2.9 二进制编码十进制表示法

二进制编码十进制(BCD) 格式,顾名思义,使用二进制表示法对十进制值进行编码。常见的通用高级语言(如 C/C++、Pascal 和 Java)很少支持十进制值。然而,面向业务的编程语言(如 COBOL 和许多数据库语言)是支持的。因此,如果你正在编写与数据库或支持十进制算术的语言接口的代码,你可能需要处理 BCD 表示。

BCD 值由一系列 nibble 组成,每个 nibble 表示一个范围在 0 到 9 之间的值。(BCD 格式仅使用 nibble 可表示的 16 个值中的 10 个。)使用一个字节可以表示包含两位十进制数字的值(0..99),如 图 2-8 所示。使用一个字,可以表示四位十进制数字(0..9999)。一个双字可以表示最多八位十进制数字。

image

图 2-8:字节中的 BCD 数据表示

一个 8 位的 BCD 变量可以表示从 0 到 99 之间的值,而同样的 8 位二进制值则可以表示从 0 到 255 之间的值。同样,一个 16 位的二进制值可以表示从 0 到 65,535 之间的值,而一个 16 位的 BCD 值只能表示其中约六分之一的值(0..9999)。不过,BCD 的问题不仅仅是存储效率低——BCD 计算也往往比二进制计算慢。

BCD 格式确实有两个优点:它非常容易在内部数字表示和十进制字符串表示之间转换 BCD 值,而且在使用 BCD 时,硬件编码多位十进制值也非常简单——例如,使用一组拨盘,每个拨盘表示一个数字。因此,你可能会在嵌入式系统(如烤面包机和闹钟)中看到 BCD 的应用,但在通用计算机软件中很少见。

几十年前,人们认为涉及 BCD(或仅十进制)算术的计算比二进制计算更为准确。因此,他们通常会使用基于十进制的算术来执行重要的计算,比如涉及货币单位的计算。某些计算在 BCD 中可能会产生更准确的结果,但对于大多数计算来说,二进制更加准确。这就是为什么大多数现代计算机程序将所有值(包括十进制值)表示为二进制形式的原因。例如,Intel 80x86 浮点单元(FPU) 支持一对指令来加载和存储 BCD 值。在内部,FPU 会将这些 BCD 值转换为二进制。它仅将 BCD 用作外部(FPU 之外的)数据格式。这种方法通常会产生更准确的结果。

2.10 定点表示

计算机系统常用两种方式来表示带有小数部分的数字:定点表示和浮点表示。

在过去,当 CPU 不支持硬件浮点运算时,定点运算在编写高性能软件、处理小数值的程序员中非常流行。定点格式支持小数值所需的软件开销比浮点格式少。然而,CPU 制造商在 CPU 中添加了 FPU 来支持硬件浮点运算,如今在通用 CPU 上很少有人尝试定点运算。通常,使用 CPU 的本地浮点格式更具成本效益。

尽管 CPU 制造商已经努力优化其系统中的浮点运算,但在某些情况下,精心编写的汇编语言程序使用定点计算比等效的浮点代码运行得更快。例如,某些 3D 游戏应用可能使用 16:16(16 位整数,16 位小数)格式,而不是 32 位浮点格式,从而产生更快的计算。由于定点运算有一些非常好的应用场景,本节将讨论定点表示法和使用定点格式的小数值。

注意

第四章将讨论浮点格式。

正如你所看到的,位置编号系统通过将数字放置在基点右侧来表示小数值(介于 0 和 1 之间的值)。在二进制编号系统中,基点右侧的每一位代表值 0 或 1,乘以 2 的某个连续负次方。我们使用二进制分数的和来表示值的小数部分。例如,值 5.25 被二进制表示为 101.01。转换为十进制得到:

1 × 2² + 1 × 2⁰ + 1 × 2^(–2) = 4 + 1 + 0.25 = 5.25

在使用定点二进制格式时,你选择二进制表示中的特定位,并隐式地将二进制点放在该位之前。你根据小数部分所需的有效位数来选择二进制点的位置。例如,如果你的值的整数部分范围从 0 到 999,你将需要至少 10 个位在二进制点的左侧来表示这个范围的值。如果需要有符号值,你还需要额外的一个位来表示符号。在 32 位定点格式中,这将剩下 21 或 22 个位用于小数部分,具体取决于你的值是否是有符号的。

定点数是实数的一个小子集。由于任何两个整数之间的值是无限的,定点值无法准确表示每一个值(做到这一点需要无限位数)。使用定点表示法时,我们必须逼近大多数实数。考虑 8 位定点格式,其中 6 位用于整数部分,2 位用于分数部分。整数部分可以表示范围为 0 到 63 的值(或表示范围为–32 到+31 的带符号值)。分数部分只能表示四个不同的值:0.0、0.25、0.5 和 0.75。使用这种格式无法精确表示 1.3;你能做的最好的就是选择最接近它的值(1.25)。这就引入了误差。你可以通过在定点格式的二进制小数点右侧添加更多位来减少这种误差(但这样会减少整数部分的范围,或者需要为定点格式添加更多位)。例如,如果你使用一个 16 位的定点格式,整数部分使用 8 位,分数部分使用 8 位,那么你可以通过二进制值 1.01001101 来逼近 1.3。其十进制等价值如下:

1 + 0.25 + 0.03125 + 0.15625 + 0.00390625 = 1.30078125

向定点数的分数部分添加更多位数,将使该值的逼近更加精确(使用这种格式时误差仅为 0.00078125,而使用之前的格式时误差为 0.05)。

在定点二进制计数系统中,无论你向定点表示的分数部分添加多少位,总有一些值无法准确表示(1.3 恰好是这样一个值)。这可能是人们(错误地)认为十进制运算比二进制运算更精确的主要原因(尤其是在处理像 0.1、0.2、0.3 等十进制小数时)。

为了对比这两种系统的精度,我们考虑一个定点十进制系统(使用 BCD 表示法)。如果我们选择一个 16 位格式,其中 8 位用于整数部分,8 位用于分数部分,那么我们可以表示范围为 0.0 到 99.99 的十进制值,小数点右侧有两位十进制精度。我们可以通过像$0130这样的十六进制值精确表示 1.3(在这个数字中,隐含的小数点出现在第二位和第三位之间)。只要在计算中仅使用分数值 0.00 到 0.99,这种 BCD 表示法比二进制定点表示法(使用 8 位分数部分)更精确。

然而,通常情况下,二进制格式更为精确。二进制格式允许你精确表示 256 个不同的分数值,而 BCD 格式只能表示 100 个。如果你选择一个任意的分数值,那么二进制定点表示法通常能比十进制格式提供更好的近似值(因为二进制分数值的数量是十进制的两倍多)。(你可以将这个比较扩展到更大的格式:例如,使用 16 位分数部分,十进制/BCD 定点格式提供精确的四位数字精度;而二进制格式则提供超过六倍的分辨率——65,536 个分数值,而不是 10,000 个。) 十进制定点格式只有在你经常处理它能精确表示的分数值时才有优势。在美国,货币计算通常会产生这些分数值,因此程序员认为十进制格式在货币计算中更好。然而,考虑到大多数金融计算要求的精度(通常是小数点后四位是最小精度),通常使用二进制格式更好。

如果你确实需要精确表示 0.00 到 0.99 之间的分数值,并且要求至少有两位数字的精度,那么二进制定点格式就不是一个可行的解决方案。幸运的是,你不必使用十进制格式;正如你将很快看到的,还有其他二进制格式可以让你精确表示这些值。

2.11 缩放数字格式

幸运的是,有一种数字表示方法,它结合了某些十进制分数的精确表示和二进制格式的精度。这个格式被称为缩放数字格式,它在使用时也很高效,并且不需要任何特殊硬件。

缩放数字格式的另一个优点是,你可以选择任何进制,而不仅仅是十进制。例如,如果你处理的是三进制(基数为 3)分数,你可以将原始输入值乘以 3(或 3 的幂),并精确表示像¹/[3]、²/[3]、⁴/[9]、⁷/[27]这样的值——这是在二进制或十进制数字系统中无法做到的。

为了表示分数值,你将原始值乘以某个数值,将分数部分转换为整数。例如,如果你希望在小数点右边保持两位精度,输入时将数值乘以 100。这将类似于将 1.3 转换为 130,我们可以精确地使用整数表示。如果你对所有的分数值都进行这样的转换(并且它们都在小数点右边有相同的两位精度),那么你可以使用标准的整数运算操作来处理这些数值。例如,如果你有值 1.5 和 1.3,它们的整数转换分别为 150 和 130。将这两个值相加,你得到 280(这对应于 2.8)。当你需要输出这些值时,你将它们除以 100,并将商作为整数部分,余数(如果需要,零扩展至两位)作为分数部分。除了需要编写专门的输入和输出例程来处理乘以 100 和除以 100(以及处理小数点)外,你会发现这种缩放数字方案几乎和普通的整数运算一样简单。

如果你按照这里描述的方式缩放数值,那么你限制了数字的整数部分的最大范围。例如,如果你需要在小数点右边保留两位精度(即将原始值乘以 100),那么你只能表示(无符号)范围为 0 到 42,949,672 的值,而不是正常范围 0 到 4,294,967,296。

当你使用缩放格式进行加法或减法时,两个操作数必须具有相同的缩放因子。如果你将左边的操作数乘以 100,那么右边的操作数也必须乘以 100。例如,如果你将变量i10缩放了 10,且将变量j100缩放了 100,那么你需要将i10乘以 10(将其缩放至 100)或将j100除以 10(将其缩放至 10),然后才能进行加法或减法运算。这样可以确保两个操作数的小数点位置相同(注意,这适用于文字常量和变量)。

在乘法和除法操作中,操作数在运算之前不需要相同的缩放因子。然而,一旦运算完成,你可能需要调整结果。假设你有两个值,分别将它们缩放了 100 来保留小数点后两位精度,i = 25(0.25)和 j = 1(0.01)。如果你使用标准的整数运算计算k = i * j,你将得到 25(25 × 1 = 25),这被解释为 0.25,但结果应该是 0.0025。计算是正确的;问题在于理解乘法运算符的工作原理。实际上,我们在计算的是:

(0.25 × (100)) × (0.01 × (100)) = 0.25 × 0.01 × (100 × 100)(交换律允许这样做)= 0.0025 × (10,000) = 25

最终结果实际上会被放大 10000 倍,因为ij都被乘以了 100;当你乘以它们的值时,结果会是乘以 10000(100 × 100),而不是 100。为了解决这个问题,在计算完成后,你应该将结果除以缩放因子。例如,k = (i * j)/100

除法操作也有类似的问题。假设我们有m = 500(5.0)和n = 250(2.5),并且我们想计算k = m/n。我们通常期望得到 200(2.0,5.0/2.5)的结果。然而,实际上我们正在计算的是:

(5 × 100) / (2.5 × 100) = 500/250 = 2

一开始看起来这似乎是正确的,但当你考虑到缩放操作后,结果实际上是 0.02。我们需要的结果是 200(2.0)。除以缩放因子会在最终结果中消除缩放因子。因此,为了正确计算结果,我们需要计算k = 100 * m/n

乘法和除法对你可用的精度有限制。如果你必须先将被除数乘以 100,那么被除数必须至少比最大整数值小 100 倍,否则将发生溢出(产生错误的结果)。同样,当你乘以两个缩放值时,最终结果必须比最大整数值小 100 倍,否则也会发生溢出。由于这些问题,在使用缩放数值表示时,你可能需要保留额外的位或使用小数字。

2.12 有理数表示法

我们看到的分数表示法的一个大问题是,它们提供的是一个近似值,而不是一个精确的表示,适用于所有有理数值。^(6) 例如,在二进制或十进制中,无法精确表示值¹/[3]。你可以切换到三进制(基数为 3)的编号系统,并精确表示¹/[3],但这样你就无法精确表示像¹/[2]或¹/[10]这样的分数值。我们需要一个可以表示任何有理分数值的编号系统。

有理数表示法使用整数对来表示分数值。一个整数表示分数的分子(n),另一个表示分母(d)。实际的值等于n/d。只要nd是“互质的”(即,不是都能被同一个数整除),这种方案提供了一个很好的分数值表示,在你为nd使用的整数表示范围内。算术运算相当简单;你可以使用在小学学习分数时学到的相同算法来加、减、乘、除分数值。然而,某些运算可能会产生非常大的分子或分母(以至于你会遇到整数溢出)。除了这个问题外,你可以使用这种方案表示广泛的分数值。

2.13 更多信息

Knuth, Donald E. 计算机程序设计艺术,第 2 卷:半数字算法。第 3 版。波士顿:Addison-Wesley,1998 年。

第三章:二进制算术和位运算

Image

正如第二章所解释的,理解计算机如何以二进制表示数据是编写在计算机上运行良好的软件的前提。与此同样重要的是理解计算机如何处理二进制数据。这是本章的重点,探讨了对二进制数据的算术、逻辑和位运算。

3.1 二进制和十六进制数的算术运算

通常,你需要手动对两个二进制(或十六进制)值进行运算,以便将结果用于你的源代码中。虽然有计算器可以计算这些结果,但你应该能够手动对二进制操作数进行简单的算术运算。十六进制算术相对复杂,因此十六进制计算器(或支持十六进制运算的软件计算器,如 Windows 计算器或智能手机应用)应该放在每个程序员的桌面上。然而,二进制算术比十进制算术更简单。

知道如何手动计算二进制算术结果非常重要,因为许多重要的算法使用这些运算(或其变种)。本节描述了如何手动进行二进制的加法、减法、乘法和除法运算,并执行各种逻辑运算。

3.1.1 二进制值的加法

将两个二进制值相加很简单;只需要掌握八条规则:^(1)

  • 0 + 0 = 0

  • 0 + 1 = 1

  • 1 + 0 = 1

  • 1 + 1 = 0,带进位

  • 进位 + 0 + 0 = 1

  • 进位 + 0 + 1 = 0,带进位

  • 进位 + 1 + 0 = 0,带进位

  • 进位 + 1 + 1 = 1,带进位

一旦掌握了这八条规则,你就可以将任意两个二进制值相加。以下是一个逐步的二进制加法示例:

          0101

        + 0011

        ------

Step 1: Add the LO bits (1 + 1 = 0 + carry).

            c

          0101

        + 0011

        ------

             0

Step 2: Add the carry plus the bits in bit position 1 (carry + 0 + 1 = 0 + carry).

           c

          0101

        + 0011

        -------

            00

Step 3: Add the carry plus the bits in bit position 2 (carry + 1 + 0 = 0 + carry).

          c

          0101

        + 0011

        ------

           000

Step 4: Add the carry plus the bits in bit position 3 (carry + 0 + 0 = 1).

          0101

        + 0011

        ------

          1000

这里是一些更多的示例:

  1100_1101       1001_1111       0111_0111

+ 0011_1011     + 0001_0001     + 0000_1001

-----------     -----------     -----------

1_0000_1000       1011_0000       1000_0000

3.1.2 二进制值的减法

与加法类似,二进制减法有八条规则:

  • 0 – 0 = 0

  • 0 – 1 = 1,借位

  • 1 – 0 = 1

  • 1 – 1 = 0

  • 0 – 0 – 借位 = 1,带借位

  • 0 – 1 – 借位 = 0,带借位

  • 1 – 0 – 借位 = 0

  • 1 – 1 – 借位 = 1,带借位

这是一个逐步的二进制减法示例:

          0101

        - 0011

        ------

Step 1: Subtract the LO bits (1 – 1 = 0).

          0101

        - 0011

        ------

             0

Step 2: Subtract the bits in bit position 1 (0 – 1 = 1 + borrow).

          0101

        - 0011

           b

        ------

            10

Step 3: Subtract the borrow and the bits in bit position 2 (1 – 0 – b = 0).

          0101

        - 0011

        ------

           010

Step 4: Subtract the bits in bit position 3 (0 – 0 = 0).

          0101

        - 0011

        ------

          0010

这里是一些更多的示例:

  1100_1101       1001_1111       0111_0111

- 0011_1011     - 0001_0001     - 0000_1001

-----------     -----------     -----------  

  1001_0010       1000_1110       0110_1110

3.1.3 二进制值的乘法

二进制数的乘法很简单;它遵循与十进制乘法相同的规则,只涉及 0 和 1:

  • 0 × 0 = 0

  • 0 × 1 = 0

  • 1 × 0 = 0

  • 1 × 1 = 1

这是一个逐步的二进制乘法示例:

      1010

   ×  0101

   -------

Step 1: Multiply the LO bit of the multiplier times the multiplicand.

      1010

   ×  0101

   -------

      1010    (1 × 1010)

Step 2: Multiply bit 1 of the multiplier times the multiplicand.

      1010

   ×  0101

   -------

      1010    (1 × 1010)

      0000    (0 × 1010)

   -------

     01010    (partial sum)

Step 3: Multiply bit 2 of the multiplier times the multiplicand.

      1010

   ×  0101

   -------

    001010    (previous partial sum)

    1010      (1 × 1010)

   -------

    110010    (partial sum)

Step 4: Multiply bit 3 of the multiplier times the multiplicand.

      1010

   ×  0101

   -------

    110010    (previous partial sum)

   0000       (0 × 1010)

   -------

   0110010    (product)

3.1.4 二进制值的除法

二进制除法使用与十进制除法相同的(长除法)算法。图 3-1 显示了一个十进制除法问题的步骤。

image

图 3-1:十进制除法(3456/12)

这个算法在二进制下更简单,因为在每一步中,你不需要猜测 12 能被余数整除多少次,也不需要将 12 与你的猜测相乘来获得需要减去的数值。在二进制算法的每一步中,除数只能整除余数零次或一次。例如,考虑将 27(11011)除以 3(11)的过程,如图 3-2 所示。

image

图 3-2:二进制长除法

3.2 位运算

我们需要在十六进制和二进制数上执行四种主要的逻辑运算:与(AND)、或(OR)、异或(XOR,排他或)和非(NOT)。与算术运算不同,执行这些运算不需要十六进制计算器。

逻辑与(AND)、或(OR)和异或(XOR)运算接受两个单比特操作数,并计算出以下结果:

AND:

            0 and 0 = 0

            0 and 1 = 0

            1 and 0 = 0

            1 and 1 = 1

OR:

            0 or 0 = 0

            0 or 1 = 1

            1 or 0 = 1

            1 or 1 = 1

XOR:

            0 xor 0 = 0

            0 xor 1 = 1

            1 xor 0 = 1

            1 xor 1 = 0

表 3-1,3-2 和 3-3 显示了与(AND)、或(OR)和异或(XOR)操作的真值表。真值表就像你在小学时接触过的乘法表。左列的值对应于操作数的左操作数,顶行的值对应于右操作数。结果位于行和列的交点处(针对特定的一对操作数)。

表 3-1: 与(AND)真值表

与(AND) 0 1
0 0 0
1 0 1

表 3-2: 或(OR)真值表

或(OR) 0 1
0 0 1
1 1 1

表 3-3: 异或(XOR)真值表

异或(XOR) 0 1
0 0 1
1 1 0

用通俗的话来说,逻辑“与”运算的定义是:“如果第一个操作数为 1 且第二个操作数为 1,结果为 1;否则,结果为 0。”我们也可以这样表达:“如果任何一个或两个操作数为 0,结果为 0。”逻辑“与”运算对于强制得到 0 结果很有用。如果其中一个操作数为 0,无论另一个操作数的值是什么,结果都是 0。如果其中一个操作数为 1,则结果是另一个操作数的值。

通常来说,逻辑“或”运算的定义是:“如果第一个操作数或第二个操作数(或两者)为 1,结果为 1;否则,结果为 0。”这也被称为包含运算。如果逻辑“或”运算的操作数之一为 1,则结果为 1。如果某个操作数为 0,则结果为另一个操作数的值。

用英语来说,逻辑“异或”运算的定义是:“如果第一个或第二个操作数为 1,但不是两个都为 1,结果为 1;否则,结果为 0。”如果某个操作数为 1,结果为另一个操作数的逆(inverse)值。

逻辑“非”运算是一元运算(意味着它只接受一个操作数)。表 3-4 是“非”运算的真值表。该运算符将其操作数的值取反。

表 3-4: 非(NOT)真值表

非(NOT) 0 1
1 0

3.3 二进制数和位串的逻辑运算

因为大多数编程语言操作的是 8 位、16 位、32 位或 64 位的位组,我们需要将这些逻辑操作的定义扩展到不仅限于单个位操作数,以便按位(或 *按位)的方式进行操作。给定两个值,按位逻辑函数对两个源操作数的位 0 进行操作,产生结果操作数的位 0;对两个操作数的位 1 进行操作,产生结果的位 1;以此类推。例如,如果你想计算两个 8 位数的按位逻辑 AND,你会对这两个数中的每一对位进行按位 AND 运算:

%1011_0101

%1110_1110

-----------

%1010_0100

这种按位执行同样适用于其他逻辑操作。当你使用逻辑 AND 和 OR 操作强制位为 01,以及使用逻辑 XOR 操作反转位时,它对于处理位串(如二进制数)非常重要。这些操作使你能够有选择性地操作值中的某些位,同时保持其他位不变。例如,如果你有一个 8 位的二进制值 X,并且你想确保第 4 到第 7 位为 0,可以将 X 与二进制值 %0000_1111 进行 AND 运算。此按位 AND 运算将强制 X 的高 4 位为 0,并且不改变 X 的低 4 位。同样,你可以通过将 X%0000_0001 进行 OR 运算,再将 X%0000_0100 进行异或(XOR)运算,来强制 X 的低位为 1,并反转 X 的第 2 位。

使用逻辑 AND、OR 和 XOR 运算操作位串被称为 掩码操作。这个术语来源于我们可以使用某些值(1 用于 AND,0 用于 OR 和 XOR)来“屏蔽”或“激活”操作数中的某些位,同时强制其他位为 01 或其反值。

几种语言提供了运算符,允许你计算其操作数的按位 AND、OR、XOR 和 NOT。C/C++/Java/Swift 语言族使用与号(&)表示按位 AND,竖线(|)表示按位 OR,插入符号(^)表示按位 XOR,波浪号(~)表示按位 NOT,如下所示:

// Here's a C/C++ example:

    i = j & k;    // Bitwise AND

    i = j | k;    // Bitwise OR

    i = j ^ k;    // Bitwise XOR

    i = ~j;       // Bitwise NOT

Visual Basic 和 Free Pascal/Delphi 语言允许你使用 andorxornot 运算符与整数操作数。对于 80x86 汇编语言,你可以使用 ANDORNOTXOR 指令。

3.4 有用的位操作

虽然位操作看起来可能有点抽象,但它们在许多非显而易见的用途上非常有用。本节描述了在各种语言中它们的一些有用特性。

3.4.1 使用 AND 测试位串中的位

你可以使用按位 AND 运算符来测试位串中的单个位,以查看它们是 0 还是 1。如果你将一个值与包含某个位位置为 1 的位串进行按位 AND 运算,且对应的位是 0,则 AND 运算的结果为 0;如果该位置的位是 1,结果则为非零。考虑以下 C/C++ 代码,它通过测试整数的第 0 位来检查一个整数值是奇数还是偶数:

IsOdd = (ValueToTest & 1) != 0;

在二进制形式下,以下是此按位与运算操作的实现:

xxxx_xxxx_xxxx_xxxx_xxxx_xxxx_xxxx_xxxx  // Assuming ValueToTest is 32 bits

0000_0000_0000_0000_0000_0000_0000_0001  // Bitwise AND with the value 1

---------------------------------------

0000_0000_0000_0000_0000_0000_0000_000x  // Result of bitwise AND

如果 ValueToTest 的最低位(LO bit)在位位置 0 处为 0,则结果为 0。如果 ValueToTest 在位位置 1 处为 1,则结果为 1。此计算忽略了 ValueToTest 中的其他所有位。

3.4.2 使用 AND 测试一组位是否为零或非零

你还可以使用按位与(bitwise AND)运算符来检查一组位是否全部为 0。例如,检查一个数字是否能被 16 整除的其中一种方法是检查最低的 4 位是否全为 0。以下是使用按位与运算符来实现这一点的 Free Pascal/Delphi 语句:

IsDivisibleBy16 := (ValueToTest and $f) = 0;

在二进制形式下,以下是此按位与运算操作的实现:

xxxx_xxxx_xxxx_xxxx_xxxx_xxxx_xxxx_xxxx  // Assuming ValueToTest is 32 bits

0000_0000_0000_0000_0000_0000_0000_1111  // Bitwise AND with $F

---------------------------------------

0000_0000_0000_0000_0000_0000_0000_xxxx  // Result of bitwise AND

只有当且仅当 ValueToTest 的最低 4 位全为 0 时,结果才为 0

3.4.3 比较二进制字符串中的一组位

AND 和 OR 操作特别有用,如果你需要将二进制值中的某一位子集与其他值进行比较。例如,你可能想比较两个 6 位值,这两个值分别位于两个 32 位值的位 0、1、10、16、24 和 31 中。诀窍是将所有不感兴趣的位设置为 0,然后比较两个结果。^(2)

请考虑以下三个二进制值;x 表示我们不关心的位:

%1xxxxxx0xxxxxxx1xxxxx0xxxxxxxx10

%1xxxxxx0xxxxxxx1xxxxx0xxxxxxxx10

%1xxxxxx1xxxxxxx1xxxxx1xxxxxxxx11

第一个和第二个二进制值(假设我们只关注位 31、24、16、10、1 和 0)是相等的。如果我们将这两个值中的任何一个与第三个值进行比较,我们会发现它们不相等。第三个值也大于前两个值。在 C/C++ 和汇编语言中,我们可以通过以下方式比较这些值:

// C/C++ example

    if( (value1 & 0x81010403) == (value2 & 0x81010403))

    {

        // Do something if bits 31, 24, 16, 10, 1, and 0 of

        // value1 and value2 are equal

    }

    if( (value1 & 0x81010403) != (value3 & 0x81010403))

    {

        // Do something if bits 31, 24, 16, 10, 1, and 0 of

        // value1 and value3 are not equal

    }

// HLA/x86 assembly example:

    mov( value1, eax );        // EAX = value1

    and( $8101_0403, eax );   // Mask out unwanted bits in EAX

    mov( value2, edx );        // EDX = value2

    and( $8101_0403, edx );   // Mask out the same set of unwanted bits in EDX

    if( eax = edx ) then      // See if the remaining bits match

        // Do something if bits 31, 24, 16, 10, 1, and 0 of

        // value1 and value2 are equal

    endif;

    mov( value1, eax );       // EAX = value1

    and( $8101_0403, eax );  // Mask out unwanted bits in EAX

    mov( value3, edx );       // EDX = value2

    and( $8101_0403, edx );  // Mask out the same set of unwanted bits in EDX

    if( eax <> edx ) then    // See if the remaining bits do not match

        // Do something if bits 31, 24, 16, 10, 1, and 0 of

        // value1 and value3 are not equal

    endif;

3.4.4 使用 AND 创建模-n* 计数器*

一个 模-n* 计数器* 从 0^(3) 计数到某个最大值,然后重置为 0。模-n 计数器非常适合创建重复的数字序列,例如 0, 1, 2, 3, 4, 5, . . . n – 1; 0, 1, 2, 3, 4, 5, . . . n – 1; 0, 1, . . .。你可以使用这样的序列来创建循环队列和其他在遇到数据结构末尾时重新利用数组元素的对象。创建模-n 计数器的常规方法是将计数器加 1,然后将结果除以 n,最后保留余数。以下是 C/C++、Pascal 和 Visual Basic 中模-n 计数器实现的代码示例:

cntr = (cntr + 1 ) % n;    // C/C++/Java/Swift

cntr := (cntr + 1) mod n;  // Pascal/Delphi

cntr = (cntr + 1) Mod n     ' Visual Basic

然而,除法是一种昂贵的操作,执行的时间远长于加法。通常,你会发现使用比较而不是取余运算符来实现模-n 计数器更为高效。以下是一个 Pascal 示例:

cntr := cntr + 1;      // Pascal example

if( cntr >= n ) then

    cntr := 0;

对于某些特殊情况,当 n 是 2 的幂时,你可以使用 AND 操作更高效、方便地增加模 n 计数器。为此,增加你的计数器,然后将其与值 x = 2^(m) – 1 进行逻辑与运算(2^(m) – 1 在比特位 0..m – 1 中包含 1,其他地方是 0)。因为 AND 操作比除法快得多,所以使用 AND 的模 n 计数器比使用余数操作符的要高效得多。在大多数 CPU 上,使用 AND 操作要比使用 if 语句快得多。以下示例展示了如何使用 AND 操作实现一个模 n 计数器,其中 n = 32:

//Note: 0x1f = 31 = 25 – 1, so n = 32 and m = 5

    cntr = (cntr + 1) & 0x1f;    // C/C++/Java/Swift example

    cntr := (cntr + 1) and $1f;  // Pascal/Delphi example

    cntr = (cntr + 1) and &h1f    ' Visual Basic example

汇编语言代码特别高效:

inc( eax );                      // Compute (eax + 1) mod 32

and( $1f, eax );

3.5 左移与旋转

另一组对比特串的逻辑操作是 左移旋转 操作。这些功能可以进一步细分为 左移左旋转右移右旋转。这些操作在许多程序中都非常有用。

左移操作将每个比特在比特串中向左移动一个位置,如 图 3-3 所示。比特 0 移动到比特位置 1,原先在比特位置 1 的值移到比特位置 2,依此类推。

image

图 3-3:左移操作(对一个字节)

你可能会问两个问题:“什么会进入比特 0?”和“HO 比特会最终去哪?”我们会将 0 移入比特 0,HO 比特的前一个值将成为这次操作的进位

许多高级语言(如 C/C++/C#、Swift、Java 和 Free Pascal/Delphi)提供了左移操作符。在 C 语言家族中,该操作符是 <<。在 Free Pascal/Delphi 中,你使用 shl 操作符。以下是一些示例:

// C:

        cLang = d << 1;     // Assigns d shifted left one position to

                            // variable "cLang"

// Delphi:

        Delphi := d shl 1;  // Assigns d shifted left one position to

                            // variable "Delphi"

将一个数字的二进制表示向左移动一位,相当于将该值乘以 2。如果你使用的编程语言没有提供显式的左移操作符,你可以通过将二进制整数值乘以 2 来模拟这一操作。虽然乘法操作通常比左移操作慢,但大多数编译器足够智能,能够将乘以 2 的常数转换为左移操作。因此,你可以像下面这样在 Visual Basic 中编写代码来进行左移:

vb = d * 2

右移操作类似于左移操作,区别在于我们将数据朝相反方向移动。比特 7 移动到比特 6,比特 6 移动到比特 5,比特 5 移动到比特 4,依此类推。在右移操作中,我们会将 0 移入比特 7,比特 0 将成为操作的进位(参见 图 3-4)。C、C++、C#、Swift 和 Java 使用 >> 操作符进行右移操作。Free Pascal/Delphi 使用 shr 操作符。大多数汇编语言也提供了右移指令(在 80x86 上是 shr)。

image

图 3-4:右移操作(对一个字节)

将一个无符号二进制值右移一个位置相当于将该值除以 2。例如,如果你将无符号表示的 254 ($FE) 右移一位,你得到的是 127 ($7F),完全符合预期。然而,如果你将 8 位二进制补码表示的 –2 ($FE) 右移一位,你得到的是 127 ($7F),这显然是不正确的。为了通过右移将有符号数除以 2,我们使用第三种右移操作——算术右移,它不会改变 HO 位的值。图 3-5 展示了一个 8 位操作数的算术右移操作。

image

图 3-5:算术右移操作(字节级)

对于二进制补码的有符号操作数,这通常会产生你预期的结果。例如,如果你对 –2 ($FE) 执行算术右移操作,得到的是 –1 ($FF)。然而,值得注意的是,这种操作总是将数字舍入到小于或等于实际结果的最接近整数。如果你对 –1 ($FF) 执行算术右移,结果是 –1,而不是 0。因为 –1 小于 0,所以算术右移操作会向 –1 舍入。这不是算术右移操作的“bug”;它只是采用了不同(尽管有效)的整数除法定义。关键是,在不支持算术右移的语言中,你可能无法使用有符号除法运算符来代替算术右移,因为大多数整数除法运算符是向 0 舍入的。

高级语言同时支持逻辑右移和算术右移的情况很少见。更糟糕的是,某些语言的规范将是否使用算术右移或逻辑右移操作的决定权留给编译器的实现者。因此,只有在操作数的最高有效位(HO 位)会导致两种右移操作产生相同结果时,使用右移运算符才是安全的。为了保证右移是逻辑右移或算术右移操作,你必须降到汇编语言中,或者手动处理 HO 位。高级代码很快就会变得难以维护,因此如果你的程序不需要跨不同 CPU 可移植,快速的内联汇编语句可能是更好的解决方案。以下代码演示了如何在不保证右移类型的语言中模拟 32 位逻辑右移和算术右移:

// Written in C/C++, assuming 32-bit integers, logical shift right:

    // Compute bit 30.

    Bit30 = ((ShiftThisValue & 0x80000000) != 0) ? 0x40000000 : 0;

    // Shifts bits 0..30.

    ShiftThisValue = (ShiftThisValue & 0x7fffffff) >> 1;

    // Merge in Bit #30.

    ShiftThisValue = ShiftThisValue | Bit30; 

// Arithmetic shift right operation

    Bits3031 = ((ShiftThisValue & 0x80000000) != 0) ? 0xC0000000 : 0;

    // Shifts bits 0..30.

    ShiftThisValue = (ShiftThisValue & 0x7fffffff) >> 1;

    // Merge bits 30/31.

    ShiftThisValue = ShiftThisValue | Bits3031;

许多汇编语言还提供各种旋转指令,通过将操作数一端移出的位重新移入另一端,循环移动位。很少有高级语言提供这种操作;幸运的是,你通常不需要使用它。如果你确实需要,你可以使用你所用高级语言中可用的移位运算符来合成这种操作:

// Pascal/Delphi Rotate Left, 32-bit example:

// Puts bit 31 into bit 0, clears other bits.

CarryOut := (ValueToRotate shr 31); 

ValueToRotate := (ValueToRotate shl 1) or CarryOut;

有关可能的移位和旋转操作的更多信息,请参考 汇编语言的艺术(No Starch Press)。

3.6 比特字段和打包数据

CPU 通常在字节、字、双字和四字数据类型上运行最为高效,^(4)但偶尔你需要处理的某些数据类型的大小并非 8、16、32 或 64 位。在这种情况下,你可能通过将不同的比特串尽可能紧凑地打包在一起而节省一些内存,避免浪费任何比特来对齐特定的数据字段到字节或其他边界。

假设日期为 04/02/01。表示这个日期需要三个数字值:月份、日期和年份。月份使用 1 到 12 的值,至少需要 4 个比特来表示。日期使用 1 到 31 的范围,需要 5 个比特来表示。年份值,假设我们处理的是 0 到 99 之间的值,则需要 7 个比特。4 + 5 + 7 总共是 16 个比特,或者 2 字节。我们可以将日期数据打包成 2 字节,而不是每个值使用一个单独的字节,这样每存储一个日期就能节省 1 字节内存。如果你需要存储许多日期,这可能会带来显著的节省。你可以像 图 3-6 中所示那样排列这些比特。

image

图 3-6:短整型打包日期格式(16 位)

MMMM 代表保存月份值的 4 个比特,DDDDD 代表保存日期值的 5 个比特,YYYYYYY 代表保存年份值的 7 个比特。每一组表示数据项的比特就是一个 比特字段。我们可以用 $4101 来表示 2001 年 4 月 2 日:

0100    00010    0000001    = %0100_0001_0000_0001 or $4101

 04      02       01

尽管打包值在空间上是高效的(也就是说,它们使用较少的内存),但它们在计算上是低效的(慢!)。原因是什么?因为需要额外的指令从不同的比特字段中解包数据。这些额外的指令需要时间来执行(并且还需要额外的字节来存储指令);因此,你必须仔细考虑打包数据字段是否真的能为你节省什么。以下是一个示例 HLA/x86 代码,演示了如何打包和解包这种 16 位日期格式。

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^(5) 问题,采用仅支持两位数年份的日期格式是相当愚蠢的。考虑 图 3-7 中展示的更好的日期格式。

image

图 3-7:长整型打包日期格式(32 位)

由于 32 位变量中的比特数超过了存储日期所需的数量,即使考虑到年份范围为 0 到 65,535,这种格式仍为 monthday 字段分配了一个完整的字节。应用程序可以将这两个字段作为字节对象进行操作,从而减少在支持字节访问的处理器上打包和解包这些字段的开销。这为年份留下了更少的比特,但 65,536 年可能足够了(你可以放心地认为你的软件在 63,000 年后不会再使用)。

你可以认为这不再是打包日期格式。毕竟,我们需要三个数值,其中两个可以各自正好适应 1 个字节,而一个至少需要 2 个字节。这种“打包”的日期格式消耗了与解包版本相同的 4 个字节,而不是最少的位数。所以,在这个例子中,打包实际上意味着打包封装。通过将数据打包到一个双字变量中,程序可以将日期值视为一个单一的数据值,而不是三个单独的变量。这意味着你通常可以用一条机器指令操作这组数据,而不是三条单独的指令。

这个长日期格式与 图 3-6 中的短日期格式之间的另一个区别在于,长日期格式重新排列了 年份月份日期 字段。这使你可以轻松地使用无符号整数比较两个日期。考虑以下 HLA/汇编代码:

mov( Date1, eax );        // Assume Date1 and Date2 are double-word variables

if( eax > Date2 ) then    // using the long packed date format.

    << do something if Date1 > Date2 >>

endif;

如果你将不同的日期字段保存在单独的变量中,或者以不同方式组织字段,你就无法像现在这样简单地比较 Date1Date2。即使你没有节省任何空间,打包数据也可以使某些计算变得更方便,甚至更高效(这与通常在打包数据时发生的情况相反)。

一些高级语言提供对打包数据的内置支持。例如,在 C 中,你可以定义如下结构:

struct

{

    unsigned bits0_3   :4;

    unsigned bits4_11  :8;

    unsigned bits12_15 :4;

    unsigned bits16_23 :8;

    unsigned bits24_31 :8;

} packedData;

这个结构指定每个字段是一个无符号对象,分别包含 4 位、8 位、4 位、8 位和 8 位。每个声明后的 :n 项指定编译器为给定字段分配的最小位数。

不幸的是,无法展示 C/C++ 编译器如何在字段之间分配来自 32 位双字的数据值,因为 C/C++ 编译器的实现者可以自由选择任何方式实现这些位字段。位字符串中位的排列是任意的(例如,编译器可能将 bits0_3 字段分配到最终对象的第 28 到 31 位之间)。编译器还可以在字段之间插入额外的位,或者为每个字段使用更多的位(这实际上和在字段之间插入额外的填充位是同一件事)。大多数 C 编译器会尽量减少多余的填充,但编译器(尤其是在不同的 CPU 上)确实存在差异。因此,C/C++ 结构体位字段声明几乎肯定是不可移植的,你无法依赖编译器对这些字段的处理方式。

使用编译器的内置数据打包功能的优点是,编译器会自动为你打包和解包数据。给定以下 C/C++ 代码,编译器会自动生成必要的机器指令,为你存储和检索单个位字段:

struct

{

    unsigned year  :7;

    unsigned month :4;

    unsigned day   :5;

} ShortDate;

        . . .

    ShortDate.day = 28;

    ShortDate.month = 2;

    ShortDate.year = 3;  // 2003

3.7 数据打包与解包

打包数据类型的优点是高效的内存使用。以美国的社会安全号码(SSN)为例,它是一个九位数的身份识别码,格式如下(每个 X 代表一个数字):

XXX–XX–XXXX

使用三个单独的 32 位整数来编码 SSN 需要 12 字节。这比使用字符数组表示该数字所需的 11 字节还要多。更好的解决方案是使用短整数(16 位)编码每个字段。这样只需要 6 字节就能表示 SSN。由于 SSN 中间字段的值总是在 0 到 99 之间,我们实际上可以通过用一个字节来编码中间字段,再缩减这个结构的大小。以下是一个示例 Free Pascal/Delphi 记录结构,定义了这个数据结构:

SSN :record

        FirstField:  smallint;  // smallints are 16 bits in Free Pascal/Delphi

        SecondField: byte;

        ThirdField:  smallint;

end;

如果我们去掉 SSN 中的连字符,结果是一个九位数的数字。因为我们可以用 30 位精确表示所有九位数值,因此可以使用 32 位整数来编码任何合法的 SSN。然而,一些处理 SSN 的软件可能需要操作各个字段。这意味着必须使用昂贵的除法、取余和乘法运算符,以从你已编码为 32 位整数格式的 SSN 中提取字段。此外,使用 32 位格式时,将 SSN 转换为字符串并进行转换会更复杂。

相反,使用快速机器指令插入和提取单独的位字段非常容易,而且创建这些字段的标准字符串表示(包括连字符)也更轻松。图 3-8 展示了使用每个字段的单独位串实现 SSN 打包数据类型的简单实现(请注意,该格式使用 31 位并忽略了 HO 位)。

image

图 3-8:SSN 打包字段编码

在打包数据对象中,从第 0 位开始的字段可以最有效地访问,因此你应该在打包数据类型中安排字段,使得你最常访问的字段^(6)从第 0 位开始。如果你不知道哪个字段会被访问得最频繁,则应将字段安排在字节边界上。如果你的打包类型中有未使用的位,应将它们均匀分布在整个结构中,以确保各个字段从字节边界开始,并且这些字段占据的是 8 位的倍数。

在图 3-8 中展示的 SSN 示例中,我们只有一个未使用的位,但实际上我们可以利用这个额外的位将两个字段对齐到字节边界,并确保其中一个字段占据一个长度为 8 位倍数的位串。请参考图 3-9,它展示了我们 SSN 数据类型的重新排列版本。

image

图 3-9:可能改进的 SSN 编码

图 3-9 中的数据格式的一个问题是,我们无法通过比较 32 位无符号整数以直观的方式对 SSN 进行排序。^(7) 如果你打算对整个 SSN 进行大量排序,图 3-8 中的格式可能会更好。

如果这种排序方式对你不重要,那么图 3-9 中的格式有一些优势。这个打包格式实际上使用了 8 位(而不是 7 位)来表示SecondField(同时将SecondField移到位位置 0);额外的一位将始终包含0。这意味着SecondField占用了位 0 到 7(一个完整的字节),而ThirdField从字节边界开始(位位置 8)。ThirdField并不占用 8 位的倍数,且FirstField并不从字节边界开始,但考虑到我们只多了一个额外的位来处理,整个编码效果还算不错。

下一个问题是:“我们如何访问这个打包类型的字段?”这里有两个独立的操作。我们需要检索或提取打包字段,并且需要插入数据到这些字段中。AND、OR 和 SHIFT 操作提供了完成这些操作的工具。

在操作这些字段时,使用三个独立的变量而不是直接操作打包数据会更方便。以我们的 SSN 示例为例,我们可以创建三个变量——FirstFieldSecondFieldThirdField——然后从打包的值中提取实际数据到这三个变量中,操作这些变量,最后在完成操作后将数据插入回各自的字段。

要从图 3-9 所示的打包格式中提取SecondField数据(记住,位 0 对齐的字段最容易访问),将打包表示中的数据复制到SecondField变量,然后使用 AND 操作屏蔽除SecondField位之外的所有位。因为SecondField是一个 7 位的值,所以掩码是一个整数,包含位位置 0 到 6 为1,其他位置为0。下面的 C/C++ 代码演示了如何将此字段提取到SecondField变量中(假设 packedValue 是一个保存了 32 位打包 SSN 的变量):

SecondField = packedValue & 0x7f;   // 0x7f = %0111_1111

提取那些没有对齐在位 0 上的字段需要稍微多一点工作。考虑图 3-9 中的ThirdField条目。我们可以通过逻辑 AND 操作将打包值与%_11_1111_1111_1111_0000_0000$3F_FF00)进行与运算,从而屏蔽掉与第一和第二字段相关的所有位。然而,这样会把ThirdField值留在位 8 到 21 中,这对于各种算术操作并不方便。解决方法是将掩码后的值向下移 8 位,使其与我们的工作变量的位 0 对齐。下面的 Pascal/Delphi 代码实现了这个过程:

ThirdField := (packedValue and $3fff00) shr 8;

你也可以先进行移位操作,然后执行逻辑与(AND)操作(不过这需要使用不同的掩码,$11_1111_1111_1111 或 $3FFF)。以下是使用该技术提取ThirdField的 C/C++/Swift 代码:

ThirdField = (packedValue >> 8) & 0x3FFF;

为了提取一个对齐在高位(HO)位的字段,例如我们 SSN 打包数据类型中的第一个字段,需要将高位字段移到第 0 位。逻辑右移操作会自动将结果的高位填充为0,因此不需要掩码操作。以下 Pascal/Delphi 代码演示了这一过程:

FirstField := packedValue shr 22; // Delphi's SHR is a logical shift right.

在 HLA/x86 汇编语言中,我们可以轻松地访问内存中任意字节边界的数据。这使得我们能够将第二个和第三个字段看作它们在数据结构中的对齐位置是从第 0 位开始的。此外,由于SecondField值是一个 8 位值(高位始终为0),因此只需要一个机器指令就能解包数据,如下所示:

movzx( (type byte packedValue), eax );

该指令提取打包值对象的第一个字节(在 80x86 架构中是打包值的低 8 位),并将该值零扩展为 32 位存储在 EAX 寄存器中(movzx表示“带零扩展的移动”)。该指令执行后,EAX 寄存器包含SecondField值。

来自打包数据类型的ThirdField值并不是 8 位的整数倍长度,因此我们仍然需要进行一个掩码操作,以清除我们产生的 32 位结果中的未使用位。然而,由于ThirdField在打包结构中是对齐在字节(8 位)边界上的,我们将能够避免在高级代码中所需的移位操作。以下是提取打包值对象中第三个字段的 HLA/x86 汇编代码:

mov( (type word packedValue[1]), ax );    // Extracts bytes 1 & 2 

                                        // from packedValue.

and( $3FFF, eax );                      // Clears all the undesired bits.

在 HLA/x86 汇编代码中,从打包值对象中提取FirstField与高级代码相同;我们只需将包含FirstField的上 10 位移到第 0 位:

mov( packedValue, eax );

shr( 22, eax );

假设你要插入的数据存储在某个变量中,并且在未使用的位中包含0,那么将一个字段插入到一个打包的对象中需要三步操作。首先,如果有必要,你需要将字段的数据向左移动,使其对齐方式与打包对象中对应字段的对齐方式一致。接下来,清除打包结构中的对应位,然后使用逻辑或(OR)操作将移动后的字段插入到打包对象中。图 3-10 显示了这一操作的细节。

image

图 3-10:将ThirdField插入到 SSN 打包类型中

以下是完成图 3-10 中显示操作的 C/C++/Swift 代码:

packedValue = (packedValue & 0xFFc000FF) | (ThirdField << 8 );

$FFC000FF是一个十六进制值,它对应于从第 8 位到第 21 位的0,而其他位置是1

3.8 更多信息

Hyde, Randall. 汇编语言艺术。第二版。旧金山:No Starch Press,2010。

Knuth, Donald E. 计算机程序设计的艺术,第 2 卷:半数值算法。第三版。波士顿:Addison-Wesley,1998。

第四章:浮点表示

Image

浮点算术是实数算术的近似,它解决了整数数据类型的一个主要问题——无法表示小数值。然而,这种近似中的不准确性可能会导致应用软件中的严重缺陷。为了编写能够在使用浮点算术时产生正确结果的优秀软件,程序员必须了解计算机底层的数字表示,并准确理解浮点算术如何近似实数算术。

4.1 浮点算术简介

实际上有无限多的实数值。浮点表示使用有限数量的位,因此只能表示有限数量的不同值。当给定的浮点格式无法精确表示某个实数值时,会使用该格式可以精确表示的最接近的值。本节将描述浮点格式的工作原理,以便你更好地理解这些近似值的缺点。

考虑一下整数和定点格式的一些问题。整数不能表示任何小数值,它们只能表示范围在 0 到 2^(n) – 1 或–2(*n*)(–1)到 2(*n*)(–1) – 1 之间的值。定点格式表示小数值,但以牺牲它们可以表示的整数值的范围为代价。浮点格式解决的这个问题就是【动态范围】(gloss01.xhtml#gloss01_85)

考虑一个简单的 16 位无符号定点格式,它使用 8 位表示小数部分,8 位表示整数部分。整数部分可以表示 0 到 255 之间的值,小数部分可以表示 0 和介于 2^(–8)与 1 之间的小数(精度大约为 2^(–8))。如果在一串计算中只需要 2 位表示小数值 0.0、0.25、0.5 和 0.75,那么小数部分的额外 6 位就浪费了。如果我们能将这些位用在数字的整数部分,将其范围从 0 到 255 扩展到 0 到 16,383,那该多好呢?这就是浮点表示的基本概念。

在浮点值中,基数点(二进制点)可以根据需要在数字的各个数字之间浮动。因此,在一个 16 位二进制数中,如果只需要 2 位精度表示小数部分,那么二进制点可以在第 1 位和第 2 位之间浮动,从而将第 2 位到第 15 位用于整数部分。浮点格式需要一个额外的字段来指定基数点在数字中的位置,这相当于科学计数法中的指数部分。

大多数浮点格式使用若干位来表示尾数,并用较少的位来表示指数. 尾数是一个通常位于有限范围内的基本值(例如,介于 0 和 1 之间)。指数是一个乘数,应用于尾数后,能生成超出该范围的值。尾数/指数配置的一个大优点是,浮点格式可以表示广泛范围的值。然而,将数字分为这两部分意味着浮点格式只能表示具有特定数量有效数字的数字。如果最小指数与最大指数之间的差值大于尾数中有效数字的数量(通常是这样的),那么浮点格式就无法精确表示浮点表示中最小和最大值之间的所有整数。

为了看到有限精度算术的影响,我们将采用一个简化的十进制浮点格式作为例子。我们的浮点格式将使用一个有三位有效数字的尾数,并使用一个有两位数字的十进制指数。尾数和指数都是有符号值,如图 4-1 所示。

image

图 4-1:简单的浮点格式

这种特定的浮点表示可以逼近 0.00 到 9.99 × 10⁹⁹之间的所有值。然而,这种格式无法表示这个范围内的所有(整数)值(那需要 100 位的精度!)。像 9,876,543,210 这样的值将被逼近为 9.88 × 10⁹(或9.88e+9,这是本书中通常使用的编程语言表示法)。

浮点格式无法像整数格式那样精确表示那么多不同的值,因为浮点格式对同一值编码了多种表示(即不同的位模式)。例如,在图 4-1 中展示的简化十进制浮点格式中,1.00e + 10.10e + 2是同一值的不同表示。由于不同可能表示的数量是有限的,每当一个值有两个可能的表示时,这就意味着该格式能表示的唯一值少了一个。

此外,浮点格式(科学计数法的一种形式)使得算术运算变得稍微复杂一些。在进行科学计数法下的加法和减法时,必须调整两个数值,使它们的指数相同。例如,在加法运算1.23e14.56e0时,可以将4.56e0转换为0.456e1,然后再进行相加。结果1.686e1无法符合我们当前格式的三位有效数字,因此我们必须将结果四舍五入截断至三位有效数字。通常,四舍五入能产生最准确的结果,因此我们将结果四舍五入为1.69e1。缺乏精度(在计算中保持的数字或位数)会影响准确性(计算的正确性)。

在前面的例子中,我们能够四舍五入结果,因为我们在计算过程中保持了位有效数字。如果我们的浮点计算在计算过程中仅限于三位有效数字,我们就必须截断(舍弃)较小数字的最后一位,得到1.68e1,这结果甚至不那么准确。因此,为了提高准确性,我们在计算过程中使用额外的数字。这些额外的数字被称为守卫数字(在二进制格式中称为守卫位)。它们在一长串计算过程中大大提高了准确性。

单次计算中丧失的准确性通常不会很严重。然而,误差可能会在一系列浮点运算中累积,从而对整个计算结果产生较大影响。例如,假设我们将1.23e31.00e0相加。调整这两个数值,使它们的指数相同后进行加法得到1.23e3 + 0.001e3。即使四舍五入后,这两者之和仍为1.23e3。这可能看起来完全合理:如果我们只能保持三位有效数字,添加一个小值应该不会影响结果。然而,假设我们将1.00e01.23e3相加 10 次。第一次加法时,我们得到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)

通常,你会将yz相加,然后将它们的和乘以x。然而,如果你首先将表达式转化为以下形式,你会获得更高的精度:

x × y + x × z

现在你可以通过先执行乘法来计算结果。^(1)

乘法和除法也有其他问题。当你将两个非常大的或非常小的数字相乘时,可能会发生溢出下溢。当你将一个小数字除以一个大数字,或一个大数字除以一个小数字时,也会发生这种情况。这引出了第四条规则:

在乘法和除法数字集合时,尽量选择具有相同相对大小的数字进行相乘或相除。

比较浮点数是非常危险的。考虑到任何计算中固有的不准确性(包括将输入字符串转换为浮点值),你永远不要直接比较两个浮点值是否相等。不同的计算可能会产生相同的(数学上的)结果,但它们的最低有效位可能不同。例如,将1.31e01.69e0相加应该得到3.00e0。同样,将1.50e01.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 . . .

误差值应稍大于你在计算中允许的最大误差值。确切的误差值取决于你使用的特定浮点格式以及你正在比较的值的大小。因此,最终的规则是:

在比较两个浮点数是否相等时,始终比较这两个值之间的差异,看它是否小于某个小的误差值。

检查两个浮点数是否相等是一个非常著名的问题,几乎所有的入门编程教材都会讨论这个问题。然而,对于小于或大于的比较,类似的问题并没有那么广为人知。假设一系列浮点计算的结果仅在±误差范围内准确,尽管浮点表示提供的精度比误差所暗示的更好。如果你将这样的结果与某个计算误差较小的结果进行比较,并且这两个值非常接近,那么进行小于或大于的比较可能会产生不正确的结果。

例如,假设我们在简化的十进制表示中进行的一系列计算得出 1.25,这个结果的准确度仅为±0.05(即,真实值可能在 1.20 和 1.30 之间),而第二个计算链产生的结果是 1.27,且其准确度达到浮点表示的最大精度(即,在四舍五入之前,实际值可能在 1.265 和 1.275 之间)。将第一个计算结果(1.25)与第二个计算结果(1.27)进行比较时,发现第一个结果小于第二个结果。不幸的是,鉴于第一个计算的不准确性,这可能并不成立——例如,如果第一个计算的正确结果在 1.27 到 1.30 之间(不包括 1.30)。

唯一合理的测试方法是检查这两个值是否在误差容限范围内。如果是,则视为相等(两者不认为小于或大于对方)。如果这些值在期望的误差容限内不相等,你可以将它们进行比较,看是否一个值小于或大于另一个值。这被称为吝啬方法;也就是说,我们尽量找出最少的值小于或大于的情况。

另一种可能性是使用急功近利方法,它试图尽可能使比较结果为true。给定两个要比较的值和一个误差容限,下面是如何急功近利地比较两个值的大小:

if( A < (B + error) ) then Eager_A_lessthan_B;

if( A > (B – error) ) then Eager_A_greaterthan_B;

不要忘记,像(B +误差)这样的计算也可能会有自己的不准确性,这取决于值B和误差的相对大小,以及此计算的不准确性可能会影响比较的最终结果。

注意

由于篇幅限制,本书仅简单介绍了在使用浮点值时可能出现的一些主要问题,以及为什么你不能将浮点运算当作实数运算来看待。如需详细了解,请查阅一本优秀的数值分析或科学计算教材。如果你打算使用浮点运算,无论使用哪种语言,都应花时间研究有限精度运算对计算结果的影响。

4.2 IEEE 浮点格式

当英特尔计划为其原始的 8086 微处理器引入浮点单元(FPU)时,公司足够聪明地意识到,设计芯片的电气工程师和固态物理学家可能没有足够的数值分析背景来设计一个好的浮点表示。所以,英特尔出资雇用了它能找到的最优秀的数值分析师来为其 8087 FPU 设计浮点格式。那位专家随后雇用了另外两位该领域的专家,他们三人(卡汉、库宁和斯通)共同设计了KCS 浮点标准。他们的工作非常出色,以至于 IEEE 组织将此格式作为 IEEE Std 754 浮点格式的基础。

为了处理广泛的性能和精度要求,英特尔实际上引入了三种浮点格式:单精度、双精度和扩展精度。单精度和双精度格式对应于 C 语言中的floatdouble类型,或 FORTRAN 中的realdouble precision类型。扩展精度包含 16 个额外的位,长链计算可以使用这些位作为保护位,在将结果四舍五入到双精度值时进行存储。

4.2.1 单精度浮点格式

单精度格式使用 24 位尾数和 8 位指数。尾数表示一个值,范围在 1.0 到接近 2.0 之间。尾数的 HO 位始终为1,表示二进制点的左侧值。剩余的 23 位尾数位出现在二进制点右侧,表示该值:

1.mmmmmmm mmmmmmmm mmmmmmmm

由于隐含的1位,尾数始终大于或等于 1。即使其他尾数位都是0,隐含的1位也总是给我们值1。二进制点右侧的每个位置表示一个值(01)乘以连续的负二次幂,但即使我们在二进制点后有几乎无限多个1位,它们仍然不能加到 2。因此,尾数可以表示的值范围是 1.0 到接近 2.0。

这里可能需要一些例子。考虑十进制值 1.7997。以下是我们可以用来计算此值的二进制尾数的步骤:

  1. 从 1.7997 中减去 2⁰,得到 0.7997 和%1.00000000000000000000000

  2. 从 0.7997 中减去 2^(–1) (¹/[2]),得到 0.2997 和%1.10000000000000000000000

  3. 从 0.2997 中减去 2^(–2) (¹/[4]),得到 0.0497 和%1.11000000000000000000000

  4. 从 0.0497 中减去 2^(–5) (¹/[32]),得到 0.0185 和%1.11001000000000000000000

  5. 从 0.0185 中减去 2^(–6) (¹/[64]),得到 0.00284 和%1.11001100000000000000000

  6. 从 0.00284 中减去 2^(–9) (¹/[512]),得到 0.000871 和%1.11001100100000000000000

  7. 从 0.000871 中减去 2^(-10) (¹/[1,024]),(大约)得到 0 和%1.11001100110000000000000

尽管 1 和 2 之间有无穷多个值,但我们只能表示其中的 800 万(2²³)个,因为我们使用的是 23 位尾数(第 24 位总是1),因此只有 23 位的精度。

尾数使用的是反码格式,而不是二进制补码。这意味着尾数的 24 位值仅仅是一个无符号的二进制数,符号位在第 31 位,决定该值是正数还是负数。反码有一个不寻常的特性,那就是0有两个表示(符号位可以设置或清除)。通常,这一点对于设计浮点软件或硬件系统的人来说很重要。我们假设0的符号位始终是清除的。

单精度浮点格式如图 4-2 所示。

image

图 4-2:单精度(32 位)浮点格式

我们通过将 2 的指数指定的幂次方提升,然后将结果与尾数相乘来表示超出尾数范围的值。指数是 8 位,采用超出 127 格式(有时称为偏置-127 指数)。在超出 127 格式中,指数 2⁰由值 127($7f)表示。要将一个指数转换为超出 127 格式,需要将 127 加到指数值上。例如,1.0 的单精度表示为$3f800000。尾数是 1.0(包括隐含位),指数是 2⁰,编码为 127($7f)。2.0 的表示为$40000000,指数 2¹编码为 128($80)。

超出 127 的指数使得比较两个浮点数是否小于或大于变得容易,仿佛它们是无符号整数,只要我们单独处理符号位(第 31 位)。如果两个值的符号不同,那么符号位为0的正值大于符号位为1的值^(2)。如果符号位都为0,我们使用直接的无符号二进制比较。如果符号位都为1,我们做无符号比较,但将结果反转(即,我们将“较小”视为“较大”,反之亦然)。在某些 CPU 上,32 位无符号比较的速度远快于 32 位浮点比较,因此,使用整数运算而非浮点运算进行比较可能会更有效。

一个 24 位的尾数提供大约 6½位十进制精度(半位精度意味着前六位数字可以在 0..9 的范围内,而第七位数字只能在 0 到x的范围内,其中x < 9 且通常接近 5)。通过一个 8 位的超出 127 的指数,单精度浮点数的动态范围大约是 2^(±128),即大约 10^(±38)。

尽管单精度浮点数非常适合许多应用,但它的动态范围对于许多金融、科学及其他应用来说并不合适。此外,在长链计算中,有限的精度可能会引入显著的误差。对于精确的计算,我们需要一种具有更高精度的浮点格式。

4.2.2 双精度浮点格式

双精度格式有助于克服单精度浮点数的问题。使用两倍的空间,双精度格式具有 11 位的超出-1,023 的指数,53 位的尾数(包括一个隐含的 HO 位1)和一个符号位。这提供了大约 10^(±308)的动态范围和 15 到 16+位的精度,对于大多数应用程序来说已经足够。双精度浮点值的形式如图 4-3 所示。

image

图 4-3:双精度(64 位)浮点格式

4.2.3 扩展精度浮点格式

为确保在涉及双精度浮动点数的长链计算中保持准确性,英特尔设计了扩展精度格式。扩展精度格式使用 80 位:64 位尾数、15 位超出 16,383 的指数和 1 位符号。尾数没有隐含的 HO 位,始终为1。扩展精度浮动点值的格式见图 4-4。

image

图 4-4:扩展精度(80 位)浮动点格式

在 80x86 FPU 上,所有计算都使用扩展精度格式。每当你加载单精度或双精度值时,FPU 会自动将其转换为扩展精度值。同样,当你将单精度或双精度值存储到内存时,FPU 会在存储前自动将该值四舍五入到适当的大小。扩展精度格式确保 32 位和 64 位计算中包含大量的保护位,这有助于确保(但不能保证)你在计算中获得完整的 32 位或 64 位精度。由于 FPU 在 80 位计算中不提供保护位(FPU 在 80 位计算中仅使用 64 位尾数),因此一些误差不可避免地会渗入 LO 位。虽然你不能假设你会得到准确的 80 位计算结果,但通常使用扩展精度格式时,你的计算结果会比 64 位更好。

支持浮动点运算的非英特尔 CPU 通常只提供 32 位和 64 位格式。因此,在这些 CPU 上进行的计算可能比在使用 80 位计算的 80x86 上进行的等效计算结果更不准确。还需注意,现代 x86-64 CPU 具有作为 SSE 扩展一部分的额外浮动点硬件;然而,这些 SSE 扩展只支持 64 位和 32 位浮动点计算。

4.2.4 四倍精度浮动点格式

原始的 80 位扩展精度浮动点格式是一个过渡措施。从“类型应保持一致”的角度来看,64 位浮动点格式的正确扩展应该是 128 位浮动点格式。可惜,当英特尔在 1970 年代末期研究浮动点格式时,四倍精度(128 位)浮动点格式在硬件上实现成本过高,因此 80 位扩展精度格式成为了过渡的折衷方案。今天,一些 CPU(如 IBM 的 POWER9 及后续版本的 ARM)能够进行四倍精度浮动点运算。

IEEE Std 754 四倍精度浮动点格式使用一个符号位、一个 15 位的超出 16,383 的偏移指数和一个 112 位(含隐含 113 位)尾数(见图 4-5)。这提供了 36 位十进制数字的精度,并且指数的大致范围为 10^(±4932)。

image

图 4-5:扩展精度(80 位)浮动点格式

4.3 归一化和非归一化值

为了在浮点运算中保持最大精度,大多数计算使用规范化值。一个规范化的浮点值是其高阶尾数位包含1的值。如果浮点计算仅涉及规范化值,则计算将更加精确,因为如果尾数的多个高阶位都为0,尾数的可用精度位数就会大大减少,从而影响计算精度。

你几乎可以通过将尾数位向左移动并减少指数,直到尾数的高阶位出现1,来规范化任何非规范化值^(3)。记住,指数是二进制指数。每次增加指数时,浮点值会乘以 2。同样,每次减少指数时,浮点值会除以 2。因此,将尾数向左移动一位并减小指数不会改变浮点数的值(这就是为什么,如你之前所见,某些数字在浮点格式中有多个表示方法)。

下面是一个非规范化值的示例:

0.100000 × 21

将尾数向左移动一位,并将指数减小,以便将其规范化:

1.000000 × 20

有两种重要的情况,浮点数无法被规范化。首先,0 无法规范化,因为浮点表示在指数和尾数字段中都包含所有的0位。不过这并不成问题,因为我们可以用一个单一的0位精确表示 0,额外的精度位是没有必要的。

当尾数的某些高阶位为0,但是偏置指数^(4)也为0时,我们也无法规范化浮点数(并且我们不能通过减小指数来规范化尾数)。IEEE 标准允许在这些情况下使用特殊的非规范化值,而不是禁止某些高阶尾数位和偏置指数均为0的小值(这是可能的最小指数)。尽管使用非规范化值使得 IEEE 浮点运算能够在不发生下溢的情况下产生更好的结果,但非规范化值提供的精度位数较少。

4.4 四舍五入

在计算过程中,浮点算术函数可能会产生比浮点格式支持的精度更高的结果(计算中的保护位保持了这部分额外的精度)。当计算完成后,代码需要将结果存储回浮点变量时,必须处理这些多余的精度位。系统如何使用保护位影响剩余位数的方式称为舍入,而舍入的方式会影响计算的准确性。传统上,浮点软件和硬件使用四种不同的方式来舍入值:截断、向上舍入、向下舍入或舍入到最接近值。

截断操作很简单,但在一系列计算中产生的结果最不准确。除了将浮点值转换为整数的手段外,现代的浮点系统几乎不使用截断(截断是将浮点值强制转换为整数的标准方式)。

向上舍入会保持数值不变,如果保护位(guard bits)全为0,但如果当前的尾数无法精确适应目标位数,向上舍入会将尾数设置为浮点格式中可能的最小较大值。像截断一样,这并不是一种常规的舍入方式。然而,它对于实现像ceil()这样的函数非常有用,ceil()会将浮点值舍入为最小可能的较大整数。

向下舍入与向上舍入类似,只不过它将结果舍入为最大的可能较小值。这听起来像是截断,但有一个微妙的区别:截断始终朝着 0 舍入。对于正数来说,截断和向下舍入执行的是相同的操作。对于负数,截断只是使用尾数中的现有位数,而向下舍入则会在最低有效位置(LO position)添加一个1位。如果结果为负数,向下舍入也会与截断不同。这也不是常规的舍入方式,但它对于实现像floor()这样的函数非常有用,floor()会将浮点值舍入为最大的可能较小整数。

四舍五入到最近是处理守护位的最直观方式。如果守护位的值小于尾数 LO 位的一半,那么四舍五入到最近将结果截断为最大的可能较小值(忽略符号)。如果守护位表示的值大于尾数 LO 位的一半,则四舍五入到最近将尾数舍入为最小的可能较大值(忽略符号)。如果守护位表示的值正好是尾数 LO 位的一半,那么 IEEE 浮动点标准规定,应该有一半的时间向上舍入,一半的时间向下舍入。你可以通过将尾数舍入到 LO 位为 0 的值来实现这一点。也就是说,如果当前尾数的 LO 位已经是 0,则使用当前尾数;如果当前尾数的 LO 位是 1,则加 1 以将其舍入到 LO 位为 0 的最小较大值。根据 IEEE 浮动点标准,采用这种方案,在精度丢失的情况下可以产生最佳的结果。

下面是一些四舍五入的例子,使用 24 位尾数,4 个守护位(即这些例子使用四舍五入到最近算法将 28 位数字四舍五入为 24 位数字):

1.000_0100_1010_0100_1001_0101_0001 -> 1.000_0100_1010_0100_1001_0101

1.000_0100_1010_0100_1001_0101_1100 -> 1.000_0100_1010_0100_1001_0110

1.000_0100_1010_0100_1001_0101_1000 -> 1.000_0100_1010_0100_1001_0110

1.000_0100_1010_0100_1001_0100_0001 -> 1.000_0100_1010_0100_1001_0100

1.000_0100_1010_0100_1001_0100_1100 -> 1.000_0100_1010_0100_1001_0101

1.000_0100_1010_0100_1001_0100_1000 -> 1.000_0100_1010_0100_1001_0100

4.5 特殊浮动点值

IEEE 浮动点格式为几个特殊值提供了特殊编码。在本节中,我们将探讨这些特殊值,它们的目的和意义,以及它们在浮动点格式中的表示。

在正常情况下,浮动点数的指数位不会包含所有 0 或所有 1。包含所有 10 位的指数表示特殊值。

如果指数包含所有 1,且尾数非零(不考虑隐式位),则尾数的 HO 位(同样不考虑隐式位)决定该值是否表示 安静的非数字(QNaN)或 信号非数字(SNaN)(见 表 4-1)。这些非数字(NaN)结果告诉系统发生了严重的误算,计算结果是完全未定义的。QNaN 表示 不确定 的结果,而 SNaN 表示发生了 无效 操作。任何涉及 NaN 的计算都会产生 NaN 结果,无论其他操作数的值如何。请注意,对于 NaN,符号位无关紧要。NaN 的二进制表示见 表 4-1。

表 4-1: NaN 的二进制表示

NaN FP 格式
SNaN 32 位 %s_11111111_0xxxx...xx(s 的值无关紧要—至少有一个 x 位必须为非零。)
SNaN 64 位 %s_1111111111_0xxxxx...x(s 的值无关紧要—至少有一个 x 位必须为非零。)
SNaN 80 位 %``s_1111111111_0xxxxx...x(s的值无关紧要——至少有一个 x 位必须非零。)
QNaN 32 位 %s_11111111_1xxxx...xx(s的值无关紧要。)
QNaN 64 位 %s_1111111111_1xxxxx...x(s的值无关紧要。)
QNaN 80 位 %s_1111111111_1xxxxx...x(s的值无关紧要。)

另有两个特殊值,当指数包含全 1 位,尾数包含全 0 时进行表示。在这种情况下,符号位决定结果是表示+infinity还是–infinity。每当一个计算涉及到无限大作为其中一个操作数时,结果将会是表 4-2 中定义的(明确的)值之一。

表 4-2: 涉及无限大的操作

操作 结果
n / ±infinity 0
±infinity × ±infinity ±infinity
±nonzero / 0 ±infinity
infinity + infinity infinity
n + infinity infinity
n - infinity -infinity
±0 / ±0 NaN
infinity - infinity NaN
±infinity / ±infinity NaN
±infinity × 0 NaN

最后,如果指数位全为0,符号位指示浮点数表示的是两个特殊值中的哪一个,即–0 或+0。因为浮点格式使用的是反码表示法,所以 0 有两种不同的表示。需要注意的是,在比较、算术和其他操作中,+0 与–0 是相等的。

使用多重零表示

IEEE 浮点格式支持+0 和–0(取决于符号位的值),它们在算术运算和比较中被视为相等——符号位被忽略。操作浮点值的软件可以使用符号位作为标志来表示不同的含义。例如,可以使用符号位指示值为 0(符号位清除)或表示该值非零但太小,无法用当前格式表示(符号位设置)。英特尔建议使用符号位来指示 0 是通过负值的下溢(符号位设置)或正值的下溢(符号位清除)产生的。假设它们的 FPU 会根据推荐的方式设置符号位,当 FPU 产生0结果时。

4.6 浮点异常

IEEE 浮点标准定义了一些退化条件,在这些条件下,浮点处理器(或软件实现的浮点代码)应当通知应用程序。这些特殊条件包括:

  • 无效操作

  • 除零

  • 非规格化操作数

  • 数值溢出

  • 数值下溢

  • 不精确的结果

在这些异常中,不精确的结果是最轻微的,因为大多数浮动点计算都会产生不精确的结果。非规格化操作数也不算太严重(尽管这个异常表明你的计算可能因为精度不足而不那么准确)。其他的异常则表示更严重的问题,你不应该忽视它们。

计算机系统如何通知应用程序这些异常情况,取决于 CPU/FPU、操作系统和编程语言,因此我们无法详细讨论你可能如何处理这些异常。然而,一般来说,你可以使用编程语言中的异常处理机制来捕捉这些条件。请注意,大多数计算机系统不会在出现异常条件时通知你,除非你明确设置了通知机制。

4.7 浮动点运算

尽管大多数现代 CPU 支持硬件浮动点运算单元(FPU),但开发一套软件浮动点运算例程仍然值得尝试,这样可以更好地理解涉及的过程。通常,你会使用汇编语言来编写数学函数,因为速度是浮动点包的设计目标之一。然而,因为我们在这里编写浮动点包只是为了更清楚地了解过程,所以我们将选择容易编写、阅读和理解的代码。

事实证明,浮动点加法和减法在 C/C++ 或 Pascal 这样的高级语言中非常容易实现,因此我们将在这些语言中实现这些函数。而浮动点乘法和除法在汇编语言中比在高级语言中更容易实现,所以我们将使用高级汇编语言(HLA)编写这些例程。

4.7.1 浮动点表示

本节将使用 IEEE 32 位单精度浮动点格式(如前面图 4-2 所示),该格式采用补码表示法来表示带符号值。这意味着,如果数字为负数,则符号位(第 31 位)为1,如果数字为正数,则符号位为0。指数是一个 8 位的超出 127 的指数,位于第 23 到第 30 位之间,尾数是一个 24 位的值,并且隐含的 HO 位为1。由于有隐含的 HO 位,因此该格式不支持非规格化值。

4.7.2 浮动点加法和减法

加法和减法基本上使用相同的代码。毕竟,计算X - Y 相当于计算 X + (- Y)。如果我们能将负数加到某个其他值上,那么我们也能通过先将某个数字取反再加到另一个值上来进行减法。而且,由于 IEEE 浮动点格式使用补码表示法,取反一个值是非常简单的——我们只需要反转符号位即可。

因为我们使用的是标准的 IEEE 32 位单精度浮点格式,理论上我们可以使用 C/C++ 的 float 数据类型(假设底层 C/C++ 编译器也使用这种格式,现代机器大多数都采用这种格式)。然而,你很快会发现,在软件中进行浮点计算时,我们需要将浮点格式中的各个字段作为位串和整数值进行操作。因此,使用 32 位的 unsigned 整型来存储浮点值的位表示会更加方便。为了避免将我们的 real 值与程序中的实际整数值混淆,我们将定义以下 real 数据类型,假设 unsigned long 是你 C/C++ 实现中的 32 位值(这一部分假设 uint32_t 类型实现了这一点,即类似于 typedef unsigned long uint32_t),并使用这种类型声明我们所有的 real 变量:

typedef uint32_t  real;

使用 C/C++ 用于浮点值的相同浮点格式有一个优点,那就是我们可以将浮点字面常量赋值给我们的 real 变量,并且可以使用现有的库例程执行其他浮点操作,如输入和输出。然而,一个潜在的问题是,如果我们在浮点表达式中使用 real 变量,C/C++ 会尝试在整数和浮点格式之间自动转换(记住,在 C/C++ 中,real 只是一个 unsigned long 整数值)。这意味着我们需要告诉编译器将我们 real 变量中的位模式视为 float 对象,而不是进行转换。

(float) realVariable 这样的简单类型强制转换是行不通的。C/C++ 编译器将生成代码,把它认为 realVariable 包含的整数转换为等效的浮点值。然而,我们希望 C/C++ 编译器将 realVariable 中找到的位模式视为 float,而不进行任何转换。以下是一个巧妙的 C/C++ 宏实现:

#define asreal(x) (*((float *) &x))

这个宏需要一个参数,该参数必须是一个 real 变量。结果是一个编译器认为是 float 变量的变量。

现在我们有了 float 变量,我们将开发两个 C/C++ 函数来计算浮点加法和减法:fpadd()fpsub()。这两个函数每个接受三个参数:操作符的左右操作数和一个指向目标的指针,这些函数将把结果存储到该目标中。这些函数的原型如下:

void fpadd( real left, real right, real *dest );

void fpsub( real left, real right, real *dest );

fpsub() 函数对右操作数取反,并调用 fpadd() 函数。以下是 fpsub() 函数的代码:

void fpsub( real left, real right, real *dest )

{

    right = right ^ 0x80000000;   // Invert the sign bit of the right operand.

    fpadd( left, right, dest );   // Let fpadd do the real work.

}

fpadd()函数是执行所有实际工作的地方。为了让fpadd()更易于理解和维护,我们将其分解为几个不同的函数,每个函数执行不同的任务。在实际的软件浮点库程序中,通常不会这样分解,因为额外的子程序调用会稍微降低效率;然而,我们开发fpadd()是为了教学目的,而且如果你需要高性能的浮点加法,可能会使用硬件 FPU 而非软件实现。

IEEE 浮点格式是打包数据类型的一个很好的例子。如你在前几章中所见,打包数据类型对于减少数据类型的存储需求非常有用,但在需要在实际计算中使用这些打包字段时就不太合适。因此,我们的浮点函数首先要做的一件事就是从浮点表示中解包符号、指数和尾数字段。

第一个解包函数extractSign()从我们打包的浮点表示中提取符号位(第 31 位),并返回值0(表示正数)或1(表示负数)。

inline int extractSign( real from )

{

    return( from >> 31);

}

这段代码也可以使用这个(可能更高效的)表达式来提取符号位:

(from & 0x80000000) != 0

然而,将第 31 位移到第 0 位可以说更容易理解。

下一个工具函数extractExponent()从打包的实数格式中解包指数位,范围为第 23 到 30 位。它通过将实数值右移 23 位,屏蔽符号位,并将超出 127 的指数转换为二进制补码格式(通过减去 127)来实现。

inline int extractExponent( real from )

{

    return ((from >> 23) & 0xff) - 127;

}

接下来是extractMantissa()函数,它从实数值中提取尾数。为了提取尾数,我们必须屏蔽指数和符号位,然后插入隐含的1高位。唯一需要注意的是,如果整个值为0,我们必须返回0

inline int extractMantissa( real from )

{

    if( (from & 0x7fffffff) == 0 ) return 0;

    return ((from & 0x7FFFFF) | 0x800000 );

}

如你之前所学,每当使用科学计数法(IEEE 浮点格式所使用的格式)进行加法或减法时,必须首先调整两个数值的指数,使它们相同。例如,要将两个十进制(基数为 10)的数1.2345e38.7654e1相加,我们必须先调整其中一个数,使它们的指数相同。我们可以通过将第一个数的小数点右移来减少它的指数。例如,以下值都等价于1.2345e3

12.345e2 123.45e1 1234.5 12345e-1

同样,我们可以通过将小数点向左移动来增加指数的值。以下值都等价于8.7654e1

0.87654e2 0.087654e3 0.0087654e4

对于涉及二进制数的浮点加法和减法,我们可以通过将尾数左移一位并递减指数,或者将尾数右移一位并递增指数来使二进制指数相同。

将尾数位右移意味着我们减少了数字的精度(因为这些位最终会被移出尾数的低位端)。为了在计算中尽可能保持准确性,我们不应截断右移出去的尾数位,而应该将结果四舍五入到我们可以用剩余的尾数位表示的最接近的值。这些是 IEEE 四舍五入的规则,按顺序:

  1. 如果最后被右移出去的位是 0,则截断结果。

  2. 如果最后被右移出去的位是 1,并且所有其他被右移出去的位中至少有一个位被设置为 1,则将尾数加 1。^(6)

  3. 如果我们右移出去的最后一位是 1,且所有其他位都是 0,则如果尾数的低位包含 1,则将结果尾数向上四舍五入 1。

右移尾数并进行四舍五入是一个相对复杂的操作,并且在浮点加法代码中会发生几次。因此,它是另一个可以作为工具函数的候选项。以下是实现此功能的 C/C++ 代码,shiftAndRound()

void shiftAndRound( uint32_t *valToShift, int bitsToShift )

{

    // Masks is used to mask out bits to check for a "sticky" bit.

    static unsigned masks[24] =

    {

        0, 1, 3, 7, 0xf, 0x1f, 0x3f, 0x7f, 

        0xff, 0x1ff, 0x3ff, 0x7ff, 0xfff, 0x1fff, 0x3fff, 0x7fff,

        0xffff, 0x1ffff, 0x3ffff, 0x7ffff, 0xfffff, 0x1fffff, 0x3fffff,

        0x7fffff

    };

    // HOmasks: Masks out the HO bit of the value masked by the masks entry.

    static unsigned HOmasks[24] =

    {

        0, 

        1, 2, 4, 0x8, 0x10, 0x20, 0x40, 0x80, 

        0x100, 0x200, 0x400, 0x800, 0x1000, 0x2000, 0x4000, 0x8000, 

        0x10000, 0x20000, 0x40000, 0x80000, 0x100000, 0x200000, 0x400000

    };

    // shiftedOut: Holds the value that will be shifted out of a mantissa

    // during the denormalization operation (used to round a denormalized

    // value).

    int shiftedOut;

    assert( bitsToShift <= 23 );

    // Okay, first grab the bits we're going to shift out (so we can determine

    // how to round this value after the shift).

    shiftedOut = *valToShift & masks[ bitsToShift ];

    // Shift the value to the right the specified number of bits.

    // Note: bit 31 is always 0, so it doesn't matter if the C

    // compiler does a logical shift right or an arithmetic shift right.

    *valToShift = *valToShift >> bitsToShift;

    // If necessary, round the value:

    if(  shiftedOut > HOmasks[ bitsToShift ] )

    {

        // If the bits we shifted out are greater than 1/2 the LO bit, then

        // round the value up by 1.

        *valToShift = *valToShift + 1;

    }

    else if( shiftedOut == HOmasks[ bitsToShift ] )

    {

        // If the bits we shifted out are exactly 1/2 of the LO bit's value,

        // then round the value to the nearest number whose LO bit is 0.

        *valToShift = *valToShift + (*valToShift & 1);

    }

    // else

    // We round the value down to the previous value. The current

    // value is already truncated (rounded down), so we don't have to do

    // anything.

}

这段代码的“技巧”在于它使用了几个查找表,masksHOmasks,来提取尾数在右移操作中使用的那些位。masks 表中的条目包含 1 位(已设置的位),这些位是右移过程中将会丢失的位置。HOmasks 表中的条目在由索引指定的位置包含一个单独的设置位;也就是说,索引为 0 的条目在位位置 0 包含 1,索引为 1 的条目在位位置 1 包含 1,依此类推。该代码根据尾数需要右移的位数,从这两个表中各自选择一个条目。

如果原始尾数值与 masks 中适当条目按位与的结果大于 HOmasks 中相应条目的值,则 shiftAndRound() 函数将右移后的尾数四舍五入到下一个较大的值。如果按位与后的尾数值等于相应的 HOmasks 元素,则代码根据尾数的低位(LO 位)来四舍五入右移后的尾数值(请注意,表达式 (*valToShift & 1) 如果尾数的低位是 1 会产生 1,否则产生 0)。最后,如果按位与后的尾数值小于 HOmasks 表中的条目,则代码不需要做任何操作,因为尾数已经被四舍五入到较小的值。

一旦我们调整了其中一个值,使得两个操作数的指数相同,下一步就是在加法算法中比较值的符号。如果两个操作数的符号相同,我们将它们的尾数相加(使用标准整数加法操作)。如果符号不同,我们必须进行减法,而不是加法。由于浮点值使用的是反码表示,而标准整数运算使用的是补码表示,我们不能简单地将负值从正值中减去。相反,我们必须从较大的值中减去较小的值,并根据原始操作数的符号和大小确定结果的符号。表 4-3 说明了如何实现这一点。

表 4-3: 处理具有不同符号的操作数

左符号 右符号 左尾数 > 右尾数? 计算尾数为 结果符号为
+ 左尾数 - 右尾数
+ 左尾数 - 右尾数 +
+ 右尾数 - 左尾数 +
+ 右尾数 - 左尾数

每当你加减两个 24 位数时,可能会产生一个需要 25 位的结果(实际上,当处理规范化值时,这是常见的)。加法或减法操作后,浮点代码必须检查结果,看看是否发生了溢出。如果发生溢出,它需要将尾数右移 1 位,四舍五入结果,然后递增指数。完成此步骤后,剩下的就是将结果的符号、指数和尾数字段打包到 32 位 IEEE 浮点格式中。以下 packFP() 函数负责将 signexponentmantissa 字段打包到 32 位浮点格式中:

inline real packFP( int sign, int exponent, int mantissa )

{

   return 

        (real)

        ( 

                (sign << 31) 

            |   ((exponent + 127) << 23)  

            |   (mantissa & 0x7fffff)

        );

}

请注意,此函数适用于规范化值、非规范化值和零,但不适用于 NaN 和无穷大。

处理完实用程序例程后,看看 fpadd() 函数,它用于将两个浮点值相加,产生一个 32 位实数结果:

void fpadd( real left, real right, real *dest )

{   

    // The following variables hold the fields associated with the 

    // left operand:

    int             Lexponent;

    uint32_t        Lmantissa;

    int             Lsign;

    // The following variables hold the fields associated with the 

    // right operand:

    int             Rexponent;

    uint32_t        Rmantissa;

    int             Rsign;

    // The following variables hold the separate fields of the result:

    int             Dexponent;

    uint32_t        Dmantissa;

    int             Dsign;

    // Extract the fields so that they're easy to work with:

    Lexponent = extractExponent( left );

    Lmantissa = extractMantissa( left );

    Lsign     = extractSign( left );

    Rexponent = extractExponent( right );

    Rmantissa = extractMantissa( right );

    Rsign     = extractSign( right );

    // Code to handle special operands (infinity and NaNs):

    if( Lexponent == 127 )

    {

        if( Lmantissa == 0 )

        {

            // If the left operand is infinity, then the result

            // depends upon the value of the right operand.

            if( Rexponent == 127 )

            {

                // If the exponent is all 1 bits (127 after unbiasing)

                // then the mantissa determines if we have an infinity value

                // (zero mantissa), a QNaN (mantissa = 0x800000), or a SNaN

                // (nonzero mantissa not equal to 0x800000). 

                if( Rmantissa == 0 )  // Do we have infinity?

                {

                    // infinity + infinity = infinity

                    // -infinity - infinity = -infinity 

                    // -infinity + infinity = NaN

                    // infinity - infinity = NaN

                    if( Lsign == Rsign )

                    {

                        *dest = right;

                    }

                    else

                    {

                        *dest = 0x7fC00000;  // +QNaN

                    }

                }

                else  // Rmantissa is nonzero, so it's a NaN

                {

                    *dest = right;  // Right is a NaN, propagate it.

                }

            }

        }

        else // Lmantissa is nonzero, Lexponent is all 1s.

        {

            // If the left operand is some NaN, then the result will

            // also be the same NaN.

            *dest = left;

        }

        // We've already calculated the result, so just return.

        return;

    }

    else if( Rexponent == 127 )

    {

        // Two case: right is either a NaN (in which case we need to

        // propagate the NaN regardless of left's value) or it is

        // +/– infinity. Because left is a "normal" number, we'll also

        // wind up propagating the infinity because any normal number

        // plus infinity is infinity.

        *dest = right;  // Right is a NaN, so propagate it.

        return;

    }

    // Okay, we've got two actual floating-point values. Let's add them 

    // together. First, we have to "denormalize" one of the operands if

    // their exponents aren't the same (when adding or subtracting values,

    // the exponents must be the same).

    //

    // Algorithm: choose the value with the smaller exponent. Shift its 

    // mantissa to the right the number of bits specified by the difference 

    // between the two exponents.

    Dexponent = Rexponent;

    if( Rexponent > Lexponent )

    {

        shiftAndRound( &Lmantissa, (Rexponent - Lexponent));

    }

    else if( Rexponent < Lexponent )

    {

        shiftAndRound( &Rmantissa, (Lexponent - Rexponent));

        Dexponent = Lexponent;

    }

    // Okay, add the mantissas. There is one catch: if the signs are opposite

    // then we've actually got to subtract one value from the other (because

    // the FP format is one's complement, we'll subtract the larger mantissa

    // from the smaller and set the destination sign according to a

    // combination of the original sign values and the largest mantissa).

    if( Rsign ^ Lsign )

    {

        // Signs are different, so we must subtract one value from the other.

        if( Lmantissa > Rmantissa )

        {

            // The left value is greater, so the result inherits the

            // sign of the left operand.

            Dmantissa = Lmantissa - Rmantissa;

            Dsign = Lsign;

        }

        else

        {

            // The right value is greater, so the result inherits the

            // sign of the right operand.

            Dmantissa = Rmantissa - Lmantissa;

            Dsign = Rsign;

        }

    }

    else

    {

        // Signs are the same, so add the values:

        Dsign = Lsign;

        Dmantissa = Lmantissa + Rmantissa;

    }

    // Normalize the result here.

    //

    // Note that during addition/subtraction, overflow of 1 bit is possible.

    // Deal with that possibility here (if overflow occurred, shift the 

    // mantissa to the right one position and adjust for this by incrementing 

    // the exponent). Note that this code returns infinity if overflow occurs 

    // when incrementing the exponent (infinity is a value with an exponent 

    // of $FF);

   if( Dmantissa >= 0x1000000 )

    {

        // Never more than 1 extra bit when doing addition/subtraction.

        // Note that by virtue of the floating-point format we're using,

        // the maximum value we can produce via addition or subtraction is

        // a mantissa value of 0x1fffffe. Therefore, when we round this

        // value it will not produce an overflow into the 25th bit.

        shiftAndRound( &Dmantissa, 1 ); // Move result into 24 bits.

        ++Dexponent;                    // Shift operation did a div by 2,

                                        // this counteracts the effect of

                                        // the shift (incrementing exponent

                                        // multiplies the value by 2).

    }

    else

    {

        // If the HO bit is clear, normalize the result

        // by shifting bits up and simultaneously decrementing

        // the exponent. We will treat 0 as a special case

        // because it's a common enough result.

        if( Dmantissa != 0 )

        {

            // The while loop multiplies the mantissa by 2 (via a shift 

            // left) and then divides the whole number by 2 (by 

            // decrementing the exponent. This continues until the HO bit of 

            // Dmantissa is set or the exponent becomes -127 (0 in the 

            // biased-127 form). If Dexponent drops down to -128, then we've 

            // got a denormalized number and we can stop.

            while( (Dmantissa < 0x800000) && (Dexponent > -127 ))

            {

                Dmantissa = Dmantissa << 1;

                --Dexponent;

            }

        }

        else

        {

            // If the mantissa went to 0, clear everything else, too.

            Dsign = 0;

            Dexponent = 0;

        }

    }

    // Reconstruct the result and store it away:

    *dest = packFP( Dsign, Dexponent, Dmantissa );

}

为总结关于 fpadd()fsub() 函数的软件实现讨论,以下是一个展示它们使用的 C main() 函数:

// A simple main program that does some trivial tests on fpadd and fpsub.

int main( int argc, char **argv )

{

    real l, r, d;

    asreal(l) = 1.0;

    asreal(r) = 2.0;

    fpadd( l, r, &d );

    printf( "dest = %x\n", d );

    printf( "dest = %12E\n", asreal( d ));

    l = d;

    asreal(r) = 4.0;

    fpsub( l, r, &d );

    printf( "dest2 = %x\n", d );

    printf( "dest2 = %12E\n", asreal( d ));

}

以下是使用 Microsoft Visual C++ 编译(并将 uint32_t 定义为 unsigned long)时产生的输出:

l = 3f800000

l = 1.000000E+00

r = 40000000

r = 2.000000E+00

dest = 40400000

dest = 3.000000E+00

dest2 = bf800000

dest2 = -1.000000E+00

4.7.3 浮点数乘法和除法

大多数软件浮点库实际上是用手工优化的汇编语言编写的,而不是用高级语言(HLL)编写的。正如前一节所示,实际上可以用高级语言编写浮点运算例程,特别是在单精度浮点加法和减法的情况下,您可以高效地编写代码。只要有合适的库函数,您还可以用高级语言编写浮点乘法和除法例程。然而,由于它们的实现实际上在汇编语言中更容易,因此本节展示了单精度浮点乘法和除法算法的 HLA 实现。

本节中的 HLA 代码实现了两个函数,fpmul()fpdiv(),它们有以下原型:

procedure fpmul( left:real32; right:real32 );  @returns( "eax" );

procedure fpdiv( left:real32; right:real32 );  @returns( "eax" );

除了这段代码是用汇编语言而不是 C 语言编写的之外,它与前一节的代码有两个主要不同之处。首先,它使用内置的real32数据类型,而不是为实数值创建新的数据类型,因为我们可以轻松地将任何 32 位内存对象强制转换为real32dword类型。其次,这些原型只支持两个参数;没有目标参数。这些函数简单地将real32结果返回在 EAX 寄存器中。^(7)

4.7.3.1 浮点乘法

每当你在科学计数法中乘以两个值时,你需要按以下方式计算结果符号、指数和尾数:

  • 结果符号是操作数符号的异或运算。也就是说,如果两个操作数的符号相同,结果符号为正;如果操作数的符号不同,结果符号为负。

  • 结果指数是操作数指数的和。

  • 结果尾数是两个操作数尾数的整数(定点)乘积。

有一些附加规则影响浮点乘法算法,这些规则是 IEEE 浮点格式的直接结果:

  • 如果任一或两个操作数为0,结果就是0(这是一个特例,因为0的表示是特殊的)。

  • 如果任一操作数是无穷大,结果就是无穷大。

  • 如果任一操作数是 NaN,结果就是那个相同的 NaN。

fpmul()过程首先检查任一操作数是否为0。如果是,函数立即返回0.0给调用者。接着,fpmul()代码检查leftright操作数是否为 NaN 或无穷大。如果发现其中一个值为 NaN 或无穷大,它将该值返回给调用者。

如果fpmul()的两个操作数都是合理的浮点值,那么fpmul()代码将提取打包浮点值的符号、指数和尾数字段。实际上,提取在这里并不是正确的术语;隔离是更好的描述。以下是隔离两个操作数符号位并计算结果符号的代码:

mov( (type dword left), ebx );  // Result sign is the XOR of the

xor( (type dword right), ebx ); // operand signs.

and( $8000_0000, ebx );         // Keep only the sign bit.

这段代码对两个操作数进行异或运算,然后屏蔽掉第 0 到第 30 位,只留下 EBX 寄存器中第 31 位的符号值。这一过程没有将符号位移到第 0 位(通常在解包数据时需要这么做),因为在后续重新打包浮点值时,这个位会重新被移动回第 31 位。

为了处理指数,fpmul()会隔离第 23 到第 30 位,并在原地操作指数。在使用科学计数法进行两个值的乘法时,你必须将指数值相加。然而,你必须从指数和中减去 127,因为添加超出 127 的指数会导致偏差被加两次。以下代码隔离指数位,调整额外的偏差,并将指数相加:

mov( (type dword left), ecx );  // Exponent goes into bits 23..30

and( $7f80_0000, ecx );         // of ECX; mask these bits.

sub( 126 << 23, ecx );          // Eliminate the bias of 127 and multiply by 2

mov( (type dword right), eax );

and( $7f80_0000, eax );

// For multiplication, we need to add the exponents:

add( eax, ecx );                // Exponent value is now in bits

                                // 23..30 of ECX.

首先,请注意这段代码减去的是 126 而不是 127。原因是稍后我们需要将尾数相乘的结果乘以 2。减去 126 而不是 127,隐式地完成了乘 2 的操作(这样可以节省后续的一条指令)。

如果前面代码中使用add(eax, ecx)的指数和过大,无法容纳在 8 位中,就会导致从 ECX 的第 30 位向第 31 位发生进位,这会设置 80x86 溢出标志。如果乘法发生溢出,我们的代码将返回infinity作为结果。

如果没有发生溢出,那么fpmul()过程需要设置两个尾数值的隐式高位。以下代码处理这项工作,去除尾数中的所有指数和符号位,并将尾数位左对齐至 EAX 和 EDX 的第 31 位。

mov( (type dword left), eax );

mov( (type dword right), edx );

// If we don't have a 0 value, then set the implied HO bit of the mantissa:

if( eax <> 0 ) then

    or( $80_0000, eax );  // Set the implied bit to 1.

endif;

shl( 8, eax );  // Moves mantissa to bits 8..31 and removes sign/exp.

// Repeat this for the right operand.

if( edx <> 0 ) then

    or( $80_0000, edx );

endif;

shl( 8, edx );

一旦尾数被移到 EAX 和 EDX 的第 31 位,我们就使用 80x86 的mul()指令进行乘法:

mul( edx );

该指令计算 EAX 和 EDX 的 64 位乘积,并将结果存储在 EDX:EAX 中(高双字在 EDX 中,低双字在 EAX 中)。由于任意两个n位整数的乘积可能需要最多 2×n位,因此mul()指令计算 EDX:EAX = EAX×EDX。在进行乘法之前将尾数左对齐确保了乘积的尾数会出现在 EDX 的第 7 到第 30 位。我们实际上需要它们出现在 EDX 的第 8 到第 31 位——这就是为什么前面这段代码在调整超出 127 的值时,减去的是 126 而不是 127(这样乘法结果会乘 2,相当于将位移左移一个位置)。由于这些数字在乘法前已经规范化,所以除非结果为0,否则 EDX 的第 30 位在乘法后会包含1。32 位 IEEE 浮点格式不支持非规范化值,因此在使用 32 位浮点数时,我们不需要担心这种情况。

因为尾数每个都是 24 位,尾数的乘积可能有多达 48 位的有效位数。我们的结果尾数只能容纳 24 位,因此需要对值进行舍入以产生 24 位的结果(使用 IEEE 舍入算法 — 参见“Rounding” 页面 71)。以下是将 EDX 中的值舍入为 24 位有效位数(位于位置 8..31)的代码:

test( $80, edx );  // Clears zero flag if bit 7 of EDX = 1.

if( @nz ) then

    add( $FFFF_FFFF, eax );  // Sets carry if EAX <> 0.

    adc( $7f, dl );          // Sets carry if DL:EAX > $80_0000_0000.

    if( @c ) then

        // If DL:EAX > $80_0000_0000 then round the mantissa

        // up by adding 1 to bit position 8:

        add( 1 << 8, edx );

    else // DL:EAX = $80_0000_0000

        // We need to round to the value that has a 0

        // in bit position 0 of the mantissa (bit #8 of EDX):

        test( 8, edx );  // Clears zero flag if bit #8 contains a 1.

        if( @nz ) then

            add( 1 << 8, edx );  // Adds a 1 starting at bit position 8.

            // If there was an overflow, renormalize:

            if( @c ) then

                rcr( 1, edx );  // Shift overflow (in carry) back into EDX.

                inc( ecx );     // Shift did a divide by 2\. Fix that.

        endif;

        endif;

    endif;

endif;

在舍入后可能需要重新规范化数字。如果尾数包含所有的 1 位并且需要向上舍入,则会导致尾数的 HO 位溢出。此代码序列末尾的 rcr()inc() 指令将溢出位放回尾数中(如果发生溢出)。

在此之后要做的唯一事情是将目标符号、指数和尾数打包到 32 位的 EAX 寄存器中。以下代码实现了这一点:

shr( 8, edx );          // Move mantissa into bits 0..23.

and( $7f_ffff, edx );   // Clear the implied bit.

lea( eax, [edx+ecx] );  // Merge mantissa and exponent into EAX.

or( ebx, eax );         // Merge in the sign.

此代码中唯一棘手的部分是使用 lea()(加载有效地址)指令来计算 EDX(尾数)和 ECX(指数)的和,并将结果移动到 EAX 中,所有这些只需一条指令。

4.7.3.2 浮点除法

浮点除法比乘法复杂一些,因为 IEEE 浮点标准在除法过程中可能发生多种退化条件。我们不打算在此处讨论处理这些条件的所有代码。相反,请参阅前面关于 fpmul() 条件的讨论,并查看本节后面关于 fdiv() 的完整代码清单。

假设我们有合理的数字进行除法,除法算法首先使用与乘法相同的算法(和代码)计算结果符号。在使用科学记数法除法两个值时,我们必须减去它们的指数。与乘法算法相反,在这里真正拆开两个除法操作数的指数并将它们从 excess-127 转换为二进制补码形式更为方便。以下是执行此操作的代码:

mov( (type dword left), ecx );  // Exponent comes from bits 23..30.

shr( 23, ecx );

and( $ff, ecx );                // Mask out the sign bit (in bit 8).

mov( (type dword right), eax );

shr( 23, eax );

and( $ff, eax );

// Eliminate the bias from the exponents:

sub( 127, ecx );

sub( 127, eax );

// For division, we need to subtract the exponents:

sub( eax, ecx );                // Leaves result exponent in ECX.

80x86 的 div() 指令绝对要求商适合 32 位。如果此条件不成立,则 CPU 可能会因为除法异常而中止操作。只要除数的 HO 位包含 1,被除数的 HO 2 位包含 %01,我们就不会得到除法错误。以下是准备除法操作前操作数的代码:

mov (type dword left), edx );

if( edx <> 0 ) then

    or( $80_0000, edx );   // Set the implied bit to 1 in the left operand.

    shl( 8, edx );

endif;

mov( (type dword right), edi );

if( edi <> 0 ) then

    or( $80_0000, edi );        // Set the implied bit to 1 in the right operand.

    shl( 8, edi );

else

    // Division by zero error, here.

endif;

接下来的步骤是实际执行除法。正如前面提到的,为了防止除法错误,我们必须将被除数向右移动 1 位(将 HO 2 位设置为 %01),具体如下:

xor( eax, eax );    // EAX := 0;

shr( 1, edx );      // Shift EDX:EAX to the right 1 bit to

rcr( 1, eax );      // prevent a division error.

div( edi );         // Compute EAX = EDX:EAX / EDI.

一旦div()指令执行完毕,商就会存储在 EAX 的高 24 位中,余数则存储在 AL:EDX 中。接下来,我们需要对结果进行标准化和四舍五入。四舍五入会稍微简单一些,因为 AL:EDX 包含了除法后的余数;如果我们需要向下取整,它会包含一个小于$80:0000_0000的值(即 80x86 的 AL 寄存器包含$80,而 EDX 包含0);如果我们需要向上取整,它会包含一个大于$80:0000_的值;如果我们需要四舍五入到最接近的值,它将包含恰好$80:0000_0000

以下是执行此操作的代码:

test( $80, al );    // See if the bit just below the LO bit of the

if( @nz ) then      // mantissa contains a 0 or 1.

    // Okay, the bit just below the LO bit of our mantissa contains a 1.

    // If all other bits below the mantissa and this bit contain 0s,

    // we have to round to the nearest mantissa value whose LO bit is 0.

    test( $7f, al );             // Clears zero flag if bits 0..6 <> 0.

    if( @nz || edx <> 0 ) then   // If bits 0..6 in AL are 0 and EDX

                                 // is 0.

        // We need to round up:

        add( $100, eax );  // Mantissa starts in bit #8 );

        if( @c ) then      // Carry set if mantissa overflows.

            // If there was an overflow, renormalize.

            rcr( 1, eax );

            inc( ecx );

        endif;

    else

        // The bits below the mantissa are exactly 1/2 the value

        // of the LO mantissa bit. So we need to round to the value

        // that has a LO mantissa bit of 0:

        test( $100, eax );

        if( @nz ) then

            add( $100, eax );

            if( @c ) then

                // If there was an overflow, renormalize.

                rcr( 1, eax );  // Put overflow bit back into EAX.

                inc( ecx );     // Adjust exponent accordingly.

            endif;

        endif;

    endif;

endif;

fpdiv的最后一步是将偏置值加回到指数中(并验证不会发生溢出),然后将商的符号、指数和尾数字段打包成 32 位浮点格式。以下是执行此操作的代码:

if( (type int32 ecx) > 127 ) then

    mov($ff-127, ecx );    // Set exponent value for infinity

    xor( eax, eax );       // because we just had overflow.

elseif( (type int32 ecx) < -128 ) then

    mov( -127, ecx );      // Return 0 for underflow (note that

    xor( eax, eax );       // next we add 127 to ECX).

endif;                                      

add( 127, ecx );           // Add the bias back in.

shl( 23, ecx );            // Move the exponent to bits 23..30.

// Okay, assemble the final real32 value:

shr( 8, eax );             // Move mantissa into bits 0..23.

and( $7f_ffff, eax );      // Clear the implied bit.

or( ecx, eax );            // Merge mantissa and exponent into EAX.

or( ebx, eax );            // Merge in the sign.

哇!这一部分代码真不少。然而,阅读所有这些代码只是为了理解浮点运算的工作原理,希望它能让你更好地理解 FPU 到底为你做了什么。

4.8 获取更多信息

Hyde, Randall. 《汇编语言的艺术》 第二版. 旧金山: No Starch Press, 2010 年。

———. “Webster: 学习汇编的互联网平台。” plantation-productions.com/Webster/index.html

Knuth, Donald E. 《计算机程序设计艺术》第 2 卷:半数值算法 第 3 版. 波士顿: Addison-Wesley, 1998 年。

第五章:字符表示**

Image

尽管计算机以“数字运算”能力而闻名,但事实是,大多数计算机系统处理字符数据的频率远高于数字数据。字符一词指的是一种人类或机器可读的符号,通常是非数字实体。一般来说,字符是你可以在键盘上输入或在显示器上显示的任何符号。除了字母字符外,字符数据还包括标点符号、数字、空格、制表符、回车符(回车键)、其他控制字符和其他特殊符号。

本章介绍了如何在计算机系统中表示字符、字符串和字符集,并讨论了对这些数据类型的各种操作。

5.1 字符数据

大多数计算机系统使用 1 字节或多字节的二进制序列来编码各种字符。Windows、macOS 和 Linux 都属于这一类别,使用 ASCII 或 Unicode 字符集,其成员可以通过 1 字节或多字节的二进制序列表示。EBCDIC 字符集在 IBM 大型机和小型计算机上使用,也是单字节字符编码的另一个例子。

本章将讨论这三种字符集及其内部表示方式,并介绍如何创建自己的字符集。

5.1.1 ASCII 字符集

ASCII(美国信息交换标准代码)字符集将 128 个字符映射到无符号整数值 0 到 127($0$7F)。尽管字符与数字值的精确映射是任意的并且并不重要,但标准化的映射使得你能够在程序和外部设备之间进行通信。标准 ASCII 代码很有用,因为几乎每个人都使用它们。例如,如果你使用 ASCII 代码 65 来表示字符 A,你可以放心,某些外部设备(如打印机)会正确地将这个值解释为 A

由于 ASCII 字符集只提供 128 个不同的字符,你可能会问:“我们该如何处理额外的 128 个值($80..$FF),这些值可以用一个字节表示?”一个选项是忽略这些额外的值,这也是本书的主要做法。另一种可能性是通过添加 128 个字符来扩展 ASCII 字符集。当然,除非你能让每个人都同意某个特定的字符集扩展^(1)(这确实是一个困难的任务),否则拥有标准化字符集的整个目的将会失效。

尽管存在一些重大缺点,如无法表示今天使用的所有字符和字母,但 ASCII 数据仍然是跨计算机系统和程序数据交换的标准。大多数程序都能接受 ASCII 数据,也能生成 ASCII 数据。因为你可能会在程序中处理 ASCII 字符,所以研究字符集的布局并记住一些关键的 ASCII 代码(如0Aa的代码)是明智的。

注意

附录 A 中的表 A-1 列出了标准 ASCII 字符集中的所有字符。

ASCII 字符集被分为四组 32 个字符。前 32 个字符,ASCII 代码$0$1F(0 到 31),形成了一组特殊的非打印字符,称为控制字符。顾名思义,这些字符执行各种打印机和显示控制操作,而不是显示符号。控制字符的示例包括回车符,它将光标定位到当前行的开头;^(2) 换行符,它将光标向下移动一行;以及退格符,它将光标向左移动一个位置。不幸的是,由于输出设备之间几乎没有标准化,不同的控制字符在不同的输出设备上执行不同的操作。要准确了解某个控制字符如何影响某个设备,请查阅该设备的手册。

第二组 32 个 ASCII 字符代码包括各种标点符号、特殊字符和数字字符。该组中最显著的字符包括空格字符(ASCII 代码$20)和数字字符(ASCII 代码$30..$39)。

第三组 32 个 ASCII 字符包含大写字母字符。字符AZ的 ASCII 代码范围为$41$5A。因为只有 26 个不同的字母字符,其余六个代码用于表示各种特殊符号。

第四组也是最后一组 32 个 ASCII 字符代码表示小写字母符号、五个额外的特殊符号和另一个控制字符(删除)。小写字母符号使用 ASCII 代码$61$7A。如果你将大写和小写字符的代码转换为二进制,你会发现大写字母与其小写字母之间仅在一个比特位置上有所不同。例如,考虑图 5-1 中Ee的字符代码。

image

图 5-1:E 和 e 的 ASCII 代码

这两个代码仅在位 5 上有所不同。大写字母字符的位 5 总是为 0;小写字母字符的位 5 总是为 1。要快速将字母字符在大写和小写之间转换,只需反转位 5。要将大写字母转换为小写字母,只需将位 5 设置为 1。同样,你可以通过将位 5 设置为 0 将小写字母转换为大写字母。

位 5 和位 6 决定了字符的组(见 表 5-1)。因此,你可以通过将位 5 和位 6 设置为 0 来将任何大写或小写字母(或特殊字符)转换为其对应的控制字符(例如,当你将位 5 和位 6 设置为 0 时,A 变为 CTRL-A;也就是说,0x41 变为 0x01)。

表 5-1: 由位 5 和位 6 决定的 ASCII 字符组

位 6 位 5
0 0 控制字符
0 1 数字和标点符号
1 0 大写字母和特殊字符
1 1 小写字母和特殊字符

位 5 和位 6 不是唯一编码有用信息的位。请考虑一下 表 5-2 中数字字符的 ASCII 码。这些 ASCII 码的十进制表示并不太能提供直观的信息。然而,十六进制表示却揭示了非常重要的内容——低阶 nibble 是所表示数字的二进制等价物。通过去除(设置为 0)ASCII 码的高阶 nibble,你就能得到该数字的二进制表示。反之,你可以通过简单地将高阶 nibble 设置为 %0011,即十进制值 3,将 09 范围内的二进制值转换为其对应的 ASCII 字符表示。你可以使用逻辑与操作来强制将高阶位设置为 0;同样,你也可以使用逻辑或操作将高阶位强制设置为 %0011。有关字符串到数字转换的更多信息,请参见 第二章。

表 5-2: 数字字符的 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 字符编码数据并不能保证在不同系统之间的兼容性。一个系统上的A字符在另一个系统上很可能仍然是A;然而,在 ASCII 代码的第一组 32 个控制码中,加上最后一组的删除码,只有 4 个控制码在大多数设备和应用程序中得到普遍支持——退格(BS)、制表符、回车(CR)和换行符(LF)。更糟糕的是,不同的机器往往以不同的方式使用这些“支持的”控制码。行尾就是一个特别棘手的例子。Windows、MS-DOS、CP/M 和其他系统使用两个字符的序列 CR/LF 来标记行尾。原版 Apple Macintosh 操作系统和许多其他系统通过单一的 CR 字符来标记行尾。Linux、BeOS、macOS 和其他 Unix 系统则通过单一的 LF 字符来标记行尾。

在不同系统之间交换简单文本文件可能会让人感到沮丧。即使你在所有文件中都使用标准的 ASCII 字符,在不同系统之间交换文件时,你仍然需要转换数据。幸运的是,许多文本编辑器会自动处理具有不同换行符的文件(许多免费的实用工具也可以为你执行此转换)。如果你必须在自己的软件中进行此操作,只需将除行尾序列外的所有字符从一个文件复制到另一个文件,然后在遇到旧的行尾序列时,发出新的行尾序列。

5.1.2 EBCDIC 字符集

尽管 ASCII 字符集无疑是最流行的字符表示法,但它并不是唯一可用的。例如,IBM 在许多大型机和小型计算机产品线中使用 EBCDIC 码。然而,在个人计算机系统中,你很少会遇到它,因此在本书中我们仅简要讨论它。

EBCDIC(发音为“Eb-suh-dic”)代表扩展二进制编码十进制交换码。如果你在想是否有这个字符编码的未扩展版本,答案是有的。早期的 IBM 系统和打孔机使用的是BCDIC(二进制编码十进制交换码),这是一个基于打孔卡和十进制表示法的字符集(用于 IBM 的老旧十进制机器)。

BCDIC 在现代数字计算机出现之前就已经存在;它诞生于旧式的 IBM 打孔机和制表机。EBCDIC 扩展了这种编码,以便为 IBM 的计算机提供一个字符集。然而,EBCDIC 继承了 BCDIC 的几个特性,这些特性在现代计算机的背景下显得有些奇怪。例如,字母字符的编码不是连续的。最初,字母字符可能确实有一个顺序编码;然而,当 IBM 扩展字符集时,它使用了一些在 BCD 格式中不存在的二进制组合(如 %1010..%1111)。这些二进制值出现在两个原本连续的 BCD 值之间,这也解释了为什么某些字符序列(如字母字符)在 EBCDIC 编码中不连续。

EBCDIC 不是单一的字符集;它是一个字符集家族。虽然 EBCDIC 字符集有一个共同的核心(例如,字母字符的编码通常是相同的),不同的版本被称为 代码页,它们对标点符号和特殊字符有不同的编码。由于单个字节中可用的编码数量有限,不同的代码页会重用一些字符编码来表示它们自己特殊的字符集。因此,如果你得到一个包含 EBCDIC 字符的文件,有人让你将其翻译为 ASCII,你很快会发现这并不是一项简单的任务。

由于 EBCDIC 字符集的奇特性,许多在 ASCII 字符上效果良好的常见算法在 EBCDIC 上根本无法使用。然而,请记住,大多数 ASCII 字符都有对应的 EBCDIC 功能等价物。有关更多细节,请查阅 IBM 的文献。

5.1.3 双字节字符集

因为一个字节最多可以表示 256 个字符,一些计算机系统使用双字节字符集(DBCSs)来表示超过 256 个字符。DBCSs 并不是使用 16 位对每个字符进行编码;相反,它们对大多数字符编码使用一个字节,仅对某些字符使用双字节编码。

一个典型的双字节字符集使用标准的 ASCII 字符集以及 $80$FF 范围内的几个额外字符。该范围内的某些值用作扩展码,告诉软件紧跟其后的是第二个字节。每个扩展字节使 DBCS 能够支持另外 256 个不同的字符编码。例如,通过三个扩展值,DBCS 可以支持最多 1,021 个不同的字符:每个扩展字节支持 256 个字符,而标准单字节集支持 253 个字符(256 – 3),我们减去 3 是因为这三个扩展字节的值每个都占用了 256 种组合中的一个,它们不算作字符。

在终端和计算机使用内存映射字符显示的年代,双字节字符集并不是很实用。硬件字符生成器确实希望每个字符的大小相同,并且希望处理有限数量的字符。然而,随着位图显示和软件字符生成器的普及(如 Windows、Macintosh、Unix/XWindows 机器、平板电脑和智能手机),处理 DBCS 成为可能。

尽管 DBCS 可以紧凑地表示大量字符,但处理 DBCS 格式的文本需要更多的计算资源。例如,确定一个包含 DBCS 字符的零终止字符串的长度(在 C/C++ 语言中很常见)可能需要相当大的工作量。字符串中的某些字符占用 2 个字节,而大多数其他字符只占用 1 个字节,因此字符串长度函数必须逐字节扫描字符串,定位任何扩展值,这些值指示一个字符占用 2 个字节。这个过程使得高性能的字符串长度函数的执行时间增加了两倍以上。

更糟糕的是,许多用于操作字符串数据的常见算法在应用于双字节字符集(DBCS)时会失败。例如,一种常见的 C/C++ 技巧是通过使用 ++ptrChar--ptrChar 等表达式递增或递减指向字符串的指针。这在 DBCS 中不起作用。虽然使用 DBCS 的人可能有一套可以在 DBCS 上工作的标准 C 库例程,但其他他们或他人编写的字符函数也很可能无法正确处理扩展字符。

DBCS 的另一个大问题是缺乏一致的标准。不同的 DBCS 对不同的字符使用相同的编码。因此,如果你需要一个支持超过 256 个字符的标准化字符集,使用 Unicode 字符集无疑是更好的选择。

5.1.4 Unicode 字符集

几十年前,Aldus、NeXT、Sun、Apple Computer、IBM、Microsoft、Research Library Group 和 Xerox 的工程师们意识到,他们的新计算机系统配备位图和用户可选字体,可以同时显示远超过 256 个不同的字符。当时,DBCS 是最常见的解决方案,但正如刚才所提到的,它们存在一些兼容性问题。因此,工程师们寻求了一条不同的道路。

他们提出的解决方案是 Unicode 字符集。最初开发 Unicode 的工程师选择了 2 字节字符大小。与 DBCS 一样,这种方法仍然需要特定的库代码(现有的单字节字符串函数并不总是适用于双字节字符),但除了改变字符的大小外,大多数现有的字符串算法仍然能够与 2 字节字符一起工作。Unicode 的定义包括了当时所有(已知/现存的)字符集,为每个字符分配了唯一的编码,以避免困扰不同 DBCS 的一致性问题。

最初的 Unicode 标准使用 16 位字来表示每个字符。因此,Unicode 支持最多 65,536 个不同的字符代码——这比 8 位字节能够表示的 256 个代码要大大提升。此外,Unicode 还与 ASCII 向后兼容。如果 Unicode 字符的二进制表示的高 9 位^(3) 为 0,则低 7 位使用标准的 ASCII 代码。如果高 9 位包含非零值,则这 16 位组成扩展字符代码(即扩展自 ASCII)。如果你想知道为什么需要如此多不同的字符代码,请注意,当时某些亚洲字符集包含了 4,096 个字符。Unicode 字符集甚至提供了一组代码,可以用来创建应用程序定义的字符集。大约一半的 65,536 个可能字符代码已经被定义,剩余的字符编码则保留用于未来扩展。

今天,Unicode 是一个通用字符集,已经长期取代了 ASCII 和旧的 DBCS(双字节字符集)。所有现代操作系统(包括 macOS、Windows、Linux、iOS、Android 和 Unix)、网页浏览器和大多数现代应用程序都提供 Unicode 支持。Unicode 联盟是一个非营利性公司,负责维护 Unicode 标准。通过维护该标准,Unicode, Inc. (home.unicode.org/), 帮助确保你在一个系统中编写的字符会在不同的系统或应用程序中按照预期显示。

5.1.5 Unicode 码点

可惜的是,尽管最初的 Unicode 标准考虑得非常周全,但它未能预见到字符数量的爆炸性增长。表情符号、星座符号、箭头、指示符号以及为互联网、移动设备和网页浏览器引入的各种符号大大扩展了 Unicode 符号库(同时也包含了对历史、过时和罕见文字的支持)。1996 年,系统工程师发现 65,536 个符号不足以满足需求。为了避免每个 Unicode 字符需要 3 或 4 个字节,Unicode 定义者放弃了创建固定大小字符表示的方法,允许使用不透明的(且可多重)编码来表示 Unicode 字符。今天,Unicode 定义了 1,112,064 个码点,远远超过了最初为 Unicode 字符分配的 2 字节容量。

一个 Unicode 码点 只是一个整数值,Unicode 将其与特定的字符符号关联;你可以把它当作字符的 ASCII 代码的 Unicode 等价物。Unicode 码点的约定是以十六进制表示,并以 U+ 为前缀;例如,U+0041 是字母 A 的 Unicode 码点。

注意

详情请见 en.wikipedia.org/wiki/Unicode#General_Category_property 了解更多关于码点的信息。

5.1.6 Unicode 编码平面

由于其历史原因,Unicode 中的 65,536 字符块是特殊的——它们被称为 多语言平面。第一个多语言平面,U+000000U+00FFFF,大致对应于原始的 16 位 Unicode 定义;Unicode 标准将其称为 基本多语言平面(BMP)。平面 1(U+010000U+01FFFF)、平面 2(U+020000U+02FFFF)和平面 14(U+0E0000U+0EFFFF)是补充平面。Unicode 保留了平面 3 到 13 供未来扩展使用,而平面 15 和 16 则为用户定义字符集保留。

Unicode 标准定义了范围为 U+000000U+10FFFF 的码点。请注意,0x10ffff 是 1,114,111,这也是 Unicode 字符集中的大多数 1,112,064 个字符的来源;剩余的 2,048 个码点被保留作为 代理,即 Unicode 扩展。你可能听说过的另一个术语 Unicode 标量,是指所有 Unicode 码点的集合中的值,除了 2,048 个代理码点。六位码点值的 HO 两个十六进制数字指定了多语言平面。为什么是 17 个平面?原因如你将看到的那样,Unicode 使用特殊的多字词条目来编码超出 U+FFFF 的码点。每个扩展编码 10 位,总共 20 位;20 位可以表示 16 个多语言平面,再加上原始的 BMP,就得到了 17 个多语言平面。这也是为什么码点范围是 U+000000U+10FFFF:编码这 16 个多语言平面加上 BMP 需要 21 位。

5.1.7 代理码点

如前所述,Unicode 最初是作为一个 16 位(2 字节)字符集编码的。当显然 16 位不足以处理当时所有可能存在的字符时,扩展变得必要。从 Unicode v2.0 开始,Unicode, Inc. 组织扩展了 Unicode 的定义,包含了多字词字符。现在,Unicode 使用代理码点(U+D800U+DFFF)来编码大于 U+FFFF 的值。图 5-2 显示了这种编码方式。

image

图 5-2:Unicode 平面 1-16 的代理码点编码

请注意,两个单元(单元 1/高代理和单元 2/低代理)总是一起出现。单元 1 的值(具有 HO 位 %110110)指定 Unicode 标量的高 10 位(b[10]..b[19]),而单元 2 的值(具有 HO 位 %110111)指定 Unicode 标量的低 10 位(b[0]..b[9])。因此,b[16] 到 b[19] 位的值加 1 指定了 Unicode 平面 1 到 16,b[0] 到 b[15] 位则指定了该平面内的 Unicode 标量值。

注意,代理码仅出现在基本多文种平面(BMP)中。其他的多语言平面不包含代理码。位b[0]到b[19],从单元 1 和 2 的值中提取,始终指定一个 Unicode 标量值(即使这些值落在U+D800U+DFFF范围内)。

5.1.8 字形、字符和字素集

每个 Unicode 码点都有一个唯一的名称。例如,U+0045的名称是“拉丁大写字母 A”。请注意,符号A不是字符的名称。A是一个字形—设备绘制的一系列笔画(一个水平笔画和两个斜笔画),用以表示这个字符。

“拉丁大写字母 A”这个单一的 Unicode 字符有许多不同的字形。例如,Times Roman 字体中的字母 A 和 Times Roman 斜体字母A有不同的字形,但 Unicode 不会区分它们(也不会区分任何两种不同字体中的A字符)。无论你使用什么字体或样式绘制,它的 Unicode 字符“拉丁大写字母 A”始终是U+0045

顺便提一句,如果你可以访问 Swift 编程语言,你可以使用以下代码打印任何 Unicode 字符的名称:

import Foundation

let charToPrintName  :String = "A"      // Print name of this character

let unicodeName =

    String(charToPrintName).applyingTransform(

        StringTransform(rawValue: "Any-Name"),

        reverse: false

    )! // Forced unwrapping is legit here because it always succeeds.

print( unicodeName )

Output from program:

\N{LATIN CAPITAL LETTER A}

那么,Unicode 中的字符到底是什么呢?Unicode 标量是 Unicode 字符,但你通常所称的字符与标量的定义是有所区别的。例如,©是一个字符还是两个?考虑下面的 Swift 代码:

import Foundation

let eAccent  :String = "e\u{301}"

print( eAccent )

print( "eAccent.count=\(eAccent.count)" )

print( "eAccent.utf16.count=\(eAccent.utf16.count)" )

"\u{301}"是 Swift 语法,用于在字符串中指定一个 Unicode 标量值;在这个特定的案例中,301组合尖音符字符的十六进制代码。

第一个print语句:

print( eAccent )

打印字符(在输出中生成©,正如我们所预期的那样)。

第二个print语句打印出 Swift 确定在字符串中存在的字符数量:

print( "eAccent.count=\(eAccent.count)" )

这会在标准输出上打印1

第三个print语句打印出字符串中的元素数量(UTF-16 元素^(4)):

print( "eAccent.utf16.count=\(eAccent.utf16.count)" )

这会在标准输出上打印2,因为字符串包含两个 UTF-16 数据的单词。

那么,再一次,这到底是一个字符还是两个?在内部(假设使用 UTF-16 编码),计算机会为这个单一字符预留 4 个字节的内存(两个 16 位的 Unicode 标量值)。^(5) 然而,在屏幕上,输出只占用一个字符位置,并且在用户看来像是一个单一字符。当这个字符出现在文本编辑器中,并且光标紧挨着字符右侧时,用户会期望按下退格键删除它。从用户的角度来看,这就是一个单一字符(正如 Swift 在打印字符串的count属性时报告的那样)。

然而,在 Unicode 中,一个字符大体上等同于一个代码点。这并不是人们通常认为的字符。在 Unicode 术语中,字形簇是人们通常所称的字符——它是一个或多个 Unicode 代码点的序列,这些代码点组合成一个单一的语言元素(即单个字符)。因此,当我们谈论与应用程序显示给终端用户的符号相关的字符时,我们实际上是在谈论字形簇。

字形簇可能会让软件开发人员感到头疼。考虑以下 Swift 代码(前一个示例的修改版):

import Foundation

let eAccent  :String = "e\u{301}\u{301}"

print( eAccent )

print( "eAccent.count=\(eAccent.count)" )

print( "eAccent.utf16.count=\(eAccent.utf16.count)" )

这段代码从前两个 print 语句输出相同的 ©1。以下输出 ©

print( eAccent )

并且这个 print 语句产生 1

print( "eAccent.count=\(eAccent.count)" )

然而,第三个 print 语句:

print( "eAccent.utf16.count=\(eAccent.utf16.count)" )

显示的是 3,而不是原始示例中的 2

这个字符串中确实有三个 Unicode 标量值(U+0065U+0301U+0301)。打印时,操作系统将 e 和两个急性重音组合字符结合,形成单一字符 ©,然后将该字符输出到标准输出设备。Swift 足够智能,知道这种组合会在显示器上创建一个单一的输出符号,因此打印 count 属性的结果仍然输出 1。然而,这个字符串中确实有(三个)Unicode 代码点,因此打印 utf16.count 输出 3

5.1.9 Unicode 规范和规范等价性

Unicode 字符 © 实际上在 Unicode 出现之前就已经存在于个人计算机中。它是原始 IBM PC 字符集的一部分,也是 Latin-1 字符集的一部分(例如,旧的 DEC 终端使用的字符集)。事实证明,Unicode 在 U+00A0U+00FF 范围内使用了 Latin-1 字符集,而 U+00E9 恰好对应于 © 字符。因此,我们可以按照以下方式修改之前的程序:

import Foundation

let eAccent  :String = "\u{E9}"

print( eAccent )

print( "eAccent.count=\(eAccent.count)" )

print( "eAccent.utf16.count=\(eAccent.utf16.count)" )

该程序的输出为:

©

1

1

哎呀!三个不同的字符串都输出 ©,但包含不同数量的代码点。想象一下,这将如何使包含 Unicode 字符的字符串编程变得复杂。例如,如果你有以下三个字符串(Swift 语法),并尝试比较它们,结果会是什么?

let eAccent1 :String = "\u{E9}"

let eAccent2 :String = "e\u{301}"

let eAccent3 :String = "e\u{301}\u{301}"

对用户而言,所有三种字符串在屏幕上看起来相同。然而,它们显然包含不同的值。如果你比较它们是否相等,结果是 true 还是 false

最终,这取决于你使用的是哪个字符串库。大多数当前的字符串库如果比较这些字符串的相等性,会返回false。有趣的是,Swift 会认为eAccent1等于eAccent2,但是它并不够聪明,无法报告eAccent1等于eAccent3,或者eAccent2等于eAccent3——尽管它显示这三个字符串的符号是相同的。许多编程语言的字符串库简单地报告这三者都不相等。

这三个 Unicode/Swift 字符串 "\{E9}""e\{301}""e\{301}\{301}" 在显示时都会产生相同的输出;因此,根据 Unicode 标准,它们是规范等效的。然而,一些字符串库并不会将这些字符串视为相等。其他一些库,如 Swift 的字符串库,会处理小的规范等效(例如,"\{E9}" == "e\{301}"),但不会处理应该等效的任意序列。^(6)

Unicode 为 Unicode 字符串定义了规范形式。规范形式的一个方面是将规范等效的序列替换为等效的序列——例如,将 "e\u{309}" 替换为 "\u{E9}",或者将 "\u{E9}" 替换为 "e\u{309}"(通常较短的形式是首选)。一些 Unicode 序列允许多个组合字符。通常,组合字符的顺序对于生成所需的字形簇来说并不重要。然而,如果组合字符按照指定的顺序排列,比较这两个字符串会更容易。规范化 Unicode 字符串还可能生成结果,其中的组合字符总是以固定顺序出现(从而提高字符串比较的效率)。

5.1.10 Unicode 编码

从 Unicode v2.0 起,标准支持一个 21 位的字符空间,能够处理超过一百万个字符(尽管大部分代码点仍然保留供将来使用)。为了支持更大的字符集,Unicode 公司允许不同的编码方式——UTF-32、UTF-16 和 UTF-8——每种方式都有其自身的优缺点。^(7)

UTF-32 使用 32 位整数来存储 Unicode 标量值。该方案的优点是,32 位整数可以表示每一个 Unicode 标量值(该值只需要 21 位)。需要随机访问字符串中字符的程序——而不必查找代理对——以及其他常数时间操作(大部分情况下)都可以使用 UTF-32 来实现。UTF-32 的明显缺点是每个 Unicode 标量值需要 4 个字节的存储——是原始 Unicode 定义的两倍,是 ASCII 字符的四倍。看起来,使用比 ASCII 和原始 Unicode 多两倍或四倍的存储空间似乎是一个小代价。毕竟,现代计算机的存储空间比 Unicode 最初出现时要大几个数量级。然而,这额外的存储空间对性能有巨大影响,因为这些额外的字节很快就会消耗掉缓存存储。此外,现代字符串处理库通常一次处理 8 个字节(在 64 位机器上)。对于 ASCII 字符,这意味着一个给定的字符串函数可以并行处理多达八个字符;而对于 UTF-32,相同的字符串函数只能并行处理两个字符。因此,UTF-32 版本的执行速度将比 ASCII 版本慢四倍。最终,即便是 Unicode 标量值也不足以表示所有 Unicode 字符(即,许多 Unicode 字符需要一系列的 Unicode 标量值),所以使用 UTF-32 并不能解决这个问题。

Unicode 支持的第二种编码格式是 UTF-16。顾名思义,UTF-16 使用 16 位(无符号)整数来表示 Unicode 值。为了处理大于 0xFFFF 的标量值,UTF-16 使用代理对方案来表示 0x0100000x10FFFF 范围内的值(参见页面 102 中的 “代理码点”)。因为绝大多数有用的字符适合 16 位表示,所以大多数 UTF-16 字符只需要 2 个字节。对于那些需要代理的罕见情况,UTF-16 需要 2 个字(32 位)来表示该字符。

最后的编码格式,毫无疑问是最流行的编码格式,是 UTF-8。UTF-8 编码与 ASCII 字符集向前兼容。特别地,所有 ASCII 字符都有一个单字节表示(它们原本的 ASCII 码,其中包含该字符的字节的高位包含 0 位)。如果 UTF-8 的高位是 1,则 UTF-8 需要额外的 1 到 3 个字节来表示 Unicode 码点。表 5-3 提供了 UTF-8 编码方案。

表 5-3: UTF 编码

字节 码点位数 第一个码点 最后一个码点 字节 1 字节 2 字节 3 字节 4
1 7 U+00 U+7F 0xxxxxxx
2 11 U+80 U+7FF 110xxxxx 10xxxxxx
3 16 U+800 U+FFFF 1110xxxx 10xxxxxx 10xxxxxx
4 21 U+10000 U+10FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

“xxx . . .”部分是 Unicode 码点的比特位。对于多字节序列,第 1 字节包含高位比特,第 2 字节包含下一个高位比特(与第 1 字节相比为低位比特),以此类推。例如,2 字节序列(%11011111%10000001)对应的 Unicode 标量是%0000_0111_1100_0001U+07C1)。

UTF-8 编码可能是最常用的编码。大多数网页都使用它。大多数 C 标准库字符串函数在处理 UTF-8 文本时无需修改(尽管一些 C 标准库函数如果程序员不小心,可能会产生格式错误的 UTF-8 字符串)。

不同的语言和操作系统使用不同的编码作为默认编码。例如,macOS 和 Windows 倾向于使用 UTF-16 编码,而大多数 Unix 系统使用 UTF-8。某些 Python 变种使用 UTF-32 作为其本地字符格式。然而,大多数编程语言都使用 UTF-8,因为它们可以继续使用基于 ASCII 的旧字符处理库来处理 UTF-8 字符。苹果的 Swift 是首批尝试正确实现 Unicode 的编程语言之一(尽管这样做会带来巨大的性能损失)。

5.1.11 Unicode 合成字符

虽然 UTF-8 和 UTF-16 编码比 UTF-32 更紧凑,但处理多字节(或多字)字符集所带来的 CPU 开销和算法复杂性使得它们的使用变得复杂,容易引入 bug 和性能问题。尽管浪费内存(尤其是缓存)存在问题,为什么不直接将字符定义为 32 位实体,然后就此了结呢?这似乎可以简化字符串处理算法,提高性能并减少代码中的缺陷可能性。

这个理论的问题在于,无法仅用 21 位(甚至 32 位)存储来表示所有可能的字形集群。许多字形集群由多个连接的 Unicode 码点组成。以下是 Chris Eidhof 和 Ole Begemann 的《Advanced Swift》(CreateSpace,2017)中的一个例子:

let chars: [Character] = [

    "\u{1ECD}\u{300}", 

    "\u{F2}\u{323}",

    "\u{6F}\u{323}\u{300}",

    "\u{6F}\u{300}\u{323}"

]

这些 Unicode 字形集群中的每一个都会产生相同的字符:一个带有下划点的ó(这是一个来自约鲁巴字符集的字符)。字符序列(U+1ECDU+300)是一个带有下划点的o,后跟一个合成重音符号。字符序列(U+F2U+323)是一个ó,后跟一个合成点。字符序列(U+6FU+323U+300)是一个o,后跟一个合成点,接着是一个合成重音符号。最后,字符序列(U+6FU+300U+323)是一个o,后跟一个合成重音符号,接着是一个合成点。所有四个字符串都会产生相同的输出。实际上,Swift 的字符串比较将这四个字符串视为相等:

print( "\u{1ECD} + \u{300} = \u{1ECD}\u{300}" )

print( "\u{F2} + \u{323} = \u{F2}\u{323}" )

print( "\u{6F} + \u{323} + \u{300} = \u{6F}\u{323}\u{300}" )

print( "\u{6F} + \u{300} + \u{323} = \u{6F}\u{300}\u{323}" )

print( chars[0] == chars[1] ) // Outputs true

print( chars[0] == chars[2] ) // Outputs true

print( chars[0] == chars[3] ) // Outputs true

print( chars[1] == chars[2] ) // Outputs true

print( chars[1] == chars[3] ) // Outputs true

print( chars[2] == chars[3] ) // Outputs true

请注意,并没有单一的 Unicode 标量值可以生成这个字符。你必须至少组合两个 Unicode 标量(或者最多三个)来在输出设备上生成这个字形簇。即使使用 UTF-32 编码,它仍然需要两个(32 位)标量来生成这个特定的输出。

表情符号带来了另一个挑战,这个问题无法通过 UTF-32 解决。考虑 Unicode 标量 U+1F471,它会打印出一个有着金发的人的表情符号。如果我们为此添加一个肤色修饰符,就会得到(U+1F471U+1F3FF),它生成的是一个肤色较深的金发人。在这两种情况下,屏幕上显示的都是一个字符。第一个例子使用了一个 Unicode 标量值,而第二个例子则需要两个标量。没有办法通过单个 UTF-32 值来编码此内容。

关键点是,某些 Unicode 字形簇需要多个标量,无论我们给标量分配多少位(例如,可能会将 30 或 40 个标量组合成一个字形簇)。这意味着我们必须处理多词序列来表示一个单一的“字符”,无论我们多么努力地避免这种情况。这就是为什么 UTF-32 从未真正普及的原因。它并没有解决对 Unicode 字符串进行随机访问的问题。如果你必须处理 Unicode 标量的归一化和组合,使用 UTF-8 或 UTF-16 编码会更有效率。

同样,今天大多数语言和操作系统都以某种形式支持 Unicode(通常使用 UTF-8 或 UTF-16 编码)。尽管处理多字节字符集存在明显的问题,但现代程序需要处理 Unicode 字符串,而不是简单的 ASCII 字符串。Swift 几乎是“纯 Unicode”的,甚至在标准的 ASCII 字符支持方面也没有太多内容。

5.2 字符串

在整数之后,字符字符串可能是现代程序中最常用的数据类型。一般来说,字符字符串是一个具有两个主要属性的字符序列:长度字符数据

字符串也可能具有其他属性,例如该特定变量允许的最大长度引用计数,用于指定有多少不同的字符串变量引用同一个字符字符串。在本节中,我们将探讨这些属性以及程序如何使用它们,描述了各种字符串格式和一些可能的字符串操作。

5.2.1 字符串格式

不同的编程语言使用不同的数据结构来表示字符串。一些字符串格式占用更少的内存,其他格式则允许更快的处理,有的格式使用起来更为方便,还有的格式为程序员和操作系统提供了额外的功能。为了帮助你更好地理解字符字符串设计背后的原理,让我们看看一些由不同高级语言推广的常见字符串表示方式。

5.2.1.1 零终止字符串

毫无疑问,零终止字符串(zero-terminated strings)是目前最常用的字符串表示方式,因为这是 C、C++以及其他几种语言的原生字符串格式。此外,你会在没有特定原生字符串格式的语言编写的程序中发现零终止字符串,例如汇编语言。

一个零终止的 ASCII 字符串是一个序列,包含零个或多个 8 位字符代码,以一个包含0字节的字节结尾(或者在 UTF-16 的情况下,序列包含零个或多个 16 位字符代码,并以一个包含0的 16 位字组成)。例如,在 C/C++中,ASCII 字符串"abc"需要 4 个字节:每个字符abc各占 1 个字节,再加上一个0字节。

零终止字符串相较于其他字符串格式有一些优势:

  • 零终止字符串可以用一个字节的开销(UTF-16 中是 2 字节,UTF-32 中是 4 字节)表示任何实际长度的字符串。

  • 鉴于 C/C++编程语言的普及,已有高性能的字符串处理库可以很好地与零终止字符串配合使用。

  • 零终止字符串很容易实现。就 C 和 C++语言而言,字符串只是字符数组。这可能是 C 语言设计者最初选择这种格式的原因——这样他们就不必用字符串操作符来使语言变得更加复杂。

  • 你可以在任何能够创建字符数组的语言中轻松表示零终止字符串。

然而,零终止字符串也有一些缺点,这意味着它们并不总是表示字符字符串数据的最佳选择:

  • 需要在操作字符串数据之前知道字符串长度的字符串函数,在操作零终止字符串时通常效率不高。计算零终止字符串的长度的唯一合理方法是从字符串开始扫描到结尾。你的字符串越长,这个函数运行的速度就越慢,所以如果你需要处理长字符串,零终止字符串格式并不是最佳选择。

  • 尽管这是一个小问题,但你不能轻易地用零终止字符串格式表示字符代码0(例如 ASCII 和 Unicode 中的 NUL 字符)。

  • 零终止字符串不包含任何信息,告诉你字符串在终止0字节之后可以增长的长度。因此,某些字符串函数(如连接操作)只能扩展现有字符串变量的长度,并且只有在调用者明确传递最大长度时,才会检查是否有溢出。

5.2.1.2 长度前缀字符串

第二种字符串格式,* 长度前缀字符串 *,克服了零终止字符串的一些问题。长度前缀字符串在像 Pascal 这样的语言中很常见;它们通常由一个字节组成,该字节指定字符串的长度,后面跟着零个或多个 8 位字符代码。在长度前缀方案中,字符串 "abc" 由 4 个字节组成:长度字节($03),后面是 abc

长度前缀字符串解决了与零终止字符串相关的两个问题:它们允许你表示 NUL 字符,并且字符串操作更加高效。长度前缀字符串的另一个优点是长度通常位于字符串的0位置(如果我们将字符串视为字符数组),因此字符串的第一个字符在数组表示中从索引1开始。对于许多字符串函数来说,使用基于1的索引来访问字符数据比使用基于0的索引(零终止字符串使用的)更加方便。

长度前缀字符串的主要缺点是它们的最大长度限制为 255 个字符(假设使用 1 字节的长度前缀)。你可以通过使用 2 字节或 4 字节的长度值来去除这个限制,但这样做会将每个字符串的开销数据从 1 字节增加到 2 字节或 4 字节。

5.2.1.3 七位字符串

7 位字符串格式是一个有趣的选项,适用于像 ASCII 这样的 7 位编码。它使用字符串中字符的(通常未使用的)高位来表示字符串的结束。除了字符串中的最后一个字符代码外,所有字符的高位都被清除,字符串中的最后一个字符的高位被设置为 1。

这种 7 位字符串格式有几个缺点:

  • 你必须扫描整个字符串才能确定字符串的长度。

  • 你不能使用零长度字符串。

  • 很少有语言为 7 位字符串提供字面量字符串常量。

  • 你最多只能使用 128 个字符代码,但在使用纯 ASCII 时这并不成问题。

然而,7 位字符串的一个大优点是它们不需要任何额外字节来编码长度。汇编语言(使用宏来创建字面量字符串常量)可能是处理 7 位字符串时最好的语言。因为 7 位字符串的优势在于它们紧凑,而汇编语言程序员往往最关注紧凑性,所以这非常契合。以下是一个将字面量字符串常量转换为 7 位字符串的 HLA 宏:

#macro sbs( s );

    // Grab all but the last character of the string:

    (@substr( s, 0, @length(s) – 1) +

        // Concatenate the last character with its HO bit set:

        char( uns8( char( @substr( s, @length(s) – 1, 1))) | $80 ) )

#endmacro

    . . .

byte sbs( "Hello World" );
5.2.1.4 HLA 字符串

只要你不太在乎每个字符串增加一些额外的字节开销,你就可以创建一种结合了长度前缀和零终止字符串优点的字符串格式,而没有它们各自的缺点。高级汇编语言已经通过其本地字符串格式实现了这一点。^(8)

HLA 字符串格式的最大缺点是每个字符串所需的开销:每个字符串 9 字节,^(9),如果你处于内存受限的环境中并且处理许多小字符串,这可能会占用显著的百分比。

HLA 字符串格式使用 4 字节的长度前缀,允许字符字符串的长度超过四十亿个字符(显然,这远远超过任何实际的 HLA 应用程序所需的)。HLA 还会将一个 0 字节附加到字符字符串数据后。额外的 4 字节开销包含该字符串的最大合法长度。拥有这个额外的字段允许 HLA 字符串函数在必要时检查字符串溢出。在内存中,HLA 字符串呈现出 图 5-3 所示的形式。

image

图 5-3:HLA 字符串格式

字符串第一个字符之前的 4 个字节包含当前字符串的长度。当前字符串长度之前的 4 个字节包含最大字符串长度。字符数据之后紧接着是一个 0 字节。最后,HLA 始终确保字符串数据结构的长度是 4 字节的倍数(出于性能考虑),因此对象的末尾可能会有最多 3 个额外的填充字节。(注意,图 5-3 中的字符串仅需要 1 字节的填充,以确保数据结构的长度是 4 字节的倍数。)

HLA 字符串变量是包含字符串中第一个字符字节地址的指针。要访问长度字段,你需要将字符串指针的值加载到 32 位寄存器中,然后访问偏移量为 -4 的 Length 字段和偏移量为 -8 的 MaxLength 字段。以下是一个示例:

static

        s :string := "Hello World";

                . . .

        mov( s, esi );        // Move the address of 'H' in "Hello World"

                              // into esi.

        mov( [esi-4], ecx );  // Puts length of string (11 for "Hello World")

                              // into ECX.

                . . .

        mov( s, esi );

        cmp( eax, [esi-8] );  // See if value in EAX exceeds the maximum

                              // string length.

        ja StringOverflow;

作为只读对象,HLA 字符串与零终止字符串兼容。例如,如果你有一个用 C 语言编写的函数,它期望你传递一个零终止字符串,你可以调用该函数并传递一个 HLA 字符串变量,如下所示:

someCFunc( hlaStringVar );

唯一的注意事项是 C 函数不能对字符串进行任何会影响其长度的修改(因为 C 代码不会更新 HLA 字符串的 Length 字段)。当然,你可以在返回时调用 C 的 strlen() 函数来更新长度字段,但通常情况下,最好不要将 HLA 字符串传递给修改零终止字符串的函数。

5.2.1.5 基于描述符的字符串

我们到目前为止考虑过的字符串格式都将属性信息(即长度和终止字节)与字符数据一起保存在内存中。一个稍微更灵活的方案是将这些信息保存在一个记录结构中,称为描述符,该结构还包含指向字符数据的指针。考虑以下 Pascal/Delphi 数据结构:

type

    dString :record

              curLength  :integer;

              strData    :^char;

   end;

请注意,这个数据结构并不保存实际的字符数据。相反,strData 指针包含字符串第一个字符的地址。curLength 字段指定字符串的当前长度。你可以向这个记录中添加任何其他字段,比如最大长度字段,尽管通常不需要最大长度字段,因为大多数使用描述符的字符串格式是 动态的(如下一节将讨论的那样)。大多数使用描述符的字符串格式只维护 Length 字段。

基于描述符的字符串系统的一个有趣特点是,关联到字符串的实际字符数据可以是更大字符串的一部分。因为实际字符数据中没有长度或终止字节,所以可以让两个字符串的字符数据重叠(见 图 5-4)。

image

图 5-4:使用描述符的重叠字符串

在这个例子中,有两个字符串——"Hello World""World"——是重叠的。这可以节省内存并使某些函数(如 substring())非常高效。当然,当字符串像这样重叠时,你不能修改字符串数据,因为这可能会擦除其他字符串的一部分。

5.2.1.6 Java 字符串

Java 使用基于描述符的字符串形式。实际的 String 数据类型(即定义 Java 字符串内部表示的结构/类)是 不透明的,这意味着你不应该知道或去修改它。尝试以 Java String API 以外的方式操作 Java 字符串是一个非常糟糕的主意,因为 Java 标准已经在几个场合更改了它们的内部表示。

例如,Java 最初将 String 类型定义为一个包含四个项的描述符:指向一个 16 位(原始)Unicode 字符数组的指针(不扩展到 16 位以上)、一个计数字段、一个偏移量字段和一个哈希码字段。偏移量和计数字段允许高效的子字符串操作,因为所有子字符串都将共享同一个字符数组。不幸的是,这种格式在一些退化的情况下产生了内存泄漏,因此 Java 的设计者更改了格式并删除了这些字段。如果你的代码使用了偏移量和计数字段(同样,这是一个不好的做法),你的代码就会因为这个更改而崩溃。

Java 还从最初的 Unicode 2 字节定义切换到 UTF-16 编码,因为很明显 16 位字符是不够的。然而,在对互联网上各种 Java 程序进行一些研究后,Oracle(Java 的所有者)发现大多数程序只使用 Latin-1 字符集(基本上是 ASCII)。正如 Oracle 自己所说:

来自不同应用程序的数据表明,字符串是 Java 堆内存使用的主要组成部分,并且大多数 java.lang.String 对象只包含 Latin-1 字符。这些字符仅需要一个字节的存储空间。因此,java.lang.String 对象的内部字符数组中有一半的空间未被使用。Java SE 9 引入的紧凑字符串特性减少了内存占用,并减少了垃圾回收活动。

这一变化对 Java 用户及其程序几乎是透明的。Oracle 向 String 描述符中添加了一个新字段,用于指定编码是 UTF-16 还是 Latin-1。如果你的程序依赖于内部表示,它们将会受到影响。

始终假设 Java String 是标准的 Unicode 字符串(通常使用 UTF-16 编码)。Java 并不试图隐藏多字字符的复杂性。作为 Java 程序员,你必须意识到字符串中字符数、代码点和字形集群之间的区别。Java 提供了一些函数——例如,String.length()String.codePointCount()BreakIterator.getCharacterInstance()——来计算所有这些值,但你的代码必须显式地调用它们。

5.2.1.7 Swift 字符串

与 Java 类似,Swift 编程语言在其字符串中使用 Unicode 字符。Swift 4.x 及更早版本使用 UTF-16 编码,这是 macOS(Apple 开发 Swift 所基于的操作系统)原生支持的编码;而在 Swift v5.0 中,Apple 将 Swift 字符串的原生编码更改为 UTF-8。与 Java 一样,Swift 的 String 类型是透明的,因此你不应该尝试修改(或以其他方式使用)其内部表示。

5.2.1.8 C# 字符串

C# 编程语言使用 UTF-16 编码表示其字符串中的字符。与 Java 和 Swift 一样,C# 的 string 类型是透明的,因此你不应该尝试修改(或以其他方式使用)其内部表示。不过,微软的文档确实声称 C# 字符串是(Unicode)字符的数组。

5.2.1.9 Python 字符串

Python 编程语言最初使用 UCS-2(原始的 16 位 Unicode,仅限 BMP)编码表示字符串。然后,Python 被修改为支持 UTF-16 或 UTF-32 编码(语言可以编译为“窄字符”或“宽字符”版本,分别支持 16 位或 32 位字符)。今天,现代版本的 Python 使用一种特殊的字符串格式,该格式跟踪字符串中的字符,并根据最紧凑的表示方式将它们存储为 ASCII、UTF-8、UTF-16 或 UTF-32。你无法直接访问 Python 中的内部字符串表示,因此不需要担心不透明类型的问题。

5.2.2 字符串类型:静态、伪动态和动态

基于迄今为止覆盖的各种字符串格式,我们现在可以根据系统何时为字符串分配存储来定义三种字符串类型。它们是静态字符串、伪动态字符串和动态字符串。

5.2.2.1 静态字符串

静态字符串是那些程序员在编写程序时选择其最大大小的字符串。Pascal 字符串和 Delphi 的“短”字符串属于这一类。你在 C/C++ 中用来保存以零为终止符的字符串的字符数组也属于这一类。考虑以下在 Pascal 中的声明:

(* Pascal static string example *)

var  pascalString :string(255);  // Max length will always be 255 characters.

下面是 C/C++ 中的一个示例:

// C/C++ static string example:

char cString[256];  // Max length will always be 255 characters

                    // (plus 0 byte).

当程序运行时,无法增加这些静态字符串的最大大小。也没有办法减少它们使用的存储空间;这些字符串对象在运行时将始终消耗 256 字节。纯静态字符串的一个优点是,编译器可以在编译时确定它们的最大长度,并隐式将此信息传递给字符串函数,以便它在运行时检查是否有越界违规。

5.2.2.2 伪动态字符串

伪动态字符串是其长度由系统在运行时通过调用类似 malloc() 的内存管理函数来分配存储空间的字符串。然而,一旦系统为字符串分配了存储空间,字符串的最大长度就会被固定。HLA 字符串通常属于这一类。^(10) HLA 程序员通常会调用 stralloc() 函数为字符串变量分配存储空间,之后该特定字符串对象的长度将被固定,无法改变。^(11)

5.2.2.3 动态字符串

动态字符串系统通常使用基于描述符的格式,每当创建一个新字符串或执行任何影响现有字符串的操作时,它会自动分配足够的存储空间给字符串对象。在动态字符串系统中,像字符串赋值和子字符串操作这样的操作相对简单——通常它们只复制字符串描述符数据,因此这些操作非常快。然而,正如在“基于描述符的字符串”一节中第 114 页所提到的那样,当以这种方式使用字符串时,你无法将数据存储回字符串对象中,因为这可能会修改系统中其他字符串对象的部分数据。

解决这个问题的方法是使用写时复制技术。每当一个字符串函数需要修改动态字符串中的字符时,该函数首先会创建字符串的副本,然后对副本进行必要的修改。研究表明,写时复制语义能够提升许多典型应用程序的性能,因为像字符串赋值和子字符串提取(其实只是部分字符串赋值)这样的操作比修改字符串中的字符数据要常见得多。这种方法唯一的缺点是,在内存中多次修改字符串数据之后,可能会有一些字符串堆区域包含不再使用的字符数据。为了避免* 内存泄漏 ,采用写时复制的动态字符串系统通常会提供 垃圾回收 代码,它会扫描字符串堆区域,寻找陈旧*的字符数据,并将该内存回收供其他用途。不幸的是,取决于使用的算法,垃圾回收可能会非常慢。

5.2.3 字符串的引用计数

假设有两个字符串描述符(或指针)指向内存中的同一字符串数据。显然,在程序仍然使用另一个指针访问相同数据时,你不能释放(即,重新用于其他用途)与一个指针关联的存储空间。一个常见的解决方案是让程序员负责跟踪这些细节。不幸的是,随着应用程序变得更加复杂,这种方法常常导致悬空指针、内存泄漏和其他与指针相关的问题。一个更好的解决方案是允许程序员释放与字符串字符数据相关联的存储空间,并且实际的释放过程会推迟,直到程序员释放最后一个引用该数据的指针。为此,字符串系统可以使用引用计数器,它用于跟踪指针及其关联的数据。

引用计数器 是一个整数,用来计数在内存中引用字符串字符数据的指针数量。每当你将字符串的地址赋给某个指针时,你就将引用计数器加 1。同样,每当你希望释放与字符串字符数据相关的存储空间时,你就将引用计数器减 1。只有当引用计数器减到 0 时,才会发生字符数据存储空间的释放。

引用计数在语言自动处理字符串赋值的细节时效果非常好。如果你尝试手动实现引用计数,你必须确保在将字符串指针赋值给其他指针变量时始终递增引用计数。实现这一点的最佳方法是不要直接赋值指针,而是通过某个函数(或宏)调用处理所有字符串赋值,并在复制指针数据的同时更新引用计数。如果你的代码未能正确更新引用计数,你将会遇到悬空指针或内存泄漏问题。

5.2.4 Delphi 字符串

尽管 Delphi 提供了一种与早期版本 Delphi 中的长度前缀字符串兼容的“短字符串”格式,但后来的 Delphi 版本(4.0 及更高版本)使用动态字符串。虽然这种字符串格式没有公开(因此可能会有所变化),但有迹象表明,Delphi 的字符串格式与 HLA 非常相似。Delphi 使用一个以零结尾的字符序列,前面有字符串长度和引用计数(而不是像 HLA 那样使用最大长度)。图 5-5 展示了 Delphi 字符串在内存中的布局。

image

图 5-5:Delphi 字符串数据格式

与 HLA 一样,Delphi 字符串变量是指向实际字符串数据第一个字符的指针。为了访问长度和引用计数字段,Delphi 字符串例程使用从字符数据基地址向后的偏移量——4 和——8。然而,由于这种字符串格式没有公开,应用程序不应直接访问长度或引用计数字段。Delphi 提供了一个长度函数,用于提取字符串长度,实际上你的应用程序无需访问引用计数字段,因为 Delphi 字符串函数会自动维护它。

5.2.5 自定义字符串格式

通常,你将使用语言提供的字符串格式,除非你有特殊的需求。如果是这样,你会发现大多数语言提供了用户定义的数据结构功能,允许你创建自己的自定义字符串格式。

请注意,语言可能会坚持使用单一的字符串格式来表示文字字符串常量。然而,通常你可以编写一个简短的转换函数,将你的语言中的文字字符串转换为你选择的任何格式。

5.3 字符集数据类型

与字符串一样,字符集数据类型(或称字符集)是一种复合数据类型,建立在字符数据类型之上。字符集是字符的数学集合。集合中的成员关系是二元关系:一个字符要么在集合中,要么不在,不能在字符集中出现同一个字符的多个副本。此外,顺序的概念(例如,某个字符是否在另一个字符之前出现,像在字符串中一样)对字符集而言是陌生的。如果两个字符是集合的成员,它们在集合中的顺序是无关紧要的。

表 5-4 列出了应用程序对字符集执行的一些常见操作。

表 5-4: 常见的字符集函数

功能/操作符 描述
成员资格(在) 检查一个字符是否是字符集的成员(返回true/false)。
交集 返回两个字符集的交集(即,既属于两个集合的字符集合)。
联集 返回两个字符集的并集(即,属于任意一个集合或两个集合的字符)。
差集 返回两个集合的差集(即,属于一个集合但不属于另一个集合的字符)。
提取 从集合中提取单个字符。
子集 如果一个字符集是另一个字符集的子集,则返回true
正确的子集 如果一个字符集是另一个字符集的正确子集,则返回true
超集 如果一个字符集是另一个字符集的超集,则返回true
正确的超集 如果一个字符集是另一个字符集的超集,则返回true
等式 如果一个字符集等于另一个字符集,则返回true
不等式 如果一个字符集不等于另一个字符集,则返回true

5.3.1 字符集的幂集表示

表示字符集有许多不同的方式。一些语言使用布尔值数组来实现字符集(每个可能的字符代码对应一个布尔值)。每个布尔值决定它对应的字符是否(true)或不(false)是字符集的成员。为了节省内存,大多数字符集实现只为集合中的每个字符分配一个位;因此,当支持 128 个字符时,它们消耗 16 字节(128 位)内存,当支持最多 256 个字符时,消耗 32 字节(256 位)内存。这种字符集的表示方式被称为幂集

HLA 语言使用一个 16 字节的数组来表示 128 个可能的 ASCII 字符,该数组在内存中的组织方式如图 5-6 所示。

image

图 5-6:HLA 字符集表示

字节 0 的位 0 对应于 ASCII 码 0(NUL 字符)。如果这个位是 1,则字符集包含 NUL 字符;如果这个位是 0,则字符集不包含 NUL 字符。同样,字节 8 的位 1 对应于 ASCII 码 65,大写字母 A。如果 A 是字符集的当前成员,则位 65 将为 1,如果不是,则为 0

Pascal(例如 Delphi)使用类似的方案来表示字符集。Delphi 允许在字符集中最多包含 256 个字符,因此 Delphi 字符集占用 256 位(或 32 字节)内存。

尽管实现字符集的方法有很多种,但这种位向量(数组)实现使得执行集合操作,如并集、交集、差集比较和成员测试,变得非常简单。

5.3.2 字符集的列表表示

有时,幂集位图并不是表示字符集的最佳方式。例如,如果你的集合通常非常小(最多三个或四个成员),使用 16 或 32 字节来表示每个集合可能会显得过于浪费。在这种情况下,你最好使用字符字符串来表示字符列表。^(12) 如果你的集合中很少有超过几个字符的元素,那么扫描字符串以查找特定字符通常对大多数应用来说已经足够高效。同样,如果你的字符集有大量可能的字符,那么幂集表示法可能会变得非常庞大(例如,实现原始的 Unicode UCS-2 字符集作为幂集需要 8,192 字节的内存,即使集合中只有一个字符)。在这种情况下,列表或字符字符串表示法可能比幂集表示法更为合适,因为你不需要为集合中的所有可能成员保留内存(只需要为实际存在的成员保留内存)。

5.4 设计你自己的字符集

ASCII、EBCDIC 和 Unicode 字符集几乎没有什么是神圣不可侵犯的。它们的主要优点在于它们是国际标准,许多系统都遵循这些标准。如果你坚持使用其中一种标准, chances are good 你将能够与他人交换信息,这也是这些代码设计的初衷。

然而,这些字符集并不是为了简化各种字符计算而设计的。ASCII 和 EBCDIC 是在如今已经过时的硬件条件下开发的——分别是机械电传打字机的键盘和打孔卡片系统。鉴于如今这类设备主要只能在博物馆里找到,因此这些字符集中的代码布局在现代计算机系统中几乎没有任何优势。如果今天我们可以设计自己的字符集,它们与 ASCII 或 EBCDIC 会有很大不同。它们可能会基于现代键盘(因此会包括常见按键的代码,如左箭头、右箭头、页面向上和页面向下)。它们的布局也会使得各种常见计算变得更加轻松。

尽管 ASCII 和 EBCDIC 字符集在短期内不会消失,但没有什么能阻止你定义自己的应用程序特定字符集。当然,这样的字符集是应用程序特定的,你将无法与不了解你私有编码的应用程序共享包含你自定义字符集的文本文件。但使用查找表在不同字符集之间进行转换是相当容易的,因此在执行输入/输出操作时,你可以在应用程序的内部字符集和外部字符集(如 ASCII)之间进行转换。假设你选择了一个合理的编码,使得程序整体更高效,尽管输入/输出过程中的效率可能会有所损失,但还是值得的。那么,你该如何选择编码呢?

你首先要问自己的是:“我希望我的字符集支持多少个字符?”显然,你选择的字符数量将直接影响字符数据的大小。一个简单的选择是 256 个可能的字符,因为字节是软件用于表示字符数据的最常见原始数据类型。然而,记住,如果你实际上并不需要 256 个字符,你可能不应该在字符集里定义那么多。例如,如果你只需 128 个,甚至 64 个字符来满足需求,那么你使用它创建的“文本文件”将会更好地压缩。同样,使用它进行数据传输时,如果每个字符只需传输 6 或 7 位而不是 8 位,传输速度会更快。如果你需要超过 256 个字符,你必须权衡使用多个编码页、双字节字符集或 16 位字符的优缺点。并且请记住,Unicode 支持用户自定义字符。所以,如果你需要超过 256 个字符,你可能需要考虑将其插入到 Unicode 中,以便与全球其他系统保持“某种程度的标准”。

在本节中,我们将定义一个包含 128 个字符的字符集,使用 8 位字节表示。大多数情况下,我们将仅仅重新排列 ASCII 字符集中的代码,使其在进行几种计算时更加方便,并且会重新命名一些控制代码,以便它们在现代系统中更有意义,而不是当初为老旧的主机和电传打字机设计的那些代码。我们还会添加一些 ASCII 标准之外的新字符。同样,这个练习的主要目的是使各种计算更加高效,而不是创造新的字符。我们将这个字符集称为HyCode字符集。

注意

这一点需要重复强调:本章中使用 HyCode 并不是试图创建某种新的字符集标准。这只是一个如何创建自定义、应用程序特定字符集以提升程序效率的示范。

5.4.1 设计高效的字符集

在设计一个新的字符集时,我们应该考虑几个方面。例如,我们是否需要能够使用现有的字符串格式来表示字符字符串?这会影响我们字符串的编码——如果你想使用操作零终止字符串的函数库,那么你需要在自定义字符集中保留编码0,用于作为字符串结束标记。然而,请记住,很多字符串函数无论你做什么,都无法与新字符集一起使用。像stricmp()这样的函数只有在你使用与 ASCII(或其他常见字符集)相同的字母字符表示时才有效。因此,你不应感到受某种特定字符串表示的限制,因为你无论如何都会编写许多自己的字符串函数来处理自定义字符。HyCode 字符集不保留0编码作为字符串结束标记,这是可以的,因为零终止字符串效率不高。

如果你查看使用字符函数的程序,你会发现某些函数经常出现,例如:

  • 检查一个字符,看看它是否是数字。

  • 将数字字符转换为其对应的数字。

  • 将数字字符转换为其对应的字符。

  • 检查一个字符,看看它是否是字母字符。

  • 检查一个字符,看看它是否是小写字母字符。

  • 检查一个字符,看看它是否是大写字母字符。

  • 使用不区分大小写的比较方法比较两个字符(或字符串)。

  • 对字母字符串进行排序(区分大小写和不区分大小写排序)。

  • 检查一个字符,看看它是否是字母数字字符。

  • 检查一个字符,看看它是否在标识符中是合法的。

  • 检查一个字符,看看它是否是常见的算术或逻辑运算符。

  • 检查一个字符,看看它是否是括号字符(即,()[]{}<>)。

  • 检查一个字符,看看它是否是标点符号字符。

  • 检查一个字符,看看它是否是空白字符(如空格、制表符或换行符)。

  • 检查一个字符,看看它是否是光标控制字符。

  • 检查一个字符,看看它是否是滚动控制键(如 PGUP、PGDN、HOME 和 END)。

  • 检查一个字符,看看它是否是功能键。

我们将设计 HyCode 字符集,使这些类型的操作尽可能高效和简便。相较于 ASCII 字符集,我们可以做出一个巨大的改进,即为属于同一类型的字符(如字母字符和控制字符)分配连续的字符编码,这样我们就可以通过一对比较来进行上述任何测试。例如,如果我们能通过与表示整个标点符号字符范围上下限的两个值进行比较来确定某个字符是否为标点符号,那就太好了。ASCII 中无法做到这一点,因为标点符号字符分散在整个字符集内。虽然我们无法通过这种方式满足每一个可能的范围比较,但我们可以设计我们的字符集,使其能够以最少的比较次数满足最常见的测试。

5.4.2 数字字符编码分组

我们可以通过将字符编码 09 保留给数字字符 0 到 9 来实现前面列表中的前三个功能。首先,通过使用单一的无符号比较来检查字符编码是否小于或等于 9,我们可以判断一个字符是否为数字。接下来,字符与其数字表示之间的转换非常简单,因为字符编码和数字表示是相同的。

5.4.3 字母字符分组

ASCII 字符集尽管远不如 EBCDIC 那样糟糕,但在处理字母字符的测试和操作时设计得并不好。以下是我们将通过 HyCode 解决的 ASCII 中的一些问题:

  • 字母字符分布在两个不重叠的范围内。进行字母字符测试时需要四次比较。

  • 小写字母的 ASCII 编码大于大写字母。如果我们要进行区分大小写的比较,将小写字母视为小于大写字母会更直观。

  • 所有小写字母的值都大于任何单个的大写字母。这会导致一些违反直觉的结果,例如 a 大于 B

HyCode 以几种有趣的方式解决了这些问题。首先,HyCode 使用 $4C$7F 的编码来表示 52 个字母字符。因为 HyCode 只使用 128 个字符编码($00..$7F),字母字符编码占用了最后的 52 个字符编码。这意味着我们可以通过比较字符编码是否大于或等于 $4C 来测试一个字符是否为字母。在高级语言中,你会像这样写这个比较:

if( c >= 76) . . .

或者,如果你的编译器支持 HyCode 字符集,可以像这样:

if( c >= 'a') . . .

在汇编语言中,你可以使用一对类似下面的指令:

         cmp( al, 76 );

         jnae NotAlphabetic;

             // Execute these statements if it's alphabetic

NotAlphabetic:

HyCode 交替使用小写字母和大写字母(即,顺序编码对应的字符为 aAbBcC 等)。这使得排序和比较字符串变得非常容易,无论你是在进行区分大小写还是不区分大小写的搜索。交替使用字符编码的 LO 位来确定字符编码是小写字母(LO 位为 0)还是大写字母(LO 位为 1)。HyCode 对字母字符使用以下编码:

a:76, A:77, b:78, B:79, c:80, C:81, . . . y:124, Y:125, z:126, Z:127

使用 HyCode 检查字母字符是大写字母还是小写字母比检查字符是否是字母字符要多一些工作,但在汇编语言中,这仍然比等效的 ASCII 比较要少工作。要测试一个字符是否属于某一大小写,你需要进行两次比较——首先检查它是否是字母字符,然后判断它的大小写。在 C/C++ 中,你可以使用如下语句:

if( (c >= 76) && (c & 1) )

{

    // execute this code if it's an uppercase character

}

if( (c >= 76) && !(c & 1) )

{

    // execute this code if it's a lowercase character

}

子表达式 (c & 1) 如果 c 的 LO 位是 1,则为 true1),意味着如果 c 是字母字符,那么它是大写字母。同样,!(c & 1) 如果 c 的 LO 位是 0,则为 true,意味着 c 是小写字母。如果你在 80x86 汇编语言中工作,你可以通过使用三条机器指令来测试一个字符是大写字母还是小写字母:

// Note: ROR(1, AL) maps lowercase to the range $26..$3F (38..63)

//       and uppercase to $A6..$BF (166..191). Note that all other characters

//       get mapped to smaller values within these ranges.

         ror( 1, al );

         cmp( al, $26 );

         jnae NotLower;    // Note: must be an unsigned branch!

             // Code that deals with a lowercase character.

NotLower:

// For uppercase, note that the ROR creates codes in the range $A8..$BF which 

// are negative (8-bit) values. They also happen to be the *most* negative 

// numbers that ROR will produce from the HyCode character set.

         ror( 1, al );

         cmp( al, $a6 );

         jge NotUpper;    // Note: must be a signed branch!

             // Code that deals with an uppercase character.

NotUpper:

很少有语言提供 ror() 操作的等价功能,并且只有少数几种语言允许你(轻松地)在同一代码序列中将字符值视为有符号和无符号。因此,这个序列可能仅限于汇编语言程序。

5.4.4 比较字母字符

HyCode 对字母字符的分组意味着字典排序几乎是免费的。通过比较 HyCode 字符值对字符串进行排序,你可以获得字典顺序,因为 HyCode 定义了以下字母字符的关系:

a < A < b < B < c < C < d < D < . . . < w < W < x < X < y < Y < z < Z

这正是你所期望的字典排序关系,同时也是大多数人直觉上会期望的关系。要进行不区分大小写的比较,你只需屏蔽字母字符的 LO 位(或将它们都强制为 1)。

为了看到在进行不区分大小写比较时 HyCode 字符集的优势,我们先来看一下在 C/C++ 中标准的不区分大小写字符比较对于两个 ASCII 字符的实现方式:

if( toupper( c ) == toupper( d ))

{

    // do code that handles c==d using a case-insensitive comparison.

}

这段代码看起来不算太复杂,但考虑一下 toupper() 函数(或通常是宏)展开后的样子:^(13)

#define toupper(ch) ( (ch >= 'a' && ch <= 'z') ? ch & 0x5f : ch )

使用这个宏,当 C 预处理器展开前面的 if 语句时,结果如下:

if

(

        ( (c >= 'a' && c <= 'z') ? c & 0x5f : c )

     == ( (d >= 'a' && d <= 'z') ? d & 0x5f : d )

)

{

        // do code that handles c==d using a case-insensitive comparison.

}

这会展开为类似于以下的 80x86 代码:

        // assume c is in cl and d is in dl.

        cmp( cl, 'a' );     // See if c is in the range 'a'..'z'

        jb NotLower;

        cmp( cl, 'z' );

        ja NotLower;

        and( $5f, cl );     // Convert lowercase char in cl to uppercase.

NotLower:

        cmp( dl, 'a' );     // See if d is in the range 'a'..'z'

        jb NotLower2;

        cmp( dl, 'z' );

        ja NotLower2;

        and( $5f, dl );     // Convert lowercase char in dl to uppercase.

NotLower2:

        cmp( cl, dl );      // Compare the (now uppercase if alphabetic)

                            // chars.

        jne NotEqual;       // Skip the code that handles c==d if they're 

                            // not equal.

            // do code that handles c==d using a case-insensitive comparison.

NotEqual:

在 HyCode 中,不区分大小写的比较要简单得多。以下是 HLA 汇编代码的样子:

// Check to see if CL is alphabetic. No need to check DL as the comparison

// will always fail if DL is nonalphabetic.

        cmp( cl, 76 );      // If CL < 76 ('a') then it's not alphabetic

        jb TestEqual;       // and there is no way the two chars are equal

                            // (even ignoring case).

        or( 1, cl );        // CL is alpha, force it to uppercase.

        or( 1, dl );        // DL may or may not be alpha. Force to 

                            // uppercase if it is.

TestEqual:

        cmp( cl, dl );      // Compare the uppercase versions of the chars.

        jne NotEqual;       // Bail out if they're not equal.

TheyreEqual:

            // do code that handles c==d using a case-insensitive comparison.

NotEqual:

如你所见,HyCode 序列使用了用于不区分大小写的两个字符比较的一半指令。

5.4.5 分组其他字符

因为字母字符位于字符编码范围的一端,而数字字符位于另一端,所以检查一个字符是否为字母数字字符需要进行两次比较(这仍然比 ASCII 中所需的四次比较要好)。以下是你可以用来检查一个字符是否为字母数字的 Pascal/Delphi 代码:

if( ch < chr(10) or ch >= chr(76) ) then . . .

一些程序(超出编译器之外)需要高效地处理表示程序标识符的字符字符串。大多数语言允许标识符中使用字母数字字符,正如你刚刚看到的,我们可以通过仅进行两次比较来检查一个字符是否为字母数字字符。

许多语言也允许在标识符中使用下划线,并且一些语言,如 MASM,允许其他字符,如“at”符号(@)和美元符号($)出现在标识符中。因此,通过将下划线字符分配值 75,并将 $@ 字符分别分配代码 7374,我们仍然可以仅通过两次比较来测试一个字符是否为标识符字符。

由于类似的原因,HyCode 将光标控制键、空白字符、括号字符(圆括号、方括号、大括号和尖括号)、算术运算符、标点字符等分组在一起。表格 5-5 列出了完整的 HyCode 字符集。如果你研究每个字符所分配的数字代码,你会发现它们允许高效地计算前面描述的大多数字符操作。

表格 5-5: HyCode 字符集

二进制 十六进制 十进制 字符 二进制 十六进制 十进制 字符
0000_0000 00 0 0 0001_1110 1E 30 结束
0000_0001 01 1 1 0001_1111 1F 31 Home
0000_0010 02 2 2 0010_0000 20 32 PgDn
0000_0011 03 3 3 0010_0001 21 33 PgUp
0000_0100 04 4 4 0010_0010 22 34
0000_0101 05 5 5 0010_0011 23 35
0000_0110 06 6 6 0010_0100 24 36 Up
0000_0111 07 7 7 0010_0101 25 37 下/换行
0000_1000 08 8 8 0010_0110 26 38 不换行空格
0000_1001 09 9 9 0010_0111 27 39 段落
0000_1010 0A 10 小键盘 0010_1000 28 40 回车
0000_1011 0B 11 光标 0010_1001 29 41 新行/回车
0000_1100 0C 12 功能 0010_1010 2A 42 Tab
0000_1101 0D 13 Alt 0010_1011 2B 43 空格
0000_1110 0E 14 控制 0010_1100 2C 44 (
0000_1111 0F 15 命令 0010_1101 2D 45 )
0001_0000 10 16 长度 0010_1110 2E 46 [
0001_0001 11 17 Len128 0010_1111 2F 47 ]
0001_0010 12 18 Bin128 0011_0000 30 48 {
0001_0011 13 19 Eos 0011_0001 31 49 }
0001_0100 14 20 Eof 0011_0010 32 50 <
0001_0101 15 21 Sentinel 0011_0011 33 51 >
0001_0110 16 22 Break/interrupt 0011_0100 34 52 =
0001_0111 17 23 Escape/cancel 0011_0101 35 53 ^
0001_1000 18 24 Pause 0011_0110 36 54 &#124;
0001_1001 19 25 Bell 0011_0111 37 55 &
0001_1010 1A 26 Back tab 0011_1000 38 56 -
0001_1011 1B 27 Backspace 0011_1001 39 57 +
0001_1100 1C 28 Delete
0001_1101 1D 29 Insert
0011_1010 3A 58 * 0101_1101 5D 93 I
0011_1011 3B 59 / 0101_1110 5E 94 j
0011_1100 3C 60 % 0101_1111 5F 95 J
0011_1101 3D 61 ~ 0110_0000 60 96 k
0011_1110 3E 62 ! 0110_0001 61 97 K
0011_1111 3F 63 ? 0110_0010 62 98 l
0100_0000 40 64 , 0110_0011 63 99 L
0100_0001 41 65 . 0110_0100 64 100 m
0100_0010 42 66 : 0110_0101 65 101 M
0100_0011 43 67 ; 0110_0110 66 102 n
0100_0100 44 68 " 0110_0111 67 103 N
0100_0101 45 69 ' 0110_1000 68 104 o
0100_0110 46 70 ` 0110_1001 69 105 O
0100_0111 47 71 \ 0110_1010 6A 106 p
0100_1000 48 72 # 0110_1011 6B 107 P
0100_1001 49 73 ` 二进制 十六进制 十进制 字符
--- --- --- --- --- --- --- ---
0000_0000 00 0 0 0001_1110 1E 30 结束
0000_0001 01 1 1 0001_1111 1F 31 Home
0000_0010 02 2 2 0010_0000 20 32 PgDn
0000_0011 03 3 3 0010_0001 21 33 PgUp
0000_0100 04 4 4 0010_0010 22 34
0000_0101 05 5 5 0010_0011 23 35
0000_0110 06 6 6 0010_0100 24 36 Up
0000_0111 07 7 7 0010_0101 25 37 下/换行
0000_1000 08 8 8 0010_0110 26 38 不换行空格
0000_1001 09 9 9 0010_0111 27 39 段落
0000_1010 0A 10 小键盘 0010_1000 28 40 回车
0000_1011 0B 11 光标 0010_1001 29 41 新行/回车
0000_1100 0C 12 功能 0010_1010 2A 42 Tab
0000_1101 0D 13 Alt 0010_1011 2B 43 空格
0000_1110 0E 14 控制 0010_1100 2C 44 (
0000_1111 0F 15 命令 0010_1101 2D 45 )
0001_0000 10 16 长度 0010_1110 2E 46 [
0001_0001 11 17 Len128 0010_1111 2F 47 ]
0001_0010 12 18 Bin128 0011_0000 30 48 {
0001_0011 13 19 Eos 0011_0001 31 49 }
0001_0100 14 20 Eof 0011_0010 32 50 <
0001_0101 15 21 Sentinel 0011_0011 33 51 >
0001_0110 16 22 Break/interrupt 0011_0100 34 52 =
0001_0111 17 23 Escape/cancel 0011_0101 35 53 ^
0001_1000 18 24 Pause 0011_0110 36 54 &#124;
0001_1001 19 25 Bell 0011_0111 37 55 &
0001_1010 1A 26 Back tab 0011_1000 38 56 -
0001_1011 1B 27 Backspace 0011_1001 39 57 +
0001_1100 1C 28 Delete
0001_1101 1D 29 Insert
0011_1010 3A 58 * 0101_1101 5D 93 I
0011_1011 3B 59 / 0101_1110 5E 94 j
0011_1100 3C 60 % 0101_1111 5F 95 J
0011_1101 3D 61 ~ 0110_0000 60 96 k
0011_1110 3E 62 ! 0110_0001 61 97 K
0011_1111 3F 63 ? 0110_0010 62 98 l
0100_0000 40 64 , 0110_0011 63 99 L
0100_0001 41 65 . 0110_0100 64 100 m
0100_0010 42 66 : 0110_0101 65 101 M
0100_0011 43 67 ; 0110_0110 66 102 n
0100_0100 44 68 " 0110_0111 67 103 N
0100_0101 45 69 ' 0110_1000 68 104 o
0100_0110 46 70 ` 0110_1001 69 105 O
0100_0111 47 71 \ 0110_1010 6A 106 p
0100_1000 48 72 # 0110_1011 6B 107 P
0110_1100 6C 108 q
0100_1010 4A 74 @ 0110_1101 6D 109 Q
0100_1011 4B 75 _ 0110_1110 6E 110 r
0100_1100 4C 76 a 0110_1111 6F 111 R
0100_1101 4D 77 A 0111_0000 70 112 s
0100_1110 4E 78 b 0111_0001 71 113 S
0100_1111 4F 79 B 0111_0010 72 114 t
0101_0000 50 80 c 0111_0011 73 115 T
0101_0001 51 81 C 0111_0100 74 116 u
0101_0010 52 82 d 0111_0101 75 117 U
0101_0011 53 83 D 0111_0110 76 118 v
0101_0100 54 84 e 0111_0111 77 119 V
0101_0101 55 85 E 0111_1000 78 120 w
0101_0110 56 86 f 0111_1001 79 121 W
0101_0111 57 87 F 0111_1010 7A 122 x
0101_1000 58 88 g 0111_1011 7B 123 X
0101_1001 59 89 G 0111_1100 7C 124 y
0101_1010 5A 90 h 0111_1101 7D 125 Y
0101_1011 5B 91 H 0111_1110 7E 126 z
0101_1100 5C 92 i 0111_1111 7F 127 Z

5.5 获取更多信息

Hyde, Randall. “HLA 标准库参考手册。”无日期。 www.plantation-productions.com/Webster/HighLevelAsm/HLADoc/bit.ly/2W5G1or

IBM. “ASCII 和 EBCDIC 字符集。”无日期。 ibm.co/33aPn3t

Unicode, Inc. “Unicode 技术网站。”最后更新于 2020 年 3 月 4 日。 www.unicode.org/

第六章:内存组织与访问

图片

本章描述了计算机系统的基本组件:CPU、内存、I/O 以及连接它们的总线。我们将首先讨论总线组织和内存组织。这两个硬件组件对软件的性能影响可能与 CPU 的速度一样大。了解内存性能特性、数据局部性和缓存操作可以帮助你设计出尽可能快速运行的软件。

6.1 基本系统组件

计算机系统的基本操作设计称为其架构。计算机设计的先驱约翰·冯·诺依曼被认为是今天使用的主要架构的创立者。例如,80x86 系列采用了冯·诺依曼架构(VNA)。典型的 VNA 包含三个主要组件:中央处理单元(CPU)内存输入/输出(I/O),如图 6-1 所示。

图片

图 6-1:典型的冯·诺依曼机器

在 VNA 机器中,如 80x86 系统,所有计算都在 CPU 内部进行。数据和机器指令存储在内存中,直到 CPU 需要它们为止,此时系统会将数据传输到 CPU 中。对于 CPU 而言,大多数 I/O 设备看起来像内存;它们之间的主要区别在于,I/O 设备通常位于外部世界,而内存则位于同一台机器内。

6.1.1 系统总线

系统总线连接 VNA 机器的各个组件。总线是一组电线,电信号通过这些电线在系统组件之间传递。大多数 CPU 有三条主要总线:数据总线、地址总线和控制总线。这些总线在不同处理器之间有所不同,但每条总线在大多数 CPU 中传输相似的信息。例如,Pentium 和 80386 的数据信号总线有不同的实现,但这两种变体都在处理器、I/O 和内存之间传输数据。

6.1.1.1 数据总线

CPU 使用数据总线在计算机系统的各个组件之间传输数据。这个总线的大小在不同 CPU 之间差异很大。实际上,总线大小(或宽度)是定义处理器“大小”的主要属性之一。

大多数现代通用 CPU(如 PC 中的 CPU)采用 32 位宽或更常见的 64 位宽数据总线。有些处理器使用 8 位或 16 位数据总线,到你读到这本书的时候,也许已经有 128 位数据总线的 CPU 了。

你经常会听到 8 位16 位32 位64 位处理器 这样的术语。处理器的大小由较小的值决定:处理器的数据线数量或最大通用整数寄存器的大小。例如,旧的 Intel 80x86 CPU 都有 64 位总线,但只有 32 位的通用整数寄存器,因此它们被归类为 32 位处理器。AMD(以及更新的 Intel)x86-64 处理器支持 64 位整数寄存器和 64 位总线,因此它们是 64 位处理器。

尽管具有 8 位、16 位、32 位和 64 位数据总线的 80x86 系列处理器可以处理与总线位宽相同大小的数据块,但它们也可以访问更小的内存单元(8 位、16 位或 32 位)。因此,任何小数据总线可以完成的任务,大数据总线也能完成;然而,较大的数据总线可能能更快地访问内存,并且能在一次内存操作中访问更大的数据块。你将在本章稍后了解这些内存访问的具体性质。

6.1.1.2 地址总线

80x86 系列处理器的数据总线在特定的内存位置或 I/O 设备与 CPU 之间传输信息。哪个内存位置或 I/O 设备就是地址总线的作用所在。系统设计师为每个内存位置和 I/O 设备分配一个唯一的内存地址。当软件想要访问特定的内存位置或 I/O 设备时,它将相应的地址放到地址总线上。设备内部的电路检查该地址,如果匹配,则传输数据。所有其他内存位置会忽略地址总线上的请求。

通过单个地址总线线,处理器可以访问两个唯一地址:0 和 1。通过 n 根地址线,处理器可以访问 2^(n) 个唯一地址(因为 n 位二进制数中有 2^(n) 个唯一值)。地址总线的位数决定了最大可寻址的内存和 I/O 位置数量。例如,早期的 80x86 处理器仅提供 20 根地址总线线。因此,它们最多只能访问 1,048,576(或 2²⁰)个内存位置。更大的地址总线可以访问更多的内存(请参见 表 6-1)。

表 6-1: 80x86 地址能力

处理器 地址总线大小 最大可寻址内存
8088, 8086, 80186, 80188 20 1,048,576 (1MB)
80286, 80386sx 24 16,777,216 (16MB)
80386dx 32 4,294,976,296 (4GB)
80486, Pentium 32 4,294,976,296 (4GB)
Pentium Pro, II, III, IV 36 68,719,476,736 (64GB)
Core, i3, i5, i7, i9 ≥ 40 ≥1,099,511,627,776 (≥1TB)

更新的处理器将支持更大的地址总线。许多其他处理器(如 ARM 和 IA-64)已经提供了更大的地址总线,实际上,在软件中支持高达 64 位的地址。

就内存而言,64 位地址范围实际上是无限的。没有人会在计算机系统中放入 2⁶⁴字节的内存,并感到还需要更多。当然,过去人们曾做过类似的预测。几年前,没有人会认为计算机需要 1GB 的内存,而如今配备 64GB 内存(或更多)的计算机已经非常常见。然而,2⁶⁴实际上等同于无限,原因很简单——根据当前宇宙的估计大小(大约是 2⁸⁶个不同的基本粒子),物理上是不可能构建出这么多内存的。除非你能将每个基本粒子连接 1 字节的内存,否则即使是整个地球上的所有内存也无法接近 2⁶⁴字节的内存。当然,也许有一天我们真会像道格拉斯·亚当斯在《银河系漫游指南》中预测的那样,使用整个行星作为计算机系统,谁知道呢?

虽然较新的 64 位处理器具有 64 位的内部地址空间,但它们很少在芯片上引出 64 条地址线。这是因为在大型 CPU 中引脚是宝贵资源,没必要引出那些永远不会使用的额外地址引脚。目前,40 到 52 位地址总线是上限。在遥远的未来,可能会有所扩展,但很难想象会有需要或甚至可能实现物理 64 位地址总线的情况。

在现代处理器中,CPU 制造商将内存控制器直接集成到 CPU 中。新型 CPU 不再采用传统的地址和数据总线来连接任意内存设备,而是包含专用的总线,用于与非常特定的动态随机存取内存(DRAM)模块进行通信。典型的 CPU 内存控制器仅连接到一定数量的 DRAM 模块;因此,您可以轻松连接到 CPU 的最大 DRAM 容量,取决于集成在 CPU 中的内存控制,而不是外部地址总线的大小。这也是为什么一些较旧的笔记本电脑即便拥有 64 位 CPU,仍然存在 16MB 或 32MB 最大内存限制的原因。^(1)

6.1.1.3 控制总线

控制总线是一个多样化的信号集合,用于控制处理器如何与系统的其余部分进行通信。为了理解它的重要性,先考虑一下数据总线。CPU 使用数据总线在自己与内存之间传输数据。系统通过控制总线上的两条线,读取写入,来确定数据流动的方向(从 CPU 到内存,或从内存到 CPU)。因此,当 CPU 想要将数据写入内存时,它会激活(在控制线中放置信号)写入控制线。当 CPU 想要从内存读取数据时,它会激活读取控制线。

尽管控制总线的确切组成在不同的处理器之间有所不同,但一些控制线——如系统时钟线、 interrupt 线、状态线和字节使能线——在所有处理器中都是共同的。字节使能线出现在一些支持字节可寻址内存的 CPU 的控制总线上。这些控制线允许 16 位、32 位和 64 位处理器通过传递伴随数据的大小来处理更小的数据块。更多细节请参见“16 位数据总线”章节,位于第 138 页和“32 位数据总线”章节,位于第 140 页。

在 80x86 系列处理器中,控制总线还包含一个信号,用于区分不同的地址空间。与许多其他处理器不同,80x86 系列提供了两个不同的地址空间:一个用于内存,另一个用于 I/O。然而,它只有一个物理地址总线,I/O 和内存共享这个总线,因此需要额外的控制线来决定该地址指向哪个组件。当这些信号处于激活状态时,I/O 设备使用地址总线的低 16 位地址;当它们处于非激活状态时,I/O 设备忽略这些信号,内存子系统接管地址总线。

6.2 内存的物理组织

一个典型的 CPU 最多可以寻址 2^(n)个不同的内存位置,其中 n 是地址总线上的位数(大多数基于 80x86 系列 CPU 构建的计算机系统并不包括最大可寻址的内存量)。但内存位置究竟是什么呢?以 80x86 为例,它支持字节可寻址内存。因此,基本的内存单元是字节。80x86 处理器通过包含 20、24、32、36 或 40 条地址线的地址总线,可以分别寻址 1MB、16MB、4GB、64GB 或 1TB 的内存。一些 CPU 系列不提供字节可寻址内存;相反,它们通常仅以双字或甚至四字块寻址内存。然而,由于大量软件假设内存是字节可寻址的(比如所有 C/C++程序),即使是那些硬件上不支持字节可寻址内存的 CPU,仍然会使用字节地址并在软件中模拟字节寻址。稍后我们会回到这个话题。

将内存视为一个字节数组。第一个字节的地址是 0,最后一个字节的地址是 2^(n) – 1。对于一个具有 20 位地址总线的 CPU,以下伪 Pascal 数组声明可以很好地近似内存的组织:

Memory: array [0..1048575] of byte; // 1MB address space (20 bits)

为了执行等效的 Pascal 语句 Memory [125] := 0;,CPU 将值0放置到数据总线上,将地址125放置到地址总线上,并在控制总线上激活写操作线,如图 6-2 所示。

image

图 6-2:内存写操作

为了执行相当于CPU := Memory [125];的操作,CPU 将地址125放置在地址总线上,激活控制总线上的读线,然后从数据总线上读取相应的数据(参见图 6-3)。

image

图 6-3:内存读操作

这个讨论适用于处理器在访问内存中的单个字节时。那么当它访问一个字或双字时会发生什么呢?因为内存由字节数组组成,我们怎么处理大于 8 位的值呢?

不同的计算机系统对于这个问题有不同的解决方案。80x86 系列将一个字的低字节存储在指定的地址,并将高字节存储在下一个位置。因此,一个字消耗两个连续的内存地址(正如你所期望的,因为一个字由 2 个字节组成)。类似地,一个双字消耗四个连续的内存位置。

一个字或双字的地址是其低字节(LO byte)的地址。其余的字节紧跟在低字节之后,高字节(HO byte)出现在字的地址加 1 或双字的地址加 3 的位置(参见图 6-4)。

字节、字和双字的值在内存中可能会重叠。例如,在图 6-4 中,你可能会有一个从地址 193 开始的字变量,一个从地址 194 开始的字节变量,以及一个从地址 192 开始的双字变量。字节、字和双字可能从内存中的任何有效地址开始。然而,我们很快会看到,从任意地址开始较大的对象并不是一个好主意。

image

图 6-4:内存中的字节、字和双字存储(在 80x86 上)

6.2.1 8 位数据总线

一个具有 8 位总线的处理器(如老旧的 8088 CPU)一次可以传输 8 位数据。因为每个内存地址对应一个 8 位字节,所以 8 位总线被证明是最方便的架构(从硬件角度看),正如图 6-5 所示。

image

图 6-5:8 位 CPU <–> 内存接口

字节寻址内存阵列的术语意味着 CPU 可以以最小为单个字节的块来寻址内存。这也意味着这是你可以一次通过处理器访问的最小内存单元。也就是说,如果处理器想要访问一个 4 位的值,它必须读取 8 位,然后忽略额外的 4 位。

字节寻址并不意味着 CPU 可以从任何任意的位边界开始访问 8 位。当你在内存中指定地址 125 时,你将获取该地址的所有 8 位——不多也不少。地址是整数;例如,你不能指定地址 125.5 来获取少于 8 位的内容,或者获取跨越两个字节地址的字节。

虽然具有 8 位数据总线的 CPU 可以方便地操作字节值,但它们也能够操作字和双字值。然而,这需要多次内存操作,因为这些处理器每次只能移动 8 位数据。加载一个字需要两次内存操作;加载一个双字需要四次内存操作。

6.2.2 16 位数据总线

一些 CPU(例如 8086、80286 以及 ARM 处理器家族的变种)有 16 位数据总线。这使得这些处理器在相同时间内可以访问比其 8 位对手多一倍的内存。这些处理器将内存组织成两个银行:一个是“偶数”银行,另一个是“奇数”银行(见图 6-6)。

image

图 6-6:字节寻址中的字内存

图 6-7 展示了数据总线与 CPU 的连接。在此图中,数据总线线路 D0 至 D7 传输字的 LO 字节,而数据总线线路 D8 至 D15 传输字的 HO 字节。

80x86 家族的 16 位处理器可以从任何任意地址加载字。如前所述,处理器从指定地址获取字的 LO 字节,从下一个连续地址获取字的 HO 字节。然而,这会产生一个微妙的问题。当你访问一个从奇数地址开始的字时,会发生什么呢?假设你要从位置 125 读取一个字。该字的 LO 字节来自位置 125,HO 字节来自位置 126。事实证明,这种方法实际上存在两个问题。

image

图 6-7:16 位处理器内存组织

如你在图 6-7 中看到的,数据总线线路 8 至 15(HO 字节)连接到奇数银行,而数据总线线路 0 至 7(LO 字节)连接到偶数银行。访问内存位置 125 时,会通过数据总线的 D8 至 D15 线路将数据传输到 CPU,数据会放置在 HO 字节中,但我们需要将数据放在 LO 字节!幸运的是,80x86 CPU 会自动识别并处理这种情况。

第二个问题更为隐晦。当访问字时,我们实际上是在访问两个独立的字节,每个字节都有自己的字节地址。那么,地址总线上会出现什么地址呢?16 位的 80x86 CPU 始终将偶数地址放置在总线上。位于偶数地址的字节总是出现在数据线路 D0 至 D7 上,而位于奇数地址的字节总是出现在数据线路 D8 至 D15 上。如果你访问偶数地址处的字,CPU 可以通过一次内存操作将整个 16 位数据块加载进来。同样,如果你访问一个字节,CPU 会激活适当的银行(使用字节使能控制线),并通过适当的数据线路传输该字节。

那么,当 CPU 以奇数地址访问一个字时会发生什么呢?就像之前给出的例子那样?CPU 不能将地址 125 放入地址总线并从内存读取 16 位数据。16 位 80x86 CPU 不会有奇数地址——它们总是偶数。因此,如果你尝试将 125 放入地址总线,实际上显示的是 124。如果你在这个地址读取 16 位数据,你会得到地址 124(低字节)和地址 125(高字节)处的字——这并不是你预期的。访问奇数地址的字需要两次内存操作(就像 8088/80188 上的 8 位总线一样)。首先,CPU 必须读取地址 125 处的字节,然后读取地址 126 处的字节。其次,它需要在内部交换这两个字节的位置,因为它们都进入了 CPU 的错误数据总线半部分。

幸运的是,16 位的 80x86 CPU 将这些细节对你隐藏起来。你的程序可以访问任何地址的字,CPU 会正确地访问并交换(如果需要)内存中的数据。然而,由于需要两次操作,在 16 位处理器上访问奇数地址的字比访问偶数地址的字要慢。通过精心安排内存使用方式,你可以提高程序在这些 CPU 上的运行速度。

6.2.3 32 位数据总线

在 16 位处理器上,访问 32 位数据总是至少需要两次内存操作。要在奇数地址访问 32 位数据,16 位处理器可能需要三次内存操作。

具有 32 位数据总线的 80x86 处理器,如奔腾和 Core 处理器,使用四个内存银行连接到 32 位数据总线(见图 6-8)。

image

图 6-8:32 位处理器内存接口

在 32 位内存接口下,80x86 CPU 可以通过一次内存操作访问任意单字节。而在 16 位内存接口下,放入地址总线的地址总是偶数;而在 32 位内存接口下,地址总是 4 的倍数。通过各种字节使能控制线,CPU 可以选择该地址上 4 个字节中的哪个进行访问。与 16 位处理器一样,CPU 会根据需要自动重新排列字节。

32 位 CPU 也可以通过单次内存操作访问最多一个字的内存地址,尽管在某些地址上的字访问需要进行两次内存操作(见图 6-9)。这是我们在 16 位处理器尝试以奇数地址取字时遇到的相同问题,只是它发生的频率是原来的一半——只有当地址除以 4 余 3 时才会出现。

image

图 6-9:在 32 位处理器上访问一个字,地址模 4 余 3

32 位 CPU 只能在目标值的地址能够被 4 整除的情况下,单次内存操作访问一个双字。如果不能整除,CPU 可能需要两次内存操作。

再次强调,80x86 CPU 会自动处理这一切。然而,正确的数据对齐仍然能带来性能上的好处。通常,字值的低字节应始终放置在偶数地址,而双字值的低字节应始终放置在能够被 4 整除的地址上。

6.2.4 64 位数据总线

像英特尔 i 系列这样的奔腾及之后的处理器,提供了 64 位的数据总线和特殊的缓存内存,这减少了非对齐数据访问的影响。尽管访问不适当地址的数据可能仍会带来一定的性能损失,但现代 x86 CPU 在此问题上的表现要优于早期的 CPU。我们将在“缓存内存”一节中进一步讨论,详见第 151 页。

6.2.5 非 80x86 处理器上的小数据访问

尽管 80x86 处理器并不是唯一允许你在任意字节地址访问字节、字或双字对象的处理器,但过去 30 年中大多数处理器都允许这样做。例如,最初的 Apple Macintosh 系统中使用的 68000 处理器允许你在任何地址访问字节,但如果你尝试在奇数地址访问字,则会引发异常。^(2) 许多处理器要求你在对象大小的倍数地址上访问该对象,否则会抛出异常。

大多数 RISC 处理器,包括现代智能手机和平板电脑中使用的 ARM 处理器,并不允许你访问字节或字对象。大多数 RISC CPU 要求所有的数据访问大小与数据总线(或通用整数寄存器大小,以较小者为准)相同。通常情况下,这是双字(32 位)或四字(64 位)的访问。如果你想在这样的机器上访问字节或字,你必须将其视为打包字段,并使用移位和掩码技术在双字中提取或插入字节和字数据。虽然在进行字符和字符串处理的软件中几乎无法避免字节访问,但如果你期望软件能够在各种现代 RISC CPU 上高效运行,应该避免使用字数据类型(及其访问带来的性能损失),而倾向于使用双字。

6.3 大端与小端存储方式

之前,你读到过 80x86 CPU 系列将一个字或双字值的低字节存储在内存中的某个特定地址,而后续的高字节则存储在更高的地址。现在我们将更深入地探讨不同处理器如何在字节可寻址的内存中存储多字节对象。

几乎每个“位大小”是 2 的幂(8、16、32、64 等)的 CPU 都按前几章所示的方式对位和半字节进行编号。虽然有一些例外,但它们很少见,而且大多数情况下它们代表的是符号的变化,而不是功能上的变化(这意味着你可以安全地忽略差异)。然而,一旦开始处理大于 8 位的对象,事情变得更加复杂。不同的 CPU 对多字节对象中的字节进行不同的组织。

考虑 80x86 CPU 上双字节的字节布局(见 图 6-10)。LO 字节,作为二进制数中最小的组成部分,位于第 0 到第 7 位,并出现在内存中最低的地址位置。似乎最少贡献的位应当位于内存中的最低地址。

image

图 6-10:80x86 处理器中的双字节布局

然而,这并不是唯一的组织方式。某些 CPU 会反转双字节中所有字节的内存地址,使用 图 6-11 所示的组织方式。

image

图 6-11:双字的替代字节布局

原始的苹果 Macintosh(68000 和 PowerPC)以及大多数非 80x86 Unix 机器使用 图 6-11 所示的数据组织方式。即便是在 80x86 系统上,某些协议(如网络传输)也规定了这种数据组织方式。因此,这并不是某种罕见的、深奥的约定;它相当常见,如果你在 PC 上工作,是不容忽视的。

英特尔使用的字节组织方式被戏谑地称为 小端字节组织方式。另一种形式则称为 大端字节组织方式

注意

这些术语来自 Jonathan Swift 的 《格列佛游记》;小人国的人们争论是否应该从小端还是大端打开鸡蛋——这是对当时天主教徒和新教徒在 Swift 写作时对各自教义争论的讽刺。

关于哪种格式更优的争论,早在多种使用不同 字节序 的 CPU 被创造出来之前就已经过时了。今天,这种争论已经没有意义。无论哪种格式更好或更差,我们都必须面对不同 CPU 存在不同字节序的事实,并且在编写软件时要小心,以确保我们的程序能够在这两种类型的处理器上运行。

当我们尝试在两台计算机之间传递二进制数据时,就会遇到大端与小端的问题。例如,在小端机上,256 的双字二进制表示具有以下字节值:

LO byte:     0

Byte #1:     1

Byte #2:     0

HO byte:     0

如果你在小端机器上组装这 4 个字节,它们的布局将呈现以下形式:

Byte:        3    2    1    0

256:         0    0    1    0    (each digit represents an 8-bit value)

然而,在大端机上,布局呈现以下形式:

Byte:        3    2    1    0

256:         0    1    0    0    (each digit represents an 8-bit value)

这意味着,如果你从一台机器获取一个 32 位值并试图在另一台具有不同字节序的机器上使用它,你将无法得到正确的结果。例如,如果你将一个大端版本的 256 值作为小端格式来解释,你会发现它在第 16 位上有一个1,而小端机器会认为该值实际上是 65,536(即%1_0000_0000_0000_0000)。

当你在两台不同的机器之间交换数据时,最佳的解决方案是将数据转换为某种规范形式(canonical form),然后在本地格式和规范格式不一致的情况下,将规范格式转换回本地格式。什么构成“规范”格式通常取决于传输介质。例如,当你通过网络传输数据时,规范格式通常是大端字节序(big-endian),因为 TCP/IP 和其他一些网络协议使用大端格式。当你通过通用串行总线(USB)传输数据时,规范格式是小端字节序(little-endian)。当然,如果你能控制两端的软件,选择规范格式是可以任意的;不过,为了避免今后出现混淆,仍然应该尝试使用适合传输介质的格式。

要在字节序之间转换,你必须进行镜像交换,即交换对象中二进制数字两端的字节,然后逐步向对象的中间移动,交换字节对。例如,要在双字之间转换大端和小端格式,你首先交换字节 0 和字节 3,然后交换字节 1 和字节 2(参见图 6-12)。

image

图 6-12:双字的字节序转换

对于字(word)值,你只需要交换高位字节(HO)和低位字节(LO)来改变字节序。对于四字(quad-word)值,你需要交换字节 0 和字节 7、字节 1 和字节 6、字节 2 和字节 5、字节 3 和字节 4。因为很少有软件处理 128 位整数,你可能不需要担心长字(long-word)字节序转换,但如果需要,概念是相同的。

请注意,字节序转换过程是自反的;也就是说,将大端字节序转换为小端字节序的相同算法,也可以将小端字节序转换为大端字节序。如果你运行该算法两次,数据将恢复为原始格式。

即使你没有编写用于在两台计算机之间交换数据的软件,字节序的问题仍然可能出现。有些程序通过将离散的字节分配到较大值的特定位置来组装较大的对象。如果软件在大端机器上将低位字节放置在第 0 到 7 位(小端格式),程序将无法产生正确的结果。因此,如果软件需要在具有不同字节组织方式的不同 CPU 上运行,它必须确定运行的机器的字节序,并相应地调整如何从字节中组装较大的对象。

为了说明如何从离散字节构建较大的对象,我们将通过一个简单示例开始,展示如何从 4 个独立字节组装一个 32 位对象。最常见的方法是创建一个判别联合体结构,它包含一个 32 位对象和一个 4 字节数组。

注意

许多语言(但并非全部)支持判别联合数据类型。例如,在 Pascal 中,你会使用一种称为“case variant”的记录。详情请参阅你的语言参考手册。

联合体类似于记录或结构,唯一的不同是编译器在内存中的同一地址为联合体的每个字段分配存储空间。考虑以下来自 C 语言的两个声明:

struct

{

    short unsigned i;   // Assume shorts require 16 bits.

    short unsigned u;

    long unsigned r;    // Assume longs require 32 bits.

} RECORDvar;

union

{

    short unsigned i;

    short unsigned u;

    long unsigned r;

} UNIONvar;

如图 6-13 所示,RECORDvar对象在内存中占用 8 个字节,且各字段的内存不与其他字段共享(也就是说,每个字段从记录的基地址开始有不同的偏移量)。另一方面,UNIONvar对象将联合体中的所有字段叠加在相同的内存位置。因此,向联合体中的i字段写入一个值,也会覆盖u字段的值以及r字段的 2 个字节(无论是低字节还是高字节,完全取决于 CPU 的字节序)。

image

图 6-13:联合体与记录(结构)在内存中的布局

在 C 编程语言中,你可以利用这种行为来访问 32 位对象的单个字节。考虑以下 C 语言中的联合体声明:

union

{

    unsigned long bits32; /* This assumes that C uses 32 bits for 

                             unsigned long */

    unsigned char bytes[4];

} theValue;

这会在小端机器上创建图 6-14 所示的数据类型,而在大端机器上则创建图 6-15 所示的结构。

image

图 6-14:在小端机器上的 C 联合体

image

图 6-15:在大端机器上的 C 联合体

在小端机器上,从 4 个离散字节组装一个 32 位对象,你可以使用如下代码:

theValue.bytes[0] = byte0;

theValue.bytes[1] = byte1;

theValue.bytes[2] = byte2;

theValue.bytes[3] = byte3;

这段代码能够正常工作,因为 C 语言将数组的第一个字节分配到内存中最低的地址(对应小端机器上theValue.bits32对象的第 0 到 7 位);数组的第二个字节紧随其后(第 8 到 15 位),然后是第三个字节(第 16 到 23 位),最后是最高字节(占用内存中的最高地址,对应第 24 到 31 位)。

然而,在大端机器上,这段代码无法正常工作,因为theValue.bytes[0]对应的是 32 位值的第 24 到 31 位,而不是第 0 到 7 位。要在大端系统上正确组装这个 32 位值,您需要使用如下代码:

theValue.bytes[0] = byte3;

theValue.bytes[1] = byte2;

theValue.bytes[2] = byte1;

theValue.bytes[3] = byte0;

但是,如何判断你的代码是在小端还是大端机器上运行呢?这其实是一个简单的任务。考虑以下 C 代码:

theValue.bytes[0] = 0;

theValue.bytes[1] = 1;

theValue.bytes[2] = 0;

theValue.bytes[3] = 0;

isLittleEndian = theValue.bits32 == 256;

在大端机器上,这段代码将把值1存储到第 16 位,产生一个 32 位值,该值肯定不等于 256;而在小端机器上,这段代码将把值1存储到第 8 位,产生一个等于 256 的 32 位值。因此,您可以测试isLittleEndian变量,以确定当前机器是小端(true)还是大端(false)。

6.4 系统时钟

尽管现代计算机速度非常快,并且一直在不断加快,但它们仍然需要时间来完成即使是最小的任务。在冯·诺依曼结构的机器中,大多数操作都是串行化的,这意味着计算机按规定的顺序执行命令。(见注释^3)在以下代码序列中,如果在I := J;语句完成之前执行I := I * 5 + 2;,那是行不通的:

I := J;

I := I * 5 + 2;

这些操作并不是瞬间完成的。将J的副本移动到I中需要一定的时间。同样,乘以 5 后再加 2 并将结果存回I也需要时间。

为了按正确的顺序执行语句,处理器依赖于系统时钟,该时钟作为系统内部的时序标准。要理解为什么某些操作需要比其他操作更长的时间,您首先必须理解系统时钟是如何工作的。

系统时钟是控制总线上的电信号,它周期性地在 0 和 1 之间切换(见图 6-16)。CPU 内的所有活动都与该时钟信号的边缘(上升沿或下降沿)同步。

image

图 6-16:系统时钟

系统时钟在 0 和 1 之间切换的速率被称为系统时钟频率,而系统时钟从 0 切换到 1 再回到 0 所需的时间称为时钟周期时钟周期。在大多数现代系统中,系统时钟频率超过数十亿个周期每秒。典型的 Pentium IV 处理器,大约在 2004 年左右,运行速度为每秒三十亿个周期或更快。赫兹(Hz)是每秒一个周期对应的单位,因此前述的 Pentium 芯片运行在 3000 到 4000 百万赫兹之间,或 3000 到 4000 兆赫(MHz),或 3 到 4 吉赫(GHz,或每秒十亿个周期)。80x86 系列的典型频率范围从 5 MHz 到数吉赫赫兹及以上。

时钟周期是时钟频率的倒数。例如,1 MHz(兆赫,或每秒一百万个周期)的时钟,其时钟周期为 1 微秒(百万分之一秒,µs^(4))。一颗运行在 1 GHz 的 CPU,其时钟周期为 1 纳秒(ns),即十亿分之一秒。时钟周期通常以微秒或纳秒为单位表示。

为了确保同步,大多数 CPU 在时钟的下降沿(时钟从 1 变为 0 时)或上升沿(时钟从 0 变为 1 时)启动一个操作。系统时钟大部分时间处于 0 或 1 状态,而在两者之间切换的时间非常短暂。因此,时钟沿是一个完美的同步点。

由于所有 CPU 操作都与时钟同步,因此 CPU 的任务执行速度不能超过时钟的运行速度。然而,仅仅因为 CPU 运行在某个时钟频率上,并不意味着它每秒执行那么多操作。许多操作需要多个时钟周期才能完成,因此 CPU 的操作速度往往比时钟速度慢得多。

6.4.1 内存访问和系统时钟

内存访问是一个与系统时钟同步的操作;也就是说,内存访问每个时钟周期最多只发生一次。在一些旧的处理器中,访问一个内存位置需要多个时钟周期。内存访问时间是从内存请求(读取或写入)到内存操作完成之间的时钟周期数。这是一个重要的值,因为更长的内存访问时间会导致较低的性能。

现代 CPU 的速度远快于内存设备,因此以这些 CPU 为基础的系统通常使用第二个时钟,即总线时钟,其频率是 CPU 速度的一部分。例如,典型的 100 MHz 到 4 GHz 范围的处理器可以使用 1600 MHz、800 MHz、500 MHz、400 MHz、133 MHz、100 MHz 或 66 MHz 的总线时钟(某个特定的 CPU 通常支持多种不同的总线速度,具体支持的范围取决于该 CPU)。

在读取内存时,内存访问时间是 CPU 将地址放到地址总线上的时间与 CPU 从数据总线上取数据的时间之间的间隔。在典型的 80x86 CPU 中,内存访问时间为一个周期,读取操作的时序大致如下图 6-17。将数据写入内存的时序类似(见图 6-18)。

image

图 6-17:典型的内存读取周期

image

图 6-18:典型的内存写入周期

CPU 不会等待内存。访问时间由总线时钟频率指定。如果内存子系统的工作速度不足以跟上 CPU 预期的访问时间,CPU 在内存读取操作时将读取到垃圾数据,在内存写入时也无法正确存储数据。这肯定会导致系统失败。

内存设备有各种不同的评级,但主要有两个参数:容量和速度。典型的动态 RAM(随机存取内存)设备的容量为 16GB(或更大),速度为 0.1 到 100 纳秒。一个典型的 4 GHz 英特尔系统使用 1600 MHz(1.6 GHz,或 0.625 纳秒)内存设备。

现在,我刚才提到内存速度必须与总线速度匹配,否则系统将会失败。在 4 GHz 下,时钟周期大约为 0.25 纳秒。那么,系统设计师如何使用 0.625 纳秒的内存呢?答案是* 等待状态*。

6.4.2 等待状态

等待状态是一个额外的时钟周期,给设备额外的时间来响应 CPU。例如,一个 100 MHz 的 Pentium 系统有 10 纳秒的时钟周期,这意味着你需要 10 纳秒的内存。实际上,你需要更快的内存设备,因为在许多计算机系统中,CPU 和内存之间有额外的解码和缓冲逻辑,这些电路会引入自己的延迟。在图 6-19 中,你可以看到缓冲和解码使系统多出了 10 纳秒的延迟。如果 CPU 需要在 10 纳秒内获得数据,内存必须在 0 纳秒内响应(这显然是不可能的)。

image

图 6-19:解码和缓冲延迟

如果具有成本效益的内存无法与快速处理器配合使用,那么公司如何销售快速 PC 呢?其中一个答案就是等待状态。例如,如果你有一个 100 MHz 的处理器,内存周期时间为 10 纳秒,而你因为缓冲和解码损失了 2 纳秒,你将需要 8 纳秒的内存。然而,如果你的系统只能支持 20 纳秒的内存怎么办?通过添加等待状态来延长内存周期到 20 纳秒,你就能解决这个问题。

几乎所有通用 CPU 都提供一个引脚(其信号出现在控制总线上),允许你插入等待状态。如果需要,内存地址解码电路会触发该信号,以便为内存提供足够的访问时间(见图 6-20)。

image

图 6-20:在内存读取操作中插入等待状态

从系统性能的角度来看,等待状态并不是好事。只要 CPU 在等待来自内存的数据,它就无法对数据进行操作。添加一个等待状态通常会加倍(或者在某些系统上更糟糕)访问内存所需的时间。在每次内存访问时都加上一个等待状态几乎就像是将处理器的时钟频率减半。在相同的时间内,你完成的工作会更少。

然而,由于增加了等待状态,我们并不注定会遭遇慢速执行。硬件设计师可以采取几种技巧,在大多数时间里实现零等待状态。最常见的做法是使用缓存(发音为“cash”)内存。

6.4.3 缓存内存

一个典型的程序倾向于反复访问相同的内存位置(称为时间局部性),并访问相邻的内存位置(空间局部性)。这两种局部性在以下 Pascal 代码段中都出现了。

for i := 0 to 10 do

         A [i] := 0;

在这个循环中,有两个空间局部性和时间局部性的出现。我们先来看一下显而易见的情况。

在这段 Pascal 代码中,程序多次引用变量ifor循环将i10进行比较,以检查循环是否完成。它还会在循环的底部将i加 1。赋值语句也使用i作为数组索引。这显示了时间局部性的实际应用。

循环本身通过将0写入A的第一个位置,再写入A的第二个位置,以此类推,从而将数组A的元素清零。由于 Pascal 将数组A的元素存储在连续的内存位置中,每次循环迭代都访问相邻的内存位置。这显示了空间局部性。

那么,时间和空间局部性的第二次出现呢?机器指令也存储在内存中,CPU 从内存中顺序获取这些指令,并在每次循环迭代时重复执行它们。

如果你查看一个典型程序的执行概况,你可能会发现程序执行的语句不到一半。通常,程序可能只使用分配给它的内存的 10%到 20%。在任何给定时刻,一个 1MB 的程序可能只访问 4KB 到 8KB 的数据和代码。因此,如果你花费一笔天价买了昂贵的零等待状态 RAM,在任何时刻你只会使用其中的极小一部分。如果你能购买少量快速 RAM,并在程序执行时动态重新分配其地址,岂不是很好?这正是缓存内存为你做的事情。

缓存内存是一种位于 CPU 和主内存之间的少量非常快速的内存。与普通内存不同,缓存中的字节没有固定的地址。缓存内存可以动态地重新分配地址,这使得系统能够将最近访问过的值保留在缓存中。CPU 从未访问过的地址,或长时间未访问的地址,仍然保留在主(慢)内存中。由于大多数内存访问是访问最近使用的变量(或接近最近访问位置的地址),因此数据通常出现在缓存内存中。

当 CPU 访问内存并在缓存中找到数据时,就会发生缓存命中。在这种情况下,CPU 通常可以零等待状态地访问数据。如果数据无法在缓存中找到,则会发生缓存未命中。在这种情况下,CPU 必须从主内存中读取数据,从而带来性能损失。为了利用时间局部性,CPU 每次访问缓存中没有的地址时,会将数据复制到缓存中。因为系统可能会很快访问该地址,所以通过将数据存储在缓存中,它可以在未来的访问中节省等待状态。

缓存内存并不能消除等待状态的需要。尽管程序可能会在内存的某个区域花费大量时间执行代码,但最终它会调用一个过程或跳转到缓存内存以外的某个代码段。当这种情况发生时,CPU 必须去主内存获取数据。由于主内存较慢,这将需要插入等待状态。然而,一旦 CPU 访问了数据,它将会被存储在缓存中,以便将来使用。

我们已经讨论了缓存内存如何处理内存访问的时间方面,但尚未涉及空间方面。当你访问它们时,缓存内存位置并不会加速程序,如果你不断访问一些你以前从未访问过的连续位置。为了解决这个问题,当发生缓存未命中时,大多数缓存系统会读取主内存中的几个连续字节(工程师称之为缓存行)。例如,80x86 系列 CPU 在缓存未命中时会读取 16 到 64 个字节。今天大多数内存芯片都有特殊模式,可以让你快速访问芯片上几个连续的内存位置。缓存利用这个功能来减少访问顺序内存位置时所需的平均等待状态数量。尽管每次缓存未命中时读取 16 个字节是昂贵的,如果你只访问对应缓存行中的少数几个字节,但缓存内存系统在平均情况下表现得相当好。

缓存命中率与缓存内存子系统的大小(以字节为单位)成正比。例如,80486 CPU 有 8,192 字节的片上缓存。英特尔声称使用这个缓存时,命中率可以达到 80%到 95%(意味着 80%到 95%的时间 CPU 会在缓存中找到数据)。这听起来非常令人印象深刻,但让我们稍微调整一下数据。假设我们选择 80%的命中率。意味着平均每五次内存访问中,就有一次不会在缓存中找到。如果你有一个 50 MHz 的处理器(20 ns 周期)和 90 ns 的内存访问时间,四分之三的内存访问只需要 20 ns(一个时钟周期),因为它们在缓存中,剩下的那一次访问将需要大约四个等待状态(正常内存访问需要 20 ns,加上额外的 80 ns 或四个等待状态,才能确保至少达到 90 ns)。然而,缓存总是从内存中读取 16 个连续的字节(4 个双字)。大多数 80486 时代的内存子系统,在访问第一个位置后大约 40 ns 内就可以读取连续地址。因此,80486 将需要额外的六个时钟周期来读取剩余的 3 个双字,总共需要 220 ns。这相当于 11 个时钟周期(每个时钟周期 20 ns),即一个正常的内存周期加上 10 个等待状态。

总的来说,系统需要 15 个时钟周期来访问五个内存位置,或者平均每次访问需要 3 个时钟周期。这相当于每次内存访问都增加了两个等待状态。听起来并不那么令人印象深刻,对吧?当你升级到更快的处理器,并且 CPU 和内存之间的速度差异增大时,情况会变得更糟。

为了提高命中率,你可以添加更多的缓存内存。遗憾的是,你不能拆开英特尔 i9 芯片,然后在芯片上焊接更多的缓存。然而,现代英特尔 CPU 的缓存比 80486 大得多,并且操作时的平均等待状态更少。这提高了缓存命中率。例如,将命中率从 80%提高到 90%可以让你在 20 个周期内访问 10 个内存位置。这将每次内存访问的平均等待状态数减少到一个等待状态——这是一个显著的改进。

改善性能的另一种方法是构建一个二级(L2)缓存系统。许多英特尔 CPU 采用这种方式。第一层是片上 8,192 字节的缓存。接下来的层级位于片上缓存和主内存之间,是一个二级缓存(见图 6-21)。在更新的处理器中,一级和二级缓存通常与 CPU 在同一个封装内。这使得 CPU 设计人员能够构建更高性能的 CPU/内存接口,从而使 CPU 能够更快速地在缓存和 CPU(以及主内存)之间传输数据。

image

图 6-21:二级缓存系统

一般的 CPU 二级缓存包含从 32,768 字节到超过 2MB 的内存。

二级缓存通常无法在零等待周期下运行。支持如此高速内存的电路将是 非常 昂贵的,因此大多数系统设计师使用较慢的内存,这需要一个或两个等待周期。尽管如此,这仍然比主内存要快得多。结合现有的片上 L1 缓存,使用 L2 缓存系统可以提升系统性能。

今天,许多 CPU 集成了 三级 (L3) 缓存。尽管 L3 缓存带来的性能提升远不及 L1 或 L2 缓存子系统,但 L3 缓存子系统通常可以非常大(通常为数兆字节^(5)),并且在拥有数 GB 主内存的大型系统中表现良好。对于那些处理大量数据但具有局部性特征的程序,L3 缓存子系统非常有效。

6.5 CPU 内存访问

大多数 CPU 有两种或三种不同的内存访问方式。现代 CPU 支持的最常见 内存寻址模式 包括 直接间接索引。一些 CPU(如 80x86)支持额外的寻址模式,如 缩放索引,而某些 RISC CPU 仅支持间接内存访问。拥有额外的内存寻址模式使得内存访问更加灵活。有时,某种特定的寻址模式允许你使用一条指令访问复杂数据结构中的数据,而否则可能需要两条或更多的指令。

RISC 处理器通常需要三到五条指令才能完成一条 80x86 指令的工作。然而,这并不意味着 80x86 程序将运行得快三到五倍。不要忘记,内存访问非常慢,通常需要等待周期。而 80x86 经常访问内存,而 RISC 处理器则很少这样做。因此,RISC 处理器可能能执行前四条指令,而这些指令根本不访问内存,而 80x86 指令则在等待内存访问时等待某些周期。在第五条指令时,RISC CPU 可能会访问内存,并可能需要自己的等待周期。如果两个处理器每个时钟周期执行一条指令,并且都需要为主内存访问插入 30 个等待周期,我们就谈到 31 个时钟周期(80x86)对 35 个时钟周期(RISC),差距大约为 12%。

选择适当的寻址模式通常可以使应用程序以更少的指令和内存访问计算相同的结果,从而提高性能。因此,如果你想编写快速且紧凑的代码,理解应用程序如何使用 CPU 提供的不同寻址模式非常重要。

6.5.1 直接内存寻址模式

直接寻址模式将变量的内存地址编码为访问变量的实际机器指令的一部分。在 80x86 上,直接地址是附加到指令编码的 32 位值。通常,程序使用直接寻址模式来访问全局静态变量。以下是 HLA 汇编语言的一个示例:

static

    i:dword;

         . . .

    mov( eax, i ); // Store EAX's value into the i variable.

当您访问那些程序执行前已知内存地址的变量时,直接寻址模式是理想的选择。通过一条指令,您可以引用与变量关联的内存位置。在那些不支持直接寻址模式的 CPU 上,您可能需要额外的指令(或更多指令)来在访问该变量之前将寄存器加载到变量的内存地址。

6.5.2 间接寻址模式

间接寻址模式通常使用一个寄存器来保存内存地址(有一些 CPU 使用内存位置来保存间接地址,但这种形式的间接寻址在现代 CPU 中很少见)。

间接寻址模式相比于直接寻址模式有几个优点。首先,您可以在运行时修改间接地址的值(该值保存在寄存器中)。其次,编码指定间接地址的寄存器所需的位数远少于编码 32 位(或 64 位)直接地址,因此指令更小。缺点是在访问该地址之前可能需要一条或多条指令将寄存器加载到地址中。

以下 HLA 序列使用了 80x86 间接寻址模式(在寄存器名称周围的括号表示使用间接寻址):

static

    byteArray: byte[16];

         . . .

    lea( ebx, byteArray );  // Loads EBX register with the address 

                            // of byteArray.

    mov( [ebx], al );       // Loads byteArray[0] into AL.

    inc( ebx );             // Point EBX at the next byte in memory

                            // (byteArray[1]).

    mov( [ebx], ah );       // Loads byteArray[1] into AH.

间接寻址模式对许多操作很有用,例如访问由指针变量引用的对象。

6.5.3 索引寻址模式

索引寻址模式结合了直接和间接寻址模式。具体来说,使用这种寻址模式的机器指令在编码中同时包含偏移量(直接地址)和寄存器。在运行时,CPU 计算这两个地址组件的和以创建一个有效地址。这种寻址模式非常适合访问数组元素和间接访问结构和记录等对象。尽管指令编码通常比间接寻址模式更大,但索引寻址模式的优势在于您可以在指令中直接指定地址,而无需使用单独的指令将地址加载到寄存器中。

这是一个典型的使用 80x86 索引寻址模式的 HLA 序列的示例:

static

    byteArray: byte[16];

        . . .

    mov( 0, ebx );                    // Initialize an index into the array.

    while( ebx < 16 ) do

        mov( 0, byteArray[ebx] );     // Zeros out byteArray[ebx].

        inc( ebx );                   // EBX := EBX +1, move on to the

                                      // next array element.

    endwhile;

在这个简短程序中,byteArray[ebx]指令演示了索引寻址模式。有效地址是byteArray变量的地址加上 EBX 寄存器的当前值。

为了避免在每条使用索引寻址模式的指令中浪费空间编码 32 位或 64 位地址,许多 CPU 提供了一种更短的形式,将 8 位或 16 位偏移量编码为指令的一部分。在使用这种更小的形式时,寄存器提供内存中对象的基址,而偏移量则提供数据结构在内存中的固定位移。例如,这对于通过指向结构的指针访问内存中记录或结构的字段非常有用。前面的 HLA 示例使用 4 字节地址编码了byteArray的地址。与此相比,下面是使用索引寻址模式的示例:

lea( ebx, byteArray ); // Loads the address of byteArray into EBX.

    . . .

mov( al, [ebx+2] );    // Stores al into byteArray[2]

这条指令使用一个字节(而不是 4 个字节)编码位移值;因此,这条指令更短且更高效。

6.5.4 缩放索引寻址模式

可在多个 CPU 上使用的缩放索引寻址模式提供了比索引寻址模式更多的两个功能:

  • 使用两个寄存器(加上偏移量)来计算有效地址的能力

  • 在计算有效地址之前,能够将这两个寄存器中的一个寄存器的值乘以一个常数(通常是 1、2、4 或 8)。

这种寻址模式对于访问数组元素尤其有用,前提是数组元素的大小与某个缩放常数匹配(有关原因,请参见第七章中关于数组的讨论)。

80x86 提供了一种缩放索引寻址模式,它有几种不同的形式,如下所示的 HLA 语句所示:

mov( [ebx+ecx*1], al );             // EBX is base address, ecx is index.

mov( wordArray[ecx*2], ax );        // wordArray is base address, ecx is index.

mov( dwordArray[ebx+ecx*4], eax );  // Effective address is combination 

                                    // of offset(dwordArray)+ebx+(ecx*4).

6.6 获取更多信息

Hennessy, John L., 和 David A. Patterson. 计算机架构:定量方法. 第 5 版. Waltham, MA: Elsevier, 2012.

Hyde, Randall. 汇编语言的艺术. 第 2 版. San Francisco: No Starch Press, 2010.

Patterson, David A., 和 John L. Hennessy. 计算机组织与设计:硬件/软件接口. 第 5 版. Waltham, MA: Elsevier, 2014.

注意

第十一章在本书中提供了关于缓存内存和内存架构的额外信息。

第七章:复合数据类型和内存对象**

Image

复合数据类型由其他更基础的类型组成。例如,指针、数组、记录或结构、元组和联合体等。许多高级语言(HLLs)为这些复合数据类型提供了语法抽象,使它们易于声明和使用,同时隐藏了其底层的复杂性。

尽管使用这些复合数据类型的成本并不算高,但如果程序员不理解它们,容易在应用程序中引入低效问题。本章将概述这些成本,帮助你更好地编写优秀的代码。

7.1 指针类型

一个 指针 是一个变量,其值指向另一个对象。像 Pascal 和 C/C++ 这样的高级语言通过抽象层隐藏了指针的简单性。如果你不了解背后的运作原理,这种增加的复杂性可能会让人感到害怕。然而,一些基础知识能帮助你轻松理解这一点。

让我们从简单的开始:一个数组。考虑以下 Pascal 中的数组声明:

M: array [0..1023] of integer;

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; 执行的是相同的操作。你可以使用任何整数表达式,范围从 01023,作为该数组的索引。以下语句 依然 执行与之前相同的操作:

i := 5;      (* assume all variables are integers*)

j := 10;

k := 50;

m [i * j - k] := 100;

但是,下面的情况如何呢?

M [1] := 0;

M [ M [1] ] := 100;

哇!这需要一些时间来消化。然而,如果你慢慢思考,你会意识到这两条指令与之前执行的是相同的操作。第一条语句将 0 存储到数组元素 M[1] 中。第二条语句获取 M[1] 的值(即 0),并使用该值来确定存储值 100 的位置。

如果你愿意接受这个例子作为合理的——也许有点奇怪,但仍然可以使用——那么你将不会对指针感到困惑,因为 M[1] 就是一个指针!好吧,严格来说不是,但如果你把 M 改成 “memory”,并把该数组的每个元素当作一个独立的内存位置,那么这就符合指针的定义——也就是说,一个内存变量,其值是指向另一个内存对象的地址。

7.1.1 指针实现

尽管大多数语言使用内存地址实现指针,但指针实际上是内存地址的一种抽象。因此,一种语言可以使用任何机制来定义指针,映射指针的值到内存中某个对象的地址。例如,某些 Pascal 的实现使用从某个固定内存地址的偏移量作为指针值。某些语言(包括像 LISP 这样的动态语言)通过使用双级间接访问来实现指针;也就是说,指针对象包含某个内存变量的地址,而该内存变量的值是要访问的对象的地址。这种方法看起来可能很复杂,但在复杂的内存管理系统中,它提供了某些优势。然而,为了简化起见,本章将假设,如前所定义,指针是一个变量,其值是内存中某个对象的地址。

正如你在前几章的示例中所见,你可以通过使用一个指针和两条 32 位 80x86 机器指令(或者在其他 CPU 上使用类似的指令序列)间接访问一个对象,如下所示:

mov( PointerVariable, ebx );   // Load the pointer variable into a register.

mov( [ebx], eax );             // Use register indirect mode to access data.

通过双级间接访问数据的效率低于直接指针实现,因为它需要额外的机器指令从内存中获取数据。在像 C/C++或 Pascal 这样的高级语言中,这一点并不明显,你会像下面这样使用双级间接访问:

i = **cDblPtr;            // C/C++

i := ^^pDblPtr;           (* Pascal *)

这看起来与单级间接访问非常相似。然而,在汇编语言中,你会看到额外的工作:

mov( hDblPtr, ebx );  // Get the pointer to a pointer.

mov( [ebx], ebx );    // Get the pointer to the value.

mov( [ebx], eax );    // Get the value.

将其与之前两条汇编指令进行对比,后者用于通过单级间接访问对象。因为双级间接访问比单级间接访问需要多 50%的代码,许多语言实现指针时采用单级间接访问。

7.1.2 指针与动态内存分配

指针通常引用你在上分配的匿名变量(堆是为动态存储分配保留的内存区域),通过像 C 中的malloc()/free(),Pascal 中的new()/dispose(),以及 C++中的new()/delete()这样的内存分配/释放函数(然而,C++11 及以后的版本更倾向于使用std::unique_ptrstd_shared_ptr进行内存分配,且自动进行内存释放)。Java、Swift、C++11(及以后的版本)和其他更现代的语言只提供与new()等效的函数。这些语言通过垃圾回收自动处理内存释放。

你在堆上分配的对象被称为匿名变量,因为你是通过它们的地址而不是名称来引用它们的。而且,由于分配函数返回堆上对象的地址,你通常将函数的返回结果存储到一个指针变量中。虽然指针变量可能有一个名称,但这个名称适用于指针的数据(一个地址),而不是该地址所引用的对象。

7.1.3 指针操作与指针运算

大多数提供指针数据类型的语言允许你为指针变量分配地址,比较指针值的相等性或不相等性,并通过指针间接引用一个对象。有些语言还允许进行额外的操作,正如你将在本节中看到的那样。

许多语言允许你对指针进行有限的算术运算。至少,这些语言提供了将整数常量加到指针上,或者从指针中减去一个整数的能力。为了理解这两种算术运算的目的,请注意 C 标准库中malloc()函数的语法:

ptrVar = malloc( bytes_to_allocate );

你传递给malloc()的参数指定了要分配的存储字节数。一个好的 C 程序员通常会像sizeof(int)这样的表达式作为此参数传递。sizeof()函数返回其单个参数所需的字节数。因此,sizeof(int)告诉malloc()至少分配足够的存储空间来存放一个int类型的变量。现在考虑以下对malloc()的调用:

ptrVar = malloc( sizeof( int ) * 8 );

如果一个整数的大小是 4 个字节,那么对malloc()的这个调用将为 32 个字节分配存储空间,地址连续分布在内存中(参见图 7-1)。

image

图 7-1:使用malloc(sizeof(int) * 8)进行内存分配

malloc()返回的指针包含这个集合中第一个整数的地址,因此 C 程序只能直接访问这八个整数中的第一个。要访问其他七个整数的单独地址,你需要在该地址上加上一个整数偏移量。在支持字节可寻址内存的机器(例如 80x86)上,内存中每个连续整数的地址是前一个整数地址加上该整数的大小。例如,如果对 C 标准库malloc()例程的调用返回内存地址$0300_1000,那么malloc()分配的八个整数将位于表 7-1 中显示的内存地址。

表 7-1: 分配给基地址$0300_1000的整数地址

整数 内存地址
0 $0300_1000..$0300_1003
1 $0300_1004..$0300_1007
2 $0300_1008..$0300_100b
3 $0300_100c..$0300_100f
4 $0300_1010..$0300_1013
5 $0300_1014..$0300_1017
6 $0300_1018..$0300_101b
7 $0300_101c..$0300_101f
7.1.3.1 将整数加到指针上

因为前面章节描述的这些整数之间的间隔正好是 4 个字节,我们将 4 加到第一个整数的地址,以获得第二个整数的地址;将 4 加到第二个整数的地址,以得到第三个整数的地址;以此类推。在汇编语言中,我们可以通过以下代码访问这八个整数:

malloc( @size( int32 ) * 8 );  // Returns storage for eight int32 objects.

                               // EAX points at this storage.

mov( 0, ecx );

mov( ecx, [eax] );             // Zero out the 32 bytes (4 bytes

mov( ecx, [eax+4] );           // at a time).

mov( ecx, [eax+8] );

mov( ecx, [eax+12] );

mov( ecx, [eax+16] );

mov( ecx, [eax+20] );

mov( ecx, [eax+24] );

mov( ecx, [eax+28] );

注意使用 80x86 索引寻址模式来访问malloc()分配的八个整数。EAX 寄存器保持着这段代码分配的八个整数的基址(第一个地址),而mov()指令中的常量在寻址模式中选择了该基址偏移量所对应的具体整数。

大多数 CPU 使用字节地址来表示内存对象。因此,当程序在内存中分配多个* n 字节的对象时,这些对象不会从连续的内存地址开始;相反,它们会出现在相距n*字节的内存地址上。然而,一些机器不允许程序在内存中的任意地址访问数据;相反,它们要求在字、双字甚至四字对齐的地址边界上访问数据。任何尝试在其他边界上访问内存的行为都会引发异常,并可能导致应用程序停止。如果高级语言支持指针运算,它必须考虑这一事实,并提供一个通用的指针运算方案,能够在不同的 CPU 架构上移植。高级语言在给指针添加整数偏移时,最常见的解决方案是将偏移量乘以指针所引用对象的大小。也就是说,如果你有一个指向 16 字节对象的指针p,那么p + 1指向比p指向的地址向后 16 字节的地方。同样,p + 2指向该地址向后 32 字节的地方。只要数据对象的大小是所需对齐大小的倍数(编译器可以通过必要时添加填充字节来强制此约束),这种方案就能避免在需要对齐数据访问的架构上出现问题。

注意,只有在指针和整数值之间,使用加法运算符才有意义。例如,在 C/C++中,你可以使用表达式*(p + i)(其中p是指向对象的指针,i是整数值)间接访问内存中的对象。将两个指针相加,或者将其他数据类型加到指针上,是没有意义的。例如,将浮点数值加到指针上就不合逻辑。(将数据引用设为某个基地址加上 1.5612 又意味着什么呢?)整数——有符号和无符号——是唯一可以加到指针上的合理值。

另一方面,你不仅可以将整数加到指针上,还可以将指针加到整数上,结果仍然是一个指针(p + ii + p都是合法的)。这是因为加法是* 交换律*——操作数的顺序不影响结果。

7.1.3.2 从指针中减去整数

从指针中减去一个整数表示指针所指向地址之前的一个内存位置。然而,减法不是交换的,且将指针减去一个整数不是合法操作(p - i是合法的,但i - p则不合法)。

在 C/C++ 中,*(p - i) 访问 p 所指向的对象之前的第 i 个对象。在 80x86 汇编语言中,像许多处理器上的汇编语言一样,你也可以在使用索引寻址模式时指定负常数偏移。例如:

mov( [ebx-4], eax );

请记住,80x86 汇编语言使用的是字节偏移,而不是对象偏移(如 C/C++ 所做的那样)。因此,这条语句将把紧接在 EBX 中内存地址之前的双字加载到 EAX 中。

7.1.3.3 从指针中减去指针

与加法不同,从一个指针变量减去另一个指针变量是有意义的。考虑以下 C/C++ 代码,它通过字符字符串查找第一个紧跟在第一个 a 后面的 e 字符:

int distance;

char *aPtr;

char *ePtr;

    . . .

aPtr = someString;    // Get ptr to start of string in aPtr.

// While we're not at the end of the string and the current

// char isn't 'a':

while( *aPtr != '\0' && *aPtr != 'a' )

{

    aPtr = aPtr + 1;  // Move on to the next character pointed

                      // at by aPtr.

}

// While we're not at the end of the string and the current 

// character isn't 'e':

ePtr = aPtr;          // Start at the 'a' char (or end of string 

                      // if no 'a').

while( *ePtr != '\0' && *ePtr != 'a' )

{

    ePtr = ePtr + 1;  // Move on to the next character pointed at by aPtr.

}

// Now compute the number of characters between the 'a' and the 'e' 

// (counting the 'a' but not counting the 'e'):

distance = (ePtr - aPtr);

从一个指针减去另一个指针会得到它们之间存在的数据对象数量(在此例中,ePtraPtr 指向字符,所以减法结果会得到两个指针之间的字符数或字节数)。

只有当两个指针都指向同一数据结构(例如,在内存中指向同一字符串中的字符,如此 C/C++ 示例所示)时,两个指针相减才有意义。虽然 C/C++(以及汇编语言)允许你减去指向内存中完全不同对象的两个指针,但结果可能几乎没有什么意义。

在 C/C++ 中进行指针相减时,两个指针的基本类型必须相同(即,两个指针必须包含两个对象的地址,这些对象的类型是相同的)。这一限制存在的原因是,C/C++ 中的指针相减会产生两个指针之间的对象数量,而不是字节数量。如果你计算内存中的字节和双字之间的对象数量,那就没有意义了;你是要计算字节数还是双字数呢?在汇编语言中你可以这样做(结果始终是两个指针之间的字节数),但从语义上讲,这样做也没有太大意义。

两个指针相减可能会返回负数,如果左侧的指针操作数的内存地址低于右侧的指针操作数。根据你的编程语言及其实现方式,如果你只关心两个指针之间的距离而不在乎哪个指针包含更大的地址,你可能需要取结果的绝对值。

7.1.3.4 比较指针

几乎所有支持指针的语言都会允许你比较两个指针,看看它们是否相等。比较两个指针将告诉你它们是否引用了内存中的同一个对象。一些语言(如汇编和 C/C++)还允许你比较两个指针,看看一个指针是否小于或大于另一个指针。然而,只有当两个指针具有相同的基类型并包含同一数据结构(如数组、字符串或记录)中某个对象的地址时,这种比较才有意义。如果你发现一个指针小于另一个指针,这意味着它引用的数据结构中的某个对象出现在第二个指针所引用的对象之前。大于比较的反向情况也适用。

7.2 数组

在字符串之后,数组可能是最常见的复合(或 聚合)数据类型。从抽象角度来看,数组是一个聚合数据类型,其成员(元素)都是相同类型的。你可以通过指定数组索引(一个整数或某些底层表示为整数的值,如字符、枚举和布尔类型)来选择数组中的成员。在本章中,我们假设数组的整数索引是连续的(尽管这并不是必须的)。也就是说,如果xy都是数组的有效索引,并且x < y,那么所有满足x < i < yi也是有效的索引。我们还假设数组元素在内存中占据连续的存储位置。因此,包含五个元素的数组在内存中的表现如图 7-2 所示。

image

图 7-2:数组在内存中的布局

数组的基地址是其第一个元素的地址,并占据最低的内存位置。第二个数组元素直接跟在第一个元素之后,第三个元素紧随其后,以此类推。索引不要求从0开始;它们可以从任何数字开始,只要是连续的。然而,除非有充分的理由,否则我们将数组从索引0开始。

每当你对数组应用索引运算符时,结果就是该索引指定的数组元素。例如,A[i]选择数组A中的第i个元素。

7.2.1 数组声明

数组声明在许多高级语言中都非常相似。C、C++和 Java 都允许你通过指定数组中元素的总数来声明数组。这些语言中的数组声明语法如下:

data_type  array_name [ number_of_elements ];

下面是一些典型的 C/C++数组声明:

char CharArray[ 128 ];

int intArray[ 8 ];

unsigned char ByteArray[ 10 ];

int *PtrArray[ 4 ];

如果你将这些数组声明为自动变量,那么 C/C++会用内存中存在的任何位模式来“初始化”它们。另一方面,如果你将这些数组声明为静态对象,那么 C/C++会将每个数组元素初始化为零。如果你想自己初始化数组,可以使用以下 C/C++语法:

data_type array_name[ number_of_elements ] = {element_list};

这是一个典型的例子:

int intArray[8] = {0,1,2,3,4,5,6,7};

Swift 数组声明与其他 C 类语言略有不同。Swift 数组声明有以下两种(等效的)形式:

var array_name = Array<element_type>()

var array_name = [element_type]()

与其他语言不同,Swift 中的数组是纯动态的。你通常在第一次创建数组时不会指定元素的数量;相反,你可以使用append()insert()等函数按需将元素添加到数组中。如果你想预声明一个具有一定数量元素的数组,你可以使用这种特殊的数组构造形式:

var array_name = Array<element_type>( repeating: initial_value, count: elements)

在这个示例中,initial_value 是元素类型的值,elements 是要在数组中创建的元素数量。例如,以下 Swift 代码创建了两个包含 100 个 Int 值的数组,每个数组元素初始化为 0

var intArray = Array<Int>( repeating: 0, count: 100)

var intArray2 = Int

你仍然可以扩展这个数组的大小(例如,通过使用append()函数);因为 Swift 数组是动态的,它们的大小可以在运行时增长或缩小。

Swift 数组可以通过初始值来创建,以下示例展示了这一点:

var intArray = [1, 2, 3]

var strArray = ["str1", "str2", "str3"]

C# 数组也是动态对象;尽管它们的语法与 Swift 略有不同,但概念是相同的:

type[ ] array_name = new type[elements];

在这里,type 是数据类型(例如,doubleint),array_name 是数组变量名,elements 是要在数组中分配的元素数量。

你也可以通过以下方式在声明时初始化 C# 数组(其他语法也是可能的;这只是一个简单的示例):

int[ ] intArray = {1, 2, 3};

string[ ] strArray = {"str1", "str2", "str3"};

HLA(高级汇编语言)中的数组声明语法如下,它在语义上等同于 C/C++ 的声明:

array_name : data_type [ number_of_elements ];

以下是一些 HLA 数组声明的示例,它们为未初始化的数组分配存储空间(第二个示例假设你已在 HLA 程序的 type 部分定义了 integer 数据类型):

static

    CharArray: char[128];         // Character array with elements

                                  //  0..127.

    IntArray: integer[8];         // Integer array with elements 0..7.

    ByteArray: byte[10];          // Byte array with elements 0..9.

    PtrArray: dword[4];           // Double-word array with elements 0..3.

你也可以使用以下声明方式来初始化数组元素:

RealArray: real32[8] := [ 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0 ];

IntegerAry: integer[8] := [ 8, 9, 10, 11, 12, 13, 14, 15 ];

这两个定义都会创建包含八个元素的数组。第一个定义将每个 4 字节的 real32 数组元素初始化为 0.07.0 范围内的某个值。第二个声明将每个 integer 数组元素初始化为 815 范围内的某个值。

Pascal/Delphi 使用以下语法来声明数组:

array_name : array[ lower_bound..upper_bound ] of data_type;

与前面的示例一样,array_name 是标识符,data_type 是该数组中每个元素的类型。与 C/C++、Java、Swift 和 HLA 不同,在 Free Pascal/Delphi 中,你指定的是数组的上下界,而不是数组的大小。以下是 Pascal 中的典型数组声明:

type

    ptrToChar = ^char;

var

    CharArray: array[0..127] of char;          // 128 elements

    IntArray: array[ 0..7 ] of integer;        // 8 elements

    ByteArray: array[0..9] of char;            // 10 elements

    PtrArray: array[0..3] of ptrToChar;        // 4 elements

尽管这些 Pascal 示例的索引从0开始,但 Pascal 并不要求如此。以下 Pascal 数组声明也是完全有效的:

var

     ProfitsByYear : array[ 1998..2039 ] of real;  // 42 elements

声明该数组的程序在访问该数组的元素时会使用19982039的索引,而不是041

许多 Pascal 编译器提供一个非常有用的功能,可以帮助你定位程序中的缺陷。每当你访问数组的一个元素时,这些编译器会自动插入代码,以验证数组索引是否在声明中指定的范围内。如果索引超出范围,这段额外的代码会使程序停止。例如,如果对Profits``ByYear的索引超出了19982039的范围,程序将因错误而中止。^(1)

通常,数组的索引是整数值,尽管一些语言允许使用其他序数类型(使用底层整数表示的数据类型)作为索引。例如,Pascal 允许charboolean作为数组索引。在 Pascal 中,声明一个数组如下是完全合理且有用的:

alphaCnt : array[ 'A'..'Z' ] of integer;

你可以通过字符表达式作为数组索引来访问alphaCnt的元素。例如,考虑以下 Pascal 代码,它将alphaCnt的每个元素初始化为0(假设ch:char出现在var部分):

for ch := 'A' to 'Z' do

    alphaCnt[ ch ] := 0;

汇编语言和 C/C++将大多数序数值视为整数值的特殊实例,因此它们是合法的数组索引。大多数 BASIC 实现允许使用浮点数作为数组索引,尽管 BASIC 在使用浮点数作为索引前会将其截断为整数。^(2)

7.2.2 数组在内存中的表示

从抽象的角度来看,数组是一个变量集合,你可以通过索引来访问这些变量。从语义上讲,我们可以根据自己的需求定义数组,只要它将不同的索引映射到内存中的不同对象,并且始终将相同的索引映射到相同的对象。然而,在实际应用中,大多数语言使用一些常见的算法来提供对数组数据的高效访问。

数组占用的存储字节数是数组元素个数与每个元素所占字节数的乘积。许多语言还会在数组的末尾添加一些填充字节,以确保数组的总长度是 4 或 8 等常见值的偶数倍(在 32 位或 64 位机器上,编译器可能会向数组添加字节,以使其长度扩展为机器字大小的倍数)。然而,程序不应该依赖这些额外的填充字节,因为它们可能存在也可能不存在。一些编译器总是添加这些字节,一些则从不添加,还有一些则根据内存中紧跟数组的对象类型决定是否添加。

许多优化编译器会尝试将数组的起始内存地址设置为某个常见大小(如 2、4 或 8 字节)的偶数倍。实际上,这就相当于在数组开始前添加填充字节,或者如果你愿意这样理解的话,添加到内存中上一个对象之后(见图 7-3)。

image

图 7-3:在数组前添加填充字节

在不支持字节寻址内存的机器上,编译器会尝试将数组的第一个元素放置在一个容易访问的边界上,并根据机器支持的边界分配数组的存储空间。如果每个数组元素的大小小于 CPU 支持的最小内存对象大小,编译器实现者有两个选择:

  • 为每个数组元素分配最小可访问的内存对象。

  • 将多个数组元素打包成一个内存单元。

第一种选择的优点是速度快,但它浪费内存,因为每个数组元素携带了一些不需要的额外存储。第二种选择则更紧凑,但较慢,因为它需要额外的指令来打包和解包数据以访问数组元素。这些机器上的编译器通常允许你指定是否希望数据打包或解包,这样你就可以在空间和速度之间做出选择。

如果你在字节寻址的机器上工作(例如 80x86),你可能不需要担心这个问题。然而,如果你使用的是高级语言(HLL),并且你的代码将来可能在其他机器上运行,你应该选择一种在所有机器上都高效的数组组织方式。

7.2.3 访问数组元素

如果你为一个数组分配了连续的内存位置,并且数组的第一个索引是0,那么访问一维数组的元素非常简单。你可以使用以下公式计算数组中任何给定元素的地址:

Element_Address = Base_Address + index * Element_Size

Element_Size 是每个数组元素所占的字节数。因此,如果每个数组元素是byte类型,Element_Size 字段为1,计算就非常简单。如果每个元素是word(或其他 2 字节类型),那么 Element_Size 就是2,依此类推。

考虑以下 Pascal 数组声明:

var  SixteenInts : array[ 0..15 ] of integer;

要在字节寻址机器上访问SixteenInts数组的元素,假设是 4 字节的整数,你可以使用以下计算:

Element_Address = AddressOf( SixteenInts ) + index * 4

在汇编语言中(在这种情况下你需要手动进行计算,而不是让编译器为你做这件事),你可以使用以下代码来访问数组元素SixteenInts[index]

mov( index, ebx );

mov( SixteenInts[ ebx*4 ], eax );

7.2.4 多维数组

大多数 CPU 可以轻松处理一维数组。不幸的是,没有一种神奇的寻址模式可以让你轻松访问多维数组的元素。访问多维数组元素需要一些工作和多个机器指令。

在讨论如何声明或访问多维数组之前,让我们先看看如何在内存中实现它们。第一个挑战是弄清楚如何将一个多维对象存储在一维内存空间中。

想象一下一个 Pascal 数组,形式如下:

A:array[0..3,0..3] of char;

这个数组包含 16 个字节,按四行四列的方式组织。我们需要将这个数组中的每个 16 个字节映射到主内存中的 16 个连续字节。图 7-4 展示了这样做的一种方式。

image

图 7-4:将 4×4 数组映射到顺序内存位置

只要遵循以下两条规则,实际的映射方式并不重要:

  • 数组中的任何两个元素不能占用相同的内存位置。

  • 数组中的每个元素必须始终映射到相同的内存位置。

因此,你需要一个具有两个输入参数的函数——一个用于行,一个用于列的值——该函数会产生一个偏移量,指向连续的 16 个内存位置块。任何满足这两个约束条件的函数都能正常工作。然而,你真正需要的是一个映射函数,它能够在运行时高效计算,并且适用于任何维度和任意维度范围的数组。虽然有许多符合这些条件的函数,但大多数高级语言使用的是两种类型:行主序排列列主序排列

在我真正描述行主序和列主序排列之前,让我们先回顾一些术语。行索引指的是对行的数字索引;也就是说,如果将单行视为一维数组,行索引将是该数组的索引。列索引有类似的含义;如果将单列视为一维数组,列索引将是该数组的索引。如果你回头看看图 7-4,在每一列上方的数字 0、1、2 和 3 是列号,而位于行左侧的这些相同数字则是行号。这个术语容易让人混淆,因为列号与行索引相同;也就是说,列号等同于索引任何一行中的位置。类似地,行号与列索引相同。本书使用术语行索引列索引,但请注意,其他作者可能使用来指代行号和列号。

7.2.4.1 行主序排列

行主序排列通过先横向遍历一行,再纵向遍历列,将数组元素分配到连续的内存位置。图 7-5 演示了这种映射方式。

image

图 7-5:行主序排列

行主序排列是大多数高级编程语言采用的方法,包括 Pascal、C/C++、Java、C#、Ada 和 Modula-2。这种组织方式非常容易实现,并且在机器语言中使用也很方便。从二维结构到线性序列的转换非常直观。图 7-6 提供了 4×4 数组排列的另一种视图。

image

图 7-6:4×4 数组的行主序排列的另一种视图

将多维数组索引集转换为单个偏移量的函数,是计算一维数组元素地址公式的轻微修改。计算 4×4 二维行主序数组的偏移量公式,假设访问形式如下:

A[ colindex ][ rowindex ]

如下所示:

Element_Address = Base_Address + (colindex * row_size + rowindex) * Element_Size

如常,Base_Address 是数组第一个元素的地址(此例为 A[0][0]),Element_Size 是数组单个元素的大小(以字节为单位)。row_size 是数组中每行的元素数量(在此例中为 4,因为每行有四个元素)。假设 Element_Size 为 1,此公式计算的偏移量如表 7-2 所示,从基地址开始。

表 7-2: 二维行主序数组的偏移量

列索引 行索引 数组偏移量
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

以下 C/C++ 代码访问行主序数组中的顺序内存位置:

for( int col=0; col < 4; ++col )

{

    for( int row=0; row < 4; ++row )

    {

        A[ col ][ row ] = 0;

    }

}

对于三维数组,计算内存偏移量的公式只稍微复杂一点。考虑以下 C/C++ 数组声明:

someType A[depth_size][col_size][row_size];

如果你有类似 A[depth_index][col_index][row_index] 的数组访问,那么计算内存偏移量的公式为:

Address = 

Base + ((depth_index * col_size + col_index) * row_size + row_index) * Element_Size

再次强调,Element_Size 是单个数组元素的大小,以字节为单位。

如果你在 C/C++ 中声明了一个 n 维数组,如下所示:

dataType A[bn-1][bn-2]...[b0];

并且你希望访问该数组中的以下元素:

A[an-1][an-2]...[a1][a0]

然后,你可以使用以下算法计算特定数组元素的地址:

Address := an-1

for i := n-2 downto 0 do

    Address := Address * bi + ai

Address := Base_Address + Address * Element_Size
7.2.4.2 列主序

列主序是另一种常见的数组元素地址函数,FORTRAN 和各种 BASIC 方言(如旧版的 Microsoft BASIC)使用列主序索引数组。列主序数组的组织方式如图 7-7 所示。

image

图 7-7:列主序

使用列主序访问数组元素的地址计算公式与行主序非常相似。不同之处在于,你需要在计算中反转索引和大小变量的顺序。也就是说,不是从最左边的索引开始到最右边,而是从右向左操作。

对于二维列主序数组,公式如下:

Element_Address = 

   Base_Address + (rowindex * col_size + colindex) * Element_Size

对于三维列主序数组,公式如下:

Element_Address = 

   Base_Address + 

       ((rowindex * col_size + colindex) * depth_size + depthindex) * Element_Size

如此类推。除了使用这些新公式外,使用列主序访问数组元素与使用行主序访问数组元素是相同的。

7.2.4.3 声明多维数组

一个“m × n”的数组有 m × n 个元素,并且需要 m × n × 元素大小字节的存储空间。为了分配数组存储空间,你必须预留这些内存。对于一维数组,不同高级语言的语法非常相似。然而,在多维数组的声明上,它们的语法开始有所不同。

在 C、C++和 Java 中,你使用以下语法来声明多维数组:

data_type array_name [dim1][dim2] . . . [dimn];

例如,下面是一个 C/C++中的三维数组声明:

int threeDInts[ 4 ][ 2 ][ 8 ];

这个示例创建了一个包含 64 个元素的数组,组织成 4 个深度、2 行和 8 列。假设每个 int 对象需要 4 个字节,那么这个数组占用 256 个字节的存储空间。

Pascal 的语法支持两种等效的方式来声明多维数组:

var

        threeDInts  : array[0..3] of array[0..1] of array[0..7] of integer;

        threeDInts2 : array[0..3, 0..1, 0..7] of integer;

C#使用以下语法来定义多维数组:

type [,]array_name = new type [dim1,dim2] ;

type [,,]array_name = new type [dim1,dim2,dim3] ;

type [,,,]array_name = new type [dim1,dim2,dim3,dim4] ;

etc.

从语义上讲,不同语言之间只有两个主要区别。第一个是数组声明是否指定每个数组维度的整体大小,还是指定上下边界。第二个是起始索引是 01 还是用户指定的值。

Swift 在传统意义上并不真正支持多维数组。它允许你创建数组的数组(数组的数组……),这些数组可以提供与多维数组相同的功能,但表现方式略有不同。有关更多细节,请参阅第 179 页的 “Swift 数组实现”。

7.2.4.4 访问多维数组的元素

在高级语言中,访问多维数组的元素是如此简单,以至于许多程序员在不考虑相关成本的情况下就这样做。在本节中,为了让你对这些成本有更清晰的了解,我们将查看一些需要访问多维数组元素时的汇编语言序列。

再次考虑上一节中 C/C++ 的 ThreeDInts 数组声明:

int ThreeDInts[ 4 ][ 2 ][ 8 ];

在 C/C++中,如果你想将该数组的元素 [i][j][k] 设置为 n,你可能会使用以下语句:

ThreeDInts[i][j][k] = n;

然而,这个语句隐藏了很多复杂性。回想一下访问三维数组元素所需的公式:

Element_Address = 

   Base_Address + 

      ((rowindex * col_size + colindex) * depth_size + depthindex) * 

          Element_Size

ThreeDInts 示例并没有避免此计算,它只是将其隐藏了。C/C++ 编译器生成的机器码类似于以下内容:

intmul( 2, i, ebx );            // EBX = 2 * i

add( j, ebx );                  // EBX = 2 * i + j

intmul( 8, ebx );               // EBX = (2 * i + j) * 8

add( k, ebx );                  // EBX = (2 * i + j) * 8 + k

mov( n, eax );

mov( eax, ThreeDInts[ebx*4] );  // ThreeDInts[i][j][k] = n

事实上,ThreeDInts 是特殊的。所有数组维度的大小都是很好的 2 的幂。这意味着 CPU 可以使用移位操作代替乘法指令,将 EBX 乘以 2 和 4。在这个示例中,由于移位操作通常比乘法更快,一个不错的 C/C++编译器会生成如下代码:

mov( i, ebx );

shl( 1, ebx );                  // EBX = 2 * i

add( j, ebx );                  // EBX = 2 * i + j

shl( 3, ebx );                  // EBX = (2 * i + j) * 8

add( k, ebx );                  // EBX = (2 * i + j) * 8 + k

mov( n, eax );

mov( eax, ThreeDInts[ebx*4] );  // ThreeDInts[i][j][k] = n

请注意,编译器只有在数组维度是 2 的幂时,才能使用更快的代码;这也是为什么许多程序员尝试声明具有这些维度的数组。当然,如果你必须在数组中声明额外的元素来实现这一目标,可能会浪费空间(尤其是在高维数组中),从而只获得微小的速度提升。

例如,如果你需要一个 10×10 的数组,并且使用行主序排列,你可以创建一个 10×16 的数组,以便使用移位(乘以 4)指令,而不是乘法(乘以 10)指令。当使用列主序排列时,你可能希望声明一个 16×10 的数组来实现相同的效果,因为行主序计算在计算数组偏移量时不使用第一维的大小,而列主序计算则不使用第二维的大小。在任何情况下,数组最终都会有 160 个元素,而不是 100 个元素。只有你能决定这种额外的空间是否值得为了略微提高的速度进行牺牲。

7.2.4.5 Swift 数组实现

Swift 数组与许多其他语言中的数组不同。首先,Swift 数组是基于 struct 对象的封闭类型(而不仅仅是内存中的元素集合)。Swift 不保证数组元素出现在连续的内存位置。然而,语言提供了以下 ContiguousArray 类型规范,它保证数组元素会出现在连续的内存位置(如 C/C++ 及其他语言中):

var array_name = ContiguousArray<element_type>()

到目前为止,一切正常。使用连续数组时,实际数组数据的存储方式与其他语言相匹配。然而,当你开始声明多维数组时,相似性就结束了。如前所述,Swift 实际上并没有多维数组;相反,它支持 数组的数组

对于大多数编程语言,数组对象严格来说是内存中数组元素的顺序,数组的数组和多维数组是相同的。然而,Swift 使用描述符(基于 struct)对象来指定数组。像字符串描述符一样,Swift 数组由包含多个字段的数据结构组成(如当前数组元素的数量和一个或多个指向实际数组数据的指针)。

当你创建一个数组的数组时,实际上是在创建一个包含这些描述符的数组,每个描述符指向一个子数组。考虑以下两个(等效的)Swift 数组数组声明(a1a2)及示例程序:

import Foundation

var a1 = [[Int]]()

var a2 = ContiguousArray<Array<Int>>()

a1.append( [1,2,3] )

a1.append( [4,5,6] )

a2.append( [1,2,3] )

a2.append( [4,5,6] )

print( a1 )

print( a2 )

print( a1[0] )

print( a1[0][1] )

运行该程序会产生以下输出:

[[1, 2, 3], [4, 5, 6]]

[[1, 2, 3], [4, 5, 6]]

[1, 2, 3]

2

对于二维数组,你会期待这种类型的输出。然而,实际上,a1a2 是一维数组,每个数组有两个元素。这两个元素是数组描述符,它们本身指向包含三个元素的数组。

尽管 a2 是一个 ContiguousArray 类型,但与 a2 相关的六个数组元素不太可能出现在连续的内存位置中。a2 中保存的两个数组描述符可能出现在连续的内存位置,但这并不一定会延续到它们共同指向的六个数据元素。

由于 Swift 动态分配数组,二维数组中的行可能具有不同的元素数量。请考虑对之前的 Swift 程序进行以下修改:

import Foundation

var a2 = ContiguousArray<Array<Int>>()

a2.append( [1,2,3] )

a2.append( [4,5] )

print( a2 )

print( a2[0] )

print( a2[0][1] )

运行此程序将产生以下输出:

[[[1, 2, 3], [4, 5]]

[1, 2, 3]

2

a2 数组中的两行具有不同的大小。根据你要完成的任务,这可能是有用的,也可能是缺陷的来源。

在 Swift 中获取标准的多维数组存储的一种方式是声明一个一维的 ContiguousArray,其元素数量足够存储多维数组的所有元素。然后使用行主序(或列主序)功能,在不需要元素大小操作数的情况下计算数组中的索引。

7.3 记录/结构体

另一个主要的复合数据结构是 Pascal 的 记录 或 C/C++ 的 结构体。Pascal 的术语可能更好,因为它避免了与 数据结构 这一术语的混淆,因此我们在这里一般使用 记录

数组是 同质 的,意味着它的所有元素都是相同类型的。另一方面,记录是 异质 的——它的元素可以具有不同的类型。记录的目的是让你将逻辑相关的值封装到一个单一的对象中。

数组让你可以通过整数索引选择特定元素。使用记录时,你必须通过字段的名称选择元素,这些元素被称为 字段。记录中的每个字段名称必须是唯一的;也就是说,你不能在同一记录中多次使用相同的字段名称。然而,所有字段名称都是本地于它们的记录的,你可以在程序的其他地方重用这些名称。

7.3.1 Pascal/Delphi 中的记录

这是 Pascal/Delphi 中 Student 数据类型的典型记录声明:

type

    Student = 

        record

            Name:     string (64);

            Major:    smallint;    // 2-byte integer in Delphi

            SSN:      string (11);

            Mid1:     smallint;

            Midt:     smallint;

            Final:    smallint;

            Homework: smallint;

            Projects: smallint;

        end;

许多 Pascal 编译器会将所有字段分配到连续的内存位置。这意味着 Pascal 会为姓名预留前 65 字节,^(3) 为专业代码预留接下来的 2 字节,为社会安全号码预留接下来的 12 字节,以此类推。

7.3.2 C/C++ 中的记录

这是相同声明的 C/C++ 版本:

typedef

    struct 

    {

        char Name[65]; // Room for a 64-character zero-terminated string.

        short Major;   // Typically a 2-byte integer in C/C++

        char SSN[12];  // Room for an 11-character zero-terminated string.

        short Mid1;

        short Mid2;

        short Final;

        short Homework;

        short Projects

    } Student;

由于 C++ 结构体实际上是类声明的一个专门形式,因此它们的行为与 C 结构体不同,并且可能包含在 C 变体中没有的额外数据(这就是 C++ 中结构体的内存存储可能不同的原因;详见第 184 页的 “记录的内存存储”)。C 和 C++ 结构体之间还有命名空间等一些小区别。

事实上,你可以告诉 C++ 使用 extern "C" 块按如下方式编译一个真正的 C struct 定义:

extern "C"

{

    struct 

    {

        char Name[65]; // Room for a 64-character zero-terminated string.

        short Major;   // Typically a 2-byte integer in C/C++

        char SSN[12];  // Room for an 11-character zero-terminated string.

        short Mid1;

        short Mid2;

        short Final;

        short Homework;

        short Projects;

    } Student;

}

注意

Java 不支持与 C struct 相对应的任何东西——它只支持类(参见第 192 页的“类”)。

7.3.3 HLA 中的记录

在 HLA 中,你也可以使用 record/endrecord 声明来创建结构类型。例如,你可以如下编码前面章节中的记录:

type

    Student:

        record

            Name:     char[65];    // Room for a 64-character

                                   // zero-terminated string.

            Major:    int16;

            SSN:      char[12];    // Room for an 11-character

                                   // zero-terminated string.

            Mid1:     int16;

            Mid2:     int16;

            Final:    int16;

            Homework: int16;

            Projects: int16;

        endrecord;

如你所见,HLA 声明与 Pascal 声明非常相似。为了与 Pascal 声明保持一致,这个例子使用字符数组而不是字符串来表示 NameSSN(社会安全号码)字段。在典型的 HLA 记录声明中,你可能会使用 string 类型来表示至少是 Name 字段(请记住,字符串变量是一个 4 字节的指针)。

7.3.4 Swift 中的记录(元组)

尽管 Swift 不支持记录的概念,但你可以使用 Swift 元组 来模拟一个记录。虽然 Swift 不像其他编程语言那样以相同方式存储记录(元组)元素(参见第 184 页的“记录的内存存储”),但是如果你想创建一个复合/聚合数据类型,而不想增加类的开销,元组是一个有用的构造。

Swift 元组只是以以下形式组织的值列表:

( value1, value2, ..., valuen )

元组中值的类型不需要完全相同。

Swift 通常使用元组从函数返回多个值。考虑以下简短的 Swift 代码片段:

func returns3Ints()->(Int, Int, Int )

{

    return(1, 2, 3)

}

var (r1, r2, r3) = returns3Ints();

print( r1, r2, r3 )

returns3Ints() 函数返回三个值(123)。以下语句将这三个整数值分别存储到 r1r2r3 中:

var (r1, r2, r3) = returns3Ints();

你还可以将元组赋值给一个单一变量,并通过整数索引作为字段名称访问元组的“字段”:

let rTuple = ( "a", "b", "c" )

print( rTuple.0, rTuple.1, rTuple.2 ) // Prints "a b c"

当然,使用像 .0 这样的字段名称会导致非常难以维护的代码。虽然你可以通过元组创建记录,但通过整数索引引用字段在实际程序中很少适用。

幸运的是,Swift 允许你为每个元组字段分配一个标签,然后你可以使用该标签代替整数索引来引用字段。考虑以下 Swift 代码片段:

typealias record = ( field1:Int, field2:Int, field3:Float64 )

var r = record(1, 2, 3.0 )

print( r.field1, r.field2, r.field3 )  // prints "1 2 3.0"

以这种方式使用 Swift 元组在语法上等同于使用 Pascal 或 HLA 记录(或 C 结构)。然而,请记住,元组在内存中的存储方式可能与这些其他语言中的记录或结构的布局不一致。像 Swift 中的数组一样,元组是一个不透明类型,没有保证定义 Swift 如何在内存中存储它们。

7.3.5 记录的内存存储

以下 Pascal 示例演示了一个典型的 Student 变量声明:

var

     John: Student;

基于前面为 Pascal Student 数据类型的声明,这将分配 81 字节的存储,并按图 7-8 所示的方式布局在内存中。

image

图 7-8:学生数据结构在内存中的存储

如果标签 John 对应于该记录的 基地址,那么 Name 字段位于偏移量 John+0Major 字段位于偏移量 John+65SSN 字段位于偏移量 John+67,依此类推。

大多数编程语言允许你通过字段名称而非数字偏移量来引用记录字段。访问字段的典型语法使用 点运算符 从记录变量中选择一个字段。假设变量 John 来自前面的示例,以下是你如何访问该记录中的不同字段:

John.Mid1 = 80;              // C/C++ example

John.Final := 93;            (* Pascal example *)

mov( 75, John.Projects );    // HLA example

图 7-8 表明,记录的所有字段在内存中按声明顺序出现,通常在实际应用中是这样的。不过从理论上讲,编译器可以自由地将字段放置在内存中的任何位置。第一个字段通常出现在记录的最低地址,第二个字段出现在下一个较高的地址,第三个字段紧跟第二个字段在内存中依次排列,依此类推。

图 7-8 还表明,编译器将字段打包到相邻的内存位置,没有它们之间的间隙。虽然许多语言都如此,但这并不是记录最常见的内存组织方式。出于性能考虑,大多数编译器实际上会将记录的字段按适当的内存边界对齐。具体的细节因语言、编译器实现和 CPU 而异,但典型的编译器会将字段放置在记录存储区内的偏移位置,这个位置对该字段的数据类型是“自然的”。以 80x86 为例,遵循 Intel ABI(应用二进制接口)的编译器将 1 字节对象分配到记录中的任何偏移量,单字只分配到偶数偏移量,双字或更大的对象则在双字边界上分配。尽管并非所有 80x86 编译器都支持 Intel ABI,但大多数支持,这使得记录能够在不同语言编写的函数和过程之间共享。在 80x86 上,其他 CPU 厂商为其处理器提供了自己的 ABI,遵循 ABI 的程序可以在运行时与其他遵循相同 ABI 的程序共享二进制数据。

除了在合理的偏移边界对齐记录的字段外,大多数编译器还确保整个记录的长度是 2、4、8 甚至 16 字节的倍数。如本章前面提到的,它们通过附加填充字节来填充记录的大小。这样可以确保记录的长度是记录中最大标量(非复合数据类型)对象的大小或 CPU 的最佳对齐大小的最小倍数。例如,如果一个记录的字段长度分别是 1、2、4、8 和 10 字节,那么一个 80x86 编译器通常会为记录的长度添加填充,使其成为 8 的倍数。这可以让您创建一个记录数组,并确保数组中的每个记录都从内存中的合理地址开始。

尽管某些 CPU 不允许访问内存中不对齐地址的对象,但许多编译器允许您禁用记录中字段的自动对齐。通常,编译器会提供一个选项,允许您全局禁用此功能。许多编译器还提供一个pragmapacked关键字,允许您逐个记录地禁用字段对齐。禁用自动字段对齐功能可以通过消除字段之间以及记录末尾的填充字节来节省一些内存(前提是您的 CPU 能够接受字段不对齐)。然而,当程序需要访问内存中不对齐的值时,它的运行速度可能会稍微变慢。

使用打包记录的一个原因是可以手动控制记录字段的对齐方式。例如,假设您有两个不同语言编写的函数,这两个函数都需要访问记录中的某些数据。假设这两个函数的编译器使用的字段对齐算法不同。像下面这样的记录声明(在 Pascal 中)可能与这两个函数访问记录数据的方式不兼容:

type

    aRecord: record

        bField : byte;  (* assume Pascal compiler supports a byte type *)

        wField : word;  (* assume Pascal compiler supports a word type *)

        dField : dword; (* assume Pascal compiler supports a double-word type *)

    end; (* record *)

这里的问题是,第一个编译器可能会分别为bFieldwFielddField字段使用偏移量 0、2 和 4,而第二个编译器可能会使用偏移量 0、4 和 8。

然而,假设第一个编译器允许您在record关键字前指定packed关键字,这会导致编译器将每个字段紧跟着前一个字段存储。虽然使用packed关键字并不能使记录与两个函数兼容,但它确实允许您手动向记录声明中添加填充字段,如下所示:

type

    aRecord: packed record

        bField   :byte;

        padding0 :array[0..2] of byte; (* add padding to dword align wField *)

        wField   :word;

        padding1 :word;                (* add padding to dword align dField *)

        dField   :dword; 

    end; (* record *)

手动添加填充可能会使代码维护变得非常麻烦。然而,如果不兼容的编译器需要共享数据,这是一个值得了解的技巧。关于打包记录的具体细节,请查阅您编程语言的参考手册。

7.4 判别联合

判别联合(或简称 联合)与记录非常相似。像记录一样,联合也有字段,可以使用点号表示法进行访问。在许多语言中,记录和联合之间唯一的语法区别是使用 union 关键字而不是 record。然而,从语义上讲,它们之间有很大的区别。在记录中,每个字段都有自己的偏移量,从记录的基地址开始,字段不重叠。然而,在联合中,所有字段的偏移量都是 0,所有字段都重叠。因此,记录的大小是所有字段大小的总和(可能还有一些填充字节),而联合的大小是其最大字段的大小(可能还有一些填充字节)。

由于联合的字段重叠,你可能会认为它在实际程序中几乎没什么用。毕竟,如果所有字段都重叠,那么更改一个字段的值会改变所有其他字段的值。这意味着联合字段是 互斥的——也就是说,你一次只能使用一个字段。虽然这确实使得联合比记录更不通用,但它们仍然有很多用途。

7.4.1 C/C++ 中的联合

下面是 C/C++ 中联合声明的示例:

typedef union

{

      unsigned int  i;

      float         r;

      unsigned char c[4];

} unionType;

假设 C/C++ 编译器为无符号整数分配 4 个字节,那么 unionType 对象的大小将为 4 个字节(因为所有三个字段都是 4 字节的对象)。

注意

不幸的是,由于涉及到安全问题,Java 不支持判别联合。你可以通过子类化实现判别联合的一些功能,但 Java 不支持在不同变量之间显式共享内存位置。

7.4.2 Pascal/Delphi 中的联合

Pascal/Delphi 使用 变体记录 来创建判别联合。变体记录的语法如下:

type

    typeName = 

       record

            <<nonvariant/union record fields go here>>

            case tag of

                const1:( field_declaration );

                const2:( field_declaration );

                    .

                    .

                    .

                constn:( field_declaration )  (* no semicolon follows

                                            the last field *)

        end;

在这个例子中,tag 是一个类型标识符(例如 booleanchar 或某个用户定义的类型),或者是一个字段声明,形式为 identifier:type。如果标签项采用后一种形式,那么 identifier 就成了记录的另一个字段,而不是 变体部分(那些跟在 case 后面的声明),并具有指定的类型。此外,Pascal 编译器可以生成代码,当应用程序尝试访问任何变体字段(除了由标签字段的值指定的字段)时,会引发异常。然而,实际上几乎没有 Pascal 编译器做这个检查。尽管如此,记住 Pascal 语言标准建议编译器应该这样做,因此一些编译器可能会这样做。

下面是 Pascal 中两个不同的变体记录声明示例:

type

    noTagRecord=

        record

            someField: integer;

            case boolean of

                true:( i:integer );

                false:( b:array[0..3] of char)

        end; (* record *)

    hasTagRecord=

        record

            case which:0..2 of

                0:( i:integer );

                1:( r:real );

                2:( c:array[0..3] of char )

        end; (* record *)

正如你在 hasTagRecord 联合中看到的,Pascal 的变体记录不需要任何普通的记录字段。即使你没有标签字段,这也是真的。

7.4.3 Swift 中的联合

Swift 不直接支持判别联合体的概念。然而,Swift 提供了一个替代方案——相当于 Pascal 的变体记录——支持安全使用联合体:枚举数据类型。

请考虑以下 Swift 枚举定义:

enum EnumType

{

    case a

    case b

    case c

}

let et = EnumType.b

print( et )  // prints "b" on standard output

到目前为止,这只是一个枚举数据类型,与联合体无关。不过,我们可以为枚举数据类型的每个枚举项附加一个值(实际上是一个元组值)。请考虑以下 Swift 程序,它演示了 enum 关联值

import Foundation

enum EnumType

{

    case isInt( Int )

    case isReal( Double )

    case isString( String )

}

func printEnumType( _ et:EnumType )

{

    switch( et )

    {

        case .isInt( let i ):

            print( i )

        case .isReal( let r ):

            print( r )

        case .isString( let s ):

            print( s )

    }

}

let etI = EnumType.isInt( 5 )

let etF = EnumType.isReal( 5.0 )

let etS = EnumType.isString( "Five" )

print( etI, etF, etS )

printEnumType( etI )

printEnumType( etF )

printEnumType( etS )

该程序生成以下输出:

isInt(5) isReal(5.0) isString("Five")

5

5.0

Five

EnumType 类型的变量会获取枚举值之一:isIntisRealisString(这三者是 EnumType 类型的常量)。除了 Swift 为这三种常量选择的内部编码(可能是 012,尽管它们的实际值并不重要)外,Swift 会将一个整数值与 isInt 关联,将一个 64 位双精度浮动点数值与 isReal 关联,并将一个字符串值与 isString 关联。这三个 let 语句将适当的值赋给 EnumType 变量;如你所见,为了赋值,你需要将其放在常量名称后的括号中。然后,你可以使用 switch 语句提取该值。

7.4.4 HLA 中的联合体

HLA 同样支持联合体;以下是一个典型的联合体声明:

type

    unionType:

        union

            i: int32;

            r: real32;

            c: char[4];

        endunion;

7.4.5 联合体的内存存储

如前所述,联合体与记录体之间的最大区别在于,记录体为每个字段分配不同偏移量的存储,而联合体则将所有字段重叠在内存中的同一偏移量。例如,考虑以下 HLA 记录体和联合体声明:

type

    numericRec:

        record

            i: int32;

            u: uns32;

            r: real64;

        endrecord;

    numericUnion:

        union

            i: int32;

            u: uns32;

            r: real64;

        endunion;

如果你声明一个变量 n,类型为 numericRec,你可以像访问 numericUnion 类型的变量一样,通过 n.in.un.r 访问各字段。然而,numericRec 对象的大小是 16 字节,因为该记录包含两个双字字段和一个四字(real64)字段。相比之下,numericUnion 变量的大小为 8 字节。图 7-9 显示了记录和联合体中 iur 字段的内存布局。

image

图 7-9:联合体与记录变量的布局

注意,Swift 的 enum 类型是不可见的。它们可能不会将每个枚举项的关联值存储在相同的内存地址中——即使它们目前存储在相同的内存位置,未来版本的 Swift 也不能保证它们会保持如此。

7.4.6 联合体的其他用途

除了节省内存,程序员使用联合体的另一个常见原因是为了在代码中创建别名。别名是某个内存对象的第二个名称。尽管别名往往会导致程序中的混淆,因此应该谨慎使用,但有时使用它们会更加方便。例如,在程序的某些部分,你可能需要不断地使用类型强制转换来引用特定对象。为了避免这种情况,你可以使用一个联合体变量,每个字段代表你想要为对象使用的不同类型。考虑以下 HLA 代码片段:

type

    CharOrUns:

        union

            c:char;

            u:uns32;

            endunion;

static

    v:CharOrUns;

使用这样的声明,你可以通过访问v.u来操作uns32对象。如果在某些时候,你需要将这个uns32变量的 LO 字节当作字符来处理,只需按照以下方式访问v.c变量即可:

mov( eax, v.u );

stdout.put( "v, as a character, is '", v.c, "'" nl );

另一个常见的做法是使用联合体将一个较大的对象拆解成其组成的字节。考虑以下 C/C++代码片段:

typedef union

{

    unsigned int u;

    unsigned char bytes[4];

} asBytes;

asBytes composite;

        .

        .

        .

    composite.u = 1234567890;

    printf

    ( 

        "HO byte of composite.u is %u, LO byte is %u\n",

        composite.u[3],

        composite.u[0]

    );

尽管以这种方式组合和拆解数据类型是一个偶尔使用的有用技巧,但请记住,这段代码不可移植。在大端和小端机器上,多字节对象的高字节(HO)和低字节(LO)出现在不同的地址。因此,这段代码在小端机器上运行正常,但在大端 CPU 上无法显示正确的字节。每当你使用联合体来拆解更大的对象时,应该意识到这一限制。尽管如此,这种技巧通常比使用左移、右移和与操作要高效得多,因此你会看到它被广泛使用。

注意

Swift 的类型安全系统不允许你使用判别联合体将比特集合作为不同类型访问。如果你真的想通过原始位赋值将一种类型转换为另一种类型,可以使用 Swift 的unsafeBitCast()函数。详情请参阅 Swift 标准库文档。

7.5 类

乍一看,像 C++、Object Pascal 或 Swift 这样的编程语言中的类看起来像是记录(或结构)的简单扩展,应该具有类似的内存组织。事实上,大多数编程语言确实非常类似地组织类的数据字段到内存中,就像记录和结构一样。编译器按照在类声明中遇到字段的顺序,将字段布局到连续的内存位置。然而,类有几个额外的特性,是纯粹的记录和结构中没有的;具体来说,成员函数(在类中声明的函数)、继承和多态对编译器如何在内存中实现类对象有很大的影响。

考虑以下 HLA 结构和 HLA 类声明:

type

     student: record 

          sName:    char[65]; 

          Major:    int16; 

          SSN:      char[12]; 

          Midterm1: int16; 

          Midterm2: int16; 

          Final:    int16; 

          Homework: int16; 

          Projects: int16;  

     endrecord; 

     student2: class 

          var 

               sName:    char[65]; 

               Major:    int16; 

               SSN:      char[12]; 

               Midterm1: int16; 

               Midterm2: int16; 

               Final:    int16; 

               Homework: int16; 

               Projects: int16;

          method setName( source:string );

          method getName( dest:string );

          procedure create;  // Constructor for class

     endclass;

与记录类型一样,HLA 为类中的所有var字段按顺序分配存储空间。实际上,如果一个类仅由var数据字段组成,它的内存表示几乎与相应的记录声明完全相同(参见图 7-10 和图 7-11)。

image

图 7-10:HLA student 记录的布局

image

图 7-11:HLA student2 类的布局

从这些图中可以看出,区别在于student2类数据的开始处存在 VMT 指针字段。VMT,即虚拟方法表,是指向与类相关联的函数(方法)指针数组的指针。^(4) 在student2示例中,VMT 字段指向一个包含两个 32 位指针的表——一个指向setName()方法,一个指向getName()方法。当程序调用此类中的虚拟方法setName()getName()时,它不会直接通过它们在内存中的地址调用这些方法。相反,它会从对象中获取 VMT 的地址,利用该指针获取具体方法的地址(setName()可能位于 VMT 的第一个索引,getName()位于第二个索引),然后使用获取到的地址间接调用方法。

7.5.1 继承

从 VMT 中获取方法地址是一项繁琐的工作。那么,为什么编译后的代码要这样做,而不是直接调用方法呢?原因在于类和对象支持的一对神奇特性:继承和多态。考虑以下 HLA 类声明:

type

        student3: class  inherits( student2 )

            var

                extraTime: int16; // Extra time allotted for exams

            override method setName;

            override procedure create;

     endclass;

student3类继承了student2类的所有数据字段和方法(如类声明中的inherits子句所指定),然后定义了一个新的数据字段extraTime,它为学生在考试期间分配额外的时间,单位为分钟。student3的声明还定义了一个新的方法setName(),它替代了student2类中原来的setName()方法(它还定义了一个重写的create过程,但我们现在暂时忽略这一点)。student3对象的内存布局见图 7-12。

image

图 7-12:HLA student3 类的布局

在内存中,student2student3 对象的区别在于 student3 数据结构末尾的额外 2 个字节以及 VMT 字段所保存的值。对于 student2 对象,VMT 字段指向 student2 类的 VMT(内存中只有一个实际的 student2 VMT,所有 student2 对象都包含指向它的指针)。如果我们有一对名为 JohnJoanstudent2 对象,它们的 VMT 字段将都包含指向同一个内存中 VMT 的地址,这个地址包含了 表 7-3 中显示的信息。

表 7-3: student2 VMT 的条目

偏移量^(5) 条目
0(字节) 指向 (student2) setName() 方法的指针
4(字节) 指向 getName() 方法的指针

现在考虑我们在内存中有一个 student3 对象(我们称它为 Jenny)。Jenny 的内存布局与 JohnJoan 类似(参见 图 7-11 和 7-12)。然而,尽管 JohnJoan 中的 VMT 字段都包含相同的值(指向 student2 VMT 的指针),但 Jenny 对象的 VMT 字段将指向 student3 VMT(参见 表 7-4)。

表 7-4: student3 VMT 的条目

偏移量 条目
0(字节) 指向 (student3) setName() 方法的指针
4(字节) 指向 getName() 方法的指针

尽管 student3 VMT 看起来与 student2 VMT 几乎相同,但有一个关键的区别:在 表 7-3 中的第一个条目指向 student2setName() 方法,而 表 7-4 中的第一个条目指向 student3setName() 方法。

将从 基类 继承的字段添加到另一个类中时,必须小心操作。记住,一个从基类继承字段的类的一个重要特征是,你可以使用指向基类的指针来访问其字段,即使该指针包含的是指向其他类的地址(该类继承了基类的字段)。考虑以下类:

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;

因为 tChildClassAtChildClassB 都继承了 tBaseClass 的字段,这两个子类包含了 ijr 字段以及它们各自特有的字段。

为了确保继承正常工作,ijr 字段在所有子类中的偏移量必须与在 tBaseClass 中相同。这样,即使 EBX 指向的是 tChildClassAtChildClassB 类型的对象,像 mov((type tBaseClass [ebx]).i, eax); 这样的指令也能正确访问 i 字段。图 7-13 显示了子类和基类的布局。

请注意,两个子类中的新字段相互之间没有任何关联,即使它们的名称相同(例如,两个子类中的c字段并不位于相同的偏移量)。尽管两个子类共享它们从公共基类继承的字段,但它们添加的任何新字段都是唯一且独立的。如果两个类中的字段共享相同的偏移量,那只是巧合,前提是这些字段并未从公共基类继承。

所有类(即使它们彼此之间没有关系)都会在对象的相同偏移量处放置指向 VMT 的指针(通常是偏移量 0)。每个程序中的类都有一个唯一的 VMT;即使类从某个基类继承字段,它们的 VMT(通常)也会与基类的 VMT 不同。图 7-14 展示了 tBaseClasstChildClassA tChildClassB类型的对象如何指向它们各自的 VMT。

image

图 7-13:内存中基类和子类的布局

image

图 7-14:对象中的 VMT 引用

每当子类从某个基类继承字段时,子类的虚拟方法表(VMT)也会继承基类的虚拟方法表中的条目。例如,tBaseClass类的 VMT 只包含一个条目——指向方法tBaseClass.mBase()的指针。tChildClassA类的 VMT 包含两个条目:指向tBaseClass.mBase()tChildClassA.mA()的方法指针。由于tChildClassB没有定义任何新的方法或迭代器,它的 VMT 只包含一个条目:指向tBaseClass.mBase()方法的指针。请注意,tChildClassB的 VMT 与tBaseClass的 VMT 完全相同。尽管如此,HLA 仍然生成了两个不同的 VMT。图 7-15 展示了这种关系。

image

图 7-15:内存中基类和子类的布局

7.5.2 类构造函数

在你实际调用 VMT 中的任何方法之前,必须确保该表格已经存在于内存中(存储类中定义的方法地址),你还必须初始化你创建的每个类中的 VMT 指针字段。如果你使用的是高级语言(如 C++、C#、Java 或 Swift),编译器在你编译类定义时会自动为你生成 VMT。至于初始化对象本身中的 VMT 指针字段,通常由每个类的默认构造函数(对象初始化函数)来处理。所有这些工作对于高级语言程序员来说都是隐藏的。这就是为什么这些类示例使用 HLA 的原因——在汇编语言(即使是高级汇编语言)中,很少有东西是隐藏的。因此,在 HLA 示例中,你可以清楚地看到对象如何工作,以及使用它们的代价。

首先,HLA 不会自动为你创建 VMT。你必须在代码中显式声明每个你定义的类的 VMT。例如,对于student2student3示例,你可以按如下方式声明它们:

readonly

      VMT( student2 );

      VMT( student3 );

从技术上讲,这些不必出现在readonly部分(它们也可以出现在 HLA 的static部分);然而,你永远不会更改 VMT 的值,因此这个部分是声明它们的好地方。

本示例中的VMT声明定义了两个符号,你可以在 HLA 程序中访问:student2._VMT_student3._VMT_。这些符号对应于每个 VMT 中的第一个条目的地址。在你的代码中的某个地方(通常是在构造函数中),你需要使用相关类的 VMT 地址来初始化对象的 VMT 字段。HLA 的类构造函数约定如下所示:

procedure student2.create; @noframe;

begin create;

    push( eax );

    // ESI will contain NULL if this is called as "student2.create();"

    // ESI will not be NULL if you call create from an object reference,

    // such as "John.create();" (in which case ESI will point at the object,

    // John in this case).

    if( esi == NULL ) then

        // If a class call, allocate storage for the object

        // on the heap.

        mov( malloc( @size( student2 )), esi );

    endif;

    mov( &student2._VMT_, this._pVMT_ );

    // If you're going to initialize other fields of the class, do that here.

    pop( eax );

    ret();

end create;

procedure student3.create; @noframe;

begin create;

    push( eax );

    if( esi == NULL ) then

        mov( malloc( @size( student3 )), esi );

    endif;

    // Must call the base constructor to do any class initialization

    // it requires.

    (type student2 [esi]).create();  // Must call the base class constructor.

    // Might want to initialize any student3-specific fields (such 

    // as extra time) here:

    // student2.create filled in the VMT pointer with the address of the

    // student2 VMT. It really needs to point at the student3 VMT.

    // Fix that here.

    mov( &student3._VMT_, this._pVMT_ );

    pop( eax );

    ret();

end create;

student2.create()student3.create()类过程(在某些语言中也称为静态类方法函数)。类过程的主要特点是代码直接调用它们,而不是间接调用(即通过 VMT)。因此,如果你调用John.create()Joan.create(),你总是会调用student2.create()类过程。同样,如果你调用Jenny.create()——或者任何student3变量的create构造函数——你总是会调用student3.create()过程。

这两条语句:

mov( &student2._VMT_, this._pVMT_ );

mov( &student3._VMT_, this._pVMT_ );

将 VMT 的地址(针对给定类)复制到正在创建的对象的 VMT 指针字段(this._pVMT_)中。

注意student3.create()构造函数中的以下语句:

(type student2 [esi]).create();  // Must call the base class constructor.

到达这一点时,80x86 的 ESI 寄存器包含指向student3对象的指针。文本(type student2 [esi])将其类型转换为student2指针。这将最终调用父类的构造函数(以初始化基类中的任何字段)。

最后,考虑以下代码:

var

    John        :pointer to student2;

    Joan        :pointer to student2;

    Jenny       :pointer to student3;

        .

        .

        .

    student2.create(); // Equivalent to calling "new student2"

                       // in other languages.

    mov( esi, John );  // Save pointer to new student2

                       // object in John

    student2.create();

    mov( esi, Joan );

    student3.create();

    mov( esi, Jenny );

如果你查看JohnJoan对象中的_pVMT_条目,你会发现它们包含student2类的 VMT 地址。同样,Jenny对象的_pVMT_字段包含student3类的 VMT 地址。

7.5.3 多态性

如果你有一个 HLA student2变量(即一个包含指向student2对象的指针的变量),你可以使用以下 HLA 代码调用该对象的setName()方法:

John.setName("John");

Joan.setName("Joan");

这些特定的调用是 HLA 中高级活动的示例。HLA 编译器为这些语句中的第一个生成的机器代码大致如下所示:

mov( John, esi );

mov( (type student2 [esi])._pVMT_, edi );

call( [edi+0] );        // Note: the offset of the setName method in the VMT is 0.

下面是这段代码在做什么:

  1. 第一行将John指针所持有的地址复制到 ESI 寄存器中。这是因为在 80x86 架构中,大多数间接访问操作发生在寄存器中,而不是内存变量中。

  2. VMT 指针是student2对象结构中的一个字段。代码需要获取指向 VMT 中setName()方法的指针。对象的_pVMT_字段(在内存中)保存着 VMT 的地址。我们必须将其加载到寄存器中,以间接访问该数据。程序将 VMT 指针复制到 80x86 的 EDI 寄存器中。

  3. VMT(现在由 EDI 指向的内存位置)包含两个条目。第一个条目(偏移量 0)包含student2.setName()方法的地址;第二个条目(偏移量 4)包含student2.getName()方法的地址。因为我们想调用student2.setName()方法,所以该指令序列中的第三条指令调用了内存位置[edi+0]所指向地址处的方法。

如你所见,这比直接调用student.``setName()方法要复杂得多。为什么我们要这么做呢?毕竟我们知道JohnJoan都是student2对象。我们还知道Jennystudent3对象。所以我们本应该能直接调用student2.setName()student3.setName()方法。这将只需要一条机器指令,既更快又更简短。

进行所有这些额外工作的原因是为了支持多态性。假设我们声明了一个通用的student2对象:

var student:pointer to student2;

当我们将Jenny的值赋给student并调用student.setName()时会发生什么?嗯,代码序列与之前调用John的代码完全相同。也就是说,代码将student中的指针加载到 ESI 寄存器,将_pVMT_字段复制到 EDI 寄存器,然后通过 VMT 的第一个条目间接跳转(该条目指向setName()方法)。然而,这个例子和前一个例子之间有一个主要区别:在这种情况下,student指向内存中的一个student3对象。所以,当代码将 VMT 的地址加载到 EDI 寄存器时,EDI 实际上指向的是student3的 VMT,而不是student2的 VMT(就像我们使用John指针时的情况)。因此,当程序调用setName()方法时,实际上是在调用student3.setName()方法,而不是student2.setName()方法。这种行为是现代面向对象编程语言中多态的基础。

7.5.4 抽象方法和抽象基类

【抽象基类】(gloss01.xhtml#gloss01_2)仅存在于为其派生类提供一组共同字段。你永远不会声明类型为抽象基类的变量;你总是使用某个派生类。抽象基类是用于创建其他类的模板,仅此而已。

标准基类和抽象基类在语法上的唯一区别是至少有一个抽象方法声明。一个【抽象方法】(gloss01.xhtml#gloss01_3)是一个特殊方法,在抽象基类中没有实际的实现。任何尝试调用该方法的行为都会引发异常。如果你在想抽象方法到底有什么用,那就继续阅读吧。

假设你想创建一组类来存储数字值。一个类可以表示无符号整数,另一个类可以表示有符号整数,第三个类可以实现 BCD 值,第四个类可以支持real64值。虽然你可以创建四个独立的类,它们各自独立工作,但这样做错失了将这组类变得更易于使用的机会。为了理解为什么,请考虑以下 HLA 类声明:

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()方法在每个类中都有声明。^(6) 主要问题是这种方法不是通用的——也就是说,你不能创建一个指向“numeric”对象的通用指针,并对该值执行加法、减法和输出等操作(不管其底层的数字表示方式如何)。

我们可以通过将之前的类声明转换为一组派生类,轻松解决这两个问题。以下代码演示了实现这一点的简便方法:

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类型的指针,并用uintsintr64对象的地址来填充该指针。该指针可以调用numeric类中找到的方法来执行加法、减法或数值输出等功能。因此,使用这个指针的应用程序不需要知道确切的数据类型;它只需以通用的方式处理数值。

这个方案的一个问题是,可以声明和使用numeric类型的变量。不幸的是,这种numeric变量无法表示任何类型的数字(请注意,numeric字段的实际数据存储实际上出现在派生类中)。更糟糕的是,因为你已经在numeric类中声明了put()方法,所以实际上你必须编写一些代码来实现该方法,即使你不应该真正调用它;实际的实现应该只出现在派生类中。虽然你可以编写一个虚拟方法来打印错误消息(或者更好的是,抛出异常),但你不应该不得不这样做。幸运的是,没有理由这么做——如果你使用抽象方法的话。

HLA 的 abstract 关键字,如果出现在方法声明后面,表示你不会为这个类提供该方法的实现。相反,所有派生类都负责为抽象方法提供具体实现。如果你尝试直接调用抽象方法,HLA 会抛出异常。以下代码修改了 numeric 类,将 put() 方法转换为抽象方法:

type

     numeric: class

          method put; abstract;

          << Other common methods shared by all the classes >>

     endclass;

抽象基类至少有一个抽象方法。但你不必让抽象基类中的 所有 方法都是抽象的;在其中声明一些标准方法(当然,还可以提供它们的实现)是完全合法的。

抽象方法声明提供了一种机制,通过该机制,基类可以指定一些派生类必须实现的通用方法。如果派生类没有提供所有抽象方法的具体实现,它们自己也将成为抽象基类。

之前你读到过,不应该创建类型为抽象基类的变量。记住,如果你尝试执行抽象方法,程序会立即抛出异常,抱怨这个非法的函数调用。

7.6 C++ 中的类

到目前为止,所有的类和对象示例都使用了 HLA。这是有道理的,因为讨论的是类的低级实现,而 HLA 很好地展示了这一点。然而,你可能在编写的程序中永远不会使用 HLA。所以现在我们来看看高级语言是如何实现类和对象的。由于 C++ 是最早支持类的高级语言之一,我们将从它开始。

以下是 C++ 中 student2 类的一个变体:

class student2

{

    private:

        char    Name[65];

        short   Major;

        char    SSN[12];

        short   Midterm1;

        short   Midterm2;

        short   Final;

        short   Homework;

        short   Projects;

    protected:

        virtual void clearGrades();

    public:

        student2();

        ~student2();

        virtual void getName(char *name_p, int maxLen);

        virtual void setName(const char *name_p);

};

与 HLA 类的主要区别之一是 privateprotectedpublic 关键字的存在。C++ 及其他高级语言(HLL)都努力支持 封装(信息隐藏),而这三个关键字是 C++ 强制执行封装的主要工具之一。作用域、隐私和封装是有助于软件工程构造的语法问题,但它们实际上并不影响类和对象在内存中的 实现。因此,由于本书的重点是实现,我们将在 WGC4WGC5 中进一步讨论封装问题。

C++ 中 student2 对象在内存中的布局将与 HLA 变体非常相似(当然,不同的编译器可能会有不同的布局,但数据字段和虚拟方法表(VMT)的基本思想仍然适用)。

下面是 C++ 中继承的一个例子:

class student3 : public student2

{

    public:

        short extraTime;

        virtual void setName(char *name_p, int maxLen);

        student3();

        ~student3();

};

在 C++ 中,结构体和类几乎是相同的。两者之间的主要区别在于,类的默认可见性是 private,而 struct 的默认可见性是 public。因此,我们可以将 student3 类重写如下:

struct student3 : public student2

{

        short extraTime;

        virtual void setName(char *name_p, int maxLen);

        student3();

        ~student3();

};

7.6.1 C++ 中的抽象成员函数和类

C++有一种特别奇怪的声明抽象成员函数的方式——你需要在类中为函数定义添加“= 0;”,如下所示:

struct absClass

{

        int someDataField;

        virtual void absFunc( void ) = 0;

 };

与 HLA 类似,如果一个类包含至少一个抽象函数,那么该类就是一个抽象类。需要注意的是,抽象函数也必须是虚函数,因为它们必须在某个派生类中被重写才能有用。

7.6.2 C++中的多重继承

C++是为数不多的几种现代编程语言之一,支持多重继承;也就是说,一个类可以从多个类继承数据和成员函数。考虑以下 C++代码片段:

class a

{

    public:

        int i;

        virtual void setI(int i) { this->i = i; }

};

class b

{

    public:

        int j;

        virtual void setJ(int j) { this->j = j; }

};

class c : public a, public b

{

    public:

        int k;

        virtual void setK(int k) { this->k = k; }

};

在这个示例中,c类继承了来自ab类的所有信息。在内存中,典型的 C++编译器会创建如图 7-16 所示的对象。

image

图 7-16:多重继承的内存布局

VMT指针条目指向一个典型的 VMT,其中包含setI()setJ()setK()方法的地址(如图 7-17 所示)。如果你调用setI()方法,编译器会生成代码,将this指针加载为对象中VMT指针条目的地址(即图 7-16 中c对象的基地址)。当进入setI()方法时,系统认为this指向的是一个a类型的对象。特别地,this.VMT字段指向一个 VMT,其第一个(也是对于a类型来说唯一的)条目是setI()方法的地址。同样,在内存中的偏移量(this+4)处(因为VMT指针是 4 字节),setI()方法将找到i数据值。对于setI()方法来说,this指向的是一个a类型的对象(尽管它实际上指向的是一个c类型的对象)。

image

图 7-17:多重继承中的this

当你调用setK()方法时,系统还会传递c对象的基地址。当然,setK()方法期望的是一个类型为c的对象,而this指针指向的是类型为c的对象,因此对象中的所有偏移量都完全符合setK()的预期。需要注意的是,c类型的对象(以及c类中的方法)通常会忽略c对象中的VMT2指针字段。

问题出现在程序尝试调用 setJ() 方法时。因为 setJ() 属于类 b,它期望 this 持有指向类 b 的 VMT 指针的地址。它还期望在偏移量(this+4)处找到数据字段 j。如果我们将 c 对象的 this 指针传递给 setJ(),访问(this+4)将引用数据字段 i,而不是 j。此外,如果类 b 的某个方法调用了类 b 中的另一个方法(例如 setJ() 递归调用自身),VMT 指针将是错误的——它指向的是包含指向 setI() 的指针的 VMT,而类 b 期望它指向的是包含指向 setJ() 的指针的 VMT(偏移量为 0)。为了解决这个问题,典型的 C++ 编译器会在 c 对象中 j 数据字段之前插入一个额外的 VMT 指针。它将初始化这个第二个 VMT 字段,使其指向 c 的 VMT 中类 b 方法指针开始的位置(见 图 7-17)。当调用类 b 的方法时,编译器会生成代码,将 this 指针初始化为指向这个第二个 VMT 指针的地址(而不是指向内存中 c 类型对象的开头)。现在,在进入类 b 的方法时——比如 setJ()——this 将指向一个合法的 VMT 指针,并且 j 数据字段将出现在类 b 方法所期望的偏移量(this+4)处。

7.7 Java 中的类

Java 作为一种基于 C 的语言,其类定义与 C++ 有些相似(尽管 Java 不支持多重继承,并且具有更合理的抽象方法声明方式)。下面是一些 Java 类声明的示例,以帮助你理解它们是如何工作的:

public abstract class a

{

        int i;

        abstract void setI(int i);

};

public class b extends a

{

    int j;

    void setI( int i )

    {

        this.i = i;

    }

    void setJ(int j)

    {

        this.j = j; 

    }

};

7.8 Swift 中的类

Swift 也是 C 语言家族的一员。像 C++ 一样,Swift 允许使用 classstruct 关键字声明类。与 C++ 不同的是,Swift 的结构体和类是两种不同的概念。Swift 的结构体有点像 C++ 类的变量,而 Swift 类则类似于 C++ 中指向对象的指针。在 Swift 的术语中,结构体是 类型对象,类是 引用 类型对象。基本上,当你创建一个结构体对象时,Swift 会为整个对象分配足够的内存,并将这块存储与变量绑定起来。^(7) 与 Java 一样,Swift 不支持多重继承;仅支持单一继承。同时,注意 Swift 不支持抽象成员函数或类。下面是一对 Swift 类的示例:

class a

{

    var i: Int;

    init( i:Int )

    {

        self.i = i;

    }

    func setI( i :Int )

    {

        self.i = i;

    }

};

class b : a

{

    var j: Int = 0;

    override func setI( i :Int )

    {

    self.i = I;

    }

    func setJ( j:Int)

    {

        self.j = j;

    }

};

在 Swift 中,所有成员函数默认都是虚拟函数。此外,init() 函数是 Swift 的构造函数。析构函数的名称是 deinit()

7.9 协议和接口

Java 和 Swift 不支持多重继承,因为它存在一些逻辑问题。经典的例子是“钻石格”数据结构。这发生在两个类(比如 bc)都从同一个类(比如 a)继承信息,然后一个第四个类(比如 d)从 bc 继承。因此,da 继承数据两次——一次通过 b,一次通过 c

尽管多重继承可能会导致一些奇怪的问题,比如这样的问题,但毫无疑问,能够从多个地方继承通常是有用的。因此,像 Java 和 Swift 这样的语言的解决方案是允许一个类从多个来源继承方法或函数,但只从一个祖先类继承数据字段。这避免了多重继承的大部分问题(特别是继承的数据字段的模糊选择),同时允许程序员从不同来源包含方法。Java 称这种扩展为 接口,Swift 称其为 协议

这是几个 Swift 协议声明和支持该协议的类的示例:

protocol someProtocol

{

    func doSomething()->Void;

    func doSomethingElse() ->Void;

}

protocol anotherProtocol

{

    func doThis()->Void;

    func doThat()->Void;

}

class supportsProtocols: someProtocol, anotherProtocol

{

    var i:Int = 0;

    func doSomething()->Void

    {

        // appropriate function body

    }

    func doSomethingElse()->Void

    {

        // appropriate function body

    }

    func doThis()->Void

    {

        // appropriate function body

    }

    func doThat()->Void

    {

        // appropriate function body

    }}

}

Swift 协议不提供任何函数。相反,支持协议的类承诺提供协议所指定的函数的实现。在前面的示例中,supportsProtocols 类负责提供它所支持的协议所要求的所有函数。实际上,协议就像只包含抽象方法的抽象类——继承类必须为所有抽象方法提供实际的实现。

这是之前示例在 Java 中的代码,它演示了相应的机制——接口:

class InterfaceDemo {

    interface someInterface

    {

        public void doSomething();

        public void doSomethingElse();

    }

    interface anotherInterface

    {

        public void doThis();

        public void doThat();

    }

    class supportsInterfaces implements someInterface, anotherInterface

    {

        int i;

        public void doSomething()

        {

                // appropriate function body

        }

        public void doSomethingElse()

        {

                // appropriate function body

        }

        public void doThis()

        {

                // appropriate function body

        }

        public void doThat()

        {

                // appropriate function body

        }

    }

    public static void main(String[] args) {

    System.out.println("InterfaceDemo");

    }

}

接口和协议在行为上有些类似于 Java 和 Swift 中的基类类型。如果你实例化一个类对象,并将该实例分配给一个接口/协议类型的变量,你可以执行该接口或协议所支持的成员函数。考虑以下 Java 示例:

someInterface some = new supportsInterfaces();

// We can call the member functions defined for someInterface:

some.doSomething();

some.doSomethingElse();

// Note that it is illegal to try and call doThis 

// or doThat (or access the i data field) using

// the "some" variable.

这是一个在 Swift 中的类似示例:

import Foundation

protocol a

{

    func b()->Void;

    func c()->Void;

}

protocol d

{

    func e()->Void;

    func f()->Void;

}

class g : a, d

{

    var i:Int = 0;

    func b()->Void {print("b")}

    func c()->Void {print("c")}

    func e()->Void {print("e")}

    func f()->Void {print("f")}

    func local()->Void {print( "local to g" )}

}

var x:a = g()

x.b()

x.c()

你通过指向包含该协议或接口中声明的函数地址的 VMT 来实现一个协议或接口。因此,在前面的示例中,Swift g 类的数据结构会有三个 VMT 指针——一个指向协议 a,一个指向协议 d,一个指向类 g(保存指向 local() 函数的指针)。

当你创建一个类型为协议/接口的变量(在前面的示例中是 x)时,变量会保存该协议的 VMT 指针。在当前示例中,将 g() 赋值给 x 变量实际上只是将协议 a 的 VMT 指针复制到 x 中。然后,当代码执行 x.bx.c 时,它会从 VMT 中获取实际函数的地址。

7.10 泛型和模板

尽管类和对象允许软件工程师以面向对象编程无法实现的方式扩展他们的系统,但对象并没有提供完全通用的解决方案。泛型,最早由 ML 编程语言在 1973 年引入,并通过 Ada 编程语言普及,为扩展性提供了面向对象编程所缺失的关键功能。今天,大多数现代编程语言——C++(模板)、Swift、Java、HLA(通过宏)和 Delphi——都支持某种形式的泛型编程。在泛型编程风格中,你会开发能够操作任意数据类型的算法,这些数据类型将在未来定义,并且在使用泛型类型之前立即提供实际的数据类型。

经典的例子是链表。编写一个简单的单链表类非常容易——比如,管理一个整数列表。然而,在创建了整数列表后,你决定需要一个双精度浮点数的列表。只需快速复制粘贴(再把节点类型从 int 改成 double),你就得到了一个处理双精度浮点数链表的类。等等,现在你需要一个字符串列表?再来一次复制粘贴,你就得到了字符串列表。现在你需要一个对象列表?好吧,再来一次复制粘贴……你明白了吧。不久之后,你就创建了半打不同的列表类,结果,哦不,你在原始实现中发现了一个 bug。现在,你得回去修复所有你创建的列表类中的这个 bug。如果你在多个不同的项目中使用了这些列表实现(你刚刚发现了“复制粘贴”编程为什么不被认为是优秀代码)。

泛型(C++ 模板)来解救你了。通过泛型类定义,你只需指定操作列表的算法(方法/成员函数);你无需担心节点类型。当你声明泛型类类型的对象时,再填充节点类型。要创建整数、双精度浮点数、字符串或对象列表,你只需向泛型列表类提供你想要的类型,仅此而已。如果你在原始(泛型)实现中发现了 bug,你只需修复该缺陷并重新编译代码;在你使用泛型类型的每个地方,编译都会应用修正。

这里是一个 C++ 节点和列表定义:

template< class T >

class node {

  public:

    T data;

  private:

    node< T > *next;

};

template< class T >

class list {

  public:

    int  isEmpty();

    void append( T data );

    T    remove();

    list() { 

      listEnd = new node< T >(); 

      listEnd->next = listEnd; 

    }

  private:

    node< T >* listEnd;

};

这段 C++ 代码中的<T>序列是一个 参数化类型。这意味着你将提供一个类型,编译器将在模板中看到 T 的每个位置时都用该类型进行替换。因此,在前面的代码中,如果你提供 int 作为参数类型,C++ 编译器将用 int 替换所有的 T。要创建一个整数和双精度浮点数的列表,你可以使用以下 C++ 代码:

#include <iostream>

#include <list>

using namespace std;

int main(void) {

    list< int > integerList;

    list< double > doubleList;

    integerList.push_back( 25 );

    integerList.push_back( 0 );

    doubleList.push_back( 1.2 );

    doubleList.push_back( 3.14 );

    cout << "integerList.size() " << integerList.size() << endl;

    cout << "doubleList.size()  " << doubleList.size()  << endl;

    return 0;

}

    doubleList.add( 3.14 );

实现泛型的最简单方法是使用宏。当编译器看到类似list <int> integerList;的声明时,它会展开相关的模板代码,并将int替换为T,在整个展开过程中如此。

因为模板展开可能会生成大量代码,现代编译器尽可能优化这一过程。例如,如果你声明两个变量如下:

list <int> iList1;

list <int> iList2;

其实没有必要为两种类型为intlist类分别创建两个独立的类。显然,模板展开结果是相同的,因此任何一个合格的编译器都会对这两个声明使用相同的类定义。

即使是更智能的编译器,也会意识到某些函数(如remove())其实并不关心底层节点的数据类型。基本的移除操作对于所有数据类型都是相同的;由于列表数据类型使用指针指向节点数据,所以没有理由为每种类型生成不同的remove()函数。借助多态性,一个remove()成员函数就能正常工作。编译器识别这一点需要更多的复杂度,但这是完全可行的。

然而,最终模板/泛型展开是一个宏展开过程。任何其他发生的事情仅仅是编译器的优化。

7.11 更多信息

Hyde, Randall. 汇编语言的艺术。第二版。旧金山:No Starch Press,2010 年。

Knuth, Donald. 计算机程序设计的艺术, 第一卷:基础算法。第三版。波士顿:Addison-Wesley Professional,1997 年。

第八章:布尔逻辑与数字设计**

Image

布尔逻辑是现代计算机系统中计算的基础。你可以使用布尔方程系统表示任何算法或任何电子计算机电路。因此,要充分理解软件如何运作,你需要理解基本的布尔逻辑和数字设计。

这部分内容对那些想设计电子电路或编写控制它们的软件的人尤其重要。即使你不打算做这些,你仍然可以利用布尔逻辑的知识来优化你的软件。许多高级语言处理布尔表达式,如控制 if 语句或 while 循环的表达式。理解布尔逻辑为你提供了优化布尔表达式并提高 HLL 代码性能所需的工具。

本章涵盖以下内容,这些内容将帮助你在优化布尔表达式时提供帮助:

  • 布尔代数、布尔运算符和布尔函数

  • 布尔公理与定理的介绍

  • 真值表与布尔函数优化

  • 规范形式

  • 电子电路及其布尔函数对照

虽然如果你只是想编写典型的程序,布尔代数和数字电路设计的详细知识并非必需,但熟悉这些主题将有助于回答为什么 CPU 制造商以某种方式实现指令的问题——这些问题无疑会在我们开始研究 CPU 的低级实现时出现。

8.1 布尔代数

布尔代数是一个演绎的数学系统。二元运算符(°)接受一对布尔输入并生成一个布尔输出。例如,布尔与运算符接受两个布尔输入并生成一个布尔输出(两个输入的逻辑与运算)。

8.1.1 布尔运算符

为了我们的目的,我们将布尔代数基于以下一组值和运算符:

  • 布尔系统中的两个可能的值是 0 和 1。通常,我们分别称这些值为falsetrue

  • • 符号表示逻辑与运算。AB 是对布尔值 AB 进行逻辑与运算的操作,也称为 AB。对于单字母变量名,本文省略了•符号;因此,AB 也表示变量 AB 的逻辑与。

  • +(加号)表示逻辑或运算。A + B 是对布尔值 AB 进行逻辑或运算的结果。我们也称之为 AB

  • 逻辑补数、逻辑否定和 NOT 是同一个一元运算符的不同名称。本章将使用'(撇号符号)来表示逻辑否定。A' 表示 A 的逻辑非。

8.1.2 布尔公理

每个代数系统都遵循一组特定的初始假设,或 公理。你可以从这一基本公理集中推导出额外的规则、定理和其他系统特性。布尔代数采用以下公理:

闭包 如果对于每一对布尔值,布尔系统在特定的二元运算符下只产生布尔结果,则该布尔系统对该运算符是 闭包 的。

交换律 如果对于所有可能的布尔值 ABA ° B = B ° A,则二元运算符 ° 是 交换律 的。

结合律 如果对于所有布尔值 ABC,(A ° B) ° C = A ° (B ° C),则二元运算符 ° 是 结合律 的。

分配律 两个二元运算符 ° 和 % 是 分配律 的,如果对于所有布尔值 ABCA ° (B % C) = (A ° B) % (A ° C)。

单位元素 如果对于所有布尔值 AA ° I = A,则布尔值 I 被称为某个二元运算符 ° 的 单位元素

逆元 如果对于所有布尔值 ABA ° I = BB ° A(即 BA 在布尔系统中的对立值),则布尔值 I 被称为关于某个二元运算符 ° 的 逆元

当应用于布尔运算符时,上述公理产生以下一组 布尔公理

P1 布尔代数在与 AND、OR 和 NOT 操作下是封闭的。

P2 AND(•)的单位元素是 1,OR(+)的单位元素是 0。逻辑非(')没有单位元素。

P3 • 和 + 运算符是交换律的。

P4 • 和 + 在彼此之间是分配律的。即,A • (B + C) = (AB) + (AC) 和 A + (BC) = (A + B) • (A + C)。

P5 • 和 + 都是结合律的。即,(AB) • C = A • (BC) 和 (A + B) + C = A + (B + C)。

P6 对于每个值 A,都存在一个值 A',使得 AA' = 0 和 A + A' = 1。这个值是 A 的逻辑补码(或 NOT)。

你可以使用这一组布尔公理证明所有其他的布尔代数定理。本章不会深入讨论以下定理的正式证明,但对它们的熟悉将是有用的:

Th1 A + A = A

Th2 AA = A

Th3 A + 0 = A

Th4 A • 1 = A

Th5 A • 0 = 0

Th6 A + 1 = 1

Th7 (A + B)' = A'B'

Th8 (AB)*' = A' + B'

Th9 A + AB = A

Th10 A • (A + B) = A

Th11 A + A'B = A + B

Th12 A' • (A + B') = A'B'

Th13 AB + AB' = A

Th14 (A' + B' ) • (A' + B) = A'

Th15 A + A' = 1

Th16 AA' = 0

注意

定理 7 和 8 被称为 德摩根定理 ,得名于发现这些定理的数学家。

布尔代数系统中的一个重要原则是对偶性。每一对定理(定理 1 和 2,定理 3 和 4,等等)形成一个对偶。你可以通过交换表达式中的运算符和常数,创造出任何有效的布尔代数表达式,结果仍然有效。具体来说,如果交换•和+运算符并且交换表达式中的 0 和 1 值,得到的表达式将遵循布尔代数的所有规则。这并不意味着对偶表达式计算相同的值,仅仅表示两者在布尔代数系统中都是合法的。

8.1.3 布尔运算符优先级

如果在一个布尔表达式中出现多个不同的布尔运算符,表达式的结果取决于运算符的优先级。以下布尔运算符按从高到低的优先级排序:

  • 括号

  • 逻辑非

  • 逻辑与

  • 逻辑或

逻辑与和或运算符是左结合的。这意味着如果两个运算符具有相同的优先级出现在三个操作数之间,你必须从左到右评估表达式。逻辑非运算是右结合的,虽然无论使用左结合还是右结合,它都会产生相同的结果,因为它是一个一元运算符,只有一个操作数。

8.2 布尔函数与真值表

一个布尔表达式是由 0、1 和字面量通过布尔运算符连接而成的序列。布尔字面量是带有否定符号(取反)或不带否定符号的变量名,所有的变量名都是单个字母字符。布尔函数是特定的布尔表达式;我们通常将布尔函数命名为F,并可能带有下标。例如,考虑以下布尔函数:

F[0] = AB + C

这个函数计算AB的逻辑与,并将这个结果与C进行逻辑或运算。如果A = 1,B = 0,C = 1,那么F[0]返回 1(1 • 0 + 1 = 1)。

你也可以使用真值表表示布尔函数。逻辑与和或函数的真值表分别显示在表 8-1 和 8-2 中。

表 8-1: 与运算真值表

0 1
0 0 0
1 0 1

表 8-2: 或运算真值表

0 1
0 0 1
1 1 1

对于二元运算符和两个输入变量,这种真值表格式非常直观和方便。然而,对于涉及多个变量的函数,它并不适用。

表 8-3 展示了另一种表示真值表的方式。这种格式有几个优点——它更容易填写表格,支持三个或更多变量,并且为两个或更多函数提供了紧凑的表示。

表 8-3: 三变量函数的真值表格式

C B A F = ABC F = AB + C F = A + BC
0 0 0 0 0 0
0 0 1 0 0 1
0 1 0 0 0 0
0 1 1 0 1 1
1 0 0 0 1 0
1 0 1 0 1 1
1 1 0 0 1 1
1 1 1 1 1 1

尽管你可以创建无限多种布尔函数,但它们并非都唯一。例如,F = AF = AA 是两个不同的函数。然而,依据定理 2,可以轻松证明无论你为 A 提供何种输入值,这两个函数的结果完全相同。事实证明,如果你固定输入变量的数量,那么可能的唯一布尔函数数量是有限的。例如,两个输入变量的布尔函数有 16 种唯一可能,三个输入变量的布尔函数有 256 种可能。给定 n 个输入变量,存在 2(2(n)) 种唯一布尔函数(2 的 2 的 n 次方)。对于两个输入变量,有 2²² 或 16 种不同的函数;对于三个输入变量,有 2²³ 或 256 种可能的函数;四个输入变量有 2²⁴ 或 2¹⁶ 或 65,536 种唯一布尔函数。

当只处理 16 个布尔函数(两个输入变量)时,我们可以为每个唯一函数命名(见 表 8-4)。

表 8-4: 两个变量的布尔函数常见名称

函数编号^(1) 函数名称 描述
0 零(清除) 无论 AB 的输入值如何,始终返回 0。
1 逻辑非或 (NOT (A OR B)) = (A + B)'
2 抑制 (AB') 抑制 = AB**' (A AND not B)。也等同于 A > BB < A
3 B 忽略 A,返回 B**'.
4 抑制 (BA**') 抑制 = BA**' (B AND not A)。也等同于 B > AA < B
5 A 返回 A**' 并忽略 B
6 异或(XOR) AB。等同于 AB
7 逻辑与非 (NOT (A AND B)) = (AB)'
8 逻辑与 AB = (A AND B)
9 等价(异或非) (A = B)。也称为异或非(不是异或)。
10 A 复制 A。返回 A 的值,忽略 B 的值。
11 蕴涵,B 蕴涵 A A + B**'.(如果 BA。)等价于 BA
12 B 复制 B。返回 B 的值,忽略 A 的值。
13 蕴涵,A 蕴涵 B B + A**'.(如果 AB。)等价于 AB
14 逻辑或 A + B。返回 AB
15 一(集合) 无论 AB 的输入值如何,始终返回 1。

8.3 函数编号

对于两个输入变量以上的情况,函数的种类太多,以至于无法为每一个函数提供具体的名称。即使是针对两个输入变量的函数,我们也通常引用函数的编号而不是其名称。例如,F[8]表示两个输入函数的逻辑与操作(AB的与),而F[14]表示逻辑或操作。当然,对于超过两个输入变量的函数,问题是:“我们如何确定一个函数的编号?”例如,函数F = AB + C对应的编号是多少?我们通过查看该函数的真值表来计算答案。如果我们将ABC的值视为一个二进制数中的位,其中C是高位,A是低位,它们会产生对应于 0 到 7 范围内的二进制字符串。每个二进制字符串关联的函数结果要么是 0,要么是 1。如果我们通过将每个组合的ABC输入值的函数结果放置到由ABC的二进制字符串指定的位位置中,最终得到的二进制数将是相应的函数编号。如果这个理解不清楚,下面的例子会帮助你理清楚。考虑F = AB + C的真值表(见表 8-5)。

表 8-5: F = AB + C 的真值表

C B A F = AB + C
0 0 0 0
0 0 1 0
0 1 0 0
0 1 1 1
1 0 0 1
1 0 1 1
1 1 0 1
1 1 1 1

输入变量CBA结合形成二进制数序列,范围从%000%111(即从 0 到 7)。如果我们用这些值表示一个 8 位值中的位编号(CBA = %111表示位 7,CBA = %110表示位 6,以此类推),我们可以通过将每个对应的CBA值组合的F = AB + C的结果放入每个位位置来确定函数编号:

CBA:           7    6    5    4    3    2    1    0

F = AB + C:    1    1    1    1    1    0    0    0

现在,如果我们将这个比特串视为一个二进制数,它会产生函数编号$F8,即 248。我们通常使用十进制表示函数编号。这也为为什么存在 2(2(n))个不同的函数提供了洞察力:如果你有n个输入变量,那么有 2^(n)种不同的变量值组合,因此在函数的二进制数中有 2^(n)位。如果你有m位,那么这些位有 2^(m)种不同的排列方式。因此,对于n个输入变量,存在m = 2^(n)个可能的位和 2^(m)或 2(2(n))个可能的函数。

8.4 布尔表达式的代数运算

你可以通过应用布尔代数的公理和定理,将一个布尔表达式转换为等效表达式。如果你想将给定表达式转换为规范形式(参见下一节),或如果你希望最小化表达式中的文字或项的数量,这一点非常重要。(文字是一个带或不带撇号的变量,是一个变量或多个不同文字的积——逻辑与运算。)电路通常由实现每个文字或项的单独组件组成,因此,最小化表达式中的文字和项的数量,可以让电路设计师使用更少的电气组件,从而减少系统的成本。

不幸的是,没有固定的规则可以应用于优化给定的表达式。就像构造数学证明一样,个人轻松完成这些转换的能力通常是经验问题。尽管如此,几个例子展示了其可能性:

ab + ab' + a'b            =  a(b + b') + a'b             By P4

                          =  a • 1 + a'b                 By P5

                          =  a + a'b                     By Th4

                          =  a + b                       By Th11

(a'b + a'b' + b')'        =  ( a'(b + b') + b')'         By P4

                          =  (a'• 1 + b')'               By P5

                          =  (a' + b')                   By Th4

                          =  ( (ab)' )'                  By Th8

                          =  ab                          By definition of not

b(a + c) + ab' + bc' + c  =  ba + bc + ab' + bc' + c     By P4

                          =  a(b + b') + b(c + c') + c   By P4

                          =  a • 1 + b • 1 + c           By P5

                          =  a + b + c                   By Th4

8.5 规范形式

每个布尔函数都有无数个等效的逻辑表达式。为了帮助消除混淆,逻辑设计师通常使用规范或标准化的形式来指定布尔函数。对于每个不同的布尔函数,我们可以从定义的集合中选择一个规范表示。

有几种方法可以为所有可能的 n 个变量的布尔函数定义一组规范表示。在每个规范集合中,单个表达式描述系统中的每个布尔函数,因此集合中的所有函数都是唯一的。本章将讨论两种规范系统——最小项和合项的和(sum of minterms)和最大项和的积(product of maxterms),但我们只会使用第一个。利用对偶原理,我们可以在这两个系统之间进行转换。

如前所述,项是单一文字或多个不同文字的积(逻辑与)。例如,如果你有两个变量,AB,则有八个可能的项:ABA'B'A'B'A'BAB'AB。对于三个变量,我们有 26 个不同的项:ABCA'B'C'A'B'A'BAB'ABA'C'A'CAC'ACB'C'B'CBC'BCA'B'C'AB'C'A'BC'ABC'A'B'CAB'CA'BCABC。随着变量数量的增加,项的数量会急剧增加。最小项是一个包含恰好 n 个文字的积,其中 n 是输入变量的数量。例如,两个变量 AB 的最小项是 A'B'AB'A'BAB。同样,三个变量 ABC 的最小项是 A'B'C'AB'C'A'BC'ABC'A'B'CAB'CA'BCABC。一般来说,对于 n 个变量,有 2^(n) 个最小项。最小项的集合很容易生成,因为它们对应于二进制数的序列(见 表 8-6)。

表 8-6: 从二进制数生成最小项

二进制等价(CBA 最小项
000 A'B'C'
001 AB'C'
010 A'BC'
011 ABC'
100 A'B'C
101 AB'C
110 A'BC
111 ABC

我们可以通过将最小项之和(逻辑“或”)来推导出 任何 布尔函数的标准形式。给定 F[248] = AB + C,其等效的标准形式为 ABC + A'BC + AB'C + A'B'C + ABC'。从代数角度,我们可以如下展示标准形式等价于 AB + C

ABC + A'BC + AB'C + A'B'C + ABC'  =  BC(A + A') + B'C(A + A') + ABC'   By P4

                                  =  BC • 1 + B'C • 1 + ABC'           By Th15

                                  =  C(B + B') + ABC'                  By P4

                                  =  C + ABC'                          By Th15 & Th4

                                  =  C + AB                            By Th11

显然,标准形式并不是最优的。然而,从标准形式生成函数的真值表非常容易。从真值表生成最小项之和的标准形式方程也非常容易。

8.5.1 最小项之和的标准形式与真值表

从最小项之和的标准形式构建真值表,请按照以下步骤操作:

  1. 将最小项转换为二进制等价物,通过将未加撇号的变量替换为 1,将加撇号的变量替换为 0,如下所示:

    F248 = CBA + CBA' + CB'A + CB'A' + C' BA
    
         = 111 + 110  + 101  + 100   + 011
    
  2. 在函数列中为适当的最小项条目放置 1:

    C B A F = AB + C
    0 0 0
    0 0 1
    0 1 0
    0 1 1 1
    1 0 0 1
    1 0 1 1
    1 1 0 1
    1 1 1 1
  3. 最后,为剩余的条目在函数列中放置数字 0:

    C B A F = AB + C
    0 0 0 0
    0 0 1 0
    0 1 0 0
    0 1 1 1
    1 0 0 1
    1 0 1 1
    1 1 0 1
    1 1 1 1

从另一个方向出发,要从真值表生成逻辑函数,请按照以下步骤操作:

  1. 找到真值表中函数结果为 1 的所有条目。在此表中,这些条目是最后五个条目。包含 1 的表格条目数量决定了标准方程中的最小项数量。

  2. 通过将 ABC 替换为 1,将 A'B'C' 替换为 0,生成单独的最小项。在此示例中,当 CBA 等于 111、110、101、100 或 011 时,F[248] 的结果为 1。因此,F[248] = CBA + CBA' + CB'A + CB'A' + C'AB

  3. 可以选择重新排列最小项内的项,并重新排列整个函数中的最小项。这是可行的,因为逻辑“或”和逻辑“与”运算都具有交换性。

这个过程同样适用于任何数量的变量,就像在表 8-7 中所示的函数 F[53,504] = ABCD + A'BCD + A'B'CD + A'B'C'D

表 8-7: F[53,504] 的真值表

D C B A F = ABCD + A'BCD + A'B'CD + A'B'C'D
0 0 0 0 0
0 0 0 1 0
0 0 1 0 0
0 0 1 1 0
0 1 0 0 0
0 1 0 1 0
0 1 1 0 0
0 1 1 1 0
1 0 0 0 1
1 0 0 1 0
1 0 1 0 0
1 0 1 1 0
1 1 0 0 1
1 1 0 1 0
1 1 1 0 1
1 1 1 1 1

也许生成布尔函数的标准形式最简单的方法是首先生成它的真值表,然后从真值表构建标准形式。实际上,我们将在两种标准形式之间转换时使用这种技术。

8.5.2 代数推导的最小项和标准形式

为了代数地生成最小项和的标准形式,我们使用分配律和定理 15(A + A' = 1)。考虑F[248] = AB + C。该函数包含两个项,ABC,但它们不是最小项。我们可以将第一个项转换为最小项和,如下所示:

AB    =  AB • 1            By Th4

      =  AB • (C + C')     By Th15

      =  ABC + ABC'        By distributive law

      =  CBA + C'BA        By associative law

类似地,我们可以将F[248]中的第二项转换为最小项和的形式,如下所示:

C     =  C • 1                              By Th4

      =  C • (A + A')                       By Th15

      =  CA + CA'                           By distributive law

      =  CA • 1 + CA' • 1                   By Th4

      =  CA • (B + B') + CA' • (B + B')     By Th15

      =  CAB + CAB' + CA'B + CA'B'          By distributive law

      =  CBA + CBA' + CB'A + CB'A'          By associative law

在这两个转换中的最后一步(重新排列项)是可选的。为了得到F[248]的最终标准形式,我们将这两个转换的结果求和:

F248    =  (CBA + C'BA) + (CBA + CBA' + CB'A + CB'A')

       =  CBA + CBA' + CB'A + CB'A' + C'BA

8.5.3 最大项积标准形式

另一种标准形式是最大项积。最大项是所有输入变量的和(逻辑或),无论是否加上取反符号。例如,考虑以下三个变量的逻辑函数,G,其为最大项积形式:

G = (A + B + C) • (A' + B + C) • (A + B' + C)

与最小项和的形式一样,每个可能的逻辑函数都有一个唯一的最大项积。每个最大项积形式都有一个等效的最小项和形式。事实上,示例中的函数G等价于之前F[248]的最小项和形式:

F248 = CBA + CBA' + CB'A + CB'A' + C'BA = AB + C

为了从最大项积生成真值表,你可以使用对偶原理;即,交换 AND 为 OR,0 为 1(反之亦然)。因此,构建真值表时,你首先要交换取反和非取反的字面量。在G中,这将得到:

G = (A' + B' + C') • (A + B' + C') • (A' + B + C')

下一步是交换逻辑或(OR)和逻辑与(AND)运算符,得到以下结果:

G = A'B'C' + AB'C' + A'BC'

最后,你需要交换所有的 0 和 1。这意味着,对于之前列出的每个最小项,你需要将 0 存入真值表的函数列,然后将真值表的其余函数列填入 1。这将会在真值表的第 0、1、2 行放入 0,剩余项填入 1,从而得到F[248]。

你可以通过生成其中一个形式的真值表并反向操作来轻松转换这两种标准形式。考虑两个变量的函数,F[7] = A + B。其最小项和形式为F[7] = A'B + AB' + AB。真值表见表 8-8。

表 8-8: 两变量的 OR 真值表

A B F7
0 0 0
1 0 1
0 1 1
1 1 1

从后向前推导出最大项的积,我们首先定位真值表中所有结果为 0 的条目。AB都等于 0 的条目是唯一一个结果为 0 的条目。这给了我们G = A' B'的第一步。然而,我们仍然需要将所有变量取反,得到G = AB。根据对偶性原则,我们还需要交换逻辑或(OR)和逻辑与(AND)运算符,得到G = A + B。这就是典型的最大项积形式。

8.6 布尔函数的简化

由于布尔函数的变量数是无限多样的,但唯一的布尔函数数量是有限的,你可能会想是否有某种方法能够简化给定的布尔函数,从而得到最优形式——即包含最少运算符的表达式。对于所有逻辑函数,都必定存在最优形式,但我们不会在规范形式中使用它,原因有二。首先,虽然在真值表形式和规范形式之间转换很容易,但从真值表生成最优形式并不那么容易。其次,对于一个函数,可能会有多个最优形式。

你可以尝试使用代数变换来产生最优形式,但不能保证你会得到最佳结果。有两种方法总是将给定的布尔函数简化为最优形式:映射方法和最小因子方法。本书介绍的是映射方法。

使用映射方法手动优化布尔函数对于二、三、四个变量的函数是可行的,但对于五个或六个变量的函数来说,它是可行但繁琐的。对于超过六个变量的函数,你应该编写程序。

映射方法的第一步是为该函数构建一个特殊的二维真值表(参见图 8-1)。仔细观察这些真值表。它们并没有使用本章前面展示的相同形式。特别地,2 位值的进展顺序是 00、01、11、10,而不是 00、01、10、11。这一点非常重要!如果你按二进制顺序组织真值表,映射优化方法将无法正常工作。我们将此称为真值映射,以便与标准真值表区分开来。^(2)

image

图 8-1:二、三、四变量真值映射

假设你的布尔函数已经是最小项和式的规范形式,则为与该函数的每个最小项相对应的真值映射单元插入 1。其他地方插入 0。例如,考虑三变量函数F = C'B'A + C'BA' + C'BA + CB'A' + CB'A + CBA' + CBA。图 8-2 展示了该函数的真值映射。

image

图 8-2: F = C'B'A + C'BA' + C'BA + CB'A' + CB'A + CBA' + CBA 的真值映射

下一步是围绕 1 的矩形组画出轮廓。你围起来的矩形必须是边长为 2 的幂。对于三个变量的布尔函数,矩形的边长可以是 1、2 和 4。你画出的矩形集合必须围绕真值图中所有包含 1 的单元格。诀窍是画出所有可能的矩形,除非一个矩形完全被另一个矩形包围,但同时要画出尽可能少的矩形。请注意,矩形可以重叠,只要一个矩形没有完全包含另一个矩形。在图 8-3 的真值图中,有三个这样的矩形。

image

图 8-3:在真值图中围绕 1 的矩形组

每个矩形代表简化后的布尔函数中的一个项。因此,简化后的布尔函数将只包含三个项。你可以通过去除矩形内同时出现正负形式的变量来构建每个项(因为正负形式会相互抵消)。在图 8-3 中,那个长条形矩形位于C = 1 的行,其中同时包含AB的正负形式。因此,我们可以从该项中去除AB。由于该矩形位于C = 1 区域,它代表的是单一项C

图 8-3 中的浅灰色方块包含CC'BB'A。因此,它代表单一项A。同样,图 8-3 中的深灰色方块包含CC'AA'B,因此它代表单一项B

最终的最优函数是由三个方块所代表的项的总和(逻辑或),即F = A + B + C。你无需考虑剩余的包含 0 的方块。

真值图形成了一个环面(类似甜甜圈的形状)。图的右边缘连接到左边缘,反之亦然。同样,顶部边缘连接到底部边缘。这为在真值图中围绕 1 的组画出矩形提供了更多可能性。考虑布尔函数F = C'B'A' + C'BA' + CB'A' + CBA'。该函数的真值图如图 8-4 所示。

image

图 8-4:F = C'B'A' + C'BA' + CB'A + CBA' 的真值图

初看之下,你可能认为最小的矩形数量是两个,如图 8-5 所示。

image

图 8-5:首次尝试围绕由 1 形成的矩形

然而,由于真值图是一个连续的对象,右侧和左侧相连接,因此我们实际上可以形成一个单一的正方形矩形,如图 8-6 所示。

image

图 8-6:函数的正确矩形

为什么在真值图中我们有一个矩形或两个矩形很重要呢?矩形越大,它们能够消去的项就越多。因此,矩形越少,最终布尔函数中出现的项就越少。

图 8-5 中的两个矩形示例生成了一个包含两个项的函数。左侧的矩形消除了C变量,留下了A'B'作为它的项。右侧的矩形也消除了C变量,留下了项BA'。因此,这个真值图将产生方程F = A'B' + A'B。我们知道这是不最优的(参见定理 13)。

现在考虑图 8-6 中的真值图。这里我们只有一个矩形,所以我们的布尔函数将只有一个项。因为这个矩形包括了CC',还包括了BB',所以剩下的唯一项就是A'。因此,这个布尔函数简化为F = A'

映射方法不能正确处理的真值图只有两种类型:包含所有 0 的真值图或包含所有 1 的真值图。这两种情况对应于布尔函数F = 0 和F = 1(即函数编号为 0 或 2^(n) – 1)。当你遇到这两种真值图时,你就知道如何最优化地表示该函数。

在使用映射方法优化布尔函数时,请记住你总是要选择边长是 2 的幂的最大矩形。即使是重叠的矩形也必须这样做(除非一个矩形完全包含另一个矩形)。考虑布尔函数F = C'B'A' + C'BA' + CB'A' + C'AB + CBA' + CBA。这将生成图 8-7 中的真值图。

image

图 8-7:F = C'B'A' + C'BA' + CB'A' + C'AB + CBA' + CBA 的真值图

初步的诱惑是创建图 8-8 中找到的矩形集合。然而,正确的映射出现在图 8-9 中。

image

图 8-8:矩形的显而易见选择

image

图 8-9:F = C'B'A' + C'BA' + CB'A' + C'AB + CBA' + CBA 的矩形集合

所有三种映射都会生成一个包含两个项的布尔函数。然而,前两种会生成表达式F = B + A'B'F = AB + A'。第三种形式生成F = B + A'。最后这种形式是最优化的(参见定理 11 和 12)。

为四个变量的函数创建的真值图更加复杂;你会发现许多矩形可能隐藏在边缘,这在图 8-10 中可以看到。这个模式列表甚至没有涵盖所有的矩形!例如,图 8-10 中的图示就没有显示任何 1×2 的矩形。

image

图 8-10:4×4 真值图的部分模式列表

这个最终的例子展示了如何优化一个四变量的函数。该函数是 F = D'C'B'A' + D'C'B'A + D'C'BA + D'C'BA' + D'CB'A + D'CBA + DCB'A + DCBA + DC'B'A' + DC'BA',其真值表见于图 8-11。

image

图 8-11:F = D'C'B'A' + D'C'B'A + D'C'BA + D'C'BA' + D'CB'A + D'CBA + DCB'A + DCBA + DC'B'A' + DC'BA' 的真值图*

图 8-12 展示了该函数的两个可能的最大矩形集合,每个集合生成三个项。

image

图 8-12:两种组合生成三项

由四个角形成的矩形,两个图中都有,包含 BB'DD',因此我们可以去掉这些项。矩形内剩下的项是 C'A',所以这个矩形表示项 C'A'

由中间四个方格形成的矩形,在两种组合中也都有,包含了* ABB'CD* 和 D' 等项。去掉 BB'DD',我们得到 CA

组合 1 有第三项,由顶行表示。这个项包含了变量 AA'BB'C'D'。我们可以去掉 AA'BB'。剩下的项是 C'D'。因此,上方真值表所表示的函数是 F = C'A' + CA + C'D'

组合 2 有第三项,由顶部/中间的四个方格表示。这个矩形包含了变量 ABB'CC'D'。我们可以去掉 BB'CC',剩下的项是 AD。因此,下方真值表所表示的函数是 F = C'A' + CA + AD'

这两个函数是等价的;两个都是最优的(记住,没有保证唯一的最优解)。其中任何一个都足够满足我们的需求:使用最少的电路组件实现布尔函数。

8.7 这和计算机有什么关系?

你可以将任何你编写的程序,也可以指定为一系列布尔方程。这意味着你可以将任何在软件中实现的算法,也能直接在硬件中实现——所有布尔函数集合与所有电子电路集合之间存在一一对应的关系。设计 CPU 和其他与计算机相关电路的电气工程师,必须对这些内容非常熟悉。

因为使用像 Pascal、C 甚至汇编语言这样的语言来指定编程问题的解决方案,比使用布尔方程来指定解决方案要容易,所以你不太可能使用一组状态机和其他逻辑电路来实现整个程序。然而,硬件解决方案可能比等效的软件解决方案快几个数量级,而且一些时间关键的操作需要硬件解决方案。

也可以在软件中实现所有硬件功能。这一点非常重要,因为你通常在硬件中实现的许多操作,使用微处理器的软件实现要便宜得多。事实上,在现代系统中,汇编语言的主要用途之一就是以低成本替代复杂的电子电路。通常,你可以用一个单价为 2 美元的微计算机芯片,通过编程来执行等效的功能,从而替代价值数十或数百美元的电子元件。

整个嵌入式系统(嵌入到其他产品中的计算机系统)领域都涉及这个问题。例如,大多数微波炉、电视机、视频游戏、CD 播放器和其他消费电子设备都包含一个或多个完整的计算机系统,其唯一目的是替代复杂的硬件设计。工程师使用计算机来完成这个任务,因为它们比传统电子电路更便宜、更容易设计。

要编写能够读取开关(输入变量)并启动电动机、LED 灯或灯具,或锁定或解锁门的软件,你需要理解布尔函数以及如何在软件中实现它们。

8.7.1 电子电路与布尔函数之间的对应关系

对于任何布尔函数,你都可以设计一个等效的电子电路,反之亦然。我们可以使用与、或和非布尔运算符构造任何电子电路,它们分别对应与门、或门和反相器(非门)电路(参见图 8-13)。这些符号是原理图中标准的电子符号。(要了解更多关于电子原理图的内容,可以查阅任何一本电子设计书籍。)

image

图 8-13:与门、或门和反相器(非门)

每个门左侧的带有AB标签的线路对应逻辑功能的输入;每个图示右侧的线路则对应该功能的输出。

一个电子电路是由多个门组合而成,能够实现某些布尔函数。考虑布尔函数F = AB + B。你可以使用与门和或门来实现这个函数。只需将两个输入变量(AB)连接到与门的输入,将与门的输出连接到或门的一个输入,将B输入变量连接到另一个或门输入。现在你就有了一个实现该函数的电子(硬件)电路。

然而,你实际上只需要一个单一的门类型——NAND(非与)门,就能实现任何电子电路(参见图 8-14)。NAND 门测试其两个输入(AB),如果两个输入均为true,则输出false;如果两个输入均为false,则输出true。你可以通过一个与门和一个反相器来构建 NAND 电路。然而,从晶体管/硬件的角度来看,NAND 门实际上比与门更容易构建;因此,NAND 门(例如 7400 集成电路)非常常见。

image

图 8-14:NAND 门

我们可以仅使用 NAND 门构造任何布尔函数,因为我们可以从 NAND 门构建反相器(NOT)、与门和或门。^(3) 构建反相器很简单;只需将两个输入连接在一起(见图 8-15)。

image

图 8-15:由 NAND 门构建的反相器

在构建了反相器之后,我们可以通过反转 NAND 门的输出,来构建一个与门,因为 NOT(NOT(A AND B))等同于A AND B(见图 8-16)。构建一个与门需要两个 NAND 门(没有人说仅用 NAND 门构建的电路是最优的,只是说它们是可能的)。

image

图 8-16:从两个 NAND 门构建 AND 门

剩下的门是逻辑“或”门。我们可以通过应用德摩根定律,从 NAND 门构建一个或门。

(A or B)'    =    A' and B'            DeMorgan's Theorem.

A or B       =    (A' and B')'         Invert both sides of the equation.

A or B       =    A' nand B'           Definition of NAND operation.

应用这些变换产生了图 8-17 中显示的电路。

image

图 8-17:从 NAND 门构建 OR 门

与其他门相比,NAND 门通常更便宜,且从相同的基本构建模块构建复杂电路比使用不同基本门构建集成电路要容易得多。

8.7.2 组合电路

计算机的 CPU 是由组合电路构建的,这些电路包含基本的布尔运算(与、或、非)、一些输入和一组输出。组合电路通常实现多个不同的布尔函数,每个输出对应一个独立的逻辑功能。

注意

非常重要的是,你要记住 每个输出代表一个不同的布尔函数。

8.7.2.1 组合加法电路

你可以使用布尔函数实现加法。假设你有两个 1 比特数字,AB。你可以使用这两个布尔函数产生这次加法的 1 比特和与 1 比特进位:

S  =  AB' + A'B        Sum of A and B.

C  =  AB               Carry from addition of A and B.

这两个布尔函数实现了一个半加法器,之所以叫半加法器,是因为它能将两个比特加在一起,但不能加上来自先前运算的进位。注意,如果AB为 1,则S = 1;如果AB都为 0 或 1(两个 1 会产生进位,这是C = AB表达式的作用),则S = 0。

一个全加法器加三个 1 比特输入(两个比特加一个来自先前加法的进位),并产生两个输出:和与进位。以下是全加法器的两个逻辑方程:

S     =  A'B'Cin + A'BCin' + AB'Cin' + ABCin

Cout  =  AB + ACin + BCin

尽管这些方程只产生单比特结果(加上进位),但通过组合加法器电路,构造一个n比特和是很容易的(见图 8-18)。

image

图 8-18:使用半加法器和全加法器构建 n 比特加法器

两个n位输入,AB,逐位传入加法器,LO 位输入为A[0]和B[0],依此类推直到 HO 位A[n][–1]和B[n][–1]。S[0]是和的 LO 位,一直到S[n][–1],最终进位表示加法是否溢出了n位。

8.7.2.2 使用七段 LED 解码器

另一个常见的组合电路是七段解码器。在计算机系统设计中,解码器电路使计算机能够识别(或解码)一串比特。

七段解码器电路接受 4 位输入,并确定在七段 LED 显示器上点亮哪些段。由于七段显示器包含七个输出值(每个段一个),因此与之相关联有七个逻辑函数(段 0 至段 6)。请参见图 8-19 了解段的分配。图 8-20 展示了每个十进制值的活跃段。

image

图 8-19:七段显示器

image

图 8-20:0 至 9 的七段值

每个七个布尔函数的四个输入是从范围为 0 到 9 的二进制数字中提取的 4 位。设D为该数字的最高位(HO 位),A为最低位(LO 位)。每个段的逻辑函数应为所有在图 8-20 中该段被点亮的二进制数字输入产生1(段点亮)。例如,S[4](段 4)应在数字 0、2、6 和 8 时点亮,这些数字对应的二进制值分别为 0000、0010、0110 和 1000。对于每一个点亮段的二进制值,你将有一个最小项在逻辑方程中:

S4 = D'C'B'A' + D'C'BA' + D'CBA' + DC'B'A'

S[0](段 0),作为第二个例子,对于数字 0、2、3、5、6、7、8 和 9 点亮,这些数字对应的二进制值为 0000、0010、0011、0101、0110、0111、1000 和 1001。因此,S[0]的逻辑函数如下:

S0 = D'C'B'A' + D'C'BA' + D'C'BA + D'CB'A + D'CBA' + D'CBA + DC'B'A' + DC'B'A
8.7.2.3 解码内存地址

解码器在内存扩展中也常被使用。例如,假设系统设计师希望在系统中安装四个(相同的)256MB 内存模块,以使总内存达到 1GB。每个 256MB 的内存模块有 28 条地址线(A[0]..A[27]),假设每个内存模块宽度为 8 位(2²⁸ × 8 位即为 256MB)。^(4)

不幸的是,如果系统设计者将这四个内存模块连接到 CPU 的地址总线上,每个模块都会响应总线上相同的地址。这将导致混乱。为了解决这个问题,每个内存模块需要响应出现在完整地址总线上的不同地址集合(地址总线的低 28 位上会显示模块地址)。通过为每个内存模块添加一个芯片选择线,并使用一个两输入、四输出的解码电路,我们可以使用芯片选择线 A[28] 和 A[29] 来指定(现在有效的 30 位)内存地址的高 2 位。详情请参见图 8-21。

image

图 8-21:向系统添加四个 256MB 内存模块

图 8-21 中的两到四线解码器电路包含四种不同的逻辑功能:每个输出一个功能。每种输入位的组合将激活单一的芯片选择线,并禁用其他三个。假设输入为 ABA = A[28],B = A[29]),四个输出功能如下:

Q0 = A'B'

Q1 = AB'

Q2 = A'B

Q3 = AB

根据标准电子电路符号,这些方程使用 Q 来表示输出。

请注意,大多数电路设计师为解码器和芯片使能使用 低电平有效逻辑。这意味着当输入值为低电平(0)时,电路被启用;当输入值为高电平(1)时,电路被禁用。实际的解码电路可能会使用以下最大项函数的和:

Q0 = A + B

Q1 = A' + B

Q2 = A + B'

Q3 = A' + B'
8.7.2.4 解码机器指令

解码电路也用于解码机器指令。我们将在第九章和第十章中更深入地探讨这一主题,但这里我们先介绍一个简单的例子。

大多数现代计算机系统使用内存中的二进制值来表示机器指令。为了执行指令,CPU 从内存中获取指令的二进制值,使用解码电路对其进行解码,然后执行相应的工作。为了了解这一过程,我们将创建一个具有非常简单指令集的虚构 CPU。图 8-22 提供了我们 CPU 的指令格式(所有对应各种指令的数字代码)。在 1 字节的操作码(opcode)中,3 位(iii)表示指令,2 位(ss)表示源操作数,2 位表示目标操作数(dd)。

image

图 8-22:一个非常简单的 CPU 的指令(操作码)格式

要确定给定指令的 8 位操作码,可以在图 8-22 的表格中查找指令的每个组成部分,并替换相应的位值。

让我们选择mov(eax, ebx);作为我们的简单示例。为了将这条指令转换为其数值等效,mov被编码为000eax被编码为00ebx被编码为01。将这三个字段组装成操作码字节(一种压缩数据类型),得到比特值:%00000001。因此,数值$1mov(eax, ebx);指令的值(见图 8-23)。

image

图 8-23:编码mov(eax, ebx);指令

本示例的典型解码器电路见图 8-24。该电路使用三个独立的解码器来解码操作码的各个字段。这比创建一个单一的 7 到 128 线解码器来解码整个操作码要简单得多。

image

图 8-24:解码简单的机器指令

图 8-24 中的电路告诉你给定的操作码指定了哪条指令以及哪些操作数。要实际执行这条指令,你必须提供额外的电路来从一组寄存器中选择源操作数和目标操作数,并相应地对这些操作数进行处理。这样的电路超出了本章的范围,因此我们将把详细内容留到后面再讲。

8.7.3 顺序和时钟逻辑

组合逻辑的一个主要问题是它是无记忆的。从理论上讲,所有逻辑函数的输出仅依赖于当前输入。输入值的任何变化都会立即反映在输出上。^(5) 不幸的是,计算机需要能够记住过去计算的结果。这就是顺序逻辑或时钟逻辑的领域。

8.7.3.1 置/复位触发器

存储单元是一个电子电路,能够在去除输入值后仍然记住该输入值。最基本的存储单元是置/复位(S/R)触发器。你可以使用两个 NAND 门构建一个 S/R 触发器存储单元,如图 8-25 所示。在该图中,两个 NAND 门的输出被反馈到另一个 NAND 门的输入之一。

image

图 8-25:由 NAND 门构建的置/复位触发器

SR输入通常为高电平,即1。如果你通过暂时S输入的值设置为0,然后再将其恢复为1,则Q输出被设置为1。同样,如果你将R输入从1切换到0,然后再切换回1,则将Q输出重置为0Q'输出与Q相反。

如果 SR 都为 1,则 Q 输出取决于 Q 的原始值。也就是说,不论 Q 是什么,顶端 NAND 门将继续输出相同的值。如果 Q 最初为 1,则底部 NAND 门接收到两个输入为 1QR),底部 NAND 门将输出 0 (Q')。因此,顶端 NAND 门的两个输入为 01,顶端 NAND 门输出 1,与 Q 的原始值相匹配。

另一方面,如果 Q 的原始值为 0,则底部 NAND 门的输入为 Q = 0R = 1,该底部 NAND 门的输出为 1。因此,顶端 NAND 门的输入为 S = 1Q' = 1。这将产生一个 0 输出,即 Q 的原始值。

假设 Q0S0R1。这将把顶端 NAND 门的两个输入设置为 10,强制输出 (Q) 为 1。将 S 恢复到高电平状态不会改变输出,因为 Q' 的值为 1。如果 Q1S0R1,你也会得到相同的结果。同样,这会产生一个 Q 输出值为 1,即使 S0 切换到 1,这个值仍然保持为 1。为了克服这个问题并使 Q 输出为 1,你必须切换 S 输入。同样的思想适用于 R 输入,不过切换它会将 Q 输出强制为 0,而不是 1

这个电路有一个陷阱。如果你同时将 SR 输入都设置为 0,它将无法正常工作。这将强制 QQ' 输出都为 1(这在逻辑上是不一致的)。哪个输入保持 0 的时间最长,就决定了触发器的最终状态。以这种方式操作的触发器被称为 不稳定

表 8-9 列出了基于当前输入和先前输出值的所有 S/R 触发器输出配置。

表 8-9: 基于当前输入和先前输出的 S/R 触发器输出状态

前一个 Q 前一个 Q' S 输入 R 输入 Q 输出 Q' 输出
x^(6) x 0 (1 > 0 > 1) 1 1 0
x x 1 0 (1 > 0 > 1) 0 1
x x 0 0 1 1^(7)
0 1 1 1 0 1
1 0 1 1 1 0
8.7.3.2 D 触发器

S/R 触发器唯一的问题是,为了能够记住 01 的值,你必须有两个不同的输入。如果我们能够通过一个输入值来指定要记住的数据值,并且提供第二个 时钟输入锁存 数据输入值,那么存储单元对我们来说将更有价值。此类型的触发器,即 D 触发器(D 代表 数据),使用的是 图 8-26 中的电路。

image

图 8-26:使用 NAND 门实现 D 触发器

假设你将QQ'输出固定为0/11/0,发送一个从01再到0时钟脉冲,将把D输入复制到Q输出(并将Q'设置为Q的反值)。要了解它是如何工作的,注意到图 8-26 中电路图的右半部分是一个 S/R 触发器。如果数据输入为1,而时钟线为高电平,这会将0置于 S/R 触发器的S输入端(并将1置于R输入端)。相反,如果数据输入为0,而时钟线为高电平,这会将0置于 S/R 触发器的R输入端(并将1置于S输入端),从而清除 S/R 触发器的输出。每当时钟输入为低电平时,SR输入都为高电平,S/R 触发器的输出不会改变。

尽管记住一个单独的比特通常很重要,但在大多数计算机系统中,你需要记住一比特。你可以通过将多个 D 触发器并联来实现这一点。将触发器串联以存储n位值形成一个寄存器。图 8-27 中的电子原理图展示了如何从一组 D 触发器构建一个 8 位寄存器。

image

图 8-27:由八个 D 触发器实现的 8 位寄存器

请注意,图 8-27 中的八个 D 触发器使用一个公共时钟线。该图没有显示触发器上的Q'输出,因为在寄存器中它们很少被需要。

D 触发器在构建许多顺序电路时非常有用,超越了简单的寄存器。例如,你可以构建一个移位寄存器,它在每次时钟脉冲时将位向左移动一个位置。一个 4 位移位寄存器如图 8-28 所示。

image

图 8-28:由 D 触发器构建的 4 位移位寄存器

你甚至可以使用触发器构建一个计数器,它计算时钟从10再回到1的切换次数。图 8-29 中的电路使用 D 触发器实现了一个 4 位计数器。

image

图 8-29:由 D 触发器构建的 4 位计数器

出乎意料的是,你可以用组合电路和少数额外的顺序电路构建一个完整的 CPU。例如,你可以通过将计数器和解码器结合来构建一个简单的状态机,称为序列器,如图 8-30 所示。

image

图 8-30:一个简单的 16 状态序列器

对于图 8-30 中的每一个时钟周期,这个序列发生器都会激活它的一个输出线。这些输出线反过来可能控制其他电路。通过在解码器的 16 条输出线上的“触发”这些其他电路,我们可以控制电路完成任务的顺序。这对于 CPU 至关重要,因为我们经常需要控制各种操作的顺序。例如,如果add(eax, ebx);指令在从 EAX(或 EBX)获取源操作数之前就把结果存储到 EBX 中,那就不好了。一个简单的序列发生器可以告诉 CPU 何时获取第一个操作数,何时获取第二个操作数,何时将它们相加,何时存储结果。然而,我们现在有些超前了——我们将在接下来的两章中详细讨论这个问题。

8.8 更多信息

Horowitz, Paul, 和 Winfield Hill. 《电子艺术》. 第 3 版. 英国剑桥:剑桥大学出版社, 2015.

注意

本章并非全面讨论布尔代数和数字设计。如果你有兴趣深入了解,可以查阅该领域的众多书籍。

第九章:CPU 架构**

图片

毫无疑问,中央处理单元(CPU)的设计对软件性能有着最大的影响。为了执行特定的指令(或命令),CPU 需要特定的电子电路来处理该指令。随着 CPU 支持的指令数量增加,CPU 的复杂度和执行这些指令所需的电路量或逻辑门也会增加。因此,为了保持逻辑门的数量和相关成本合理地较小,CPU 设计师必须限制 CPU 能够执行的指令数量和复杂性。这就是所谓的 CPU 的指令集

本章以及下一章将讨论 CPU 及其指令集的设计——这些信息对于编写高性能软件至关重要。

9.1 基本的 CPU 设计

早期计算机系统中的程序通常是硬接线到电路中的。也就是说,计算机的接线决定了计算机将执行的算法。计算机必须重新接线才能解决不同的问题。这是一个困难的任务,只有电气工程师能够完成。

因此,计算机设计的下一个进展是可编程计算机系统,在这种系统中,计算机操作员可以使用一组插槽和连接电线的面板,轻松地“重新接线”计算机,这就是跳线板。计算机程序由一排排插槽组成,每一排表示程序执行期间的一个操作(指令)。为了执行一条指令,程序员将电线插入相应的插槽中(参见图 9-1)。

图片

图 9-1:跳线板编程

可用指令的数量受到每排插槽能够容纳多少个插槽的限制。CPU 设计师很快意识到,通过少量的额外逻辑电路,他们可以将指定n个不同指令所需的插槽数量从n个插槽减少到 log2 个插槽。他们通过为每个指令分配一个唯一的二进制数字来实现这一点(例如,图 9-2 展示了如何使用仅 3 个位表示 8 条指令)。

图片

图 9-2:编码指令

图 9-2 中的示例需要八个逻辑功能来解码跳线板上的ABC位,但额外的电路(一个三到八线解码器)是值得的,因为它将每个指令的插槽总数从八个减少到三个。

许多 CPU 指令需要操作数。例如,mov 指令将数据从计算机的一个位置移动到另一个位置,比如从一个寄存器到另一个寄存器,因此需要源操作数和目标操作数。操作数被编码为机器指令的一部分,插槽对应源和目标。图 9-3 展示了处理 mov 指令的插槽组合之一。

image

图 9-3:编码带有源和目标字段的指令

mov 指令将数据从源寄存器移动到目标寄存器,add 指令将源寄存器的值加到目标寄存器,以此类推。这个方案允许在每条指令仅使用七个插槽的情况下,编码出 128 条不同的指令。

如前所述,早期板式编程的一个大问题是,程序的功能受限于机器上可用的插槽数量。早期计算机设计师意识到,板式插槽与内存中的比特之间存在关系。他们意识到,可以将机器指令的二进制等价物存储在主内存中,当 CPU 需要执行指令时,从内存中获取该二进制数,并将其加载到一个特殊寄存器中进行解码。这个发明被称为存储程序计算机,它是计算机设计的另一个重要进展。

解决方法是向 CPU 中添加更多电路,称为控制单元(CU)。控制单元使用一个特殊的寄存器,即指令指针,来保存指令的二进制数字代码地址(也称为操作码opcode)。控制单元从内存中提取指令的操作码,并将其放入指令解码寄存器中执行。执行完指令后,控制单元递增指令指针,并从内存中获取下一条指令进行执行。

9.2 解码与执行指令:随机逻辑与微代码

一旦控制单元从内存中获取指令,传统 CPU 通常使用两种常见方法来执行指令:随机逻辑(硬连线)和微代码(仿真)。例如,80x86 系列就同时使用了这两种技术。

随机逻辑^(1)或硬连线方法使用解码器、锁存器、计数器和其他硬件逻辑设备来操作操作码数据。随机逻辑速度快,但在电路设计上带来挑战;对于具有大规模复杂指令集的 CPU 来说,很难合理布局电路,以便在芯片的二维空间中将相关电路放置得尽可能接近。

基于微码的 CPU 包含一个小而快速的执行单元(负责执行特定功能的电路),称为* 微引擎*,它使用二进制操作码从微码库中选择一组指令。这个微码每个时钟周期执行一条微指令,微指令的序列执行所有步骤,以完成该指令所需的所有计算。

尽管这个微引擎本身非常快,但它必须从微码 ROM(只读内存)中获取指令。因此,如果内存技术比执行逻辑更慢,微引擎必须以与微码 ROM 相同的速度运行,这反过来限制了 CPU 的运行速度。

随机逻辑方法可以减少执行操作码指令的时间,前提是典型的 CPU 速度比内存速度更快,但这并不意味着它一定比微码方法更快。随机逻辑通常包含一个顺序器,按顺序遍历多个状态(每个时钟周期一个状态)。无论你是通过执行微指令消耗时钟周期,还是在随机逻辑状态机中逐步执行,你仍然在消耗时间。

哪种方法更适合 CPU 设计完全取决于当前的内存技术状态。如果内存技术比 CPU 技术更快,微码方法可能更有意义。如果内存技术比 CPU 技术更慢,随机逻辑通常能更快地执行机器指令。

9.3 按步骤执行指令

无论 CPU 使用哪种方法,你都需要理解 CPU 如何执行单个机器指令。为此,我们将考虑四个具有代表性的 80x86 指令——movaddloopjnz如果不为零则跳转)——来让你了解 CPU 如何执行其指令集中的所有指令。

正如你之前看到的,mov指令将数据从源操作数复制到目标操作数。add指令将源操作数的值加到目标操作数中。loopjnz条件跳转指令——它们测试某个条件,如果为true,它们跳转到内存中的某个其他指令;如果为false,则继续执行下一条指令。jnz指令测试 CPU 内的一个布尔变量,称为零标志,如果零标志的值为0,则将控制转移到目标指令(跳转到的指令);如果零标志的值为1,则继续执行下一条指令。程序通过指定目标指令与jnz指令在内存中的字节距离来指示目标指令的地址。

loop指令递减 ECX 寄存器的值,并且如果结果值不为0,则将控制转移到目标指令。这是复杂指令集计算机(CISC)指令的一个好例子,因为它执行了多个操作:

  1. 它从 ECX 中减去 1。

  2. 如果 ECX 不为0,它会进行条件跳转。

也就是说,loop大致相当于以下指令序列:

sub( 1, ecx ); // On the 80x86, the sub instruction sets the zero flag

jnz SomeLabel; // the result of the subtraction is 0.

为了执行movaddjnzloop指令,CPU 需要执行一系列不同的操作。每个操作执行都需要一定的时间,整个指令执行所需的时间通常等于 CPU 执行每个操作或阶段(步骤)所需的一个时钟周期。显然,指令所需的阶段越多,它的运行速度就越慢。由于复杂指令具有多个执行阶段,因此它们的运行速度通常比简单指令慢。

尽管 80x86 CPU 有所不同,并不一定执行完全相同的步骤,但它们的操作序列是相似的。本节展示了一些可能的序列,所有序列都从相同的三个执行阶段开始:

  1. 从内存中获取指令的操作码。

  2. 用紧跟操作码后的字节地址更新 EIP(扩展指令指针)寄存器。

  3. 解码指令的操作码,以查看它指定了什么指令。

9.3.1 mov 指令

解码后的 32 位 80x86 mov(srcReg, destReg);指令可能使用以下(额外的)执行阶段:

  1. 从源寄存器(srcReg)获取数据。

  2. 将获取的值存储到目标寄存器(destReg)中。

mov(srcReg, destMem);指令可能使用以下执行阶段:

  1. 从紧跟操作码后的内存位置获取与内存操作数相关的位移值。

  2. 更新 EIP,使其指向操作码后面操作数的第一个字节。

  3. 如果mov指令使用复杂的寻址模式(例如,索引寻址模式),则计算目标内存位置的有效地址。

  4. 从 srcReg 获取数据。

  5. 将获取的值存储到目标内存位置。

mov(srcMem, destReg);指令非常类似,只是将寄存器访问与内存访问交换了这些步骤。

mov(constant, destReg);指令可能使用以下执行阶段:

  1. 从紧跟操作码后的内存位置获取与源操作数相关的常量。

  2. 更新 EIP,使其指向操作码后面的第一个字节。

  3. 将常量值存储到目标寄存器中。

假设每个阶段需要一个时钟周期执行,那么这个序列(包括三个共同的阶段)将需要六个时钟周期来执行。

mov(constant, destMem);指令可能使用以下执行阶段:

  1. 从紧跟在操作码后的内存位置获取与内存操作数相关的位移值。

  2. 更新 EIP,使其指向紧跟在操作数后的第一个字节。

  3. 从紧跟在内存操作数位移后的内存位置获取常量操作数的值。

  4. 更新 EIP,使其指向常量的第一个字节。

  5. 计算目标内存位置的有效地址,如果mov指令使用复杂寻址模式(例如,索引寻址模式)。

  6. 将常量值存入目标内存位置。

9.3.2 add 指令

add指令稍微复杂一些。以下是解码后的add(srcReg, destReg);指令必须完成的典型操作(超出了常见操作集):

  1. 获取源寄存器的值并将其发送到算术逻辑单元(ALU),该单元负责处理 CPU 中的算术运算。

  2. 获取目标寄存器操作数的值并将其发送到 ALU。

  3. 指示 ALU 将值相加。

  4. 将结果存回目标寄存器操作数。

  5. 使用加法操作的结果更新标志寄存器。

注意

标志寄存器,也称为条件码寄存器程序状态字,是 CPU 中的一个布尔变量数组,用于跟踪前一条指令是否产生溢出、零结果、负结果或其他类似的条件。*

如果源操作数是内存位置而非寄存器,且add指令的形式为add(srcMem, destReg);,则指令序列稍微复杂一些:

  1. 从紧跟在操作码后的内存位置获取与内存操作数相关的位移值。

  2. 更新 EIP,使其指向紧跟在操作数后的第一个字节。

  3. 计算源内存位置的有效地址,如果add指令使用复杂寻址模式(例如,索引寻址模式)。

  4. 从内存中获取源操作数的数据并将其发送到 ALU。

  5. 获取目标寄存器操作数的值并将其发送到 ALU。

  6. 指示 ALU 将值相加。

  7. 将结果存回目标寄存器操作数。

  8. 使用加法操作的结果更新标志寄存器。

如果源操作数是常量且目标操作数是寄存器,则add指令的形式为add(constant, destReg);,CPU 可能会按如下方式处理它:

  1. 获取紧跟在操作码后的常量操作数,并将其发送到 ALU。

  2. 更新 EIP,使其指向紧跟在操作码后的常量的第一个字节。

  3. 获取目标寄存器操作数的值并将其发送到 ALU。

  4. 指示 ALU 将值相加。

  5. 将结果存回目标寄存器操作数。

  6. 使用加法操作的结果更新标志寄存器。

这个指令序列需要九个周期来完成。

如果源操作数是常数,并且目标操作数是内存位置,那么add指令的形式为add(constant, destMem);,此时序列稍显复杂:

  1. 从紧跟操作码之后的内存中获取与内存操作数相关的位移值。

  2. 更新 EIP,使其指向操作码后紧跟操作数的第一个字节。

  3. 如果add指令使用复杂的寻址模式(例如,索引寻址模式),则计算目标内存位置的有效地址。

  4. 获取紧跟在内存操作数位移值后的常数操作数,并将其发送到 ALU。

  5. 从内存中获取目标操作数的数据并将其发送到 ALU。

  6. 更新 EIP,使其指向紧跟内存操作数后常数的第一个字节。

  7. 指示 ALU 进行加法运算。

  8. 将结果存储回目标内存操作数中。

  9. 使用加法运算结果更新标志寄存器。

这个指令序列需要 11 或 12 个周期来完成,具体取决于是否需要计算有效地址。

9.3.3 jnz 指令

由于 80x86 的jnz指令不允许不同类型的操作数,它只需要一个步骤序列。解码后的jnz label;指令可能使用以下附加的执行阶段:

  1. 获取位移值(跳转距离),并将其发送到 ALU。

  2. 更新 EIP 寄存器,以保存紧跟位移操作数后的指令地址。

  3. 测试零标志,看它是否被清除(即是否包含0)。

  4. 如果零标志被清除,将 EIP 中的值复制到 ALU。

  5. 如果零标志被清除,指示 ALU 将位移值与 EIP 值相加。

  6. 如果零标志被清除,将加法结果复制回 EIP。

注意,如果跳转未被执行,jnz指令需要的步骤更少,因此运行时所需的时钟周期也更少。这对于条件跳转指令来说是非常典型的。

9.3.4 循环指令

由于 80x86 的loop指令不允许不同类型的操作数,它只需要一个步骤序列。解码后的 80x86 loop指令可能使用如下的执行序列:^(2)

  1. 获取 ECX 寄存器的值并将其发送到 ALU。

  2. 指示 ALU 将此值递减。

  3. 将结果发送回 ECX 寄存器。如果该结果非零,则设置一个特殊的内部标志。

  4. 从内存中获取紧跟在操作码后的位移值(跳转距离),并将其发送到 ALU。

  5. 使用紧跟在位移操作数后的指令地址更新 EIP 寄存器。

  6. 测试特殊的内部标志,查看 ECX 是否非零。

  7. 如果标志被设置(即它包含1),将 EIP 中的值复制到 ALU。

  8. 如果标志被设置,指示 ALU 将位移值与 EIP 值相加。

  9. 如果标志位被设置, 将加法的结果复制回 EIP 寄存器。

jnz指令类似,请注意,loop指令在未跳转时执行得更快,CPU 将继续执行紧接在loop指令之后的指令。

9.4 RISC 与 CISC:通过执行更多、更快的指令提高性能

早期的微处理器(包括 80x86 及其前身)是复杂指令集计算机(CISC)的典型例子。当时设计这些 CPU 时的思路是,让每条指令完成更多的工作可以让程序运行得更快,因为它们执行的指令较少(而具有较少复杂指令的 CPU 则需要执行更多指令才能完成相同的工作量)。数字设备公司(DEC)的 PDP-11 及其继任者 VAX 就是这种设计理念的代表。

在 1980 年代初期,计算机架构研究人员发现,这种复杂性带来了巨大的代价。所有支持这些复杂指令所需的硬件最终限制了 CPU 的整体时钟速度。对 VAX 11-780 小型计算机的实验表明,执行多个简单指令的程序比执行较少、更加复杂的指令的程序运行得更快。这些研究人员假设,如果将指令集精简到最基本的部分,只使用简单的指令,就能提升硬件性能(通过提高时钟速度)。他们将这种新架构称为精简指令集计算机(RISC)。^(3)由此开始了伟大的“RISC 与 CISC”辩论:哪种架构更好?

至少在纸面上,RISC CPU 看起来更好。实际上,它们的时钟速度较慢,因为现有的 CISC 设计有一个巨大的先发优势(因为它们的设计者有更多的时间来优化它们)。当 RISC CPU 设计成熟到足以实现更高时钟速度时,CISC 设计已经进化,利用了 RISC 研究的成果。今天,80x86 CISC CPU 仍然是高性能的王者。RISC CPU 则找到了不同的市场:它们通常比 CISC 处理器更节能,因此通常出现在便携和低功耗设计中(例如手机和平板电脑)。

尽管 80x86(CISC CPU)仍然是性能的领导者,但仍然可以编写包含更多简单的 80x86 指令的程序,这些程序比那些包含较少、更复杂 80x86 指令的程序运行得更快。80x86 设计者保留了这些遗留指令,以便你能够执行仍包含这些指令的旧软件。然而,新的编译器避免使用这些遗留指令,从而生成运行速度更快的代码。

然而,从 RISC 研究中得出一个重要的结论是,每条指令的执行时间在很大程度上取决于它所做的工作量。一条指令所需的内部操作越多,执行时间就越长。除了通过减少内部操作的数量来提高执行时间,RISC 还优先考虑能够并行执行的内部操作——即并行

9.5 并行性:加速处理的关键

如果我们能够减少 CPU 执行其指令集中的每条指令所需的时间,那么包含这些指令序列的应用程序也将比平时运行得更快。

RISC 处理器的早期目标是平均每个时钟周期执行一条指令。然而,即使 RISC 指令被简化,它的实际执行仍然需要多个步骤。那么,处理器如何实现这一目标呢?答案是并行性。

考虑以下mov(srcReg, destReg)指令的步骤:

  1. 从内存中获取指令的操作码。

  2. 用操作码后一个字节的地址更新 EIP 寄存器。

  3. 解码指令的操作码,以查看它指定了什么指令。

  4. srcReg中获取数据。

  5. 将获取的值存入目标寄存器(destReg)。

CPU 必须在更新 EIP 寄存器并获取操作码后一个字节的地址之前从内存中获取指令的操作码,必须在知道要获取源寄存器的值之前解码操作码,必须在能够将获取的值存入目标寄存器之前获取源寄存器的值。

执行这个mov指令的所有阶段都是串行的。也就是说,CPU 必须在执行下一阶段之前完成当前阶段。唯一的例外是第 2 步,更新 EIP 寄存器。虽然这一阶段必须在第一阶段之后执行,但之后的所有阶段都不依赖于它。我们可以与其他任何阶段并行执行这一步,它也不会影响mov指令的操作。通过并行执行两个阶段,我们可以将该指令的执行时间减少一个时钟周期。以下序列展示了一个可能的并行执行方式:

  1. 从内存中获取指令的操作码。

  2. 解码指令的操作码,以查看它指定了什么指令。

  3. srcReg中获取数据,并用操作码后一个字节的地址更新 EIP 寄存器。

  4. 将获取的值存入目标寄存器(destReg)。

尽管mov(srcReg, destReg)指令中的其余阶段必须串行执行,但mov指令的其他形式也提供了通过并行执行阶段来节省周期的类似机会。例如,考虑 80x86 的mov([ebx+disp], eax)指令:

  1. 从内存中获取指令的操作码。

  2. 用操作码后一个字节的地址更新 EIP 寄存器。

  3. 解码指令的操作码,以确定其指定的指令。

  4. 获取位移值,用于计算源操作数的有效地址。

  5. 更新 EIP,指向内存中位移值后的第一个字节。

  6. 计算源操作数的有效地址。

  7. 从内存中获取源操作数的数据值。

  8. 将结果存储到目标寄存器操作数中。

再次,我们可以重叠执行此指令的多个阶段。在以下示例中,我们通过将 EIP 的两次更新与其他两项操作重叠,将步骤数从八个减少到六个:

  1. 从内存中获取指令的操作码。

  2. 解码指令的操作码,以确定其指定的指令,更新 EIP 寄存器,指向操作码后面的字节地址。

  3. 获取位移值,用于计算源操作数的有效地址。

  4. 计算源操作数的有效地址,更新 EIP,指向内存中位移值后的第一个字节。

  5. 从内存中获取源操作数的数据值。

  6. 将结果存储到目标寄存器操作数中。

作为最后一个示例,考虑 add(constant, [ebx+disp]); 指令。其串行执行如下:

  1. 从内存中获取指令的操作码。

  2. 使用操作码后面的字节地址更新 EIP 寄存器。

  3. 解码指令的操作码,以确定其指定的指令。

  4. 从紧跟在操作码后面的内存位置获取位移值。

  5. 更新 EIP,指向操作码后面的第一个字节,超越位移操作数。

  6. 计算第二个操作数的有效地址。

  7. 获取紧跟在位移值后面的常量操作数,并将其发送到 ALU。

  8. 从内存中获取目标操作数的数据,并将其发送到算术逻辑单元(ALU)。

  9. 更新 EIP,指向位移操作数后面常量的第一个字节。

  10. 指示 ALU 执行加法操作。

  11. 将结果存储回目标(第二)操作数中。

  12. 使用加法操作的结果更新标志寄存器。

我们可以重叠执行此指令的多个阶段,因为它们不依赖于其直接前驱的结果:

  1. 从内存中获取指令的操作码。

  2. 解码指令的操作码,以确定其指定的指令,更新 EIP 寄存器,指向操作码后面的字节地址。

  3. 从紧跟在操作码后面的内存位置获取位移值。

  4. 更新 EIP,指向操作码后面超越位移操作数的第一个字节,计算内存操作数(ebx+disp)的有效地址。

  5. 获取紧跟在位移值后面的常量操作数,并将其发送到 ALU。

  6. 从内存中获取目标操作数的数据,并将其发送到 ALU。

  7. 指示 ALU 执行加法操作,并更新 EIP 以指向常量值之后的第一个字节。

  8. 将结果存储回第二个操作数 使用加法操作的结果更新标志寄存器。

尽管看起来 CPU 可以在同一阶段获取常量和内存操作数,因为它们的值互不依赖,但它不能这样做(至少目前不能!),因为它只有一个数据总线,而这两个值都来自内存。在接下来的章节中,你将看到我们如何克服这个问题。

通过重叠执行的各个阶段,我们大大减少了这些指令完成执行所需的步骤数,从而减少了时钟周期数。这是提升 CPU 性能的关键之一,而无需提高芯片的时钟频率。然而,单凭这一方法的收获是有限的,因为指令执行仍然是串行的。从下一个章节开始,我们将看到如何重叠相邻指令的执行,以节省额外的周期。

9.5.1 功能单元

如你在 add 指令中所见,两个值相加并存储其和的步骤不能并行进行,因为你必须先计算出和,才能进行存储操作。此外,CPU 无法在指令执行过程中共享某些资源。只有一个数据总线,CPU 在试图将数据存储到内存时不能获取指令的操作码。此外,许多构成指令执行的步骤共享 CPU 中的功能单元。

功能单元 是执行公共操作的逻辑单元,例如算术逻辑单元和控制单元。一个功能单元一次只能执行一个操作;你不能同时执行两个使用相同功能单元的操作。为了设计一个可以并行执行多个阶段的 CPU,我们必须合理安排这些阶段,以减少潜在的冲突,或者增加额外的逻辑,以便通过在不同功能单元中执行,能够同时进行两个(或更多)操作。

再次考虑 mov(srcMem, destReg); 指令可能需要的步骤:

  1. 从内存中获取指令的操作码。

  2. 更新 EIP 寄存器以保存紧跟操作码之后的位移值地址。

  3. 解码指令的操作码,查看它指定了什么指令。

  4. 从内存中获取位移值,以计算源操作数的有效地址。

  5. 更新 EIP 寄存器以保存超出位移值的字节地址。

  6. 计算源操作数的有效地址。

  7. 获取源操作数的值。

  8. 将获取的值存储到目标寄存器中。

第一个操作使用 EIP 寄存器的值,因此我们不能将其与后续步骤重叠,后续步骤会调整 EIP 中的值。此外,第一个操作使用总线从内存中获取指令操作码,并且由于后续的每个步骤都依赖于该操作码,因此不太可能将其与其他步骤重叠。

第二步和第三步没有共享任何功能单元,并且第三步不依赖于 EIP 寄存器的值,而该寄存器在第二步中被修改。因此,我们可以修改控制单元,使其在解码指令的同时调整 EIP 寄存器。这样可以减少mov指令执行所需的一个周期。

第三步和第四步,即解码指令操作码和获取位移值,看起来无法并行执行,因为必须解码操作码以确定 CPU 是否需要从内存中获取位移操作数。然而,我们可以设计 CPU 使其仍然预取位移值,以便在需要时可以使用。

当然,步骤 7 和步骤 8 的执行无法重叠,因为 CPU 必须在存储之前先获取该值。

通过将所有可能的步骤组合在一起,我们可能得到以下mov指令的执行顺序:

  1. 从内存中获取指令的操作码。

  2. 解码指令的操作码以查看它指定的指令,更新 EIP 寄存器以保存操作码后跟的位移值的地址。

  3. 从内存中获取位移值,以计算源操作数的有效地址,更新 EIP 寄存器以保存位移值后的字节地址。

  4. 计算源操作数的有效地址。

  5. 从内存中获取源操作数的值。

  6. 将获取的值存入目标寄存器。

通过向 CPU 添加少量逻辑,我们已经减少了mov指令执行所需的一两个周期。这种简单的优化同样适用于大多数其他指令。

现在考虑loop指令,它有几个使用 ALU 的步骤。如果 CPU 只有一个 ALU,它必须按顺序执行这些步骤。然而,如果 CPU 有多个 ALU(即多个功能单元),它可以并行执行其中的一些步骤。例如,CPU 可以在更新 EIP 值的同时减少 ECX 寄存器中的值(使用 ALU)。请注意,loop指令还使用 ALU 将减少后的 ECX 值与0进行比较(以决定是否跳转)。然而,递减 ECX 和将其与0比较之间存在数据依赖关系,因此 CPU 无法同时执行这两项操作。

9.5.2 预取队列

现在我们已经看过一些简单的优化技术,考虑当mov指令在具有 32 位数据总线的 CPU 上执行时会发生什么。如果mov指令从内存中获取一个 8 位位移值,CPU 可能会连同位移值一起获取额外的 3 个字节(32 位数据总线使我们可以在一个总线周期中获取 4 个字节)。数据总线上的第二个字节实际上是下一条指令的操作码。如果我们能够将这个操作码保留到下一条指令执行时再使用,那么我们就能节省一个周期的执行时间,因为它就不需要再次获取相同的操作码字节了。

9.5.2.1 使用未使用的总线周期

我们仍然可以做更多的改进。当mov指令执行时,CPU 并不是在每个时钟周期都访问内存。例如,当数据被存储到目标寄存器时,数据总线是空闲的。数据总线空闲时,我们可以预取并保存下一条指令的操作码和操作数。

执行此操作的硬件是【预取队列】(gloss01.xhtml#gloss01_203)。图 9-4 展示了带有预取队列的 CPU 内部结构。

image

图 9-4:带有预取队列的 CPU 设计

总线接口单元(BIU),顾名思义,控制着地址总线和数据总线的访问。BIU 充当“交通警察”,处理不同模块(如执行单元和预取队列)对总线访问的同时请求。每当 CPU 内部的某个组件希望访问主存时,它会向 BIU 发送这个请求。

每当执行单元不使用总线接口单元(BIU)时,BIU 可以从存储机器指令的内存中获取更多字节,并将它们存储在预取队列中。然后,每当 CPU 需要指令操作码或操作数值时,它从预取队列中获取下一个可用字节。因为 BIU 一次从内存中获取多个字节,并且每个时钟周期内,CPU 通常从预取队列中消耗的字节数少于可用字节数,所以指令通常会在预取队列中待命,供 CPU 使用。

然而,并不能保证所有指令和操作数在需要时都能位于预取队列中。例如,考虑 80x86 的jnz Label;指令。如果该指令的 2 字节形式出现在内存的地址 400 和 401 处,预取队列可能包含地址 402、403、404、405、406、407 等位置的字节。如果jnz将控制转移到目标地址 480 处的Label,那么地址 402、403、404 等处的字节对 CPU 就没有用了。系统将不得不暂停片刻,以便从地址 480 处获取数据,才能继续执行。尽管如此,大多数时候,CPU 从内存中获取的是顺序值,因此将数据保存在预取队列中可以节省时间。

9.5.2.2 指令重叠

另一个改进是将解码下一个指令的操作码与执行前一个指令的最后一步操作重叠。在 CPU 处理操作数后,预取队列中下一个可用的字节是操作码,CPU 可以解码它,因为指令解码器在 CPU 执行当前指令的步骤时处于空闲状态。当然,如果当前指令修改了 EIP 寄存器,那么 CPU 在解码操作上花费的时间就会浪费;然而,由于这与当前指令的其他操作并行进行,因此这种解码不会拖慢系统速度(尽管需要额外的电路)。

9.5.2.3 总结背景预取事件

现在我们的指令执行顺序假设以下 CPU 预取事件正在后台(并行)发生:

  1. 如果预取队列未满(通常它可以容纳 8 到 32 字节,具体取决于处理器),并且 BIU 在当前时钟周期内处于空闲状态,则从 EIP 寄存器中找到的地址开始,获取下一个双字。

  2. 如果指令解码器处于空闲状态,并且当前指令不需要操作数,CPU 应开始解码预取队列前端的操作码。如果当前指令需要操作数,那么 CPU 会开始解码预取队列中位于该操作数之后的字节。

现在让我们重新考虑我们的mov(srcreg, destreg);指令。因为我们已经添加了预取队列和 BIU,我们可以将此指令的获取和解码阶段与前一个指令的特定阶段重叠,以得到以下步骤:

  1. 获取并解码指令;这与前一个指令重叠。

  2. 获取源寄存器,并使用下一个指令的地址更新 EIP 寄存器。

  3. 将获取的值存储到目标寄存器中。

本示例中的指令执行时序假设操作码已存在于预取队列中,并且 CPU 已对其进行解码。如果两者之一不成立,则需要额外的周期来从内存中获取操作码并解码指令。

9.5.3 阻碍预取队列性能的条件

跳转和条件跳转指令在转移控制到目标位置时比其他指令慢,因为 CPU 不能将获取和解码下一个指令的操作与执行跳转指令的过程重叠,这会导致控制转移。在执行跳转指令之后,预取队列可能需要几个周期才能重新加载。

注意

如果你想写出快速的代码,尽量避免在程序中跳来跳去。

条件跳转指令只有在实际跳转到目标位置时才会使预取队列失效。如果跳转条件为false,执行将继续进行下一条指令,且预取队列中的值保持有效。因此,在编写程序时,如果你能确定哪个跳转条件最常发生,应安排程序使最常见的条件导致程序继续执行下一条指令,而不是跳转到其他位置。

此外,指令大小(以字节为单位)会影响预取队列的性能。指令越大,CPU 清空预取队列的速度就越快。涉及常数和内存操作数的指令通常是最大的。如果你连续执行一系列这样的指令,CPU 可能会因为它从预取队列中移除指令的速度快于 BIU 将数据复制到预取队列的速度而不得不等待。因此,尽可能使用较短的指令。

最后,预取队列在数据总线较宽时表现最佳。16 位的 8086 处理器比 8 位的 8088 运行得更快,因为它可以用更少的总线访问来保持预取队列的满状态。别忘了,CPU 需要使用总线进行其他操作。访问内存的指令与预取队列争夺对总线的访问。如果你有一系列访问内存的指令,预取队列可能会很快被清空,一旦发生这种情况,CPU 必须等待 BIU 从内存中获取新的操作码,才能继续执行指令。

9.5.4 流水线:重叠执行多条指令

使用 BIU 和执行单元并行执行指令是流水线的一个特例。大多数现代处理器都采用流水线技术来提高性能。除少数例外,流水线使我们能够每个时钟周期执行一条指令。

预取队列的优势在于它允许 CPU 将获取和解码指令操作码的过程与其他指令的执行过程重叠。假设你愿意添加硬件,你几乎可以并行执行所有操作。这就是流水线的基本思想。

流水线操作通过并行执行多条指令来提高应用程序的平均性能。然而,正如你在预取队列中看到的那样,某些指令(及其组合)在流水线系统中表现得比其他指令更好。通过理解流水线操作的工作原理,你可以组织你的应用程序,使其运行得更快。

9.5.4.1 一个典型的流水线

考虑执行一个通用操作所需的步骤,每个步骤都需要一个时钟周期:

  1. 从内存中获取指令的操作码。

  2. 解码操作码(如果需要)预取位移操作数、常数操作数或两者。

  3. 如果需要,计算内存操作数的有效地址(例如,[ebx+disp])。

  4. 如有需要,获取任何内存操作数和/或寄存器的值。

  5. 计算结果。

  6. 将结果存储到目标寄存器中。

假设你愿意为一些额外的硅片付费,你可以为每个步骤构建一个小型微处理器来处理。其组织结构类似于图 9-5 所示。

image

图 9-5:指令执行的流水线实现

在第 4 阶段,CPU 获取源操作数和目的操作数。你可以通过在 CPU 内部放置多个数据路径(例如从寄存器到算术逻辑单元 ALU)并确保没有两个操作数会同时竞争数据总线的使用来设置这一点(也就是说,没有内存到内存的操作)。

如果你为流水线中的每个阶段设计一个单独的硬件组件,如图 9-5 所示,它们几乎都可以并行执行。当然,你不能同时获取和解码多个指令的操作码,但你可以在解码当前指令的操作码时,提前获取下一条指令的操作码。如果你有一个n阶段的流水线,通常会有n条指令并发执行。图 9-6 展示了流水线操作的情况。T1、T2、T3 等代表系统时钟的连续“滴答”(时间 = 1,时间 = 2,依此类推)。

image

图 9-6:流水线中的指令执行

在时间 T = T1 时,CPU 获取第一条指令的操作码字节。在 T = T2 时,CPU 开始解码第一条指令的操作码,并且并行地从预取队列中获取一块字节,假设第一条指令有一个操作数。同时,CPU 还指示 BIU 获取第二条指令的操作码,因为第一条指令不再需要那个电路。

注意,这里存在一个小冲突。CPU 正试图从预取队列中获取下一个字节作为操作数;同时,它也在从预取队列中获取操作数数据作为操作码。它如何能同时做这两件事呢?稍后你将看到解决方案。

在时间 T = T3 时,CPU 计算任何内存操作数的地址,如果第一条指令访问了内存。如果第一条指令不访问内存,CPU 什么也不做。在 T3 期间,CPU 还解码第二条指令的操作码,并获取第二条指令中的操作数。最后,CPU 还获取第三条指令的操作码。随着时钟每一次的前进,流水线中每条指令的另一个执行阶段完成,CPU 又从内存中获取下一条指令的操作码。

这个过程持续进行,直到 T = T6 时,CPU 完成了第一条指令的执行,计算了第二条指令的结果,并获取了流水线中第六条指令的操作码。需要注意的重要事项是,在 T = T5 之后,CPU 在每个时钟周期内都会完成一条指令。一旦 CPU 填充了流水线,它就在每个周期完成一条指令。即使有复杂的寻址模式需要计算、内存操作数需要获取,或其他在非流水线处理器上消耗周期的操作,这一点也是成立的。你所需要做的只是增加更多的阶段到流水线中,你仍然可以在一个时钟周期内有效地处理每一条指令。

现在回到我之前提到的流水线组织中的小冲突。例如,在 T = T2 时,CPU 尝试预取包含第一条指令任何操作数的一块字节,同时它还获取第二条指令的操作码。在 CPU 解码第一条指令之前,它不知道该指令需要多少个操作数,也不知道它们的长度。而且,直到它确定了这些信息,CPU 才知道该获取哪一个字节作为第二条指令的操作码。那么,流水线如何能与当前指令的任何地址操作数并行获取下一条指令的操作码呢?

一种解决方案是禁止同时操作,以避免潜在的数据危害。如果一条指令有地址或常量操作数,我们可以简单地延迟下一条指令的开始。不幸的是,许多指令都有这些额外的操作数,因此这种方法会显著阻碍 CPU 的执行速度。

第二种解决方案是投入更多硬件来解决问题。操作数和常量的大小通常为 1 字节、2 字节或 4 字节。因此,如果我们实际从内存中获取位于当前操作码解码位置之后 1 字节、3 字节和 5 字节的字节,那么其中之一可能包含下一条指令的操作码。一旦我们解码完当前指令,就知道它消耗了多少字节,因此我们也知道下一条操作码的偏移位置。我们可以使用一个简单的数据选择电路来选择三个候选操作码字节中我们要使用的字节。

在实际操作中,我们实际上需要从三个以上的候选项中选择下一个操作码字节,因为 80x86 指令有多种不同的长度。例如,一个将 32 位常量复制到内存位置的mov指令可能长达 10 个字节或更多。此外,指令的长度从 1 字节到 15 字节不等。并且 80x86 上的一些操作码超过 1 字节,因此 CPU 可能需要获取多个字节才能正确解码当前指令。然而,通过投入更多硬件,我们可以在获取下一条指令的同时解码当前的操作码。

9.5.4.2 流水线中的停顿

不幸的是,上一节中呈现的场景过于简单。我们的简单流水线忽略了两个问题:指令之间对总线的访问竞争(即总线竞争)和非顺序指令执行。这两个问题可能会增加流水线中指令的平均执行时间。通过了解流水线的工作原理,你可以编写软件以避免这些陷阱,从而提高应用程序的性能。

总线竞争可能发生在任何一条指令需要访问内存中的某个项时。例如,如果mov(reg, mem);指令需要将数据存储到内存中,而mov(mem, reg);指令需要从内存中取数据,那么由于 CPU 试图同时执行这两个操作,可能会发生地址和数据总线的竞争。

处理总线竞争的一个简单方法是通过流水线停顿。当 CPU 面临总线竞争时,会优先处理流水线中已执行得最远的指令。这会导致流水线中较后的指令停顿,并且该指令的执行需要两个时钟周期(参见图 9-7)。

image

图 9-7:流水线停顿

还有许多其他总线竞争的情况。例如,获取操作数的指令需要访问预取队列,同时 CPU 还需要访问该队列以获取下一条指令的操作码。鉴于我们迄今为止概述的简单流水线方案,大多数指令不太可能以每条指令一个时钟周期(CPI)来执行。

作为流水线停顿的另一个例子,考虑当一条指令修改EIP 寄存器中的值时会发生什么。例如,如果jnz指令将控制转移到目标标签,它可能会改变 EIP 寄存器中的值,这意味着下一组要执行的指令并不紧接在jnz指令之后。当指令jnz label;执行完毕时(假设零标志位清除,因此分支被采取),我们已经启动了另外五条指令,而且距离完成第一条指令只剩一个时钟周期。CPU 必须避免执行这些指令,否则它将计算出不正确的结果。

唯一合理的解决方案是刷新整个流水线,并重新开始获取操作码。然而,这样做会导致严重的执行时间惩罚。在我们的示例中,下一条指令完成执行需要流水线的长度(六个周期)。流水线越长,系统每个周期可以完成的任务就越多,但如果程序跳跃很频繁,执行速度就越慢。不幸的是,你无法控制流水线的阶段数^(4),但你可以控制程序中的传输指令数量,因此在流水线系统中最好将这些指令保持在最小限度。

9.5.5 指令缓存:提供多条内存路径

系统设计师可以通过智能使用预取队列和缓存内存子系统来解决许多总线竞争问题。正如你所看到的,他们可以设计预取队列来缓冲来自指令流的数据。然而,他们也可以使用一个独立的指令缓存(与数据缓存分开)来存储机器指令。作为程序员,你无法控制 CPU 的指令缓存是如何组织的,但了解它的工作方式可能会促使你使用某些指令序列,这些序列在其他情况下可能会导致停顿。

假设 CPU 有两个独立的内存空间,一个用于指令,另一个用于数据,每个内存空间都有自己的总线。这被称为哈佛架构,因为第一台这样的机器是在哈佛大学建造的。在哈佛机器上,不会发生总线竞争;BIU 可以继续在指令总线上获取操作码,同时访问数据/内存总线上的内存(参见图 9-8)。

image

图 9-8:典型的哈佛架构机器

在现实世界中,真正的哈佛架构机器非常少。为了支持两个物理上独立的总线,处理器上需要额外的引脚,这增加了处理器的成本并引入了许多其他工程问题。然而,微处理器设计师发现,通过使用独立的片上缓存来存储数据和指令,他们可以在很少有其缺点的情况下获得哈佛架构的许多优点。先进的 CPU 使用内部哈佛架构和外部冯·诺依曼架构。图 9-9 展示了带有独立数据和指令缓存的 80x86 结构。

image

图 9-9:使用独立的代码和数据缓存

CPU 内部各部分之间的每条路径代表一个独立的总线,数据可以在所有路径上并行流动。这意味着预取队列可以从指令缓存中拉取指令操作码,而执行单元则将数据写入数据缓存。然而,即使有缓存,也不总是能避免总线竞争。在有两个独立缓存的安排中,BIU 仍然需要使用数据/地址总线从内存中提取操作码,只要它们不在指令缓存中。同样,数据缓存仍然需要偶尔从内存中缓存数据。

尽管你无法控制 CPU 上缓存的存在、大小或类型,但你必须了解缓存的工作原理,以便编写最佳程序。在芯片上,一级(L1)指令缓存通常非常小(在典型的 CPU 上,介于 4KB 和 64KB 之间),与主内存的大小相比,因此,指令越短,就能装入缓存的指令越多(你还厌烦“短指令”了吗?)。缓存中存储的指令越多,总线竞争发生的频率就越低。同样,使用寄存器保存临时结果会减少对数据缓存的压力,这样就不需要频繁地将数据刷新到内存或从内存中检索数据了。

9.5.6 流水线危害

使用流水线还有另一个问题:危害。危害有两种类型:控制危害和数据危害。我们实际上已经讨论过控制危害,尽管没有按名称提及。当 CPU 跳转到内存中的某个新位置时,控制危害就会发生,随之而来的是必须从流水线中清除各个执行阶段的指令。数据危害发生在两条指令试图按顺序访问同一个内存位置时。

让我们通过以下指令序列的执行概况来看一下数据危害:

mov( SomeVar, ebx );

mov( [ebx], eax );

当这两条指令执行时,流水线的状态会像图 9-10 一样。

image

图 9-10:数据危害

这两条指令试图获取地址存储在 SomeVar 指针变量中的 32 位值。然而,这个指令序列无法正常工作! 第二条指令在第一条指令将内存位置 SomeVar 的地址复制到 EBX 之前就访问了 EBX 中的值(图 9-10 中的 T5 和 T6)。

像 80x86 这样的 CISC 处理器会自动处理危害。(有些 RISC 芯片不会,如果你在某些 RISC 芯片上尝试这个序列,你将把一个错误的值存储在 EAX 中。)为了处理这个示例中的数据危害,CISC 处理器会暂停流水线以同步这两条指令。实际执行可能看起来像图 9-11。

image

图 9-11:CISC CPU 如何处理数据危害

通过将第二条指令延迟两个时钟周期,CPU 确保加载指令会在正确的地址加载 EAX 的值。不幸的是,mov([ebx], eax);指令现在需要三个时钟周期才能执行,而不是一个时钟周期。然而,增加两个时钟周期总比产生错误的结果要好。

幸运的是,你(或你的编译器)可以减少程序执行速度中,由于冲突带来的影响。数据冲突发生在当一条指令的源操作数是前一条指令的目的操作数时。将 EBX 从 SomeVar 加载然后再从[EBX]加载 EAX(即,EBX 指向的双字内存位置)是没有问题的,只要它们不是紧接着执行的。假设代码序列是:

mov( 2000, ecx );

mov( SomeVar, ebx );

mov( [ebx], eax );

我们可以通过简单地重新排列指令来减少该代码序列中冲突的影响,具体如下:

mov( SomeVar, ebx );

mov( 2000, ecx );

mov( [ebx], eax );

现在,mov([ebx], eax);指令只需要一个额外的时钟周期。通过在mov(SomeVar, ebx);mov([ebx], eax);指令之间插入另一条指令,你可以完全消除冲突的影响(当然,插入的指令不能修改 EAX 和 EBX 寄存器中的值)。

在一个流水线处理器中,程序中指令的顺序可能会极大地影响程序的性能。如果你在编写汇编代码,始终要注意可能的冲突,并通过重新排列指令序列尽可能消除它们。如果你使用的是编译器,选择一个能够正确处理指令顺序的编译器。

9.5.7 超标量操作:并行执行指令

在目前的流水线架构下,最好的执行时间是每条指令一个 CPI。是否可以比这个更快地执行指令呢?一开始你可能会想,“当然不行——我们每个时钟周期最多只能执行一条操作,所以我们不可能每个时钟周期执行多条指令。”然而,请记住,一个指令并非一个操作。在之前的例子中,每条指令完成的操作数量在六到八个之间。通过在 CPU 中添加七到八个独立的单元,我们可以有效地在一个时钟周期内执行这八个操作,从而得到一个 CPI。如果我们添加更多硬件,并一次性执行例如 16 个操作,我们能达到 0.5 CPI 吗?答案是有条件的“可以”。一个包含这些额外硬件的 CPU 被称为超标量CPU,它可以在一个时钟周期内执行多条指令。80x86 家族从 Pentium 处理器开始支持超标量执行。

超标量 CPU 拥有多个执行单元(见图 9-12)。如果它在预取队列中遇到两条或更多条可以独立执行的指令,它将同时执行它们。

image

图 9-12:支持超标量操作的 CPU

使用超标量有几个优点。假设指令流中有以下指令:

mov( 1000, eax );

mov( 2000, ebx );

如果周围代码没有其他问题或冒险,而且这两条指令的 6 个字节目前都在预取队列中,那么没有理由不能并行获取并执行这两条指令。实现这一点只需要在 CPU 芯片上增加额外的硅片来实现两个执行单元。

除了加速独立指令外,超标量 CPU 还可以加速具有数据冒险的程序序列。普通 CPU 的一项限制是,一旦发生冒险,受影响的指令会完全停滞管线。随后的每条指令也必须等待 CPU 同步执行被阻塞的指令。然而,超标量 CPU 可以在不具有自身冒险的情况下,继续执行那些跟随冒险指令的指令。这减轻了(虽然并未消除)对精确指令调度的某些需求。

你编写针对超标量 CPU 的软件方式会显著影响其性能。最重要的是你现在可能已经厌烦的规则:使用短指令。指令越短,CPU 每次操作中可以获取的指令就越多,因此,CPU 执行速度比每周期指令数(CPI)大于 1 的情况更有可能更快。大多数超标量 CPU 并没有完全复制执行单元。可能有多个算术逻辑单元(ALU)、浮点单元等,这意味着某些指令序列可以非常快速地执行,而其他指令则不能。你需要研究 CPU 的精确组成,以决定哪些指令序列能产生最佳的性能。

9.5.8 非顺序执行

在标准超标量 CPU 中,安排指令以避免冒险和管线停滞是程序员(或编译器)的责任。更先进的 CPU 实际上可以通过在程序执行过程中自动重新调度指令,减轻这一负担并提高性能。为了理解如何实现这一点,考虑以下的指令序列:

mov( SomeVar, ebx );

mov( [ebx], eax );

mov( 2000, ecx );

第一条和第二条指令之间存在数据冒险。第二条指令必须等待直到第一条指令执行完毕。这会导致流水线停顿,并增加程序的运行时间。通常,停顿会影响随后的每一条指令。然而,第三条指令的执行不依赖于前两条指令的任何结果。因此,mov(2000, ecx); 指令的执行不需要停顿。它可以在第二条指令等待第一条指令完成时继续执行。这种技术被称为 乱序执行,因为 CPU 可以在代码流中出现的指令完成之前就执行这些指令。

请记住,CPU 只能在确保执行顺序与顺序执行结果完全相同的情况下乱序执行指令。虽然有许多技术问题使得这一特性比看起来更为复杂,但只要付出足够的工程努力,还是可以实现的。

9.5.9 寄存器重命名

一个限制 80x86 CPU 超标量操作效能的问题是其有限数量的通用寄存器。例如,假设 CPU 拥有四个不同的流水线,因此能够同时执行四条指令。假设这些指令之间没有冲突,且可以同时执行,那么实际上很难在每个时钟周期内实现四条指令同时执行,因为大多数指令需要操作两个寄存器操作数。为了实现四条指令同时执行,你需要八个不同的寄存器:四个目标寄存器和四个源寄存器(任何目标寄存器都不能作为其他指令的源寄存器)。拥有大量寄存器的 CPU 可以轻松处理这个任务,但 80x86 的寄存器有限,使得这一任务变得困难。幸运的是,有一个技巧可以缓解部分问题:寄存器重命名

寄存器重命名是一种巧妙的方式,能够让 CPU 拥有比实际更多的寄存器。程序员无法直接访问这些额外的寄存器,但 CPU 可以利用它们在某些情况下防止发生数据冒险。例如,考虑以下的简短指令序列:

mov( 0, eax );

mov( eax, i );

mov( 50, eax );

mov( eax, j );

第一条和第二条指令之间、第三条和第四条指令之间都有数据冲突。在超标量 CPU 中,乱序执行通常会允许第一条和第三条指令同时执行,然后第二条和第四条指令也可以同时执行。然而,第一条和第三条指令之间也存在数据冲突,因为它们使用了相同的寄存器。程序员本可以通过使用不同的寄存器(比如 EBX)来解决第三条和第四条指令之间的冲突。然而,假设程序员无法这么做,因为所有其他寄存器都保存着重要的值。这个指令序列是否注定会在应该只需两个周期的超标量 CPU 上执行四个周期?

CPU 可以采用的一个高级技巧是为每个通用寄存器创建一个寄存器池。也就是说,CPU 可以支持一个 EAX 寄存器数组,而不是只有一个 EAX 寄存器;我们可以将这些寄存器称为 EAX[0]、EAX[1]、EAX[2],以此类推。同样,你也可以为其他寄存器创建一个数组:EBX[0]到 EBX[n]、ECX[0]到 ECX[n],以此类推。指令集不允许程序员为某条指令选择这些特定寄存器数组元素中的一个,但如果这样做不会改变整体计算并且能够加速程序执行,CPU 可以自动选择其中的一个。这就是寄存器重命名。例如,考虑以下序列(由 CPU 自动选择寄存器数组元素):

mov( 0, eax[0] );

mov( eax[0], i );

mov( 50, eax[1] );

mov( eax[1], j );

因为 EAX[0]和 EAX[1]是不同的寄存器,CPU 可以同时执行第一条和第三条指令。同样,CPU 也可以同时执行第二条和第四条指令。

尽管这是一个简单的示例,而且不同的 CPU 在实现寄存器重命名时有不同的方式,但你可以看到 CPU 如何利用这一技术来提升性能。

9.5.10 VLIW 架构

超标量操作尝试在硬件中调度多个指令同时执行。另一种技术是 Intel 在其 IA-64 架构中使用的超长指令字(VLIW)。在 VLIW 计算机系统中,CPU 会提取一个大的字节块(对于 IA-64 Itanium CPU 是 41 位),并一次性解码并执行。这块字节块通常包含两条或更多指令(在 IA-64 中是三条)。VLIW 计算要求程序员或编译器正确调度每个块中的指令,以避免任何数据冲突或其他问题,但如果一切顺利,CPU 每个时钟周期可以执行三条或更多指令。

9.5.11 并行处理

大多数通过架构进步提高 CPU 性能的技术都涉及指令的并行执行。如果程序员了解底层架构,他们可以编写更快的代码,但即使程序员没有编写特殊代码来利用这些架构进步,这些进步也常常能显著提升性能。

忽视底层架构的唯一问题是,硬件在将需要顺序执行才能正常运行的程序并行化方面所能做的有限。要真正生成并行程序,程序员必须专门编写并行代码,当然,这也需要 CPU 的架构支持。本节及下一节将涉及 CPU 可以提供的支持类型。

常见的 CPU 使用被称为单指令单数据(SISD)模型。这意味着 CPU 一次只执行一条指令,并且该指令只操作一片数据。^(5) 两种常见的并行模型是单指令多数据(SIMD)多指令多数据(MIMD)模型。许多现代 CPU,包括 80x86,都在一定程度上支持这些并行执行模型,提供了一种混合的 SISD/SIMD/MIMD 架构。

在 SIMD 模型中,CPU 执行单一的指令流,就像纯 SISD 模型一样,但它并行地操作多个数据片段。例如,考虑 80x86 的add指令。这是一个 SISD 指令,操作(即生成)单个数据。的确,该指令从两个源操作数中获取值,但最终结果是add指令只将和存储到一个目标操作数中。另一方面,SIMD 版本的add会同时计算多个和。例如,通过paddb MMX 指令,你可以在执行单一指令的情况下,加法最多达到八对独立的值。以下是此指令的示例:

paddb( mm0, mm1 );

尽管该指令看起来只有两个操作数(像典型的 80x86 的 SISD add指令),但 MMX 寄存器(MM0 和 MM1)实际上存储着八个独立的字节值(MMX 寄存器是 64 位宽,但被视为八个 8 位值)。

除非你有一个能够利用 SIMD 指令的算法,否则它们并不那么有用。幸运的是,高速 3D 图形和多媒体应用程序从这些 SIMD(和 MMX)指令中受益匪浅,因此它们在 80x86 CPU 中的加入为这些重要应用程序提供了巨大的性能提升。

MIMD 模型使用多个指令,操作多个数据块(通常每个数据对象使用一条指令,尽管这些指令中的某一条也可能操作多个数据项)。这些指令彼此独立执行,因此很少有单个程序(或者更具体地说,单个执行线程)会使用 MIMD 模型。然而,如果你有一个多任务环境,其中多个程序试图同时执行,MIMD 模型确实允许每个程序同时执行自己的代码流。这种类型的并行系统被称为多处理器系统

9.5.12 多处理

流水线技术、超标量操作、乱序执行和 VLIW 设计都是 CPU 设计师用来并行执行多个操作的技术。这些技术支持精细粒度并行性,并有助于加速计算机系统中相邻指令的执行。如果增加更多的功能单元可以提高并行性,那么如果你向系统中添加另一个 CPU 会发生什么呢?这种方法称为多处理,它可以提高系统性能,尽管没有其他技术那样均匀。

多处理并不会提高程序的性能,除非该程序是专门为在多处理器系统上运行而编写的。如果你构建一个有两个 CPU 的系统,这些 CPU 无法在单个程序中交替执行指令。将程序的指令从一个处理器切换到另一个处理器在时间上是非常昂贵的。因此,多处理器系统仅在能够并发执行多个进程或线程的操作系统中有效。为了将这种类型的并行性与流水线和超标量操作提供的并行性区分开来,我们称其为粗粒度并行性

向系统中添加多个处理器并不像将两个或多个处理器接入主板那样简单。为了理解为什么会这样,考虑在一个多处理器系统中运行的两个独立程序,这两个程序分别运行在不同的处理器上。这两个处理器通过写入共享物理内存块来相互通信。当 CPU 1 向该内存块写入数据时,它会在本地缓存数据,并且可能不会立即将数据写入物理内存。如果 CPU 2 尝试同时读取这个共享内存块,它最终从主内存(或其本地缓存)中读取旧数据,而不是读取 CPU 1 写入其本地缓存的更新数据。这就是缓存一致性问题。为了让这两个功能正常运行,这两个 CPU 必须在修改共享对象时互相通知对方,以便另一个 CPU 可以更新其本地缓存的副本。

多处理是 RISC CPU 相对于英特尔 CPU 的一个重要优势领域。虽然英特尔 80x86 系统在大约 32 个处理器时会遇到收益递减的情况,Sun SPARC 和其他 RISC 处理器则轻松支持 64 个 CPU 系统(而且似乎每天都有更多的处理器问世)。这就是为什么大型数据库和大型网页服务器系统倾向于使用昂贵的基于 Unix 的 RISC 系统,而非 80x86 系统。

更新版的英特尔 i 系列和 Xeon 处理器支持一种称为超线程技术的混合多处理方式。超线程技术背后的理念看似简单——在典型的超标量处理器中,很少有指令序列能够在每个时钟周期内充分利用 CPU 的所有功能单元。与其让这些功能单元空闲,CPU 可以同时运行两个独立的执行线程,从而保持所有功能单元都在工作。这使得单个 CPU 在典型的多处理器系统中,能够有效地完成相当于 1.5 个 CPU 的工作量。

9.6 更多信息

Hennessy, John L., 和 David A. Patterson. 计算机架构:定量方法. 第 5 版. Waltham, MA: Elsevier, 2012.

注意

本章中缺少的一个主题是 CPU 实际指令集的设计。这是下一章的内容。

第十章:10

指令集架构

Image

本章讨论了 CPU 指令集的实现。尽管给定指令集的选择通常超出了软件工程师的控制范围,但理解硬件设计工程师在设计 CPU 指令集时必须做出的决策,肯定能帮助你编写更高效的代码。

CPU 指令集包含若干基于计算机架构师对软件工程师编写代码方式假设的权衡。如果你选择的机器指令符合这些假设,你的代码可能会运行得更快,并且需要更少的机器资源。相反,如果你的代码违反了这些假设,它的性能很可能不会像本应表现得那样好。

虽然研究指令集看似只适合汇编语言程序员,但即便是高级语言程序员也能从中受益。毕竟,每个高级语言(HLL)语句都会映射到某些机器指令序列,而指令集设计的一般概念在不同架构之间是可以迁移的。即使你从不打算使用汇编语言编写软件,理解底层机器指令的工作原理以及它们是如何设计的依然很重要。

10.1 指令集设计的重要性

尽管像缓存、流水线和超标量实现这样的特性可以在原始设计过时之后加入到 CPU 中,但一旦 CPU 进入生产阶段并且人们开始使用它编写软件,改变指令集就变得非常困难。因此,指令集设计需要非常谨慎的考虑;设计师必须从设计周期的开始就确保【指令集架构(ISA)】(gloss01.xhtml#gloss01_125)的正确性。

你可能会认为“厨房水槽”式的指令集设计方法——即你能想到的每一条指令都包括在内——是最好的。然而,指令集设计是妥协管理的典范。为什么我们不能拥有一切?实际上,现实世界中有一些让人难以接受的现实阻止了这一点:

硅片资源 第一个现实问题是,每个特性都需要在 CPU 的硅片(芯片)上占用一定数量的晶体管,因此 CPU 设计师有一个“硅片预算”——即有限的晶体管数量。显然,没有足够的晶体管来支持在 CPU 上加入每一个可能的特性。例如,原始的 8086 处理器的硅片预算不到 30,000 个晶体管。1999 年的 Pentium III 处理器的预算超过 900 万个晶体管。2019 年的 AWS Graviton2(ARM)CPU 拥有超过 300 亿个晶体管。(^(1))这三种预算反映了从 1978 年到今天半导体技术的差异。

成本 尽管今天在 CPU 上使用数十亿个晶体管是可能的,但使用的晶体管越多,CPU 的成本就越高。例如,在 2018 年初,使用数十亿晶体管的英特尔 i7 处理器的价格为数百美元,而当时的现代 CPU 拥有 30,000 个晶体管,价格却低于一美元。

可扩展性 很难预见人们未来会需要哪些功能。例如,英特尔的 MMX 和 SIMD 指令扩展是为了使多媒体编程在奔腾处理器上更加实用而添加的。回到 1978 年,当英特尔创建第一款 8086 处理器时,几乎没有人能预测到这些指令的需求。CPU 设计师必须为在未来的 CPU 系列中扩展指令集预留空间,以满足当前无法预见的需求。

对旧指令的遗留支持 这种残酷的现实几乎与可扩展性相反。通常,CPU 设计师认为重要的某个指令,最终会发现它的实用性远不如预期。例如,80x86 CPU 上的 loopenter 指令在现代高性能程序中几乎没有使用。通常情况下,采用万象俱全方法的 CPU 中有些指令根本不会被程序使用。不幸的是,一旦指令被添加到指令集中,它必须在所有未来版本的处理器中得到支持,除非极少数程序使用该指令,以至于 CPU 设计师愿意让这些程序不再支持。

复杂性 一个 CPU 设计师必须考虑到汇编程序员和编译器开发人员将如何使用这个芯片。采用万象俱全方法的 CPU 可能会吸引已经熟悉该 CPU 的人,但其他人不会愿意学习一个过于复杂的系统。

采用万象俱全方法所面临的问题有一个共同的解决方案:为 CPU 的第一个版本设计一个简单的指令集,并为后续扩展留下空间。这是 80x86 之所以如此受欢迎且长寿的主要原因之一。英特尔从一个相对简单的 CPU 开始,并在多年里找到了一种扩展指令集的方法,以适应新的功能。

10.2 基本指令设计目标

你的程序效率在很大程度上取决于它们使用的指令。短指令使用极少的内存,且通常执行速度快,但它们无法处理大型任务。较大的指令可以处理更复杂的任务,单个指令通常能完成多个短指令的工作,但它们可能会消耗过多内存或需要执行多个机器周期。为了让软件工程师能够编写出最佳的代码,计算机架构师必须在两者之间找到平衡。

在典型的 CPU 中,计算机将指令编码为数字值(操作码,或opcodes),并将其存储在内存中。对这些指令进行编码是指令集设计中的一项主要任务,需要仔细考虑。每条指令必须有一个唯一的操作码,以便 CPU 能够区分它们。使用n位数字,可以有 2^(n)个不同的操作码,因此编码m条指令至少需要 log2 位。需要记住的要点是,单个 CPU 指令的大小取决于 CPU 支持的指令总数。

编码操作码比为每条指令分配一个唯一的数字值更复杂。如前一章所讨论的,解码每条指令并执行指定的任务需要实际的电路。对于一个 7 位操作码,我们可以编码 128 条不同的指令。要解码这 128 条指令,需要一个 7 到 128 行的解码器——这是一块昂贵的电路。然而,假设指令操作码包含某些(按位)模式,一个大型解码器通常可以通过几个更小、更便宜的解码器来替代。

如果一个指令集包含 128 个不相关的指令,除了对每条指令解码整个比特串外,你几乎无能为力。然而,在大多数架构中,指令是有分类的。例如,在 80x86 的 CPU 中,mov(eax, ebx);mov(ecx, edx); 有不同的操作码,因为它们是不同的指令,但显然它们是相关的,因为它们都将数据从一个寄存器移动到另一个寄存器。唯一的区别是它们的源操作数和目标操作数。因此,CPU 设计师可以用子操作码对像mov这样的指令进行编码,然后使用操作码中的其他位字段来编码指令的操作数。

例如,给定一个仅包含八条指令的指令集,每条指令有两个操作数,且每个操作数只有四个可能值中的一个,我们可以使用三个打包字段进行编码,分别包含 3、2 和 2 位(见图 10-1)。

image

图 10-1:将操作码分成几个字段以简化解码

这种编码只需要三个简单的解码器来确定 CPU 应该执行的操作。虽然这是一个基础示例,但它展示了指令集设计中的一个非常重要的方面:操作码应该易于解码。简化操作码的最简单方法是使用多个不同的位字段来构造它。这些位字段越小,硬件解码和执行指令就越容易。

因此,CPU 设计师的目标是为操作码的指令字段和操作数字段分配适当数量的位。为指令字段选择更多的位可以让操作码编码更多的指令,就像为操作数字段选择更多的位可以让操作码指定更多的操作数(通常是内存位置或寄存器)。你可能会认为,当使用 n 位编码 2^(n) 个不同的指令时,你在选择指令大小上几乎没有什么余地。为了编码这 2^(n) 个指令,肯定需要 n 位;你无法用更少的位来做到这一点。然而,n 位以上是可以使用的。这可能看起来浪费,但有时却是有利的。同样,选择适当的指令大小是指令集设计中更重要的方面之一。

10.2.1 选择操作码长度

操作码的长度不是任意的。假设 CPU 能够从内存中读取字节,那么操作码可能必须是 8 位的倍数。如果 CPU 无法从内存中读取字节(大多数 RISC CPU 只能以 32 位或 64 位为单位读取内存),那么操作码的大小将与 CPU 能够一次从内存读取的最小对象的大小相同。任何试图将操作码大小缩小到这一限制以下的尝试都是徒劳的。在本章中,我们将讨论第一种情况:操作码必须是 8 位的倍数。

另一个需要考虑的点是指令操作数的大小。一些 CPU 设计师将所有操作数包含在他们的操作码中。另一些 CPU 设计师则不将像立即数或地址偏移量这样的操作数算作操作码的一部分,我们将采用这种方法。

一个 8 位的操作码只能编码 256 个不同的指令。即使我们不将指令操作数算作操作码的一部分,只有 256 个不同指令也是一个严格的限制。尽管存在 8 位操作码的 CPU,但现代处理器通常有超过 256 个不同的指令。由于操作码的长度必须是 8 位的倍数,所以下一个最小的操作码大小是 16 位。一个 2 字节的操作码可以编码最多 65,536 个不同的指令,尽管这些指令会更大。

当减少指令大小是一个重要设计目标时,CPU 设计师通常会采用数据压缩理论。第一步是分析为典型 CPU 编写的程序,并统计每条指令在大量应用中出现的频率。第二步是创建这些指令的列表,并按使用频率进行排序。接下来,设计师将 1 字节的操作码分配给最常用的指令;2 字节的操作码分配给次常用的指令;3 字节或更多字节的操作码分配给使用较少的指令。尽管这种方案需要操作码的最大大小为 3 字节或更多,但大多数程序中的实际指令将使用 1 字节或 2 字节的操作码。平均操作码长度将在 1 字节和 2 字节之间(假设为 1.5 字节),而且典型程序的长度将比所有指令都采用 2 字节操作码时要短(参见图 10-2)。

image

图 10-2:使用可变长度操作码编码指令

尽管使用可变长度指令可以让我们创建更小的程序,但这也有代价。首先,解码可变长度指令比解码固定长度指令要复杂一些。在解码特定指令字段之前,CPU 必须首先解码指令的大小,这会消耗一定时间。这可能会影响 CPU 的整体性能,因解码步骤的延迟会限制 CPU 的最大时钟速度(因为这些延迟拉长了一个时钟周期,从而降低了 CPU 的时钟频率)。可变长度指令还会使得在流水线中解码多个指令变得困难,因为 CPU 无法轻松地确定预取队列中的指令边界。

由于这些原因及其他原因,大多数流行的 RISC 架构都避免使用可变长度指令。然而,在本章中,我们将研究一种可变长度方法,因为节省内存是一个值得追求的目标。

10.2.2 面向未来的规划

在实际选择要在 CPU 中实现的指令之前,设计师必须为未来做规划。如前所述,初始设计后,新的指令需求无疑会出现,因此明智的做法是专门为扩展预留一些操作码。鉴于图 10-2 中的指令操作码格式,预留 64 个 1 字节操作码块、半数(4,096 个)2 字节操作码和半数(1,048,576 个)3 字节操作码以备将来使用,这可能不是个坏主意。放弃 64 个极为宝贵的 1 字节操作码似乎有些奢侈,但历史表明,这种远见最终会得到回报。

10.2.3 选择指令

下一步是选择要实现的指令。即使近一半的指令已经为将来扩展预留,也并不意味着所有剩余的操作码必须用于实现指令。设计师可以将其中一些指令保持未实现,实际上也是为未来保留。正确的方法不是尽可能快地使用完操作码,而是根据设计妥协,生成一个一致且完整的指令集。添加指令比删除指令容易得多,因此,在第一次设计时,通常最好采用更简单的设计。

首先,选择一些通用的指令类型。在设计初期,限制选择常见的指令非常重要。其他处理器的指令集可能是寻找建议的最佳地方。例如,大多数处理器都有以下指令:

  • 数据移动指令(例如mov

  • 算术与逻辑指令(例如addsubandornot

  • 比较指令

  • 条件跳转指令(通常在比较指令后使用)

  • 输入/输出指令

  • 其他杂项指令

初始指令集应包括合理数量的指令,以使程序员能够编写高效的程序,同时不超出硅片预算或违反其他设计约束。这要求 CPU 设计师基于深入的研究、实验和仿真做出战略决策。

10.2.4 指令操作码分配

在选择初步指令后,CPU 设计师将为其分配操作码。这个过程的第一步是根据指令所共享的特征对指令进行分组。例如,add 指令可能支持与 sub 指令完全相同的操作数,因此将这两条指令分组是合乎逻辑的。另一方面,notneg 指令通常只需要一个操作数。因此,将这两条指令放入同一组是合理的,但与 addsub 分开。

一旦所有指令被分组,下一步就是对它们进行编码。典型的编码方案使用一些位来选择组别,使用一些位来选择该组中的特定指令,还有一些位用于编码操作数类型(例如寄存器、内存位置和常量)。编码所有这些信息所需的位数会直接影响指令的大小,而不管指令的使用频率如何。例如,假设需要 2 位来选择指令的组,4 位来选择该组中的指令,6 位来指定指令的操作数类型。在这种情况下,指令将无法适应 8 位的操作码。另一方面,如果我们只需要将 8 个不同寄存器中的一个推入栈中,那么 4 位就足以指定push指令组,3 位足以指定寄存器。

使用最少空间对指令操作数进行编码始终是一个问题,因为许多指令允许多个操作数。例如,通用的 32 位 80x86 mov指令允许两个操作数并且需要 2 字节的操作码。^(2) 然而,英特尔注意到mov(disp, eax);mov(eax, disp);在程序中经常出现,因此它创建了这些指令的特殊 1 字节版本,以减少它们的大小,从而减小使用它们的程序的大小。然而,英特尔并没有移除这些指令的 2 字节版本:有两个不同的指令将 EAX 存储到内存中,还有两个不同的指令从内存中加载 EAX。编译器或汇编器将始终生成每对指令中的较短版本。

英特尔在mov指令上做了一个重要的权衡:它放弃了一个额外的操作码,以提供每个指令变体的更短版本。事实上,英特尔在很多地方都使用了这个技巧,以创建更短且更易解码的指令。回到 1978 年,为了减少程序大小,创建冗余指令是一个很好的折衷方案,因为当时内存的成本较高。然而,今天的 CPU 设计师可能会将这些冗余操作码用于其他目的。

10.3 Y86 假设处理器

由于 80x86 处理器家族随着时间的推移进行了改进,英特尔在 1978 年的设计目标,以及计算机架构的演变,80x86 指令的编码非常复杂且有些不合逻辑。简而言之,80x86 并不是一个很好的指令集设计入门示例。为了绕过这一点,我们将分两个阶段讨论指令集设计:首先,我们将为 Y86 开发一个简单的指令集,Y86 是一个 80x86 的小子集,然后我们将扩展讨论到完整的 80x86 指令集。

10.3.1 Y86 的局限性

假设的 Y86 处理器是 80x86 CPU 的非常简化版本。它仅支持:

  • 一个操作数大小:16 位。这个简化使我们不必将操作数大小作为操作码的一部分进行编码(从而减少我们所需的操作码总数)。

  • 四个 16 位寄存器:AX、BX、CX 和 DX。这使我们能够仅用 2 位编码寄存器操作数(相比 80x86 系列需要 3 位来编码八个寄存器)。

  • 一个 16 位的地址总线,最大可寻址 65,536 字节的内存。

这些简化,加上非常有限的指令集,将使我们能够通过 1 字节的操作码和在适用时 2 字节的位移/偏移量来编码所有 Y86 指令。

10.3.2 Y86 指令

包括两种形式的 mov 指令,Y86 CPU 仍然只提供 18 条基本指令。这些指令中,七条有两个操作数,八条有一个操作数,五条没有操作数。指令包括 mov(两种形式)、addsubcmpandornotjejnejbjbejajaejmpgetputhalt

10.3.2.1 mov 指令

mov 指令有两种形式,合并到同一个指令类别中:

mov( reg/memory/constant, reg );

mov( reg, memory );

在这些形式中,reg 是寄存器 axbxcxdx;内存是指定内存位置的操作数;常数是使用十六进制表示的数字常数。

10.3.2.2 算术和逻辑指令

算术和逻辑指令如下:

add( reg/memory/constant, reg );

sub( reg/memory/constant, reg );

cmp( reg/memory/constant, reg );

and( reg/memory/constant, reg );

or( reg/memory/constant, reg );

not( reg/memory );

add 指令将第一个操作数的值加到第二个操作数的值,并将结果存储在第二个操作数中。sub 指令将第一个操作数的值从第二个操作数的值中减去,并将差值存储在第二个操作数中。cmp 指令将第一个操作数的值与第二个操作数的值进行比较,并保存比较结果以供条件跳转指令(在下一节中描述)使用。andor 指令计算它们两个操作数之间的按位逻辑运算,并将结果存储在第二个操作数中。not 指令单独出现,因为它只支持单个操作数。not 是按位逻辑操作,它反转其单个内存或寄存器操作数的位。

10.3.2.3 控制转移指令

控制转移指令 中断顺序存储在内存位置的指令执行,并将控制权转移到存储在内存其他位置的指令。这些指令可以是无条件的,也可以是根据 cmp 指令的结果有条件地执行。控制转移指令包括:

ja   dest;  // Jump if above (i.e., greater than)

jae  dest;  // Jump if above or equal (i.e., greater than or equal to)

jb   dest;  // Jump if below (i.e., less than)

jbe  dest;  // Jump if below or equal (i.e., less than or equal to)

je   dest;  // Jump if equal

jne  dest;  // Jump if not equal

jmp  dest;  // Unconditional jump

前六条指令(jajaejbjbejejne)允许你检查前一条 cmp 指令的结果——即该指令的第一个和第二个操作数的比较结果。^(3) 例如,如果你使用 cmp(ax, bx); 指令比较 AX 和 BX 寄存器,然后执行 ja 指令,若 AX 大于 BX,Y86 CPU 将跳转到指定的目标位置。如果 AX 不大于 BX,控制流将继续执行程序中的下一条指令。与前六条指令不同,jmp 指令无条件地将控制转移到目标地址的指令。

10.3.2.4 杂项指令

Y86 支持三条没有操作数的指令:

get;   // Read an integer value into the AX register

put;   // Display the value in the AX register

halt;  // Terminate the program

getput 指令让你读取和写入整数值:get 提示用户输入一个十六进制值,并将该值存储到 AX 寄存器中;put 显示 AX 寄存器的十六进制值。halt 指令终止程序执行。

10.3.3 Y86 的操作数类型和寻址模式

在分配操作码之前,我们需要查看这些指令支持的操作数。18 条 Y86 指令使用五种不同的操作数类型:寄存器、常数以及三种内存寻址模式(间接寻址模式、索引寻址模式和直接寻址模式)。有关这些寻址模式的更多细节,请参见第六章。

10.3.4 编码 Y86 指令

因为真实的 CPU 使用逻辑电路解码操作码并相应地执行操作,所以随意分配操作码给机器指令并不好。通常,CPU 操作码使用一定数量的比特来表示指令类别(如 movaddsub),并使用一定数量的比特来编码每个操作数。

一条典型的 Y86 指令如图 10-3 所示。

image

图 10-3:基本 Y86 指令编码

基本指令长度为 1 或 3 字节,其操作码由一个字节组成,该字节包含三个字段。第一个字段由高位 3 比特组成,用于定义指令,这 3 比特提供了八种可能的组合。由于有 18 条不同的 Y86 指令,我们需要采取一些方法来处理剩余的 10 条指令。

10.3.4.1 八条通用 Y86 指令

如图 10-3 所示,八个基本操作码中的七个用于编码 orandcmpsubadd 指令,以及两种版本的 mov 指令。第八个 000扩展操作码。这个特殊的指令类别,我们稍后会讨论,它提供了一种机制,允许我们扩展可用指令集。

要确定特定指令的完整操作码,你只需选择适当的iiirrmmm字段的位(见图 10-3)。rr字段包含目标寄存器(除了mov指令的版本,其iii字段为111),mmm字段编码源寄存器。例如,要编码mov(bx, ax);指令,你需要选择iii = 110mov(reg, reg);),rr = 00ax),mmm = 001bx)。这将生成 1 字节的指令%11000001,即$c0

一些 Y86 指令的大小大于 1 字节。为了说明为什么需要这种方式,举个例子,考虑mov([1000], ax);指令,它将存储在内存位置$1000的值加载到 AX 寄存器中。该操作码的编码是%11000110,即$c6。然而,mov([2000], ax);指令的编码也是$c6。显然,这两条指令执行的操作不同:一条将 AX 寄存器从内存位置$1000加载,而另一条则从内存位置$2000加载 AX 寄存器。

为了区分使用[xxxx]或[xxxx+bx]寻址模式编码地址的指令,或使用立即寻址模式编码常数的指令,你必须将 16 位地址或常数附加到指令的操作码中。在这个 16 位地址或常数中,LO 字节在内存中紧随操作码,HO 字节则紧跟 LO 字节。因此,mov([1000], ax);的 3 字节编码为$c6$00$10,而mov([2000], ax);的 3 字节编码为$c6$00$20

10.3.4.2 特殊扩展操作码

图 10-3 中的特殊操作码使 Y86 CPU 能够扩展可通过单字节编码的指令集。该操作码处理多个零操作数和单操作数指令,如图 10-4 和图 10-5 所示。

图 10-4 展示了四种单操作数指令类的编码。rr字段的前 2 位编码%00进一步扩展了指令集,通过提供一种编码零操作数指令的方法,如图 10-5 所示。这些指令中有五条是非法操作码;三条合法操作码是halt指令,用于终止程序执行;get指令,用于从用户读取十六进制值并存储到 AX 寄存器中;以及put指令,用于输出 AX 寄存器中的值。

image

图 10-4:单操作数指令编码(iii = %000

image

图 10-5:零操作数指令编码(iii = %000rr = %00

rr 字段的第二个 2 位编码 %01 也是一个扩展操作码的一部分,提供了所有 Y86 跳转指令(参见 图 10-6)。第三个 rr 字段编码 %10 用于 not 指令。第四个 rr 字段编码目前尚未分配。任何试图执行 iii 字段编码为 %000rr 字段编码为 %11 的操作码的操作都会导致处理器因非法指令错误而停止。正如前面所讨论的,CPU 设计师通常会保留未分配的操作码,以便未来扩展指令集(如英特尔在从 80286 处理器过渡到 80386 或从 32 位 x86 处理器过渡到 64 位 x86-64 处理器时所做的那样)。

Y86 指令集中的七个跳转指令都采用 jxx 地址; 的形式。jmp 指令将跟随操作码后的 16 位地址值复制到指令指针寄存器中,导致 CPU 从 jmp 的目标地址获取下一条指令。其余六条指令—jajaejbjbejejne—会测试某个条件,如果条件为 true,则将地址值复制到指令指针寄存器中。第八个操作码 %00001111 是另一个非法操作码。这些编码显示在 图 10-6 中。

image

图 10-6:跳转指令编码

10.3.5 编码 Y86 指令的示例

Y86 处理器并不像人类可读的字符串(例如 mov(ax, bx);)那样执行指令。相反,它从内存中获取指令的位模式,例如 $c1,然后解码并执行这些位模式。像 mov(ax, bx);add(5, cx); 这样的可读指令必须首先转换为二进制表示,或 机器码。本节将探讨这种转换。

10.3.5.1 add 指令

我们将从一个非常简单的例子开始转换,即 add(cx, dx); 指令。一旦选择了指令,就可以在前一节的操作码图中查找该指令。add 指令位于第一组(参见 图 10-3),其 iii 字段为 %101。源操作数是 cx,因此 mmm 字段为 %010。目标操作数是 dx,所以 rr 字段为 %11。将这些位合并产生操作码 %10111010,即 $ba(参见 图 10-7)。

image

图 10-7:编码 add( cx, dx ); 指令

现在考虑add(5, ax)指令。由于它有一个立即数源操作数(常数),mmm字段将是%111(见图 10-3)。目标寄存器操作数是ax%00),指令类字段是%101,所以完整的操作码为%10100111,即$a7。然而,我们还没有完成。我们还必须将 16 位常数$0005作为指令的一部分,常数的低字节紧随操作码之后,高字节紧随低字节之后,因为字节是以小端字节序排列的。因此,内存中的字节序列从最低地址到最高地址是$a7, $05, $00(见图 10-8)。

image

图 10-8:编码add( 5, ax );指令

add([2ff+bx], cx)指令还包含一个 16 位常数,它是索引寻址模式中的位移部分。为了编码这条指令,我们使用以下字段值:iii = %101rr = %10mmm = %101。这产生了操作码字节%10110101,即$b5。完整的指令还需要常数$2ff,因此完整的指令是 3 字节序列$b5, $ff, $02(见图 10-9)。

image

图 10-9:编码add( [$2ff+bx], cx );指令

现在考虑add([1000], ax)。该指令将内存位置$1000$1001中的 16 位内容加到 AX 寄存器中的值上。再次强调,iii = %101用于add指令。目标寄存器是ax,因此rr = %00。最后,寻址模式是仅位移寻址模式,因此mmm = %110。这就形成了操作码%10100110,即$a6。完整的指令长度为 3 个字节,因为它还必须在操作码后的 2 个字节中编码内存位置的位移(地址)。因此,完整的 3 字节序列是$a6, $00, $10(见图 10-10)。

image

图 10-10:编码add( [1000], ax );指令

最后需要考虑的寻址模式是寄存器间接寻址模式[bx]add([bx],bx)指令使用以下编码值:mmm = %101rr = %01bx),mmm = %100[bx])。由于 BX 寄存器中的值完全指定了内存地址,因此无需为指令编码附加位移字段。因此,该指令仅为 1 个字节长(见图 10-11)。

image

图 10-11:编码add([bx], bx);指令

你使用类似的方法来编码subcmpandor指令。编码这些指令与add指令之间唯一的区别在于你在操作码中的iii字段使用的值。

10.3.5.2 mov 指令

Y86 的 mov 指令是特别的,因为它有两种形式。add 指令的编码和 mov 指令第一种形式的编码(iii = %110)之间的唯一区别是 iii 字段。这种形式的 mov 将常量或由 mmm 字段指定的寄存器或内存地址的数据复制到由 rr 字段指定的目标寄存器。

第二种形式的 mov 指令(iii = %111)将数据从 rr 字段指定的源寄存器复制到 mmm 字段指定的目标内存位置。在这种形式的 mov 指令中,rrmmm 字段的源和目标含义是颠倒的:rr 是源字段,mmm 是目标字段。另一种区别是,在第二种形式的 mov 指令中,mmm 字段只能包含 %100 ([bx])、%101 ([disp+bx]) 和 %110 ([disp]) 这些值。目标值不能是 mmm 字段在 %000%011 范围内编码的任何寄存器,或者是由 mmm 字段为 %111 编码的常量。这些编码是非法的,因为 mov 的第一种形式处理的是寄存器目标的情况,而且将数据存储到常量中没有任何意义。

10.3.5.3 not 指令

not 指令是 Y86 处理器支持的唯一具有单一内存/寄存器操作数的指令。它的语法如下:

not(reg);

或者:

not(address);

其中,地址表示一种内存寻址模式([bx][disp+bx][disp])。你不能为 not 指令指定常量操作数。

因为 not 只有一个操作数,所以它只需要 mmm 字段来编码该操作数。iii 字段为 %000rr 字段为 %10 可以标识 not 指令。实际上,只要 iii 字段包含 0,CPU 就知道它必须解码超出 iii 字段的位来识别指令。在这种情况下,rr 字段指定我们编码的是 not 还是其他一些特殊编码的指令。

要编码类似 not(ax) 的指令,指定 iii 字段为 %000rr 字段为 %10,然后按照 add 指令的编码方式编码 mmm 字段。因为 mmm = %000 对应于 AX,not(ax) 将被编码为 %00010000,即 $10(参见 图 10-12)。

*image

图 10-12: 编码 not(AX); 指令

not 指令不允许立即数或常量操作数,因此操作码 %00010111 ($17) 是非法的操作码。

10.3.5.4 跳转指令

Y86 跳转指令也使用特殊编码,这意味着跳转指令的iii字段总是%000。这些指令总是 3 字节长。第一个字节是操作码,指定要执行的跳转指令,接下来的 2 个字节指定内存中 CPU 要跳转到的地址(如果条件满足,针对条件跳转的情况)。Y86 有七种不同的跳转指令,六种条件跳转和一种无条件跳转jmp。这七种指令都将iii = %000rr = %01,因此它们仅在mmm字段上有所不同。第八个可能的操作码,mmm字段值为%111,是非法操作码(见图 10-6)。

编码这些指令相对简单。选择你要编码的指令就能完全确定操作码。操作码的值范围是$08$0e$0f是非法操作码)。

唯一需要考虑的字段是操作码后面的 16 位操作数。这个字段保存目标指令的地址,目标指令是无条件跳转总是跳转到的指令,也是条件跳转在条件为true时跳转到的指令。要正确编码这个 16 位操作数,你必须知道目标指令操作码字节的地址。如果你已经将目标指令转换成二进制形式并存储到内存中,那么就不成问题——只需将目标指令的地址指定为跳转指令的唯一操作数。另一方面,如果你还没有编写、转换并将目标指令放入内存,那么知道它的地址似乎需要一些预言的技巧。幸运的是,你可以通过计算当前跳转指令和目标指令之间所有指令的长度来弄清楚——但不幸的是,这是一个艰巨的任务。

计算距离的最佳方法是将所有指令写在纸上,计算它们的长度(这很容易,因为所有指令要么是 1 字节,要么是 3 字节,具体取决于是否有 16 位操作数),然后为每条指令分配一个合适的地址。完成后,你就知道每条指令的起始地址,并且可以在编码时将目标地址操作数放入你的跳转指令中。

10.3.5.5 零操作数指令

剩余的指令,即零操作数指令,是最容易编码的。因为它们没有操作数,所以它们总是 1 字节长。这些指令总是具有iii = %000rr = %00,而mmm指定特定的指令操作码(见图 10-5)。注意,Y86 CPU 将五个这样的指令未定义(因此我们可以将这些操作码用于未来的扩展)。

10.3.6 扩展 Y86 指令集

Y86 CPU 是一个简单的 CPU,仅适合用于演示如何编码机器指令。然而,和任何优秀的 CPU 一样,Y86 设计允许通过添加新指令来进行扩展。

你可以通过使用未定义或非法操作码来扩展 CPU 的指令集。因此,由于 Y86 CPU 有几个非法和未定义的操作码,我们将利用它们来扩展指令集。

使用未定义操作码来定义新指令最有效的方法是,当操作码组内有未定义的位模式,并且你想要添加的新指令属于同一组时。例如,操作码%00011mmm 属于与not指令相同的组,not指令的iii字段值也是%000。如果你决定确实需要一个neg(取反)指令,使用%00011mmm 操作码是合理的,因为你可能希望neg指令使用与not指令相同的语法。同样,如果你想向指令集中添加一个零操作数指令,Y86 提供了五个未定义的零操作数指令供你选择(%0000000..%00000100;见图 10-5)。你只需占用其中一个操作码,并将你的指令分配给它。

不幸的是,Y86 CPU 没有很多非法操作码可用。例如,如果你想添加shl(左移)、shr(右移)、rol(左旋转)和ror(右旋转)指令作为单操作数指令,那么单操作数指令操作码组内没有足够的空间(目前只有%00011mmm 是开放的)。同样,也没有开放的双操作数操作码,因此如果你想添加xor(异或)指令或其他双操作数指令,你将会遇到困难。

处理这种困境的一种常见方法,也是英特尔设计师采用的方法,是使用未定义的操作码作为前缀操作码字节。例如,操作码$ff是非法的(它对应于mov(dx,常量)指令),但我们可以将其用作特殊前缀字节,进一步扩展指令集(见图 10-13)。^(4)

image

图 10-13:使用前缀字节扩展指令集

每当 CPU 在内存中遇到前缀字节时,它会读取并解码内存中的下一个字节作为实际的操作码。然而,它不会像处理没有前缀字节的标准操作码那样处理第二个字节。相反,它允许 CPU 设计师创建一个完全新的操作码方案,独立于原始指令集。单字节扩展操作码允许 CPU 设计师将最多 256 条指令添加到指令集中。为了增加更多的指令,设计师可以使用原始指令集中未使用的非法操作码字节来添加更多的扩展操作码,每个扩展操作码都有自己独立的指令集;或者他们可以在操作码扩展前缀字节后添加一个 2 字节的操作码(最多增加 65,536 条新指令);或者他们可以执行任何他们能想到的其他方案。

当然,这种方法的一个重大缺点是它将新指令的大小增加了 1 字节,因为每条指令现在都需要将前缀字节作为操作码的一部分。这也增加了电路的成本(因为解码前缀字节和多重指令集相对复杂),所以你不希望对基本指令集使用这种方案。尽管如此,当操作码用尽时,这是一种扩展指令集的好方法。

10.4 编码 80x86 指令

Y86 处理器易于理解;我们可以轻松地手动为其编码指令,它是学习如何分配操作码的绝佳工具。它也是一个完全假设的设备,仅作为教学工具。因此,是时候看一看真实 CPU 的机器指令格式了:80x86。毕竟,你编写的程序将在真实的 CPU 上运行,因此,为了充分理解编译器如何处理你的代码——以便在编写代码时选择最佳的语句和数据结构——你需要理解真实指令是如何编码的。

即使你使用的是不同的 CPU,研究 80x86 指令编码仍然很有帮助。他们之所以称 80x86 为复杂指令集计算机(CISC)芯片,绝非没有理由。虽然确实存在更复杂的指令编码,但没有人会挑战它是目前常用的更复杂的指令集之一的说法。因此,探索它将为理解其他现实世界 CPU 的运行提供宝贵的见解。

通用 80x86 32 位指令的形式如图 10-14 所示。^(5)

image

图 10-14:80x86 32 位指令编码

注意

尽管这个图表似乎暗示指令的最大长度可以达到 16 字节,但实际上 15 字节才是上限。

前缀字节不同于我们在上一节讨论的操作码扩展前缀字节。相反,80x86 前缀字节修改现有指令的行为。每条指令最多可以附加四个前缀字节,但 80x86 支持超过四个不同的前缀值。许多前缀字节的行为是互斥的,如果你在指令前添加一对互斥的前缀字节,指令的结果将是未定义的。稍后我们将看一下这些前缀字节中的几个。

(32 位)80x86 支持两种基本的操作码大小:标准的 1 字节操作码和由$0f操作码扩展前缀字节和第二个字节组成的 2 字节操作码,后者指定实际的指令。可以将这个操作码扩展前缀字节视为 Y86 编码中iii字段的 8 位扩展。这使得能够编码最多 512 种不同的指令类别,尽管 80x86 并没有全部使用它们。实际上,许多指令类别使用此操作码扩展前缀字节中的某些位,用于明确与指令类别无关的用途。例如,考虑一下图 10-15 中显示的add指令操作码。

位 1(d)指定传输的方向。如果该位为0,则目标操作数是一个内存位置,例如add(al, [ebx]);。如果该位为1,则目标操作数是一个寄存器,如add([ebx], al);

image

图 10-15:80x86 add操作码

位 0(s)指定add指令操作的操作数的大小。然而,这里有个问题。32 位 80x86 家族支持最多三种不同的操作数大小:8 位操作数、16 位操作数和 32 位操作数。通过一个大小位,指令只能编码这三种大小中的两种。在 32 位操作系统中,绝大多数操作数是 8 位或 32 位的,因此 80x86 CPU 使用操作码中的大小位来编码这些大小。对于 16 位操作数,它们的出现频率低于 8 位或 32 位操作数,Intel 使用一个特殊的操作码前缀字节来指定大小。只要 16 位操作数的指令出现频率低于每八条指令中的一条(这通常是情况),这种方式比在指令大小中添加另一个位更为紧凑。使用大小前缀字节使得 Intel 的设计者能够扩展操作数的大小数量,而无需改变来自原始 16 位处理器的指令编码。

请注意,AMD/Intel 的 64 位架构在操作码前缀字节上更为复杂。然而,CPU 在特殊的 64 位模式下运行;实际上,64 位 80x86 CPU(通常称为 X86-64 CPUs)具有两种完全不同的指令集,每种指令集有自己的编码。X86-64 CPU 可以在 64 位和 32 位模式之间切换,以处理使用不同指令集编写的程序。本章中的编码覆盖的是 32 位变体;有关 64 位版本的详细信息,请参阅 Intel 或 AMD 文档。

10.4.1 编码指令操作数

mod-reg-r/m 字节(见 图 10-14)通过指定用于访问操作数的基本寻址模式及其大小,提供了指令操作数的编码。这个字节包含了在 图 10-16 中展示的字段。

image

图 10-16:mod-reg-r/m 字节

reg 字段几乎总是指定一个 80x86 寄存器。然而,根据指令的不同,reg 指定的寄存器可以是源操作数也可以是目标操作数。为了区分这两者,许多指令的上码包含了 d(方向)字段,当 reg 是源操作数时,d 的值为 0,当 reg 是目标操作数时,d 的值为 1

该字段使用在 表 10-1 中找到的 3 位寄存器编码。如前所述,指令操作码中的大小位指示 reg 字段是指定 8 位还是 32 位寄存器(当在现代 32 位操作系统下运行时)。为了使 reg 字段指定一个 16 位寄存器,你必须将操作码中的大小位设置为 1,并且添加一个额外的前缀字节。

表 10-1: reg 字段编码

reg 值 数据大小为 8 位时的寄存器 数据大小为 16 位时的寄存器 数据大小为 32 位时的寄存器
%000 al ax eax
%001 cl cx ecx
%010 dl dx edx
%011 bl bx ebx
%100 ah sp esp
%101 ch bp ebp
%110 dh si esi
%111 bh di edi

在双操作数指令的操作码中,d 位指示 reg 字段是包含源操作数还是目标操作数,modr/m 字段一起指定另一个操作数。在像 notneg 这样的单操作数指令中,reg 字段包含操作码扩展,modr/m 字段组合在一起指定唯一的操作数。modr/m 字段指定的操作数寻址模式列在 表 10-2 和 10-3 中。

表 10-2: mod 字段编码

mod 描述
%00 指定寄存器间接寻址模式(有两个例外:当 r/m = %100 时,没有位移操作数的缩放索引[sib]寻址模式;当 r/m = %101 时,仅为位移的寻址模式)。
%01 指定后续有一个 1 字节的有符号位移紧跟在寻址模式字节之后。
%10 指定后续有一个 1 字节的有符号位移紧跟在寻址模式字节之后。
%11 指定直接寄存器访问。

表 10-3: mod-r/m 编码

mod r/m 寻址模式
%00 %000 [eax]
%01 %000 [eax+disp[8]]
%10 %000 [eax+disp[32]]
%11 %000 alaxeax
%00 %001 [ecx]
%01 %001 [ecx+disp[8]]
%10 %001 [ecx+disp[32]]
%11 %001 clcxecx
%00 %010 [edx]
%01 %010 [edx+disp[8]]
%10 %010 [edx+disp[32]]
%11 %010 dldxedx
%00 %011 [ebx]
%01 %011 [ebx+disp[8]]
%10 %011 [ebx+disp[32]]
%11 %011 blbxebx
%00 %100 扩展索引(sib)模式
%01 %100 sib + disp[8] 模式
%10 %100 sib + disp[32] 模式
%11 %100 ahspesp
%00 %101 仅位移模式(32 位位移)
%01 %101 [ebp+disp[8]]
%10 %101 [ebp+disp[32]]
%11 %101 chbpebp
%00 %110 [esi]
%01 %110 [esi+disp[8]]
%10 %110 [esi+disp[32]]
%11 %110 dhsiesi
%00 %111 [edi]
%01 %111 [edi+disp[8]]
%10 %111 [edi+disp[32]]
%11 %111 bhdiedi

关于 表 10-2 和 10-3 有几个有趣的地方需要注意。首先,[reg+disp] 寻址模式有两种不同的形式:一种形式使用 8 位位移,另一种形式使用 32 位位移。位移范围在 -128 到 +127 之间的寻址模式,仅需要在操作码后面添加一个字节来编码位移。位移值位于此范围内的指令比位移值超出此范围并需要 4 字节编码的指令通常更短且有时更快。

第二点需要注意的是,[ebp] 寄存器寻址模式并不存在。如果你查看 表 10-3 中这个寻址模式应当属于的位置(即 r/m%101mod%00),你会发现该位置被 32 位位移仅寻址模式所占用。寻址模式的基本编码方案并不允许仅使用位移的寻址模式,因此英特尔“偷用了”[ebp]的编码,并将其用于仅位移寻址模式。幸运的是,使用[ebp]寻址模式时,你可以通过将 8 位位移设置为 0,改用 [ebp+disp[8]] 寻址模式来完成相同的操作。尽管这样的指令比实际存在[ebp]寻址模式时会稍微长一些,但其功能是一样的。英特尔明智地选择用这种方式替代特定的寄存器间接寻址模式,预见到程序员使用它的频率会比其他寄存器间接寻址模式要少。

你还会发现表格中缺少了以下几种寻址模式:[esp][esp+disp[8]][esp+disp[32]]。英特尔的设计师借用了这三种寻址模式的编码,以支持它们在 80x86 系列的 32 位处理器中添加的 缩放索引寻址 模式。

如果 r/m = %100mod = %00,这表示一种寻址模式,形式为 [reg[1]32+reg[2]32*n]。这种缩放索引寻址模式通过将 reg[2] 乘以 n(n = 1248),再加上 reg[1],来计算内存中的最终地址。程序通常在 reg[1] 是指向字节数组(n = 1)、字数组(n = 2)、双字数组(n = 4)或四字数组(n = 8)的基地址的指针时使用此寻址模式,reg[2] 存储该数组的索引。

如果 r/m = %100mod = %01,这表示一种寻址模式,形式为 [reg[1]32+reg[2]32*n+disp[8]]。这种缩放索引寻址模式通过将 reg[2] 乘以 n(n = 1248),再加上 reg[1] 和 8 位符号位移(符号扩展至 32 位),来计算内存中的最终地址。程序通常在 reg[1] 是指向记录数组基地址的指针时使用此寻址模式,reg[2] 存储该数组的索引,disp[8] 提供指向记录中所需字段的偏移量。

如果 r/m = %100mod = %10,这表示一种寻址模式,形式为 [reg[1]32+reg[2]32*n+disp[32]]。这种缩放索引寻址模式通过将 reg[2] 乘以 n(n = 1248),再加上 reg[1] 和 32 位符号位移,来计算内存中的最终地址。程序通常在对字节、字、双字或四字的静态数组进行索引时使用此寻址模式。

如果 sib 模式的某个值出现在 modr/m 字段中,那么寻址模式就是一个缩放索引寻址模式,并且在 mod-reg-r/m 字节之后会有一个第二个字节(即 sib)。不过,不要忘记,mod 字段仍然指定了 0、1 或 4 字节的位移大小。图 10-17 显示了这个额外的 sib 的布局,表 10-4、表 10-5 和 表 10-6 解释了每个 sib 字段的值。

image

图 10-17:sib(缩放索引字节)布局

表 10-4: 缩放值

缩放值 索引 * 缩放值
%00 索引 * 1
%01 索引 * 2
%10 索引 * 4
%11 索引 * 8

表 10-5: sib 编码的寄存器值

索引值 寄存器
%000 EAX
%001 ECX
%010 EDX
%011 EBX
%100 非法
%101 EBP
%110 ESI
%111 EDI

表 10-6: sib 编码的基址寄存器值

基址值 寄存器
%000 EAX
%001 ECX
%010 EDX
%011 EBX
%100 ESP
%101 仅在 mod = %00 时为位移,如果 mod = %01%10 则为 EBP
%110 ESI
%111 EDI

mod-reg-r/msib 字节是复杂且难以理解的,毫无疑问。原因在于英特尔在转换为 32 位格式时,重用了其 16 位寻址电路,而不是直接放弃它。在那个时候保留它是有合理的硬件原因的,但结果就是指定寻址模式的方案变得非常复杂。正如你可以想象的那样,当英特尔和 AMD 开发出 x86-64 架构时,情况变得更加糟糕。

请注意,如果 mod-reg-r/m 字节的 r/m 字段包含 %100,并且 mod 字段不包含 %11,那么寻址模式是 sib 模式,而不是预期的 [esp][esp+disp[8]][esp+disp[32]] 模式。在这种情况下,编译器或汇编器会在 mod-reg-r/m 字节之后立即生成一个额外的 sib 字节。表 10-7 列出了 80x86 上合法的缩放索引寻址模式的各种组合。

在表 10-7 中列出的每种寻址模式下,mod-reg-r/m 字节的 mod 字段指定位移的大小(0、1 或 4 字节)。sib 字段中的基址和索引字段分别指定基址寄存器和索引寄存器。请注意,该寻址模式不允许使用 ESP 作为索引寄存器。推测英特尔之所以将这一特定模式设为未定义,是为了允许将寻址模式扩展到 3 字节,以便在未来版本的 CPU 中使用,尽管这样做看起来有点极端。

就像 mod-reg-r/m 编码用位移寻址模式替换了 [ebp] 寻址模式一样,sib 寻址格式用位移加索引模式(即没有基址寄存器)替换了 [ebp+index*scale] 模式。如果你确实需要使用 [ebp+index*scale] 寻址模式,你需要改用 [disp[8]+ebp+index*scale] 模式,并指定一个值为 0 的 1 字节位移。

表 10-7:比例索引寻址模式

mod 索引 合法的比例索引寻址模式^(6)
%00Base ° %101 %000 [base[32]+eax*n]
%001 [base[32]+ecx*n]
%010 [base[32]+edx*n]
%011 [base[32]+ebx*n]
%100 n/a^(7)
%101 [base[32]+ebp*n]
%110 [base[32]+esi*n]
%111 [base[32]+edi*n]
%00Base = %101^(8) %000 [disp[32]+eax*n]
%001 [disp[32]+ecx*n]
%010 [disp[32]+edx*n]
%011 [disp[32]+ebx*n]
%100 n/a
%101 [disp[32]+ebp*n]
%110 [disp[32]+esi*n]
%111 [disp[32]+edi*n]
%01 %000 [disp[8]+base[32]+eax*n]
%001 [disp[8]+base[32]+ecx*n]
%010 [disp[8]+base[32]+edx*n]
%011 [disp[8]+base[32]+ebx*n]
%100 n/a
%101 [disp[8]+base[32]+ebp*n]
%110 [disp[8]+base[32]+esi*n]
%111 [disp[8]+base[32]+edi*n]
%10 %000 [disp[32]+base[32]+eax*n]
%001 [disp[32]+base[32]+ecx*n]
%010 [disp[32]+base[32]+edx*n]
%011 [disp[32]+base[32]+ebx*n]
%100 n/a
%101 [disp[32]+base[32]+ebp*n]
%110 [disp[32]+base[32]+esi*n]
%111 [disp[32]+base[32]+edi*n]

10.4.2 编码 add 指令

为了帮助你弄清楚如何使用这个复杂的方案来编码指令,我们来看一个使用各种寻址模式的 80x86 add 指令示例。add 操作码根据其方向和大小位可以是 $00$01$02$03(见 图 10-15)。图 10-18 到 10-25 展示了如何使用不同的寻址模式编码 add 指令的各种形式。

image

图 10-18:编码 add(al, cl); 指令

mod-reg-r/m 组织和方向位有一个有趣的副作用:一些指令有两种合法的操作码。例如,我们也可以通过反转 regr/m 字段中 AL 和 CL 寄存器的位置,并将操作码中的 d 位(第 1 位)设置为 1,将 add(al, cl); 指令(如 图 10-18 所示)编码为 $02, $c8。这适用于所有具有两个寄存器操作数和方向位的指令,例如 图 10-19 中的 add(eax, ecx); 指令,它也可以编码为 $03, $c8

image

图 10-19:编码 add(eax, ecx); 指令

image

图 10-20:编码 add(disp, edx); 指令

image

图 10-21:编码 add([ebx], edi); 指令

image

图 10-22:编码 add([esi+disp``[8]``], eax); 指令

image

图 10-23:编码 add([ebp+disp``[32]``], ebx); 指令

image

图 10-24:编码 add([disp``[32]``+eax*1], ebp); 指令

image

图 10-25:编码 add([ebx+edi*4], ecx); 指令

10.4.3 在 x86 上编码立即数(常量)操作数

你可能已经注意到,mod-reg-r/msib 字节不包含任何可以用来指定指令包含立即操作数的位组合。80x86 使用完全不同的操作码来指定立即操作数。图 10-26 显示了 add 立即数指令的基本编码。

image

图 10-26:编码 add 立即数指令

add 立即数指令和标准 add 指令的编码有三个主要区别。首先,也是最重要的,操作码在 HO 位上有一个 1。这告诉 CPU 指令包含一个立即常数。然而,仅凭这一变化,并不能告诉 CPU 必须执行 add 指令,正如你稍后将看到的那样。

第二个区别是操作码中没有方向位。这是有道理的,因为你不能将常量指定为目标操作数。因此,目标操作数总是由 mod-reg-r/m 字段中的 modr/m 位指定的位置。

在方向位的位置,操作码有一个符号扩展(x)位。对于 8 位操作数,CPU 忽略符号扩展位。对于 16 位和 32 位操作数,符号扩展位指定了 add 指令后常数的大小。如果符号扩展位为 0,则常数已经与操作数的大小相同(16 或 32 位)。如果符号扩展位为 1,则常数为带符号 8 位值,CPU 会在将其加到操作数之前对该值进行符号扩展,扩展到适当的大小。这个小技巧通常会让程序变得更短,因为你常常会将小常数加到 16 位或 32 位的目标操作数中。

add 立即数指令和标准 add 指令的第三个区别是 mod-reg-r/m 字节中 reg 字段的含义。由于该指令意味着源操作数是常数,而 mod-r/m 字段指定了目标操作数,因此指令无需使用 reg 字段来指定操作数。相反,80x86 CPU 使用这 3 位作为操作码扩展。对于 add 立即数指令,这 3 位必须为 0,而其他位模式则对应于不同的指令。

当常数被加到内存位置时,任何与该内存位置相关的位移值会立即出现在常数数据之前。

10.4.4 编码 8 位、16 位和 32 位操作数

在设计 8086 时,英特尔使用了一个操作码位(s)来指定操作数大小是 8 位还是 16 位。后来,当英特尔通过引入 80386 将 80x86 架构扩展到 32 位时,英特尔遇到了一个问题:使用这个单一的操作数大小位,它只能编码两种大小,但需要编码三种(8 位、16 位和 32 位)。为了解决这个问题,英特尔使用了一个 操作数大小前缀字节

英特尔研究了其指令集,得出结论:在 32 位环境中,程序更可能使用 8 位和 32 位操作数,而不是 16 位操作数。因此,它决定让操作码中的大小位(s)在 8 位和 32 位操作数之间进行选择,正如前面章节所描述的那样。尽管现代 32 位程序不常使用 16 位操作数,但有时仍然需要使用它们。因此,英特尔允许你在 32 位指令前加上操作数大小前缀字节,其值为 $66,该前缀字节告诉 CPU 操作数包含的是 16 位数据,而不是 32 位数据。

对于 16 位指令,你不必显式添加操作数大小前缀字节;汇编器或编译器会自动处理这个问题。然而,请记住,当你在 32 位程序中使用 16 位对象时,由于前缀值的原因,指令会多出 1 个字节。因此,如果大小和(在较小程度上)速度很重要,你应该小心使用 16 位指令。

10.4.5 编码 64 位操作数

在 64 位模式下运行时,英特尔和 AMD 的 x84-64 处理器使用特殊的操作码前缀字节来指定 64 位寄存器。有 16 个 REX 操作码字节用于处理 64 位操作数和寻址模式。由于没有 16 个可用的单字节操作码,AMD(该指令集的设计者)选择重新利用 16 个现有的操作码(inc(reg)dec(reg) 指令的 1 字节操作码变体)。这些指令仍然有 2 字节变体,因此 AMD 并没有完全删除这些指令,而是仅移除了 1 字节版本。然而,标准的 32 位代码(其中很多确实使用这些 1 字节的增量和递减指令)不能再在 64 位模型上运行。这就是为什么 AMD 和英特尔引入了新的 32 位和 64 位操作模式——使得 CPU 可以在同一块硅片上运行旧的 32 位代码和新的 64 位代码。

10.4.6 指令的替代编码

如本章前面所述,英特尔为 80x86 设计的主要目标之一是创建一个指令集,使程序员能够编写非常短的程序,以节省当时非常宝贵的内存。英特尔实现这一目标的一种方法是为一些常用指令创建替代编码。这些替代指令比其标准对应指令更短,英特尔希望程序员能广泛使用这些较短版本,从而编写出更短的程序。

这些替代指令的一个好例子是 add(constant, accumulator); 指令,其中累加器为 alaxeax。80x86 为 add(constant, al);add(constant, eax); 提供了 1 字节的操作码,分别为 $04$05。由于是 1 字节的操作码且没有 mod-reg-r/m 字节,这些指令比其标准的 add 立即数指令短 1 字节。add(constant, ax);指令需要一个操作数大小前缀,因此其操作码实际上是 2 字节的。然而,这仍然比对应的标准add` 立即数指令少 1 字节。

使用这些指令不需要指定任何特殊内容。任何合适的汇编器或编译器在将源代码转换为机器代码时,都会自动选择它可以使用的最短指令。然而,您应该注意,英特尔只为累加器寄存器提供了替代编码。因此,如果您有多个指令可供选择,并且这些选择中包含累加器寄存器,通常来说,AL、AX 和 EAX 寄存器是最佳选择。然而,这个选项通常只对汇编语言程序员可用。

10.5 指令集设计对程序员的影响

只有通过了解计算机架构,特别是 CPU 如何编码机器指令,您才能最有效地使用计算机的指令。通过研究指令集设计,您可以清楚地了解以下内容:

  • 为什么有些指令比其他指令短

  • 为什么有些指令比其他指令执行得更快

  • CPU 能高效处理哪些常量值

  • 常量是否比内存位置更高效

  • 为什么某些算术和逻辑操作比其他操作更高效

  • 哪些类型的算术表达式比其他类型更容易被翻译成机器代码

  • 为什么如果代码在目标代码中控制流跨越较大的距离,会导致效率降低

……等等。

通过研究指令集设计,你将更加意识到你所编写代码的影响(即使是在高级语言中),这些影响在 CPU 上的高效运行方面尤为重要。掌握了这些知识后,你将能够编写出更优秀的代码。

10.6 更多信息

Hennessy, John L., 和 David A. Patterson. 计算机架构:量化方法. 第 5 版. Waltham, MA: Elsevier, 2012 年。

Hyde, Randall. 汇编语言艺术. 第 2 版. 旧金山:No Starch Press, 2010 年。

Intel. “Intel® 64 和 IA-32 架构软件开发者手册。”最后更新于 2019 年 11 月 11 日。 software.intel.com/en-us/articles/intel-sdm/.

第十一章:内存架构与组织**

Image

本章讨论内存层次结构——计算机系统中不同类型和性能级别的内存。尽管程序员通常将所有形式的内存视为等同,但不正确地使用内存可能会对性能产生负面影响。本章将展示如何在程序中最好地利用内存层次结构。

11.1 内存层次结构

大多数现代程序都能从大量非常快速的内存中受益。不幸的是,随着内存设备变大,它往往会变得更慢。例如,缓存内存非常快速,但它们也很小且昂贵。主内存便宜且容量大,但它很慢,需要等待周期。内存层次结构提供了一种比较不同类型内存的成本和性能的方式。图 11-1 显示了内存层次结构的一种变种。

image

图 11-1:内存层次结构

内存层次结构的顶层是 CPU 的通用 寄存器。寄存器提供了对数据的最快访问方式。寄存器文件也是层次结构中最小的内存对象(例如,32 位的 80x86 只有八个通用寄存器,而 x86-64 变种最多有 16 个通用寄存器)。由于无法在 CPU 中增加更多寄存器,因此它们也是最昂贵的内存位置。即使我们将 FPU、MMX/AltiVec/Neon、SSE/SIMD、AVX/2/-512 和其他 CPU 寄存器计算在内,这一部分内存层次结构的寄存器数量仍然非常有限,而且每个寄存器字节的成本相当高。

接下来,我们进入 一级缓存(L1 Cache) 系统,它是内存层次结构中下一个性能最好的子系统。与寄存器类似,CPU 制造商通常将 L1 缓存集成在芯片上,且无法扩展。其大小通常较小,通常在 4KB 到 32KB 之间,尽管这比 CPU 芯片上可用的寄存器内存要大得多。虽然 L1 缓存的大小在 CPU 上是固定的,但每个缓存字节的成本远低于每个寄存器字节的成本,因为缓存存储的容量超过了所有寄存器总和,而系统设计者为这两种内存类型支付的成本等于 CPU 的价格。

二级缓存(L2 Cache) 在某些 CPU 上存在,但并非所有 CPU 都有。例如,英特尔的 i3、i5、i7 和 i9 CPU 包括 L2 缓存作为其一部分,但一些英特尔旧版 Celeron 芯片则没有。L2 缓存通常比 L1 缓存大得多(例如,256KB 到 1MB,相较于 4KB 到 32KB)。在具有内建 L2 缓存的 CPU 上,缓存无法扩展。它的成本仍然低于 L1 缓存,因为我们将 CPU 的成本分摊到两个缓存的所有字节上,而 L2 缓存更大。

三级缓存(L3)出现在几乎所有 Intel 处理器中,除了最旧的型号。L3 缓存比 L2 缓存更大(在较新的 Intel 芯片上通常为 8MB)。

主内存子系统位于 L3(如果没有 L3 则是 L2)缓存系统下方,属于内存层次结构中的一部分。主内存是通用的、相对低成本的内存——通常是 DRAM 或类似的便宜内存——存在于大多数计算机系统中。然而,主内存技术存在许多差异,导致速度有所不同。主内存类型包括标准 DRAM、同步 DRAM(SDRAM)、双倍数据速率 DRAM(DDRAM)、DDR3、DDR4 等。通常,你不会在同一台计算机系统中发现这些技术的混合。

主内存下方是NUMA内存子系统。NUMA 代表非统一内存访问,这有点误导。该术语意味着不同类型的内存具有不同的访问时间,这描述了整个内存层次结构;然而,在图 11-1 中,它指的是与主内存电子特性相似,但因某种原因操作速度明显较慢的内存块。NUMA 的一个典型例子是视频(或图形)卡上的内存。另一个例子是闪存,它的访问和传输时间比标准半导体 RAM 慢得多。其他提供内存块以供 CPU 和外设共享的外部设备通常也具有较慢的访问时间。

大多数现代计算机系统实现了一个虚拟内存方案,利用大容量存储磁盘驱动器来模拟主内存。虚拟内存子系统负责在程序需要时,透明地将数据从磁盘复制到主内存,反之亦然。虽然磁盘的速度明显慢于主内存,但磁盘每比特的成本也低三个数量级。因此,将数据保存在磁存储器或固态硬盘(SSD)上比保存在主内存中便宜得多。

文件存储内存也使用磁盘介质来存储程序数据。然而,虚拟内存子系统负责在程序需要时,将数据在磁盘(或 SSD)和主内存之间传输,而存储和检索文件存储数据则由程序负责。在许多情况下,使用文件存储内存的速度比使用虚拟内存稍慢,这也是文件存储内存在内存层次结构中较低的原因。^(1)

接下来是网络存储。在内存层次结构的这一层,程序将数据保存在通过网络连接到计算机系统的不同内存系统中。网络存储可以是虚拟内存、文件存储内存或分布式共享内存(DSM),其中不同计算机系统上运行的进程共享存储在公共内存块中的数据,并通过网络交流对该内存块的更改。

虚拟内存、文件存储和网络存储是在线内存子系统的例子。这些内存子系统中的内存访问速度比访问主内存要慢。然而,当程序请求来自这些三种在线内存子系统的数据时,内存设备会尽可能快地响应请求,取决于其硬件的响应能力。这对内存层次结构中其余的层次并不适用。

近线离线存储子系统可能无法立即响应程序对数据的请求。离线存储系统将数据以电子形式(通常是磁性或光学形式)存储在介质上,但这些介质未必与需要数据的计算机系统相连接。离线存储的例子包括磁带、未连接的外部磁盘驱动器、磁盘盒、光盘、USB 存储棒、SD 卡和软盘。当程序需要访问离线存储的数据时,它必须停止并等待某人或某个设备将适当的介质挂载到计算机系统上。这种延迟可能相当长(也许计算机操作员决定去喝杯咖啡?)。

近线存储使用与离线存储相同类型的介质,但它不需要外部源在数据可供访问之前挂载介质。近线存储系统将介质存放在一个特殊的机器人唱盘设备中,程序请求时,该设备可以自动挂载所需的介质。

硬拷贝存储只是以某种形式打印出来的数据。如果程序请求某些数据,而这些数据仅以硬拷贝形式存在,那么必须由某人手动将数据输入计算机。纸张或其他硬拷贝介质可能是最便宜的存储形式,至少对于某些数据类型来说是如此。

11.2 内存层次结构的运作

内存层次结构的核心目的是为了允许合理快速地访问大量内存。如果只需要少量内存,我们会为所有操作使用快速的静态 RAM(缓存内存使用的电路)。如果速度不是问题,我们会为所有操作使用虚拟内存。内存层次结构使我们能够利用空间局部性时间局部性的原理,将经常访问的数据移入快速内存,将不常访问的数据留在较慢的内存中。不幸的是,在程序执行过程中,常用数据和不常用数据的集合会发生变化。我们不能在程序开始时就简单地将数据分配到各个内存层级中,并在程序执行过程中不再处理这些数据。相反,不同的内存子系统需要能够在程序执行过程中根据空间局部性或时间局部性的变化,通过动态移动数据来适应这些变化。

在寄存器和内存之间移动数据完全是程序的功能。程序使用诸如mov之类的机器指令将数据加载到寄存器中,并将寄存器中的数据存储到内存中。程序员或编译器有责任尽可能长时间将频繁访问的数据保留在寄存器中;CPU 不会自动将数据放入通用寄存器以提高性能。

程序只能在文件存储层及以下的级别显式控制对寄存器、主内存和那些内存层次子系统的访问。程序通常并不意识到寄存器级别和主内存之间的内存层次关系。特别是,缓存访问和虚拟内存操作通常对程序是透明的;也就是说,对这些内存层次的访问通常在程序不干预的情况下发生。程序只是访问主内存,硬件和操作系统负责其余部分。

当然,如果程序总是访问主内存,它会运行得很慢,因为现代 DRAM 主内存子系统的速度远远慢于 CPU。缓存内存子系统和 CPU 缓存控制器的工作是将数据从主内存移动到 L1、L2 和 L3 缓存,以便 CPU 能够快速访问经常请求的数据。同样,虚拟内存子系统的职责是将经常请求的数据从硬盘移动到主内存(如果需要更快的访问,缓存子系统则会将数据从主内存移动到缓存)。

除少数例外,大多数内存子系统的访问都是透明的,在内存层次结构的一个级别与其上下一个级别之间进行。例如,CPU 很少直接访问主内存。相反,当 CPU 请求数据时,L1 缓存子系统接管此请求。如果请求的数据在缓存中,L1 缓存子系统将数据返回给 CPU,内存访问过程结束。如果请求的数据不在 L1 缓存中,L1 缓存子系统会将请求传递给 L2 缓存子系统。如果 L2 缓存子系统有该数据,它将数据返回给 L1 缓存,然后 L1 缓存将数据返回给 CPU。未来对相同数据的请求将由 L1 缓存而非 L2 缓存来满足,因为 L1 缓存现在拥有该数据的副本。L2 缓存之后,L3 缓存接管。

如果 L1、L2 或 L3 缓存子系统都没有数据的副本,请求将传送到主内存。如果在主内存中找到数据,主内存子系统将其传递给 L3 缓存,L3 缓存再将其传递给 L2 缓存,L2 缓存再传递给 L1 缓存,L1 缓存最终将其传递给 CPU。数据现在已经位于 L1 缓存中,因此未来对该数据的任何请求都将由 L1 缓存来满足。

如果数据不在主内存中,而是存储在某个存储设备上的虚拟内存中,操作系统会接管,先从磁盘或其他设备(如网络存储服务器)读取数据,再将数据传递给主内存子系统。然后,主内存通过缓存将数据传递给 CPU,如前所述。

由于空间局部性和时间局部性,大部分内存访问发生在 L1 缓存子系统。接下来,大部分访问发生在 L2 缓存子系统。之后,L3 缓存系统处理大部分访问。最不频繁的访问发生在虚拟内存中。

11.3 内存子系统的相对性能

再看一下图 11-1,注意到随着层次的上升,各个内存层级的速度增加。那么,每个连续层级的速度差异有多大呢?简短的回答是,速度梯度不是均匀的。任何两个相邻层级之间的速度差异从“几乎没有差别”到“四个数量级”不等。

寄存器无疑是存储你需要快速访问的数据的最佳位置。访问寄存器从不需要额外时间,大多数访问数据的机器指令可以访问寄存器数据。此外,访问内存的指令通常需要额外的字节(位移字节)作为指令编码的一部分。这使得指令变得更长,且通常执行更慢。

英特尔的 80x86 指令时序表声称,像mov(someVar, ecx);这样的指令应该和mov(ebx, ecx);一样快速。然而,如果你仔细阅读细则,会发现英特尔是基于几个假设做出这一声明的。首先,它假设 someVar 的值已经存在于 L1 缓存中。如果不在,缓存控制器必须先在 L2 缓存中查找,再到 L3 缓存、主内存,甚至更糟的是,在虚拟内存子系统的磁盘上查找。突然之间,原本应该在 4 GHz 处理器上的 0.25 纳秒(即一个时钟周期)内执行的指令,竟然需要几毫秒才能完成。这是一个超过六个数量级的差异。确实,未来访问该变量时,将只需一个时钟周期,因为它会被存储在 L1 缓存中。但是,即使你在缓存中访问 someVar 的值一百万次,每次访问的平均时间仍然大约是两个时钟周期,因为第一次访问 someVar 时需要的时间比较长。

当然,某些变量在虚拟内存子系统中存储在磁盘上的可能性是相当低的。然而,L1 缓存子系统和主内存子系统之间仍然存在几倍数量级的性能差异。因此,如果程序必须从主内存中检索数据,经过 999 次内存访问后,你仍然需要支付大约两个时钟周期的平均成本,而英特尔的文档声称这应该只需要一个周期。

除非二级或三级缓存没有与 CPU 一起打包,否则 L1、L2 和 L3 缓存系统之间的速度差异不会那么显著。在 4 GHz 的处理器上,如果缓存在零等待周期下工作,L1 缓存必须在 0.25 纳秒内响应(一些处理器在 L1 缓存访问中实际上会引入等待周期,但 CPU 设计师会尽量避免这种情况)。访问 L2 缓存中的数据总是比 L1 缓存慢,并且总是至少包含一个等待周期,相对可能更多。

L2 缓存访问速度慢于 L1 缓存访问的原因有几个。首先,CPU 需要时间来判断它要寻找的数据是否不在 L1 缓存中。当它做出判断时,内存访问周期几乎已经完成,已经没有时间去访问 L2 缓存中的数据。其次,为了降低成本,L2 缓存的电路可能比 L1 缓存的电路更慢。第三,L2 缓存通常比 L1 缓存大 16 到 64 倍,而较大的内存子系统往往比较小的更慢。这一切都会导致访问 L2 缓存时需要额外的等待周期。如前所述,L2 缓存的访问速度可能比 L1 缓存慢一个数量级。当你必须访问 L3 缓存中的数据时,也会出现同样的情况。

L1、L2 和 L3 缓存在发生缓存未命中时系统获取的数据量也有所不同(参见 第六章)。当 CPU 从 L1 缓存中获取或写入数据时,通常只会获取或写入请求的数据。如果你执行 mov(al, memory); 指令,CPU 只会向缓存写入一个字节。类似地,如果你执行 mov(mem32, eax); 指令,CPU 会从 L1 缓存中读取恰好 32 位的数据。然而,访问 L1 缓存以下的内存子系统并不像这样按小块操作。通常,内存子系统在访问内存层级的更低级别时,会移动数据块或 缓存行。例如,如果你执行 mov(mem32, eax); 指令,并且 mem32 的值不在 L1 缓存中,缓存控制器不会仅仅从 L2 缓存中读取 mem32 的 32 位数据,假设它在那里存在。相反,缓存控制器会从 L2 缓存中读取一整块字节(通常为 16、32 或 64 字节,具体取决于处理器)。希望程序能表现出空间局部性,从而读取一块字节可以加速对内存中相邻对象的未来访问。不幸的是,mov(mem32, eax); 指令不会完成,直到 L1 缓存从 L2 缓存中读取完整的缓存行。这段额外的时间被称为 延迟。如果程序在未来不再访问与 mem32 相邻的内存对象,这段延迟时间就成了浪费的时间。

L2 和 L3 缓存以及 L3 和主内存之间存在类似的性能差距。主内存通常比 L3 缓存慢一个数量级;L3 访问比 L2 访问慢得多。为了加速对相邻内存对象的访问,L3 缓存会以缓存行的形式从主内存中读取数据。同样,L2 缓存也会从 L3 中读取缓存行。

标准 DRAM 比 SSD 存储快两个到三个数量级(SSD 存储比硬盘快一个数量级,这也是为什么硬盘通常有自己的基于 DRAM 的缓存)。为了克服这个问题,L3 缓存和主内存之间通常存在两到三个数量级的差异,以便磁盘和主内存之间的速度差异与主内存和 L3 缓存之间的速度差异相匹配。(在内存层级中平衡性能特征是一个目标,旨在有效利用不同类型的内存。)

在本章中,我们不考虑其他内存层级子系统的性能,因为它们或多或少由程序员控制。由于它们的访问不是自动的,所以很难说程序将会多频繁地访问它们。然而,在 第十二章中,我们将讨论一些关于这些存储设备的考虑因素。

11.4 缓存架构

到目前为止,我们已经将缓存视为一个神奇的地方,它在需要时自动存储数据,或许会根据 CPU 的需求获取新的数据。但缓存究竟是如何做到这一点的呢?当缓存已满而 CPU 请求额外数据时,会发生什么情况?在这一节中,我们将探讨缓存的内部组织结构,并尝试解答这两个问题以及其他一些问题。

程序在任意时刻只访问少量数据,按照这种方式大小调整的缓存将提高程序的性能。不幸的是,程序需要的数据通常不会在连续的内存位置上——它通常会分布在整个地址空间中。因此,缓存设计必须考虑到缓存需要将位于内存中不同地址的数据对象进行映射。

如前一节所述,缓存内存并不是以单一字节组的方式组织的。相反,它通常以【缓存行】(gloss01.xhtml#gloss01_42)的块形式组织,每一行包含一定数量的字节(通常是像 16、32 或 64 这样的 2 的幂次),如图 11-2 所示。

image

图 11-2:8KB 缓存的可能组织方式

我们可以将不同的非连续地址附加到每个缓存行上。缓存行 0 可能对应地址$10000$1000F,缓存行 1 可能对应地址$21400$2140F。通常,如果一个缓存行的长度为n字节,它将包含主内存中位于n字节边界上的n字节数据。在图 11-2 中的例子里,缓存行的长度为 16 字节,因此一个缓存行包含 16 字节的块,这些块在主内存中位于 16 字节的边界上(换句话说,缓存行中第一个字节地址的低 4 位总是 0)。

当缓存控制器从内存层次结构中的较低层读取一个缓存行时,数据会被存放到缓存的哪个位置?答案取决于使用的缓存方案。缓存方案有三种不同的类型:直接映射缓存、完全关联缓存和n路集合关联缓存。

11.4.1 直接映射缓存

直接映射缓存(也称为单路集合关联缓存)中,主内存中的特定数据块总是被加载到——映射到——相同的缓存行,该缓存行由数据块内存地址中的少量位确定。图 11-3 展示了缓存控制器如何为一个拥有 512 个 16 字节缓存行和 32 位主内存地址的 8KB 缓存选择合适的缓存行。

image

图 11-3:在直接映射缓存中选择缓存行

一个具有 512 个缓存行的缓存需要 9 位来选择其中一个缓存行(2⁹ = 512)。在这个例子中,地址的第 4 到第 12 位决定使用哪个缓存行(假设我们将缓存行编号为 0 到 511),而地址的第 0 到第 3 位则决定 16 字节缓存行内的具体字节。

直接映射缓存方案非常容易实现。从内存地址中提取 9 个(或其他数量的)位,并将结果用作缓存行数组的索引是非常简单且快速的,尽管这种设计可能无法有效利用所有的缓存内存。

例如,图 11-3 中的缓存方案将地址 0 映射到缓存行 0。它还将地址$2000(8KB)、$4000(16KB)、$6000(24KB)、$8000(32KB)以及所有其他是 8KB 倍数的地址映射到缓存行 0。这意味着,如果一个程序不断访问的是 8KB 倍数的地址,并且不访问其他位置,系统将仅使用缓存行 0,其他所有缓存行将未被使用。在这种极端情况下,缓存实际上被限制为一个缓存行的大小,并且每次 CPU 请求一个映射到缓存行 0 但未存在于其中的地址时,必须降级到内存层次结构的较低级别来访问该数据。

11.4.2 完全关联缓存

在一个完全关联的缓存子系统中,缓存控制器可以将字节块放置在缓存内存中任意一个缓存行中。虽然这是最灵活的缓存系统,但实现完全关联所需的额外电路非常昂贵,甚至可能会拖慢内存子系统的速度。由于这个原因,大多数 L1 和 L2 缓存并不是完全关联的。

11.4.3 n 路集合关联缓存

如果完全关联缓存过于复杂、过于缓慢且过于昂贵,而直接映射缓存又过于低效,那么n路集合关联缓存是两者之间的折衷方案。在n路集合关联缓存中,缓存被划分为n个缓存行的集合。CPU 根据内存地址位的某个子集来确定使用哪个集合,就像在直接映射方案中一样,缓存控制器则使用完全关联映射算法来确定在该集合内使用哪一个缓存行。

例如,一个 8KB 二路集合关联缓存子系统,具有 16 字节的缓存线,将缓存组织为 256 个缓存行集合,每个集合包含两条缓存线。内存地址的八位用于确定这 256 个不同集合中的哪一个将包含数据。一旦确定了缓存行集合,缓存控制器将数据块映射到该集合中的两个缓存线之一(见图 11-4)。这意味着位于 8KB 边界上的两个不同内存地址(在第 4 到第 11 位上的值相同的地址)可以同时出现在缓存中。然而,如果尝试访问一个地址为 8KB 的偶数倍的第三个内存位置,则会发生冲突。

image

图 11-4:二路集合关联缓存

四路集合关联缓存将四条关联缓存线放入每个缓存行集合中。在像图 11-4 中的 8KB 缓存中,四路集合关联缓存方案将有 128 个缓存行集合,每个集合包含四条缓存线。这将使得缓存能够在没有冲突的情况下保持最多四个不同的数据块,每个数据块都会映射到直接映射缓存中的同一缓存行。

二路或四路集合关联缓存比直接映射缓存要好得多,而且比全关联缓存复杂度低得多。每个缓存行集合中的缓存线越多,我们就越接近创建全关联缓存,但也会带来复杂性和速度上的问题。大多数缓存设计都是直接映射、二路集合关联或四路集合关联。80x86 系列的各种成员都使用这三种缓存方案。

将缓存方案与访问类型匹配

尽管直接映射缓存有其缺点,但对于顺序访问的数据,它实际上非常有效,而非随机访问的数据。因为 CPU 通常按顺序执行机器指令,所以指令字节可以非常有效地存储在直接映射缓存中。然而,程序访问数据的方式往往比访问代码更为随机,因此数据更适合存储在二路或四路集合关联缓存中。

由于这些不同的访问模式,许多 CPU 设计师为数据和机器指令字节使用独立的缓存——例如,使用一个 8KB 数据缓存和一个 8KB 指令缓存,而不是单一的 16KB 统一缓存。这种方法的优点是,每个缓存都可以使用最适合它将存储的特定值的缓存方案。缺点是这两个缓存的大小各为统一缓存的一半,可能导致比统一缓存更多的缓存未命中。选择合适的缓存组织结构是一个困难的问题,超出了本书的范围,只有在分析了目标处理器上运行的多个程序后,才能做出选择。

11.4.4 缓存行替换策略

到目前为止,我们已经回答了“我们将数据块存放在哪里”的问题。现在,我们转向同样重要的问题:“如果我们想要将数据块放入缓存行,但缓存行不可用,会发生什么?”

对于直接映射缓存架构,缓存控制器只是简单地用新数据替换缓存行中原本的数据。任何后续对旧数据的引用都会导致缓存未命中,缓存控制器将不得不通过替换该行中的任何数据,将旧数据恢复到缓存中。

对于二路组相联缓存,替换算法要复杂一些。如你所见,每当 CPU 引用一个内存位置时,缓存控制器会使用地址的某些位来确定应该用来存储数据的缓存行组。然后,借助一些精密电路,缓存控制器判断数据是否已经存在于目标组的两个缓存行中的一个。如果数据不存在,CPU 必须从内存中获取它,控制器则需要从两个缓存行中选择一个来使用。如果其中一个或两个缓存行当前未被使用,控制器会选择未使用的缓存行。然而,如果两个缓存行都正在使用,控制器必须从中选择一个,并用新数据替换它的内容。

控制器无法预测哪个缓存行的数据会首先被引用并替换其他缓存行,但它可以使用时效性原理:如果一个内存位置最近被引用过,那么它很可能在不久的将来再次被引用。这意味着以下推论:如果一个内存位置有一段时间没有被访问,它很可能要很长时间后 CPU 才会再次访问它。因此,许多缓存控制器使用最近最少使用(LRU)算法。

在二路组相联缓存系统中,LRU 策略很容易实现,只需为每组两个缓存行使用一个比特位。每当 CPU 访问其中一个缓存行时,该比特位会被设置为0,而每当 CPU 访问另一个缓存行时,该比特位会被设置为1。然后,当需要替换时,缓存控制器将替换 LRU 缓存行,该行由该比特位的反值指示。

对于四路(或更多路)组相联缓存,维护 LRU 信息会更加困难,这也是此类缓存电路更复杂的原因之一。由于 LRU 可能带来的复杂性,其他替换策略有时会代替它使用。其中两种,先进先出(FIFO)随机,比 LRU 更容易实现,但它们也有各自的问题。它们的优缺点的全面讨论超出了本书的范围,但你可以在计算机架构或操作系统的书籍中找到更多信息。

11.4.5 缓存写入策略

当 CPU 向内存写入数据时会发生什么?简单的答案是,CPU 将数据写入缓存,这也是最快的操作。然而,当缓存行数据随后被从内存读取的数据替换时会发生什么?如果修改过的缓存行内容没有写入主存,它们将丢失。下次 CPU 访问该数据时,它将重新加载带有旧数据的缓存行。

很明显,任何写入缓存的数据最终都必须写入主存。缓存使用两种常见的写入策略:写直达写回

写直达策略规定,每次将数据写入缓存时,缓存会立即将该缓存行的副本写入主存。CPU 在缓存控制器将数据从缓存写入主存时不需要暂停。因此,除非 CPU 在写操作后需要立即访问主存,否则该操作与程序的执行并行进行。由于写直达策略会尽可能快地用新值更新主存,当两个不同的 CPU 通过共享内存进行通信时,这是一种更好的策略。

然而,写操作需要一定时间,在此期间 CPU 很可能想要访问主存,因此这种策略可能不是高性能的解决方案。更糟糕的是,假设 CPU 连续多次从内存位置读取并写入数据。使用写直达策略时,CPU 将使总线饱和,进行缓存行写入,这将显著影响程序的性能。

使用写回策略时,写入缓存的数据不会立即写入主存;相反,缓存控制器会稍后更新主存。此方案通常性能更高,因为在短时间内对同一缓存行的多次写入不会生成多次写入主存。

为了确定哪些缓存行必须写回主存,缓存控制器通常会在每个缓存行中维护一个脏位。每当缓存控制器将数据写入缓存时,系统会设置此位。在稍后的时间里,缓存控制器检查脏位以判断是否需要将缓存行写入内存。例如,每当缓存控制器用内存中的其他数据替换缓存行时,它首先检查脏位,如果该位被设置,控制器将在进行缓存行替换之前将该缓存行写入内存。请注意,这会增加缓存行替换期间的延迟时间。如果缓存控制器能够在没有其他总线访问的情况下将脏缓存行写入主存,延迟时间可能会减少。有些系统提供此功能,而有些系统由于经济原因不提供此功能。

11.4.6 缓存使用与软件

缓存子系统并不是解决内存访问慢的灵丹妙药,实际上它可能会损害应用程序的性能。为了让缓存系统有效,软件必须在设计时考虑缓存行为。特别地,好的软件必须表现出空间或时间局部性——软件设计师通过将常用变量放置在内存相邻位置,以确保它们尽可能地落入同一缓存行——并避免使用会导致缓存频繁替换缓存行的数据结构和访问模式。

假设一个应用程序访问多个不同地址的数据,这些地址会被缓存控制器映射到同一缓存行。每次访问时,缓存控制器必须读取一个新的缓存行(如果旧缓存行有脏数据,可能需要将其刷新回内存)。因此,每次内存访问都会带来从主内存获取缓存行的延迟成本。这个退化情况被称为* thrashing *,它可能会使程序变慢一个到两个数量级,具体取决于主内存的速度和缓存行的大小。我们将在本章稍后再次讨论 thrashing。

现代 80x86 CPU 上缓存子系统的一个好处是它可以自动处理许多不对齐的数据引用。记住,如果访问的单词或双字对象的地址不是该对象大小的偶数倍,会产生性能惩罚。通过提供一些复杂的逻辑,Intel 的设计师消除了这个惩罚,只要数据对象完全位于缓存行内。然而,如果该对象跨越了一个缓存行,惩罚仍然存在。

11.5 NUMA 和外部设备

尽管系统中的大多数 RAM 是基于与处理器总线直接连接的高速 DRAM,但并非所有内存都以这种方式连接到 CPU。有时,一大块 RAM 是外部设备的一部分——例如,显卡、网络接口卡或 USB 控制器——你通过将数据写入该设备的 RAM 来与设备通信。不幸的是,这些外部设备的 RAM 访问时间通常比主内存的访问时间要慢得多。在本节中,我们将以显卡为例,尽管 NUMA 性能同样适用于其他设备和内存技术。

一块典型的显卡通过计算机系统内部的外部组件互联高速总线(PCI-e)与 CPU 接口。尽管 16 通道 PCI-e 总线非常快速,但内存访问仍然要快得多。游戏程序员早就发现,将屏幕数据的副本放在主内存中处理,并只在每次视频回扫时(通常是每秒 1/60 次,以避免闪烁)将这些数据写入显卡的 RAM,比每次想要更改时直接写入显卡要快得多。

缓存和虚拟内存子系统是透明操作的(也就是说,应用程序无法察觉底层的操作),但 NUMA 内存不是,所以写入 NUMA 设备的程序必须尽可能最小化访问次数(例如,通过使用离屏位图来保存临时结果)。如果你实际在 NUMA 设备上存储和检索数据,例如在闪存卡上,你必须显式地自己缓存数据。

11.6 虚拟内存、内存保护与分页

在现代操作系统中,如 Android、iOS、Linux、macOS 或 Windows,多个不同的程序通常会同时在内存中运行。这会带来几个问题:

  • 如何防止程序互相干扰彼此的内存呢?

  • 如果两个程序都希望将一个值加载到内存中的地址$1000,那么如何才能同时加载这两个值并同时执行这两个程序呢?

  • 如果计算机有 64GB 内存,并且你决定加载并执行三个不同的应用程序,其中两个需要 32GB,一个需要 16GB(更别提操作系统为自身目的所需的内存)会发生什么呢?

这些问题的答案都在现代处理器支持的虚拟内存子系统中。

在像 80x86 这样的 CPU 上,虚拟内存为每个进程提供其自己的 32 位地址空间^(2)。这意味着一个程序中的地址$1000在物理上与另一个程序中的地址$1000不同。CPU 通过将程序使用的虚拟地址映射到实际内存中的不同物理地址来实现这种“魔术”。虚拟地址和物理地址不必相同,而且通常它们不是。例如,程序 1 的虚拟地址$1000可能实际上对应物理地址$215000,而程序 2 的虚拟地址$1000可能对应物理内存地址$300000。CPU 通过分页实现这一点。

分页的概念非常简单。首先,你将内存分割成叫做页面的字节块。主内存中的一个页面可以与缓存子系统中的缓存行相比较,尽管页面通常比缓存行要大得多。例如,32 位的 80x86 CPU 使用的页面大小为 4,096 字节;而 64 位变种允许更大的页面大小。

对于每个页面,你使用查找表将虚拟地址的高位映射到内存中物理地址的高位,并将虚拟地址的低位作为该页面的索引。例如,使用 4,096 字节的页面时,你将虚拟地址的低 12 位用作页面内的偏移量(0..4095),而将高 20 位作为查找表的索引,该表返回物理地址的实际高 20 位(参见图 11-5)。

image

图 11-5:将虚拟地址转换为物理地址

20 位索引进入页表会在页表中需要超过一百万个条目。如果每个条目是 32 位的,那么页表的大小将是 4MB——比许多运行在内存中的程序还要大!然而,通过使用多级页表,你可以轻松地为大多数小程序创建一个仅有 8KB 长的页表。具体细节在这里不重要。只需要放心,除非你的程序使用了整个 4GB 的地址空间,否则你不需要一个 4MB 的页表。

如果你稍微研究一下图 11-5,你可能会发现使用页表的一个问题——它需要两次独立的内存访问才能检索存储在内存中单一物理地址的数据:一次是从页表中获取值,另一次是从所需的内存位置读取或写入数据。为了防止将页表条目堆积在数据或指令缓存中,从而增加数据和指令请求的缓存未命中次数,页表使用了它自己的缓存,称为转换旁路缓冲区(TLB)。这个缓存通常在现代 Intel 处理器中有 64 到 512 个条目——足够处理相当数量的内存而不会发生未命中。因为程序通常在任何给定时刻处理的数据少于这个数量,所以大多数页表访问来自缓存,而非主内存。

如前所述,页表中的每个条目包含 32 位,尽管系统实际上只需要 20 位来将每个虚拟地址映射到物理地址。Intel 在 80x86 上使用剩余的 12 位来提供内存保护信息:

  • 一位标志位表示页面是可读写还是只读。

  • 一位标志位决定你是否可以在该页面上执行代码。

  • 若干位决定应用程序是否可以访问该页面,还是仅操作系统可以访问。

  • 若干位决定 CPU 是否已写入页面,但尚未写入对应的物理内存地址(即页面是否“脏”以及 CPU 是否最近访问了该页面)。

  • 一位标志位决定页面是否实际存在于物理内存中,还是存在于某个地方的二级存储中。

你的应用程序无法访问页表(读取和写入页表是操作系统的责任),因此它们不能修改这些位。然而,一些操作系统提供了你可以调用的函数,若你想更改页表中的某些位(例如,Windows 允许你将一个页面设置为只读)。

除了重新映射内存使多个程序能够在主内存中共存外,分页还提供了一种机制,使操作系统能够将不常用的页面移动到辅助存储。引用局部性不仅适用于缓存行,也适用于主内存中的页面。在任何给定时刻,程序只会访问包含数据和指令字节的主内存中少量的页面;这组页面被称为工作集。尽管工作集随时间变化缓慢,但在较短的时间内,它保持不变。因此,程序的其余部分不需要占用其他进程可能需要的宝贵主内存存储。如果操作系统能够将当前未使用的页面保存到磁盘,那么它们将释放的主内存空间可以供其他需要的程序使用。

当然,将数据从主内存移出的一个问题是,程序最终可能确实需要这些数据。如果你尝试访问一个内存页,而页表位告诉内存管理单元(MMU)该页不在主内存中,CPU 将中断程序并将控制权交给操作系统。操作系统从磁盘驱动器读取相应的页面数据,并将其复制到主内存中的某个空闲页面。这一过程几乎与完全关联缓存子系统使用的过程相同,唯一不同的是访问磁盘比访问主内存要慢得多。事实上,你可以将主内存视为一个具有 4,096 字节缓存行的完全关联写回缓存,它缓存了存储在磁盘驱动器上的数据。缓存和主内存的置换策略及其他行为非常相似。

注意

有关操作系统如何在主内存和辅助存储之间交换页面的更多信息,请参考操作系统设计的教科书。

因为每个程序都有一个独立的页表,而且程序本身无法访问这些页表,所以程序之间无法相互干扰。也就是说,一个程序不能修改其页表以访问另一个进程的[地址空间](gloss01.xhtml#gloss01_7)中的数据。如果你的程序通过覆盖自身而崩溃,它不会同时崩溃其他程序。这是分页内存系统的一个重要优点。

如果两个程序想要协作并共享数据,它们可以通过将数据放入它们共享的内存区域来实现。它们只需要告诉操作系统它们希望共享某些内存页。操作系统将返回每个进程一个指向某个内存段的指针,该段的物理地址对两个进程来说是相同的。在 Windows 系统中,你可以通过使用内存映射文件来实现;更多细节请参见操作系统文档。macOS 和 Linux 也支持内存映射文件以及一些特殊的共享内存操作;同样,详情请参见操作系统文档。

尽管本讨论特别适用于 80x86 CPU,但多级分页系统在其他 CPU 上也很常见。页面大小通常在约 1KB 到 4MB 之间,具体取决于 CPU。对于支持大于 4GB 地址空间的 CPU,一些 CPU 使用反向页表三层页表。尽管这些细节超出了本章的讨论范围,但基本原理是相同的:CPU 在主内存和磁盘之间移动数据,以尽量将经常访问的数据保持在主内存中。这些其他的页表方案在应用程序仅使用部分可用内存空间时,能够有效减少页表的大小。

抖动

抖动是一个退化的情况,可能会导致整个系统性能下降到内存层次结构中较低层次的速度,如主内存,或者更糟糕的是,磁盘驱动器。抖动的主要原因有两个:

  • 在内存层次结构中的某个级别上,内存不足以适当地容纳程序的工作集缓存行或页面

  • 不具备引用局部性的程序

如果内存不足以容纳工作集页面或缓存行,内存系统将不断地用主内存或磁盘中的另一个数据块替换缓存或主内存中的一个数据块。结果,系统的运行速度就会降到内存层次结构中较慢的内存的速度。虚拟内存中常见的抖动现象就是如此。例如,一个用户可能同时运行多个应用程序,这些程序所需的内存总和大于可用的物理内存。因此,当操作系统在应用程序之间切换时,它必须将每个应用程序的数据,可能还有程序指令,从磁盘中读入或写出。由于在程序之间切换通常比从磁盘中获取数据要快得多,这会使程序运行速度大幅下降。

如前所述,如果程序没有展示引用局部性,并且低层内存子系统不是完全关联的,即使当前内存层级中有空闲内存,抖动也可能发生。让我们回顾之前的例子,假设一个 8KB 的 L1 缓存系统使用一个直接映射的缓存,拥有 512 个 16 字节的缓存行。如果一个程序在每次访问时引用间隔为 8KB 的数据对象,那么系统将不得不反复用主内存中的数据替换缓存中的相同缓存行。即使其他 511 个缓存行当前未被使用,这种情况仍然会发生。

当内存不足导致抖动(thrashing)时,你可以通过增加内存来解决问题。如果无法增加内存,可以尝试减少并发运行的进程数量,或者修改程序,使其在给定时间内引用更少的内存。若是引用局部性(locality of reference)导致了抖动,则应重构程序及其数据结构,使其内存引用物理上更加接近。

11.7 编写能够理解内存层级的软件

意识到内存性能行为的软件可以比那些没有意识到的运行得更快。虽然一个系统的缓存和分页机制对于典型程序可能表现得相当好,但其实很容易编写出即使没有缓存系统也能运行得更快的软件。最好的软件是能够最大化利用内存层级结构的。

一个典型的坏设计案例是以下循环,它初始化了一个二维整数数组:

int array[256][256];

        . . .

    for( i=0; i<256; ++i )

        for( j=0; j<256; ++j )

            array[j][i] = i*j;

信不信由你,以下代码在现代 CPU 上运行的速度比前面的代码要慢得多:

int array[256][256];

        . . .

    for( i=0; i<256; ++i )

        for( j=0; j<256; ++j )

            array[i][j] = i*j;

这两段代码的唯一不同之处在于,在访问数组元素时,ij 索引被交换了。这个小修改可能导致它们的运行时间相差一到两个数量级!要理解为什么,记住 C 语言对二维数组在内存中的排列使用的是行主序(row-major ordering)。这意味着第二段代码在内存中访问的是顺序位置,展示了空间局部性(spatial locality of reference)。而第一段代码则按以下顺序访问数组元素:

array[0][0]

array[1][0]

array[2][0]

array[3][0]

    . . .

array[254][0]

array[255][0]

array[0][1]

array[1][1]

array[2][1]

    . . .

如果整数占用 4 字节,那么该序列将从数组的基地址开始,分别访问偏移量为 0、1,024、2,048、3,072 等的双字(double-word)值,这些值显然是顺序的。很可能,这段代码会将 n 个整数加载到一个 n-路集合关联缓存中,然后立刻导致抖动,因为每个随后的数组元素都必须从缓存复制到主内存中,以防数据被覆盖。

第二段代码序列没有出现抖动。假设使用 64 字节的缓存行,第二段代码序列将在加载另一个缓存行并替换已有缓存行之前,将 16 个整数值存储到同一个缓存行中。因此,第二段代码序列将内存中缓存行的检索成本分摊到 16 次内存访问中,而不是像第一段代码序列那样仅通过一次访问来完成。

除了按顺序访问内存中的变量外,你还可以使用几种其他的变量声明技巧来最大化内存层次结构的性能。首先,将在公共代码序列中使用的所有变量一起声明。在大多数语言中,这将为变量分配物理上相邻的内存位置,从而支持空间局部性和时间局部性。其次,使用局部(自动)变量,因为大多数语言将局部存储分配到栈上,而系统频繁访问栈,栈上的变量往往会被缓存。第三,将标量变量一起声明,并与数组和记录变量分开。访问任何一个相邻的标量变量通常会迫使系统将所有相邻的对象加载到缓存中。

通常,研究你程序表现出的内存访问模式,并相应地调整你的应用程序。你可以花费数小时用手动优化的汇编语言重写代码,试图提高 10%的性能,但如果你改进程序访问内存的方式,通常会看到性能提升一个数量级的情况。

11.8 运行时内存组织

像 macOS、Linux 或 Windows 这样的操作系统将不同类型的数据放入主内存的不同区段(或)。虽然可以通过运行链接器并指定各种参数来控制内存组织,但默认情况下,Windows 会按照图 11-6 中所示的组织方式将典型程序加载到内存中(macOS 和 Linux 也类似,尽管它们重新排列了一些区段)。

image

图 11-6:典型的 Windows 运行时内存组织

操作系统保留了最低的内存地址,你的应用程序通常不能访问这些地址的数据(或执行这些地址的指令)。操作系统保留这些空间的一个原因是帮助检测NULL指针引用。程序员通常会将指针初始化为NULL0),表示指针无效。如果你尝试在这样的操作系统中访问内存位置0,它会生成通用保护错误,以表明你访问了一个不包含有效数据的内存位置。

剩余的七个内存区段存储与你程序相关的不同类型的数据:

  • 代码段存储程序的机器指令。

  • 常量区包含编译器生成的只读数据。

  • 只读数据段保存用户定义的只能读取、不能写入的数据。

  • 静态区保存用户定义的已初始化静态变量。

  • 存储区或 BSS 区保存用户定义的未初始化变量。

  • 栈区维护着局部变量和其他临时数据。

  • 堆区维护动态变量。

注意

通常,编译器会将代码、常量和只读数据段合并在一起,因为它们都包含只读数据。

大多数情况下,给定的应用程序可以使用编译器和链接器/加载器为这些区段选择的默认布局。然而,在某些情况下,了解内存布局可以帮助你开发更短的程序。例如,将代码、常量和只读数据区段合并为一个只读区段,可以节省编译器/链接器可能在它们之间插入的填充空间。尽管这些节省对大型应用程序来说可能微不足道,但对小程序的大小影响却可能非常大。

以下各节将详细讨论这些内存区域。

11.8.1 静态与动态对象、绑定与生命周期

在探讨典型程序的内存组织结构之前,我们需要定义一些术语:绑定、生命周期、静态和动态。

绑定 是将属性与对象关联的过程。例如,当你给一个变量赋值时,这个值就被绑定到该变量上,直到你将另一个值赋给该变量为止。类似地,如果你在程序运行时为一个变量分配内存,这个变量就绑定到那个地址,直到你将不同的地址与该变量关联。绑定不一定发生在运行时。例如,在编译时,值会被绑定到常量对象上,而这些绑定在程序运行时不能改变。

生命周期 是指一个属性从你首次将该属性绑定到对象开始,到你断开这个绑定(例如通过将另一个属性绑定到该对象)为止的过程。例如,一个变量的生命周期是从你首次为该变量分配内存开始,到你释放该变量的存储空间为止。

静态对象是指在应用程序执行之前就绑定了某个属性的对象(通常是在编译时或链接阶段,尽管也有可能在更早的时候就绑定值)。常量就是静态对象的一个很好的例子;它们在整个程序执行过程中绑定着相同的值。像 Pascal、C/C++ 和 Ada 等编程语言中的全局(程序级别)变量也是静态对象的例子,因为它们在程序生命周期内始终绑定着相同的地址。因此,静态对象的生命周期从程序开始执行的时刻起,直到应用程序终止为止。

与静态绑定相关的是标识符的作用域概念——标识符名称绑定到对象的程序部分。由于名称仅在编译期间存在,作用域在编译语言中被视为静态属性。(在解释性语言中,解释器在程序执行期间维护标识符名称,因此作用域可以是非静态属性。)局部变量的作用域通常限制在声明它的过程或函数内(或者在像 Pascal 或 Ada 这样的块结构语言中的任何嵌套过程或函数声明内),并且该名称在子程序外不可见。事实上,可以在不同的作用域中重用标识符的名称(即,在不同的函数或过程内)。在这种情况下,标识符的第二次出现将绑定到与第一次出现不同的对象。

动态对象是指在程序执行过程中某个属性被赋予的对象。在程序运行时,程序可能会选择动态地更改该属性。该属性的生命周期从应用程序将属性绑定到对象的那一刻开始,直到程序断开该绑定时结束。如果程序从未断开该绑定,那么该属性的生命周期将从关联时刻延续到程序终止时刻。系统在程序执行期间将动态属性绑定到对象。

注意

一个对象可能同时拥有静态和动态属性的组合。例如,一个静态变量在程序的整个执行时间内都绑定着一个地址,但它可以在程序生命周期内绑定不同的值。然而,任何给定的属性要么是静态的,要么是动态的;它不可能同时是两者。

11.8.2 代码、只读和常量部分

内存中的代码部分包含程序的机器指令。您的编译器将您编写的每个语句转换为一个或多个字节值的序列。在程序执行过程中,CPU 将这些字节值解释为机器指令。

大多数编译器还将程序的只读数据附加到代码区块,因为像代码指令一样,只读数据已经是写保护的。然而,在 Windows、macOS、Linux 以及许多其他操作系统中,完全可以在可执行文件中创建一个单独的区块并将其标记为只读。因此,一些编译器支持一个独立的只读数据区块。这些区块包含已初始化的数据、表格和程序在执行过程中不应修改的其他对象。

图 11-6 中所示的常量区块通常包含编译器生成的数据(与用户定义的只读数据相对)。大多数编译器实际上会直接将这些数据写入代码区块。这就是为什么如前所述,在大多数可执行文件中,你会发现有一个单一的区块,它结合了代码区块、只读数据区块和常量数据区块。

11.8.3 静态变量区块

许多语言允许你在编译阶段初始化全局变量。例如,在 C/C++中,你可以使用如下语句为这些静态对象提供初始值:

static int i = 10;

static char ch[] = { 'a', 'b', 'c', 'd' };

在 C/C++以及其他语言中,编译器将这些初始值放置在可执行文件中。当你执行应用程序时,操作系统会将包含这些静态变量的可执行文件部分加载到内存中,以便这些值出现在与静态变量相关的地址上。因此,当此程序首次开始执行时,ich将绑定这些值。

11.8.4 存储变量区块

存储变量(或 BSS)区块是编译器通常放置没有显式值的静态对象的地方。BSS 代表“由符号开始的区块”(Block Started by Symbol),这是一个旧的汇编语言术语,用来描述一种伪操作码,通常用于为未初始化的静态数组分配存储空间。在像 Windows 和 Linux 这样的现代操作系统中,编译器/链接器会将所有未初始化的变量放入 BSS 区块,这个区块仅告诉操作系统为该区块保留多少字节。当操作系统将程序加载到内存中时,它会为 BSS 区块中的所有对象保留足够的内存,并将这部分内存填充为0

请注意,可执行文件中的 BSS 区块实际上不包含任何数据,因此在 BSS 区块中声明未初始化的静态对象(尤其是大型数组)的程序将占用较少的磁盘空间。

然而,并不是所有编译器都实际使用 BSS 部分。例如,一些 Microsoft 语言和链接器只是将未初始化的对象放置在静态/只读数据部分,并显式地给它们一个初始值0。尽管 Microsoft 声称这种方案更快,但如果你的代码包含大型未初始化数组,这无疑会使可执行文件变得更大(因为数组的每个字节最终都出现在可执行文件中——如果编译器将数组放在 BSS 部分,这种情况就不会发生)。

11.8.5 堆栈部分

堆栈是一种数据结构,它根据过程调用和返回等操作动态扩展和收缩。在运行时,系统将所有自动变量(非静态局部变量)、子程序参数、临时值以及其他对象放置在内存中的堆栈部分,采用一种称为激活记录的特殊数据结构(这个名字恰如其分,因为系统在子程序开始执行时创建它,并在子程序返回到调用者时销毁它)。因此,内存中的堆栈部分非常繁忙。

大多数 CPU 使用一个叫做栈指针的寄存器来实现堆栈。然而,一些 CPU 并不提供显式的栈指针,而是使用通用寄存器来实现堆栈。如果 CPU 提供栈指针,我们称它支持硬件栈;如果它使用通用寄存器,则称它使用软件实现的栈。80x86 提供硬件栈,而 MIPS Rx000 系列 CPU 则使用软件实现的栈。提供硬件栈的系统通常可以比实现软件栈的系统用更少的指令操作堆栈上的数据。理论上,硬件栈会减慢 CPU 执行所有指令的速度,但实际上,80x86 CPU 是最快的 CPU 之一,这充分证明了拥有硬件栈并不一定意味着 CPU 会变慢。

11.8.6 堆部分与动态内存分配

尽管简单程序可能只需要静态和自动变量,但复杂的程序需要能够在程序控制下动态地分配和释放存储(在运行时)。C 语言和 HLA 语言为此提供了malloc()free()函数,C++提供了new()delete(),Pascal 使用new()dispose(),其他语言也包括类似的例程。这些内存分配例程有一些共同点:它们允许程序员请求分配多少字节的存储,它们返回一个指向新分配存储的指针(即该存储的地址),并且它们提供一个将存储空间返回给系统的功能,一旦存储不再需要,系统就可以在未来的分配调用中重新利用这部分空间。动态内存分配发生在被称为的内存部分。

一般来说,应用程序通过指针变量隐式或显式地引用堆上的数据;一些语言,如 Java,背后隐式地使用指针。因此,堆内存中的对象通常被称为匿名变量,因为我们通过它们的内存地址(通过指针)来引用它们,而不是通过名称。

操作系统和应用程序在程序开始执行后创建堆内存区域;堆从来不是可执行文件的一部分。通常,操作系统和语言运行时库为应用程序维护堆。尽管内存管理的实现方式有所不同,但你仍然应该对堆的分配和回收有一个基本的了解,因为不当使用它们会对应用程序的性能产生非常负面的影响。

11.8.6.1 一种简单的内存分配方案

一个极其简单(且快速)的内存分配方案会返回一个指向内存块的指针,该内存块的大小由调用者请求。它会从堆中划分出分配请求,返回当前未使用的内存块。

一个非常简单的内存管理器可能会维护一个单一的变量(空闲空间指针),指向堆。每当有内存分配请求时,系统会复制这个堆指针并将其返回给应用程序;然后,堆管理例程会将内存请求的大小加到指针变量中保存的地址,并验证内存请求是否试图使用比堆中可用内存更多的空间(一些内存管理器在内存请求过大时返回错误指示,如NULL指针,其他的则抛出异常)。当堆管理例程增加空闲空间指针时,它们实际上会将所有之前的内存标记为“不可供未来请求使用”。

11.8.6.2 垃圾回收

这种简单的内存管理方案的问题在于它浪费内存,因为没有垃圾回收机制来帮助应用程序释放内存,以便之后可以重新使用。垃圾回收——即在应用程序使用完内存后回收内存——是堆管理系统的主要目的之一。

唯一的难点是,支持垃圾回收需要一定的开销。内存管理代码需要更复杂,执行时间会更长,并且需要一些额外的内存来维护堆管理系统使用的内部数据结构。

让我们考虑一个支持垃圾回收的堆管理器的简单实现。这个简单的系统维护一个(链式)空闲内存块列表。列表中的每个空闲内存块需要两个双字节值:一个指定空闲块的大小,另一个包含指向列表中下一个空闲块的链接(即指针),如图 11-7 所示。

系统用一个NULL链接指针初始化堆,大小字段包含堆中所有空闲空间的大小。当有内存分配请求时,堆管理器会遍历列表,找到一个足够大的空闲块来满足请求。这个搜索过程是堆管理器的一个重要特征。一些常见的搜索算法有首次适应搜索和最适应搜索。首次适应搜索,顾名思义,扫描块列表,直到找到第一个足够大的块来满足分配请求。最适应搜索则扫描整个列表,找到一个足够大的最小块来满足请求。最适应算法的优点是,它通常能比首次适应算法更好地保存较大的块,因此系统在后续有更大内存分配请求时,依然能够满足。另一方面,首次适应算法则会直接抓取它找到的第一个合适的大块,即使有一个较小的块就能满足请求,这可能会限制系统处理未来大型内存请求的能力。

image

图 11-7:使用空闲内存块列表进行堆管理

尽管如此,首次适应算法(first-fit)相比最适应算法(best-fit)确实有一些优势。最明显的一点是它通常更快。最适应算法需要扫描空闲块列表中的每一个块,以找到足够大的最小块来满足分配请求(除非,它在过程中恰好找到了一个尺寸完全合适的块)。而首次适应算法一旦找到一个足够大的块满足请求,就可以停止。

首次适应算法通常也较少受到一种叫做外部碎片的退化状态的影响。碎片化是经过一系列分配和回收请求后的结果。记住,当堆管理器满足内存分配请求时,它通常会创建两个内存块:一个是用于请求的已使用块,另一个是包含剩余字节的空闲块(假设请求的大小与块大小不完全匹配)。经过一段时间的操作后,最适应算法可能会产生大量剩余内存块,这些块太小,无法满足平均大小的内存请求,因此它们实际上变得无法使用。随着这些小碎片在堆中积累,它们可能会占用大量内存。这可能导致堆中没有足够大的块来满足内存分配请求,尽管堆中仍然有足够的总空闲内存(分散在堆的各个地方)。请参见图 11-8 查看这一情况的示例。

image

图 11-8:内存碎片

除了首次适应(first-fit)和最佳适应(best-fit)搜索算法外,还有其他的内存分配策略。这些策略中有些执行速度更快,有些内存开销更小,有些容易理解(而有些则非常复杂),有些可以减少碎片,有些则能够合并并使用非连续的空闲内存块。内存/堆管理是计算机科学中研究得比较深入的课题之一,关于不同方案优缺点的文献也相当多。如果你想了解更多内存分配策略的信息,可以参考一本关于操作系统设计的好书。

11.8.6.3 释放分配的内存

内存分配只是整个过程的一半。如前所述,堆管理器必须提供一个调用,使得应用程序能够将不再需要的内存返回以供未来重用。例如,在 C 和 HLA 中,应用程序通过调用 free() 函数来完成这一操作。

初看起来,free() 似乎是一个非常简单的函数:只需将先前分配的、现在未使用的内存块附加到空闲链表的末尾。这个简单实现的问题在于,它几乎可以保证堆在非常短的时间内碎片化到不可用的程度。考虑一下图 11-9 中的情况。

image

图 11-9:释放内存块

如果 free() 的简单实现只是将要释放的内存块添加到空闲链表中,那么图 11-9 中的内存组织就会产生三个空闲块。然而,由于这三个块是连续的,堆管理器实际上应该将它们合并成一个单一的空闲块,以便能够满足更大的请求。不幸的是,这个操作需要扫描空闲块列表,以确定是否有任何空闲块与系统正在释放的块相邻。

虽然你可以设计一种数据结构,使得组合相邻的空闲块变得更加容易,但这种方案通常会在堆上的每个块中增加 8 个或更多字节的开销。是否这是一个合理的折衷,取决于内存分配的平均大小。如果使用堆管理器的应用程序倾向于分配小对象,那么每个内存块的额外开销可能会占用堆空间的较大百分比。然而,如果大多数分配是大的,那么这几个字节的开销就不那么重要了。

11.8.6.4 操作系统与内存分配

堆管理器使用的算法和数据结构的性能只是性能难题中的一部分。最终,堆管理器需要向操作系统请求内存块。在一种极端情况下,操作系统直接处理所有内存分配请求。在另一种极端情况下,堆管理器是一个与应用程序链接的运行时库例程,首先向操作系统请求大块内存,然后在应用程序发出分配请求时将这些内存块分配出去。

向操作系统发出直接的内存分配请求的问题在于,操作系统的 API 调用通常非常慢。这是因为它们通常涉及 CPU 在内核模式和用户模式之间的切换(这并不快)。因此,操作系统直接实现的堆管理器,如果应用程序频繁调用内存分配和释放例程,性能表现会不好。

由于操作系统调用的高开销,大多数语言在其运行时库中实现了自己版本的malloc()free()函数。在第一次内存分配时,malloc()例程会向操作系统请求一个大块内存,应用程序的malloc()free()例程自己管理这块内存。如果出现一个malloc()函数无法在其最初创建的内存块中满足的分配请求,malloc()将向操作系统请求另一个大块内存(通常比请求的大得多),并将该内存块添加到其空闲列表的末尾。因为应用程序的malloc()free()例程只偶尔调用操作系统,所以应用程序不会遭受频繁操作系统调用所带来的性能损失。

大多数标准堆管理函数在典型程序中表现合理。然而,请记住,这些过程在实现和语言上具有很强的特定性;在编写需要高性能组件的软件时,假设malloc()free()相对高效是危险的。确保高性能堆管理器的唯一便携方式是开发自己应用程序特定的分配/释放例程。编写此类例程超出了本书的范围,但你应该知道你有这个选项。

11.8.6.5 堆内存开销

堆管理器通常会出现两种开销:性能(速度)和内存(空间)。到目前为止,这一讨论主要涉及性能方面,但现在我们将注意力转向内存。

系统分配的每个块需要比应用程序请求的存储空间多出一些开销;至少,这些开销是用于跟踪块大小的几个字节。更复杂(更高性能)的方案可能需要额外的字节,但通常开销在 4 到 16 字节之间。堆管理器可以将这些信息保存在一个单独的内部表中,或者直接将块大小和其他内存管理信息附加到它分配的块上。

将这些信息保存在内部表中的有几个优点。首先,应用程序很难不小心覆盖存储在其中的信息;而将数据附加到堆内存块本身并不能很好地防止这种可能性。其次,将内存管理信息放在内部数据结构中,允许内存管理器判断给定的指针是否有效(即,它是否指向堆管理器认为已分配的某个内存块)。

将控制信息直接附加到堆管理器分配的每个块上的优点在于,它非常容易找到这些信息,而将信息存储在内部表中可能需要进行搜索操作。

影响堆管理器相关开销的另一个问题是分配粒度——堆管理器支持的最小字节数。尽管大多数堆管理器允许你请求最小为 1 字节的分配,但它们实际上可能分配一个大于 1 的最小字节数。为了确保分配的对象在该对象的合理地址上对齐,大多数堆管理器在 4 字节、8 字节或 16 字节边界上分配内存块。出于性能原因,许多堆管理器会在典型的缓存行边界上开始每次分配,通常是 16 字节、32 字节或 64 字节。

无论粒度如何,如果应用程序请求的字节数小于堆管理器粒度的倍数,堆管理器将分配额外的存储字节,以确保完整的分配是粒度值的偶数倍。这个数量因堆管理器(甚至可能因特定堆管理器的版本)而异,因此应用程序不应假设它拥有比请求更多的内存。

堆管理器分配的额外内存会导致另一种形式的碎片化,称为内部碎片化。与外部碎片化类似,内部碎片化在系统中产生了少量无法满足未来分配请求的剩余内存。假设内存分配是随机大小的,每次分配时产生的内部碎片化的平均量是粒度大小的一半。幸运的是,对于大多数内存管理器,粒度大小非常小(通常为 16 字节或更少),所以经过成千上万次的内存分配后,你只会因为内部碎片化而丧失几十个字节或几千字节的内存。

在分配粒度和内存控制信息相关的开销之间,一个典型的内存请求可能需要 4 到 16 字节,再加上应用程序所请求的字节数。如果你进行大规模的内存分配请求(数百或数千字节),这些额外的开销字节不会占用堆内存的很大比例。然而,如果你分配许多小对象,由于内部碎片和内存控制信息所消耗的内存,可能会占据堆区域的很大一部分。例如,考虑一个简单的内存管理器,它总是以 4 字节对齐分配数据块,并且需要一个 4 字节的长度值,附加到每个内存请求上用于存储。这意味着堆管理器为每次分配所需的最小存储量为 8 字节。如果你进行一系列malloc()调用来分配单个字节,应用程序将无法使用它分配的几乎 88%的内存。即使你在每次分配请求中分配 4 字节的值,堆管理器也会消耗 67%的内存用于开销。然而,如果你的平均分配是 256 字节的块,开销仅占总内存分配的约 2%。总之,分配请求越大,控制信息和内部碎片对堆的影响就越小。

许多计算机科学期刊中的软件工程研究发现,内存分配/释放请求会导致显著的性能损失。在这些研究中,作者通过实现自己简化的、特定应用程序的内存管理算法,而不是调用标准的运行时库或操作系统内核的内存分配代码,通常可以获得 100%或更好的性能提升。希望本节内容能让你意识到自己代码中可能存在的这个潜在问题。

11.9 获取更多信息

Hennessy, John L., 和 David A. Patterson. 计算机体系结构:定量方法(第五版)。马萨诸塞州沃尔瑟姆:Elsevier,2012 年。

第十二章:输入与输出**

Image

一个典型程序有三个基本任务:输入、计算和输出。到目前为止,我们主要关注计算机系统的计算方面,但现在我们将转向输入和输出。

本章将重点讨论 CPU 的原始输入/输出(I/O)活动,而不是高层应用通常使用的抽象文件或字符 I/O。它将讨论 CPU 如何将数据传输到外部世界并从外部世界获取数据,特别关注 I/O 操作背后的性能问题。由于所有高层 I/O 活动最终都会通过低层 I/O 系统进行,因此如果你想编写与外部世界高效通信的程序,理解这些过程的工作原理至关重要。

12.1 将 CPU 连接到外部世界

首先要知道的是,典型计算机系统中的 I/O 与典型高级编程语言中的 I/O 截然不同。在计算机系统的原始 I/O 层次上,你很少能找到像 Pascal 的 writeln、C++ 的 cout、C 的 printf、Swift 的 print,甚至 HLA 的 stdinstdout 语句那样的机器指令。事实上,大多数 I/O 机器指令的行为完全像 80x86 的 mov 指令。为了将数据发送到输出设备,CPU 只需将数据移动到一个特殊的内存位置;而为了从输入设备读取数据,CPU 会从设备的地址中获取数据。I/O 操作的行为与内存的读写操作非常相似,不同之处在于 I/O 通常涉及更多的等待状态。

根据 CPU 在给定端口地址上读取和写入数据的能力,I/O 端口可以分为五类:只读、写入、读/写、双重 I/O 和双向 I/O。

只读端口是一个输入端口。如果 CPU 只能从端口读取数据,那么这些数据必须来自计算机系统外部的某个来源。尝试写入只读端口通常不是一个好主意,因为尽管硬件通常会忽略这种尝试,但它可能会导致某些设备出现故障。一个好的只读端口例子是原始 IBM PC 上并行打印机接口的状态端口。该端口的数据指定打印机的当前状态,而硬件会忽略写入该端口的任何数据。

写入端口始终是一个输出端口。写入此类端口的数据可以供外部设备使用。尝试从写入端口读取数据通常会返回总线上的垃圾值,因此你的程序不应依赖于这些值的含义。输出端口通常使用一个锁存器设备来存储要发送到外部世界的数据。当 CPU 写入与输出锁存器关联的端口地址时,锁存器会存储数据,并将其通过一组外部信号线提供(见图 12-1)。

image

图 12-1:典型的写入端口

输出端口的一个完美示例是并行打印机端口。CPU 通常将一个 ASCII 字符写入一个字节宽的输出端口,该端口连接到计算机机箱背面的 DB-25F 连接器。通过电缆将此数据传输到打印机,打印机的输入端口接收到该数据(从打印机的角度来看,它是从计算机系统读取数据)。打印机内部的处理器通常将此 ASCII 字符转换为一系列点,并打印到纸上。

输出端口可以是只写或读/写端口。例如,图 12-1 中的端口是一个只写端口。由于触发器上的输出不会回路到 CPU 的数据总线,因此 CPU 无法读取触发器中包含的数据。要使触发器工作,地址解码线(En)和写控制线(W)都必须处于激活状态。如果 CPU 尝试读取触发器地址上的数据,地址解码线虽然处于激活状态,但写控制线未激活,因此触发器不会响应读取请求。

读/写端口从外界角度来看是一个输出(只写)端口。然而,顾名思义,CPU 也可以从这样的端口读取数据——具体来说,它读取的是最后写入该端口的数据。这样做不会影响传递给外部外设的数据显示。^(1) 图 12-2 展示了一个读/写端口。

image

图 12-2:读/写端口

如你所见,写入输出端口的数据会回路到第二个触发器。将这两个触发器的地址放置在地址总线上,会激活两个触发器的地址解码线。因此,为了在这两个触发器之间选择,CPU 还必须激活读线或写线。激活读线(即在读操作过程中发生的情况)将启用下触发器。这会将先前写入输出端口的数据放到 CPU 的数据总线上,允许 CPU 读取该数据。

图 12-2 中的端口不是输入端口——真正的输入端口从外部引脚读取数据。尽管 CPU 可以从这个触发器读取数据,但该电路的结构只是允许 CPU 读取它之前写入端口的数据,从而避免程序必须将该值保存在单独的变量中。外部连接器上的数据仅供输出,不能将现实世界的输入设备连接到这些信号引脚。

双 I/O 端口也是一个读/写端口,但当你读取双 I/O 端口时,你是从外部输入设备读取数据,而不是从端口地址的输出侧读取最后写入的数据。向双 I/O 端口写入数据会将数据传输到某个外部输出设备,就像向只写端口写入数据一样。图 12-3 展示了如何将双 I/O 端口与系统进行接口。

image

图 12-3:双 I/O 端口

双 I/O 端口实际上是由两个端口组成——一个只读端口和一个只写端口——它们共享相同的端口地址。读取该地址会访问只读端口,而写入该地址会访问只写端口。本质上,这种端口安排利用了读(R)和写(W)控制线,提供了一个额外的地址位,指定使用哪一个端口。

最后,双向端口允许 CPU 既能从外部设备读取数据,又能向外部设备写入数据。为了正常工作,双向端口必须向外围设备传递各种控制线,如读写使能信号,以便设备能够根据 CPU 的读写请求改变数据传输的方向。实际上,双向端口通过双向锁存器或缓冲器扩展了 CPU 的总线。

通常,给定的外围设备使用多个 I/O 端口。例如,原始的 IBM PC 并行打印机接口使用了三个端口地址:一个读写 I/O 端口、一个只读输入端口和一个只写输出端口。读写数据端口允许 CPU 读取通过它写入的最后一个 ASCII 字符。输入端口返回来自打印机的控制信号,指示打印机是否准备好接收另一个字符、是否离线、是否缺纸等状态。输出端口则向打印机传输控制信息。后来的 PC 型号用双向端口替代了数据端口,通过并行端口实现设备间的数据传输。双向数据端口提高了连接到 PC 并行端口的各种设备(如磁盘和磁带驱动器)的性能。(当然,现代 PC 通过 USB 端口与打印机通信——从硬件角度来看,这是完全不同的一种设备。)

12.2 连接端口到系统的其他方式

到目前为止的例子可能给你留下了这样一个印象:CPU 总是通过数据总线读取和写入外围设备数据。然而,虽然 CPU 通常会通过数据总线传输从输入端口读取的数据,但它并不总是通过数据总线将数据写入输出端口。事实上,一种非常常见的输出方式是直接访问端口的地址,而不向其写入任何数据。图 12-4 展示了一个使用置位/复位(S/R)触发器的简单例子,演示了这一技术。

image

图 12-4:通过直接访问端口输出数据

在该电路中,地址解码器解码两个独立的地址。对第一个地址的任何读写访问都会将输出线设置为1;对第二个地址的任何读写访问都会将输出线设置为0。该电路忽略了 CPU 数据线上的数据以及读写线的状态。唯一重要的是,CPU 访问了这两个地址中的一个。

另一种将输出端口连接到系统的方法是将读/写状态线连接到 D 触发器的数据输入端。图 12-5 展示了如何设计这样的设备。

image

图 12-5:使用读/写控制作为输出数据的方式

在此图中,任何对端口的读取操作都会将输出位设置为0,而对该端口的任何写入操作都会将输出位设置为1(在写入指定地址时,读控制线会是HIGH)。

这些只是工程师们为避免使用数据总线(主要是为了降低硬件成本或提高性能)而设计的众多方案中的两个例子。然而,除非另有说明,本章中其余的例子假设 CPU 通过数据总线与外部设备进行数据的读写。

12.3 I/O 机制

计算机系统用来与外设设备通信的三种基本 I/O 机制是:内存映射输入/输出、I/O 映射输入/输出和直接内存访问(DMA)。内存映射 I/O使用 CPU 内存地址空间中的普通位置与外设设备进行通信。I/O 映射输入/输出使用与内存分开的地址空间,并且通过特殊的机器指令在该 I/O 地址空间与外部世界之间传输数据。直接内存访问(DMA)是一种特殊的内存映射 I/O 形式,其中外设设备在没有 CPU 干预的情况下读取和写入位于内存中的数据。每种 I/O 机制都有其优点和缺点,我们将在本节中讨论。

通常,硬件系统设计师决定设备如何连接到计算机系统;程序员对这个决定几乎没有控制权。然而,通过关注用于 CPU 与外设之间通信的 I/O 机制的成本与效益,你可以选择最大化应用程序中 I/O 性能的代码序列。

12.3.1 内存映射 I/O

内存映射外设设备与 CPU 的地址和数据线连接的方式与常规内存相同,因此每当 CPU 写入或读取与外设相关联的地址时,CPU 都会将数据传输到设备或从设备传输数据。这种机制有几个优点,仅有少数几个缺点。

内存映射 I/O 子系统的主要优点是 CPU 可以使用任何访问内存的指令,如mov,在 CPU 与外设之间传输数据。例如,如果你正在尝试访问一个读/写或双向端口,你可以使用 80x86 的读/修改/写指令,如add,来读取端口、操作值,然后将数据写回端口,所有这些操作只需一个指令。当然,如果端口是只读或只写的,那么这样的指令就没有太大用处。

内存映射 I/O 设备的一个大缺点是它们会占用 CPU 内存映射中的地址空间。外设设备消耗的每一个地址字节,都会减少一个可用于安装实际内存的字节。通常,分配给外设(或一组相关外设)的最小空间是一个内存页(在 80x86 架构中为 4,096 字节)。幸运的是,一台典型的 PC 通常只有几十个这样的设备,因此这通常不是大问题。然而,对于一些外设设备(如显卡)来说,这可能会成为问题,因为它们会消耗大量地址空间。一些显卡拥有 1GB 到 32GB 的板载内存,并将其映射到内存地址空间中,这意味着这些显卡消耗的 1GB 到 32GB 地址范围无法作为常规内存被系统使用(尽管在 64 位处理器上,这几乎不会成为问题)。

I/O 与缓存

CPU 不能缓存用于内存映射 I/O 端口的值。从输入端口缓存数据意味着后续读取该端口时,会从缓存中访问值,而不是直接从端口读取数据,而端口的数据可能会发生变化。同样,在写回缓存机制下,某些写操作可能永远无法到达输出端口,因为 CPU 可能会先将多个写操作保存在缓存中,然后再将最后一个写操作发送到实际的 I/O 端口。为了避免这些潜在问题,我们需要一种机制来告诉 CPU 不要缓存对某些内存位置的访问。

解决方案在于 CPU 的内存管理子系统。例如,80x86 的页表项包含一个标志,CPU 可以使用该标志来确定是否可以将内存中的页面数据映射到缓存中。如果该标志设置为某个方式,缓存会正常工作;如果该标志设置为另一种方式,CPU 不会缓存对该页面的访问。

12.3.2 I/O 映射输入/输出

如前所述,I/O 映射输入/输出使用一个与正常内存空间分开的特殊 I/O 地址空间,并结合特殊的机器指令来访问设备地址。例如,80x86 CPU 提供了 inout 指令,专门用于这个目的。这些指令的行为类似于 mov,不同之处在于它们传输数据时使用的是特殊的 I/O 地址空间,而不是普通的内存地址空间。通常,提供 I/O 映射输入/输出功能的处理器使用相同的物理地址总线来传输内存地址和 I/O 设备地址。额外的控制线区分了属于正常内存空间的地址和属于特殊 I/O 地址空间的地址。这意味着这类 CPU 可以同时使用 I/O 映射输入/输出或内存映射 I/O。因此,如果 CPU 地址空间中的 I/O 映射位置数量不足,硬件设计师始终可以改用内存映射 I/O(就像典型 PC 上的显卡一样)。

在现代的 80x86 PC 系统中,使用 PCI 总线(或其后续变种)的系统主板上的特殊外设芯片将 I/O 地址空间重新映射到主内存空间,从而允许程序使用内存映射或 I/O 映射输入/输出访问 I/O 映射的设备。

12.3.3 直接内存访问

内存映射 I/O 子系统和 I/O 映射子系统都是程序化 I/O的一种形式,因为它们要求 CPU 在外设设备和内存之间移动数据。为了将从程序化 I/O 输入端口获取的 10 字节数据存储到内存中,CPU 必须从输入端口读取每个值并将其存储到内存中。

然而,通过 CPU 每次处理 1 字节(或字或双字)的数据可能对于非常高速的 I/O 设备来说过于缓慢。这些设备通常有一个接口连接到 CPU 的总线,这样它们可以直接读写内存——也就是说,无需 CPU 作为中介。直接内存访问(DMA)允许 I/O 操作与其他 CPU 操作并行进行,从而提高系统的整体速度——除非 CPU 和 DMA 设备同时试图使用地址和数据总线。只有当总线为空闲时,I/O 设备才能并行处理,这种情况发生在 CPU 拥有缓存并访问缓存的代码和数据时。尽管如此,即使 CPU 必须停止并等待 DMA 操作完成才能开始不同的操作,DMA 方法仍然要快得多,因为许多总线操作是指令获取或 I/O 端口访问,这些操作在 DMA 操作期间不会发生。

一个典型的 DMA 控制器由一对计数器和其他电路组成,电路与内存和外设设备进行接口。一个计数器作为地址寄存器,为每次传输提供地址总线上的地址。第二个计数器指定数据传输的数量。应用程序用应该开始传输数据的块的地址初始化 DMA 控制器的地址计数器。每当外设设备想要向内存传输数据或从内存传输数据时,它会向 DMA 控制器发送信号,后者将地址计数器的值放置在地址总线上。与 DMA 控制器协调,外设设备在输入操作期间将数据放置到数据总线上以写入内存,或者在输出操作期间从数据总线上读取内存中取出的数据。^(2) 在成功的数据传输后,DMA 控制器递增其地址寄存器并递减传输计数器。此过程会重复,直到传输计数器递减为零。

12.4 I/O 速度层级

不同的外设有不同的数据传输速率。一些设备,如键盘,与 CPU 的速度相比极为缓慢。其他设备,如固态硬盘驱动器,实际上可以比 CPU 处理数据的速度更快地传输数据。数据传输的适当编程技巧在很大程度上取决于参与 I/O 操作的外设的传输速度。因此,在讨论如何编写最合适的代码之前,我们应当建立一些术语,以描述外设的不同传输速率。

低速设备:数据产生或消耗速率远低于 CPU 可处理速率的设备。为了讨论的目的,我们假设低速设备的工作速度比 CPU 慢三倍或更多数量级。

中速设备:数据传输速率大致与 CPU 相同,或比 CPU 慢三个数量级的设备(使用程序化 I/O 访问设备)。

高速设备:数据传输速度超过 CPU 使用程序化 I/O 处理的设备。

外设的速度决定了 I/O 操作使用的 I/O 机制类型。显然,高速设备必须使用 DMA,因为程序化 I/O 太慢。中速和低速设备可以使用三种 I/O 机制中的任何一种进行数据传输(尽管低速设备由于额外硬件成本的原因很少使用 DMA)。

对于典型的总线架构,CPU 每微秒可以进行一次数据传输,甚至更快。因此,高速设备是指那些数据传输速度超过每微秒一次的设备。中速传输是指每 1 到 100 微秒进行一次数据传输的设备。低速设备通常每 100 微秒以上才传输一次数据。当然,低速、中速和高速设备的这些定义是依赖于系统的。更快的 CPU 和总线支持更快的中速操作。

请注意,每微秒一次的传输与每秒 1MB 的传输速率并不相同。外设实际上每次数据传输操作可以传输超过 1 字节的数据。例如,当使用 80x86 in(dx, eax); 指令时,外设可以在一次传输中传输 4 字节的数据。因此,如果设备能够每微秒进行一次传输,它可以通过此指令以每秒 4MB 的速度进行数据传输。

12.5 系统总线和数据传输速率

在第六章中,你看到 CPU 使用系统总线与内存和 I/O 设备进行通信。如果你曾经查看过计算机内部或阅读过系统规格,你可能见过诸如 PCIISAEISA 或甚至 NuBus 等术语,用来指代计算机的系统总线。在本节中,我们将讨论这些不同的计算机系统总线如何与 CPU 总线相关,以及它们如何影响系统的性能。

单一计算机系统通常使用多个总线。因此,软件工程师可以根据外设设备的总线连接选择使用哪些外设设备。为了最大化某一特定总线的性能,可能需要不同于其他总线的编程技术。尽管无法选择特定计算机系统所采用的总线,但软件工程师可以在可用的总线中进行选择,以提升应用程序的性能。

计算机系统总线,如 PCI(外设组件互连)和 ISA(工业标准架构),定义了计算机系统内部的物理连接器。具体来说,它们描述了一组电子信号(总线上的连接针脚)、物理尺寸(即连接器布局及其相互之间的距离)以及连接不同电子设备的数据传输协议。这些总线通常是 CPU 本地总线(地址、数据和控制线)的扩展,因为系统总线上的许多信号与 CPU 的信号是相同的。

然而,外设总线本身不一定与 CPU 的总线相同——它们可能比 CPU 上的信号多或少。例如,ISA 总线仅支持 24 条地址线,而英特尔和 AMD 的 x86-64 总线则支持 40 到 52 条地址线。

不同的外设设备被设计为使用不同的外设总线。图 12-6 显示了典型计算机系统中 PCI 和 ISA 总线的组织结构。^(3)

image

图 12-6:典型 PC 中 PCI 和 ISA 总线的连接

注意 CPU 的地址和数据总线如何连接到 PCI 总线控制器外设设备,但不直接连接到 PCI 总线本身。PCI 总线控制器包含两组针脚,提供一个 桥接,连接 CPU 的本地总线和 PCI 总线。当地总线上的信号线不会直接连接到 PCI 总线上的对应信号线;相反,PCI 总线控制器充当中介,重新路由所有 CPU 和 PCI 总线之间的数据传输请求。

请注意,ISA 总线控制器通常连接到 PCI 总线控制器,而不是直接连接到 CPU。这通常是出于成本或性能的考虑(例如,可能有一个限制,规定能够直接连接到 CPU 总线的设备数量,超出这个数量可能需要额外的缓冲)。

CPU 的本地总线通常以 CPU 频率的一部分运行。当前典型的本地总线频率为 66 MHz、100 MHz、133 MHz、400 MHz、533 MHz 和 800 MHz,但它们可能会变得更快。通常,只有内存和少数选定外设,如 PCI 总线控制器,才能连接到 CPU 的总线,并在这个高频率下运行。

因为典型的 CPU 总线宽度为 64 位,并且理论上每个时钟周期可以完成一次数据传输,所以 CPU 的总线最大数据传输速率为每秒 8 字节乘以时钟频率,例如对于 100 MHz 的总线为每秒 800MB。实际上,CPU 很少能够达到最大数据传输速率,但它们通常能达到某个百分比的速率,因此总线越快,数据在给定时间内进出 CPU(及缓存)的速度就越快。

12.5.1 PCI 总线的性能

PCI 总线有几种配置。基础配置有一个 32 位宽的数据总线,运行在 33 MHz。像 CPU 的本地总线一样,PCI 总线理论上能够在每个时钟周期传输数据。这意味着总线的理论最大数据传输速率为 4 字节乘以 33 MHz,即每秒 132MB。然而,实际上,PCI 总线除非在短暂的突发模式下,否则无法接近这一性能水平。更新版本的 PCI-e 提供多达 16 条“通道”,使数据传输速度更快(主要用于高性能显卡)。

每当 CPU 想要访问 PCI 总线上的外设时,它必须与其他外设协商以获得使用总线的权利。这种协商可能需要几个时钟周期,直到 PCI 控制器授予 CPU 访问总线的权限。如果 CPU 每次总线传输写入双字,协商时间实际上会大大降低传输速率。要接近总线的最大理论带宽,唯一的方法是使用 DMA 控制器并以突发模式传输数据。在突发模式下,DMA 控制器只进行一次协商,然后进行多次传输,在每次传输之间不释放总线。

PCI 总线有几个增强功能可以提高性能。一些 PCI 总线支持 64 位宽的数据路径。显然,这将最大理论数据传输速率从每次传输 4 字节提高到每次传输 8 字节。另一个增强功能是将总线运行在 66 MHz,这也将吞吐量翻倍。使用 64 位宽、66 MHz 的总线,数据传输速率将是基线配置的四倍。这些 PCI 总线的可选增强功能使其能够随着 CPU 性能的提升而增长。高性能版本的 PCI 总线——PCI-X 曾经使用一段时间,但它已经被 PCI-e 总线所取代。PCI-e 是一种串行总线,通过几条数据线串行传输数据。然而,它使用通道并行传输额外的数据。例如,16 通道的 PCI-e 总线比单通道版本快 16 倍。

12.5.2 ISA 总线的性能

ISA 总线是从原始的 PC/AT 计算机系统继承下来的。该总线宽 16 位,工作频率为 8 MHz。每个总线周期需要四个时钟周期(总线周期是指通过 ISA 总线传输一个 16 位数据字所需的时间)。由于这些原因,ISA 总线每微秒大约只能完成一次数据传输。使用 16 位宽的总线时,数据传输速率大约为每秒 2MB。这比 CPU 本地总线和 PCI 总线都要慢得多。通常,ISA 总线只能支持低速和中速设备——如 RS-232 通信设备、调制解调器或并行打印机接口——连接到 ISA 总线。大多数其他设备,如磁盘、扫描仪和网络卡,速度都超过了 ISA 总线的能力。

在大多数系统上访问 ISA 总线首先需要争取 PCI 总线的使用权,但因为 PCI 总线的速度远快于 ISA 总线,这种协商时间对 ISA 总线上外设的性能几乎没有影响。因此,将 ISA 控制器直接连接到 CPU 的本地总线并不会显著提高性能。

幸运的是,ISA 总线如今已经彻底过时,你在现代 PC 上是找不到它的。少数工业 PC 和 SBC(单板计算机)仍然支持 ISA 总线连接,以兼容遗留应用程序,但除此之外,ISA 总线已经不复存在。

12.5.3 AGP 总线

视频显示(即图形)卡是非常特殊的外设,它们需要极高的总线性能,以确保快速的屏幕更新和图形操作。不幸的是,如果 CPU 必须不断与其他外设争用 PCI 总线的使用权,图形性能可能会受到影响。为了解决这个问题,显卡设计师创造了加速图形端口(AGP),它是 CPU 本地总线与视频显示卡之间的接口,提供了多种控制线和总线协议,专门为视频显示卡设计。

AGP 连接允许 CPU 快速将数据传输到视频显示 RAM 并从中获取数据(见图 12-7)。

image

图 12-7:AGP 总线接口

因为每个系统只有一个 AGP 端口,所以一次只能有一张显卡使用 AGP 插槽。这样做的好处是系统无需与其他设备争用 AGP 总线的访问权限。然而,到了 2008 年,显卡的性能超过了 AGP 总线的性能。大多数现代显卡改用多通道 PCI-e 总线接口。

12.6 缓冲区

如果某个 I/O 设备生成或消耗数据的速度超过了系统能够从该设备传输数据的速度,系统设计者有两个选择:提供更快的连接以连接 CPU 和设备,或降低两者之间的数据传输速度。

如果外围设备连接到像 ISA 这样较慢的总线,系统设计师可以通过切换到更宽的总线(如 64 位 PCI)、更快的总线(具有更高频率的总线)或更高性能的总线(如 PCI-e)来创建更快的连接。系统设计师有时还可以创建更快的总线接口,正如他们在 AGP 连接中所做的那样。

另一种选择——减慢外围设备与计算机系统之间的传输速率——并不像最初看起来那么糟糕。大多数高速设备并不是以恒定的速率向系统传输数据。相反,它们通常会快速传输一块数据,然后闲置一段时间。虽然突发速率高于 CPU 或内存能够处理的速率,但平均数据传输速率通常较低。如果你能够平衡高带宽的峰值,并在外围设备空闲时传输一些数据,就可以轻松地在外围设备和计算机系统之间移动数据,而不需要依赖昂贵的高带宽总线或连接。

这个技巧是利用外围设备的内存来缓冲数据。外围设备在输入操作时可以快速将数据填充到缓冲区,而在输出操作时可以快速从缓冲区提取数据。一旦外围设备处于空闲状态,系统就以可持续的速度清空或重新填充缓冲区。只要外围设备的平均数据传输速率低于系统所支持的最大带宽,并且缓冲区足够大以容纳传入和传出的数据,这种方案就可以让外围设备以较低的平均数据传输速率与系统进行通信。

通常,为了节省成本,缓冲会发生在 CPU 的内存中,而不是外围设备上。在这种情况下,通常由软件工程师负责为外围设备初始化缓冲区。在某些情况下,外围设备或操作系统都不会为外围设备的数据提供缓冲区,因此应用程序必须提供缓冲区,以保持最大性能并避免数据丢失。在其他情况下,设备或操作系统可能提供一个小的缓冲区,但应用程序可能不会处理数据足够频繁,导致小缓冲区中的数据溢出;在这种情况下,应用程序可以创建一个更大的本地缓冲区。

12.7 握手

许多 I/O 设备不能接受任何速率的数据。例如,一个基于 i9 的 PC 每秒可以发送数亿个字符到打印机,但打印机无法每秒打印这么多字符。同样,一个输入设备(如键盘)永远不会每秒向系统传输数百万次按键(因为键盘的操作速度是人类速度,而不是计算机速度)。由于这些能力上的差异,CPU 需要某种方式来协调计算机系统与外围设备之间的数据传输。

一种常见的方法是通过一个与数据端口不同的端口发送和接收状态位。例如,打印机可以发送一个单独的位来告诉系统它是否准备好接收更多数据。同样,另一个端口中的单一状态位可以指示键盘数据端口是否有按键输入。CPU 可以在向打印机写入字符或从键盘读取键值之前测试这些位。

使用状态位指示设备是否准备好接收或传输数据被称为握手,因为这种协议类似于两个人通过握手表示达成一致。

以下 80x86 汇编语言程序段演示了握手的工作原理:

mov( $379, dx );      // Initialize DX with the address of the status port.

repeat

    in( dx, al );     // Get the parallel port status into the AL register.

    and( $80, al );   // Clear z flag if the HO bit is set.

until( @nz );         // Repeat until the HO bit contains a 1.

// Okay to write another byte to the printer data port here.

这段代码会持续循环,直到打印机状态寄存器(在输入端口$379处)的 HO 位为0,并且一旦 HO 位被设置(表示打印机已准备好接收数据),循环才会退出。

12.8 I/O 端口超时

上一节中repeat..until循环的一个问题是,它可能会无限旋转,等待打印机准备好接收更多输入。如果有人关闭了打印机或者打印机电缆断开,程序可能会冻结,永远等待打印机重新可用。通常,更好的做法是在出现问题时告知用户,而不是让系统挂起。为此,可以在循环中加入超时期限;一旦超过该期限,超时就会导致程序提示用户外设设备出现问题。

大多数外设设备在合理时间内都会有所响应。例如,即使在最坏的情况下,大多数打印机也会在上次传输后的几秒钟内准备好接收额外的字符数据。因此,如果超过 30 秒或更长时间,打印机没有接收新字符,可能存在问题。通常,编写的程序会暂停,提示用户检查打印机,之后用户确认问题解决后,程序会继续打印。

选择一个合适的超时期限并不是一件容易的事。你必须仔细平衡程序可能产生的虚假警报带来的烦恼与程序在出现实际问题时长时间卡住的痛苦。这两种情况同样令人恼火。

创建超时期限的一个简单方法是计算程序在等待外设的握手信号时循环的次数。考虑以下对上一节repeat..until循环的修改:

mov( $379, dx );          // Initialize DX with the address of the status port.

mov( 30_000_000, ecx );   // Timeout period of approximately 30 seconds,

                          // assuming port access time is about 1 microsecond.

HandshakeLoop:

    in( dx, al );         // Get the parallel port status into the AL register.

    and( $80, al );       // Clear z flag if the HO bit is set.

loopz HandshakeLoop;      // Decrement ECX and loop while ECX <> 0 and

                          // the HO bit of AL contains a 0.

if( ecx <> 0 ) then

    // Okay to write another byte to the printer data port here.

else

    // We had a timeout condition if we get here.

endif;

这段代码将在打印机准备好接受数据时退出,或者大约 30 秒后退出。你可能会对 30 秒的数字产生疑问,因为基于软件的循环(将 ECX 计数到 0)在不同处理器上运行的速度应该不同。然而,in()指令读取总线上的端口,这意味着该指令执行大约需要 1 微秒(I/O 端口通常会注入很多等待状态)。因此,循环执行一百万次大约需要 1 秒(上下浮动 50%,但对于我们的目的来说足够接近)。几乎无论 CPU 的频率如何,这一点都是成立的。

12.9 中断和轮询输入/输出

轮询是不断测试端口以查看数据是否可用的过程。前面章节中的握手循环提供了轮询的好例子——CPU 在一个短小的循环中等待,测试打印机端口的状态值,直到打印机准备好接受更多数据,然后 CPU 可以将更多数据传输到打印机。轮询输入/输出本质上是低效的。如果这个例子中的打印机需要 10 秒钟来接收另一个字节的数据,那么在这 10 秒钟内,CPU 什么都不做,完全浪费时间。

在早期的个人计算机系统中,程序的行为正是如此。当程序想要从键盘读取一个按键时,它会轮询键盘状态端口,直到有按键输入。这些早期的计算机在等待键盘时无法进行其他处理。

解决这个问题的方法是使用中断机制。中断是由外部硬件事件触发的,例如打印机准备好接收另一个字符,这会导致 CPU 中断当前的指令序列并调用一个特殊的中断服务程序(ISR)。通常,ISR 会执行以下一系列事件:

  1. 它保留了所有机器寄存器和标志的当前值,以便中断的计算可以稍后继续。

  2. 它执行所需的操作来服务该中断。

  3. 它恢复寄存器和标志为中断前的值。

  4. 它恢复被中断的代码的执行。

在大多数计算机系统中,典型的 I/O 设备在将数据提供给 CPU 时,或在能够接收来自 CPU 的数据时,都会产生一个中断。ISR 在后台快速处理中断请求,允许前台的其他计算继续正常进行。

虽然 ISR 通常由操作系统设计者或外围设备制造商编写,大多数操作系统允许通过信号或类似机制将中断传递给应用程序。这使得你可以直接在应用程序中包含 ISR。例如,你可以利用这个功能,当外围设备的内部缓冲区已满时,通知应用程序,并让应用程序将数据从外围设备的缓冲区复制到应用程序的缓冲区,以防止数据丢失。

12.10 受保护模式操作与设备驱动程序

如果你正在使用一个古老的 Windows 95 或 98 系统,你可以编写汇编代码直接访问 I/O 端口。前面提到的握手代码就是一个很好的例子。然而,现代版本的 Windows 以及所有版本的 Linux 和 macOS 都采用了受保护模式操作。在这种模式下,只有操作系统和某些特权程序可以直接访问设备。标准应用程序,即使是用汇编语言编写的,也没有这种特权。如果你编写一个简单的程序,试图将数据发送到 I/O 端口,系统会产生一个非法访问异常并停止你的程序。

Linux 不允许任何程序访问 I/O 端口,只有拥有“超级用户”(root)权限的程序才能这样做。对于有限的 I/O 访问,可以使用 Linux 的 ioperm 系统调用,使某些 I/O 端口对用户应用程序可用。(有关更多详细信息,请阅读 ioperm 的 man 页面。)

如果 Linux、macOS 和 Windows 不允许直接访问外部设备,那么程序是如何与这些设备进行通信的呢?显然,这是可以做到的,因为应用程序一直在与现实世界的设备互动。答案是,这些操作系统允许特别编写的模块,即设备驱动程序,来访问 I/O 端口。编写设备驱动程序的完整讨论远远超出了本书的范围,但了解它们的工作原理有助于你理解在受保护模式操作系统下 I/O 的可能性和局限性。

12.10.1 设备驱动模型

设备驱动程序是一种特殊类型的程序,它与操作系统相连接。它必须遵循一些特定的协议,并且必须调用操作系统的某些特殊接口,这些接口是标准应用程序无法使用的。此外,为了在系统中安装设备驱动程序,你必须拥有管理员权限,因为设备驱动程序可能带来各种安全性和资源分配风险,你不能让系统变得脆弱。因此,安装过程不是一件简单的事情,应用程序也不能随意加载或卸载驱动程序。

幸运的是,典型 PC 上的设备数量是有限的,因此你只需要有限数量的设备驱动程序。你通常会在安装设备的同时在操作系统中安装设备驱动程序,或者如果设备内置于 PC 中,则在安装操作系统时一并安装。实际上,只有在自己构建设备时,或者在某些特殊情况下,你需要利用某些标准设备驱动程序无法处理的设备功能时,才需要编写自己的设备驱动程序。

设备驱动程序模型非常适用于低速设备,在这种情况下,操作系统和设备驱动程序能够比设备所需的速度更快地响应设备。该模型对于中高速设备也非常适用,尤其是在系统需要传输大量数据块到设备与设备之间时。然而,设备驱动程序模型也有一些缺点,其中之一是它不支持需要设备与应用程序之间进行大量交互的中高速数据传输。

问题在于,调用操作系统是一个昂贵的过程。每当应用程序调用操作系统将数据传输到设备时,可能需要几百微秒,甚至几毫秒,设备驱动程序才能实际看到应用程序的数据。如果设备与应用程序之间的交互需要持续不断的数据传输,那么如果每次传输都必须通过操作系统,就会产生很大的延迟。对于这类应用程序,你需要编写一个特殊的设备驱动程序,能够自行处理交易,而不是不断地返回给应用程序。

因为应用程序不能直接访问设备(在现代操作系统中),它们之间的所有通信必须通过设备驱动程序作为中介进行。那么,问题是,应用程序如何与设备驱动程序进行通信?

12.10.2 与设备驱动程序的通信

在大多数情况下,现代操作系统下与外部设备的通信就像向文件写入数据或从文件读取数据一样。在大多数操作系统中,你通过使用特殊的文件名如COM1(串行端口)或LPT1(并行端口)来打开一个“文件”,操作系统会自动与指定的设备建立连接。当你使用完设备时,你需要“关闭”关联的文件,这告诉操作系统应用程序已经完成对该设备的使用,其他应用程序可以使用该设备。

当然,大多数设备并不支持与磁盘文件相同的语义。有些设备,如打印机或调制解调器,可以接受一长串未格式化的数据流,但其他设备可能需要你将数据预先格式化为块,并通过单次写操作将这些块写入设备。具体的语义取决于特定的设备。不过,向外部设备发送数据的典型方法是使用操作系统的“写”函数,向该函数传递一个包含一些数据的缓冲区,而从设备读取数据的方法是调用操作系统的“读”函数,向该函数传递一个缓冲区的地址,操作系统将数据写入该缓冲区。

但是并非所有设备都符合这些流式 I/O文件 I/O 的数据语义。因此,大多数操作系统提供了一个设备控制 API,允许你将信息直接传递给外部设备的设备驱动程序,以处理流式 I/O 模型失败的情况。

由于各个操作系统不同,关于操作系统 API 接口的具体细节超出了本书的范围。尽管大多数操作系统使用类似的方案,但它们的差异足以使得无法用一般方式描述它们。因此,欲了解更多详情,请参考你特定操作系统的程序员参考手册。

12.11 更多信息

Silberschatz, Abraham, Peter Baer Galvin, 和 Greg Gagne. “第十三章: 输入/输出系统.” 载于 操作系统概念. 第 8 版. 霍博肯, NJ: 约翰·威利与儿子出版社, 2009 年。

注意

Patterson 和 Hennessy 的早期版本《计算机架构:定量方法提供了关于 I/O 设备和总线的良好章节;可惜的是,由于它涵盖了非常旧的外围设备,作者决定删除该章节,而不是在后续版本中更新它。互联网搜索似乎是你能找到关于这个主题一致信息的最后地方(当然,除了这本书)。

第十三章:计算机外围总线**

Image

系统总线并不是计算机系统中唯一的总线。还有许多专用的外围总线。本章讨论了 SCSI、IDE/ATA、SATA、SAS、FibreChannel、Firewire 和 USB 总线,这些总线连接了计算机和各种外围设备。

13.1 小型计算机系统接口

小型计算机系统接口(* SCSI *,发音为“scuzzy”)是一种外围设备互连总线,用于将高速外围设备连接到个人计算机系统。SCSI 总线于 1980 年代初期设计,并在 1980 年代中期随着 Apple Macintosh 计算机系统的推出而广泛流行。最初的 SCSI 总线支持 8 位双向数据总线,并且能够每秒传输 5MB 的数据,这在当时的硬盘子系统中被视为高性能。尽管按现代标准来看其早期性能相当慢,但 SCSI 经过多次修订,依然是一个高性能的外围设备互连系统。在其最盛大的时候,这些老式的 SCSI 设备能够达到每秒传输 320MB(兆字节)的速度。

尽管 SCSI(小型计算机系统接口)互连系统最常用于磁盘驱动子系统,但 SCSI 的设计初衷是支持通过电缆连接的各种 PC 外围设备。事实上,随着 SCSI 在 1980 年代末期和 1990 年代的普及,你可以看到打印机、扫描仪、影像设备、摄影排版机、网络和显示适配器以及许多其他设备都与 SCSI 总线连接。

然而,随着 USB、FireWire 和 Thunderbolt 外围连接系统的出现,SCSI 作为通用外围总线的流行度已经下降。除了一些非常高性能的磁盘驱动子系统和一些非常专业化的外围设备外,很少有新的外围设备使用这一接口。为了理解为什么 SCSI 的流行度下降,我们来看一下 SCSI 用户多年来面临的问题。

13.1.1 限制

当 SCSI 最初推出时,SCSI 总线支持 SCSI 适配器卡和最多七个外围设备的并行连接。为了连接多个设备,你需要从主机控制卡引出一根电缆连接到第一个外围设备。要连接第二个设备,你需要从第一个设备的第二个连接器引出一根电缆连接到第二个设备。要连接第三个设备,你需要从第二个设备的独立连接器引出电缆连接到第三个设备,依此类推。在这个设备的“菊花链”末端,你需要在最后一个外围设备的最后一个连接器上连接一个特殊的终端设备。如果没有在 SCSI 链的末端加上特殊的“终端器”,许多 SCSI 系统将无法正常工作,甚至无法工作。

作为一种“便利”,许多外设制造商将终端电路设计集成到他们的设备中。不幸的是,在 SCSI 链中间连接多个终端器就像根本没有终端器一样糟糕。尽管许多设计了终端电路的制造商通常提供禁用终端器的选项,但有些并没有。确保这些具有主动终端电路的设备位于 SCSI 链的末端往往很麻烦,即便某些设备提供了启用或禁用终端器的选项,如果文档不在手边,了解适当的 DIP 开关设置也是一大挑战。因此,许多计算机用户在他们的系统中遇到了 SCSI 设备链无法正常工作的情况。

在原始的 SCSI 总线上,计算机系统所有者必须为每个设备分配一个 0 到 7 的八个数字“地址”,其中地址 7 通常保留给主机控制卡。如果 SCSI 链中的两个设备有相同的地址,它们将无法正常工作。这使得将 SCSI 外设从一台计算机系统迁移到另一台系统时变得有些困难,因为被移动的设备的地址通常已被新系统中的另一个设备占用。

原始的 SCSI 总线也有其他限制。首先,它仅支持七个外设设备。当 SCSI 最初设计时,这通常不是问题,因为像硬盘和扫描仪这样的常见 SCSI 外设非常昂贵,每个售价数千美元。当时,连接超过七个设备并不是普通计算机用户的做法。然而,随着硬盘和其他 SCSI 外设价格的下降,七个外设的限制变得非常繁琐。

其次,SCSI 并不是热插拔的;也就是说,在通电状态下,你无法拔出或连接外部设备。这样做可能会导致 SCSI 控制器、外部设备,甚至 SCSI 总线上的其他外设受到电气损坏。随着 SCSI 外设变得更加经济实惠,人们开始将多个设备连接到计算机系统中,因此希望能够将一个设备从一个系统中拔出并插入到另一个系统中的需求也随之增加,但 SCSI 并不支持这一功能。

13.1.2 改进

尽管存在这些缺点,SCSI 的受欢迎程度依然不断增长。为了保持这种受欢迎程度,SCSI 随时间进行了修改,以提升其功能。第一版修改是 SCSI-2,它将速度从 5 MHz 提高到 10 MHz,从而使得总线的数据传输速率翻倍。这是必要的,因为像磁盘驱动器等高性能设备的速度提升迅速,原始的 SCSI 实际上使它们变慢。接下来,将双向 SCSI 数据总线的大小从 8 位扩展到 16 位,不仅将数据传输速率从 10MBps 提升到 20MBps,还增加了可以连接到总线的外设数量,从 7 个增加到 15 个。SCSI-2 的变体被称为快速 SCSI(10 MHz)、宽 SCSI(16 位)和快速宽 SCSI(16 位,10 MHz)。

不足为奇的是,SCSI-3 紧随 SCSI-2 之后。SCSI-3 提供了各种不同的连接选项,同时保持与旧标准的兼容性。虽然 SCSI-3(使用如 Ultra、Ultra-Wide、Ultra2、Wide Ultra2、Ultra3 和 Ultra320 等名称)仍然以并行电缆模式作为 16 位总线操作,并且仍然支持最多 15 个外设,但它大幅提高了总线的操作速度以及 SCSI 外设可连接的最大物理距离。简而言之,SCSI-3 以高达 160 MHz 的速度运行,使得 SCSI 总线能够以高达 320MBps 的速率进行数据传输(也就是说,比许多 PCI 总线互连还要快!)。

SCSI 最初是一个并行接口。如今,它支持四种不同的互连标准:SCSI 并行接口(SPI)、通过 FireWire 的串行 SCSI、光纤通道仲裁环路以及串行附加 SCSI(SAS)。SPI 是大多数人所熟知的原始标准,通常与 SCSI 联系在一起。SCSI 并行电缆包含 8 或 16 条数据线,具体取决于所使用的 SCSI 接口类型。这使得 SCSI 电缆笨重、沉重且昂贵。并行 SCSI 接口还限制了系统中 SCSI 链的最大长度,仅为几米。这些问题,特别是经济因素,是现代计算机系统仅在需要极高性能时才使用 SCSI 外设的原因。

请注意,计算机系统并不拥有 SCSI 总线,也不一定会直接控制总线上各种外设之间的通信。SCSI 是一个真正的对等总线,任何两个外设都可以相互通信。事实上,两个计算机系统共享同一条 SCSI 总线是可能的(尽管不常见)。

这种对等操作能够极大地提高整体系统的性能。为了说明这一点,考虑一个磁带备份系统。在实际应用中,大多数磁带备份程序将一个数据块从磁盘驱动器读取到计算机内存中,然后将该数据块从计算机内存写入磁带驱动器。理论上,在 SCSI 总线上,磁带驱动器和磁盘驱动器可以直接相互通信。磁带备份软件会发送两个命令,一个给磁盘驱动器,一个给磁带驱动器,指示磁盘驱动器将数据块直接传输到磁带驱动器,而不是通过计算机系统。这样不仅减少了 SCSI 总线上传输的次数,加快了传输速度,还释放了计算机 CPU 做其他事情。尽管实际上,少数磁带备份系统是这样工作的,但也有许多例子显示,两个外设可以在不通过计算机作为中介的情况下直接在 SCSI 总线上进行通信。将 SCSI 外设编程以这种方式操作(而不是通过计算机内存传输数据)是优秀编程的一个典型例子。

13.1.3 SCSI 协议

SCSI 不仅仅是一个电气连接,还是一个协议。你不会仅仅通过向 SCSI 接口卡上的几个寄存器写入一些数据,再将数据通过 SCSI 电缆发送到外部设备来与 SCSI 外设通信。相反,你会在内存中构建一个数据结构,其中包含 SCSI 命令、命令参数、你想发送到 SCSI 外设的任何数据,以及可能包含外设返回的数据存储位置的指针。一旦构建了这个数据结构,你通常会提供数据结构的地址给 SCSI 控制器,然后 SCSI 控制器从系统内存中获取命令,并将其发送到 SCSI 总线上适当的外设。

13.1.3.1 SCSI 命令集

随着 SCSI 硬件多年来的发展,SCSI 协议也不断演进——

或者是 SCSI 命令集。SCSI 最初并不是为了仅仅作为硬盘接口而设计,随着新型计算机外设的出现,它所支持的外设种类也在不断增加。为了适应这些新的、意料之外的 SCSI 总线用途,SCSI 的设计者创建了一个与设备无关的命令协议,使其能够在新设备发明时轻松扩展。与此相比,某些设备接口,如最初的集成磁盘电子技术(IDE)接口,仅适用于硬盘驱动器。

SCSI 协议传输一个包含外设地址、命令和命令数据的数据包。SCSI-3 标准大致将这些命令分为以下几类:

SCSI 控制器命令(SCC) RAID 阵列的控制命令

SCSI 外壳服务(SES)命令 外壳服务命令

SCSI 图形命令(SGC) 打印机的图形命令

SCSI 块命令(SBC) 硬盘接口命令

管理服务器命令(MSC) 用于 SCSI 协议之间转换的命令

多媒体命令(MMC) 用于 DVD 驱动器等设备的多媒体命令

基于对象的存储设备(OSD)命令 用于管理对象的分配、放置和访问的命令

SCSI 主命令(SPC) 主命令

简化块命令(RBC) 用于简化硬盘子系统的命令

SCSI 流命令(SSC) 用于磁带驱动器的流命令

尽管 SCSI 命令本身是标准化的,但实际的 SCSI 主机控制器接口却不是。不同的主机控制器制造商使用不同的硬件将其 SCSI 控制器芯片连接到主机计算机系统,因此与 SCSI 控制器芯片的交互方式取决于特定的主机控制器设备。由于 SCSI 控制器是非常复杂且难以编程的,而且没有“标准”的 SCSI 接口芯片,程序员面临着必须编写多个不同版本的软件来控制 SCSI 设备的问题。

13.1.3.2 SCSI 设备驱动程序

为了纠正这种情况,像 Adaptec 这样的 SCSI 主机控制器制造商创建了专门的设备驱动模块,提供一个统一的接口给他们的设备。程序员不再直接向 SCSI 芯片写数据,而是创建一个内存中的数据结构,包含要放在 SCSI 总线上的 SCSI 命令,调用设备驱动程序软件,让设备驱动程序将 SCSI 命令传输到 SCSI 总线。这样做有几个好处:

  • 它使程序员不必了解每个特定主机控制器的复杂性。

  • 它允许不同制造商为其 SCSI 控制器设备提供兼容的接口。

  • 它允许制造商创建一个单一的优化驱动程序,正确支持其设备的能力,而不是促使个别程序员为该设备编写(可能是平庸的)代码。

  • 它允许制造商在不破坏与现有软件兼容性的情况下,改变其设备未来版本的硬件。

这一概念被引入了现代操作系统。今天,SCSI 主机控制器制造商为 Windows 等操作系统编写 SCSI miniport 驱动程序。这些 miniport 驱动程序为主机控制器提供了一个硬件无关的接口,使得操作系统可以简单地说:“这是一个 SCSI 命令,把它放到 SCSI 总线上。”

13.1.4 SCSI 优势

SCSI 接口的一个大优点是它提供了并行处理 SCSI 命令的功能。也就是说,主机系统可以将多个不同的 SCSI 命令放到总线上,多个外设可以同时处理这些命令。一些设备,如磁盘驱动器,甚至可以同时接受多个命令并按最有效的顺序处理它们。例如,假设一个磁盘驱动器当前接近 1,000 块。如果系统发送的块读取请求是块 5,000、4,560、3,000 和 8,000,磁盘控制器可以重新排列这些请求,并按最有效的顺序处理它们(可能是 3,000、4,560、5,000,然后是 8,000),在磁头穿过磁盘表面时完成读取操作。这会大大提高在多任务操作系统中处理来自多个不同应用程序的磁盘 I/O 请求的性能。

SCSI 也是 RAID 系统的理想接口,因为 SCSI 是为数不多的支持在同一接口上连接大量驱动器的磁盘控制器接口之一。

原始的 SPI(并行 SCSI)几乎已经消失。即便是通过 FireWire 的 SCSI 也几乎不存在了(FireWire 本身也是如此)。然而,今天 SCSI 仍然以 SAS(串行附加 SCSI)的形式存在。高性能硬盘驱动器使用 SAS 命令集(而非标准的 SATA 命令集)。目前性能最强的 RAID 系统仍然是基于 SAS 硬盘构建的。

SCSI 命令集非常强大,专为高性能应用设计。它足够庞大且复杂,空间限制使得无法在此详细介绍。有兴趣深入了解 SCSI 编程的读者应该参考Gary Field、Peter M. Ridge 等人编著的《SCSI 书籍》(第 2 版,No Starch Press,2000 年)。完整的 SCSI 规范可以在网上的多个网站上找到。快速搜索“ SCSI 规范”应该能找到多个副本。

13.2 IDE/ATA 接口

尽管 SCSI 性能非常高,但它也很昂贵。一个 SCSI 设备需要一个复杂且快速的处理器来处理 SCSI 总线上所有可能的操作。此外,由于 SCSI 设备可以在对等基础上运行(也就是说,一个外设可以在没有主机计算机系统干预的情况下与另一个外设通信),每个 SCSI 设备必须在其控制器板上的 ROM 中携带大量复杂的软件。如果仅仅是为了连接一块硬盘到个人计算机系统,而需要添加支持完整 SCSI 的所有额外功能,显然是过度设计。集成驱动电子学(IDE)接口的出现,旨在提供一种简化的、低成本的大容量存储选项。

IDE 接口的设计理念是通过利用主机计算机的 CPU 来处理数据,从而降低磁盘驱动器的成本(SCSI 使用嵌入式 CPU 处理大部分工作)。由于 PC 的 CPU 通常在 SCSI 传输过程中是空闲的,这似乎是资源的一个好利用。IDE 驱动器由于比 SCSI 驱动器便宜数百美元,因此在 PC 系统中变得非常流行。IDE 接口和 IDE 驱动器的成本远低于 SCSI,确保了其受欢迎程度。

由于原始的 IDE 规范专门针对硬盘驱动器设计,并不特别适合其他类型的存储设备,因此设计 IDE 接口的委员会重新开始工作,开发了带数据包接口的高级技术附加(ATAPI),通常简称为ATA。像 SCSI 一样,ATA 标准在过去的几年中经过了多次修订和改进。ATAPI 规范(截至 2013 年第八版)扩展了 IDE,以支持包括磁带驱动器、Zip 驱动器、CD-ROM、DVD、可移动磁带驱动器等在内的各种大容量存储设备。为了扩展 IDE 接口以支持所有这些不同的存储设备,ATAPI 的设计者采用了一种数据包命令格式,这种格式在某些情况下与 SCSI 数据包命令格式非常相似,甚至相同。

然而,在现代受保护模式操作系统(如 Windows 或 Linux)中,应用程序员通常不允许直接与硬件进行通信。理论上,可以为 IDE 编写一个迷你端口驱动程序,模拟 SCSI 的工作方式。然而,在实际操作中,操作系统供应商通常会提供一个软件库,提供应用程序编程接口(API),用于与 IDE/ATAPI 设备进行交互。然后,应用程序员可以调用 API 函数,传递适当的参数,底层的库函数会处理与硬件直接通信的其余任务。

在现代系统中编程 ATAPI 设备与编程 SCSI 设备非常相似。你需要加载一个基于内存的数据结构,其中包含一个命令码和一组参数,然后将这个内存结构传递给驱动程序库函数,该函数将数据传递到 ATAPI,并最终到达目标存储设备。如果没有这样的低级库,并且你的操作系统允许,你可以编程控制 ATAPI 设备来获取这些数据(现代系统通常使用 DMA)。

完整的 ATAPI 规范几乎有 500 页长,因此我们这里没有足够的空间来全面介绍。如果你有兴趣更详细地了解 IDE/ATAPI,可以在线搜索“ATAPI 规范”。

现代机器使用串行 ATA(SATA)控制器。这是经典 IDE/ATAPI 并行接口的高性能串行版本。然而,对程序员而言,它看起来与 ATAPI 完全相同。

13.2.1 SATA 接口

随着时间的推移,硬盘的速度逐渐足够快,以至于 IDE/ATA 接口开始限制驱动器性能。串行 AT 附件 (SATA) 以及后来出现的 SATA-II 和 SATA-III,相比于并行 IDE/ATA(通常缩写为 PATA,即“并行 ATA”),提供了几个优势。PATA 的传输速度最大为 133MBps,而 SATA-I、II 和 III 分别支持 1.5Gbps(即 150MBps)、3.0Gbps(即 300MBps)和 6.0Gbps(即 600MBps)的数据传输速率,尽管很少有(RAID)系统能接近达到这些数据传输速率。与 PATA 相比,SATA 还具有其他优势,包括更小的电缆(7 根导线,而不是 40 或 80 根)和热插拔功能。今天,大多数连接到 PC 的硬盘驱动器都使用 SATA 接口(而其他大部分则使用 SAS 接口,这实际上是通过 SATA 传输的 SCSI,或 Fibre Channel 接口)。

13.2.2 Fibre Channel

Fibre Channel 是一种非常高性能的传输机制(最高可达 128Gbps)。虽然它是一个通用的网络协议,适用于大型主机计算机,但它的主要用途之一是将高性能的磁盘阵列连接到计算机系统(通常是服务器)。对于磁盘驱动器的使用,Fibre Channel 通过 Fibre Channel 电缆传输 SCSI 命令。因此,1980 年代的 SCSI 接口在今天的 Fibre Channel 中得以延续,依然是最高性能的磁盘接口协议。

13.3 通用串行总线

通用串行总线 (USB) 是一种机制,允许你通过一个接口将各种外设连接到 PC,类似于 SCSI。USB 支持 热插拔设备,意味着你可以在不关闭电源或重启计算机的情况下插拔设备,它还支持 即插即用设备,这意味着一旦插入设备,操作系统会自动加载设备驱动程序(如果有的话)。然而,这种灵活性是有代价的。与串行或并行端口的编程相比,USB 设备的编程要复杂得多。你不能通过读取或写入少量设备寄存器与 USB 外设进行通信。

13.3.1 USB 设计

为了理解 USB 背后的动机,可以考虑当 Windows 95 首次发布时,PC 用户所面临的情况,那时距 IBM PC 的推出已经近 14 年。IBM 设计其 PC 时,采用了多种外设连接方式,这些连接方式在 1970 年代后期的个人计算机和小型计算机中都很常见。然而,IBM 的设计人员并未预见(或考虑到)人们在接下来的几十年中会发明出如此多种类的外设来连接到 PC。它们也没有预计任何个别 PC 用户会将超过几个外设连接到他们的计算机上。当然,三条并行端口、四条串行端口和一个硬盘驱动器应该足够了!

当 Windows 95 推出时,人们已经开始将他们的 PC 连接到各种各样的设备,包括声卡、视频数字化器、数码相机、先进的游戏设备、扫描仪、电话、鼠标、数位板、SCSI 设备以及数百种原始 PC 设计者未曾想象过的其他设备。这些设备的创建者通过将硬件与 PC 进行连接,使用了原本为其他设备设计的外设 I/O 端口地址、中断和 DMA 通道。这样做的问题是端口地址、中断和 DMA 通道的数量有限,而大量设备却在争夺它们。为了绕过这一问题,设备制造商在他们的卡上添加了“跳线”,使购买者可以从一小部分不同的端口地址、中断和 DMA 通道中进行选择,以避免与其他设备的冲突。

创建一个无冲突的系统是一个复杂的过程,而且在某些外设组合的情况下是无法实现的。事实上,在这一时期,Apple Macintosh 的一个重要卖点就是你可以轻松连接多个外设设备,而无需担心设备冲突。所需要的是一种新的外设连接系统,它能在不产生冲突的情况下支持大量设备。USB 就是这个答案。

USB 通过使用 7 位地址,允许最多同时连接 127 个设备。USB 保留了第 128 个槽位,即地址 0,用于自动配置目的。在现实中,几乎不可能将这么多设备成功连接到单一 PC,但可以知道 USB 具有相当大的增长潜力,这一点与原始的 PC 不同。

尽管名称中有“总线”一词,但 USB 并不是一种真正的“总线”,因为它并不允许多个设备彼此通信。相反,USB 是一种控制器/外设连接,其中 PC 始终充当控制器。这意味着,例如,数字相机不能直接通过 USB 与打印机通信。为了将信息从相机传输到打印机,这两个设备都需要连接到 PC,相机必须先将数据发送到 PC,然后 PC 再将数据传递给打印机。PCIe、ISA、FireWire(IEEE 1394)和 Thunderbolt 总线允许两个设备进行点对点通信(即独立于主机的 CPU),但 USB 并没有设计成支持这种通信方式(为了降低外设和其包含的 USB 接口芯片的成本)。^(1)

USB 还通过将尽可能多的复杂性转移到主机(PC)端来降低外设成本。这里的思路是,PC 的 CPU 提供的性能远高于大多数 USB 外设中使用的低成本微控制器。这意味着为 USB 外设编写嵌入式软件的工作量并不会比使用其他接口更多。另一方面,在主机端编写 USB 软件则非常复杂——复杂到实际上不可能期望程序员能够完成。

相反,操作系统供应商必须提供一个 USB 主机控制器堆栈,使其能够与 USB 设备进行通信,大多数应用程序开发人员通过操作系统的设备驱动接口与这些设备进行交互。即使是需要为特定设备编写自定义 USB 设备驱动程序的开发人员,也不会直接与 USB 硬件进行交互。相反,他们会通过操作系统调用 USB 主机控制器堆栈,向其请求特定设备的服务。由于典型的 USB 主机控制器堆栈通常包含大约 20,000 到 50,000 行 C 代码,并且需要几年时间的开发,因此在没有原生 USB 堆栈(如 MS-DOS)的系统上编写 USB 设备程序的机会非常小。

13.3.2 USB 性能

初始的 USB 设计支持两种不同类型的外设——

慢速和快速——以支持不同价格区间的设备。慢速设备在 USB 上传输速度最高可达 1.5Mbps(每秒百万比特),而快速设备则能够达到 12Mbps(1.5MBps)的传输速度。成本敏感型设备可以以低成本制造为低速设备。非成本敏感型设备则可以使用 12Mbps 的数据传输速率。

USB 2.0 规范增加了一个高速模式,支持最高 480Mbps 的数据传输速率(60MBps),但这也带来了额外的复杂性和成本。USB 3.0 提高了性能,达到了 635MBps(超级速度)。最后,USB 3.1 和 USB-C(Thunderbolt 3)接口分别将速度提升到了 5GBps(每秒千兆字节;超级速度)、10GBps(超级速度+)和 40GBps。预计 USB 4.0 能够达到 80GBps 的传输速度。

USB 不会将整个可用带宽分配给一个外设,而是主控制器堆栈对 USB 上的数据进行复用,有效地给每个外设一个总线的“时间片”。USB 使用 1 毫秒时钟。在每个毫秒周期的开始,USB 主控制器开始一个新的 USB ,在一个帧内,每个外设可以发送或接收一个数据包。数据包的大小会有所不同,取决于设备的速度和传输时间,但通常包含 4 到 64 字节的数据。如果你在四个外设之间以相等的速率传输数据,通常会期望 USB 堆栈以轮询的方式在主机和每个外设之间传输一个数据包,首先处理第一个外设,其次是第二个外设,依此类推。就像多任务操作系统中的时间切片一样,这种数据传输机制使得看起来像是主机和每个 USB 外设之间并发地传输数据,尽管实际上每次只能有一个传输在 USB 上进行。

尽管 USB 提供了一个非常灵活和可扩展的系统,但由于总线上共享带宽,可能会导致设备的速度变慢。例如,如果你将两个硬盘驱动器连接到 USB 并同时访问这两个驱动器,这两个驱动器必须共享 USB 上的可用带宽。对于 USB 1.x 设备,这会导致明显的速度下降。对于 USB 2.x 设备,可用带宽足够高(通常高于两个硬盘驱动器所能支持的带宽),因此你不会注意到性能下降。对于 USB 3.x(及更高版本)和 USB-C,性能与许多原生总线控制器相当。(例如,Thunderbolt-3/USB-C 提供了一个传输机制,用于 PCI 总线和 SCSI。)理论上,你可以使用多个主控制器来提供多个 USB 总线系统(每个总线提供完整的带宽),但这仅仅解决了性能问题的一部分。

另一个性能考虑因素是 USB 主控制器堆栈的开销。尽管 USB 1.x 硬件可能支持 12Mbps 带宽,但存在一些空闲时间——也就是没有数据传输的时间——因为主控制器堆栈需要一些时间来设置数据传输。在一些 USB 系统中,由于主控制器堆栈占用了大量的 CPU 时间来设置传输和移动数据,最多只能达到理论 USB 带宽的一半。在一些使用较慢处理器(如 486、StrongArm 或 MIPS)运行嵌入式 USB 1.x 主控制器设备的嵌入式系统中,这可能是一个实际问题。

如果某个主机控制器堆栈无法维持完整的 USB 带宽,通常意味着 CPU 处理 USB 信息的速度赶不上 USB 产生信息的速度,因为 CPU 的处理能力已经饱和——并且没有时间进行其他计算。请记住,USB 将所有复杂的计算留给主机控制器来处理,并且在主机上执行 USB 堆栈中的代码需要占用 CPU 周期。主机控制器可能会因为处理 USB 流量而过度占用资源,以至于导致非 USB 流量的整体系统性能下降。

幸运的是,在配备 USB 2.x控制器的 PC 上,主机控制器只消耗了 USB 带宽的一小部分。当 USB-3 和 USB-C 出现时,USB 硬件开始支持其他传输协议,如 SCSI 和 PCI,从而解决了许多与 USB 相关的性能问题。

13.3.3 USB 传输类型

USB 协议支持四种不同类型的数据传输:控制传输、批量传输、中断传输和等时传输。数据传输机制由外设制造商决定,而不是由应用程序员决定,即,如果一个设备使用等时数据传输模式与主机 PC 通信,程序员无法决定改为使用批量传输。应用程序可能甚至不会意识到底层的传输方案,只要软件能够处理设备产生或消耗数据的速率。

USB 通常使用控制传输来初始化外设设备,通过读取和写入数据到外设的寄存器。例如,如果你有一个 USB 转串口转换器设备,你通常会使用控制传输来设置波特率、数据位数、奇偶校验位、停止位数等,就像你将数据存储到 8250SCC 寄存器集一样。^(2) USB 保证控制传输的正确传递,并且保证至少 10%的 USB 带宽用于控制传输,以防止饥饿现象,即某个特定的传输永远不会发生,因为某些优先级更高的传输始终在进行。

USB 的大容量传输用于在主机和外设之间传输大量数据块。大容量传输仅在全速(12Mbps)、高速(480Mbps)和超高速(USB 3/USB-C)设备上可用,低速设备不支持此功能。在全速设备上,大容量传输通常每个数据包传输 4 到 64 字节的数据;在高速和超高速设备上,你可以每个数据包传输最多 1,023 字节的数据。USB 保证大容量数据包在主机和外设之间的正确传输,但不保证及时传输。如果 USB 正在处理大量其他传输,完成大容量传输可能需要一些时间。理论上,如果 USB 在正确组合的等时、interrupt 和控制传输下非常繁忙,大容量传输可能永远不会发生。然而,在实际操作中,大多数 USB 堆栈确实会为大容量传输预留一小部分带宽(通常约为 2 到 2.5%),以防止带宽“饿死”。

USB 旨在通过设备传输大量数据,这些设备需要正确地传输数据,但不一定要求快速。例如,当你将数据传输到打印机或在计算机和磁盘驱动器之间传输数据时,正确的传输远比及时传输更为重要。当然,等待似乎无尽的时间来保存文件到 USB 磁盘驱动器可能会让人觉得烦人,但比起将错误数据写入磁盘文件,缓慢操作要好得多。

对于那些既需要正确数据传输又要求及时交付的设备,USB 使用中断传输。尽管名称中有“中断”二字,但中断传输并不涉及计算机系统中的中断。相反,USB 协议将中断传输标记为高优先级事件。主机会轮询 USB 上的所有设备,但当设备有数据可用时,不会中断主机。使用中断传输类型的外设可以请求主机轮询它的频率,选择从 1 毫秒到 255 毫秒的间隔。^(3)

为了保证中断传输在主机和外设之间的正确和及时交付,USB 主机控制器堆栈必须在每次应用程序打开设备进行中断传输时预留一部分 USB 带宽。例如,如果某个特定设备希望每毫秒得到服务,并且每个数据包需要传输 16 字节数据,USB 主机控制器堆栈必须从总带宽中预留超过 128Kbps(千比特每秒)的带宽(16 字节 × 8 位每字节 × 1,000 个数据包每秒)。你需要预留稍多一点带宽,因为总线上还有一些协议开销——至少 10 到 20%,但根据 USB 堆栈的编写方式,可能还会更多。

由于 USB 带宽有限,并且每当你打开设备使用时,中断传输会消耗一定的带宽,因此在任何时候不能有任意数量的中断传输同时激活。一旦 USB 带宽(减去 USB 保留的 10%用于控制传输)被消耗完,堆栈就会拒绝激活任何新的中断传输。

中断传输的数据包大小在 4 到 64 字节之间,尽管大多数情况下它们都在这个范围的低端。较大的数据包会阻止系统保证所需的轮询频率。

许多设备使用中断传输来通知主机 CPU 有数据可用,然后主机使用大容量传输来实际读取设备中的数据。如果主机与外设之间需要传输的数据量足够小,外设可以将数据作为中断数据负载的一部分进行传输,以避免第二次传输。键盘、鼠标、操纵杆和类似设备通常以这种方式传输数据。磁盘驱动器、扫描仪和其他类似设备使用中断传输通知主机数据可用,然后使用大容量传输来移动数据。

等时(或iso)传输是 USB 支持的第四种传输类型。像中断传输一样,iso 传输需要及时传输。像大容量传输一样,它们通常涉及更大的数据包。然而,与其他三种传输类型不同,iso 传输不能保证主机与外设设备之间的正确传输。及时传输对于 iso 传输非常重要,以至于如果数据包延迟到达,它就可能完全无法到达。外设设备如音频输入(麦克风)、输出(扬声器)以及摄像头使用 iso 传输。如果丢失数据包,或者数据包在外设与主机之间传输错误,你可能会在视频显示或音频信号中看到短暂的卡顿,但只要这种问题不是频繁发生,就不会造成灾难性后果。

与中断传输类似,iso 传输也会消耗 USB 带宽。每当你打开与 iso USB 外设设备的连接时,该设备会请求一定的带宽。如果带宽可用,USB 主机控制器堆栈会为该设备保留带宽,直到应用程序使用完该设备。如果没有足够的带宽,USB 堆栈会通知应用程序,告知它在用户停止使用其他 iso 和中断设备,释放一些带宽之前无法使用所需的设备。

13.3.4 USB-C

USB 最初与 FireWire 竞争,争夺外围设备开发者的关注。最初,FireWire 是一个性能更高的接口和协议。然而,随着 USB-2 的出现,尤其是 USB-3 的推出,FireWire 的吸引力逐渐减弱。在这段时间里,Apple 与 Intel 合作开发了一种新的外部外围设备总线协议——Thunderbolt。Thunderbolt 在性能上完全超过了 USB。竞赛再次开始,这一次是在 USB 和 Thunderbolt 之间。然而,Intel(同时推广 USB 和 Thunderbolt)决定将这两个标准合并成一个:USB-C。USB-C 实际上是一个 Thunderbolt 3 硬件接口,它携带 USB、PCI、SCSI 以及其他协议通过串行总线传输。现在,你不必做出选择——USB-C(或 Thunderbolt-3)已经成为首选接口。

13.3.5 USB 设备驱动程序

大多数提供 USB 堆栈的操作系统都支持 USB 设备驱动程序的动态加载和卸载,这在 USB 术语中称为客户端驱动程序。每当你将 USB 设备连接到 USB 时,主机系统会收到一个信号,告知其总线拓扑已发生变化(即,USB 上有了一个新设备)。主机控制器会扫描新设备,这是一个被称为枚举的过程,然后从外设读取一些配置信息。除此之外,这些配置信息会告诉 USB 堆栈设备的类型、制造商和型号等信息。USB 主机堆栈使用这些信息来确定加载哪个设备驱动程序到内存中。如果 USB 堆栈找不到合适的驱动程序,它通常会弹出一个对话框,要求用户提供帮助;如果用户无法提供适当驱动程序的路径,系统就会忽略新设备。同样地,当用户拔掉设备时,如果该驱动程序没有用于其他设备,USB 堆栈会从内存中卸载相应的设备驱动程序。

为了简化许多常见设备(如键盘、磁盘驱动器、鼠标和游戏杆)的设备驱动程序实现,USB 标准定义了某些设备类。创建符合这些标准化设备类的外围设备制造商无需为其设备提供驱动程序。相反,USB 主机控制器堆栈中附带的类驱动程序提供了所需的唯一接口。类驱动程序的例子包括 HID(人机接口设备,如键盘、鼠标和游戏杆)、STORAGE(磁盘、CD 和磁带驱动器)、COMMUNICATIONS(调制解调器和串行转换器)、AUDIO(扬声器、麦克风和电话设备)和 PRINTERS(打印机)。外围设备制造商可以选择提供自己的专有功能,为其产品增添附加功能,但客户通常只需通过插入设备,无需专门安装驱动程序,就能通过现有的类驱动程序获得基本功能。

13.4 获取更多信息

Axelson, Jan. USB 完整指南:开发者手册。第 4 版。威斯康星州麦迪逊:Lakeview 出版社,2009 年。

Field, Gary, Peter M. Ridge 等. SCSI 手册。第 2 版。旧金山:No Starch Press,2000 年。

注意

对于 USB、FireWire 和 TCP/IP(网络)协议栈,您可以在网上找到大量的信息。例如, www.usb.org/ 包含了 USB 协议的所有技术规格以及各种常见 USB 主控制器芯片组的编程信息。您还可以找到大量的在线代码资源,例如 Linux 中完整的 TCP/IP 和 USB 主控制器栈源代码。

第十四章:大容量存储设备和文件系统**

图片

现代计算机中最常见的输入输出设备可能就是大容量存储设备了。尽管有些 PC 没有显示器(它们是无头操作的),甚至没有键盘或鼠标(它们是远程访问的),几乎所有被认作 PC 的计算机系统都配有某种大容量存储设备。本章将重点介绍各种大容量存储设备——硬盘、软盘、磁带驱动器、闪存驱动器、固态硬盘等——以及它们用于组织存储数据的特殊文件系统格式。

14.1 磁盘驱动器

几乎所有现代计算机系统都包含某种硬盘驱动单元,以提供在线大容量存储。曾几何时,某些工作站厂商生产无盘工作站,但由于固定(即“硬”)磁盘和固态硬盘(SSD)单元的价格不断下降,存储空间不断增加,几乎完全消除了无盘计算机系统的存在。磁盘驱动器在现代系统中如此普及,以至于大多数人都视其为理所当然。然而,对于程序员来说,理所当然地看待磁盘驱动器是非常危险的。软件不断与磁盘驱动器交互,作为应用文件存储的媒介,因此,如果你想编写高效的代码,理解磁盘驱动器的工作原理是非常重要的。

14.1.1 软盘驱动器

软盘几乎已经从今天的 PC 中消失。它们有限的存储容量(通常为 1.44MB)对于现代应用程序及其产生的数据来说过于小。很难相信,在 PC 革命初期,一个 143KB(那是字节,不是兆字节或吉字节)容量的软盘驱动器曾被视为高档商品。然而,软盘驱动器未能跟上计算机行业技术的进步。因此,我们在本章中不再考虑它们。

14.1.2 硬盘

固定磁盘驱动器,通常称为硬盘,是当今最常见的大容量存储设备(不过,截止到 2020 年,SSD 正在迅速取代硬盘)。现代硬盘真的是一项工程奇迹。1982 年到 2020 年间,单个硬盘驱动单元的容量增长了超过 240 万倍,从 5MB 增至超过 16TB(千兆字节)。与此同时,新的硬盘驱动单元的最低价格从 2500 美元降至 50 美元以下。没有其他计算机系统组件经历过如此激烈的容量和性能提升,同时价格却大幅下降。(半导体 RAM 可能排名第二:如果你用 1982 年的价格,今天能买到大约 40,000 倍容量的 RAM。)

在硬盘价格逐渐降低、容量逐渐增加的同时,它们的速度也在不断提升。在 1980 年代初,硬盘子系统能够实现每秒 1MB 的数据传输速率,往返于硬盘和 CPU 内存之间;而现代硬盘的传输速率可以超过 2500MBps。^(1) 尽管这种性能提升不像内存或 CPU 那样显著,但要记住,硬盘是机械设备,物理定律对其有更大的限制。在某些情况下,硬盘成本的下降使得系统设计人员可以通过使用磁盘阵列来提升性能(详见第 388 页的“RAID 系统”)。通过使用某些硬盘子系统,如磁盘阵列,你可以实现 2500MBps(或更高)的传输速率,尽管这样做并不便宜。

硬盘之所以叫这个名字,是因为它们的数据存储在一个小而坚硬的盘片上,盘片通常由铝或玻璃制成,并涂有磁性材料。与此相对,软盘则将数据存储在一片薄薄的可弯曲的 Mylar 塑料上。

在硬盘驱动的术语中,小型的铝或玻璃盘片称为盘片。每个盘片有两个表面,前面和后面(或顶部和底部),这两个表面都涂有磁性涂层。在操作过程中,硬盘单元以特定的速度旋转这个盘片,通常的速度是 3600、5400、7200、10000 或 15000 转每分钟(RPM)。一般来说,虽然并不总是如此,盘片转速越快,从磁盘读取数据的速度越快,磁盘和系统之间的数据传输速率也越高。笔记本电脑中的小型硬盘通常以较慢的速度旋转,比如 2000 或 4000 RPM,以节省电池寿命并减少热量产生。

硬盘子系统包含两个主要的活动组件:磁盘盘片和读写磁头。读写磁头在静止时,悬浮在磁盘表面的同心圆上,或称为轨道。每个轨道被分成一个个被称为扇区的部分。扇区的实际数量因硬盘设计而异,但典型的硬盘每个轨道上有 32 到 128 个扇区(参见图 14-1)。每个扇区通常包含 256 到 4096 字节的数据。许多硬盘驱动单元允许操作系统在几种不同的扇区大小之间进行选择,最常见的有 512 字节和 4096 字节。

image

图 14-1:硬盘盘片上的轨道和扇区

硬盘通过读写磁头向盘片发送一系列电脉冲来记录数据,这些电脉冲转化为磁脉冲并被盘片的磁性表面保存。磁盘控制器记录这些脉冲的频率受到电子质量、读写磁头设计和磁性表面质量的限制。

磁介质能够在其磁盘表面记录两个相邻的比特,并在稍后的读取操作中区分它们。然而,随着比特记录越来越紧密,它在磁性领域中变得越来越难以区分它们。比特密度是衡量某个硬盘能在其轨道中压缩数据的紧密程度——比特密度越高,单个轨道上可以挤压的数据就越多。然而,恢复密集压缩的数据需要更快和更昂贵的电子设备。

比特密度对驱动器的性能有很大影响。如果驱动器的盘片以固定的转速旋转,那么比特密度越高,在一定时间内,更多的比特将会旋转到读写头下方。较大的磁盘驱动器通常比小型磁盘驱动器更快,因为它们采用了更高的比特密度。

通过将磁盘的读写头大致沿着从磁盘盘片中心到外缘的直线路径移动,系统可以将单个读写头定位到几千个轨道中的任何一个。然而,仅使用一个读写头意味着在磁盘的多个轨道之间移动头部需要相当长的时间。实际上,两个最常引用的硬盘性能参数是读写头的平均寻道时间和轨道到轨道寻道时间。

平均寻道时间是将读写头从磁盘的边缘移动到中心,或者反向移动所需时间的一半。一个典型的高性能磁盘驱动器的平均寻道时间在 5 到 10 毫秒之间。另一方面,它的轨道到轨道寻道时间——即将磁盘读写头从一个轨道移动到下一个轨道所需的时间——大约是 1 或 2 毫秒。从这些数字可以看出,读写头的加速和减速在轨道到轨道寻道时间中占据的比例远远高于在平均寻道时间中的比例。横跨 1000 个轨道的时间是移动到下一个轨道的时间的 20 倍。而且,由于将读写头从一个轨道移动到下一个轨道通常是最常见的操作,因此轨道到轨道寻道时间可能更能反映磁盘的性能。然而,无论你使用哪种度量标准,都要记住,移动磁盘的读写头是你在磁盘驱动器上可以执行的最昂贵的操作之一,所以你应该尽量减少这类操作。

由于大多数硬盘子系统在磁盘盘片的两面上记录数据,因此每个盘片上有两个读写头——一个用于顶部,一个用于底部。由于大多数硬盘为了增加存储容量而在磁盘组件中采用多个盘片(见图 14-2),一个典型的硬盘有多个读写头对。

image

图 14-2:多盘片硬盘组件

各种读写头物理上连接到同一个执行器。因此,每个读写头都位于其各自盘片上的同一轨道上,并且所有读写头作为一个整体一起在磁盘表面上移动。当前所有读写头所在的轨道集合称为圆柱(参见图 14-3)。

image

图 14-3:硬盘圆柱

尽管使用多个读写头和盘片会增加硬盘驱动器的成本,但也能提高性能。当系统所需的数据不位于当前轨道时,性能提升尤为明显。在只有一个盘片的硬盘子系统中,读写头需要移动到另一个轨道以找到数据。但在一个具有多个盘片的子系统中,下一块要读取的数据通常位于同一个圆柱内。而且,由于硬盘控制器可以快速在读写头之间进行电子切换,因此将盘片数量加倍,几乎可以使磁盘单元的轨道到轨道寻道性能加倍,因为它减少了寻道操作的次数。当然,增加盘片数量还会增加单元的存储容量,这也是高容量硬盘通常具有更高性能的原因之一。

在较旧的磁盘驱动器中,当系统希望从某个盘片的某个轨道读取特定扇区时,它会指示磁盘将读写头定位到相应的轨道上,然后磁盘驱动器等待所需的扇区转到读写头下方。但是在读写头定位好之前,所需的扇区有可能已经刚好从读写头下方经过,这时磁盘必须等到几乎完成一整圈旋转后才能读取数据。平均而言,所需的扇区大约出现在磁盘的中间位置。如果磁盘以 7,200 转每分钟(每秒 120 转)的速度旋转,则完成一次盘片旋转需要 8.333 毫秒。通常,在扇区转到读写头下方之前会有 4.2 毫秒的延迟。这种延迟称为平均旋转延迟,通常等于一次旋转所需的时间除以 2。

要了解平均旋转延迟可能带来的问题,可以考虑操作系统通常以扇区大小的数据块来操作磁盘数据。例如,当从磁盘文件中读取数据时,操作系统通常请求磁盘子系统读取一个数据扇区并返回该数据。在收到数据后,操作系统会处理数据,然后很可能会向磁盘发出额外的数据请求。但是,当第二次请求的数据位于当前磁道的下一个扇区时会发生什么情况呢?不幸的是,当操作系统正在处理第一个扇区的数据时,磁盘盘片仍然在读写磁头下方转动。如果操作系统在读取第一个扇区后没有立即通知驱动器读取下一个磁道上的扇区,那么第二个扇区会在读写磁头下方旋转。这时,操作系统将不得不等待几乎一整个磁盘旋转周期,才能读取第二个扇区。这种现象被称为转速丧失(blowing revs)。如果操作系统(或应用程序)在读取文件数据时不断发生转速丧失,文件系统性能会显著下降。在早期的“单任务”操作系统中,这种转速丧失是一个令人不愉快的生活事实。如果一个磁道有 64 个扇区,通常需要磁盘旋转 64 次才能读取完一个磁道上的所有数据。

为了解决这个问题,旧款驱动器的磁盘格式化程序允许用户交错扇区。交错是将扇区分布在磁道上,使得逻辑上相邻的扇区在磁盘表面上并不物理相邻的过程(见图 14-4)。

交错扇区的优点是,一旦操作系统读取一个扇区,在逻辑上相邻的扇区旋转到读写磁头下方之前,会经过一个完整的扇区旋转时间。这给操作系统提供了时间来进行一些处理,并在所需的扇区进入读写磁头下方之前发出新的磁盘 I/O 请求。然而,在现代的多任务操作系统中,很难保证一个应用程序能获得 CPU 的控制权,以便在下一个逻辑扇区进入磁头下方之前做出响应,因此交错并不是很有效。

image

图 14-4:扇区交错

为了解决这个问题,并提高磁盘性能,现代大多数磁盘驱动器在磁盘控制器中包括内存,使其能够在一次磁盘旋转中读取整个磁道的数据。一旦控制器将磁道数据缓存到内存中,控制器可以以 RAM 速度而非磁盘旋转速度进行磁盘读写操作,这可以显著提高性能。读取磁道的第一个扇区仍然会表现出旋转延迟,但一旦磁盘控制器读取整个磁道,延迟几乎会消失。

一个典型的轨道可能有 64 个扇区,每个扇区 512 字节,总共 32KB 每个轨道。由于新型硬盘通常具有 8MB 到 512MB 的控制器内存,因此控制器可以在其内存中缓存数百个轨道。因此,磁盘控制器缓存不仅提高了单个轨道上磁盘读写操作的性能,还提高了整体磁盘性能。请注意,磁盘控制器缓存加速了读操作写操作。例如,CPU 通常可以在几微秒内将数据写入磁盘控制器的缓存内存,然后返回到正常的数据处理,同时磁盘控制器将磁头移动到适当的轨道位置。当磁头最终到达适当的轨道位置时,控制器可以将缓存中的数据写入磁盘表面。

从应用设计者的角度来看,磁盘子系统设计的进展减少了理解磁盘驱动器几何结构(轨道和扇区布局)以及磁盘控制器硬件如何影响应用程序性能的必要性。尽管有这些努力使硬件对应用程序透明,然而,想要编写出优秀代码的软件工程师必须始终意识到磁盘驱动器的底层操作。例如,知道顺序文件操作通常比随机访问操作快得多是非常有价值的,因为顺序操作需要的磁头寻道次数较少。此外,如果你知道磁盘控制器有板载缓存,你可以将文件数据写入较小的块,在块操作之间做其他处理,从而为硬件写入数据到磁盘表面提供时间。尽管早期程序员用来最大化磁盘性能的技术不适用于现代硬件,但通过了解磁盘的工作原理及其数据存储方式,你可以避免导致代码运行缓慢的各种陷阱。

14.1.3 RAID 系统

由于现代硬盘通常有 8 到 16 个磁头,你可能会想知道是否可以通过同时在多个磁头上读取或写入数据来提高性能。虽然这在理论上是可能的,但直到 SATA 和更大的磁盘缓存出现之前,实际上并没有发生过这种情况。但还有另一种提高磁盘驱动器性能的方法,那就是使用并行读写操作——冗余廉价磁盘阵列(RAID)配置。

RAID 的概念非常简单:你将多个硬盘驱动器连接到一个特殊的主机控制卡(有时称为适配器),该控制卡同时读取和写入各个硬盘驱动器。通过将两个硬盘连接到 RAID 控制卡,你可以实现比单个硬盘驱动器快大约两倍的读写速度。通过连接四个硬盘驱动器,你可以将平均性能提高接近四倍。

RAID 控制器根据磁盘子系统的用途支持不同的配置。所谓的RAID 0子系统使用多个磁盘驱动器仅仅是为了提高数据传输速率。如果你将两块 150GB 的磁盘驱动器连接到 RAID 控制器,你将得到相当于 300GB 的磁盘子系统,并且数据传输速率翻倍。这是个人 RAID 系统的典型配置——即那些没有安装在文件服务器上的系统。

许多高端文件服务器系统是RAID 1(及更高版本)子系统,这些系统将数据的多个副本存储在多个磁盘驱动器上,而不是通过提高系统与磁盘驱动器之间的数据传输速率。在这种配置下,如果某块磁盘发生故障,数据的副本仍会保存在另一块磁盘上。一些更高级别的 RAID 子系统结合了四块或更多的磁盘驱动器,以提高数据传输速率并提供冗余数据存储。这种配置通常出现在高端、高可用性的文件服务器系统中。

现代 RAID 系统配置可以分为以下几类:

RAID 0 将数据交错存储在所有磁盘上以提高性能(以牺牲可靠性为代价)。这被称为条带化。需要至少两块磁盘。

RAID 1 将数据复制到成对的磁盘上,以提高可靠性(以牺牲性能为代价;同时将可用的总存储空间减少一半)。允许至少一块磁盘故障而不丢失数据(根据故障磁盘的不同,可能支持两块或更多磁盘的故障)。需要偶数块磁盘,最少两块磁盘。这被称为镜像

RAID 5 在磁盘上存储校验信息。比 RAID 1 快,比 RAID 0 慢。允许一块磁盘故障而不丢失数据。最少需要三块磁盘。使用三块磁盘时,66%的总存储空间可用于数据;超过三块磁盘时,增加的磁盘空间将提高数据存储容量。

RAID 6 在磁盘上存储冗余的校验信息。比 RAID 1 快,比 RAID 0 和 RAID 5 慢。允许两块磁盘故障而不丢失数据。最少需要四块磁盘。使用四块磁盘时,一半的总存储空间可用于数据,但超过四块磁盘时,增加的磁盘空间将提高系统存储容量。

RAID 10 是 RAID 1 + RAID 0 的组合。最少需要四块驱动器;扩展必须以成对驱动器的形式进行。数据在磁盘间交错(条带化)以提高性能,并且在成对的驱动器上提供冗余存储以确保可靠性。比 RAID 1 快(但比 RAID 0 慢)。

RAID 50, RAID 60 是 RAID 5 + RAID 0 或 RAID 6 + RAID 0 的组合。

还有其他 RAID 组合(如 2、3 和 4),但大多数已经过时,你不会在现代系统中看到它们的使用。

RAID 系统使你能够显著提高磁盘子系统的性能,而无需购买昂贵且特殊的大容量存储解决方案。尽管软件工程师不能假设世界上所有的计算机系统都有可用的快速 RAID 子系统,但对于那些要求绝对最高性能存储子系统的应用,RAID(可能使用 SSD)可以是一个解决方案。

14.1.4 光驱

光驱使用激光束和特殊的光敏介质来记录和播放数字数据。与使用磁性介质的硬盘子系统相比,光驱有一些优势:

  • 它们更具抗震性,因此在操作过程中敲击磁盘驱动器时,不会像硬盘那样轻易损坏驱动单元。

  • 该介质通常是可拆卸的,可以让你保持几乎无限量的离线或近线存储。

  • 它们的容量相当大(尽管现代的 USB 闪存驱动器和 SD 卡的容量更大)。

曾几何时,光存储系统看起来像是未来的趋势,因为它们在一个小空间内提供了非常高的存储容量。不幸的是,除了少数一些细分市场外,它们已不再受欢迎,因为它们也有几个缺点:

  • 虽然它们的读取性能尚可,但写入速度非常慢——比硬盘慢一个数量级,而且只比磁光(旧的结合磁性/光学的软盘)驱动器快几倍。

  • 尽管光学介质比磁性介质要坚固得多,但硬盘中的磁性介质通常被密封,以防尘土、湿气和磨损。相比之下,光学介质很容易被任何真正想要损坏磁盘表面的人接触到。

  • 光盘子系统的寻址时间远比磁盘慢。

  • 光盘的存储容量有限,目前低于约 128GB(蓝光)。

最终,USB 闪存驱动器的低价和日益增长的容量使得光驱在个人电脑中的使用逐渐消失。

然而,光盘子系统仍然在近线存储子系统中使用,这些系统通常使用机器人唱机来管理数百或数千张光盘。虽然可以争辩说,一排高容量硬盘驱动器会提供更节省空间的存储解决方案,但它将消耗更多电力,产生更多热量,并且需要比光盘唱机更复杂的接口,后者通常只有一个光驱单元和一个机器人磁盘选择机制。对于归档存储,服务器系统很少需要访问存储子系统中的任何特定数据,唱机系统是一种非常具有成本效益的解决方案。

如果你编写的软件需要操作光驱子系统上的文件,最重要的是记住读取访问速度远快于写入访问速度。你应该尽量将光驱系统作为“以读为主”的设备,并尽量避免向设备写入数据。你还应该避免在光盘表面进行随机访问,因为寻道时间非常慢。

CD、DVD 和 Blu-ray 驱动器也是光驱。不过,由于它们的广泛使用,以及与标准光驱相比,其组织和性能的显著不同,它们值得单独讨论。

14.1.5 CD、DVD 和 Blu-ray 驱动器

CD-ROM 是第一个在个人计算机市场上获得广泛接受的光驱子系统。CD-ROM 磁盘基于音频 CD 的数字录音标准,并且与当时的硬盘驱动器存储容量(通常为 100MB)相比,它们提供了大量存储(650MB)。随着时间的推移,当然这种关系发生了反转。不过,CD-ROM 仍然成为大多数商业应用的首选分发载体,完全取代了软盘作为这一目的的媒介。

尽管 CD-ROM 格式在大批量分发中非常便宜,通常每张磁盘只需几美分,但它不适合小批量生产。问题在于,制作一个磁盘母盘(用于制作一批 CD-ROM)通常需要花费数百或数千美元,这意味着只有在生产的磁盘数量达到至少几千张时,CD-ROM 才通常具有成本效益。

解决方案是新的 CD 介质——CD-可记录(CD-R),它允许制作一次性 CD-ROM。CD-R 使用一次写入的光盘技术,委婉地称为WORM(一次写入,多次读取)。首次推出时,CD-R 磁盘的价格大约为 10 到 15 美元。然而,一旦驱动器达到临界数量,媒体制造商开始大规模生产空白 CD-R 磁盘,它们的批发零售价格降至大约 0.25 美元。因此,CD-R 使得分发大量小数据成为可能。

CD-R 的一个明显缺点是“一次写入”的限制。为了解决这一问题,创建了 CD-可重写(CD-RW)驱动器和介质。顾名思义,CD-RW 支持读取和写入。然而,与光盘不同的是,你不能仅仅重写 CD-RW 上的某一个扇区。相反,要在 CD-RW 磁盘上重写数据,你必须先擦除整个磁盘。

尽管当 CD 首次推出时,650MB 的存储空间看起来像是一个巨大的容量,但“数据和程序会填满所有可用空间”这一古老的格言在实际中完全成立。尽管 CD 最终扩展到了 700MB,但各种游戏(含嵌入视频)、大型数据库、开发者文档、程序员开发系统、剪贴画、股票照片,甚至常规应用程序,都达到了一个单一 CD 无法满足的程度。DVD-ROM(后来是 DVD-R、DVD-RW、DVD+RW 和 DVD-RAM)磁盘通过在单个磁盘上提供 3GB 到 17GB 的存储空间解决了这个问题。除了 DVD-RAM 格式外,DVD 格式可以看作是 CD 格式的更快、更大容量的版本。两者之间有一些明显的技术差异,但大多数差异对软件而言是透明的。今天,蓝光光盘提供高达 128GB 的存储空间(蓝光 BDXL)。然而,通过互联网进行的电子分发已经在很大程度上取代了物理介质,因此蓝光光盘从未像分发或存储介质那样普及。

CD 和 DVD 格式是为了从存储介质中读取连续流数据——流式数据而创建的。读取硬盘上存储的数据所需的磁头到磁头的移动时间,在流式数据传输序列中产生了一个较大的间隙,这对于音频和视频应用来说是不可接受的。CD 和 DVD 将信息记录在一个单一的、非常长的轨道上,这个轨道在整个磁盘表面形成一个螺旋形。因此,CD 或 DVD 播放器可以通过以恒定速度沿着磁盘的单一螺旋轨道移动激光束,来连续读取数据。

虽然拥有单一轨道非常适合流式数据的传输,但这也使得在磁盘上定位特定扇区变得更加困难。CD 或 DVD 驱动器只能通过机械地将激光束定位到磁盘上的某个位置,来大致估算扇区的位置。接下来,它必须从磁盘表面实际读取数据,以确定激光的位置,然后进行微调以定位所需的扇区。因此,在 CD 或 DVD 磁盘上搜索特定扇区的时间可能比在硬盘上搜索特定扇区的时间长一个数量级。

对于编写与 CD 或 DVD 媒体交互的代码的程序员来说,最重要的要记住的是随机访问是禁忌的。这些媒体是为顺序流式访问设计的,在这样的媒体上查找数据会影响你的应用程序性能。如果你使用这些光盘来将应用程序及其数据提供给最终用户,如果需要高性能的随机访问,应该让用户在使用前将数据复制到硬盘上。

14.2 磁带驱动器

磁带驱动器曾是流行的大容量存储设备。在硬盘驱动器容量较小的年代,PC 用户通常使用磁带驱动器来备份存储在硬盘上的数据。许多年里,磁带存储的每兆字节成本远低于硬盘存储。事实上,曾经磁带存储和磁盘存储之间每兆字节的成本差距达到了一个数量级。而且,由于磁带驱动器比大多数硬盘驱动器存储更多的数据,它们在空间利用上也更高效。

然而,由于竞争和硬盘驱动器市场的技术进步,磁带逐渐失去了这些优势。如今,硬盘驱动器的存储容量已超过 16TB,硬盘的最优价格点大约为每 GB $0.25。如今,磁带存储每兆字节的成本远高于硬盘存储。此外,只有少数几种磁带技术可以在单个磁带上存储 250GB 的数据,而且这些技术(如数字线性磁带(DLT))非常昂贵。难怪磁带驱动器在如今的家庭 PC 中使用越来越少,通常只出现在大型文件服务器上。线性磁带开放(LTO)驱动器的容量已扩展到大约 12TB(预计未来可达到约 200TB)。尽管如此,今天一盘典型的 LTO-8 磁带的价格几乎为$130(美元),每兆字节的价格大约是硬盘的一半。

在大型机时代,应用程序与磁带驱动器的交互方式与今天的应用程序与硬盘驱动器的交互方式非常相似。然而,磁带驱动器并不是一个高效的随机访问设备。也就是说,尽管软件可以从磁带中读取随机位置的数据块,但其性能并不理想。当然,在大多数应用程序运行在大型机上的时代,应用程序通常不是交互式的,而且 CPU 的处理速度也更慢;因此,“可接受性能”的标准是不同的。

在磁带驱动器中,读写头是固定的,磁带传输机制会将磁带沿线性方式从磁带的起始位置移动到结束位置,或反之。如果此时磁带的起始位置正好位于读写头下方,而你想读取磁带末端的数据,就必须将整个磁带移动过读写头,才能到达所需的数据。这可能非常缓慢,可能需要几十秒甚至几百秒,具体取决于磁带的长度和格式。相比之下,硬盘读写头重新定位的时间通常只有几十毫秒(或者 SSD 的数据访问几乎是瞬间完成的)。因此,为了在磁带驱动器上获得良好的性能,软件必须针对顺序访问设备的限制进行编写。特别是,数据应该在磁带上按顺序读取或写入。

最初,数据是以块的形式写入磁带(就像硬盘上的扇区一样),并且驱动器被设计为允许准随机访问磁带的块。如果你曾经看过使用磁带驱动器的老电影,看到磁带卷轴不断地停止、启动、停止、反转、停止、继续,你就看到了“随机访问”的实际操作。这种磁带驱动器非常昂贵,因为它们需要强大的电机、精细加工的磁带路径机制等等。随着硬盘容量的增加和价格的下降,应用程序不再将磁带作为数据处理介质,仅将其用于离线存储(备份硬盘数据)。

由于磁带的顺序数据访问不再需要原始磁带驱动器的重型机械结构,磁带驱动器制造商试图制造一种低成本的产品,专门用于顺序访问。它们的解决方案是流式磁带驱动器,其设计目的是让数据不断地从 CPU 传输到磁带,或者反之。例如,在将硬盘数据备份到磁带时,流式磁带驱动器像录音视频一样处理数据,让磁带不断运行,将硬盘数据持续写入磁带。由于流式磁带驱动器的工作方式,极少有应用程序直接与磁带单元交互。今天,除了由系统管理员运行的磁带备份工具程序外,其他任何程序几乎都不会访问磁带硬件。

14.3 闪存存储

一种由于其紧凑的形态而变得流行的存储介质是闪存。闪存介质实际上是一种半导体设备,基于电可擦可编程只读存储器(EEPROM)技术,尽管名称中包含“只读”,但它既可读取也可写入。与常规的半导体存储器不同,闪存是非易失性的,意味着即使断电,它也能保持数据。像其他半导体技术一样,闪存存储完全依赖电子操作,不需要任何马达或其他机电设备才能正常工作。因此,闪存存储设备比机械存储解决方案(如硬盘驱动器)更可靠、更抗震,并且消耗的电量远低于这些机械存储设备。这使得闪存存储在便携式电池驱动设备中尤其有价值,如手机、平板电脑、笔记本电脑、电子相机、MP3 播放器和录音机。

目前,闪存存储模块提供超过 1TB 的存储空间,其最佳价格大约为每千兆字节 0.15 美元(美国)。这使得它们在每比特的成本上与硬盘存储相当。

闪存设备以多种不同的形式销售。OEM(原始设备制造商)可以购买外观类似其他半导体芯片的闪存存储设备,并将其直接安装在电路板上。然而,今天销售的大多数闪存存储设备都被集成在几种标准形式中,包括 SDHC 卡、CompactFlash 卡、智能内存模块、存储棒、USB/闪存模块或 SSD。例如,你可能会从相机中取出 CompactFlash 卡,将其插入 PC 上的特殊 CompactFlash 卡读卡器,然后像访问磁盘驱动器上的文件一样访问你的照片。

闪存存储模块中的内存是以字节块的形式组织的,类似于硬盘上的扇区。然而,与普通的半导体内存或 RAM 不同,你不能在闪存存储模块中写入单个字节。尽管通常可以读取闪存存储设备中的单个字节,但要写入特定字节,你必须首先擦除该字节所在的整个块。块的大小因设备而异,但大多数操作系统会将这些闪存块视为磁盘扇区,用于读取和写入。尽管基本的闪存存储设备本身可以直接连接到 CPU 的内存总线,但大多数常见的闪存存储包(如 CompactFlash 卡和存储棒)包含模拟硬盘接口的电子元件,你可以像访问硬盘驱动器一样访问闪存设备。

闪存内存设备,尤其是 EEPROM 设备,的一个有趣方面是它们有一个有限的写入寿命。也就是说,你只能在闪存内存模块的特定存储单元中写入一定次数的数据,超过这个次数后,该单元将开始无法保持信息。早期的 EEPROM/闪存设备中,这个问题尤为严重,因为在出现故障之前的平均写入次数大约为 10,000 次。也就是说,如果某个软件连续写入同一个内存块 10,000 次,那么该 EEPROM/闪存设备可能会在该块中产生坏内存单元,从而使整个芯片失效。另一方面,如果软件仅在 10,000 个独立的块中各写一次,该设备仍然可以在每个存储单元上进行 9,999 次写入。因此,这些早期设备的操作系统会尽量将写操作分散到整个设备中,以减少损坏。尽管现代闪存设备仍然存在这个问题,但技术进步已经将其减少到几乎可以忽略不计的程度。现代闪存内存单元支持大约一百万次写入周期,之后才会出现问题。此外,今天的操作系统会像标记硬盘上的坏扇区一样标记坏闪存块,一旦确定某个块坏了,操作系统会跳过该块。

由于是电子设备,闪存设备根本没有旋转延迟时间,也没有太多寻道时间。写入地址到闪存模块需要一点点时间,但与硬盘的磁头寻道时间相比,这几乎可以忽略不计。尽管如此,闪存的速度通常远不及典型的 RAM。读取闪存设备的数据通常需要微秒(而不是纳秒),而闪存设备与系统之间的接口可能还需要额外的时间来设置数据传输。此外,通常会通过 USB 闪存读卡器设备将闪存存储模块与 PC 连接,这进一步将每字节的平均读取时间降低到几百微秒。

写入性能更差。为了将一块数据写入闪存,你必须先写入数据,再读取回来,与原始数据进行比较,如果不匹配则重新写入。这个过程可能需要几十分甚至几百毫秒的时间。

因此,闪存存储模块通常比高性能硬盘子系统慢得多。然而,主要由于高端数码相机用户希望能够在短时间内拍摄尽可能多的照片,技术进步正在提升它们的性能。尽管闪存的性能可能短期内无法赶上硬盘性能,但预计它将随着时间的推移不断提高。

14.4 RAM 磁盘

另一个有趣的大容量存储设备是 RAM 磁盘,它是一种半导体解决方案,将计算机系统的大块内存当作磁盘驱动器来使用,利用内存阵列模拟块和扇区。基于内存的磁盘的优势在于它们具有非常高的性能。RAM 磁盘不受硬盘、光盘和软盘驱动器上与磁头寻道时间和旋转延迟相关的时间延迟的影响。它们与 CPU 的接口也更快,因此数据传输时间非常短,通常以最大总线速度运行。很难想象有比 RAM 磁盘更快的存储技术。

然而,RAM 磁盘有两个缺点:成本和易失性。RAM 磁盘系统中每字节存储的成本非常高。事实上,按字节计算,半导体存储比磁盘硬盘存储贵多达 10,000 倍。因此,RAM 磁盘通常具有较低的存储容量,通常不超过几 GB。而且 RAM 磁盘是易失性的——如果没有持续供电,它们会丢失数据。这通常意味着半导体磁盘非常适合存储临时文件和在关机之前会复制回永久存储设备的文件。它们不太适合长期保存重要信息。

14.5 固态硬盘

现代高性能 PC 使用固态硬盘(SSD)。SSD 使用闪存(如 USB 闪存驱动器)并通过高性能接口与系统连接。但 SSD 不仅仅是外观不同的 USB 闪存驱动器。USB 闪存驱动器设计时主要考虑每比特的成本——除了某些相机应用(特别是 4K 和 8K 摄像机)外,速度通常是次要的,成本和容量才是主要考量。例如,典型的 USB 闪存驱动器速度远不及一款中等水平的硬盘。另一方面,SSD 必须非常快速。由于其固态设计,它们通常比旋转磁介质快一个数量级。通过 RAID 配置,SSD 实际上能够达到 SATA 接口的性能极限。

在撰写本文时,SSD 的价格是高容量硬盘的 4 到 16 倍(8TB 硬盘和 1TB SSD 的价格大约都是 100 美元)。然而,按每千兆字节计算的价格差距正在缩小。SSD 正在迅速取代旋转磁盘驱动器,而旋转磁介质可能会被历史的垃圾桶所淘汰(就像磁带驱动器一样)。在那之前,为什么还会有人愿意为 SSD 支付更多呢?

SSD 通常使用不同的底层技术来存储数据,并提供更快速的电子接口到计算机。这就是为什么 SSD 通常比 USB 闪存驱动器要贵得多的原因。这也是为什么 SSD 能够实现 2,500MBps 的数据传输速度,而高质量的存储卡只能达到大约 100MBps(而 USB 闪存/拇指驱动器的速度甚至更慢)。

从程序员的角度来看,SSD 的一个重大优势是你不再需要担心寻道时间和其他延迟问题。与硬盘相比,SSD 通常是更接近真正的随机访问设备。在 SSD 上,从驱动器的开始位置访问数据,再到结束位置的访问时间,仅比访问驱动器中其他数据元素的时间稍长。

然而,SSD 也有一些缺点。首先,它们的写入性能通常比读取性能慢得多(尽管写入 SSD 的速度仍然比写入硬盘快)。幸运的是,读取数据的频率远高于写入,但在处理向 SSD 写入数据的软件时,必须考虑这一点。第二个缺点是 SSD 在一段时间后会磨损。反复写入同一位置最终会导致相关的存储单元失败。幸运的是,现代操作系统能够绕过这些故障。然而,当你编写持续覆盖文件数据的应用程序时,请牢记这一问题。

14.6 混合硬盘

大多数现代硬盘都包含板载 RAM 缓存(例如,用于存储整个磁道的数据,以消除旋转延迟)。混合硬盘,如苹果公司早期的 Fusion Drive,结合了一块小型 SSD 与一个大容量硬盘——在苹果的例子中,通常是一个 32GB 到 128GB 的 SSD 和一个 2TB 的磁盘。常访问的数据会保留在 SSD 缓存中,当需要为新数据腾出空间时,它会被交换到硬盘中。这与主内存中的缓存工作方式相同,可以提高系统性能,使经常访问的数据接近 SSD 的速度。

14.7 大容量存储设备上的文件系统

很少有应用程序直接访问大容量存储设备。也就是说,应用程序通常不会直接读取和写入大容量存储设备上的磁道、扇区或块;相反,它们是在存储设备上打开、读取、写入并以其他方式操作文件。操作系统的文件管理器抽象化了底层存储设备的物理配置,并为单个设备上的多个独立文件提供了一个方便的存储设施。

在最早的计算机系统中,应用程序负责跟踪数据在大容量存储设备上的物理位置,因为当时没有文件管理器可用来执行此操作。它们通过仔细考虑数据在磁盘上的布局来最大化性能。例如,它们可以手动交错数据在磁道的不同扇区中,以便在读取和写入磁道上的扇区之间,给 CPU 留出时间进行处理。这类软件通常比使用通用文件管理器的软件快好几倍。后来,当文件管理器变得普遍可用时,一些应用程序开发者仍然出于性能原因自行管理存储设备上的文件。特别是在软盘时代,低级软件通过在磁道和扇区级别操作数据,通常比使用文件管理器系统的相同应用程序快 10 倍。

理论上,今天的软件也可以从这种方法中受益,但在实践中,你很少会在现代应用程序中看到这种低级别的磁盘访问,原因有几个。首先,编写能够以如此低的级别操作大容量存储设备的软件会让你依赖于特定的设备。也就是说,如果你的软件操作的是一个每个磁道有 48 个扇区、每个柱面有 12 个磁道、每个驱动器有 768 个柱面的磁盘,那么该软件在不同扇区、磁道和柱面布局的驱动器上将无法实现最佳性能(如果能运行的话)。第二,以低级别访问磁盘会使得在不同应用程序之间共享设备变得困难,尤其是在多任务系统中,可能会有多个应用程序同时共享该设备。例如,如果你已经在磁道的各个扇区上布置了数据,以便将计算时间与扇区访问协调,那么当操作系统中断你的程序并分配时间片给其他应用程序时,你的工作将会丢失——这段时间本来是用来在下一个数据扇区经过读写头之前完成必要的计算的。第三,一些现代大容量存储设备的功能,例如板载缓存控制器和将存储设备呈现为一系列块的 SCSI 接口,而不是呈现为具有特定磁道和扇区几何结构的设备,消除了低级软件可能具有的任何优势。第四,现代操作系统通常包含文件缓冲和块缓存算法,可以提供良好的文件系统性能,从而无需以如此低的级别操作。最后,低级磁盘访问非常复杂,编写这种软件很困难。

14.7.1 顺序文件系统

最早的文件管理系统将文件顺序存储在磁盘表面。也就是说,如果磁盘上的每个扇区/块存储 512 字节,并且一个文件的大小是 32KB,那么该文件将在磁盘表面上占用 64 个连续的扇区/块。为了在未来的某个时间访问该文件,文件管理器只需要知道文件的起始块号和它占用的块数。文件系统必须在某个非易失性存储介质中维护这两条信息。显然,这些信息存储在存储介质本身中,称为目录——这是一个从特定磁盘位置开始的值数组,操作系统可以在应用程序请求特定文件时引用。文件管理器可以在目录中查找文件的名称,并提取其起始块和长度。通过这些信息,文件系统可以向应用程序提供访问文件数据的权限。

顺序文件系统的一个优点是非常快速。如果文件存储在磁盘表面上的顺序块中,操作系统可以非常迅速地读取或写入单个文件的数据。但顺序文件组织也存在一些严重的问题。最大且最明显的缺点是,一旦文件管理器将另一个文件放置在磁盘上的下一个块处,就无法扩展文件的大小。磁盘碎片化是另一个大问题。随着应用程序创建和删除许多小型和中型文件,磁盘会填满许多短小的未使用扇区,这些扇区单独来看对于大多数文件来说太小了。在顺序文件系统上,磁盘上常常有足够的空闲空间来存储一些数据,但因为它被分散在磁盘表面的小块上,无法使用。为了解决这个问题,用户必须运行磁盘压缩程序,将所有空闲扇区合并并通过物理重新排列文件,将它们移动到磁盘的末端。另一种解决方案是将文件从一个满磁盘复制到另一个空磁盘,收集那些许多小的未使用扇区。显然,这是用户必须做的额外工作——这本应该是操作系统执行的任务。

顺序文件存储方案在多任务操作系统中真正崩溃。如果两个应用程序试图同时将文件数据写入磁盘,文件系统必须将第二个应用程序文件的起始块放置在第一个应用程序文件所需的最后一个块之后。由于操作系统无法确定文件可以增长多大,每个应用程序在首次打开文件时必须告诉操作系统文件的最大长度。不幸的是,许多应用程序无法预先确定它们需要多少空间来存储文件,因此它们在打开文件时必须猜测文件的大小。如果估算的文件大小太小,程序要么必须因“文件已满”错误而中止,要么应用程序必须创建一个更大的文件,将“已满”文件中的旧数据复制到新文件中,然后删除旧文件。正如你能想象的那样,这种做法效率极低,绝对不是优良的代码。

为了避免此类性能问题,许多应用程序严重高估了它们所需的文件空间。因此,当文件实际上没有使用分配给它们的所有数据时,它们最终会浪费磁盘空间,这是一种内部碎片化形式。此外,如果应用程序在关闭文件时截断了文件,那么返回给操作系统的空闲部分往往会把磁盘分割成之前描述的那些小的、无法使用的空闲块,这个问题被称为外部碎片化。基于这些原因,现代操作系统已经用更为复杂的存储管理方案取代了磁盘上的顺序存储。

14.7.2 高效的文件分配策略

大多数现代文件分配策略允许文件存储在磁盘的任意块上。由于文件系统现在可以将文件的字节放置在磁盘上的任何空闲块中,因此外部碎片和文件大小限制的问题几乎被消除了。只要磁盘上至少有一个空闲块,就可以扩展任何文件的大小。然而,这种灵活性也带来了一些额外的复杂性。在顺序文件系统中,查找磁盘上的空闲空间很容易;只需记下目录中各个文件的起始块号和大小,文件系统就可以找到足够大的空闲块来满足当前的磁盘分配请求(如果有的话)。但在文件跨任意块存储的情况下,扫描目录并记录一个文件使用了哪些块计算起来太昂贵,因此文件系统必须跟踪空闲和已使用的块。大多数现代操作系统使用三种数据结构之一——集合、表格(数组)或列表——来跟踪哪些扇区是空闲的,哪些是已用的。每种方案都有其优缺点。

14.7.2.1 空间位图

空间位图方案使用集合数据结构来维护磁盘驱动器上的空闲块集合。如果一个块是该集合的成员,文件管理器可以在需要另一个块为文件时将其移除。由于集合成员关系是布尔关系(一个块要么在集合中,要么不在),因此只需要 1 位就能指定每个块的集合成员关系。

通常,文件管理器会保留磁盘的某个部分来存放一个位图,该位图指定磁盘上哪些块是空闲的。位图会占用磁盘上的若干块,每个块所占的空间表示磁盘上其它块的数量,我们可以通过将块大小(以字节为单位)乘以 8(每字节的位数)来计算。例如,如果操作系统在磁盘上使用 4,096 字节的块,则由一个块组成的位图可以跟踪磁盘上最多 32,768 个其他块。

位图方案的缺点是,随着磁盘容量的增大,位图也会变得越来越大。例如,在一个 120GB 的驱动器上,块大小为 4,096 字节时,位图几乎长达 4MB。虽然这只是磁盘总容量的一个小比例,但访问这么大位图中的单个比特位可能会很笨拙。为了找到一个空闲块,操作系统必须在线性查找中遍历这个 4MB 的位图。即使你将位图保存在系统内存中(考虑到每个磁盘都必须这样做,这其实是有点昂贵的),每次需要空闲扇区时查找它也是一种昂贵的提议。因此,你很少会看到这种方案用于较大的磁盘驱动器上。

位图方案的一个优点(也是缺点)是,文件管理器仅使用它来跟踪磁盘上的空闲空间,而不是跟踪属于某个特定文件的扇区。因此,如果空闲空间位图发生损坏,实际上并不会永久丢失任何数据;你可以通过遍历所有磁盘目录并计算哪些扇区被这些目录中的文件使用来轻松重建它(显然,剩余的扇区就是空闲的)。虽然这个过程有点耗时,但如果发生灾难时,能够有这个选项还是挺好的。

14.7.2.2 文件分配表

另一种跟踪磁盘扇区使用情况的方法是使用扇区指针表,或者称为文件分配表(FAT)。这种方案被广泛使用,巩固了它的流行性,这也是大多数 USB 闪存驱动器上默认的文件分配方案。FAT 结构的一个有趣方面是,它将空闲空间管理和文件扇区分配管理合并为同一数据结构,与使用各自独立数据结构的位图方案相比,最终节省了空间。此外,与位图方案不同,FAT 不需要进行低效的线性搜索来找到下一个可用的空闲扇区。

FAT 实际上不过是一组自相对指针数组(也就是说,指向自身的索引),为存储设备上的每个扇区/块保留一个指针。当磁盘初始化时,它的表面上的前几个块被保留给根目录和 FAT 本身等对象,磁盘上剩余的块则是空闲的。根目录中的某个地方有一个空闲空间指针,它指定磁盘上下一个可用的空闲块。假设空闲空间指针最初包含值64,意味着下一个空闲块是块 64,那么 FAT 中索引 64、65、66 等位置的条目将包含以下值,假设磁盘上有n个块,从 0 到n–1 编号:

FAT 索引 FAT 条目值
. . . . . .
64 65
65 66
66 67
67 68
. . . . . .
n – 2 n – 1
n – 1 0

块 64 中的条目告诉你磁盘上下一个可用的空闲块是 65。接着查看条目 65,你会发现磁盘上下一个可用空闲块的值是66。FAT 中的最后一个条目包含一个0(块 0 包含整个磁盘分区的元数据,永远不会被使用)。

每当应用程序需要一个或多个块来存储一些新数据时,文件管理器会获取空闲空间指针的值,然后继续遍历 FAT 条目,直到找到足够的块来存储新数据。例如,如果每个块大小为 4,096 字节,而当前应用程序试图向一个文件写入 8,000 字节,文件管理器需要从空闲块列表中移除两个块,步骤如下:

  1. 获取空闲空间指针的值。

  2. 保存空闲空间指针的值,以便确定第一个空闲扇区。

  3. 继续查看 FAT 条目,直到存储应用程序数据所需的块数。

  4. 提取应用程序需要存储其数据的最后一个块的 FAT 条目值,并将空闲空间指针设置为该值。

  5. 在应用程序使用的最后一个块的 FAT 条目值上存储一个0,标记应用程序所需块列表的结束。

  6. 将空闲空间指针的原始值(即在这些步骤之前的值)返回到 FAT 中,作为指向现在已分配给应用程序的块列表的指针。

在我们之前的示例中,块分配之后,应用程序可以使用块 64 和 65,空闲空间指针包含66,FAT 看起来如下所示:

FAT 索引 FAT 条目值
. . . . . .
64 65
65 0
66 67
67 68
. . . . . .
n – 2 n – 1
n – 1 0

这并不意味着 FAT 中的条目总是包含表中下一个条目的索引。随着文件管理器为磁盘上的文件分配和释放存储空间,这些数字往往会变得混乱。例如,如果一个应用程序将块 64 归还到空闲列表,但仍保留块 65,空闲空间指针将包含值64,FAT 将具有以下值:

FAT 索引 FAT 条目值
. . . . . .
64 66
65 0
66 67
67 68
. . . . . .
n – 2 n – 1
n – 1 0

如前所述,FAT 数据结构的一个优点是,它将空闲空间管理和文件分配管理合并为一个单一的数据结构。这意味着每个文件不需要携带其数据占用的块的列表。相反,它只需要一个指针值,指定 FAT 中的一个索引,从该索引可以找到文件数据的第一个块。剩余包含文件数据的块可以通过遍历 FAT 来找到。

FAT 方案相对于集合(位图)方案的一个重要优势是,一旦使用 FAT 文件系统的磁盘满了,它就不再维护哪些块是空闲的。相比之下,位图方案即使没有空闲块可用,仍然会占用磁盘空间来跟踪空闲块。FAT 方案用文件块指针替换了原本用来跟踪空闲块的条目。当磁盘满时,原本用于维护空闲块列表的值不再占用磁盘空间,因为它们现在都在跟踪文件中的块。在这种情况下,空闲空间指针包含0(表示空的空闲空间列表),所有 FAT 条目都包含文件数据的块索引链。

然而,FAT 方案确实有几个缺点。首先,FAT 文件系统中的表格与集合方案文件系统中的位图不同,它代表了一个单点故障。如果 FAT 以某种方式被破坏,修复磁盘和恢复文件可能会非常困难;丢失磁盘上的一些空闲空间是一个问题,但丢失文件在磁盘上的位置则是一个重大问题。此外,由于磁盘头通常比在磁盘的任何其他区域花费更多时间在 FAT 区域,FAT 是硬盘最容易受到磁头撞击损坏的部分,也是软盘或光驱最容易出现过度磨损的部分。这是一个足够重要的担忧,以至于一些 FAT 文件系统提供了在磁盘上保留 FAT 额外副本的选项。

FAT 的另一个问题是它通常位于磁盘上的固定位置,通常是在某些较低的块号处。为了确定要读取哪个块或哪些块以获取特定文件,磁盘读写头必须移动到 FAT 区域,因此,如果 FAT 位于磁盘的开头,它们将不断地来回移动,跨越较大的距离。这种大规模的读写头移动不仅很慢,而且容易加速磁盘驱动器机械部件的磨损。在微软操作系统的新版本中,FAT-32 方案通过允许将 FAT 定位在磁盘的开头以外的位置来解决部分问题,尽管 FAT 仍然处于固定位置。如果操作系统没有将 FAT 缓存到主内存中,FAT 文件系统的应用程序文件 I/O 性能可能会非常低,这在系统崩溃时可能很危险,因为你可能会失去所有尚未写入磁盘的 FAT 条目对应的文件数据。

FAT 方案对于文件的随机访问也非常低效。为了从文件的偏移量m读取到偏移量n,文件管理器必须将n除以块大小,以获得包含偏移量n的字节的块偏移量,将m除以块大小以获得其块偏移量,然后依次在这两个块之间的 FAT 链中查找包含所需数据的扇区。这种线性搜索可能非常耗时,尤其是当文件是一个大型数据库,当前块位置与所需块位置之间有成千上万个块时。

FAT 文件系统的另一个问题,虽然相对深奥,却依然存在,那就是它不支持稀疏文件。也就是说,你不能在文件的字节 0 和字节 1,000,000 之间写入数据,而不在这两个位置之间的每个字节上都分配磁盘空间。一些非 FAT 的文件管理器只分配应用程序写入数据的块。例如,如果一个应用程序只在文件的字节 0 和 1,000,000 处写入数据,那么文件管理器只为该文件分配两个块。如果应用程序尝试读取一个未先分配的块(例如,如果应用程序在当前示例中尝试读取偏移量为 500,000 的字节,而该位置之前没有写入过数据),文件管理器会简单地返回0,而不会实际占用磁盘上的任何空间。但由于 FAT 的组织方式,你无法在磁盘上创建稀疏文件。

14.7.2.3 块列表

为了克服 FAT 文件系统的局限性,先进的操作系统——如 Windows NT/2000/XP/7/8/10、macOS(APFS)以及各种版本的 Unix——采用了块列表方案。事实上,块列表方案享有 FAT 系统的所有优点(如高效的非线性空闲块定位和空闲块列表的高效存储),同时解决了许多 FAT 系统的问题。

块列表方案的实现方式是首先在磁盘上预留几个块,用于存储(通常是)32 位或 64 位的指针,指向磁盘上的每个空闲块。如果磁盘上的每个块大小为 4,096 字节,则一个块可以存储 1,024(或 512)个指针。通过将磁盘上的块数除以 1,024(或 512),可以确定空闲块列表最初将消耗的块数。正如你很快会看到的那样,一旦磁盘被填满,系统将使用这些块来存储数据,因此空闲块列表所占用的块并不会带来额外的存储开销。

如果空闲块列表中的一个块包含 1,024 个指针(以下示例假设使用 32 位指针),那么前 1,023 个指针包含磁盘上空闲块的块号。文件管理器在磁盘上维护两个指针:一个保存当前包含空闲块指针的块的块号,另一个保存该块中的索引。每当文件系统需要一个空闲块时,它会通过这两个指针从空闲块列表中获取一个索引。然后,文件管理器将索引增加到空闲块列表中的下一个可用条目。当索引增加到 1,023(即空闲块列表中的第 1,024 项)时,文件管理器不会使用该索引处的指针值来定位一个空闲块,而是将其作为包含磁盘上空闲块指针列表的下一个块的地址,并将当前块(其中包含现在空的块指针列表)用作空闲块。这就是文件管理器如何重用最初用于保存空闲块列表的块,而不是像 FAT 那样重用空闲块列表中的指针来跟踪属于给定文件的块。一旦文件管理器用完了某个块中的所有空闲块指针,它将使用该块来存储实际的文件数据。

与 FAT 不同,列表方案并不将空闲块列表和文件列表合并为同一数据结构。相反,每个文件都有一个独立的数据结构,用于保存与该文件相关联的块列表。在典型的 Unix 和 Linux 文件系统中,文件的目录项实际上保存了列表中的前 8 到 16 个条目(见 图 14-5)。这样,操作系统就能追踪小文件(最大 32KB 或 64KB),而不需要在磁盘上分配任何额外的空间。

image

图 14-5:小文件的块列表

对于不同类型的 Unix 系统的研究表明,绝大多数文件都很小,将多个指针嵌入目录项提供了一种高效访问小文件的方式。当然,随着时间的推移,平均文件大小似乎有所增加。但事实证明,块大小也趋向于增大。最初进行这项研究时,典型的块大小为 512 字节,而今天已经是 4,096 字节。因此,在这段时间内,平均文件大小可能增加了 8 倍,而在平均情况下,目录项并未需要额外的空间。

对于中等大小的文件,最大约为 4MB,操作系统将分配一个包含 1,024 个指针的单个块,用于指向存储文件数据的块。操作系统继续使用目录项中找到的指针来指向文件的前几个块,然后它使用磁盘上的一个块来存储下一组块指针。通常,目录项中的最后一个指针保存此块的位置(见 图 14-6)。

image

图 14-6:中等文件的块列表

对于大于大约 4MB 的文件,文件系统切换到三级块方案,该方案适用于最大 4GB 的文件大小。在该方案中,目录条目的最后一个指针存储了一个包含 1,024 个指针的块的位置,而该块中的每个指针都指向另一个包含 1,024 个指针的块,这些指针存储的每个指针在这个块中指向包含实际文件数据的块。详细信息请参见 图 14-7。

image

图 14-7:适用于大文件(最大 4GB)的三级块列表

这种树形结构的一个优点是,它可以轻松支持稀疏文件:应用程序可以在文件的第 0 块和第 100 块写入数据,而无需为这两个点之间的每个块分配数据块。通过在块列表的中间条目中放置一个特殊的块指针值(通常是 0),操作系统可以判断文件中是否不存在某个块。如果应用程序尝试读取文件中丢失的块,操作系统可以简单地返回所有 0,表示该块为空。当然,一旦应用程序向一个先前未分配的块写入数据,操作系统必须将数据复制到磁盘,并在块列表中填入相应的块指针。

随着磁盘容量的增大,这种方案所施加的 4GB 文件限制开始对某些应用程序(如视频编辑器、大型数据库应用程序和 Web 服务器)造成问题。通过向块列表树中添加另一级,可以轻松将此方案扩展 1,000 倍,达到 4TB。这个方法的唯一问题是,间接层级越多,随机文件访问速度越慢,因为操作系统可能需要从磁盘读取多个块才能获取一个数据块。(当只有一层时,将块指针列表缓存在内存中是有意义的,但有两层和三层时,想对每个文件都这样做就不现实了)。另一种扩展最大文件大小(每次扩展 4GB)的方法是使用多个指针指向二级文件块(例如,将目录中的所有或大部分 8 到 16 个指针指向二级块列表项,而不是直接指向文件数据块)。虽然目前没有扩展超过三层的惯例,但可以放心,一旦需求出现,操作系统设计师将会开发高效访问大文件的方案。例如,64 位操作系统可以使用 64 位指针,而不是 32 位指针,从而消除 4GB 限制。

14.8 编写操作大容量存储设备上数据的软件

理解不同大容量存储设备的行为非常重要,特别是当你想编写能够操作这些设备中文件的高性能软件时。尽管现代操作系统试图将应用程序与大容量存储的物理现实隔离开来,但操作系统能为你做的事是有限的。而且,由于操作系统无法预测你特定应用程序如何访问大容量存储设备上的文件,它无法为你的应用程序量身定制文件访问优化;相反,它的优化是针对具有典型文件访问模式的应用程序设计的。因此,如果你的应用程序的文件 I/O 方式不典型,那么你就不太可能从系统中获得最佳的性能。在本节中,我们将探讨如何与操作系统协调文件访问活动,以实现最佳性能。

14.8.1 文件访问性能

虽然磁盘驱动器和大多数其他大容量存储设备常被认为是“随机访问”设备,但实际上,大容量存储的访问通常在顺序访问时更为高效。磁盘驱动器上的顺序访问相对高效,因为操作系统可以一次移动读写头一个磁道(假设文件按顺序块出现在磁盘上)。这比访问磁盘上的一个块、将读写头移动到另一个磁道、访问另一个块、再移动读写头,依此类推,要快得多。因此,如果可能的话,你应该避免应用程序中的随机文件访问。

你还应该尽量在每次文件访问时读取或写入大块数据,而不是更频繁地读取或写入小块数据。这有两个原因。首先,操作系统调用的速度并不快,因此,如果你通过每次读取或写入更多的数据来减少调用次数,应用程序通常会运行得更快。其次,操作系统必须读取或写入整个磁盘块。如果你的块大小是 4,096 字节,但你只向某个块写入 2,000 字节,然后又跳到文件中的另一个位置进行访问,操作系统实际上必须先从磁盘读取整个 4,096 字节的块,将 2,000 字节合并进去,然后再将整个 4,096 字节写回磁盘。与此对比,如果写入操作是一次性写入完整的 4,096 字节,操作系统就不必先从磁盘读取数据;它只需要写入这个块。写入完整的块提高了磁盘访问性能,提升幅度为 2 倍,因为写入部分块需要操作系统首先读取块、合并数据,再写入块;而写入完整块则无需读取操作。即使你的应用程序没有按磁盘块大小的整数倍写入数据,写入大块数据仍然能提高性能。如果你在一次写入操作中向文件写入 16,000 字节,操作系统仍然需要使用读-合并-写操作来写入那 16,000 字节中的最后一个块,但它将只使用写操作来写入前面三个块。

如果你从一个相对空的磁盘开始,操作系统通常会尝试将新文件的数据写入顺序块。这种组织方式对于未来文件访问可能是最有效的。然而,随着系统用户在磁盘上创建和删除文件,单个文件的数据块可能会非顺序分布。在非常糟糕的情况下,操作系统可能会在磁盘的不同位置分配几个块。这会导致即使是顺序文件访问,也可能像慢速的随机文件访问那样表现。如前所述,这种文件碎片化会显著降低文件系统的性能。不幸的是,应用程序无法确定其文件数据是否在磁盘表面上碎片化,即使它能确定,也几乎无法解决这一问题。虽然有一些工具可以整理磁盘表面上的数据块,应用程序通常不能请求它们的执行(而且“碎片整理”工具本身也非常慢)。

尽管应用程序在正常程序执行过程中很少有机会对数据文件进行碎片整理,但你可以采取一些措施来减少数据文件碎片化的概率。你可以遵循的最佳建议是始终以大块数据写入文件。实际上,如果你能够通过一次写操作将整个文件写入,最好这样做。除了加快操作系统的访问速度外,写入大量数据往往会导致分配顺序块。当你将小块数据写入磁盘时,其他应用程序在多任务环境下也可能同时写入磁盘。在这种情况下,操作系统可能会交错不同应用程序对文件的块分配请求,从而使某个特定文件的数据不太可能按顺序写入。即使你计划随机访问数据的某些部分,也要尽量将文件的数据写入顺序块,因为在顺序写入的文件中查找随机记录通常需要的寻道时间远少于查找那些数据块散布在各处的文件。

如果你打算创建一个文件并反复访问其数据块,无论是随机访问还是顺序访问,最好在磁盘上预分配这些数据块。例如,如果你知道文件的数据不会超过 1MB,你可以在应用程序开始操作文件之前,先将一百万个0写入磁盘。这样做可以帮助确保操作系统将文件写入磁盘的顺序块中。尽管你需要付出初始代价来写入这些0(这通常不是你会做的操作),但在读写头寻道时间上的节省很可能弥补了这一点。如果一个应用程序同时读取或写入两个或更多文件(这几乎肯定会导致不同文件的数据块交错),这种方法尤其有用。

14.8.2 同步与异步 I/O

由于大多数大容量存储设备是机械式的,因此会受到机械延迟的影响,频繁使用它们的应用程序必须等待它们完成读写操作。大多数磁盘 I/O 操作是同步的,意味着发出操作系统调用的应用程序必须等待该 I/O 请求完成后才能继续后续操作。

这就是为什么大多数现代操作系统也提供* 异步 I/O*功能的原因,在这种功能中,操作系统启动应用程序的请求后,会立即将控制权返回给应用程序,而无需等待 I/O 操作完成。当 I/O 操作进行时,应用程序承诺不会对指定的数据缓冲区进行任何操作。然而,应用程序可以进行计算并安排额外的 I/O 操作,因为操作系统会在原始请求完成时通知它。当你在系统中访问多个磁盘驱动器上的文件时,这种方式尤其有用,通常只有 SCSI 和其他高端驱动器才能做到这一点。

14.8.3 I/O 类型的影响

编写处理大容量存储设备的软件时,另一个重要的考虑因素是你所执行的 I/O 类型。二进制 I/O 通常比格式化文本 I/O 更快,因为写入磁盘的数据格式不同。例如,假设你有一个包含 16 个整数值的数组,想要将其写入文件。为此,你可以使用以下两段 C/C++ 代码序列中的任何一个:

FILE *f;

int array[16];

    . . .

// Sequence #1:

fwrite( f, array, 16 * sizeof( int ));

    . . .

// Sequence #2:

for( i=0; i < 16; ++i )

    fprintf( f, "%d ", array[i] );

第二个代码序列看起来比第一个运行得慢,因为它使用了循环,而不是单一的调用,来逐步遍历数组中的每个元素。但是,尽管循环带来的额外执行开销对写操作的执行时间有些许负面影响,这种效率损失与第二个序列中的真正问题相比微不足道。第一个代码序列将由 16 个 32 位整数组成的 64 字节内存映像写入磁盘,而第二个代码序列则将每个 16 个整数转换为字符串,然后将每个字符串写入磁盘。这种整数到字符串的转换过程相对较慢。此外,fprintf() 函数必须在运行时解释格式字符串("%d"),这也会带来额外的延迟。

格式化 I/O 的优势在于,生成的文件既易于人类阅读,也容易被其他应用程序读取。然而,如果你使用文件来存储只对你的应用程序有意义的数据,更高效的方法可能是将数据以内存映像的形式写入文件。

14.8.4 内存映射文件

内存映射文件利用操作系统的虚拟内存功能,将应用程序空间中的内存地址直接映射到磁盘上的块。现代操作系统拥有高度优化的虚拟内存子系统,因此将文件 I/O 叠加在虚拟内存子系统之上,可以实现非常高效的文件访问。此外,内存映射文件的访问非常简便。当你打开一个内存映射文件时,操作系统会返回指向某个内存块的内存指针。只需像访问任何其他内存数据结构一样访问此指针引用的内存位置,你就可以访问文件数据。这使得文件访问几乎变得微不足道,同时通常能提高文件操作性能,尤其是在文件访问是随机的情况下。

内存映射文件比普通文件更高效的原因之一是操作系统只需要读取一次内存映射文件所属的块的列表。然后,操作系统会设置系统的内存管理表,指向文件所属的每个块。打开文件后,操作系统很少需要从磁盘读取任何文件元数据,这大大减少了在随机文件访问过程中多余的磁盘访问。它也改善了顺序文件访问,尽管程度较轻。操作系统不需要不断地在磁盘、操作系统内部缓冲区和应用程序数据缓冲区之间复制数据。

内存映射文件访问确实有一些缺点。首先,你无法将庞大的文件完全映射到内存中,至少在那些使用 32 位地址总线并为每个应用程序分配最多 4GB 内存的旧 PC 和操作系统上无法实现。通常,对于大于 256MB 的文件,使用内存映射访问方案并不实际,尽管随着越来越多支持 64 位寻址的 CPU 的问世,这一情况有所改变。当应用程序已经使用了系统中大部分物理内存时,使用内存映射文件也不是一个好主意。幸运的是,这两种情况并不常见,因此它们对内存映射文件的使用限制不大。

一个更常见且重要的问题是,当你首次创建一个内存映射文件时,必须告诉操作系统该文件的最大大小。如果无法确定文件的最终大小,你必须高估它,然后在关闭文件时截断它。不幸的是,这会在文件打开时浪费系统内存。内存映射文件在你以只读模式操作文件或只是读取和写入现有文件中的数据而不扩展文件大小时表现良好。幸运的是,你总是可以先使用传统的文件访问机制创建一个文件,然后使用内存映射文件 I/O 在稍后访问该文件。

最后,几乎每个操作系统对内存映射文件的访问方式都不同,因此内存映射文件的 I/O 代码在操作系统之间不太可能是可移植的。不过,打开和关闭内存映射文件的代码非常简短,而且为需要支持的不同操作系统提供多个版本的代码也并不难。当然,实际访问文件数据的操作是简单的内存访问,这与操作系统无关。有关内存映射文件的更多信息,请查阅操作系统的 API 参考文档。鉴于它们的便利性和性能,你应该在应用程序中尽可能考虑使用内存映射文件。

14.9 更多信息

Silberschatz, Abraham, Peter Baer Galvin, 和 Greg Gagne. 操作系统概念。第 8 版。霍博肯,NJ:John Wiley & Sons, 2009.

第十五章:杂项输入输出设备

Image

虽然大容量存储设备可以说是现代计算机系统中最常见的外设,但还有许多其他广泛使用的设备,例如通信端口(串口和并口)、键盘和鼠标以及声卡。这些外设将是本章的重点。

15.1 探索特定 PC 外设

从某些方面来看,讨论现代 PC 上的真实设备是危险的,因为传统的(“遗留”)设备几乎已经从 PC 设计中消失。随着制造商推出新款 PC,他们正在去除许多遗留的、易于编程的外设,如并口和串口,取而代之的是像 USB 和 Thunderbolt 这样的复杂外设。尽管本书并不涉及如何编程这些新型外设的详细讨论,但你需要了解它们的行为,以便编写出能够访问它们的优秀代码。

注意

由于本章剩余部分讨论的外设的性质,所呈现的信息仅适用于 IBM 兼容 PC。由于本书篇幅所限,无法详细介绍不同系统上特定 I/O 设备的行为。其他系统支持类似的 I/O 设备,但其硬件接口可能与此处描述的不同。尽管如此,基本原则仍然适用。

15.1.1 键盘

原始的 IBM PC 键盘本身就是一个计算机系统。键盘外壳内部埋藏着一个 8042 微控制器芯片,持续扫描键盘上的开关,检查是否有按键被按下。这一处理过程与 PC 的正常活动并行进行,即使 PC 的 80x86 处理器在忙于其他事务,键盘也从未错过任何一个按键。

一个典型的按键输入始于用户按下键盘上的一个键。这会关闭开关中的电接触点,键盘的微控制器可以感应到这一点。不幸的是,机械开关并不总是完美地关闭。通常,接触点在最终稳定连接之前会相互弹跳几次。对于不断读取开关的微控制器芯片来说,这些接触点的弹跳看起来像是一系列非常快速的按键按下和松开。如果微控制器将这些误认为多个按键输入,就会导致一个被称为键盘弹跳的现象,这个问题在许多便宜或老旧的键盘中很常见。即使在最昂贵和最新的键盘上,只要扫描频率足够高,键盘弹跳也可能成为问题,因为机械开关根本无法这么快速稳定。一个典型的便宜键通常需要 5 毫秒左右来稳定下来,因此如果键盘扫描软件的轮询频率低于这个值,控制器将有效地错过键盘弹跳。为了消除键盘弹跳,限制键盘扫描的频率这一做法被称为去抖动。典型的键盘控制器每 10 到 25 毫秒扫描一次键盘;低于这个频率可能会导致键跳动,而频率过高则可能会导致丢失按键输入(尤其是对于打字速度非常快的人)。

键盘控制器在每次扫描键盘并发现某个键被按住时,不应生成新的按键代码序列。用户可能会按住一个键很多毫秒,甚至几百毫秒才松开,我们不希望这被记录为多个按键输入。相反,键盘控制器应该在键从上升位置变为按下位置时(即按键按下操作)生成一个单一的按键代码值。此外,现代键盘提供了一个自动重复功能,一旦用户按住一个键超过一定时间(通常大约半秒钟),它会将按住的键视为一系列的按键输入,前提是用户继续按住该键。然而,即便是这些自动重复的按键输入也受到限制,每秒只能产生大约 10 次按键,而不是键盘控制器扫描所有键盘开关的频率。

在检测到按键按下事件后,微控制器会向 PC 发送一个键盘扫描码。扫描码该键的 ASCII 码无关;它是 IBM 在最初开发 PC 键盘时选择的一个任意值。PC 键盘实际上会为每个按下的键生成两个扫描码。当按键被按下时,它会生成一个按下码,当键被释放时,它会生成一个释放码。如果用户长时间按住某个键,直到自动重复操作开始,键盘控制器会持续发送一系列的按下码,直到键被释放,此时键盘控制器会发送一个单一的释放码。

8042 微控制器芯片将这些扫描码传输到 PC,PC 通过中断服务例程(ISR)处理它们,处理的是键盘的输入。拥有单独的按下和松开码非常重要,因为某些键(如 SHIFT、CTRL 和 ALT)只有在按住时才有意义。通过为所有键生成松开码,键盘确保键盘 ISR 知道哪些键被按下,同时用户按住其中一个修饰键。系统如何处理这些扫描码取决于操作系统,但通常操作系统的键盘设备驱动程序会将扫描码序列转换为适当的 ASCII 码或其他应用程序可用的符号。

如今,几乎所有 PC 键盘都通过 USB 端口与计算机连接,并且它们可能使用比原始 IBM PC 键盘中使用的 8042 更现代的微控制器,但除此之外,它们的行为完全相同。

15.1.2 标准 PC 平行端口

原始的 IBM PC 设计提供了对三个并行打印机端口的支持(IBM 将其标记为LPT1:LPT2:LPT3:)。当时激光打印机和喷墨打印机仍然是几年的未来,IBM 可能预见到的是支持标准点阵打印机、波轮打印机,甚至可能是为不同目的设计的其他辅助类型打印机的机器。IBM 几乎可以肯定没有预见到并行端口的广泛使用,否则它可能会设计得不同。在鼎盛时期,PC 的并行端口控制了键盘、磁盘驱动器、磁带驱动器、SCSI 适配器、以太网和其他网络适配器、操纵杆适配器、辅助键盘设备、其他各种设备,以及哦,是的,打印机。

如今,由于连接器大小和性能问题,平行端口在大多数系统中已基本消失。然而,它仍然是一个有趣的设备。它是少数几个爱好者可以用来将 PC 与他们自己构建的简单设备连接起来的接口之一。因此,学习如何编程平行端口是许多硬件爱好者自发承担的任务。

在一个单向并行通信系统中,有两个不同的站点:发送站点和接收站点。发送站点将其数据放置在数据线上,并通知接收站点数据已就绪;接收站点随后读取数据线并通知发送站点它已接收数据。请注意,这两个站点是如何同步访问数据线的——接收站点在接收到发送站点的指示之前不会读取数据线,而发送站点在接收站点移除数据并告知发送站点它已接收数据之前不会在数据线上放置新值。换句话说,这种打印机与计算机系统之间的并行通信形式依赖于握手操作来协调数据传输。

计算机的并行端口除了八条数据线外,还使用三条控制信号实现握手。发送方使用信号线(或数据信号线)告知接收方数据可用。接收方使用确认线告诉发送方它已经接收了数据。第三条握手线,忙碌线,告诉发送方接收方正在忙碌,因此发送方不应尝试发送数据。忙碌信号与确认信号的不同之处在于,确认信号告诉系统接收方已经接受并处理了数据,而忙碌信号仅仅表示接收方暂时无法接收新数据——它并不意味着上一条数据传输已被处理(甚至已接收)。

在典型的数据传输会话中,发送方:

  1. 检查忙碌线以查看接收方是否忙碌。如果忙碌线是激活的,发送方将在一个循环中等待直到忙碌线变为非激活状态。

  2. 将数据放置在数据线上。

  3. 激活信号线。

  4. 在一个循环中等待直到确认线变为激活状态。

  5. 设置信号线为非激活状态。

  6. 在一个循环中等待接收方将确认线设置为非激活状态,表示它已识别信号线现在是非激活状态。

  7. 对每个必须传输的字节,重复步骤 1 到 6。

与此同时,接收方:

  1. 当接收方准备好接收数据时,设置忙碌线为非激活状态。

  2. 在一个循环中等待直到信号线变为激活状态。

  3. 从数据线读取数据。

  4. 激活确认线。

  5. 在一个循环中等待直到信号线变为非激活状态。

  6. 设置忙碌线为激活状态(可选)。

  7. 设置确认线为非激活状态。

  8. 处理数据。

  9. 设置忙碌线为非激活状态(可选)。

  10. 对接收到的每个额外字节,重复步骤 2 到 9。

通过仔细遵循这些步骤,接收方和发送方协调各自的操作,确保发送方不会在接收方消费数据之前尝试将多个字节放到数据线上,且接收方不会在发送方未发送数据时尝试读取数据。

15.1.3 串口

RS-232 串行通信标准可能是世界上最受欢迎的串行通信方案。尽管它有许多缺点(速度是主要问题),但它被广泛使用,且有成千上万的设备可以通过 RS-232 串行接口连接到 PC。尽管许多设备仍在使用这一标准,但它正迅速被 USB 取代(如今你可以通过将 USB 转 RS-232 电缆插入 PC 来处理大多数 RS-232 接口需求)。

原始 PC 系统设计支持最多同时使用四个 RS-232 兼容设备,分别通过COM1:, COM2:, COM3:COM4:端口连接。为了连接更多串行设备,你可以购买接口卡,允许你向 PC 添加 16 个或更多的串行端口。

在个人计算机的早期,DOS 程序员必须直接访问 8250 串行通信控制器(SCC)来实现其应用程序中的 RS-232 通信。一个典型的串行通信程序会有一个串行端口中断服务例程(ISR),它从 SCC 读取传入数据并将传出数据写入芯片,同时还包括初始化芯片以及缓冲传入和传出数据的代码。

幸运的是,今天的应用程序开发人员很少直接编程 SCC。相反,像 Windows 和 Linux 这样的操作系统提供了复杂的串行通信设备驱动程序,应用程序开发人员可以调用这些驱动程序。这些驱动程序提供了一套一致的功能集,所有应用程序都可以使用,这减少了实现串行通信功能所需的学习曲线。操作系统设备驱动程序方法的另一个优势是,它消除了对 8250 SCC 的依赖。使用操作系统设备驱动程序的应用程序将自动与不同的 SCC 配合工作。相比之下,直接编程 8250 的应用程序将无法在使用 USB 到 RS232 转换电缆的系统上工作。然而,如果该转换电缆的制造商为操作系统提供了合适的设备驱动程序,那么通过该操作系统进行串行通信的应用程序将自动与 USB/串行设备兼容。

对 RS-232 串行通信的深入研究超出了本书的范围。如需了解更多相关信息,请查阅操作系统程序员指南或阅读专门讨论此主题的许多优秀教材。

15.2 鼠标、触控板和其他指点设备

与磁盘驱动器、键盘和显示设备一起,指点设备可能是现代 PC 上最常见的外设之一。指点设备是外设中最简单的设备之一,提供给计算机一个非常简单的数据流。它们分为两类:一种是返回指针的相对位置,另一种是返回指点设备的绝对位置。相对位置是自上次系统读取设备以来的位置变化;绝对位置是固定坐标系统内的一组坐标值。鼠标、触控板和轨迹球返回相对坐标;触摸屏、光笔、压敏平板和操纵杆返回绝对坐标。

一般来说,将绝对坐标系统转换为相对坐标系统是容易的,但反过来则有问题。将相对坐标系统转换为绝对坐标系统需要一个常量的参考点,如果例如有人将鼠标从表面上抬起并放到另一个地方,这个参考点可能会变得毫无意义。幸运的是,大多数窗口系统使用来自指点设备的相对坐标值,因此返回相对坐标的指点设备的局限性不是问题。

早期的鼠标通常是光机械设备,旋转着两个沿鼠标主体 x 轴和 y 轴方向的编码轮。通常,两个轮子都会进行编码,每当它们移动一定距离时,发送 2 位脉冲。一个位告诉系统轮子已经移动了某个距离,另一个位告诉系统轮子的移动方向。^(1) 通过不断追踪来自鼠标的 4 位数据(每个轴 2 位),计算机系统可以确定鼠标的移动距离和方向,并在应用程序请求该位置时保持鼠标位置的非常精确记录。

让 CPU 追踪每次鼠标移动的一个问题是,当鼠标快速移动时,它会生成一个持续且高速的数据流。如果系统正在进行其他计算,它可能会错过一些传入的鼠标数据,从而导致丢失鼠标的位置。此外,主机的 CPU 时间最好用于应用程序计算,而不是跟踪鼠标位置。

结果,鼠标制造商很早就决定在鼠标内部集成一个简单的微控制器,以跟踪鼠标的物理运动,并响应系统对鼠标坐标更新的请求,或者至少在鼠标位置发生变化时定期生成中断。大多数现代鼠标通过 USB 连接到系统,并响应每大约 8 毫秒发生的系统请求的位置信息更新。

由于鼠标作为图形用户界面(GUI)指针设备被广泛接受,计算机制造商创造了许多其他设备来执行相同的功能,但更具便携性——例如,鼠标并不是最方便在旅行中连接到笔记本电脑的指针设备。轨迹球、应变计(许多笔记本电脑上GH键之间的小“棒”)、触摸板、轨迹点和触摸屏都是制造商附加到笔记本电脑、平板电脑和个人数字助理(PDA)上的设备示例,以创建更便捷的指针设备。虽然这些设备在用户便利性上有所不同,但对操作系统来说,它们看起来都像鼠标。因此,从软件的角度来看,它们之间几乎没有区别。

在现代操作系统中,应用程序很少直接与指针设备进行交互。相反,操作系统跟踪鼠标的位置,并更新光标和其他鼠标效果,然后在发生某种指针设备事件(如按钮按下)时通知应用程序。作为对应用程序查询的响应,操作系统返回系统光标的位置和指针设备按钮的状态。

15.3 摇杆和游戏控制器

为 IBM PC 创建的模拟游戏适配器允许用户将最多四个电阻式电位器和四个数字开关连接到 PC 上。PC 游戏适配器的设计显然受到了 Apple II 计算机模拟输入功能的影响,Apple II 是当时最流行的计算机,也是 PC 开发时的参考。IBM 的模拟输入设计像 Apple 的一样,旨在保持极低的成本。准确性和性能根本不被关注。事实上,你可以以不到 3 美元的零售价格购买电子元件,自己组装一个游戏适配器。

由于直接读取原始 IBM PC 游戏控制器的电子元件存在固有的低效性,大多数现代游戏控制器在控制器内部包含将物理位置转换为数字值的模拟电子元件,并通过 USB 接口与系统连接。Microsoft Windows 和其他现代操作系统提供了一个特殊的游戏控制器设备驱动程序接口和 API,允许应用程序确定游戏控制器具有什么功能,并以标准化的形式将数据发送给这些应用程序。这使得游戏控制器制造商能够提供许多原始 PC 游戏控制器接口无法实现的特性。现代应用程序读取游戏控制器的数据,就像读取文件或其他字符型设备(如键盘)的数据一样。这大大简化了此类设备的编程,同时提高了整体系统性能。

一些“老派”的游戏程序员认为调用 API 本身就是低效的,认为伟大的代码总是直接控制硬件。这个观点有些过时,原因有几点。首先,大多数现代操作系统不允许应用程序直接访问硬件,即使程序员想要这样做。其次,直接与硬件通信的软件无法支持像让操作系统处理硬件那样广泛的设备。最后,大多数操作系统的设备驱动程序可能由硬件厂商或操作系统开发者的团队编写,比个人编写更为高效。

因为现代游戏控制器不再受限于原始 IBM PC 游戏控制卡的设计,它们提供了广泛的功能。有关如何为特定设备编程 API 的信息,请参考相关的游戏控制器和操作系统文档。

15.4 声卡

原始的 IBM PC 配备了内置扬声器,CPU 可以通过(使用板载定时器芯片)编程来产生单频音调。尽管可以产生各种各样的音效,但这需要编程控制直接连接到扬声器的一个单独位。这个过程几乎消耗了所有可用的 CPU 时间。在 PC 发布后的几年内,像 Creative Labs 这样的各大制造商创建了一种特殊的接口板——声卡——提供了更高质量的 PC 音频输出,并且消耗的 CPU 资源远远少于之前。

首批为 PC 设计的声卡并没有遵循任何标准,因为当时并不存在这样的标准。创意实验室(Creative Labs)的 Sound Blaster 声卡成为了事实上的标准,因为它具有合理的功能,并且销量极高。当时,并没有为声卡提供设备驱动程序,因此大多数应用程序都是直接编程访问声卡的寄存器。最初,很多应用程序都是为 Sound Blaster 声卡编写的,以至于任何想要使用大多数音频应用程序的人都必须购买这款声卡。其他声卡制造商很快模仿了 Sound Blaster 的设计,结果它们都被困在这个设计里,因为它们新增的任何功能都无法得到现有音频软件的支持。

声卡技术停滞不前,直到微软在 Windows 中引入了多媒体支持。最初的音频卡仅能进行中等质量的音乐合成,只能提供适用于视频游戏的低劣音效。一些卡片支持 8 位电话质量的音频采样,但音质显然不高保真。一旦 Windows 提供了一个标准化的、设备无关的音频接口,声卡制造商开始为 PC 生产高质量的声卡。

很快,出现了“CD 质量”的声卡,这些声卡能够以 44.1 KHz 和 16 位的质量录制和回放音频。更高质量的声卡开始加入波表合成硬件,能够实现更真实的乐器合成。像 Roland 和 Yamaha 这样的合成器制造商也推出了带有其高端合成器相同电子元件的声卡。如今,专业录音室使用基于 PC 的数字音频录音系统,以 24 位分辨率在 96 KHz(甚至 192 KHz)下录制原创音乐,毫无疑问,它们产生的效果优于大多数模拟录音系统。当然,这些系统的成本高达数千美元。它们绝对不是那种售价不到 100 美元的典型声卡。

15.4.1 音频接口外设如何产生声音

现代音频接口外设^(2)通常通过以下三种方式之一产生声音:模拟(FM 合成)、数字波表合成或数字回放。前两种方案产生音乐音调,是大多数基于计算机的合成器的基础,而第三种则用于回放数字录制的音频。

FM 合成方案是一种较老的、低成本的音乐合成机制,它通过控制音效卡上的各种振荡器和其他声音产生电路来创造音乐音调。这类设备产生的声音通常质量较低,令人联想到早期的视频游戏;无法将其误认为是真正的乐器。虽然一些低端音效卡仍将 FM 合成作为主要的声音生成机制,但现代音频外设很少再用它来生成除“合成”声音之外的任何声音。

现代音效卡提供的音乐合成功能通常采用波表合成技术:音频制造商通常会录制并数字化实际乐器的若干音符,然后将这些数字录音编程到只读存储器(ROM)中,并将其组装进音频接口电路中。当应用程序请求音频接口播放某个乐器的音符时,音频硬件会从 ROM 中回放录音,产生非常逼真的声音。

然而,波表合成并不仅仅是一个数字回放方案。为了录制超过 100 种不同乐器,每种乐器有几个八度音程范围,这将需要非常昂贵的 ROM 存储空间。因此,大多数此类设备的制造商会在音频接口卡上嵌入软件,通过改变几个八度音程的数字化波形来升高或降低音符。这使得制造商只需为每种乐器录制并存储一个八度音程(12 个音符)。一些合成器使用软件将单个录制的音符转换为任何其他音符,以降低成本,但制造商录制的音符越多,最终声音的质量就越好。一些高端音频卡会为复杂的乐器(如钢琴)录制多个八度音程,而对一些使用较少、结构较简单的声音产生物体(如枪声、爆炸声和人群噪音)只录制少数音符。

最后,纯数字回放有两个用途:回放任意音频录音和进行高端音乐合成,称为采样。采样合成器实际上是基于 RAM 的波表合成器版本。与将数字化乐器存储在 ROM 中不同,采样合成器将它们存储在系统 RAM 中。每当应用程序想要播放某个乐器的特定音符时,系统从系统 RAM 中提取该音符的录音,并将其发送到音频电路进行回放。像波表合成方法一样,采样合成器可以将数字化音符升降八度,但因为系统没有 ROM 中与字节成本相关的限制,音频制造商通常可以录制更多来自现实世界乐器的样本。一般来说,采样合成器提供麦克风输入,因此你可以自己创建样本。这使得你可以例如通过录制一只狗叫声并在合成器上生成几个八度的“狗叫”音符来演奏一首歌。第三方通常会出售包含流行乐器高质量样本的“音色库”。

纯数字回放的另一个用途是作为数字音频录音机。几乎所有现代声卡都有音频输入,理论上可以录制“CD 质量”的立体声音频。^(3) 这使得用户可以录制模拟信号并原样回放,像磁带录音机一样。借助足够的外部设备,甚至可以自己制作音乐录音并刻录自己的音乐 CD,尽管为了做到这一点,你需要比典型的 Sound Blaster 卡更高级的设备——至少像 DigiDesign ProTools HDX 或 M-Audio 系统那样先进的设备。

15.4.2 音频和 MIDI 文件格式

在现代 PC 中,回放声音的标准机制有两种:音频文件回放和 MIDI 文件回放。

音频文件包含数字化的声音样本以供回放。虽然有许多不同的音频文件格式(例如 WAV 和 AIF),基本原理是相同的——文件包含一些头部信息,指定录音格式(如 16 位 44.1 KHz 或 8 位 22 KHz)以及样本的数量,后面跟着实际的声音样本。一些较简单的文件格式允许在正确初始化声卡后,直接将数据传输到典型的声卡;其他格式可能在声卡处理数据之前需要进行少量数据转换。无论哪种情况,音频文件格式本质上是硬件独立版本的数据,通常是你会传输到通用声卡的数据。

声音文件的一个问题是它们可能会变得相当大。一分钟的立体声 CD 质量音频大约需要不到 10MB 的存储空间。一首典型的 3 到 4 分钟的歌曲需要 20MB 到 45MB 之间的存储空间。这样的文件不仅占用了大量的 RAM,还会消耗软件分发文件中相当一部分的存储空间。如果您播放的是您录制的独特音频序列,您别无选择,只能使用这些空间来存储该序列。然而,如果您播放的是由一系列重复声音组成的音频序列,您可以使用采样合成器所用的相同技术,仅存储每个声音的一个实例,然后使用某种索引值来指示您要播放的声音。这可以大大减小音乐文件的大小。

这正是音乐仪器数字接口(MIDI)文件格式的理念所在。MIDI 是一种控制音乐合成和其他设备的标准协议。如果您想播放没有人声或其他非音乐元素的音乐,MIDI 可以非常高效。

MIDI 文件与其存储音频样本不同,它仅指定播放的音乐音符、播放时间、播放时长、使用的乐器等等。由于只需几字节即可指定所有这些信息,因此 MIDI 文件可以非常紧凑地表示一整首歌。高质量的 MIDI 文件通常在 20KB 到 100KB 之间,适用于一首典型的 3 到 4 分钟歌曲。与之对比的是同样时长的音频文件需要 20MB 到 45MB。如今大多数声卡能够通过板载波形合成器或 FM 合成器播放通用 MIDI(GM)文件。大多数合成器制造商使用 GM 标准来控制其设备,因此其使用非常广泛,GM 文件也很容易获得。

MIDI 的一个问题是,播放质量取决于最终用户的声卡质量。一些较昂贵的音频板能够非常好地播放 MIDI 文件,但一些较低成本的音频板——包括不幸的是,许多将音频接口集成在主板上的系统——播放出来的声音就像卡通般的效果。

因此,您需要仔细考虑在应用程序中使用 MIDI。一方面,MIDI 具有文件较小和处理速度更快的优点。另一方面,在某些系统上,音频质量可能非常低,使得您的应用听起来很糟糕。您必须在这些方法的利弊之间找到适合您特定应用的平衡。

由于大多数现代声卡能够播放 CD 质量的录音,你可能会想知道为什么制造商不直接收集一堆样本并模拟这些采样合成器。实际上,他们做了。以 Roland 为例,它提供了虚拟音效画布程序,软件模拟其硬件 Sound Canvas 模块。这些虚拟合成器能够产生非常高质量的输出,但会消耗大量 CPU 能力,从而减少了应用程序可用的处理能力。如果你的应用程序不需要完全的 CPU 功能,这些虚拟合成器提供了一种非常高质量、低成本的解决方案。

如果你知道你的目标受众会使用合成器,另一种解决方案是通过 MIDI 接口端口将外部合成器模块连接到你的 PC,并将 MIDI 数据发送到合成器进行播放。这对于一个面向有限客户群的专业应用来说是一个可接受的解决方案,因为除了音乐人,很少有人会拥有合成器。

15.4.3 音频设备编程

现代应用程序中音频的一个最好的方面是音频的标准化程度非常高。文件格式和音频硬件接口在现代应用程序中非常容易使用。与大多数其他外设一样,很少有现代程序直接控制音频硬件,因为像 Windows 和 Linux 这样的操作系统提供了设备驱动程序来处理这些工作。在典型的 Windows 应用程序中产生声音所需的操作不多,仅需从包含声音信息的文件中读取数据,并将这些数据写入另一个由设备驱动程序使用的文件,后者再与实际的音频硬件进行交互。

在编写基于音频的软件时,另一个需要考虑的问题是你所使用的 CPU 是否具备多媒体扩展。奔腾及之后的 80x86 CPU 提供 MMX、SSE 和 AVX 指令集。其他 CPU 系列也提供类似的指令集扩展(例如,PowerPC 上的 AltiVec 指令或 ARM 上的 NEON)。虽然操作系统可能会在设备驱动程序中使用这些扩展指令,但你也可以在自己的应用程序中使用它们。不幸的是,这通常需要汇编语言编程,因为很少有高级语言能够高效地访问这些指令集。因此,如果你打算进行高性能的多媒体编程,汇编语言可能是你需要学习的内容。有关 Pentium SSE/AVX 指令集的更多细节,参见 汇编语言的艺术

15.5 进一步了解

Axelson, Jan. 并行端口完全手册:编程、接口与使用 PC 的并行打印端口。麦迪逊,威斯康星州:Lakeview 出版社,2000 年。

———. 串口完全手册:RS-232 和 RS-485 链接与网络的编程与电路。麦迪逊,威斯康星州:Lakeview 出版社,2000 年。

Hyde, Randall. 汇编语言的艺术。第二版。旧金山:No Starch Press,2010 年。

第十六章:后记:低级思维,高级编程

Image

本书的目标是让你开始从机器级的角度思考。迫使自己在这个层次上编写代码的一种方法是用汇编语言编写应用程序。当你一条一条地用汇编语言编写代码时,你会对每一条语句的成本有一个相当清晰的了解。

不幸的是,使用汇编语言并不是大多数应用程序的现实解决方案。汇编语言的缺点在过去几十年间已被广泛宣传(甚至夸大),因此许多人决定不再选择汇编语言。

与汇编语言编写代码不同,使用高级语言编写代码并不会强制你在抽象的高层次上思考。在编写高级代码时,完全没有什么能阻止你用低级术语来思考。本书已经为你提供了做这件事所需的背景知识。通过学习计算机如何表示数据,你了解了高级语言数据类型如何映射到机器级别。通过学习 CPU 如何执行机器指令,你了解了各种操作在高级语言应用中的成本。通过学习内存性能,你了解了如何组织你的高级语言变量和其他数据,以最大化缓存和内存访问。现在,这个谜题只剩下最后一块: “究竟如何将某个特定编译器的高级语言语句映射到机器级别?” 这个话题足够庞大,值得另起一本书来讲解。这也是《写出伟大代码》系列第二卷的目的:低级思维,高级编程

WGC2 将从本书的结尾接着讲解。它将教你如何将典型高级语言中的每条语句映射到机器代码,如何在两个或多个高级语言序列之间做出选择,以生成最优的机器代码,并如何分析这些机器代码以评估它的质量以及产生它的高级语言代码的质量。同时,它将帮助你更好地理解编译器的工作原理,并鼓励你协助编译器更好地完成它的工作。

恭喜你在编写伟大代码的进程中取得了如此大的进展。第二卷见。

第十七章:A

ASCII 字符集

二进制 十六进制 十进制 字符
0000_0000 00 0 NULL
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 Line feed
0000_1011 0B 11 CTRL K
0000_1100 0C 12 Form feed
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 CTRL [
0001_1100 1C 28 CTRL \
0001_1101 1D 29 ESC
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
posted @ 2025-12-01 09:45  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报