计算机组成入门指南-全-

计算机组成入门指南(全)

原文:zh.annas-archive.org/md5/3b0d86b0e71058eab4f6050c6f12d1c6

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

图片

本书从程序员的角度介绍了计算机硬件是如何工作的概念。硬件由一组机器指令控制。这些指令控制硬件的方式被称为指令集架构(ISA)。程序员的工作是设计这些指令的序列,使硬件执行操作来解决问题。

几乎所有计算机程序都是用高级语言编写的。部分语言是通用的,其他语言则针对特定应用。但它们的共同目的是为程序员提供一组编程结构,这些结构更适合用人类的方式来解决问题,而不是直接与 ISA 和硬件细节打交道。

本书适合谁阅读

你是否曾想过,当你用高级语言编写程序时,“幕后”发生了什么?你知道计算机可以编程来做决策,但它们是如何做到的呢?你可能知道数据是以位存储的,但当存储一个十进制数字时,这意味着什么?我在本书中的目标是回答这些问题以及更多关于计算机如何工作的疑问。我们将同时探讨硬件组件和用于控制硬件的机器级指令。

我假设你知道如何用高级语言进行编程的基础知识,但你不需要是专家程序员。在讨论硬件组件后,我们将学习并编写大量的汇编语言程序,这是一种直接转化为机器指令的语言。

编写汇编语言是一个繁琐、容易出错、耗时的过程,因此应尽量避免。对于大多数树莓派上的编程项目,最佳语言是 Python,它随树莓派操作系统一起提供,并且在电子项目上有很好的支持。Python 非常适合让我们远离编写汇编语言的繁琐。然而,我们在这里的目标是研究编程概念,而不是创建应用程序,所以我们主要使用 C 作为高级语言。

关于本书

我在编写本书时遵循的指南是:

  • 如果学习建立在你已经知道的概念上,那么学习就会更容易。

  • 现实世界中的硬件和软件为学习理论概念提供了一个更有趣的平台。

  • 用于学习的工具应该是廉价且易于获得的。

本书中的编程

本书基于 AArch64 架构,这是 ARM 架构的 64 位版本。它同时支持 64 位的 A64 和 32 位的 A32 指令集。

本书中的所有编程都使用在 64 位 Raspberry Pi OS 下运行的 GNU 编程环境完成。所有程序都在我的 Raspberry Pi 3 和 Raspberry Pi 5 上进行了测试。第二十章包括一节关于在 Raspberry Pi 5 上进行通用输入/输出(GPIO)引脚汇编语言编程的内容,这与早期的 Raspberry Pi 模型有显著不同。

由于 Python 能很好地将我们与计算机的指令集架构(ISA)隔离,我们使用 C 作为我们的高级语言,部分 C++ 内容出现在第十八章。GNU 编程工具使我们能够轻松地看到 C 和 C++ 如何使用 ISA。如果你不懂 C/C++,也不用担心;我们的 C/C++ 编程非常简单,我会在过程中向你解释所需的知识。

学习汇编语言时有一个重要问题:在应用程序中使用键盘和终端屏幕。通过键盘输入和屏幕输出的编程非常复杂,远超初学者的水平。GNU 编程环境包括 C 标准库。为了符合本书的“现实世界”标准,我们将使用该库中的函数,这些函数可以从汇编语言中轻松调用,用于在我们的应用程序中操作键盘和屏幕。

为什么要阅读本书?

鉴于有许多优秀的高级语言可以让你编写程序,而无需关心机器指令如何控制硬件,你可能会想知道为什么要学习本书中的内容。所有高级语言最终都会被转换为控制硬件的机器指令。理解硬件的工作原理以及指令如何控制硬件,有助于你理解计算机的能力和局限性。我相信这种理解能够让你成为一个更好的程序员,即使你使用的是高级语言。

当然,学习汇编语言还有许多其他原因。如果你的兴趣在于系统编程——编写操作系统的部分功能、编写编译器,甚至设计另一种更高级的语言——这些工作通常需要在汇编语言层面上有所了解。如果你的主要兴趣在硬件方面,我认为理解程序如何使用硬件是很重要的。

嵌入式系统编程中也有许多具有挑战性的机会,嵌入式系统是指计算机有专门任务的系统。这些系统是我们日常生活中的重要组成部分:比如手机、家电、汽车、暖通空调系统、医疗设备等。嵌入式系统是物联网(IoT)技术的核心组件。编程这些系统通常需要理解计算机如何在汇编语言层面与各种硬件设备交互。

最后,如果你已经了解其他处理器的汇编语言,本书将作为你阅读 ARM 手册的入门书。

章节组织

本书大致分为三部分,分别侧重于数学与逻辑、硬件和软件。数学与逻辑部分旨在为您提供讨论概念所需的语言。硬件部分是对构建计算机所用组件的介绍。

前两部分提供了讨论软件如何控制硬件的背景。我们将查看 C 编程语言中的每个基本编程结构,书的最后部分会涉及一些 C++内容。然后,我们将研究编译器如何将 C/C++代码翻译为汇编语言。我还将向您展示程序员如何直接在汇编语言中编写相同的结构。

第一章:设置舞台 介绍计算机的三个基本子系统及其连接方式。本章还讨论了设置本书中使用的编程工具。

第二章:数据存储格式 展示了如何使用二进制和十六进制数字系统存储无符号整数,以及如何使用 ASCII 码存储字符。在本章中,我们将编写第一个 C 程序,并使用gdb调试器来探索这些概念。

第三章:计算机算术 介绍无符号和有符号整数的加法和减法,并解释使用固定数量的位表示整数的限制。

第四章:布尔代数 介绍了布尔代数运算符和函数,并讨论了使用代数工具和卡诺图进行函数最小化。

第五章:逻辑门 以电子学的介绍为开篇,随后讨论了逻辑门及其如何使用互补金属氧化物半导体(CMOS)晶体管构建。

第六章:组合逻辑电路 讨论了没有记忆功能的逻辑电路,包括加法器、解码器、多路复用器和可编程逻辑设备。

第七章:时序逻辑电路 讨论了保持记忆的时钟逻辑电路和非时钟逻辑电路,以及使用状态转换表和状态图进行电路设计。

第八章:内存 介绍了内存层次结构(云存储、大容量存储、主内存、缓存和 CPU 寄存器),并讨论了用于寄存器、SRAM 和 DRAM 的内存硬件设计。

第九章:中央处理单元 概述了 CPU 子系统。本章还解释了指令执行周期和主要的 A64 寄存器,并展示了如何在gdb调试器中查看寄存器内容。

第十章:汇编语言编程 介绍了最小的 C 函数,既有编译器生成的汇编语言,也有直接用汇编语言编写的版本。本章涵盖了汇编指令和第一个指令的使用。我举了一个例子,展示如何使用gdb的文本用户界面作为学习工具。

第十一章:主函数内部  描述了如何在寄存器中传递参数、位置无关代码以及如何使用调用栈传递返回地址和自动局部变量。

第十二章:指令细节  探讨了指令如何在位级别进行编码。本章还讨论了指令所需地址是如何计算的,以及汇编器和链接器程序的算法。

第十三章:控制流结构  介绍了如何在汇编语言中实现程序流控制,包括 whiledo-whileforif-elseswitch 结构。

第十四章:子函数内部  描述了函数如何访问外部变量(全局变量、按值传递、按指针传递和按引用传递),并总结了栈帧的结构。

第十五章:子函数的特殊用途  展示了递归是如何工作的。本章讨论了如何使用汇编语言访问高层语言中无法直接访问的 CPU 硬件特性,通过单独的函数或内联汇编来实现。

第十六章:位运算、乘法和除法指令  描述了位掩码、位移操作以及乘法和除法指令。

第十七章:数据结构  解释了如何在汇编语言层面实现和访问数组和记录(struct)。

第十八章:面向对象编程  展示了如何在 C++ 中将 struct 用作对象。

第十九章:分数数字  描述了定点数和浮点数、IEEE 754 标准以及一些 A64 浮点指令。

第二十章:输入/输出  比较了 I/O 与内存和总线时序,描述了内存映射 I/O,并展示了如何在树莓派上编程 GPIO,既有 C 语言实现,也有汇编语言实现。本章还简要介绍了轮询 I/O 编程,并讨论了中断驱动和直接内存访问 I/O。

第二十一章:异常与中断  简要描述了 AArch64 如何处理异常和中断。本章包括了使用 svc 指令进行系统调用的示例,而不依赖 C 运行时环境。

本书的高效使用方法

我已将本书的内容按照一种方式组织,你应该能够通过遵循几个简单的指南高效地学习这些材料。

许多章节的结尾都有“你的练习”环节,给你提供了练习与章节正文材料相关内容的机会。这些练习是为了帮助你练习,而不是测试。我已经在网上提供了大多数问题的答案和我的解法,网址是 rgplantz.github.io。如果你是使用这本书的讲师,抱歉,你得自己出题!许多练习都有相对明显的扩展,讲师可以用它们来创建课堂作业。

为了高效利用这些练习,我推荐一个迭代的过程:

  1. 尝试自己解决问题。花点时间,但不要让自己卡住太久。

  2. 如果答案没有立即浮现,查看我的解法。在某些情况下,我会在提供完整解法之前给出提示。

  3. 返回到第一步,带着一些关于经验丰富的汇编语言程序员如何接近问题解决方案的知识。

我强烈建议你自己输入代码。这一动手操作将帮助你更快地掌握材料。如果没有其他好处,至少它迫使你阅读代码中的每一个字符。从我的在线解法中复制粘贴代码并没有什么好处;坦白说,本书中的所有程序在现实世界中并没有实际用途。代码是提供给你自己练习的,因此请以此心态来使用它。

这种动手实践的方法同样适用于前几章中的数学内容,其中包括在不同数字进制之间进行转换。任何一款好的计算器都能轻松完成这项任务,但实际的转换并不是重点。重点是学习数据值如何通过位模式表示,使用纸和笔进行计算将帮助你感受这些模式。

我们将在第一章开始,先概述计算机的主要子系统。接着,我将描述如何在我的两台树莓派(3 型和 5 型)上设置编程环境,以便创建和运行本书中的程序。

第一章:设置环境

图片

我们将首先简要概述计算机硬件如何组织成三个子系统。 本章的目标是确保我们有一个共同的框架来讨论事物是如何组织的以及它们是如何相互配合的。在这个框架内,你将学习到如何创建和执行程序。

本书中有相当一部分编程内容。为了帮助你做好准备,本章结尾部分将描述如何设置编程环境,以我的系统为例进行讲解。

计算机子系统

你可以将计算机硬件视为由三个独立的子系统组成:中央处理单元 (CPU)内存输入/输出 (I/O)。这些子系统通过总线连接,如图 1-1 所示。

图片

图 1-1:计算机的子系统

让我们逐一介绍这些元素:

中央处理单元 (CPU)   控制数据在内存和 I/O 设备之间的流动。CPU 对数据执行算术和逻辑操作。它可以根据算术和逻辑操作的结果决定操作的顺序。它包含少量非常快速的内存。

内存   为 CPU 和 I/O 设备提供便于访问的存储空间,用于存储指令和处理的数据。

输入/输出 (I/O)   与外部世界和大容量存储设备(例如磁盘、网络、USB 和打印机)进行通信。

总线   是一条物理通信路径,具有指定如何使用该路径的协议。

如图 1-1 中的箭头所示,信号可以在总线上双向流动。地址总线用于指定内存位置或 I/O 设备。程序数据和程序指令通过数据总线流动。控制总线传输指定如何使用其他总线上的信号的控制信号。

图 1-1 中的总线显示了必须在三个子系统之间传递的信号的逻辑分组。给定的总线实现可能没有为每种类型的信号提供物理上独立的路径。例如,如果你曾经在计算机中安装过显卡,它可能使用了外设组件互联快速(PCI-E)总线。PCI-E 总线上的相同物理连接同时传输地址和数据,但在不同的时间传输。

创建和执行程序

一个程序由一系列存储在内存中的机器指令组成。机器指令使计算机执行特定操作,可以将其视为计算机的原生语言。

当我们创建一个新程序时,我们使用编辑器编写程序的源代码,通常使用高级语言如 Python、Java、C++或 C。Python 仍然是最流行的编程语言之一,也是编程树莓派时最常用的语言。

要在 Python 中创建一个程序,我们使用编辑器编写程序并将其存储在源代码文件中。然后,我们使用python命令来执行我们的程序。例如,要执行名为my_program.py的 Python 程序,我们会使用以下命令:

$ python my_program.py

该命令调用 Python 程序,它是一个解释器,将每条 Python 语言语句翻译成机器指令,并告诉计算机执行它。每次我们想要执行程序时,都需要使用python命令来解释源代码并执行它。

Python 和其他解释型语言做得很好,它们将机器语言隐藏在我们面前。然而,本书的目标是让我们了解程序如何使用机器语言来控制计算机,因此我们将使用 C 语言进行编程,这样更容易让我们看到机器代码。

和 Python 一样,我们使用编辑器编写 C 语言程序并将其存储在源代码文件中。然后,我们使用编译器将 C 源代码翻译成机器语言。与逐条翻译和执行每条语句不同,编译器会在翻译之前考虑源代码文件中的所有语句,以找出最优的机器码翻译方式。最终的机器代码会存储在目标文件中。一个或多个目标文件可以链接在一起,生成一个可执行文件,这就是我们用来运行程序的文件。例如,我们可以使用以下命令编译名为my_program.c的程序:

$ gcc -o my_program my_program.c

为了执行我们的程序,我们使用:

$ ./my_program

如果你不懂 C 语言也不用担心,我会在本书中逐步解释我们所需要的功能。

无论它们来自解释器程序还是可执行文件,组成程序的机器指令都会被加载到内存中。大多数程序还包含一些常量数据,这些数据也会被加载到内存中。CPU 通过读取或获取每条指令来执行程序,并执行它。数据也会根据程序需要被获取。

当 CPU 准备执行程序中的下一条指令时,它会将该指令在内存中的位置放到地址总线上。CPU 还会在控制总线上放置读取信号。内存子系统会响应,通过将指令放到数据总线上,CPU 可以从中复制指令。如果 CPU 被指示从内存中读取数据,也会发生相同的事件顺序。

如果 CPU 被指示将数据存储到内存中,它会将数据放到数据总线上,将数据要存储的位置放到地址总线上,并将写入信号放到控制总线上。内存子系统会通过将数据总线上的数据复制到指定的内存位置来响应。

大多数程序还会访问 I/O 设备。其中一些是与人类互动的设备,如键盘、鼠标或屏幕;另一些是机器可读的 I/O 设备,如磁盘。与 CPU 和内存相比,I/O 设备的速度非常慢,并且它们的时序特性差异很大。由于这些时序特性,I/O 设备与 CPU 和内存之间的数据传输必须显式编程。

编程 I/O 设备需要深入了解设备的工作原理以及它如何与 CPU 和内存交互。我们将在书的后面部分探讨一些通用概念。同时,我们在本书中编写的几乎每个程序都会使用至少一个终端屏幕,它是一个输出设备。操作系统包括执行 I/O 的功能,C 运行时环境提供了一个库,其中包含访问操作系统 I/O 功能的应用程序功能。我们将使用这些 C 库函数执行大部分 I/O 操作,并将 I/O 编程留给更高级的书籍。

这几段旨在为你提供计算机硬件组织的总体概述。在深入探索这些概念之前,下一节将帮助你设置你在本书其余部分编程所需的工具。

编程环境

在这一节中,我将描述如何设置我的树莓派,以便进行本书中描述的所有编程。如果你正在设置树莓派,我还建议你阅读树莓派文档中的“设置你的树莓派”一节,网址是www.raspberrypi.com/documentation/computers/getting-started.html

我使用的是官方支持的操作系统——树莓派操作系统(Raspberry Pi OS),它基于 Debian Linux 发行版。你必须使用 64 位版本来进行本书中的编程,32 位版本将无法使用。其他可用于树莓派的操作系统可能不支持我们将要进行的编程。

树莓派(Raspberry Pi)使用的是 micro SD 卡作为辅助存储,而不是硬盘或固态硬盘。我使用了树莓派映像器(Raspberry Pi Imager)来设置我的 micro SD 卡(可以在www.raspberrypi.com/software/ 下载,并附有简短的视频教程)。运行树莓派映像器时,选择Raspberry Pi OS (其他),然后选择Raspberry Pi OS 完整版(64 位)

完整版本包括你进行本书编程所需的软件工具。你应当使用最新版本,并保持系统更新。这可能会安装比本书编写时可用的版本更新的软件开发工具。你可能会看到与书中的代码列表略有不同,但任何差异应该很小。

Raspberry Pi OS 使用 bash shell 程序来接受键盘命令并将其传递给操作系统。如果你对命令行不熟悉,随着本书的进行,我会向你展示你需要的基本命令。如果你花时间熟悉命令行的使用,你将更加高效。欲了解更多内容,我推荐 William Shotts 的 The Linux Command Line,第二版(No Starch Press,2019)。

你还应该熟悉 Linux 为我们将要使用的编程工具提供的文档。最简单的是大多数程序内置的帮助系统。你可以通过输入程序名称并仅使用 --help 选项来访问帮助。例如,gcc --help 会列出你可以与 gcc 一起使用的命令行选项,并简要描述每个选项的功能。

大多数 Linux 程序都包含手册,通常称为 man 页,它提供比帮助功能更完整的文档。你可以通过使用 man 命令并跟随程序名称来查看。例如,man man 会显示 man 程序的 man 页。

GNU 程序附带更完整的文档,可以使用info程序进行阅读。你可以通过以下命令在你的系统上安装 Raspberry Pi OS info 包:

$ sudo apt install info

安装完成后,你可以通过以下命令查看有关 info 的信息,命令将生成如下所示的输出:

$ info info 
Next: Stand-alone Info,  Up: (dir) 

Stand-alone GNU Info 

** 
This documentation describes the stand-alone Info reader which you can 
use to read Info documentation. 
   If you are new to the Info reader, then you can get started by typing 
'H' for a list of basic key bindings.  You can read through the rest of 
this manual by typing <SPC> and <DEL> (or <Space> and <Backspace>) to 
move forwards and backwards in it. 

* Menu: 

* Stand-alone Info::            What is Info? 
* Invoking Info::               Options you can pass on the command line. 
* Cursor Commands::             Commands which move the cursor within a node. 
* Scrolling Commands::          Commands for reading the text within a node. 
* Node Commands::               Commands for selecting a new node. 
* Searching Commands::          Commands for searching an Info file. 
* Index Commands::              Commands for looking up in indices. 
* Xref Commands::               Commands for selecting cross-references. 
* Window Commands::             Commands which manipulate multiple windows. 
* Printing Nodes::              How to print out the contents of a node. 
* Miscellaneous Commands::      A few commands that defy categorization. 
* Variables::                   How to change the default behavior of Info. 
* Colors and Styles::           Customize the colors used by Info. 
* Custom Key Bindings::         How to define your own key-to-command bindings. 
* Index::                       Global index. 
-----Info: (info-stnd)Top, 31 lines --All------------------------------------- 
--snip--

*开头并以::结尾的项目是指向手册中其他页面的超链接。你可以使用键盘上的箭头键将光标放置在这些项目的任何位置,然后按下 ENTER 键以打开该页面。

为了获得我们将要使用的编程工具的info文档,我需要安装以下 Raspberry Pi OS 包:

binutils-doc 这为 GNU 汇编器 as(有时称为 gas)添加了有用的文档。

gcc-doc 这为 GNU gcc 编译器添加了有用的文档。

你需要的这些功能的包可能会根据你使用的操作系统版本有所不同。

在大多数情况下,我编译程序时没有进行优化(使用了-O0选项),因为目标是学习概念,而不是创建最高效的代码。这些示例应适用于安装了 Raspberry Pi OS 的大多数版本的 gccg++as。然而,编译器生成的机器代码可能会有所不同,具体取决于编译器的配置和版本。在本书进行到一半时,你将开始看到编译器生成的汇编语言。任何差异应在接下来的章节中保持一致。

你需要使用文本编辑器进行编程,而不是使用文字处理软件。文字处理软件会在文本中添加许多隐藏的控制字符来格式化文本。这些隐藏字符会干扰编译器和汇编器,导致它们无法正常工作。

Raspberry Pi 上有几款优秀的文本编辑器,每款编辑器都有自己的特色。我建议你尝试几款,并决定你最喜欢哪一款。Raspberry Pi OS 预装了一些选项。如果你右键点击一个文本文件,你可以选择以下编辑器之一:

Geany 这是默认的编程编辑器。你只需双击一个源代码文件,它就会自动打开。Geany 编辑器在集成开发环境(IDE)中提供了许多有用的功能。

文本编辑器 实际的编辑器是 Mousepad。这是一个非常简洁的编辑器,缺少许多对编写程序代码有用的功能。

Vim Vim 编辑器是 Vi 编辑器的改进版,Vi 编辑器是为 1976 年的 Unix 系统创建的。它提供了一种面向模式的命令行用户界面。文本通过键盘命令进行操作。若干命令将 Vim 切换到“文本插入”模式。按 ESC 键可以返回到命令模式。

Raspberry Pi OS 还预装了 Thonny IDE。它所包含的工具主要用于 Python 编程。

另一个流行的编辑器是 Emacs。你可以使用以下命令在 Raspberry Pi 上安装它:

$ sudo apt install emacs

你可以通过命令行或图形用户界面使用 Emacs。

我最喜欢的编辑器是 Visual Studio Code(VS Code)。VS Code 是免费的,适用于所有常见平台;你可以在 code.visualstudio.com 了解更多信息。它也包含在 Raspberry Pi OS 的软件包库中,可以通过以下命令进行安装:

$ sudo apt install code

安装后,右键点击一个文本文件时会显示 Visual Studio Code。VS Code 使用图形用户界面进行编辑,同时允许你打开终端窗口使用命令行。

这里提到的程序名称包括 geanymousepadvimthonnyemacscode。要通过命令行启动这些编辑器中的任何一个,输入程序名称后跟你想要打开的文件名。例如,你可以使用 VS Code 创建“轮到你了”练习 1.1 中的 Python 程序,命令如下:

$ code hello_world.py

如果文件 hello_world.py 尚不存在,VS Code 在你保存工作时会创建它。如果文件已经存在,VS Code 会打开它供你继续编辑。

我在我的 Windows 11 笔记本上安装了 VS Code。它允许我登录到我的 Raspberry Pi,在编辑面板中完成所有编辑工作,并打开终端面板来编译和执行我的程序。你不需要在 Raspberry Pi 上安装 VS Code。

Geany、Vim 和 VS Code 都是本书中涉及的编程的不错选择。如果你已经习惯使用 Raspberry Pi 上的某个文本编辑器,我建议你继续使用它。不要花太多时间去选择“最佳”编辑器。

轮到你了

1.1 确保你了解本书中将使用的树莓派。它使用什么 CPU?它有多少内存?有哪些 I/O 设备连接到它?你将使用哪个编辑器?

1.2 在一个名为 hello_world.py 的文件中创建以下 Python 程序并执行它:

# Hello, World program
print("Hello, World!")

在这个练习中创建了哪些文件?

1.3 在一个名为 hello_world.c 的文件中编写以下 C 程序,然后编译并执行它:

// Hello, World program 
#include <stdio.h> 
int main(void) 
{ 
    printf("Hello, World!\n"); 
    return 0; 
}

在这个练习中创建了哪些文件?

你学到的内容

中央处理单元(CPU) 控制计算机大多数活动的子系统。它还包含少量非常快速的内存。

内存 提供程序和数据存储的子系统。

输入/输出(I/O) 提供与外部世界和大容量存储设备通信的子系统。

总线 CPU、内存和 I/O 之间的通信通道。

程序执行 了解在程序运行时,三个子系统和总线是如何使用的概况。

编程环境 设置进行本书编程所需工具的示例。

在下一章,你将开始学习数据如何存储在计算机中,了解 C 语言编程,并开始学习如何将调试器作为学习工具使用。

第二章:数据存储格式**

Image

你可能习惯于将计算机视为用于存储程序、文件和图形的硬件设备。在本书中,我们将以不同的方式看待计算机:它们是由数十亿个双态开关和一个或多个控制单元——这些设备能够检测并改变开关的状态。

在第一章中,我们讨论了如何通过输入和输出与计算机外部的世界进行通信。在本章中,我们将开始探索计算机如何对数据进行编码以便存储在内存中,并且我们将编写一些 C 语言程序来探讨这些概念。

开关与开关组

无论你在计算机上做什么——播放视频、发布社交媒体动态、编写程序——都是通过双态开关之间的组合相互作用来完成的。每一种开关组合都表示计算机可能处于的一个状态。如果你想描述计算机上发生的事情,你可以列出一个开关组合。用简单的英语表达,这就像是“第一个开关打开,第二个开关也打开,但第三个开关关闭,而第四个开关打开。”但是以这种方式描述计算机是很困难的,因为现代计算机使用的是数十亿个开关。相反,我们使用一种更简洁的数字表示法。

用位表示开关

你可能熟悉十进制系统,它使用数字 0 到 9 来书写数字。我们希望用数字表示开关,但我们的开关只有 2 种状态,而不是 10 种。在这里,二进制系统——一种使用01的二位系统——就显得非常有用了。

我们将使用二进制数字,通常简称为,来表示开关的状态。一个位可以有两个值:0,表示开关是“关闭”的,和1,表示开关是“打开”的。如果我们愿意,也可以将这些数字的值反过来分配;重要的是我们要保持一致。让我们使用位来简化关于开关的描述:我们有一台计算机,其中第一个、第二个和第四个开关是打开的,第三个开关是关闭的。用二进制表示就是1101

表示位组

即使是二进制,有时我们也会有很多位,导致数字变得难以阅读。在这种情况下,我们使用十六进制数字来表示位模式。十六进制系统有 16 个数字,每个数字可以表示一组 4 个位。表 2-1 显示了 4 位的所有 16 种可能组合及其对应的十六进制数字。使用十六进制一段时间后,你可能会记住这个表格,但如果忘记了,网上搜索一个十六进制转二进制的转换器会很方便。

表 2-1: 4 位的十六进制表示

一个十六进制数字 四个二进制数字(位)
0 0000
1 0001
2 0010
3 0011
4 0100
5 0101
6 0110
7 0111
8 1000
9 1001
a 1010
b 1011
c 1100
d 1101
e 1110
f 1111

使用十六进制表示时,我们可以用一个数字表示1101,即“开,开,关,开”——d[16] = 1101[2]。

注意

当上下文不清晰时,我会在本文中使用下标来表示数字的进制。例如,100[10]表示十进制,100[16]表示十六进制,100[2]表示二进制。

八进制系统基于数字 8,虽然不常见,但你偶尔会遇到它。八个八进制数字从07,每个数字代表一组三个比特。表 2-2 展示了每组三个比特与其对应的单个八进制数字之间的关系。

表 2-2: 3 比特的八进制表示

一个八进制数字 三个二进制数字(比特)
0 000
1 001
2 010
3 011
4 100
5 101
6 110
7 111

例如,我们使用的 4 比特示例1101[2],在八进制中表示为15[8]。

使用十六进制数字

十六进制数字在需要指定一组开关状态时特别方便,比如 16 个或 32 个开关。每四个比特位可以用一个十六进制数字来表示。例如:

6c2a16 = 01101100001010102

0123abcd16 = 000000010010001110101011110011012

单一比特位通常无法用于存储数据。计算机中一次可以访问的最小比特数被定义为字节。在大多数现代计算机中,一个字节由 8 个比特组成,但也有例外。例如,CDC 6000 系列科学主机计算机使用的是 6 位字节。

在 C 和 C++编程语言中,数字前加0x——即零和小写字母x——表示该数字是以十六进制表示的。若数字前仅加0,则表示八进制表示。C++允许通过在数字前加0b来指定二进制值。虽然0b表示二进制的语法不是 C 标准的一部分,但我们的编译器gcc支持这一语法。因此,当我们在本书中编写 C 或 C++代码时,这些都表示相同的含义:

100 = 0x64 = 0144 = 0b01100100

如果你使用不同的 C 编译器,可能无法使用0b语法来指定二进制。

你的回合

2.1 将以下比特模式转换为十六进制:

(a) 0100 0101 0110 0111

(b) 1000 1001 1010 1011

(c) 1111 1110 1101 1100

(d) 0000 0010 0101 0010

2.2 将以下十六进制模式转换为二进制:

(a) 83af

(b) 9001

(c) aaaa

(d) 5555

2.3 以下每个表示多少比特?

(a) ffffffff

(b) 7fff58b7def0

(c) 1111[2]

(d) 1111[16]

2.4 以下每个需要多少个十六进制数字来表示?

(a) 8 比特

(b) 32 比特

(c) 64 比特

(d) 10 比特

(e) 20 比特

(f) 7 比特

二进制与十进制的数学等价性

在上一节中,你学习了二进制数字是如何自然地表示计算机内部开关状态的。你还学到了我们可以用十六进制表示四个开关的状态,通过一个字符。在这一节中,我将向你展示二进制数系统的一些数学性质,以及它如何与我们更为熟悉的十进制(基数 10)数系统互相转换。

了解位置符号系统

按照约定,当我们写数字时,使用位置符号系统。位置符号系统意味着符号的值取决于它在符号组中的位置。在我们熟悉的十进制数系统中,我们使用符号 0、1、...、9 来表示数字。

在数字 50 中,符号 5 的值是 50,因为它位于十位,任何在该位置的数字都会乘以 10。在数字 500 中,符号 5 的值是 500,因为它位于百位。符号 5 在任何位置的值都是一样的,但其值取决于它在数字中所处的位置。

更进一步地,在十进制数系统中,整数 123 意味着

Image

或者:

Image

在这个例子中,最右边的数字 3 是最低有效数字,因为它对数字总值的贡献最小。最左边的数字 1 是最高有效数字,因为它对数字总值的贡献最大。

另一种数字系统

在位置符号系统发明之前,人们使用计数系统来跟踪数量。罗马数字系统就是一种著名的计数系统示例。它使用符号 I 表示 1,V 表示 5,X 表示 10,L 表示 50,等等。要表示两个物品,你只需用两个 I:II。类似地,XX 表示 20 个物品。

罗马数字系统的两个主要规则是,表示较大值的符号排在前面;如果一个表示较小值的符号出现在较大符号之前,则较小符号的值会从紧接其后的较大符号的值中被减去。例如,IV 代表 4,因为 I(1)小于 V(5),所以从 V 的值中减去 I 的值。

罗马数字系统中没有零的符号,因为在计数系统中不需要符号 0。在位置符号系统中,我们需要一个符号来标记该位置没有值,但该位置仍然对表示的值起作用:在 500 中的零告诉我们,十位和个位没有值。只有百位上有一个 5 的值。

位置符号的发明极大简化了算术运算,并导致了我们今天所知的数学。如果你需要说服自己,可以在罗马数字系统中试着将 60(LX)除以 3(III)。(答案:XX。)

十进制数系统的基数进制是 10。这意味着有 10 个符号来表示数字 0 到 9。将一个数字向左移动一位会使其值增加 10 倍。将其向右移动一位会使其值减少 10 倍。位置记数法可以推广到任何基数 r,即

Image

其中数字中有 n 位,每个 d[i] 是一个单独的数字,满足 0 ≤ d[i] < r

这个表达式告诉我们如何确定数字中每一位的值。我们通过从右边开始按顺序计数,从零开始来确定每一位的位置。在每个位置,我们将基数 r 提到该位置的幂次方,然后将其与该数字的值相乘。将所有结果相加,我们就得到了该数字所表示的值。

二进制数系统的进制是 2,因此只有两个符号来表示数字。这意味着 d[i] = 01,我们可以将该表达式写为

Image

其中数字中有 n 位,每个 d[i] = 01

在下一节中,我们将把二进制数转换为无符号十进制数并反向转换。有符号数字可以是正数或负数,但无符号数字没有符号。我们将在第三章中讨论有符号数字。

二进制转换为无符号十进制

你可以通过计算 2 的位置次方,然后将其与该位置上的位值相乘,轻松地将二进制转换为十进制。例如:

Image

使用伪代码,二进制转换为十进制的过程可以总结为:

Let result = 0
Repeat for each i = 0, ..., (n - 1)
    Add di × 2i to result

在每个位位置,算法计算 2^i 并将其与相应的位值(01)相乘。

注意

尽管我们此时只考虑整数,但该算法确实可以推广到小数值。只需将进制 r 的指数继续扩展到负值——即 r^(n – 1)r^(n – 2),...,r⁰r(–*1*)*,*r(–2),... 这将在第十九章中详细讨论。

轮到你了

2.5     查看本节中的广义方程,十进制数 29,458,254 和十六进制数 29458254rn 和每个 d[i] 的值是什么?

2.6     将以下 8 位二进制数转换为十进制:

(a)     1010 1010

(b)     0101 0101

(c)     1111 0000

(d)     0000 1111

(e)     1000 0000

(f)     0110 0011

(g)     0111 1011

(h)     1111 1111

2.7     将以下 16 位二进制数转换为十进制:

(a)     1010 1011 1100 1101

(b)     0001 0011 0011 0100

(c)     1111 1110 1101 1100

(d)     0000 0111 1101 1111

(e)     1000 0000 0000 0000

(f)     0000 0100 0000 0000

(g)     0111 1011 1010 1010

(h)     0011 0000 0011 1001

2.8     开发一个算法将十六进制转为十进制,并将以下 16 位数字转换为十进制:

(a)     a000

(b)     ffff

(c)     0400

(d)     1111

(e)     8888

(f)     0190

(g)     abcd

(h)     5555

无符号十进制转二进制

如果我们想将一个无符号十进制整数 N 转换为二进制,我们将其设置为与之前的二进制数表达式相等,得到方程

Image

其中每个 d[i] = 01。我们将这个方程两边都除以 2,右边每个 2 的指数减 1,得到

Image

其中 N[1] 是整数部分,余数 r[0] 对于偶数为 0,对于奇数为 1。稍作改写,我们得到等效方程:

Image

右侧括号内的所有项都是整数。方程两边的整数部分必须相等,分数部分也必须相等。也就是说,我们得到

Image 和:

Image

因此,你可以看到 d[0] = r[0]。将 r[0] /2(即 d[0]/2)从扩展方程的两边相减,得到:

Image

再次,我们将两边都除以 2:

Image

使用与之前相同的推理,d[1] = r[1]。我们可以通过从右到左反复除以 2,并使用余数作为相应位的值来得到一个数字的二进制表示。这个过程总结在以下算法中,其中斜杠(/)是整数除法运算符,百分号(%)是取模运算符:

quotient = N
i = 0
di = quotient % 2
quotient = quotient / 2
While quotient != 0
    i = i + 1
    di = quotient % 2
    quotient = quotient / 2

一些编程任务需要特定的位模式,例如,编程硬件设备。在这些情况下,指定位模式而不是数值更为自然。我们可以将位分成四组,并使用十六进制表示每一组。例如,如果我们的算法要求使用零和一交替出现的位——0101 0101 0101 0101 0101 0101 0101 0101——我们可以将其转换为十进制值 431,655,765,或者我们也可以用十六进制表示为 0x55555555(在 C/C++ 语法中)。一旦你记住了表 2-1,你会发现使用十六进制表示位模式更加轻松。

本节讨论的内容仅涉及无符号整数。带符号整数的表示依赖于 CPU 的一些架构特性,我们将在第三章中讨论。

轮到你了

2.9     将以下无符号十进制整数转换为其 8 位十六进制表示:

(a)     100

(b)     123

(c)     10

(d)     88

(e)     255

(f)     16

(g)     32

(h)     128

2.10     将以下无符号十进制整数转换为其 16 位十六进制表示:

(a)     1,024

(b)     1,000

(c)     32,768

(d)     32,767

(e)     256

(f)     65,535

(g)     4,660

(h)     43,981

2.11     发明一种代码,允许你存储带有加号或减号的字母成绩(即成绩 A、A–、B+、B、B–、. . . 、D、D–、F)。你的代码需要多少位?

数据存储在内存中

现在我们有了开始讨论数据如何存储在计算机内存中的语言。我们将从内存是如何组织的开始。计算机中用于存储程序指令和数据的内存一般有两种类型:

随机存取内存 (RAM)

一旦一个位(开关)被设置为01,它将保持该状态,直到控制单元主动改变它或电源被切断。控制单元既可以读取位的状态,也可以改变位的状态。

随机存取内存这个名称是误导性的。在这里,随机存取意味着访问内存中的任何字节所需的时间相同,而不是在读取字节时涉及任何随机性。我们将 RAM 与顺序访问内存 (SAM)进行对比,后者访问字节所需的时间取决于其在某个序列中的位置。SAM 的一个例子是磁带,通常用于备份,在那里检索速度并不那么重要。访问字节所需的时间取决于存储在磁带上的字节的物理位置与当前磁带位置的关系。

只读内存 (ROM)

控制单元可以读取 ROM 中每个位的状态,但不能改变它。你可以通过专用硬件重新编程某些类型的 ROM,但当电源关闭时,位将保持在新的状态。ROM 也被称为非易失性内存 (NVM)

内存地址

内存中的每个字节都有一个位置或地址,类似于办公大楼中的房间号。特定字节的地址是固定不变的。也就是说,从内存开始的第 957 个字节将始终是第 957 个字节。然而,任何给定字节中每个位的状态(01)是可以改变的。

计算机科学家通常使用十六进制表示内存中每个字节的地址,编号从零开始。因此,我们可以说第 957 个字节的地址是0x3bc(即十进制的 956)。

内存中的前 16 个字节的地址是0123456789abcdef。使用这种表示法

<address> : <content>

我们可以展示内存中前 16 个字节的内容,如表 2-3 所示(这里的内容是任意的)。

表 2-3: 内存前 16 个字节的示例内容

地址 内容 地址 内容
0x00000000 0x6a 0x00000008 0xf0
0x00000001 0xf0 0x00000009 0x02
0x00000002 0x5e 0x0000000a 0x33
0x00000003 0x00 0x0000000b 0x3c
0x00000004 0xff 0x0000000c 0xc3
0x00000005 0x51 0x0000000d 0x3c
0x00000006 0xcf 0x0000000e 0x55
0x00000007 0x18 0x0000000f 0xaa

每个字节的内容由两个十六进制数字表示,这些数字指定字节的 8 位的精确状态。

但是,字节的 8 位状态能告诉我们什么呢?程序员在将数据存储到内存时,需要考虑两个问题:

存储数据需要多少位?

为了回答这个问题,我们需要知道该数据项允许多少种不同的值。请查看表 2-1(4 位)和表 2-2(3 位)中可以表示的不同值的数量。我们可以用 n 位表示最多 2^n 种不同的值。还要注意的是,我们可能并不会使用在分配空间内所有可能的位模式。

存储数据的代码是什么?

我们日常生活中处理的大多数数据不是以零和一的形式表示的。为了将其存储在计算机内存中,程序员必须决定如何将数据编码成零和一。

在本章的其余部分,你将看到我们如何利用一个或多个字节中位的状态将字符和无符号整数存储到内存中。

字符

当你编程时,几乎总是在操作文本字符串,这些字符串是字符数组。你写的第一个程序可能是一个“Hello, World!”程序。如果你用 C 语言写的,可能会使用类似这样的语句:

printf("Hello, World!\n");

或者,在 C++ 中:

cout < "Hello, World!" < endl;

当将这些语句翻译成机器代码时,编译器必须做两件事:

  • 将每个字符存储在内存中的一个位置,以便控制单元可以访问它们。

  • 生成机器指令将字符写到屏幕上。

我们将从考虑如何在内存中存储一个单独的字符开始。

编码字符

用于计算机存储字符编码的最常见标准是 Unicode UTF-8。它使用 1 到 4 个字节来存储一个称为 代码点 的数字,该数字表示一个字符。一个 Unicode 代码点写作 U+h,其中 h 是四到六个十六进制数字。操作系统和显示硬件将一个或多个代码点与一个 字形 关联,字形就是我们在屏幕上或纸上看到的内容。例如,U+0041 是拉丁大写字母 A 的代码点,而在本书使用的字体中,它对应的字形是 A。

UTF-8 向后兼容一个较旧的标准,即 美国信息交换标准代码,或 ASCII(发音为“ask-ee”)。ASCII 使用 7 位来指定一个包含 128 个字符的字符集中的每个代码点,该字符集包括英语字母(大写和小写)、数字、特殊字符和控制字符。在本书的所有编程中,我们只会使用 UTF-8 的 ASCII 子集中的字符,U+0000 到 U+007F。

表 2-4 展示了用于表示十六进制数字的字符的 Unicode 代码点,以及在我们的编程环境中存储这些字符的对应 8 位模式。稍后在本书中,你将有机会使用这个表格,当你学习如何从整数的字符表示转换为其二进制表示时。目前,注意到虽然数字字符是按照连续的位模式顺序组织的,但它们与字母字符之间有一个间隙。

表 2-4: 一些十六进制字符的 UTF-8 代码点

代码点 字符描述 字符字形 位模式
U+0030 数字零 0 0x30
U+0031 数字一 1 0x31
U+0032 数字二 2 0x32
U+0033 数字三 3 0x33
U+0034 数字四 4 0x34
U+0035 数字五 5 0x35
U+0036 数字六 6 0x36
U+0037 数字七 7 0x37
U+0038 数字八 8 0x38
U+0039 数字九 9 0x39
U+0061 拉丁小写字母 a a 0x61
U+0062 拉丁小写字母 b b 0x62
U+0063 拉丁小写字母 c c 0x63
U+0064 拉丁小写字母 d d 0x64
U+0065 拉丁小写字母 e e 0x65
U+0066 拉丁小写字母 f f 0x66

尽管十六进制数值部分与代码点 U+0000 到 U+007F 的位模式相同,但这并不一定适用于其他字符。例如,U+00B5 是微符号的代码点,它在内存中以 16 位模式 0xc2b5 存储,并且在本书中使用的字体显示为字形 µ

UTF-8 使用每个字符 1 字节来存储代码点 U+0000 到 U+007F。字节中的位 6 和位 5(记住,位从右到左编号,从 0 开始)指定了四个字符组,如 表 2-5 所示。特殊字符大多是标点符号。例如,空格字符是 U+0020,分号字符(;)是 U+003B。

表 2-5: 字符组在代码点 U+0000 到 U+007F 中的分布

位 6 位 5 字符类型
0 0 控制字符
0 1 数字字符和特殊字符
1 0 大写字母和特殊字符
1 1 小写字母和特殊字符

你可以通过在 Linux 终端窗口中输入命令 man ascii 来生成与 ASCII 字符对应的代码点表。 (你可能需要在计算机上安装 ascii 程序。) 这个表非常庞大,不适合记忆,但它有助于大致了解其组织结构。

你可以在 www.unicode.org/releases/ 了解更多关于 Unicode 的信息。若想了解更多关于 Unicode 的形成过程,我推荐 Joel Spolsky 的《每个软件开发者必须绝对了解的 Unicode 和字符集最基本知识(没有借口!)》一文,链接在 www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/

轮到你了

2.12     许多人使用大写字母表示字母的十六进制字符。我所知道的每种编程语言都接受这两种大小写。使用大写十六进制字符的位模式重新做 表 2-4。

2.13     为小写字母字符创建一个 ASCII 表。

2.14     为大写字母字符创建一个 ASCII 表。

2.15     为标点符号创建一个 ASCII 表。

存储文本字符串

回到 Hello, World!\n,编译器将这个文本字符串作为一个常量字符数组存储。为了指定这个数组的范围,C 风格的字符串使用代码点 U+0000(ASCII NUL)作为字符串结尾的 哨兵 值,哨兵值是一个唯一值,用来标示字符序列的结束。因此,编译器必须为这个字符串分配 15 个字节:13 个字节用于 Hello, World!,1 个字节用于换行符 \n,1 个字节用于 NUL。 表 2-6 显示了这个文本字符串如何从内存位置 0x4004a1 开始存储。

表 2-6: Hello, World!\n 存储在内存中

地址 内容 地址 内容
0x4004a1 0x48 0x4004a9 0x6f
0x4004a2 0x65 0x4004aa 0x72
0x4004a3 0x6c 0x4004ab 0x6c
0x4004a4 0x6c 0x4004ac 0x64
0x4004a5 0x6f 0x4004ad 0x21
0x4004a6 0x2c 0x4004ae 0x0a
0x4004a7 0x20 0x4004af 0x00
0x4004a8 0x57

C 使用 U+000A(ASCII LF)作为换行符(在此示例中位于地址 0x4004ae),尽管 C 语法要求程序员写出两个字符 \n。文本字符串以 NUL 字符(位于 0x4004af)结尾。

在 Pascal 语言中,字符串的长度由字符串中的第一个字节指定,该字节被视为一个 8 位无符号整数。(这也是 Pascal 中文本字符串限制为 256 个字符的原因。)C++ 的字符串类有更多功能,但实际的文本字符串仍然存储为 C 风格的文本字符串,嵌套在 C++ 字符串实例中。

无符号整数

由于无符号整数可以用任何进制表示,最明显的存储方式可能就是使用二进制数系统。如果我们从右到左给一个字节的位编号,那么最低位将存储在位 0,接下来的位存储在位 1,以此类推。例如,整数 123[10] = 7b[16],因此它存储的字节状态将是01111011[2]。仅使用一个字节会将无符号整数的范围限制在 0 到 255[10]之间,因为ff[16] = 255[10]。在我们的编程环境中,无符号整数的默认大小是 4 字节,允许的范围是 0 到 4,294,967,295[10]。

使用二进制数系统的一个限制是,在对十进制数进行算术操作之前,你需要将其从字符字符串转换为二进制数系统。例如,十进制数 123 将以字符字符串格式存储为四个字节0x310x320x330x00,而在无符号整数格式中,它将以 4 字节的二进制数0x0000007b存储。在另一端,为了大多数现实世界的显示用途,二进制数需要转换为它们的十进制字符表示。

二进制编码十进制(BCD)是另一种存储整数的编码方式。它为每个十进制数字使用 4 位,如表 2-7 所示。

表 2-7: 二进制编码十进制

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

例如,在一个 16 位的存储位置中,十进制数 1,234 将以 BCD 格式存储为0001 0010 0011 0100(在二进制数系统中,它将是0000 0100 1101 0010)。

由于只有可能的 16 种组合中的 10 种被使用,剩余的六种位模式被浪费了。这意味着,如果我们使用 BCD,一个 16 位的存储位置可以存储 0 到 9,999 之间的值,而如果使用二进制,它的范围则是 0 到 65,535。这是内存利用效率较低的一种方式。另一方面,BCD 在字符格式和整数格式之间的转换更简单,正如你将在第十六章中学到的那样。

BCD 在专门处理数字业务数据的系统中非常重要,因为这些系统通常比执行数学运算更频繁地打印数字。COBOL 是一种面向业务应用的编程语言,支持打包 BCD 格式,其中每个 8 位字节存储两个 BCD 码数字。这里,最后(4 位)的数字用于存储数字的符号,如表 2-8 所示。使用的具体代码取决于实现。

表 2-8: 打包 BCD 格式的示例符号代码

符号 符号代码
+ 1010
1011
+ 1100
1101
+ 1110
无符号 1111

例如,0001 0010 0011 1010 表示 +123,0001 0010 0011 1011 表示 -123,0001 0010 0011 1111 表示 123。

接下来,我们将使用 C 编程语言来探讨这些概念。如果你是 C 新手,这个讨论将为你提供语言的介绍。

使用 C 探索数据格式

在这一节中,我们将使用 C 编程语言编写我们的第一个程序。这些特定的程序展示了数字在内存中存储的方式与我们人类读取它们的方式之间的差异。C 语言使我们能够接近硬件,理解核心概念,同时处理许多底层的细节。你应该不会觉得本书中的简单 C 程序太难,尤其是如果你已经会使用其他语言编程的话。

如果你曾经在更高级的语言中学习编程,如 C++、Java 或 Python,你可能学过面向对象编程。C 不支持面向对象的编程范式;它是一种过程式编程语言。C 程序被分为函数,函数是一个命名的编程语句组。其他编程语言也使用过程子程序这些术语,根据语言的不同,它们之间可能有一些细微的区别。

使用 C 和 C++ I/O 库

大多数高级编程语言都包含一个标准库,可以将其视为语言的一部分。一个标准库包含可以在语言中用于执行常见任务的函数和数据结构,如终端 I/O(向屏幕写入和从键盘读取)。C 包含 C 标准库,C++ 包含 C++ 标准库

C 程序员使用 stdio 库中的函数进行终端 I/O,而 C++ 程序员则使用 iostream 库中的函数。例如,读取键盘输入的整数,给它加 100,并将结果写入屏幕的 C 代码序列如下:

int x;
scanf("%i", &x);
x += 100;
printf("%i", x);

C++ 的代码序列大致如下:

int x;
cin << x;
x +=100;
cout << x;

在这两个示例中,代码从键盘读取字符(每个字符作为一个独立的 char),并将 char 序列转换为相应的 int 格式。然后,它将 int 加 100 后,再将结果转换回 char 序列并在屏幕上显示。前面的 C 或 C++ I/O 库函数完成了 char 序列和 int 存储格式之间的必要转换。

图 2-1 显示了 C 应用程序、I/O 库和操作系统之间的关系。

图片

图 2-1:I/O 库与应用程序和操作系统的关系

当从键盘读取时,scanf库函数首先调用read 系统调用 函数,这是操作系统中的一个函数,用于从键盘读取字符。键盘上的输入是一个字符串,每个字符都是char数据类型。scanf库函数将这个字符串转换为应用程序所需的int数据类型。printf库函数则将int数据类型转换为对应的字符串,并调用write系统调用函数将每个字符写入屏幕。

在图 2-1 中,一个应用程序可以直接调用readwrite函数来传输字符。我们将在第十六章中深入探讨这一点,届时我们将编写自己的转换函数。尽管 C/C++库函数在这方面做得比我们好得多,但亲自实现这个功能将帮助你更好地理解数据是如何存储在内存中并被软件操作的。

注意

即使你熟悉 GNU make 程序,学习如何使用它来构建你的程序也是值得的。此时可能看起来有点多余,但对于简单的程序来说,学习起来更容易。手册可以通过几种格式在 www.gnu.org/software/make/manual/ 获取,我对使用它的评论可以在我的网站上找到,网址是 rgplantz.github.io

编写和执行你的第一个 C 程序

大多数编程书籍从一个简单的程序开始,这个程序仅仅是将“Hello, World!”打印到计算机屏幕上,但我们将从一个程序开始,它读取一个十六进制值,既作为无符号整数,也作为文本字符串(示例 2-1)。

int_and_string.c

➊ // Read and display an integer and a text string.

➋ #include <stdio.h>

   int main(void)
   {
    ➌ unsigned int an_int;
       char a_string[10];

    ➍ printf("Enter a number in hexadecimal: ");
    ➎ scanf("%x", &an_int);
       printf("Enter it again: ");
    ❻ scanf("%s", a_string);
    ❼ printf("The integer is %u and the string is %s\n", an_int, a_string);

    ❽ return 0;
   }

示例 2-1:显示整数和文本字符串之间差异的程序

我们以一些文档开始我们的代码,这些文档简要描述了程序的功能❶。当编写自己的源文件时,您也应当在文档中包括您的名字和编写日期(在本书的示例程序中为了节省空间,我省略了这些内容)。在一行中,紧跟两个斜杠字符//后的所有文本都是注释。C 语言还允许我们使用/*开始一个多行注释,并用*/结束它。注释是给人类读者看的,对程序本身没有任何影响。

实际影响程序的第一个操作是使用 #include 指令包含头文件,即 stdio.h ❷。正如你将要学习的,C 编译器需要知道传递给或从函数返回的每个数据项的类型。头文件用于提供每个函数的原型声明,以指定这些数据类型。stdio.h 头文件定义了 C 标准库中许多函数的接口,告诉编译器在我们的源代码中遇到这些函数的调用时该怎么做。stdio.h 头文件已经安装在你的计算机上,存放在编译器知道的位置。

本清单中的其余代码是 C main 函数的定义。所有 C 程序都由函数组成,这些函数具有以下一般格式:

return-data-type function-name(parameter-list)
{
    function-body
}

当一个 C 程序被执行时,操作系统首先设置一个托管环境独立环境,这会为你的计算机配置资源以运行该程序。托管环境包括对 C 标准库中函数的访问,而独立环境则不包括。书中大部分程序运行在托管环境中。关于如何在第二十一章的“管理调用”部分使用独立环境,我会在第 474 页进行说明。

在 C 托管环境中,程序的执行从 main 函数开始,这意味着你编写的程序必须包含一个名为 main 的函数。main 函数可以调用其他函数,而这些函数又可以调用其他函数。但是,程序控制通常会最终回到 main 函数,然后返回到 C 托管环境。

当在 C 中调用一个函数时,调用函数可以在调用中包含一个参数列表,作为输入传递给被调用的函数。这些输入作为被调用函数中执行计算的参数。例如,在清单 2-1 中,当程序首次启动时,main 函数调用了 printf 函数,并传递了一个参数,即文本字符串 ❹。printf 函数使用这个文本字符串来确定屏幕上显示的内容。在第十四章中,我们将仔细查看参数如何传递给函数,以及它们如何作为参数在函数中使用。清单 2-1 中的 main 函数不需要来自 C 托管环境的数据;我们通过在其定义中使用 void 作为参数列表来表示这一点。

执行完成后,函数通常会返回到调用函数。当返回时,被调用函数可以将一个数据项传递给调用函数。main函数应该返回一个整数值给 C 语言环境,表示程序在执行过程中是否检测到任何错误。因此,main的返回数据类型是int。Listing 2-1 中的main函数将整数 0 返回给 C 语言环境,并将此值传递给操作系统。

在 Listing 2-1 中,我们在main函数的函数体开始部分定义了两个变量:一个无符号整数an_int和一个文本字符串a_string ❸。大多数现代编程语言允许我们在代码的任何地方引入新变量,但 C 语言要求它们必须在函数开始时列出。(这一规则有一些例外,但它们超出了本书的范围。)可以把它当作是在给出如何使用它们的指令之前,先列出食谱中的原料。我们通过引入变量名并指定其数据类型来定义一个变量。[10]符号告诉编译器为a_string变量分配一个长度为 10 的char数组,这样我们就可以存储一个最多 9 个字符的 C 风格文本字符串。(第 10 个char是终止的NUL字符。)我们将在第十七章详细讨论数组。

程序使用 C 标准库中的printf函数在屏幕上显示文本。对printf的调用中的第一个参数是一个格式字符串,它是由普通字符(除了%)组成的文本字符串,用于在屏幕上显示。

printf的最简单格式字符串就是你想打印的文本,没有任何变量。如果你想打印变量的值,格式字符串就充当了你想打印的文本模板。在文本字符串中,你希望打印变量值的位置由一个转换说明符标记。每个转换说明符以%字符开头,变量名按照它们在模板中出现的顺序列出,位于格式字符串后面 ❼。

%字符是每个转换说明符的开头,后面紧跟一个或多个转换代码字符,用以告诉printf如何显示变量的值。表 2-9 展示了printfscanf格式字符串的一些常见转换说明符。

表 2-9: 一些常见的转换说明符

转换说明符 表示方式
%u 无符号十进制整数
%d%i 有符号十进制整数
%f 浮点数
%x 十六进制
%s 文本字符串

转换说明符可以包含其他指定属性的字符,例如显示字段的宽度、值是否在字段内左对齐或右对齐等等。我这里不再详细讲解;要了解更多内容,请阅读printf的 man 页面 3(输入 man 3 printf 命令查看 man 页面)。

调用 C 标准库函数scanf时,第一个参数也是格式字符串。我们在格式字符串中使用相同的转换说明符来告诉scanf函数如何解释键盘输入的字符 ❺。我们通过使用地址运算符(&)来告诉scanf将输入的整数存储在变量&an_int中。当将数组的名称传递给函数时,C 会传递数组的地址,因此在调用scanf读取文本字符串时,我们不需要使用&运算符 ❻。

scanf的格式字符串中,除了这些转换说明符外,格式字符串中的其他字符必须与键盘输入完全匹配。例如,格式字符串

scanf("1 %i and 2 %i", &one_int, &two_int);

需要类似下面的输入:

1 123 and 2 456

这将从键盘读取整数 123 和 456。你可以阅读 man 页面 3 了解scanf的更多信息(输入 man 3 scanf 命令)。

最后,main函数向 C 托管环境返回 0,C 托管环境将此值传递给操作系统。值 0 告诉操作系统一切顺利 ❽。

在我的计算机上编译并运行列表 2-1 中的程序,得到了以下输出:

$ gcc -Wall -o int_and_string int_and_string.c
$ ./int_and_string
Enter a hexadecimal value: 123abc
Enter it again: 123abc
The integer is 1194684 and the string is 123abc
$

列表 2-1 中的程序演示了一个重要概念:十六进制是人类为了表示位模式而使用的便捷方式。一个数字本身并不是二进制、十进制或十六进制的;它仅仅是一个值。并且,可以在这三种数字进制中等价地表示一个特定的值。事实上,它还可以在任何数字进制(2、16、285 等等)中等价表示,但由于计算机由二进制开关组成,因此从二进制数制的角度来看存储在计算机中的数值是有意义的。

轮到你了

2.16     编写一个十六进制到十进制转换的 C 程序。程序将允许用户输入一个十六进制数,并打印出对应的十进制值。输出应该是这样的:0x7b = 123

2.17     编写一个十进制到十六进制转换的 C 程序。程序将允许用户输入一个十进制数,然后打印出对应的十六进制值。输出应该是这样的:123 = 0x7b

2.18     将程序列表 2-1 中最后一个printf语句的%u改为%i。如果输入ffffffff,程序会打印什么?

使用调试器检查内存

现在我们已经开始编写程序,你需要学习如何使用 GNU 调试器gdb。一个调试器是一个允许你在观察和控制程序行为的同时运行程序的工具。当你使用调试器时,它就像你是一个木偶师,而你的程序是一个精心控制的木偶。你主要的控制工具是断点;当你设置一个断点并且程序在运行时遇到它时,程序会暂停并将控制权交还给调试器。控制权交给调试器后,你可以查看程序变量中存储的值,这可以帮助你找出程序中的错误。

如果你觉得这一切显得有些过早——到目前为止我们的程序很简单,似乎不需要调试——我保证,学习如何在简单的例子中使用调试器,要比在一个复杂的且无法运行的程序上学习要好得多。即使你编写的是没有错误的程序,gdb也是学习本书内容的一个有价值的工具。在接下来的gdb会话中,我将展示如何确定一个变量在内存中的存储位置,并查看存储在那里的是十进制值还是十六进制值。你将看到如何在一个运行中的程序上使用gdb,以说明前面几页讨论的概念。

你将在第九章和第十章中看到更多内容,但这里列出的gdb命令足以让你开始使用:

b source_filename:line_number 在指定的 source_filename 文件中的 line_number 行设置一个断点。代码在遇到该断点时会停止运行,并将控制权交回给gdb,让你测试代码中的各种元素。

c 从当前的位置继续程序执行。

h command 获取如何使用命令的帮助。

i r 显示寄存器的内容(info registers)。(你将在第九章中学习 CPU 寄存器。)

l line_number 列出以指定 line_number 为中心的 10 行源代码。

print expression 评估表达式并打印其值。

printf "format", var1, var2, ..., varn 以给定格式显示 var1, var2, ..., varn 的值。"format"字符串遵循与 C 标准库中printf相同的规则。

r 在gdb控制下运行已加载的程序。

x/nfs memory_address 以指定格式 f 和大小 s,从 memory_address 开始显示(检查)内存中的 n 个值。

使用你的调试器

让我们通过使用gdb在清单 2-1 中逐步执行程序,探索到目前为止覆盖的一些概念。阅读时请在你的电脑上跟着操作;当你实际使用gdb时,理解它会更容易。请注意,你在电脑上看到的地址可能与你在这个示例中看到的不同。

我将通过使用gcc命令来编译程序:

$ gcc -g -Wall -o int_and_string int_and_string.c

-g 选项告诉编译器将调试信息包含在可执行程序中。-Wall 选项告诉编译器发出警告,提醒你代码中看似正确的 C 代码可能并非你想要写的内容。例如,它会警告你在函数中声明了一个从未使用过的变量,这可能意味着你忘记了什么。

-o 选项指定输出文件的名称,即可执行程序。

在编译程序后,我可以使用此命令在 gdb 的控制下运行程序:

$ gdb ./int_and_string
--snip--
Reading symbols from ./int_and_string...
(gdb) l
1       // Read and display an integer and a text string.
2
3       #include <stdio.h>
4
5       int main(void)
6       {
7           unsigned int an_int;
8           char a_string[10];
9
10          printf("Enter a number in hexadecimal: ");
(gdb)
11          scanf("%x", &an_int);
12          printf("Enter it again: ");
13          scanf("%s", a_string);
14          
15          printf("The integer is %u and the string is %s\n", an_int, a_string);
16
17          return 0;
18      }
(gdb)

gdb 启动时的消息,我已从之前的输出中删除以节省空间,包含有关调试器的信息,并参考其使用文档。

l 命令列出 10 行源代码,然后将控制权交回给 gdb 程序,显示 (gdb) 提示符。按回车键可以重复上一个命令,l 会显示接下来的(最多)10 行。

断点用于停止程序并将控制权返回给调试器。我喜欢在函数即将调用另一个函数时设置断点,这样我可以检查传递给被调用函数的参数变量的值。此 main 函数在第 15 行调用 printf,所以我在这里设置了断点。由于我已经在查看源代码并希望设置断点的位置,因此不需要指定文件名:

(gdb) b 15
Breakpoint 1 at 0x80c: file int_and_string.c, line 15.

如果 gdb 在执行程序时遇到此语句,它将在执行语句之前暂停,并将控制权交回给调试器。

设置好断点后,我运行程序:

(gdb) r
Starting program: /home/bob/progs/chapter_02/int_and_string/int_and_string
Enter a hexadecimal value: 123abc
Enter it again: 123abc

Breakpoint 1, main () at int_and_string.c:15
15   printf("The integer is %u and the string is %s\n", an_int, a_string);

r 命令从头开始执行程序。当程序到达断点时,控制权返回给 gdb,它将显示下一个准备执行的程序语句。在继续执行之前,我会显示传递给 printf 函数的两个变量的内容:

(gdb) print an_int
$1 = 1194684
(gdb) print a_string
$2 = "123abc\000\000\000"

我们可以使用 print 命令来显示当前存储在变量中的值。gdb 从源代码中知道每个变量的数据类型。它会以十进制显示 int 类型的变量。当显示 char 类型的变量时,gdb 会尽力显示与代码点值相对应的字符图形。如果没有相应的字符图形,gdb 会以 \ 后跟三个八进制数字来显示代码点值(见 表 2-2)。例如,NUL 没有对应的字符图形,所以 gdb 在我输入的文本字符串末尾显示 \000

printf 命令可以格式化显示的值。格式化字符串与 C 标准库中的 printf 函数相同:

(gdb) printf "an_int = %u = %#x\n", an_int, an_int
an_int = 1194684 = 0x123abc
(gdb) printf "a_string = %s\n", a_string
a_string = 123abc

gdb 还提供了一个命令 x,用于直接检查内存内容(即实际的位模式)。它的帮助消息简洁明了,但提供了你所需的所有信息:

(gdb) help x
Examine memory: x/FMT ADDRESS.
ADDRESS is an expression for the memory address to examine.
FMT is a repeat count followed by a format letter and a size letter.
Format letters are o(octal), x(hex), d(decimal), u(unsigned decimal),
 t(binary), f(float), a(address), i(instruction), c(char) and s(string).
Size letters are b(byte), h(halfword), w(word), g(giant, 8 bytes).
The specified number of objects of the specified size are printed
according to the format.
Defaults for format and size letters are those previously used.
Default count is 1\. Default address is following last thing printed
with this command or "print".

x 命令需要提供一个内存区域的地址来显示内容。我们可以使用 print 命令来查找变量的地址:

(gdb) print &an_int
$3 = (unsigned int *) 0x7fffffef7c

我们可以使用x命令以三种不同的方式显示an_int的内容——一个十进制字(1dw),一个十六进制字(1xw),以及四个十六进制字节(4xb)——如下所示:

(gdb) x/1dw 0x7fffffef7c
0x7fffffef7c: 1194684 
(gdb) x/1xw 0x7fffffef7c
0x7fffffef7c: 0x00123abc
(gdb) x/4xb 0x7fffffef7c
0x7fffffef7c: ❶ 0xbc  0x3a     0x12     0x00

注意

一个 单词 的大小取决于你使用的计算机环境。在我们的环境中,它是 4 个字节。

这些四个字节的显示可能看起来乱序了。第一个字节❶位于行左侧显示的地址。行中的下一个字节位于后续地址0x7fffffef7d。因此,该行显示了位于内存地址0x7fffffef7c0x7fffffef7d0x7fffffef7e0x7fffffef7f处的字节,从左到右依次排列,组成了变量an_int。当单独显示这四个字节时,最不重要的字节首先出现在内存中。这就是所谓的小端存储顺序;我将在之后的gdb教程中进一步解释。

我们也可以通过首先获取a_string变量的地址来显示其内容:

(gdb) print &a_string
$4 = (char (*)[10]) 0x7fffffef70

接下来,我们将以两种方式查看a_string的内容,分别是 10 个字符(10c)和 10 个十六进制字节(10xb):

(gdb) x/10c 0x7fffffef70
0x7fffffef70:   49 '1'  50 '2'  51 '3'  97 'a'  98 'b'  99 'c'  0 '\000'  0 '\000'
0x7fffffef78:   0 '\000'  0 '\000'
(gdb) x/10xb 0x7fffffef70
0x7fffffef70:   0x31    0x32    0x33    0x61    0x62    0x63    0x00    0x00
0x7fffffef78:   0x00    0x00

字符显示会以十进制显示每个字符的代码点及其字符字形。十六进制字节显示仅以十六进制显示每个字节的代码点。两种显示都显示了标记我们输入的六个字符字符串结束的NUL字符。由于我们请求显示 10 个字节,剩余的 3 个字节包含与我们的文本字符串无关的随机值,通常称为垃圾值

最后,我继续执行程序并退出gdb

(gdb) c
Continuing.
The integer is 1194684 and the string is 123abc
[Inferior 1 (process 2289) exited normally]
(gdb) q
$

理解内存中的字节存储顺序

完整的 4 字节显示和内存中0x7fffffef7c处整数值的 1 字节显示之间的差异,说明了一个名为字节序(endianess)或字节存储顺序的概念。我们通常从左到右读取数字,左侧的数字比右侧的数字更具意义(占用更多)。

小端存储

数据在内存中存储时,最不 重要的字节在多字节值中位于地址编号最小的地方。也就是说,内存中存储的是“最小”(计数最少)的字节。

当我们逐字节检查内存时,每个字节都会显示在按地址升序排列的内存地址中:

0x7fffffef7c: 0xbc
0x7fffffef7d: 0x3a
0x7fffffef7e: 0x12
0x7fffffef7f: 0x00

初看之下,值似乎是反向存储的,因为值的最不重要的(“小端”)字节首先存储在内存中。当我们命令gdb显示整个 4 字节值时,它知道我们是小端环境,并会重新排列字节的显示顺序,使其正确:

0x7fffffef7c: 0x00123abc
大端存储

数据在内存中存储时, 重要的字节在多字节值中位于地址编号最小的地方。也就是说,内存中存储的是“最大”(计数最多)的字节。

如果我们在一台大端计算机上运行之前的程序,比如使用 PowerPC 架构的计算机,我们将看到以下内容(假设变量位于相同的地址):

(gdb) x/1xw 0x7fffffef7c
0x7ffffff2ec: 0x00123abc
(gdb) x/4xb 0x7fffffef7c   [BIG-ENDIAN COMPUTER, NOT OURS!]
0x7ffffff2ec: 0x00 0x12 0x3a 0xbc

即,在大端计算机中,4 个字节将以以下方式存储:

0x7fffffef7c: 0x00
0x7fffffef7d: 0x12
0x7fffffef7e: 0x3a
0x7fffffef7f: 0xbc

再次,gdb 会知道这是一个大端计算机,因此会以正确的顺序显示完整的 4 字节值。

在绝大多数编程场景中,字节序并不是一个问题。然而,你需要了解它,因为在调试器中检查内存时它可能会让人困惑。字节序也是不同计算机之间通信时的问题。例如,传输控制协议/互联网协议(TCP/IP) 被定义为大端,有时也叫做 网络字节顺序。AArch64 架构中的指令以小端顺序存储。数据可以存储为任意顺序,但我们环境中的默认顺序是小端,操作系统会为互联网通信重新排序字节。如果你为操作系统本身或者可能没有操作系统的嵌入式系统编写通信软件,你也需要了解字节顺序。

你的回合

2.19 在 列表 2-1 中输入程序。用 gdb 跟踪程序。使用你得到的数字,解释 an_inta_string 变量在内存中的存储位置以及每个位置存储的内容。

你学到的内容

比特 计算机是由开/关开关组成的,我们可以用比特表示它们。

十六进制 这是基于 16 的数字系统。每个十六进制数字,0f,代表 4 位。

字节 这是 8 位的一个组。该比特模式可以用两个十六进制数字表示。

十进制与二进制转换 这两种数字系统在数学上是等价的。

内存寻址 内存中的字节按顺序编号(寻址)。字节的地址通常以十六进制表示。

字节序 一个超过 1 字节的整数可以将最高位字节存储在最低字节地址(大端)或将最低位字节存储在最低字节地址(小端)。我们的环境是小端。

UTF-8 编码 这是用于将字符存储在内存中的编码。

字符串 C 风格的字符串是一个由字符组成的数组,以 NUL 字符结束。

printf 这个 C 库函数用于在屏幕上输出格式化的数据。

scanf 这个 C 库函数用于从键盘读取格式化数据。

调试 gdb 调试器可以用来查看变量在程序执行的每一步中如何变化。

在下一章中,你将学习如何在二进制数字系统中进行加法和减法运算,适用于无符号和有符号整数。这将揭示使用固定数量的比特表示数值时可能出现的一些潜在错误。

第三章:计算机算术

Image

计算的现实是我们有有限的比特数。在上一章中,你学到了每个数据项必须适应固定数量的比特,这取决于它的数据类型。本章将向你展示,这一限制甚至使我们最基本的数学运算变得复杂。对于有符号和无符号的数字,有限的比特数是一个我们在纸上或脑中做数学时通常不会想到的约束。

CPU 包括用于一组单比特 状态标志 的内存。它们中有一个 进位标志(C) 和一个 溢出标志(V),使我们能够检测到加法或减法操作后,二进制数的结果超过了分配给数据类型的比特数。我们将在后续章节中深入探讨进位标志和溢出标志,但现在让我们看看加法和减法如何影响它们。

十进制数系统中的无符号整数

当计算机做算术时,它们是在二进制数系统中进行的。这些运算一开始可能看起来很困难,但如果你记得手工做十进制算术的细节,二进制算术就变得容易多了。虽然现在大多数人使用计算器做加法,但回顾手工做加法所需的所有步骤将帮助我们开发出在二进制和十六进制中进行加法和减法的算法。

注意

大多数计算机架构提供其他进制的算术指令,但那些是比较专业的。我们在本书中不会讨论这些。

加法

让我们限制在两位十进制数上。考虑这两个数字,x = 67 和 y = 79。用手工在纸上加法会是这样的:

Image

我们从右侧开始,首先加上个位上的两个十进制数字:7 + 9 = 16,超过了 10,差 6。我们通过在和的个位放一个 6,并将 1 进位到十位上来表示:

Image

接下来,我们加上十位上的三个十进制数字:1(来自个位的进位)+ 6 + 7。三个数字的和超过了 10,差 4,我们通过在十位放一个 4,并记录最终进位为 1 来表示。因为我们只用两位数字,所以没有百位。

以下算法展示了加两个十进制整数,xy 的过程。在这个算法中,x[i]y[i] 分别是 xy 的第 i 位数字,从右到左编号:

Let Carry0 = 0 
Repeat for each i = 0, ..., (N - 1)               // Starting in ones place 

    Sumi = (xi + yi + Carryi) % 10                // Remainder 
    Carryi + 1 = (xi + yi + Carryi) / 10           // Integer division

这个算法之所以有效,是因为我们在写数字时使用了位置表示法;数字向左移动一位,值就增加 10 倍。当前位进位到左边一位时总是 0 或 1。

我们在 /% 操作中使用 10,因为十进制数系统中恰好有 10 个数字:0, 1, 2, . . . , 9。由于我们在 N 位系统中工作,我们将结果限制为 N 位。最终的进位,Carry[N],要么是 0,要么是 1,并且它是结果的一部分,与 N 位的和一起。

减法

对于减法,有时你需要从被减数的下一个高位借位(即被减的数字)。我们将使用之前用过的数字(67 和 79)来进行减法,并分步骤说明,以便你理解这个过程。借位的工作将在两个数字上方的借位行中进行:

图片

首先,我们需要从十位上的 6 借 1,并把它加到个位上的 7。然后,我们就可以从 17 中减去 9,得到 8:

图片

接下来,我们需要从两位数以外的地方借位,我们通过在“进位”位置标记一个 1 来表示。这样我们就得到了十位上的 15,然后从中减去 7:

图片

这在下面的算法中有所体现,其中 x 是被减数,y 是被减数(减数)。如果在这个算法结束时 Borrow 为 1,这表明你必须从两个数的 N 位数以外的地方借位,因此 N 位的结果是错误的。虽然它叫做 进位标志,但它的作用是表明操作结果无法适应数据类型的位数。因此,进位标志在减法操作完成时显示 Borrow 的值(来自超出数据类型大小的部分):

Let Borrow = 0
Repeat for each i = 0, ..., (N - 1)
➊ If y[i] ≤ x[i]
       Let Difference[i] = x[i] - y[i]
   Else
    ➋ Let j = i + 1
    ➌ While (x[j] = 0) and (j < N)
           Add 1 to j
    ➍ If j = N
        ➎ Let Borrow = 1
           Subtract 1 from j
           Add 10 to x[j]
    ❻ While j > i
           Subtract 1 from x[j]
           Subtract 1 from j
           Add 10 to x[j]
    ❼ Let Difference[i] = x[i] - y[i]

这个算法看起来并没有那么复杂(但我花了很长时间才搞明白!)。如果我们正在减的数字与被减数的数字相同或更大❶,我们就完成了该位的操作。否则,我们需要从左边的下一个位借位❷。如果我们尝试从的下一个数字是 0,我们需要继续向左移动,直到找到一个非零数字,或者直到我们到达数字的最左端❸。如果我们达到为数字分配的位数❹,我们通过将 Borrow 设置为 1 来表示这一点❺。

在我们从左边的各位借位之后,我们会回到当前处理的位置❻,并进行减法❼。当你在纸上做减法时,你会自动在脑海中完成这些步骤,但在二进制和十六进制系统中,可能不会那么直观。(我会作弊,把中间的借位写成十进制。)

如果你遇到困难,不用担心。你不需要深入理解这个算法来理解本书中的内容,但我认为通过它可以帮助你学习如何为其他计算问题开发算法。将日常程序转化为编程语言使用的逻辑语句通常是一个困难的任务。

二进制系统中的无符号整数

在本节中,您将学习如何对无符号二进制整数执行加法和减法运算。在继续之前,请仔细查看 表 3-1(特别是二进制位模式)。您可能不会立刻记住这个表,但在您使用二进制和十六进制数字系统一段时间后,您会发现将 10、a1010 看作是相同的数字,只是在不同的数字系统中表示。

表 3-1: 十六进制数字的对应位模式和无符号十进制值

一个十六进制数字 四个二进制位(比特) 无符号十进制
0 0000 0
1 0001 1
2 0010 2
3 0011 3
4 0100 4
5 0101 5
6 0110 6
7 0111 7
8 1000 8
9 1001 9
a 1010 10
b 1011 11
c 1100 12
d 1101 13
e 1110 14
f 1111 15

现在您已经熟悉了 表 3-1,我们来讨论无符号整数。这样做时,请不要忘记,就数字的值而言,无论我们将整数视为十进制、十六进制还是二进制,它们在数学上是等价的。然而,我们可能会想知道计算机在二进制中执行算术时,是否得到与我们使用十进制算术进行相同计算时相同的结果。让我们仔细看一些具体的运算。

加法

在接下来的示例中,我们使用 4 位值。首先,考虑将两个无符号整数 2 和 4 相加:

Image

十进制值 2 在二进制中表示为 0010,而十进制 4 在二进制中表示为 0100。进位标志,或称 C,等于 0,因为加法运算的结果也是 4 位长。我们像在十进制中一样,在相同的位置上进行加法操作(这里以二进制和十六进制显示,进位只在二进制中显示)。

接下来,考虑两个更大的整数。在保持我们 4 位存储空间的情况下,我们将两个无符号整数 4 和 14 相加:

Image

在这种情况下,进位标志等于 1,因为运算结果超过了我们为存储整数分配的 4 位。因此,我们的结果是错误的。如果我们将进位标志包括在结果中,我们将得到一个 5 位值,结果为 10010[2] = 18[10],这是正确的。在这种情况下,我们需要在软件中考虑进位标志。

减法

让我们从 4 中减去 14,或者从1110中减去0100

Image

CPU 可以通过将进位标志设置为1,表示我们必须从 4 位之外借位,这意味着此减法的 4 位结果是错误的。

这些 4 位算术示例可以推广到计算机执行的任何大小的算术运算。AArch64 架构有一个加法指令,当没有最终进位时,它将进位标志设置为0;如果加法结果有最终进位,则将进位标志设置为1。类似地,还有一个减法指令,如果减法不需要借位,它会将进位标志设置为0;如果减法需要借位,则将进位标志设置为1

注意

我们的 C 编译器并没有使用这些加法和减法指令。在执行加法或减法运算时,并没有进位或借位的指示。我们将在第十五章中详细讨论这一点,当时我们会讨论如何将汇编语言嵌入到 C 代码中。

轮到你了

3.1     存储一个十进制数字需要多少位?发明一种用于在 32 位中存储八个十进制数字的编码。使用这种编码,二进制加法是否能产生正确的结果?你在第二章中见过这种编码,并了解了它的有用性。

3.2     开发一个算法,用于在二进制数系统中加法定长整数。

3.3     开发一个算法,用于在十六进制数系统中加法定长整数。

3.4     开发一个算法,用于在二进制数系统中减去定长整数。

3.5     开发一个算法,用于在十六进制数系统中减去定长整数。

加法和减法的有符号整数

当表示非零有符号十进制整数时,有两种可能性:它们可以是正数或负数。由于只有两种选择,我们只需要使用 1 位作为符号位。我们可以使用符号幅度码,通过简单地使用最高位来表示有符号数——比如0表示正,1表示负。但如果这样做,我们会遇到一些问题。举个例子,考虑加法 +2 和 -2:

Image

结果,1100[2],在我们的代码中等于 –4[10],这在算术上是不正确的。我们对于无符号数使用的简单加法方法,在使用符号幅度码时,对于有符号数将无法正确工作。

一些计算机架构在使用有符号十进制整数时确实使用 1 位作为符号位。它们有一个特殊的有符号加法指令来处理类似的情况。(顺便提一句:这类计算机有+0 和–0!)但大多数计算机使用不同的编码方式来表示有符号数,从而允许使用简单的加法指令进行有符号加法。让我们现在来看一下。

理解二的补码

在数学中,补码是指必须加上去才能使其“完整”的数值。当将这一概念应用于数字时,完整的定义取决于你所使用的基数(或进制)以及你允许表示数字的位数。如果x是基数r下的n位数,那么它的基数补码,¬x,被定义为使得x + (¬x) = radixn*,其中*radixn是 1 后跟n个 0。例如,如果我们使用的是两位十进制数,那么 37 的基数补码是 63,因为 37 + 63 = 10² = 100。换句话说,将一个数字与它的基数补码相加会得到 0,进位超出n位数。

另一个有用的概念是缩小基数补码,它的定义是使得x + diminished_radix_complement = radix^n – 1。例如,37 的缩小基数补码是 62,因为 37 + 62 = 10² – 1 = 99。如果你将一个数字与它的缩小基数补码相加,结果是基数中最大位数的n个数字:在这个例子中是两个 9(基数 10 的两位数)。

为了了解基数补码如何表示负数,考虑一个录音带播放器,它播放一盘包含磁带的录音带,磁带在两个卷轴之间来回绕动。

磁带上的音频录音是一个模拟信号,不包含关于磁带位置的信息。许多录音带播放器都有一个四位数的计数器,表示磁带的位置。你可以插入一盘磁带并按下重置按钮,将计数器设为 0000。当你向前或向后移动磁带时,计数器会记录移动。这些计数器提供了磁带位置的“编码”表示,单位是任意的。现在,假设我们可以插入一盘磁带,设法将其移到中心,然后按下重置按钮。向前移动磁带(正方向)将使计数器递增。向后移动磁带(负方向)将使计数器递减。特别地,如果我们从 0000 开始,移动到+1,磁带计数器上的“代码”将显示 0001。另一方面,如果我们从 0000 开始,移动到–1,磁带计数器上的“代码”将显示 9999。

我们可以使用磁带系统执行之前示例中的运算,(+2) + (–2):

  1. 将磁带向前移动到(+2);计数器显示 0002。

  2. 通过将磁带向后移动两步,给计数器加上(–2);现在计数器显示 0000,这在我们的编码中代表 0。

接下来,我们将执行相同的运算,从(–2)开始,然后加上(+2):

  1. 将磁带向后移动到(–2);计数器显示 9998。

  2. 通过将磁带向前移动两步,给计数器加上(+2);现在计数器显示 0000,但有进位(9998 + 2 = 0000,进位 = 1)。

如果我们忽略进位,答案是正确的:9998 是 0002 的 10 的补码(基数为 10)。当使用基数补码表示法加两个带符号整数时,进位是无关紧要的。加两个带符号数字时,结果可能无法适应为存储结果分配的位数,就像无符号数字一样。但是我们的“磁带”示例刚刚说明,进位标志可能不会告诉我们结果无法适应。我们将在下一节讨论这个问题。

计算机使用二进制数字系统,其中的基数是 2。让我们来看一下用于表示带符号整数的 二进制补码 表示法。它使用与带符号十进制整数在位模式中表示的“磁带计数器”相同的通用模式。

表 3-2 显示了 4 位值的十六进制、二进制和带符号十进制(以二进制补码表示法)之间的对应关系。在二进制中,将“磁带”从 0 向后移动一位(负数),从 00001111。在十六进制中,从 0f

表 3-2: 四位二进制补码表示法

一个十六进制数字 四个二进制数字(位) 带符号十进制
8 1000 –8
9 1001 –7
a 1010 –6
b 1011 –5
c 1100 –4
d 1101 –3
e 1110 –2
f 1111 –1
0 0000 0
1 0001 +1
2 0010 +2
3 0011 +3
4 0100 +4
5 0101 +5
6 0110 +6
7 0111 +7

这是关于此表的一些重要观察:

  • 每个正数的高位是 0,每个负数的高位是 1

  • 尽管改变一个数字的符号(即取反)比单纯改变高位更复杂,但通常会将高位称为 符号位

  • 该表示法允许比正数多一个负数。

  • 这种表示法(使用 4 位)能够表示的整数范围 x 为:

Image

或者:

Image

最后的观察可以推广到 n 位的以下公式:

Image

使用二进制补码表示法时,任何 n 位整数 x 的负值定义为:

Image

请注意,2^n 在二进制中写成 1 后跟 n0。换句话说,在 n 位二进制补码表示法中,将一个数加上它的负值会得到 n0 和一个进位 1

计算二进制补码

我们将通过使用二进制补码表示法推导出计算一个数字负值的方法。解出 x 的定义方程,我们得到:

Image

这对数学家来说可能看起来很奇怪,但请记住,在这个方程中,x 被限制为 n 位,而 2^nn + 1 位(1 后跟 n0)。

例如,如果我们想在 8 位二进制中计算–123(使用二的补码表示),我们进行如下算术运算:

Image

这个减法运算容易出错,所以让我们对计算–x的方程式做一些代数处理。我们将两边都减去 1,并稍微调整一下:

Image

这给我们带来了:

Image

如果这看起来比我们的第一个方程式更复杂,不用担心。我们来考虑量 (2^n – 1)。因为 2^n 在二进制中是 1 后跟 n0,所以 (2^n – 1) 就是 n1。例如,对于 n = 8:

Image

因此,我们可以说:

Image

其中 11 . . . 1[2] 表示 n1

虽然这可能一开始不明显,但当你考虑之前在 8 位二进制下计算–123 的示例时,你会发现这个减法是多么简单。设 x = 123,得到:

Image

或者,使用十六进制表示,得到:

Image 因为这里所有量都有 n 位,所以这个计算很简单——只需翻转所有位,得到减小的基数补码,也称为二进制系统中的一的补码1变为00变为1

计算负数所需要做的就是将1加到结果中。最后,我们得到以下结果:

Image

提示

为了重新检查你的算术运算,注意你正在转换的数值是偶数还是奇数。这在所有数字进制中都是相同的。

轮到你了

3.6     开发一个算法,将带符号十进制整数转换为二的补码二进制。

3.7     开发一个算法,将二的补码二进制表示的整数转换为带符号十进制。

3.8     以下 16 位十六进制值以二的补码表示。它们对应的带符号十进制数是什么?

(a)     1234

(b)     ffff

(c)     8000

(d)     7fff

3.9     展示如何将以下每个带符号十进制整数存储为 16 位二的补码表示。请用十六进制给出你的答案:

(a)     +1,024

(b)     –1,024

(c)     –256

(d)     –32,767

二进制中带符号整数的加法与减法

用来表示值的位数是在程序编写时由计算机架构和使用的编程语言决定的。这就是为什么如果结果过大,你不能像在纸上那样增加更多数字(位)。对于无符号整数,解决这个问题的方法是进位标志,它指示两个无符号整数的和是否超过了分配给它的位数。在这一节中,你将学习到,两个带符号数相加也可能会得到超过分配位数的结果,但在这种情况下,只有进位标志并不能指示错误。

CPU 可以通过使用 溢出标志 V 来指示符号数的和超出了它的位数。溢出标志的值是通过一个可能一开始看起来不直观的操作计算得出的:倒数第二个进位和最终进位的 异或 (XOR) 运算。举个例子,假设我们正在相加两个 8 位数 15[16] 和 6f[16]:

Image

在这个例子中,进位是 0,倒数第二个进位是 1V 标志等于最终进位和倒数第二个进位的异或,V = C(penultimate_carry),其中 ⊻ 是异或运算符。在这个例子中,V = 01 = 1

逐一查看,我们会看到为什么 V 标志指示了在二进制补码表示下加法结果的有效性。在接下来的三节中,我们将讨论三种可能的情况:两个数的符号相反,两个数都为正,或者两个数都为负。

符号相反的两个数

x 为负数,y 为正数。我们可以将 xy 表示为二进制如下:

Image

也就是说,一个数的高位(符号位)是 1,而另一个数的高位(符号位)是 0,无论其他位是什么。

x + y 的结果始终保持在二进制补码表示的范围内:

Image

如果我们将 xy 相加,可能会有两种进位结果:

  • 如果倒数第二个进位是 0

Image * 如果倒数第二个进位是 1

Image

相加两个符号相反的整数时,溢出标志 0 总是为零,因此和始终在分配的范围内。

两个正数

如果 xy 都是正数,我们可以将它们表示为二进制如下:

Image

在这里,无论其他位是什么,两个数的高位(符号位)都是 0

再次,如果我们将 xy 相加,可能会有两种进位结果:

  • 如果倒数第二个进位是 0

Image

这个加法产生了 V = 00 = 0。和的高位是 0,因此是正数,并且和在范围内。

  • 如果倒数第二个进位是 1

Image

这个加法产生了 V = 01 = 1。和的高位是 1,因此是负数。两个正数相加不可能得到负数,因此和必定超出了分配的范围。

两个负数

如果 xy 都是负数,我们可以将它们表示为二进制如下:

Image 在这种情况下,两个数的高位(符号位)都是 1,无论其他位是什么。

如果我们将 xy 相加,可能会有两种进位结果:

  • 如果倒数第二个进位是 0Image

    这给出了V = 10 = 1。和的高位是0,所以它是正数。但两个负数相加不能得到正数,因此和已经超出了分配的范围。

  • 如果倒数第二个进位是1Image

    这给出了V = 11 = 0。和的高位是1,所以它是负数,且和在范围内。

我们在这里不会讨论减法。相同的规则也适用,邀请你自行探索。

我们可以根据你刚才学到的内容以及我们在第 40 页《二进制系统中的无符号整数》中所做的内容,陈述以下加法或减法规则:

  • 当程序将结果视为无符号时,进位标志C的值为0,当且仅当结果在n位范围内;V不相关。

  • 当程序将结果视为有符号时,溢出标志V的值为0,当且仅当结果在n位范围内;C不相关。

注意

使用二进制补码表示法意味着 CPU 不需要额外的指令来进行有符号加法和减法,从而简化了硬件。CPU 只看到位模式。AArch64 架构包括加法和减法指令,根据相应二进制运算规则设置 C V ,无论程序如何处理数字。有符号和无符号的区别完全由程序决定。每次加法或减法操作后,程序应检查无符号整数的 C 或有符号整数的 V 的状态,并至少指示和是否出错。许多高级语言不会执行此检查,这可能导致一些难以察觉的程序错误。

整数编码的循环特性

无符号整数和有符号整数使用的符号表示法具有循环特性——对于给定的位数,每个编码“回绕”。图 3-1 使用 3 位数字的“解码环”展示了这一点。

Image

图 3-1:3 位有符号和无符号整数的“解码环”

要使用此解码环加法或减法两个整数,请按照以下步骤操作:

  1. 选择与所用整数类型(有符号或无符号)对应的解码环。

  2. 移动到解码环上对应第一个整数的位置。

  3. 沿着该环移动与第二个整数相等的“辐条”数量。顺时针移动表示加法,逆时针移动表示减法。

如果未超出无符号整数的上限或未超出有符号整数的下限,则结果是正确的。

轮到你了

3.10 使用图 3-1 中的解码环进行以下算术运算。请指出结果是“正确”还是“错误”:

(a) 无符号整数:1 + 3

(b) 无符号整数:3 + 4

(c) 无符号整数:5 + 6

(d) 有符号整数:(+1)+(+3)

(e) 有符号整数:(–3)–(+3)

(f)     有符号整数:(+3)+(–4)

3.11     将下列一对 8 位数字(以十六进制显示)相加,并指明结果是“正确”还是“错误”。首先将它们视为无符号值,然后再视为有符号值(以二补码存储):

(a)     55 + aa

(b)     55 + f0

(c)     80 + 7b

(d)     63 + 7b

(e)     0f + ff

(f)     80 + 80

3.12     将下列一对 16 位数字(以十六进制显示)相加,并指明结果是“正确”还是“错误”。首先将它们视为无符号值,然后再视为有符号值(以二补码存储):

(a)     1234 + edcc

(b)     1234 + fedc

(c)     8000 + 8000

(d)     0400 + ffff

(e)     07d0 + 782f

(f)     8000 + ffff

你所学到的

二进制运算   计算机在二进制数字系统中执行加法和减法。两个数字相加可能会得到一个比每个数字宽 1 位的结果。从一个数字中减去另一个数字可能需要从超出两个数字宽度的 1 位借位。

有符号/无符号表示   位模式可以被视为表示有符号或无符号整数。二补码表示法通常用于表示有符号整数。

进位标志   CPU 包括一个 1 位的进位标志C,它可以显示加法或减法的结果是否超过了无符号整数所允许的位数。

溢出标志   CPU 包括一个 1 位的溢出标志V,它可以显示加法或减法的结果是否超过了使用二补码表示的有符号整数所允许的位数。

在下一章,你将学习如何进行布尔代数。虽然一开始可能觉得有点陌生,但一旦我们开始,你会发现它实际上比初等代数要简单。因为在布尔代数中,所有的结果都只会是01

第四章:布尔代数**

Image

布尔代数由 19 世纪的英国数学家乔治·布尔发展,他致力于用数学严密性解决逻辑问题。他为操作逻辑值建立了一个数学系统,其中变量的唯一可能值为,通常分别表示为10

布尔代数中的基本运算包括合取(AND)、析取(OR)和否定(NOT)。这将其与初等代数区分开来,后者包括实数的无限集合,并使用加法、减法、乘法和除法等算术运算。(指数运算是重复乘法的简化表示法。)

当数学家和逻辑学家在以越来越复杂和抽象的方式扩展布尔代数领域时,工程师们则在学习如何通过电路中的开关来利用电流流动执行逻辑运算。这两个领域并行发展,直到 1930 年代中期,一名研究生克劳德·香农证明了电气开关可以用来实现所有布尔代数表达式的功能。(在描述开关电路时,布尔代数有时被称为开关代数。)香农的发现打开了无限的可能性,布尔代数也因此成为计算机的数学基础。

在本章中,我将从基本布尔运算符的描述开始。接下来,你将学习它们的逻辑规则,这些规则构成了布尔代数的基础。然后,我将解释如何将布尔变量和运算符组合成代数表达式,以形成布尔逻辑函数。最后,我将讨论简化布尔函数的技术。在接下来的章节中,你将学习如何利用电子开关实现逻辑功能,并将这些功能连接到逻辑电路中,以执行计算机的基本功能:算术、逻辑运算和存储。

基本布尔运算符

布尔运算符作用于称为操作数的值或值对。有几个符号用于表示每个布尔运算符,我将在每个运算符的描述中提到。在本书中,我将介绍逻辑学家使用的符号。

我将使用真值表来展示每个运算的结果。真值表显示所有可能的操作数组合的结果。例如,考虑两个比特xy的加法。存在四种可能的值组合,其中一种是加法,结果会有一个和以及一个可能的进位。表 4-1 展示了如何在真值表中表示这一点。

表 4-1: 两比特加法的真值表

x y 进位
0 0 0 0
0 1 0 1
1 0 0 1
1 1 1 0

我还将提供 的电子电路表示,这些门是实现布尔运算符的电子设备。你将在 第五章 到 第八章 中学习更多关于这些设备的内容,在这些章节中,你还将看到物理设备的实际行为与真值表中显示的理想数学行为略有不同。

与初等代数一样,你可以将这些基本运算符结合起来定义二次运算符。你将在本章末尾定义 XOR 运算符时看到一个例子。现在,让我们先来看看这些基本运算符:

与(AND)

与运算符是一个 二元运算符,意味着它作用于两个操作数。仅当 两个 操作数都为 1 时,AND 运算的结果才为 1;否则,结果为 0。在逻辑学中,这个操作被称为 合取。我将用 ∧ 来表示 AND 操作。使用 · 符号或简单地使用 AND 也很常见。

图 4-1 显示了一个与门的电路符号和定义输出的真值表,操作数为 xy

Image

图 4-1:作用于两个变量的与门, x y

如真值表所示,与运算符具有类似于初等代数中乘法的性质,这就是为什么一些人用 · 符号来表示它。

或(OR)

或运算符也是一个二元运算符。如果至少有一个操作数为 1,则 OR 运算的结果为 1;否则,结果为 0。在逻辑学中,这个操作被称为 析取。我将用 ∨ 来表示 OR 操作。使用 + 符号或简单地使用 OR 也很常见。图 4-2 显示了一个或门的电路符号和定义输出的真值表,操作数为 xy

Image

图 4-2:作用于两个变量的或门, x y

真值表显示,或运算符遵循类似于初等代数中加法的规则,这就是为什么一些人用 + 符号来表示它。

非(NOT)

非运算符是一个 一元运算符,它只作用于一个操作数。当操作数为 0 时,NOT 运算的结果为 1;当操作数为 1 时,结果为 0。非操作的其他名称包括 补码反转。我将用 ¬ 来表示 NOT 操作。使用 ' 符号、在变量上方的下划线或简单地使用 NOT 也很常见。图 4-3 显示了一个非门的电路符号和定义输出的真值表,操作数为 x

Image

图 4-3:作用于一个变量的非门, x

如你所见,非运算符具有类似于初等代数中算术取反的性质,但也有一些显著的不同之处。

AND 是乘法性质,OR 是加法性质,这并非偶然。乔治·布尔发展了他的代数,以将数学严谨性应用于逻辑,并使用加法和乘法来操作逻辑语句。他基于使用 AND 进行乘法、OR 进行加法来制定这些规则。在下一节中,你将学习如何使用这些运算符与 NOT 一起表示逻辑语句。

布尔表达式

就像你可以使用初等代数运算符将变量组合成像(x + y)这样的表达式一样,你也可以使用布尔运算符将变量组合成布尔表达式。

然而,有一个显著的区别。布尔表达式是由值(01)和文字构成的。在布尔代数中,文字是指在表达式中使用的单一变量实例或其补集。在表达式中

Image

有三个变量(xyz)以及七个文字。在布尔表达式中,你会发现每个变量都有它的补集形式和非补集形式,因为每种形式都是一个独立的文字。

我们可以使用∧或∨运算符来组合文字。像初等代数一样,布尔代数表达式由组成——由运算符作用于文字的组,例如(xy)或(ab)——运算优先级(或运算顺序)指定在求值表达式时如何应用这些运算符。表 4-2 列出了布尔运算符的优先级规则。与初等代数一样,括号内的表达式优先计算。

表 4-2: 布尔代数运算符的优先级规则

运算 符号 优先级
NOT ¬x 最高
AND xy 中级
OR xy 最低

现在你已经知道了三个基本布尔运算符的工作原理,接下来我们将研究它们在代数表达式中遵循的一些规则。正如你在本章后面将看到的那样,我们可以利用这些规则来简化布尔表达式,从而简化我们在硬件中实现这些表达式的方式。

知道如何简化布尔表达式对于硬件开发者和软件编程者来说都是一项重要工具。计算机仅仅是布尔逻辑的物理表现形式。即使你对编程的唯一兴趣是编写程序,你写下的每一条编程语句最终都是由完全符合布尔代数系统的硬件来执行的。我们的编程语言通过抽象化隐藏了其中的很多细节,但它们仍然使用布尔表达式来实现编程逻辑。

布尔代数规则

当你将布尔代数中的 AND 和 OR 与初等代数中的乘法和加法进行比较时,你会发现布尔代数的一些规则很熟悉,但也有一些显著不同。

与初等代数相同的规则

让我们从相同的规则开始;在下一部分,我们将讨论不同的规则。这些规则如下:

与运算和或运算是结合性的

我们说一个运算符是结合性的,如果我们可以改变在表达式中应用两个或更多次运算符的顺序,而不会改变表达式的值。数学上表示为:

图片

为了证明与运算和或运算的结合性规则,让我们使用穷举真值表,如表 4-3 和表 4-4 所示。表 4-3 列出了变量 xyz 的所有可能值,以及术语 (yz) 和 (xy) 的中间计算结果。在最后两列中,我计算了等式两边每个表达式的值,证明了这两个等式是成立的。

表 4-3: 与运算的结合性

x y z (yz) (xy) x ∧ (yz) (xy) ∧ z
0 0 0 0 0 0 0
0 0 1 0 0 0 0
0 1 0 0 0 0 0
0 1 1 1 0 0 0
1 0 0 0 0 0 0
1 0 1 0 0 0 0
1 1 0 0 1 0 0
1 1 1 1 1 1 1

表 4-4 列出了变量 xyz 的所有可能值,以及术语 (yz) 和 (xy) 的中间计算结果。在最后两列中,我计算了等式两边每个表达式的值,进一步证明了这两个等式是成立的。

表 4-4: 或运算的结合性

x y z (yz) (xy) x ∨ (yz) (xy) ∨ z
0 0 0 0 0 0 0
0 0 1 1 0 1 1
0 1 0 1 1 1 1
0 1 1 1 1 1 1
1 0 0 0 1 1 1
1 0 1 1 1 1 1
1 1 0 1 1 1 1
1 1 1 1 1 1 1

这种策略适用于本节所示的每个规则,但我这里只会通过真值表来展示结合性规则。你将在“你的转机”部分的练习 4.1 中,完成其他规则的真值表。

与运算和或运算具有单位值

一个单位值是特定于某个运算的值,使用该运算对一个具有单位值的量进行运算时,结果是原始量的值。对于与运算(AND)和或运算(OR),单位值分别是 10

图片

与运算和或运算是交换性的

我们可以说,如果可以反转操作数的顺序而不改变操作结果,则该运算符是交换律的:

Image

AND 对 OR 具有分配性

对经过 OR 运算的数量应用 AND 运算符时,可以将 AND 运算符分配到每一个 OR 运算的数量:

Image

与初等代数不同,累加型的 OR 对乘法型的 AND 是分配的。你将在下一节看到这一点。

AND 具有废除值(也称为消亡值)

废除值是指对一个数量进行该废除值操作后,结果仍然是废除值。AND 的废除值是 0

Image 我们习惯于在初等代数中将 0 作为乘法的废除值,但加法没有废除值的概念。你将在下一节了解 OR 运算的废除值。

NOT 表示自反

一个运算符表现出自反性,如果对一个数量应用两次该运算符后,结果仍然是原始数量:

Image

自反性仅仅是双重补集的应用:NOT(NOT true) = true。这类似于初等代数中的双重否定。

与初等代数不同的规则

尽管 AND 是乘法型的,OR 是加法型的,但这些逻辑运算与算术运算之间存在显著差异。这些差异源于布尔代数处理的是评估为真或假的逻辑表达式,而初等代数处理的是无限多的实数集合。在这一节中,你将看到一些可能让你想起初等代数的表达式,但布尔代数的规则不同。那些规则是:

OR 对 AND 具有分配性

对于通过 AND 运算得到的数量,应用 OR 运算符时可以将 OR 运算符分配到每一个 AND 运算的数量:

Image

因为加法在初等代数中不对乘法具有分配性,你可能会忽视这种布尔表达式的操作方式。

首先,我们来看初等代数。使用加法作为 OR,乘法作为 AND 的时候,在之前的方程中我们得到:

Image

当我们代入 x = 1,y = 2 和 z = 3 时,左边的表达式得到:

1 + (2 · 3) = 7

然后,右边的表达式得到:

(1 + 2) · (1 + 3) = 12

因此,在初等代数中,加法对于乘法具有分配性。

用真值表展示 OR 在布尔代数中对 AND 的分配性是最好的方法,如 表 4-5 所示。

表 4-5: OR 对 AND 的分配性

x y z x ∨ (yz) (xy) ∧ (xz)
0 0 0 0 0
0 0 1 0 0
0 1 0 0 0
0 1 1 1 1
1 0 0 1 1
1 0 1 1 1
1 1 0 1 1
1 1 1 1 1

比较两个右侧列,你可以看出,将变量x与每个 AND 操作的变量yz进行或(OR)运算,得到的结果与将x与每个变量进行或(OR)运算,再将两个或(OR)结果进行与(AND)运算的结果相同。因此,分配律成立。

或(OR)有一个消除值(也称为湮灭值)

在初等代数中加法没有消除值,但在布尔代数中,或(OR)的消除值是1

图片

与(AND)和或(OR)都有补值

补值是变量的减基补值。你在第三章中学到,某个量与该量的减基补值的和等于(基数 – 1)。由于布尔代数中的基数为 2,0的补值是11的补值是0。因此,布尔量的补值就是该量的非(NOT),即:

图片

补值展示了与(AND)和或(OR)逻辑运算与乘法和加法算术运算之间的一个区别。在初等代数中:

图片

即使我们将x限制为 0 或 1,在初等代数中,1 · (–1) = –1,1 + (–1) = 0。

与(AND)和或(OR)是幂等的

如果一个运算符是幂等的,将其应用于两个相同的操作数会得到该操作数。换句话说:

图片

这与初等代数中的情况不同,在初等代数中,重复将一个数乘以它本身是指数运算,重复将一个数加到它本身则相当于乘法。

德摩根定律适用

在布尔代数中,与(AND)和或(OR)运算之间的特殊关系由德摩根定律表达,该定律指出:

图片

第一个方程表示两个布尔量的与(AND)的非(NOT)等于这两个量的非(NOT)的或(OR)。第二个方程表示两个布尔量的或(OR)的非(NOT)等于这两个量的非(NOT)的与(AND)。

这种关系是对偶原则的一个例子,在布尔代数中,若你将每个0替换为1,每个1替换为0,每个与(AND)替换为或(OR),每个或(OR)替换为与(AND),该等式仍然成立。回顾刚刚给出的规则,你会发现除了自反外,所有规则都有对偶运算。德摩根定律是对偶的最佳例子之一;你将在完成“你的练习”4.2 节练习时看到这一原则的应用。

你的练习

4.1     使用真值表证明本节中给出的布尔代数规则。

4.2     证明德摩根定律。

布尔函数

计算机的功能基于布尔逻辑,这意味着计算机的各种操作是由布尔函数指定的。布尔函数看起来有点像初等代数中的函数,但变量可以是未补充或已补充的形式。变量和常量通过布尔运算符连接。布尔函数的结果只能是 10(真或假)。

当我们在第 41 页的第三章讨论二进制数系统中的加法时,你会看到,在加两个位,xy 时,我们必须在计算中考虑可能的进位到它们的位位置。导致进位为 1 的条件是:

当前位位置没有进位,x = 1,且 y = 1,或者

当前位位置有进位,x = 0,且 y = 1,或者

当前位位置有进位,x = 1,且 y = 0,或者

当前位位置有进位,x = 1,且 y = 1

我们可以用布尔函数更简洁地表示这个

Image

其中 x 是一个位,y 是另一个位,c[in] 是来自下一位位置的进位,C[out](c[in], x, y) 是当前位位置加法的进位。我们将在本节中使用这个方程,但首先,让我们思考一下布尔函数和初等代数函数之间的区别。

像初等代数函数一样,布尔代数函数也可以进行数学操作,但数学运算是不同的。初等代数的运算是在无限的实数集上进行的,而布尔函数只在两个可能的值 01 上操作。初等代数函数可以评估为任意实数,但布尔函数只能评估为 01

这个差异意味着我们必须以不同的方式思考布尔函数。例如,看看这个初等代数函数:

Image

你可能会这样理解:“如果我将 x 的值乘以 y 的负值,我将得到 F(x, y) 的值。”然而,对于布尔函数

Image

只有四种可能性。如果 x = 1 且 y = 0,那么 F(x, y) = 1;对于其他三种情况,F(x, y) = 0。而你可以将任何数字代入初等代数函数,但布尔代数函数会告诉你哪些变量值导致该函数的结果为 1。我认为初等代数函数是在要求我代入变量值进行评估,而布尔代数函数则是告诉我哪些变量值会使函数结果为 1

也有更简单的方式来表达进位的条件,这些简化使得我们能够用更少的逻辑门实现布尔进位函数,从而降低成本和功耗。在本节及接下来的章节中,你将了解布尔代数的数学性质是如何让函数简化变得更加容易和简洁的。

规范和或最小项之和

布尔函数的规范形式明确显示了在定义该函数的每个项中,问题中的每个变量是否取反,就像我们之前用简单语言描述产生进位为1的条件一样。这确保了你已经考虑了函数定义中的所有可能组合。

我们将使用第 62 页的进位方程来说明这些概念。尽管方程中的括号不是必需的,但我已将它们添加以帮助你看到方程的形式。括号显示了四个乘积项,这些项中的所有字面量仅通过与运算进行操作。然后将这四个乘积项进行或运算。由于或运算类似于加法,因此右侧被称为积和。它也被称为析取标准形式

让我们更仔细地看看这些乘积项。每个乘积项都包含了这个方程中所有变量的字面量(正向或取反形式)。一个有 n 个变量的方程有 2^n种变量值的排列;一个最小项是一个乘积项,准确地指定了其中一种排列。由于有四种 c[in]xy 的取值组合可以产生进位 1,因此前面的方程有四个可能的八个最小项。一种通过将所有评估为1的最小项求和(或运算)定义的布尔函数,被称为规范和最小项之和,或完整析取标准形式。由最小项之和定义的函数当至少有一个最小项的值为1时,函数的值也为1

对于每个最小项,恰好有一组变量的取值使得该最小项的值为1。例如,前一个方程中的最小项(c[in]x ∧ ¬y)仅在 c[in] = 1x = 1y = 0 时取值为1。一个不包含问题中所有变量(无论是正向还是取反形式)的乘积项,将总是对于更多的变量取值组合评估为1,而不是最小项。例如,(c[in]x) 对于 c[in] = 1x = 1y = 0c[in] = 1x = 1y = 1 时都取值为1。(我们称它们为最小项,因为它们最小化了评估为1的情况数。)

与其列出一个函数中所有的文字常量,逻辑设计师通常使用符号m[i]来指定第i个最小项,其中i是通过将文字常量按顺序排列并将其视为二进制数字时,表示该数字的整数。例如,c[in] = 1x = 1y = 0,得到的二进制数为110,即十进制数 6;因此,该最小项是m[6]。

表 4-6 显示了指定进位的三变量函数的所有八个可能最小项。

表 4-6: 使进位为 1 的条件

c[in] x y 最小项 C[out](c[in], x, y**)
0 0 0 m[0]     (¬c[in] ∧ ¬x ∧ ¬y) 0
0 0 1 m[1]     (¬c[in] ∧ ¬xy) 0
0 1 0 m[2]     (¬c[in]x ∧ ¬y) 0
0 1 1 m[3]     (¬c[in]xy) 1
1 0 0 m[4]     (c[in] ∧ ¬x ∧ ¬y) 0
1 0 1 m[5]     (c[in] ∧ ¬xy) 1
1 1 0 m[6]     (c[in]x ∧ ¬y) 1
1 1 1 m[7]     (c[in]x ∧ y) 1

C[out](c[in], x, y)列显示了在我们的方程中,哪些最小项使其值为1

使用这种符号将布尔方程写为标准和并式,并使用∑符号表示求和,我们可以将进位函数重新表述为:

Image

我们这里只是一个简单的例子。对于更复杂的函数,列出所有最小项容易出错。简化后的符号更易于操作,且有助于避免出错。

标准积或最大项积

根据可用组件和个人选择等因素,设计师可能更倾向于处理当函数值为0而非1的情况。在我们的示例中,这意味着设计中指定了进位补码为0的情况。为了了解这个过程,接下来我们对指定进位的方程两边取补,运用德摩根定律:

Image

因为我们对方程两边取了补,所以现在得到的是¬C[out]的布尔方程,即进位的补码。因此,我们需要寻找使¬C[out]的值为0而非1的条件。在这个方程中,由于布尔运算符的优先级规则,括号是必需的。括号表示四个和项,即所有文字常量仅通过“或”运算进行处理的项。然后,四个和项通过“与”运算相乘。由于“与”运算类似于乘法,因此右侧称为和项的积,也可以说是合取标准形式

每个和项都包含该方程中的所有变量,以文字形式(无补充或补充形式)。与 最小项 是指定变量的 2^n 种排列中的单一排列的 项不同,最大项 是指定这些排列之一的 项。通过将所有计算结果为 0 的最大项相乘(AND 运算),定义的布尔函数称为 规范积,即 最大项积完全合取范式

每个最大项都精确地标识出使该项在 OR 连接时计算结果为 0 的一组变量值。例如,前面方程中的最大项 (¬c[in] ∨ ¬xy) 只有在 c[in] = 1x = 1y = 0 时计算结果为 0。但是,如果和项中没有包含问题中的所有变量,无论是原始形式还是补充形式,它总是会对多个变量值集合计算出 0。例如,和项 (¬c[in] ∨ ¬x) 对于此示例中的三个变量的两组值计算出 0,即 c[in] = 1x = 1y = 0c[in] = 1x = 1y = 1。(我们称它们为 最大 项,因为它们最小化了计算为 0 的情况数量,从而 最大化 了计算为 1 的情况数量。)

与其写出函数中的所有文字,逻辑设计师通常使用符号 M[i] 来指定第 i 个最大项,其中 i 是由问题中文字值拼接而成的二进制数的整数值。例如,将 c[in] = 1x = 1y = 0 拼接起来得到 110,这就是最大项 M[6]。表 4-7 中的真值表显示了导致进位为 0 的最大项。请注意,当 c[in] = 1x = 1y = 0 时,最大项 M[6] = (¬c[in] ∨ ¬xy) 计算结果为 0

表 4-7 显示了指定进位补码的三变量函数的所有八个可能的最大项。

表 4-7: 使进位补码为 0 的条件

c[in] x y 最大项 ¬C[out](c[in], x, y)
0 0 0 M[0]     (c[in]xy) 1
0 0 1 M[1]     (c[in]x ∨ ¬y) 1
0 1 0 M[2]     (c[in] ∨ ¬xy) 1
0 1 1 M[3]     (c[in] ∨ ¬x ∨ ¬y) 0
1 0 0 M[4]     (¬c[in]xy) 1
1 0 1 M[5]     (¬c[in]x ∨ ¬y) 0
1 1 0 M[6]     (¬c[in] ∨ ¬xy) 0
1 1 1 M[7]     (¬c[in] ∨ ¬x ∨ ¬y) 0

¬C[out](c[in], x, y) 列显示了我们的方程中哪些最大项导致其计算结果为 0

使用这种符号表示法将布尔方程写成标准积形式,并使用∏符号表示乘法,我们可以将进位补函数重新表述为:

Image

在表 4-7 中,你会看到这些条件导致进位补值为0,从而使进位为1。这表明,使用最小项或最大项是等效的。

标准布尔形式的比较

表 4-8 显示了三变量问题的所有最小项和最大项。比较对应的最小项和最大项揭示了最小项和最大项的对偶性:可以通过使用德摩根定律,将每个变量取反并互换或门和与门来从另一项构造出对偶。

表 4-8: 三变量问题的标准项

最小项 = 1 x y z 最大项 = 0
m[0]     ¬x ∧ ¬y ∧ ¬z 0 0 0 M[0]     xyz
m[1]     ¬x ∧ ¬yz 0 0 1 M[1]     xy ∨ ¬z
m[2]     ¬xy ∧ ¬z 0 1 0 M[2]     x ∨ ¬yz
m[3]     ¬xyz 0 1 1 M[3]     x ∨ ¬y ∨ ¬z
m[4]     x ∧ ¬y ∧ ¬z 1 0 0 M[4]     ¬xyz
m[5]     x ∧ ¬yz 1 0 1 M[5]     ¬xy ∨ ¬z
m[6]     xy ∧ ¬z 1 1 0 M[6]     ¬x ∨ ¬yz
m[7]     xyz 1 1 1 M[7]     ¬x ∨ ¬y ∨ ¬z

图 4-4 中的维恩图提供了使用最小项和最大项这两个术语的原因的图示。

Image

图 4-4:三变量的最小项和最大项之间的关系

标准形式给出了函数的完整且唯一的陈述,因为它们考虑了变量值的所有可能组合。然而,通常有更简化的解决方案。本章的其余部分将致力于布尔函数简化的方法。

布尔表达式简化

在硬件中实现布尔函数时,每个∧运算符表示一个与门(AND gate),每个∨运算符表示一个或门(OR gate),每个¬运算符表示一个非门(NOT gate)。通常,硬件的复杂性与使用的与门和或门的数量有关(非门较简单,通常不会显著增加复杂性)。更简化的硬件使用更少的组件,从而节省成本和空间,并减少功耗。这些节省在手持设备和可穿戴设备中尤为重要。本节将介绍如何操作布尔表达式,以减少与门和或门的数量,从而简化硬件实现。

最简表达式

在简化函数时,从规范形式之一开始,以确保你已考虑到所有可能的情况。为了将问题转化为规范形式,创建一个列出问题中所有可能组合的真值表。从真值表中,列出定义函数的最小项或最大项将变得容易。

拥有规范表达式后,下一步是寻找一个功能等价的最小表达式,这是一个使用最少字面量和布尔运算符实现与规范表达式相同功能的表达式。为了简化表达式,我们应用布尔代数规则,以减少项数和每个项中的字面量数量,而不改变表达式的逻辑意义。

最小表达式有两种类型,具体取决于是使用最小项(minterms)还是最大项(maxterms):

最小和积

当从最小项描述问题时,最小表达式,称为 最小和积,是一个和积表达式,其中所有其他在数学上等价的和积表达式至少有与其相同数量的乘积项,且具有相同数量乘积项的表达式至少有与其相同数量的字面量。作为最小和积的一个例子,考虑以下方程:

Image

S 处于规范形式,因为每一个乘积项都明确显示了三个变量的贡献。其他三个函数是 S 的简化形式。尽管这三个函数的乘积项数量相同,S3S 的最小和积,因为它的乘积项比 S1S2 包含更少的字面量。

最小积和

当从最大项描述问题时,最小表达式,称为 最小积和,是一个积和表达式,其中所有其他在数学上等价的积和表达式至少有与其相同数量的和项,且具有相同数量和项的表达式至少有与其相同数量的字面量。作为最小积和的一个例子,考虑以下方程:

Image P 处于规范形式,其他三个函数是 P 的简化形式。尽管这三个函数的和项数量与 P 相同,P3P 的最小积和,因为它的乘积项比 P1P2 包含更少的字面量。

一个问题可能有多个最小解。良好的硬件设计通常涉及找到多个最小解,并在可用硬件的背景下评估每个解。这不仅仅是使用更少的门电路;例如,当我们讨论硬件实现时,你会学到,合理放置的非门(NOT gate)可以减少硬件复杂度。

在接下来的两部分中,你将学习找到最小表达式的两种方法。

使用代数运算进行最小化

为了说明简化布尔函数复杂性的重要性,让我们回到进位函数:

Image

方程右侧的表达式是最小项的和。图 4-5 展示了实现此功能的电路。它需要四个与门和一个或门。与门输入端的小圆圈表示该输入处有一个非门。

Image

图 4-5:生成加法两个数字时的进位值的硬件实现

让我们尝试简化在图 4-5 中实现的布尔表达式,看看是否能减少硬件需求。请注意,可能没有单一的解决路径,也可能有多个正确的解决方案。我这里仅展示一种方式。

首先,我们将做一些看起来可能有些奇怪的事情。我们将使用幂等性规则,将第四项复制两次:

Image 接下来,我们将稍微调整乘积项,以便将三个原始项中的每一项与(c[in]xy)进行或运算:

Image

现在,我们可以使用与或分配规则,将与运算分配到或运算上,提取出与1的项:

Image

图 4-6 展示了这个功能的电路。我们不仅消除了一个与门,而且每个与门和或门的输入减少了一个。

Image

图 4-6:简化的硬件实现,用于生成加法两个数字时的进位值

比较图 4-5 和图 4-6 中的电路,我们可以看到布尔代数帮助我们简化了硬件实现。这种简化来自于用通俗的语言表达出导致进位为1的条件。方程的原始标准形式表示,当满足以下四种情况时,进位C[out](c[in], x, y)将为1

如果c[in] = 0x = 1,且y = 1

如果c[in] = 1x = 0,且y = 1

如果c[in] = 1x = 1,且y = 0

如果c[in] = 1x = 1,且y = 1

最小化可以更简洁地表述:当c[in]xy中至少有两个为1时,进位为1

注意

这些指定加法进位的逻辑电路和第三章中的减法算法示例,展示了编程错误的常见来源之一。指定看似非常简单的活动,将其转换为计算机可以执行的简单逻辑步骤,可能是一个非常繁琐且容易出错的过程。

我们通过从最小项和的形式开始,得到了 图 4-6 中的解;换句话说,我们处理的是生成进位 1c[in]xy 的值。正如你在“标准积项或最大项积项”一节中所看到的 第 64 页,由于进位必须为 10,从生成进位补码 0c[in]xy 的值出发,并将方程写成最大项的积项也是完全有效的。

Image

为了简化这个方程,我们将采取与最小项和相同的方法,首先将最后一项复制两次,得到:

Image

加入一些括号有助于澄清简化过程:

Image

接下来,我们将使用 OR 对 AND 的分配。因为这比较复杂,我会详细讲解如何简化该方程中第一组积项的步骤;其他两个分组的步骤类似。OR 对 AND 的分配具有如下通用形式:

Image

我们第一组中的和项共享一个 (¬x ∨ ¬y),因此我们将在通用形式中进行以下替换:

Image

进行替换并使用 AND 的补码规则,我们得到:

Image

并且对其他两个分组应用相同的运算,我们得到:

Image

图 4-7 显示了该功能的电路实现。该电路生成进位的补码。我们需要对输出 ¬C[out](c[in], x, y) 进行补码运算,以获得进位的值。

Image

图 4-7:当加法运算中生成进位的补码时,简化的硬件实现

如果你比较 图 4-7 和 图 4-6,你可以从图形上看到德摩根定律,其中 OR 被转化为 AND,且输入值被取补。你可能会觉得 图 4-6 中的电路看起来更简单,因为 图 4-7 中的电路需要在六个输入 OR 门上加上 NOT 门。但正如你将在下一章中学到的那样,由于构建逻辑门的设备本身的电子特性,情况可能并非如此。

这里需要理解的一个重要观点是,解决问题的方法不止一种。硬件工程师的工作之一就是根据成本、组件可用性等因素,决定哪种解决方案最好。

使用卡诺图最小化

用于最小化布尔函数的代数运算可能并不总是显而易见的。你可能会发现使用逻辑语句的图形表示更容易理解。

一个常用的图形工具用于处理布尔函数的是 卡诺图,也称为 K-map。卡诺图由摩里斯·卡诺(Maurice Karnaugh)于 1953 年发明,他是贝尔实验室的通信工程师,提供了一种可视化的方式来找到与代数方法相同的简化方式。它可以与乘积和(使用最小项)或和之积(使用最大项)一起使用。这里,我将展示它们的工作原理,从最小项开始。

使用卡诺图简化乘积和之和

卡诺图是一个矩形网格,每个最小项对应一个单元格。对于 n 个变量,共有 2^n 个单元格。图 4-8 是一个显示两个变量 xy 的所有四个可能最小项的卡诺图。纵轴用于绘制 x,横轴用于绘制 y。每行的 x 值通过紧邻行左侧的数字(01)显示,每列的 y 值显示在列顶部。

Image

图 4-8:两变量最小项在卡诺图中的映射

为了说明如何使用卡诺图,我们来看一个两个变量的任意函数:

Image

首先,在与方程中出现的每个最小项对应的单元格中放置1,如图 4-9 所示。

Image

图 4-9:任意函数 F(x*, y) 的卡诺图

在与每个求值为1的最小项对应的单元格中放置1,图示了方程何时求值为1。右侧的两个单元格对应最小项m[1]和m[3],(¬xy)和(xy)。由于这些项是按“或”运算组合的,只要其中一个最小项求值为1F(x, y) 就求值为1。使用分配律和补充规则,我们得到结果:

Image

这在代数上显示,当 y1时,F(x, y) 的值为1,接下来通过简化此卡诺图你会看到这一点。

两个最小项(¬xy)和(xy)之间的唯一区别是从 x 变为 ¬x。卡诺图的排列方式使得仅有一个变量在共享边的两个单元格之间发生变化,这一要求称为邻接规则

要使用卡诺图进行简化,您需要在和积卡诺图中将两个相邻的单元分组,这两个单元的值为1。然后,消除它们之间不同的变量,并将这两个积项合并在一起。重复此过程可以简化方程。每个分组都会在最终的和积表达式中消除一个积项。此方法可以扩展到有两个以上变量的方程,但所分组的单元数量必须是 2 的倍数,而且只能分组相邻的单元。邻接关系从边缘到边缘、从上下边界进行环绕。您可以在图 4-18 中看到一个例子,位置在第 79 页。

要查看这些操作如何运作,请参考图 4-10 中的卡诺图分组。

Image

图 4-10:F(x, y)中的两个最小项分组

这个分组是我们之前所做的代数运算的图形表示,其中F(x, y)在y = 1时为1,不管x的值是多少。因此,分组通过消除x将两个最小项合并成一个积项。

从上一个分组可以看出,我们的最终简化函数将包含y项。接下来,我们再做一个分组,找到下一个项。首先,我们将代数化简原始方程。返回到原始方程F(x, y),我们可以使用幂等性规则来复制一个最小项:

Image

现在,我们将对第一个积项和我们刚刚添加的积项进行一些代数运算:

Image

我们可以直接在卡诺图上进行操作,而不是使用代数运算,正如在图 4-11 中所示。该图显示了不同的分组可以包含相同的单元(最小项)。

Image

图 4-11:一个卡诺图分组,显示(x ∧ ¬y ∨ (¬x ∧ y)x ∧ y*)= x ∨ y

底行中的分组表示积项x,右列中的分组表示y,这为我们提供了以下最小化结果:

Image 请注意,在两个分组中都包含的单元(xy)是我们在代数解法中通过幂等规则复制的项。您可以把将一个单元包含在多个分组中看作是添加该单元的副本,就像我们之前在代数运算中所做的那样,然后将它与组中的其他单元合并,从而将其移除。

当函数中只有两个变量时,邻接规则会自动满足。添加另一个变量后,我们需要考虑如何排序卡诺图中的单元,以便能够使用邻接规则来简化布尔表达式。

卡诺图中的单元排序

二进制码和二进制编码十进制(BCD)码的一个问题是,相邻值之间的差异通常涉及多个比特的变化。1943 年,弗兰克·格雷提出了一种编码,即格雷码,其中相邻值仅有一个比特不同。尽管在格雷码中编码值会使得算术运算变得复杂,但它简化了显示值之间的邻接关系,这帮助我们如何在卡诺图中排列单元格。

构造格雷码非常简单。首先从一个比特开始:

十进制 格雷码
0 0
1 1

添加一个比特,首先写出现有模式的镜像:

格雷码
0
1
1
0

然后,在每个原始比特模式的前面加上一个0,并在镜像集的前面加上一个1,就得到了两位的格雷码,如表 4-9 所示。

表 4-9: 两位的格雷码

十进制 格雷码
0 00
1 01
2 11
3 10

这就是为什么格雷码有时被称为反射二进制码(RBC)的原因。表 4-10 展示了四位的格雷码。

表 4-10: 四位的格雷码

十进制 格雷码 二进制
0 0000 0000
1 0001 0001
2 0011 0010
3 0010 0011
4 0110 0100
5 0111 0101
6 0101 0110
7 0100 0111
8 1100 1000
9 1101 1001
10 1111 1010
11 1110 1011
12 1010 1100
13 1011 1101
14 1001 1110
15 1000 1111

我们来比较表 4-10 中十进制值 7 和 8 的二进制码与格雷码。7 和 8 的二进制码分别是01111000;在十进制值只变化 1 时,所有四个比特都会改变。但是 7 和 8 的格雷码分别是01001100;只有一个比特发生变化,这符合卡诺图的邻接规则。

请注意,在相邻值之间只变化一个比特的模式,在比特模式回绕时也成立。也就是说,从最高值(四位时为 15)到最低值(0)时,只有一个比特发生变化。

使用三变量卡诺图

为了理解邻接性特性的重要性,我们来考虑一个更复杂的函数。我们将使用卡诺图来简化进位函数,该函数有三个变量。添加一个新变量意味着我们需要将单元格的数量翻倍以容纳最小项。为了保持图的二维结构,我们将新变量添加到已有的变量的一侧。我们需要总共八个单元格(2³),因此我们将其绘制为宽四格、高两格的形式。我们将 z 添加到 y 轴,并按照 图 4-12 所示,在卡诺图中将 yz 放在水平轴上,x 放在垂直轴上。

图片

图 4-12:三变量最小项的卡诺图映射

三变量卡诺图上方的比特模式顺序是 00011110,与 表 4-9 中的格雷码顺序 00011011 不同。邻接规则在卡诺图的边缘也是适用的——即从 m[2] 到 m[0] 或从 m[6] 到 m[4]——这意味着组合可以跨越图的边缘。 (其他轴标记方案也可以使用,正如本节末尾所展示的那样。)

你在本章之前看到过,进位可以表示为四个最小项的和:

图片

图 4-13 显示了这些四个最小项在卡诺图中的位置。

图片

图 4-13:进位函数的卡诺图

我们寻找可以组合在一起的邻接单元格,从而消除乘积项中的一个变量。如前所述,这些组合可以重叠,得到如 图 4-14 所示的三组。

图片

图 4-14:进位函数的最小积和形式 = 1

使用 图 4-14 中卡诺图的三组,我们得到与代数运算得到的相同的方程:

图片 ##### 使用卡诺图简化和积形式

使用显示进位补集为 0 的函数也是有效的。我们使用最大项来做到这一点:

图片

图 4-15 显示了三变量卡诺图中最大项的排列。

图片

图 4-15:三变量最大项的卡诺图映射

在处理最大项表示法时,你标记出评估为 0 的单元格。最小化过程与处理最小项时相同,只不过你需要组合那些包含 0 的单元格。

图 4-16 显示了 ¬C[out](c[in], x, y),即进位的补集的最小化。

图片

图 4-16:NOT 进位的最小和积形式的函数 = 0

图 4-16 中的卡诺图得出了我们代数计算中得到的相同的和积形式,这个结果表示进位补码为0

Image

比较图 4-14 与图 4-16 可以清晰地展示德摩根定律。在进行此比较时,请记住,图 4-14 显示了需要加和的积项,图 4-16 显示了需要乘积的和项,结果是补码。因此,我们在从一个卡诺图转换到另一个时,会交换01,并交换与或运算。

为了进一步强调最小项与最大项的对偶性,可以对比图 4-17(a)与(b)。

Image

图 4-17: (a) 一个最小项与 (b) 一个最大项的比较

图 4-17(a)显示了该函数:

Image

尽管这不是必须的,通常也不这么做,但我们在每个表示未包含在该函数中的最小项的单元格中放置了0

类似地,在图 4-17(b)中,我们在每个表示未包含在该函数中的最大项的单元格中放置了1

Image

这个比较直观地展示了最小项如何指定卡诺图中最少数量的1,而最大项如何指定最多数量的1

探索卡诺图中的更大组合

到目前为止,我们在卡诺图中只将两个单元格组合在一起。在这里,我将展示一个更大组合的示例。考虑一个当 3 位数为偶数时输出1的函数。表 4-11 显示了真值表。它使用1表示数字为偶数,使用0表示奇数。

表 4-11:3 位数字的偶数值

最小项 x y z 数字 偶数 (x, y, z)
m[0] 0 0 0 0 1
m[1] 0 0 1 1 0
m[2] 0 1 0 2 1
m[3] 0 1 1 3 0
m[4] 1 0 0 4 1
m[5] 1 0 1 5 0
m[6] 1 1 0 6 1
m[7] 1 1 1 7 0

该函数的标准积和形式为:

Image

图 4-18 在卡诺图中显示了这些最小项,四个项被组合在一起。我们可以将所有四个项组合在一起,因为它们都有相邻的边。

Image

图 4-18:显示 3 位数字偶数值的卡诺图

从图 4-18 中的卡诺图,我们可以写出当 3 位数为偶数时的方程式:

Image

卡诺图显示,无论xy的值如何,只要z = 0即可。

向卡诺图添加更多变量

每次你向卡诺图中添加一个变量时,你需要将单元格的数量加倍。卡诺图工作的唯一要求是按照相邻规则排列最小项(或最大项)。图 4-19 显示了四变量卡诺图的最小项。yz 变量位于横轴上,wx 变量位于纵轴上。

Image

图 4-19:四变量最小项在卡诺图上的映射

到目前为止,我们假设我们的函数中包含了每个最小项(或最大项)。但是设计并非在真空中进行的。我们可能知道整体设计中的其他组件,告知我们某些变量值的组合永远不会发生。接下来,我将向你展示如何在函数简化过程中考虑这一知识。卡诺图提供了一种特别清晰的方式来可视化这种情况。

使用“无关”单元格

有时,你会知道变量可能具有的值。如果你知道哪些值的组合永远不会发生,那么代表这些组合的最小项(或最大项)就不相关了。例如,你可能需要一个函数来指示两个可能事件中的一个是否发生,但你知道这两个事件不能同时发生。我们将这两个事件命名为 xy,并让 0 表示事件未发生,1 表示事件已发生。表 4-12 显示了我们函数 F(x, y) 的真值表。

表 4-12: xy 发生,但不同时发生的真值表

x y F (x, y)
0 0 0
0 1 1
1 0 1
1 1 ×

我们可以通过在该行中放置 × 来表明这两个事件不能同时发生。我们可以画出一个卡诺图,表示无法在系统中存在的最小项,并用 × 标记,如图 4-20 所示。× 代表一个 无关 单元格;将该单元格与其他单元格分组不会影响函数的求值。

Image

图 4-20: F(x, y) 的卡诺图,显示了一个“无关”单元格

因为表示最小项 (xy) 的单元格是一个“无关”单元格,我们可以选择将其包括在内,或不包括在内,进行最简化分组,得到如图所示的两个分组。图 4-20 显示的卡诺图为我们提供了解决方案

Image

这就是一个简单的或门。你可能已经猜到这个解决方案,而无需使用卡诺图。当你学习第七章末尾的两个数字逻辑电路设计时,你将看到“无关”单元格的更有趣应用。

卡诺图可以用于简化仅有两层逻辑的情况:与门组连接到或门,或门组连接到与门。正如你将在接下来的几章中看到的,大多数逻辑设计涉及超过两层的逻辑。卡诺图可以提供一些有用的指导,但你需要仔细考虑整体设计。你将会在 第六章 中看到一个使用卡诺图设计具有三层逻辑的加法器电路的例子。

组合基本布尔运算符

如本章前面提到的,我们可以组合基本的布尔运算符来实现更复杂的布尔运算符。现在你已经知道如何处理布尔函数,我们将设计一个常见的运算符——异或,通常称为 XOR,并使用三种基本运算符:与门、或门和非门来实现它。它如此常用,以至于有了专用的电路符号。让我们来看一下:

XOR

XOR 是一个二元运算符。当且仅当两个操作数中有一个为 1 时,结果为 1;否则,结果为 0。我将使用 ⊻ 来表示 XOR 运算,也常用 ⊕ 符号。 图 4-21 展示了输入 xy 的 XOR 门操作。

Image

图 4-21:作用于两个变量 xy 的 XOR 门

该操作的最小项实现为:

Image

XOR 运算符可以通过两个与门、两个非门和一个或门实现,如 图 4-22 所示。

Image

图 4-22:由与门、或门和非门组成的 XOR 门

我们当然可以设计更多的布尔运算符,但接下来的章节将继续学习如何使用简单的开关在硬件中实现这些运算符。正如你将发现的那样,你在本章学到的理想最简解决方案,可能并不总是最好的解决方案,因为涉及到开关的电气特性、逻辑层次的复杂性、硬件组件的可用性等因素。

你的回合

4.3 设计一个函数,检测所有的 4 位整数中偶数。

4.4 为该函数找出一个最简的积和表达式:

Image

4.5 为该函数找出一个最简的和积表达式:

Image

4.6 变量在卡诺图中的排列是任意的,但最小项(或最大项)需要与标记一致。使用 图 4-12 中的符号,标明每个最小项的位置:

Image

4.7 卡诺图的变量排列是任意的,但最小项(或最大项)必须与标记一致。使用图 4-12 中的标记法,展示每个最小项的位置:

Image

4.8 为五个变量创建卡诺图。你可能需要回顾表 4-10 中的格雷码,并将其扩展为五位。

4.9 设计一个逻辑函数,用于检测单一数字的质数。假设数字以 4 位 BCD 编码(见表 2-7)。该函数在每个质数上为1

你学到了什么

布尔运算符 三个基本的布尔运算符是 AND、OR 和 NOT。

布尔代数规则 布尔代数提供了一种数学方法来处理逻辑规则。AND 类似于乘法,OR 则类似于初等代数中的加法。

简化布尔代数表达式 布尔函数指定了计算机的功能。简化这些函数有助于实现更简洁的硬件设计。

卡诺图 这为简化布尔表达式提供了一种图形化的方法。

格雷码 这展示了如何在卡诺图中排列单元格。

组合基本布尔运算符 XOR 可以通过 AND、OR 和 NOT 运算符组合实现。

下一章将介绍基础电子学,为理解晶体管如何用作开关提供基础。从这一点出发,我们将探讨晶体管开关如何用来实现逻辑门。

第五章:逻辑门**

图片

在上一章,你学习了布尔代数表达式以及如何使用逻辑门实现它们。在这一章,你将学习如何使用晶体管在硬件中实现逻辑门,晶体管是用来实现我们在本书中讨论的开关的固态电子设备。

为了帮助你理解晶体管的工作原理,我们将从电子学的简单介绍开始。接下来,你将学习如何将晶体管成对连接,以实现更快的开关速度并减少电力消耗。我们将在本章的最后讨论一些关于使用晶体管构建逻辑门的实际注意事项。

电子学速成课程

你不需要成为电气工程师就能理解逻辑门的工作原理,但理解一些基本概念会有所帮助。在本节中,我将简要介绍电子电路的基本概念。我们将从两个定义开始:

电流 指的是电荷的运动。电荷的单位是库伦(C)。1 库伦每秒(1 C/s)的流动定义为 1安培(A),通常简写为。电流只有在电路中从电源的一侧到另一侧有完全连接的路径时才会流动。

电压 也叫电势差,指的是电路中两个点之间每单位电荷的电能差。一伏特(V)定义为在导体(电流流经的介质)上两个点之间的电差,当 1 安培(A)的电流通过导体时,消耗 1 瓦特(W)的功率。

一台计算机由以下电子元件构成:

  • 提供电力的电源

  • 影响电流流动和电压水平的无源元件,但其特性无法被其他电子元件改变

  • 在一个或多个电子元件的控制下,能够在电源、无源元件和其他有源元件的各种组合之间切换的有源元件

  • 连接其他元件的导体

让我们来看一下这些电子元件如何工作。

电源和电池

在几乎所有国家,电力以交流电(AC)的形式提供。对于交流电,电压大小与时间的关系图呈现正弦波形状。计算机电路使用直流电(DC)电源,直流电与交流电不同,不随时间变化。电源用于将交流电转换为直流电,如图 5-1 所示。

图片

图 5-1:交流电/直流电电源

电池也提供直流电电源。在绘制电路时,我们将使用电池符号(图 5-2)来表示直流电源。图 5-2 中的电源提供 5 伏特直流电。

图片

图 5-2:5 V 直流电源的电路符号

在之前的章节中,你已经看到计算机中发生的一切都是基于 10 的系统。但这些 10 是如何在物理上表示的呢?计算机电路通过区分两个不同的电压水平来表示逻辑 0 和逻辑 1。例如,逻辑 0 可能由 0 V 直流电表示,逻辑 1 由 5 V 直流电表示。反过来也可以实现:5 V 作为逻辑 0,0 V 作为逻辑 1。唯一的要求是硬件设计的一致性。幸运的是,程序员不需要关心实际使用的电压;这最好交给计算机硬件工程师处理。

注意

电子设备被设计成在一定范围的电压下可靠工作。例如,一个设计为工作在标称 5 V 的设备,通常有 ±5% 的公差,即 4.75 V 到 5.25 V。

计算机电路中的元件不断在两种电压水平之间切换。每次电压切换都需要时间,这限制了电路完成操作的速度。正如你将在“晶体管”一节中看到的,在第 96 页,加快切换时间会消耗更多的电力,并产生热量。过多的热量可能损坏元件,进而限制计算速度。电路元件的时间依赖特性是计算机硬件工程师设计时需要重点考虑的因素。我们将在下一节详细讨论这些特性。

无源元件

所有电路都具有以下电磁特性,这些特性分布在整个电路中:

电阻 阻碍电流流动,从而消耗能量。电能转化为热能。

电容 将能量储存在电场中。电容两端的电压不能瞬间变化。

电感 将能量储存在磁场中。电流通过电感时不能瞬间变化。

储能为电场需要时间,因此电容器阻碍电压随时间变化。储能为磁场也需要时间,因此电感阻碍电流随时间变化。这两种特性与电阻一起被统称为阻抗。阻抗对变化的阻碍减慢了计算机中发生的开关操作,而电阻则消耗电能。我们将在本节余下的部分讨论这些特性的基本时序特性,但对于功耗的讨论会留给更深入的书籍。

为了感受这些属性的影响,我们将考虑用于将这些属性放置在电路中特定位置的离散电子设备:电阻器、电容器和电感器。这些属于一种称为无源元件的广泛电子元件类别,它们不能被电子控制;它们只是消耗或储存能量。图 5-3 显示了我们将在此讨论的无源电子设备的电路符号。

Image

图 5-3:无源设备的电路符号

开关

开关有两种位置:开和关。在开的位置,两个端点之间没有连接,不发生导电。当开关关闭时,两个端点之间的连接完成,从而导电。图 5-3(a)中的符号通常表示手动操作的开关。在第 96 页的“晶体管”部分中,您将了解到计算机使用晶体管作为开关来控制开关的开闭,从而实现计算机的开/关逻辑。

电阻器

电阻器用于限制电路中特定位置的电流量。通过限制电流流入电容器或电感器,电阻器影响这些设备(在第 90 页的“电容器”部分和第 93 页的“电感器”部分讨论)的能量储存建立时间。电阻的大小通常与电容或电感的大小一起选择,以提供特定的时间特性。电阻器还用于将通过设备的电流限制到非破坏性的水平。

由于它限制电流流动,电阻器会不可逆地将电能转化为热能。与电容器或电感器不同,电阻器不会储存能量,后者可以在稍后的时间将储存的能量返回电路。

单个电阻器的电压与电流关系由欧姆定律给出,

Image 其中V(t)是时刻t电阻器两端的电压差,I(t)是时刻t流过电阻器的电流,R是电阻器的阻值。电阻器的阻值以欧姆为单位。

图 5-4 所示的电路图显示了两个电阻器通过开关连接到一个提供 5 伏电压的电源。希腊字母Ω用来表示欧姆,而 kΩ表示 10³欧姆。由于电流只能在闭合路径中流动,开关未闭合时电流不会流动。

Image

图 5-4:两个串联电阻器与电源和开关

在图 5-4 中,两个电阻处于相同的路径上,因此当开关闭合时,相同的电流 I 会通过它们每一个。处于相同电流流动路径上的电阻称为串联连接。为了确定从电源流出的电流,我们需要计算电流路径中的总电阻。在这个例子中,这是两个电阻的总和:

Image

因此,5 V 电压应用于总计 2.5 kΩ 的电阻。解出 I,并省略 t 因为电源电压不随时间变化,得到:

Image

其中 mA 表示毫安。

我们现在可以通过将电阻值和电流相乘来确定图 5-4 中 A 点和 B 点之间的电压差:

Image

类似地,B 点和 C 点之间的电压差为:

Image 因此,将电阻串联连接起到电压分配器的作用,将 5 V 电压分配到两个电阻上:1.0 kΩ 电阻上的 2.0 V 和 1.5 kΩ 电阻上的 3.0 V。

图 5-5 显示了两个电阻并联连接的情况。

Image

图 5-5:两个电阻并联

在图 5-5 中,当开关闭合时,电源的全部电压 5 V 被施加在 A 点和 C 点之间。因此,每个电阻两端都有 5 V 电压,我们可以使用欧姆定律来计算流过每个电阻的电流:

Image

当开关闭合时,电源的总电流 I[T] = I[1] + I[2] 会在 A 点被分配到两个电阻上。它必须等于通过两个电阻的电流之和:

Image

电容器

电容器电场的形式储存能量,本质上是静止的电荷。电容器最初允许电流流入。但它不会提供持续的电流流动路径,而是储存电荷,形成电场,并导致电流随着时间减少。

由于建立电场需要时间,电容器常用于平滑电压的快速变化。当电流突然增加并流入电容器时,电容器会吸收电荷。然后,当电流突然减少时,电容器会释放储存的电荷。

电容器两端的电压随着时间变化,遵循以下规律:

Image

其中 V(t) 是电容器在时间 t 时的电压差,I(t) 是时间 t 时流过电容器的电流,C 是电容器的电容值,以法拉(F)为单位。

注意

如果你还没有学习微积分,符号表示 积分,可以看作是“无限小求和。”这个方程说明,电压随着时间从 0 增加到当前时刻 t 时会逐步累积。你将在 图 5-7 中看到这个过程的图形视图。

图 5-6 显示了一个 1.0 微法(μF)电容器通过 1.0 kΩ 电阻充电的过程。

Image

图 5-6:电容器与电阻串联

正如你将在本章后面看到的,这个电路是一个粗略模拟,模拟的是一个晶体管的输出连接到另一个晶体管的输入。第一个晶体管的输出(它就像 图 5-6 中的电源加电阻)具有电阻,第二个晶体管的输入具有电容。第二个晶体管的开关行为取决于(等效)电容器两端的电压 V[BC](t) 达到阈值。

我们来看一下电容器两端电压达到阈值所需的时间。假设当开关第一次闭合时,电容器两端的电压 V[BC] 为 0 V,电流通过电阻流入电容器。电阻器两端的电压加上电容器两端的电压必须等于电源提供的电压。即:

Image

从电容器两端电压 V[BC] 为 0 V 开始,当开关首次闭合时,电源的完整 5.0 V 将出现在电阻器两端。因此,电路中的初始电流将为:

Image 这种初始电流冲击电容器的过程导致电容器两端的电压逐渐升高,接近电源电压。前面的积分方程表明,随着电容器两端电压接近其最终值,电压的积累以指数方式减小。

随着电容器两端电压 V[BC](t) 的增加,电阻器两端电压 V[AB](t) 必须减少。当电容器两端的电压最终等于电源电压时,电阻器两端的电压为 0 V,电路中的电流为零。电流流动的指数递减速率由电阻值与电容值的乘积 RC 给出,称为时间常数。对于本例中的 RC 值,我们得到:

Image

其中 s 为秒,ms 为毫秒。

假设在 图 5-6 中,当开关闭合时电容器两端电压为 0 V,那么电容器两端随时间变化的电压由下式给出:

Image

图 5-7 显示了图 5-6 中电路的图形表示。左侧的 y 轴表示电容器两端的电压,而右侧的 y 轴表示电阻器两端的电压。请注意,两个刻度方向相反。

图片

图 5-7:电容器随时间充电

在时间 t = 1.0 毫秒(一个时间常数)时,电容器两端的电压为:

图片

这超过了计算机中常用的典型晶体管的阈值电压。你将在本章稍后部分了解更多相关内容。

六个时间常数之后,电容器两端的电压为:

图片

此时,电阻器两端的电压基本为 0 V,电流非常小。

电感器

一个电感器磁场的形式储存能量,磁场是由运动中的电荷产生的。电感器最初会阻止电荷的流动,需要时间来建立磁场。通过提供电荷流动(电流)的连续路径,电感器创造出磁场。

在计算机中,电感器主要用于电源以及将电源与 CPU 连接的电路。如果你能看到计算机内部,你可能会在主板上靠近 CPU 的位置看到一个小型的(大约 1 厘米直径)环状设备,绕着它有电线。这是一个电感器,用于平滑供给 CPU 的电源。

尽管电感器或电容器都可以用来平滑电源,但电感器是通过抵抗电流变化来实现的,而电容器则是通过抵抗电压变化来实现的。关于选择使用哪个,或者是否同时使用它们来平滑电源的问题,超出了本书的范围。

在时间 t 时,电感器两端的电压 V(t) 与通过电感器的电流 I(t) 之间的关系为:

图片

其中 L 是电感器的值,以亨利 (H) 为单位。

注意

再次,我们在这里使用了一些微积分。 dI(t)/dt 表示微分,即 I(t) 随时间 t 变化的速率。这个方程表示,时间 t 时的电压与该时刻 I(t) 的变化速率成正比。(你将在图 5-9 中看到这个图形表示。)

图 5-8 显示了一个 1.0 μH 的电感器与一个 1.0 kΩ 的电阻器串联连接。

图片

图 5-8:电感器与电阻器串联

当开关断开时,电流不会通过此电路。当开关闭合时,电感器最初会阻碍电流的流动,并且电感器中需要一定的时间来建立磁场。在开关关闭之前,电流未通过电阻器,因此电阻器两端的电压,V[BC],为 0V。电感器两端的电压,V[AB],为电源的完整 5V。随着电流开始流过电感器,电阻器两端的电压,V[BC](t),逐渐增大。这导致电感器两端的电压呈指数性下降。当电感器两端的电压最终达到 0V 时,电阻器两端的电压为 5V,电路中的电流为 5.0 mA。

指数电压下降的速率由时间常数L/R给出。使用图 5-8 中的RL值,我们得到:

图片

其中 ns 为纳秒。

当开关闭合时,电感器两端随时间变化的电压由以下公式给出:

图片

如图 5-9 所示,左侧的 y 轴显示了图 5-8 中电路的电阻器两端的电压,而右侧的 y 轴显示了电感器两端的电压。请注意,刻度的方向是相反的。

图片

图 5-9:电感器随时间充电

在时间t = 1.0 ns(一个时间常数)时,电感器两端的电压为:

图片大约经过 6 纳秒(六个时间常数)后,电感器两端的电压基本等于 0V。此时,电源的完整电压跨越电阻器,电流稳定为 5.0 mA。

图 5-8 中的电路展示了电感器如何与 CPU 电源一起使用。此电路中的电源模拟计算机电源,而电阻器模拟 CPU,CPU 从电源中消耗电能。电源产生的电压包含噪声,即叠加在直流电平上的小型高频波动。如图 5-9 所示,提供给 CPU 的电压,V[BC](t),在短时间内变化不大。串联在电源与 CPU 之间的电感器作用是平滑电压,供电给 CPU。

功率消耗

硬件设计中一个重要的部分是功率消耗,尤其是在电池供电的设备中。在我们讨论的三种电磁属性中,电阻是主要的功率消耗者。

能量是引起变化的能力,而功率是衡量能量多快被用来产生变化的指标。能量的基本单位是焦耳 (J)。功率的基本单位是瓦特 (W),它定义为每秒消耗 1 焦耳(J/s)。例如,我有一个备用电池,能存储 240 瓦时(Wh)的能量。这意味着它能储存足够的能量来提供 240 瓦特的功率持续 1 小时,或 240 Wh × 3,600 秒/小时 = 864,000 J。伏特和安培的单位被定义为 1 W = 1 V × 1 A。这导致了功率的公式,

Image

其中,P是所用功率,V是组件两端的电压,I是流过它的电流。

在短暂的充电时间后,电容器会阻止电流流动。这个方程表明,电容器的功率消耗随后趋近于零。充电电容器所使用的能量以电场的形式储存。类似地,电感器两端的电压在短暂充电时间后趋近于零,导致电感器消耗的功率为零。电感器将充电能量以磁场的形式储存。

然而,电阻器并不存储能量。只要电阻器两端存在电压差,电流就会流过它。

电阻器使用的功率,R,由以下公式给出:

Image

这些功率在电阻器中转化为热量。由于功率消耗与电流的平方成正比,一个常见的硬件设计目标是减少电流流动的量。

本节讨论了计算机工程师在其设计中包含的理想化的无源组件。在现实中,每个组件都包含电阻、电容和电感三个特性的元素,硬件设计工程师需要考虑这些。由于这些二次效应通常微妙且麻烦,设计时必须加以注意。

本章的其余部分将讨论有源组件,这些组件通过电子控制并用于实现构成计算机基础的开关。正如你将看到的,主动组件包括电阻和电容,它们影响所用电路的设计。

晶体管

在前几章中,我将计算机描述为一组双态开关,并讨论了如何通过这些开关的设置 01 来表示数据。接着,我们探讨了如何使用逻辑门将01组合起来,实现逻辑功能。在本节中,你将学习如何使用晶体管实现构成计算机的双态开关。

晶体管是一种其电阻可以通过电子方式控制的设备,因此它是一个有源元件。电子控制的能力使得晶体管构成的开关与本章前面提到的可以机械控制的简单开关不同。在研究晶体管如何作为开关之前,让我们先看看如何使用机械的开关实现逻辑门。我们将使用 NOT 门作为示例。

图 5-10 显示了两个按钮开关串联连接在 5 V 和 0 V 之间。

图片

图 5-10:由两个按钮开关组成的 NOT 门

上面的开关是常闭的。当按下其按钮(从左侧)时,两个小圆圈之间的连接被断开,从而在此点上打开电路。下面的开关是常开的。当按下其按钮时,两个小圆圈之间建立连接,从而在此点上完成电路。

我们将 5 V 代表 1,0 V 代表 0。这个 NOT 门的输入 x 同时按下两个按钮。我们将以以下方式控制 x:当 x = 1 时,我们按下这两个按钮;当 x = 0 时,我们不按按钮。当按钮没有被按下时 (x = 0),5 V 会连接到输出端,¬x,表示 1。当按钮被按下时 (x = 1),5 V 被断开,0 V 连接到输出端,表示 0。因此,输入 1 会得到输出 0,输入 0 会得到输出 1

早期的计算设备使用机械开关来实现其逻辑,但按照今天的标准,这些计算机的运算速度非常慢。现代计算机使用晶体管,晶体管是一种由半导体材料制成的电子设备,可以在电子控制下快速地在导通状态和非导通状态之间切换。

就像使用机械控制的按钮开关的示例一样,我们使用两种不同的电压来表示 10。例如,我们可能使用高电压,例如 +5 V,来表示 1,使用低电压,例如 0 V,来表示 0。但是,晶体管可以通过电子方式开关,这使得它们比最初计算机中使用的机械开关要快得多。晶体管还占用更少的空间,并且消耗更少的电力。

在接下来的章节中,我们将介绍现代计算机中常用的两种晶体管:MOSFET 开关和 CMOS 开关。

MOSFET 开关

在当今计算机逻辑电路中,最常用的开关晶体管是金属氧化物半导体场效应晶体管(MOSFET)。MOSFET 有多种类型,使用不同的电压等级和极性。我将描述最常见的类型——增强型 MOSFET的行为,并将其他类型的详细内容留给更深入的书籍。在这里的简要讨论将帮助你理解它们的基本工作原理。

MOSFET 的基本材料通常是硅,它是一种半导体,意味着它能够导电,但导电性不强。通过添加杂质(这一过程称为掺杂)可以改善其导电性。根据杂质的类型,电导率可以是电子流动或缺少电子的流动(称为空穴)。由于电子带负电荷,因此导电子的类型被称为N 型,而导空穴的类型被称为P 型。MOSFET 的主要导电通道是通道,它连接在 MOSFET 的源极漏极端子之间。栅极由相反类型的半导体材料构成,并控制通道中的导电性。

图 5-11 展示了两种基本的 MOSFET 类型:N-channel 和 P-channel。在这里,我展示了每个 MOSFET 如何通过电阻连接到 5 V 电源。

图片

图 5-11:两种基本类型的 MOSFET

这些是简化的电路,目的是提供讨论 MOSFET 工作原理的背景。每个 MOSFET 有三个连接点或端子。栅极作为输入端子,施加在栅极上的电压(相对于施加在源极上的电压)控制通过 MOSFET 的电流流动。漏极作为输出端子。N-channel MOSFET 的源极连接到电源的低电压端,而 P-channel 的源极则连接到高电压端。

在学习了布尔代数中的补码之后,你可能不会对这两种类型的 MOSFET 具有互补的行为感到惊讶。你将在接下来的章节中看到我们如何将它们以互补对的方式连接,从而使得开关速度更快、效率更高,而不是仅使用一种类型。

首先,我们将研究每种 MOSFET 作为单一开关设备的工作方式,从 N-channel MOSFET 开始。

N-channel MOSFET

在图 5-11(a)中,N-channel MOSFET 的漏极通过电阻 R 与电源的 5 V 端连接,源极则连接到 0 V 端。

当加到栅极的电压相对于源极为正时,N 通道 MOSFET 的漏极和源极之间的电阻减小。当这个电压达到一个阈值,通常在 1 V 范围内时,电阻变得非常低,从而为漏极和源极之间的电流提供了一个良好的导电路径。由此产生的电路等效于图 5-12(a)。

在图 5-12(a)中,电流从电源的 5 V 连接流向 0 V 连接,通过电阻器 R。漏极的电压将为 0 V。这个电流流动的问题是电阻器消耗功率,简单地将其转化为热量。稍后你会看到我们为什么不希望通过增加电阻来限制电流流动,从而减少功耗。

Image

图 5-12:N 通道 MOSFET 开关等效电路:(a)开关闭合,(b)开关打开

如果加到栅极的电压切换为几乎与加到源极的电压相同——在此例中为 0 V——则 MOSFET 关闭,产生如图 5-12(b)所示的等效电路。漏极通常连接到另一个 MOSFET 的栅极,当其从一种状态切换到另一种状态时,只会短暂地吸收电流。状态切换后的短暂时刻,漏极与另一个 MOSFET 栅极的连接不再吸收电流。由于没有电流流过电阻器,电阻器上没有电压差。因此,漏极的电压将为 5 V。

电阻器被认为是作为上拉装置起作用的,因为当 MOSFET 关闭时,电路通过电阻器完成,电阻器作用是将漏极的电压拉高到电源的高电压。

P 通道 MOSFET

现在,让我们看一下 P 通道 MOSFET,如图 5-11(b)所示。漏极通过电阻器 R 连接到低电压(0 V),源极连接到高电压电源(5 V)。当加到栅极的电压切换为几乎与加到源极的电压相同时,MOSFET 关闭。在这种情况下,电阻器作为下拉装置作用,将漏极的电压拉低到 0 V。图 5-13(a)显示了等效电路。

当加到栅极的电压相对于源极为负时,P 通道 MOSFET 的漏极和源极之间的电阻减小。当这个电压达到一个阈值,通常在 -1 V 范围内时,电阻变得非常低,为漏极和源极之间的电流提供了一个良好的导电路径。图 5-13(b)显示了当栅极相对于源极为 -5 V 时产生的等效电路。

Image

图 5-13:P 型 MOSFET 开关等效电路:(a)开关打开,(b)开关关闭

图 5-11 中的 MOSFET 电路电阻对两种 MOSFET 类型都存在一些问题。我们接下来将讨论这些问题。

MOSFET 电路中的电阻

图 5-12(a) 和 图 5-13(b) 中的等效电路显示,当相应的 MOSFET 处于开启状态时,它就像一个闭合的开关,从而导致电流流过上拉或下拉电阻。MOSFET 处于开启状态时,通过电阻的电流会消耗功率,这些功率会转化为热量。

除了 MOSFET 在开启状态时使用功率的上拉和下拉电阻外,这种硬件设计还有另一个问题。尽管 MOSFET 的栅极几乎不消耗电流以保持开启或关闭状态,但要改变其状态需要短暂的电流脉冲。这个电流由连接到栅极的设备提供,可能来自另一个 MOSFET 的漏极。在本书中我不打算深入细节,但可以说明,从另一个 MOSFET 的漏极提供的电流量通常受到其上拉或下拉电阻的限制。这个情况本质上与图 5-6 和 图 5-7 中的情形相同,您会看到电容充电所需的时间在电阻值较大时更长。

因此,这里有一个权衡:电阻越大,电流流动越小,这会减少 MOSFET 开启状态下的功耗。但较大的电阻也会减少漏极处可用电流的量,从而增加将 MOSFET 切换到漏极的时间。我们面临一个困境:小的上拉和下拉电阻增加功耗,但大的电阻会降低计算机的速度。

CMOS 开关

我们可以通过 互补金属氧化物半导体(CMOS) 技术来解决这个困境。为了了解这个原理,让我们去除上拉和下拉电阻,并将 P 型和 N 型 MOSFET 的漏极连接起来。P 型 MOSFET 将替代 N 型电路中的上拉电阻,而 N 型 MOSFET 将替代 P 型电路中的下拉电阻。我们还将连接两个栅极,得到图 5-14 中所示的电路。

Image

图 5-14:CMOS 反相器(NOT)电路

图 5-15(a) 显示了在较高电源电压 5 V 下的等效电路。上拉 MOSFET(P 通道)关闭,下拉 MOSFET(N 通道)打开,因此漏极被拉到较低的电源电压 0 V。在 图 5-15(b) 中,栅极处于较低的电源电压 0 V,这使得 P 通道 MOSFET 打开,而 N 通道 MOSFET 关闭。P 通道 MOSFET 将漏极拉到较高的电源电压 5 V。

Image

图 5-15:一个 CMOS 反相器等效电路:(a)上拉开路,下拉闭路,(b)上拉闭路,下拉开路

我在 表 5-1 中总结了这种行为。

表 5-1: 单个 CMOS 的真值表

漏极
0 V 5 V
5 V 0 V

如果我们将门连接作为输入,将漏极连接作为输出,并且让 5 V 为逻辑 1,0 V 为逻辑 0,那么 CMOS 实现了一个 NOT 门。

使用 CMOS 电路的两个主要优点是:

  • 它们消耗的功率非常小。由于 N 通道和 P 通道 MOSFET 的开关速度差异,开关期间只会流过少量电流。较少的电流意味着较少的热量,这通常是芯片设计中的限制因素。

  • 电路的响应速度更快。MOSFET 可以比电阻器更快地提供输出电流,进而充电下一个 MOSFET 的栅极。这使我们能够构建更快的计算机。

图 5-16 显示了一个使用三个 CMOS 实现的与门。

Image

图 5-16:使用三个 CMOS 晶体管的与门

表 5-2 中的真值表显示了前两个 CMOS 的中间输出(图 5-16 中的 A 点)。

表 5-2: 图 5-16 中与门的真值表

x y A x ∧ y
0 0 1 0
0 1 1 0
1 0 1 0
1 1 0 1

从真值表中,我们可以看到 A 点的信号是 ¬(xy)。A 点到输出的电路是一个 NOT 门。A 点的结果被称为 NAND 运算。它比与门少使用两个晶体管。我们将在下一节讨论这一点的含义。

NAND 和 NOR 门

正如你在上一节中所学到的,晶体管的固有设计意味着大多数电路会反转信号。也就是说,对于大多数电路,输入端的高电压会在输出端产生低电压,反之亦然。因此,与门通常需要在输出端添加一个 NOT 门,以实现真正的与运算。

你还学到,使用 NAND 门生成 NOT(AND)比普通的 AND 门需要更少的晶体管。这种组合非常常见,以至于它有了一个名字:NAND 门。当然,我们还有一个与 OR 门等效的门,称为NOR 门

NAND 一个二元运算符,只有在两个操作数都为1时才会返回0,否则返回1。我们将使用¬(xy)来表示 NAND 运算。图 5-17 展示了 NAND 门的硬件符号,并附有一个真值表,显示了其对输入xy的操作。

Image

图 5-17:NAND 门作用于两个变量,xy*

NOR 一个二元运算符,当至少一个操作数为1时,结果为0,否则结果为1。我们将使用¬(xy)来表示 NOR 运算。图 5-18 展示了 NOR 门的硬件符号,并附有一个真值表,显示了其对输入xy的操作。

Image

图 5-18:NOR 门作用于两个变量,xy*

注意图 5-17 和图 5-18 中 NAND 门和 NOR 门输出端的小圆圈。这表示NOT,就像你在图 4-3 中看到的 NOT 门一样。

尽管在前一章中我们明确展示了当门的输入被补充时使用的 NOT 门,但通常会简单地在输入端使用这些小圆圈来表示补充。例如,图 5-19 显示了一个 OR 门,其两个输入都被补充。

Image

图 5-19:NAND 门的另一种绘制方式

如真值表所示,这是实现 NAND 门的另一种方式。正如你在第四章中学到的,德摩根定律证明了这一点:

Image

NAND 作为通用门

关于 NAND 门的一个有趣特性是,它们可以用来构建 AND、OR 和 NOT 门。这意味着 NAND 门可以用于实现任何布尔函数。从这个角度看,你可以把 NAND 门看作是通用门。回想一下德摩根定律,NOR 门也可以作为通用门,这应该不会让你感到惊讶。但由于 CMOS 晶体管的物理特性,NAND 门更快且占用更少空间,因此几乎总是首选方案。

让我们了解如何使用 NAND 门构建 AND、OR 或 NOT 门。要使用 NAND 门构建 NOT 门,只需将信号连接到 NAND 门的两个输入端,如图 5-20 所示。

Image

图 5-20:由 NAND 门构建的 NOT 门

要构建 AND 门,我们可以观察到图 5-21 中的第一个 NAND 门产生¬(xy),并将其连接到一个如图 5-20 所示的 NOT 门,从而产生(xy)。

Image

图 5-21:由两个 NAND 门构建的与门(AND gate)

我们可以使用德摩根定律(De Morgan's law)推导出一个或门(OR gate)。考虑以下内容:

Image

所以,为了实现或门(OR),我们需要三个 NAND 门,如图 5-22 所示。位于 xy 输入的两个 NAND 门被连接成非门(NOT gates),产生 ¬x 和 ¬y,这会在第三个 NAND 门的输出端产生 ¬(¬x ∧ ¬y)。

Image

图 5-22:由三个 NAND 门构建的或门(OR gate)

看起来我们正在增加复杂性来用 NAND 门构建电路,但请考虑以下函数:

Image

如果不了解逻辑门是如何构建的,那么用图 5-23 所示的电路来实现这个功能是合情合理的。

Image

图 5-23:使用两个与门(AND gates)和一个或门(OR gate)的 F(w, x, y, z)*

虽然看起来我们可能走错了方向,但让我们给这个电路增加一些硬件。内卷性质表明 ¬(¬x) = x,因此我们可以在每个路径上添加两个非门(NOT gates),如图 5-24 所示。

Image

图 5-24:使用两个与门(AND gates)、一个或门(OR gate)和四个非门(NOT gates)的 F(w, x, y, z)*

比较在输入 wxyz 上操作的两个与门(AND gate)/非门(NOT gate)组合与图 5-17,我们可以看到它们每一个实际上只是一个 NAND 门。它们将在两个最左边的非门输出中产生 ¬(wx) 和 ¬(yz)。

从图 5-19 中应用德摩根定律可以看到,(¬a) ∨ (¬b) = ¬(ab)。换句话说,我们可以用一个单一的 NAND 门替代两个最右边的非门和或门的组合:

Image

结果电路,如图 5-25 所示,使用了三个 NAND 门。

Image

图 5-25:使用三个 NAND 门的 F(w, x, y, z)*

从简单地查看图 5-23 和图 5-25 中的逻辑电路图来看,可能会觉得我们在这个电路转换中没有获得什么。然而,我们在前一节中看到,一个 NAND 门(图 5-16 中的 A 点)比与门少用了两个晶体管。因此,NAND 门的实现更节能、速度更快,或门也是如此。

从与门(AND)/或门(OR)/非门(NOT)设计到只使用 NAND 门的转换非常简单:

  1. 将该函数表示为最简的乘积和。

  2. 将乘积(与(AND)项)和最终的和(或(OR))转换为 NAND 门。

  3. 对于任何只有一个文字(literal)的乘积,添加一个 NAND 门。

我在这里说的关于 NAND 门的内容同样适用于 NOR 门。你只需应用德摩根定律来求得所有内容的补集。但如前所述,NAND 门通常比 NOR 门更快且占用更少的空间,因此几乎总是首选方案。

与软件一样,硬件设计是一个迭代过程。大多数问题没有唯一的解决方案,你通常需要开发多个设计,并在可用硬件的上下文中分析每一个设计。正如前面的例子所示,看起来相同的两个解决方案在硬件层面上可能是非常不同的。

轮到你了

5.1 使用 NOR 门设计一个 NOT 门、一个 AND 门和一个 OR 门。

5.2 设计一个使用 NAND 门的电路,检测两个 2 位整数 xy 的“低于”条件,F(x, y) = 1。对于无符号整数比较,通常使用低于/高于,而对于有符号整数比较,通常使用小于/大于。

你学到的内容

基础电子学概念 电阻、电容和电感会影响电子电路中的电压和电流流动。

晶体管 半导体器件,可以用作电子控制的开关。

MOSFET 用于计算机中实现逻辑门的最常用开关器件。金属氧化物半导体场效应晶体管有 N 型和 P 型两种类型。

CMOS N 型和 P 型 MOSFET 在互补配置中配对,以提高开关速度并减少功耗。

NAND 和 NOR 门 由于晶体管的固有电子特性,这些门比 AND 和 OR 门需要更少的晶体管。

在下一章中,你将学习如何将简单的逻辑门连接在电路中,以实现构建计算机所需的复杂操作。

第六章:**6

组合逻辑电路**

Image

在上一章中,你了解了计算机的基本组成部分——逻辑门。计算机是由逻辑门的组合构成的,这些逻辑门被称为逻辑电路,用于处理数字信息。

在本章以及接下来的两章中,我们将探讨如何构建一些构成 CPU、内存和其他设备的逻辑电路。我不会描述这些单元的全部内容;相反,我们将查看其中的一些小部分,并讨论其背后的概念。目标是提供一个关于这些逻辑电路背后思想的入门概览。

逻辑电路的两种类型

逻辑电路有两种类型。组合逻辑电路的输出仅依赖于任何特定时刻给定的输入,而不依赖于任何先前的输入。时序逻辑电路的输出依赖于先前和当前的输入。

为了阐明这两种类型,我们考虑一个电视遥控器。你可以通过在遥控器上输入数字来选择一个特定的频道。频道选择仅依赖于你输入的数字,而忽略了你之前正在查看的频道。因此,输入与输出之间的关系是组合的。

遥控器还有一个输入,用于调节频道的升高或降低。这个输入依赖于先前选择的频道和之前上下频道按钮的序列。频道上下按钮展示了一个时序输入/输出的关系。

我们将在下一章探讨时序逻辑电路。在本章中,我们将通过几个组合逻辑电路的例子来了解它们是如何工作的。

信号电压水平

电子逻辑电路用高电压或低电压来表示10。我们将表示1的电压称为有效电压。如果我们使用更高的电压来表示1,那么该信号称为高电平有效。如果我们使用较低的电压来表示1,那么该信号称为低电平有效

一个高电平信号可以连接到一个低电平输入,但硬件设计师必须考虑到这种差异。例如,如果所需的逻辑输入为低电平输入的是1,则所需电压是两种电压中较低的;如果连接到该输入的信号是高电平的,则逻辑1是两种电压中较高的,信号必须先被反向才能在低电平输入中被解释为1

我在本书中讨论逻辑电路时只使用逻辑电平——01——而避免使用硬件中实际的电压水平,但你应该了解这些术语,因为它们在与他人交谈或阅读组件规格书时可能会出现。

加法器

我们将从 CPU 中执行的最基本操作之一开始:加法运算两个比特。我们的最终目标是加法运算两个 n 位数。

从第二章回顾,二进制数中的位从右向左编号(最低有效位到最高有效位),从 0 开始。我将从展示如何在第i位位置添加两个位开始,并通过展示如何添加两个 4 位数并考虑每个位位置的进位来完成讨论。

半加器

可以用几种电路进行加法运算。我们将从半加器开始,它简单地将一个数的当前位位置上的两个位相加(以二进制表示)。这在表 6-1 中的真值表中展示,其中x[i]是数x的第i位,y[i]列中的值表示数y的第i位。Sum[i]是数Sum的第i位,Carry[i] [+ 1]是从添加位x[i]y[i]得到的进位。

表 6-1: 使用半加器添加两个位的真值表

x[i] y[i] Carry[i + 1] Sum[i]
0 0 0 0
0 1 0 1
1 0 0 1
1 1 1 0

和是两个输入的异或,进位是两个输入的与。图 6-1 展示了半加器的逻辑电路。

Image

图 6-1:半加器逻辑电路

但这里有一个缺陷:半加器只能处理两个输入位。它可以用来添加两个数的同一位位置的两个位,但它不考虑可能来自下一个低阶位位置的进位。将此进位作为第三个输入包括进来将得到一个全加器。

全加器

与半加器不同,全加器电路有三个 1 位输入:Carry[i]x[i]y[i]Carry[i]是将两个上一个位位置的位相加时产生的进位(右边的位)。例如,如果我们正在添加位位置 5 的两个位,那么全加器的输入是位位置 5 的两个位加上位于位置 4 的进位。表 6-2 显示了结果。

表 6-2: 使用全加器添加两个位的真值表

Carry[i]** x[i] y[i]** Carry[i + 1] Sum[i]
0 0 0 0 0
0 0 1 0 1
0 1 0 0 1
0 1 1 1 0
1 0 0 0 1
1 0 1 1 0
1 1 0 1 0
1 1 1 1 1

要设计一个全加器电路,我们从指定Sum[i]1的函数开始,作为来自表 6-2 的积和项:

Image

此方程式中没有明显的简化,因此让我们看看Sum[i]的卡诺图,如图 6-2 所示。

Image

图 6-2:三个比特的和的卡诺图,进位[i],x[i]y[i]

在图 6-2 中没有明显的分组,因此我们剩下四个积项来计算前面方程中的Sum[i]

你在第四章中学到,进位[i] + 1 可以通过以下方程表示:

Image

这两个函数共同构成了全加器的电路,如图 6-3 所示。

Image

图 6-3:全加器电路

全加器使用九个逻辑门。在接下来的部分中,我们将看到是否能找到一个更简单的电路。

由两个半加器构成的全加器

为了看是否能找到一个更简单的方案来加法两位和来自下一低阶位的进位,让我们回到Sum[i]的方程。利用分配法则,我们可以将其重新排列如下:

Image

在第四章中,你学到了第一个积项中括号里的量是x[i]y[i]的异或(XOR):

Image

因此,我们得出:

Image

让我们操作第二个积项中的括号里的量。回忆一下布尔代数中 x ∧ ¬x = 0,所以我们可以写出如下式子:

Image

因此:

Image

我们将进行一些操作,开发出一个进位[i] [+ 1]的布尔函数,这个操作可能会显得违反直觉。让我们从加法三个比特时的进位卡诺图开始(见图 4-14),但去除两个分组,如图 6-4 中虚线所示。

Image

图 6-4:来自图 4-14 的卡诺图,重新绘制去除了两个重叠分组(虚线)

这将给我们以下方程:

Image 注意,这个方程中的两个项,(x[i] ∧ y[i]) 和 (x[i]y[i]), 已经通过半加器生成(见图 6-1)。通过第二个半加器和一个或门,我们可以实现一个全加器,如图 6-5 所示。

Image

图 6-5:使用两个半加器的全加器

现在你应该理解了半加器全加器这两个术语的来源。

更简单的电路不一定更好。事实上,我们不能仅凭观察图 6-3 和 6-5 中的两个全加器电路就判断哪一个更好。好的工程设计取决于许多因素,例如每个逻辑门的实现方式、逻辑门的成本及其可用性等等。我在这里展示了两种替代方案,以表明不同的方法可以导致不同但功能等效的设计。

波纹进位加法和减法电路

现在你知道如何加两个给定位置的比特,并加上来自下一个低位位置的进位。但是大多数程序处理的数值都有很多比特,因此我们需要一种方法来加两个n位数的每个位的对应比特。这可以通过n位加法器来实现,它可以通过n个全加器来构建。图 6-6 展示了一个 4 位加法器。

Image

图 6-6:一个 4 位加法器

加法从右侧的全加器开始,接收两个最低位的比特,x[0]和y[0]。由于这是最低位,因此没有进位,c[0] = 0。该位的和是s[0],并且来自此加法的进位c[1]被连接到左侧下一个全加器的进位输入,在那里它与x[1]和y[1]相加。因此,第i个全加器将操作数的两个i位加起来,并加上来自(i - 1)个全加器的进位(其值为01)。

每个全加器处理一个比特(通常称为切片)的总宽度。每个位位置的进位将加到下一个更高位位置的比特上。加法过程从最低位流向最高位,以一种波动效应进行,这也赋予了这种加法方法名称——波纹进位加法

请注意,在图 6-6 中,我们有CV,分别是进位标志溢出标志。你在第三章中已经学习了进位和溢出。AArch64 架构包括记录进位和溢出是否发生的加法和减法指令。你将在第九章中学到更多内容。

让我们看看如何使用类似的思路来实现减法。回想一下,在二进制补码表示法中,一个数的负值是通过取它的二进制补码,翻转所有位并加上1来实现的。因此,我们可以通过以下方式从x中减去y

Image

如果我们对每个y[i]取反,并将初始进位设为1,而不是0,那么我们就可以在图 6-5 中的加法器实现减法。每个y[i]可以通过与1进行异或操作来取反。这就得到了图 6-7 中的 4 位电路,当func = 0时,它会加两个 4 位数,当func = 1时,它会减这两个数。

Image

图 6-7:一个 4 位加法器/减法器

当然,在从右到左计算和时会有时间延迟。通过电路设计,利用已知的中间进位值 c[i],可以显著减少计算时间,但我在本书中不会详细讲解这些细节。让我们转到下一个电路类型。

你的回合

6.1 在第三章中,你学习了进位标志(C)和溢出标志(V)。CPU 还具有零标志(Z)和负标志(N)。当算术操作的结果为零时,Z 标志为 1;如果结果是负数,并且该数字被认为是二补码表示,则 N 标志为 1。设计一个电路,使用图 6-7 中的全加器输出,s[0]、s[1]、s[2]、s[3]、c[3] 和 c[4],并输出 CVNZ 标志。

解码器

计算机中的许多地方需要根据一个数字选择多个连接中的一个。例如,正如你将在第八章中看到的,CPU 有一小部分组织成 寄存器 的内存,用于计算。AArch64 架构提供了 31 个通用的 64 位寄存器。如果一条指令使用其中一个寄存器,则必须使用指令中的 5 位来选择应该使用哪个 31 个寄存器之一。

这个选择可以通过 解码器 完成。解码器的输入是寄存器的 4 位数字,输出是连接到指定寄存器的 16 个可能连接中的一个。

解码器有 n 个二进制输入,可以产生最多 2^n 个二进制输出。最常见的解码器类型,有时称为 线路解码器,它根据每个输入位模式只选择一个输出线并将其设为 1。解码器通常还包括一个 使能 输入。表 6-3 是一个带有 使能 输入的 3×8(三个输入,八个输出)解码器的真值表,展示了其工作原理。

表 6-3: 带有使能的 3×8 解码器真值表

输入 输出
使能 x[2] x[1]
--- --- ---
0 0 0
0 0 0
0 0 1
0 0 1
0 1 0
0 1 0
0 1 1
0 1 1
1 0 0
1 0 0
1 0 1
1 0 1
1 1 0
1 1 0
1 1 1
1 1 1

Enable = 0 时,所有输出线都是 0。当 Enable = 1 时,输入的 3 位数 x = x[2]x[1]x[0] 会选择哪个输出线被设置为 1。因此,该解码器可用于用 3 位数字选择八个寄存器中的一个。(我这里没有使用 AArch64 架构中的所有 31 个寄存器,以保持表格的合理大小。)

在表 6-3 中指定的 3×8 行解码器可以通过四输入与门实现,如图 6-8 所示。

Image

图 6-8:带有 Enable 的 3×8 解码器电路

解码器比初看起来的更具多功能性。每一个可能的输入可以视为一个最小项(如需复习最小项,参见第四章中的“标准和或最小项”部分,见第 62 页)。表 6-3 中的行解码器显示,当最小项为 1Enable1 时,只有一个输出为 1。因此,解码器可以看作是一个“最小项生成器”。

我们从第四章知道,任何逻辑表达式都可以表示为最小项的或,因此可以通过对解码器的输出进行或运算来实现任何逻辑表达式。例如,如果回顾全加器的卡诺图(参见图 6-2 和图 6-4),你可能会看到 Sum[i](Carry[i], x[i], y[i]) 和 Carry**i + 1 可以表示为最小项的或运算,

Image

其中,下标 i 表示 xyCarry 的位切片,m 上的下标是最小项符号的一部分。

我们可以用 3×8 解码器和两个四输入或门实现全加器的每个位切片,如图 6-9 所示。一个 n 位加法器需要 n 个这样的电路。

Image

图 6-9:用 3×8 解码器实现的全加器的 1 位切片

图 6-8 中的解码器电路需要八个与门和三个非门。图 6-9 中的全加器增加了两个或门,总共需要 13 个逻辑门。与图 6-5 中的全加器设计相比,该设计仅需要五个逻辑门(两个异或门、两个与门和一个或门),看起来使用解码器构建全加器会增加电路的复杂性。然而,请记住,设计必须考虑其他因素,如元件的可用性、元件成本等。

轮到你了

6.2    你可能见过七段显示器,它们用于显示数字。七段显示器中的每个段通过将1应用到连接该段的输入引脚来点亮。假设你有一个 8 位输入的七段显示器,它点亮段和小数点,如下图所示:

图片

例如,你可以使用位模式0110 1101显示数字 5。然而,写一个程序使用 BCD 编码来表示单个数字会更方便。设计一个解码器,将 BCD 中的数字转换为七段显示器上的段模式。

多路复用器

在前一节中,你了解了如何使用n位数来选择应设置为1的 2^n个输出线路中的一个。相反的情况也会发生,即我们需要选择哪些输入应该被传递。例如,在进行加法等算术操作时,数字可能来自 CPU 内部的不同位置。(你将在接下来的几章中学习更多内容。)操作本身将由一个算术单元执行,而 CPU 需要从所有可能的位置中选择操作的输入。

能够做出这种选择的设备叫做多路复用器(MUX)。它可以通过使用n个选择线路,在 2^n个输入线路之间切换。图 6-10 展示了一个四路多路复用器的电路。

图片

图 6-10:四路多路复用器电路

输出为:

图片

在使用与门和或门时,随着输入数量的增加,实施一个多路复用器所需的晶体管数量会变得很大。每个输入到多路复用器的输入都需要一个三输入与门,每个与门的输出连接到或门的一个输入。所以,或门的输入数量等于多路复用器的输入数量。

这个四路多路复用器需要一个四输入的或门。如果我们尝试扩大规模,n输入的或门将为较大的n带来一些技术电子问题。通过使用一种能够断开其输出信号与输入信号连接的门,可以避免使用n输入的或门,接下来我们将讨论这种门。

三态缓冲器

被称为三态缓冲器的逻辑门有三个可能的输出:01和“无连接”。“无连接”输出实际上是一个高阻抗连接,也称为高 Z开放。三态缓冲器有一个数据输入和一个Enable输入,其行为如图 6-11 所示。

图片

图 6-11:三态缓冲器

Enable = 1时,输出等于输入,并且连接到三态缓冲器之后的电路元件上。但当Enable = 0时,输出实际上是断开的。这与0不同,断开意味着它对连接的电路元件没有任何影响。

“无连接”输出使我们可以物理连接多个三态缓冲器的输出,但只选择其中一个将其输入传递到公共输出线上。图 6-12 中的四路多路复用器展示了我们如何通过使用n个三态缓冲器来避免使用n输入的或门。

图片

图 6-12:由解码器和三态缓冲器构建的四路多路复用器

图 6-12 中的 2×4 解码器选择连接输入wxyz的三态缓冲器,以将其中一个输入连接到输出,形成四路多路复用器。图 6-13 展示了多路复用器使用的电路符号,以及展示其行为的真值表。

图片

图 6-13:四路多路复用器

作为一个使用这种四路多路复用器的例子,考虑一台有四个寄存器和一个加法器的计算机。我们将寄存器命名为wxyz。如果我们将每个寄存器中对应位置的比特连接到多路复用器,那么我们可以使用 2 位选择器s[1]s[0]来选择哪个寄存器将提供输入给加法器。例如,位置 5 的每个比特,w[5]、x[5]、y[5] 和 z[5],将连接到多路复用器 5 的一个输入。如果s[1]s[0] = 10,则加法器的输入将是y[5]。

可编程逻辑器件

到目前为止,我们讨论的是使用单独逻辑门的硬件设计。如果设计发生变化,逻辑门的配置也会发生变化。这几乎总是意味着承载逻辑门并将它们连接起来的电路板需要重新设计。变化也通常意味着需要订购不同类型的逻辑门,这可能既昂贵又需要时间。通过使用可编程逻辑器件(PLDs)来实现所需的逻辑功能,可以减少这些问题。

PLD 包含许多 AND 门和 OR 门,可以编程实现布尔函数。输入及其补码值连接到 AND 门。所有 AND 门一起称为AND 平面AND 阵列。从 AND 门输出的信号连接到 OR 门,所有 OR 门一起称为OR 平面OR 阵列。根据类型,一个或两个平面可以编程实现组合逻辑。在使用 PLD 时,设计变更仅需要改变设备的编程方式,而不是购买不同的设备,这意味着电路板无需重新设计。

PLD 有多种类型。大多数可以由用户编程。有些在制造时预编程,有些甚至可以由用户擦除和重新编程。编程技术范围从指定制造掩模(对于预编程设备)到廉价的电子编程系统。在本节中,我们将看看 PLD 的三个主要类别。

可编程逻辑阵列

可编程逻辑阵列(PLA)中,AND 平面和 OR 平面都是可编程的。PLA 用于实现逻辑函数。图 6-14 提供了具有两个输入变量和这些变量两种可能输出函数的 PLA 的一般思路。

图片

图 6-14:可编程逻辑阵列的简化电路

每个输入变量,无论是其未补码形式还是补码形式,都通过保险丝输入到 AND 门中。保险丝是用于保护电路的薄导体片。如果通过它的电流足够大,导体会熔化,打开电路并停止电流流动。可编程逻辑器件可以通过断开(或烧断)适当的保险丝来编程,从而移除输入到逻辑门的信号。某些设备使用反保险丝代替保险丝;这些通常是开路的,编程它们意味着完成连接而不是移除连接。可以重新编程的设备具有可以烧断然后再连接的保险丝。

在图 6-14 中,电路图中的 S 形线代表保险丝。这些保险丝可以被烧断或保持不变,以便编程每个 AND 门输出输入变量xx, y, 和 ¬y的乘积。由于每个输入及其补码都作为输入到每个 AND 门中,任何 AND 门都可以被编程以输出一个最小项。

由 AND 门平面产生的乘积连接到 OR 门的输入,同样通过保险丝。因此,根据哪些 OR 门的保险丝保持不变,每个 OR 门的输出就是一个乘积和的总和。可能还有额外的逻辑电路用于选择不同的输出之间。您已经看到,任何布尔函数都可以表达为乘积和,因此通过烧断保险丝,此逻辑器件可以编程实现任何布尔函数。

一个 PLA 通常比图 6-14 中显示的更大,后者已经复杂到难以绘制。为了简化绘制,通常使用类似图 6-15 的示意图来指定设计。

Image

图 6-15:可编程逻辑阵列的示意图,其中的点表示连接

这个图可能有点难以理解。在图 6-14 中,每个与门有多个输入:每个变量一个,每个变量的补集一个。在图 6-15 中,我们使用一条单独的水平线连接到每个与门的输入,以表示多根导线(变量和补集),因此图 6-15 中的每个与门有八个输入,尽管我们只画了一条线。

垂直线和水平线交点处的点表示保险丝未被切断的地方,从而形成连接。例如,最上方水平线上的三个点表示有三个输入连接到那个与门。最上方与门的输出为:

Image

再次参考图 6-14,你可以看到每个与门的输出都连接到每个或门(通过保险丝)。因此,或门也有多个输入——每个与门一个——而通向或门输入的垂直线代表多根导线。图 6-15 中的 PLA 已被编程以提供以下三种功能:

Image

由于与门阵列可以产生所有可能的最小项,而或门阵列可以提供任何最小项的和,PLA 可以用来实现任何可能的逻辑功能。如果我们想改变功能,只需简单地编程另一个 PLA 并替换旧的即可。

只读存储器

虽然 PLD 没有内存(意味着当前状态不受输入先前状态的影响),但它们可以用来制造非易失性内存——即在断电时仍能保持内容的内存。只读存储器(ROM)用于存储比特模式,这些模式可以表示数据或程序指令。程序只能读取存储在 ROM 中的数据或程序;ROM 的内容不能通过写入新的数据或程序指令来改变。ROM 通常用于具有固定功能集的设备中,如手表、汽车发动机控制单元和家电。事实上,我们的生活中充满了由存储在 ROM 中的程序控制的设备。

ROM 可以作为 PLD 实现,其中只有或门平面可以编程。与门平面保持连接,提供所有最小项。我们可以将 ROM 的输入视为地址;然后,或门平面被编程以在每个地址提供位模式。例如,在图 6-16 中示意的 ROM 设备有两个输入,a[1]和a[0],它们提供一个 2 位的地址。

图片

图 6-16:一个 4 字节 ROM 设备

在图 6-16 中的×连接表示永久连接,显示与门平面是固定的。每个与门在 ROM 设备中的每个地址生成一个最小项。或门平面可以生成最多 2^n个 8 位字节,其中 n 是输入到与门平面的地址输入位数。到或门的连接(点)表示存储在相应地址的位模式。表 6-4 显示了一个 ROM 设备,其中或门平面已经被编程为存储四个字符 0、1、2 和 3(以 ASCII 码表示)。

表 6-4: 一个存储四个 ASCII 字符的 ROM 设备

最小项 地址 内容 ASCII 字符
¬a[1]¬a[0] 00 00110000 0
¬a[1]a[0] 01 00110001 1
a[1] ¬a[0] 10 00110010 2
a[1]a[0] 11 00110011 3

尽管在此示例中我们仅存储了数据,但计算机指令是位模式,因此我们同样可以将整个程序存储在 ROM 设备中。与 PLA 一样,如果需要更改程序,只需重新编程另一个 ROM 设备并替换旧的设备。

ROM 设备有多种类型。尽管在制造过程中 ROM 设备的位模式是固定的,可编程只读存储器(PROM)设备则由使用者进行编程。此外,还有可擦除可编程只读存储器(EPROM)设备,可以通过紫外线光擦除后再进行重新编程。

可编程阵列逻辑

可编程阵列逻辑(PAL)设备中,每个或门都被永久连接到一组与门上。只有与门平面是可编程的。图 6-17 中示意的 PAL 设备有四个输入和两个输出,每个输出最多可以是四个乘积的和。

图片

图 6-17:一个具有两种功能的可编程阵列逻辑设备

在或门平面中,“×”连接表示顶部四个与门进行或运算,产生 F1,底部四个与门进行或运算,产生 F2。此图中的与门平面已经被编程为生成这两个功能:

图片

在这里介绍的三种可编程逻辑设备中,PLA 设备是最灵活的,因为我们可以编程 OR 平面和 AND 平面,但它也是最昂贵的。ROM 设备的灵活性较差:它可以编程生成任意组合的最小项,然后将它们 OR 在一起。我们知道任何函数都可以作为最小项的 OR 来实现,因此我们可以用 ROM 设备生成任何函数,但 ROM 设备不允许我们最小化函数,因为所有的积项必须是最小项。

PAL 设备是最不灵活的,因为在 AND 平面中编程的所有积项都会被 OR 在一起。因此,我们不能通过编程 OR 平面来选择哪些最小项包含在函数中。然而,PAL 设备允许我们进行一些布尔函数最小化。如果所需的函数可以在 PAL 设备中实现,那么使用 PAL 设备将比使用 ROM 或 PLA 设备更便宜。

轮到你了

6.3 设计一个 ROM 设备,存储四个字符 a、b、c 和 d。

6.4 设计一个 ROM 设备,存储四个字符 A、B、C 和 D。

6.5 比较两个值以确定哪个较大,或者它们是否相同,是计算中的常见操作。执行此类比较的硬件设备称为比较器。使用可编程逻辑设备设计一个比较器,用于比较两个 2 位值。你的比较器将有三个输出:相等、大于和小于。

你学到了什么

组合逻辑电路 这些电路仅依赖于任何时刻的输入。它们没有记忆输入之前的效果。示例包括加法器、解码器、多路复用器和可编程逻辑设备。

半加器 该电路有两个 1 位输入,并产生两个 1 位输出:输入的和以及该和的进位。

全加器 该电路有三个 1 位输入,并产生两个 1 位输出:输入的和以及该和的进位。

脉冲进位加法器 该电路使用n个全加器来加法n位数。每个全加器的进位输出是下一个更高位位置的全加器的三个输入之一。

解码器 一种根据 2^n个输入选择n个输出之一的设备。

多路复用器(MUX) 一种根据n位选择信号选择 2^n个输入中的一个的设备。

可编程逻辑阵列(PLA) 一种用于生成最小项的或逻辑组合以实现布尔函数的硬件设备。

只读存储器(ROM) 非易失性存储器,输入为数据或指令的地址。

可编程阵列逻辑(PAL) 一种用于在硬件中实现布尔函数的设备。它比 PLA 或 ROM 设备灵活性较差,但也更便宜。

在下一章,你将学习顺序逻辑电路,它们使用反馈来保持其活动的记忆。

第七章:顺序逻辑电路**

Image

在上一章中,你学习了组合逻辑电路,它们仅依赖于当前的输入。换句话说,组合逻辑电路是瞬时的(除了电子元件需要的稳定时间):它们的输出仅依赖于在观察输出时的输入。另一方面,顺序逻辑电路依赖于当前输入和过去输入。它们有一个时间历史,可以通过电路的当前状态来总结。

从形式上讲,系统状态是对系统的一种描述,使得在时间 t[0] 时的状态和从时间 t[0] 到时间 t[1] 的输入能够唯一地确定在时间 t[1] 时的状态,以及从时间 t[0] 到时间 t[1] 的输出。换句话说,系统状态提供了影响系统的所有因素的总结。知道系统在任意时刻 t 的状态,你就能知道从此时起系统的行为如何。这些状态是如何产生的则无关紧要。

系统状态的概念通过有限状态机得以体现,有限状态机是一种计算的数学模型,它存在于有限个状态中的某一个。有限状态机的外部输入使得它从一个状态转移到另一个状态,或者保持在同一状态,同时可能产生一个输出。顺序逻辑电路用于实现有限状态机。如果一个顺序逻辑电路的输出仅依赖于它所处的状态,那么它被称为摩尔状态机。如果输出还依赖于导致状态转换的输入,那么它被称为梅利状态机

在本章中,你将学习如何在逻辑电路中使用反馈来保持门电路在特定的状态下,从而实现存储。我们将使用状态图来展示输入如何导致顺序逻辑电路在不同状态之间转换,以及相应的输出是什么。你还将学习如何使用时钟同步顺序逻辑电路,以提供可靠的结果。

锁存器

我们将首先学习的顺序逻辑电路是锁存器,它是一个 1 位存储设备,依据输入可以处于两种状态之一。锁存器可以通过连接两个或更多的逻辑门来构造,使得一个门的输出作为另一个门的输入;只要电源保持连接,这样就能保持两个门的输出处于相同的状态。锁存器的状态不依赖于时间。(术语锁存器也用于描述一个多位存储设备,其行为类似于这里描述的 1 位设备。)

使用 NOR 门的 SR 锁存器

最基本的锁存器是 置位-复位 (SR) 锁存器。它有两个输入,SR,以及两个状态,置位复位。该状态作为主要输出,Q。通常还会提供补充输出,¬Q。当输出为 Q = 1 和 ¬Q = 0 时,SR 锁存器处于置位状态。当 Q = 0 和 ¬Q = 1 时,它处于复位状态。图 7-1 展示了使用 NOR 门实现 SR 锁存器的简单示例。

图片

图 7-1:SR 锁存器的 NOR 门实现

每个 NOR 门的输出都被送入另一个门的输入。正如我在本章中描述电路的行为时,你将看到这种反馈是保持锁存器处于某一状态的原因。

SR 锁存器有四种可能的输入组合,如下所述:

S = 0, R = 0:保持当前状态

如果锁存器处于置位状态 (Q = 1 和 ¬Q = 0),输入 S = 0R = 0 将导致上方 NOR 门输出 ¬(01) = 0,下方 NOR 门输出 ¬(00) = 1。相反,如果锁存器处于复位状态 (Q = 0 和 ¬Q = 1),则上方 NOR 门输出为 ¬(00) = 1,下方 NOR 门输出为 ¬(10) = 0。因此,两个 NOR 门之间的交叉反馈维持了锁存器当前的状态。

S = 1, R = 0:置位 (Q = 1)

如果锁存器处于复位状态,这些输入将导致上方 NOR 门输出为 ¬(10) = 0,从而将 ¬Q 改为 0。这一输出被反馈至下方 NOR 门的输入,得到 ¬(00) = 1。下方 NOR 门输出的反馈保持上方 NOR 门输出为 ¬(11) = 0。锁存器随后进入置位状态 (Q = 1 和 ¬Q = 0)。

如果锁存器处于置位状态,则上方的 NOR 门输出 ¬(11) = 0,下方的 NOR 门输出为 ¬(00) = 1。因此,锁存器保持在置位状态。

S = 0, R = 1:复位 (Q = 0)

如果锁存器处于置位状态,则下方的 NOR 门输出 ¬(01) = 0,从而将 Q 改为 0。这一输出被反馈至上方 NOR 门的输入,得到 ¬(00) = 1。上方 NOR 门输出的反馈保持下方 NOR 门的输出为 ¬(11) = 0。锁存器随后进入复位状态(Q = 0,¬Q = 1)。

如果锁存器已经处于复位状态,则下方的 NOR 门输出 ¬(11) = 0,上方的 NOR 门输出为 ¬(00) = 1,因此锁存器保持在复位状态。

S = 1, R = 1:不允许

如果Q = 0 且 ¬Q = 1,则上部的 NOR 门产生 ¬(10) = 0。这一结果被反馈到下部 NOR 门的输入,产生 ¬(01) = 0。这将导致Q = ¬Q,这与布尔代数定律不一致。

如果Q = 1 且 ¬Q = 0,则下部的 NOR 门产生 ¬(01) = 0。这一结果被反馈到上部 NOR 门的输入,产生 ¬(10) = 0。这将导致Q = ¬Q,这是不一致的。电路必须设计成避免这种输入组合。

为了简化,我们可以通过图示表示这一逻辑。图 7-2 介绍了一种图形化方式来展示 NOR 门 SR 锁存器的行为:状态图。在该图中,当前状态显示在气泡中,且对应的主输出位于状态下方。带有箭头的线显示状态之间的可能转换,并标注了导致状态转换到下一状态的输入。

Image

图 7-2:NOR 门 SR 锁存器的状态图

图 7-2 中的两个圆圈表示 SR 锁存器的两个可能状态:设置或重置。线上的标签显示了导致每次状态转换的输入组合SR。例如,当锁存器处于重置状态时,有两个可能的输入,SR = 00SR = 01,这将使其保持在该状态。输入SR = 10会使其转换到设置状态。由于输出仅依赖于状态,而不依赖于输入,因此锁存器是一个摩尔状态机。

熟悉图论的人会认识到,状态图是一个有向图:状态是顶点,导致转换的输入是边。尽管这些内容超出了本书的范围,但图论中的工具在设计过程中是非常有用的。

和图论一样,我们也可以通过状态转换表以表格形式展示相同的行为,如表 7-1 所示。

表 7-1: NOR 门 SR 锁存器状态转换表

S R Q Q[next]
0 0 0 0
0 0 1 1
0 1 0 0
0 1 1 0
1 0 0 1
1 0 1 1
1 1 0 x
1 1 1 x

在表 7-1 中,SR 是输入,Q 是当前状态下的输出,Q[next] 显示由于相应输入所导致的下一状态的输出。底部两行中的 x 表示一种不可能的情况。

对于 NOR 门 SR 锁存器,两个输入通常保持在 0,从而维持当前状态,使输出为Q。暂时将R改为 1 会使状态重置,从而将输出更改为Q = 0,如状态转换表中的Q[next]列所示。暂时将S改为 1 会使状态设置,从而使输出为Q = 1

如前所述,输入组合 S = R = 1 不被允许,因为这会导致 SR 锁存器处于不一致状态,正如状态转换表中的 Q[next] 列所示的禁止行,那里标有 x。

使用 NAND 门的 SR 锁存器

它们的构造物理特性使得 NAND 门比 NOR 门更快。让我们从 NOR 门输出的方程式开始:

Image

从德摩根定律,我们得到以下结果:

Image

这表明,如果我们对两个输入取反,则 NAND 门在功能上等同于 NOR 门,只是输出取反。

这导致了如图 7-3 所示的电路,使用 ¬S 和 ¬R 作为输入。为了强调两种设计的逻辑对偶性(NAND 和 NOR),我绘制了电路,输出 Q 位于顶部,¬Q 位于底部。

Image

图 7-3:SR 锁存器的 NAND 门实现

与 NOR 门 SR 锁存器一样,NAND 门 SR 锁存器在输出为 Q = 1 且 ¬Q = 0 时被认为处于置位状态;在 Q = 0 且 ¬Q = 1 时则处于复位状态。输入组合有四种可能:

¬S = 1, ¬R = 1:保持当前状态

如果锁存器处于置位状态(Q = 1 且 ¬Q = 0),则上部 NAND 门输出为¬(10) = 1,下部 NAND 门输出为¬(11) = 0。如果 Q = 0 且 ¬Q = 1,则锁存器处于复位状态;上部 NAND 门输出为¬(11) = 0,下部 NAND 门输出为¬(01) = 1。两个 NAND 门之间的交叉反馈保持锁存器的状态。

¬S = 0, ¬R = 1:置位(Q = 1)

如果锁存器处于复位状态,则上部 NAND 门的输出为¬(01) = 1,从而将 Q 更改为 1。该信号反馈到下部 NAND 门的输入,得到¬(11) = 0。从下部 NAND 门输出到上部 NAND 门输入的反馈保持上部门的输出为¬(00) = 1。锁存器已进入置位状态(Q = 1 且 ¬Q = 0)。

如果锁存器已处于置位状态,则上部 NAND 门输出为¬(0mathtt0) = 1,下部 NAND 门的输出为¬(11) = 0。因此,锁存器保持在置位状态。

¬S = 1, ¬R = 0:复位(Q = 0)

如果锁存器处于置位状态,下部 NAND 门输出为¬(10) = 1。该信号反馈到上部 NAND 门的输入,从而使 Q = ¬(11) = 0。从上部 NAND 门输出到下部 NAND 门输入的反馈保持下部门的输出为¬(00) = 1,因此锁存器进入复位状态(Q = 0 且 ¬Q = 1)。

如果锁存器已经处于复位状态,底部的 NAND 门输出¬(00) = 1,顶部 NAND 门的输出是¬(11) = 0。锁存器保持在复位状态。

¬S = 0, ¬R = 0:不允许

如果锁存器处于复位状态,顶部的 NAND 门输出¬(01) = 1。这个结果会反馈到底部 NAND 门的输入,导致输出¬(10) = 1。这也会导致Q = ¬Q,这种情况是不一致的。

如果锁存器处于设定状态,底部的 NAND 门输出¬(10) = 1。这个结果会反馈到顶部 NAND 门的输入,导致输出¬(01) = 1。这也会导致Q = ¬Q,这种情况是不一致的。电路设计必须防止这种输入组合。

图 7-4 展示了使用状态图表示的 NAND 门 SR 锁存器的行为。

Image

图 7-4:NAND 门 SR 锁存器

将此与图 7-2 中的 NOR 门 SR 锁存器进行比较,你会发现它们描述的是相同的行为。例如,向 NOR 门 SR 锁存器输入SR = 10会将其置于设定状态,而输入

¬S¬R = 01输入到 NAND 门 SR 锁存器会将其置于设定状态。我发现,在分析电路时,必须仔细思考这个问题。当只有两个选择时,一点点偏差就可能导致行为与预期相反。

表 7-2 是一个 NAND 门 SR 锁存器的状态转移表。

表 7-2: 一个 NAND 门 SR 锁存器状态转移表

¬S ¬R Q Q[next]**
1 1 0 0
1 1 1 1
1 0 0 0
1 0 1 0
0 1 0 1
0 1 1 1
0 0 0 x
0 0 1 x

同时将0输入到两个端口会导致问题——即两个 NAND 门的输出都会变为1。换句话说,Q = ¬Q = 1,这是逻辑上不可能的。电路设计必须防止这种输入组合。底部两行中的 x 表示一个不可能的状态。

使用两个 NAND 门实现的 SR 锁存器可以被看作是 NOR 门 SR 锁存器的补集。通过将¬S和¬R保持在1,状态得以保持。暂时将¬S设为0会将状态设定为Q = 1,而将¬R设为0会将其复位,输出Q = 0

到目前为止,我们一直在查看一个单独的锁存器。问题在于,当输入发生变化时,锁存器的状态及其输出也会发生变化。在计算机中,它将与许多其他设备互连,每个设备都随新输入变化状态。每个设备改变状态并将其输出传播到下一个设备需要时间。精确的时序取决于设备制造中的细微差异,因此结果可能不可靠。我们需要一种手段来同步活动,为操作带来一些秩序。我们将通过向 SR 锁存器添加一个Enable输入来开始,这将使我们能够更精确地控制何时允许输入影响状态。

带 Enable 的 SR 锁存器

通过添加两个 NAND 门以提供Enable输入,我们可以更好地控制 SR 锁存器。将这两个 NAND 门的输出连接到¬S¬R锁存器的输入,便得到了一个门控 SR 锁存器,如图 7-5 所示。

Image

图 7-5:带门控的 SR 锁存器

在这个电路中,只要Enable = 0,两个控制 NAND 门的输出就会保持在1。这将发送¬S = 1 和 ¬R = 1 到该电路的¬S¬R锁存器部分的输入,从而使状态保持不变。通过将额外的Enable输入与SR输入线进行与运算,我们可以控制状态何时改变为下一个值。

表 7-3 显示了带Enable控制的 SR 锁存器的状态行为。

表 7-3: 带门控的 SR 锁存器状态转换表

Enable S R Q Q[next]**
0 0 0
0 1 1
1 0 0 0 0
1 0 0 1 1
1 0 1 0 0
1 0 1 1 0
1 1 0 0 1
1 1 0 1 1
1 1 1 0 x
1 1 1 1 x

在表 7-3 中,a —表示某个输入无关紧要,x 表示禁止的结果。如前所述,设计必须防止产生禁止结果的输入组合。只有当Enable = 1时,锁存器的状态才能跟随SR输入。因此,这种设备被称为电平触发型

在下一节中,我将简化门控的 SR 锁存器,并创建一个只接收单一数据输入D的锁存器,并控制何时这个输入会影响锁存器的状态。

D 型锁存器

D 型锁存器允许我们存储 1 位的值。我们从表 7-4 的真值表开始,该表包括表 7-3 中当Enable = 1R = ¬S时的行。

表 7-4:Enable的 D 型锁存器真值表

Enable S R D Q Q[next]**
0 0 0
0 1 1
1 0 1 0 0 0
1 0 1 0 1 0
1 1 0 1 0 1
1 1 0 1 1 1

我们正在寻找一种设计,具有两个输入:一个是使能,另一个是D(数据的简写)。我们希望D = 1时设置状态,使得输出Q = 1,而D = 0时重置状态,使得输出Q = 0,当使能线变为1时。D的值在使能 = 0时不应对状态产生任何影响。

我们可以通过添加一个 NOT 门,从一个带使能的 SR 锁存器构造出一个带使能的 D 锁存器,具体如图 7-6 所示。

Image

图 7-6:由 SR 锁存器构造的带使能的 D 锁存器

唯一的数据输入D被送入 SR 锁存器的S端;数据值的反码被送入R端。

现在,我们有了一个可以使用D输入存储 1 位数据的电路,并且可以通过使能输入与其他操作进行同步。然而,D 锁存器存在一些问题。D 锁存器的状态可能会在启用时受到输入的影响。因此,它的输出可能在锁存器启用时发生变化,这使得它很难与其他设备可靠地同步。

这种方案在锁存器需要长时间保持在某一状态时非常有效。一般来说,锁存器适用于那些我们希望选择一个状态并保持一段时间的操作,这段时间与计算机中的其他操作不同步。例如,I/O 端口的时序取决于连接到端口的设备的行为。例如,正在运行的程序无法知道用户何时按下键盘上的一个键。当按下键时,程序可能还没有准备好接收字符,因此该字符的二进制代码应被锁存到输入端口中。一旦字符代码存储在输入端口,锁存器将被禁用,直到程序从锁存器读取字符代码。

CPU 和主内存中的大多数计算操作必须进行时间上的协调。将多个电路连接到相同的时钟信号可以同步它们的操作。让我们考虑如何同步连接在电路中的 D 锁存器。我们可以将一个输入信号送入这个 D 锁存器,并通过时钟信号使能锁存器,但如果输入变化,它的输出也会变化,这会导致在启用时输出不可靠。如果我们的 D 锁存器的输出连接到另一个设备的输入,那么在 D 锁存器启用时,第二个设备的输入也变得不可靠。为了解决这个问题并提供可靠的输入,我们应当在 D 锁存器稳定后禁用它。

由于连接的物理原因,我们的 D 触发器的输出到达第二个设备的输入也需要一些时间,这被称为传播延迟。因此,第二个设备应该被禁用,直到我们 D 触发器的输入是可靠的,并且我们已经考虑了传播延迟。

当第二个设备被禁用并等待来自我们 D 触发器的可靠输入时,它的输出(来自上一个时钟周期)是可靠的。因此,如果它连接到另一个设备的输入,这第三个设备就可以被启用。这导致了一种方案,其中每隔一个设备被启用,而交替的设备被禁用。在等待的时间等于所有连接设备的最长稳定时间和传播延迟时间之和后,禁用的设备被启用,启用的设备被禁用。数字10通过这种交替启用/禁用周期在这些设备的电路中传播。

正如你可能想象的那样,协调启用和禁用之间的这种反复切换可能是困难的。我将在下一节中给出这个问题的解决方案。

翻转触发器

虽然触发器可以通过时钟信号的电平来控制,但在时钟信号启用触发器的时间段内,输入的任何变化都会影响它的输出。翻转触发器在时钟周期的特定时间提供输出,例如当时钟信号从0转换为1时。由于输出在时钟信号的转换点变得可用,它被称为边缘触发。触发事件发生后,翻转触发器的输出会持续整个时钟周期。这提供了将多个翻转触发器连接在一起并通过一个时钟同步它们操作所需的可靠性。我将从时钟的讨论开始,然后我们将看看一些翻转触发器的例子。

注意

术语有所不同。有些人也称触发器为翻转触发器。我将使用术语触发器来指代一个级别触发的设备,没有时间要求,而翻转触发器则指由时钟信号控制的边缘触发设备。

时钟

顺序逻辑电路具有时间历史,这些历史被其状态所总结。我们通过时钟来跟踪时间,时钟是一个提供电子时钟信号的设备。这通常是一个方波,在01电平之间交替变化,如图 7-7 所示。这个信号作为需要同步的设备的启用/禁用输入。

Image

图 7-7:用于同步顺序逻辑电路的典型时钟信号

为了实现可靠的行为,大多数同步电路使用边缘触发的设备。每个电平上的时间通常是相同的,时钟信号的正边缘(01)或负边缘(10)都可以使用。

时钟频率必须足够慢,以便电路元件有时间在下一个时钟过渡发生之前完成它们的操作。例如,可靠操作锁存器或触发器要求输入信号在设备启用之前的某段时间内保持稳定,这段时间称为建立时间。输入信号在启用信号开始后必须保持稳定一段时间,这段时间称为保持时间。实际上,这些时间可能会随温度、制造差异等因素变化。硬件设计人员需要参考制造商的规格来确定这些时间值的极限。

让我们看几个可以由时钟控制的触发器电路的例子。

D 触发器

我们将通过将时钟信号连接到图 7-6 中的受控 D 锁存器的Enable输入开始。在这里,只要Enable = 1,输入就会影响输出。问题是如果在Enable = 1时输入发生变化,输出也会变化,导致设计不可靠。

一种将输出与输入变化隔离的方法是将我们的 D 锁存器的输出连接到 SR 锁存器的输入,以主/从配置连接,如图 7-8 所示。

图片

图 7-8:一个 D 触发器,正边沿触发

D 触发器的主部分处理输入并向副部分提供可靠的输入以获得最终输出。我们想要存储的位01被送入 D 锁存器的D输入,时钟信号则送入CLK输入。D 锁存器的未反转输出送入S输入,其反转输出送入 SR 锁存器的R输入。D 触发器的最终输出来自 SR 锁存器的输出。

我将向你解释这个电路是如何工作的。主部分的行为在表 7-4 中显示了 D 锁存器的真值表。副部分的行为在表 7-3 中显示了 SR 锁存器的真值表。图 7-9 展示了我们 D 触发器中关键点的时序。

图片

图 7-9:D 触发器的时序

图 7-9 中的数据输入D与时钟信号并不完全同步,它的时序有些不规则。这可能是由于传播延迟、其他组件的干扰、电路中的温度梯度以及其他因素引起的。

我们从 CLK 首次变为0的时刻开始。这个信号被反转,从而启用 D 锁存器。D 锁存器的输出S跟随D输入,从0变为1。时钟路径中的第二个反相器禁用 SR 锁存器,从而锁存触发器的输出Q,保持在0电平。

当 CLK 信号为1时,D 锁存器被禁用,这将其输出SR分别锁存为10。在这个时钟半周期内,这为 SR 锁存器提供了稳定的输入信号。经过两次反转的 CLK 信号使 SR 锁存器被启用,从而使触发器的输出Q变为1

然后,CLK 信号变为0,禁用二级部分的 SR 锁存器,这在该时钟半周期内保持在1电平。

因此,触发器引入了一个半个时钟周期的时间延迟,用于接受输入并提供输出,但输出在整个时钟周期内保持稳定。输出在一个精确的时间点可用,即从01的过渡。这就是所谓的正边缘触发。如果图 7-8 中连接到 CLK 信号的第一个 NOT 门被移除,我们就会得到一个具有负边缘触发的 D 触发器。

有时,触发器必须在时钟信号开始之前被设置为已知值——例如,在计算机启动时。这些已知值是独立于时钟过程输入的;因此,它们是异步输入

图 7-10 展示了一种添加了异步预置PR)输入的 D 触发器。

Image

图 7-10:带有异步预置的正边缘触发 D 触发器

1被施加到PR输入时,Q变为1,而¬Q变为0,无论其他输入是什么——即使是 CLK 也不例外。通常也会有一个异步清除输入(CLR),它将状态(和输出)设置为0

虽然实现边沿触发的 D 触发器有更高效的电路,但本讨论展示了它们如何通过普通的逻辑门来构建。它们既经济又高效,因此被广泛应用于超大规模集成(VLSI)电路中,这些电路在单一半导体微芯片上集成了数十亿个晶体管门。

与其绘制每个 D 触发器的实现细节,电路设计师通常使用图 7-11 中显示的符号。

Image

图 7-11:用于 D 触发器的符号:(a)正边缘触发,(b)负边缘触发

图 7-11 中标出了各种输入和输出。硬件设计师通常使用Ǭ代替¬Q。通常会将触发器标记为Qn,其中n = 1, 2, . . .,用来识别整个电路中的触发器。图 7-11(b)中时钟输入处的小圆圈表示该 D 触发器是由负脉冲时钟触发的。

现在你已经看到了保存状态的一些逻辑组件,让我们来看看使用这些组件设计顺序逻辑电路的过程。

设计顺序逻辑电路

我们将考虑设计时序逻辑电路的一般步骤。设计通常是迭代的,正如你从编程经验中无疑已经学到的那样。你从一个设计开始,分析它,然后完善设计,使其更快、更便宜,等等。随着经验的积累,设计过程通常需要更少的迭代。

以下步骤是构建第一个工作设计的好方法:

  1. 从问题的自然语言描述中,创建状态转换表和状态图,展示电路需要执行的操作。这些形成了你将设计的电路的基本技术规范。

  2. 选择一个二进制代码来表示状态,并创建状态表和/或状态图的二进制编码版本。对于N个状态,代码需要 log[2] N位。任何代码都可以使用,但某些代码可能会导致电路中组合逻辑的简化。

  3. 选择一个触发器类型。这个选择通常由你手头的组件决定。

  4. 确定每个触发器所需的输入,以实现每个所需的转换。

  5. 简化每个触发器的输入。卡诺图或代数方法是简化过程中的好工具。

  6. 绘制电路。

步骤 5 可能会让你重新思考选择的触发器类型。选择触发器、确定输入和简化这三个步骤可能需要反复进行,直到达到一个好的设计。以下两个例子说明了这个过程。你可以把这些当作引导性的“你的回合”练习;如果你有数字电路模拟器或所需的硬件,我建议你利用这些资源跟随练习。

计数器

我们想设计一个具有使能输入的计数器。当使能 = 1时,它会按照序列 0, 1, 2, 3, 0, 1, . . . 递增,每次时钟信号触发时递增。使能 = 0会使计数器保持在当前状态。输出是该序列的 2 位二进制数。以下是步骤:

步骤 1:创建状态转换表和状态图。

每个时钟信号触发时,如果使能 = 1,计数器会递增 1。如果使能 = 0,它会保持在当前状态。图 7-12 展示了四个状态—0, 1, 2, 3—以及每个状态对应的 2 位输出。

图像

图 7-12:计数器的状态图,循环经过 0, 1, 2, 3, 0, 1, . . .

表 7-5 展示了这个计数器的状态转换表。

表 7-5:计数器的状态转换表

使能 = 0 使能 = 1
当前n**** 下一个n**** 下一个n****
--- --- ---
0 0 1
1 1 2
2 2 3
3 3 0

使能 = 0时,计数器基本上是关闭的;当使能 = 1时,计数器会自动递增 1,达到上限 3 后回绕到 0。

步骤 2:创建状态表/状态图的二进制编码版本。

对于四个状态,我们需要 2 位。我们将n定义为状态,用 2 位二进制数n[1]n[0]表示。表 7-6 展示了其行为。

表 7-6: 2 位计数器的状态转换表

当前 下一个
使能 n[1] n[0]
--- --- ---
0 0 0
0 0 1
0 1 0
0 1 1
1 0 0
1 0 1
1 1 0
1 1 1

步骤 3:选择触发器。

我们将使用 D 触发器。经过设计后,我们可能会决定使用另一种触发器效果更好。然后,我们可以返回这一阶段,重新执行剩余步骤。一位经验丰富的设计师可能会对问题有一些见解,建议从另一种类型的触发器开始。通常,任何潜在的成本或功耗节省都不足以证明更换为另一种类型的触发器是值得的。

步骤 4:确定触发器的输入。

我们需要两个触发器,每个比特一个。D 触发器会在下一个时钟周期存储其输入的值,因此导致每个触发器变化到下一个状态的输入在表 7-6 的“Next”列下显示。

我们可以写出布尔方程,显示输入使能(Enable)和当前的n[1]和n[0]的逻辑组合,从而产生所需的输入。我们将使用E表示使能,用D[1]和D[0]表示各自 D 触发器的输入:

Image

步骤 5:简化所需的输入。

我们可以使用卡诺图来找到更简单的解决方案,如图 7-13 所示。

Image

图 7-13:使用 D 触发器实现的 2 位计数器的卡诺图

卡诺图使我们能够简化每个触发器输入的布尔方程:

Image

步骤 6:绘制电路图。

我们将使用一个 PLA(在第六章的“可编程逻辑阵列”中介绍)来生成两个 D 触发器的输入。图 7-14 展示了我们用来实现该计数器的电路。

Image

图 7-14:用 PLA 和两个 D 触发器实现的 2 位计数器

图 7-15 展示了二进制计数器的时序图,当其按顺序进展 3、0、1、2、3(11, 00, 01, 10, 11)时。

Image

图 7-15:用 D 触发器实现的 2 位计数器的时序图

D[i] 是第 i 个 D 触发器的输入,n[i] 是其输出。请记住,当第 i 个输入 D[i] 被应用到其 D 触发器时,触发器的输出直到时钟周期的后半段才会发生变化。这可以通过比较图中相应输出 n[i] 的迹线来看到。

分支预测器

在我们的第二个示例中,我们将设计一个分支预测器。这个例子比前一个要复杂一些。

除了非常便宜的微控制器,大多数现代 CPU 都分阶段执行指令。每个阶段由专门的硬件组成,用于执行该阶段的操作。指令像流水线一样通过每个阶段。例如,如果你要创建一个流水线来制造木椅,你可以分为三个阶段:锯木头制作椅子的零件,组装零件,最后涂漆椅子。每个阶段所需的硬件分别是锯子、锤子和螺丝刀、以及画笔。

CPU 中专用硬件的安排称为流水线。流水线的第一阶段硬件设计用于从内存中提取指令,正如你在第九章中将看到的那样。在从内存中提取指令后,指令会传递到流水线的下一个阶段,在那里进行解码。同时,流水线的第一阶段会从内存中提取下一条指令。结果是 CPU 同时处理多条指令,这提供了一些并行性,从而提高了执行速度。

几乎所有程序都包含条件分支点——即下一个要提取的指令可能位于两个不同内存位置的地方。不幸的是,直到决策指令经过流水线的多个阶段,才知道应该提取哪一条指令。

为了保持执行速度,一旦条件分支指令从提取阶段传递过来,如果 CPU 能够预测从哪里提取下一条指令,就会很有帮助。然后,它可以继续执行。如果预测错误,CPU 会通过清空流水线并提取其他指令来忽略在预测指令上所做的工作,新的指令进入流水线的开始阶段。

在这一节中,我们将设计一个电路,用于预测条件分支是否会被执行。预测器将持续预测相同的结果,直到连续两次预测错误,分支才会被执行或不执行。

以下是我们设计分支预测器电路时将遵循的步骤:

步骤 1:创建状态表和状态图。

我们将使用“Yes”表示分支被执行,使用“No”表示分支未被执行。状态图在图 7-16 中显示了四种可能的状态。

图片

图 7-16:我们的分支预测器的四种可能状态

让我们从 No 状态开始。在这里,至少在最后两次执行此指令时,分支都未被执行。输出是预测这次也不会被执行。电路的输入是指令执行完毕后,分支是否实际被执行。

在图 7-16 中标记为 Actual = Not Taken 的弧线回到 No 状态,预测(输出)是下次执行指令时分支将不会被执行。如果分支被执行,Actual = Taken 的弧线表明电路进入 No–Error 状态,表示预测出现一次错误。但因为必须连续两次出错才能改变预测,所以电路仍然预测不会执行分支(Don’t Take)作为输出。

从 No–Error 状态出发,如果分支未被执行(预测正确),电路返回到 No 状态。然而,如果分支被执行,电路连续两次预测错误,因此电路进入 Yes 状态,并且输出为预测执行分支(Take)。

我将剩余部分的状态图追踪留给你作为练习。一旦你对其工作原理感到满意,请查看表 7-7,该表提供了我们电路的技术规格。

表 7-7: 分支预测器状态表

Actual = Not Taken Actual = Taken
当前状态 预测 下一个状态 预测
--- --- --- ---
No 不执行 No 不执行
No–Error 不执行 No 不执行
Yes–Error 执行 No 不执行
Yes 执行 Yes–Error 执行

当条件分支的结果(是否执行分支)在流水线中被确定时,表 7-7 显示下一个状态以及相应的预测。这个预测将用于确定下次遇到此指令时,应该存储哪个地址——下一个指令的地址还是分支目标的地址。

步骤 2:表示状态。

对于这个问题,我们将选择一个二进制代码表示状态,s[1]s[0],如表 7-8 所示。

表 7-8: 分支预测器的状态

状态 s[1] s[0] 预测
No 0 0 不执行
No–Error 0 1 不执行
Yes–Error 1 0 执行
Yes 1 1 执行

预测是 1 位的,s[1],如果预测为不执行分支(Don’t Take),则为 0,如果预测为执行分支(Take),则为 1

让输入 Actual 在分支未被执行时为 0,在分支被执行时为 1,并使用表 7-8 中的状态表示法,我们得到表 7-9 所示的状态转移表。

表 7-9:分支预测器的状态转移表

当前 下一个
实际 s[1] s[0]
--- --- ---
0 0 0
0 0 1
0 1 0
0 1 1
1 0 0
1 0 1
1 1 0
1 1 1

当条件分支指令到达流水线中的一个点时,系统将决定是否执行该分支,这个信息作为输入 Actual 被传递到预测器电路中,该电路将状态从当前状态(Current)转换为下一个状态(Next),以便在下次遇到该指令时使用。

步骤 3:选择一个触发器。

我们在这里再次使用 D 触发器,并且与前一个例子一样,仍然有相同的注意事项。

步骤 4:确定触发器的输入。

我们需要两个触发器,每个位一个。D 触发器会在下一个时钟周期存储其输入值,因此引起每个触发器状态变化的输入显示在表 7-9 中的两个“下一个”列下。

我们可以写出布尔方程,显示三个信号的逻辑组合,Actual 和当前的 s[1] 与 s[0],它们产生所需的输入给每个 D 触发器,使其转到下一个 s[1] 和 s[0]。我们将使用 A 来表示 Actual,使用 D[1] 和 D[0] 来表示分别传递给 D 触发器的输入:

Image 步骤 5:简化所需的输入。

我们将通过使用以下布尔恒等式开始:

Image

我们的方程变为:

Image

步骤 6:绘制电路图。

在这个电路中,输入为 Actual = 0,如果上次分支未被执行;如果分支被执行,则输入为 Actual = 1。与我们的计数器一样,我们将使用一个 PLA 和两个 D 触发器,正如在图 7-17 中所示。

Image

图 7-17:使用 PLA 和两个 D 触发器的分支预测器电路

这个例子展示了最简单的分支预测方法。虽然存在更复杂的方法,且关于分支预测效果的研究仍在进行中,尽管它可以加速某些算法,但分支预测所需的额外硬件会消耗更多电力,这在电池供电的设备中是一个值得关注的问题。

我们在这两个例子设计中都使用了 D 触发器和 PLA,但像往常一样,组件的选择取决于多个因素:成本、组件的可用性、设计工具、设计时间、电力消耗等等。

现在轮到你了

7.1 重新设计图 7-14 中的 2 位计数器,使用单独的门而不是 PLA。

7.2 重新设计图 7-17 中的分支预测器,使用单独的门而不是 PLA。

你学到了什么

顺序逻辑电路(Sequential logic circuits) 这些电路的输出依赖于当前和过去的输入。它们具有时间历史,可以通过电路的当前状态进行概括。

锁存器(Latch) 一种存储 1 位数据的设备。改变该位值的能力由使能信号的电平控制;这种方式称为电平触发。

触发器(Flip-flop) 一种存储 1 位数据的设备。改变该位值的能力由时钟信号的边沿变化控制;这种方式称为边沿触发。

SR 锁存器(SR latch) SR 锁存器的状态取决于其输入,可以是设置状态或复位状态。

D 触发器(D flip-flop) D 触发器存储 1 位数据。通过将两个锁存器连接成主从配置,输出与输入隔离,从而使触发器能够与时钟信号同步。D 触发器的输出只能在每个时钟周期内更改一次。

在本章中,你看到的两个设计顺序逻辑电路的示例是基于 D 触发器和可编程逻辑阵列(PLAs)。在下一章中,你将学习计算机系统中使用的各种存储结构。

第八章:内存**

Image

在前面的三章中,你已经学习了用于实现逻辑功能的一些硬件。现在,我们将探讨如何利用这些功能来实现构成计算机的子系统,从内存开始。

每个计算机用户都希望拥有大量内存和快速计算。然而,速度更快的内存价格更高,因此必须做出一定的权衡。本章将以不同类型内存如何平衡速度与成本的使用方式开始。然后,我将描述几种实现内存硬件的不同方法。

内存层次结构

一般来说,内存距离 CPU 越近,它的速度就越快,成本也越高。最慢的内存是云端内存,成本也最低。我的电子邮件账户在云端提供 15GB 的存储空间,并且不收费(如果忽略看到一些广告的“成本”),但其速度受限于我的互联网连接。而在另一端,CPU 内部的内存与 CPU 运行速度相同,但相对较贵。树莓派的 CPU 中大约有 500 字节的内存可以供我们在程序中使用。

图 8-1 展示了这种内存层次结构。随着我们接近 CPU(图中的顶部),内存速度更快,成本也更高,因此内存的数量较少。

Image

图 8-1:计算机内存层次结构

图 8-1 中的前三层通常包括在现代计算机的 CPU 芯片中。在到达主内存之前,可能还有一到两层缓存。主内存和磁盘或固态硬盘(SSD),或两者,通常与 CPU 在同一机箱内。

离 CPU 最远的下一层代表离线数据存储设备,其中 DVD 和 U 盘只是两个例子。你可能还有外部 USB 磁盘、磁带驱动器等。为了使这些设备对计算机可用,通常需要采取一些物理操作,例如将 DVD 插入驱动器或将 U 盘插入 USB 端口。

这一层次结构的最后一层是云端存储。虽然我们大多数人将计算机设置为自动登录云端,但它并不总是可用的。

在本章中,我将从云层之上的两层开始,分别是离线存储和磁盘/SSD,并向 CPU 寄存器推进。然后,我将描述构建寄存器所使用的硬件,并逐步讲解向主内存扩展的过程。我们将把最外层三层的实现讨论留给其他书籍。

大容量存储

在图 8-1 中,云层之上的两层统称为大容量存储。大容量存储设备存储大量数据,而且当设备断电时数据仍然会保留。

让我们再看一下计算机的主要子系统,在第一章中介绍过。正如图 8-2 所示,三个子系统通过数据、控制和地址总线相互通信。此外,输入/输出(I/O)块包括与大容量存储设备接口的专用电路。

图片

图 8-2:计算机的子系统

例如,树莓派有电路实现了 SD 总线协议,并使用 micro SD 卡作为其 SSD。操作系统包括软件(设备驱动程序),应用程序通过该软件访问 micro SD 卡上的数据和应用程序。我将在第二十章中讨论 I/O 编程,但设备驱动程序的具体内容超出了本书的范围。

在本章的其余部分,我将介绍易失性内存,当电源关闭时其内容会丢失。

主内存

主内存是你在购买计算机时在规格中看到的随机存取内存(RAM)。主内存在硬件中通过总线接口与 CPU 同步,我将在第九章和第二十章中讨论。因此,程序员只需指定地址以及是从内存加载项还是存储新值,就可以访问内存中的项目。

你的树莓派内存大小取决于型号。树莓派 3 A+有 512MB,3 B 和 3 B+每个有 1GB。树莓派 4 B 可以有 2GB、4GB 或 8GB。树莓派 5 可以有 4GB 或 8GB。其他内存配置可能也有。

通常,整个程序和数据集不会加载到主内存中。相反,操作系统只将当前正在处理的部分从大容量存储加载到主内存中。现代计算机中的大多数大容量存储设备只能按预定大小的进行访问。例如,树莓派操作系统使用 4KB 的磁盘块大小。当需要的指令或数据项被加载到主内存时,计算机会加载包含所需项目的整个指令或数据块。程序中附近的部分(指令或数据)很可能很快会被需要。由于它们已经在主内存中,操作系统无需再次访问大容量存储设备,从而加快了程序执行速度。

主内存最常见的组织方式是将程序指令和数据都存储在主内存中。这被称为冯·诺依曼架构,最初由约翰·冯·诺依曼描述(《EDVAC 报告初稿》,宾夕法尼亚大学摩尔电气工程学院,1945 年),尽管当时其他计算机科学先驱也在研究相同的概念。

冯·诺依曼架构的一个缺点是,如果一条指令要求从内存中读取数据(或写入数据),程序序列中的下一条指令在当前指令完成数据传输之前无法通过同一总线从内存中读取(或写入)。这被称为 冯·诺依曼瓶颈。这种冲突会减慢程序执行速度,并催生了另一种存储程序架构:哈佛架构,其中程序和数据存储在不同的内存中,每个内存都有各自的总线连接到 CPU。这使得 CPU 可以同时访问程序指令和数据。这种专业化减少了内存使用的灵活性,通常会增加所需的总内存量。它还需要额外的内存访问硬件。这些额外的内存和访问硬件增加了成本。Level 1 缓存通常采用哈佛架构,从而为指令和数据提供单独的路径连接到 CPU。

冯·诺依曼架构的另一个缺点是,程序可以被编写成将自身视为数据,从而启用自我修改,这通常是个坏主意。像大多数现代通用操作系统一样,Linux 禁止应用程序自我修改。

缓存内存

我使用的大多数程序占用主内存的几十或几百兆字节,但大多数执行时间被循环占用,这些循环反复执行相同的几条指令,访问相同的几个变量,占用的内存只有几十或几百字节。大多数现代计算机在主内存和 CPU 之间包括非常快速的 缓存内存,它为当前正在处理的程序指令和变量提供了一个更快的位置。

缓存内存按层次组织,Level 1 最靠近 CPU,也是最小的。树莓派的缓存大小各不相同,如 表 8-1 所示。

表 8-1: 树莓派的缓存大小

Level 1 Level 2 Level 3
型号 指令 数据 统一
--- --- --- ---
3 A+, B, B+ 4 × 32KB 4 × 32KB 512KB
4 B 4 × 48KB 4 × 32KB 1MB
5 4 × 64KB 4 × 64KB 4 × 512KB

注意

我见过关于不同树莓派型号的缓存大小的不同信息,所以我不能保证 表 8-1 中的缓存大小的准确性,但它们足够接近,可以说明缓存的工作原理。

执行计算的 CPU 部分称为 处理器核心,或简称 核心。所有树莓派型号中的 CPU,如 表 8-1 中所示,均具有四个处理器核心。表格中前缀为 4 × 的内存大小表示每个处理器核心都拥有该量的缓存内存。

Raspberry Pi 3 和 4 具有两个缓存级别,而 Raspberry Pi 5 有三个。所有型号的一级缓存都使用哈佛架构。二级和三级缓存都是统一缓存,存储指令和数据。Raspberry Pi 中的缓存内存按 64 字节块组织,称为。指令和数据通过 64 字节的地址边界,一次一行地从主内存传输到缓存,反之亦然。

当程序需要访问指令或数据项时,硬件首先检查它是否位于一级缓存中。如果没有,它会检查二级缓存。如果数据在二级缓存中,硬件将包含所需指令或数据的缓存行复制到一级缓存,然后再传送到 CPU,在程序再次需要该数据或一级缓存需要为来自二级缓存的其他指令或数据重新使用该位置之前,数据将一直保留在那里。硬件继续这个过程,直到它在某个缓存中找到所需项,或者到达主内存。

当数据被写入主内存时,它首先被写入一级缓存,然后是下一级缓存。使用缓存有许多方案,它们可能变得相当复杂。我将把进一步讨论缓存的内容留给更高级的内容,例如* en.wikibooks.org/wiki/Microprocessor_Design/Cache *上的微处理器设计和缓存的维基书文章。

访问一级缓存的时间接近 CPU 的速度。二级缓存大约慢 10 倍,三级缓存大约慢 100 倍,主内存大约慢 1000 倍。这些值是近似的,并且在不同的实现中差异很大。现代处理器将缓存内存集成在与 CPU 相同的芯片中,有些处理器甚至具有超过三个级别的缓存。

计算机的性能通常受到 CPU 读取指令和数据到 CPU 所需时间的限制,而不是 CPU 本身的速度。将指令和数据放入一级缓存可以减少这一时间。当然,如果它们不在一级缓存中,硬件需要从二级或三级缓存中,或从主内存复制其他指令或数据到三级缓存,再到二级缓存,最终到达一级缓存,那么访问将比直接从主内存获取指令或数据花费更长的时间。缓存的有效性取决于局部性原理,即程序在短时间内倾向于访问附近的内存地址。这也是为什么优秀程序员将程序,特别是重复的部分,拆分成小单元的原因。一个小的程序单元更有可能完全装入缓存中的几行,从而在连续重复时能够迅速访问。

你的回合

8.1    确定你的 Raspberry Pi 的缓存大小。lscpu命令将显示你的 CPU 型号名称。

8.2 确定您的树莓派上每个缓存的行大小。您可以使用getconf -a| grep CACHE命令。

寄存器

最快的内存是在 CPU 内部:寄存器。寄存器通常提供几百字节的存储,并以与 CPU 相同的速度访问。它们主要用于数值计算、逻辑运算、临时数据存储、保存地址以及类似的短期操作——有点像我们在手动计算时使用的草稿纸。

许多寄存器可以直接由程序员访问,而其他寄存器则是隐藏的。一些寄存器用于硬件中,起到 CPU 与 I/O 设备之间的接口作用。CPU 中的寄存器组织方式特定于特定的 CPU 架构,这是在汇编语言级别编程计算机时最重要的方面之一。

在下一章中,您将了解我们将在本书编程中使用的 ARM CPU 中的主要寄存器。但在进入这一部分之前,让我们先看看如何使用前几章讨论的逻辑设备在硬件中实现内存。

在硬件中实现内存

从图 8-1 中显示的层次结构的顶部开始,我们将首先看到如何在 CPU 寄存器中实现内存。然后,我们将从 CPU 开始,逐步向下探讨,您将看到在将这些设计应用于更大内存系统时出现的一些限制,例如缓存和主内存。我们将探索这些更大系统中内存的设计,但本书中不会涉及大容量存储系统的实现。

四位寄存器

让我们从一个简单的4 位寄存器设计开始,这种寄存器可能出现在用于价格敏感型消费品(如咖啡机和遥控器)的廉价 CPU 中。图 8-3 展示了一个使用 D 触发器实现 4 位寄存器的设计。每当时钟进行正向跳变时,寄存器的状态(内容)r = r[3]r[2]r[1]r[0]被设置为输入d = d[3]d[2]d[1]d[0]。

图片

图 8-3:每个位使用 D 触发器的 4 位寄存器

这个电路的问题在于,任何d[i]的变化都会改变下一个时钟周期中相应存储位r[i]的状态,因此寄存器的内容实际上只有一个时钟周期内是有效的。一位模式的单周期缓冲对于某些应用是可以的,但我们还需要能够存储值,直到它被显式更改,可能是在数十亿个时钟周期后。让我们添加一个Store信号,并从每个位的输出r[i]反馈。当Store = 0时,我们希望每个r[i]保持不变,当Store = 1时,它跟随输入d[i],如表 8-2 所示。

表 8-2: 在 D 触发器中存储一位

存储 d[i] r[i]** Q
0 0 0 0
0 0 1 1
0 1 0 0
0 1 1 1
1 0 0 0
1 0 1 0
1 1 0 1
1 1 1 1

表 8-2 导出了Q的布尔方程,这是每个 D 触发器的新输出:

Image

该方程可以通过在每个 D 触发器的输入端使用三个 NAND 门来实现,如图 8-4 所示。

Image

图 8-4:带有存储信号的 4 位寄存器

这个设计还有另一个重要特性,源自 D 触发器的主从特性。从部分的状态在时钟周期的后半段才会改变。因此,连接到该寄存器输出的电路可以在时钟周期的前半段读取当前状态,而主部分则准备可能将状态更改为新内容。

现在我们有了一种方法来存储,例如,加法器电路的结果。寄存器的输出可以作为另一个电路的输入,该电路对数据执行算术或逻辑操作。

寄存器还可以被设计用来对存储在其中的数据执行简单的操作。接下来,我们将看到一个寄存器设计,它可以将串行数据转换为并行格式。

移位寄存器

一个移位寄存器使用一系列 D 触发器,就像图 8-4 中的简单存储寄存器一样,但每个触发器的输出都连接到下一个触发器的输入端,如图 8-5 所示。我们可以将移位寄存器用作串行输入并行输出(SIPO)设备。

Image

图 8-5:一个 4 位串行到并行移位寄存器

在图 8-5 中的移位寄存器中,一串串行比特流输入到s[i]。每当时钟跳动一次,Q0 的输出会被应用到 Q1 的输入端,从而将r[0]的前一个值复制到新的r[1]。Q0 的状态会变为新的s[i]的值,从而将此值复制为新的r[0]。串行比特流继续在移位寄存器的 4 个位中传播。在任何时刻,串行流中的最后 4 位可以并行显示在四个输出端,即r[3]、r[2]、r[1]、r[0],其中r[3]是最早的。

同样的电路可以用来提供一个四个时钟周期的时间延迟,在串行比特流中;只需将r[3]作为串行输出。

寄存器文件

CPU 中用于类似操作的寄存器被组合成一个 寄存器文件。例如,正如你将在下一章中看到的,我们要编程的 Raspberry Pi 的 CPU 包含 31 个 64 位通用寄存器,用于整数运算、地址的临时存储等。我们需要一种机制来寻址寄存器文件中的每个寄存器。

考虑一个由八个 4 位寄存器组成的寄存器文件,如图 8-4 所示。我们将八个寄存器的输出命名为 r0 到 r7。因此,来自八个寄存器的 i 位分别是 r0[i]r7[i]。要读取其中一个寄存器的 4 位数据(例如,寄存器 r5 中的 r5[3]、r5[2]、r5[1] 和 r5[0]),我们需要使用 3 位来指定其中一个寄存器。你在第六章学到过, multiplexer(多路复用器)可以选择多个输入中的一个。我们可以将一个 8 × 1 的多路复用器连接到这八个寄存器的每一位,如图 8-6 所示。

图像

图 8-6:用于选择寄存器文件输出的八路多路复用器

多路复用器的输入,r0[i]r7[i],是来自八个寄存器 r0–r7 的 i 位。RegSel 线上的斜杠和旁边的 3 是用来表示这里有三条线路的符号。

图 8-6 只显示了 i 位的输出;对于 n 位寄存器,需要 n 个多路复用器,因此一个 4 位寄存器需要四个这样的多路复用器输出电路。同一个 RegSel 会同时应用于所有四个多路复用器,以输出同一寄存器的所有 4 位。更大的寄存器则需要相应更多的多路复用器。

读/写内存

你已经在图 8-3 中看到过如何构建一个 4 位寄存器来存储来自 D 触发器的值。现在,我们需要能够选择何时读取寄存器中存储的值,并在不读取时断开输出。一个三态缓冲器(在第六章中介绍)可以实现这一点,如图 8-7 所示。该电路仅适用于一个 4 位寄存器。我们需要为计算机中的每个寄存器配备一个这样的电路。addr [j] 线来自解码器,用于选择一个寄存器。

图像

图 8-7:一个 4 位读/写寄存器

Write = 1 会使 4 位数据 d[3]d[2]d[1]d[0] 存储到 D 触发器 Q3、Q2、Q1 和 Q0 中。当 Read = 0 时,4 位输出 r[3]r[2]r[1]r[0] 将与 D 触发器断开连接。将 Read = 1 设置为 1 时,会连接输出。

让我们继续沿着图 8-1 中的内存层级往下看,缓存内存通常由类似于寄存器文件的触发器构成。

静态随机存储器

我们讨论的使用触发器的内存被称为静态随机存取内存(SRAM)。之所以叫做静态,是因为只要电源保持,它就会保持其值。正如你在第二章中所学的那样,它被称为随机,因为在此内存中访问任何(随机)字节所需的时间是相同的。

SRAM 通常用于缓存内存。如表 8-1 所示,Raspberry Pi 上的缓存大小可以从 32KB 到 2MB 不等。每个 SRAM 位大约需要六个晶体管来实现。

继续向下查看内存层次结构,我们到达了主内存,这是计算机内部最大的内存单元。Raspberry Pi 的主内存大小从 1GB 到 8GB 不等,具体取决于型号,因此将 SRAM 用作主内存会非常昂贵。接下来,我们将看看一种适用于大型主内存系统的较便宜类型的内存。

动态随机存取内存

动态随机存取内存(DRAM)通常通过将电容器充电至两种电压之一来存储一个位。该电路只需要一个晶体管来给电容器充电,如图 8-8 所示。这些电路以矩形阵列的方式排列。

Image

图 8-8:一个 DRAM 位

行选择线设置为1时,该行中的所有晶体管都会被打开,从而将相应的电容器连接到感应放大器/锁存器。存储在电容器中的值——高电压或低电压——会被放大并存储在锁存器中。这样,它就可以被读取。由于这一操作会使电容器放电,因此必须从锁存器中存储的值中刷新它们。提供了单独的电路来执行刷新操作。

当要将数据存储在 DRAM 中时,新的位值01首先存储在锁存器中。然后,将行选择设置为1,感应放大器/锁存器电路将与逻辑01对应的电压应用到电容器上。电容器将适当地充电或放电。

这些操作比单纯地切换触发器需要更多时间,因此 DRAM 的速度明显比 SRAM 慢。此外,每一行电容器必须大约每 60 毫秒进行一次读取和刷新。这进一步降低了内存访问速度。

除了内存本身外,访问大型内存系统中各个字节所需的硬件量可能相当可观。让我们来看一种减少寻址内存所需门数的方法。

举个例子,在 1MB 内存中选择 1 字节需要一个 20 位的地址。这反过来需要一个 20×2²⁰ 的地址解码器,如图 8-9 所示。

Image

图 8-9:使用一个 20 × 2²⁰ 地址解码器访问 1MB 内存

回想一下,n×2^n解码器需要 2^n个与门。因此,一个 20×2²⁰的解码器需要 1,048,576 个与门。我们可以通过将内存组织成 1,024 行和 1,024 列的网格来简化电路,如图 8-10 所示。然后,我们可以通过选择一行和一列来选择一个字节,每个解码器使用 10×2¹⁰解码器。

虽然需要两个解码器,但每个解码器只需要 2¹⁰个与门,因此每个解码器的总共需要 2×2¹⁰ = 2,048 个与门。当然,访问内存中的单个字节稍微复杂一些,且将 20 位地址拆分成两个 10 位部分会增加一些复杂性。不过,这个例子应该能让你大致了解工程师如何简化设计。

图片

图 8-10:使用两个 10×2¹⁰地址解码器寻址 1MB 内存

现在你对现代计算机中内存的层级结构有了清晰的了解,它使得程序能够快速执行,同时保持硬件成本在合理范围内。尽管 DRAM 比 CPU 慢,但其每位的低成本使其成为主内存的理想选择。当我们靠近内存层次结构中的 CPU 时,更快速的 SRAM 被用于缓存。由于缓存内存远小于主内存,因此 SRAM 每位的较高成本在这里是可以接受的,且由于 CPU 执行的程序通常需要的指令和数据都在缓存中,我们可以看到 SRAM 的高速在程序执行中的好处。

轮到你了

8.3 从表 8-2 推导出D(Store, d[i], r[i])的公式。

你所学到的

内存层次结构 计算机存储是这样组织的:更小、更快速、成本更高的内存靠近 CPU。随着程序执行,较小量的程序指令和数据会被复制到逐级更快的内存层级。这种做法之所以有效,是因为程序需要的下一个内存位置很可能与当前内存位置相邻。

寄存器 位于 CPU 中的几千字节的内存,以与 CPU 相同的速度访问。由触发器实现。

缓存 数千到数百万字节的内存,位于 CPU 外部,但通常在同一芯片上。缓存内存比 CPU 慢,但与 CPU 同步。缓存通常按层次组织,越靠近 CPU 的内存层次越小且越快。缓存通常使用 SRAM 实现。

主内存 数亿到数十亿字节的内存,独立于 CPU。主内存比 CPU 慢,但与 CPU 同步。通常使用 DRAM 实现。

静态随机存取内存 (SRAM) 使用触发器存储位。SRAM 速度快,但成本高。

动态随机存取内存 (DRAM) 使用电容器存储位。DRAM 速度较慢,但其成本远低于 SRAM。

在下一章,你将从程序员的角度学习树莓派中 CPU 的组织结构。

第九章:中央处理单元

Image

现在你已经了解了构建中央处理单元(CPU)的电子组件,是时候学习一些 ARM CPU 的具体细节了。

Arm Ltd 是一家设计公司,将其设计作为知识产权(IP)授权给其他公司。树莓派使用由博通公司制造的系统级芯片(SoC)。SoC 是在单一集成电路中组合的多个 IP 模块,包含 CPU 以及计算机系统的其他许多组件。树莓派使用的 SoC 包含一个 ARM CPU IP 模块和与 CPU 协同工作的其他设备 IP 模块。

本书基于第八版 ARM CPU 架构,Armv8-A。这个版本被多个树莓派模型使用,包括树莓派 3 A+、B 和 B+,树莓派 4 B 以及树莓派 5。

一个 Armv8-A 处理器可以在 AArch64 或 AArch32 执行状态下运行。AArch64是一个 64 位执行状态,支持 64 位地址和A64指令集。A64 指令可以使用 64 位寄存器进行处理。AArch32是一个 32 位执行状态,支持 32 位地址和A32以及T32指令集。(在之前的 Arm 文档中,A32 被称为 ARM,T32 被称为 Thumb。)A32 和 T32 指令可以使用 32 位寄存器进行处理。

A64 和 A32 指令都是 32 位长的。T32 指令的长度为 32 位或 16 位。T32 指令集在 ARM 架构的小型设备实现中很有用,因为它可以减少程序所需的内存量。本书中,我使用的是 AArch64 执行状态下的 A64 指令集。

本章将从典型 CPU 的概述开始,接着介绍 AArch64 执行状态中的寄存器以及程序员如何访问它们。最后,我将通过一个使用gdb调试器查看寄存器内容的示例来结束本章。

CPU 概述

正如你可能已经知道的,CPU 是计算机的核心。它按照你在程序中指定的执行路径进行操作,执行所有的算术和逻辑运算。它还从内存中获取程序所需的指令和数据。

让我们从典型 CPU 的主要子系统入手。接下来我将描述 CPU 如何从内存中获取指令,并在执行程序时使用这些指令。

CPU 子系统

图 9-1 展示了典型 CPU 主要子系统的框图。这是一个高度简化的图示,只展示了一个处理核心;实际的 CPU 要复杂得多,但这里讨论的一般概念适用于大多数 CPU。

Image

图 9-1:CPU 的主要子系统

各子系统通过内部总线连接,总线是硬件通道和控制这些通道通信的协议。我们简要看一下图 9-1 中的每个子系统。在这个概述之后,我们将重点讨论程序员最关心的子系统以及它们在 A64 架构中的使用。主要的 CPU 子系统包括:

程序计数器 在 A64 架构中,程序计数器寄存器包含当前正在执行的指令的内存地址。在许多其他架构中,它包含紧跟当前指令之后的指令的地址。在 ARM A32 和 T32 架构中,它包含紧跟在紧跟指令之后的指令的地址。在 x86 架构中,它通常被称为指令指针

缓存内存 尽管可以认为这不是 CPU 的一部分,但大多数现代 CPU 都在 CPU 芯片上集成了一些缓存内存。CPU 如何使用缓存内存在第八章中已做解释。

指令寄存器 当指令被获取时,它会被加载到指令寄存器中进行解码和执行。指令寄存器的位模式决定了控制单元使 CPU 执行的操作。一旦该操作完成,指令寄存器中的位模式将被更改为程序中下一个指令的位模式,CPU 将执行由此新位模式指定的操作。

寄存器文件 正如你在第八章中看到的,寄存器文件是用于相似操作的一组寄存器。大多数 CPU 都有多个寄存器文件。例如,A64 架构包括一个用于整数运算的寄存器文件和另一个用于浮点和向量运算的寄存器文件。编译器和汇编器为每个寄存器指定了名称。所有算术和逻辑操作以及数据移动操作都至少涉及一个寄存器文件中的寄存器。

控制单元 指令寄存器中的位会在控制单元中解码。为了执行指令所指定的操作,控制单元会生成控制 CPU 其他子系统的信号。它通常作为有限状态机实现,包含解码器、多路复用器和其他逻辑组件。

算术逻辑单元 (ALU) ALU 用于执行程序中指定的算术和逻辑操作。当 CPU 需要执行自己的算术运算(例如,两个值相加以计算内存地址)时,也会使用 ALU。

条件标志 ALU 执行的每个操作都会产生不同的条件,这些条件可以被记录下来供程序使用。例如,如第三章中讨论的,加法可能会产生进位。A64 指令集包含加法和减法指令,在 ALU 完成操作后,它们会将其中一个条件标志设置为0(无进位)或1(有进位)。

总线接口 这就是 CPU 与其他计算机子系统——内存和输入/输出(I/O)——通信的方式,如图 1-1 所示(参见第一章)。它包含将地址放到地址总线、通过数据总线读写数据,以及将控制信号放到控制总线的电路。许多 CPU 的总线接口与外部总线控制单元接口,外部总线控制单元再与内存以及不同类型的 I/O 总线(例如 USB、SATA 或 PCI-E)接口。

指令执行周期

让我们更详细地了解一下 CPU 如何执行存储在主内存中的程序。CPU 通过使用你在第一章中学习到的三条总线——地址总线、数据总线和控制总线——通过总线接口从主内存中获取指令。

在 A64 架构中,程序计数器寄存器中的地址始终指向(具有当前执行指令的内存地址)程序中正在执行的指令。CPU 获取此地址处的指令后,会对其进行解码并执行。然后,CPU 会将 4(指令的字节数)加到程序计数器中,使其包含程序中下一个指令的地址。因此,程序计数器标记了程序中的当前位置。

有些指令会改变程序计数器中的地址,从而导致程序从一个位置跳转到另一个位置。在这种情况下,指令执行后程序计数器中的地址不会增加。

当 CPU 从内存中获取指令时,它将该指令加载到指令寄存器中。指令寄存器中的位模式使得 CPU 执行指令中指定的操作。一旦该操作完成,另一个指令会自动加载到指令寄存器中,CPU 将执行下一个位模式所指定的操作。

大多数现代 CPU 使用指令队列,其中多个指令等待执行。专用的电子电路在常规控制单元执行指令时,保持指令队列的满载。这是一种实现细节,允许控制单元运行得更快;控制单元如何执行程序的本质可以通过单一的指令寄存器模型来表示,接下来我会描述这一点。

从内存中获取每条指令的步骤,也就是执行程序的步骤如下:

  1. 一系列指令被存储在内存中。

  2. 第一条指令所在的内存地址被复制到程序计数器。

  3. CPU 将程序计数器中的地址通过地址总线发送到内存。

  4. CPU 通过控制总线发送“读取”信号。

  5. 内存通过数据总线响应,发送该内存位置的位状态副本,CPU 随后将其复制到指令寄存器中。

  6. CPU 执行指令寄存器中的指令。

  7. 如果指令没有更改程序计数器中的地址,CPU 会自动递增程序计数器,使其包含内存中下一条指令的地址。

  8. 返回第 3 步。

第 3、4 和 5 步被称为指令获取。注意,第 3 步到第 8 步构成一个周期,称为指令执行周期。图 9-2 以图形方式展示了非分支 A64 指令的情况。

图片

图 9-2:非分支 A64 指令的指令执行周期

CPU 向程序计数器添加 4,因为每条 A64 指令的长度是 32 位。一些计算机架构在执行指令之前,会将指令长度加到程序计数器中。

在 ARM A32 指令集架构中,CPU 将当前执行指令的地址加上 8(两个指令的长度)。你在第七章中学到,指令是通过管道分阶段获取并执行的。在较早的 32 位 ARM 架构中,在当前指令执行之前,已经取出了另外两条指令,并且这些指令正在通过管道。这一差异在 AArch64 架构的 A32 模式中得以保留,以实现向后兼容。

大多数时候,这些差异对你来说并不重要,但正如你在第十二章讨论指令编码时所看到的,了解这些细节可能有助于调试程序。

图 9-2 中的wfi指令使得 CPU 停止执行指令并等待中断。A64 架构还有一条指令wfe,它也会停止指令执行并等待事件。中断和事件的区别超出了本书的范围;关键点是,存在一些指令可以告诉 CPU 停止执行指令,从而暂停指令执行周期。你将在第二十一章中进一步了解中断。

程序中的大多数指令至少会使用一个寄存器文件中的一个寄存器。程序通常将数据从内存加载到寄存器中,对数据进行操作,然后将结果存储回内存。寄存器还用于保存存储在内存中的项目的地址,从而充当指向数据或其他地址的指针。

到了本书的这一部分,我将主要关注 A64 指令集架构。我建议你下载 《了解架构—介绍 ARM 架构》,可以通过 developer.arm.com/documentation/102404/latest 获取。该文档比本书所涵盖的内容更高级,但我在这里的一个目标是帮助你学会如何阅读更高级的资料。两者之间反复对照将帮助你更好地理解这些内容。

本章的其余部分主要用于描述 A64 架构中的通用寄存器。你将学习如何在 gdb 调试器中查看它们的内容,并将在下一章中学习如何在汇编语言中开始使用它们。

A64 寄存器

CPU 内存的一部分被组织成寄存器。机器指令通过它们的地址访问 CPU 寄存器,就像它们访问主内存一样。当然,寄存器寻址空间与主内存寻址空间是分开的;寄存器地址被放置在内部 CPU 总线上,而不是总线接口的地址部分,因为寄存器就在 CPU 内部。从程序员的角度来看,区别在于汇编器为寄存器预定义了名称,而程序员为内存地址创建符号名称。

因此,在你编写的每个汇编语言程序中,以下情况会发生:

  • CPU 寄存器通过使用在汇编器中预定义的名称进行访问。

  • 内存是通过程序员为内存位置提供名称,并在用户程序中使用该名称来访问的。

表 9-1 列出了 A64 架构中的基本编程寄存器。

表 9-1: 基本 A64 寄存器

编号 大小 名称 用途
31 64 位 x0x30w0w30 通用寄存器
1 64 位 sp 栈指针
1 64 位 xzrwzr 零寄存器
1 64 位 pc 程序计数器
1 64 位 nzcv 条件标志
32 128 位 d0x31s0s31,或 h0h31 浮点或向量
1 64 位 fpcr 浮点控制
1 64 位 fpsr 浮点状态

x0x30 指的是寄存器的完整 64 位,w0w30 指的是相同寄存器的 32 位低位。同样,xzr 是一个 64 位的 0 整数,wzr 是一个 32 位的 0 整数。对于浮点寄存器,d0d31 指的是完整的 128 位,s0s31 指的是低 64 位,h0h31 指的是低 32 位的寄存器。

让我们更详细地看看这些寄存器。我将从 31 个通用寄存器开始,然后我们将查看几个具有特殊用途的寄存器。我将在第十九章中讲解浮点寄存器。

通用寄存器

通用寄存器用于整数数据类型,例如 intchar 整数值(有符号和无符号)、字符表示、布尔值和内存地址。每个寄存器中的每一位按从右到左的顺序编号,从 0 开始。所以最右边的位是 0,紧接着左边的是 1,依此类推。由于每个通用寄存器有 64 位,最左边的位是 63。

计算机中的每条指令将一组位视为一个单独的单位。早期,这个单位被称为。每个 CPU 架构都有一个字长。在现代 CPU 架构中,不同的指令操作在不同数量的位上,但术语从最初的 32 位 ARM 架构延续到当前的 64 位架构。因此,8 位称为字节,16 位称为半字,32 位称为,64 位称为双字,128 位称为四字

在 A64 架构中,通用寄存器 r0r30 可以作为 32 位字或 64 位双字进行访问。我们的汇编器使用 w0w30 表示字部分,使用 x0x30 表示双字部分,如图 9-3 所示。

图片

图 9-3:A64 通用寄存器名称

当一条指令写入寄存器的低 32 位时,高 32 位会被设置为 0

专用寄存器

如你刚刚学到的,程序计数器寄存器,命名为 pc,包含当前指令的地址。软件不能直接写入 pc;它只能通过分支指令间接修改。

尽管它被视为通用寄存器,带链接分支指令使用 x30 寄存器作为链接寄存器,命名为 lr,当调用函数时传递返回地址。我将在第十一章中详细解释这一点,但现在请记住,当你编写汇编语言代码时,使用 x30 寄存器时需要小心。

你可能习惯了计算机中的大多数事情以 2 的倍数发生,所以你可能会想为什么只有 31 个通用寄存器。没有名为 w31x31 的寄存器。相反,A64 架构将第 32 个寄存器视为堆栈指针,命名为 sp,或者零寄存器,命名为 wzrxzr

堆栈是一个位于内存中的数据结构,sp 寄存器包含其地址。我将在第十一章中详细讲解这一部分。寄存器名称 wsp 指的是堆栈指针寄存器的低 32 位。这将用于使用 32 位寻址的特殊情况,在本书中我不会详细介绍这些内容。

如果您的算法需要值 0,许多指令允许您使用 wzr 寄存器表示 32 位 0 或使用 xzr 寄存器表示 64 位 0。如果使用指令将值存储到零寄存器中,该值将被简单丢弃;然而,这实际上是有用途的,正如我们在 第十三章 中看到的那样,比较值的指令会使用它。零寄存器不需要实现为物理寄存器。

状态标志,如您在 图 9-1 中看到的,位于 nzcv 寄存器中。多个算术和逻辑操作会影响状态标志,这些标志位于 nzcv 寄存器的第 31 位到第 28 位,如 图 9-4 所示。该寄存器中的其他 60 位保留供其他用途。

图片

图 9-4:A64 nzcv 寄存器

标志的名称和导致它们为真(即值为 1)的条件,如 表 9-2 所示。

表 9-2: 状态标志

名称 功能 将标志设置为 1 的条件
N 负数标志 结果的最高位为 1
Z 零标志 结果为 0
C 进位标志 显示进位或借位
V 溢出标志 有符号整数(补码)算术溢出

机器指令可以用来测试状态标志的状态。例如,有一条指令,当 Z 标志为 1 时,会跳转到程序中的另一个位置。

接下来,我们将查看一些 C/C++ 数据类型,它们与通用寄存器的大小有关。

C/C++ 整型数据类型和寄存器大小

程序中的每一段数据都有一个 数据类型,该数据类型指定了数据的可能值、表示这些值的位模式、可以对数据执行的操作以及数据在程序中的语义使用。

一些编程语言,包括 C、C++ 和 Java,要求程序员显式声明程序中使用的值的数据类型。其他语言,如 Python、BASIC 和 JavaScript,可以根据值的使用方式来确定数据类型。

大多数编程语言会指定每种数据类型的变量所能存储的值的范围。例如,在 C 和 C++ 中,int 必须能够存储范围为 -32,768 到 +32,767 的值;因此,它的大小至少为 16 位。unsigned int 必须能够存储范围为 0 到 65,535 的值,因此它也必须至少为 16 位。编程环境可以超过语言规范中给出的最小要求。

CPU 制造商指定了与 CPU 架构特定的机器级数据类型,通常包括一些在设计中独有的专用数据类型。表 9-3 给出了你可以从我们的编译器gccg++中预期的 A64 寄存器大小,但你应该小心,不要指望这些大小总是相同的。*<数据类型>表示指向指定数据类型内存地址的指针。

表 9-3: A64 架构中某些 C/C++数据类型的大小

数据类型 大小(位) 描述
char 8 字节
short 16 整数
int 32 整数
long 32 整数
long long 64 整数
float 32 单精度浮点数
double 64 双精度浮点数
*<数据类型> 64 指针

一个值通常可以由多种数据类型表示。例如,大多数人会认为 123 表示整数一百二十三,但这个值可以在计算机中以intchar[](一个char数组,其中每个元素存储一个字符的代码点)形式存储。

如表 9-3 所示,在我们的 C/C++环境中,int是存储在一个字中的,因此 123 将以位模式0x0000007b存储。作为 C 风格的文本字符串,我们还需要 4 字节的内存,但位模式将是0x310x320x33,和0x00——也就是字符123NUL。(回忆一下,C 风格的字符串是以NUL字符结束的。)

如果你对问题的解决方案依赖于数据大小,C 标准库通常定义了特定的大小。例如,GNU C 库定义int16_t为 16 位有符号整数,u_int16_t为 16 位无符号整数。在少数情况下,你可能希望使用汇编语言来确保正确性。

你可以通过查看寄存器中发生的事情来了解很多关于 CPU 如何工作的知识。在下一节中,你将学习如何使用gdb调试器查看寄存器。

使用 gdb 查看 CPU 寄存器

我将使用清单 9-1 中的程序来向你展示如何使用gdb查看 CPU 寄存器的内容。

inches_to_feet.c

// Convert inches to feet and inches.

#include <stdio.h>
#define INCHES_PER_FOOT 12

int main(void)
{
 ➊ register int feet;
    register int inches;
 ➋ int total_inches;
    int *ptr; ptr = &total_inches;

    printf("Enter inches: ");
 ➌ scanf("%i", ptr);

    feet = total_inches / INCHES_PER_FOOT;
    inches = total_inches % INCHES_PER_FOOT;
    printf("%i\" = %i' %i\"\n", total_inches, feet, inches);

    return 0;
}

清单 9-1:一个简单的程序,演示如何使用 gdb 查看 CPU 寄存器

我使用了register存储类修饰符❶,请求编译器使用 CPU 寄存器来存储feetinches变量,而不是将它们存储在内存中。register修饰符只是建议性的;C 语言标准并不要求编译器遵循这一请求。但请注意,我并没有请求编译器使用 CPU 寄存器来存储total_inches变量❷。这个变量必须放在内存中,因为scanf需要一个指向total_inches位置的指针❸来存储从键盘读取的值。

我在第二章中介绍了一些gdb命令。当你在一个已经运行的程序中触发断点时,这里有一些额外的命令,你可能会发现它们有助于在控制程序的情况下移动并查看程序的信息:

n (next) 执行当前源代码语句。如果它是函数调用,则执行整个函数。

s (step) 执行当前源代码语句。如果它是函数调用,则进入该函数,达到被调用函数的第一条指令。

si (step instruction) 执行当前机器指令。如果它是函数调用,则进入该函数。

sho arc (show architecture) 显示gdb当前使用的架构。你可以使用此命令确保gdb以 AArch64 模式运行。

这是我如何使用gdb控制程序执行并观察寄存器内容的示例——请注意,如果你自己复制这个例子,可能会看到不同的地址:

➊ $ gcc -g -Wall -o inches_to_feet inches_to_feet.c
➋ $ gdb ./inches_to_feet
   GNU gdb (Debian 10.1-1.7) 10.1.90.20210103-git
   --snip--
   Reading symbols from ./inches_to_feet...
➌ (gdb) l
   1       // Convert inches to feet and inches.
   2 3       #include <stdio.h>
   4       #define INCHES_PER_FOOT 12
   5
   6       int main(void)
   7       {
   8           register int feet;
   9           register int inches;
   10          int total_inches;
➍ (gdb)
   11          int *ptr;
   12
   13          ptr = &total_inches;
   14
   15          printf("Enter inches: ");
   16          scanf("%i", ptr);
   17
   18          feet = total_inches / INCHES_PER_FOOT;
   19          inches = total_inches % INCHES_PER_FOOT;
   20          printf("%i\" = %i' %i\"\n", total_inches, feet, inches);
   (gdb)
   21
   22          return 0;
   23      }

我先编译程序❶,然后将其加载到gdb中❷。调试器首先打印关于它自身的信息,我在这里删除了这些信息以节省空间。然后,我列出源代码❸,以便知道在哪里设置断点。使用回车键❹(某些键盘上的 RETURN 键)可以重复上一个命令(但不会显示该命令)。

我想跟踪程序处理数据的过程,方法是设置程序中一些关键点的断点:

(gdb) b 15
Breakpoint 1 at 0x7d8: file inches_to_feet.c, line 15.
(gdb) b 18
Breakpoint 2 at 0x7f4: file inches_to_feet.c, line 18.

我在程序提示用户输入数据的地方(第 15 行)设置了第一个断点,在程序开始进行计算的地方(第 18 行)设置了第二个断点。

当我运行程序时,它会在遇到第一个断点时暂停:

(gdb) r
Starting program: /home/progs/chapter_09/inches_to_feet/inches_to_feet

Breakpoint 1, main () at inches_to_feet.c:15
15          printf("Enter inches: ");

程序在源代码的第 15 行停止,控制权返回到gdbi r命令显示寄存器的内容(确保在ir之间输入空格):

(gdb) i r
x0             0x7fffffef54        549755809620
x1             0x7ffffff0b8        549755809976
x2             0x7ffffff0c8        549755809992
x3             0x55555507c4        366503856068
x4             0x0                 0
x5             0xbc0984bf4dff9186  -4897237162606489210
x6             0x7ff7fb6c58        549621296216
x7             0x1000000040        68719476800
x8             0xffffffffffffffff  -1
x9             0xf                 15
x10            0x0                 0
x11            0x0                 0
x12            0x7ff7e48e48        549619797576
x13            0x0                 0
x14            0x0                 0
x15            0x6fffff4a          1879048010
x16            0x0                 0
x17            0x0                 0
x18            0x7fffffdb20        549755804448
x19            0x5555550880        366503856256
x20            0x0                 0
x21            0x55555506b0        366503855792
x22            0x0                 0
x23            0x0                 0
x24            0x0                 0
x25            0x0                 0
x26            0x0                 0
x27            0x0                 0
x28            0x0                 0
x29            0x7fffffef30        549755809584
x30            0x7ff7e65e18        549619916312
sp             0x7fffffef30        0x7fffffef30
pc             0x55555507d8        0x55555507d8 <main+20>
cpsr           0x60000000          [ EL=0 C Z ]
fpsr           0x0                 0
fpcr           0x0                 0

此显示器告诉我们用户输入数据之前寄存器的内容(你会看到不同的数字)。我们可能想知道编译器是否按照我们的要求使用了feetinches变量的寄存器,如果是,它使用了哪些寄存器。

我们想知道这些信息,以便在程序使用寄存器之前和之后查看寄存器的内容,从而判断程序是否将正确的值存储在寄存器中。我们可以通过要求gdb打印这两个变量的地址来回答这个问题:

(gdb) print &feet
Address requested for identifier "feet" which is in register $x20
(gdb) print &inches
Address requested for identifier "inches" which is in register $x19

当我们询问一个变量的地址时,gdb会给出与程序员提供的标识符相关联的内存地址。但在这个程序中,我要求编译器使用寄存器,gdb会告诉我们编译器为每个变量选择了哪个寄存器。

我没有要求编译器为total_inchesptr变量使用寄存器,因此gdb应该会告诉我们它们在内存中的位置:

(gdb) print &total_inches
$1 = (int *) 0x7fffffef54
(gdb) print &ptr
$2 = (int **) 0x7fffffef58

现在我们知道x19用于存储inchesx20用于存储feet,在使用它们进行计算之前,我们可以查看这两个寄存器当前存储的内容:

➊ (gdb) i r x19 x20
   x19            0x5555550880        366503856256
   x20            0x0                 0

我们可以指定只查看我们想看的两个寄存器❶,而不是显示所有寄存器。根据表 9-3,我们知道inchesfeet只使用x19x20寄存器的w19w20部分,但gdb始终显示整个 64 位内容。

程序继续执行,程序要求用户输入英寸的数量。在这里,我输入了123。接着,程序在遇到下一个断点时返回到gdb

(gdb) c
Continuing.
Enter inches: 123

Breakpoint 2, main () at inches_to_feet.c:18
18          feet = total_inches / INCHES_PER_FOOT;

程序即将计算英尺的数量,然后计算剩余的英寸。在开始计算之前,让我们确保用户的输入已存储在正确的位置:

(gdb) print total_inches
$3 = 123

现在我们让程序进行计算:

   (gdb) n
   19          inches = total_inches % INCHES_PER_FOOT;
➊ (gdb)
   20          printf("%i\" = %i' %i\"\n", total_inches, feet, inches);

按下 ENTER 键会重复执行n命令❶。程序现在准备好打印出计算结果。让我们检查一下,确保所有的计算都正确执行,并且结果已存储在正确的位置:

(gdb) i r x19 x20
x19            0x3                 3
x20            0xa                 10

还有其他方法可以查看inchesfeet中存储的内容:

(gdb) print $x19
$4 = 3
(gdb) print $x20
$5 = 10
(gdb) print inches
$6 = 3
(gdb) print feet
$7 = 10

使用gdbprint命令时,即使一个寄存器用来存储变量,你一次只能打印一个变量。i r命令的寄存器名称前不需要加$前缀,但print命令则需要加上。

在完成程序执行之前,让我们最后检查一下所有的寄存器:

(gdb) i r
x0             0x78                120
x1             0x7b                123
x2             0xa                 10
x3             0x0                 0
x4             0x3                 3
x5             0x7fffffe9f3        549755808243
x6             0x21a               538
x7             0x7b                123
x8             0x7ff7f5a338        549620917048
x9             0x5                 5
x10            0xa                 10
x11            0xffffffffffffffff  -1
x12            0xffffffc8          4294967240
x13            0x7fffffeef0        549755809520 x14            0x2                 2
x15            0x410               1040
x16            0x0                 0
x17            0x0                 0
x18            0x0                 0
x19            0x3                 3
x20            0xa                 10
x21            0x55555506b0        366503855792
x22            0x0                 0
x23            0x0                 0
x24            0x0                 0
x25            0x0                 0
x26            0x0                 0
x27            0x0                 0
x28            0x0                 0
x29            0x7fffffef30        549755809584
x30            0x55555507f4        366503856116
sp             0x7fffffef30        0x7fffffef30
pc             0x5555550848        0x5555550848 <main+132>
cpsr           0x60200000          [ EL=0 SS C Z ]
fpsr           0x0                 0
fpcr           0x0                 0

在这个显示中没有什么特别之处,但当你积累一些查看此类显示的经验后,你会学会在某些时候发现问题所在。现在我已经确认程序正确执行了所有计算,我将继续执行程序的后续部分,使用c命令,然后退出:

(gdb) c
Continuing.
123" = 10' 3"
[Inferior 1 (process 2265) exited normally]
(gdb) q
$

程序继续执行,打印结果并返回控制权给gdb。当然,最后要做的就是使用q命令退出gdb

你的练习

9.1 修改清单 9-1 中的程序,要求为变量total_inchesptr使用寄存器。编译器允许你这么做吗?如果不允许,为什么?

9.2 编写一个 C 程序,允许你确定计算机的字节序(详见第 33 页的第二章)。

9.3 修改练习 9.2 中的程序,使用gdb演示字节序是 CPU 的一个属性。也就是说,尽管 32 位的int在内存中以小端方式存储,但它会以“正确”的顺序读取到寄存器中。

你所学到的

通用寄存器 A64 架构中的 31 个 64 位寄存器,它们为 CPU 中的计算提供了一小块内存。

条件标志 用于表示某些算术或逻辑操作是否产生了进位、溢出、负值或零值的位。

程序计数器 一个指针,保存当前正在执行的指令的地址。

指令寄存器 保存当前正在执行的指令。

算术逻辑单元(ALU) 执行指定的算术和逻辑操作。

控制单元 控制 CPU 中的活动。

总线接口 负责将 CPU 与主内存和 I/O 设备连接。

缓存内存 保存当前 CPU 正在处理的程序部分,包括指令和数据。缓存内存比主内存更快。

指令执行周期 详细描述 CPU 如何处理一系列指令。

C/C++数据类型大小 数据类型的大小与寄存器的大小密切相关。

在下一章中,你将开始用汇编语言编程你的树莓派。

第十章:汇编语言编程

Image

在前面的章节中,你学习了计算机如何使用10表示操作和数据。这些10就是机器语言。现在,我们将转向机器级编程。我们不再使用机器语言,而是使用汇编语言,它为每条机器语言指令提供一个简短的助记符。我们将使用汇编器程序将汇编语言翻译成控制计算机的机器语言指令。

创建汇编语言程序类似于在高级编译语言中创建程序,如 C、C++、Java 或 FORTRAN。我将使用 C 作为编程模型,探索所有高级编程语言中常见的主要编程构造和数据结构。我们使用的编译器gcc允许我们查看它生成的汇编语言。从这里开始,我将向你展示如何直接在汇编语言中实现编程构造和数据结构。

我们将首先查看编译器将 C 源代码转换为可执行程序的步骤。接下来,我将讨论这些步骤中哪些适用于汇编语言编程,并带你完成直接在汇编语言中创建一个可以在 C 托管环境中运行的程序的过程。你还将学习到一个对学习汇编语言很有用的gdb模式。

在阅读本章时,你还应该参考 Raspberry Pi OS 中与本文讨论的程序相关的 man 页面和info文档资源。你可能需要在你的 Raspberry Pi 上安装其中的一些程序,具体请参考《编程环境》中的第 4 页。

在本书的其余部分,我将经常使用 GNU 汇编程序as。有些人称这个汇编器为gas,是GNU 汇编器的简称。我会解释你需要了解如何使用as程序,但我建议你获取一份Using as手册,以便在我们继续时学习详细信息。它可以在https://www.gnu.org/manual/manual.html的 GNU Binutils 集合中的软件开发部分找到。

从 C 开始

gcc编译器通过执行几个不同的步骤,从一个或多个源文件创建可执行程序。每个步骤生成一个中间文件,作为下一个步骤的输入。这里对每个步骤的描述假设只有一个 C 源文件,filename.c

预处理

预处理是第一步。此步骤通过调用程序cpp来解析预处理指令,如#include(文件包含)、#define(宏定义)和#if(条件编译)。每个预处理指令都以#字符开头,可能会被发音或不发音——例如,你可能会听到#include指令被称为includepound-includehash-includehashtag-include

编译过程可以在预处理阶段结束时使用-E选项停止,该选项会将结果 C 源代码写入标准输出。标准输出是来自 Linux 命令行程序的纯文本输出,通常连接到终端窗口。你可以使用>操作符将输出重定向到文件,如下所示:

$ gcc -Wall -O0 -E <filename.c> > <filename.i>

.i文件扩展名表示一个不需要预处理的文件。

编译

接下来,编译器将预处理后得到的源代码翻译成汇编语言。编译过程可以在编译阶段结束时使用-S选项(大写字母 S)停止,该选项会将汇编语言源代码写入.s文件。

汇编

在编译器生成实现 C 源代码的汇编语言后,汇编程序as将汇编语言翻译成机器代码(指令和数据)。可以在汇编阶段结束时使用-c选项停止此过程,该选项会将机器代码写入名为.o目标文件。除了机器代码外,目标文件还包括关于代码的元数据,链接器使用这些元数据来解析不同模块之间的交叉引用,确定如何定位程序的不同部分等。它还包括关于模块的元数据,供调试器使用。

链接

ld程序确定每个函数和数据项在程序执行时将位于内存中的位置。它将程序员的标签替换为该标签的内存地址。如果调用的函数在外部库中,则在调用该函数的位置会注明,并且外部库函数的地址会在程序执行时确定。

编译器指示ld程序将计算机代码添加到设置 C 托管环境的可执行文件中。这包括打开路径到标准输出(屏幕)和标准输入(键盘)等操作,以供程序使用。

这个链接的结果会写入一个可执行文件。可执行文件的默认名称是a.out,但你可以使用-o选项指定其他名称。

如果你没有使用任何 gcc 选项来在这些步骤的末尾停止进程(-E-S-c),编译器会执行所有四个步骤并自动删除中间文件,最终只留下可执行程序。如果你希望 gcc 保留所有中间文件,可以使用 -save-temps 选项。

能够在中途停止 gcc 的补充功能是,我们可以提供已经有效通过早期步骤的文件,gcc 会将这些文件融入到剩下的步骤中。例如,如果我们写了一个汇编语言文件(.s),gcc 会跳过预处理和编译步骤,只对该文件执行汇编和链接步骤。如果我们只提供目标文件(.o),gcc 会直接进入链接步骤。这的一个隐性好处是,我们可以编写调用 C 标准库函数的汇编程序(这些库函数已经是目标文件格式),并且 gcc 会自动将我们的汇编程序与这些库函数链接。

在命名文件时,务必使用 GNU 编程环境中指定的文件扩展名。编译器在每个步骤的默认操作取决于该步骤所适用的文件扩展名。要查看这些命名约定,请在命令行输入 info gcc,选择 调用 GCC,然后选择 整体选项。如果你没有使用指定的文件扩展名,编译器可能不会按你想要的方式操作,甚至可能会覆盖一个必需的文件。

从 C 到汇编语言

用 C 语言编写的程序是按函数组织的。每个函数在程序中都有一个唯一的名称。在 C 环境设置好后,会调用 main 函数,因此我们的程序将从 main 函数开始。

让我们先看看 gcc 为清单 10-1 中的最小 C 程序生成的汇编语言代码,清单 10-1 中可以找到。

do_nothing.c

// Minimum components of a C program

int main(void)
{
    return 0;
}

清单 10-1:最小的 C 程序

这个程序除了向操作系统返回 0 外什么也不做。程序可以返回各种数值错误代码;0 表示程序没有检测到任何错误。

尽管这个程序几乎不做任何事,但仍需要执行一些指令才能返回 0。为了看到发生了什么,我们将首先使用以下 Linux 命令将这个程序从 C 语言翻译成汇编语言:

$ gcc -Wall -O0 -S do_nothing.c

注意

如果你不熟悉 GNU make 程序,值得学习如何使用它来构建程序。现在看起来可能有点过于复杂,但在简单的程序中学习要容易得多。手册可以在 www.gnu.org/software/make/manual/ 获取,另外我在我的网站上也有一些使用它的评论,网址是 rgplantz.github.io

在展示该命令的结果之前,我将解释我使用的选项。-O0(大写字母 O 和数字零)选项告诉编译器不要使用任何优化。这与本书的目标一致,目的是展示机器层面上发生的事情;要求编译器优化代码可能会掩盖一些重要的细节。

你已经学过,-Wall选项会让编译器在代码中遇到可疑的结构时发出警告。对于这个简单的程序来说,这不太可能成为问题,但养成这个好习惯是很有帮助的。

-S选项指示编译器在编译阶段后停止,并将编译结果写入一个文件,该文件的名称与 C 源代码文件相同,但扩展名为.s,而不是.c。之前的编译器命令生成了列表 10-2 中显示的汇编语言,并将其保存在文件do_nothing.s中。

do_nothing.s

        .arch armv8-a
        .file   "do_nothing.c"
        .text
        .align  2
        .global main
        .type   main, %function
main:
.LFB0:
        .cfi_startproc
        mov     w0, 0
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (Debian 10.2.1-6) 10.2.1 20210110"
        .section .note.GNU-stack,"",@progbits

列表 10-2:编译器生成的最小 C 程序汇编语言

在列表 10-2 中,首先需要注意的是汇编语言是按行组织的。每行只包含一个汇编语言语句,且没有语句跨越多行。这与许多高级语言的自由格式结构不同,后者行结构无关紧要。实际上,好的程序员会利用编写多行语句和缩进来强调代码结构。优秀的汇编语言程序员使用空行来帮助分隔算法的不同部分,并且几乎在每行代码上都加上注释。

这些行大致上被组织成列。此时它们可能对你没有多大意义,因为它们是用汇编语言编写的,但如果你仔细观察,每一行都会组织成四个可能的字段:

label: operation operand(s)   // comment

并不是所有的行都会在所有字段中都有条目。汇编程序要求至少有一个空格或制表符字符来分隔字段。当编写汇编语言时,如果你使用 TAB 键从一个字段跳到下一个字段,使列对齐,那么你的程序会更容易阅读。

让我们更详细地看一下每个字段:

标签 该字段允许我们为程序中的内存地址指定一个符号名称。程序的其他部分可以通过名称引用该内存地址。标签由一个标识符组成,后面紧跟一个:字符。稍后我会讲解创建标识符的规则。只有需要引用的行才会被标记。

操作 该字段包含一个指令操作码(opcode)或一个汇编指令(有时称为伪操作)。汇编程序将操作码及其操作数转换为机器指令,当程序执行时,这些指令会被复制到内存中。汇编指令是给汇编程序的指令,用来指导汇编过程。

操作数 该字段指定用于操作的参数。参数可以是明确的值、寄存器的名称或程序员创建的名称。根据操作的不同,操作数的数量可以是零到多个。

注释 在//字符后面的行内容(//后面的部分)会被汇编器忽略,从而为程序员提供了写可读注释的方式。由于汇编语言不像高级语言那么容易阅读,因此优秀的程序员会在几乎每一行代码上加上注释。当然,编译器没有为清单 10-2 中的代码添加注释,因为它无法知道程序员的意图。

你可能已经注意到,清单 10-2 中的大多数操作符都以.字符开头。这些是汇编指令。每个汇编指令都以.字符开头,这个字符可能会发音或不发音——例如,你可能听到.text被称为textdot-text。这些是对汇编程序本身的指令,而不是计算机指令。编译器生成了一些我们在本书中编写的汇编语言中不需要的汇编指令;接下来我们将快速看一下这些指令,然后再看必需的指令。

未使用的汇编指令

清单 10-2 中以.cfi开头的汇编指令告诉汇编器生成可用于调试和某些错误情况的信息。以.LF开头的标签标记了在代码中用于生成这些信息的位置。对此的讨论超出了本书的范围,但它们出现在清单中可能会造成混淆,因此我们将告诉编译器不要在汇编语言文件中包含它们,方法是使用-fno-asynchronous-unwind-tables-fno-unwind-tables选项:

$ gcc -Wall -O0 -S -fno-asynchronous-unwind-tables -fno-unwind-tables do_nothing.c

这将生成文件do_nothing.s,如清单 10-3 所示。我用了粗体来显示我们将使用的指令,未使用的指令则未加粗。编译器没有对这个清单中的汇编语言代码添加注释,但我使用///添加了我自己的注释,帮助你看到与 C 源代码的关系。我将在本书中展示的许多编译器生成的汇编语言清单中加入自己的注释。

do_nothing.s

        .arch armv8-a
        .file   "do_nothing.c"
        .text
        .align  2
        .global main
        .type   main, %function
main:
        mov     w0, 0           /// return 0;
        ret
        .size   main, .-main
        .ident  "GCC: (Debian 10.2.1-6) 10.2.1 20210110"
        .section        .note.GNU-stack,"",@progbits

清单 10-3:编译器生成的没有.cfi 指令的最小 C 程序汇编语言

我们已经去除了.cfi指令,但在编写自己的汇编语言函数时,仍然有一些汇编指令是我们不需要的。这些包括:

.file gcc用它来指定这个汇编语言来自哪个 C 源文件。当直接编写汇编语言时,这个指令不会被使用。

.size 此指令计算由汇编该文件所生成的机器代码的大小(以字节为单位)。 . 符号表示代码中的当前地址,因此算术表达式 .-main 从当前地址减去 main 的地址。.size 指令将此值与 main 标签相关联,作为目标文件中的元数据的一部分,从而给出该函数的字节数。这对于内存有限的系统可能是有用的信息,但在我们的程序中并不关心。

.ident 该指令提供有关所使用的编译器的信息,这些信息会包含在目标文件中。这在经历多年的发展过程中的大型项目中可能会有用,但我们不需要它。

.section 该指令为链接器提供关于如何处理该函数栈的信息。我们不会包含它,而是接受链接器的默认设置。

接下来,我们将查看编写汇编语言时需要的指令。

必需的汇编指令

必需的汇编指令在 清单 10-3 中以粗体显示。

ARM 指令集架构有多个变种,并且它还在不断发展。.arch 汇编指令告诉汇编器我们正在使用哪种 ARM 架构。如果我们使用了在特定架构中不可用的指令,它会提醒我们。对于本书中编写的简单程序来说,.arch 指令可以说并非必须,但我们将使用它以确保安全。

.text 汇编指令告诉汇编器将后续内容放置在文本区。文本区是什么意思?在 Linux 中,汇编器生成的目标文件采用 可执行与可链接格式(ELF)。ELF 标准规定了许多类型的段,每个段指定了存储在其中的信息类型。我们使用汇编指令告诉汇编器将代码放置在哪个段中。文本段是存放程序可执行指令的地方。

Linux 操作系统在将程序从磁盘加载时,还将内存分为多个以供特定用途。链接器将属于每个段的所有部分汇总在一起,输出一个按段组织的可执行 ELF 文件,这样可以更容易地让操作系统将程序加载到内存中。四种常见的段类型如下:

文本(也叫代码)文本段是存放程序指令和常量数据的地方。操作系统防止程序修改存储在文本段中的内容,使其变为只读。

数据 全局变量和静态局部变量存储在数据段中。全局变量可以被程序中的任何函数访问。静态局部变量只能被其所在的函数访问,但其值在多次调用该函数之间保持不变。程序可以同时读取和写入数据段中的变量。这些变量在程序运行期间保持不变。

堆栈 自动局部变量和链接函数的信息存储在调用堆栈中。自动局部变量在调用函数时创建,在函数返回调用函数时删除。程序可以同时读取和写入堆栈上的内存;它在程序执行过程中动态分配和释放。

是一个程序运行时可以使用的内存池。C 程序通过调用 malloc 函数(C++ 程序调用 new)从堆中获取一块内存。程序可以同时读取和写入堆内存;它用于存储数据,并通过在程序中调用 free(在 C++ 中是 delete)显式释放。

注意

这是 ELF 节和段的简化概述。更多细节,请阅读 ELF 的 man 页面以及像《ELF-64 Object File Format》这样的资源,你可以在 uclibc.org/docs/elf-64-gen.pdf 下载, 以及 John R. Levine 的《Linkers & Loaders》(Morgan Kaufmann,1999)。* readelf 程序也对于了解 ELF 文件非常有用。

.align 指令用于将其后面的代码对齐到一个地址边界。在清单 10-3 中使用的参数 2,根据你使用的平台有不同的含义。在 ARM 平台上,它指定程序计数器中应为 0 的低位数。如果这些位不是 0,汇编器将增加地址,直到它们为 0。因此,跟随 .align 2 指令的代码将从一个完整字对齐的地址开始。

.global 指令有一个参数,即标识符 main.global 指令使得该名称在全局范围内可见,这样在其他文件中定义的函数就可以引用该名称。设置 C 托管环境的代码是为了调用名为 main 的函数,因此该名称必须是全局作用域。在所有 C/C++ 程序中,程序都从 main 函数开始。在本书中,我也将从 main 函数开始汇编语言程序,并在 C 托管环境中执行它们。

你可以编写不依赖于 C 托管环境的独立汇编语言程序,在这种情况下,你可以为程序中的第一个函数创建自己的名称。你需要在汇编步骤结束时使用 -c 选项停止编译过程。然后,你可以单独使用 ld 命令链接目标 (.o) 文件,而不是作为 gcc 的一部分。我将在第二十一章中详细描述这一点。

.type 指令有两个参数:main@function。这使得标识符 main 被记录在目标文件中,作为函数的名称。

这最后三个指令并没有被翻译成实际的机器指令。相反,它们用于描述随后的语句的特性。在下一章中,我们将开始看到汇编指令,它们将常量数据存储到内存中供程序使用。

现在你已经看到编译器是如何将 C 代码翻译成汇编语言的,接下来我们来看看如何直接用汇编语言编写程序。在导言中,我曾说过你应该避免用汇编语言编写程序。但本书的目标是帮助你了解计算机在机器级别的工作原理。直接用汇编语言编写程序比仅仅阅读编译器的工作要更好地帮助学习。

从汇编语言开始

清单 10-4 是由一位程序员(我)用汇编语言编写的,而不是由编译器编写的。自然,我添加了注释来解释我的代码完成了什么。

do_nothing.s

  // Minimum components of a C program, in assembly language

          .arch   armv8-a
          .text
          .align  2
          .global main
          .type   main, %function
➊ main:
        ➋ mov     w0, wzr  // return 0;
           ret

清单 10-4:用汇编语言编写的最简化 C 风格程序

第一行以两个斜杠 // 开始。紧随这两个字符之后的所有内容,直到行尾,都是注释,不会引发汇编程序的任何操作。注释可以在一行的任何地方以两个斜杠开始。与高级语言一样,它们仅供人类读者参考,对程序没有任何影响。

这一行注释后面跟着一个空行,也不会引发汇编程序的任何操作,但对于人类可读性非常有帮助。

关于程序注释的一点说明。初学者常常注释编程语句做了什么,而不是它相对于解决问题的目的。你的注释应该描述你做了什么,而不是计算机做了什么。例如,像这样的注释

counter = 1;  // Let counter = 1

在 C 中并不是很有用。但像这样的注释

counter = 1;  // Need to start at 1

这可能非常有用。

在我们在汇编语言程序中使用的五个汇编指令(在前一节中已描述)之后,我们看到了标签 main,它位于此函数的第一个内存地址❶。通常将标签放在单独的一行,这样它适用于占用内存的下一个汇编语言语句❷。这种做法可以让你创建更长、更有意义的标签,同时保持代码的列组织,以提高可读性。

汇编语言中的符号名称

由于这是程序中的顶级函数,因此需要使用标签main,但我们很快就会编写需要创建自己名称的程序。创建符号名称的规则与 C/C++类似。每个名称必须以字母或字符._开头,后面跟随字母、数字和/或$_字符。第一个字符不能是数字,除了本地标签,稍后会描述。一个名称的长度没有限制,所有字符都具有意义。虽然关键字(操作符、寄存器名称、指令)的字母大小写不重要,但标签的大小写是有区别的。例如,my_labelMy_label是不同的。编译器生成的标签以.字符开头,许多与系统相关的名称以_字符开头;最好避免以这些字符开头,以免无意中创建与系统中已有标签重复的标签。

我们的汇编器还允许我们使用无符号整数 N 来创建本地标签。你的汇编语言代码可以将这些标签作为 Nb(最近使用的 N 向后)和 Nf(下一个使用的 N 向前)来引用。这意味着你可以有多个本地标签使用相同的数字。虽然看起来这会简化你的代码编写,但使用本地标签通常不是一种好的编程技巧,因为标签的名称没有显示其目的。

汇编语言指令的基本格式

ARM 指令分为三类:加载与存储、数据处理和程序流控制。我们将首先查看汇编语言指令的一般格式。我不会列出所有 A64 指令,而是一次介绍几个—那些能说明当前编程概念的指令。我还将只提供这些指令的常用变体。

要详细了解指令及其所有变体,可以从https://developer.arm.com/documentation/ddi0487/latest](https://developer.arm.com/documentation/ddi0487/latest)下载Arm 架构参考手册(A-Profile 架构)。这本手册可能有些难读,但通过在本书中对指令的描述与手册中的描述之间来回查阅,应该能帮助你学会如何阅读这本手册。

汇编语言提供了一组助记符,这些助记符与机器语言指令直接对应。助记符是一个简短、类似英语的字符组合,用来表示指令的操作。即使你之前从未接触过汇编语言,清单 10-4 ❷中的mov w0, wzr指令可能比它代表的机器码0x2a1f03e0更容易理解。你大概可以推断出它是将 32 位零寄存器wzr的内容移动到w0寄存器中。接下来的几段内容将解释这是什么意思。

严格来说,助记符是完全任意的,只要你有一个汇编程序能将它们转换为期望的机器指令。然而,大多数汇编程序遵循 CPU 厂商提供的手册中使用的助记符。

ARM 使用加载与存储架构,这意味着数据项必须先加载到寄存器中,或者是指令的一部分,才能在算术或逻辑操作中使用。

加载或存储指令的通用格式是:

operation register, memory_address

memory_address 是内存地址的标签,或者是包含地址的寄存器的名称。加载指令将数据项从 memory_address 复制到寄存器。存储指令将数据项从寄存器复制到 memory_address。在大多数情况下,你将仅处理每个加载或存储指令的一个数据项,但 A64 架构包括允许你在一条指令中处理两个数据项的加载与存储指令,你将在第十一章中看到它是如何工作的。

数据处理指令——算术和逻辑操作——具有以下通用格式:

operation register(s), source(s)

第一个操作数是操作结果存放的地方。有些指令使用两个寄存器存放结果。源操作数可以有一个到三个,可以是寄存器或立即数值。立即数值是一个明确的常量。

控制程序流程的指令具有以下通用格式:

operation memory_address

或:

operation data, memory_address

memory_address 是内存地址的标签,或者是包含地址的寄存器的名称。数据必须存储在寄存器中或作为立即数值。

指令描述中使用的符号

以下是我将在本书中描述指令时使用的符号列表,这些符号与 Arm 手册中使用的符号略有不同:

wd 用于操作结果的 32 位目标寄存器。

xd 一个 64 位目标寄存器,用于存放操作结果。

ws, wn 用于操作的 32 位源寄存器。如果有多个源寄存器,它们将按编号排列。

xs, xn 用于操作的 64 位源寄存器。如果有多个源寄存器,它们将按编号排列。

xb, xn 保存基地址的 64 位寄存器。

offset 加到基地址上的常量数值。

imm 一个常量数值,其大小取决于指令。

amnt 用于移位源操作数的位数。

addr 一个地址,通常是标签。

cond nzcv寄存器中位的逻辑组合。

xtnd 指定在操作中使用的操作数的扩展版本。

{} 表示一个或多个操作数是可选的。

| 表示可以使用左侧或右侧的操作数。

寄存器可以是第九章中描述的 31 个通用寄存器中的任何一个。大多数指令也允许我们使用wzrxzrwspsp寄存器。

第一类指令

再次强调,我不会描述所有的指令,也不会描述所有我所描述的指令的变体。我的目的是为你提供理解本书中介绍的编程概念所需的信息,并让你能够在需要时舒适地使用其他资源。

让我们从最常用的汇编语言指令 mov 开始。以下是该指令的一些变体;更多内容可以在 Arm 手册中找到:

mov—移动寄存器

mov wd, ws |wzrws 或 wzr 中的 32 位值复制到 wd,并将 xd 的第 63 到 32 位置为 0。

mov xd, xs |xzrxs 或 xzr 中的 64 位值复制到 xd。

mov—移动到或从 sp

mov xd |sp, xs |spxs 或 sp 中的 64 位值复制到 xd 或 sp

mov—移动立即数

mov wd, imm 将 16 位的值 imm 复制到 wd 的低位部分,并将 xd 的第 63 到 16 位置为 0。

mov xd, imm 将 16 位的值 imm 复制到 xd 的低位部分,并将 xd 的第 63 到 16 位置为 0。

movz—移动立即数并清零

movz wd, imm{, lsl amnt} 将 16 位的值 imm 复制到 wd,并可选择将该值左移 amnt 位。xd 的其他 48 位被设置为 0。amnt 可以是 0(默认)或 16

movz xd, imm{, lsl amnt} 将 16 位的值 imm 复制到 xd,并可选择将该值左移 amnt 位。xd 的其他 48 位被设置为 0。amnt 可以是 0(默认)、163248

movk—移动立即数并保留

movk wd, imm{, lsl amnt} 将 16 位的值 imm 复制到 wd,并可选择将该值左移 amnt 位。xd 的其他 48 位不变。amnt 可以是 0(默认)或 16

movk xd, imm{, lsl amnt} 将 16 位的值 imm 复制到 xd,并可选择将该值左移 amnt 位。xd 的其他 48 位不变。amnt 可以是 0(默认)、163248

movn—移动立即数并取反

movn wd, imm{, lsl amnt} 将 16 位的值 imm 取反后复制到 wd,并可选择将该值左移 amnt 位。xd 的其他 48 位被设置为 1。amnt 可以是 0(默认)或 16

movn xd, imm{, lsl amnt} 将 16 位的值 imm 取反后复制到 xd,并可选择将该值左移 amnt 位。xd 的其他 48 位被设置为 1。amnt 可以是 0(默认)、163248

在示例 10-4 中,唯一的其他指令是 ret,它会导致返回到调用函数,假设返回地址在 x30 中:

ret—从函数返回

retx30 中的地址移动到程序计数器 pc

现在你明白我为什么在第九章中说 x30 通常用作链接寄存器了:当函数被调用时,返回地址会被放入 x30。我们将在第十一章中看到函数调用时它是如何工作的。

gdb最有价值的用途之一就是作为学习工具。它有一种模式,特别有助于学习每条汇编语言指令的作用。我将在下一节中向你展示如何使用示例 10-4 中的程序来做到这一点。这也将帮助你更熟悉使用gdb,这是调试程序时必须掌握的一项重要技能。

使用 gdb 学习汇编语言

在这里你可以运行示例 10-4 中的程序,跟随讨论的步骤进行。你可以使用以下命令来汇编、链接和执行它:

$ as --gstabs -o do_nothing.o do_nothing.s
$ gcc -o do_nothing do_nothing.o
$ ./do_nothing

--gstabs选项(注意这里有两个短横线)告诉汇编器在目标文件中包含调试信息。gcc程序识别到唯一的输入文件已经是一个目标文件,所以它直接进入链接阶段。无需告诉gcc包含调试信息,因为汇编器已经将其包含在目标文件中。

正如你从名字中猜到的,运行这个程序时,屏幕上不会显示任何内容。我们将使用gdb来逐步执行程序,这样我们就可以看到这个程序实际上是做了什么的。

gdb调试器有一种模式,可以在执行每条汇编语言指令时查看其效果。文本用户界面(TUI)模式将终端窗口分为上部的显示窗格和下部的常规命令窗格。显示窗格还可以进一步分成两个显示窗格。

每个显示窗格可以显示源代码(src)、寄存器(regs)或反汇编的机器代码(asm)。反汇编是将机器代码(10)转换为相应的汇编语言的过程。反汇编过程无法识别程序员定义的名称,因此你只能看到由汇编和链接过程生成的数值。我们在第十二章中查看指令细节时,asm显示可能更有用。

使用 TUI 模式的文档可以在gdbinfo中找到。我将在这里简单介绍如何使用 TUI 模式,使用我们从示例 10-4 中的汇编语言版本的do_nothing。我将逐步讲解每一条指令。你将在“轮到你了”部分的练习 10.1 中有机会逐条执行这些指令,详见第 205 页。

注意

我的示例展示了 gdb 从命令行运行的情况。我被告知,如果尝试在 Emacs 中运行 gdb ,可能效果不佳。

正如我们在第二章中所做的,我们将通过gdb运行do_nothing,但这次我们将使用 TUI 模式:

$ gdb -tui ./do_nothing

这应该会显示类似于图 10-1 中的屏幕。请注意,图 10-1 到 图 10-7 已放大以便阅读;根据你的终端窗口设置,屏幕显示可能有所不同。

Image

图 10-1:在 TUI 模式下启动 gdb 会显示 src 视图。

输入 c 以继续显示此截图中的初步消息。

接下来,在程序的第一条指令处设置断点,并设置显示布局。在 TUI 模式中有几种显示布局可供选择。我们将使用图 10-2 中显示的 regs 布局。

image

图 10-2:将 regs 显示面板添加到 TUI 窗口中

layout regs 命令将显示面板分为 regs 面板和 src 面板。我在告诉 gdb 运行程序之前,将焦点移至 regs 面板。程序开始时,x0 寄存器的值为 0x1(你的值可能不同)。即将执行的程序指令已被高亮显示。

显示面板的大小不足以显示所有的 A64 寄存器。聚焦在 regs 面板上,使用上下箭头键以及 page up 和 page down 键滚动浏览寄存器显示。我按下了三次 page down 键,以便查看图 10-3。

image

图 10-3:查看其他寄存器

x30 寄存器中的值显示了程序执行完毕后将返回到 C 托管环境的地址。你将在图 10-6 中看到这个过程。pc 中的值是我们 main 函数中的第一条指令的地址。

让我们告诉 gdb 执行一条指令,如图 10-4 所示。

image

图 10-4:寄存器的变化已被高亮显示。

在图 10-4 中,gdb 高亮显示了即将执行的下一条指令以及已更改的寄存器。我们知道 pc 会发生变化,因为我们执行了一个指令。

已执行的指令更改了 x0,但这在图 10-4 中的 regs 显示面板中没有显示。我使用了 page up 键来查看图 10-5 中的 x0

image

图 10-5:查看刚执行的指令更改的寄存器

x0 寄存器在图 10-5 中被高亮显示,以显示它已发生变化。

接下来,我们将告诉 gdb 执行 ret 指令,这应该会把我们带回 C 托管环境,如图 10-6 所示。

image

图 10-6:回到 C 托管环境

ret 指令将链接寄存器 x30 中的地址复制到 pc,从而实现了从 main 函数的返回。

最后,我们继续执行图 10-7 中的程序。

图片

图 10-7:程序已完成。

剩下的就是退出gdb

你的回合

10.1     在清单 10-4 中输入程序,并使用gdb单步执行代码。

10.2     用汇编语言编写以下 C 函数:

int f(void) {

    return 0;
}

确保没有错误地汇编。使用-S选项编译f.c并将gcc的汇编语言与您的汇编语言进行比较。编写一个 C 语言的main函数来测试您的汇编语言函数f,并打印出该函数的返回值。

10.3     编写三个只返回整数的汇编语言函数。它们每个应该返回一个不同的非零整数。编写一个 C 语言的main函数来测试您的汇编语言函数,并使用printf打印出这些函数的返回值。

10.4     编写三个只返回字符的汇编语言函数。每个函数应返回一个不同的字符。编写一个 C 语言的main函数来测试您的汇编语言函数,并使用printf打印出这些函数的返回值。

你学到了什么

编辑器 用于编写选定编程语言程序源代码的程序。

预处理 编译的第一阶段。它将其他文件引入源代码,解释指令等,为实际编译做准备。

编译 将选定的编程语言翻译成汇编语言。

汇编 将汇编语言翻译成机器语言。

链接 将单独的目标代码模块和库链接在一起,生成最终的可执行程序。

汇编指令 在汇编过程中指导汇编程序。

mov 指令 在 CPU 内部移动值。

ret 指令 将程序流返回到调用函数。

gdb TUI 模式 当你逐步执行程序时,实时显示寄存器的变化。这是一个非常好的学习工具。

你可能会想,如果我们从main调用另一个函数,那么x30中的返回地址会发生什么。为了使用x30作为链接寄存器调用另一个函数,我们需要在执行另一个函数时将返回地址保存到某个地方。你将在下一章学习如何做到这一点,我们将详细讨论如何向函数传递参数,如何使用调用栈,以及如何在函数中创建局部变量。

第十一章:在主函数内部

Image

正如你在第十章中学到的,C 程序开始时会执行一个名为main的函数,该函数从 C 宿主环境中的启动函数调用。main函数将调用其他函数(子函数)来执行大部分处理。即使是一个简单的“Hello, World!”程序,也需要调用另一个函数来在屏幕上显示消息。

在本章中,我们将重点讲解main函数,但这些概念适用于我们将编写的所有函数。我们将从详细了解调用栈开始,它用于保存值和局部变量。然后,我们将探讨如何在函数中处理数据,以及如何将参数传递给其他函数。最后,我将通过向你展示如何使用这些知识来编写汇编语言中的main函数来结束本章。

使用调用栈

调用栈,通常简称为,是创建局部变量和在函数中保存项目的非常有用的地方。在我们讲解如何使用栈来完成这些目的之前,你需要了解栈是什么以及它是如何工作的。

栈的一般操作

栈是一种线性数据结构,创建于内存中用于存储数据项。数据项的插入(或删除)只能在栈的一端进行,这一端被称为栈顶。程序通过栈指针来跟踪栈顶。

非正式地,你可以把栈想象成像架子上的餐盘堆叠。你只需要能够访问栈顶的那个项目。(是的,如果你从栈的某个地方取出餐盘,可能会弄坏一些东西。)栈有两个基本操作:

push data_item 将 data_item 放置到栈顶,并将栈指针移动到指向此最新项。

pop location 将栈顶的项目移动到位置,并将栈指针移动到指向栈顶当前的项目。

栈是一种后进先出(LIFO)数据结构。最后被推入栈的项将是第一个被弹出的项。

为了说明栈的概念,我们继续使用餐盘的例子。假设我们有三个颜色不同的餐盘:一个红色的放在餐桌上,一个绿色的放在厨房台面上,另一个蓝色的放在床头柜上。我们将它们按照以下方式堆叠在架子上:

  1. 推入红色餐盘。

  2. 推入绿色餐盘。

  3. 推入蓝色餐盘。

到这个时刻,我们的餐盘堆叠看起来像图 11-1。

image

图 11-1:堆叠的三个餐盘

现在我们执行下一个操作:

  1. 从厨房台面弹出。

这将把蓝色餐盘移到厨房台面(回想一下,蓝色餐盘之前是放在床头柜上的),并留下如图 11-2 所示的餐盘堆叠。

image

图 11-2:一个晚餐盘已从栈中弹出。

如果你猜到栈很容易被搞砸,那么你是对的。栈必须按照严格的规则使用。在任何一个函数内:

  • 在弹出任何东西之前,始终先将项推入栈中。

  • 永远不要弹出比你推入的更多的东西。

  • 永远弹出所有你已经推入栈中的项。

如果你对推入栈中的项没有使用需求,可以将栈指针设置回函数最初进入时的位置。这相当于丢弃已弹出的项。(我们的晚餐盘比喻在这里就不成立了。)

维持这一纪律的一个好方法是将栈的使用类比于代数表达式中的括号。推入栈就像左括号,而弹出则像右括号。括号对可以嵌套,但必须匹配。试图向栈中推入过多项的情况叫做栈溢出。试图弹出栈底以下的项叫做栈下溢

栈是通过专门分配一块连续的主内存区域来实现的。栈可以在内存中向任何一个方向增长,向更高的地址或更低的地址。上升栈向更高的地址增长,下降栈向更低的地址增长。栈指针可以指向栈顶的项,即满栈,也可以指向下一个将被推入栈中的项所在的内存位置,即空栈。这四种可能的栈实现方式如图 11-3 所示,其中整数 1、2 和 3 按顺序推入栈中。注意,在这个图中,内存地址是向下增加的,这是我们通常在gdb调试器中查看的方式。

image

图 11-3:实现栈的四种方式

我们环境中的调用栈是一个满的下降栈。要理解这个选择,想想你可能如何在内存中组织事物。回想一下控制单元在程序执行时会自动递增程序计数器。程序的大小差异巨大,因此将程序指令存储在较低的内存地址处,可以在程序大小上提供最大的灵活性。

栈是一个动态结构。你无法预测任何给定程序执行时所需的栈空间,因此也不可能知道需要分配多少空间。为了分配尽可能多的空间,同时避免与程序指令发生冲突,应从最高的内存地址开始分配栈空间,并让它向较低的地址增长。

这是一个高度简化的对栈在内存中“向下”增长的实现方式的合理化解释。程序中各个元素在内存中的组织比这里所描述的要复杂得多,但这可能帮助你理解为什么这种看起来有些奇怪的实现方式实际上是有充分理由的。

A64 架构没有pushpop指令。它有允许你有效地将项目推入栈或从栈中弹出项目的指令,但栈上的大多数操作都是通过在调用栈上分配内存并直接将项目存储到该内存或从中加载项目来完成的。接下来,我们将看看函数如何使用调用栈。

栈帧

每个调用其他函数的函数都需要在栈上为被调用函数分配内存,用于保存项目和存储局部变量。这块分配的内存叫做栈帧激活记录。为了理解这一过程,我们将从一个包含一个局部变量并调用两个 C 标准库函数的程序开始:printfscanf。该程序见列表 11-1。

inc_int.c

// Increment an integer.

#include <stdio.h>

int main(void)
{
    int x;

    printf("Enter an integer: ");
    scanf("%i", &x);
    x++;
    printf("Result: %i\n", x); return 0;
}

列表 11-1:一个用于递增整数的程序

你可以通过查看编译器生成的汇编语言来看到栈帧是如何创建的,见列表 11-2。在本章接下来的几个部分中,我将引用这个列表中的编号行,通过第 222 页。

inc_int.s

        .arch armv8-a
        .file   "inc_int.c"
        .text
      ➊ .section        .rodata
        .align  3
.LC0:
        .string "Enter an integer: "
        .align  3
.LC1:
      ➋ .string "%i"
        .align  3
.LC2:
        .string "Result: %i\n"
        .text
        .align  2
        .global main
        .type   main, %function
main:
     ➌ stp     x29, x30, [sp, -32]!  /// Create stack frame
        mov     x29, sp               /// Set our frame pointer
        adrp    x0, .LC0              /// Page address
        add     x0, x0, :lo12:.LC0    /// Offset in page
        bl      printf
     ➍ add     x0, sp, 28            /// Address of x
        mov     x1, x0
     ➎ adrp    x0, .LC1
        add     x0, x0, :lo12:.LC1
        bl      __isoc99_scanf
     ➏ ldr     w0, [sp, 28]          /// Load int
        add     w0, w0, 1
     ❼ str     w0, [sp, 28]          /// x++;
        ldr     w0, [sp, 28]
        mov     w1, w0
        adrp    x0, .LC2
        add     x0, x0, :lo12:.LC2 bl      printf
        mov     w0, 0
     ❽ ldp     x29, x30, [sp], 32
        ret
        .size   main, .-main
        .ident  "GCC: (Debian 10.2.1-6) 10.2.1 20210110"
        .section        .note.GNU-stack,"",@progbits

列表 11-2:编译器生成的程序汇编语言,见列表 11-1

用于创建栈帧的指令形成了函数前言。函数前言中的第一条指令通常是stp指令:

stp—存储寄存器对

stp ws1, ws2, [xb{, 偏移量}]ws1 的值存储在xb 地址处,将ws2 的值存储在xb + 4 的位置。如果存在偏移量,它必须是 4 的倍数,并且在存储寄存器值之前添加到地址;xb 不会改变。

stp xs1, xs2 , [xb{, 偏移量}]xs1 的值存储在xb 地址处,将xs2 的值存储在xb + 8 的位置。如果存在偏移量,它必须是 8 的倍数,并且在存储寄存器值之前添加到地址;xb 不会改变。

stp—存储寄存器对,前索引

stp ws1, ws2, [xb, 偏移量]!将偏移量(必须是 4 的倍数)加到xb。然后它将ws1 的值存储在xb 的新地址处,将ws2 的值存储在xb + 4 的位置。

stp xs1, xs2, [xb, 偏移量]!将偏移量(必须是 8 的倍数)加到xb。然后它将xs1 的值存储在xb 的新地址处,将xs2 的值存储在xb + 8 的位置。

stp—存储寄存器对,后索引

stp ws1, ws2, [xb], 偏移量将ws1 的值存储在xb 地址处,将ws2 的值存储在xb + 4 的位置。然后它将偏移量(必须是 4 的倍数)加到xb。

stp xs1, xs2, [xb], 偏移量将xs1 的值存储在xb 地址处,将xs2 的值存储在xb + 8 的位置。然后它将偏移量(必须是 8 的倍数)加到xb。

注意

几乎所有其他 A64 指令的操作数顺序是 destination(s), source(s)但是对于存储指令,则是相反的。

我们写的大多数函数都将以类似这样的stp指令开始:

stp    x29, x30, [sp, -32]!

编译器在清单 11-2 中函数的开始处做了这件事,创建了堆栈帧 ❸。

ARM 64 位架构过程调用标准 (AArch64)》文档(可在* github.com/ARM-software/abi-aa/releases中以 PDF 和 HTML 格式获取)规定,帧指针*(存储在寄存器x29中,也称为fp)应该指向堆栈帧的顶部,即调用函数的帧指针所在的位置。指令mov x29, sp将设置被调用函数的帧指针,如清单 11-2 所示。

stp指令在此处指定堆栈内存地址的方式,[sp, -32]!,可能对你来说没有太多意义。让我们来看一下在 A64 架构中,指令是如何访问内存的。

A64 内存寻址

指令可能以两种方式引用内存地址:地址可以作为指令的一部分进行编码,通常称为绝对地址,或者它可以使用相对寻址,其中指令指定一个偏移量,相对于基地址。在后一种情况下,偏移量的大小和基地址的位置会被编码在指令中。

A64 架构中的所有指令长度为 32 位,但地址是 64 位长的。我们将在第十二章中详细介绍机器码,但显然,64 位地址无法适配 32 位指令。为了引用 64 位地址,指令使用表 11-1 中列出的相对寻址模式,在执行时计算地址。

表 11-1: A64 寻址模式

模式 语法 备注
字面值 标签 pc-相对
基寄存器 [base] 仅寄存器
基址加偏移 [base, offset] 寄存器相对
预索引 [base, offset]! 在寄存器前加偏移
后索引 [base], offset 在寄存器后加偏移

表 11-1 中的每种寻址方式都以基寄存器中的 64 位地址开始。字面模式使用 pc-相对寻址,其中程序计数器作为基寄存器。如果标签与引用它的指令位于同一段中,汇编器会计算从引用指令到标签指令的地址偏移量,并将该偏移量填入引用指令中。如果标签位于另一段中,链接器会计算偏移量,并在引用标签的位置填充该偏移量。指令中允许的位数限制了地址偏移量的大小。

pc相对寻址的一个优点是它为我们提供了位置无关代码(PIC),这意味着无论函数加载到内存的哪里,它都会正确执行。我们环境中gcc编译器的默认设置是生成 PIC,链接阶段生成位置无关可执行文件(PIE)。这意味着链接器不会为程序指定加载地址,因此操作系统可以将程序加载到任何它选择的位置。这样做的好处是,不包括加载地址在可执行文件中可以提高安全性。

在另外四种模式中,基址寄存器是一个通用寄存器,x0x30sp。对于基址加偏移模式,偏移量可以是一个立即数或寄存器中的值。偏移量会被符号扩展到 64 位,并加到基址寄存器中的值上以计算地址。如果偏移量在寄存器中,它可以被缩放,以使其成为加载或存储字节数的倍数。当你学习如何处理整数数组时,你会看到这一点,在第十七章中会介绍。

在预索引模式下,计算出的地址会在加载或存储值之前存储到基址寄存器中。而在后索引模式下,计算出的地址会在加载或存储值之后存储到基址寄存器中。

对于预索引模式,偏移量只能是一个立即数。后索引模式允许偏移量是一个立即数,或者对于一些高级编程技巧,偏移量可以是寄存器中的值。

我们环境中的调用栈是全下降式的(见图 11-3),因此stp指令使用预索引寻址模式。在清单 11-2 中的函数中,地址指定为[sp, -32]! ❸。这会在将调用者的帧指针和返回地址存储到栈上之前,先将 32 从栈指针中减去。这实际上为该函数分配了 16 字节的栈空间,然后将返回地址和调用者的帧指针压入调用栈。栈帧分配的字节数必须始终是 16 的倍数,因为栈指针sp必须始终对齐到 16 字节的地址边界。

在函数完成其处理后,我们需要一个函数尾部来恢复调用者的帧指针和链接寄存器,并删除栈帧。在清单 11-2 中的函数,通过以下指令完成这一操作:

ldp    x29, x30, [sp], 32

这条指令将栈顶的两个值加载到帧指针和链接寄存器中,然后将栈指针加 32❽。这实际上将栈顶的两个值弹出到x29x30寄存器中,然后删除该函数的栈帧。让我们来看一下ldp指令的一些变种,它允许我们一次从内存中加载两个值:

ldp—加载寄存器对

ldp wd1, wd2, [xb{, offset}] 会将xb 地址处的值加载到wd1 中,并将xb + 4 处的值加载到wd2 中。如果偏移量存在,它必须是 4 的倍数,并在加载值之前加到地址上;xb 本身不会改变。

ldp xd1, xd2, [xb{, offset}] 会将xb 地址处的值加载到xd1 中,并将xb + 8 处的值加载到xd2 中。如果偏移量存在,它必须是 8 的倍数,并在加载值之前加到地址上;xb 本身不会改变。

ldp—加载寄存器对,预索引

ldp wd1, wd2, [xb, offset]! 会将偏移量(必须是 4 的倍数)加到xb。接着,它会将新的xb 地址处的值加载到wd1 中,并将xb + 4 处的值加载到wd2 中。

ldp xd1, xd2, [xb, offset]! 会将偏移量(必须是 8 的倍数)加到xb。接着,它会将新的xb 地址处的值加载到xd1 中,并将xb + 8 处的值加载到xd2 中。

ldp—加载寄存器对,后索引

ldp wd1, wd2, [xb], 偏移量会将xb 地址处的值加载到wd1 中,并将xb + 4 处的值加载到wd2 中。接着,它会将偏移量(必须是 4 的倍数)加到xb。

ldp xd1, xd2, [xb], 偏移量会将xb 地址处的值加载到xd1 中,并将xb + 8 处的值加载到xd2 中。接着,它会将偏移量(必须是 8 的倍数)加到xb。

接下来,我们将看到该函数如何使用栈内存中的其他 16 字节。

调用栈中的局部变量

C 语言中的局部变量只能在定义它们的函数中通过名称直接访问。我们可以通过将该变量的地址传递给另一个函数,来允许其他函数访问我们函数中的局部变量,包括修改其值。这就是scanf能够为x存储值的原因,你将在第 221 页看到这一点。

你在第九章中学到过 CPU 寄存器可以作为变量使用。但如果我们将所有的变量都存储在 CPU 寄存器中,即使是一个小程序,也很快就会用完寄存器。因此,我们需要在内存中为变量分配空间。

如本章后续所示,函数需要保存一些寄存器的内容,以便调用函数使用。如果我们想在自己的函数中使用这样的寄存器,将它的内容存储到一个局部变量中是一个不错的选择,这样在返回调用函数之前我们可以恢复它。

栈帧满足局部变量的要求。它在函数首次开始时创建,在函数完成时删除。栈帧中的内存可以通过基址加偏移寻址模式轻松访问(参见表 11-1),sp作为基址寄存器。在清单 11-2 中有一个例子,我们在其中加载整数:

ldr    w0, [sp, 28]

该指令将位于 sp 地址偏移 28 字节处的 32 位字加载到 w0 ❻ 中。该函数将其堆栈帧视为记录,而非堆栈,使用此代码。你将在 第十七章 中学习关于记录的内容。

图 11-4 给出了 列表 11-1 和 11-2 中 main 函数完成的堆栈帧的示意图。

image

图 11-4: 列表 11-1 和 11-2 中函数的堆栈帧

堆栈上的两个地址各占 8 字节,而 int 变量 x 占 4 字节。灰色区域中的内存未使用,但对于保持堆栈指针 sp 在 16 字节地址边界上对齐是必要的。

现在你知道如何使用堆栈帧了,我们来看一下这个函数是如何处理数据的。

函数中的数据处理

A64 是一种加载–存储架构,这意味着操作数据的指令不能访问内存。对于数据在内存之间的移动,有一组单独的指令。

这与寄存器–内存架构相对,后者包含能够操作内存中数据的指令。数据操作仍然由 CPU 的算术/逻辑单元执行(参见 图 9-1),但它们使用的寄存器对程序员是隐藏的。Intel x86 就是寄存器–内存架构的一个例子。

在 列表 11-2 中的 main 函数的处理非常简单:程序将 1 加到一个整数上。但在执行此操作之前,它需要通过 ldr 指令 ❻ 将值加载到寄存器中。由于该程序会改变变量中的值,因此新的值必须通过 str 指令 ❼ 存回内存。

让我们看看一些常用的指令,用于从内存加载值:

ldr—加载寄存器, pc相对

ldr wd, addrwd 加载为位于地址 addr 处的 32 位值,该地址必须与此指令的地址 ±1MB 之内。xd 的位 63 到 32 被设置为 0

ldr xd, addrxd 加载为位于地址 addr 处的 64 位值,该地址必须与此指令的地址 ±1MB 之内。

ldr—加载寄存器,基址寄存器相对

ldr wd, [xb{, offset}]wd 加载为通过将 xb 中的地址与可选偏移(该偏移量是 4 的倍数,范围为 0 到 16,380)相加得到的内存位置中的 32 位值。xd 的位 63 到 32 被设置为 0

ldr xd, [xb{, offset}]xd 加载为通过将 xb 中的地址与可选偏移(该偏移量是 8 的倍数,范围为 0 到 32,760)相加得到的内存位置中的 64 位值。

ldrsw—加载寄存器,带符号字,基址寄存器相对

ldrsw wd, [xb{, offset}]wd 加载为位于内存位置的 32 位值,该位置是通过将 xb 中的地址与可选的偏移量相加得到的,偏移量是 4 的倍数,范围是 0 到 16,380。xd 的第 63 位到第 32 位被设置为加载字的第 31 位副本。

ldrb—加载寄存器,无符号字节,基寄存器相对

ldrb wd, [xb{, offset}]wd 的低位字节加载为位于内存位置的 8 位值,该位置是通过将 xb 中的地址与可选的偏移量相加得到的,偏移量的范围是 0 到 4,095。xd 的第 31 位到第 8 位被设置为 0;第 63 位到第 32 位不变。

ldrsb—加载寄存器,带符号字节,基寄存器相对

ldrsb wd, [xb{, offset}]wd 的低位字节加载为位于内存位置的 8 位值,该位置是通过将 xb 中的地址与可选的偏移量相加得到的,偏移量的范围是 0 到 4,095。xd 的第 31 位到第 8 位被设置为加载字节的第 7 位副本;第 63 位到第 32 位不变。

以下是一些用于将值存储到内存中的类似指令:

str—存储寄存器,pc 相对

str ws, addrw`s 中的 32 位值存储到地址为 addr 的内存位置,该位置必须与此指令相差 ±1MB。

str xs, addrx`s 中的 64 位值存储到内存位置 addr,该位置必须与此指令相差 ±1MB。

str—存储寄存器,基寄存器相对

str ws, [xb{, offset}]ws 中的 32 位值存储到内存位置,该位置是通过将 xb 中的地址与可选的偏移量相加得到的,偏移量是 4 的倍数,范围是 0 到 16,380。

str xs, [xb{, offset}]xs 中的 64 位值存储到内存位置,该位置是通过将 xb 中的地址与可选的偏移量相加得到的,偏移量是 8 的倍数,范围是 0 到 32,670。

strb—存储寄存器,字节,基寄存器相对

strb ws, [xb{, offset}] 将低位 8 位的值存储在 ws 中,存储位置是通过将 xb 中的地址与可选的偏移量相加得到的,偏移量的范围是 0 到 4,095。

程序简单地将 1 加到变量上,这可以通过 add 指令完成。我这里会提到 sub 指令,因为它与之非常相似,但只会给出一些基本语法(这两条指令有多个选项,具体内容可以在手册中找到):

add—扩展寄存器加法

add wd, ws1, ws2{, xtnd amnt}ws1 和 ws2 中的值相加,并将结果存储在 wd 中。从 ws2 加的值可以是字节、半字、字或双字。它可以进行符号扩展或零扩展,然后在加法之前左移 0 到 4 位,使用 xtnd amnt 选项。

add xd, xs1, xs2{, xtnd amnt}xs1 和 xs2 中的值相加,并将结果存储在 xd 中。从 xs2 加的值可以是字节、半字、字或双字。它可以进行符号扩展或零扩展,然后在加法之前左移 0 到 4 位,使用 xtnd amnt 选项。

add—加法立即数

add wd, ws, imm{, shft} 将 imm 加到 ws 的值中,并将结果存储在 wd 中。imm 操作数是一个无符号整数,范围从 0 到 4,095,在加法前可以使用 shft 选项将其左移 0 或 12 位。

add xd, xs, imm{, shft} 将 imm 加到 xs 的值中,并将结果存储在 xd 中。imm 操作数是一个无符号整数,范围从 0 到 4,095,在加法前可以使用 shft 选项将其左移 0 或 12 位。

sub—减法扩展寄存器

sub wd, ws1, ws2{, xtnd amnt}ws2 中减去 ws1 的值,并将结果存储在 wd 中。要从 ws2 中减去的值可以是字节、半字、字或双字。它可以是符号扩展或零扩展,然后在减法前使用 xtnd amnt 选项将其左移 0 到 4 位。

sub xd, xs1, xs2{, xtnd amnt}xs2 中减去 xs1 的值,并将结果存储在 xd 中。要从 xs2 中减去的值可以是字节、半字、字或双字。它可以是符号扩展或零扩展,然后在减法前使用 xtnd amnt 选项将其左移 0 到 4 位。

sub—减法立即数

sub wd, ws, imm{, shft}ws 中减去 imm,并将结果存储在 wd 中。imm 操作数是一个无符号整数,范围从 0 到 4,095,在减法前可以使用 shft 选项将其左移 0 或 12 位。

sub xd, xs, imm{, shft}xs 中减去 imm,并将结果存储在 xd 中。imm 操作数是一个无符号整数,范围从 0 到 4,095,在减法前可以使用 shft 选项将其左移 0 或 12 位。

表 11-2 列出了 addsub 指令中 xtnd 选项的允许值。

表 11-2: addsub 指令中 xtnd 的允许值

xtnd 效果
uxtb 无符号扩展字节
uxth 无符号扩展半字
uxtw 无符号扩展字
uxtx 无符号扩展双字
sxtb 带符号扩展字节
sxth 带符号扩展半字
sxtw 带符号扩展字
sxtx 双字的带符号扩展

扩展从源寄存器的指定位开始,向左添加位以匹配指令中其他寄存器的宽度。对于无符号扩展,添加的位全是 0。对于符号扩展,添加的位是起始值的最高位的副本。在使用 w 寄存器时,uxtw 可以替换为 lsl;在使用 x 寄存器时,uxtx 可以替换为 lsl

扩展一个已经是 64 位宽的双字,以匹配 x 寄存器的大小,可能看起来没有意义,但指令语法要求我们在希望移位时使用整个 xtnd amnt 选项。

x2x3 中的以下值为例,演示这些大小扩展的工作原理:

x2: 0xaaaaaaaaaaaaaaaa
x3: 0x89abba89fedccdef

指令序列

add    w0, w3, w2, uxtb
add    w1, w3, w2, sxtb

给出:

x0: 0xfedcce99
x3: 0xfedccd99

随着书中的进展,我们将看到其他使用这些宽度扩展的指令。

现在你知道如何进行算术运算了,让我们看看如何调用其他函数。

通过寄存器传递参数

函数传递参数给另一个函数有几种方法。我将首先介绍如何使用寄存器传递参数。在第十四章更详细讲解子函数时,我将讨论其他方法。

回想一下在第二章中提到的,当一个函数调用另一个函数时,它可以传递被调用函数作为参数使用的参数。原则上,C 编译器—或者你在编写汇编语言时—可以使用 31 个通用寄存器中的任何一个,除了链接寄存器x30,来在函数之间传递参数。只需将参数存储在寄存器中,然后调用所需的函数。当然,调用函数和被调用函数需要就每个参数所在的寄存器达成一致。

避免犯错的最佳方法是遵循一套标准规则。这一点尤其重要,如果有多人为同一个程序编写代码。其他人已经意识到拥有这种标准的重要性,并开发了一个应用二进制接口(ABI),其中包括一套用于在 A64 架构中传递参数的标准。我们使用的编译器gcc遵循Arm 64 位架构的过程调用标准(请参见第 213 页),我们在编写汇编语言时也将遵循这些规则。

表 11-3 总结了被调用函数如何使用寄存器的标准。

表 11-3: 通用寄存器的使用

寄存器 用途 是否保存?
x0x7 参数;结果
x8 结果地址
x9x18 临时寄存器
x19x28 变量
x29 帧指针
x30 链接寄存器
sp 栈指针
xzr 零寄存器 不适用

我们将使用wn 代替xn 来表示 32 位寄存器名称。本书中使用的是 64 位寻址。由于x29x30将始终包含地址,因此我们永远不会使用w29w30

“是否保存?”列显示被调用函数是否需要保留该寄存器中的值以供调用函数使用。如果我们需要使用必须保留的寄存器,我们将在栈帧中为此创建一个局部变量。

调用函数按从左到右的顺序在寄存器中传递参数,从x0开始(对于 32 位值为w0)。这允许传递最多八个参数,x0x7。你将在第十四章中看到如何使用调用栈传递超过八个参数。

以如何传递参数为例,让我们看看在示例 11-1 中调用scanf的情况:

scanf("%i", &x);

让我们从第二个参数开始,x 的地址。在 图 11-4 中,x 位于栈指针 sp 偏移量 28 字节的位置。从编译器生成的汇编语言中可以看到,在 清单 11-2 中,计算该地址可以通过将 28 加到 sp ❹ 来完成。由于这是第二个参数,它需要移动到 x1

第一个参数——文本字符串 "%i",它是通过 .string 汇编指令 ❷ 创建的——比较复杂。.string 指令的通用格式是:

.string "text"

这会创建一个 C 风格的文本字符串,作为一个 char 数组,每个字符代码点占用一个字节,再加上一个字节用于终止的 NUL 字符。

编译器将程序中的三个文本字符串放置在目标文件的 .rodata 区段 ❶ 中。加载器/链接器通常将 .rodata 区段加载到执行代码后的 text 段中。注意,每个文本字符串都按 8 字节(64 位)对齐,通过 .align 3 指令来实现。这可能使得代码执行得稍微更快。

当你在 C 中将一个数组传递给函数时,只有数组第一个元素的地址会被传递。所以,"%i" 的第一个字符的地址会被传递给 scanf。A64 架构提供了两条指令来将地址加载到寄存器中:

adr—地址

adr xd, addr 将内存地址 addr 加载到 xd 中;addr 必须在 ±1MB 范围内。

adrp—地址页面

adrp xd, addr 将 addr 的页面地址加载到 xd 的第 63 至 12 位中,低 12 位设为 0。页面地址是 addr 的下一个较低的 4KB 地址边界,并且 addr 必须在 ±4GB 范围内。

两条指令都使用字面寻址模式(见 表 11-1)来引用内存地址。它们每条指令都允许一个 21 位的偏移量值,因此 adr 的范围为 ±1MB。由于低 12 位为 0,adrp 指令从 pc 给出了一个 33 位的偏移量,因此从 pc 的寻址范围为 ±4GB,但具有 4KB 的粒度。

adrp 指令有效地将内存视为被分成 4KB 页面。(这些页面在概念上与操作系统用来管理主内存的内存页面是不同的。)它将 4KB 页面开始的地址,即 页面地址,加载到目标寄存器中。与 adr 指令相比,这增加了我们可以加载到寄存器中的地址范围,从 ±1MB 增加到 ±4GB,但我们仍然需要将 4KB 页面内的偏移量加到寄存器中的页面地址上。

因此,我们可以通过两条指令的序列加载位于 ±4GB 范围内的 64 位地址。编译器在 清单 11-2 中使用以下代码 ❺ 完成了这个操作:

adrp    x0, .LC1
add     x0, x0, :lo12:.LC1

由于标签.LC1位于.rodata段中,链接器计算从指令到标签的偏移量。adrp指令将该偏移量的页号加载到x0中。:lo12:修饰符告诉汇编器只使用偏移量的低 12 位作为add指令的立即数。这一过程可能对您来说有些困惑。这是因为指令中用于立即数的位数有限;当我们讲解如何将指令编码为二进制时,您将看到详细信息,参见第十二章。

在将参数加载到寄存器后,我们通过blblr指令将程序流转移到另一个函数:

bl—分支并链接

bl addr 将地址pc加上 4,并将结果加载到x30中。然后,它将地址addr的内存地址加载到pc中,从而跳转到addr,该地址必须位于此指令的±128MB 范围内。

blr—分支并链接,寄存器

blr xs 将地址pc加上 4,并将结果加载到x30中。然后,它将xs 中的 64 位地址移动到pc,从而跳转到该地址。

这些指令用于调用函数。将地址pc加 4 后,得到blblr指令后面的内存地址。我们通常希望被调用的函数返回到这个位置。x30寄存器被这两条分支指令用作链接寄存器。

在接下来的部分,我们将用汇编语言编写程序。它将与编译器生成的代码非常相似,但我们将使用更易读的名称。

用汇编语言编写 main 函数

列表 11-3 展示了我用汇编语言编写的inc_int程序版本。它紧密跟随编译器从 C 版本生成的汇编代码,见列表 11-2,但我添加了注释,并为字符串常量使用了更有意义的标签。这应该能让您更容易理解程序如何使用栈并将参数传递给其他函数。

inc_int.s

// Increment an integer.
        .arch armv8-a
// Stack frame
     ➊ .equ    x, 28 ➋ .equ    FRAME, 32
// Constant data
     ➌ .section .rodata
prompt:
        .string "Enter an integer: "
input_format:
        .string "%i"
result:
        .string "Result: %i\n"
// Code
        .text
        .align  2
        .global main
        .type   main, %function
main:
     ➍ stp     fp, lr, [sp, FRAME]!  // Create stack frame
        mov     fp, sp                // Set our frame pointer

        adr     x0, prompt            // Prompt user
        bl      printf
        add     x1, sp, x             // Address for input
        adr     x0, input_format      // scanf format string
     ➎ bl      scanf

        ldr     w0, [sp, x]           // Get x
        add     w1, w0, 1             // Add 1
        str     w1, [sp, x]           // x++

        adr     x0, result            // printf format string
        bl      printf                // Result is in w1

        mov     w0, wzr
     ➏ ldp     fp, lr, [sp], FRAME   // Delete stack frame
        ret

列表 11-3:用汇编语言递增一个整数的程序

我们在列表 11-3 中看到了另一个汇编指令.equ。其格式为:

.equ symbol, expression

表达式必须计算为一个整数,汇编器将符号设置为该值。然后,您可以在代码中使用该符号,使代码更易读,汇编器将插入表达式的值。该表达式通常只是一个整数。例如,我将符号FRAME等于整数 32 ❷。这使我们能够编写自文档化的代码 ❹。我还使用了汇编器的名称fplr,分别表示寄存器x29x30

注意,我们不需要为 .rodata 段指定 .text 段 ❸。汇编器和链接器会生成 .rodata 段,操作系统决定它的加载位置。我也没有对 .rodata 段中的文本字符串进行对齐。尽管对齐可能会让代码执行得更快一点,但也可能浪费一些内存字节。(对于我们在本书中做的编程,这两个因素都无关紧要。)我还使用了 adr 而不是 adrp 来加载字符串的地址。我们在本书中编写的程序非常简单,因此我预计 .rodata 段中的字符串会在使用它们的指令的 ±1MB 范围内。

最后,我调用了 scanf 而不是 __isoc99_scanf ❺。__isoc99_ 前缀禁止使用一些非标准的转换说明符;再次强调,这超出了本书的范围。

我们的变量 x 位于栈帧 ❶ 中。栈帧在函数前言 ❹ 中创建,并在函数尾声 ❻ 中删除,这使得 x 成为一个自动局部变量。

轮到你了

11.1     你可以告诉 gcc 编译器使用 -Ofast 选项为速度优化生成的代码,或者使用 -Os 选项为大小优化生成的代码。为每个选项生成 列表 11-1 中程序的汇编语言代码。有什么不同?

11.2     修改 列表 11-3 中的程序,使其输入两个整数,并显示这两个数的和与差。

11.3     在一个名为 sum_diff.c 的文件中输入以下 C 代码:

// Add and subtract two integers.

void sum_diff(int x, int y, int *sum, int *diff)
{
    *sum = x + y;
    *diff = x - y;
}

修改 列表 11-3 中的程序,使其输入两个整数,调用 sum_diff 计算这两个整数的和与差,然后显示两个结果。

你学到了什么

调用栈 用于存储程序数据和地址的内存区域,按需增长和收缩。

栈帧 调用栈上的内存,用于保存返回地址和调用者的帧指针,以及用于创建局部变量。

函数前言 创建栈帧的指令。

函数尾声 恢复调用者的链接寄存器和帧指针并删除栈帧的指令。

自动局部变量 每次调用函数时新创建的变量。它们可以很容易地创建在调用栈上。

向子函数传递参数 最多可以通过 x0x7 寄存器传递八个参数。

调用函数 分支和链接指令 blblr 将程序流转移到一个函数,并将返回地址存储在 x30 中。

A64 寻址 有多种模式可以使用 32 位指令生成 64 位地址。

位置无关可执行文件 操作系统可以将程序加载到内存中的任何位置,并且它将正确执行。

加载-存储架构 指令只能对寄存器中的数据进行操作。

在下一章,我们将简要了解指令是如何在机器语言中编码的。这将帮助你理解一些指令限制的原因,例如在引用内存地址时偏移量的大小。

第十二章:指令详情**

Image

在第二章和第三章中,你学习了如何使用位模式表示数据。然后,在第四章到第八章中,你学习了如何在硬件中实现位,并利用它们执行计算。在本章中,我将解释一些关于指令如何通过位模式编码的细节,这些位模式指定了操作和它们操作的数据的位置。

本章的主要目标是让你对计算机指令如何知道它们操作的数据的位置有一个整体了解。每个指令的机器码细节并不是人们会记住的内容——你需要查阅手册来获得这些细节——但能够理解这些内容帮助我在职业生涯中更好地理解和调试许多程序。

《Arm 架构参考手册 A-Profile 架构》,可以在developer.arm.com/documentation/ddi0487/latest找到,详细描述了给定指令中每一位是如何影响该指令执行的,读起来可能有些吓人。为了帮助你学会如何阅读手册中的细节,我将涵盖几个指令,并在手册的描述上添加我自己的解释。

如第十章中所提到的,A64 指令分为三大类:

加载与存储 这些指令用于在内存和通用寄存器之间传输数据。

数据处理 这些指令在寄存器中的数据项和作为指令一部分编码的常量上进行操作。

程序流程控制 这些指令用于改变指令执行的顺序,而不是它们加载到内存中的顺序。

在本章中,我们将看几个每种类型指令的例子。

查看机器码

汇编列表是汇编器从汇编语言源代码生成的特定类型文件,显示了每条汇编语言指令对应的机器码。我将使用清单 12-1 中的程序来展示几条指令的机器语言。

add_consts.s

// Add three constants to show some machine code.
        .arch armv8-a
// Stack frame
        .equ    z, 28
        .equ    FRAME, 32
// Constant data
        .section  .rodata
format:
        .string "%i + %i + 456 = %i\n"
// Code
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp       fp, lr, [sp, FRAME]!  // Create stack frame
        mov       fp, sp

        mov       w19, 123              // 1st constant
        mov       w20, -123             // 2nd constant
        add       w21, w19, w20         // Add them
        add       w22, w21, 456         // Another constant
        str       w22, [sp, z]          // Store sum

        ldr       w3, [sp, z]           // Get sum
     ➊ mov       w2, w20               // Get 2nd constant
     ➋ orr       w2, wzr, w20          // Alias
        mov       w1, w19               // Get 1st constant
        adr       x0, format            // Assume on same page bl        printf

        mov       w0, wzr               // Return 0
        ldp       fp, lr, [sp], FRAME   // Delete stack frame
        ret

清单 12-1:一个用于加法常量的程序

当然,这是一个傻乎乎的程序——所有的数据都是常量——但它确实让我能够说明一些要点。例如,我使用了两条不同的指令,mov ❶ 和 orr ❷,将w20中的值复制到w2中。这将使我们能够比较两种完成相同效果的机器码。

我们可以通过将-al选项传递给汇编器来生成汇编列表。这会将汇编列表写入标准输出,默认是屏幕。我们可以使用重定向操作符>捕获它。例如,我使用了命令

$ as --gstabs -al -o add_consts.o add_consts.s > add_consts.lst

生成清单 12-2 中显示的汇编列表文件。

add_consts.lst

AARCH64 GAS  add_consts.s     page 1

    1               // Add three constants to show some machine code.
    2                       .arch armv8-a
    3               // Stack frame
    4                       .equ    z, 28
    5                       .equ    FRAME, 32
    6               // Constant data
    7                       .section  .rodata
    8               format:
    9 0000 2569202B         .string "%i + %i + 456 = %i\n"
    9      20256920
    9      2B203435
    9      36203D20
    9      25690A00
   10               // Code
   11                       .text
   12                       .align 2
   13                       .global main
   14                       .type main, %function
   15               main:
➊ 16 0000 FD7B82A9           stp     fp, lr, [sp, FRAME]!  // Create stack frame
   17 0004 FD030091           mov     fp, sp
   18
   19 0008 730F8052           mov     w19, 123              // 1st constant
   20 000c 540F8012           mov     w20, -123             // 2nd constant
   21 0010 7502140B           add     w21, w19, w20         // Add them 22 0014 B6220711           add     w22, w21, 456         // Another constant
   23 0018 F61F00B9           str     w22, [sp, z]          // Store sum
   24
   25 001c E31F40B9           ldr     w3, [sp, z]           // Get sum
➋ 26 0020 E203142A           mov     w2, w20               // Get 2nd constant
➌ 27 0024 E203142A           orr     w2, wzr, w20          // Alias instruction
   28 0028 E103132A           mov     w1, w19               // Get 1st constant
   29 002c 00000010           adr     x0, format            // Assume on same page
   30 0030 00000094           bl      printf
   31
   32 0034 E0031F2A           mov     w0, wzr               // Return 0
   33 0038 FD7BC2A8           ldp     fp, lr, [sp], FRAME   // Delete stack frame
   34 003c C0035FD6           ret

清单 12-2:程序的汇编清单文件,参见清单 12-1

汇编清单文件中的第一列显示源文件中的相应行号。下一列显示从每个节的开始到该行的 16 位相对地址,采用十六进制表示。

第三列给出了指令或数据的机器码,也以十六进制表示。所有 A64 指令都是 32 位宽的。汇编清单显示了每条指令中的 4 个字节,它们将按顺序存储在内存中。由于我们处于小端环境,汇编清单中的 4 个字节会按相反顺序显示。例如,本程序中的第一条指令❶是 32 位字0xa9827bfd。(我们稍后将在“从寄存器到寄存器的数据移动”部分,位于第 232 页中,查看我在此清单中标出的其他指令。)

在某些情况下,我描述中的比特字段名称与手册中的不同。请注意,我使用的一些名称在手册中的其他地方可能有不同的含义。以下是我使用的名称:

sf 大小标志。当为0时,操作数为 32 位值;当为1时,操作数为 64 位值。

imm 指令使用的常数整数。

hw 在将 16 位imm值加载到寄存器之前,左移的半字(16 位)的数量。

b_offset 当前指令到某个地址的字节数。

b_offset:hi b_offset的高位部分。

b:lo b_offset的低位部分。

w_offset 当前指令到某个地址的 32 位字的数量。

rb 持有 64 位基地址的寄存器的编号,该地址用于此指令。

rd 目标寄存器的编号,用于存储指令操作的结果。如果有两个目标寄存器,我使用rd1rd2

rs 源寄存器的编号,用于存储在指令操作中使用的值。如果有两个源寄存器,我使用rs1rs2

pi 指示指令如何处理基寄存器:01表示后索引,11表示前索引,或10表示不改变。

sh 告诉指令是否在操作前对操作数进行移位:如果为1,则为移位;如果为0,则不移位。对于 2 位的sh字段,00表示lsl01表示lsr10表示asr11表示ror

shft_amnt 操作数要左移的位数。

位 28 到 25 显示指令所在的组:加载和存储组(0x4, 0x6, 0xc, 0xe)、数据处理组(0x5, 0x7, 0x8, 0x9, 0xd, 0xf),或者程序流控制组(0xa, 0xb)。许多指令有变体。汇编器将选择适合我们使用的操作数的变体。我将首先向你展示清单 12-1 中的加载和存储指令。

加载和存储指令的编码

图 12-1 显示了基本的加载指令。

图片

图 12-1:基本的加载指令:Idr w3, [sp, z]

图 12-2 显示了基本的存储指令。

Image

图 12-2:基本的存储指令: str w22, [sp, z]

在这两条指令中,第 24 位是 1,这表示汇编器使用了立即数、无符号偏移量变体。其他变体的第 24 位是 0。这两条指令都使用 sp 作为基地址寄存器,rb 字段中的值为 11111,而在这两条指令中,从基寄存器的偏移量是 32 位字的数量。代码清单 12-1 中的偏移量是 z,即 28。我们在汇编语言中使用字节数,但汇编器将这个值除以 4,以便在指令的机器码中编码字的数量,这样 w_offset 字段中的值就是 7。

在图 12-1 中,sf 字段中的 0rd 字段中的 00011 告诉 CPU 使用 w3 作为目标寄存器,在图 12-2 中,sf 字段中的 0rs 字段中的 10110 告诉 CPU 使用 w22 作为源寄存器。

堆栈框架是通过 stp 指令创建的,如图 12-3 所示。

Image

图 12-3:将 fp sp 推送到堆栈的指令: stp fp, lr, [sp, FRAME]!

它通过 ldp 指令删除,如图 12-4 所示。

Image

图 12-4:从堆栈中弹出 fp sp 的指令: ldp fp, lr, [sp], FRAME

这两个指令都使用 sp 作为基址寄存器。在图 12-3 中,pi 字段中的 11 告诉 CPU 在存储寄存器 x29x30 的内容到该地址之前(前索引)从 sp 中减去 w_offset,即 4 个字(32 字节)。因为 sf1,CPU 会存储每个寄存器的整个 64 位数据。

pi 字段在图 12-4 中是 01,因此在将两个 64 位的值加载到寄存器 x29x30 后,sp 会加上 32(后索引)。CPU 从内存加载 64 位数据到每个寄存器,因为 sf1。接下来,我将解释代码清单 12-2 中的数据处理指令。

编码数据处理指令

数据处理指令作用于已经在 CPU 中的值,这些值可能存储在寄存器中,也可能是指令本身的一部分。它们用于移动数据或对数据进行算术和逻辑运算。在某些情况下,这些操作会重叠。

从寄存器到寄存器的数据移动

我们将首先查看将值从 sp 寄存器移动到 fp 寄存器的指令,如图 12-5 所示。

Image

图 12-5:基本的寄存器到寄存器的移动指令: mov fp, sp

接下来,我们将查看一种使用逻辑运算符将值从一个寄存器有效地移动到另一个寄存器的指令,见图 12-6。

Image

图 12-6:通过逻辑运算移动数据的指令: orr w2, wzr, w20

请注意,图 12-5 中的rs字段与图 12-6 中的rs1字段相同,但在第一个情况下,它是堆栈指针的代码,而在第二个情况下,它是零寄存器。这表明,寄存器 31 的处理方式,作为堆栈指针或零寄存器,取决于指令。

你可能会想知道为什么在图 12-6 中我展示的是orr指令,而不是mov指令。正如你从名称中可能猜到的那样,orr指令对两个源寄存器rs1rs2中的值执行按位或(bitwise OR)操作,并将结果存储到目标寄存器rd中。由于我们的指令中rs1是零寄存器,因此这个操作实际上只是将rs2中的值移动到rd,这相当于mov w2, w20。当我们在第十六章讨论逻辑运算符时,我会更详细地描述orr指令。

在清单 12-2(见第 229 页)中的mov w2, w20 ❷和orr w2, wzr, w20 ❸指令使用的是完全相同的机器码。这两种指令的不同名称被称为别名。对于具有别名的指令,你应当使用能更好表达算法意图的名称。在我们的示例程序中,mov w2, w20是一个更好的选择。

将常量移入寄存器

图 12-7 展示了将正数或无符号常量移入寄存器的指令。

Image

图 12-7:将正常量移入寄存器的指令: mov w19, 123

图 12-8 展示了将负常量移动到寄存器中的指令。

Image

图 12-8:将负常量移入寄存器的指令: mov w20, -123

尽管这两条指令都使用mov助记符,但在图 12-7 中,位 30 为1,而在图 12-8 中,它为0。其区别在于,第一条指令是在移动一个正数,而第二条指令则是在移动一个负数。当mov指令中的常量为负数时,汇编器会使用第十章中的movn(移位并取反)指令。

在图 12-7 中,常量+123 被编码为0x007b,这符合我们的预期,但在图 12-8 中,我们看到–123 被编码为0x007a,在十进制中是+122。

你在第三章中学到,在二补码表示法中,负数可以通过取数字的补码再加 1 来计算。换句话说,-123 在二补码表示法中是+122 的补码。图 12-8 中的mov指令计算imm字段值的 NOT 值,并将其符号扩展到目标寄存器的大小后存储到该寄存器中。所以,这条指令使用0x007a0xffffff85存储到w20寄存器中,如调试器所示:

Breakpoint 1, main () at add_consts.s:24
24              str     w22, [sp, z]          // Store sum
(gdb) i r w19 w20 w21 w22
w19            0x7b                123
w20            0xffffff85          4294967173
w21            0x0                 0
w22            0x1c8               456

在这两条指令中,常数必须适应 16 位立即数值。正数的范围是 0 到+65,535,负数的范围是-1 到-65,536。

执行算术运算

我们来看一下在图 12-9 中显示的将常数加到寄存器值中的指令。

Image

图 12-9:将常数添加到值的指令: add w22, w21, 456

这条指令将 12 位的imm值加到rs中的值,并将结果存储在rd寄存器中。当移位位sh1时,imm值会先左移 12 位再进行加法。这个移位选项允许我们在两次add操作中添加一个 24 位的常数。第一次将添加低 12 位,第二次将添加高 12 位。

将图 12-9 与图 12-5 进行比较,你会看到另一个别名的例子。如果imm为 0 且rdrs中的一个为 31,那么这条add指令与mov指令(用于sp寄存器)是相同的。

计算地址

现在,让我们看一下加载printf格式字符串地址到x0寄存器中的指令,如图 12-10 所示。

Image

图 12-10:加载地址的指令: adr x0, format

你在第十一章中学到,这条指令通过将这条指令的偏移量加到formatpc中的值来计算format的地址,然后将结果加载到x0寄存器中。这条指令中的b_offset字段显示所有 21 位都是0(别忘了包括b:lo字段中的两个低位)。这似乎表明format文本字符串位于与这条指令相同的位置,显然这是不可能的。该文本字符串位于.rodata段中。链接器将在链接过程中决定该段的存放位置,并填充b_offset:hib:lo字段。

查看可执行文件的详细信息

你可以使用一个名为objdump的程序查看可执行程序文件中的代码。例如,要转储add_consts文件的内容,可以使用以下命令:

   $ objdump -D add_consts
   --snip--
   0000000000000774 <main>:
   774:   a9827bfd        stp     x29, x30, [sp, #32]!
   778:   910003fd        mov     x29, sp
   77c:   52800f73        mov     w19, #0x7b                      // #123
   780:   12800f54        mov     w20, #0xffffff85                // #-123
   784:   0b140275        add     w21, w19, w20
   788:   110722b6        add     w22, w21, #0x1c8
   78c:   b9001ff6        str     w22, [sp, #28]
   790:   b9401fe3        ldr     w3, [sp, #28]
   794:   2a1403e2        mov     w2, w20
   798:   2a1403e2        mov     w2, w20
   79c:   2a1303e1        mov     w1, w19
➊ 7a0:   100005c0        adr     x0, 858 <format>
   7a4:   97ffffab        bl      650 <printf@plt>
   7a8:   2a1f03e0        mov     w0, wzr
   7ac:   a8c27bfd        ldp     x29, x30, [sp], #32
   7b0:   d65f03c0        ret
   7b4:   d503201f        nop
   7b8:   d503201f        nop
   7bc:   d503201f        nop
   --snip--
   0000000000000858 <format>:
   858:   2b206925        adds    w5, w9, w0, uxtx #2
   85c:   20692520        .inst   0x20692520 ; undefined
   860:   3534202b        cbnz    w11, 68c64 <__bss_end__+0x57c24> 864:   203d2036        .inst   0x203d2036 ; undefined
   868:   000a6925        .inst   0x000a6925 ; undefined
   --snip--

-D选项转储文件中的所有段,假设它们都包含指令,并将它们反汇编。我这里只显示了我们关心的两个段。

第一列显示每条指令加载到内存时的相对地址。操作系统将在加载程序时决定基加载地址。

第二列显示该地址处指令的机器码。请注意,objdump显示的机器码是 32 位指令顺序,而不是我们在汇编清单文件中看到的小端字节顺序。

链接器已填充adr指令中的format文本字符串的偏移量❶。根据图 12-10,该偏移量为000000000000010110100。将其加到指令的相对地址上,得到0x7a4 + 0x0b4 = 0x858

相对地址0x858处的机器码以字节0x250x690x200x2b开始,这些是格式文本字符串中前四个字符的代码点:%i、空格和+。汇编清单文件(见清单 12-2)按正确顺序显示这些字节。接下来,我将向你展示此程序中的两个指令,它们使程序流程转移到内存中下一个指令之外的位置。

编码程序流程控制指令

我将从用于调用printf函数的指令开始,见图 12-11。

Image

图 12-11:函数调用指令: bl printf

bl指令将pc中的地址加上 4 后的值复制到链接寄存器x30。然后,它将w_offset向左移 2 位,得到字节偏移量,扩展为 64 位,并将结果加到pc。结果是将bl指令后面的指令地址保存到链接寄存器中,然后将程序流程转移到距离bl指令地址w_offset字的地方。由于w_offset是 26 位宽,因此字节偏移量被限制为 28 位,导致在内存中最多可以转移±128MB。

我们将查看的最后一条指令是ret,见图 12-12。

Image

图 12-12:函数返回指令: ret

ret指令将指定寄存器中地址的值移动到pc。尽管在清单 12-2 中我没有指定寄存器,但汇编器默认使用x30。我们可以使用另一个寄存器,但这将与已发布的标准不一致,并可能导致程序出现错误。

你来试试

12.1 在清单 12-1 中输入程序,并使用调试器确定adr指令何时知道格式文本字符串的地址。

12.2 尝试修改清单 12-1 中的常量,找出常量的大小限制。

12.3 修改清单 12-1 中的程序,使用 64 位整数(C 语言中的long int)。这是否允许你使用更大的常量?

12.4    编写一个 C 程序,完成与我们在清单 12-1 中汇编语言程序相同的功能。你的 C 程序是否允许你使用更大的常量?如果可以,为什么?

现在你知道了机器代码是什么样子,我们来看一下汇编器程序是如何将汇编语言翻译成机器代码的。链接功能的通用算法类似;我也会讲解这一部分。

将汇编语言翻译成机器代码

本节的介绍是概述性内容,因此忽略了大多数细节。我的目的是仅向你大致展示汇编器如何将源代码翻译成机器语言,以及链接器如何将构成整个程序的不同模块连接起来。

汇编器

汇编器将汇编语言翻译成机器代码的最简单方法是逐行处理源代码,一次翻译一行。这种方法在大多数情况下是有效的,除了指令引用当前行之后的标签的位置时。

为了了解汇编器如何处理这些前向引用,我将在第十三章中的清单 13-11 中做一个前向引用,我们将在其中使用这些引用。清单 12-3 显示了该程序的汇编清单文件的一部分。

--snip--
33 0024 3F00006B                cmp     w1, w0                // Above or below middle?
34 0028 88000054                b.hi    tails                 // Above -> tails
35 002c 00000010                adr     x0, heads_msg         // Below -> heads message
36 0030 00000094                bl      puts                  // Print message
37 0034 03000014                b       continue              // Skip else part
38               tails: 39 0038 00000010                adr     x0, tails_msg         // Tails message page address
--snip--

清单 12-3:来自第十三章的清单 13-11 程序的一部分

我将在第十三章中详细讲解,但这一段代码比较了w0w1寄存器中的值。如果w1中的值较高,b.hi指令会导致程序流程跳转到标记为tails的地址。

图 12-13 展示了汇编器为b.hi指令生成的机器代码。

图片

图 12-13:条件分支: b.hi tails

tails标签位于相对位置0x38,比0x28处的b.hi指令高0x10字节。在图 12-13 中,w_offset0x00004,即0x10字节。问题是汇编器是如何知道tails标签的前向引用位置的。

处理前向引用的常用方法是使用两遍汇编器,它会扫描程序两遍。在第一次遍历期间,汇编器创建一个本地符号表,将每个符号与一个数字值关联起来。通过.equ指令定义的符号会直接输入到表中。

对于代码中带标签的位置,汇编器需要确定每个标签相对于正在汇编的模块起始位置的距离,并将该值和标签输入表中。每个.text.data段在文件中都会创建一个独立的本地符号表。

这是一个两遍汇编器第一次遍历的通用算法,它生成一个本地符号表:

Let location_counter = 0
do
    Read a line of source code
    if (.equ directive)
        local_symbol_table.symbol = symbol
        local_symbol_table.value = expression value
    else if (line has a label)
        local_symbol_table.symbol = label
        local_symbol_table.value = location_counter
    Determine number_of_bytes required by line when assembled
    location_counter = location_counter + number_of_bytes
while (more lines of source code)

一旦本地符号表创建完成,汇编器会对源代码文件进行第二遍扫描。它使用内建的操作码表来确定机器代码,当指令中使用符号时,它会在本地符号表中查找符号的值。如果在本地符号表中没有找到该符号,它会在指令中留出空位来填入数字,并将符号及其位置记录在目标文件中。

一般算法如下:

Let location_counter = 0
do
    Read a line of source code
    Find machine code from opcode table
    if (symbol is used in instruction)
        if (symbol found in local_symbol_table)
            Get value of symbol
        else
            Let value = 0
            Write symbol and location_counter to object file
        Add symbol value to instruction
    Write assembled instruction to object file
    Determine number_of_bytes used by the assembled instruction
    location_counter = location_counter + number_of_bytes
while (more lines of source code)

作为替代方案,我们可以创建一个单遍汇编器。它需要维护一个前向引用位置的列表,当找到标签时,使用该表回去填写适当的值。

再次强调,这是对汇编过程的高度简化概述,旨在展示汇编器工作的大致思路。Andrew S. Tanenbaum 和 Todd Austin 的《结构化计算机组织》第 6 版(Pearson,2012)中的第七章提供了有关汇编过程的更多细节。Leland Beck 的《系统软件:系统编程导论》第 3 版(Pearson,1997)中的第二章详细讨论了汇编程序的设计。

大多数函数会有函数调用,即对其他文件中定义的.text段标签的引用,这些引用无法由汇编器解决。.data段中的任何标签也是如此,即使它们定义在同一个源代码文件中。接下来的部分将展示一个解决这些引用的程序。

链接器

链接器的工作是确定程序中标签的相对位置,以便在引用标签的地方插入该标签的偏移量。链接器的工作方式与汇编器类似,只不过基本单元是机器代码块,而不是一行汇编语言。一个典型的程序由多个目标文件组成,每个文件通常有不止一个.text段,并可能有.data段,这些段必须被链接在一起。与汇编器一样,可以通过两遍扫描来解决前向引用问题。

汇编器生成的目标文件包含文件中每个段的大小,以及所有全局符号的列表和它们在段中的使用位置。在第一遍扫描中,链接器读取每个目标文件,并创建一个全局符号表,该表包含每个全局符号相对于程序起始位置的相对位置。在第二遍扫描中,链接器创建一个可执行文件,该文件包括所有来自目标文件的机器代码,并将全局符号表中的相对位置值插入到引用它们的位置。

这个过程解决了所有引用程序中各模块定义的名称的引用,但它会将所有对外部定义名称的引用(如在 C 标准库中定义的函数或变量名)保持未解决。链接器将这些未解决的引用输入到全局偏移表(GOT)中。

如果外部引用是函数调用,链接器还会将此信息输入到过程链接表(PLT)中,并记录引用所在的机器码位置。你可以通过查看我们在列表 12-1 中如何编写调用 C 标准库函数的代码,来看到链接器是如何做到这一点的:

bl     printf

使用objdump程序查看该程序的可执行文件内容,我们可以看到链接器所做的添加:

7a4:   97ffffab      bl     650 <printf@plt>

从图 12-11 中bl指令的编码中,我们看到w_offset是 26 位值0x3ffffab。一个字有 4 个字节,所以这等于 28 位字节偏移0xffffeac。将字节偏移加到指令的相对地址上,得到0x00007a4 + 0xffffeac = 0x0000650。(别忘了这些是有符号整数,所以这个加法的进位是无关紧要的。)这就是从这个bl指令到 PLT 中printf链接位置的偏移。

当程序运行时,操作系统还会加载程序的 GOT 和 PLT。在执行过程中,如果程序访问一个外部变量,操作系统会加载包含该变量定义的库模块,并将其相对地址输入到 GOT 中。当程序调用 PLT 中的某个函数时,如果该函数尚未加载,操作系统会加载它,将其地址插入程序的 GOT,并相应地调整 PLT 中的对应条目。

我想再次强调,正如之前关于汇编程序的讨论一样,这只是链接器工作原理的粗略概述。如果你想深入了解链接器,我推荐 John R. Levine 的Linkers & Loaders(摩根·考夫曼出版社,1999 年)。

你学到了什么

机器码 控制 CPU 的指令位模式。

汇编列表 程序中每条指令对应的机器码,可以由汇编程序生成(可选)。

寄存器 寄存器的编号在 5 个比特位中编码。

寄存器大小 一个比特编码指示使用的是完整的 64 位寄存器还是低 32 位寄存器。

立即数 指令中编码的常量。

地址偏移 从引用指令到内存地址的距离,可以在引用指令中编码。

别名 汇编程序可能为某些指令提供多个名称,以更好地表达使用该指令的意图。

汇编器 将汇编语言翻译为机器码并创建全局符号表的程序。

链接器 一个程序,用于解决程序中各个段落之间的交叉引用,并创建一个由操作系统使用的过程链接表。

到目前为止,我们的所有程序都使用了顺序程序流并调用了子函数。在下一章,我们将回到编程,你将学习其他两个必要的程序流程结构:重复和双向分支。

第十三章:控制流构造**

图片

在编写 C 语言或汇编语言程序时,我们指定每个语句或指令执行的顺序。这个顺序称为控制流。通过指定控制流来编程被称为命令式编程。这与声明式编程相对,声明式编程中我们只是陈述计算的逻辑,另一个程序则会确定控制流来执行它。

如果你一直使用make来构建你的程序,正如在第二章中推荐的那样,你的 makefile 中的语句就是声明式编程的一个例子。你指定结果的逻辑,而make程序则自动确定控制流来生成结果。

有三种基本的控制流构造:顺序、迭代和选择。你已经在我们之前写的程序中看到了顺序:每条指令或子函数都是按顺序执行的。在本章中,我将向你展示如何改变控制流,跳过某些指令以迭代相同的指令块,或者在多个指令块之间进行选择。你将看到这些控制流构造如何在汇编语言级别实现。在第十四章中,我将讲解通过调用子函数来改变控制流的细节。

跳转

跳转指令将控制流转移到指令指定的内存地址。有两种类型的跳转:无条件跳转和条件跳转。迭代和选择都使用条件跳转来根据真/假的条件改变控制流。在实现迭代和选择流构造时,你也会经常使用无条件跳转。我们先从无条件跳转讲起,然后再看条件跳转。

无条件

如你在第九章中所学,当一条指令执行时,CPU 会自动将程序计数器pc加 4,以保存内存中下一条指令的地址。无条件跳转指令通过将程序计数器改为跳转目标地址,替代将程序计数器加 4,从而使 CPU 继续在目标地址执行程序。

你已经了解了一个无条件跳转指令——ret,它用于从函数返回——在第十章中讲过。还有两个其他的跳转指令,bbr

b—无条件跳转

b label 将跳转到距离当前指令±128MB 范围内的标签地址。

br—无条件跳转寄存器

br x将跳转到x中的 64 位地址。

b 指令通常与条件分支指令一起使用,用于跳过代码块或返回代码块的开头并再次执行它。对于 b 指令,CPU 会将 b 指令与标签之间的 26 位字偏移符号扩展为 64 位,并将这个符号数加到 pc 上。br 指令只是将 x 寄存器中的 64 位值复制到 pc 上。

尽管 br x30 看起来与 ret 具有相同的效果,但 brret 指令并不是彼此的别名;它们是不同的指令。区别在于,br 告诉 CPU 这可能不是一个函数返回,而 ret 告诉 CPU 这可能一个函数返回。具体细节超出了本书的范围,但这些提示可以帮助 CPU 优化执行指令的某些细节。

条件

有两种类型的条件分支指令。一种是测试 nzcv 寄存器中的条件标志设置(见图 9-4 和第九章)。我们需要使用另一条指令在测试之前设置标志,以确定是否分支。

另一种类型的条件分支指令是测试寄存器中的值,以确定是否分支。此组指令不依赖于条件标志,也不会改变它们。

我将从根据条件标志设置进行分支的指令开始:

b.cond—条件分支

b.cond 标签测试 nzcv 寄存器中的设置,如果它们与条件匹配,则跳转到标签,跳转范围为从此指令起的 ±128MB。cond 的可能值见表 13-1。

表 13-1: 允许的分支条件码

代码 cond 含义 条件标志
0000 eq 相等 Z
0001 ne 不相等 ¬Z
0010 cshs 进位集;无符号大于或相等 C
0011 cclo 进位未设置;无符号小于 ¬C
0100 mi 负数;负值 N
0101 pl 正数;正值或零 ¬N
0110 vs 溢出 V
0111 vc 无溢出 ¬V
1000 hi 无符号大于 C ∧ ¬Z
1001 ls 无符号小于或相等 ¬(C ∧ ¬Z)
1010 ge 符号大于或等于 N = V
1011 lt 符号小于 ¬(N = V )
1100 gt 符号大于 ¬Z ∧ (N = V )
1101 le 符号小于或等于 ¬(¬Z ∧ (N = V ))
1110 al 总是执行 任何
1111 nv 总是执行 任何

你应该在程序中使用b.cond 指令,当你希望根据另一个操作的结果在两个分支之间做出选择时。重要的是,b.cond 指令必须紧接在其结果决定分支的指令后面。任何中间的指令或函数调用都可能更改条件标志的设置,从而给出错误的分支依据。

除此之外,还有四条指令,它们根据通用寄存器中的值进行分支:

cbz—如果零则比较并分支

cbz ws, 如果ws 中的值为0,则跳转到标签,跳转范围为±1MB。

cbz xs, 如果xs 中的值为0,则跳转到标签,跳转范围为±1MB。

cbnz—如果不为零则比较并分支

cbnz ws, 如果ws 中的值不为0,则跳转到标签,跳转范围为±1MB。

cbnz xs, 如果xs 中的值不为0,则跳转到标签,跳转范围为±1MB。

tbz—测试位并在零时分支

tbz ws, imm, 如果ws 中的位号 imm 为0,则跳转到标签,跳转范围为±1MB。

tbz xs, imm, 如果xs 中的位号 imm 为0,则跳转到标签,跳转范围为±1MB。

tbnz—测试位并在非零时分支

tbnz ws, imm, 如果ws 中的位号 imm 不为0,则跳转到标签,跳转范围为±1MB。

tbnz xs, imm, 如果xs 中的位号 imm 不为0,则跳转到标签,跳转范围为±1MB。

注意

使用条件分支指令如 b.gt b.le 时,容易忘记测试的顺序:源操作数与目标操作数比较,还是目标与源比较。当调试我的程序时,我几乎总是使用* gdb 并在条件分支指令处设置断点。当程序中断时,我检查值并使用 si 命令查看分支的方向。

现在你已经知道如何控制指令执行的流程,我将向你展示一些编程结构。我们将从重复开始。这可以通过两种方式实现:迭代,即程序重复执行一个代码块,直到满足某个条件;以及递归,即函数反复调用自身,直到满足条件为止。

我将在下一节中讲解迭代,并在讨论第十五章时说明递归的特殊用法。

迭代

许多算法使用迭代,也称为循环。一个循环会持续执行代码块的迭代,直到循环控制变量的值满足终止条件,导致循环结束。使用循环结构时,循环控制变量的值必须在迭代的代码块中发生变化。

逐个字符处理文本字符串是循环的一个很好的例子。我将使用 图 2-1 中的两个系统调用函数,writeread,来说明这些概念。该图显示,printf 将数据从内存存储格式转换为字符格式,并调用 write 系统调用函数将字符显示在屏幕上。当从键盘读取字符时,scanf 调用 read 系统调用函数,并将字符转换为内存存储格式。

write 和 read 系统调用函数

writeread 系统调用函数将屏幕和键盘视为文件。当程序首次启动时,操作系统打开三个文件——标准输入标准输出标准错误——并为每个文件分配一个整数,这个整数被称为 文件描述符。程序通过使用文件描述符与每个文件进行交互。调用 writeread 的 C 接口在 可移植操作系统接口 (POSIX) 标准中定义,你可以在 pubs.opengroup.org/onlinepubs/9699919799/ 上找到该标准。

调用这两个函数的一般格式是

int write(int fd, char *buf, int n);

int read(int fd, char *buf, int n);

其中,fd 是文件描述符,buf 是字符存储的地址,n 是要写入或读取的字符数。你可以在 writeread 的手册页中阅读更多细节:

> man 2 write
> man 2 read

表 13-2 显示了我将使用的文件描述符及其通常关联的设备。

表 13-2: writeread 系统调用函数的文件描述符

名称 编号 用途
STDIN_FILENO 0 从键盘读取字符
STDOUT_FILENO 1 将字符写入屏幕
STDERR_FILENO 2 将错误信息写入屏幕

这些名称在系统头文件 unistd.h 中定义,该文件位于我的 Raspberry Pi O 上的 /usr/include/unistd.h (注意,你系统中的位置可能不同)。现在,让我们继续讨论循环结构。

while 循环

while 循环是循环的基本形式。以下是 C 语言中的形式:

initialize loop control variable
while (expression) {
    body
    change loop control variable
}
next statement

在进入 while 循环之前,你需要初始化循环控制变量。在 while 循环的开始,表达式会作为布尔值进行求值。如果表达式求值为假(在 C 中为 0),控制流将继续到下一个语句。如果表达式求值为真——即 C 中的任何非零值——则执行循环体内的语句,改变循环控制变量,控制流会重新回到顶部,重新评估表达式。

图 13-1 显示了 while 循环的控制流。虽然循环的终止条件可以依赖于多个变量,但我这里只使用一个变量来澄清讨论。

image

图 13-1:while 循环的控制流

示例 13-1 展示了如何使用while循环将文本字符串一个字符一个字符地写入终端窗口。

hello_world.c

   // Write Hello, World! one character at a time.

   #include <unistd.h>
➊ #define NUL '\x00'

   int main(void)
{
  ➋ char *message_ptr = "Hello, World!\n";

  ➌ while (*message_ptr != NUL) {
      ➍ write(STDOUT_FILENO, message_ptr, 1);
      ➎ message_ptr++;
    }

    return 0;
}

示例 13-1:一个将“Hello, World!”一个字符一个字符写入屏幕的程序

我使用#define指令为NUL字符 ❶指定了一个符号名称。message_ptr变量被定义为指向char类型的指针,并作为循环控制变量。它被初始化为指向一个文本字符串 ❷。正如我们在查看这段代码的汇编语言时看到的,编译器将把文本字符串存储在内存的只读部分,并将该文本字符串中第一个字符的地址存储在message_ptr指针变量中。

while语句首先检查循环控制变量message_ptr是否指向NUL字符 ❸;如果不是,程序流程进入while循环体,并将message_ptr指向的字符写入屏幕 ❹。然后,循环控制变量递增,指向文本字符串中的下一个字符 ❺。程序流程返回到循环的顶部,检查下一个字符是否是NUL字符。当message_ptr指向NUL字符 ❸时,循环终止。首先测试这个条件意味着如果字符串为空,程序甚至不会进入while循环体,因为message_ptr只会指向NUL字符。这是一个微妙但重要的while循环要点:如果终止条件已经满足,循环体中的代码是永远不会被执行的。

对于示例 13-1,编译器生成了在示例 13-2 中显示的汇编语言。

hello_world.s

        .arch armv8-a
        .file   "hello_world.c"
        .text
     ➊ .section        .rodata
        .align  3
.LC0:
        .string "Hello, World!\n"
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     x29, x30, [sp, -32]!
        mov     x29, sp
     ➋ adrp    x0, .LC0
        add     x0, x0, :lo12:.LC0 str     x0, [sp, 24]      /// message_ptr variable
     ➌ b       .L2               /// Go to check
.L3:
        mov     x2, 1             /// One character
        ldr     x1, [sp, 24]      /// Address in message_ptr
        mov     w0, 1             /// STDOUT_FILENO
        bl      write
        ldr     x0, [sp, 24]
        add     x0, x0, 1
        str     x0, [sp, 24]      /// message_ptr++;
.L2:
        ldr     x0, [sp, 24]
        ldrb    w0, [x0]          /// Current char
     ➍ cmp     w0, 0             /// NUL?
     ➎ bne     .L3               /// No, back to top
        mov     w0, 0
        ldp     x29, x30, [sp], 32
        ret
        .size   main, .-main
        .ident  "GCC: (Debian 10.2.1-6) 10.2.1 20210110"
        .section        .note.GNU-stack,"",@progbits

示例 13-2:在示例 13-1 中的函数对应的编译器生成的汇编语言

汇编语言显示,文本字符串存储在.rodata部分 ❶。然后,message_ptr被初始化为包含文本字符串起始地址的指针 ❷。

尽管这段汇编语言似乎在循环结束时检测终止条件 ❹,但它遵循了图 13-1 所示的逻辑流程。首先,它跳转到.L2 ❸,在那里检查终止条件,然后跳转到.L3开始执行while循环体 ❺。你可能注意到,编译器使用了bne指令。它和b.ne是一样的;在编写条件分支指令时,.字符是可选的。

在这段代码中,有一个新的指令,cmp ❹:

cmp—比较

cmp reg, imm 从寄存器 reg 的值中减去 imm,并根据结果设置条件标志。减法的结果会被丢弃。

cmp reg1, reg2 从寄存器 reg1 的值中减去寄存器 reg2 的值,并根据结果设置条件标志。减法的结果会被丢弃。

cmp 指令紧接着条件分支指令常常用于程序中的决策。

为了与编译器所做的比较,我们将在我的汇编语言版本中遵循 while 循环的模式,如 清单 13-3 所示。

hello_world.s

// Write Hello, World! one character at a time.
        .arch armv8-a
// Useful names
        .equ    NUL, 0
        .equ    STDOUT, 1
// Stack frame
        .equ    save19, 16
        .equ    FRAME, 32
// Constant data
        .section  .rodata
message:
        .string "Hello, World!\n"
// Code
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     fp, lr, [sp, -FRAME]! // Create stack frame
        mov     fp, sp                // Set our frame pointer
        str     x19, [sp, save19]     // Save for caller
        adr     x19, message          // Address of message
loop:
        ldrb    w0, [x19]             // Load character
     ➊ cmp     w0, NUL               // End of string?
        b.eq    done                  // Yes
        mov     w2, 1                 // No, one char
        mov     x1, x19               // Address of char
        mov     x0, STDOUT            // Write on screen
        bl      write
        add     x19, x19, 1           // Increment pointer
        b       loop                  //   and continue
done:
        mov     w0, wzr               // Return 0
        ldr     x19, [sp, save 19]    // Restore reg
        ldp     fp, lr, [sp], FRAME   // Delete stack frame
        ret

清单 13-3:一个汇编语言程序,通过一次输出一个字符写出 Hello, World!

我们本可以在条件检查中使用 cbz 指令,而不是 cmpb.eq 序列 ❶,但我认为这里使用 NUL 更加清晰。这个解决方案同样适用于任何终止字符。

我的汇编语言解决方案比编译器生成的代码(清单 13-2)效率低,因为每次循环迭代都会执行 b 指令,此外还有条件 b.eq 指令。虽然执行时间稍微增加,但通常值得为了代码可读性做出这种权衡。

当使用一个 哨兵值(这是一个标记数据序列结束的唯一值)作为终止条件时,while 循环效果很好。例如,清单 13-1 和 13-3 中的 while 循环适用于任何长度的文本字符串,并且会继续将一个字符一个字符地写入屏幕,直到遇到哨兵值,即 NUL 字符。C 语言还有另一种循环结构,for 循环,许多程序员认为它在某些算法中更自然;我们接下来会看看它。

for 循环

尽管它们的 C 语法有所不同,但 whilefor 循环结构在语义上是等价的。语法上的区别是,for 循环允许你将所有三个控制元素——循环控制变量初始化、检查和改变——都放在括号内。C 中 for 循环的一般形式如下:

for (initialize loop control variable; expression; change loop control variable) {
    body
}
next statement

将所有控制元素放在括号内并非必须。实际上,我们也可以将 for 循环写成如下形式:

initialize loop control variable
for (;expression;) {
    body
    change loop control variable
}
next statement

请注意,for 循环语法确实要求在括号中包括两个分号。

在 清单 13-4 中,我用 for 循环重写了 清单 13-1 中的程序。

hello_world_for.c

// Write Hello, World! one character at a time.

#include <unistd.h>
#define NUL '\x00'

int main(void)
{
    char *message_ptr;

    for (message_ptr = "Hello, World!\n"; *message_ptr != NUL; message_ptr++) {
        write(STDOUT_FILENO, message_ptr, 1);
    } return 0;
}

清单 13-4:使用 for 循环编写 Hello, World!的程序

注意

由于此程序中的 for 语句只控制一个 C 语句,实际上你不需要在该语句周围加上大括号。我通常还是会加上它们,因为如果我稍后修改程序并添加另一个语句时,我经常会忘记需要加大括号。

你可能会想知道哪种循环结构更好。这里正是你汇编语言知识派上用场的时候。当我使用gcc生成清单 13-4 的汇编语言时,得到的汇编语言代码与清单 13-1 中的while循环版本是一样的。由于for循环的汇编语言已在清单 13-2 中展示,所以这里不再重复。

for循环与while循环的对比中,我们可以得出结论,你应该使用对于你解决的问题最自然的高级语言循环结构。通常这是一个主观的选择。

for循环通常用于计数控制循环,其中循环迭代次数在开始循环之前就已确定。稍后我们将看到此用法的示例,当我们讨论选择结构时。首先,让我们看看 C 语言中的第三种循环结构。它提供了不同的行为:与while循环和for循环结构在终止条件被循环控制变量的初始值满足时跳过循环体的做法不同,do-while循环结构将始终至少执行一次循环体。

do-while 循环

在某些情况下,您的算法需要至少执行一次循环体。在这些情况下,do-while 循环可能会更自然。它具有以下一般形式:

do {
    body
    change loop control variable
} while (expression)
next statement

do-while循环结构中,表达式的值是在循环体结束时计算的。循环会持续执行,直到该评估结果为布尔值 false。

在清单 13-5 中,我使用do-while循环重写了 Hello, World! 程序。

hello_world_do.c

// Write Hello, World! one character at a time.

#include <unistd.h> #define NUL '\x00'

int main(void)
{
    char *message_ptr = "Hello, World!\n";

    do {
        write(STDOUT_FILENO, message_ptr, 1);
        message_ptr++;
    } while (*message_ptr != NUL);

    return 0;
}

清单 13-5:使用 do-while 循环编写 Hello, World! 程序

注意

这个程序有潜在的 bug! do-while 循环结构将始终至少执行一次循环体。考虑一个空的文本字符串,它是一个包含 NUL 字符的单字节。一个 do-while 循环会将 NUL 字符写到屏幕上(实际上没有做任何事情),然后检查内存中的下一个字节,这个字节可能是任何内容。如果这个字节不是 NUL 字符, do-while 循环会继续执行,写入该字节及下一个字节所表示的字符,直到遇到 NUL 字符。程序的行为可能每次运行时都不同,因此错误可能不会在你的测试中显现出来。

我们可以使用gcc生成的汇编语言(见清单 13-6),来说明do-while结构与whilefor结构之间的差异。

hello_world_do.s

           .arch armv8-a
           .file   "hello_world_do.c"
           .text
           .section        .rodata
           .align  3
   .LC0:
           .string "Hello, World!\n"
           .text
           .align  2
           .global main
           .type   main, %function
   main:
           stp     x29, x30, [sp, -32]!
           mov     x29, sp
           adrp    x0, .LC0
           add     x0, x0, :lo12:.LC0
           str     x0, [sp, 24]      /// message_ptr variable
➊ .L2:
           mov     x2, 1             /// One character ldr     x1, [sp, 24]      /// Address in message_ptr
           mov     w0, 1             /// STDOUT_FILENO
           bl      write
           ldr     x0, [sp, 24]
           add     x0, x0, 1
           str     x0, [sp, 24]      /// message_ptr++;
           ldr     x0, [sp, 24]
           ldrb    w0, [x0]          /// Current char
        ➋ cmp     w0, 0             /// NUL?
           bne     .L2               /// No, back to top
           mov     w0, 0
           ldp     x29, x30, [sp], 32
           ret
           .size   main, .-main
           .ident  "GCC: (Debian 10.2.1-6) 10.2.1 20210110"
           .section        .note.GNU-stack,"",@progbits

清单 13-6:编译器生成的清单 13-5 函数的汇编语言

如果你将 列表 13-6 中显示的汇编语言与 列表 13-2 中显示的汇编语言进行比较,后者显示了为 whilefor 循环生成的汇编语言,你会发现唯一的区别是 do-while 循环在第一次执行循环之前不会跳转到循环控制检查 ❷。do-while 结构看起来可能更高效,但在汇编语言中,唯一的优化是第一次执行循环时少了一个跳转。

接下来,我们将研究如何选择是否执行一段代码。

现在轮到你了

13.1     输入 列表 13-1、13-4 和 13-5 中的三个 C 程序,并使用编译器为每个程序生成汇编语言。比较三种循环结构的汇编语言。编译器随着版本变化而变化,因此你应该查看你所使用的编译器的输出。

13.2     编写一个汇编语言程序,要求:

(a)     提示用户输入一些文本

(b)     使用 read 系统调用函数读取输入的文本

(c)     在终端窗口回显用户输入的文本

你需要在栈上分配空间,用于存储用户输入的字符。

条件语句

另一种常见的流程控制结构是选择结构,在这里我们决定是否执行某段代码。我将从最简单的情况开始,基于布尔条件语句判断是否执行单个代码块,然后展示如何使用布尔条件语句选择执行两个代码块中的一个。最后,我将讨论如何根据整数值选择多个代码块。

如果

C 语言中 if 条件语句的一般形式如下:

if (expression) {
    block
}
next statement

该表达式被计算为布尔值。如果它计算为假,或者在 C 语言中为 0,控制流将继续执行下一条语句。如果表达式计算为真(在 C 语言中为非零值),则执行代码块中的语句,控制流将继续到下一条语句。

列表 13-7 提供了一个 if 语句的示例,模拟抛硬币 10 次并显示每次正面朝上的情况。

coin_flips1.c

// Flip a coin, show heads.

#include <stdio.h>
#include <stdlib.h>
#define N_TIMES 10

int main()
{
    register int random_number;
    register int i;

 ➊ for (i = 0; i < N_TIMES; i++) {
     ➋ random_number = random();
     ➌ if (random_number < RAND_MAX/2) {
         ➍ puts("heads");
        }
    }

    return 0;
}

列表 13-7:一个模拟抛硬币并显示正面朝上的程序

该程序使用计数控制的 for 循环模拟抛硬币 10 次 ❶。模拟过程调用了 C 标准库中的 random 函数 ❷。如果随机数在 random 函数所有可能值的下半部分 ❸,我们称之为“正面”。为了显示这个结果,我们使用 C 标准库中的 puts 函数,它将一个简单的文本字符串打印到屏幕上,并附加一个换行符 ❹。对于 列表 13-7,编译器生成了 列表 13-8 中显示的汇编语言。

coin_flips1.s

           .arch armv8-a
           .file   "coin_flips1.c"
           .text
           .section        .rodata
           .align  3
   .LC0:
           .string "heads"
           .text
           .align  2
           .global main
           .type   main, %function
   main:
           stp     x29, x30, [sp, -32]!
           mov     x29, sp
           stp     x19, x20, [sp, 16]  /// Use for i and random_number
           mov     w19, 0
           b       .L2
   .L4:
           bl      random
           mov     w20, w0             /// Random number
           mov     w0, 1073741822      /// RAND_MAX/2
           cmp     w20, w0
        ➊ bgt     .L3                 /// Skip message
           adrp    x0, .LC0
           add     x0, x0, :lo12:.LC0
           bl      puts
➋ .L3:
           add     w19, w19, 1         /// i++;
   .L2:
           cmp     w19, 9
           ble     .L4                 /// Continue if <= 9
           mov     w0, 0
           ldp     x19, x20, [sp, 16]  /// Restore regs for caller
           ldp     x29, x30, [sp], 32
           ret
           .size   main, .-main
           .ident  "GCC: (Debian 10.2.1-6) 10.2.1 20210110"
           .section        .note.GNU-stack,"",@progbits

清单 13-8:编译器为 清单 13-7 中的函数生成的汇编语言

if 语句通过一个简单的条件分支来实现。如果条件成立—在这种情况下,bgt,即大于时跳转 ❶—程序流将跳过由 if 语句控制的代码块 ❷。接下来,我将向你展示如何在两个不同的代码块之间进行选择。

if-then-else

C 中的 if-then-else 条件语句 的一般形式如下(C 不使用 then 关键字):

if (expression) {
    then block
} else {
    else block
}
next statement

表达式作为布尔值进行评估。如果表达式的结果为真,则执行 then 块中的语句,并且控制流跳转到下一个语句。如果结果为假(在 C 中为 0),则控制流跳转到 else 块,并继续执行下一个语句。

图 13-2 显示了 if-then-else 条件语句的控制流。

image

图 13-2:一个 if-then-else 条件语句的控制流

清单 13-7 中的抛硬币程序不够用户友好,因为用户不知道硬币被抛了多少次。我们可以通过使用 if-then-else 条件语句来改进程序,打印出硬币为反面时的消息,如 清单 13-9 所示。

coin_flips2.c

// Flip a coin, showing heads or tails.

#include <stdio.h>
#include <stdlib.h>
#define N_TIMES 10

int main()
{ register int random_number;
    register int i;

    for (i = 0; i < N_TIMES; i++) {
        random_number = random();
        if (random_number < RAND_MAX/2) {
            puts("heads");
        } else {
            puts("tails");
        }
    }

    return 0;
}

清单 13-9:一个抛硬币并声明其为正面或反面的程序

清单 13-10 显示了编译器为 清单 13-9 生成的汇编语言。

coin_flips2.s

           .arch armv8-a
           .file   "coin_flips2.c"
           .text
           .section        .rodata
           .align  3
   .LC0:
           .string "heads"
           .align  3
   .LC1:
           .string "tails"
           .text
           .align  2
           .global main
           .type   main, %function
   main:
           stp     x29, x30, [sp, -32]!
           mov     x29, sp
           stp     x19, x20, [sp, 16]
           mov     w19, 0
           b       .L2
   .L5:
           bl      random
           mov     w20, w0
           mov     w0, 1073741822
           cmp     w20, w0
           bgt     .L3                 /// Go to else block
           adrp    x0, .LC0            /// Then block
           add     x0, x0, :lo12:.LC0
           bl      puts ➊ b       .L4                 /// Branch over else block
   .L3:
           adrp    x0, .LC1            /// Else block
           add     x0, x0, :lo12:.LC1
           bl      puts
➋ .L4:
           add     w19, w19, 1         /// Next statement
   .L2:
           cmp     w19, 9
           ble     .L5
           mov     w0, 0
           ldp     x19, x20, [sp, 16]
           ldp     x29, x30, [sp], 32
           ret
           .size   main, .-main
           .ident  "GCC: (Debian 10.2.1-6) 10.2.1 20210110"
           .section        .note.GNU-stack,"",@progbits

清单 13-10:编译器为 清单 13-9 中的函数生成的汇编语言

汇编语言显示,我们需要在 then 块的末尾 ❶ 进行一次无条件跳转,以跳过 else 块 ❷。

我的抛硬币程序的汇编语言设计略有不同,如 清单 13-11 所示。

coin_flips2.s

// Flip a coin, showing heads or tails.
        .arch armv8-a
// Useful names
        .equ    N_TIMES, 10           // Number of flips
        .equ    RAND_MID, 1073741822  // RAND_MAX/2
// Stack frame
        .equ    save19, 28
        .equ    FRAME, 32
// Constant data
        .section  .rodata
heads_msg:
        .string "heads"
tails_msg:
        .string "tails"
// Code
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     fp, lr, [sp, -FRAME]! // Create stack frame
        mov     fp, sp                // Set our frame pointer
        str     w19, [sp, save19]     // Save for i local var
        mov     w19, wzr              // i = 0 loop:
        mov     w0, N_TIMES           // Total number of times
        cmp     w19, w0               // Is i at end?
        b.hs    done                  // Yes
        bl      random                // No, get random number
        mov     w1, RAND_MID          // Halfway
     ➊ cmp     w1, w0                // Above or below middle?
        b.hi    tails                 // Above -> tails
        adr     x0, heads_msg         // Below -> heads message
        bl      puts                  // Print message
        b       continue              // Skip else part
tails:
        adr     x0, tails_msg         // Tails message address
        bl      puts                  // Print message
continue:
        add     w19, w19, 1           // Increment i
        b       loop                  //   and continue loop
done:
        mov     w0, wzr               // Return 0
        ldr     w19, [sp, save19]     // Restore reg
        ldp     fp, lr, [sp], FRAME   // Delete stack frame
        ret                           // Back to caller

清单 13-11:抛硬币程序的汇编语言设计

random 函数返回一个随机数,并存储在 w0 寄存器中。我将其保留在那里,与我已经加载到 w1 寄存器中的中间值进行比较 ❶。w0w1 寄存器不需要在函数中保存。编译器使用 w20 作为 random_number 变量,它需要被保存。

在决定为函数中的变量选择哪些寄存器时,检查 第十一章 中的 表 11-3 规则非常重要。该表指出,函数必须保持 x19 中的值,以便调用函数使用。你可能会看到在这里遵守规则的重要性。我们的函数不仅必须在返回时保持 x19 的值,而且我们可以假设我们调用的函数也会保持我们在 x19 中的值。因此,可以放心地假设,在函数调用期间该值保持不变。

我这里不深入讲解细节,但如果你需要从多个代码块中选择一个执行,你可以在梯形结构中使用else-if语句。其一般形式如下:

if (expression_1) {
    block_1
} else if (expression_2)  {
    block_2
}
⋮ } else if (expression_n-1) {
    block_n-1
} else {
    block_n
}
next statement

if-then-else选择是基于控制表达式的布尔值评估,但正如你将在下一节看到的那样,有些算法中的选择是基于离散值的,这些值用于选择多个情况中的一个。

switch

C 语言提供了一个switch 条件语句,根据选择器的值,控制流会跳转到代码块列表中的某个位置。switch的一般形式如下:

switch (selector) {
    case selector_1:
        block_1
    case selector_2:
        block_2
    ⋮
    case selector_n:
        block_n
    default:
        default block
}
next statement

选择器可以是任何计算结果为整数的表达式。每个 selector_1、selector_2、...、selector_n 必须是整数常量。switch将跳转到case,其 selector_1、selector_2、...、selector_n 等于选择器的评估结果。如果选择器的值没有匹配任何 selector_1、selector_2、...、selector_n 整数,switch将跳转到default。在执行完对应的 block_1、block_2、...、block_n 之后,程序流会继续执行剩余的代码块。switch中的任何地方遇到break语句都会立即退出switch并跳转到下一个语句。

列表 13-12 展示了如何在 C 语言中使用switch语句。

switch.c

// Select one of three or default.

#include <stdio.h>
#define N_TIMES 10

int main(void)
{ register int selector;
    register int i;

    for (i = 1; i <= N_TIMES; i++) {
        selector = i;
        switch (selector) {
            case 1:
                puts("i = 1");
             ➊ break;
            case 2:
                puts("i = 2");
                break;
            case 3:
                puts("i = 3");
                break;
            default:
                puts("i > 3");
        }
    }

    return 0;
}

列表 13-12:A switch 语句

我希望这个程序只执行与i值对应的情况。为了防止它执行switch中的后续情况,我在每个代码块的末尾加上了break语句,这会导致程序从switch中退出 ❶。

列表 13-13 展示了编译器如何实现这个switch

switch.s

           .arch armv8-a
           .file   "switch.c"
           .text
           .section        .rodata
           .align  3
   .LC0:
           .string "i = 1"
           .align  3
   .LC1:
           .string "i = 2"
           .align  3
   .LC2:
           .string "i = 3"
           .align  3
   .LC3:
           .string "i > 3"
           .text
           .align  2
           .global main .type   main, %function
   main:
           stp     x29, x30, [sp, -32]!
           mov     x29, sp
           mov     w0, 1
           str     w0, [sp, 28]
           b       .L2
➊ .L8:                              /// Branch logic to decide
           ldr     w0, [sp, 28]      ///   which block to execute
           cmp     w0, 3
           beq     .L3
           ldr     w0, [sp, 28]
           cmp     w0, 3
           bgt     .L4
           ldr     w0, [sp, 28]
           cmp     w0, 1
           beq     .L5
           ldr     w0, [sp, 28]
           cmp     w0, 2
           beq     .L6
           b       .L4
➋ .L5:                              /// Blocks to select from
           adrp    x0, .LC0
           add     x0, x0, :lo12:.LC0
           bl      puts
           b       .L7
   .L6:
           adrp    x0, .LC1
           add     x0, x0, :lo12:.LC1
           bl      puts
           b       .L7
   .L3:
           adrp    x0, .LC2
           add     x0, x0, :lo12:.LC2
           bl      puts
           b       .L7
   .L4:
           adrp    x0, .LC3
           add     x0, x0, :lo12:.LC3
           bl      puts
   .L7:
           ldr     w0, [sp, 28]
           add     w0, w0, 1
           str     w0, [sp, 28]
   .L2:
           ldr     w0, [sp, 28]
           cmp     w0, 10 ble     .L8
           mov     w0, 0
           ldp     x29, x30, [sp], 32
           ret
           .size   main, .-main
           .ident  "GCC: (Debian 10.2.1-6) 10.2.1 20210110"
           .section        .note.GNU-stack,"",@progbits

列表 13-13:编译器生成的汇编语言,针对列表 13-12 中的函数

在列表 13-13 中,编译器为switch创建了两个部分。第一部分是决定执行哪个代码块的逻辑 ❶。根据选择器的值,这将把程序流转移到第二部分中的正确代码块 ❷。

现在,让我们看一下实现switch的另一种方式:分支表,也叫做跳转表。分支表是我们需要选择的代码块地址的表格。我们需要设计一个算法,根据选择器的值来选择表中的正确地址,然后跳转到该地址。

列表 13-14 展示了为当前示例实现这一点的一种方式。

switch.s

// Select one of three or default.
        .arch armv8-a
// Useful names
        .equ    N_TIMES, 10           // Number of loops
        .equ    DEFAULT, 4            // Default case
// Stack frame
        .equ    save1920, 16
        .equ    FRAME, 32
// Constant data
        .section  .rodata
one_msg:
        .string "i = 1"
two_msg:
        .string "i = 2"
three_msg:
        .string "i = 3"
over_msg:
        .string "i > 3"
// Branch table
     ➊ .align  3
br_table:
     ➋ .quad   one                   // Addresses where messages
        .quad   two                   //   are printed
        .quad   three
        .quad   default
// Program code
        .text
        .align  2 .global main
        .type   main, %function
main:
        stp     fp, lr, [sp, -FRAME]! // Create stack frame
        mov     fp, sp                // Set our frame pointer
     ➌ stp     x19, x20, [sp, save1920]  // Save for caller
        mov     x19, 1                // i = 1
        mov     x20, DEFAULT          // Default case
loop:
        cmp     x19, N_TIMES          // Is i at end?
        b.hi    done                  // Yes, leave loop
     ➍ adr     x0, br_table          // Address of branch table
        cmp     x19, x20              // Default case?
     ➎ csel    x1, x19, x20, lo      // Low, use i
     ➏ sub     x1, x1, 1             // Relative to first table entry
     ❼ add     x0, x0, x1, lsl 3     // Add address offset in table
     ❽ ldr     x0, [x0]              // Load address from table
        br      x0                    //   and branch there
one:
        adr     x0, one_msg           // = 1
        bl      puts                  // Write to screen
        b       continue
two:
        adr     x0, two_msg           // = 2
        bl      puts                  // Write to screen
        b       continue
three:
        adr     x0, three_msg         // = 3
        bl      puts                  // Write to screen
        b       continue
default:
        adr     x0, over_msg          // > 3
        bl      puts                  // Write to screen
continue:
        add     x19, x19, 1           // Increment i
        b       loop                  //   and continue loop
done:
        mov     w0, wzr               // Return 0
        ldp     x19, x20, [sp, save1920]  // Restore reg
        ldp     fp, lr, [sp], FRAME   // Delete stack frame
        ret                           // Back to caller

列表 13-14:用于使用分支表的汇编语言设计

分支表中的每一项是对应选择器变量值的代码块地址。.quad 汇编指令告诉汇编器分配 8 字节的内存,并将其初始化为操作数的值 ❷。我们使用它来存储算法将要选择的每个代码块的地址。由于分支表中的项是 64 位地址,我们需要将表的开始对齐到 64 位地址边界 ❶。

我们的算法使用 x19x20 作为局部变量,过程调用标准规定我们需要保存它们的内容,以供调用的函数使用(请参见 表 11-3 和 第十一章) ❸。我们还可以假设我们从此函数调用的其他函数会保留它们的内容。

我们需要确定从分支表中加载哪个块的地址。我们从表开头的地址开始 ❹。然后,我们将 x19 中的当前 i 值与默认案例的编号进行比较。如果 i 的值小于默认案例编号,我们将使用 csel 指令将该值移入 x1。如果 i 的值与默认案例编号相同或更高,则 csel 指令将默认案例编号(存储在 x20 中)移入 x1 ❹。现在我们在 x1 中有了案例编号,我们需要减去 1,以获得表中从第一个项开始的偏移量 ❻。

接下来,我们需要将案例偏移量转换为地址偏移量,以便将其加到分支表开头的地址上。分支表中的每个项占用 8 字节。我们使用 add 指令的一个选项,将偏移量的值(在 x1 中)向左移动 3 位 ❼。这样就能在将偏移量加到分支表的起始地址(在 x0 中)之前,将偏移量乘以 8。

现在,x0 包含我们想要的项目在分支表中的地址。我们用项目本身的地址替换该项的地址,而该地址是要执行的代码块的地址 ❽。

csel 指令对于在寄存器中选择两个值时实现简单的 if-then-else 构造非常有用。它的形式如下:

csel—条件选择

csel reg1, reg2, reg3, cond 会测试 nzcv 寄存器中的设置,如果 cond 为真,则将 reg2 移动到 reg1;如果 cond 为假,则将 reg3 移动到 reg1。

现在你知道了实现 switch 构造的两种方法。很难说分支表是否比 if-else 结构更高效。对于大量的案例,if-else 结构可能需要进行多次测试,才能选择到正确的案例。效率还取决于缓存使用、内部 CPU 设计等因素,而且不同的 CPU 实现(即使使用相同的指令集)之间也可能存在差异。两种方法之间的差异可能并不显著,因此你应该选择与所解决问题更匹配的那种方法。

轮到你了

13.3     修改清单 13-11 中的汇编语言程序,使其将随机数的最低四分之一和最高四分之一(0RAND_MAX/43*RAND_MAX/4RAND_MAX)视为正面。它将视随机数的中间一半(RAND_MAX/43*RAND_MAX/4)为反面。

13.4     移除清单 13-12 中的break语句。这将如何改变程序的行为?生成更改后的程序的汇编语言,并与清单 13-13 中的程序进行比较。

13.5     修改清单 13-14 中的程序,使其使用if条件语句,而不是csel指令。

13.6     重写清单 13-14 中的程序,使其使用if-else条件语句的梯形结构,而不是使用switch

你学到了什么

无条件分支 更改程序计数器以改变控制流。

条件分支 评估nzcv寄存器中的状态标志的布尔组合,如果组合结果为真,则改变控制流。

while 循环 检查布尔条件,然后在条件为真时迭代执行代码块,直到条件变为假。

for 循环 检查布尔条件,然后在条件为真时迭代执行代码块,直到条件变为假。

do-while 循环 执行一次代码块,并在布尔条件为真时重复执行,直到条件变为假。

if 条件语句 检查布尔条件,如果条件为真,则执行一段代码块。

if-then-else 条件语句 检查布尔条件,然后根据条件为真或为假的情况执行两个代码块中的一个。

switch 条件语句 评估一个表达式,然后根据表达式的整数值跳转到代码块列表中的某个位置。

现在你已经了解了控制流结构和main函数,接下来我们将讨论如何编写自己的子函数。在下一章中,你将学习如何传递参数以及如何在子函数中访问这些参数。

第十四章:子函数内部

图片

良好的工程实践通常包括将问题拆分为功能上不同的子问题。在软件中,这种方法会导致程序包含多个函数,每个函数解决一个子问题。

这种分治方法的主要优点是,通常解决一个小的子问题比解决整个问题要容易。另一个优点是,之前对子问题的解决方案通常是可以重用的,就像我们通过使用 C 标准库中的函数所展示的那样。通过让几个人同时处理整体问题的不同部分,我们也可以节省开发时间。

当像这样拆解一个问题时,重要的是协调许多部分解决方案,使它们能够协同工作,提供正确的整体解决方案。在软件中,这意味着确保调用函数和被调用函数之间的数据接口正常工作。为了确保接口的正确操作,必须明确指定接口。在本章中,我将向你展示如何做到这一点。我将首先向你展示如何将数据项放置在全局位置,以便程序中的所有函数都可以直接访问它们。然后,我将介绍如何限制数据项作为函数参数传递,这样我们就可以更好地控制函数处理的数据。

在前几章中,你学会了如何通过寄存器将参数传递给函数。在本章中,你将学习如何将这些参数存储在内存中,以便寄存器可以在被调用函数内部重新使用。你还将学会如何传递比第十一章中表 11-3 所指定的八个寄存器更多的参数。

最后,我会更详细地讨论函数内部变量的创建。我将涵盖只在程序流程处于该函数时存在的变量,以及在整个程序运行期间保持在内存中的变量,但只能在其定义的函数内访问。

然而,在我们深入了解函数的内部工作原理之前,先来看看 C 中一些关于变量名称使用的规则。

C 中变量名称的作用域

作用域是指我们代码中变量名称可见的地方,意味着我们可以使用该名称。这不是一本关于 C 的书,所以我不会涵盖变量名称在程序中可以使用的所有规则,但我会解释足够的内容,帮助你理解基本概念。

在 C 中,声明一个变量将其名称和数据类型引入当前作用域。定义一个变量是一个声明,同时也为变量分配内存。变量只能在程序中定义一次,但正如你将在第 271 页的“全局变量”部分看到的,它可以在多个作用域中声明。

在函数定义内部定义的变量称为局部变量,而在函数参数列表中声明的名称称为形式参数。局部变量和形式参数都具有函数作用域:它们的作用范围从声明的位置开始,直到函数的结束。

在 C 中,是由一对匹配的大括号{}括起来的 C 语句组。块内部定义的变量的作用范围从定义的位置开始,直到该块的结束,包括任何被包含的块。这就是块作用域

函数原型仅是函数的声明,而不是定义。它包含函数的名称、传递给函数的参数的数据类型,以及返回数据类型。在原型中,参数不需要命名,但这样做能为原型本身提供一些文档说明。原型声明中参数名称的作用范围仅限于其自身的原型。这一限制允许我们在不同的函数原型中使用相同的名称。例如,C 标准库中包含计算正弦和余弦的函数,其原型如下:

double sin(double x);
double cos(double x);

我们可以在同一个函数中使用两个函数原型,而无需为参数使用不同的名称。

在查看最终一种作用域——文件作用域之前,我将简要介绍传递参数给函数的原因。

传递参数概述

输入和输出相对于我们的视角。阅读本节时,请小心区分来自调用函数的输入和输出,以及来自程序用户的输入和输出。在本章中,我们讨论的是来自其他函数的输入和输出。我们将在第二十章中探讨程序输入和输出与 I/O 设备相关的内容。

为了说明区别,请考虑以下 C 程序语句(来自清单 2-1 在第二章中),该语句用于从键盘(一个 I/O 设备)输入整数:

scanf("%x", &an_int);

scanf函数从main函数接收一个数据输入:格式化文本字符串"%x"的地址。scanf函数读取从键盘输入的用户数据,并将数据(一个无符号整数)输出到main函数中的an_int变量。

函数可以通过四种方式与程序其他部分的数据交互:

直接 程序中全局的数据可以从任何函数中直接访问。

输入 数据来自程序的其他部分,并被函数使用,但原始副本未被修改。

输出 函数将新数据提供给程序的其他部分。

更新 函数修改由程序其他部分持有的数据项。新值基于函数调用前的值。

如果被调用的函数也知道数据项的位置,那么可以执行这四种交互,但这会暴露数据的原始副本,并允许其被更改,即使它仅仅是作为输入传递给被调用函数。

我们可以通过将输出放置在全局已知位置(如寄存器或全局已知地址)来输出数据。我们还可以将存储输出的地址传递给被调用的函数。更新需要被调用的函数知道要更新的数据的地址。

为了看到它是如何工作的,我们将从查看全局变量是如何创建的,以及它们在子函数中如何被访问开始。

全局变量

全局变量 在任何函数外部定义,并具有 文件作用域,这意味着它们可以从定义的地方一直访问到文件的末尾。全局变量还可以通过 extern 修饰符在另一个文件中访问。使用 extern 仅将变量的名称和数据类型引入声明的作用域,而不会为其分配内存。

清单 14-1 展示了如何定义全局变量。

sum_ints_global.c

  // Add two integers using global variables.

  #include <stdio.h>
  #include "add_two_global.h"

➊ int x = 123, y = 456, z;    // Define global variables

  int main(void)
  {
      add_two();
      printf("%i + %i = %i\n", x, y, z);

      return 0;
  }

清单 14-1:一个 使用三个全局变量的 主函数

xyz 的定义放在函数体外使它们成为全局变量❶。前两个变量被初始化,但第三个没有。我将向你展示编译器如何处理这些差异。

这个 main 函数调用了 add_two 函数,它将 xy 相加,并将结果存储在 z 中。清单 14-2 显示了编译器为此 main 函数生成的汇编语言。

sum_ints_global.s

        .arch armv8-a
        .file   "sum_ints_global.c"
     ➊ .text
        .global x
     ➋ .data                     /// Data segment
        .align  2
        .type   x, %object
        .size   x, 4
x:
     ➌ .word   123               /// Initialize
        .global y
        .align  2
        .type   y, %object
        .size   y, 4
y:
        .word   456
        .global z
     ➍ .bss                      /// .bss section
        .align  2
        .type   z, %object
     ➎ .size   z, 4
z:
     ➏ .zero   4                 /// Could use .skip
        .section        .rodata
        .align  3
.LC0:
        .string "%i + %i = %i\n"
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     x29, x30, [sp, -16]!
        mov     x29, sp
        bl      add_two
     ➐ adrp    x0, x             /// Address  defined
        add     x0, x0, :lo12:x   ///   in this file
        ldr     w1, [x0]
        adrp    x0, y
        add     x0, x0, :lo12:y
        ldr     w2, [x0]
        adrp    x0, z
        add     x0, x0, :lo12:z
        ldr     w0, [x0]
        mov     w3, w0
        adrp    x0, .LC0
        add     x0, x0, :lo12:.LC0
        bl      printf
        mov     w0, 0
        ldp     x29, x30, [sp], 16
        ret
        .size   main, .-main
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

清单 14-2:在清单 14-1 中使用的函数的编译器生成的汇编语言

我不知道为什么编译器添加了第一个 .text 指令❶,但它并不需要。它的效果立即被 .data 汇编指令覆盖,这将我们切换到数据段❷。

.word 指令分配一个字(4 字节)的内存,并将其初始化为参数的值,这里是整数 123 ❸。然后,.bss 汇编指令将我们切换到 块开始符号 区段,当程序加载到内存中执行时,该区段将位于数据段中❹。在 .bss 区段中定义的每个标签都将表示未初始化内存块的起始位置。程序的可执行文件中仅存储每个标记块的大小,从而使文件变得更小。

Linux 操作系统在加载程序时会将内存中 .bss 区段的所有字节初始化为 0,但除非在源代码中显式将其设置为 0,否则你的算法不应依赖于其中的变量为 0。

.size汇编指令将一个标签与其块中的字节数相关联❺。z标签表示程序中的一个 4 字节变量。虽然z在我们的 C 代码中没有初始化,并且.bss段在程序加载时会被设置为 0,编译器仍然使用了.zero汇编指令,指定了 4 字节内存,每个字节在此处都设置为 0❻。.skip指令在.bss段中会产生与.zero相同的效果。由于我们处于.bss段,汇编器不会在目标文件中存储这 4 个零字节。

这些变量在此文件中定义,因此编译器使用adrp/add两条指令序列来加载它们的地址❼。

接下来,我们来看add_two。首先,我们需要为该函数准备一个头文件。这在清单 14-3 中有所展示。

add_two_global.h

   // Add two global integers.

➊ #ifndef ADD_TWO_GLOBAL_H
➋ #define ADD_TWO_GLOBAL_H
➌ void add_two(void);
   #endif

清单 14-3:使用全局变量的 add_two 函数的头文件

头文件用于声明函数原型,这在 C 源代码文件中只能声明一次❸。一个头文件可以包含其他头文件,其中一些可能会包含原始头文件,从而导致函数原型被多次声明。为了避免这种情况,我们定义了一个标识符,它是头文件名称的风格化版本❷。

我们从#ifndef汇编指令开始,检查此标识符是否已经定义❶。如果没有,文件的内容会一直包括到#endif指令的末尾,定义文件名标识符并声明函数原型。之后在任何进一步包含该头文件时,检查文件名标识符会显示该标识符已经定义,因此预处理器会跳到#endif并避免再次声明函数原型。

清单 14-4 显示了使用全局变量的add_two函数的定义。

add_two_global.c

   // Add two global integers.

➊ #include "add_two_global.h"

➋ extern int x, y, z;

   void add_two(void)
   {
       z = x + y;
   }

清单 14-4:使用全局变量的 add_two 函数

函数的头文件应该包含在定义该函数的文件中,以确保头文件中的函数原型与定义匹配❶。全局变量仅在一个地方定义,但在使用它们的任何其他文件中都需要声明❷。

清单 14-5 显示了编译器为add_two函数生成的汇编语言。

add_two_global.s

        .arch armv8-a
        .file   "add_two_global.c"
        .text
        .align  2
        .global add_two
        .type   add_two, %function
add_two:
     ➊ adrp    x0, :got:x              /// Global offset table page
     ➋ ldr     x0, [x0, :got_lo12:x]   /// Address of x
        ldr     w1, [x0]
        adrp    x0, :got:y
        ldr     x0, [x0, :got_lo12:y]
        ldr     w0, [x0]
        add     w1, w1, w0
        adrp    x0, :got:z
        ldr     x0, [x0, :got_lo12:z]
        str     w1, [x0]
     ➌ nop
        ret
        .size   add_two, .-add_two
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

清单 14-5:编译器为清单 14-4 中的函数生成的汇编语言

add_two函数声明了xyz变量,并使用extern存储类说明符以便它可以访问这些变量,但它需要使用另一种技术来加载这些变量的地址,因为这些变量在另一个文件中定义。加载器将全局变量的地址存储在全局偏移表(GOT)中,该表在程序加载到内存中执行时被引入,参见《链接器》章节中的第 239 页。:got:操作数修饰符告诉加载器,在填充adrp指令❶中的页面偏移时,使用包含变量地址的 GOT。

这里的ldr指令使用了x0中的 GOT 页面地址作为其基址❷。:got_lo12:操作数修饰符告诉加载器使用偏移量的低 12 位来获取变量地址存储在 GOT 中的位置,从而用x0中变量的地址覆盖 GOT 的页面地址。

列表 14-5 还包含了一条新指令nop(读作“no-op”)❸。它对算法没有任何影响。手册中提到它用于指令对齐的目的:

nop—无操作

nop对程序计数器加 4,且没有其他效果。

尽管在小型程序中全局变量使用起来比较简单,但在大型程序中管理它们非常笨重。你需要精确地追踪程序中每个函数如何操作全局变量。如果在函数内定义变量,并且仅将需要的内容传递给每个子函数,那么管理变量将变得容易得多。在接下来的章节中,我将向你展示如何控制传递给和从子函数返回的内容。

显式传递参数

当我们限制每个函数只使用它所需要的变量时,隔离函数的内部工作与其他函数之间的关系变得更加容易。这是一个名为信息隐藏的原则。它意味着你,作为程序员,只需处理子函数完成其特定任务所需要的那些变量和常量。当然,大多数子函数都需要以某种方式与其调用函数中的一些变量进行交互。在本节中,我们将讨论一个函数如何使用显式传递给它的参数来接收输入、产生输出或更新变量。

当一个值仅作为输入传递给被调用函数时,我们可以将该值的副本传递给被调用函数。这被称为按值传递。按值传递可以防止被调用函数改变调用函数中的值。

从被调用函数接收输出有点复杂。实现这一点的一种方法是使用返回值,在我们的环境中,返回值被放置在w0寄存器中。使用w0寄存器假定返回值是一个int。这种技术在本书的大多数示例程序中都有使用。main函数几乎总是返回 0 给调用它的操作系统中的函数。对于返回较大值还有其他规则,但我们在本书中不涉及。

调用函数接收被调用函数输出的其他技术要求调用函数传递给被调用函数一个用于存储输出的地址。这可以在高级语言中实现为通过指针传递通过引用传递。它们的区别在于,使用指针传递时,程序可以改变指针指向另一个对象,而通过引用传递时,程序无法改变指针。C 语言和 C++都支持通过指针传递,但只有 C++支持通过引用传递。这些在汇编语言级别是相同的;存储输出的地址会传递给被调用函数。区别由高级语言强制执行。

接下来,您将学习 C 语言如何控制对其局部变量的访问。

在 C 语言中

在本节中,我将编写与清单 14-1、14-3 和 14-4 中相同的程序,但这次我将把变量定义为main函数中的局部变量,并将它们作为参数传递给子函数。清单 14-6 展示了新的main函数版本。

sum_ints.c

// Add two integers using local variables.

#include <stdio.h>
#include "add_two.h"

int main(void)
{
 ➊ int x = 123, y = 456, z;

 ➋ add_two(&z, x, y);
    printf("%i + %i = %i\n", x, y, z);

    return 0;
}

清单 14-6:一个 使用三个局部变量的 main 函数 *

在函数体内定义变量❶使得这些变量仅对该函数可见。add_two函数将在我们作为第一个参数传递的地址处存储其结果。我们使用 C 语言的地址操作符&来获取z变量的地址,从而得到&z❷。xy变量的值是add_two函数的输入,因此我们传递这些变量的副本。

清单 14-7 展示了add_two函数的头文件。

add_two.h

// Add two integers and output the sum.

#ifndef ADD_TWO_H
#define ADD_TWO_H
void add_two(int *a, int b, int c);
#endif

清单 14-7:使用局部变量的 add_two 函数的头文件

清单 14-8 展示了add_two函数的定义。

add_two.c

//Add two integers and output the sum.

#include "add_two.h"

void add_two(int *a, int b, int c)
{
    int sum;

    sum = b + c;
 ➊ *a = sum;
}

清单 14-8:使用局部变量的 add_two 函数

参数列表中的第一个参数a是一个指向int的指针。这意味着a保存了我们需要存储sum值的地址。为了解除引用a,我们使用 C 语言的解引用操作符*,得到*a❶。这将在a传递的地址处存储计算结果。

在汇编语言中

清单 14-9 展示了编译器为清单 14-6 中的main函数生成的汇编语言。

sum_ints.s

         .arch armv8-a
         .file   "sum_ints.c"
         .text
         .section        .rodata
         .align  3
         .LC0:
         .string "%i + %i = %i\n"
         .text
         .align  2
         .global main
         .type   main, %function
 main:
      ❶ stp     x29, x30, [sp, -32]!
         mov     x29, sp
         mov     w0, 123
      ❷ str     w0, [sp, 28]        /// x = 123;
         mov     w0, 456
         str     w0, [sp, 24]        /// y = 456;
      ❸ add     x0, sp, 20          /// Address of z
      ❹ ldr     w2, [sp, 24]        /// Load y and x
         ldr     w1, [sp, 28]
         bl      add_two
         ldr     w0, [sp, 20]
         mov     w3, w0
         ldr     w2, [sp, 24]
         ldr     w1, [sp, 28]
         adrp    x0, .LC0
         add     x0, x0, :lo12:.LC0
         bl      printf
         mov     w0, 0
         ldp     x29, x30, [sp], 32
         ret
         .size   main, .-main
         .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
         .section        .note.GNU-stack,"",@progbits

清单 14-9:编译器生成的函数汇编语言,清单 14-6 中的函数

所有三个变量都是自动局部变量,因此编译器会在堆栈帧中为它们分配空间 ❶。初始化的自动局部变量在每次调用函数时都会重新创建,因此需要主动进行初始化 ❷。

我们将z变量的地址传递给add_two函数,这样它可以将输出存储在该地址 ❸,同时我们将xy变量的值传递给函数作为输入 ❹。

清单 14-10 展示了编译器生成的add_two函数的汇编语言。

add_two.s

         .arch armv8-a
         .file   "add_two.c"
         .text
         .align  2
         .global add_two
         .type   add_two, %function
 add_two:
      ❶ sub     sp, sp, #32
      ❷ str     x0, [sp, 8]         /// Address for output
         str     w1, [sp, 4]         /// Value of first input
         str     w2, [sp]            /// Value of second input
         ldr     w1, [sp, 4]
         ldr     w0, [sp]
         add     w0, w1, w0
         str     w0, [sp, 28]        /// Store sum locally
         ldr     x0, [sp, 8]
         ldr     w1, [sp, 28]        /// Load sum
         str     w1, [x0]            /// Store for caller
         nop
         add     sp, sp, 32
         ret
         .size   add_two, .-add_two
         .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
         .section        .note.GNU-stack,"",@progbits

清单 14-10:编译器生成的函数汇编语言,清单 14-8 中的函数

你可能会注意到这个函数的第一件事是,它没有保存链接寄存器lr和帧指针fp的内容 ❶。这两个地址组成了帧记录lr中的地址提供了回到调用此函数的地方的链接,而fp提供了回到调用函数的帧记录的链接。在某些错误情况下,帧记录链可能会很有用,但我在本书中不会深入讨论这个细节。

AArch64 过程调用标准规定,若一个小函数不调用其他函数,则不需要帧记录,这解释了编译器为什么省略了它。一个不调用任何函数的函数通常被称为叶函数

这个函数简单地分配了一个 32 字节的堆栈帧,并在其中存储传递给它的三个项 ❷。你可能会觉得在这个非常简单的函数中并不需要这么做,但在更复杂的函数中可能是必须的。图 14-1 给出了add_two堆栈帧的图示。

Image

图 14-1: add_two 函数的堆栈帧,见清单 14-10

cb的值是此函数的输入,a是存储函数输出的地址。

清单 14-11 展示了我可能如何在汇编语言中编写add_two函数。

add_two.s

// Add two integers and output the sum.
// Calling sequence:
//    x0 <- address of output
//    w1  <- integer
//    w2  <- integer
//    Returns 0
        .arch armv8-a
// Stack frame
     ❶ .equ    save1920, 16
        .equ    FRAME, 32
// Code
        .text
        .align  2
        .global add_two
        .type   add_two, %function
add_two:
        stp     fp, lr, [sp, -FRAME]!     // Create stack frame
        mov     fp, sp                    // Set our frame pointer
     ❷ stp     x19, x20, [sp, save1920]  // Save for local vars

        mov     x20, x0                   // For output
     ❸ add     w19, w2, w1               // Compute sum
        str     w19, [x20]                // Output sum

        mov     w0, wzr                   // Return 0
     ❹ ldp     x19, x20, [sp, save1920]  // Restore reg
        ldp     fp, lr, [sp], FRAME       // Delete stack frame
        ret                               // Back to caller

清单 14-11:用汇编语言编写的 add_two 函数

对于这个小的叶函数,堆栈帧并不是必须的,但我在这里创建了一个堆栈帧,以展示如何为调用函数保存寄存器,以便将它们作为局部变量使用。我们首先需要在堆栈帧中指定一个位置 ❶。这导致了图 14-2 所示的堆栈帧。

Image

图 14-2: add_two 函数的堆栈帧,见清单 14-11

尽管算法只使用x19寄存器的低位字,我们需要保存整个 64 位,因为我们的算法可能会改变x19的高位 32 位❷。事实上,这里的add指令将把x19的高位 32 位清零❸。不要忘记,在撤销栈帧之前,我们需要恢复保存的寄存器❹。

将我汇编语言版本的add_two函数的栈帧与编译器为 C 版本在图 14-1 中创建的栈帧进行比较,可以注意到我在栈帧的顶部创建了一个帧记录。然后我保存了x19x20寄存器,以便在函数中进行计算。如果我后来修改我的汇编语言add_two函数,使其调用另一个函数,我不需要更改它的栈帧,因为它已经有了一个帧记录,而标准规定,调用的函数必须为调用函数保存x19x20寄存器中的值。

在下一节中,你将看到当我们想传递超过八个可以通过寄存器传递的参数时,栈是如何提供帮助的。

传递超过八个参数

大多数函数传递的参数少于我们可以通过寄存器传递的八个参数,但有时调用函数需要向另一个函数传递超过八个参数。在这种情况下,超过前八个的参数会通过调用栈传递。它们会在调用函数之前被放置到栈上。我将使用清单 14-12、14-14 和 14-15 中的程序来向你展示这一过程是如何工作的。

sum11ints.c

// Sum the integers 1 to 11.

#include <stdio.h>
#include "add_eleven.h"

int main(void)
{
    int total;
    int a = 1;
    int b = 2;
    int c = 3;
    int d = 4;
    int e = 5;
    int f = 6;
    int g = 7;
    int h = 8;
    int i = 9;
    int j = 10;
    int k = 11;

    total = add_eleven(a, b, c, d, e, f, g, h, i, j, k);
    printf("The sum is %i\n", total);

    return 0;
}

清单 14-12:一个程序向子函数传递超过八个参数

这个main函数创建了 11 个整数变量,并将它们初始化为 1 到 11 的值。然后它调用add_eleven函数来计算这 11 个数字的和,并打印结果。

清单 14-13 展示了编译器为清单 14-12 中的main函数生成的汇编语言。

sum11ints.s

        .arch armv8-a
        .file   "sum11ints.c"
        .text
        .section        .rodata
        .align  3
.LC0:
        .string "The sum is %i\n"
        .text
        .align  2
        .global main
        .type   main, %function
main:
     ❶ sub     sp, sp, #96         /// Local vars and args
        stp     x29, x30, [sp, 32]  /// Store caller fp and lr
        add     x29, sp, 32         /// Point fp to caller's fp
        mov     w0, 1               /// Store values in local vars
        str     w0, [sp, 92]
        mov     w0, 2
        str     w0, [sp, 88]
        mov     w0, 3
        str     w0, [sp, 84]
        mov     w0, 4
        str     w0, [sp, 80]
        mov     w0, 5
        str     w0, [sp, 76]
        mov     w0, 6
        str     w0, [sp, 72]
        mov     w0, 7
        str     w0, [sp, 68]
        mov     w0, 8
        str     w0, [sp, 64]
        mov     w0, 9
        str     w0, [sp, 60]
        mov     w0, 10
        str     w0, [sp, 56]
        mov     w0, 11
        str     w0, [sp, 52]
        ldr     w0, [sp, 52]        /// Store args on the stack
     ❷ str     w0, [sp, 16]
        ldr     w0, [sp, 56]
        str     w0, [sp, 8]
        ldr     w0, [sp, 60]
        str     w0, [sp]
     ❸ ldr     w7, [sp, 64]        /// Load args into regs
        ldr     w6, [sp, 68]
        ldr     w5, [sp, 72]
        ldr     w4, [sp, 76]
        ldr     w3, [sp, 80]
        ldr     w2, [sp, 84]
        ldr     w1, [sp, 88]
        ldr     w0, [sp, 92]
        bl      add_eleven
        str     w0, [sp, 48]
        ldr     w1, [sp, 48]
        adrp    x0, .LC0
        add     x0, x0, :lo12:.LC0
        bl      printf
        mov     w0, 0
        ldp     x29, x30, [sp, 32]
        add     sp, sp, 96
        ret
        .size   main, .-main
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

清单 14-13:编译器为清单 14-12 中的函数生成的汇编语言

当这个函数创建它的栈帧时,它在栈上为需要传递的额外参数分配内存❶。在调用add_eleven函数之前,它会从右到左处理参数列表。由于它只能通过寄存器传递八个参数,因此需要将三个多余的参数存储到栈上❷。这些参数存储在栈帧的顶部,这是 AArch64 程序调用标准所指定的,调用的函数应当预期这些参数。图 14-3 显示了此时栈的状态。

Image

图 14-3:在调用add_eleven之前,清单 14-12 中main函数的栈帧

图 14-3 显示栈指针指向传递给add_eleven函数的参数。这张图中参数区域的名称ninetenelevenadd_eleven函数使用的对应参数名称。过程调用标准允许mainadd_eleven都可以访问栈中参数区域的内存,但参数传递只能从调用者到被调用者——也就是在这个例子中从mainadd_eleven

虽然我们传递的是 4 字节的int类型,但 AArch64 过程调用标准规定,我们必须为每个栈参数使用 8 字节,与寄存器参数相同。遵循这个规则可以确保你可以将汇编语言函数与 C 语言函数一起使用。在图 14-3 中,编译器将第九、第十和第十一参数存储在栈中每个参数槽的低 4 字节中。(不要忘记我们的内存顺序是小端序。)

在为调用add_eleven函数设置好栈帧后,我们将剩余的八个参数存储在寄存器中❸。

让我们来看看add_eleven函数是如何从栈中获取参数的。我将从该函数的头文件开始,见清单 14-14。

add_eleven.h

// Add 11 integers and return the sum.

#ifndef ADD_ELEVEN_H
#define ADD_ELEVEN_H
int add_eleven(int one, int two, int three, int four, int five, int six,
               int seven, int eight, int nine, int ten, int eleven);
#endif

清单 14-14:add_eleven函数的头文件

add_eleven函数定义在清单 14-15 中。

add_eleven.c

// Add 11 integers and return the sum.

#include <stdio.h>
#include "add_eleven.h"

int add_eleven(int one, int two, int three, int four, int five, int six,
               int seven, int eight, int nine, int ten, int eleven)
{
    int sum = one + two + three + four + five + six
            + seven + eight + nine + ten + eleven;
    printf("Added them\n");

    return sum;
}

清单 14-15:一个从调用函数接收超过八个参数的函数

清单 14-16 显示了编译器为清单 14-15 中的add_eleven函数生成的汇编语言。

add_eleven.s

        .arch armv8-a
        .file   "add_eleven.c"
        .text
        .section        .rodata
        .align  3
.LC0:
        .string "Added them"
        .text
        .align  2
        .global add_eleven
        .type   add_eleven, %function
add_eleven:
        stp     x29, x30, [sp, -64]!
        mov     x29, sp
     ❶ str     w0, [sp, 44]          /// Save register arguments locally
        str     w1, [sp, 40]
        str     w2, [sp, 36]
        str     w3, [sp, 32]
        str     w4, [sp, 28]
        str     w5, [sp, 24]
        str     w6, [sp, 20]
        str     w7, [sp, 16]
        ldr     w1, [sp, 44]          /// Add first 8 inputs
        ldr     w0, [sp, 40]
        add     w1, w1, w0
        ldr     w0, [sp, 36]
        add     w1, w1, w0
        ldr     w0, [sp, 32]
        add     w1, w1, w0
        ldr     w0, [sp, 28]
        add     w1, w1, w0
        ldr     w0, [sp, 24]
        add     w1, w1, w0
        ldr     w0, [sp, 20]
        add     w1, w1, w0
        ldr     w0, [sp, 16]
        add     w1, w1, w0
     ❷ ldr     w0, [sp, 64]          /// Add inputs 9-11 from the stack
        add     w1, w1, w0
        ldr     w0, [sp, 72]
        add     w0, w1, w0
        ldr     w1, [sp, 80]
        add     w0, w1, w0
        str     w0, [sp, 60]
        adrp    x0, .LC0
        add     x0, x0, :lo12:.LC0
        bl      puts
        ldr     w0, [sp, 60]
        ldp     x29, x30, [sp], 64
        ret
        .size   add_eleven, .-add_eleven
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

清单 14-16:编译器为清单 14-15 中的函数生成的汇编语言

在创建栈帧后,编译器会保存通过寄存器传递的参数,因为调用另一个函数可能会改变它们的内容❶。它会在函数开始时进行此操作,以便在编译函数中的 C 语句时无需跟踪哪些参数已经被保存。

在从栈中加载这八个参数并对其求和后,函数将添加在调用函数的栈帧中的剩余三个参数❷。

图 14-4 显示了mainadd_eleven函数的栈帧。

图片

图 14-4:调用add_eleven函数并创建其栈帧后的栈帧

我在第 279 页中提到,帧指针指向帧记录,帧记录指向调用函数的帧记录,依此类推。图 14-4 展示了这条链条一直回溯到main的帧记录。

我只展示了add_eleven应使用的sp偏移量。这个函数知道在从sp减去 64 之前,sp指向三个 32 位的参数,因此栈上的第一个参数现在距离sp是+64。

程序调用标准允许add_eleven函数将项存储到并从图 14-4 中标记为“add_eleven only”的区域加载数据。它允许add_eleven函数使用标记为“Both”的栈区域中的项,但不允许被调用函数在该区域将项传递回调用函数。add_eleven函数不允许访问标记为“main only”的栈区域。

main函数可以将项存储到并从图 14-4 中标记为“main only”的区域加载数据,但它仅被允许将项存储到标记为“Both”的栈区域。

栈帧规则

在编写汇编语言时,必须严格遵守寄存器使用和参数传递规则。任何偏离都会导致难以调试的错误。

图 14-5 展示了栈帧的整体结构。

图片

图 14-5:一个栈帧

栈帧并不总是包含图 14-5 中的所有部分。如果函数从未向它调用的函数传递超过八个参数,则顶部框将不存在。在这种情况下,spfp都指向帧记录。

有些函数可能没有任何局部变量或保存的寄存器内容。如果它是一个叶函数,我们甚至不需要一个栈帧记录。

如果函数传递的参数不超过八个,那么图中的底部框将不存在。底部框是栈帧中唯一一个当前函数和调用函数都能访问的区域。

让我们用汇编语言编写sum11ints程序。除非是非常简单的函数,否则我通常从为每个函数绘制栈帧图开始,类似于图 14-3 和图 14-4。然后,我使用.equ指令为栈上需要访问的每个位置赋予符号名称。

列表 14-17 展示了我们如何为sum11ints程序中的main函数进行此操作。

sum11ints.s

// Sum the integers 1 to 11.
        .arch armv8-a
// Stack frame
     ❶ .equ    arg9, 0
        .equ    arg10, 8
        .equ    arg11, 16
        .equ    frame_record, 32
        .equ    total, 48
        .equ    k, 52
        .equ    j, 56
        .equ    i, 60
        .equ    h, 64
        .equ    g, 68
        .equ    f, 72
        .equ    e, 76
        .equ    d, 80
        .equ    c, 84
        .equ    b, 88
        .equ    a, 92
        .equ    FRAME, 96
// Constant data
        .section  .rodata
        .align  3
format:
        .string "The sum is %i\n"
        .text
        .align  2
        .global main
        .type   main, %function
main:
     ❷ sub     sp, sp, FRAME              // Allocate our stack frame
        stp     fp, lr, [sp, frame_record] // Create frame record
        add     fp, sp, frame_record       // Set our frame pointer
        mov     w0, 1                      // Store values in local vars
     ❸ str     w0, [sp, a]
        mov     w0, 2
        str     w0, [sp, b]
        mov     w0, 3
        str     w0, [sp, c]
        mov     w0, 4
        str     w0, [sp, d]
        mov     w0, 5
        str     w0, [sp, e]
        mov     w0, 6
        str     w0, [sp, f]
        mov     w0, 7
        str     w0, [sp, g]
        mov     w0, 8
        str     w0, [sp, h]
        mov     w0, 9
        str     w0, [sp, i]
        mov     w0, 10
        str     w0, [sp, j]
        mov     w0, 11
        str     w0, [sp, k]
        ldr     w0, [sp, k]                // Store args 9-11
        str     w0, [sp, arg11]            //   on the stack
        ldr     w0, [sp, j]
        str     w0, [sp, arg10]
        ldr     w0, [sp, i]
        str     w0, [sp, arg9]
        ldr     w7, [sp, h]                // Load args 1-8
        ldr     w6, [sp, g]                //   in regs 0-7
        ldr     w5, [sp, f]
        ldr     w4, [sp, e]
        ldr     w3, [sp, d]
        ldr     w2, [sp, c]
        ldr     w1, [sp, b]
        ldr     w0, [sp, a]
        bl      add_eleven                 // Add all
        str     w0, [sp, total]            // Store returned sum
        ldr     w1, [sp, total]            // Argument to printf
        adr     x0, format                 // Format string 
        bl      printf                     // Print result

        mov     w0, wzr                    // Return 0
        ldp     fp, lr, [sp, frame_record] // Restore fp and lr
        add     sp, sp, FRAME              // Delete stack frame
        ret                                // Back to caller

列表 14-17:用汇编语言编写的 sum11ints main 函数

.equ指令的列表很好地展示了我们的栈帧是什么样子❶。使用符号名称来表示我们的变量,使得汇编语言代码更加易读,因为我们不必记住栈上每个项的数值偏移❸。

函数序言的算法需要考虑到main将通过栈传递参数。我们首先为栈帧分配空间 ❷。然后,在传递参数的区域之后创建栈帧记录。一旦我们创建了栈帧的.equ视图,这个过程就变得简单了。

接下来,我们将在汇编语言中编写add_eleven函数,使用图 14-4 中的图示来设置.equ指令的值。汇编语言代码展示在清单 14-18 中。

add_eleven.s

// Add 11 integers and return the sum.
// Calling sequence:
//    w0 through w7 <- 8 integers
//    [sp] <- integer
//    [sp+8] <- integer
//    [sp+16] <- integer
//    Returns sum
        .arch armv8-a
// Stack frame
        .equ    eight, 16
        .equ    seven, 20
        .equ    six, 24
        .equ    five, 28
        .equ    four, 32
        .equ    three, 36
        .equ    two, 40
        .equ    one, 44
        .equ    sum, 60
     ❶ .equ    FRAME, 64             // End of our frame
        .equ    nine, 64              // Stack args
        .equ    ten, 72
        .equ    eleven, 80
// Constant data
        .section  .rodata
        .align  3
msg:
        .string "Added them"
        .text
        .align  2
        .global add_eleven
        .type   add_eleven, %function
add_eleven:
     ❷ stp     fp, lr, [sp, -FRAME]! // Create stack frame
        mov     fp, sp                // Set our frame pointer

        str     w0, [sp, one]         // Save register args
        str     w1, [sp, two]
        str     w2, [sp, three]
        str     w3, [sp, four]
        str     w4, [sp, five]
        str     w5, [sp, six]
        str     w6, [sp, seven]
        str     w7, [sp, eight]
        ldr     w1, [sp, one]         // Load args             
        ldr     w0, [sp, two]
        add     w1, w1, w0            //   and sum them
        ldr     w0, [sp, three]
        add     w1, w1, w0
        ldr     w0, [sp, four]
        add     w1, w1, w0
        ldr     w0, [sp, five]
        add     w1, w1, w0
        ldr     w0, [sp, six]
        add     w1, w1, w0
        ldr     w0, [sp, seven]
        add     w1, w1, w0
        ldr     w0, [sp, eight]
        add     w1, w1, w0
        ldr     w0, [sp, nine]
        add     w1, w1, w0
        ldr     w0, [sp, ten]
        add     w1, w1, w0
        ldr     w0, [sp, eleven]
        add     w1, w1, w0
        str     w1, [sp, sum]         // Store sum
        adr     x0, msg               // Tell user we're done
        bl      puts

        ldr     w0, [sp, sum]         // Return the sum
        ldp     fp, lr, [sp], FRAME   // Delete stack frame
        ret                           // Back to caller

清单 14-18:用汇编语言编写的 add_eleven 函数

我们需要小心区分栈帧中仅由当前函数使用的部分和由调用函数传入的参数。通过.equ指令❶命名这个边界,可以方便地创建我们的栈帧 ❷。与清单 14-17 中的main函数一样,.equ指令的命名也使得汇编语言代码更易于阅读。

自动局部变量的值在退出函数时会丢失。有时你希望局部变量提供的信息是隐藏的,但你也希望在后续调用函数时该变量的内容保持不变。接下来我们将讨论如何实现这一点。

你的回合

14.1     展示在清单 14-18 中的汇编语言add_eleven函数如何与清单 14-12 中的 C 语言main函数一起工作。

14.2     展示初始化清单 14-15 中的sum变量与单独进行加法运算(如在清单 14-8 中所示)是相同的。

14.3     用汇编语言编写一个程序,求两个由用户输入的整数之间所有整数的和。

14.4     用汇编语言编写三个函数:write_charwrite_strread_str。稍后在本书中的练习中你将使用这些函数。以下是每个函数的规格:

(a)     write_char通过write系统调用在终端窗口中写入一个字符。它接受一个参数并返回 0。

(b)     write_str通过write系统调用在终端窗口中写入文本。它接受一个参数,并返回写入的字符数。

(c)     read_str通过read系统调用从键盘读取字符,并将它们存储在内存中作为 C 风格的文本字符串,不包括换行符。它接受两个参数:一个指向存储文本的内存位置的指针和最大存储字符数。如果输入的字符数超过最大值,它会读取剩余的输入,但不存储它。返回值是输入的字符数,减去NUL终止字符。

使用以下 C 语言main函数测试这些函数。别忘了为你的汇编语言函数编写 C 头文件。

// Prompt user to enter text and echo it.

#include "write_char.h"
#include "write_str.h"
#include "read_str.h"
#define MAX 5
#define BUFF_SZ MAX+1   // Make room for NUL

int main(void)
{
    char text[BUFF_SZ];

    write_str("Enter some text: ");
    read_str(text, MAX);
    write_str("You entered: ");
    write_str(text);
    write_char('\n');

    return 0;
}

提示:测试read_str函数时使用一个较小的MAX值。

静态局部变量

正如在第十一章中讨论的那样,自动局部变量在函数的前置代码中创建,并在函数的尾部删除。这意味着自动局部变量存储的值在后续调用函数时会丢失。但在某些情况下,我们可能希望在函数调用之间保持变量的值,同时仍然保持局部变量的信息隐藏优势。例如,我们可能有一个从多个其他函数中调用的函数,并且想要维护它被调用的次数。我们可以使用全局变量,但全局变量无法提供局部变量的信息隐藏特性。

作为替代,我们可以使用静态局部变量。像自动局部变量一样,静态局部变量具有局部作用域;然而,像全局变量一样,它会在整个程序的生命周期内一直保留在内存中。

在 C 中

我将使用清单 14-19、14-20 和 14-21 中的程序,解释静态局部变量在内存中是如何创建的。这些清单展示了自动局部变量、静态局部变量和全局变量在可见性和持久性上的区别。

var_life.c

// Compare the scope and lifetime of automatic, static, and global variables.

#include <stdio.h>
#include "add_const.h"
#define INIT_X 12
#define INIT_Y 34
#define INIT_Z 56

int z = INIT_Z;

int main(void)
{
    int x = INIT_X;
    int y = INIT_Y;

    printf("           automatic   static   global\n");
    printf("                   x        y        z\n");
    printf("In main:%12i %8i %8i\n", x, y, z);
    add_const();
    add_const();
    printf("In main:%12i %8i %8i\n", x, y, z);
    return 0;
}

清单 14-19:一个比较自动局部变量、静态局部变量和全局变量的程序

这个main函数定义了三个int变量:全局变量z和自动局部变量xy

清单 14-20 展示了add_const函数的头文件,该函数将常量值添加到自动局部变量、静态局部变量和在main中定义的全局变量中。

add_const.h

// Add a constant to an automatic local variable, a static local variable, 
// and a global variable.

#ifndef ADD_CONST_H
#define ADD_CONST_H
void add_const(void);
#endif

清单 14-20:add_const函数的头文件

清单 14-21 是add_const函数的定义。

add_const.c

// Add a constant to an automatic local variable, a static local variable, 
// and a global variable.

#include <stdio.h>
#include "add_const.h"
#define INIT_X 78
#define INIT_Y 90
#define ADDITION 1000

void add_const(void)
{
    int x = INIT_X;         // Every call
  ❶ static int y = INIT_Y;  // First call only
  ❷ extern int z;           // Global

     x += ADDITION;          // Add to each
     y += ADDITION;
     z += ADDITION;

     printf("In add_const:%7i %8i %8i\n", x, y, z);
}

清单 14-21:一个将常量值添加到三个变量的函数

add_const函数定义了两个局部变量xyy变量被指定为static ❶。这意味着它只会在第一次调用add_const时被初始化为INIT_Y。(如果你没有给静态局部变量赋初值,编译器会将其初始化为 0,但如果你的意图是这样,建议明确地初始化为 0。)add_consty所做的任何更改都会在此次调用及所有后续的调用中保持不变。z变量使用extern修饰符 ❷ 声明,表示它在程序的其他地方定义。

add_const函数将一个常量值加到函数中声明的三个变量上。printf语句显示了每次调用add_const时,在add_const中定义的局部变量xy,以及在main中定义的全局变量z的值。

执行此程序会得到以下输出:

           automatic   static   global
                   x        y        z
In main:          12       34       56
In add_const:   1078     1090     1056
In add_const:❶ 1078   ❷ 2090     2056
In main:          12       34   ❸ 2056

如你所见,main 中的 xadd_const 中的 x 不同。每次 main 调用 add_const 时,add_const 中的 x 都被初始化为 78,然后函数将其加上 1,000 ❶。这表明每次调用 add_const 时,都会自动创建一个新的 x

你还可以看到 main 中的 yadd_const 中的 y 不同,但 add_const 中的 xy 变量行为并不相同。第一次调用 add_const 时,它将其 y 变量初始化为 90 并加上 1,000。然而,这次第一次调用 add_const 的结果会持续存在。第二次调用 add_const 时,不会重新初始化 y;该函数只是将 1,000 加到静态 y 的现有值上 ❷。

尽管程序中有两个 x 和两个 y,但只有一个 z,它是在 main 中定义的。程序的输出显示,mainz 赋予初始值 56,而每次调用 add_const 函数时,add_const 都会将 1,000 加到该值上 ❸。

汇编语言中的内容

清单 14-22 显示了编译器为 var_life 程序中的 main 函数生成的汇编语言。

var_life.s

         .arch armv8-a
        .file   "var_life.c"
        .text
     ❶ .global z
        .data
        .align  2
        .type   z, %object
        .size   z, 4
z:
        .word   56                  /// One instance of z
        .section        .rodata
        .align  3
.LC0:
        .string "           automatic   static   global"
        .align  3
.LC1:
        .string "                   x        y        z"
        .align  3
.LC2:
        .string "In main:%12i %8i %8i\n"
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     x29, x30, [sp, -32]!
        mov     x29, sp
        mov     w0, 12
        str     w0, [sp, 28]        /// main's x
        mov     w0, 34
     ❷ str     w0, [sp, 24]        /// main's y
        adrp    x0, .LC0
        add     x0, x0, :lo12:.LC0
        bl      puts
        adrp    x0, .LC1
        add     x0, x0, :lo12:.LC1
        bl      puts
        adrp    x0, z
        add     x0, x0, :lo12:z
        ldr     w0, [x0]
        mov     w3, w0
        ldr     w2, [sp, 24]
        ldr     w1, [sp, 28]
        adrp    x0, .LC2
        add     x0, x0, :lo12:.LC2
        bl      printf
        bl      add_const
        bl      add_const
        adrp    x0, z
        add     x0, x0, :lo12:z
        ldr     w0, [x0]
        mov     w3, w0
        ldr     w2, [sp, 24]
        ldr     w1, [sp, 28]
        adrp    x0, .LC2
        add     x0, x0, :lo12:.LC2
        bl      printf
        mov     w0, 0
        ldp     x29, x30, [sp], 32
        ret
        .size   main, .-main
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

清单 14-22:编译器为清单 14-19 中的函数生成的汇编语言

这段代码中的大部分内容应该对你来说是熟悉的。程序中的 z 实例在 main 中定义为全局变量,编译器使用我们的名字标记该变量 ❶。main 中的 y 被创建在栈上 ❷。

让我们看看编译器为 add_const 函数生成的汇编语言,如清单 14-23 所示。

add_const.s

        .arch armv8-a
        .file   "add_const.c"
        .text}
        .section        .rodata
        .align  3
.LC0:
        .string "In add_const:%7i %8i %8i\n"
        .text
        .align  2
        .global add_const
        .type   add_const, %function
add_const:
        stp     x29, x30, [sp, -32]!
        mov     x29, sp
        mov     w0, 78
     ❶ str     w0, [sp, 28]
        ldr     w0, [sp, 28]
        add     w0, w0, 1000
        str     w0, [sp, 28]            /// add_const's x
     ❷ adrp    x0, y.0
        add     x0, x0, :lo12:y.0       /// add_const's y
        ldr     w0, [x0]
        add     w1, w0, 1000
        adrp    x0, y.0
        add     x0, x0, :lo12:y.0
        str     w1, [x0]
     ❸ adrp    x0, :got:z              /// Global z
        ldr     x0, [x0, :got_lo12:z]
        ldr     w0, [x0]
        add     w1, w0, 1000
        adrp    x0, :got:z
        ldr     x0, [x0, :got_lo12:z]
        str     w1, [x0]
        adrp    x0, y.0
        add     x0, x0, :lo12:y.0
        ldr     w1, [x0]
        adrp    x0, :got:z
        ldr     x0, [x0, :got_lo12:z]
        ldr     w0, [x0]
        mov     w3, w0
        mov     w2, w1
        ldr     w1, [sp, 28]
        adrp    x0, .LC0
        add     x0, x0, :lo12:.LC0
        bl      printf
        nop
        ldp     x29, x30, [sp], 32
        ret
        .size   add_const, .-add_const
     ❹ .data
        .align  2
        .type   y.0, %object
        .size   y.0, 4
y.0:
     ❺ .word   90
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

清单 14-23:编译器生成的汇编语言,用于清单 14-21 中的 add_const 函数

编译器将 x 变量分配到栈帧中,因此它知道栈指针的偏移量 ❶。正如你在 第 271 页的“全局变量”一节中学到的那样,add_const 需要从 GOT 获取全局变量 z 的地址 ❸。

编译器处理静态局部变量 y 与全局变量略有不同。它将我们的名字改为 y.0 ❷。这种对变量名字的修饰叫做 名称修饰名称重整。我更喜欢称其为修饰,因为它是对我们原始名字的补充。

静态局部变量不能存在于栈帧中。像在 main 函数中定义的全局变量 z(参见 清单 14-22)一样,静态局部变量 y 被分配在 .data 段 ❹。它被标记为其修饰后的名字,并通过 .word 汇编指令初始化 ❺。我们通过它的修饰名来获取 y 变量的地址 ❷。

编译器需要装饰名称以满足 C 语言的规则,同时生成有效的汇编语言文件。正如你可能还记得从本章的第一部分(第 270 页),C 语言中变量名称的作用域仅扩展到它定义的块的结束。这意味着我们可以使用相同的名称在另一个不同的块中定义另一个静态变量,只要这个块不包含在第一个块内。但这两个变量都会在汇编语言中生成标签,且它们具有文件作用域。C 编译器需要区分这两个标签,因为汇编器要求每个标签在文件内都是唯一的。它通过装饰我们的静态局部变量名称来实现这一点。

在汇编语言中编写程序时,我们可以使用更有意义的名称来区分变量,使代码更易于阅读。清单 14-24 和 14-25 展示了我如何在汇编语言中编写这个程序。

var_life.s

// Compare the scope and lifetime of automatic, static, and global variables.
        .arch armv8-a
// Useful names
        .equ    INIT_X, 12
        .equ    INIT_Y, 34
        .equ    INIT_Z, 56
// Stack frame
        .equ    x, 24
        .equ    y, 28
        .equ    FRAME, 32
// Code
        .global z
        .data
        .align  2
        .type   z, %object
        .size   z, 4
z:
        .word   INIT_Z
        .section  .rodata
heading0:
        .string "           automatic   static   global"
heading1:
        .string "                   x        y        z"
msg:
        .string "In main:%12i %8i %8i\n"
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     fp, lr, [sp, -FRAME]!   // Create stack frame
        mov     fp, sp                  // Set frame pointer

        mov     w0, INIT_X
        str     w0, [sp, x]             // x = INIT_X;
        mov     w0, INIT_Y
        str     w0, [sp, y]             // y = INIT_Y;
        adr     x0, heading0            // Print two-line header
        bl      puts
        adr     x0, heading1
        bl      puts

     ❶ adr     x0, z 
        ldr     w3, [x0]                // Global z
        ldr     w2, [sp, y]             // Local y
        ldr     w1, [sp, x]             // Local x
        adr     x0, msg                 // Show values
        bl      printf
        bl      add_const               // Add constants
        bl      add_const               //   twice

        adr     x0, z                   // Repeat display
        ldr     w3, [x0]
        ldr     w2, [sp, y]
        ldr     w1, [sp, x]
        adr     x0, msg
        bl      printf

        mov     w0, wzr                 // Return 0
        ldp     fp, lr, [sp], FRAME     // Delete stack frame
        ret                             // Back to caller

清单 14-24:一个汇编语言程序,用于比较自动局部变量、静态局部变量和全局变量

我的汇编语言main函数与编译器在清单 14-22 中所做的非常相似,但我使用了更有意义的名称。只使用终端 I/O 的程序通常非常小,因此我假设z会位于指令的±1MB 范围内,并使用adr指令加载它的地址❶。如果事实并非如此,链接器应该会警告我们,这时我们需要使用adrp/add指令序列来加载z的地址。

清单 14-25 展示了我如何在汇编语言中编写add_const

add_const.s

   // Add a constant to an automatic local variable, a static local variable, 
   // and a global variable.
           .arch armv8-a
   // Useful names
           .equ    INIT_X, 78
           .equ    INIT_Y, 90
           .equ    ADDITION, 1000
   // Stack frame
           .equ    x, 28
           .equ    FRAME, 32
   // Code
           .data
           .align  2
           .type   y, %object
           .size   y, 4
❶ y:
           .word   INIT_Y
           .section  .rodata
   msg:
           .string "In add_const:%7i %8i %8i\n"
           .text
           .align  2
           .global add_const
           .type   add_const, %function
   add_const:
           stp     fp, lr, [sp, -FRAME]!   // Create stack frame
           mov     fp, sp                  // Set frame pointer

           mov     w0, INIT_X
           add     w0, w0, ADDITION        // Add constant
           str     w0, [sp, x]             // x += ADDITION
        ❷ adr     x0, y
           ldr     w1, [x0]                // Load our y
           add     w1, w1, ADDITION        // Add constant
           str     w1, [x0]                // y += ADDITION;    
           adrp    x0, :got:z              // z page number
           ldr     x0, [x0, :got_lo12:z]   // z address
           ldr     w1, [x0]                // Load z
           add     w1, w1, ADDITION        // Add constant
           str     w1, [x0]                // z += ADDITION

           adrp    x0, :got:z              // z page number
           ldr     x0, [x0, :got_lo12:z]   // z address
           ldr     w3, [x0]                // Load global z
           adr     x0, y
           ldr     w2, [x0]                // Load our y
           ldr     w1, [sp, x]             // Load our x
           adr     x0, msg                 // Show current values
           bl      printf

           mov     w0, wzr                 // Return 0
           ldp     fp, lr, [sp], FRAME     // Delete stack frame
           ret                             // Back to caller

清单 14-25:一个汇编语言函数,用于将常量值加到三个变量上

我没有装饰y变量的名称❶。正如我们之前看到的,编译器会装饰静态局部变量的名称,以防止如果我们在同一个 C 源文件的另一个函数中使用相同的静态局部名称时,编译器生成的汇编语言中出现标签重复的情况。但在汇编语言中编写代码使我们在选择标签名称时更加灵活。如果我们在同一文件中使用y来标记另一个内存位置,并使用add_const函数,汇编器会告诉我们错误。

main函数中的z变量一样,我假设y静态变量会接近访问它的指令❷。

接下来,我将简要总结程序内存特性。

程序内存特性

当我们在第十章开始编写汇编语言程序时,你了解了不同的内存段。表 14-1 总结了程序中一些最常见组件的内存特性,以及它们被放置的内存段。

表 14-1:常见程序组件的内存特性

组件 内存段 访问 生命周期
自动局部变量 读写 函数
常量 文本 只读 程序
指令 文本 只读 程序
静态局部变量 数据 读写 程序
全局变量 数据 读写 程序

Table 14-2 总结了常用的汇编指令,用于控制程序组件在内存中的位置。

Table 14-2: 一些常见的汇编内存指令

指令 内存段 效果
.text 文本 指令跟随其后
.rodata 文本 常量数据跟随其后
.string "*string*", ... 文本 字符数组,每个字符由NUL结束
.ascii "*string*", ... 文本 字符数组
.asciz "*string*", ... 文本 字符数组,每个字符由NUL结束
.bss 数据 后续数据内存初始化为零
.data 数据 变量数据跟随其后
.byte *expression*, ... 数据 初始化内存,每个expression占 1 字节
.hword *expression*, ... 数据 初始化内存,每个expression占 2 字节
.word *expression*, ... 数据 初始化内存,每个expression占 4 字节
.quad *expression*, ... 数据 初始化内存,每个expression占 8 字节

.string.ascii.asciz 指令可以分配多个文本字符串,每个字符串由逗号分隔。.string.asciz 指令会在文本字符串的末尾添加NUL字符,而.ascii则不会。

.byte.hword.word.quad 指令可以应用于零个或多个表达式,每个表达式都必须计算为整数值。多字节值以小端顺序存储。如果没有表达式,则不会分配内存。

这只是这些指令的摘要。如需更多细节,请参考asinfo页面。

你的回合

14.5     修改 Listings 14-24 和 14-25 中的程序,使得add_const函数能够打印出它被调用的次数。

| 14.6 | 在 Listing 14-21 中的文件里复制add_const函数,命名第二个副本为add_const2。相应地修改 Listing 14-20 中的头文件。修改 Listing 14-19 中的main函数,使其调用这两个函数两次。这样会如何影响add_constadd_const2函数中的名称修饰?

你所学到的

全局变量 在程序的整个生命周期内保持存在(全局作用域)。

自动局部变量 在调用函数时创建,并在退出函数时删除(函数作用域)。

静态局部变量 在第一次调用其函数时初始化。它们的值(包括任何更改)在随后的函数调用之间保持不变(函数作用域)。

传递参数 前八个参数通过寄存器传递。任何额外的参数通过栈传递。

值传递 传递的是值的副本。

指针传递 传递的是变量的地址。被调用函数中可以更改该地址。

引用传递 传递的是变量的地址。被调用函数中无法更改该地址。

栈帧 栈上的一个区域,用于存储函数的帧记录、局部变量、保存的寄存器内容以及传递给函数的参数。

帧指针 一个寄存器,包含栈帧中帧记录的地址。

帧记录 两个地址:调用函数的帧指针和返回地址。

现在你已经了解了函数的内部工作原理,接下来我会在下一章展示一些子函数的特殊用法。

第十五章:子函数的特殊用途

Image

正如你在 第十四章 中学到的,子函数最常见的用途是将一个问题分解成更小、更易解决的子问题。这是 递归 的基础,也是本章前半部分的主题。

在我讲解递归之后,我将向你展示子函数的另一个用途:直接访问汇编语言中的硬件功能,这些功能在高级语言中可能无法轻易访问。

递归

许多计算机解决方案涉及重复的操作。你在 第十三章 中学会了如何使用迭代—whilefordo-while 循环—来执行重复的操作。虽然迭代可以用来解决任何重复性问题,但某些解决方案使用递归描述时会更加简洁。

递归算法 是一种调用自身来计算问题的简化版本,并使用该结果来计算更复杂的情况的算法。递归调用会持续进行,直到简化的情况达到 基准情况,即一个可以轻松计算的情况。在这一点上,递归算法会将基准情况的值返回给下一个更复杂的情况,并在该计算中使用这个值。这个返回/计算的过程会继续进行,逐步执行更复杂的计算,直到我们得出原始问题的解决方案。

让我们来看一个例子。在数学中,我们用 ! 来表示正整数的阶乘操作,这可以递归定义:

Image

第一个方程式展示了 n! 是通过计算它自身的简化版本 (n – 1)! 来定义的。这个计算会反复执行,直到我们达到基准情况 n = 0。然后我们返回并计算每一个 n!。

作为对比,阶乘操作的迭代定义是:

Image

尽管两种定义阶乘操作的形式涉及相同数量的计算,但递归形式更简洁,或许对某些人来说也更直观。

清单 15-1 到 15-3 展示了一个使用函数 factorial 来计算 3! 的程序。当我们使用 gdb 来检查程序的行为时,你会明白为何使用一个小的固定值。

three_factorial.c

// Compute 3 factorial.

 #include <stdio.h>
 #include "factorial.h"

 int main(void)
 {
     unsigned int x = 3;
     unsigned int y;

     y = factorial(x);
     printf("%u! = %u\n", x, y);

     return 0;
 }

清单 15-1:计算 3! 的程序

数学中的阶乘函数是针对非负整数定义的,因此我们使用 unsigned int 类型。

对于 factorial 函数的头文件,在 清单 15-2 中并没有什么特别之处。

factorial.h

// Return n factorial.

 #ifndef FACTORIAL_H
 #define FACTORIAL_H
 unsigned int factorial(unsigned int n);
 #endif

清单 15-2:计算 n! 的函数的头文件

清单 15-3 显示了 factorial 函数如何通过调用自身来执行更简单的计算 (n – 1)!, 从而轻松地计算 n!

factorial.c

// Return n factorial.

 #include "factorial.h"

 unsigned int factorial(unsigned int n)
 {
     unsigned int current = 1;   // Assume base case

  ➊ if (n != 0) {
      ➋ current = n * factorial(n - 1);
     }
     return current;
 }

清单 15-3:计算 n! 的函数

factorial 函数首先检查基础情况 n = 0 ❶。如果处于基础情况,当前结果为 1。如果不在基础情况,factorial 函数会调用自身计算 (n – 1)!,并将结果乘以 n 以获得 n!❷。

main 函数的汇编语言并无特别之处,但让我们看看编译器为 factorial 函数生成的内容,如列表 15-4 所示。

factorial.s

         .arch armv8-a
         .file   "factorial.c"
         .text
         .align  2
         .global factorial
         .type   factorial, %function
 factorial:
         stp     x29, x30, [sp, -48]!
         mov     x29, sp
      ➊ str     w0, [sp, 28]
         mov     w0, 1
         str     w0, [sp, 44]
         ldr     w0, [sp, 28]
      ➋ cmp     w0, 0             /// Check for base case
         beq     .L2
         ldr     w0, [sp, 28]
         sub     w0, w0, #1        /// n - 1
      ➌ bl      factorial         /// Recursive call
         mov     w1, w0
         ldr     w0, [sp, 28]
      ➍ mul     w0, w0, w1        /// n * (n - 1)!
         str     w0, [sp, 44]
 .L2:
         ldr     w0, [sp, 44]
         ldp     x29, x30, [sp], 48
         ret
         .size   factorial, .-factorial
         .ident  "GCC: Debian 12.2.0-14) 12.2.0"
         .section        .note.GNU-stack,"",@progbits

列表 15-4:编译器生成的列表 15-3 中函数的汇编语言

factorial 函数使用的算法是一个简单的 if 结构,你在第十三章中学过 ❷。递归函数的关键部分是,我们需要在寄存器中保存传递给它的任何参数,以便这些寄存器可以在递归调用时重新使用来传递参数。

例如,factorial 函数接受一个参数 n,它通过 w0 寄存器传递。从表 11-3 中第十一章我们知道,在我们的函数中不需要保存 x0 的内容,但我们需要使用 x0w0 部分进行递归调用,并传入新的值 (n - 1) ❸。当递归调用返回时,我们需要 n 的原始值来计算 n * (n - 1)!。编译器在堆栈帧中为保存 n 分配了空间 ❶。

我们还没有讨论过 mul 指令。正如你可能猜到的,列表 15-4 中的 mul 指令会将 w0 中的整数与 w1 中的整数相乘,将乘积保留在 w0 中 ❹。乘法指令的细节有些复杂,我会在第十六章中讲解。

我们可以通过直接用汇编语言编写来稍微简化 factorial 函数。让我们从设计堆栈帧开始,如图 15-1 所示。

图片

图 15-1: factorial 函数的堆栈帧设计

列表 15-5 展示了我们的汇编语言版本。

factorial.s

// Compute n factorial.
// Calling sequence:
//    w0 <- n
//    Returns n!
        .arch armv8-a
// Stack frame
        .equ    n, 16
     ➊ .equ    FRAME, 32
// Code
        .text
        .align  2
        .global factorial
        .type   factorial, %function
factorial:
        stp     fp, lr, [sp, -FRAME]! // Create stack frame
        mov     fp, sp                // Set our frame pointer

        str     w0, [sp, n]           // Save n
        mov     w1, w0                //   and make a copy
        mov     w0, 1                 // Assume base case, 0! = 1
     ➋ cbz     w1, base_case         // Check for base case
        sub     w0, w1, 1             // No,
        bl      factorial             //   compute (n - 1)!
        ldr     w1, [sp, n]           // Get n
     ➌ mul     w0, w0, w1            // n * (n - 1)!
base_case:
        ldp     fp, lr, [sp], FRAME   // Delete stack frame
        ret                           // Back to caller

列表 15-5:计算 n! 的函数

这里有几个关键的差异需要注意。首先,我不知道为什么编译器为堆栈帧分配了 48 字节(见列表 15-4),但我们只需要 32 字节 ❶。其次,我们使用了一个局部变量来存储输入的 n。编译器使用局部变量来存储计算结果 n * (n - 1)!,但我们将其保留在 w0 寄存器中 ❸。第三,我们使用了 cbz 指令,而不是编译器使用的 cmp/beq 指令对 ❷。

递归算法可以简单而优雅,但它们大量使用堆栈。我使用了我们汇编语言版本的 factorial(以及列表 15-2 中的 C 头文件)和列表 15-1 中的 main 函数,并在 gdb 下运行了程序,这样我们就可以看看堆栈的使用情况:

(gdb) l factorial
   11              .text
   12              .align  2
   13              .global factorial
   14              .type   factorial, %function
   15      factorial:
   16              stp     fp, lr, [sp, -FRAME]! // Create stack frame
   17              mov     fp, sp                // Set our frame pointer
   18
   19              str     w0, [sp, n]           // Save n
   20              mov     w1, w0                //   and make a copy
   (gdb) 
   21              mov     w0, 1                 // Assume base case, 0! = 1
   22              cbz     w1, base_case         // Check for base case
   23              sub     w0, w1, 1             // No,
➊ 24              bl      factorial             //   compute (n - 1)!
   25              ldr     w1, [sp, n]           // Get n
   26              mul     w0, w0, w1            // n * (n - 1)!
➋ 27      base_case:
   28              ldp     fp, lr, [sp], FRAME   // Delete stack frame
   29              ret                           // Back to caller
   (gdb) b 24
   Breakpoint 1 at 0x7cc: file factorial.s, line 24.
   (gdb) b 27
   Breakpoint 2 at 0x7d8: file factorial.s, line 28.

我设置了两个断点,一个是在递归调用factorial ❶时,另一个是在函数算法结束的地方 ❷。每当程序回到gdb时,我们将查看此次调用factorial的输入值、传递给下一次调用factorial的输入值、pc以及每次调用factorial的栈帧:

(gdb) r
Starting program: /home/bob/chapter_15/factorial_asm/three_factorial 

Breakpoint 1, factorial () at factorial.s:24
24              bl      factorial             //    Compute (n - 1)!
(gdb) i r x0 x1 sp pc
x0             0x2                 2
x1             0x3                 3
sp             0x7fffffef40        0x7fffffef40
pc             0x55555507cc        0x55555507cc <factorial+28>
(gdb) x/4gx 0x7fffffef40
0x7fffffef40:   0x0000007fffffef60   ➊ 0x000000555555078c
0x7fffffef50:➋ 0x0000005500000003      0x0000000000000000

main函数调用factorial函数时传入了3作为输入,这个值保存在栈上 ❷。查看这个显示时,请记住输入是一个 32 位的int。栈上的每项数据宽度是 64 位,因此这个int被存储在这个栈位置的低 32 位中。通过查看 32 字节的栈帧,我们可以看到返回地址回到main ❶。

现在我们准备使用寄存器w0调用factorial,并传入输入(3 - 1) = 2。当我们继续执行程序时,它将在factorial中相同的位置暂停,因为该函数在返回main之前会调用自身:

(gdb) c
Continuing.

Breakpoint 1, factorial () at factorial.s:24
24              bl      factorial             //    Compute (n - 1)!
(gdb) i r x0 x1 sp pc
x0             0x1                 1
x1             0x2                 2
sp          ➊ 0x7fffffef20        0x7fffffef20
pc             0x55555507cc        0x55555507cc <factorial+28>
(gdb) x/8gx 0x7fffffef20
0x7fffffef20:   0x0000007fffffef40   ➋ 0x00000055555507d0
0x7fffffef30:➌ 0x0000007f00000002      0x000000555555081c
0x7fffffef40:   0x0000007fffffef60      0x000000555555078c
0x7fffffef50:   0x0000005500000003      0x0000000000000000

factorial函数向栈上添加了一个新的 32 字节栈帧 ❶。这次调用factorial的输入值2已经保存在这个新的栈帧中 ❸。

程序计数器pc显示bl factorial指令位于0x55555507cc。在 A64 架构中,所有指令宽度为 32 位,因此所有递归调用factorial函数都会返回到0x55555507cc + 0x4 = 0x55555507d0的位置。这就是存储在框架记录中的返回地址 ❷。

如果我们再输入两次 c(继续),最终我们会到达程序流离开第四次factorial调用的地方,并在第二个断点处暂停:

(gdb) c
Continuing.

Breakpoint 2, base_case () at factorial.s:28
28              ldp     fp, lr, [sp], FRAME   // Delete stack frame
(gdb) i r x0 x1 sp pc
x0             0x1                 1
x1             0x0                 0
sp             0x7fffffeee0        0x7fffffeee0
pc             0x55555507d8        0x55555507d8 <base_case>
(gdb) x/16gx 0x7fffffeee0 (@\label{pg:recurse}@)
0x7fffffeee0:   0x0000007fffffef00      0x00000055555507d0
0x7fffffeef0:➊ 0x0000000000000000      0x0000000000000000
0x7fffffef00:   0x0000007fffffef20      0x00000055555507d0
0x7fffffef10:   0x0000007f00000001      0x0000000000000000
0x7fffffef20:   0x0000007fffffef40      0x00000055555507d0
0x7fffffef30:   0x0000007f00000002      0x000000555555081c
0x7fffffef40:   0x0000007fffffef60      0x000000555555078c
0x7fffffef50:   0x0000005500000003      0x0000000000000000

这个视图显示了每次调用factorial时,都会创建一个新的栈帧。它还展示了每次调用factorial的输入是如何保存在栈上的,最后一个是0 ❶。由于这是基准情况,程序流直接跳到了函数的末尾。

栈上前三个栈帧的框架记录中的返回地址是0x00000055555507d0。每次递归调用factorial返回时,都会返回到factorial函数中bl factorial指令后面的指令。当我们继续执行程序时,它再次在第二个断点处暂停:

(gdb) c
Continuing.

Breakpoint 2, base_case () at factorial.s:28
28              ldp     fp, lr, [sp], FRAME   // Delete stack frame
(gdb) i r x0 x1 sp pc
x0             0x1                 1
x1             0x1                 1
sp             0x7fffffef00        0x7fffffef00
pc             0x55555507d8        0x55555507d8 <base_case>
(gdb) x/12gx 0x7fffffef00
0x7fffffef00:   0x0000007fffffef20      0x00000055555507d0
0x7fffffef10:   0x0000007f00000001      0x0000000000000000
0x7fffffef20:   0x0000007fffffef40      0x00000055555507d0
0x7fffffef30:   0x0000007f00000002      0x000000555555081c
0x7fffffef40:   0x0000007fffffef60      0x000000555555078c
0x7fffffef50:   0x0000005500000003      0x0000000000000000

比较这个显示和之前的显示,我们可以看到顶部的框架记录——即当factorial函数以0作为输入调用时创建的框架记录——已经从栈上移除。

当我们继续执行时,程序再次在第二个断点处暂停,等待从这次factorial调用中返回:

(gdb) c
Continuing.

Breakpoint 2, base_case () at factorial.s:28
28              ldp     fp, lr, [sp], FRAME   // Delete stack frame

再次使用继续命令(c)将我们带回原始的main中对factorial的调用:

(gdb) c
Continuing.

Breakpoint 2, base_case () at factorial.s:28
28              ldp     fp, lr, [sp], FRAME   // Delete stack frame
(gdb) i r x0 x1 sp pc
x0             0x6                 6
x1             0x3                 3
sp             0x7fffffef40        0x7fffffef40
pc             0x55555507d8        0x55555507d8 <base_case>
(gdb) x/4gx 0x7fffffef40
0x7fffffef40:   0x0000007fffffef60   ➊ 0x000000555555078c
0x7fffffef50:   0x0000005500000003      0x0000000000000000

框架记录中的返回地址回到了main ❶。当我们继续执行时,main将为我们打印结果:

(gdb) c
Continuing.
3! = 6
[Inferior 1 (process 2310) exited normally]
(gdb)

调试器还为我们打印了一些进程信息。我们仍然在gdb中,需要退出它。

当程序处于基本情况时(参见第 311 页),回顾栈的显示,注意到每次调用递归函数时都会创建一个新的栈帧。我们在这个例子中使用了一个小数字,但计算大数字的阶乘会占用大量栈空间。而且,由于每次重复都会调用一个函数,递归算法可能会很耗时。

每个递归解决方案都有一个等效的迭代解决方案,通常在时间和栈使用上更高效。例如,计算整数阶乘的迭代算法(参见第 306 页)很简单,因此迭代解决方案可能更为优选。然而,许多问题(例如某些排序算法)更自然地适合使用递归解决方案。对于这类问题,代码的简化通常是值得付出的递归代价。

我们通常会在高级语言中编写递归函数,但我在这里使用汇编语言是为了让你理解递归是如何工作的。接下来,我将向你展示如何使用汇编语言访问可能在你使用的高级语言中无法直接访问的硬件特性。

轮到你了

15.1     输入 C 程序至 15-3。在gdb下运行你的程序,并在if语句的开头和return语句处设置断点。每次断点触发时,你应该看到输入当前调用factorialn值。gdbbt(回溯)和info f(帧信息)命令将提供更多关于栈帧的信息,这对调试递归 C 函数非常有帮助。

在汇编语言中访问 CPU 特性

在第十四章中,为了sum_ints程序(参见列表 14-8)仅仅添加两个整数就创建一个完整的子函数,可能看起来有些傻,这本来可以通过一条指令完成。但正如你在第三章学到的,即使是简单的加法,也可能会产生进位或溢出。

你在第十一章中学到的addsub指令不会影响条件标志,但 A64 架构包括addsub的变体,addssubs,它们会根据操作结果设置nzcv寄存器中的条件标志。

C 和 C++并没有提供检查nzcv寄存器中溢出或进位标志的方法。在这一部分,我将展示两种方法来在 C 程序中使用汇编语言指示加法溢出的情况:我们可以编写一个在 C 代码中可调用的单独汇编函数,或者直接在 C 代码中嵌入汇编语言。

编写一个单独的函数

我将从 第十四章 中的 sum_ints 程序开始,使用 C 语言重写它,以便在加法产生溢出时提醒用户。我将在子函数 add_two 中检查溢出,并通过返回机制将结果传回 main 函数。

清单 15-6 显示了修改后的 main 函数,该函数检查 add_two 函数的返回值是否发生溢出。

sum_ints.c

// Add two integers and show if there is overflow.

#include <stdio.h>
#include "add_two.h"

int main(void)
{
    int x, y, z, overflow;

    printf("Enter an integer: ");
    scanf("%i", &x);
    printf("Enter an integer: ");
    scanf("%i", &y);
    overflow = add_two(&z, x, y);
    printf("%i + %i = %i\n", x, y, z);
    if (overflow)
        printf("** Overflow occurred **\n");

    return 0;
}

清单 15-6:一个加法程序,检查两个整数相加时是否发生溢出

接下来,我将重写 add_two 函数,使其在没有溢出的情况下返回 0,在发生溢出时返回 1(回忆一下,在 C 语言中,零在逻辑上为假,非零值为真)。我将在 main 函数中将这个结果赋值给变量 overflow

清单 15-7 显示了新的 add_two 函数的头文件。

add_two.h

// Add two integers and return 1 for overflow, 0 for no overflow.

#ifndef ADD_TWO_H
#define ADD_TWO_H
int add_two(int *a, int b, int c);
#endif

清单 15-7:add_two 函数的头文件

函数声明中的唯一变化是返回一个 int 类型,而不是 void。我们需要在 add_two 函数的定义中添加溢出检查,如 清单 15-8 所示。

add_two.c

// Add two integers and return 1 for overflow, 0 for no overflow.

#include "add_two.h"

int add_two(int *a, int b, int c)
{
    int sum;
    int overflow = 0;   // Assume no overflow

    sum = b + c;
 ➊ if (((b > 0) && (c > 0) && (sum < 0)) ||
            ((b < 0) && (c < 0) && (sum > 0))) {
        overflow = 1;
    }
    *a = sum;

    return overflow;
}

清单 15-8:一个加法函数,返回溢出指示

你在 第三章 中学到,如果相加的两个整数符号相同,结果却是相反符号,那么就发生了溢出,因此我们使用这个逻辑来检查溢出 ❶。

清单 15-9 显示了编译器从这个 C 源代码生成的汇编语言。

add_two.s

        .arch armv8-a
        .file   "add_two.c"
        .text
        .align  2
        .global add_two
        .type   add_two, %function
add_two:
        sub     sp, sp, #32
        str     x0, [sp, 12]    /// a
        str     w1, [sp, 8]     /// b
        str     w2, [sp]        /// c
        str     wzr, [sp, 28]   /// overflow = 0;
        ldr     w1, [sp, 12]
        ldr     w0, [sp, 8]
        add     w0, w1, w0
        str     w0, [sp, 24]
     ➊ ldr     w0, [sp, 12]    /// Start overflow check
        cmp     w0, 0
        ble     .L2
        ldr     w0, [sp, 8]
        cmp     w0, 0
        ble     .L2
        ldr     w0, [sp, 24]
        cmp     w0, 0
        blt     .L3
.L2:
        ldr     w0, [sp, 12]
        cmp     w0, 0
        bge     .L4
        ldr     w0, [sp, 8]
        cmp     w0, 0
        bge     .L4
        ldr     w0, [sp, 24]
        cmp     w0, 0
        ble     .L4
.L3:
        mov     w0, 1
        str     w0, [sp, 28]    /// overflow = 1;
.L4:
        ldr     x0, [sp]
        ldr     w1, [sp, 24]
        str     w1, [x0]
        ldr     w0, [sp, 28]
        add     sp, sp, 32
        ret
        .size   addTwo, .-addTwo
        .ident  "GCC: Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

清单 15-9:编译器生成的 清单 15-8 中的 add_two 函数的汇编语言

如你所见,在 C 语言中检查溢出大约需要 20 条指令 ❶。在 清单 15-10 中,我将 add_two 函数重写为汇编语言,以便我可以使用 adds 指令进行加法运算,然后通过 nzcv 寄存器中的条件标志来检测溢出。

add_two.s

// Add two integers and output the sum; return overflow T or F.
// Calling sequence:
//    x0 <- address a, for output
//    w1 <- integer b
//    w2 <- integer c
//    Returns 1 for overflow, 0 for no overflow
        .arch armv8-a
// Code
        .text
        .align  2
        .global add_two
        .type   add_two, %function
add_two:
        adds    w1, w1, w2            // Add and set condition flags
        str     w1, [x0]              // Store output
     ➊ cinc    w0, wzr, vs           // Overflow flag
        ret                           // Back to caller

清单 15-10:带有溢出检测的 add_two 函数(汇编语言版)

我没有为这个非常简单的叶子函数创建栈帧,因为我们不太可能修改这个函数来调用其他函数。

我使用了 cinc 指令 ❶ 来读取 nzcv 寄存器中的溢出标志,并将 w0 寄存器加载为 01,具体取决于溢出标志是 0 还是 1

cinc—条件增量

cinc wd , ws , cond 将 ws 中的值移动到 wd 中,如果 cond 为真,则将值加 1。

cinc xd , xs , cond 将 xs 中的值移动到 xd 中,如果 cond 为真,则将值加 1。

cond 的可能值可以在 第十三章 的 表 13-1 中找到。

使用示例 15-10 中的汇编语言版本的add_two与示例 15-6 中的main函数一起,展示了编写汇编语言子函数的原因之一。它使我们能够访问 CPU 的一个特性,即nzcv寄存器中的V标志,而这种特性在我们使用的高级语言 C 中是无法访问的。使用汇编语言编写代码使我们能够确保在操作(本例中的加法)和标志检查之间没有其他指令会改变标志。

这个例子还说明了返回值的常见用法。输入和输出通常通过参数列表传递,关于计算的补充信息则通过返回值携带。

话虽如此,单纯地调用一个函数来加两个数字是低效的。在下一节中,我们将看看 C 语言的一个常见扩展,它允许我们直接在 C 代码中插入汇编语言。

使用内联汇编语言

和许多 C 编译器一样,gcc包含一个标准 C 语言的扩展,允许我们在 C 代码中嵌入汇编语言,通常称为内联汇编。这样做可能会很复杂。我们在这里看一个简单的例子。你可以在gcc.gnu.org/onlinedocs/gcc/Using-Assembly-Language-with-C.html中查看详细信息,或者使用info gcc命令并选择C 扩展 ▶ 使用汇编语言与 C ▶ 扩展汇编

gcc编译器使用以下通用格式来在 C 中嵌入汇编语言,这从asm关键字开始:

asm asm-qualifiers (assembly language statements 
               : output operands
               : input operands
               : clobbers);

asm-限定符用于帮助编译器优化 C 代码,这个话题超出了本书的范围。我们并没有要求编译器优化我们的 C 代码,因此我们不会使用 asm-限定符。

输出操作数是可能会被汇编语言语句改变的 C 变量,因此充当汇编语言语句的输出。输入操作数是汇编语言语句使用的 C 变量,但不会改变,因此充当汇编语言语句的输入。破坏的寄存器是被汇编语言语句显式改变的寄存器,从而告诉编译器这些寄存器可能发生的变化。

在示例 15-11 中,我使用内联汇编语言来检查加法中的溢出。

sum_ints.c

// Add two integers and show if there is overflow.

#include <stdio.h>

int main(void)
{
    int x, y, z, overflow;

    printf("Enter an integer: ");
    scanf("%i", &x);
    printf("Enter an integer: ");
    scanf("%i", &y);

 ➊ asm ("adds %w0, %w2, %w3\n"
        "cinc %w1, wzr, vs"
     ➋ : "=r" (z), "=r" (overflow)
     ➌ : "r" (x), "r" (y));

    printf("%i + %i = %i\n", x, y, z);
    if (overflow)
        printf("** Overflow occurred **\n");

    return 0;
}

示例 15-11:一个程序,用于加两个整数并显示是否溢出

这里代码的第一点需要注意的是,在汇编语言中放置adds指令非常重要,这样我们可以在指令执行后立即检查溢出❶。如果我们在 C 语言中进行加法,编译器会使用add指令,如在示例 15-9 中所示,但该指令不会设置条件标志。

每个汇编语言指令都有一个作为文本字符串的模板,字符串被引号包围❶。每条指令的操作数根据它们在输出:输入操作数列表中的相对位置编号,从 0 开始。我在汇编语言模板中为变量编号加上w前缀,告诉编译器这些是字(32 位)值。请记住,汇编语言代码是以行作为单位的,因此在每条汇编语言语句的末尾添加换行符\n是很重要的。最后一条汇编语言语句后不需要换行符。

注意

小心不要混淆操作数编号和寄存器编号。 %w0 是我们输出:输入操作数列表中第一个操作数的 32 位值,而 w0 x0 寄存器的低位 32 位。

每个输出或输入操作数的语法是:

"constraint " (C variable name )

在我们的程序中,z位于位置 0,overflow位于位置 1❷;x位于位置 2,y位于位置 3❸。

约束告诉编译器在汇编语言模板中可以使用哪种操作数。例如,"m"表示编译器应该使用内存位置,而"r"表示应该使用寄存器。通过在类型前加上=,我们告诉编译器我们的汇编语言会在该位置存储一个值。因此,"=r" (z)约束告诉编译器,它需要为%w0操作数使用一个寄存器,我们的汇编语言将在该寄存器中存储一个值,并将该值存储到 C 变量z中❷。"r" (x)约束则告诉编译器使用寄存器存储 C 变量x中的值,但我们的汇编语言不会改变该寄存器中的值❸。

请注意,当你使用内联汇编语言时,编译器可能会为你的 C 代码生成与汇编语言不兼容的汇编代码。最好生成整个函数的汇编语言(使用-S编译器选项),并仔细阅读它,确保函数按照你的意图执行。我们将在 Listing 15-12 中做这个。

sum_ints.s

        .arch armv8-a
        .file   "sum_ints.c"
        .text
        .section        .rodata
        .align  3
.LC0:
        .string "Enter an integer: "
        .align  3
.LC1:
        .string "%i"
        .align  3
.LC2:
        .string "%i + %i = %i\n"
        .align  3
.LC3:
        .string "** Overflow occurred **"
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     x29, x30, [sp, -32]!
        mov     x29, sp
        adrp    x0, .LC0
        add     x0, x0, :lo12:.LC0
        bl      printf
        add     x0, sp, 20
        mov     x1, x0
        adrp    x0, .LC1
        add     x0, x0, :lo12:.LC1
        bl      __isoc99_scanf
        adrp    x0, .LC0
        add     x0, x0, :lo12:.LC0
        bl      printf
        add     x0, sp, 16
        mov     x1, x0
        adrp    x0, .LC1
        add     x0, x0, :lo12:.LC1
        bl      __isoc99_scanf
     ➊ ldr     w0, [sp, 20]    /// w0 <- x
        ldr     w1, [sp, 16]    /// w1 <- y
#APP
// 14 "sumInts.c" 1
        adds w1, w0, w1
csinc w0, wzr, wzr, vc
// 0 "" 2
#NO_APP
     ➋ str     w1, [sp, 28]    /// z <- result of addition
        str     w0, [sp, 24]    /// overflow <- overflow flag
        ldr     w0, [sp, 20]
        ldr     w1, [sp, 16]
        ldr     w3, [sp, 28]
        mov     w2, w1
        mov     w1, w0
        adrp    x0, .LC2
        add     x0, x0, :lo12:.LC2
        bl      printf
        ldr     w0, [sp, 24]
        cmp     w0, 0
        beq     .L2
        adrp    x0, .LC3
        add     x0, x0, :lo12:.LC3
        bl      puts
.L2:
        mov     w0, 0
        ldp     x29, x30, [sp], 32
        ret
        .size   main, .-main
        .ident  "GCC: Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

Listing 15-12:编译器生成的内联汇编代码

我们告诉编译器,我们插入的汇编语言使用寄存器作为输入("r"),因此它将 C 变量的值加载到寄存器中❶。类似地,我们指定汇编语言中的输出使用寄存器("=r"),编译器将寄存器中的值存储到 C 变量中❷。

如果你觉得内联汇编看起来很复杂,你没错。C 语言标准将内联汇编列为语言的常见扩展,但也指出扩展并不是标准的一部分。这意味着,如果你使用不同的编译器,即使在同一台计算机上,使用内联汇编的 C 代码可能无法正常工作。在大多数情况下,如果我需要使用汇编语言,我会像在示例 15-10 中那样使用单独的函数,这样的代码在不同编译器之间是可移植的。

轮到你了

15.2 修改示例 15-6、15-7 和 15-8 中的 C 程序,使用unsigned int类型,并在加法产生进位时提醒用户。它将如下声明变量:

unsigned int x = 0, y = 0, z;

读取和打印unsigned int值的格式代码是%u。这里有一个例子:

scanf("%u", &x);

15.3 修改示例 15-11 中的程序,使用unsigned int类型,并在加法产生进位时提醒用户。

15.4 修改示例 15-11 中的程序,为zoverflow变量使用register int类型。这样做会如何改变编译器生成的汇编语言?

你学到的内容

递归 提供了一些问题的简单而优雅的解决方案,但会使用大量的栈空间。

访问硬件特性 大多数编程语言不允许直接访问计算机的所有硬件特性。使用汇编语言子函数或内联汇编语言可能是最好的解决方案。

内联汇编 允许我们在 C 代码中嵌入汇编语言,传递帧指针和调用函数的返回地址。

现在你已经了解了一些在程序中使用函数的常见方法,我们将继续讨论乘法、除法和逻辑操作。在下一章中,你将学习如何将 ASCII 码表示的数字字符串转换为它们所代表的整数。

第十六章:位运算、乘法和除法指令**

Image

现在你已经了解了程序的组织结构,让我们把注意力转向计算。我将从解释逻辑运算符开始,这些运算符可以通过一种叫做掩码的技术来改变值中的单个位。

然后,我将继续讲解移位操作,这提供了一种通过 2 的幂进行乘法或除法的方法。在本章的最后两个小节中,我将介绍整数的算术乘法和除法。

位掩码

通常,将数据项看作是位模式而不是数值实体会更为合适。例如,如果你回顾一下第 2-5 表中在第二章的内容,你会看到,ASCII 中大写字母和小写字母之间的唯一区别是第 5 位比特,大写字母为0,小写字母为1。例如,字符m的 ASCII 码是0x6d,而M0x4d。如果你想编写一个函数,将一个字母字符串从小写转为大写,你可以将其视为数值差异 32。你需要确定字符的当前大小写,并决定是否通过减去 32 来改变它。

但还有一种更快的方法。我们可以通过使用逻辑按位运算和掩码(位掩码)来改变位模式。掩码是一种特定的位模式,可以用来将变量中的指定位设置为10,或者反转它们。例如,为了确保一个字母字符是大写,我们需要确保其第 5 位为0,掩码为11011111 = 0xdf。然后,使用前面提到的m0x6d 0xdf = 0x4d,即M。如果字符已经是大写,那么0x4d 0xdf = 0x4d,保持大写。这个解决方案避免了在转换前检查字符的大小写。

我们可以对其他操作使用类似的逻辑。为了将某一位设为1,需要在掩码中将适当的位位置设置为1,然后使用按位或操作。为了将某一位设置为0,则需要在该位置放置0,并在掩码中的其他位位置放置1,然后使用按位与操作。你可以通过将1放置在你想反转的每个位上,并在所有其他位置放置0,然后使用按位异或操作来反转位。

C 语言中的位掩码

列表 16-1 到 16-3 中的程序展示了如何使用掩码将文本字符串中的所有小写字母字符转换为大写。

uppercase.c

// Make an alphabetic text string uppercase.

   #include <stdio.h>
   #include "to_upper.h"
   #include "write_str.h"
   #include "write_char.h"
   #include "read_str.h"
➊ #define MAX 50
➋ #define ARRAY_SZ MAX+1

   int main(void)
   {
       char my_string[ARRAY_SZ];

       write_str("Enter up to 50 alphabetic characters: ");
    ➌ read_str(my_string, MAX);

       to_upper(my_string, my_string);
       write_str("All upper: ");
       write_str(my_string);
       write_char('\n');

       return 0;
   }

列表 16-1:将字母文本字符串转换为大写的程序

注意

本程序,以及书中随后的许多程序,使用了 read_str、write_char write_str 函数,这些函数是你在“轮到你”练习 14.4 中编写的,位于第 293 页。如果你愿意,也可以改用 C 标准库中的 gets、putchar puts 函数,但你需要在调用它们的书中函数中做出适当的更改,因为它们的行为略有不同。

在清单 16-1 中,我们使用#define为允许的最大字符数赋予一个符号名称❶。char数组需要多一个元素,以便存放终止的NUL字符❷。这两个#define的实例使得我们可以在一个地方轻松修改长度,确保char数组的长度正确,并将正确的值传递给read_str函数。

你在第十四章中学到,当使用一个参数的名称来将变量传递给函数时,它是按值传递的;一个变量值的副本被传递给被调用的函数。如果我们想传递变量的地址,需要使用&(地址操作符)。C 语言对数组名称的处理不同。当参数的名称是数组时,C 使用指针传递;数组起始地址会被传递给函数,而不是数组中所有值的副本。因此,我们在将数组作为参数传递给函数时使用&操作符❸。你将在第十七章中学到更多关于数组实现的知识。

这个main函数没有其他新内容,我们接着看to_upper子函数。清单 16-2 展示了这个函数的头文件。

to_upper.h

// Convert alphabetic letters in a C string to uppercase.

#ifndef TO_UPPER_H
#define TO_UPPER_H
int to_upper(char *dest_ptr, char *src_ptr);
#endif

清单 16-2:to_upper 函数的头文件

第一个参数src_ptr是要转换的文本字符串的地址,第二个参数dest_ptr是存放转换结果的地址。在清单 16-1 中,我们将相同的数组作为源数组和目标数组传递,因此to_upper将用新值替换数组中存储的字符。

清单 16-3 给出了to_upper的定义。

to_upper.c

   // Convert alphabetic letters in a C string to uppercase.

   #include "to_upper.h"
➊ #define UPMASK 0xdf
   #define NUL '\0'

   int to_upper(char *dest_ptr, char *src_ptr)
   {
       int count = 0;
       while (*src_ptr != NUL) {
        ➋ *dest_ptr = *src_ptr & UPMASK;
           src_ptr++;
           dest_ptr++;
           count++;
       }
    ➌ *dest_ptr = *src_ptr;   // Include NUL

       return count;
}

清单 16-3:一个将小写字母字符转换为大写的函数

为了确保第 5 位为0,我们使用一个在第 5 位为0,其他位为1的掩码❶。当当前字符不是NUL字符时,我们执行按位与操作,将源数组中的字符与掩码进行按位与运算,这样就会将第 5 位屏蔽,其他位在结果中保持不变❷。这个 AND 操作的结果存储在目标数组中。不要忘记包括输入文本字符串中的NUL字符❸!如果忘记这样做,这将是一个编程错误,并且在测试中不会显示出来,前提是输出存储位置后面的内存字节恰好是0x00NUL字符)。如果你改变了输入文本字符串的长度,内存中的下一个字节可能就不是0x00了。这个错误因此可能会以一种看似随机的方式出现。

尽管这个函数返回处理的字符数,但我们的main函数并没有使用返回值。调用函数不需要使用返回值,但我通常会在类似的函数中包含一个计数算法,用于调试目的。

Listing 16-4 显示了编译器为to_upper函数生成的汇编语言代码。

to_upper.s

        .arch armv8-a
        .file   "to_upper.c"
        .text
        .align  2
        .global to_upper
        .type   to_upper, %function
to_upper:
        sub     sp, sp, #32
        str     x0, [sp, 8]       /// Save destination address
        str     x1, [sp]          /// Save source address
        str     wzr, [sp, 28]     /// count = 0;
        b       .L2
.L3:
        ldr     x0, [sp, 8]
        ldrb    w0, [x0]
     ➊ and     w0, w0, -33       /// -33 = 0xffffffdf
        and     w1, w0, 255       /// 255 = 0x000000ff
        ldr     x0, [sp]
        strb    w1, [x0]
        ldr     x0, [sp, 8]
        add     x0, x0, 1
        str     x0, [sp, 8]
        ldr     x0, [sp]
        add     x0, x0, 1
        str     x0, [sp]
        ldr     w0, [sp, 28]
        add     w0, w0, 1
        str     w0, [sp, 28]
.L2:
        ldr     x0, [sp, 8]
        ldrb    w0, [x0]
        cmp     w0, 0
        bne     .L3
     ➋ ldr     x0, [sp, 8]       /// Copy NUL char
        ldrb    w1, [x0]
        ldr     x0, [sp]
        strb    w1, [x0]
        ldr     w0, [sp, 28]
        add     sp, sp, 32
        ret
        .size   to_upper, .-to_upper
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

Listing 16-4:编译器生成的 Listing 16-3 函数的汇编语言

你可能会注意到,我们的编译器将while循环结构化,以至于在循环终止后不需要复制NUL字符❷。但我们仍然需要编写正确的 C 代码,因为另一个编译器可能会对while循环使用不同的结构。

在从源char数组加载当前字符后,第一个and指令使用-33 = 0xffffffdf掩码对w0中的字进行掩码操作,这样第 5 位就变为0,从而确保字符为大写❶。第二个and指令使用0x000000ff掩码,确保我们得到一个 8 位的char值并存储在w0中。

将字符视为位模式而不是数值,可以让我们在不使用if语句先测试字符的大小写的情况下,将小写字符转换为大写,同时不改变大写字符。

你可能会想知道为什么编译器使用了两个and指令,而不是仅仅使用0xdf作为掩码并用一个and指令来完成。为了回答这个问题,让我们更详细地看一下基本的逻辑指令。

基本逻辑指令

逻辑指令按位操作——即它们在两个操作数的对应位位置上逐位操作。这三种基本的逻辑指令分别用于 AND、OR 或 XOR 操作。A64 指令集为每种操作提供了两种版本。移位寄存器版本允许在应用操作之前对源操作数之一进行移位。立即数版本仅允许某些特定位模式,稍后我会在描述指令后解释:

and—AND 移位寄存器

and wd , ws1 , ws2 {, shft amnt } 执行位与操作,将 ws1 和 ws2 中的值进行位与操作,并将结果存储在 wd 中。使用 shft amnt 选项,可以在 AND 操作前将 ws2 中的值左移 0 到 31 位。

and xd , xs1 , xs2 {, shft amnt } 执行位与操作,将 xs1 和 xs2 中的值进行位与操作,并将结果存储在 xd 中。使用 shft amnt 选项,可以在 AND 操作前将 xs2 中的值左移 0 到 63 位。

and—与立即数

and wd , ws , imm 执行位与操作,将 imm 的 32 位模式和 ws 中的值进行位与操作,并将结果存储在 wd 中。

and xd , xs , imm 执行位与操作,将 imm 的 64 位模式和 xs 中的值进行位与操作,并将结果存储在 xd 中。

orr—包含或移位寄存器

orr wd , ws1 , ws2 {, shft amnt } 执行位或操作,将 ws1 和 ws2 中的值进行位或操作,并将结果存储在 wd 中。使用 shft amnt 选项,可以在 OR 操作前将 ws2 中的值左移 0 到 31 位。

orr xd , xs1 , xs2 {, shft amnt } 执行位或操作,将 xs1 和 xs2 中的值进行位或操作,并将结果存储在 xd 中。使用 shft amnt 选项,可以在 OR 操作前将 xs2 中的值左移 0 到 63 位。

orr—包含或立即数

orr wd , ws , imm 执行位或操作,将 imm 的 32 位模式和 ws 中的值进行位或操作,并将结果存储在 wd 中。

orr xd , xs , imm 执行位或操作,将 imm 的 64 位模式和 xs 中的值进行位或操作,并将结果存储在 xd 中。

eor—排他或移位寄存器

eor wd , ws1 , ws2 {, shft amnt } 执行位异或操作,将 ws1 和 ws2 中的值进行位异或操作,并将结果存储在 wd 中。使用 shft amnt 选项,可以在 XOR 操作前将 ws2 中的值左移 0 到 31 位。

eor xd , xs1 , xs2 {, shft amnt } 执行位异或操作,将 xs1 和 xs2 中的值进行位异或操作,并将结果存储在 xd 中。使用 shft amnt 选项,可以在 XOR 操作前将 xs2 中的值左移 0 到 63 位。

eor—排他或立即数

eor wd , ws , imm 执行位异或操作,将 imm 的 32 位模式和 ws 中的值进行位异或操作,并将结果存储在 wd 中。

eor xd , xs , imm 执行位异或操作,将 imm 的 64 位模式和 xs 中的值进行位异或操作,并将结果存储在 xd 中。

表 16-1 列出了 shft 选项的允许值。

表 16-1: 移位寄存器逻辑指令中允许的 shft 值

shft 效果
lsl 逻辑左移
lsr 逻辑右移
asr 算术右移
ror 右旋转

逻辑移位将空出的位填充为 0。算术移位将空出的位填充为被移位值的高位副本。右旋转将所有位向右移,将低位移到高位位置。这些逻辑指令的 32 位版本不会改变目标寄存器中的 32 位高位,因此对 w 寄存器的移位或旋转仅应用于相应 x 寄存器的低 32 位。

你在第十二章中学到,这些逻辑指令中的 imm 值不能是 32 位或 64 位。为了查看这三条指令如何编码 imm 值,让我们看看第 16-4 节中第一个 and 指令的机器代码,如图 16-1 所示。

图片

图 16-1: 和 w0, w0, -33 指令的机器代码,在第 16-4 节中

N 字段中的 0 表示 32 位操作。1 表示 64 位操作。

imms 字段指定两个数字:重复模式中的位数和模式中连续 1 的数量。我将使用表 16-2 来解释这个过程。

表 16-2: 立即数逻辑指令中 imms 值的编码

N imms 模式大小(位) 1 的数量
0 11110x 2 1
0 1110xx 4 1–3
0 110xxx 8 1–7
0 10xxxx 16 1–15
0 0xxxxx 32 1–31
1 xxxxxx 64 1–63

对于 N 列中为 0 的行,imms 列中第一个 0 的位置(从左侧读取)指定模式中的位数。N 列中的 1 指定 64 位模式。

每一行中,x 位置上的二进制数字加 1 指定从右侧开始的连续 1 的数量。例如,如果 N0imms110010,则指定一个具有三个连续 1 的 8 位模式。这将产生 32 位的掩码 0x07070707

在图 16-1 中显示的 and 指令的 immr 字段中的 6 位数字指定在逻辑操作之前要应用于掩码的右移位数。

此指令中指定的掩码从一个包含 31 个连续 1 的 32 位模式 0x7fffffff 开始,该模式仅出现一次。然后,这个模式被右旋转 26 位,得到用于 AND 操作的掩码 0xffffffdf

接下来,我将向你展示如何直接在汇编语言中编写这个程序。

汇编语言中的位掩码

我们将在汇编语言版本中使用相同的掩码算法,但我们将使用更易于理解的标识符。汇编语言版本的 main 函数如第 16-5 节所示。

uppercase.s

// Make an alphabetic text string uppercase.
        .arch armv8-a
// Useful constant
        .equ    MAX,50                    // Character limit
// Stack frame
        .equ    the_string, 16
      ➊ .equ    FRAME, 80                 // Allows >51 bytes
// Code
        .text
        .section  .rodata
        .align  3
prompt:
        .string "Enter up to 50 alphabetic characters: "
result:
        .string "All upper: "
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     fp, lr, [sp, -FRAME]! // Create stack frame
        mov     fp, sp                // Set our frame pointer
        adr     x0, prompt            // Prompt message
        bl      write_str             // Ask for input

        add     x0, sp, the_string    // Place to store string
        mov     w1, MAX               // Limit number of input chars
        bl      read_str              // Get from keyboard

        add     x1, sp, the_string    // Address of string
     ➋ mov     x0, x1                // Replace the string
        bl      to_upper              // Do conversion

        adr     x0, result            // Show result
        bl      write_str
        add     x0, sp, the_string    // Converted string
        bl      write_str
        mov     w0, '\n'              // Nice formatting
        bl      write_char

        mov     w0, 0                 // Return 0
        ldp     x29, x30, [sp], FRAME // Delete stack frame
        ret

清单 16-5:将文本字符串转换为大写的程序

我们在堆栈上为字符数组分配了 50 字节。再加上 16 字节用于保存spfp,这使得帧的大小至少为 66 字节。为了保持堆栈指针在 16 字节边界上正确对齐,我们为堆栈帧分配了 80 字节❶。

我们将char数组的地址传递给to_upper函数,作为源和目标,这样它就会用新的值替换数组中的原始值❷。

我将使用与编译器相同的掩码算法来编写to_upper的汇编代码,但我将以不同的方式构建该函数。清单 16-6 展示了代码。

to_upper.s

// Convert alphabetic letters in a C string to uppercase.
// Calling sequence:
//    x0 <- pointer to result
//    x1 <- pointer to string to convert
//    Return number of characters converted.
        .arch armv8-a
// Useful constant
     ➊ .equ    UPMASK, 0xdf
// Program code
        .text
        .align  2
        .global to_upper
        .type   to_upper, %function
to_upper:
        mov     w2, wzr               // counter = 0
loop:
     ➋ ldrb    w3, [x1]              // Load character
        cbz     w3, allDone           // All done if NUL char
     ➌ movz    w4, UPMASK            // If not, do masking
        and     w3, w3, w4            // Mask to upper
        strb    w3, [x0]              // Store result
        add     x0, x0, 1             // Increment destination pointer,
        add     x1, x1, 1             //   source pointer,
        add     w2, w2, 1             //   and counter,
        b       loop                  //   and continue
done:
        strb    w3, [x0]              // Terminating NUL got us here
        mov     w0, w2                // Return count
        ret                           // Back to caller

清单 16-6:将文本转换为大写的程序

我们使用寄存器w2w3w4来存储局部变量,而不是将它们放在堆栈帧中。标准规定我们不需要为调用函数保存这些寄存器的内容。

ldrb指令将字符加载到w寄存器中❷。我们需要使用 32 位的掩码,以匹配寄存器的宽度。该算法的正确掩码是0x000000df❶。编译器使用两个掩码来实现正确的结果:

and     w3, w3, -33
and     w3, w3, 255

ldrb指令将寄存器中的 24 个高位清零,因此我们只需使用第一条指令。但这可能会让维护此代码的人感到困惑,因为该算法处理的是位模式,而不是整数。将指令写成这样

and     w3, w3, 0xffffffdf

可能会更加混淆。

我们使用movz指令将正确的掩码加载到寄存器中,这清晰地表明了我们的意图❸。然后,我们使用and指令的寄存器形式来掩码出字符中的小写位。在下一节中,我将向你展示如何通过移位操作将值乘以或除以二的幂。

你的回合

16.1     编写一个汇编语言程序,将所有字母字符转换为小写。

16.2     编写一个汇编语言程序,将所有字母字符的大小写转换为相反的大小写。

16.3     编写一个汇编语言程序,将所有字母字符转换为大写和小写。你的程序还应该在显示了大写和小写转换后,展示用户的原始输入字符串。

移位操作

有时候,能够将变量中的所有位向左或向右移位是很有用的。如果变量是整数,将所有位左移一个位置实际上是将整数乘以二,而将它们右移一个位置则实际上是将其除以二。通过使用左右移位来进行乘法/除法运算,是非常高效的,尤其是对二的幂的乘除运算。

在 C 语言中

我将通过展示一个程序来讲解移位操作,该程序从键盘读取以十六进制输入的整数并将其存储为long int。该程序最多读取八个十六进制字符,01、...、f,每个字符为 8 位 ASCII 码,表示一个 4 位整数:0、1、...、15。

清单 16-7 显示了该程序的main函数。

convert_hex.c

// Get a hex number from the user and store it as an int.

#include <stdio.h>
#include "write_str.h"
#include "read_str.h"
#include "hex_to_int.h"

#define MAX 8
#define ARRAY_SZ MAX+1

int main()
{
    char the_string[ARRAY_SZ];
    int the_int;

    write_str("Enter up to 8 hex characters: ");
    read_str(the_string, MAX);

    hex_to_int(&the_int, the_string);
    printf("0x%x = %i\n", the_int, the_int);
    return 0;
}

清单 16-7:一个将十六进制输入转换为 int 的程序

在“轮到你了”练习 14.4 中,你设计了read_str函数来限制它在传递给它的char数组中存储的字符数。如果用户输入超过八个字符,read_str将用NUL字符终止字符串,并丢弃多余的字符。

清单 16-8 显示了hex_to_int函数的头文件。

hex_to_int.h

// Convert a hex character string to an int.
// Return number of characters.

#ifndef HEX_TO_INT_H
#define HEX_TO_INT_H
int hex_to_int(int *int_ptr, char *string_ptr);
#endif

清单 16-8: hex_to_int 函数的头文件

头文件声明了hex_to_int函数,该函数接受两个指针。char指针作为输入,long int指针作为主输出的位置。hex_to_int函数还返回一个int值,表示它转换的字符数量。

清单 16-9 显示了hex_to_int函数的定义。

hex_to_int.c

// Convert a hex character string to an int.
// Return number of characters.

#include "hex_to_int.h"
#define GAP 0x07
#define INTPART 0x0f  // Also works for lowercase
#define NUL '\0'

int hex_to_int(int *int_ptr, char *string_ptr)
{
    char current;
    int result;
    int count;

    count = 0;
 ➊ result = 0;
    current = *string_ptr;
    while (current != NUL) {
     ➋ if (current > '9') {
            current -= GAP;
        }
     ➌ current = current & INTPART;
     ➍ result = result << 4;
     ➎ result |= current;
        string_ptr++;
        count++;
        current = *string_ptr;
    }

    *int_ptr = result;
    return count;
}

清单 16-9:C 语言中的 hex_to_int 函数

我们的程序首先将 32 位输出设置为0❶。然后,从最重要的十六进制字符(用户输入的第一个字符)开始,程序将每个 8 位的 ASCII 码转换为对应的 4 位整数。

查看表格 2-4 和 2-5 以及第二章,我们可以看到数字字符的 ASCII 码范围是0x300x39,小写字母字符的范围是0x610x66。从字母字符中减去0x27的差值,得到的位模式是0x300x31、...、0x390x3a、...、0x3f,这些是输入字符的位模式❷。当然,用户也可以输入大写字母字符,其范围是0x410x46。减去0x27后,得到的位模式是0x300x31、...、0x390x1a、...、0x1f。每个十六进制字符代表 4 位,如果我们观察减去0x27后低 4 位的值,无论用户输入小写字母还是大写字母,其值是相同的。我们可以通过使用&(C 语言中的按位与运算符)和0x0f掩码将字符码转换为 4 位整数❸。

接下来,我们将累积值中的所有位左移 4 位,为下一个由十六进制字符表示的 4 个位腾出空间❹。左移操作会在四个最低有效位位置留下0,因此我们可以使用|(按位或运算符)将current中的 4 个位复制到这些位置❺。

current变量的类型是charresult的类型是int。在 C 语言中,对于算术和逻辑运算,较窄值的宽度会自动扩展,以匹配较宽值的宽度❺。

让我们看看编译器为hex_to_int函数生成的汇编语言,见清单 16-10。

hex_to_int.s

        .arch armv8-a
        .file   "hex_to_int.c"
        .text
        .align  2
        .global hex_to_int
        .type   hex_to_int, %function
hex_to_int:
        sub     sp, sp, #32
        str     x0, [sp, 8]
        str     x1, [sp]
        str     wzr, [sp, 20]
        str     wzr, [sp, 24]
        ldr     x0, [sp]
        ldrb    w0, [x0]
        strb    w0, [sp, 31]
        b       .L2
.L4:
        ldrb    w0, [sp, 31]
        cmp     w0, 57        /// > '9'?
        bls     .L3
        ldrb    w0, [sp, 31]  /// Yes
        sub     w0, w0, #7    /// Remove gap
        strb    w0, [sp, 31]
.L3:
     ➊ ldrb    w0, [sp, 31]
     ➋ and     w0, w0, 15    /// Leave only 4 bits
        strb    w0, [sp, 31]
        ldr     w0, [sp, 24]
     ➌ lsl     w0, w0, 4     /// Room for 4 bits
        str     w0, [sp, 24]
        ldrb    w0, [sp, 31]
        ldr     w1, [sp, 24]
        orr     w0, w1, w0    /// Copy new 4 bits
        str     w0, [sp, 24]
        ldr     x0, [sp]
        add     x0, x0, 1
        str     x0, [sp]
        ldr     w0, [sp, 20]
        add     w0, w0, 1
        str     w0, [sp, 20]
        ldr     x0, [sp]
        ldrb    w0, [x0]
        strb    w0, [sp, 31]
.L2:
        ldrb    w0, [sp, 31]
        cmp     w0, 0         /// NUL
        bne     .L4
        ldr     x0, [sp, 8]
        ldr     w1, [sp, 24]
        str     w1, [x0]
        ldr     w0, [sp, 20]
        add     sp, sp, 32
        ret
        .size   hex_to_int, .-hex_to_int
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

列表 16-10:编译器生成的汇编语言,用于 列表 16-9 中的 C 函数

编译器使用 ldrb 指令将字符加载到 w 寄存器中 ❶。这会将第 8 位到第 31 位设置为 0,有效地将 8 位的 char 类型转换为 32 位的 int 类型。即使在 列表 16-9 中没有显式的类型转换,编译器也会执行这样的转换,但显式的类型转换更清晰地表达了我们的意图,而且不会影响代码的效率。

该算法中的掩码是四个连续的 1,因此可以使用 and 指令的立即数形式 ❷。

我们在这里看到了一条新指令,lsl ❸。正如你可能猜到的,这条指令将 x0 中的值左移 4 位,并将结果加载到 x1 中。让我们来看一些常见的移位指令:

lsl—逻辑左移立即数

lsl wd , ws , amnt 将 ws 中的值按 amnt 位左移,腾出的位用 0 填充,并将结果加载到 wd 中。

lsl xd , xs , amnt 将 xs 中的值按 amnt 位左移,腾出的位用 0 填充,并将结果加载到 xd 中。

lsr—逻辑右移立即数

lsr wd , ws , amnt 将 ws 中的值按 amnt 位右移,腾出的位用 0 填充,并将结果加载到 wd 中。

lsr xd , xs , amnt 将 xs 中的值按 amnt 位右移,腾出的位用 0 填充,并将结果加载到 xd 中。

asr—算术右移立即数

asr wd , ws , amnt 将 ws 中的值按 amnt 位右移,将最高位复制到腾出的位,并将结果加载到 wd 中。

asr xd , xs , amnt 将 xs 中的值按 amnt 位右移,将最高位复制到腾出的位,并将结果加载到 xd 中。

ror—立即数右旋

ror wd , ws , amnt 将 ws 中的值按 amnt 位右移,将低位的值复制到腾出的高位,并将结果加载到 wd 中。

ror xd , xs , amnt 将 xs 中的值按 amnt 位右移,将低位的值复制到腾出的高位,并将结果加载到 xd 中。

接下来,我将采用与之前的大小写转换 C 程序相似的方法编写十六进制到整数的汇编语言转换程序。

在汇编语言中

我将从 main 函数的堆栈框架图开始设计我们的 convert_hex 程序,如 图 16-2 所示。

Image

图 16-2: convert_hex 程序的堆栈框架

通过这个图示,main 函数的汇编语言设计变得非常直观,如 列表 16-11 所示。

convert_hex.s

// Get a hex number from the user and store it as an int.
        .arch armv8-a
// Useful constant
        .equ    MAX, 8
// Stack frame
        .equ    the_int, 16
        .equ    the_string, 20
     ➊ .equ    FRAME, 32
// Code
        .text
        .section  .rodata
        .align  3
prompt:
        .string "Enter up to 8 hex characters: "
format:
        .string "0x%x = %i\n"
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     fp, lr, [sp, -FRAME]! // Create stack frame
        mov     fp, sp                // Our frame pointer

        adr     x0, prompt            // Prompt message
        bl      write_str             // Ask for input

        add     x0, sp, the_string    // Place to store string
        mov     w1, MAX               // Limit number of input chars
        bl      read_str              // Get from keyboard

        add     x1, sp, the_string    // Address of string
        add     x0, sp, the_int       // Place to store int
        bl      hex_to_int            // Do conversion

        ldr     w2, [sp, the_int]     // Load int
        ldr     w1, [sp, the_int]     // printf shows this copy in hex
        adr     x0, format            // Format string
        bl      printf

        mov     w0, 0                 // Return 0
        ldp     x29, x30, [sp], FRAME // Delete stack frame
        ret

列表 16-11:用于将十六进制值转换为 int main 函数汇编语言

main 函数中没有什么新内容。除了保存 fplr 寄存器的 16 字节,以及用于 int 的 4 字节外,我们还需要在堆栈帧中为十六进制文本字符串分配 9 字节,堆栈帧的大小至少为 29 字节。由于帧大小必须是 16 的倍数以保持栈指针正确对齐,因此我们使用 32 字节 ❶。

我们将为汇编语言版本的 hex_to_int 使用寄存器来存储变量。我们的堆栈帧将非常简单,因此我们不需要图表来设计它。图 16-12 显示了我们的函数。

hex_to_int.s

// Convert a hex character string to an int.
// Calling sequence:
//    x0 <- pointer to int result
//    x1 <- pointer to hex character string to convert
//    Return number of characters converted.
        .arch armv8-a
// Useful constants
        .equ    INTPART, 0x0f         // Also works for lowercase
        .equ    GAP, 0x07             // Between numerals and alpha
// Program code
        .text
        .align  2
        .global hex_to_int
        .type   hex_to_int, %function
hex_to_int:
        mov     w2, wzr               // result = 0
        mov     w3, wzr               // counter = 0
convert:
        ldrb    w4, [x1]              // Load character
        cbz     w4, done              // NUL character?
        cmp     w4, '9                // Numeral?
        b.ls    no_gap                // Yes
        sub     w4, w4, GAP           // No, remove gap
no_gap:
        and     w4, w4, INTPART       // 4-bit integer
        lsl     w2, w2, 4             // Make room for it
        orr     w2, w2, w4            // Insert new 4-bit integer
        add     x1, x1, 1             // Increment source pointer
        add     w3, w3, 1             //   and counter
        b       convert               //   and continue
done:
        str     w2, [x0]              // Output result
        mov     w0, w3                // Return count
        ret                           // Back to caller

清单 16-12: hex_to_int 函数的汇编语言版本

移位操作适用于乘以和除以 2 的幂,但我们也需要能够乘以和除以其他数字。在接下来的两节中,我们将讨论乘以和除以任意整数,并将浮动和小数值的考虑推迟到第十九章。

你的回合

16.4     修改清单 16-7 中的 C main 函数,使其显示转换的十六进制字符的数量。使用清单 16-12 中的汇编语言 hex_to_int 函数进行转换。

16.5     编写一个汇编语言程序,将八进制输入转换为 long int

乘法

在本节中,我将讨论乘以不是 2 的幂的整数。这可以通过循环实现,但大多数通用 CPU 都包含乘法指令。

在 C 语言中

让我们修改清单 16-7 至 16-9 中的 C 程序,将十进制数字文本字符串转换为无符号整数。当从十六进制文本字符串转换时,我们将累积值左移了 4 位,从而将其乘以 16。我们将使用相同的算法来转换十进制文本字符串,但这次我们将乘以 10 而不是 16。

清单 16-13 至 16-15 显示了 C 程序。

convert_dec.c

   // Get a decimal number from the user and store it as an int.

   #include <stdio.h>
   #include "write_str.h"
   #include "read_str.h"
   #include "dec_to_int.h"
➊ #define MAX 11
   #define ARRAY_SZ MAX+1

   int main(void)
   {
       char the_string[ARRAY_SZ];
       int the_int;

       write_str("Enter an integer: ");
       read_str(the_string, MAX);

       dec_to_int(&the_int, the_string);
       printf("\"%s\" is stored as 0x%x\n", the_string, the_int);

       return 0;
   }

清单 16-13:将十进制输入转换为 int 的程序

这个 main 函数与将十六进制输入转换为 int 的函数非常相似,但 int 中的最大字符数为 10。我们需要将 MAX 设置为 11 个字符,以便允许整数前面有可能的正负号 ❶。

清单 16-14 显示了 dec_to_int 函数的头文件。

hex_to_int.h

// Convert a decimal character string to an int.
// Return number of decimal characters.

#ifndef DEC_TO_INT_H
#define DEC_TO_INT_H
int dec_to_int(int *int_ptr, char *string_ptr);
#endif

清单 16-14: dec_to_int 函数的头文件

头文件声明了 dec_to_int 函数,该函数接受两个指针:char 指针作为输入,int 指针作为主要输出的位置。dec_to_int 函数还返回一个 int,表示它转换的字符数。

清单 16-15 显示了 dec_to_int 函数的定义。

dec_to_int.c

   // Convert a decimal character string to an unsigned int.
   // Return number of characters.

   #include <stdio.h>
   #include <stdbool.h>
   #include "dec_to_int.h"
   #define INTMASK 0x0f
   #define RADIX 10
   #define NUL '\0'
➊ int dec_to_int(int *int_ptr, char *string_ptr)
   {
       bool negative = false;       // Assume positive
       int result = 0;
       int count = 0;

    ➋ if (*string_ptr == '-') {
           negative = true;
           string_ptr++;
    ➌ } else if (*string_ptr == '+') {
           string_ptr++;
       }

       while (*string_ptr != NUL) {
        ➍ result = RADIX * result;
        ➎ result += (int)(*string_ptr & INTMASK);
           string_ptr++;
           count++;
       }

       if (negative) {
           result = -result;
       }
       *int_ptr = result;
       return count;
   }

清单 16-15:C 语言中的 dec_to_int 函数

我们需要做的第一件事是检查数字前面是否有+号或–号。如果是–号,我们将negative标志设置为true❷。用户输入正数时通常不会加+号,但如果有+号,我们仍然需要将指针递增到字符串中的第一个数字字符❸。

RADIX常量不是 2 的幂,因此我们不能通过简单的左移来进行乘法运算❹。

string_ptr变量指向一个char❶。这个char被掩码并加到result中,result是一个int。大多数编译器会在将char值赋给int变量时提升其类型,但我更倾向于显式地将其强制转换为所需类型❺。

让我们看看编译器如何执行乘以 10 的操作。它在清单 16-16 中展示。

dec_to_int.s

        .arch armv8-a
        .file   "dec_to_int.c"
        .text
        .align  2
        .global dec_to_int
        .type   dec_to_int, %function
dec_to_int:
        sub     sp, sp, #32
        str     x0, [sp, 8]     /// Save int_ptr
        str     x1, [sp]        /// Save string_ptr
        strb    wzr, [sp, 31]   /// negative = false;
        str     wzr, [sp, 24]   /// result = 0;
        str     wzr, [sp, 20]   /// count = 0;
        ldr     x0, [sp]
        ldrb    w0, [x0]
        cmp     w0, 45
        bne     .L2
        mov     w0, 1
        strb    w0, [sp, 31]
        ldr     x0, [sp]
        add     x0, x0, 1
        str     x0, [sp]
        b       .L4
.L2:
        ldr     x0, [sp]
        ldrb    w0, [x0]
        cmp     w0, 43
        bne     .L4
        ldr     x0, [sp]
        add     x0, x0, 1
        str     x0, [sp]
        b       .L4
.L5:
        ldr     w1, [sp, 24]
        mov     w0, w1
     ➊ lsl     w0, w0, 2       /// 4 * result
        add     w0, w0, w1      /// (4 * result) + result
        lsl     w0, w0, 1       /// 2 * ((4 * result) + result)
        str     w0, [sp, 24]    /// result = 10 * result;
        ldr     x0, [sp]
        ldrb    w0, [x0]
        and     w0, w0, 15
        ldr     w1, [sp, 24]
        add     w0, w1, w0
        str     w0, [sp, 24]
        ldr     x0, [sp]
        add     x0, x0, 1
        str     x0, [sp]
        ldr     w0, [sp, 20]
        add     w0, w0, 1
        str     w0, [sp, 20]
.L4:
        ldr     x0, [sp]
        ldrb    w0, [x0]
        cmp     w0, 0
        bne     .L5
        ldrb    w0, [sp, 31]    /// Check negative flag
        cmp     w0, 0
        beq     .L6
        ldr     w0, [sp, 24]
     ➋ neg     w0, w0
        str     w0, [sp, 24]
.L6:
        ldr     x0, [sp, 8]
        ldr     w1, [sp, 24]
        str     w1, [x0]
        ldr     w0, [sp, 20]
        add     sp, sp, 32
        ret
        .size   dec_to_int, .-dec_to_int
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

清单 16-16:由编译器生成的 dec_to_int 函数的汇编语言,在清单 16-15 中展示

乘法指令通常需要更长时间来执行,因此编译器使用了移位和加法的组合来将result乘以 10❶。这一四条指令序列等同于以下的 C 语句:

result = 2 * ((4 * result) + result);

这条语句中的两次乘法是按 2 的幂进行的,因此它们通过简单的左移来完成。

注意,这个转换算法中的算术运算是无符号的。我们在数字字符串的开头检查了–号,如果有–号,在转换结果的末尾我们会对结果取反❷。

使用移位和加法进行乘法运算是有限制的。为了了解乘法指令的工作方式,我们将使用其中一种来改写我们的十进制转换程序的汇编语言版本。

在汇编语言中

A64 架构有十多种不同的乘法指令。在本书中,我只会展示其中的一些;你可以在手册中阅读更多的指令。

图 16-3 显示了我们程序中将十进制文本字符串转换为int类型的main函数的汇编语言版本的堆栈帧设计。

图片

图 16-3: convert_dec 程序的堆栈帧

我们的main函数的汇编语言版本与 C 版本类似,展示在清单 16-17 中。

convert_dec.s

// Get a decimal number from the user and store it as an int.
        .arch armv8-a
// Useful constant
        .equ    MAX, 12               // Character storage limit
// Stack frame
        .equ    the_int, 16
        .equ    the_string, 20
        .equ    FRAME, 32
// Code
        .text
        .section  .rodata
        .align  3
prompt:
        .string "Enter an integer: "
        .align  3
format:
        .string "\"%s\" is stored as 0x%x\n"
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     fp, lr, [sp, -FRAME]! // Create stack frame
        mov     fp, sp                // Our frame pointer
        adr     x0, prompt            // Prompt message
        bl      write_str             // Ask for input

        mov     w1, MAX               // Limit number of input chars
        add     x0, sp, the_string    // Place to store string
        bl      read_str              // Get from keyboard

        add     x1, sp, the_string    // Address of string
        add     x0, sp, the_int       // Place to store the int
        bl      dec_to_int            // Do conversion
        ldr     w2, [sp, the_int]     // Load the int
        add     x1, sp, the_string    // Input text string
        adr     x0, format            // Format message
        bl      printf                // Show results
        mov     w0, wzr               // Return 0
        ldp     x29, x30, [sp], FRAME // Delete stack frame
        ret

清单 16-17:一个将十进制值转换为 int 类型的汇编语言程序

清单 16-18 显示了我们dec_to_int的汇编语言版本。

dec_to_int.s

// Convert a decimal text string to an int.
// Calling sequence:
//    x0 <- place to store int
//    x1 <- pointer to string
//    Return number of characters.
        .arch armv8-a
// Useful constants
        .equ    RADIX,10
        .equ    INTMASK,0x0f
// Program code
        .text
        .align  2
        .global dec_to_int
        .type   dec_to_int, %function
dec_to_int:
        mov     w2, wzr               // count = 0
        mov     w3, wzr               // result = 0
        mov     w4, wzr               // negative = false
        mov     w5, RADIX             // Handy to have in reg

        ldrb    w6, [x1]              // Load first character
        cmp     w6, '-                // Minus sign?
        b.ne    check_pos             // No, check for plus sign
        mov     x4, 1                 // Yes, negative = true
        add     x1, x1, 1             // Increment string pointer
        b       convert               //   and convert numerals
check_pos:
        cmp     w6, '+                // Plus sign?
        b.ne    convert               // No, convert numerals
        add     x1, x1, 1             // Yes, skip over it

convert:
        ldrb    w6, [x1]              // Load character
        cbz     w6, check_sign        // NUL char?
        and     w6, w6, INTMASK       // No, mask to integer
     ➊ mul     w3, w3, w5            // result * RADIX
        add     w3, w3, w6            // Add new integer
        add     w2, w2, 1             // count++
        add     x1, x1, 1             // string_ptr++
        b       convert               //   and continue
check_sign:
        cbz     w4, positive          // Check negative flag
        neg     w3, w3                // Negate if flag is true
positive:
        str     w3, [x0]              // Output result
        mov     w0, w2                // Return count
        ret                           // Back to caller

清单 16-18:汇编语言中的 dec_to_int 函数

我们不再使用移位加法算法来乘以 10,而是使用mul指令❶。我们来看看这种指令的几种变体:

mul—乘法寄存器

mul wd , ws1 , ws2ws1 和ws2 中的值相乘,并将结果存储在w`d 中。

mul xd , xs1 , xs2xs1 和xs2 中的值相乘,并将结果存储在x`d 中。

当乘以两个n位整数时,结果的宽度可以达到 2n位。这里不提供正式证明,但你可能会被这样一个例子说服:考虑最大的 3 位数111,加上1得到1000。从1000 * 1000 = 1000000,我们可以得出结论111 * 111 <= 111111。更准确地,111 * 111 = 110001

如果mul指令的结果超出了目标寄存器的位宽,那么高位会丢失。例如,如果w2包含0xcccccccc,而w1包含0x00000002,那么mul w0, w1, w2将在x0寄存器中得到0x0000000099999998。如果我们将这些值视为无符号整数,正确的结果应该是0x0000000199999998,如果将其视为有符号整数,则结果应该是0xffffffff99999998。但是当指令写入寄存器的w部分时,该寄存器的高 32 位会被设置为0。因此,当两个 32 位整数的乘积不在 0≤乘积≤ 4,294,967,295(无符号整数)或–2,147,483,648≤乘积≤ +2,147,483,647(有符号整数)范围内时,mul指令将产生错误结果。

为了处理这个尺寸问题,A64 架构包括了两条乘法指令,它们使用 64 位的目标寄存器来处理两个 32 位数相乘超过 32 位范围的情况:

umull—无符号长整型乘法

umull xd , ws1 , ws2ws1 和ws2中的值相乘,并将结果加载到xd 中。如果结果的大小没有占满xd 的全部 64 位,那么未占满的高位将填充为0

smull—带符号长整型乘法

smull xd , ws1 , ws2ws1 和ws2中的值相乘,并将结果加载到xd 中。如果结果的大小没有占满xd 的全部 64 位,那么未占满的高位将用结果的最高位进行填充,从而实现符号扩展。

继续我们之前的例子,如果w2包含0xcccccccc,而w1包含0x00000002,那么umull x0, w1, w2将在x0中得到0x0000000199999998,而smull x0, w1, w2将在x0中得到0xffffffff99999998

如果我们使用 64 位的long int类型,且无法证明乘法结果永远不会超过 64 位,那么 A64 架构包含了两条乘法指令,在乘两个 64 位数时,能够提供高 64 位的结果:

umulh—无符号高位乘法

umulh xd , xs1 , xs2xs1 和xs2中的值相乘,并将结果的高 64 位加载到xd 中,且高位扩展为零。

smulh—带符号高位乘法

smulh xd , xs1 , xs2xs1 和xs2中的值相乘,并将结果的高 64 位加载到xd 中,且高位扩展为符号位。

因此,乘两个 64 位整数需要两条指令。如果我们将x0x1中的两个整数视为无符号整数,我们将使用以下两条指令:

mul     x3, x0, x1
umulh   x2, x0, x1

如果我们将x0x1中的两个整数视为有符号数,我们将使用以下两条指令:

mul     x3, x0, x1
smulh   x2, x0, x1

在这两种情况下,结果是一个 128 位的整数,高位 64 位存储在寄存器x2中,低位 64 位存储在x3中。

乘法指令不会影响nzcv寄存器中的条件标志。我们需要仔细分析我们的算法,以适应所有可能的值,并使用适当的指令。

接下来,我将介绍除法,它是乘法的逆运算。

你的回合

16.6     编写一个dec_to_uint汇编语言函数,将一个无符号十进制数从文本字符串格式转换为unsigned int格式。

16.7     我断言 C 语句result = 2 * ((4 * result) + result);等价于清单 16-15 中的result = RADIX * result;语句。请在清单 16-15 中做出该更改,并将编译器生成的汇编语言与清单 16-16 中的进行比较。

除法

在乘法中,当我们相乘两个n位的数字时,我们担心结果会是 2n位宽。而在除法中,商的宽度不会超过被除数,但除法的速度比乘法慢得多。在这一部分,你将学习一种可以加速除法的算法,特别是当除数是常量时。整数除法可能会有余数,这个余数也可能需要计算。

我将从一个 C 函数开始,该函数将一个int转换为它所表示的数字文本字符串,这是之前dec_to_int函数的逆操作。

在 C 语言中

我们的main函数将从用户读取一个整数,减去 123 并显示结果。我们的子函数int_to_dec将使用除法算法将一个 32 位的int转换为表示它的文本字符串,这样main函数就可以显示结果。清单 16-19 至 16-21 展示了完整的程序。

sub_123.c

// Read an int from the user, subtract 123, and display the result.

#include "write_str.h"
#include "write_char.h"
#include "read_str.h"
#include "dec_to_int.h"
#include "int_to_dec.h"
#define MAX 11
#define ARRAY_SZ MAX+1

int main(void)
{
    char the_string[ARRAY_SZ];
    int the_int;

    write_str("Enter an integer: ");
    read_str(the_string, MAX);

    dec_to_int(&the_int, the_string);
    the_int -= 123;
    int_to_dec(the_string, the_int);

    write_str("The result is: ");
    write_str(the_string);
    write_char('\n');

    return 0;
}

清单 16-19:一个从 int 中减去 123 的程序

这个程序的main函数非常简单。我们使用清单 16-18 中的dec_to_int汇编语言版本将用户输入转换为int。然后,我们从int中减去 123,将结果转换为文本字符串表示,并显示结果。

清单 16-20 展示了int_to_dec函数的头文件。

int_to_dec.h

// Convert an int to its decimal text string representation.
// Return number of characters.

#ifndef INT_TO_DEC_H
#define INT_TO_DEC_H
int int_to_dec(char *dec_string, int the_int);
#endif

清单 16-20:int_to_dec函数的头文件

头文件声明了int_to_dec函数。int是输入,char指针是主要输出的存储位置。int_to_dec函数还返回一个int,表示输出字符串中的字符数。

清单 16-21 展示了int_to_dec函数的定义。

int_to_dec.c

// Convert an int to its decimal text string representation.
// Return number of characters.

#include "int_to_dec.h"
#define ARRAY_SZ 12
#define ASCII 0x30
#define RADIX 10
#define NUL '\0'

int int_to_dec(char *dec_string, int the_int)
{
    char reverse[ARRAY_SZ];
    char digit;
    char *_ptr;
    unsigned int working;
    int count = 0;

 ➊ if (the_int < 0) {
        the_int = -the_int;
        *dec_string = '-';
        count++;
        decString++;
    }
    ptr = reverse;                   // Point to local char array
 ➋ *ptr = NUL;                      // Start with termination char
 ➌ working = (unsigned int)the_int; // Use unsigned arithmetic
    do {
        ptr++;
     ➍ digit = (char)(working % RADIX);
        *ptr = ASCII | digit;
        working = working / RADIX;
    } while (working > 0);

    count = 0;
    if (negative) {
        *dec_string = '-';
        count++;
        dec_string++;
    }
 ➎ do {                            // Reverse string
        *dec_string = *ptr;
        count++;
        dec_string++;
        ptr--;
     ➏ } while (*ptr != NUL);
    *dec_string = *ptr;             // Copy termination char

    return count;
}

清单 16-21:C 语言中的int_to_dec函数

我们用于查找表示int十进制字符的算法涉及将int反复除以 10。%操作符计算除法的余数❹。对于正整数,余数将是一个 32 位的int,范围从 0 到 9,或者0x000000000x00000009,即最低位十进制数字的值。

然而,对于负整数,余数将是一个int,范围从 0 到-9,或者0x000000000xfffffff7。该范围内的负值需要使用不同的算法将它们转换为相应的 ASCII 数字字符。我们这里使用的解决方案是取负输入整数的相反数,结果文本前加上“-”符号,并转换为正结果❶。

该算法适用于所有 32 位负数,除了一个:-2,147,483,648。二进制补码格式中没有 32 位的+2,147,483,648;取-2,147,483,648 的相反数仍然是-2,147,483,648。我们的解决方案是将取负后的int转换为unsigned int❸。这不会改变值0x80000000的位模式,但它告诉编译器使用无符号的%/操作。因此,对于 2,147,483,648,working % RADIX操作将给我们0x00000008

我们将unsigned int%操作转换为char,使用(char),即类型转换操作符❹。括号是类型转换操作符的语法部分。然后,我们使用按位或操作符将char转换为其对应的 ASCII 码数字字符。

注意

在使用类型转换操作符转换值的类型时要小心。我们正在将一个 32 位的值转换为 8 位值,这可能会导致信息丢失。在这种情况下,我们知道信息将以 4 位进行编码,因此转换是安全的。

由于该算法是从右到左工作的,因此字符以反向顺序存储。因此,我们需要反转文本字符串的顺序,以供调用函数使用❺。首先存储NUL字符❷提供了一种方法,能够知道整个文本字符串是否已完全反向复制❻。

接下来,我们将查看编译器生成的汇编语言,如清单 16-22 所示。

int_to_dec.s

.arch armv8-a
        .file   "int_to_dec.c"
        .text
        .align  2
        .global int_to_dec
        .type   int_to_dec, %function
int_to_dec:
        sub     sp, sp, #48
        str     x0, [sp, 8]
        str     w1, [sp, 4]
        str     wzr, [sp, 32]
        ldr     w0, [sp, 4]
        cmp     w0, 0               /// Check for negative
        bge     .L2
        ldr     w0, [sp, 4]
        neg     w0, w0
        str     w0, [sp, 4]
        ldr     x0, [sp, 8]
        mov     w1, 45
        strb    w1, [x0]
        ldr     w0, [sp, 32]
        add     w0, w0, 1
        str     w0, [sp, 32]
        ldr     x0, [sp, 8]
        add     x0, x0, 1
        str     x0, [sp, 8]
.L2:
        add     x0, sp, 16
        str     x0, [sp, 40]        /// ptr
        ldr     x0, [sp, 40]
        strb    wzr, [x0]           /// *ptr = NUL;
        ldr     w0, [sp, 4]
        str     w0, [sp, 36]        /// working = the_int;
.L3:
        ldr     x0, [sp, 40]
        add     x0, x0, 1
        str     x0, [sp, 40]
        ldr     w2, [sp, 36]
     ➊ mov     w0, 52429           /// 0xcccd
        movk    w0, 0xcccc, lsl 16  /// 0xcccccccd
     ➋ umull   x0, w2, w0          /// Multiply by 0.1
        lsr     x0, x0, 32          /// Divide by 2³⁵
        lsr     w1, w0, 3
        mov     w0, w1
     ➌ lsl     w0, w0, 2
        add     w0, w0, w1
        lsl     w0, w0, 1
     ➍ sub     w1, w2, w0          /// working % RADIX
        mov     w0, w1
        strb    w0, [sp, 31]
        ldrb    w0, [sp, 31]
     ➎ orr     w0, w0, 48          /// Convert to ASCII
        and     w1, w0, 255
        ldr     x0, [sp, 40]
        strb    w1, [x0]            /// *ptr = ASCII | digit
        ldr     w1, [sp, 36]
     ➏ mov     w0, 52429
        movk    w0, 0xcccc, lsl 16
        umull   x0, w1, w0
        lsr     x0, x0, 32
        lsr     w0, w0, 3
        str     w0, [sp, 36]        /// working = working / RADIX;
        ldr     w0, [sp, 36]
        cmp     w0, 0
        bne     .L3
.L4:
        ldr     x0, [sp, 40]
        ldrb    w1, [x0]
        ldr     x0, [sp, 8]
        strb    w1, [x0]
        ldr     w0, [sp, 32]
        add     w0, w0, 1
        str     w0, [sp, 32]
        ldr     x0, [sp, 8]
        add     x0, x0, 1
        str     x0, [sp, 8]
        ldr     x0, [sp, 40]
        sub     x0, x0, #1
        str     x0, [sp, 40]
        ldr     x0, [sp, 40]
        ldrb    w0, [x0]
        cmp     w0, 0
        bne     .L4
        ldr     x0, [sp, 40]
        ldrb    w1, [x0]
        ldr     x0, [sp, 8]
        strb    w1, [x0]
        ldr     w0, [sp, 32]
        add     sp, sp, 48
        ret
        .size   int_to_dec, .-int_to_dec
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

清单 16-22:编译器生成的用于 int_to_dec 函数的汇编语言,如清单 16-21 所示

C 算法涉及除以 10,但编译器使用umull指令执行余数(%)操作❷。这是因为除法比乘法耗时要多。由于我们除的是常数,编译器使用以下算术运算:

Image

编译器使用n = 35,得到常数 2³⁵/10 = 3,435,973,836.8。将其四舍五入到最接近的整数得到 3,435,973,837 = 0xcccccccd❶。在将working整数乘以此常数后,算法将结果右移 35 位以除以 2³⁵。

编译器然后使用移位加法算法将这个商乘以 10 ❸。从原始被除数中减去结果后,我们得到余数❹,该余数转换为相应的 ASCII 字符❺。

编译器使用与处理%操作相同的乘法和移位算法来实现/操作❻。

使用乘法和移位进行除法是有限制的。为了查看除法指令如何工作,我们将在int_to_dec函数的汇编语言版本中使用除法指令。

在汇编语言中

我们没有查看 C 版本的sub_123程序中main函数的编译器生成的汇编语言(Listing 16-19)。它与我们的汇编语言版本相似,后者显示在 Listing 16-23 中。

sub_123.s

   // Subtract 123 from an integer.
           .arch armv8-a
   // Useful constants
           .equ    CONSTANT, 123         // Number to subtract
           .equ    MAX, 11               // Maximum digits
➊ // Stack frame
           .equ    the_int, 16
           .equ    the_string, 20
           .equ    FRAME, 32
   // Code
           .text
           .section  .rodata
           .align  3
           prompt:
           .string "Enter an integer: "
   message:
           .string "The result is: "
           .text
           .align  2
           .global main
           .type   main, %function
   main:
           stp     fp, lr, [sp, -FRAME]! // Create stack frame
           mov     fp, sp                // Our frame pointer

           adr     x0, prompt            // Prompt message
           bl      write_str             // Ask for input
           add     x0, sp, the_string
           mov     w1, MAX
           bl      read_str

           add     x1, sp, the_string    // Input
           add     x0, sp, the_int       // Place for output
           bl      dec_to_int            // Convert to int

           ldr     w1, [sp, the_int]
           sub     w1, w1, CONSTANT      // Subtract our constant
           add     x0, sp, the_string    // Place for output
           bl      int_to_dec            // Convert to text string

           adr     x0, message           // Tell user that
           bl      write_str
           add     x0, sp, the_string    //   this is the result
           bl      write_str
           mov     w0, '\n'
           bl      write_char

           mov     w0, wzr               // Return 0
           ldp     fp, lr, [sp], FRAME   // Delete stack frame
           ret                           // Back to caller

Listing 16-23:sub_123程序的 main 函数的汇编语言版本

main函数中没有新的内容;我们可以使用 Figure 16-3 中的相同栈帧设计❶。

Listing 16-24 显示了我们int_to_dec函数的汇编语言版本。

int_to_dec.s

   // Convert an int to its decimal text string representation.
   // Calling sequence:
   //    x0 <- place to store string
   //    w1 <- the int
   //    Return number of characters in the string.
           .arch armv8-a
   // Useful constants
           .equ    RADIX, 10             // Number base
           .equ    INT2CHAR, 0x30        // ASCII zero
           .equ    MINUS, '-             // Minus sign
➊ // Stack frame
           .equ    reverse, 0
           .equ    FRAME, 16
   // Code
           .text
           .align  2
           .global int_to_dec
           .type   int_to_dec, %function
   int_to_dec:
           sub     sp, sp, FRAME         // Local string on stack

           cmp     w1, wzr               // => 0?
           tbz     w1, 31, non_negative  // Yes, go to conversion
           neg     w1, w1                // No, negate int
           mov     w2, MINUS
           strb    w2, [x0]              // Start with minus sign
           add     x0, x0, 1             // Increment pointer
   non_negative:
           add     x3, sp, reverse       // Pointer to local string storage
           strb    wzr, [x3]             // Create end with NUL
           mov     w2, RADIX             // Put in register
   do_while:
           add     x3, x3, 1             // Increment local pointer
        ➋ udiv    w4, w1, w2            // Quotient = dividend / RADIX
        ➌ msub    w5, w4, w2, w1        // Rem. = dividend - RADIX * quot.
           orr     w5, w5, INT2CHAR      // Convert to ASCII
           strb    w5, [x3]              // Store character
        ➍ mov     w1, w4                // Remove remainder
           cbnz    w1, do_while          // Continue if more left

           mov     w6, wzr               // count = 0
   copy:
           ldrb    w5, [x3]              // Load character
           strb    w5, [x0]              // Store it
           add     x0, x0, 1             // Increment to pointer
           sub     x3, x3, 1             // Decrement from pointer
           add     w6, w6, 1             // Increment counter
           cbnz    w5, copy              // Continue until NUL char
           strb    w5, [x0]              // Store NUL character

           mov     w0, w6                // Return count
           add     sp, sp, FRAME         // Delete stack frame
           ret                           // Back to caller

Listing 16-24:int_to_dec函数的汇编语言版本

由于这是一个叶子函数,我们不需要帧记录,但我们确实需要在栈上为存储生成的文本字符串留出空间,以便按逆序生成它❶。

我们没有像编译器那样使用乘法和移位算法,而是使用udiv指令将数字除以 10 ❷。这将商存储在w4寄存器中。然后,我们使用msub指令将商(w4寄存器)乘以基数(w2寄存器),并从被除数(w1寄存器)中减去该乘积,将余数保留在w5寄存器中❸。由于我们已经在w4寄存器中得到了商,我们可以使用它进行该循环的下一次迭代❹。

在 Listing 16-24 中,我们看到两个新的指令:msubudiv。我还将在此描述sdiv

msub—乘法与减法

msub wd , ws1 , ws2 , ws3 将ws1 乘以ws2,然后从乘积中减去ws3 的值,并将结果加载到wd 中。

msub xd , xs1 , xs2 , xs3 将xs1 乘以xs2,然后从乘积中减去xs3 的值,并将结果加载到xd 中。

udiv—无符号除法

udiv wd , ws1 , ws2 将ws1除以ws2 并将结果存储在w`d 中。它将所有值视为无符号数字。

udiv xd , xs1 , xs2 将xs1除以xs2 并将结果存储在x`d 中。它将所有值视为无符号数字。

sdiv—有符号除法

sdiv wd , ws1 , ws2 将ws1除以ws2 并将结果存储在wd 中。如果ws1 和ws2 符号相同,则wd 中的值为正;如果它们符号相反,则w`d 中的值为负。

sdiv xd , xs1 , xs2xs1 除以xs2,并将结果存储到xd 中。如果xs1 和xs2 符号相同,xd 中的值将为正;如果它们符号相反,x`d 中的值将为负。

除法指令不会影响nzcv寄存器中的条件标志。如果除数为零,它们甚至不会报错;它们会将 0 加载到目标寄存器中。你需要仔细分析算法的所有可能值,以确保永远不会除以零。

轮到你了

16.8 编写两个汇编语言函数put_intget_intput_int函数接受一个参数,一个 32 位有符号整数,并将其显示在屏幕上。get_int函数返回一个 32 位有符号整数,读取自键盘输入。编写一个main函数进行 C 语言测试put_intget_int。我们将在后续章节中使用put_intget_int来显示和读取整数。

16.9 编写两个汇编语言函数put_uintget_uintput_uint函数接受一个参数,一个 32 位无符号整数,并将其显示在屏幕上。get_uint函数返回一个 32 位无符号整数,读取自键盘输入。使用练习 16.6 中的dec_to_uint函数,第 348 页,并编写uint_to_dec函数。编写一个main函数进行 C 语言测试put_uintget_uint。我们将在后续章节中使用put_uintget_uint来显示和读取整数。

16.10 编写一个汇编语言程序,允许用户输入两个有符号的十进制整数。程序将对这两个整数进行加、减、乘、除运算,并显示结果的和、差、积、商和余数。

你学到了什么

位掩码 我们可以使用按位逻辑指令直接改变变量中的位模式。

位移 变量中的位可以向左或向右移动,实际上是通过 2 的倍数进行乘法或除法运算。

乘法 乘法指令允许我们执行有符号或无符号的乘法。乘以较大的 64 位值需要两条指令来处理 128 位的结果。

更快的乘法 当乘以常数时,使用加法和移位的组合可能比乘法指令更快。

除法 除法指令允许我们执行有符号或无符号的除法运算。

更快的除法 当除以常数时,乘法和移位的组合可能比除法指令更快。

在二进制存储和字符显示之间转换数字 当数字以二进制形式存储时,算术运算会更简单,但键盘输入和屏幕显示使用的是相应的字符格式。

我们现在已经介绍了如何组织程序流程以及如何对数据项执行算术或逻辑操作。在下一章中,我们将探讨两种最基本的数据组织方式:数组和记录。

第十七章:数据结构**

Image

编程的一个重要部分是确定如何最好地组织数据。在本章中,我们将介绍两种最基本的数据组织方式:数组,用于将相同数据类型的数据项分组;记录,用于将不同数据类型的数据项分组。

这些数据组织方式决定了我们如何访问单个数据项。两者都需要两个地址项来定位数据项。由于数组中的数据项都是相同类型,我们可以通过数组名和元素的索引值来访问单个数据项。访问记录中的单个数据项则需要记录名和数据项在记录中的名称。

数组

数组 是一组相同数据类型的数据元素,按顺序排列。我们可以使用数组的名称和索引值来访问数组中的单个元素,索引值指定元素相对于数组开始位置的编号。我们在前几章中使用了char数组来存储 ASCII 字符作为文本字符串。数组中的每个元素都是相同类型的char,即 1 字节。在之前的应用中,我们按顺序访问每个字符,因此我们从指向第一个char的指针开始,并通过将其递增 1 来访问每个后续的char。我们不需要索引来定位文本字符串数组中的每个char

在本章中,我们将讨论int数组,每个数组元素占用 4 字节。如果我们从指向第一个元素的指针开始,我们需要将其递增 4 以访问每个后续元素;使用数组索引来访问每个单独元素会更加简便。你将看到如何将索引值转换为地址偏移量,以便相对于数组的起始位置访问数组元素。你还将看到 C 语言传递数组给其他函数的方式与传递其他数据项的方式不同。

在 C 语言中

在 C 语言中,我们通过指定元素数据类型、给数组命名并指定数组元素的数量来定义一个数组。让我们从清单 17-1 中的示例开始。

fill_array.c

// Allocate an int array, store (2 * element number)
// in each element, and print the array contents.

#include "twice_index.h"
#include "display_array.h"
#define N 10

int main(void)
{
    int my_array[N];

    twice_index(my_array, N);
    display_array(my_array, N);

    return 0;
}

清单 17-1:一个存储 int 并显示它们的程序

这个main函数调用了twice_index函数,该函数将数组中的每个元素设置为其索引值的两倍。例如,twice_indexint类型的 8 存储在数组元素的第 4 个位置。然后,main函数调用display_array函数,打印整个数组的内容到终端窗口。

你第一次接触数组是在第二章中,当时学习了 C 风格的字符串。那些字符串使用了一个哨兵值NUL来标记数组的结束。我们这里使用的数组没有哨兵值,因此我们需要将数组中的元素数量传递给每个处理它的函数。

你可能注意到,我们传递给函数的参数看起来像是按值传递了数组,因为我们仅在参数列表中给出了数组的名称。但twice_index将值存储到数组中,因此它需要知道数组在内存中的位置。

通常,程序员通过值传递一个输入值给函数。但如果输入包含大量数据项,将它们都复制到寄存器和栈中会非常低效;通过指针传递会更有意义。数组几乎总是包含许多数据项,因此 C 语言的设计者决定始终通过指针传递它们。当你将数组的名称作为参数传递给函数调用时,C 会传递数组第一个元素的地址;因此,twice_index可以输出数据到数组中。

为了更清楚地看到这一点,让我们来看一下该main函数的编译器生成的汇编语言,如列表 17-2 所示。

fill_array.s

        .arch armv8-a
        .file   "fill_array.c"
        .text
        .align  2
        .global main
        .type   main, %function
main:
     ➊ stp     x29, x30, [sp, -64]!
        mov     x29, sp
     ➋ add     x0, sp, 24            /// Address of array
        mov     w1, 10
        bl      twice_index
     ➌ add     x0, sp, 24
        mov     w1, 10
        bl      display_array
        mov     w0, 0
        ldp     x29, x30, [sp], 64
        ret
        .size   main, .-main
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

列表 17-2:编译器生成的列表 17-1 中函数的汇编语言

整个int数组在main的栈帧中分配 ❶。在汇编语言中,数组的地址同时传递给twice_index函数 ❷和display_array函数 ❸。数组的元素是display_array函数的输入,因此它不需要知道数组的地址,但传递整个数组的地址要比传递数组中每个元素的副本更高效。

接下来,我们将查看将int存储到数组中的函数twice_index。列表 17-3 显示了它的头文件。

twice_index.h

// Store (2 * element number) in each array element.

#ifndef TWICE_INDEX_H
#define TWICE_INDEX_H
void twice_index(int the_array[], int n_elements);
#endif

列表 17-3:twice_index函数的头文件

这个原型声明展示了我们如何使用[]语法来表示函数的参数是一个数组。如前所述,我们需要将数组的元素数量作为单独的参数提供。

twice_index函数的定义见列表 17-4。

twice_index.c

// Store (2 * element number) in each array element.

#include "twice_index.h"

void twice_index(int the_array[], int n_elements)
{
    int i;

    for (i = 0; i < n_elements; i++) {
        the_array[i] = 2 * i;
    }
}

列表 17-4:将数组中每个元素存储为索引值两倍的函数

当循环开始时,循环的迭代次数是已知的,因此我们使用for循环来处理数组。编译器为twice_index函数生成的汇编语言如列表 17-5 所示。

twice_index.s

        .arch armv8-a
        .file   "twice_index.c"
        .text
        .align  2
        .global twice_index
        .type   twice_index, %function
twice_index:
        sub     sp, sp, #32
        str     x0, [sp, 8]       /// Address of the_array
        str     w1, [sp, 4]       /// n_elements
        str     wzr, [sp, 28]     /// i = 0;
        b       .L2
.L3:
        ldrsw   x0, [sp, 28]
     ➊ lsl     x0, x0, 2         /// Each element is 4 bytes
        ldr     x1, [sp, 8]
     ➋ add     x0, x1, x0        /// Address of ith element
        ldr     w1, [sp, 28]
        lsl     w1, w1, 1         /// 2 * i;
        str     w1, [x0]
        ldr     w0, [sp, 28]
        add     w0, w0, 1         /// i++
        str     w0, [sp, 28]
.L2:
        ldr     w1, [sp, 28]
        ldr     w0, [sp, 4]
        cmp     w1, w0
        blt     .L3
        nop
        nop
        add     sp, sp, 32
        ret
        .size   twice_index, .-twice_index
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

列表 17-5:编译器生成的列表 17-4 中函数的汇编语言

要访问数组元素,编译器计算元素相对于数组起始位置的偏移量,然后将这个偏移量加到数组起始地址上。这是一个int类型的数组,因此每个元素占用 4 个字节。编译器将数组索引i左移 2 位,以将其乘以 4 ❶。将4 * i加到数组起始地址上,就得到了数组元素的地址 ❷。

接下来,我们将查看用于显示数组内容的 display_array 函数。列表 17-6 显示了此函数的头文件。

display_array.h

// Print the int array contents.

#ifndef DISPLAY_ARRAY_H
#define DISPLAY_ARRAY_H
void display_array(int the_array[], int n_elements);
#endif

列表 17-6: display_array 函数的头文件

twice_index 函数类似,我们使用 [] 语法来表示传递给 display_array 函数的参数是一个数组。我们还需要提供数组中元素的数量作为单独的参数。函数定义见 列表 17-7。

display_array.c

// Print the int array contents.

#include "display_array.h"
#include "write_str.h"
#include "write_char.h"
#include "put_int.h"
void display_array(int the_array[], int n_elements)
{
    int i;
    for (i = 0; i < n_elements; i++) {
        write_str("my_array[");
        put_int(i);
        write_str("] = ");
        put_int(the_array[i]);
        write_char('\n');
    }
}

列表 17-7:用于显示数组中 int 类型元素的函数

为了显示整数,我们使用了你在“你的练习”16.8 练习中编写的 put_int 函数,位于第 358 页。列表 17-8 显示了该函数的编译器生成的汇编语言。

display_array.s

        .arch armv8-a
        .file   "display_array.c"
        .text
        .section        .rodata
        .align  3
        .LC0:
        .string "my_array["
        .align  3
.LC1:
        .string "] = "
        .text
        .align  2
        .global display_array
        .type   display_array, %function
display_array:
        stp     x29, x30, [sp, -48]!
        mov     x29, sp
     ➊ str     x0, [sp, 24]      /// Address of the_array
        str     w1, [sp, 20]      /// n_elements
        str     wzr, [sp, 44]     /// i = 0;
        b       .L2
.L3:
        adrp    x0, .LC0
        add     x0, x0, :lo12:.LC0
        bl      write_str
        ldr     w0, [sp, 44]
        bl      put_int
        adrp    x0, .LC1
        add     x0, x0, :lo12:.LC1
        bl      write_str
        ldrsw   x0, [sp, 44]
     ➋ lsl     x0, x0, 2         /// Each element is 4 bytes
     ➌ ldr     x1, [sp, 24]      /// Address of array
        add     x0, x1, x0        /// Address of ith element
        ldr     w0, [x0]
        bl      put_int
        mov     w0, 10            /// '\n' character
        bl      write_char
        ldr     w0, [sp, 44]
        add     w0, w0, 1         /// i++
        str     w0, [sp, 44]
.L2:
        ldr     w1, [sp, 44]
        ldr     w0, [sp, 20]
        cmp     w1, w0
        blt     .L3
        nop
        nop
        ldp     x29, x30, [sp], 48
        ret
        .size   display_array, .-display_array
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

列表 17-8:为 列表 17-7 中的函数生成的汇编语言

尽管数组是 display_array 函数的输入,但 C 语言将数组的地址传递给被调用的函数 ❶ ❸。和 twice_index 函数一样,编译器将索引乘以 4 来获取数组中每个 int 的偏移量 ❷。接下来,我将演示在直接用汇编语言编写此程序时如何以不同的方式对数组元素进行索引。

汇编语言中

我们对 fill_array 程序的处理方法与编译器的类似,但我们将使用稍微直观的指令。列表 17-9 显示了我们的 main 函数。

fill_array.s

// Allocate an int array, store (2 * element number)
// in each element, and print array contents.
        .arch armv8-a
// Useful constant
        .equ    N, 10                   // Array length
// Stack frame
        .equ    my_array, 16
        .equ    FRAME, 64
// Code
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     fp, lr, [sp, -FRAME]!   // Create stack frame
        mov     fp, sp                  // Set our frame pointer

        mov     w1, N                   // Length of array
        add     x0, sp, my_array        // Address of array
        bl      twice_index             // Fill the array

        mov     w1, N                   // Number of elements
        add     x0, sp, my_array        // Address of array
        bl      display_array           // Print array contents

        mov     w0, wzr                 // Return 0
        ldp     fp, lr, [sp], FRAME     // Delete stack frame
        ret

列表 17-9:用于存储 int 类型元素并显示它们的汇编语言程序

这与编译器在 列表 17-2 中生成的内容类似,只是我们使用了更具意义的名称。然而,我们将在 twice_index 函数中使用不同的方法来计算每个数组元素的地址,具体内容见 列表 17-10。

twice_index.s

// Store (2 * element number) in each array element.
// Calling sequence:
//    x0 <- address of array
//    w1 <- number of array elements
//    Return 0.
        .arch armv8-a
// Code
        .text
        .align  2
        .global twice_index
        .type   twice_index, %function
twice_index:
        mov     w2, wzr                // i = 0
loop:
        add     w3, w2, w2             // 2 * i
     ➊ str     w3, [x0, w2, uxtw 2]   // Current element address
        add     w2, w2, 1              // i++
        cmp     w2, w1                 // At end?
        b.lt    loop                   // No, continue filling

        mov     w0, wzr                // Yes, return 0
        ret

列表 17-10:将每个数组元素中的索引值乘以 2 的汇编语言函数

我们使用了一种 str 指令变种,使用寄存器保存基址寄存器的偏移量,而不是常量 ❶。在我们的例子中,索引存储在 32 位寄存器 w2 中,因此值需要在加到基址寄存器 x0 中的地址之前扩展到 64 位。由于数组中的每个元素为 4 字节,因此索引值需要乘以 4 以得到地址偏移量。

让我们看看使用寄存器保存基址寄存器偏移量的加载和存储指令:

ldr—加载寄存器,基址寄存器相对,寄存器偏移量

ldr wd , [xb , wo , xtnd { amnt }]wd 加载为内存位置的 32 位值,该内存位置是通过将 xb 中的地址与 wo 中的值相加,并可选择性地左移 2 位并扩展为 64 位。

ldr wd , [xb , xo {, xtnd { amnt }}] 从内存位置加载 32 位值到 wd,内存位置由 xb 中的地址与 xo 中的值相加得到,值可以选择性地左移 2 位。

ldr xd , [xb , wo , xtnd { amnt }] 从内存位置加载 64 位值到 xd,内存位置由 xb 中的地址与 wo 中的值相加得到,值可以选择性地左移 3 位,并扩展到 64 位。

ldr xd , [xb , xo {, xtnd { amnt }}] 从内存位置加载 64 位值到 xd,内存位置由 xb 中的地址与 xo 中的值相加得到,值可以选择性地左移 3 位。

str—存储寄存器,基址寄存器相对,寄存器偏移

str ws , [xb , wo , xtnd { amnt }] 将 32 位值存储在 ws 中,存储位置由 xb 中的地址与 wo 中的值相加得到,值可以选择性地左移 2 位,并扩展到 64 位。

str ws , [xb , xo {, xtnd { amnt }}] 将 32 位值存储在 ws 中,存储位置由 xb 中的地址与 xo 中的值相加得到,值可以选择性地左移 2 位。

str xs , [xb , wo , xtnd { amnt }] 将 64 位值存储在 xs 中,存储位置由 xb 中的地址与 wo 中的值相加得到,值可以选择性地左移 3 位,并扩展到 64 位。

str xs , [xb , xo {, xtnd { amnt }}] 将 64 位值存储在 xs 中,存储位置由 xb 中的地址与 xo 中的值相加得到,值可以选择性地左移 3 位。

Table 17-1 列出了 ldrstr 指令中 xtnd 选项的允许值。

Table 17-1: ldrstr 指令中 xtnd 的允许值

xtnd 效果
uxtw 字长的无符号扩展, 可选左移
lsl 左移
sxtw 字长的有符号扩展, 可选左移
sxtx 左移

32 位偏移量 wo 必须扩展到 64 位,以便与基址寄存器 xb 中的地址相加。sxtx 选项用于语法对称,它的效果与 lsl 相同。

amnt 的允许值为 02,适用于 wd 和 ws 寄存器,03 适用于 xd 和 xs 寄存器。这使我们能够将偏移寄存器中的值,wo 或 xo,乘以我们正在加载或存储的数据元素的字节数。这使得将数组索引转换为数组元素地址偏移变得简单,如我们在 Listing 17-10❶ 中所见。

Listing 17-11 显示了用于显示数组内容的函数。

display_array.s

// Display ints in an array.
// Calling sequence:
//    x0 <- address of array
//    w1 <- number of array elements
        .arch armv8-a
// Stack frame
        .equ    save1920, 16
        .equ    save21, 32
        .equ    FRAME, 48
// Code
        .section  .rodata
        .align  3
msg1:
        .string "my_array["
msg2:
        .string "] = "
        .text
        .align  2
        .global display_array
        .type   display_array, %function
display_array:
     ➊ stp     fp, lr, [sp, -FRAME]!     // Create stack frame
        mov     fp, sp                    // Set our frame pointer
        stp     x19, x20,  [sp, save1920] // Save regs
        str     x21, [sp, save21]

        mov     x19, x0                   // Array address
        mov     w20, w1                   // Array size
        mov     w21, wzr                  // Array index
loop:
        adr     x0, msg1                  // Start line
        bl      write_str
        mov     w0, w21                   // Index
        bl      put_int
        adr     x0, msg2                  // More text on line
        bl      write_str
        ldr     w0, [x19, w21, uxtw 2]    // Current element
        bl      put_int
        mov     w0, '\n'                  // Finish line
        bl      write_char
        add     w21, w21, 1               // Increment index
        cmp     w21, w20                  // At end?
        b.lt    loop                      // No, continue

        mov     w0, wzr                   // Return 0
        ldp     x19, x20,  [sp, save1920] // Restore regs
        ldr     x21, [sp, save21]
        ldp     fp, lr, [sp], FRAME       // Delete stack frame
        ret

Listing 17-11: 显示数组中整数的汇编语言函数

该函数使用与 twice_index 函数相同的基本算法,但它调用了其他函数,因此我们需要创建一个堆栈记录 ❶。我们还需要保存调用函数的 x19x20x21 寄存器。

数组用于将相同类型的数据项组合在一起。在下一节中,我们将看看如何将不同数据类型的项组合在一起。

你的回合

17.1 将twice_indexdisplay_array函数修改为使用指针传递(int *the_array),而不是数组传递(int the_array[])。将编译器生成的汇编语言与 Listings 17-2、17-5 和 17-8 中的内容进行比较。

修改 Listings 17-9 中的程序,改为 17-11,使得每个数组元素存储 16 倍的索引值。

记录

记录(或结构)允许程序员将几个可能具有不同数据类型的数据项组合在一起,形成一个新的程序员定义的数据类型。记录中每个数据项的位置称为字段元素。你也可能会看到一个字段被称为成员,特别是在面向对象编程中。我将在下一章介绍 C++对象。

由于记录中的字段可能具有不同的大小,因此访问它们比访问数组中的数据项要复杂一些。我将首先介绍在 C 语言中如何实现,然后我们将看看如何将记录传递给其他函数。

在 C 语言中

让我们先来看一个定义记录、将数据存储到各个字段并显示这些值的程序,如 Listing 17-12 所示。

fill_record.c

// Allocate a record and assign a value to each field.

#include <stdio.h>

int main(void)
{
    struct {
     ➊ char a;
        int i;
        char b;
        int j;
        char c;
➋ } x;

➌ x.a = 'a';
    x.i = 12;
    x.b = 'b';
    x.j = 34;
    x.c = 'c';

    printf("x: %c, %i, %c, %i, %c\n", x.a, x.i,
          x.b, x.j, x.c);
    return 0;
}

Listing 17-12: 一个存储数据到记录中的程序

我们使用struct关键字在 C 中声明一个记录。记录的字段使用通常的 C 语法声明:数据类型后跟字段名称 ❶。从struct关键字开始,到结束的}大括号为止,定义了一个新的数据类型 ❷。我们通过在此数据类型后面加上变量名来定义一个记录变量。记录的各个字段通过点操作符加字段名称来访问 ❸。

我们可以通过查看编译器为此函数生成的汇编语言来了解记录在内存中的存储方式,如 Listing 17-13 所示。

fill_record.s

        .arch armv8-a
        .file   "fill_record.c"
        .text
        .section        .rodata
        .align  3
.LC0:
        .string "x: %c, %i, %c, %i, %c\n"
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     x29, x30, [sp, -48]!
        mov     x29, sp
        mov     w0, 97
     ➊ strb    w0, [sp, 24]    /// x.a = 'a';
        mov     w0, 12
     ➋ str     w0, [sp, 28]    /// x.i = 12;
        mov     w0, 98
        strb    w0, [sp, 32]    /// x.b = 'b';
        mov     w0, 34
        str     w0, [sp, 36]    /// x.j = 34;
        mov     w0, 99
        strb    w0, [sp, 40]    /// x.c = 'c';
        ldrb    w0, [sp, 24]
        mov     w6, w0
        ldr     w0, [sp, 28]
        ldrb    w1, [sp, 32]
        mov     w3, w1
        ldr     w1, [sp, 36]
        ldrb    w2, [sp, 40]
        mov     w5, w2
        mov     w4, w1
        mov     w2, w0
        mov     w1, w6
        adrp    x0, .LC0
        add     x0, x0, :lo12:.LC0
        bl      printf
        mov     w0, 0
        ldp     x29, x30, [sp], 48
        ret
        .size   main, .-main
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

Listing 17-13: 编译器生成的 Listing 17-12 中 main 函数的汇编语言

和其他局部变量一样,记录也分配在函数的堆栈框架中。记录中各个字段通过从堆栈指针的偏移量来访问 ❶。Figure 17-1 显示了编译器为这个 main 函数使用的堆栈框架。

Image

Figure 17-1: 记录中字段的堆栈框架

在第十二章中你学到了,ldrstr 指令编码了字的偏移量(参见图 12-1 和图 12-2 后的讨论),因此 ij 字段必须各自对齐到 4 字节的字边界 ❷。abc 字段分别被放置在偏移量为 24、32 和 40 的字的低位字节中。

struct 在栈帧中占用 20 个字节。你在图 17-1 中看到的空白区域是 struct 中未使用的字节。灰色区域是栈帧中未使用的字节。

将记录传递给另一个函数会引发额外的问题。如你所见,我们需要指定传递的数据类型,但一个记录可以有许多字段,每个字段可以有不同的数据类型。接下来,你将看到 C 语言如何解决这个问题。

每次定义另一个记录实例时都定义字段是很繁琐的。C 语言允许我们使用 结构标签(或简单称为 标签)来定义我们自己的 struct 类型,它作为字段定义的同义词。这不仅对定义多个相同字段组合的记录很有用,而且对于将记录传递给其他函数也是必要的。

例如,我们在清单 17-12 中定义了 struct 变量 x,如下所示:

struct {
    char a;
    int i;
    char b;
    int j;
    char c;
} x;

相反,我们可以为 struct 中的字段创建一个标签,像这样:

struct chars_and_ints {
    char a;
    int i;
    char b;
    int j;
    char c;
};

现在我们已经为程序员自定义的数据类型 struct chars_and_ints 创建了一个名称,我们可以像通常定义变量一样定义它的变量:

struct chars_and_ints x;

我们将首先在一个单独的头文件中声明我们的新 struct 数据类型,如清单 17-14 所示。

my_record.h

// Declare a record.

#ifndef MY_RECORD_H
#define MY_RECORD_H
struct chars_and_ints {
    char a;
    int i;
    char b;
    int j;
    char c;
};
#endif

清单 17-14:一个记录标签

我们在任何需要定义 struct chars_and_ints 变量或函数参数的文件中包含这个头文件。

清单 17-15 展示了如何使用 chars_and_ints 在一个函数中定义两个记录。

fill_records.c

// Allocate two records, assign a value to each field
// in each record, and display the contents.

#include "my_record.h"
#include "load_record.h"
#include "display_record.h"

int main(void)
{
 ➊ struct chars_and_ints x;
    struct chars_and_ints y;

 ➋ load_record(&x, 'a', 12, 'b', 34, 'c');
    load_record(&y, 'd', 56, 'e', 78, 'f');

 ➌ display_record(x);
    display_record(y);

    return 0;
}

清单 17-15:一个将数据加载到两个记录并显示其内容的程序

struct C 关键字和我们的标签指定了我们正在定义的记录变量的数据类型 ❶。由于 load_record 函数将数据值输出到记录中,我们需要传递记录的地址 ❷。记录是 display_record 函数的输入,因此我们使用按值传递来传递记录的副本 ❸。

清单 17-16 中显示了 main 函数的编译器生成的汇编语言,展示了通过指针传递记录给 load_record 和通过值传递记录给 display_record 的区别。

fill_records.s

        .arch armv8-a
        .file   "fill_records.c"
        .text
        .align  2
        .global main
        .type   main, %function
main:
     ➊ stp     x29, x30, [sp, -96]!
        mov     x29, sp
     ➋ add     x0, sp, 72        /// Pass by pointer
        mov     w5, 99
        mov     w4, 34
        mov     w3, 98
        mov     w2, 12
        mov     w1, 97
        bl      load_record
        add     x0, sp, 48
        mov     w5, 102
        mov     w4, 78
        mov     w3, 101
        mov     w2, 56
        mov     w1, 100
        bl      load_record
     ➌ add     x2, sp, 16        /// Point to temporary place
        add     x3, sp, 72        ///   and copy record there
        ldp     x0, x1, [x3]
        stp     x0, x1, [x2]
        ldr     w0, [x3, 16]
        str     w0, [x2, 16]
     ➍ add     x0, sp, 16
        bl      display_record   /// Pass temp place by pointer
        add     x2, sp, 16
        add     x3, sp, 48
        ldp     x0, x1, [x3]
        stp     x0, x1, [x2]
        ldr     w0, [x3, 16]
        str     w0, [x2, 16]
        add     x0, sp, 16
        bl      display_record
        mov     w0, 0
        ldp     x29, x30, [sp], 96
        ret
        .size   main, .-main
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

清单 17-16:清单 17-15 中 main 函数的编译器生成的汇编语言

load_record函数将数据输出到struct中,因此它是通过指针传递的❷。正如我们在清单 17-2 中看到的,当我们将数组的名称传递给函数时,即使它是输入,C 语言也使用指针传递,因为数组通常很大。记录也可以很大,但这种情况不如数组常见,因此当我们将struct的名称传递给函数时,C 语言使用值传递。然而,过程调用标准规定,如果struct超过 16 字节,它必须复制到内存中,并通过指针传递复制。

图 17-1 显示了我们的struct大小为 20 字节。编译器在堆栈帧中为此副本分配了额外的内存❶。我们的main函数创建了struct的副本❸,并将指向该副本的指针作为输入传递给load_record函数❹。

让我们看看load_record函数,其头文件在清单 17-17 中显示。

load_record.h

// Load a record with data.

#ifndef LOAD_RECORD_H
#define LOAD_RECORD_H
#include "my_record.h"
int load_record(struct chars_and_ints *a_record, char x, int y, char z);
#endif

清单 17-17:将数据加载到记录中的函数的头文件

清单 17-18 显示了load_record函数的定义。

load_record.c

// Load a record with data.

#include "load_record.h"

void load_record(struct chars_and_ints *a_record, char v, int w,
                 char x, int y, char z)
{
 ➊ (*a_record).a = v;
 ➋ a_record->b = x;     // Equivalent syntax
    a_record->c = z;
    a_record->i = w;
    a_record->j = y;
}

清单 17-18:将数据加载到记录中的函数

在字段选择之前,必须使用圆括号来解引用a_record指针变量,因为在 C 语言中,字段选择操作符(.)的优先级高于解引用操作符(*)❶。没有这些圆括号,*a_record.a就表示*(a_record.a)。指向记录的指针非常常见,因此 C 语言提供了等效的->语法,先解引用记录指针,再选择字段❷。

清单 17-19 显示了编译器为我们的load_record函数生成的汇编语言。

load_record.s

        .arch armv8-a
        .file   "load_record.c"
        .text
        .align  2
        .global load_record
        .type   load_record, %function
load_record:
     ➊ sub     sp, sp, #32
        str     x0, [sp, 24]    /// a_record address
        strb    w1, [sp, 23]    /// v
        str     w2, [sp, 16]    /// w
        strb    w3, [sp, 22]    /// x
        str     w4, [sp, 12]    /// y
        strb    w5, [sp, 21]    /// z
        ldr     x0, [sp, 24]    /// Load a_record address
        ldrb    w1, [sp, 23]    /// Load v
        strb    w1, [x0]        /// Store in record field
     ➋ ldr     x0, [sp, 24]    /// Load a_record address
        ldrb    w1, [sp, 22]    /// Load X
        strb    w1, [x0, 8]     /// Store in record field
        ldr     x0, [sp, 24]
        ldrb    w1, [sp, 21]
        strb    w1, [x0, 16]
        ldr     x0, [sp, 24]    /// Load a_record address
        ldr     w1, [sp, 16]    /// Load w
        str     w1, [x0, 4]     /// Store in record field
        ldr     x0, [sp, 24]
        ldr     w1, [sp, 12]
        str     w1, [x0, 12]
        nop
        add     sp, sp, 32
        ret
        .size   load_record, .-load_record
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

清单 17-19:编译器生成的汇编语言,针对清单 17-18 中的函数

这是一个叶函数,因此我们不需要帧记录,但编译器为保存函数的参数创建了一个堆栈帧❶。在访问每个字段之前,先检索struct的地址❷。

现在,让我们看看display_record函数,其头文件在清单 17-20 中显示。

display_record.h

// Display the contents of a record.

#ifndef DISPLAY_RECORD_H
#define DISPLAY_RECORD_H
#include "a_record.h"
void display_record(struct chars_and_ints a_record);
#endif

清单 17-20:显示记录中数据的函数的头文件

清单 17-21 显示了display_record函数的定义。

display_record.c

// Display the contents of a record.

#include <stdio.h>
#include "display_record.h"

void display_record(struct chars_and_ints a_record)
{
    printf("%c, %i, %c, %i, %c\n", a_record.a, a_record.i, a_record.b,
          a_record.j, a_record.c);
}

清单 17-21:显示记录中数据的函数

清单 17-22 显示了编译器为display_record函数生成的汇编语言。

display_record.s

        .arch armv8-a
        .file   "display_record.c"
        .text
        .section        .rodata
        .align  3
        .LC0:
        .string "%c, %i, %c, %i, %c\n"
        .text
        .align  2
        .global display_record
        .type   display_record, %function
display_record:
        stp     x29, x30, [sp, -32]!
        mov     x29, sp
        str     x19, [sp, 16]
     ➊ mov     x19, x0         /// Pointer to caller's copy
        ldrb    w0, [x19]
        mov     w6, w0
        ldr     w0, [x19, 4]
        ldrb    w1, [x19, 8]
        mov     w3, w1
        ldr     w1, [x19, 12]
        ldrb    w2, [x19, 16]
     ➋ mov     w5, w2          /// Arguments to printf
        mov     w4, w1
        mov     w2, w0
        mov     w1, w6
        adrp    x0, .LC0
        add     x0, x0, :lo12:.LC0
        bl      printf
        nop
        ldr     x19, [sp, 16]
        ldp     x29, x30, [sp], 32
        ret
        .size   display_record, .-display_record
        .ident  "GCC: (Debian 12.2.0-14) 12.2.0"
        .section        .note.GNU-stack,"",@progbits

清单 17-22:编译器生成的汇编语言,针对清单 17-21 中的函数

编译器使用x19作为指向调用者复制的struct的指针❶。在检索记录中每个字段的值后,它将这些值加载到适当的寄存器中,以便传递给printf函数❷。

你已经了解了如何通过值传递大于 16 字节的 struct。接下来,让我们来看一下如何传递小于 16 字节的记录。我们将通过重新排列记录的字段并使用汇编语言来实现这一点。

在汇编语言中

虽然这通常不是一个问题,但我们可以重新排列记录中的字段,使它稍微变小。图 17-2 显示了我们将如何在 main 函数的堆栈框架中放置两个记录及其字段的位置。

图片

图 17-2:堆栈框架中的两个记录

使用此堆栈框架图,我们可以按照列表 17-23 中所示设计 main 函数。

fill_records.s

// Allocate two records, assign a value to each field
// in each record, and display the contents.
        .arch armv8-a
// Stack frame
        .equ    x, 16
     ➊ .equ    y, 32
        .equ    FRAME, 48
// Code
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     fp, lr, [sp, -FRAME]!   // Create stack frame
        mov     fp, sp                  // Set our frame pointer

        mov     w5, 'c'                 // Data to load
        mov     w4, 34
        mov     w3, 'b'
        mov     w2, 12
        mov     w1, 'a'
        add     x0, sp, x               // Address of first record
        bl      load_record             // Load values

        mov     w5, 'f'                 // Data to load
        mov     w4, 78
        mov     w3, 'e'
        mov     w2, 56
        mov     w1, 'd'
        add     x0, sp, y               // Address of second record
        bl      load_record             // Load values

     ➋ ldr     x0, [sp, x]             // First 8 bytes of x
        ldr     w1, [sp, x+8]           // Last 4 bytes of x
        bl      display_record          // Display x

     ➌ ldr     x0, [sp, y]             // First 8 bytes of y
        ldr     w1, [sp, y+8]           // Last 4 bytes of y
        bl      display_record          // Display y

        mov     w0, wzr                 // Return 0
        ldp     fp, lr, [sp], FRAME     // Delete stack frame
        ret

列表 17-23:加载并显示两个记录的汇编语言程序

在图 17-2 中,记录长度为 12 个字节。过程调用标准规定,长度小于 16 字节的记录应通过寄存器传递。我们将记录的整个 12 字节,包括字段之间未使用的内存空隙,加载到寄存器 x0w1 中,以传递给 display_record ❷。

ldr 指令要求偏移量是目标寄存器字节数的倍数。当我们在图 17-2 中设计堆栈框架时,我们将 y 记录的起始位置放在双字边界上 ❶,这样我们就可以通过 x0 寄存器 ❸ 传递记录的前 8 个字节。

使用我们在图 17-2 中的堆栈框架图,我们将在一个名为 my_record.s 的文件中放置记录字段偏移量的 .equ 指令,如列表 17-24 所示。

my_record.s

// Assembly language declaration of a record.
// This record takes 12 bytes.
        .equ    a, 0
        .equ    b, 1
        .equ    c, 2
        .equ    i, 4
        .equ    j, 8

列表 17-24:记录的字段偏移量

我们可以将 my_record.s 文件包含在任何使用字段名称的汇编语言文件中,以确保一致性。我们来看一下它是如何在 load_record 函数中工作的,如列表 17-25 所示。

load_record.s

// Load the fields of my_record.s.
// Calling sequence:
//    x0 <- address of record
//    w1 <- char a
//    w2 <- int x
//    w3 <- char b
//    w4 <- int y
//    w5 <- char c
//    Returns 0
        .arch armv8-a
     ➊ .include  "my_record.s"   // Field offsets
// Code
        .text
        .align  2
        .global load_record
        .type   load_record, %function
load_record:
        strb    w1, [x0, a]       // First char
        str     w2, [x0, i]       // First int
        strb    w3, [x0, b]       // Second char

        str     w4, [x0, j]       // Second int
        strb    w5, [x0, c]       // Third char

        mov     w0, wzr           // Return 0
        ret

列表 17-25:将数据加载到记录中的汇编语言函数

这个函数的算法非常简单。你可以看到这里有一个新的汇编指令,.include ❶。这个指令的参数是一个用引号括起来的文件名。该文件中的文本会插入到此位置。my_record.s 中的 .equ 值给出了记录中字段的偏移量。

过程调用标准规定,当记录的长度小于 16 字节并通过寄存器传递时,我们需要在被调用函数中复制该记录。这与通过值传递较大的记录不同,后者是将记录的副本在调用函数中创建,并将指向该副本的指针传递给被调用函数。我们将在被调用函数的堆栈框架中放置 display_record 的副本,如图 17-3 所示。

图片

图 17-3:保存的 x19 寄存器和 记录 在堆栈框架中

列表 17-26 展示了最终的 display_record 函数设计。

display_record.s

// Display the fields of my_record.s.
//    x0 <- first 8 bytes of record contents
//    w1 <- remaining 4 bytes of record contents
        .arch armv8-a
        .include  "my_record.s"      // Field offsets
// Stack frame
        .equ    save19, 16
        .equ    record, 24
        .equ    FRAME, 48
// Code
        .section  .rodata
        .align  3
separator:
        .string ", "
        .text
        .align  2
        .global display_record
        .type   display_record, %function
display_record:
        stp     fp, lr, [sp, -FRAME]! // Create stack frame
        mov     fp, sp                // Set our frame pointer
     ➊ str     x19, [sp, save19]     // Save reg

        add     x19, sp, record       // Point to our copy
     ➋ str     x0, [x19]             // Make a copy of record
        str     w1, [x19, 8]

        ldrb    w0, [x19, a]          // First char
        bl      write_char            // Display
        adr     x0, separator         // Field separation
        bl      write_str
        ldr     w0, [x19, i]          // First int
        bl      put_int               // Display
        adr     x0, separator         // Field separation
        bl      write_str
        ldrb    w0, [x19, b]          // Second char
        bl      write_char            // Display
        adr     x0, separator         // Field separation
        bl      write_str
        ldr     w0, [x19, j]          // Second int
        bl      put_int               // Display
        adr     x0, separator         // Field separation
        bl      write_str
        ldrb    w0, [x19, c]          // Third char
        bl      write_char            // Display
        mov     w0, '\n'              // Newline
        bl      write_char

        mov     w0, wzr               // Return 0
        ldr     x19, [sp, save19]     // Restore reg
        ldp     fp, lr, [sp], FRAME   // Delete stack frame
        ret

清单 17-26:用于显示记录数据的汇编语言函数

程序调用标准规定,被调用的函数需要保留x19中的值。我们将其内容保存在堆栈帧中,以便可以使用该寄存器指向我们本地的记录副本❶。然后,我们将记录中的 12 个字节,其中 8 个字节存储在x0中,4 个字节存储在w1中,保存在堆栈帧的内存中,以创建我们本地的记录副本❷。

你的回合

17.3 更改清单 17-12 中字段的顺序,将char字段组合在一起,然后是int字段。重新编译清单 17-15、17-17、17-18、17-20 和 17-21 中的程序。将编译器生成的汇编语言与清单 17-16、17-19 和 17-22 中的内容进行比较。

你所学到的

数组 是存储在内存中连续的相同数据类型的数据项集合。

处理数组 CPU 有一种地址模式,可以通过索引值访问数组元素。

传递数组 在 C 语言中,数组是通过指针传递,而不是通过值传递。

记录 是将不同数据类型的数据项可能会在内存中一起存储的集合,可能会有用于地址对齐目的的填充。

访问记录字段 可以使用带有偏移地址模式的地址来访问记录字段。

传递记录 即使是输入记录,通过指针传递通常更高效。

在下一章中,我将向你展示 C++如何使用记录来实现面向对象编程范式。

第十八章:面向对象编程**

Image

到目前为止,在本书中,我一直使用过程化编程范式(在《用 C 探索数据格式》一章中描述,见第 23 页)。在这一章中,我将介绍如何在 C++ 中以汇编语言级别实现面向对象编程。

在面向对象编程中,我们可以创建对象,每个对象都是某个类的实例。类具有一组属性,即定义对象状态的数据项,以及方法,可以查询或改变类对象的属性。软件解决方案通常包括构造对象,然后编程向对象发送消息,对象使用方法作用于其属性。

我将使用 C++,一种 C 语言的面向对象扩展,来说明这些概念。你将学习如何使用记录来存储对象的属性,以及如何将方法实现为与记录关联的函数。

C++ 的许多其他特性对于创建良好的面向对象编程解决方案也非常重要,但我在本书中不会详细讨论。如果你是 C++ 新手,Josh Lospinoso 的《C++ 快速入门》(No Starch Press,2019)是一个不错的起点。如果你想在学习如何使用 C++ 后深入了解其设计,推荐 Bjarne Stroustrup(C++ 的创造者)所著的《C++ 之旅》第三版(Addison-Wesley Professional,2022)。另一个很好的在线资源是 C++ 核心准则,网址是isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines,该准则由 Bjarne Stroustrup 和 Herb Sutter 定期更新。

让我们从一个非常简单的 C++ 类开始,然后查看 C++ 编译器生成的汇编语言代码。

C++ 中的对象

C++ 允许我们创建,它们就像创建对象的蓝图。C++ 类非常像 C 语言中的记录,但除了定义对象属性的数据成员外,还可以包含作为类成员的函数。在 C++ 中,我们通过调用类的成员函数,向对象发送消息,指示它执行某个方法。

C++ 指定了六个特殊成员函数,用于创建和删除对象。在本书中,我将只介绍最常用的两个:构造函数和析构函数。构造函数用于创建对象的实例,这个过程称为实例化。构造函数的工作是分配必要的内存资源,并在向对象发送消息之前将其置于已知的状态。

C++ 编译器会在我们实例化对象的地方自动生成代码来调用构造函数。构造函数的名称与类相同,不能有返回值——甚至不能是 void默认构造函数 不接受任何参数。我们也可以编写接受参数的构造函数,一个类可以有多个构造函数。

destructor 函数的作用是释放构造函数分配的资源,从而删除对象。例如,构造函数可能会从堆上分配内存(在 第十章 中描述),而析构函数则负责释放这块内存。每个类只能有一个析构函数,它的名字与类相同,并以 ~(波浪号)字符开头。析构函数不能有返回值,并且不接受任何参数。当程序流离开对象的作用域时,C++ 编译器会自动生成代码来调用析构函数。

为了演示这些概念,我们将查看一个简单的 Fraction 类,它的属性是两个 intnumeratordenominator。我们将包括一个构造函数、一个析构函数以及一些用于处理 Fraction 对象的成员函数。如果我们没有提供构造函数或析构函数,C++ 编译器将自动提供适当的代码来执行对象的构造和销毁;我们将在“通过编译器编写构造函数和析构函数”一节中探讨这是什么意思,见 第 399 页。

我们从 Fraction 类的声明开始,将其放入头文件中,以便在任何使用该类的文件中都可以包含它,如 清单 18-1 所示。

fraction.h

   // A simple Fraction class

   #ifndef FRACTION_H
   #define FRACTION_H
   class Fraction {
❶ public:
       Fraction();               // Default constructor
       ~Fraction();              // Destructor
       void get();               // Get user's values
       void display();           // Display fraction
       void add_integer(int x);  // Add x to fraction
❷ private:
       int numerator;
       int denominator;
  };
  #endif

清单 18-1:C++ 分数

class 声明的整体语法类似于 C 语言中的记录声明,但它增加了将类的方法作为成员函数包含进来的功能。对类成员的访问可以是私有的受保护的公开的。我们这里只讨论基本的公开和私有访问控制,复杂的访问控制概念留给 C++ 书籍去讨论。

我们将成员函数声明为 public ❶。这意味着它们可以从类外部使用,用来向该类的对象发送消息。它们提供了与对象的接口。

我们将数据成员放在 class 声明的 private 部分 ❷。这意味着只有成员函数可以直接访问它们,从而让成员函数控制 numeratordenominator,即 Fraction 类的属性。类外的代码只能通过公共接口访问它们。

默认情况下,class 的访问权限是私有的。如果我们将属性列在 public 区域之前,则无需使用 private 关键字,但我喜欢先列出 class 的公共接口。

也可以使用 struct 关键字来声明 C++ 类,如 清单 18-2 所示。

fraction.h

// A simple Fraction class

#ifndef FRACTION_H
#define FRACTION_H
struct Fraction {
public:
    Fraction();               // Default constructor
    ~Fraction();              // Destructor
    void get();               // Get user's values
    void display();           // Display fraction void add_integer(int x);  // Add x to fraction
private:
    int numerator;
    int denominator;
};
#endif

清单 18-2:C++ Fraction 结构

C++ 中 struct 的默认访问范围是 public,但我喜欢明确声明。private 声明是私有作用域所必需的。

尽管 classstruct 除了默认的访问范围不同外是相同的,但我更喜欢使用 class 关键字,因为它强调了它不仅仅是一个简单的记录。然而,这只是个人的选择。接下来,我们将看看如何创建对象以及如何向对象发送消息。

创建一个对象

我将通过一个简单的程序来说明如何创建对象并向其发送消息,该程序允许用户输入分数的分子和分母值,然后将 1 加到分数上。这个程序在清单 18-3 中展示。

inc_fraction.cpp

// Get a fraction from the user and add 1.

#include "fraction.h"

int main(void)
{
 ❶ Fraction my_fraction;

 ❷ my_fraction.display();
    my_fraction.get();
    my_fraction.add_integer(1);
    my_fraction.display();

    return 0;
}

清单 18-3:一个将 1 加到分数的程序

我们通过使用类名并为对象提供一个名称来实例化一个对象 ❶,就像定义变量一样。点操作符(.)用于向类中的方法发送消息 ❷,该方法调用对象所属类中的相应成员函数。该程序在获取用户输入值之前显示分数的状态,然后在将 1 加到用户的分数后再次显示。

接下来,我们将查看 C++ 编译器生成的汇编语言,用于实现清单 18-3 中的 main 函数。我们环境中的 C++ 编译器叫做 g++

我使用了以下命令来生成清单 18-4 中的汇编语言:

$ g++ -S -Wall -O0 -fno-unwind-tables -fno-asynchronous-unwind-tables
      -fno-exceptions inc_fraction.cpp

这与我们一直用于 C 代码的命令相同,只不过我添加了 -fno-exceptions 选项。C++ 提供了一个异常机制来处理检测到的运行时错误。编译器通过汇编指令提供该功能的信息,这可能会使我们讨论对象实现的内容变得更加复杂。使用 -fno-exceptions 选项将关闭此功能。

inc_fraction.s

        .arch armv8-a
        .file   "inc_fraction.cpp"
        .text
        .align   2
        .global  main
        .type    main, %function
main:
     ❶ stp      x29, x30, [sp, -48]!
        mov      x29, sp
        str      x19, [sp, 16]
     ❷ add      x0, sp, 40
     ❸ bl       _ZN8FractionC1Ev          /// Construct my_fraction
        add      x0, sp, 40
     ❹ bl       _ZN8Fraction7displayEv
        add      x0, sp, 40
        bl       _ZN8Fraction3getEv
        add      x0, sp, 40
        mov      w1, 1
     ❺ bl       _ZN8Fraction11add_integerEi
        add      x0, sp, 40
        bl       _ZN8Fraction7displayEv
        mov      w19, 0
        add      x0, sp, 40
     ❻ bl       _ZN8FractionD1Ev         /// Destruct my_fraction
        mov      w0, w19
        ldr      x19, [sp, 16]
        ldp      x29, x30, [sp], 48
        ret
        .size    main, .-main
        .ident   "GCC: (Debian 10.2.1-6) 10.2.1 20210110"
        .section         .note.GNU-stack,"",@progbits

清单 18-4:C++ 编译器生成的用于 main 函数的汇编语言,见清单 18-3

首先需要注意的是,my_fraction(见清单 18-3 中的➊)是一个自动局部变量,因此在 main 函数的序言 ❶ 中为该 Fraction 对象分配了栈内存空间。该内存区域的地址被传递给构造函数 _ZN8FractionC1Ev,该函数将初始化该对象 ❷。

C++ 编译器将我们在清单 18-1 中声明的构造函数 Fraction 的名称修饰为_ZN8FractionC1Ev ❸。你在第十五章中学到了 C 编译器如何修饰静态局部变量的名称。其目的是区分同一文件中不同函数中具有相同名称的静态局部变量。

C++使用名称修饰将成员函数与其类关联起来。查看清单 18-4 中对类成员函数的调用,你会发现它们都以_ZN8Fraction开头。由于函数名是全局作用域的,包含类名可以让我们在程序中定义其他类,这些类的成员函数可能有相同的名称。例如,程序中可能有多个类都包含一个display成员函数。名称修饰通过类名来标识每个display成员函数所属的类❹。

C++名称修饰还允许函数重载,即拥有多个名称相同但参数数量和类型不同的类成员函数。在清单 18-4 中,修饰后的_ZN8Fraction11add_integerEi,就是我们的add_integer成员函数,最后有一个附加的i;这表示该函数接受一个int类型的单一参数❺。通过在名称修饰中包含参数的数量和类型,可以区分重载函数。在“你的任务”练习 18.1 中,你将有机会重载默认构造函数,页面 406 中有详细介绍。

名称修饰的标准并不统一,因此每个编译器可能会有不同的做法。这意味着程序中的所有 C++代码必须使用兼容的编译器和链接器进行编译和链接。

看一下每个成员函数调用之前的指令❷。对象的地址作为第一个参数传递给每个成员函数。这是一个隐式参数,在 C++代码中不会显示。在接下来的章节中,当我们查看成员函数内部时,你将看到如何访问这个地址。

尽管在我们编写的 C++代码中没有显示,但编译器会在程序流程离开对象的作用域时生成对析构函数的调用❻。在一些更高级的编程技术中,我们会显式调用析构函数,但书中不涉及这些内容。大多数时候,我们让编译器决定何时调用析构函数。

接下来,我们将看看这个Fraction类的构造函数、析构函数和其他成员函数。

定义类成员函数

尽管通常将每个 C 函数定义放在自己的文件中,但 C++源文件通常会组织成将所有函数定义包含在一个类中。清单 18-5 显示了我们Fraction类的成员函数定义。

fraction.cpp

   // A simple Fraction class

   #include "fraction.h"
   #include <iostream>
❶ using namespace std;

❷ Fraction::Fraction()
   {
    ❸ numerator = 0;
       denominator = 1;
   }

   Fraction::~Fraction() {}
   // Nothing to do for this object

   void Fraction::get()
   {
    ❹ cout << "Enter numerator: ";
    ❺ cin >> numerator;

       cout << "Enter denominator: ";
       cin >> denominator;

       if (denominator == 0) {
           cout << "WARNING: Setting 0 denominator to 1\n";
           denominator = 1;
       }
   }

   void Fraction::display()
   {
       cout << numerator << '/' << denominator << '\n';
   }

   void Fraction::add_integer(int x)
   {
       numerator += x * denominator;
   }

清单 18-5:我们Fraction类的成员函数定义

C++在你在第十四章中学到的 C 语言作用域规则上添加了一些作用域规则。尽管在清单 18-1 中声明的成员函数是公有的,但它们具有类作用域,这意味着它们的名称需要与类相关联。编译器通过装饰名称来实现这一点,但在类作用域外定义成员函数时,我们需要使用作用域解析运算符::)来建立这种关联❷。

构造函数的主要目的是初始化对象。我们将把Fraction对象设置为0/1❸,这是一个合理的初始值。

C++标准库提供了用于向屏幕写入和从键盘读取的对象。ostream类中的cout对象将字符流写入标准输出,通常连接到屏幕❹。它使用插入运算符<<)并将数据转换为适当的字符字符串。istream类中的cin对象从标准输入读取字符流,通常连接到键盘❺。它使用提取运算符>>)并将字符字符串转换为适合其读取的变量的数据类型。

除了类作用域外,C++还允许我们将事物的名称收集到命名空间作用域中。iostream头文件将coutcin对象放置在std命名空间中。我们需要在使用这些对象时指定该命名空间❶。

接下来,我们将查看这些成员函数的编译器生成的汇编语言。为了简化讨论,我们将分别查看文件中的每个函数,参见清单 18-6 到清单 18-11。在查看这些内容时,请记住这是一个文件,因此标签在所有六个清单中都是可见的。

fraction.s文件的开始部分,展示在清单 18-6 中,是为 C++ I/O 库中的cincout I/O 对象分配内存的代码。我们在清单 18-5 中包含iostream头文件,告诉编译器插入这段代码。

fraction.s(a)

        .arch armv8-a
        .file   "fraction.cpp"
        .text
        .section        .rodata       /// For operating system
        .align   3
        .type    _ZStL19piecewise_construct, %object
        .size    _ZStL19piecewise_construct, 1
_ZStL19piecewise_construct:
        .zero    1
     ❶ .local   _ZStL8__ioinit
     ❷ .comm    _ZStL8__ioinit,1,8

清单 18-6:cincout I/O 对象使用的字节

.local汇编指令将_ZStL8__ioinit标签限制在此对象内❶。.comm指令在内存中分配 1 个字节,并将其对齐到 8 字节地址,并标记为_ZStL8__ioinit❷。如果程序中有其他.comm指令使用相同的标签,它们将共享相同的内存。这段内存供cincout I/O 对象使用;它的具体用法超出了本书的范围。

清单 18-7 展示了文件的第二部分,即我们Fraction对象的构造函数。

fraction.s(b)

        .text
        .align  2
 .global _ZN8FractionC2Ev
        .type   _ZN8FractionC2Ev, %function
_ZN8FractionC2Ev:
        sub     sp, sp, #16
     ❶ str      x0, [sp, 8]                    /// Save this pointer
        ldr     x0, [sp, 8]
     ❷ str      wzr, [x0]                      /// numerator = 0;
        ldr     x0, [sp, 8]
        mov     w1, 1
     ❸ str      w1, [x0, 4]                    /// denominator = 1;
        nop
        add     sp, sp, 16
        ret
        .size   _ZN8FractionC2Ev, .-_ZN8FractionC2Ev
        .global _ZN8FractionC1Ev
        .set    _ZN8FractionC1Ev,_ZN8FractionC2Ev

清单 18-7:编译器生成的构造函数汇编语言,见清单 18-5

我们的构造函数初始化了位于栈帧中的 Fraction 对象的 numerator ❷ 和 denominator ❸ 数据成员。回到列表 18-4,你会看到每个成员函数的第一个参数是正在操作的对象的地址。但是查看列表 18-1 中的类声明时,你会发现该地址没有出现在成员函数的参数列表中。它被称为 隐藏参数

如果一个成员函数访问的是与调用它的对象属于同一类的另一个对象,它需要能够区分这两个对象。尽管它没有出现在参数列表中,C++ 使用 this 作为隐藏参数的名称,这是一个指针变量,包含了调用成员函数的对象的地址 ❶。

编译器假设我们的成员函数正在处理 this 指针变量中地址所指向的对象,因此我们通常不需要显式使用它。但某些情况要求我们显式地使用该指针。例如,我们可能会编写一个 Fraction 构造函数,允许我们像这样指定初始化值:

Fraction::Fraction(int numerator, int denominator) {
    this->numerator = numerator;
    this->denominator = denominator;
}

参数名称优先于成员名称,因此我们必须通过 this 指针来消除歧义。

现在你知道 Fraction 对象的数据成员在对象中的位置后,你可以在图 18-1 中看到该对象是如何存储在 main 的栈帧中的。

图片

图 18-1: my_fraction 对象在 main 的栈帧中

我不知道为什么编译器在这个栈帧中包含了 16 字节的空间,但程序中并没有使用这些空间。

构造函数后面是析构函数,如列表 18-8 所示。

fraction.s(c)

        .align  2
        .global _ZN8FractionD2Ev
        .type   _ZN8FractionD2Ev, %function
_ZN8FractionD2Ev:
        sub     sp, sp, #16
        str     x0, [sp, 8]
        nop
        add     sp, sp, 16
        ret
        .size   _ZN8FractionD2Ev, .-_ZN8FractionD2Ev
        .global _ZN8FractionD1Ev
        .set    _ZN8FractionD1Ev,_ZN8FractionD2Ev

列表 18-8:在 列表 18-5 中析构函数的编译器生成的汇编语言

在这个简单的类中,析构函数没有任何操作。之前在列表 18-4 中,你看到对象的内存是由 main 函数的序言分配到栈帧中的,而不是由构造函数分配的。同样,对象的内存会在 main 的尾声代码中,在调用析构函数后从栈中删除。一些构造函数从堆中分配内存,在这种情况下,析构函数应该释放这些内存。

析构函数的汇编语言后面是成员函数的汇编语言。列表 18-9 显示了 get 成员函数的汇编语言。

fraction.s(d)

        .section          .rodata
        .align   3
        .LC0:
        .string  "Enter numerator: "
        .align   3 
.LC1:
        .string  "Enter denominator: "
        .align   3
.LC2:
        .string  "WARNING: Setting 0 denominator to 1"
        .text
        .align   2
        .global  _ZN8Fraction3getEv
        .type    _ZN8Fraction3getEv, %function
_ZN8Fraction3getEv:
        stp      x29, x30, [sp, -32]!
        mov      x29, sp
        str      x0, [sp, 24]
        adrp     x0, .LC0
        add      x1, x0, :lo12:.LC0
      ❶ adrp     x0, :got:_ZSt4cout         /// From global library
        ldr      x0, [x0, #:got_lo12:_ZSt4cout]
        bl       _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc
        ldr      x0, [sp, 24]
        mov      x1, x0
      ❷ adrp     x0, :got:_ZSt3cin          /// From global library
        ldr      x0, [x0, #:got_lo12:_ZSt3cin]
        bl       _ZNSirsERi
        adrp     x0, .LC1
        add      x1, x0, :lo12:.LC1
        adrp     x0, :got:_ZSt4cout
        ldr      x0, [x0, #:got_lo12:_ZSt4cout]
        bl       _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc
        ldr      x0, [sp, 24]
        add      x0, x0, 4
        mov      x1, x0
        adrp     x0, :got:_ZSt3cin
        ldr      x0, [x0, #:got_lo12:_ZSt3cin]
        bl       _ZNSirsERi
        ldr      x0, [sp, 24]
        ldr      w0, [x0, 4]
        cmp      w0, 0                       /// Check for 0 denominator
        bne      .L5
        adrp     x0, .LC2
        add      x1, x0, :lo12:.LC2
        adrp     x0, :got:_ZSt4cout
        ldr      x0, [x0, #:got_lo12:_ZSt4cout]
        bl       _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc
        ldr      x0, [sp, 24]
        mov      w1, 1
        str      w1, [x0, 4] .L5:
        nop
        ldp      x29, x30, [sp], 32
        ret
        .size    _ZN8Fraction3getEv, .-_ZN8Fraction3getEv

列表 18-9:在 列表 18-5 中的 get 函数的编译器生成的汇编语言

当程序加载到内存中时,cout 对象的位置会被加载到我们的全局偏移表 (GOT) ❶ 中。cin 对象的位置也会被加载到我们的 GOT ❷ 中。

接下来,我们将查看 display 成员函数,如列表 18-10 所示。

fraction.s(e)

        .align   2
        .global _ZN8Fraction7displayEv
        .type   _ZN8Fraction7displayEv, %function
_ZN8Fraction7displayEv:
        stp     x29, x30, [sp, -32]!
        mov     x29, sp
        str     x0, [sp, 24]
        ldr     x0, [sp, 24]
        ldr     w0, [x0]
        mov     w1, w0
        adrp    x0, :got:_ZSt4cout
        ldr     x0, [x0, #:got_lo12:_ZSt4cout]
      ❶ bl      _ZNSolsEi
        mov     w1, 47          /// '/' character
      ❷ bl      _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_c
        mov     x2, x0
        ldr     x0, [sp, 24]
        ldr     w0, [x0, 4]
        mov     w1, w0
        mov     x0, x2
        bl      _ZNSolsEi
        mov     w1, 10          /// '/ n'   character
        bl      _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_c
        nop
        ldp     x29, x30, [sp], 32
        ret
        .size   _ZN8Fraction7displayEv, .-_ZN8Fraction7displayEv

清单 18-10:在清单 18-5 中显示的编译器生成的汇编语言

在我们的 C++代码中(见清单 18-5),我们将插入操作链式调用到cout对象上。编译器将数据项类型与它所调用的ostream类成员函数匹配。第一个值是int ❶,第二个是char ❷,依此类推。

清单 18-11 展示了编译器为add_integer成员函数生成的汇编语言。

fraction.s(f)

        .align  2
        .global _ZN8Fraction11add_integerEi
        .type   _ZN8Fraction11add_integerEi, %function
_ZN8Fraction11add_integerEi:
        sub     sp, sp, #16
      ❶ str     x0, [sp, 8]
        str     w1, [sp, 4]
        ldr     x0, [sp, 8]
        ldr     w1, [x0]
        ldr     x0, [sp, 8]
        ldr     w2, [x0, 4]
        ldr     w0, [sp, 4]
        mul     w0, w2, w0
        add     w1, w1, w0
        ldr     x0, [sp, 8]
        str     w1, [x0]
        nop
        add     sp, sp, 16
        ret
        .size   _ZN8Fraction11add_integerEi, .-_ZN8Fraction11add_integerEi

清单 18-11:在清单 18-5 中显示的 add_integer 编译器生成的汇编语言

this指针变量在该函数的栈帧中创建 ❶。这是一个叶子函数,因此编译器不会在栈帧中生成帧记录。

如果你使用g++编译器生成汇编语言,你将看到另外两个函数,_Z41__static_initialization_and_destruction_0ii_GLOBAL__sub_I__ZN8FractionC2Ev。操作系统在程序加载时调用这些函数,以设置coutcin输入输出流。具体细节超出了本书的范围,因此我不会在这里展示它们。

构造函数的目的是分配对象所需的系统资源并初始化对象。析构函数则释放构造函数分配的资源。在下一节中,你将看到编译器可以为像我们Fraction类这样简单的对象自动生成构造函数和析构函数。

通过编译器编写构造函数和析构函数

在 C++中,对象的初始化是复杂的,因为有许多种方式可以实现。上一节介绍了最基本的方式。现在,我将向你展示一些简单的 C++语法,它告诉编译器自己处理初始化的方式。

C++核心指南中的建议 C.45(见* isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rc-default *)指出:“不要定义仅初始化数据成员的默认构造函数;请改为使用类内成员初始化器。”

清单 18-12 展示了我们如何重写Fraction类,告诉编译器为我们生成构造函数和析构函数。

fraction_dflt.h

// A simple Fraction class

#ifndef FRACTION_DFLT_H
#define FRACTION_DFLT_H
class Fraction {
public:
 ❶ Fraction() = default;     // Tell compiler to generate default
    ~Fraction() = default;    //   constructor and destructor
    void get();               // Get user's values
    void display();           // Display fraction
    void add_integer(int x);  // Add x to fraction
private:
 ❷ int numerator {};
    int denominator {1};
};
#endif

清单 18-12:C++ Fraction 类指定默认构造函数和析构函数

2011 年 9 月发布的 C++11 标准新增了显式默认函数,这些函数通过= default符号来指定 ❶。标准规定,编译器必须生成函数体并尽可能将其内联。

根据 C++ 核心准则的建议,我在清单 18-12 中使用了类内成员初始化器来指定数据成员的初始化值❷。数据成员的初始值通过大括号 {} 来指定;一个空的大括号表示编译器使用 0。C++ 还允许以下语法,使用等号进行数据成员初始化:

int numerator = 0;
int numerator = {};

我喜欢使用简单的大括号初始化语法,因为它传达了一个信息,即变量的实际赋值直到对象实例化时才会发生,正如你将很快看到的那样。有关这些差异的讨论,请参阅本章开始时引用的 Josh Lospinoso 的书。

由于我们已经告诉编译器创建默认构造函数和析构函数,因此可以从我们的成员函数定义文件中删除这些函数,如清单 18-13 所示。

fraction_dflt.cpp

// A simple Fraction class

#include "fraction_dflt.h"
#include <iostream>
 using namespace std;

void Fraction::get()
{
    cout << "Enter numerator: ";
    cin >> numerator;

    cout << "Enter denominator: ";
    cin >> denominator;

    if (denominator == 0) {
        cout << "WARNING: Setting 0 denominator to 1\n";
        denominator = 1;
    }
}

void Fraction::display()
{
    cout << numerator << '/' << denominator << '\n';
}

void Fraction::add_integer(int x)
{
    numerator += x * denominator;
}

清单 18-13:带有编译器生成构造函数和析构函数的 C++ Fraction

清单 18-14 展示了此程序的 main 函数。

inc_fraction_dflt.cpp

   // Get a fraction from the user and add 1.

❶ #include "fraction_dflt.h"

   int main(void)
   {
       Fraction my_fraction;

       my_fraction.display();
       my_fraction.get();
       my_fraction.add_integer(1);
       my_fraction.display();

       return 0;
   }

清单 18-14:一个将 1 加到分数中的程序

这是与清单 18-3 中的 main 函数相同,唯一不同的是我们使用了来自清单 18-12 的fraction_dflt.h头文件,以匹配清单 18-13 中的成员函数定义❶。

这个头文件告诉编译器需要为我们编写构造函数和析构函数,如清单 18-15 所示。

inc_fraction_dflt.s

       .arch armv8-a
       .file   "inc_fraction_dflt.cpp"
       .text
       .align  2
       .global main
       .type   main, %function
main:
     ❶ stp     x29, x30, [sp, -32]!
       mov     x29, sp
     ❷ str     wzr, [sp, 24]                  /// int numerator {};
       mov     w0, 1
       str     w0, [sp, 28]                    /// int denominator {1};
       add     x0, sp, 24
       bl      _ZN8Fraction7displayEv
       add     x0, sp, 24
       bl      _ZN8Fraction3getEv
       add     x0, sp, 24
       mov     w1, 1
       bl      _ZN8Fraction11add_integerEi
       add     x0, sp, 24
       bl      _ZN8Fraction7displayEv
       mov     w0, 0
       ldp     x29, x30, [sp], 32
       ret
       .size   main, .-main
       .ident  "GCC: (Debian 10.2.1-6) 10.2.1 20210110"
       .section       .note.GNU-stack,"",@progbits

清单 18-15:由清单 18-14 生成的编译器汇编语言,显示了在清单 18-12 中指定的默认构造函数和析构函数

将此与清单 18-4 进行比较,你可以看到编译器为 Fraction 对象分配的栈帧比我们提供构造函数成员函数时少了 16 个字节❶。它随后将数据成员的初始化内联,而不是调用函数来进行初始化❷。

默认构造函数不接受任何参数,但我们可能希望在实例化对象时向构造函数传递一些参数。C++ 允许我们拥有多个构造函数,只要它们的参数列表不同。你将在下一节看到这如何运作。

在 C++ 中重载默认构造函数

函数重载是指类中的两个或多个函数具有相同的名称,但参数列表或返回类型不同。为了演示,我们将重载我们的默认构造函数,该构造函数不接受任何参数,使用一个接受单个 int 参数的构造函数,这样我们就可以在实例化 Fraction 对象时指定分子值。清单 18-16 展示了我们的新类。

fraction_2.h

// The Fraction class with two constructors

#ifndef FRACTION_2_H
#define FRACTION_2_H
class Fraction {
public:
     Fraction() = default;    // Tell compiler to generate default
  ❶ Fraction(int n) : numerator{n} {};  // Allow setting numerator

    ~Fraction() = default;
    void get();               // Get user's values
    void display();           // Display fraction
    void add_integer(int x);  // Add x to fraction
private:
  ❷ int numerator {123};     // Weird values so we can see
     int denominator {456};   //   what the compiler is doing
};
#endif

清单 18-16:向 Fraction 类添加第二个构造函数

第二个构造函数与默认构造函数的区别仅在于其参数列表 ❶。我们使用了 C++语法来告诉编译器如何利用参数初始化numerator数据成员。

我使用了奇怪的类内成员初始化值,以便让你更容易看出汇编语言是如何初始化我们的对象的 ❷。

让我们修改main函数,添加另一个使用我们重载构造函数的Fraction对象,如清单 18-17 所示。

inc_fractions.cpp

// Get two fractions from the user and increment each by 1.

#include "fraction_2.h"

int main(void)
{
    Fraction x;
    x.display();
    x.get();
    x.add_integer(1);
    x.display(); 
  ❶ Fraction y(78);
    y.display();
    y.get();
    y.add_integer(1);
    y.display();

    return 0;
}

清单 18-17:一个程序,向两个使用不同构造函数的分数加 1

对于第二个Fraction对象,我们将 78 传递给构造函数,作为初始值numerator ❶。

清单 18-18 展示了编译器如何用汇编语言实现第二个构造函数。

inc_fractions.s

           .arch armv8-a
           .file   "inc_fractions.cpp"
           .text
         ❶ .section .text._ZN8FractionC2Ei,"axG",@progbits,_ZN8FractionC5Ei,comdat
           .align  2
         ❷ .weak   _ZN8FractionC2Ei    /// Define label once
           .type   _ZN8FractionC2Ei, %function
❸ _ZN8FractionC2Ei:
           sub     sp, sp, #16
           str     x0, [sp, 8]
           str     w1, [sp, 4]         /// Save n
           ldr     x0, [sp, 8]
         ❹ ldr     w1, [sp, 4]
           str     w1, [x0]            /// Initialize numerator
           ldr     x0, [sp, 8]
         ❺ mov     w1, 456             /// Initialize denominator
           str     w1, [x0, 4]
           nop
           add     sp, sp, 16
           ret
           .size   _ZN8FractionC2Ei, .-_ZN8FractionC2Ei
           .weak   _ZN8FractionC1Ei
           .set    _ZN8FractionC1Ei,_ZN8FractionC2Ei
           .text
           .align  2
           .global main
           .type   main, %function
    main:
           stp     x29, x30, [sp, -32]!
           mov     x29, sp
           mov     w0, 123
           str     w0, [sp, 24] mov     w0, 456
           str     w0, [sp, 28]
           add     x0, sp, 24
           bl      _ZN8Fraction7displayEv
           add     x0, sp, 24
           bl      _ZN8Fraction3getEv
           add     x0, sp, 24
           mov     w1, 1
           bl      _ZN8Fraction11add_integerEi
           add     x0, sp, 24
           bl      _ZN8Fraction7displayEv
           add     x0, sp, 16
         ❻ mov     w1, 78               /// Constant supplied in main
           bl     _ZN8FractionC1Ei
           add    x0, sp, 16
           bl     _ZN8Fraction7displayEv
           add    x0, sp, 16
           bl     _ZN8Fraction3getEv
           add    x0, sp, 16
           mov    w1, 1
           bl     _ZN8Fraction11add_integerEi
           add    x0, sp, 16
           bl     _ZN8Fraction7displayEv
           mov    w0, 0
           ldp    x29, x30, [sp], 32
           ret
           .size  main, .-main
           .ident "GCC: (Debian 10.2.1-6) 10.2.1 20210110"
           .section       .note.GNU-stack,"",@progbits

清单 18-18:编译器生成的清单 18-17 中函数的汇编语言

编译器为我们的构造函数生成了一个独立的函数,该函数接受一个参数 ❸。编译器将这个构造函数放置在一个特殊的部分,标记为comdat,在那里可以从其他文件中的函数调用它,这些函数使用相同的构造函数来实例化一个Fraction对象 ❶。.weak汇编指令告诉编译器只在这个文件中生成一次这个标签 ❷。虽然我们在main函数中将一个显式整数传递给构造函数 ❻,但如果我们实例化多个Fraction对象,并且这些对象的变量值直到程序运行时才知道,那么独立的函数会更高效。

传递给_ZN8FractionC2Ei构造函数的参数用于初始化我们Fraction对象的numerator属性 ❹。我们的默认类内值用于初始化denominator属性 ❺。

由于编译器为我们编写了这个构造函数,我们只需要将定义其他成员函数的文件中的#include "fraction_dflt.h"更改为#include "fraction_2.h",如清单 18-13 所示。我不会在这里重复该清单。

用汇编语言编写面向对象的程序其实没有多大意义。我们可以为所有函数发明一个名称修饰方案,但我们仍然会使用过程式编程范式来调用它们,而不是面向对象范式。然而,在某些情况下,我们可能希望调用用汇编语言编写的函数。我们将在下一节中学习如何做到这一点。

轮到你了

18.1 在 C++ 程序中添加另一个构造函数,参考清单 18-1、18-3 和 18-5,该构造函数接受两个整数参数以初始化Fraction。添加一个使用第二个构造函数的对象。例如,Fraction y(1,2);将创建一个初始化为1/2Fraction对象。修改main函数以显示这个第二个Fraction对象,获取其新值,为第二个对象加上一个整数,并再次显示它。

18.2 编写一个 C++ 程序,提示用户输入分子和分母,然后使用用户的值实例化一个Fraction对象。显示新对象,为其加上 1,并显示该对象的新状态。

在 C++ 中调用汇编语言函数

为了展示如何在 C++ 中调用汇编语言函数,我将修改我们的Fraction成员函数,使用我们的汇编语言函数write_strwrite_charput_intget_int来进行屏幕输出和键盘输入。清单 18-19 展示了我们需要在成员函数定义中做出的修改。

fraction_asm.cpp

   // A simple Fraction class

   #include "fraction_dflt.h"
   // Use the following C functions.
❶  extern "C" {
        #include "write_str.h"
        #include "write_char.h"
        #include "get_int.h"
        #include "put_int.h"
   }
   // Use char arrays because write_str is a C function.
   char num_msg[] = "Enter numerator: ";
   char den_msg[] = "Enter denominator: ";

   void Fraction::get()
   {
       write_str(num_msg);
       numerator = get_int();
       write_str(den_msg); 
       denominator = get_int();
   }

   void Fraction::display()
   {
       put_int(numerator);
       write_char('/');
       put_int(denominator);
       write_char('\n');
   }

   void Fraction::add_integer(int x)
   {
       numerator += x * denominator;
   }

清单 18-19:在Fraction类中调用汇编语言函数

extern "C"告诉 C++ 编译器这些头文件中的项目具有 C 链接方式,因此编译器不会修饰它们的名称❶。

清单 18-20 展示了这如何影响get成员函数。

fraction_asm.s

        .arch armv8-a
        .file   "fraction_asm.cpp"
        .text
        .global num_msg
        .data
        .align  3
        .type   num_msg, %object
        .size   num_msg, 18
num_msg:
        .string "Enter numerator: "
        .global den_msg
        .align  3
        .type   den_msg, %object
        .size   den_msg, 20
den_msg:
        .string "Enter denominator: "
        .text
        .align  2
        .global _ZN8Fraction3getEv
        .type   _ZN8Fraction3getEv, %function
_ZN8Fraction3getEv:
        stp     x29, x30, [sp, -32]!
        mov     x29, sp
        str     x0, [sp, 24]
        adrp    x0, num_msg
        add     x0, x0, :lo12:num_msg
      ❶ bl      write_str 
        bl      get_int
        mov     w1, w0
        ldr     x0, [sp, 24]
        str     w1, [x0]
        adrp    x0, den_msg
        add     x0, x0, :lo12:den_msg
        bl      write_str
        bl      get_int
        mov     w1, w0
        ldr     x0, [sp, 24]
        str     w1, [x0, 4]
        nop
        ldp     x29, x30, [sp], 32
        ret
        .size   _ZN8Fraction3getEv, .-_ZN8Fraction3getEv
--snip--

清单 18-20:在清单 18-19 中,编译器生成的汇编语言对应的get函数

汇编语言函数通过我们为它们指定的名称进行调用,没有任何修饰❶。如果你使用g++生成display成员函数的汇编语言,你会看到类似的结果,对于put_intwrite_char函数的调用也是如此。

你学到了什么

声明定义对象状态的数据成员,并包括用于访问这些数据成员的任何成员函数。

C++ 中的对象 一个命名的内存区域,包含类中声明的数据成员。

方法或成员函数 类中声明的成员函数可以被调用来访问同一类对象的状态。

名称修饰 编译器创建成员函数名称时,会包括函数名、所属类以及函数的参数数量和类型。

构造函数 一种成员函数,用于初始化对象。

析构函数 一种成员函数,用于清理不再需要的资源。

本章简要介绍了 C++ 实现基本面向对象编程特性的方法。

现在你已经学会了几种在程序中组织数据的技术,我将回到如何在二进制中存储数据的话题。到目前为止,在本书中,我只在程序中使用了整数值。在下一章,你将看到小数值是如何在内存中表示的,并了解一些用于操作它们的 CPU 指令。

第十九章:分数值

Image

到目前为止,我们的程序只使用了整数值——整数和字符。在本章中,我们将了解计算机如何表示分数值。你将学习两种表示分数值的方法:定点和浮点。

我将从定点数开始,向你展示分数值如何在二进制中表示。正如你将看到的那样,使用一些位来表示数字的小数部分,会减少剩余位数给整数部分,这样就减少了我们能表示的数字范围。包含小数部分只能将该范围划分为更小的部分。

这个范围限制将引导我们讨论浮点数,它们允许更大的范围,但也带来其他限制。我将向你展示浮点数表示法的格式和特性,然后讨论最常见的浮点二进制标准 IEEE 754。我将在本章末简要介绍 A64 架构中浮点数的处理方式。

二进制中的分数值

让我们从分数值的数学表达开始。回顾一下第二章,我们知道十进制整数 N 可以用二进制表示为

Image

其中每个 d[i] = 01

我们可以扩展它以包括一个分数部分 F,使得

Image

其中每个 d[i] = 01。注意此方程右侧 d[0] 和 d[-1] 之间的 二进制点。所有二进制点右侧的项都是二的负次幂,因此这一部分数字的和就是分数值。就像左侧的十进制点一样,二进制点将数字的小数部分与整数部分分开。这里有一个例子:

Image

尽管任何整数都可以表示为二的幂的和,但二进制中分数值的精确表示仅限于二的次幂的和。例如,考虑分数值 0.9 的 8 位表示。根据以下等式

Image

我们得到如下结果:

Image

事实上,

Image

其中 Image 表示该位模式无限重复。

要将分数值四舍五入到最近的值,请检查四舍五入位置右侧的位。如果右侧的下一个位是 0,则丢弃四舍五入位置右侧的所有位。如果右侧的下一个位是 1 且其后跟随的任何位都是 1,则在四舍五入的位置加 1

如果右边的下一个位是1,且所有后续位都是0,则使用舍入到偶数规则。如果你要舍入的位是0,直接丢弃舍入位右边的所有位。如果你要舍入的位是1,则将其加1,然后丢弃舍入位右边的所有位。

让我们将 0.9 四舍五入到 8 位。前面你已经看到,在二进制点右边的第九位是0,所以我们将丢弃第八位之后的所有位。于是我们使用

图像

这会导致如下的舍入误差:

图像

AArch64 架构支持其他浮动点舍入算法。这些内容在《A-Profile 架构的 Arm 架构参考手册》中有详细讨论,手册可在developer.arm.com/documentation/ddi0487/latest找到。

我们通常以十进制表示数字,数字中有一个固定位置的小数点,用来将分数部分与整数部分分开。让我们看看这种方法在二进制中是如何工作的。

定点数

定点数本质上是一个缩放的整数表示,其中缩放通过基数点的位置显示,基数点将数字的分数部分与整数部分分开。在十进制中,我们称其为小数点,在二进制中,我们称其为二进制点。讲英语的国家通常使用句点,其他地区通常使用逗号。

例如,1,234.5[10]表示 12,345[10]按 1/10 进行缩放,二进制的 10011010010.1[2]是 100110100101[2]按 1/2 的比例缩放。当使用定点数进行计算时,你需要注意小数点的位置。

在本节的第一部分,我们将研究具有分数部分是二的倒数次幂的数字缩放情况,在这种情况下分数部分可以精确表示。然后,我们将研究如何将十进制中的分数数字进行缩放,以避免之前描述的舍入误差。

当分数部分是二的倒数次幂的和时

我将从一个程序开始,它将添加两个指定到最近十六分之一的测量值。一个例子是测量英寸的长度。英寸的分数部分通常以二的倒数次幂(1/2、1/4、1/8 等)表示,这些可以在二进制系统中精确表示。

我们的程序使用到最接近的十六分之一长度,因此我们将每个值乘以 16,以便得到一个完整的十六分之一数字。程序首先从键盘读取长度的整数部分,然后读取十六分之一的数量。列表 19-1 展示了我们如何缩放数字的整数部分,然后将分数部分按读取顺序加上。

get_length.s

// Get a length in inches and 1/16s.
// Calling sequence:
//    Return the fixed-point number.
        .arch armv8-a
// Stack frame
        .equ    save19, 16
        .equ    FRAME, 32
// Constant data
        .section  .rodata
        .align  3
prompt:
        .string "Enter length (inches and 1/16s)\n"
inches:
        .string "      Inches: "
fraction:
        .string "  Sixteenths: "
// Code
        .text
        .align  2
        .global get_length
        .type   get_length, %function
get_length:
        stp     fp, lr, [sp, -FRAME]!  // Create stack frame
        mov     fp, sp                 // Set our frame pointer
        str     x19, [sp, save19]      // For local var

        adr     x0, prompt             // Ask for length
        bl      write_str 
        adr     x0, inches             // Ask for integer
        bl      write_str
      ❶ bl      get_uint               // Integer part
      ❷ lsl     w19, w0, 4             // 4 bits for fraction

        adr      x0, fraction          // Ask for fraction
        bl       write_str
        bl       get_uint              // Fractional part
        add      w0, w0, w19           // Add integer part

        ldr      x19, [sp, save19]     // Restore for caller
        ldp      fp, lr, [sp], FRAME   // Delete stack frame
        ret                            // Back to caller

列表 19-1:一个从键盘读取英寸和十六分之一英寸的数字的函数

我们为英寸数和英寸的十六分之一数分配了 32 位,每个值都作为整数从键盘读取。请注意,我们使用了get_uint函数来读取每个无符号int❶。你在“你的挑战”练习 16.9 中被要求编写这个函数,见第 358 页。

我们将整数部分左移 4 位,以便将其乘以 16❷。在加上分数部分后,我们得到该值的总十六分之一数。例如,5 9/16 将存储为整数 5 × 16 + 9 = 89。

缩放操作为整数部分保留了 28 位。这限制了我们的数字范围为 0 到 268,435,455 15/16。这个范围是 32 位无符号整数 0 到 4,294,967,295 范围的 1/16,但分辨率为最接近的 1/16。

我们用来显示这些测量值的函数,如清单 19-2 所示,显示了整数部分和分数部分。

display_length.s

// Display a length to the nearest sixteenth.
        .arch armv8-a
// Calling sequence:
//    w0[31-4] <- integer part
//    w0[3-0]  <- fractional part
//    Return 0.
// Useful constants
     ❶ .equ     FOUR_BITS, 0xf        // For fraction
// Stack frame
        .equ     save19, 16
        .equ     FRAME, 32
// Constant data
        .section   .rodata
        .align   3
sixteenths:
        .string  "/16"
// Code
        .text
        .align   2
        .global  display_length
        .type    display_length, %function
display_length:
        stp      fp, lr, [sp, -FRAME]!  // Create stack frame
        mov      fp, sp                 // Set our frame pointer
        str      x19, [sp, save19]      // For local var

        mov      w19, w0                // Save input.
     ❷ lsr      w0, w19, 4             // Integer part
        bl       put_uint

        mov      w0, '   '              // Some formatting
        bl       write_char

     ❸ and       w0, w19, FOUR_BITS     // Mask off integer
        bl        put_uint               // Fractional part
 ❹ adr       x0, sixteenths         // More formatting
        bl        write_str

        mov       w0, wzr                // Return 0
        ldr       x19, [sp, save19]      // Restore for caller
        ldp       fp, lr, [sp], FRAME    // Delete stack frame
        ret                              // Back to caller

清单 19-2:一个显示数字到最接近十六分之一的函数

我们将数字右移 4 位,以便将整数部分显示为一个整数❷。使用一个 4 位的掩码❶,我们屏蔽掉整数部分,将分数部分显示为另一个整数❸。我们添加一些文本以显示这个第二个整数是分数部分❹。

清单 19-3 展示了一个main函数,它将两个数字加到最接近的十六分之一。

add_lengths.s

// Add 2 lengths, fixed-point, to nearest sixteenth.
        .arch armv8-a
// Stack frame
        .equ    save1920, 16
        .equ    FRAME, 32
// Constant data
        .section  .rodata
        .align   3
sum_msg:
        .string  "Sum = "
// Code
        .text
        .align   2
        .global  main
        .type    main, %function
main:
        stp      fp, lr, [sp, -FRAME]!        // Create stack frame
        mov      fp, sp                       // Set our frame pointer
        stp      x19, x20, [sp, save1920]     // For local vars

        bl       get_length
        mov      w19, w0                      // First number
        bl       get_length
        mov      w20, w0                      // Second number

        adr      x0, sum_msg                  // Some formatting
        bl       write_str
     ❶ add      w0, w20, w19                 // Add lengths
        bl       display_length               // Show result
 mov      w0, '\n'                     // Finish formatting
        bl       write_char

        mov      w0, wzr                      // Return 0
        ldp      x19, x20, [sp, save1920]     // Restore for caller
        ldp      fp, lr, [sp], FRAME          // Delete stack frame
        ret                                   // Back to caller

清单 19-3:一个将两长度加到最接近的十六分之一的程序

如果你查看第 410 页中表示分数值的二进制方程,你可能会说服自己,整数add指令将适用于整个数字,包括分数部分❶。

让我们思考一下在这里如何处理分数部分的定点格式。当我们从键盘读取整数部分时,我们将其左移了四个位位置,以便乘以 16。这为加上分数部分的十六分之一数留出了空间。我们实际上创建了一个 32 位的数字,其中二进制点位于第五和第四位之间(位 4 和位 3)。这是有效的,因为分数部分是二的负幂的和。

这个示例在二进制数字上工作得很好,但在计算中我们主要使用十进制数字。正如你在本章前面看到的,大多数分数十进制数字无法转换为有限的位数,需要四舍五入。在下一节中,我将讨论如何避免在将分数十进制数字表示为二进制时出现四舍五入错误。

当分数部分是十进制时

我将使用一个程序,作为示例来说明如何将两个美元值加到最接近的分,这就是在十进制中使用分数值的方式。与清单 19-1 到 19-3 中的测量加法程序一样,我们将从读取货币值的函数get_money开始,具体见清单 19-4。

get_money.s

// Get dollars and cents from the keyboard.
// Calling sequence:
//    Return integer amount as cents.
        .arch armv8-a
// Stack frame
        .equ    save19, 16
        .equ    FRAME, 32
// Constant data
        .section  .rodata
        .align  3
prompt:
        .string "Enter amount (use same sign for dollars and cents)\n"
dollars:
        .string "   Dollars: " 
cents:
        .string "   Cents: "
// Code
        .text
        .align  2
        .global get_money
        .type   get_money, %function
get_money:
        stp     fp, lr, [sp, -FRAME]!   // Create stack frame
        mov     fp, sp                  // Set our frame pointer
        str     x19, [sp, save19]       // For local var

        adr     x0, prompt              // Ask for amount
        bl      write_str
        adr     x0, dollars             // Ask for dollars
        bl      write_str
        bl      get_int                 // Dollars
     ❶ mov      w1, 100                // 100 cents per dollar
        mul     w19, w0, w1             // Scale

        adr     x0, cents               // Ask for cents
        bl      write_str
        bl      get_int                 // Cents
     ❷ add     w0, w0, w19             // Add scaled dollars

        ldr     x19, [sp, save19]       // Restore for caller
        ldp     fp, lr, [sp], FRAME     // Delete stack frame
        ret                             // Back to caller

Listing 19-4:一个从键盘读取美元和分的函数

我们的货币值指定为最接近的分,因此我们将美元—整数部分—乘以 100❶。然后,我们将分—小数部分—加到一起,得到我们缩放后的int值❷。

在存储小数分数时,整数部分和小数部分不会像我们之前的例子那样分成不同的比特字段。例如,$1.10 会存储为 110 = 0x0000006e,$2.10 会存储为 210 = 0x000000d2。由于我们在这个程序中使用的是 32 位带符号整数,因此货币值的范围是 –$21,473,836.48 ≤ money_amount ≤ +$21,473,836.47。

显示美元和分将需要与显示长度(以分之一为单位)不同的算法,如 Listing 19-5 所示。

display_money.s

// Display dollars and cents.
        .arch armv8-a
// Calling sequence:
//    w0 <- value in cents
//    Return 0.
 // Stack frame
        .equ      save1920, 16
        .equ      FRAME, 32
// Constant data
        .section    .rodata
        .align   3
// Code
        .text
        .align   2
        .global  display_money
        .type    display_money, %function
display_money:
        stp      fp, lr, [sp, -FRAME]!     // Create stack frame
        mov      fp, sp                    // Set our frame pointer
        stp      x19, x20, [sp, save1920]  // For local vars

        mov      w1, 100                   // 100 cents per dollar
     ❶ sdiv     w20, w0, w1               // Dollars
        msub     w19, w20, w1, w0          // Leaving cents

        mov      w0, '$'                   // Some formatting
        bl       write_char
        mov      w0, w20                   // Dollars
        bl       put_int

        mov      w0, '.'s                  // Some formatting
        bl       write_char
        cmp      w19, wzr                  // Negative?
     ❷ cneg    w19, w19, mi               // Make non-negative
     ❸ cmp     w19, 10                    // Check for single digit
        b.hs     no_zero                   // Two digits
        mov      w0, '0'                   // One digit needs leading '0'  
        bl       write_char
no_zero:
        mov      w0, w19                   // Cents
        bl       put_int

        mov      w0, wzr                   // Return 0
        ldp      x19, x29, [sp, save1920]  // Restore for caller
        ldp      fp, lr, [sp], FRAME       // Delete stack frame
        ret                                // Back to caller

Listing 19-5:一个显示美元和分的函数

移位操作不能让我们除以 100,因此我们使用带符号除法指令div来获取美元❶。这次除法的余数就是分数。

我们对余数的计算将与整数部分具有相同的符号。负号在显示美元时会出现,但我们不希望它出现在分数部分,因此在显示分数之前,我们会取反分数的值❷。我们会检查分数是否小于 10,如果是,我们会将小数点右边的第一个数字设为 0❸。

我们在这里看到一个新指令,cneg:

cneg—条件取反

cneg wd, ws,cond 在 cond 为真时,将ws中的 32 位值取反后加载到wd 中。如果 cond 不为真,则将ws加载到wd 中。

cneg xd, xs,cond 在 cond 为真时,将xs中的 64 位值取反后加载到xd 中。如果 cond 不为真,则将xs加载到xd 中。

可能的条件cond可以是 Table 13-1 中列出的任何条件标志,除了alnv

该程序的main函数,如 Listing 19-6 所示,将从键盘输入两个美元金额,将它们相加,并显示它们的总和。

add_money.s

// Add two dollar values.
        .arch armv8-a
// Stack frame
        .equ    save1920, 16
        .equ    FRAME, 32
// Constant data
        .section  .rodata
        .align  3
sum_msg:
        .string "Sum = "
// Code
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     fp, lr, [sp, -FRAME]!        // Create stack frame
        mov     fp, sp                       // Set our frame pointer
        stp     x19, x20, [sp, save1920]     // For local vars

        bl      get_money
        mov     w19, w0                      // First number
        bl      get_money
        mov     w20, w0                      // Second number

        adr     x0, sum_msg                  // Some formatting
        bl      write_str
     ❶ add      w0, w19, w20                // Add values
        bl      display_money                // Show result
        mov     w0, '\n'                     // Finish formatting 
        bl      write_char

        mov     w0, wzr                      // Return 0
        ldp     x19, x20, [sp, save1920]     // Restore for caller
        ldp     fp, lr, [sp], FRAME          // Delete stack frame
        ret                                  // Back to caller

Listing 19-6:一个添加两个美元值的程序

我们对整数部分的缩放已将美元转换为分,因此一个简单的add指令就能为我们计算总和❶。我们的display_money函数将整理出该总和中的美元和分。

该方案在处理许多数字时表现良好,但我们通常使用科学记数法来表示非常大或非常小的数字。在接下来的章节中,你将看到科学记数法是如何引入另一种存储小数值的方式的。

你的回合

19.1 输入 Listings 19-1 至 19-3 中的程序。使用gdb调试器,检查main中的w19w20寄存器中存储的数字。识别整数部分和小数部分。

19.2   输入清单 19-4 到 19-6 中的程序。使用gdb调试器检查在main中存储在w19w20寄存器中的数字。识别出整数部分和小数部分。

19.3   输入清单 19-4 到 19-6 中的程序。运行程序,使用$21,474,836.47 作为一个数值,$0.01 作为另一个数值。程序给出的总数是多少?为什么?

19.4   编写一个汇编语言程序,允许用户输入开始时间和任务所需的时间,然后计算完成时间。使用 24 小时制时钟,并精确到秒。

浮动点数

浮动点数的范围比定点数要大得多。然而,重要的是要理解浮动点数并不是实数。实数包括从–到+的所有数字的连续体。你已经知道计算机中的位数是有限的,因此可以表示的最大值是有限制的。但问题不仅仅是数量级的限制。

正如你在这一节中看到的,浮动点数只是实数的一个小子集。相邻的浮动点数之间存在显著的间隙。这些间隙可能会导致几种类型的错误,详细内容请见“浮动点算术错误”,在第 425 页。更糟糕的是,这些错误可能出现在中间结果中,使得它们很难调试。

浮动点表示法

浮动点表示法基于科学记数法。在浮动点表示法中,我们有一个符号和两个数字来完全指定一个值:有效数字指数。一个十进制浮动点数被写作有效数字乘以 10 的指数幂。例如,考虑这两个数字:

Image

在浮动点表示法中,数字是标准化的,即小数点左边只有一个数字,并且 10 的指数相应调整。如果我们同意每个数字都经过标准化,并且我们在使用基数为 10 的表示法,那么每个浮动点数由三项完全指定:有效数字、指数和符号。在前两个例子中:

Image

使用浮动点表示法的优点是,在给定的数字位数下,我们可以表示更大的数值范围。

让我们来看看浮动点数是如何在计算机中存储的。

IEEE 754 浮动点标准

存储浮动点数的最常用标准是 IEEE 754(* standards.ieee.org/standard/754-2019.html *)。图 19-1 展示了其一般模式。

Image

图 19-1:存储 IEEE 754 浮动点数的一般模式

A64 架构支持四种变体格式来存储浮动点数:两种 16 位格式,一种 32 位格式和一种 64 位格式。其中,16 位半精度、32 位单精度和 64 位双精度格式遵循 IEEE 754 标准。BF16格式(也称为BFloat16)与 IEEE 754 单精度格式相同,但具有截断的有效数字。这样可以在保持 32 位格式的动态范围的同时减少内存存储需求,但牺牲了精度。这个折衷在一些机器学习算法中很有用。A64 架构包含操作 BF16 数据的指令,但我们在本书中不会使用它们。这些格式在图 19-2 中展示。

Image

图 19-2:存储(a)半精度,(b)BF16,(c)单精度和(d)双精度浮动点数的格式

图 19-2 中显示的格式中的值表示以标准化形式存储的浮动点数N

Image

第一个位,s,是符号位,0表示正数,1表示负数。与十进制科学记数法类似,指数会进行调整,以便二进制点左侧只有一个非零数字。不过,在二进制中,这个数字总是 1,因此有效数字为 1.f。由于它总是 1,因此整数部分(1)不需要存储。这被称为隐藏位。只存储有效数字的小数部分f

这些格式需要支持负指数。你可能首先想到使用二进制补码。然而,IEEE 标准是在 1970 年代开发的,那时浮动点计算需要大量的 CPU 时间。许多程序中的算法仅依赖于两个数字的比较,当时的计算机科学家意识到,允许整数比较指令的格式会导致更快的执行时间。因此,他们决定在存储指数之前加上一个叫做偏置的数值,使得最小的可允许指数存储为 0。结果是一个带偏置的指数,可以存储为无符号整数。在图 19-2 中,半精度 IEEE 格式的偏置为 15,单精度 IEEE 和 BF16 格式的偏置为 127,双精度 IEEE 格式的偏置为 1,023。

隐藏位方案存在一个问题:无法表示 0。为了解决这个问题以及其他问题,IEEE 754 标准包含了几个特殊情况:

零值 所有偏置指数位和分数字段都为0,因此可以表示–0 和+0。这样可以保留收敛到 0 的计算的符号。

非正规化 如果待表示的值小于所有偏置指数位均为0时能表示的值,这意味着e具有最小的负值,此时不再假定隐藏位存在。在这种情况下,偏移量减少 1。

无穷大 无穷大通过将所有偏置指数位设置为1,所有分数字段设置为0来表示。这样,符号位可以同时表示+和–,使我们仍然能够比较超出范围的数字。

非数(NaN) 如果偏置指数位全为1,但分数字段不全为0,则表示一个错误值。这可以用来表示一个浮点变量尚未赋值。NaN 应该视为程序错误。

一个导致无穷大的运算示例是将一个非零值除以 0。产生 NaN 的示例是具有未定义结果的运算,例如将 0 除以 0。

接下来,我将讨论用于处理浮点数的 A64 硬件。

浮点硬件

表 9-1 在第九章中展示了 A64 架构包含一个寄存器文件,该文件有 32 个 128 位寄存器,用于浮点或向量运算,这些寄存器被称为SIMD&FP寄存器。

A64 架构包括向量指令,可以同时对 SIMD&FP 寄存器中的多个数据项进行操作。这是一种称为单指令多数据(SIMD)的计算方法。这些指令的数据项可以是 8 到 64 位,因此一个寄存器可以容纳 2 到 16 个数据项。对于整数和浮点操作都有向量指令。

向量指令对 SIMD&FP 寄存器中的每个数据项进行独立操作,不受寄存器中其他数据项的影响。这些指令对于处理数组等任务的算法非常有用。一个向量指令可以并行处理多个数组元素,从而显著提高运算速度。此类算法在多媒体和科学应用中很常见。

A64 架构还包括标量浮点指令,这些指令操作 SIMD&FP 寄存器低位部分的单个浮点数据项。

使用 SIMD 指令进行编程超出了本书的范围;我们这里只讨论标量浮点运算。图 19-3 展示了用于标量浮点指令的 SIMD&FP 寄存器部分的名称。

图片

图 19-3:A64 浮点寄存器名称

清单 19-7 展示了我们如何使用这些寄存器进行浮点算术运算。

add_floats.s

// Add two floats.
        .arch armv8-a
// Stack frame
        .equ    x, 16
        .equ    y, 20
        .equ    FRAME, 32
// Constant data
        .section  .rodata
        .align   3
prompt_format:
        .string  "Enter number: "
get_format:
        .string  "%f"
sum_format:
        .string  "%f + %f = %f\n"
// Code
        .text
        .align   2
        .global  main
        .type    main, %function
main:
        stp      fp, lr, [sp, FRAME]!    // Create stack frame
        mov      fp, sp

        adr      x0, prompt_format       // Ask for number
        bl       printf
        add      x1, sp, x               // Place for first number
        adr      x0, get_format          // Get it
     ❶ bl       scanf
        adr      x0, prompt_format       // Ask for number
        bl       printf
        add      x1, sp, y               // Place for second number
        adr      x0, get_format          // Get it
        bl       scanf

     ❷ ldr      s0, [sp, x]              // Load x
        ldr      s1, [sp, y]              //      and y
     ❸ fadd     s2, s0, s1               // Sum
        fcvt     d0, s0                   // Doubles for printf
        fcvt     d1, s1
        fcvt     d2, s2
        adr      x0, sum_format           // Formatting for printf
        bl       printf 
        mov      w0, wzr                  // Return 0
        ldp      fp, lr, [sp], FRAME      // Delete stack frame
        ret

清单 19-7:一个程序用于加法运算两个浮点数

我们使用 C 标准库中的 scanf 函数从键盘读取浮点数 ❶。这会将数字以 32 位 IEEE 754 格式存储在内存中。因此,我们不需要特殊指令将数字加载到浮点寄存器中;我们可以直接使用 ldr 指令 ❷。

我们需要使用浮点加法指令 fadd 来计算数字 ❸ 的和。我不会列出所有用于执行算术运算的浮点指令,但这里列出了四个基本指令:

fadd—浮点加法(标量)

fadd hd, hs1, hs2hs1 和 hs2 中的半精度浮点数相加,并将结果存储在 hd 中。

fadd sd, ss1, ss2ss1 和 ss2 中的单精度浮点数相加,并将结果存储在 sd 中。

fadd dd, ds1, ds2ds1 和 ds2 中的双精度浮点数相加,并将结果存储在 dd 中。

fsub—浮点减法(标量)

fsub hd, hs1, hs2hs2 中减去 hs1 中的半精度浮点数,并将结果存储在 h`d 中。

fsub sd, ss1, ss2ss1 中减去 ss2 中的单精度浮点数,并将结果存储在 s`d 中。

fsub dd, ds1, ds2ds1 中减去 ds2 中的双精度浮点数,并将结果存储在 d`d 中。

fmul—浮点乘法(标量)

fmul hd, hs1, hs2hs1 和 hs2 中的半精度浮点数相乘,并将结果存储在 hd 中。

fmul sd, ss1, ss2ss1 和 ss2 中的单精度浮点数相乘,并将结果存储在 sd 中。

fmul dd, ds1, ds2ds1 和 ds2 中的双精度浮点数相乘,并将结果存储在 dd 中。

fdiv—浮点除法(标量)

fdiv hd, hs1, hs2hs1 中的半精度浮点数除以 hs2 中的半精度浮点数,并将结果存储在 h`d 中。

fdiv sd, ss1, ss2ss1 中的单精度浮点数除以 ss2 中的单精度浮点数,并将结果存储在 s`d 中。

fdiv dd, ds1, ds2ds1 中的双精度浮点数除以 ds2 中的双精度浮点数,并将结果存储在 d`d 中。

printf 函数要求浮点数以 double 类型传递,因此我们使用 fcvt 指令将 float 值转换为 double 类型。fcvt 指令将源寄存器中的浮点格式转换为目标寄存器中的浮点格式:

fcvt—浮点精度转换(标量)

fcvt sd, hshs 中的半精度浮点数转换为 s`d 中的单精度浮点数。

fcvt dd, hshs 中的半精度浮点数转换为 d`d 中的双精度浮点数。

fcvt hd, ssss 中的单精度浮点数转换为 h`d 中的半精度浮点数。

fcvt dd, ssss 中的单精度浮点数转换为 d`d 中的双精度浮点数。

fcvt hd, dsds 中的双精度浮点数转换为 h`d 中的半精度浮点数。

fcvt sd, ds 将ds 中的双精度转换为sd 中的单精度。

尽管在这个程序中我们没有使用比较操作,但这里有一个浮点数比较指令的例子:

fcmp—浮点数比较(标量)

fcmp hs1, hs2 将hs1 中的半精度浮点数与hs2 进行比较,并相应地设置nzcv寄存器中的条件标志。

fcmp hs, 0.0hs 中的半精度浮点数与 0.0 进行比较,并相应地设置nzcv寄存器中的条件标志。

fcmp ss1, ss2 将ss1 中的单精度浮点数与ss2 进行比较,并相应地设置nzcv寄存器中的条件标志。

fcmp ss, 0.0ss 中的单精度浮点数与 0.0 进行比较,并相应地设置nzcv寄存器中的条件标志。

fcmp ds1, ds2 将ds1 中的双精度浮点数与ds2 进行比较,并相应地设置nzcv寄存器中的条件标志。

fcmp ds, 0.0ds 中的双精度浮点数与 0.0 进行比较,并相应地设置nzcv寄存器中的条件标志。

由于fcmp指令会设置nzcv寄存器中的条件标志,因此我们可以使用第十三章中描述的条件跳转指令,并根据表 13-1 中的条件来根据浮点值控制程序流。

如前所述,浮点计算可能会导致程序中的一些微妙的数值误差。我将在下一节中介绍这些问题。

浮点算术误差

很容易将浮点数视为实数,但它们并非如此。大多数浮点数是它们所表示的实数的四舍五入近似值。在使用浮点算术时,您需要意识到四舍五入对计算的影响。如果不注意四舍五入的影响,您可能无法发现计算中可能出现的误差。

我将在这里讨论的大多数算术误差也可能发生在定点算术中。可能最常见的算术误差是四舍五入误差,这种误差可能由两个原因引起:要么存储的比特数有限,要么小数值在所有数字基中无法精确表示。

这两个限制同样适用于定点表示。正如本章前面所提到的,通常可以通过缩放定点数来消除小数值的问题——但是,存储的比特数限制了值的范围。

浮点数表示通过使用指数来指定整数部分的起始位置,减少了范围问题。然而,浮点数的有效数字是一个分数,这意味着大多数浮点数在二进制中没有精确的表示,导致四舍五入误差。

浮点数运算的一个最大问题是,CPU 指令可能会改变一个数字的有效数字,同时调整指数,从而导致位丢失和更多的舍入错误。而在整数运算中,任何位的移动在程序中都是显式的。

在进行整数计算时,你需要注意结果中最重要位上的错误:无符号整数的进位和有符号整数的溢出。而对于浮点数,基数点会被调整以保持最重要位的完整性。浮点数运算中的大多数错误是由于需要将数值适配到分配的比特数中时,低位的舍入产生的。浮点数运算中的错误更为微妙,但它们可能对程序的准确性产生重要影响。

让我们看看在浮点计算中可能出现的不同类型的错误。

舍入误差

在本章开始时,你已经看到大多数十进制小数在二进制中没有精确的等价表示,导致存储在内存中的是一个四舍五入的近似值。运行清单 19-7 中的add_floats程序可以说明这个问题:


$ ./add_floats
Enter number: 123.4
Enter number: 567.8
123.400002 + 567.799988 = 691.199989

程序使用的数字并不是我输入的数字,fadd指令没有正确地将程序中的数字加在一起。在你返回去查找清单 19-7 中的错误之前,让我们引入调试器,看看能否弄明白发生了什么:

--snip--
(gdb) b 43 Breakpoint 1 at 0x7fc: file add_floats.s, line 43.
(gdb) r
Starting program: /home/bob/add_floats_asm/add_floats
Enter number: 123.4
Enter number: 567.8

Breakpoint 1, main () at add_floats.s:42
43              bl      printf

我在调用printf的地方设置了一个断点,然后运行程序,输入了和之前一样的数字。我们来看一下传递给printf的三个值:

(gdb) i r d0 d1 d2
d0             {f = 0x7b, u = 0x405ed999a0000000, s = 0x405ed999a0000000}
{f = 123.40000152587891, u = 4638383920075767808, s = 4638383920075767808}
d1             {f = 0x237, u = 0x4081be6660000000, s = 0x4081be6660000000}
{f = 567.79998779296875, u = 4648205637329616896, s = 4648205637329616896}
d2             {f = 0x2b3, u = 0x4085999994000000, s = 0x4085999994000000}
{f = 691.19998931884766, u = 4649291075221979136, s = 4649291075221979136}
(gdb)

这个显示可能有点让人困惑。对于每个浮点寄存器,第一个括号中的值是十六进制的。第一个值(f =)显示数字的整数部分,格式是十六进制。例如,d0中的整数部分是0x7b = 123[10],即我输入的数字的整数部分。接下来的两个值(u =s =)显示了整个数字的比特模式,它是以这种方式存储的。我们可以利用这个比特模式结合图 19-2(d)中的格式来推算出浮点数的值。

第二组括号中的值显示的是浮点数的值(f =),以及这些比特被解释为无符号整数(u =)和有符号整数(s =)的值。

如果你仍然对这个显示感到困惑,不要担心。我自己也觉得有点困惑。重要的部分是,显示中展示的每个寄存器中实际存储的浮点数:d0中的 123.40000152587891,d1中的 567.79998779296875,和d2中的 691.19998931884766。运行程序时,printf函数将这些数字四舍五入到了小数点后六位。这些值反映了大多数十进制小数在二进制中没有精确等价表示的事实。

吸收

吸收 发生在加减两个数量级差异较大的数字时。较小的数字的值会在计算中丢失。让我们在 gdb 中运行我们的 add_floats 程序,看看这种情况是如何发生的:

--snip--
(gdb) b 39
Breakpoint 1 at 0x7f0: file add_floats.s, line 39\. 
(gdb) r
Starting program: /home/bob/add_floats_asm/add_floats
Enter number: 16777215.0
Enter number: 0.1

Breakpoint 1, main () at add_floats.s:39
39              fcvt    d0, s0                    // Doubles for printf
(gdb) i r s0 s1 s2
s0             {f = 0xffffff, u = 0x4b7fffff, s = 0x4b7fffff}
{f = 16777215, u = 1266679807, s = 1266679807}
s1             {f = 0x0, u = 0x3dcccccd, s = 0x3dcccccd}
{f = 0.100000001, u = 1036831949, s = 1036831949}
s2             {f = 0xffffff, u = 0x4b7fffff, s = 0x4b7fffff}
{f = 16777215, u = 1266679807, s = 1266679807}
(gdb) c
Continuing.
16777215.000000 + 0.100000 = 16777215.000000
[Inferior 1 (process 2109) exited normally]
(gdb)

gdb 显示中,我们看到寄存器中的值是:

s0: 0x4b7fffff
s1: 0x3dcccccd
s2: 0x4b7fffff

CPU 在执行加法之前会对数字的二进制点进行对齐。图 19-2(c) 中的模式显示,fadd 指令执行了以下加法:

Image

由于单精度浮点数的有效数字为 24 位(其中一个是隐藏位),s2 中的数字被四舍五入为 111111111111111111111111,因此丢失了二进制点右侧的所有位。s1 中的数字被 s0 中的更大数字吸收。

消除

消除 发生在减去两个差距很小的数字时。由于浮点表示法保留了高位部分的完整性,减法将在结果的高位部分得到 0。如果任何一个数字已经被四舍五入,它的低位部分就不再精确,这意味着结果会出错。

为了演示,我们可以使用我们的 add_floats 程序通过输入负数来进行减法。这里有一个使用两个接近数字的示例:

Enter number: 1677721.5
Enter number: -1677721.4
1677721.500000 + -1677721.375000 = 0.125000

gdb 显示中,我们看到寄存器中的值是:

s0: 0x4b7fffff
s1: 0x3dcccccd
s2: 0x4b7fffff

这次减法中的相对误差是 (0.125 – 0.1) / 0.1 = 0.25 = 25%。第二个数字从 –1,677,721.4 四舍五入为 –1,677,721.375,这导致了算术错误。

让我们看看这些数字如何作为 float 类型处理:

Image

减法导致 s0s1 中的高位 20 位相消,只留下 s2 中的三个有效位。s1 中的四舍五入误差传递到 s2 中,导致了 s2 的错误。

让我们使用两个不会出现四舍五入误差的值:

Enter number: 1677721.5
Enter number: -1677721.25
1677721.500000 + -1677721.250000 = 0.250000

在这种情况下,三个数字被准确地存储:

Image

这次减法仍然导致 s0s1 的高位 20 位相消,并且只留下 s3 的三个有效位,但 s3 是正确的。

灾难性消除 发生在至少有一个浮点数存在四舍五入误差时,这会导致差值中的错误。如果两个数字都被精确存储,则会发生 良性消除。这两种类型的消除都会导致结果中的有效位丧失。

结合性

浮点错误最具隐蔽性的影响之一是那些导致中间结果出错的错误。它们可能在某些数据集上出现,但在其他数据集上没有。中间结果中的错误甚至可能导致浮点加法不满足结合律——也就是说,对于某些 float 类型的值 xyz(x + y) + z 不等于 x + (y + z)

让我们写一个简单的 C 程序来测试结合性,如 清单 19-8 所示。

three_floats.c

// Test the associativity of floats.

#include <stdio.h>

int main(void)
{
    float x, y, z, sum1, sum2;

    printf("Enter a number: ");
    scanf("%f", &x);
    printf("Enter a number: ");
    scanf("%f", &y);
    printf("Enter a number: ");
    scanf("%f", &z);

    sum1 = x + y;
    sum1 += z;      // sum1 = (x + y) + z
    sum2 = y + z;
    sum2 += x;      // sum2 = x + (y + z)

    if (sum1 == sum2)
       printf("%f is the same as %f\n", sum1, sum2);
    else
       printf("%f is not the same as %f\n", sum1, sum2);

    return 0;
}

Listing 19-8:一个展示浮点运算不满足结合律的程序

我们从一些简单的数字开始:

$ ./three_floats
Enter a number: 1.0
Enter a number: 2.0
Enter a number: 3.0
6.000000 is the same as 6.000000
$ ./three_floats
Enter a number: 1.1
Enter a number: 1.2
Enter a number: 1.3
3.600000 is not the same as 3.600000

看起来我们的程序有一个 bug。让我们使用 gdb 看看是否能找出问题所在。我在 sum1 += z 语句处设置了一个断点,这样我们就可以查看程序中五个变量的内容,然后我运行了程序:

--snip--
(gdb) b 16
Breakpoint 1 at 0x83c: file three_floats.c, line 16.
(gdb) r
Starting program: /home/bob/three_floats/three_floats
Enter a number: 1.1
Enter a number: 1.2
Enter a number: 1.3

Breakpoint 1, main () at three_floats.c:16
16          sum1 += z;      // sum1 = (x + y) + z

接下来,我们来确定变量的地址:

(gdb) p &x
$1 = (float *) 0x7fffffef94
(gdb) p &y
$2 = (float *) 0x7fffffef90
(gdb) p &z
$3 = (float *) 0x7fffffef8c
(gdb) p &sum1
$4 = (float *) 0x7fffffef9c
(gdb) p &sum2
$5 = (float *) 0x7fffffef98

变量存储在从 z 开始的五个连续 32 位字中,地址为 0x7fffffef8c。我们来查看这五个值,分别以浮点格式和十六进制表示:

(gdb) x/5fw 0x7fffffef8c
0x7fffffef8c:   1.29999995           1.20000005       1.10000002       0
0x7fffffef9c:   2.30000019
(gdb) x/5xw 0x7fffffef8c
0x7fffffef8c:   0x3fa66666           0x3f99999a       0x3f8ccccd       0x00000000
0x7fffffef9c:   0x40133334

我们将以十六进制的方式处理这些值,来弄清楚发生了什么。根据 图 19-2(c) 中的 IEEE 754 单精度浮点格式,我们得到以下加法:

Image

由于该格式仅允许 23 位有效数字,CPU 会对和进行四舍五入,得到以下数字(记住在 第 410 页 中讨论的偶数舍入规则):

Image

这是我们之前在 gdb 显示中看到存储在 sum1 地址(0x7fffffef9c)中的数字,格式为 IEEE 754。

现在,我们执行当前指令,它将 z 加到 sum1 上,并查看它的新值:

(gdb) n
17          sum2 = y + z;
(gdb) x/1fw 0x7fffffef9c
0x7fffffef9c:   3.60000014
(gdb) x/1xw 0x7fffffef9c
0x7fffffef9c:   0x40666667

CPU 执行了以下加法:

Image

CPU 接着对 sum1 进行四舍五入,得到一个 23 位的有效数字:

Image

现在,我们将按照相同的步骤计算 sum2

(gdb) n
18          sum2 += x;       // sum2 = x + (y + z)
(gdb) x/1fw 0x7fffffef98
0x7fffffef98:   2.5
(gdb) x/1xw 0x7fffffef98
0x7fffffef98:   0x40200000

这里的数字是以下加法的结果:

Image

四舍五入后得到:

Image

当前语句加上了 x

(gdb) n
20          if (sum1 == sum2)
(gdb) x/1fw 0x7fffffef98
0x7fffffef98:   3.5999999
(gdb) x/1xw 0x7fffffef98
0x7fffffef98:   0x40666666

这执行了加法:

Image

CPU 接着对 sum2 进行四舍五入,得到一个 23 位的有效数字(同样,记住偶数舍入规则):

Image

继续执行程序直到结束,得到:

(gdb) c
Continuing.
3.600000 is not the same as 3.600000
[Inferior 1 (process 3107) exited normally]

printf 函数已经将 sum1sum2 的显示结果四舍五入,使得它们看起来相等,但通过 gdb 查看程序内部会发现它们并不相等。我们得出结论,程序中的 bug 不在于我们的逻辑,而是在于我们使用浮点变量的方式。

两种加法顺序的差异非常小:

Image

然而,如果这是一个涉及乘以大数的计算,这个小差异可能变得显著。

从这个例子中学到的主要教训是,浮点运算很少是精确的。

轮到你了

19.5 修改 Listing 19-8 中的 C 程序,使用 double 类型。这样做能使浮点加法满足结合律吗?

关于数值精度的评论

初学者程序员常常将浮动点数视为实数,因此认为它们比整数更准确。确实,使用整数有其自身的问题:即便是加法运算两个大整数也可能导致溢出。乘法运算更容易产生溢出的结果。而且,整数除法会产生两个值——商和余数——而不是浮动点数除法产生的一个值。

但浮动点数并不是实数。正如你在本章中所看到的,浮动点数表示扩展了数值的范围,但也有可能产生不准确的结果。要得到算术上精确的结果,必须对你的算法进行深入分析。以下是一些值得考虑的建议:

  • 尝试调整数据,使得可以使用整数运算。

  • 使用double而不是float。这可以提高准确性,并且可能实际提高执行速度。大多数 C 和 C++库函数都接受double作为参数,因此当将float传递给这些函数时,编译器会将其转换为double,就像在 Listing 19-7 中的printf调用一样。

  • 尝试安排计算顺序,使得相似大小的数字进行加法或减法运算。

  • 避免复杂的算术表达式,这些表达式可能会掩盖不正确的中间结果。

  • 选择能加剧你算法的问题的测试数据。如果你的程序处理小数值,包含一些没有精确二进制等效的数值。

好消息是,随着今天 64 位计算机的出现,整数的范围已经大大扩展。

图片

许多编程语言中都有可以使用任意精度算术的库。你可以在en.wikipedia.org/wiki/List_of_arbitrary-precision_arithmetic_software找到这些库的列表。

本节提供了使用浮动点数时出现数值错误的主要原因概述。欲深入了解这一主题,David Goldberg 的论文《What Every Computer Scientist Should Know About Floating-Point Arithmetic》(《ACM Computing Surveys》,第 23 卷,第 1 期,1991 年 3 月)和en.wikipedia.org/wiki/Floating-point_arithmetic是不错的起点。若想了解减少舍入误差的编程技巧,你可以阅读关于 Kahan 求和算法的文章,链接是en.wikipedia.org/wiki/Kahan_summation_algorithm

你学到了什么

二进制表示的小数值 小数值在二进制中的表示是 2 的倒数幂之和。

二进制中的定点表示 二进制点被假定为在数值的二进制表示中的特定位置。

浮点数不是实数 浮点数之间的间隔会根据指数的不同而变化。

浮点通常不如定点精确 舍入误差通常被浮点格式的标准化掩盖,并可能在多次计算中累积。

IEEE 754 计算机程序中表示浮点值的最常见标准。整数部分总是 1。指数指定包含或排除整数部分的位数。

SIMD 和浮点硬件 浮点指令在 CPU 中使用单独的寄存器文件。

到目前为止,在本书中,我讨论了按步骤执行指令的程序。但在某些情况下,指令不能与其操作数执行任何有意义的操作——例如,当我们除以 0 时。如本章前面所示,这可能会触发程序执行顺序的异常。我们还可能希望允许外部事件,如使用键盘,来中断正在进行的程序执行。在讨论完输入/输出后,在第二十章中,我将在第二十一章中讲解中断和异常。

第二十章:输入/输出**

Image

I/O 子系统是程序用来与外部世界(即 CPU 和内存之外的设备)进行通信的部分。大多数程序从一个或多个输入设备读取数据,处理数据,然后将结果写入一个或多个输出设备。

键盘和鼠标是典型的输入设备;显示屏和打印机是典型的输出设备。虽然大多数人不会这样看待它们,但像磁盘、固态硬盘(SSD)、USB 闪存等设备也是 I/O 设备。

我将从讨论 I/O 设备与内存的时序特性开始,然后介绍这如何影响 CPU 与 I/O 设备之间的接口。

时序考虑

由于 CPU 通过相同的总线访问内存和 I/O 设备(见图 1-1 在第一章中),这可能会让人认为程序可以像访问内存一样访问 I/O 设备。也就是说,你可能会期望通过使用ldrstr指令在 CPU 和特定的 I/O 设备之间传输字节数据来执行 I/O 操作。这对于许多设备是可以实现的,但为了正确工作,必须考虑一些特殊情况。一个主要的问题是内存和 I/O 之间的时序差异。在处理 I/O 时序之前,让我们先考虑一下内存的时序特性。

注意

正如我指出的,本书中给出的三总线描述显示了 CPU 和 I/O 设备之间的逻辑交互。大多数现代计算机使用多种总线。CPU 如何连接到各种总线由硬件处理。程序员通常只处理逻辑视图。

内存时序

内存的一个重要特性是它的时序相对均匀,并且不依赖于外部事件。这意味着内存时序可以由硬件处理,程序员无需担心;我们可以简单地通过 CPU 指令将数据从内存中移入或移出。

计算机中常用的两种 RAM 类型:

SRAM   只要电源开启,它就能保持其值。为了做到这一点,它需要更多的组件,因此成本较高且体积较大,但访问速度非常快。

DRAM   使用被动组件,数据值只能保持几分之一秒。DRAM 包括自动刷新数据值的电路,防止值完全丢失。它比 SRAM 便宜,但速度慢 5 到 10 倍。

树莓派的大部分内存是 DRAM,因为它比 SRAM 便宜且体积更小。由于每条指令都必须从内存中提取,慢速的内存访问限制了程序的执行速度。

通过使用由 SRAM 组成的缓存内存系统,可以提高程序执行速度。将 SRAM 缓存与 DRAM 主内存结合使用,有助于确保 CPU 访问内存时最小化时间延迟。

需要注意的是,CPU 的速度仍然比内存速度快(即使是 SRAM)。访问内存——获取指令、加载数据、存储数据——通常是减慢程序执行的最重要因素。有一些技术可以提高缓存性能,从而改善内存访问时间,但采用这些技术需要对你所使用的系统的 CPU 和内存配置有透彻的理解,这超出了本书的范围。

I/O 设备时序

几乎所有的 I/O 设备都比内存慢得多。考虑一下常见的输入设备——键盘。以每分钟 120 个字的速度打字,相当于每秒输入大约 10 个字符,或者每个字符之间的延迟大约为∼100 毫秒。在这段时间内,一颗以 2 GHz 运行的 CPU 可以执行约 2 亿条指令。更不用说,按键之间的时间间隔非常不一致,许多按键间隔会远比这个时间长。

即使是 SSD,相对于内存来说也很慢。例如,典型的 SSD 的传输速度大约是 500MB 每秒。而 DDR4 内存(常用于主内存)的传输速度大约是 20GB 每秒,快了约 40 倍。

除了速度远低于内存外,I/O 设备在时序上还表现出更大的波动性。有些人打字很快,有些人打字很慢。磁盘上的数据可能刚好经过读写头,或者可能已经过去,在这种情况下,你将不得不等待磁盘几乎转一圈才能让数据再次出现在读写头下。

正如在第九章开头所指出的,树莓派使用的是一个系统芯片(SoC),它包括一个或多个处理器核心以及许多控制器,用于管理计算机的其他部分。大多数基于 ARM 架构的 SoC 使用的是由 Arm 在 1997 年首次推出的高级微控制器总线架构(AMBA)。该架构定义了几种协议,用于选择适当的速度,以便在 CPU 和 SoC 中其他功能部件之间进行通信。具体细节超出了本书的范围,但如果你有兴趣了解更多关于 AMBA 的内容,可以从* www.arm.com/architecture/system-architectures/amba *上的免费在线培训视频入手。

SoC 包含大多数外部 I/O 接口的控制器。除了树莓派 Zero 外,所有树莓派都配备了第二个芯片,提供额外的外部 I/O 接口。树莓派 1、1+、2、3 或 3+上的 I/O 芯片提供以太网和 USB 2.0 端口,并通过 USB 2.0 与 SoC 进行通信。树莓派 4 上的 I/O 芯片提供 USB 2.0 和 USB 3.0 端口,并通过 PCI Express(PCI-E)总线与 SoC 进行通信。

树莓派 5 使用了一款新的 I/O 控制器芯片,名为 RP1,它整合了之前 SoC 上的大多数外部 I/O 接口控制器,包括 USB、MIPI 摄像头和显示器、以太网和通用输入输出 (GPIO)。这样的设备通常被称为 南桥。RP1 通过 PCI-E 总线与 SoC 进行通信。将较慢的 I/O 功能移至独立的芯片有助于简化 SoC,使其能够更快速地运行,并专注于计算密集型任务。

接下来,我将向你展示如何访问 I/O 设备的寄存器。

访问 I/O 设备

CPU 通过 设备控制器 与 I/O 设备进行工作,设备控制器是执行实际控制 I/O 设备工作的硬件。例如,键盘控制器检测按下的是哪个键,并将其转换为代表该键的位模式。它还会检测修饰键,如 SHIFT 或 CTRL 是否被按下,并相应地设置位模式。

设备控制器通过一组寄存器与 CPU 进行接口。通常,设备控制器提供以下类型的 I/O 寄存器:

数据 用于将数据发送到输出设备或从输入设备读取数据

状态 提供关于设备当前状态的信息,包括控制器本身

控制 允许程序向控制器发送命令以更改设备和控制器的设置

设备控制器接口通常会有多个相同类型的寄存器,特别是控制寄存器和状态寄存器。

将数据发送到输出设备很像将数据存储在内存中:你将数据存储在设备控制器的数据寄存器中。输出设备与内存的不同之处在于时机。如我之前所说,程序员在将数据存储到内存时不需要关心时机。然而,输出设备可能未准备好接收新数据——它可能正在处理先前发送的数据。这时,状态寄存器就发挥了作用。程序需要检查状态寄存器,看看设备控制器是否准备好接受新数据。

从输入设备读取数据就像将数据从内存加载到 CPU 中一样:你从设备控制器的数据寄存器中加载数据。与内存的区别在于,输入设备可能没有新数据,因此程序需要检查输入设备控制器的状态寄存器,以查看是否有新数据。

大多数 I/O 设备还需要通过控制寄存器告诉它们该做什么。例如,在等待输出设备控制器准备好接受新数据并将数据移到数据寄存器之后,一些设备控制器要求你告诉它们将数据输出到实际设备。或者,如果你想从输入设备获取数据,一些设备控制器要求你请求它们获取输入。你可以将这些命令发送到控制寄存器。

CPU 可以通过两种方式访问设备控制器上的 I/O 寄存器:通过内存映射 I/O 和端口映射 I/O。使用内存映射 I/O时,一段内存地址范围专用于 I/O 端口,每个 I/O 寄存器被映射到该范围内的一个内存地址。然后,使用加载和存储指令来读取或写入设备控制器上的 I/O 寄存器。

使用端口映射 I/O时,I/O 设备控制器的寄存器被分配到一个独立的寻址空间。CPU 通过特殊的 I/O 指令与 I/O 寄存器进行通信。

AArch64 架构仅支持内存映射 I/O。x86 架构则是支持两种 I/O 类型的示例。

如果我们首先了解 Linux 和大多数其他操作系统在执行程序时如何管理内存,理解内存映射 I/O 会更容易。程序在虚拟内存地址空间中运行,这是一种通过从 0 到最大值的连续寻址模拟大内存的技术。这些就是你在使用gdb时看到的地址——例如,sppc寄存器中的地址。

尽管 AArch64 架构允许 64 位寻址,但当前的 CPU 硬件实现仅使用 52 位地址。这使得可以在这个虚拟地址空间中执行程序的最大地址为 2⁵²字节(4 pebibytes)。但是,Raspberry Pi 只有 1 到 8GiB(或吉二进制字节)的物理内存,即计算机中安装的实际 RAM,程序需要在物理内存中才能执行。

注意

我们通常使用基于 10 的幂次方的度量命名约定来指定多字节量:千字节、兆字节、千兆字节等。国际电工委员会(IEC)还定义了一种基于 2 的幂次方的命名约定:千二进制字节、兆二进制字节、吉二进制字节等。例如,千字节是 1,000 字节,而千二进制字节是 1,024 字节。你可以在 en.wikipedia.org/wiki/Byte 阅读更多关于命名约定的信息。*

操作系统通过将每个程序划分为页面,来管理程序在物理内存中的放置。Raspberry Pi OS 在大多数型号上使用 4KiB(或千二进制字节)页面大小,在 Model 5 上使用 16KiB 页面大小。物理内存被划分为相同大小的页面框架。包含当前由 CPU 执行的代码的程序页面会从存储位置(例如磁盘、DVD、USB 闪存驱动器)加载到物理内存的页面框架中。

操作系统维护一个页表,显示程序的页面当前在物理内存中的加载位置。图 20-1 使用页表展示了虚拟内存和物理内存之间的关系。

图像

图 20-1:虚拟内存和物理内存之间的关系

Raspberry Pi 上使用的 SoC 包括一个内存管理单元(MMU)。当 CPU 需要访问内存中的某个项时,它使用该项的虚拟地址。MMU 将虚拟地址作为索引,查找页表中对应的页面,并从中定位到该项。如果请求的页面当前未加载到物理内存中,MMU 会生成一个页故障异常,触发操作系统中的一个函数,将该页面加载到物理内存中,并在页表中记录其位置。(你将在第二十一章中学习关于异常的内容。)

页表存储在主内存中,因此使用它需要两次内存访问:一次是从页表中检索框架号,另一次是访问主内存中的位置。为了加速这一过程,MMU 在硬件中包含了转换后备缓冲区(TLB)。TLB 是一小部分快速内存,包含了页表中最近使用的条目。就像你在第八章中学到的内存缓存一样,TLB 利用程序在短时间内访问邻近内存地址的趋势来加速内存访问。MMU 首先查看 TLB。如果页表项在其中,那么只需要一次访问主内存。

与虚拟内存映射到物理内存的方式类似,虚拟内存地址也可以映射到 I/O 设备控制器寄存器地址空间。将控制器寄存器与虚拟内存地址关联,使得我们可以使用访问内存的 CPU 指令来访问 I/O 设备控制器寄存器。内存映射 I/O 的一个优点是,通常可以在不使用内联汇编语言的情况下,用更高级的语言如 C 语言编写 I/O 函数。

I/O 编程

根据它们处理的数据量以及处理数据的速度,I/O 设备使用不同的技术与 CPU 进行通信。这些差异反映在设备控制器编程的方式上,用以执行其功能。

当时序不重要时,我们可以简单地使用指令将数据项发送到输出设备,或者在程序中希望输出或输入数据的位置从输入设备读取数据项。这种方式适用于那些不需要时间来处理传输的二进制数据的 I/O 设备。稍后在本章中,你将看到一个使用这种技术的例子,我们将编程一个 I/O 设备,使其在一个输出引脚上输出两个电压水平之一。

大多数 I/O 设备控制器需要相当长的时间来处理输入和输出数据。例如,当我们按下键盘上的某个键时,键盘设备控制器需要检测按下了哪个键,然后将这一信息转换为一个 8 位模式,表示我们按下的字符。如果我们的程序需要这个字符,我们必须首先检查键盘设备控制器的状态寄存器,以确定它是否已经完成了这个过程。如果设备控制器处于就绪状态,我们就可以从设备控制器读取数据。

我们在程序中使用轮询算法来实现这一点。轮询通常包括一个循环,在每次循环迭代中检查设备的状态寄存器,直到设备处于就绪状态。当设备控制器准备好时,我们将数据加载到一个通用 CPU 寄存器中。当程序化 I/O 使用轮询算法时,通常称为轮询 I/O

类似地,输出设备控制器可能正在忙于输出前一个数据项。我们的程序需要轮询设备控制器,直到它准备好接受新的输出数据。

轮询 I/O 的缺点是 CPU 可能需要长时间等待设备准备好。这种情况如果 CPU 专门运行一个系统中的程序(例如控制微波炉),可能是可以接受的,但在现代计算的多任务环境中则不可接受。

如果我们能告诉 I/O 设备在准备好进行数据输入或输出时通知我们,并在此期间将 CPU 用于其他任务,我们就能让 CPU 做更多的工作。许多 I/O 设备包括一个中断控制器,就是为了这个目的:当设备完成操作或准备好进行另一个操作时,它可以向 CPU 发送一个中断信号。

来自外部设备的中断会导致 CPU 调用中断处理程序,即操作系统中处理来自中断设备的输入或输出的函数。这通常被称为中断驱动 I/O。我将在第二十一章讨论允许 CPU 调用中断处理程序的特性。

在所有这些技术中,CPU 都是发起数据传输到 I/O 设备控制器或从 I/O 设备控制器传输数据的。我们称之为程序化 I/O

进行高速大数据传输的 I/O 设备通常具备直接内存访问(DMA)的能力。它们有一个DMA 控制器,可以直接访问主内存而不需要 CPU。例如,在从磁盘读取数据时,DMA 控制器接受一个内存地址和一个读取磁盘数据的命令。当 DMA 控制器将数据从磁盘读取到其自身的缓冲区内存时,它会将数据直接写入主内存。当 DMA 数据传输完成时,控制器向 CPU 发送一个中断,从而调用磁盘中断处理程序,通知操作系统数据已经在内存中可用。

接下来,我们将看一个不需要轮询的输出:一个可以放置为两个电压之一的单个引脚。

编程通用 I/O 设备

通用输入输出(GPIO) 是一种信号线,可以配置为输入或输出 1 位数据。它们最初是在集成电路芯片上按组实现的,每条 GPIO 电路的 I/O 线路都连接到芯片上的一个引脚。如今,GPIO 电路通常包含在 SoC 设计中,可以用于点亮 LED、读取开关等。

所有 Raspberry Pi 型号都包括以组方式排列的 GPIO,仍然称为芯片。一个芯片有 28 条线路,连接到位于 Raspberry Pi 主板顶部边缘的 40 引脚的GPIO 排针,我们可以使用这些引脚来控制外部 I/O 设备。(原始的 Raspberry Pi 1 配备有一个 26 引脚的 GPIO 排针,连接到 17 条 GPIO 线路。)

在本节中,我将向你展示如何编程一个 GPIO 线路,输出一个单一的比特,使其对应的 GPIO 排针引脚在 0.0 V 和 +3.3 V 之间交替。我们不需要轮询 GPIO 线路来查看它是否准备好进行这个 1 位输出,因为它总是准备好的。我们将利用这些电压交替来闪烁一个 LED。

28 条 GPIO 线路与 40 引脚排针的对应关系显示在 表 20-1 中。

表 20-1: GPIO 线路与 Raspberry Pi 排针引脚的对应关系

信号 排针 引脚 信号
+3.3 V 电源 1 2 +5 V 电源
GPIO2 3 4 +5 V 电源
GPIO3 5 6 地线
GPIO4 7 8 GPIO14
地线 9 10 GPIO15
GPIO17 11 12 GPIO18
GPIO27 13 14 地线
GPIO22 15 16 GPIO23
+3.3 V 电源 17 18 GPIO24
GPIO10 19 20 地线
GPIO9 21 22 GPIO25
GPIO11 23 24 GPIO8
地线 25 26 GPIO7
GPIO0 27 28 GPIO1
GPIO5 29 30 地线
GPIO6 31 32 GPIO12
GPIO13 33 34 地线
GPIO19 35 36 GPIO16
GPIO26 37 38 GPIO20
地线 39 40 GPIO21

GPIO 排针的引脚编号假设我们正在向下看 Raspberry Pi 的顶部,排针位于右侧。排针有两排,引脚编号为奇数的在左侧,偶数的在右侧。注意,GPIO 线路的编号与 GPIO 排针的引脚编号并不完全一致。

你还可以在网上查看此信息,访问 www.raspberrypi.com/documentation/computers/raspberry-pi.html#gpio-and-the-40-pin-header,而在 Raspberry Pi 上使用 pinout 命令会显示引脚对应信息。

很多 Raspberry Pi 文档将 GPIO 设备的信号称为 GPIO 引脚。为了避免混淆,我将使用GPIO 线路来表示信号,使用GPIO 头引脚来表示 GPIO 头上的物理连接器。正如你稍后在本章中看到的,这种命名约定与我们将用于编程 GPIO 线路的 gpiod 库一致。我们将从连接用于闪烁 LED 的硬件电路开始。

连接闪烁 LED 电路

为了跟随这个项目,你需要一个 LED,一个 220 Ω 的电阻,以及几根连接跳线。将电路搭建在面包板上比直接将元件放在桌面或工作台上要简单得多。

在将任何东西连接到 Raspberry Pi 的 GPIO 引脚之前,你应该先关闭设备并切断电源。这些引脚彼此靠得很近,容易不小心将两个引脚短接在一起,这可能会损坏你的 Raspberry Pi。

我们将使用图 20-2 中显示的电路。

Image

图 20-2:闪烁 LED 的电路

你在第五章的图 5-3 中看到了电阻的电路符号。上面有两个箭头的三角形是 LED 的电路符号,三个水平线组成的三角形是地(0.0 V)的符号。根据表 20-1 中的信息,我们将通过 GPIO 头的引脚 11 连接到 GPIO17,并通过引脚 9 连接到地。

确保正确连接 LED。图中 LED 的左侧是阳极,右侧是阴极。LED 的制造商应该提供文档,说明每个引脚的作用。

我们将编写一个程序,使 GPIO17 引脚的电压在 +3.3 V 和 0.0 V 之间交替。在 +3.3 V 时,电流通过电阻和 LED,导致 LED 点亮。220 Ω 的电阻是必须的,它能限制流过的电流量,因为过多的电流可能会损坏 LED。当我们的程序将 GPIO17 引脚切换到 0.0 V 时,电流不再流过 LED,LED 熄灭。

我们先写一个 C 程序,确保电路连接正确。

用 C 语言让 LED 闪烁,适用于所有型号

Raspberry Pi OS 提供了两个函数库,允许我们在高级语言中操作 GPIO。pigpio 库提供了 C 和 Python 函数,而 gpiozero 提供了一个简单的 Python 编程接口。你可以分别在abyz.me.uk/rpi/pigpio/gpiozero.readthedocs.io/en/stable/中了解它们。

截至目前,pigpio 库在树莓派 5 型上无法使用。我安装了 gpiod 包,其中包括一些用于操作 GPIO 的有用命令行工具。它还安装了 libgpiod 库,这是官方支持的 GPIO 接口。我已经在我的树莓派 3 型和树莓派 5 型上用 C 和 Python 程序测试了这个库。我使用以下三个命令安装了命令行和开发工具:

$ sudo apt install gpiod
$ sudo apt install libgpiod-dev
$ sudo apt install libgpiod-doc

注意

在写作时,这将安装库和工具的 1.6.3 版本。开发者已经发布了 2.1.1 版本,但它还未出现在树莓派操作系统的仓库中。当仓库更新时,我预计将添加额外的实用工具,并且库中的某些函数名称可能会发生变化。所有版本的源代码可在 git.kernel.org/pub/scm/libs/libgpiod/libgpiod.git 找到。

gpiod 包安装了六个用于操作 GPIO 设备的实用程序:gpiodetectgpiofindgpiogetgpioinfogpiomongpiosetlibgpiod-doc 包安装了这些实用工具的手册页面,而 libgpiod-dev 包安装了我们将在 C 程序中使用的 libgpiod 库的接口。

树莓派操作系统还包含了其他有用的工具,如 pinoutpinctrlpinout 程序有一个手册页面,描述了它的使用方法。pinctrl help 命令展示了如何使用 pinctrl。我使用 pinctrl 来调试用于闪烁 LED 的程序,你将在这里看到它们。

我们将使用 libgpiod 库中的函数编写一个 C 程序,该程序展示在 Listing 20-1 中,用于测试电路 Figure 20-2 中的 LED 闪烁。我们版本的库文档可以在 www.lane-fu.com/linuxmirror/libgpiod/doc/html/index.html 找到。如果你更喜欢使用 Python,请参阅“你的回合” 练习 20.2,它位于 第 460 页。

blink_led.c

// Blink an LED.
#include <stdio.h>
#include <unistd.h>
#include <gpiod.h>

#define LINE 17                // GPIO line connected to LED
#define OFF 0                  // Pin at 0.0 V
#define ON 1                   // Pin at 3.3 V
#define BLINKS 5               // Number of blinks
#define SECONDS 3              // Time between blinks

int main(void)
{
  ❶  struct gpiod_chip *chip;
      struct gpiod_line *line;
      int i;
      int error; 
  ❷  chip = gpiod_chip_open("/dev/gpiochip0");  // On RPi 5 use /dev/gpiochip4
      if(!chip) {
           puts("Cannot open chip");
           return -1;
  }

       line = gpiod_chip_get_line(chip, LINE);
       if(line == NULL) {
            gpiod_chip_close(chip);
            puts("Cannot get GPIO line");
            return -1;
       }
       error = gpiod_line_request_output(line, "example", 0);
       if(error == -1) {
            gpiod_line_release(line);
            gpiod_chip_close(chip);
            puts("Cannot set GPIO output");
            return -1;
       }

       for (i = 0; i < BLINKS; i++) {
        ❸  gpiod_line_set_value(line, ON);
            printf("led on...\n");
            sleep(SECONDS);
            gpiod_line_set_value(line, OFF);
            printf("...led off\n");
            sleep(SECONDS);
       }
       gpiod_line_release(line);
       gpiod_chip_close(chip);

       return 0;
}

Listing 20-1: 使用 GPIO 闪烁 LED 的 C 程序

在编译这个文件时,我们需要在命令的末尾显式指定 libgpiod 库,如下所示:

$ gcc -g -Wall -o blink_led blink_led.c -lgpiod

struct gpiod_chipstruct gpiod_line 都在 gpiod.h 头文件 ❶ 中声明,如下所示:

struct gpiod_chip;
struct gpiod_line;

这种 C 语法是一种定义 chipline 指针变量为存储地址的方式。

树莓派有多个 GPIO 芯片。GPIO 芯片 0 连接到 3 型和 4 型模型的 GPIO 头引脚;GPIO 芯片 4 连接到树莓派 5 型的 GPIO 头引脚,所以如果你使用的是该模型,需要将 gpiochip0 改为 gpiochip4 ❷。

一旦 GPIO 行被配置为输出,我们就可以开关该位以闪烁 LED ❸。

接下来,让我们看看如何使用汇编语言来闪烁 LED。

注意

我们将使用的汇编语言代码并不完善。它旨在提供一个关于如何编程 I/O 设备的概述。如果你想使用 GPIO 控制外部设备,我建议使用 libgpiod 库中的函数。它与操作系统集成,提供 GPIO 的稳健功能。

用汇编语言闪烁 LED,适用于 3 型板和 4 型板

每个 GPIO 设备线路的功能是通过六个 32 位寄存器来选择的,这些寄存器命名为 GPFSEL0 到 GPFSEL5。三个位用于选择 GPIO 设备线路的功能。寄存器 GPFSEL0 到 GPFSEL4 每个选择 10 条线路的功能,剩下 2 个未使用的位。对于 3 型板,GPFSEL5 寄存器选择 4 条线路的功能,留下 20 个未使用的位;而在 4 型板上,GPFSEL5 寄存器选择 8 条线路的功能,留下 8 个未使用的位。

我建议下载你的 Raspberry Pi 数据手册——对于 3 型板,请参考 Broadcom BCM2835 SoC 数据手册,地址为* datasheets.raspberrypi.com/bcm2835/bcm2835-peripherals.pdf ,而对于 4 型板,请参考 Broadcom BCM-2711 SoC 数据手册,地址为 datasheets.raspberrypi.com/bcm2711/bcm2711-peripherals.pdf *,以便在阅读本节时作为参考。阅读数据手册并不容易,但在此处的解释与适用型号的数据手册之间来回查阅,应该有助于你学会如何阅读它。

请注意,我只在我的 Raspberry Pi 3 上测试了此程序。Raspberry Pi 4 的数据手册显示 GPIO 寄存器与 3 型板相同,因此此代码也应该适用于 4 型板。

清单 20-2 显示了我们的汇编语言程序来闪烁 LED。

blink_led.s

// Blink an LED connected to GPIO line 17 every three seconds.

// Define your RPi model: 0, 1, 2, 3, 4, 5
    ❶ .equ    RPI_MODEL, 3
// Useful constants
        .equ    N_BLINKS, 5               // Number of times to blink
        .equ    DELTA_TIME, 3             // Seconds between blinks
        .equ    GPIO_LINE, 17             // Line number
// The following are defined in /usr/include/asm-generic/fcntl.h.
// Note that the values are specified in octal.
        .equ    O_RDWR, 00000002          // Open for read/write
        .equ    O_SYNC, 04010000          // Complete writes in hardware
// The following are defined in /usr/include/asm-generic/mman-common.h.
        .equ    PROT_READ, 0x1            // Page can be read
        .equ    PROT_WRITE, 0x2           // Page can be written
        .equ    MAP_SHARED, 0x01          // Share changes
// Beginning address of peripherals
 ❷ .if     (RPI_MODEL == 0) || (RPI_MODEL == 1)
        .equ    PERIPHS, 0x20000000 >> 16 // RPi 0 or 1
    .elseif (RPI_MODEL == 2) || (RPI_MODEL == 3)
        .equ    PERIPHS, 0x3f000000 >> 16 // RPi 2 or 3
    .elseif RPI_MODEL == 4
        .equ    PERIPHS, 0x7e000000 >> 16 // RPi 4
    .else
        .equ    PERIPHS, 0x1f00000000 >> 16   // RPi 5
    .endif
// Offset to GPIO registers
    .if     RPI_MODEL != 5
        .equ    GPIO_OFFSET, 0x200000     // Other RPi models
    .else
        .equ    GPIO_OFFSET, 0xd0000      // RPi 5
    .endif
// Amount of memory to map and flags
        .equ    MEM_SIZE, 0x400000        // Enough to include all GPIO regs
     ❸ .equ    OPEN_FLAGS, O_RDWR | O_SYNC   // Open file flags
        .equ    PROT_RDWR, PROT_READ | PROT_WRITE   // Allow read and write
        .equ    NO_ADDR_PREF, 0           // Let OS choose address of mapping

// Stack frame
        .equ    save1920, 16              // Save regs
        .equ    save21, 32
        .equ    FRAME, 48
// Constant data
        .section .rodata
        .align  2
dev_mem:
        .asciz  "/dev/mem"
err_msg:
        .asciz  "Cannot map I/O memory.\n"
on_msg:
        .asciz  "led on...\n"
off_msg:
        .asciz  "...led off\n"

// Code
        .text
        .align  2
        .global main
        .type   main, %function
main:
        stp     fp, lr, [sp, -FRAME]!     // Create stack frame
        mov     fp, sp                    // Set our frame pointer
        stp     x19, x20, [sp, save1920]  // Save regs
        str     x21, [sp, save21]

// Open /dev/mem for read/write and syncing.
        mov     w1, OPEN_FLAGS & 0xffff   // Move 32-bit flags
        movk    w1, OPEN_FLAGS / 0xffff, lsl 16
        adr     x0, dev_mem               // I/O device memory
     ❹ bl      open
        cmp     w0, -1                    // Check for error
        b.eq    error_return              // End if error
        mov     w19, w0                   // /dev/mem file descriptor

// Map the GPIO registers to a main memory location so we can access them.
        movz    x5, PERIPHS & 0xffff, lsl 16
        movk    x5, PERIPHS / 0xffff, lsl 32
        mov     w4, w19                   // File descriptor
        mov     w3, MAP_SHARED            // Share with other processes
        mov     w2, PROT_RDWR             // Read/write this memory
        mov     w1, MEM_SIZE              // Amount of memory needed
        mov     w0, NO_ADDR_PREF          // Let kernel pick memory
     ❺ bl      mmap
        cmp     x0, -1                    // Check for error
        b.eq    error_return              // w0 also = -1, end function
        mov     x20, x0                   // Save mapped address
        mov     w0, w19                   // /dev/mem file descriptor
        bl      close                     // Close /dev/mem file

// Make the line an output.
        mov     x0, x20                   // Get mapped memory address
        add    x0, x0, GPIO_OFFSET        // Start of GPIO registers
        mov     w1, GPIO_LINE
 ❻ .if     RPI_MODEL != 5
        bl      gpio_line_to_output
    .else
        bl      gpio_5_line_to_output
    .endif
        mov     x21, x0                   // Pointer to register base

// Turn the line on and off.
        mov     x19, N_BLINKS             // Number of times to do it
loop:
        adr     x0, on_msg                // Tell user it's on
        bl      write_str
 mov     w1, GPIO_LINE             // GPIO line number
        mov     x0, x21                   // Pointer to register base
 ❼ .if     RPI_MODEL != 5
        bl      gpio_line_set             // Turn LED on
    .else
        bl      gpio_5_line_set           // Turn LED on
    .endif
        mov     w0, DELTA_TIME            // Wait
        bl      sleep

        adr     x0, off_msg               // Tell user it's off
        bl      write_str
        mov     w1, GPIO_LINE             // GPIO line number
        mov     x0, x21                   // Pointer to register base
 ❽ .if     RPI_MODEL != 5
        bl      gpio_line_clr             // Turn LED off
    .else
        bl      gpio_5_line_clr           // Turn LED off
    .endif
        mov     w0, DELTA_TIME            // Wait
        bl      sleep

        subs    x19, x19, 1               // Decrement loop counter
        b.gt    loop                      // Loop if > 0

        mov     x0, x20                   // Our mapped memory
        mov     w1, MEM_SIZE              // Amount we mapped for GPIO
     ❾ bl      munmap                    // Unmap it
        mov     w0, wzr                   // Return 0
error_return:
        ldr     x21, [sp, save21]         // Restore regs
        ldp     x19, x20, [sp, save1920]
        ldp     fp, lr, [sp], FRAME       // Delete stack frame
        ret

清单 20-2:一个使用 GPIO 闪烁 LED 的汇编语言程序

清单 20-1 中的 C 程序使用了操作系统提供的库函数来控制 GPIO 线路。我们在 清单 20-2 中的汇编语言程序直接访问 GPIO 寄存器。操作系统仅允许拥有 root 权限的用户执行此操作,因此我们需要使用 sudo 来运行该程序,如下所示:

$ sudo ./blink_led

Linux 将 I/O 设备视为文件。它们按名称列出在 /dev 目录中。/dev/mem 文件是主内存的映像。该文件中的地址表示物理内存地址。使用 open 系统调用函数打开文件将允许我们访问 I/O 设备的物理内存 ❹。它的手册页为我们提供了 open 函数的原型:

int open(const char *pathname, int flags);

路径名是要打开的文件或设备的完整路径和名称。手册页列出了必须传递给 open 函数的标志名称,以指定调用函数如何访问它。

每个标志的数值可以在头文件/usr/include/asm-generic/fcntl.h中找到。头文件是用 C 语言编写的,因此我们不能使用.include指令将其添加到我们的汇编语言源代码中。我使用了.equ指令来定义open函数中所需的标志。汇编器支持对文字值进行算术和逻辑运算。我们使用 OR 运算符(|)将所需的不同标志组合成一个单一的 32 位整数,作为 flags 参数❸。

操作系统阻止应用程序直接访问 I/O 内存地址空间。我们需要告诉操作系统将 GPIO 内存地址空间映射到应用程序内存地址空间,这样我们就可以在应用程序中访问 GPIO 寄存器。

I/O 外设地址空间的起始位置取决于 Raspberry Pi 型号。GNU 汇编器有一些指令,可以让我们选择要在汇编中包含哪些代码行。我们使用.if指令以及一系列.elseif指令,根据我们使用的型号选择PERIPHS的值❷。使用.if指令和.else指令可以选择 GPIO 寄存器相对于 I/O 外设起始地址的正确偏移量。一个单独的.equ指令设置RPI_MODEL,以控制这些条件汇编指令❶。不要忘记在每个.if构造后用.endif指令结束。

我们使用 POSIX 标准中指定的mmap系统调用函数,将 GPIO 寄存器映射到应用程序内存中❺。它的手册页为该函数提供了原型:

void *mmap(void addr[.length], size_t length, int prot,
          int flags int fd, off_t offset);

如果addr0,操作系统将为映射选择应用程序的内存地址。长度是我们需要的字节数,用于设备上的所有寄存器。该映射将使用整页的数量。我选择了 4MB,以确保我们包含所有用于编程 GPIO 的 I/O 寄存器,适用于所有 Raspberry Pi 型号。

mmap的手册页列出了必须传递给函数的 prot 值,用于指定调用函数如何访问它。手册页还列出了 flags 的值,指定操作系统如何处理访问。

每个 prot 和 flag 的数值可以在头文件/usr/include/asm-generic/mman-common.h中找到。头文件也是用 C 语言编写的,因此我使用了.equ指令来定义该函数中所需的 prot 和 flags。

在调用openmmap函数以通过应用程序内存使 GPIO 寄存器可访问之后,我们使用close函数释放文件描述符。GPIO 寄存器仍然对我们的应用程序可访问。

现在我们可以通过应用内存寻址访问 GPIO 上的 I/O 寄存器,我们将 GPIO 引脚配置为输出。如下一节所示,执行此操作的方法对于 Raspberry Pi 5 是不同的 ❻。设置 GPIO 为输出后,我们进入一个循环,其中交替打开和关闭 LED。对于 Raspberry Pi 5,使用的是不同的方法 ❼ ❽。我们使用 unistd 库中的 sleep 函数将 LED 保持在开或关状态几秒钟。

尽管程序在结束时会释放我们用于 GPIO 寄存器的应用内存,但在程序不再需要时调用 munmap 函数释放内存仍然是一个好习惯 ❾。

将 GPIO 引脚指定为输出可以使用 Listing 20-3 中显示的函数。

gpio_line_to_output.s

// Make a GPIO line an output. Assume that GPIO registers
// have been mapped to application memory.
// Calling sequence:
//      x0 <- address of GPIO in mapped memory
//      w1 <- GPIO line number
//      Return address of GPIO.

// Useful constants
        .equ    FIELD_MASK, 0b111  // 3 bits
        .equ    OUTPUT, 1          // Use line for output

// Code
        .text
        .align  2
        .global gpio_line_to_output
        .type   gpio_line_to_output, %function
gpio_line_to_output:
// Determine register and location of line function field.
        mov     w3, 10              // 10 fields per GPFSEL register
     ❶ udiv    w4, w1, w3          // GPFSEL register number
     ❷ msub    w5, w4, w3, w1      // Relative FSEL number in register
// Compute address of GPFSEL register and line field in register.
     ❸ lsl     w4, w4, 2           // Offset to GPFSEL register
        add     x7, x0, x4          // GPFSELn memory address
        ldr     w4, [x7]            // GPFSELn register contents

     ❹ add     w5, w5, w5, lsl 1   // 3 X relative FSEL number
        mov     w6, FIELD_MASK      // FSEL line field
 lsl     w6, w6, w5          // Shift to relative FSEL bit position
     ❺ bic     w4, w4, w6          // Clear current FSEL

        mov     w2, OUTPUT          // Function = output
     ❻ lsl     w2, w2, w5          // Shift function code to FSEL position
        orr     w4, w4, w2          // Insert function code
        str     w4, [x7]            // Update GPFSEL register

        ret

Listing 20-3: 在大多数 Raspberry Pi 型号中将 GPIO 引脚设置为输出

GPIO 有六个 32 位的功能选择寄存器,分别为 GPFSEL0 到 GPFSEL5。每个寄存器被划分为 10 个 3 位字段,命名为 FSELn,其中 n = 0、1、...、57。GPFSEL0 中的第 2 到第 0 位为 FSEL0 字段,第 5 到第 3 位为 FSEL1 字段,以此类推,直到 GPFSEL5 中的第 23 到第 21 位为 FSEL57 字段。GPFSEL0 到 GPFSEL4 中的第 31 和第 30 位,以及 GPFSEL5 中的第 31 到第 24 位未使用。所有引脚都可以配置为输入 000,或输出 001。某些引脚可以配置为其他功能;FSELn 指定了 GPIO 引脚 n 的功能。

将 GPIO 引脚编号除以 10 可以得到 GPFSEL 寄存器的编号 ❶。除法的余数给出了寄存器中 FSEL 字段的编号 ❷。例如,我们的程序使用 GPIO 引脚 17,并且其功能由 GPFSEL1 寄存器中的第七个 FSEL 字段控制。

GPFSEL 寄存器位于 GPIO 内存的开始位置,因此将 GPFSEL 寄存器编号乘以 4 可以得到该寄存器的内存地址偏移量 ❸。每个 FSEL 字段为 3 位,因此我们将 FSEL 编号乘以 3 来得到 GPFSEL 寄存器中的相对位位置 ❹。

我们需要确保不更改 GPFSEL 寄存器中的其他 FSEL 字段。在将 GPFSEL 寄存器的副本加载到 CPU 寄存器中后,我们使用一个 3 位掩码,通过 bic 指令清除我们的 FSEL 字段 ❺:

bic — 位清除

bic wd, ws1, ws2{,shft amnt}ws1ws2 进行按位与运算,操作前可以选用偏移量 amnt 位,结果存储在 wd 中。shft 可以是 lsllsr,偏移量范围是 031。默认情况下没有偏移。

bic xd, xs1, xs2{,shft amnt}xs1xs2 进行按位与运算,操作前可以选用偏移量 amnt 位,结果存储在 xd 中。shft 可以是 lsllsrasrror。amnt 可以是 063。默认情况下没有偏移。

接下来,我们将功能代码移至 GPFSEL 寄存器中的 FSEL 字段位置,使用orr指令插入功能代码,并更新映射的 GPIO 内存❻。

注意

虽然我们已将 GPIO 寄存器映射到应用程序内存地址中,但我们无法直接通过 gdb 检查这些寄存器的内容,因为它使用与mmap函数不同的技术来访问内存地址。在第 460 页的“Your Turn”练习 20.3 中,我会向你展示另一种在gdb中检查寄存器内容的方法。

现在我们已经将 GPIO 线路设置为 1 位输出设备,可以控制它输出的电压。该线路始终准备好接受我们更改电压的操作,因此我们不需要检查其状态。我们将使用gpio_line_set函数在示例 20-4 中设置该线路,将其设为+3.3 V。

gpio_line_set.s

// Set a GPIO line. Assume that GPIO registers
// have been mapped to application memory.
// Calling sequence:
//       x0 <- address of GPIO in mapped memory
//       w1 <- line number

// Constants
     ❶ .equ    GPSET0, 0x1c     // GPSET register offset

// Code
        .text
        .align  2
        .global gpio_line_set
        .type   gpio_line_set, %function
gpio_line_set:
        add     x0, x0, GPSET0  // Address of GPSET0 register
        mov     w2, 1           // Need a 1
     ❷ lsl     w2, w2, w1      // Move to specified bit position
        str     w2, [x0]        // Output

        ret

示例 20-4:在大多数树莓派模型中设置 GPIO 线路

GPIO 有两个 32 位输出设置寄存器,GPSET0 和 GPSET1。GPSET0 寄存器位于 GPIO 内存开始的0x1c字节处❶。紧接着是 GPSET1 寄存器,位于0x20。GPSET0 中的 31 至 0 位控制 31 至 0 号线路,GPSET1 中的 21 至 0 位控制 53 至 32 号线路。GPIO 头针仅连接到 27 至 0 号 GPIO 线路,因此此功能仅适用于 GPSET0。

GPSET 寄存器是只写的。与 GPFSEL 寄存器不同,我们不会加载 GPSET 寄存器的内容;我们只需将1移至与我们用作输出的线路相对应的位位置,并通过str指令设置该线路,将+3.3 V 放置在线路上❷。将0写入位位置没有效果。

当我们想将线路的输出更改为 0.0 V 时,我们会清除该线路,如示例 20-5 所示。

gpio_line_clr.s

// Clear GPIO line. Assume that GPIO registers
// have been mapped to application memory.
// Calling sequence:
//       x0 <- address of GPIO in mapped memory
//       w1 <- line number

// Constants
     ❶ .equ    GPCLR0, 0x28    // GPCLR register offset

// Code
        .text
        .align  2
        .global gpio_line_clr
        .type   gpio_line_clr, %function
gpio_line_clr:
        add     x0, x0, GPCLR0  // Address of GPCLR0 register
        mov     w2, 1           // Need a 1
        lsl     w2, w2, w1      // Move to specified bit position
        str     w2, [x0]        // Output

        ret

示例 20-5:在大多数树莓派模型中清除 GPIO 线路

与设置寄存器类似,GPIO 还有两个 32 位输出清除寄存器,GPCLR0 和 GPCLR1。GPCLR0 位于 GPSET0 之后 12 字节❶。它们的编程方式与 GPSET 寄存器相同,不同之处在于,将1存储到与所选输出线路对应的位位置会清除该线路,将线路置为 0.0 V。

接下来,我们将看看如何在树莓派 5 上闪烁 LED,它使用与 GPIO 的编程接口非常不同的方式。

在汇编语言中闪烁 LED,树莓派 5 模型

示例 20-2 中的main函数设计用于树莓派 5,只需将RPI_MODEL更改为5。条件汇编指令将选择适当的值和函数调用。但控制 GPIO 的方法不同。让我们看看在树莓派 5 上是如何实现的。

Raspberry Pi 5 上的 GPIO 电路位于 RP1 芯片上。截止目前文档还不完整,但可以找到初步草案《RP1 外设》(2023 年 11 月)在 datasheets.raspberrypi.com/rp1/rp1-peripherals.pdf

在 Raspberry Pi 5 上将 GPIO 引脚设置为输出与其他型号不同。 列表 20-6 显示了我们执行此操作的函数。

gpio_5_line_to_output.s

// Make a GPIO line an output. Assume that GPIO registers
// have been mapped to application memory.
// Calling sequence:
//      x0 <- address of GPIO in mapped memory
//      w1 <- GPIO line number
//      Return address of RIOBase.

// Constants
        .equ    RIOBase, 0x10000  // Offset to RIO registers
        .equ    PADBase, 0x20000  // Offset to PAD registers
        .equ    SYS_RIO, 5        // Use RIO to control GPIO
        .equ    PAD_AMPS, 0x10    // 4 mA
        .equ    RIO_SET, 0x2000   // Set reg offset
        .equ    RIO_OE, 0x04      // Output enable

// Code
        .text
        .align  2
        .global gpio_5_line_to_output
        .type   gpio_5_line_to_output, %function
gpio_5_line_to_output:
     ❶ lsl    x2, x1, 3         // 8 x line number
        add    x3, x0, x2        // GPIO_line_number_STATUS
        mov    w2, SYS_RIO       // System registered I/O
     ❷ str    w2, [x3, 4]       // GPIO_line_number_CTRL

        add    x2, x0, PADBase
        add    x2, x2, 4         // Skip over VOLTAGE_SELECT reg
        lsl    x3, x1, 2         // 4 x line number
        add    x3, x3, x2        // Pad reg address of line number
        mov    w4, PAD_AMPS      // 4 mA
     ❸ str    w4, [x3]          // Set pad amps

        add    x0, x0, RIOBase
        mov    w2, 1             // A bit
        lsl    w2, w2, w1        // Shift to line location
        add    x3, x0, RIO_SET   // Use RIO set register
     ❹ str    w2, [x3, RIO_OE]  // Make line an output

        ret

列表 20-6:在 Raspberry Pi 5 上将 GPIO 引脚设置为输出的函数

Raspberry Pi 5 GPIO 设备有 28 对寄存器,分别称为 GPIO0_STATUS、GPIO0_CTRL、GPIO1_STATUS、GPIO1_CTRL 直到 GPIO27_STATUS 和 GPIO27_CTRL。与 GPIO 引脚 n 对应的 GPIOn_CTRL 寄存器的地址偏移量为 8 × n ❶。位 4 到 0 设置功能。

RP1 提供了一组注册 I/O (RIO) 寄存器,用于控制 GPIO 引脚。要使用 RIO 接口,我们将 SYS_RIO 函数存储到 GPIO 控制寄存器中 ❷。

GPIO 引脚的输出电路称为垫片。我们可以通过几种方式设置输出垫片的特性——例如,设置为 2、4、8 或 12 毫安。我们将其设置为 4 毫安,以使 LED 闪烁 ❸。我们通过将其配置为输出设备来完成 GPIO 引脚的设置 ❹。

现在,我们已经将 GPIO 引脚设置为 1 位输出设备,可以控制它输出的电压。GPIO 的 RIO 接口有四个寄存器用于控制引脚。表 20-2 显示了每个寄存器与 RIO 基址的偏移量。

表 20-2: RIO 寄存器偏移量

偏移量 操作
0x0000 正常读/写
0x1000 写入时执行 XOR
0x2000 写入时设置位掩码
0x3000 写入时清除位掩码

每个寄存器中的位 n 对应于 GPIOn 引脚。将一个字存储到这些寄存器中的一个会导致对 GPIO 引脚进行相应操作。位掩码设置或清除意味着只有位为 1 的部分作用于对应的 GPIO 引脚。位位置上的 0 对应的 GPIO 引脚没有影响。分开的设置和清除寄存器意味着我们不需要读取寄存器内容,进行更改后再写回结果。

配置为输出的 GPIO 引脚始终准备好让我们更改电压,因此我们无需检查其状态。我们将使用 列表 20-7 中的 gpio_5_line_set 函数来设置该引脚,从而将其设置为 +3.3 V。

gpio_5_line_set.s

// Set GPIO line. Assume that GPIO registers
// have been mapped to programming memory.
// Calling sequence:
//       x0 <- address of RIOBase in mapped memory
//       w1 <- line number

// Constants
     ❶ .equ    RIO_SET, 0x2000     // Set reg

// Code
        .text
        .align  2
 .global gpio_5_line_set
        .type   gpio_5_line_set, %function
gpio_5_line_set:
        mov     w2, 1               // A bit
     ❷ lsl     w2, w2, w1          // Shift to line location
        add     x0, x0, RIO_SET     // Address of RIO set reg
        str     w2, [x0]            // Line low
        ret

列表 20-7:在 Raspberry Pi 5 上设置 GPIO 引脚的函数

我们将1移到与我们的引脚号对应的位位置 ❷。然后,我们将这个字存储到 RIO 设置寄存器中,从而将 GPIO 引脚设置为高电压 ❶。

清除引脚的算法与设置引脚的算法相同,如 列表 20-8 所示。

gpio_5_line_clr.s

// Clear a GPIO line. Assume that GPIO registers
// have been mapped to programming memory.
// Calling sequence:
//       x0 <- address of RIOBase in mapped memory
//       w1 <- line number

// Constants
     ❶ .equ    RIO_CLR, 0x3000     // Clear reg

// Code
        .text
        .align  2
        .global gpio_5_line_clr
        .type   gpio_5_line_clr, %function
gpio_5_line_clr:
        mov     w2, 1               // A bit
        lsl     w2, w2, w1          // Shift to line location
        add     x0, x0, RIO_CLR     // Address of RIO clear reg
        str     w2, [x0]            // Line low
        ret

列表 20-8:在 Raspberry Pi 5 上清除 GPIO 引脚的函数

gpio_line_clr函数唯一的区别在于我们使用 RIO 清除寄存器将 GPIO 线路设置为低电压❶。

闪烁 LED 是一个非常简单的输出操作示例。我们不需要检查线路是否准备好让我们打开或关闭它;我们只需要发送命令给设备。大多数 I/O 操作并不像这样简单。它需要一些时间来形成要在屏幕上显示的图像,或者将键盘上的按键转换为比特模式。

在下一节中,我将为你提供一个总体概述,说明我们的程序代码如何处理这些定时问题。

该轮到你了

20.1 当 LED 开启时,其典型电压降为 2 到 3 伏,具体取决于 LED 的颜色。计算当 LED 开启时,图 20-2 中的电路中预计流经 LED 的电流。

20.2 编写一个 Python 程序,每三秒钟闪烁一次 LED,闪烁五次。可以参考www.raspberrypi.com/documentation/computers/os.html#use-gpio-from-python获取有关如何实现的提示。

20.3 修改清单 20-2 中的main函数,使其交替闪烁连接到 GPIO 线 16 和 17 的 LED。如果你使用的是除了 5 号之外的树莓派型号,请在gdb中运行程序,并在gpio_line_to_output函数中设置断点,这样你就可以看到适当的函数代码存储在 GPFSEL0 寄存器中。如果你在gpio_line_to_output函数的ret指令之前添加一条ldr w7, [x0]指令,你可以更清楚地看到这一点。程序员有时会添加这样的代码用于调试,通常在最终产品中会删除这些代码。

20.4 修改汇编语言的 LED 闪烁程序,使 LED 每 0.5 秒闪烁一次。你需要使用usleep函数。此函数的整数参数是需要睡眠的微秒数。

20.5 如果你使用的是树莓派 5,编写一个汇编语言程序,使用单个gpio_5_line_toggle函数开关 LED 五次,而不是使用gpio_5_line_setgpio_5_line_clr函数。

轮询输入/输出编程算法

让我们来看一些简单的轮询算法,展示如何为 I/O 编程一个通用异步接收器/发射器(UART)

当 UART 作为输出设备使用时,它执行并行到串行转换,一次传输一个字节的数据位。作为输入设备时,UART 一次接收一个比特,并执行串行到并行转换,以重新组装传输给它的字节。因此,一个 8 位字节可以通过仅使用三条线路(传输、接收和公共线路)进行传输和接收。传输和接收的 UART 必须设置为相同的比特率。

在空闲状态下,发送 UART 将高电压置于传输线上。当程序输出一个字节到 UART 时,发送 UART 首先发送一个起始位;它通过将传输线置于低电压,持续传输一个比特的时间,这个时间对应于约定的比特率。

在起始位之后,发送器按约定的比特率发送数据位。UART 使用移位寄存器一次移位一个比特,并相应地设置输出线路上的电压。大多数 UART 从低位比特开始。当整个字节发送完成后,UART 将输出线路恢复为空闲状态,至少持续 1 个比特时间,从而发送至少一个停止位

图 20-3 展示了一个典型设置的 UART 如何发送 ASCII 编码的两个字符mn

Image

图 20-3:UART 输出以发送字符 m n

接收 UART 监视传输线,寻找起始位。当它检测到起始位时,它使用移位寄存器将单个比特重新组装成一个字节,并将其作为输入提供给接收程序。

我们将使用常见类型的 16550 UART 作为我们的编程示例。16550 UART 有 13 个 8 位寄存器,如表 20-3 所示。

表 20-3: 16550 UART 的寄存器

名称 地址 DLAB 目的
RHR 000 0 接收保持寄存器(输入字节)
THR 000 0 发送保持寄存器(输出字节)
IER 001 0 中断使能(设置中断类型)
ISR 010 x 中断状态(显示中断类型)
FCR 010 x FIFO 控制(设置 FIFO 参数)
LCR 011 x 行控制(设置通信格式)
MCR 100 x 调制解调器控制(设置与调制解调器的接口)
LSR 101 x 行状态(显示数据传输状态)
MSR 110 0 调制解调器状态(显示调制解调器状态)
SCR 111 x 临时寄存器
DLL 000 1 除数锁存器(低位字节)
DLM 001 1 除数锁存器(高位字节)
PSD 101 1 预分频器分频

表 20-3 中的地址是 UART 基地址的偏移量。你可能已经注意到,一些寄存器具有相同的偏移量。该偏移量处寄存器的功能取决于我们的程序如何处理它。例如,如果程序从偏移量000加载数据,它是从接收保持寄存器(RHR)加载。但如果程序将数据存储到偏移量000,则它是存储到发送保持寄存器(THR)。

除数锁存器访问位(DLAB)是行控制寄存器(LCR)中的第 7 位。当它被设置为1时,偏移量000连接到 16 位除数锁存器值的低位字节,而偏移量001连接到除数锁存器值的高位字节。

16550 UART 可以被编程为中断驱动的 I/O 和直接内存访问。它在发送器和接收器寄存器上都包含了 16 字节的先进先出(FIFO)缓冲区。它还可以被编程控制串行调制解调器。

较旧的个人计算机通常将 UART 连接到 COM 端口。在过去,COM 端口通常用于将打印机和调制解调器等设备连接到计算机,但如今大多数 PC 都使用 USB 端口进行串行 I/O。

Raspberry Pi 有一个类似于 16550 的 UART,接收器和发送器寄存器连接到 GPIO 引脚。编程 UART 超出了本书的范围,但我将使用 C 语言大致展示它是如何实现的。

请注意,我们在这一节中编写的 C 函数仅用于展示概念,并不做任何实际有用的事情。事实上,运行它们将会引发操作系统的错误信息。

我假设 UART 已安装在使用内存映射 I/O 的计算机中,这样我就可以用 C 语言展示算法。为了简化起见,我这里只做轮询 I/O,这需要以下三个函数:

UART_init 初始化 UART。这包括设置硬件中的参数,如速度和通信协议。

UART_in 读取一个已经被 UART 接收的字符。

UART_out 写入一个字符,通过 UART 进行传输。

这三个函数可以让我们使用 UART 接收一个字符,然后将该字符发送出去,如 Listing 20-9 所示。

UART_echo.c

// Use a UART to echo a single character.
// WARNING: This code does not run on any known device. It is
// meant to sketch some general I/O concepts only.

#include "UART_functions.h"
#define UART0 (unsigned char *)0xfe200040   // Address of UART

int UART_echo(void)
{
    unsigned char character;

    UART_init(UART0);
    character = UART_in(UART0);
    UART_out(UART0, character);

    return 0;
}

Listing 20-9:一个使用 UART 读写单个字符的函数

我们将只探索 UART 的一些特性。让我们从一个提供寄存器符号名称和我们将在示例程序中使用的一些数字的文件开始,如 Listing 20-10 所示。

UART_def.h

   // Definitions for a 16550 UART.
   // WARNING: This code does not run on any known device. It is
   // meant to sketch some general I/O concepts only.
 #ifndef UART_DEFS_H
   #define UART_DEFS_H

   // Register offsets
   #define RHR 0x00    // Receive holding register
   #define THR 0x00    // Transmit holding register
   #define IER 0x01    // Interrupt enable register
   #define FCR 0x02    // FIFO control register
   #define LCR 0x03    // Line control register
   #define LSR 0x05    // Line status register
   #define DLL 0x00    // Divisor latch LSB
   #define DLM 0x01    // Divisor latch MSB

   // Status bits
   #define RxRDY 0x01  // Receiver ready
   #define TxRDY 0x20  // Transmitter ready

   // Commands
   #define NO_FIFO       0x00  // Don't use FIFO
   #define NO_INTERRUPT  0x00  // Polling mode
   #define MSB_38400     0x00  // 2 bytes used to
   #define LSB_38400     0x03  //   set baud 38400
   #define N_BITS        0x03  // 8 bits
   #define STOP_BIT      0x00  // 1 stop bit
   #define NO_PARITY     0x00
❶ #define SET_COM       N_BITS | STOP_BIT | NO_PARITY
❷ #define SET_BAUD      0x80 | SET_COM
   #endif

Listing 20-10:16550 UART 的定义

寄存器的偏移量相对于 UART 映射内存地址的起始位置是固定的。这些偏移量以及状态和控制位的设置取自 16550 数据手册,您可以在 www.ti.com/product/TL16C550D 下载。

让我们来看一下我是如何得出 SETCOM 控制值 ❶ 的。通过向线路状态寄存器写入 1 字节来设置通信参数。每个数据帧可以包含 5 到 8 位。数据手册告诉我们,将位 1 和 0 设置为 11 将指定 8 位。因此,我将 NBITS 设置为 0x03。将位 2 设置为 0 表示一个停止位,所以 STOPBIT = 0x00。我不使用校验位(位 3),所以 NOPARITY = 0x00。我将这些常量与 OR 运算符结合起来,生成设置通信参数的字节。当然,我们其实不需要这两个零值,但指定它们使我们的意图更加明确。

单位波特率是通信速度的度量,定义为每秒钟符号的数量。UART 只使用两种电压电平进行通信,即符号01,即 1 个比特。对于 UART,波特率等同于每秒传输或接收的比特数。我们需要将 DLAB 位设置为1,以将我们的 UART 设置为允许我们设置波特率的模式❷。

接下来,我们需要一个头文件来声明这些函数,如列表 20-11 所示。

UART_functions.h

// Initialize, read, and write functions for an abstract UART.
// WARNING: This code does not run on any known device. It is
// meant to sketch some general I/O concepts only.

#ifndef UART_FUNCTIONS_H
#define UART_FUNCTIONS_H
void UART_init(unsigned char* UART);                  // Initialize UART
unsigned char UART_in(unsigned char* UART);           // Input
void UART_out(unsigned char* UART, unsigned char c);  // Output
#endif

列表 20-11:UART 函数声明

头文件声明了使用我们 UART 的三个基本函数。本书不会涉及 UART 的更高级特性。

我们将这三个函数的定义放在一个文件中,如列表 20-12 所示,因为它们通常是一起使用的。

UART_functions.c

// Initialize, read, and write functions for a 16550 UART.
// WARNING: This code does not run on any known device. It is
// meant to sketch some general I/O concepts only.

#include "UART_defs.h"
#include "UART_functions.h"

// UART_init initializes the UART and enables it.
void UART_init(unsigned char* UART)
{
    unsigned char* port = UART;

    *(port+IER) = NO_INTERRUPT;   // No interrupts
    *(port+FCR) = NO_FIFO;        // No FIFO
    *(port+LCR) = SET_BAUD;       // Set frequency mode
    *(port+DLM) = MSB_38400;      // Set to 38400 baud
    *(port+DLL) = LSB_38400;      // 2 regs to set
    *(port+LCR) = SET_COM;        // Communications mode
}

// UART_in waits until the UART has a character and then reads it.
unsigned char UART_in(unsigned char* UART)
{
    unsigned char* port = UART;
    unsigned char character;
 ❶ while ((*(port+LSR) & RxRDY) != 0) {
    }
    character = *(port+RHR);
    return character;
}

// UART_out waits until the UART is ready and then writes a character.
void UART_out(unsigned char* UART, unsigned char character)
{
    unsigned char* port = UART;

 ❷ while ((*(port+LSR) & TxRDY) != 0) {
    }
    *(port+THR) = character;
}

列表 20-12:C 语言中 UART 内存映射 I/O 函数的定义

UART_init函数设置 UART 的各种通信参数。我在本示例中使用的值的目的将在列表 20-10 之后进行解释。

UART_in函数在while循环中等待,直到RxRDY位变为1,即当 UART 有一个字符准备从其接收保持寄存器中读取时❶。该字符通过赋值给一个局部变量从接收保持寄存器中读取。

UART_out函数在while循环中等待,直到TxRDY位变为1,即当 UART 准备好接收我们发送到传输保持寄存器的字符时❷。该字符通过赋值给传输保持寄存器发送出去。

这些函数提供了一个如何在轮询模式下使用 UART 的总体视图。这里我省略了许多确保稳健操作所需的细节。如果你想使用树莓派上的某个 UART,可以从www.raspberrypi.com/documentation/computers/raspberry-pi.html#gpio-and-the-40-pin-header的文档开始。我建议通过操作系统来访问 UART,而不是像我们之前在本章中点亮 LED 时那样直接访问。我没有在树莓派上使用过 UART,因此不能为它们做担保,但网上有教程可以参考。

你所学到的内容

虚拟内存 程序使用的内存地址空间。

内存映射单元 (MMU) 是一种硬件设备,使用映射表将虚拟内存地址转换为物理内存地址。

转换后备缓冲区 (TLB) 是 MMU 中的内存,缓存最近使用的映射表条目。

内存时序 内存访问与 CPU 的时序同步。

I/O 时序 I/O 设备比 CPU 要慢得多,并且具有广泛的特性,因此我们需要编程来访问它们。

总线时序 总线通常以分层的方式安排,以更好地匹配不同 I/O 设备之间的时序差异。

端口映射 I/O 通过这种技术,I/O 设备寄存器拥有自己的地址空间。

内存映射 I/O 通过这种技术,I/O 寄存器被分配到主内存地址空间的一部分。

程序化 I/O 程序在需要时直接将数据从输入设备传输到输出设备或反向操作。

轮询 I/O 程序在一个循环中等待,直到 I/O 设备准备好传输数据。

中断驱动 I/O 当 I/O 设备准备好传输数据时,它会中断 CPU。

直接内存访问 I/O 设备可以在不使用 CPU 的情况下,直接将数据传输到主内存或从主内存传输数据。

通用 I/O (GPIO) 通用 I/O 设备可以被编程为输入或输出一个单一的位信息。

在下一章中,你将学习关于 CPU 的功能,这些功能使得它能够控制 I/O 硬件,并防止应用程序在不通过操作系统的情况下访问硬件。

第二十一章:异常与中断**

图片

到目前为止,我们一直将每个应用程序视为独占使用计算机。但与大多数操作系统一样,Raspberry Pi OS 允许多个应用程序并行运行。它以交替的方式管理硬件,按照需求为每个应用程序及操作系统本身提供所需的硬件资源。

这里有两个问题。首先,操作系统为了执行管理任务,必须保持对应用程序与硬件交互的控制。它通过使用 CPU 中的特权级别系统来实现这一点,从而让操作系统控制其与应用程序之间的网关。

第二,在第二十章中我们看到,大多数 I/O 设备在准备好提供输入或接受输出时,可以中断 CPU 正在进行的活动。CPU 有一个机制来通过这个网关引导 I/O 中断,并调用由操作系统控制的功能,从而允许操作系统保持对 I/O 设备的控制。

在这一章中,我将首先讨论 CPU 如何利用特权级别来强化对硬件的控制。然后,我将讲解什么样的事件会导致特权级别的变化,以及 CPU 如何响应这些事件。最后,我将以一条指令为结束,讨论这条指令如何让应用程序穿越应用软件与系统软件之间的网关,直接调用操作系统中的实用功能。

对这一材料的全面讨论需要详细了解操作系统的内部结构以及如何编程使用特定的硬件,这超出了本书的范围。这里的目标是为你提供一个非常概括的概览。

应用软件与系统软件

软件通常可以分为应用软件和系统软件。我们使用应用软件来处理计算机上的大部分任务,而系统软件则管理硬件资源的使用,为应用软件提供对硬件的受控访问。

系统软件与应用软件之间的划分是通过特权级别系统来维持的。操作系统在特权级别上执行,使其能够管理大多数硬件资源。应用程序则在非特权级别上执行,以防止它们直接访问大部分硬件。操作系统作为特权程序,充当应用程序对计算机资源使用的监督者。

这种特权分离使操作系统能够管理执行多个应用程序所需的资源。例如,我们可以运行一个媒体应用程序播放音乐,同时使用编辑器应用程序编辑源文件。在等待我们按下另一个键时,操作系统允许媒体应用程序使用 CPU。当我们按下键时,操作系统暂停媒体应用程序足够长的时间来读取按键,然后将控制权交回给媒体应用程序,同时等待非常缓慢(在 CPU 时间内)的我们按下下一个键。

异常是指导致当前执行的代码流被暂停,并将 CPU 控制权交给运行在特权级别软件的事件。中断是来自连接到 CPU 的设备引发的一种异常事件。在讨论可能导致异常的原因以及异常发生时的处理过程之前,让我们先看看特权级别。

注意

虽然一般概念相同,但术语有所不同,因此在阅读相关手册时需要注意。例如,ARM 使用异常作为更通用的术语,其中中断是异常的一种类型。另一方面,Intel 使用中断作为更通用的术语,而异常是中断的一种类型。

特权和异常级别

操作系统使用异常级别来执行当前正在运行的软件的特权级别。任何时候,CPU 都在四个可能的异常级别中的一个级别运行。表 21-1 显示了从最低特权到最高特权的各个级别。

表 21-1: AArch64 异常级别

级别 用途
EL0 应用程序
EL1 操作系统
EL2 虚拟机监控程序
EL3 固件/安全监视器

应用程序在最低的异常级别 EL0 下执行。操作系统在异常级别 EL1 下执行。

虚拟机监控程序允许我们在同一计算机上同时运行多个操作系统,通过协调它们与硬件资源的交互。虚拟机监控程序在异常级别 EL2 下执行,赋予它对操作系统的监督控制。

固件提供对设备硬件的低级控制。它存储在只读存储器中,并在最高的异常级别 EL3 下执行。当 Raspberry Pi 首次启动时,CPU 从 EL3 开始,以便可以访问所有硬件。

AArch64 架构定义了安全状态非安全状态。在安全状态下,可以访问所有硬件,而在非安全状态下,访问受到限制。两个状态之间的切换由安全监视器控制,安全监视器是一种只能在异常级别为 EL3 时执行的软件。

在第二十章中,你学习了内存管理单元(MMU)如何使用页表将虚拟内存地址映射到物理内存地址。页表中的每个虚拟内存范围条目包括一个 2 位的访问权限(AP)字段,用于该内存范围。表 21-2 展示了每个 CPU 异常级别下的这些权限。

表 21-2: AArch64 内存访问权限级别

AP EL0(非特权) EL1/2/3(特权)
00 无访问 读/写
01 读/写 读/写
10 无访问 只读
11 只读 只读

应用程序在其指令和只读数据加载到访问权限设置为11的虚拟内存中执行。如果它们有任何全局变量,这些变量将被加载到访问权限设置为01的虚拟内存中。

操作系统的指令和只读数据被加载到访问权限设置为10的虚拟内存中,其全局变量则加载到访问权限设置为00的虚拟内存中。

在第二十章的清单 20-2 中,你看到过如何处理这些内存访问权限的例子,当时是在编程 GPIO 设备时。操作系统将一段特权虚拟内存地址空间映射到 GPIO 设备寄存器的硬件地址。我们使用mmap系统调用函数,将应用程序中的非特权内存映射到操作系统的 GPIO 地址空间,以便我们的应用程序能够访问它。

除了异常级别外,Armv8-A 处理器还有两种执行状态,AArch32 和 AArch64,如第九章介绍中所述。操作系统在启动时设置执行状态。

异常提供了一种改变执行状态的方法。当一个异常将 CPU 带到更高的异常级别时,我们可以告诉处理器保持当前执行状态或转换到 AArch64。在从异常返回到较低的异常级别时,我们可以告诉处理器保持当前执行状态或转换到 AArch32。这使得我们可以在 64 位的树莓派操作系统下运行 32 位应用程序,但在 32 位版本的树莓派操作系统下无法运行 64 位应用程序。

在接下来的章节中,你将看到异常如何允许使用特权软件。

异常事件

有几种事件可能会导致异常。最常见的原因之一是应用程序(非特权)需要操作系统(特权)提供的服务。

一个例子是当我们调用write系统调用函数来在屏幕上显示文本时。如第二章中的图 2-1 所示,write函数直接与操作系统通信,操作系统再将字符发送到屏幕。这是通过svc指令完成的,它会引发异常。你将在本章后面看到如何使用svc

svc指令引发的异常是同步异常,即其时序与 CPU 的时序同步。其他同步异常的原因包括尝试执行在当前异常级别无效的指令,尝试访问超出当前异常级别范围的内存地址,以及调试器在程序中插入断点。

异步异常与 CPU 的时序无关。异步异常,也称为中断,通常来自 I/O 设备。

异步异常的一个例子是当我们使用read系统调用函数从键盘获取字符时。如图 2-1 所示,read函数也直接与操作系统通信。然而,当read函数请求从键盘获取字符时,操作系统无法预知下一个按键何时会被按下。

操作系统通知键盘设备控制器它正在等待一个字符,并将当前运行的程序置于等待状态。如果有另一个程序准备运行,操作系统将把 CPU 控制权交给该程序。当用户按下键盘上的某个键时,键盘设备控制器向 CPU 发送中断信号,从而引发异常。当 CPU 完成当前指令的执行后,它会处理该中断异常,通常会将等待的程序置于就绪状态。

异常处理是由一段称为异常处理程序的代码完成的。CPU 通过执行处理程序代码来响应异常。我们来看一下 CPU 是如何做到这一点的。

CPU 对异常的响应

处理器包含一组系统寄存器,用于保存处理器的配置设置。这些寄存器包括保存响应和处理异常所需数据的寄存器。部分用于异常的系统寄存器如表 21-3 所示。

表 21-3: 处理异常的部分系统寄存器

名称 寄存器 用途
currentel 当前异常级别 位 3 和位 2 保存异常级别(00表示 EL0,01表示 EL1,10表示 EL2,11表示 EL3)
elr 异常链接 引发异常的指令地址
esr 异常综合 关于异常原因的信息
far 错误地址 引发故障的访问地址
hcr 虚拟化配置 与 EL2 相关的虚拟化设置
scr 安全配置 与 EL3 相关的安全状态设置
sctlr 系统控制 关于系统的信息
spsr 保存的程序状态 异常发生时该异常级别的 PSTATE
vbar 向量基地址 异常发生时跳转到该异常级别的基地址

这些寄存器只能通过在特权级别运行的软件访问。只有一个 currentel 寄存器。hcr 寄存器位于 EL2,scr 寄存器位于 EL3。此表中的其他系统寄存器在 EL1、EL2 和 EL3 上都有实例。

当前 CPU 的异常级别由 currentel 中的第 3 位和第 2 位确定。其他 62 位保留供未来使用。该内容可以通过在特权级别执行 mrs xd, currentel 指令加载到通用寄存器中:

mrs—移动系统寄存器

mrs xd, systemreg 将系统寄存器 systemreg 的内容复制到 xd。仅在异常级别 EL1 及以上有效。

还有一个 msr 指令,用于将内容存储到某些系统寄存器中,但不能存储到 currentel 寄存器中。currentel 寄存器中的 2 位异常级别字段的内容只能通过异常来更改。

异常级别只能通过异常事件增加,异常事件可以增加级别或保持不变,但 EL0 的异常事件只能增加级别。减少异常级别的唯一方法是使用 eret 指令,它将降低级别或保持不变:

eret—异常返回

eretspsr 寄存器恢复当前异常级别的 PSTATE,并将当前异常级别的 elr 寄存器中的地址加载到 pc

PSTATE 是系统寄存器位设置的抽象,定义了当前的处理器状态。例如,currentel 寄存器中的第 3 位和第 2 位包含在 PSTATE 中。第九章中 表 9-2 中列出的 nzcv 寄存器中的条件标志也包含在 PSTATE 中。

响应异常时,CPU 执行的操作类似于 bl 指令,但有一些显著的不同之处。最明显的区别是,我们在程序代码中指定 bl 指令跳转的地址,而异常会导致跳转到 异常向量表 中的一段代码。

异常向量表有 16 个条目,按 4 个一组排列,如 表 21-4 所示。

表 21-4: 异常向量表中的条目

偏移 类型 条件
0x000 同步 从当前 EL 使用 EL0 的 SP
0x080 IRQ
0x100 FIQ
0x180 SError
0x200 同步 从当前 EL 使用当前级别的 SP
0x280 IRQ
0x300 FIQ
0x380 SError
0x400 同步 从较低的 EL,使用 AArch64 跳转到下一个较低的 EL
0x480 IRQ
0x500 FIQ
0x580 SError
0x600 同步 从较低的 EL,使用 AArch32 跳转到下一个较低的 EL
0x680 IRQ
0x700 FIQ
0x780 SError

异常级别 EL1、EL2 和 EL3 各自拥有自己的 2KiB 异常向量表。每个 16 个条目包含处理与该条目对应的异常类型和条件的代码。每个条目的长度为 128 字节,可以容纳最多 32 条异常处理指令。如果需要超过 32 条指令来处理异常,则可以从该条目程序代码中调用表外的函数。

每个异常向量表在操作系统首次启动时创建。每个表的地址存储在相应异常级别的向量基地址寄存器vbar中。异常会根据异常类型和异常发生时的条件,跳转到这些条目中某一条目的第一条指令。

同步异常来自 CPU,如前一节所述。在三种异步异常中,IRQ 是通常来自 I/O 设备控制器的中断请求。在早期版本的 ARM 架构中,FIQ 是来自 I/O 设备控制器的快速中断请求,其优先级高于 IRQ。从 Armv8-A 架构开始,FIQ 与 IRQ 具有相同的优先级;它只是提供了另一条中断路径。这两条路径的使用取决于架构的实现。

SError 是一种系统错误,用于指示内存系统中发生了意外事件。导致 SError 异常的事件也会根据实现方式有所不同。

每种类型的异常选择的入口取决于异常是否来自相同的异常级别,或者来自较低的级别。如果异常来自当前异常级别,选择将依赖于是否使用 EL0 或当前异常级别的堆栈指针。

如果异常来自较低的异常级别,则选择的入口取决于紧接着的较低级别是使用 AArch32 还是 AArch64 执行。例如,可能有一个 32 位虚拟机与 64 位操作系统并行运行。32 位虚拟机中的应用程序运行在 EL0,而其 32 位操作系统运行在 EL1。当处于 AArch32 模式时发生的异常,若跳转到 EL2 的虚拟机监控器(hypervisor),将进入表 21-4 的第四组中的一个条目。该组中的所有处理程序都来自 32 位虚拟机的操作系统。适当条目的异常处理程序将在 32 位操作系统内执行。

CPU 对异常的响应假设我们希望返回到被异常中断的执行流位置,并以异常发生前的状态继续执行 CPU。

在响应异常时,CPU 执行以下操作:

  1. 将 PSTATE 的内容保存在它即将进入的异常级别的spsr寄存器中

  2. 将最后执行完的指令地址存储到elr寄存器中

  3. 对于同步和系统错误异常,将原因写入它即将进入的异常级别的esr寄存器

  4. 对于地址相关的同步异常,将原因写入它即将进入的异常级别的far寄存器

  5. 更新 PSTATE 为新的异常级别

  6. 将适当条目的地址加载到pc寄存器中,指向异常向量表

异常处理程序负责保存和恢复它使用的任何通用寄存器。每个异常级别都有一个单独的堆栈指针寄存器,因此可以在每个级别设置单独的堆栈。当在 EL1、EL2 或 EL3 执行时,可以使用当前级别的堆栈指针或 EL0 的堆栈指针。

在异常处理程序恢复所有保存的通用寄存器后,它使用eret指令将 PSTATE 恢复到异常发生前的状态,并返回到异常发生的地方。PSTATE 包括在currentel寄存器中的 2 位异常级别,因此此操作还会将 CPU 返回到它原先所在的异常级别。

编写异常处理程序是一本书超出范围的高级话题,但在下一节中你将看到如何通过异常调用操作系统的服务。

监控调用

操作系统作为特权实体,负责管理计算机资源。当一个没有特权的应用程序需要使用特权资源时,它通过监控调用向操作系统请求服务。

在清单 13-3 中,第十三章里我们使用了write系统调用函数,将Hello, World!一个字符一个字符地写到屏幕上。write函数是对监控调用的 C 语言封装。在清单 21-1 中的程序被称为独立的,因为它没有使用任何 C 语言库函数。相反,当我们需要操作系统的服务时,我们直接使用监控调用。

hello_world.s

   // Write Hello, World! using a system call.
           .arch armv8-a
   // Useful names
           .equ    NUL, 0
           .equ    STDOUT, 1
        ➊ .equ    WRITE, 0x40
           .equ    EXIT, 0x5d
   // Stack frame
           .equ    save19, 16
           .equ    FRAME, 32
   // Constant data
           .section  .rodata message:
           .string "Hello, World!\n"
   // Code
           .text
           .align  2
           .global my_hello
           .type   my_hello, %function
➋ my_hello:
           adr     x1, message       // Address of message
   loop:
           ldrb    w3, [x1]          // Load character
           cmp     w3, NUL           // End of string?
           b.eq    done              // Yes
           mov     x2, 1             // No, one char
           mov     x0, STDOUT        // Write on screen
           mov     x8, WRITE
        ➌ svc     0                 // Tell OS to do it
           add     x1, x1, 1         // Increment pointer
           b       loop              //   and continue
   done:
           mov     w0, wzr           // Return value
        ➍ mov     x8, EXIT          // Terminate this process
           svc     0

清单 21-1: Hello, World! 独立程序

gcc编译器假设我们使用 C 语言托管环境,并要求第一个函数必须命名为main。由于我们没有使用 C 语言库,我们不需要使用 C 语言托管环境,可以随意命名我们的函数 ❷。组装完这个函数后,我们直接进入加载程序,并用-e选项告诉它从哪个位置开始执行该函数:

$ ld -e my_hello -o hello_world hello_world.o

当我们需要操作系统输出一个字符时,我们使用svc指令 ❸:

svc—超级用户调用

svc imm 会导致从异常级别 EL0 跳转到 EL1 的异常。它将 0x15 存储在 esr 寄存器的第 31 到 26 位,并将 imm(一个 16 位无符号整数)存储在 esr 寄存器的第 15 到 0 位。

异常级别 EL1 由操作系统处理。svc 处理程序使用 x8 寄存器中的整数来确定应采取的操作。写操作的编号是 0x40 ❶。操作系统中写操作的其他参数与 C 库中的 write 函数参数相同。svc 指令的参数 imm 在 Linux 中未使用。

我们的 my_hello 函数是由操作系统直接启动的一个新进程,而不是从 C 托管环境中调用的。我们通过另一个 supervisor 调用 ❹ 来终止此进程。

请注意,在这个独立的程序中,我们不需要创建栈帧或保存任何寄存器。svc 的异常处理程序将在返回时恢复通用寄存器的状态。由于这个程序不会返回到 C 托管环境,我们不需要保存 spfp 寄存器。

操作系统操作的编号、操作的参数以及传递这些参数的寄存器可以在 www.chromium.org/chromium-os/developer-library/reference/linux-constants/syscalls/ 上找到。该网站上有四个表格:x86_64(64 位)、arm(32 位)、arm64(64 位)和 x86(32 位)。确保使用与本书配套的 arm64 表格。

表 21-5 列出了常见的 svc 代码。

表 21-5: svc 调用的一些寄存器内容

操作 x8 x0 x1 x2
read 0x3f 文件描述符 字符地址 字符数量
write 0x40 文件描述符 字符地址 字符数量
exit 0x5d 错误代码

为了完整性,以下是导致异常跳转到虚拟机监控器的指令 hvc 和跳转到安全监控器的指令 smc

hvc—虚拟机监控器调用

hvc imm 会导致从异常级别 EL1 跳转到 EL2 的异常。它将 0x16 存储在 esr 寄存器的第 31 到 26 位,并将 imm(一个 16 位无符号整数)存储在 esr 寄存器的第 15 到 0 位。

smc—安全监控器调用

smc imm 会导致从异常级别 EL1 或 EL2 跳转到 EL3 的异常。它将 0x17 存储在 esr 寄存器的第 31 到 26 位,并将 imm(一个 16 位无符号整数)存储在 esr 寄存器的第 15 到 0 位。

AArch64 架构中的异常处理机制非常复杂,超出了本书的范围。一个好的下一步是阅读 Learn the Architecture—AArch64 Exception Model,该文档可以在 developer.arm.com/documentation/102412/0103 上找到。

轮到你了

21.1 选择一个你用汇编语言编写的程序。在你知道它将被执行的地方添加指令mrs x0, currentel。你的程序仍然可以成功汇编和链接,但当你运行该程序时会发生什么?

21.2 修改“你的练习”中的三个函数write_charwrite_strread_str,练习 14.4 位于第 293 页,使它们使用svc指令,而不是调用 C 语言的writeread函数。

21.3 我们在第二十章中的代码清单 20-2 中使用的mmap操作的监控调用号是多少?

你所学到的

特权级别 操作系统通过标记内存地址和在较低异常级别的 CPU 上运行应用程序,保持对硬件资源的特权。

异常级别 CPU 以四个异常级别之一运行软件:从最不特权到最特权,依次为 EL0、EL1、EL2 或 EL3。

异常 当前执行的代码流中发生中断,导致 CPU 控制权转交给在特权级别(EL1–EL3)运行的软件。

中断 其他硬件设备可以中断 CPU 的常规执行周期,并触发异常。

svc 在 AArch64 中触发异常的指令。

异常处理程序 操作系统中的一个函数,当发生异常或中断时,CPU 会调用它。

异常向量表 一个异常处理程序的数组。

独立程序 一个不使用 C 库函数的程序。

这只是对异常和中断的简要概述。其细节较为复杂,需要对你所使用的 CPU 的具体型号有深入了解。

这就是我对计算机组织的介绍。我希望它为你提供了继续深入探索你感兴趣的任何主题所需的工具。

posted @ 2025-11-30 19:34  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报